Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/mobile/src/connection/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Connection } from "@t3tools/client-runtime/connection";
import { shellSnapshotLoaderLayer } from "@t3tools/client-runtime/state/shell";
import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads";
import * as Layer from "effect/Layer";
import { Atom } from "effect/unstable/reactivity";

Expand All @@ -9,12 +11,15 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
Layer.provide(runtimeContextLayer),
);

const snapshotLoaderLayer = Layer.merge(threadSnapshotLoaderLayer, shellSnapshotLoaderLayer);

type ConnectionLayerSource =
| typeof Connection.layer
| typeof snapshotLoaderLayer
| typeof runtimeContextLayer
| typeof connectionPlatformLayer;

const connectionLayer = Connection.layer.pipe(
const connectionLayer = Layer.merge(Connection.layer, snapshotLoaderLayer).pipe(
Layer.provideMerge(Layer.mergeAll(runtimeContextLayer, providedConnectionPlatformLayer)),
);

Expand Down
19 changes: 11 additions & 8 deletions apps/mobile/src/connection/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import {
} from "@t3tools/client-runtime/connection";
import {
EnvironmentId,
OrchestrationThread,
OrchestrationShellSnapshot,
OrchestrationThreadDetailSnapshot,
ThreadId,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
Expand All @@ -32,7 +32,10 @@ import { makeCatalogStore, type SecureCatalogStorage } from "./catalog-store";
const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
const SHELL_SNAPSHOT_CACHE_DIRECTORY = "connection-shell-snapshots";
const LEGACY_SHELL_SNAPSHOT_CACHE_DIRECTORY = "shell-snapshots";
const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 1;
// v2 stores the snapshot sequence alongside the thread so a warm cache can
// resume via `afterSequence` instead of re-downloading the full thread body.
// Older v1 entries (no sequence) fail to decode and are treated as a cold cache.
const THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION = 2;
const THREAD_SNAPSHOT_CACHE_DIRECTORY = "connection-thread-snapshots";

const StoredShellSnapshot = Schema.Struct({
Expand All @@ -45,7 +48,7 @@ const StoredThreadSnapshot = Schema.Struct({
schemaVersion: Schema.Literal(THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION),
environmentId: EnvironmentId,
threadId: ThreadId,
thread: OrchestrationThread,
snapshot: OrchestrationThreadDetailSnapshot,
});

const LegacyStoredShellSnapshot = Schema.Struct({
Expand Down Expand Up @@ -355,18 +358,18 @@ export const connectionStorageLayer = Layer.effectContext(
Schema.decodeUnknownResult(StoredThreadSnapshot)(parsed),
).pipe(Effect.mapError((cause) => shellPersistenceError("load-thread", cause)));
return stored.environmentId === environmentId && stored.threadId === threadId
? Option.some(stored.thread)
? Option.some(stored.snapshot)
: Option.none();
}),
saveThread: (environmentId, thread) =>
saveThread: (environmentId, snapshot) =>
Effect.gen(function* () {
const file = yield* threadSnapshotFile(environmentId, thread.id, "save-thread");
const file = yield* threadSnapshotFile(environmentId, snapshot.thread.id, "save-thread");
const encoded = yield* Effect.fromResult(
Schema.encodeUnknownResult(StoredThreadSnapshot)({
schemaVersion: THREAD_SNAPSHOT_CACHE_SCHEMA_VERSION,
environmentId,
threadId: thread.id,
thread,
threadId: snapshot.thread.id,
snapshot,
}),
).pipe(Effect.mapError((cause) => shellPersistenceError("save-thread", cause)));
yield* Effect.try({
Expand Down
10 changes: 10 additions & 0 deletions apps/server/src/auth/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
EnvironmentOperationForbiddenError,
EnvironmentRequestInvalidError,
type EnvironmentRequestInvalidReason,
EnvironmentResourceNotFoundError,
type EnvironmentResourceNotFoundReason,
EnvironmentScopeRequiredError,
EnvironmentAuthenticatedAuth,
EnvironmentAuthenticatedPrincipal,
Expand Down Expand Up @@ -137,6 +139,14 @@ function failEnvironmentOperationForbidden(reason: "current_session_revoke_not_a
);
}

export function failEnvironmentNotFound(reason: EnvironmentResourceNotFoundReason) {
return currentEnvironmentTraceId.pipe(
Effect.flatMap((traceId) =>
Effect.fail(new EnvironmentResourceNotFoundError({ code: "not_found", reason, traceId })),
),
);
}

export function failEnvironmentInternal(reason: EnvironmentInternalErrorReason, error?: unknown) {
return Effect.gen(function* () {
const traceId = yield* currentEnvironmentTraceId;
Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/checkpointing/CheckpointDiffQuery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ describe("CheckpointDiffQuery.layer", () => {
}),
getThreadShellById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
}),
),
);
Expand Down Expand Up @@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => {
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
}),
),
);
Expand Down Expand Up @@ -281,6 +283,7 @@ describe("CheckpointDiffQuery.layer", () => {
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
}),
),
);
Expand Down Expand Up @@ -348,6 +351,7 @@ describe("CheckpointDiffQuery.layer", () => {
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
}),
),
);
Expand Down Expand Up @@ -400,6 +404,7 @@ describe("CheckpointDiffQuery.layer", () => {
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
}),
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ describe("OrchestrationEngine", () => {
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
}),
),
Layer.provide(
Expand Down
4 changes: 2 additions & 2 deletions apps/server/src/orchestration/Layers/OrchestrationEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,8 @@ const makeOrchestrationEngine = Effect.gen(function* () {
Effect.annotateLogs({ sequence: commandReadModel.snapshotSequence }),
);

const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive) =>
eventStore.readFromSequence(fromSequenceExclusive);
const readEvents: OrchestrationEngineShape["readEvents"] = (fromSequenceExclusive, limit) =>
eventStore.readFromSequence(fromSequenceExclusive, limit);

const dispatch: OrchestrationEngineShape["dispatch"] = (command) =>
Effect.gen(function* () {
Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
OrchestrationReadModel,
OrchestrationShellSnapshot,
OrchestrationThread,
OrchestrationThreadDetailSnapshot,
ProjectScript,
TurnId,
type OrchestrationCheckpointSummary,
Expand Down Expand Up @@ -2033,6 +2034,35 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
);
});

