Restore versioned shell-cache hydration, reconcile repository enrichment across multiple environments, and decode non-null settled shell timestamps correctly.
Unified · Split
| @@ -0,0 +1,176 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import type { ApplicationStoredEvent, OrchestrationV2ShellSnapshot } from "@t3tools/contracts"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Stream from "effect/Stream"; | ||
| 5 | |||
| 6 | import { | ||
| 7 | coalesceShellApplicationEvents, | ||
| 8 | composeShellStreamWithEnrichment, | ||
| 9 | shellStreamItemFromEnrichmentRefresh, | ||
| 10 | shellStreamItemsFromInitialSnapshot, | ||
| 11 | } from "./ShellStream.ts"; | ||
| 12 | |||
| 13 | function project(sequence: number, id: string): ApplicationStoredEvent { | ||
| 14 | return { | ||
| 15 | sequence, | ||
| 16 | aggregateKind: "project", | ||
| 17 | aggregateId: id, | ||
| 18 | } as ApplicationStoredEvent; | ||
| 19 | } | ||
| 20 | |||
| 21 | function thread(sequence: number, id: string): ApplicationStoredEvent { | ||
| 22 | return { | ||
| 23 | sequence, | ||
| 24 | event: { threadId: id }, | ||
| 25 | } as ApplicationStoredEvent; | ||
| 26 | } | ||
| 27 | |||
| 28 | const emptyShellSnapshot = { | ||
| 29 | schemaVersion: 1, | ||
| 30 | snapshotSequence: 0, | ||
| 31 | projects: [], | ||
| 32 | threads: [], | ||
| 33 | archivedThreads: [], | ||
| 34 | } as OrchestrationV2ShellSnapshot; | ||
| 35 | |||
| 36 | describe("coalesceShellApplicationEvents", () => { | ||
| 37 | it("keeps the newest event per aggregate and preserves sequence order", () => { | ||
| 38 | expect( | ||
| 39 | coalesceShellApplicationEvents([ | ||
| 40 | thread(2, "thread-a"), | ||
| 41 | project(3, "project-a"), | ||
| 42 | thread(4, "thread-b"), | ||
| 43 | thread(5, "thread-a"), | ||
| 44 | project(6, "project-a"), | ||
| 45 | ]).map((event) => event.sequence), | ||
| 46 | ).toEqual([4, 5, 6]); | ||
| 47 | }); | ||
| 48 | }); | ||
| 49 | |||
| 50 | describe("shellStreamItemFromEnrichmentRefresh", () => { | ||
| 51 | it("batches nearby completion roots onto one snapshot item", () => { | ||
| 52 | expect( | ||
| 53 | shellStreamItemFromEnrichmentRefresh({ | ||
| 54 | snapshot: emptyShellSnapshot, | ||
| 55 | changes: [ | ||
| 56 | { workspaceRoot: "/workspace/a" }, | ||
| 57 | { workspaceRoot: "/workspace/b" }, | ||
| 58 | { workspaceRoot: "/workspace/a" }, | ||
| 59 | ], | ||
| 60 | }), | ||
| 61 | ).toEqual({ | ||
| 62 | kind: "snapshot", | ||
| 63 | snapshot: emptyShellSnapshot, | ||
| 64 | resolvedRepositoryIdentityRoots: ["/workspace/a", "/workspace/b"], | ||
| 65 | }); | ||
| 66 | }); | ||
| 67 | }); | ||
| 68 | |||
| 69 | describe("shellStreamItemsFromInitialSnapshot", () => { | ||
| 70 | it("emits unmarked authoritative then same-sequence marked enrichment when roots resolved", () => { | ||
| 71 | const snapshot = { | ||
| 72 | ...emptyShellSnapshot, | ||
| 73 | snapshotSequence: 7, | ||
| 74 | } as OrchestrationV2ShellSnapshot; | ||
| 75 | |||
| 76 | expect( | ||
| 77 | shellStreamItemsFromInitialSnapshot({ | ||
| 78 | snapshot, | ||
| 79 | resolvedRepositoryIdentityRoots: ["/workspace/a", "/workspace/a"], | ||
| 80 | }), | ||
| 81 | ).toEqual([ | ||
| 82 | { kind: "snapshot", snapshot }, | ||
| 83 | { | ||
| 84 | kind: "snapshot", | ||
| 85 | snapshot, | ||
| 86 | resolvedRepositoryIdentityRoots: ["/workspace/a"], | ||
| 87 | }, | ||
| 88 | ]); | ||
| 89 | }); | ||
| 90 | |||
| 91 | it("emits only the unmarked authoritative snapshot when no roots resolved", () => { | ||
| 92 | expect( | ||
| 93 | shellStreamItemsFromInitialSnapshot({ | ||
| 94 | snapshot: emptyShellSnapshot, | ||
| 95 | resolvedRepositoryIdentityRoots: [], | ||
| 96 | }), | ||
| 97 | ).toEqual([{ kind: "snapshot", snapshot: emptyShellSnapshot }]); | ||
| 98 | }); | ||
| 99 | }); | ||
| 100 | |||
| 101 | describe("composeShellStreamWithEnrichment", () => { | ||
| 102 | it.effect( | ||
| 103 | "emits every initial item before enrichment even when enrichment is already ready", | ||
| 104 | () => | ||
| 105 | Effect.gen(function* () { | ||
| 106 | const initialSnapshot = { | ||
| 107 | ...emptyShellSnapshot, | ||
| 108 | snapshotSequence: 5, | ||
| 109 | } as OrchestrationV2ShellSnapshot; | ||
| 110 | const enrichmentSnapshot = { | ||
| 111 | ...emptyShellSnapshot, | ||
| 112 | snapshotSequence: 10, | ||
| 113 | } as OrchestrationV2ShellSnapshot; | ||
| 114 | |||
| 115 | const initialItems = shellStreamItemsFromInitialSnapshot({ | ||
| 116 | snapshot: initialSnapshot, | ||
| 117 | resolvedRepositoryIdentityRoots: ["/workspace/a"], | ||
| 118 | }); | ||
| 119 | // Enrichment stream is fully ready before the composed stream is pulled. | ||
| 120 | const enrichment = Stream.make( | ||
| 121 | shellStreamItemFromEnrichmentRefresh({ | ||
| 122 | snapshot: enrichmentSnapshot, | ||
| 123 | changes: [{ workspaceRoot: "/workspace/b" }], | ||
| 124 | }), | ||
| 125 | ); | ||
| 126 | const tail = Stream.make( | ||
| 127 | { kind: "synchronized" as const }, | ||
| 128 | { | ||
| 129 | kind: "project.removed" as const, | ||
| 130 | sequence: 6, | ||
| 131 | projectId: "project-a", | ||
| 132 | }, | ||
| 133 | ); | ||
| 134 | |||
| 135 | const items = Array.from( | ||
| 136 | yield* composeShellStreamWithEnrichment({ | ||
| 137 | initial: Stream.fromIterable(initialItems), | ||
| 138 | tail, | ||
| 139 | enrichment, | ||
| 140 | }).pipe(Stream.runCollect), | ||
| 141 | ); | ||
| 142 | |||
| 143 | expect(items.slice(0, initialItems.length)).toEqual(initialItems); | ||
| 144 | |||
| 145 | const enrichmentIndex = items.findIndex( | ||
| 146 | (item) => | ||
| 147 | item.kind === "snapshot" && | ||
| 148 | "resolvedRepositoryIdentityRoots" in item && | ||
| 149 | item.resolvedRepositoryIdentityRoots?.includes("/workspace/b"), | ||
| 150 | ); | ||
| 151 | expect(enrichmentIndex).toBeGreaterThanOrEqual(initialItems.length); | ||
| 152 | |||
| 153 | for (let index = 0; index < initialItems.length; index++) { | ||
| 154 | expect(items[index]).toEqual(initialItems[index]); | ||
| 155 | } | ||
| 156 | }), | ||
| 157 | ); | ||
| 158 | |||
| 159 | it.effect("still interleaves enrichment with the post-prefix tail after initials drain", () => | ||
| 160 | Effect.gen(function* () { | ||
| 161 | const items = Array.from( | ||
| 162 | yield* composeShellStreamWithEnrichment({ | ||
| 163 | initial: Stream.make("initial-unmarked", "initial-marked"), | ||
| 164 | tail: Stream.make("tail-a", "tail-b"), | ||
| 165 | enrichment: Stream.make("enrichment"), | ||
| 166 | }).pipe(Stream.runCollect), | ||
| 167 | ); | ||
| 168 | |||
| 169 | expect(items.slice(0, 2)).toEqual(["initial-unmarked", "initial-marked"]); | ||
| 170 | expect(items).toContain("enrichment"); | ||
| 171 | expect(items).toContain("tail-a"); | ||
| 172 | expect(items).toContain("tail-b"); | ||
| 173 | expect(items.indexOf("enrichment")).toBeGreaterThanOrEqual(2); | ||
| 174 | }), | ||
| 175 | ); | ||
| 176 | }); | ||
| @@ -1,9 +1,80 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | OrchestrationV2ArchivedShellStreamItem, | 2 | OrchestrationV2ArchivedShellStreamItem, |
| 3 | OrchestrationV2ShellSnapshot, | ||
| 3 | OrchestrationV2ThreadShellSnapshot, | 4 | OrchestrationV2ThreadShellSnapshot, |
| 4 | OrchestrationV2ShellStreamItem, | 5 | OrchestrationV2ShellStreamItem, |
| 5 | OrchestrationV2StoredEvent, | 6 | OrchestrationV2StoredEvent, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 8 | import * as Stream from "effect/Stream"; | ||
| 9 | |||
| 10 | /** Keep only the newest shell-relevant event per project/thread aggregate. */ | ||
| 11 | export function coalesceShellApplicationEvents( | ||
| 12 | events: ReadonlyArray<ApplicationStoredEvent>, | ||
| 13 | ): ReadonlyArray<ApplicationStoredEvent> { | ||
| 14 | const latestByAggregate = new Map<string, ApplicationStoredEvent>(); | ||
| 15 | for (const stored of events) { | ||
| 16 | const key = | ||
| 17 | "aggregateKind" in stored | ||
| 18 | ? `project:${stored.aggregateId}` | ||
| 19 | : `thread:${stored.event.threadId}`; | ||
| 20 | latestByAggregate.set(key, stored); | ||
| 21 | } | ||
| 22 | return Array.from(latestByAggregate.values()).sort( | ||
| 23 | (left, right) => left.sequence - right.sequence, | ||
| 24 | ); | ||
| 25 | } | ||
| 26 | |||
| 27 | /** | ||
| 28 | * Emit the initial shell prefix strictly first, then merge the post-prefix | ||
| 29 | * tail with enrichment refreshes. Prevents a newer marked enrichment from | ||
| 30 | * landing before the unmarked authoritative initial snapshot. | ||
| 31 | */ | ||
| 32 | export function composeShellStreamWithEnrichment<A, E, R, A2, E2, R2, A3, E3, R3>(input: { | ||
| 33 | readonly initial: Stream.Stream<A, E, R>; | ||
| 34 | readonly tail: Stream.Stream<A2, E2, R2>; | ||
| 35 | readonly enrichment: Stream.Stream<A3, E3, R3>; | ||
| 36 | }): Stream.Stream<A | A2 | A3, E | E2 | E3, R | R2 | R3> { | ||
| 37 | return Stream.concat(input.initial, Stream.merge(input.tail, input.enrichment)); | ||
| 38 | } | ||
| 39 | |||
| 40 | /** Build a shell snapshot stream item for a batched enrichment completion. */ | ||
| 41 | export function shellStreamItemFromEnrichmentRefresh(input: { | ||
| 42 | readonly snapshot: OrchestrationV2ShellSnapshot; | ||
| 43 | readonly changes: ReadonlyArray<{ readonly workspaceRoot: string }>; | ||
| 44 | }): Extract<OrchestrationV2ShellStreamItem, { readonly kind: "snapshot" }> { | ||
| 45 | return { | ||
| 46 | kind: "snapshot", | ||
| 47 | snapshot: input.snapshot, | ||
| 48 | resolvedRepositoryIdentityRoots: [ | ||
| 49 | ...new Set(input.changes.map((change) => change.workspaceRoot)), | ||
| 50 | ], | ||
| 51 | }; | ||
| 52 | } | ||
| 53 | |||
| 54 | /** | ||
| 55 | * Initial subscribe frames: always emit the unmarked authoritative snapshot, | ||
| 56 | * then a same-sequence enrichment frame only when some roots already resolved. | ||
| 57 | */ | ||
| 58 | export function shellStreamItemsFromInitialSnapshot(input: { | ||
| 59 | readonly snapshot: OrchestrationV2ShellSnapshot; | ||
| 60 | readonly resolvedRepositoryIdentityRoots: ReadonlyArray<string>; | ||
| 61 | }): ReadonlyArray<Extract<OrchestrationV2ShellStreamItem, { readonly kind: "snapshot" }>> { | ||
| 62 | const authoritative = { | ||
| 63 | kind: "snapshot" as const, | ||
| 64 | snapshot: input.snapshot, | ||
| 65 | }; | ||
| 66 | if (input.resolvedRepositoryIdentityRoots.length === 0) { | ||
| 67 | return [authoritative]; | ||
| 68 | } | ||
| 69 | return [ | ||
| 70 | authoritative, | ||
| 71 | { | ||
| 72 | kind: "snapshot" as const, | ||
| 73 | snapshot: input.snapshot, | ||
| 74 | resolvedRepositoryIdentityRoots: [...new Set(input.resolvedRepositoryIdentityRoots)], | ||
| 75 | }, | ||
| 76 | ]; | ||
| 77 | } | ||
| 7 | 78 | ||
| 8 | /** Converts a committed event and its resulting shell snapshot into one delta. */ | 79 | /** Converts a committed event and its resulting shell snapshot into one delta. */ |
| 9 | export function shellStreamItemFromSnapshot(input: { | 80 | export function shellStreamItemFromSnapshot(input: { |
| @@ -0,0 +1,121 @@ | |||
| 1 | import { | ||
| 2 | AuthOrchestrationReadScope, | ||
| 3 | EnvironmentHttpApi, | ||
| 4 | type OrchestrationProjectShell, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Predicate from "effect/Predicate"; | ||
| 8 | import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; | ||
| 9 | import * as SqlClient from "effect/unstable/sql/SqlClient"; | ||
| 10 | |||
| 11 | import { | ||
| 12 | annotateEnvironmentRequest, | ||
| 13 | failEnvironmentInternal, | ||
| 14 | failEnvironmentNotFound, | ||
| 15 | requireEnvironmentScope, | ||
| 16 | } from "../auth/http.ts"; | ||
| 17 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 18 | import * as OrchestrationEventStore from "../persistence/Services/OrchestrationEventStore.ts"; | ||
| 19 | import * as ProjectEnrichmentService from "../project/ProjectEnrichmentService.ts"; | ||
| 20 | import * as ThreadManagementService from "./ThreadManagementService.ts"; | ||
| 21 | |||
| 22 | function isThreadNotFound(error: unknown): boolean { | ||
| 23 | return ( | ||
| 24 | Predicate.hasProperty(error, "cause") && | ||
| 25 | Predicate.hasProperty(error.cause, "_tag") && | ||
| 26 | error.cause._tag === "ProjectionStoreThreadNotFoundError" | ||
| 27 | ); | ||
| 28 | } | ||
| 29 | |||
| 30 | /** | ||
| 31 | * Serves orchestration V2 snapshots over HTTP so clients can load the | ||
| 32 | * (potentially large) shell and thread projections off the socket — gzip | ||
| 33 | * compressible and cacheable — and then resume the WebSocket subscription via | ||
| 34 | * `afterSequence`. | ||
| 35 | */ | ||
| 36 | export const orchestrationHttpApiLayer = HttpApiBuilder.group( | ||
| 37 | EnvironmentHttpApi, | ||
| 38 | "orchestration", | ||
| 39 | Effect.fnUntraced(function* (handlers) { | ||
| 40 | const sql = yield* SqlClient.SqlClient; | ||
| 41 | const threadManagement = yield* ThreadManagementService.ThreadManagementService; | ||
| 42 | const applicationEvents = yield* OrchestrationEventStore.OrchestrationEventStore; | ||
| 43 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; | ||
| 44 | const projectEnrichment = yield* ProjectEnrichmentService.ProjectEnrichmentService; | ||
| 45 | |||
| 46 | const enrichProjectShells = Effect.fn("http.orchestration.enrichProjectShells")( | ||
| 47 | (projects: ReadonlyArray<OrchestrationProjectShell>) => | ||
| 48 | Effect.forEach( | ||
| 49 | projects, | ||
| 50 | (project) => | ||
| 51 | // Use immediately available enrichment only. Awaiting git-backed | ||
| 52 | // identity resolution can exceed the client shell-snapshot budget | ||
| 53 | // (ProcessRunner allows probes up to one minute). Background workers | ||
| 54 | // plus the WS enrichment subscription fill in repositoryIdentity. | ||
| 55 | projectEnrichment.getAvailable(project.workspaceRoot).pipe( | ||
| 56 | Effect.map((enrichment) => ({ | ||
| 57 | ...project, | ||
| 58 | repositoryIdentity: enrichment.repositoryIdentity, | ||
| 59 | })), | ||
| 60 | ), | ||
| 61 | { concurrency: 16 }, | ||
| 62 | ), | ||
| 63 | ); | ||
| 64 | |||
| 65 | const loadShellSnapshot = Effect.fn("http.orchestration.loadShellSnapshot")(function* () { | ||
| 66 | const base = yield* sql.withTransaction( | ||
| 67 | Effect.gen(function* () { | ||
| 68 | const projects = yield* projectionSnapshotQuery.getShellSnapshotWithoutEnrichment(); | ||
| 69 | const threads = yield* threadManagement.getShellSnapshot(); | ||
| 70 | return { | ||
| 71 | schemaVersion: threads.schemaVersion, | ||
| 72 | snapshotSequence: yield* applicationEvents.latestApplicationSequence, | ||
| 73 | projects: projects.projects, | ||
| 74 | threads: threads.threads, | ||
| 75 | archivedThreads: threads.archivedThreads, | ||
| 76 | } as const; | ||
| 77 | }), | ||
| 78 | ); | ||
| 79 | const projects = yield* enrichProjectShells(base.projects); | ||
| 80 | return { ...base, projects }; | ||
| 81 | }); | ||
| 82 | |||
| 83 | return handlers | ||
| 84 | .handle( | ||
| 85 | "shellSnapshot", | ||
| 86 | Effect.fn("environment.orchestration.shellSnapshot")(function* (args) { | ||
| 87 | yield* annotateEnvironmentRequest(args.endpoint.name); | ||
| 88 | yield* requireEnvironmentScope(AuthOrchestrationReadScope); | ||
| 89 | return yield* loadShellSnapshot().pipe( | ||
| 90 | Effect.catch((cause) => | ||
| 91 | failEnvironmentInternal("orchestration_snapshot_failed", cause), | ||
| 92 | ), | ||
| 93 | ); | ||
| 94 | }), | ||
| 95 | ) | ||
| 96 | .handle( | ||
| 97 | "threadSnapshot", | ||
| 98 | Effect.fn("environment.orchestration.threadSnapshot")(function* (args) { | ||
| 99 | yield* annotateEnvironmentRequest(args.endpoint.name); | ||
| 100 | yield* requireEnvironmentScope(AuthOrchestrationReadScope); | ||
| 101 | const snapshot = yield* threadManagement.getThreadSnapshot(args.params.threadId).pipe( | ||
| 102 | Effect.catch( | ||
| 103 | Effect.fnUntraced(function* (error) { | ||
| 104 | if (isThreadNotFound(error)) { | ||
| 105 | return yield* failEnvironmentNotFound("thread_not_found"); | ||
| 106 | } | ||
| 107 | return yield* failEnvironmentInternal( | ||
| 108 | "orchestration_thread_snapshot_failed", | ||
| 109 | error, | ||
| 110 | ); | ||
| 111 | }), | ||
| 112 | ), | ||
| 113 | ); | ||
| 114 | return { | ||
| 115 | snapshotSequence: snapshot.snapshotSequence, | ||
| 116 | projection: snapshot.projection, | ||
| 117 | }; | ||
| 118 | }), | ||
| 119 | ); | ||
| 120 | }), | ||
| 121 | ); | ||
| @@ -98,9 +98,19 @@ const SharedApplicationDataPlaneTestLayer = Layer.merge( · 97 unmodified lines | |||
| 98 | ).pipe( | 98 | ).pipe( |
| 99 | Layer.provide( | 99 | Layer.provide( |
| 100 | Layer.succeed(ProjectEnrichmentService, { | 100 | Layer.succeed(ProjectEnrichmentService, { |
| 101 | peek: () => Effect.succeed({ repositoryIdentity: null, faviconPath: null }), | 101 | peek: () => |
| 102 | Effect.succeed({ | ||
| 103 | repositoryIdentity: null, | ||
| 104 | faviconPath: null, | ||
| 105 | repositoryIdentityResolved: false, | ||
| 106 | }), | ||
| 102 | request: () => Effect.void, | 107 | request: () => Effect.void, |
| 103 | getAvailable: () => Effect.succeed({ repositoryIdentity: null, faviconPath: null }), | 108 | getAvailable: () => |
| 109 | Effect.succeed({ | ||
| 110 | repositoryIdentity: null, | ||
| 111 | faviconPath: null, | ||
| 112 | repositoryIdentityResolved: false, | ||
| 113 | }), | ||
| 104 | invalidate: () => Effect.void, | 114 | invalidate: () => Effect.void, |
| 105 | subscribeChanges: Effect.never, | 115 | subscribeChanges: Effect.never, |
| 106 | }), | 116 | }), |
| @@ -168,6 +168,110 @@ it.effect("keeps repository workers available when every favicon worker is hung" · 167 unmodified lines | |||
| 168 | }), | 168 | }), |
| 169 | ); | 169 | ); |
| 170 | 170 | ||
| 171 | it.effect("getAvailable returns immediately while repository identity is still unresolved", () => | ||
| 172 | Effect.gen(function* () { | ||
| 173 | const repositoryStarted = yield* Deferred.make<void>(); | ||
| 174 | const releaseRepository = yield* Deferred.make<void>(); | ||
| 175 | const metadataLayer = Layer.merge( | ||
| 176 | Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { | ||
| 177 | resolve: (workspaceRoot) => | ||
| 178 | Deferred.succeed(repositoryStarted, undefined).pipe( | ||
| 179 | Effect.andThen(Deferred.await(releaseRepository)), | ||
| 180 | Effect.as(identity(workspaceRoot)), | ||
| 181 | ), | ||
| 182 | }), | ||
| 183 | Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { | ||
| 184 | resolvePath: (workspaceRoot) => Effect.succeed(`${workspaceRoot}/favicon.svg`), | ||
| 185 | }), | ||
| 186 | ); | ||
| 187 | |||
| 188 | yield* Effect.gen(function* () { | ||
| 189 | const service = yield* ProjectEnrichment.ProjectEnrichmentService; | ||
| 190 | |||
| 191 | // Non-blocking path must not wait on hung git/identity resolution. | ||
| 192 | const immediate = yield* service.getAvailable("/pending-identity"); | ||
| 193 | assert.isNull(immediate.repositoryIdentity); | ||
| 194 | assert.isNull(immediate.faviconPath); | ||
| 195 | assert.isFalse(immediate.repositoryIdentityResolved); | ||
| 196 | |||
| 197 | yield* Deferred.await(repositoryStarted); | ||
| 198 | |||
| 199 | // Background lane still resolves after the snapshot path has returned. | ||
| 200 | yield* Deferred.succeed(releaseRepository, undefined); | ||
| 201 | const resolved = yield* waitForAvailable( | ||
| 202 | service, | ||
| 203 | "/pending-identity", | ||
| 204 | (value) => value.repositoryIdentity !== null, | ||
| 205 | ); | ||
| 206 | assert.equal(resolved.repositoryIdentity?.canonicalKey, "example.test/v1/pending-identity"); | ||
| 207 | assert.isTrue(resolved.repositoryIdentityResolved); | ||
| 208 | }).pipe(Effect.provide(makeLayer(metadataLayer))); | ||
| 209 | }), | ||
| 210 | ); | ||
| 211 | |||
| 212 | it.effect( | ||
| 213 | "reports successful cached null as resolved while cold and failed lookups stay unresolved", | ||
| 214 | () => | ||
| 215 | Effect.gen(function* () { | ||
| 216 | const repositoryStarted = yield* Deferred.make<void>(); | ||
| 217 | const releaseRepository = yield* Deferred.make<void>(); | ||
| 218 | const metadataLayer = Layer.merge( | ||
| 219 | Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { | ||
| 220 | resolve: (workspaceRoot) => { | ||
| 221 | if (workspaceRoot === "/no-remote") { | ||
| 222 | return Effect.succeed(null); | ||
| 223 | } | ||
| 224 | if (workspaceRoot === "/fails") { | ||
| 225 | return Effect.die("repository resolver failed"); | ||
| 226 | } | ||
| 227 | return Deferred.succeed(repositoryStarted, undefined).pipe( | ||
| 228 | Effect.andThen(Deferred.await(releaseRepository)), | ||
| 229 | Effect.as(identity(workspaceRoot)), | ||
| 230 | ); | ||
| 231 | }, | ||
| 232 | }), | ||
| 233 | Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { | ||
| 234 | resolvePath: () => Effect.succeed(null), | ||
| 235 | }), | ||
| 236 | ); | ||
| 237 | |||
| 238 | yield* Effect.gen(function* () { | ||
| 239 | const service = yield* ProjectEnrichment.ProjectEnrichmentService; | ||
| 240 | const changes = yield* service.subscribeChanges; | ||
| 241 | |||
| 242 | const cold = yield* service.peek("/never-requested"); | ||
| 243 | assert.isNull(cold.repositoryIdentity); | ||
| 244 | assert.isFalse(cold.repositoryIdentityResolved); | ||
| 245 | |||
| 246 | const inFlight = yield* service.getAvailable("/in-flight"); | ||
| 247 | assert.isNull(inFlight.repositoryIdentity); | ||
| 248 | assert.isFalse(inFlight.repositoryIdentityResolved); | ||
| 249 | yield* Deferred.await(repositoryStarted); | ||
| 250 | assert.isFalse((yield* service.peek("/in-flight")).repositoryIdentityResolved); | ||
| 251 | |||
| 252 | yield* service.request("/no-remote"); | ||
| 253 | const nullChange = yield* PubSub.take(changes); | ||
| 254 | assert.equal(nullChange.workspaceRoot, "/no-remote"); | ||
| 255 | assert.isTrue(nullChange.repositoryIdentityResolved); | ||
| 256 | assert.isNull(nullChange.enrichment.repositoryIdentity); | ||
| 257 | assert.isTrue(nullChange.enrichment.repositoryIdentityResolved); | ||
| 258 | |||
| 259 | // Warm success null stays resolved and does not republish. | ||
| 260 | const warmNull = yield* service.getAvailable("/no-remote"); | ||
| 261 | assert.isNull(warmNull.repositoryIdentity); | ||
| 262 | assert.isTrue(warmNull.repositoryIdentityResolved); | ||
| 263 | |||
| 264 | yield* service.request("/fails"); | ||
| 265 | const failChange = yield* PubSub.take(changes); | ||
| 266 | assert.equal(failChange.workspaceRoot, "/fails"); | ||
| 267 | assert.isFalse(failChange.repositoryIdentityResolved); | ||
| 268 | const failed = yield* service.peek("/fails"); | ||
| 269 | assert.isNull(failed.repositoryIdentity); | ||
| 270 | assert.isFalse(failed.repositoryIdentityResolved); | ||
| 271 | }).pipe(Effect.provide(makeLayer(metadataLayer))); | ||
| 272 | }), | ||
| 273 | ); | ||
| 274 | |||
| 171 | it.effect("deduplicates requests, bounds pending work, and reloads invalidated roots", () => | 275 | it.effect("deduplicates requests, bounds pending work, and reloads invalidated roots", () => |
| 172 | Effect.gen(function* () { | 276 | Effect.gen(function* () { |
| 173 | const firstStarted = yield* Deferred.make<void>(); | 277 | const firstStarted = yield* Deferred.make<void>(); |
| @@ -24,6 +24,8 @@ const DEFAULT_FAILURE_TTL = Duration.seconds(5); · 23 unmodified lines | |||
| 24 | export interface ProjectEnrichment { | 24 | export interface ProjectEnrichment { |
| 25 | readonly repositoryIdentity: RepositoryIdentity | null; | 25 | readonly repositoryIdentity: RepositoryIdentity | null; |
| 26 | readonly faviconPath: string | null; | 26 | readonly faviconPath: string | null; |
| 27 | /** True when identity resolution completed successfully, including cached null. */ | ||
| 28 | readonly repositoryIdentityResolved: boolean; | ||
| 27 | } | 29 | } |
| 28 | 30 | ||
| 29 | export interface ProjectEnrichmentChange { | 31 | export interface ProjectEnrichmentChange { |
| @@ -73,6 +75,13 @@ function availableValue<A, E>(cached: Option.Option<Exit.Exit<A, E>>): A | null · 43 unmodified lines | |||
| 73 | }); | 75 | }); |
| 74 | } | 76 | } |
| 75 | 77 | ||
| 78 | function isSuccessfullyResolved<A, E>(cached: Option.Option<Exit.Exit<A, E>>): boolean { | ||
| 79 | return Option.match(cached, { | ||
| 80 | onNone: () => false, | ||
| 81 | onSome: (exit) => Exit.isSuccess(exit), | ||
| 82 | }); | ||
| 83 | } | ||
| 84 | |||
| 76 | interface EnrichmentWorkLane { | 85 | interface EnrichmentWorkLane { |
| 77 | readonly pendingRoots: Ref.Ref<ReadonlySet<string>>; | 86 | readonly pendingRoots: Ref.Ref<ReadonlySet<string>>; |
| 78 | readonly queue: Queue.Queue<string>; | 87 | readonly queue: Queue.Queue<string>; |
| @@ -159,12 +168,14 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( · 80 unmodified lines | |||
| 159 | const repositoryIdentity = yield* Cache.get(repositoryIdentityCache, workspaceRoot); | 168 | const repositoryIdentity = yield* Cache.get(repositoryIdentityCache, workspaceRoot); |
| 160 | yield* logFailure(workspaceRoot, "repositoryIdentity", repositoryIdentity); | 169 | yield* logFailure(workspaceRoot, "repositoryIdentity", repositoryIdentity); |
| 161 | const faviconPath = yield* Cache.getSuccess(faviconCache, workspaceRoot); | 170 | const faviconPath = yield* Cache.getSuccess(faviconCache, workspaceRoot); |
| 171 | const repositoryIdentityResolved = Exit.isSuccess(repositoryIdentity); | ||
| 162 | yield* PubSub.publish(changes, { | 172 | yield* PubSub.publish(changes, { |
| 163 | workspaceRoot, | 173 | workspaceRoot, |
| 164 | repositoryIdentityResolved: Exit.isSuccess(repositoryIdentity), | 174 | repositoryIdentityResolved, |
| 165 | enrichment: { | 175 | enrichment: { |
| 166 | repositoryIdentity: availableValue(Option.some(repositoryIdentity)), | 176 | repositoryIdentity: availableValue(Option.some(repositoryIdentity)), |
| 167 | faviconPath: availableValue(faviconPath), | 177 | faviconPath: availableValue(faviconPath), |
| 178 | repositoryIdentityResolved, | ||
| 168 | }, | 179 | }, |
| 169 | }); | 180 | }); |
| 170 | }, | 181 | }, |
| @@ -227,6 +238,7 @@ export const make = Effect.fn("ProjectEnrichmentService.make")(function* ( · 56 unmodified lines | |||
| 227 | return { | 238 | return { |
| 228 | repositoryIdentity: availableValue(repositoryIdentity), | 239 | repositoryIdentity: availableValue(repositoryIdentity), |
| 229 | faviconPath: availableValue(faviconPath), | 240 | faviconPath: availableValue(faviconPath), |
| 241 | repositoryIdentityResolved: isSuccessfullyResolved(repositoryIdentity), | ||
| 230 | }; | 242 | }; |
| 231 | }); | 243 | }); |
| 232 | 244 | ||
| @@ -70,7 +70,11 @@ import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts" · 69 unmodified lines | |||
| 70 | import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; | 70 | import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; |
| 71 | import { | 71 | import { |
| 72 | archivedShellStreamItemFromSnapshot, | 72 | archivedShellStreamItemFromSnapshot, |
| 73 | coalesceShellApplicationEvents, | ||
| 74 | composeShellStreamWithEnrichment, | ||
| 75 | shellStreamItemFromEnrichmentRefresh, | ||
| 73 | shellStreamItemFromSnapshot, | 76 | shellStreamItemFromSnapshot, |
| 77 | shellStreamItemsFromInitialSnapshot, | ||
| 74 | } from "./orchestration-v2/ShellStream.ts"; | 78 | } from "./orchestration-v2/ShellStream.ts"; |
| 75 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | 79 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 76 | import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts"; | 80 | import * as OrchestrationEventStore from "./persistence/Services/OrchestrationEventStore.ts"; |
| @@ -429,13 +433,28 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 352 unmodified lines | |||
| 429 | Effect.forEach( | 433 | Effect.forEach( |
| 430 | projects, | 434 | projects, |
| 431 | (project) => | 435 | (project) => |
| 436 | // Non-blocking: emit with cached identity (or null) and schedule | ||
| 437 | // background resolution. subscribeChanges is attached before | ||
| 438 | // loadSnapshot, so later identity completions push refreshed | ||
| 439 | // shells for multi-env grouping without blocking the initial | ||
| 440 | // snapshot or completion marker on slow git probes. | ||
| 432 | projectEnrichment.getAvailable(project.workspaceRoot).pipe( | 441 | projectEnrichment.getAvailable(project.workspaceRoot).pipe( |
| 433 | Effect.map((enrichment) => ({ | 442 | Effect.map((enrichment) => ({ |
| 434 | ...project, | 443 | project: { |
| 435 | repositoryIdentity: enrichment.repositoryIdentity, | 444 | ...project, |
| 445 | repositoryIdentity: enrichment.repositoryIdentity, | ||
| 446 | }, | ||
| 447 | repositoryIdentityResolved: enrichment.repositoryIdentityResolved, | ||
| 436 | })), | 448 | })), |
| 437 | ), | 449 | ), |
| 438 | { concurrency: 16 }, | 450 | { concurrency: 16 }, |
| 451 | ).pipe( | ||
| 452 | Effect.map((enriched) => ({ | ||
| 453 | projects: enriched.map((entry) => entry.project), | ||
| 454 | resolvedRepositoryIdentityRoots: enriched | ||
| 455 | .filter((entry) => entry.repositoryIdentityResolved) | ||
| 456 | .map((entry) => entry.project.workspaceRoot), | ||
| 457 | })), | ||
| 439 | ), | 458 | ), |
| 440 | ); | 459 | ); |
| 441 | const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; | 460 | const threadLaunch = yield* ThreadLaunchService.ThreadLaunchService; |
| @@ -660,8 +679,11 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 218 unmodified lines | |||
| 660 | } as const; | 679 | } as const; |
| 661 | }), | 680 | }), |
| 662 | ); | 681 | ); |
| 663 | const projects = yield* enrichProjectShells(base.projects); | 682 | const enriched = yield* enrichProjectShells(base.projects); |
| 664 | return { ...base, projects }; | 683 | return { |
| 684 | snapshot: { ...base, projects: enriched.projects } as OrchestrationV2ShellSnapshot, | ||
| 685 | resolvedRepositoryIdentityRoots: enriched.resolvedRepositoryIdentityRoots, | ||
| 686 | }; | ||
| 665 | }); | 687 | }); |
| 666 | const snapshot = yield* loadSnapshot().pipe( | 688 | const snapshot = yield* loadSnapshot().pipe( |
| 667 | Effect.mapError( | 689 | Effect.mapError( |
| @@ -717,15 +739,98 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 49 unmodified lines | |||
| 717 | 739 | ||
| 718 | const enrichmentRefreshes = Stream.fromSubscription(enrichmentChanges).pipe( | 740 | const enrichmentRefreshes = Stream.fromSubscription(enrichmentChanges).pipe( |
| 719 | Stream.filter((change) => change.repositoryIdentityResolved), | 741 | Stream.filter((change) => change.repositoryIdentityResolved), |
| 720 | Stream.debounce("25 millis"), | 742 | Stream.groupedWithin(64, Duration.millis(25)), |
| 721 | Stream.mapEffect(() => loadSnapshot()), | 743 | Stream.mapEffect((changes) => |
| 722 | Stream.map((snapshot) => ({ kind: "snapshot" as const, snapshot })), | 744 | loadSnapshot().pipe( |
| 745 | Effect.map(({ snapshot }) => | ||
| 746 | shellStreamItemFromEnrichmentRefresh({ | ||
| 747 | snapshot, | ||
| 748 | changes: Array.from(changes), | ||
| 749 | }), | ||
| 750 | ), | ||
| 751 | ), | ||
| 752 | ), | ||
| 723 | ); | 753 | ); |
| 724 | 754 | ||
| 725 | return Stream.merge( | 755 | // Always attach the enrichment subscription before the first load so |
| 726 | Stream.concat(Stream.make({ kind: "snapshot" as const, snapshot }), live), | 756 | // completions that race HTTP snapshot fetch still push a refresh. |
| 727 | enrichmentRefreshes, | 757 | // When the client already holds a shell snapshot (cached, or loaded |
| 728 | ).pipe( | 758 | // over HTTP) it passes that snapshot's sequence. We still emit one |
| 759 | // enriched snapshot up front: getAvailable may have been cold on the | ||
| 760 | // HTTP path (null identity), and enrichment PubSub events published | ||
| 761 | // before this subscribe attached are dropped. Rehydrating here fills | ||
| 762 | // repositoryIdentity for cross-environment project grouping even on | ||
| 763 | // afterSequence resumes. Application events after the sequence still | ||
| 764 | // stream as deltas; overlapping events are deduped by sequence on the | ||
| 765 | // client. | ||
| 766 | // | ||
| 767 | // After the unmarked authoritative frame, emit a same-sequence | ||
| 768 | // metadata-only frame for roots that already resolved successfully | ||
| 769 | // (including cached null). Cold/failed roots stay unmarked and use | ||
| 770 | // the PubSub enrichment path when they complete later. | ||
| 771 | const completionMarker = | ||
| 772 | input.requestCompletionMarker === true | ||
| 773 | ? Stream.make({ kind: "synchronized" as const }) | ||
| 774 | : Stream.empty; | ||
| 775 | const initialSnapshotItems = (loaded: { | ||
| 776 | readonly snapshot: OrchestrationV2ShellSnapshot; | ||
| 777 | readonly resolvedRepositoryIdentityRoots: ReadonlyArray<string>; | ||
| 778 | }) => | ||
| 779 | Stream.fromIterable( | ||
| 780 | shellStreamItemsFromInitialSnapshot({ | ||
| 781 | snapshot: loaded.snapshot, | ||
| 782 | resolvedRepositoryIdentityRoots: loaded.resolvedRepositoryIdentityRoots, | ||
| 783 | }), | ||
| 784 | ); | ||
| 785 | // Initial unmarked (+ optional same-load marked) always drains first. | ||
| 786 | // Enrichment merges only with the post-prefix tail so a ready marked | ||
| 787 | // refresh cannot interleave before the authoritative initial frame. | ||
| 788 | const completionThenLive = (afterSequence: number) => | ||
| 789 | Stream.concat(completionMarker, liveFrom(afterSequence)); | ||
| 790 | |||
| 791 | const stream = yield* Effect.gen(function* () { | ||
| 792 | const loaded = yield* loadSnapshot(); | ||
| 793 | const initial = initialSnapshotItems(loaded); | ||
| 794 | if (input.afterSequence === undefined) { | ||
| 795 | return composeShellStreamWithEnrichment({ | ||
| 796 | initial, | ||
| 797 | tail: completionThenLive(loaded.snapshot.snapshotSequence), | ||
| 798 | enrichment: enrichmentRefreshes, | ||
| 799 | }); | ||
| 800 | } | ||
| 801 | |||
| 802 | const highWater = yield* applicationEvents.latestApplicationSequence; | ||
| 803 | const replayGap = highWater - input.afterSequence; | ||
| 804 | if (replayGap < 0 || replayGap > 1_000) { | ||
| 805 | return composeShellStreamWithEnrichment({ | ||
| 806 | initial, | ||
| 807 | tail: completionThenLive(loaded.snapshot.snapshotSequence), | ||
| 808 | enrichment: enrichmentRefreshes, | ||
| 809 | }); | ||
| 810 | } | ||
| 811 | |||
| 812 | const replay = toShellStream( | ||
| 813 | applicationEvents.readApplicationEvents({ | ||
| 814 | afterSequence: input.afterSequence, | ||
| 815 | throughSequence: highWater, | ||
| 816 | }), | ||
| 817 | ); | ||
| 818 | return composeShellStreamWithEnrichment({ | ||
| 819 | initial, | ||
| 820 | tail: Stream.concat(Stream.concat(replay, completionMarker), liveFrom(highWater)), | ||
| 821 | enrichment: enrichmentRefreshes, | ||
| 822 | }); | ||
| 823 | }).pipe( | ||
| 824 | Effect.mapError( | ||
| 825 | (cause) => | ||
| 826 | new OrchestrationV2GetShellSnapshotError({ | ||
| 827 | message: "Failed to prepare the application shell stream", | ||
| 828 | cause, | ||
| 829 | }), | ||
| 830 | ), | ||
| 831 | ); | ||
| 832 | |||
| 833 | return stream.pipe( | ||
| 729 | Stream.mapError( | 834 | Stream.mapError( |
| 730 | (cause) => | 835 | (cause) => |
| 731 | new OrchestrationV2GetShellSnapshotError({ | 836 | new OrchestrationV2GetShellSnapshotError({ |
| @@ -753,7 +858,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => · 21 unmodified lines | |||
| 753 | .pipe( | 858 | .pipe( |
| 754 | Effect.flatMap((snapshot) => | 859 | Effect.flatMap((snapshot) => |
| 755 | enrichProjectShells(snapshot.projects).pipe( | 860 | enrichProjectShells(snapshot.projects).pipe( |
| 756 | Effect.map((projects) => ({ ...snapshot, projects })), | 861 | Effect.map(({ projects }) => ({ ...snapshot, projects })), |
| 757 | ), | 862 | ), |
| 758 | ), | 863 | ), |
| 759 | Effect.mapError( | 864 | Effect.mapError( |
| @@ -1,5 +1,11 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { EnvironmentId } from "@t3tools/contracts"; | 2 | import { |
| 3 | EnvironmentId, | ||
| 4 | MessageId, | ||
| 5 | RuntimeRequestId, | ||
| 6 | type OrchestrationV2ShellSnapshot, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import * as DateTime from "effect/DateTime"; | ||
| 3 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 4 | import * as Option from "effect/Option"; | 10 | import * as Option from "effect/Option"; |
| 5 | import * as Schema from "effect/Schema"; | 11 | import * as Schema from "effect/Schema"; |
| @@ -10,39 +16,107 @@ import { · 4 unmodified lines | |||
| 10 | StoredOrchestrationThreadSnapshot, | 16 | StoredOrchestrationThreadSnapshot, |
| 11 | decodeOrDiscardOrchestrationCache, | 17 | decodeOrDiscardOrchestrationCache, |
| 12 | } from "./orchestrationCache.ts"; | 18 | } from "./orchestrationCache.ts"; |
| 13 | import { v2Projection, v2ShellSnapshot, v2ThreadId } from "../state/orchestrationV2TestFixtures.ts"; | 19 | import { |
| 20 | v2Now, | ||
| 21 | v2Projection, | ||
| 22 | v2ShellSnapshot, | ||
| 23 | v2ThreadShell, | ||
| 24 | v2ThreadId, | ||
| 25 | } from "../state/orchestrationV2TestFixtures.ts"; | ||
| 14 | 26 | ||
| 15 | const environmentId = EnvironmentId.make("environment-cache-test"); | 27 | const environmentId = EnvironmentId.make("environment-cache-test"); |
| 16 | const decodeStoredShellSnapshot = Schema.decodeUnknownEffect(StoredOrchestrationShellSnapshot); | 28 | const StoredShellSnapshotJson = Schema.fromJsonString(StoredOrchestrationShellSnapshot); |
| 17 | const decodeStoredThreadSnapshot = Schema.decodeUnknownEffect(StoredOrchestrationThreadSnapshot); | 29 | const StoredThreadSnapshotJson = Schema.fromJsonString(StoredOrchestrationThreadSnapshot); |
| 30 | const encodeStoredShellSnapshot = Schema.encodeSync(StoredOrchestrationShellSnapshot); | ||
| 31 | const decodeStoredShellSnapshotSync = Schema.decodeUnknownSync(StoredOrchestrationShellSnapshot); | ||
| 32 | const encodeStoredShellSnapshotJson = Schema.encodeSync(StoredShellSnapshotJson); | ||
| 33 | const decodeStoredShellSnapshotJson = Schema.decodeUnknownSync(StoredShellSnapshotJson); | ||
| 34 | const encodeStoredThreadSnapshotJson = Schema.encodeSync(StoredThreadSnapshotJson); | ||
| 35 | const decodeStoredThreadSnapshotJson = Schema.decodeUnknownSync(StoredThreadSnapshotJson); | ||
| 36 | |||
| 37 | class TestCacheDecodeError extends Schema.TaggedErrorClass<TestCacheDecodeError>()( | ||
| 38 | "TestCacheDecodeError", | ||
| 39 | { | ||
| 40 | message: Schema.String, | ||
| 41 | }, | ||
| 42 | ) {} | ||
| 43 | |||
| 44 | const shellSnapshotWithSummaries: OrchestrationV2ShellSnapshot = { | ||
| 45 | ...v2ShellSnapshot, | ||
| 46 | threads: [ | ||
| 47 | { | ||
| 48 | ...v2ThreadShell, | ||
| 49 | pendingRuntimeRequest: { | ||
| 50 | id: RuntimeRequestId.make("runtime-request-v2"), | ||
| 51 | kind: "command", | ||
| 52 | createdAt: v2Now, | ||
| 53 | }, | ||
| 54 | latestVisibleMessage: { | ||
| 55 | id: MessageId.make("message-v2"), | ||
| 56 | role: "assistant", | ||
| 57 | text: "Done", | ||
| 58 | updatedAt: v2Now, | ||
| 59 | }, | ||
| 60 | latestUserMessageAt: v2Now, | ||
| 61 | settledAt: v2Now, | ||
| 62 | }, | ||
| 63 | ], | ||
| 64 | }; | ||
| 18 | 65 | ||
| 19 | describe("orchestration cache envelopes", () => { | 66 | describe("orchestration cache envelopes", () => { |
| 20 | it.effect("accepts V2 shell and thread cache envelopes", () => | 67 | it.effect("round-trips V2 shell and thread cache envelopes as JSON", () => |
| 21 | Effect.gen(function* () { | 68 | Effect.sync(() => { |
| 22 | const shell = yield* decodeStoredShellSnapshot({ | 69 | const encodedShell = encodeStoredShellSnapshotJson({ |
| 23 | schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, | 70 | schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, |
| 24 | environmentId, | 71 | environmentId, |
| 25 | snapshot: v2ShellSnapshot, | 72 | snapshot: shellSnapshotWithSummaries, |
| 26 | }); | 73 | }); |
| 27 | const thread = yield* decodeStoredThreadSnapshot({ | 74 | const shell = decodeStoredShellSnapshotJson(encodedShell); |
| 75 | const encodedThread = encodeStoredThreadSnapshotJson({ | ||
| 28 | schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, | 76 | schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, |
| 29 | environmentId, | 77 | environmentId, |
| 30 | threadId: v2ThreadId, | 78 | threadId: v2ThreadId, |
| 31 | thread: v2Projection, | 79 | thread: v2Projection, |
| 32 | }); | 80 | }); |
| 81 | const thread = decodeStoredThreadSnapshotJson(encodedThread); | ||
| 82 | const [threadShell] = shell.snapshot.threads; | ||
| 33 | 83 | ||
| 34 | expect(shell.snapshot).toEqual(v2ShellSnapshot); | 84 | expect(threadShell?.latestVisibleMessage?.text).toBe("Done"); |
| 35 | expect(thread.thread).toEqual(v2Projection); | 85 | expect( |
| 86 | threadShell?.latestVisibleMessage === null || | ||
| 87 | threadShell?.latestVisibleMessage === undefined | ||
| 88 | ? null | ||
| 89 | : DateTime.formatIso(threadShell.latestVisibleMessage.updatedAt), | ||
| 90 | ).toBe("2026-06-20T00:00:00.000Z"); | ||
| 91 | expect( | ||
| 92 | threadShell?.latestUserMessageAt === null || threadShell?.latestUserMessageAt === undefined | ||
| 93 | ? null | ||
| 94 | : DateTime.formatIso(threadShell.latestUserMessageAt), | ||
| 95 | ).toBe("2026-06-20T00:00:00.000Z"); | ||
| 96 | expect( | ||
| 97 | threadShell?.settledAt === null || threadShell?.settledAt === undefined | ||
| 98 | ? null | ||
| 99 | : DateTime.formatIso(threadShell.settledAt), | ||
| 100 | ).toBe("2026-06-20T00:00:00.000Z"); | ||
| 101 | expect(DateTime.formatIso(thread.snapshot.projection.thread.updatedAt)).toBe( | ||
| 102 | "2026-06-20T00:00:00.000Z", | ||
| 103 | ); | ||
| 104 | expect(thread.snapshot.projection).toEqual(v2Projection); | ||
| 105 | expect(thread.snapshot.snapshotSequence).toBe(4); | ||
| 36 | }), | 106 | }), |
| 37 | ); | 107 | ); |
| 38 | 108 | ||
| 39 | it.effect("discards V1-versioned cache envelopes after a decode failure", () => | 109 | it.effect("discards V1-versioned cache envelopes after a decode failure", () => |
| 40 | Effect.gen(function* () { | 110 | Effect.gen(function* () { |
| 41 | let discardCount = 0; | 111 | let discardCount = 0; |
| 42 | const decoded = decodeStoredShellSnapshot({ | 112 | const encodedShell = encodeStoredShellSnapshot({ |
| 43 | schemaVersion: 1, | 113 | schemaVersion: ORCHESTRATION_CACHE_SCHEMA_VERSION, |
| 44 | environmentId, | 114 | environmentId, |
| 45 | snapshot: v2ShellSnapshot, | 115 | snapshot: v2ShellSnapshot, |
| 116 | }); | ||
| 117 | const decoded = Effect.try({ | ||
| 118 | try: () => decodeStoredShellSnapshotSync({ ...encodedShell, schemaVersion: 1 }), | ||
| 119 | catch: (cause) => new TestCacheDecodeError({ message: String(cause) }), | ||
| 46 | }).pipe(Effect.map(Option.some)); | 120 | }).pipe(Effect.map(Option.some)); |
| 47 | 121 | ||
| 48 | const result = yield* decodeOrDiscardOrchestrationCache( | 122 | const result = yield* decodeOrDiscardOrchestrationCache( |
| @@ -1,7 +1,8 @@ | |||
| 1 | import { | 1 | import { |
| 2 | EnvironmentId, | 2 | EnvironmentId, |
| 3 | OrchestrationV2ShellSnapshot, | 3 | OrchestrationV2ShellSnapshotJson, |
| 4 | OrchestrationV2ThreadProjection, | 4 | OrchestrationV2ThreadDetailSnapshot, |
| 5 | OrchestrationV2ThreadProjectionJson, | ||
| 5 | ThreadId, | 6 | ThreadId, |
| 6 | } from "@t3tools/contracts"; | 7 | } from "@t3tools/contracts"; |
| 7 | import * as Effect from "effect/Effect"; | 8 | import * as Effect from "effect/Effect"; |
| @@ -13,14 +14,17 @@ export const ORCHESTRATION_CACHE_SCHEMA_VERSION = 2 as const; · 5 unmodified lines | |||
| 13 | export const StoredOrchestrationShellSnapshot = Schema.Struct({ | 14 | export const StoredOrchestrationShellSnapshot = Schema.Struct({ |
| 14 | schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION), | 15 | schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION), |
| 15 | environmentId: EnvironmentId, | 16 | environmentId: EnvironmentId, |
| 16 | snapshot: OrchestrationV2ShellSnapshot, | 17 | snapshot: OrchestrationV2ShellSnapshotJson, |
| 17 | }); | 18 | }); |
| 18 | 19 | ||
| 19 | export const StoredOrchestrationThreadSnapshot = Schema.Struct({ | 20 | export const StoredOrchestrationThreadSnapshot = Schema.Struct({ |
| 20 | schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION), | 21 | schemaVersion: Schema.Literal(ORCHESTRATION_CACHE_SCHEMA_VERSION), |
| 21 | environmentId: EnvironmentId, | 22 | environmentId: EnvironmentId, |
| 22 | threadId: ThreadId, | 23 | threadId: ThreadId, |
| 23 | thread: OrchestrationV2ThreadProjection, | 24 | snapshot: OrchestrationV2ThreadDetailSnapshot.mapFields((fields) => ({ |
| 25 | ...fields, | ||
| 26 | projection: OrchestrationV2ThreadProjectionJson, | ||
| 27 | })), | ||
| 24 | }); | 28 | }); |
| 25 | 29 | ||
| 26 | /** Invalid orchestration caches are disposable and must never block live synchronization. */ | 30 | /** Invalid orchestration caches are disposable and must never block live synchronization. */ |
| @@ -5,10 +5,14 @@ import { · 4 unmodified lines | |||
| 5 | type OrchestrationV2ShellStreamItem, | 5 | type OrchestrationV2ShellStreamItem, |
| 6 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 7 | import { describe, expect, it } from "@effect/vitest"; | 7 | import { describe, expect, it } from "@effect/vitest"; |
| 8 | import * as Deferred from "effect/Deferred"; | ||
| 8 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Exit from "effect/Exit"; | ||
| 11 | import * as Fiber from "effect/Fiber"; | ||
| 9 | import * as Option from "effect/Option"; | 12 | import * as Option from "effect/Option"; |
| 10 | import * as Queue from "effect/Queue"; | 13 | import * as Queue from "effect/Queue"; |
| 11 | import * as Ref from "effect/Ref"; | 14 | import * as Ref from "effect/Ref"; |
| 15 | import * as Scope from "effect/Scope"; | ||
| 12 | import * as Stream from "effect/Stream"; | 16 | import * as Stream from "effect/Stream"; |
| 13 | import * as SubscriptionRef from "effect/SubscriptionRef"; | 17 | import * as SubscriptionRef from "effect/SubscriptionRef"; |
| 14 | 18 | ||
| @@ -22,8 +26,8 @@ import * as ConnectionWakeups from "../connection/wakeups.ts"; · 7 unmodified lines | |||
| 22 | import * as Persistence from "../platform/persistence.ts"; | 26 | import * as Persistence from "../platform/persistence.ts"; |
| 23 | import * as RpcSession from "../rpc/session.ts"; | 27 | import * as RpcSession from "../rpc/session.ts"; |
| 24 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; | 28 | import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; |
| 25 | import { makeEnvironmentShellState } from "./shell.ts"; | 29 | import { makeEnvironmentShellState, ShellSnapshotLoader } from "./shell.ts"; |
| 26 | import { v2ShellSnapshot } from "./orchestrationV2TestFixtures.ts"; | 30 | import { v2Project, v2ShellSnapshot } from "./orchestrationV2TestFixtures.ts"; |
| 27 | 31 | ||
| 28 | const TARGET = new PrimaryConnectionTarget({ | 32 | const TARGET = new PrimaryConnectionTarget({ |
| 29 | environmentId: EnvironmentId.make("environment-1"), | 33 | environmentId: EnvironmentId.make("environment-1"), |
| @@ -70,7 +74,7 @@ describe("environment shell synchronization", () => { · 40 unmodified lines | |||
| 70 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | 74 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); |
| 71 | const cache = Persistence.EnvironmentCacheStore.of({ | 75 | const cache = Persistence.EnvironmentCacheStore.of({ |
| 72 | loadShell: () => Effect.succeed(Option.none()), | 76 | loadShell: () => Effect.succeed(Option.none()), |
| 73 | saveShell: () => Effect.never, | 77 | saveShell: () => Effect.void, |
| 74 | loadThread: () => Effect.succeed(Option.none()), | 78 | loadThread: () => Effect.succeed(Option.none()), |
| 75 | saveThread: () => Effect.void, | 79 | saveThread: () => Effect.void, |
| 76 | removeThread: () => Effect.void, | 80 | removeThread: () => Effect.void, |
| @@ -352,4 +356,415 @@ describe("environment shell synchronization", () => { · 275 unmodified lines | |||
| 352 | expect(yield* Ref.get(loaderCalls)).toBe(2); | 356 | expect(yield* Ref.get(loaderCalls)).toBe(2); |
| 353 | }), | 357 | }), |
| 354 | ); | 358 | ); |
| 359 | |||
| 360 | it.effect("flushes the latest live snapshot without blocking connection state updates", () => | ||
| 361 | Effect.gen(function* () { | ||
| 362 | const events = yield* Queue.unbounded<OrchestrationV2ShellStreamItem>(); | ||
| 363 | const saved = yield* Ref.make<ReadonlyArray<OrchestrationV2ShellSnapshot>>([]); | ||
| 364 | const saveStarted = yield* Deferred.make<void>(); | ||
| 365 | const releaseSave = yield* Deferred.make<void>(); | ||
| 366 | const client = { | ||
| 367 | [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), | ||
| 368 | } as unknown as WsRpcProtocolClient; | ||
| 369 | const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); | ||
| 370 | const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>( | ||
| 371 | Option.some(session(client)), | ||
| 372 | ); | ||
| 373 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | ||
| 374 | target: TARGET, | ||
| 375 | state: supervisorState, | ||
| 376 | session: activeSession, | ||
| 377 | prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), | ||
| 378 | connect: Effect.void, | ||
| 379 | disconnect: Effect.void, | ||
| 380 | retryNow: Effect.void, | ||
| 381 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | ||
| 382 | const cache = Persistence.EnvironmentCacheStore.of({ | ||
| 383 | loadShell: () => Effect.succeed(Option.none()), | ||
| 384 | saveShell: (_environmentId, snapshot) => | ||
| 385 | Deferred.succeed(saveStarted, undefined).pipe( | ||
| 386 | Effect.andThen(Deferred.await(releaseSave)), | ||
| 387 | Effect.andThen(Ref.update(saved, (values) => [...values, snapshot])), | ||
| 388 | ), | ||
| 389 | loadThread: () => Effect.succeed(Option.none()), | ||
| 390 | saveThread: () => Effect.void, | ||
| 391 | removeThread: () => Effect.void, | ||
| 392 | loadServerConfig: () => Effect.succeed(Option.none()), | ||
| 393 | saveServerConfig: () => Effect.void, | ||
| 394 | loadVcsRefs: () => Effect.succeed(Option.none()), | ||
| 395 | saveVcsRefs: () => Effect.void, | ||
| 396 | clear: () => Effect.void, | ||
| 397 | }); | ||
| 398 | const snapshotLoader = ShellSnapshotLoader.of({ | ||
| 399 | load: () => Effect.succeed(Option.none()), | ||
| 400 | }); | ||
| 401 | |||
| 402 | const shellState = yield* makeEnvironmentShellState().pipe( | ||
| 403 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 404 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), | ||
| 405 | Effect.provideService(ShellSnapshotLoader, snapshotLoader), | ||
| 406 | ); | ||
| 407 | |||
| 408 | yield* SubscriptionRef.set(supervisorState, { | ||
| 409 | desired: true, | ||
| 410 | network: "online", | ||
| 411 | phase: "connecting", | ||
| 412 | stage: "synchronizing", | ||
| 413 | attempt: 1, | ||
| 414 | generation: 0, | ||
| 415 | lastFailure: null, | ||
| 416 | retryAt: null, | ||
| 417 | }); | ||
| 418 | yield* Queue.offer(events, { | ||
| 419 | kind: "snapshot", | ||
| 420 | snapshot: LIVE_SHELL_SNAPSHOT, | ||
| 421 | }); | ||
| 422 | yield* Queue.offer(events, { kind: "synchronized" }); | ||
| 423 | yield* SubscriptionRef.changes(shellState).pipe( | ||
| 424 | Stream.filter((state) => state.status === "live"), | ||
| 425 | Stream.runHead, | ||
| 426 | ); | ||
| 427 | |||
| 428 | yield* SubscriptionRef.set(supervisorState, { | ||
| 429 | desired: true, | ||
| 430 | network: "online", | ||
| 431 | phase: "connecting", | ||
| 432 | stage: "synchronizing", | ||
| 433 | attempt: 2, | ||
| 434 | generation: 1, | ||
| 435 | lastFailure: null, | ||
| 436 | retryAt: null, | ||
| 437 | }); | ||
| 438 | yield* SubscriptionRef.set(supervisorState, { | ||
| 439 | desired: false, | ||
| 440 | network: "online", | ||
| 441 | phase: "available", | ||
| 442 | stage: null, | ||
| 443 | attempt: 2, | ||
| 444 | generation: 2, | ||
| 445 | lastFailure: null, | ||
| 446 | retryAt: null, | ||
| 447 | }); | ||
| 448 | yield* Deferred.await(saveStarted); | ||
| 449 | |||
| 450 | yield* SubscriptionRef.set(supervisorState, { | ||
| 451 | desired: true, | ||
| 452 | network: "online", | ||
| 453 | phase: "connecting", | ||
| 454 | stage: "synchronizing", | ||
| 455 | attempt: 3, | ||
| 456 | generation: 3, | ||
| 457 | lastFailure: null, | ||
| 458 | retryAt: null, | ||
| 459 | }); | ||
| 460 | for (let index = 0; index < 100; index += 1) { | ||
| 461 | if ((yield* SubscriptionRef.get(shellState)).status === "synchronizing") break; | ||
| 462 | yield* Effect.yieldNow; | ||
| 463 | } | ||
| 464 | |||
| 465 | expect((yield* SubscriptionRef.get(shellState)).status).toBe("synchronizing"); | ||
| 466 | expect(yield* Ref.get(saved)).toEqual([]); | ||
| 467 | |||
| 468 | yield* Deferred.succeed(releaseSave, undefined); | ||
| 469 | for (let index = 0; index < 100; index += 1) { | ||
| 470 | if ((yield* Ref.get(saved)).length > 0) break; | ||
| 471 | yield* Effect.yieldNow; | ||
| 472 | } | ||
| 473 | |||
| 474 | expect(yield* Ref.get(saved)).toEqual([LIVE_SHELL_SNAPSHOT]); | ||
| 475 | }), | ||
| 476 | ); | ||
| 477 | |||
| 478 | it.effect("re-persists same-sequence enrichment when an older in-flight save completes", () => | ||
| 479 | Effect.gen(function* () { | ||
| 480 | const repositoryIdentity = { | ||
| 481 | canonicalKey: "github.com/example/repo", | ||
| 482 | locator: { | ||
| 483 | source: "git-remote" as const, | ||
| 484 | remoteName: "origin", | ||
| 485 | remoteUrl: "https://github.com/example/repo.git", | ||
| 486 | }, | ||
| 487 | }; | ||
| 488 | const unenrichedSnapshot: OrchestrationV2ShellSnapshot = { | ||
| 489 | ...LIVE_SHELL_SNAPSHOT, | ||
| 490 | snapshotSequence: 1, | ||
| 491 | projects: [{ ...v2Project, repositoryIdentity: null }], | ||
| 492 | }; | ||
| 493 | const enrichedSnapshot: OrchestrationV2ShellSnapshot = { | ||
| 494 | ...LIVE_SHELL_SNAPSHOT, | ||
| 495 | snapshotSequence: 1, | ||
| 496 | projects: [{ ...v2Project, repositoryIdentity }], | ||
| 497 | }; | ||
| 498 | |||
| 499 | const events = yield* Queue.unbounded<OrchestrationV2ShellStreamItem>(); | ||
| 500 | const saved = yield* Ref.make<ReadonlyArray<OrchestrationV2ShellSnapshot>>([]); | ||
| 501 | const unenrichedSaveStarted = yield* Deferred.make<void>(); | ||
| 502 | const releaseUnenrichedSave = yield* Deferred.make<void>(); | ||
| 503 | const client = { | ||
| 504 | [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), | ||
| 505 | } as unknown as WsRpcProtocolClient; | ||
| 506 | const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); | ||
| 507 | const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>( | ||
| 508 | Option.some(session(client)), | ||
| 509 | ); | ||
| 510 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | ||
| 511 | target: TARGET, | ||
| 512 | state: supervisorState, | ||
| 513 | session: activeSession, | ||
| 514 | prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), | ||
| 515 | connect: Effect.void, | ||
| 516 | disconnect: Effect.void, | ||
| 517 | retryNow: Effect.void, | ||
| 518 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | ||
| 519 | const cache = Persistence.EnvironmentCacheStore.of({ | ||
| 520 | loadShell: () => Effect.succeed(Option.none()), | ||
| 521 | saveShell: (_environmentId, snapshot) => | ||
| 522 | Effect.gen(function* () { | ||
| 523 | if (snapshot.projects[0]?.repositoryIdentity == null) { | ||
| 524 | yield* Deferred.succeed(unenrichedSaveStarted, undefined); | ||
| 525 | yield* Deferred.await(releaseUnenrichedSave); | ||
| 526 | } | ||
| 527 | yield* Ref.update(saved, (values) => [...values, snapshot]); | ||
| 528 | }), | ||
| 529 | loadThread: () => Effect.succeed(Option.none()), | ||
| 530 | saveThread: () => Effect.void, | ||
| 531 | removeThread: () => Effect.void, | ||
| 532 | loadServerConfig: () => Effect.succeed(Option.none()), | ||
| 533 | saveServerConfig: () => Effect.void, | ||
| 534 | loadVcsRefs: () => Effect.succeed(Option.none()), | ||
| 535 | saveVcsRefs: () => Effect.void, | ||
| 536 | clear: () => Effect.void, | ||
| 537 | }); | ||
| 538 | const snapshotLoader = ShellSnapshotLoader.of({ | ||
| 539 | load: () => Effect.succeed(Option.none()), | ||
| 540 | }); | ||
| 541 | |||
| 542 | const shellState = yield* makeEnvironmentShellState().pipe( | ||
| 543 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 544 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), | ||
| 545 | Effect.provideService(ShellSnapshotLoader, snapshotLoader), | ||
| 546 | ); | ||
| 547 | |||
| 548 | yield* SubscriptionRef.set(supervisorState, { | ||
| 549 | desired: true, | ||
| 550 | network: "online", | ||
| 551 | phase: "connecting", | ||
| 552 | stage: "synchronizing", | ||
| 553 | attempt: 1, | ||
| 554 | generation: 0, | ||
| 555 | lastFailure: null, | ||
| 556 | retryAt: null, | ||
| 557 | }); | ||
| 558 | yield* Queue.offer(events, { | ||
| 559 | kind: "snapshot", | ||
| 560 | snapshot: unenrichedSnapshot, | ||
| 561 | }); | ||
| 562 | yield* Queue.offer(events, { kind: "synchronized" }); | ||
| 563 | yield* SubscriptionRef.changes(shellState).pipe( | ||
| 564 | Stream.filter((state) => state.status === "live"), | ||
| 565 | Stream.runHead, | ||
| 566 | ); | ||
| 567 | |||
| 568 | // Disconnect flush starts an immediate persist of the unenriched snapshot | ||
| 569 | // (debounced path needs TestClock advancement; flush does not). | ||
| 570 | yield* SubscriptionRef.set(supervisorState, { | ||
| 571 | desired: false, | ||
| 572 | network: "online", | ||
| 573 | phase: "available", | ||
| 574 | stage: null, | ||
| 575 | attempt: 1, | ||
| 576 | generation: 1, | ||
| 577 | lastFailure: null, | ||
| 578 | retryAt: null, | ||
| 579 | }); | ||
| 580 | yield* Deferred.await(unenrichedSaveStarted); | ||
| 581 | |||
| 582 | // Same sequence, different content. Session stays open so the stream can | ||
| 583 | // still apply enrichment while the older save is in flight. | ||
| 584 | yield* Queue.offer(events, { | ||
| 585 | kind: "snapshot", | ||
| 586 | snapshot: enrichedSnapshot, | ||
| 587 | }); | ||
| 588 | yield* SubscriptionRef.changes(shellState).pipe( | ||
| 589 | Stream.filter( | ||
| 590 | (state) => | ||
| 591 | Option.isSome(state.snapshot) && | ||
| 592 | state.snapshot.value.projects[0]?.repositoryIdentity != null, | ||
| 593 | ), | ||
| 594 | Stream.runHead, | ||
| 595 | ); | ||
| 596 | |||
| 597 | // Completing the older same-sequence save must re-persist the latest object. | ||
| 598 | yield* Deferred.succeed(releaseUnenrichedSave, undefined); | ||
| 599 | for (let index = 0; index < 100; index += 1) { | ||
| 600 | const values = yield* Ref.get(saved); | ||
| 601 | if (values.length > 0 && values.at(-1)?.projects[0]?.repositoryIdentity != null) { | ||
| 602 | break; | ||
| 603 | } | ||
| 604 | yield* Effect.yieldNow; | ||
| 605 | } | ||
| 606 | |||
| 607 | const finalSaved = (yield* Ref.get(saved)).at(-1); | ||
| 608 | expect(finalSaved?.snapshotSequence).toBe(1); | ||
| 609 | expect(finalSaved?.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity); | ||
| 610 | }), | ||
| 611 | ); | ||
| 612 | |||
| 613 | it.effect("scope teardown flush stays stable while a save is blocked", () => | ||
| 614 | Effect.gen(function* () { | ||
| 615 | const lateSnapshot: OrchestrationV2ShellSnapshot = { | ||
| 616 | ...LIVE_SHELL_SNAPSHOT, | ||
| 617 | snapshotSequence: 2, | ||
| 618 | projects: [{ ...v2Project, title: "Late stream event" }], | ||
| 619 | }; | ||
| 620 | const events = yield* Queue.unbounded<OrchestrationV2ShellStreamItem>(); | ||
| 621 | const saved = yield* Ref.make<ReadonlyArray<OrchestrationV2ShellSnapshot>>([]); | ||
| 622 | const saveStarted = yield* Deferred.make<void>(); | ||
| 623 | const releaseSave = yield* Deferred.make<void>(); | ||
| 624 | const client = { | ||
| 625 | [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), | ||
| 626 | } as unknown as WsRpcProtocolClient; | ||
| 627 | const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); | ||
| 628 | const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>( | ||
| 629 | Option.some(session(client)), | ||
| 630 | ); | ||
| 631 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | ||
| 632 | target: TARGET, | ||
| 633 | state: supervisorState, | ||
| 634 | session: activeSession, | ||
| 635 | prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), | ||
| 636 | connect: Effect.void, | ||
| 637 | disconnect: Effect.void, | ||
| 638 | retryNow: Effect.void, | ||
| 639 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | ||
| 640 | const cache = Persistence.EnvironmentCacheStore.of({ | ||
| 641 | loadShell: () => Effect.succeed(Option.none()), | ||
| 642 | saveShell: (_environmentId, snapshot) => | ||
| 643 | Deferred.succeed(saveStarted, undefined).pipe( | ||
| 644 | Effect.andThen(Deferred.await(releaseSave)), | ||
| 645 | Effect.andThen(Ref.update(saved, (values) => [...values, snapshot])), | ||
| 646 | ), | ||
| 647 | loadThread: () => Effect.succeed(Option.none()), | ||
| 648 | saveThread: () => Effect.void, | ||
| 649 | removeThread: () => Effect.void, | ||
| 650 | loadServerConfig: () => Effect.succeed(Option.none()), | ||
| 651 | saveServerConfig: () => Effect.void, | ||
| 652 | loadVcsRefs: () => Effect.succeed(Option.none()), | ||
| 653 | saveVcsRefs: () => Effect.void, | ||
| 654 | clear: () => Effect.void, | ||
| 655 | }); | ||
| 656 | const snapshotLoader = ShellSnapshotLoader.of({ | ||
| 657 | load: () => Effect.succeed(Option.none()), | ||
| 658 | }); | ||
| 659 | |||
| 660 | const scope = yield* Scope.make(); | ||
| 661 | const shellState = yield* makeEnvironmentShellState().pipe( | ||
| 662 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 663 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), | ||
| 664 | Effect.provideService(ShellSnapshotLoader, snapshotLoader), | ||
| 665 | Scope.provide(scope), | ||
| 666 | ); | ||
| 667 | |||
| 668 | yield* SubscriptionRef.set(supervisorState, { | ||
| 669 | desired: true, | ||
| 670 | network: "online", | ||
| 671 | phase: "connecting", | ||
| 672 | stage: "synchronizing", | ||
| 673 | attempt: 1, | ||
| 674 | generation: 0, | ||
| 675 | lastFailure: null, | ||
| 676 | retryAt: null, | ||
| 677 | }); | ||
| 678 | yield* Queue.offer(events, { | ||
| 679 | kind: "snapshot", | ||
| 680 | snapshot: LIVE_SHELL_SNAPSHOT, | ||
| 681 | }); | ||
| 682 | yield* Queue.offer(events, { kind: "synchronized" }); | ||
| 683 | yield* SubscriptionRef.changes(shellState).pipe( | ||
| 684 | Stream.filter((state) => state.status === "live"), | ||
| 685 | Stream.runHead, | ||
| 686 | ); | ||
| 687 | |||
| 688 | // Close scope so the finalizer flush blocks on save. Workers must be | ||
| 689 | // interrupted before that flush; late stream events must not extend it. | ||
| 690 | const closeFiber = yield* Effect.forkChild(Scope.close(scope, Exit.void)); | ||
| 691 | yield* Deferred.await(saveStarted); | ||
| 692 | yield* Queue.offer(events, { | ||
| 693 | kind: "snapshot", | ||
| 694 | snapshot: lateSnapshot, | ||
| 695 | }); | ||
| 696 | for (let index = 0; index < 50; index += 1) { | ||
| 697 | yield* Effect.yieldNow; | ||
| 698 | } | ||
| 699 | yield* Deferred.succeed(releaseSave, undefined); | ||
| 700 | yield* Fiber.join(closeFiber); | ||
| 701 | |||
| 702 | const values = yield* Ref.get(saved); | ||
| 703 | expect(values.length).toBeGreaterThan(0); | ||
| 704 | expect(values.every((snapshot) => snapshot.snapshotSequence === 1)).toBe(true); | ||
| 705 | expect(values.every((snapshot) => snapshot.projects[0]?.title !== "Late stream event")).toBe( | ||
| 706 | true, | ||
| 707 | ); | ||
| 708 | }), | ||
| 709 | ); | ||
| 710 | |||
| 711 | it.effect("applies authoritative lower-sequence HTTP resets over client-ahead cache", () => | ||
| 712 | Effect.gen(function* () { | ||
| 713 | const cachedSnapshot: OrchestrationV2ShellSnapshot = { | ||
| 714 | ...v2ShellSnapshot, | ||
| 715 | snapshotSequence: 20, | ||
| 716 | projects: [{ ...v2Project, title: "Client ahead" }], | ||
| 717 | }; | ||
| 718 | const httpSnapshot: OrchestrationV2ShellSnapshot = { | ||
| 719 | ...v2ShellSnapshot, | ||
| 720 | snapshotSequence: 4, | ||
| 721 | projects: [{ ...v2Project, title: "Server reset" }], | ||
| 722 | }; | ||
| 723 | const events = yield* Queue.unbounded<OrchestrationV2ShellStreamItem>(); | ||
| 724 | const client = { | ||
| 725 | [ORCHESTRATION_V2_WS_METHODS.subscribeShell]: () => Stream.fromQueue(events), | ||
| 726 | } as unknown as WsRpcProtocolClient; | ||
| 727 | const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE); | ||
| 728 | const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ | ||
| 729 | target: TARGET, | ||
| 730 | state: supervisorState, | ||
| 731 | session: yield* SubscriptionRef.make(Option.some(session(client))), | ||
| 732 | prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), | ||
| 733 | connect: Effect.void, | ||
| 734 | disconnect: Effect.void, | ||
| 735 | retryNow: Effect.void, | ||
| 736 | } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); | ||
| 737 | const cache = Persistence.EnvironmentCacheStore.of({ | ||
| 738 | loadShell: () => Effect.succeed(Option.some(cachedSnapshot)), | ||
| 739 | saveShell: () => Effect.void, | ||
| 740 | loadThread: () => Effect.succeed(Option.none()), | ||
| 741 | saveThread: () => Effect.void, | ||
| 742 | removeThread: () => Effect.void, | ||
| 743 | loadServerConfig: () => Effect.succeed(Option.none()), | ||
| 744 | saveServerConfig: () => Effect.void, | ||
| 745 | loadVcsRefs: () => Effect.succeed(Option.none()), | ||
| 746 | saveVcsRefs: () => Effect.void, | ||
| 747 | clear: () => Effect.void, | ||
| 748 | }); | ||
| 749 | const snapshotLoader = ShellSnapshotLoader.of({ | ||
| 750 | load: () => Effect.succeed(Option.some(httpSnapshot)), | ||
| 751 | }); | ||
| 752 | const shellState = yield* makeEnvironmentShellState().pipe( | ||
| 753 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | ||
| 754 | Effect.provideService(Persistence.EnvironmentCacheStore, cache), | ||
| 755 | Effect.provideService(ShellSnapshotLoader, snapshotLoader), | ||
| 756 | ); | ||
| 757 | |||
| 758 | yield* SubscriptionRef.changes(shellState).pipe( | ||
| 759 | Stream.filter( | ||
| 760 | (state) => Option.isSome(state.snapshot) && state.snapshot.value.snapshotSequence === 4, | ||
| 761 | ), | ||
| 762 | Stream.runHead, | ||
| 763 | ); | ||
| 764 | |||
| 765 | const state = yield* SubscriptionRef.get(shellState); | ||
| 766 | expect(Option.getOrThrow(state.snapshot).snapshotSequence).toBe(4); | ||
| 767 | expect(Option.getOrThrow(state.snapshot).projects[0]?.title).toBe("Server reset"); | ||
| 768 | }), | ||
| 769 | ); | ||
| 355 | }); | 770 | }); |
| @@ -24,7 +24,7 @@ import { EnvironmentCacheStore } from "../platform/persistence.ts"; · 23 unmodified lines | |||
| 24 | import { subscribeDynamic } from "../rpc/client.ts"; | 24 | import { subscribeDynamic } from "../rpc/client.ts"; |
| 25 | import type { RpcSession } from "../rpc/session.ts"; | 25 | import type { RpcSession } from "../rpc/session.ts"; |
| 26 | import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts"; | 26 | import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts"; |
| 27 | import { applyShellStreamEvent } from "./shellReducer.ts"; | 27 | import { applyShellStreamEvent, mergeShellSnapshotProjects } from "./shellReducer.ts"; |
| 28 | import type { EnvironmentCatalogState } from "./connections.ts"; | 28 | import type { EnvironmentCatalogState } from "./connections.ts"; |
| 29 | import { followStreamInEnvironment } from "./runtime.ts"; | 29 | import { followStreamInEnvironment } from "./runtime.ts"; |
| 30 | 30 | ||
| @@ -72,23 +72,49 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 41 unmodified lines | |||
| 72 | status: shellStatusForSnapshot(cachedSnapshot), | 72 | status: shellStatusForSnapshot(cachedSnapshot), |
| 73 | error: Option.none(), | 73 | error: Option.none(), |
| 74 | }); | 74 | }); |
| 75 | const awaitingCompletion = yield* Ref.make(false); | ||
| 76 | const latestLiveSnapshot = yield* Ref.make<Option.Option<OrchestrationV2ShellSnapshot>>( | ||
| 77 | Option.none(), | ||
| 78 | ); | ||
| 75 | const persistence = yield* Queue.sliding<OrchestrationV2ShellSnapshot>(1); | 79 | const persistence = yield* Queue.sliding<OrchestrationV2ShellSnapshot>(1); |
| 76 | 80 | ||
| 77 | const persist = Effect.fn("EnvironmentShellState.persist")(function* ( | 81 | const persist = Effect.fn("EnvironmentShellState.persist")(function* ( |
| 78 | snapshot: OrchestrationV2ShellSnapshot, | 82 | snapshot: OrchestrationV2ShellSnapshot, |
| 79 | ) { | 83 | ) { |
| 80 | yield* cache.saveShell(environmentId, snapshot).pipe( | 84 | let nextSnapshot = snapshot; |
| 81 | Effect.catch((error) => | 85 | while (true) { |
| 82 | Effect.logWarning("Could not persist environment shell cache.").pipe( | 86 | yield* cache.saveShell(environmentId, nextSnapshot).pipe( |
| 83 | Effect.annotateLogs({ | 87 | Effect.catch((error) => |
| 84 | environmentId, | 88 | Effect.logWarning("Could not persist environment shell cache.").pipe( |
| 85 | ...safeErrorLogAttributes(error), | 89 | Effect.annotateLogs({ |
| 86 | }), | 90 | environmentId, |
| 91 | ...safeErrorLogAttributes(error), | ||
| 92 | }), | ||
| 93 | ), | ||
| 87 | ), | 94 | ), |
| 88 | ), | 95 | ); |
| 89 | ); | 96 | |
| 97 | const latestSnapshot = yield* Ref.get(latestLiveSnapshot); | ||
| 98 | // Same sequence can still mean newer content (e.g. repository enrichment). | ||
| 99 | if (Option.isNone(latestSnapshot) || latestSnapshot.value === nextSnapshot) { | ||
| 100 | return; | ||
| 101 | } | ||
| 102 | nextSnapshot = latestSnapshot.value; | ||
| 103 | } | ||
| 104 | }); | ||
| 105 | |||
| 106 | const flushLiveShellSnapshot = Effect.gen(function* () { | ||
| 107 | const snapshot = yield* Ref.get(latestLiveSnapshot); | ||
| 108 | if (Option.isNone(snapshot)) { | ||
| 109 | return; | ||
| 110 | } | ||
| 111 | yield* persist(snapshot.value); | ||
| 90 | }); | 112 | }); |
| 91 | 113 | ||
| 114 | // Register before scoped worker fibers so reverse finalizer order interrupts | ||
| 115 | // those fibers first and this flush sees a stable latestLiveSnapshot. | ||
| 116 | yield* Effect.addFinalizer(() => flushLiveShellSnapshot); | ||
| 117 | |||
| 92 | yield* Stream.fromQueue(persistence).pipe( | 118 | yield* Stream.fromQueue(persistence).pipe( |
| 93 | Stream.debounce("500 millis"), | 119 | Stream.debounce("500 millis"), |
| 94 | Stream.runForEach(persist), | 120 | Stream.runForEach(persist), |
| @@ -102,6 +128,8 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 7 unmodified lines | |||
| 102 | status: shellStatusForSnapshot(current.snapshot), | 128 | status: shellStatusForSnapshot(current.snapshot), |
| 103 | })), | 129 | })), |
| 104 | ), | 130 | ), |
| 131 | Effect.andThen(flushLiveShellSnapshot.pipe(Effect.forkScoped)), | ||
| 132 | Effect.asVoid, | ||
| 105 | ); | 133 | ); |
| 106 | const setSynchronizing = SubscriptionRef.update(state, (current) => ({ | 134 | const setSynchronizing = SubscriptionRef.update(state, (current) => ({ |
| 107 | ...current, | 135 | ...current, |
| @@ -149,7 +177,15 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 41 unmodified lines | |||
| 149 | const current = yield* SubscriptionRef.get(state); | 177 | const current = yield* SubscriptionRef.get(state); |
| 150 | const nextSnapshot = | 178 | const nextSnapshot = |
| 151 | item.kind === "snapshot" | 179 | item.kind === "snapshot" |
| 152 | ? item.snapshot | 180 | ? mergeShellSnapshotProjects( |
| 181 | Option.getOrNull(current.snapshot), | ||
| 182 | item.snapshot, | ||
| 183 | item.resolvedRepositoryIdentityRoots === undefined | ||
| 184 | ? undefined | ||
| 185 | : { | ||
| 186 | resolvedRepositoryIdentityRoots: item.resolvedRepositoryIdentityRoots, | ||
| 187 | }, | ||
| 188 | ) | ||
| 153 | : Option.match(current.snapshot, { | 189 | : Option.match(current.snapshot, { |
| 154 | onNone: () => null, | 190 | onNone: () => null, |
| 155 | onSome: (snapshot) => | 191 | onSome: (snapshot) => |
| @@ -161,6 +197,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") · 5 unmodified lines | |||
| 161 | return; | 197 | return; |
| 162 | } | 198 | } |
| 163 | 199 | ||
| 200 | yield* Ref.set(latestLiveSnapshot, Option.some(nextSnapshot)); | ||
| 164 | const waiting = yield* Ref.get(awaitingCompletion); | 201 | const waiting = yield* Ref.get(awaitingCompletion); |
| 165 | yield* SubscriptionRef.set(state, { | 202 | yield* SubscriptionRef.set(state, { |
| 166 | snapshot: Option.some(nextSnapshot), | 203 | snapshot: Option.some(nextSnapshot), |
| @@ -2,7 +2,25 @@ import { ProjectId, ThreadId } from "@t3tools/contracts"; · 1 unmodified line | |||
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; | 4 | import { v2Project, v2ShellSnapshot, v2ThreadShell } from "./orchestrationV2TestFixtures.ts"; |
| 5 | import { applyShellStreamEvent } from "./shellReducer.ts"; | 5 | import { applyShellStreamEvent, mergeShellSnapshotProjects } from "./shellReducer.ts"; |
| 6 | |||
| 7 | const repositoryIdentity = { | ||
| 8 | canonicalKey: "github.com/example/repo", | ||
| 9 | locator: { | ||
| 10 | source: "git-remote" as const, | ||
| 11 | remoteName: "origin", | ||
| 12 | remoteUrl: "https://github.com/example/repo.git", | ||
| 13 | }, | ||
| 14 | }; | ||
| 15 | |||
| 16 | const otherIdentity = { | ||
| 17 | canonicalKey: "github.com/example/other", | ||
| 18 | locator: { | ||
| 19 | source: "git-remote" as const, | ||
| 20 | remoteName: "origin", | ||
| 21 | remoteUrl: "https://github.com/example/other.git", | ||
| 22 | }, | ||
| 23 | }; | ||
| 6 | 24 | ||
| 7 | describe("applyShellStreamEvent", () => { | 25 | describe("applyShellStreamEvent", () => { |
| 8 | it("ignores stale project updates without mutating the snapshot", () => { | 26 | it("ignores stale project updates without mutating the snapshot", () => { |
| @@ -42,6 +60,312 @@ describe("applyShellStreamEvent", () => { · 33 unmodified lines | |||
| 42 | expect(removed.projects).toEqual([]); | 60 | expect(removed.projects).toEqual([]); |
| 43 | }); | 61 | }); |
| 44 | 62 | ||
| 63 | it("keeps prior repositoryIdentity when a delta arrives with null identity", () => { | ||
| 64 | const withIdentity = applyShellStreamEvent(v2ShellSnapshot, { | ||
| 65 | kind: "project.updated", | ||
| 66 | sequence: 1, | ||
| 67 | project: { ...v2Project, repositoryIdentity }, | ||
| 68 | }); | ||
| 69 | expect(withIdentity.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity); | ||
| 70 | |||
| 71 | const withoutIdentity = applyShellStreamEvent(withIdentity, { | ||
| 72 | kind: "project.updated", | ||
| 73 | sequence: 2, | ||
| 74 | project: { ...v2Project, title: "Still same root", repositoryIdentity: null }, | ||
| 75 | }); | ||
| 76 | expect(withoutIdentity.projects[0]?.title).toBe("Still same root"); | ||
| 77 | expect(withoutIdentity.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity); | ||
| 78 | }); | ||
| 79 | |||
| 80 | it("does not keep prior repositoryIdentity after a workspace root move", () => { | ||
| 81 | const withIdentity = applyShellStreamEvent(v2ShellSnapshot, { | ||
| 82 | kind: "project.updated", | ||
| 83 | sequence: 1, | ||
| 84 | project: { ...v2Project, repositoryIdentity }, | ||
| 85 | }); | ||
| 86 | |||
| 87 | const moved = applyShellStreamEvent(withIdentity, { | ||
| 88 | kind: "project.updated", | ||
| 89 | sequence: 2, | ||
| 90 | project: { | ||
| 91 | ...v2Project, | ||
| 92 | workspaceRoot: "/tmp/other-root", | ||
| 93 | repositoryIdentity: null, | ||
| 94 | }, | ||
| 95 | }); | ||
| 96 | expect(moved.projects[0]?.workspaceRoot).toBe("/tmp/other-root"); | ||
| 97 | expect(moved.projects[0]?.repositoryIdentity).toBeNull(); | ||
| 98 | }); | ||
| 99 | |||
| 100 | it("merges full snapshot projects without dropping known repositoryIdentity", () => { | ||
| 101 | const previous = { | ||
| 102 | ...v2ShellSnapshot, | ||
| 103 | projects: [{ ...v2Project, repositoryIdentity }], | ||
| 104 | }; | ||
| 105 | const next = mergeShellSnapshotProjects(previous, { | ||
| 106 | ...v2ShellSnapshot, | ||
| 107 | snapshotSequence: 2, | ||
| 108 | projects: [{ ...v2Project, title: "Refreshed", repositoryIdentity: null }], | ||
| 109 | }); | ||
| 110 | expect(next.projects[0]?.title).toBe("Refreshed"); | ||
| 111 | expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity); | ||
| 112 | }); | ||
| 113 | |||
| 114 | it("authoritative lower-sequence reset replaces client-ahead structural state", () => { | ||
| 115 | const previous = { | ||
| 116 | ...v2ShellSnapshot, | ||
| 117 | snapshotSequence: 9, | ||
| 118 | projects: [{ ...v2Project, title: "Client ahead", repositoryIdentity }], | ||
| 119 | threads: [{ ...v2ThreadShell, title: "Local-only thread" }], | ||
| 120 | }; | ||
| 121 | const next = mergeShellSnapshotProjects(previous, { | ||
| 122 | ...v2ShellSnapshot, | ||
| 123 | snapshotSequence: 3, | ||
| 124 | projects: [{ ...v2Project, title: "Server reset", repositoryIdentity: null }], | ||
| 125 | threads: [{ ...v2ThreadShell, title: "Server thread" }], | ||
| 126 | }); | ||
| 127 | |||
| 128 | expect(next.snapshotSequence).toBe(3); | ||
| 129 | expect(next.projects[0]?.title).toBe("Server reset"); | ||
| 130 | expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity); | ||
| 131 | expect(next.threads[0]?.title).toBe("Server thread"); | ||
| 132 | }); | ||
| 133 | |||
| 134 | it("client-ahead survives lower marked enrichment then still resets on unmarked authoritative", () => { | ||
| 135 | const previous = { | ||
| 136 | ...v2ShellSnapshot, | ||
| 137 | snapshotSequence: 20, | ||
| 138 | projects: [{ ...v2Project, title: "Client ahead", repositoryIdentity }], | ||
| 139 | threads: [{ ...v2ThreadShell, title: "Local-only thread" }], | ||
| 140 | }; | ||
| 141 | const serverSnapshot = { | ||
| 142 | ...v2ShellSnapshot, | ||
| 143 | snapshotSequence: 3, | ||
| 144 | projects: [{ ...v2Project, title: "Server reset", repositoryIdentity: null }], | ||
| 145 | threads: [{ ...v2ThreadShell, title: "Server thread" }], | ||
| 146 | }; | ||
| 147 | |||
| 148 | // Lower-sequence marked enrichment is identity-only and must not replace structure. | ||
| 149 | const afterEnrichment = mergeShellSnapshotProjects(previous, serverSnapshot, { | ||
| 150 | resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot], | ||
| 151 | }); | ||
| 152 | expect(afterEnrichment.snapshotSequence).toBe(20); | ||
| 153 | expect(afterEnrichment.projects[0]?.title).toBe("Client ahead"); | ||
| 154 | expect(afterEnrichment.projects[0]?.repositoryIdentity).toBeNull(); | ||
| 155 | expect(afterEnrichment.threads[0]?.title).toBe("Local-only thread"); | ||
| 156 | |||
| 157 | // Unmarked authoritative at the same lower sequence still performs the reset. | ||
| 158 | const afterAuthoritative = mergeShellSnapshotProjects(afterEnrichment, serverSnapshot); | ||
| 159 | expect(afterAuthoritative.snapshotSequence).toBe(3); | ||
| 160 | expect(afterAuthoritative.projects[0]?.title).toBe("Server reset"); | ||
| 161 | expect(afterAuthoritative.projects[0]?.repositoryIdentity).toBeNull(); | ||
| 162 | expect(afterAuthoritative.threads[0]?.title).toBe("Server thread"); | ||
| 163 | }); | ||
| 164 | |||
| 165 | it("authoritative null retains identity then same-sequence marked enrichment clears it", () => { | ||
| 166 | const previous = { | ||
| 167 | ...v2ShellSnapshot, | ||
| 168 | snapshotSequence: 9, | ||
| 169 | projects: [{ ...v2Project, title: "Client ahead", repositoryIdentity }], | ||
| 170 | threads: [{ ...v2ThreadShell, title: "Local-only thread" }], | ||
| 171 | }; | ||
| 172 | const serverSnapshot = { | ||
| 173 | ...v2ShellSnapshot, | ||
| 174 | snapshotSequence: 3, | ||
| 175 | projects: [{ ...v2Project, title: "Server reset", repositoryIdentity: null }], | ||
| 176 | threads: [{ ...v2ThreadShell, title: "Server thread" }], | ||
| 177 | }; | ||
| 178 | |||
| 179 | const afterAuthoritative = mergeShellSnapshotProjects(previous, serverSnapshot); | ||
| 180 | expect(afterAuthoritative.snapshotSequence).toBe(3); | ||
| 181 | expect(afterAuthoritative.projects[0]?.title).toBe("Server reset"); | ||
| 182 | expect(afterAuthoritative.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity); | ||
| 183 | expect(afterAuthoritative.threads[0]?.title).toBe("Server thread"); | ||
| 184 | |||
| 185 | const afterEnrichment = mergeShellSnapshotProjects(afterAuthoritative, serverSnapshot, { | ||
| 186 | resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot], | ||
| 187 | }); | ||
| 188 | expect(afterEnrichment.snapshotSequence).toBe(3); | ||
| 189 | expect(afterEnrichment.projects[0]?.title).toBe("Server reset"); | ||
| 190 | expect(afterEnrichment.projects[0]?.repositoryIdentity).toBeNull(); | ||
| 191 | expect(afterEnrichment.threads[0]?.title).toBe("Server thread"); | ||
| 192 | }); | ||
| 193 | |||
| 194 | it("takes identity from a lower-sequence enrichment without reverting live thread state", () => { | ||
| 195 | const previous = { | ||
| 196 | ...v2ShellSnapshot, | ||
| 197 | snapshotSequence: 5, | ||
| 198 | projects: [{ ...v2Project, repositoryIdentity: null }], | ||
| 199 | threads: [{ ...v2ThreadShell, title: "Newest title" }], | ||
| 200 | }; | ||
| 201 | const next = mergeShellSnapshotProjects( | ||
| 202 | previous, | ||
| 203 | { | ||
| 204 | ...v2ShellSnapshot, | ||
| 205 | snapshotSequence: 4, | ||
| 206 | projects: [{ ...v2Project, repositoryIdentity }], | ||
| 207 | threads: [{ ...v2ThreadShell, title: "Stale title" }], | ||
| 208 | }, | ||
| 209 | { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] }, | ||
| 210 | ); | ||
| 211 | |||
| 212 | expect(next.snapshotSequence).toBe(5); | ||
| 213 | expect(next.threads[0]?.title).toBe("Newest title"); | ||
| 214 | expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity); | ||
| 215 | }); | ||
| 216 | |||
| 217 | it("lower-sequence enrichment cannot resurrect empty structural state", () => { | ||
| 218 | const previous = { | ||
| 219 | ...v2ShellSnapshot, | ||
| 220 | snapshotSequence: 7, | ||
| 221 | projects: [], | ||
| 222 | threads: [], | ||
| 223 | archivedThreads: [], | ||
| 224 | }; | ||
| 225 | const next = mergeShellSnapshotProjects( | ||
| 226 | previous, | ||
| 227 | { | ||
| 228 | ...v2ShellSnapshot, | ||
| 229 | snapshotSequence: 4, | ||
| 230 | projects: [{ ...v2Project, repositoryIdentity }], | ||
| 231 | threads: [v2ThreadShell], | ||
| 232 | }, | ||
| 233 | { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] }, | ||
| 234 | ); | ||
| 235 | |||
| 236 | expect(next.snapshotSequence).toBe(7); | ||
| 237 | expect(next.projects).toEqual([]); | ||
| 238 | expect(next.threads).toEqual([]); | ||
| 239 | }); | ||
| 240 | |||
| 241 | it("resolved null clears identity only for the marked root", () => { | ||
| 242 | const otherProject = { | ||
| 243 | ...v2Project, | ||
| 244 | id: ProjectId.make("project-other"), | ||
| 245 | workspaceRoot: "/workspace/other", | ||
| 246 | repositoryIdentity: otherIdentity, | ||
| 247 | }; | ||
| 248 | const previous = { | ||
| 249 | ...v2ShellSnapshot, | ||
| 250 | snapshotSequence: 5, | ||
| 251 | projects: [{ ...v2Project, repositoryIdentity }, otherProject], | ||
| 252 | }; | ||
| 253 | const next = mergeShellSnapshotProjects( | ||
| 254 | previous, | ||
| 255 | { | ||
| 256 | ...v2ShellSnapshot, | ||
| 257 | snapshotSequence: 4, | ||
| 258 | projects: [ | ||
| 259 | { ...v2Project, repositoryIdentity: null }, | ||
| 260 | { ...otherProject, repositoryIdentity: null }, | ||
| 261 | ], | ||
| 262 | }, | ||
| 263 | { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] }, | ||
| 264 | ); | ||
| 265 | |||
| 266 | expect(next.snapshotSequence).toBe(5); | ||
| 267 | expect(next.projects).toHaveLength(2); | ||
| 268 | expect(next.projects[0]?.repositoryIdentity).toBeNull(); | ||
| 269 | expect(next.projects[1]?.repositoryIdentity).toEqual(otherIdentity); | ||
| 270 | }); | ||
| 271 | |||
| 272 | it("unrelated unresolved roots retain identity in the same enrichment snapshot", () => { | ||
| 273 | const otherProject = { | ||
| 274 | ...v2Project, | ||
| 275 | id: ProjectId.make("project-other"), | ||
| 276 | workspaceRoot: "/workspace/other", | ||
| 277 | repositoryIdentity: otherIdentity, | ||
| 278 | }; | ||
| 279 | const previous = { | ||
| 280 | ...v2ShellSnapshot, | ||
| 281 | snapshotSequence: 3, | ||
| 282 | projects: [{ ...v2Project, repositoryIdentity }, otherProject], | ||
| 283 | threads: [{ ...v2ThreadShell, title: "Current thread" }], | ||
| 284 | }; | ||
| 285 | const next = mergeShellSnapshotProjects( | ||
| 286 | previous, | ||
| 287 | { | ||
| 288 | ...v2ShellSnapshot, | ||
| 289 | snapshotSequence: 3, | ||
| 290 | projects: [ | ||
| 291 | { ...v2Project, repositoryIdentity }, | ||
| 292 | { ...otherProject, repositoryIdentity: null }, | ||
| 293 | ], | ||
| 294 | threads: [{ ...v2ThreadShell, title: "Stale thread" }], | ||
| 295 | }, | ||
| 296 | { resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot] }, | ||
| 297 | ); | ||
| 298 | |||
| 299 | expect(next.threads[0]?.title).toBe("Current thread"); | ||
| 300 | expect(next.projects[0]?.repositoryIdentity).toEqual(repositoryIdentity); | ||
| 301 | expect(next.projects[1]?.repositoryIdentity).toEqual(otherIdentity); | ||
| 302 | }); | ||
| 303 | |||
| 304 | it("clears identity for multiple roots resolved in one enrichment batch", () => { | ||
| 305 | const otherProject = { | ||
| 306 | ...v2Project, | ||
| 307 | id: ProjectId.make("project-other"), | ||
| 308 | workspaceRoot: "/workspace/other", | ||
| 309 | repositoryIdentity: otherIdentity, | ||
| 310 | }; | ||
| 311 | const previous = { | ||
| 312 | ...v2ShellSnapshot, | ||
| 313 | snapshotSequence: 3, | ||
| 314 | projects: [{ ...v2Project, repositoryIdentity }, otherProject], | ||
| 315 | }; | ||
| 316 | const next = mergeShellSnapshotProjects( | ||
| 317 | previous, | ||
| 318 | { | ||
| 319 | ...v2ShellSnapshot, | ||
| 320 | snapshotSequence: 3, | ||
| 321 | projects: [ | ||
| 322 | { ...v2Project, repositoryIdentity: null }, | ||
| 323 | { ...otherProject, repositoryIdentity: null }, | ||
| 324 | ], | ||
| 325 | }, | ||
| 326 | { | ||
| 327 | resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot, otherProject.workspaceRoot], | ||
| 328 | }, | ||
| 329 | ); | ||
| 330 | |||
| 331 | expect(next.projects.map((project) => project.repositoryIdentity)).toEqual([null, null]); | ||
| 332 | }); | ||
| 333 | |||
| 334 | it("does not replace a live identity with one from an unmarked lower-sequence enrichment", () => { | ||
| 335 | const liveIdentity = { | ||
| 336 | canonicalKey: "github.com/example/live", | ||
| 337 | locator: { | ||
| 338 | source: "git-remote" as const, | ||
| 339 | remoteName: "origin", | ||
| 340 | remoteUrl: "https://github.com/example/live.git", | ||
| 341 | }, | ||
| 342 | }; | ||
| 343 | const staleIdentity = { | ||
| 344 | canonicalKey: "github.com/example/stale", | ||
| 345 | locator: { | ||
| 346 | source: "git-remote" as const, | ||
| 347 | remoteName: "origin", | ||
| 348 | remoteUrl: "https://github.com/example/stale.git", | ||
| 349 | }, | ||
| 350 | }; | ||
| 351 | const next = mergeShellSnapshotProjects( | ||
| 352 | { | ||
| 353 | ...v2ShellSnapshot, | ||
| 354 | snapshotSequence: 5, | ||
| 355 | projects: [{ ...v2Project, repositoryIdentity: liveIdentity }], | ||
| 356 | }, | ||
| 357 | { | ||
| 358 | ...v2ShellSnapshot, | ||
| 359 | snapshotSequence: 4, | ||
| 360 | projects: [{ ...v2Project, repositoryIdentity: staleIdentity }], | ||
| 361 | }, | ||
| 362 | { resolvedRepositoryIdentityRoots: [] }, | ||
| 363 | ); | ||
| 364 | |||
| 365 | expect(next.snapshotSequence).toBe(5); | ||
| 366 | expect(next.projects[0]?.repositoryIdentity).toEqual(liveIdentity); | ||
| 367 | }); | ||
| 368 | |||
| 45 | it("moves a thread between active and archive without duplicating it", () => { | 369 | it("moves a thread between active and archive without duplicating it", () => { |
| 46 | const archived = applyShellStreamEvent(v2ShellSnapshot, { | 370 | const archived = applyShellStreamEvent(v2ShellSnapshot, { |
| 47 | kind: "thread.updated", | 371 | kind: "thread.updated", |
| @@ -1,4 +1,5 @@ | |||
| 1 | import type { | 1 | import type { |
| 2 | OrchestrationProjectShell, | ||
| 2 | OrchestrationV2ShellSnapshot, | 3 | OrchestrationV2ShellSnapshot, |
| 3 | OrchestrationV2ShellStreamItem, | 4 | OrchestrationV2ShellStreamItem, |
| 4 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| @@ -12,6 +13,85 @@ function upsertById<T extends { readonly id: unknown }>( · 7 unmodified lines | |||
| 12 | return items.map((candidate, candidateIndex) => (candidateIndex === index ? item : candidate)); | 13 | return items.map((candidate, candidateIndex) => (candidateIndex === index ? item : candidate)); |
| 13 | } | 14 | } |
| 14 | 15 | ||
| 16 | function retainRepositoryIdentity( | ||
| 17 | previous: OrchestrationProjectShell | undefined, | ||
| 18 | next: OrchestrationProjectShell, | ||
| 19 | ): OrchestrationProjectShell { | ||
| 20 | if ( | ||
| 21 | next.repositoryIdentity == null && | ||
| 22 | previous?.repositoryIdentity != null && | ||
| 23 | previous.workspaceRoot === next.workspaceRoot | ||
| 24 | ) { | ||
| 25 | return { ...next, repositoryIdentity: previous.repositoryIdentity }; | ||
| 26 | } | ||
| 27 | return next; | ||
| 28 | } | ||
| 29 | |||
| 30 | export interface MergeShellSnapshotOptions { | ||
| 31 | /** | ||
| 32 | * Enrichment refresh: structure and sequence must not roll back when at or | ||
| 33 | * below cache; listed roots accept identity exactly (including null). | ||
| 34 | * Omit this options object for authoritative HTTP/initial WebSocket snapshots. | ||
| 35 | */ | ||
| 36 | readonly resolvedRepositoryIdentityRoots: ReadonlyArray<string>; | ||
| 37 | } | ||
| 38 | |||
| 39 | /** | ||
| 40 | * Merge an incoming full shell snapshot into prior client state. | ||
| 41 | * | ||
| 42 | * Authoritative snapshots (no options) replace structure and sequence even when | ||
| 43 | * lower than cache, while retaining a prior non-null identity when the candidate | ||
| 44 | * is still unresolved/null for the same root. | ||
| 45 | * | ||
| 46 | * Enrichment snapshots (options present) never resurrect or roll back | ||
| 47 | * projects/threads/archives/sequence when at or below cache; they only patch | ||
| 48 | * repository identity for matching current projects. | ||
| 49 | */ | ||
| 50 | export function mergeShellSnapshotProjects( | ||
| 51 | previous: OrchestrationV2ShellSnapshot | null | undefined, | ||
| 52 | next: OrchestrationV2ShellSnapshot, | ||
| 53 | options?: MergeShellSnapshotOptions, | ||
| 54 | ): OrchestrationV2ShellSnapshot { | ||
| 55 | if (previous === null || previous === undefined) { | ||
| 56 | return next; | ||
| 57 | } | ||
| 58 | |||
| 59 | const isEnrichment = options !== undefined; | ||
| 60 | const resolvedRootSet = isEnrichment ? new Set(options.resolvedRepositoryIdentityRoots) : null; | ||
| 61 | |||
| 62 | if (isEnrichment && next.snapshotSequence <= previous.snapshotSequence) { | ||
| 63 | const nextById = new Map(next.projects.map((project) => [project.id, project] as const)); | ||
| 64 | return { | ||
| 65 | ...previous, | ||
| 66 | projects: previous.projects.map((project) => { | ||
| 67 | const candidate = nextById.get(project.id); | ||
| 68 | if (candidate === undefined || candidate.workspaceRoot !== project.workspaceRoot) { | ||
| 69 | return project; | ||
| 70 | } | ||
| 71 | if (resolvedRootSet?.has(project.workspaceRoot) === true) { | ||
| 72 | return { ...project, repositoryIdentity: candidate.repositoryIdentity }; | ||
| 73 | } | ||
| 74 | if (project.repositoryIdentity == null && candidate.repositoryIdentity != null) { | ||
| 75 | return { ...project, repositoryIdentity: candidate.repositoryIdentity }; | ||
| 76 | } | ||
| 77 | return project; | ||
| 78 | }), | ||
| 79 | }; | ||
| 80 | } | ||
| 81 | |||
| 82 | const previousById = new Map(previous.projects.map((project) => [project.id, project] as const)); | ||
| 83 | return { | ||
| 84 | ...next, | ||
| 85 | projects: next.projects.map((project) => { | ||
| 86 | const prior = previousById.get(project.id); | ||
| 87 | if (resolvedRootSet?.has(project.workspaceRoot) === true) { | ||
| 88 | return project; | ||
| 89 | } | ||
| 90 | return retainRepositoryIdentity(prior, project); | ||
| 91 | }), | ||
| 92 | }; | ||
| 93 | } | ||
| 94 | |||
| 15 | /** Applies one committed V2 shell delta while preserving active/archive exclusivity. */ | 95 | /** Applies one committed V2 shell delta while preserving active/archive exclusivity. */ |
| 16 | export function applyShellStreamEvent( | 96 | export function applyShellStreamEvent( |
| 17 | snapshot: OrchestrationV2ShellSnapshot, | 97 | snapshot: OrchestrationV2ShellSnapshot, |
| @@ -20,12 +100,19 @@ export function applyShellStreamEvent( · 2 unmodified lines | |||
| 20 | if (event.sequence <= snapshot.snapshotSequence) return snapshot; | 100 | if (event.sequence <= snapshot.snapshotSequence) return snapshot; |
| 21 | 101 | ||
| 22 | switch (event.kind) { | 102 | switch (event.kind) { |
| 23 | case "project.updated": | 103 | case "project.updated": { |
| 104 | // Enrichment is async. A project mutation can land with null | ||
| 105 | // repositoryIdentity while an earlier snapshot already resolved it. | ||
| 106 | // Keep the prior identity for the same workspace root so multi-env | ||
| 107 | // grouping does not split until a full snapshot refresh arrives. | ||
| 108 | const previous = snapshot.projects.find((project) => project.id === event.project.id); | ||
| 109 | const project = retainRepositoryIdentity(previous, event.project); | ||
| 24 | return { | 110 | return { |
| 25 | ...snapshot, | 111 | ...snapshot, |
| 26 | projects: upsertById(snapshot.projects, event.project), | 112 | projects: upsertById(snapshot.projects, project), |
| 27 | snapshotSequence: event.sequence, | 113 | snapshotSequence: event.sequence, |
| 28 | }; | 114 | }; |
| 115 | } | ||
| 29 | case "project.removed": | 116 | case "project.removed": |
| 30 | return { | 117 | return { |
| 31 | ...snapshot, | 118 | ...snapshot, |
| @@ -23,16 +23,42 @@ import { · 22 unmodified lines | |||
| 23 | OrchestrationV2CheckpointScope, | 23 | OrchestrationV2CheckpointScope, |
| 24 | OrchestrationV2Command, | 24 | OrchestrationV2Command, |
| 25 | OrchestrationV2DomainEvent, | 25 | OrchestrationV2DomainEvent, |
| 26 | OrchestrationV2ShellSnapshot, | ||
| 26 | OrchestrationV2Subagent, | 27 | OrchestrationV2Subagent, |
| 27 | OrchestrationV2ThreadProjection, | 28 | OrchestrationV2ThreadProjection, |
| 28 | OrchestrationV2TurnItem, | 29 | OrchestrationV2TurnItem, |
| 29 | } from "./orchestrationV2.ts"; | 30 | } from "./orchestrationV2.ts"; |
| 30 | 31 | ||
| 31 | const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z"); | 32 | const now = DateTime.makeUnsafe("2026-04-20T00:00:00.000Z"); |
| 33 | const LegacyShellStreamItem = Schema.Union([ | ||
| 34 | Schema.Struct({ kind: Schema.Literal("synchronized") }), | ||
| 35 | Schema.Struct({ | ||
| 36 | kind: Schema.Literal("snapshot"), | ||
| 37 | snapshot: OrchestrationV2ShellSnapshot, | ||
| 38 | }), | ||
| 39 | ]); | ||
| 40 | const decodeLegacyShellStreamItem = Schema.decodeUnknownSync(LegacyShellStreamItem); | ||
| 32 | const decodeOrchestrationV2Command = Schema.decodeUnknownSync(OrchestrationV2Command); | 41 | const decodeOrchestrationV2Command = Schema.decodeUnknownSync(OrchestrationV2Command); |
| 33 | const decodeOrchestrationV2TurnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem); | 42 | const decodeOrchestrationV2TurnItem = Schema.decodeUnknownSync(OrchestrationV2TurnItem); |
| 34 | 43 | ||
| 35 | describe("orchestration V2 contracts", () => { | 44 | describe("orchestration V2 contracts", () => { |
| 45 | it("lets legacy snapshot decoders ignore enrichment metadata", () => { | ||
| 46 | const decoded = decodeLegacyShellStreamItem({ | ||
| 47 | kind: "snapshot", | ||
| 48 | snapshot: { | ||
| 49 | schemaVersion: 1, | ||
| 50 | snapshotSequence: 0, | ||
| 51 | projects: [], | ||
| 52 | threads: [], | ||
| 53 | archivedThreads: [], | ||
| 54 | }, | ||
| 55 | resolvedRepositoryIdentityRoots: ["/workspace/project"], | ||
| 56 | }); | ||
| 57 | |||
| 58 | expect(decoded.kind).toBe("snapshot"); | ||
| 59 | expect("resolvedRepositoryIdentityRoots" in decoded).toBe(false); | ||
| 60 | }); | ||
| 61 | |||
| 36 | it("decodes nested checkpoint scopes without making child scopes advance app run count", () => { | 62 | it("decodes nested checkpoint scopes without making child scopes advance app run count", () => { |
| 37 | const rootScope = Schema.decodeUnknownSync(OrchestrationV2CheckpointScope)({ | 63 | const rootScope = Schema.decodeUnknownSync(OrchestrationV2CheckpointScope)({ |
| 38 | id: "scope-root-1", | 64 | id: "scope-root-1", |
| @@ -1185,6 +1185,12 @@ export const OrchestrationV2ShellStreamItem = Schema.Union([ · 1184 unmodified lines | |||
| 1185 | Schema.Struct({ | 1185 | Schema.Struct({ |
| 1186 | kind: Schema.Literal("snapshot"), | 1186 | kind: Schema.Literal("snapshot"), |
| 1187 | snapshot: OrchestrationV2ShellSnapshot, | 1187 | snapshot: OrchestrationV2ShellSnapshot, |
| 1188 | /** | ||
| 1189 | * Workspace roots whose repository-identity resolution completed for this | ||
| 1190 | * enrichment refresh. Omitted on authoritative HTTP/initial WebSocket | ||
| 1191 | * snapshots. Older clients ignore the field. | ||
| 1192 | */ | ||
| 1193 | resolvedRepositoryIdentityRoots: Schema.optionalKey(Schema.Array(Schema.String)), | ||
| 1188 | }), | 1194 | }), |
| 1189 | Schema.Struct({ | 1195 | Schema.Struct({ |
| 1190 | kind: Schema.Literal("project.updated"), | 1196 | kind: Schema.Literal("project.updated"), |
| @@ -1528,6 +1534,77 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ · 337 unmodified lines | |||
| 1528 | ]); | 1534 | ]); |
| 1529 | export type OrchestrationV2TurnItemJson = typeof OrchestrationV2TurnItemJson.Type; | 1535 | export type OrchestrationV2TurnItemJson = typeof OrchestrationV2TurnItemJson.Type; |
| 1530 | 1536 | ||
| 1537 | export const OrchestrationV2ProjectedTurnItemJson = OrchestrationV2ProjectedTurnItem.mapFields( | ||
| 1538 | (fields) => ({ | ||
| 1539 | ...fields, | ||
| 1540 | item: OrchestrationV2TurnItemJson, | ||
| 1541 | }), | ||
| 1542 | ); | ||
| 1543 | export type OrchestrationV2ProjectedTurnItemJson = typeof OrchestrationV2ProjectedTurnItemJson.Type; | ||
| 1544 | |||
| 1545 | export const OrchestrationV2ThreadProjectionJson = OrchestrationV2ThreadProjection.mapFields( | ||
| 1546 | (fields) => ({ | ||
| 1547 | ...fields, | ||
| 1548 | thread: OrchestrationV2AppThreadJson, | ||
| 1549 | runs: Schema.Array(OrchestrationV2RunJson), | ||
| 1550 | attempts: Schema.Array(OrchestrationV2RunAttemptJson), | ||
| 1551 | nodes: Schema.Array(OrchestrationV2ExecutionNodeJson), | ||
| 1552 | subagents: Schema.Array(OrchestrationV2SubagentJson), | ||
| 1553 | providerSessions: Schema.Array(OrchestrationV2ProviderSessionJson), | ||
| 1554 | providerThreads: Schema.Array(OrchestrationV2ProviderThreadJson), | ||
| 1555 | providerTurns: Schema.Array(OrchestrationV2ProviderTurnJson), | ||
| 1556 | runtimeRequests: Schema.Array(OrchestrationV2RuntimeRequestJson), | ||
| 1557 | messages: Schema.Array(OrchestrationV2ConversationMessageJson), | ||
| 1558 | plans: Schema.Array(OrchestrationV2PlanArtifact), | ||
| 1559 | turnItems: Schema.Array(OrchestrationV2TurnItemJson), | ||
| 1560 | checkpointScopes: Schema.Array(OrchestrationV2CheckpointScopeJson), | ||
| 1561 | checkpoints: Schema.Array(OrchestrationV2CheckpointJson), | ||
| 1562 | contextHandoffs: Schema.Array(OrchestrationV2ContextHandoffJson), | ||
| 1563 | contextTransfers: Schema.Array(OrchestrationV2ContextTransferJson), | ||
| 1564 | visibleTurnItems: Schema.Array(OrchestrationV2ProjectedTurnItemJson), | ||
| 1565 | updatedAt: Schema.DateTimeUtcFromString, | ||
| 1566 | }), | ||
| 1567 | ); | ||
| 1568 | export type OrchestrationV2ThreadProjectionJson = typeof OrchestrationV2ThreadProjectionJson.Type; | ||
| 1569 | |||
| 1570 | export const OrchestrationV2PendingRuntimeRequestSummaryJson = | ||
| 1571 | OrchestrationV2PendingRuntimeRequestSummary.mapFields((fields) => ({ | ||
| 1572 | ...fields, | ||
| 1573 | createdAt: Schema.DateTimeUtcFromString, | ||
| 1574 | })); | ||
| 1575 | export type OrchestrationV2PendingRuntimeRequestSummaryJson = | ||
| 1576 | typeof OrchestrationV2PendingRuntimeRequestSummaryJson.Type; | ||
| 1577 | |||
| 1578 | export const OrchestrationV2LatestVisibleMessageSummaryJson = | ||
| 1579 | OrchestrationV2LatestVisibleMessageSummary.mapFields((fields) => ({ | ||
| 1580 | ...fields, | ||
| 1581 | updatedAt: Schema.DateTimeUtcFromString, | ||
| 1582 | })); | ||
| 1583 | export type OrchestrationV2LatestVisibleMessageSummaryJson = | ||
| 1584 | typeof OrchestrationV2LatestVisibleMessageSummaryJson.Type; | ||
| 1585 | |||
| 1586 | export const OrchestrationV2ThreadShellJson = OrchestrationV2ThreadShell.mapFields((fields) => ({ | ||
| 1587 | ...fields, | ||
| 1588 | pendingRuntimeRequest: Schema.NullOr(OrchestrationV2PendingRuntimeRequestSummaryJson), | ||
| 1589 | latestVisibleMessage: Schema.NullOr(OrchestrationV2LatestVisibleMessageSummaryJson), | ||
| 1590 | latestUserMessageAt: Schema.NullOr(Schema.DateTimeUtcFromString), | ||
| 1591 | createdAt: Schema.DateTimeUtcFromString, | ||
| 1592 | updatedAt: Schema.DateTimeUtcFromString, | ||
| 1593 | archivedAt: Schema.NullOr(Schema.DateTimeUtcFromString), | ||
| 1594 | settledAt: Schema.NullOr(Schema.DateTimeUtcFromString), | ||
| 1595 | deletedAt: Schema.NullOr(Schema.DateTimeUtcFromString), | ||
| 1596 | })); | ||
| 1597 | export type OrchestrationV2ThreadShellJson = typeof OrchestrationV2ThreadShellJson.Type; | ||
| 1598 | |||
| 1599 | export const OrchestrationV2ShellSnapshotJson = OrchestrationV2ShellSnapshot.mapFields( | ||
| 1600 | (fields) => ({ | ||
| 1601 | ...fields, | ||
| 1602 | threads: Schema.Array(OrchestrationV2ThreadShellJson), | ||
| 1603 | archivedThreads: Schema.Array(OrchestrationV2ThreadShellJson), | ||
| 1604 | }), | ||
| 1605 | ); | ||
| 1606 | export type OrchestrationV2ShellSnapshotJson = typeof OrchestrationV2ShellSnapshotJson.Type; | ||
| 1607 | |||
| 1531 | const OrchestrationV2JsonEventBaseFields = { | 1608 | const OrchestrationV2JsonEventBaseFields = { |
| 1532 | ...OrchestrationV2EventBase.fields, | 1609 | ...OrchestrationV2EventBase.fields, |
| 1533 | occurredAt: Schema.DateTimeUtcFromString, | 1610 | occurredAt: Schema.DateTimeUtcFromString, |
| @@ -708,6 +708,61 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { · 707 unmodified lines | |||
| 708 | }), | 708 | }), |
| 709 | ); | 709 | ); |
| 710 | 710 | ||
| 711 | it.effect("drops agent-internal JSON-RPC responses with non-numeric request ids", () => | ||
| 712 | Effect.gen(function* () { | ||
| 713 | const { stdio, input } = yield* makeInMemoryStdio(); | ||
| 714 | const notifications = | ||
| 715 | yield* Deferred.make<ReadonlyArray<AcpProtocol.AcpIncomingNotification>>(); | ||
| 716 | const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ | ||
| 717 | stdio, | ||
| 718 | serverRequestMethods: new Set(), | ||
| 719 | }); | ||
| 720 | |||
| 721 | yield* transport.incoming.pipe( | ||
| 722 | Stream.take(1), | ||
| 723 | Stream.runCollect, | ||
| 724 | Effect.flatMap((notificationChunk) => Deferred.succeed(notifications, notificationChunk)), | ||
| 725 | Effect.forkScoped, | ||
| 726 | ); | ||
| 727 | |||
| 728 | yield* Queue.offer( | ||
| 729 | input, | ||
| 730 | encoder.encode( | ||
| 731 | `${encodeUnknownJsonString({ | ||
| 732 | jsonrpc: "2.0", | ||
| 733 | id: "skills-reload", | ||
| 734 | result: { | ||
| 735 | reloaded: true, | ||
| 736 | }, | ||
| 737 | })}\n`, | ||
| 738 | ), | ||
| 739 | ); | ||
| 740 | yield* Queue.offer( | ||
| 741 | input, | ||
| 742 | yield* encodeJsonl(SessionUpdateNotification, { | ||
| 743 | jsonrpc: "2.0", | ||
| 744 | method: "session/update", | ||
| 745 | params: { | ||
| 746 | sessionId: "session-1", | ||
| 747 | update: { | ||
| 748 | sessionUpdate: "plan", | ||
| 749 | entries: [ | ||
| 750 | { | ||
| 751 | content: "Reload skills", | ||
| 752 | priority: "high", | ||
| 753 | status: "in_progress", | ||
| 754 | }, | ||
| 755 | ], | ||
| 756 | }, | ||
| 757 | }, | ||
| 758 | }), | ||
| 759 | ); | ||
| 760 | |||
| 761 | const [update] = yield* Deferred.await(notifications); | ||
| 762 | assert.equal(update?._tag, "SessionUpdate"); | ||
| 763 | }), | ||
| 764 | ); | ||
| 765 | |||
| 711 | it.effect("cleans up interrupted extension requests before a late response arrives", () => | 766 | it.effect("cleans up interrupted extension requests before a late response arrives", () => |
| 712 | Effect.gen(function* () { | 767 | Effect.gen(function* () { |
| 713 | const { stdio, input, output } = yield* makeInMemoryStdio(); | 768 | const { stdio, input, output } = yield* makeInMemoryStdio(); |
| @@ -117,6 +117,9 @@ const encodeJsonRpcNotification = Schema.encodeUnknownExit( · 116 unmodified lines | |||
| 117 | ), | 117 | ), |
| 118 | ); | 118 | ); |
| 119 | 119 | ||
| 120 | const isEffectRpcRequestId = (requestId: string): boolean => | ||
| 121 | requestId !== "" && /^-?\d+$/.test(requestId); | ||
| 122 | |||
| 120 | export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* ( | 123 | export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(function* ( |
| 121 | options: AcpPatchedProtocolOptions, | 124 | options: AcpPatchedProtocolOptions, |
| 122 | ): Effect.fn.Return<AcpPatchedProtocol, never, Scope.Scope> { | 125 | ): Effect.fn.Return<AcpPatchedProtocol, never, Scope.Scope> { |
| @@ -452,12 +455,19 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 329 unmodified lines | |||
| 452 | return observeIncoming.pipe(Effect.andThen(Queue.offer(serverQueue, message)), Effect.asVoid); | 455 | return observeIncoming.pipe(Effect.andThen(Queue.offer(serverQueue, message)), Effect.asVoid); |
| 453 | }; | 456 | }; |
| 454 | 457 | ||
| 458 | const forwardToRpcClient = ( | ||
| 459 | message: RpcMessage.ResponseChunkEncoded | RpcMessage.ResponseExitEncoded, | ||
| 460 | ) => | ||
| 461 | isEffectRpcRequestId(message.requestId) | ||
| 462 | ? Queue.offer(clientQueue, message).pipe(Effect.asVoid) | ||
| 463 | : Effect.void; | ||
| 464 | |||
| 455 | const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) => | 465 | const handleExitEncoded = (message: RpcMessage.ResponseExitEncoded) => |
| 456 | Ref.get(extPending).pipe( | 466 | Ref.get(extPending).pipe( |
| 457 | Effect.flatMap((pending) => { | 467 | Effect.flatMap((pending) => { |
| 458 | const pendingRequest = pending.get(String(message.requestId)); | 468 | const pendingRequest = pending.get(String(message.requestId)); |
| 459 | if (!pendingRequest) { | 469 | if (!pendingRequest) { |
| 460 | return Queue.offer(clientQueue, message).pipe(Effect.asVoid); | 470 | return forwardToRpcClient(message); |
| 461 | } | 471 | } |
| 462 | if (message.exit._tag === "Success") { | 472 | if (message.exit._tag === "Success") { |
| 463 | return completeExtPendingSuccess(message.requestId, message.exit.value); | 473 | return completeExtPendingSuccess(message.requestId, message.exit.value); |
| @@ -504,7 +514,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi · 40 unmodified lines | |||
| 504 | message.requestId, | 514 | message.requestId, |
| 505 | ), | 515 | ), |
| 506 | ) | 516 | ) |
| 507 | : Queue.offer(clientQueue, message).pipe(Effect.asVoid); | 517 | : forwardToRpcClient(message); |
| 508 | }), | 518 | }), |
| 509 | ); | 519 | ); |
| 510 | case "Defect": | 520 | case "Defect": |