diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index d0da061b..4830c2c6 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -154,7 +154,6 @@ export function sessionRoutes( throw registryError; } for (const revision of revisions) { - if (revision.state !== "operational") continue; try { const manifest = await runner.sessionShow(id, revision.snapshotPath); if (manifest) return { manifest, workspaceConfigPath: revision.snapshotPath }; @@ -339,12 +338,6 @@ export function sessionRoutes( if ("markPersisted" in resolved && "abort" in resolved) { revisionLease = resolved as Awaited>; } - 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.", @@ -481,7 +474,6 @@ export function sessionRoutes( const runner = runnerFor(scopedPrincipal); const revisions = await sessionRevisions(); const lists = await Promise.all(revisions - .filter((revision) => revision.state === "operational") .map((revision) => runner.sessionList(revision.snapshotPath) as Promise)); const sessions = new Map(); for (const row of lists.flat()) { diff --git a/backend/src/routes/sql.ts b/backend/src/routes/sql.ts index 67b529f1..a5e48554 100644 --- a/backend/src/routes/sql.ts +++ b/backend/src/routes/sql.ts @@ -24,7 +24,6 @@ export function sqlRoutes(app: FastifyInstance, deps: { ? await registry.listRetainedSnapshots.call(deps.workspaceRegistry) : await deps.workspaceRegistry.list(); for (const revision of revisions) { - if (revision.state !== "operational") continue; try { const manifest = await runner.sessionShow(id, revision.snapshotPath); if (!manifest) continue; diff --git a/backend/src/routes/workspaces.ts b/backend/src/routes/workspaces.ts index 5aa5a638..8e778ddb 100644 --- a/backend/src/routes/workspaces.ts +++ b/backend/src/routes/workspaces.ts @@ -339,12 +339,7 @@ export function workspaceRoutes(app: FastifyInstance, deps: WorkspaceRoutesDeps) app.post("/workspaces/:id/test", async (request, reply) => { try { const { id } = z.object({ id: workspaceId }).parse(request.params); - const { workspace, revision } = await deps.registry.read(id); - if (revision.state !== "operational") { - throw new WorkspaceRegistryError( - "workspace_not_activatable", "Workspace requires explicit migration", - ); - } + const { workspace } = await deps.registry.read(id); let operational: CanonicalWorkspace; try { operational = validateOperationalWorkspace(workspace); diff --git a/backend/src/workspaces/registry.ts b/backend/src/workspaces/registry.ts index 71df45bb..2eda9570 100644 --- a/backend/src/workspaces/registry.ts +++ b/backend/src/workspaces/registry.ts @@ -10,7 +10,6 @@ import { type GitStatus, } from "./git-repository.js"; import { - isCanonicalWorkspace, parseWorkspaceYaml, serializeWorkspaceYaml, validateOperationalWorkspace, @@ -26,7 +25,6 @@ export interface WorkspaceRevision { commit: string; blob: string; snapshotPath: string; - state: "operational" | "migration_required"; } export interface SessionRevisionLease { @@ -74,17 +72,6 @@ interface RevisionLeaseRecord { state: "creating" | "persisted"; } -type LegacyWorkspaceRevision = Omit; - -interface LegacyActiveState { - head: string; - revisions: LegacyWorkspaceRevision[]; -} - -interface LegacySnapshotManifest extends LegacyActiveState { - files: Record; -} - function workspacePath(id: string): string { if (!/^[a-z][a-z0-9-]{2,62}$/.test(id)) { throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID is invalid"); @@ -181,7 +168,7 @@ export class WorkspaceRegistry { if (!entry.isDirectory() || entry.isSymbolicLink() || !/^[0-9a-f]{40}$/.test(entry.name)) continue; if (entry.name === active.head) continue; const state = await this.snapshotState(entry.name); - revisions.push(...state.revisions.filter((revision) => revision.state === "operational")); + revisions.push(...state.revisions); } return revisions; } catch (error) { @@ -212,9 +199,6 @@ export class WorkspaceRegistry { const state = await this.activeState(); const revision = state.revisions.find((candidate) => candidate.id === id); if (!revision) throw new WorkspaceRegistryError("workspace_invalid", "Workspace is unavailable"); - if (revision.state !== "operational") { - throw new WorkspaceRegistryError("workspace_invalid", "Workspace is unavailable"); - } let workspace: WorkspaceDescriptor; try { workspace = validateOperationalWorkspace( @@ -437,8 +421,7 @@ export class WorkspaceRegistry { const base = await this.readSnapshotCanonical(request.baseCommit, id); let remote: CanonicalWorkspace | undefined; if (existing) { - const read = await this.read(id); - remote = isCanonicalWorkspace(read.workspace) ? read.workspace : undefined; + remote = (await this.read(id)).workspace; } return new WorkspaceConflictError( this.changedFields(base, remote), @@ -453,8 +436,7 @@ export class WorkspaceRegistry { private async readSnapshotCanonical(commit: string, id: string): Promise { try { const source = await readFile(this.snapshotPath(commit, id), "utf8"); - const workspace = parseWorkspaceYaml(source); - return isCanonicalWorkspace(workspace) ? workspace : undefined; + return parseWorkspaceYaml(source); } catch { return undefined; } @@ -482,7 +464,6 @@ export class WorkspaceRegistry { } private async assertEvidenceContext(workspace: WorkspaceDescriptor, revision: string): Promise { - if (!isCanonicalWorkspace(workspace)) return; if (workspace.evidence?.source.type !== "filesystem") return; // P6 owns recursive containment. Here we deliberately validate only the declared root object. await this.repository.assertTreeAtRevision(revision, workspace.evidence.source.uri); @@ -504,7 +485,6 @@ export class WorkspaceRegistry { source: string; workspace: WorkspaceDescriptor; blob: string; - state: WorkspaceRevision["state"]; }> = []; const collectionOwners = new Map(); try { @@ -516,27 +496,19 @@ export class WorkspaceRegistry { throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID does not match its repository path"); } await this.assertEvidenceContext(workspace, safeHead); - let snapshotSource = source; - const state: WorkspaceRevision["state"] = isCanonicalWorkspace(workspace) - ? "operational" - : "migration_required"; - if (isCanonicalWorkspace(workspace)) { - const collection = workspace.semantic_index.vector_store.collection; - const owner = collectionOwners.get(collection); - if (owner !== undefined) { - throw new Error(`duplicate qdrant collection ownership: ${collection} (${owner}, ${id})`); - } - collectionOwners.set(collection, id); - buildInstallationContract(workspace); - renderWorkspaceDocs(workspace); - snapshotSource = serializeWorkspaceYaml(workspace); + const collection = workspace.semantic_index.vector_store.collection; + const owner = collectionOwners.get(collection); + if (owner !== undefined) { + throw new Error(`duplicate qdrant collection ownership: ${collection} (${owner}, ${id})`); } + collectionOwners.set(collection, id); + buildInstallationContract(workspace); + renderWorkspaceDocs(workspace); snapshots.push({ id, - source: snapshotSource, + source: serializeWorkspaceYaml(workspace), workspace, blob: await this.repository.blob(path), - state, }); } } catch (error) { @@ -549,10 +521,9 @@ export class WorkspaceRegistry { commit: safeHead, blob: snapshot.blob, snapshotPath: this.snapshotPath(safeHead, snapshot.id), - state: snapshot.state, })); if (this.pathExists(snapshotDirectory)) { - await this.assertOrMigrateSnapshotIntegrity({ head: safeHead, revisions }); + await this.assertSnapshotIntegrity({ head: safeHead, revisions }); } else { const staging = join(this.repository.snapshotsPath, `.staging-${randomUUID()}`); await mkdir(staging, { mode: 0o700 }); @@ -564,13 +535,11 @@ export class WorkspaceRegistry { const docsName = `${snapshot.id}.md`; await writeFile(join(staging, yamlName), snapshot.source, { encoding: "utf8", mode: 0o400 }); files[yamlName] = digest(snapshot.source); - if (snapshot.state === "operational") { - const docs = renderWorkspaceDocs(snapshot.workspace); - await writeFile(join(staging, envName), docs.envExample, { encoding: "utf8", mode: 0o400 }); - await writeFile(join(staging, docsName), docs.markdown, { encoding: "utf8", mode: 0o400 }); - files[envName] = digest(docs.envExample); - files[docsName] = digest(docs.markdown); - } + const docs = renderWorkspaceDocs(snapshot.workspace); + await writeFile(join(staging, envName), docs.envExample, { encoding: "utf8", mode: 0o400 }); + await writeFile(join(staging, docsName), docs.markdown, { encoding: "utf8", mode: 0o400 }); + files[envName] = digest(docs.envExample); + files[docsName] = digest(docs.markdown); } await writeFile(join(staging, "snapshot.json"), JSON.stringify({ head: safeHead, revisions, files }), { encoding: "utf8", mode: 0o400, @@ -609,12 +578,7 @@ export class WorkspaceRegistry { private async tryActiveState(): Promise { const file = join(this.repository.statePath, "active.json"); try { - const parsed: unknown = JSON.parse(await readFile(file, "utf8")); - if (this.isLegacyActiveState(parsed)) { - return await this.migrateLegacyActiveState(parsed); - } - const state = parsed as ActiveState; - this.assertActiveState(state); + const state = this.decodeActiveState(JSON.parse(await readFile(file, "utf8"))); await this.assertSnapshotIntegrity(state); return state; } catch (error) { @@ -631,125 +595,86 @@ export class WorkspaceRegistry { await rename(staging, target); } - private async writeSnapshotManifest(directory: string, manifest: SnapshotManifest): Promise { - const target = join(directory, "snapshot.json"); - const staging = join(directory, `.snapshot-${randomUUID()}.json`); - await writeFile(staging, JSON.stringify(manifest), { encoding: "utf8", mode: 0o400 }); - await rename(staging, target); + private decodeActiveState(value: unknown): ActiveState { + const state = this.strictObject(value, ["head", "revisions"]); + return this.decodeStateRevisions(state.head, state.revisions); } - private assertActiveState(state: ActiveState): void { - safeCommit(state.head); - if (!Array.isArray(state.revisions)) throw new Error("bad state"); + private decodeSnapshotManifest(value: unknown): SnapshotManifest { + const manifest = this.strictObject(value, ["head", "revisions", "files"]); + const state = this.decodeStateRevisions(manifest.head, manifest.revisions); + if (!manifest.files || typeof manifest.files !== "object" || Array.isArray(manifest.files)) { + throw new Error("bad manifest files"); + } + const entries = Object.entries(manifest.files as Record); + if (entries.some(([, contentsDigest]) => typeof contentsDigest !== "string")) { + throw new Error("bad manifest files"); + } + return { ...state, files: Object.fromEntries(entries) as Record }; + } + + private decodeStateRevisions(headValue: unknown, revisionsValue: unknown): ActiveState { + if (typeof headValue !== "string" || !Array.isArray(revisionsValue)) throw new Error("bad state"); + const head = safeCommit(headValue); const ids = new Set(); - for (const revision of state.revisions) { - safeCommit(revision.commit); - safeBlob(revision.blob); - if (revision.commit !== state.head || ids.has(revision.id)) throw new Error("bad revision"); - if (revision.state !== "operational" && revision.state !== "migration_required") throw new Error("bad revision"); + const revisions = revisionsValue.map((value) => { + const revision = this.decodeRevision(value, head); + if (ids.has(revision.id)) throw new Error("duplicate revision"); ids.add(revision.id); - workspacePath(revision.id); - if (!isAbsolute(revision.snapshotPath) || revision.snapshotPath !== this.snapshotPath(revision.commit, revision.id)) { - throw new Error("bad snapshot path"); - } + return revision; + }); + return { head, revisions }; + } + + private decodeRevision(value: unknown, head: string): WorkspaceRevision { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("bad revision"); + const revision = value as Record; + const keys = Object.keys(revision); + const required = ["id", "commit", "blob", "snapshotPath"]; + const hasHistoricalState = Object.prototype.hasOwnProperty.call(revision, "state"); + if ( + keys.length !== required.length + (hasHistoricalState ? 1 : 0) + || !required.every((key) => Object.prototype.hasOwnProperty.call(revision, key)) + || (hasHistoricalState && revision.state !== "operational") + ) { + throw new Error("bad revision"); } - } - - private isLegacyActiveState(value: unknown): value is LegacyActiveState { - if (!value || typeof value !== "object") return false; - const revisions = (value as { revisions?: unknown }).revisions; - return Array.isArray(revisions) && revisions.length > 0 && revisions.every((revision) => ( - revision && typeof revision === "object" && !("state" in revision) - )); - } - - private isLegacySnapshotManifest(value: unknown): value is LegacySnapshotManifest { - return this.isLegacyActiveState(value) - && !!(value as { files?: unknown }).files - && typeof (value as { files?: unknown }).files === "object" - && !Array.isArray((value as { files?: unknown }).files); - } - - private assertLegacyActiveState(state: LegacyActiveState): void { - safeCommit(state.head); - if (!Array.isArray(state.revisions) || state.revisions.length === 0) throw new Error("bad legacy state"); - const ids = new Set(); - for (const revision of state.revisions) { - safeCommit(revision.commit); - safeBlob(revision.blob); - if (revision.commit !== state.head || ids.has(revision.id)) throw new Error("bad legacy revision"); - ids.add(revision.id); - workspacePath(revision.id); - if (!isAbsolute(revision.snapshotPath) || revision.snapshotPath !== this.snapshotPath(revision.commit, revision.id)) { - throw new Error("bad legacy snapshot path"); - } + if ( + typeof revision.id !== "string" + || typeof revision.commit !== "string" + || typeof revision.blob !== "string" + || typeof revision.snapshotPath !== "string" + ) { + throw new Error("bad revision"); } + const id = revision.id; + const commit = safeCommit(revision.commit); + const blob = safeBlob(revision.blob); + const snapshotPath = revision.snapshotPath; + workspacePath(id); + if ( + commit !== head + || !isAbsolute(snapshotPath) + || snapshotPath !== this.snapshotPath(commit, id) + ) { + throw new Error("bad revision"); + } + // Always reconstruct a fresh public revision. The sole accepted historical state field is + // compatibility input and must never cross the registry boundary. + return { id, commit, blob, snapshotPath }; } - private async migrateLegacyActiveState(legacy: LegacyActiveState): Promise { - this.assertLegacyActiveState(legacy); - const state = await this.deriveStateFromLegacyRevisions(legacy); - const manifest = await this.readSnapshotManifest(state.head); - if (this.isLegacySnapshotManifest(manifest)) { - await this.migrateLegacySnapshotManifest(state, manifest); - } else { - await this.assertSnapshotIntegrity(state); - } - await this.writeActiveState(state); - return state; - } - - private async deriveStateFromLegacyRevisions(legacy: LegacyActiveState): Promise { - const revisions: WorkspaceRevision[] = []; - for (const revision of legacy.revisions) { - const source = await readFile(revision.snapshotPath, "utf8"); - const workspace = parseWorkspaceYaml(source); - if (workspace.workspace.id !== revision.id) throw new Error("legacy snapshot workspace is invalid"); - revisions.push({ - ...revision, - state: isCanonicalWorkspace(workspace) ? "operational" : "migration_required", - }); - } - return { head: legacy.head, revisions }; - } - - private async assertOrMigrateSnapshotIntegrity(state: ActiveState): Promise { - const manifest = await this.readSnapshotManifest(state.head); - if (this.isLegacySnapshotManifest(manifest)) { - await this.migrateLegacySnapshotManifest(state, manifest); - return; - } - await this.assertSnapshotIntegrity(state); - } - - private async migrateLegacySnapshotManifest( - state: ActiveState, - suppliedManifest?: LegacySnapshotManifest, - ): Promise { - const manifest = suppliedManifest ?? await this.readSnapshotManifest(state.head); - try { - if (!this.isLegacySnapshotManifest(manifest)) throw new Error("snapshot is not pre-state"); - this.assertLegacyActiveState(manifest); - if (manifest.head !== state.head || !this.sameLegacyRevisions(manifest.revisions, state.revisions)) { - throw new Error("legacy manifest revisions do not match active state"); - } - const derived = await this.deriveStateFromLegacyRevisions(manifest); - if (!this.sameRevisions(derived.revisions, state.revisions)) { - throw new Error("legacy manifest state does not match workspace snapshots"); - } - const directory = join(this.repository.snapshotsPath, state.head); - const legacyExpected = state.revisions.flatMap((revision) => [ - `${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`, - ]); - await this.assertManifestFiles(directory, manifest.files, legacyExpected); - await this.assertSnapshotEvidenceContexts(state); - const expected = this.expectedSnapshotFiles(state); - const files = Object.fromEntries(expected.map((name) => [name, manifest.files[name]])); - await this.writeSnapshotManifest(directory, { ...state, files }); - } catch (error) { - if (error instanceof WorkspaceRegistryError) throw error; - throw new WorkspaceRegistryError("workspace_invalid", "Workspace snapshot integrity check failed"); + private strictObject(value: unknown, expectedKeys: readonly string[]): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("bad state"); + const record = value as Record; + const keys = Object.keys(record); + if ( + keys.length !== expectedKeys.length + || !expectedKeys.every((key) => Object.prototype.hasOwnProperty.call(record, key)) + ) { + throw new Error("bad state"); } + return record; } private async readSnapshotManifest(head: string): Promise { @@ -758,14 +683,7 @@ export class WorkspaceRegistry { } private async snapshotState(head: string): Promise { - const manifest = await this.readSnapshotManifest(safeCommit(head)); - if (this.isLegacySnapshotManifest(manifest)) { - const state = await this.deriveStateFromLegacyRevisions(manifest); - await this.migrateLegacySnapshotManifest(state, manifest); - return state; - } - const state = manifest as ActiveState; - this.assertActiveState(state); + const state = this.decodeSnapshotManifest(await this.readSnapshotManifest(safeCommit(head))); await this.assertSnapshotIntegrity(state); return state; } @@ -773,8 +691,7 @@ export class WorkspaceRegistry { private async assertSnapshotIntegrity(state: ActiveState): Promise { const directory = join(this.repository.snapshotsPath, state.head); try { - const manifest = await this.readSnapshotManifest(state.head) as SnapshotManifest; - this.assertActiveState(manifest); + const manifest = this.decodeSnapshotManifest(await this.readSnapshotManifest(state.head)); if (manifest.head !== state.head || !this.sameRevisions(manifest.revisions, state.revisions)) { throw new Error("manifest revisions do not match active state"); } @@ -789,9 +706,9 @@ export class WorkspaceRegistry { private expectedSnapshotFiles(state: ActiveState): string[] { // P1 snapshots only descriptors and derived public docs. P6 owns revision-pinned // workspace-content materialization and its recursive containment checks. - return state.revisions.flatMap((revision) => revision.state === "operational" - ? [`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`] - : [`${revision.id}.yaml`]); + return state.revisions.flatMap((revision) => [ + `${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`, + ]); } private async assertManifestFiles( @@ -818,16 +735,6 @@ export class WorkspaceRegistry { } private sameRevisions(left: WorkspaceRevision[], right: WorkspaceRevision[]): boolean { - return left.length === right.length && left.every((revision, index) => { - const candidate = right[index]; - return candidate !== undefined - && candidate.id === revision.id && candidate.commit === revision.commit - && candidate.blob === revision.blob && candidate.snapshotPath === revision.snapshotPath - && candidate.state === revision.state; - }); - } - - private sameLegacyRevisions(left: LegacyWorkspaceRevision[], right: WorkspaceRevision[]): boolean { return left.length === right.length && left.every((revision, index) => { const candidate = right[index]; return candidate !== undefined diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index d93b019c..50c6db3b 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -38,13 +38,13 @@ function operationalWorkspace(id = "default") { const defaultWorkspaceRegistry = { list: vi.fn(async () => [{ id: "default", commit: "e".repeat(40), blob: "f".repeat(40), - snapshotPath: `/data/workspace-registry/snapshots/${"e".repeat(40)}/default.yaml`, state: "operational", + snapshotPath: `/data/workspace-registry/snapshots/${"e".repeat(40)}/default.yaml`, }]), read: vi.fn(async (id: string) => ({ workspace: operationalWorkspace(id), revision: { id, commit: "e".repeat(40), blob: "f".repeat(40), - snapshotPath: `/data/workspace-registry/snapshots/${"e".repeat(40)}/${id}.yaml`, state: "operational", + snapshotPath: `/data/workspace-registry/snapshots/${"e".repeat(40)}/${id}.yaml`, }, })), }; @@ -306,8 +306,8 @@ test("retention scans a removed workspace's retained snapshot", async () => { const activeSnapshot = "/registry/snapshots/a/other.yaml"; const removedSnapshot = "/registry/snapshots/e/removed.yaml"; const listRetainedSnapshots = vi.fn(async () => [ - { id: "other", commit: "a".repeat(40), state: "operational", snapshotPath: activeSnapshot }, - { id: "removed", commit: removedRevision, state: "operational", snapshotPath: removedSnapshot }, + { id: "other", commit: "a".repeat(40), snapshotPath: activeSnapshot }, + { id: "removed", commit: removedRevision, snapshotPath: removedSnapshot }, ]); const app = buildApp(loadConfig({ AUTH_MODE: "upstream", THT_HARNESS_DIR: "../harness" }), { thtRunner: { @@ -318,7 +318,7 @@ test("retention scans a removed workspace's retained snapshot", async () => { }), } as any, workspaceRegistry: { - list: async () => [{ id: "other", commit: "a".repeat(40), state: "operational", snapshotPath: activeSnapshot }], + list: async () => [{ id: "other", commit: "a".repeat(40), snapshotPath: activeSnapshot }], listRetainedSnapshots, reconcileSnapshotRetention: retained, } as any, @@ -472,7 +472,7 @@ test("creates a session from the active immutable workspace revision", async () }, revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), - snapshotPath: "/data/workspace-registry/snapshots/abc/psd-clinical.yaml", state: "operational", + snapshotPath: "/data/workspace-registry/snapshots/abc/psd-clinical.yaml", }, })), } as any, @@ -524,7 +524,6 @@ test("rejects an SSH-only workspace before persisting or starting a session", as 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, @@ -552,7 +551,6 @@ test("hands a revision lease to retention only after the session manifest is dur 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, @@ -590,7 +588,7 @@ test("creates a session from the configured default workspace revision when work workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } }, revision: { id, commit: "c".repeat(40), blob: "d".repeat(40), - snapshotPath: `/data/workspace-registry/snapshots/${"c".repeat(40)}/${id}.yaml`, state: "operational", + snapshotPath: `/data/workspace-registry/snapshots/${"c".repeat(40)}/${id}.yaml`, }, })), }; @@ -676,11 +674,11 @@ test("session lifecycle locates a B session when installation default is A", asy workspaceRegistry: { read: async (id: string) => ({ workspace: { llm_policy: { allowed: ["zai/glm-5.2"] } }, - revision: { id, commit: "b".repeat(40), blob: "d".repeat(40), snapshotPath: bPath, state: "operational" }, + revision: { id, commit: "b".repeat(40), blob: "d".repeat(40), snapshotPath: bPath }, }), list: async () => [ - { id: "a-workspace", commit: "a".repeat(40), blob: "a".repeat(40), snapshotPath: aPath, state: "operational" }, - { id: "b-workspace", commit: "b".repeat(40), blob: "b".repeat(40), snapshotPath: bPath, state: "operational" }, + { id: "a-workspace", commit: "a".repeat(40), blob: "a".repeat(40), snapshotPath: aPath }, + { id: "b-workspace", commit: "b".repeat(40), blob: "b".repeat(40), snapshotPath: bPath }, ], readPinned: vi.fn(async (id: string, revision: string) => { expect([id, revision]).toEqual(["b-workspace", "c".repeat(40)]); @@ -956,7 +954,7 @@ test("POST /sessions/:id/resume uses the manifest's retained workspace revision" workspace: operationalWorkspace("psd-clinical"), revision: { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), - snapshotPath: "/data/workspace-registry/snapshots/aaaaaaaa/psd-clinical.yaml", state: "operational", + snapshotPath: "/data/workspace-registry/snapshots/aaaaaaaa/psd-clinical.yaml", }, })), } as any, @@ -1072,7 +1070,7 @@ test("a pruned pin blocks Resume but not active or mutation lifecycle routes", a } as any, workspaceRegistry: { list: async () => [{ - id: "b-workspace", commit: "a".repeat(40), blob: "a".repeat(40), snapshotPath: activePath, state: "operational", + id: "b-workspace", commit: "a".repeat(40), blob: "a".repeat(40), snapshotPath: activePath, }], readPinned, } as any, @@ -2299,7 +2297,7 @@ test("rename authorizes and mutates through the same registry snapshot", async ( } as any, workspaceRegistry: { list: async () => [{ - id: "tenant-a", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: tenantPath, state: "operational", + id: "tenant-a", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: tenantPath, }], } as any, }); @@ -2861,7 +2859,6 @@ test.each([ const revision = { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: `/data/workspace-registry/snapshots/${"a".repeat(40)}/psd-clinical.yaml`, - state: "operational" as const, }; try { diff --git a/backend/test/routes-sql-meta.test.ts b/backend/test/routes-sql-meta.test.ts index a0f6b1c0..a85e364f 100644 --- a/backend/test/routes-sql-meta.test.ts +++ b/backend/test/routes-sql-meta.test.ts @@ -108,7 +108,7 @@ test("registry-backed SQL preview resolves and uses the session's pinned runtime workspaceRegistry: { list: async () => [{ id: "psd-clinical", commit: "a".repeat(40), blob: "c".repeat(40), - snapshotPath: activePath, state: "operational", + snapshotPath: activePath, }], readPinned: async () => ({ workspace: {}, workspaceConfigPath: pinnedPath }), } as any, diff --git a/backend/test/routes-workspaces.test.ts b/backend/test/routes-workspaces.test.ts index 00e85ea5..d9aa77cc 100644 --- a/backend/test/routes-workspaces.test.ts +++ b/backend/test/routes-workspaces.test.ts @@ -114,7 +114,6 @@ const revision: WorkspaceRevision = { commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/registry/snapshots/psd-clinical.yaml", - state: "operational", }; type RegistryFake = Pick; @@ -237,6 +236,8 @@ test("lists compatible workspace summaries and reads a validated workspace", asy })]); expect(detail.statusCode).toBe(200); expect(detail.json()).toMatchObject({ workspace, revision }); + expect(list.json()[0].revision).not.toHaveProperty("state"); + expect(detail.json().revision).not.toHaveProperty("state"); }); test("validates a canonical workspace and runs the injected installation diagnostic", async () => { @@ -278,36 +279,6 @@ test.each([ expect(registry.publish).not.toHaveBeenCalled(); }); -test("rejects a migration-required v2 workspace before resolving semantic diagnostics", async () => { - const diagnose = vi.fn(async () => ({ - activatable: false, - diagnostics: [{ level: "error" as const, code: "binding_missing" as const, field: "THT_WS_PSD_CLINICAL_VECTOR_BASE_URL", message: "Installation binding is missing or invalid." }], - })); - const registry = registryFake({ - read: vi.fn(async () => ({ workspace: workspaceV2, revision: { ...revision, state: "migration_required" as const } })), - }); - const app = appFor(registry, diagnose); - - const originalEnv = { ...process.env }; - process.env.THT_WS_PSD_CLINICAL_DWH_TRANSPORT = "rest_api"; - process.env.THT_WS_PSD_CLINICAL_DWH_BASE_URL = "https://dwh.example.test"; - process.env.THT_WS_PSD_CLINICAL_VECTOR_TRANSPORT = "rest_api"; - process.env.THT_WS_PSD_CLINICAL_VECTOR_BASE_URL = "https://vector.example.test"; - process.env.THT_WS_PSD_CLINICAL_EMBEDDING_BASE_URL = "https://embedding.example.test"; - try { - const testResult = await app.inject({ method: "POST", url: "/workspaces/psd-clinical/test", payload: {} }); - - expect(testResult.statusCode).toBe(400); - expect(testResult.json()).toEqual({ - code: "workspace_not_activatable", - message: "Workspace cannot be activated on this installation.", - }); - expect(diagnose).not.toHaveBeenCalled(); - } finally { - process.env = originalEnv; - } -}); - test("runs diagnostics for a schema v3 workspace without external semantic bindings", async () => { const diagnose = vi.fn(async () => ({ activatable: true, diagnostics: [] })); const app = appFor(registryFake(), diagnose); diff --git a/backend/test/workspace-registry.test.ts b/backend/test/workspace-registry.test.ts index bf019005..68ce1bb9 100644 --- a/backend/test/workspace-registry.test.ts +++ b/backend/test/workspace-registry.test.ts @@ -312,37 +312,47 @@ async function pushInvalidWorkspace(source: string): Promise { await git(source, ["push", "origin", "main"]); } -function legacyDigest(contents: string): string { - return createHash("sha256").update(contents).digest("hex"); +type HistoricalRevisionState = "absent" | "operational" | "migration_required" | "unknown"; + +function revisionWithHistoricalState( + revision: Record, + encoding: HistoricalRevisionState, +): Record { + const { state: _state, ...stateFree } = revision; + return encoding === "absent" ? stateFree : { + ...stateFree, + state: encoding === "unknown" ? "retired" : encoding, + }; } -function persistPreStateManifest(root: string, commit: string): void { - const snapshotDirectory = join(root, "snapshots", commit); +function rewritePersistedRevisionStates( + root: string, + commit: string, + activeEncoding: HistoricalRevisionState, + manifestEncoding: HistoricalRevisionState, +): void { const activePath = join(root, "state", "active.json"); - const snapshotPath = join(snapshotDirectory, "snapshot.json"); + const snapshotPath = join(root, "snapshots", commit, "snapshot.json"); const active = JSON.parse(readFileSync(activePath, "utf8")); const manifest = JSON.parse(readFileSync(snapshotPath, "utf8")); - const envName = "psd-clinical.env.example"; - const docsName = "psd-clinical.md"; - const envExample = "# Legacy registry artifact\n"; - const markdown = "# Legacy registry artifact\n"; - - chmodSync(join(snapshotDirectory, envName), 0o600); - chmodSync(join(snapshotDirectory, docsName), 0o600); - writeFileSync(join(snapshotDirectory, envName), envExample); - writeFileSync(join(snapshotDirectory, docsName), markdown); - active.revisions = active.revisions.map(({ state: _state, ...revision }: Record) => revision); - manifest.revisions = manifest.revisions.map(({ state: _state, ...revision }: Record) => revision); - manifest.files = { - "psd-clinical.yaml": manifest.files["psd-clinical.yaml"], - [envName]: legacyDigest(envExample), - [docsName]: legacyDigest(markdown), - }; + active.revisions = active.revisions.map((revision: Record) => ( + revisionWithHistoricalState(revision, activeEncoding) + )); + manifest.revisions = manifest.revisions.map((revision: Record) => ( + revisionWithHistoricalState(revision, manifestEncoding) + )); writeFileSync(activePath, JSON.stringify(active)); chmodSync(snapshotPath, 0o600); writeFileSync(snapshotPath, JSON.stringify(manifest)); } +function persistedState(root: string, commit: string): { active: any; manifest: any } { + return { + active: JSON.parse(readFileSync(join(root, "state", "active.json"), "utf8")), + manifest: JSON.parse(readFileSync(join(root, "snapshots", commit, "snapshot.json"), "utf8")), + }; +} + test("allows first API publication and delete-last from a content-only registry base", async () => { const remote = await contentOnlyFixture(); const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote)); @@ -390,7 +400,6 @@ test("concurrent first lists lazily bootstrap a clean registry once safely", asy expect.objectContaining({ id: "psd-clinical", commit: remote.initialCommit, - state: "operational", }), ]); } @@ -706,63 +715,171 @@ test.each([ expect(existsSync(join(remote.root, "registry", "state", "active.json"))).toBe(false); }); -test("migrates a validated pre-state manifest while preserving its v3 operational state", async () => { +test("writes only state-free revisions and never exposes revision state", async () => { const remote = await fixture(); const root = join(remote.root, "registry"); - const firstRegistry = new WorkspaceRegistry(config(root, remote.remote)); - await firstRegistry.bootstrap(); - persistPreStateManifest(root, remote.initialCommit); + const registry = new WorkspaceRegistry(config(root, remote.remote)); + await registry.bootstrap(); - const restoredRegistry = new WorkspaceRegistry(config(root, remote.remote)); - await expect(restoredRegistry.bootstrap()).resolves.toMatchObject({ - head: remote.initialCommit, - degraded: false, + const initial = persistedState(root, remote.initialCommit); + expect(Object.keys(initial.active).sort()).toEqual(["head", "revisions"]); + expect(Object.keys(initial.manifest).sort()).toEqual(["files", "head", "revisions"]); + expect(Object.keys(initial.active.revisions[0]).sort()).toEqual(["blob", "commit", "id", "snapshotPath"]); + expect(Object.keys(initial.manifest.revisions[0]).sort()).toEqual(["blob", "commit", "id", "snapshotPath"]); + + const listed = await registry.list(); + const read = await registry.read("psd-clinical"); + expect(listed[0]).not.toHaveProperty("state"); + expect(read.revision).not.toHaveProperty("state"); + + const published = await registry.publish({ + action: "update", + workspace: workspaceWith("psd-clinical", { name: "State-free revision" }), + baseCommit: remote.initialCommit, + baseBlob: listed[0]!.blob, }); - await expect(restoredRegistry.list()).resolves.toMatchObject([ - { id: "psd-clinical", state: "operational" }, - ]); - - const active = JSON.parse(readFileSync(join(root, "state", "active.json"), "utf8")); - const manifest = JSON.parse(readFileSync(join(root, "snapshots", remote.initialCommit, "snapshot.json"), "utf8")); - expect(active.revisions[0].state).toBe("operational"); - expect(manifest.revisions[0].state).toBe("operational"); - expect(Object.keys(manifest.files).sort()).toEqual([ - "psd-clinical.env.example", "psd-clinical.md", "psd-clinical.yaml", - ]); + const updated = persistedState(root, published!.commit); + expect(updated.active.revisions[0]).not.toHaveProperty("state"); + expect(updated.manifest.revisions[0]).not.toHaveProperty("state"); + expect(published).not.toHaveProperty("state"); }); -test("finishes a pre-state active manifest migration after its v3 snapshot was atomically updated", async () => { +test("accepts historical operational state without leaking it or rewriting the immutable snapshot", async () => { + const remote = await fixture(); + const root = join(remote.root, "registry"); + await new WorkspaceRegistry(config(root, remote.remote)).bootstrap(); + rewritePersistedRevisionStates(root, remote.initialCommit, "operational", "operational"); + const snapshotPath = join(root, "snapshots", remote.initialCommit, "snapshot.json"); + const historicalManifest = readFileSync(snapshotPath, "utf8"); + + const restored = new WorkspaceRegistry(config(root, remote.remote)); + const listed = await restored.list(); + const read = await restored.read("psd-clinical"); + + expect(listed[0]).not.toHaveProperty("state"); + expect(read.revision).not.toHaveProperty("state"); + expect(readFileSync(snapshotPath, "utf8")).toBe(historicalManifest); + + await restored.bootstrap(); + const rewrittenActive = persistedState(root, remote.initialCommit).active; + expect(rewrittenActive.revisions[0]).not.toHaveProperty("state"); + expect(readFileSync(snapshotPath, "utf8")).toBe(historicalManifest); +}); + +test.each([ + ["historical active and state-free snapshot", "operational", "absent"], + ["state-free active and historical snapshot", "absent", "operational"], +] as const)("normalizes mixed persisted revision encodings: %s", async (_name, activeState, manifestState) => { + const remote = await fixture(); + const root = join(remote.root, "registry"); + await new WorkspaceRegistry(config(root, remote.remote)).bootstrap(); + rewritePersistedRevisionStates(root, remote.initialCommit, activeState, manifestState); + const snapshotPath = join(root, "snapshots", remote.initialCommit, "snapshot.json"); + const historicalManifest = readFileSync(snapshotPath, "utf8"); + + const revisions = await new WorkspaceRegistry(config(root, remote.remote)).list(); + + expect(revisions[0]).not.toHaveProperty("state"); + expect(readFileSync(snapshotPath, "utf8")).toBe(historicalManifest); +}); + +test.each([ + ["migration_required in active state", "migration_required", "absent"], + ["migration_required in snapshot manifest", "absent", "migration_required"], + ["unknown state in active state", "unknown", "operational"], + ["unknown state in snapshot manifest", "operational", "unknown"], +] as const)("rejects %s", async (_name, activeState, manifestState) => { + const remote = await fixture(); + const root = join(remote.root, "registry"); + await new WorkspaceRegistry(config(root, remote.remote)).bootstrap(); + rewritePersistedRevisionStates(root, remote.initialCommit, activeState, manifestState); + + await expect(new WorkspaceRegistry(config(root, remote.remote)).list()).rejects.toMatchObject({ + code: "workspace_invalid", + }); +}); + +test.each([ + ["active top level", "active", "top"], + ["active revision", "active", "revision"], + ["snapshot top level", "manifest", "top"], + ["snapshot revision", "manifest", "revision"], +] as const)("rejects unknown fields in %s", async (_name, component, location) => { + const remote = await fixture(); + const root = join(remote.root, "registry"); + await new WorkspaceRegistry(config(root, remote.remote)).bootstrap(); + const path = component === "active" + ? join(root, "state", "active.json") + : join(root, "snapshots", remote.initialCommit, "snapshot.json"); + const persisted = JSON.parse(readFileSync(path, "utf8")); + if (location === "top") persisted.unexpected = true; + else persisted.revisions[0].unexpected = true; + if (component === "manifest") chmodSync(path, 0o600); + writeFileSync(path, JSON.stringify(persisted)); + + await expect(new WorkspaceRegistry(config(root, remote.remote)).list()).rejects.toMatchObject({ + code: "workspace_invalid", + }); +}); + +test("normalizes operational state in retained historical snapshots without rewriting them", async () => { const remote = await fixture(); const root = join(remote.root, "registry"); const registry = new WorkspaceRegistry(config(root, remote.remote)); await registry.bootstrap(); - persistPreStateManifest(root, remote.initialCommit); - + writeFileSync(join(remote.source, "workspaces", "psd-clinical.yaml"), validYaml.replace( + "name: Policlinico San Donato", "name: Current workspace", + )); + await git(remote.source, ["add", "workspaces/psd-clinical.yaml"]); + await git(remote.source, ["commit", "-m", "Update active workspace"]); + await git(remote.source, ["push", "origin", "main"]); + await registry.pull(); + rewritePersistedRevisionStates(root, remote.initialCommit, "absent", "operational"); const snapshotPath = join(root, "snapshots", remote.initialCommit, "snapshot.json"); - const manifest = JSON.parse(readFileSync(snapshotPath, "utf8")); - manifest.revisions[0].state = "operational"; - writeFileSync(snapshotPath, JSON.stringify(manifest)); + const historicalManifest = readFileSync(snapshotPath, "utf8"); - const restoredRegistry = new WorkspaceRegistry(config(root, remote.remote)); - await expect(restoredRegistry.list()).resolves.toMatchObject([ - { id: "psd-clinical", state: "operational" }, - ]); + const retained = await new WorkspaceRegistry(config(root, remote.remote)).listRetainedSnapshots(); + + expect(retained).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: "psd-clinical", commit: remote.initialCommit }), + ])); + expect(retained.every((revision) => !("state" in revision))).toBe(true); + expect(readFileSync(snapshotPath, "utf8")).toBe(historicalManifest); }); -test("rejects a corrupt pre-state manifest rather than accepting it during migration", async () => { +test("normalizes historical operational state during offline fallback after restart", async () => { const remote = await fixture(); const root = join(remote.root, "registry"); - const registry = new WorkspaceRegistry(config(root, remote.remote)); - await registry.bootstrap(); - persistPreStateManifest(root, remote.initialCommit); - const snapshotPath = join(root, "snapshots", remote.initialCommit, "snapshot.json"); - const manifest = JSON.parse(readFileSync(snapshotPath, "utf8")); - manifest.files["psd-clinical.yaml"] = "0".repeat(64); - writeFileSync(snapshotPath, JSON.stringify(manifest)); + await new WorkspaceRegistry(config(root, remote.remote)).bootstrap(); + rewritePersistedRevisionStates(root, remote.initialCommit, "operational", "operational"); + rmSync(remote.remote, { recursive: true, force: true }); - const restoredRegistry = new WorkspaceRegistry(config(root, remote.remote)); - await expect(restoredRegistry.bootstrap()).rejects.toMatchObject({ code: "workspace_invalid" }); - await expect(restoredRegistry.list()).rejects.toMatchObject({ code: "workspace_invalid" }); + const restored = new WorkspaceRegistry(config(root, remote.remote)); + await expect(restored.pull()).resolves.toMatchObject({ degraded: true, head: remote.initialCommit }); + const listed = await restored.list(); + const read = await restored.read("psd-clinical"); + expect(listed[0]).not.toHaveProperty("state"); + expect(read.revision).not.toHaveProperty("state"); +}); + +test("fails closed when a retained snapshot descriptor is not schema v3", async () => { + const remote = await fixture(); + const root = join(remote.root, "registry"); + await new WorkspaceRegistry(config(root, remote.remote)).bootstrap(); + const snapshotDirectory = join(root, "snapshots", remote.initialCommit); + const yamlPath = join(snapshotDirectory, "psd-clinical.yaml"); + chmodSync(yamlPath, 0o600); + const legacy = legacyV2Yaml(); + writeFileSync(yamlPath, legacy); + const manifestPath = join(snapshotDirectory, "snapshot.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + manifest.files["psd-clinical.yaml"] = createHash("sha256").update(legacy).digest("hex"); + chmodSync(manifestPath, 0o600); + writeFileSync(manifestPath, JSON.stringify(manifest)); + + await expect(new WorkspaceRegistry(config(root, remote.remote)).list()).rejects.toMatchObject({ + code: "workspace_invalid", + }); }); test("keeps the last valid snapshot when a pulled commit has invalid YAML", async () => { @@ -889,8 +1006,8 @@ test("lists operational descriptors retained after their workspace was removed f const retained = await registry.listRetainedSnapshots(); expect(retained).toEqual(expect.arrayContaining([ - expect.objectContaining({ id: "psd-clinical", commit: remote.initialCommit, state: "operational" }), - expect.objectContaining({ id: "archive-only", state: "operational" }), + expect.objectContaining({ id: "psd-clinical", commit: remote.initialCommit }), + expect.objectContaining({ id: "archive-only" }), ])); });