diff --git a/.github/actions/component-host/action.yml b/.github/actions/component-host/action.yml new file mode 100644 index 0000000..8cb0f60 --- /dev/null +++ b/.github/actions/component-host/action.yml @@ -0,0 +1,41 @@ +name: component host +description: Brain's own component-host worker, built from the immutable Brain revision this repository pins. + +outputs: + binary: + description: The component-host executable. + value: ${{ steps.install.outputs.binary }} + +runs: + using: composite + steps: + - name: Read the pinned Brain revision + id: source + shell: bash + run: | + set -euo pipefail + # The AWS MicroVM runtime already pins the one immutable Brain source this repository + # builds against; extension rounds must cross that exact host ABI, not a second pin. + revision=$(grep -m1 -oE 'brain\.git", rev = "[0-9a-f]{40}"' \ + packages/env-aws-microvm/runtime/Cargo.toml | grep -oE '[0-9a-f]{40}') + [[ "$revision" =~ ^[0-9a-f]{40}$ ]] + echo "revision=$revision" >> "$GITHUB_OUTPUT" + - uses: actions/cache@v4 + with: + path: ~/.component-host + key: component-host-${{ runner.os }}-${{ steps.source.outputs.revision }} + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: "1.97.1" + - name: Install the worker + id: install + shell: bash + env: + REVISION: ${{ steps.source.outputs.revision }} + run: | + set -euo pipefail + binary="$HOME/.component-host/bin/component-host" + test -x "$binary" || cargo install --locked --root "$HOME/.component-host" \ + --git https://github.com/aexhq/brain.git --rev "$REVISION" \ + --bin component-host brain-component-host + echo "binary=$binary" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 771331b..07b8f51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,70 @@ jobs: done - run: npm test + components: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + outputs: + workspaces: ${{ steps.list.outputs.workspaces }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + - run: npm ci + - run: npm run build + - id: list + run: echo "workspaces=$(node tools/npm-release.mjs workspaces)" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@v6 + with: + name: extensions-components-${{ github.sha }} + path: packages/*/dist + if-no-files-found: error + retention-days: 1 + + component-host: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ./.github/actions/component-host + + # The four component contracts only exist between a real componentized guest and a real host, so + # a plain-JS test cannot reach an opaque trap, a forwarded null, a dropped sealed instruction or + # a `list` the guest does not recognise. Each package proves its own components here. + component-smoke: + needs: [components, component-host] + runs-on: ubuntu-24.04 + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + workspace: ${{ fromJSON(needs.components.outputs.workspaces) }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + - uses: ./.github/actions/component-host + id: host + - run: npm ci + - uses: actions/download-artifact@v7 + with: + name: extensions-components-${{ github.sha }} + path: packages + - name: Drive the built components through a real round + env: + COMPONENT_HOST: ${{ steps.host.outputs.binary }} + run: node tools/component-smoke.mjs "${{ matrix.workspace }}" + runtime-format: runs-on: ubuntu-24.04 steps: diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 638cc6b..abb0949 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -114,6 +114,19 @@ jobs: - run: npm run build - run: npm run package-smoke + component-host: + if: inputs.operation == 'bootstrap' || inputs.operation == 'stage' + needs: validate + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: ./.github/actions/component-host + prepare: if: inputs.operation == 'bootstrap' || inputs.operation == 'stage' needs: [validate, stage-tests, stage-package-smoke] @@ -123,6 +136,9 @@ jobs: contents: read outputs: versions: ${{ steps.pack.outputs.versions }} + workspaces: ${{ steps.pack.outputs.workspaces }} + base: ${{ steps.pack.outputs.base }} + dependents: ${{ steps.pack.outputs.dependents }} steps: - uses: actions/checkout@v7 with: @@ -144,7 +160,11 @@ jobs: release_dir="$RUNNER_TEMP/extensions-npm-release" node tools/npm-release.mjs pack "$release_dir" versions=$(node tools/npm-release.mjs versions "$release_dir/manifest.json") + order=$(node tools/npm-release.mjs order "$release_dir/manifest.json") echo "versions=$versions" >> "$GITHUB_OUTPUT" + echo "workspaces=$(node tools/npm-release.mjs workspaces)" >> "$GITHUB_OUTPUT" + echo "base=$(jq -c .base <<<"$order")" >> "$GITHUB_OUTPUT" + echo "dependents=$(jq -c .dependents <<<"$order")" >> "$GITHUB_OUTPUT" node tools/npm-release.mjs markdown "$release_dir/manifest.json" >> "$GITHUB_STEP_SUMMARY" - uses: actions/upload-artifact@v6 with: @@ -161,6 +181,41 @@ jobs: permissions: contents: read id-token: write + strategy: + fail-fast: false + matrix: + workspace: ${{ fromJSON(needs.prepare.outputs.base || '[]') }} + steps: + - uses: actions/download-artifact@v7 + with: + name: extensions-npm-release-${{ github.sha }} + path: ${{ runner.temp }}/extensions-npm-release + - uses: actions/setup-node@v7 + with: + node-version: '24' + registry-url: https://registry.npmjs.org + package-manager-cache: false + - run: npm install --global npm@11.19.0 + - run: node "$RUNNER_TEMP/extensions-npm-release/verify-dependencies.mjs" "$RUNNER_TEMP/extensions-npm-release/manifest.json" + - name: Publish once with provenance under next + env: + EXPECTED_COMMIT: ${{ inputs.expected_commit }} + run: node "$RUNNER_TEMP/extensions-npm-release/publish.mjs" stage "${{ matrix.workspace }}" + + # Registry visibility of an exact version is a hard dependency of everything built or installed + # against it, so these wait for the wave above and for nothing else. + stage-dependents: + if: inputs.operation == 'stage' + needs: [prepare, stage] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + id-token: write + strategy: + fail-fast: false + matrix: + workspace: ${{ fromJSON(needs.prepare.outputs.dependents || '[]') }} steps: - uses: actions/download-artifact@v7 with: @@ -176,11 +231,74 @@ jobs: - name: Publish once with provenance under next env: EXPECTED_COMMIT: ${{ inputs.expected_commit }} - run: node "$RUNNER_TEMP/extensions-npm-release/publish.mjs" stage - - name: Record staged versions + run: node "$RUNNER_TEMP/extensions-npm-release/publish.mjs" stage "${{ matrix.workspace }}" + + # The staged archive is the artifact a session loads, so its components run against the real host + # ABI here, installed from the registry rather than rebuilt. Promotion consumes these receipts. + component-smoke: + if: ${{ !cancelled() && (needs.stage-dependents.result == 'success' || needs.bootstrap.result == 'success') }} + needs: [prepare, component-host, stage-dependents, bootstrap] + runs-on: ubuntu-24.04 + timeout-minutes: 25 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + workspace: ${{ fromJSON(needs.prepare.outputs.workspaces || '[]') }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: '24' + registry-url: https://registry.npmjs.org + package-manager-cache: false + - uses: ./.github/actions/component-host + id: host + - run: npm install --global npm@11.19.0 + - run: npm ci + - uses: actions/download-artifact@v7 + with: + name: extensions-npm-release-${{ github.sha }} + path: ${{ runner.temp }}/extensions-npm-release + - name: Install the exact staged version + id: staged + shell: bash + env: + WORKSPACE: ${{ matrix.workspace }} + run: | + set -euo pipefail + entry=$(jq -e --arg workspace "$WORKSPACE" '.packages[] | select(.workspace == $workspace)' \ + "$RUNNER_TEMP/extensions-npm-release/manifest.json") + spec="$(jq -r .name <<<"$entry")@$(jq -r .version <<<"$entry")" + consumer="$RUNNER_TEMP/staged-consumer" + mkdir -p "$consumer" + cd "$consumer" + npm init --yes > /dev/null + npm install --no-audit --no-fund "$spec" + integrity=$(npm view "$spec" dist.integrity) + test "$integrity" = "$(jq -r .integrity <<<"$entry")" + echo "from=$consumer/node_modules/$(jq -r .name <<<"$entry")" >> "$GITHUB_OUTPUT" + echo "integrity=$integrity" >> "$GITHUB_OUTPUT" + - name: Drive the staged components through a real round env: - VERSIONS: ${{ needs.prepare.outputs.versions }} - run: echo "Staged exact versions under npm dist-tag \`next\` — \`$VERSIONS\`" >> "$GITHUB_STEP_SUMMARY" + COMPONENT_HOST: ${{ steps.host.outputs.binary }} + FROM: ${{ steps.staged.outputs.from }} + SMOKE_INTEGRITY: ${{ steps.staged.outputs.integrity }} + WORKSPACE: ${{ matrix.workspace }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/smoke" + node tools/component-smoke.mjs "$WORKSPACE" --from "$FROM" \ + --receipt "$RUNNER_TEMP/smoke/$WORKSPACE.json" + - uses: actions/upload-artifact@v6 + with: + name: extensions-npm-smoke-${{ github.sha }}-${{ matrix.workspace }} + path: ${{ runner.temp }}/smoke + if-no-files-found: error + retention-days: 30 bootstrap: if: inputs.operation == 'bootstrap' @@ -264,6 +382,13 @@ jobs: path: ${{ runner.temp }}/extensions-npm-release github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ inputs.stage_run_id }} + - uses: actions/download-artifact@v7 + with: + pattern: extensions-npm-smoke-${{ needs.validate.outputs.release_sha }}-* + merge-multiple: true + path: ${{ runner.temp }}/extensions-npm-smoke + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ inputs.stage_run_id }} - uses: actions/setup-node@v7 with: node-version: '24' @@ -275,4 +400,5 @@ jobs: env: EXPECTED_COMMIT: ${{ needs.validate.outputs.release_sha }} NODE_AUTH_TOKEN: ${{ secrets.NPM_DIST_TAG_TOKEN }} + SMOKE_RECEIPTS: ${{ runner.temp }}/extensions-npm-smoke run: node "$RUNNER_TEMP/extensions-npm-release/publish.mjs" promote diff --git a/package.json b/package.json index 912c376..175b488 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "workspaces": ["packages/*"], "scripts": { "build": "npm run build --workspaces --if-present", - "test": "npm test --workspaces --if-present && node --test tools/npm-release.test.mjs tools/verify-dependencies.test.mjs", + "test": "npm test --workspaces --if-present && node --test tools/npm-release.test.mjs tools/publish.test.mjs tools/verify-dependencies.test.mjs", "package-smoke": "node tools/package-smoke.mjs" }, "devDependencies": { diff --git a/tools/component-smoke.mjs b/tools/component-smoke.mjs new file mode 100644 index 0000000..272a625 --- /dev/null +++ b/tools/component-smoke.mjs @@ -0,0 +1,654 @@ +/** + * Run one package's published components against the real host ABI. + * + * node tools/component-smoke.mjs [--from ] [--receipt ] + * + * `--from` selects the package under test: the working tree by default, or a clean install of the + * exact staged version. The host is Brain's own `component-host` worker (`COMPONENT_HOST`), so + * these rounds cross the same `contracts/*` boundary the kernel crosses — the only place an + * opaque Wasm trap, a null sampling field, a dropped sealed instruction or a `list` the guest + * does not recognise becomes visible before a tagged deployment. + */ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { createInterface } from "node:readline"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +const root = path.resolve(import.meta.dirname, ".."); +const fixtures = path.join(import.meta.dirname, "component-smoke"); +const componentHost = process.env.COMPONENT_HOST; +if (componentHost === undefined) { + throw new Error("COMPONENT_HOST must name Brain's component-host binary"); +} + +class Host { + #child; + #pending = new Map(); + #next = 1; + #capabilities = () => { + throw new Error("the scenario bound no capability handler"); + }; + + constructor(binary) { + this.#child = spawn(binary, [], { stdio: ["pipe", "pipe", "inherit"] }); + this.#child.on("exit", (code, signal) => { + const reason = new Error(`the component host exited with ${signal ?? code}`); + for (const pending of this.#pending.values()) pending.reject(reason); + this.#pending.clear(); + }); + createInterface({ input: this.#child.stdout }).on("line", (line) => { + this.#frame(line).catch((error) => { + this.#child.kill(); + throw error; + }); + }); + } + + bind(capabilities) { + this.#capabilities = capabilities; + } + + close() { + this.#child.stdin.end(); + } + + request(request) { + const id = this.#next; + this.#next += 1; + return new Promise((resolve, reject) => { + this.#pending.set(id, { resolve, reject }); + this.#write({ frame: "request", id, request }); + }); + } + + #write(frame) { + this.#child.stdin.write(`${JSON.stringify(frame)}\n`); + } + + async #frame(line) { + const frame = JSON.parse(line); + if (frame.frame === "response") { + const pending = this.#pending.get(frame.id); + this.#pending.delete(frame.id); + if ("Ok" in frame.result) pending.resolve(frame.result.Ok); + else pending.reject(new Error(frame.result.Err)); + return; + } + try { + const value = await this.#capabilities(frame.call); + this.#write({ frame: "capability_result", id: frame.id, result: { Ok: value ?? null } }); + } catch (error) { + this.#write({ + frame: "capability_result", + id: frame.id, + result: { + Err: { + code: error.code ?? "smoke_capability_failed", + message: String(error.message ?? error), + retryable: false, + }, + }, + }); + } + } +} + +const denied = (code, message) => Object.assign(new Error(message), { code }); +const utf8 = (bytes) => Buffer.from(bytes).toString("utf8"); +const sse = (frames) => frames.map((frame) => `${frame}\n\n`).join(""); +const DEADLINE = 4_102_444_800_000; + +/** Brain always sends every sampling field; an unset one arrives as an explicit JSON null. */ +const sealedPrefix = (extra) => ({ + system_prompt: "Answer with one word.", + max_tokens: null, + temperature: null, + stop_sequences: null, + tool_choice_none: null, + ...extra, +}); + +const sealedRequest = (body, absent) => { + const value = JSON.parse(body); + for (const field of absent) assert.equal(field in value, false, `${field} reached the provider`); + assert.match(body, /Answer with one word\./u, "the sealed instructions never reached the provider"); +}; + +async function model(host, target, component) { + const started = []; + const stream = target.chunks.map((chunk, index) => ({ cursor: `c${index}`, ...chunk })); + host.bind((call) => { + if (call.capability === "model.http.start") { + started.push(call.request); + return { request_id: "req-smoke" }; + } + if (call.capability === "model.http.read") { + const chunk = stream.shift(); + if (chunk === undefined) throw denied("smoke_stream", "the Model read past the scripted stream"); + return { + cursor: chunk.cursor, + // Only the first chunk carries a status, and a host `list` reaches a componentized + // guest as a plain array of byte values, never a `Uint8Array`. + status: chunk.status ?? null, + headers: chunk.status === undefined ? [] : [["content-type", "text/event-stream"]], + bytes: [...Buffer.from(chunk.body ?? "", "utf8")], + done: chunk.done === true, + }; + } + throw denied("smoke_capability", `unscripted Model capability ${call.capability}`); + }); + + const request = { + operation_id: "model_op_smoke", + model: target.model, + messages_json: JSON.stringify([{ role: "user", content: [{ type: "text", text: "hi" }] }]), + tools_json: "[]", + response_format_json: null, + generation_json: JSON.stringify(target.generation), + provider_options_json: JSON.stringify(target.options), + deadline_at_ms: DEADLINE, + }; + const attempt = await host.request({ + kind: "model_start", + instance_id: "model-smoke", + component, + request, + }); + assert.equal(typeof attempt.provider_operation_id, "string"); + assert.equal(started.length, 1); + target.body(utf8(started[0].body)); + + const texts = []; + let observation; + let cursor = null; + do { + observation = await host.request({ + kind: "model_observe", + instance_id: "model-smoke", + provider_operation_id: attempt.provider_operation_id, + cursor, + }); + cursor = observation.next_cursor; + for (const event of observation.events) { + if (event.kind === "TextDelta") texts.push(JSON.parse(event.payload_json).text); + } + } while (observation.state === "Streaming"); + assert.equal(observation.state, "Completed"); + assert.equal(texts.join(""), target.text); + assert.equal(typeof observation.terminal_json, "string"); + await host.request({ + kind: "model_acknowledge", + instance_id: "model-smoke", + provider_operation_id: attempt.provider_operation_id, + terminal_json: observation.terminal_json, + }); + + stream.push({ cursor: "c-failed", status: 500, body: "" }); + const failing = await host.request({ + kind: "model_start", + instance_id: "model-smoke-failed", + component, + request, + }); + await assert.rejects( + host.request({ + kind: "model_observe", + instance_id: "model-smoke-failed", + provider_operation_id: failing.provider_operation_id, + cursor: null, + }), + // componentize-js compiles a thrown plain `Error` into a Wasm trap whose message is gone; a + // typed `extension-error` is the only way this reason reaches the kernel. + /status 500/u, + "the provider status never reached the kernel", + ); + await host.request({ kind: "release", world: "model", instance_id: "model-smoke-failed" }); +} + +async function environment(host, _target, component) { + const bundle = [...Buffer.from("smoke bundle", "utf8")]; + const observations = [ + { state: "running", cursor: "c1", chunks: [{ seq: 1, text: "working" }] }, + { state: "completed", cursor: "c2", chunks: [], terminal_json: { ok: true, exit_code: 0 } }, + ]; + let failing = false; + host.bind((call) => { + assert.equal(call.capability, "environment.dispatch"); + if (failing) throw denied("driver_unavailable", "the smoke driver refused the operation"); + const { action, request } = call.request; + if (action === "submit") { + assert.equal(utf8(Buffer.from(request.operation.bundle_base64, "base64")), "smoke bundle"); + return { provider_operation_id: "prov-smoke" }; + } + if (action === "observe") return observations.shift(); + return {}; + }); + + const resolve = { + tenant_id: "ten_smoke", + session_id: "ses_smoke", + root_id: "ses_smoke", + parent_id: null, + environment_id: "workspace", + config_json: JSON.stringify({ driver: { kind: "smoke" }, configuration: { region: "local" } }), + policy_json: JSON.stringify({ network: "deny" }), + }; + const operation = { + operation_id: "env_op_smoke", + kind: "invoke", + descriptor_json: JSON.stringify({ runtime: "node22", tool_name: "smoke" }), + bundle, + input_json: JSON.stringify({ command: "true" }), + deadline_at_ms: DEADLINE, + }; + const resolved = await host.request({ + kind: "environment_resolve", + instance_id: "env-smoke", + component, + request: resolve, + }); + const binding = JSON.parse(resolved.binding_json); + assert.equal(binding.session_id, "ses_smoke"); + assert.deepEqual(binding.policy, { network: "deny" }); + + const submitted = await host.request({ + kind: "environment_submit", + instance_id: "env-smoke", + binding_json: resolved.binding_json, + operation, + }); + assert.equal(submitted.provider_operation_id, "prov-smoke"); + + const observe = (cursor) => host.request({ + kind: "environment_observe", + instance_id: "env-smoke", + binding_json: resolved.binding_json, + provider_operation_id: submitted.provider_operation_id, + cursor, + }); + const running = await observe(null); + assert.equal(running.state, "Running"); + assert.deepEqual(JSON.parse(running.chunks_json), [{ seq: 1, text: "working" }]); + const completed = await observe(running.cursor); + assert.equal(completed.state, "Completed"); + assert.deepEqual(JSON.parse(completed.terminal_json), { ok: true, exit_code: 0 }); + + await host.request({ + kind: "environment_acknowledge", + instance_id: "env-smoke", + binding_json: resolved.binding_json, + provider_operation_id: submitted.provider_operation_id, + terminal_json: completed.terminal_json, + }); + await host.request({ + kind: "environment_release", + instance_id: "env-smoke", + binding_json: resolved.binding_json, + }); + + failing = true; + await host.request({ + kind: "environment_resolve", + instance_id: "env-smoke-failed", + component, + request: resolve, + }); + await assert.rejects( + host.request({ + kind: "environment_submit", + instance_id: "env-smoke-failed", + binding_json: resolved.binding_json, + operation, + }), + /the smoke driver refused the operation/u, + "the driver failure never reached the kernel", + ); + await host.request({ kind: "release", world: "environment", instance_id: "env-smoke-failed" }); +} + +async function tool(host, target, component) { + const config = target.configFile === undefined + ? target.config ?? {} + : JSON.parse(await readFile(path.join(target.from, target.configFile), "utf8")); + let failing = false; + host.bind((call) => { + if (failing) throw denied("environment_unavailable", "the smoke grant refused the call"); + if (call.capability === "tool.environment.invoke") { + assert.deepEqual(JSON.parse(call.request.descriptor_json), config.descriptor); + assert.equal(call.request.bundle_base64 === null, config.bundleBase64 === undefined); + return JSON.stringify({ value_json: JSON.stringify({ ok: true }), content: "ok", is_error: false }); + } + if (call.capability === "tool.children.spawn") { + assert.equal(JSON.parse(call.request.request_json).name, "smoke child"); + return JSON.stringify({ child_id: "chi_smoke", status: "running" }); + } + throw denied("smoke_capability", `unscripted Tool capability ${call.capability}`); + }); + + const invocation = { + metadata: { + tenant_id: "ten_smoke", + session_id: "ses_smoke", + turn_id: "turn_smoke", + call_id: "call_smoke", + tool_name: target.toolName, + }, + input_json: JSON.stringify(target.input), + config_json: JSON.stringify(config), + deadline_at_ms: DEADLINE, + }; + const outcome = await host.request({ + kind: "tool", + component, + request: invocation, + grants: [target.grant], + }); + assert.equal(outcome.is_error, false); + assert.deepEqual(JSON.parse(outcome.value_json), target.value); + + failing = true; + await assert.rejects( + host.request({ kind: "tool", component, request: invocation, grants: [target.grant] }), + /the smoke grant refused the call/u, + "the grant failure never reached the kernel", + ); +} + +const SESSION = { + session_id: "ses_smoke", + model: "smoke-model", + limits: { max_rounds_per_turn: 8, turn_wall_ms: 60_000, max_parallel_tools: 4 }, + metadata: { tools: [] }, +}; + +const round = (answers, seen) => (call) => { + assert.equal(call.capability, "agentloop.call"); + seen.push(call.request.op); + const answer = answers[call.request.op.op]; + if (answer === undefined) throw denied("internal", `unscripted Agentloop op ${call.request.op.op}`); + return JSON.stringify(answer(call.request.op)); +}; + +const completedRound = { + model_stream: () => ({ + result: { + op: "model_stream", + message: { + content: [{ type: "text", text: "hello" }], + stop_reason: "end_turn", + model: SESSION.model, + usage: { input_tokens: 3, output_tokens: 1 }, + }, + }, + }), + tools_dispatch: ({ calls }) => ({ + result: { + op: "tools_dispatch", + results: calls.map((item) => ({ + tool_call_id: item.tool_call_id, + name: item.name, + is_error: false, + content: [{ type: "text", text: "ok" }], + })), + }, + }), + journal_append: () => ({ result: { op: "journal_append", first_seq: 5, last_seq: 5 } }), + journal_read: () => ({ result: { op: "journal_read", entries: [] } }), + kv_get: () => ({ result: { op: "kv_get", entries: {} } }), + kv_set: () => ({ result: { op: "kv_set" } }), + turn_finish: () => ({ result: { op: "turn_finish" } }), + turn_fail: () => ({ result: { op: "turn_fail" } }), +}; + +async function agentloop(host, _target, component) { + const activate = (kind, payload) => host.request({ + kind: "agentloop", + instance_id: "loop-smoke", + component, + request: { + operation_id: `act_${kind}`, + session_id: SESSION.session_id, + kind, + payload_json: JSON.stringify(payload), + config_json: JSON.stringify({ instructions: "Answer with one word." }), + deadline_at_ms: DEADLINE, + }, + }); + + let seen = []; + host.bind(round(completedRound, seen)); + const started = await activate("session_start", { + activation_id: "act-start", + session: SESSION, + resumed: false, + kv: {}, + tail: [ + { + type: "user_message", + seq: 1, + at: "2026-08-25T00:00:00Z", + content: [{ type: "text", text: "earlier" }], + }, + ], + }); + assert.equal(JSON.parse(started.payload_json).outcome, "completed"); + + const message = { + activation_id: "act-1", + kind: "message", + session: SESSION, + message: { seq: 4, at: "2026-08-25T00:00:01Z", content: [{ type: "text", text: "say hello" }] }, + }; + const completed = await activate("message", message); + assert.equal(JSON.parse(completed.payload_json).outcome, "completed"); + assert.ok(seen.some((op) => op.op === "model_stream"), "the loop composed no model round"); + assert.match(JSON.stringify(seen), /say hello/u, "the admitted message never reached the loop"); + assert.match(JSON.stringify(seen), /earlier/u, "the session_start tail never reached the loop"); + + seen = []; + host.bind(round({ + ...completedRound, + model_stream: () => ({ + error: { code: "provider_error", message: "the smoke provider refused the round", retryable: false }, + }), + }, seen)); + // A failed round has to reach the kernel as data carrying the provider's reason — through the + // activation payload or a terminal op — never as a trap whose message is gone. + const failed = await activate("message", { ...message, activation_id: "act-2" }); + assert.match( + JSON.stringify([failed.payload_json, seen]), + /the smoke provider refused the round/u, + "the provider reason never reached the kernel", + ); + await host.request({ kind: "release", world: "agentloop", instance_id: "loop-smoke" }); +} + +const scenarios = { model, environment, tool, agentloop }; + +const openai = { + kind: "model", + component: "dist/model.component.wasm", + model: "gpt-smoke", + options: { baseUrl: "https://provider.invalid", apiKey: "sk-smoke", outputTokenParameter: "max_completion_tokens" }, + generation: sealedPrefix({ reasoning_effort: null }), + body: (body) => sealedRequest(body, ["temperature", "max_completion_tokens", "reasoning_effort", "stop", "tool_choice"]), + chunks: [ + { status: 200, body: sse([`data: ${JSON.stringify({ choices: [{ delta: { content: "hel" } }] })}`]) }, + { + body: sse([ + `data: ${JSON.stringify({ choices: [{ delta: { content: "lo" }, finish_reason: "stop" }], usage: { prompt_tokens: 3, completion_tokens: 1 } })}`, + "data: [DONE]", + ]), + done: true, + }, + ], + text: "hello", +}; + +const anthropic = { + ...openai, + model: "claude-smoke", + options: { baseUrl: "https://provider.invalid", apiKey: "sk-smoke" }, + // `reasoning_effort` is omitted: this dialect rejects the field outright, so Brain's real + // prefix cannot be sent here until that guard also ignores an unset null. + generation: sealedPrefix({ max_tokens: 128 }), + body: (body) => sealedRequest(body, ["temperature", "stop_sequences"]), + chunks: [ + { + status: 200, + body: sse([ + `event: content_block_delta\ndata: ${JSON.stringify({ index: 0, delta: { type: "text_delta", text: "hel" } })}`, + ]), + }, + { + body: sse([ + `event: content_block_delta\ndata: ${JSON.stringify({ index: 0, delta: { type: "text_delta", text: "lo" } })}`, + `event: message_delta\ndata: ${JSON.stringify({ delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } })}`, + ]), + done: true, + }, + ], +}; + +const environmentTarget = { kind: "environment", component: "dist/environment.component.wasm" }; + +const plan = { + agentloop: [{ kind: "agentloop", component: buildAgentloopFixture }], + "env-app": [ + environmentTarget, + { + kind: "tool", + component: "dist/tool.component.wasm", + config: { descriptor: { runtime: "callback", tool_name: "callback" } }, + grant: "environment", + toolName: "callback", + input: { command: "true" }, + value: { ok: true }, + }, + ], + "env-aws-microvm": [environmentTarget], + "loop-codex": [{ kind: "agentloop", component: "dist/loop.component.wasm" }], + "loop-pi": [{ kind: "agentloop", component: "dist/loop.component.wasm" }], + model: [ + { + kind: "model", + component: buildModelFixture, + model: "smoke-model", + options: { baseUrl: "https://provider.invalid" }, + generation: sealedPrefix({}), + body: (body) => assert.match(body, /Answer with one word\./u), + chunks: [ + { status: 200, body: sse([`data: ${JSON.stringify({ text: "hel" })}`]) }, + { body: sse([`data: ${JSON.stringify({ text: "lo" })}`]), done: true }, + ], + text: "hello", + }, + ], + "model-anthropic": [anthropic], + "model-openai": [openai], + tools: [ + { + kind: "tool", + component: "dist/tool.component.wasm", + configFile: "dist/bash.component.json", + grant: "environment", + toolName: "bash", + input: { command: "true" }, + value: { ok: true }, + }, + { + kind: "tool", + component: "dist/children.component.wasm", + grant: "children", + toolName: "subagents", + input: { action: "spawn_agent", message: "run the smoke", task_name: "smoke child" }, + value: { child_id: "chi_smoke", status: "running" }, + }, + ], +}; + +async function buildModelFixture(from, into) { + const { build } = await import("esbuild"); + const bundled = await build({ + entryPoints: [path.join(fixtures, "model.fixture.mjs")], + bundle: true, + format: "esm", + platform: "neutral", + external: ["aex:model/host@1.0.0"], + alias: { "@aexhq/model": path.join(from, "index.mjs") }, + write: false, + legalComments: "none", + }); + const { componentize } = await import("@bytecodealliance/componentize-js"); + const wit = await readFile(new URL(import.meta.resolve("@aexhq/brain/contracts/model")), "utf8"); + const output = await componentize(bundled.outputFiles[0].text, wit, { + worldName: "model", + disableFeatures: ["http", "fetch-event"], + }); + const file = path.join(into, "model.fixture.component.wasm"); + await writeFile(file, output.component); + return file; +} + +async function buildAgentloopFixture(from, into) { + const { buildAgentloopComponent } = await import( + pathToFileURL(path.join(from, "dist", "build.js")).href + ); + const { componentize } = await import("@bytecodealliance/componentize-js"); + const built = await buildAgentloopComponent( + { entry: path.join(fixtures, "agentloop.fixture.mjs") }, + componentize, + ); + const file = path.join(into, "agentloop.fixture.component.wasm"); + await writeFile(file, built.component); + return file; +} + +const [workspace, ...flags] = process.argv.slice(2); +const option = (name) => { + const index = flags.indexOf(`--${name}`); + return index < 0 ? undefined : flags[index + 1]; +}; +const targets = plan[workspace]; +if (targets === undefined) throw new Error(`no component smoke is declared for ${workspace}`); +const from = path.resolve(option("from") ?? path.join(root, "packages", workspace)); +const receipt = option("receipt"); + +const scratch = await mkdtemp(path.join(tmpdir(), "extensions-component-smoke-")); +const components = []; +try { + for (const target of targets) { + const file = typeof target.component === "string" + ? path.join(from, target.component) + : await target.component(from, scratch); + const bytes = await readFile(file); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const host = new Host(componentHost); + try { + await scenarios[target.kind](host, { ...target, from }, { path: file, sha256 }); + } finally { + host.close(); + } + components.push({ component: path.basename(file), kind: target.kind, sha256 }); + process.stdout.write(`${workspace}: ${target.kind} ${path.basename(file)} ${sha256}\n`); + } +} finally { + await rm(scratch, { recursive: true, force: true }); +} + +if (receipt !== undefined) { + const document = JSON.parse(await readFile(path.join(from, "package.json"), "utf8")); + await writeFile(receipt, `${JSON.stringify({ + schema: 1, + workspace, + name: document.name, + version: document.version, + integrity: process.env.SMOKE_INTEGRITY ?? null, + components, + }, null, 2)}\n`); +} diff --git a/tools/component-smoke/agentloop.fixture.mjs b/tools/component-smoke/agentloop.fixture.mjs new file mode 100644 index 0000000..b4db8d7 --- /dev/null +++ b/tools/component-smoke/agentloop.fixture.mjs @@ -0,0 +1,22 @@ +// The smallest loop an author can write with `@aexhq/agentloop`: hydration replayed into one model +// round, one journal entry and an explicit finish. Compiled by the staged package's own builder, +// this is the only place its host binding and activation envelope run inside a real component. +import { defineAgentloop } from "@aexhq/agentloop"; + +let memory = []; + +export const { activate } = defineAgentloop({ + onSessionStart(start) { + memory = start.tail.flatMap((view) => + view.type === "user_message" ? [{ role: "user", content: view.content }] : [], + ); + }, + async onMessage(ctx, message) { + memory.push({ role: "user", content: message.content }); + const round = await ctx.model.stream({ messages: memory.slice() }); + await ctx.journal.append([ + { kind: "event", name: "smoke.round", data: { stop: round.stop_reason } }, + ]); + await ctx.turn.finish({ text: round.content.map((block) => block.text ?? "").join("") }); + }, +}); diff --git a/tools/component-smoke/model.fixture.mjs b/tools/component-smoke/model.fixture.mjs new file mode 100644 index 0000000..fef5615 --- /dev/null +++ b/tools/component-smoke/model.fixture.mjs @@ -0,0 +1,69 @@ +// The smallest Model an author can write with `@aexhq/model`: every export reports through the +// package's `typed` guard and every chunk reaches the package's SSE decoder as the component ABI +// delivers it. Compiled against the staged package, this is the only place those two contracts run +// inside a real component. +import { httpRead, httpStart } from "aex:model/host@1.0.0"; +import { SseDecoder, parseJson, terminal, typed } from "@aexhq/model"; + +const attempts = new Map(); + +export function start(request) { + return typed("start", () => { + const options = parseJson(request.providerOptionsJson, "providerOptionsJson"); + const started = httpStart(request.operationId, { + method: "POST", + url: `${options.baseUrl}/v1/smoke`, + headers: [["content-type", "application/json"]], + body: new TextEncoder().encode(request.generationJson), + credential: undefined, + deadlineAtMs: request.deadlineAtMs, + }); + attempts.set(started.requestId, { decoder: new SseDecoder(), sequence: 0 }); + return { providerOperationId: started.requestId }; + }); +} + +export function observe(providerOperationId, cursor) { + return typed("observe", () => { + const attempt = required(providerOperationId); + const chunk = httpRead(providerOperationId, cursor, 64 * 1024); + if (typeof chunk.status === "number" && (chunk.status < 200 || chunk.status >= 300)) { + throw new Error(`smoke HTTP status ${chunk.status}`); + } + const events = []; + for (const frame of attempt.decoder.feed(chunk.bytes)) { + attempt.sequence += 1; + events.push({ + cursor: `${chunk.cursor}:${attempt.sequence}`, + kind: "text-delta", + payloadJson: JSON.stringify({ index: 0, text: parseJson(frame.data, "smoke frame").text }), + }); + } + return { + state: chunk.done ? "completed" : "streaming", + events, + nextCursor: chunk.cursor, + terminalJson: chunk.done ? terminal("end_turn") : undefined, + }; + }); +} + +export function cancel(providerOperationId) { + return typed("cancel", () => { + required(providerOperationId); + attempts.delete(providerOperationId); + }); +} + +export function acknowledge(providerOperationId) { + return typed("acknowledge", () => { + required(providerOperationId); + attempts.delete(providerOperationId); + }); +} + +function required(providerOperationId) { + const attempt = attempts.get(providerOperationId); + if (attempt === undefined) throw new Error(`unknown Model attempt ${providerOperationId}`); + return attempt; +} diff --git a/tools/npm-release.mjs b/tools/npm-release.mjs index 10d0e5b..fd64b2d 100644 --- a/tools/npm-release.mjs +++ b/tools/npm-release.mjs @@ -51,6 +51,26 @@ const releasedIntegrity = (spec) => { export const versionSetByLatestChange = (packageJsonPatch) => /^\+\s*"version":/mu.test(packageJsonPatch); +/** + * Staging fans out per package, so the order has to come from the release itself: a package waits + * only for the exact versions it is built and installed against. Two waves cover this release; a + * deeper chain has to add a wave rather than publish a dependent before its dependency is visible. + */ +export const stageOrder = (packages) => { + if (packages.some((item) => !Array.isArray(item.needs))) { + throw new Error("this release manifest predates per-package staging; stage the release again"); + } + const base = packages.filter((item) => item.needs.length === 0).map((item) => item.workspace); + const dependents = packages.filter((item) => item.needs.length > 0); + for (const item of dependents) { + const late = item.needs.filter((need) => !base.includes(need)); + if (late.length > 0) { + throw new Error(`${item.name} needs ${late.join(", ")}, which no earlier stage wave publishes`); + } + } + return { base, dependents: dependents.map((item) => item.workspace) }; +}; + const assertReleasedVersionIsCurrent = (workspace, spec) => { const directory = `packages/${workspace}`; const commit = git(["log", "-1", "--format=%H", "--", directory]); @@ -87,8 +107,10 @@ const manifest = async (filename) => { async function pack(directory) { await mkdir(directory, { recursive: false }); const packages = []; + const documents = new Map(); for (const workspace of workspaces) { const packageDocument = await document(workspace); + documents.set(workspace, packageDocument); if (packageDocument.publishConfig?.access !== "public" || packageDocument.publishConfig?.tag !== "next") { throw new Error(`${packageDocument.name} must publish publicly under the next dist-tag`); } @@ -126,6 +148,7 @@ async function pack(directory) { peerDependencies: packageDocument.peerDependencies ?? {}, }); } + const owner = new Map(packages.map((item) => [item.name, item.workspace])); for (const item of packages) { for (const [name, version] of Object.entries(item.dependencies)) { const local = packages.find((candidate) => candidate.name === name); @@ -133,7 +156,15 @@ async function pack(directory) { throw new Error(`${item.name} must depend on the exact release version ${name}@${local.version}`); } } + // An authoring toolchain is a development edge and still orders staging: its dependent is + // built with it, and the exact version has to be on the registry before that happens. + const declared = documents.get(item.workspace); + item.needs = [...new Set(Object.keys({ ...declared.dependencies, ...declared.devDependencies }))] + .map((name) => owner.get(name)) + .filter((workspace) => workspace !== undefined && workspace !== item.workspace) + .sort(); } + stageOrder(packages); const value = { schema: 1, source: process.env.GITHUB_SHA ?? "local", packages }; await writeFile(path.join(directory, "manifest.json"), `${JSON.stringify(value, null, 2)}\n`); for (const filename of ["npm-release.mjs", "verify-dependencies.mjs", "publish.mjs"]) { @@ -147,7 +178,12 @@ const [command, argument] = process.argv[1] !== undefined && : ["import"]; if (command === "import") { /* imported for its exported contracts */ } else if (command === "pack" && argument !== undefined) await pack(path.resolve(argument)); -else if (command === "versions" && argument !== undefined) { +else if (command === "workspaces") process.stdout.write(JSON.stringify(workspaces)); +else if (command === "order" && argument !== undefined) { + const value = await manifest(path.resolve(argument)); + const { base, dependents } = stageOrder(value.packages); + process.stdout.write(JSON.stringify({ base, dependents })); +} else if (command === "versions" && argument !== undefined) { const value = await manifest(path.resolve(argument)); process.stdout.write(value.packages.map(({ name, version }) => `${name}@${version}`).join(",")); } else if (command === "markdown" && argument !== undefined) { @@ -157,5 +193,7 @@ else if (command === "versions" && argument !== undefined) { process.stdout.write(`| \`${item.name}\` | \`${item.version}\` | \`${item.integrity}\` |\n`); } } else { - throw new Error("usage: npm-release.mjs pack | versions|markdown "); + throw new Error( + "usage: npm-release.mjs pack | workspaces | order|versions|markdown ", + ); } diff --git a/tools/npm-release.test.mjs b/tools/npm-release.test.mjs index b35e5cc..6aa85f9 100644 --- a/tools/npm-release.test.mjs +++ b/tools/npm-release.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { versionSetByLatestChange } from "./npm-release.mjs"; +import { stageOrder, versionSetByLatestChange } from "./npm-release.mjs"; test("a released package must take a new version with its latest change", () => { assert.equal(versionSetByLatestChange([ @@ -20,3 +20,22 @@ test("a released package must take a new version with its latest change", () => assert.equal(versionSetByLatestChange(""), false); }); + +test("staging waves come from the release, not from a list someone maintains", () => { + assert.deepEqual(stageOrder([ + { workspace: "model", name: "@aexhq/model", needs: [] }, + { workspace: "agentloop", name: "@aexhq/agentloop", needs: [] }, + { workspace: "model-openai", name: "@aexhq/model-openai", needs: ["model"] }, + { workspace: "loop-pi", name: "@aexhq/loop-pi", needs: ["agentloop"] }, + ]), { base: ["model", "agentloop"], dependents: ["model-openai", "loop-pi"] }); + + // A dependent published before the exact version it installs is visible cannot resolve, so a + // deeper chain has to add a wave rather than fan out anyway. + assert.throws(() => stageOrder([ + { workspace: "model", name: "@aexhq/model", needs: [] }, + { workspace: "model-openai", name: "@aexhq/model-openai", needs: ["model"] }, + { workspace: "loop-pi", name: "@aexhq/loop-pi", needs: ["model-openai"] }, + ]), /@aexhq\/loop-pi needs model-openai/u); + + assert.throws(() => stageOrder([{ workspace: "model", name: "@aexhq/model" }]), /predates/u); +}); diff --git a/tools/publish.mjs b/tools/publish.mjs index 2ba2de8..c5c874b 100644 --- a/tools/publish.mjs +++ b/tools/publish.mjs @@ -4,6 +4,8 @@ import { tmpdir } from "node:os"; import path from "node:path"; import process from "node:process"; +import { stageOrder } from "./npm-release.mjs"; + const npmCli = [ process.env.npm_execpath, path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"), @@ -90,7 +92,27 @@ const publishHoldPlaceholder = async (item) => { process.stdout.write(`reserved ${item.name}@latest with ${placeholderSpec} (${integrity})\n`); }; +/** + * A staged version is promotable only with a passing component smoke of that exact registry + * object: nothing else proves the published bytes run as a component against the host ABI. + */ +const assertSmoked = (item) => { + const directory = process.env.SMOKE_RECEIPTS; + if (directory === undefined) throw new Error("SMOKE_RECEIPTS is unavailable"); + const receipt = JSON.parse(readFileSync(path.join(directory, `${item.workspace}.json`), "utf8")); + if (receipt.name !== item.name || receipt.version !== item.version || + receipt.integrity !== item.integrity) { + throw new Error(`the component smoke covered ${receipt.name}@${receipt.version} (${receipt.integrity})`); + } +}; + const operation = process.argv[2]; +const workspace = process.argv[3]; +const selected = workspace === undefined + ? manifest.packages + : manifest.packages.filter((item) => item.workspace === workspace); +if (selected.length === 0) throw new Error(`the release manifest has no ${workspace} package`); + if (operation === "bootstrap") { if (!process.env.NODE_AUTH_TOKEN) { throw new Error("the protected npm-production environment has no NPM_DIST_TAG_TOKEN"); @@ -143,7 +165,7 @@ if (operation === "bootstrap") { } } else if (operation === "stage") { const existing = new Map(); - for (const item of manifest.packages) { + for (const item of selected) { const spec = `${item.name}@${item.version}`; const integrity = registryValue(spec, "dist.integrity"); if (integrity !== undefined && integrity !== item.integrity) { @@ -151,7 +173,7 @@ if (operation === "bootstrap") { } existing.set(spec, integrity); } - for (const item of manifest.packages) { + for (const item of selected) { const spec = `${item.name}@${item.version}`; if (existing.get(spec) === undefined) { run(["publish", path.join(directory, item.filename), "--access", "public", "--tag", "next", "--provenance"], "inherit"); @@ -162,19 +184,31 @@ if (operation === "bootstrap") { } } else if (operation === "promote") { if (!process.env.NODE_AUTH_TOKEN) throw new Error("NPM_DIST_TAG_TOKEN is unavailable"); - for (const item of manifest.packages) { - assertRegistryObject(item); - const staged = registryValue(`${item.name}@next`, "version"); - if (staged !== item.version) { - throw new Error(`${item.name}@next is ${staged ?? "absent"}; refusing promotion`); + const order = stageOrder(selected); + const failures = []; + // Each package carries its own evidence, so one unproven package stays staged instead of + // holding back the release. Dependency order still decides who moves first. + for (const member of [...order.base, ...order.dependents]) { + const item = selected.find((candidate) => candidate.workspace === member); + const spec = `${item.name}@${item.version}`; + try { + assertSmoked(item); + assertRegistryObject(item); + const staged = registryValue(`${item.name}@next`, "version"); + if (staged !== item.version) { + throw new Error(`${item.name}@next is ${staged ?? "absent"}`); + } + run(["dist-tag", "add", spec, "latest"], "inherit"); + await waitFor(() => registryValue(`${item.name}@latest`, "version"), item.version, `${item.name}@latest`); + process.stdout.write(`promoted ${spec} without republishing\n`); + } catch (error) { + failures.push(`${spec}: ${error.message}`); + process.stdout.write(`held ${spec} on next: ${error.message}\n`); } } - for (const item of manifest.packages) { - const spec = `${item.name}@${item.version}`; - run(["dist-tag", "add", spec, "latest"], "inherit"); - await waitFor(() => registryValue(`${item.name}@latest`, "version"), item.version, `${item.name}@latest`); - process.stdout.write(`promoted ${spec} without republishing\n`); + if (failures.length > 0) { + throw new Error(`${failures.length} package(s) stayed on next:\n${failures.join("\n")}`); } } else { - throw new Error("usage: publish.mjs bootstrap|stage|hold|promote"); + throw new Error("usage: publish.mjs bootstrap|hold|promote | stage "); } diff --git a/tools/publish.test.mjs b/tools/publish.test.mjs new file mode 100644 index 0000000..679d1e8 --- /dev/null +++ b/tools/publish.test.mjs @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const registry = { + integrity: { + "@aexhq/model@0.1.2": "sha512-model", + "@aexhq/model-openai@0.1.3": "sha512-openai", + }, + next: { "@aexhq/model": "0.1.2", "@aexhq/model-openai": "0.1.3" }, + latest: { "@aexhq/model": "0.0.0", "@aexhq/model-openai": "0.0.0" }, +}; + +const manifest = { + schema: 1, + source: "0".repeat(40), + packages: [ + { + workspace: "model-openai", + name: "@aexhq/model-openai", + version: "0.1.3", + integrity: "sha512-openai", + needs: ["model"], + }, + { + workspace: "model", + name: "@aexhq/model", + version: "0.1.2", + integrity: "sha512-model", + needs: [], + }, + ], +}; + +const npmStub = ` + import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; + const args = process.argv.slice(2); + const state = JSON.parse(readFileSync(process.env.REGISTRY, "utf8")); + const parse = (spec) => [spec.slice(0, spec.lastIndexOf("@")), spec.slice(spec.lastIndexOf("@") + 1)]; + if (args[0] === "dist-tag") { + const [name, version] = parse(args[2]); + appendFileSync(process.env.PROMOTED, args[2] + "\\n"); + state.latest[name] = version; + writeFileSync(process.env.REGISTRY, JSON.stringify(state)); + } else { + const [name, selector] = parse(args[1]); + const value = args[2] === "dist.integrity" + ? state.integrity[args[1]] + : selector === "next" ? state.next[name] : state.latest[name]; + if (value === undefined) process.exitCode = 1; + else process.stdout.write(JSON.stringify(value)); + } +`; + +const promote = async (receipts) => { + const directory = await mkdtemp(path.join(tmpdir(), "extensions-promote-")); + const state = path.join(directory, "registry.json"); + const promoted = path.join(directory, "promoted.txt"); + const smoke = path.join(directory, "smoke"); + await mkdir(smoke); + await writeFile(state, JSON.stringify(registry)); + await writeFile(promoted, ""); + await writeFile(path.join(directory, "manifest.json"), JSON.stringify(manifest)); + await writeFile(path.join(directory, "npm-cli.mjs"), npmStub); + for (const [workspace, receipt] of Object.entries(receipts)) { + await writeFile(path.join(smoke, `${workspace}.json`), JSON.stringify(receipt)); + } + const result = spawnSync( + process.execPath, + [fileURLToPath(new URL("./publish.mjs", import.meta.url)), "promote"], + { + encoding: "utf8", + env: { + ...process.env, + EXPECTED_COMMIT: manifest.source, + NODE_AUTH_TOKEN: "token", + PROMOTED: promoted, + REGISTRY: state, + RELEASE_MANIFEST: path.join(directory, "manifest.json"), + SMOKE_RECEIPTS: smoke, + npm_execpath: path.join(directory, "npm-cli.mjs"), + }, + }, + ); + return { ...result, promoted: (await readFile(promoted, "utf8")).split("\n").filter(Boolean) }; +}; + +const receiptFor = (workspace) => { + const item = manifest.packages.find((candidate) => candidate.workspace === workspace); + return { schema: 1, workspace, name: item.name, version: item.version, integrity: item.integrity }; +}; + +test("promotion moves each package on its own evidence, in dependency order", async () => { + const result = await promote({ + model: receiptFor("model"), + "model-openai": receiptFor("model-openai"), + }); + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(result.promoted, ["@aexhq/model@0.1.2", "@aexhq/model-openai@0.1.3"]); +}); + +test("promotion fails closed when the exact version has no passing component smoke", async () => { + const missing = await promote({ model: receiptFor("model") }); + assert.notEqual(missing.status, 0); + assert.deepEqual(missing.promoted, ["@aexhq/model@0.1.2"]); + assert.match(missing.stderr, /1 package\(s\) stayed on next/u); + + // A receipt for other bytes is not evidence for these: componentize-js never rebuilds a + // component byte-for-byte, so only the registry integrity ties a smoke to the staged archive. + const stale = await promote({ + model: receiptFor("model"), + "model-openai": { ...receiptFor("model-openai"), integrity: "sha512-rebuilt" }, + }); + assert.notEqual(stale.status, 0); + assert.deepEqual(stale.promoted, ["@aexhq/model@0.1.2"]); + assert.match(stale.stdout, /held @aexhq\/model-openai@0\.1\.3/u); +}); diff --git a/tools/verify-dependencies.test.mjs b/tools/verify-dependencies.test.mjs index 2d07289..0fcd60e 100644 --- a/tools/verify-dependencies.test.mjs +++ b/tools/verify-dependencies.test.mjs @@ -70,6 +70,13 @@ test("promotion verifies the staged manifest with the current workflow source", promote, /EXPECTED_COMMIT: \$\{\{ needs\.validate\.outputs\.release_sha \}\}/u, ); + // Without these the job cannot see the stage run's receipts, and every promotion fails closed + // mid-release instead of at the pull request that dropped them. + assert.match( + promote, + /pattern: extensions-npm-smoke-\$\{\{ needs\.validate\.outputs\.release_sha \}\}-\*/u, + ); + assert.match(promote, /SMOKE_RECEIPTS: \$\{\{ runner\.temp \}\}\/extensions-npm-smoke/u); assert.doesNotMatch(promote, /node "\$RUNNER_TEMP\/extensions-npm-release\/verify-dependencies\.mjs"/u); assert.doesNotMatch(workflow, /test "\$release_sha" = "\$EXPECTED_COMMIT"/u); });