fix: harden workspace activation and snapshot retention
This commit is contained in:
+14
-7
@@ -13,21 +13,28 @@
|
|||||||
contents are never stored in Git, API responses, browser storage, diagnostics, or bundles.
|
contents are never stored in Git, API responses, browser storage, diagnostics, or bundles.
|
||||||
- **Migration and session safety.** Schema-v2 descriptors are operational; legacy descriptors are
|
- **Migration and session safety.** Schema-v2 descriptors are operational; legacy descriptors are
|
||||||
visible as `migration_required` until migrated by the documented operator workflow. New sessions
|
visible as `migration_required` until migrated by the documented operator workflow. New sessions
|
||||||
persist workspace ID and immutable Git revision. Resume resolves that historical snapshot, while
|
acquire a persistent revision lease before readiness and persist workspace ID plus immutable Git
|
||||||
retention preserves every revision referenced by an open, closed, or failed unarchived manifest.
|
revision. Retention hands that lease off only after an authoritative scan observes the manifest,
|
||||||
|
so a stale concurrent scan cannot prune the pinned snapshot. Resume resolves that historical
|
||||||
|
snapshot, while retention preserves every revision referenced by an open, closed, or failed
|
||||||
|
unarchived manifest.
|
||||||
Reconciliation runs only with a complete local installation list or an administrator's complete
|
Reconciliation runs only with a complete local installation list or an administrator's complete
|
||||||
server list, never from a remote user's partial view.
|
server list, never from a remote user's partial view.
|
||||||
|
- **SSH connector boundary.** The current OpenSSH forward is owned by one bounded diagnostic and is
|
||||||
|
always cleaned up afterward. DWH/vector `ssh_tunnel` bindings therefore return
|
||||||
|
`workspace_not_activatable`, and new-session creation rejects them before persistence. Direct and
|
||||||
|
REST runtime connectors remain supported; Git remote access over SSH is unaffected.
|
||||||
- **Operator manuals.** Follow [the local manual](docs/install/local-workspace-registry.md) for
|
- **Operator manuals.** Follow [the local manual](docs/install/local-workspace-registry.md) for
|
||||||
macOS/Windows/Linux Docker Desktop deployment and [the server manual](docs/install/server-workspace-registry.md)
|
macOS/Windows/Linux Docker Desktop deployment and [the server manual](docs/install/server-workspace-registry.md)
|
||||||
for Gitea-compatible remotes, reverse proxy, migration, backup, and recovery. The release
|
for Gitea-compatible remotes, reverse proxy, migration, backup, and recovery. The release
|
||||||
workflow is Git review/push → installation pull → validate → local diagnostic test → browser-local
|
workflow is Git review/push → installation pull → validate → local diagnostic test → browser-local
|
||||||
workspace/model/reasoning selection → revision-pinned session.
|
workspace/model/reasoning selection → revision-pinned session.
|
||||||
- **Verification recorded for this source branch.** `git diff --check` passed; backend Vitest
|
- **Verification recorded for this source branch.** `git diff --check` passed; backend Vitest
|
||||||
**365/365** and TypeScript passed; frontend Vitest **398/398** and TypeScript passed; the
|
**371/371** and TypeScript passed; frontend Vitest **398/398** and TypeScript passed; the
|
||||||
harness document regression passed **10/10**. `./scripts/workspace-registry-smoke.sh`, both
|
harness document regression passed **10/10**. `./scripts/workspace-registry-smoke.sh` and the
|
||||||
installation-document verifier profiles, and a final unrestricted full harness run remain the
|
executable installation-manual fixture verifier passed with Docker. A final unrestricted full
|
||||||
release commands to execute in the deployment environment; the local long-running harness run
|
harness run remains a release command for the deployment environment; the earlier local
|
||||||
was intentionally cancelled before it produced a final result.
|
long-running harness run was intentionally cancelled before it produced a final result.
|
||||||
|
|
||||||
## Session summary redesign — LIVE 2026-07-23
|
## Session summary redesign — LIVE 2026-07-23
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ every revision referenced by an open, closed, or failed unarchived session. It r
|
|||||||
single local installation list or from a server administrator's complete session list, never from
|
single local installation list or from a server administrator's complete session list, never from
|
||||||
a remote user's partial list.
|
a remote user's partial list.
|
||||||
|
|
||||||
|
Connector `ssh_tunnel` bindings are diagnostic-only in this release: their bounded probe always
|
||||||
|
cleans up the loopback forward and returns `workspace_not_activatable`; session creation is rejected
|
||||||
|
before persistence. Git registry access over SSH is unaffected. Use direct or REST connector
|
||||||
|
transport for runtime sessions.
|
||||||
|
|
||||||
`docker-compose.dev.yml` is deliberately local: both published ports bind to `127.0.0.1`,
|
`docker-compose.dev.yml` is deliberately local: both published ports bind to `127.0.0.1`,
|
||||||
`THT_SESSION_STORAGE=local`, and `THT_HOME=/data/local-home`. Do not set
|
`THT_SESSION_STORAGE=local`, and `THT_HOME=/data/local-home`. Do not set
|
||||||
`THOTH_PUBLIC_EXPOSURE=true` for that profile; the backend rejects that public/local combination
|
`THOTH_PUBLIC_EXPOSURE=true` for that profile; the backend rejects that public/local combination
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ import { ReadinessManager } from "./runtime/readiness-manager.js";
|
|||||||
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
import { WorkspaceRegistry } from "./workspaces/registry.js";
|
||||||
import { createProductionWorkspaceDiagnoser } from "./workspaces/diagnostics.js";
|
import { createProductionWorkspaceDiagnoser } from "./workspaces/diagnostics.js";
|
||||||
import { workspaceRoutes, type WorkspaceDiagnoser } from "./routes/workspaces.js";
|
import { workspaceRoutes, type WorkspaceDiagnoser } from "./routes/workspaces.js";
|
||||||
|
import { resolveRuntimeBindings, supportsSessionRuntime } from "./workspaces/bindings.js";
|
||||||
|
import type { WorkspaceDescriptor } from "./workspaces/schema.js";
|
||||||
|
|
||||||
export interface BuildAppDeps {
|
export interface BuildAppDeps {
|
||||||
thtRunner?: ThtRunner;
|
thtRunner?: ThtRunner;
|
||||||
@@ -29,6 +31,7 @@ export interface BuildAppDeps {
|
|||||||
hub?: SseHub;
|
hub?: SseHub;
|
||||||
workspaceRegistry?: WorkspaceRegistry;
|
workspaceRegistry?: WorkspaceRegistry;
|
||||||
workspaceDiagnoser?: WorkspaceDiagnoser;
|
workspaceDiagnoser?: WorkspaceDiagnoser;
|
||||||
|
workspaceRuntimeSupport?: (workspace: WorkspaceDescriptor) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
|
export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstance {
|
||||||
@@ -55,6 +58,13 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
|||||||
const workspaceRegistry = deps?.workspaceRegistry ?? new WorkspaceRegistry(config.workspaceRegistry);
|
const workspaceRegistry = deps?.workspaceRegistry ?? new WorkspaceRegistry(config.workspaceRegistry);
|
||||||
const workspaceDiagnoser = deps?.workspaceDiagnoser
|
const workspaceDiagnoser = deps?.workspaceDiagnoser
|
||||||
?? createProductionWorkspaceDiagnoser(config.workspaceDiagnosticTimeoutMs);
|
?? createProductionWorkspaceDiagnoser(config.workspaceDiagnosticTimeoutMs);
|
||||||
|
const workspaceRuntimeSupport = deps?.workspaceRuntimeSupport ?? ((workspace: WorkspaceDescriptor) => (
|
||||||
|
supportsSessionRuntime(resolveRuntimeBindings(
|
||||||
|
workspace,
|
||||||
|
process.env,
|
||||||
|
config.workspaceRegistry.secretRoots,
|
||||||
|
))
|
||||||
|
));
|
||||||
const readiness = deps?.readiness ?? new ReadinessManager(
|
const readiness = deps?.readiness ?? new ReadinessManager(
|
||||||
tht as ThtRunner,
|
tht as ThtRunner,
|
||||||
Math.round(config.ollamaEnsureTimeoutMs / 1000),
|
Math.round(config.ollamaEnsureTimeoutMs / 1000),
|
||||||
@@ -88,6 +98,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
|||||||
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, listModels, workspaceRegistry,
|
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, listModels, workspaceRegistry,
|
||||||
dwhPrecheck: config.dwhPrecheck,
|
dwhPrecheck: config.dwhPrecheck,
|
||||||
legacyWorkspaceMode: config.legacyWorkspaceMode,
|
legacyWorkspaceMode: config.legacyWorkspaceMode,
|
||||||
|
workspaceRuntimeSupport,
|
||||||
});
|
});
|
||||||
sqlRoutes(app, { tht: tht as ThtRunner, getSettings });
|
sqlRoutes(app, { tht: tht as ThtRunner, getSettings });
|
||||||
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
|
metaRoutes(app, { harnessDir: config.harnessDir, listModels });
|
||||||
|
|||||||
+127
-90
@@ -8,6 +8,7 @@ import type { PrincipalContext } from "../auth/principal.js";
|
|||||||
import type { ReadinessManager } from "../runtime/readiness-manager.js";
|
import type { ReadinessManager } from "../runtime/readiness-manager.js";
|
||||||
import type { ListModelsFn } from "./meta.js";
|
import type { ListModelsFn } from "./meta.js";
|
||||||
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
import type { WorkspaceRegistry } from "../workspaces/registry.js";
|
||||||
|
import type { WorkspaceDescriptor } from "../workspaces/schema.js";
|
||||||
|
|
||||||
const BOOTSTRAP_FAILURE_MESSAGE =
|
const BOOTSTRAP_FAILURE_MESSAGE =
|
||||||
"Session startup failed. Check configuration and connectivity, then Resume the session.";
|
"Session startup failed. Check configuration and connectivity, then Resume the session.";
|
||||||
@@ -34,6 +35,8 @@ export function sessionRoutes(
|
|||||||
dwhPrecheck?: boolean;
|
dwhPrecheck?: boolean;
|
||||||
/** Explicit loopback-only compatibility path for old clients that send `workspace`. */
|
/** Explicit loopback-only compatibility path for old clients that send `workspace`. */
|
||||||
legacyWorkspaceMode?: boolean;
|
legacyWorkspaceMode?: boolean;
|
||||||
|
/** Fail-closed installation/runtime transport capability check. */
|
||||||
|
workspaceRuntimeSupport: (workspace: WorkspaceDescriptor) => boolean;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const lifecycleTails = new Map<string, Promise<void>>();
|
const lifecycleTails = new Map<string, Promise<void>>();
|
||||||
@@ -289,110 +292,144 @@ export function sessionRoutes(
|
|||||||
code: "workspace_revision_unavailable",
|
code: "workspace_revision_unavailable",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let workspaceConfigPath: string | undefined;
|
let revisionLease: Awaited<ReturnType<WorkspaceRegistry["acquireSessionRevision"]>> | undefined;
|
||||||
let workspaceId: string | undefined;
|
let manifestPersisted = false;
|
||||||
let workspaceRevision: string | undefined;
|
try {
|
||||||
let allowedModels: readonly string[] | undefined;
|
let workspaceConfigPath: string | undefined;
|
||||||
if (requestedWorkspaceId) {
|
let workspaceId: string | undefined;
|
||||||
try {
|
let workspaceRevision: string | undefined;
|
||||||
const resolved = await d.workspaceRegistry.read(requestedWorkspaceId);
|
let allowedModels: readonly string[] | undefined;
|
||||||
if (resolved.revision.state !== "operational") {
|
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({
|
return reply.code(409).send({
|
||||||
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
error: WORKSPACE_REVISION_UNAVAILABLE_MESSAGE,
|
||||||
code: "workspace_revision_unavailable",
|
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 provider = b.provider ?? s.provider;
|
const model = b.model ?? s.model;
|
||||||
const model = b.model ?? s.model;
|
const thinking = b.thinking ?? s.thinking;
|
||||||
const thinking = b.thinking ?? s.thinking;
|
if (allowedModels && provider && model && !allowedModels.includes(`${provider}/${model}`)) {
|
||||||
if (allowedModels && provider && model && !allowedModels.includes(`${provider}/${model}`)) {
|
return reply.code(400).send({ error: "Selected model is not allowed by this workspace." });
|
||||||
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" });
|
|
||||||
}
|
}
|
||||||
}
|
// A persisted session is resumable without keeping Pi alive. New work replaces every
|
||||||
if (provider && model) {
|
// runtime owned by this principal, while runtimes belonging to other users remain intact.
|
||||||
let available: Awaited<ReturnType<ListModelsFn>>;
|
// 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 {
|
try {
|
||||||
available = await d.listModels();
|
({ id } = await runner.sessionNew({
|
||||||
} catch {
|
question: b.question, name: b.name, workspaceConfigPath,
|
||||||
return reply.code(503).send({
|
workspaceId, workspaceRevision, provider, model, thinking,
|
||||||
error: MODEL_UNAVAILABLE_MESSAGE,
|
}));
|
||||||
code: "model_unavailable",
|
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(
|
info(id, "Session created");
|
||||||
(candidate) => candidate.provider === provider && candidate.id === model,
|
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 { id };
|
||||||
return reply.code(503).send({
|
} finally {
|
||||||
error: MODEL_UNAVAILABLE_MESSAGE,
|
if (revisionLease && !manifestPersisted) {
|
||||||
code: "model_unavailable",
|
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) => {
|
app.get("/sessions", async (req, reply) => {
|
||||||
const principal = getPrincipal(req);
|
const principal = getPrincipal(req);
|
||||||
|
|||||||
@@ -143,3 +143,11 @@ export function resolveRuntimeBindings(
|
|||||||
embedding: resolveBinding(workspace, "EMBEDDING", env, secretRoots),
|
embedding: resolveBinding(workspace, "EMBEDDING", env, secretRoots),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSH bindings are currently probe-only: diagnostics owns a short-lived tunnel, while the
|
||||||
|
* session runtime has no tunnel owner. Keep activation fail-closed until that lifecycle exists.
|
||||||
|
*/
|
||||||
|
export function supportsSessionRuntime(bindings: RuntimeBindings): boolean {
|
||||||
|
return bindings.dwh.transport !== "ssh_tunnel" && bindings.vector.transport !== "ssh_tunnel";
|
||||||
|
}
|
||||||
|
|||||||
@@ -535,7 +535,9 @@ function diagnosticError(code: WorkspaceErrorCode, field?: string): Diagnostic {
|
|||||||
? "Installation binding is missing or invalid."
|
? "Installation binding is missing or invalid."
|
||||||
: code === "semantic_index_incompatible"
|
: code === "semantic_index_incompatible"
|
||||||
? "Semantic index metadata is incompatible with this workspace."
|
? "Semantic index metadata is incompatible with this workspace."
|
||||||
: "Connector diagnostic failed.",
|
: code === "workspace_not_activatable"
|
||||||
|
? "This transport can be tested, but it is not available to runtime sessions."
|
||||||
|
: "Connector diagnostic failed.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,6 +853,16 @@ export function createWorkspaceDiagnoser(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The concrete SSH adapter deliberately owns only a bounded diagnostic tunnel and closes it
|
||||||
|
// in `finally`. Until a session runtime owns an equivalent long-lived tunnel, a successful
|
||||||
|
// probe is connectivity evidence only and must never be advertised as activatable.
|
||||||
|
if (
|
||||||
|
(bindings.dwh.transport === "ssh_tunnel" || bindings.vector.transport === "ssh_tunnel")
|
||||||
|
&& !diagnostics.some((diagnostic) => diagnostic.level === "error")
|
||||||
|
) {
|
||||||
|
diagnostics.push(diagnosticError("workspace_not_activatable"));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activatable: !diagnostics.some((diagnostic) => diagnostic.level === "error"),
|
activatable: !diagnostics.some((diagnostic) => diagnostic.level === "error"),
|
||||||
diagnostics,
|
diagnostics,
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ export interface WorkspaceRevision {
|
|||||||
state: "operational" | "migration_required";
|
state: "operational" | "migration_required";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionRevisionLease {
|
||||||
|
workspace: WorkspaceDescriptor;
|
||||||
|
revision: WorkspaceRevision;
|
||||||
|
/** Mark the manifest durable; retention removes the lease only after observing that manifest. */
|
||||||
|
markPersisted(): Promise<void>;
|
||||||
|
/** Remove a lease for a session that failed before its manifest was durable. */
|
||||||
|
abort(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
export type PublishWorkspaceRequest =
|
export type PublishWorkspaceRequest =
|
||||||
| { action: "create"; workspace: CanonicalWorkspace; baseCommit: string }
|
| { action: "create"; workspace: CanonicalWorkspace; baseCommit: string }
|
||||||
| { action: "update"; workspace: CanonicalWorkspace; baseCommit: string; baseBlob: string }
|
| { action: "update"; workspace: CanonicalWorkspace; baseCommit: string; baseBlob: string }
|
||||||
@@ -56,6 +65,14 @@ interface SnapshotManifest extends ActiveState {
|
|||||||
files: Record<string, string>;
|
files: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RevisionLeaseRecord {
|
||||||
|
version: 1;
|
||||||
|
token: string;
|
||||||
|
workspaceId: string;
|
||||||
|
commit: string;
|
||||||
|
state: "creating" | "persisted";
|
||||||
|
}
|
||||||
|
|
||||||
type LegacyWorkspaceRevision = Omit<WorkspaceRevision, "state">;
|
type LegacyWorkspaceRevision = Omit<WorkspaceRevision, "state">;
|
||||||
|
|
||||||
interface LegacyActiveState {
|
interface LegacyActiveState {
|
||||||
@@ -178,6 +195,56 @@ export class WorkspaceRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the active revision and create its cross-process retention lease under the same
|
||||||
|
* repository lock. The lease bridges the interval before `session_manifest.yaml` is durable.
|
||||||
|
*/
|
||||||
|
async acquireSessionRevision(id: string): Promise<SessionRevisionLease> {
|
||||||
|
await this.repository.ensureLayout();
|
||||||
|
return await this.lock.run(async () => {
|
||||||
|
const state = await this.activeState();
|
||||||
|
const revision = state.revisions.find((candidate) => candidate.id === id);
|
||||||
|
if (!revision) throw new WorkspaceRegistryError("workspace_invalid", "Workspace is unavailable");
|
||||||
|
let workspace: WorkspaceDescriptor;
|
||||||
|
try {
|
||||||
|
workspace = parseWorkspaceYaml(await readFile(revision.snapshotPath, "utf8"));
|
||||||
|
} catch (error) {
|
||||||
|
throw workspaceError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = randomUUID();
|
||||||
|
const record: RevisionLeaseRecord = {
|
||||||
|
version: 1,
|
||||||
|
token,
|
||||||
|
workspaceId: id,
|
||||||
|
commit: revision.commit,
|
||||||
|
state: "creating",
|
||||||
|
};
|
||||||
|
const path = await this.writeRevisionLease(record, true);
|
||||||
|
let localState: RevisionLeaseRecord["state"] | "aborted" = "creating";
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspace,
|
||||||
|
revision,
|
||||||
|
markPersisted: async () => {
|
||||||
|
if (localState === "persisted") return;
|
||||||
|
if (localState === "aborted") throw new WorkspaceRegistryError(
|
||||||
|
"workspace_invalid", "Workspace revision lease is unavailable",
|
||||||
|
);
|
||||||
|
await this.lock.run(async () => {
|
||||||
|
await this.replaceRevisionLease(path, { ...record, state: "persisted" });
|
||||||
|
});
|
||||||
|
localState = "persisted";
|
||||||
|
},
|
||||||
|
abort: async () => {
|
||||||
|
if (localState !== "creating") return;
|
||||||
|
await this.lock.run(async () => { await rm(path, { force: true }); });
|
||||||
|
localState = "aborted";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Read a retained immutable snapshot for a session pinned to a historical commit. */
|
/** Read a retained immutable snapshot for a session pinned to a historical commit. */
|
||||||
async readPinned(id: string, commit: string): Promise<{ workspace: WorkspaceDescriptor; workspaceConfigPath: string }> {
|
async readPinned(id: string, commit: string): Promise<{ workspace: WorkspaceDescriptor; workspaceConfigPath: string }> {
|
||||||
const snapshotPath = this.snapshotPath(safeCommit(commit), id);
|
const snapshotPath = this.snapshotPath(safeCommit(commit), id);
|
||||||
@@ -195,9 +262,12 @@ export class WorkspaceRegistry {
|
|||||||
* a partial, per-user list could otherwise remove another user's resumable workspace pin.
|
* a partial, per-user list could otherwise remove another user's resumable workspace pin.
|
||||||
*/
|
*/
|
||||||
async reconcileSnapshotRetention(referencedCommits: readonly string[]): Promise<void> {
|
async reconcileSnapshotRetention(referencedCommits: readonly string[]): Promise<void> {
|
||||||
const retained = new Set(referencedCommits.map(safeCommit));
|
const manifestReferences = new Set(referencedCommits.map(safeCommit));
|
||||||
|
const retained = new Set(manifestReferences);
|
||||||
await this.repository.ensureLayout();
|
await this.repository.ensureLayout();
|
||||||
await this.lock.run(async () => {
|
await this.lock.run(async () => {
|
||||||
|
const leases = await this.revisionLeases();
|
||||||
|
for (const { record } of leases) retained.add(record.commit);
|
||||||
retained.add((await this.activeState()).head);
|
retained.add((await this.activeState()).head);
|
||||||
const entries = await readdir(this.repository.snapshotsPath, { withFileTypes: true });
|
const entries = await readdir(this.repository.snapshotsPath, { withFileTypes: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -210,9 +280,77 @@ export class WorkspaceRegistry {
|
|||||||
if (!current.isDirectory() || current.isSymbolicLink()) continue;
|
if (!current.isDirectory() || current.isSymbolicLink()) continue;
|
||||||
await rm(path, { recursive: true, force: true });
|
await rm(path, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
// A persisted lease is handed off only when this exact authoritative scan has observed a
|
||||||
|
// manifest pin for its commit. A stale scan therefore keeps the lease and cannot prune it.
|
||||||
|
for (const { path, record } of leases) {
|
||||||
|
if (record.state === "persisted" && manifestReferences.has(record.commit)) {
|
||||||
|
await rm(path, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private revisionLeaseDirectory(): string {
|
||||||
|
return join(this.repository.statePath, "revision-leases");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeRevisionLease(record: RevisionLeaseRecord, exclusive: boolean): Promise<string> {
|
||||||
|
const directory = this.revisionLeaseDirectory();
|
||||||
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||||
|
const path = join(directory, `${record.token}.json`);
|
||||||
|
await writeFile(path, JSON.stringify(record), {
|
||||||
|
encoding: "utf8",
|
||||||
|
mode: 0o600,
|
||||||
|
flush: true,
|
||||||
|
...(exclusive ? { flag: "wx" } : {}),
|
||||||
|
});
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async replaceRevisionLease(path: string, record: RevisionLeaseRecord): Promise<void> {
|
||||||
|
const staging = `${path}.staging-${randomUUID()}`;
|
||||||
|
try {
|
||||||
|
await writeFile(staging, JSON.stringify(record), {
|
||||||
|
encoding: "utf8", mode: 0o600, flag: "wx", flush: true,
|
||||||
|
});
|
||||||
|
await rename(staging, path);
|
||||||
|
} catch (error) {
|
||||||
|
await rm(staging, { force: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async revisionLeases(): Promise<Array<{ path: string; record: RevisionLeaseRecord }>> {
|
||||||
|
const directory = this.revisionLeaseDirectory();
|
||||||
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true });
|
||||||
|
const leases: Array<{ path: string; record: RevisionLeaseRecord }> = [];
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile() || entry.isSymbolicLink() || !/^[0-9a-f-]{36}\.json$/.test(entry.name)) {
|
||||||
|
throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision lease is invalid");
|
||||||
|
}
|
||||||
|
const path = join(directory, entry.name);
|
||||||
|
let record: RevisionLeaseRecord;
|
||||||
|
try {
|
||||||
|
record = JSON.parse(await readFile(path, "utf8")) as RevisionLeaseRecord;
|
||||||
|
} catch {
|
||||||
|
throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision lease is invalid");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
record.version !== 1
|
||||||
|
|| `${record.token}.json` !== entry.name
|
||||||
|
|| !/^[0-9a-f-]{36}$/.test(record.token)
|
||||||
|
|| !/^[a-z][a-z0-9-]{2,62}$/.test(record.workspaceId)
|
||||||
|
|| !/^[0-9a-f]{40}$/.test(record.commit)
|
||||||
|
|| (record.state !== "creating" && record.state !== "persisted")
|
||||||
|
) {
|
||||||
|
throw new WorkspaceRegistryError("workspace_invalid", "Workspace revision lease is invalid");
|
||||||
|
}
|
||||||
|
leases.push({ path, record });
|
||||||
|
}
|
||||||
|
return leases;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish canonical YAML and derived public documentation as one optimistic Git revision.
|
* Publish canonical YAML and derived public documentation as one optimistic Git revision.
|
||||||
* The browser never provides paths or generated artifacts; those are derived server-side.
|
* The browser never provides paths or generated artifacts; those are derived server-side.
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ const defaultWorkspaceRegistry = {
|
|||||||
|
|
||||||
function buildApp(config: Parameters<typeof buildRealApp>[0], deps: Record<string, unknown> = {}) {
|
function buildApp(config: Parameters<typeof buildRealApp>[0], deps: Record<string, unknown> = {}) {
|
||||||
return buildRealApp(config, {
|
return buildRealApp(config, {
|
||||||
|
workspaceRuntimeSupport: () => true,
|
||||||
...deps,
|
...deps,
|
||||||
workspaceRegistry: { ...defaultWorkspaceRegistry, ...(deps.workspaceRegistry as object | undefined) },
|
workspaceRegistry: { ...defaultWorkspaceRegistry, ...(deps.workspaceRegistry as object | undefined) },
|
||||||
} as any);
|
} as any);
|
||||||
@@ -324,6 +325,100 @@ test("creates a session from the active immutable workspace revision", async ()
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects an SSH-only workspace before persisting or starting a session", async () => {
|
||||||
|
const sessionNew = vi.fn(async () => ({ id: "must-not-exist" }));
|
||||||
|
const ensure = vi.fn(async () => ({ ok: true }));
|
||||||
|
const createFor = vi.fn();
|
||||||
|
const abort = vi.fn(async () => {});
|
||||||
|
const markPersisted = vi.fn(async () => {});
|
||||||
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
|
thtRunner: { sessionNew, searchPack: async () => {} } as any,
|
||||||
|
readiness: { ensure } as any,
|
||||||
|
mgr: { get: () => undefined, createFor } as any,
|
||||||
|
getSettings: () => ({ workspace: "ssh-workspace" }) as any,
|
||||||
|
workspaceRuntimeSupport: vi.fn(() => false),
|
||||||
|
workspaceRegistry: {
|
||||||
|
acquireSessionRevision: vi.fn(async () => ({
|
||||||
|
workspace: {
|
||||||
|
workspace: { schema_version: 2, id: "ssh-workspace", name: "SSH", language: "en" },
|
||||||
|
dwh: {
|
||||||
|
engine: "postgres", database: "postgres", schema: "public",
|
||||||
|
supported_transports: ["ssh_tunnel"],
|
||||||
|
},
|
||||||
|
semantic_index: {
|
||||||
|
vector_store: {
|
||||||
|
engine: "pgvector", database: "postgres", schema: "vectors",
|
||||||
|
collection: "documents", dimensions: 768, distance: "cosine",
|
||||||
|
supported_transports: ["ssh_tunnel"],
|
||||||
|
},
|
||||||
|
embedding: {
|
||||||
|
provider: "ollama_compatible", model: "nomic-embed-text", dimensions: 768,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
llm_policy: { allowed: ["zai/glm-5.2"] },
|
||||||
|
},
|
||||||
|
revision: {
|
||||||
|
id: "ssh-workspace", commit: "a".repeat(40), blob: "b".repeat(40),
|
||||||
|
snapshotPath: `/data/workspace-registry/snapshots/${"a".repeat(40)}/ssh-workspace.yaml`,
|
||||||
|
state: "operational",
|
||||||
|
},
|
||||||
|
abort,
|
||||||
|
markPersisted,
|
||||||
|
})),
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(409);
|
||||||
|
expect(response.json()).toMatchObject({ code: "workspace_not_activatable" });
|
||||||
|
expect(ensure).not.toHaveBeenCalled();
|
||||||
|
expect(sessionNew).not.toHaveBeenCalled();
|
||||||
|
expect(createFor).not.toHaveBeenCalled();
|
||||||
|
expect(abort).toHaveBeenCalledOnce();
|
||||||
|
expect(markPersisted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hands a revision lease to retention only after the session manifest is durable", async () => {
|
||||||
|
const persisted = deferred<{ id: string }>();
|
||||||
|
const markPersisted = vi.fn(async () => {});
|
||||||
|
const abort = vi.fn(async () => {});
|
||||||
|
const acquireSessionRevision = vi.fn(async () => ({
|
||||||
|
workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } },
|
||||||
|
revision: {
|
||||||
|
id: "leased", commit: "a".repeat(40), blob: "b".repeat(40),
|
||||||
|
snapshotPath: `/data/workspace-registry/snapshots/${"a".repeat(40)}/leased.yaml`,
|
||||||
|
state: "operational",
|
||||||
|
},
|
||||||
|
markPersisted,
|
||||||
|
abort,
|
||||||
|
}));
|
||||||
|
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
|
||||||
|
thtRunner: { sessionNew: () => persisted.promise, searchPack: async () => {} } as any,
|
||||||
|
readiness: { ensure: async () => ({ ok: true }) } as any,
|
||||||
|
mgr: {
|
||||||
|
get: () => undefined,
|
||||||
|
createFor: () => ({ bridge: { onClientEvent: () => {} } }),
|
||||||
|
configure: async () => {},
|
||||||
|
start: () => {},
|
||||||
|
} as any,
|
||||||
|
getSettings: () => ({ workspace: "leased", provider: "zai", model: "glm-5.2" }) as any,
|
||||||
|
listModels: async () => [{ provider: "zai", id: "glm-5.2", name: "GLM", reasoning: true }],
|
||||||
|
workspaceRuntimeSupport: () => true,
|
||||||
|
workspaceRegistry: { acquireSessionRevision } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
expect(markPersisted).not.toHaveBeenCalled();
|
||||||
|
expect(abort).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
persisted.resolve({ id: "leased-session" });
|
||||||
|
expect((await request).statusCode).toBe(200);
|
||||||
|
expect(markPersisted).toHaveBeenCalledOnce();
|
||||||
|
expect(abort).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
test("creates a session from the configured default workspace revision when workspaceId is omitted", async () => {
|
test("creates a session from the configured default workspace revision when workspaceId is omitted", async () => {
|
||||||
const sessionNew = vi.fn(async () => ({ id: "default-pinned" }));
|
const sessionNew = vi.fn(async () => ({ id: "default-pinned" }));
|
||||||
const registry = {
|
const registry = {
|
||||||
|
|||||||
@@ -522,6 +522,33 @@ test("retains a historical snapshot while a resumable manifest still references
|
|||||||
expect(existsSync(registry.snapshotPath(currentCommit, "psd-clinical"))).toBe(true);
|
expect(existsSync(registry.snapshotPath(currentCommit, "psd-clinical"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a session revision lease survives stale retention scans until its manifest is observed", async () => {
|
||||||
|
const remote = await fixture();
|
||||||
|
const root = join(remote.root, "registry");
|
||||||
|
const registry = new WorkspaceRegistry(config(root, remote.remote));
|
||||||
|
await registry.bootstrap();
|
||||||
|
const lease = await registry.acquireSessionRevision("psd-clinical");
|
||||||
|
|
||||||
|
writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), validYaml.replace(
|
||||||
|
"name: Policlinico San Donato", "name: Concurrent revision",
|
||||||
|
));
|
||||||
|
await git(remote.source, ["add", "workspaces/psd-clinical.yaml"]);
|
||||||
|
await git(remote.source, ["commit", "-m", "Publish while session is starting"]);
|
||||||
|
await git(remote.source, ["push", "origin", "main"]);
|
||||||
|
await registry.pull();
|
||||||
|
|
||||||
|
await registry.reconcileSnapshotRetention([]);
|
||||||
|
expect(existsSync(registry.snapshotPath(remote.initialCommit, "psd-clinical"))).toBe(true);
|
||||||
|
|
||||||
|
await lease.markPersisted();
|
||||||
|
await registry.reconcileSnapshotRetention([]);
|
||||||
|
expect(existsSync(registry.snapshotPath(remote.initialCommit, "psd-clinical"))).toBe(true);
|
||||||
|
|
||||||
|
await registry.reconcileSnapshotRetention([remote.initialCommit]);
|
||||||
|
await registry.reconcileSnapshotRetention([]);
|
||||||
|
expect(existsSync(registry.snapshotPath(remote.initialCommit, "psd-clinical"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("lists operational descriptors retained after their workspace was removed from the active revision", async () => {
|
test("lists operational descriptors retained after their workspace was removed from the active revision", async () => {
|
||||||
const remote = await fixture();
|
const remote = await fixture();
|
||||||
const root = join(remote.root, "registry");
|
const root = join(remote.root, "registry");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parse } from "yaml";
|
import { parse } from "yaml";
|
||||||
import { renderRuntimeConfig, type RuntimeBindings, type RuntimePaths } from "../src/workspaces/runtime-renderer.js";
|
import { renderRuntimeConfig, type RuntimeBindings, type RuntimePaths } from "../src/workspaces/runtime-renderer.js";
|
||||||
|
import { supportsSessionRuntime } from "../src/workspaces/bindings.js";
|
||||||
import { parseWorkspaceYaml } from "../src/workspaces/schema.js";
|
import { parseWorkspaceYaml } from "../src/workspaces/schema.js";
|
||||||
|
|
||||||
const workspace = parseWorkspaceYaml(`workspace:
|
const workspace = parseWorkspaceYaml(`workspace:
|
||||||
@@ -90,6 +91,18 @@ const directBindings: RuntimeBindings = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
test("runtime support stays fail-closed for either SSH connector", () => {
|
||||||
|
expect(supportsSessionRuntime(directBindings)).toBe(true);
|
||||||
|
expect(supportsSessionRuntime({
|
||||||
|
...directBindings,
|
||||||
|
dwh: { ...directBindings.dwh, transport: "ssh_tunnel" },
|
||||||
|
})).toBe(false);
|
||||||
|
expect(supportsSessionRuntime({
|
||||||
|
...directBindings,
|
||||||
|
vector: { ...directBindings.vector, transport: "ssh_tunnel" },
|
||||||
|
})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test("renders a direct PostgreSQL binding to the legacy harness shape", () => {
|
test("renders a direct PostgreSQL binding to the legacy harness shape", () => {
|
||||||
const yaml = renderRuntimeConfig(workspace, directBindings, paths);
|
const yaml = renderRuntimeConfig(workspace, directBindings, paths);
|
||||||
const rendered = parse(yaml);
|
const rendered = parse(yaml);
|
||||||
|
|||||||
@@ -336,7 +336,7 @@ llm_policy:
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("uses a loopback-only SSH tunnel for the bounded connector probe", async () => {
|
test("does not advertise a probe-only SSH tunnel as usable by runtime sessions", async () => {
|
||||||
const adapters = successfulAdapters();
|
const adapters = successfulAdapters();
|
||||||
const sshBindings: RuntimeBindings = {
|
const sshBindings: RuntimeBindings = {
|
||||||
...bindings,
|
...bindings,
|
||||||
@@ -360,7 +360,11 @@ test("uses a loopback-only SSH tunnel for the bounded connector probe", async ()
|
|||||||
|
|
||||||
const result = await diagnose(adapters)(workspace, sshBindings, { writeProbe: false });
|
const result = await diagnose(adapters)(workspace, sshBindings, { writeProbe: false });
|
||||||
|
|
||||||
expect(result.activatable).toBe(true);
|
expect(result.activatable).toBe(false);
|
||||||
|
expect(result.diagnostics).toContainEqual(expect.objectContaining({
|
||||||
|
level: "error",
|
||||||
|
code: "workspace_not_activatable",
|
||||||
|
}));
|
||||||
expect(adapters.withSshTunnel).toHaveBeenCalledWith(expect.objectContaining({
|
expect(adapters.withSshTunnel).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
localHost: "127.0.0.1",
|
localHost: "127.0.0.1",
|
||||||
localPort: 0,
|
localPort: 0,
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ THT_WS_PSD_CLINICAL_VECTOR_API_KEY_FILE=/run/secrets/psd-vector-api-key
|
|||||||
```
|
```
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
# SSH tunnel; host-key verification and TLS target name remain mandatory.
|
# SSH tunnel diagnostic only; runtime sessions are fail-closed in this release.
|
||||||
THT_WS_PSD_CLINICAL_DWH_TRANSPORT=ssh_tunnel
|
THT_WS_PSD_CLINICAL_DWH_TRANSPORT=ssh_tunnel
|
||||||
THT_WS_PSD_CLINICAL_DWH_USER=thoth_reader
|
THT_WS_PSD_CLINICAL_DWH_USER=thoth_reader
|
||||||
THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE=/run/secrets/psd-dwh-reader
|
THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE=/run/secrets/psd-dwh-reader
|
||||||
@@ -128,6 +128,10 @@ Repeat the SSH names for `VECTOR` where needed. REST diagnostics reject a privat
|
|||||||
rather than weakening TLS; use runtime-trusted HTTPS or verified direct/SSH native TLS. See the
|
rather than weakening TLS; use runtime-trusted HTTPS or verified direct/SSH native TLS. See the
|
||||||
[diagnostic protocol](../workspace-diagnostic-protocol.md).
|
[diagnostic protocol](../workspace-diagnostic-protocol.md).
|
||||||
|
|
||||||
|
An SSH connector can prove installation reachability, host-key verification, authentication, and
|
||||||
|
target identity, but it intentionally returns `workspace_not_activatable`; select direct or REST
|
||||||
|
before creating sessions. Git pull/push over SSH remains fully supported and is independent.
|
||||||
|
|
||||||
## Bootstrap, first pull, and diagnostics
|
## Bootstrap, first pull, and diagnostics
|
||||||
|
|
||||||
Copy [the local Compose example](examples/local-compose.workspace-registry.yaml) and exactly one
|
Copy [the local Compose example](examples/local-compose.workspace-registry.yaml) and exactly one
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ THT_WS_PSD_CLINICAL_EMBEDDING_BASE_URL=https://embeddings.internal.example
|
|||||||
```
|
```
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
# SSH tunnel requires explicit host-key verification and TLS target identity.
|
# SSH tunnel diagnostic only; runtime sessions are fail-closed in this release.
|
||||||
THT_WS_PSD_CLINICAL_DWH_TRANSPORT=ssh_tunnel
|
THT_WS_PSD_CLINICAL_DWH_TRANSPORT=ssh_tunnel
|
||||||
THT_WS_PSD_CLINICAL_DWH_USER=thoth_reader
|
THT_WS_PSD_CLINICAL_DWH_USER=thoth_reader
|
||||||
THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE=/run/secrets/psd-dwh-reader
|
THT_WS_PSD_CLINICAL_DWH_PASSWORD_FILE=/run/secrets/psd-dwh-reader
|
||||||
@@ -132,6 +132,10 @@ rather than disable verification; use runtime-trusted HTTPS or verified direct/S
|
|||||||
the [diagnostic protocol](../workspace-diagnostic-protocol.md) for its read-only checks and optional
|
the [diagnostic protocol](../workspace-diagnostic-protocol.md) for its read-only checks and optional
|
||||||
reversible writer probe.
|
reversible writer probe.
|
||||||
|
|
||||||
|
An SSH connector can be tested with strict host-key and target verification, but it intentionally
|
||||||
|
returns `workspace_not_activatable`; configure direct or REST transport before starting sessions.
|
||||||
|
The Git registry itself may still use SSH normally.
|
||||||
|
|
||||||
## Same-origin reverse proxy, bootstrap, and health
|
## Same-origin reverse proxy, bootstrap, and health
|
||||||
|
|
||||||
Copy [the server Compose example](examples/server-compose.workspace-registry.yaml) plus exactly one
|
Copy [the server Compose example](examples/server-compose.workspace-registry.yaml) plus exactly one
|
||||||
|
|||||||
@@ -227,6 +227,12 @@ The local listener is `127.0.0.1` only. The process is terminated in cleanup aft
|
|||||||
probe, on timeout, or on failure. There is no accept-new mode, no disabled host-key checking, and
|
probe, on timeout, or on failure. There is no accept-new mode, no disabled host-key checking, and
|
||||||
no persistent forwarding.
|
no persistent forwarding.
|
||||||
|
|
||||||
|
In this release, `ssh_tunnel` is therefore a diagnostic-only connector transport. A successful
|
||||||
|
probe is followed by `workspace_not_activatable`, and `POST /sessions` rejects the workspace before
|
||||||
|
persisting a manifest or starting Pi. Use direct PostgreSQL/pgvector or REST for runtime sessions
|
||||||
|
until the backend owns a tunnel for the full runtime lifecycle. This restriction does not apply to
|
||||||
|
using SSH as the transport for the workspace Git remote.
|
||||||
|
|
||||||
## Reader-only fallback
|
## Reader-only fallback
|
||||||
|
|
||||||
A workspace may be fully valid in Git but non-activatable locally when a required reader binding,
|
A workspace may be fully valid in Git but non-activatable locally when a required reader binding,
|
||||||
|
|||||||
Reference in New Issue
Block a user