@edgehero/pi-dispatch 4.2.0 → 4.3.1
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 +14 -4
- package/package.json +3 -2
- package/src/azure-host.mjs +28 -1
- package/src/azure-prompt.mjs +26 -3
- package/src/config.mjs +11 -3
- package/src/doctor.mjs +24 -2
- package/src/env-allowlist.mjs +11 -4
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +114 -23
- package/src/forgejo-prompt.mjs +26 -3
- package/src/forges.mjs +40 -12
- package/src/github-host.mjs +21 -1
- package/src/github-prompt.mjs +26 -3
- package/src/gitlab-host.mjs +24 -1
- package/src/gitlab-prompt.mjs +26 -3
- package/src/host-pi.mjs +8 -5
- package/src/image-preflight.mjs +10 -0
- package/src/index.mjs +6 -0
- package/src/model-catalog.mjs +2 -0
- package/src/models-json.mjs +65 -53
- package/src/parked.mjs +18 -0
- package/src/processor.mjs +55 -2
- package/src/provider-steering.mjs +1 -0
- package/src/queue.mjs +39 -1
- package/src/reserved-env.mjs +1 -0
- package/src/run-container.mjs +4 -0
- package/src/run-history.mjs +22 -2
- package/src/session-store.mjs +45 -2
- package/src/start.mjs +19 -0
- package/src/triggers-file.mjs +4 -0
- package/src/triggers.mjs +35 -6
package/src/queue.mjs
CHANGED
|
@@ -247,6 +247,38 @@ async function addLocalJob(queue, { folder, flow, task, command, provider, model
|
|
|
247
247
|
// window only needs to absorb burst re-labels, not the full redelivery window.
|
|
248
248
|
const SEMANTIC_WINDOW_MS = 10 * 60 * 1000;
|
|
249
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Close the semantic window of a run that PARKED (REQ-AWAIT-INPUT, DES-AWAIT-INPUT-ENDS-THE-JOB). Never throws.
|
|
252
|
+
*
|
|
253
|
+
* The window exists to coalesce rapid duplicate deliveries into one ACTIVE job. A ttl-mode key outlives the job it
|
|
254
|
+
* was set for (bullmq 5.80.4, `removeDeduplicationKeyIfNeededOnFinalization.lua`: a finished job deletes its key only
|
|
255
|
+
* when it has no ttl), so without this a person's answer that lands within ten minutes of the delivery that STARTED
|
|
256
|
+
* the parked run is swallowed against that finished run: the receiver answers `deduplicated`, nothing runs, and the
|
|
257
|
+
* marker stays. Once a run has parked, the next delivery on its key is a deliberate answer, never a duplicate.
|
|
258
|
+
*
|
|
259
|
+
* It asks the BullMQ job itself, rather than rebuilding the key from the job data: `job.deduplicationId` is the id
|
|
260
|
+
* the add stored with the job (`deid` in its hash), so there is no second spelling of the key format to drift from
|
|
261
|
+
* `enqueueForgeJob`'s, and it covers every suffix (the `closed:`/`cmd:` slot, a replica's `:r<n>`) by construction.
|
|
262
|
+
* And `job.removeDeduplicationKey()` is a compare-and-delete (`removeDeduplicationKey-1.lua`: DEL only when the key
|
|
263
|
+
* still holds THIS job's id), where `queue.removeDeduplicationKey(id)` is a bare DEL that could free a key a later
|
|
264
|
+
* job now holds.
|
|
265
|
+
*
|
|
266
|
+
* Fails open with a named line: a failure only leaves today's behaviour (the answer waits out the window).
|
|
267
|
+
* `released` is false, logged as a release and not as a failure, when the key is already gone (the window lapsed
|
|
268
|
+
* during a long run) or now names another job.
|
|
269
|
+
*/
|
|
270
|
+
export async function releaseSemanticWindow(job, log = () => {}) {
|
|
271
|
+
if (!job?.deduplicationId || typeof job.removeDeduplicationKey !== "function") return false;
|
|
272
|
+
try {
|
|
273
|
+
const released = (await job.removeDeduplicationKey()) === true;
|
|
274
|
+
log("parked_window_released", { jobId: job.id ?? null, released });
|
|
275
|
+
return released;
|
|
276
|
+
} catch (error) {
|
|
277
|
+
log("parked_window_release_failed", { jobId: job.id ?? null, code: typeof error?.code === "string" ? error.code : "error" });
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
250
282
|
/**
|
|
251
283
|
* Enqueue a GitHub-triggered job. Returns `{ jobId, deduplicated, survivingJobId? }` -- the computed
|
|
252
284
|
* per-delivery id, plus whether the SEMANTIC window swallowed this delivery (issue #289; the GUID
|
|
@@ -311,7 +343,7 @@ export async function enqueueGitLabJob(queue, fields) {
|
|
|
311
343
|
* window, replicas never coalesce against each other, and an unflagged job's dedup id is the same string it
|
|
312
344
|
* has always been.
|
|
313
345
|
*/
|
|
314
|
-
export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, models, maxCostUsd, packages, image, backend, excludeTools, mcp, skillsDir, instructions, resume, secrets, secretsProfile, waitFor, replica, replicas }) {
|
|
346
|
+
export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, models, maxCostUsd, packages, image, backend, excludeTools, mcp, skillsDir, instructions, resume, awaitInput, parkedReply, secrets, secretsProfile, waitFor, replica, replicas }) {
|
|
315
347
|
const jobId = forgeDeliveryJobId(kind, trigger?.deliveryId, replica);
|
|
316
348
|
// `packages` (whether to load the operator-staged pi packages) and `image` (which container image to run)
|
|
317
349
|
// come off the MATCHED trigger (INT-TRIGGERS-FILE-CONTRACT / REQ-GLOBAL-PI-OVERLAY) and land on `data`
|
|
@@ -366,6 +398,12 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
|
|
|
366
398
|
// delivery, and `trigger` is what /job/event.json is built from.
|
|
367
399
|
...(instructions !== undefined && { instructions }),
|
|
368
400
|
...(resume !== undefined && { resume }),
|
|
401
|
+
// REQ-AWAIT-INPUT (issue #625): only when armed, so an unflagged job's data is byte-identical, and at JOB level
|
|
402
|
+
// for resume's reason: `trigger` is copied verbatim into /job/event.json.
|
|
403
|
+
...(awaitInput === true && { awaitInput: true }),
|
|
404
|
+
// A phrase-free reply to a parked issue (issue #625), gated by the worker on the key's `parked` mark. Never part
|
|
405
|
+
// of an id: it says how the delivery was routed, not what it is.
|
|
406
|
+
...(parkedReply === true && { parkedReply: true }),
|
|
369
407
|
// REQ-TRIGGER-SECRETS. The env variables this trigger binds and the profile whose resolver reads
|
|
370
408
|
// them. REFERENCES only: no value is ever enqueued, because a queued job is durable and a resolved
|
|
371
409
|
// credential in Redis would outlive the container it was scoped to. The worker resolves them at job
|
package/src/reserved-env.mjs
CHANGED
|
@@ -43,6 +43,7 @@ export const CONTAINER_ENV_NAMES = new Set([
|
|
|
43
43
|
"PI_FLOW",
|
|
44
44
|
"PI_COMMAND",
|
|
45
45
|
"PI_EXCLUDE_TOOLS",
|
|
46
|
+
"PI_AWAIT_INPUT",
|
|
46
47
|
"PI_MCP_SERVERS",
|
|
47
48
|
// Issues #501, #502: the per-job dollar cap (written by the worker since #501, from the trigger's and the
|
|
48
49
|
// deployment's caps) and the allowed-model list (reserved until #502 writes it). Neither can be bound through
|
package/src/run-container.mjs
CHANGED
|
@@ -148,6 +148,10 @@ export function makeRunContainer({
|
|
|
148
148
|
excludeTools: Array.isArray(job.excludeTools) && job.excludeTools.length > 0 ? job.excludeTools : undefined,
|
|
149
149
|
// REQ-MCP-OPERATOR-SERVERS: the trigger's MCP server names, off `job` like excludeTools.
|
|
150
150
|
mcpServers: Array.isArray(job.mcp) && job.mcp.length > 0 ? job.mcp : undefined,
|
|
151
|
+
// Issue #625: whether the runner offers the tool a parked run ends on. Only beside a staged transcript: the
|
|
152
|
+
// runner refuses the flag without one (await-input-without-session), so a job whose resume degraded to no
|
|
153
|
+
// mount is not armed into that refusal.
|
|
154
|
+
awaitInput: job.awaitInput === true && prepared.session ? true : undefined,
|
|
151
155
|
// Issue #502: the effective allowed-model list, off `job` like excludeTools. index.mjs `effectiveJobOf` folds
|
|
152
156
|
// the deployment's PI_ALLOWED_MODELS in under the trigger's own, so `job.models` is already the one list.
|
|
153
157
|
allowedModels: Array.isArray(job.models) && job.models.length > 0 ? job.models : null,
|
package/src/run-history.mjs
CHANGED
|
@@ -172,11 +172,30 @@ export function parseExitTurns(text) {
|
|
|
172
172
|
* Live from the first image that declares `mcp`: an image built before the worker named them prints the same line.
|
|
173
173
|
*/
|
|
174
174
|
export const MCP_RUNNER_REASONS = Object.freeze(["mcp-unavailable", "mcp-config-invalid", "mcp-config-mismatch", "mcp-secret-missing", "mcp-command-missing", "mcp-shadowed"]);
|
|
175
|
-
|
|
175
|
+
/**
|
|
176
|
+
* The runner's exit-2 refusals of a job armed with `run.awaitInput` (issue #625), each before the prompt: another
|
|
177
|
+
* extension registers the tool a parked run ends on (`await-input-shadowed`), or the job has no session file to park
|
|
178
|
+
* a transcript in (`await-input-without-session`, a worker regression, since the worker arms the flag only beside
|
|
179
|
+
* `run.resume` and refuses that with no store). Named for MCP_RUNNER_REASONS' reason: the operator's fix is not a budget.
|
|
180
|
+
*/
|
|
181
|
+
export const AWAIT_INPUT_RUNNER_REASONS = Object.freeze(["await-input-shadowed", "await-input-without-session"]);
|
|
182
|
+
export const RUNNER_POLICY_REASONS = new Set(["provider-auth-refused", "cost-cap", "model-not-allowed", "cost-cap-unenforceable", "model-policy-unenforceable", ...MCP_RUNNER_REASONS, ...AWAIT_INPUT_RUNNER_REASONS]);
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The runner's reasons that ride a COMPLETED exit (`code: 0`) and that the worker acts on, closed (issue #625). One so
|
|
186
|
+
* far: `awaiting-input`, a run that asked a person a question and ended so the next run resumes with the answer. Every
|
|
187
|
+
* other exit-0 reason (`stop`, `length`, `command-completed`, `tool-terminated`) is still dropped, as before.
|
|
188
|
+
*
|
|
189
|
+
* Read for the same bounded reason RUNNER_POLICY_REASONS is: it cannot change the retry class. The processor consults it
|
|
190
|
+
* only inside its exit-0 branch, so the worst a forged line can do on a real exit 0 is mark a completed run parked: the
|
|
191
|
+
* worker then posts one fixed sentence and writes a marker beside a transcript the job already promoted.
|
|
192
|
+
*/
|
|
193
|
+
export const RUNNER_COMPLETED_REASONS = new Set(["awaiting-input"]);
|
|
176
194
|
|
|
177
195
|
/**
|
|
178
196
|
* The reason off the decisive exit line (`decisiveExitLine`), or `null`: a member of `RUNNER_POLICY_REASONS`
|
|
179
|
-
* and only when that same line says `code: 2
|
|
197
|
+
* and only when that same line says `code: 2`, or a member of `RUNNER_COMPLETED_REASONS` and only when it says
|
|
198
|
+
* `code: 0` (issue #625). Read off the same line as `parseExitTurns`, and NEVER throws.
|
|
180
199
|
*
|
|
181
200
|
* Why this may reach the outcome when its five siblings may not: it cannot change the retry class. The
|
|
182
201
|
* processor consults it only inside its container-exit-2 branch, so a container that exits 1 while
|
|
@@ -189,6 +208,7 @@ export const RUNNER_POLICY_REASONS = new Set(["provider-auth-refused", "cost-cap
|
|
|
189
208
|
export function parseExitReason(text) {
|
|
190
209
|
const parsed = decisiveExitLine(text);
|
|
191
210
|
if (parsed === null) return null;
|
|
211
|
+
if (parsed?.code === 0 && RUNNER_COMPLETED_REASONS.has(parsed?.reason)) return parsed.reason;
|
|
192
212
|
return parsed?.code === 2 && RUNNER_POLICY_REASONS.has(parsed?.reason) ? parsed.reason : null;
|
|
193
213
|
}
|
|
194
214
|
|
package/src/session-store.mjs
CHANGED
|
@@ -90,6 +90,15 @@ const CONTEXT_FILE = "context";
|
|
|
90
90
|
* stamp existed ran on `local`, and the default is a setting that can move under it.
|
|
91
91
|
*/
|
|
92
92
|
const VENUE_FILE = "venue";
|
|
93
|
+
/**
|
|
94
|
+
* The run that wrote the transcript beside it ended `awaiting-input` (REQ-AWAIT-INPUT, issue #625): it asked a person
|
|
95
|
+
* a question and ended so that the next run on this key resumes with the answer. One line, the promotion's
|
|
96
|
+
* `now()` in milliseconds. Keyed state written where the key already is, like `resume-chain`, so it is not the
|
|
97
|
+
* index `DES-SESSION-KEY-IS-DERIVED-NOT-INDEXED` refuses: it answers one question about this key and nothing
|
|
98
|
+
* looks it up by anything else. Written and REMOVED by promotion alone, under the promotion lock: a parked run
|
|
99
|
+
* writes it, any other completed run on the key removes it, so it never outlives the transcript it describes.
|
|
100
|
+
*/
|
|
101
|
+
export const PARKED_FILE = "parked";
|
|
93
102
|
/**
|
|
94
103
|
* What the venue stamp reads while a promotion is swapping the transcript under it. It matches no venue:
|
|
95
104
|
* parentheses are outside the charset every backend name is validated against (a test pins every name this
|
|
@@ -540,7 +549,7 @@ export function makeSessionStore({
|
|
|
540
549
|
return st.isDirectory() ? `${st.dev}:${st.ino}` : null;
|
|
541
550
|
}
|
|
542
551
|
|
|
543
|
-
function promoteSession(session, { piVersion = null, context = null } = {}) {
|
|
552
|
+
function promoteSession(session, { piVersion = null, context = null, parked = false } = {}) {
|
|
544
553
|
// The second DI-seam backstop, and unreachable for the same reason as the `!sessionsDir` return
|
|
545
554
|
// above: sessionKeyFor is total and binary (null, or 32 hex chars), so resolveSession returns null
|
|
546
555
|
// rather than a keyless session, and processor.mjs only calls this when prepare handed it one. Kept
|
|
@@ -711,6 +720,7 @@ export function makeSessionStore({
|
|
|
711
720
|
// it is reporting.
|
|
712
721
|
writeSidecar(dir, RESUME_CHAIN_FILE, session.key, chainValue(session));
|
|
713
722
|
writeContextSidecar(dir, session, context);
|
|
723
|
+
writeParkedSidecar(dir, session, parked);
|
|
714
724
|
} finally {
|
|
715
725
|
fs.closeSync(fd);
|
|
716
726
|
try {
|
|
@@ -840,6 +850,20 @@ export function makeSessionStore({
|
|
|
840
850
|
writeSidecar(dir, CONTEXT_FILE, session.key, `${context.tokens} ${context.window}${stamp}`);
|
|
841
851
|
}
|
|
842
852
|
|
|
853
|
+
// Issue #625: `parked` is set by a run that ended awaiting-input, and cleared by every other promotion, so the
|
|
854
|
+
// marker always describes the transcript beside it. Caught like its siblings: the promotion already happened.
|
|
855
|
+
function writeParkedSidecar(dir, session, parked) {
|
|
856
|
+
if (parked === true) {
|
|
857
|
+
writeSidecar(dir, PARKED_FILE, session.key, String(now()));
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
try {
|
|
861
|
+
fs.unlinkSync(join(dir, PARKED_FILE));
|
|
862
|
+
} catch {
|
|
863
|
+
// Absent is the desired state, so failing to remove what is not there is success.
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
843
867
|
function keyDir(key) {
|
|
844
868
|
return join(sessionsDir, key);
|
|
845
869
|
}
|
|
@@ -1289,5 +1313,24 @@ export function makeSessionStore({
|
|
|
1289
1313
|
}
|
|
1290
1314
|
}
|
|
1291
1315
|
|
|
1292
|
-
|
|
1316
|
+
/**
|
|
1317
|
+
* Whether the key a job resolves to holds a `parked` mark (issue #625): the TRUTH the worker gates a phrase-free reply
|
|
1318
|
+
* on, where the forge label was only the receiver's hint. Free, read-only and never throws, so it can answer before
|
|
1319
|
+
* the mint. An issue's key is a pure function of the job (`pi/issue-<n>`); a pull request's needs its head branch,
|
|
1320
|
+
* which costs a token to read, which is why a phrase-free reply is routed on issues only and this resolves no PR.
|
|
1321
|
+
* The key's own name must `lstat` as a real directory, resolveSession's first gate, so a planted link answers no.
|
|
1322
|
+
*/
|
|
1323
|
+
function isParked(job) {
|
|
1324
|
+
if (!sessionsDir || job?.target?.type === "pull_request") return false;
|
|
1325
|
+
const key = sessionKeyFor(job);
|
|
1326
|
+
if (!key) return false;
|
|
1327
|
+
try {
|
|
1328
|
+
if (!fs.lstatSync(keyDir(key)).isDirectory()) return false;
|
|
1329
|
+
} catch {
|
|
1330
|
+
return false;
|
|
1331
|
+
}
|
|
1332
|
+
return readSidecar(key, PARKED_FILE) !== null;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
return { resolveSession, promoteSession, reapSessions, isParked };
|
|
1293
1336
|
}
|
package/src/start.mjs
CHANGED
|
@@ -2002,6 +2002,23 @@ export async function startWorker(
|
|
|
2002
2002
|
log("comment", { jobId: job?.id, text });
|
|
2003
2003
|
};
|
|
2004
2004
|
|
|
2005
|
+
// Issue #625: the forge's parked marker, set on a park and cleared when it no longer applies. comment()'s shape and its
|
|
2006
|
+
// rule: it NEVER throws, because the marker decides nothing (the session store's sidecar does) and a forge that refuses
|
|
2007
|
+
// the label must not flip an outcome. A local job has no forge and no marker. Issues only: a pull request's reply is
|
|
2008
|
+
// routed on the phrase (parked.mjs).
|
|
2009
|
+
const parkedLabel = async (job, on) => {
|
|
2010
|
+
const forge = forgeFor(job);
|
|
2011
|
+
if (!forge?.host?.setParkedLabel || job?.target?.type !== "issue") return;
|
|
2012
|
+
try {
|
|
2013
|
+
const auth = forge.auth ?? (await ensureAuth(job.kind));
|
|
2014
|
+
if (!auth) return;
|
|
2015
|
+
const token = await auth.mintToken(job);
|
|
2016
|
+
await forge.host.setParkedLabel(job, job.target, on, token);
|
|
2017
|
+
} catch (err) {
|
|
2018
|
+
log("parked_label_failed", { jobId: job?.id, on, reason: err?.message });
|
|
2019
|
+
}
|
|
2020
|
+
};
|
|
2021
|
+
|
|
2005
2022
|
// The operator's failure hook (issue #288, INT-ON-FAILURE-HOOK-CONTRACT). Constructed ONLY when the
|
|
2006
2023
|
// knob is set: an unset PI_ON_FAILURE builds nothing, spawns nothing, and logs nothing -- the
|
|
2007
2024
|
// byte-identical guarantee. Fired from the two terminal listeners below, never from the processor:
|
|
@@ -2241,6 +2258,8 @@ export async function startWorker(
|
|
|
2241
2258
|
// Completed-only, so a policy or infra exit leaves the canonical transcript byte-identical and a
|
|
2242
2259
|
// retry starts from what the first attempt did (CONST-RETRY-INFRA-ONLY).
|
|
2243
2260
|
promoteSession: sessionStore.promoteSession,
|
|
2261
|
+
isParked: sessionStore.isParked,
|
|
2262
|
+
parkedLabel,
|
|
2244
2263
|
// The same value the store above was built from, passed explicitly so the processor's fail-closed
|
|
2245
2264
|
// `run.resume` gate answers from THIS config rather than from its own env default. Identical on the
|
|
2246
2265
|
// real path; the difference shows under an injected env, where the store would be built from the
|
package/src/triggers-file.mjs
CHANGED
|
@@ -478,6 +478,10 @@ export const AUTHORED_NARROWING_FIELDS = Object.freeze([
|
|
|
478
478
|
// REQ-MCP-OPERATOR-SERVERS: the MCP servers a trigger's jobs connect. Not a widening when dropped but the same
|
|
479
479
|
// silent outcome: the job runs its flow without the servers it was written against, on a clean record.
|
|
480
480
|
Object.freeze({ key: "mcp", authored: (v) => Array.isArray(v) && v.length > 0 }),
|
|
481
|
+
// REQ-AWAIT-INPUT: the tool a run ends on to wait for a person. Dropped by a receiver from before #625 (which tolerates
|
|
482
|
+
// the key as unknown), the job runs without it: the agent cannot ask, and the record reads like a clean run. Only
|
|
483
|
+
// `true` arms it, and the job data carries it only then, so `false` is nothing authored.
|
|
484
|
+
Object.freeze({ key: "awaitInput", authored: (v) => v === true }),
|
|
481
485
|
]);
|
|
482
486
|
|
|
483
487
|
export function makeCheckWaitSkew({ triggersPath, fs = nodeFs }) {
|
package/src/triggers.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { imageRefProblem } from "./image-ref.mjs";
|
|
|
30
30
|
// materialize.mjs and the admin already import it, and a keep-in-sync copy would drift where a
|
|
31
31
|
// traversal guard cannot). flow-gate's module body is import-inert, so this keeps parseTriggers pure.
|
|
32
32
|
import { SKILL_NAME_RE } from "./flow-gate.mjs";
|
|
33
|
-
import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
|
|
33
|
+
import { FORGE_HOST_VARS, FORGE_KINDS, MINTED_TOKEN_VARS, RECEIVER_ONLY_VARS, RUN_KINDS, forgeSpec, isForgeKind } from "./forges.mjs";
|
|
34
34
|
import { findDuplicateKey } from "./json-duplicates.mjs";
|
|
35
35
|
// The server-name rule, from the one module that parses the overlay's list (its body is import-inert).
|
|
36
36
|
import { FORGE_MCP_KINDS, FORGE_MCP_SERVER_NAME, MCP_SERVER_NAME_RE } from "./mcp-config.mjs";
|
|
@@ -261,7 +261,7 @@ const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
|
261
261
|
* Every set is IMPORTED, never retyped, which is the rule `sandbox.test.mjs` keeps for the same reason: a
|
|
262
262
|
* forge added to the table later must not need a second edit here to stay covered.
|
|
263
263
|
*/
|
|
264
|
-
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]);
|
|
264
|
+
export const RESERVED_ENV_NAMES = new Set([...MINTED_TOKEN_VARS, ...FORGE_HOST_VARS, ...RECEIVER_ONLY_VARS, ...WORKER_ONLY_SECRET_VARS, ...EGRESS_ENV_VARS, ...CONTAINER_ENV_NAMES, ...RUNNER_ENV_NAMES, ...PROVIDER_STEERING_VARS]);
|
|
265
265
|
|
|
266
266
|
function isNonEmptyString(value) {
|
|
267
267
|
return typeof value === "string" && value.trim() !== "";
|
|
@@ -425,6 +425,9 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
425
425
|
// `false` or absent. Both must keep reaching the job payload unchanged -- an operator who wrote down
|
|
426
426
|
// today's default must not get a `data` that disagrees with the file they reviewed.
|
|
427
427
|
const resume = validateResumeFlag(run, `cron trigger "${id}"`, path);
|
|
428
|
+
// Called for its refusal only (issue #625): `true` needs run.resume, which cron refuses, so the returned `run`
|
|
429
|
+
// grows no `awaitInput` key, exactly as it grows no `replicas` one.
|
|
430
|
+
validateAwaitInputFlag(run, `cron trigger "${id}"`, path);
|
|
428
431
|
// Called and DISCARDED: on a cron trigger this can only refuse, and the refusal is the point. The
|
|
429
432
|
// returned `run` below deliberately grows no `replicas` key -- a cron entry can never carry one.
|
|
430
433
|
validateReplicas(run, `cron trigger "${id}"`, path);
|
|
@@ -557,6 +560,28 @@ function validateResumeFlag(run, at, path) {
|
|
|
557
560
|
return run.resume;
|
|
558
561
|
}
|
|
559
562
|
|
|
563
|
+
/**
|
|
564
|
+
* Validate the per-trigger `run.awaitInput` flag (REQ-AWAIT-INPUT, issue #625): whether the job is offered the tool
|
|
565
|
+
* that ends a run to wait for a person's answer. Strictly boolean, validateResumeFlag's rule and for its reason: a
|
|
566
|
+
* truthy `"false"` would arm what the operator meant to leave off.
|
|
567
|
+
*
|
|
568
|
+
* `true` needs `run.resume: true` beside it, and that one rule carries every other refusal: a parked run is only
|
|
569
|
+
* worth anything if the next run resumes its transcript, and `run.resume` is already refused on cron and beside
|
|
570
|
+
* `run.command`, `run.replicas` and `run.secrets`, each for a reason that holds here too. So this adds no list of
|
|
571
|
+
* its own, which is one place fewer for the two to disagree.
|
|
572
|
+
*
|
|
573
|
+
* Returns the flag, undefined when absent, so an unflagged trigger normalizes byte-identically to today's.
|
|
574
|
+
*/
|
|
575
|
+
function validateAwaitInputFlag(run, at, path) {
|
|
576
|
+
if (run.awaitInput !== undefined && typeof run.awaitInput !== "boolean") {
|
|
577
|
+
throw configError(`${at}: run.awaitInput must be true or false when present: ${path}`);
|
|
578
|
+
}
|
|
579
|
+
if (run.awaitInput === true && run.resume !== true) {
|
|
580
|
+
throw configError(`${at}: run.awaitInput needs run.resume: true beside it -- a run that stops to wait for an answer is continued by the next run on the same issue or pull request, and only run.resume keeps the transcript it would continue from: ${path}`);
|
|
581
|
+
}
|
|
582
|
+
return run.awaitInput;
|
|
583
|
+
}
|
|
584
|
+
|
|
560
585
|
/**
|
|
561
586
|
* Validate the per-trigger `run.image` reference, shared by all four normalizers. It selects the Docker image
|
|
562
587
|
* this trigger's job containers run in, overriding the deployment-wide `PI_JOB_IMAGE` for this trigger only;
|
|
@@ -1649,6 +1674,7 @@ function normalizeLabel(on, run, index, path) {
|
|
|
1649
1674
|
const skillsDir = validateSkillsDir(run, at, path);
|
|
1650
1675
|
const instructions = validateInstructions(run, at, path);
|
|
1651
1676
|
const resume = validateResumeFlag(run, at, path);
|
|
1677
|
+
const awaitInput = validateAwaitInputFlag(run, at, path);
|
|
1652
1678
|
const repository = validateRepository(run, "label", at, path);
|
|
1653
1679
|
const replicas = validateReplicas(run, at, path);
|
|
1654
1680
|
const secrets = validateSecrets(run, at, path);
|
|
@@ -1660,7 +1686,7 @@ function normalizeLabel(on, run, index, path) {
|
|
|
1660
1686
|
const ref = validateRunModel(on, run, at, path);
|
|
1661
1687
|
return {
|
|
1662
1688
|
on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
|
|
1663
|
-
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 }), ...(mcp !== undefined && { mcp }), ...ref },
|
|
1689
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(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 }), ...(mcp !== undefined && { mcp }), ...ref },
|
|
1664
1690
|
};
|
|
1665
1691
|
}
|
|
1666
1692
|
|
|
@@ -1692,6 +1718,7 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
1692
1718
|
const skillsDir = validateSkillsDir(run, at, path);
|
|
1693
1719
|
const instructions = validateInstructions(run, at, path);
|
|
1694
1720
|
const resume = validateResumeFlag(run, at, path);
|
|
1721
|
+
const awaitInput = validateAwaitInputFlag(run, at, path);
|
|
1695
1722
|
const repository = validateRepository(run, "comment", at, path);
|
|
1696
1723
|
const replicas = validateReplicas(run, at, path);
|
|
1697
1724
|
const secrets = validateSecrets(run, at, path);
|
|
@@ -1703,7 +1730,7 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
1703
1730
|
const ref = validateRunModel(on, run, at, path);
|
|
1704
1731
|
return {
|
|
1705
1732
|
on: { type: "comment", phrase: on.phrase },
|
|
1706
|
-
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 }), ...(mcp !== undefined && { mcp }), ...ref },
|
|
1733
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(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 }), ...(mcp !== undefined && { mcp }), ...ref },
|
|
1707
1734
|
};
|
|
1708
1735
|
}
|
|
1709
1736
|
|
|
@@ -1762,6 +1789,7 @@ function normalizeIssue(on, run, index, path) {
|
|
|
1762
1789
|
const skillsDir = validateSkillsDir(run, at, path);
|
|
1763
1790
|
const instructions = validateInstructions(run, at, path);
|
|
1764
1791
|
const resume = validateResumeFlag(run, at, path);
|
|
1792
|
+
const awaitInput = validateAwaitInputFlag(run, at, path);
|
|
1765
1793
|
validateRepository(run, "issue", at, path);
|
|
1766
1794
|
const replicas = validateReplicas(run, at, path);
|
|
1767
1795
|
const secrets = validateSecrets(run, at, path);
|
|
@@ -1780,7 +1808,7 @@ function normalizeIssue(on, run, index, path) {
|
|
|
1780
1808
|
...(number !== undefined && { number }),
|
|
1781
1809
|
...(once !== undefined && { once }),
|
|
1782
1810
|
},
|
|
1783
|
-
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 }), ...(mcp !== undefined && { mcp }), ...ref },
|
|
1811
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
|
|
1784
1812
|
};
|
|
1785
1813
|
}
|
|
1786
1814
|
|
|
@@ -1850,6 +1878,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
1850
1878
|
const skillsDir = validateSkillsDir(run, at, path);
|
|
1851
1879
|
const instructions = validateInstructions(run, at, path);
|
|
1852
1880
|
const resume = validateResumeFlag(run, at, path);
|
|
1881
|
+
const awaitInput = validateAwaitInputFlag(run, at, path);
|
|
1853
1882
|
validateRepository(run, "pull_request", at, path);
|
|
1854
1883
|
const replicas = validateReplicas(run, at, path);
|
|
1855
1884
|
const secrets = validateSecrets(run, at, path);
|
|
@@ -1873,7 +1902,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
1873
1902
|
...(number !== undefined && { number }),
|
|
1874
1903
|
...(once !== undefined && { once }),
|
|
1875
1904
|
},
|
|
1876
|
-
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 }), ...(mcp !== undefined && { mcp }), ...ref },
|
|
1905
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(awaitInput !== undefined && { awaitInput }), ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(waitFor !== undefined && { waitFor }), ...(backend !== undefined && { backend }), ...(excludeTools !== undefined && { excludeTools }), ...(mcp !== undefined && { mcp }), ...ref },
|
|
1877
1906
|
};
|
|
1878
1907
|
}
|
|
1879
1908
|
|