| author | |
| committer | |
| log | fb45896588afb89b3151e2432682f39e98bfb51f |
| tree | a2cdd86e52f03f636fc80db89ef5c1178229977d |
| parent | b52518f85fa3f11a6a405e3bebaf908ebdc5add7 |
8 files changed, 276 insertions(+), 187 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts+1-2| ... | @@ -215,14 +215,13 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { | ... | @@ -215,14 +215,13 @@ export const makeOrchestrationIntegrationHarness = Effect.gen(function* () { |
| 215 | Layer.provide(providerSessionDirectoryLayer), | 215 | Layer.provide(providerSessionDirectoryLayer), |
| 216 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), | 216 | Layer.provide(Layer.succeed(ProviderAdapterRegistry, registry)), |
| 217 | ); | 217 | ); |
| 218 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); | ||
| 219 | 218 | ||
| 220 | const runtimeServicesLayer = Layer.mergeAll( | 219 | const runtimeServicesLayer = Layer.mergeAll( |
| 221 | orchestrationLayer, | 220 | orchestrationLayer, |
| 222 | OrchestrationProjectionSnapshotQueryLive, | 221 | OrchestrationProjectionSnapshotQueryLive, |
| 223 | ProjectionCheckpointRepositoryLive, | 222 | ProjectionCheckpointRepositoryLive, |
| 224 | ProjectionPendingApprovalRepositoryLive, | 223 | ProjectionPendingApprovalRepositoryLive, |
| 225 | checkpointStoreLayer, | 224 | CheckpointStoreLive, |
| 226 | providerLayer, | 225 | providerLayer, |
| 227 | ); | 226 | ); |
| 228 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( | 227 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+6-3| ... | @@ -233,11 +233,12 @@ describe("CheckpointReactor", () => { | ... | @@ -233,11 +233,12 @@ describe("CheckpointReactor", () => { |
| 233 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 233 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 234 | Layer.provide(SqlitePersistenceMemory), | 234 | Layer.provide(SqlitePersistenceMemory), |
| 235 | ); | 235 | ); |
| 236 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); | 236 | |
| 237 | const layer = CheckpointReactorLive.pipe( | 237 | const layer = CheckpointReactorLive.pipe( |
| 238 | Layer.provideMerge(orchestrationLayer), | 238 | Layer.provideMerge(orchestrationLayer), |
| 239 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), | 239 | Layer.provideMerge(Layer.succeed(ProviderService, provider.service)), |
| 240 | Layer.provideMerge(checkpointStoreLayer), | 240 | Layer.provideMerge(CheckpointStoreLive), |
| 241 | Layer.provideMerge(NodeServices.layer), | ||
| 241 | ); | 242 | ); |
| 242 | 243 | ||
| 243 | runtime = ManagedRuntime.make(layer); | 244 | runtime = ManagedRuntime.make(layer); |
| ... | @@ -437,7 +438,9 @@ describe("CheckpointReactor", () => { | ... | @@ -437,7 +438,9 @@ describe("CheckpointReactor", () => { |
| 437 | 438 | ||
| 438 | await Effect.runPromise(Effect.sleep("40 millis")); | 439 | await Effect.runPromise(Effect.sleep("40 millis")); |
| 439 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); | 440 | const midReadModel = await Effect.runPromise(harness.engine.getReadModel()); |
| 440 | const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1")); | 441 | const midThread = midReadModel.threads.find( |
| 442 | (entry) => entry.id === ThreadId.makeUnsafe("thread-1"), | ||
| 443 | ); | ||
| 441 | expect(midThread?.checkpoints).toHaveLength(0); | 444 | expect(midThread?.checkpoints).toHaveLength(0); |
| 442 | 445 | ||
| 443 | harness.provider.emit({ | 446 | harness.provider.emit({ |
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+206-153| ... | @@ -477,146 +477,150 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { | ... | @@ -477,146 +477,150 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 477 | }), | 477 | }), |
| 478 | ); | 478 | ); |
| 479 | 479 | ||
| 480 | it.effect("overwrites attachment file bytes when a message updates the same attachment index", () => | 480 | it.effect( |
| 481 | Effect.gen(function* () { | 481 | "overwrites attachment file bytes when a message updates the same attachment index", |
| 482 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | 482 | () => |
| 483 | const eventStore = yield* OrchestrationEventStore; | 483 | Effect.gen(function* () { |
| 484 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-overwrite-")); | 484 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
| 485 | const now = new Date().toISOString(); | 485 | const eventStore = yield* OrchestrationEventStore; |
| 486 | const later = new Date(Date.now() + 1_000).toISOString(); | 486 | const stateDir = fs.mkdtempSync( |
| 487 | path.join(os.tmpdir(), "t3-projection-attachments-overwrite-"), | ||
| 488 | ); | ||
| 489 | const now = new Date().toISOString(); | ||
| 490 | const later = new Date(Date.now() + 1_000).toISOString(); | ||
| 487 | 491 | ||
| 488 | const serverConfig = { | 492 | const serverConfig = { |
| 489 | mode: "web", | 493 | mode: "web", |
| 490 | port: 0, | 494 | port: 0, |
| 491 | host: undefined, | 495 | host: undefined, |
| 492 | cwd: "/tmp/project-attachments", | 496 | cwd: "/tmp/project-attachments", |
| 493 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), | 497 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), |
| 494 | stateDir, | 498 | stateDir, |
| 495 | staticDir: undefined, | 499 | staticDir: undefined, |
| 496 | devUrl: undefined, | 500 | devUrl: undefined, |
| 497 | noBrowser: true, | 501 | noBrowser: true, |
| 498 | authToken: undefined, | 502 | authToken: undefined, |
| 499 | autoBootstrapProjectFromCwd: false, | 503 | autoBootstrapProjectFromCwd: false, |
| 500 | logWebSocketEvents: false, | 504 | logWebSocketEvents: false, |
| 501 | } satisfies ServerConfigShape; | 505 | } satisfies ServerConfigShape; |
| 502 | 506 | ||
| 503 | yield* eventStore.append({ | 507 | yield* eventStore.append({ |
| 504 | type: "project.created", | 508 | type: "project.created", |
| 505 | eventId: EventId.makeUnsafe("evt-overwrite-1"), | 509 | eventId: EventId.makeUnsafe("evt-overwrite-1"), |
| 506 | aggregateKind: "project", | 510 | aggregateKind: "project", |
| 507 | aggregateId: ProjectId.makeUnsafe("project-overwrite"), | 511 | aggregateId: ProjectId.makeUnsafe("project-overwrite"), |
| 508 | occurredAt: now, | 512 | occurredAt: now, |
| 509 | commandId: CommandId.makeUnsafe("cmd-overwrite-1"), | 513 | commandId: CommandId.makeUnsafe("cmd-overwrite-1"), |
| 510 | causationEventId: null, | 514 | causationEventId: null, |
| 511 | correlationId: CommandId.makeUnsafe("cmd-overwrite-1"), | 515 | correlationId: CommandId.makeUnsafe("cmd-overwrite-1"), |
| 512 | metadata: {}, | 516 | metadata: {}, |
| 513 | payload: { | 517 | payload: { |
| 514 | projectId: ProjectId.makeUnsafe("project-overwrite"), | 518 | projectId: ProjectId.makeUnsafe("project-overwrite"), |
| 515 | title: "Project Overwrite", | 519 | title: "Project Overwrite", |
| 516 | workspaceRoot: "/tmp/project-overwrite", | 520 | workspaceRoot: "/tmp/project-overwrite", |
| 517 | defaultModel: null, | 521 | defaultModel: null, |
| 518 | scripts: [], | 522 | scripts: [], |
| 519 | createdAt: now, | 523 | createdAt: now, |
| 520 | updatedAt: now, | 524 | updatedAt: now, |
| 521 | }, | 525 | }, |
| 522 | }); | 526 | }); |
| 523 | |||
| 524 | yield* eventStore.append({ | ||
| 525 | type: "thread.created", | ||
| 526 | eventId: EventId.makeUnsafe("evt-overwrite-2"), | ||
| 527 | aggregateKind: "thread", | ||
| 528 | aggregateId: ThreadId.makeUnsafe("thread-overwrite"), | ||
| 529 | occurredAt: now, | ||
| 530 | commandId: CommandId.makeUnsafe("cmd-overwrite-2"), | ||
| 531 | causationEventId: null, | ||
| 532 | correlationId: CommandId.makeUnsafe("cmd-overwrite-2"), | ||
| 533 | metadata: {}, | ||
| 534 | payload: { | ||
| 535 | threadId: ThreadId.makeUnsafe("thread-overwrite"), | ||
| 536 | projectId: ProjectId.makeUnsafe("project-overwrite"), | ||
| 537 | title: "Thread Overwrite", | ||
| 538 | model: "gpt-5-codex", | ||
| 539 | branch: null, | ||
| 540 | worktreePath: null, | ||
| 541 | createdAt: now, | ||
| 542 | updatedAt: now, | ||
| 543 | }, | ||
| 544 | }); | ||
| 545 | 527 | ||
| 546 | yield* eventStore.append({ | 528 | yield* eventStore.append({ |
| 547 | type: "thread.message-sent", | 529 | type: "thread.created", |
| 548 | eventId: EventId.makeUnsafe("evt-overwrite-3"), | 530 | eventId: EventId.makeUnsafe("evt-overwrite-2"), |
| 549 | aggregateKind: "thread", | 531 | aggregateKind: "thread", |
| 550 | aggregateId: ThreadId.makeUnsafe("thread-overwrite"), | 532 | aggregateId: ThreadId.makeUnsafe("thread-overwrite"), |
| 551 | occurredAt: now, | 533 | occurredAt: now, |
| 552 | commandId: CommandId.makeUnsafe("cmd-overwrite-3"), | 534 | commandId: CommandId.makeUnsafe("cmd-overwrite-2"), |
| 553 | causationEventId: null, | 535 | causationEventId: null, |
| 554 | correlationId: CommandId.makeUnsafe("cmd-overwrite-3"), | 536 | correlationId: CommandId.makeUnsafe("cmd-overwrite-2"), |
| 555 | metadata: {}, | 537 | metadata: {}, |
| 556 | payload: { | 538 | payload: { |
| 557 | threadId: ThreadId.makeUnsafe("thread-overwrite"), | 539 | threadId: ThreadId.makeUnsafe("thread-overwrite"), |
| 558 | messageId: MessageId.makeUnsafe("message-overwrite"), | 540 | projectId: ProjectId.makeUnsafe("project-overwrite"), |
| 559 | role: "user", | 541 | title: "Thread Overwrite", |
| 560 | text: "first image", | 542 | model: "gpt-5-codex", |
| 561 | attachments: [ | 543 | branch: null, |
| 562 | { | 544 | worktreePath: null, |
| 563 | type: "image", | 545 | createdAt: now, |
| 564 | name: "file.png", | 546 | updatedAt: now, |
| 565 | mimeType: "image/png", | 547 | }, |
| 566 | sizeBytes: 5, | 548 | }); |
| 567 | dataUrl: "data:image/png;base64,SGVsbG8=", | ||
| 568 | }, | ||
| 569 | ], | ||
| 570 | turnId: null, | ||
| 571 | streaming: false, | ||
| 572 | createdAt: now, | ||
| 573 | updatedAt: now, | ||
| 574 | }, | ||
| 575 | }); | ||
| 576 | 549 | ||
| 577 | yield* eventStore.append({ | 550 | yield* eventStore.append({ |
| 578 | type: "thread.message-sent", | 551 | type: "thread.message-sent", |
| 579 | eventId: EventId.makeUnsafe("evt-overwrite-4"), | 552 | eventId: EventId.makeUnsafe("evt-overwrite-3"), |
| 580 | aggregateKind: "thread", | 553 | aggregateKind: "thread", |
| 581 | aggregateId: ThreadId.makeUnsafe("thread-overwrite"), | 554 | aggregateId: ThreadId.makeUnsafe("thread-overwrite"), |
| 582 | occurredAt: later, | 555 | occurredAt: now, |
| 583 | commandId: CommandId.makeUnsafe("cmd-overwrite-4"), | 556 | commandId: CommandId.makeUnsafe("cmd-overwrite-3"), |
| 584 | causationEventId: null, | 557 | causationEventId: null, |
| 585 | correlationId: CommandId.makeUnsafe("cmd-overwrite-4"), | 558 | correlationId: CommandId.makeUnsafe("cmd-overwrite-3"), |
| 586 | metadata: {}, | 559 | metadata: {}, |
| 587 | payload: { | 560 | payload: { |
| 588 | threadId: ThreadId.makeUnsafe("thread-overwrite"), | 561 | threadId: ThreadId.makeUnsafe("thread-overwrite"), |
| 589 | messageId: MessageId.makeUnsafe("message-overwrite"), | 562 | messageId: MessageId.makeUnsafe("message-overwrite"), |
| 590 | role: "user", | 563 | role: "user", |
| 591 | text: "", | 564 | text: "first image", |
| 592 | attachments: [ | 565 | attachments: [ |
| 593 | { | 566 | { |
| 594 | type: "image", | 567 | type: "image", |
| 595 | name: "file.png", | 568 | name: "file.png", |
| 596 | mimeType: "image/png", | 569 | mimeType: "image/png", |
| 597 | sizeBytes: 5, | 570 | sizeBytes: 5, |
| 598 | dataUrl: "data:image/png;base64,V29ybGQ=", | 571 | dataUrl: "data:image/png;base64,SGVsbG8=", |
| 599 | }, | 572 | }, |
| 600 | ], | 573 | ], |
| 601 | turnId: null, | 574 | turnId: null, |
| 602 | streaming: false, | 575 | streaming: false, |
| 603 | createdAt: now, | 576 | createdAt: now, |
| 604 | updatedAt: later, | 577 | updatedAt: now, |
| 605 | }, | 578 | }, |
| 606 | }); | 579 | }); |
| 607 | 580 | ||
| 608 | yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); | 581 | yield* eventStore.append({ |
| 582 | type: "thread.message-sent", | ||
| 583 | eventId: EventId.makeUnsafe("evt-overwrite-4"), | ||
| 584 | aggregateKind: "thread", | ||
| 585 | aggregateId: ThreadId.makeUnsafe("thread-overwrite"), | ||
| 586 | occurredAt: later, | ||
| 587 | commandId: CommandId.makeUnsafe("cmd-overwrite-4"), | ||
| 588 | causationEventId: null, | ||
| 589 | correlationId: CommandId.makeUnsafe("cmd-overwrite-4"), | ||
| 590 | metadata: {}, | ||
| 591 | payload: { | ||
| 592 | threadId: ThreadId.makeUnsafe("thread-overwrite"), | ||
| 593 | messageId: MessageId.makeUnsafe("message-overwrite"), | ||
| 594 | role: "user", | ||
| 595 | text: "", | ||
| 596 | attachments: [ | ||
| 597 | { | ||
| 598 | type: "image", | ||
| 599 | name: "file.png", | ||
| 600 | mimeType: "image/png", | ||
| 601 | sizeBytes: 5, | ||
| 602 | dataUrl: "data:image/png;base64,V29ybGQ=", | ||
| 603 | }, | ||
| 604 | ], | ||
| 605 | turnId: null, | ||
| 606 | streaming: false, | ||
| 607 | createdAt: now, | ||
| 608 | updatedAt: later, | ||
| 609 | }, | ||
| 610 | }); | ||
| 609 | 611 | ||
| 610 | const attachmentPath = path.join( | 612 | yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); |
| 611 | stateDir, | 613 | |
| 612 | "attachments", | 614 | const attachmentPath = path.join( |
| 613 | "thread-overwrite", | 615 | stateDir, |
| 614 | "message-overwrite-0.png", | 616 | "attachments", |
| 615 | ); | 617 | "thread-overwrite", |
| 616 | assert.equal(fs.existsSync(attachmentPath), true); | 618 | "message-overwrite-0.png", |
| 617 | assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("V29ybGQ=", "base64")); | 619 | ); |
| 618 | fs.rmSync(stateDir, { recursive: true, force: true }); | 620 | assert.equal(fs.existsSync(attachmentPath), true); |
| 619 | }), | 621 | assert.deepEqual(fs.readFileSync(attachmentPath), Buffer.from("V29ybGQ=", "base64")); |
| 622 | fs.rmSync(stateDir, { recursive: true, force: true }); | ||
| 623 | }), | ||
| 620 | ); | 624 | ); |
| 621 | 625 | ||
| 622 | 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", () => |
| ... | @@ -624,7 +628,9 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { | ... | @@ -624,7 +628,9 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 624 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | 628 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
| 625 | const eventStore = yield* OrchestrationEventStore; | 629 | const eventStore = yield* OrchestrationEventStore; |
| 626 | const sql = yield* SqlClient.SqlClient; | 630 | const sql = yield* SqlClient.SqlClient; |
| 627 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-rollback-")); | 631 | const stateDir = fs.mkdtempSync( |
| 632 | path.join(os.tmpdir(), "t3-projection-attachments-rollback-"), | ||
| 633 | ); | ||
| 628 | const now = new Date().toISOString(); | 634 | const now = new Date().toISOString(); |
| 629 | 635 | ||
| 630 | const serverConfig = { | 636 | const serverConfig = { |
| ... | @@ -643,12 +649,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { | ... | @@ -643,12 +649,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 643 | } satisfies ServerConfigShape; | 649 | } satisfies ServerConfigShape; |
| 644 | 650 | ||
| 645 | const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) => | 651 | const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) => |
| 646 | eventStore | 652 | eventStore.append(event).pipe( |
| 647 | .append(event) | 653 | Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), |
| 648 | .pipe( | 654 | Effect.provideService(ServerConfig, serverConfig), |
| 649 | Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), | 655 | ); |
| 650 | Effect.provideService(ServerConfig, serverConfig), | ||
| 651 | ); | ||
| 652 | 656 | ||
| 653 | yield* appendAndProject({ | 657 | yield* appendAndProject({ |
| 654 | type: "project.created", | 658 | type: "project.created", |
| ... | @@ -780,12 +784,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { | ... | @@ -780,12 +784,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 780 | } satisfies ServerConfigShape; | 784 | } satisfies ServerConfigShape; |
| 781 | 785 | ||
| 782 | const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) => | 786 | const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) => |
| 783 | eventStore | 787 | eventStore.append(event).pipe( |
| 784 | .append(event) | 788 | Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), |
| 785 | .pipe( | 789 | Effect.provideService(ServerConfig, serverConfig), |
| 786 | Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), | 790 | ); |
| 787 | Effect.provideService(ServerConfig, serverConfig), | ||
| 788 | ); | ||
| 789 | 791 | ||
| 790 | yield* appendAndProject({ | 792 | yield* appendAndProject({ |
| 791 | type: "project.created", | 793 | type: "project.created", |
| ... | @@ -996,12 +998,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { | ... | @@ -996,12 +998,10 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 996 | } satisfies ServerConfigShape; | 998 | } satisfies ServerConfigShape; |
| 997 | 999 | ||
| 998 | const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) => | 1000 | const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) => |
| 999 | eventStore | 1001 | eventStore.append(event).pipe( |
| 1000 | .append(event) | 1002 | Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), |
| 1001 | .pipe( | 1003 | Effect.provideService(ServerConfig, serverConfig), |
| 1002 | Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)), | 1004 | ); |
| 1003 | Effect.provideService(ServerConfig, serverConfig), | ||
| 1004 | ); | ||
| 1005 | 1005 | ||
| 1006 | yield* appendAndProject({ | 1006 | yield* appendAndProject({ |
| 1007 | type: "project.created", | 1007 | type: "project.created", |
| ... | @@ -1101,6 +1101,59 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { | ... | @@ -1101,6 +1101,59 @@ projectionLayer("OrchestrationProjectionPipeline", (it) => { |
| 1101 | }), | 1101 | }), |
| 1102 | ); | 1102 | ); |
| 1103 | 1103 | ||
| 1104 | it.effect("ignores unsafe thread ids for attachment cleanup paths", () => | ||
| 1105 | Effect.gen(function* () { | ||
| 1106 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | ||
| 1107 | const eventStore = yield* OrchestrationEventStore; | ||
| 1108 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-projection-attachments-unsafe-")); | ||
| 1109 | const now = new Date().toISOString(); | ||
| 1110 | const attachmentsRootDir = path.join(stateDir, "attachments"); | ||
| 1111 | const attachmentsSentinelPath = path.join(attachmentsRootDir, "sentinel.txt"); | ||
| 1112 | const stateDirSentinelPath = path.join(stateDir, "state-sentinel.txt"); | ||
| 1113 | fs.mkdirSync(attachmentsRootDir, { recursive: true }); | ||
| 1114 | fs.writeFileSync(attachmentsSentinelPath, "keep-attachments-root", "utf8"); | ||
| 1115 | fs.writeFileSync(stateDirSentinelPath, "keep-state-dir", "utf8"); | ||
| 1116 | |||
| 1117 | const serverConfig = { | ||
| 1118 | mode: "web", | ||
| 1119 | port: 0, | ||
| 1120 | host: undefined, | ||
| 1121 | cwd: "/tmp/project-attachments", | ||
| 1122 | keybindingsConfigPath: path.join(stateDir, "keybindings.json"), | ||
| 1123 | stateDir, | ||
| 1124 | staticDir: undefined, | ||
| 1125 | devUrl: undefined, | ||
| 1126 | noBrowser: true, | ||
| 1127 | authToken: undefined, | ||
| 1128 | autoBootstrapProjectFromCwd: false, | ||
| 1129 | logWebSocketEvents: false, | ||
| 1130 | } satisfies ServerConfigShape; | ||
| 1131 | |||
| 1132 | yield* eventStore.append({ | ||
| 1133 | type: "thread.deleted", | ||
| 1134 | eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"), | ||
| 1135 | aggregateKind: "thread", | ||
| 1136 | aggregateId: ThreadId.makeUnsafe(".."), | ||
| 1137 | occurredAt: now, | ||
| 1138 | commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"), | ||
| 1139 | causationEventId: null, | ||
| 1140 | correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"), | ||
| 1141 | metadata: {}, | ||
| 1142 | payload: { | ||
| 1143 | threadId: ThreadId.makeUnsafe(".."), | ||
| 1144 | deletedAt: now, | ||
| 1145 | }, | ||
| 1146 | }); | ||
| 1147 | |||
| 1148 | yield* projectionPipeline.bootstrap.pipe(Effect.provideService(ServerConfig, serverConfig)); | ||
| 1149 | |||
| 1150 | assert.equal(fs.existsSync(attachmentsRootDir), true); | ||
| 1151 | assert.equal(fs.existsSync(attachmentsSentinelPath), true); | ||
| 1152 | assert.equal(fs.existsSync(stateDirSentinelPath), true); | ||
| 1153 | fs.rmSync(stateDir, { recursive: true, force: true }); | ||
| 1154 | }), | ||
| 1155 | ); | ||
| 1156 | |||
| 1104 | it.effect("resumes from projector last_applied_sequence without replaying older events", () => | 1157 | it.effect("resumes from projector last_applied_sequence without replaying older events", () => |
| 1105 | Effect.gen(function* () { | 1158 | Effect.gen(function* () { |
| 1106 | const projectionPipeline = yield* OrchestrationProjectionPipeline; | 1159 | const projectionPipeline = yield* OrchestrationProjectionPipeline; |
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+47-17| ... | @@ -77,6 +77,21 @@ interface AttachmentSideEffects { | ... | @@ -77,6 +77,21 @@ interface AttachmentSideEffects { |
| 77 | readonly prunedThreadRelativePaths: Map<string, Set<string>>; | 77 | readonly prunedThreadRelativePaths: Map<string, Set<string>>; |
| 78 | } | 78 | } |
| 79 | 79 | ||
| 80 | function toSafeThreadAttachmentSegment(threadId: string): string | null { | ||
| 81 | const segment = encodeURIComponent(threadId); | ||
| 82 | if ( | ||
| 83 | segment.length === 0 || | ||
| 84 | segment === "." || | ||
| 85 | segment === ".." || | ||
| 86 | segment.includes("/") || | ||
| 87 | segment.includes("\\") || | ||
| 88 | segment.includes("\0") | ||
| 89 | ) { | ||
| 90 | return null; | ||
| 91 | } | ||
| 92 | return segment; | ||
| 93 | } | ||
| 94 | |||
| 80 | function parseBase64DataUrl( | 95 | function parseBase64DataUrl( |
| 81 | dataUrl: string, | 96 | dataUrl: string, |
| 82 | ): { readonly mimeType: string; readonly base64: string } | null { | 97 | ): { readonly mimeType: string; readonly base64: string } | null { |
| ... | @@ -114,7 +129,14 @@ const materializeAttachmentsForProjection = Effect.fn(function* (input: { | ... | @@ -114,7 +129,14 @@ const materializeAttachmentsForProjection = Effect.fn(function* (input: { |
| 114 | 129 | ||
| 115 | const path = yield* Path.Path; | 130 | const path = yield* Path.Path; |
| 116 | const attachmentsRootDir = path.join(serverConfig.value.stateDir, "attachments"); | 131 | const attachmentsRootDir = path.join(serverConfig.value.stateDir, "attachments"); |
| 117 | const threadSegment = encodeURIComponent(input.threadId); | 132 | const threadSegment = toSafeThreadAttachmentSegment(input.threadId); |
| 133 | if (!threadSegment) { | ||
| 134 | yield* Effect.logWarning("skipping attachment materialization for unsafe thread id", { | ||
| 135 | threadId: input.threadId, | ||
| 136 | messageId: input.messageId, | ||
| 137 | }); | ||
| 138 | return input.attachments; | ||
| 139 | } | ||
| 118 | const messageSegment = encodeURIComponent(input.messageId); | 140 | const messageSegment = encodeURIComponent(input.messageId); |
| 119 | 141 | ||
| 120 | return yield* Effect.forEach( | 142 | return yield* Effect.forEach( |
| ... | @@ -282,7 +304,11 @@ function collectThreadAttachmentRelativePaths( | ... | @@ -282,7 +304,11 @@ function collectThreadAttachmentRelativePaths( |
| 282 | threadId: string, | 304 | threadId: string, |
| 283 | messages: ReadonlyArray<ProjectionThreadMessage>, | 305 | messages: ReadonlyArray<ProjectionThreadMessage>, |
| 284 | ): Set<string> { | 306 | ): Set<string> { |
| 285 | const threadPrefix = `${encodeURIComponent(threadId)}/`; | 307 | const threadSegment = toSafeThreadAttachmentSegment(threadId); |
| 308 | if (!threadSegment) { | ||
| 309 | return new Set(); | ||
| 310 | } | ||
| 311 | const threadPrefix = `${threadSegment}/`; | ||
| 286 | const relativePaths = new Set<string>(); | 312 | const relativePaths = new Set<string>(); |
| 287 | for (const message of messages) { | 313 | for (const message of messages) { |
| 288 | for (const attachment of message.attachments ?? []) { | 314 | for (const attachment of message.attachments ?? []) { |
| ... | @@ -317,10 +343,18 @@ const runAttachmentSideEffects = Effect.fn(function* (input: { | ... | @@ -317,10 +343,18 @@ const runAttachmentSideEffects = Effect.fn(function* (input: { |
| 317 | 343 | ||
| 318 | yield* Effect.forEach( | 344 | yield* Effect.forEach( |
| 319 | input.sideEffects.deletedThreadIds, | 345 | input.sideEffects.deletedThreadIds, |
| 320 | (threadId) => { | 346 | (threadId) => |
| 321 | const threadDir = input.path.join(attachmentsRootDir, encodeURIComponent(threadId)); | 347 | Effect.gen(function* () { |
| 322 | return input.fileSystem.remove(threadDir, { recursive: true, force: true }); | 348 | const threadSegment = toSafeThreadAttachmentSegment(threadId); |
| 323 | }, | 349 | if (!threadSegment) { |
| 350 | yield* Effect.logWarning("skipping attachment cleanup for unsafe thread id", { | ||
| 351 | threadId, | ||
| 352 | }); | ||
| 353 | return; | ||
| 354 | } | ||
| 355 | const threadDir = input.path.join(attachmentsRootDir, threadSegment); | ||
| 356 | yield* input.fileSystem.remove(threadDir, { recursive: true, force: true }); | ||
| 357 | }), | ||
| 324 | { concurrency: 1 }, | 358 | { concurrency: 1 }, |
| 325 | ); | 359 | ); |
| 326 | 360 | ||
| ... | @@ -330,8 +364,13 @@ const runAttachmentSideEffects = Effect.fn(function* (input: { | ... | @@ -330,8 +364,13 @@ const runAttachmentSideEffects = Effect.fn(function* (input: { |
| 330 | if (input.sideEffects.deletedThreadIds.has(threadId)) { | 364 | if (input.sideEffects.deletedThreadIds.has(threadId)) { |
| 331 | return Effect.void; | 365 | return Effect.void; |
| 332 | } | 366 | } |
| 333 | const threadDir = input.path.join(attachmentsRootDir, encodeURIComponent(threadId)); | ||
| 334 | return Effect.gen(function* () { | 367 | return Effect.gen(function* () { |
| 368 | const threadSegment = toSafeThreadAttachmentSegment(threadId); | ||
| 369 | if (!threadSegment) { | ||
| 370 | yield* Effect.logWarning("skipping attachment prune for unsafe thread id", { threadId }); | ||
| 371 | return; | ||
| 372 | } | ||
| 373 | const threadDir = input.path.join(attachmentsRootDir, threadSegment); | ||
| 335 | const entries = yield* input.fileSystem | 374 | const entries = yield* input.fileSystem |
| 336 | .readDirectory(threadDir, { recursive: true }) | 375 | .readDirectory(threadDir, { recursive: true }) |
| 337 | .pipe(Effect.catch(() => Effect.succeed([] as Array<string>))); | 376 | .pipe(Effect.catch(() => Effect.succeed([] as Array<string>))); |
| ... | @@ -601,16 +640,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { | ... | @@ -601,16 +640,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { |
| 601 | stageFileWrite: (pendingWrite) => { | 640 | stageFileWrite: (pendingWrite) => { |
| 602 | attachmentSideEffects.writes.push(pendingWrite); | 641 | attachmentSideEffects.writes.push(pendingWrite); |
| 603 | }, | 642 | }, |
| 604 | }).pipe( | 643 | }).pipe(Effect.provideService(Path.Path, path)) |
| 605 | Effect.provideService(Path.Path, path), | ||
| 606 | Effect.catch((cause) => | ||
| 607 | Effect.logWarning("failed to persist message attachments", { | ||
| 608 | threadId: event.payload.threadId, | ||
| 609 | messageId: event.payload.messageId, | ||
| 610 | cause, | ||
| 611 | }).pipe(Effect.as(event.payload.attachments)), | ||
| 612 | ), | ||
| 613 | ) | ||
| 614 | : existingMessage?.attachments; | 644 | : existingMessage?.attachments; |
| 615 | yield* projectionThreadMessageRepository.upsert({ | 645 | yield* projectionThreadMessageRepository.upsert({ |
| 616 | messageId: event.payload.messageId, | 646 | messageId: event.payload.messageId, |
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+2-1| ... | @@ -26,6 +26,7 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli | ... | @@ -26,6 +26,7 @@ function makeDirectoryLayer<E, R>(persistenceLayer: Layer.Layer<SqlClient.SqlCli |
| 26 | return Layer.mergeAll( | 26 | return Layer.mergeAll( |
| 27 | runtimeRepositoryLayer, | 27 | runtimeRepositoryLayer, |
| 28 | ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), | 28 | ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), |
| 29 | NodeServices.layer, | ||
| 29 | ); | 30 | ); |
| 30 | } | 31 | } |
| 31 | 32 | ||
| ... | @@ -223,5 +224,5 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL | ... | @@ -223,5 +224,5 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL |
| 223 | }).pipe(Effect.provide(directoryLayer)); | 224 | }).pipe(Effect.provide(directoryLayer)); |
| 224 | 225 | ||
| 225 | fs.rmSync(tempDir, { recursive: true, force: true }); | 226 | fs.rmSync(tempDir, { recursive: true, force: true }); |
| 226 | }).pipe(Effect.provide(NodeServices.layer))); | 227 | })); |
| 227 | }); | 228 | }); |
apps/server/src/serverLayers.ts+3-3| ... | @@ -61,16 +61,16 @@ export function makeServerRuntimeServicesLayer() { | ... | @@ -61,16 +61,16 @@ export function makeServerRuntimeServicesLayer() { |
| 61 | Layer.provide(OrchestrationEventStoreLive), | 61 | Layer.provide(OrchestrationEventStoreLive), |
| 62 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), | 62 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 63 | ); | 63 | ); |
| 64 | const checkpointStoreLayer = CheckpointStoreLive.pipe(Layer.provide(NodeServices.layer)); | 64 | |
| 65 | const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe( | 65 | const checkpointDiffQueryLayer = CheckpointDiffQueryLive.pipe( |
| 66 | Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), | 66 | Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), |
| 67 | Layer.provideMerge(checkpointStoreLayer), | 67 | Layer.provideMerge(CheckpointStoreLive), |
| 68 | ); | 68 | ); |
| 69 | 69 | ||
| 70 | const runtimeServicesLayer = Layer.mergeAll( | 70 | const runtimeServicesLayer = Layer.mergeAll( |
| 71 | orchestrationLayer, | 71 | orchestrationLayer, |
| 72 | OrchestrationProjectionSnapshotQueryLive, | 72 | OrchestrationProjectionSnapshotQueryLive, |
| 73 | checkpointStoreLayer, | 73 | CheckpointStoreLive, |
| 74 | checkpointDiffQueryLayer, | 74 | checkpointDiffQueryLayer, |
| 75 | ); | 75 | ); |
| 76 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( | 76 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( |
apps/server/src/wsServer.test.ts+10-6| ... | @@ -4,7 +4,7 @@ import os from "node:os"; | ... | @@ -4,7 +4,7 @@ import os from "node:os"; |
| 4 | import path from "node:path"; | 4 | import path from "node:path"; |
| 5 | 5 | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 7 | import { Effect, Exit, Layer, PubSub, Scope, Stream } from "effect"; | 7 | import { Effect, Exit, Layer, PlatformError, PubSub, Scope, Stream } from "effect"; |
| 8 | import { describe, expect, it, afterEach, vi } from "vitest"; | 8 | import { describe, expect, it, afterEach, vi } from "vitest"; |
| 9 | import { createServer } from "./wsServer"; | 9 | import { createServer } from "./wsServer"; |
| 10 | import WebSocket from "ws"; | 10 | import WebSocket from "ws"; |
| ... | @@ -40,13 +40,14 @@ import type { | ... | @@ -40,13 +40,14 @@ import type { |
| 40 | } from "@t3tools/contracts"; | 40 | } from "@t3tools/contracts"; |
| 41 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager"; | 41 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager"; |
| 42 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; | 42 | import { makeSqlitePersistenceLive, SqlitePersistenceMemory } from "./persistence/Layers/Sqlite"; |
| 43 | import { SqlClient } from "effect/unstable/sql"; | 43 | import { SqlClient, SqlError } from "effect/unstable/sql"; |
| 44 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; | 44 | import { ProviderService, type ProviderServiceShape } from "./provider/Services/ProviderService"; |
| 45 | import { Open, type OpenShape } from "./open"; | 45 | import { Open, type OpenShape } from "./open"; |
| 46 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; | 46 | import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; |
| 47 | import type { GitCoreShape } from "./git/Services/GitCore.ts"; | 47 | import type { GitCoreShape } from "./git/Services/GitCore.ts"; |
| 48 | import { GitCore } from "./git/Services/GitCore.ts"; | 48 | import { GitCore } from "./git/Services/GitCore.ts"; |
| 49 | import { GitCommandError, GitManagerError } from "./git/Errors.ts"; | 49 | import { GitCommandError, GitManagerError } from "./git/Errors.ts"; |
| 50 | import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; | ||
| 50 | 51 | ||
| 51 | interface PendingMessages { | 52 | interface PendingMessages { |
| 52 | queue: unknown[]; | 53 | queue: unknown[]; |
| ... | @@ -354,7 +355,10 @@ describe("WebSocket Server", () => { | ... | @@ -354,7 +355,10 @@ describe("WebSocket Server", () => { |
| 354 | 355 | ||
| 355 | async function createTestServer( | 356 | async function createTestServer( |
| 356 | options: { | 357 | options: { |
| 357 | persistenceLayer?: Layer.Layer<SqlClient.SqlClient, never>; | 358 | persistenceLayer?: Layer.Layer< |
| 359 | SqlClient.SqlClient, | ||
| 360 | SqlError.SqlError | MigrationError | PlatformError.PlatformError | ||
| 361 | >; | ||
| 358 | cwd?: string; | 362 | cwd?: string; |
| 359 | autoBootstrapProjectFromCwd?: boolean; | 363 | autoBootstrapProjectFromCwd?: boolean; |
| 360 | logWebSocketEvents?: boolean; | 364 | logWebSocketEvents?: boolean; |
| ... | @@ -614,9 +618,9 @@ describe("WebSocket Server", () => { | ... | @@ -614,9 +618,9 @@ describe("WebSocket Server", () => { |
| 614 | 618 | ||
| 615 | it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => { | 619 | it("includes bootstrap ids in welcome when cwd project and thread already exist", async () => { |
| 616 | const stateDir = makeTempDir("t3code-state-bootstrap-existing-"); | 620 | const stateDir = makeTempDir("t3code-state-bootstrap-existing-"); |
| 617 | const persistenceLayer = makeSqlitePersistenceLive( | 621 | const persistenceLayer = makeSqlitePersistenceLive(path.join(stateDir, "state.sqlite")).pipe( |
| 618 | path.join(stateDir, "state.sqlite"), | 622 | Layer.provide(NodeServices.layer), |
| 619 | ).pipe(Layer.provide(NodeServices.layer)) as unknown as Layer.Layer<SqlClient.SqlClient, never>; | 623 | ); |
| 620 | const cwd = "/test/bootstrap-existing"; | 624 | const cwd = "/test/bootstrap-existing"; |
| 621 | 625 | ||
| 622 | server = await createTestServer({ | 626 | server = await createTestServer({ |
apps/server/src/wsServer.ts+1-2| ... | @@ -23,7 +23,6 @@ import { | ... | @@ -23,7 +23,6 @@ import { |
| 23 | WsPush, | 23 | WsPush, |
| 24 | WsResponse, | 24 | WsResponse, |
| 25 | } from "@t3tools/contracts"; | 25 | } from "@t3tools/contracts"; |
| 26 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 27 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; | 26 | import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; |
| 28 | import { | 27 | import { |
| 29 | Cause, | 28 | Cause, |
| ... | @@ -787,6 +786,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< | ... | @@ -787,6 +786,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< |
| 787 | }); | 786 | }); |
| 788 | 787 | ||
| 789 | export const ServerLive = Layer.succeed(Server, { | 788 | export const ServerLive = Layer.succeed(Server, { |
| 790 | start: createServer().pipe(Effect.provide(NodeServices.layer)), | 789 | start: createServer(), |
| 791 | stopSignal: Effect.never, | 790 | stopSignal: Effect.never, |
| 792 | } satisfies ServerShape); | 791 | } satisfies ServerShape); |