483bc68a94
Enable evolution by default for source-backed workspaces. Add stable conformance ownership, semantic-major review, candidate typechecking, and durable fenced cutover with explicit migrations and forward recovery. Independently supervise package runtimes so unchanged resource owners keep their processes and connections across cutover. Add scoped invocation authority, resource sessions, and typed callback rebinding. Wire opaque object references through generated bindings and RPCs. Add canonical relationship sets, keyed maps, and ordered lists with scoped transactional mutations, revision checks, and inverse consistency. Support planned cascade deletion, protection, tombstones, and lifecycle foundations. Add journaled structural edits, package/function/migration scaffolding, managed repository creation, and resumable bottom-up dependency pin publication. Document lifetime boundaries, revision pinning, prototype compatibility policy, commands, and deferred work. Validate with 210 tests, user-systemd process/connection continuity, generated-package TypeScript checks, and Nix host/protocol checks. TTL handoff, physical reclamation, general multi-step migrations, and root-systemd migration isolation acceptance remain deferred.
139 lines
12 KiB
TypeScript
139 lines
12 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import {contentDigest} from "../capability-model/evolution.js";
|
|
import {validateMigrationCatalog, type MigrationCatalog, type MigrationDeclaration} from "../capability-model/migrations.js";
|
|
import {formatQuixosLock, type GitSource} from "../resource-lock/index.js";
|
|
import {parseQx, walkSyntax} from "./source.js";
|
|
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 = {
|
|
source: Source; directory?: string; name?: string; id?: string; revision?: string;
|
|
declaration?: string;
|
|
tools?: {quixos: Source; protocol: Source; helpers: Source; sdk: Source};
|
|
nixifyPluginUrl?: string;
|
|
migration?: Omit<MigrationDeclaration, "implementation"> & {contracts: Record<string, unknown>};
|
|
};
|
|
const marker = "// Generated by qx-scaffold-v1\n";
|
|
const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`;
|
|
const source = (value: Source): GitSource => {
|
|
const url = new URL(value.repository);
|
|
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(value.commit)) throw new Error("Scaffolds require credential-free HTTPS sources and full exact commits");
|
|
return {resolver: "git", ...value};
|
|
};
|
|
const nixSource = (value: Source) => `git+${source(value).repository}?ref=refs/tags/quixos-reachability/${value.commit}&rev=${value.commit}`;
|
|
const nixString = (value: string) => JSON.stringify(value).replaceAll("${", "\\${");
|
|
const safeName = (name: string | undefined): string => {
|
|
if (!name || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error("Scaffold requires a simple authored name");
|
|
return name;
|
|
};
|
|
const ownedJson = async <T>(root: string, file: string): Promise<T> => {
|
|
const target = path.join(root, file);
|
|
if (!(await fs.realpath(target)).startsWith(`${await fs.realpath(root)}/`)) throw new Error("Scaffold input escapes repository");
|
|
const value = JSON.parse(await fs.readFile(target, "utf8"));
|
|
if (value.generatedBy !== "qx-scaffold-v1") throw new Error(`Not scaffold-owned: ${file}`);
|
|
return value;
|
|
};
|
|
|
|
/** Recipes describe structural edits; planStructure owns validation/journaling.
|
|
* Implementation files are created once and never rewritten by refresh. */
|
|
export const scaffoldRecipe = async (root: string, command: "package" | "function" | "migration" | "refresh", spec: ScaffoldRecipe): Promise<StructuralRequest> => {
|
|
source(spec.source);
|
|
if (spec.directory && !/^[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$/.test(spec.directory)) throw new Error("Scaffold directory must be contained");
|
|
const prefix = spec.directory ? `${spec.directory}/` : "";
|
|
const files: StructuralRequest["files"] = [];
|
|
const create = (file: string, content: string) => files.push({file: prefix + file, create: content});
|
|
const generated = (file: string, content: string) => files.push({file: prefix + file, generated: content});
|
|
let registry: Registry;
|
|
let catalog: MigrationCatalog & {generatedBy: "qx-scaffold-v1"};
|
|
if (command === "package") {
|
|
const name = safeName(spec.name);
|
|
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`);
|
|
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"]}));
|
|
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("flake.nix", `{
|
|
inputs.protocol.url = ${nixString(nixSource(spec.tools.protocol))};
|
|
inputs.nixpkgs.follows = "protocol/nixpkgs";
|
|
inputs.flake-utils.follows = "protocol/flake-utils";
|
|
inputs.helpers = { url = ${nixString(nixSource(spec.tools.helpers))}; flake = false; };
|
|
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"; };
|
|
};
|
|
}\n`);
|
|
generated("quixos.resources.json", json({generatedBy: "qx-scaffold-v1", resources: []}));
|
|
} else {
|
|
registry = await ownedJson<Registry>(root, prefix + "quixos.scaffold.json");
|
|
catalog = await ownedJson<typeof catalog>(root, prefix + "quixos.migrations.json");
|
|
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");
|
|
const declaration = spec.declaration ?? `function ${name} id ${JSON.stringify(spec.id)} : unit -> unit;`;
|
|
const parsed = parseQx(`package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`);
|
|
const exports = [...walkSyntax(parsed.root)].filter((node) => ["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind));
|
|
if (parsed.diagnostics.length || exports.length !== 1) throw new Error("Expected one valid package export declaration");
|
|
const wrapped = `package Scaffold id "scaffold" revision "scaffold@1" { ${declaration} }`;
|
|
const node = exports[0];
|
|
const derived = [...walkSyntax(node)].some((entry) => entry.kind === "eventClause");
|
|
if (command === "migration" && (node.kind !== "packageFunctionExport" || spec.declaration)) throw new Error("Migration exports use the scaffold's unit function declaration and dedicated migration entrypoint");
|
|
if (wrapped.slice(node.children.find((child) => child.kind === "identifier")!.start, node.children.find((child) => child.kind === "identifier")!.end) !== name
|
|
|| JSON.parse(wrapped.slice(node.children.find((child) => child.kind === "stringLiteral")!.start, node.children.find((child) => child.kind === "stringLiteral")!.end)) !== spec.id) throw new Error("Declaration name/ID must match its registration");
|
|
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<void> => { 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`;
|
|
create(file, implementation);
|
|
registry.exports.push({name, id: spec.id, file, ...(command === "migration" ? {migration: true} : {})});
|
|
if (command === "migration") {
|
|
if (!spec.migration) throw new Error("Migration scaffold requires retained contracts and an explicit transition");
|
|
const {contracts, ...transition} = spec.migration;
|
|
for (const [digest, contract] of Object.entries(contracts)) {
|
|
if (contentDigest(contract) !== digest || (catalog.contracts[digest] && contentDigest(catalog.contracts[digest]) !== digest)) throw new Error("Retained migration contract mismatch");
|
|
catalog.contracts[digest] = contract;
|
|
}
|
|
catalog.migrations.push({...transition, implementation: {exportId: spec.id, file, digest: contentDigest(implementation)}});
|
|
}
|
|
}
|
|
if (command === "refresh") for (const migration of catalog.migrations) {
|
|
const file = path.join(root, prefix, migration.implementation.file);
|
|
if (!(await fs.realpath(file)).startsWith(`${await fs.realpath(path.join(root, prefix))}/`)) throw new Error("Migration implementation escapes package");
|
|
migration.implementation.digest = contentDigest(await fs.readFile(file, "utf8"));
|
|
}
|
|
}
|
|
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("") +
|
|
`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`);
|
|
generated("descriptor.quixos-package.txtpb", `# Generated by qx-scaffold-v1\npackage_id: ${JSON.stringify(registry.id)}\npackage_revision_id: ${JSON.stringify(registry.revision)}\nruntime_protocol_version: "quixos-capabilities-v1"\n` + registry.exports.map((entry) => `exports: { export_id: ${JSON.stringify(entry.id)} runtime_symbol: ${JSON.stringify(entry.name)} }\n`).join(""));
|
|
return {kind: "package", source: spec.source, resourceRoot: spec.directory, files};
|
|
};
|