refactor: remove workspace revision state
This commit is contained in:
@@ -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<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");
|
||||
@@ -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<CanonicalWorkspace | undefined> {
|
||||
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<void> {
|
||||
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<string, string>();
|
||||
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<ActiveState | undefined> {
|
||||
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<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 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<string, unknown>);
|
||||
if (entries.some(([, contentsDigest]) => typeof contentsDigest !== "string")) {
|
||||
throw new Error("bad manifest files");
|
||||
}
|
||||
return { ...state, files: Object.fromEntries(entries) as Record<string, string> };
|
||||
}
|
||||
|
||||
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<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 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<string, unknown>;
|
||||
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<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");
|
||||
}
|
||||
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<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);
|
||||
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<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("bad state");
|
||||
const record = value as Record<string, unknown>;
|
||||
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<unknown> {
|
||||
@@ -758,14 +683,7 @@ export class WorkspaceRegistry {
|
||||
}
|
||||
|
||||
private async snapshotState(head: string): Promise<ActiveState> {
|
||||
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<void> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user