diff --git a/flake.nix b/flake.nix index 54ad388..0477270 100644 --- a/flake.nix +++ b/flake.nix @@ -52,6 +52,7 @@ --bundle --platform=node --target=node24 --format=esm \ --outfile=quixos-lock-check.mjs esbuild dist/src/bindings/cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-codegen-ts.mjs + esbuild dist/src/bindings/react-platform.js --bundle --platform=node --target=node24 --format=esm --outfile=react-platform.mjs esbuild dist/src/capability-language/tool-cli.js --bundle --platform=node --target=node24 --format=esm --outfile=quixos-qx.mjs runHook postBuild ''; @@ -105,6 +106,7 @@ 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 react-platform.mjs "$out/libexec/quixos-protocol/react-platform.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/src/bindings/cli.ts b/src/bindings/cli.ts index f3a0c01..21f34fc 100644 --- a/src/bindings/cli.ts +++ b/src/bindings/cli.ts @@ -1,13 +1,18 @@ #!/usr/bin/env node import { readFile, writeFile } from "node:fs/promises"; import { generateTypeScriptBindings } from "./index.js"; +import path from "node:path"; +import {reactPlatformTypes} from "./react-platform.js"; const main = async () => { const [schema, revision, output, options, ...rest] = process.argv.slice(2); if (!schema || !revision || !output || rest.length) throw new Error( "usage: quixos-codegen-ts SCHEMA.json PACKAGE_REVISION OUTPUT.ts [OPTIONS.json]"); - const generated = generateTypeScriptBindings(JSON.parse(await readFile(schema, "utf8")), revision, - options ? JSON.parse(await readFile(options, "utf8")) : {}); + const config = options ? JSON.parse(await readFile(options, "utf8")) : {}; + const generated = generateTypeScriptBindings(JSON.parse(await readFile(schema, "utf8")), revision, config); await writeFile(output, generated); + if (config.messages?.["org.quixos.web-studio.ReactProps"]) { + await writeFile(path.join(path.dirname(output), "web-studio-react-runtime.d.ts"), reactPlatformTypes); + } }; main().catch((error: unknown) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; }); diff --git a/src/bindings/react-platform.ts b/src/bindings/react-platform.ts new file mode 100644 index 0000000..0d3a997 --- /dev/null +++ b/src/bindings/react-platform.ts @@ -0,0 +1,62 @@ +/** One authoring contract, distributed by scaffolding and immutable codegen. + * Web Studio checks its concrete exports against this contract during its build. */ +export const reactPlatformTypes = `// Generated by the Quixos React platform contract. Do not edit. +declare module "@quixos/web-studio-react-runtime" { + import type * as React from "react"; + export function useComponentOverlayContainer(): HTMLElement; + export function useComponentStyleRoot(): ShadowRoot; + export type ObjectRef = string & { + readonly $quixosAtom: AtomId; + }; + export type LiveFieldProp = { + value: T; + source: { + objectId: string; + slotId: string; + valueType?: string; + storagePolicy?: string; + revision?: string | number | bigint; + crdtSnapshot?: { + type: string; + encoding: string; + payload: string; + }; + }; + }; + export type ReactComponentHostProps = { + onAction?: (action: Action) => void; + fallback?: React.ReactNode; + className?: string; + style?: React.CSSProperties; + onError?: (error: Error) => void; + }; + export type ReactComponentImplementationProps = { + camino: CaminoProps; + render: RenderProps; + dispatch: (action: Action) => void; + }; + export const createWebStudioComponent: < + ForObject extends ObjectRef, RenderProps extends object, Action, + >(config: { expectedAtomId: string }) => (props: { + forObject: ForObject; + } & RenderProps & ReactComponentHostProps) => any; + export const invokeCapability: ( + objectId: string, + interfaceRevisionId: string, + operationId: string, + value?: unknown, + options?: {clientMutationId?: string}, + ) => Promise; + export const h: typeof React.createElement; + export const useLiveField: ( + field: LiveFieldProp, + options?: { + reconcileRegister?: (state: { + confirmed: T; + optimistic: T; + pending: boolean; + }) => T; + }, + ) => readonly [T, (value: T) => Promise]; +} +`; diff --git a/src/capability-language/authoring-check.ts b/src/capability-language/authoring-check.ts index 656bab8..3dae757 100644 --- a/src/capability-language/authoring-check.ts +++ b/src/capability-language/authoring-check.ts @@ -22,6 +22,7 @@ export async function checkAuthoring(start: string, output: string, options: { b directory, checker: checkerIdentity(), candidateOnly: true, activationEvidence: false, blockers: [], phase: "convergence", output, }; try { + console.error(`[${new Date().toISOString()}] Check: capture source and converge dependencies`); // 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], { @@ -32,6 +33,7 @@ export async function checkAuthoring(start: string, output: string, options: { b }); const converged = JSON.parse(captured.stdout) as Awaited>; timings.captureMs = Math.round(performance.now() - started); + console.error(`[${new Date().toISOString()}] Check: source captured in ${(timings.captureMs / 1000).toFixed(1)}s`); 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")); @@ -39,8 +41,13 @@ export async function checkAuthoring(start: string, output: string, options: { b report.commit = converged.candidate.commit; report.phase = "verification"; const buildStarted = performance.now(); - report.artifactPath = await buildImmutableCandidate(converged.candidate, resource.kind, path.join(output, "nix.log"), options.contractOnly); - timings.immutableCheckMs = Math.round(performance.now() - buildStarted); + console.error(`[${new Date().toISOString()}] Check: immutable Nix ${options.contractOnly ? "contract" : "verification"}; build output: ${path.join(output, "nix.log")}`); + try { + report.artifactPath = await buildImmutableCandidate(converged.candidate, resource.kind, path.join(output, "nix.log"), options.contractOnly); + } finally { + timings.immutableCheckMs = Math.round(performance.now() - buildStarted); + console.error(`[${new Date().toISOString()}] Check: immutable phase ended after ${(timings.immutableCheckMs / 1000).toFixed(1)}s`); + } const candidateText = await fs.readFile(path.join(report.artifactPath, "candidate.json"), "utf8"); await fs.writeFile(path.join(output, "candidate.json"), candidateText); report.compilation = "passed"; diff --git a/src/capability-language/scaffold-recipes.ts b/src/capability-language/scaffold-recipes.ts index 057f3d3..8f8e0e9 100644 --- a/src/capability-language/scaffold-recipes.ts +++ b/src/capability-language/scaffold-recipes.ts @@ -6,6 +6,7 @@ import {formatQuixosLock, type GitSource} from "../resource-lock/index.js"; import {parseQx, walkSyntax} from "./source.js"; import type {StructuralRequest} from "./structural-plan.js"; import {addImplementation} from "./implementation-edit.js"; +import {reactPlatformTypes} from "../bindings/react-platform.js"; type Source = {repository: string; commit: string}; type PackageEditModel = {generatedBy: "qx-scaffold-v1"; name: string; id: string; revision: string; exports: {name: string; id: string; file: string; migration?: boolean}[]}; @@ -69,6 +70,7 @@ export const scaffoldRecipe = async (root: string, command: "package" | "functio 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 componentSource from "../component.js?browser-source";\nimport type {Implementation} from "../gen/qx.js";\nexport const handler: Implementation["sourceGet"] = () => componentSource;\n`); create("src/browser-assets.d.ts", `declare module "*?browser-source" { const source: string; export default source; }\n`); + create("src/gen/web-studio-react-runtime.d.ts", reactPlatformTypes); } 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`); diff --git a/test/scaffold-recipes.test.ts b/test/scaffold-recipes.test.ts index 9ffc174..6b9605a 100644 --- a/test/scaffold-recipes.test.ts +++ b/test/scaffold-recipes.test.ts @@ -9,6 +9,7 @@ import {scaffoldRecipe} from "../src/capability-language/scaffold-recipes.js"; import {planStructure, applyStructure} from "../src/capability-language/structural-plan.js"; import {contentDigest} from "../src/capability-model/evolution.js"; import {sealMigrations} from "../src/capability-language/migration-seal.js"; +import {reactPlatformTypes} from "../src/bindings/react-platform.js"; const execFile = promisify(callback); test("React preset applies its browser build script and shared-platform imports", async (context) => { @@ -20,6 +21,7 @@ test("React preset applies its browser build script and shared-platform imports" await applyStructure(await planStructure(root, request)); assert.match(await fs.readFile(path.join(root, "src/impl/sourceGet.ts"), "utf8"), /component.js\?browser-source/); assert.match(await fs.readFile(path.join(root, "flake.nix"), "utf8"), /browserSources = true/); + assert.equal(await fs.readFile(path.join(root, "src/gen/web-studio-react-runtime.d.ts"), "utf8"), reactPlatformTypes); assert.equal(JSON.parse(await fs.readFile(path.join(root, "quixos.check.json"), "utf8")).options.messages["org.quixos.web-studio.ReactProps"].export, "opaqueReactPropsBinding"); await assert.rejects(fs.access(path.join(root, "quixos.scaffold.json"))); assert.equal(JSON.parse(await fs.readFile(path.join(root, "tsconfig.json"), "utf8")).compilerOptions.jsx, "react-jsx");