@edgehero/pi-dispatch 1.6.0 → 1.7.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/.env.example +7 -0
- package/package.json +2 -1
- package/src/cli.mjs +74 -17
- package/src/config.mjs +83 -0
- package/src/cron.mjs +116 -4
- package/src/doctor.mjs +113 -3
- package/src/fingerprint.mjs +78 -0
- package/src/fleet-lease.mjs +179 -0
- package/src/host-registry.mjs +279 -0
- package/src/image-preflight.mjs +8 -3
- package/src/index.mjs +219 -65
- package/src/queue.mjs +130 -2
- package/src/run-history.mjs +98 -4
- package/src/schedules.mjs +67 -4
- package/src/start.mjs +217 -27
package/src/run-history.mjs
CHANGED
|
@@ -114,7 +114,34 @@ export function parseExitTurns(text) {
|
|
|
114
114
|
* Read-only telemetry, exactly like `parseExitTurns`: NEVER throws and MUST NOT feed exit-code or retry
|
|
115
115
|
* classification (INT-RUNNER-EXIT-CODE-PROTOCOL). A malformed or non-object `tokens` (or one missing a
|
|
116
116
|
* numeric `total`) is `null`, never a partial that could poison the daily token counter.
|
|
117
|
+
*
|
|
118
|
+
* The admitted object is REBUILT through `rebuildTokens` rather than returned as it arrived. See that
|
|
119
|
+
* function for why: the pass-through it replaces is what made this comment's "integer token counts and
|
|
120
|
+
* numeric cost only" false one level below `buildRecord`'s literal.
|
|
121
|
+
*/
|
|
122
|
+
/**
|
|
123
|
+
* The CLOSED `session.reason` enum, verbatim from `INT-RUN-HISTORY-FILE-CONTRACT`. Three producers write
|
|
124
|
+
* this field (resolve, runner, promote) and the contract has always called the set closed; until this
|
|
125
|
+
* list existed, nothing enforced it and the runner's half was an unchecked string. Kept here rather than
|
|
126
|
+
* beside the store because this module is where the container's copy is admitted, and an enum that lives
|
|
127
|
+
* anywhere but the admission point is a comment, not a check.
|
|
117
128
|
*/
|
|
129
|
+
const SESSION_REASONS = new Set([
|
|
130
|
+
"resumed",
|
|
131
|
+
"absent",
|
|
132
|
+
"expired",
|
|
133
|
+
"conversation-too-old",
|
|
134
|
+
"resume-chain-too-long",
|
|
135
|
+
"context-too-full",
|
|
136
|
+
"too-large",
|
|
137
|
+
"unparseable",
|
|
138
|
+
"not-a-regular-file",
|
|
139
|
+
"pi-version-changed",
|
|
140
|
+
"locked",
|
|
141
|
+
"promote-failed",
|
|
142
|
+
"disabled",
|
|
143
|
+
]);
|
|
144
|
+
|
|
118
145
|
/**
|
|
119
146
|
* The runner's `session` object off the exit line: `{ resumed: <bool>, reason: "<enum>" }` or null when
|
|
120
147
|
* the container died before emitting one (REQ-RESUMABLE-SESSION).
|
|
@@ -125,7 +152,8 @@ export function parseExitTurns(text) {
|
|
|
125
152
|
* without both numbers it is indistinguishable from an ordinary cold start. A feature that fails open
|
|
126
153
|
* must still say that it did.
|
|
127
154
|
*
|
|
128
|
-
* PII-free by construction: a boolean and a fixed enum. No key, no branch name, no path
|
|
155
|
+
* PII-free by construction: a boolean and a fixed enum. No key, no branch name, no path -- and since the
|
|
156
|
+
* `SESSION_REASONS` check below, that sentence is enforced rather than merely intended.
|
|
129
157
|
*/
|
|
130
158
|
export function parseExitSession(text) {
|
|
131
159
|
if (typeof text !== "string") return null;
|
|
@@ -137,7 +165,14 @@ export function parseExitSession(text) {
|
|
|
137
165
|
if (parsed?.event !== "exit") continue;
|
|
138
166
|
const sess = parsed?.session;
|
|
139
167
|
if (sess && typeof sess === "object" && !Array.isArray(sess) && typeof sess.resumed === "boolean") {
|
|
140
|
-
|
|
168
|
+
// The reason is checked against the CLOSED enum, not merely against `typeof === "string"`, which
|
|
169
|
+
// is what it used to be. The container owns this value, so an unchecked string put an
|
|
170
|
+
// attacker-shapeable one into a record whose PII-free property rests on holding none -- while
|
|
171
|
+
// the comment above claimed "a boolean and a fixed enum". An unrecognised token reads as `null`
|
|
172
|
+
// (the runner said nothing this contract can represent) rather than being carried through: the
|
|
173
|
+
// enum is documented CLOSED in INT-RUN-HISTORY-FILE-CONTRACT, so a value outside it was already
|
|
174
|
+
// contract-violating and every consumer already handles null.
|
|
175
|
+
return { resumed: sess.resumed, reason: SESSION_REASONS.has(sess.reason) ? sess.reason : null };
|
|
141
176
|
}
|
|
142
177
|
return null;
|
|
143
178
|
}
|
|
@@ -176,6 +211,43 @@ export function parseExitContext(text) {
|
|
|
176
211
|
return null;
|
|
177
212
|
}
|
|
178
213
|
|
|
214
|
+
/**
|
|
215
|
+
* The keys the runner actually emits, in its own emission order: the metered snapshot
|
|
216
|
+
* (`image/runner/src/usage-meter.mjs` -> `snapshot`) plus the token-budget fallback
|
|
217
|
+
* (`image/runner/run-job.mjs` -> `pickTotals`, which sends the first four and `metered: false`). Order
|
|
218
|
+
* matters because it is what makes a conformant runner's object round-trip byte-identically through the
|
|
219
|
+
* rebuild below, so the record's bytes do not move for anyone running a real image.
|
|
220
|
+
*/
|
|
221
|
+
const TOKEN_KEYS = ["input", "output", "total", "cost", "metered", "rootTotal", "otherTotal", "looseTotal", "sessions", "calls", "unresolved", "unpriced"];
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Rebuild the billed totals from a closed key list rather than passing the container's object through.
|
|
225
|
+
*
|
|
226
|
+
* This function exists because the pass-through was a hole. `parseExitTokens` used to `return t`
|
|
227
|
+
* verbatim whenever `t.total` was a number, so any key the container invented -- a path, a branch name,
|
|
228
|
+
* a string it read out of the workspace -- rode into the durable record, and from there into anything
|
|
229
|
+
* that mirrors it. The record's PII-free-by-construction property held at `buildRecord`'s own level and
|
|
230
|
+
* NOT one level down, while this module's own comment claimed "integer token counts and numeric cost
|
|
231
|
+
* only". `parseExitUsage` already rebuilds for exactly this reason and says so; this is the sibling that
|
|
232
|
+
* did not, and the asymmetry was an oversight rather than a decision.
|
|
233
|
+
*
|
|
234
|
+
* A key the runner omitted stays OMITTED rather than becoming null: the fallback shape legitimately
|
|
235
|
+
* carries only five of the twelve, and a null there would read as "measured zero" for a number nobody
|
|
236
|
+
* measured. `typeof === "number"` rather than `Number.isFinite`, deliberately, so this narrows WHICH
|
|
237
|
+
* KEYS survive and never which objects are admitted -- the admission gate above is unchanged.
|
|
238
|
+
*/
|
|
239
|
+
function rebuildTokens(t) {
|
|
240
|
+
const out = {};
|
|
241
|
+
for (const key of TOKEN_KEYS) {
|
|
242
|
+
if (key === "metered") {
|
|
243
|
+
if (typeof t.metered === "boolean") out.metered = t.metered;
|
|
244
|
+
} else if (typeof t[key] === "number") {
|
|
245
|
+
out[key] = t[key];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
179
251
|
export function parseExitTokens(text) {
|
|
180
252
|
if (typeof text !== "string") return null;
|
|
181
253
|
const lines = text.split("\n");
|
|
@@ -185,7 +257,7 @@ export function parseExitTokens(text) {
|
|
|
185
257
|
const parsed = parseTailLine(line);
|
|
186
258
|
if (parsed?.event !== "exit") continue;
|
|
187
259
|
const t = parsed?.tokens;
|
|
188
|
-
if (t && typeof t === "object" && !Array.isArray(t) && typeof t.total === "number") return t;
|
|
260
|
+
if (t && typeof t === "object" && !Array.isArray(t) && typeof t.total === "number") return rebuildTokens(t);
|
|
189
261
|
return null;
|
|
190
262
|
}
|
|
191
263
|
return null;
|
|
@@ -306,7 +378,7 @@ function rebuildUsage(u) {
|
|
|
306
378
|
* default to `null` when the outcome does not carry them, so the record shape is stable whether or not
|
|
307
379
|
* the source reports those fields.
|
|
308
380
|
*/
|
|
309
|
-
export function buildRecord({ job, result, error, startedAt, endedAt }) {
|
|
381
|
+
export function buildRecord({ job, result, error, startedAt, endedAt, host = null }) {
|
|
310
382
|
const data = job.data ?? {};
|
|
311
383
|
const kind = data.kind ?? job.name;
|
|
312
384
|
const source = result ?? error ?? {};
|
|
@@ -369,6 +441,28 @@ export function buildRecord({ job, result, error, startedAt, endedAt }) {
|
|
|
369
441
|
// BRANCH NAME ARE DELIBERATELY ABSENT: this record's PII-free-by-construction property rests on it
|
|
370
442
|
// holding no attacker-chosen string, and a branch name is exactly that.
|
|
371
443
|
session: source.session ?? null,
|
|
444
|
+
// Which machine ran this (issue #57). Additive, nullable, an explicit literal, TAIL position: every
|
|
445
|
+
// prior addition took the tail, and "field order is the serialisation order" is this record's
|
|
446
|
+
// contract, so the tail is the only placement that leaves twenty-four existing positions untouched.
|
|
447
|
+
// Unconditional rather than a conditional spread, on the same contract sentence and on the
|
|
448
|
+
// `tokens`/`usage`/`session` precedent that null-with-the-key-present is this record's normal case.
|
|
449
|
+
//
|
|
450
|
+
// ADMISSIBILITY, which has to engage this record's own sentences rather than sidestep them. The
|
|
451
|
+
// PII-free-by-construction property rests on holding NO ATTACKER-CHOSEN STRING, and `host`
|
|
452
|
+
// satisfies that absolutely: there is no path from a webhook payload, an issue body, a branch name
|
|
453
|
+
// or a folder to this value. It is fixed once at boot from one environment variable against a
|
|
454
|
+
// charset that excludes `/` and `\`, so it cannot even be path-shaped -- which is the property
|
|
455
|
+
// `targetFor` drops a local folder to its basename to get.
|
|
456
|
+
//
|
|
457
|
+
// What it is NOT is anonymous, and that is worth writing down rather than glossing. The default is
|
|
458
|
+
// `os.hostname()`, and on a personal machine a hostname is often a person's name. The honest word
|
|
459
|
+
// is OPERATOR-DISCLOSED: the operator names their own machines, this record is written to their own
|
|
460
|
+
// disk, that name is already on every packet the machine sends, and `PI_WORKER_NAME` is the
|
|
461
|
+
// documented answer for anyone who wants something else here.
|
|
462
|
+
//
|
|
463
|
+
// Passed in rather than read from a module-level value, so `buildRecord` stays pure and every
|
|
464
|
+
// existing caller keeps getting `null` without knowing this field exists.
|
|
465
|
+
host,
|
|
372
466
|
};
|
|
373
467
|
}
|
|
374
468
|
|
package/src/schedules.mjs
CHANGED
|
@@ -23,7 +23,7 @@ import { parseTriggers } from "./triggers.mjs";
|
|
|
23
23
|
* valid deployment. `readFileSync`/`existsSync` are injectable so tests exercise the full path with no
|
|
24
24
|
* real filesystem.
|
|
25
25
|
*/
|
|
26
|
-
export function loadSchedules(config, { readFileSync = fsReadFileSync, existsSync = fsExistsSync } = {}) {
|
|
26
|
+
export function loadSchedules(config, { readFileSync = fsReadFileSync, existsSync = fsExistsSync, fleet = false } = {}) {
|
|
27
27
|
const path = config.triggersFile;
|
|
28
28
|
if (path === null || path === undefined) return []; // cron disabled
|
|
29
29
|
|
|
@@ -33,14 +33,77 @@ export function loadSchedules(config, { readFileSync = fsReadFileSync, existsSyn
|
|
|
33
33
|
|
|
34
34
|
const triggers = parseTriggers(readFileSync(path, "utf8"), path);
|
|
35
35
|
|
|
36
|
-
return triggers.filter((t) => t.on.type === "cron").map((t) => normalizeCronSchedule(t, path, existsSync));
|
|
36
|
+
return triggers.filter((t) => t.on.type === "cron").map((t) => normalizeCronSchedule(t, path, existsSync, fleet));
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The cron set AS AUTHORED, before any placement decision (issue #57).
|
|
41
|
+
*
|
|
42
|
+
* This is the object two hosts have to agree about, and it is deliberately not `loadSchedules`'s output.
|
|
43
|
+
* That function resolves PLACEMENT -- it replaces every trigger whose folder is on another machine with
|
|
44
|
+
* a stub -- so its result differs per host BY CONSTRUCTION. Fingerprinting it would make every correctly
|
|
45
|
+
* configured fleet refuse itself forever: mini1 owns `/a`, mini2 owns `/b`, their sets never match, and
|
|
46
|
+
* neither ever reconciles again. What they share is the FILE, so the file is what gets hashed.
|
|
47
|
+
*
|
|
48
|
+
* Pure and fs-free apart from the read: no `existsSync`, because existence is exactly the question that
|
|
49
|
+
* makes two honest hosts differ.
|
|
50
|
+
*/
|
|
51
|
+
export function authoredCron(config, { readFileSync = fsReadFileSync, existsSync = fsExistsSync } = {}) {
|
|
52
|
+
const path = config.triggersFile;
|
|
53
|
+
if (path === null || path === undefined) return null; // cron disabled: no opinion at all (see cronFingerprint)
|
|
54
|
+
if (!existsSync(path)) return null;
|
|
55
|
+
try {
|
|
56
|
+
return parseTriggers(readFileSync(path, "utf8"), path)
|
|
57
|
+
.filter((t) => t.on.type === "cron")
|
|
58
|
+
.map((t) => ({ schedulerId: t.on.id, pattern: t.on.pattern, run: t.run }));
|
|
59
|
+
} catch {
|
|
60
|
+
// A file this host cannot parse is not an opinion about what should be scheduled. It refuses boot
|
|
61
|
+
// elsewhere and keeps last-good on reload; here it must not become a fingerprint that disagrees
|
|
62
|
+
// with every peer.
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Split a schedule set into the triggers THIS host serves and the ones it does not (issue #57).
|
|
69
|
+
*
|
|
70
|
+
* `loadSchedules` already refused everything a pure validator could refuse and everything the filesystem
|
|
71
|
+
* could answer for a trigger this host owns. What is left is the third question, and it is the one Gap 2
|
|
72
|
+
* is about: a folder that is not here is not necessarily a mistake, it may simply be another machine's.
|
|
73
|
+
*
|
|
74
|
+
* Exported so the split is testable without an fs, and so a caller can report what it will not be running.
|
|
75
|
+
*/
|
|
76
|
+
export function servedSchedules(schedules) {
|
|
77
|
+
const served = [];
|
|
78
|
+
const unserved = [];
|
|
79
|
+
for (const s of schedules) (s.unserved ? unserved : served).push(s);
|
|
80
|
+
return { served, unserved };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeCronSchedule({ on, run }, path, existsSync, fleet) {
|
|
40
84
|
// The pure validator already guaranteed a non-empty, `:`-free, charset-valid, unique id and a
|
|
41
85
|
// well-formed pattern; folder existence is the one fs-dependent check it deferred to here.
|
|
86
|
+
//
|
|
87
|
+
// ON A FLEET THAT IS THE WRONG QUESTION. `INT-TRIGGERS-FILE-CONTRACT` splits this as "type here,
|
|
88
|
+
// reality where it can be known", and issue #57 adds a third level: PLACEMENT, where the fleet is
|
|
89
|
+
// known. A folder that is absent on THIS machine may simply belong to another one, and refusing the
|
|
90
|
+
// worker's boot for it takes every unrelated trigger -- every forge job, every other folder -- offline
|
|
91
|
+
// with it. That is the sentence #57's own acceptance forbids.
|
|
92
|
+
//
|
|
93
|
+
// `fleet` is `PI_WORKER_NAME` being DECLARED, deliberately, and not a registry read. Two reasons, and
|
|
94
|
+
// both are failures I would otherwise have shipped. A registry read here would make a fleet-wide
|
|
95
|
+
// restart into a fleet-wide boot refusal, because every host would come up seeing no peers yet. And it
|
|
96
|
+
// would have to happen after the Valkey client exists, which is BELOW the four destructive boot sweeps
|
|
97
|
+
// -- so a single-host deployment with one typo'd folder would reap containers, prune history and delete
|
|
98
|
+
// sandboxes on every restart before refusing. Declaring a name is the operator saying "this is a
|
|
99
|
+
// fleet", it is known before anything runs, and it keeps a single-host deployment byte-identical.
|
|
42
100
|
if (!existsSync(run.folder)) {
|
|
43
|
-
|
|
101
|
+
if (!fleet) {
|
|
102
|
+
throw configError(`cron trigger "${on.id}": run.folder does not exist: ${run.folder} (${path})`);
|
|
103
|
+
}
|
|
104
|
+
// Not mine. Its skillsDir is not my business either: `isAbsolute` is OS-dependent, and judging
|
|
105
|
+
// another host's path on my platform is the exact mistake the shared validator refuses to make.
|
|
106
|
+
return { schedulerId: on.id, unserved: "folder-absent" };
|
|
44
107
|
}
|
|
45
108
|
|
|
46
109
|
// `run.skillsDir` gets the same treatment, and for the same reason (REQ-PER-TRIGGER-SKILLS): the pure
|