@edgehero/pi-dispatch 2.0.0 → 3.0.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.
Files changed (80) hide show
  1. package/.env.example +44 -7
  2. package/README.md +14 -6
  3. package/deploy/com.pi-dispatch.worker.plist +1 -1
  4. package/deploy/docker-compose.yml +12 -0
  5. package/deploy/egress-proxy.conf +28 -3
  6. package/deploy/pi-dispatch-egress-proxy.container +8 -2
  7. package/deploy/worker-env-wrapper.cmd +1 -1
  8. package/deploy/worker-env-wrapper.sh +3 -3
  9. package/package.json +9 -2
  10. package/src/allocation.mjs +731 -0
  11. package/src/backends.mjs +243 -0
  12. package/src/budget.mjs +40 -4
  13. package/src/cli.mjs +222 -11
  14. package/src/config.mjs +126 -5
  15. package/src/daemon-facts.mjs +3 -0
  16. package/src/deployment-venue.mjs +1 -0
  17. package/src/doctor.mjs +2316 -183
  18. package/src/dollar-budget.mjs +373 -0
  19. package/src/dollar-fingerprint.mjs +83 -0
  20. package/src/egress-cli.mjs +316 -0
  21. package/src/egress-proxy-state.mjs +35 -5
  22. package/src/egress.mjs +16 -3
  23. package/src/env-allowlist.mjs +142 -18
  24. package/src/env-file.mjs +194 -25
  25. package/src/envelope.mjs +413 -0
  26. package/src/exit-code.mjs +22 -0
  27. package/src/fleet-lease.mjs +85 -25
  28. package/src/get-token.mjs +16 -5
  29. package/src/git-dirty.mjs +67 -0
  30. package/src/github-app-setup.mjs +6 -3
  31. package/src/github-host.mjs +5 -3
  32. package/src/host-pi.mjs +19 -3
  33. package/src/identity.mjs +2 -1
  34. package/src/image-preflight.mjs +98 -24
  35. package/src/image-ref.mjs +37 -0
  36. package/src/import-pi.mjs +4 -2
  37. package/src/index.mjs +407 -62
  38. package/src/init.mjs +18 -0
  39. package/src/job-id.mjs +26 -3
  40. package/src/live-probes.mjs +24 -9
  41. package/src/model-catalog.mjs +297 -0
  42. package/src/model-endpoints.mjs +649 -0
  43. package/src/model-ref.mjs +151 -0
  44. package/src/models-json.mjs +262 -0
  45. package/src/money.mjs +144 -0
  46. package/src/octokit-log.mjs +65 -0
  47. package/src/outbox-plan.mjs +218 -0
  48. package/src/outbox.mjs +29 -9
  49. package/src/output-cap.mjs +157 -0
  50. package/src/packages.mjs +2 -2
  51. package/src/pause-windows.mjs +81 -2
  52. package/src/pi-model-loader.mjs +77 -0
  53. package/src/podman-stack.mjs +16 -3
  54. package/src/portfolio-snapshot.mjs +304 -0
  55. package/src/prepare-local.mjs +247 -12
  56. package/src/prepare.mjs +35 -3
  57. package/src/pricing.mjs +9 -5
  58. package/src/priorities.mjs +569 -0
  59. package/src/processor.mjs +603 -173
  60. package/src/project-id.mjs +17 -0
  61. package/src/projects.mjs +238 -0
  62. package/src/provider-key.mjs +32 -7
  63. package/src/provider-steering.mjs +214 -59
  64. package/src/queue.mjs +111 -6
  65. package/src/reserved-env.mjs +30 -0
  66. package/src/run-container.mjs +59 -5
  67. package/src/run-history.mjs +379 -24
  68. package/src/run-mirror.mjs +30 -0
  69. package/src/runtime-settings.mjs +104 -9
  70. package/src/schedules.mjs +33 -1
  71. package/src/scoped-limits.mjs +447 -27
  72. package/src/secrets.mjs +2 -1
  73. package/src/service.mjs +15 -4
  74. package/src/session-store.mjs +131 -6
  75. package/src/start.mjs +528 -40
  76. package/src/subscriptions.mjs +7 -3
  77. package/src/triggers-file.mjs +65 -4
  78. package/src/triggers.mjs +140 -9
  79. package/src/up.mjs +308 -34
  80. package/src/valkey-endpoint.mjs +3 -2
