Skip to content

Commit a3d634a

Browse files
authored
Merge pull request #93 from edgehero/feat/dispatch-setup-wizard
feat: /dispatch setup — first-run wizard, deployment pointer, and detection (issue #92)
2 parents bcadb98 + 1af2807 commit a3d634a

17 files changed

Lines changed: 2146 additions & 18 deletions

README.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,12 +279,25 @@ then open the panel:
279279
pi install npm:@edgehero/pi-dispatch-admin # then, in pi: /dispatch
280280
```
281281

282+
**No deployment yet? The console builds one.** When `/dispatch` finds nothing — no pointer, no env, no
283+
config in the folder, queue unreachable — it offers **`/dispatch setup`**: pick a deployment folder
284+
(default `~/pi-dispatch`), consent to an `npm install` of the pinned runtime, watch `pi-dispatch up` run
285+
its own per-action prompts in your terminal, optionally install the worker as a user-level service, and
286+
land in the panel — with an optional first trigger for the repo you're sitting in, its flow picked from
287+
that repo's own `.pi/skills/`. Every step shows what it will do and asks first; every step can be
288+
declined; nothing is ever written into your repo (the `ai-trigger: allow` opt-in line is printed for
289+
*you* to commit), and no credential ever passes through a dialog. It's the same consented CLI underneath
290+
— the wizard just types it for you.
291+
282292
Two other ways to load it: from a clone, the in-repo `.pi/extensions` shim auto-loads once you've trusted
283293
the project; or point pi at the source with `pi -e admin/src/index.ts` (add that path to the `"extensions"`
284-
array in `~/.pi/agent/settings.json` to make it permanent). To operate a **live** deployment, give the pi
285-
session the same `VALKEY_URL`, `PI_SETTINGS_FILE`, `PI_TRIGGERS_FILE`, `PI_PAUSE_WINDOWS_FILE`, and
286-
`PI_LOGS_DIR` the worker uses — the panel reads and writes those same files and queue, so both act on one
287-
deployment.
294+
array in `~/.pi/agent/settings.json` to make it permanent). Pointing the panel at a **live** deployment is
295+
automatic when `/dispatch setup` built it: the wizard writes a small **deployment pointer**
296+
(`~/.pi/agent/pi-dispatch-deployment.json`, override `PI_DISPATCH_DEPLOYMENT_FILE` — absolute paths only,
297+
never credentials), and the panel finds the deployment from any directory. Your own env always wins, key
298+
by key — exporting the same `VALKEY_URL` / `PI_SETTINGS_FILE` / `PI_TRIGGERS_FILE` /
299+
`PI_PAUSE_WINDOWS_FILE` / `PI_LOGS_DIR` the worker uses still works exactly as before, and is still the
300+
way to aim one pi session at a *different* deployment.
288301

289302
Bare `/dispatch` opens the live dashboard overlay — one snapshot per second, `p`/`r` to pause/resume the
290303
queue in place, ``/`` to move across the triggers and runs, `Enter` to drill into either. **Triggers are

admin/skills/operate-pi-dispatch/SKILL.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,19 @@ Use them like this:
5757

5858
The confirm is the approval step. Treat a decline as a final, legitimate answer.
5959

60+
## Setting up a deployment — `/dispatch setup`, and why you cannot run it
61+
62+
When the tools report no reachable deployment (queue unreachable, no configured paths), the fix is the
63+
first-run wizard — and it is **operator-typed only**: there is no model-callable setup tool, on purpose.
64+
Tell the operator to type `/dispatch setup`. It will, with a consent step per action: create a deployment
65+
folder, npm-install the pinned runtime into it, hand the terminal to `pi-dispatch up` (whose own y/N
66+
prompts gate the docker actions), optionally install the worker as a user-level service, write the
67+
deployment pointer so the panel finds everything afterwards, and offer a first trigger for the repo the
68+
session is in. What it will NOT do, ever: write into the operator's repo (the `ai-trigger: allow` line is
69+
printed for them to commit), accept a credential through a dialog, or run anything with `--yes`.
70+
Do not try to reproduce the wizard's steps through other tools or shell access — the sequencing exists
71+
so each mutation carries its own human gate.
72+
6073
## Staged packages — `run.packages`, and why you cannot set it
6174

6275
When a trigger fires, the job loads the third-party **pi packages the operator staged** into their global

admin/src/deployment-pointer.mjs

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
/**
2+
* The deployment POINTER (INT-DEPLOYMENT-POINTER-CONTRACT, issue #92): the one file that lets the admin's
3+
* `/dispatch` find a deployment the setup wizard built somewhere else. `resolvePaths` (read-model.mjs) is
4+
* env-only BY CONTRACT and stays untouched -- a wizard-built deployment in `~/pi-dispatch` is invisible from
5+
* any other cwd -- so this module layers the pointer's entries into `process.env` ONCE at extension load.
6+
* Env always wins, key by key; with the layering in place, every `resolvePaths(process.env)` call site
7+
* (every command and every LLM tool) is covered with zero signature changes.
8+
*
9+
* The contract in one line: **the pointer resolves paths only, never credentials, never capability
10+
* grants.** The read side enforces that with an allowlist (`POINTER_ENV_ALLOWLIST`) rather than a
11+
* blocklist: anything else in `env` is DROPPED silently (the operator-file unknown-fields-dropped
12+
* policy) -- most pointedly `PI_DISPATCH_RUN_ROOTS`, because a pointer that could widen the AI-run
13+
* folder allowlist would be a second, unreviewed door to a capability the panel deliberately gates
14+
* behind the operator's own env; and any credential-shaped key, because secrets travel through the
15+
* operator's env and the token broker, never through a file the wizard writes world-readable.
16+
*
17+
* Failure doctrine: a broken pointer must leave `/dispatch` exactly as functional as before the pointer
18+
* existed (env then cwd defaults). So `readPointer` NEVER throws -- unparseable JSON, a non-object, a
19+
* missing/invalid version, a newer version all come back as `{ ignored: reason }` -- and the apply path
20+
* retains a one-line notice for the next `/dispatch` to surface (the REBUILT_NOTICE idiom: a surfaced
21+
* warning, never a throw). This is deliberately weaker than the subscriptions file's loud refusal; the
22+
* reconciliation is recorded in the spec: the pointer is an availability aid, not a data file.
23+
*
24+
* Staleness is the wizard's problem, not the reader's: `readPointer` never stats `deploymentDir` or any
25+
* env value. The reader is pure over the file text plus one `fs.readFileSync`, so it cannot slow a
26+
* session down or invent a second existence check that disagrees with the wizard's.
27+
*/
28+
29+
import * as nodeFs from "node:fs";
30+
import { homedir } from "node:os";
31+
import { join, isAbsolute } from "node:path";
32+
33+
/** The pointer schema version this build understands. A NEWER file is ignored with a notice, never guessed at. */
34+
export const POINTER_VERSION = 1;
35+
36+
/**
37+
* The only env keys a pointer may set -- the path/URL variables `resolvePaths` reads to find a deployment's
38+
* files. An allowlist, not a blocklist: a new resolvePaths variable gets pointer coverage only by being
39+
* added HERE, on review. `PI_DISPATCH_RUN_ROOTS` is absent on purpose (a capability grant, not a path),
40+
* as is every credential-shaped key (the pointer never carries secrets).
41+
*/
42+
export const POINTER_ENV_ALLOWLIST = Object.freeze([
43+
"VALKEY_URL",
44+
"PI_LOGS_DIR",
45+
"PI_SETTINGS_FILE",
46+
"PI_TRIGGERS_FILE",
47+
"PI_PAUSE_WINDOWS_FILE",
48+
"PI_SUBSCRIPTIONS_FILE",
49+
]);
50+
51+
const POINTER_BASENAME = "pi-dispatch-deployment.json";
52+
53+
// ---- module state: the once-per-process memo, the retained notice, and the owned-key ledger ----
54+
55+
// Whether the process-wide layering has happened. applyDeploymentPointer is called from the extension
56+
// factory, which pi may in principle evaluate more than once; the memo makes every call after the first
57+
// a no-op so the layering result cannot depend on load order or count.
58+
let appliedOnce = false;
59+
60+
// The retained one-line notice from an ignored pointer, surfaced once by the next /dispatch. A single
61+
// slot, latest-wins: there is one pointer file, so there is at most one thing to say about it.
62+
let notice;
63+
64+
// The keys THIS MODULE wrote into the env, and therefore the only keys a re-apply may update. A var the
65+
// operator exported is never in here, so it can never be overwritten -- not even by the wizard's own
66+
// re-apply after it rewrites the pointer.
67+
const ownedKeys = new Set();
68+
69+
/**
70+
* Where the pointer lives: `PI_DISPATCH_DEPLOYMENT_FILE`, defaulting to pi's own agent dir
71+
* (`PI_CODING_AGENT_DIR` or `~/.pi/agent` -- the repo's one established home-dir pattern, resolved the
72+
* way worker/src/import-pi.mjs does). `||` on both reads so an EMPTY string falls back like an unset one,
73+
* matching resolvePaths' own defaulting idiom.
74+
*/
75+
export function pointerPath(env = process.env) {
76+
return (
77+
env.PI_DISPATCH_DEPLOYMENT_FILE ||
78+
join(env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), POINTER_BASENAME)
79+
);
80+
}
81+
82+
/**
83+
* Validate + filter one raw parsed pointer into the canonical `{ version, deploymentDir, env }` shape.
84+
* The single normalizer for BOTH sides: `readPointer` runs every file through it, and `writePointer`
85+
* runs every candidate through it before writing -- so a pointer that would be ignored on read is never
86+
* written, and a disallowed env key can neither be applied NOR persisted through this API.
87+
*
88+
* Returns `{ pointer }` or `{ ignored: reason }`. Never throws.
89+
*/
90+
function normalizePointer(raw) {
91+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
92+
return { ignored: "not a JSON object" };
93+
}
94+
// version is the one field that cannot be retrofitted: required, integer >= 1. A NEWER version means a
95+
// future wizard wrote a shape this build does not understand -- ignore the whole file (degrading to the
96+
// pre-pointer behavior) rather than half-read it, and name both versions so the operator knows which
97+
// side to upgrade.
98+
if (!Number.isInteger(raw.version) || raw.version < 1) {
99+
return { ignored: "missing or invalid version (integer >= 1 required)" };
100+
}
101+
if (raw.version > POINTER_VERSION) {
102+
return { ignored: `version ${raw.version} is newer than the supported version ${POINTER_VERSION}` };
103+
}
104+
if (typeof raw.deploymentDir !== "string" || !isAbsolute(raw.deploymentDir)) {
105+
return { ignored: "deploymentDir must be an absolute path" };
106+
}
107+
// An absent env map reads as {} (a pointer may name only the deployment dir); a PRESENT non-object env
108+
// is a malformed file and ignored whole -- silently "fixing" it would mask a hand-edit gone wrong.
109+
const rawEnv = raw.env === undefined ? {} : raw.env;
110+
if (rawEnv === null || typeof rawEnv !== "object" || Array.isArray(rawEnv)) {
111+
return { ignored: "env must be an object" };
112+
}
113+
const env = {};
114+
// Iterating the ALLOWLIST (not the file's keys) is what makes every unknown key -- run roots,
115+
// credentials, typos -- vanish without a branch to forget. Values must be non-empty strings; and every
116+
// path value must be ABSOLUTE, because a relative path would resolve against whichever session's cwd
117+
// happens to read it -- silently wrong in exactly the cross-directory case the pointer exists to fix.
118+
// VALKEY_URL is a URL, not a filesystem path, so it is exempt from the absoluteness gate only.
119+
for (const key of POINTER_ENV_ALLOWLIST) {
120+
const value = rawEnv[key];
121+
if (typeof value !== "string" || value === "") continue;
122+
if (key !== "VALKEY_URL" && !isAbsolute(value)) continue;
123+
env[key] = value;
124+
}
125+
// Unknown TOP-LEVEL fields drop here too: only the three canonical fields are ever kept or re-written.
126+
return { pointer: { version: raw.version, deploymentDir: raw.deploymentDir, env } };
127+
}
128+
129+
/**
130+
* Read and normalize the pointer file. Returns exactly one of:
131+
* `{ pointer }` -- a valid pointer, env already allowlist-filtered
132+
* `{ ignored: reason }` -- a file that exists but cannot be honored (never a throw)
133+
* `{ absent: true }` -- no file: the normal pre-wizard state, not an error
134+
*
135+
* Pure over the file text + `fs.readFileSync`: no stat of `deploymentDir`, no module-state mutation
136+
* (notices are retained by the APPLY path, not here) -- stale-deployment detection is the wizard's job.
137+
*/
138+
export function readPointer({ path = pointerPath(), fs = nodeFs } = {}) {
139+
let text;
140+
try {
141+
text = fs.readFileSync(path, "utf8");
142+
} catch (err) {
143+
if (err && err.code === "ENOENT") return { absent: true };
144+
// Any other read failure (permissions, EISDIR, ...) degrades like a malformed file: ignored, named.
145+
return { ignored: `unreadable: ${err?.message ?? err}` };
146+
}
147+
let raw;
148+
try {
149+
raw = JSON.parse(text);
150+
} catch (err) {
151+
return { ignored: `unparseable JSON: ${err.message}` };
152+
}
153+
return normalizePointer(raw);
154+
}
155+
156+
/**
157+
* The shared layering step for apply/re-apply. A key is written only when the pointer may govern it:
158+
* either nobody set it (`undefined` -- and note that an operator's EMPTY export counts as set, because
159+
* exporting `PI_LOGS_DIR=""` is still operator intent this module must not second-guess), or this module
160+
* itself set it on an earlier pass (`ownedKeys`). An ignored pointer retains the one-line notice and
161+
* applies nothing.
162+
*/
163+
function layerPointer(env, fs) {
164+
const path = pointerPath(env);
165+
const res = readPointer({ path, fs });
166+
if (res.ignored) {
167+
notice = `deployment pointer ignored: ${res.ignored} (${path})`;
168+
return { applied: [] };
169+
}
170+
if (res.absent) return { applied: [] };
171+
const applied = [];
172+
for (const [key, value] of Object.entries(res.pointer.env)) {
173+
if (env[key] !== undefined && !ownedKeys.has(key)) continue; // the operator's export always wins
174+
env[key] = value;
175+
ownedKeys.add(key);
176+
applied.push(key);
177+
}
178+
return { applied };
179+
}
180+
181+
/**
182+
* Layer the pointer's allowed, absolute env entries into `env` (default `process.env`), once per process.
183+
* Returns `{ applied: [keys] }` (informational; empty on memo hit / absent / ignored).
184+
*
185+
* Called at the TOP of the extension factory on purpose: `resolvePaths(process.env)` runs per-command AND
186+
* per LLM tool call, so factory-time layering is in place before any resolve -- including for an operator
187+
* who never types `/dispatch` and only lets the model call `dispatch_status`. Memoized so a second factory
188+
* evaluation cannot re-read the file mid-session; the ONLY refresh path is `reapplyDeploymentPointer`,
189+
* which the wizard calls deliberately after rewriting the file.
190+
*/
191+
export function applyDeploymentPointer(env = process.env, { fs = nodeFs } = {}) {
192+
if (appliedOnce) return { applied: [] };
193+
appliedOnce = true;
194+
return layerPointer(env, fs);
195+
}
196+
197+
/**
198+
* Re-read the pointer and refresh the layering -- the wizard's post-write hook, so a just-built deployment
199+
* takes effect in the SAME pi session without a restart. Same signature and return as apply, but it skips
200+
* the memo. Safety is unchanged: it may set a still-unset key or update a key THIS MODULE set earlier,
201+
* and never one the operator exported (even one exported after the first apply). A key the module owns
202+
* that a rewritten pointer no longer carries keeps its last applied value until process restart -- the
203+
* re-apply updates, it never unsets, so a half-typed pointer edit cannot yank paths out from under a
204+
* live panel.
205+
*/
206+
export function reapplyDeploymentPointer(env = process.env, { fs = nodeFs } = {}) {
207+
appliedOnce = true; // a later plain apply stays a no-op; this call IS the refresh
208+
return layerPointer(env, fs);
209+
}
210+
211+
/**
212+
* Return the retained one-line notice once, then clear it -- undefined when there is nothing to say.
213+
* The `/dispatch` handler drains this into `notify(..., "warning")`, which is the pointer's entire error
214+
* surface: one line, once, never a throw.
215+
*/
216+
export function takePointerNotice() {
217+
const n = notice;
218+
notice = undefined;
219+
return n;
220+
}
221+
222+
/**
223+
* Write a pointer for the wizard: validated through the SAME normalizer as the read side (a pointer that
224+
* would be ignored on read is refused here with `{ invalid: reason }` and the file is untouched), then
225+
* written atomically (tmp + rename, the repo's write idiom) as 2-space JSON with a trailing newline --
226+
* hand-editable thereafter. Note it persists the NORMALIZED shape: disallowed env keys and relative path
227+
* values are dropped before the bytes exist, so this API cannot be used to smuggle a credential or a
228+
* capability grant into the file. Returns `{ ok: true }` on success.
229+
*/
230+
export function writePointer({ path = pointerPath(), pointer, fs = nodeFs } = {}) {
231+
const res = normalizePointer(pointer);
232+
if (res.ignored) return { invalid: res.ignored };
233+
const tmp = `${path}.tmp`;
234+
fs.writeFileSync(tmp, `${JSON.stringify(res.pointer, null, 2)}\n`);
235+
fs.renameSync(tmp, path);
236+
return { ok: true };
237+
}
238+
239+
/**
240+
* TEST-ONLY: clear the process-wide memo, the retained notice, and the owned-key ledger, so each test
241+
* case starts from the fresh-process state. Named to make any production call site an obvious review
242+
* failure; production code has no reason to reset -- the memo being process-wide is the point.
243+
*/
244+
export function resetForTests() {
245+
appliedOnce = false;
246+
notice = undefined;
247+
ownedKeys.clear();
248+
}

0 commit comments

Comments
 (0)