@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.
@@ -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
- return { resumed: sess.resumed, reason: typeof sess.reason === "string" ? sess.reason : null };
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
- function normalizeCronSchedule({ on, run }, path, existsSync) {
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
- throw configError(`cron trigger "${on.id}": run.folder does not exist: ${run.folder} (${path})`);
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