@edgehero/pi-dispatch 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +9 -0
- package/package.json +7 -2
- package/src/backend-conformance.mjs +262 -0
- package/src/backend-local.mjs +222 -0
- package/src/backend-registry.mjs +157 -0
- package/src/backends.mjs +595 -0
- package/src/config.mjs +63 -1
- package/src/container-spec.mjs +226 -0
- package/src/docker-run.mjs +100 -106
- package/src/doctor.mjs +118 -0
- package/src/index.mjs +123 -10
- package/src/outbox.mjs +8 -0
- package/src/packages.mjs +7 -4
- package/src/processor.mjs +48 -11
- package/src/queue.mjs +14 -2
- package/src/sandbox-cli.mjs +36 -0
- package/src/schedules.mjs +1 -1
- package/src/start.mjs +118 -80
- package/src/triggers.mjs +120 -5
package/src/start.mjs
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
1
|
import { readFileSync, watch } from "node:fs";
|
|
3
2
|
import { dirname, basename, join } from "node:path";
|
|
4
|
-
import { promisify } from "node:util";
|
|
5
3
|
import { configError, loadConfig } from "./config.mjs";
|
|
6
4
|
import { makeRedisClient, parseConnection } from "./connection.mjs";
|
|
7
5
|
import { reconcileGated, reloadSchedules } from "./cron.mjs";
|
|
@@ -32,6 +30,10 @@ import { loadScopedLimits, scopeKeyPrefix } from "./scoped-limits.mjs";
|
|
|
32
30
|
import { makeWaitChecker } from "./wait-check.mjs";
|
|
33
31
|
import { makeWaitState } from "./wait-state.mjs";
|
|
34
32
|
import { hostQueueName, makeQueue } from "./queue.mjs";
|
|
33
|
+
import { makeLocalBackend, makeReaper, makeStopContainer } from "./backend-local.mjs";
|
|
34
|
+
import { makeBackendRegistry, reapAll } from "./backend-registry.mjs";
|
|
35
|
+
import { DEFAULT_BACKEND } from "./backends.mjs";
|
|
36
|
+
|
|
35
37
|
import { makeRunContainer } from "./run-container.mjs";
|
|
36
38
|
import { makeSecretsResolver } from "./secrets.mjs";
|
|
37
39
|
import { buildRecord, makeFindPreviousRun, makeLogReaper, makeLogSink, makeRecordWriter, sanitizeJobId } from "./run-history.mjs";
|
|
@@ -40,7 +42,6 @@ import { effectiveSettings, readOverlay } from "./runtime-settings.mjs";
|
|
|
40
42
|
import { authoredCron, loadSchedules, servedSchedules } from "./schedules.mjs";
|
|
41
43
|
import { makeStallGuard } from "./scheduler-stall-guard.mjs";
|
|
42
44
|
|
|
43
|
-
const exec = promisify(execFile);
|
|
44
45
|
|
|
45
46
|
/** How long boot will wait for `docker image inspect` before shipping without a digest. */
|
|
46
47
|
const BOOT_IMAGE_TIMEOUT_MS = 5_000;
|
|
@@ -172,49 +173,6 @@ function watchScopedLimitsFile(config, ref, log) {
|
|
|
172
173
|
}
|
|
173
174
|
}
|
|
174
175
|
|
|
175
|
-
export function makeReaper({ log }) {
|
|
176
|
-
return async function reap() {
|
|
177
|
-
try {
|
|
178
|
-
const { stdout } = await exec("docker", ["ps", "--filter", "name=pi-job-", "--format", "{{.Names}}"]);
|
|
179
|
-
const names = stdout
|
|
180
|
-
.split("\n")
|
|
181
|
-
.map((n) => n.trim())
|
|
182
|
-
.filter(Boolean);
|
|
183
|
-
for (const name of names) {
|
|
184
|
-
await exec("docker", ["rm", "-f", name]);
|
|
185
|
-
log("reaped_container", { name });
|
|
186
|
-
}
|
|
187
|
-
// REQ-EGRESS-ALLOWLIST: the per-job networks those containers were on. Swept AFTER the containers,
|
|
188
|
-
// because a network with a member still attached cannot be removed -- and swept by the SAME
|
|
189
|
-
// `pi-job-` filter, so the namespace rule that keeps an operator's live sandbox safe from the
|
|
190
|
-
// container reaper keeps their sandbox NETWORK safe too, with no second rule to remember.
|
|
191
|
-
//
|
|
192
|
-
// A crashed worker is the case this exists for: `runContainer`'s own finally removes the network
|
|
193
|
-
// on every ordinary path, so anything still here outlived a process that did not get to run it.
|
|
194
|
-
// A network still in use by something else fails to remove and is skipped, which is correct: this
|
|
195
|
-
// is a best-effort sweep and never a reason not to boot.
|
|
196
|
-
const { stdout: nets } = await exec("docker", ["network", "ls", "--filter", "name=pi-job-", "--format", "{{.Name}}"]);
|
|
197
|
-
for (const net of nets.split("\n").map((n) => n.trim()).filter(Boolean)) {
|
|
198
|
-
try {
|
|
199
|
-
await exec("docker", ["network", "rm", net]);
|
|
200
|
-
log("reaped_network", { network: net });
|
|
201
|
-
} catch {} // still in use, or already gone -- either way not this boot's problem
|
|
202
|
-
}
|
|
203
|
-
// Whether the enumeration HAPPENED, which the scope-claim sweep depends on: it may only delete a
|
|
204
|
-
// claim naming this host once this host has actually established that it holds no containers.
|
|
205
|
-
return { reaped: true };
|
|
206
|
-
} catch (err) {
|
|
207
|
-
// The `docker ps` is inside this try, so this path CANNOT establish that this host holds no
|
|
208
|
-
// containers -- whether it failed before listing anything or after reaping some and then losing
|
|
209
|
-
// the daemon. Either way the claim "I hold nothing" is unproven, and sweeping on it would free
|
|
210
|
-
// slots for containers that may STILL BE RUNNING, letting another host start more alongside
|
|
211
|
-
// them: a money overrun rather than a tidy-up. Conservative in the only safe direction.
|
|
212
|
-
log("reaper_skipped", { reason: err?.message });
|
|
213
|
-
return { reaped: false };
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
176
|
/**
|
|
219
177
|
* The runnable worker. Reads config, connects to Valkey, wires every REAL dependency the processor
|
|
220
178
|
* needs, and starts draining the queue. `createWorker` already installs the timeout, the
|
|
@@ -246,6 +204,10 @@ export async function startWorker(
|
|
|
246
204
|
makeHost = makeGitHubHost,
|
|
247
205
|
createWorkerFn = createWorker,
|
|
248
206
|
makeReaper: makeReaperFn = makeReaper,
|
|
207
|
+
makeBackendRegistry: makeBackendRegistryFn = makeBackendRegistry,
|
|
208
|
+
// Additional backend bundles, in registration order after `local`. The deployment still decides which
|
|
209
|
+
// are BLESSED (PI_BACKENDS) and which is default; this only says which exist.
|
|
210
|
+
extraBackends = [],
|
|
249
211
|
makeLogSink: makeLogSinkFn = makeLogSink,
|
|
250
212
|
makeRecordWriter: makeRecordWriterFn = makeRecordWriter,
|
|
251
213
|
makeRunMirror: makeRunMirrorFn = makeRunMirror,
|
|
@@ -353,9 +315,24 @@ export async function startWorker(
|
|
|
353
315
|
// Clear strays left by a previous crash before the worker starts draining. Best-effort: the reaper
|
|
354
316
|
// swallows its own docker errors; this guard keeps any reaper failure from blocking boot.
|
|
355
317
|
// Whether the container reaper actually ENUMERATED, which the scope-claim sweep below depends on.
|
|
318
|
+
// #227. ONE map, read twice: the boot sweep below combines every entry, and each backend's bundle takes
|
|
319
|
+
// its own reaper from it by name. Held here rather than derived from the bundles because the bundles
|
|
320
|
+
// cannot exist yet -- they need the log sink, the package resolver and the image preflight, all built
|
|
321
|
+
// further down -- and reaching for one here is a temporal dead zone the boot try/catch would swallow.
|
|
322
|
+
let backendReaps = {};
|
|
323
|
+
|
|
356
324
|
let reaped = false;
|
|
357
325
|
try {
|
|
358
|
-
|
|
326
|
+
// CONSTRUCTED INSIDE THE GUARD, not above it. The comment on this try says it "keeps any reaper
|
|
327
|
+
// failure from blocking boot", and a factory that throws is a reaper failure -- an earlier draft
|
|
328
|
+
// hoisted the construction out and quietly made that sentence false.
|
|
329
|
+
// DERIVED from the bundles, not a hand-kept parallel map. An earlier draft had a literal here and the
|
|
330
|
+
// registry cross-checking it, which made adding a venue three edits that nothing forced to agree --
|
|
331
|
+
// and a forgotten one is INVISIBLE, because `reapAll` is conservative over the reapers it is handed
|
|
332
|
+
// rather than over the venues that exist. A bundle already carries its own `reap`, so taking it from
|
|
333
|
+
// there is one place. `local`'s is built here because its bundle cannot exist yet.
|
|
334
|
+
backendReaps = { [DEFAULT_BACKEND]: makeReaperFn({ log }), ...Object.fromEntries(extraBackends.map((b) => [b?.name, b?.reap])) };
|
|
335
|
+
reaped = (await reapAll(Object.values(backendReaps), { log }))?.reaped === true;
|
|
359
336
|
} catch (err) {
|
|
360
337
|
log("reaper_skipped", { reason: err?.message });
|
|
361
338
|
}
|
|
@@ -629,8 +606,85 @@ export async function startWorker(
|
|
|
629
606
|
});
|
|
630
607
|
|
|
631
608
|
|
|
609
|
+
// #227: WHERE this job's container runs. The three functions that decide whether a container may start and
|
|
610
|
+
// then start it -- two pre-spend gates and the launcher -- bundled into one value with a completeness
|
|
611
|
+
// check, so the set has a name instead of being three unrelated `deps` keys. Byte-identical to passing them individually -- the same three functions reach the same three keys,
|
|
612
|
+
// built from the same config, behind the same injectable factories -- and this is the seam a second
|
|
613
|
+
// backend is selected at once there is one to select.
|
|
614
|
+
//
|
|
615
|
+
// Assigned key by key below rather than spread, because the bundle also carries `name` and `declares`,
|
|
616
|
+
// and `deps` is the processor's namespace: a spread would put a backend's name into it under a key the
|
|
617
|
+
// processor is free to mean something else by.
|
|
618
|
+
const localBackend = makeLocalBackend({
|
|
619
|
+
// #227. The two the earlier slices deferred, now real seams. `reap` keeps its tri-state: the boot
|
|
620
|
+
// sweep below only sweeps this host's scope claims once the reaper has PROVEN this host holds no
|
|
621
|
+
// job containers, and an unproven answer must never free a slot.
|
|
622
|
+
stopContainer: makeStopContainer(),
|
|
623
|
+
reap: backendReaps[DEFAULT_BACKEND],
|
|
624
|
+
// One deployment default, two consumers, adjacent by construction: the preflight that refuses a missing
|
|
625
|
+
// image BEFORE the budget slot, and the factory that puts it in the argv. Both resolve a trigger's own
|
|
626
|
+
// `run.image` through the same resolveJobImage, so the image that was checked is the image that runs.
|
|
627
|
+
// Nothing is memoised (see its construction above): `docker image inspect` costs ~tens of ms against a
|
|
628
|
+
// container run of minutes, and a cache would be wrong in both directions -- an operator who builds the
|
|
629
|
+
// image mid-day would stay refused, one who removes it would stay admitted. Contrast the staged-package
|
|
630
|
+
// manifest, correctly read once at boot because it is deploy-time state under a :ro mount.
|
|
631
|
+
imagePreflight,
|
|
632
|
+
// REQ-EGRESS-ALLOWLIST, and built here for the same reason the image preflight is: one deployment
|
|
633
|
+
// value, one place, so the gate that checks the proxy and the runner that attaches to its network
|
|
634
|
+
// cannot disagree about which proxy is meant. Nothing is memoised here either -- an operator who
|
|
635
|
+
// starts the proxy mid-day must not stay refused, and one who stops it must not stay admitted.
|
|
636
|
+
// Unarmed it spawns nothing at all, so a deployment without a policy pays for none of this.
|
|
637
|
+
egressPreflight: makeEgressPreflightFn({ proxy: config.egressProxy, armed: config.egress }),
|
|
638
|
+
runContainer: makeRunContainerFn({
|
|
639
|
+
image: config.jobImage,
|
|
640
|
+
hostEnv: env,
|
|
641
|
+
egress: config.egress, // REQ-EGRESS-ALLOWLIST: the per-job network and the proxy variables
|
|
642
|
+
egressProxy: config.egressProxy,
|
|
643
|
+
openJobLog,
|
|
644
|
+
globalPiDir: config.globalPiDir, // REQ-GLOBAL-PI-OVERLAY: :ro overlay mount when configured
|
|
645
|
+
allowGlobalExtensions: config.allowGlobalExtensions,
|
|
646
|
+
// REQ-GLOBAL-PI-OVERLAY: staged package paths; every job receives them unless its trigger set
|
|
647
|
+
// packages:false. A RESOLVER, not the array: the factory is still constructed exactly once, only
|
|
648
|
+
// the value it reads became a call, so a re-stage takes effect on the next job without a restart.
|
|
649
|
+
packagePaths: getPackagePaths,
|
|
650
|
+
forwardEnv: config.forwardEnv,
|
|
651
|
+
authFromPi: config.authFromPi, // source the provider key from ~/.pi/agent/auth.json when env has none
|
|
652
|
+
// Self-hosted instance URLs, keyed by forge. A MAP rather than one scalar per forge: the table says
|
|
653
|
+
// which variable each lands in, so a forge with no self-hosted concept simply has no entry, and
|
|
654
|
+
// adding one does not widen this signature again.
|
|
655
|
+
forgeHosts: { gitlab: config.gitlab?.apiUrl ?? null, forgejo: config.forgejo?.apiUrl ?? null, azure: config.azure?.orgUrl ?? null },
|
|
656
|
+
}),
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// #227. WHICH backend runs which job, and the one place that decides. One bundle today, so every
|
|
660
|
+
// resolution returns it -- but the mechanism is real, so `run.backend` stops being a validated label and
|
|
661
|
+
// the abort path can reach a venue it did not build. `config.backends[0]` is the deployment's default,
|
|
662
|
+
// and the registry refuses a default it does not hold rather than discovering it at the first pickup.
|
|
663
|
+
const backends = makeBackendRegistryFn({
|
|
664
|
+
// #227. A SEAM, not a literal. `docs/backends.md` tells an adapter author to register their bundle
|
|
665
|
+
// here, and until this was injectable that instruction described code nobody could run: the array
|
|
666
|
+
// was hard-coded, so a venue could pass the conformance suite, get a table entry and be blessed in
|
|
667
|
+
// PI_BACKENDS, and then be refused at boot as blessed-but-unbuilt with nowhere to put it. It is also
|
|
668
|
+
// what lets a wiring test prove `startWorker` actually CONNECTS the registry to the processor --
|
|
669
|
+
// six mutations reverting that connection survived the whole suite, which is the same shape as the
|
|
670
|
+
// bug that shipped: invisible while there is one venue.
|
|
671
|
+
bundles: [localBackend, ...extraBackends],
|
|
672
|
+
defaultName: config.defaultBackend,
|
|
673
|
+
// Cross-checked at boot rather than discovered at the first pickup: a name PI_BACKENDS blesses but
|
|
674
|
+
// nothing builds passes both the loader and the pre-spend gate, and a venue with no boot reaper is
|
|
675
|
+
// swept by nothing while still reporting the host as proven clean.
|
|
676
|
+
blessed: config.backends,
|
|
677
|
+
reaps: backendReaps,
|
|
678
|
+
});
|
|
679
|
+
|
|
632
680
|
const worker = createWorkerFn({
|
|
633
681
|
connection: parseConnection(config.valkeyUrl),
|
|
682
|
+
// #227. The abort path's stop, resolved per job rather than hard-wired to docker. A container NAME is
|
|
683
|
+
// not enough to find the runtime holding it once there is more than one venue.
|
|
684
|
+
stopContainer: backends.stopContainer,
|
|
685
|
+
// The NAME the abort stops, built by the venue that will build the container rather than by the local
|
|
686
|
+
// adapter reached for directly -- the name and the stop have to come from the same venue.
|
|
687
|
+
containerName: backends.containerName,
|
|
634
688
|
hostQueue,
|
|
635
689
|
// Names the BullMQ Worker, which makes `getWorkers()` rows tell hosts apart -- bullmq appends
|
|
636
690
|
// `:w:<name>` to the client name and `moveToActive` stamps `processedBy` onto each active job's
|
|
@@ -718,20 +772,8 @@ export async function startWorker(
|
|
|
718
772
|
const state = await held.getState();
|
|
719
773
|
return state === "delayed" || state === "waiting" || state === "active" || state === "prioritized" || state === "waiting-children";
|
|
720
774
|
},
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
// `run.image` through the same resolveJobImage, so the image that was checked is the image that runs.
|
|
724
|
-
// Nothing is memoised: `docker image inspect` costs ~tens of ms against a container run of minutes, and a
|
|
725
|
-
// cache would be wrong in both directions -- an operator who builds the image mid-day would stay refused,
|
|
726
|
-
// one who removes it would stay admitted. Contrast the staged-package manifest, correctly read once at
|
|
727
|
-
// boot because it is deploy-time state under a :ro mount; the host's image set is not.
|
|
728
|
-
imagePreflight,
|
|
729
|
-
// REQ-EGRESS-ALLOWLIST, and built here for the same reason the image preflight is: one deployment
|
|
730
|
-
// value, one place, so the gate that checks the proxy and the runner that attaches to its network
|
|
731
|
-
// cannot disagree about which proxy is meant. Nothing is memoised here either -- an operator who
|
|
732
|
-
// starts the proxy mid-day must not stay refused, and one who stops it must not stay admitted.
|
|
733
|
-
// Unarmed it spawns nothing at all, so a deployment without a policy pays for none of this.
|
|
734
|
-
egressPreflight: makeEgressPreflightFn({ proxy: config.egressProxy, armed: config.egress }),
|
|
775
|
+
imagePreflight: backends.imagePreflight,
|
|
776
|
+
egressPreflight: backends.egressPreflight,
|
|
735
777
|
// Completed-only, so a policy or infra exit leaves the canonical transcript byte-identical and a
|
|
736
778
|
// retry starts from what the first attempt did (CONST-RETRY-INFRA-ONLY).
|
|
737
779
|
promoteSession: sessionStore.promoteSession,
|
|
@@ -748,25 +790,21 @@ export async function startWorker(
|
|
|
748
790
|
// the panel should not have to restart the worker to use it. A deployment that declares nothing
|
|
749
791
|
// spawns nothing at all: the gate only calls this when a trigger is armed.
|
|
750
792
|
resolveSecrets: makeSecretsResolverFn({ envProfiles: config.secretProfiles, roots: config.secretResolverRoots, timeoutMs: config.secretResolveTimeoutMs, forwardEnv: config.forwardEnv, log }),
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
// which variable each lands in, so a forge with no self-hosted concept simply has no entry, and
|
|
767
|
-
// adding one does not widen this signature again.
|
|
768
|
-
forgeHosts: { gitlab: config.gitlab?.apiUrl ?? null, forgejo: config.forgejo?.apiUrl ?? null, azure: config.azure?.orgUrl ?? null },
|
|
769
|
-
}),
|
|
793
|
+
// #227. What PI_BACKENDS blessed, so a trigger naming an unblessed venue refuses pre-spend. The
|
|
794
|
+
// panel's picker is bounded by the same list, and this is the half that binds: the overlay is
|
|
795
|
+
// not the reviewed artifact (DES-PER-TRIGGER-SECRET-PROFILE).
|
|
796
|
+
blessedBackends: config.backends,
|
|
797
|
+
runContainer: backends.runContainer,
|
|
798
|
+
// #227. Resolved through the registry like every other per-job backend fact, so the integers the
|
|
799
|
+
// processor treats as "never started" are the ones the venue that ran the job actually uses.
|
|
800
|
+
// NOT `?? []`. An empty list means "this venue never reports a never-started exit", which sends a
|
|
801
|
+
// 125 to the unknown-exit branch -- no `reason`, so the budget slot is KEPT and BullMQ retries,
|
|
802
|
+
// burning a second one per never-started job. That is the bug the explicit case group was added
|
|
803
|
+
// to fix. A bundle that omits the field is a wiring defect, so it fails loudly here rather than
|
|
804
|
+
// silently in the money direction; `backends.mjs` requires it of every bundle.
|
|
805
|
+
// Off the REGISTRY's surface, like every other per-job backend fact. Rebuilding it at the call site
|
|
806
|
+
// is how a fact ends up dispatched on one path and hardcoded on another.
|
|
807
|
+
neverStartedExits: backends.neverStartedExits,
|
|
770
808
|
prepareWorkspace: makePrepareWorkspace({
|
|
771
809
|
jobsDir: config.jobsDir,
|
|
772
810
|
forgeFor,
|
package/src/triggers.mjs
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* Custom: triggers validated inline per config.mjs/schedules.mjs precedent; zod not in deps
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
import { BACKEND_NAMES, backendFor } from "./backends.mjs";
|
|
26
27
|
import { EGRESS_ENV_VARS, WORKER_ONLY_SECRET_VARS, configError } from "./config.mjs";
|
|
27
28
|
// SKILL_NAME_RE is the single-sourced skill charset (flow-gate exports it for exactly this reason:
|
|
28
29
|
// materialize.mjs and the admin already import it, and a keep-in-sync copy would drift where a
|
|
@@ -350,6 +351,7 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
350
351
|
validateDisarmed(on, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
351
352
|
const secrets = validateSecrets(run, `cron trigger "${id}"`, path);
|
|
352
353
|
const secretsProfile = validateSecretsProfile(run, `cron trigger "${id}"`, path);
|
|
354
|
+
const backend = validateBackend(on, run, `cron trigger "${id}"`, path, { localWorkspace: true });
|
|
353
355
|
// Called for its refusal only: the returned `run` below deliberately grows no `waitFor` key, exactly as
|
|
354
356
|
// it grows no `replicas` one, because a cron entry can never carry either (issue #230).
|
|
355
357
|
validateWaitFor(on, run, `cron trigger "${id}"`, path, { onType: "cron" });
|
|
@@ -361,7 +363,7 @@ function normalizeCron(on, run, index, path, state) {
|
|
|
361
363
|
// freeze today's default into every stored repeatable.
|
|
362
364
|
return {
|
|
363
365
|
on: { type: "cron", id, pattern },
|
|
364
|
-
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 }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }) },
|
|
366
|
+
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 }), ...(secrets !== undefined && { secrets }), ...(secretsProfile !== undefined && { secretsProfile }), ...(backend !== undefined && { backend }) },
|
|
365
367
|
};
|
|
366
368
|
}
|
|
367
369
|
|
|
@@ -992,6 +994,115 @@ function validateSecretsProfile(run, at, path) {
|
|
|
992
994
|
return profile;
|
|
993
995
|
}
|
|
994
996
|
|
|
997
|
+
/**
|
|
998
|
+
* Would running a folder-bound job in this venue be impossible? Exported ONLY so it can be driven.
|
|
999
|
+
*
|
|
1000
|
+
* The refusal it answers cannot be reached through `parseTriggers` today: the table holds one entry and it
|
|
1001
|
+
* is local, so no trigger can name a remote venue and the branch below is unreachable. A mutation pass duly
|
|
1002
|
+
* deleted that branch and the whole suite stayed green. A rule nothing can exercise is a rule that will be
|
|
1003
|
+
* wrong on the day it finally matters, which is the day a second backend lands -- so the decision lives
|
|
1004
|
+
* here, as one line over an explicit entry, and a test drives it against a synthetic remote one.
|
|
1005
|
+
*
|
|
1006
|
+
* `!== false` and not `=== true`: `remote` is a plain field on the table entry rather than a member of the
|
|
1007
|
+
* closed PROPERTIES list, so an entry that simply omits it would otherwise read as local and be admitted
|
|
1008
|
+
* onto a folder-bound trigger. That is the fail-open direction `backends.mjs`'s own rule forbids ("a backend
|
|
1009
|
+
* that omits one is not admitted for it"), and it would defeat with an absent key a refusal this file calls
|
|
1010
|
+
* physics. A test pins that every entry declares it, so both halves have to fail together.
|
|
1011
|
+
*/
|
|
1012
|
+
export function refusesLocalWorkspace(entry, localWorkspace) {
|
|
1013
|
+
return Boolean(localWorkspace) && entry?.remote !== false;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* `run.backend` -- WHICH venue this trigger's container is built in (issue #227).
|
|
1018
|
+
*
|
|
1019
|
+
* A NAME, never a configuration. `validateSecretsProfile` above is the template end to end, and the reason
|
|
1020
|
+
* is the same: a trigger SELECTS among what the deployment already blessed, and never configures a posture.
|
|
1021
|
+
* `run.network` was rejected outright for that reason, and this field must not become a way back to it --
|
|
1022
|
+
* naming `remote-vendor` cannot turn egress off, because what egress a venue can provide is the backend
|
|
1023
|
+
* table's answer and the deployment's floor bounds which venues are reachable at all.
|
|
1024
|
+
*
|
|
1025
|
+
* WHAT IS CHECKED HERE AND WHAT IS NOT. This file answers what the FILE knows: that the name is a name, and
|
|
1026
|
+
* that the venue could ever run this kind of job. Whether the DEPLOYMENT blessed it is `PI_BACKENDS`, which
|
|
1027
|
+
* lives in the environment, and a loader that read it would refuse a reviewed file on a per-host setting --
|
|
1028
|
+
* so the processor refuses that pre-spend instead. Same split `secretsProfile` already draws between the
|
|
1029
|
+
* charset check here and `secret-profile-unknown` there.
|
|
1030
|
+
*
|
|
1031
|
+
* A NEAR-MISS SPELLING IS REFUSED, which puts this in `waitFor`'s class rather than `run.imgae`'s. A
|
|
1032
|
+
* misspelled image gives you the default image and a job that ran; a misspelled `backend` gives you the
|
|
1033
|
+
* DEFAULT VENUE and a job that ran -- byte-identical in the record, the panel and the log to one that
|
|
1034
|
+
* correctly named a venue, while the operator reads the file as though it chose. That is the destructive
|
|
1035
|
+
* absence `validateWaitFor` refuses near-misses for, one field over.
|
|
1036
|
+
*/
|
|
1037
|
+
function validateBackend(on, run, at, path, { localWorkspace }) {
|
|
1038
|
+
// The near-miss sweep, exactly `validateWaitFor`'s: on `run` the exact spelling is the field, on `on`
|
|
1039
|
+
// every spelling is wrong including the correct one, because a venue is a property of the run.
|
|
1040
|
+
for (const [label, source, exactIsLegal] of [
|
|
1041
|
+
["run", run, true],
|
|
1042
|
+
["on", on, false],
|
|
1043
|
+
]) {
|
|
1044
|
+
for (const key of Object.keys(source ?? {})) {
|
|
1045
|
+
if (exactIsLegal && key === "backend") continue;
|
|
1046
|
+
// The PLURAL is included, and it is the likeliest miss of all: the deployment-side variable is
|
|
1047
|
+
// `PI_BACKENDS`, so `run.backends` is what an operator writes from memory. Nothing else in this
|
|
1048
|
+
// file needs that, which is why the comparison is against a small set rather than `waitFor`'s
|
|
1049
|
+
// single normalized string.
|
|
1050
|
+
// A HOMOGLYPH is caught too. `replace(/[^a-z0-9]/gi, "")` DELETES a non-ASCII character rather
|
|
1051
|
+
// than failing on it, so a Cyrillic "a" in `b<U+0430>ckend` normalizes to `bckend` and an
|
|
1052
|
+
// Armenian "n" in `backe<U+0578>d` to `backed` -- both miss an equality test and are dropped in
|
|
1053
|
+
// exactly the silence this sweep exists to prevent. Such a key arrives by paste from a rendered
|
|
1054
|
+
// doc or a chat client, the same route homoglyphs take into package names.
|
|
1055
|
+
//
|
|
1056
|
+
// So a key carrying non-ASCII is compared as a SUBSEQUENCE: whatever ASCII letters survive must
|
|
1057
|
+
// still be obtainable from `backend`/`backends` in order. That catches any number of substituted
|
|
1058
|
+
// glyphs without guessing which ones, and a genuinely unrelated non-ASCII key (`fl<U+00F6>w`)
|
|
1059
|
+
// keeps falling through to this file's documented tolerance of unknown `run` keys.
|
|
1060
|
+
const normalized = key.replace(/[^a-z0-9]/gi, "").toLowerCase();
|
|
1061
|
+
const targets = ["backend", "backends"];
|
|
1062
|
+
const isSubsequence = (needle, hay) => {
|
|
1063
|
+
let i = 0;
|
|
1064
|
+
for (const ch of hay) if (i < needle.length && needle[i] === ch) i++;
|
|
1065
|
+
return i === needle.length;
|
|
1066
|
+
};
|
|
1067
|
+
// The floor is 4 rather than 5 so a key with three substituted glyphs is still caught. Only a
|
|
1068
|
+
// non-ASCII key reaches this branch, so the false-positive surface is a non-ASCII key whose
|
|
1069
|
+
// surviving letters happen to be an in-order subset of "backend", which no field here is.
|
|
1070
|
+
const suspicious = /^[\x20-\x7E]*$/.test(key)
|
|
1071
|
+
? targets.includes(normalized)
|
|
1072
|
+
: normalized.length >= 4 && targets.some((t) => isSubsequence(normalized, t));
|
|
1073
|
+
if (!suspicious) continue;
|
|
1074
|
+
throw configError(`${at}: ${label}.${key} is not a field -- did you mean run.backend? A venue the loader drops runs the job somewhere else while the file reads as though it chose, so a near miss is refused rather than dropped: ${path}`);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const backend = run.backend;
|
|
1079
|
+
if (backend === undefined) return undefined;
|
|
1080
|
+
|
|
1081
|
+
if (!isNonEmptyString(backend)) {
|
|
1082
|
+
throw configError(`${at}: run.backend must be a non-empty string naming one of the backends this deployment can run jobs in (got ${JSON.stringify(backend)}): ${path}`);
|
|
1083
|
+
}
|
|
1084
|
+
if (!ID_CHARSET.test(backend)) {
|
|
1085
|
+
throw configError(`${at}: run.backend ${JSON.stringify(backend)} may use letters, digits, dot, dash and underscore only -- PI_BACKENDS is a comma-separated list, so a name carrying that separator could not be blessed: ${path}`);
|
|
1086
|
+
}
|
|
1087
|
+
const entry = backendFor(backend);
|
|
1088
|
+
if (!entry) {
|
|
1089
|
+
throw configError(`${at}: run.backend ${JSON.stringify(backend)} is not a backend this build knows (known: ${BACKEND_NAMES.join(", ")}) -- whether your deployment BLESSES it is PI_BACKENDS, checked before the job spends: ${path}`);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
// A LOCAL job cannot run in a remote venue, and this is physics rather than policy. `DES-WORKER-ON-HOST`
|
|
1093
|
+
// finding (2): "the operator's own folder must be bind-mounted as /workspace, edited in place. There is
|
|
1094
|
+
// no volume to hide behind." A cron trigger normalizes to a local run for the same reason. Refused at
|
|
1095
|
+
// LOAD and permanently, because no amount of deployment configuration can make a folder on this machine
|
|
1096
|
+
// appear inside someone else's daemon -- so this is not a bound an operator could widen, it is one the
|
|
1097
|
+
// world imposes, and discovering it per job would burn a pickup for an answer the file already had.
|
|
1098
|
+
// The decision itself is `refusesLocalWorkspace` above, so it can be driven; see its comment.
|
|
1099
|
+
if (refusesLocalWorkspace(entry, localWorkspace)) {
|
|
1100
|
+
throw configError(`${at}: run.backend ${JSON.stringify(backend)} is remote, and this trigger runs against a folder on THIS machine that has to be bind-mounted and edited in place -- there is no volume to hide behind (DES-WORKER-ON-HOST): ${path}`);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
return backend;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
995
1106
|
/**
|
|
996
1107
|
* `run.waitFor` -- the conditions that must all clear before this trigger's job starts (issue #230).
|
|
997
1108
|
*
|
|
@@ -1147,10 +1258,11 @@ function normalizeLabel(on, run, index, path) {
|
|
|
1147
1258
|
const replicas = validateReplicas(run, at, path);
|
|
1148
1259
|
const secrets = validateSecrets(run, at, path);
|
|
1149
1260
|
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
1261
|
+
const backend = validateBackend(on, run, at, path, { localWorkspace: false });
|
|
1150
1262
|
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
1151
1263
|
return {
|
|
1152
1264
|
on: { type: "label", any: predicate.any, all: predicate.all, none: predicate.none },
|
|
1153
|
-
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 }) },
|
|
1265
|
+
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 }) },
|
|
1154
1266
|
};
|
|
1155
1267
|
}
|
|
1156
1268
|
|
|
@@ -1186,10 +1298,11 @@ function normalizeComment(on, run, index, path, state) {
|
|
|
1186
1298
|
const replicas = validateReplicas(run, at, path);
|
|
1187
1299
|
const secrets = validateSecrets(run, at, path);
|
|
1188
1300
|
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
1301
|
+
const backend = validateBackend(on, run, at, path, { localWorkspace: false });
|
|
1189
1302
|
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
1190
1303
|
return {
|
|
1191
1304
|
on: { type: "comment", phrase: on.phrase },
|
|
1192
|
-
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 }) },
|
|
1305
|
+
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 }) },
|
|
1193
1306
|
};
|
|
1194
1307
|
}
|
|
1195
1308
|
|
|
@@ -1252,6 +1365,7 @@ function normalizeIssue(on, run, index, path) {
|
|
|
1252
1365
|
const replicas = validateReplicas(run, at, path);
|
|
1253
1366
|
const secrets = validateSecrets(run, at, path);
|
|
1254
1367
|
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
1368
|
+
const backend = validateBackend(on, run, at, path, { localWorkspace: false });
|
|
1255
1369
|
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
1256
1370
|
return {
|
|
1257
1371
|
on: {
|
|
@@ -1262,7 +1376,7 @@ function normalizeIssue(on, run, index, path) {
|
|
|
1262
1376
|
...(number !== undefined && { number }),
|
|
1263
1377
|
...(once !== undefined && { once }),
|
|
1264
1378
|
},
|
|
1265
|
-
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 }) },
|
|
1379
|
+
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 }) },
|
|
1266
1380
|
};
|
|
1267
1381
|
}
|
|
1268
1382
|
|
|
@@ -1336,6 +1450,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
1336
1450
|
const replicas = validateReplicas(run, at, path);
|
|
1337
1451
|
const secrets = validateSecrets(run, at, path);
|
|
1338
1452
|
const secretsProfile = validateSecretsProfile(run, at, path);
|
|
1453
|
+
const backend = validateBackend(on, run, at, path, { localWorkspace: false });
|
|
1339
1454
|
const waitFor = validateWaitFor(on, run, at, path, { onType: on.type });
|
|
1340
1455
|
return {
|
|
1341
1456
|
on: {
|
|
@@ -1351,7 +1466,7 @@ function normalizePullRequest(on, run, index, path) {
|
|
|
1351
1466
|
...(number !== undefined && { number }),
|
|
1352
1467
|
...(once !== undefined && { once }),
|
|
1353
1468
|
},
|
|
1354
|
-
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 }) },
|
|
1469
|
+
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 }) },
|
|
1355
1470
|
};
|
|
1356
1471
|
}
|
|
1357
1472
|
|