483bc68a94
Enable evolution by default for source-backed workspaces. Add stable conformance ownership, semantic-major review, candidate typechecking, and durable fenced cutover with explicit migrations and forward recovery. Independently supervise package runtimes so unchanged resource owners keep their processes and connections across cutover. Add scoped invocation authority, resource sessions, and typed callback rebinding. Wire opaque object references through generated bindings and RPCs. Add canonical relationship sets, keyed maps, and ordered lists with scoped transactional mutations, revision checks, and inverse consistency. Support planned cascade deletion, protection, tombstones, and lifecycle foundations. Add journaled structural edits, package/function/migration scaffolding, managed repository creation, and resumable bottom-up dependency pin publication. Document lifetime boundaries, revision pinning, prototype compatibility policy, commands, and deferred work. Validate with 210 tests, user-systemd process/connection continuity, generated-package TypeScript checks, and Nix host/protocol checks. TTL handoff, physical reclamation, general multi-step migrations, and root-systemd migration isolation acceptance remain deferred.
207 lines
15 KiB
TypeScript
207 lines
15 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import type { Binding, Conformance, DependencyBinding, PersistentAttachment, WorkspaceRevision } from "./types.js";
|
|
import { validateWorkspaceRevision } from "./validation.js";
|
|
|
|
/** Content hashing is independent of JSON object insertion order, not array order. */
|
|
const compareText = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
|
|
export const canonicalJson = (value: unknown): string => {
|
|
if (value === null || typeof value === "string" || typeof value === "boolean") return JSON.stringify(value);
|
|
if (typeof value === "number" && Number.isFinite(value)) return JSON.stringify(value);
|
|
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
if (typeof value === "object" && value !== null) {
|
|
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) throw new Error("Expected a plain JSON object");
|
|
return `{${Object.entries(value).filter(([, entry]) => entry !== undefined).sort(([a], [b]) => compareText(a, b))
|
|
.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
|
|
}
|
|
throw new Error(`Cannot hash non-JSON value: ${typeof value}`);
|
|
};
|
|
export const contentDigest = (value: unknown) => `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
|
|
const semantic = (value: unknown): unknown => {
|
|
if (Array.isArray(value)) return value.map(semantic);
|
|
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value)
|
|
.filter(([key, entry]) => entry !== undefined && key !== "displayName" && key !== "documentation")
|
|
.map(([key, entry]) => [key, semantic(entry)]));
|
|
return value;
|
|
};
|
|
const sorted = <T>(entries: readonly T[], key: (entry: T) => string) => [...entries].sort((a, b) => compareText(key(a), key(b)));
|
|
export const conformanceIdentity = (entry: Conformance): string => entry.id ?? `legacy:${entry.atomId}:${entry.interfaceRevisionId}`;
|
|
|
|
export type StorageContract = { id: string; ownerId: string; kind: "state" | "edge"; digest: string; definition: unknown };
|
|
export const storageContracts = (workspace: WorkspaceRevision): StorageContract[] => {
|
|
const result: StorageContract[] = [];
|
|
const add = (attachment: PersistentAttachment, ownerId: string) => {
|
|
const definition = semantic(attachment);
|
|
result.push({ id: attachment.id, ownerId, kind: attachment.kind, definition, digest: contentDigest({ ownerId, definition }) });
|
|
};
|
|
for (const attachment of workspace.sharedAttachments) add(attachment, "legacy:workspace");
|
|
for (const conformance of workspace.conformances) for (const attachment of conformance.privateAttachments) add(attachment, conformanceIdentity(conformance));
|
|
return sorted(result, (entry) => entry.id);
|
|
};
|
|
|
|
type GraphNode = { value: unknown; dependencies: Set<string>; reviewProviders: Set<string> };
|
|
export type RuntimeContract = { groupId: string; packageId: string; packageRevisionId: string; digest: string; reviewProviders: string[]; dependencies: Array<{ id: string; digest: string }> };
|
|
|
|
/** Build only outbound execution dependencies. Incoming callers never retain or invalidate a provider. */
|
|
export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[] => {
|
|
const nodes = new Map<string, GraphNode>();
|
|
const node = (key: string, value: unknown) => {
|
|
const result = { value: semantic(value), dependencies: new Set<string>(), reviewProviders: new Set<string>() };
|
|
nodes.set(key, result);
|
|
return result;
|
|
};
|
|
const conformanceKey = (atom: string, iface: string) => `conformance:${atom}:${iface}`;
|
|
for (const storage of storageContracts(workspace)) node(`attachment:${storage.id}`, storage);
|
|
for (const iface of workspace.interfaceImports) node(`interface:${iface.revisionId}`, {
|
|
...iface, members: sorted(iface.members, (entry) => entry.id).map((entry) => ({ ...entry, operations: sorted(entry.operations, (operation) => operation.id) })),
|
|
});
|
|
for (const pkg of workspace.packageImports) node(`package:${pkg.revisionId}`, {
|
|
...pkg, semanticMajor: pkg.semanticMajor ?? 1,
|
|
exports: sorted(pkg.exports, (entry) => entry.id).map((entry) => ({ ...entry, dependencyPorts: sorted(entry.dependencyPorts, (port) => port.id) })),
|
|
});
|
|
const dependency = (parent: GraphNode, binding: DependencyBinding, atomId: string, reviews?: Set<string>) => {
|
|
if (binding.kind === "state") parent.dependencies.add(`attachment:${binding.slotId}`);
|
|
if (binding.kind === "edge") parent.dependencies.add(`attachment:${binding.edgeTypeId}`);
|
|
if (binding.kind === "constructor") {
|
|
parent.dependencies.add(`constructor:${binding.atomId}`);
|
|
const ctor = workspace.constructors.find((entry) => entry.atomId === binding.atomId);
|
|
const pkg = workspace.packageImports.find((entry) => entry.revisionId === ctor?.packageRevisionId);
|
|
if (pkg) reviews?.add(pkg.packageId);
|
|
}
|
|
if (binding.kind !== "constructor" && binding.via) parent.dependencies.add(`attachment:${binding.via.edgeTypeId}`);
|
|
if (binding.kind === "interface") {
|
|
parent.dependencies.add(`interface:${binding.interfaceRevisionId}`);
|
|
// An edge traversal may select any matching target. Conservatively include every possible witness.
|
|
for (const conformance of workspace.conformances) {
|
|
if (conformance.interfaceRevisionId === binding.interfaceRevisionId && (binding.via || conformance.atomId === atomId)) {
|
|
parent.dependencies.add(conformanceKey(conformance.atomId, conformance.interfaceRevisionId));
|
|
reviews?.add(conformanceIdentity(conformance));
|
|
for (const operation of conformance.operationBindings) {
|
|
if (operation.binding.kind !== "package") continue;
|
|
const revisionId = operation.binding.packageRevisionId;
|
|
const pkg = workspace.packageImports.find((entry) => entry.revisionId === revisionId);
|
|
if (pkg) reviews?.add(pkg.packageId);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
const binding = (parent: GraphNode, value: Binding, atomId: string, context: unknown) => {
|
|
if (value.kind !== "package") { dependency(parent, value, atomId); return; }
|
|
const packageNode = nodes.get(`package:${value.packageRevisionId}`)!;
|
|
parent.dependencies.add(`package:${value.packageRevisionId}`);
|
|
const normalized = { ...value, dependencies: sorted(value.dependencies, (entry) => entry.portId) };
|
|
const key = `binding:${contentDigest({ atomId, context, value: semantic(normalized) })}`;
|
|
const bound = node(key, { atomId, context, binding: normalized });
|
|
packageNode.dependencies.add(key);
|
|
for (const port of value.dependencies) dependency(bound, port.binding, atomId, packageNode.reviewProviders);
|
|
};
|
|
for (const conformance of workspace.conformances) {
|
|
const parent = node(conformanceKey(conformance.atomId, conformance.interfaceRevisionId), {
|
|
id: conformanceIdentity(conformance), semanticMajor: conformance.semanticMajor ?? 1,
|
|
atomId: conformance.atomId, interfaceRevisionId: conformance.interfaceRevisionId,
|
|
operations: sorted(conformance.operationBindings, (entry) => entry.operationId),
|
|
materializations: sorted(conformance.relationshipMaterializations, (entry) => entry.memberId),
|
|
});
|
|
parent.dependencies.add(`interface:${conformance.interfaceRevisionId}`);
|
|
for (const attachment of conformance.privateAttachments) parent.dependencies.add(`attachment:${attachment.id}`);
|
|
for (const operation of conformance.operationBindings) binding(parent, operation.binding, conformance.atomId, {
|
|
conformanceId: conformanceIdentity(conformance), semanticMajor: conformance.semanticMajor ?? 1, operationId: operation.operationId,
|
|
});
|
|
for (const materialization of conformance.relationshipMaterializations) {
|
|
parent.dependencies.add(`constructor:${materialization.constructorAtomId}`);
|
|
parent.dependencies.add(`attachment:${materialization.edgeTypeId}`);
|
|
}
|
|
}
|
|
for (const constructor of workspace.constructors) {
|
|
const parent = node(`constructor:${constructor.atomId}`, constructor);
|
|
binding(parent, { kind: "package", ...constructor }, constructor.atomId, { constructor: constructor.atomId });
|
|
}
|
|
const counts = new Map<string, number>();
|
|
for (const pkg of workspace.packageImports) counts.set(pkg.packageId, (counts.get(pkg.packageId) ?? 0) + 1);
|
|
return sorted(workspace.packageImports.map((pkg): RuntimeContract => {
|
|
const visited = new Set<string>();
|
|
const walk = (key: string) => {
|
|
if (visited.has(key)) return;
|
|
const entry = nodes.get(key);
|
|
if (!entry) throw new Error(`Unresolved execution dependency ${key}`);
|
|
visited.add(key);
|
|
for (const target of entry.dependencies) walk(target);
|
|
};
|
|
walk(`package:${pkg.revisionId}`);
|
|
const dependencies = [...visited].sort().map((id) => ({ id, digest: contentDigest(nodes.get(id)!.value) }));
|
|
return { groupId: counts.get(pkg.packageId) === 1 ? pkg.packageId : `${pkg.packageId}#${pkg.revisionId}`,
|
|
packageId: pkg.packageId, packageRevisionId: pkg.revisionId, digest: contentDigest(dependencies),
|
|
reviewProviders: [...nodes.get(`package:${pkg.revisionId}`)!.reviewProviders].sort(), dependencies };
|
|
}), (entry) => entry.groupId);
|
|
};
|
|
|
|
export type EvolutionReview = { requirementDigest: string; decision: "changed" | "accepted-unchanged"; rationale: string; agentId: string };
|
|
export type ReviewRequirement = { consumerId: string; providerId: string; oldMajor: number; newMajor: number; requirementDigest: string };
|
|
export type RuntimeAction = { groupId: string; action: "keep" | "start" | "replace" | "retire"; previous?: RuntimeContract; candidate?: RuntimeContract; reasons: string[] };
|
|
export type EvolutionReport = {
|
|
schemaVersion: 1; baselineDigest: string | null; candidateDigest: string; checkerVersion: string;
|
|
runtimeActions: RuntimeAction[];
|
|
storageChanges: Array<{ id: string; kind: "add" | "remove" | "change"; previous?: StorageContract; candidate?: StorageContract }>;
|
|
reviews: Array<ReviewRequirement & { accepted: boolean }>;
|
|
packageChecks: Array<{ groupId: string; contractDigest: string }>;
|
|
blockers: string[];
|
|
};
|
|
|
|
export const planEvolution = (baseline: WorkspaceRevision | null, candidate: WorkspaceRevision,
|
|
options: { reviews?: EvolutionReview[]; allowLegacy?: boolean } = {}): EvolutionReport => {
|
|
const issues = validateWorkspaceRevision(candidate);
|
|
if (issues.length) throw new Error(`Invalid candidate workspace:\n${issues.map((entry) => `${entry.path}: ${entry.message}`).join("\n")}`);
|
|
if (baseline && baseline.workspaceId !== candidate.workspaceId) throw new Error("Cannot evolve a different workspace");
|
|
const candidateDigest = contentDigest(candidate);
|
|
const checkerVersion = "quixos-evolution-v1";
|
|
const blockers: string[] = [];
|
|
if (!options.allowLegacy) {
|
|
if (candidate.sharedAttachments.length) blockers.push("Assign legacy workspace-shared attachments to explicit conformance owners");
|
|
for (const entry of candidate.conformances) if (!entry.id) blockers.push(`Conformance ${entry.atomId} as ${entry.interfaceRevisionId} requires an authored ID`);
|
|
}
|
|
const previousRuntimes = new Map((baseline ? runtimeContracts(baseline) : []).map((entry) => [entry.groupId, entry]));
|
|
const nextRuntimes = new Map(runtimeContracts(candidate).map((entry) => [entry.groupId, entry]));
|
|
const runtimeActions: RuntimeAction[] = [...new Set([...previousRuntimes.keys(), ...nextRuntimes.keys()])].sort().map((groupId) => {
|
|
const previous = previousRuntimes.get(groupId), next = nextRuntimes.get(groupId);
|
|
const before = new Map(previous?.dependencies.map((entry) => [entry.id, entry.digest]));
|
|
const after = new Map(next?.dependencies.map((entry) => [entry.id, entry.digest]));
|
|
const reasons = [...new Set([...before.keys(), ...after.keys()])].sort().filter((id) => before.get(id) !== after.get(id));
|
|
return { groupId, action: !previous ? "start" : !next ? "retire" : previous.digest === next.digest ? "keep" : "replace",
|
|
...(previous ? { previous } : {}), ...(next ? { candidate: next } : {}), reasons };
|
|
});
|
|
const beforeStorage = new Map((baseline ? storageContracts(baseline) : []).map((entry) => [entry.id, entry]));
|
|
const afterStorage = new Map(storageContracts(candidate).map((entry) => [entry.id, entry]));
|
|
const storageChanges: EvolutionReport["storageChanges"] = [];
|
|
for (const id of [...new Set([...beforeStorage.keys(), ...afterStorage.keys()])].sort()) {
|
|
const previous = beforeStorage.get(id), next = afterStorage.get(id);
|
|
if (previous?.digest !== next?.digest) storageChanges.push({ id, kind: !previous ? "add" : !next ? "remove" : "change",
|
|
...(previous ? { previous } : {}), ...(next ? { candidate: next } : {}) });
|
|
}
|
|
const providers = (workspace: WorkspaceRevision) => [
|
|
...workspace.packageImports.map((entry) => ({ id: entry.packageId as string, revision: entry.revisionId as string, major: entry.semanticMajor ?? 1,
|
|
node: `package:${entry.revisionId}`, digest: contentDigest(entry) })),
|
|
...workspace.conformances.map((entry) => ({ id: conformanceIdentity(entry), revision: contentDigest(entry), major: entry.semanticMajor ?? 1,
|
|
node: `conformance:${entry.atomId}:${entry.interfaceRevisionId}`, digest: contentDigest(entry) })),
|
|
];
|
|
const oldProviders = baseline ? providers(baseline) : [];
|
|
const reviews: EvolutionReport["reviews"] = [];
|
|
for (const provider of providers(candidate)) {
|
|
const old = oldProviders.filter((entry) => entry.id === provider.id);
|
|
if (old.length > 1) { blockers.push(`Ambiguous semantic-major lineage for ${provider.id}`); continue; }
|
|
if (!old[0] || old[0].major === provider.major) continue;
|
|
if (provider.major < old[0].major) blockers.push(`Semantic major decreases for ${provider.id}`);
|
|
for (const consumer of nextRuntimes.values()) {
|
|
if (consumer.packageId === provider.id || !consumer.reviewProviders.includes(provider.id)) continue;
|
|
const requirement = { consumerId: consumer.groupId, providerId: provider.id, oldMajor: old[0].major, newMajor: provider.major };
|
|
const requirementDigest = contentDigest({ ...requirement, oldProvider: old[0].digest, newProvider: provider.digest, consumer: consumer.digest, checkerVersion });
|
|
const accepted = (options.reviews ?? []).some((entry) => entry.requirementDigest === requirementDigest &&
|
|
["changed", "accepted-unchanged"].includes(entry.decision) && entry.rationale.trim() && entry.agentId.trim());
|
|
reviews.push({ ...requirement, requirementDigest, accepted });
|
|
if (!accepted) blockers.push(`Semantic-major review required: ${consumer.groupId} consumes ${provider.id}`);
|
|
}
|
|
}
|
|
return { schemaVersion: 1, baselineDigest: baseline ? contentDigest(baseline) : null, candidateDigest, checkerVersion,
|
|
runtimeActions, storageChanges, reviews, packageChecks: runtimeActions.filter((entry) => entry.candidate && entry.action !== "keep")
|
|
.map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })), blockers };
|
|
};
|