@edgehero/pi-dispatch 0.2.0 → 1.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/package.json +3 -1
- package/src/config.mjs +19 -2
- package/src/doctor.mjs +137 -3
- package/src/env-allowlist.mjs +17 -1
- package/src/flow-gate.mjs +3 -1
- package/src/github-app-setup.mjs +4 -18
- package/src/image-preflight.mjs +9 -0
- package/src/open-browser.mjs +24 -0
- package/src/outbox.mjs +14 -0
- package/src/packages.mjs +85 -2
- package/src/prepare-github.mjs +12 -1
- package/src/prepare.mjs +12 -3
- package/src/processor.mjs +17 -0
- package/src/queue.mjs +25 -5
- package/src/run-container.mjs +7 -0
- package/src/run-history.mjs +13 -0
- package/src/schedules.mjs +7 -1
- package/src/triggers.mjs +107 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@edgehero/pi-dispatch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Self-hosted job harness for the pi coding agent: a BullMQ worker that drains the queue, mints scoped forge tokens, and runs one container per job — plus the pi-dispatch CLI (init, up, doctor, service).",
|
|
6
6
|
"keywords": [
|
|
@@ -44,6 +44,8 @@
|
|
|
44
44
|
"./config": "./src/config.mjs",
|
|
45
45
|
"./exit-code": "./src/exit-code.mjs",
|
|
46
46
|
"./flow-gate": "./src/flow-gate.mjs",
|
|
47
|
+
"./materialize": "./src/materialize.mjs",
|
|
48
|
+
"./open-browser": "./src/open-browser.mjs",
|
|
47
49
|
"./git-dirty": "./src/git-dirty.mjs",
|
|
48
50
|
"./queue": "./src/queue.mjs",
|
|
49
51
|
"./connection": "./src/connection.mjs",
|
package/src/config.mjs
CHANGED
|
@@ -15,6 +15,13 @@ export function configError(message) {
|
|
|
15
15
|
return error;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// The chain caps' DEFAULTS, exported (issue #54) so the admin's read-model can state them without a
|
|
19
|
+
// second literal to drift and without calling loadConfig, whose GitHub-auth validation throws on
|
|
20
|
+
// problems unrelated to a path read (the documented reason resolvePaths never calls it). The env
|
|
21
|
+
// OVERRIDES stay right here in loadConfig; only the defaults are shared.
|
|
22
|
+
export const CHAIN_DEPTH_MAX_DEFAULT = 1; // DES-JOB-OUTBOX-CHAINING; 0 = chaining kill-switch (fail-closed)
|
|
23
|
+
export const CHAIN_MAX_PER_JOB_DEFAULT = 2; // INT-OUTBOX-CONTRACT: max request-<n>.json collected per parent
|
|
24
|
+
|
|
18
25
|
function boundedInt(env, name, fallback, min, want) {
|
|
19
26
|
const raw = env[name];
|
|
20
27
|
if (raw === undefined || raw === "") {
|
|
@@ -191,8 +198,8 @@ export function loadConfig(env = process.env, { fileExists = existsSync } = {})
|
|
|
191
198
|
// A bound on how large a transcript may be before it stops being resumed. Not disk hygiene: an
|
|
192
199
|
// oversized transcript is a prefill an operator never sized PI_MAX_TOKENS for.
|
|
193
200
|
sessionMaxBytes: nonNegativeInt(env, "PI_SESSION_MAX_BYTES", 8 * 1024 * 1024), // 0 = no cap
|
|
194
|
-
chainDepthMax: nonNegativeInt(env, "PI_CHAIN_DEPTH_MAX",
|
|
195
|
-
chainMaxPerJob: nonNegativeInt(env, "PI_CHAIN_MAX_PER_JOB",
|
|
201
|
+
chainDepthMax: nonNegativeInt(env, "PI_CHAIN_DEPTH_MAX", CHAIN_DEPTH_MAX_DEFAULT), // DES-JOB-OUTBOX-CHAINING; 0 = chaining kill-switch (fail-closed)
|
|
202
|
+
chainMaxPerJob: nonNegativeInt(env, "PI_CHAIN_MAX_PER_JOB", CHAIN_MAX_PER_JOB_DEFAULT), // INT-OUTBOX-CONTRACT: max request-<n>.json collected per parent
|
|
196
203
|
dispatchRunPerHour: nonNegativeInt(env, "PI_DISPATCH_RUN_PER_HOUR", 3), // DES-ADMIN-VIA-PI-EXTENSION; 0 = disable dispatch_run
|
|
197
204
|
dispatchRunRoots: delimitedList(env.PI_DISPATCH_RUN_ROOTS), // DES-AI-TRIGGER-FLOW-GATE: default [] fails closed — no folder passes, dispatch_run refuses everything
|
|
198
205
|
github: { ...loadGitHubAuth(env, fileExists), allowGhResume: env.PI_SESSIONS_ALLOW_GH_SOURCE === "1" },
|
|
@@ -262,6 +269,16 @@ export function defaultLogsDir() {
|
|
|
262
269
|
return `${process.env.TMPDIR ?? process.env.TEMP ?? "/tmp"}/pi-dispatch/logs`.replace(/\\/g, "/");
|
|
263
270
|
}
|
|
264
271
|
|
|
272
|
+
export function defaultGraphDir(env = process.env) {
|
|
273
|
+
// Under the OS temp dir by default, beside logs/ and jobs/ -- the admin's graph HTML artifact
|
|
274
|
+
// (issue #54) is host-side display output on the defaultLogsDir doctrine, and deliberately NOT
|
|
275
|
+
// inside logsDir: INT-RUN-HISTORY-FILE-CONTRACT names that directory's filename shape, and a
|
|
276
|
+
// stray .html beside the sidecars would widen a contract for a file that is not a record.
|
|
277
|
+
// Overridable with PI_GRAPH_DIR; exported so the admin resolves the same default without
|
|
278
|
+
// loadConfig, like defaultSandboxDir above.
|
|
279
|
+
return `${env.TMPDIR ?? env.TEMP ?? "/tmp"}/pi-dispatch/graph`.replace(/\\/g, "/");
|
|
280
|
+
}
|
|
281
|
+
|
|
265
282
|
export function defaultSettingsFile() {
|
|
266
283
|
// Under the OS temp dir by default. Holds the runtime-tunable settings overlay shared with the admin
|
|
267
284
|
// extension (INT-CONFIG-OVERLAY-CONTRACT); a worker-owned path that never enters the container env
|
package/src/doctor.mjs
CHANGED
|
@@ -53,8 +53,9 @@ import { defaultSandboxDir, globalExtensionsEnabled } from "./config.mjs";
|
|
|
53
53
|
import { isForgeKind } from "./forges.mjs";
|
|
54
54
|
import { findLiteralSecret, ADMIN_RE } from "./import-pi.mjs";
|
|
55
55
|
import { agentDirFrom, readHostPi } from "./host-pi.mjs";
|
|
56
|
-
import { PACKAGES_SUBDIR, readStageManifest } from "./packages.mjs";
|
|
56
|
+
import { PACKAGES_SUBDIR, readStagedSkills, readStageManifest } from "./packages.mjs";
|
|
57
57
|
import { copySkillTree } from "./copy-tree.mjs";
|
|
58
|
+
import { SKILL_NAME_RE } from "./flow-gate.mjs";
|
|
58
59
|
import { parseTriggers } from "./triggers.mjs";
|
|
59
60
|
|
|
60
61
|
const NODE_FLOOR = [22, 19]; // pi's engine floor (22.19.0)
|
|
@@ -251,7 +252,7 @@ export async function collectChecks(env, seams) {
|
|
|
251
252
|
// image checks just below, and `optingOut`/`requiring` colour the staged-packages lines further down.
|
|
252
253
|
// `optingOut` counts the only value that withholds the staged set; `requiring` counts an explicit
|
|
253
254
|
// run.packages: true, which arms nothing any more but is still an operator statement of intent.
|
|
254
|
-
const { requiring, optingOut, resuming, replicating, instructing, images, skillsDirs, forges, repositories } = readTriggerFacts(env, fileExists, cwd);
|
|
255
|
+
const { requiring, optingOut, resuming, replicating, instructing, commands, images, skillsDirs, forges, repositories, flows } = readTriggerFacts(env, fileExists, cwd);
|
|
255
256
|
|
|
256
257
|
// Only meaningful if docker itself responds; otherwise the image check is noise on top of a down daemon.
|
|
257
258
|
const imageCode = dockerCode === 0 ? await runCmd(spawn, "docker", ["image", "inspect", jobImage]) : null;
|
|
@@ -325,6 +326,91 @@ export async function collectChecks(env, seams) {
|
|
|
325
326
|
}
|
|
326
327
|
}
|
|
327
328
|
|
|
329
|
+
// REQ-PER-TRIGGER-SKILLS (issue #189). One line per distinct (flow, folder, skillsDir, packages)
|
|
330
|
+
// question: does the flow this trigger names resolve in ANY tier this host can see? Probed in the
|
|
331
|
+
// loader's own precedence order (repo > injected > overlay > staged packages), first hit wins the
|
|
332
|
+
// line. ⚠ and NEVER ✗ when nothing resolves -- a forge trigger's repo is not on this host and
|
|
333
|
+
// mid-setup is legal -- and no fixAction (triggers content is the never tier). The runner's
|
|
334
|
+
// flow_not_loaded line is the exact, in-container half of the same answer
|
|
335
|
+
// (DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS): these probes read dir names, and a frontmatter
|
|
336
|
+
// `name:` rename is invisible to them, so a ⚠ here can be wrong in only the loud direction.
|
|
337
|
+
// A deployment with no triggers adds no lines at all, so its output is byte-identical.
|
|
338
|
+
if (flows.length > 0) {
|
|
339
|
+
// One stage read for the whole run; each tuple's staged probe is a lookup on its result.
|
|
340
|
+
const staged = readStagedSkills({ globalPiDir: env.PI_GLOBAL_PI_DIR, readFile: (p) => readFileSync(p, "utf8"), fileExists });
|
|
341
|
+
const groups = new Map();
|
|
342
|
+
for (const f of flows) {
|
|
343
|
+
const key = JSON.stringify([f.flow, f.folder, f.skillsDir, f.packages]);
|
|
344
|
+
if (!groups.has(key)) groups.set(key, { ...f, labels: [] });
|
|
345
|
+
groups.get(key).labels.push(f.label);
|
|
346
|
+
}
|
|
347
|
+
for (const g of groups.values()) {
|
|
348
|
+
const at = g.labels.join(", ");
|
|
349
|
+
// The charset pre-check doubles as the interpolation guard for the git probe below, and it is
|
|
350
|
+
// a finding of its own: a name the skill charset refuses can never materialise in ANY tier
|
|
351
|
+
// (materialize and copy-tree enforce the same RE on the way in).
|
|
352
|
+
if (!SKILL_NAME_RE.test(g.flow)) {
|
|
353
|
+
checks.push({
|
|
354
|
+
ok: false,
|
|
355
|
+
warn: true,
|
|
356
|
+
label: `Trigger flow ${JSON.stringify(g.flow)} fails the skill name charset (${at})`,
|
|
357
|
+
fix: "a flow name must match the skill charset (lowercase alphanumerics, - and _, 64 max), or no tier can ever hold it -- fix run.flow",
|
|
358
|
+
});
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
let resolved = null;
|
|
362
|
+
const checked = [];
|
|
363
|
+
const unknown = [];
|
|
364
|
+
if (g.folder) {
|
|
365
|
+
const state = await repoFlowAtHead(spawn, g.folder, g.flow);
|
|
366
|
+
if (state === "present") resolved = `repo .pi/skills at HEAD of ${g.folder}`;
|
|
367
|
+
else if (state === "absent") checked.push("repo .pi/skills at HEAD");
|
|
368
|
+
else unknown.push(`repo (${g.folder} is not readable as a git repo here)`);
|
|
369
|
+
} else {
|
|
370
|
+
unknown.push("repo (a forge clone, not on this host)");
|
|
371
|
+
}
|
|
372
|
+
if (!resolved && g.skillsDir) {
|
|
373
|
+
if (fileExists(join(g.skillsDir, g.flow, "SKILL.md"))) resolved = `injected run.skillsDir ${g.skillsDir}`;
|
|
374
|
+
else checked.push("injected run.skillsDir");
|
|
375
|
+
}
|
|
376
|
+
if (!resolved && env.PI_GLOBAL_PI_DIR) {
|
|
377
|
+
if (fileExists(join(env.PI_GLOBAL_PI_DIR, "skills", g.flow, "SKILL.md"))) resolved = "the overlay skills/";
|
|
378
|
+
else checked.push("overlay skills/");
|
|
379
|
+
}
|
|
380
|
+
if (!resolved) {
|
|
381
|
+
if (!g.packages) {
|
|
382
|
+
checked.push("staged packages (withheld: run.packages false)");
|
|
383
|
+
} else {
|
|
384
|
+
const hit = staged.skills.find((s) => s.name === g.flow);
|
|
385
|
+
if (hit) resolved = `staged package ${hit.package}`;
|
|
386
|
+
else if (staged.unenumerable.length > 0) unknown.push(`staged package(s) ${staged.unenumerable.join(", ")} (manifest patterns, not enumerable here)`);
|
|
387
|
+
else checked.push("staged packages");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (resolved) {
|
|
391
|
+
checks.push({ ok: true, label: `Trigger flow "${g.flow}" resolves (${at}: ${resolved})` });
|
|
392
|
+
} else {
|
|
393
|
+
checks.push({
|
|
394
|
+
ok: false,
|
|
395
|
+
warn: true,
|
|
396
|
+
label: `Trigger flow "${g.flow}" resolves in NO tier visible here (${at})`,
|
|
397
|
+
fix: `checked: ${checked.join(", ") || "nothing checkable"}${unknown.length > 0 ? `; not checkable here: ${unknown.join(", ")}` : ""} -- commit .pi/skills/${g.flow}/SKILL.md, add the skill to run.skillsDir or the overlay skills/, or stage a package shipping it; a job of this trigger runs without the flow it names (the runner logs flow_not_loaded) and still exits 0`,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// run.command triggers (issue #189): ONE advisory line, deliberately WITHOUT the per-tier probes the
|
|
404
|
+
// flow block above runs. A command is registered by extension CODE at pi startup -- repo .pi/, the
|
|
405
|
+
// overlay and staged packages all contribute, and none is enumerable host-side without executing the
|
|
406
|
+
// extension, which doctor must never do. The honest line names where the real check lives instead;
|
|
407
|
+
// unlike a missing flow, the failure there is LOUD (a refusal, not a clean exit 0), which is why this
|
|
408
|
+
// is advisory and carries no fixAction (triggers content is the never tier). A deployment with no
|
|
409
|
+
// command triggers adds no line at all, so its output is byte-identical.
|
|
410
|
+
if (commands > 0) {
|
|
411
|
+
checks.push({ ok: true, label: `${commands} command trigger(s): a command is only verifiable in-container -- the runner refuses an unregistered one pre-spend (command-unregistered)` });
|
|
412
|
+
}
|
|
413
|
+
|
|
328
414
|
// Issue #41: every DISTINCT image a trigger names in run.image, minus the deployment default already
|
|
329
415
|
// checked above. Two silent-failure modes, and both used to be impossible because there was one image.
|
|
330
416
|
// 1. the image was never built -- a job that refuses pre-spend at 03:00 in a log nobody is reading, and
|
|
@@ -1191,8 +1277,37 @@ function aiTriggerNames(dir) {
|
|
|
1191
1277
|
return names;
|
|
1192
1278
|
}
|
|
1193
1279
|
|
|
1280
|
+
/**
|
|
1281
|
+
* Does `.pi/skills/<flow>/SKILL.md` exist at HEAD of a local folder? "present" | "absent" | "unknown".
|
|
1282
|
+
*
|
|
1283
|
+
* Deliberately NOT readFlowGate: that module answers WHO may fire a flow (the ai-trigger frontmatter,
|
|
1284
|
+
* at a caller-pinned sha) and its catch collapses ANY git failure into deny -- fail-closed is right
|
|
1285
|
+
* for a gate and exactly wrong here, where deny-because-git-broke would print a confident wrong
|
|
1286
|
+
* answer on an advisory line. Doctor resolving HEAD itself is also fine: the gate's no-ref rule
|
|
1287
|
+
* defends against an agent self-authorizing mid-run, and a host-side preflight has no agent. What IS
|
|
1288
|
+
* the gate's, verbatim, is the ls-tree read, the 100644-blob requirement and the hardening flags --
|
|
1289
|
+
* copied so the two readers cannot disagree about what "a committed skill file" means, and so a
|
|
1290
|
+
* hostile repo config cannot run code during the read (flow-gate.mjs's defaultGit, restated).
|
|
1291
|
+
*/
|
|
1292
|
+
const GIT_READ_FLAGS = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "--no-pager"];
|
|
1293
|
+
async function repoFlowAtHead(spawn, folder, flow) {
|
|
1294
|
+
if (!SKILL_NAME_RE.test(flow)) return "unknown"; // the caller pre-checks; belt against interpolation
|
|
1295
|
+
const head = await runCmdCapture(spawn, "git", [...GIT_READ_FLAGS, "-C", folder, "rev-parse", "HEAD"]);
|
|
1296
|
+
const sha = head.code === 0 ? head.output.trim() : null;
|
|
1297
|
+
if (!sha || !/^[0-9a-f]{40,64}$/.test(sha)) return "unknown";
|
|
1298
|
+
const tree = await runCmdCapture(spawn, "git", [...GIT_READ_FLAGS, "-C", folder, "ls-tree", "-z", sha, `.pi/skills/${flow}/SKILL.md`]);
|
|
1299
|
+
if (tree.code !== 0) return "unknown";
|
|
1300
|
+
const record = tree.output.split("\0").find((r) => r);
|
|
1301
|
+
if (!record) return "absent"; // valid sha, path absent at that commit
|
|
1302
|
+
const tab = record.indexOf("\t");
|
|
1303
|
+
const [mode, type] = tab === -1 ? [] : record.slice(0, tab).split(/\s+/);
|
|
1304
|
+
// A symlink/gitlink entry is "absent" for this question too: the gate would refuse it, and the
|
|
1305
|
+
// materialiser never copies it, so nothing downstream treats it as a skill file.
|
|
1306
|
+
return mode === "100644" && type === "blob" ? "present" : "absent";
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1194
1309
|
function readTriggerFacts(env, fileExists, cwd) {
|
|
1195
|
-
const none = { requiring: 0, optingOut: 0, resuming: 0, replicating: 0, instructing: 0, images: [], skillsDirs: [], forges: [], repositories: [] };
|
|
1310
|
+
const none = { requiring: 0, optingOut: 0, resuming: 0, replicating: 0, instructing: 0, commands: 0, images: [], skillsDirs: [], forges: [], repositories: [], flows: [] };
|
|
1196
1311
|
try {
|
|
1197
1312
|
// Unset falls back to ./triggers.json in cwd, MIRRORING the receiver's own default
|
|
1198
1313
|
// (receiver/src/config.mjs) -- the two must read the same file, or doctor preflights a deployment
|
|
@@ -1209,6 +1324,10 @@ function readTriggerFacts(env, fileExists, cwd) {
|
|
|
1209
1324
|
// REQ-REPLICA-RUNS. `> 1` rather than `!== undefined` because the loader already refuses anything
|
|
1210
1325
|
// else -- this counts triggers that will actually multiply spend, which is the only reason to say so.
|
|
1211
1326
|
replicating: triggers.filter((t) => t.run.replicas > 1).length,
|
|
1327
|
+
// run.command triggers (issue #189), counted for the one advisory line below. The `flows`
|
|
1328
|
+
// tuple list already filters to `typeof f.flow === "string"`, so a command trigger drops out
|
|
1329
|
+
// of the flow-tier probes naturally -- no exclusion needed there.
|
|
1330
|
+
commands: triggers.filter((t) => typeof t.run.command === "string").length,
|
|
1212
1331
|
optingOut: triggers.filter((t) => t.run.packages === false).length,
|
|
1213
1332
|
images: [...new Set(triggers.map((t) => t.run.image).filter((i) => typeof i === "string"))].sort(),
|
|
1214
1333
|
// REQ-PER-TRIGGER-SKILLS. The distinct host directories the file names, deduped like `images`,
|
|
@@ -1224,6 +1343,21 @@ function readTriggerFacts(env, fileExists, cwd) {
|
|
|
1224
1343
|
// would report all-green and never mention that the credential it needs was never looked for.
|
|
1225
1344
|
forges: [...new Set(triggers.map((t) => t.run.kind).filter(isForgeKind))].sort(),
|
|
1226
1345
|
repositories: [...new Set(triggers.filter((t) => t.run.kind === "github" && typeof t.run.repository === "string").map((t) => t.run.repository))].sort(),
|
|
1346
|
+
// REQ-PER-TRIGGER-SKILLS (issue #189). Per-trigger TUPLES, unlike every deduped set above,
|
|
1347
|
+
// because a flow-resolution answer depends on the trigger's own folder/skillsDir/packages --
|
|
1348
|
+
// two triggers naming the same flow with different skillsDirs are two different questions.
|
|
1349
|
+
// The label is how a line names its trigger: cron entries by their id, id-less webhook
|
|
1350
|
+
// entries by raw file position (the admin's trigger:<index> identity).
|
|
1351
|
+
flows: triggers
|
|
1352
|
+
.map((t, index) => ({
|
|
1353
|
+
label: t.on.type === "cron" ? `cron "${t.on.id}"` : `${t.on.type} trigger #${index}`,
|
|
1354
|
+
flow: t.run.flow,
|
|
1355
|
+
kind: t.run.kind,
|
|
1356
|
+
folder: typeof t.run.folder === "string" ? t.run.folder : null,
|
|
1357
|
+
skillsDir: typeof t.run.skillsDir === "string" ? t.run.skillsDir : null,
|
|
1358
|
+
packages: t.run.packages !== false,
|
|
1359
|
+
}))
|
|
1360
|
+
.filter((f) => typeof f.flow === "string"),
|
|
1227
1361
|
};
|
|
1228
1362
|
} catch {
|
|
1229
1363
|
return none;
|
package/src/env-allowlist.mjs
CHANGED
|
@@ -110,7 +110,7 @@ function resolveEnvName(provider, cred) {
|
|
|
110
110
|
* `allowGlobalExtensions` defaults to TRUE here, matching loadConfig's default (REQ-GLOBAL-PI-OVERLAY): a
|
|
111
111
|
* caller that says nothing gets the operator's staged setup, and only an explicit `false` withholds it.
|
|
112
112
|
*/
|
|
113
|
-
export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], sessionFile = null, authFromPi = false, agentDir, readFile = readFileSync }) {
|
|
113
|
+
export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId, githubToken, forgeKind, forgeHosts = {}, hostEnv, allowGlobalExtensions = true, packagePaths = [], forwardEnv = [], sessionFile = null, flow = null, command = null, authFromPi = false, agentDir, readFile = readFileSync }) {
|
|
114
114
|
// The provider credential(s), by pi's expected variable name(s) -- from the worker env, or (when
|
|
115
115
|
// PI_AUTH_FROM_PI is set and the env has none) host-side from pi's auth.json. Throws (config) if
|
|
116
116
|
// neither source yields one, which the processor turns into a pre-spend refusal.
|
|
@@ -146,6 +146,22 @@ export function buildContainerEnv({ provider, model, maxTurns, maxTokens, jobId,
|
|
|
146
146
|
// an empty string, for PI_PACKAGES' reason: an empty value is a third state neither side reads the
|
|
147
147
|
// same way, and the one reading a container must not have to infer which was meant.
|
|
148
148
|
PI_SESSION_FILE: sessionFile || undefined,
|
|
149
|
+
// The trigger's run.flow, STRUCTURALLY (issue #189). The flow already reaches the container as
|
|
150
|
+
// prompt prose ("Use the X skill"), but pi never matches prose against loaded skill names, so a
|
|
151
|
+
// flow that resolves in no tier runs to a clean exit 0 -- the silent no-op this repo brands the
|
|
152
|
+
// worst outcome available. This variable is what lets the runner compare the name against what
|
|
153
|
+
// actually loaded. It rides env and NOT event.json because an execution knob is not a fact about
|
|
154
|
+
// the delivery (see prepare-github.mjs on replicas). Absent means "no flow to verify" (a bare
|
|
155
|
+
// run.task cron job), never an empty string, for PI_PACKAGES' reason.
|
|
156
|
+
PI_FLOW: flow || undefined,
|
|
157
|
+
// The trigger's run.command, STRUCTURALLY (issue #189) -- PI_FLOW's twin: the runner compares it
|
|
158
|
+
// against the commands that actually registered and refuses an unregistered one before any spend,
|
|
159
|
+
// where the prompt's bare `/name` would otherwise read as prose and run to a clean exit 0. It
|
|
160
|
+
// rides env and NOT event.json for the same reason PI_FLOW does. Absent means "not a command
|
|
161
|
+
// job", never an empty string, for PI_PACKAGES' reason. PI_FLOW and PI_COMMAND are mutually
|
|
162
|
+
// exclusive by parse (command XOR flow); that is deliberately NOT re-enforced here -- a second
|
|
163
|
+
// validator is a second place to disagree with the first.
|
|
164
|
+
PI_COMMAND: command || undefined,
|
|
149
165
|
// Kill switch for job-time package installation, UNCONDITIONAL for every job. pi's resolver shells out
|
|
150
166
|
// to a REAL `npm install` for any npm:/git: source unless offline mode is on, and `~/.pi/agent` IS
|
|
151
167
|
// writable in the container. We emit only local paths, so nothing should reach that branch -- this
|
package/src/flow-gate.mjs
CHANGED
|
@@ -64,7 +64,9 @@ export async function readFlowGate({ folder, flow, sha, git = defaultGit }) {
|
|
|
64
64
|
// Custom: no YAML parser in worker deps; a single ai-trigger boolean does not justify js-yaml.
|
|
65
65
|
// Strict leading-frontmatter scan: strip a BOM, normalise CRLF, isolate the leading `---`..`---`
|
|
66
66
|
// block, and require an exact `ai-trigger: allow` line (an optional surrounding double quote).
|
|
67
|
-
|
|
67
|
+
// Exported (issue #54) so the admin's skill enumeration answers "chainable?" with THIS scanner and
|
|
68
|
+
// not a keep-in-sync copy -- the SKILL_NAME_RE lesson one export up, applied to the frontmatter test.
|
|
69
|
+
export function aiTriggerAllows(buf) {
|
|
68
70
|
const text = buf.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
69
71
|
const block = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(text);
|
|
70
72
|
if (!block) return false; // no parseable frontmatter -> deny
|
package/src/github-app-setup.mjs
CHANGED
|
@@ -32,13 +32,13 @@
|
|
|
32
32
|
* Everything side-effecting is injected (fetch, the listener, the browser opener, prompt, fs, clock),
|
|
33
33
|
* defaulting to the real thing — the up.mjs convention — so the whole flow is testable offline.
|
|
34
34
|
*/
|
|
35
|
-
import { spawn as nodeSpawn } from "node:child_process";
|
|
36
35
|
import { createSign } from "node:crypto";
|
|
37
36
|
import { chmodSync, existsSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
38
37
|
import { hostname } from "node:os";
|
|
39
38
|
import { join } from "node:path";
|
|
40
39
|
import { parseArgs } from "node:util";
|
|
41
40
|
import { updateEnvFile } from "./env-file.mjs";
|
|
41
|
+
import { openBrowser as defaultOpenBrowser } from "./open-browser.mjs";
|
|
42
42
|
import { defaultPrompt } from "./up.mjs";
|
|
43
43
|
|
|
44
44
|
const API_ROOT = "https://api.github.com";
|
|
@@ -503,20 +503,6 @@ async function defaultListen(pageFor) {
|
|
|
503
503
|
};
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
* (on the right machine, or through the port-forward the wizard suggests) is the real contract; the
|
|
510
|
-
* spawn is only a convenience on top. Failures are swallowed for the same reason.
|
|
511
|
-
*/
|
|
512
|
-
function defaultOpenBrowser(url) {
|
|
513
|
-
const [cmd, args] =
|
|
514
|
-
process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
515
|
-
try {
|
|
516
|
-
const child = nodeSpawn(cmd, args, { stdio: "ignore", detached: true });
|
|
517
|
-
child.on("error", () => {});
|
|
518
|
-
child.unref();
|
|
519
|
-
} catch {
|
|
520
|
-
// No opener on this host — the printed URL carries the flow.
|
|
521
|
-
}
|
|
522
|
-
}
|
|
506
|
+
// The best-effort platform opener moved to its own module (issue #54) so the admin's graph export
|
|
507
|
+
// shares this one reviewed argv table instead of hand-copying it; the print-the-URL-first doctrine
|
|
508
|
+
// lives in its docstring and is unchanged here.
|
package/src/image-preflight.mjs
CHANGED
|
@@ -33,6 +33,7 @@ export function resolveJobImage(job, defaultImage) {
|
|
|
33
33
|
* { unavailable: image} -- docker itself did not answer => INFRA, retry
|
|
34
34
|
* { forgeUnsupported } -- present, but declares it cannot serve this job's forge => POLICY, refuse
|
|
35
35
|
* { replicaUnsupported }-- present, but does not declare replica support for a replica job => POLICY
|
|
36
|
+
* { commandUnsupported }-- present, but does not declare command support for a command job => POLICY
|
|
36
37
|
*
|
|
37
38
|
* A non-zero `docker image inspect` is AMBIGUOUS -- an absent image and an unreachable daemon both exit 1 --
|
|
38
39
|
* so the failure path disambiguates POSITIVELY with `docker info` rather than by matching docker's stderr.
|
|
@@ -87,6 +88,14 @@ export function makeImagePreflight({ image, spawnFn = spawn }) {
|
|
|
87
88
|
if (job?.replica !== undefined && !(capabilities ?? []).includes("replicas")) {
|
|
88
89
|
return { replicaUnsupported: wanted, declared: capabilities ?? [] };
|
|
89
90
|
}
|
|
91
|
+
// Same inclusion-list polarity as `replicas` directly above, and the same class of stale-image
|
|
92
|
+
// failure it guards (issue #189): a runner that predates run.command reads no PI_COMMAND, so
|
|
93
|
+
// the bare `/name args` prompt reaches the model as PROSE -- no handler runs, the agent
|
|
94
|
+
// improvises, and the queue records a clean exit 0. Unreachable for a commandless job, so the
|
|
95
|
+
// existing fleet pays nothing for it.
|
|
96
|
+
if (job?.command !== undefined && !(capabilities ?? []).includes("commands")) {
|
|
97
|
+
return { commandUnsupported: wanted, declared: capabilities ?? [] };
|
|
98
|
+
}
|
|
90
99
|
return { ok: true, image: wanted, piVersion };
|
|
91
100
|
}
|
|
92
101
|
if ((await runDocker(spawnFn, ["info"])).code === 0) return { missing: wanted };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Best-effort platform browser opener. ALWAYS paired with the URL printed to the terminal -- a
|
|
5
|
+
* headless or SSH'd operator has no opener that works, and the printed URL pasted into any browser
|
|
6
|
+
* (on the right machine, or through the port-forward the caller suggests) is the real contract; the
|
|
7
|
+
* spawn is only a convenience on top. Failures are swallowed for the same reason.
|
|
8
|
+
*
|
|
9
|
+
* One module on purpose (issue #54): the GitHub App wizard and the admin's graph export both open a
|
|
10
|
+
* browser, and two hand-copies of platform-opener argv is exactly the drift class the repo's mirror
|
|
11
|
+
* tests exist to prevent. `spawn` and `platform` are injectable so the argv table is testable
|
|
12
|
+
* without launching anything.
|
|
13
|
+
*/
|
|
14
|
+
export function openBrowser(url, { spawn = nodeSpawn, platform = process.platform } = {}) {
|
|
15
|
+
const [cmd, args] =
|
|
16
|
+
platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
|
|
17
|
+
try {
|
|
18
|
+
const child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
19
|
+
child.on("error", () => {});
|
|
20
|
+
child.unref();
|
|
21
|
+
} catch {
|
|
22
|
+
// No opener on this host -- the printed URL carries the flow.
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/outbox.mjs
CHANGED
|
@@ -119,6 +119,20 @@ export function makeCollectChain({ queue, enqueue = enqueueLocalJob, readFlowGat
|
|
|
119
119
|
continue;
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
// Commands are NEVER AI-reachable (issue #189): a request naming one refuses outright,
|
|
123
|
+
// with no opt-in to widen. The flow gate below reads a COMMITTED artifact -- the target
|
|
124
|
+
// repo's SKILL.md frontmatter at the pinned sha, merge-gated and reviewable -- but a
|
|
125
|
+
// command is an operator-staged pi extension with no committed artifact a gate could
|
|
126
|
+
// read, so there is nothing to gate ON and fail-closed is the only honest answer. First
|
|
127
|
+
// in the semantic ladder, before the charset check: which field the request used is
|
|
128
|
+
// decided before any opinion about its spelling. A completed command job's OWN /outbox
|
|
129
|
+
// may still chain INTO flows through the unchanged gate below; nothing chains into a
|
|
130
|
+
// command.
|
|
131
|
+
if (req.command !== undefined) {
|
|
132
|
+
refuse("chain-command-refused", i);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
122
136
|
// Explicit property reads ONLY -- `req` is never spread into job data.
|
|
123
137
|
const flow = req.flow;
|
|
124
138
|
const task = req.task;
|
package/src/packages.mjs
CHANGED
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
* manifest must degrade to "no staged packages", not crash the worker mid-queue.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
28
|
-
import { join } from "node:path";
|
|
27
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
28
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
29
29
|
import { configError } from "./config.mjs";
|
|
30
30
|
// ENTRY_NAME_RE / ADMIN_RE are import-pi's -- imported rather than re-declared so the staged dir charset
|
|
31
31
|
// and the admin block cannot drift between the stager and this validator (doctor.mjs sets the precedent
|
|
@@ -269,6 +269,89 @@ export function readStageManifest({ globalPiDir, readFile = readFileSync, fileEx
|
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Enumerate the skills the staged packages would contribute to a job (issue #189). Returns
|
|
274
|
+
* `{ skills: [{ name, package, dir }], unenumerable: [packageName...] }`, and NEVER throws --
|
|
275
|
+
* readStageManifest's policy, because the consumers are advisory (doctor's per-trigger flow lines,
|
|
276
|
+
* and issue #188's topology) and a half-staged tree must degrade to "nothing visible", not a crash.
|
|
277
|
+
*
|
|
278
|
+
* The semantics mirror pi's collectPackageResources at the 0.80.7 pin EXACTLY, because an enumerator
|
|
279
|
+
* that agrees with pi by hand is how doctor comes to report a tier pi then ignores:
|
|
280
|
+
* - a `pi` manifest object means its `skills` entries are the ONLY sources -- a manifest WITHOUT a
|
|
281
|
+
* `skills` key contributes NO skills and gets NO convention fallback (readPiManifest short-circuits
|
|
282
|
+
* before the dir walk);
|
|
283
|
+
* - no `pi` key at all falls through to the convention `skills/` dir (RESOURCE_DIRS);
|
|
284
|
+
* - a manifest entry may be a SKILL.md file, a skill dir, or a dir of skill dirs -- pi walks
|
|
285
|
+
* recursively, so this walks the same shape (bounded, it is host-advisory);
|
|
286
|
+
* - a glob (`*`/`?`) or override (`!`/`+`/`-` prefix) entry makes the whole package UNENUMERABLE
|
|
287
|
+
* rather than guessed at: patterns can also DISABLE files, and a wrong ✓ (a skill reported that pi
|
|
288
|
+
* filters out) is the direction an advisory line must never err in. Reported, not silently skipped.
|
|
289
|
+
*
|
|
290
|
+
* Names are DIR basenames (or the SKILL.md's parent dir), which is pi's fallback naming rule; a
|
|
291
|
+
* frontmatter `name:` rename is invisible here. That approximation is deliberate and one-directional:
|
|
292
|
+
* it can only turn a would-be ✓ into a ⚠, and the runner's flow_not_loaded check compares against the
|
|
293
|
+
* names pi actually loaded (DES-FLOW-RESOLUTION-TWO-ADVISORY-LAYERS).
|
|
294
|
+
*/
|
|
295
|
+
export function readStagedSkills({ globalPiDir, readFile = readFileSync, fileExists = existsSync, readDir = readdirSync } = {}) {
|
|
296
|
+
const out = { skills: [], unenumerable: [] };
|
|
297
|
+
const manifest = readStageManifest({ globalPiDir, readFile, fileExists });
|
|
298
|
+
if (!manifest) return out;
|
|
299
|
+
|
|
300
|
+
for (const pkg of manifest.packages) {
|
|
301
|
+
const root = join(globalPiDir, PACKAGES_SUBDIR, pkg.dir);
|
|
302
|
+
let sources;
|
|
303
|
+
try {
|
|
304
|
+
const parsed = JSON.parse(readFile(join(root, "package.json"), "utf8"));
|
|
305
|
+
const pi = parsed !== null && typeof parsed === "object" && parsed.pi !== null && typeof parsed.pi === "object" ? parsed.pi : null;
|
|
306
|
+
if (pi) {
|
|
307
|
+
const entries = Array.isArray(pi.skills) ? pi.skills.filter((e) => typeof e === "string") : [];
|
|
308
|
+
if (entries.some((e) => e.startsWith("!") || e.startsWith("+") || e.startsWith("-") || e.includes("*") || e.includes("?"))) {
|
|
309
|
+
out.unenumerable.push(pkg.name);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
// `..`-carrying entries are dropped rather than resolved: the stager never writes one, and
|
|
313
|
+
// following one would make an advisory reader walk outside the staged tree. A segment
|
|
314
|
+
// test, not a prefix test, so it holds on Windows separators too (parsePackagePaths' rule).
|
|
315
|
+
sources = entries.filter((e) => !e.split(/[\\/]/).includes("..")).map((e) => resolve(root, e));
|
|
316
|
+
} else {
|
|
317
|
+
sources = [join(root, "skills")];
|
|
318
|
+
}
|
|
319
|
+
} catch {
|
|
320
|
+
continue; // no readable package.json: pi would skip it too
|
|
321
|
+
}
|
|
322
|
+
for (const source of sources) {
|
|
323
|
+
for (const skillFile of walkSkillFiles(source, fileExists, readDir)) {
|
|
324
|
+
out.skills.push({ name: basename(dirname(skillFile)), package: pkg.name, dir: pkg.dir });
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* The SKILL.md files under one manifest source, the shapes pi's collectFilesFromPaths accepts: the file
|
|
333
|
+
* itself, a dir holding SKILL.md, or a tree of skill dirs (walked to a small fixed depth -- pi recurses
|
|
334
|
+
* unbounded, but a host-advisory reader stops where real layouts stop). Never throws.
|
|
335
|
+
*/
|
|
336
|
+
function walkSkillFiles(source, fileExists, readDir, depth = 3) {
|
|
337
|
+
if (basename(source) === "SKILL.md") return fileExists(source) ? [source] : [];
|
|
338
|
+
const found = [];
|
|
339
|
+
const own = join(source, "SKILL.md");
|
|
340
|
+
if (fileExists(own)) found.push(own);
|
|
341
|
+
if (depth === 0) return found;
|
|
342
|
+
let children;
|
|
343
|
+
try {
|
|
344
|
+
children = readDir(source, { withFileTypes: true });
|
|
345
|
+
} catch {
|
|
346
|
+
return found; // absent or unreadable: nothing visible here
|
|
347
|
+
}
|
|
348
|
+
for (const child of children) {
|
|
349
|
+
if (!child.isDirectory?.()) continue;
|
|
350
|
+
found.push(...walkSkillFiles(join(source, child.name), fileExists, readDir, depth - 1));
|
|
351
|
+
}
|
|
352
|
+
return found;
|
|
353
|
+
}
|
|
354
|
+
|
|
272
355
|
/**
|
|
273
356
|
* The CONTAINER paths of the staged packages, in manifest order -- what gets handed to pi as local package
|
|
274
357
|
* specs. Built with template literals and never `path.join`: the worker may run on Windows, where `join`
|
package/src/prepare-github.mjs
CHANGED
|
@@ -190,6 +190,15 @@ export async function prepareGithubWorkspace(
|
|
|
190
190
|
const session = job.resume === true ? resolveSession(job, { jobDir, resolved, piVersion }) : null;
|
|
191
191
|
|
|
192
192
|
// Issue text is DATA: it enters the USER prompt (buildGithubPrompt), never a system prompt.
|
|
193
|
+
//
|
|
194
|
+
// A command job (issue #189) skips the per-forge envelope entirely: the prompt is the slash
|
|
195
|
+
// invocation `/${job.command}` and nothing else -- no data heading, no quoted issue text, and NO
|
|
196
|
+
// trailing newline, because pi hands everything after the first space to the handler as its
|
|
197
|
+
// argument string verbatim. CONST-ISSUE-TEXT-IS-DATA is preserved and arguably STRENGTHENED:
|
|
198
|
+
// payload text reaches a command job only as event.json below, a file the handler chooses to
|
|
199
|
+
// parse, never interpolated into prompt prose at all. The envelope's never-merge discipline is
|
|
200
|
+
// not lost either -- that discipline addresses MODEL prose, and a command handler is
|
|
201
|
+
// operator-staged code, the same trust tier extensions hold generally.
|
|
193
202
|
writeFile(
|
|
194
203
|
join(jobDir, "prompt.md"),
|
|
195
204
|
// `replica`/`replicas` (REQ-REPLICA-RUNS) are host-assigned integers off job.data, so they are safe
|
|
@@ -198,7 +207,9 @@ export async function prepareGithubWorkspace(
|
|
|
198
207
|
// them away -- harmless, and always undefined while replicas are github-only.
|
|
199
208
|
// `review` rides beside `comment` and, like `replica`/`replicas`, is destructured away by the
|
|
200
209
|
// gitlab/forgejo/azure builders -- harmless, and always undefined while reviews are github-only.
|
|
201
|
-
|
|
210
|
+
job.command
|
|
211
|
+
? `/${job.command}`
|
|
212
|
+
: buildPrompt({ flow: job.flow, target: job.target, comment: job.trigger?.comment, resumed: session?.resume === true, replica: job.replica, replicas: job.replicas, review: job.trigger?.review, instructions: job.instructions }),
|
|
202
213
|
{ mode: 0o444 },
|
|
203
214
|
);
|
|
204
215
|
|
package/src/prepare.mjs
CHANGED
|
@@ -93,10 +93,19 @@ export function makePrepareWorkspace({
|
|
|
93
93
|
// flow can discover the trigger context (mirroring the github prompt, which names the same
|
|
94
94
|
// file); nothing in-container reads it otherwise. The pointer sits AFTER the flow hint and
|
|
95
95
|
// BEFORE the operator's task, which stays verbatim (CONST-ISSUE-TEXT-IS-DATA).
|
|
96
|
+
//
|
|
97
|
+
// A command job (issue #189) bypasses ALL of that: the prompt is the slash invocation itself,
|
|
98
|
+
// `/name args`, and nothing else -- no pointer line, no task, and critically NO trailing
|
|
99
|
+
// newline, because pi hands everything after the first space to the handler as its argument
|
|
100
|
+
// string verbatim, so a newline appended here would land inside the args. The pointer is
|
|
101
|
+
// prose addressed to a MODEL reading instructions; a command handler is code, and its context
|
|
102
|
+
// channel is /job/event.json itself, which prepare-local already writes for every local job.
|
|
96
103
|
const pointer = "Context about this run -- its trigger and schedule -- is in /job/event.json.\n\n";
|
|
97
|
-
const task = job.
|
|
98
|
-
?
|
|
99
|
-
:
|
|
104
|
+
const task = job.command
|
|
105
|
+
? `/${job.command}`
|
|
106
|
+
: job.flow
|
|
107
|
+
? `Use the "${job.flow}" skill for this task.\n\n${pointer}${job.task ?? ""}`
|
|
108
|
+
: `${pointer}${job.task ?? ""}`;
|
|
100
109
|
const event = localEventContext(job, queueJobId, findPreviousRun);
|
|
101
110
|
return discardOnPolicy(stampSandbox(await prepareLocal({ folder: job.folder, task, jobDir, event }), sandbox), jobDir);
|
|
102
111
|
}
|
package/src/processor.mjs
CHANGED
|
@@ -159,6 +159,23 @@ export async function runJob(job, deps) {
|
|
|
159
159
|
log("refused_image_replicas_unsupported", { image: img.replicaUnsupported, declared: img.declared });
|
|
160
160
|
return { outcome: "policy", reason: "job-image-replicas-unsupported", exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried
|
|
161
161
|
}
|
|
162
|
+
if (img.commandUnsupported) {
|
|
163
|
+
// The image is present and does not declare command support (issue #189), so its runner
|
|
164
|
+
// predates run.command: it reads no PI_COMMAND, and the bare `/name args` prompt reaches the
|
|
165
|
+
// model as PROSE -- no handler runs, the agent improvises, and the queue records a clean exit
|
|
166
|
+
// 0. The in-container half of the gate (the runner's own command-unregistered refusal) does
|
|
167
|
+
// not exist on such an image, which is exactly why the host must refuse first.
|
|
168
|
+
//
|
|
169
|
+
// Determinate, so a refusal rather than a retry, and pre-spend, because no version of this
|
|
170
|
+
// gets better by running. Like the replica branch above, the message names the FIX rather
|
|
171
|
+
// than the label that noticed it.
|
|
172
|
+
await comment(
|
|
173
|
+
job,
|
|
174
|
+
`Refused: the job image "${img.commandUnsupported}" does not declare command support (\`dev.pi-dispatch.capabilities\` ${img.declared.length > 0 ? `declares: ${img.declared.join(", ")}` : "is absent"}), so its runner would not dispatch \`run.command\`. Rebuild the image from a version that has this feature. Not run.`,
|
|
175
|
+
);
|
|
176
|
+
log("refused_image_commands_unsupported", { image: img.commandUnsupported, declared: img.declared });
|
|
177
|
+
return { outcome: "policy", reason: "job-image-commands-unsupported", exitCode: null, turns: null, tokens: null, provider: job.provider ?? null, model: job.model ?? null, budgetReserved: false }; // return => not retried
|
|
178
|
+
}
|
|
162
179
|
if (img.unavailable) {
|
|
163
180
|
// docker itself did not answer -- transient infra, NOT a determinate refusal. THROWN so BullMQ
|
|
164
181
|
// retries (CONST-RETRY-INFRA-ONLY). `container-never-started` is literally true here, and it reuses
|
package/src/queue.mjs
CHANGED
|
@@ -16,11 +16,16 @@ export function makeQueue(connection) {
|
|
|
16
16
|
* removeOnComplete keeps the dedup window ~= the retention. Unlike webhooks, local jobs are not
|
|
17
17
|
* redelivered, so a modest window is enough.
|
|
18
18
|
*/
|
|
19
|
-
export async function enqueueLocalJob(queue, { folder, flow, task, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = new Date() }) {
|
|
19
|
+
export async function enqueueLocalJob(queue, { folder, flow, task, command, provider, model, maxTurns, image, skillsDir, chainDepth, parentJobId, jobId, now = new Date() }) {
|
|
20
20
|
const minute = now.toISOString().slice(0, 16); // YYYY-MM-DDTHH:MM -- the dedup window
|
|
21
21
|
// A caller-supplied jobId (the outbox collector's retry-idempotent chainedJobId) wins; otherwise the
|
|
22
|
-
// minute-windowed localJobId is the dedup key.
|
|
23
|
-
|
|
22
|
+
// minute-windowed localJobId is the dedup key. A command job (issue #189) fills the flow slot with
|
|
23
|
+
// `cmd:<command>` rather than leaving it empty: a command trigger carries no flow/task, so without it
|
|
24
|
+
// two DIFFERENT commands on one folder in one minute would hash identically and the second would
|
|
25
|
+
// vanish silently -- and the `cmd:` prefix keeps a command named X from colliding with a flow named X
|
|
26
|
+
// (`:` is outside the skill-name charset, so no real flow can spell the prefixed form). A flow job's
|
|
27
|
+
// key is byte-identical to before the feature.
|
|
28
|
+
const id = jobId ?? localJobId({ folder, flow: command !== undefined ? `cmd:${command}` : flow, task, minute });
|
|
24
29
|
// image/chainDepth/parentJobId land on `data` only when present, so a plain non-chained job's data is
|
|
25
30
|
// byte-identical. `image` is the container image this job runs in (INT-TRIGGERS-FILE-CONTRACT); absent
|
|
26
31
|
// resolves the deployment default at job start, never a value frozen here.
|
|
@@ -29,6 +34,10 @@ export async function enqueueLocalJob(queue, { folder, flow, task, provider, mod
|
|
|
29
34
|
folder,
|
|
30
35
|
flow,
|
|
31
36
|
task,
|
|
37
|
+
// The registered pi command this job dispatches instead of a flow (issue #189). Conditional like
|
|
38
|
+
// `image`, so a flow job's data keeps exactly the keys it has today; the parse-level XOR means a
|
|
39
|
+
// job carrying it has flow/task undefined, which JSON serialization drops.
|
|
40
|
+
...(command !== undefined && { command }),
|
|
32
41
|
provider,
|
|
33
42
|
model,
|
|
34
43
|
maxTurns,
|
|
@@ -115,7 +124,7 @@ export async function enqueueGitLabJob(queue, fields) {
|
|
|
115
124
|
* window, replicas never coalesce against each other, and an unflagged job's dedup id is the same string it
|
|
116
125
|
* has always been.
|
|
117
126
|
*/
|
|
118
|
-
export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, trigger, provider, model, maxTurns, packages, image, skillsDir, instructions, resume, replica, replicas }) {
|
|
127
|
+
export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, target, flow, command, trigger, provider, model, maxTurns, packages, image, skillsDir, instructions, resume, replica, replicas }) {
|
|
119
128
|
const jobId = forgeDeliveryJobId(kind, trigger?.deliveryId, replica);
|
|
120
129
|
// `packages` (whether to load the operator-staged pi packages) and `image` (which container image to run)
|
|
121
130
|
// come off the MATCHED trigger (INT-TRIGGERS-FILE-CONTRACT / REQ-GLOBAL-PI-OVERLAY) and land on `data`
|
|
@@ -132,6 +141,11 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
|
|
|
132
141
|
...(azure !== undefined && { azure }),
|
|
133
142
|
target,
|
|
134
143
|
flow,
|
|
144
|
+
// The registered pi command this trigger dispatches instead of a flow (issue #189). Conditional
|
|
145
|
+
// like `packages`/`image` below, so an unflagged trigger's job data is byte-identical -- and at
|
|
146
|
+
// JOB level, never inside `trigger`, for their reason too: an execution knob is not a fact about
|
|
147
|
+
// the delivery, and `trigger` is copied verbatim into /job/event.json.
|
|
148
|
+
...(command !== undefined && { command }),
|
|
135
149
|
trigger,
|
|
136
150
|
provider,
|
|
137
151
|
model,
|
|
@@ -156,7 +170,13 @@ export async function enqueueForgeJob(queue, kind, { repo, projectId, azure, tar
|
|
|
156
170
|
};
|
|
157
171
|
await queue.add(kind, data, {
|
|
158
172
|
jobId,
|
|
159
|
-
|
|
173
|
+
// A command job (issue #189) fills the semantic key's flow slot with `cmd:<command>`: a command
|
|
174
|
+
// trigger carries no flow, so the slot would otherwise read `undefined` for every command and one
|
|
175
|
+
// command's 10-minute window would swallow a different command's delivery on the same target. The
|
|
176
|
+
// `cmd:` prefix keeps a command named X from coalescing against a flow named X -- `:` is outside
|
|
177
|
+
// the skill-name charset, so no real flow can spell the prefixed form -- and a flow job's key
|
|
178
|
+
// stays byte-identical to before the feature.
|
|
179
|
+
deduplication: { id: `${repo}${targetSeparator(kind, target?.type)}${target.number}:${command !== undefined ? `cmd:${command}` : flow}${replica !== undefined ? `:r${replica}` : ""}`, ttl: SEMANTIC_WINDOW_MS }, // ttl in ms
|
|
160
180
|
attempts: 2,
|
|
161
181
|
backoff: { type: "exponential", delay: 60_000 },
|
|
162
182
|
removeOnComplete: { age: 31 * 24 * 3600 }, // age in seconds -- do not cross units with the ms ttl above
|
package/src/run-container.mjs
CHANGED
|
@@ -74,6 +74,13 @@ export function makeRunContainer({
|
|
|
74
74
|
// The constant is imported rather than re-typed so the mount below and this variable name one
|
|
75
75
|
// path -- two literals is how they drift with both suites green.
|
|
76
76
|
sessionFile: prepared.session ? CONTAINER_SESSION_FILE : undefined,
|
|
77
|
+
// Issue #189: the flow name, structurally, so the runner can verify it against the loaded
|
|
78
|
+
// skill set. Off `job` like maxTurns; absent (a bare run.task cron job) emits no variable.
|
|
79
|
+
flow: typeof job.flow === "string" && job.flow.trim() !== "" ? job.flow : undefined,
|
|
80
|
+
// Issue #189: the command name, structurally, so the runner can refuse an unregistered one
|
|
81
|
+
// before any spend (command-unregistered). Same guard shape as `flow` directly above, and
|
|
82
|
+
// mutually exclusive with it by parse -- a job carries one or the other, never both.
|
|
83
|
+
command: typeof job.command === "string" && job.command.trim() !== "" ? job.command : undefined,
|
|
77
84
|
authFromPi, // source the provider key from pi's auth.json when the env has none
|
|
78
85
|
});
|
|
79
86
|
|
package/src/run-history.mjs
CHANGED
|
@@ -295,6 +295,19 @@ export function buildRecord({ job, result, error, startedAt, endedAt }) {
|
|
|
295
295
|
// deliberately not stored, for the reason `session` states one group below.
|
|
296
296
|
replica: data.replica ?? null,
|
|
297
297
|
replicas: data.replicas ?? null,
|
|
298
|
+
// Trigger attribution (INT-RUN-HISTORY-FILE-CONTRACT, issue #54): additive and nullable, explicit
|
|
299
|
+
// literals beside the replica fields whose admissibility argument they reuse, no spread. Both read
|
|
300
|
+
// the receiver's harness-computed `matched` from this job's own `job.data.trigger`: `index` is the
|
|
301
|
+
// raw triggers-array position of the entry that fired (cron entries counted) and `type` that entry's
|
|
302
|
+
// `on.type` -- an INTEGER and a FIXED ENUM ("label" | "comment" | "pull_request"), nothing
|
|
303
|
+
// attacker-chosen. The third `matched` key (`label`/`phrase`/`action`) is DELIBERATELY absent:
|
|
304
|
+
// a label that satisfied an `any` predicate is collaborator-applied payload text, and `type`
|
|
305
|
+
// already names the route. Cron jobs carry `trigger: { id, pattern }` with no `matched`, so both
|
|
306
|
+
// stay null there on purpose -- a cron run's attribution is already exact via its
|
|
307
|
+
// `repeat:<id>:<millis>` jobId (see makeFindPreviousRun), and that join also works retroactively
|
|
308
|
+
// over the whole retention window, which a new record field cannot.
|
|
309
|
+
triggerIndex: data.trigger?.matched?.index ?? null,
|
|
310
|
+
triggerType: data.trigger?.matched?.type ?? null,
|
|
298
311
|
// Session telemetry (INT-RUN-HISTORY-FILE-CONTRACT): additive, nullable, an explicit literal, no
|
|
299
312
|
// spread. `{ resumed, reason, bytes }` -- a boolean, a fixed enum and an integer. THE KEY AND THE
|
|
300
313
|
// BRANCH NAME ARE DELIBERATELY ABSENT: this record's PII-free-by-construction property rests on it
|
package/src/schedules.mjs
CHANGED
|
@@ -68,7 +68,13 @@ function normalizeCronSchedule({ on, run }, path, existsSync) {
|
|
|
68
68
|
// cron-only field: it is carried into the local `/job/event.json` (INT-CONTAINER-JOB-INPUTS) so a
|
|
69
69
|
// scheduled job can name its own trigger; the INT-TRIGGERS-FILE-CONTRACT byte-match acceptance is
|
|
70
70
|
// amended for exactly this field.
|
|
71
|
-
|
|
71
|
+
//
|
|
72
|
+
// `command` (issue #189) is conditional like `skillsDir`, not present-and-undefined like flow/task,
|
|
73
|
+
// and both spellings serve the same byte-identity: a flow trigger's stored repeatable must not grow a
|
|
74
|
+
// key. A command trigger carries no flow/task at all (the validator enforces the XOR), so those two
|
|
75
|
+
// keys hold undefined here and drop at JSON serialization -- the command schedule's data is exactly
|
|
76
|
+
// kind/folder/command plus the shared fields.
|
|
77
|
+
const data = { kind: "local", folder: run.folder, flow: run.flow, task: run.task, ...(run.command !== undefined && { command: run.command }), provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), resume: run.resume, trigger: { id: on.id, pattern: on.pattern } };
|
|
72
78
|
// Retention only; the deterministic repeat:<id>:<millis> jobId supplies dedup, so no jobId here, and
|
|
73
79
|
// scheduler jobs are not retried (DES-CRON-VIA-BULLMQ-SCHEDULER) so no attempts/backoff.
|
|
74
80
|
const opts = { removeOnComplete: { age: 24 * 3600 }, removeOnFail: { age: 7 * 24 * 3600 } };
|
package/src/triggers.mjs
CHANGED
|
@@ -191,13 +191,20 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
191
191
|
throw configError(`cron trigger "${id}": on.pattern must have 5 or 6 space-separated fields, got ${fieldCount}: ${path}`);
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
// FIRST among the run checks (all four normalizers do this), so a command-only entry is never told to
|
|
195
|
+
// add the flow it deliberately does not have, and a flow+command entry gets the exclusion message
|
|
196
|
+
// rather than whichever single-field check happens to run first.
|
|
197
|
+
const command = validateCommand(run, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
198
|
+
|
|
194
199
|
if (!isNonEmptyString(run.folder)) {
|
|
195
200
|
throw configError(`cron trigger "${id}": run.folder must be a non-empty string: ${path}`);
|
|
196
201
|
}
|
|
197
|
-
if (!isNonEmptyString(run.flow)) {
|
|
198
|
-
throw configError(`cron trigger "${id}": run.flow must be a non-empty string: ${path}`);
|
|
202
|
+
if (command === undefined && !isNonEmptyString(run.flow)) {
|
|
203
|
+
throw configError(`cron trigger "${id}": run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
199
204
|
}
|
|
200
|
-
|
|
205
|
+
// Gated on the flow path only: a command job's prompt IS the command line, and validateCommand has
|
|
206
|
+
// already refused any run.task written beside one.
|
|
207
|
+
if (command === undefined && !isNonEmptyString(run.task)) {
|
|
201
208
|
throw configError(`cron trigger "${id}": run.task must be a non-empty string: ${path}`);
|
|
202
209
|
}
|
|
203
210
|
|
|
@@ -229,7 +236,7 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
229
236
|
// freeze today's default into every stored repeatable.
|
|
230
237
|
return {
|
|
231
238
|
on: { type: "cron", id, pattern },
|
|
232
|
-
run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...(skillsDir !== undefined && { skillsDir }) },
|
|
239
|
+
run: { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages, image, resume, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }) },
|
|
233
240
|
};
|
|
234
241
|
}
|
|
235
242
|
|
|
@@ -450,6 +457,85 @@ function validateInstructions(run, at, path, { cron = false } = {}) {
|
|
|
450
457
|
return text;
|
|
451
458
|
}
|
|
452
459
|
|
|
460
|
+
/**
|
|
461
|
+
* `run.command` (issue #189): dispatch a REGISTERED pi extension command headlessly, instead of a flow.
|
|
462
|
+
* The runner half is already merged: it exports the name via PI_COMMAND, refuses pre-spend when
|
|
463
|
+
* `getCommand` does not know it, and prompts with the exact `/name args` line and nothing else, so the
|
|
464
|
+
* arguments reach the command handler precisely as written here. Shared by all four normalizers so both
|
|
465
|
+
* services refuse the same file identically; the runner re-validates at the paid boundary regardless.
|
|
466
|
+
*
|
|
467
|
+
* EXACTLY ONE of run.flow / run.command, and the exclusion is checked BEFORE every normalizer's
|
|
468
|
+
* flow-required check on purpose: the two mistakes need their own messages. A flow+command entry no
|
|
469
|
+
* longer says which one runs and must hear that, not whichever single-field complaint fires first; a
|
|
470
|
+
* command-only entry must never be told to add the flow it deliberately does not have.
|
|
471
|
+
*
|
|
472
|
+
* The value rules each refuse something specific:
|
|
473
|
+
* - no leading "/": the runner PREPENDS the slash when it builds the prompt, so a written one would
|
|
474
|
+
* dispatch "//name" -- a command no registry holds, refused only after review already passed it.
|
|
475
|
+
* - surrounding whitespace is REFUSED, never trimmed (validateImageRef's rule): the arguments pass to
|
|
476
|
+
* the handler verbatim, so a silent trim is the reviewed file disagreeing with what runs.
|
|
477
|
+
* - no control characters. A newline would smuggle a SECOND line into what the operator reviewed as
|
|
478
|
+
* one command line, and the whole class is refused rather than the newline alone because every
|
|
479
|
+
* member is invisible in review, which is the hazard. The regex is spelled in \u escapes for the
|
|
480
|
+
* same reason: a literal ESC in this source would be exactly the unreviewable byte it refuses.
|
|
481
|
+
*
|
|
482
|
+
* Cross-field refusals, validateReplicas' posture (a field accepted where it does nothing is one an
|
|
483
|
+
* operator sets and then trusts):
|
|
484
|
+
* - run.task on cron: a command job's prompt IS the `/name args` line, so there is no task text for
|
|
485
|
+
* the runner to render -- two prompts written for one job, with only one ever sent.
|
|
486
|
+
* - run.instructions on the webhook kinds: instructions land in the prompt ENVELOPE, which a command
|
|
487
|
+
* job bypasses entirely, so nothing would render them. (Cron refuses instructions already, with its
|
|
488
|
+
* own run.task message, and that refusal stays the one a cron entry gets.)
|
|
489
|
+
* - run.resume: true on any kind: what a resumed session should do with a re-dispatched command is
|
|
490
|
+
* UNDESIGNED -- "not yet covered", validateResumeFlag's own vocabulary, because it is a gap to
|
|
491
|
+
* close and not a limit. Only `true` is refused; `false` is the documented default and refusing it
|
|
492
|
+
* would refuse an operator for writing down the behaviour they already have.
|
|
493
|
+
*
|
|
494
|
+
* Everything else stays orthogonal on purpose -- replicas, image, packages, skillsDir, repository,
|
|
495
|
+
* github. Those gate the CONTAINER a job runs in, and a command job runs in the same container a flow
|
|
496
|
+
* job does.
|
|
497
|
+
*
|
|
498
|
+
* A COMMENT command trigger has no default flow, which leaves the receiver's `<phrase> <flow>` comment
|
|
499
|
+
* override with nothing to override. That token is made inert by the receiver's FILTER, not refused
|
|
500
|
+
* here: the override lives in adversarial comment text, which this file-shape validator never sees.
|
|
501
|
+
*
|
|
502
|
+
* `at` is the caller's message prefix, `onType` selects the cross-field set. Returns the command,
|
|
503
|
+
* undefined when absent, and the callers spread it conditionally: a flow trigger must not grow the key
|
|
504
|
+
* at all, so an unflagged file normalizes byte-identically to today's (deepEqual pins depend on it).
|
|
505
|
+
*/
|
|
506
|
+
function validateCommand(run, at, path, { onType }) {
|
|
507
|
+
const raw = run.command;
|
|
508
|
+
if (run.flow !== undefined && raw !== undefined) {
|
|
509
|
+
throw configError(`${at}: exactly one of run.flow or run.command must be set -- a trigger dispatches either a flow or a registered command, and with both present the file does not say which one runs: ${path}`);
|
|
510
|
+
}
|
|
511
|
+
if (raw === undefined) return undefined;
|
|
512
|
+
if (!isNonEmptyString(raw)) {
|
|
513
|
+
throw configError(`${at}: run.command must be a non-empty string -- the registered command name, optionally followed by its arguments: ${path}`);
|
|
514
|
+
}
|
|
515
|
+
if (raw !== raw.trim()) {
|
|
516
|
+
throw configError(`${at}: run.command must not have leading or trailing whitespace -- the arguments reach the command handler verbatim, so trimming here would make the reviewed file disagree with what runs (got ${JSON.stringify(raw)}): ${path}`);
|
|
517
|
+
}
|
|
518
|
+
if (raw.startsWith("/")) {
|
|
519
|
+
throw configError(`${at}: run.command must not start with "/" -- the runner prepends the slash when it builds the /name args prompt, so a written one would dispatch "//name" (got ${JSON.stringify(raw)}): ${path}`);
|
|
520
|
+
}
|
|
521
|
+
// The class is the RUNNER's (parseCommand, image/runner/src/config.mjs), DEL included: the two
|
|
522
|
+
// must refuse identically, or a value that loads here refuses in-container with the budget
|
|
523
|
+
// slot already burned -- the drift INT-TRIGGERS-FILE-CONTRACT promises cannot happen.
|
|
524
|
+
if (/[\u0000-\u001F\u007F]/.test(raw)) {
|
|
525
|
+
throw configError(`${at}: run.command must not contain control characters -- a newline would smuggle a second line into what the operator reviewed as one command line (got ${JSON.stringify(raw)}): ${path}`);
|
|
526
|
+
}
|
|
527
|
+
if (onType === "cron" && run.task !== undefined) {
|
|
528
|
+
throw configError(`${at}: run.command and run.task cannot be combined -- a command job's prompt IS the /name args line, so there is no task text for the runner to render; put the arguments in run.command: ${path}`);
|
|
529
|
+
}
|
|
530
|
+
if (onType !== "cron" && run.instructions !== undefined) {
|
|
531
|
+
throw configError(`${at}: run.command and run.instructions cannot be combined -- instructions render into the prompt envelope, and a command job's prompt is the exact /name args line with no envelope, so nothing would render them: ${path}`);
|
|
532
|
+
}
|
|
533
|
+
if (run.resume === true) {
|
|
534
|
+
throw configError(`${at}: combining run.command and run.resume is not yet covered -- what a resumed session should do with a re-dispatched command is undesigned, so this is a gap to close, not a limit: ${path}`);
|
|
535
|
+
}
|
|
536
|
+
return raw;
|
|
537
|
+
}
|
|
538
|
+
|
|
453
539
|
/**
|
|
454
540
|
* Validate an `{any, all, none}` label predicate. Selectors are validated as arrays of non-empty strings
|
|
455
541
|
* BEFORE the positive-selector count, because `.length` is truthy on a string too -- a string selector
|
|
@@ -553,8 +639,10 @@ function validateReplicas(run, at, path) {
|
|
|
553
639
|
function normalizeLabel(on, run, index, path) {
|
|
554
640
|
const at = `trigger at index ${index}`;
|
|
555
641
|
const predicate = validatePredicate(on, index, path, true);
|
|
556
|
-
|
|
557
|
-
|
|
642
|
+
// First among the run checks, before the flow-required check -- validateCommand says why.
|
|
643
|
+
const command = validateCommand(run, at, path, { onType: "label" });
|
|
644
|
+
if (command === undefined && !isNonEmptyString(run.flow)) {
|
|
645
|
+
throw configError(`${at}: label trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
558
646
|
}
|
|
559
647
|
const packages = validatePackagesFlag(run, at, path);
|
|
560
648
|
const image = validateImageRef(run, at, path);
|
|
@@ -565,7 +653,7 @@ function normalizeLabel(on, run, index, path) {
|
|
|
565
653
|
const replicas = validateReplicas(run, at, path);
|
|
566
654
|
return {
|
|
567
655
|
on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
|
|
568
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
|
|
656
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
|
|
569
657
|
};
|
|
570
658
|
}
|
|
571
659
|
|
|
@@ -574,8 +662,12 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
574
662
|
if (!isNonEmptyString(on.phrase)) {
|
|
575
663
|
throw configError(`${at}: comment trigger on.phrase must be a non-empty string: ${path}`);
|
|
576
664
|
}
|
|
577
|
-
|
|
578
|
-
|
|
665
|
+
// First among the run checks, before the flow-required check -- validateCommand says why. A command
|
|
666
|
+
// trigger has NO default flow for the `<phrase> <flow>` comment override to replace; making that
|
|
667
|
+
// token inert is the receiver filter's job, not a shape this validator can see.
|
|
668
|
+
const command = validateCommand(run, at, path, { onType: "comment" });
|
|
669
|
+
if (command === undefined && !isNonEmptyString(run.flow)) {
|
|
670
|
+
throw configError(`${at}: comment trigger run.flow (the default flow) must be a non-empty string (or use run.command): ${path}`);
|
|
579
671
|
}
|
|
580
672
|
// At most one comment trigger PER FORGE. The cap exists because the receiver holds one comment rule
|
|
581
673
|
// per forge and a second would be silently unreachable -- so it is a cap on ambiguity, not on count,
|
|
@@ -593,7 +685,7 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
593
685
|
const replicas = validateReplicas(run, at, path);
|
|
594
686
|
return {
|
|
595
687
|
on: { type: "comment", phrase: on.phrase },
|
|
596
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
|
|
688
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }), ...(repository !== undefined && { repository }) },
|
|
597
689
|
};
|
|
598
690
|
}
|
|
599
691
|
|
|
@@ -634,8 +726,10 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
634
726
|
throw configError(`${at}: an azure pull_request trigger cannot carry a label predicate -- Azure DevOps attaches tags to work items, never to pull requests, so any/all/none could never match: ${path}`);
|
|
635
727
|
}
|
|
636
728
|
const predicate = validatePredicate(on, index, path, requirePositive);
|
|
637
|
-
|
|
638
|
-
|
|
729
|
+
// First among the run checks, before the flow-required check -- validateCommand says why.
|
|
730
|
+
const command = validateCommand(run, at, path, { onType: "pull_request" });
|
|
731
|
+
if (command === undefined && !isNonEmptyString(run.flow)) {
|
|
732
|
+
throw configError(`${at}: pull_request trigger run.flow must be a non-empty string (or use run.command): ${path}`);
|
|
639
733
|
}
|
|
640
734
|
const packages = validatePackagesFlag(run, at, path);
|
|
641
735
|
const image = validateImageRef(run, at, path);
|
|
@@ -655,7 +749,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
655
749
|
all: predicate.all,
|
|
656
750
|
none: predicate.none,
|
|
657
751
|
},
|
|
658
|
-
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }) },
|
|
752
|
+
run: { kind: run.kind, flow: run.flow, packages, image, resume, replicas, ...(command !== undefined && { command }), ...(skillsDir !== undefined && { skillsDir }), ...(instructions !== undefined && { instructions }) },
|
|
659
753
|
};
|
|
660
754
|
}
|
|
661
755
|
|