From fae4e48f72d4a2d2787e4d8a7fe22d921a53ae5b Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Sun, 13 Sep 2026 22:07:18 -0700 Subject: [PATCH 1/3] Unify workspace authoring, verification and scaffolding workflows Use exact jj snapshots and one candidate-bound Nix builder for incremental checks, template validation and activation. Keep provenance internal and separate recovery checkpoint failures from local command success. Provision workspace-scoped managed package/interface repositories with recoverable Central effects. Add TypeScript/React presets, function and dependency commands, and scaffold enrollment for all TODO packages. Install authoring guides and controlled Codex sandbox rules. Invalidate module resolutions across cutover, including in-flight races, and content-address host platform entries. Strengthen domain-model and verification instructions. Validated real jj/Nix authoring, React/Slate dependency installation, bottom-up local Git publication, packaged CLI tests, PostgreSQL recovery/auth tests, Web Studio tests and host configuration. Public protocol/helpers and the validated 19-resource TODO template are published. Retained the approved exact private baseline and updated the installation's default template pin to 68d54f0d52be433ebf60bdc1faf7646c57f90307. Master and live deployments remain unchanged. See docs/WORKSPACE_AUTHORING_PROGRESS.md. --- flake.nix | 3 + nix/checked-package.nix | 17 +++ src/capability-language/candidate-check.ts | 144 ++++++++------------ src/capability-language/checked-build.ts | 62 +++++++++ src/capability-language/pin-upgrades.ts | 13 +- src/capability-language/scaffold-recipes.ts | 33 ++--- src/capability-language/structural-plan.ts | 2 +- src/capability-language/tool-cli.ts | 71 +++++++++- test/candidate-check.test.ts | 2 +- test/pin-upgrades.test.ts | 42 ++++++ test/scaffold-recipes.test.ts | 15 +- 11 files changed, 288 insertions(+), 116 deletions(-) create mode 100644 nix/checked-package.nix create mode 100644 src/capability-language/checked-build.ts diff --git a/flake.nix b/flake.nix index 4a7f32f..e63a10c 100644 --- a/flake.nix +++ b/flake.nix @@ -19,6 +19,8 @@ pkgs.esbuild pkgs.protobuf pkgs.git + pkgs.jujutsu + pkgs.gnutar ]; buildPhase = '' runHook preBuild @@ -60,6 +62,7 @@ installPhase = '' runHook preInstall mkdir -p "$out" + install -Dm644 nix/checked-package.nix "$out/share/checked-package.nix" cp --reflink=auto --recursive grammar "$out/grammar" cp --reflink=auto --recursive proto "$out/proto" cp --reflink=auto --recursive dist "$out/dist" diff --git a/nix/checked-package.nix b/nix/checked-package.nix new file mode 100644 index 0000000..e6a79bc --- /dev/null +++ b/nix/checked-package.nix @@ -0,0 +1,17 @@ +# One derivation path for provisional checking and activation. The source and +# schema come from exact committed inputs resolved by the Quixos compiler. +{ source, schema, generator, packageRevisionId, system ? builtins.currentSystem }: +let + packageSource = builtins.path { + path = /. + source; + name = "quixos-package-source"; + filter = path: _: let name = baseNameOf path; in name != ".git" && name != ".jj"; + }; + package = builtins.getFlake ("path:" + builtins.unsafeDiscardStringContext (toString packageSource)); + checked = package.quixosPackages.${system}.checkedServer or + (throw "Package ${packageRevisionId} lacks checkedServer; use the supported package scaffold."); +in checked { + inherit packageRevisionId; + schema = builtins.path { path = /. + schema; name = "candidate-package-bindings.json"; }; + generator = builtins.storePath generator; +} diff --git a/src/capability-language/candidate-check.ts b/src/capability-language/candidate-check.ts index 2ed1ed3..8c77de8 100644 --- a/src/capability-language/candidate-check.ts +++ b/src/capability-language/candidate-check.ts @@ -7,7 +7,8 @@ import { createHash } from "node:crypto"; import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js"; import { createGitCapabilityResolver } from "./git-resolver.js"; import { contentDigest, planEvolution, type EvolutionReview, type WorkspaceRevision } from "../capability-model/index.js"; -import { bindingSchema, generateTypeScriptBindings, type BindingSchema, type TypeScriptBindingOptions } from "../bindings/index.js"; +import { bindingSchema } from "../bindings/index.js"; +import {snapshotCommit, checkoutCommit, buildCheckedPackage} from "./checked-build.js"; const execFile = promisify(execFileCallback); const bytesDigest = (value: Uint8Array) => `sha256:${createHash("sha256").update(value).digest("hex")}`; @@ -64,116 +65,87 @@ export const snapshotRepository = async (source: string, destination: string) => return { source: root, directory: destination, treeDigest: contentDigest(contents), files: contents }; }; +// Local mirrors accelerate resolution, but only their committed locked trees +// may stand in for published dependencies. Never relabel dirty files as a pin. +async function committedResolver(root: string, temporary: string, filename?: string, publishedOnly = false) { + const map = publishedOnly ? {resources: []} : await localResourceSnapshots(root, filename); + const resources = []; + for (const [index, entry] of map.resources.entries()) { + const directory = path.join(temporary, `dependency-${index}`); + await checkoutCommit(entry.directory, entry.commit, directory); + resources.push({...entry, directory}); + } + const snapshotMap = path.join(temporary, "snapshots.json"); + await fs.writeFile(snapshotMap, JSON.stringify({resources})); + return createGitCapabilityResolver({checkoutRoot: path.join(temporary, "resolved"), snapshotMap}); +} + export const checkResourceCandidate = async (options: {root: string; output: string; kind: "package" | "interface"; source: {repository: string; commit: string}; snapshotMap?: string; publishedOnly?: boolean}) => { await fs.mkdir(options.output, {mode: 0o700}); const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-resource-check-")); const blockers: string[] = []; - let diagnostics = ""; - let treeDigest: string | undefined; + let treeDigest: string | undefined, commit: string | undefined, artifactPath: string | undefined; try { - const root = await snapshotRepository(options.root, path.join(temporary, "root")); - treeDigest = root.treeDigest; - const map = options.publishedOnly ? {resources: []} : await localResourceSnapshots(options.root, options.snapshotMap); - const resources = []; - for (const [index, entry] of map.resources.entries()) { - const snapshot = await snapshotRepository(entry.directory, path.join(temporary, `dependency-${index}`)); - resources.push({...entry, directory: snapshot.directory}); - } - const snapshotMap = path.join(temporary, "snapshots.json"); - await fs.writeFile(snapshotMap, JSON.stringify({resources})); - const resolveResource = await createGitCapabilityResolver({checkoutRoot: path.join(temporary, "resolved"), snapshotMap}); - const compiled = await compileCapabilityResourceRepository({rootDirectory: root.directory, kind: options.kind, source: {resolver: "git", ...options.source}, resolveResource}); + commit = await snapshotCommit(options.root); + treeDigest = (await snapshotRepository(options.root, path.join(temporary, "observed"))).treeDigest; + const root = path.join(temporary, "source"); + await checkoutCommit(options.root, commit, root); + const resolveResource = await committedResolver(options.root, temporary, options.snapshotMap, options.publishedOnly); + const compiled = await compileCapabilityResourceRepository({rootDirectory: root, kind: options.kind, source: {resolver: "git", repository: options.source.repository, commit}, resolveResource}); if (compiled.resource.kind === "package") { - const configuration = JSON.parse(await fs.readFile(path.join(root.directory, "quixos.check.json"), "utf8")); - const output = configuration.bindingOutput as string; - if (configuration.backend !== "typescript" || !/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*\.ts$/.test(output)) throw new Error("Unsupported candidate checker configuration"); - const modules = path.join(root.source, "node_modules"); - await fs.access(path.join(modules, ".bin/tsc")); - await fs.symlink(modules, path.join(root.directory, "node_modules"), "dir"); - const destination = path.join(root.directory, output); - await fs.mkdir(path.dirname(destination), {recursive: true}); - await fs.writeFile(destination, generateTypeScriptBindings(bindingSchema(compiled), compiled.resource.revision.revisionId, configuration.options)); - diagnostics = (await execFile(path.join(modules, ".bin/tsc"), ["--noEmit", "--pretty", "false"], {cwd: root.directory, maxBuffer: 16 * 1024 * 1024})).stdout; + const schema = path.join(temporary, "bindings.json"); + await fs.writeFile(schema, JSON.stringify(bindingSchema(compiled))); + artifactPath = await buildCheckedPackage(root, schema, compiled.resource.revision.revisionId); } + if (await snapshotCommit(options.root) !== commit) throw new Error("Source changed during verification; run the check again"); await fs.writeFile(path.join(options.output, "candidate.json"), JSON.stringify(compiled.resource, null, 2)); } catch (error) { blockers.push(error instanceof Error ? error.message : String(error)); - diagnostics += (error as {stdout?: string; stderr?: string}).stdout ?? ""; - diagnostics += (error as {stderr?: string}).stderr ?? ""; } finally {await fs.rm(temporary, {recursive: true, force: true});} - const result = {candidateOnly: true, activationEvidence: false, treeDigest, blockers, diagnostics}; + const result = {candidateOnly: true, activationEvidence: false, commit, treeDigest, artifactPath, blockers, + note: "Checked immutable candidate; cutover independently checks current migration/review requirements. No publication or activation performed."}; await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2)); return result; }; -export const checkWorkspaceCandidate = async (options: { root: string; output: string; snapshotMap?: string; baseline?: string; reviews?: string }) => { - // A new output directory is the whole artifact boundary; never overwrite a prior check. - await fs.mkdir(options.output, { mode: 0o700 }); - const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "quixos-candidate-")); - const blockers: string[] = []; - const checks: unknown[] = []; - const snapshots = []; +export const checkWorkspaceCandidate = async (options: {root: string; output: string; snapshotMap?: string; baseline?: string; reviews?: string}) => { + await fs.mkdir(options.output, {mode: 0o700}); + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-workspace-check-")); + const blockers: string[] = [], checks: {packageRevisionId: string; artifactPath: string}[] = []; + let commit: string | undefined; try { - const root = await snapshotRepository(options.root, path.join(temporary, "root")); - snapshots.push(root); - const map = await localResourceSnapshots(options.root, options.snapshotMap); - const resources = []; - for (const [index, entry] of map.resources.entries()) { - const source = entry.directory; - const snapshot = await snapshotRepository(source, path.join(temporary, `resource-${index}`)); - snapshots.push(snapshot); - resources.push({ ...entry, directory: snapshot.directory }); + commit = await snapshotCommit(options.root); + for (const entry of (await localResourceSnapshots(options.root, options.snapshotMap)).resources) { + const current = await snapshotCommit(entry.directory); + const tree = async (revision: string) => (await execFile("git", ["rev-parse", `${revision}^{tree}`], {cwd: entry.directory})).stdout.trim(); + if (await tree(current) !== await tree(entry.commit)) throw new Error(`Edited resource is not in the root's locked candidate: ${entry.directory}. Check that resource, then run qx-workspace resource upgrade --publish to propagate its revision.`); } - const mapFile = path.join(temporary, "snapshots.json"); - await fs.writeFile(mapFile, JSON.stringify({ resources })); - const resolveResource = await createGitCapabilityResolver({ checkoutRoot: path.join(temporary, "resolved"), snapshotMap: mapFile }); - const compiled = await compileWorkspaceRepository({ rootDirectory: root.directory, resolveResource }); + const root = path.join(temporary, "source"); + await checkoutCommit(options.root, commit, root); + const resolveResource = await committedResolver(options.root, temporary, options.snapshotMap); + const compiled = await compileWorkspaceRepository({rootDirectory: root, sourceRootCommit: commit, resolveResource}); const baseline = options.baseline ? JSON.parse(await fs.readFile(options.baseline, "utf8")) as WorkspaceRevision : null; const reviews = options.reviews ? JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[] : []; - const evolution = planEvolution(baseline, compiled.workspace, { reviews }); + const evolution = planEvolution(baseline, compiled.workspace, {reviews}); blockers.push(...evolution.blockers); - const schema: BindingSchema = { format: "quixos-bindings", version: 1, interfaces: compiled.workspace.interfaceImports, packages: compiled.workspace.packageImports }; - for (const resource of compiled.resources.filter((entry) => entry.kind === "package")) { - if (resource.resource.kind !== "package") continue; - const revision = resource.resource.revision; - let config: { backend: string; bindingOutput: string; options?: TypeScriptBindingOptions }; - try { config = JSON.parse(await fs.readFile(path.join(resource.directory, "quixos.check.json"), "utf8")); } - catch { blockers.push(`No candidate checker configured for ${revision.revisionId} (quixos.check.json)`); continue; } - if (config.backend !== "typescript" || !/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*\.ts$/.test(config.bindingOutput) - || config.bindingOutput.split("/").includes("..")) { blockers.push(`Unsupported checker or binding path for ${revision.revisionId}`); continue; } - const sourceSnapshot = snapshots.find((entry) => entry.directory === resource.directory); - const dependencyRoot = sourceSnapshot?.source ?? resource.directory; - const modules = path.join(dependencyRoot, "node_modules"); - try { await fs.access(path.join(modules, ".bin", "tsc")); } - catch { blockers.push(`Missing installed TypeScript checker/dependencies for ${revision.revisionId}; install its locked development dependencies first`); continue; } - if (sourceSnapshot) await fs.symlink(modules, path.join(resource.directory, "node_modules"), "dir"); - const generated = generateTypeScriptBindings(schema, revision.revisionId, config.options); - const destination = path.join(resource.directory, config.bindingOutput); - await fs.mkdir(path.dirname(destination), { recursive: true }); - await fs.writeFile(destination, generated); - let success = false, diagnostics = ""; - try { diagnostics = (await execFile(path.join(modules, ".bin", "tsc"), ["--noEmit", "--pretty", "false", "--listFiles"], { cwd: resource.directory, maxBuffer: 16 * 1024 * 1024 })).stdout; success = true; } - catch (error) { const result = error as Error & {stdout?: string; stderr?: string}; diagnostics = `${result.stdout ?? ""}\n${result.stderr ?? result.message}`; } - const typeInputs = []; - for (const line of diagnostics.split(/\r?\n/)) if (path.isAbsolute(line) && /\.[cm]?tsx?$/.test(line)) { - try { typeInputs.push({file: line, digest: bytesDigest(await fs.readFile(line))}); } catch { success = false; } - } - const checker = await fs.realpath(path.join(modules, ".bin", "tsc")); - const check = { packageRevisionId: revision.revisionId, success, bindingSchemaDigest: contentDigest(schema), generatedDigest: contentDigest(generated), - checkerDigest: contentDigest({ executable: bytesDigest(await fs.readFile(checker)), typeInputs }), diagnostics }; - checks.push(check); - if (!success) blockers.push(`Typecheck failed for ${revision.revisionId}`); + for (const resource of compiled.resources.filter(entry => entry.kind === "package")) { + // Per-package recursive schema, identical to host activation, not unrelated + // workspace declarations that would unnecessarily invalidate build caches. + const candidate = await compileCapabilityResourceRepository({rootDirectory: resource.directory, kind: "package", source: resource.source, resolveResource}); + const schema = path.join(temporary, "bindings.json"); + await fs.writeFile(schema, JSON.stringify(bindingSchema(candidate))); + const artifactPath = await buildCheckedPackage(resource.directory, schema, candidate.resource.revision.revisionId); + checks.push({packageRevisionId: candidate.resource.revision.revisionId, artifactPath}); } - const result = { schemaVersion: 1, candidateOnly: true, activationEvidence: false, - sourceDigest: contentDigest(snapshots.map(({source, treeDigest}) => ({source, treeDigest}))), - snapshots: snapshots.map(({source, treeDigest}) => ({source, treeDigest})), evolution, checks, blockers, - note: "Local source-tree checks do not certify old Git revisions. Publication must repin the DAG and recheck final immutable artifacts." }; + if (await snapshotCommit(options.root) !== commit) throw new Error("Source changed during verification; run the check again"); + const result = {schemaVersion: 1, candidateOnly: true, activationEvidence: false, commit, evolution, checks, blockers, + note: "Checks the committed root and its exact locked dependencies. Resource edits must be verified and repinned before they enter this candidate. No publication or activation performed."}; await fs.writeFile(path.join(options.output, "candidate.json"), JSON.stringify(compiled.workspace, null, 2)); await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2)); return result; } catch (error) { - const result = { schemaVersion: 1, candidateOnly: true, activationEvidence: false, checks, blockers: [...blockers, error instanceof Error ? error.message : String(error)] }; + const result = {schemaVersion: 1, candidateOnly: true, activationEvidence: false, commit, checks, blockers: [...blockers, error instanceof Error ? error.message : String(error)]}; await fs.writeFile(path.join(options.output, "report.json"), JSON.stringify(result, null, 2)); return result; - } finally { await fs.rm(temporary, { recursive: true, force: true }); } + } finally {await fs.rm(temporary, {recursive: true, force: true});} }; diff --git a/src/capability-language/checked-build.ts b/src/capability-language/checked-build.ts new file mode 100644 index 0000000..ec9dcd2 --- /dev/null +++ b/src/capability-language/checked-build.ts @@ -0,0 +1,62 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import {execFile as callback, spawn} from "node:child_process"; +import {promisify} from "node:util"; +import {fileURLToPath} from "node:url"; +const execFile = promisify(callback); +const environment = () => ({...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", GIT_TERMINAL_PROMPT: "0"}); + +/** Checking snapshots jj, but never publishes or activates the working copy. */ +export async function snapshotCommit(root: string): Promise { + const run = async (...args: string[]) => (await execFile("jj", args, {cwd: root, env: environment()})).stdout.trim(); + await run("status"); + // jj resolve --list exits 1 on a clean revision. Query structured revision + // metadata instead of depending on diagnostic wording or swallowing errors. + if (await run("--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "conflict") !== "false") throw new Error("Resolve source conflicts before verification"); + const commit = await run("--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"); + if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) throw new Error("Verification requires an exact jj commit"); + await execFile("git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"], {cwd: root, env: environment()}); + const {stdout} = await execFile("git", ["ls-files", "--others", "--exclude-standard", "-z"], {cwd: root}); + if (stdout) throw new Error("Source contains files not captured by jj; inspect jj tracking before verification"); + return commit; +} + +/** Use Git's actual committed tree, never dirty overlays labelled as old pins. */ +export async function checkoutCommit(root: string, commit: string, destination: string) { + await fs.mkdir(destination, {recursive: true}); + const archive = await execFile("git", ["archive", "--format=tar", commit], {cwd: root, encoding: "buffer", maxBuffer: 128 * 1024 * 1024}); + await new Promise((resolve, reject) => { + const child = spawn("tar", ["-xf", "-", "-C", destination], {stdio: ["pipe", "ignore", "pipe"]}); + let error = ""; + child.stderr.on("data", chunk => {error += chunk;}); + child.on("error", reject); + child.on("close", code => code === 0 ? resolve() : reject(new Error(`Cannot extract committed source: ${error}`))); + child.stdin.on("error", reject); + child.stdin.end(archive.stdout); + }); +} + +/** Shared by provisional checks, template publication and host activation. + * The Nix derivation is the cached check; its metadata is internal provenance, + * not a certificate authored or approved by the workspace agent. + */ +export async function buildCheckedPackage(source: string, schema: string, packageRevisionId: string): Promise { + const generator = process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + if (!/^\/nix\/store\/[^/]+$/.test(generator)) throw new Error("Run verification with the installed Quixos tooling (its exact Nix checker is required)"); + const builder = path.join(generator, "share/checked-package.nix"); + await fs.access(builder); + return await new Promise((resolve, reject) => { + const child = spawn("nix", ["build", "--impure", "--file", builder, + "--argstr", "source", source, "--argstr", "schema", schema, + "--argstr", "generator", generator, "--argstr", "packageRevisionId", packageRevisionId, + "--no-link", "--print-out-paths", "-L"], {env: environment(), stdio: ["ignore", "pipe", "inherit"]}); + let output = ""; + child.stdout.on("data", chunk => {output += chunk;}); + child.on("error", reject); + child.on("close", code => { + const artifact = output.trim(); + if (code !== 0 || !/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(artifact)) reject(new Error(`Checked Nix build failed (${code}); see build diagnostics above`)); + else resolve(artifact); + }); + }); +} diff --git a/src/capability-language/pin-upgrades.ts b/src/capability-language/pin-upgrades.ts index 7310dc0..f84224d 100644 --- a/src/capability-language/pin-upgrades.ts +++ b/src/capability-language/pin-upgrades.ts @@ -10,6 +10,7 @@ import {planStructure, applyStructure, type StructuralRequest} from "./structura import {snapshotRepository, checkResourceCandidate, checkWorkspaceCandidate} from "./candidate-check.js"; import {compileWorkspaceRepository, compileCapabilityResourceRepository, type ResolvedCapabilityResource} from "./assembly.js"; import {createGitCapabilityResolver} from "./git-resolver.js"; +import {snapshotCommit} from "./checked-build.js"; const execFile = promisify(callback); type Source = {repository: string; commit: string}; export type UpgradeNode = {kind: "workspace" | "package" | "interface"; directory: string; source: Source}; @@ -47,7 +48,7 @@ export const discoverUpgradeSpec = async (workbench: string): Promise ({kind: entry.kind, source: entry.source, directory: path.relative(workbench, path.resolve(workbench, entry.directory))}))]; @@ -75,7 +76,7 @@ export const planPinUpgrades = async (workbenchPath: string, spec: UpgradeSpec): const nodes: NodePlan[] = []; for (const node of spec.nodes) { const root = await location(workbench, node.directory); - if (await command(root, "git", ["remote", "get-url", "origin"]) !== node.source.repository) throw new Error(`Upgrade origin differs from selected source: ${node.directory}`); + if (await command(root, "git", ["config", "--get", "remote.origin.url"]) !== node.source.repository) throw new Error(`Upgrade origin differs from selected source: ${node.directory}`); const loaded = await loadQuixosLock(path.join(root, "quixos.lock")); if (!loaded.ok) throw new Error(`Invalid lock in ${node.directory}: ${loaded.diagnostics.map((entry) => entry.message).join("; ")}`); const dependencies = loaded.lock.resources.map((resource) => keys.get(sourceKey(resource))).filter((value): value is string => Boolean(value)); @@ -113,11 +114,7 @@ const effects: UpgradeEffects = { if (evolution?.reviews.some((review) => !review.accepted)) throw new Error("Explicit semantic-major review required before publishing the workspace"); }, async snapshot(root) { - // Unlike checking, publication deliberately captures the working copy. - await command(root, "jj", ["status"]); - const conflicts = await command(root, "jj", ["resolve", "--list"]); - if (conflicts) throw new Error("Resolve source conflicts before publication"); - const commit = await command(root, "jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"]); + const commit = await snapshotCommit(root); if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(commit)) throw new Error("Publication did not resolve an exact commit"); await command(root, "git", ["diff", "--exit-code", "--no-ext-diff", "--no-textconv", commit, "--"]); const tracked = new Set((await command(root, "git", ["ls-tree", "-r", "--name-only", "-z", commit])).split("\0")); @@ -152,7 +149,7 @@ export const applyPinUpgrades = async (plan: UpgradePlan, journalId?: string, im if (!journalId) await writeJournal(filename, journal); for (const node of plan.nodes) { const root = await location(plan.workbench, node.directory); - if (await command(root, "git", ["remote", "get-url", "origin"]) !== node.source.repository) throw new Error("Upgrade remote changed after planning"); + if (await command(root, "git", ["config", "--get", "remote.origin.url"]) !== node.source.repository) throw new Error("Upgrade remote changed after planning"); let step = journal.steps.find((entry) => entry.directory === node.directory); if (step?.phase === "published") continue; if (!step) { diff --git a/src/capability-language/scaffold-recipes.ts b/src/capability-language/scaffold-recipes.ts index de0112f..1e315cc 100644 --- a/src/capability-language/scaffold-recipes.ts +++ b/src/capability-language/scaffold-recipes.ts @@ -9,6 +9,7 @@ import type {StructuralRequest} from "./structural-plan.js"; type Source = {repository: string; commit: string}; type Registry = {generatedBy: "qx-scaffold-v1"; name: string; id: string; revision: string; exports: {name: string; id: string; file: string; migration?: boolean}[]}; export type ScaffoldRecipe = { + template?: "typescript" | "typescript-react"; source: Source; directory?: string; name?: string; id?: string; revision?: string; declaration?: string; tools?: {quixos: Source; protocol: Source; helpers: Source; sdk: Source}; @@ -49,23 +50,31 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio let catalog: MigrationCatalog & {generatedBy: "qx-scaffold-v1"}; if (command === "package") { const name = safeName(spec.name); + if (spec.template && !["typescript", "typescript-react"].includes(spec.template)) throw new Error("Unknown package template"); + const react = spec.template === "typescript-react"; if (!spec.id || !spec.revision || !spec.tools) throw new Error("Package scaffold requires id, revision, and exact quixos/protocol/helpers/sdk tool sources"); Object.values(spec.tools).forEach(source); registry = {generatedBy: "qx-scaffold-v1", name, id: spec.id, revision: spec.revision, exports: []}; catalog = {generatedBy: "qx-scaffold-v1", schemaVersion: 1, contracts: {}, migrations: []}; - create("package.qx", `package ${name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n}\n`); + if (react) registry.exports.push({name: "sourceGet", id: `export:${name}:source`, file: "src/impl/sourceGet.ts"}); + create("package.qx", `package ${name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n${react ? ` function sourceGet id ${JSON.stringify(`export:${name}:source`)} : unit -> string;\n` : ""}}\n`); create("quixos.lock", formatQuixosLock({formatVersion: 1, quixos: source(spec.tools.quixos), resources: []})); create("package.json", json({name: `@quixos/${name.toLowerCase()}`, version: "0.1.0", private: true, type: "module", packageManager: "yarn@4.18.0", - scripts: {build: "tsc -p tsconfig.json", typecheck: "tsc --noEmit"}, dependencies: {"@quixos/camino-package-runtime": `${spec.tools.sdk.repository}#commit=${spec.tools.sdk.commit}`}, - devDependencies: {"@types/node": "^24", typescript: "^7.0.2"}})); - create("tsconfig.json", json({compilerOptions: {target: "ES2023", module: "NodeNext", moduleResolution: "NodeNext", strict: true, outDir: "dist", skipLibCheck: true}, include: ["src/**/*.ts"]})); + scripts: {build: `tsc -p tsconfig.json${react ? " && node scripts/build-component.mjs" : ""}`, typecheck: "tsc --noEmit"}, dependencies: {"@quixos/camino-package-runtime": `${spec.tools.sdk.repository}#commit=${spec.tools.sdk.commit}`}, + devDependencies: {"@types/node": "^24", typescript: "^7.0.2", ...(react ? {react: "^18.3.1", "@types/react": "^18.3.12", esbuild: "^0.25.12"} : {})}})); + create("tsconfig.json", json({compilerOptions: {target: "ES2023", module: "NodeNext", moduleResolution: "NodeNext", strict: true, types: ["node", ...(react ? ["react"] : [])], outDir: "dist", rootDir: "src", skipLibCheck: true, ...(react ? {jsx: "react-jsx", esModuleInterop: true} : {})}, include: ["src/**/*.ts", "src/**/*.tsx"]})); + if (react) { + create("src/component.tsx", `// Props are opaque at the platform boundary until capability generics exist.\nexport default function Component(_props: {camino: unknown; render: unknown; dispatch: (action: unknown) => void}) {\n return