const getThreadDetailSnapshot: ProjectionSnapshotQueryShape["getThreadDetailSnapshot"] = (
threadId,
) =>
// Read the thread detail and the snapshot sequence within a single
// transaction so the sequence is consistent with the returned state; a
// projector update landing between two separate reads could otherwise return
// a sequence ahead of the thread detail, causing the client to resume from
// too far and drop events.
sql
.withTransaction(
Effect.gen(function* () {
const thread = yield* getThreadDetailById(threadId);
if (Option.isNone(thread)) {
return Option.none<OrchestrationThreadDetailSnapshot>();
}
const { snapshotSequence } = yield* getSnapshotSequence();
return Option.some({ snapshotSequence, thread: thread.value });
}),
)
.pipe(
Effect.mapError((error) =>
isPersistenceError(error)
? error
: toPersistenceSqlError("ProjectionSnapshotQuery.getThreadDetailSnapshot:transaction")(
error,
),
),
);

return {
getCommandReadModel,
getSnapshot,
Expand All @@ -2047,6 +2077,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
getFullThreadDiffContext,
getThreadShellById,
getThreadDetailById,
getThreadDetailSnapshot,
} satisfies ProjectionSnapshotQueryShape;
});

Expand Down
5 changes: 5 additions & 0 deletions apps/server/src/orchestration/Services/OrchestrationEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,15 @@ export interface OrchestrationEngineShape {
* Replay persisted orchestration events from an exclusive sequence cursor.
*
* @param fromSequenceExclusive - Sequence cursor (exclusive).
* @param limit - Maximum number of events to read. Defaults to the event
* store's page-bounded default; pass a higher value when the caller must
* read every event after the cursor (e.g. per-thread catch-up that filters
* a small subset out of a potentially larger global range).
* @returns Stream containing ordered events.
*/
readonly readEvents: (
fromSequenceExclusive: number,
limit?: number,
) => Stream.Stream<OrchestrationEvent, OrchestrationEventStoreError, never>;

/**
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
OrchestrationReadModel,
OrchestrationShellSnapshot,
OrchestrationThread,
OrchestrationThreadDetailSnapshot,
OrchestrationThreadShell,
ProjectId,
ThreadId,
Expand Down Expand Up @@ -157,6 +158,16 @@ export interface ProjectionSnapshotQueryShape {
readonly getThreadDetailById: (
threadId: ThreadId,
) => Effect.Effect<Option.Option<OrchestrationThread>, ProjectionRepositoryError>;

/**
* Read a single active thread detail together with the projection snapshot
* sequence in one consistent transaction, so the returned `snapshotSequence`
* exactly matches the state reflected in `thread` (no interleaving projector
* update between the two reads).
*/
readonly getThreadDetailSnapshot: (
threadId: ThreadId,
) => Effect.Effect<Option.Option<OrchestrationThreadDetailSnapshot>, ProjectionRepositoryError>;
}

