fix: migrate pre-state workspace manifests

This commit is contained in:
2026-08-03 23:39:06 +02:00
parent 450d7ab07f
commit 6ab80d8a38
2 changed files with 265 additions and 21 deletions
+165 -21
View File
@@ -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);