| author | |
| committer | |
| log | c0054cb56f1439c7bfda7771f919bdb7f948bab7 |
| tree | 8148227ca4aa09e7121c259e5564650c926a9b80 |
| parent | 41734dfbbfd47d2d9639dfbdd0912bb623e51403 |
- add attachment store helpers for IDs, route paths, and file resolution
- stop projection pipeline from mutating/storing data URLs; keep attachment metadata unchanged
- update Codex send/branch generation flows and tests to resolve images via attachment IDs18 files changed, 499 insertions(+), 407 deletions(-)
apps/server/src/attachmentStore.ts created+95| ... | ... | @@ -0,0 +1,95 @@ |
| 1 | import { randomUUID } from "node:crypto"; | |
| 2 | ||
| 3 | import type { ChatAttachment } from "@t3tools/contracts"; | |
| 4 | ||
| 5 | import { | |
| 6 | ATTACHMENTS_ROUTE_PREFIX, | |
| 7 | normalizeAttachmentRelativePath, | |
| 8 | resolveAttachmentRelativePath, | |
| 9 | } from "./attachmentPaths.ts"; | |
| 10 | import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; | |
| 11 | ||
| 12 | const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"]; | |
| 13 | ||
| 14 | function toSafeThreadAttachmentSegment(threadId: string): string | null { | |
| 15 | const segment = encodeURIComponent(threadId); | |
| 16 | if ( | |
| 17 | segment.length === 0 || | |
| 18 | segment === "." || | |
| 19 | segment === ".." || | |
| 20 | segment.includes("/") || | |
| 21 | segment.includes("\\") || | |
| 22 | segment.includes("\0") | |
| 23 | ) { | |
| 24 | return null; | |
| 25 | } | |
| 26 | return segment; | |
| 27 | } | |
| 28 | ||
| 29 | export function createAttachmentId(threadId: string): string | null { | |
| 30 | const threadSegment = toSafeThreadAttachmentSegment(threadId); | |
| 31 | if (!threadSegment) { | |
| 32 | return null; | |
| 33 | } | |
| 34 | return `${threadSegment}-${randomUUID()}`; | |
| 35 | } | |
| 36 | ||
| 37 | export function attachmentRelativePath(attachment: ChatAttachment): string { | |
| 38 | switch (attachment.type) { | |
| 39 | case "image": { | |
| 40 | const extension = inferImageExtension({ | |
| 41 | mimeType: attachment.mimeType, | |
| 42 | fileName: attachment.name, | |
| 43 | }); | |
| 44 | return `${attachment.id}${extension}`; | |
| 45 | } | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | export function resolveAttachmentPath(input: { | |
| 50 | readonly stateDir: string; | |
| 51 | readonly attachment: ChatAttachment; | |
| 52 | }): string | null { | |
| 53 | return resolveAttachmentRelativePath({ | |
| 54 | stateDir: input.stateDir, | |
| 55 | relativePath: attachmentRelativePath(input.attachment), | |
| 56 | }); | |
| 57 | } | |
| 58 | ||
| 59 | export function attachmentRoutePath(attachment: ChatAttachment): string { | |
| 60 | return `${ATTACHMENTS_ROUTE_PREFIX}/${encodeURIComponent(attachment.id)}`; | |
| 61 | } | |
| 62 | ||
| 63 | export function resolveAttachmentPathById(input: { | |
| 64 | readonly stateDir: string; | |
| 65 | readonly attachmentId: string; | |
| 66 | }): string | null { | |
| 67 | const normalizedId = normalizeAttachmentRelativePath(input.attachmentId); | |
| 68 | if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { | |
| 69 | return null; | |
| 70 | } | |
| 71 | for (const extension of ATTACHMENT_FILENAME_EXTENSIONS) { | |
| 72 | const maybePath = resolveAttachmentRelativePath({ | |
| 73 | stateDir: input.stateDir, | |
| 74 | relativePath: `${normalizedId}${extension}`, | |
| 75 | }); | |
| 76 | if (maybePath) { | |
| 77 | return maybePath; | |
| 78 | } | |
| 79 | } | |
| 80 | return null; | |
| 81 | } | |
| 82 | ||
| 83 | export function parseAttachmentIdFromRelativePath(relativePath: string): string | null { | |
| 84 | const normalized = normalizeAttachmentRelativePath(relativePath); | |
| 85 | if (!normalized || normalized.includes("/")) { | |
| 86 | return null; | |
| 87 | } | |
| 88 | const extensionIndex = normalized.lastIndexOf("."); | |
| 89 | if (extensionIndex <= 0) { | |
| 90 | return null; | |
| 91 | } | |
| 92 | const id = normalized.slice(0, extensionIndex); | |
| 93 | return id.length > 0 && !id.includes(".") ? id : null; | |
| 94 | } | |
| 95 |
apps/server/src/codexAppServerManager.test.ts+2-8| ... | ... | @@ -191,10 +191,7 @@ describe("sendTurn", () => { |
| 191 | 191 | attachments: [ |
| 192 | 192 | { |
| 193 | 193 | type: "image", |
| 194 | name: "error.png", | |
| 195 | mimeType: "image/png", | |
| 196 | sizeBytes: 1_024, | |
| 197 | dataUrl: "data:image/png;base64,AAAA", | |
| 194 | url: "data:image/png;base64,AAAA", | |
| 198 | 195 | }, |
| 199 | 196 | ], |
| 200 | 197 | model: "gpt-5.3", |
| ... | ... | @@ -242,10 +239,7 @@ describe("sendTurn", () => { |
| 242 | 239 | attachments: [ |
| 243 | 240 | { |
| 244 | 241 | type: "image", |
| 245 | name: "diagram.png", | |
| 246 | mimeType: "image/png", | |
| 247 | sizeBytes: 256, | |
| 248 | dataUrl: "data:image/png;base64,BBBB", | |
| 242 | url: "data:image/png;base64,BBBB", | |
| 249 | 243 | }, |
| 250 | 244 | ], |
| 251 | 245 | }); |
apps/server/src/codexAppServerManager.ts+10-3| ... | ... | @@ -14,7 +14,6 @@ import { |
| 14 | 14 | normalizeModelSlug, |
| 15 | 15 | type ProviderApprovalDecision, |
| 16 | 16 | type ProviderEvent, |
| 17 | type ProviderSendTurnInput, | |
| 18 | 17 | type ProviderSession, |
| 19 | 18 | type ProviderSessionStartInput, |
| 20 | 19 | type ProviderTurnStartResult, |
| ... | ... | @@ -71,6 +70,14 @@ interface JsonRpcNotification { |
| 71 | 70 | params?: unknown; |
| 72 | 71 | } |
| 73 | 72 | |
| 73 | export interface CodexAppServerSendTurnInput { | |
| 74 | readonly sessionId: ProviderSessionId; | |
| 75 | readonly input?: string; | |
| 76 | readonly attachments?: ReadonlyArray<{ type: "image"; url: string }>; | |
| 77 | readonly model?: string; | |
| 78 | readonly effort?: string; | |
| 79 | } | |
| 80 | ||
| 74 | 81 | export interface CodexThreadTurnSnapshot { |
| 75 | 82 | id: ProviderTurnId; |
| 76 | 83 | items: unknown[]; |
| ... | ... | @@ -290,7 +297,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 290 | 297 | } |
| 291 | 298 | } |
| 292 | 299 | |
| 293 | async sendTurn(input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> { | |
| 300 | async sendTurn(input: CodexAppServerSendTurnInput): Promise<ProviderTurnStartResult> { | |
| 294 | 301 | const context = this.requireSession(input.sessionId); |
| 295 | 302 | if (!context.session.threadId) { |
| 296 | 303 | throw new Error("Session is missing a thread id."); |
| ... | ... | @@ -310,7 +317,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 310 | 317 | if (attachment.type === "image") { |
| 311 | 318 | turnInput.push({ |
| 312 | 319 | type: "image", |
| 313 | url: attachment.dataUrl, | |
| 320 | url: attachment.url, | |
| 314 | 321 | }); |
| 315 | 322 | } |
| 316 | 323 | } |
apps/server/src/git/Layers/CodexTextGeneration.test.ts+33-31| ... | ... | @@ -294,28 +294,36 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 294 | 294 | stdinMustContain: "Attachment metadata:", |
| 295 | 295 | }, |
| 296 | 296 | Effect.gen(function* () { |
| 297 | const textGeneration = yield* TextGeneration; | |
| 297 | const fs = yield* FileSystem.FileSystem; | |
| 298 | const path = yield* Path.Path; | |
| 299 | const attachmentId = `thread-branch-image-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | |
| 300 | const attachmentPath = path.join(process.cwd(), "attachments", `${attachmentId}.png`); | |
| 301 | yield* fs.makeDirectory(path.join(process.cwd(), "attachments"), { recursive: true }); | |
| 302 | yield* fs.writeFile(attachmentPath, Buffer.from("hello")); | |
| 298 | 303 | |
| 299 | const generated = yield* textGeneration.generateBranchName({ | |
| 300 | cwd: process.cwd(), | |
| 301 | message: "Fix layout bug from screenshot.", | |
| 302 | attachments: [ | |
| 303 | { | |
| 304 | type: "image", | |
| 305 | name: "bug.png", | |
| 306 | mimeType: "image/png", | |
| 307 | sizeBytes: 5, | |
| 308 | dataUrl: "data:image/png;base64,SGVsbG8=", | |
| 309 | }, | |
| 310 | ], | |
| 311 | }); | |
| 304 | const textGeneration = yield* TextGeneration; | |
| 305 | const generated = yield* textGeneration | |
| 306 | .generateBranchName({ | |
| 307 | cwd: process.cwd(), | |
| 308 | message: "Fix layout bug from screenshot.", | |
| 309 | attachments: [ | |
| 310 | { | |
| 311 | type: "image", | |
| 312 | id: attachmentId, | |
| 313 | name: "bug.png", | |
| 314 | mimeType: "image/png", | |
| 315 | sizeBytes: 5, | |
| 316 | }, | |
| 317 | ], | |
| 318 | }) | |
| 319 | .pipe(Effect.ensuring(fs.remove(attachmentPath).pipe(Effect.catch(() => Effect.void)))); | |
| 312 | 320 | |
| 313 | 321 | expect(generated.branch).toBe("fix/ui-regression"); |
| 314 | 322 | }), |
| 315 | 323 | ), |
| 316 | 324 | ); |
| 317 | 325 | |
| 318 | it.effect("resolves persisted attachment URLs to files for codex image inputs", () => | |
| 326 | it.effect("resolves persisted attachment ids to files for codex image inputs", () => | |
| 319 | 327 | withFakeCodexEnv( |
| 320 | 328 | { |
| 321 | 329 | output: JSON.stringify({ |
| ... | ... | @@ -326,12 +334,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 326 | 334 | Effect.gen(function* () { |
| 327 | 335 | const fs = yield* FileSystem.FileSystem; |
| 328 | 336 | const path = yield* Path.Path; |
| 329 | const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | |
| 330 | const imageFileName = `message-1-${uniqueSuffix}.png`; | |
| 331 | const imagePath = path.join(process.cwd(), "attachments", "thread-1", imageFileName); | |
| 332 | yield* fs.makeDirectory(path.join(process.cwd(), "attachments", "thread-1"), { | |
| 333 | recursive: true, | |
| 334 | }); | |
| 337 | const attachmentId = | |
| 338 | `thread-1-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | |
| 339 | const imagePath = path.join(process.cwd(), "attachments", `${attachmentId}.png`); | |
| 340 | yield* fs.makeDirectory(path.join(process.cwd(), "attachments"), { recursive: true }); | |
| 335 | 341 | yield* fs.writeFile(imagePath, Buffer.from("hello")); |
| 336 | 342 | |
| 337 | 343 | const textGeneration = yield* TextGeneration; |
| ... | ... | @@ -342,10 +348,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 342 | 348 | attachments: [ |
| 343 | 349 | { |
| 344 | 350 | type: "image", |
| 351 | id: attachmentId, | |
| 345 | 352 | name: "bug.png", |
| 346 | 353 | mimeType: "image/png", |
| 347 | 354 | sizeBytes: 5, |
| 348 | dataUrl: `/attachments/thread-1/${imageFileName}`, | |
| 349 | 355 | }, |
| 350 | 356 | ], |
| 351 | 357 | }) |
| ... | ... | @@ -357,8 +363,6 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 357 | 363 | }), |
| 358 | 364 | ), |
| 359 | 365 | ), |
| 360 | ) | |
| 361 | .pipe( | |
| 362 | 366 | Effect.ensuring( |
| 363 | 367 | fs.remove(imagePath).pipe(Effect.catch(() => Effect.void)), |
| 364 | 368 | ), |
| ... | ... | @@ -369,7 +373,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 369 | 373 | ), |
| 370 | 374 | ); |
| 371 | 375 | |
| 372 | it.effect("ignores absolute non-route attachment paths for codex image inputs", () => | |
| 376 | it.effect("ignores missing attachment ids for codex image inputs", () => | |
| 373 | 377 | withFakeCodexEnv( |
| 374 | 378 | { |
| 375 | 379 | output: JSON.stringify({ |
| ... | ... | @@ -380,11 +384,9 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 380 | 384 | Effect.gen(function* () { |
| 381 | 385 | const fs = yield* FileSystem.FileSystem; |
| 382 | 386 | const path = yield* Path.Path; |
| 383 | const tempDir = yield* fs.makeTempDirectoryScoped({ | |
| 384 | prefix: "t3code-codex-attachment-bypass-", | |
| 385 | }); | |
| 386 | const absoluteImagePath = path.join(tempDir, "outside.png"); | |
| 387 | yield* fs.writeFile(absoluteImagePath, Buffer.from("hello")); | |
| 387 | const missingAttachmentId = `thread-missing-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | |
| 388 | const missingPath = path.join(process.cwd(), "attachments", `${missingAttachmentId}.png`); | |
| 389 | yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); | |
| 388 | 390 | |
| 389 | 391 | const textGeneration = yield* TextGeneration; |
| 390 | 392 | const result = yield* textGeneration |
| ... | ... | @@ -394,10 +396,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { |
| 394 | 396 | attachments: [ |
| 395 | 397 | { |
| 396 | 398 | type: "image", |
| 399 | id: missingAttachmentId, | |
| 397 | 400 | name: "outside.png", |
| 398 | 401 | mimeType: "image/png", |
| 399 | 402 | sizeBytes: 5, |
| 400 | dataUrl: absoluteImagePath, | |
| 401 | 403 | }, |
| 402 | 404 | ], |
| 403 | 405 | }) |
apps/server/src/git/Layers/CodexTextGeneration.ts+14-75| ... | ... | @@ -3,10 +3,9 @@ import { randomUUID } from "node:crypto"; |
| 3 | 3 | import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect"; |
| 4 | 4 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 5 | 5 | |
| 6 | import { resolveAttachmentRoutePath } from "../../attachmentPaths.ts"; | |
| 6 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | |
| 7 | 7 | import { ServerConfig } from "../../config.ts"; |
| 8 | 8 | import { TextGenerationError } from "../Errors.ts"; |
| 9 | import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts"; | |
| 10 | 9 | import { |
| 11 | 10 | type BranchNameGenerationInput, |
| 12 | 11 | type BranchNameGenerationResult, |
| ... | ... | @@ -109,7 +108,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 109 | 108 | |
| 110 | 109 | type MaterializedImageAttachments = { |
| 111 | 110 | readonly imagePaths: ReadonlyArray<string>; |
| 112 | readonly tempImagePaths: ReadonlyArray<string>; | |
| 113 | 111 | }; |
| 114 | 112 | |
| 115 | 113 | const readStreamAsString = <E>( |
| ... | ... | @@ -151,96 +149,41 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 151 | 149 | ); |
| 152 | 150 | }; |
| 153 | 151 | |
| 154 | const writeTempBinaryFile = ( | |
| 155 | operation: string, | |
| 156 | prefix: string, | |
| 157 | bytes: Uint8Array, | |
| 158 | extension: string, | |
| 159 | ): Effect.Effect<string, TextGenerationError> => { | |
| 160 | const normalizedExtension = extension.startsWith(".") ? extension : `.${extension}`; | |
| 161 | const filePath = path.join( | |
| 162 | tempDir, | |
| 163 | `t3code-${prefix}-${process.pid}-${randomUUID()}${normalizedExtension}`, | |
| 164 | ); | |
| 165 | return fileSystem.writeFile(filePath, bytes).pipe( | |
| 166 | Effect.mapError( | |
| 167 | (cause) => | |
| 168 | new TextGenerationError({ | |
| 169 | operation, | |
| 170 | detail: `Failed to write temp file at ${filePath}.`, | |
| 171 | cause, | |
| 172 | }), | |
| 173 | ), | |
| 174 | Effect.as(filePath), | |
| 175 | ); | |
| 176 | }; | |
| 177 | ||
| 178 | 152 | const safeUnlink = (filePath: string): Effect.Effect<void, never> => |
| 179 | 153 | fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); |
| 180 | 154 | |
| 181 | 155 | const materializeImageAttachments = ( |
| 182 | operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName", | |
| 156 | _operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName", | |
| 183 | 157 | attachments: BranchNameGenerationInput["attachments"], |
| 184 | ): Effect.Effect<MaterializedImageAttachments, TextGenerationError> => { | |
| 185 | const tempImagePaths: string[] = []; | |
| 186 | ||
| 187 | return Effect.gen(function* () { | |
| 158 | ): Effect.Effect<MaterializedImageAttachments, TextGenerationError> => | |
| 159 | Effect.gen(function* () { | |
| 188 | 160 | if (!attachments || attachments.length === 0) { |
| 189 | return { imagePaths: [], tempImagePaths }; | |
| 161 | return { imagePaths: [] }; | |
| 190 | 162 | } |
| 191 | 163 | |
| 192 | 164 | const imagePaths: string[] = []; |
| 193 | for (const [index, attachment] of attachments.entries()) { | |
| 165 | for (const attachment of attachments) { | |
| 194 | 166 | if (attachment.type !== "image") { |
| 195 | 167 | continue; |
| 196 | 168 | } |
| 197 | 169 | |
| 198 | const parsed = parseBase64DataUrl(attachment.dataUrl); | |
| 199 | if (parsed && parsed.mimeType.startsWith("image/")) { | |
| 200 | const bytes = Buffer.from(parsed.base64, "base64"); | |
| 201 | if (bytes.byteLength === 0) { | |
| 202 | continue; | |
| 203 | } | |
| 204 | ||
| 205 | const extension = inferImageExtension({ | |
| 206 | mimeType: parsed.mimeType, | |
| 207 | fileName: attachment.name, | |
| 208 | }); | |
| 209 | const imagePath = yield* writeTempBinaryFile( | |
| 210 | operation, | |
| 211 | `codex-image-${index}`, | |
| 212 | bytes, | |
| 213 | extension, | |
| 214 | ); | |
| 215 | imagePaths.push(imagePath); | |
| 216 | tempImagePaths.push(imagePath); | |
| 217 | continue; | |
| 218 | } | |
| 219 | ||
| 220 | const persistedPath = resolveAttachmentRoutePath({ | |
| 170 | const resolvedPath = resolveAttachmentPath({ | |
| 221 | 171 | stateDir: serverConfig.stateDir, |
| 222 | dataUrl: attachment.dataUrl, | |
| 172 | attachment, | |
| 223 | 173 | }); |
| 224 | if (!persistedPath || !path.isAbsolute(persistedPath)) { | |
| 174 | if (!resolvedPath || !path.isAbsolute(resolvedPath)) { | |
| 225 | 175 | continue; |
| 226 | 176 | } |
| 227 | 177 | const fileInfo = yield* fileSystem |
| 228 | .stat(persistedPath) | |
| 178 | .stat(resolvedPath) | |
| 229 | 179 | .pipe(Effect.catch(() => Effect.succeed(null))); |
| 230 | 180 | if (!fileInfo || fileInfo.type !== "File") { |
| 231 | 181 | continue; |
| 232 | 182 | } |
| 233 | imagePaths.push(persistedPath); | |
| 183 | imagePaths.push(resolvedPath); | |
| 234 | 184 | } |
| 235 | return { imagePaths, tempImagePaths }; | |
| 236 | }).pipe( | |
| 237 | Effect.tapError(() => | |
| 238 | Effect.all(tempImagePaths.map((filePath) => safeUnlink(filePath)), { | |
| 239 | concurrency: "unbounded", | |
| 240 | }).pipe(Effect.asVoid), | |
| 241 | ), | |
| 242 | ); | |
| 243 | }; | |
| 185 | return { imagePaths }; | |
| 186 | }); | |
| 244 | 187 | |
| 245 | 188 | const runCodexJson = <S extends Schema.Top>({ |
| 246 | 189 | operation, |
| ... | ... | @@ -454,10 +397,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 454 | 397 | |
| 455 | 398 | const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { |
| 456 | 399 | return Effect.gen(function* () { |
| 457 | const { imagePaths, tempImagePaths } = yield* materializeImageAttachments( | |
| 458 | "generateBranchName", | |
| 459 | input.attachments, | |
| 460 | ); | |
| 400 | const { imagePaths } = yield* materializeImageAttachments("generateBranchName", input.attachments); | |
| 461 | 401 | const attachmentLines = (input.attachments ?? []).map( |
| 462 | 402 | (attachment) => |
| 463 | 403 | `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, |
| ... | ... | @@ -492,7 +432,6 @@ const makeCodexTextGeneration = Effect.gen(function* () { |
| 492 | 432 | branch: Schema.String, |
| 493 | 433 | }), |
| 494 | 434 | imagePaths, |
| 495 | cleanupPaths: tempImagePaths, | |
| 496 | 435 | }); |
| 497 | 436 | |
| 498 | 437 | return { |
apps/server/src/main.test.ts+14-3| ... | ... | @@ -49,10 +49,21 @@ const testLayer = Layer.mergeAll( |
| 49 | 49 | const runCli = ( |
| 50 | 50 | args: ReadonlyArray<string>, |
| 51 | 51 | env: Record<string, string> = { T3CODE_NO_BROWSER: "true" }, |
| 52 | ) => | |
| 53 | Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe( | |
| 54 | Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))), | |
| 52 | ) => { | |
| 53 | const uniqueStateDir = `/tmp/t3-cli-state-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; | |
| 54 | return Command.runWith(t3Cli, { version: "0.0.0-test" })(args).pipe( | |
| 55 | Effect.provide( | |
| 56 | ConfigProvider.layer( | |
| 57 | ConfigProvider.fromEnv({ | |
| 58 | env: { | |
| 59 | T3CODE_STATE_DIR: uniqueStateDir, | |
| 60 | ...env, | |
| 61 | }, | |
| 62 | }), | |
| 63 | ), | |
| 64 | ), | |
| 55 | 65 | ); |
| 66 | }; | |
| 56 | 67 | |
| 57 | 68 | beforeEach(() => { |
| 58 | 69 | vi.clearAllMocks(); |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+51-65| ... | ... | @@ -178,7 +178,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 178 | 178 | }), |
| 179 | 179 | ); |
| 180 | 180 | |
| 181 | it.effect("materializes message image attachments into stateDir and stores URL references", () => | |
| 181 | it.effect("stores message attachment references without mutating payloads", () => | |
| 182 | 182 | Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"))).pipe( |
| 183 | 183 | Effect.flatMap((stateDir) => |
| 184 | 184 | Effect.gen(function* () { |
| ... | ... | @@ -205,10 +205,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 205 | 205 | attachments: [ |
| 206 | 206 | { |
| 207 | 207 | type: "image", |
| 208 | id: "thread-attachments-att-1", | |
| 208 | 209 | name: "example.png", |
| 209 | 210 | mimeType: "image/png", |
| 210 | 211 | sizeBytes: 5, |
| 211 | dataUrl: "data:image/png;base64,SGVsbG8=", | |
| 212 | 212 | }, |
| 213 | 213 | ], |
| 214 | 214 | turnId: null, |
| ... | ... | @@ -232,21 +232,12 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 232 | 232 | assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ |
| 233 | 233 | { |
| 234 | 234 | type: "image", |
| 235 | id: "thread-attachments-att-1", | |
| 235 | 236 | name: "example.png", |
| 236 | 237 | mimeType: "image/png", |
| 237 | 238 | sizeBytes: 5, |
| 238 | dataUrl: "/attachments/thread-attachments/message-attachments-0.png", | |
| 239 | 239 | }, |
| 240 | 240 | ]); |
| 241 | ||
| 242 | const attachmentPath = path.join( | |
| 243 | stateDir, | |
| 244 | "attachments", | |
| 245 | "thread-attachments", | |
| 246 | "message-attachments-0.png", | |
| 247 | ); | |
| 248 | assert.equal(fs.existsSync(attachmentPath), true); | |
| 249 | assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("SGVsbG8=", "base64")); | |
| 250 | 241 | }).pipe( |
| 251 | 242 | (effect) => runWithProjectionPipelineLayer(stateDir, effect), |
| 252 | 243 | Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), |
| ... | ... | @@ -255,7 +246,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 255 | 246 | ), |
| 256 | 247 | ); |
| 257 | 248 | |
| 258 | it.effect("materializes only image data URLs and uses a safe extension whitelist", () => | |
| 249 | it.effect("preserves mixed image attachment metadata as-is", () => | |
| 259 | 250 | Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"))).pipe( |
| 260 | 251 | Effect.flatMap((stateDir) => |
| 261 | 252 | Effect.gen(function* () { |
| ... | ... | @@ -282,17 +273,17 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 282 | 273 | attachments: [ |
| 283 | 274 | { |
| 284 | 275 | type: "image", |
| 276 | id: "thread-attachments-safe-att-1", | |
| 285 | 277 | name: "untrusted.exe", |
| 286 | 278 | mimeType: "image/x-unknown", |
| 287 | 279 | sizeBytes: 5, |
| 288 | dataUrl: "data:image/x-unknown;base64,SGVsbG8=", | |
| 289 | 280 | }, |
| 290 | 281 | { |
| 291 | 282 | type: "image", |
| 283 | id: "thread-attachments-safe-att-2", | |
| 292 | 284 | name: "not-image.png", |
| 293 | 285 | mimeType: "image/png", |
| 294 | 286 | sizeBytes: 5, |
| 295 | dataUrl: "data:text/plain;base64,SGVsbG8=", | |
| 296 | 287 | }, |
| 297 | 288 | ], |
| 298 | 289 | turnId: null, |
| ... | ... | @@ -316,36 +307,19 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 316 | 307 | assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ |
| 317 | 308 | { |
| 318 | 309 | type: "image", |
| 310 | id: "thread-attachments-safe-att-1", | |
| 319 | 311 | name: "untrusted.exe", |
| 320 | 312 | mimeType: "image/x-unknown", |
| 321 | 313 | sizeBytes: 5, |
| 322 | dataUrl: "/attachments/thread-attachments-safe/message-attachments-safe-0.bin", | |
| 323 | 314 | }, |
| 324 | 315 | { |
| 325 | 316 | type: "image", |
| 317 | id: "thread-attachments-safe-att-2", | |
| 326 | 318 | name: "not-image.png", |
| 327 | 319 | mimeType: "image/png", |
| 328 | 320 | sizeBytes: 5, |
| 329 | dataUrl: "data:text/plain;base64,SGVsbG8=", | |
| 330 | 321 | }, |
| 331 | 322 | ]); |
| 332 | ||
| 333 | const firstAttachmentPath = path.join( | |
| 334 | stateDir, | |
| 335 | "attachments", | |
| 336 | "thread-attachments-safe", | |
| 337 | "message-attachments-safe-0.bin", | |
| 338 | ); | |
| 339 | assert.equal(fs.existsSync(firstAttachmentPath), true); | |
| 340 | assert.deepEqual(fs.readFileSync(firstAttachmentPath), Buffer.from("SGVsbG8=", "base64")); | |
| 341 | ||
| 342 | const secondAttachmentPath = path.join( | |
| 343 | stateDir, | |
| 344 | "attachments", | |
| 345 | "thread-attachments-safe", | |
| 346 | "message-attachments-safe-1.png", | |
| 347 | ); | |
| 348 | assert.equal(fs.existsSync(secondAttachmentPath), false); | |
| 349 | 323 | }).pipe( |
| 350 | 324 | (effect) => runWithProjectionPipelineLayer(stateDir, effect), |
| 351 | 325 | Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), |
| ... | ... | @@ -425,10 +399,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 425 | 399 | attachments: [ |
| 426 | 400 | { |
| 427 | 401 | type: "image", |
| 402 | id: "thread-clear-attachments-att-1", | |
| 428 | 403 | name: "clear.png", |
| 429 | 404 | mimeType: "image/png", |
| 430 | 405 | sizeBytes: 5, |
| 431 | dataUrl: "data:image/png;base64,SGVsbG8=", | |
| 432 | 406 | }, |
| 433 | 407 | ], |
| 434 | 408 | turnId: null, |
| ... | ... | @@ -477,17 +451,18 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 477 | 451 | ); |
| 478 | 452 | |
| 479 | 453 | it.effect( |
| 480 | "overwrites attachment file bytes when a message updates the same attachment index", | |
| 454 | "overwrites stored attachment references when a message updates attachments", | |
| 481 | 455 | () => |
| 482 | 456 | Effect.sync(() => |
| 483 | 457 | fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")), |
| 484 | 458 | ).pipe( |
| 485 | Effect.flatMap((stateDir) => | |
| 486 | Effect.gen(function* () { | |
| 487 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 488 | const eventStore = yield* OrchestrationEventStore; | |
| 489 | const now = new Date().toISOString(); | |
| 490 | const later = new Date(Date.now() + 1_000).toISOString(); | |
| 459 | Effect.flatMap((stateDir) => | |
| 460 | Effect.gen(function* () { | |
| 461 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | |
| 462 | const eventStore = yield* OrchestrationEventStore; | |
| 463 | const sql = yield* SqlClient.SqlClient; | |
| 464 | const now = new Date().toISOString(); | |
| 465 | const later = new Date(Date.now() + 1_000).toISOString(); | |
| 491 | 466 | |
| 492 | 467 | yield* eventStore.append({ |
| 493 | 468 | type: "project.created", |
| ... | ... | @@ -550,10 +525,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 550 | 525 | attachments: [ |
| 551 | 526 | { |
| 552 | 527 | type: "image", |
| 528 | id: "thread-overwrite-att-1", | |
| 553 | 529 | name: "file.png", |
| 554 | 530 | mimeType: "image/png", |
| 555 | 531 | sizeBytes: 5, |
| 556 | dataUrl: "data:image/png;base64,SGVsbG8=", | |
| 557 | 532 | }, |
| 558 | 533 | ], |
| 559 | 534 | turnId: null, |
| ... | ... | @@ -581,10 +556,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 581 | 556 | attachments: [ |
| 582 | 557 | { |
| 583 | 558 | type: "image", |
| 559 | id: "thread-overwrite-att-2", | |
| 584 | 560 | name: "file.png", |
| 585 | 561 | mimeType: "image/png", |
| 586 | 562 | sizeBytes: 5, |
| 587 | dataUrl: "data:image/png;base64,V29ybGQ=", | |
| 588 | 563 | }, |
| 589 | 564 | ], |
| 590 | 565 | turnId: null, |
| ... | ... | @@ -596,14 +571,23 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 596 | 571 | |
| 597 | 572 | yield* projectionPipeline.bootstrap; |
| 598 | 573 | |
| 599 | const attachmentPath = path.join( | |
| 600 | stateDir, | |
| 601 | "attachments", | |
| 602 | "thread-overwrite", | |
| 603 | "message-overwrite-0.png", | |
| 604 | ); | |
| 605 | assert.equal(fs.existsSync(attachmentPath), true); | |
| 606 | assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("V29ybGQ=", "base64")); | |
| 574 | const rows = yield* sql<{ | |
| 575 | readonly attachmentsJson: string | null; | |
| 576 | }>` | |
| 577 | SELECT attachments_json AS "attachmentsJson" | |
| 578 | FROM projection_thread_messages | |
| 579 | WHERE message_id = 'message-overwrite' | |
| 580 | `; | |
| 581 | assert.equal(rows.length, 1); | |
| 582 | assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [ | |
| 583 | { | |
| 584 | type: "image", | |
| 585 | id: "thread-overwrite-att-2", | |
| 586 | name: "file.png", | |
| 587 | mimeType: "image/png", | |
| 588 | sizeBytes: 5, | |
| 589 | }, | |
| 590 | ]); | |
| 607 | 591 | }).pipe( |
| 608 | 592 | (effect) => runWithProjectionPipelineLayer(stateDir, effect), |
| 609 | 593 | Effect.ensuring( |
| ... | ... | @@ -701,10 +685,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 701 | 685 | attachments: [ |
| 702 | 686 | { |
| 703 | 687 | type: "image", |
| 688 | id: "thread-rollback-att-1", | |
| 704 | 689 | name: "rollback.png", |
| 705 | 690 | mimeType: "image/png", |
| 706 | 691 | sizeBytes: 5, |
| 707 | dataUrl: "data:image/png;base64,SGVsbG8=", | |
| 708 | 692 | }, |
| 709 | 693 | ], |
| 710 | 694 | turnId: null, |
| ... | ... | @@ -728,8 +712,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 728 | 712 | const attachmentPath = path.join( |
| 729 | 713 | stateDir, |
| 730 | 714 | "attachments", |
| 731 | "thread-rollback", | |
| 732 | "message-rollback-0.png", | |
| 715 | "thread-rollback-att-1.png", | |
| 733 | 716 | ); |
| 734 | 717 | assert.equal(fs.existsSync(attachmentPath), false); |
| 735 | 718 | yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`; |
| ... | ... | @@ -839,10 +822,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 839 | 822 | attachments: [ |
| 840 | 823 | { |
| 841 | 824 | type: "image", |
| 825 | id: "thread-revert-files-keep", | |
| 842 | 826 | name: "keep.png", |
| 843 | 827 | mimeType: "image/png", |
| 844 | 828 | sizeBytes: 5, |
| 845 | dataUrl: "data:image/png;base64,SGVsbG8=", | |
| 846 | 829 | }, |
| 847 | 830 | ], |
| 848 | 831 | turnId: TurnId.makeUnsafe("turn-keep"), |
| ... | ... | @@ -892,10 +875,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 892 | 875 | attachments: [ |
| 893 | 876 | { |
| 894 | 877 | type: "image", |
| 878 | id: "thread-revert-files-remove", | |
| 895 | 879 | name: "remove.png", |
| 896 | 880 | mimeType: "image/png", |
| 897 | 881 | sizeBytes: 5, |
| 898 | dataUrl: "data:image/png;base64,V29ybGQ=", | |
| 899 | 882 | }, |
| 900 | 883 | ], |
| 901 | 884 | turnId: TurnId.makeUnsafe("turn-remove"), |
| ... | ... | @@ -908,15 +891,16 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 908 | 891 | const keepPath = path.join( |
| 909 | 892 | stateDir, |
| 910 | 893 | "attachments", |
| 911 | "thread-revert-files", | |
| 912 | "message-keep-0.png", | |
| 894 | "thread-revert-files-keep.png", | |
| 913 | 895 | ); |
| 914 | 896 | const removePath = path.join( |
| 915 | 897 | stateDir, |
| 916 | 898 | "attachments", |
| 917 | "thread-revert-files", | |
| 918 | "message-remove-0.png", | |
| 899 | "thread-revert-files-remove.png", | |
| 919 | 900 | ); |
| 901 | fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true }); | |
| 902 | fs.writeFileSync(keepPath, Buffer.from("keep")); | |
| 903 | fs.writeFileSync(removePath, Buffer.from("remove")); | |
| 920 | 904 | assert.equal(fs.existsSync(keepPath), true); |
| 921 | 905 | assert.equal(fs.existsSync(removePath), true); |
| 922 | 906 | |
| ... | ... | @@ -1022,10 +1006,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 1022 | 1006 | attachments: [ |
| 1023 | 1007 | { |
| 1024 | 1008 | type: "image", |
| 1009 | id: "thread-delete-files-att-1", | |
| 1025 | 1010 | name: "delete.png", |
| 1026 | 1011 | mimeType: "image/png", |
| 1027 | 1012 | sizeBytes: 5, |
| 1028 | dataUrl: "data:image/png;base64,SGVsbG8=", | |
| 1029 | 1013 | }, |
| 1030 | 1014 | ], |
| 1031 | 1015 | turnId: null, |
| ... | ... | @@ -1035,8 +1019,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 1035 | 1019 | }, |
| 1036 | 1020 | }); |
| 1037 | 1021 | |
| 1038 | const threadAttachmentDir = path.join(stateDir, "attachments", "thread-delete-files"); | |
| 1039 | assert.equal(fs.existsSync(threadAttachmentDir), true); | |
| 1022 | const threadAttachmentPath = path.join(stateDir, "attachments", "thread-delete-files-att-1.png"); | |
| 1023 | fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true }); | |
| 1024 | fs.writeFileSync(threadAttachmentPath, Buffer.from("delete")); | |
| 1025 | assert.equal(fs.existsSync(threadAttachmentPath), true); | |
| 1040 | 1026 | |
| 1041 | 1027 | yield* appendAndProject({ |
| 1042 | 1028 | type: "thread.deleted", |
| ... | ... | @@ -1054,7 +1040,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 1054 | 1040 | }, |
| 1055 | 1041 | }); |
| 1056 | 1042 | |
| 1057 | assert.equal(fs.existsSync(threadAttachmentDir), false); | |
| 1043 | assert.equal(fs.existsSync(threadAttachmentPath), false); | |
| 1058 | 1044 | }).pipe( |
| 1059 | 1045 | (effect) => runWithProjectionPipelineLayer(stateDir, effect), |
| 1060 | 1046 | Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))), |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+33-103| ... | ... | @@ -37,8 +37,7 @@ import { |
| 37 | 37 | OrchestrationProjectionPipeline, |
| 38 | 38 | type OrchestrationProjectionPipelineShape, |
| 39 | 39 | } from "../Services/ProjectionPipeline.ts"; |
| 40 | import { ATTACHMENTS_ROUTE_PREFIX, attachmentRouteToRelativePath } from "../../attachmentPaths.ts"; | |
| 41 | import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts"; | |
| 40 | import { attachmentRelativePath } from "../../attachmentStore.ts"; | |
| 42 | 41 | |
| 43 | 42 | export const ORCHESTRATION_PROJECTOR_NAMES = { |
| 44 | 43 | projects: "projection.projects", |
| ... | ... | @@ -62,13 +61,7 @@ interface ProjectorDefinition { |
| 62 | 61 | ) => Effect.Effect<void, ProjectionRepositoryError>; |
| 63 | 62 | } |
| 64 | 63 | |
| 65 | interface PendingAttachmentWrite { | |
| 66 | readonly absolutePath: string; | |
| 67 | readonly bytes: Uint8Array; | |
| 68 | } | |
| 69 | ||
| 70 | 64 | interface AttachmentSideEffects { |
| 71 | readonly writes: Array<PendingAttachmentWrite>; | |
| 72 | 65 | readonly deletedThreadIds: Set<string>; |
| 73 | 66 | readonly prunedThreadRelativePaths: Map<string, Set<string>>; |
| 74 | 67 | } |
| ... | ... | @@ -89,62 +82,10 @@ function toSafeThreadAttachmentSegment(threadId: string): string | null { |
| 89 | 82 | } |
| 90 | 83 | |
| 91 | 84 | const materializeAttachmentsForProjection = Effect.fn(function* (input: { |
| 92 | readonly threadId: string; | |
| 93 | readonly messageId: string; | |
| 94 | 85 | readonly attachments: ReadonlyArray<ChatAttachment>; |
| 95 | readonly stageFileWrite: (pendingWrite: PendingAttachmentWrite) => void; | |
| 96 | 86 | }) { |
| 97 | 87 | if (input.attachments.length === 0) return []; |
| 98 | ||
| 99 | const serverConfig = yield* Effect.service(ServerConfig); | |
| 100 | ||
| 101 | const path = yield* Path.Path; | |
| 102 | const attachmentsRootDir = path.join(serverConfig.stateDir, "attachments"); | |
| 103 | const threadSegment = toSafeThreadAttachmentSegment(input.threadId); | |
| 104 | if (!threadSegment) { | |
| 105 | yield* Effect.logWarning("skipping attachment materialization for unsafe thread id", { | |
| 106 | threadId: input.threadId, | |
| 107 | messageId: input.messageId, | |
| 108 | }); | |
| 109 | return input.attachments; | |
| 110 | } | |
| 111 | const messageSegment = encodeURIComponent(input.messageId); | |
| 112 | ||
| 113 | return yield* Effect.forEach( | |
| 114 | input.attachments, | |
| 115 | (attachment, index) => | |
| 116 | Effect.sync(() => { | |
| 117 | if (attachment.type !== "image" || !attachment.dataUrl.startsWith("data:")) { | |
| 118 | return attachment; | |
| 119 | } | |
| 120 | ||
| 121 | const parsed = parseBase64DataUrl(attachment.dataUrl); | |
| 122 | if (!parsed) { | |
| 123 | return attachment; | |
| 124 | } | |
| 125 | if (!parsed.mimeType.startsWith("image/")) { | |
| 126 | return attachment; | |
| 127 | } | |
| 128 | ||
| 129 | const bytes = Buffer.from(parsed.base64, "base64"); | |
| 130 | if (bytes.byteLength === 0) return attachment; | |
| 131 | ||
| 132 | const fileName = `${index}${inferImageExtension({ | |
| 133 | mimeType: parsed.mimeType, | |
| 134 | fileName: attachment.name, | |
| 135 | })}`; | |
| 136 | const uniqueFileName = `${messageSegment}-${fileName}`; | |
| 137 | const relativePath = `${threadSegment}/${uniqueFileName}`; | |
| 138 | const absolutePath = path.join(attachmentsRootDir, threadSegment, uniqueFileName); | |
| 139 | input.stageFileWrite({ absolutePath, bytes }); | |
| 140 | ||
| 141 | return { | |
| 142 | ...attachment, | |
| 143 | dataUrl: `${ATTACHMENTS_ROUTE_PREFIX}/${relativePath}`, | |
| 144 | } satisfies ChatAttachment; | |
| 145 | }), | |
| 146 | { concurrency: 1 }, | |
| 147 | ); | |
| 88 | return input.attachments; | |
| 148 | 89 | }); |
| 149 | 90 | |
| 150 | 91 | function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { |
| ... | ... | @@ -267,22 +208,16 @@ function collectThreadAttachmentRelativePaths( |
| 267 | 208 | if (!threadSegment) { |
| 268 | 209 | return new Set(); |
| 269 | 210 | } |
| 270 | const threadPrefix = `${threadSegment}/`; | |
| 271 | 211 | const relativePaths = new Set<string>(); |
| 272 | 212 | for (const message of messages) { |
| 273 | 213 | for (const attachment of message.attachments ?? []) { |
| 274 | 214 | if (attachment.type !== "image") { |
| 275 | 215 | continue; |
| 276 | 216 | } |
| 277 | const relativePath = attachmentRouteToRelativePath(attachment.dataUrl); | |
| 278 | if (!relativePath || !relativePath.startsWith(threadPrefix)) { | |
| 217 | if (!attachment.id.startsWith(`${threadSegment}-`)) { | |
| 279 | 218 | continue; |
| 280 | 219 | } |
| 281 | const threadRelativePath = relativePath.slice(threadPrefix.length).replace(/^[/\\]+/, ""); | |
| 282 | if (threadRelativePath.length === 0 || threadRelativePath.startsWith("..")) { | |
| 283 | continue; | |
| 284 | } | |
| 285 | relativePaths.add(threadRelativePath.replace(/\\/g, "/")); | |
| 220 | relativePaths.add(attachmentRelativePath(attachment)); | |
| 286 | 221 | } |
| 287 | 222 | } |
| 288 | 223 | return relativePaths; |
| ... | ... | @@ -306,8 +241,26 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid |
| 306 | 241 | }); |
| 307 | 242 | return; |
| 308 | 243 | } |
| 309 | const threadDir = path.join(attachmentsRootDir, threadSegment); | |
| 310 | yield* fileSystem.remove(threadDir, { recursive: true, force: true }); | |
| 244 | const entries = yield* fileSystem | |
| 245 | .readDirectory(attachmentsRootDir, { recursive: false }) | |
| 246 | .pipe(Effect.catch(() => Effect.succeed([] as Array<string>))); | |
| 247 | yield* Effect.forEach( | |
| 248 | entries, | |
| 249 | (entry) => | |
| 250 | Effect.gen(function* () { | |
| 251 | const normalizedEntry = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); | |
| 252 | if (normalizedEntry.length === 0 || normalizedEntry.includes("/")) { | |
| 253 | return; | |
| 254 | } | |
| 255 | if (!normalizedEntry.startsWith(`${threadSegment}-`)) { | |
| 256 | return; | |
| 257 | } | |
| 258 | yield* fileSystem.remove(path.join(attachmentsRootDir, normalizedEntry), { | |
| 259 | force: true, | |
| 260 | }); | |
| 261 | }), | |
| 262 | { concurrency: 1 }, | |
| 263 | ); | |
| 311 | 264 | }), |
| 312 | 265 | { concurrency: 1 }, |
| 313 | 266 | ); |
| ... | ... | @@ -324,20 +277,22 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid |
| 324 | 277 | yield* Effect.logWarning("skipping attachment prune for unsafe thread id", { threadId }); |
| 325 | 278 | return; |
| 326 | 279 | } |
| 327 | const threadDir = path.join(attachmentsRootDir, threadSegment); | |
| 328 | 280 | const entries = yield* fileSystem |
| 329 | .readDirectory(threadDir, { recursive: true }) | |
| 281 | .readDirectory(attachmentsRootDir, { recursive: false }) | |
| 330 | 282 | .pipe(Effect.catch(() => Effect.succeed([] as Array<string>))); |
| 331 | 283 | yield* Effect.forEach( |
| 332 | 284 | entries, |
| 333 | 285 | (entry) => |
| 334 | 286 | Effect.gen(function* () { |
| 335 | const threadRelativePath = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); | |
| 336 | if (threadRelativePath.length === 0 || threadRelativePath.startsWith("..")) { | |
| 287 | const relativePath = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); | |
| 288 | if (relativePath.length === 0 || relativePath.includes("/")) { | |
| 289 | return; | |
| 290 | } | |
| 291 | if (!relativePath.startsWith(`${threadSegment}-`)) { | |
| 337 | 292 | return; |
| 338 | 293 | } |
| 339 | 294 | |
| 340 | const absolutePath = path.join(threadDir, threadRelativePath); | |
| 295 | const absolutePath = path.join(attachmentsRootDir, relativePath); | |
| 341 | 296 | const fileInfo = yield* fileSystem |
| 342 | 297 | .stat(absolutePath) |
| 343 | 298 | .pipe(Effect.catch(() => Effect.succeed(null))); |
| ... | ... | @@ -345,7 +300,7 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid |
| 345 | 300 | return; |
| 346 | 301 | } |
| 347 | 302 | |
| 348 | if (!keptThreadRelativePaths.has(threadRelativePath)) { | |
| 303 | if (!keptThreadRelativePaths.has(relativePath)) { | |
| 349 | 304 | yield* fileSystem.remove(absolutePath, { force: true }); |
| 350 | 305 | } |
| 351 | 306 | }), |
| ... | ... | @@ -356,22 +311,6 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid |
| 356 | 311 | { concurrency: 1 }, |
| 357 | 312 | ); |
| 358 | 313 | |
| 359 | const writesByPath = new Map<string, Uint8Array>(); | |
| 360 | for (const pendingWrite of sideEffects.writes) { | |
| 361 | // Last write wins for a path so updated attachment contents replace stale bytes. | |
| 362 | writesByPath.set(pendingWrite.absolutePath, pendingWrite.bytes); | |
| 363 | } | |
| 364 | yield* Effect.forEach( | |
| 365 | writesByPath.entries(), | |
| 366 | ([absolutePath, bytes]) => | |
| 367 | Effect.gen(function* () { | |
| 368 | yield* fileSystem.makeDirectory(path.dirname(absolutePath), { | |
| 369 | recursive: true, | |
| 370 | }); | |
| 371 | yield* fileSystem.writeFile(absolutePath, bytes); | |
| 372 | }), | |
| 373 | { concurrency: 1 }, | |
| 374 | ); | |
| 375 | 314 | }); |
| 376 | 315 | |
| 377 | 316 | const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| ... | ... | @@ -589,16 +528,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 589 | 528 | const nextAttachments = |
| 590 | 529 | event.payload.attachments !== undefined |
| 591 | 530 | ? yield* materializeAttachmentsForProjection({ |
| 592 | threadId: event.payload.threadId, | |
| 593 | messageId: event.payload.messageId, | |
| 594 | 531 | attachments: event.payload.attachments, |
| 595 | stageFileWrite: (pendingWrite) => { | |
| 596 | attachmentSideEffects.writes.push(pendingWrite); | |
| 597 | }, | |
| 598 | }).pipe( | |
| 599 | Effect.provideService(ServerConfig, serverConfig), | |
| 600 | Effect.provideService(Path.Path, path), | |
| 601 | ) | |
| 532 | }) | |
| 602 | 533 | : existingMessage?.attachments; |
| 603 | 534 | yield* projectionThreadMessageRepository.upsert({ |
| 604 | 535 | messageId: event.payload.messageId, |
| ... | ... | @@ -1089,7 +1020,6 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 1089 | 1020 | const runProjectorForEvent = (projector: ProjectorDefinition, event: OrchestrationEvent) => |
| 1090 | 1021 | Effect.gen(function* () { |
| 1091 | 1022 | const attachmentSideEffects: AttachmentSideEffects = { |
| 1092 | writes: [], | |
| 1093 | 1023 | deletedThreadIds: new Set<string>(), |
| 1094 | 1024 | prunedThreadRelativePaths: new Map<string, Set<string>>(), |
| 1095 | 1025 | }; |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+7-21| ... | ... | @@ -258,10 +258,10 @@ describe("ProviderCommandReactor", () => { |
| 258 | 258 | attachments: [ |
| 259 | 259 | { |
| 260 | 260 | type: "image", |
| 261 | id: "thread-1-att-rename", | |
| 261 | 262 | name: "bug.png", |
| 262 | 263 | mimeType: "image/png", |
| 263 | 264 | sizeBytes: 5, |
| 264 | dataUrl: "data:image/png;base64,SGVsbG8=", | |
| 265 | 265 | }, |
| 266 | 266 | ], |
| 267 | 267 | }, |
| ... | ... | @@ -280,15 +280,10 @@ describe("ProviderCommandReactor", () => { |
| 280 | 280 | attachments: [ |
| 281 | 281 | { |
| 282 | 282 | type: "image", |
| 283 | id: "thread-1-att-rename", | |
| 283 | 284 | name: "bug.png", |
| 284 | 285 | mimeType: "image/png", |
| 285 | 286 | sizeBytes: 5, |
| 286 | dataUrl: path.join( | |
| 287 | harness.stateDir, | |
| 288 | "attachments", | |
| 289 | "thread-1", | |
| 290 | "user-message-worktree-rename-0.png", | |
| 291 | ), | |
| 292 | 287 | }, |
| 293 | 288 | ], |
| 294 | 289 | }); |
| ... | ... | @@ -307,9 +302,8 @@ describe("ProviderCommandReactor", () => { |
| 307 | 302 | }); |
| 308 | 303 | }); |
| 309 | 304 | |
| 310 | it("reuses persisted attachment files for branch generation and turn start", async () => { | |
| 311 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-attachments-")); | |
| 312 | const harness = await createHarness({ stateDir }); | |
| 305 | it("passes persisted attachment references to branch generation and turn start", async () => { | |
| 306 | const harness = await createHarness(); | |
| 313 | 307 | const now = new Date().toISOString(); |
| 314 | 308 | |
| 315 | 309 | await Effect.runPromise( |
| ... | ... | @@ -334,10 +328,10 @@ describe("ProviderCommandReactor", () => { |
| 334 | 328 | attachments: [ |
| 335 | 329 | { |
| 336 | 330 | type: "image", |
| 331 | id: "thread-1-att-persisted", | |
| 337 | 332 | name: "bug.png", |
| 338 | 333 | mimeType: "image/png", |
| 339 | 334 | sizeBytes: 5, |
| 340 | dataUrl: "data:image/png;base64,SGVsbG8=", | |
| 341 | 335 | }, |
| 342 | 336 | ], |
| 343 | 337 | }, |
| ... | ... | @@ -350,23 +344,16 @@ describe("ProviderCommandReactor", () => { |
| 350 | 344 | await waitFor(() => harness.generateBranchName.mock.calls.length === 1); |
| 351 | 345 | await waitFor(() => harness.sendTurn.mock.calls.length === 1); |
| 352 | 346 | |
| 353 | const expectedAttachmentPath = path.join( | |
| 354 | stateDir, | |
| 355 | "attachments", | |
| 356 | "thread-1", | |
| 357 | "user-message-persisted-attachments-0.png", | |
| 358 | ); | |
| 359 | ||
| 360 | 347 | expect(harness.generateBranchName.mock.calls[0]?.[0]).toEqual({ |
| 361 | 348 | cwd: "/tmp/provider-project/.t3/worktrees/t3code-abcdef12", |
| 362 | 349 | message: "Fix visual bug from screenshot", |
| 363 | 350 | attachments: [ |
| 364 | 351 | { |
| 365 | 352 | type: "image", |
| 353 | id: "thread-1-att-persisted", | |
| 366 | 354 | name: "bug.png", |
| 367 | 355 | mimeType: "image/png", |
| 368 | 356 | sizeBytes: 5, |
| 369 | dataUrl: expectedAttachmentPath, | |
| 370 | 357 | }, |
| 371 | 358 | ], |
| 372 | 359 | }); |
| ... | ... | @@ -374,14 +361,13 @@ describe("ProviderCommandReactor", () => { |
| 374 | 361 | attachments: [ |
| 375 | 362 | { |
| 376 | 363 | type: "image", |
| 364 | id: "thread-1-att-persisted", | |
| 377 | 365 | name: "bug.png", |
| 378 | 366 | mimeType: "image/png", |
| 379 | 367 | sizeBytes: 5, |
| 380 | dataUrl: "data:image/png;base64,SGVsbG8=", | |
| 381 | 368 | }, |
| 382 | 369 | ], |
| 383 | 370 | }); |
| 384 | expect(fs.existsSync(expectedAttachmentPath)).toBe(true); | |
| 385 | 371 | }); |
| 386 | 372 | |
| 387 | 373 | it("skips worktree branch generation after the first user turn", async () => { |
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+2-71| ... | ... | @@ -12,17 +12,11 @@ import { |
| 12 | 12 | type ProviderThreadId, |
| 13 | 13 | type TurnId, |
| 14 | 14 | } from "@t3tools/contracts"; |
| 15 | import { Cache, Cause, Duration, Effect, FileSystem, Layer, Option, Queue, Stream } from "effect"; | |
| 15 | import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect"; | |
| 16 | 16 | |
| 17 | 17 | import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts"; |
| 18 | import { | |
| 19 | resolveAttachmentRelativePath, | |
| 20 | resolveAttachmentRoutePath, | |
| 21 | } from "../../attachmentPaths.ts"; | |
| 22 | import { ServerConfig } from "../../config.ts"; | |
| 23 | 18 | import { GitCore } from "../../git/Services/GitCore.ts"; |
| 24 | 19 | import { TextGeneration } from "../../git/Services/TextGeneration.ts"; |
| 25 | import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts"; | |
| 26 | 20 | import { ProviderService } from "../../provider/Services/ProviderService.ts"; |
| 27 | 21 | import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; |
| 28 | 22 | import { |
| ... | ... | @@ -109,8 +103,6 @@ const make = Effect.gen(function* () { |
| 109 | 103 | const providerService = yield* ProviderService; |
| 110 | 104 | const git = yield* GitCore; |
| 111 | 105 | const textGeneration = yield* TextGeneration; |
| 112 | const fileSystem = yield* FileSystem.FileSystem; | |
| 113 | const serverConfig = yield* Effect.service(ServerConfig); | |
| 114 | 106 | const handledTurnStartKeys = yield* Cache.make<string, true>({ |
| 115 | 107 | capacity: HANDLED_TURN_START_KEY_MAX, |
| 116 | 108 | timeToLive: HANDLED_TURN_START_KEY_TTL, |
| ... | ... | @@ -291,61 +283,6 @@ const make = Effect.gen(function* () { |
| 291 | 283 | }); |
| 292 | 284 | }); |
| 293 | 285 | |
| 294 | const resolvePersistedAttachments = Effect.fnUntraced(function* (input: { | |
| 295 | readonly threadId: ThreadId; | |
| 296 | readonly messageId: string; | |
| 297 | readonly attachments: ReadonlyArray<ChatAttachment> | undefined; | |
| 298 | }) { | |
| 299 | if (!input.attachments || input.attachments.length === 0) { | |
| 300 | return input.attachments ?? []; | |
| 301 | } | |
| 302 | ||
| 303 | const threadSegment = encodeURIComponent(input.threadId); | |
| 304 | const messageSegment = encodeURIComponent(input.messageId); | |
| 305 | ||
| 306 | return yield* Effect.forEach(Array.from(input.attachments.entries()), ([index, attachment]) => | |
| 307 | Effect.gen(function* () { | |
| 308 | if (attachment.type !== "image") return attachment; | |
| 309 | ||
| 310 | const resolvedRoutePath = resolveAttachmentRoutePath({ | |
| 311 | stateDir: serverConfig.stateDir, | |
| 312 | dataUrl: attachment.dataUrl, | |
| 313 | }); | |
| 314 | const resolvedMaterializedPath = | |
| 315 | resolvedRoutePath ?? | |
| 316 | (() => { | |
| 317 | const parsed = parseBase64DataUrl(attachment.dataUrl); | |
| 318 | if (!parsed || !parsed.mimeType.startsWith("image/")) { | |
| 319 | return null; | |
| 320 | } | |
| 321 | const extension = inferImageExtension({ | |
| 322 | mimeType: parsed.mimeType, | |
| 323 | fileName: attachment.name, | |
| 324 | }); | |
| 325 | return resolveAttachmentRelativePath({ | |
| 326 | stateDir: serverConfig.stateDir, | |
| 327 | relativePath: `${threadSegment}/${messageSegment}-${index}${extension}`, | |
| 328 | }); | |
| 329 | })(); | |
| 330 | ||
| 331 | if (!resolvedMaterializedPath) { | |
| 332 | return attachment; | |
| 333 | } | |
| 334 | ||
| 335 | const fileInfo = yield* fileSystem | |
| 336 | .stat(resolvedMaterializedPath) | |
| 337 | .pipe(Effect.catch(() => Effect.succeed(null))); | |
| 338 | if (!fileInfo || fileInfo.type !== "File") { | |
| 339 | return attachment; | |
| 340 | } | |
| 341 | return { | |
| 342 | ...attachment, | |
| 343 | dataUrl: resolvedMaterializedPath, | |
| 344 | } satisfies ChatAttachment; | |
| 345 | }), | |
| 346 | ); | |
| 347 | }); | |
| 348 | ||
| 349 | 286 | const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced(function* (input: { |
| 350 | 287 | readonly threadId: ThreadId; |
| 351 | 288 | readonly branch: string | null; |
| ... | ... | @@ -440,19 +377,13 @@ const make = Effect.gen(function* () { |
| 440 | 377 | return; |
| 441 | 378 | } |
| 442 | 379 | |
| 443 | const branchGenerationAttachments = yield* resolvePersistedAttachments({ | |
| 444 | threadId: event.payload.threadId, | |
| 445 | messageId: message.id, | |
| 446 | attachments: message.attachments, | |
| 447 | }); | |
| 448 | ||
| 449 | 380 | yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({ |
| 450 | 381 | threadId: event.payload.threadId, |
| 451 | 382 | branch: thread.branch, |
| 452 | 383 | worktreePath: thread.worktreePath, |
| 453 | 384 | messageId: message.id, |
| 454 | 385 | messageText: message.text, |
| 455 | ...(message.attachments !== undefined ? { attachments: branchGenerationAttachments } : {}), | |
| 386 | ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), | |
| 456 | 387 | }).pipe(Effect.forkScoped); |
| 457 | 388 | |
| 458 | 389 | yield* sendTurnForThread({ |
apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts+2-2| ... | ... | @@ -21,10 +21,10 @@ layer("ProjectionThreadMessageRepository", (it) => { |
| 21 | 21 | const persistedAttachments = [ |
| 22 | 22 | { |
| 23 | 23 | type: "image" as const, |
| 24 | id: "thread-preserve-attachments-att-1", | |
| 24 | 25 | name: "example.png", |
| 25 | 26 | mimeType: "image/png", |
| 26 | 27 | sizeBytes: 5, |
| 27 | dataUrl: "/attachments/thread-preserve-attachments/message-preserve-attachments/0.png", | |
| 28 | 28 | }, |
| 29 | 29 | ]; |
| 30 | 30 | |
| ... | ... | @@ -74,10 +74,10 @@ layer("ProjectionThreadMessageRepository", (it) => { |
| 74 | 74 | attachments: [ |
| 75 | 75 | { |
| 76 | 76 | type: "image", |
| 77 | id: "thread-clear-attachments-att-1", | |
| 77 | 78 | name: "example.png", |
| 78 | 79 | mimeType: "image/png", |
| 79 | 80 | sizeBytes: 5, |
| 80 | dataUrl: "/attachments/thread-clear-attachments/message-clear-attachments/0.png", | |
| 81 | 81 | }, |
| 82 | 82 | ], |
| 83 | 83 | isStreaming: false, |
apps/server/src/provider/Layers/CodexAdapter.test.ts+27-8| ... | ... | @@ -4,7 +4,6 @@ import { |
| 4 | 4 | ProviderItemId, |
| 5 | 5 | type ProviderApprovalDecision, |
| 6 | 6 | type ProviderEvent, |
| 7 | type ProviderSendTurnInput, | |
| 8 | 7 | ProviderSessionId, |
| 9 | 8 | type ProviderSession, |
| 10 | 9 | type ProviderSessionStartInput, |
| ... | ... | @@ -12,12 +11,17 @@ import { |
| 12 | 11 | ProviderTurnId, |
| 13 | 12 | type ProviderTurnStartResult, |
| 14 | 13 | } from "@t3tools/contracts"; |
| 14 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 15 | 15 | import { afterAll, assert, it, vi } from "@effect/vitest"; |
| 16 | 16 | import { assertFailure } from "@effect/vitest/utils"; |
| 17 | 17 | |
| 18 | import { Effect, Fiber, Stream } from "effect"; | |
| 18 | import { Effect, Fiber, Layer, Stream } from "effect"; | |
| 19 | 19 | |
| 20 | import { CodexAppServerManager } from "../../codexAppServerManager.ts"; | |
| 20 | import { | |
| 21 | CodexAppServerManager, | |
| 22 | type CodexAppServerSendTurnInput, | |
| 23 | } from "../../codexAppServerManager.ts"; | |
| 24 | import { ServerConfig } from "../../config.ts"; | |
| 21 | 25 | import { ProviderAdapterValidationError } from "../Errors.ts"; |
| 22 | 26 | import { CodexAdapter } from "../Services/CodexAdapter.ts"; |
| 23 | 27 | import { makeCodexAdapterLive } from "./CodexAdapter.ts"; |
| ... | ... | @@ -44,7 +48,7 @@ class FakeCodexManager extends CodexAppServerManager { |
| 44 | 48 | ); |
| 45 | 49 | |
| 46 | 50 | public sendTurnImpl = vi.fn( |
| 47 | async (_input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> => ({ | |
| 51 | async (_input: CodexAppServerSendTurnInput): Promise<ProviderTurnStartResult> => ({ | |
| 48 | 52 | threadId: ProviderThreadId.makeUnsafe("thread-1"), |
| 49 | 53 | turnId: asTurnId("turn-1"), |
| 50 | 54 | }), |
| ... | ... | @@ -78,7 +82,7 @@ class FakeCodexManager extends CodexAppServerManager { |
| 78 | 82 | return this.startSessionImpl(input); |
| 79 | 83 | } |
| 80 | 84 | |
| 81 | override sendTurn(input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> { | |
| 85 | override sendTurn(input: CodexAppServerSendTurnInput): Promise<ProviderTurnStartResult> { | |
| 82 | 86 | return this.sendTurnImpl(input); |
| 83 | 87 | } |
| 84 | 88 | |
| ... | ... | @@ -118,7 +122,12 @@ class FakeCodexManager extends CodexAppServerManager { |
| 118 | 122 | } |
| 119 | 123 | |
| 120 | 124 | const validationManager = new FakeCodexManager(); |
| 121 | const validationLayer = it.layer(makeCodexAdapterLive({ manager: validationManager })); | |
| 125 | const validationLayer = it.layer( | |
| 126 | makeCodexAdapterLive({ manager: validationManager }).pipe( | |
| 127 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | |
| 128 | Layer.provideMerge(NodeServices.layer), | |
| 129 | ), | |
| 130 | ); | |
| 122 | 131 | |
| 123 | 132 | validationLayer("CodexAdapterLive validation", (it) => { |
| 124 | 133 | it.effect("returns validation error for non-codex provider on startSession", () => |
| ... | ... | @@ -147,7 +156,12 @@ const sessionErrorManager = new FakeCodexManager(); |
| 147 | 156 | sessionErrorManager.sendTurnImpl.mockImplementation(async () => { |
| 148 | 157 | throw new Error("Unknown session: sess-missing"); |
| 149 | 158 | }); |
| 150 | const sessionErrorLayer = it.layer(makeCodexAdapterLive({ manager: sessionErrorManager })); | |
| 159 | const sessionErrorLayer = it.layer( | |
| 160 | makeCodexAdapterLive({ manager: sessionErrorManager }).pipe( | |
| 161 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | |
| 162 | Layer.provideMerge(NodeServices.layer), | |
| 163 | ), | |
| 164 | ); | |
| 151 | 165 | |
| 152 | 166 | sessionErrorLayer("CodexAdapterLive session errors", (it) => { |
| 153 | 167 | it.effect("maps unknown-session sendTurn errors to ProviderAdapterSessionNotFoundError", () => |
| ... | ... | @@ -178,7 +192,12 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { |
| 178 | 192 | }); |
| 179 | 193 | |
| 180 | 194 | const lifecycleManager = new FakeCodexManager(); |
| 181 | const lifecycleLayer = it.layer(makeCodexAdapterLive({ manager: lifecycleManager })); | |
| 195 | const lifecycleLayer = it.layer( | |
| 196 | makeCodexAdapterLive({ manager: lifecycleManager }).pipe( | |
| 197 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), | |
| 198 | Layer.provideMerge(NodeServices.layer), | |
| 199 | ), | |
| 200 | ); | |
| 182 | 201 | |
| 183 | 202 | lifecycleLayer("CodexAdapterLive lifecycle", (it) => { |
| 184 | 203 | it.effect("maps completed agent message items to canonical message.completed events", () => |
apps/server/src/provider/Layers/CodexAdapter.ts+45-4| ... | ... | @@ -17,7 +17,7 @@ import { |
| 17 | 17 | ProviderThreadId, |
| 18 | 18 | ProviderTurnId, |
| 19 | 19 | } from "@t3tools/contracts"; |
| 20 | import { Effect, Layer, Queue, Schema, Stream } from "effect"; | |
| 20 | import { Effect, FileSystem, Layer, Queue, Schema, Stream } from "effect"; | |
| 21 | 21 | |
| 22 | 22 | import { |
| 23 | 23 | ProviderAdapterProcessError, |
| ... | ... | @@ -29,6 +29,8 @@ import { |
| 29 | 29 | } from "../Errors.ts"; |
| 30 | 30 | import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts"; |
| 31 | 31 | import { CodexAppServerManager } from "../../codexAppServerManager.ts"; |
| 32 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | |
| 33 | import { ServerConfig } from "../../config.ts"; | |
| 32 | 34 | import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 33 | 35 | |
| 34 | 36 | const PROVIDER = "codex" as const; |
| ... | ... | @@ -426,6 +428,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime |
| 426 | 428 | |
| 427 | 429 | const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 428 | 430 | Effect.gen(function* () { |
| 431 | const fileSystem = yield* FileSystem.FileSystem; | |
| 432 | const serverConfig = yield* Effect.service(ServerConfig); | |
| 429 | 433 | const nativeEventLogger = |
| 430 | 434 | options?.nativeEventLogPath !== undefined |
| 431 | 435 | ? makeEventNdjsonLogger(options.nativeEventLogPath) |
| ... | ... | @@ -475,9 +479,46 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) => |
| 475 | 479 | }; |
| 476 | 480 | |
| 477 | 481 | const sendTurn: CodexAdapterShape["sendTurn"] = (input) => |
| 478 | Effect.tryPromise({ | |
| 479 | try: () => manager.sendTurn(input), | |
| 480 | catch: (cause) => toRequestError(input.sessionId, "turn/start", cause), | |
| 482 | Effect.gen(function* () { | |
| 483 | const codexAttachments = yield* Effect.forEach( | |
| 484 | input.attachments ?? [], | |
| 485 | (attachment) => | |
| 486 | Effect.gen(function* () { | |
| 487 | const attachmentPath = resolveAttachmentPath({ | |
| 488 | stateDir: serverConfig.stateDir, | |
| 489 | attachment, | |
| 490 | }); | |
| 491 | if (!attachmentPath) { | |
| 492 | return yield* toRequestError( | |
| 493 | input.sessionId, | |
| 494 | "turn/start", | |
| 495 | new Error(`Invalid attachment id '${attachment.id}'.`), | |
| 496 | ); | |
| 497 | } | |
| 498 | const bytes = yield* fileSystem.readFile(attachmentPath).pipe( | |
| 499 | Effect.mapError((cause) => toRequestError(input.sessionId, "turn/start", cause)), | |
| 500 | ); | |
| 501 | return { | |
| 502 | type: "image" as const, | |
| 503 | url: `data:${attachment.mimeType};base64,${Buffer.from(bytes).toString("base64")}`, | |
| 504 | }; | |
| 505 | }), | |
| 506 | { concurrency: 1 }, | |
| 507 | ); | |
| 508 | ||
| 509 | return yield* Effect.tryPromise({ | |
| 510 | try: () => { | |
| 511 | const managerInput = { | |
| 512 | sessionId: input.sessionId, | |
| 513 | ...(input.input !== undefined ? { input: input.input } : {}), | |
| 514 | ...(input.model !== undefined ? { model: input.model } : {}), | |
| 515 | ...(input.effort !== undefined ? { effort: input.effort } : {}), | |
| 516 | ...(codexAttachments.length > 0 ? { attachments: codexAttachments } : {}), | |
| 517 | }; | |
| 518 | return manager.sendTurn(managerInput); | |
| 519 | }, | |
| 520 | catch: (cause) => toRequestError(input.sessionId, "turn/start", cause), | |
| 521 | }); | |
| 481 | 522 | }); |
| 482 | 523 | |
| 483 | 524 | const interruptTurn: CodexAdapterShape["interruptTurn"] = (sessionId, turnId) => |
apps/server/src/serverLayers.ts+2-2| ... | ... | @@ -1,7 +1,7 @@ |
| 1 | 1 | import path from "node:path"; |
| 2 | 2 | |
| 3 | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { Effect, Layer } from "effect"; | |
| 4 | import { Effect, FileSystem, Layer } from "effect"; | |
| 5 | 5 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 6 | 6 | |
| 7 | 7 | import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery"; |
| ... | ... | @@ -37,7 +37,7 @@ import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY"; |
| 37 | 37 | export function makeServerProviderLayer(): Layer.Layer< |
| 38 | 38 | ProviderService, |
| 39 | 39 | ProviderUnsupportedError, |
| 40 | SqlClient.SqlClient | ServerConfig | |
| 40 | SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem | |
| 41 | 41 | > { |
| 42 | 42 | return Effect.gen(function* () { |
| 43 | 43 | const { stateDir } = yield* ServerConfig; |
apps/server/src/wsServer.test.ts+1| ... | ... | @@ -420,6 +420,7 @@ describe("WebSocket Server", () => { |
| 420 | 420 | Layer.provideMerge(runtimeLayer), |
| 421 | 421 | Layer.provideMerge(openLayer), |
| 422 | 422 | Layer.provideMerge(serverConfigLayer), |
| 423 | Layer.provideMerge(NodeServices.layer), | |
| 423 | 424 | ); |
| 424 | 425 | const runtimeServices = await Effect.runPromise( |
| 425 | 426 | Layer.build(dependenciesLayer).pipe(Scope.provide(scope)), |
apps/server/src/wsServer.ts+99-5| ... | ... | @@ -12,8 +12,11 @@ import type { Duplex } from "node:stream"; |
| 12 | 12 | import Mime from "@effect/platform-node/Mime"; |
| 13 | 13 | import { |
| 14 | 14 | CommandId, |
| 15 | type ClientOrchestrationCommand, | |
| 16 | type OrchestrationCommand, | |
| 15 | 17 | ORCHESTRATION_WS_CHANNELS, |
| 16 | 18 | ORCHESTRATION_WS_METHODS, |
| 19 | PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, | |
| 17 | 20 | ProjectId, |
| 18 | 21 | ThreadId, |
| 19 | 22 | TerminalEvent, |
| ... | ... | @@ -60,6 +63,8 @@ import { |
| 60 | 63 | normalizeAttachmentRelativePath, |
| 61 | 64 | resolveAttachmentRelativePath, |
| 62 | 65 | } from "./attachmentPaths"; |
| 66 | import { createAttachmentId, resolveAttachmentPath, resolveAttachmentPathById } from "./attachmentStore.ts"; | |
| 67 | import { parseBase64DataUrl } from "./imageMime.ts"; | |
| 63 | 68 | |
| 64 | 69 | /** |
| 65 | 70 | * ServerShape - Service API for server lifecycle control. |
| ... | ... | @@ -235,6 +240,88 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 235 | 240 | }); |
| 236 | 241 | }); |
| 237 | 242 | |
| 243 | const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { | |
| 244 | readonly command: ClientOrchestrationCommand; | |
| 245 | }) { | |
| 246 | if (input.command.type !== "thread.turn.start") { | |
| 247 | return input.command as OrchestrationCommand; | |
| 248 | } | |
| 249 | const turnStartCommand = input.command; | |
| 250 | ||
| 251 | const normalizedAttachments = yield* Effect.forEach( | |
| 252 | turnStartCommand.message.attachments, | |
| 253 | (attachment) => | |
| 254 | Effect.gen(function* () { | |
| 255 | const parsed = parseBase64DataUrl(attachment.dataUrl); | |
| 256 | if (!parsed || !parsed.mimeType.startsWith("image/")) { | |
| 257 | return yield* new RouteRequestError({ | |
| 258 | message: `Invalid image attachment payload for '${attachment.name}'.`, | |
| 259 | }); | |
| 260 | } | |
| 261 | ||
| 262 | const bytes = Buffer.from(parsed.base64, "base64"); | |
| 263 | if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { | |
| 264 | return yield* new RouteRequestError({ | |
| 265 | message: `Image attachment '${attachment.name}' is empty or too large.`, | |
| 266 | }); | |
| 267 | } | |
| 268 | ||
| 269 | const attachmentId = createAttachmentId(turnStartCommand.threadId); | |
| 270 | if (!attachmentId) { | |
| 271 | return yield* new RouteRequestError({ | |
| 272 | message: "Failed to create a safe attachment id.", | |
| 273 | }); | |
| 274 | } | |
| 275 | ||
| 276 | const persistedAttachment = { | |
| 277 | type: "image" as const, | |
| 278 | id: attachmentId, | |
| 279 | name: attachment.name, | |
| 280 | mimeType: parsed.mimeType.toLowerCase(), | |
| 281 | sizeBytes: bytes.byteLength, | |
| 282 | }; | |
| 283 | ||
| 284 | const attachmentPath = resolveAttachmentPath({ | |
| 285 | stateDir: serverConfig.stateDir, | |
| 286 | attachment: persistedAttachment, | |
| 287 | }); | |
| 288 | if (!attachmentPath) { | |
| 289 | return yield* new RouteRequestError({ | |
| 290 | message: `Failed to resolve persisted path for '${attachment.name}'.`, | |
| 291 | }); | |
| 292 | } | |
| 293 | ||
| 294 | yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe( | |
| 295 | Effect.mapError( | |
| 296 | () => | |
| 297 | new RouteRequestError({ | |
| 298 | message: `Failed to create attachment directory for '${attachment.name}'.`, | |
| 299 | }), | |
| 300 | ), | |
| 301 | ); | |
| 302 | yield* fileSystem.writeFile(attachmentPath, bytes).pipe( | |
| 303 | Effect.mapError( | |
| 304 | () => | |
| 305 | new RouteRequestError({ | |
| 306 | message: `Failed to persist attachment '${attachment.name}'.`, | |
| 307 | }), | |
| 308 | ), | |
| 309 | ); | |
| 310 | ||
| 311 | return persistedAttachment; | |
| 312 | }), | |
| 313 | { concurrency: 1 }, | |
| 314 | ); | |
| 315 | ||
| 316 | return { | |
| 317 | ...turnStartCommand, | |
| 318 | message: { | |
| 319 | ...turnStartCommand.message, | |
| 320 | attachments: normalizedAttachments, | |
| 321 | }, | |
| 322 | } satisfies OrchestrationCommand; | |
| 323 | }); | |
| 324 | ||
| 238 | 325 | // HTTP server — serves static files or redirects to Vite dev server |
| 239 | 326 | const httpServer = http.createServer((req, res) => { |
| 240 | 327 | const respond = ( |
| ... | ... | @@ -261,10 +348,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 261 | 348 | return; |
| 262 | 349 | } |
| 263 | 350 | |
| 264 | const filePath = resolveAttachmentRelativePath({ | |
| 265 | stateDir: serverConfig.stateDir, | |
| 266 | relativePath: normalizedRelativePath, | |
| 267 | }); | |
| 351 | const filePath = | |
| 352 | !normalizedRelativePath.includes("/") && !normalizedRelativePath.includes(".") | |
| 353 | ? resolveAttachmentPathById({ | |
| 354 | stateDir: serverConfig.stateDir, | |
| 355 | attachmentId: normalizedRelativePath, | |
| 356 | }) | |
| 357 | : resolveAttachmentRelativePath({ | |
| 358 | stateDir: serverConfig.stateDir, | |
| 359 | relativePath: normalizedRelativePath, | |
| 360 | }); | |
| 268 | 361 | if (!filePath) { |
| 269 | 362 | respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path"); |
| 270 | 363 | return; |
| ... | ... | @@ -508,7 +601,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 508 | 601 | |
| 509 | 602 | case ORCHESTRATION_WS_METHODS.dispatchCommand: { |
| 510 | 603 | const { command } = request.body; |
| 511 | return yield* orchestrationEngine.dispatch(command); | |
| 604 | const normalizedCommand = yield* normalizeDispatchCommand({ command }); | |
| 605 | return yield* orchestrationEngine.dispatch(normalizedCommand); | |
| 512 | 606 | } |
| 513 | 607 | |
| 514 | 608 | case ORCHESTRATION_WS_METHODS.getTurnDiff: { |
apps/web/src/store.ts+7-3| ... | ... | @@ -229,6 +229,10 @@ function toAttachmentPreviewUrl(rawUrl: string): string { |
| 229 | 229 | return rawUrl; |
| 230 | 230 | } |
| 231 | 231 | |
| 232 | function attachmentPreviewRoutePath(attachmentId: string): string { | |
| 233 | return `/attachments/${encodeURIComponent(attachmentId)}`; | |
| 234 | } | |
| 235 | ||
| 232 | 236 | function normalizeTerminalIds(terminalIds: string[]): string[] { |
| 233 | 237 | const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0); |
| 234 | 238 | const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT); |
| ... | ... | @@ -487,13 +491,13 @@ export function reducer(state: AppState, action: Action): AppState { |
| 487 | 491 | } |
| 488 | 492 | : null, |
| 489 | 493 | messages: thread.messages.map((message) => { |
| 490 | const attachments = message.attachments?.map((attachment, index) => ({ | |
| 494 | const attachments = message.attachments?.map((attachment) => ({ | |
| 491 | 495 | type: "image" as const, |
| 492 | id: `${message.id}:${index}`, | |
| 496 | id: attachment.id, | |
| 493 | 497 | name: attachment.name, |
| 494 | 498 | mimeType: attachment.mimeType, |
| 495 | 499 | sizeBytes: attachment.sizeBytes, |
| 496 | previewUrl: toAttachmentPreviewUrl(attachment.dataUrl), | |
| 500 | previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)), | |
| 497 | 501 | })); |
| 498 | 502 | const normalizedMessage: ChatMessage = { |
| 499 | 503 | id: message.id, |
packages/contracts/src/orchestration.ts+55-3| ... | ... | @@ -60,12 +60,28 @@ export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; |
| 60 | 60 | export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; |
| 61 | 61 | export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; |
| 62 | 62 | export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; |
| 63 | export const CHAT_ATTACHMENT_ID_MAX_CHARS = 128; | |
| 63 | 64 | |
| 64 | 65 | // Correlation id is command id by design in this model. |
| 65 | 66 | export const CorrelationId = CommandId; |
| 66 | 67 | export type CorrelationId = typeof CorrelationId.Type; |
| 67 | 68 | |
| 69 | export const ChatAttachmentId = TrimmedNonEmptyString.check( | |
| 70 | Schema.isMaxLength(CHAT_ATTACHMENT_ID_MAX_CHARS), | |
| 71 | Schema.isPattern(/^[a-z0-9_-]+$/i), | |
| 72 | ); | |
| 73 | export type ChatAttachmentId = typeof ChatAttachmentId.Type; | |
| 74 | ||
| 68 | 75 | export const ChatImageAttachment = Schema.Struct({ |
| 76 | type: Schema.Literal("image"), | |
| 77 | id: ChatAttachmentId, | |
| 78 | name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), | |
| 79 | mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)), | |
| 80 | sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)), | |
| 81 | }); | |
| 82 | export type ChatImageAttachment = typeof ChatImageAttachment.Type; | |
| 83 | ||
| 84 | export const UploadChatImageAttachment = Schema.Struct({ | |
| 69 | 85 | type: Schema.Literal("image"), |
| 70 | 86 | name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), |
| 71 | 87 | mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)), |
| ... | ... | @@ -74,10 +90,12 @@ export const ChatImageAttachment = Schema.Struct({ |
| 74 | 90 | Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), |
| 75 | 91 | ), |
| 76 | 92 | }); |
| 77 | export type ChatImageAttachment = typeof ChatImageAttachment.Type; | |
| 93 | export type UploadChatImageAttachment = typeof UploadChatImageAttachment.Type; | |
| 78 | 94 | |
| 79 | 95 | export const ChatAttachment = Schema.Union([ChatImageAttachment]); |
| 80 | 96 | export type ChatAttachment = typeof ChatAttachment.Type; |
| 97 | export const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]); | |
| 98 | export type UploadChatAttachment = typeof UploadChatAttachment.Type; | |
| 81 | 99 | |
| 82 | 100 | export const ProjectScriptIcon = Schema.Literals([ |
| 83 | 101 | "play", |
| ... | ... | @@ -307,6 +325,24 @@ export const ThreadTurnStartCommand = Schema.Struct({ |
| 307 | 325 | createdAt: IsoDateTime, |
| 308 | 326 | }); |
| 309 | 327 | |
| 328 | export const ClientThreadTurnStartCommand = Schema.Struct({ | |
| 329 | type: Schema.Literal("thread.turn.start"), | |
| 330 | commandId: CommandId, | |
| 331 | threadId: ThreadId, | |
| 332 | message: Schema.Struct({ | |
| 333 | messageId: MessageId, | |
| 334 | role: Schema.Literal("user"), | |
| 335 | text: Schema.String, | |
| 336 | attachments: Schema.Array(UploadChatAttachment), | |
| 337 | }), | |
| 338 | model: Schema.optional(TrimmedNonEmptyString), | |
| 339 | effort: Schema.optional(TrimmedNonEmptyString), | |
| 340 | assistantDeliveryMode: Schema.optional(AssistantDeliveryMode), | |
| 341 | approvalPolicy: ProviderApprovalPolicy, | |
| 342 | sandboxMode: ProviderSandboxMode, | |
| 343 | createdAt: IsoDateTime, | |
| 344 | }); | |
| 345 | ||
| 310 | 346 | export const ThreadTurnInterruptCommand = Schema.Struct({ |
| 311 | 347 | type: Schema.Literal("thread.turn.interrupt"), |
| 312 | 348 | commandId: CommandId, |
| ... | ... | @@ -339,7 +375,7 @@ export const ThreadSessionStopCommand = Schema.Struct({ |
| 339 | 375 | createdAt: IsoDateTime, |
| 340 | 376 | }); |
| 341 | 377 | |
| 342 | export const ClientOrchestrationCommand = Schema.Union([ | |
| 378 | export const DispatchableClientOrchestrationCommand = Schema.Union([ | |
| 343 | 379 | ProjectCreateCommand, |
| 344 | 380 | ProjectMetaUpdateCommand, |
| 345 | 381 | ProjectDeleteCommand, |
| ... | ... | @@ -352,6 +388,22 @@ export const ClientOrchestrationCommand = Schema.Union([ |
| 352 | 388 | ThreadCheckpointRevertCommand, |
| 353 | 389 | ThreadSessionStopCommand, |
| 354 | 390 | ]); |
| 391 | export type DispatchableClientOrchestrationCommand = | |
| 392 | typeof DispatchableClientOrchestrationCommand.Type; | |
| 393 | ||
| 394 | export const ClientOrchestrationCommand = Schema.Union([ | |
| 395 | ProjectCreateCommand, | |
| 396 | ProjectMetaUpdateCommand, | |
| 397 | ProjectDeleteCommand, | |
| 398 | ThreadCreateCommand, | |
| 399 | ThreadDeleteCommand, | |
| 400 | ThreadMetaUpdateCommand, | |
| 401 | ClientThreadTurnStartCommand, | |
| 402 | ThreadTurnInterruptCommand, | |
| 403 | ThreadApprovalRespondCommand, | |
| 404 | ThreadCheckpointRevertCommand, | |
| 405 | ThreadSessionStopCommand, | |
| 406 | ]); | |
| 355 | 407 | export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type; |
| 356 | 408 | |
| 357 | 409 | export const ThreadSessionSetCommand = Schema.Struct({ |
| ... | ... | @@ -422,7 +474,7 @@ export const InternalOrchestrationCommand = Schema.Union([ |
| 422 | 474 | export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; |
| 423 | 475 | |
| 424 | 476 | export const OrchestrationCommand = Schema.Union([ |
| 425 | ClientOrchestrationCommand, | |
| 477 | DispatchableClientOrchestrationCommand, | |
| 426 | 478 | InternalOrchestrationCommand, |
| 427 | 479 | ]); |
| 428 | 480 | export type OrchestrationCommand = typeof OrchestrationCommand.Type; |