authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-05 22:00:13-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-05 22:00:13-07:00
log482d56233dc3c27f614c843e0f6cc02b3f648a5f
tree1554867b625abf454049dd635e0b6402b624d5c8
parent6009720844f053e048e18ca1abe4f429559ab9fc
signature Signed by PGP key B5690EEEBB952194

Load thread snapshots over HTTP before live sync (#3719)

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>

31 files changed, 907 insertions(+), 109 deletions(-)

apps/mobile/src/connection/runtime.ts+6-1
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import { Connection } from "@t3tools/client-runtime/connection";1import { Connection } from "@t3tools/client-runtime/connection";
2import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";
3import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads";
2import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
3import { Atom } from "effect/unstable/reactivity";5import { Atom } from "effect/unstable/reactivity";
46
...@@ -9,12 +11,15 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(...@@ -9,12 +11,15 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
9 Layer.provide(runtimeContextLayer),11 Layer.provide(runtimeContextLayer),
10);12);
1113
14const snapshotLoaderLayer = Layer.merge(threadSnapshotLoaderLayer, shellSnapshotLoaderLayer);
15
12type ConnectionLayerSource =16type ConnectionLayerSource =
13 | typeof Connection.layer17 | typeof Connection.layer
18 | typeof snapshotLoaderLayer
14 | typeof runtimeContextLayer19 | typeof runtimeContextLayer
15 | typeof connectionPlatformLayer;20 | typeof connectionPlatformLayer;
1621
17const connectionLayer = Connection.layer.pipe(22const connectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe(
18 Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)),23 Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)),
19);24);
2025
apps/mobile/src/connection/storage.ts+11-8
...@@ -16,8 +16,8 @@ import {...@@ -16,8 +16,8 @@ import {
16} from "@t3tools/client-runtime/connection";16} from "@t3tools/client-runtime/connection";
17import {17import {
18 EnvironmentId,18 EnvironmentId,
19 OrchestrationThread,
20 OrchestrationShellSnapshot,19 OrchestrationShellSnapshot,
20 OrchestrationThreadDetailSnapshot,
21 ThreadId,21 ThreadId,
22} from "@t3tools/contracts";22} from "@t3tools/contracts";
23import * as Context from "effect/Context";23import * as Context from "effect/Context";
...@@ -32,7 +32,10 @@ import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store";...@@ -32,7 +32,10 @@ import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store";
32const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;32const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
33const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots";33const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots";
34const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";34const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";
35const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;35// v2 stores the snapshot sequence alongside the thread so a warm cache can
36// resume via `afterSequence` instead of re-downloading the full thread body.
37// Older v1 entries (no sequence) fail to decode and are treated as a cold cache.
38const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2;
36const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots";39const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots";
3740
38const StoredShellSnapshot = Schema.Struct({41const StoredShellSnapshot = Schema.Struct({
...@@ -45,7 +48,7 @@ const StoredThreadSnapshot = Schema.Struct({...@@ -45,7 +48,7 @@ const StoredThreadSnapshot = Schema.Struct({
45 schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION),48 schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION),
46 environmentId: EnvironmentId,49 environmentId: EnvironmentId,
47 threadId: ThreadId,50 threadId: ThreadId,
48 thread: OrchestrationThread,51 snapshot: OrchestrationThreadDetailSnapshot,
49});52});
5053
51const LegacyStoredShellSnapshot = Schema.Struct({54const LegacyStoredShellSnapshot = Schema.Struct({
...@@ -355,18 +358,18 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -355,18 +358,18 @@ export const connectionStorageLayer = Layer.effectContext(
355 Schema.decodeUnknownResult(StoredThreadSnapshot)(parsed),358 Schema.decodeUnknownResult(StoredThreadSnapshot)(parsed),
356 ).pipe(Effect.mapError((cause) => shellPersistenceError("load-thread", cause)));359 ).pipe(Effect.mapError((cause) => shellPersistenceError("load-thread", cause)));
357 return stored.environmentId === environmentId && stored.threadId === threadId360 return stored.environmentId === environmentId && stored.threadId === threadId
358 ? Option.some(stored.thread)361 ? Option.some(stored.snapshot)
359 : Option.none();362 : Option.none();
360 }),363 }),
361 saveThread: (environmentId, thread) =>364 saveThread: (environmentId, snapshot) =>
362 Effect.gen(function* () {365 Effect.gen(function* () {
363 const file = yield* threadSnapshotFile(environmentId, thread.id, "save-thread");366 const file = yield* threadSnapshotFile(environmentId, snapshot.thread.id, "save-thread");
364 const encoded = yield* Effect.fromResult(367 const encoded = yield* Effect.fromResult(
365 Schema.encodeUnknownResult(StoredThreadSnapshot)({368 Schema.encodeUnknownResult(StoredThreadSnapshot)({
366 schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION,369 schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION,
367 environmentId,370 environmentId,
368 threadId: thread.id,371 threadId: snapshot.thread.id,
369 thread,372 snapshot,
370 }),373 }),
371 ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause)));374 ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause)));
372 yield* Effect.try({375 yield* Effect.try({
apps/server/src/auth/http.ts+10
...@@ -16,6 +16,8 @@ import {...@@ -16,6 +16,8 @@ import {
16 EnvironmentOperationForbiddenError,16 EnvironmentOperationForbiddenError,
17 EnvironmentRequestInvalidError,17 EnvironmentRequestInvalidError,
18 type EnvironmentRequestInvalidReason,18 type EnvironmentRequestInvalidReason,
19 EnvironmentResourceNotFoundError,
20 type EnvironmentResourceNotFoundReason,
19 EnvironmentScopeRequiredError,21 EnvironmentScopeRequiredError,
20 EnvironmentAuthenticatedAuth,22 EnvironmentAuthenticatedAuth,
21 EnvironmentAuthenticatedPrincipal,23 EnvironmentAuthenticatedPrincipal,
...@@ -137,6 +139,14 @@ function failEnvironmentOperationForbidden(reason: "current_session_revoke_not_a...@@ -137,6 +139,14 @@ function failEnvironmentOperationForbidden(reason: "current_session_revoke_not_a
137 );139 );
138}140}
139141
142export function failEnvironmentNotFound(reason: EnvironmentResourceNotFoundReason) {
143 return currentEnvironmentTraceId.pipe(
144 Effect.flatMap((traceId) =>
145 Effect.fail(new EnvironmentResourceNotFoundError({ code: "not_found", reason, traceId })),
146 ),
147 );
148}
149
140export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, error?: unknown) {150export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, error?: unknown) {
141 return Effect.gen(function* () {151 return Effect.gen(function* () {
142 const traceId = yield* currentEnvironmentTraceId;152 const traceId = yield* currentEnvironmentTraceId;
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+5
...@@ -107,6 +107,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -107,6 +107,7 @@ describe("CheckpointDiffQuery.layer", () => {
107 }),107 }),
108 getThreadShellById: () => Effect.succeed(Option.none()),108 getThreadShellById: () => Effect.succeed(Option.none()),
109 getThreadDetailById: () => Effect.succeed(Option.none()),109 getThreadDetailById: () => Effect.succeed(Option.none()),
110 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
110 }),111 }),
111 ),112 ),
112 );113 );
...@@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => {
199 getFullThreadDiffContext: () => Effect.die("unused"),200 getFullThreadDiffContext: () => Effect.die("unused"),
200 getThreadShellById: () => Effect.succeed(Option.none()),201 getThreadShellById: () => Effect.succeed(Option.none()),
201 getThreadDetailById: () => Effect.succeed(Option.none()),202 getThreadDetailById: () => Effect.succeed(Option.none()),
203 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
202 }),204 }),
203 ),205 ),
204 );206 );
...@@ -281,6 +283,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -281,6 +283,7 @@ describe("CheckpointDiffQuery.layer", () => {
281 getFullThreadDiffContext: () => Effect.die("unused"),283 getFullThreadDiffContext: () => Effect.die("unused"),
282 getThreadShellById: () => Effect.succeed(Option.none()),284 getThreadShellById: () => Effect.succeed(Option.none()),
283 getThreadDetailById: () => Effect.succeed(Option.none()),285 getThreadDetailById: () => Effect.succeed(Option.none()),
286 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
284 }),287 }),
285 ),288 ),
286 );289 );
...@@ -348,6 +351,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -348,6 +351,7 @@ describe("CheckpointDiffQuery.layer", () => {
348 getFullThreadDiffContext: () => Effect.die("unused"),351 getFullThreadDiffContext: () => Effect.die("unused"),
349 getThreadShellById: () => Effect.succeed(Option.none()),352 getThreadShellById: () => Effect.succeed(Option.none()),
350 getThreadDetailById: () => Effect.succeed(Option.none()),353 getThreadDetailById: () => Effect.succeed(Option.none()),
354 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
351 }),355 }),
352 ),356 ),
353 );357 );
...@@ -400,6 +404,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -400,6 +404,7 @@ describe("CheckpointDiffQuery.layer", () => {
400 getFullThreadDiffContext: () => Effect.succeed(Option.none()),404 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
401 getThreadShellById: () => Effect.succeed(Option.none()),405 getThreadShellById: () => Effect.succeed(Option.none()),
402 getThreadDetailById: () => Effect.succeed(Option.none()),406 getThreadDetailById: () => Effect.succeed(Option.none()),
407 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
403 }),408 }),
404 ),409 ),
405 );410 );
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+1
...@@ -200,6 +200,7 @@ describe("OrchestrationEngine", () => {...@@ -200,6 +200,7 @@ describe("OrchestrationEngine", () => {
200 getFullThreadDiffContext: () => Effect.succeed(Option.none()),200 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
201 getThreadShellById: () => Effect.succeed(Option.none()),201 getThreadShellById: () => Effect.succeed(Option.none()),
202 getThreadDetailById: () => Effect.succeed(Option.none()),202 getThreadDetailById: () => Effect.succeed(Option.none()),
203 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
203 }),204 }),
204 ),205 ),
205 Layer.provide(206 Layer.provide(
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+2-2
...@@ -306,8 +306,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {...@@ -306,8 +306,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {
306 Effect.annotateLogs({ sequence: commandReadModel.snapshotSequence }),306 Effect.annotateLogs({ sequence: commandReadModel.snapshotSequence }),
307 );307 );
308308
309 const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive) =>309 const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive, limit) =>
310 eventStore.readFromSequence(fromSequenceExclusive);310 eventStore.readFromSequence(fromSequenceExclusive, limit);
311311
312 const dispatch: OrchestrationEngineShape["dispatch"] = (command) =>312 const dispatch: OrchestrationEngineShape["dispatch"] = (command) =>
313 Effect.gen(function* () {313 Effect.gen(function* () {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+31
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9 OrchestrationReadModel,9 OrchestrationReadModel,
10 OrchestrationShellSnapshot,10 OrchestrationShellSnapshot,
11 OrchestrationThread,11 OrchestrationThread,
12 OrchestrationThreadDetailSnapshot,
12 ProjectScript,13 ProjectScript,
13 TurnId,14 TurnId,
14 type OrchestrationCheckpointSummary,15 type OrchestrationCheckpointSummary,
...@@ -2033,6 +2034,35 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2033,6 +2034,35 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2033 );2034 );
2034 });2035 });
20352036
2037 const getThreadDetailSnapshot: ProjectionSnapshotQueryShape["getThreadDetailSnapshot"] = (
2038 threadId,
2039 ) =>
2040 // Read the thread detail and the snapshot sequence within a single
2041 // transaction so the sequence is consistent with the returned state; a
2042 // projector update landing between two separate reads could otherwise return
2043 // a sequence ahead of the thread detail, causing the client to resume from
2044 // too far and drop events.
2045 sql
2046 .withTransaction(
2047 Effect.gen(function* () {
2048 const thread = yield* getThreadDetailById(threadId);
2049 if (Option.isNone(thread)) {
2050 return Option.none<OrchestrationThreadDetailSnapshot>();
2051 }
2052 const { snapshotSequence } = yield* getSnapshotSequence();
2053 return Option.some({ snapshotSequence, thread: thread.value });
2054 }),
2055 )
2056 .pipe(
2057 Effect.mapError((error) =>
2058 isPersistenceError(error)
2059 ? error
2060 : toPersistenceSqlError("ProjectionSnapshotQuery.getThreadDetailSnapshot:transaction")(
2061 error,
2062 ),
2063 ),
2064 );
2065
2036 return {2066 return {
2037 getCommandReadModel,2067 getCommandReadModel,
2038 getSnapshot,2068 getSnapshot,
...@@ -2047,6 +2077,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -2047,6 +2077,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
2047 getFullThreadDiffContext,2077 getFullThreadDiffContext,
2048 getThreadShellById,2078 getThreadShellById,
2049 getThreadDetailById,2079 getThreadDetailById,
2080 getThreadDetailSnapshot,
2050 } satisfies ProjectionSnapshotQueryShape;2081 } satisfies ProjectionSnapshotQueryShape;
2051});2082});
20522083
apps/server/src/orchestration/Services/OrchestrationEngine.ts+5
...@@ -26,10 +26,15 @@ export interface OrchestrationEngineShape {...@@ -26,10 +26,15 @@ export interface OrchestrationEngineShape {
26 * Replay persisted orchestration events from an exclusive sequence cursor.26 * Replay persisted orchestration events from an exclusive sequence cursor.
27 *27 *
28 * @param fromSequenceExclusive - Sequence cursor (exclusive).28 * @param fromSequenceExclusive - Sequence cursor (exclusive).
29 * @param limit - Maximum number of events to read. Defaults to the event
30 * store's page-bounded default; pass a higher value when the caller must
31 * read every event after the cursor (e.g. per-thread catch-up that filters
32 * a small subset out of a potentially larger global range).
29 * @returns Stream containing ordered events.33 * @returns Stream containing ordered events.
30 */34 */
31 readonly readEvents: (35 readonly readEvents: (
32 fromSequenceExclusive: number,36 fromSequenceExclusive: number,
37 limit?: number,
33 ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError, never>;38 ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError, never>;
3439
35 /**40 /**
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+11
...@@ -14,6 +14,7 @@ import type {...@@ -14,6 +14,7 @@ import type {
14 OrchestrationReadModel,14 OrchestrationReadModel,
15 OrchestrationShellSnapshot,15 OrchestrationShellSnapshot,
16 OrchestrationThread,16 OrchestrationThread,
17 OrchestrationThreadDetailSnapshot,
17 OrchestrationThreadShell,18 OrchestrationThreadShell,
18 ProjectId,19 ProjectId,
19 ThreadId,20 ThreadId,
...@@ -157,6 +158,16 @@ export interface ProjectionSnapshotQueryShape {...@@ -157,6 +158,16 @@ export interface ProjectionSnapshotQueryShape {
157 readonly getThreadDetailById: (158 readonly getThreadDetailById: (
158 threadId: ThreadId,159 threadId: ThreadId,
159 ) => Effect.Effect<Option.Option<OrchestrationThread>, ProjectionRepositoryError>;160 ) => Effect.Effect<Option.Option<OrchestrationThread>, ProjectionRepositoryError>;
161
162 /**
163 * Read a single active thread detail together with the projection snapshot
164 * sequence in one consistent transaction, so the returned `snapshotSequence`
165 * exactly matches the state reflected in `thread` (no interleaving projector
166 * update between the two reads).
167 */
168 readonly getThreadDetailSnapshot: (
169 threadId: ThreadId,
170 ) => Effect.Effect<Option.Option<OrchestrationThreadDetailSnapshot>, ProjectionRepositoryError>;
160}171}
161172
162/**173/**
apps/server/src/orchestration/http.ts+34
...@@ -4,6 +4,7 @@ import {...@@ -4,6 +4,7 @@ import {
4 EnvironmentHttpApi,4 EnvironmentHttpApi,
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
7import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";8import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
89
9import { normalizeDispatchCommand } from "./Normalizer.ts";10import { normalizeDispatchCommand } from "./Normalizer.ts";
...@@ -11,6 +12,7 @@ import {...@@ -11,6 +12,7 @@ import {
11 annotateEnvironmentRequest,12 annotateEnvironmentRequest,
12 failEnvironmentInternal,13 failEnvironmentInternal,
13 failEnvironmentInvalidRequest,14 failEnvironmentInvalidRequest,
15 failEnvironmentNotFound,
14 requireEnvironmentScope,16 requireEnvironmentScope,
15} from "../auth/http.ts";17} from "../auth/http.ts";
16import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";18import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
...@@ -38,6 +40,38 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(...@@ -38,6 +40,38 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
38 );40 );
39 }),41 }),
40 )42 )
43 .handle(
44 "shellSnapshot",
45 Effect.fn("environment.orchestration.shellSnapshot")(function* (args) {
46 yield* annotateEnvironmentRequest(args.endpoint.name);
47 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
48 return yield* projectionSnapshotQuery
49 .getShellSnapshot()
50 .pipe(
51 Effect.catch((cause) =>
52 failEnvironmentInternal("orchestration_snapshot_failed", cause),
53 ),
54 );
55 }),
56 )
57 .handle(
58 "threadSnapshot",
59 Effect.fn("environment.orchestration.threadSnapshot")(function* (args) {
60 yield* annotateEnvironmentRequest(args.endpoint.name);
61 yield* requireEnvironmentScope(AuthOrchestrationReadScope);
62 const snapshot = yield* projectionSnapshotQuery
63 .getThreadDetailSnapshot(args.params.threadId)
64 .pipe(
65 Effect.catch((cause) =>
66 failEnvironmentInternal("orchestration_thread_snapshot_failed", cause),
67 ),
68 );
69 if (Option.isNone(snapshot)) {
70 return yield* failEnvironmentNotFound("thread_not_found");
71 }
72 return snapshot.value;
73 }),
74 )
41 .handle(75 .handle(
42 "dispatch",76 "dispatch",
43 Effect.fn("environment.orchestration.dispatch")(function* (args) {77 Effect.fn("environment.orchestration.dispatch")(function* (args) {
apps/server/src/project/ProjectSetupScriptRunner.test.ts+1
...@@ -43,6 +43,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>...@@ -43,6 +43,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
43 getFullThreadDiffContext: () => Effect.die("unused"),43 getFullThreadDiffContext: () => Effect.die("unused"),
44 getThreadShellById: () => Effect.die("unused"),44 getThreadShellById: () => Effect.die("unused"),
45 getThreadDetailById: () => Effect.die("unused"),45 getThreadDetailById: () => Effect.die("unused"),
46 getThreadDetailSnapshot: () => Effect.die("unused"),
46 });47 });
4748
48const makeTerminalManagerLayer = (49const makeTerminalManagerLayer = (
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
...@@ -209,6 +209,7 @@ describe("ProviderSessionReaper", () => {...@@ -209,6 +209,7 @@ describe("ProviderSessionReaper", () => {
209 : Option.none(),209 : Option.none(),
210 ),210 ),
211 getThreadDetailById: () => Effect.die("unused"),211 getThreadDetailById: () => Effect.die("unused"),
212 getThreadDetailSnapshot: () => Effect.die("unused"),
212 }),213 }),
213 ),214 ),
214 Layer.provideMerge(NodeServices.layer),215 Layer.provideMerge(NodeServices.layer),
apps/server/src/server.test.ts+1
...@@ -702,6 +702,7 @@ const buildAppUnderTest = (options?: {...@@ -702,6 +702,7 @@ const buildAppUnderTest = (options?: {
702 getProjectShellById: () => Effect.succeed(Option.none()),702 getProjectShellById: () => Effect.succeed(Option.none()),
703 getThreadShellById: () => Effect.succeed(Option.none()),703 getThreadShellById: () => Effect.succeed(Option.none()),
704 getThreadDetailById: () => Effect.succeed(Option.none()),704 getThreadDetailById: () => Effect.succeed(Option.none()),
705 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
705 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),706 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
706 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),707 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
707 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),708 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
apps/server/src/serverRuntimeStartup.test.ts+4
...@@ -96,6 +96,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa...@@ -96,6 +96,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa
96 getFullThreadDiffContext: () => Effect.succeed(Option.none()),96 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
97 getThreadShellById: () => Effect.succeed(Option.none()),97 getThreadShellById: () => Effect.succeed(Option.none()),
98 getThreadDetailById: () => Effect.succeed(Option.none()),98 getThreadDetailById: () => Effect.succeed(Option.none()),
99 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
99 }),100 }),
100 Effect.provideService(AnalyticsService.AnalyticsService, {101 Effect.provideService(AnalyticsService.AnalyticsService, {
101 record: () => Effect.void,102 record: () => Effect.void,
...@@ -158,6 +159,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa...@@ -158,6 +159,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
158 getFullThreadDiffContext: () => Effect.succeed(Option.none()),159 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
159 getThreadShellById: () => Effect.die("unused"),160 getThreadShellById: () => Effect.die("unused"),
160 getThreadDetailById: () => Effect.die("unused"),161 getThreadDetailById: () => Effect.die("unused"),
162 getThreadDetailSnapshot: () => Effect.die("unused"),
161 }),163 }),
162 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {164 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
163 readEvents: () => Stream.empty,165 readEvents: () => Stream.empty,
...@@ -200,6 +202,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when...@@ -200,6 +202,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
200 getFullThreadDiffContext: () => Effect.succeed(Option.none()),202 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
201 getThreadShellById: () => Effect.die("unused"),203 getThreadShellById: () => Effect.die("unused"),
202 getThreadDetailById: () => Effect.die("unused"),204 getThreadDetailById: () => Effect.die("unused"),
205 getThreadDetailSnapshot: () => Effect.die("unused"),
203 }),206 }),
204 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {207 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
205 readEvents: () => Stream.empty,208 readEvents: () => Stream.empty,
...@@ -248,6 +251,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa...@@ -248,6 +251,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
248 getFullThreadDiffContext: () => Effect.succeed(Option.none()),251 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
249 getThreadShellById: () => Effect.die("unused"),252 getThreadShellById: () => Effect.die("unused"),
250 getThreadDetailById: () => Effect.die("unused"),253 getThreadDetailById: () => Effect.die("unused"),
254 getThreadDetailSnapshot: () => Effect.die("unused"),
251 }),255 }),
252 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {256 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
253 readEvents: () => Stream.empty,257 readEvents: () => Stream.empty,
apps/server/src/ws.ts+109-40
...@@ -30,6 +30,8 @@ import {...@@ -30,6 +30,8 @@ import {
30 OrchestrationDispatchCommandError,30 OrchestrationDispatchCommandError,
31 type OrchestrationEvent,31 type OrchestrationEvent,
32 type OrchestrationShellStreamEvent,32 type OrchestrationShellStreamEvent,
33 type OrchestrationShellStreamItem,
34 type OrchestrationThreadStreamItem,
33 OrchestrationGetFullThreadDiffError,35 OrchestrationGetFullThreadDiffError,
34 OrchestrationGetSnapshotError,36 OrchestrationGetSnapshotError,
35 OrchestrationGetTurnDiffError,37 OrchestrationGetTurnDiffError,
...@@ -1059,10 +1061,54 @@ const makeWsRpcLayer = (...@@ -1059,10 +1061,54 @@ const makeWsRpcLayer = (
1059 ),1061 ),
1060 { "rpc.aggregate": "orchestration" },1062 { "rpc.aggregate": "orchestration" },
1061 ),1063 ),
1062 [ORCHESTRATION_WS_METHODS.subscribeShell]: (_input) =>1064 [ORCHESTRATION_WS_METHODS.subscribeShell]: (input) =>
1063 observeRpcStreamEffect(1065 observeRpcStreamEffect(
1064 ORCHESTRATION_WS_METHODS.subscribeShell,1066 ORCHESTRATION_WS_METHODS.subscribeShell,
1065 Effect.gen(function* () {1067 Effect.gen(function* () {
1068 const liveStream = orchestrationEngine.streamDomainEvents.pipe(
1069 Stream.mapEffect(toShellStreamEvent),
1070 Stream.flatMap((event) =>
1071 Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty,
1072 ),
1073 );
1074
1075 // When the client already holds a shell snapshot (cached, or loaded
1076 // over HTTP) it passes that snapshot's sequence, and we resume by
1077 // replaying shell events after it instead of re-sending the whole
1078 // projects/threads list over the socket. As in the thread path, the
1079 // live subscription is attached (into a scope-bound buffer) before
1080 // draining the catch-up replay so no event published during the
1081 // replay window is lost; overlapping events are deduped by sequence
1082 // on the client. The full range is read (not the store's default
1083 // page limit) since the shell filter runs after reading.
1084 if (input.afterSequence !== undefined) {
1085 const afterSequence = input.afterSequence;
1086 return Stream.unwrap(
1087 Effect.gen(function* () {
1088 const liveBuffer = yield* Queue.unbounded<OrchestrationShellStreamItem>();
1089 yield* Effect.forkScoped(
1090 liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))),
1091 );
1092 const catchUpStream = orchestrationEngine
1093 .readEvents(afterSequence, Number.MAX_SAFE_INTEGER)
1094 .pipe(
1095 Stream.mapEffect(toShellStreamEvent),
1096 Stream.flatMap((event) =>
1097 Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty,
1098 ),
1099 Stream.mapError(
1100 (cause) =>
1101 new OrchestrationGetSnapshotError({
1102 message: "Failed to replay orchestration shell events",
1103 cause,
1104 }),
1105 ),
1106 );
1107 return Stream.concat(catchUpStream, Stream.fromQueue(liveBuffer));
1108 }),
1109 );
1110 }
1111
1066 const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe(1112 const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe(
1067 Effect.tapError((cause) =>1113 Effect.tapError((cause) =>
1068 Effect.logError("orchestration shell snapshot load failed", { cause }),1114 Effect.logError("orchestration shell snapshot load failed", { cause }),
...@@ -1076,13 +1122,6 @@ const makeWsRpcLayer = (...@@ -1076,13 +1122,6 @@ const makeWsRpcLayer = (
1076 ),1122 ),
1077 );1123 );
10781124
1079 const liveStream = orchestrationEngine.streamDomainEvents.pipe(
1080 Stream.mapEffect(toShellStreamEvent),
1081 Stream.flatMap((event) =>
1082 Option.isSome(event) ? Stream.succeed(event.value) : Stream.empty,
1083 ),
1084 );
1085
1086 return Stream.concat(1125 return Stream.concat(
1087 Stream.make({1126 Stream.make({
1088 kind: "snapshot" as const,1127 kind: "snapshot" as const,
...@@ -1114,8 +1153,65 @@ const makeWsRpcLayer = (...@@ -1114,8 +1153,65 @@ const makeWsRpcLayer = (
1114 observeRpcStreamEffect(1153 observeRpcStreamEffect(
1115 ORCHESTRATION_WS_METHODS.subscribeThread,1154 ORCHESTRATION_WS_METHODS.subscribeThread,
1116 Effect.gen(function* () {1155 Effect.gen(function* () {
1117 const [threadDetail, snapshotSequence] = yield* Effect.all([1156 const isThisThreadDetailEvent = (event: OrchestrationEvent) =>
1118 projectionSnapshotQuery.getThreadDetailById(input.threadId).pipe(1157 event.aggregateKind === "thread" &&
1158 event.aggregateId === input.threadId &&
1159 isThreadDetailEvent(event);
1160
1161 const liveStream = orchestrationEngine.streamDomainEvents.pipe(
1162 Stream.filter(isThisThreadDetailEvent),
1163 Stream.map((event) => ({
1164 kind: "event" as const,
1165 event,
1166 })),
1167 );
1168
1169 // When the client already loaded the snapshot over HTTP it passes
1170 // that snapshot's sequence, and we resume the live subscription by
1171 // replaying persisted events after it instead of re-sending the
1172 // (potentially multi-KB) snapshot frame over the socket.
1173 //
1174 // The live PubSub subscription must be attached *before* draining
1175 // the catch-up replay, otherwise events published during the replay
1176 // window are dropped (they are past the persisted tail the replay
1177 // read, but the live stream is not yet subscribed). So fork the
1178 // live stream into a buffer bound to this stream's scope, then emit
1179 // catch-up followed by the buffered/ongoing live events. Overlapping
1180 // events are deduped by sequence on the client.
1181 //
1182 // Read the full range after the cursor (not the store's default
1183 // page-bounded limit): the range is normally tiny (a fresh HTTP
1184 // snapshot sequence) and the per-thread filter runs after reading,
1185 // so a global cap could otherwise omit this thread's events.
1186 if (input.afterSequence !== undefined) {
1187 const afterSequence = input.afterSequence;
1188 return Stream.unwrap(
1189 Effect.gen(function* () {
1190 const liveBuffer = yield* Queue.unbounded<OrchestrationThreadStreamItem>();
1191 yield* Effect.forkScoped(
1192 liveStream.pipe(Stream.runForEach((item) => Queue.offer(liveBuffer, item))),
1193 );
1194 const catchUpStream = orchestrationEngine
1195 .readEvents(afterSequence, Number.MAX_SAFE_INTEGER)
1196 .pipe(
1197 Stream.filter(isThisThreadDetailEvent),
1198 Stream.map((event) => ({ kind: "event" as const, event })),
1199 Stream.mapError(
1200 (cause) =>
1201 new OrchestrationGetSnapshotError({
1202 message: `Failed to replay thread ${input.threadId} events`,
1203 cause,
1204 }),
1205 ),
1206 );
1207 return Stream.concat(catchUpStream, Stream.fromQueue(liveBuffer));
1208 }),
1209 );
1210 }
1211
1212 const snapshot = yield* projectionSnapshotQuery
1213 .getThreadDetailSnapshot(input.threadId)
1214 .pipe(
1119 Effect.mapError(1215 Effect.mapError(
1120 (cause) =>1216 (cause) =>
1121 new OrchestrationGetSnapshotError({1217 new OrchestrationGetSnapshotError({
...@@ -1123,46 +1219,19 @@ const makeWsRpcLayer = (...@@ -1123,46 +1219,19 @@ const makeWsRpcLayer = (
1123 cause,1219 cause,
1124 }),1220 }),
1125 ),1221 ),
1126 ),1222 );
1127 projectionSnapshotQuery.getSnapshotSequence().pipe(
1128 Effect.map(({ snapshotSequence }) => snapshotSequence),
1129 Effect.mapError(
1130 (cause) =>
1131 new OrchestrationGetSnapshotError({
1132 message: "Failed to load orchestration snapshot sequence",
1133 cause,
1134 }),
1135 ),
1136 ),
1137 ]);
11381223
1139 if (Option.isNone(threadDetail)) {1224 if (Option.isNone(snapshot)) {
1140 return yield* new OrchestrationGetSnapshotError({1225 return yield* new OrchestrationGetSnapshotError({
1141 message: `Thread ${input.threadId} was not found`,1226 message: `Thread ${input.threadId} was not found`,
1142 cause: input.threadId,1227 cause: input.threadId,
1143 });1228 });
1144 }1229 }
11451230
1146 const liveStream = orchestrationEngine.streamDomainEvents.pipe(
1147 Stream.filter(
1148 (event) =>
1149 event.aggregateKind === "thread" &&
1150 event.aggregateId === input.threadId &&
1151 isThreadDetailEvent(event),
1152 ),
1153 Stream.map((event) => ({
1154 kind: "event" as const,
1155 event,
1156 })),
1157 );
1158
1159 return Stream.concat(1231 return Stream.concat(
1160 Stream.make({1232 Stream.make({
1161 kind: "snapshot" as const,1233 kind: "snapshot" as const,
1162 snapshot: {1234 snapshot: snapshot.value,
1163 snapshotSequence,
1164 thread: threadDetail.value,
1165 },
1166 }),1235 }),
1167 liveStream,1236 liveStream,
1168 );1237 );
apps/web/src/connection/runtime.ts+6-1
...@@ -1,4 +1,6 @@...@@ -1,4 +1,6 @@
1import { Connection } from "@t3tools/client-runtime/connection";1import { Connection } from "@t3tools/client-runtime/connection";
2import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";
3import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads";
2import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
3import { Atom } from "effect/unstable/reactivity";5import { Atom } from "effect/unstable/reactivity";
46
...@@ -9,12 +11,15 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(...@@ -9,12 +11,15 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
9 Layer.provide(runtimeContextLayer),11 Layer.provide(runtimeContextLayer),
10);12);
1113
14const snapshotLoaderLayer = Layer.merge(threadSnapshotLoaderLayer, shellSnapshotLoaderLayer);
15
12type ConnectionLayerSource =16type ConnectionLayerSource =
13 | typeof Connection.layer17 | typeof Connection.layer
18 | typeof snapshotLoaderLayer
14 | typeof runtimeContextLayer19 | typeof runtimeContextLayer
15 | typeof connectionPlatformLayer;20 | typeof connectionPlatformLayer;
1621
17const connectionLayer = Connection.layer.pipe(22const connectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe(
18 Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)),23 Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)),
19);24);
2025
apps/web/src/connection/storage.ts+12-9
...@@ -20,7 +20,7 @@ import {...@@ -20,7 +20,7 @@ import {
20import {20import {
21 EnvironmentId,21 EnvironmentId,
22 OrchestrationShellSnapshot,22 OrchestrationShellSnapshot,
23 OrchestrationThread,23 OrchestrationThreadDetailSnapshot,
24 ThreadId,24 ThreadId,
25} from "@t3tools/contracts";25} from "@t3tools/contracts";
26import * as Context from "effect/Context";26import * as Context from "effect/Context";
...@@ -45,11 +45,14 @@ const StoredShellSnapshot = Schema.Struct({...@@ -45,11 +45,14 @@ const StoredShellSnapshot = Schema.Struct({
45 snapshot: OrchestrationShellSnapshot,45 snapshot: OrchestrationShellSnapshot,
46});46});
47const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot);47const StoredShellSnapshotJson = Schema.fromJsonString(StoredShellSnapshot);
48// v2 stores the snapshot sequence alongside the thread so a warm cache can
49// resume via `afterSequence` instead of re-downloading the full thread body.
50// Older v1 entries (no sequence) fail to decode and are treated as a cold cache.
48const StoredThreadSnapshot = Schema.Struct({51const StoredThreadSnapshot = Schema.Struct({
49 schemaVersion: Schema.Literal(1),52 schemaVersion: Schema.Literal(2),
50 environmentId: EnvironmentId,53 environmentId: EnvironmentId,
51 threadId: ThreadId,54 threadId: ThreadId,
52 thread: OrchestrationThread,55 snapshot: OrchestrationThreadDetailSnapshot,
53});56});
54const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot);57const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot);
55const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument);58const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument);
...@@ -473,7 +476,7 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -473,7 +476,7 @@ export const connectionStorageLayer = Layer.effectContext(
473 Effect.mapError((cause) => persistenceError("load-thread", cause)),476 Effect.mapError((cause) => persistenceError("load-thread", cause)),
474 Effect.map((stored) =>477 Effect.map((stored) =>
475 stored.environmentId === environmentId && stored.threadId === threadId478 stored.environmentId === environmentId && stored.threadId === threadId
476 ? Option.some(stored.thread)479 ? Option.some(stored.snapshot)
477 : Option.none(),480 : Option.none(),
478 ),481 ),
479 );482 );
...@@ -484,18 +487,18 @@ export const connectionStorageLayer = Layer.effectContext(...@@ -484,18 +487,18 @@ export const connectionStorageLayer = Layer.effectContext(
484 : persistenceError("load-thread", cause),487 : persistenceError("load-thread", cause),
485 ),488 ),
486 ),489 ),
487 saveThread: (environmentId, thread) =>490 saveThread: (environmentId, snapshot) =>
488 Effect.gen(function* () {491 Effect.gen(function* () {
489 const encoded = yield* encodeStoredThreadSnapshot({492 const encoded = yield* encodeStoredThreadSnapshot({
490 schemaVersion: 1,493 schemaVersion: 2,
491 environmentId,494 environmentId,
492 threadId: thread.id,495 threadId: snapshot.thread.id,
493 thread,496 snapshot,
494 }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause)));497 }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause)));
495 yield* writeDatabaseValue(498 yield* writeDatabaseValue(
496 database,499 database,
497 THREAD_STORE_NAME,500 THREAD_STORE_NAME,
498 threadCacheKey(environmentId, thread.id),501 threadCacheKey(environmentId, snapshot.thread.id),
499 encoded,502 encoded,
500 );503 );
501 }).pipe(504 }).pipe(
apps/web/src/environments/primary/auth.ts+2
...@@ -221,6 +221,8 @@ function readEnvironmentHttpErrorStatus(error: EnvironmentHttpCommonErrorType):...@@ -221,6 +221,8 @@ function readEnvironmentHttpErrorStatus(error: EnvironmentHttpCommonErrorType):
221 case "EnvironmentScopeRequiredError":221 case "EnvironmentScopeRequiredError":
222 case "EnvironmentOperationForbiddenError":222 case "EnvironmentOperationForbiddenError":
223 return 403;223 return 403;
224 case "EnvironmentResourceNotFoundError":
225 return 404;
224 case "EnvironmentInternalError":226 case "EnvironmentInternalError":
225 return 500;227 return 500;
226 }228 }
packages/client-runtime/src/connection/errors.ts+9
...@@ -132,6 +132,15 @@ export function mapRemoteEnvironmentError(...@@ -132,6 +132,15 @@ export function mapRemoteEnvironmentError(
132 detail: "The environment rejected the authentication request.",132 detail: "The environment rejected the authentication request.",
133 traceId: error.traceId,133 traceId: error.traceId,
134 });134 });
135 case "EnvironmentResourceNotFoundError":
136 // Not expected during connection authorization, but the shared request
137 // error type now includes it (used by resource fetches like the thread
138 // snapshot). Treat it as a configuration issue with the endpoint.
139 return new ConnectionBlockedError({
140 reason: "configuration",
141 detail: "The environment endpoint could not be found.",
142 traceId: error.traceId,
143 });
135 case "RemoteEnvironmentAuthTimeoutError":144 case "RemoteEnvironmentAuthTimeoutError":
136 return new ConnectionTransientError({145 return new ConnectionTransientError({
137 reason: "timeout",146 reason: "timeout",
packages/client-runtime/src/platform/persistence.ts+6-3
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import {1import {
2 type EnvironmentId,2 type EnvironmentId,
3 type OrchestrationThread,
4 type OrchestrationShellSnapshot,3 type OrchestrationShellSnapshot,
4 type OrchestrationThreadDetailSnapshot,
5 type ThreadId,5 type ThreadId,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import * as Context from "effect/Context";7import * as Context from "effect/Context";
...@@ -60,10 +60,13 @@ export class EnvironmentCacheStore extends Context.Service<...@@ -60,10 +60,13 @@ export class EnvironmentCacheStore extends Context.Service<
60 readonly loadThread: (60 readonly loadThread: (
61 environmentId: EnvironmentId,61 environmentId: EnvironmentId,
62 threadId: ThreadId,62 threadId: ThreadId,
63 ) => Effect.Effect<Option.Option<OrchestrationThread>, ConnectionPersistenceError>;63 ) => Effect.Effect<
64 Option.Option<OrchestrationThreadDetailSnapshot>,
65 ConnectionPersistenceError
66 >;
64 readonly saveThread: (67 readonly saveThread: (
65 environmentId: EnvironmentId,68 environmentId: EnvironmentId,
66 thread: OrchestrationThread,69 snapshot: OrchestrationThreadDetailSnapshot,
67 ) => Effect.Effect<void, ConnectionPersistenceError>;70 ) => Effect.Effect<void, ConnectionPersistenceError>;
68 readonly removeThread: (71 readonly removeThread: (
69 environmentId: EnvironmentId,72 environmentId: EnvironmentId,
packages/client-runtime/src/rpc/http.ts+2
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 type EnvironmentInternalError,5 type EnvironmentInternalError,
6 type EnvironmentOperationForbiddenError,6 type EnvironmentOperationForbiddenError,
7 type EnvironmentRequestInvalidError,7 type EnvironmentRequestInvalidError,
8 type EnvironmentResourceNotFoundError,
8 type EnvironmentScopeRequiredError,9 type EnvironmentScopeRequiredError,
9} from "@t3tools/contracts";10} from "@t3tools/contracts";
10import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";11import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";
...@@ -70,6 +71,7 @@ export type RemoteEnvironmentRequestError =...@@ -70,6 +71,7 @@ export type RemoteEnvironmentRequestError =
70 | EnvironmentAuthInvalidError71 | EnvironmentAuthInvalidError
71 | EnvironmentScopeRequiredError72 | EnvironmentScopeRequiredError
72 | EnvironmentOperationForbiddenError73 | EnvironmentOperationForbiddenError
74 | EnvironmentResourceNotFoundError
73 | EnvironmentInternalError75 | EnvironmentInternalError
74 | RemoteEnvironmentAuthFetchError76 | RemoteEnvironmentAuthFetchError
75 | RemoteEnvironmentAuthInvalidJsonError77 | RemoteEnvironmentAuthInvalidJsonError
packages/client-runtime/src/state/environmentHttpAuth.ts created+73
...@@ -0,0 +1,73 @@
1import * as Effect from "effect/Effect";
2import * as Option from "effect/Option";
3import { FetchHttpClient, type HttpMethod } from "effect/unstable/http";
4
5import type { PreparedHttpAuthorization } from "../connection/model.ts";
6import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
7import { RemoteEnvironmentAuthFetchError } from "../rpc/http.ts";
8
9export interface EnvironmentHttpAuthHeaders {
10 readonly authorization?: string;
11 readonly dpop?: string;
12}
13
14/**
15 * Primary/local environments with no bearer or DPoP credential authenticate the
16 * browser via a session cookie. A cross-origin `fetch` does not send cookies by
17 * default, so those requests must opt into credentialed mode; bearer/DPoP
18 * connections carry their credential in a header and need no cookies. Applied
19 * per-request via `FetchHttpClient.RequestInit`, which the fetch client reads
20 * from the fiber context at request time.
21 */
22export const withEnvironmentCredentials = <A, E, R>(
23 authorization: PreparedHttpAuthorization | null,
24 request: Effect.Effect<A, E, R>,
25): Effect.Effect<A, E, R> =>
26 authorization === null
27 ? request.pipe(Effect.provideService(FetchHttpClient.RequestInit, { credentials: "include" }))
28 : request;
29
30/**
31 * Build the authorization headers for an authenticated environment HTTP
32 * request, matching the credential the connection was prepared with:
33 * - primary/local connections carry no credential,
34 * - bearer connections send a static `Bearer` token,
35 * - relay connections send a `DPoP` access token with a freshly signed proof
36 * bound to this request's method and URL.
37 *
38 * The DPoP signer is passed in (not resolved from context) and is only required
39 * for relay/DPoP connections, so bearer/primary connections work even when no
40 * signer is available.
41 */
42export const buildEnvironmentAuthHeaders = (
43 authorization: PreparedHttpAuthorization | null,
44 method: HttpMethod.HttpMethod,
45 url: string,
46 signer: Option.Option<ManagedRelayDpopSigner["Service"]>,
47): Effect.Effect<EnvironmentHttpAuthHeaders, RemoteEnvironmentAuthFetchError> =>
48 Effect.gen(function* () {
49 if (authorization === null) {
50 return {};
51 }
52 if (authorization._tag === "Bearer") {
53 return { authorization: `Bearer ${authorization.token}` };
54 }
55 if (Option.isNone(signer)) {
56 return yield* new RemoteEnvironmentAuthFetchError({
57 message: "No DPoP signer is available to authorize the environment request.",
58 cause: authorization._tag,
59 });
60 }
61 const proof = yield* signer.value
62 .createProof({ method, url, accessToken: authorization.accessToken })
63 .pipe(
64 Effect.mapError(
65 (cause) =>
66 new RemoteEnvironmentAuthFetchError({
67 message: "Could not create the environment request authorization proof.",
68 cause,
69 }),
70 ),
71 );
72 return { authorization: `DPoP ${authorization.accessToken}`, dpop: proof };
73 });
packages/client-runtime/src/state/shell-sync.test.ts+78-2
...@@ -20,7 +20,7 @@ import * as EnvironmentSupervisor from "../connection/supervisor.ts";...@@ -20,7 +20,7 @@ import * as EnvironmentSupervisor from "../connection/supervisor.ts";
20import * as Persistence from "../platform/persistence.ts";20import * as Persistence from "../platform/persistence.ts";
21import * as RpcSession from "../rpc/session.ts";21import * as RpcSession from "../rpc/session.ts";
22import type { WsRpcProtocolClient } from "../rpc/protocol.ts";22import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
23import { makeEnvironmentShellState } from "./shell.ts";23import { makeEnvironmentShellState, ShellSnapshotLoader } from "./shell.ts";
2424
25const TARGET = new PrimaryConnectionTarget({25const TARGET = new PrimaryConnectionTarget({
26 environmentId: EnvironmentId.make("environment-1"),26 environmentId: EnvironmentId.make("environment-1"),
...@@ -29,6 +29,15 @@ const TARGET = new PrimaryConnectionTarget({...@@ -29,6 +29,15 @@ const TARGET = new PrimaryConnectionTarget({
29 wsBaseUrl: "wss://environment.example.test",29 wsBaseUrl: "wss://environment.example.test",
30});30});
3131
32const PREPARED: PreparedConnection = {
33 environmentId: TARGET.environmentId,
34 label: TARGET.label,
35 httpBaseUrl: TARGET.httpBaseUrl,
36 socketUrl: TARGET.wsBaseUrl,
37 httpAuthorization: null,
38 target: TARGET,
39};
40
32const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = {41const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = {
33 snapshotSequence: 1,42 snapshotSequence: 1,
34 projects: [],43 projects: [],
...@@ -61,7 +70,7 @@ describe("environment shell synchronization", () => {...@@ -61,7 +70,7 @@ describe("environment shell synchronization", () => {
61 target: TARGET,70 target: TARGET,
62 state: supervisorState,71 state: supervisorState,
63 session: activeSession,72 session: activeSession,
64 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),73 prepared: yield* SubscriptionRef.make(Option.some(PREPARED)),
65 connect: Effect.void,74 connect: Effect.void,
66 disconnect: Effect.void,75 disconnect: Effect.void,
67 retryNow: Effect.void,76 retryNow: Effect.void,
...@@ -74,9 +83,15 @@ describe("environment shell synchronization", () => {...@@ -74,9 +83,15 @@ describe("environment shell synchronization", () => {
74 removeThread: () => Effect.void,83 removeThread: () => Effect.void,
75 clear: () => Effect.void,84 clear: () => Effect.void,
76 });85 });
86 // Cold cache with no HTTP snapshot available → falls back to the
87 // socket-embedded snapshot.
88 const snapshotLoader = ShellSnapshotLoader.of({
89 load: () => Effect.succeed(Option.none()),
90 });
77 const shellState = yield* makeEnvironmentShellState().pipe(91 const shellState = yield* makeEnvironmentShellState().pipe(
78 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),92 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
79 Effect.provideService(Persistence.EnvironmentCacheStore, cache),93 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
94 Effect.provideService(ShellSnapshotLoader, snapshotLoader),
80 );95 );
8196
82 yield* SubscriptionRef.set(supervisorState, {97 yield* SubscriptionRef.set(supervisorState, {
...@@ -117,4 +132,65 @@ describe("environment shell synchronization", () => {...@@ -117,4 +132,65 @@ describe("environment shell synchronization", () => {
117 expect(Option.getOrThrow(state.snapshot)).toEqual(LIVE_SHELL_SNAPSHOT);132 expect(Option.getOrThrow(state.snapshot)).toEqual(LIVE_SHELL_SNAPSHOT);
118 }),133 }),
119 );134 );
135
136 it.effect("resumes a warm shell cache via afterSequence without an HTTP fetch", () =>
137 Effect.gen(function* () {
138 const cachedSnapshot: OrchestrationShellSnapshot = {
139 snapshotSequence: 5,
140 projects: [],
141 threads: [],
142 updatedAt: "2026-06-06T00:00:00.000Z",
143 };
144 const events = yield* Queue.unbounded<OrchestrationShellStreamItem>();
145 const capturedAfterSequence = yield* SubscriptionRef.make<number | undefined>(undefined);
146 const loaderCalls = yield* SubscriptionRef.make(0);
147 const client = {
148 [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { readonly afterSequence?: number }) =>
149 Stream.unwrap(
150 SubscriptionRef.set(capturedAfterSequence, input.afterSequence).pipe(
151 Effect.as(Stream.fromQueue(events)),
152 ),
153 ),
154 } as unknown as WsRpcProtocolClient;
155 const supervisorState = yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE);
156 const activeSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(
157 Option.some(session(client)),
158 );
159 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
160 target: TARGET,
161 state: supervisorState,
162 session: activeSession,
163 prepared: yield* SubscriptionRef.make(Option.some(PREPARED)),
164 connect: Effect.void,
165 disconnect: Effect.void,
166 retryNow: Effect.void,
167 } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]);
168 const cache = Persistence.EnvironmentCacheStore.of({
169 loadShell: () => Effect.succeed(Option.some(cachedSnapshot)),
170 saveShell: () => Effect.void,
171 loadThread: () => Effect.succeed(Option.none()),
172 saveThread: () => Effect.void,
173 removeThread: () => Effect.void,
174 clear: () => Effect.void,
175 });
176 const snapshotLoader = ShellSnapshotLoader.of({
177 load: () =>
178 SubscriptionRef.update(loaderCalls, (count) => count + 1).pipe(Effect.as(Option.none())),
179 });
180 yield* makeEnvironmentShellState().pipe(
181 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
182 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
183 Effect.provideService(ShellSnapshotLoader, snapshotLoader),
184 );
185
186 // Wait until the subscription is established from the warm cache.
187 yield* SubscriptionRef.changes(capturedAfterSequence).pipe(
188 Stream.filter((value) => value !== undefined),
189 Stream.runHead,
190 );
191
192 expect(yield* SubscriptionRef.get(capturedAfterSequence)).toBe(5);
193 expect(yield* SubscriptionRef.get(loaderCalls)).toBe(0);
194 }),
195 );
120});196});
packages/client-runtime/src/state/shell.ts+46-8
...@@ -19,6 +19,7 @@ import { EnvironmentSupervisor } from "../connection/supervisor.ts";...@@ -19,6 +19,7 @@ import { EnvironmentSupervisor } from "../connection/supervisor.ts";
19import { safeErrorLogAttributes } from "../errors/safeLog.ts";19import { safeErrorLogAttributes } from "../errors/safeLog.ts";
20import { EnvironmentCacheStore } from "../platform/persistence.ts";20import { EnvironmentCacheStore } from "../platform/persistence.ts";
21import { subscribe } from "../rpc/client.ts";21import { subscribe } from "../rpc/client.ts";
22import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts";
22import { applyShellStreamEvent } from "./shellReducer.ts";23import { applyShellStreamEvent } from "./shellReducer.ts";
23import type { EnvironmentCatalogState } from "./connections.ts";24import type { EnvironmentCatalogState } from "./connections.ts";
24import { followStreamInEnvironment } from "./runtime.ts";25import { followStreamInEnvironment } from "./runtime.ts";
...@@ -48,6 +49,7 @@ const SHELL_SYNCHRONIZATION_ERROR_MESSAGE = "Could not synchronize environment d...@@ -48,6 +49,7 @@ const SHELL_SYNCHRONIZATION_ERROR_MESSAGE = "Could not synchronize environment d
48export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")(function* () {49export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")(function* () {
49 const supervisor = yield* EnvironmentSupervisor;50 const supervisor = yield* EnvironmentSupervisor;
50 const cache = yield* EnvironmentCacheStore;51 const cache = yield* EnvironmentCacheStore;
52 const snapshotLoader = yield* ShellSnapshotLoader;
51 const environmentId = supervisor.target.environmentId;53 const environmentId = supervisor.target.environmentId;
52 const cachedSnapshot = yield* cache.loadShell(environmentId).pipe(54 const cachedSnapshot = yield* cache.loadShell(environmentId).pipe(
53 Effect.catch((error) =>55 Effect.catch((error) =>
...@@ -147,13 +149,45 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")...@@ -147,13 +149,45 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
147 yield* Queue.offer(persistence, nextSnapshot);149 yield* Queue.offer(persistence, nextSnapshot);
148 });150 });
149151
150 yield* subscribe(152 yield* Effect.forkScoped(
151 ORCHESTRATION_WS_METHODS.subscribeShell,153 Effect.gen(function* () {
152 {},154 // Establish the base shell snapshot to resume from, minimizing bytes over
153 {155 // the wire:
154 onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)),156 // - Warm cache: reuse the cached snapshot (zero network) and resume via
155 },157 // `afterSequence` so we only receive shell events since the cached
156 ).pipe(Stream.runForEach(applyItem), Effect.forkScoped);158 // sequence.
159 // - Cold cache: load the full shell snapshot over HTTP (gzip-compressible,
160 // and off the socket), then resume via `afterSequence`.
161 // If no base can be established we fall back to the socket-embedded
162 // snapshot so the shell still synchronizes. Overlapping/replayed events are
163 // deduped by sequence in applyItem.
164 const base = Option.isSome(cachedSnapshot)
165 ? cachedSnapshot
166 : yield* Effect.gen(function* () {
167 const prepared = yield* SubscriptionRef.changes(supervisor.prepared).pipe(
168 Stream.filter(Option.isSome),
169 Stream.map((current) => current.value),
170 Stream.runHead,
171 );
172 return Option.isSome(prepared)
173 ? yield* snapshotLoader.load(prepared.value)
174 : Option.none<OrchestrationShellSnapshot>();
175 });
176
177 if (Option.isSome(base)) {
178 yield* applyItem({ kind: "snapshot", snapshot: base.value });
179 }
180
181 const subscribeInput = Option.match(base, {
182 onNone: () => ({}),
183 onSome: (snapshot) => ({ afterSequence: snapshot.snapshotSequence }),
184 });
185
186 yield* subscribe(ORCHESTRATION_WS_METHODS.subscribeShell, subscribeInput, {
187 onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)),
188 }).pipe(Stream.runForEach(applyItem));
189 }),
190 );
157 yield* SubscriptionRef.changes(supervisor.state).pipe(191 yield* SubscriptionRef.changes(supervisor.state).pipe(
158 Stream.runForEach((connectionState) => {192 Stream.runForEach((connectionState) => {
159 switch (connectionProjectionPhase(connectionState)) {193 switch (connectionProjectionPhase(connectionState)) {
...@@ -293,7 +327,10 @@ export function createEnvironmentServerConfigsAtom(input: {...@@ -293,7 +327,10 @@ export function createEnvironmentServerConfigsAtom(input: {
293}327}
294328
295export function createEnvironmentShellAtoms<R, E>(329export function createEnvironmentShellAtoms<R, E>(
296 runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>,330 runtime: Atom.AtomRuntime<
331 EnvironmentRegistry | EnvironmentCacheStore | ShellSnapshotLoader | R,
332 E
333 >,
297) {334) {
298 const stateAtom = Atom.family((environmentId: EnvironmentId) =>335 const stateAtom = Atom.family((environmentId: EnvironmentId) =>
299 runtime.atom(shellStateChanges(environmentId), {336 runtime.atom(shellStateChanges(environmentId), {
...@@ -316,4 +353,5 @@ export function createEnvironmentShellAtoms<R, E>(...@@ -316,4 +353,5 @@ export function createEnvironmentShellAtoms<R, E>(
316export * from "./models.ts";353export * from "./models.ts";
317export * from "./shellCommands.ts";354export * from "./shellCommands.ts";
318export * from "./shellReducer.ts";355export * from "./shellReducer.ts";
356export * from "./shellSnapshotHttp.ts";
319export * from "./snapshots.ts";357export * from "./snapshots.ts";
packages/client-runtime/src/state/shellSnapshotHttp.ts created+92
...@@ -0,0 +1,92 @@
1import type { OrchestrationShellSnapshot } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import { HttpClient } from "effect/unstable/http";
8
9import type { PreparedConnection } from "../connection/model.ts";
10import { environmentEndpointUrl } from "../environment/endpoint.ts";
11import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
12import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts";
13import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";
14
15// Bounded so a pathologically slow endpoint cannot block the (cheaper) socket
16// fallback for long. The cached shell renders while this runs.
17const DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS = 6_000;
18
19/**
20 * Load the environment shell snapshot (projects + thread shells) over HTTP
21 * instead of as the WebSocket subscription's first frame. The response is
22 * gzip-compressible by the transport and keeps the (potentially large) list off
23 * the socket.
24 */
25export const fetchEnvironmentShellSnapshot = Effect.fn(
26 "clientRuntime.state.fetchEnvironmentShellSnapshot",
27)(function* (input: {
28 readonly prepared: PreparedConnection;
29 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
30 readonly timeoutMs?: number;
31}) {
32 const requestUrl = environmentEndpointUrl(input.prepared.httpBaseUrl, "/api/orchestration/shell");
33 const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl);
34 const headers = yield* buildEnvironmentAuthHeaders(
35 input.prepared.httpAuthorization,
36 "GET",
37 requestUrl,
38 input.signer,
39 );
40 return yield* executeEnvironmentHttpRequest(
41 requestUrl,
42 input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS,
43 withEnvironmentCredentials(
44 input.prepared.httpAuthorization,
45 client.orchestration.shellSnapshot({ headers }),
46 ),
47 );
48});
49
50/**
51 * Loads the environment shell snapshot over HTTP, returning `Option.none()` when
52 * it cannot be loaded (so the caller falls back to the socket-embedded snapshot).
53 * Decouples the shell state machine from the underlying HTTP + DPoP details and
54 * keeps them out of test contexts.
55 */
56export class ShellSnapshotLoader extends Context.Service<
57 ShellSnapshotLoader,
58 {
59 readonly load: (
60 prepared: PreparedConnection,
61 ) => Effect.Effect<Option.Option<OrchestrationShellSnapshot>>;
62 }
63>()("@t3tools/client-runtime/state/shellSnapshotHttp/ShellSnapshotLoader") {}
64
65export const shellSnapshotLoaderLayer: Layer.Layer<
66 ShellSnapshotLoader,
67 never,
68 HttpClient.HttpClient
69> = Layer.effect(
70 ShellSnapshotLoader,
71 Effect.gen(function* () {
72 const httpClient = yield* HttpClient.HttpClient;
73 // Resolve the DPoP signer optionally: it is only needed for relay/DPoP
74 // connections, so the loader must not hard-require it.
75 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
76 return ShellSnapshotLoader.of({
77 load: (prepared: PreparedConnection) =>
78 fetchEnvironmentShellSnapshot({ prepared, signer }).pipe(
79 Effect.map(Option.some<OrchestrationShellSnapshot>),
80 Effect.provideService(HttpClient.HttpClient, httpClient),
81 Effect.catchCause((cause) =>
82 Effect.logWarning(
83 "Could not load the environment shell snapshot over HTTP; using the socket snapshot instead.",
84 ).pipe(
85 Effect.annotateLogs({ cause: Cause.pretty(cause) }),
86 Effect.as(Option.none<OrchestrationShellSnapshot>()),
87 ),
88 ),
89 ),
90 });
91 }),
92);
packages/client-runtime/src/state/threadSnapshotHttp.ts created+120
...@@ -0,0 +1,120 @@
1import type { OrchestrationThreadDetailSnapshot, ThreadId } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import { HttpClient } from "effect/unstable/http";
8
9import type { PreparedConnection } from "../connection/model.ts";
10import { environmentEndpointUrl } from "../environment/endpoint.ts";
11import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts";
12import {
13 executeEnvironmentHttpRequest,
14 makeEnvironmentHttpApiClient,
15 type RemoteEnvironmentRequestError,
16} from "../rpc/http.ts";
17import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts";
18
19// Bounded so a pathologically slow endpoint cannot block the (cheaper) socket
20// fallback for long. The cached thread renders while this runs, so the wait only
21// delays the transition to live data on the first open, not the initial paint.
22const DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS = 6_000;
23
24/**
25 * Load a thread's detail snapshot over HTTP instead of embedding it in the
26 * WebSocket subscription's first frame. The response is gzip-compressible by
27 * the transport and keeps the (potentially multi-KB) snapshot off the socket.
28 */
29export const fetchEnvironmentThreadSnapshot = Effect.fn(
30 "clientRuntime.state.fetchEnvironmentThreadSnapshot",
31)(function* (input: {
32 readonly prepared: PreparedConnection;
33 readonly threadId: ThreadId;
34 readonly signer: Option.Option<ManagedRelayDpopSigner["Service"]>;
35 readonly timeoutMs?: number;
36}) {
37 const requestUrl = environmentEndpointUrl(
38 input.prepared.httpBaseUrl,
39 `/api/orchestration/threads/${input.threadId}`,
40 );
41 const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl);
42 const headers = yield* buildEnvironmentAuthHeaders(
43 input.prepared.httpAuthorization,
44 "GET",
45 requestUrl,
46 input.signer,
47 );
48 return yield* executeEnvironmentHttpRequest(
49 requestUrl,
50 input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS,
51 withEnvironmentCredentials(
52 input.prepared.httpAuthorization,
53 client.orchestration.threadSnapshot({
54 params: { threadId: input.threadId },
55 headers,
56 }),
57 ),
58 );
59});
60
61export type FetchEnvironmentThreadSnapshotError = RemoteEnvironmentRequestError;
62
63/**
64 * Loads a thread's detail snapshot over HTTP, returning `Option.none()` when it
65 * cannot be loaded (so the caller falls back to the socket-embedded snapshot).
66 * Decouples the thread state machine from the underlying HTTP + DPoP details and
67 * keeps them out of test contexts.
68 */
69export class ThreadSnapshotLoader extends Context.Service<
70 ThreadSnapshotLoader,
71 {
72 readonly load: (
73 prepared: PreparedConnection,
74 threadId: ThreadId,
75 ) => Effect.Effect<Option.Option<OrchestrationThreadDetailSnapshot>>;
76 }
77>()("@t3tools/client-runtime/state/threadSnapshotHttp/ThreadSnapshotLoader") {}
78
79export const threadSnapshotLoaderLayer: Layer.Layer<
80 ThreadSnapshotLoader,
81 never,
82 HttpClient.HttpClient
83> = Layer.effect(
84 ThreadSnapshotLoader,
85 Effect.gen(function* () {
86 const httpClient = yield* HttpClient.HttpClient;
87 // Resolve the DPoP signer optionally: it is only needed for relay/DPoP
88 // connections, so the loader must not hard-require it (bearer/primary
89 // connections work without one).
90 const signer = yield* Effect.serviceOption(ManagedRelayDpopSigner);
91 return ThreadSnapshotLoader.of({
92 load: (prepared: PreparedConnection, threadId: ThreadId) =>
93 fetchEnvironmentThreadSnapshot({ prepared, threadId, signer }).pipe(
94 Effect.map(Option.some<OrchestrationThreadDetailSnapshot>),
95 Effect.provideService(HttpClient.HttpClient, httpClient),
96 // A genuinely missing thread (404) is expected — the socket
97 // subscription is the source of truth for thread existence and will
98 // surface the deletion — so don't treat it as an error worth warning
99 // about; just defer to the socket path.
100 Effect.catchTags({
101 EnvironmentResourceNotFoundError: () =>
102 Effect.logDebug(
103 "Thread snapshot not found over HTTP; deferring to the socket subscription.",
104 ).pipe(
105 Effect.annotateLogs({ threadId }),
106 Effect.as(Option.none<OrchestrationThreadDetailSnapshot>()),
107 ),
108 }),
109 Effect.catchCause((cause) =>
110 Effect.logWarning(
111 "Could not load the thread snapshot over HTTP; using the socket snapshot instead.",
112 ).pipe(
113 Effect.annotateLogs({ threadId, cause: Cause.pretty(cause) }),
114 Effect.as(Option.none<OrchestrationThreadDetailSnapshot>()),
115 ),
116 ),
117 ),
118 });
119 }),
120);
packages/client-runtime/src/state/threads-atoms.test.ts+2-2
...@@ -6,12 +6,12 @@ import { Atom } from "effect/unstable/reactivity";...@@ -6,12 +6,12 @@ import { Atom } from "effect/unstable/reactivity";
6import type { EnvironmentRegistry } from "../connection/registry.ts";6import type { EnvironmentRegistry } from "../connection/registry.ts";
7import type { EnvironmentCacheStore } from "../platform/persistence.ts";7import type { EnvironmentCacheStore } from "../platform/persistence.ts";
8import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";8import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
9import { createEnvironmentThreadStateAtoms } from "./threads.ts";9import { createEnvironmentThreadStateAtoms, type ThreadSnapshotLoader } from "./threads.ts";
1010
11describe("createEnvironmentThreadStateAtoms", () => {11describe("createEnvironmentThreadStateAtoms", () => {
12 it("retains thread state across short subscriber gaps", () => {12 it("retains thread state across short subscriber gaps", () => {
13 const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<13 const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<
14 EnvironmentRegistry | EnvironmentCacheStore,14 EnvironmentRegistry | EnvironmentCacheStore | ThreadSnapshotLoader,
15 never15 never
16 >;16 >;
17 const threads = createEnvironmentThreadStateAtoms(runtime);17 const threads = createEnvironmentThreadStateAtoms(runtime);
packages/client-runtime/src/state/threads-sync.test.ts+90-11
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 ProviderInstanceId,6 ProviderInstanceId,
7 ThreadId,7 ThreadId,
8 type OrchestrationThread,8 type OrchestrationThread,
9 type OrchestrationThreadDetailSnapshot,
9 type OrchestrationThreadStreamItem,10 type OrchestrationThreadStreamItem,
10} from "@t3tools/contracts";11} from "@t3tools/contracts";
11import { describe, expect, it } from "@effect/vitest";12import { describe, expect, it } from "@effect/vitest";
...@@ -30,6 +31,7 @@ import * as RpcSession from "../rpc/session.ts";...@@ -30,6 +31,7 @@ import * as RpcSession from "../rpc/session.ts";
30import {31import {
31 EMPTY_ENVIRONMENT_THREAD_STATE,32 EMPTY_ENVIRONMENT_THREAD_STATE,
32 makeEnvironmentThreadState,33 makeEnvironmentThreadState,
34 ThreadSnapshotLoader,
33 type EnvironmentThreadState,35 type EnvironmentThreadState,
34} from "./threads.ts";36} from "./threads.ts";
3537
...@@ -40,6 +42,15 @@ const TARGET = new PrimaryConnectionTarget({...@@ -40,6 +42,15 @@ const TARGET = new PrimaryConnectionTarget({
40 wsBaseUrl: "wss://environment.example.test",42 wsBaseUrl: "wss://environment.example.test",
41});43});
42const THREAD_ID = ThreadId.make("thread-1");44const THREAD_ID = ThreadId.make("thread-1");
45const CACHED_SNAPSHOT_SEQUENCE = 7;
46const PREPARED: PreparedConnection = {
47 environmentId: TARGET.environmentId,
48 label: TARGET.label,
49 httpBaseUrl: TARGET.httpBaseUrl,
50 socketUrl: TARGET.wsBaseUrl,
51 httpAuthorization: null,
52 target: TARGET,
53};
43const BASE_THREAD: OrchestrationThread = {54const BASE_THREAD: OrchestrationThread = {
44 id: THREAD_ID,55 id: THREAD_ID,
45 projectId: ProjectId.make("project-1"),56 projectId: ProjectId.make("project-1"),
...@@ -89,13 +100,16 @@ function awaitThreadState(...@@ -89,13 +100,16 @@ function awaitThreadState(
89100
90const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: {101const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: {
91 readonly cached?: OrchestrationThread;102 readonly cached?: OrchestrationThread;
103 readonly httpSnapshot?: Option.Option<OrchestrationThreadDetailSnapshot>;
92}) {104}) {
93 const inputs = yield* Queue.unbounded<TestThreadInput>();105 const inputs = yield* Queue.unbounded<TestThreadInput>();
94 const observed = yield* Queue.unbounded<EnvironmentThreadState>();106 const observed = yield* Queue.unbounded<EnvironmentThreadState>();
95 const latest = yield* Ref.make<EnvironmentThreadState>(EMPTY_ENVIRONMENT_THREAD_STATE);107 const latest = yield* Ref.make<EnvironmentThreadState>(EMPTY_ENVIRONMENT_THREAD_STATE);
96 const retryCount = yield* Ref.make(0);108 const retryCount = yield* Ref.make(0);
97 const subscriptionCount = yield* Ref.make(0);109 const subscriptionCount = yield* Ref.make(0);
98 const savedThreads = yield* Ref.make<ReadonlyArray<OrchestrationThread>>([]);110 const loaderCalls = yield* Ref.make(0);
111 const lastSubscribeAfterSequence = yield* Ref.make<number | undefined>(undefined);
112 const savedThreads = yield* Ref.make<ReadonlyArray<OrchestrationThreadDetailSnapshot>>([]);
99 const removedThreads = yield* Ref.make<ReadonlyArray<ThreadId>>([]);113 const removedThreads = yield* Ref.make<ReadonlyArray<ThreadId>>([]);
100 const supervisorState = yield* SubscriptionRef.make<SupervisorConnectionState>(114 const supervisorState = yield* SubscriptionRef.make<SupervisorConnectionState>(
101 AVAILABLE_CONNECTION_STATE,115 AVAILABLE_CONNECTION_STATE,
...@@ -107,17 +121,30 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o...@@ -107,17 +121,30 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
107 ),121 ),
108 );122 );
109 const client = {123 const client = {
110 [ORCHESTRATION_WS_METHODS.subscribeThread]: () =>124 [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { readonly afterSequence?: number }) =>
111 Stream.unwrap(125 Stream.unwrap(
112 Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe(126 Ref.updateAndGet(subscriptionCount, (count) => count + 1).pipe(
113 Effect.map(() => streamFrom(inputs)),127 Effect.andThen(Ref.set(lastSubscribeAfterSequence, input.afterSequence)),
128 Effect.as(streamFrom(inputs)),
114 ),129 ),
115 ),130 ),
116 } as unknown as WsRpcProtocolClient;131 } as unknown as WsRpcProtocolClient;
117 const supervisorSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(132 const supervisorSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(
118 Option.some(testSession(client)),133 Option.some(testSession(client)),
119 );134 );
120 const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(Option.none());135 const prepared = yield* SubscriptionRef.make<Option.Option<PreparedConnection>>(
136 Option.some(PREPARED),
137 );
138 const snapshotLoader = ThreadSnapshotLoader.of({
139 load: (_prepared, threadId) =>
140 Ref.update(loaderCalls, (count) => count + 1).pipe(
141 Effect.as(
142 threadId === THREAD_ID
143 ? (options?.httpSnapshot ?? Option.none<OrchestrationThreadDetailSnapshot>())
144 : Option.none<OrchestrationThreadDetailSnapshot>(),
145 ),
146 ),
147 });
121 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({148 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
122 target: TARGET,149 target: TARGET,
123 state: supervisorState,150 state: supervisorState,
...@@ -133,7 +160,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o...@@ -133,7 +160,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
133 loadThread: (_environmentId, threadId) =>160 loadThread: (_environmentId, threadId) =>
134 Effect.succeed(161 Effect.succeed(
135 threadId === THREAD_ID && options?.cached !== undefined162 threadId === THREAD_ID && options?.cached !== undefined
136 ? Option.some(options.cached)163 ? Option.some({
164 snapshotSequence: CACHED_SNAPSHOT_SEQUENCE,
165 thread: options.cached,
166 })
137 : Option.none(),167 : Option.none(),
138 ),168 ),
139 saveThread: (_environmentId, thread) =>169 saveThread: (_environmentId, thread) =>
...@@ -145,6 +175,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o...@@ -145,6 +175,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
145 const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe(175 const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe(
146 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),176 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
147 Effect.provideService(Persistence.EnvironmentCacheStore, cache),177 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
178 Effect.provideService(ThreadSnapshotLoader, snapshotLoader),
148 );179 );
149 yield* SubscriptionRef.changes(threadState).pipe(180 yield* SubscriptionRef.changes(threadState).pipe(
150 Stream.runForEach((state) =>181 Stream.runForEach((state) =>
...@@ -159,6 +190,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o...@@ -159,6 +190,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
159 latest,190 latest,
160 retryCount,191 retryCount,
161 subscriptionCount,192 subscriptionCount,
193 loaderCalls,
194 lastSubscribeAfterSequence,
162 supervisorState,195 supervisorState,
163 supervisorSession,196 supervisorSession,
164 savedThreads,197 savedThreads,
...@@ -217,19 +250,38 @@ const deleted = (): OrchestrationThreadStreamItem => ({...@@ -217,19 +250,38 @@ const deleted = (): OrchestrationThreadStreamItem => ({
217});250});
218251
219describe("EnvironmentThreads", () => {252describe("EnvironmentThreads", () => {
220 it.effect("publishes cached data before a live snapshot arrives", () =>253 it.effect("publishes cached data immediately from a warm cache", () =>
221 Effect.gen(function* () {254 Effect.gen(function* () {
222 const harness = yield* makeHarness({ cached: BASE_THREAD });255 const harness = yield* makeHarness({ cached: BASE_THREAD });
223 const state = yield* awaitThreadState(256 const state = yield* awaitThreadState(harness.observed, (value) => Option.isSome(value.data));
224 harness.observed,
225 (value) => value.status === "cached" && Option.isSome(value.data),
226 );
227257
228 expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD);258 expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD);
229 expect(Option.isNone(state.error)).toBe(true);259 expect(Option.isNone(state.error)).toBe(true);
230 }),260 }),
231 );261 );
232262
263 it.effect("resumes a warm cache via afterSequence without an HTTP fetch", () =>
264 Effect.gen(function* () {
265 const harness = yield* makeHarness({ cached: BASE_THREAD });
266
267 // The warm cache reaches live from the cached data, and a live event
268 // applies on top of it.
269 yield* Queue.offer(harness.inputs, titleUpdated("Live title", CACHED_SNAPSHOT_SEQUENCE + 1));
270 yield* awaitThreadState(
271 harness.observed,
272 (value) =>
273 value.status === "live" &&
274 Option.isSome(value.data) &&
275 value.data.value.title === "Live title",
276 );
277
278 // The subscription resumed from the cached sequence and never fetched the
279 // full snapshot over HTTP.
280 expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(CACHED_SNAPSHOT_SEQUENCE);
281 expect(yield* Ref.get(harness.loaderCalls)).toBe(0);
282 }),
283 );
284
233 it.effect("reduces live events and persists the latest thread", () =>285 it.effect("reduces live events and persists the latest thread", () =>
234 Effect.gen(function* () {286 Effect.gen(function* () {
235 const harness = yield* makeHarness({ cached: BASE_THREAD });287 const harness = yield* makeHarness({ cached: BASE_THREAD });
...@@ -247,7 +299,34 @@ describe("EnvironmentThreads", () => {...@@ -247,7 +299,34 @@ describe("EnvironmentThreads", () => {
247 yield* Effect.yieldNow;299 yield* Effect.yieldNow;
248300
249 expect(Option.getOrThrow(state.data).title).toBe("Live title");301 expect(Option.getOrThrow(state.data).title).toBe("Live title");
250 expect((yield* Ref.get(harness.savedThreads)).at(-1)?.title).toBe("Live title");302 expect((yield* Ref.get(harness.savedThreads)).at(-1)?.thread.title).toBe("Live title");
303 expect((yield* Ref.get(harness.savedThreads)).at(-1)?.snapshotSequence).toBe(2);
304 }),
305 );
306
307 it.effect("seeds the thread from the HTTP snapshot and resumes live events", () =>
308 Effect.gen(function* () {
309 const httpThread: OrchestrationThread = { ...BASE_THREAD, title: "HTTP title" };
310 const harness = yield* makeHarness({
311 httpSnapshot: Option.some({ snapshotSequence: 1, thread: httpThread }),
312 });
313 // No socket snapshot is pushed; only a live event arrives over the socket.
314 // It can only be applied if the HTTP snapshot already seeded the thread.
315 yield* Queue.offer(harness.inputs, titleUpdated("Live title", 2));
316
317 const state = yield* awaitThreadState(
318 harness.observed,
319 (value) =>
320 value.status === "live" &&
321 Option.isSome(value.data) &&
322 value.data.value.title === "Live title",
323 );
324
325 expect(Option.getOrThrow(state.data).title).toBe("Live title");
326 // Cold cache: the full snapshot was loaded over HTTP and the socket
327 // resumed from that snapshot's sequence.
328 expect(yield* Ref.get(harness.loaderCalls)).toBeGreaterThanOrEqual(1);
329 expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBe(1);
251 }),330 }),
252 );331 );
253332
packages/client-runtime/src/state/threads.ts+69-20
...@@ -2,6 +2,7 @@ import {...@@ -2,6 +2,7 @@ import {
2 ORCHESTRATION_WS_METHODS,2 ORCHESTRATION_WS_METHODS,
3 type EnvironmentId as EnvironmentIdType,3 type EnvironmentId as EnvironmentIdType,
4 type OrchestrationThread,4 type OrchestrationThread,
5 type OrchestrationThreadDetailSnapshot,
5 type OrchestrationThreadStreamItem,6 type OrchestrationThreadStreamItem,
6 type ThreadId as ThreadIdType,7 type ThreadId as ThreadIdType,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
...@@ -18,6 +19,7 @@ import { connectionProjectionPhase } from "../connection/model.ts";...@@ -18,6 +19,7 @@ import { connectionProjectionPhase } from "../connection/model.ts";
18import { EnvironmentSupervisor } from "../connection/supervisor.ts";19import { EnvironmentSupervisor } from "../connection/supervisor.ts";
19import { EnvironmentCacheStore } from "../platform/persistence.ts";20import { EnvironmentCacheStore } from "../platform/persistence.ts";
20import { subscribe } from "../rpc/client.ts";21import { subscribe } from "../rpc/client.ts";
22import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts";
21import { parseThreadKey, threadKey } from "./entities.ts";23import { parseThreadKey, threadKey } from "./entities.ts";
22import { applyThreadDetailEvent } from "./threadReducer.ts";24import { applyThreadDetailEvent } from "./threadReducer.ts";
23import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";25import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
...@@ -44,6 +46,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make...@@ -44,6 +46,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
44) {46) {
45 const supervisor = yield* EnvironmentSupervisor;47 const supervisor = yield* EnvironmentSupervisor;
46 const cache = yield* EnvironmentCacheStore;48 const cache = yield* EnvironmentCacheStore;
49 const snapshotLoader = yield* ThreadSnapshotLoader;
47 const environmentId = supervisor.target.environmentId;50 const environmentId = supervisor.target.environmentId;
48 const cached = yield* cache.loadThread(environmentId, threadId).pipe(51 const cached = yield* cache.loadThread(environmentId, threadId).pipe(
49 Effect.catch((error) =>52 Effect.catch((error) =>
...@@ -53,22 +56,27 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make...@@ -53,22 +56,27 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
53 threadId,56 threadId,
54 error: error.message,57 error: error.message,
55 }),58 }),
56 Effect.as(Option.none<OrchestrationThread>()),59 Effect.as(Option.none<OrchestrationThreadDetailSnapshot>()),
57 ),60 ),
58 ),61 ),
59 );62 );
63 const cachedThread = Option.map(cached, (snapshot) => snapshot.thread);
60 const state = yield* SubscriptionRef.make<EnvironmentThreadState>({64 const state = yield* SubscriptionRef.make<EnvironmentThreadState>({
61 data: cached,65 data: cachedThread,
62 status: statusWithoutLiveData(cached),66 status: statusWithoutLiveData(cachedThread),
63 error: Option.none(),67 error: Option.none(),
64 });68 });
65 const lastSequence = yield* SubscriptionRef.make(0);69 // Seed the resume cursor from the cached snapshot so a warm cache can catch up
66 const persistence = yield* Queue.sliding<OrchestrationThread>(1);70 // via `afterSequence` instead of re-downloading the full thread body.
71 const lastSequence = yield* SubscriptionRef.make(
72 Option.match(cached, { onNone: () => 0, onSome: (snapshot) => snapshot.snapshotSequence }),
73 );
74 const persistence = yield* Queue.sliding<OrchestrationThreadDetailSnapshot>(1);
6775
68 const persist = Effect.fn("EnvironmentThreadState.persist")(function* (76 const persist = Effect.fn("EnvironmentThreadState.persist")(function* (
69 thread: OrchestrationThread,77 snapshot: OrchestrationThreadDetailSnapshot,
70 ) {78 ) {
71 yield* cache.saveThread(environmentId, thread).pipe(79 yield* cache.saveThread(environmentId, snapshot).pipe(
72 Effect.catch((error) =>80 Effect.catch((error) =>
73 Effect.logWarning("Could not persist the thread cache.").pipe(81 Effect.logWarning("Could not persist the thread cache.").pipe(
74 Effect.annotateLogs({82 Effect.annotateLogs({
...@@ -120,7 +128,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make...@@ -120,7 +128,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
120 status: "live",128 status: "live",
121 error: Option.none(),129 error: Option.none(),
122 });130 });
123 yield* Queue.offer(persistence, thread);131 // Persist the thread together with the sequence it reflects so the next warm
132 // cache can resume from exactly here.
133 const snapshotSequence = yield* SubscriptionRef.get(lastSequence);
134 yield* Queue.offer(persistence, { snapshotSequence, thread });
124 });135 });
125136
126 const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () {137 const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () {
...@@ -187,21 +198,55 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make...@@ -187,21 +198,55 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
187 );198 );
188199
189 yield* setSynchronizing;200 yield* setSynchronizing;
190 yield* subscribe(201 yield* Effect.forkScoped(
191 ORCHESTRATION_WS_METHODS.subscribeThread,202 Effect.gen(function* () {
192 { threadId },203 // Establish the base snapshot to resume from, minimizing bytes over the
193 {204 // wire:
194 onExpectedFailure: setStreamError,205 // - Warm cache: reuse the cached snapshot (zero network) and resume via
195 retryExpectedFailureAfter: "250 millis",206 // `afterSequence` so we only receive events since the cached sequence.
196 },207 // - Cold cache: load the full snapshot over HTTP (gzip-compressible, and
197 ).pipe(Stream.runForEach(applyItem), Effect.forkScoped);208 // off the socket), then resume via `afterSequence`.
209 // If no base can be established we fall back to the socket-embedded
210 // snapshot so the thread still synchronizes. Overlapping/replayed events
211 // are deduped by sequence in applyItem.
212 const base = Option.isSome(cached)
213 ? cached
214 : yield* Effect.gen(function* () {
215 // Cold cache only: wait for a prepared connection so we can
216 // authenticate the HTTP request; this mirrors the socket path, which
217 // likewise waits for a live session.
218 const prepared = yield* SubscriptionRef.changes(supervisor.prepared).pipe(
219 Stream.filter(Option.isSome),
220 Stream.map((current) => current.value),
221 Stream.runHead,
222 );
223 return Option.isSome(prepared)
224 ? yield* snapshotLoader.load(prepared.value, threadId)
225 : Option.none<OrchestrationThreadDetailSnapshot>();
226 });
227
228 if (Option.isSome(base)) {
229 yield* applyItem({ kind: "snapshot", snapshot: base.value });
230 }
231
232 const subscribeInput = Option.match(base, {
233 onNone: () => ({ threadId }),
234 onSome: (snapshot) => ({ threadId, afterSequence: snapshot.snapshotSequence }),
235 });
236
237 yield* subscribe(ORCHESTRATION_WS_METHODS.subscribeThread, subscribeInput, {
238 onExpectedFailure: setStreamError,
239 retryExpectedFailureAfter: "250 millis",
240 }).pipe(Stream.runForEach(applyItem));
241 }),
242 );
198243
199 yield* Effect.addFinalizer(() =>244 yield* Effect.addFinalizer(() =>
200 SubscriptionRef.get(state).pipe(245 Effect.all([SubscriptionRef.get(state), SubscriptionRef.get(lastSequence)]).pipe(
201 Effect.flatMap((current) =>246 Effect.flatMap(([current, snapshotSequence]) =>
202 Option.match(current.data, {247 Option.match(current.data, {
203 onNone: () => Effect.void,248 onNone: () => Effect.void,
204 onSome: persist,249 onSome: (thread) => persist({ snapshotSequence, thread }),
205 }),250 }),
206 ),251 ),
207 ),252 ),
...@@ -218,7 +263,10 @@ export function threadStateChanges(environmentId: EnvironmentIdType, threadId: T...@@ -218,7 +263,10 @@ export function threadStateChanges(environmentId: EnvironmentIdType, threadId: T
218}263}
219264
220export function createEnvironmentThreadStateAtoms<R, E>(265export function createEnvironmentThreadStateAtoms<R, E>(
221 runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>,266 runtime: Atom.AtomRuntime<
267 EnvironmentRegistry | EnvironmentCacheStore | ThreadSnapshotLoader | R,
268 E
269 >,
222) {270) {
223 const family = Atom.family((key: string) => {271 const family = Atom.family((key: string) => {
224 const { environmentId, threadId } = parseThreadKey(key);272 const { environmentId, threadId } = parseThreadKey(key);
...@@ -240,6 +288,7 @@ export function createEnvironmentThreadStateAtoms<R, E>(...@@ -240,6 +288,7 @@ export function createEnvironmentThreadStateAtoms<R, E>(
240288
241export * from "./archivedThreads.ts";289export * from "./archivedThreads.ts";
242export * from "./checkpointDiff.ts";290export * from "./checkpointDiff.ts";
291export * from "./threadSnapshotHttp.ts";
243export * from "./composerPathSearch.ts";292export * from "./composerPathSearch.ts";
244export * from "./threadCommands.ts";293export * from "./threadCommands.ts";
245export * from "./threadDetail.ts";294export * from "./threadDetail.ts";
packages/contracts/src/environmentHttp.ts+46-1
...@@ -24,12 +24,14 @@ import {...@@ -24,12 +24,14 @@ import {
24 AuthWebSocketTicketResult,24 AuthWebSocketTicketResult,
25 ServerAuthSessionMethod,25 ServerAuthSessionMethod,
26} from "./auth.ts";26} from "./auth.ts";
27import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts";27import { AuthSessionId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
28import { ExecutionEnvironmentDescriptor } from "./environment.ts";28import { ExecutionEnvironmentDescriptor } from "./environment.ts";
29import {29import {
30 ClientOrchestrationCommand,30 ClientOrchestrationCommand,
31 DispatchResult,31 DispatchResult,
32 OrchestrationReadModel,32 OrchestrationReadModel,
33 OrchestrationShellSnapshot,
34 OrchestrationThreadDetailSnapshot,
33} from "./orchestration.ts";35} from "./orchestration.ts";
34import {36import {
35 RelayCloudEnvironmentHealthRequest,37 RelayCloudEnvironmentHealthRequest,
...@@ -80,6 +82,7 @@ export const EnvironmentInternalErrorReason = Schema.Literals([...@@ -80,6 +82,7 @@ export const EnvironmentInternalErrorReason = Schema.Literals([
80 "client_sessions_load_failed",82 "client_sessions_load_failed",
81 "client_session_revoke_failed",83 "client_session_revoke_failed",
82 "orchestration_snapshot_failed",84 "orchestration_snapshot_failed",
85 "orchestration_thread_snapshot_failed",
83 "orchestration_dispatch_failed",86 "orchestration_dispatch_failed",
84 "internal_error",87 "internal_error",
85]);88]);
...@@ -155,11 +158,29 @@ export class EnvironmentInternalError extends Schema.TaggedErrorClass<Environmen...@@ -155,11 +158,29 @@ export class EnvironmentInternalError extends Schema.TaggedErrorClass<Environmen
155 }158 }
156}159}
157160
161export const EnvironmentResourceNotFoundReason = Schema.Literals(["thread_not_found"]);
162export type EnvironmentResourceNotFoundReason = typeof EnvironmentResourceNotFoundReason.Type;
163
164export class EnvironmentResourceNotFoundError extends Schema.TaggedErrorClass<EnvironmentResourceNotFoundError>()(
165 "EnvironmentResourceNotFoundError",
166 {
167 code: Schema.Literal("not_found"),
168 reason: EnvironmentResourceNotFoundReason,
169 traceId: TrimmedNonEmptyString,
170 },
171 { httpApiStatus: 404 },
172) {
173 [HttpServerRespondable.symbol]() {
174 return HttpServerResponse.schemaJson(EnvironmentResourceNotFoundError)(this, { status: 404 });
175 }
176}
177
158export const EnvironmentHttpCommonError = Schema.Union([178export const EnvironmentHttpCommonError = Schema.Union([
159 EnvironmentRequestInvalidError,179 EnvironmentRequestInvalidError,
160 EnvironmentAuthInvalidError,180 EnvironmentAuthInvalidError,
161 EnvironmentScopeRequiredError,181 EnvironmentScopeRequiredError,
162 EnvironmentOperationForbiddenError,182 EnvironmentOperationForbiddenError,
183 EnvironmentResourceNotFoundError,
163 EnvironmentInternalError,184 EnvironmentInternalError,
164]);185]);
165export type EnvironmentHttpCommonError = typeof EnvironmentHttpCommonError.Type;186export type EnvironmentHttpCommonError = typeof EnvironmentHttpCommonError.Type;
...@@ -269,6 +290,11 @@ const EnvironmentOrchestrationSnapshotErrors = [...@@ -269,6 +290,11 @@ const EnvironmentOrchestrationSnapshotErrors = [
269 EnvironmentScopeRequiredError,290 EnvironmentScopeRequiredError,
270 EnvironmentInternalError,291 EnvironmentInternalError,
271] as const;292] as const;
293const EnvironmentOrchestrationThreadSnapshotErrors = [
294 EnvironmentScopeRequiredError,
295 EnvironmentResourceNotFoundError,
296 EnvironmentInternalError,
297] as const;
272const EnvironmentOrchestrationDispatchErrors = [298const EnvironmentOrchestrationDispatchErrors = [
273 EnvironmentRequestInvalidError,299 EnvironmentRequestInvalidError,
274 EnvironmentScopeRequiredError,300 EnvironmentScopeRequiredError,
...@@ -422,6 +448,10 @@ export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth")...@@ -422,6 +448,10 @@ export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth")
422 }).middleware(EnvironmentAuthenticatedAuth),448 }).middleware(EnvironmentAuthenticatedAuth),
423 ) {}449 ) {}
424450
451const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({
452 threadId: ThreadId,
453});
454
425export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration")455export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration")
426 .add(456 .add(
427 HttpApiEndpoint.get("snapshot", "/api/orchestration/snapshot", {457 HttpApiEndpoint.get("snapshot", "/api/orchestration/snapshot", {
...@@ -430,6 +460,21 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr...@@ -430,6 +460,21 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr
430 error: EnvironmentOrchestrationSnapshotErrors,460 error: EnvironmentOrchestrationSnapshotErrors,
431 }).middleware(EnvironmentAuthenticatedAuth),461 }).middleware(EnvironmentAuthenticatedAuth),
432 )462 )
463 .add(
464 HttpApiEndpoint.get("shellSnapshot", "/api/orchestration/shell", {
465 headers: OptionalBearerHeaders,
466 success: OrchestrationShellSnapshot,
467 error: EnvironmentOrchestrationSnapshotErrors,
468 }).middleware(EnvironmentAuthenticatedAuth),
469 )
470 .add(
471 HttpApiEndpoint.get("threadSnapshot", "/api/orchestration/threads/:threadId", {
472 headers: OptionalBearerHeaders,
473 params: EnvironmentOrchestrationThreadSnapshotParams,
474 success: OrchestrationThreadDetailSnapshot,
475 error: EnvironmentOrchestrationThreadSnapshotErrors,
476 }).middleware(EnvironmentAuthenticatedAuth),
477 )
433 .add(478 .add(
434 HttpApiEndpoint.post("dispatch", "/api/orchestration/dispatch", {479 HttpApiEndpoint.post("dispatch", "/api/orchestration/dispatch", {
435 headers: OptionalBearerHeaders,480 headers: OptionalBearerHeaders,
packages/contracts/src/orchestration.ts+22-1
...@@ -451,8 +451,29 @@ export const OrchestrationShellStreamItem = Schema.Union([...@@ -451,8 +451,29 @@ export const OrchestrationShellStreamItem = Schema.Union([
451]);451]);
452export type OrchestrationShellStreamItem = typeof OrchestrationShellStreamItem.Type;452export type OrchestrationShellStreamItem = typeof OrchestrationShellStreamItem.Type;
453453
454export const OrchestrationSubscribeShellInput = Schema.Struct({
455 /**
456 * When provided, the server skips the initial full shell snapshot and instead
457 * replays shell events after this sequence before streaming live events.
458 * Clients that already hold a cached (or HTTP-loaded) shell snapshot pass its
459 * sequence here so the subscription resumes without re-sending the entire
460 * projects/threads list (overlapping events are deduped by sequence on the
461 * client).
462 */
463 afterSequence: Schema.optionalKey(NonNegativeInt),
464});
465export type OrchestrationSubscribeShellInput = typeof OrchestrationSubscribeShellInput.Type;
466
454export const OrchestrationSubscribeThreadInput = Schema.Struct({467export const OrchestrationSubscribeThreadInput = Schema.Struct({
455 threadId: ThreadId,468 threadId: ThreadId,
469 /**
470 * When provided, the server skips the initial snapshot frame and instead
471 * replays events after this sequence before streaming live events. Clients
472 * that load the snapshot over HTTP pass the snapshot's sequence here so the
473 * live subscription resumes without a gap (overlapping events are deduped by
474 * sequence on the client).
475 */
476 afterSequence: Schema.optionalKey(NonNegativeInt),
456});477});
457export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type;478export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type;
458479
...@@ -1244,7 +1265,7 @@ export const OrchestrationRpcSchemas = {...@@ -1244,7 +1265,7 @@ export const OrchestrationRpcSchemas = {
1244 output: OrchestrationThreadStreamItem,1265 output: OrchestrationThreadStreamItem,
1245 },1266 },
1246 subscribeShell: {1267 subscribeShell: {
1247 input: Schema.Struct({}),1268 input: OrchestrationSubscribeShellInput,
1248 output: OrchestrationShellStreamItem,1269 output: OrchestrationShellStreamItem,
1249 },1270 },
1250} as const;1271} as const;