diff --git a/conformance/README.md b/conformance/README.md index dba8dc2..36794a1 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -19,4 +19,4 @@ Runnable suites: language-agnostic YAML cases plus thin per-language harnesses. | **Host** | launches via pipeline block only; adapter-sync every launch; host obligations; never edits agent- or system-owned files | | **Agent** | none — deliberately. Any agent that reads files, runs a CLI, and speaks HTTP participates | -The suite is also how the TypeScript implementation proves parity, and how the safe-evolve lifecycle semantics are verified: the harness's **Safe-evolve** class drives the real `agent-app`/`a2app` CLIs over a scaffolded runnable blueprint — dev instance on a hidden port with a fresh replayed DB, live data untouched, operate traffic routed to the candidate, the validate→promote gate pass (including refusal after a post-validate edit), mandatory pre-promote backup, dev destruction on promote, and restore's capture-first contract (framework spec 7.2). +The suite is also how the TypeScript implementation proves parity, and how the safe-evolve lifecycle semantics are verified: the harness's **Safe-evolve** class drives the real `agent-app`/`a2app` CLIs over a scaffolded runnable blueprint — dev instance on a hidden port with a fresh replayed DB, live data untouched, operate traffic routed to the candidate, an operation runner's `trigger` queuing a claimable task on the booted app, the validate→promote gate pass (including refusal after a post-validate edit), mandatory pre-promote backup, dev destruction on promote, and restore's capture-first contract (framework spec 7.2). diff --git a/conformance/harness/src/lifecycle.ts b/conformance/harness/src/lifecycle.ts index 81133af..9cc004c 100644 --- a/conformance/harness/src/lifecycle.ts +++ b/conformance/harness/src/lifecycle.ts @@ -20,7 +20,10 @@ import type { CheckResult, SuiteResult } from "./runner.js"; const TEST_HOME = mkdtempSync(join(tmpdir(), "a2app-conf-evolve-home-")); -function run(entry: string, args: string[], cwd?: string): Promise<{ exit: number; out: string }> { +/** `out` interleaves both streams in arrival order, for messages. `stdout` + * alone is what to parse: the CLIs keep it machine-readable and put banners + * and logs on stderr. */ +function run(entry: string, args: string[], cwd?: string): Promise<{ exit: number; out: string; stdout: string }> { return new Promise((resolvePromise) => { const child = spawn(process.execPath, [entry, ...args], { stdio: ["ignore", "pipe", "pipe"], @@ -28,10 +31,14 @@ function run(entry: string, args: string[], cwd?: string): Promise<{ exit: numbe ...(cwd !== undefined ? { cwd } : {}), }); let out = ""; - child.stdout.on("data", (d) => (out += d)); + let stdout = ""; + child.stdout.on("data", (d) => { + out += d; + stdout += d; + }); child.stderr.on("data", (d) => (out += d)); - child.on("close", (code) => resolvePromise({ exit: code ?? -1, out })); - child.on("error", (e) => resolvePromise({ exit: -1, out: String(e) })); + child.on("close", (code) => resolvePromise({ exit: code ?? -1, out, stdout })); + child.on("error", (e) => resolvePromise({ exit: -1, out: String(e), stdout: "" })); }); } @@ -178,6 +185,41 @@ export async function runSafeEvolveClass( check("operate commands target the dev instance while it is up (test data never reaches live)", failures); } + // ── an operation runner's trigger queues a task ────────────────────────── + // The only booted path through server.mjs's runner toolbox. The wrapper reads + // the adapter handle at call time, so a broken one passes `node --check` and + // every boot, and fails only here, when a runner first fires. + { + const failures: string[] = []; + const res = await run(operateEntry, [appDir, "planning", "tasks", "task_welcome", "request-triage"]); + // Drop the "answering from the DEV instance" banner so the error itself fits. + const said = res.out.split("\n").filter((l) => !l.startsWith("ℹ")).join(" ").trim().slice(0, 300); + const queued = /"queued":\s*"([^"]+)"/.exec(res.stdout)?.[1]; + if (res.exit !== 0) failures.push(`request-triage exit ${res.exit}: ${said}`); + else if (!queued) failures.push(`request-triage did not report a queued task: ${said}`); + else { + const listed = await run(operateEntry, [appDir, "tasks"]); + type Task = { id: string; request?: { capability?: string; payload?: unknown } }; + let tasks: Task[] | null = null; + try { + tasks = (JSON.parse(listed.stdout) as { tasks?: Task[] }).tasks ?? []; + } catch { + failures.push(`tasks list is not JSON (exit ${listed.exit}): ${listed.out.trim().slice(0, 200)}`); + } + // An unreadable listing says nothing about the queue, so it is not also + // reported as the task missing from it. + const task = tasks?.find((t) => t.id === queued); + if (tasks !== null && !task) failures.push(`queued task ${queued} is not in the app's task queue`); + else if (task) { + if (task.request?.capability !== "triage") failures.push(`task capability is ${JSON.stringify(task.request?.capability)}, expected "triage"`); + if (JSON.stringify(task.request?.payload) !== JSON.stringify({ task: "task_welcome" })) { + failures.push(`task payload is ${JSON.stringify(task.request?.payload)}, expected the record's id`); + } + } + } + check("an operation runner's trigger(type, payload, capability) queues a claimable task", failures); + } + // ── validate with dev up records a promotable pass ─────────────────────── { const failures: string[] = []; diff --git a/toolkits/blueprint-react-node/template/server.mjs b/toolkits/blueprint-react-node/template/server.mjs index c87075b..ad6f377 100644 --- a/toolkits/blueprint-react-node/template/server.mjs +++ b/toolkits/blueprint-react-node/template/server.mjs @@ -221,12 +221,12 @@ const binding = { // `trigger` is the app→agent seam. This file is system-owned, so without it // an author whose only seam is `a2app.schema.mjs` could never enqueue agent // work: the adapter handle that can do so lives here and nowhere they may - // edit. `app` is assigned further down and read at call time, which is + // edit. `a2app` is declared further down and read at call time, which is // always after boot. return runner(args, ctx, { store, /** Emit a declared event, and enqueue a task when `capability` is given. */ - trigger: (type, payload, capability) => app.trigger({ type, payload: payload ?? {}, ...(capability ? { capability } : {}) }), + trigger: (type, payload, capability) => a2app.trigger({ type, payload: payload ?? {}, ...(capability ? { capability } : {}) }), }); }, }; diff --git a/toolkits/blueprint-react-node/test/app-to-agent.test.mjs b/toolkits/blueprint-react-node/test/app-to-agent.test.mjs index 899b355..b808323 100644 --- a/toolkits/blueprint-react-node/test/app-to-agent.test.mjs +++ b/toolkits/blueprint-react-node/test/app-to-agent.test.mjs @@ -43,6 +43,15 @@ const server = readFileSync(join(templateDir, "server.mjs"), "utf8"); ok("server.mjs hands runners a trigger", /runner\(args, ctx, \{[\s\S]*?trigger:/.test(server)); ok("…and declares the app's event types to the adapter", /events:\s*schema\.events/.test(server)); +// The wrapper reads the adapter handle at call time, so a wrong name is not a +// syntax error and `node --check` passes it: it surfaces only when a runner +// first fires, as " is not defined". Pin the name it calls to the binding +// `createA2App` actually returns. +const callee = /trigger:\s*\([^)]*\)\s*=>\s*([A-Za-z_$][\w$]*)\.trigger\(/.exec(server)?.[1]; +const instance = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*createA2App\(/.exec(server)?.[1]; +ok("server.mjs creates the adapter with createA2App", Boolean(instance)); +check("…and the runner's trigger calls that same binding", callee, instance); + /* ------------------------------------------- every fired type is declared */ // Cheap and exact: the runners are source, and a literal first argument is how