${name}

Edit this component, then run qx-workspace check.

;\n}\n`); + create("src/impl/sourceGet.ts", `import {readFile} from "node:fs/promises";\nimport type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["sourceGet"] = () => readFile(new URL("./component.mjs", import.meta.url), "utf8");\n`); + create("scripts/build-component.mjs", `import {build} from "esbuild";\nconst platform = new Map([\n ["react", "/__quixos/platform/react/v18.mjs"],\n ["react/jsx-runtime", "/__quixos/platform/react-jsx-runtime/v18.mjs"],\n ["react/jsx-dev-runtime", "/__quixos/platform/react-jsx-dev-runtime/v18.mjs"],\n ["@quixos/web-studio-react-runtime", "/__quixos/platform/web-studio-react-runtime/v1.mjs"],\n]);\nawait build({entryPoints: ["dist/component.js"], outfile: "dist/component.mjs", bundle: true, format: "esm", platform: "browser", target: "es2022", plugins: [{name: "quixos-platform", setup(api) {api.onResolve({filter: /.*/}, ({path}) => platform.has(path) ? {path: platform.get(path), external: true} : undefined);}}]});\n`); + } create(".gitignore", "node_modules/\ndist/\n.quixos/\nresult\n.yarn/install-state.gz\n"); create(".yarnrc.yml", `nodeLinker: node-modules\nenableScripts: true\nnpmMinimalAgeGate: 0\napprovedGitRepositories:\n - ${JSON.stringify(spec.tools.sdk.repository)}\nsupportedArchitectures:\n os: [current, linux]\n cpu: [current, x64, arm64]\n libc: [current, glibc]\n`); const nixifyPluginUrl = spec.nixifyPluginUrl ?? "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/yarn-plugin-nixify-patched/raw/commit/4528fdd20b30d869262443b3f044549810e75fb8/dist/yarn-plugin-nixify.js"; const plugin = new URL(nixifyPluginUrl); if (plugin.protocol !== "https:" || plugin.username || plugin.password || plugin.search || plugin.hash || !/\/commit\/[a-f0-9]{40,64}\//.test(plugin.pathname)) throw new Error("Nixify plugin must have an exact credential-free HTTPS commit URL"); generated("quixos.toolchain.json", json({generatedBy: "qx-scaffold-v1", nixifyPluginUrl})); - create("quixos.check.json", json({backend: "typescript", bindingOutput: "src/generated-bindings.ts"})); + create("quixos.check.json", json({backend: "typescript", bindingOutput: "src/gen/qx.ts"})); create("flake.nix", `{ inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))}; inputs.nixpkgs.follows = "protocol/nixpkgs"; @@ -74,17 +83,9 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio outputs = inputs@{ self, protocol, nixpkgs, flake-utils, helpers, ... }: (import (toString helpers + "/quixos-package-helpers.nix")).mkCaminoTsYarnNixifyFlake { inherit inputs nixpkgs flake-utils; packageRoot = ./.; - bindings = { system, ... }: { - generator = (builtins.getAttr system protocol.packages).default; - repository = ${nixString(spec.source.repository)}; - commit = self.rev or (throw "Publish an exact package revision before building an activation artifact"); - packageRevisionId = ${nixString(spec.revision)}; - output = "src/generated-bindings.ts"; - resources = map (entry: entry // { directory = builtins.fetchGit { url = entry.repository; rev = entry.commit; ref = "refs/tags/quixos-reachability/" + entry.commit; }; }) (builtins.fromJSON (builtins.readFile ./quixos.resources.json)).resources; - }; bundle = { entry = "dist/server.js"; }; migrationEntrypoint = "dist/migrate.js"; - installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; }; + installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; ${react ? 'extraFiles = [ { source = "dist/component.mjs"; target = "component.mjs"; } ];' : ""} }; }; }\n`); generated("quixos.resources.json", json({generatedBy: "qx-scaffold-v1", resources: []})); @@ -107,7 +108,7 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio files.push({file: prefix + "package.qx", edits: [{operation: "append", parent: {kind: "packageResourceDecl", id: registry.id}, source: declaration}]}); const file = `src/${command === "migration" ? "migrations" : "impl"}/${name}.ts`; const implementation = command === "migration" ? `import type {MigrationContext} from "@quixos/camino-package-runtime";\nexport const handler = async (_context: MigrationContext): Promise => { throw new Error(${JSON.stringify(`Implement migration ${name}`)}); };\n` - : `import type {Implementation} from "../generated-bindings.js";\nexport const handler: Implementation[${JSON.stringify(name)}] = ${derived ? '{kind: "derived", get: ' : ""}async (_context) => { throw new Error(${JSON.stringify(`Implement ${name}`)}); }${derived ? "}" : ""};\n`; + : `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`; create(file, implementation); registry.exports.push({name, id: spec.id, file, ...(command === "migration" ? {migration: true} : {})}); if (command === "migration") { @@ -129,7 +130,7 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio validateMigrationCatalog(catalog, new Set(registry.exports.map((entry) => entry.id))); generated("quixos.scaffold.json", json(registry)); generated("quixos.migrations.json", json(catalog)); - generated("src/server.ts", marker + `import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./generated-bindings.js";\n` + registry.exports.filter((entry) => !entry.migration).map((entry, index) => `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`).join("") + + generated("src/server.ts", marker + `import {servePackageRuntime} from "@quixos/camino-package-runtime";\nimport {createRuntime} from "./gen/qx.js";\n` + registry.exports.filter((entry) => !entry.migration).map((entry, index) => `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`).join("") + `servePackageRuntime(createRuntime({\n` + registry.exports.map((entry) => ` ${JSON.stringify(entry.name)}: ${entry.migration ? 'async () => { throw new Error("Migration-only export"); }' : `impl${registry.exports.filter((value) => !value.migration).indexOf(entry)}`},`).join("\n") + `\n}));\n`); const migrations = registry.exports.filter((entry) => entry.migration); generated("src/migrate.ts", marker + `import {serveMigration} from "@quixos/camino-package-runtime";\n` + migrations.map((entry, index) => `import {handler as impl${index}} from ${JSON.stringify(`./${entry.file.slice(4, -3)}.js`)};\n`).join("") + `await serveMigration({${migrations.map((entry, index) => `${JSON.stringify(entry.id)}: impl${index}`).join(", ")}});\n`); diff --git a/src/capability-language/structural-plan.ts b/src/capability-language/structural-plan.ts index 5cd0e01..28be4b9 100644 --- a/src/capability-language/structural-plan.ts +++ b/src/capability-language/structural-plan.ts @@ -18,7 +18,7 @@ export type StructuralRequest = { type Change = {file: string; before: string | null; after: string; mode: number}; type Journal = {schemaVersion: 1; id: string; root: string; phase: "prepared" | "complete"; changes: Change[]}; const safeFile = (file: string) => { - if (!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc.yml|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|lock|ts|tsx|json|nix|txtpb))$/.test(file) + if (!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*(?:\.gitignore|\.yarnrc.yml|[A-Za-z0-9_-][A-Za-z0-9_.-]*\.(?:qx|lock|ts|tsx|mjs|json|nix|txtpb))$/.test(file) || file.split("/").some((part) => [".git", ".jj", ".quixos", "node_modules"].includes(part))) throw new Error(`Unsafe scaffold path ${file}`); }; const read = async (root: string, file: string): Promise => { diff --git a/src/capability-language/tool-cli.ts b/src/capability-language/tool-cli.ts index 6ea6998..a9788ac 100644 --- a/src/capability-language/tool-cli.ts +++ b/src/capability-language/tool-cli.ts @@ -11,9 +11,61 @@ import os from "node:os"; import {spawnSync} from "node:child_process"; import { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "./structural-plan.js"; import {scaffoldRecipe, type ScaffoldRecipe} from "./scaffold-recipes.js"; +import {buildCheckedPackage, snapshotCommit} from "./checked-build.js"; +import {formatQuixosLock, loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js"; + +const authorSource = async (root: string) => { + const result = spawnSync("git", ["remote", "get-url", "origin"], {cwd: root, encoding: "utf8"}); + if (result.error || result.status !== 0) throw new Error("Managed resource has no origin"); + return {repository: result.stdout.trim(), commit: await snapshotCommit(root)}; +}; const main = async () => { const [command, ...args] = process.argv.slice(2); + if (command === "scaffold-dependency") { + const [root, kind, name, repository, commit, ...flags] = args; + if (!root || !["interface", "package"].includes(kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name ?? "") || !repository || !commit || flags.some(flag => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-dependency ROOT interface|package NAME REPOSITORY COMMIT [--write]"); + const resourceKind = kind as "package" | "interface"; + let entrypoint: "workspace" | "package" | "interface" | undefined; + for (const candidate of ["workspace", "package", "interface"] as const) { + try {await readFile(path.join(root, `${candidate}.qx`)); if (entrypoint) throw new Error("Ambiguous repository entrypoint"); entrypoint = candidate;} + catch (error) {if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;} + } + if (!entrypoint) throw new Error("No QX repository entrypoint"); + const lock = await loadQuixosLock(path.join(root, "quixos.lock")); + if (!lock.ok) throw new Error("Invalid resource lock"); + let target = "quixos.lock"; + for (const file of lock.lock.sourceFiles ?? ["quixos.lock"]) { + const parsed = parseQuixosLockDocument(await readFile(path.join(root, file), "utf8")); + if (parsed.ok && parsed.document.resources.some(entry => entry.kind === kind && entry.binding === name)) target = file; + } + const request: StructuralRequest = {kind: entrypoint, source: await authorSource(root), files: [ + {file: `${entrypoint}.qx`, edits: [{operation: "import", kind: resourceKind, name}]}, + {file: target, edits: [{operation: "dependency", kind: resourceKind, name, source: {repository, commit}}]}, + ]}; + const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP); + process.stdout.write(`${JSON.stringify({...plan, applied: flags.includes("--write") ? await applyStructure(plan) : undefined}, null, 2)}\n`); + return; + } + if (command === "scaffold-interface") { + const [root, specFile, ...flags] = args; + if (!root || !specFile || flags.some(flag => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-interface ROOT SPEC_JSON [--write]"); + const spec = JSON.parse(await readFile(specFile, "utf8")) as ScaffoldRecipe; + if (!spec.name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) || !spec.id || !spec.revision || !spec.tools?.quixos) throw new Error("Interface scaffold requires name, id, revision and Quixos toolchain source"); + const request: StructuralRequest = {kind: "interface", source: spec.source, files: [ + {file: "interface.qx", create: `interface ${spec.name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n}\n`}, + {file: "quixos.lock", create: formatQuixosLock({formatVersion: 1, quixos: {resolver: "git", ...spec.tools.quixos}, resources: []})}, + {file: ".gitignore", create: ".quixos/\n"}, + ]}; + const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP); + process.stdout.write(`${JSON.stringify({...plan, applied: flags.includes("--write") ? await applyStructure(plan) : undefined}, null, 2)}\n`); + return; + } + if (command === "build-package") { + if (args.length !== 3) throw new Error("usage: quixos-qx build-package COMMITTED_SOURCE SCHEMA PACKAGE_REVISION_ID"); + process.stdout.write(`${await buildCheckedPackage(args[0], args[1], args[2])}\n`); + return; + } if (command === "source-digest") { if (!args[0] || args.length !== 1) throw new Error("usage: quixos-qx source-digest ROOT"); const temporary = await mkdtemp(path.join(os.tmpdir(), "qx-source-digest-")); @@ -47,8 +99,18 @@ const main = async () => { } if (["scaffold-package", "scaffold-function", "scaffold-migration", "scaffold-refresh"].includes(command)) { const [root, specFile, ...flags] = args; + let spec: ScaffoldRecipe; + if (command === "scaffold-function" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(specFile ?? "")) { + const registry = JSON.parse(await readFile(path.join(root, "quixos.scaffold.json"), "utf8")); + spec = {source: await authorSource(root), name: specFile, id: `export:${registry.name}:${specFile}`}; + const declaration = flags.indexOf("--declaration"); + if (declaration >= 0) { + if (!flags[declaration + 1]) throw new Error("--declaration requires a QX declaration file"); + spec.declaration = await readFile(flags[declaration + 1], "utf8"); + flags.splice(declaration, 2); + } + } else spec = JSON.parse(await readFile(specFile, "utf8")) as ScaffoldRecipe; if (!root || !specFile || flags.some((flag) => !["--write", "--install"].includes(flag)) || (flags.includes("--install") && !flags.includes("--write"))) throw new Error("usage: quixos-qx scaffold-package|function|migration|refresh ROOT SPEC_JSON [--write [--install]]"); - const spec = JSON.parse(await readFile(specFile, "utf8")) as ScaffoldRecipe; const request = await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration" | "refresh", spec); const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP); const applied = flags.includes("--write") ? await applyStructure(plan) : undefined; @@ -56,10 +118,15 @@ const main = async () => { const cwd = path.resolve(root, spec.directory ?? ""); const toolchain = JSON.parse(await readFile(path.join(cwd, "quixos.toolchain.json"), "utf8")); if (toolchain.generatedBy !== "qx-scaffold-v1" || typeof toolchain.nixifyPluginUrl !== "string") throw new Error("Missing scaffold toolchain"); - for (const [executable, args] of [["corepack", ["yarn", "plugin", "import", toolchain.nixifyPluginUrl]], ["corepack", ["yarn", "config", "set", "generateDefaultNix", "false"]], ["corepack", ["yarn", "config", "set", "individualNixPackaging", "true"]], ["corepack", ["yarn", "install"]], ["corepack", ["yarn", "typecheck"]], ["nix", ["flake", "lock"]]] as const) { + for (const [executable, args] of [["corepack", ["yarn", "plugin", "import", toolchain.nixifyPluginUrl]], ["corepack", ["yarn", "config", "set", "generateDefaultNix", "false"]], ["corepack", ["yarn", "config", "set", "individualNixPackaging", "true"]], ["corepack", ["yarn", "install"]]] as const) { const result = spawnSync(executable, [...args], {cwd, stdio: ["inherit", 2, 2]}); if (result.error || result.status !== 0) throw new Error(`Scaffold files retained; ${executable} ${args.join(" ")} failed: ${result.error?.message ?? result.status}`); } + try {await readFile(path.join(cwd, "yarn-project.nix"));} + catch {throw new Error("Nixify did not generate yarn-project.nix. It skips repositories under the OS temporary directory; use an ordinary workspace checkout and retry installation.");} + await snapshotCommit(cwd); + const locked = spawnSync("nix", ["flake", "lock"], {cwd, stdio: ["inherit", 2, 2]}); + if (locked.error || locked.status !== 0) throw new Error("Scaffold files retained; nix flake lock failed"); } process.stdout.write(`${JSON.stringify({...plan, applied}, null, 2)}\n`); return; diff --git a/test/candidate-check.test.ts b/test/candidate-check.test.ts index 5248d30..757d46c 100644 --- a/test/candidate-check.test.ts +++ b/test/candidate-check.test.ts @@ -37,7 +37,7 @@ test("candidate check produces explicitly non-activation evidence and never over context.after(() => fs.rm(directory, { recursive: true, force: true })); const root = path.join(directory, "source"), output = path.join(directory, "check"); await fs.mkdir(root); - await execFile("git", ["-C", root, "init"]); + await execFile("jj", ["git", "init", "--colocate", root]); await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos.git"; commit "${"a".repeat(40)}"; } }`); await fs.writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"b".repeat(40)}" { atom Subject id "atom:subject"; }`); const result = await checkWorkspaceCandidate({root, output}); diff --git a/test/pin-upgrades.test.ts b/test/pin-upgrades.test.ts index bf9baad..da493d2 100644 --- a/test/pin-upgrades.test.ts +++ b/test/pin-upgrades.test.ts @@ -8,6 +8,48 @@ import {execFile as callback} from "node:child_process"; import {planPinUpgrades, applyPinUpgrades, type UpgradeEffects} from "../src/capability-language/pin-upgrades.js"; const execFile = promisify(callback); +test("real jj snapshots and immutable Git publication propagate a changed interface into the root", async (context) => { + const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-real-upgrade-")); + context.after(() => fs.rm(workbench, {recursive: true, force: true})); + // Exercise the actual effects with local bare remotes, without external writes. + const environment = { + GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: `url.file://${workbench}/remotes/.insteadOf`, + GIT_CONFIG_VALUE_0: "https://upgrade.test/", QUIXOS_JJ_NO_CHECKPOINT: "1", + }; + const previous = Object.fromEntries(Object.keys(environment).map(key => [key, process.env[key]])); + Object.assign(process.env, environment); + context.after(() => {for (const [key, value] of Object.entries(previous)) if (value === undefined) delete process.env[key]; else process.env[key] = value;}); + const run = async (cwd: string, command: string, args: string[]) => (await execFile(command, args, {cwd})).stdout.trim(); + await fs.mkdir(path.join(workbench, "remotes")); + const nodes = []; + for (const [kind, directory, remote] of [["interface", "resources/Named", "named.git"], ["workspace", "root", "workspace.git"]] as const) { + const root = path.join(workbench, directory); + await fs.mkdir(root, {recursive: true}); + await run(workbench, "git", ["init", "--bare", path.join(workbench, "remotes", remote)]); + await run(root, "jj", ["git", "init", "--colocate"]); + await run(root, "git", ["remote", "add", "origin", `https://upgrade.test/${remote}`]); + await fs.writeFile(path.join(root, ".gitignore"), ".quixos/\n"); + const child = nodes[0]; + await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://upgrade.test/quixos.git"; commit "${"a".repeat(40)}"; } ${child ? `interface Named source { repository "${child.source.repository}"; commit "${child.source.commit}"; }` : ""} }`); + await fs.writeFile(path.join(root, `${kind}.qx`), kind === "interface" ? 'interface Named id "interface:named" revision "interface:named@1" {}' : `workspace W id "workspace:w" revision "workspace:w@1" commit "${"a".repeat(40)}" { import interface Named; atom A id "atom:a"; }`); + await run(root, "jj", ["describe", "-m", "Initial source"]); + const commit = await run(root, "jj", ["log", "--no-graph", "-r", "@", "-T", "commit_id"]); + await run(root, "git", ["push", "origin", `${commit}:refs/tags/quixos-reachability/${commit}`]); + nodes.push({kind, directory, source: {repository: `https://upgrade.test/${remote}`, commit}}); + } + await fs.mkdir(path.join(workbench, ".quixos")); + await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({resources: [nodes[0]]})); + await fs.appendFile(path.join(workbench, nodes[0].directory, "interface.qx"), "\n// incremental author edit\n"); + const plan = await planPinUpgrades(workbench, {nodes, bootstrap: true}); + const result = await applyPinUpgrades(plan); + assert.equal(result.activated, false); + const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8")); + const commit = graph.resources[0].source.commit; + assert.notEqual(commit, nodes[0].source.commit); + assert.match(await fs.readFile(path.join(workbench, "root/quixos.lock"), "utf8"), new RegExp(commit)); + assert.match(await run(workbench, "git", ["--git-dir", path.join(workbench, "remotes/named.git"), "show-ref"]), new RegExp(commit)); +}); + test("pin upgrades publish children before parent locks and resume without republishing completed nodes", async (context) => { const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-upgrade-test-")); context.after(() => fs.rm(workbench, {recursive: true, force: true})); diff --git a/test/scaffold-recipes.test.ts b/test/scaffold-recipes.test.ts index 44cc359..10076cf 100644 --- a/test/scaffold-recipes.test.ts +++ b/test/scaffold-recipes.test.ts @@ -10,6 +10,17 @@ import {planStructure, applyStructure} from "../src/capability-language/structur import {contentDigest} from "../src/capability-model/evolution.js"; const execFile = promisify(callback); +test("React preset applies its browser build script and shared-platform imports", async (context) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-react-recipe-")); + context.after(() => fs.rm(root, {recursive: true, force: true})); + await execFile("git", ["-C", root, "init"]); + const source = {repository: "https://example.test/react.git", commit: "a".repeat(40)}; + const request = await scaffoldRecipe(root, "package", {source, name: "React", id: "package:react", revision: "package:react@1", template: "typescript-react", tools: {quixos: source, protocol: source, helpers: source, sdk: source}}); + await applyStructure(await planStructure(root, request)); + assert.match(await fs.readFile(path.join(root, "scripts/build-component.mjs"), "utf8"), /__quixos\/platform\/react/); + assert.equal(JSON.parse(await fs.readFile(path.join(root, "tsconfig.json"), "utf8")).compilerOptions.jsx, "react-jsx"); +}); + test("package/function/migration scaffolds register implementations and refresh code digests without overwriting code", async (context) => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-recipes-")); context.after(() => fs.rm(root, {recursive: true, force: true})); @@ -21,7 +32,7 @@ test("package/function/migration scaffolds register implementations and refresh await apply("package", {...base, name: "Chess", id: "package:chess", revision: "package:chess@1", tools: {quixos: source, protocol: source, helpers: source, sdk: source}}); await apply("function", {...base, name: "play", id: "export:play"}); const filename = path.join(root, base.directory, "src/impl/play.ts"); - const edited = 'import type {Implementation} from "../generated-bindings.js";\nexport const handler: Implementation["play"] = async () => null;\n'; + const edited = 'import type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["play"] = async () => null;\n'; await fs.writeFile(filename, edited); const old = {version: 1}, next = {version: 2}, from = contentDigest(old), to = contentDigest(next); await apply("migration", {...base, name: "upgrade", id: "export:upgrade", migration: {id: "upgrade-v2", scopeId: "board", from, to, predecessors: [], ports: [], contracts: {[from]: old, [to]: next}}}); @@ -31,7 +42,7 @@ test("package/function/migration scaffolds register implementations and refresh assert.equal(await fs.readFile(filename, "utf8"), edited); const catalog = JSON.parse(await fs.readFile(path.join(root, base.directory, "quixos.migrations.json"), "utf8")); assert.equal(catalog.migrations[0].implementation.digest, contentDigest(await fs.readFile(migrationFile, "utf8"))); - const bindings = await fs.readFile(path.join(root, base.directory, "src/generated-bindings.ts"), "utf8"); + const bindings = await fs.readFile(path.join(root, base.directory, "src/gen/qx.ts"), "utf8"); assert.match(bindings, /export:play/); assert.match(await fs.readFile(path.join(root, base.directory, "src/migrate.ts"), "utf8"), /export:upgrade/); if (process.env.QX_SCAFFOLD_TEST_SDK) { From 01ca965c7f7aaa6787f44cdce8a5edfc23fe4980 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Mon, 14 Sep 2026 10:26:11 -0700 Subject: [PATCH 2/3] Make workspace authoring converge through immutable Nix candidates Coordinate registered resource edits bottom-up into retained exact remote sources. Use one Nix-owned source graph for provisional checking, template publication, explicit baseline upgrades and host activation; retain independent runtime pins. Add scoped contract inspection, historical recovery, derived worklists, crash-safe locks, named dependency adoption and plain-QX structural editing. Repair TODO ownership and template instantiation, and document the supported agent workflow. Validated with protocol and command suites, real jj/Nix convergence and cache checks, TS/React installed-command acceptance, and fresh TODO first-edit acceptance. No live deployment or public publication performed. Props projection generation and a one-command rich feature generator remain explicitly outside this delivery. --- flake.nix | 3 + nix/checked-candidate.nix | 77 ++++++++ src/capability-language/assembly.ts | 2 +- src/capability-language/authoring-check.ts | 76 ++++++++ src/capability-language/authoring-context.ts | 49 ++++++ src/capability-language/authoring-converge.ts | 166 ++++++++++++++++++ src/capability-language/authoring-inspect.ts | 73 ++++++++ src/capability-language/authoring-worklist.ts | 60 +++++++ src/capability-language/checked-build.ts | 33 ++++ src/capability-language/file-lock.ts | 21 +++ src/capability-language/git-resolver.ts | 43 +++-- src/capability-language/inspect-cli.ts | 9 +- src/capability-language/pin-upgrades.ts | 36 ++-- src/capability-language/scaffold-recipes.ts | 1 - src/capability-language/structural-edits.ts | 23 ++- src/capability-language/structural-plan.ts | 36 ++-- src/capability-language/tool-cli.ts | 145 +++++++++++---- src/capability-model/validation.ts | 6 +- test/authoring-converge.test.ts | 84 +++++++++ test/authoring-inspect.test.ts | 37 ++++ test/authoring-worklist.test.ts | 45 +++++ test/capability-model.test.ts | 17 ++ test/file-lock.test.ts | 33 ++++ test/git-resolver.test.ts | 37 ++++ test/nix-candidate.test.ts | 34 ++++ test/pin-upgrades.test.ts | 3 +- test/scaffold-recipes.test.ts | 4 + test/structural-edits.test.ts | 16 +- test/structural-plan.test.ts | 9 + 29 files changed, 1103 insertions(+), 75 deletions(-) create mode 100644 nix/checked-candidate.nix create mode 100644 src/capability-language/authoring-check.ts create mode 100644 src/capability-language/authoring-context.ts create mode 100644 src/capability-language/authoring-converge.ts create mode 100644 src/capability-language/authoring-inspect.ts create mode 100644 src/capability-language/authoring-worklist.ts create mode 100644 src/capability-language/file-lock.ts create mode 100644 test/authoring-converge.test.ts create mode 100644 test/authoring-inspect.test.ts create mode 100644 test/authoring-worklist.test.ts create mode 100644 test/file-lock.test.ts create mode 100644 test/git-resolver.test.ts create mode 100644 test/nix-candidate.test.ts diff --git a/flake.nix b/flake.nix index e63a10c..2d07fa0 100644 --- a/flake.nix +++ b/flake.nix @@ -21,6 +21,7 @@ pkgs.git pkgs.jujutsu pkgs.gnutar + pkgs.util-linux ]; buildPhase = '' runHook preBuild @@ -63,6 +64,8 @@ runHook preInstall mkdir -p "$out" install -Dm644 nix/checked-package.nix "$out/share/checked-package.nix" + substitute nix/checked-candidate.nix "$out/share/checked-candidate.nix" \ + --replace-fail '@nixpkgs@' '${pkgs.path}' cp --reflink=auto --recursive grammar "$out/grammar" cp --reflink=auto --recursive proto "$out/proto" cp --reflink=auto --recursive dist "$out/dist" diff --git a/nix/checked-candidate.nix b/nix/checked-candidate.nix new file mode 100644 index 0000000..fe8b396 --- /dev/null +++ b/nix/checked-candidate.nix @@ -0,0 +1,77 @@ +# All source trees and recursive locks are fetched by Nix at exact retained +# revisions. No authoring-directory overlay or externally assembled schema. +{ repository, commit, kind, generator, contractOnly ? false, system ? builtins.currentSystem }: +let + pkgs = import (/. + "@nixpkgs@") { inherit system; }; + protocol = builtins.storePath generator; + sourceKey = source: "${source.kind}:${source.repository}@${source.commit}"; + fetch = source: builtins.fetchGit { + url = source.repository; + rev = source.commit; + ref = "refs/tags/quixos-reachability/${source.commit}"; + shallow = true; + }; + load = ancestors: source: + if builtins.elem (sourceKey source) ancestors then + throw "Source dependency cycle at ${sourceKey source}" + else if builtins.length ancestors >= 100 then + throw "Source dependency depth exceeds 100" + else let + directory = fetch source; + lockFile = pkgs.runCommand "qx-source-lock.json" { } '' + ${protocol}/bin/quixos-lock-check ${directory}/quixos.lock > "$out" + ''; + lock = builtins.fromJSON (builtins.readFile lockFile); + children = map (entry: load (ancestors ++ [ (sourceKey source) ]) (entry.source // { inherit (entry) kind; })) lock.resources; + in source // { inherit directory children; }; + root = load [ ] { inherit kind repository commit; }; + flatten = node: [ node ] ++ pkgs.lib.concatMap flatten node.children; + nodes = builtins.attrValues (builtins.listToAttrs (map (node: { + name = sourceKey node; value = node; + }) (flatten root))); + snapshots = pkgs.writeText "qx-nix-source-graph.json" (builtins.toJSON { + resources = map (node: { inherit (node) kind repository commit directory; }) + (builtins.filter (node: node.kind != "workspace") nodes); + }); + compile = node: pkgs.runCommand "qx-${node.kind}-contract" { } '' + mkdir -p "$out" + ${if node.kind == "workspace" then '' + ${protocol}/bin/quixos-workspace-compile --root ${node.directory} \ + --source-root-commit ${pkgs.lib.escapeShellArg node.commit} \ + --checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} \ + --graph-out "$out/graph.json" > "$out/candidate.json" + '' else '' + ${protocol}/bin/quixos-resource-compile --root ${node.directory} \ + --kind ${node.kind} --repository ${pkgs.lib.escapeShellArg node.repository} \ + --commit ${pkgs.lib.escapeShellArg node.commit} \ + --checkout-root "$TMPDIR/checkouts" --snapshot-map ${snapshots} --snapshot-only true \ + --graph-out "$out/graph.json" --schema-out "$out/bindings.json" > "$out/candidate.json" + ''} + ''; + contract = compile root; + checkPackage = node: let + compiled = compile node; + candidate = builtins.fromJSON (builtins.readFile "${compiled}/candidate.json"); + package = builtins.getFlake ("git+${node.repository}?rev=${node.commit}&ref=refs/tags/quixos-reachability/${node.commit}"); + checked = package.quixosPackages.${system}.checkedServer or + (throw "Package ${node.repository} lacks checkedServer; use the supported package scaffold."); + artifact = checked { + schema = "${compiled}/bindings.json"; + generator = protocol; + packageRevisionId = candidate.revision.revisionId; + }; + in { packageRevisionId = candidate.revision.revisionId; artifactPath = artifact; }; + checks = if contractOnly then [ ] else map checkPackage (builtins.filter (node: node.kind == "package") nodes); + manifest = pkgs.writeText "qx-candidate-checks.json" (builtins.toJSON checks); +in pkgs.runCommand (if contractOnly then "qx-contract" else "qx-checked-candidate") { } '' + mkdir -p "$out" + cp ${contract}/candidate.json "$out/candidate.json" + cp ${contract}/graph.json "$out/graph.json" + cp ${manifest} "$out/checks.json" + ${pkgs.lib.optionalString (kind != "workspace") ''cp ${contract}/bindings.json "$out/bindings.json"''} + ${pkgs.lib.optionalString (kind == "package") '' + ${protocol}/bin/quixos-codegen-ts ${contract}/bindings.json \ + ${pkgs.lib.escapeShellArg (builtins.fromJSON (builtins.readFile "${contract}/candidate.json")).revision.revisionId} \ + "$out/bindings.ts" ${pkgs.lib.optionalString (builtins.pathExists (root.directory + "/bindings.json")) (toString root.directory + "/bindings.json")} + ''} +'' diff --git a/src/capability-language/assembly.ts b/src/capability-language/assembly.ts index efb4c03..ecaf2e3 100644 --- a/src/capability-language/assembly.ts +++ b/src/capability-language/assembly.ts @@ -361,7 +361,7 @@ export const compileWorkspaceRepository = async (options: { : {}), ...(options.workspaceRevisionId ? { id: capabilityId.workspaceRevision(options.workspaceRevisionId) } - : {}), + : options.sourceRootCommit ? { id: capabilityId.workspaceRevision(`workspace-revision:${options.workspaceId ?? compiled.workspace.workspaceId}:${options.sourceRootCommit}`) } : {}), ...(options.sourceRootCommit ? { sourceRootCommit: options.sourceRootCommit } : {}), diff --git a/src/capability-language/authoring-check.ts b/src/capability-language/authoring-check.ts new file mode 100644 index 0000000..017a1fc --- /dev/null +++ b/src/capability-language/authoring-check.ts @@ -0,0 +1,76 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +import { authoringContext } from "./authoring-context.js"; +import type { convergeAuthoring } from "./authoring-converge.js"; +import { execFile as callback } from "node:child_process"; +import { promisify } from "node:util"; +import { buildImmutableCandidate, checkerIdentity } from "./checked-build.js"; +import { planEvolution, type WorkspaceRevision, type EvolutionReview } from "../capability-model/index.js"; + +export const checkRecordName = (directory: string) => createHash("sha256").update(directory).digest("hex") + ".json"; +export async function checkAuthoring(start: string, output: string, options: { baseline?: string; reviews?: string; contractOnly?: boolean } = {}) { + const context = await authoringContext(start); + const location = await fs.realpath(start); + const directory = location === context.workbench ? "root" : path.relative(context.workbench, location); + 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 } = { + directory, checker: checkerIdentity(), candidateOnly: true, activationEvidence: false, blockers: [], phase: "convergence", output, + }; + try { + // Serialize only source capture, not the potentially slow Nix build. + // Repository-scoped agents can check separate immutable candidates in parallel. + const captured = await promisify(callback)("quixos-qx", ["converge", context.workbench, directory], { + maxBuffer: 4 * 1024 * 1024, env: {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1"}, + }).catch(error => { + if (typeof error.stdout === "string" && error.stdout.trim().startsWith("{")) return {stdout: error.stdout}; + throw error; + }); + const converged = JSON.parse(captured.stdout) as Awaited>; + if (!converged.candidate) { + report.phase = converged.worklist.find(entry => entry.phase !== "dependency")?.phase ?? "convergence"; + throw new Error(converged.worklist.map(entry => `${entry.directory} [${entry.phase}]: ${entry.message}`).join("\n")); + } + report.commit = converged.candidate.commit; + report.phase = "verification"; + 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); + if (resource.kind === "workspace" && !options.contractOnly) { + report.phase = "evolution"; + let baseline = options.baseline; + if (!baseline) { + try { + const host = JSON.parse(await fs.readFile("/etc/quixos/workspace-source.json", "utf8")); + if (await fs.realpath(host.workbenchRoot) === context.workbench) baseline = JSON.parse(await fs.readFile(path.join(host.runtimeClosureRoot, "manifest.json"), "utf8")).workspacePlanPath; + } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + } + const before = baseline ? JSON.parse(await fs.readFile(baseline, "utf8")) as WorkspaceRevision : null; + const reviews = options.reviews ? JSON.parse(await fs.readFile(options.reviews, "utf8")) as EvolutionReview[] : []; + 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); + } + if (!report.blockers.length) report.phase = options.contractOnly ? "contract-only" : "checked"; + } catch (error) { report.blockers.push(String(error instanceof Error ? error.message : error)); } + await fs.writeFile(path.join(output, "report.json"), JSON.stringify(report, null, 2)); + if (options.contractOnly) return report; + const records = path.join(context.workbench, ".quixos/checks"); + await fs.mkdir(records, { recursive: true }); + const remember = async (value: typeof report) => { + const filename = path.join(records, checkRecordName(value.directory)), temporary = `${filename}.${randomUUID()}.tmp`; + await fs.writeFile(temporary, JSON.stringify(value, null, 2), { flag: "wx", mode: 0o600 }); + await fs.rename(temporary, filename); + }; + if (report.artifactPath) { + const graph = JSON.parse(await fs.readFile(path.join(report.artifactPath, "graph.json"), "utf8")); + for (const checked of graph.resources) { + const managed = context.resources.find(entry => entry.kind === checked.kind && entry.source?.repository === checked.source.repository); + if (managed && managed.directory !== directory) await remember({...report, directory: managed.directory, commit: checked.source.commit, blockers: [], phase: "checked"}); + } + } + await remember(report); + return report; +} diff --git a/src/capability-language/authoring-context.ts b/src/capability-language/authoring-context.ts new file mode 100644 index 0000000..09d7dfd --- /dev/null +++ b/src/capability-language/authoring-context.ts @@ -0,0 +1,49 @@ +import { readFile, realpath } from "node:fs/promises"; +import path from "node:path"; +import { loadQuixosLock, type GitSource } from "../resource-lock/index.js"; + +export type AuthoringResource = { + kind: "workspace" | "interface" | "package"; + directory: string; + resourceId?: string; + source?: GitSource; +}; + +/** Registration, not directory co-location, defines the editable selection. */ +export async function authoringContext(start: string) { + let workbench = await realpath(start); + for (;;) { + let text: string | undefined; + try { text = await readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + if (text !== undefined) { + const graph = JSON.parse(text) as { resources: AuthoringResource[] }; + if (!Array.isArray(graph.resources)) throw new Error("Managed resource inventory is malformed"); + const resources: AuthoringResource[] = [{ kind: "workspace", directory: "root" }]; + const identities = new Set(), directories = new Set(["root"]); + for (const entry of graph.resources) { + // Compiler graphs carry resolved paths; the authoring API presents + // stable workbench-relative names and validates containment here. + if (typeof entry.directory === "string" && path.isAbsolute(entry.directory)) entry.directory = path.relative(workbench, entry.directory); + if (!["interface", "package"].includes(entry.kind) || !entry.source || + !/^resources\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(entry.directory)) { + throw new Error("Invalid managed resource registration"); + } + const identity = `${entry.kind}\0${entry.resourceId ?? entry.source.repository}`; + if (identities.has(identity) || directories.has(entry.directory)) { + throw new Error(`Multiple editable selections for ${entry.resourceId ?? entry.source.repository}`); + } + identities.add(identity); directories.add(entry.directory); + resources.push({kind: entry.kind, directory: entry.directory, resourceId: entry.resourceId, source: entry.source}); + } + return { workbench, resources, async baseline() { + const result = await loadQuixosLock(path.join(workbench, "root/quixos.lock")); + if (!result.ok) throw new Error(`Workspace source baseline is invalid: ${result.diagnostics.map(d => d.message).join("; ")}`); + return result.lock.quixos; + } }; + } + const parent = path.dirname(workbench); + if (parent === workbench) throw new Error("Not in a managed workbench; select one with --workbench DIRECTORY"); + workbench = parent; + } +} diff --git a/src/capability-language/authoring-converge.ts b/src/capability-language/authoring-converge.ts new file mode 100644 index 0000000..a34f7d2 --- /dev/null +++ b/src/capability-language/authoring-converge.ts @@ -0,0 +1,166 @@ +import { readFile, writeFile, rename, rm, realpath } from "node:fs/promises"; +import path from "node:path"; +import { execFile as callback } from "node:child_process"; +import { promisify } from "node:util"; +import { randomUUID } from "node:crypto"; +import { authoringContext } from "./authoring-context.js"; +import { snapshotCommit } from "./checked-build.js"; +import { loadQuixosLock, parseQuixosLockDocument, formatQuixosLockDocument, retentionTagForCommit, type GitSource } from "../resource-lock/index.js"; + +const execFile = promisify(callback); +const command = async (cwd: string, executable: string, args: string[]) => (await execFile(executable, args, { + cwd, maxBuffer: 4 * 1024 * 1024, + env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", QUIXOS_SUBTREE_PUBLISH: "0", GIT_TERMINAL_PROMPT: "0" }, +})).stdout.trim(); +const identity = (kind: string, repository: string) => `${kind}\0${repository}`; + +export type AuthoringBlocker = { directory: string; phase: "resolution" | "dependency" | "source" | "publication" | "concurrent-edit"; message: string }; + +/** Source retention only. Neither successful convergence nor an empty source + * worklist grants typechecking, semantic review or activation approval. + * Caller serializes coordinators; package authors may still be editing. */ +export async function convergeAuthoring(start: string, target = "root") { + const context = await authoringContext(start); + const graphFile = path.join(context.workbench, ".quixos/resource-graph.json"); + const graphBefore = await readFile(graphFile, "utf8"); + const blockers: AuthoringBlocker[] = []; + const nodes = new Map(); + const selected = new Map(); + for (const entry of context.resources) { + const root = path.join(context.workbench, entry.directory); + let repository = entry.source?.repository; + try { + if (await realpath(root) !== root) throw new Error(`Managed checkout crosses a symlink: ${entry.directory}`); + // Transport rewrites must not become committed source identities. + const origin = await command(root, "git", ["config", "--get", "remote.origin.url"]); + if (repository && origin !== repository) throw new Error(`Origin differs from registered source for ${entry.directory}`); + repository ??= origin; + } catch (error) { + blockers.push({directory: entry.directory, phase: "source", message: String(error).slice(0, 2000)}); + if (!repository) throw error; // The root has no separate registered source. + } + const key = identity(entry.kind, repository); + if (selected.has(key)) throw new Error(`More than one editable checkout for ${repository}`); + selected.set(key, entry.directory); + nodes.set(entry.directory, { ...entry, source: { resolver: "git", repository, commit: entry.source?.commit ?? "" }, dependencies: [] }); + } + for (const node of nodes.values()) { + try { + const lock = await loadQuixosLock(path.join(context.workbench, node.directory, "quixos.lock")); + if (!lock.ok) throw new Error(lock.diagnostics.map(d => `${d.fileName}: ${d.message}`).join("\n")); + node.dependencies = [...new Set(lock.lock.resources.flatMap(entry => { + const directory = selected.get(identity(entry.kind, entry.source.repository)); + return directory ? [directory] : []; + }))]; + } catch (error) { blockers.push({ directory: node.directory, phase: "resolution", message: String(error) }); } + } + const complete = new Map(), active = new Set(); + const visited = new Set(); + if (!nodes.has(target)) throw new Error(`Not a registered repository: ${target}`); + const visit = async (directory: string): Promise => { + visited.add(directory); + if (complete.has(directory)) return true; + if (blockers.some(entry => entry.directory === directory)) return false; + if (active.has(directory)) { blockers.push({ directory, phase: "dependency", message: `Source dependency cycle: ${[...active, directory].join(" -> ")}` }); return false; } + active.add(directory); + const node = nodes.get(directory)!; + for (const dependency of node.dependencies) if (!await visit(dependency)) { + blockers.push({ directory, phase: "dependency", message: `Waiting for ${dependency}` }); active.delete(directory); return false; + } + const root = path.join(context.workbench, directory); + let phase: AuthoringBlocker["phase"] = "source"; + try { + const lock = await loadQuixosLock(path.join(root, "quixos.lock")); + if (!lock.ok) throw new Error("Lock changed during convergence; retry after joining writers"); + for (const file of lock.lock.sourceFiles ?? ["quixos.lock"]) { + const filename = path.join(root, file), before = await readFile(filename, "utf8"); + const parsed = parseQuixosLockDocument(before, file); + if (!parsed.ok) throw new Error(`Invalid lock ${file}`); + let changed = false; + for (const dependency of parsed.document.resources) { + const target = selected.get(identity(dependency.kind, dependency.source.repository)); + const source = target ? complete.get(target) : undefined; + if (target && !source) throw new Error(`Dependencies changed during convergence (${dependency.binding}); join writers and retry`); + if (source && source.commit !== dependency.source.commit) { dependency.source = source; changed = true; } + } + if (changed) { + const temporary = `${filename}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, formatQuixosLockDocument(parsed.document), { flag: "wx" }); + if (await readFile(filename, "utf8") !== before) throw new Error(`Concurrent edit to ${file}; retry after joining writers`); + await rename(temporary, filename); + } finally { await rm(temporary, { force: true }); } + } + } + const commit = await snapshotCommit(root); + phase = "publication"; + const ref = retentionTagForCommit(commit); + const remote = await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref]); + if (remote && remote.split(/\s+/)[0] !== commit) throw new Error(`Conflicting immutable retention ref ${ref}`); + if (!remote) await command(root, "git", ["push", node.source.repository, `${commit}:${ref}`]); + if ((await command(root, "git", ["ls-remote", "--refs", node.source.repository, ref])).split(/\s+/)[0] !== commit) throw new Error("Published source retention was not observed"); + complete.set(directory, { ...node.source, commit }); + } catch (error) { blockers.push({ directory, phase, message: String(error).slice(0, 4000) }); } + active.delete(directory); + return complete.has(directory); + }; + // Include newly created, not-yet-imported resources, then the root. + if (target === "root") for (const directory of [...nodes.keys()].filter(d => d !== "root")) await visit(directory); + await visit(target); + for (let index = blockers.length - 1; index >= 0; index--) if (!visited.has(blockers[index].directory)) blockers.splice(index, 1); + for (const [directory, source] of complete) { + try { if (await snapshotCommit(path.join(context.workbench, directory)) !== source.commit) throw new Error("Source advanced while converging; join writers and retry"); } + catch (error) { blockers.push({ directory, phase: "concurrent-edit", message: String(error) }); } + } + // Persist successful selections even if another repository is still broken. + // Recovery must not depend on all parents succeeding in the same invocation. + const graph = JSON.parse(graphBefore); + for (const resource of graph.resources) resource.directory = path.relative(context.workbench, path.resolve(context.workbench, resource.directory)); + const replacements = new Map(); + for (const resource of graph.resources) { + const source = complete.get(resource.directory); + if (!source) continue; + const key = `${resource.kind}\0${source.repository}\0${source.commit}`; + replacements.set(resource.key, key); + if (resource.source.commit !== source.commit) delete resource.revisionId; + resource.source = source; resource.key = key; + } + for (const resource of graph.resources) for (const dependency of resource.dependencies ?? []) { + dependency.resourceKey = replacements.get(dependency.resourceKey) ?? dependency.resourceKey; + } + for (const direct of graph.directResources ?? []) direct.resourceKey = replacements.get(direct.resourceKey) ?? direct.resourceKey; + // Inventory is a projection of actual locks, including newly added/removed + // imports. Never require a successful parent compilation to repair it. + for (const [directory] of complete) { + const lock = await loadQuixosLock(path.join(context.workbench, directory, "quixos.lock")); + if (!lock.ok) continue; + const dependencies = lock.lock.resources.map(dependency => ({ + binding: `${dependency.kind}\0${dependency.binding}`, + resourceKey: `${dependency.kind}\0${dependency.source.repository}\0${dependency.source.commit}`, + })); + if (directory === "root") { + graph.quixos = lock.lock.quixos; + graph.directResources = lock.lock.resources.map((dependency, index) => ({ + kind: dependency.kind, binding: dependency.binding, resourceKey: dependencies[index].resourceKey, + ...(selected.has(identity(dependency.kind, dependency.source.repository)) + ? {directory: selected.get(identity(dependency.kind, dependency.source.repository))} : {}), + })); + } else { + const resource = graph.resources.find((entry: {directory: string}) => entry.directory === directory); + if (resource) resource.dependencies = dependencies; + } + } + const graphAfter = JSON.stringify(graph, null, 2) + "\n"; + if (graphBefore !== graphAfter) { + const temporary = `${graphFile}.${randomUUID()}.tmp`; + try { + await writeFile(temporary, graphAfter, { flag: "wx", mode: 0o600 }); + if (await readFile(graphFile, "utf8") !== graphBefore) throw new Error("Managed inventory changed during convergence; source is retained, retry after joining writers"); + await rename(temporary, graphFile); + } finally { await rm(temporary, { force: true }); } + } + return { workbench: context.workbench, converged: blockers.length === 0, + candidate: blockers.length ? null : complete.get(target) ?? null, + retained: [...complete].map(([directory, source]) => ({ directory, source })), + worklist: blockers, verificationEvidence: false, activated: false }; +} diff --git a/src/capability-language/authoring-inspect.ts b/src/capability-language/authoring-inspect.ts new file mode 100644 index 0000000..20d8f1d --- /dev/null +++ b/src/capability-language/authoring-inspect.ts @@ -0,0 +1,73 @@ +import { execFile as callback } from "node:child_process"; +import { promisify } from "node:util"; +import { realpath } from "node:fs/promises"; +import path from "node:path"; +import { parseQx, walkSyntax } from "./source.js"; +import { authoringContext } from "./authoring-context.js"; +import { readQxSource } from "./source-loader.js"; + +const execFile = promisify(callback); +const git = async (root: string, args: string[]) => (await execFile("git", ["-C", root, ...args], { + maxBuffer: 8 * 1024 * 1024, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, +})).stdout; +const message = (error: unknown) => String(error instanceof Error ? error.message : error).slice(0, 2000); + +/** Syntax-only contract inspection is deliberately NOT verification evidence. + * Each file can recover independently; current valid files always win. */ +export async function inspectAuthoringRepository(root: string, historyLimit = 100) { + const names = (await git(root, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"])) + .split("\0").filter(name => name.endsWith(".qx")); + if (names.length > 128) throw new Error("Repository inspection exceeds 128 QX files; split the resource into smaller repositories"); + const files = []; + for (const name of [...new Set(names)].sort()) { + let source = "", errors: unknown[] = [], revision: string | null = null; + try { + source = await readQxSource(root, name); + if (source.length > 262144) throw new Error(`Inspection file exceeds 256 KiB: ${name}`); + errors = parseQx(source, name).diagnostics; + } catch (error) { errors = [{ message: message(error) }]; } + const currentErrors = errors; + if (errors.length) { + // Git can traverse jj's immutable commit DAG without mutating/snapshotting @. + const head = await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"], + { cwd: root, env: { ...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1" } }).then(r => r.stdout.trim(), () => "HEAD"); + const commits = await git(root, ["rev-list", `--max-count=${historyLimit}`, head, "--", name]).catch(() => ""); + for (const commit of commits.trim().split("\n").filter(Boolean)) { + const historical = await git(root, ["show", `${commit}:${name}`]).catch(() => null); + if (historical === null || historical.length > 262144) continue; + if (!parseQx(historical, name).diagnostics.length) { + source = historical; revision = commit; errors = []; break; + } + } + } + const syntax = errors.length ? null : parseQx(source, name); + files.push({ file: name, status: errors.length ? "unavailable" : revision ? "historical" : "current", + revision, currentErrors: currentErrors.slice(0, 20), omittedErrors: Math.max(0, currentErrors.length - 20), + declarations: syntax ? [...walkSyntax(syntax.root)] + .filter(node => /^(?:interface|package|atom|state|edge|method|function|event|conformance)\w*Decl$/.test(node.kind)) + .map(node => ({ kind: node.kind, source: source.slice(node.start, node.end) })) : [], + }); + } + return { verificationEvidence: false as const, resolutionChecked: false as const, files }; +} + +export async function inspectWorkbench(start: string, selector?: string) { + const context = await authoringContext(start); + if (!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 selected = context.resources.filter(entry => !selector || selector === entry.directory || + selector === entry.resourceId || selector === path.basename(entry.directory)); + 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 = []; + for (const entry of selected) { + try { + const root = path.join(context.workbench, entry.directory); + if (await realpath(root) !== root) throw new Error("Managed checkout crosses a symlink"); + resources.push({ ...entry, ...await inspectAuthoringRepository(root) }); + } catch (error) { resources.push({ ...entry, error: message(error) }); } + } + return { workbench: context.workbench, verificationEvidence: false, resources }; +} diff --git a/src/capability-language/authoring-worklist.ts b/src/capability-language/authoring-worklist.ts new file mode 100644 index 0000000..5ed3d51 --- /dev/null +++ b/src/capability-language/authoring-worklist.ts @@ -0,0 +1,60 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { execFile as callback } from "node:child_process"; +import { promisify } from "node:util"; +import { authoringContext } from "./authoring-context.js"; +import { inspectAuthoringRepository } from "./authoring-inspect.js"; +import { checkRecordName } from "./authoring-check.js"; +import { loadQuixosLock } from "../resource-lock/index.js"; +import { checkerIdentity } from "./checked-build.js"; + +const execFile = promisify(callback); +export async function authoringWorklist(start: string) { + const context = await authoringContext(start); + const entries: {directory: string; resourceId?: string; phase: string; message: string; next: string}[] = []; + const dependencies = new Map(); + 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`}); + try { + if (await fs.realpath(root) !== root) throw new Error("Registered checkout crosses a symlink"); + const inspected = await inspectAuthoringRepository(root); + for (const file of inspected.files) if (file.currentErrors.length) add("syntax", `${file.file}: ${JSON.stringify(file.currentErrors)}${file.status === "historical" ? `; historical contract available at ${file.revision}` : ""}`); + const lock = await loadQuixosLock(path.join(root, "quixos.lock")); + if (!lock.ok) add("resolution", lock.diagnostics.map(entry => `${entry.fileName}: ${entry.message}`).join("\n")); + else dependencies.set(resource.directory, lock.lock.resources.flatMap(dependency => { + const selected = context.resources.find(entry => entry.kind === dependency.kind && entry.source?.repository === dependency.source.repository); + if (selected?.source && selected.source.commit !== dependency.source.commit) add("propagation", `Dependency ${dependency.binding} has advanced; check will repin it automatically`); + return selected ? [selected.directory] : []; + })); + let record; + try { record = JSON.parse(await fs.readFile(path.join(context.workbench, ".quixos/checks", checkRecordName(resource.directory)), "utf8")); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + if (!record?.commit) { + if (record?.blockers?.length) add(record.phase, record.blockers.join("\n")); + else add("unchecked", "No immutable candidate check recorded yet"); + continue; + } + if (record.checker !== checkerIdentity()) add("unchecked", "The installed checker changed since the last check"); + const env = {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1"}; + const commit = (await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"], {cwd: root, env})).stdout.trim(); + const dirty = await execFile("git", ["diff", "--quiet", "--no-ext-diff", record.commit, "--"], {cwd: root}).then(() => false, () => true); + const untracked = (await execFile("git", ["ls-files", "--others", "--exclude-standard"], {cwd: root})).stdout; + if (commit !== record.commit || dirty || untracked) add("unchecked", `Edits are newer than the last check (${record.commit.slice(0, 12)})`); + else if (record.blockers?.length) add(record.phase, record.blockers.join("\n")); + } catch (error) { add("inspection", String(error).slice(0, 3000)); } + } + // Fixed-point propagation, independent of registration order. + const blocked = new Set(entries.map(entry => entry.directory)); + let changed = true; + while (changed) { + changed = false; + for (const [directory, required] of dependencies) if (!blocked.has(directory)) { + const waiting = required.filter(dependency => blocked.has(dependency)); + if (waiting.length) { blocked.add(directory); changed = true; entries.push({directory, phase: "dependency", message: `Waiting for ${waiting.join(", ")}`, next: "Resolve the named repositories, then rerun check"}); } + } + } + return { workbench: context.workbench, verificationEvidence: false, worklist: entries, + note: "Derived authoring guidance, not activation approval. Independent repositories can be delegated separately; join writers before a root check." }; +} diff --git a/src/capability-language/checked-build.ts b/src/capability-language/checked-build.ts index ec9dcd2..2a3f63e 100644 --- a/src/capability-language/checked-build.ts +++ b/src/capability-language/checked-build.ts @@ -1,10 +1,12 @@ import fs from "node:fs/promises"; import path from "node:path"; import {execFile as callback, spawn} from "node:child_process"; +import { createWriteStream } from "node:fs"; import {promisify} from "node:util"; import {fileURLToPath} from "node:url"; const execFile = promisify(callback); const environment = () => ({...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1", GIT_TERMINAL_PROMPT: "0"}); +export const checkerIdentity = () => process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); /** Checking snapshots jj, but never publishes or activates the working copy. */ export async function snapshotCommit(root: string): Promise { @@ -60,3 +62,34 @@ export async function buildCheckedPackage(source: string, schema: string, packag }); }); } + +/** Exact remote source DAG; the Nix checker owns schema construction and all + * nested fetches. Keep build noise in a named log, with a bounded failure tail. */ +export async function buildImmutableCandidate(source: { repository: string; commit: string }, kind: "workspace" | "interface" | "package", logFile: string, contractOnly = false): Promise { + if (!/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(source.commit)) throw new Error("An immutable candidate requires an exact commit"); + const url = new URL(source.repository); + if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error("Candidate origin must be credential-free HTTPS"); + const generator = process.env.QUIXOS_CHECK_GENERATOR ?? path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); + if (!/^\/nix\/store\/[^/]+$/.test(generator)) throw new Error("Use the installed Quixos checker"); + const builder = path.join(generator, "share/checked-candidate.nix"); + await fs.access(builder); + const log = createWriteStream(logFile, { flags: "wx", mode: 0o600 }); + await new Promise((resolve, reject) => { log.once("open", () => resolve()); log.once("error", reject); }); + return await new Promise((resolve, reject) => { + let output = "", tail = "", failure: Error | undefined; + const child = spawn("nix", ["build", "--impure", "--file", builder, + "--argstr", "repository", source.repository, "--argstr", "commit", source.commit, + "--argstr", "kind", kind, "--argstr", "generator", generator, + "--arg", "contractOnly", contractOnly ? "true" : "false", + "--no-link", "--print-out-paths", "-L"], { env: environment(), stdio: ["ignore", "pipe", "pipe"] }); + log.on("error", error => { failure = error; child.kill(); }); + child.stdout.on("data", chunk => { output += chunk; }); + child.stderr.on("data", chunk => { log.write(chunk); tail = (tail + String(chunk)).slice(-6000); }); + child.on("error", error => { failure = error; }); + child.on("close", code => log.end(() => { + if (failure) reject(failure); + else if (code !== 0 || !/^\/nix\/store\/[a-z0-9]{32}-[^\s/]+$/.test(output.trim())) reject(new Error(`Candidate Nix check failed (${code}). Full log: ${logFile}\n${tail}`)); + else resolve(output.trim()); + })); + }); +} diff --git a/src/capability-language/file-lock.ts b/src/capability-language/file-lock.ts new file mode 100644 index 0000000..53a54bc --- /dev/null +++ b/src/capability-language/file-lock.ts @@ -0,0 +1,21 @@ +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, + 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 */ }); + child.stderr.on("data", chunk => { diagnostics = (diagnostics + String(chunk)).slice(-2000); }); + const closed = new Promise((resolve) => { child.once("close", () => resolve()); }); + try { + 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.stdout.on("data", chunk => { output += chunk; if (output.includes("locked\n")) resolve(); }); + }); + return await work(); + } finally { child.stdin.end(); await closed; } +} diff --git a/src/capability-language/git-resolver.ts b/src/capability-language/git-resolver.ts index b324d74..bbd2f27 100644 --- a/src/capability-language/git-resolver.ts +++ b/src/capability-language/git-resolver.ts @@ -1,6 +1,6 @@ import childProcess from "node:child_process"; import crypto from "node:crypto"; -import { mkdir, readFile, realpath } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, realpath, rename, rm, stat } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import type { CapabilityRepositoryResolver } from "./assembly.js"; @@ -61,7 +61,27 @@ export const createGitCapabilityResolver = async (options: { checkoutRoot, checkoutName(kind, source.repository, source.commit), ); - await execFile("git", [ + const verify = async (checkout: string) => { + const { stdout } = await execFile("git", ["-C", checkout, "rev-parse", "HEAD"]); + if (stdout.trim().toLowerCase() !== source.commit.toLowerCase()) { + throw new Error(`Locked commit mismatch for ${source.repository}: wanted ${source.commit}, fetched ${stdout.trim()}`); + } + const { stdout: changes } = await execFile("git", ["-C", checkout, "status", "--porcelain", "--untracked-files=all"]); + if (changes.trim()) throw new Error(`Dependency checkout was modified: ${checkout}`); + }; + // Only complete, checked clones become visible under the deterministic name. + // Concurrent resolvers may fetch independently, but cannot observe a partial clone. + if (await stat(directory).then(() => true, (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return false; + throw error; + })) { + await verify(directory); + return { directory }; + } + const staging = await mkdtemp(path.join(checkoutRoot, ".fetch-")); + const checkout = path.join(staging, "checkout"); + try { + await execFile("git", [ "-c", "advice.detachedHead=false", "clone", @@ -71,18 +91,21 @@ export const createGitCapabilityResolver = async (options: { "--branch", `quixos-reachability/${source.commit.toLowerCase()}`, source.repository, - directory, + checkout, ]); - const { stdout } = await execFile("git", ["-C", directory, "rev-parse", "HEAD"]); - if (stdout.trim().toLowerCase() !== source.commit.toLowerCase()) { - throw new Error( - `Locked commit mismatch for ${source.repository}: ` + - `wanted ${source.commit}, fetched ${stdout.trim()}`, - ); + await verify(checkout); + try { await rename(checkout, directory); } + catch (error) { + if (!["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) throw error; + await verify(directory); + } + } finally { + await rm(staging, { recursive: true, force: true }); } return { directory }; })(); checkouts.set(key, pending); - return await pending; + try { return await pending; } + catch (error) { checkouts.delete(key); throw error; } }; }; diff --git a/src/capability-language/inspect-cli.ts b/src/capability-language/inspect-cli.ts index ffe9ded..e63e7ea 100644 --- a/src/capability-language/inspect-cli.ts +++ b/src/capability-language/inspect-cli.ts @@ -2,12 +2,19 @@ // Data only: never load files, resolve repositories, or evaluate code. import { parseQx } from "./source.js"; import { parseQuixosLockDocument } from "../resource-lock/parser.js"; +import { instantiateWorkspaceIdentity } from "./structural-edits.js"; let input = ""; for await (const chunk of process.stdin) { input += chunk; if (Buffer.byteLength(input) > 6 * 1024 * 1024) throw new Error("Inspection input exceeds limit"); } -const files: { path: string; source: string }[] = JSON.parse(input); +const document = JSON.parse(input); +if (!Array.isArray(document) && document?.operation === "instantiate-workspace") { + if (typeof document.source !== "string" || document.source.length > 262144 || typeof document.workspaceId !== "string") throw new Error("Invalid template identity request"); + process.stdout.write(JSON.stringify({ source: instantiateWorkspaceIdentity(document.source, document.workspaceId) })); + process.exit(0); +} +const files: { path: string; source: string }[] = document; if (!Array.isArray(files) || files.length > 128) throw new Error("Too many source files"); const result = files.map(({ path, source }) => { if (typeof path !== "string" || typeof source !== "string" || source.length > 262144) diff --git a/src/capability-language/pin-upgrades.ts b/src/capability-language/pin-upgrades.ts index f84224d..927313b 100644 --- a/src/capability-language/pin-upgrades.ts +++ b/src/capability-language/pin-upgrades.ts @@ -7,10 +7,12 @@ import {promisify} from "node:util"; import {loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js"; import {contentDigest} from "../capability-model/evolution.js"; import {planStructure, applyStructure, type StructuralRequest} from "./structural-plan.js"; -import {snapshotRepository, checkResourceCandidate, checkWorkspaceCandidate} from "./candidate-check.js"; +import {snapshotRepository} from "./candidate-check.js"; import {compileWorkspaceRepository, compileCapabilityResourceRepository, type ResolvedCapabilityResource} from "./assembly.js"; import {createGitCapabilityResolver} from "./git-resolver.js"; -import {snapshotCommit} from "./checked-build.js"; +import {snapshotCommit, buildImmutableCandidate} from "./checked-build.js"; +import {planEvolution, type WorkspaceRevision, type EvolutionReview} from "../capability-model/index.js"; +import {withFileLock} from "./file-lock.js"; const execFile = promisify(callback); type Source = {repository: string; commit: string}; export type UpgradeNode = {kind: "workspace" | "package" | "interface"; directory: string; source: Source}; @@ -103,15 +105,21 @@ export type UpgradeEffects = { const effects: UpgradeEffects = { async check(node, root, output, spec) { if (node.kind === "workspace" && !spec.baseline && !spec.bootstrap) throw new Error("Upgrading a workspace requires its checked active baseline for major-review checks (or explicit bootstrap:true for a new workspace)"); - // Publication checks consume already-published dependency revisions, never - // workbench dirty overlays masquerading as those immutable identities. - const snapshotMap = `${output}-published-dependencies.json`; - await fs.writeFile(snapshotMap, JSON.stringify({resources: []}), {flag: "wx"}); - const result = node.kind === "workspace" ? await checkWorkspaceCandidate({root, output, snapshotMap, baseline: spec.baseline, reviews: spec.reviews}) - : await checkResourceCandidate({root, output, kind: node.kind, source: node.source, snapshotMap}); - if (result.blockers.length) throw new Error(`Refactor required in ${node.directory}: ${result.blockers.join("; ")}`); - const evolution = (result as {evolution?: {reviews: {accepted: boolean}[]}}).evolution; - if (evolution?.reviews.some((review) => !review.accepted)) throw new Error("Explicit semantic-major review required before publishing the workspace"); + // Explicit baseline upgrades use the same immutable Nix checker. Retaining + // an unverified source is safe and must precede a remote flake fetch. + await fs.mkdir(output); + const commit = await snapshotCommit(root); + await effects.publish(root, commit); + const artifact = await buildImmutableCandidate({...node.source, commit}, node.kind, path.join(output, "nix.log")); + const candidate = await fs.readFile(path.join(artifact, "candidate.json"), "utf8"); + await fs.writeFile(path.join(output, "candidate.json"), candidate); + if (node.kind === "workspace") { + const baseline = spec.baseline ? JSON.parse(await fs.readFile(spec.baseline, "utf8")) as WorkspaceRevision : null; + const reviews = spec.reviews ? JSON.parse(await fs.readFile(spec.reviews, "utf8")) as EvolutionReview[] : []; + const evolution = planEvolution(baseline, JSON.parse(candidate), {reviews}); + await fs.writeFile(path.join(output, "evolution.json"), JSON.stringify(evolution, null, 2)); + if (evolution.blockers.length) throw new Error(`Refactor required in ${node.directory}: ${evolution.blockers.join("; ")}`); + } }, async snapshot(root) { const commit = await snapshotCommit(root); @@ -139,10 +147,10 @@ export const applyPinUpgrades = async (plan: UpgradePlan, journalId?: string, im const directory = path.join(plan.workbench, ".quixos", "upgrades"); await fs.mkdir(directory, {recursive: true, mode: 0o700}); if (await fs.realpath(directory) !== directory) throw new Error("Upgrade journals must not cross symlinks"); - const lock = await fs.open(path.join(directory, "writer.lock"), "wx", 0o600); const id = journalId ?? randomUUID(); - if (!/^[a-f0-9-]{36}$/.test(id)) {await lock.close(); await fs.unlink(path.join(directory, "writer.lock")); throw new Error("Invalid upgrade journal ID");} + if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid upgrade journal ID"); const filename = path.join(directory, `${id}.json`); + return withFileLock(path.join(directory, "writer.lock"), async () => { try { const journal: Journal = journalId ? JSON.parse(await fs.readFile(filename, "utf8")) : {schemaVersion: 1, plan, steps: []}; if (journal.plan.digest !== plan.digest) throw new Error("Upgrade journal belongs to another plan"); @@ -242,5 +250,5 @@ export const applyPinUpgrades = async (plan: UpgradePlan, journalId?: string, im } return {id, journal: filename, revisions: journal.steps.map((step) => ({directory: step.directory, commit: step.commit})), activated: false}; } catch (error) {throw new Error(`${error instanceof Error ? error.message : String(error)}; upgrade journal ${filename}`, {cause: error});} - finally {await lock.close(); await fs.unlink(path.join(directory, "writer.lock"));} + }); }; diff --git a/src/capability-language/scaffold-recipes.ts b/src/capability-language/scaffold-recipes.ts index 1e315cc..1c5c19c 100644 --- a/src/capability-language/scaffold-recipes.ts +++ b/src/capability-language/scaffold-recipes.ts @@ -88,7 +88,6 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio installServer = { libexecName = ${JSON.stringify(name.toLowerCase())}; descriptorPath = "descriptor.quixos-package.txtpb"; ${react ? 'extraFiles = [ { source = "dist/component.mjs"; target = "component.mjs"; } ];' : ""} }; }; }\n`); - generated("quixos.resources.json", json({generatedBy: "qx-scaffold-v1", resources: []})); } else { registry = await ownedJson(root, prefix + "quixos.scaffold.json"); catalog = await ownedJson(root, prefix + "quixos.migrations.json"); diff --git a/src/capability-language/structural-edits.ts b/src/capability-language/structural-edits.ts index 0f983d2..7ba8ac3 100644 --- a/src/capability-language/structural-edits.ts +++ b/src/capability-language/structural-edits.ts @@ -21,6 +21,21 @@ const selectable = new Set(["workspaceDecl", "fragmentDecl", "interfaceResourceD "valueMember", "relationshipMember", "operationMember", "packageOperationExport", "packageFunctionExport", "packageConstructorExport", "conformanceDecl", "stateDecl", "edgeDecl", "constructorBindingDecl", "resourceImportDecl", "sourceImportDecl", "operationBindingDecl"]); +/** Bind only the template root identity; schema/atom identities are reusable. + * The revision's real identity is derived from its containing commit at compile time. */ +export const instantiateWorkspaceIdentity = (source: string, workspaceId: string): string => { + if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(workspaceId)) throw new Error("Workspace identity must be a UUID"); + const syntax = parseQx(source); + if (syntax.diagnostics.length) throw new Error("Cannot instantiate a malformed template workspace"); + const declaration = syntax.root.children.find(node => node.kind === "workspaceDecl"); + const literals = declaration?.children.filter(node => node.kind === "stringLiteral"); + if (!literals || literals.length !== 3) throw new Error("Template must contain a workspace declaration"); + return applySourceEdits(source, [ + { ...literals[0], text: JSON.stringify(workspaceId) }, + { ...literals[1], text: JSON.stringify(`workspace-revision:${workspaceId}:source`) }, + ]); +}; + const select = (source: string, selector: StructuralSelector): {node: SyntaxNode; syntax: ReturnType} => { if (!selectable.has(selector.kind)) throw new Error(`Unsupported structural selector ${selector.kind}`); const syntax = parseQx(source); @@ -117,10 +132,16 @@ export const editStructure = (source: string, edit: StructuralEdit): string => { if (!closing) throw new Error("Append requires a declaration with a body"); result = applySourceEdits(source, [{start: closing.start, end: closing.start, text: `\n${edit.source}\n`}]); } else { + // A resource parser node includes imports/external declarations preceding + // its header. Replacing the declaration must not delete that preamble. + const identifier = node.children.find(child => child.kind === "identifier"); + const header = ["packageResourceDecl", "interfaceResourceDecl"].includes(node.kind) && identifier + ? syntax.tokens.filter(token => token.start >= node.start && token.end <= identifier.start && + token.kind === (node.kind === "packageResourceDecl" ? "PACKAGE" : "INTERFACE")).at(-1)?.start : undefined; const wrapper = edit.operation === "remove" && ["stateDecl", "edgeDecl"].includes(node.kind) ? [...walkSyntax(syntax.root)].filter((entry) => ["conformanceItem", "sharedAttachmentDecl"].includes(entry.kind) && entry.start <= node.start && entry.end >= node.end).sort((a, b) => (a.end - a.start) - (b.end - b.start))[0] : undefined; - result = applySourceEdits(source, [{start: wrapper?.start ?? node.start, end: wrapper?.end ?? node.end, text: edit.operation === "replace" ? edit.source : ""}]); + result = applySourceEdits(source, [{start: wrapper?.start ?? header ?? node.start, end: wrapper?.end ?? node.end, text: edit.operation === "replace" ? edit.source : ""}]); } const checked = parseQx(result); if (checked.diagnostics.length) throw new Error(`Invalid structural change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`); diff --git a/src/capability-language/structural-plan.ts b/src/capability-language/structural-plan.ts index 28be4b9..c81f101 100644 --- a/src/capability-language/structural-plan.ts +++ b/src/capability-language/structural-plan.ts @@ -8,11 +8,15 @@ import { snapshotRepository, localResourceSnapshots } from "./candidate-check.js import { compileWorkspaceRepository, compileCapabilityResourceRepository } from "./assembly.js"; import { createGitCapabilityResolver } from "./git-resolver.js"; import {bindingSchema, generateTypeScriptBindings} from "../bindings/index.js"; +import { parseQx } from "./source.js"; +import { parseQuixosLockDocument } from "../resource-lock/index.js"; +import { withFileLock } from "./file-lock.js"; export type StructuralRequest = { kind: "workspace" | "interface" | "package"; source?: {repository: string; commit: string}; resourceRoot?: string; + validation?: "syntax" | "resource-graph"; files: ({file: string; edits: StructuralEdit[]} | {file: string; create: string} | {file: string; generated: string})[]; }; type Change = {file: string; before: string | null; after: string; mode: number}; @@ -50,6 +54,7 @@ const durableJson = async (file: string, value: unknown) => { /** Validate the entire edited resource graph in a private snapshot before writes. */ export const planStructure = async (rootPath: string, request: StructuralRequest, snapshotMap?: string) => { + if (request.validation && !["syntax", "resource-graph"].includes(request.validation)) throw new Error("Unknown structural validation mode"); const root = await fs.realpath(rootPath); const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-structure-")); try { @@ -74,11 +79,28 @@ export const planStructure = async (rootPath: string, request: StructuralRequest after = input.edits.reduce(editStructure, before); } if (Buffer.byteLength(after) > 1024 * 1024) throw new Error("Scaffold file exceeds 1 MiB"); + if (input.file.endsWith("package.qx")) { + let registry; + try { registry = JSON.parse(await fs.readFile(path.join(root, path.dirname(input.file), "quixos.scaffold.json"), "utf8")); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } + if (registry?.generatedBy === "qx-scaffold-v1") { + const declaration = parseQx(after).root.children.find(node => node.kind === "packageResourceDecl"); + const literal = declaration?.children.find(node => node.kind === "stringLiteral"); + if (literal && JSON.parse(after.slice(literal.start, literal.end)) !== registry.id) + throw new Error("Cannot change a scaffold-owned package identity independently of its registry; create a new managed package instead"); + } + } const mode = before === null ? 0o644 : (await fs.stat(path.join(root, input.file))).mode & 0o777; changes.push({file: input.file, before, after, mode}); await containedParent(snapshot.directory, input.file); await fs.writeFile(path.join(snapshot.directory, input.file), after); } + if (request.validation === "syntax") { + for (const change of changes) { + if (change.file.endsWith(".qx") && parseQx(change.after, change.file).diagnostics.length) throw new Error(`Invalid QX syntax in ${change.file}`); + if (change.file.endsWith(".lock") && !parseQuixosLockDocument(change.after, change.file).ok) throw new Error(`Invalid lock syntax in ${change.file}`); + } + } else { const localMap = path.join(temporary, "local-resources.json"); await fs.writeFile(localMap, JSON.stringify(await localResourceSnapshots(root, snapshotMap))); const resolveResource = await createGitCapabilityResolver({checkoutRoot: path.join(temporary, "resources"), snapshotMap: localMap}); @@ -93,7 +115,6 @@ export const planStructure = async (rootPath: string, request: StructuralRequest const configuration = JSON.parse(await fs.readFile(path.join(resourceRoot, "quixos.check.json"), "utf8")); const artifacts = [ {file: configuration.bindingOutput as string, after: generateTypeScriptBindings(bindingSchema(compiled), compiled.resource.revision.revisionId, configuration.options)}, - {file: "quixos.resources.json", after: JSON.stringify({generatedBy: "qx-scaffold-v1", resources: compiled.resources.filter((entry) => entry.directory !== resourceRoot).map((entry) => ({kind: entry.kind, repository: entry.source.repository, commit: entry.source.commit}))}, null, 2) + "\n"}, ]; for (const artifact of artifacts) { const file = request.resourceRoot ? `${request.resourceRoot}/${artifact.file}` : artifact.file; @@ -108,11 +129,12 @@ export const planStructure = async (rootPath: string, request: StructuralRequest } } else throw new Error("Resource plans require kind and exact authored source identity"); + } if (changes.length > 100) throw new Error("Structural plan including generated artifacts exceeds 100 files"); // Validation may fetch dependencies; reject edits made while it was running. for (const entry of changes) if (await read(root, entry.file) !== entry.before) throw new Error(`Source changed while planning: ${entry.file}`); for (const entry of observed) if (contentDigest(await fs.readFile(path.join(root, entry.file), "utf8")) !== entry.digest) throw new Error(`Validation input changed while planning: ${entry.file}`); - return {root, changes, observed, digest: contentDigest(changes), validation: "resource-graph" as const}; + return {root, changes, observed, digest: contentDigest(changes), validation: request.validation ?? "resource-graph" as const}; } finally { await fs.rm(temporary, {recursive: true, force: true}); } }; @@ -154,15 +176,7 @@ const replayStructure = async (rootPath: string, id: string) => { const withStructureLock = async (root: string, work: () => Promise) => { await containedParent(root, ".quixos/scaffolds/placeholder.json"); const lock = path.join(root, ".quixos", "scaffolds", "writer.lock"); - // Never steal a possibly live writer's lock. A process crash requires the - // operator to verify that writer is gone, remove this lock, then resume its - // journal. This is deliberately fail-closed instead of guessing from a PID. - const handle = await fs.open(lock, "wx", 0o600).catch((error) => { - if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Another scaffold writer or interrupted writer owns ${lock}; verify it has exited before removing its lock and resuming`); - throw error; - }); - try { await handle.writeFile(JSON.stringify({pid: process.pid})); await handle.sync(); return await work(); } - finally { await handle.close(); await fs.unlink(lock); } + return withFileLock(lock, work); }; export const resumeStructure = async (rootPath: string, id: string) => { const root = await fs.realpath(rootPath); diff --git a/src/capability-language/tool-cli.ts b/src/capability-language/tool-cli.ts index a9788ac..4449bb0 100644 --- a/src/capability-language/tool-cli.ts +++ b/src/capability-language/tool-cli.ts @@ -1,29 +1,120 @@ #!/usr/bin/env node -import { readFile, writeFile, mkdtemp, rm } from "node:fs/promises"; +import { readFile, writeFile, mkdtemp, rm, realpath } from "node:fs/promises"; import { parseQx, formatQx, lintQx } from "./source.js"; import { scaffoldAtom } from "./scaffold.js"; import { createGitCapabilityResolver } from "./git-resolver.js"; import { planEvolution } from "../capability-model/index.js"; -import { checkWorkspaceCandidate, checkResourceCandidate, snapshotRepository } from "./candidate-check.js"; +import { snapshotRepository } from "./candidate-check.js"; import {planPinUpgrades, applyPinUpgrades, discoverUpgradeSpec, type UpgradeSpec} from "./pin-upgrades.js"; import path from "node:path"; import os from "node:os"; import {spawnSync} from "node:child_process"; import { planStructure, applyStructure, resumeStructure, type StructuralRequest } from "./structural-plan.js"; import {scaffoldRecipe, type ScaffoldRecipe} from "./scaffold-recipes.js"; -import {buildCheckedPackage, snapshotCommit} from "./checked-build.js"; +import {buildCheckedPackage, buildImmutableCandidate, snapshotCommit} from "./checked-build.js"; import {formatQuixosLock, loadQuixosLock, parseQuixosLockDocument} from "../resource-lock/index.js"; +import { walkSyntax } from "./source.js"; +import { inspectWorkbench } from "./authoring-inspect.js"; +import { authoringContext } from "./authoring-context.js"; +import { convergeAuthoring } from "./authoring-converge.js"; +import { checkAuthoring } from "./authoring-check.js"; +import { authoringWorklist } from "./authoring-worklist.js"; const authorSource = async (root: string) => { - const result = spawnSync("git", ["remote", "get-url", "origin"], {cwd: root, encoding: "utf8"}); + const result = spawnSync("git", ["config", "--get", "remote.origin.url"], {cwd: root, encoding: "utf8"}); if (result.error || result.status !== 0) throw new Error("Managed resource has no origin"); return {repository: result.stdout.trim(), commit: await snapshotCommit(root)}; }; +const readSpec = async (value: string) => { + if (value === "-") { let input = ""; for await (const chunk of process.stdin) { input += chunk; if (input.length > 1024 * 1024) throw new Error("Scaffold specification exceeds 1 MiB"); } return JSON.parse(input); } + return JSON.parse(value.trimStart().startsWith("{") ? value : await readFile(value, "utf8")); +}; +const planSummary = (plan: Awaited>) => ({ + root: plan.root, validation: plan.validation, + changes: plan.changes.map(change => ({file: change.file, beforeBytes: change.before?.length ?? 0, afterBytes: change.after?.length ?? 0})), + note: "Structural plan only, not implementation verification. Run qx-workspace check while iterating.", +}); + const main = async () => { const [command, ...args] = process.argv.slice(2); + if (command === "worklist" && !args.includes("--help")) { + if (args.length !== 1) throw new Error("usage: quixos-qx worklist WORKBENCH"); + process.stdout.write(`${JSON.stringify(await authoringWorklist(args[0]), null, 2)}\n`); + return; + } + if (["author-check", "author-contract"].includes(command) && !args.includes("--help")) { + const [root, output, ...flags] = args; + if (!root || !output) throw new Error("usage: quixos-qx author-check ROOT OUTPUT [--baseline FILE] [--reviews FILE]"); + const options: {baseline?: string; reviews?: string} = {}; + for (let index = 0; index < flags.length; index += 2) { + if (!flags[index + 1]) throw new Error("Missing check option value"); + if (flags[index] === "--baseline") options.baseline = flags[index + 1]; + else if (flags[index] === "--reviews") options.reviews = flags[index + 1]; + else throw new Error(`Unknown check option ${flags[index]}`); + } + const result = await checkAuthoring(root, output, {...options, contractOnly: command === "author-contract"}); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (result.blockers.length) process.exitCode = 1; + return; + } + if (["converge", "_converge"].includes(command) && !args.includes("--help")) { + if (!args.length || args.length > 2) throw new Error("usage: quixos-qx converge WORKBENCH [REGISTERED_DIRECTORY] (join package writers first)"); + const context = await authoringContext(args[0]); + if (command === "converge") { + const result = spawnSync("flock", ["--exclusive", "--nonblock", "--conflict-exit-code", "75", path.join(context.workbench, ".quixos/converge.lock"), + process.execPath, process.argv[1], "_converge", context.workbench, ...(args[1] ? [args[1]] : [])], {stdio: "inherit"}); + if (result.error) throw result.error; + if (result.status === 75) process.stderr.write("Another source coordinator is running; retry when it finishes.\n"); + process.exitCode = result.status ?? 1; return; + } + const result = await convergeAuthoring(context.workbench, args[1]); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + if (!result.converged) process.exitCode = 1; + return; + } + if (command === "check-committed" && !args.includes("--help")) { + const [kind, repository, commit, log, ...extra] = args; + if (!["workspace", "interface", "package"].includes(kind) || !log || extra.length) throw new Error("usage: quixos-qx check-committed workspace|interface|package REPOSITORY COMMIT LOG_FILE"); + process.stdout.write(`${await buildImmutableCandidate({repository, commit}, kind as "workspace" | "interface" | "package", log)}\n`); + return; + } + if (!command || command === "--help" || args.includes("--help")) { + process.stdout.write("quixos-qx: author-check, author-contract, converge, worklist, inspect, resources, check-committed, source-baseline, scaffold-package, scaffold-interface, scaffold-function, scaffold-dependency, scaffold-structure, scaffold-resume, pin-upgrade, parse, lint, format\n" + + "inspect WORKBENCH [RESOURCE] shows provisional contracts, with explicit historical fallback; never verification evidence.\n" + + "resources WORKBENCH lists registered editable repositories. Use qx-workspace for the workspace authoring workflow.\n"); + return; + } + if (command === "inspect" || command === "resources") { + if (!args[0] || args.length > (command === "inspect" ? 2 : 1)) throw new Error(`usage: quixos-qx ${command} WORKBENCH${command === "inspect" ? " [RESOURCE]" : ""}`); + const result = command === "inspect" ? await inspectWorkbench(args[0], args[1]) : await authoringContext(args[0]); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + if (command === "source-baseline") { + if (args.length !== 1) throw new Error("usage: quixos-qx source-baseline WORKBENCH"); + process.stdout.write(`${JSON.stringify(await (await authoringContext(args[0])).baseline())}\n`); + return; + } if (command === "scaffold-dependency") { - const [root, kind, name, repository, commit, ...flags] = args; + const [root, kind, name, ...remaining] = args; + let repository: string | undefined, commit: string | undefined; + const flags = [...remaining]; + if (flags.length && !flags[0].startsWith("--")) { repository = flags.shift(); commit = flags.shift(); } + else if (root && ["interface", "package"].includes(kind) && name) { + const context = await authoringContext(root); + const alias = await realpath(path.join(context.workbench, `${kind}s`, name)).catch(() => null); + const matches = context.resources.filter(entry => entry.kind === kind && (entry.resourceId === name || path.basename(entry.directory) === name || path.join(context.workbench, entry.directory) === alias)); + if (matches.length !== 1 || !matches[0].source) throw new Error(`Select exactly one registered ${kind} with qx-workspace resources; no match for ${name}`); + const selected = matches[0]; + let source = selected.source!; + if (flags.includes("--write")) { + const retained = spawnSync("quixos-qx", ["converge", context.workbench, selected.directory], {encoding: "utf8", env: {...process.env, QUIXOS_JJ_NO_CHECKPOINT: "1"}}); + if (retained.error || retained.status !== 0) throw new Error(`Dependency source needs attention: ${retained.error?.message ?? retained.stdout ?? retained.stderr}`); + source = JSON.parse(retained.stdout).candidate; + } + repository = source.repository; commit = source.commit; + } if (!root || !["interface", "package"].includes(kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name ?? "") || !repository || !commit || flags.some(flag => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-dependency ROOT interface|package NAME REPOSITORY COMMIT [--write]"); const resourceKind = kind as "package" | "interface"; let entrypoint: "workspace" | "package" | "interface" | undefined; @@ -44,13 +135,13 @@ const main = async () => { {file: target, edits: [{operation: "dependency", kind: resourceKind, name, source: {repository, commit}}]}, ]}; const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP); - process.stdout.write(`${JSON.stringify({...plan, applied: flags.includes("--write") ? await applyStructure(plan) : undefined}, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({...planSummary(plan), applied: flags.includes("--write") ? await applyStructure(plan) : undefined}, null, 2)}\n`); return; } if (command === "scaffold-interface") { const [root, specFile, ...flags] = args; if (!root || !specFile || flags.some(flag => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-interface ROOT SPEC_JSON [--write]"); - const spec = JSON.parse(await readFile(specFile, "utf8")) as ScaffoldRecipe; + const spec = await readSpec(specFile) as ScaffoldRecipe; if (!spec.name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(spec.name) || !spec.id || !spec.revision || !spec.tools?.quixos) throw new Error("Interface scaffold requires name, id, revision and Quixos toolchain source"); const request: StructuralRequest = {kind: "interface", source: spec.source, files: [ {file: "interface.qx", create: `interface ${spec.name} id ${JSON.stringify(spec.id)} revision ${JSON.stringify(spec.revision)} {\n}\n`}, @@ -58,7 +149,7 @@ const main = async () => { {file: ".gitignore", create: ".quixos/\n"}, ]}; const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP); - process.stdout.write(`${JSON.stringify({...plan, applied: flags.includes("--write") ? await applyStructure(plan) : undefined}, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({...planSummary(plan), applied: flags.includes("--write") ? await applyStructure(plan) : undefined}, null, 2)}\n`); return; } if (command === "build-package") { @@ -89,14 +180,6 @@ const main = async () => { process.stdout.write(`${JSON.stringify(publish ? await applyPinUpgrades(plan, resume, undefined, {acceptEdits}) : plan, null, 2)}\n`); return; } - if (command === "check-resource") { - const [root, kind, repository, commit, output, ...extra] = args; - if (!root || !output || !["package", "interface"].includes(kind) || extra.length) throw new Error("usage: quixos-qx check-resource ROOT package|interface REPOSITORY COMMIT OUTPUT"); - const result = await checkResourceCandidate({root, kind: kind as "package" | "interface", source: {repository, commit}, output, publishedOnly: true}); - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - if (result.blockers.length) process.exitCode = 1; - return; - } if (["scaffold-package", "scaffold-function", "scaffold-migration", "scaffold-refresh"].includes(command)) { const [root, specFile, ...flags] = args; let spec: ScaffoldRecipe; @@ -107,9 +190,16 @@ const main = async () => { if (declaration >= 0) { if (!flags[declaration + 1]) throw new Error("--declaration requires a QX declaration file"); spec.declaration = await readFile(flags[declaration + 1], "utf8"); + const parsed = parseQx(`package Draft id "package:draft" revision "package:draft@1" { ${spec.declaration} }`); + if (parsed.diagnostics.length) throw new Error(parsed.diagnostics.map(d => d.message).join("\n")); + const exported = [...walkSyntax(parsed.root)].filter(node => ["packageOperationExport", "packageFunctionExport", "packageConstructorExport"].includes(node.kind)); + if (exported.length !== 1) throw new Error("--declaration must contain exactly one function, operation or constructor export"); + const literal = exported[0].children.find(node => node.kind === "stringLiteral"); + if (!literal) throw new Error("Declaration requires an authored export ID"); + spec.id = JSON.parse(parsed.source.slice(literal.start, literal.end)); flags.splice(declaration, 2); } - } else spec = JSON.parse(await readFile(specFile, "utf8")) as ScaffoldRecipe; + } else spec = await readSpec(specFile) as ScaffoldRecipe; if (!root || !specFile || flags.some((flag) => !["--write", "--install"].includes(flag)) || (flags.includes("--install") && !flags.includes("--write"))) throw new Error("usage: quixos-qx scaffold-package|function|migration|refresh ROOT SPEC_JSON [--write [--install]]"); const request = await scaffoldRecipe(root, command.slice(9) as "package" | "function" | "migration" | "refresh", spec); const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP); @@ -128,16 +218,17 @@ const main = async () => { const locked = spawnSync("nix", ["flake", "lock"], {cwd, stdio: ["inherit", 2, 2]}); if (locked.error || locked.status !== 0) throw new Error("Scaffold files retained; nix flake lock failed"); } - process.stdout.write(`${JSON.stringify({...plan, applied}, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({...planSummary(plan), applied}, null, 2)}\n`); return; } if (command === "scaffold-structure") { const [root, spec, ...flags] = args; if (!root || !spec || flags.some((flag) => flag !== "--write")) throw new Error("usage: quixos-qx scaffold-structure ROOT SPEC_JSON [--write]"); - const request = JSON.parse(await readFile(spec, "utf8")) as StructuralRequest; + const request = await readSpec(spec) as StructuralRequest; + if (request.kind !== "workspace" && !request.source) request.source = await authorSource(root); const plan = await planStructure(root, request, process.env.QUIXOS_SNAPSHOT_MAP); const applied = flags.includes("--write") ? await applyStructure(plan) : undefined; - process.stdout.write(`${JSON.stringify({...plan, applied}, null, 2)}\n`); + process.stdout.write(`${JSON.stringify({...planSummary(plan), applied}, null, 2)}\n`); return; } if (command === "scaffold-resume") { @@ -146,19 +237,7 @@ const main = async () => { process.stdout.write(`${JSON.stringify(await resumeStructure(root, id), null, 2)}\n`); return; } - if (command === "check") { - const [root, output, ...flags] = args; - if (!root || !output || flags.length % 2) throw new Error("usage: quixos-qx check ROOT OUTPUT [--snapshot-map FILE] [--baseline FILE] [--reviews FILE]"); - const values = new Map(); - for (let index = 0; index < flags.length; index += 2) { - if (!["--snapshot-map", "--baseline", "--reviews"].includes(flags[index])) throw new Error(`Unknown check option ${flags[index]}`); - values.set(flags[index], flags[index + 1]); - } - const result = await checkWorkspaceCandidate({ root, output, snapshotMap: values.get("--snapshot-map"), baseline: values.get("--baseline"), reviews: values.get("--reviews") }); - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); - if (result.blockers.length) process.exitCode = 1; - return; - } + if (["check", "check-resource"].includes(command)) throw new Error("Use qx-workspace check in the registered repository; handwritten source/snapshot-map candidates are no longer an authoring check path"); if (command === "evolution") { const [baseline, candidate, reviews, ...extra] = args; if (!baseline || !candidate || extra.length) throw new Error("usage: quixos-qx evolution BASELINE_JSON CANDIDATE_JSON [REVIEWS_JSON]"); diff --git a/src/capability-model/validation.ts b/src/capability-model/validation.ts index 22f527a..7ffdd7a 100644 --- a/src/capability-model/validation.ts +++ b/src/capability-model/validation.ts @@ -1467,8 +1467,12 @@ const validateConformances = ( ); continue; } - validateAttachmentAccess(issues, attachment, conformance, `${bindingPath}.binding.edgeTypeId`); const projection = edgeProjection(attachment.attachment, binding.projectionId); + // An owned endpoint may explicitly export read-only traversal, including + // a native inverse relationship view. This never exports mutation rights. + if (!(binding.primitive === "resolve" && projection?.endpoint.publicTraversal)) { + validateAttachmentAccess(issues, attachment, conformance, `${bindingPath}.binding.edgeTypeId`); + } const relationshipMember = operationEntry.member.kind === "relationship" ? operationEntry.member : undefined; diff --git a/test/authoring-converge.test.ts b/test/authoring-converge.test.ts new file mode 100644 index 0000000..8f0b8fa --- /dev/null +++ b/test/authoring-converge.test.ts @@ -0,0 +1,84 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { execFile as callback } from "node:child_process"; +import { promisify } from "node:util"; +import { convergeAuthoring } from "../src/capability-language/authoring-converge.js"; +import { loadQuixosLock } from "../src/resource-lock/index.js"; +const execFile = promisify(callback); + +test("source convergence propagates nested edits and unchanged snapshots reach a fixed point", async context => { + const temporary = await fs.mkdtemp(path.join(os.tmpdir(), "qx-converge-test-")); + context.after(() => fs.rm(temporary, { recursive: true, force: true })); + const workbench = path.join(temporary, "workbench"), remotes = path.join(temporary, "remotes"); + await fs.mkdir(path.join(workbench, ".quixos"), { recursive: true }); + await fs.mkdir(remotes); + const origin = "https://convergence.example.test/"; + const previous = Object.fromEntries(["GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0"].map(key => [key, process.env[key]])); + process.env.GIT_CONFIG_COUNT = "1"; + process.env.GIT_CONFIG_KEY_0 = `url.file://${remotes}/.insteadOf`; + process.env.GIT_CONFIG_VALUE_0 = origin; + context.after(() => { for (const [key, value] of Object.entries(previous)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } }); + const resources = []; + let dependency = ""; + for (const [directory, kind, name] of [["resources/Base", "interface", "Base"], ["resources/Consumer", "package", "Consumer"], ["root", "workspace", "Root"]]) { + const root = path.join(workbench, directory); + await fs.mkdir(root, { recursive: true }); + await execFile("jj", ["git", "init", "--colocate", root]); + await execFile("git", ["init", "--bare", path.join(remotes, name)]); + const repository = `${origin}${name}`; + await execFile("git", ["-C", root, "remote", "add", "origin", repository]); + await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; } ${dependency} }`); + await fs.writeFile(path.join(root, `${kind}.qx`), "draft"); + await execFile("jj", ["status"], { cwd: root }); + const commit = (await execFile("jj", ["--ignore-working-copy", "log", "--no-graph", "-r", "@", "-T", "commit_id"], { cwd: root })).stdout.trim(); + if (kind !== "workspace") resources.push({ directory, kind, resourceId: `${kind}:${name}`, source: { resolver: "git", repository, commit } }); + dependency = `${kind} ${name} source { repository "${repository}"; commit "${commit}"; }`; + } + await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({ resources })); + const first = await convergeAuthoring(workbench); + assert.deepEqual(first.worklist, []); + assert.equal(first.converged, true); + assert.equal(first.verificationEvidence, false, "source retention never approves even syntactically invalid code"); + await fs.writeFile(path.join(workbench, "resources/Base/interface.qx"), "edited draft"); + const second = await convergeAuthoring(workbench); + assert.deepEqual(second.worklist, []); + assert.notEqual(second.candidate?.commit, first.candidate?.commit); + const consumer = await loadQuixosLock(path.join(workbench, "resources/Consumer/quixos.lock")); + assert.ok(consumer.ok); + assert.equal(consumer.lock.resources[0].source.commit, second.retained.find(entry => entry.directory === "resources/Base")?.source.commit); + const third = await convergeAuthoring(workbench); + assert.deepEqual(third, second); + const base = path.join(workbench, "resources/Base"); + const consumerRoot = path.join(workbench, "resources/Consumer"); + const goodLock = await fs.readFile(path.join(consumerRoot, "quixos.lock"), "utf8"); + await fs.writeFile(path.join(consumerRoot, "quixos.lock"), "broken draft"); + const scoped = await convergeAuthoring(workbench, "resources/Base"); + assert.deepEqual(scoped.worklist, [], "an unrelated malformed consumer cannot block a provider check"); + await fs.writeFile(path.join(base, "interface.qx"), "another edit"); + const partial = await convergeAuthoring(workbench); + assert.equal(partial.converged, false); + const graph = JSON.parse(await fs.readFile(path.join(workbench, ".quixos/resource-graph.json"), "utf8")); + assert.equal(graph.resources[0].source.commit, partial.retained.find(entry => entry.directory === "resources/Base")?.source.commit); + await fs.writeFile(path.join(consumerRoot, "quixos.lock"), goodLock); + const resumed = await convergeAuthoring(workbench); + assert.deepEqual(resumed.worklist, []); + assert.deepEqual(await convergeAuthoring(workbench), resumed); + await execFile("git", ["-C", base, "remote", "set-url", "origin", origin + "Wrong"]); + const mismatch = await convergeAuthoring(workbench); + assert.ok(mismatch.worklist.some(entry => entry.phase === "source" && /Origin differs/.test(entry.message))); + await execFile("git", ["-C", base, "remote", "set-url", "origin", origin + "Base"]); + await fs.rename(path.join(remotes, "Base"), path.join(remotes, "Base-offline")); + const offline = await convergeAuthoring(workbench); + assert.equal(offline.candidate, null); + assert.ok(offline.worklist.some(entry => entry.phase === "publication")); + await fs.rename(path.join(remotes, "Base-offline"), path.join(remotes, "Base")); + assert.equal((await convergeAuthoring(workbench)).converged, true); + const consumerSource = resumed.retained.find(entry => entry.directory === "resources/Consumer")!.source; + await fs.writeFile(path.join(base, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; } package Consumer source { repository "${consumerSource.repository}"; commit "${consumerSource.commit}"; } }`); + const cycle = await convergeAuthoring(workbench); + assert.equal(cycle.candidate, null); + assert.ok(cycle.worklist.some(entry => /Source dependency cycle/.test(entry.message))); +}); diff --git a/test/authoring-inspect.test.ts b/test/authoring-inspect.test.ts new file mode 100644 index 0000000..e9e4418 --- /dev/null +++ b/test/authoring-inspect.test.ts @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, writeFile, rm, symlink } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { execFile as callback } from "node:child_process"; +import { promisify } from "node:util"; +import { inspectAuthoringRepository } from "../src/capability-language/authoring-inspect.js"; + +const execFile = promisify(callback); +test("inspection keeps current files and labels historical recovery without granting verification", async context => { + const root = await mkdtemp(path.join(os.tmpdir(), "qx-inspection-test-")); + context.after(() => rm(root, { recursive: true, force: true })); + const git = (...args: string[]) => execFile("git", ["-C", root, ...args]); + await git("init"); + await writeFile(path.join(root, "interface.qx"), 'interface Example id "interface:example" revision "interface:example@1" {}'); + await git("add", "."); + await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "contract"); + const commit = (await git("rev-parse", "HEAD")).stdout.trim(); + await writeFile(path.join(root, "interface.qx"), "interface broken {{{"); + await writeFile(path.join(root, "new.qx"), 'interface New id "interface:new" revision "interface:new@1" {}'); + const inspected = await inspectAuthoringRepository(root); + assert.equal(inspected.verificationEvidence, false); + assert.equal(inspected.resolutionChecked, false); + assert.equal(inspected.files[0].status, "historical"); + assert.equal(inspected.files[0].revision, commit); + assert.ok(inspected.files[0].currentErrors.length); + assert.match(inspected.files[0].declarations[0].source, /Example/); + assert.equal(inspected.files[1].status, "current"); + assert.match(inspected.files[1].declarations[0].source, /New/); + assert.equal((await git("rev-parse", "HEAD")).stdout.trim(), commit); + await symlink(path.join(root, "interface.qx"), path.join(root, "linked.qx")); + const linked = (await inspectAuthoringRepository(root)).files.find(file => file.file === "linked.qx")!; + assert.equal(linked.status, "unavailable"); + assert.deepEqual(linked.declarations, []); + assert.match(JSON.stringify(linked.currentErrors), /ordinary files/); +}); diff --git a/test/authoring-worklist.test.ts b/test/authoring-worklist.test.ts new file mode 100644 index 0000000..659db7a --- /dev/null +++ b/test/authoring-worklist.test.ts @@ -0,0 +1,45 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import {execFile as callback} from "node:child_process"; +import {promisify} from "node:util"; +import {authoringWorklist} from "../src/capability-language/authoring-worklist.js"; +import {checkRecordName} from "../src/capability-language/authoring-check.js"; +import {checkerIdentity, snapshotCommit} from "../src/capability-language/checked-build.js"; +const execFile = promisify(callback); + +test("worklist grows and clears from current source, dependency and checker observations", async context => { + const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-worklist-test-")); + context.after(() => fs.rm(workbench, {recursive: true, force: true})); + const root = path.join(workbench, "root"), provider = path.join(workbench, "resources/Base"); + await fs.mkdir(root); await fs.mkdir(provider, {recursive: true}); + await fs.mkdir(path.join(workbench, ".quixos/checks"), {recursive: true}); + const header = `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; }`; + for (const directory of [root, provider]) await execFile("jj", ["git", "init", "--colocate", directory]); + await fs.writeFile(path.join(provider, "interface.qx"), 'interface Base id "interface:base" revision "interface:base@1" {}'); + await fs.writeFile(path.join(provider, "quixos.lock"), `${header} }`); + const baseCommit = await snapshotCommit(provider); + await fs.writeFile(path.join(root, "workspace.qx"), `workspace Test id "workspace:test" revision "workspace:test@1" commit "${"b".repeat(40)}" { import interface Base; }`); + await fs.writeFile(path.join(root, "quixos.lock"), `${header} interface Base source { repository "https://example.test/base"; commit "${baseCommit}"; } }`); + const rootCommit = await snapshotCommit(root); + await fs.writeFile(path.join(workbench, ".quixos/resource-graph.json"), JSON.stringify({resources: [ + {kind: "interface", directory: provider, source: {resolver: "git", repository: "https://example.test/base", commit: baseCommit}}, + ]})); + assert.equal((await authoringWorklist(workbench)).worklist.filter(entry => entry.phase === "unchecked").length, 2); + const remember = (directory: string, commit: string, checker = checkerIdentity()) => fs.writeFile( + path.join(workbench, ".quixos/checks", checkRecordName(directory)), JSON.stringify({commit, checker, phase: "checked", blockers: []})); + await remember("root", rootCommit); await remember("resources/Base", baseCommit); + assert.deepEqual((await authoringWorklist(workbench)).worklist, []); + await fs.writeFile(path.join(provider, "interface.qx"), "interface broken {{{"); + const broken = await authoringWorklist(workbench); + assert.ok(broken.worklist.some(entry => entry.directory === "resources/Base" && entry.phase === "syntax" && /historical/.test(entry.message))); + assert.ok(broken.worklist.some(entry => entry.directory === "root" && entry.phase === "dependency")); + await fs.writeFile(path.join(provider, "interface.qx"), 'interface Base id "interface:base" revision "interface:base@1" {}'); + assert.deepEqual((await authoringWorklist(workbench)).worklist, []); + await remember("resources/Base", baseCommit, "old-checker"); + assert.ok((await authoringWorklist(workbench)).worklist.some(entry => /checker changed/.test(entry.message))); + await fs.writeFile(path.join(workbench, ".quixos/checks", checkRecordName("resources/Base")), JSON.stringify({phase: "publication", blockers: ["source retention unavailable"]})); + assert.ok((await authoringWorklist(workbench)).worklist.some(entry => entry.phase === "publication" && /source retention/.test(entry.message))); +}); diff --git a/test/capability-model.test.ts b/test/capability-model.test.ts index 2945c52..5dab7f0 100644 --- a/test/capability-model.test.ts +++ b/test/capability-model.test.ts @@ -208,6 +208,23 @@ test("related-object dependency views cannot traverse another conformance's priv assert.equal(validateWorkspaceRevision(workspace).some((entry) => entry.code === "private-attachment-access"), false, "Only an explicitly exported read-only traversal crosses ownership"); }); +test("public traversal permits a native inverse read without exporting mutation authority", () => { + const workspace = makeValidCapabilityWorkspace(); + const owned = conformance(workspace, fixtureId.projectOwnedConformance); + const index = owned.privateAttachments.findIndex(entry => entry.kind === "edge" && entry.id === fixtureId.projectOwner); + const edge = owned.privateAttachments.splice(index, 1)[0]; + assert.ok(edge?.kind === "edge"); + conformance(workspace, fixtureId.projectNamedConformance).privateAttachments.push(edge); + expectIssue(workspace, "private-attachment-access"); + edge.endpoints.find(endpoint => endpoint.projectionId === fixtureId.projectOwnerProjection)!.publicTraversal = true; + const readPath = `conformances[${workspace.conformances.indexOf(owned)}].operationBindings[0]`; + assert.equal(validateWorkspaceRevision(workspace).some(issue => issue.code === "private-attachment-access" && issue.path.startsWith(readPath)), false); + const binding = owned.operationBindings[0].binding; + assert.ok(binding.kind === "edge"); + binding.primitive = "connect"; + expectIssue(workspace, "private-attachment-access"); +}); + test("native state and edge providers must match operation shape", () => { const stateWorkspace = makeValidCapabilityWorkspace(); const state = conformance( diff --git a/test/file-lock.test.ts b/test/file-lock.test.ts new file mode 100644 index 0000000..937fbb2 --- /dev/null +++ b/test/file-lock.test.ts @@ -0,0 +1,33 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import {mkdtemp, rm} from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import {spawn} from "node:child_process"; +import {once} from "node:events"; +import {withFileLock} from "../src/capability-language/file-lock.js"; + +test("authoring lock excludes concurrent mutations and survives owner death", async context => { + 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"); + await withFileLock(filename, async () => { + await assert.rejects(withFileLock(filename, async () => assert.fail("concurrent mutation")), /Another authoring command/); + }); + 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(() => {});});`], + {stdio: ["ignore", "pipe", "pipe"]}); + context.after(() => owner.kill("SIGKILL")); + await once(owner.stdout, "data"); + 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; } + } + assert.equal(acquired, true); +}); diff --git a/test/git-resolver.test.ts b/test/git-resolver.test.ts new file mode 100644 index 0000000..6e6a96e --- /dev/null +++ b/test/git-resolver.test.ts @@ -0,0 +1,37 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { execFile as callback } from "node:child_process"; +import { promisify } from "node:util"; +import { createGitCapabilityResolver } from "../src/capability-language/git-resolver.js"; + +const execFile = promisify(callback); +test("dependency resolution is reusable across processes, concurrent and rejects modified checkouts", async context => { + const temporary = await mkdtemp(path.join(os.tmpdir(), "qx-resolver-test-")); + context.after(() => rm(temporary, { recursive: true, force: true })); + const origin = path.join(temporary, "origin"); + await mkdir(origin); + const git = (...args: string[]) => execFile("git", ["-C", origin, ...args]); + await git("init"); + await writeFile(path.join(origin, "interface.qx"), "contract"); + await git("add", "."); + await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "contract"); + const commit = (await git("rev-parse", "HEAD")).stdout.trim(); + await git("tag", `quixos-reachability/${commit}`); + const source = { resolver: "git" as const, repository: origin, commit }; + const options = { checkoutRoot: path.join(temporary, "cache") }; + const a = await createGitCapabilityResolver(options); + const b = await createGitCapabilityResolver(options); + const [first, second] = await Promise.all([a(source, "interface"), b(source, "interface")]); + assert.equal(first.directory, second.directory); + const c = await createGitCapabilityResolver(options); + assert.equal((await c(source, "interface")).directory, first.directory); + await writeFile(path.join(first.directory, "interface.qx"), "changed"); + const d = await createGitCapabilityResolver(options); + await assert.rejects(d(source, "interface"), /modified/); + // A failed request is not memoized forever; repair can be observed on retry. + await writeFile(path.join(first.directory, "interface.qx"), "contract"); + assert.equal((await d(source, "interface")).directory, first.directory); +}); diff --git a/test/nix-candidate.test.ts b/test/nix-candidate.test.ts new file mode 100644 index 0000000..035b5d0 --- /dev/null +++ b/test/nix-candidate.test.ts @@ -0,0 +1,34 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { execFile as callback } from "node:child_process"; +import { promisify } from "node:util"; +const execFile = promisify(callback); + +test("Nix checks a retained immutable source without a local overlay and reuses the result", {skip: !process.env.QX_CHECK_GENERATOR}, async context => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-nix-candidate-test-")); + context.after(() => fs.rm(root, {recursive: true, force: true})); + const git = (...args: string[]) => execFile("git", ["-C", root, ...args]); + await git("init"); + await fs.writeFile(path.join(root, "interface.qx"), 'interface Example id "interface:example" revision "interface:example@1" {}'); + await fs.writeFile(path.join(root, "quixos.lock"), `quixos-lock version 1 { quixos source { repository "https://example.test/quixos"; commit "${"a".repeat(40)}"; } }`); + await git("add", "."); + await git("-c", "user.name=Test", "-c", "user.email=test@example.test", "commit", "-m", "contract"); + const commit = (await git("rev-parse", "HEAD")).stdout.trim(); + await git("tag", `quixos-reachability/${commit}`); + const generator = process.env.QX_CHECK_GENERATOR!; + const repository = "https://immutable-candidate.example.test/contract.git"; + const env = {...process.env, GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: `url.file://${root}.insteadOf`, GIT_CONFIG_VALUE_0: repository}; + const build = async () => (await execFile("nix", ["build", "--impure", "--file", path.join(generator, "share/checked-candidate.nix"), + "--argstr", "repository", repository, "--argstr", "commit", commit, + "--argstr", "kind", "interface", "--argstr", "generator", generator, + "--option", "substitute", "false", "--no-link", "--print-out-paths"], {env, maxBuffer: 4 * 1024 * 1024})).stdout.trim(); + const output = await build(); + const candidate = JSON.parse(await fs.readFile(path.join(output, "candidate.json"), "utf8")); + assert.equal(candidate.revision.source.commit, commit); + await fs.writeFile(path.join(root, "interface.qx"), "broken draft"); + assert.equal(await build(), output); + assert.deepEqual(JSON.parse(await fs.readFile(path.join(output, "checks.json"), "utf8")), []); +}); diff --git a/test/pin-upgrades.test.ts b/test/pin-upgrades.test.ts index da493d2..d715bd8 100644 --- a/test/pin-upgrades.test.ts +++ b/test/pin-upgrades.test.ts @@ -8,13 +8,14 @@ import {execFile as callback} from "node:child_process"; import {planPinUpgrades, applyPinUpgrades, type UpgradeEffects} from "../src/capability-language/pin-upgrades.js"; const execFile = promisify(callback); -test("real jj snapshots and immutable Git publication propagate a changed interface into the root", async (context) => { +test("real jj snapshots and immutable Git publication propagate a changed interface into the root", {skip: !process.env.QX_CHECK_GENERATOR}, async (context) => { const workbench = await fs.mkdtemp(path.join(os.tmpdir(), "qx-real-upgrade-")); context.after(() => fs.rm(workbench, {recursive: true, force: true})); // Exercise the actual effects with local bare remotes, without external writes. const environment = { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: `url.file://${workbench}/remotes/.insteadOf`, GIT_CONFIG_VALUE_0: "https://upgrade.test/", QUIXOS_JJ_NO_CHECKPOINT: "1", + QUIXOS_CHECK_GENERATOR: process.env.QX_CHECK_GENERATOR!, }; const previous = Object.fromEntries(Object.keys(environment).map(key => [key, process.env[key]])); Object.assign(process.env, environment); diff --git a/test/scaffold-recipes.test.ts b/test/scaffold-recipes.test.ts index 10076cf..0b9ddaa 100644 --- a/test/scaffold-recipes.test.ts +++ b/test/scaffold-recipes.test.ts @@ -55,4 +55,8 @@ 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/); + 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" {}'}], + }]}), /scaffold-owned package identity/); }); diff --git a/test/structural-edits.test.ts b/test/structural-edits.test.ts index f6c2c32..4aef125 100644 --- a/test/structural-edits.test.ts +++ b/test/structural-edits.test.ts @@ -1,6 +1,17 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { editStructure, scaffoldResourceSource } from "../src/capability-language/structural-edits.js"; +import { editStructure, scaffoldResourceSource, instantiateWorkspaceIdentity } from "../src/capability-language/structural-edits.js"; + +test("template identity binding changes only the workspace header and is idempotent", () => { + const source = '// workspace fake id "do-not-touch"\nworkspace Todo id "workspace:todo" revision "workspace:todo@1" commit "' + "a".repeat(40) + '" { atom Task id "atom:task"; }'; + const id = "00000000-0000-0000-0000-000000000123"; + const bound = instantiateWorkspaceIdentity(source, id); + assert.match(bound, /atom Task id "atom:task"/); + assert.ok(bound.startsWith('// workspace fake id "do-not-touch"')); + assert.ok(bound.includes(`workspace Todo id "${id}"`)); + assert.equal(instantiateWorkspaceIdentity(bound, id), bound); + assert.throws(() => instantiateWorkspaceIdentity(source, "not-a-workspace"), /UUID/); +}); test("package scaffolding and function edits preserve surrounding source and use exact selectors", () => { const source = `// 🧭 resource comment\n${scaffoldResourceSource("package", "Chess", "package:chess", "package:chess@1")}`; @@ -13,6 +24,9 @@ test("package scaffolding and function edits preserve surrounding source and use assert.ok(!editStructure(replaced, {operation: "remove", target: {kind: "packageFunctionExport", id: "export:evaluate"}}).includes("evaluate")); assert.throws(() => editStructure(source, {operation: "remove", target: {kind: "packageResourceDecl", id: "package:chess@1"}}), /exactly once/); assert.throws(() => editStructure(source, {operation: "append", parent: {kind: "packageResourceDecl"}, source: "not valid QX"}), /Invalid structural/); + const prefixed = `import interface Board;\nexternal atom Game id "atom:game";\n${source}`; + const replacedPackage = editStructure(prefixed, {operation: "replace", target: {kind: "packageResourceDecl", id: "package:chess"}, source: scaffoldResourceSource("package", "Chess", "package:chess", "package:chess@2")}); + assert.ok(replacedPackage.startsWith('import interface Board;\nexternal atom Game id "atom:game";')); }); test("dependency scaffolding validates exact sources and preserves unrelated lock comments", () => { diff --git a/test/structural-plan.test.ts b/test/structural-plan.test.ts index 697b2b9..abfec34 100644 --- a/test/structural-plan.test.ts +++ b/test/structural-plan.test.ts @@ -34,4 +34,13 @@ test("structural plans validate the graph, journal originals, and reject stale e await fs.writeFile(applied.journalPath, JSON.stringify(journal)); assert.equal((await resumeStructure(root, applied.id)).phase, "complete"); await assert.rejects(() => planStructure(root, request), /Duplicate|duplicate/); + // Cross-repository edits may temporarily refer to an unfinished provider. + const provisional: StructuralRequest = {kind: "workspace", validation: "syntax", files: [{file: "workspace.qx", edits: [ + {operation: "import", kind: "interface", name: "NotImplementedYet"}, + ]}]}; + const draft = await planStructure(root, provisional); + assert.equal(draft.validation, "syntax"); + await applyStructure(draft); + assert.match(await fs.readFile(path.join(root, "workspace.qx"), "utf8"), /import interface NotImplementedYet/); + await assert.rejects(() => planStructure(root, {...provisional, validation: "resource-graph"})); }); From 8aac091f6cdc7aa340487b60565c080c13568d17 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Mon, 14 Sep 2026 15:24:37 -0700 Subject: [PATCH 3/3] Make workspace authoring transitions and typed invocation coherent --- flake.nix | 2 + proto/quixos/orch.proto | 14 +++ src/bindings/client.ts | 29 ++++++ src/capability-language/authoring-check.ts | 6 +- src/capability-language/authoring-inspect.ts | 7 +- src/capability-language/authoring-worklist.ts | 2 +- src/capability-language/file-lock.ts | 4 +- src/capability-language/scaffold-recipes.ts | 30 +++++++ src/capability-model/evolution.ts | 32 ++++++- src/gen/quixos/orch_pb.ts | 89 ++++++++++++++++--- test/evolution.test.ts | 1 + test/file-lock.test.ts | 13 +-- test/scaffold-recipes.test.ts | 6 ++ 13 files changed, 209 insertions(+), 26 deletions(-) create mode 100644 src/bindings/client.ts 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..ccc9112 100644 --- a/test/evolution.test.ts +++ b/test/evolution.test.ts @@ -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)); 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" {}'}],