fix: harden workspace activation and snapshot retention
This commit is contained in:
+127
-90
@@ -8,6 +8,7 @@ import type { PrincipalContext } from "../auth/principal.js";
|
||||
import type { ReadinessManager } from "../runtime/readiness-manager.js";
|
||||
import type { ListModelsFn } from "./meta.js";
|
||||
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
||||
import type { WorkspaceDescriptor } from "../workspaces/schema.js";
|
||||
|
||||
const BOOTSTRAP_FAILURE_MESSAGE =
|
||||
"Session startup failed. Check configuration and connectivity, then Resume the session.";
|
||||
@@ -34,6 +35,8 @@ export function sessionRoutes(
|
||||
dwhPrecheck?: boolean;
|
||||
/** Explicit loopback-only compatibility path for old clients that send `workspace`. */
|
||||
legacyWorkspaceMode?: boolean;
|
||||
/** Fail-closed installation/runtime transport capability check. */
|
||||
workspaceRuntimeSupport: (workspace: WorkspaceDescriptor) => boolean;
|
||||
},
|
||||
) {
|
||||
const lifecycleTails = new Map<string, Promise<void>>();
|
||||
@@ -289,110 +292,144 @@ export function sessionRoutes(
|
||||
code: "workspace_revision_unavailable",
|
||||
});
|
||||
}
|
||||
let workspaceConfigPath: string | undefined;
|
||||
let workspaceId: string | undefined;
|
||||
let workspaceRevision: string | undefined;
|
||||
let allowedModels: readonly string[] | undefined;
|
||||
if (requestedWorkspaceId) {
|
||||
try {
|
||||
const resolved = await d.workspaceRegistry.read(requestedWorkspaceId);
|
||||
if (resolved.revision.state !== "operational") {
|
||||
let revisionLease: Awaited<ReturnType<WorkspaceRegistry["acquireSessionRevision"]>> | undefined;
|
||||
let manifestPersisted = false;
|
||||
try {
|
||||
let workspaceConfigPath: string | undefined;
|
||||
let workspaceId: string | undefined;
|
||||
let workspaceRevision: string | undefined;
|
||||
let allowedModels: readonly string[] | undefined;
|
||||
if (requestedWorkspaceId) {
|
||||
try {
|
||||
const registry = d.workspaceRegistry as Partial<WorkspaceRegistry>;
|
||||
const resolved = typeof registry.acquireSessionRevision === "function"
|
||||
? await registry.acquireSessionRevision.call(d.workspaceRegistry, requestedWorkspaceId)
|
||||
: await d.workspaceRegistry.read(requestedWorkspaceId);
|
||||
if ("markPersisted" in resolved && "abort" in resolved) {
|
||||
revisionLease = resolved as Awaited<ReturnType<WorkspaceRegistry["acquireSessionRevision"]>>;
|
||||
}
|
||||
if (resolved.revision.state !== "operational") {
|
||||
return reply.code(409).send({
|
||||
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||
code: "workspace_revision_unavailable",
|
||||
});
|
||||
}
|
||||
if (!d.workspaceRuntimeSupport(resolved.workspace)) {
|
||||
return reply.code(409).send({
|
||||
error: "This workspace transport is not available to runtime sessions.",
|
||||
code: "workspace_not_activatable",
|
||||
});
|
||||
}
|
||||
workspaceConfigPath = resolved.revision.snapshotPath;
|
||||
workspaceId = resolved.revision.id;
|
||||
workspaceRevision = resolved.revision.commit;
|
||||
allowedModels = resolved.workspace.llm_policy.allowed;
|
||||
} catch {
|
||||
return reply.code(409).send({
|
||||
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||
code: "workspace_revision_unavailable",
|
||||
});
|
||||
}
|
||||
workspaceConfigPath = resolved.revision.snapshotPath;
|
||||
workspaceId = resolved.revision.id;
|
||||
workspaceRevision = resolved.revision.commit;
|
||||
allowedModels = resolved.workspace.llm_policy.allowed;
|
||||
} catch {
|
||||
return reply.code(409).send({
|
||||
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||
code: "workspace_revision_unavailable",
|
||||
});
|
||||
}
|
||||
}
|
||||
const provider = b.provider ?? s.provider;
|
||||
const model = b.model ?? s.model;
|
||||
const thinking = b.thinking ?? s.thinking;
|
||||
if (allowedModels && provider && model && !allowedModels.includes(`${provider}/${model}`)) {
|
||||
return reply.code(400).send({ error: "Selected model is not allowed by this workspace." });
|
||||
}
|
||||
// A persisted session is resumable without keeping Pi alive. New work replaces every
|
||||
// runtime owned by this principal, while runtimes belonging to other users remain intact.
|
||||
// Optional chaining preserves the deliberately narrow manager stubs used by route tests.
|
||||
for (const id of d.mgr.teardownForPrincipal?.(principal) ?? []) boundRuntimes.delete(id);
|
||||
const ensure = await d.readiness.ensure(workspaceConfigPath ?? "", principal);
|
||||
if (!ensure.ok) return reply.code(503).send({ error: READINESS_FAILURE_MESSAGE });
|
||||
// Local-only: verify the DWH is reachable BEFORE creating the session, so a dropped
|
||||
// VPN surfaces as an up-front alert instead of a session that spawns Pi and then dies
|
||||
// in bootstrap retrieval. `code` lets the client show a specific message.
|
||||
if (d.dwhPrecheck) {
|
||||
const ping = await runner.dbPing(workspaceConfigPath);
|
||||
if (!ping.ok) {
|
||||
console.error(`[dwh-precheck] refusing new session — DWH unreachable: ${ping.detail}`);
|
||||
return reply.code(503).send({ error: DWH_UNREACHABLE_MESSAGE, code: "dwh_unreachable" });
|
||||
const provider = b.provider ?? s.provider;
|
||||
const model = b.model ?? s.model;
|
||||
const thinking = b.thinking ?? s.thinking;
|
||||
if (allowedModels && provider && model && !allowedModels.includes(`${provider}/${model}`)) {
|
||||
return reply.code(400).send({ error: "Selected model is not allowed by this workspace." });
|
||||
}
|
||||
}
|
||||
if (provider && model) {
|
||||
let available: Awaited<ReturnType<ListModelsFn>>;
|
||||
// A persisted session is resumable without keeping Pi alive. New work replaces every
|
||||
// runtime owned by this principal, while runtimes belonging to other users remain intact.
|
||||
// Optional chaining preserves the deliberately narrow manager stubs used by route tests.
|
||||
for (const id of d.mgr.teardownForPrincipal?.(principal) ?? []) boundRuntimes.delete(id);
|
||||
const ensure = await d.readiness.ensure(workspaceConfigPath ?? "", principal);
|
||||
if (!ensure.ok) return reply.code(503).send({ error: READINESS_FAILURE_MESSAGE });
|
||||
// Local-only: verify the DWH is reachable BEFORE creating the session, so a dropped
|
||||
// VPN surfaces as an up-front alert instead of a session that spawns Pi and then dies
|
||||
// in bootstrap retrieval. `code` lets the client show a specific message.
|
||||
if (d.dwhPrecheck) {
|
||||
const ping = await runner.dbPing(workspaceConfigPath);
|
||||
if (!ping.ok) {
|
||||
console.error(`[dwh-precheck] refusing new session — DWH unreachable: ${ping.detail}`);
|
||||
return reply.code(503).send({ error: DWH_UNREACHABLE_MESSAGE, code: "dwh_unreachable" });
|
||||
}
|
||||
}
|
||||
if (provider && model) {
|
||||
let available: Awaited<ReturnType<ListModelsFn>>;
|
||||
try {
|
||||
available = await d.listModels();
|
||||
} catch {
|
||||
return reply.code(503).send({
|
||||
error: MODEL_UNAVAILABLE_MESSAGE,
|
||||
code: "model_unavailable",
|
||||
});
|
||||
}
|
||||
const selectedAvailable = available.some(
|
||||
(candidate) => candidate.provider === provider && candidate.id === model,
|
||||
);
|
||||
if (!selectedAvailable) {
|
||||
return reply.code(503).send({
|
||||
error: MODEL_UNAVAILABLE_MESSAGE,
|
||||
code: "model_unavailable",
|
||||
});
|
||||
}
|
||||
}
|
||||
// Browser choices are copied to the persisted manifest together with the immutable
|
||||
// registry snapshot. The legacy fallback stays available for sessions created before
|
||||
// the browser-local preference migration.
|
||||
let id: string;
|
||||
try {
|
||||
available = await d.listModels();
|
||||
} catch {
|
||||
return reply.code(503).send({
|
||||
error: MODEL_UNAVAILABLE_MESSAGE,
|
||||
code: "model_unavailable",
|
||||
({ id } = await runner.sessionNew({
|
||||
question: b.question, name: b.name, workspaceConfigPath,
|
||||
workspaceId, workspaceRevision, provider, model, thinking,
|
||||
}));
|
||||
manifestPersisted = true;
|
||||
if (revisionLease) {
|
||||
await revisionLease.markPersisted().catch((error: unknown) => {
|
||||
console.error(
|
||||
`[session:${id}] revision lease hand-off failed:`,
|
||||
error instanceof Error ? error.message : "unknown error",
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch { return storageFailure(reply); }
|
||||
const options = {
|
||||
provider, model, thinking,
|
||||
author: principal.displayName ?? principal.subject,
|
||||
principal,
|
||||
question: b.question,
|
||||
};
|
||||
let rt: ReturnType<PiProcessManager["createFor"]> | undefined;
|
||||
try {
|
||||
rt = d.mgr.createFor(id, options);
|
||||
bindRuntime(id, rt, runner, workspaceConfigPath);
|
||||
} catch (error) {
|
||||
if (rt) d.mgr.teardownIfCurrent(id, rt);
|
||||
console.error(
|
||||
`[pi:${id}] runtime construction failed:`,
|
||||
error instanceof Error ? error.message : "unknown error",
|
||||
);
|
||||
await runner.failSession(id, workspaceConfigPath).catch((persistenceError: unknown) => {
|
||||
console.error(`[session:${id}] failSession persistence failed:`, persistenceError);
|
||||
});
|
||||
return reply.code(503).send({ error: BOOTSTRAP_FAILURE_MESSAGE });
|
||||
}
|
||||
const selectedAvailable = available.some(
|
||||
(candidate) => candidate.provider === provider && candidate.id === model,
|
||||
info(id, "Session created");
|
||||
bootstrap(
|
||||
id, rt, runner, workspaceConfigPath, d.mgr.configure(rt, options),
|
||||
runner.searchPack(b.question, id, workspaceConfigPath),
|
||||
() => d.mgr.start(id, rt, options),
|
||||
);
|
||||
if (!selectedAvailable) {
|
||||
return reply.code(503).send({
|
||||
error: MODEL_UNAVAILABLE_MESSAGE,
|
||||
code: "model_unavailable",
|
||||
return { id };
|
||||
} finally {
|
||||
if (revisionLease && !manifestPersisted) {
|
||||
await revisionLease.abort().catch((error: unknown) => {
|
||||
console.error(
|
||||
"[session] revision lease cleanup failed:",
|
||||
error instanceof Error ? error.message : "unknown error",
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
// Browser choices are copied to the persisted manifest together with the immutable
|
||||
// registry snapshot. The legacy fallback stays available for sessions created before
|
||||
// the browser-local preference migration.
|
||||
let id: string;
|
||||
try {
|
||||
({ id } = await runner.sessionNew({
|
||||
question: b.question, name: b.name, workspaceConfigPath,
|
||||
workspaceId, workspaceRevision, provider, model, thinking,
|
||||
}));
|
||||
} catch { return storageFailure(reply); }
|
||||
const options = {
|
||||
provider, model, thinking,
|
||||
author: principal.displayName ?? principal.subject,
|
||||
principal,
|
||||
question: b.question,
|
||||
};
|
||||
let rt: ReturnType<PiProcessManager["createFor"]> | undefined;
|
||||
try {
|
||||
rt = d.mgr.createFor(id, options);
|
||||
bindRuntime(id, rt, runner, workspaceConfigPath);
|
||||
} catch (error) {
|
||||
if (rt) d.mgr.teardownIfCurrent(id, rt);
|
||||
console.error(
|
||||
`[pi:${id}] runtime construction failed:`,
|
||||
error instanceof Error ? error.message : "unknown error",
|
||||
);
|
||||
await runner.failSession(id, workspaceConfigPath).catch((persistenceError: unknown) => {
|
||||
console.error(`[session:${id}] failSession persistence failed:`, persistenceError);
|
||||
});
|
||||
return reply.code(503).send({ error: BOOTSTRAP_FAILURE_MESSAGE });
|
||||
}
|
||||
info(id, "Session created");
|
||||
bootstrap(
|
||||
id, rt, runner, workspaceConfigPath, d.mgr.configure(rt, options),
|
||||
runner.searchPack(b.question, id, workspaceConfigPath),
|
||||
() => d.mgr.start(id, rt, options),
|
||||
);
|
||||
return { id };
|
||||
});
|
||||
app.get("/sessions", async (req, reply) => {
|
||||
const principal = getPrincipal(req);
|
||||
|
||||
Reference in New Issue
Block a user