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:
Timothy J. Aveni
2026-09-14 15:24:37 -07:00
parent 01ca965c7f
commit 14b0ef25c3
13 changed files with 231 additions and 27 deletions
+2
View File
@@ -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"
+14
View File
@@ -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 {}
+29
View File
@@ -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`;
};
+5 -1
View File
@@ -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 -2
View File
@@ -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);
+2 -2
View File
@@ -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");
+29 -3
View File
@@ -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 };
}; };
File diff suppressed because one or more lines are too long
+23 -1
View File
@@ -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");
});
+7 -6
View File
@@ -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);
}); });
+6
View File
@@ -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" {}'}],