feat(runner): report a run.flow that resolves in no loaded skill tier… #302
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # REQ-UPSTREAM-CONTRACT-TESTS — the pi upgrade gate. | |
| # | |
| # Asserts the assumptions pi-dispatch pins about pi, and fails the build when one stops holding. | |
| # CONST-PI-VERSION-PINNED makes an upgrade an explicit commit that edits a version string, so this | |
| # workflow is what turns that commit into a gate rather than a hope. | |
| # | |
| # Every assertion below covers a failure that is SILENT. A crash reports itself and needs no test; | |
| # these do not. Each maps to a point where a claim about pi was verified, believed, and later refuted: | |
| # - "appendSystemPrompt composes with file discovery" -> it REPLACES it, via a `??` | |
| # - "createAgentSession reloads the loader you pass" -> only one it built itself | |
| # - "-nc is a flag you remember" -> it is an object you forget to build | |
| # - "pi never throws" -> its own JSDoc says otherwise | |
| # Each was found by reading source, not by running code. These tests are what catch the next one. | |
| name: pi upgrade check | |
| on: | |
| push: | |
| paths: | |
| - "image/**" | |
| - "worker/**" | |
| - "receiver/**" | |
| - "admin/**" | |
| - "guardrails/**" | |
| - "package.json" | |
| - "pi-packages.example.json" | |
| - "triggers.example.json" | |
| - ".github/workflows/pi-upgrade-check.yml" | |
| - ".github/scripts/admin-pi-canary.mjs" | |
| - ".github/scripts/host-pi-canary.mjs" | |
| # DELIBERATELY UNFILTERED, unlike the push trigger above. Five of the jobs below are REQUIRED status | |
| # checks on main (issue #105), and a required check that never reports blocks the merge FOREVER -- a | |
| # docs-only PR under the old path filter reported nothing at all (PR #106: "no checks reported") and | |
| # would now be unmergeable. Re-adding a `paths:` here re-opens that deadlock. | |
| # | |
| # The rejected alternative was a companion workflow carrying the complementary `paths-ignore:` list and | |
| # emitting same-named stand-in jobs: a PR touching BOTH docs/ and worker/ fires both workflows, because | |
| # paths-ignore runs whenever ANY changed file falls outside its list, and two check runs then share one | |
| # context name -- a green stand-in that can mask a red real run. The whole suite is ~90s; buying that | |
| # back is not worth a mechanism that can hide a failure. | |
| pull_request: | |
| schedule: | |
| # Weekly: pi ships breaking changes between minors and its HEAD moved within 24h of this | |
| # project's design being written. A pin that is never exercised rots silently. | |
| - cron: "0 6 * * 1" | |
| workflow_dispatch: | |
| jobs: | |
| version-pin: | |
| name: pins are exact (CONST-PI-VERSION-PINNED) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: No floating ranges on pi packages | |
| run: | | |
| # A floating range turns a silent upstream minor into every queued job becoming a | |
| # no-op with no signal, because the queue still reports success. | |
| if grep -rnE '@earendil-works/pi-[a-z-]+@[\^~]|"@earendil-works/pi-[a-z-]+":[[:space:]]*"[\^~]' image/ package.json; then | |
| echo "::error::Floating range on a pi package. CONST-PI-VERSION-PINNED requires an exact pin." | |
| exit 1 | |
| fi | |
| echo "OK: pi pinned exactly" | |
| - name: The image's pi-version LABEL matches the runner's pin | |
| run: | | |
| # The worker reads this label to decide whether a persisted transcript was written by the same | |
| # pi (REQ-RESUMABLE-SESSION). If it drifts from the runner's actual pin the failure is silent | |
| # in the worst direction: the label says the schema matches, the schema does not, and a resumed | |
| # job hands pi tool-call arguments it no longer accepts. A stale label is worse than no label, | |
| # because no label means "never resume" and a wrong one means "resume anyway". | |
| pinned=$(node -p "require('./image/runner/package.json').dependencies['@earendil-works/pi-coding-agent']") | |
| labelled=$(grep -oE 'ARG PI_VERSION=[0-9][^[:space:]]*' image/Dockerfile | cut -d= -f2) | |
| if [ "$pinned" != "$labelled" ]; then | |
| echo "::error::image/Dockerfile PI_VERSION=$labelled but image/runner/package.json pins $pinned" | |
| exit 1 | |
| fi | |
| echo "OK: image label and runner pin agree ($pinned)" | |
| - name: Base image is pinned by digest | |
| run: | | |
| # Same reasoning, different vendor. A floating base tag is the identical silent break. | |
| if ! grep -qE '^FROM .*@sha256:[0-9a-f]{64}' image/Dockerfile; then | |
| echo "::error::Base image is not digest-pinned in image/Dockerfile." | |
| exit 1 | |
| fi | |
| echo "OK: base pinned by digest" | |
| - name: The example pi-packages.json pins exact versions | |
| run: | | |
| # Third-party pi packages an operator stages into the global overlay are pinned by the SAME | |
| # reasoning as pi itself (CONST-PI-VERSION-PINNED, INT-PI-PACKAGES-FILE-CONTRACT). The grep | |
| # above is deliberately NOT extended to them: the operator's real pi-packages.json lives on | |
| # their host, not in this repo, so there is nothing here to grep. The REAL enforcement is | |
| # import-pi's load-time refusal (EXACT_VERSION_RE in worker/src/packages.mjs), which stages | |
| # nothing at all when a version is a range, a tag, or a wildcard. This step only keeps the | |
| # EXAMPLE we ship honest -- an example carrying "^1.4.2" would teach the failure mode the | |
| # refusal exists to prevent. | |
| bad=$(grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]*"' pi-packages.example.json \ | |
| | grep -vE '"version"[[:space:]]*:[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?"' || true) | |
| if [ -n "$bad" ]; then | |
| echo "::error::pi-packages.example.json carries a non-exact version: $bad" | |
| exit 1 | |
| fi | |
| echo "OK: example package pins are exact" | |
| - name: The example triggers.json names only an image this repo produces | |
| run: | | |
| # Same reasoning again, and the same honest limit. A trigger's run.image is operator data | |
| # (INT-TRIGGERS-FILE-CONTRACT): the real file lives on the operator's host, so there is nothing | |
| # here to grep, and the REAL enforcement is parseTriggers' load-time refusal plus the worker's | |
| # pre-spend `docker image inspect`. This step only keeps the EXAMPLE we ship honest -- an example | |
| # naming "my-python:latest" would teach a tag nobody has, and turn a first run into a refusal. | |
| # The list grew when this repo started producing a second image: Azure DevOps' only CLI is the | |
| # Azure CLI plus its devops extension (~1 GB of Python), which does not belong in the image every | |
| # job runs, so it ships as image/Dockerfile.azure and an azure trigger names it via run.image | |
| # (DES-IMAGE-DECLARES-ITS-FORGES). Any OTHER tag here is still refused. | |
| bad=$(grep -oE '"image"[[:space:]]*:[[:space:]]*"[^"]*"' triggers.example.json \ | |
| | grep -vE '"image"[[:space:]]*:[[:space:]]*"pi-job:(latest|azure)"' || true) | |
| if [ -n "$bad" ]; then | |
| echo "::error::triggers.example.json names an image this repo does not produce: $bad" | |
| exit 1 | |
| fi | |
| echo "OK: the example names only images this repo produces" | |
| - name: glab is pinned by version AND checksum | |
| run: | | |
| # `gh` installs from an apt channel and is this image's one unpinned dependency. A second CLI | |
| # must not repeat that: an unattended upstream change would land inside a container holding a | |
| # repo credential, which is CONST-PI-VERSION-PINNED's reasoning one layer out. The version and | |
| # both per-architecture digests are literals in the Dockerfile, and this asserts they stay. | |
| for name in GLAB_VERSION GLAB_SHA256_AMD64 GLAB_SHA256_ARM64; do | |
| if ! grep -qE "^ARG ${name}=[0-9a-f.]+$" image/Dockerfile; then | |
| echo "::error::${name} is missing or not a literal pin in image/Dockerfile." | |
| exit 1 | |
| fi | |
| done | |
| if ! grep -q 'sha256sum -c -' image/Dockerfile; then | |
| echo "::error::The glab download is not checksum-verified." | |
| exit 1 | |
| fi | |
| echo "OK: glab pinned by version and verified by checksum" | |
| - name: tea is pinned by version AND checksum | |
| run: | | |
| # Identical reasoning to glab's, and issue #61 asks for it by name -- "a second CLI should not | |
| # repeat that". `gh` remains this image's one unpinned dependency; every CLI added since is | |
| # pinned to an exact version and verified against a per-architecture digest. | |
| for name in TEA_VERSION TEA_SHA256_AMD64 TEA_SHA256_ARM64; do | |
| if ! grep -qE "^ARG ${name}=[0-9a-f.]+$" image/Dockerfile; then | |
| echo "::error::${name} is missing or not a literal pin in image/Dockerfile." | |
| exit 1 | |
| fi | |
| done | |
| echo "OK: tea pinned by version and verified by checksum" | |
| - name: The azure image variant pins the Azure CLI and declares its forges | |
| run: | | |
| # The variant is built from the default image, so it inherits the base digest, the pi pin and the | |
| # pi-version label rather than re-declaring them -- a second copy of those is a second thing to | |
| # forget to bump. What it MUST declare itself is the Azure CLI version and the forge list the | |
| # worker's pre-spend preflight reads: a label naming a forge whose CLI is absent would turn a | |
| # loud refusal into a paid container that fails at step 3 (DES-IMAGE-DECLARES-ITS-FORGES). | |
| if ! grep -qE '^ARG AZURE_CLI_VERSION=[0-9]' image/Dockerfile.azure; then | |
| echo "::error::AZURE_CLI_VERSION is missing or not a literal pin in image/Dockerfile.azure." | |
| exit 1 | |
| fi | |
| if ! grep -q 'dev.pi-dispatch.forges=.*azure' image/Dockerfile.azure; then | |
| echo "::error::image/Dockerfile.azure does not declare azure in dev.pi-dispatch.forges." | |
| exit 1 | |
| fi | |
| if ! grep -q 'dev.pi-dispatch.forges=' image/Dockerfile; then | |
| echo "::error::image/Dockerfile does not declare dev.pi-dispatch.forges." | |
| exit 1 | |
| fi | |
| echo "OK: the azure variant pins its CLI and declares its forges" | |
| no-automatic-merge: | |
| name: no automatic merge (CONST-MERGE-NEVER-AUTOMATIC) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: No merge API call anywhere | |
| run: | | |
| # CONST-MERGE-NEVER-AUTOMATIC: "grep is the test". A match is a merge symbol reaching | |
| # the code — the human review step is the last line and is not negotiable. | |
| # | |
| # The pattern has TWO surfaces since GitLab landed (issue #42). A grep that still only knew | |
| # GitHub's would pass while `PUT /projects/:id/merge_requests/:iid/merge` sat in the worker: | |
| # the constraint would read as enforced and would not be. | |
| if grep -rnE 'pulls\.merge|gh pr merge|mergePullRequest|merge_pull_request|[Aa]utoMerge|/pulls/.*/merge|glab mr merge|merge_requests/[^ ]*/merge|acceptMergeRequest' worker/src image/runner receiver/src; then | |
| echo "::error::Merge symbol found. CONST-MERGE-NEVER-AUTOMATIC forbids any merge call." | |
| exit 1 | |
| fi | |
| echo "OK: no merge symbols (both forges)" | |
| contract-tests: | |
| name: pinned assumptions still hold (offline, no API key) | |
| runs-on: ubuntu-latest | |
| services: | |
| # A real Valkey backs the queue integration test (enqueue + dedup). AOF is not needed for a | |
| # test run; the point is a genuine Redis-protocol server, not the fake. | |
| valkey: | |
| image: valkey/valkey:8 | |
| ports: | |
| - 6379:6379 | |
| options: >- | |
| --health-cmd "valkey-cli ping" --health-interval 10s --health-timeout 3s --health-retries 5 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version-file: ".nvmrc" # pi requires >=22.19.0; engine-strict enforces it | |
| - run: npm ci --no-audit --no-fund | |
| # The scariest assertions in this project are FREE: every trap lives at the resource-loader | |
| # boundary (pure) or at before_agent_start (which fires strictly before any provider HTTP | |
| # call). No API key, no tokens, no flake, no excuse not to run them on every build. | |
| # | |
| # PI_DISPATCH_REQUIRE_*_TESTS=1 turns a skip into a hard failure. A skipped assertion is an | |
| # UNVERIFIED assertion, and "skipped = pass" is precisely the reasoning that lets a | |
| # guardrail-less agent ship green. VALKEY_TEST_URL activates the queue integration test. | |
| # | |
| # Cron/scheduler assumptions this run pins (BullMQ 5.80.4), each SILENT if it drifts: | |
| # - no-backfill / no-overlap / deterministic repeat: jobId -> specs/design.md:206-216 | |
| # - must-handle -10/-11 (schedule edit else silent no-op) -> specs/design.md:231 | |
| # - scheduler jobs bypass maxStalledCount (stall carve-out) -> specs/constitution.md:203-216 | |
| # Exercised by worker/test/cron.integration.test.mjs (VALKEY_TEST_URL + PI_DISPATCH_REQUIRE_WORKER_TESTS gate it live). | |
| - name: Contract tests -- guardrails, -nc, exit codes, env allowlist, queue, receiver enqueue, cron (all required) | |
| env: | |
| PI_DISPATCH_REQUIRE_LOADER_TESTS: "1" | |
| PI_DISPATCH_REQUIRE_WORKER_TESTS: "1" | |
| PI_DISPATCH_REQUIRE_RECEIVER_TESTS: "1" | |
| VALKEY_TEST_URL: "redis://127.0.0.1:6379" | |
| run: npm test | |
| image: | |
| name: the job image holds its contract | |
| runs-on: ubuntu-latest | |
| # CORE vs RUNNER, and the line matters. CORE is what ANY image must satisfy to be nameable in a | |
| # trigger's `run.image` -- it lives in image/verify-image.sh, which this job calls, so the portable | |
| # checklist has ONE definition rather than a copy here and a copy in the docs. RUNNER asserts properties | |
| # of the runner THIS repo ships (its exact refusal strings, a path under /app a foreign image need not | |
| # have) and stays here, because it is not portable. That distinction is the difference between | |
| # "conformant" and "built from this repo", which docs/job-image.md and OQ-012 both have to draw. | |
| # | |
| # An operator verifying THEIR image runs the script directly, on the host that holds it. It cannot be a | |
| # workflow input: jobs launch with --pull=never, so the images pi-dispatch can run are exactly the ones | |
| # on the worker's own machine, and a GitHub runner has no access to those. | |
| env: | |
| IMAGE_REF: pi-job:ci | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Build | |
| run: docker build -f image/Dockerfile -t pi-job:ci . | |
| # --- CORE: nothing may make docker fetch an image (INT-CONTAINER-RUNTIME-CONTRACT) --- | |
| # The argv-side proof is the frozen-intent deepEqual in worker/test/docker-run.test.mjs, which runs in | |
| # contract-tests. THIS step asserts docker's own behaviour, and exists only because a flag an older | |
| # docker accepts and silently disregards looks identical to a working one -- the exact silent class | |
| # this workflow is for. Same register as the PI_OFFLINE probe further down. | |
| - name: "CORE: --pull=never is honoured, not silently ignored" | |
| run: | | |
| if docker run --rm --pull=never pi-dispatch-definitely-not-present:ci true 2>/dev/null; then | |
| echo "::error::docker ran an absent image under --pull=never -- the flag is being ignored." | |
| exit 1 | |
| fi | |
| echo "OK: --pull=never refuses an image that is not local" | |
| # --- INT-SDK-SESSION-OPTIONS --- | |
| # `pi --mode print` does not exist: --mode accepts text|json|rpc, and --print/-p is a separate | |
| # boolean. --help rather than a real prompt, because a real prompt needs a paid API key and a | |
| # contract test that costs money is a contract test that gets disabled. | |
| # --entrypoint is required: ENTRYPOINT is the runner and ignores CMD, so `docker run img pi ...` | |
| # would silently run the runner instead of pi and the assertion would test nothing. | |
| # The CORE checklist, from the single definition an operator also runs against their own image | |
| # (docs/job-image.md). Kept as a script rather than inlined steps so the portable half cannot drift | |
| # from what the docs promise: pi present, CapEff=0, non-root, /job:ro enforced by the kernel, a | |
| # writable agent dir, guardrails present AND unwritable, Chromium rendering a real page, fonts. | |
| - name: "CORE: the conformance checklist any nameable image must pass" | |
| run: ./image/verify-image.sh "$IMAGE_REF" | |
| # --- INT-CONTAINER-JOB-INPUTS / REQ-GLOBAL-PI-OVERLAY --- | |
| # A staged package that did not mount must be refused BEFORE any spend. This is the one failure | |
| # that pi will not report for us: it SKIPS a local package source that does not resolve -- no | |
| # error, no diagnostic -- so an unmounted package otherwise runs the flow to a clean exit 0 | |
| # without the tools it was written for, and reports success for work it could not have done. | |
| # | |
| # NO --entrypoint here, deliberately: the assertion is about the RUNNER, which is the entrypoint. | |
| # The exit code alone would be vacuous (a missing /job/prompt.md is also exit 2), so the message | |
| # is asserted too -- and the second run is the differential that proves the packages check fires | |
| # FIRST, before the prompt is even read. | |
| - name: "RUNNER: a staged package path that did not mount is refused pre-spend (exit 2)" | |
| run: | | |
| run_runner() { | |
| docker run --rm --network none \ | |
| -e PI_PROVIDER=anthropic -e PI_MODEL=claude-sonnet-4-5-20250929 -e PI_MAX_TURNS=1 "$@" \ | |
| "$IMAGE_REF" 2>&1 | |
| } | |
| set +e | |
| out=$(run_runner -e PI_PACKAGES=/opt/pi-global/packages/definitely-not-mounted) | |
| code=$? | |
| set -e | |
| echo "$out" | |
| [ "$code" = "2" ] || { echo "::error::PI_PACKAGES pointing at a missing dir exited $code, want 2 (policy, not retried)."; exit 1; } | |
| echo "$out" | grep -q "staged package path does not exist" \ | |
| || { echo "::error::exit 2 came from something other than the staged-package check -- the assertion is vacuous."; exit 1; } | |
| # Differential: with PI_PACKAGES unset the SAME invocation dies later, on the missing prompt. | |
| # That is what shows the packages check runs before the job inputs are read, i.e. pre-spend. | |
| set +e | |
| out2=$(run_runner) | |
| code2=$? | |
| set -e | |
| [ "$code2" = "2" ] || { echo "::error::control run exited $code2, want 2"; exit 1; } | |
| echo "$out2" | grep -q "missing job input" \ | |
| || { echo "::error::control run did not fail on the prompt -- the ordering claim is unproven."; exit 1; } | |
| echo "OK: staged-package refusal precedes the job-input read" | |
| # --- INT-CONTAINER-RUNTIME-CONTRACT (PI_OFFLINE=1) --- | |
| # pi's resolver shells out to a REAL `npm install` for any npm:/git: source unless offline mode | |
| # is on, and ~/.pi/agent IS writable in the container -- so without this flag an unresolved | |
| # source is a live network install of third-party code at agent runtime. With it, | |
| # resolvePackageSources takes the `installMissing() -> false -> continue` branch: a clean skip. | |
| # | |
| # ONLY the PI_OFFLINE=1 half is asserted, on purpose. The obvious negative half (same run with | |
| # the variable unset, expecting failure) would be VACUOUS here: under `--network none` that run | |
| # fails because the network is off, not because pi refused, so a green would be asserting that | |
| # docker's --network none works. Distinguishing the two would mean asserting on npm's retry | |
| # timing or its error text, neither of which is a contract we can pin. The other direction -- | |
| # unset, WITH a network -- is exactly the live third-party install this flag exists to prevent, | |
| # so it is not something to run in CI either. The real enforcement is that the worker sets | |
| # PI_OFFLINE=1 on every job and the runner re-asserts it in-process before the loader is built. | |
| # | |
| # The three suppression flags MIRROR image/runner/src/loader.mjs (noContextFiles:false, | |
| # noExtensions:false, noSkills:true) rather than suppressing everything, so this probe exercises | |
| # the configuration a job actually runs. They had drifted to all-true, which was the old posture | |
| # (CONST-NO-CONTEXT-FILES-MANDATORY, since amended); the probe still passed, which is exactly the | |
| # problem -- a probe that green-lights a config nothing uses proves nothing about the one that does. | |
| # | |
| # Syncing them does NOT weaken what is asserted, because the npm: source does not travel through | |
| # the flag: resolveExtensionSources reads additionalExtensionPaths into cliExtensionPaths BEFORE | |
| # the branch, and noExtensions only decides whether DISCOVERED paths merge in afterwards. But a | |
| # bare `count === 0` WOULD have become weaker -- with discovery on it also asserts "nothing was | |
| # discovered anywhere", which is true here (the image's ~/.pi/agent is empty and /tmp has no .pi/) | |
| # and would silently stop being the thing under test the day either changed. So the assertion is | |
| # a DIFFERENCE against the same loader without the npm: source: whatever discovery contributes, | |
| # it contributes to both, and only the unresolvable source can move the number. | |
| - name: "RUNNER: PI_OFFLINE=1 turns an unresolvable npm: source into a clean skip" | |
| run: | | |
| PROBE='import { DefaultResourceLoader, SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent"; | |
| const build = (additionalExtensionPaths) => new DefaultResourceLoader({ | |
| cwd: "/tmp", agentDir: getAgentDir(), settingsManager: SettingsManager.inMemory({}), | |
| noContextFiles: false, noSkills: true, noExtensions: false, | |
| additionalExtensionPaths, | |
| }); | |
| const count = async (paths) => { const l = build(paths); await l.reload(); return l.getExtensions().extensions.length; }; | |
| const baseline = await count([]); | |
| const withSource = await count(["npm:@pi-dispatch/definitely-does-not-exist"]); | |
| if (withSource !== baseline) { console.error("the unresolvable npm: source changed the loaded set: " + baseline + " -> " + withSource); process.exit(1); } | |
| console.log("OK: offline skip, nothing installed (loaded " + baseline + " either way)");' | |
| docker run --rm --network none -e PI_OFFLINE=1 -e PROBE="$PROBE" \ | |
| --entrypoint sh "$IMAGE_REF" -c 'cd /app && node --input-type=module -e "$PROBE"' \ | |
| || { echo "::error::PI_OFFLINE=1 did not make an unresolvable npm: source a clean skip -- pi may have tried to install at load time."; exit 1; } | |
| # --- DES-USAGE-METER-VIA-API-PROVIDER-REGISTRY (trap (g) in INT-SDK-SESSION-OPTIONS) --- | |
| # The meter must bind pi-coding-agent's OWN nested pi-ai: that is the module-level api-provider | |
| # registry a running session dispatches through, and a bare `import "@earendil-works/pi-ai"` binds | |
| # something else and meters nothing while reporting success. | |
| # | |
| # What this step deliberately does NOT assert is "pi-ai is installed twice". That is FALSE HERE, and | |
| # an earlier version of this step failed on it. The image installs the ROOT lockfile against | |
| # `image/runner/package.json` ONLY, which declares pi-coding-agent and @playwright/cli and NOT | |
| # pi-ai; the hoisted copy a dev checkout has is the WORKER's dependency, and the worker is not | |
| # installed in this image. So in the image the nested copy is the ONLY copy and a bare specifier | |
| # does not resolve at all. The dual-copy fact is still pinned, just where it is true: | |
| # image/runner/test/pinned-api.test.mjs asserts it in the contract-tests job, which does a full | |
| # workspace install. Duplicating it here would only re-assert a dev-box layout inside a container | |
| # that does not have it. | |
| # | |
| # So assert what IS load-bearing here, and assert it by running OUR OWN resolver inside the built | |
| # image rather than re-deriving the layout in shell -- a shell re-derivation encodes a rule the | |
| # runner does not follow, since resolvePiAiCompat wraps BOTH lookups in tryResolve and skips an | |
| # unresolvable candidate instead of throwing. No `cd /app`: the import is an absolute file:// URL | |
| # and resolvePiAiCompat resolves from usage-meter.mjs's own URL, which is what lets the real runner | |
| # work from cwd=/workspace. | |
| - name: "RUNNER: the meter's resolver offers pi-coding-agent's nested pi-ai first, inside the image" | |
| run: | | |
| PROBE='import { existsSync } from "node:fs"; | |
| import { fileURLToPath } from "node:url"; | |
| import { resolvePiAiCompat } from "file:///app/image/runner/src/usage-meter.mjs"; | |
| const candidates = resolvePiAiCompat(); | |
| if (candidates.length === 0) { console.error("resolvePiAiCompat() offered NO pi-ai compat candidate at all"); process.exit(1); } | |
| if (candidates[0].tag !== "nested") { console.error("first candidate is tagged " + candidates[0].tag + ", want nested"); process.exit(1); } | |
| if (!existsSync(fileURLToPath(candidates[0].url))) { console.error("the nested candidate is not on disk"); process.exit(1); } | |
| const mod = await import(candidates[0].url); | |
| for (const fn of ["getApiProvider", "getApiProviders", "registerApiProvider", "resetApiProviders"]) { | |
| if (typeof mod[fn] !== "function") { console.error("the nested compat copy no longer exports " + fn + "()"); process.exit(1); } | |
| } | |
| console.log("OK: " + candidates.length + " candidate(s), nested first, compat surface intact");' | |
| docker run --rm --network none -e PROBE="$PROBE" \ | |
| --entrypoint sh "$IMAGE_REF" -c 'node --input-type=module -e "$PROBE"' \ | |
| || { echo "::error::The usage meter no longer offers pi-coding-agent's NESTED pi-ai first in this image. That copy owns the module-level api-provider registry every session dispatches through, so it is the only one worth metering; installProcessUsageMeter still DECIDES by runtime mutation probe and would fall back, so this step is about the ORDER and the surface, not the decision. If the layout moved, re-verify in this order: resolvePiAiCompat's nested-path derivation in image/runner/src/usage-meter.mjs, the compat exports the probe and the wrapper call (getApiProvider/getApiProviders/registerApiProvider/resetApiProviders, createAssistantMessageEventStream), trap (g) in specs/interfaces.md, and image/runner/test/pinned-api.test.mjs -- which pins the dual-copy layout in contract-tests, where the worker's hoisted pi-ai is installed too."; exit 1; } | |
| admin-extension-canary: | |
| # The admin extension is the default front door (issue #96): it is TESTED against an exact pi | |
| # (SUPPORTED_PI_VERSION, locked to the admin devDependency pin by admin/test/load.test.mjs) but | |
| # declares a "*" peer, because pi never installs peers for host-provided packages and an exact | |
| # peer pin ERESOLVEs every plain-npm consumer whose pi differs by a patch -- the full reasoning | |
| # lives in .github/scripts/admin-pi-canary.mjs's header. A wildcard peer without a canary is a | |
| # hope; this job is the canary. It installs pi@latest into a SCRATCH dir and asserts the admin's | |
| # pinned assumptions against THAT install: the pinned-api needle list, the USED_API members on | |
| # ExtensionAPI, the runtime VERSION export, and an actual load-and-register of the built bundle. | |
| # | |
| # Failure protocol: a red run here means pi moved underneath the admin. Retest locally against | |
| # the new pi, bump SUPPORTED_PI_VERSION and the admin devDependency pin TOGETHER (load.test.mjs | |
| # locks them to each other), and republish @edgehero/pi-dispatch-admin. | |
| name: the admin extension survives latest pi (canary) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version-file: ".nvmrc" | |
| # NEVER install anything extra into the repo root. admin/test/load.test.mjs and | |
| # admin/test/pinned-extension-api.test.mjs anchor on the HOISTED PIN the lockfile resolves; a | |
| # root `npm install @earendil-works/pi-coding-agent@latest` would silently flip both from | |
| # pinned-version assertions into HEAD assertions -- green-lighting exactly the drift they | |
| # exist to catch. The @latest copy goes into a mktemp scratch dir outside the checkout. | |
| - run: npm ci --no-audit --no-fund | |
| - run: node admin/build.mjs | |
| - name: Install pi@latest into a scratch dir (never the repo) | |
| run: | | |
| SCRATCH=$(mktemp -d) | |
| echo "SCRATCH=$SCRATCH" >> "$GITHUB_ENV" | |
| cd "$SCRATCH" | |
| npm init -y >/dev/null | |
| # Distinguish failures (release.yml's npm-view doctrine): an install error here is | |
| # infrastructure -- network, registry, npm itself -- NOT a canary verdict about pi, and | |
| # reading it as either "pi broke us" or "all clear" would be a guess. Fail loudly instead. | |
| if ! npm install @earendil-works/pi-coding-agent@latest --no-audit --no-fund; then | |
| echo "::error::npm install @earendil-works/pi-coding-agent@latest failed -- an infrastructure failure, not a pi-drift verdict. Re-run before reading anything into it." | |
| exit 1 | |
| fi | |
| node -p "'canary pi: ' + require('$SCRATCH/node_modules/@earendil-works/pi-coding-agent/package.json').version" | |
| - name: Run the canary against the scratch pi | |
| run: | | |
| # The bundle is copied INTO the scratch dir so its bare pi import resolves against | |
| # scratch/node_modules -- ESM resolves bare specifiers from the importing file's own | |
| # location, which is what keeps the repo's hoisted pin out of the probe entirely. The | |
| # canary script re-copies fresh and asserts (a)-(d); see its header for the mechanics. | |
| cp admin/dist/index.mjs "$SCRATCH/admin-bundle.mjs" | |
| node .github/scripts/admin-pi-canary.mjs "$SCRATCH" | |
| host-pi-canary: | |
| # `worker/src/host-pi.mjs` MIRRORS private pi internals (issue #102): where `pi install` puts a | |
| # package, and whether `pi config` disabled a resource. pi exports no public answer to either, which | |
| # is the residual recorded as OQ-018. `worker/test/host-pi.pinned.test.mjs` gates the PINNED version | |
| # inside contract-tests as a hard failure; this job is the advance warning against `latest`, and both | |
| # read one shared needle list (PINNED_PI_NEEDLES) so the gate and the canary cannot drift apart. | |
| # | |
| # Its OWN job rather than a step on the admin canary, deliberately. Sharing that job made a run named | |
| # "the admin extension survives latest pi" go red for a reason that had nothing to do with the admin | |
| # extension, which is a mislabelled signal however accurate the annotation underneath it was. Separate | |
| # jobs also mean an admin failure no longer hides this result, and vice versa. | |
| # | |
| # Failure protocol: drift here is a `::warning::` and the job stays GREEN, because nothing shipped is | |
| # broken -- the worker pins pi, and the pinned test is what fails when that pin is wrong. A RED run | |
| # means the canary could not execute at all (pi did not install), which is infrastructure and says | |
| # nothing either way about drift. See the script header for the full reasoning. | |
| name: host-pi mirrors survive latest pi (canary) | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version-file: ".nvmrc" | |
| # No `npm ci`: this job reads two source files and one scratch install, and never imports the | |
| # repo's own pinned pi -- installing it would be the drift-hiding mistake the admin canary's own | |
| # comment warns about. | |
| - name: Install pi@latest into a scratch dir (never the repo) | |
| run: | | |
| SCRATCH=$(mktemp -d) | |
| echo "SCRATCH=$SCRATCH" >> "$GITHUB_ENV" | |
| cd "$SCRATCH" | |
| npm init -y >/dev/null | |
| # Same doctrine as the admin canary: an install error is infrastructure, NOT a drift verdict. | |
| if ! npm install @earendil-works/pi-coding-agent@latest --no-audit --no-fund; then | |
| echo "::error::npm install @earendil-works/pi-coding-agent@latest failed -- an infrastructure failure, not a pi-drift verdict. Re-run before reading anything into it." | |
| exit 1 | |
| fi | |
| node -p "'canary pi: ' + require('$SCRATCH/node_modules/@earendil-works/pi-coding-agent/package.json').version" | |
| - name: "pi's package-install paths and enablement grammar still read the way host-pi.mjs assumes" | |
| run: node .github/scripts/host-pi-canary.mjs "$SCRATCH" |