fix: migrate pre-state workspace manifests
This commit is contained in:
@@ -42,6 +42,17 @@ interface SnapshotManifest extends ActiveState {
|
||||
files: Record<string, string>;
|
||||
}
|
||||
|
||||
type LegacyWorkspaceRevision = Omit<WorkspaceRevision, "state">;
|
||||
|
||||
interface LegacyActiveState {
|
||||
head: string;
|
||||
revisions: LegacyWorkspaceRevision[];
|
||||
}
|
||||
|
||||
interface LegacySnapshotManifest extends LegacyActiveState {
|
||||
files: Record<string, string>;
|
||||
}
|
||||
|
||||
function workspacePath(id: string): string {
|
||||
if (!/^[a-z][a-z0-9-]{2,62}$/.test(id)) {
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace ID is invalid");
|
||||
@@ -185,7 +196,7 @@ export class WorkspaceRegistry {
|
||||
state: snapshot.state,
|
||||
}));
|
||||
if (this.pathExists(snapshotDirectory)) {
|
||||
await this.assertSnapshotIntegrity({ head: safeHead, revisions });
|
||||
await this.assertOrMigrateSnapshotIntegrity({ head: safeHead, revisions });
|
||||
} else {
|
||||
const staging = join(this.repository.snapshotsPath, `.staging-${randomUUID()}`);
|
||||
await mkdir(staging, { mode: 0o700 });
|
||||
@@ -242,7 +253,11 @@ export class WorkspaceRegistry {
|
||||
private async tryActiveState(): Promise<ActiveState | undefined> {
|
||||
const file = join(this.repository.statePath, "active.json");
|
||||
try {
|
||||
const state = JSON.parse(await readFile(file, "utf8")) as ActiveState;
|
||||
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);
|
||||
await this.assertSnapshotIntegrity(state);
|
||||
return state;
|
||||
@@ -260,6 +275,13 @@ export class WorkspaceRegistry {
|
||||
await rename(staging, target);
|
||||
}
|
||||
|
||||
private async writeSnapshotManifest(directory: string, manifest: SnapshotManifest): Promise<void> {
|
||||
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 assertActiveState(state: ActiveState): void {
|
||||
safeCommit(state.head);
|
||||
if (!Array.isArray(state.revisions) || state.revisions.length === 0) throw new Error("bad state");
|
||||
@@ -277,38 +299,151 @@ export class WorkspaceRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
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<string>();
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async migrateLegacyActiveState(legacy: LegacyActiveState): Promise<ActiveState> {
|
||||
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<ActiveState> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
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 async readSnapshotManifest(head: string): Promise<unknown> {
|
||||
const path = join(this.repository.snapshotsPath, head, "snapshot.json");
|
||||
return JSON.parse(await readFile(path, "utf8"));
|
||||
}
|
||||
|
||||
private async assertSnapshotIntegrity(state: ActiveState): Promise<void> {
|
||||
const directory = join(this.repository.snapshotsPath, state.head);
|
||||
const manifestPath = join(directory, "snapshot.json");
|
||||
try {
|
||||
const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as SnapshotManifest;
|
||||
const manifest = await this.readSnapshotManifest(state.head) as SnapshotManifest;
|
||||
this.assertActiveState(manifest);
|
||||
if (manifest.head !== state.head || !this.sameRevisions(manifest.revisions, state.revisions)) {
|
||||
throw new Error("manifest revisions do not match active state");
|
||||
}
|
||||
const expected = state.revisions.flatMap((revision) => revision.state === "operational"
|
||||
? [`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`]
|
||||
: [`${revision.id}.yaml`]);
|
||||
if (Object.keys(manifest.files).length !== expected.length || !expected.every((name) => (
|
||||
/^[0-9a-f]{64}$/.test(manifest.files[name] ?? "")
|
||||
))) throw new Error("manifest files are invalid");
|
||||
for (const name of expected) {
|
||||
const path = join(directory, name);
|
||||
const entry = lstatSync(path);
|
||||
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("snapshot file is invalid");
|
||||
const contents = await readFile(path);
|
||||
if (digest(contents) !== manifest.files[name]) throw new Error("snapshot file does not match manifest");
|
||||
if (name.endsWith(".yaml")) {
|
||||
const workspace = parseWorkspaceYaml(contents.toString("utf8"));
|
||||
if (workspace.workspace.id !== name.slice(0, -".yaml".length)) throw new Error("snapshot workspace is invalid");
|
||||
}
|
||||
}
|
||||
await this.assertManifestFiles(directory, manifest.files, this.expectedSnapshotFiles(state));
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceRegistryError) throw error;
|
||||
throw new WorkspaceRegistryError("workspace_invalid", "Workspace snapshot integrity check failed");
|
||||
}
|
||||
}
|
||||
|
||||
private expectedSnapshotFiles(state: ActiveState): string[] {
|
||||
return state.revisions.flatMap((revision) => revision.state === "operational"
|
||||
? [`${revision.id}.yaml`, `${revision.id}.env.example`, `${revision.id}.md`]
|
||||
: [`${revision.id}.yaml`]);
|
||||
}
|
||||
|
||||
private async assertManifestFiles(
|
||||
directory: string,
|
||||
files: Record<string, string>,
|
||||
expected: string[],
|
||||
): Promise<void> {
|
||||
if (!files || typeof files !== "object" || Object.keys(files).length !== expected.length || !expected.every((name) => (
|
||||
/^[0-9a-f]{64}$/.test(files[name] ?? "")
|
||||
))) throw new Error("manifest files are invalid");
|
||||
for (const name of expected) {
|
||||
const path = join(directory, name);
|
||||
const entry = lstatSync(path);
|
||||
if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("snapshot file is invalid");
|
||||
const contents = await readFile(path);
|
||||
if (digest(contents) !== files[name]) throw new Error("snapshot file does not match manifest");
|
||||
if (name.endsWith(".yaml")) {
|
||||
const workspace = parseWorkspaceYaml(contents.toString("utf8"));
|
||||
if (workspace.workspace.id !== name.slice(0, -".yaml".length)) {
|
||||
throw new Error("snapshot workspace is invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sameRevisions(left: WorkspaceRevision[], right: WorkspaceRevision[]): boolean {
|
||||
return left.length === right.length && left.every((revision, index) => {
|
||||
const candidate = right[index];
|
||||
@@ -319,6 +454,15 @@ export class WorkspaceRegistry {
|
||||
});
|
||||
}
|
||||
|
||||
private sameLegacyRevisions(left: LegacyWorkspaceRevision[], 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;
|
||||
});
|
||||
}
|
||||
|
||||
private pathExists(path: string): boolean {
|
||||
try {
|
||||
const entry = lstatSync(path);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync,
|
||||
} from "node:fs";
|
||||
@@ -91,6 +92,35 @@ async function pushInvalidWorkspace(source: string): Promise<void> {
|
||||
await git(source, ["push", "origin", "main"]);
|
||||
}
|
||||
|
||||
function legacyDigest(contents: string): string {
|
||||
return createHash("sha256").update(contents).digest("hex");
|
||||
}
|
||||
|
||||
function persistPreStateManifest(root: string, commit: string): void {
|
||||
const snapshotDirectory = join(root, "snapshots", commit);
|
||||
const activePath = join(root, "state", "active.json");
|
||||
const snapshotPath = join(snapshotDirectory, "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";
|
||||
|
||||
writeFileSync(join(snapshotDirectory, envName), envExample);
|
||||
writeFileSync(join(snapshotDirectory, docsName), markdown);
|
||||
active.revisions = active.revisions.map(({ state: _state, ...revision }: Record<string, unknown>) => revision);
|
||||
manifest.revisions = manifest.revisions.map(({ state: _state, ...revision }: Record<string, unknown>) => revision);
|
||||
manifest.files = {
|
||||
"psd-clinical.yaml": manifest.files["psd-clinical.yaml"],
|
||||
[envName]: legacyDigest(envExample),
|
||||
[docsName]: legacyDigest(markdown),
|
||||
};
|
||||
writeFileSync(activePath, JSON.stringify(active));
|
||||
chmodSync(snapshotPath, 0o600);
|
||||
writeFileSync(snapshotPath, JSON.stringify(manifest));
|
||||
}
|
||||
|
||||
test("bootstraps a checkout and activates a validated immutable snapshot", async () => {
|
||||
const remote = await fixture();
|
||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
@@ -123,6 +153,76 @@ test("lists a v1 descriptor in migration-required state without rendering operat
|
||||
expect(existsSync(join(remote.root, "registry", "snapshots", status.head!, "psd-clinical.md"))).toBe(false);
|
||||
});
|
||||
|
||||
test("migrates a validated pre-state manifest and keeps its v1 workspace migration-gated", async () => {
|
||||
const legacyYaml = validYaml.replace(
|
||||
" database: postgres\n schema: vectors\n",
|
||||
"",
|
||||
).replace("schema_version: 2", "schema_version: 1");
|
||||
const remote = await fixture(legacyYaml);
|
||||
const root = join(remote.root, "registry");
|
||||
const firstRegistry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await firstRegistry.bootstrap();
|
||||
persistPreStateManifest(root, remote.initialCommit);
|
||||
|
||||
const restoredRegistry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await expect(restoredRegistry.bootstrap()).resolves.toMatchObject({
|
||||
head: remote.initialCommit,
|
||||
degraded: false,
|
||||
});
|
||||
await expect(restoredRegistry.list()).resolves.toMatchObject([
|
||||
{ id: "psd-clinical", state: "migration_required" },
|
||||
]);
|
||||
|
||||
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("migration_required");
|
||||
expect(manifest.revisions[0].state).toBe("migration_required");
|
||||
expect(Object.keys(manifest.files)).toEqual(["psd-clinical.yaml"]);
|
||||
});
|
||||
|
||||
test("finishes a pre-state active manifest migration after its snapshot was atomically updated", async () => {
|
||||
const legacyYaml = validYaml.replace(
|
||||
" database: postgres\n schema: vectors\n",
|
||||
"",
|
||||
).replace("schema_version: 2", "schema_version: 1");
|
||||
const remote = await fixture(legacyYaml);
|
||||
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.revisions[0].state = "migration_required";
|
||||
manifest.files = { "psd-clinical.yaml": manifest.files["psd-clinical.yaml"] };
|
||||
writeFileSync(snapshotPath, JSON.stringify(manifest));
|
||||
|
||||
const restoredRegistry = new WorkspaceRegistry(config(root, remote.remote));
|
||||
await expect(restoredRegistry.list()).resolves.toMatchObject([
|
||||
{ id: "psd-clinical", state: "migration_required" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("rejects a corrupt pre-state manifest rather than accepting it during migration", async () => {
|
||||
const legacyYaml = validYaml.replace(
|
||||
" database: postgres\n schema: vectors\n",
|
||||
"",
|
||||
).replace("schema_version: 2", "schema_version: 1");
|
||||
const remote = await fixture(legacyYaml);
|
||||
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));
|
||||
|
||||
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" });
|
||||
});
|
||||
|
||||
test("keeps the last valid snapshot when a pulled commit has invalid YAML", async () => {
|
||||
const remote = await fixture();
|
||||
const registry = new WorkspaceRegistry(config(join(remote.root, "registry"), remote.remote));
|
||||
|
||||
Reference in New Issue
Block a user