| author | |
| committer | |
| log | 505b4d0bc3581b2bf1eb82db1d3768e15c0fe526 |
| tree | 681cb938801cdb955f3aa4585aabba8ff855886a |
| parent | 343c0a94729b7d8040dd4a0fe54a8c6fdee6eeef |
- add shared attachment path normalization and resolution helpers
- reuse persisted image files for turn start and branch name generation
- cover persisted attachment flow in text generation and reactor tests7 files changed, 354 insertions(+), 44 deletions(-)
apps/server/src/attachmentPaths.ts created+54| ... | @@ -0,0 +1,54 @@ | ||
| 1 | import path from "node:path"; | ||
| 2 | |||
| 3 | import { ATTACHMENTS_ROUTE_PREFIX } from "./projectFaviconRoute.ts"; | ||
| 4 | |||
| 5 | export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null { | ||
| 6 | const normalized = path.normalize(rawRelativePath).replace(/^[/\\]+/, ""); | ||
| 7 | if ( | ||
| 8 | normalized.length === 0 || | ||
| 9 | normalized.startsWith("..") || | ||
| 10 | normalized.includes("\0") | ||
| 11 | ) { | ||
| 12 | return null; | ||
| 13 | } | ||
| 14 | return normalized.replace(/\\/g, "/"); | ||
| 15 | } | ||
| 16 | |||
| 17 | export function attachmentRouteToRelativePath(dataUrl: string): string | null { | ||
| 18 | const prefix = `${ATTACHMENTS_ROUTE_PREFIX}/`; | ||
| 19 | if (!dataUrl.startsWith(prefix)) { | ||
| 20 | return null; | ||
| 21 | } | ||
| 22 | return normalizeAttachmentRelativePath(dataUrl.slice(prefix.length)); | ||
| 23 | } | ||
| 24 | |||
| 25 | export function resolveAttachmentRelativePath(input: { | ||
| 26 | readonly stateDir: string; | ||
| 27 | readonly relativePath: string; | ||
| 28 | }): string | null { | ||
| 29 | const normalizedRelativePath = normalizeAttachmentRelativePath(input.relativePath); | ||
| 30 | if (!normalizedRelativePath) { | ||
| 31 | return null; | ||
| 32 | } | ||
| 33 | |||
| 34 | const attachmentsRoot = path.resolve(path.join(input.stateDir, "attachments")); | ||
| 35 | const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath)); | ||
| 36 | if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) { | ||
| 37 | return null; | ||
| 38 | } | ||
| 39 | return filePath; | ||
| 40 | } | ||
| 41 | |||
| 42 | export function resolveAttachmentRoutePath(input: { | ||
| 43 | readonly stateDir: string; | ||
| 44 | readonly dataUrl: string; | ||
| 45 | }): string | null { | ||
| 46 | const relativePath = attachmentRouteToRelativePath(input.dataUrl); | ||
| 47 | if (!relativePath) { | ||
| 48 | return null; | ||
| 49 | } | ||
| 50 | return resolveAttachmentRelativePath({ | ||
| 51 | stateDir: input.stateDir, | ||
| 52 | relativePath, | ||
| 53 | }); | ||
| 54 | } | ||
apps/server/src/git/Layers/CodexTextGeneration.test.ts+63| ... | @@ -3,10 +3,28 @@ import { it } from "@effect/vitest"; | ... | @@ -3,10 +3,28 @@ import { it } from "@effect/vitest"; |
| 3 | import { Effect, FileSystem, Layer, Path } from "effect"; | 3 | import { Effect, FileSystem, Layer, Path } from "effect"; |
| 4 | import { expect } from "vitest"; | 4 | import { expect } from "vitest"; |
| 5 | 5 | ||
| 6 | import { type ServerConfigShape, ServerConfig } from "../../config.ts"; | ||
| 6 | import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; | 7 | import { CodexTextGenerationLive } from "./CodexTextGeneration.ts"; |
| 7 | import { TextGenerationError } from "../Errors.ts"; | 8 | import { TextGenerationError } from "../Errors.ts"; |
| 8 | import { TextGeneration } from "../Services/TextGeneration.ts"; | 9 | import { TextGeneration } from "../Services/TextGeneration.ts"; |
| 9 | 10 | ||
| 11 | function makeTestServerConfig(stateDir: string): ServerConfigShape { | ||
| 12 | return { | ||
| 13 | mode: "web", | ||
| 14 | port: 0, | ||
| 15 | host: undefined, | ||
| 16 | cwd: process.cwd(), | ||
| 17 | keybindingsConfigPath: "", | ||
| 18 | stateDir, | ||
| 19 | staticDir: undefined, | ||
| 20 | devUrl: undefined, | ||
| 21 | noBrowser: true, | ||
| 22 | authToken: undefined, | ||
| 23 | autoBootstrapProjectFromCwd: false, | ||
| 24 | logWebSocketEvents: false, | ||
| 25 | }; | ||
| 26 | } | ||
| 27 | |||
| 10 | function makeFakeCodexBinary(dir: string) { | 28 | function makeFakeCodexBinary(dir: string) { |
| 11 | return Effect.gen(function* () { | 29 | return Effect.gen(function* () { |
| 12 | const fs = yield* FileSystem.FileSystem; | 30 | const fs = yield* FileSystem.FileSystem; |
| ... | @@ -311,6 +329,51 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { | ... | @@ -311,6 +329,51 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 311 | ), | 329 | ), |
| 312 | ); | 330 | ); |
| 313 | 331 | ||
| 332 | it.effect("resolves persisted attachment URLs to files for codex image inputs", () => | ||
| 333 | withFakeCodexEnv( | ||
| 334 | { | ||
| 335 | output: JSON.stringify({ | ||
| 336 | branch: "fix/ui-regression", | ||
| 337 | }), | ||
| 338 | requireImage: true, | ||
| 339 | }, | ||
| 340 | Effect.gen(function* () { | ||
| 341 | const fs = yield* FileSystem.FileSystem; | ||
| 342 | const path = yield* Path.Path; | ||
| 343 | const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-state-textgen-" }); | ||
| 344 | const imagePath = path.join( | ||
| 345 | stateDir, | ||
| 346 | "attachments", | ||
| 347 | "thread-1", | ||
| 348 | "message-1-0.png", | ||
| 349 | ); | ||
| 350 | yield* fs.makeDirectory(path.join(stateDir, "attachments", "thread-1"), { | ||
| 351 | recursive: true, | ||
| 352 | }); | ||
| 353 | yield* fs.writeFile(imagePath, Buffer.from("hello")); | ||
| 354 | |||
| 355 | const textGeneration = yield* TextGeneration; | ||
| 356 | const generated = yield* textGeneration | ||
| 357 | .generateBranchName({ | ||
| 358 | cwd: process.cwd(), | ||
| 359 | message: "Fix layout bug from screenshot.", | ||
| 360 | attachments: [ | ||
| 361 | { | ||
| 362 | type: "image", | ||
| 363 | name: "bug.png", | ||
| 364 | mimeType: "image/png", | ||
| 365 | sizeBytes: 5, | ||
| 366 | dataUrl: "/attachments/thread-1/message-1-0.png", | ||
| 367 | }, | ||
| 368 | ], | ||
| 369 | }) | ||
| 370 | .pipe(Effect.provideService(ServerConfig, makeTestServerConfig(stateDir))); | ||
| 371 | |||
| 372 | expect(generated.branch).toBe("fix/ui-regression"); | ||
| 373 | }), | ||
| 374 | ), | ||
| 375 | ); | ||
| 376 | |||
| 314 | it.effect("returns null branch when codex returns the wrong object shape", () => | 377 | it.effect("returns null branch when codex returns the wrong object shape", () => |
| 315 | withFakeCodexEnv( | 378 | withFakeCodexEnv( |
| 316 | { | 379 | { |
apps/server/src/git/Layers/CodexTextGeneration.ts+35-14| ... | @@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto"; | ... | @@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto"; |
| 3 | import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect"; | 3 | import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect"; |
| 4 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 4 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 5 | 5 | ||
| 6 | import { resolveAttachmentRoutePath } from "../../attachmentPaths.ts"; | ||
| 7 | import { ServerConfig } from "../../config.ts"; | ||
| 6 | import { TextGenerationError } from "../Errors.ts"; | 8 | import { TextGenerationError } from "../Errors.ts"; |
| 7 | import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts"; | 9 | import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts"; |
| 8 | import { | 10 | import { |
| ... | @@ -245,6 +247,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -245,6 +247,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 245 | if (!attachments || attachments.length === 0) { | 247 | if (!attachments || attachments.length === 0) { |
| 246 | return []; | 248 | return []; |
| 247 | } | 249 | } |
| 250 | const serverConfig = yield* Effect.serviceOption(ServerConfig); | ||
| 248 | 251 | ||
| 249 | const imagePaths: string[] = []; | 252 | const imagePaths: string[] = []; |
| 250 | for (const [index, attachment] of attachments.entries()) { | 253 | for (const [index, attachment] of attachments.entries()) { |
| ... | @@ -253,26 +256,44 @@ const makeCodexTextGeneration = Effect.gen(function* () { | ... | @@ -253,26 +256,44 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 253 | } | 256 | } |
| 254 | 257 | ||
| 255 | const parsed = parseBase64DataUrl(attachment.dataUrl); | 258 | const parsed = parseBase64DataUrl(attachment.dataUrl); |
| 256 | if (!parsed || !parsed.mimeType.startsWith("image/")) { | 259 | if (parsed && parsed.mimeType.startsWith("image/")) { |
| 260 | const bytes = Buffer.from(parsed.base64, "base64"); | ||
| 261 | if (bytes.byteLength === 0) { | ||
| 262 | continue; | ||
| 263 | } | ||
| 264 | |||
| 265 | const extension = inferImageExtension({ | ||
| 266 | mimeType: parsed.mimeType, | ||
| 267 | fileName: attachment.name, | ||
| 268 | }); | ||
| 269 | const imagePath = yield* writeTempBinaryFile( | ||
| 270 | operation, | ||
| 271 | `codex-image-${index}`, | ||
| 272 | bytes, | ||
| 273 | extension, | ||
| 274 | ); | ||
| 275 | imagePaths.push(imagePath); | ||
| 257 | continue; | 276 | continue; |
| 258 | } | 277 | } |
| 259 | 278 | ||
| 260 | const bytes = Buffer.from(parsed.base64, "base64"); | 279 | const persistedPath = |
| 261 | if (bytes.byteLength === 0) { | 280 | Option.isSome(serverConfig) |
| 281 | ? resolveAttachmentRoutePath({ | ||
| 282 | stateDir: serverConfig.value.stateDir, | ||
| 283 | dataUrl: attachment.dataUrl, | ||
| 284 | }) | ||
| 285 | : null; | ||
| 286 | const localPath = persistedPath ?? attachment.dataUrl; | ||
| 287 | if (!path.isAbsolute(localPath)) { | ||
| 262 | continue; | 288 | continue; |
| 263 | } | 289 | } |
| 264 | 290 | const fileInfo = yield* fileSystem.stat(localPath).pipe( | |
| 265 | const extension = inferImageExtension({ | 291 | Effect.catch(() => Effect.succeed(null)), |
| 266 | mimeType: parsed.mimeType, | ||
| 267 | fileName: attachment.name, | ||
| 268 | }); | ||
| 269 | const imagePath = yield* writeTempBinaryFile( | ||
| 270 | operation, | ||
| 271 | `codex-image-${index}`, | ||
| 272 | bytes, | ||
| 273 | extension, | ||
| 274 | ); | 292 | ); |
| 275 | imagePaths.push(imagePath); | 293 | if (!fileInfo || fileInfo.type !== "File") { |
| 294 | continue; | ||
| 295 | } | ||
| 296 | imagePaths.push(localPath); | ||
| 276 | } | 297 | } |
| 277 | return imagePaths; | 298 | return imagePaths; |
| 278 | }); | 299 | }); |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+3-16| ... | @@ -37,9 +37,8 @@ import { | ... | @@ -37,9 +37,8 @@ import { |
| 37 | OrchestrationProjectionPipeline, | 37 | OrchestrationProjectionPipeline, |
| 38 | type OrchestrationProjectionPipelineShape, | 38 | type OrchestrationProjectionPipelineShape, |
| 39 | } from "../Services/ProjectionPipeline.ts"; | 39 | } from "../Services/ProjectionPipeline.ts"; |
| 40 | import { | 40 | import { attachmentRouteToRelativePath } from "../../attachmentPaths.ts"; |
| 41 | ATTACHMENTS_ROUTE_PREFIX, | 41 | import { ATTACHMENTS_ROUTE_PREFIX } from "../../projectFaviconRoute.ts"; |
| 42 | } from "../../projectFaviconRoute.ts"; | ||
| 43 | import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts"; | 42 | import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts"; |
| 44 | 43 | ||
| 45 | export const ORCHESTRATION_PROJECTOR_NAMES = { | 44 | export const ORCHESTRATION_PROJECTOR_NAMES = { |
| ... | @@ -262,18 +261,6 @@ function retainProjectionActivitiesAfterRevert( | ... | @@ -262,18 +261,6 @@ function retainProjectionActivitiesAfterRevert( |
| 262 | ); | 261 | ); |
| 263 | } | 262 | } |
| 264 | 263 | ||
| 265 | function toAttachmentRelativePath(dataUrl: string): string | null { | ||
| 266 | const prefix = `${ATTACHMENTS_ROUTE_PREFIX}/`; | ||
| 267 | if (!dataUrl.startsWith(prefix)) { | ||
| 268 | return null; | ||
| 269 | } | ||
| 270 | const relativePath = dataUrl.slice(prefix.length).replace(/^[/\\]+/, ""); | ||
| 271 | if (relativePath.length === 0 || relativePath.startsWith("..")) { | ||
| 272 | return null; | ||
| 273 | } | ||
| 274 | return relativePath.replace(/\\/g, "/"); | ||
| 275 | } | ||
| 276 | |||
| 277 | function collectThreadAttachmentRelativePaths( | 264 | function collectThreadAttachmentRelativePaths( |
| 278 | threadId: string, | 265 | threadId: string, |
| 279 | messages: ReadonlyArray<ProjectionThreadMessage>, | 266 | messages: ReadonlyArray<ProjectionThreadMessage>, |
| ... | @@ -289,7 +276,7 @@ function collectThreadAttachmentRelativePaths( | ... | @@ -289,7 +276,7 @@ function collectThreadAttachmentRelativePaths( |
| 289 | if (attachment.type !== "image") { | 276 | if (attachment.type !== "image") { |
| 290 | continue; | 277 | continue; |
| 291 | } | 278 | } |
| 292 | const relativePath = toAttachmentRelativePath(attachment.dataUrl); | 279 | const relativePath = attachmentRouteToRelativePath(attachment.dataUrl); |
| 293 | if (!relativePath || !relativePath.startsWith(threadPrefix)) { | 280 | if (!relativePath || !relativePath.startsWith(threadPrefix)) { |
| 294 | continue; | 281 | continue; |
| 295 | } | 282 | } |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+109-2| ... | @@ -1,3 +1,7 @@ | ... | @@ -1,3 +1,7 @@ |
| 1 | import fs from "node:fs"; | ||
| 2 | import os from "node:os"; | ||
| 3 | import path from "node:path"; | ||
| 4 | |||
| 1 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; | 5 | import type { ProviderRuntimeEvent } from "@t3tools/contracts"; |
| 2 | import { | 6 | import { |
| 3 | ApprovalRequestId, | 7 | ApprovalRequestId, |
| ... | @@ -13,6 +17,7 @@ import { | ... | @@ -13,6 +17,7 @@ import { |
| 13 | import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; | 17 | import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect"; |
| 14 | import { afterEach, describe, expect, it, vi } from "vitest"; | 18 | import { afterEach, describe, expect, it, vi } from "vitest"; |
| 15 | 19 | ||
| 20 | import { type ServerConfigShape, ServerConfig } from "../../config.ts"; | ||
| 16 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; | 21 | import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; |
| 17 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; | 22 | import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; |
| 18 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; | 23 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| ... | @@ -36,6 +41,23 @@ const asApprovalRequestId = (value: string): ApprovalRequestId => | ... | @@ -36,6 +41,23 @@ const asApprovalRequestId = (value: string): ApprovalRequestId => |
| 36 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); | 41 | const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value); |
| 37 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); | 42 | const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); |
| 38 | 43 | ||
| 44 | function makeTestServerConfig(stateDir: string): ServerConfigShape { | ||
| 45 | return { | ||
| 46 | mode: "web", | ||
| 47 | port: 0, | ||
| 48 | host: undefined, | ||
| 49 | cwd: process.cwd(), | ||
| 50 | keybindingsConfigPath: "", | ||
| 51 | stateDir, | ||
| 52 | staticDir: undefined, | ||
| 53 | devUrl: undefined, | ||
| 54 | noBrowser: true, | ||
| 55 | authToken: undefined, | ||
| 56 | autoBootstrapProjectFromCwd: false, | ||
| 57 | logWebSocketEvents: false, | ||
| 58 | }; | ||
| 59 | } | ||
| 60 | |||
| 39 | async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> { | 61 | async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> { |
| 40 | const deadline = Date.now() + timeoutMs; | 62 | const deadline = Date.now() + timeoutMs; |
| 41 | const poll = async (): Promise<void> => { | 63 | const poll = async (): Promise<void> => { |
| ... | @@ -70,7 +92,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -70,7 +92,7 @@ describe("ProviderCommandReactor", () => { |
| 70 | runtime = null; | 92 | runtime = null; |
| 71 | }); | 93 | }); |
| 72 | 94 | ||
| 73 | async function createHarness() { | 95 | async function createHarness(input?: { readonly stateDir?: string }) { |
| 74 | const now = new Date().toISOString(); | 96 | const now = new Date().toISOString(); |
| 75 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); | 97 | const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>()); |
| 76 | let nextSessionIndex = 1; | 98 | let nextSessionIndex = 1; |
| ... | @@ -124,7 +146,7 @@ describe("ProviderCommandReactor", () => { | ... | @@ -124,7 +146,7 @@ describe("ProviderCommandReactor", () => { |
| 124 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 146 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 125 | Layer.provide(SqlitePersistenceMemory), | 147 | Layer.provide(SqlitePersistenceMemory), |
| 126 | ); | 148 | ); |
| 127 | const layer = ProviderCommandReactorLive.pipe( | 149 | const liveLayer = ProviderCommandReactorLive.pipe( |
| 128 | Layer.provideMerge(orchestrationLayer), | 150 | Layer.provideMerge(orchestrationLayer), |
| 129 | Layer.provideMerge(Layer.succeed(ProviderService, service)), | 151 | Layer.provideMerge(Layer.succeed(ProviderService, service)), |
| 130 | Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), | 152 | Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)), |
| ... | @@ -135,6 +157,14 @@ describe("ProviderCommandReactor", () => { | ... | @@ -135,6 +157,14 @@ describe("ProviderCommandReactor", () => { |
| 135 | ), | 157 | ), |
| 136 | ), | 158 | ), |
| 137 | ); | 159 | ); |
| 160 | const layer = | ||
| 161 | input?.stateDir !== undefined | ||
| 162 | ? liveLayer.pipe( | ||
| 163 | Layer.provideMerge( | ||
| 164 | Layer.succeed(ServerConfig, makeTestServerConfig(input.stateDir)), | ||
| 165 | ), | ||
| 166 | ) | ||
| 167 | : liveLayer; | ||
| 138 | runtime = ManagedRuntime.make(layer); | 168 | runtime = ManagedRuntime.make(layer); |
| 139 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); | 169 | const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); |
| 140 | const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); | 170 | const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); |
| ... | @@ -285,6 +315,83 @@ describe("ProviderCommandReactor", () => { | ... | @@ -285,6 +315,83 @@ describe("ProviderCommandReactor", () => { |
| 285 | }); | 315 | }); |
| 286 | }); | 316 | }); |
| 287 | 317 | ||
| 318 | it("reuses persisted attachment files for branch generation and turn start", async () => { | ||
| 319 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-attachments-")); | ||
| 320 | const harness = await createHarness({ stateDir }); | ||
| 321 | const now = new Date().toISOString(); | ||
| 322 | |||
| 323 | await Effect.runPromise( | ||
| 324 | harness.engine.dispatch({ | ||
| 325 | type: "thread.meta.update", | ||
| 326 | commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-persisted"), | ||
| 327 | threadId: ThreadId.makeUnsafe("thread-1"), | ||
| 328 | branch: "t3code/abcdef12", | ||
| 329 | worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-abcdef12", | ||
| 330 | }), | ||
| 331 | ); | ||
| 332 | |||
| 333 | await Effect.runPromise( | ||
| 334 | harness.engine.dispatch({ | ||
| 335 | type: "thread.turn.start", | ||
| 336 | commandId: CommandId.makeUnsafe("cmd-turn-start-persisted-attachments"), | ||
| 337 | threadId: ThreadId.makeUnsafe("thread-1"), | ||
| 338 | message: { | ||
| 339 | messageId: asMessageId("user-message-persisted-attachments"), | ||
| 340 | role: "user", | ||
| 341 | text: "Fix visual bug from screenshot", | ||
| 342 | attachments: [ | ||
| 343 | { | ||
| 344 | type: "image", | ||
| 345 | name: "bug.png", | ||
| 346 | mimeType: "image/png", | ||
| 347 | sizeBytes: 5, | ||
| 348 | dataUrl: "data:image/png;base64,SGVsbG8=", | ||
| 349 | }, | ||
| 350 | ], | ||
| 351 | }, | ||
| 352 | approvalPolicy: "on-request", | ||
| 353 | sandboxMode: "workspace-write", | ||
| 354 | createdAt: now, | ||
| 355 | }), | ||
| 356 | ); | ||
| 357 | |||
| 358 | await waitFor(() => harness.generateBranchName.mock.calls.length === 1); | ||
| 359 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); | ||
| 360 | |||
| 361 | const expectedAttachmentPath = path.join( | ||
| 362 | stateDir, | ||
| 363 | "attachments", | ||
| 364 | "thread-1", | ||
| 365 | "user-message-persisted-attachments-0.png", | ||
| 366 | ); | ||
| 367 | |||
| 368 | expect(harness.generateBranchName.mock.calls[0]?.[0]).toEqual({ | ||
| 369 | cwd: "/tmp/provider-project/.t3/worktrees/t3code-abcdef12", | ||
| 370 | message: "Fix visual bug from screenshot", | ||
| 371 | attachments: [ | ||
| 372 | { | ||
| 373 | type: "image", | ||
| 374 | name: "bug.png", | ||
| 375 | mimeType: "image/png", | ||
| 376 | sizeBytes: 5, | ||
| 377 | dataUrl: expectedAttachmentPath, | ||
| 378 | }, | ||
| 379 | ], | ||
| 380 | }); | ||
| 381 | expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({ | ||
| 382 | attachments: [ | ||
| 383 | { | ||
| 384 | type: "image", | ||
| 385 | name: "bug.png", | ||
| 386 | mimeType: "image/png", | ||
| 387 | sizeBytes: 5, | ||
| 388 | dataUrl: expectedAttachmentPath, | ||
| 389 | }, | ||
| 390 | ], | ||
| 391 | }); | ||
| 392 | expect(fs.existsSync(expectedAttachmentPath)).toBe(true); | ||
| 393 | }); | ||
| 394 | |||
| 288 | it("skips worktree branch generation after the first user turn", async () => { | 395 | it("skips worktree branch generation after the first user turn", async () => { |
| 289 | const harness = await createHarness(); | 396 | const harness = await createHarness(); |
| 290 | const now = new Date().toISOString(); | 397 | const now = new Date().toISOString(); |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+82-2| ... | @@ -1,3 +1,5 @@ | ... | @@ -1,3 +1,5 @@ |
| 1 | import fs from "node:fs"; | ||
| 2 | |||
| 1 | import { | 3 | import { |
| 2 | type ChatAttachment, | 4 | type ChatAttachment, |
| 3 | CommandId, | 5 | CommandId, |
| ... | @@ -15,8 +17,11 @@ import { | ... | @@ -15,8 +17,11 @@ import { |
| 15 | import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect"; | 17 | import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect"; |
| 16 | 18 | ||
| 17 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; | 19 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; |
| 20 | import { resolveAttachmentRelativePath, resolveAttachmentRoutePath } from "../../attachmentPaths.ts"; | ||
| 21 | import { ServerConfig } from "../../config.ts"; | ||
| 18 | import { GitCore } from "../../git/Services/GitCore.ts"; | 22 | import { GitCore } from "../../git/Services/GitCore.ts"; |
| 19 | import { TextGeneration } from "../../git/Services/TextGeneration.ts"; | 23 | import { TextGeneration } from "../../git/Services/TextGeneration.ts"; |
| 24 | import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts"; | ||
| 20 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; | 25 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 21 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; | 26 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 22 | import { | 27 | import { |
| ... | @@ -103,6 +108,7 @@ const make = Effect.gen(function* () { | ... | @@ -103,6 +108,7 @@ const make = Effect.gen(function* () { |
| 103 | const providerService = yield* ProviderService; | 108 | const providerService = yield* ProviderService; |
| 104 | const git = yield* GitCore; | 109 | const git = yield* GitCore; |
| 105 | const textGeneration = yield* TextGeneration; | 110 | const textGeneration = yield* TextGeneration; |
| 111 | const serverConfig = yield* Effect.serviceOption(ServerConfig); | ||
| 106 | const handledTurnStartKeys = yield* Cache.make<string, true>({ | 112 | const handledTurnStartKeys = yield* Cache.make<string, true>({ |
| 107 | capacity: HANDLED_TURN_START_KEY_MAX, | 113 | capacity: HANDLED_TURN_START_KEY_MAX, |
| 108 | timeToLive: HANDLED_TURN_START_KEY_TTL, | 114 | timeToLive: HANDLED_TURN_START_KEY_TTL, |
| ... | @@ -280,6 +286,74 @@ const make = Effect.gen(function* () { | ... | @@ -280,6 +286,74 @@ const make = Effect.gen(function* () { |
| 280 | }); | 286 | }); |
| 281 | }); | 287 | }); |
| 282 | 288 | ||
| 289 | const resolvePersistedAttachments = Effect.fnUntraced(function* ( | ||
| 290 | input: { | ||
| 291 | readonly threadId: ThreadId; | ||
| 292 | readonly messageId: string; | ||
| 293 | readonly attachments: ReadonlyArray<ChatAttachment> | undefined; | ||
| 294 | }, | ||
| 295 | ) { | ||
| 296 | if (!input.attachments || input.attachments.length === 0) { | ||
| 297 | return input.attachments ?? []; | ||
| 298 | } | ||
| 299 | |||
| 300 | if (Option.isNone(serverConfig)) { | ||
| 301 | return input.attachments; | ||
| 302 | } | ||
| 303 | const threadSegment = encodeURIComponent(input.threadId); | ||
| 304 | const messageSegment = encodeURIComponent(input.messageId); | ||
| 305 | |||
| 306 | return yield* Effect.forEach( | ||
| 307 | Array.from(input.attachments.entries()), | ||
| 308 | ([index, attachment]) => | ||
| 309 | Effect.gen(function* () { | ||
| 310 | if (attachment.type !== "image") { | ||
| 311 | return attachment; | ||
| 312 | } | ||
| 313 | |||
| 314 | const resolvedRoutePath = resolveAttachmentRoutePath({ | ||
| 315 | stateDir: serverConfig.value.stateDir, | ||
| 316 | dataUrl: attachment.dataUrl, | ||
| 317 | }); | ||
| 318 | const resolvedMaterializedPath = | ||
| 319 | resolvedRoutePath ?? | ||
| 320 | (() => { | ||
| 321 | const parsed = parseBase64DataUrl(attachment.dataUrl); | ||
| 322 | if (!parsed || !parsed.mimeType.startsWith("image/")) { | ||
| 323 | return null; | ||
| 324 | } | ||
| 325 | const extension = inferImageExtension({ | ||
| 326 | mimeType: parsed.mimeType, | ||
| 327 | fileName: attachment.name, | ||
| 328 | }); | ||
| 329 | return resolveAttachmentRelativePath({ | ||
| 330 | stateDir: serverConfig.value.stateDir, | ||
| 331 | relativePath: `${threadSegment}/${messageSegment}-${index}${extension}`, | ||
| 332 | }); | ||
| 333 | })(); | ||
| 334 | |||
| 335 | if (!resolvedMaterializedPath) { | ||
| 336 | return attachment; | ||
| 337 | } | ||
| 338 | |||
| 339 | const isFile = yield* Effect.sync(() => { | ||
| 340 | try { | ||
| 341 | return fs.statSync(resolvedMaterializedPath).isFile(); | ||
| 342 | } catch { | ||
| 343 | return false; | ||
| 344 | } | ||
| 345 | }); | ||
| 346 | if (!isFile) { | ||
| 347 | return attachment; | ||
| 348 | } | ||
| 349 | return { | ||
| 350 | ...attachment, | ||
| 351 | dataUrl: resolvedMaterializedPath, | ||
| 352 | } satisfies ChatAttachment; | ||
| 353 | }), | ||
| 354 | ); | ||
| 355 | }); | ||
| 356 | |||
| 283 | const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced(function* (input: { | 357 | const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced(function* (input: { |
| 284 | readonly threadId: ThreadId; | 358 | readonly threadId: ThreadId; |
| 285 | readonly branch: string | null; | 359 | readonly branch: string | null; |
| ... | @@ -381,19 +455,25 @@ const make = Effect.gen(function* () { | ... | @@ -381,19 +455,25 @@ const make = Effect.gen(function* () { |
| 381 | return; | 455 | return; |
| 382 | } | 456 | } |
| 383 | 457 | ||
| 458 | const resolvedAttachments = yield* resolvePersistedAttachments({ | ||
| 459 | threadId: event.payload.threadId, | ||
| 460 | messageId: message.id, | ||
| 461 | attachments: message.attachments, | ||
| 462 | }); | ||
| 463 | |||
| 384 | yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ | 464 | yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ |
| 385 | threadId: event.payload.threadId, | 465 | threadId: event.payload.threadId, |
| 386 | branch: thread.branch, | 466 | branch: thread.branch, |
| 387 | worktreePath: thread.worktreePath, | 467 | worktreePath: thread.worktreePath, |
| 388 | messageId: message.id, | 468 | messageId: message.id, |
| 389 | messageText: message.text, | 469 | messageText: message.text, |
| 390 | ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), | 470 | ...(message.attachments !== undefined ? { attachments: resolvedAttachments } : {}), |
| 391 | }); | 471 | }); |
| 392 | 472 | ||
| 393 | yield* sendTurnForThread({ | 473 | yield* sendTurnForThread({ |
| 394 | threadId: event.payload.threadId, | 474 | threadId: event.payload.threadId, |
| 395 | messageText: message.text, | 475 | messageText: message.text, |
| 396 | ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), | 476 | ...(message.attachments !== undefined ? { attachments: resolvedAttachments } : {}), |
| 397 | ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), | 477 | ...(event.payload.model !== undefined ? { model: event.payload.model } : {}), |
| 398 | ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}), | 478 | ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}), |
| 399 | approvalPolicy: event.payload.approvalPolicy, | 479 | approvalPolicy: event.payload.approvalPolicy, |
apps/server/src/wsServer.ts+8-10| ... | @@ -55,6 +55,7 @@ import { Open } from "./open"; | ... | @@ -55,6 +55,7 @@ import { Open } from "./open"; |
| 55 | import { ServerConfig } from "./config"; | 55 | import { ServerConfig } from "./config"; |
| 56 | import { GitCore } from "./git/Services/GitCore.ts"; | 56 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 57 | import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; | 57 | import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; |
| 58 | import { normalizeAttachmentRelativePath, resolveAttachmentRelativePath } from "./attachmentPaths"; | ||
| 58 | 59 | ||
| 59 | /** | 60 | /** |
| 60 | * ServerShape - Service API for server lifecycle control. | 61 | * ServerShape - Service API for server lifecycle control. |
| ... | @@ -249,21 +250,18 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -249,21 +250,18 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 249 | } | 250 | } |
| 250 | 251 | ||
| 251 | if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { | 252 | if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) { |
| 252 | const attachmentsRoot = path.resolve(path.join(serverConfig.stateDir, "attachments")); | ||
| 253 | const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); | 253 | const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length); |
| 254 | const normalizedRelativePath = path.normalize(rawRelativePath).replace(/^[/\\]+/, ""); | 254 | const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath); |
| 255 | 255 | if (!normalizedRelativePath) { | |
| 256 | if ( | ||
| 257 | normalizedRelativePath.length === 0 || | ||
| 258 | normalizedRelativePath.startsWith("..") || | ||
| 259 | normalizedRelativePath.includes("\0") | ||
| 260 | ) { | ||
| 261 | respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); | 256 | respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); |
| 262 | return; | 257 | return; |
| 263 | } | 258 | } |
| 264 | 259 | ||
| 265 | const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath)); | 260 | const filePath = resolveAttachmentRelativePath({ |
| 266 | if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) { | 261 | stateDir: serverConfig.stateDir, |
| 262 | relativePath: normalizedRelativePath, | ||
| 263 | }); | ||
| 264 | if (!filePath) { | ||
| 267 | respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); | 265 | respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); |
| 268 | return; | 266 | return; |
| 269 | } | 267 | } |