- 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
Unified · Split
| @@ -480,6 +480,149 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { · 479 unmodified lines | |||
| 480 | }), | 480 | }), |
| 481 | ); | 481 | ); |
| 482 | 482 | ||
| 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; |
| @@ -364,9 +364,8 @@ const runAttachmentSideEffects = Effect.fn(function* (input: { · 363 unmodified lines | |||
| 364 | 364 | ||
| 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: { · 2 unmodified lines | |||
| 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.fileSystem | 377 | 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 | ); |