Skip to content

Commit 35ca4cc

Browse files
committed
refactor(worker): share the platform browser opener; add the graph artifact dir (issue #54)
Two enablers for the graph HTML export, behaviour-identical for everything that exists today. The best-effort platform opener moves from github-app-setup.mjs into its own module with an exports-map subpath (./open-browser), because the graph export needs the same spawn and two hand-copies of platform-opener argv is exactly the drift class the mirror tests exist to prevent. The argv table, the detached/ignored/unref posture and the swallow-everything doctrine are unchanged and now pinned literally per platform (the up.mjs exact-argv doctrine); spawn and platform are injectable so nothing launches in tests. github-app-setup's injected openBrowser seam is untouched. defaultGraphDir joins defaultLogsDir/defaultSandboxDir in config.mjs: the worker-owned temp path for the graph artifact, PI_GRAPH_DIR override, and deliberately NOT inside logsDir, whose filename shape is contract (INT-RUN-HISTORY-FILE-CONTRACT). resolvePaths mirrors it as graphDir on the existing pattern. Specs: no entry changes; the artifact itself (and its DES-ADMIN-VIA-PI-EXTENSION amendment) is the next slice, and this refactor changes no behaviour. INT-RUN-HISTORY-FILE-CONTRACT UNCHANGED, checked (nothing new is written into logsDir). DES-GH-APP-MANIFEST-SETUP UNCHANGED, checked (same opener, same doctrine, one module over). Suite in the CI posture: 2108 pass, 0 skipped; admin bundle builds. Signed-off-by: Rob Boerman <robboerman@live.nl>
1 parent cb3ec3c commit 35ca4cc

9 files changed

Lines changed: 120 additions & 21 deletions

File tree

admin/src/read-model.mjs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
import * as nodeFs from "node:fs";
1919
import { join, delimiter, sep } from "node:path";
2020
import { execFileSync } from "node:child_process";
21-
import { defaultLogsDir, defaultSandboxDir, CHAIN_DEPTH_MAX_DEFAULT, CHAIN_MAX_PER_JOB_DEFAULT } from "@edgehero/pi-dispatch/config";
21+
import { defaultLogsDir, defaultSandboxDir, defaultGraphDir, CHAIN_DEPTH_MAX_DEFAULT, CHAIN_MAX_PER_JOB_DEFAULT } from "@edgehero/pi-dispatch/config";
2222
import { settingsFilePath, readOverlay, writeOverlay, KNOWN_KEYS } from "@edgehero/pi-dispatch/runtime-settings";
2323
import { sanitizeJobId } from "@edgehero/pi-dispatch/run-history";
2424
import { dayKey, weekKey, monthKey } from "@edgehero/pi-dispatch/budget";
@@ -99,6 +99,11 @@ export function resolvePaths(env = process.env) {
9999
// GRAPH view exists to remove.
100100
chainDepthMax: parseNonNegInt(env.PI_CHAIN_DEPTH_MAX, CHAIN_DEPTH_MAX_DEFAULT),
101101
chainMaxPerJob: parseNonNegInt(env.PI_CHAIN_MAX_PER_JOB, CHAIN_MAX_PER_JOB_DEFAULT),
102+
// Where the graph HTML artifact lands (issue #54): the worker's own temp-dir default, imported
103+
// like defaultLogsDir/defaultSandboxDir above, so the admin and any future worker consumer agree
104+
// on the path without loadConfig. Deliberately NOT logsDir -- that directory's filename shape is
105+
// contract (INT-RUN-HISTORY-FILE-CONTRACT).
106+
graphDir: env.PI_GRAPH_DIR || defaultGraphDir(env),
102107
};
103108
}
104109

admin/test/fixtures/worker-import-probe.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@ import { settingsFilePath } from "@edgehero/pi-dispatch/runtime-settings";
55
// test, rather than at the first /dispatch graph in a bundled install.
66
import { selectEntries, keepOnlyDeclaredSkills } from "@edgehero/pi-dispatch/materialize";
77
import { aiTriggerAllows } from "@edgehero/pi-dispatch/flow-gate";
8-
import { CHAIN_DEPTH_MAX_DEFAULT } from "@edgehero/pi-dispatch/config";
8+
import { CHAIN_DEPTH_MAX_DEFAULT, defaultGraphDir } from "@edgehero/pi-dispatch/config";
9+
import { openBrowser } from "@edgehero/pi-dispatch/open-browser";
910