/**
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/orchestration/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import {
EnvironmentHttpApi,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";

import { normalizeDispatchCommand } from "./Normalizer.ts";
import {
annotateEnvironmentRequest,
failEnvironmentInternal,
failEnvironmentInvalidRequest,
failEnvironmentNotFound,
requireEnvironmentScope,
} from "../auth/http.ts";
import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
Expand Down Expand Up @@ -38,6 +40,38 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
);
}),
)
.handle(
"shellSnapshot",
Effect.fn("environment.orchestration.shellSnapshot")(function* (args) {
yield* annotateEnvironmentRequest(args.endpoint.name);
yield* requireEnvironmentScope(AuthOrchestrationReadScope);
return yield* projectionSnapshotQuery
.getShellSnapshot()
.pipe(
Effect.catch((cause) =>
failEnvironmentInternal("orchestration_snapshot_failed", cause),
),
);
}),
)
.handle(
"threadSnapshot",
Effect.fn("environment.orchestration.threadSnapshot")(function* (args) {
yield* annotateEnvironmentRequest(args.endpoint.name);
yield* requireEnvironmentScope(AuthOrchestrationReadScope);
const snapshot = yield* projectionSnapshotQuery
.getThreadDetailSnapshot(args.params.threadId)
.pipe(
Effect.catch((cause) =>
failEnvironmentInternal("orchestration_thread_snapshot_failed", cause),
),
);
if (Option.isNone(snapshot)) {
return yield* failEnvironmentNotFound("thread_not_found");
}
return snapshot.value;
}),
)
.handle(
"dispatch",
Effect.fn("environment.orchestration.dispatch")(function* (args) {
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/project/ProjectSetupScriptRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
getFullThreadDiffContext: () => Effect.die("unused"),
getThreadShellById: () => Effect.die("unused"),
getThreadDetailById: () => Effect.die("unused"),
getThreadDetailSnapshot: () => Effect.die("unused"),
});

const makeTerminalManagerLayer = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ describe("ProviderSessionReaper", () => {
: Option.none(),
),
getThreadDetailById: () => Effect.die("unused"),
getThreadDetailSnapshot: () => Effect.die("unused"),
}),
),
Layer.provideMerge(NodeServices.layer),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,7 @@ const buildAppUnderTest = (options?: {
getProjectShellById: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/serverRuntimeStartup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ it.effect("launchStartupHeartbeat does not block the caller while counts are loa
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.succeed(Option.none()),
getThreadDetailById: () => Effect.succeed(Option.none()),
getThreadDetailSnapshot: () => Effect.succeed(Option.none()),
}),
Effect.provideService(AnalyticsService.AnalyticsService, {
record: () => Effect.void,
Expand Down Expand Up @@ -158,6 +159,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.die("unused"),
getThreadDetailById: () => Effect.die("unused"),
getThreadDetailSnapshot: () => Effect.die("unused"),
}),
Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
readEvents: () => Stream.empty,
Expand Down Expand Up @@ -200,6 +202,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.die("unused"),
getThreadDetailById: () => Effect.die("unused"),
getThreadDetailSnapshot: () => Effect.die("unused"),
}),
Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
readEvents: () => Stream.empty,
Expand Down Expand Up @@ -248,6 +251,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
getFullThreadDiffContext: () => Effect.succeed(Option.none()),
getThreadShellById: () => Effect.die("unused"),
getThreadDetailById: () => Effect.die("unused"),
getThreadDetailSnapshot: () => Effect.die("unused"),
}),
Effect.provideService(OrchestrationEngine.OrchestrationEngineService, {
readEvents: () => Stream.empty,
Expand Down
Loading
Loading