@edgehero/pi-dispatch 2.1.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.
- package/.env.example +41 -5
- package/README.md +11 -5
- package/deploy/docker-compose.yml +12 -0
- package/deploy/egress-proxy.conf +28 -3
- package/deploy/pi-dispatch-egress-proxy.container +8 -2
- package/package.json +8 -1
- package/src/allocation.mjs +731 -0
- package/src/backends.mjs +243 -0
- package/src/budget.mjs +40 -4
- package/src/cli.mjs +222 -11
- package/src/config.mjs +126 -5
- package/src/daemon-facts.mjs +3 -0
- package/src/deployment-venue.mjs +1 -0
- package/src/doctor.mjs +2261 -203
- package/src/dollar-budget.mjs +373 -0
- package/src/dollar-fingerprint.mjs +83 -0
- package/src/egress-cli.mjs +316 -0
- package/src/egress-proxy-state.mjs +35 -5
- package/src/egress.mjs +12 -0
- package/src/env-allowlist.mjs +107 -6
- package/src/env-file.mjs +194 -25
- package/src/envelope.mjs +413 -0
- package/src/exit-code.mjs +22 -0
- package/src/fleet-lease.mjs +85 -25
- package/src/get-token.mjs +16 -5
- package/src/git-dirty.mjs +67 -0
- package/src/github-app-setup.mjs +6 -3
- package/src/github-host.mjs +5 -3
- package/src/identity.mjs +2 -1
- package/src/image-preflight.mjs +98 -24
- package/src/image-ref.mjs +37 -0
- package/src/import-pi.mjs +4 -2
- package/src/index.mjs +407 -62
- package/src/init.mjs +18 -0
- package/src/job-id.mjs +26 -3
- package/src/live-probes.mjs +24 -9
- package/src/model-catalog.mjs +297 -0
- package/src/model-endpoints.mjs +649 -0
- package/src/model-ref.mjs +151 -0
- package/src/models-json.mjs +262 -0
- package/src/money.mjs +144 -0
- package/src/octokit-log.mjs +65 -0
- package/src/outbox-plan.mjs +218 -0
- package/src/outbox.mjs +29 -9
- package/src/output-cap.mjs +157 -0
- package/src/pause-windows.mjs +81 -2
- package/src/pi-model-loader.mjs +77 -0
- package/src/podman-stack.mjs +16 -3
- package/src/portfolio-snapshot.mjs +304 -0
- package/src/prepare-local.mjs +247 -12
- package/src/prepare.mjs +35 -3
- package/src/priorities.mjs +569 -0
- package/src/processor.mjs +599 -170
- package/src/project-id.mjs +17 -0
- package/src/projects.mjs +238 -0
- package/src/provider-steering.mjs +179 -65
- package/src/queue.mjs +111 -6
- package/src/reserved-env.mjs +30 -0
- package/src/run-container.mjs +59 -5
- package/src/run-history.mjs +379 -24
- package/src/run-mirror.mjs +30 -0
- package/src/runtime-settings.mjs +104 -9
- package/src/schedules.mjs +33 -1
- package/src/scoped-limits.mjs +447 -27
- package/src/service.mjs +15 -4
- package/src/session-store.mjs +131 -6
- package/src/start.mjs +528 -40
- package/src/triggers-file.mjs +65 -4
- package/src/triggers.mjs +135 -7
- package/src/up.mjs +308 -34
- 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
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
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/pause-windows.mjs
CHANGED
|
@@ -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
|
|
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
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi's own model loader, from the pi-coding-agent installed beside the worker, for `doctor` (issue #502's open
|
|
3
|
+
* question: can the worker's view of the overlay `models.json` disagree with pi's?).
|
|
4
|
+
*
|
|
5
|
+
* THE CHOICE, and why. The issue suggests running pi's loader in the job image. Doctor does run that image already,
|
|
6
|
+
* for the egress canary, but a probe that mounts the operator's overlay into it needs the canary's venue, job-user and
|
|
7
|
+
* SELinux handling, and every doctor run would pay a container start for it. The worker and the job image are built
|
|
8
|
+
* from one lockfile, so the pi-coding-agent that a checkout of this repository installs at the workspace root after
|
|
9
|
+
* `npm ci` is the pi the image carries, and doctor asks that one, in-process. Its version is named on doctor's line.
|
|
10
|
+
* Where it is not installed (a worker installed on its own: the worker package depends on pi-ai only), doctor says
|
|
11
|
+
* the comparison was not made rather than guessing. And only the PINNED pi counts (PR #551's review): an `npm i -g`
|
|
12
|
+
* layout resolves whatever pi-coding-agent sits beside the worker, a global pi CLI of any version, so the loader
|
|
13
|
+
* reports the worker's pinned pi-ai (its exact dependency; pi-ai and pi-coding-agent are released in lockstep, and a
|
|
14
|
+
* test holds this pin equal to the runner's pi-coding-agent pin, which is the image's) and doctor compares nothing
|
|
15
|
+
* when the pi-coding-agent version differs from it.
|
|
16
|
+
*
|
|
17
|
+
* Its own module because it is the one place doctor's code must touch `process.env`: pi reads `PI_OFFLINE` from the
|
|
18
|
+
* process environment, and the runtime is created with it set so asking about models can never reach the network.
|
|
19
|
+
* It is restored straight after.
|
|
20
|
+
*
|
|
21
|
+
* NOTHING ON DISK (PR #551's review, round 2). The runtime is handed an in-memory credential store and an in-memory
|
|
22
|
+
* models store. With pi's file-backed defaults it wrote an `auth.json` into a temporary directory, and pi's file
|
|
23
|
+
* backend creates the parent directory before it writes, so a write landing after the cleanup brought the directory
|
|
24
|
+
* back (17 were found in a TMPDIR); the default models store would also have written `models-store.json` beside the
|
|
25
|
+
* operator's overlay file. Neither store holds anything the question needs: composition reads the config only.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { readFileSync } from "node:fs";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `{ version, pinned, read(path) }`, or null when pi-coding-agent cannot be resolved from here. `version` is the
|
|
32
|
+
* resolved pi-coding-agent's, `pinned` the worker package's exact `@earendil-works/pi-ai` dependency. `read` answers
|
|
33
|
+
* `{ loads, has(provider, id) }`: `loads` is `ModelConfig.load(path).getError() === undefined`, and `has` asks a
|
|
34
|
+
* `ModelRuntime` created over the file (pi's provider composition included) for the model, with in-memory credential
|
|
35
|
+
* and models stores, so nothing is read from or written to disk beyond the file itself.
|
|
36
|
+
*/
|
|
37
|
+
export async function loadPiModelLoader({ resolveEntry = (name) => import.meta.resolve(name), workerPackage = new URL("../package.json", import.meta.url) } = {}) {
|
|
38
|
+
let ModelConfig;
|
|
39
|
+
let ModelRuntime;
|
|
40
|
+
let AuthStorage;
|
|
41
|
+
let InMemoryCodingAgentModelsStore;
|
|
42
|
+
let version;
|
|
43
|
+
let pinned = null;
|
|
44
|
+
try {
|
|
45
|
+
pinned = JSON.parse(readFileSync(workerPackage, "utf8"))?.dependencies?.["@earendil-works/pi-ai"] ?? null;
|
|
46
|
+
} catch {
|
|
47
|
+
// No pin to compare against: doctor then says it cannot tell, rather than comparing with any pi.
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const entry = resolveEntry("@earendil-works/pi-coding-agent");
|
|
51
|
+
({ ModelConfig } = await import(new URL("core/model-config.js", entry).href));
|
|
52
|
+
({ ModelRuntime } = await import(new URL("core/model-runtime.js", entry).href));
|
|
53
|
+
({ AuthStorage } = await import(new URL("core/auth-storage.js", entry).href));
|
|
54
|
+
({ InMemoryCodingAgentModelsStore } = await import(new URL("core/models-store.js", entry).href));
|
|
55
|
+
version = JSON.parse(readFileSync(new URL("../package.json", entry), "utf8")).version;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
version,
|
|
61
|
+
pinned,
|
|
62
|
+
async read(path) {
|
|
63
|
+
const config = await ModelConfig.load(path);
|
|
64
|
+
const loads = config.getError() === undefined;
|
|
65
|
+
// env-internal PI_OFFLINE: pi's own switch, set here so asking pi about models never reaches the network.
|
|
66
|
+
const offline = process.env.PI_OFFLINE;
|
|
67
|
+
process.env.PI_OFFLINE = "1";
|
|
68
|
+
try {
|
|
69
|
+
const runtime = await ModelRuntime.create({ modelsPath: path, credentials: AuthStorage.inMemory(), modelsStore: new InMemoryCodingAgentModelsStore(), refreshOnCreate: false });
|
|
70
|
+
return { loads, has: (provider, id) => runtime.getModel(provider, id) !== undefined };
|
|
71
|
+
} finally {
|
|
72
|
+
if (offline === undefined) delete process.env.PI_OFFLINE;
|
|
73
|
+
else process.env.PI_OFFLINE = offline;
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|