1011
export const ok =
1112
typeof settingsFilePath === "function" &&
1213
typeof selectEntries === "function" &&
1314
typeof keepOnlyDeclaredSkills === "function" &&
1415
typeof aiTriggerAllows === "function" &&
16+
typeof defaultGraphDir === "function" &&
17+
typeof openBrowser === "function" &&
1518
Number.isInteger(CHAIN_DEPTH_MAX_DEFAULT);

admin/test/read-model.test.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ test("resolvePaths reads env with safe defaults and never calls loadConfig", ()
146146
// Pinned so the sandbox default does not drag the OS temp dir into this equality
147147
// (REQ-RESURRECTABLE-SANDBOX); the defaulting itself is asserted in its own test below.
148148
PI_SANDBOX_DIR: "/sbx",
149+
// Pinned for the same temp-dir reason; the default is asserted in its own test below.
150+
PI_GRAPH_DIR: "/g",
149151
});
150152
assert.deepEqual(p, {
151153
valkeyUrl: "redis://h:1",
@@ -163,11 +165,18 @@ test("resolvePaths reads env with safe defaults and never calls loadConfig", ()
163165
schedulerStallMax: 2,
164166
chainDepthMax: 1,
165167
chainMaxPerJob: 2,
168+
graphDir: "/g",
166169
pauseWindowsPath: "./pause-windows.json",
167170
subscriptionsPath: "/subs.json",
168171
});
169172
});
170173

