Build workspace agent, capability graph, and versioned cutovers
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
capabilityId,
|
||||
compileWorkspaceRevision,
|
||||
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);
|
||||
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;
|
||||
}): 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 workspaceSource = await readFile(workspacePath, "utf8");
|
||||
const compiled = compileCapabilitySource(workspaceSource, workspacePath, environment);
|
||||
if (!compiled.ok) {
|
||||
throw new Error(diagnosticsMessage("Workspace", compiled.diagnostics));
|
||||
}
|
||||
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,
|
||||
])),
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user