Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
4 changes: 3 additions & 1 deletion apps/mobile/src/connection/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Connection } from "@t3tools/client-runtime/connection";
import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads";
import * as Layer from "effect/Layer";
import { Atom } from "effect/unstable/reactivity";

Expand All @@ -11,10 +12,11 @@ const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(

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

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

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
20 changes: 20 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,24 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
);
}),
)
.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