174+
test("resolvePaths resolves the graph dir from PI_GRAPH_DIR with the worker's temp default", () => {
175+
assert.equal(resolvePaths({ PI_GRAPH_DIR: "/x/graphs" }).graphDir, "/x/graphs");
176+
assert.ok(resolvePaths({}).graphDir.endsWith("/pi-dispatch/graph"), "the default is the worker-owned temp path, never cwd");
177+
assert.ok(resolvePaths({ PI_GRAPH_DIR: "" }).graphDir.endsWith("/pi-dispatch/graph"), "empty falls back like every other path here");
178+
});
179+
171180
test("resolvePaths falls back to defaults on empty env (no worker config required)", () => {
172181
const p = resolvePaths({});
173182
assert.equal(p.valkeyUrl, "redis://127.0.0.1:6379");

worker/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"./exit-code": "./src/exit-code.mjs",
4646
"./flow-gate": "./src/flow-gate.mjs",
4747
"./materialize": "./src/materialize.mjs",
48+
"./open-browser": "./src/open-browser.mjs",
4849
"./git-dirty": "./src/git-dirty.mjs",
4950
"./queue": "./src/queue.mjs",
5051
"./connection": "./src/connection.mjs",

worker/src/config.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,16 @@ export function defaultLogsDir() {
269269
return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/logs`.replace(/\\/g, "/");
270270
}
271271

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+
272282
export function defaultSettingsFile() {
273283
// Under the OS temp dir by default. Holds the runtime-tunable settings overlay shared with the admin
274284
// extension (INT-CONFIG-OVERLAY-CONTRACT); a worker-owned path that never enters the container env

worker/src/github-app-setup.mjs

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@
3232
* Everything side-effecting is injected (fetch, the listener, the browser opener, prompt, fs, clock),
3333
* defaulting to the real thing — the up.mjs convention — so the whole flow is testable offline.
3434
*/
35-
import { spawn as nodeSpawn } from "node:child_process";
3635
import { createSign } from "node:crypto";
3736
import { chmodSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3837
import { hostname } from "node:os";
3938
import { join } from "node:path";
4039
import { parseArgs } from "node:util";
4140
import { updateEnvFile } from "./env-file.mjs";
41+
import { openBrowser as defaultOpenBrowser } from "./open-browser.mjs";
4242
import { defaultPrompt } from "./up.mjs";
4343

4444
const API_ROOT = "https://api.github.com";
@@ -503,20 +503,6 @@ async function defaultListen(pageFor) {
503503
};
504504
}
505505

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.

worker/src/open-browser.mjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

worker/test/config.test.mjs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import assert from "node:assert/strict";
22
import { delimiter } from "node:path";
33
import { test } from "node:test";
4-
import { CHAIN_DEPTH_MAX_DEFAULT, CHAIN_MAX_PER_JOB_DEFAULT, configError, globalExtensionsEnabled, loadConfig, loadGitLabAuth } from "../src/config.mjs";
4+
import { CHAIN_DEPTH_MAX_DEFAULT, CHAIN_MAX_PER_JOB_DEFAULT, configError, defaultGraphDir, globalExtensionsEnabled, loadConfig, loadGitLabAuth } from "../src/config.mjs";
55
import { FORGES, FORGE_KINDS } from "../src/forges.mjs";
66

77
test("loads conservative defaults with an empty-ish env", () => {
@@ -34,6 +34,15 @@ test("AI-trigger / chaining knobs default conservatively", () => {
3434
assert.deepEqual(c.dispatchRunRoots, []);
3535
});
3636

37+
test("defaultGraphDir is the worker-owned temp path, beside logs/ and jobs/, never inside logsDir", () => {
38+
// NOT logsDir on purpose: INT-RUN-HISTORY-FILE-CONTRACT names that directory's filename shape,
39+
// and a stray .html beside the sidecars would widen a contract for a file that is not a record.
40+
assert.equal(defaultGraphDir({ TMPDIR: "/t" }), "/t/pi-dispatch/graph");
41+
assert.equal(defaultGraphDir({ TEMP: "C:\\Temp" }), "C:/Temp/pi-dispatch/graph", "backslashes normalise like the sibling defaults");
42+
assert.equal(defaultGraphDir({}), "/tmp/pi-dispatch/graph");
43+
assert.ok(!defaultGraphDir({}).includes("/logs"), "never inside the run-history directory");
44+
});
45+
3746
test("the exported chain-cap defaults are the literals loadConfig uses (issue #54)", () => {
3847
// The admin's resolvePaths imports these so the graph never states a cap the worker does not
3948
// enforce. The literal assertions beside the loadConfig ones make widening either a reviewed edit:

worker/test/open-browser.test.mjs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import assert from "node:assert/strict";
2+
import { test } from "node:test";
3+
import { openBrowser } from "../src/open-browser.mjs";
4+
5+
/** A recording child the spawn fake returns; `errorHandlers` proves the swallow listener is attached. */
6+
function fakeChild() {
7+
const child = { errorHandlers: [], unrefCalled: false };
8+
child.on = (event, cb) => {
9+
if (event === "error") child.errorHandlers.push(cb);
10+
return child;
11+
};
12+
child.unref = () => {
13+
child.unrefCalled = true;
14+
};
15+
return child;
16+
}
17+
18+
test("openBrowser spawns the exact per-platform argv, detached and ignored", () => {
19+
// The argv table is the module (the GitHub App wizard shipped it first; the graph export reuses
20+
// it), so it is pinned literally per platform -- the up.mjs exact-argv doctrine.
21+
const cases = [
22+
["darwin", "open", ["https://x"]],
23+
["win32", "cmd", ["/c", "start", "", "https://x"]],
24+
["linux", "xdg-open", ["https://x"]],
25+
];
26+
for (const [platform, cmd, args] of cases) {
27+
const calls = [];
28+
const child = fakeChild();
29+
openBrowser("https://x", { platform, spawn: (...a) => (calls.push(a), child) });
30+
assert.equal(calls.length, 1, platform);
31+
assert.deepEqual(calls[0], [cmd, args, { stdio: "ignore", detached: true }], platform);
32+
assert.equal(child.errorHandlers.length, 1, "the async error path must be swallowed, or a missing opener crashes the process later");
33+
assert.equal(child.unrefCalled, true, "the child must not hold the event loop open");
34+
}
35+
});
36+
37+
test("openBrowser swallows a synchronous spawn failure -- the printed URL carries the flow", () => {
38+
assert.doesNotThrow(() =>
39+
openBrowser("https://x", {
40+
platform: "linux",
41+
spawn: () => {
42+
throw new Error("ENOENT: no xdg-open");
43+
},
44+
}),
45+
);
46+
});
47+
48+
test("openBrowser's swallowed error handler is inert when invoked", () => {
49+
const child = fakeChild();
50+
openBrowser("https://x", { platform: "darwin", spawn: () => child });
51+
assert.doesNotThrow(() => child.errorHandlers[0](new Error("spawn open ENOENT")));
52+
});

0 commit comments

Comments
 (0)