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 @@
11import { Connection } from "@t3tools/client-runtime/connection";
2import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";
3import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads";
24import * as Layer from "effect/Layer";
35import { Atom } from "effect/unstable/reactivity";
46
......@@ -9,12 +11,15 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
911 Layer.provide(runtimeContextLayer),
1012);
1113
14const snapshotLoaderLayer = Layer.merge(threadSnapshotLoaderLayer, shellSnapshotLoaderLayer);
15
1216type ConnectionLayerSource =
1317 | typeof Connection.layer
18 | typeof snapshotLoaderLayer
1419 | typeof runtimeContextLayer
1520 | typeof connectionPlatformLayer;
1621
17const connectionLayer = Connection.layer.pipe(
22const connectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe(
1823 Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)),
1924);
2025
apps/mobile/src/connection/storage.ts+11-8
......@@ -16,8 +16,8 @@ import {
1616} from "@t3tools/client-runtime/connection";
1717import {
1818 EnvironmentId,
19 OrchestrationThread,
2019 OrchestrationShellSnapshot,
20 OrchestrationThreadDetailSnapshot,
2121 ThreadId,
2222} from "@t3tools/contracts";
2323import * as Context from "effect/Context";
......@@ -32,7 +32,10 @@ import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store";
3232const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
3333const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots";
3434const 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;
3639const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots";
3740
3841const StoredShellSnapshot = Schema.Struct({
......@@ -45,7 +48,7 @@ const StoredThreadSnapshot = Schema.Struct({
4548 schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION),
4649 environmentId: EnvironmentId,
4750 threadId: ThreadId,
48 thread: OrchestrationThread,
51 snapshot: OrchestrationThreadDetailSnapshot,
4952});
5053
5154const LegacyStoredShellSnapshot = Schema.Struct({
......@@ -355,18 +358,18 @@ export const connectionStorageLayer = Layer.effectContext(
355358 Schema.decodeUnknownResult(StoredThreadSnapshot)(parsed),
356359 ).pipe(Effect.mapError((cause) => shellPersistenceError("load-thread", cause)));
357360 return stored.environmentId === environmentId && stored.threadId === threadId
358 ? Option.some(stored.thread)
361 ? Option.some(stored.snapshot)
359362 : Option.none();
360363 }),
361 saveThread: (environmentId, thread) =>
364 saveThread: (environmentId, snapshot) =>
362365 Effect.gen(function* () {
363 const file = yield* threadSnapshotFile(environmentId, thread.id, "save-thread");
366 const file = yield* threadSnapshotFile(environmentId, snapshot.thread.id, "save-thread");
364367 const encoded = yield* Effect.fromResult(
365368 Schema.encodeUnknownResult(StoredThreadSnapshot)({
366369 schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION,
367370 environmentId,
368 threadId: thread.id,
369 thread,
371 threadId: snapshot.thread.id,
372 snapshot,
370373 }),
371374 ).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause)));
372375 yield* Effect.try({
apps/server/src/auth/http.ts+10
......@@ -16,6 +16,8 @@ import {
1616 EnvironmentOperationForbiddenError,
1717 EnvironmentRequestInvalidError,
1818 type EnvironmentRequestInvalidReason,
19 EnvironmentResourceNotFoundError,
20 type EnvironmentResourceNotFoundReason,
1921 EnvironmentScopeRequiredError,
2022 EnvironmentAuthenticatedAuth,
2123 EnvironmentAuthenticatedPrincipal,
......@@ -137,6 +139,14 @@ function failEnvironmentOperationForbidden(reason: "current_session_revoke_not_a
137139 );
138140}
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
140150export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, error?: unknown) {
141151 return Effect.gen(function* () {
142152 const traceId = yield* currentEnvironmentTraceId;
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+5
......@@ -107,6 +107,7 @@ describe("CheckpointDiffQuery.layer", () => {
107107 }),
108108 getThreadShellById: () => Effect.succeed(Option.none()),
109109 getThreadDetailById: () => Effect.succeed(Option.none()),
110 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
110111 }),
111112 ),
112113 );
......@@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => {
199200 getFullThreadDiffContext: () => Effect.die("unused"),
200201 getThreadShellById: () => Effect.succeed(Option.none()),
201202 getThreadDetailById: () => Effect.succeed(Option.none()),
203 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
202204 }),
203205 ),
204206 );
......@@ -281,6 +283,7 @@ describe("CheckpointDiffQuery.layer", () => {
281283 getFullThreadDiffContext: () => Effect.die("unused"),
282284 getThreadShellById: () => Effect.succeed(Option.none()),
283285 getThreadDetailById: () => Effect.succeed(Option.none()),
286 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
284287 }),
285288 ),
286289 );
......@@ -348,6 +351,7 @@ describe("CheckpointDiffQuery.layer", () => {
348351 getFullThreadDiffContext: () => Effect.die("unused"),
349352 getThreadShellById: () => Effect.succeed(Option.none()),
350353 getThreadDetailById: () => Effect.succeed(Option.none()),
354 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
351355 }),
352356 ),
353357 );
......@@ -400,6 +404,7 @@ describe("CheckpointDiffQuery.layer", () => {
400404 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
401405 getThreadShellById: () => Effect.succeed(Option.none()),
402406 getThreadDetailById: () => Effect.succeed(Option.none()),
407 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
403408 }),
404409 ),
405410 );
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+1
......@@ -200,6 +200,7 @@ describe("OrchestrationEngine", () => {
200200 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
201201 getThreadShellById: () => Effect.succeed(Option.none()),
202202 getThreadDetailById: () => Effect.succeed(Option.none()),
203 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
203204 }),
204205 ),
205206 Layer.provide(
apps/server/src/orchestration/Layers/OrchestrationEngine.ts+2-2
......@@ -306,8 +306,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {
306306 Effect.annotateLogs({ sequence: commandReadModel.snapshotSequence }),
307307 );
308308
309 const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive) =>
310 eventStore.readFromSequence(fromSequenceExclusive);
309 const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive, limit) =>
310 eventStore.readFromSequence(fromSequenceExclusive, limit);
311311
312312 const dispatch: OrchestrationEngineShape["dispatch"] = (command) =>
313313 Effect.gen(function* () {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+31
......@@ -9,6 +9,7 @@ import {
99 OrchestrationReadModel,
1010 OrchestrationShellSnapshot,
1111 OrchestrationThread,
12 OrchestrationThreadDetailSnapshot,
1213 ProjectScript,
1314 TurnId,
1415 type OrchestrationCheckpointSummary,
......@@ -2033,6 +2034,35 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20332034 );
20342035 });
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
20362066 return {
20372067 getCommandReadModel,
20382068 getSnapshot,
......@@ -2047,6 +2077,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
20472077 getFullThreadDiffContext,
20482078 getThreadShellById,
20492079 getThreadDetailById,
2080 getThreadDetailSnapshot,
20502081 } satisfies ProjectionSnapshotQueryShape;
20512082});
20522083
apps/server/src/orchestration/Services/OrchestrationEngine.ts+5
......@@ -26,10 +26,15 @@ export interface OrchestrationEngineShape {
2626 * Replay persisted orchestration events from an exclusive sequence cursor.
2727 *
2828 * @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).
2933 * @returns Stream containing ordered events.
3034 */
3135 readonly readEvents: (
3236 fromSequenceExclusive: number,
37 limit?: number,
3338 ) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError, never>;
3439
3540 /**
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+11
......@@ -14,6 +14,7 @@ import type {
1414 OrchestrationReadModel,
1515 OrchestrationShellSnapshot,
1616 OrchestrationThread,
17 OrchestrationThreadDetailSnapshot,
1718 OrchestrationThreadShell,
1819 ProjectId,
1920 ThreadId,
......@@ -157,6 +158,16 @@ export interface ProjectionSnapshotQueryShape {
157158 readonly getThreadDetailById: (
158159 threadId: ThreadId,
159160 ) => 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>;
160171}
161172
162173/**
apps/server/src/orchestration/http.ts+34
......@@ -4,6 +4,7 @@ import {
44 EnvironmentHttpApi,
55} from "@t3tools/contracts";
66import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";
78import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
89
910import { normalizeDispatchCommand } from "./Normalizer.ts";
......@@ -11,6 +12,7 @@ import {
1112 annotateEnvironmentRequest,
1213 failEnvironmentInternal,
1314 failEnvironmentInvalidRequest,
15 failEnvironmentNotFound,
1416 requireEnvironmentScope,
1517} from "../auth/http.ts";
1618import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
......@@ -38,6 +40,38 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
3840 );
3941 }),
4042 )
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 )
4175 .handle(
4276 "dispatch",
4377 Effect.fn("environment.orchestration.dispatch")(function* (args) {
apps/server/src/project/ProjectSetupScriptRunner.test.ts+1
......@@ -43,6 +43,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
4343 getFullThreadDiffContext: () => Effect.die("unused"),
4444 getThreadShellById: () => Effect.die("unused"),
4545 getThreadDetailById: () => Effect.die("unused"),
46 getThreadDetailSnapshot: () => Effect.die("unused"),
4647 });
4748
4849const makeTerminalManagerLayer = (
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
......@@ -209,6 +209,7 @@ describe("ProviderSessionReaper", () => {
209209 : Option.none(),
210210 ),
211211 getThreadDetailById: () => Effect.die("unused"),
212 getThreadDetailSnapshot: () => Effect.die("unused"),
212213 }),
213214 ),
214215 Layer.provideMerge(NodeServices.layer),
apps/server/src/server.test.ts+1
......@@ -702,6 +702,7 @@ const buildAppUnderTest = (options?: {
702702 getProjectShellById: () => Effect.succeed(Option.none()),
703703 getThreadShellById: () => Effect.succeed(Option.none()),
704704 getThreadDetailById: () => Effect.succeed(Option.none()),
705 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
705706 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
706707 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
707708 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
9696 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
9797 getThreadShellById: () => Effect.succeed(Option.none()),
9898 getThreadDetailById: () => Effect.succeed(Option.none()),
99 getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
99100 }),
100101 Effect.provideService(AnalyticsService.AnalyticsService, {
101102 record: () => Effect.void,
......@@ -158,6 +159,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
158159 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
159160 getThreadShellById: () => Effect.die("unused"),
160161 getThreadDetailById: () => Effect.die("unused"),
162 getThreadDetailSnapshot: () => Effect.die("unused"),
161163 }),
162164 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
163165 readEvents: () => Stream.empty,
......@@ -200,6 +202,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
200202 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
201203 getThreadShellById: () => Effect.die("unused"),
202204 getThreadDetailById: () => Effect.die("unused"),
205 getThreadDetailSnapshot: () => Effect.die("unused"),
203206 }),
204207 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
205208 readEvents: () => Stream.empty,
......@@ -248,6 +251,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
248251 getFullThreadDiffContext: () => Effect.succeed(Option.none()),
249252 getThreadShellById: () => Effect.die("unused"),
250253 getThreadDetailById: () => Effect.die("unused"),
254 getThreadDetailSnapshot: () => Effect.die("unused"),
251255 }),
252256 Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
253257 readEvents: () => Stream.empty,
apps/server/src/ws.ts+109-40
......@@ -30,6 +30,8 @@ import {
3030 OrchestrationDispatchCommandError,
3131 type OrchestrationEvent,
3232 type OrchestrationShellStreamEvent,
33 type OrchestrationShellStreamItem,
34 type OrchestrationThreadStreamItem,
3335 OrchestrationGetFullThreadDiffError,
3436 OrchestrationGetSnapshotError,
3537 OrchestrationGetTurnDiffError,
......@@ -1059,10 +1061,54 @@ const makeWsRpcLayer = (
10591061 ),
10601062 { "rpc.aggregate": "orchestration" },
10611063 ),
1062 [ORCHESTRATION_WS_METHODS.subscribeShell]: (_input) =>
1064 [ORCHESTRATION_WS_METHODS.subscribeShell]: (input) =>
10631065 observeRpcStreamEffect(
10641066 ORCHESTRATION_WS_METHODS.subscribeShell,
10651067 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
10661112 const snapshot = yield* projectionSnapshotQuery.getShellSnapshot().pipe(
10671113 Effect.tapError((cause) =>
10681114 Effect.logError("orchestration shell snapshot load failed", { cause }),
......@@ -1076,13 +1122,6 @@ const makeWsRpcLayer = (
10761122 ),
10771123 );
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
10861125 return Stream.concat(
10871126 Stream.make({
10881127 kind: "snapshot" as const,
......@@ -1114,8 +1153,65 @@ const makeWsRpcLayer = (
11141153 observeRpcStreamEffect(
11151154 ORCHESTRATION_WS_METHODS.subscribeThread,
11161155 Effect.gen(function* () {
1117 const [threadDetail, snapshotSequence] = yield* Effect.all([
1118 projectionSnapshotQuery.getThreadDetailById(input.threadId).pipe(
1156 const isThisThreadDetailEvent = (event: OrchestrationEvent) =>
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(
11191215 Effect.mapError(
11201216 (cause) =>
11211217 new OrchestrationGetSnapshotError({
......@@ -1123,46 +1219,19 @@ const makeWsRpcLayer = (
11231219 cause,
11241220 }),
11251221 ),
1126 ),
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 ]);
1222 );
11381223
1139 if (Option.isNone(threadDetail)) {
1224 if (Option.isNone(snapshot)) {
11401225 return yield* new OrchestrationGetSnapshotError({
11411226 message: `Thread ${input.threadId} was not found`,
11421227 cause: input.threadId,
11431228 });
11441229 }
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
11591231 return Stream.concat(
11601232 Stream.make({
11611233 kind: "snapshot" as const,
1162 snapshot: {
1163 snapshotSequence,
1164 thread: threadDetail.value,
1165 },
1234 snapshot: snapshot.value,
11661235 }),
11671236 liveStream,
11681237 );
apps/web/src/connection/runtime.ts+6-1
......@@ -1,4 +1,6 @@
11import { Connection } from "@t3tools/client-runtime/connection";
2import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";
3import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads";
24import * as Layer from "effect/Layer";
35import { Atom } from "effect/unstable/reactivity";
46
......@@ -9,12 +11,15 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
911 Layer.provide(runtimeContextLayer),
1012);
1113
14const snapshotLoaderLayer = Layer.merge(threadSnapshotLoaderLayer, shellSnapshotLoaderLayer);
15
1216type ConnectionLayerSource =
1317 | typeof Connection.layer
18 | typeof snapshotLoaderLayer
1419 | typeof runtimeContextLayer
1520 | typeof connectionPlatformLayer;
1621
17const connectionLayer = Connection.layer.pipe(
22const connectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe(
1823 Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)),
1924);
2025
apps/web/src/connection/storage.ts+12-9
......@@ -20,7 +20,7 @@ import {
2020import {
2121 EnvironmentId,
2222 OrchestrationShellSnapshot,
23 OrchestrationThread,
23 OrchestrationThreadDetailSnapshot,
2424 ThreadId,
2525} from "@t3tools/contracts";
2626import * as Context from "effect/Context";
......@@ -45,11 +45,14 @@ const StoredShellSnapshot = Schema.Struct({
4545 snapshot: OrchestrationShellSnapshot,
4646});
4747const 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.
4851const StoredThreadSnapshot = Schema.Struct({
49 schemaVersion: Schema.Literal(1),
52 schemaVersion: Schema.Literal(2),
5053 environmentId: EnvironmentId,
5154 threadId: ThreadId,
52 thread: OrchestrationThread,
55 snapshot: OrchestrationThreadDetailSnapshot,
5356});
5457const StoredThreadSnapshotJson = Schema.fromJsonString(StoredThreadSnapshot);
5558const ConnectionCatalogDocumentJson = Schema.fromJsonString(ConnectionCatalogDocument);
......@@ -473,7 +476,7 @@ export const connectionStorageLayer = Layer.effectContext(
473476 Effect.mapError((cause) => persistenceError("load-thread", cause)),
474477 Effect.map((stored) =>
475478 stored.environmentId === environmentId && stored.threadId === threadId
476 ? Option.some(stored.thread)
479 ? Option.some(stored.snapshot)
477480 : Option.none(),
478481 ),
479482 );
......@@ -484,18 +487,18 @@ export const connectionStorageLayer = Layer.effectContext(
484487 : persistenceError("load-thread", cause),
485488 ),
486489 ),
487 saveThread: (environmentId, thread) =>
490 saveThread: (environmentId, snapshot) =>
488491 Effect.gen(function* () {
489492 const encoded = yield* encodeStoredThreadSnapshot({
490 schemaVersion: 1,
493 schemaVersion: 2,
491494 environmentId,
492 threadId: thread.id,
493 thread,
495 threadId: snapshot.thread.id,
496 snapshot,
494497 }).pipe(Effect.mapError((cause) => persistenceError("save-thread", cause)));
495498 yield* writeDatabaseValue(
496499 database,
497500 THREAD_STORE_NAME,
498 threadCacheKey(environmentId, thread.id),
501 threadCacheKey(environmentId, snapshot.thread.id),
499502 encoded,
500503 );
501504 }).pipe(
apps/web/src/environments/primary/auth.ts+2
......@@ -221,6 +221,8 @@ function readEnvironmentHttpErrorStatus(error: EnvironmentHttpCommonErrorType):
221221 case "EnvironmentScopeRequiredError":
222222 case "EnvironmentOperationForbiddenError":
223223 return 403;
224 case "EnvironmentResourceNotFoundError":
225 return 404;
224226 case "EnvironmentInternalError":
225227 return 500;
226228 }
packages/client-runtime/src/connection/errors.ts+9
......@@ -132,6 +132,15 @@ export function mapRemoteEnvironmentError(
132132 detail: "The environment rejected the authentication request.",
133133 traceId: error.traceId,
134134 });
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 });
135144 case "RemoteEnvironmentAuthTimeoutError":
136145 return new ConnectionTransientError({
137146 reason: "timeout",
packages/client-runtime/src/platform/persistence.ts+6-3
......@@ -1,7 +1,7 @@
11import {
22 type EnvironmentId,
3 type OrchestrationThread,
43 type OrchestrationShellSnapshot,
4 type OrchestrationThreadDetailSnapshot,
55 type ThreadId,
66} from "@t3tools/contracts";
77import * as Context from "effect/Context";
......@@ -60,10 +60,13 @@ export class EnvironmentCacheStore extends Context.Service<
6060 readonly loadThread: (
6161 environmentId: EnvironmentId,
6262 threadId: ThreadId,
63 ) => Effect.Effect<Option.Option<OrchestrationThread>, ConnectionPersistenceError>;
63 ) => Effect.Effect<
64 Option.Option<OrchestrationThreadDetailSnapshot>,
65 ConnectionPersistenceError
66 >;
6467 readonly saveThread: (
6568 environmentId: EnvironmentId,
66 thread: OrchestrationThread,
69 snapshot: OrchestrationThreadDetailSnapshot,
6770 ) => Effect.Effect<void, ConnectionPersistenceError>;
6871 readonly removeThread: (
6972 environmentId: EnvironmentId,
packages/client-runtime/src/rpc/http.ts+2
......@@ -5,6 +5,7 @@ import {
55 type EnvironmentInternalError,
66 type EnvironmentOperationForbiddenError,
77 type EnvironmentRequestInvalidError,
8 type EnvironmentResourceNotFoundError,
89 type EnvironmentScopeRequiredError,
910} from "@t3tools/contracts";
1011import { httpHeaderRedactionLayer } from "@t3tools/shared/httpObservability";
......@@ -70,6 +71,7 @@ export type RemoteEnvironmentRequestError =
7071 | EnvironmentAuthInvalidError
7172 | EnvironmentScopeRequiredError
7273 | EnvironmentOperationForbiddenError
74 | EnvironmentResourceNotFoundError
7375 | EnvironmentInternalError
7476 | RemoteEnvironmentAuthFetchError
7577 | 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";
2020import * as Persistence from "../platform/persistence.ts";
2121import * as RpcSession from "../rpc/session.ts";
2222import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
23import { makeEnvironmentShellState } from "./shell.ts";
23import { makeEnvironmentShellState, ShellSnapshotLoader } from "./shell.ts";
2424
2525const TARGET = new PrimaryConnectionTarget({
2626 environmentId: EnvironmentId.make("environment-1"),
......@@ -29,6 +29,15 @@ const TARGET = new PrimaryConnectionTarget({
2929 wsBaseUrl: "wss://environment.example.test",
3030});
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
3241const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = {
3342 snapshotSequence: 1,
3443 projects: [],
......@@ -61,7 +70,7 @@ describe("environment shell synchronization", () => {
6170 target: TARGET,
6271 state: supervisorState,
6372 session: activeSession,
64 prepared: yield* SubscriptionRef.make(Option.none<PreparedConnection>()),
73 prepared: yield* SubscriptionRef.make(Option.some(PREPARED)),
6574 connect: Effect.void,
6675 disconnect: Effect.void,
6776 retryNow: Effect.void,
......@@ -74,9 +83,15 @@ describe("environment shell synchronization", () => {
7483 removeThread: () => Effect.void,
7584 clear: () => Effect.void,
7685 });
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 });
7791 const shellState = yield* makeEnvironmentShellState().pipe(
7892 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
7993 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
94 Effect.provideService(ShellSnapshotLoader, snapshotLoader),
8095 );
8196
8297 yield* SubscriptionRef.set(supervisorState, {
......@@ -117,4 +132,65 @@ describe("environment shell synchronization", () => {
117132 expect(Option.getOrThrow(state.snapshot)).toEqual(LIVE_SHELL_SNAPSHOT);
118133 }),
119134 );
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 );
120196});
packages/client-runtime/src/state/shell.ts+46-8
......@@ -19,6 +19,7 @@ import { EnvironmentSupervisor } from "../connection/supervisor.ts";
1919import { safeErrorLogAttributes } from "../errors/safeLog.ts";
2020import { EnvironmentCacheStore } from "../platform/persistence.ts";
2121import { subscribe } from "../rpc/client.ts";
22import { ShellSnapshotLoader } from "./shellSnapshotHttp.ts";
2223import { applyShellStreamEvent } from "./shellReducer.ts";
2324import type { EnvironmentCatalogState } from "./connections.ts";
2425import { followStreamInEnvironment } from "./runtime.ts";
......@@ -48,6 +49,7 @@ const SHELL_SYNCHRONIZATION_ERROR_MESSAGE = "Could not synchronize environment d
4849export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")(function* () {
4950 const supervisor = yield* EnvironmentSupervisor;
5051 const cache = yield* EnvironmentCacheStore;
52 const snapshotLoader = yield* ShellSnapshotLoader;
5153 const environmentId = supervisor.target.environmentId;
5254 const cachedSnapshot = yield* cache.loadShell(environmentId).pipe(
5355 Effect.catch((error) =>
......@@ -147,13 +149,45 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
147149 yield* Queue.offer(persistence, nextSnapshot);
148150 });
149151
150 yield* subscribe(
151 ORCHESTRATION_WS_METHODS.subscribeShell,
152 {},
153 {
154 onExpectedFailure: (cause) => setStreamError(Cause.squash(cause)),
155 },
156 ).pipe(Stream.runForEach(applyItem), Effect.forkScoped);
152 yield* Effect.forkScoped(
153 Effect.gen(function* () {
154 // Establish the base shell snapshot to resume from, minimizing bytes over
155 // the wire:
156 // - Warm cache: reuse the cached snapshot (zero network) and resume via
157 // `afterSequence` so we only receive shell events since the cached
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 );
157191 yield* SubscriptionRef.changes(supervisor.state).pipe(
158192 Stream.runForEach((connectionState) => {
159193 switch (connectionProjectionPhase(connectionState)) {
......@@ -293,7 +327,10 @@ export function createEnvironmentServerConfigsAtom(input: {
293327}
294328
295329export function createEnvironmentShellAtoms<R, E>(
296 runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>,
330 runtime: Atom.AtomRuntime<
331 EnvironmentRegistry | EnvironmentCacheStore | ShellSnapshotLoader | R,
332 E
333 >,
297334) {
298335 const stateAtom = Atom.family((environmentId: EnvironmentId) =>
299336 runtime.atom(shellStateChanges(environmentId), {
......@@ -316,4 +353,5 @@ export function createEnvironmentShellAtoms<R, E>(
316353export * from "./models.ts";
317354export * from "./shellCommands.ts";
318355export * from "./shellReducer.ts";
356export * from "./shellSnapshotHttp.ts";
319357export * 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";
66import type { EnvironmentRegistry } from "../connection/registry.ts";
77import type { EnvironmentCacheStore } from "../platform/persistence.ts";
88import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
9import { createEnvironmentThreadStateAtoms } from "./threads.ts";
9import { createEnvironmentThreadStateAtoms, type ThreadSnapshotLoader } from "./threads.ts";
1010
1111describe("createEnvironmentThreadStateAtoms", () => {
1212 it("retains thread state across short subscriber gaps", () => {
1313 const runtime = Atom.runtime(Layer.empty) as unknown as Atom.AtomRuntime<
14 EnvironmentRegistry | EnvironmentCacheStore,
14 EnvironmentRegistry | EnvironmentCacheStore | ThreadSnapshotLoader,
1515 never
1616 >;
1717 const threads = createEnvironmentThreadStateAtoms(runtime);
packages/client-runtime/src/state/threads-sync.test.ts+90-11
......@@ -6,6 +6,7 @@ import {
66 ProviderInstanceId,
77 ThreadId,
88 type OrchestrationThread,
9 type OrchestrationThreadDetailSnapshot,
910 type OrchestrationThreadStreamItem,
1011} from "@t3tools/contracts";
1112import { describe, expect, it } from "@effect/vitest";
......@@ -30,6 +31,7 @@ import * as RpcSession from "../rpc/session.ts";
3031import {
3132 EMPTY_ENVIRONMENT_THREAD_STATE,
3233 makeEnvironmentThreadState,
34 ThreadSnapshotLoader,
3335 type EnvironmentThreadState,
3436} from "./threads.ts";
3537
......@@ -40,6 +42,15 @@ const TARGET = new PrimaryConnectionTarget({
4042 wsBaseUrl: "wss://environment.example.test",
4143});
4244const 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};
4354const BASE_THREAD: OrchestrationThread = {
4455 id: THREAD_ID,
4556 projectId: ProjectId.make("project-1"),
......@@ -89,13 +100,16 @@ function awaitThreadState(
89100
90101const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (options?: {
91102 readonly cached?: OrchestrationThread;
103 readonly httpSnapshot?: Option.Option<OrchestrationThreadDetailSnapshot>;
92104}) {
93105 const inputs = yield* Queue.unbounded<TestThreadInput>();
94106 const observed = yield* Queue.unbounded<EnvironmentThreadState>();
95107 const latest = yield* Ref.make<EnvironmentThreadState>(EMPTY_ENVIRONMENT_THREAD_STATE);
96108 const retryCount = yield* Ref.make(0);
97109 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>>([]);
99113 const removedThreads = yield* Ref.make<ReadonlyArray<ThreadId>>([]);
100114 const supervisorState = yield* SubscriptionRef.make<SupervisorConnectionState>(
101115 AVAILABLE_CONNECTION_STATE,
......@@ -107,17 +121,30 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
107121 ),
108122 );
109123 const client = {
110 [ORCHESTRATION_WS_METHODS.subscribeThread]: () =>
124 [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { readonly afterSequence?: number }) =>
111125 Stream.unwrap(
112126 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)),
114129 ),
115130 ),
116131 } as unknown as WsRpcProtocolClient;
117132 const supervisorSession = yield* SubscriptionRef.make<Option.Option<RpcSession.RpcSession>>(
118133 Option.some(testSession(client)),
119134 );
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 });
121148 const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
122149 target: TARGET,
123150 state: supervisorState,
......@@ -133,7 +160,10 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
133160 loadThread: (_environmentId, threadId) =>
134161 Effect.succeed(
135162 threadId === THREAD_ID && options?.cached !== undefined
136 ? Option.some(options.cached)
163 ? Option.some({
164 snapshotSequence: CACHED_SNAPSHOT_SEQUENCE,
165 thread: options.cached,
166 })
137167 : Option.none(),
138168 ),
139169 saveThread: (_environmentId, thread) =>
......@@ -145,6 +175,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
145175 const threadState = yield* makeEnvironmentThreadState(THREAD_ID).pipe(
146176 Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
147177 Effect.provideService(Persistence.EnvironmentCacheStore, cache),
178 Effect.provideService(ThreadSnapshotLoader, snapshotLoader),
148179 );
149180 yield* SubscriptionRef.changes(threadState).pipe(
150181 Stream.runForEach((state) =>
......@@ -159,6 +190,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o
159190 latest,
160191 retryCount,
161192 subscriptionCount,
193 loaderCalls,
194 lastSubscribeAfterSequence,
162195 supervisorState,
163196 supervisorSession,
164197 savedThreads,
......@@ -217,19 +250,38 @@ const deleted = (): OrchestrationThreadStreamItem => ({
217250});
218251
219252describe("EnvironmentThreads", () => {
220 it.effect("publishes cached data before a live snapshot arrives", () =>
253 it.effect("publishes cached data immediately from a warm cache", () =>
221254 Effect.gen(function* () {
222255 const harness = yield* makeHarness({ cached: BASE_THREAD });
223 const state = yield* awaitThreadState(
224 harness.observed,
225 (value) => value.status === "cached" && Option.isSome(value.data),
226 );
256 const state = yield* awaitThreadState(harness.observed, (value) => Option.isSome(value.data));
227257
228258 expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD);
229259 expect(Option.isNone(state.error)).toBe(true);
230260 }),
231261 );
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
233285 it.effect("reduces live events and persists the latest thread", () =>
234286 Effect.gen(function* () {
235287 const harness = yield* makeHarness({ cached: BASE_THREAD });
......@@ -247,7 +299,34 @@ describe("EnvironmentThreads", () => {
247299 yield* Effect.yieldNow;
248300
249301 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);
251330 }),
252331 );
253332
packages/client-runtime/src/state/threads.ts+69-20
......@@ -2,6 +2,7 @@ import {
22 ORCHESTRATION_WS_METHODS,
33 type EnvironmentId as EnvironmentIdType,
44 type OrchestrationThread,
5 type OrchestrationThreadDetailSnapshot,
56 type OrchestrationThreadStreamItem,
67 type ThreadId as ThreadIdType,
78} from "@t3tools/contracts";
......@@ -18,6 +19,7 @@ import { connectionProjectionPhase } from "../connection/model.ts";
1819import { EnvironmentSupervisor } from "../connection/supervisor.ts";
1920import { EnvironmentCacheStore } from "../platform/persistence.ts";
2021import { subscribe } from "../rpc/client.ts";
22import { ThreadSnapshotLoader } from "./threadSnapshotHttp.ts";
2123import { parseThreadKey, threadKey } from "./entities.ts";
2224import { applyThreadDetailEvent } from "./threadReducer.ts";
2325import { THREAD_STATE_IDLE_TTL_MS } from "./threadRetention.ts";
......@@ -44,6 +46,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
4446) {
4547 const supervisor = yield* EnvironmentSupervisor;
4648 const cache = yield* EnvironmentCacheStore;
49 const snapshotLoader = yield* ThreadSnapshotLoader;
4750 const environmentId = supervisor.target.environmentId;
4851 const cached = yield* cache.loadThread(environmentId, threadId).pipe(
4952 Effect.catch((error) =>
......@@ -53,22 +56,27 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
5356 threadId,
5457 error: error.message,
5558 }),
56 Effect.as(Option.none<OrchestrationThread>()),
59 Effect.as(Option.none<OrchestrationThreadDetailSnapshot>()),
5760 ),
5861 ),
5962 );
63 const cachedThread = Option.map(cached, (snapshot) => snapshot.thread);
6064 const state = yield* SubscriptionRef.make<EnvironmentThreadState>({
61 data: cached,
62 status: statusWithoutLiveData(cached),
65 data: cachedThread,
66 status: statusWithoutLiveData(cachedThread),
6367 error: Option.none(),
6468 });
65 const lastSequence = yield* SubscriptionRef.make(0);
66 const persistence = yield* Queue.sliding<OrchestrationThread>(1);
69 // Seed the resume cursor from the cached snapshot so a warm cache can catch up
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
6876 const persist = Effect.fn("EnvironmentThreadState.persist")(function* (
69 thread: OrchestrationThread,
77 snapshot: OrchestrationThreadDetailSnapshot,
7078 ) {
71 yield* cache.saveThread(environmentId, thread).pipe(
79 yield* cache.saveThread(environmentId, snapshot).pipe(
7280 Effect.catch((error) =>
7381 Effect.logWarning("Could not persist the thread cache.").pipe(
7482 Effect.annotateLogs({
......@@ -120,7 +128,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
120128 status: "live",
121129 error: Option.none(),
122130 });
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 });
124135 });
125136
126137 const setDeleted = Effect.fn("EnvironmentThreadState.setDeleted")(function* () {
......@@ -187,21 +198,55 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make
187198 );
188199
189200 yield* setSynchronizing;
190 yield* subscribe(
191 ORCHESTRATION_WS_METHODS.subscribeThread,
192 { threadId },
193 {
194 onExpectedFailure: setStreamError,
195 retryExpectedFailureAfter: "250 millis",
196 },
197 ).pipe(Stream.runForEach(applyItem), Effect.forkScoped);
201 yield* Effect.forkScoped(
202 Effect.gen(function* () {
203 // Establish the base snapshot to resume from, minimizing bytes over the
204 // wire:
205 // - Warm cache: reuse the cached snapshot (zero network) and resume via
206 // `afterSequence` so we only receive events since the cached sequence.
207 // - Cold cache: load the full snapshot over HTTP (gzip-compressible, and
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
199244 yield* Effect.addFinalizer(() =>
200 SubscriptionRef.get(state).pipe(
201 Effect.flatMap((current) =>
245 Effect.all([SubscriptionRef.get(state), SubscriptionRef.get(lastSequence)]).pipe(
246 Effect.flatMap(([current, snapshotSequence]) =>
202247 Option.match(current.data, {
203248 onNone: () => Effect.void,
204 onSome: persist,
249 onSome: (thread) => persist({ snapshotSequence, thread }),
205250 }),
206251 ),
207252 ),
......@@ -218,7 +263,10 @@ export function threadStateChanges(environmentId: EnvironmentIdType, threadId: T
218263}
219264
220265export function createEnvironmentThreadStateAtoms<R, E>(
221 runtime: Atom.AtomRuntime<EnvironmentRegistry | EnvironmentCacheStore | R, E>,
266 runtime: Atom.AtomRuntime<
267 EnvironmentRegistry | EnvironmentCacheStore | ThreadSnapshotLoader | R,
268 E
269 >,
222270) {
223271 const family = Atom.family((key: string) => {
224272 const { environmentId, threadId } = parseThreadKey(key);
......@@ -240,6 +288,7 @@ export function createEnvironmentThreadStateAtoms<R, E>(
240288
241289export * from "./archivedThreads.ts";
242290export * from "./checkpointDiff.ts";
291export * from "./threadSnapshotHttp.ts";
243292export * from "./composerPathSearch.ts";
244293export * from "./threadCommands.ts";
245294export * from "./threadDetail.ts";
packages/contracts/src/environmentHttp.ts+46-1
......@@ -24,12 +24,14 @@ import {
2424 AuthWebSocketTicketResult,
2525 ServerAuthSessionMethod,
2626} from "./auth.ts";
27import { AuthSessionId, TrimmedNonEmptyString } from "./baseSchemas.ts";
27import { AuthSessionId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
2828import { ExecutionEnvironmentDescriptor } from "./environment.ts";
2929import {
3030 ClientOrchestrationCommand,
3131 DispatchResult,
3232 OrchestrationReadModel,
33 OrchestrationShellSnapshot,
34 OrchestrationThreadDetailSnapshot,
3335} from "./orchestration.ts";
3436import {
3537 RelayCloudEnvironmentHealthRequest,
......@@ -80,6 +82,7 @@ export const EnvironmentInternalErrorReason = Schema.Literals([
8082 "client_sessions_load_failed",
8183 "client_session_revoke_failed",
8284 "orchestration_snapshot_failed",
85 "orchestration_thread_snapshot_failed",
8386 "orchestration_dispatch_failed",
8487 "internal_error",
8588]);
......@@ -155,11 +158,29 @@ export class EnvironmentInternalError extends Schema.TaggedErrorClass<Environmen
155158 }
156159}
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
158178export const EnvironmentHttpCommonError = Schema.Union([
159179 EnvironmentRequestInvalidError,
160180 EnvironmentAuthInvalidError,
161181 EnvironmentScopeRequiredError,
162182 EnvironmentOperationForbiddenError,
183 EnvironmentResourceNotFoundError,
163184 EnvironmentInternalError,
164185]);
165186export type EnvironmentHttpCommonError = typeof EnvironmentHttpCommonError.Type;
......@@ -269,6 +290,11 @@ const EnvironmentOrchestrationSnapshotErrors = [
269290 EnvironmentScopeRequiredError,
270291 EnvironmentInternalError,
271292] as const;
293const EnvironmentOrchestrationThreadSnapshotErrors = [
294 EnvironmentScopeRequiredError,
295 EnvironmentResourceNotFoundError,
296 EnvironmentInternalError,
297] as const;
272298const EnvironmentOrchestrationDispatchErrors = [
273299 EnvironmentRequestInvalidError,
274300 EnvironmentScopeRequiredError,
......@@ -422,6 +448,10 @@ export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth")
422448 }).middleware(EnvironmentAuthenticatedAuth),
423449 ) {}
424450
451const EnvironmentOrchestrationThreadSnapshotParams = Schema.Struct({
452 threadId: ThreadId,
453});
454
425455export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestration")
426456 .add(
427457 HttpApiEndpoint.get("snapshot", "/api/orchestration/snapshot", {
......@@ -430,6 +460,21 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr
430460 error: EnvironmentOrchestrationSnapshotErrors,
431461 }).middleware(EnvironmentAuthenticatedAuth),
432462 )
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 )
433478 .add(
434479 HttpApiEndpoint.post("dispatch", "/api/orchestration/dispatch", {
435480 headers: OptionalBearerHeaders,
packages/contracts/src/orchestration.ts+22-1
......@@ -451,8 +451,29 @@ export const OrchestrationShellStreamItem = Schema.Union([
451451]);
452452export 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
454467export const OrchestrationSubscribeThreadInput = Schema.Struct({
455468 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),
456477});
457478export type OrchestrationSubscribeThreadInput = typeof OrchestrationSubscribeThreadInput.Type;
458479
......@@ -1244,7 +1265,7 @@ export const OrchestrationRpcSchemas = {
12441265 output: OrchestrationThreadStreamItem,
12451266 },
12461267 subscribeShell: {
1247 input: Schema.Struct({}),
1268 input: OrchestrationSubscribeShellInput,
12481269 output: OrchestrationShellStreamItem,
12491270 },
12501271} as const;