@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.
- package/.env.example +44 -7
- package/README.md +14 -6
- package/deploy/com.pi-dispatch.worker.plist +1 -1
- package/deploy/docker-compose.yml +12 -0
- package/deploy/egress-proxy.conf +28 -3
- package/deploy/pi-dispatch-egress-proxy.container +8 -2
- package/deploy/worker-env-wrapper.cmd +1 -1
- package/deploy/worker-env-wrapper.sh +3 -3
- package/package.json +9 -2
- 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 +2316 -183
- 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 +16 -3
- package/src/env-allowlist.mjs +142 -18
- 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/host-pi.mjs +19 -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/packages.mjs +2 -2
- 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/pricing.mjs +9 -5
- package/src/priorities.mjs +569 -0
- package/src/processor.mjs +603 -173
- package/src/project-id.mjs +17 -0
- package/src/projects.mjs +238 -0
- package/src/provider-key.mjs +32 -7
- package/src/provider-steering.mjs +214 -59
- 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/secrets.mjs +2 -1
- package/src/service.mjs +15 -4
- package/src/session-store.mjs +131 -6
- package/src/start.mjs +528 -40
- package/src/subscriptions.mjs +7 -3
- package/src/triggers-file.mjs +65 -4
- package/src/triggers.mjs +140 -9
- package/src/up.mjs +308 -34
- package/src/valkey-endpoint.mjs +3 -2
package/src/subscriptions.mjs
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Operator-declared subscription plans (issue #53): the one place a flat-rate plan's real price can be
|
|
3
|
-
* stated.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* stated. pi-ai's rate table for a subscription-backed provider does not state the plan's price: at 0.80.7
|
|
4
|
+
* kimi-coding and zai-coding-cn shipped all-zero tables, so their runs recorded cost 0 and read as FREE
|
|
5
|
+
* when they were PREPAID; at the 0.99.1 pin most of their models carry an implied API-equivalent rate
|
|
6
|
+
* instead (issue #509), so the same prepaid runs now record a POSITIVE cost and read as METERED spend.
|
|
7
|
+
* Either way the table is the wrong price, and a zero rate is no longer even a hint that a plan exists
|
|
8
|
+
* (the qwen-token-plan family is all-zero, and so are free models of metered providers). And the env
|
|
9
|
+
* boundary REFUSES OAuth/subscription logins on purpose (env-allowlist.mjs: an expiring token cannot power an
|
|
6
10
|
* unattended service), so no credential ever reaches the worker that could name the plan. An operator-side
|
|
7
11
|
* declaration is therefore the only honest price source, and `subscriptions.json` is that declaration.
|
|
8
12
|
*
|
package/src/triggers-file.mjs
CHANGED
|
@@ -433,7 +433,8 @@ export function makeCheckOnceSpent({ triggersPath, fs = nodeFs }) {
|
|
|
433
433
|
}
|
|
434
434
|
|
|
435
435
|
/**
|
|
436
|
-
* Detect the version skew `run.waitFor` opens (issue #230), pre-spend, from the worker's own file read
|
|
436
|
+
* Detect the version skew `run.waitFor` opens (issue #230), pre-spend, from the worker's own file read, and the
|
|
437
|
+
* same skew for every field in `AUTHORED_NARROWING_FIELDS` (issue #502).
|
|
437
438
|
*
|
|
438
439
|
* The hazard is `DES-TRIGGERS-UNIFIED-FILE`'s widening rule arriving somewhere it has never bitten. Unknown
|
|
439
440
|
* keys DROP, which for every previous field was a harmless no-op: an old parser meeting `run.image` gives
|
|
@@ -455,6 +456,27 @@ export function makeCheckOnceSpent({ triggersPath, fs = nodeFs }) {
|
|
|
455
456
|
* "run", because a broken read must never wedge every job. Only a positive, identity-confirmed mismatch --
|
|
456
457
|
* this entry authored conditions, this job carries none -- refuses.
|
|
457
458
|
*/
|
|
459
|
+
/**
|
|
460
|
+
* The authored NARROWING fields the skew check covers besides `waitFor` (issues #502, #501): a `run` key whose absence
|
|
461
|
+
* WIDENS the job, so a service that drops it (a receiver below the version floor, or a current one reading a dead
|
|
462
|
+
* inode through compose's single-file `:ro` mount) turns a narrowed trigger into an unrestricted job that records
|
|
463
|
+
* as a clean run. `authored` says whether the entry in the file carries the field; the job is skewed when it does
|
|
464
|
+
* and the job arrived with the key absent or null. A new field of this class is ONE row here. `waitFor` keeps its
|
|
465
|
+
* own branch below because its refusal counts conditions and has its own reason (`wait-skew`); every row here is
|
|
466
|
+
* refused as `trigger-skew`, naming the field.
|
|
467
|
+
*
|
|
468
|
+
* The job checked must be the job as it ARRIVED (`job.data`), never one the worker filled: the effective job
|
|
469
|
+
* carries the deployment's PI_ALLOWED_MODELS as `models`, which would hide exactly the drop this exists to see.
|
|
470
|
+
* index.mjs hands this check the arrived value.
|
|
471
|
+
*/
|
|
472
|
+
export const AUTHORED_NARROWING_FIELDS = Object.freeze([
|
|
473
|
+
Object.freeze({ key: "models", authored: (v) => Array.isArray(v) && v.length > 0 }),
|
|
474
|
+
// Issue #501: a trigger's per-job dollar cap. Dropped by a receiver from before #501 (which tolerated the key as
|
|
475
|
+
// unknown), the job would run under the deployment's wider cap, or with none, on a clean record. Any present
|
|
476
|
+
// value counts as authored; whether it is a valid amount is the loader's question, not this one.
|
|
477
|
+
Object.freeze({ key: "maxCostUsd", authored: (v) => v !== undefined && v !== null }),
|
|
478
|
+
]);
|
|
479
|
+
|
|
458
480
|
export function makeCheckWaitSkew({ triggersPath, fs = nodeFs }) {
|
|
459
481
|
// Cached by mtime, unlike `checkOnceSpent` which re-reads every time. The difference is which jobs each
|
|
460
482
|
// one runs for: the one-shot check is gated on `matched.once === true`, so it is rare by construction and
|
|
@@ -478,19 +500,28 @@ export function makeCheckWaitSkew({ triggersPath, fs = nodeFs }) {
|
|
|
478
500
|
}
|
|
479
501
|
};
|
|
480
502
|
|
|
503
|
+
// STRICT, like `waitFor` since #230 (PR #536's review, round 3): a job whose authored entry carries a narrowing
|
|
504
|
+
// field it arrived without is refused, even when the operator ADDED that field after the job was queued. A
|
|
505
|
+
// fail-open fenced on the file's mtime was tried and removed: ANY later write of the file (the worker's own
|
|
506
|
+
// one-shot disarm, a console edit, a touch, an `rsync -t` mtime in the future) reopened the hole for every job.
|
|
507
|
+
// Refusing a job queued before the edit is the safe direction, and its comment says to re-run it.
|
|
481
508
|
return async function checkWaitSkew(job) {
|
|
482
509
|
if (typeof triggersPath !== "string" || triggersPath === "") return { ok: true };
|
|
483
510
|
// Cron and CLI jobs carry no matched index, and cannot carry `waitFor` at all.
|
|
484
511
|
const index = job?.trigger?.matched?.index;
|
|
485
512
|
if (!Number.isInteger(index)) return { ok: true };
|
|
486
513
|
// The field arrived. Whether its conditions are SATISFIED is the gate's business, not this check's.
|
|
487
|
-
|
|
514
|
+
const waitArrived = Array.isArray(job?.waitFor) && job.waitFor.length > 0;
|
|
515
|
+
const missing = AUTHORED_NARROWING_FIELDS.filter((row) => job?.[row.key] === undefined || job[row.key] === null);
|
|
516
|
+
if (waitArrived && missing.length === 0) return { ok: true };
|
|
488
517
|
|
|
489
518
|
const triggers = read();
|
|
490
519
|
if (triggers === null) return { ok: true };
|
|
491
520
|
const entry = triggers[index];
|
|
492
521
|
const authored = entry?.run?.waitFor;
|
|
493
|
-
|
|
522
|
+
const waitSkewed = !waitArrived && Array.isArray(authored) && authored.length > 0;
|
|
523
|
+
const dropped = missing.find((row) => row.authored(entry?.run?.[row.key]));
|
|
524
|
+
if (!waitSkewed && dropped === undefined) return { ok: true };
|
|
494
525
|
|
|
495
526
|
// The identity guard readDisarmState keeps, and WIDER than flow alone. `triggerIndex` is a RAW array
|
|
496
527
|
// position, so an insertion, a reorder, or a stray `triggers.json` at the worker's cwd can all put a
|
|
@@ -505,7 +536,37 @@ export function makeCheckWaitSkew({ triggersPath, fs = nodeFs }) {
|
|
|
505
536
|
const onType = job?.trigger?.matched?.type;
|
|
506
537
|
if (typeof onType === "string" && entry?.on?.type !== onType) return { ok: true };
|
|
507
538
|
|
|
508
|
-
return { skewed: true, conditions: authored.length };
|
|
539
|
+
if (waitSkewed) return { skewed: true, conditions: authored.length };
|
|
540
|
+
return { skewed: true, field: dropped.key };
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Issue #505: `(job) => boolean`, whether the LIVE triggers file still flags the job's cron trigger
|
|
546
|
+
* `run.portfolio: true`. A portfolio job is queued with the flag on its data (a scheduler tick, or
|
|
547
|
+
* `pi-dispatch run --trigger`), and the flag is budget authority, so the data alone is never trusted: the operator
|
|
548
|
+
* removing the flag from the reviewed file must take effect for a job already queued.
|
|
549
|
+
*
|
|
550
|
+
* The same file the one-shot checks read (`PI_TRIGGERS_FILE`, else `./triggers.json` in the worker's working
|
|
551
|
+
* directory), parsed by the shared validator rather than read raw, because the answer gives authority and an entry
|
|
552
|
+
* the loader would refuse grants none. Found by the cron id, which the loader keeps unique, never by an index, and then
|
|
553
|
+
* held to the job: the entry's `folder`, `flow`, `command` and `task` must equal the job's. An id is a name, and a
|
|
554
|
+
* name can be reused: a job queued from an old entry must not borrow the flag of a different entry that took its id
|
|
555
|
+
* later (review of PR #575). Every doubt (no path, no file, a file that does not parse, no cron entry with that id, an
|
|
556
|
+
* entry that is not the one the job came from) answers false: the job then runs as an ordinary cron job, the direction
|
|
557
|
+
* that grants nothing. Never throws.
|
|
558
|
+
*/
|
|
559
|
+
export function makeCheckPortfolioFlag({ triggersPath, fs = nodeFs }) {
|
|
560
|
+
return function checkPortfolioFlag(job) {
|
|
561
|
+
const id = job?.trigger?.id;
|
|
562
|
+
if (typeof triggersPath !== "string" || triggersPath === "" || typeof id !== "string") return false;
|
|
563
|
+
try {
|
|
564
|
+
const entry = parseTriggers(fs.readFileSync(triggersPath, "utf8"), triggersPath).find((t) => t.on.type === "cron" && t.on.id === id);
|
|
565
|
+
if (entry?.run?.portfolio !== true) return false;
|
|
566
|
+
return ["folder", "flow", "command", "task"].every((k) => entry.run[k] === job[k]);
|
|
567
|
+
} catch {
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
509
570
|
};
|
|
510
571
|
}
|
|
511
572
|
|
package/src/triggers.mjs
CHANGED
|
@@ -32,14 +32,20 @@ import { imageRefProblem } from "./image-ref.mjs";
|
|
|
32
32
|
import { SKILL_NAME_RE } from "./flow-gate.mjs";
|
|
33
33
|
import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
|
|
34
34
|
import { findDuplicateKey } from "./json-duplicates.mjs";
|
|
35
|
+
import { validateModelRef } from "./model-ref.mjs";
|
|
36
|
+
import { parseUsdMicros } from "./money.mjs";
|
|
35
37
|
import { PROVIDER_STEERING_VARS } from "./provider-steering.mjs";
|
|
36
|
-
import { CONTAINER_ENV_NAMES } from "./reserved-env.mjs";
|
|
38
|
+
import { CONTAINER_ENV_NAMES, RUNNER_ENV_NAMES } from "./reserved-env.mjs";
|
|
37
39
|
// The wait grammar's two shared halves (issue #230). `afterInstantMs` is imported rather than restated so
|
|
38
40
|
// the loader and the pickup gate cannot disagree about what a legal instant is: a second spelling here is
|
|
39
41
|
// how a file that loads clean starts holding for an instant nobody wrote. wait-for.mjs is pure and
|
|
40
42
|
// fs-free, so importing it keeps parseTriggers pure.
|
|
41
43
|
import { WAIT_CONDITION_KEYS, WAIT_CONDITION_MAX, afterInstantMs } from "./wait-for.mjs";
|
|
42
44
|
|
|
45
|
+
// Re-exported so the console checks a provider or model it is about to write with the loader's own
|
|
46
|
+
// grammar, through the subpath it already imports, rather than a copy that could drift.
|
|
47
|
+
export { validateModelRef };
|
|
48
|
+
|
|
43
49
|
// EXPORTED for the same reason as PR_ACTIONS: the admin re-states this vocabulary to a MODEL.
|
|
44
50
|
export const ON_TYPES = new Set(["cron", "label", "comment", "pull_request", "issue"]);
|
|
45
51
|
|
|
@@ -167,7 +173,10 @@ export const REVIEW_STATES = new Set(["approved", "changes_requested", "commente
|
|
|
167
173
|
const REVIEW_ACTION = "review_submitted";
|
|
168
174
|
|
|
169
175
|
/**
|
|
170
|
-
* The pi tool names a trigger may exclude (issue #291) -- the built-in set of the PINNED pi, 0.
|
|
176
|
+
* The pi tool names a trigger may exclude (issue #291) -- the built-in set of the PINNED pi, 0.99.1, in pi's
|
|
177
|
+
* own `allToolNames` order. `powershell` joined it at the 0.99.1 bump (issue #509): pi registers it as a
|
|
178
|
+
* built-in but does not activate it by default (`DEFAULT_TOOL_NAMES` is read, bash, edit, write), so a
|
|
179
|
+
* trigger that means "no shell" names it beside `bash`.
|
|
171
180
|
* Hand-written because this validator is pure and pi-free (the worker does not depend on the agent
|
|
172
181
|
* package), and therefore BOLTED twice to the artifact it restates: `worker/test/exclude-tools.pinned.test.mjs`
|
|
173
182
|
* and `image/runner/test/pinned-api.test.mjs` both derive the set from the pinned package and fail with a
|
|
@@ -177,7 +186,7 @@ const REVIEW_ACTION = "review_submitted";
|
|
|
177
186
|
* validation exists to close -- pi ignores unknown names in `excludeTools` without a diagnostic.
|
|
178
187
|
*/
|
|
179
188
|
// EXPORTED for the two pinned-set bolts and for the admin, which states the vocabulary to an operator.
|
|
180
|
-
export const EXCLUDABLE_TOOL_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
|
|
189
|
+
export const EXCLUDABLE_TOOL_NAMES = new Set(["read", "bash", "powershell", "edit", "write", "grep", "find", "ls"]);
|
|
181
190
|
|
|
182
191
|
// A cron id flows into BullMQ's deterministic `repeat:<id>:<nextMillis>` jobId, so a `:` corrupts that
|
|
183
192
|
// parse; the charset also excludes `:` and the dedicated check names the reason.
|
|
@@ -225,7 +234,7 @@ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
|
225
234
|
* Every set is IMPORTED, never retyped, which is the rule `sandbox.test.mjs` keeps for the same reason: a
|
|
226
235
|
* forge added to the table later must not need a second edit here to stay covered.
|
|
227
236
|
*/
|
|
228
|
-
const RESERVED_ENV_NAMES = new Set([...MINTED_TOKEN_VARS, ...FORGE_HOST_VARS, ...WORKER_ONLY_SECRET_VARS, ...EGRESS_ENV_VARS, ...CONTAINER_ENV_NAMES, ...PROVIDER_STEERING_VARS]);
|
|
237
|
+
export const RESERVED_ENV_NAMES = new Set([...MINTED_TOKEN_VARS, ...FORGE_HOST_VARS, ...WORKER_ONLY_SECRET_VARS, ...EGRESS_ENV_VARS, ...CONTAINER_ENV_NAMES, ...RUNNER_ENV_NAMES, ...PROVIDER_STEERING_VARS]);
|
|
229
238
|
|
|
230
239
|
function isNonEmptyString(value) {
|
|
231
240
|
return typeof value === "string" && value.trim() !== "";
|
|
@@ -307,6 +316,13 @@ function normalizeTrigger(entry, index, path, state) {
|
|
|
307
316
|
if (!isForgeKind(run.kind)) {
|
|
308
317
|
throw configError(`${at}: a ${on.type} trigger is webhook-driven and produces a forge job; run.kind must be one of ${FORGE_KINDS.join("|")} (got ${JSON.stringify(run.kind)}): ${path}`);
|
|
309
318
|
}
|
|
319
|
+
// Issue #505: `run.portfolio` is cron-only, refused here for every webhook type at once (and before the disarmed
|
|
320
|
+
// sentinel below, so a disarmed entry carrying it still refuses the file). A forge job is driven by text an issue or
|
|
321
|
+
// pull request author chose, and it has no `/outbox`, so a plan channel there would hand budget authority to whoever
|
|
322
|
+
// can open an issue. Any value is refused, `false` included: on a webhook kind the field has no meaning to state.
|
|
323
|
+
if (run.portfolio !== undefined) {
|
|
324
|
+
throw configError(`${at}: run.portfolio is only available on a cron trigger: a ${on.type} trigger runs on text a forge user wrote, and a budget plan must come from a schedule the operator reviewed: ${path}`);
|
|
325
|
+
}
|
|
310
326
|
let normalized;
|
|
311
327
|
if (on.type === "label") normalized = normalizeLabel(on, run, index, path);
|
|
312
328
|
else if (on.type === "comment") normalized = normalizeComment(on, run, index, path, state);
|
|
@@ -398,6 +414,12 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
398
414
|
// A cron job's session narrows like any other: the field selects what the container's agent can do,
|
|
399
415
|
// which is orthogonal to what triggered it (issue #291).
|
|
400
416
|
const excludeTools = validateExcludeTools(on, run, `cron trigger "${id}"`, path);
|
|
417
|
+
// Validated since #502; before it these three were copied untouched, so `run.provider: 7` loaded.
|
|
418
|
+
const ref = validateRunModel(on, run, `cron trigger "${id}"`, path);
|
|
419
|
+
// Issue #505: the portfolio flag. Strictly boolean, the house rule (`run.github`'s reason: a truthy string must not
|
|
420
|
+
// quietly arm budget authority). `true` beside `run.command` is refused: a plan is written by a flow's judgement,
|
|
421
|
+
// and a command has no committed skill to review. `false` is today's default and is carried, `run.resume`'s rule.
|
|
422
|
+
const portfolio = validatePortfolio(run, command, `cron trigger "${id}"`, path);
|
|
401
423
|
|
|
402
424
|
// provider/model/maxTurns stay absent when omitted so the value resolves at job start against the
|
|
403
425
|
// settings overlay/env, not a default frozen here (INT-CONFIG-OVERLAY-CONTRACT). github/packages/image stay
|
|
@@ -406,10 +428,27 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
406
428
|
// freeze today's default into every stored repeatable.
|
|
407
429
|
return {
|
|
408
430
|
on: { type: "cron", id, pattern },
|
|
409
|
-
run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider:
|
|
431
|
+
run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: ref.provider, model: ref.model, maxTurns: ref.maxTurns, github: run.github, packages, image, resume, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(ref.models !== undefined && { models: ref.models }), ...(ref.maxCostUsd !== undefined && { maxCostUsd: ref.maxCostUsd }), ...(portfolio !== undefined && { portfolio }) },
|
|
410
432
|
};
|
|
411
433
|
}
|
|
412
434
|
|
|
435
|
+
/**
|
|
436
|
+
* `run.portfolio` on a cron trigger (issue #505): the job may send a priorities plan back through
|
|
437
|
+
* `/outbox/priorities.json`. Returns the flag, or undefined when absent, so an unflagged entry normalizes
|
|
438
|
+
* byte-identically. Reviewed-file-only, like `run.secrets`: no tool and no panel key writes it.
|
|
439
|
+
*/
|
|
440
|
+
function validatePortfolio(run, command, at, path) {
|
|
441
|
+
const portfolio = run.portfolio;
|
|
442
|
+
if (portfolio === undefined) return undefined;
|
|
443
|
+
if (typeof portfolio !== "boolean") {
|
|
444
|
+
throw configError(`${at}: run.portfolio must be true or false when present: ${path}`);
|
|
445
|
+
}
|
|
446
|
+
if (portfolio === true && command !== undefined) {
|
|
447
|
+
throw configError(`${at}: run.portfolio cannot be set beside run.command: a budget plan is written by a flow's judgement, and a command has no committed skill to review: ${path}`);
|
|
448
|
+
}
|
|
449
|
+
return portfolio;
|
|
450
|
+
}
|
|
451
|
+
|
|
413
452
|
/**
|
|
414
453
|
* Validate the per-trigger `run.packages` flag, shared by all four normalizers. It is an opt-OUT: the pi
|
|
415
454
|
* packages an operator pinned into the global overlay load for every job, and `run.packages: false` is how a
|
|
@@ -1169,6 +1208,94 @@ function validateBackend(on, run, at, path, { localWorkspace }) {
|
|
|
1169
1208
|
return backend;
|
|
1170
1209
|
}
|
|
1171
1210
|
|
|
1211
|
+
/**
|
|
1212
|
+
* The `run` field a key spells or nearly spells (`run.provider`, `run.model`, `run.models`, `run.maxTurns`,
|
|
1213
|
+
* `run.maxCostUsd`), or null. The exact field names match too: the CALLER decides whether an exact name is legal
|
|
1214
|
+
* where it found it (the loader allows them under `run`, never under `on`; the console's trigger tools refuse
|
|
1215
|
+
* `run.models` and `run.maxCostUsd` in any spelling, issue #501 part 7). One sweep, so the two cannot disagree on
|
|
1216
|
+
* what a near miss is.
|
|
1217
|
+
*/
|
|
1218
|
+
export function runModelFieldNearMiss(key) {
|
|
1219
|
+
// What the sweep compares against, case and separators removed. `providerid` and `modelid` are what an
|
|
1220
|
+
// operator copies from a provider's own API docs; `allowedmodels` is the issue's own name for the list.
|
|
1221
|
+
// Deliberately NOT a synonym hunt (`llm`, `engine`): that is the general unknown-key sweep this file's
|
|
1222
|
+
// forward-compatibility posture rejects.
|
|
1223
|
+
// Singular and plural of each, excludeTools' rule: `maxTurn` and `providers` are one grammatical step
|
|
1224
|
+
// from the field, and `modelName` is how several provider SDKs spell the id.
|
|
1225
|
+
const targets = RUN_MODEL_NEAR_MISS_TARGETS;
|
|
1226
|
+
const isSubsequence = (needle, hay) => {
|
|
1227
|
+
let i = 0;
|
|
1228
|
+
for (const ch of hay) if (i < needle.length && needle[i] === ch) i++;
|
|
1229
|
+
return i === needle.length;
|
|
1230
|
+
};
|
|
1231
|
+
const normalized = String(key).replace(/[^a-z0-9]/gi, "").toLowerCase();
|
|
1232
|
+
// The homoglyph branch is validateBackend's (its comment carries the rationale), floor 4 included:
|
|
1233
|
+
// `m<U+043E>del` normalizes to `mdel`, and a floor of 5 would let the shortest target's likeliest
|
|
1234
|
+
// homoglyph through. Only a non-ASCII key reaches this branch, so no ASCII field is at risk.
|
|
1235
|
+
// The suggestion names the target that actually MATCHED, so a homoglyph key is pointed at the field
|
|
1236
|
+
// its surviving letters spell rather than at whatever a prefix test would guess.
|
|
1237
|
+
const hit = /^[\x20-\x7E]*$/.test(key)
|
|
1238
|
+
? (Object.hasOwn(targets, normalized) ? normalized : undefined)
|
|
1239
|
+
: normalized.length >= 4 ? Object.keys(targets).find((t) => isSubsequence(normalized, t)) : undefined;
|
|
1240
|
+
return hit === undefined ? null : targets[hit];
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const RUN_MODEL_NEAR_MISS_TARGETS = Object.freeze({
|
|
1244
|
+
provider: "run.provider", providers: "run.provider", providerid: "run.provider", providerids: "run.provider", providername: "run.provider",
|
|
1245
|
+
model: "run.model", modelid: "run.model", modelname: "run.model",
|
|
1246
|
+
models: "run.models", modelids: "run.models", modelnames: "run.models", allowedmodels: "run.models", allowedmodel: "run.models",
|
|
1247
|
+
maxturns: "run.maxTurns", maxturn: "run.maxTurns",
|
|
1248
|
+
maxcostusd: "run.maxCostUsd", maxcost: "run.maxCostUsd",
|
|
1249
|
+
});
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* `run.provider`, `run.model` and `run.maxTurns` (issue #502): which model this trigger's job runs on, and
|
|
1253
|
+
* its turn limit. Every kind carries them; before #502 only cron did, and unchecked.
|
|
1254
|
+
*
|
|
1255
|
+
* The value grammar is `model-ref.mjs`'s, shared with the console. This wrapper adds the KEY sweep, in
|
|
1256
|
+
* `validateExcludeTools`' shape and for its reason: a misspelled `run.modelId` loads clean and the job runs
|
|
1257
|
+
* on the deployment default model while the file reads as though it chose one. That is a destructive
|
|
1258
|
+
* absence once a model is policy, and the price difference between two models is the whole point of
|
|
1259
|
+
* naming one.
|
|
1260
|
+
*
|
|
1261
|
+
* `run.maxCostUsd` (issue #501) is validated here too, since the sweep below already owns its near misses:
|
|
1262
|
+
* the per-job dollar cap, a plain decimal string or number (`parseUsdMicros`: above 0, at most 1,000,000,
|
|
1263
|
+
* at most 6 decimals), carried AS WRITTEN and emitted only when present (`null` is absent, the model keys'
|
|
1264
|
+
* rule). It can only NARROW: the worker runs the job under the smaller of it and the deployment's cap, and
|
|
1265
|
+
* refuses a file with one above the env cap when it loads it (`loadSchedules`, at boot and on a live reload;
|
|
1266
|
+
* this loader stays pure, so it cannot read env).
|
|
1267
|
+
*
|
|
1268
|
+
* `run.models` (the allowed-model list) is validated with them since #502's second part, and its misspellings
|
|
1269
|
+
* were refused here before it was.
|
|
1270
|
+
*/
|
|
1271
|
+
function validateRunModel(on, run, at, path) {
|
|
1272
|
+
const exact = new Set(["provider", "model", "maxTurns", "models", "maxCostUsd"]);
|
|
1273
|
+
for (const [label, source, exactIsLegal] of [
|
|
1274
|
+
["run", run, true],
|
|
1275
|
+
["on", on, false],
|
|
1276
|
+
]) {
|
|
1277
|
+
for (const key of Object.keys(source ?? {})) {
|
|
1278
|
+
if (exactIsLegal && exact.has(key)) continue;
|
|
1279
|
+
const meant = runModelFieldNearMiss(key);
|
|
1280
|
+
if (meant === null) continue;
|
|
1281
|
+
throw configError(`${at}: ${label}.${key} is not a field -- did you mean ${meant}? A model choice the loader drops runs the job on the deployment default while the file reads as though it chose, so a near miss is refused rather than dropped: ${path}`);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
return { ...validateModelRef(run, at, path), ...validateMaxCostUsd(run, at, path) };
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// `run.maxCostUsd` (issue #501): `{ maxCostUsd }` when present and valid, `{}` when absent or null. The
|
|
1288
|
+
// refusal names the trigger and the key and never the value, `parseUsdMicros`' rule.
|
|
1289
|
+
function validateMaxCostUsd(run, at, path) {
|
|
1290
|
+
if (run?.maxCostUsd === null || run?.maxCostUsd === undefined) return {};
|
|
1291
|
+
try {
|
|
1292
|
+
parseUsdMicros(run.maxCostUsd, "run.maxCostUsd");
|
|
1293
|
+
} catch (error) {
|
|
1294
|
+
throw configError(`${at}: ${error.message}: ${path}`);
|
|
1295
|
+
}
|
|
1296
|
+
return { maxCostUsd: run.maxCostUsd };
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1172
1299
|
/**
|
|
1173
1300
|
* `run.excludeTools` -- pi tool names this trigger's session must NOT have (issue #291).
|
|
1174
1301
|
*
|
|
@@ -1414,9 +1541,10 @@ function normalizeLabel(on, run, index, path) {
|
|
|
1414
1541
|
const backend = validateBackend(on, run, at, path, { localWorkspace: false });
|
|
1415
1542
|
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
1416
1543
|
const excludeTools = validateExcludeTools(on, run, at, path);
|
|
1544
|
+
const ref = validateRunModel(on, run, at, path);
|
|
1417
1545
|
return {
|
|
1418
1546
|
on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
|
|
1419
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }) },
|
|
1547
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...ref },
|
|
1420
1548
|
};
|
|
1421
1549
|
}
|
|
1422
1550
|
|
|
@@ -1455,9 +1583,10 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
1455
1583
|
const backend = validateBackend(on, run, at, path, { localWorkspace: false });
|
|
1456
1584
|
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
1457
1585
|
const excludeTools = validateExcludeTools(on, run, at, path);
|
|
1586
|
+
const ref = validateRunModel(on, run, at, path);
|
|
1458
1587
|
return {
|
|
1459
1588
|
on: { type: "comment", phrase: on.phrase },
|
|
1460
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }) },
|
|
1589
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...ref },
|
|
1461
1590
|
};
|
|
1462
1591
|
}
|
|
1463
1592
|
|
|
@@ -1523,6 +1652,7 @@ function normalizeIssue(on, run, index, path) {
|
|
|
1523
1652
|
const backend = validateBackend(on, run, at, path, { localWorkspace: false });
|
|
1524
1653
|
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
1525
1654
|
const excludeTools = validateExcludeTools(on, run, at, path);
|
|
1655
|
+
const ref = validateRunModel(on, run, at, path);
|
|
1526
1656
|
return {
|
|
1527
1657
|
on: {
|
|
1528
1658
|
type: "issue",
|
|
@@ -1532,7 +1662,7 @@ function normalizeIssue(on, run, index, path) {
|
|
|
1532
1662
|
...(number !== undefined && { number }),
|
|
1533
1663
|
...(once !== undefined && { once }),
|
|
1534
1664
|
},
|
|
1535
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }) },
|
|
1665
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...ref },
|
|
1536
1666
|
};
|
|
1537
1667
|
}
|
|
1538
1668
|
|
|
@@ -1609,6 +1739,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
1609
1739
|
const backend = validateBackend(on, run, at, path, { localWorkspace: false });
|
|
1610
1740
|
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
1611
1741
|
const excludeTools = validateExcludeTools(on, run, at, path);
|
|
1742
|
+
const ref = validateRunModel(on, run, at, path);
|
|
1612
1743
|
return {
|
|
1613
1744
|
on: {
|
|
1614
1745
|
type: "pull_request",
|
|
@@ -1623,7 +1754,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
1623
1754
|
...(number !== undefined && { number }),
|
|
1624
1755
|
...(once !== undefined && { once }),
|
|
1625
1756
|
},
|
|
1626
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }) },
|
|
1757
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...ref },
|
|
1627
1758
|
};
|
|
1628
1759
|
}
|
|
1629
1760
|
|