@edgehero/pi-dispatch 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@edgehero/pi-dispatch",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Self-hosted job harness for the pi coding agent: a BullMQ worker that drains the queue, mints scoped forge tokens, and runs one container per job — plus the pi-dispatch CLI (init, up, doctor, service).",
6
6
  "keywords": [
@@ -44,6 +44,8 @@
44
44
  "./config": "./src/config.mjs",
45
45
  "./exit-code": "./src/exit-code.mjs",
46
46
  "./flow-gate": "./src/flow-gate.mjs",
47
+ "./materialize": "./src/materialize.mjs",
48
+ "./open-browser": "./src/open-browser.mjs",
47
49
  "./git-dirty": "./src/git-dirty.mjs",
48
50
  "./queue": "./src/queue.mjs",
49
51
  "./connection": "./src/connection.mjs",
package/src/config.mjs CHANGED
@@ -15,6 +15,13 @@ export function configError(message) {
15
15
  return error;
16
16
  }
17
17
 
18
+ // The chain caps' DEFAULTS, exported (issue #54) so the admin's read-model can state them without a
19
+ // second literal to drift and without calling loadConfig, whose GitHub-auth validation throws on
20
+ // problems unrelated to a path read (the documented reason resolvePaths never calls it). The env
21
+ // OVERRIDES stay right here in loadConfig; only the defaults are shared.
22
+ export const CHAIN_DEPTH_MAX_DEFAULT = 1; // DES-JOB-OUTBOX-CHAINING; 0 = chaining kill-switch (fail-closed)
23
+ export const CHAIN_MAX_PER_JOB_DEFAULT = 2; // INT-OUTBOX-CONTRACT: max request-<n>.json collected per parent
24
+
18
25
  function boundedInt(env, name, fallback, min, want) {
19
26
  const raw = env[name];
20
27
  if (raw === undefined || raw === "") {
@@ -191,8 +198,8 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
191
198
  // A bound on how large a transcript may be before it stops being resumed. Not disk hygiene: an
192
199
  // oversized transcript is a prefill an operator never sized PI_MAX_TOKENS for.
193
200
  sessionMaxBytes: nonNegativeInt(env, "PI_SESSION_MAX_BYTES", 8 * 1024 * 1024), // 0 = no cap
194
- chainDepthMax: nonNegativeInt(env, "PI_CHAIN_DEPTH_MAX", 1), // DES-JOB-OUTBOX-CHAINING; 0 = chaining kill-switch (fail-closed)
195
- chainMaxPerJob: nonNegativeInt(env, "PI_CHAIN_MAX_PER_JOB", 2), // INT-OUTBOX-CONTRACT: max request-<n>.json collected per parent
201
+ chainDepthMax: nonNegativeInt(env, "PI_CHAIN_DEPTH_MAX", CHAIN_DEPTH_MAX_DEFAULT), // DES-JOB-OUTBOX-CHAINING; 0 = chaining kill-switch (fail-closed)
202
+ chainMaxPerJob: nonNegativeInt(env, "PI_CHAIN_MAX_PER_JOB", CHAIN_MAX_PER_JOB_DEFAULT), // INT-OUTBOX-CONTRACT: max request-<n>.json collected per parent
196
203
  dispatchRunPerHour: nonNegativeInt(env, "PI_DISPATCH_RUN_PER_HOUR", 3), // DES-ADMIN-VIA-PI-EXTENSION; 0 = disable dispatch_run
197
204
  dispatchRunRoots: delimitedList(env.PI_DISPATCH_RUN_ROOTS), // DES-AI-TRIGGER-FLOW-GATE: default [] fails closed — no folder passes, dispatch_run refuses everything
198
205
  github: { ...loadGitHubAuth(env, fileExists), allowGhResume: env.PI_SESSIONS_ALLOW_GH_SOURCE === "1" },
@@ -262,6 +269,16 @@ export function defaultLogsDir() {
262
269
  return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/logs`.replace(/\\/g, "/");
263
270
  }
264
271
 
272
+ export function defaultGraphDir(env = process.env) {
273
+ // Under the OS temp dir by default, beside logs/ and jobs/ -- the admin's graph HTML artifact
274
+ // (issue #54) is host-side display output on the defaultLogsDir doctrine, and deliberately NOT
275
+ // inside logsDir: INT-RUN-HISTORY-FILE-CONTRACT names that directory's filename shape, and a
276
+ // stray .html beside the sidecars would widen a contract for a file that is not a record.
277
+ // Overridable with PI_GRAPH_DIR; exported so the admin resolves the same default without
278
+ // loadConfig, like defaultSandboxDir above.
279
+ return `${env.TMPDIR ?? env.TEMP ?? "/tmp"}/pi-dispatch/graph`.replace(/\\/g, "/");
280
+ }
281
+
265
282
  export function defaultSettingsFile() {
266
283
  // Under the OS temp dir by default. Holds the runtime-tunable settings overlay shared with the admin
267
284
  // extension (INT-CONFIG-OVERLAY-CONTRACT); a worker-owned path that never enters the container env
package/src/flow-gate.mjs CHANGED
@@ -64,7 +64,9 @@ export async function readFlowGate({ folder, flow, sha, git = defaultGit }) {
64
64
  // Custom: no YAML parser in worker deps; a single ai-trigger boolean does not justify js-yaml.
65
65
  // Strict leading-frontmatter scan: strip a BOM, normalise CRLF, isolate the leading `---`..`---`
66
66
  // block, and require an exact `ai-trigger: allow` line (an optional surrounding double quote).
67
- function aiTriggerAllows(buf) {
67
+ // Exported (issue #54) so the admin's skill enumeration answers "chainable?" with THIS scanner and
68
+ // not a keep-in-sync copy -- the SKILL_NAME_RE lesson one export up, applied to the frontmatter test.
69
+ export function aiTriggerAllows(buf) {
68
70
  const text = buf.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
69
71
  const block = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(text);
70
72
  if (!block) return false; // no parseable frontmatter -> deny
@@ -32,13 +32,13 @@
32
32
  * Everything side-effecting is injected (fetch, the listener, the browser opener, prompt, fs, clock),
33
33
  * defaulting to the real thing — the up.mjs convention — so the whole flow is testable offline.
34
34
  */
35
- import { spawn as nodeSpawn } from "node:child_process";
36
35
  import { createSign } from "node:crypto";
37
36
  import { chmodSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
38
37
  import { hostname } from "node:os";
39
38
  import { join } from "node:path";
40
39
  import { parseArgs } from "node:util";
41
40
  import { updateEnvFile } from "./env-file.mjs";
41
+ import { openBrowser as defaultOpenBrowser } from "./open-browser.mjs";
42
42
  import { defaultPrompt } from "./up.mjs";
43
43
 
44
44
  const API_ROOT = "https://api.github.com";
@@ -503,20 +503,6 @@ async function defaultListen(pageFor) {
503
503
  };
504
504
  }
505
505
 
506
- /**
507
- * Best-effort platform browser opener. ALWAYS paired with the URL printed to the terminal — a
508
- * headless or SSH'd operator has no opener that works, and the printed URL pasted into any browser
509
- * (on the right machine, or through the port-forward the wizard suggests) is the real contract; the
510
- * spawn is only a convenience on top. Failures are swallowed for the same reason.
511
- */
512
- function defaultOpenBrowser(url) {
513
- const [cmd, args] =
514
- process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
515
- try {
516
- const child = nodeSpawn(cmd, args, { stdio: "ignore", detached: true });
517
- child.on("error", () => {});
518
- child.unref();
519
- } catch {
520
- // No opener on this host — the printed URL carries the flow.
521
- }
522
- }
506
+ // The best-effort platform opener moved to its own module (issue #54) so the admin's graph export
507
+ // shares this one reviewed argv table instead of hand-copying it; the print-the-URL-first doctrine
508
+ // lives in its docstring and is unchanged here.
@@ -0,0 +1,24 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+
3
+ /**
4
+ * Best-effort platform browser opener. ALWAYS paired with the URL printed to the terminal -- a
5
+ * headless or SSH'd operator has no opener that works, and the printed URL pasted into any browser
6
+ * (on the right machine, or through the port-forward the caller suggests) is the real contract; the
7
+ * spawn is only a convenience on top. Failures are swallowed for the same reason.
8
+ *
9
+ * One module on purpose (issue #54): the GitHub App wizard and the admin's graph export both open a
10
+ * browser, and two hand-copies of platform-opener argv is exactly the drift class the repo's mirror
11
+ * tests exist to prevent. `spawn` and `platform` are injectable so the argv table is testable
12
+ * without launching anything.
13
+ */
14
+ export function openBrowser(url, { spawn = nodeSpawn, platform = process.platform } = {}) {
15
+ const [cmd, args] =
16
+ platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
17
+ try {
18
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
19
+ child.on("error", () => {});
20
+ child.unref();
21
+ } catch {
22
+ // No opener on this host -- the printed URL carries the flow.
23
+ }
24
+ }
@@ -295,6 +295,19 @@ export function buildRecord({ job, result, error, startedAt, endedAt }) {
295
295
  // deliberately not stored, for the reason `session` states one group below.
296
296
  replica: data.replica ?? null,
297
297
  replicas: data.replicas ?? null,
298
+ // Trigger attribution (INT-RUN-HISTORY-FILE-CONTRACT, issue #54): additive and nullable, explicit
299
+ // literals beside the replica fields whose admissibility argument they reuse, no spread. Both read
300
+ // the receiver's harness-computed `matched` from this job's own `job.data.trigger`: `index` is the
301
+ // raw triggers-array position of the entry that fired (cron entries counted) and `type` that entry's
302
+ // `on.type` -- an INTEGER and a FIXED ENUM ("label" | "comment" | "pull_request"), nothing
303
+ // attacker-chosen. The third `matched` key (`label`/`phrase`/`action`) is DELIBERATELY absent:
304
+ // a label that satisfied an `any` predicate is collaborator-applied payload text, and `type`
305
+ // already names the route. Cron jobs carry `trigger: { id, pattern }` with no `matched`, so both
306
+ // stay null there on purpose -- a cron run's attribution is already exact via its
307
+ // `repeat:<id>:<millis>` jobId (see makeFindPreviousRun), and that join also works retroactively
308
+ // over the whole retention window, which a new record field cannot.
309
+ triggerIndex: data.trigger?.matched?.index ?? null,
310
+ triggerType: data.trigger?.matched?.type ?? null,
298
311
  // Session telemetry (INT-RUN-HISTORY-FILE-CONTRACT): additive, nullable, an explicit literal, no
299
312
  // spread. `{ resumed, reason, bytes }` -- a boolean, a fixed enum and an integer. THE KEY AND THE
300
313
  // BRANCH NAME ARE DELIBERATELY ABSENT: this record's PII-free-by-construction property rests on it