Skip to content

Commit 030aa31

Browse files
authored
Merge pull request #97 from edgehero/feat/dispatch-default-route
feat: /dispatch as the default setup route, and a service-unit fix for npm deployments (issue #96)
2 parents d9d8193 + 371c6ec commit 030aa31

23 files changed

Lines changed: 1976 additions & 265 deletions
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
/**
2+
* The admin extension's pi canary (issue #96). Runs the admin's pinned-pi assumptions against an
3+
* ARBITRARY pi install -- in CI, @latest -- so a breaking pi release fails a build here instead of
4+
* an operator's /dispatch there.
5+
*
6+
* Why this exists at all: admin/package.json declares `"@earendil-works/pi-coding-agent": "*"` as
7+
* its peer range. That is deliberate, and this header is where the reason is recorded (JSON takes
8+
* no comments): pi's own packages doc prescribes `"*"` peers for host-provided packages -- pi never
9+
* installs a peer, the host session IS the pi -- and an exact peer pin makes every plain-npm
10+
* consumer ERESOLVE the moment their pi differs by a patch. The exact TESTED version still lives in
11+
* two places locked to each other: `SUPPORTED_PI_VERSION` in admin/src/index.ts and the admin
12+
* devDependency pin (admin/test/load.test.mjs asserts they cannot drift). A wildcard peer without a
13+
* canary would be a hope; this script is what keeps it honest.
14+
*
15+
* Usage: node .github/scripts/admin-pi-canary.mjs <scratch-dir>
16+
* where <scratch-dir> holds an `npm install @earendil-works/pi-coding-agent` (any version, in CI
17+
* @latest) under <scratch-dir>/node_modules. Never point it at the repo root: the repo's hoisted
18+
* copy is the PIN the admin test suite anchors on, and canarying it would assert nothing.
19+
*
20+
* Asserts, against THAT install:
21+
* (a) every needle from the admin's pinned-api needle list appears in its
22+
* dist/core/extensions/types.d.ts
23+
* (b) every USED_API member is declared as a method on its `interface ExtensionAPI`
24+
* (c) `VERSION` is a runtime string export of the package root
25+
* (d) the BUILT admin bundle actually loads with that pi resolvable and registers exactly one
26+
* `dispatch` command with no stderr refusal line
27+
*
28+
* Resolution mechanics for (d): the bundle (admin/dist/index.mjs, pi kept external by
29+
* admin/build.mjs) is COPIED into the scratch dir. ESM resolves a bare specifier against the
30+
* IMPORTING FILE's own location, so the copy's `@earendil-works/pi-coding-agent` import walks up
31+
* from <scratch-dir> and finds <scratch-dir>/node_modules -- the install under test -- never the
32+
* repo's hoisted pin. bullmq/ioredis are external in the bundle too but are NOT under test; they
33+
* are satisfied by symlinking the repo's own pinned copies into the scratch node_modules (offline,
34+
* deterministic, no second install).
35+
*/
36+
37+
import { copyFileSync, existsSync, mkdtempSync, readFileSync, symlinkSync } from "node:fs";
38+
import { dirname, join, resolve } from "node:path";
39+
import { fileURLToPath, pathToFileURL } from "node:url";
40+
41+
/**
42+
* Source of truth: admin/test/pinned-extension-api.test.mjs. That test asserts these against the
43+
* PINNED pi and may not import from (or be edited for) a CI script; this is a deliberate second
44+
* copy, and admin/test/wiring.test.mjs is the anti-drift bolt: it imports these exports and fails
45+
* the suite when they diverge from the pinned test's literals or from the real USED_API export.
46+
*/
47+
export const NEEDLES = [
48+
"registerCommand(name",
49+
"registerTool<TParams",
50+
"sendMessage<T",
51+
"getArgumentCompletions",
52+
"custom<T>(",
53+
"notify(message",
54+
"confirm(title",
55+
"select(title",
56+
"input(title",
57+
"session_start",
58+
"executionMode",
59+
];
60+
61+
/** Mirrors USED_API in admin/src/index.ts -- deepEqual-checked by wiring.test.mjs (same bolt). */
62+
export const USED_API_MEMBERS = ["registerCommand", "registerTool", "sendMessage", "on"];
63+
64+
const here = dirname(fileURLToPath(import.meta.url));
65+
const repoRoot = resolve(here, "..", "..");
66+
67+
/**
68+
* Extract the body text of a named `interface X { ... }` by brace balancing. A copy of the helper
69+
* in admin/test/pinned-extension-api.test.mjs (see the NEEDLES note on why a copy).
70+
*/
71+
function extractInterface(src, name) {
72+
const start = src.indexOf(`interface ${name} {`);
73+
if (start === -1) return null;
74+
let depth = 0;
75+
for (let i = src.indexOf("{", start); i < src.length; i++) {
76+
if (src[i] === "{") depth++;
77+
else if (src[i] === "}" && --depth === 0) return src.slice(start, i + 1);
78+
}
79+
return null;
80+
}
81+
82+
let failures = 0;
83+
function fail(msg) {
84+
failures++;
85+
console.error(`canary FAIL: ${msg}`);
86+
}
87+
function ok(msg) {
88+
console.log(`canary ok: ${msg}`);
89+
}
90+
91+
async function main(scratchArg) {
92+
const scratch = resolve(scratchArg);
93+
const piDir = join(scratch, "node_modules", "@earendil-works", "pi-coding-agent");
94+
if (!existsSync(join(piDir, "package.json"))) {
95+
console.error(`canary: no pi install at ${piDir} -- npm install @earendil-works/pi-coding-agent into the scratch dir first`);
96+
process.exit(2);
97+
}
98+
const piPkg = JSON.parse(readFileSync(join(piDir, "package.json"), "utf8"));
99+
console.log(`canary: probing @earendil-works/pi-coding-agent ${piPkg.version} at ${piDir}`);
100+
101+
// ---- (a) the needle list still appears in the install's extension types ----
102+
const typesPath = join(piDir, "dist", "core", "extensions", "types.d.ts");
103+
let typesSrc;
104+
try {
105+
typesSrc = readFileSync(typesPath, "utf8");
106+
} catch (err) {
107+
fail(`cannot read ${typesPath}: ${err.message} -- pi moved its extension type declarations`);
108+
}
109+
if (typesSrc !== undefined) {
110+
const missing = NEEDLES.filter((needle) => !typesSrc.includes(needle));
111+
if (missing.length > 0) {
112+
fail(`extensions/types.d.ts no longer contains: ${missing.map((n) => JSON.stringify(n)).join(", ")}`);
113+
} else {
114+
ok(`(a) all ${NEEDLES.length} pinned-api needles present`);
115+
}
116+
117+
// ---- (b) every USED_API member is a method on this install's ExtensionAPI ----
118+
const block = extractInterface(typesSrc, "ExtensionAPI");
119+
if (!block) {
120+
fail("could not find `interface ExtensionAPI` in the install's types.d.ts");
121+
} else {
122+
let allMembers = true;
123+
for (const member of USED_API_MEMBERS) {
124+
if (!new RegExp(`\\b${member}\\s*[<(]`).test(block)) {
125+
allMembers = false;
126+
fail(`ExtensionAPI no longer declares "${member}" as a method`);
127+
}
128+
}
129+
if (allMembers) ok(`(b) all ${USED_API_MEMBERS.length} USED_API members declared on ExtensionAPI`);
130+
}
131+
}
132+
133+
// ---- (c) VERSION is a runtime string export of the package root ----
134+
const dot = piPkg.exports?.["."];
135+
const entryRel = typeof dot === "string" ? dot : (dot?.import ?? dot?.default ?? piPkg.main ?? "./dist/index.js");
136+
try {
137+
const piMod = await import(pathToFileURL(join(piDir, entryRel)).href);
138+
if (typeof piMod.VERSION === "string") {
139+
ok(`(c) VERSION is exported and a string ("${piMod.VERSION}")`);
140+
} else {
141+
fail(`the package root exports VERSION as ${typeof piMod.VERSION}, want string -- the admin's runtime advisory depends on it`);
142+
}
143+
} catch (err) {
144+
fail(`importing the package root (${entryRel}) threw: ${err.message}`);
145+
}
146+
147+
// ---- (d) the built admin bundle loads with THIS pi and registers cleanly ----
148+
const builtBundle = join(repoRoot, "admin", "dist", "index.mjs");
149+
if (!existsSync(builtBundle)) {
150+
console.error("canary: admin/dist/index.mjs is missing -- run `node admin/build.mjs` first");
151+
process.exit(2);
152+
}
153+
// Always copy fresh so the probe can never run against a stale bundle left in the scratch dir.
154+
const bundleCopy = join(scratch, "admin-bundle.mjs");
155+
copyFileSync(builtBundle, bundleCopy);
156+
// The bundle's other externals (heavy runtime deps, not under test) resolve to the repo's own
157+
// pinned install via symlink -- nothing is ever network-installed here, and the scratch pi stays
158+
// the only pi in the resolution path.
159+
for (const dep of ["bullmq", "ioredis"]) {
160+
const target = join(scratch, "node_modules", dep);
161+
if (!existsSync(target)) symlinkSync(join(repoRoot, "node_modules", dep), target, "dir");
162+
}
163+
// Hermeticity: the factory layers the deployment pointer from pi's agent dir at load; pin that to
164+
// an empty dir under the scratch so the probe can never read (or be steered by) a real ~/.pi/agent.
165+
process.env.PI_CODING_AGENT_DIR = mkdtempSync(join(scratch, "canary-agent-"));
166+
delete process.env.PI_DISPATCH_DEPLOYMENT_FILE;
167+
168+
try {
169+
const mod = await import(pathToFileURL(bundleCopy).href);
170+
if (typeof mod.default !== "function") throw new Error("the bundle's default export is not a factory function");
171+
if (JSON.stringify([...mod.USED_API].sort()) !== JSON.stringify([...USED_API_MEMBERS].sort())) {
172+
fail(`the bundle's USED_API (${mod.USED_API}) differs from the canary's list (${USED_API_MEMBERS}) -- update USED_API_MEMBERS here`);
173+
}
174+
175+
// A recording proxy exposing exactly the USED_API members; anything else the factory reaches
176+
// for throws, mirroring admin/test/wiring.test.mjs's discipline.
177+
const registered = [];
178+
const pi = new Proxy(
179+
{},
180+
{
181+
get(_target, key) {
182+
if (typeof key !== "string") return undefined;
183+
if (key === "registerCommand") return (name, def) => registered.push([name, def]);
184+
if (USED_API_MEMBERS.includes(key)) return () => {};
185+
throw new Error(`admin extension reached a non-USED_API pi member: ${key}`);
186+
},
187+
},
188+
);
189+
const stderrLines = [];
190+
const origError = console.error;
191+
console.error = (...a) => stderrLines.push(a.join(" "));
192+
try {
193+
mod.default(pi);
194+
} finally {
195+
console.error = origError;
196+
}
197+
if (stderrLines.length > 0) {
198+
fail(`the factory printed to stderr (the refusal path fired?): ${stderrLines[0]}`);
199+
} else if (registered.length !== 1 || registered[0][0] !== "dispatch") {
200+
fail(`expected exactly one registerCommand("dispatch"), got: ${JSON.stringify(registered.map(([n]) => n))}`);
201+
} else if (typeof registered[0][1]?.handler !== "function") {
202+
fail("the registered dispatch command has no handler function");
203+
} else {
204+
ok(`(d) the built bundle loads against pi ${piPkg.version} and registers /dispatch cleanly`);
205+
}
206+
} catch (err) {
207+
fail(`loading the admin bundle against pi ${piPkg.version} threw: ${err.message}`);
208+
}
209+
210+
if (failures > 0) {
211+
console.error(
212+
`canary: ${failures} failure(s) against pi ${piPkg.version}. pi moved underneath the admin: ` +
213+
"retest locally, bump SUPPORTED_PI_VERSION and the admin devDependency pin together " +
214+
"(load.test.mjs locks them to each other), and republish @edgehero/pi-dispatch-admin.",
215+
);
216+
process.exit(1);
217+
}
218+
console.log(`canary: PASS against pi ${piPkg.version}`);
219+
}
220+
221+
// Main-module guard: wiring.test.mjs imports this file for NEEDLES/USED_API_MEMBERS, and an import
222+
// must never run the probe.
223+
const isMain = process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url;
224+
if (isMain) {
225+
const scratchArg = process.argv[2];
226+
if (!scratchArg) {
227+
console.error("usage: node .github/scripts/admin-pi-canary.mjs <scratch-dir>");
228+
process.exit(2);
229+
}
230+
await main(scratchArg);
231+
}

.github/workflows/pi-upgrade-check.yml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,25 @@ on:
2020
- "image/**"
2121
- "worker/**"
2222
- "receiver/**"
23+
- "admin/**"
2324
- "guardrails/**"
2425
- "package.json"
2526
- "pi-packages.example.json"
2627
- "triggers.example.json"
2728
- ".github/workflows/pi-upgrade-check.yml"
29+
- ".github/scripts/admin-pi-canary.mjs"
2830
pull_request:
2931
paths:
3032
- "image/**"
3133
- "worker/**"
3234
- "receiver/**"
35+
- "admin/**"
3336
- "guardrails/**"
3437
- "package.json"
3538
- "pi-packages.example.json"
3639
- "triggers.example.json"
3740
- ".github/workflows/pi-upgrade-check.yml"
41+
- ".github/scripts/admin-pi-canary.mjs"
3842
schedule:
3943
# Weekly: pi ships breaking changes between minors and its HEAD moved within 24h of this
4044
# project's design being written. A pin that is never exercised rots silently.
@@ -400,3 +404,58 @@ jobs:
400404
docker run --rm --network none -e PROBE="$PROBE" \
401405
--entrypoint sh "$IMAGE_REF" -c 'node --input-type=module -e "$PROBE"' \
402406
|| { 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; }
407+
408+
admin-extension-canary:
409+
# The admin extension is the default front door (issue #96): it is TESTED against an exact pi
410+
# (SUPPORTED_PI_VERSION, locked to the admin devDependency pin by admin/test/load.test.mjs) but
411+
# declares a "*" peer, because pi never installs peers for host-provided packages and an exact
412+
# peer pin ERESOLVEs every plain-npm consumer whose pi differs by a patch -- the full reasoning
413+
# lives in .github/scripts/admin-pi-canary.mjs's header. A wildcard peer without a canary is a
414+
# hope; this job is the canary. It installs pi@latest into a SCRATCH dir and asserts the admin's
415+
# pinned assumptions against THAT install: the pinned-api needle list, the USED_API members on
416+
# ExtensionAPI, the runtime VERSION export, and an actual load-and-register of the built bundle.
417+
#
418+
# Failure protocol: a red run here means pi moved underneath the admin. Retest locally against
419+
# the new pi, bump SUPPORTED_PI_VERSION and the admin devDependency pin TOGETHER (load.test.mjs
420+
# locks them to each other), and republish @edgehero/pi-dispatch-admin.
421+
name: the admin extension survives latest pi (canary)
422+
runs-on: ubuntu-latest
423+
steps:
424+
- uses: actions/checkout@v4
425+
426+
- uses: actions/setup-node@v4
427+
with:
428+
node-version-file: ".nvmrc"
429+
430+
# NEVER install anything extra into the repo root. admin/test/load.test.mjs and
431+
# admin/test/pinned-extension-api.test.mjs anchor on the HOISTED PIN the lockfile resolves; a
432+
# root `npm install @earendil-works/pi-coding-agent@latest` would silently flip both from
433+
# pinned-version assertions into HEAD assertions -- green-lighting exactly the drift they
434+
# exist to catch. The @latest copy goes into a mktemp scratch dir outside the checkout.
435+
- run: npm ci --no-audit --no-fund
436+
437+
- run: node admin/build.mjs
438+
439+
- name: Install pi@latest into a scratch dir (never the repo)
440+
run: |
441+
SCRATCH=$(mktemp -d)
442+
echo "SCRATCH=$SCRATCH" >> "$GITHUB_ENV"
443+
cd "$SCRATCH"
444+
npm init -y >/dev/null
445+
# Distinguish failures (release.yml's npm-view doctrine): an install error here is
446+
# infrastructure -- network, registry, npm itself -- NOT a canary verdict about pi, and
447+
# reading it as either "pi broke us" or "all clear" would be a guess. Fail loudly instead.
448+
if ! npm install @earendil-works/pi-coding-agent@latest --no-audit --no-fund; then
449+
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."
450+
exit 1
451+
fi
452+
node -p "'canary pi: ' + require('$SCRATCH/node_modules/@earendil-works/pi-coding-agent/package.json').version"
453+
454+
- name: Run the canary against the scratch pi
455+
run: |
456+
# The bundle is copied INTO the scratch dir so its bare pi import resolves against
457+
# scratch/node_modules -- ESM resolves bare specifiers from the importing file's own
458+
# location, which is what keeps the repo's hoisted pin out of the probe entirely. The
459+
# canary script re-copies fresh and asserts (a)-(d); see its header for the mechanics.
460+
cp admin/dist/index.mjs "$SCRATCH/admin-bundle.mjs"
461+
node .github/scripts/admin-pi-canary.mjs "$SCRATCH"

0 commit comments

Comments
 (0)