authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-01 01:42:02-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 20:06:21-08:00
logc0054cb56f1439c7bfda7771f919bdb7f948bab7
tree8148227ca4aa09e7121c259e5564650c926a9b80
parent41734dfbbfd47d2d9639dfbdd0912bb623e51403

Switch image attachments to persisted ID-based paths

- 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 IDs

18 files changed, 499 insertions(+), 407 deletions(-)

apps/server/src/attachmentStore.ts created+95
......@@ -0,0 +1,95 @@
1import { randomUUID } from "node:crypto";
2
3import type { ChatAttachment } from "@t3tools/contracts";
4
5import {
6 ATTACHMENTS_ROUTE_PREFIX,
7 normalizeAttachmentRelativePath,
8 resolveAttachmentRelativePath,
9} from "./attachmentPaths.ts";
10import { inferImageExtension, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts";
11
12const ATTACHMENT_FILENAME_EXTENSIONS = [...SAFE_IMAGE_FILE_EXTENSIONS, ".bin"];
13
14function 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
29export function createAttachmentId(threadId: string): string | null {
30 const threadSegment = toSafeThreadAttachmentSegment(threadId);
31 if (!threadSegment) {
32 return null;
33 }
34 return `${threadSegment}-${randomUUID()}`;
35}
36
37export 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
49export 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
59export function attachmentRoutePath(attachment: ChatAttachment): string {
60 return `${ATTACHMENTS_ROUTE_PREFIX}/${encodeURIComponent(attachment.id)}`;
61}
62
63export 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
83export 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", () => {
191191 attachments: [
192192 {
193193 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",
198195 },
199196 ],
200197 model: "gpt-5.3",
......@@ -242,10 +239,7 @@ describe("sendTurn", () => {
242239 attachments: [
243240 {
244241 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",
249243 },
250244 ],
251245 });
apps/server/src/codexAppServerManager.ts+10-3
......@@ -14,7 +14,6 @@ import {
1414 normalizeModelSlug,
1515 type ProviderApprovalDecision,
1616 type ProviderEvent,
17 type ProviderSendTurnInput,
1817 type ProviderSession,
1918 type ProviderSessionStartInput,
2019 type ProviderTurnStartResult,
......@@ -71,6 +70,14 @@ interface JsonRpcNotification {
7170 params?: unknown;
7271}
7372
73export 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
7481export interface CodexThreadTurnSnapshot {
7582 id: ProviderTurnId;
7683 items: unknown[];
......@@ -290,7 +297,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
290297 }
291298 }
292299
293 async sendTurn(input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> {
300 async sendTurn(input: CodexAppServerSendTurnInput): Promise<ProviderTurnStartResult> {
294301 const context = this.requireSession(input.sessionId);
295302 if (!context.session.threadId) {
296303 throw new Error("Session is missing a thread id.");
......@@ -310,7 +317,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
310317 if (attachment.type === "image") {
311318 turnInput.push({
312319 type: "image",
313 url: attachment.dataUrl,
320 url: attachment.url,
314321 });
315322 }
316323 }
apps/server/src/git/Layers/CodexTextGeneration.test.ts+33-31
......@@ -294,28 +294,36 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
294294 stdinMustContain: "Attachment metadata:",
295295 },
296296 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"));
298303
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))));
312320
313321 expect(generated.branch).toBe("fix/ui-regression");
314322 }),
315323 ),
316324 );
317325
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", () =>
319327 withFakeCodexEnv(
320328 {
321329 output: JSON.stringify({
......@@ -326,12 +334,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
326334 Effect.gen(function* () {
327335 const fs = yield* FileSystem.FileSystem;
328336 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 });
335341 yield* fs.writeFile(imagePath, Buffer.from("hello"));
336342
337343 const textGeneration = yield* TextGeneration;
......@@ -342,10 +348,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
342348 attachments: [
343349 {
344350 type: "image",
351 id: attachmentId,
345352 name: "bug.png",
346353 mimeType: "image/png",
347354 sizeBytes: 5,
348 dataUrl: `/attachments/thread-1/${imageFileName}`,
349355 },
350356 ],
351357 })
......@@ -357,8 +363,6 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
357363 }),
358364 ),
359365 ),
360 )
361 .pipe(
362366 Effect.ensuring(
363367 fs.remove(imagePath).pipe(Effect.catch(() => Effect.void)),
364368 ),
......@@ -369,7 +373,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
369373 ),
370374 );
371375
372 it.effect("ignores absolute non-route attachment paths for codex image inputs", () =>
376 it.effect("ignores missing attachment ids for codex image inputs", () =>
373377 withFakeCodexEnv(
374378 {
375379 output: JSON.stringify({
......@@ -380,11 +384,9 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
380384 Effect.gen(function* () {
381385 const fs = yield* FileSystem.FileSystem;
382386 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));
388390
389391 const textGeneration = yield* TextGeneration;
390392 const result = yield* textGeneration
......@@ -394,10 +396,10 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
394396 attachments: [
395397 {
396398 type: "image",
399 id: missingAttachmentId,
397400 name: "outside.png",
398401 mimeType: "image/png",
399402 sizeBytes: 5,
400 dataUrl: absoluteImagePath,
401403 },
402404 ],
403405 })
apps/server/src/git/Layers/CodexTextGeneration.ts+14-75
......@@ -3,10 +3,9 @@ import { randomUUID } from "node:crypto";
33import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";
44import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
55
6import { resolveAttachmentRoutePath } from "../../attachmentPaths.ts";
6import { resolveAttachmentPath } from "../../attachmentStore.ts";
77import { ServerConfig } from "../../config.ts";
88import { TextGenerationError } from "../Errors.ts";
9import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";
109import {
1110 type BranchNameGenerationInput,
1211 type BranchNameGenerationResult,
......@@ -109,7 +108,6 @@ const makeCodexTextGeneration = Effect.gen(function* () {
109108
110109 type MaterializedImageAttachments = {
111110 readonly imagePaths: ReadonlyArray<string>;
112 readonly tempImagePaths: ReadonlyArray<string>;
113111 };
114112
115113 const readStreamAsString = <E>(
......@@ -151,96 +149,41 @@ const makeCodexTextGeneration = Effect.gen(function* () {
151149 );
152150 };
153151
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
178152 const safeUnlink = (filePath: string): Effect.Effect<void, never> =>
179153 fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void));
180154
181155 const materializeImageAttachments = (
182 operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName",
156 _operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName",
183157 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* () {
188160 if (!attachments || attachments.length === 0) {
189 return { imagePaths: [], tempImagePaths };
161 return { imagePaths: [] };
190162 }
191163
192164 const imagePaths: string[] = [];
193 for (const [index, attachment] of attachments.entries()) {
165 for (const attachment of attachments) {
194166 if (attachment.type !== "image") {
195167 continue;
196168 }
197169
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({
221171 stateDir: serverConfig.stateDir,
222 dataUrl: attachment.dataUrl,
172 attachment,
223173 });
224 if (!persistedPath || !path.isAbsolute(persistedPath)) {
174 if (!resolvedPath || !path.isAbsolute(resolvedPath)) {
225175 continue;
226176 }
227177 const fileInfo = yield* fileSystem
228 .stat(persistedPath)
178 .stat(resolvedPath)
229179 .pipe(Effect.catch(() => Effect.succeed(null)));
230180 if (!fileInfo || fileInfo.type !== "File") {
231181 continue;
232182 }
233 imagePaths.push(persistedPath);
183 imagePaths.push(resolvedPath);
234184 }
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 });
244187
245188 const runCodexJson = <S extends Schema.Top>({
246189 operation,
......@@ -454,10 +397,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
454397
455398 const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => {
456399 return Effect.gen(function* () {
457 const { imagePaths, tempImagePaths } = yield* materializeImageAttachments(
458 "generateBranchName",
459 input.attachments,
460 );
400 const { imagePaths } = yield* materializeImageAttachments("generateBranchName", input.attachments);
461401 const attachmentLines = (input.attachments ?? []).map(
462402 (attachment) =>
463403 `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
......@@ -492,7 +432,6 @@ const makeCodexTextGeneration = Effect.gen(function* () {
492432 branch: Schema.String,
493433 }),
494434 imagePaths,
495 cleanupPaths: tempImagePaths,
496435 });
497436
498437 return {
apps/server/src/main.test.ts+14-3
......@@ -49,10 +49,21 @@ const testLayer = Layer.mergeAll(
4949const runCli = (
5050 args: ReadonlyArray<string>,
5151 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 ),
5565 );
66};
5667
5768beforeEach(() => {
5869 vi.clearAllMocks();
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+51-65
......@@ -178,7 +178,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
178178 }),
179179 );
180180
181 it.effect("materializes message image attachments into stateDir and stores URL references", () =>
181 it.effect("stores message attachment references without mutating payloads", () =>
182182 Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"))).pipe(
183183 Effect.flatMap((stateDir) =>
184184 Effect.gen(function* () {
......@@ -205,10 +205,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
205205 attachments: [
206206 {
207207 type: "image",
208 id: "thread-attachments-att-1",
208209 name: "example.png",
209210 mimeType: "image/png",
210211 sizeBytes: 5,
211 dataUrl: "data:image/png;base64,SGVsbG8=",
212212 },
213213 ],
214214 turnId: null,
......@@ -232,21 +232,12 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
232232 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
233233 {
234234 type: "image",
235 id: "thread-attachments-att-1",
235236 name: "example.png",
236237 mimeType: "image/png",
237238 sizeBytes: 5,
238 dataUrl: "/attachments/thread-attachments/message-attachments-0.png",
239239 },
240240 ]);
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"));
250241 }).pipe(
251242 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
252243 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
......@@ -255,7 +246,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
255246 ),
256247 );
257248
258 it.effect("materializes only image data URLs and uses a safe extension whitelist", () =>
249 it.effect("preserves mixed image attachment metadata as-is", () =>
259250 Effect.sync(() => fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-"))).pipe(
260251 Effect.flatMap((stateDir) =>
261252 Effect.gen(function* () {
......@@ -282,17 +273,17 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
282273 attachments: [
283274 {
284275 type: "image",
276 id: "thread-attachments-safe-att-1",
285277 name: "untrusted.exe",
286278 mimeType: "image/x-unknown",
287279 sizeBytes: 5,
288 dataUrl: "data:image/x-unknown;base64,SGVsbG8=",
289280 },
290281 {
291282 type: "image",
283 id: "thread-attachments-safe-att-2",
292284 name: "not-image.png",
293285 mimeType: "image/png",
294286 sizeBytes: 5,
295 dataUrl: "data:text/plain;base64,SGVsbG8=",
296287 },
297288 ],
298289 turnId: null,
......@@ -316,36 +307,19 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
316307 assert.deepEqual(JSON.parse(rows[0]?.attachmentsJson ?? "null"), [
317308 {
318309 type: "image",
310 id: "thread-attachments-safe-att-1",
319311 name: "untrusted.exe",
320312 mimeType: "image/x-unknown",
321313 sizeBytes: 5,
322 dataUrl: "/attachments/thread-attachments-safe/message-attachments-safe-0.bin",
323314 },
324315 {
325316 type: "image",
317 id: "thread-attachments-safe-att-2",
326318 name: "not-image.png",
327319 mimeType: "image/png",
328320 sizeBytes: 5,
329 dataUrl: "data:text/plain;base64,SGVsbG8=",
330321 },
331322 ]);
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);
349323 }).pipe(
350324 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
351325 Effect.ensuring(Effect.sync(() => fs.rmSync(stateDir, { recursive: true, force: true }))),
......@@ -425,10 +399,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
425399 attachments: [
426400 {
427401 type: "image",
402 id: "thread-clear-attachments-att-1",
428403 name: "clear.png",
429404 mimeType: "image/png",
430405 sizeBytes: 5,
431 dataUrl: "data:image/png;base64,SGVsbG8=",
432406 },
433407 ],
434408 turnId: null,
......@@ -477,17 +451,18 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
477451 );
478452
479453 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",
481455 () =>
482456 Effect.sync(() =>
483457 fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")),
484458 ).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();
491466
492467 yield* eventStore.append({
493468 type: "project.created",
......@@ -550,10 +525,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
550525 attachments: [
551526 {
552527 type: "image",
528 id: "thread-overwrite-att-1",
553529 name: "file.png",
554530 mimeType: "image/png",
555531 sizeBytes: 5,
556 dataUrl: "data:image/png;base64,SGVsbG8=",
557532 },
558533 ],
559534 turnId: null,
......@@ -581,10 +556,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
581556 attachments: [
582557 {
583558 type: "image",
559 id: "thread-overwrite-att-2",
584560 name: "file.png",
585561 mimeType: "image/png",
586562 sizeBytes: 5,
587 dataUrl: "data:image/png;base64,V29ybGQ=",
588563 },
589564 ],
590565 turnId: null,
......@@ -596,14 +571,23 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
596571
597572 yield* projectionPipeline.bootstrap;
598573
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 ]);
607591 }).pipe(
608592 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
609593 Effect.ensuring(
......@@ -701,10 +685,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
701685 attachments: [
702686 {
703687 type: "image",
688 id: "thread-rollback-att-1",
704689 name: "rollback.png",
705690 mimeType: "image/png",
706691 sizeBytes: 5,
707 dataUrl: "data:image/png;base64,SGVsbG8=",
708692 },
709693 ],
710694 turnId: null,
......@@ -728,8 +712,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
728712 const attachmentPath = path.join(
729713 stateDir,
730714 "attachments",
731 "thread-rollback",
732 "message-rollback-0.png",
715 "thread-rollback-att-1.png",
733716 );
734717 assert.equal(fs.existsSync(attachmentPath), false);
735718 yield* sql`DROP TRIGGER IF EXISTS fail_thread_messages_projection_state_update`;
......@@ -839,10 +822,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
839822 attachments: [
840823 {
841824 type: "image",
825 id: "thread-revert-files-keep",
842826 name: "keep.png",
843827 mimeType: "image/png",
844828 sizeBytes: 5,
845 dataUrl: "data:image/png;base64,SGVsbG8=",
846829 },
847830 ],
848831 turnId: TurnId.makeUnsafe("turn-keep"),
......@@ -892,10 +875,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
892875 attachments: [
893876 {
894877 type: "image",
878 id: "thread-revert-files-remove",
895879 name: "remove.png",
896880 mimeType: "image/png",
897881 sizeBytes: 5,
898 dataUrl: "data:image/png;base64,V29ybGQ=",
899882 },
900883 ],
901884 turnId: TurnId.makeUnsafe("turn-remove"),
......@@ -908,15 +891,16 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
908891 const keepPath = path.join(
909892 stateDir,
910893 "attachments",
911 "thread-revert-files",
912 "message-keep-0.png",
894 "thread-revert-files-keep.png",
913895 );
914896 const removePath = path.join(
915897 stateDir,
916898 "attachments",
917 "thread-revert-files",
918 "message-remove-0.png",
899 "thread-revert-files-remove.png",
919900 );
901 fs.mkdirSync(path.join(stateDir, "attachments"), { recursive: true });
902 fs.writeFileSync(keepPath, Buffer.from("keep"));
903 fs.writeFileSync(removePath, Buffer.from("remove"));
920904 assert.equal(fs.existsSync(keepPath), true);
921905 assert.equal(fs.existsSync(removePath), true);
922906
......@@ -1022,10 +1006,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
10221006 attachments: [
10231007 {
10241008 type: "image",
1009 id: "thread-delete-files-att-1",
10251010 name: "delete.png",
10261011 mimeType: "image/png",
10271012 sizeBytes: 5,
1028 dataUrl: "data:image/png;base64,SGVsbG8=",
10291013 },
10301014 ],
10311015 turnId: null,
......@@ -1035,8 +1019,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
10351019 },
10361020 });
10371021
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);
10401026
10411027 yield* appendAndProject({
10421028 type: "thread.deleted",
......@@ -1054,7 +1040,7 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
10541040 },
10551041 });
10561042
1057 assert.equal(fs.existsSync(threadAttachmentDir), false);
1043 assert.equal(fs.existsSync(threadAttachmentPath), false);
10581044 }).pipe(
10591045 (effect) => runWithProjectionPipelineLayer(stateDir, effect),
10601046 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 {
3737 OrchestrationProjectionPipeline,
3838 type OrchestrationProjectionPipelineShape,
3939} from "../Services/ProjectionPipeline.ts";
40import { ATTACHMENTS_ROUTE_PREFIX, attachmentRouteToRelativePath } from "../../attachmentPaths.ts";
41import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";
40import { attachmentRelativePath } from "../../attachmentStore.ts";
4241
4342export const ORCHESTRATION_PROJECTOR_NAMES = {
4443 projects: "projection.projects",
......@@ -62,13 +61,7 @@ interface ProjectorDefinition {
6261 ) => Effect.Effect<void, ProjectionRepositoryError>;
6362}
6463
65interface PendingAttachmentWrite {
66 readonly absolutePath: string;
67 readonly bytes: Uint8Array;
68}
69
7064interface AttachmentSideEffects {
71 readonly writes: Array<PendingAttachmentWrite>;
7265 readonly deletedThreadIds: Set<string>;
7366 readonly prunedThreadRelativePaths: Map<string, Set<string>>;
7467}
......@@ -89,62 +82,10 @@ function toSafeThreadAttachmentSegment(threadId: string): string | null {
8982}
9083
9184const materializeAttachmentsForProjection = Effect.fn(function* (input: {
92 readonly threadId: string;
93 readonly messageId: string;
9485 readonly attachments: ReadonlyArray<ChatAttachment>;
95 readonly stageFileWrite: (pendingWrite: PendingAttachmentWrite) => void;
9686}) {
9787 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;
14889});
14990
15091function extractActivityRequestId(payload: unknown): ApprovalRequestId | null {
......@@ -267,22 +208,16 @@ function collectThreadAttachmentRelativePaths(
267208 if (!threadSegment) {
268209 return new Set();
269210 }
270 const threadPrefix = `${threadSegment}/`;
271211 const relativePaths = new Set<string>();
272212 for (const message of messages) {
273213 for (const attachment of message.attachments ?? []) {
274214 if (attachment.type !== "image") {
275215 continue;
276216 }
277 const relativePath = attachmentRouteToRelativePath(attachment.dataUrl);
278 if (!relativePath || !relativePath.startsWith(threadPrefix)) {
217 if (!attachment.id.startsWith(`${threadSegment}-`)) {
279218 continue;
280219 }
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));
286221 }
287222 }
288223 return relativePaths;
......@@ -306,8 +241,26 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid
306241 });
307242 return;
308243 }
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 );
311264 }),
312265 { concurrency: 1 },
313266 );
......@@ -324,20 +277,22 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid
324277 yield* Effect.logWarning("skipping attachment prune for unsafe thread id", { threadId });
325278 return;
326279 }
327 const threadDir = path.join(attachmentsRootDir, threadSegment);
328280 const entries = yield* fileSystem
329 .readDirectory(threadDir, { recursive: true })
281 .readDirectory(attachmentsRootDir, { recursive: false })
330282 .pipe(Effect.catch(() => Effect.succeed([] as Array<string>)));
331283 yield* Effect.forEach(
332284 entries,
333285 (entry) =>
334286 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}-`)) {
337292 return;
338293 }
339294
340 const absolutePath = path.join(threadDir, threadRelativePath);
295 const absolutePath = path.join(attachmentsRootDir, relativePath);
341296 const fileInfo = yield* fileSystem
342297 .stat(absolutePath)
343298 .pipe(Effect.catch(() => Effect.succeed(null)));
......@@ -345,7 +300,7 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid
345300 return;
346301 }
347302
348 if (!keptThreadRelativePaths.has(threadRelativePath)) {
303 if (!keptThreadRelativePaths.has(relativePath)) {
349304 yield* fileSystem.remove(absolutePath, { force: true });
350305 }
351306 }),
......@@ -356,22 +311,6 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid
356311 { concurrency: 1 },
357312 );
358313
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 );
375314});
376315
377316const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
......@@ -589,16 +528,8 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
589528 const nextAttachments =
590529 event.payload.attachments !== undefined
591530 ? yield* materializeAttachmentsForProjection({
592 threadId: event.payload.threadId,
593 messageId: event.payload.messageId,
594531 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 })
602533 : existingMessage?.attachments;
603534 yield* projectionThreadMessageRepository.upsert({
604535 messageId: event.payload.messageId,
......@@ -1089,7 +1020,6 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () {
10891020 const runProjectorForEvent = (projector: ProjectorDefinition, event: OrchestrationEvent) =>
10901021 Effect.gen(function* () {
10911022 const attachmentSideEffects: AttachmentSideEffects = {
1092 writes: [],
10931023 deletedThreadIds: new Set<string>(),
10941024 prunedThreadRelativePaths: new Map<string, Set<string>>(),
10951025 };
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+7-21
......@@ -258,10 +258,10 @@ describe("ProviderCommandReactor", () => {
258258 attachments: [
259259 {
260260 type: "image",
261 id: "thread-1-att-rename",
261262 name: "bug.png",
262263 mimeType: "image/png",
263264 sizeBytes: 5,
264 dataUrl: "data:image/png;base64,SGVsbG8=",
265265 },
266266 ],
267267 },
......@@ -280,15 +280,10 @@ describe("ProviderCommandReactor", () => {
280280 attachments: [
281281 {
282282 type: "image",
283 id: "thread-1-att-rename",
283284 name: "bug.png",
284285 mimeType: "image/png",
285286 sizeBytes: 5,
286 dataUrl: path.join(
287 harness.stateDir,
288 "attachments",
289 "thread-1",
290 "user-message-worktree-rename-0.png",
291 ),
292287 },
293288 ],
294289 });
......@@ -307,9 +302,8 @@ describe("ProviderCommandReactor", () => {
307302 });
308303 });
309304
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();
313307 const now = new Date().toISOString();
314308
315309 await Effect.runPromise(
......@@ -334,10 +328,10 @@ describe("ProviderCommandReactor", () => {
334328 attachments: [
335329 {
336330 type: "image",
331 id: "thread-1-att-persisted",
337332 name: "bug.png",
338333 mimeType: "image/png",
339334 sizeBytes: 5,
340 dataUrl: "data:image/png;base64,SGVsbG8=",
341335 },
342336 ],
343337 },
......@@ -350,23 +344,16 @@ describe("ProviderCommandReactor", () => {
350344 await waitFor(() => harness.generateBranchName.mock.calls.length === 1);
351345 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
352346
353 const expectedAttachmentPath = path.join(
354 stateDir,
355 "attachments",
356 "thread-1",
357 "user-message-persisted-attachments-0.png",
358 );
359
360347 expect(harness.generateBranchName.mock.calls[0]?.[0]).toEqual({
361348 cwd: "/tmp/provider-project/.t3/worktrees/t3code-abcdef12",
362349 message: "Fix visual bug from screenshot",
363350 attachments: [
364351 {
365352 type: "image",
353 id: "thread-1-att-persisted",
366354 name: "bug.png",
367355 mimeType: "image/png",
368356 sizeBytes: 5,
369 dataUrl: expectedAttachmentPath,
370357 },
371358 ],
372359 });
......@@ -374,14 +361,13 @@ describe("ProviderCommandReactor", () => {
374361 attachments: [
375362 {
376363 type: "image",
364 id: "thread-1-att-persisted",
377365 name: "bug.png",
378366 mimeType: "image/png",
379367 sizeBytes: 5,
380 dataUrl: "data:image/png;base64,SGVsbG8=",
381368 },
382369 ],
383370 });
384 expect(fs.existsSync(expectedAttachmentPath)).toBe(true);
385371 });
386372
387373 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 {
1212 type ProviderThreadId,
1313 type TurnId,
1414} from "@t3tools/contracts";
15import { Cache, Cause, Duration, Effect, FileSystem, Layer, Option, Queue, Stream } from "effect";
15import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";
1616
1717import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
18import {
19 resolveAttachmentRelativePath,
20 resolveAttachmentRoutePath,
21} from "../../attachmentPaths.ts";
22import { ServerConfig } from "../../config.ts";
2318import { GitCore } from "../../git/Services/GitCore.ts";
2419import { TextGeneration } from "../../git/Services/TextGeneration.ts";
25import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";
2620import { ProviderService } from "../../provider/Services/ProviderService.ts";
2721import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
2822import {
......@@ -109,8 +103,6 @@ const make = Effect.gen(function* () {
109103 const providerService = yield* ProviderService;
110104 const git = yield* GitCore;
111105 const textGeneration = yield* TextGeneration;
112 const fileSystem = yield* FileSystem.FileSystem;
113 const serverConfig = yield* Effect.service(ServerConfig);
114106 const handledTurnStartKeys = yield* Cache.make<string, true>({
115107 capacity: HANDLED_TURN_START_KEY_MAX,
116108 timeToLive: HANDLED_TURN_START_KEY_TTL,
......@@ -291,61 +283,6 @@ const make = Effect.gen(function* () {
291283 });
292284 });
293285
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
349286 const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced(function* (input: {
350287 readonly threadId: ThreadId;
351288 readonly branch: string | null;
......@@ -440,19 +377,13 @@ const make = Effect.gen(function* () {
440377 return;
441378 }
442379
443 const branchGenerationAttachments = yield* resolvePersistedAttachments({
444 threadId: event.payload.threadId,
445 messageId: message.id,
446 attachments: message.attachments,
447 });
448
449380 yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({
450381 threadId: event.payload.threadId,
451382 branch: thread.branch,
452383 worktreePath: thread.worktreePath,
453384 messageId: message.id,
454385 messageText: message.text,
455 ...(message.attachments !== undefined ? { attachments: branchGenerationAttachments } : {}),
386 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
456387 }).pipe(Effect.forkScoped);
457388
458389 yield* sendTurnForThread({
apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts+2-2
......@@ -21,10 +21,10 @@ layer("ProjectionThreadMessageRepository", (it) => {
2121 const persistedAttachments = [
2222 {
2323 type: "image" as const,
24 id: "thread-preserve-attachments-att-1",
2425 name: "example.png",
2526 mimeType: "image/png",
2627 sizeBytes: 5,
27 dataUrl: "/attachments/thread-preserve-attachments/message-preserve-attachments/0.png",
2828 },
2929 ];
3030
......@@ -74,10 +74,10 @@ layer("ProjectionThreadMessageRepository", (it) => {
7474 attachments: [
7575 {
7676 type: "image",
77 id: "thread-clear-attachments-att-1",
7778 name: "example.png",
7879 mimeType: "image/png",
7980 sizeBytes: 5,
80 dataUrl: "/attachments/thread-clear-attachments/message-clear-attachments/0.png",
8181 },
8282 ],
8383 isStreaming: false,
apps/server/src/provider/Layers/CodexAdapter.test.ts+27-8
......@@ -4,7 +4,6 @@ import {
44 ProviderItemId,
55 type ProviderApprovalDecision,
66 type ProviderEvent,
7 type ProviderSendTurnInput,
87 ProviderSessionId,
98 type ProviderSession,
109 type ProviderSessionStartInput,
......@@ -12,12 +11,17 @@ import {
1211 ProviderTurnId,
1312 type ProviderTurnStartResult,
1413} from "@t3tools/contracts";
14import * as NodeServices from "@effect/platform-node/NodeServices";
1515import { afterAll, assert, it, vi } from "@effect/vitest";
1616import { assertFailure } from "@effect/vitest/utils";
1717
18import { Effect, Fiber, Stream } from "effect";
18import { Effect, Fiber, Layer, Stream } from "effect";
1919
20import { CodexAppServerManager } from "../../codexAppServerManager.ts";
20import {
21 CodexAppServerManager,
22 type CodexAppServerSendTurnInput,
23} from "../../codexAppServerManager.ts";
24import { ServerConfig } from "../../config.ts";
2125import { ProviderAdapterValidationError } from "../Errors.ts";
2226import { CodexAdapter } from "../Services/CodexAdapter.ts";
2327import { makeCodexAdapterLive } from "./CodexAdapter.ts";
......@@ -44,7 +48,7 @@ class FakeCodexManager extends CodexAppServerManager {
4448 );
4549
4650 public sendTurnImpl = vi.fn(
47 async (_input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> => ({
51 async (_input: CodexAppServerSendTurnInput): Promise<ProviderTurnStartResult> => ({
4852 threadId: ProviderThreadId.makeUnsafe("thread-1"),
4953 turnId: asTurnId("turn-1"),
5054 }),
......@@ -78,7 +82,7 @@ class FakeCodexManager extends CodexAppServerManager {
7882 return this.startSessionImpl(input);
7983 }
8084
81 override sendTurn(input: ProviderSendTurnInput): Promise<ProviderTurnStartResult> {
85 override sendTurn(input: CodexAppServerSendTurnInput): Promise<ProviderTurnStartResult> {
8286 return this.sendTurnImpl(input);
8387 }
8488
......@@ -118,7 +122,12 @@ class FakeCodexManager extends CodexAppServerManager {
118122}
119123
120124const validationManager = new FakeCodexManager();
121const validationLayer = it.layer(makeCodexAdapterLive({ manager: validationManager }));
125const validationLayer = it.layer(
126 makeCodexAdapterLive({ manager: validationManager }).pipe(
127 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
128 Layer.provideMerge(NodeServices.layer),
129 ),
130);
122131
123132validationLayer("CodexAdapterLive validation", (it) => {
124133 it.effect("returns validation error for non-codex provider on startSession", () =>
......@@ -147,7 +156,12 @@ const sessionErrorManager = new FakeCodexManager();
147156sessionErrorManager.sendTurnImpl.mockImplementation(async () => {
148157 throw new Error("Unknown session: sess-missing");
149158});
150const sessionErrorLayer = it.layer(makeCodexAdapterLive({ manager: sessionErrorManager }));
159const sessionErrorLayer = it.layer(
160 makeCodexAdapterLive({ manager: sessionErrorManager }).pipe(
161 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
162 Layer.provideMerge(NodeServices.layer),
163 ),
164);
151165
152166sessionErrorLayer("CodexAdapterLive session errors", (it) => {
153167 it.effect("maps unknown-session sendTurn errors to ProviderAdapterSessionNotFoundError", () =>
......@@ -178,7 +192,12 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
178192});
179193
180194const lifecycleManager = new FakeCodexManager();
181const lifecycleLayer = it.layer(makeCodexAdapterLive({ manager: lifecycleManager }));
195const lifecycleLayer = it.layer(
196 makeCodexAdapterLive({ manager: lifecycleManager }).pipe(
197 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
198 Layer.provideMerge(NodeServices.layer),
199 ),
200);
182201
183202lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
184203 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 {
1717 ProviderThreadId,
1818 ProviderTurnId,
1919} from "@t3tools/contracts";
20import { Effect, Layer, Queue, Schema, Stream } from "effect";
20import { Effect, FileSystem, Layer, Queue, Schema, Stream } from "effect";
2121
2222import {
2323 ProviderAdapterProcessError,
......@@ -29,6 +29,8 @@ import {
2929} from "../Errors.ts";
3030import { CodexAdapter, type CodexAdapterShape } from "../Services/CodexAdapter.ts";
3131import { CodexAppServerManager } from "../../codexAppServerManager.ts";
32import { resolveAttachmentPath } from "../../attachmentStore.ts";
33import { ServerConfig } from "../../config.ts";
3234import { makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
3335
3436const PROVIDER = "codex" as const;
......@@ -426,6 +428,8 @@ function mapToRuntimeEvents(event: ProviderEvent): ReadonlyArray<ProviderRuntime
426428
427429const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
428430 Effect.gen(function* () {
431 const fileSystem = yield* FileSystem.FileSystem;
432 const serverConfig = yield* Effect.service(ServerConfig);
429433 const nativeEventLogger =
430434 options?.nativeEventLogPath !== undefined
431435 ? makeEventNdjsonLogger(options.nativeEventLogPath)
......@@ -475,9 +479,46 @@ const makeCodexAdapter = (options?: CodexAdapterLiveOptions) =>
475479 };
476480
477481 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 });
481522 });
482523
483524 const interruptTurn: CodexAdapterShape["interruptTurn"] = (sessionId, turnId) =>
apps/server/src/serverLayers.ts+2-2
......@@ -1,7 +1,7 @@
11import path from "node:path";
22
33import * as NodeServices from "@effect/platform-node/NodeServices";
4import { Effect, Layer } from "effect";
4import { Effect, FileSystem, Layer } from "effect";
55import * as SqlClient from "effect/unstable/sql/SqlClient";
66
77import { CheckpointDiffQueryLive } from "./checkpointing/Layers/CheckpointDiffQuery";
......@@ -37,7 +37,7 @@ import { NodePtyAdapterLive } from "./terminal/Layers/NodePTY";
3737export function makeServerProviderLayer(): Layer.Layer<
3838 ProviderService,
3939 ProviderUnsupportedError,
40 SqlClient.SqlClient | ServerConfig
40 SqlClient.SqlClient | ServerConfig | FileSystem.FileSystem
4141> {
4242 return Effect.gen(function* () {
4343 const { stateDir } = yield* ServerConfig;
apps/server/src/wsServer.test.ts+1
......@@ -420,6 +420,7 @@ describe("WebSocket Server", () => {
420420 Layer.provideMerge(runtimeLayer),
421421 Layer.provideMerge(openLayer),
422422 Layer.provideMerge(serverConfigLayer),
423 Layer.provideMerge(NodeServices.layer),
423424 );
424425 const runtimeServices = await Effect.runPromise(
425426 Layer.build(dependenciesLayer).pipe(Scope.provide(scope)),
apps/server/src/wsServer.ts+99-5
......@@ -12,8 +12,11 @@ import type { Duplex } from "node:stream";
1212import Mime from "@effect/platform-node/Mime";
1313import {
1414 CommandId,
15 type ClientOrchestrationCommand,
16 type OrchestrationCommand,
1517 ORCHESTRATION_WS_CHANNELS,
1618 ORCHESTRATION_WS_METHODS,
19 PROVIDER_SEND_TURN_MAX_IMAGE_BYTES,
1720 ProjectId,
1821 ThreadId,
1922 TerminalEvent,
......@@ -60,6 +63,8 @@ import {
6063 normalizeAttachmentRelativePath,
6164 resolveAttachmentRelativePath,
6265} from "./attachmentPaths";
66import { createAttachmentId, resolveAttachmentPath, resolveAttachmentPathById } from "./attachmentStore.ts";
67import { parseBase64DataUrl } from "./imageMime.ts";
6368
6469/**
6570 * ServerShape - Service API for server lifecycle control.
......@@ -235,6 +240,88 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
235240 });
236241 });
237242
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
238325 // HTTP server — serves static files or redirects to Vite dev server
239326 const httpServer = http.createServer((req, res) => {
240327 const respond = (
......@@ -261,10 +348,16 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
261348 return;
262349 }
263350
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 });
268361 if (!filePath) {
269362 respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path");
270363 return;
......@@ -508,7 +601,8 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
508601
509602 case ORCHESTRATION_WS_METHODS.dispatchCommand: {
510603 const { command } = request.body;
511 return yield* orchestrationEngine.dispatch(command);
604 const normalizedCommand = yield* normalizeDispatchCommand({ command });
605 return yield* orchestrationEngine.dispatch(normalizedCommand);
512606 }
513607
514608 case ORCHESTRATION_WS_METHODS.getTurnDiff: {
apps/web/src/store.ts+7-3
......@@ -229,6 +229,10 @@ function toAttachmentPreviewUrl(rawUrl: string): string {
229229 return rawUrl;
230230}
231231
232function attachmentPreviewRoutePath(attachmentId: string): string {
233 return `/attachments/${encodeURIComponent(attachmentId)}`;
234}
235
232236function normalizeTerminalIds(terminalIds: string[]): string[] {
233237 const ids = terminalIds.map((id) => id.trim()).filter((id) => id.length > 0);
234238 const unique = [...new Set(ids)].slice(0, MAX_THREAD_TERMINAL_COUNT);
......@@ -487,13 +491,13 @@ export function reducer(state: AppState, action: Action): AppState {
487491 }
488492 : null,
489493 messages: thread.messages.map((message) => {
490 const attachments = message.attachments?.map((attachment, index) => ({
494 const attachments = message.attachments?.map((attachment) => ({
491495 type: "image" as const,
492 id: `${message.id}:${index}`,
496 id: attachment.id,
493497 name: attachment.name,
494498 mimeType: attachment.mimeType,
495499 sizeBytes: attachment.sizeBytes,
496 previewUrl: toAttachmentPreviewUrl(attachment.dataUrl),
500 previewUrl: toAttachmentPreviewUrl(attachmentPreviewRoutePath(attachment.id)),
497501 }));
498502 const normalizedMessage: ChatMessage = {
499503 id: message.id,
packages/contracts/src/orchestration.ts+55-3
......@@ -60,12 +60,28 @@ export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000;
6060export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8;
6161export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024;
6262export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;
63export const CHAT_ATTACHMENT_ID_MAX_CHARS = 128;
6364
6465// Correlation id is command id by design in this model.
6566export const CorrelationId = CommandId;
6667export type CorrelationId = typeof CorrelationId.Type;
6768
69export const ChatAttachmentId = TrimmedNonEmptyString.check(
70 Schema.isMaxLength(CHAT_ATTACHMENT_ID_MAX_CHARS),
71 Schema.isPattern(/^[a-z0-9_-]+$/i),
72);
73export type ChatAttachmentId = typeof ChatAttachmentId.Type;
74
6875export 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});
82export type ChatImageAttachment = typeof ChatImageAttachment.Type;
83
84export const UploadChatImageAttachment = Schema.Struct({
6985 type: Schema.Literal("image"),
7086 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
7187 mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)),
......@@ -74,10 +90,12 @@ export const ChatImageAttachment = Schema.Struct({
7490 Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS),
7591 ),
7692});
77export type ChatImageAttachment = typeof ChatImageAttachment.Type;
93export type UploadChatImageAttachment = typeof UploadChatImageAttachment.Type;
7894
7995export const ChatAttachment = Schema.Union([ChatImageAttachment]);
8096export type ChatAttachment = typeof ChatAttachment.Type;
97export const UploadChatAttachment = Schema.Union([UploadChatImageAttachment]);
98export type UploadChatAttachment = typeof UploadChatAttachment.Type;
8199
82100export const ProjectScriptIcon = Schema.Literals([
83101 "play",
......@@ -307,6 +325,24 @@ export const ThreadTurnStartCommand = Schema.Struct({
307325 createdAt: IsoDateTime,
308326});
309327
328export 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
310346export const ThreadTurnInterruptCommand = Schema.Struct({
311347 type: Schema.Literal("thread.turn.interrupt"),
312348 commandId: CommandId,
......@@ -339,7 +375,7 @@ export const ThreadSessionStopCommand = Schema.Struct({
339375 createdAt: IsoDateTime,
340376});
341377
342export const ClientOrchestrationCommand = Schema.Union([
378export const DispatchableClientOrchestrationCommand = Schema.Union([
343379 ProjectCreateCommand,
344380 ProjectMetaUpdateCommand,
345381 ProjectDeleteCommand,
......@@ -352,6 +388,22 @@ export const ClientOrchestrationCommand = Schema.Union([
352388 ThreadCheckpointRevertCommand,
353389 ThreadSessionStopCommand,
354390]);
391export type DispatchableClientOrchestrationCommand =
392 typeof DispatchableClientOrchestrationCommand.Type;
393
394export 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]);
355407export type ClientOrchestrationCommand = typeof ClientOrchestrationCommand.Type;
356408
357409export const ThreadSessionSetCommand = Schema.Struct({
......@@ -422,7 +474,7 @@ export const InternalOrchestrationCommand = Schema.Union([
422474export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type;
423475
424476export const OrchestrationCommand = Schema.Union([
425 ClientOrchestrationCommand,
477 DispatchableClientOrchestrationCommand,
426478 InternalOrchestrationCommand,
427479]);
428480export type OrchestrationCommand = typeof OrchestrationCommand.Type;