authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-27 21:00:53-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-27 21:30:46-08:00
log7d4b02689ebcb9dbe56e283f0ec3a17f45c8816b
tree61f6393230f034ff4be89582f82558e5e83a0947
parent9962f57ad0b1b591971826bb8f48f02fb9bc79e7

Overwrite attachment bytes for repeated message attachment index

- Make projection attachment writes use last-write-wins per file path - Always write attachment files so updated message attachments replace stale bytes - Add regression test covering same message/attachment index update behavior

2 files changed, 146 insertions(+), 9 deletions(-)

apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+143
...@@ -480,6 +480,149 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {...@@ -480,6 +480,149 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => {
480 }),480 }),
481 );481 );
482482
483 it.effect("overwrites attachment file bytes when a message updates the same attachment index", () =>
484 Effect.gen(function* () {
485 const projectionPipeline = yield* OrchestrationProjectionPipeline;
486 const eventStore = yield* OrchestrationEventStore;
487 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-"));
488 const now = new Date().toISOString();
489 const later = new Date(Date.now() + 1_000).toISOString();
490
491 const serverConfig = {
492 mode: "web",
493 port: 0,
494 host: undefined,
495 cwd: "/tmp/project-attachments",
496 keybindingsConfigPath: path.join(stateDir, "keybindings.json"),
497 stateDir,
498 staticDir: undefined,
499 devUrl: undefined,
500 noBrowser: true,
501 authToken: undefined,
502 autoBootstrapProjectFromCwd: false,
503 logWebSocketEvents: false,
504 } satisfies ServerConfigShape;
505
506 yield* eventStore.append({
507 type: "project.created",
508 eventId: EventId.makeUnsafe("evt-overwrite-1"),
509 aggregateKind: "project",
510 aggregateId: ProjectId.makeUnsafe("project-overwrite"),
511 occurredAt: now,
512 commandId: CommandId.makeUnsafe("cmd-overwrite-1"),
513 causationEventId: null,
514 correlationId: CommandId.makeUnsafe("cmd-overwrite-1"),
515 metadata: {},
516 payload: {
517 projectId: ProjectId.makeUnsafe("project-overwrite"),
518 title: "Project Overwrite",
519 workspaceRoot: "/tmp/project-overwrite",
520 defaultModel: null,
521 scripts: [],
522 createdAt: now,
523 updatedAt: now,
524 },
525 });
526
527 yield* eventStore.append({
528 type: "thread.created",
529 eventId: EventId.makeUnsafe("evt-overwrite-2"),
530 aggregateKind: "thread",
531 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
532 occurredAt: now,
533 commandId: CommandId.makeUnsafe("cmd-overwrite-2"),
534 causationEventId: null,
535 correlationId: CommandId.makeUnsafe("cmd-overwrite-2"),
536 metadata: {},
537 payload: {
538 threadId: ThreadId.makeUnsafe("thread-overwrite"),
539 projectId: ProjectId.makeUnsafe("project-overwrite"),
540 title: "Thread Overwrite",
541 model: "gpt-5-codex",
542 branch: null,
543 worktreePath: null,
544 createdAt: now,
545 updatedAt: now,
546 },
547 });
548
549 yield* eventStore.append({
550 type: "thread.message-sent",
551 eventId: EventId.makeUnsafe("evt-overwrite-3"),
552 aggregateKind: "thread",
553 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
554 occurredAt: now,
555 commandId: CommandId.makeUnsafe("cmd-overwrite-3"),
556 causationEventId: null,
557 correlationId: CommandId.makeUnsafe("cmd-overwrite-3"),
558 metadata: {},
559 payload: {
560 threadId: ThreadId.makeUnsafe("thread-overwrite"),
561 messageId: MessageId.makeUnsafe("message-overwrite"),
562 role: "user",
563 text: "first image",
564 attachments: [
565 {
566 type: "image",
567 name: "file.png",
568 mimeType: "image/png",
569 sizeBytes: 5,
570 dataUrl: "data:image/png;base64,SGVsbG8=",
571 },
572 ],
573 turnId: null,
574 streaming: false,
575 createdAt: now,
576 updatedAt: now,
577 },
578 });
579
580 yield* eventStore.append({
581 type: "thread.message-sent",
582 eventId: EventId.makeUnsafe("evt-overwrite-4"),
583 aggregateKind: "thread",
584 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),
585 occurredAt: later,
586 commandId: CommandId.makeUnsafe("cmd-overwrite-4"),
587 causationEventId: null,
588 correlationId: CommandId.makeUnsafe("cmd-overwrite-4"),
589 metadata: {},
590 payload: {
591 threadId: ThreadId.makeUnsafe("thread-overwrite"),
592 messageId: MessageId.makeUnsafe("message-overwrite"),
593 role: "user",
594 text: "",
595 attachments: [
596 {
597 type: "image",
598 name: "file.png",
599 mimeType: "image/png",
600 sizeBytes: 5,
601 dataUrl: "data:image/png;base64,V29ybGQ=",
602 },
603 ],
604 turnId: null,
605 streaming: false,
606 createdAt: now,
607 updatedAt: later,
608 },
609 });
610
611 yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig));
612
613 const attachmentPath = path.join(
614 stateDir,
615 "attachments",
616 "thread-overwrite",
617 "message-overwrite",
618 "0.png",
619 );
620 assert.equal(fs.existsSync(attachmentPath), true);
621 assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("V29ybGQ=", "base64"));
622 fs.rmSync(stateDir, { recursive: true, force: true });
623 }),
624 );
625
483 it.effect("does not persist attachment files when projector transaction rolls back", () =>626 it.effect("does not persist attachment files when projector transaction rolls back", () =>
484 Effect.gen(function* () {627 Effect.gen(function* () {
485 const projectionPipeline = yield* OrchestrationProjectionPipeline;628 const projectionPipeline = yield* OrchestrationProjectionPipeline;
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+3-9
...@@ -364,9 +364,8 @@ const runAttachmentSideEffects = Effect.fn(function* (input: {...@@ -364,9 +364,8 @@ const runAttachmentSideEffects = Effect.fn(function* (input: {
364364
365 const writesByPath = new Map<string, Uint8Array>();365 const writesByPath = new Map<string, Uint8Array>();
366 for (const pendingWrite of input.sideEffects.writes) {366 for (const pendingWrite of input.sideEffects.writes) {
367 if (!writesByPath.has(pendingWrite.absolutePath)) {367 // Last write wins for a path so updated attachment contents replace stale bytes.
368 writesByPath.set(pendingWrite.absolutePath, pendingWrite.bytes);368 writesByPath.set(pendingWrite.absolutePath, pendingWrite.bytes);
369 }
370 }369 }
371 yield* Effect.forEach(370 yield* Effect.forEach(
372 writesByPath.entries(),371 writesByPath.entries(),
...@@ -375,12 +374,7 @@ const runAttachmentSideEffects = Effect.fn(function* (input: {...@@ -375,12 +374,7 @@ const runAttachmentSideEffects = Effect.fn(function* (input: {
375 yield* input.fileSystem.makeDirectory(input.path.dirname(absolutePath), {374 yield* input.fileSystem.makeDirectory(input.path.dirname(absolutePath), {
376 recursive: true,375 recursive: true,
377 });376 });
378 const alreadyExists = yield* input.fileSystem377 yield* input.fileSystem.writeFile(absolutePath, bytes);
379 .exists(absolutePath)
380 .pipe(Effect.catch(() => Effect.succeed(false)));
381 if (!alreadyExists) {
382 yield* input.fileSystem.writeFile(absolutePath, bytes);
383 }
384 }),378 }),
385 { concurrency: 1 },379 { concurrency: 1 },
386 );380 );