@@ -0,0 +1,218 @@
1
+ import * as nodeFs from "node:fs";
2
+ import { join } from "node:path";
3
+ import { PLAN_MAX_BYTES, parsePlan } from "./priorities.mjs";
4
+
5
+ /**
6
+ * The plan collector (issue #505, INT-OUTBOX-CONTRACT's second file, DES-JOB-OUTBOX-CHAINING's second kind): the host-side
7
+ * reader of a completed portfolio job's `/outbox/priorities.json`, which it hands to `applyPlan` (allocation.mjs) with
8
+ * the writer `{ kind: "portfolio-job", jobId, triggerId }`.
9
+ *
10
+ * A SEPARATE FILE, never a `request-<n>.json` with a `type` key: a plan would then share the chain count cap
11
+ * (`PI_CHAIN_MAX_PER_JOB`), and a worker too old to know the key would refuse it as `chain-bad-flow-name`, a reason that
12
+ * says nothing about plans. An older worker never opens a file it does not know.
13
+ *
14
+ * NEVER THROWS, the rule `makeCollectChain` keeps and for its reason: this runs after a completed, PAID container, so a
15
+ * throw would turn that completion into a retry and pay again for one answer (CONST-RETRY-INFRA-ONLY). Every fault is
16
+ * caught, logged as a fixed token, and recorded as `plan-collect-error`. A refused plan is a recorded outcome of a
17
+ * completed job, never a failed job.
18
+ *
19
+ * The ladder, failing closed at the first miss (each refusal a fixed token, `PLAN_COLLECT_REASONS`):
20
+ * 1. No `/outbox/priorities.json` (or no outbox: a forge job has none). Only ENOENT and ENOTDIR mean "no file", and
21
+ * the answer waits for rung 2's three checks (issue #507). A job all three confirm as a portfolio job is refused as
22
+ * `plan-absent`, recorded like every refusal below: the job exists to write a plan, so writing none is the
23
+ * trigger's own attempt, and the operator and the next manager run (its `lastAttempt`) must see "wrote nothing"
24
+ * rather than last week's answer. Every other job that writes no plan says `plan: null` and writes no row, so an
25
+ * unflagged job is byte-identical to before (and a job whose flag went away while it ran is no longer asked for a
26
+ * plan). Any other lstat answer (an outbox the job made unreadable) is judged by rung 2 first: a job that was never
27
+ * a portfolio job then has no plan (`null`), and a confirmed one meets rung 4, which reads it again.
28
+ * 2. Portfolio authority, `plan-not-portfolio`: the job was a portfolio job AT PICKUP (the processor's `portfolio`
29
+ * decision: the flag on the data, a cron `trigger`, no chain field, and the live file), it still has the flag, a
30
+ * trigger and no chain field on its data, prepare AGREED (it wrote the snapshot, `prepared.portfolio`), and the
31
+ * LIVE triggers file still flags that same entry NOW. All three, so an operator who removes the flag while the job
32
+ * runs is obeyed, a job that ran without the facts writes no plan, and a flag added back mid-run grants nothing to
33
+ * a job that started as an ordinary one.
34
+ * WHERE it is recorded depends on who was refused. A job that was never a portfolio job at pickup (a manual run, a
35
+ * chained child, an unflagged cron job) is refused in its run record and its log line ONLY: no audit row and
36
+ * no `alloc:log` row, because `alloc:log` holds 500 rows, is the panel's revert history and the next manager's
37
+ * `lastAttempt`, and any local job could otherwise wipe it one row per run. A job the pickup confirmed whose flag
38
+ * went away later (at prepare or at collection) is the trigger's own attempt, so it is recorded like every other
39
+ * refusal below.
40
+ * 3. Size, `plan-oversize`: more than 16 KiB (`PLAN_MAX_BYTES`), from `lstat`, before anything is opened.
41
+ * 4. A regular file, `plan-not-regular-file`: `lstat`, which never follows a link, so a symlink is refused on its own
42
+ * inode. Then the file is OPENED with `O_NOFOLLOW` (a link swapped in after the lstat fails with ELOOP) and
43
+ * `O_NONBLOCK` (a FIFO swapped in cannot hang the worker on open), and the OPEN descriptor is `fstat`ed: regular and
44
+ * within the size again, so what is read is what was judged. At most `PLAN_MAX_BYTES + 1` bytes are read, so a
45
+ * file that grows after the fstat is still refused as oversize. Any other fs fault is `plan-unreadable`.
46
+ * 5. JSON with an object root, `plan-parse-error`.
47
+ * 6. `applyPlan`, which judges the plan (`plan-invalid` naming one field) and applies the ladder of
48
+ * DES-DELEGATED-ALLOCATION-INSIDE-ENVELOPE (`delegation-off` through `plan-busy`).
49
+ * Rung 1 (`plan-absent`), rung 2 (as above) and rungs 3 to 5 are recorded by `recordRefusal` (a file row and an
50
+ * `alloc:log` row, the reason and nothing of the body); rung 6 records its own. So no refusal of a portfolio job is
51
+ * silent, and the next snapshot's `lastAttempt` tells the next manager run. `plan-collect-error` means the outcome is UNKNOWN: a fault after the compare-and-set was
52
+ * sent (a lost reply) may hide a plan that applied, so the record carries the plan's id when it could be computed, and
53
+ * `alloc:plan` and the audit file are the truth.
54
+ *
55
+ * Returns the record's `plan`: `{ outcome, reason, planId, clamped }` (`outcome` one of `applied`, `duplicate`,
56
+ * `refused`), or null when there was no file and the job was not a confirmed portfolio job. The worker log line is
57
+ * `plan_collected { jobId, outcome, reason }`.
58
+ */
59
+
60
+ /** The collector's own refusals, in ladder order, and the catch-all for a fault inside it. */
61
+ export const PLAN_ABSENT = "plan-absent";
62
+ export const PLAN_NOT_PORTFOLIO = "plan-not-portfolio";
63
+ export const PLAN_OVERSIZE = "plan-oversize";
64
+ export const PLAN_NOT_REGULAR_FILE = "plan-not-regular-file";
65
+ export const PLAN_UNREADABLE = "plan-unreadable";
66
+ export const PLAN_PARSE_ERROR = "plan-parse-error";
67
+ export const PLAN_COLLECT_ERROR = "plan-collect-error";
68
+ export const PLAN_COLLECT_REASONS = Object.freeze([PLAN_ABSENT, PLAN_NOT_PORTFOLIO, PLAN_OVERSIZE, PLAN_NOT_REGULAR_FILE, PLAN_UNREADABLE, PLAN_PARSE_ERROR, PLAN_COLLECT_ERROR]);
69
+
70
+ /** The plan file's name in `/outbox`. */
71
+ export const PLAN_FILE = "priorities.json";
72
+
73
+ const HEX16_RE = /^[0-9a-f]{16}$/;
74
+ const TOKEN_RE = /^[a-z][a-z0-9-]{0,63}$/;
75
+
76
+ class Refusal extends Error {
77
+ constructor(reason) {
78
+ super(reason);
79
+ this.reason = reason;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Read the plan file under rungs 3 and 4, or throw a `Refusal`. Returns the text. `fs` needs `lstatSync`, `openSync`,
85
+ * `fstatSync`, `readSync`, `closeSync` and `constants`.
86
+ */
87
+ function readPlanFile(fs, path) {
88
+ const pre = fs.lstatSync(path);
89
+ if (pre.size > PLAN_MAX_BYTES) throw new Refusal(PLAN_OVERSIZE);
90
+ if (!pre.isFile()) throw new Refusal(PLAN_NOT_REGULAR_FILE);
91
+ const { O_RDONLY, O_NOFOLLOW, O_NONBLOCK } = fs.constants ?? nodeFs.constants;
92
+ let fd;
93
+ try {
94
+ fd = fs.openSync(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
95
+ } catch (error) {
96
+ // ELOOP: a link was swapped in after the lstat. ENXIO: a socket. Both are files of the wrong kind.
97
+ if (error?.code === "ELOOP" || error?.code === "ENXIO") throw new Refusal(PLAN_NOT_REGULAR_FILE);
98
+ throw error;
99
+ }
100
+ try {
101
+ const st = fs.fstatSync(fd);
102
+ if (!st.isFile()) throw new Refusal(PLAN_NOT_REGULAR_FILE);
103
+ if (st.size > PLAN_MAX_BYTES) throw new Refusal(PLAN_OVERSIZE);
104
+ const buf = Buffer.alloc(PLAN_MAX_BYTES + 1);
105
+ let got = 0;
106
+ for (;;) {
107
+ const n = fs.readSync(fd, buf, got, buf.length - got, null);
108
+ if (n === 0) break;
109
+ got += n;
110
+ if (got > PLAN_MAX_BYTES) throw new Refusal(PLAN_OVERSIZE);
111
+ }
112
+ return buf.subarray(0, got).toString("utf8");
113
+ } finally {
114
+ try {
115
+ fs.closeSync(fd);
116
+ } catch {}
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Build the collector. `allocation` is the worker's allocation state (`applyPlan`, `recordRefusal`), `governing()` this
122
+ * host's `{ envelope, digest }` or null, `projects()` the live parsed projects.json, `checkPortfolioFlag(jobData)` the
123
+ * live-file check (triggers-file.mjs). `now` is injected.
124
+ */
125
+ export function makeCollectPlan({ allocation, governing = () => null, projects = () => [], checkPortfolioFlag = async () => false, fs = nodeFs, log = () => {}, now = () => new Date() }) {
126
+ return async function collectPlan({ job, prepared, portfolio = false }) {
127
+ const data = job?.data ?? {};
128
+ const jobId = job?.id ?? null;
129
+ const result = (outcome, reason, planId = null, clamped = false) => {
130
+ log("plan_collected", { jobId, outcome, reason });
131
+ return { outcome, reason, planId: typeof planId === "string" && HEX16_RE.test(planId) ? planId : null, clamped: clamped === true };
132
+ };
133
+ const writer = { kind: "portfolio-job", jobId, triggerId: typeof data.trigger?.id === "string" ? data.trigger.id : null };
134
+ // `audit: false` for a job that was never a portfolio job: the run record and the log line only.
135
+ const refuse = async (reason, at, { audit = true } = {}) => {
136
+ if (!audit) return result("refused", reason);
137
+ try {
138
+ await allocation.recordRefusal({ writer, reason, digest: governing()?.digest ?? null, now: at });
139
+ } catch (error) {
140
+ // The run record still says it; the audit row is what is lost, and the line says so.
141
+ log("plan_refusal_row_lost", { jobId, reason, code: typeof error?.code === "string" ? error.code : "error" });
142
+ }
143
+ return result("refused", reason);
144
+ };
145
+ let at;
146
+ let text = null;
147
+ try {
148
+ at = now();
149
+ if (data.kind !== "local" || typeof prepared?.jobDir !== "string") return null;
150
+ const path = join(prepared.jobDir, "outbox", PLAN_FILE);
151
+ // Rung 1: is there a file? lstat, so a dangling link is a file that is there (and refused below), not "none".
152
+ // Nothing is decided here (issue #507): whether "no file" is `null` or `plan-absent` depends on rung 2, and any
153
+ // other error is judged by rung 2 first too, then read again by rung 4.
154
+ let present = true;
155
+ let absent = false;
156
+ try {
157
+ fs.lstatSync(path);
158
+ } catch (error) {
159
+ absent = error?.code === "ENOENT" || error?.code === "ENOTDIR";
160
+ present = false;
161
+ }
162
+
163
+ // Rung 2: the pickup decision, prepare and the live file, all three.
164
+ const shaped = data.portfolio === true && data.trigger !== undefined && data.parentJobId === undefined && data.chainDepth === undefined;
165
+ // A job that was never a portfolio job, with no file PROVEN present (none, or an outbox that could not be read),
166
+ // has no plan: the record stays `plan: null`, so an unflagged job that writes no plan is byte-identical whatever
167
+ // its outbox.
168
+ if (portfolio !== true || !shaped) return present ? await refuse(PLAN_NOT_PORTFOLIO, at, { audit: false }) : null;
169
+ // A confirmed job whose flag went away at prepare or since, with no file: nobody asks it for a plan any more, so
170
+ // it has none to be refused. With a file, that file is the trigger's attempt, refused and recorded.
171
+ if (prepared.portfolio !== true) return absent ? null : await refuse(PLAN_NOT_PORTFOLIO, at);
172
+ const live = await Promise.resolve()
173
+ .then(() => checkPortfolioFlag(data))
174
+ .catch(() => false);
175
+ if (live !== true) return absent ? null : await refuse(PLAN_NOT_PORTFOLIO, at);
176
+ // All three agree and there is no file: the portfolio job wrote no plan, and it says so (issue #507).
177
+ if (absent) return await refuse(PLAN_ABSENT, at);
178
+
179
+ // Rungs 3 and 4.
180
+ try {
181
+ text = readPlanFile(fs, path);
182
+ } catch (error) {
183
+ if (error instanceof Refusal) return await refuse(error.reason, at);
184
+ return await refuse(PLAN_UNREADABLE, at);
185
+ }
186
+
187
+ // Rung 5. Never the parser's message: it quotes the agent's text.
188
+ let parsed;
189
+ try {
190
+ parsed = JSON.parse(text);
191
+ } catch {
192
+ return await refuse(PLAN_PARSE_ERROR, at);
193
+ }
194
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return await refuse(PLAN_PARSE_ERROR, at);
195
+
196
+ // Rung 6: the plan's own judgement and the apply ladder, recorded by applyPlan itself.
197
+ const g = governing() ?? null;
198
+ const applied = await allocation.applyPlan({ envelope: g?.envelope ?? null, digest: g?.digest ?? null, projects: projects(), text, writer, now: at });
199
+ const reason = typeof applied?.reason === "string" && TOKEN_RE.test(applied.reason) ? applied.reason : null;
200
+ if (applied?.outcome === "applied") return result("applied", null, applied.planId, applied.clamped);
201
+ if (applied?.outcome === "duplicate") return result("duplicate", reason, applied.planId);
202
+ // `apply-failed` (a CAS lost to another writer) is a refusal as `plan-stale`: the plan did not apply.
203
+ return result("refused", reason ?? PLAN_COLLECT_ERROR, applied?.planId ?? null);
204
+ } catch (error) {
205
+ // Infrastructure inside applyPlan (Valkey, the audit file) or a defect here: logged by its code, recorded as a
206
+ // refusal, and the completed job stays completed.
207
+ log("plan_collect_failed", { jobId, code: typeof error?.code === "string" ? error.code : "error" });
208
+ // The plan's id when the text is in hand, so a plan that applied behind a lost reply can still be found. The id
209
+ // is a property of the text alone (INT-PRIORITIES-PLAN-CONTRACT), so it is computed without the envelope and with
210
+ // the widest plan life, which only the validity of `validUntil` depends on.
211
+ let planId = null;
212
+ try {
213
+ if (typeof text === "string") planId = parsePlan(text, { now: at, maxPlanDays: 366 }).id ?? null;
214
+ } catch {}
215
+ return result("refused", PLAN_COLLECT_ERROR, planId);
216
+ }
217
+ };
218
+ }
package/src/outbox.mjs CHANGED
@@ -158,19 +158,36 @@ export function makeCollectChain({ queue, enqueue = enqueueLocalJob, readFlowGat
158
158
 
159
159
  // Enqueue an ordinary local job on the parent's OWN folder (folder is forced, not read from
160
160
  // the outbox). The child id is retry-idempotent so a retried parent dedups instead of fanning out.
161
+ // The folder AS THE PARENT NAMED IT (issue #504 part B), never `prepared.workspace`, which is now the folder
162
+ // resolved at prepare: the named spelling is what matched the parent's scoped-limits row, its project member and
163
+ // its folder mutex, and the child's own prepare resolves and judges it again.
161
164
  await enqueue(queue, {
162
- folder: prepared.workspace,
165
+ folder: job.data?.folder ?? prepared.workspace,
163
166
  flow,
164
167
  task,
165
- // The child runs the parent's OWN folder, so it needs the parent's toolchain by definition -- and
166
- // this is where `image` differs from provider/model, which are deliberately NOT inherited. A
167
- // fallback provider still runs the flow; a fallback IMAGE gives a child that cannot find its tools,
168
- // writes a plausible report and exits 0, which is the queue-reports-success failure class arriving
169
- // by the back door. Read off `job.data` -- the parent's own validated job data -- and NEVER off
170
- // `req`: the agent cannot choose its child's image any more than it can choose its folder or depth
171
- // (INT-OUTBOX-CONTRACT's explicit-property-reads rule). Undefined stays undefined, so a parent with
172
- // no image chains a child whose data is byte-identical to today's.
168
+ // The child runs the parent's OWN folder, so it needs the parent's toolchain by definition: a
169
+ // fallback IMAGE gives a child that cannot find its tools, writes a plausible report and exits 0,
170
+ // which is the queue-reports-success failure class arriving by the back door. Read off `job.data`
171
+ // -- the parent's own validated job data -- and NEVER off `req`: the agent cannot choose its
172
+ // child's image any more than it can choose its folder or depth (INT-OUTBOX-CONTRACT's
173
+ // explicit-property-reads rule). Undefined stays undefined, so a parent with no image chains a
174
+ // child whose data is byte-identical to today's.
173
175
  image: job.data?.image,
176
+ // Issue #502: provider, model and the allowed-model list are INHERITED, and they used not to be.
177
+ // The old reason was "a fallback provider still runs the flow", true while a model was only a
178
+ // preference. Once a trigger can name the models its jobs may call, a child on the deployment
179
+ // default is a child outside the parent's policy: a cheap-model triage trigger would chain a
180
+ // child on the dearest model, and a listed parent would chain an unlisted child. Off `job.data`
181
+ // (the parent's own trigger fields, before the overlay and env fill), never off `req`, so the
182
+ // agent can neither pick its child's model nor drop the list by omitting a key. A parent whose
183
+ // trigger named none chains a child that resolves the default at its own start, as before, and
184
+ // a parent on the deployment's PI_ALLOWED_MODELS chains a child the same env list governs.
185
+ provider: job.data?.provider,
186
+ model: job.data?.model,
187
+ models: job.data?.models,
188
+ // Issue #501's per-job dollar cap, by the same narrowing rule: a child that dropped it would run
189
+ // uncapped. Passed through whenever the parent's data carries it; absent stays absent.
190
+ maxCostUsd: job.data?.maxCostUsd,
174
191
  // #227, and INHERITED for the reason `image` directly above is: a chained child continues its
175
192
  // parent's work, so it belongs in the venue the parent's trigger chose, not silently back on
176
193
  // the deployment default. Written down rather than left to inference because this file's
@@ -199,6 +216,9 @@ export function makeCollectChain({ queue, enqueue = enqueueLocalJob, readFlowGat
199
216
  // it wrote for itself. The operator's grant was to the trigger they reviewed, not to whatever
200
217
  // that job decides to queue next. A chained child that genuinely needs a secret gets it from a
201
218
  // trigger of its own, which is an operator edit to a reviewed file.
219
+ // `portfolio` (issue #505) is ABSENT for the same reason, and more plainly: it is budget authority, granted
220
+ // to one cron trigger the operator reviewed. A child is an agent's request, so inheriting the flag would let
221
+ // a portfolio job queue a second plan writer of its own choosing.
202
222
  chainDepth: childDepth,
203
223
  parentJobId: job.id,
204
224
  // chainedJobId deliberately does NOT take the image: the child's identity is (parent, flow, task).
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Whether the runner's cost guard can bound a model's output (issue #507), asked from the worker side for doctor. The
3
+ * rule is the runner's (`completionsOwnServer` and `callCostBound`, image/runner/src/usage-meter.mjs): on
4
+ * openai-completions to a host outside the hosts below, only a cap sent as `max_tokens` bounds the output, so a
5
+ * priced model whose composed `compat.maxTokensField` is not `"max_tokens"` is refused at its first call under a
6
+ * dollar cap.
7
+ *
8
+ * COPIED, not imported: the job image ships `image/runner` alone and the worker package ships `src` alone, so neither
9
+ * can import the other (the same reason `FIRST_CALL_OVERHEAD_TOKENS` is a copy in doctor.mjs).
10
+ * `worker/test/output-cap.test.mjs` holds both lists equal to the runner's and the predicate to the runner's verdict.
11
+ * Pure, and it never imports pi: the builtin catalog lookups are handed in.
12
+ */
13
+
14
+ import { composedCost, endpointsForModel, isZeroCost, modelEntryOf } from "./model-endpoints.mjs";
15
+
16
+ /** The hosts pi's catalog serves openai-completions on: the runner's `COMPLETIONS_CATALOG_HOSTS`, copied. */
17
+ export const COMPLETIONS_CATALOG_HOSTS = Object.freeze([
18
+ "api.ant-ling.com",
19
+ "api.cerebras.ai",
20
+ "api.cloudflare.com",
21
+ "api.deepseek.com",
22
+ "api.fireworks.ai",
23
+ "api.groq.com",
24
+ "api.individual.githubcopilot.com",
25
+ "api.moonshot.ai",
26
+ "api.moonshot.cn",
27
+ "api.together.ai",
28
+ "api.xiaomimimo.com",
29
+ "api.z.ai",
30
+ "gateway.ai.cloudflare.com",
31
+ "inference.baseten.co",
32
+ "integrate.api.nvidia.com",
33
+ "open.bigmodel.cn",
34
+ "opencode.ai",
35
+ "openrouter.ai",
36
+ "router.huggingface.co",
37
+ "token-plan-ams.xiaomimimo.com",
38
+ "token-plan-cn.xiaomimimo.com",
39
+ "token-plan-sgp.xiaomimimo.com",
40
+ "token-plan.ap-southeast-1.maas.aliyuncs.com",
41
+ "token-plan.cn-beijing.maas.aliyuncs.com",
42
+ ]);
43
+
44
+ /** The runner's `COMPLETIONS_EXTRA_HOSTS`, copied: api.openai.com reads the field pi sends it. */
45
+ export const COMPLETIONS_EXTRA_HOSTS = Object.freeze(["api.openai.com"]);
46
+
47
+ /** The runner's `completionsOwnServer`, copied: `{ api, baseUrl }` on openai-completions to a host neither list names. */
48
+ export function completionsOwnServer(model) {
49
+ if (model?.api !== "openai-completions") return false;
50
+ try {
51
+ const host = new URL(model.baseUrl).hostname;
52
+ return !COMPLETIONS_CATALOG_HOSTS.includes(host) && !COMPLETIONS_EXTRA_HOSTS.includes(host);
53
+ } catch {
54
+ return true;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * The parts of a model the rule reads, composed as pi 0.99.1's provider-composer.js composes them from the overlay
60
+ * `models.json` (`models`, parsed, or null) and the builtin catalog (`builtinModel(provider, id)`, injected):
61
+ * - a model the overlay DEFINES: its own `api` and `baseUrl`, else its provider's, else those of pi's DEFAULTS
62
+ * model (`findModelDefaults`, mirrored in `definedDefaults`); its compat field from its `modelOverrides` entry,
63
+ * else its own, else its provider's (a defined model takes no builtin compat);
64
+ * - a BUILTIN model: its own `api`; the provider's `baseUrl` over its own; its compat field from the override, else
65
+ * the provider's, else the catalog's.
66
+ * The cost is `composedCost`'s. Null when neither the overlay nor the catalog knows the model. `builtinChatModels`
67
+ * (the provider's catalog chat models, injected) feeds the defaults.
68
+ */
69
+ export function outputCapView({ models, provider, modelId, builtinModel = () => null, builtinChatModels = () => [] }) {
70
+ const { entry, defined, override } = modelEntryOf(models, provider, modelId);
71
+ const builtin = typeof builtinModel === "function" ? builtinModel(provider, modelId) : null;
72
+ const field = (compat) => (compat !== null && typeof compat === "object" ? compat.maxTokensField : undefined);
73
+ const cost = composedCost({ models, provider, modelId, builtinModel });
74
+ if (defined) {
75
+ const composed = definedDefaults(entry, modelId, typeof builtinChatModels === "function" ? builtinChatModels(provider) : []);
76
+ return {
77
+ api: composed?.api,
78
+ baseUrl: composed?.baseUrl,
79
+ maxTokensField: field(override?.compat) ?? field(defined.compat) ?? field(entry?.compat),
80
+ cost,
81
+ };
82
+ }
83
+ if (builtin && typeof builtin === "object") {
84
+ return {
85
+ api: str(builtin.api),
86
+ baseUrl: str(entry?.baseUrl) ?? str(builtin.baseUrl),
87
+ maxTokensField: field(override?.compat) ?? field(entry?.compat) ?? field(builtin.compat),
88
+ cost,
89
+ };
90
+ }
91
+ return null;
92
+ }
93
+
94
+ const str = (v) => (typeof v === "string" ? v : undefined);
95
+
96
+ /**
97
+ * The `{ api, baseUrl }` pi 0.99.1 composes for the overlay-defined model `modelId` (provider-composer.js
98
+ * `applyModelsJson`): the provider's chat models start as the catalog's (each on the provider's `baseUrl` when it sets
99
+ * one), and each definition in the file's order is composed and then replaces the model of its id or joins the list.
100
+ * A definition's `api` is its own, else the provider's, else its DEFAULTS model's, and its `baseUrl` likewise, where the
101
+ * defaults are `findModelDefaults` over the list so far: the model of the same id, else one of the definition's api,
102
+ * else the first openai-completions model, else the first. Null when no definition has that id.
103
+ */
104
+ function definedDefaults(entry, modelId, catalogChat) {
105
+ const list = (Array.isArray(catalogChat) ? catalogChat : [])
106
+ .filter((m) => m !== null && typeof m === "object" && typeof m.id === "string" && (m.type ?? "chat") === "chat")
107
+ .map((m) => ({ id: m.id, api: str(m.api), baseUrl: str(entry?.baseUrl) ?? str(m.baseUrl) }));
108
+ let found = null;
109
+ for (const d of Array.isArray(entry?.models) ? entry.models : []) {
110
+ if (d === null || typeof d !== "object" || typeof d.id !== "string") continue;
111
+ const wanted = str(d.api) ?? str(entry.api);
112
+ const defaults = list.find((m) => m.id === d.id) ?? (wanted ? list.find((m) => m.api === wanted) : undefined) ?? list.find((m) => m.api === "openai-completions") ?? list[0];
113
+ const composed = { id: d.id, api: wanted ?? defaults?.api, baseUrl: str(d.baseUrl) ?? str(entry.baseUrl) ?? defaults?.baseUrl };
114
+ const at = list.findIndex((m) => m.id === d.id);
115
+ if (at >= 0) list[at] = composed;
116
+ else list.push(composed);
117
+ if (d.id === modelId) found = composed;
118
+ }
119
+ return found;
120
+ }
121
+
122
+ /**
123
+ * Is every priced call on this model refused under a dollar cap for its output cap? True for a view on
124
+ * openai-completions to an own server whose field is not `"max_tokens"`, unless its cost is all zeros (the runner
125
+ * bounds a zero-rated model at 0 before it asks about output). A cost that cannot be told counts as priced.
126
+ */
127
+ export function outputUnboundable(view) {
128
+ if (!view || !completionsOwnServer(view)) return false;
129
+ if (view.maxTokensField === "max_tokens") return false;
130
+ return !(view.cost !== null && isZeroCost(view.cost));
131
+ }
132
+
133
+ /**
134
+ * The priced models a declared endpoint serves whose output the runner cannot bound (`outputUnboundable`), as
135
+ * `[{ provider, modelId }]` in the overlay's order: each model the overlay defines, then each builtin chat model of a
136
+ * provider it names (`builtinChatModels`, injected), which takes the provider's `baseUrl`. Only models
137
+ * `endpointsForModel` puts on a declared endpoint.
138
+ */
139
+ export function ignoredOutputCapModels({ models, endpoints, builtinModel = () => null, builtinChatModels = () => [] }) {
140
+ const providers = models?.providers;
141
+ if (providers === null || typeof providers !== "object" || Array.isArray(providers) || !Array.isArray(endpoints) || endpoints.length === 0) return [];
142
+ const found = [];
143
+ for (const provider of Object.keys(providers)) {
144
+ const { entry } = modelEntryOf(models, provider, "");
145
+ if (entry === null) continue;
146
+ const defined = (Array.isArray(entry.models) ? entry.models : []).filter((m) => m !== null && typeof m === "object" && typeof m.id === "string");
147
+ const listed = typeof builtinChatModels === "function" ? builtinChatModels(provider) : [];
148
+ const builtins = (Array.isArray(listed) ? listed : []).filter((b) => b !== null && typeof b === "object" && typeof b.id === "string" && !defined.some((m) => m.id === b.id));
149
+ for (const modelId of [...defined.map((m) => m.id), ...builtins.map((b) => b.id)]) {
150
+ const lookup = (p, id) => (p === provider ? (builtins.find((b) => b.id === id) ?? (typeof builtinModel === "function" ? builtinModel(p, id) : null)) : null);
151
+ if (!outputUnboundable(outputCapView({ models, provider, modelId, builtinModel: lookup, builtinChatModels }))) continue;
152
+ if (endpointsForModel({ models, provider, modelId, endpoints }).length === 0) continue;
153
+ found.push({ provider, modelId });
154
+ }
155
+ }
156
+ return found;
157
+ }
package/src/packages.mjs CHANGED
@@ -61,7 +61,7 @@ export const NPM_NAME_RE = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
61
61
  * would add an edge to the import-pi <-> packages cycle that only survives because both sides use their
62
62
  * bindings at call time.
63
63
  *
64
- * This list is pi's, not ours: at the 0.80.7 pin `collectPackageResources` falls through to exactly these
64
+ * This list is pi's, not ours: at the 0.99.1 pin (as at 0.80.7) `collectPackageResources` falls through to exactly these
65
65
  * four directory names when `readPiManifest` returns null, so a package with no `pi` key and a `skills/`
66
66
  * dir IS a pi package. See host-pi.mjs's PINNED_PI_NEEDLES for the assertion that keeps that true.
67
67
  */
@@ -281,7 +281,7 @@ export function readStageManifest({ globalPiDir, readFile = readFileSync, fileEx
281
281
  * readStageManifest's policy, because the consumers are advisory (doctor's per-trigger flow lines,
282
282
  * and issue #188's topology) and a half-staged tree must degrade to "nothing visible", not a crash.
283
283
  *
284
- * The semantics mirror pi's collectPackageResources at the 0.80.7 pin EXACTLY, because an enumerator
284
+ * The semantics mirror pi's collectPackageResources at the 0.99.1 pin EXACTLY (unchanged since 0.80.7), because an enumerator
285
285
  * that agrees with pi by hand is how doctor comes to report a tier pi then ignores:
286
286
  * - a `pi` manifest object means its `skills` entries are the ONLY sources -- a manifest WITHOUT a
287
287
  * `skills` key contributes NO skills and gets NO convention fallback (readPiManifest short-circuits
@@ -14,7 +14,9 @@
14
14
  */
15
15
 
16
16
  import { existsSync as fsExistsSync, readFileSync as fsReadFileSync } from "node:fs";
17
+ import { isAbsolute } from "node:path";
17
18
  import { configError } from "./config.mjs";
19
+ import { FORGE_KINDS, isForgeKind } from "./forges.mjs";
18
20
 
19
21
  // Sunday-first to match JS getUTCDay() and the Intl weekday index used in zonedParts().
20
22
  const DAYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
@@ -77,6 +79,18 @@ function normalizeWindow(w, index, path) {
77
79
  const at = `pause window at index ${index}`;
78
80
  if (w === null || typeof w !== "object") throw configError(`${at}: must be an object: ${path}`);
79
81
  if (!isNonEmptyString(w.scope)) throw configError(`${at}: scope must be a non-empty string: ${path}`);
82
+ // Issue #498: a qualified scope (`github:acme/web`) pauses one forge's repo, and a near miss (`gitub:acme/web`)
83
+ // is refused rather than kept as a window that pauses nothing. A bare or folder scope is stored exactly as it was.
84
+ let scope = w.scope.trim();
85
+ if (scope !== "*") {
86
+ let parsed;
87
+ try {
88
+ parsed = parseScopeString(scope);
89
+ } catch (error) {
90
+ throw configError(`${at}: ${error.message}: ${path}`);
91
+ }
92
+ if (parsed.type === "qualified") scope = `${parsed.kind}:${parsed.repo}`;
93
+ }
80
94
 
81
95
  const fromMin = parseHHMM(w.from, "from", at, path);
82
96
  const toMin = parseHHMM(w.to, "to", at, path);
@@ -105,7 +119,7 @@ function normalizeWindow(w, index, path) {
105
119
  if (w.dateTo !== undefined) { assertDate(w.dateTo, "dateTo", at, path); dateTo = String(w.dateTo).trim(); }
106
120
  if (dateFrom && dateTo && dateFrom > dateTo) throw configError(`${at}: dateFrom must be <= dateTo: ${path}`);
107
121
 
108
- const norm = { scope: w.scope.trim(), from: w.from.trim(), to: w.to.trim(), fromMin, toMin, tz };
122
+ const norm = { scope, from: w.from.trim(), to: w.to.trim(), fromMin, toMin, tz };
109
123
  if (days) norm.days = days;
110
124
  if (dateFrom) norm.dateFrom = dateFrom;
111
125
  if (dateTo) norm.dateTo = dateTo;
@@ -199,18 +213,83 @@ export function scopeOf(job) {
199
213
  return job?.kind === "local" ? job?.folder : job?.repo;
200
214
  }
201
215
 
216
+ /**
217
+ * The forge-qualified scope of a job (issue #498): `<kind>:<repo>` (NFC) for a forge job, such as `github:acme/web` or
218
+ * `forgejo:acme/web`, the folder unchanged for a local job, and null when a forge job has no `kind` or no `repo`.
219
+ * `scopeOf` keeps returning the bare repo, because existing pause windows and scoped-limit rows match on it; this is
220
+ * the second spelling a row may use to name exactly one forge's repo.
221
+ */
222
+ export function qualifiedScopeOf(job) {
223
+ if (job?.kind === "local") return job?.folder ?? null;
224
+ if (!isNonEmptyString(job?.kind) || !isNonEmptyString(job?.repo)) return null;
225
+ return `${job.kind}:${job.repo}`.normalize("NFC");
226
+ }
227
+
228
+ /**
229
+ * One segment of a forge repo path: no `/`, `#`, `:` or control character, and no whitespace at its start or end.
230
+ * Whitespace INSIDE a segment is allowed for every forge, because Azure DevOps project and repository names may hold
231
+ * spaces (`Fabrikam Fiber/Web App`) and the receiver copies them verbatim. One rule for every kind is the simpler one;
232
+ * its cost is that `github:acme web` loads as a cap that guards nothing, as a mistyped bare row always has.
233
+ */
234
+ const REPO_SEGMENT = "[^/\\s#:\\u0000-\\u001f\\u007f](?:[^/#:\\u0000-\\u001f\\u007f]*[^/\\s#:\\u0000-\\u001f\\u007f])?";
235
+ /** A forge repo path: two or more `REPO_SEGMENT`s joined by single `/`, so none is empty. */
236
+ const QUALIFIED_REPO = new RegExp(`^${REPO_SEGMENT}(?:/${REPO_SEGMENT})+$`, "u");
237
+
238
+ /**
239
+ * Classify a scope as written in an operator file (issue #498): `{ type, kind, repo }` where `type` is
240
+ * - `local`: an absolute folder path (platform-native `isAbsolute`), or a path with a one-letter drive prefix
241
+ * (`C:\srv`), which keeps the handling it always had: verbatim, and inert on a POSIX worker;
242
+ * - `qualified`: `<forge kind>:<repo>`, `kind` one of `FORGE_KINDS` and `repo` the NFC rest;
243
+ * - `bare`: anything else, such as `owner/name`, which matches that repo on every forge.
244
+ * A `<word>:` prefix (no `/` before the first `:`) that is not a forge kind THROWS a `configError` naming the known
245
+ * kinds: `gitub:acme/web` would otherwise be a row that guards nothing while it reads as a cap. For the same reason a
246
+ * qualified repo must have a forge repo's shape (`QUALIFIED_REPO`): two or more `/`-separated segments, so no leading
247
+ * or trailing `/` and no `//`, no control character, `#` or `:` in any segment, and no whitespace at a segment's start
248
+ * or end (inside one it is allowed: Azure DevOps names may hold spaces). That refuses the
249
+ * likely slips (`github:acme/web/`, a `#12` pasted from a run target, a doubled `github:github:` prefix), each of which
250
+ * no delivery's repo can ever equal. The split is at the
251
+ * FIRST `:`, which is safe because no forge allows `:` in a repo or project path: GitHub, GitLab and Forgejo names
252
+ * are `[A-Za-z0-9._-]` segments, and Azure DevOps refuses `:` in project and repository names. `"*"` is the caller's
253
+ * business (pause windows accept it, scoped limits refuse it) and is not classified here. The message carries no path:
254
+ * the caller adds which entry and which file.
255
+ */
256
+ export function parseScopeString(text) {
257
+ const scope = String(text).trim().normalize("NFC");
258
+ if (isAbsolute(scope) || /^[A-Za-z]:/.test(scope)) return { type: "local", kind: null, repo: null };
259
+ const m = /^([^/\\:]+):(.*)$/s.exec(scope);
260
+ if (m === null) return { type: "bare", kind: null, repo: null };
261
+ if (!isForgeKind(m[1])) throw configError(`scope ${JSON.stringify(scope)} starts with an unknown prefix "${m[1]}:" (a forge-qualified scope starts with one of ${FORGE_KINDS.join(", ")}, such as github:owner/name)`);
262
+ if (m[2].trim() === "") throw configError(`scope ${JSON.stringify(scope)} names a forge and no repo (write ${m[1]}:owner/name)`);
263
+ const repo = m[2].trim();
264
+ if (!QUALIFIED_REPO.test(repo)) throw configError(`scope ${JSON.stringify(scope)} is not a forge repo after "${m[1]}:" (write ${m[1]}:owner/name: segments separated by single "/", no "#", ":" or control characters, and no space at the start or end of a segment), so it would guard nothing`);
265
+ return { type: "qualified", kind: m[1], repo };
266
+ }
267
+
268
+ /**
269
+ * A row scope as a forge comment names it (issue #498): the repo without its forge prefix, so a refusal posted on
270
+ * `acme/web` reads "acme/web" whether the row was written bare or qualified. Any other scope is returned unchanged.
271
+ */
272
+ export function unqualifiedScope(scope) {
273
+ if (typeof scope !== "string") return scope;
274
+ const colon = scope.indexOf(":");
275
+ return colon > 0 && isForgeKind(scope.slice(0, colon)) ? scope.slice(colon + 1) : scope;
276
+ }
277
+
202
278
  /**
203
279
  * When, in epoch ms, the current pause for this job's scope ends — or `null` when the job is not paused. A
204
280
  * job is paused if `now` falls inside any scope-matching window (`scope === "*"` matches every scope); the
205
281
  * latest end among active windows is returned so a single deferral clears them all (re-checked on wake).
282
+ * A window matches on the RAW `scopeOf` value (so every existing bare window keeps matching every forge, as before)
283
+ * or on the job's `qualifiedScopeOf` (issue #498: `github:acme/web` pauses the GitHub job and not the Forgejo one).
206
284
  */
207
285
  export function pauseUntilMs(windows, job, nowMs) {
208
286
  if (!Array.isArray(windows) || windows.length === 0) return null;
209
287
  const scope = scopeOf(job);
210
288
  if (!isNonEmptyString(scope)) return null;
289
+ const qualified = qualifiedScopeOf(job);
211
290
  let end = null;
212
291
  for (const w of windows) {
213
- if (w.scope !== "*" && w.scope !== scope) continue;
292
+ if (w.scope !== "*" && w.scope !== scope && w.scope !== qualified) continue;
214
293
  const e = windowEndAt(w, nowMs);
215
294
  if (e !== null && (end === null || e > end)) end = e;
216
295
  }