402 lines
18 KiB
TypeScript
402 lines
18 KiB
TypeScript
import type {
|
|
WorkspaceRevision,
|
|
ExecutionContract,
|
|
ValueType,
|
|
Binding,
|
|
DependencyBinding,
|
|
EdgeTraversal,
|
|
InterfaceRevisionId,
|
|
AtomId,
|
|
OperationId,
|
|
} from "../capability-model/types.js";
|
|
import { validateWorkspaceRevision } from "../capability-model/validation.js";
|
|
import { canonicalJson, contentDigest } from "../capability-model/evolution.js";
|
|
import { resolveForwardedBinding } from "../capability-model/forwarding.js";
|
|
import { linkQueries, type LinkedQuery } from "../query/link.js";
|
|
import { type BindingSchema } from "../bindings/index.js";
|
|
|
|
export class ExecutionPlanError extends Error {
|
|
constructor(
|
|
readonly code: string,
|
|
readonly path: string,
|
|
message: string,
|
|
) {
|
|
super(`${code} ${path}: ${message}`);
|
|
this.name = "ExecutionPlanError";
|
|
}
|
|
}
|
|
export const hexDigest = (value: unknown): string => contentDigest(value).slice("sha256:".length);
|
|
const sorted = <T>(values: readonly T[], key: (value: T) => string): T[] =>
|
|
[...values].sort((a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
|
|
export const publicOperationKey = (atom: string, iface: string, operation: string): string =>
|
|
JSON.stringify([atom, iface, operation]);
|
|
export type DataDependency =
|
|
| { kind: "state"; slot: string; via?: EdgeTraversal }
|
|
| { kind: "edge"; edge: string; projection: string; via?: EdgeTraversal }
|
|
| { kind: "operation"; operation: string; port: string; via?: EdgeTraversal }
|
|
| { kind: "query"; query: string; port: string };
|
|
export interface PlannedOperation {
|
|
key: string;
|
|
atomId: string;
|
|
interfaceRevisionId: string;
|
|
operationId: string;
|
|
scope: "object" | "class";
|
|
execution: ExecutionContract;
|
|
input: ValueType;
|
|
output: ValueType;
|
|
/** Concrete target after public forwarding. Never chosen by a runtime caller. */
|
|
binding: Exclude<Binding, { kind: "interface" }>;
|
|
dependencies: DataDependency[];
|
|
}
|
|
export interface PlannedQuery {
|
|
id: string;
|
|
query: LinkedQuery;
|
|
/** Query-selected getter entrypoints; dependency traversal supplies canonical inputs. */
|
|
operations: string[];
|
|
/** Local query roots that admission must support, in addition to selected wire outputs. */
|
|
admittedOperations: string[];
|
|
}
|
|
export interface PlanPackage {
|
|
revision: string;
|
|
source: WorkspaceRevision["packageImports"][number]["source"];
|
|
sourceDigest: string;
|
|
bindingDigest: string;
|
|
exports: { id: string; execution: ExecutionContract }[];
|
|
}
|
|
export interface ExecutionPlanDefinition {
|
|
codec: "camino-execution-plan-v1";
|
|
workspace: WorkspaceRevision;
|
|
bindingSchema: BindingSchema;
|
|
packages: PlanPackage[];
|
|
owners: { id: string; atom: string; interface: string; initialization: unknown; storage: unknown }[];
|
|
operations: PlannedOperation[];
|
|
queries: PlannedQuery[];
|
|
profiles: { slot: string; profile: string }[];
|
|
versions: {
|
|
value: "camino-value-v1";
|
|
reference: "camino-ref-v1";
|
|
ports: "quixos-transaction-ports-v1";
|
|
crdt: "camino-automerge-3.4.1-structured-v1";
|
|
};
|
|
}
|
|
export interface ExecutionPlan {
|
|
definition: ExecutionPlanDefinition;
|
|
digest: string;
|
|
}
|
|
export const executionVersions: ExecutionPlanDefinition["versions"] = {
|
|
value: "camino-value-v1",
|
|
reference: "camino-ref-v1",
|
|
ports: "quixos-transaction-ports-v1",
|
|
crdt: "camino-automerge-3.4.1-structured-v1",
|
|
};
|
|
/** Selection only: the coordinator separately verifies the caller's grant and receiver witness. */
|
|
export function resolvePlannedOperation(
|
|
plan: ExecutionPlan,
|
|
atom: string,
|
|
iface: string,
|
|
operation: string,
|
|
): PlannedOperation {
|
|
const key = publicOperationKey(atom, iface, operation);
|
|
const selected = plan.definition.operations.find((entry) => entry.key === key);
|
|
if (!selected) throw new ExecutionPlanError("UNINSTALLED_OPERATION", key, "No installed public operation binding");
|
|
return selected;
|
|
}
|
|
|
|
/** Bound argument/state codec subset. A parsed type alone does not mean a runtime implements it. */
|
|
function checkValue(type: ValueType, path: string, allowChange = false): void {
|
|
switch (type.kind) {
|
|
case "scalar":
|
|
case "object-ref":
|
|
return;
|
|
case "builtin":
|
|
if (type.name === "unit") return;
|
|
break;
|
|
case "optional":
|
|
case "list":
|
|
checkValue(type.value, path, allowChange);
|
|
return;
|
|
case "record":
|
|
for (const [name, field] of Object.entries(type.fields)) checkValue(field, `${path}.${name}`, allowChange);
|
|
return;
|
|
case "change":
|
|
if (allowChange) {
|
|
checkValue(type.value, path);
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
throw new ExecutionPlanError("UNSUPPORTED_PORTABLE_CODEC", path, `Portable execution does not support ${type.kind}`);
|
|
}
|
|
|
|
/** Compile declarations into a closed dispatch/dependency graph before artifact selection. */
|
|
export function compileExecutionPlan(input: WorkspaceRevision): ExecutionPlan {
|
|
const workspace = structuredClone(input);
|
|
const issues = validateWorkspaceRevision(workspace);
|
|
if (issues.length)
|
|
throw new ExecutionPlanError(
|
|
"INVALID_WORKSPACE",
|
|
workspace.id,
|
|
issues.map((i) => `${i.path}: ${i.message}`).join("\n"),
|
|
);
|
|
const interfaces = new Map(workspace.interfaceImports.map((i) => [i.revisionId, i]));
|
|
const packages = new Map(workspace.packageImports.map((p) => [p.revisionId, p]));
|
|
const attachments = [...workspace.sharedAttachments, ...workspace.conformances.flatMap((c) => c.privateAttachments)];
|
|
for (const pkg of workspace.packageImports)
|
|
for (const entry of pkg.exports)
|
|
if (entry.execution && entry.execution.kind !== "remote") {
|
|
checkValue(entry.inputType, `${pkg.revisionId}/${entry.id}/input`);
|
|
checkValue(entry.outputType, `${pkg.revisionId}/${entry.id}/output`);
|
|
}
|
|
for (const attachment of attachments)
|
|
if (attachment.kind === "state" && attachment.storagePolicy.kind === "crdt-document")
|
|
checkValue(attachment.valueType, attachment.id);
|
|
const operationMap = new Map<string, PlannedOperation>();
|
|
const queries = linkQueries(workspace);
|
|
const queryKey = (pkg: string, id: string) => JSON.stringify([pkg, id]);
|
|
const queryMap = new Map(queries.map((q) => [queryKey(q.packageRevisionId, q.checked.declaration.id), q]));
|
|
const declaration = (iface: InterfaceRevisionId, operation: OperationId) =>
|
|
interfaces
|
|
.get(iface)
|
|
?.members.flatMap((m) => m.operations)
|
|
.find((o) => o.id === operation);
|
|
const queryOperations = (query: LinkedQuery) =>
|
|
query.fields.map((f) => publicOperationKey(f.atomId, f.interfaceRevisionId, f.getter));
|
|
const targetAtoms = (binding: DependencyBinding, atom: AtomId): AtomId[] => {
|
|
if (!("via" in binding) || !binding.via) return [atom];
|
|
const edge = attachments.find((a) => a.id === binding.via!.edgeTypeId);
|
|
if (!edge || edge.kind !== "edge") throw new ExecutionPlanError("MISSING_EDGE", atom, "Missing traversal edge");
|
|
const endpoint = edge.endpoints.find((e) => e.projectionId !== binding.via!.projectionId)!;
|
|
const constraint = endpoint.constraint;
|
|
return workspace.atoms
|
|
.filter((a) =>
|
|
constraint.kind === "atom"
|
|
? constraint.atomId === a.id
|
|
: workspace.conformances.some(
|
|
(c) => c.atomId === a.id && c.interfaceRevisionId === constraint.interfaceRevisionId,
|
|
),
|
|
)
|
|
.map((a) => a.id);
|
|
};
|
|
const add = (atom: AtomId, iface: InterfaceRevisionId, operation: OperationId): PlannedOperation => {
|
|
const key = publicOperationKey(atom, iface, operation),
|
|
existing = operationMap.get(key);
|
|
if (existing) return existing;
|
|
const publicContract = declaration(iface, operation);
|
|
if (!publicContract) throw new ExecutionPlanError("MISSING_OPERATION", key, "Missing public contract");
|
|
const { binding } = resolveForwardedBinding(workspace, atom, iface, operation);
|
|
const planned: PlannedOperation = {
|
|
key,
|
|
atomId: atom,
|
|
interfaceRevisionId: iface,
|
|
operationId: operation,
|
|
scope: publicContract.scope === "class" ? "class" : "object",
|
|
execution: publicContract.execution ?? { kind: "remote" },
|
|
input: publicContract.inputType,
|
|
output: publicContract.outputType,
|
|
binding,
|
|
dependencies: [],
|
|
};
|
|
operationMap.set(key, planned);
|
|
const portable = planned.execution.kind !== "remote";
|
|
if (portable) {
|
|
checkValue(planned.input, `${key}/input`, binding.kind === "state" && binding.primitive === "edit");
|
|
checkValue(planned.output, `${key}/output`);
|
|
}
|
|
if (binding.kind === "state") planned.dependencies.push({ kind: "state", slot: binding.slotId });
|
|
else if (binding.kind === "edge")
|
|
planned.dependencies.push({ kind: "edge", edge: binding.edgeTypeId, projection: binding.projectionId });
|
|
else {
|
|
const entry = packages.get(binding.packageRevisionId)?.exports.find((e) => e.id === binding.exportId);
|
|
if (!entry) throw new ExecutionPlanError("MISSING_EXPORT", key, "Unresolved package export");
|
|
for (const dependency of binding.dependencies) {
|
|
const port = entry.dependencyPorts.find((p) => p.id === dependency.portId)!;
|
|
const target = dependency.binding;
|
|
const via = "via" in target && target.via ? { via: target.via } : {};
|
|
if ("via" in target && target.via)
|
|
planned.dependencies.push({ kind: "edge", edge: target.via.edgeTypeId, projection: target.via.projectionId });
|
|
if (target.kind === "state") planned.dependencies.push({ kind: "state", slot: target.slotId, ...via });
|
|
else if (target.kind === "edge")
|
|
planned.dependencies.push({ kind: "edge", edge: target.edgeTypeId, projection: target.projectionId, ...via });
|
|
else if (target.kind === "interface" && port.requirement.kind === "interface") {
|
|
const selected =
|
|
port.requirement.operations ??
|
|
interfaces.get(target.interfaceRevisionId)!.members.flatMap((m) => m.operations.map((o) => o.id));
|
|
for (const targetAtom of targetAtoms(target, atom))
|
|
for (const selectedOperation of selected) {
|
|
const operation = add(targetAtom, target.interfaceRevisionId, selectedOperation);
|
|
planned.dependencies.push({ kind: "operation", operation: operation.key, port: port.id, ...via });
|
|
}
|
|
} else if (target.kind === "query") {
|
|
const query = queryMap.get(queryKey(target.packageRevisionId, target.queryId));
|
|
if (!query) throw new ExecutionPlanError("MISSING_QUERY", key, `Unresolved query port ${port.id}`);
|
|
planned.dependencies.push({
|
|
kind: "query",
|
|
query: queryKey(target.packageRevisionId, target.queryId),
|
|
port: port.id,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
planned.dependencies = sorted(planned.dependencies, canonicalJson);
|
|
return planned;
|
|
};
|
|
for (const conformance of workspace.conformances)
|
|
for (const entry of conformance.operationBindings)
|
|
add(conformance.atomId, conformance.interfaceRevisionId, entry.operationId);
|
|
// Check the transitive closure, retaining a dependency path for diagnostics.
|
|
for (const root of operationMap.values()) {
|
|
if (root.execution.kind === "remote") continue;
|
|
const requested = root.execution,
|
|
visited = new Set<string>();
|
|
const walk = (key: string, path: string[]) => {
|
|
if (visited.has(key)) return;
|
|
visited.add(key);
|
|
const node = operationMap.get(key)!;
|
|
if (node.execution.kind === "remote" || (requested.kind === "snapshot" && node.execution.kind !== "snapshot"))
|
|
throw new ExecutionPlanError(
|
|
"NONPORTABLE_DEPENDENCY",
|
|
path.join(" -> "),
|
|
"Execution effect is not allowed by the root contract",
|
|
);
|
|
for (const target of requested.targets)
|
|
if (!node.execution.targets.includes(target))
|
|
throw new ExecutionPlanError(
|
|
"MISSING_EXECUTION_TARGET",
|
|
path.join(" -> "),
|
|
`Dependency does not provide ${target}`,
|
|
);
|
|
for (const dependency of node.dependencies) {
|
|
if (dependency.kind === "operation")
|
|
walk(dependency.operation, [...path, dependency.port, dependency.operation]);
|
|
if (dependency.kind === "query") {
|
|
// An admitted presentation window is not an authoritative mutation read set.
|
|
if (requested.kind === "transaction")
|
|
throw new ExecutionPlanError(
|
|
"QUERY_IN_TRANSACTION",
|
|
[...path, dependency.port].join(" -> "),
|
|
"Capture explicit object references as mutation inputs; local presentation queries cannot define transaction membership",
|
|
);
|
|
const query = queryMap.get(dependency.query)!;
|
|
if (query.checked.declaration.placement?.kind !== "local")
|
|
throw new ExecutionPlanError(
|
|
"REMOTE_QUERY_DEPENDENCY",
|
|
path.join(" -> "),
|
|
"Portable code requires a local query",
|
|
);
|
|
for (const operation of queryOperations(query)) walk(operation, [...path, dependency.port, operation]);
|
|
}
|
|
}
|
|
};
|
|
walk(root.key, [root.key]);
|
|
}
|
|
const plannedQueries: PlannedQuery[] = queries.map((query) => ({
|
|
id: queryKey(query.packageRevisionId, query.checked.declaration.id),
|
|
query,
|
|
operations: queryOperations(query).sort(),
|
|
admittedOperations: [],
|
|
}));
|
|
for (const query of plannedQueries) {
|
|
const placement = query.query.checked.declaration.placement;
|
|
if (placement?.kind !== "local") continue;
|
|
const source = plannedQueries.find(
|
|
(q) => q.id === queryKey(query.query.packageRevisionId, placement.sourceQueryId),
|
|
);
|
|
if (!source || source.query.checked.declaration.placement?.kind !== "replica")
|
|
throw new ExecutionPlanError("MISSING_REPLICA_SOURCE", query.id, "Local query source must be a replica query");
|
|
const localRoot = query.query.checked.declaration.root,
|
|
sourceRoot = source.query.checked.declaration.root;
|
|
if (localRoot.kind !== sourceRoot.kind || localRoot.interface !== sourceRoot.interface)
|
|
throw new ExecutionPlanError(
|
|
"REPLICA_ROOT_MISMATCH",
|
|
query.id,
|
|
"Local and replica queries must use the same root contract",
|
|
);
|
|
const traversals = (linked: LinkedQuery) => {
|
|
const paths = new Set<string>();
|
|
const visit = (selections: LinkedQuery["checked"]["selection"], parents: string[]) => {
|
|
for (const selection of selections) {
|
|
const member = selection.interfaceRevisionId
|
|
? interfaces.get(selection.interfaceRevisionId)?.members.find((m) => m.id === selection.memberId)
|
|
: undefined;
|
|
const path = member?.kind === "relationship" ? [...parents, member.id] : parents;
|
|
if (member?.kind === "relationship") paths.add(JSON.stringify(path));
|
|
visit(selection.selection, path);
|
|
}
|
|
};
|
|
visit(linked.checked.selection, []);
|
|
return paths;
|
|
};
|
|
const admittedTraversals = traversals(source.query);
|
|
for (const traversal of traversals(query.query))
|
|
if (!admittedTraversals.has(traversal))
|
|
throw new ExecutionPlanError(
|
|
"REPLICA_TRAVERSAL_MISSING",
|
|
query.id,
|
|
`Replica source does not admit relationship path ${traversal}`,
|
|
);
|
|
// The admission executor follows these canonical dependencies on the admitted roots.
|
|
// They do not change source candidate membership or make local take/filters authoritative.
|
|
source.admittedOperations.push(...query.operations);
|
|
for (const operation of query.operations) {
|
|
const node = operationMap.get(operation)!;
|
|
if (node.execution.kind !== "snapshot" || !node.execution.targets.includes("browser"))
|
|
throw new ExecutionPlanError(
|
|
"NONPORTABLE_QUERY_FIELD",
|
|
`${query.id}/${operation}`,
|
|
"Local query getter must provide browser snapshot execution",
|
|
);
|
|
}
|
|
}
|
|
for (const query of plannedQueries) query.admittedOperations = [...new Set(query.admittedOperations)].sort();
|
|
const schema: BindingSchema = {
|
|
format: "quixos-bindings",
|
|
version: 1,
|
|
interfaces: sorted(
|
|
workspace.interfaceImports.filter((i) => !i.template),
|
|
(i) => i.revisionId,
|
|
),
|
|
interfaceTemplates: sorted(
|
|
workspace.interfaceImports.filter((i) => i.template),
|
|
(i) => i.revisionId,
|
|
),
|
|
packages: sorted(workspace.packageImports, (p) => p.revisionId),
|
|
};
|
|
const normalizedSchema = JSON.parse(canonicalJson(schema)) as BindingSchema;
|
|
const definition: ExecutionPlanDefinition = {
|
|
codec: "camino-execution-plan-v1",
|
|
workspace,
|
|
bindingSchema: normalizedSchema,
|
|
packages: sorted(workspace.packageImports, (p) => p.revisionId).map((p) => ({
|
|
revision: p.revisionId,
|
|
source: p.source,
|
|
sourceDigest: hexDigest({ revision: p.revisionId, source: p.source }),
|
|
bindingDigest: hexDigest(normalizedSchema),
|
|
exports: sorted(p.exports, (e) => e.id).map((e) => ({ id: e.id, execution: e.execution ?? { kind: "remote" } })),
|
|
})),
|
|
owners: sorted(workspace.conformances, (c) => c.id ?? "").map((c) => {
|
|
if (!c.id)
|
|
throw new ExecutionPlanError(
|
|
"OWNER_ID_REQUIRED",
|
|
`${c.atomId}/${c.interfaceRevisionId}`,
|
|
"Installed state owners require explicit stable identities",
|
|
);
|
|
return {
|
|
id: c.id,
|
|
atom: c.atomId,
|
|
interface: c.interfaceRevisionId,
|
|
initialization: c.initialization ?? [],
|
|
storage: c.privateAttachments,
|
|
};
|
|
}),
|
|
operations: sorted([...operationMap.values()], (o) => o.key),
|
|
queries: sorted(plannedQueries, (q) => q.id),
|
|
profiles: sorted(
|
|
attachments.filter((a) => a.kind === "state" && a.storagePolicy.kind === "crdt-document"),
|
|
(a) => a.id,
|
|
).map((a) => ({ slot: a.id, profile: "camino-automerge-3.4.1-structured-v1" })),
|
|
versions: { ...executionVersions },
|
|
};
|
|
const normalized = JSON.parse(canonicalJson(definition)) as ExecutionPlanDefinition;
|
|
return { definition: normalized, digest: hexDigest(normalized) };
|
|
}
|