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.
387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
import { readFile, realpath } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { loadQxSources, resolveQxSources } from "./source-loader.js";
|
|
import {
|
|
capabilityId,
|
|
compileWorkspaceRevision,
|
|
validateMigrationCatalog,
|
|
contentDigest,
|
|
type AtomDefinition,
|
|
type CompiledWorkspaceRevision,
|
|
type InterfaceRevision,
|
|
type PackageRevision,
|
|
type SourceRevision,
|
|
type WorkspaceRevision,
|
|
} from "../capability-model/index.js";
|
|
import {
|
|
loadQuixosLock,
|
|
type GitSource,
|
|
type LockedResource,
|
|
type QuixosRepositoryLock,
|
|
} from "../resource-lock/index.js";
|
|
import {
|
|
compileCapabilityResourceSource,
|
|
compileCapabilitySource,
|
|
type CapabilityImportEnvironment,
|
|
type CapabilityResource,
|
|
type CapabilityResourceImport,
|
|
} from "./parser.js";
|
|
|
|
export type CapabilityRepositorySnapshot = {
|
|
directory: string;
|
|
};
|
|
|
|
export type CapabilityRepositoryResolver = (
|
|
source: GitSource,
|
|
kind: LockedResource["kind"],
|
|
) => Promise<CapabilityRepositorySnapshot>;
|
|
|
|
export type ResolvedCapabilityResource = {
|
|
key: string;
|
|
kind: LockedResource["kind"];
|
|
source: GitSource;
|
|
directory: string;
|
|
lock: QuixosRepositoryLock;
|
|
resource: CapabilityResource;
|
|
dependencies: ReadonlyMap<string, ResolvedCapabilityResource>;
|
|
};
|
|
|
|
export type CompiledWorkspaceRepository = {
|
|
workspace: WorkspaceRevision;
|
|
plan: CompiledWorkspaceRevision;
|
|
lock: QuixosRepositoryLock;
|
|
resources: ResolvedCapabilityResource[];
|
|
directResources: ReadonlyMap<string, ResolvedCapabilityResource>;
|
|
};
|
|
|
|
export type CompiledCapabilityResourceRepository = {
|
|
resource: CapabilityResource;
|
|
lock: QuixosRepositoryLock;
|
|
resources: ResolvedCapabilityResource[];
|
|
directResources: ReadonlyMap<string, ResolvedCapabilityResource>;
|
|
};
|
|
|
|
const sourceKey = (kind: LockedResource["kind"], source: GitSource) =>
|
|
`${kind}\0${source.repository}\0${source.commit.toLowerCase()}`;
|
|
|
|
const bindingKey = (kind: LockedResource["kind"], binding: string) =>
|
|
`${kind}\0${binding}`;
|
|
|
|
const importsKey = (entry: CapabilityResourceImport | LockedResource) =>
|
|
bindingKey(entry.kind, entry.binding);
|
|
|
|
const assertImportsMatchLock = (
|
|
label: string,
|
|
imports: readonly CapabilityResourceImport[],
|
|
resources: readonly LockedResource[],
|
|
) => {
|
|
const authored = [...imports].map(importsKey).sort();
|
|
const locked = [...resources].map(importsKey).sort();
|
|
if (
|
|
authored.length !== locked.length ||
|
|
authored.some((entry, index) => entry !== locked[index])
|
|
) {
|
|
throw new Error(
|
|
`${label} imports do not match quixos.lock:\n` +
|
|
`authored: ${authored.join(", ") || "none"}\n` +
|
|
`locked: ${locked.join(", ") || "none"}`,
|
|
);
|
|
}
|
|
};
|
|
|
|
const exactRevisions = <Revision extends {
|
|
revisionId: string;
|
|
source: SourceRevision;
|
|
}>(revisions: readonly Revision[]) => {
|
|
const seen = new Set<string>();
|
|
return revisions.filter((revision) => {
|
|
const key = `${revision.revisionId}\0${revision.source.repository}\0${revision.source.commit}`;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key);
|
|
return true;
|
|
});
|
|
};
|
|
|
|
const exactAtoms = (atoms: readonly AtomDefinition[]) => {
|
|
const seen = new Set<string>();
|
|
return atoms.filter((atom) => {
|
|
if (seen.has(atom.id)) return false;
|
|
seen.add(atom.id);
|
|
return true;
|
|
});
|
|
};
|
|
|
|
const diagnosticsMessage = (
|
|
label: string,
|
|
diagnostics: readonly {
|
|
fileName: string;
|
|
line: number;
|
|
column: number;
|
|
phase: string;
|
|
code: string;
|
|
message: string;
|
|
path?: string;
|
|
}[],
|
|
) => `${label} did not compile:\n${diagnostics.map((entry) => {
|
|
const location = entry.line > 0
|
|
? `${entry.fileName}:${entry.line}:${entry.column + 1}`
|
|
: `${entry.fileName}${entry.path ? `:${entry.path}` : ""}`;
|
|
return `${location}: ${entry.phase} ${entry.code}: ${entry.message}`;
|
|
}).join("\n")}`;
|
|
|
|
const revisionFor = (node: ResolvedCapabilityResource) =>
|
|
node.resource.revision;
|
|
|
|
const resourceClosure = (
|
|
roots: readonly ResolvedCapabilityResource[],
|
|
): ResolvedCapabilityResource[] => {
|
|
const result: ResolvedCapabilityResource[] = [];
|
|
const seen = new Set<string>();
|
|
const visit = (node: ResolvedCapabilityResource) => {
|
|
if (seen.has(node.key)) return;
|
|
seen.add(node.key);
|
|
for (const dependency of node.dependencies.values()) visit(dependency);
|
|
result.push(node);
|
|
};
|
|
for (const root of roots) visit(root);
|
|
return result;
|
|
};
|
|
|
|
const environmentFor = (
|
|
direct: readonly [LockedResource, ResolvedCapabilityResource][],
|
|
closure: readonly ResolvedCapabilityResource[],
|
|
): CapabilityImportEnvironment => ({
|
|
interfaces: new Map(direct.flatMap(([locked, node]) =>
|
|
node.resource.kind === "interface"
|
|
? [[locked.binding, node.resource.revision] as const]
|
|
: [])),
|
|
packages: new Map(direct.flatMap(([locked, node]) =>
|
|
node.resource.kind === "package"
|
|
? [[locked.binding, node.resource.revision] as const]
|
|
: [])),
|
|
interfaceClosure: exactRevisions(closure.flatMap((node) =>
|
|
node.resource.kind === "interface" ? [node.resource.revision] : [])),
|
|
packageClosure: exactRevisions(closure.flatMap((node) =>
|
|
node.resource.kind === "package" ? [node.resource.revision] : [])),
|
|
externalAtoms: exactAtoms(closure.flatMap((node) => node.resource.externalAtoms)),
|
|
});
|
|
|
|
const createResourceGraphResolver = (
|
|
quixosCommit: string,
|
|
resolveResource: CapabilityRepositoryResolver,
|
|
) => {
|
|
const resolved = new Map<string, Promise<ResolvedCapabilityResource>>();
|
|
const active: string[] = [];
|
|
|
|
const visit = async (
|
|
locked: LockedResource,
|
|
suppliedSnapshot?: CapabilityRepositorySnapshot,
|
|
): Promise<ResolvedCapabilityResource> => {
|
|
const key = sourceKey(locked.kind, locked.source);
|
|
const cached = resolved.get(key);
|
|
if (cached) {
|
|
if (active.includes(key)) {
|
|
throw new Error(`Resource dependency cycle: ${[...active, key].join(" -> ")}`);
|
|
}
|
|
return await cached;
|
|
}
|
|
const pending = (async () => {
|
|
active.push(key);
|
|
try {
|
|
const snapshot = suppliedSnapshot ?? await resolveResource(locked.source, locked.kind);
|
|
const lockResult = await loadQuixosLock(path.join(snapshot.directory, "quixos.lock"));
|
|
if (!lockResult.ok) {
|
|
throw new Error(diagnosticsMessage(`${locked.kind} ${locked.binding} lock`, lockResult.diagnostics));
|
|
}
|
|
if (lockResult.lock.quixos.policy) {
|
|
throw new Error(
|
|
`${locked.kind} ${locked.binding} lock declares workspace Quixos policy ` +
|
|
`${lockResult.lock.quixos.policy}; resource locks may only declare their exact authored-against commit`,
|
|
);
|
|
}
|
|
if (lockResult.lock.quixos.commit.toLowerCase() !== quixosCommit.toLowerCase()) {
|
|
throw new Error(
|
|
`${locked.kind} ${locked.binding} selects Quixos ${lockResult.lock.quixos.commit}, ` +
|
|
`but the repository graph selects ${quixosCommit}`,
|
|
);
|
|
}
|
|
const directPairs: Array<[LockedResource, ResolvedCapabilityResource]> = [];
|
|
for (const dependency of lockResult.lock.resources) {
|
|
directPairs.push([dependency, await visit(dependency)]);
|
|
}
|
|
const dependencyClosure = resourceClosure(
|
|
directPairs.map(([, node]) => node),
|
|
);
|
|
const environment = environmentFor(directPairs, dependencyClosure);
|
|
const manifestName = locked.kind === "interface" ? "interface.qx" : "package.qx";
|
|
const manifestPath = path.join(snapshot.directory, manifestName);
|
|
const sourceText = await readFile(manifestPath, "utf8");
|
|
const compiled = compileCapabilityResourceSource(sourceText, {
|
|
source: locked.source,
|
|
fileName: manifestPath,
|
|
environment,
|
|
});
|
|
if (!compiled.ok) {
|
|
throw new Error(diagnosticsMessage(`${locked.kind} ${locked.binding}`, compiled.diagnostics));
|
|
}
|
|
if (compiled.resource.kind !== locked.kind) {
|
|
throw new Error(
|
|
`${manifestPath} declares ${compiled.resource.kind}, not ${locked.kind}`,
|
|
);
|
|
}
|
|
assertImportsMatchLock(manifestPath, compiled.resource.imports, lockResult.lock.resources);
|
|
if (compiled.resource.kind === "package") {
|
|
let catalogText: string | undefined;
|
|
try { catalogText = await readFile(path.join(snapshot.directory, "quixos.migrations.json"), "utf8"); }
|
|
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
if (catalogText !== undefined) {
|
|
const catalog = validateMigrationCatalog(JSON.parse(catalogText), new Set(compiled.resource.revision.exports.map((entry) => entry.id)));
|
|
const root = await realpath(snapshot.directory);
|
|
for (const migration of catalog.migrations) {
|
|
const implementation = await realpath(path.join(root, migration.implementation.file));
|
|
if (!implementation.startsWith(`${root}${path.sep}`)) throw new Error("Migration implementation escapes its package");
|
|
if (contentDigest(await readFile(implementation, "utf8")) !== migration.implementation.digest) throw new Error(`Migration implementation digest mismatch: ${migration.id}`);
|
|
}
|
|
compiled.resource.revision.migrationCatalog = catalog;
|
|
}
|
|
}
|
|
return {
|
|
key,
|
|
kind: locked.kind,
|
|
source: locked.source,
|
|
directory: snapshot.directory,
|
|
lock: lockResult.lock,
|
|
resource: compiled.resource,
|
|
dependencies: new Map(directPairs.map(([dependency, node]) => [
|
|
bindingKey(dependency.kind, dependency.binding),
|
|
node,
|
|
])),
|
|
};
|
|
} finally {
|
|
active.pop();
|
|
}
|
|
})();
|
|
resolved.set(key, pending);
|
|
return await pending;
|
|
};
|
|
|
|
return {
|
|
visit,
|
|
nodes: async () => await Promise.all(resolved.values()),
|
|
};
|
|
};
|
|
|
|
export const compileCapabilityResourceRepository = async (options: {
|
|
rootDirectory: string;
|
|
kind: LockedResource["kind"];
|
|
source: GitSource;
|
|
resolveResource: CapabilityRepositoryResolver;
|
|
}): Promise<CompiledCapabilityResourceRepository> => {
|
|
const lockResult = await loadQuixosLock(path.join(options.rootDirectory, "quixos.lock"));
|
|
if (!lockResult.ok) {
|
|
throw new Error(diagnosticsMessage("Resource lock", lockResult.diagnostics));
|
|
}
|
|
const resolver = createResourceGraphResolver(
|
|
lockResult.lock.quixos.commit,
|
|
options.resolveResource,
|
|
);
|
|
const root = await resolver.visit({
|
|
kind: options.kind,
|
|
binding: "<root>",
|
|
source: options.source,
|
|
}, { directory: options.rootDirectory });
|
|
return {
|
|
resource: root.resource,
|
|
lock: root.lock,
|
|
resources: await resolver.nodes(),
|
|
directResources: root.dependencies,
|
|
};
|
|
};
|
|
|
|
export const compileWorkspaceRepository = async (options: {
|
|
rootDirectory: string;
|
|
resolveResource: CapabilityRepositoryResolver;
|
|
workspaceId?: string;
|
|
workspaceRevisionId?: string;
|
|
sourceRootCommit?: string;
|
|
/** An editor's proposed source snapshot; locks and dependency revisions remain exact. */
|
|
readSource?: (name: string) => Promise<string>;
|
|
}): Promise<CompiledWorkspaceRepository> => {
|
|
const rootLockResult = await loadQuixosLock(
|
|
path.join(options.rootDirectory, "quixos.lock"),
|
|
);
|
|
if (!rootLockResult.ok) {
|
|
throw new Error(diagnosticsMessage("Workspace lock", rootLockResult.diagnostics));
|
|
}
|
|
const rootLock = rootLockResult.lock;
|
|
const resolver = createResourceGraphResolver(rootLock.quixos.commit, options.resolveResource);
|
|
|
|
const directPairs: Array<[LockedResource, ResolvedCapabilityResource]> = [];
|
|
for (const locked of rootLock.resources) {
|
|
directPairs.push([locked, await resolver.visit(locked)]);
|
|
}
|
|
const nodes = await resolver.nodes();
|
|
const environment = environmentFor(directPairs, nodes);
|
|
const workspacePath = path.join(options.rootDirectory, "workspace.qx");
|
|
const sources = options.readSource ? await resolveQxSources(options.readSource) : await loadQxSources(options.rootDirectory);
|
|
const compiled = compileCapabilitySource(sources.source, workspacePath, environment);
|
|
if (!compiled.ok) {
|
|
throw new Error(diagnosticsMessage("Workspace", compiled.diagnostics.map((diagnostic) =>
|
|
diagnostic.line > 0 ? { ...diagnostic, ...sources.originalPosition(diagnostic.line, diagnostic.column) } : diagnostic)));
|
|
}
|
|
assertImportsMatchLock(workspacePath, compiled.imports, rootLock.resources);
|
|
const availableInterfaceIds = new Set(
|
|
nodes.flatMap((node) => node.resource.kind === "interface"
|
|
? [node.resource.revision.revisionId]
|
|
: []),
|
|
);
|
|
const availableAtomIds = new Set(compiled.workspace.atoms.map((atom) => atom.id));
|
|
for (const node of nodes) {
|
|
for (const requirement of node.resource.externalInterfaces) {
|
|
if (!availableInterfaceIds.has(requirement.revisionId)) {
|
|
throw new Error(
|
|
`${node.kind} ${node.resource.revision.displayName} requires external interface ` +
|
|
`${requirement.binding} (${requirement.revisionId}), but the workspace resource graph does not provide it`,
|
|
);
|
|
}
|
|
}
|
|
for (const atom of node.resource.externalAtoms) {
|
|
if (!availableAtomIds.has(atom.id)) {
|
|
throw new Error(
|
|
`${node.kind} ${node.resource.revision.displayName} requires external atom ` +
|
|
`${atom.displayName} (${atom.id}), but the workspace does not define it`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
const workspace: WorkspaceRevision = {
|
|
...compiled.workspace,
|
|
...(options.workspaceId
|
|
? { workspaceId: capabilityId.workspace(options.workspaceId) }
|
|
: {}),
|
|
...(options.workspaceRevisionId
|
|
? { id: capabilityId.workspaceRevision(options.workspaceRevisionId) }
|
|
: {}),
|
|
...(options.sourceRootCommit
|
|
? { sourceRootCommit: options.sourceRootCommit }
|
|
: {}),
|
|
};
|
|
const checked = compileWorkspaceRevision(workspace);
|
|
if (!checked.ok) {
|
|
throw new Error(
|
|
`Instantiated workspace did not compile:\n${checked.issues.map((entry) =>
|
|
`${entry.path}: ${entry.message}`).join("\n")}`,
|
|
);
|
|
}
|
|
return {
|
|
workspace,
|
|
plan: checked.plan,
|
|
lock: rootLock,
|
|
resources: nodes,
|
|
directResources: new Map(directPairs.map(([locked, node]) => [
|
|
bindingKey(locked.kind, locked.binding),
|
|
node,
|
|
])),
|
|
};
|
|
};
|