authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 22:58:59-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 20:06:21-08:00
log505b4d0bc3581b2bf1eb82db1d3768e15c0fe526
tree681cb938801cdb955f3aa4585aabba8ff855886a
parent343c0a94729b7d8040dd4a0fe54a8c6fdee6eeef

Resolve persisted attachment paths across server pipelines

- add shared attachment path normalization and resolution helpers - reuse persisted image files for turn start and branch name generation - cover persisted attachment flow in text generation and reactor tests

7 files changed, 354 insertions(+), 44 deletions(-)

apps/server/src/attachmentPaths.ts created+54
......@@ -0,0 +1,54 @@
1import path from "node:path";
2
3import { ATTACHMENTS_ROUTE_PREFIX } from "./projectFaviconRoute.ts";
4
5export function normalizeAttachmentRelativePath(rawRelativePath: string): string | null {
6 const normalized = path.normalize(rawRelativePath).replace(/^[/\\]+/, "");
7 if (
8 normalized.length === 0 ||
9 normalized.startsWith("..") ||
10 normalized.includes("\0")
11 ) {
12 return null;
13 }
14 return normalized.replace(/\\/g, "/");
15}
16
17export function attachmentRouteToRelativePath(dataUrl: string): string | null {
18 const prefix = `${ATTACHMENTS_ROUTE_PREFIX}/`;
19 if (!dataUrl.startsWith(prefix)) {
20 return null;
21 }
22 return normalizeAttachmentRelativePath(dataUrl.slice(prefix.length));
23}
24
25export function resolveAttachmentRelativePath(input: {
26 readonly stateDir: string;
27 readonly relativePath: string;
28}): string | null {
29 const normalizedRelativePath = normalizeAttachmentRelativePath(input.relativePath);
30 if (!normalizedRelativePath) {
31 return null;
32 }
33
34 const attachmentsRoot = path.resolve(path.join(input.stateDir, "attachments"));
35 const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath));
36 if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) {
37 return null;
38 }
39 return filePath;
40}
41
42export function resolveAttachmentRoutePath(input: {
43 readonly stateDir: string;
44 readonly dataUrl: string;
45}): string | null {
46 const relativePath = attachmentRouteToRelativePath(input.dataUrl);
47 if (!relativePath) {
48 return null;
49 }
50 return resolveAttachmentRelativePath({
51 stateDir: input.stateDir,
52 relativePath,
53 });
54}
apps/server/src/git/Layers/CodexTextGeneration.test.ts+63
......@@ -3,10 +3,28 @@ import { it } from "@effect/vitest";
33import { Effect, FileSystem, Layer, Path } from "effect";
44import { expect } from "vitest";
55
6import { type ServerConfigShape, ServerConfig } from "../../config.ts";
67import { CodexTextGenerationLive } from "./CodexTextGeneration.ts";
78import { TextGenerationError } from "../Errors.ts";
89import { TextGeneration } from "../Services/TextGeneration.ts";
910
11function makeTestServerConfig(stateDir: string): ServerConfigShape {
12 return {
13 mode: "web",
14 port: 0,
15 host: undefined,
16 cwd: process.cwd(),
17 keybindingsConfigPath: "",
18 stateDir,
19 staticDir: undefined,
20 devUrl: undefined,
21 noBrowser: true,
22 authToken: undefined,
23 autoBootstrapProjectFromCwd: false,
24 logWebSocketEvents: false,
25 };
26}
27
1028function makeFakeCodexBinary(dir: string) {
1129 return Effect.gen(function* () {
1230 const fs = yield* FileSystem.FileSystem;
......@@ -311,6 +329,51 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
311329 ),
312330 );
313331
332 it.effect("resolves persisted attachment URLs to files for codex image inputs", () =>
333 withFakeCodexEnv(
334 {
335 output: JSON.stringify({
336 branch: "fix/ui-regression",
337 }),
338 requireImage: true,
339 },
340 Effect.gen(function* () {
341 const fs = yield* FileSystem.FileSystem;
342 const path = yield* Path.Path;
343 const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-state-textgen-" });
344 const imagePath = path.join(
345 stateDir,
346 "attachments",
347 "thread-1",
348 "message-1-0.png",
349 );
350 yield* fs.makeDirectory(path.join(stateDir, "attachments", "thread-1"), {
351 recursive: true,
352 });
353 yield* fs.writeFile(imagePath, Buffer.from("hello"));
354
355 const textGeneration = yield* TextGeneration;
356 const generated = yield* textGeneration
357 .generateBranchName({
358 cwd: process.cwd(),
359 message: "Fix layout bug from screenshot.",
360 attachments: [
361 {
362 type: "image",
363 name: "bug.png",
364 mimeType: "image/png",
365 sizeBytes: 5,
366 dataUrl: "/attachments/thread-1/message-1-0.png",
367 },
368 ],
369 })
370 .pipe(Effect.provideService(ServerConfig, makeTestServerConfig(stateDir)));
371
372 expect(generated.branch).toBe("fix/ui-regression");
373 }),
374 ),
375 );
376
314377 it.effect("returns null branch when codex returns the wrong object shape", () =>
315378 withFakeCodexEnv(
316379 {
apps/server/src/git/Layers/CodexTextGeneration.ts+35-14
......@@ -3,6 +3,8 @@ 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";
7import { ServerConfig } from "../../config.ts";
68import { TextGenerationError } from "../Errors.ts";
79import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";
810import {
......@@ -245,6 +247,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
245247 if (!attachments || attachments.length === 0) {
246248 return [];
247249 }
250 const serverConfig = yield* Effect.serviceOption(ServerConfig);
248251
249252 const imagePaths: string[] = [];
250253 for (const [index, attachment] of attachments.entries()) {
......@@ -253,26 +256,44 @@ const makeCodexTextGeneration = Effect.gen(function* () {
253256 }
254257
255258 const parsed = parseBase64DataUrl(attachment.dataUrl);
256 if (!parsed || !parsed.mimeType.startsWith("image/")) {
259 if (parsed && parsed.mimeType.startsWith("image/")) {
260 const bytes = Buffer.from(parsed.base64, "base64");
261 if (bytes.byteLength === 0) {
262 continue;
263 }
264
265 const extension = inferImageExtension({
266 mimeType: parsed.mimeType,
267 fileName: attachment.name,
268 });
269 const imagePath = yield* writeTempBinaryFile(
270 operation,
271 `codex-image-${index}`,
272 bytes,
273 extension,
274 );
275 imagePaths.push(imagePath);
257276 continue;
258277 }
259278
260 const bytes = Buffer.from(parsed.base64, "base64");
261 if (bytes.byteLength === 0) {
279 const persistedPath =
280 Option.isSome(serverConfig)
281 ? resolveAttachmentRoutePath({
282 stateDir: serverConfig.value.stateDir,
283 dataUrl: attachment.dataUrl,
284 })
285 : null;
286 const localPath = persistedPath ?? attachment.dataUrl;
287 if (!path.isAbsolute(localPath)) {
262288 continue;
263289 }
264
265 const extension = inferImageExtension({
266 mimeType: parsed.mimeType,
267 fileName: attachment.name,
268 });
269 const imagePath = yield* writeTempBinaryFile(
270 operation,
271 `codex-image-${index}`,
272 bytes,
273 extension,
290 const fileInfo = yield* fileSystem.stat(localPath).pipe(
291 Effect.catch(() => Effect.succeed(null)),
274292 );
275 imagePaths.push(imagePath);
293 if (!fileInfo || fileInfo.type !== "File") {
294 continue;
295 }
296 imagePaths.push(localPath);
276297 }
277298 return imagePaths;
278299 });
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+3-16
......@@ -37,9 +37,8 @@ import {
3737 OrchestrationProjectionPipeline,
3838 type OrchestrationProjectionPipelineShape,
3939} from "../Services/ProjectionPipeline.ts";
40import {
41 ATTACHMENTS_ROUTE_PREFIX,
42} from "../../projectFaviconRoute.ts";
40import { attachmentRouteToRelativePath } from "../../attachmentPaths.ts";
41import { ATTACHMENTS_ROUTE_PREFIX } from "../../projectFaviconRoute.ts";
4342import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";
4443
4544export const ORCHESTRATION_PROJECTOR_NAMES = {
......@@ -262,18 +261,6 @@ function retainProjectionActivitiesAfterRevert(
262261 );
263262}
264263
265function toAttachmentRelativePath(dataUrl: string): string | null {
266 const prefix = `${ATTACHMENTS_ROUTE_PREFIX}/`;
267 if (!dataUrl.startsWith(prefix)) {
268 return null;
269 }
270 const relativePath = dataUrl.slice(prefix.length).replace(/^[/\\]+/, "");
271 if (relativePath.length === 0 || relativePath.startsWith("..")) {
272 return null;
273 }
274 return relativePath.replace(/\\/g, "/");
275}
276
277264function collectThreadAttachmentRelativePaths(
278265 threadId: string,
279266 messages: ReadonlyArray<ProjectionThreadMessage>,
......@@ -289,7 +276,7 @@ function collectThreadAttachmentRelativePaths(
289276 if (attachment.type !== "image") {
290277 continue;
291278 }
292 const relativePath = toAttachmentRelativePath(attachment.dataUrl);
279 const relativePath = attachmentRouteToRelativePath(attachment.dataUrl);
293280 if (!relativePath || !relativePath.startsWith(threadPrefix)) {
294281 continue;
295282 }
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+109-2
......@@ -1,3 +1,7 @@
1import fs from "node:fs";
2import os from "node:os";
3import path from "node:path";
4
15import type { ProviderRuntimeEvent } from "@t3tools/contracts";
26import {
37 ApprovalRequestId,
......@@ -13,6 +17,7 @@ import {
1317import { Effect, Exit, Layer, ManagedRuntime, PubSub, Scope, Stream } from "effect";
1418import { afterEach, describe, expect, it, vi } from "vitest";
1519
20import { type ServerConfigShape, ServerConfig } from "../../config.ts";
1621import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
1722import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
1823import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
......@@ -36,6 +41,23 @@ const asApprovalRequestId = (value: string): ApprovalRequestId =>
3641const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);
3742const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);
3843
44function makeTestServerConfig(stateDir: string): ServerConfigShape {
45 return {
46 mode: "web",
47 port: 0,
48 host: undefined,
49 cwd: process.cwd(),
50 keybindingsConfigPath: "",
51 stateDir,
52 staticDir: undefined,
53 devUrl: undefined,
54 noBrowser: true,
55 authToken: undefined,
56 autoBootstrapProjectFromCwd: false,
57 logWebSocketEvents: false,
58 };
59}
60
3961async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
4062 const deadline = Date.now() + timeoutMs;
4163 const poll = async (): Promise<void> => {
......@@ -70,7 +92,7 @@ describe("ProviderCommandReactor", () => {
7092 runtime = null;
7193 });
7294
73 async function createHarness() {
95 async function createHarness(input?: { readonly stateDir?: string }) {
7496 const now = new Date().toISOString();
7597 const runtimeEventPubSub = Effect.runSync(PubSub.unbounded<ProviderRuntimeEvent>());
7698 let nextSessionIndex = 1;
......@@ -124,7 +146,7 @@ describe("ProviderCommandReactor", () => {
124146 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
125147 Layer.provide(SqlitePersistenceMemory),
126148 );
127 const layer = ProviderCommandReactorLive.pipe(
149 const liveLayer = ProviderCommandReactorLive.pipe(
128150 Layer.provideMerge(orchestrationLayer),
129151 Layer.provideMerge(Layer.succeed(ProviderService, service)),
130152 Layer.provideMerge(Layer.succeed(GitCore, { renameBranch } as unknown as GitCoreShape)),
......@@ -135,6 +157,14 @@ describe("ProviderCommandReactor", () => {
135157 ),
136158 ),
137159 );
160 const layer =
161 input?.stateDir !== undefined
162 ? liveLayer.pipe(
163 Layer.provideMerge(
164 Layer.succeed(ServerConfig, makeTestServerConfig(input.stateDir)),
165 ),
166 )
167 : liveLayer;
138168 runtime = ManagedRuntime.make(layer);
139169 const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService));
140170 const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor));
......@@ -285,6 +315,83 @@ describe("ProviderCommandReactor", () => {
285315 });
286316 });
287317
318 it("reuses persisted attachment files for branch generation and turn start", async () => {
319 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3code-reactor-attachments-"));
320 const harness = await createHarness({ stateDir });
321 const now = new Date().toISOString();
322
323 await Effect.runPromise(
324 harness.engine.dispatch({
325 type: "thread.meta.update",
326 commandId: CommandId.makeUnsafe("cmd-thread-meta-set-temp-branch-persisted"),
327 threadId: ThreadId.makeUnsafe("thread-1"),
328 branch: "t3code/abcdef12",
329 worktreePath: "/tmp/provider-project/.t3/worktrees/t3code-abcdef12",
330 }),
331 );
332
333 await Effect.runPromise(
334 harness.engine.dispatch({
335 type: "thread.turn.start",
336 commandId: CommandId.makeUnsafe("cmd-turn-start-persisted-attachments"),
337 threadId: ThreadId.makeUnsafe("thread-1"),
338 message: {
339 messageId: asMessageId("user-message-persisted-attachments"),
340 role: "user",
341 text: "Fix visual bug from screenshot",
342 attachments: [
343 {
344 type: "image",
345 name: "bug.png",
346 mimeType: "image/png",
347 sizeBytes: 5,
348 dataUrl: "data:image/png;base64,SGVsbG8=",
349 },
350 ],
351 },
352 approvalPolicy: "on-request",
353 sandboxMode: "workspace-write",
354 createdAt: now,
355 }),
356 );
357
358 await waitFor(() => harness.generateBranchName.mock.calls.length === 1);
359 await waitFor(() => harness.sendTurn.mock.calls.length === 1);
360
361 const expectedAttachmentPath = path.join(
362 stateDir,
363 "attachments",
364 "thread-1",
365 "user-message-persisted-attachments-0.png",
366 );
367
368 expect(harness.generateBranchName.mock.calls[0]?.[0]).toEqual({
369 cwd: "/tmp/provider-project/.t3/worktrees/t3code-abcdef12",
370 message: "Fix visual bug from screenshot",
371 attachments: [
372 {
373 type: "image",
374 name: "bug.png",
375 mimeType: "image/png",
376 sizeBytes: 5,
377 dataUrl: expectedAttachmentPath,
378 },
379 ],
380 });
381 expect(harness.sendTurn.mock.calls[0]?.[0]).toMatchObject({
382 attachments: [
383 {
384 type: "image",
385 name: "bug.png",
386 mimeType: "image/png",
387 sizeBytes: 5,
388 dataUrl: expectedAttachmentPath,
389 },
390 ],
391 });
392 expect(fs.existsSync(expectedAttachmentPath)).toBe(true);
393 });
394
288395 it("skips worktree branch generation after the first user turn", async () => {
289396 const harness = await createHarness();
290397 const now = new Date().toISOString();
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+82-2
......@@ -1,3 +1,5 @@
1import fs from "node:fs";
2
13import {
24 type ChatAttachment,
35 CommandId,
......@@ -15,8 +17,11 @@ import {
1517import { Cache, Cause, Duration, Effect, Layer, Option, Queue, Stream } from "effect";
1618
1719import { resolveThreadWorkspaceCwd } from "../../checkpointing/Utils.ts";
20import { resolveAttachmentRelativePath, resolveAttachmentRoutePath } from "../../attachmentPaths.ts";
21import { ServerConfig } from "../../config.ts";
1822import { GitCore } from "../../git/Services/GitCore.ts";
1923import { TextGeneration } from "../../git/Services/TextGeneration.ts";
24import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";
2025import { ProviderService } from "../../provider/Services/ProviderService.ts";
2126import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
2227import {
......@@ -103,6 +108,7 @@ const make = Effect.gen(function* () {
103108 const providerService = yield* ProviderService;
104109 const git = yield* GitCore;
105110 const textGeneration = yield* TextGeneration;
111 const serverConfig = yield* Effect.serviceOption(ServerConfig);
106112 const handledTurnStartKeys = yield* Cache.make<string, true>({
107113 capacity: HANDLED_TURN_START_KEY_MAX,
108114 timeToLive: HANDLED_TURN_START_KEY_TTL,
......@@ -280,6 +286,74 @@ const make = Effect.gen(function* () {
280286 });
281287 });
282288
289 const resolvePersistedAttachments = Effect.fnUntraced(function* (
290 input: {
291 readonly threadId: ThreadId;
292 readonly messageId: string;
293 readonly attachments: ReadonlyArray<ChatAttachment> | undefined;
294 },
295 ) {
296 if (!input.attachments || input.attachments.length === 0) {
297 return input.attachments ?? [];
298 }
299
300 if (Option.isNone(serverConfig)) {
301 return input.attachments;
302 }
303 const threadSegment = encodeURIComponent(input.threadId);
304 const messageSegment = encodeURIComponent(input.messageId);
305
306 return yield* Effect.forEach(
307 Array.from(input.attachments.entries()),
308 ([index, attachment]) =>
309 Effect.gen(function* () {
310 if (attachment.type !== "image") {
311 return attachment;
312 }
313
314 const resolvedRoutePath = resolveAttachmentRoutePath({
315 stateDir: serverConfig.value.stateDir,
316 dataUrl: attachment.dataUrl,
317 });
318 const resolvedMaterializedPath =
319 resolvedRoutePath ??
320 (() => {
321 const parsed = parseBase64DataUrl(attachment.dataUrl);
322 if (!parsed || !parsed.mimeType.startsWith("image/")) {
323 return null;
324 }
325 const extension = inferImageExtension({
326 mimeType: parsed.mimeType,
327 fileName: attachment.name,
328 });
329 return resolveAttachmentRelativePath({
330 stateDir: serverConfig.value.stateDir,
331 relativePath: `${threadSegment}/${messageSegment}-${index}${extension}`,
332 });
333 })();
334
335 if (!resolvedMaterializedPath) {
336 return attachment;
337 }
338
339 const isFile = yield* Effect.sync(() => {
340 try {
341 return fs.statSync(resolvedMaterializedPath).isFile();
342 } catch {
343 return false;
344 }
345 });
346 if (!isFile) {
347 return attachment;
348 }
349 return {
350 ...attachment,
351 dataUrl: resolvedMaterializedPath,
352 } satisfies ChatAttachment;
353 }),
354 );
355 });
356
283357 const maybeGenerateAndRenameWorktreeBranchForFirstTurn = Effect.fnUntraced(function* (input: {
284358 readonly threadId: ThreadId;
285359 readonly branch: string | null;
......@@ -381,19 +455,25 @@ const make = Effect.gen(function* () {
381455 return;
382456 }
383457
458 const resolvedAttachments = yield* resolvePersistedAttachments({
459 threadId: event.payload.threadId,
460 messageId: message.id,
461 attachments: message.attachments,
462 });
463
384464 yield* maybeGenerateAndRenameWorktreeBranchForFirstTurn({
385465 threadId: event.payload.threadId,
386466 branch: thread.branch,
387467 worktreePath: thread.worktreePath,
388468 messageId: message.id,
389469 messageText: message.text,
390 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
470 ...(message.attachments !== undefined ? { attachments: resolvedAttachments } : {}),
391471 });
392472
393473 yield* sendTurnForThread({
394474 threadId: event.payload.threadId,
395475 messageText: message.text,
396 ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
476 ...(message.attachments !== undefined ? { attachments: resolvedAttachments } : {}),
397477 ...(event.payload.model !== undefined ? { model: event.payload.model } : {}),
398478 ...(event.payload.effort !== undefined ? { effort: event.payload.effort } : {}),
399479 approvalPolicy: event.payload.approvalPolicy,
apps/server/src/wsServer.ts+8-10
......@@ -55,6 +55,7 @@ import { Open } from "./open";
5555import { ServerConfig } from "./config";
5656import { GitCore } from "./git/Services/GitCore.ts";
5757import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
58import { normalizeAttachmentRelativePath, resolveAttachmentRelativePath } from "./attachmentPaths";
5859
5960/**
6061 * ServerShape - Service API for server lifecycle control.
......@@ -249,21 +250,18 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
249250 }
250251
251252 if (url.pathname.startsWith(ATTACHMENTS_ROUTE_PREFIX)) {
252 const attachmentsRoot = path.resolve(path.join(serverConfig.stateDir, "attachments"));
253253 const rawRelativePath = url.pathname.slice(ATTACHMENTS_ROUTE_PREFIX.length);
254 const normalizedRelativePath = path.normalize(rawRelativePath).replace(/^[/\\]+/, "");
255
256 if (
257 normalizedRelativePath.length === 0 ||
258 normalizedRelativePath.startsWith("..") ||
259 normalizedRelativePath.includes("\0")
260 ) {
254 const normalizedRelativePath = normalizeAttachmentRelativePath(rawRelativePath);
255 if (!normalizedRelativePath) {
261256 respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path");
262257 return;
263258 }
264259
265 const filePath = path.resolve(path.join(attachmentsRoot, normalizedRelativePath));
266 if (!filePath.startsWith(`${attachmentsRoot}${path.sep}`)) {
260 const filePath = resolveAttachmentRelativePath({
261 stateDir: serverConfig.stateDir,
262 relativePath: normalizedRelativePath,
263 });
264 if (!filePath) {
267265 respond(400, { "Content-Type": "text/plain" }, "Invalid attachment path");
268266 return;
269267 }