Repair workspace authoring: typed RPC inputs, automatic preservation, and template releases
Use checked input contracts and protobuf JSON for CLI roundtrips; generate Web Studio platform inputs; reject invalid constructors before allocation and filter Createable eligibility. Preserve compatible storage without no-op migrations, report activation readiness, and validate migration coverage before maintenance. Follow authored package declarations during scaffold refresh and queue source capture. Add a real local TODO check/activate/create/place/edit acceptance, publish updated protocol and SDK dependencies, and make verified template default selection explicit.
This commit is contained in:
@@ -32,6 +32,7 @@
|
|||||||
export QUIXOS_PROTO_PATH="${pkgs.protobuf}/include:$PWD/proto''${QUIXOS_PROTO_PATH:+:$QUIXOS_PROTO_PATH}"
|
export QUIXOS_PROTO_PATH="${pkgs.protobuf}/include:$PWD/proto''${QUIXOS_PROTO_PATH:+:$QUIXOS_PROTO_PATH}"
|
||||||
patchShebangs node_modules/.bin node_modules/@bufbuild/protoc-gen-es/bin
|
patchShebangs node_modules/.bin node_modules/@bufbuild/protoc-gen-es/bin
|
||||||
yarn build
|
yarn build
|
||||||
|
esbuild dist/src/bindings/client.js --bundle --platform=node --target=node24 --format=esm --outfile=client-codegen.mjs
|
||||||
diff --recursive --unified "$TMPDIR/generated-before/proto" src/gen
|
diff --recursive --unified "$TMPDIR/generated-before/proto" src/gen
|
||||||
diff --recursive --unified "$TMPDIR/generated-before/capability" src/capability-language/generated
|
diff --recursive --unified "$TMPDIR/generated-before/capability" src/capability-language/generated
|
||||||
diff --recursive --unified "$TMPDIR/generated-before/lock" src/resource-lock/generated
|
diff --recursive --unified "$TMPDIR/generated-before/lock" src/resource-lock/generated
|
||||||
@@ -103,6 +104,7 @@ exec ${pkgs.nodejs_24}/bin/node "$out/libexec/quixos-protocol/quixos-lock-check.
|
|||||||
EOF
|
EOF
|
||||||
chmod +x "$out/bin/quixos-lock-check"
|
chmod +x "$out/bin/quixos-lock-check"
|
||||||
mkdir -p "$out/libexec/quixos-protocol"
|
mkdir -p "$out/libexec/quixos-protocol"
|
||||||
|
install -m644 client-codegen.mjs "$out/libexec/quixos-protocol/client-codegen.mjs"
|
||||||
install -m644 quixos-codegen-ts.mjs quixos-qx.mjs "$out/libexec/quixos-protocol/"
|
install -m644 quixos-codegen-ts.mjs quixos-qx.mjs "$out/libexec/quixos-protocol/"
|
||||||
install -m644 quixos-descriptor-check.mjs "$out/libexec/quixos-protocol/quixos-descriptor-check.mjs"
|
install -m644 quixos-descriptor-check.mjs "$out/libexec/quixos-protocol/quixos-descriptor-check.mjs"
|
||||||
install -m644 quixos-capability-compile.mjs "$out/libexec/quixos-protocol/quixos-capability-compile.mjs"
|
install -m644 quixos-capability-compile.mjs "$out/libexec/quixos-protocol/quixos-capability-compile.mjs"
|
||||||
|
|||||||
@@ -72,6 +72,20 @@ message GetWorkspaceResponse {
|
|||||||
string workspace_id = 1;
|
string workspace_id = 1;
|
||||||
string workspace_revision_id = 2;
|
string workspace_revision_id = 2;
|
||||||
string source_root_commit = 3;
|
string source_root_commit = 3;
|
||||||
|
// Checked constructors whose wire input can be empty. Web Studio intersects
|
||||||
|
// this with its temporary Createable marker; the marker is not a factory.
|
||||||
|
repeated string empty_input_constructible_atom_ids = 4;
|
||||||
|
repeated CapabilityInputContract capability_inputs = 5;
|
||||||
|
repeated ConstructorInputContract constructor_inputs = 6;
|
||||||
|
}
|
||||||
|
message CapabilityInputContract {
|
||||||
|
string interface_revision_id = 1;
|
||||||
|
string operation_id = 2;
|
||||||
|
string type_json = 3;
|
||||||
|
}
|
||||||
|
message ConstructorInputContract {
|
||||||
|
string atom_id = 1;
|
||||||
|
string type_json = 2;
|
||||||
}
|
}
|
||||||
message ListActivationsRequest {}
|
message ListActivationsRequest {}
|
||||||
message ListPackageDescriptorsRequest {}
|
message ListPackageDescriptorsRequest {}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type {InterfaceRevision, ValueType} from "../capability-model/types.js";
|
||||||
|
|
||||||
|
/** Host clients have no package receiver, but must use the same checked
|
||||||
|
* interface signatures and argument framing as generated package ports. */
|
||||||
|
export const generateClientContracts = (interfaces: InterfaceRevision[], messages: Record<string, string>) => {
|
||||||
|
const type = (value: ValueType): string => {
|
||||||
|
switch (value.kind) {
|
||||||
|
case "builtin": return value.name === "unit" ? "undefined" : "string";
|
||||||
|
case "scalar": return ({bool: "boolean", string: "string", bytes: "Uint8Array", int32: "number", uint32: "number", double: "number", int64: "bigint", uint64: "bigint"})[value.name];
|
||||||
|
case "object-ref": return `{readonly $quixosRef: string}`;
|
||||||
|
case "optional": return `(${type(value.value)} | null)`;
|
||||||
|
case "list": return `Array<${type(value.value)}>`;
|
||||||
|
case "record": return `{${Object.entries(value.fields).map(([name, field]) => `${JSON.stringify(name)}${field.kind === "optional" ? "?" : ""}: ${type(field)}`).join("; ")}}`;
|
||||||
|
case "message": {
|
||||||
|
const binding = messages[value.descriptorId];
|
||||||
|
if (!binding) throw new Error(`Missing host message type ${value.descriptorId}`);
|
||||||
|
return binding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const operations = interfaces.flatMap(iface => iface.members.flatMap(member => member.operations
|
||||||
|
.filter(operation => operation.mode === "call").map(operation => ({...operation, interfaceRevisionId: iface.revisionId}))));
|
||||||
|
return `// Generated from checked QX interfaces. Regenerate with scripts/generate-platform-contracts.mjs.\n` +
|
||||||
|
`export type PlatformInputs = {\n${operations.map(operation => ` ${JSON.stringify(operation.id)}: ${type(operation.inputType)};`).join("\n")}\n};\n` +
|
||||||
|
`export const platformOperations = ${JSON.stringify(Object.fromEntries(operations.map(operation => [operation.id, {
|
||||||
|
interfaceRevisionId: operation.interfaceRevisionId,
|
||||||
|
input: operation.inputType.kind === "builtin" && operation.inputType.name === "unit" ? "unit" : ["record", "message"].includes(operation.inputType.kind) ? "fields" : "value",
|
||||||
|
}])), null, 2)} as const;\n`;
|
||||||
|
};
|
||||||
@@ -16,7 +16,7 @@ export async function checkAuthoring(start: string, output: string, options: { b
|
|||||||
const resource = context.resources.find(entry => entry.directory === directory);
|
const resource = context.resources.find(entry => entry.directory === directory);
|
||||||
if (!resource) throw new Error("Run check from a registered repository root or the workbench");
|
if (!resource) throw new Error("Run check from a registered repository root or the workbench");
|
||||||
await fs.mkdir(output, { mode: 0o700 });
|
await fs.mkdir(output, { mode: 0o700 });
|
||||||
const report: { directory: string; checker: string; candidateOnly: true; activationEvidence: false; commit?: string; artifactPath?: string; blockers: string[]; phase: string; output: string } = {
|
const report: { directory: string; checker: string; candidateOnly: true; activationEvidence: false; commit?: string; artifactPath?: string; blockers: string[]; phase: string; output: string; compilation?: "passed"; activationReadiness?: "preserve" | "migration-required" | "blocked"; migrationRequired?: string[] } = {
|
||||||
directory, checker: checkerIdentity(), candidateOnly: true, activationEvidence: false, blockers: [], phase: "convergence", output,
|
directory, checker: checkerIdentity(), candidateOnly: true, activationEvidence: false, blockers: [], phase: "convergence", output,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
@@ -38,6 +38,7 @@ export async function checkAuthoring(start: string, output: string, options: { b
|
|||||||
report.artifactPath = await buildImmutableCandidate(converged.candidate, resource.kind, path.join(output, "nix.log"), options.contractOnly);
|
report.artifactPath = await buildImmutableCandidate(converged.candidate, resource.kind, path.join(output, "nix.log"), options.contractOnly);
|
||||||
const candidateText = await fs.readFile(path.join(report.artifactPath, "candidate.json"), "utf8");
|
const candidateText = await fs.readFile(path.join(report.artifactPath, "candidate.json"), "utf8");
|
||||||
await fs.writeFile(path.join(output, "candidate.json"), candidateText);
|
await fs.writeFile(path.join(output, "candidate.json"), candidateText);
|
||||||
|
report.compilation = "passed";
|
||||||
if (resource.kind === "workspace" && !options.contractOnly) {
|
if (resource.kind === "workspace" && !options.contractOnly) {
|
||||||
report.phase = "evolution";
|
report.phase = "evolution";
|
||||||
let baseline = options.baseline;
|
let baseline = options.baseline;
|
||||||
@@ -52,6 +53,9 @@ export async function checkAuthoring(start: string, output: string, options: { b
|
|||||||
const evolution = planEvolution(before, JSON.parse(candidateText), { reviews });
|
const evolution = planEvolution(before, JSON.parse(candidateText), { reviews });
|
||||||
await fs.writeFile(path.join(output, "evolution.json"), JSON.stringify(evolution, null, 2));
|
await fs.writeFile(path.join(output, "evolution.json"), JSON.stringify(evolution, null, 2));
|
||||||
report.blockers.push(...evolution.blockers);
|
report.blockers.push(...evolution.blockers);
|
||||||
|
report.migrationRequired = evolution.migrationRequired;
|
||||||
|
report.activationReadiness = evolution.blockers.length ? "blocked" : evolution.migrationRequired.length ? "migration-required" : "preserve";
|
||||||
|
if (evolution.migrationRequired.length) report.blockers.push(`Explicit migration required for: ${evolution.migrationRequired.join(", ")}. Compilation passed; supply a migration path before cutover.`);
|
||||||
}
|
}
|
||||||
if (!report.blockers.length) report.phase = options.contractOnly ? "contract-only" : "checked";
|
if (!report.blockers.length) report.phase = options.contractOnly ? "contract-only" : "checked";
|
||||||
} catch (error) { report.blockers.push(String(error instanceof Error ? error.message : error)); }
|
} catch (error) { report.blockers.push(String(error instanceof Error ? error.message : error)); }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
|||||||
import { parseQx, walkSyntax } from "./source.js";
|
import { parseQx, walkSyntax } from "./source.js";
|
||||||
import { authoringContext } from "./authoring-context.js";
|
import { authoringContext } from "./authoring-context.js";
|
||||||
import { readQxSource } from "./source-loader.js";
|
import { readQxSource } from "./source-loader.js";
|
||||||
|
import {loadQuixosLock} from "../resource-lock/index.js";
|
||||||
|
|
||||||
const execFile = promisify(callback);
|
const execFile = promisify(callback);
|
||||||
const git = async (root: string, args: string[]) => (await execFile("git", ["-C", root, ...args], {
|
const git = async (root: string, args: string[]) => (await execFile("git", ["-C", root, ...args], {
|
||||||
@@ -53,12 +54,14 @@ export async function inspectAuthoringRepository(root: string, historyLimit = 10
|
|||||||
|
|
||||||
export async function inspectWorkbench(start: string, selector?: string) {
|
export async function inspectWorkbench(start: string, selector?: string) {
|
||||||
const context = await authoringContext(start);
|
const context = await authoringContext(start);
|
||||||
if (!selector) {
|
if (!selector || selector === ".") {
|
||||||
const relative = path.relative(context.workbench, await realpath(start));
|
const relative = path.relative(context.workbench, await realpath(start));
|
||||||
selector = context.resources.find(entry => relative === entry.directory || relative.startsWith(entry.directory + path.sep))?.directory ?? "root";
|
selector = context.resources.find(entry => relative === entry.directory || relative.startsWith(entry.directory + path.sep))?.directory ?? "root";
|
||||||
}
|
}
|
||||||
|
const lock = await loadQuixosLock(path.join(context.workbench, "root/quixos.lock"));
|
||||||
|
const aliases = lock.ok ? lock.lock.resources.filter(entry => entry.binding === selector) : [];
|
||||||
const selected = context.resources.filter(entry => !selector || selector === entry.directory ||
|
const selected = context.resources.filter(entry => !selector || selector === entry.directory ||
|
||||||
selector === entry.resourceId || selector === path.basename(entry.directory));
|
selector === entry.resourceId || selector === path.basename(entry.directory) || aliases.some(alias => alias.kind === entry.kind && alias.source.repository === entry.source?.repository));
|
||||||
if (!selected.length) throw new Error(`No registered resource matches ${selector}`);
|
if (!selected.length) throw new Error(`No registered resource matches ${selector}`);
|
||||||
if (selector && selected.length > 1) throw new Error(`Ambiguous resource ${selector}; use its resource ID or directory`);
|
if (selector && selected.length > 1) throw new Error(`Ambiguous resource ${selector}; use its resource ID or directory`);
|
||||||
const resources = [];
|
const resources = [];
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function authoringWorklist(start: string) {
|
|||||||
for (const resource of context.resources) {
|
for (const resource of context.resources) {
|
||||||
const root = path.join(context.workbench, resource.directory);
|
const root = path.join(context.workbench, resource.directory);
|
||||||
const add = (phase: string, message: string) => entries.push({directory: resource.directory, resourceId: resource.resourceId, phase, message,
|
const add = (phase: string, message: string) => entries.push({directory: resource.directory, resourceId: resource.resourceId, phase, message,
|
||||||
next: phase === "syntax" ? `qx-workspace inspect ${resource.directory}` : `cd ${resource.directory} && qx-workspace check`});
|
next: phase === "syntax" ? `qx-workspace inspect ${resource.directory}` : phase === "evolution" ? "Inspect evolution.json in the check output; resolve its named migration/review requirements before cutover" : `cd ${resource.directory} && qx-workspace check`});
|
||||||
try {
|
try {
|
||||||
if (await fs.realpath(root) !== root) throw new Error("Registered checkout crosses a symlink");
|
if (await fs.realpath(root) !== root) throw new Error("Registered checkout crosses a symlink");
|
||||||
const inspected = await inspectAuthoringRepository(root);
|
const inspected = await inspectAuthoringRepository(root);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
|
|||||||
/** Kernel-owned lock: a crashed coordinator cannot leave a stale ownership file.
|
/** Kernel-owned lock: a crashed coordinator cannot leave a stale ownership file.
|
||||||
* The persistent file is just an inode; EOF releases the helper's lock. */
|
* The persistent file is just an inode; EOF releases the helper's lock. */
|
||||||
export async function withFileLock<T>(filename: string, work: () => Promise<T>): Promise<T> {
|
export async function withFileLock<T>(filename: string, work: () => Promise<T>): Promise<T> {
|
||||||
const child = spawn("flock", ["--exclusive", "--nonblock", "--conflict-exit-code", "75", filename,
|
const child = spawn("flock", ["--exclusive", "--timeout", "120", "--conflict-exit-code", "75", filename,
|
||||||
process.execPath, "-e", 'process.stdout.write("locked\\n"); process.stdin.resume();'], {stdio: ["pipe", "pipe", "pipe"]});
|
process.execPath, "-e", 'process.stdout.write("locked\\n"); process.stdin.resume();'], {stdio: ["pipe", "pipe", "pipe"]});
|
||||||
let diagnostics = "";
|
let diagnostics = "";
|
||||||
child.stdin.on("error", () => { /* acquisition/exit handling reports helper failure */ });
|
child.stdin.on("error", () => { /* acquisition/exit handling reports helper failure */ });
|
||||||
@@ -13,7 +13,7 @@ export async function withFileLock<T>(filename: string, work: () => Promise<T>):
|
|||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
let output = "";
|
let output = "";
|
||||||
child.once("error", reject);
|
child.once("error", reject);
|
||||||
child.once("exit", code => reject(new Error(code === 75 ? "Another authoring command owns this repository; retry when it finishes" : `Cannot acquire authoring lock: ${diagnostics}`)));
|
child.once("exit", code => reject(new Error(code === 75 ? "Timed out after 120 seconds waiting for another authoring command; inspect that command before retrying" : `Cannot acquire authoring lock: ${diagnostics}`)));
|
||||||
child.stdout.on("data", chunk => { output += chunk; if (output.includes("locked\n")) resolve(); });
|
child.stdout.on("data", chunk => { output += chunk; if (output.includes("locked\n")) resolve(); });
|
||||||
});
|
});
|
||||||
return await work();
|
return await work();
|
||||||
|
|||||||
@@ -91,6 +91,36 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
|
|||||||
} else {
|
} else {
|
||||||
registry = await ownedJson<Registry>(root, prefix + "quixos.scaffold.json");
|
registry = await ownedJson<Registry>(root, prefix + "quixos.scaffold.json");
|
||||||
catalog = await ownedJson<typeof catalog>(root, prefix + "quixos.migrations.json");
|
catalog = await ownedJson<typeof catalog>(root, prefix + "quixos.migrations.json");
|
||||||
|
// package.qx is authoritative. The registry remembers implementation paths,
|
||||||
|
// not a second declaration list that can erase an author's new exports.
|
||||||
|
const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8");
|
||||||
|
const syntax = parseQx(authored);
|
||||||
|
if (syntax.diagnostics.length) throw new Error("Cannot refresh an invalid package.qx; fix the reported syntax first");
|
||||||
|
const declaration = [...walkSyntax(syntax.root)].find(node => node.kind === "packageResourceDecl");
|
||||||
|
if (!declaration) throw new Error("Expected a package declaration");
|
||||||
|
const text = (node: typeof declaration) => authored.slice(node.start, node.end);
|
||||||
|
const literals = declaration.children.filter(node => node.kind === "stringLiteral");
|
||||||
|
registry.name = text(declaration.children.find(node => node.kind === "identifier")!);
|
||||||
|
registry.id = JSON.parse(text(literals[0]));
|
||||||
|
registry.revision = JSON.parse(text(literals[1]));
|
||||||
|
const previousExports = registry.exports;
|
||||||
|
registry.exports = [];
|
||||||
|
for (const node of walkSyntax(declaration)) {
|
||||||
|
if (!["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind)) continue;
|
||||||
|
const name = safeName(text(node.children.find(child => child.kind === "identifier")!));
|
||||||
|
const id = JSON.parse(text(node.children.find(child => child.kind === "stringLiteral")!));
|
||||||
|
if (registry.exports.some(entry => entry.id === id || entry.name === name)) throw new Error("Duplicate package export name or ID");
|
||||||
|
const old = previousExports.find(entry => entry.id === id);
|
||||||
|
const file = old?.file ?? `src/impl/${name}.ts`;
|
||||||
|
if (!old) {
|
||||||
|
const exists = await fs.access(path.join(root, prefix, file)).then(() => true, () => false);
|
||||||
|
if (!exists) {
|
||||||
|
const derived = [...walkSyntax(node)].some(child => child.kind === "eventClause");
|
||||||
|
create(file, `import type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation[${JSON.stringify(name)}] = ${derived ? '{kind: "derived", get: ' : ""}async (_context) => { throw new Error(${JSON.stringify(`Implement ${name}`)}); }${derived ? "}" : ""};\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
registry.exports.push({...old, name, id, file});
|
||||||
|
}
|
||||||
if (command !== "refresh") {
|
if (command !== "refresh") {
|
||||||
const name = safeName(spec.name);
|
const name = safeName(spec.name);
|
||||||
if (!spec.id || registry.exports.some((entry) => entry.id === spec.id || entry.name === name)) throw new Error("New export requires a unique name and ID");
|
if (!spec.id || registry.exports.some((entry) => entry.id === spec.id || entry.name === name)) throw new Error("New export requires a unique name and ID");
|
||||||
|
|||||||
@@ -20,13 +20,37 @@ const semantic = (value: unknown): unknown => {
|
|||||||
if (Array.isArray(value)) return value.map(semantic);
|
if (Array.isArray(value)) return value.map(semantic);
|
||||||
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value)
|
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value)
|
||||||
.filter(([key, entry]) => entry !== undefined && key !== "displayName" && key !== "documentation")
|
.filter(([key, entry]) => entry !== undefined && key !== "displayName" && key !== "documentation")
|
||||||
.map(([key, entry]) => [key, semantic(entry)]));
|
// These are authored data/maps, not schema nodes. A user field literally
|
||||||
|
// named displayName or documentation is semantic and must stay in the hash.
|
||||||
|
.map(([key, entry]) => [key, key === "defaultValue" || key === "fields" ? entry : semantic(entry)]));
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
const sorted = <T>(entries: readonly T[], key: (entry: T) => string) => [...entries].sort((a, b) => compareText(key(a), key(b)));
|
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 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 type StorageContract = { id: string; ownerId: string; kind: "state" | "edge"; digest: string; definition: unknown };
|
||||||
|
/** Automatic evolution preserves values; it never interprets migration code or
|
||||||
|
* guesses that a new nominal message descriptor means the same representation. */
|
||||||
|
export const storageChangeRequiresMigration = (previous: StorageContract | undefined, next: StorageContract | undefined,
|
||||||
|
oldAtomIds: ReadonlySet<string>): boolean => {
|
||||||
|
if (!next) return true;
|
||||||
|
const after = next.definition as PersistentAttachment;
|
||||||
|
if (!previous) {
|
||||||
|
if (after.kind === "state") return oldAtomIds.has(after.attachedTo) && after.defaultValue === undefined && after.valueType.kind !== "optional";
|
||||||
|
return after.endpoints.some(endpoint => endpoint.cardinality === "exactly-one" &&
|
||||||
|
(endpoint.constraint.kind !== "atom" || oldAtomIds.has(endpoint.constraint.atomId)));
|
||||||
|
}
|
||||||
|
if (previous.ownerId !== next.ownerId || previous.kind !== next.kind) return true;
|
||||||
|
const before = previous.definition as PersistentAttachment;
|
||||||
|
if (before.kind === "state" && after.kind === "state") {
|
||||||
|
// Capture materializes old defaults, so changing a default affects only
|
||||||
|
// newly constructed objects, not existing sparse state.
|
||||||
|
const {defaultValue: _beforeDefault, ...beforeStorage} = before;
|
||||||
|
const {defaultValue: _afterDefault, ...afterStorage} = after;
|
||||||
|
return canonicalJson(beforeStorage) !== canonicalJson(afterStorage);
|
||||||
|
}
|
||||||
|
return canonicalJson(before) !== canonicalJson(after);
|
||||||
|
};
|
||||||
export const storageContracts = (workspace: WorkspaceRevision): StorageContract[] => {
|
export const storageContracts = (workspace: WorkspaceRevision): StorageContract[] => {
|
||||||
const result: StorageContract[] = [];
|
const result: StorageContract[] = [];
|
||||||
const add = (attachment: PersistentAttachment, ownerId: string) => {
|
const add = (attachment: PersistentAttachment, ownerId: string) => {
|
||||||
@@ -141,7 +165,8 @@ export type RuntimeAction = { groupId: string; action: "keep" | "start" | "repla
|
|||||||
export type EvolutionReport = {
|
export type EvolutionReport = {
|
||||||
schemaVersion: 1; baselineDigest: string | null; candidateDigest: string; checkerVersion: string;
|
schemaVersion: 1; baselineDigest: string | null; candidateDigest: string; checkerVersion: string;
|
||||||
runtimeActions: RuntimeAction[];
|
runtimeActions: RuntimeAction[];
|
||||||
storageChanges: Array<{ id: string; kind: "add" | "remove" | "change"; previous?: StorageContract; candidate?: StorageContract }>;
|
storageChanges: Array<{ id: string; kind: "add" | "remove" | "change"; requiresMigration: boolean; previous?: StorageContract; candidate?: StorageContract }>;
|
||||||
|
migrationRequired: string[];
|
||||||
reviews: Array<ReviewRequirement & { accepted: boolean }>;
|
reviews: Array<ReviewRequirement & { accepted: boolean }>;
|
||||||
packageChecks: Array<{ groupId: string; contractDigest: string }>;
|
packageChecks: Array<{ groupId: string; contractDigest: string }>;
|
||||||
blockers: string[];
|
blockers: string[];
|
||||||
@@ -175,6 +200,7 @@ export const planEvolution = (baseline: WorkspaceRevision | null, candidate: Wor
|
|||||||
for (const id of [...new Set([...beforeStorage.keys(), ...afterStorage.keys()])].sort()) {
|
for (const id of [...new Set([...beforeStorage.keys(), ...afterStorage.keys()])].sort()) {
|
||||||
const previous = beforeStorage.get(id), next = afterStorage.get(id);
|
const previous = beforeStorage.get(id), next = afterStorage.get(id);
|
||||||
if (previous?.digest !== next?.digest) storageChanges.push({ id, kind: !previous ? "add" : !next ? "remove" : "change",
|
if (previous?.digest !== next?.digest) storageChanges.push({ id, kind: !previous ? "add" : !next ? "remove" : "change",
|
||||||
|
requiresMigration: storageChangeRequiresMigration(previous, next, new Set(baseline?.atoms.map(atom => atom.id) ?? [])),
|
||||||
...(previous ? { previous } : {}), ...(next ? { candidate: next } : {}) });
|
...(previous ? { previous } : {}), ...(next ? { candidate: next } : {}) });
|
||||||
}
|
}
|
||||||
const providers = (workspace: WorkspaceRevision) => [
|
const providers = (workspace: WorkspaceRevision) => [
|
||||||
@@ -201,6 +227,6 @@ export const planEvolution = (baseline: WorkspaceRevision | null, candidate: Wor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { schemaVersion: 1, baselineDigest: baseline ? contentDigest(baseline) : null, candidateDigest, checkerVersion,
|
return { schemaVersion: 1, baselineDigest: baseline ? contentDigest(baseline) : null, candidateDigest, checkerVersion,
|
||||||
runtimeActions, storageChanges, reviews, packageChecks: runtimeActions.filter((entry) => entry.candidate && entry.action !== "keep")
|
runtimeActions, storageChanges, migrationRequired: storageChanges.filter(entry => entry.requiresMigration).map(entry => entry.id), reviews, packageChecks: runtimeActions.filter((entry) => entry.candidate && entry.action !== "keep")
|
||||||
.map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })), blockers };
|
.map((entry) => ({ groupId: entry.groupId, contractDigest: entry.candidate!.digest })), blockers };
|
||||||
};
|
};
|
||||||
|
|||||||
+78
-11
File diff suppressed because one or more lines are too long
+23
-1
@@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { capabilityId as id, valueType, validateWorkspaceRevision } from "../src/capability-model/index.js";
|
import { capabilityId as id, valueType, validateWorkspaceRevision } from "../src/capability-model/index.js";
|
||||||
import { contentDigest, planEvolution, runtimeContracts, storageContracts } from "../src/capability-model/evolution.js";
|
import { contentDigest, planEvolution, runtimeContracts, storageContracts, storageChangeRequiresMigration } from "../src/capability-model/evolution.js";
|
||||||
import { capabilityFixtureSource, capabilityResourceSources, compileCapabilityFixture, makeValidCapabilityWorkspace } from "./fixtures/capability-model.js";
|
import { capabilityFixtureSource, capabilityResourceSources, compileCapabilityFixture, makeValidCapabilityWorkspace } from "./fixtures/capability-model.js";
|
||||||
|
|
||||||
test("QX carries stable conformance IDs and implementation semantic majors", () => {
|
test("QX carries stable conformance IDs and implementation semantic majors", () => {
|
||||||
@@ -68,6 +68,7 @@ test("storage defaults and ownership changes invalidate consumers without requir
|
|||||||
if (slot.kind === "state") slot.defaultValue = "Different default";
|
if (slot.kind === "state") slot.defaultValue = "Different default";
|
||||||
const report = planEvolution(before, after, { allowLegacy: true });
|
const report = planEvolution(before, after, { allowLegacy: true });
|
||||||
assert.equal(report.storageChanges.length, 1);
|
assert.equal(report.storageChanges.length, 1);
|
||||||
|
assert.deepEqual(report.migrationRequired, []);
|
||||||
assert.equal(report.runtimeActions[0]!.action, "replace");
|
assert.equal(report.runtimeActions[0]!.action, "replace");
|
||||||
assert.deepEqual(report.reviews, []);
|
assert.deepEqual(report.reviews, []);
|
||||||
assert.notDeepEqual(storageContracts(before), storageContracts(after));
|
assert.notDeepEqual(storageContracts(before), storageContracts(after));
|
||||||
@@ -96,3 +97,24 @@ test("evolution enrollment is explicit and never erases legacy ownership", () =>
|
|||||||
assert.equal(runtimeContracts(workspace).length, 1);
|
assert.equal(runtimeContracts(workspace).length, 1);
|
||||||
assert.throws(() => planEvolution(workspace, { ...workspace, workspaceId: id.workspace("other") }), /different workspace/);
|
assert.throws(() => planEvolution(workspace, { ...workspace, workspaceId: id.workspace("other") }), /different workspace/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("automatic preservation distinguishes additions and defaults from incompatible storage", () => {
|
||||||
|
const workspace = makeValidCapabilityWorkspace();
|
||||||
|
const before = storageContracts(workspace).find(entry => entry.kind === "state")!;
|
||||||
|
const next = structuredClone(before);
|
||||||
|
const value = next.definition as Record<string, unknown>;
|
||||||
|
value.defaultValue = {displayName: "important user data"};
|
||||||
|
assert.equal(storageChangeRequiresMigration(before, next, new Set()), false);
|
||||||
|
next.ownerId = "another-owner";
|
||||||
|
assert.equal(storageChangeRequiresMigration(before, next, new Set()), true);
|
||||||
|
assert.equal(storageChangeRequiresMigration(before, undefined, new Set()), true);
|
||||||
|
delete value.defaultValue;
|
||||||
|
assert.equal(storageChangeRequiresMigration(undefined, next, new Set([value.attachedTo as string])), true);
|
||||||
|
assert.equal(storageChangeRequiresMigration(undefined, next, new Set()), false);
|
||||||
|
const slot = workspace.sharedAttachments.find(entry => entry.kind === "state")!;
|
||||||
|
if (slot.kind !== "state") throw new Error("fixture slot");
|
||||||
|
slot.defaultValue = {displayName: "one"};
|
||||||
|
const first = storageContracts(workspace);
|
||||||
|
slot.defaultValue = {displayName: "two"};
|
||||||
|
assert.notDeepEqual(storageContracts(workspace), first, "user data must not be stripped as schema metadata");
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,9 +11,14 @@ test("authoring lock excludes concurrent mutations and survives owner death", as
|
|||||||
const root = await mkdtemp(path.join(os.tmpdir(), "qx-lock-test-"));
|
const root = await mkdtemp(path.join(os.tmpdir(), "qx-lock-test-"));
|
||||||
context.after(() => rm(root, {recursive: true, force: true}));
|
context.after(() => rm(root, {recursive: true, force: true}));
|
||||||
const filename = path.join(root, "lock");
|
const filename = path.join(root, "lock");
|
||||||
|
const events: string[] = [];
|
||||||
|
let queued: Promise<void>;
|
||||||
await withFileLock(filename, async () => {
|
await withFileLock(filename, async () => {
|
||||||
await assert.rejects(withFileLock(filename, async () => assert.fail("concurrent mutation")), /Another authoring command/);
|
queued = withFileLock(filename, async () => {events.push("second");});
|
||||||
|
events.push("first");
|
||||||
});
|
});
|
||||||
|
await queued!;
|
||||||
|
assert.deepEqual(events, ["first", "second"]);
|
||||||
const module = new URL("../src/capability-language/file-lock.js", import.meta.url).href;
|
const module = new URL("../src/capability-language/file-lock.js", import.meta.url).href;
|
||||||
const owner = spawn(process.execPath, ["--input-type=module", "-e",
|
const owner = spawn(process.execPath, ["--input-type=module", "-e",
|
||||||
`import {withFileLock} from ${JSON.stringify(module)}; await withFileLock(${JSON.stringify(filename)}, async () => {process.stdout.write('ready'); await new Promise(() => {});});`],
|
`import {withFileLock} from ${JSON.stringify(module)}; await withFileLock(${JSON.stringify(filename)}, async () => {process.stdout.write('ready'); await new Promise(() => {});});`],
|
||||||
@@ -23,11 +28,7 @@ test("authoring lock excludes concurrent mutations and survives owner death", as
|
|||||||
const exited = once(owner, "exit");
|
const exited = once(owner, "exit");
|
||||||
owner.kill("SIGKILL");
|
owner.kill("SIGKILL");
|
||||||
await exited;
|
await exited;
|
||||||
// EOF release happens in the helper; wait a bounded amount for scheduling.
|
|
||||||
let acquired = false;
|
let acquired = false;
|
||||||
for (let attempt = 0; attempt < 30 && !acquired; attempt++) {
|
await withFileLock(filename, async () => {acquired = true;});
|
||||||
try { await withFileLock(filename, async () => {acquired = true;}); }
|
|
||||||
catch (error) { if (!/Another authoring command/.test(String(error))) throw error; }
|
|
||||||
}
|
|
||||||
assert.equal(acquired, true);
|
assert.equal(acquired, true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ test("package/function/migration scaffolds register implementations and refresh
|
|||||||
await execFile("nix-instantiate", ["--parse", path.join(packageRoot, "flake.nix")]);
|
await execFile("nix-instantiate", ["--parse", path.join(packageRoot, "flake.nix")]);
|
||||||
}
|
}
|
||||||
await assert.rejects(() => apply("function", {...base, name: "play", id: "export:play"}), /unique/);
|
await assert.rejects(() => apply("function", {...base, name: "play", id: "export:play"}), /unique/);
|
||||||
|
const declarations = path.join(root, base.directory, "package.qx");
|
||||||
|
await fs.writeFile(declarations, (await fs.readFile(declarations, "utf8")).replace(/}\s*$/, ' function authored id "export:authored" : unit -> unit;\n}\n'));
|
||||||
|
await apply("refresh", base);
|
||||||
|
assert.match(await fs.readFile(path.join(root, base.directory, "src/server.ts"), "utf8"), /"authored":/);
|
||||||
|
assert.match(await fs.readFile(path.join(root, base.directory, "src/impl/authored.ts"), "utf8"), /Implement authored/);
|
||||||
|
assert.equal(await fs.readFile(filename, "utf8"), edited);
|
||||||
await assert.rejects(() => planStructure(root, {kind: "package", source, resourceRoot: base.directory, validation: "syntax", files: [{
|
await assert.rejects(() => planStructure(root, {kind: "package", source, resourceRoot: base.directory, validation: "syntax", files: [{
|
||||||
file: `${base.directory}/package.qx`, edits: [{operation: "replace", target: {kind: "packageResourceDecl", id: "package:chess"},
|
file: `${base.directory}/package.qx`, edits: [{operation: "replace", target: {kind: "packageResourceDecl", id: "package:chess"},
|
||||||
source: 'package Other id "package:other" revision "package:other@1" {}'}],
|
source: 'package Other id "package:other" revision "package:other@1" {}'}],
|
||||||
|
|||||||
Reference in New Issue
Block a user