| author | |
| committer | |
| log | 1f31906c8e3003b2a9d7118c4da163544fa86586 |
| tree | 3263cae17e7c9103542c2e5b7a8bc3a334b1606d |
| parent | 4d1f24ba4123279708e169fcb196f78da8f11351 |
| signature | Signed by PGP key B5690EEEBB952194 |
Continues #17044 by @SunkenInTime.
Co-authored-by: Dara Adedeji <daraadedeji07@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>11 files changed, 367 insertions(+), 154 deletions(-)
apps/server/integration/transferBudgetV2.integration.test.ts+2| ... | ... | @@ -88,6 +88,8 @@ const layerManagement = Layer.unwrap( |
| 88 | 88 | projections.getThreadSnapshotWindow(id, options).pipe(Effect.orDie), |
| 89 | 89 | getThreadShell: (id) => projections.getThreadShell(id).pipe(Effect.orDie), |
| 90 | 90 | getShellSnapshot: (options) => projections.getShellSnapshot(options).pipe(Effect.orDie), |
| 91 | readShellSnapshot: (options) => | |
| 92 | projections.readShellSnapshot(options).pipe(Effect.map(Effect.orDie), Effect.orDie), | |
| 91 | 93 | streamStoredEventsFrom: (input) => |
| 92 | 94 | sink.stream({ ...input, bounded: true }).pipe(Stream.orDie), |
| 93 | 95 | }); |
apps/server/src/orchestration-v2/Orchestrator.ts+28-13| ... | ... | @@ -100,6 +100,7 @@ import { |
| 100 | 100 | type ProjectionRecordFilter, |
| 101 | 101 | type ProjectionRecords, |
| 102 | 102 | type ProjectionCheckpointContext, |
| 103 | type ShellSnapshotOptions, | |
| 103 | 104 | } from "./ProjectionStore.ts"; |
| 104 | 105 | import type { ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; |
| 105 | 106 | import { ProviderAdapterRegistryV2 } from "./ProviderAdapterRegistry.ts"; |
| ... | ... | @@ -319,11 +320,16 @@ export interface OrchestratorV2Shape { |
| 319 | 320 | }, |
| 320 | 321 | OrchestratorV2Error |
| 321 | 322 | >; |
| 322 | readonly getShellSnapshot: (options?: { | |
| 323 | readonly location?: "active" | "archive"; | |
| 324 | /** Background sweeps only: skips settled threads. */ | |
| 325 | readonly unsettledOnly?: boolean; | |
| 326 | }) => Effect.Effect<OrchestrationV2ThreadShellSnapshot, OrchestratorV2Error>; | |
| 323 | readonly getShellSnapshot: ( | |
| 324 | options?: ShellSnapshotOptions, | |
| 325 | ) => Effect.Effect<OrchestrationV2ThreadShellSnapshot, OrchestratorV2Error>; | |
| 326 | /** See `ProjectionStoreV2Shape.readShellSnapshot`. */ | |
| 327 | readonly readShellSnapshot: ( | |
| 328 | options?: ShellSnapshotOptions, | |
| 329 | ) => Effect.Effect< | |
| 330 | Effect.Effect<OrchestrationV2ThreadShellSnapshot, OrchestratorV2Error>, | |
| 331 | OrchestratorV2Error | |
| 332 | >; | |
| 327 | 333 | readonly getThreadShell: ( |
| 328 | 334 | threadId: ThreadId, |
| 329 | 335 | ) => Effect.Effect<OrchestrationV2ThreadShell | null, OrchestratorV2Error>; |
| ... | ... | @@ -10802,6 +10808,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 10802 | 10808 | ), |
| 10803 | 10809 | ); |
| 10804 | 10810 | |
| 10811 | const shellProjectionError = (cause: unknown) => | |
| 10812 | new OrchestratorProjectionError({ threadId: ThreadId.make("thread:shell"), cause }); | |
| 10813 | ||
| 10805 | 10814 | return OrchestratorV2.of({ |
| 10806 | 10815 | resumeQueuedRuns, |
| 10807 | 10816 | recoverDelegatedTasks, |
| ... | ... | @@ -10845,15 +10854,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 10845 | 10854 | .getThreadSnapshotWindow(threadId, options) |
| 10846 | 10855 | .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))), |
| 10847 | 10856 | getShellSnapshot: (options) => |
| 10848 | projectionStore.getShellSnapshot(options).pipe( | |
| 10849 | Effect.mapError( | |
| 10850 | (cause) => | |
| 10851 | new OrchestratorProjectionError({ | |
| 10852 | threadId: ThreadId.make("thread:shell"), | |
| 10853 | cause, | |
| 10854 | }), | |
| 10857 | projectionStore.getShellSnapshot(options).pipe(Effect.mapError(shellProjectionError)), | |
| 10858 | readShellSnapshot: (options) => | |
| 10859 | projectionStore | |
| 10860 | .readShellSnapshot(options) | |
| 10861 | .pipe( | |
| 10862 | Effect.mapError(shellProjectionError), | |
| 10863 | Effect.map(Effect.mapError(shellProjectionError)), | |
| 10855 | 10864 | ), |
| 10856 | ), | |
| 10857 | 10865 | getThreadShell: (threadId) => |
| 10858 | 10866 | projectionStore |
| 10859 | 10867 | .getThreadShell(threadId) |
| ... | ... | @@ -10982,6 +10990,13 @@ const layerUnavailable: Layer.Layer<OrchestratorV2> = Layer.succeed( |
| 10982 | 10990 | cause: "Orchestration V2 live runtime is not configured.", |
| 10983 | 10991 | }), |
| 10984 | 10992 | ), |
| 10993 | readShellSnapshot: () => | |
| 10994 | Effect.fail( | |
| 10995 | new OrchestratorProjectionError({ | |
| 10996 | threadId: ThreadId.make("thread:shell"), | |
| 10997 | cause: "Orchestration V2 live runtime is not configured.", | |
| 10998 | }), | |
| 10999 | ), | |
| 10985 | 11000 | getThreadShell: (threadId) => |
| 10986 | 11001 | Effect.fail( |
| 10987 | 11002 | new OrchestratorProjectionError({ |
apps/server/src/orchestration-v2/ProjectionStore.test.ts+75| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | MessageId, |
| 10 | 10 | type ModelSelection, |
| 11 | 11 | type OrchestrationV2ProviderThread, |
| 12 | type OrchestrationV2ThreadShellSnapshot, | |
| 12 | 13 | NodeId, |
| 13 | 14 | ProjectId, |
| 14 | 15 | ProviderDriverKind, |
| ... | ... | @@ -1728,6 +1729,80 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { |
| 1728 | 1729 | }), |
| 1729 | 1730 | ); |
| 1730 | 1731 | |
| 1732 | it.effect("reads the shell snapshot first and decodes it in a separate step", () => | |
| 1733 | Effect.gen(function* () { | |
| 1734 | const projectionStore = yield* ProjectionStore.ProjectionStoreV2; | |
| 1735 | const sql = yield* SqlClient.SqlClient; | |
| 1736 | const now = yield* DateTime.now; | |
| 1737 | const createThread = (threadId: ThreadId) => | |
| 1738 | projectionStore.apply({ | |
| 1739 | id: EventId.make(`event:${threadId}:created`), | |
| 1740 | type: "thread.created", | |
| 1741 | threadId, | |
| 1742 | occurredAt: now, | |
| 1743 | payload: { | |
| 1744 | createdBy: "user", | |
| 1745 | creationSource: "web", | |
| 1746 | id: threadId, | |
| 1747 | projectId: ProjectId.make("project:shell-read"), | |
| 1748 | title: "Shell read", | |
| 1749 | providerInstanceId, | |
| 1750 | modelSelection, | |
| 1751 | runtimeMode: "full-access", | |
| 1752 | interactionMode: "default", | |
| 1753 | branch: null, | |
| 1754 | worktreePath: null, | |
| 1755 | activeProviderThreadId: null, | |
| 1756 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | |
| 1757 | forkedFrom: null, | |
| 1758 | createdAt: now, | |
| 1759 | updatedAt: now, | |
| 1760 | archivedAt: null, | |
| 1761 | settledOverride: null, | |
| 1762 | settledAt: null, | |
| 1763 | lastVisitedAt: null, | |
| 1764 | deletedAt: null, | |
| 1765 | }, | |
| 1766 | }); | |
| 1767 | const readThreadId = ThreadId.make("thread:shell-read:before"); | |
| 1768 | const laterThreadId = ThreadId.make("thread:shell-read:after"); | |
| 1769 | const shellIds = (snapshot: OrchestrationV2ThreadShellSnapshot) => | |
| 1770 | snapshot.threads | |
| 1771 | .map((thread) => thread.id) | |
| 1772 | .filter((id) => id === readThreadId || id === laterThreadId); | |
| 1773 | ||
| 1774 | yield* createThread(readThreadId); | |
| 1775 | const decode = yield* sql.withTransaction(projectionStore.readShellSnapshot()); | |
| 1776 | // Commits between the read and the decode, as another request can while | |
| 1777 | // the HTTP and WebSocket loaders decode outside their transaction. | |
| 1778 | yield* createThread(laterThreadId); | |
| 1779 | ||
| 1780 | assert.deepEqual(shellIds(yield* decode), [readThreadId]); | |
| 1781 | assert.sameMembers(shellIds(yield* projectionStore.getShellSnapshot()), [ | |
| 1782 | readThreadId, | |
| 1783 | laterThreadId, | |
| 1784 | ]); | |
| 1785 | ||
| 1786 | // No decoding under the read transaction: a payload that cannot decode | |
| 1787 | // fails the returned step, not the read. | |
| 1788 | const [stored] = yield* sql<{ readonly payload_json: string }>` | |
| 1789 | SELECT payload_json FROM orchestration_v2_projection_threads | |
| 1790 | WHERE thread_id = ${laterThreadId} | |
| 1791 | `; | |
| 1792 | const setPayload = (payload: string) => | |
| 1793 | sql`UPDATE orchestration_v2_projection_threads SET payload_json = ${payload} | |
| 1794 | WHERE thread_id = ${laterThreadId}`; | |
| 1795 | yield* setPayload("{}"); | |
| 1796 | const failure = yield* sql | |
| 1797 | .withTransaction(projectionStore.readShellSnapshot()) | |
| 1798 | .pipe( | |
| 1799 | Effect.flatMap(Effect.flip), | |
| 1800 | Effect.ensuring(Effect.orDie(setPayload(stored!.payload_json))), | |
| 1801 | ); | |
| 1802 | assert.strictEqual(failure._tag, "ProjectionStoreReadError"); | |
| 1803 | }), | |
| 1804 | ); | |
| 1805 | ||
| 1731 | 1806 | it.effect("does not treat visited or marked-unread state as thread activity", () => |
| 1732 | 1807 | Effect.gen(function* () { |
| 1733 | 1808 | const projectionStore = yield* ProjectionStore.ProjectionStoreV2; |
apps/server/src/orchestration-v2/ProjectionStore.ts+140-99| ... | ... | @@ -308,6 +308,15 @@ export interface ProjectionTimelinePage { |
| 308 | 308 | readonly hasMore: boolean; |
| 309 | 309 | } |
| 310 | 310 | |
| 311 | export interface ShellSnapshotOptions { | |
| 312 | readonly location?: "active" | "archive"; | |
| 313 | /** | |
| 314 | * For background sweeps, not clients: skips settled threads before any of | |
| 315 | * their run, item or session rows are read. | |
| 316 | */ | |
| 317 | readonly unsettledOnly?: boolean; | |
| 318 | } | |
| 319 | ||
| 311 | 320 | export interface ProjectionStoreV2Shape { |
| 312 | 321 | readonly getThreadAttachmentIds: ( |
| 313 | 322 | threadId: ThreadId, |
| ... | ... | @@ -334,14 +343,21 @@ export interface ProjectionStoreV2Shape { |
| 334 | 343 | readonly apply: ( |
| 335 | 344 | event: OrchestrationV2DomainEvent, |
| 336 | 345 | ) => Effect.Effect<void, ProjectionStoreV2Error>; |
| 346 | readonly getShellSnapshot: ( | |
| 347 | options?: ShellSnapshotOptions, | |
| 348 | ) => Effect.Effect<OrchestrationV2ThreadShellSnapshot, ProjectionStoreV2Error>; | |
| 337 | 349 | /** |
| 338 | * `unsettledOnly` is for background sweeps, not clients: it skips settled | |
| 339 | * threads before any of their run, item or session rows are read. | |
| 350 | * Runs the shell snapshot's SQL reads and returns the step that decodes them. | |
| 351 | * Callers that combine the shell with other reads run this inside their own | |
| 352 | * transaction and the returned decode after it commits, so the shared | |
| 353 | * connection is not held while thousands of rows are converted. | |
| 340 | 354 | */ |
| 341 | readonly getShellSnapshot: (options?: { | |
| 342 | readonly location?: "active" | "archive"; | |
| 343 | readonly unsettledOnly?: boolean; | |
| 344 | }) => Effect.Effect<OrchestrationV2ThreadShellSnapshot, ProjectionStoreV2Error>; | |
| 355 | readonly readShellSnapshot: ( | |
| 356 | options?: ShellSnapshotOptions, | |
| 357 | ) => Effect.Effect< | |
| 358 | Effect.Effect<OrchestrationV2ThreadShellSnapshot, ProjectionStoreV2Error>, | |
| 359 | ProjectionStoreV2Error | |
| 360 | >; | |
| 345 | 361 | readonly getThreadShell: ( |
| 346 | 362 | threadId: ThreadId, |
| 347 | 363 | ) => Effect.Effect<OrchestrationV2ThreadShell | null, ProjectionStoreV2Error>; |
| ... | ... | @@ -5516,103 +5532,125 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 5516 | 5532 | } satisfies ShellThreadState; |
| 5517 | 5533 | }); |
| 5518 | 5534 | |
| 5519 | const getShellSnapshot: ProjectionStoreV2Shape["getShellSnapshot"] = (options) => | |
| 5520 | sql | |
| 5521 | .withTransaction( | |
| 5522 | Effect.gen(function* () { | |
| 5523 | const targetThreadRows = yield* selectShellThreadRows( | |
| 5524 | undefined, | |
| 5525 | options?.location, | |
| 5526 | options?.unsettledOnly ?? false, | |
| 5527 | ); | |
| 5528 | const targetThreadIds = new Set( | |
| 5529 | targetThreadRows.map((row) => ThreadId.make(row.thread_id)), | |
| 5530 | ); | |
| 5531 | const rowsByThreadId = new Map( | |
| 5532 | targetThreadRows.map((row) => [ThreadId.make(row.thread_id), row] as const), | |
| 5533 | ); | |
| 5534 | const pendingSourceIds = targetThreadRows.flatMap((row) => | |
| 5535 | row.forked_from_run_source_thread_id === null | |
| 5536 | ? [] | |
| 5537 | : [ThreadId.make(row.forked_from_run_source_thread_id)], | |
| 5538 | ); | |
| 5539 | while (pendingSourceIds.length > 0) { | |
| 5540 | const sourceId = pendingSourceIds.pop(); | |
| 5541 | if (sourceId === undefined || rowsByThreadId.has(sourceId)) continue; | |
| 5542 | const source = (yield* selectShellThreadRows(sourceId))[0]; | |
| 5543 | if (source === undefined) continue; | |
| 5544 | rowsByThreadId.set(sourceId, source); | |
| 5545 | if (source.forked_from_run_source_thread_id !== null) { | |
| 5546 | pendingSourceIds.push(ThreadId.make(source.forked_from_run_source_thread_id)); | |
| 5547 | } | |
| 5548 | } | |
| 5549 | const threadRows = [...rowsByThreadId.values()]; | |
| 5550 | const threadIds = [...rowsByThreadId.keys()]; | |
| 5551 | const forkSourceIds = shellForkSourceIds(threadRows); | |
| 5552 | const readForThreadIds = <A>( | |
| 5553 | read: (ids: ReadonlyArray<ThreadId>) => Effect.Effect<ReadonlyArray<A>, unknown>, | |
| 5554 | ids: ReadonlyArray<ThreadId> = threadIds, | |
| 5555 | ) => (ids.length === 0 ? Effect.succeed([] as ReadonlyArray<A>) : read(ids)); | |
| 5556 | const [runRows, itemCountRows, sequenceRows, providerThreadRows, pendingTurnItemRows] = | |
| 5557 | yield* Effect.all([ | |
| 5558 | readForThreadIds(selectShellRunRows, forkSourceIds), | |
| 5559 | readForThreadIds(selectShellRunItemCounts, forkSourceIds), | |
| 5560 | sql<{ readonly snapshot_sequence: number | null }>` | |
| 5561 | SELECT MAX(sequence) AS snapshot_sequence | |
| 5562 | FROM orchestration_events | |
| 5563 | WHERE application_event_version = 2 | |
| 5564 | AND aggregate_kind = 'thread' | |
| 5565 | `, | |
| 5566 | readForThreadIds(selectShellProviderThreadRows), | |
| 5567 | readForThreadIds(selectShellPendingTurnItemRows), | |
| 5568 | ]); | |
| 5569 | ||
| 5570 | const { runOrdinalsByThreadId, itemCountsByThreadId } = runMapsByThreadId({ | |
| 5571 | runRows, | |
| 5572 | itemCountRows, | |
| 5573 | }); | |
| 5574 | const { providerThreadsByThreadId, pendingTurnItemsByThreadId } = | |
| 5575 | yield* pendingBackgroundDataByThreadId({ providerThreadRows, pendingTurnItemRows }); | |
| 5576 | const states = yield* Effect.forEach(threadRows, (row) => | |
| 5577 | shellThreadStateFromRow({ | |
| 5578 | row, | |
| 5579 | runOrdinalsByThreadId, | |
| 5580 | itemCountsByThreadId, | |
| 5581 | providerThreadsByThreadId, | |
| 5582 | pendingTurnItemsByThreadId, | |
| 5583 | }), | |
| 5584 | ); | |
| 5585 | const statesByThreadId = new Map(states.map((state) => [state.thread.id, state])); | |
| 5535 | const shellSnapshotReadError = (cause: unknown) => | |
| 5536 | new ProjectionStoreReadError({ threadId: ThreadId.make("thread:shell"), cause }); | |
| 5586 | 5537 | |
| 5587 | const shells = states | |
| 5588 | .filter((state) => targetThreadIds.has(state.thread.id)) | |
| 5589 | .map((state) => | |
| 5590 | shellFromState({ | |
| 5591 | state, | |
| 5592 | visibleItemCount: visibleItemCountForShell({ | |
| 5593 | threadId: state.thread.id, | |
| 5594 | statesByThreadId, | |
| 5595 | }), | |
| 5596 | }), | |
| 5597 | ); | |
| 5538 | const readShellSnapshotRows = (options: ShellSnapshotOptions | undefined) => | |
| 5539 | Effect.gen(function* () { | |
| 5540 | const targetThreadRows = yield* selectShellThreadRows( | |
| 5541 | undefined, | |
| 5542 | options?.location, | |
| 5543 | options?.unsettledOnly ?? false, | |
| 5544 | ); | |
| 5545 | const targetThreadIds = new Set( | |
| 5546 | targetThreadRows.map((row) => ThreadId.make(row.thread_id)), | |
| 5547 | ); | |
| 5548 | const rowsByThreadId = new Map( | |
| 5549 | targetThreadRows.map((row) => [ThreadId.make(row.thread_id), row] as const), | |
| 5550 | ); | |
| 5551 | const pendingSourceIds = targetThreadRows.flatMap((row) => | |
| 5552 | row.forked_from_run_source_thread_id === null | |
| 5553 | ? [] | |
| 5554 | : [ThreadId.make(row.forked_from_run_source_thread_id)], | |
| 5555 | ); | |
| 5556 | while (pendingSourceIds.length > 0) { | |
| 5557 | const sourceId = pendingSourceIds.pop(); | |
| 5558 | if (sourceId === undefined || rowsByThreadId.has(sourceId)) continue; | |
| 5559 | const source = (yield* selectShellThreadRows(sourceId))[0]; | |
| 5560 | if (source === undefined) continue; | |
| 5561 | rowsByThreadId.set(sourceId, source); | |
| 5562 | if (source.forked_from_run_source_thread_id !== null) { | |
| 5563 | pendingSourceIds.push(ThreadId.make(source.forked_from_run_source_thread_id)); | |
| 5564 | } | |
| 5565 | } | |
| 5566 | const threadRows = [...rowsByThreadId.values()]; | |
| 5567 | const threadIds = [...rowsByThreadId.keys()]; | |
| 5568 | const forkSourceIds = shellForkSourceIds(threadRows); | |
| 5569 | const readForThreadIds = <A>( | |
| 5570 | read: (ids: ReadonlyArray<ThreadId>) => Effect.Effect<ReadonlyArray<A>, unknown>, | |
| 5571 | ids: ReadonlyArray<ThreadId> = threadIds, | |
| 5572 | ) => (ids.length === 0 ? Effect.succeed([] as ReadonlyArray<A>) : read(ids)); | |
| 5573 | const [runRows, itemCountRows, sequenceRows, providerThreadRows, pendingTurnItemRows] = | |
| 5574 | yield* Effect.all([ | |
| 5575 | readForThreadIds(selectShellRunRows, forkSourceIds), | |
| 5576 | readForThreadIds(selectShellRunItemCounts, forkSourceIds), | |
| 5577 | sql<{ readonly snapshot_sequence: number | null }>` | |
| 5578 | SELECT MAX(sequence) AS snapshot_sequence | |
| 5579 | FROM orchestration_events | |
| 5580 | WHERE application_event_version = 2 | |
| 5581 | AND aggregate_kind = 'thread' | |
| 5582 | `, | |
| 5583 | readForThreadIds(selectShellProviderThreadRows), | |
| 5584 | readForThreadIds(selectShellPendingTurnItemRows), | |
| 5585 | ]); | |
| 5586 | return { | |
| 5587 | targetThreadIds, | |
| 5588 | threadRows, | |
| 5589 | runRows, | |
| 5590 | itemCountRows, | |
| 5591 | sequenceRows, | |
| 5592 | providerThreadRows, | |
| 5593 | pendingTurnItemRows, | |
| 5594 | }; | |
| 5595 | }); | |
| 5598 | 5596 | |
| 5599 | return { | |
| 5600 | schemaVersion: ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION, | |
| 5601 | snapshotSequence: sequenceRows[0]?.snapshot_sequence ?? 0, | |
| 5602 | threads: shells.filter((thread) => thread.archivedAt === null), | |
| 5603 | archivedThreads: shells.filter((thread) => thread.archivedAt !== null), | |
| 5604 | }; | |
| 5597 | const decodeShellSnapshot = ({ | |
| 5598 | targetThreadIds, | |
| 5599 | threadRows, | |
| 5600 | runRows, | |
| 5601 | itemCountRows, | |
| 5602 | sequenceRows, | |
| 5603 | providerThreadRows, | |
| 5604 | pendingTurnItemRows, | |
| 5605 | }: Effect.Success<ReturnType<typeof readShellSnapshotRows>>) => | |
| 5606 | Effect.gen(function* () { | |
| 5607 | const { runOrdinalsByThreadId, itemCountsByThreadId } = runMapsByThreadId({ | |
| 5608 | runRows, | |
| 5609 | itemCountRows, | |
| 5610 | }); | |
| 5611 | const { providerThreadsByThreadId, pendingTurnItemsByThreadId } = | |
| 5612 | yield* pendingBackgroundDataByThreadId({ providerThreadRows, pendingTurnItemRows }); | |
| 5613 | const states = yield* Effect.forEach(threadRows, (row) => | |
| 5614 | shellThreadStateFromRow({ | |
| 5615 | row, | |
| 5616 | runOrdinalsByThreadId, | |
| 5617 | itemCountsByThreadId, | |
| 5618 | providerThreadsByThreadId, | |
| 5619 | pendingTurnItemsByThreadId, | |
| 5605 | 5620 | }), |
| 5606 | ) | |
| 5607 | .pipe( | |
| 5608 | Effect.mapError( | |
| 5609 | (cause) => | |
| 5610 | new ProjectionStoreReadError({ | |
| 5611 | threadId: ThreadId.make("thread:shell"), | |
| 5612 | cause, | |
| 5613 | }), | |
| 5614 | ), | |
| 5615 | 5621 | ); |
| 5622 | const statesByThreadId = new Map(states.map((state) => [state.thread.id, state])); | |
| 5623 | ||
| 5624 | const shells = states | |
| 5625 | .filter((state) => targetThreadIds.has(state.thread.id)) | |
| 5626 | .map((state) => | |
| 5627 | shellFromState({ | |
| 5628 | state, | |
| 5629 | visibleItemCount: visibleItemCountForShell({ | |
| 5630 | threadId: state.thread.id, | |
| 5631 | statesByThreadId, | |
| 5632 | }), | |
| 5633 | }), | |
| 5634 | ); | |
| 5635 | ||
| 5636 | return { | |
| 5637 | schemaVersion: ORCHESTRATION_V2_PROJECTION_SCHEMA_VERSION, | |
| 5638 | snapshotSequence: sequenceRows[0]?.snapshot_sequence ?? 0, | |
| 5639 | threads: shells.filter((thread) => thread.archivedAt === null), | |
| 5640 | archivedThreads: shells.filter((thread) => thread.archivedAt !== null), | |
| 5641 | }; | |
| 5642 | }).pipe(Effect.mapError(shellSnapshotReadError)); | |
| 5643 | ||
| 5644 | const readShellSnapshot: ProjectionStoreV2Shape["readShellSnapshot"] = (options) => | |
| 5645 | readShellSnapshotRows(options).pipe( | |
| 5646 | Effect.mapError(shellSnapshotReadError), | |
| 5647 | Effect.map(decodeShellSnapshot), | |
| 5648 | ); | |
| 5649 | ||
| 5650 | const getShellSnapshot: ProjectionStoreV2Shape["getShellSnapshot"] = (options) => | |
| 5651 | sql | |
| 5652 | .withTransaction(readShellSnapshotRows(options)) | |
| 5653 | .pipe(Effect.mapError(shellSnapshotReadError), Effect.flatMap(decodeShellSnapshot)); | |
| 5616 | 5654 | |
| 5617 | 5655 | // Per-thread shell for the live shell streams: reads only the target thread |
| 5618 | 5656 | // plus its fork-source chain instead of materializing every thread. Returns |
| ... | ... | @@ -5687,6 +5725,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 5687 | 5725 | return { |
| 5688 | 5726 | apply, |
| 5689 | 5727 | getShellSnapshot, |
| 5728 | readShellSnapshot, | |
| 5690 | 5729 | getThreadShell, |
| 5691 | 5730 | getThread, |
| 5692 | 5731 | getSettlementCandidates, |
| ... | ... | @@ -5780,6 +5819,8 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect( |
| 5780 | 5819 | archivedThreads: visible.filter((thread) => thread.archivedAt !== null), |
| 5781 | 5820 | }; |
| 5782 | 5821 | }), |
| 5822 | readShellSnapshot: (options) => | |
| 5823 | service.getShellSnapshot(options).pipe(Effect.map(Effect.succeed)), | |
| 5783 | 5824 | getThreadShell: (threadId) => |
| 5784 | 5825 | Effect.gen(function* () { |
| 5785 | 5826 | const existing = (yield* Ref.get(replayState)).projections; |
apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts+1| ... | ... | @@ -213,6 +213,7 @@ it.effect( |
| 213 | 213 | apply: () => Effect.void, |
| 214 | 214 | getLimitRecoveryCandidates: () => Effect.die("unused getLimitRecoveryCandidates"), |
| 215 | 215 | getShellSnapshot: () => Effect.die("unused getShellSnapshot"), |
| 216 | readShellSnapshot: () => Effect.die("unused readShellSnapshot"), | |
| 216 | 217 | getThreadShell: () => Effect.die("unused getThreadShell"), |
| 217 | 218 | getThread: () => Ref.get(projection).pipe(Effect.map((state) => state.thread)), |
| 218 | 219 | getSettlementCandidates: () => Effect.die("unused getSettlementCandidates"), |
apps/server/src/orchestration-v2/ShellStream.test.ts+45| ... | ... | @@ -7,9 +7,12 @@ import type { |
| 7 | 7 | OrchestrationV2ThreadShell, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; |
| 10 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | |
| 10 | 11 | import * as DateTime from "effect/DateTime"; |
| 11 | 12 | import * as Duration from "effect/Duration"; |
| 12 | 13 | import * as Effect from "effect/Effect"; |
| 14 | import * as Option from "effect/Option"; | |
| 15 | import * as SqlClient from "effect/sql/SqlClient"; | |
| 13 | 16 | import * as Stream from "effect/Stream"; |
| 14 | 17 | import * as TestClock from "effect/testing/TestClock"; |
| 15 | 18 | |
| ... | ... | @@ -20,6 +23,7 @@ import { |
| 20 | 23 | coalesceStoredThreadEvents, |
| 21 | 24 | composeShellStreamWithEnrichment, |
| 22 | 25 | dedupeShellEnrichment, |
| 26 | loadShellSnapshotParts, | |
| 23 | 27 | shellStreamItemFromEnrichmentRefresh, |
| 24 | 28 | shellStreamItemFromThreadShell, |
| 25 | 29 | shellStreamItemsFromInitialSnapshot, |
| ... | ... | @@ -101,6 +105,47 @@ function storedThreadEvent( |
| 101 | 105 | return { sequence, event: { threadId, ...event } } as OrchestrationV2StoredEvent; |
| 102 | 106 | } |
| 103 | 107 | |
| 108 | describe("loadShellSnapshotParts", () => { | |
| 109 | it.effect("decodes the threads after the read transaction releases the connection", () => | |
| 110 | Effect.gen(function* () { | |
| 111 | const sql = yield* SqlClient.SqlClient; | |
| 112 | const steps: Array<string> = []; | |
| 113 | const step = <A>(name: string, value: A) => | |
| 114 | Effect.serviceOption(sql.transactionService).pipe( | |
| 115 | Effect.map((transaction) => { | |
| 116 | steps.push(`${name}:${Option.isSome(transaction) ? "in" : "out of"} transaction`); | |
| 117 | return value; | |
| 118 | }), | |
| 119 | ); | |
| 120 | const thread = shellFixture({}); | |
| 121 | ||
| 122 | const snapshot = yield* loadShellSnapshotParts({ | |
| 123 | sql, | |
| 124 | readThreads: step( | |
| 125 | "read threads", | |
| 126 | step("decode threads", { | |
| 127 | schemaVersion: 1, | |
| 128 | snapshotSequence: 3, | |
| 129 | threads: [thread], | |
| 130 | archivedThreads: [], | |
| 131 | }), | |
| 132 | ), | |
| 133 | listProjects: step("list projects", []), | |
| 134 | latestSequence: step("latest sequence", 7), | |
| 135 | }); | |
| 136 | ||
| 137 | expect(steps).toEqual([ | |
| 138 | "read threads:in transaction", | |
| 139 | "list projects:in transaction", | |
| 140 | "latest sequence:in transaction", | |
| 141 | "decode threads:out of transaction", | |
| 142 | ]); | |
| 143 | expect(snapshot.snapshotSequence).toBe(7); | |
| 144 | expect(snapshot.threads.threads).toEqual([thread]); | |
| 145 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), | |
| 146 | ); | |
| 147 | }); | |
| 148 | ||
| 104 | 149 | function shellFixture(overrides: Partial<OrchestrationV2ThreadShell>): OrchestrationV2ThreadShell { |
| 105 | 150 | return { id: "thread-a", archivedAt: null, ...overrides } as OrchestrationV2ThreadShell; |
| 106 | 151 | } |
apps/server/src/orchestration-v2/ShellStream.ts+28| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | import * as Clock from "effect/Clock"; |
| 16 | 16 | import * as Effect from "effect/Effect"; |
| 17 | 17 | import * as Schema from "effect/Schema"; |
| 18 | import type * as SqlClient from "effect/sql/SqlClient"; | |
| 18 | 19 | import * as Stream from "effect/Stream"; |
| 19 | 20 | |
| 20 | 21 | /** Build the regular navigation shell without duplicating the archive dataset. */ |
| ... | ... | @@ -32,6 +33,33 @@ export function buildActiveShellSnapshot(input: { |
| 32 | 33 | }; |
| 33 | 34 | } |
| 34 | 35 | |
| 36 | /** | |
| 37 | * Loads the thread shell, projects and sequence for the shell snapshots. | |
| 38 | * One transaction covers the reads so they agree; the threads are decoded | |
| 39 | * after it commits, because the server shares one SQLite connection and | |
| 40 | * decoding a large shell takes longer than reading it. | |
| 41 | */ | |
| 42 | export const loadShellSnapshotParts = <E1, E2, E3, E4>(input: { | |
| 43 | readonly sql: SqlClient.SqlClient; | |
| 44 | readonly readThreads: Effect.Effect<Effect.Effect<OrchestrationV2ThreadShellSnapshot, E2>, E1>; | |
| 45 | readonly listProjects: Effect.Effect<ReadonlyArray<OrchestrationProjectShell>, E3>; | |
| 46 | readonly latestSequence: Effect.Effect<number, E4>; | |
| 47 | }) => | |
| 48 | Effect.gen(function* () { | |
| 49 | const read = yield* input.sql.withTransaction( | |
| 50 | Effect.all({ | |
| 51 | decodeThreads: input.readThreads, | |
| 52 | projects: input.listProjects, | |
| 53 | snapshotSequence: input.latestSequence, | |
| 54 | }), | |
| 55 | ); | |
| 56 | return { | |
| 57 | projects: read.projects, | |
| 58 | threads: yield* read.decodeThreads, | |
| 59 | snapshotSequence: read.snapshotSequence, | |
| 60 | }; | |
| 61 | }); | |
| 62 | ||
| 35 | 63 | export type ShellApplicationEvent = |
| 36 | 64 | | Pick< |
| 37 | 65 | Extract<ApplicationStoredEvent, { readonly aggregateKind: "project" }>, |
apps/server/src/orchestration-v2/ThreadManagementService.ts+7| ... | ... | @@ -309,6 +309,12 @@ export interface ThreadManagementServiceShape { |
| 309 | 309 | readonly getShellSnapshot: (options?: { |
| 310 | 310 | readonly location?: "active" | "archive"; |
| 311 | 311 | }) => Effect.Effect<OrchestrationV2ThreadShellSnapshot, Orchestrator.OrchestratorV2Error>; |
| 312 | readonly readShellSnapshot: (options?: { | |
| 313 | readonly location?: "active" | "archive"; | |
| 314 | }) => Effect.Effect< | |
| 315 | Effect.Effect<OrchestrationV2ThreadShellSnapshot, Orchestrator.OrchestratorV2Error>, | |
| 316 | Orchestrator.OrchestratorV2Error | |
| 317 | >; | |
| 312 | 318 | readonly getThreadShell: Orchestrator.OrchestratorV2["Service"]["getThreadShell"]; |
| 313 | 319 | readonly listProjectThreads: (input: { |
| 314 | 320 | readonly projectId: ProjectId; |
| ... | ... | @@ -818,6 +824,7 @@ const make = Effect.gen(function* () { |
| 818 | 824 | getProjectThreadRecords, |
| 819 | 825 | getProjectThread, |
| 820 | 826 | getShellSnapshot: orchestrator.getShellSnapshot, |
| 827 | readShellSnapshot: orchestrator.readShellSnapshot, | |
| 821 | 828 | getThreadShell: orchestrator.getThreadShell, |
| 822 | 829 | listProjectThreads, |
| 823 | 830 | sendToThread, |
apps/server/src/orchestration-v2/http.ts+7-9| ... | ... | @@ -31,7 +31,7 @@ import { |
| 31 | 31 | } from "./threadHistoryPaging.ts"; |
| 32 | 32 | import * as ThreadManagementService from "./ThreadManagementService.ts"; |
| 33 | 33 | import * as ProjectStore from "./ProjectStore.ts"; |
| 34 | import { buildActiveShellSnapshot } from "./ShellStream.ts"; | |
| 34 | import { buildActiveShellSnapshot, loadShellSnapshotParts } from "./ShellStream.ts"; | |
| 35 | 35 | import { boundedSnapshotResponseFields } from "./ThreadStream.ts"; |
| 36 | 36 | import { projectThreadProjectionForWire } from "./WireProjection.ts"; |
| 37 | 37 | |
| ... | ... | @@ -95,14 +95,12 @@ export const layer = HttpApiBuilder.group( |
| 95 | 95 | ); |
| 96 | 96 | |
| 97 | 97 | const loadShellSnapshot = Effect.fn("http.orchestration.loadShellSnapshot")(function* () { |
| 98 | const base = yield* sql.withTransaction( | |
| 99 | Effect.gen(function* () { | |
| 100 | const threads = yield* threadManagement.getShellSnapshot({ location: "active" }); | |
| 101 | return buildActiveShellSnapshot({ | |
| 102 | projects: yield* projectStore.listShells(), | |
| 103 | threads, | |
| 104 | snapshotSequence: yield* applicationEvents.latestApplicationSequence, | |
| 105 | }); | |
| 98 | const base = buildActiveShellSnapshot( | |
| 99 | yield* loadShellSnapshotParts({ | |
| 100 | sql, | |
| 101 | readThreads: threadManagement.readShellSnapshot({ location: "active" }), | |
| 102 | listProjects: projectStore.listShells(), | |
| 103 | latestSequence: applicationEvents.latestApplicationSequence, | |
| 106 | 104 | }), |
| 107 | 105 | ); |
| 108 | 106 | const projects = yield* enrichProjectShells(base.projects); |
apps/server/src/relay/AgentAwarenessRelay.test.ts+1| ... | ... | @@ -190,6 +190,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( |
| 190 | 190 | catchUp.shellSnapshotReads += 1; |
| 191 | 191 | return { schemaVersion: 2, snapshotSequence: 1, threads: [], archivedThreads: [] }; |
| 192 | 192 | }), |
| 193 | readShellSnapshot: unused, | |
| 193 | 194 | ensureLegacyTranscript: unused, |
| 194 | 195 | dispatch: unused, |
| 195 | 196 | getTimelinePage: () => Effect.die("Unused timeline read"), |
apps/server/src/ws.ts+33-33| ... | ... | @@ -130,6 +130,7 @@ import { |
| 130 | 130 | coalesceStoredThreadEvents, |
| 131 | 131 | composeShellStreamWithEnrichment, |
| 132 | 132 | dedupeShellEnrichment, |
| 133 | loadShellSnapshotParts, | |
| 133 | 134 | shellStreamItemFromEnrichmentRefresh, |
| 134 | 135 | shellStreamItemFromThreadShell, |
| 135 | 136 | shellStreamItemsFromInitialSnapshot, |
| ... | ... | @@ -967,14 +968,12 @@ export const subscribeOrchestrationV2Shell = Effect.fn("ws.orchestrationV2.subsc |
| 967 | 968 | }, |
| 968 | 969 | ); |
| 969 | 970 | const loadSnapshot = Effect.fn("ws.orchestrationV2.loadShellSnapshot")(function* () { |
| 970 | const base = yield* sql.withTransaction( | |
| 971 | Effect.gen(function* () { | |
| 972 | const threads = yield* threadManagement.getShellSnapshot({ location: "active" }); | |
| 973 | return buildActiveShellSnapshot({ | |
| 974 | projects: yield* projects.listShells(), | |
| 975 | threads, | |
| 976 | snapshotSequence: yield* applicationEvents.latestApplicationSequence, | |
| 977 | }); | |
| 971 | const base = buildActiveShellSnapshot( | |
| 972 | yield* loadShellSnapshotParts({ | |
| 973 | sql, | |
| 974 | readThreads: threadManagement.readShellSnapshot({ location: "active" }), | |
| 975 | listProjects: projects.listShells(), | |
| 976 | latestSequence: applicationEvents.latestApplicationSequence, | |
| 978 | 977 | }), |
| 979 | 978 | ); |
| 980 | 979 | const enriched = yield* enrichProjectShells(base.projects); |
| ... | ... | @@ -1741,32 +1740,33 @@ const layerWsRpc = ( |
| 1741 | 1740 | .refreshStatus(cwd) |
| 1742 | 1741 | .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); |
| 1743 | 1742 | |
| 1744 | const getOrchestrationV2ArchivedShellSnapshot = sql | |
| 1745 | .withTransaction( | |
| 1746 | Effect.gen(function* () { | |
| 1747 | const threads = yield* threadManagement.getShellSnapshot({ location: "archive" }); | |
| 1748 | return { | |
| 1749 | schemaVersion: threads.schemaVersion, | |
| 1750 | snapshotSequence: yield* applicationEvents.latestApplicationSequence, | |
| 1751 | projects: yield* projectStore.listShells(), | |
| 1752 | threads: threads.archivedThreads, | |
| 1753 | } as const; | |
| 1754 | }), | |
| 1755 | ) | |
| 1756 | .pipe( | |
| 1757 | Effect.flatMap((snapshot) => | |
| 1758 | enrichProjectShells(snapshot.projects).pipe( | |
| 1759 | Effect.map(({ projects }) => ({ ...snapshot, projects })), | |
| 1760 | ), | |
| 1761 | ), | |
| 1762 | Effect.mapError( | |
| 1763 | (cause) => | |
| 1764 | new OrchestrationV2GetShellSnapshotError({ | |
| 1765 | message: "Failed to load archived thread snapshot", | |
| 1766 | cause, | |
| 1767 | }), | |
| 1743 | const getOrchestrationV2ArchivedShellSnapshot = Effect.gen(function* () { | |
| 1744 | const { threads, projects, snapshotSequence } = yield* loadShellSnapshotParts({ | |
| 1745 | sql, | |
| 1746 | readThreads: threadManagement.readShellSnapshot({ location: "archive" }), | |
| 1747 | listProjects: projectStore.listShells(), | |
| 1748 | latestSequence: applicationEvents.latestApplicationSequence, | |
| 1749 | }); | |
| 1750 | return { | |
| 1751 | schemaVersion: threads.schemaVersion, | |
| 1752 | snapshotSequence, | |
| 1753 | projects, | |
| 1754 | threads: threads.archivedThreads, | |
| 1755 | } as const; | |
| 1756 | }).pipe( | |
| 1757 | Effect.flatMap((snapshot) => | |
| 1758 | enrichProjectShells(snapshot.projects).pipe( | |
| 1759 | Effect.map(({ projects }) => ({ ...snapshot, projects })), | |
| 1768 | 1760 | ), |
| 1769 | ); | |
| 1761 | ), | |
| 1762 | Effect.mapError( | |
| 1763 | (cause) => | |
| 1764 | new OrchestrationV2GetShellSnapshotError({ | |
| 1765 | message: "Failed to load archived thread snapshot", | |
| 1766 | cause, | |
| 1767 | }), | |
| 1768 | ), | |
| 1769 | ); | |
| 1770 | 1770 | |
| 1771 | 1771 | const subscribeOrchestrationV2ArchivedShell = Effect.fn( |
| 1772 | 1772 | "ws.orchestrationV2.subscribeArchivedShell", |