From a9e2d39451e07f613de310d286266a574640d40e Mon Sep 17 00:00:00 2001 From: Mike Long Date: Sat, 28 Mar 2026 19:05:17 -0700 Subject: [PATCH 1/7] test: add UI AST v2 consumer proof --- ...nchmark-async-data-web.ui.surface.ast.json | 226 ++++++++++++++++++ docs/plans/ui-ast-v2-cutover-merge-note.md | 90 +++++++ scripts/__tests__/generation-adapter.test.mjs | 201 +++++++++++++++- 3 files changed, 515 insertions(+), 2 deletions(-) create mode 100644 contracts/generated/benchmark-async-data-web.ui.surface.ast.json create mode 100644 docs/plans/ui-ast-v2-cutover-merge-note.md diff --git a/contracts/generated/benchmark-async-data-web.ui.surface.ast.json b/contracts/generated/benchmark-async-data-web.ui.surface.ast.json new file mode 100644 index 0000000..73dd5a3 --- /dev/null +++ b/contracts/generated/benchmark-async-data-web.ui.surface.ast.json @@ -0,0 +1,226 @@ +{ + "$schema": "https://contracts.surfaces.local/ui.surface.ast.schema.json", + "astId": "benchmark-async-data-web.generated", + "color": { + "allowedValues": [ + "rgba(255, 255, 255, 0.92)", + "var(--accent)", + "var(--bg)", + "var(--muted)", + "var(--surface-alt)", + "var(--surface)", + "var(--text)", + "white" + ], + "policy": "warn" + }, + "constraints": { + "motion": { + "allowedDurationsMs": [ + 180, + 220 + ], + "allowedTimingFunctions": [ + "ease" + ] + } + }, + "description": "Extracted from Next.js app (Phase 0).", + "migration": { + "escalations": [], + "sourceFormat": "web.surface.contract@1" + }, + "surfaces": [ + { + "displayName": "Benchmark Async Data Web", + "icons": { + "allowedSources": [], + "policy": "warn" + }, + "id": "benchmark-async-data-web", + "kind": "application", + "nodes": [ + { + "children": [ + "data.hero", + "data.stream", + "data.queue", + "data.health" + ], + "description": "Root group for Benchmark Async Data Web.", + "id": "benchmark-async-data-web.root", + "kind": "group", + "label": "Benchmark Async Data Web" + }, + { + "description": "Server-rendered hero summary for the async dashboard.", + "id": "data.hero", + "intent": "hero", + "kind": "section", + "label": "hero", + "sectionId": "data.hero" + }, + { + "description": "Primary async stream lane for dashboard events.", + "id": "data.stream", + "intent": "stream", + "kind": "section", + "label": "stream", + "sectionId": "data.stream" + }, + { + "description": "Queued review lane for async dashboard data.", + "id": "data.queue", + "intent": "queue", + "kind": "section", + "label": "queue", + "sectionId": "data.queue" + }, + { + "description": "Health counters panel for the async dashboard.", + "id": "data.health", + "intent": "health", + "kind": "section", + "label": "health", + "sectionId": "data.health" + } + ], + "phase0": { + "authPosture": "public", + "requiresShell": true + }, + "platforms": [ + { + "allowedFonts": [ + "Inter", + "sans-serif", + "var(--font-inter)" + ], + "layout": { + "chromePolicy": { + "allowInsetShadow": false, + "allowOuterShadow": false, + "maxBorderRadiusPx": 0, + "policy": "off", + "targets": [ + "layout-container", + "page-container", + "top-level-section" + ] + }, + "maxContentWidth": 1180, + "requiredContainers": [ + "page-container" + ] + }, + "platform": "web" + } + ], + "rootNodeId": "benchmark-async-data-web.root", + "runtime": { + "contexts": [ + { + "blockedActionsWhilePending": [ + "submit-refresh" + ], + "id": "loading", + "kind": "loading", + "when": "request == pending" + }, + { + "id": "empty", + "kind": "empty", + "when": "items.length == 0" + }, + { + "id": "error", + "kind": "error", + "preserveLastGoodContent": true, + "preserveSections": [ + "data.hero", + "data.stream", + "data.queue", + "data.health" + ], + "requiredRecoveryActions": [ + "retry" + ], + "when": "request == failed" + }, + { + "id": "success", + "kind": "success", + "when": "request == fulfilled" + } + ], + "feedbackRecovery": { + "policy": "warn", + "requiredStateKinds": [ + "loading", + "empty", + "error", + "success" + ] + }, + "policy": "warn" + }, + "states": [ + { + "id": "loading", + "kind": "loading" + }, + { + "id": "empty", + "kind": "empty" + }, + { + "id": "error", + "kind": "error" + }, + { + "id": "success", + "kind": "success" + } + ] + } + ], + "tokens": { + "layout": { + "allowedTokens": [ + "var(--page-width)" + ], + "policy": "warn", + "tokenMetadata": [ + { + "aliases": [], + "attributes": [ + "max-width" + ], + "normalizedValue": "1180px", + "token": "var(--page-width)" + } + ] + }, + "motion": { + "allowedTokens": [], + "policy": "warn" + }, + "typography": { + "allowedTokens": [ + "var(--font-inter)" + ], + "policy": "warn", + "tokenMetadata": [ + { + "aliases": [], + "attributes": [ + "font-family" + ], + "normalizedValue": "var(--font-inter)", + "token": "var(--font-inter)" + } + ] + } + }, + "version": "0.1.0" +} diff --git a/docs/plans/ui-ast-v2-cutover-merge-note.md b/docs/plans/ui-ast-v2-cutover-merge-note.md new file mode 100644 index 0000000..e4b9d6d --- /dev/null +++ b/docs/plans/ui-ast-v2-cutover-merge-note.md @@ -0,0 +1,90 @@ +# UI AST V2 Merge Note + +**Scope:** `surfaces-webapps` consumer proof only. This repo does not define AST semantics; it proves compatibility with the canonical `interfacectl` cutover. + +**Merge order:** + +1. `interfacectl` +2. `surfaces-webapps` + +--- + +## A) interfacectl dependency + +Pin the dependency in the `surfaces-webapps` PR body to: + +- **Branch:** `codex/ui-ast-v2-cutover` +- **Current worktree base SHA:** `b83ec779711613e39127a00e0d42052b39d84d9d` + +If the interfacectl PR head changes before opening the `surfaces-webapps` PR, replace the SHA with the final interfacectl PR head SHA. The merge order stays the same. + +--- + +## B) Scope in this repo + +This PR should include only: + +- `contracts/generated/benchmark-async-data-web.ui.surface.ast.json` +- resolver-backed consumer smoke coverage proving: + - AST freshness relative to the legacy benchmark contract + - `validate --ast` against the live benchmark app + - `compile --ast` + - `prepare-generation` + - `prepare-runtime` + +Do not generalize benchmark fixtures, local-workbench service inputs, or repo-wide scripts to AST-native inputs in this pass. + +--- + +## C) Accepted benchmark warning posture + +The benchmark proof is acceptable for this cutover at: + +- **0 validation errors** +- existing warn-level findings remain documented and non-blocking + +Current warn-level findings observed during validation: + +- `feedback.state-missing` for required async state `empty` +- `feedback.state-missing` for required async state `success` +- `feedback.last-good-content-missing` for the `error` context preserve-section requirement + +These warnings are intentionally not fixed in this cutover. If a reviewer blocks on the proof surface warning posture, treat that as a follow-up fixture-cleanup task, not part of the AST cutover itself. + +--- + +## D) Verification + +Run with the feature `interfacectl` worktree wired explicitly: + +```bash +export SURFACES_INTERFACECTL_ROOT=/Users/mike/SurfacesPlatform/interfacectl/.worktrees/ui-ast-v2-cutover +node --test scripts/__tests__/generation-adapter.test.mjs +``` + +Expected result: + +- existing consumer smoke passes +- benchmark AST freshness passes +- AST-derived compile/prepare-generation/prepare-runtime proof passes + +--- + +## E) Suggested PR body + +```markdown +## UI AST V2 consumer proof + +### Dependency +- Depends on `interfacectl` branch `codex/ui-ast-v2-cutover` +- Pin to interfacectl SHA `b83ec779711613e39127a00e0d42052b39d84d9d` (update if the interfacectl PR head changes before merge) +- Merge order is fixed: `interfacectl` first, `surfaces-webapps` second + +### What changed +- Added committed AST draft for `benchmark-async-data-web` +- Added resolver-backed consumer proof for AST freshness, validate, compile, prepare-generation, and prepare-runtime + +### Warning posture +- Benchmark proof remains acceptable at `0` errors +- Existing warn-level async feedback findings are documented and intentionally left out of scope for this cutover +``` diff --git a/scripts/__tests__/generation-adapter.test.mjs b/scripts/__tests__/generation-adapter.test.mjs index ef73627..ffe378a 100644 --- a/scripts/__tests__/generation-adapter.test.mjs +++ b/scripts/__tests__/generation-adapter.test.mjs @@ -14,8 +14,21 @@ const repoRoot = path.resolve( ); const packageJsonPath = path.join(repoRoot, "package.json"); const interfacectlRoot = + process.env.SURFACES_INTERFACECTL_ROOT ?? findSiblingInterfacectlRepoRoot({ cwd: repoRoot }) ?? path.resolve(repoRoot, "..", "interfacectl"); +const benchmarkAsyncDataContractPath = path.join( + repoRoot, + "contracts", + "generated", + "benchmark-async-data-web.contract.json", +); +const benchmarkAsyncDataAstPath = path.join( + repoRoot, + "contracts", + "generated", + "benchmark-async-data-web.ui.surface.ast.json", +); function runCommand(command, args, options = {}) { const child = spawn(command, args, { @@ -201,8 +214,9 @@ test("consumer smoke: compile, prepare, init session, and validate via direct up assert.equal(prepareResult.exitCode, 0, prepareResult.stderr); assert.doesNotMatch(prepareResult.stdout, /"surfaceId"\s*:/); const generationInput = JSON.parse(await fsp.readFile(generationInputPath, "utf8")); - assert.equal(generationInput.bundle.version, "2.0"); + assert.equal(generationInput.bundle.version, "3.0"); assert.equal(generationInput.contract.id, "consumer-smoke"); + assert.equal(generationInput.ast.id, "consumer-smoke"); assert.equal(generationInput.surface.surfaceId, "demo-surface"); const initSessionResult = await runCommand( @@ -260,7 +274,7 @@ test("consumer smoke: compile, prepare, init session, and validate via direct up assert.equal(validateResult.exitCode, 0, validateResult.stderr); const payload = parseTrailingJson(validateResult.stdout); assert.equal(payload.status, "pass"); - assert.equal(payload.bundle.version, "2.0"); + assert.equal(payload.bundle.version, "3.0"); assert.equal(payload.contract.id, "consumer-smoke"); } finally { await fsp.rm(tempDir, { recursive: true, force: true }); @@ -269,3 +283,186 @@ test("consumer smoke: compile, prepare, init session, and validate via direct up await fsp.rm(sessionDir, { recursive: true, force: true }); } }); + +test("consumer smoke: benchmark async data AST stays fresh and drives bundle preparation", async () => { + assert.ok( + fs.existsSync(interfacectlRoot), + `Standalone interfacectl repo not found at ${interfacectlRoot}.`, + ); + assert.ok( + fs.existsSync(benchmarkAsyncDataContractPath), + `Benchmark contract fixture missing at ${benchmarkAsyncDataContractPath}.`, + ); + assert.ok( + fs.existsSync(benchmarkAsyncDataAstPath), + `Benchmark AST fixture missing at ${benchmarkAsyncDataAstPath}.`, + ); + + const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "surfaces-ast-consumer-")); + const migratedAstPath = path.join(tempDir, "benchmark-async-data-web.ui.surface.ast.json"); + const configPath = path.join(tempDir, "benchmark-async-data-web.config.json"); + const bundleRoot = path.join(repoRoot, "artifacts", "generation-bundles", `benchmark-async-data-web-${Date.now()}`); + const preparedGenerationPath = path.join(tempDir, "prepared-generation.json"); + const preparedRuntimePath = path.join(tempDir, "prepared-runtime.json"); + + try { + await writeJson(configPath, { + surfaceRoots: { + "benchmark-async-data-web": "apps/benchmark-async-data-web", + }, + }); + + const migrateResult = await runCommand( + "node", + [ + path.join(interfacectlRoot, "packages", "interfacectl-cli", "dist", "index.js"), + "migrate-ui-ast", + "--contract", + benchmarkAsyncDataContractPath, + "--out", + migratedAstPath, + "--format", + "json", + ], + { + cwd: repoRoot, + }, + ); + assert.equal(migrateResult.exitCode, 0, migrateResult.stderr); + + const committedAst = JSON.parse(await fsp.readFile(benchmarkAsyncDataAstPath, "utf8")); + const migratedAst = JSON.parse(await fsp.readFile(migratedAstPath, "utf8")); + assert.deepEqual( + committedAst, + migratedAst, + "Committed AST fixture must stay fresh relative to the migrated legacy contract", + ); + + const validateResult = await runCommand( + "node", + [ + "scripts/interfacectl-tool.mjs", + "run", + "cli", + "validate", + "--workspace-root", + ".", + "--ast", + benchmarkAsyncDataAstPath, + "--config", + configPath, + "--surface", + "benchmark-async-data-web", + "--format", + "json", + "--exit-codes", + "v2", + ], + { + cwd: repoRoot, + env: { + SURFACES_INTERFACECTL_ROOT: interfacectlRoot, + }, + }, + ); + assert.equal(validateResult.exitCode, 0, validateResult.stderr); + const validationPayload = JSON.parse(validateResult.stdout); + // This cutover only requires the benchmark proof to stay at zero errors. + // Existing warn-level async feedback findings are documented in the merge note + // and intentionally remain out of scope for this pass. + assert.equal(validationPayload.summary.errors, 0); + assert.ok( + validationPayload.findings.every((finding) => finding.surface === "benchmark-async-data-web"), + "validation findings should stay scoped to the benchmark fixture", + ); + + const compileResult = await runCommand( + "node", + [ + "scripts/interfacectl-tool.mjs", + "run", + "cli", + "compile", + "--ast", + benchmarkAsyncDataAstPath, + "--out", + bundleRoot, + ], + { + cwd: repoRoot, + env: { + SURFACES_INTERFACECTL_ROOT: interfacectlRoot, + }, + }, + ); + assert.equal(compileResult.exitCode, 0, compileResult.stderr); + + const prepareGenerationResult = await runCommand( + "node", + [ + "scripts/interfacectl-tool.mjs", + "run", + "cli", + "prepare-generation", + "--bundle-root", + bundleRoot, + "--surface", + "benchmark-async-data-web", + "--out", + preparedGenerationPath, + ], + { + cwd: repoRoot, + env: { + SURFACES_INTERFACECTL_ROOT: interfacectlRoot, + }, + }, + ); + assert.equal(prepareGenerationResult.exitCode, 0, prepareGenerationResult.stderr); + + const prepareRuntimeResult = await runCommand( + "node", + [ + "scripts/interfacectl-tool.mjs", + "run", + "cli", + "prepare-runtime", + "--bundle-root", + bundleRoot, + "--surface", + "benchmark-async-data-web", + "--out", + preparedRuntimePath, + ], + { + cwd: repoRoot, + env: { + SURFACES_INTERFACECTL_ROOT: interfacectlRoot, + }, + }, + ); + assert.equal(prepareRuntimeResult.exitCode, 0, prepareRuntimeResult.stderr); + + const generationPayload = JSON.parse(await fsp.readFile(preparedGenerationPath, "utf8")); + const runtimePayload = JSON.parse(await fsp.readFile(preparedRuntimePath, "utf8")); + + assert.equal(generationPayload.bundle.version, "3.0"); + assert.equal(generationPayload.ast.id, "benchmark-async-data-web.generated"); + assert.equal(generationPayload.surface.surfaceId, "benchmark-async-data-web"); + assert.equal(generationPayload.generation.ast.rootNodeId, "benchmark-async-data-web.root"); + assert.match(generationPayload.bundle.sourcePaths.contract, /derived\/contract\.normalized\.json$/); + + assert.equal(runtimePayload.bundle.version, "3.0"); + assert.equal(runtimePayload.ast.id, "benchmark-async-data-web.generated"); + assert.equal(runtimePayload.runtime.ast.rootNodeId, "benchmark-async-data-web.root"); + assert.deepEqual( + runtimePayload.runtime.ast.nodes + .filter((node) => node.kind === "section") + .map((node) => node.sectionId), + ["data.hero", "data.stream", "data.queue", "data.health"], + ); + } finally { + await fsp.rm(tempDir, { recursive: true, force: true }); + await fsp.rm(bundleRoot, { recursive: true, force: true }); + } +}); From be469c5b8be2157d0a6312fe0d527c31d1f4846b Mon Sep 17 00:00:00 2001 From: Mike Long Date: Sat, 28 Mar 2026 21:38:02 -0700 Subject: [PATCH 2/7] feat: migrate workbench to ui ast artifacts --- .../_components/local-workbench.tsx | 71 +- .../lib/generation-benchmarks.ts | 4 +- apps/surfaceops-web/lib/interfacectl.ts | 67 +- .../lib/local-workbench/benchmark-fixtures.ts | 7 +- .../lib/local-workbench/local-llm.ts | 182 ++--- .../lib/local-workbench/service.ts | 707 ++++++++++-------- .../lib/local-workbench/types.ts | 70 +- .../tests/generation-benchmarks.test.ts | 4 +- apps/surfaceops-web/tests/local-llm.test.ts | 52 +- .../local-workbench-browser-smoke.test.mjs | 31 +- .../tests/local-workbench-routes.test.ts | 4 +- .../tests/local-workbench-service.test.ts | 405 +++++++--- .../tests/local-workbench.test.tsx | 40 +- .../reference-target-web.ui.surface.ast.json | 185 +++++ scripts/__tests__/generation-adapter.test.mjs | 88 ++- 15 files changed, 1297 insertions(+), 620 deletions(-) create mode 100644 contracts/generated/reference-target-web.ui.surface.ast.json diff --git a/apps/surfaceops-web/app/contracts/governance/_components/local-workbench.tsx b/apps/surfaceops-web/app/contracts/governance/_components/local-workbench.tsx index ca3367d..a30ebe5 100644 --- a/apps/surfaceops-web/app/contracts/governance/_components/local-workbench.tsx +++ b/apps/surfaceops-web/app/contracts/governance/_components/local-workbench.tsx @@ -213,7 +213,7 @@ function deriveLatestResultState({ tone: "neutral", label: "Ready", title: "No rerun yet", - summary: "Review the surface and contract, then trigger the first regeneration when you are ready to test the flow.", + summary: "Review the surface and UI AST, then trigger the first regeneration when you are ready to test the flow.", detail: null, }; } @@ -245,13 +245,13 @@ function nextActionCopy({ acceptedSuggestionCount: number; }) { if (!modelConfigured) { - return "Refine the contract in chat now, then configure the local model before attempting another regeneration."; + return "Refine the UI AST in chat now, then configure the local model before attempting another regeneration."; } if (openSuggestionCount > 0) { return `There ${openSuggestionCount === 1 ? "is" : "are"} ${openSuggestionCount} open suggestion${openSuggestionCount === 1 ? "" : "s"}. Review them below, accept the ones that should steer the next pass, then regenerate.`; } if (latestRun?.status === "failed") { - return "Review the latest failure, then try another regeneration or switch to chat if the contract needs more guidance first."; + return "Review the latest failure, then try another regeneration or switch to chat if the UI AST needs more guidance first."; } if (latestRunOperatorNote) { return "The last pass made progress but still needs another targeted patch. Regenerate again after reviewing the evidence."; @@ -260,7 +260,7 @@ function nextActionCopy({ return "Accepted guidance is active. Regenerate when you want to test the next implementation pass against those decisions."; } if (!latestRun) { - return "Trigger the first regeneration when you are ready to evaluate the surface against the current contract."; + return "Trigger the first regeneration when you are ready to evaluate the surface against the current UI AST."; } return "If the latest result is close, refine in chat. If you need another implementation pass, regenerate."; } @@ -363,7 +363,7 @@ export function LocalWorkbench({ const showImplementationDebug = Boolean( data.latestRunAgentInput.path || data.latestRunAgentInput.content - || latestRun?.effectiveContractPath + || latestRun?.effectiveAstPath || latestRun?.explainabilityPath, ); @@ -486,7 +486,7 @@ export function LocalWorkbench({ throw new Error(json.error ?? "Contract chat update failed."); } setChatDraft(""); - setFeedback("Working contract updated. Review the latest result and regenerate when you want to test the change."); + setFeedback("Working UI AST updated. Review the latest result and regenerate when you want to test the change."); router.refresh(); } @@ -498,9 +498,9 @@ export function LocalWorkbench({ }); const json = await response.json(); if (!response.ok || !json.ok) { - throw new Error(json.error ?? "Local contract publish failed."); + throw new Error(json.error ?? "Local UI AST publish failed."); } - setFeedback(`Published revision ${json.publish.publishId}. Runtime and generation artifacts are frozen for this contract snapshot.`); + setFeedback(`Published revision ${json.publish.publishId}. Runtime and generation artifacts are frozen for this UI AST snapshot.`); router.refresh(); } @@ -549,14 +549,14 @@ export function LocalWorkbench({ >
- Contract workbench + UI AST workbench

{data.target.label}

- {data.contract.contractId} - v{data.contract.version} + {data.ast.astId} + v{data.ast.version} Local-only reference-target flow
@@ -773,7 +773,7 @@ export function LocalWorkbench({ } style={secondaryButton(isPending)} > - {isPending && pendingAction === "publish" ? "Publishing..." : "Publish current contract"} + {isPending && pendingAction === "publish" ? "Publishing..." : "Publish current UI AST"} {data.localModel.managementPath ? ( @@ -1438,14 +1439,14 @@ export function LocalWorkbench({