diff --git a/flake.nix b/flake.nix index 2d07fa0..54ad388 100644 --- a/flake.nix +++ b/flake.nix @@ -32,6 +32,7 @@ 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 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/capability" src/capability-language/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 chmod +x "$out/bin/quixos-lock-check" 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-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" diff --git a/proto/quixos/orch.proto b/proto/quixos/orch.proto index fcf1026..7ce44cf 100644 --- a/proto/quixos/orch.proto +++ b/proto/quixos/orch.proto @@ -72,6 +72,20 @@ message GetWorkspaceResponse { string workspace_id = 1; string workspace_revision_id = 2; 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 ListPackageDescriptorsRequest {} diff --git a/src/bindings/client.ts b/src/bindings/client.ts new file mode 100644 index 0000000..26ae37e --- /dev/null +++ b/src/bindings/client.ts @@ -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) => { + 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`; +}; diff --git a/src/capability-language/authoring-check.ts b/src/capability-language/authoring-check.ts index 017a1fc..73a3de0 100644 --- a/src/capability-language/authoring-check.ts +++ b/src/capability-language/authoring-check.ts @@ -16,7 +16,7 @@ export async function checkAuthoring(start: string, output: string, options: { b const resource = context.resources.find(entry => entry.directory === directory); if (!resource) throw new Error("Run check from a registered repository root or the workbench"); 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, }; 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); const candidateText = await fs.readFile(path.join(report.artifactPath, "candidate.json"), "utf8"); await fs.writeFile(path.join(output, "candidate.json"), candidateText); + report.compilation = "passed"; if (resource.kind === "workspace" && !options.contractOnly) { report.phase = "evolution"; 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 }); await fs.writeFile(path.join(output, "evolution.json"), JSON.stringify(evolution, null, 2)); 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"; } catch (error) { report.blockers.push(String(error instanceof Error ? error.message : error)); } diff --git a/src/capability-language/authoring-inspect.ts b/src/capability-language/authoring-inspect.ts index 20d8f1d..f0b0c9b 100644 --- a/src/capability-language/authoring-inspect.ts +++ b/src/capability-language/authoring-inspect.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { parseQx, walkSyntax } from "./source.js"; import { authoringContext } from "./authoring-context.js"; import { readQxSource } from "./source-loader.js"; +import {loadQuixosLock} from "../resource-lock/index.js"; const execFile = promisify(callback); 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) { const context = await authoringContext(start); - if (!selector) { + if (!selector || selector === ".") { 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"; } + 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 || - 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 (selector && selected.length > 1) throw new Error(`Ambiguous resource ${selector}; use its resource ID or directory`); const resources = []; diff --git a/src/capability-language/authoring-worklist.ts b/src/capability-language/authoring-worklist.ts index 5ed3d51..3805562 100644 --- a/src/capability-language/authoring-worklist.ts +++ b/src/capability-language/authoring-worklist.ts @@ -16,7 +16,7 @@ export async function authoringWorklist(start: string) { for (const resource of context.resources) { const root = path.join(context.workbench, resource.directory); 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 { if (await fs.realpath(root) !== root) throw new Error("Registered checkout crosses a symlink"); const inspected = await inspectAuthoringRepository(root); diff --git a/src/capability-language/file-lock.ts b/src/capability-language/file-lock.ts index 53a54bc..2c41173 100644 --- a/src/capability-language/file-lock.ts +++ b/src/capability-language/file-lock.ts @@ -3,7 +3,7 @@ import { spawn } from "node:child_process"; /** 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. */ export async function withFileLock(filename: string, work: () => Promise): Promise { - 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"]}); let diagnostics = ""; child.stdin.on("error", () => { /* acquisition/exit handling reports helper failure */ }); @@ -13,7 +13,7 @@ export async function withFileLock(filename: string, work: () => Promise): await new Promise((resolve, reject) => { let output = ""; 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(); }); }); return await work(); diff --git a/src/capability-language/scaffold-recipes.ts b/src/capability-language/scaffold-recipes.ts index 1c5c19c..70f50e3 100644 --- a/src/capability-language/scaffold-recipes.ts +++ b/src/capability-language/scaffold-recipes.ts @@ -91,6 +91,36 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio } else { registry = await ownedJson(root, prefix + "quixos.scaffold.json"); catalog = await ownedJson(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") { 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"); diff --git a/src/capability-model/evolution.ts b/src/capability-model/evolution.ts index 5c0ba08..6014f9a 100644 --- a/src/capability-model/evolution.ts +++ b/src/capability-model/evolution.ts @@ -20,13 +20,37 @@ const semantic = (value: unknown): unknown => { if (Array.isArray(value)) return value.map(semantic); if (value && typeof value === "object") return Object.fromEntries(Object.entries(value) .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; }; const sorted = (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 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): 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[] => { const result: StorageContract[] = []; const add = (attachment: PersistentAttachment, ownerId: string) => { @@ -141,7 +165,8 @@ export type RuntimeAction = { groupId: string; action: "keep" | "start" | "repla export type EvolutionReport = { schemaVersion: 1; baselineDigest: string | null; candidateDigest: string; checkerVersion: string; 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; packageChecks: Array<{ groupId: string; contractDigest: 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()) { const previous = beforeStorage.get(id), next = afterStorage.get(id); 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 } : {}) }); } 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, - 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 }; }; diff --git a/src/gen/quixos/orch_pb.ts b/src/gen/quixos/orch_pb.ts index 01117f0..94bcf1b 100644 --- a/src/gen/quixos/orch_pb.ts +++ b/src/gen/quixos/orch_pb.ts @@ -18,7 +18,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file quixos/orch.proto. */ export const file_quixos_orch: GenFile = /*@__PURE__*/ - fileDesc("ChFxdWl4b3Mvb3JjaC5wcm90bxILcXVpeG9zLm9yY2gipQEKFkNvbnN0cnVjdE9iamVjdFJlcXVlc3QSDwoHYXRvbV9pZBgBIAEoCRI9CgVpbnB1dBgCIAMoCzIuLnF1aXhvcy5vcmNoLkNvbnN0cnVjdE9iamVjdFJlcXVlc3QuSW5wdXRFbnRyeRo7CgpJbnB1dEVudHJ5EgsKA2tleRgBIAEoCRIcCgV2YWx1ZRgCIAEoCzINLmNhbWluby5WYWx1ZToCOAEiPwoXQ29uc3RydWN0T2JqZWN0UmVzcG9uc2USJAoGb2JqZWN0GAEgASgLMhQuY2FtaW5vLkNhbWlub09iamVjdCJtCiZSZXNvbHZlT3JDb25zdHJ1Y3RSZWxhdGVkT2JqZWN0UmVxdWVzdBIRCglvYmplY3RfaWQYASABKAkSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAIgASgJEhEKCW1lbWJlcl9pZBgDIAEoCSJkCidSZXNvbHZlT3JDb25zdHJ1Y3RSZWxhdGVkT2JqZWN0UmVzcG9uc2USJAoGb2JqZWN0GAEgASgLMhQuY2FtaW5vLkNhbWlub09iamVjdBITCgtjb25zdHJ1Y3RlZBgCIAEoCCLwAQoXSW52b2tlQ2FwYWJpbGl0eVJlcXVlc3QSKQoKY2FwYWJpbGl0eRgBIAEoCzIVLnF1aXhvcy5DYXBhYmlsaXR5UmVmEhEKCW9iamVjdF9pZBgCIAEoCRI+CgVpbnB1dBgDIAMoCzIvLnF1aXhvcy5vcmNoLkludm9rZUNhcGFiaWxpdHlSZXF1ZXN0LklucHV0RW50cnkSGgoSY2xpZW50X211dGF0aW9uX2lkGAQgASgJGjsKCklucHV0RW50cnkSCwoDa2V5GAEgASgJEhwKBXZhbHVlGAIgASgLMg0uY2FtaW5vLlZhbHVlOgI4ASLRAQoYSW52b2tlQ2FwYWJpbGl0eVJlc3BvbnNlEhUKDWludm9jYXRpb25faWQYASABKAkSKwoKYWN0aXZhdGlvbhgCIAEoCzIXLnF1aXhvcy5vcmNoLkFjdGl2YXRpb24SCgoCb2sYAyABKAgSHQoGcmVzdWx0GAQgASgLMg0uY2FtaW5vLlZhbHVlEg0KBWVycm9yGAUgASgJEjcKDGRlcGVuZGVuY2llcxgGIAMoCzIhLnF1aXhvcy5ydW50aW1lLkRlcml2ZWREZXBlbmRlbmN5ItIBChZXYXRjaENhcGFiaWxpdHlSZXF1ZXN0EikKCmNhcGFiaWxpdHkYASABKAsyFS5xdWl4b3MuQ2FwYWJpbGl0eVJlZhIRCglvYmplY3RfaWQYAiABKAkSPQoFaW5wdXQYAyADKAsyLi5xdWl4b3Mub3JjaC5XYXRjaENhcGFiaWxpdHlSZXF1ZXN0LklucHV0RW50cnkaOwoKSW5wdXRFbnRyeRILCgNrZXkYASABKAkSHAoFdmFsdWUYAiABKAsyDS5jYW1pbm8uVmFsdWU6AjgBIuMBChRXYXRjaENhcGFiaWxpdHlFdmVudBIVCg1pbnZvY2F0aW9uX2lkGAEgASgJEisKCmFjdGl2YXRpb24YAiABKAsyFy5xdWl4b3Mub3JjaC5BY3RpdmF0aW9uEhAKCHdhdGNoX2lkGAMgASgJEhwKBXZhbHVlGAQgASgLMg0uY2FtaW5vLlZhbHVlEjcKDGRlcGVuZGVuY2llcxgFIAMoCzIhLnF1aXhvcy5ydW50aW1lLkRlcml2ZWREZXBlbmRlbmN5Eg0KBWVycm9yGAYgASgJEg8KB2luaXRpYWwYByABKAgiFQoTR2V0V29ya3NwYWNlUmVxdWVzdCJnChRHZXRXb3Jrc3BhY2VSZXNwb25zZRIUCgx3b3Jrc3BhY2VfaWQYASABKAkSHQoVd29ya3NwYWNlX3JldmlzaW9uX2lkGAIgASgJEhoKEnNvdXJjZV9yb290X2NvbW1pdBgDIAEoCSIYChZMaXN0QWN0aXZhdGlvbnNSZXF1ZXN0Ih8KHUxpc3RQYWNrYWdlRGVzY3JpcHRvcnNSZXF1ZXN0IlAKHkxpc3RQYWNrYWdlRGVzY3JpcHRvcnNSZXNwb25zZRIuCgtkZXNjcmlwdG9ycxgBIAMoCzIZLnF1aXhvcy5QYWNrYWdlRGVzY3JpcHRvciIcChpMaXN0UGFja2FnZVJ1bnRpbWVzUmVxdWVzdCJSChtMaXN0UGFja2FnZVJ1bnRpbWVzUmVzcG9uc2USMwoIcnVudGltZXMYASADKAsyIS5xdWl4b3Mub3JjaC5QYWNrYWdlUnVudGltZVN0YXR1cyJHChdMaXN0QWN0aXZhdGlvbnNSZXNwb25zZRIsCgthY3RpdmF0aW9ucxgBIAMoCzIXLnF1aXhvcy5vcmNoLkFjdGl2YXRpb24iPwoWQ2xvc2VBY3RpdmF0aW9uUmVxdWVzdBIVCg1hY3RpdmF0aW9uX2lkGAEgASgJEg4KBnJlYXNvbhgCIAEoCSJGChdDbG9zZUFjdGl2YXRpb25SZXNwb25zZRIrCgphY3RpdmF0aW9uGAEgASgLMhcucXVpeG9zLm9yY2guQWN0aXZhdGlvbiLrAQoKQWN0aXZhdGlvbhIVCg1hY3RpdmF0aW9uX2lkGAEgASgJEigKBmV4cG9ydBgCIAEoCzIYLnF1aXhvcy5QYWNrYWdlRXhwb3J0UmVmEhEKCW9iamVjdF9pZBgDIAEoCRINCgVzdGF0ZRgEIAEoCRIOCgZkZW1hbmQYBSABKA0SEQoJb3BlbmVkX2F0GAYgASgJEhQKDGxhc3RfdXNlZF9hdBgHIAEoCRIYChBpZGxlX2RlYWRsaW5lX2F0GAggASgJEhEKCWNsb3NlZF9hdBgJIAEoCRIUCgxjbG9zZV9yZWFzb24YCiABKAkiswIKFFBhY2thZ2VSdW50aW1lU3RhdHVzEhMKC3J1bnRpbWVfa2V5GAEgASgJEhsKE3BhY2thZ2VfcmV2aXNpb25faWQYAiABKAkSGQoRc291cmNlX3JlcG9zaXRvcnkYAyABKAkSFQoNc291cmNlX2NvbW1pdBgEIAEoCRIUCgxidWlsZF90YXJnZXQYBSABKAkSEwoLc2VydmVyX3BhdGgYBiABKAkSCwoDcGlkGAcgASgNEg0KBXN0YXRlGAggASgJEhIKCnN0YXJ0ZWRfYXQYCSABKAkSGQoRbGFzdF9oYW5kc2hha2VfYXQYCiABKAkSIAoYcnVudGltZV9wcm90b2NvbF92ZXJzaW9uGAsgASgJEh8KF2FkdmVydGlzZWRfZXhwb3J0X2NvdW50GAwgASgNMq4HChNPcmNoZXN0cmF0b3JSdW50aW1lEl8KEEludm9rZUNhcGFiaWxpdHkSJC5xdWl4b3Mub3JjaC5JbnZva2VDYXBhYmlsaXR5UmVxdWVzdBolLnF1aXhvcy5vcmNoLkludm9rZUNhcGFiaWxpdHlSZXNwb25zZRJbCg9XYXRjaENhcGFiaWxpdHkSIy5xdWl4b3Mub3JjaC5XYXRjaENhcGFiaWxpdHlSZXF1ZXN0GiEucXVpeG9zLm9yY2guV2F0Y2hDYXBhYmlsaXR5RXZlbnQwARJcCg9Db25zdHJ1Y3RPYmplY3QSIy5xdWl4b3Mub3JjaC5Db25zdHJ1Y3RPYmplY3RSZXF1ZXN0GiQucXVpeG9zLm9yY2guQ29uc3RydWN0T2JqZWN0UmVzcG9uc2USjAEKH1Jlc29sdmVPckNvbnN0cnVjdFJlbGF0ZWRPYmplY3QSMy5xdWl4b3Mub3JjaC5SZXNvbHZlT3JDb25zdHJ1Y3RSZWxhdGVkT2JqZWN0UmVxdWVzdBo0LnF1aXhvcy5vcmNoLlJlc29sdmVPckNvbnN0cnVjdFJlbGF0ZWRPYmplY3RSZXNwb25zZRJTCgxHZXRXb3Jrc3BhY2USIC5xdWl4b3Mub3JjaC5HZXRXb3Jrc3BhY2VSZXF1ZXN0GiEucXVpeG9zLm9yY2guR2V0V29ya3NwYWNlUmVzcG9uc2UScQoWTGlzdFBhY2thZ2VEZXNjcmlwdG9ycxIqLnF1aXhvcy5vcmNoLkxpc3RQYWNrYWdlRGVzY3JpcHRvcnNSZXF1ZXN0GisucXVpeG9zLm9yY2guTGlzdFBhY2thZ2VEZXNjcmlwdG9yc1Jlc3BvbnNlEmgKE0xpc3RQYWNrYWdlUnVudGltZXMSJy5xdWl4b3Mub3JjaC5MaXN0UGFja2FnZVJ1bnRpbWVzUmVxdWVzdBooLnF1aXhvcy5vcmNoLkxpc3RQYWNrYWdlUnVudGltZXNSZXNwb25zZRJcCg9MaXN0QWN0aXZhdGlvbnMSIy5xdWl4b3Mub3JjaC5MaXN0QWN0aXZhdGlvbnNSZXF1ZXN0GiQucXVpeG9zLm9yY2guTGlzdEFjdGl2YXRpb25zUmVzcG9uc2USXAoPQ2xvc2VBY3RpdmF0aW9uEiMucXVpeG9zLm9yY2guQ2xvc2VBY3RpdmF0aW9uUmVxdWVzdBokLnF1aXhvcy5vcmNoLkNsb3NlQWN0aXZhdGlvblJlc3BvbnNlYgZwcm90bzM", [file_camino_api, file_quixos_package, file_quixos_refs, file_quixos_runtime]); + fileDesc("ChFxdWl4b3Mvb3JjaC5wcm90bxILcXVpeG9zLm9yY2gipQEKFkNvbnN0cnVjdE9iamVjdFJlcXVlc3QSDwoHYXRvbV9pZBgBIAEoCRI9CgVpbnB1dBgCIAMoCzIuLnF1aXhvcy5vcmNoLkNvbnN0cnVjdE9iamVjdFJlcXVlc3QuSW5wdXRFbnRyeRo7CgpJbnB1dEVudHJ5EgsKA2tleRgBIAEoCRIcCgV2YWx1ZRgCIAEoCzINLmNhbWluby5WYWx1ZToCOAEiPwoXQ29uc3RydWN0T2JqZWN0UmVzcG9uc2USJAoGb2JqZWN0GAEgASgLMhQuY2FtaW5vLkNhbWlub09iamVjdCJtCiZSZXNvbHZlT3JDb25zdHJ1Y3RSZWxhdGVkT2JqZWN0UmVxdWVzdBIRCglvYmplY3RfaWQYASABKAkSHQoVaW50ZXJmYWNlX3JldmlzaW9uX2lkGAIgASgJEhEKCW1lbWJlcl9pZBgDIAEoCSJkCidSZXNvbHZlT3JDb25zdHJ1Y3RSZWxhdGVkT2JqZWN0UmVzcG9uc2USJAoGb2JqZWN0GAEgASgLMhQuY2FtaW5vLkNhbWlub09iamVjdBITCgtjb25zdHJ1Y3RlZBgCIAEoCCLwAQoXSW52b2tlQ2FwYWJpbGl0eVJlcXVlc3QSKQoKY2FwYWJpbGl0eRgBIAEoCzIVLnF1aXhvcy5DYXBhYmlsaXR5UmVmEhEKCW9iamVjdF9pZBgCIAEoCRI+CgVpbnB1dBgDIAMoCzIvLnF1aXhvcy5vcmNoLkludm9rZUNhcGFiaWxpdHlSZXF1ZXN0LklucHV0RW50cnkSGgoSY2xpZW50X211dGF0aW9uX2lkGAQgASgJGjsKCklucHV0RW50cnkSCwoDa2V5GAEgASgJEhwKBXZhbHVlGAIgASgLMg0uY2FtaW5vLlZhbHVlOgI4ASLRAQoYSW52b2tlQ2FwYWJpbGl0eVJlc3BvbnNlEhUKDWludm9jYXRpb25faWQYASABKAkSKwoKYWN0aXZhdGlvbhgCIAEoCzIXLnF1aXhvcy5vcmNoLkFjdGl2YXRpb24SCgoCb2sYAyABKAgSHQoGcmVzdWx0GAQgASgLMg0uY2FtaW5vLlZhbHVlEg0KBWVycm9yGAUgASgJEjcKDGRlcGVuZGVuY2llcxgGIAMoCzIhLnF1aXhvcy5ydW50aW1lLkRlcml2ZWREZXBlbmRlbmN5ItIBChZXYXRjaENhcGFiaWxpdHlSZXF1ZXN0EikKCmNhcGFiaWxpdHkYASABKAsyFS5xdWl4b3MuQ2FwYWJpbGl0eVJlZhIRCglvYmplY3RfaWQYAiABKAkSPQoFaW5wdXQYAyADKAsyLi5xdWl4b3Mub3JjaC5XYXRjaENhcGFiaWxpdHlSZXF1ZXN0LklucHV0RW50cnkaOwoKSW5wdXRFbnRyeRILCgNrZXkYASABKAkSHAoFdmFsdWUYAiABKAsyDS5jYW1pbm8uVmFsdWU6AjgBIuMBChRXYXRjaENhcGFiaWxpdHlFdmVudBIVCg1pbnZvY2F0aW9uX2lkGAEgASgJEisKCmFjdGl2YXRpb24YAiABKAsyFy5xdWl4b3Mub3JjaC5BY3RpdmF0aW9uEhAKCHdhdGNoX2lkGAMgASgJEhwKBXZhbHVlGAQgASgLMg0uY2FtaW5vLlZhbHVlEjcKDGRlcGVuZGVuY2llcxgFIAMoCzIhLnF1aXhvcy5ydW50aW1lLkRlcml2ZWREZXBlbmRlbmN5Eg0KBWVycm9yGAYgASgJEg8KB2luaXRpYWwYByABKAgiFQoTR2V0V29ya3NwYWNlUmVxdWVzdCKXAgoUR2V0V29ya3NwYWNlUmVzcG9uc2USFAoMd29ya3NwYWNlX2lkGAEgASgJEh0KFXdvcmtzcGFjZV9yZXZpc2lvbl9pZBgCIAEoCRIaChJzb3VyY2Vfcm9vdF9jb21taXQYAyABKAkSKgoiZW1wdHlfaW5wdXRfY29uc3RydWN0aWJsZV9hdG9tX2lkcxgEIAMoCRI/ChFjYXBhYmlsaXR5X2lucHV0cxgFIAMoCzIkLnF1aXhvcy5vcmNoLkNhcGFiaWxpdHlJbnB1dENvbnRyYWN0EkEKEmNvbnN0cnVjdG9yX2lucHV0cxgGIAMoCzIlLnF1aXhvcy5vcmNoLkNvbnN0cnVjdG9ySW5wdXRDb250cmFjdCJhChdDYXBhYmlsaXR5SW5wdXRDb250cmFjdBIdChVpbnRlcmZhY2VfcmV2aXNpb25faWQYASABKAkSFAoMb3BlcmF0aW9uX2lkGAIgASgJEhEKCXR5cGVfanNvbhgDIAEoCSI+ChhDb25zdHJ1Y3RvcklucHV0Q29udHJhY3QSDwoHYXRvbV9pZBgBIAEoCRIRCgl0eXBlX2pzb24YAiABKAkiGAoWTGlzdEFjdGl2YXRpb25zUmVxdWVzdCIfCh1MaXN0UGFja2FnZURlc2NyaXB0b3JzUmVxdWVzdCJQCh5MaXN0UGFja2FnZURlc2NyaXB0b3JzUmVzcG9uc2USLgoLZGVzY3JpcHRvcnMYASADKAsyGS5xdWl4b3MuUGFja2FnZURlc2NyaXB0b3IiHAoaTGlzdFBhY2thZ2VSdW50aW1lc1JlcXVlc3QiUgobTGlzdFBhY2thZ2VSdW50aW1lc1Jlc3BvbnNlEjMKCHJ1bnRpbWVzGAEgAygLMiEucXVpeG9zLm9yY2guUGFja2FnZVJ1bnRpbWVTdGF0dXMiRwoXTGlzdEFjdGl2YXRpb25zUmVzcG9uc2USLAoLYWN0aXZhdGlvbnMYASADKAsyFy5xdWl4b3Mub3JjaC5BY3RpdmF0aW9uIj8KFkNsb3NlQWN0aXZhdGlvblJlcXVlc3QSFQoNYWN0aXZhdGlvbl9pZBgBIAEoCRIOCgZyZWFzb24YAiABKAkiRgoXQ2xvc2VBY3RpdmF0aW9uUmVzcG9uc2USKwoKYWN0aXZhdGlvbhgBIAEoCzIXLnF1aXhvcy5vcmNoLkFjdGl2YXRpb24i6wEKCkFjdGl2YXRpb24SFQoNYWN0aXZhdGlvbl9pZBgBIAEoCRIoCgZleHBvcnQYAiABKAsyGC5xdWl4b3MuUGFja2FnZUV4cG9ydFJlZhIRCglvYmplY3RfaWQYAyABKAkSDQoFc3RhdGUYBCABKAkSDgoGZGVtYW5kGAUgASgNEhEKCW9wZW5lZF9hdBgGIAEoCRIUCgxsYXN0X3VzZWRfYXQYByABKAkSGAoQaWRsZV9kZWFkbGluZV9hdBgIIAEoCRIRCgljbG9zZWRfYXQYCSABKAkSFAoMY2xvc2VfcmVhc29uGAogASgJIrMCChRQYWNrYWdlUnVudGltZVN0YXR1cxITCgtydW50aW1lX2tleRgBIAEoCRIbChNwYWNrYWdlX3JldmlzaW9uX2lkGAIgASgJEhkKEXNvdXJjZV9yZXBvc2l0b3J5GAMgASgJEhUKDXNvdXJjZV9jb21taXQYBCABKAkSFAoMYnVpbGRfdGFyZ2V0GAUgASgJEhMKC3NlcnZlcl9wYXRoGAYgASgJEgsKA3BpZBgHIAEoDRINCgVzdGF0ZRgIIAEoCRISCgpzdGFydGVkX2F0GAkgASgJEhkKEWxhc3RfaGFuZHNoYWtlX2F0GAogASgJEiAKGHJ1bnRpbWVfcHJvdG9jb2xfdmVyc2lvbhgLIAEoCRIfChdhZHZlcnRpc2VkX2V4cG9ydF9jb3VudBgMIAEoDTKuBwoTT3JjaGVzdHJhdG9yUnVudGltZRJfChBJbnZva2VDYXBhYmlsaXR5EiQucXVpeG9zLm9yY2guSW52b2tlQ2FwYWJpbGl0eVJlcXVlc3QaJS5xdWl4b3Mub3JjaC5JbnZva2VDYXBhYmlsaXR5UmVzcG9uc2USWwoPV2F0Y2hDYXBhYmlsaXR5EiMucXVpeG9zLm9yY2guV2F0Y2hDYXBhYmlsaXR5UmVxdWVzdBohLnF1aXhvcy5vcmNoLldhdGNoQ2FwYWJpbGl0eUV2ZW50MAESXAoPQ29uc3RydWN0T2JqZWN0EiMucXVpeG9zLm9yY2guQ29uc3RydWN0T2JqZWN0UmVxdWVzdBokLnF1aXhvcy5vcmNoLkNvbnN0cnVjdE9iamVjdFJlc3BvbnNlEowBCh9SZXNvbHZlT3JDb25zdHJ1Y3RSZWxhdGVkT2JqZWN0EjMucXVpeG9zLm9yY2guUmVzb2x2ZU9yQ29uc3RydWN0UmVsYXRlZE9iamVjdFJlcXVlc3QaNC5xdWl4b3Mub3JjaC5SZXNvbHZlT3JDb25zdHJ1Y3RSZWxhdGVkT2JqZWN0UmVzcG9uc2USUwoMR2V0V29ya3NwYWNlEiAucXVpeG9zLm9yY2guR2V0V29ya3NwYWNlUmVxdWVzdBohLnF1aXhvcy5vcmNoLkdldFdvcmtzcGFjZVJlc3BvbnNlEnEKFkxpc3RQYWNrYWdlRGVzY3JpcHRvcnMSKi5xdWl4b3Mub3JjaC5MaXN0UGFja2FnZURlc2NyaXB0b3JzUmVxdWVzdBorLnF1aXhvcy5vcmNoLkxpc3RQYWNrYWdlRGVzY3JpcHRvcnNSZXNwb25zZRJoChNMaXN0UGFja2FnZVJ1bnRpbWVzEicucXVpeG9zLm9yY2guTGlzdFBhY2thZ2VSdW50aW1lc1JlcXVlc3QaKC5xdWl4b3Mub3JjaC5MaXN0UGFja2FnZVJ1bnRpbWVzUmVzcG9uc2USXAoPTGlzdEFjdGl2YXRpb25zEiMucXVpeG9zLm9yY2guTGlzdEFjdGl2YXRpb25zUmVxdWVzdBokLnF1aXhvcy5vcmNoLkxpc3RBY3RpdmF0aW9uc1Jlc3BvbnNlElwKD0Nsb3NlQWN0aXZhdGlvbhIjLnF1aXhvcy5vcmNoLkNsb3NlQWN0aXZhdGlvblJlcXVlc3QaJC5xdWl4b3Mub3JjaC5DbG9zZUFjdGl2YXRpb25SZXNwb25zZWIGcHJvdG8z", [file_camino_api, file_quixos_package, file_quixos_refs, file_quixos_runtime]); /** * @generated from message quixos.orch.ConstructObjectRequest @@ -290,6 +290,24 @@ export type GetWorkspaceResponse = Message<"quixos.orch.GetWorkspaceResponse"> & * @generated from field: string source_root_commit = 3; */ sourceRootCommit: string; + + /** + * Checked constructors whose wire input can be empty. Web Studio intersects + * this with its temporary Createable marker; the marker is not a factory. + * + * @generated from field: repeated string empty_input_constructible_atom_ids = 4; + */ + emptyInputConstructibleAtomIds: string[]; + + /** + * @generated from field: repeated quixos.orch.CapabilityInputContract capability_inputs = 5; + */ + capabilityInputs: CapabilityInputContract[]; + + /** + * @generated from field: repeated quixos.orch.ConstructorInputContract constructor_inputs = 6; + */ + constructorInputs: ConstructorInputContract[]; }; /** @@ -299,6 +317,55 @@ export type GetWorkspaceResponse = Message<"quixos.orch.GetWorkspaceResponse"> & export const GetWorkspaceResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_quixos_orch, 9); +/** + * @generated from message quixos.orch.CapabilityInputContract + */ +export type CapabilityInputContract = Message<"quixos.orch.CapabilityInputContract"> & { + /** + * @generated from field: string interface_revision_id = 1; + */ + interfaceRevisionId: string; + + /** + * @generated from field: string operation_id = 2; + */ + operationId: string; + + /** + * @generated from field: string type_json = 3; + */ + typeJson: string; +}; + +/** + * Describes the message quixos.orch.CapabilityInputContract. + * Use `create(CapabilityInputContractSchema)` to create a new message. + */ +export const CapabilityInputContractSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_quixos_orch, 10); + +/** + * @generated from message quixos.orch.ConstructorInputContract + */ +export type ConstructorInputContract = Message<"quixos.orch.ConstructorInputContract"> & { + /** + * @generated from field: string atom_id = 1; + */ + atomId: string; + + /** + * @generated from field: string type_json = 2; + */ + typeJson: string; +}; + +/** + * Describes the message quixos.orch.ConstructorInputContract. + * Use `create(ConstructorInputContractSchema)` to create a new message. + */ +export const ConstructorInputContractSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_quixos_orch, 11); + /** * @generated from message quixos.orch.ListActivationsRequest */ @@ -310,7 +377,7 @@ export type ListActivationsRequest = Message<"quixos.orch.ListActivationsRequest * Use `create(ListActivationsRequestSchema)` to create a new message. */ export const ListActivationsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_quixos_orch, 10); + messageDesc(file_quixos_orch, 12); /** * @generated from message quixos.orch.ListPackageDescriptorsRequest @@ -323,7 +390,7 @@ export type ListPackageDescriptorsRequest = Message<"quixos.orch.ListPackageDesc * Use `create(ListPackageDescriptorsRequestSchema)` to create a new message. */ export const ListPackageDescriptorsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_quixos_orch, 11); + messageDesc(file_quixos_orch, 13); /** * @generated from message quixos.orch.ListPackageDescriptorsResponse @@ -340,7 +407,7 @@ export type ListPackageDescriptorsResponse = Message<"quixos.orch.ListPackageDes * Use `create(ListPackageDescriptorsResponseSchema)` to create a new message. */ export const ListPackageDescriptorsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_quixos_orch, 12); + messageDesc(file_quixos_orch, 14); /** * @generated from message quixos.orch.ListPackageRuntimesRequest @@ -353,7 +420,7 @@ export type ListPackageRuntimesRequest = Message<"quixos.orch.ListPackageRuntime * Use `create(ListPackageRuntimesRequestSchema)` to create a new message. */ export const ListPackageRuntimesRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_quixos_orch, 13); + messageDesc(file_quixos_orch, 15); /** * @generated from message quixos.orch.ListPackageRuntimesResponse @@ -370,7 +437,7 @@ export type ListPackageRuntimesResponse = Message<"quixos.orch.ListPackageRuntim * Use `create(ListPackageRuntimesResponseSchema)` to create a new message. */ export const ListPackageRuntimesResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_quixos_orch, 14); + messageDesc(file_quixos_orch, 16); /** * @generated from message quixos.orch.ListActivationsResponse @@ -387,7 +454,7 @@ export type ListActivationsResponse = Message<"quixos.orch.ListActivationsRespon * Use `create(ListActivationsResponseSchema)` to create a new message. */ export const ListActivationsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_quixos_orch, 15); + messageDesc(file_quixos_orch, 17); /** * @generated from message quixos.orch.CloseActivationRequest @@ -409,7 +476,7 @@ export type CloseActivationRequest = Message<"quixos.orch.CloseActivationRequest * Use `create(CloseActivationRequestSchema)` to create a new message. */ export const CloseActivationRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_quixos_orch, 16); + messageDesc(file_quixos_orch, 18); /** * @generated from message quixos.orch.CloseActivationResponse @@ -426,7 +493,7 @@ export type CloseActivationResponse = Message<"quixos.orch.CloseActivationRespon * Use `create(CloseActivationResponseSchema)` to create a new message. */ export const CloseActivationResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_quixos_orch, 17); + messageDesc(file_quixos_orch, 19); /** * @generated from message quixos.orch.Activation @@ -488,7 +555,7 @@ export type Activation = Message<"quixos.orch.Activation"> & { * Use `create(ActivationSchema)` to create a new message. */ export const ActivationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_quixos_orch, 18); + messageDesc(file_quixos_orch, 20); /** * @generated from message quixos.orch.PackageRuntimeStatus @@ -560,7 +627,7 @@ export type PackageRuntimeStatus = Message<"quixos.orch.PackageRuntimeStatus"> & * Use `create(PackageRuntimeStatusSchema)` to create a new message. */ export const PackageRuntimeStatusSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_quixos_orch, 19); + messageDesc(file_quixos_orch, 21); /** * @generated from service quixos.orch.OrchestratorRuntime diff --git a/test/evolution.test.ts b/test/evolution.test.ts index 1139649..c0ccba8 100644 --- a/test/evolution.test.ts +++ b/test/evolution.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; 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"; 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"; const report = planEvolution(before, after, { allowLegacy: true }); assert.equal(report.storageChanges.length, 1); + assert.deepEqual(report.migrationRequired, []); assert.equal(report.runtimeActions[0]!.action, "replace"); assert.deepEqual(report.reviews, []); 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.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; + 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"); +}); diff --git a/test/file-lock.test.ts b/test/file-lock.test.ts index 937fbb2..2a09875 100644 --- a/test/file-lock.test.ts +++ b/test/file-lock.test.ts @@ -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-")); context.after(() => rm(root, {recursive: true, force: true})); const filename = path.join(root, "lock"); + const events: string[] = []; + let queued: Promise; 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 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(() => {});});`], @@ -23,11 +28,7 @@ test("authoring lock excludes concurrent mutations and survives owner death", as const exited = once(owner, "exit"); owner.kill("SIGKILL"); await exited; - // EOF release happens in the helper; wait a bounded amount for scheduling. let acquired = false; - for (let attempt = 0; attempt < 30 && !acquired; attempt++) { - try { await withFileLock(filename, async () => {acquired = true;}); } - catch (error) { if (!/Another authoring command/.test(String(error))) throw error; } - } + await withFileLock(filename, async () => {acquired = true;}); assert.equal(acquired, true); }); diff --git a/test/scaffold-recipes.test.ts b/test/scaffold-recipes.test.ts index 0b9ddaa..5f3e68a 100644 --- a/test/scaffold-recipes.test.ts +++ b/test/scaffold-recipes.test.ts @@ -55,6 +55,12 @@ test("package/function/migration scaffolds register implementations and refresh await execFile("nix-instantiate", ["--parse", path.join(packageRoot, "flake.nix")]); } 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: [{ file: `${base.directory}/package.qx`, edits: [{operation: "replace", target: {kind: "packageResourceDecl", id: "package:chess"}, source: 'package Other id "package:other" revision "package:other@1" {}'}],