Repair workspace authoring: typed RPC inputs, automatic preservation, and template releases
Use checked input contracts and protobuf JSON for CLI roundtrips; generate Web Studio platform inputs; reject invalid constructors before allocation and filter Createable eligibility. Preserve compatible storage without no-op migrations, report activation readiness, and validate migration coverage before maintenance. Follow authored package declarations during scaffold refresh and queue source capture. Add a real local TODO check/activate/create/place/edit acceptance, publish updated protocol and SDK dependencies, and make verified template default selection explicit.
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import type {InterfaceRevision, ValueType} from "../capability-model/types.js";
|
||||
|
||||
/** Host clients have no package receiver, but must use the same checked
|
||||
* interface signatures and argument framing as generated package ports. */
|
||||
export const generateClientContracts = (interfaces: InterfaceRevision[], messages: Record<string, string>) => {
|
||||
const type = (value: ValueType): string => {
|
||||
switch (value.kind) {
|
||||
case "builtin": return value.name === "unit" ? "undefined" : "string";
|
||||
case "scalar": return ({bool: "boolean", string: "string", bytes: "Uint8Array", int32: "number", uint32: "number", double: "number", int64: "bigint", uint64: "bigint"})[value.name];
|
||||
case "object-ref": return `{readonly $quixosRef: string}`;
|
||||
case "optional": return `(${type(value.value)} | null)`;
|
||||
case "list": return `Array<${type(value.value)}>`;
|
||||
case "record": return `{${Object.entries(value.fields).map(([name, field]) => `${JSON.stringify(name)}${field.kind === "optional" ? "?" : ""}: ${type(field)}`).join("; ")}}`;
|
||||
case "message": {
|
||||
const binding = messages[value.descriptorId];
|
||||
if (!binding) throw new Error(`Missing host message type ${value.descriptorId}`);
|
||||
return binding;
|
||||
}
|
||||
}
|
||||
};
|
||||
const operations = interfaces.flatMap(iface => iface.members.flatMap(member => member.operations
|
||||
.filter(operation => operation.mode === "call").map(operation => ({...operation, interfaceRevisionId: iface.revisionId}))));
|
||||
return `// Generated from checked QX interfaces. Regenerate with scripts/generate-platform-contracts.mjs.\n` +
|
||||
`export type PlatformInputs = {\n${operations.map(operation => ` ${JSON.stringify(operation.id)}: ${type(operation.inputType)};`).join("\n")}\n};\n` +
|
||||
`export const platformOperations = ${JSON.stringify(Object.fromEntries(operations.map(operation => [operation.id, {
|
||||
interfaceRevisionId: operation.interfaceRevisionId,
|
||||
input: operation.inputType.kind === "builtin" && operation.inputType.name === "unit" ? "unit" : ["record", "message"].includes(operation.inputType.kind) ? "fields" : "value",
|
||||
}])), null, 2)} as const;\n`;
|
||||
};
|
||||
@@ -16,7 +16,7 @@ export async function checkAuthoring(start: string, output: string, options: { b
|
||||
const resource = context.resources.find(entry => entry.directory === directory);
|
||||
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)); }
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<T>(filename: string, work: () => Promise<T>): Promise<T> {
|
||||
const child = spawn("flock", ["--exclusive", "--nonblock", "--conflict-exit-code", "75", filename,
|
||||
const child = spawn("flock", ["--exclusive", "--timeout", "120", "--conflict-exit-code", "75", filename,
|
||||
process.execPath, "-e", 'process.stdout.write("locked\\n"); process.stdin.resume();'], {stdio: ["pipe", "pipe", "pipe"]});
|
||||
let diagnostics = "";
|
||||
child.stdin.on("error", () => { /* acquisition/exit handling reports helper failure */ });
|
||||
@@ -13,7 +13,7 @@ export async function withFileLock<T>(filename: string, work: () => Promise<T>):
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
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();
|
||||
|
||||
@@ -91,6 +91,36 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio
|
||||
} else {
|
||||
registry = await ownedJson<Registry>(root, prefix + "quixos.scaffold.json");
|
||||
catalog = await ownedJson<typeof catalog>(root, prefix + "quixos.migrations.json");
|
||||
// package.qx is authoritative. The registry remembers implementation paths,
|
||||
// not a second declaration list that can erase an author's new exports.
|
||||
const authored = await fs.readFile(path.join(root, prefix, "package.qx"), "utf8");
|
||||
const syntax = parseQx(authored);
|
||||
if (syntax.diagnostics.length) throw new Error("Cannot refresh an invalid package.qx; fix the reported syntax first");
|
||||
const declaration = [...walkSyntax(syntax.root)].find(node => node.kind === "packageResourceDecl");
|
||||
if (!declaration) throw new Error("Expected a package declaration");
|
||||
const text = (node: typeof declaration) => authored.slice(node.start, node.end);
|
||||
const literals = declaration.children.filter(node => node.kind === "stringLiteral");
|
||||
registry.name = text(declaration.children.find(node => node.kind === "identifier")!);
|
||||
registry.id = JSON.parse(text(literals[0]));
|
||||
registry.revision = JSON.parse(text(literals[1]));
|
||||
const previousExports = registry.exports;
|
||||
registry.exports = [];
|
||||
for (const node of walkSyntax(declaration)) {
|
||||
if (!["packageFunctionExport", "packageOperationExport", "packageConstructorExport"].includes(node.kind)) continue;
|
||||
const name = safeName(text(node.children.find(child => child.kind === "identifier")!));
|
||||
const id = JSON.parse(text(node.children.find(child => child.kind === "stringLiteral")!));
|
||||
if (registry.exports.some(entry => entry.id === id || entry.name === name)) throw new Error("Duplicate package export name or ID");
|
||||
const old = previousExports.find(entry => entry.id === id);
|
||||
const file = old?.file ?? `src/impl/${name}.ts`;
|
||||
if (!old) {
|
||||
const exists = await fs.access(path.join(root, prefix, file)).then(() => true, () => false);
|
||||
if (!exists) {
|
||||
const derived = [...walkSyntax(node)].some(child => child.kind === "eventClause");
|
||||
create(file, `import type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation[${JSON.stringify(name)}] = ${derived ? '{kind: "derived", get: ' : ""}async (_context) => { throw new Error(${JSON.stringify(`Implement ${name}`)}); }${derived ? "}" : ""};\n`);
|
||||
}
|
||||
}
|
||||
registry.exports.push({...old, name, id, file});
|
||||
}
|
||||
if (command !== "refresh") {
|
||||
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");
|
||||
|
||||
@@ -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 = <T>(entries: readonly T[], key: (entry: T) => string) => [...entries].sort((a, b) => compareText(key(a), key(b)));
|
||||
export const conformanceIdentity = (entry: Conformance): string => entry.id ?? `legacy:${entry.atomId}:${entry.interfaceRevisionId}`;
|
||||
|
||||
export type StorageContract = { id: string; ownerId: string; kind: "state" | "edge"; digest: string; definition: unknown };
|
||||
/** Automatic evolution preserves values; it never interprets migration code or
|
||||
* guesses that a new nominal message descriptor means the same representation. */
|
||||
export const storageChangeRequiresMigration = (previous: StorageContract | undefined, next: StorageContract | undefined,
|
||||
oldAtomIds: ReadonlySet<string>): boolean => {
|
||||
if (!next) return true;
|
||||
const after = next.definition as PersistentAttachment;
|
||||
if (!previous) {
|
||||
if (after.kind === "state") return oldAtomIds.has(after.attachedTo) && after.defaultValue === undefined && after.valueType.kind !== "optional";
|
||||
return after.endpoints.some(endpoint => endpoint.cardinality === "exactly-one" &&
|
||||
(endpoint.constraint.kind !== "atom" || oldAtomIds.has(endpoint.constraint.atomId)));
|
||||
}
|
||||
if (previous.ownerId !== next.ownerId || previous.kind !== next.kind) return true;
|
||||
const before = previous.definition as PersistentAttachment;
|
||||
if (before.kind === "state" && after.kind === "state") {
|
||||
// Capture materializes old defaults, so changing a default affects only
|
||||
// newly constructed objects, not existing sparse state.
|
||||
const {defaultValue: _beforeDefault, ...beforeStorage} = before;
|
||||
const {defaultValue: _afterDefault, ...afterStorage} = after;
|
||||
return canonicalJson(beforeStorage) !== canonicalJson(afterStorage);
|
||||
}
|
||||
return canonicalJson(before) !== canonicalJson(after);
|
||||
};
|
||||
export const storageContracts = (workspace: WorkspaceRevision): StorageContract[] => {
|
||||
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<ReviewRequirement & { accepted: boolean }>;
|
||||
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 };
|
||||
};
|
||||
|
||||
+78
-11
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user