@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 +3 -1
- package/src/config.mjs +19 -2
- package/src/flow-gate.mjs +3 -1
- package/src/github-app-setup.mjs +4 -18
- package/src/open-browser.mjs +24 -0
- package/src/run-history.mjs +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch",
|
|
3
|
-
"version": "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",
|
|
195
|
-
chainMaxPerJob: nonNegativeInt(env, "PI_CHAIN_MAX_PER_JOB",
|
|
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
|
-
|
|
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
|
package/src/github-app-setup.mjs
CHANGED
|
@@ -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
|
-
|
|
508
|
-
|
|
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
|
+
}
|
package/src/run-history.mjs
CHANGED
|
@@ -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
|