@edgehero/pi-dispatch 0.1.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 +160 -0
- package/deploy/com.pi-dispatch.worker.plist +66 -0
- package/deploy/nssm-install.cmd +59 -0
- package/deploy/receiver.service +36 -0
- package/deploy/worker-env-wrapper.cmd +50 -0
- package/deploy/worker-env-wrapper.sh +63 -0
- package/deploy/worker.service +55 -0
- package/package.json +83 -0
- package/src/azure-auth.mjs +61 -0
- package/src/azure-host.mjs +236 -0
- package/src/azure-identity.mjs +63 -0
- package/src/azure-prompt.mjs +118 -0
- package/src/branch.mjs +80 -0
- package/src/budget.mjs +179 -0
- package/src/cli.mjs +208 -0
- package/src/config.mjs +329 -0
- package/src/connection.mjs +40 -0
- package/src/cron.mjs +94 -0
- package/src/docker-run.mjs +119 -0
- package/src/doctor.mjs +1127 -0
- package/src/env-allowlist.mjs +198 -0
- package/src/env-file.mjs +153 -0
- package/src/exit-code.mjs +32 -0
- package/src/flow-gate.mjs +82 -0
- package/src/forgejo-auth.mjs +77 -0
- package/src/forgejo-host.mjs +172 -0
- package/src/forgejo-identity.mjs +74 -0
- package/src/forgejo-prompt.mjs +123 -0
- package/src/forges.mjs +148 -0
- package/src/get-token.mjs +226 -0
- package/src/git-dirty.mjs +16 -0
- package/src/github-app-setup.mjs +517 -0
- package/src/github-host.mjs +159 -0
- package/src/github-prompt.mjs +286 -0
- package/src/gitlab-auth.mjs +72 -0
- package/src/gitlab-host.mjs +200 -0
- package/src/gitlab-identity.mjs +61 -0
- package/src/gitlab-prompt.mjs +123 -0
- package/src/identity.mjs +57 -0
- package/src/image-preflight.mjs +180 -0
- package/src/import-pi.mjs +451 -0
- package/src/index.mjs +177 -0
- package/src/init.mjs +77 -0
- package/src/job-id.mjs +100 -0
- package/src/materialize.mjs +138 -0
- package/src/outbox.mjs +179 -0
- package/src/packages.mjs +188 -0
- package/src/pause-windows.mjs +218 -0
- package/src/prepare-github.mjs +260 -0
- package/src/prepare-local.mjs +76 -0
- package/src/prepare.mjs +199 -0
- package/src/pricing.mjs +168 -0
- package/src/processor.mjs +360 -0
- package/src/queue.mjs +152 -0
- package/src/run-container.mjs +133 -0
- package/src/run-history.mjs +534 -0
- package/src/runtime-settings.mjs +188 -0
- package/src/sandbox-cli.mjs +156 -0
- package/src/sandbox-store.mjs +269 -0
- package/src/sandbox.mjs +171 -0
- package/src/scheduler-stall-guard.mjs +67 -0
- package/src/schedules.mjs +62 -0
- package/src/service.mjs +677 -0
- package/src/session-key.mjs +108 -0
- package/src/session-store.mjs +249 -0
- package/src/start.mjs +502 -0
- package/src/subscriptions.mjs +208 -0
- package/src/triggers.mjs +491 -0
- package/src/up.mjs +315 -0
package/src/sandbox.mjs
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
import { configError } from "./config.mjs";
|
|
5
|
+
import { buildDockerRunArgs } from "./docker-run.mjs";
|
|
6
|
+
import { sanitizeJobId } from "./run-history.mjs";
|
|
7
|
+
import { readManifest } from "./sandbox-store.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* sandbox.mjs -- the operator session's container shape (INT-SANDBOX-CONTRACT).
|
|
11
|
+
*
|
|
12
|
+
* A SECOND container shape, deliberately not a second copy of the first. The argv comes from
|
|
13
|
+
* `buildDockerRunArgs` through its `extraFlags` seam, so `ISOLATION_FLAGS`, `--memory` and `--cpus` reach
|
|
14
|
+
* this container BY CONSTRUCTION: a future change to the boundary cannot land on job containers and miss
|
|
15
|
+
* this one, which is the whole reason for reusing the builder rather than writing a leaner argv here.
|
|
16
|
+
*
|
|
17
|
+
* What differs from a job, and every difference is the point:
|
|
18
|
+
* - `-i -t --entrypoint bash`. `INT-CONTAINER-RUNTIME-CONTRACT` says "No TTY (`-it` absent)" and stays
|
|
19
|
+
* true; this is a different contract for a different object, not an amendment to that one.
|
|
20
|
+
* - NO CREDENTIALS. Not the minted forge token, not the provider key, not one forwarded host variable.
|
|
21
|
+
* `buildContainerEnv` is deliberately NOT reused: it writes the mint into that forge's variable names
|
|
22
|
+
* (env-allowlist.mjs) and throws outright when no provider credential resolves, so a credential-free
|
|
23
|
+
* container cannot be produced from it. The env here is two variables, both about the terminal.
|
|
24
|
+
* - No `/outbox`, no `/session`, no `/opt/pi-global`. The agent is not running; there is nothing to
|
|
25
|
+
* chain, no transcript to continue and no overlay to layer.
|
|
26
|
+
*
|
|
27
|
+
* The agent is not running. The operator is at the keyboard and brings their own auth if they need it.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const exec = promisify(execFile);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The name namespace, and it is load-bearing. The boot reaper filters `name=pi-job-`
|
|
34
|
+
* (`makeReaper` in start.mjs) and docker matches that as a SUBSTRING, so a sandbox must not contain it --
|
|
35
|
+
* otherwise a worker restart kills the shell an operator is sitting in. `pi-sandbox-` is outside that
|
|
36
|
+
* filter on purpose, and a test pins it.
|
|
37
|
+
*/
|
|
38
|
+
export const SANDBOX_NAME_PREFIX = "pi-sandbox-";
|
|
39
|
+
|
|
40
|
+
/** `pi-sandbox-<jobId>`. `sanitizeJobId` already maps to `[A-Za-z0-9._-]`, which is a legal docker name. */
|
|
41
|
+
export function sandboxContainerName(jobId) {
|
|
42
|
+
return `${SANDBOX_NAME_PREFIX}${sanitizeJobId(jobId)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Turn `--publish` values into docker flags, BOUND TO LOOPBACK, always.
|
|
47
|
+
*
|
|
48
|
+
* `3000` publishes container 3000 on host 3000; `8080:3000` publishes container 3000 on host 8080. An
|
|
49
|
+
* explicit bind address is refused rather than honoured: this container holds whatever the agent wrote,
|
|
50
|
+
* and the deployment's own admin surface is 127.0.0.1-only for the same reason
|
|
51
|
+
* (`DES-PANEL-SEPARATE-FROM-RECEIVER`). Making the LAN case merely inconvenient would be a worse answer
|
|
52
|
+
* than making it unavailable.
|
|
53
|
+
*/
|
|
54
|
+
export function parsePublish(values = []) {
|
|
55
|
+
const flags = [];
|
|
56
|
+
for (const raw of values) {
|
|
57
|
+
const match = /^(\d{1,5})(?::(\d{1,5}))?$/.exec(String(raw).trim());
|
|
58
|
+
const host = match && Number(match[1]);
|
|
59
|
+
const container = match && Number(match[2] ?? match[1]);
|
|
60
|
+
if (!match || !inPortRange(host) || !inPortRange(container)) {
|
|
61
|
+
throw configError(`invalid --publish ${JSON.stringify(raw)} (want <port> or <hostPort>:<containerPort>; the bind address is always 127.0.0.1)`);
|
|
62
|
+
}
|
|
63
|
+
flags.push("-p", `127.0.0.1:${host}:${container}`);
|
|
64
|
+
}
|
|
65
|
+
return flags;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function inPortRange(n) {
|
|
69
|
+
return Number.isInteger(n) && n >= 1 && n <= 65535;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build the `docker run` argv for one operator session (excluding the leading "docker").
|
|
74
|
+
*
|
|
75
|
+
* @param image the image the original run used, from its manifest
|
|
76
|
+
* @param name `pi-sandbox-<jobId>`
|
|
77
|
+
* @param workspace host path mounted /workspace:rw -- the retained clone, or the operator's own folder
|
|
78
|
+
* @param jobDir the retained per-job dir, mounted /job:ro exactly as the run had it
|
|
79
|
+
* @param publish already-parsed `-p` flags
|
|
80
|
+
* @param term the host's TERM, so the shell renders
|
|
81
|
+
* @param idleSeconds bash's own TMOUT; 0 omits it
|
|
82
|
+
*/
|
|
83
|
+
export function buildSandboxRunArgs({ image, name, workspace, jobDir, publish = [], term, idleSeconds = 0 }) {
|
|
84
|
+
return buildDockerRunArgs({
|
|
85
|
+
image,
|
|
86
|
+
name,
|
|
87
|
+
workspace,
|
|
88
|
+
jobDir,
|
|
89
|
+
// The ONLY two variables, and neither is a credential. TERM so the shell renders; TMOUT so a
|
|
90
|
+
// forgotten session closes itself. `buildDockerRunArgs` skips undefined, so an unset TERM or a
|
|
91
|
+
// disabled idle timeout emits nothing rather than an empty string.
|
|
92
|
+
env: {
|
|
93
|
+
TERM: term || undefined,
|
|
94
|
+
TMOUT: idleSeconds > 0 ? String(idleSeconds) : undefined,
|
|
95
|
+
},
|
|
96
|
+
// Ahead of the env and the mounts, and well ahead of the image, which buildDockerRunArgs keeps as
|
|
97
|
+
// the final positional. `--entrypoint` also clears the image's CMD; this repo's Dockerfile sets
|
|
98
|
+
// none, so `bash` runs bare and `-it` makes it interactive, in the baked WORKDIR as the baked
|
|
99
|
+
// non-root USER.
|
|
100
|
+
extraFlags: ["-i", "-t", "--entrypoint", "bash", ...publish],
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The JOB IDS of sandboxes running right now -- ids, not container names, because that is the shape both
|
|
106
|
+
* consumers want: the reaper compares them to directory names, and `--list` marks rows.
|
|
107
|
+
*
|
|
108
|
+
* THROWS when docker cannot be asked, and that is the point: "none are running" and "I could not find out"
|
|
109
|
+
* must not arrive as the same empty array. The reaper deletes directories, and it treats the second case as
|
|
110
|
+
* a reason to skip the whole sweep -- swallowing the error here would quietly turn a docker outage into a
|
|
111
|
+
* blind sweep that can pull a bind mount out from under a live shell. The CLI, which only draws a column,
|
|
112
|
+
* degrades on its own.
|
|
113
|
+
*
|
|
114
|
+
* TIMED, unlike the boot container reaper's own `docker ps`: an unreachable daemon does not fail the CLI
|
|
115
|
+
* fast, it blocks, and this call sits in front of a worker that has not started draining yet.
|
|
116
|
+
*/
|
|
117
|
+
export async function listRunningSandboxes({ execFn = exec } = {}) {
|
|
118
|
+
const { stdout } = await execFn("docker", ["ps", "--filter", `name=${SANDBOX_NAME_PREFIX}`, "--format", "{{.Names}}"], { timeout: 5000 });
|
|
119
|
+
return stdout
|
|
120
|
+
.split("\n")
|
|
121
|
+
.map((n) => n.trim())
|
|
122
|
+
.filter((n) => n.startsWith(SANDBOX_NAME_PREFIX))
|
|
123
|
+
.map((n) => n.slice(SANDBOX_NAME_PREFIX.length));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve one retained run into a launchable argv, or a NAMED refusal.
|
|
128
|
+
*
|
|
129
|
+
* Split out from the launch so both callers -- the CLI and the admin panel -- refuse identically, and so
|
|
130
|
+
* the whole decision is testable without docker. Every refusal names what to do next, the posture
|
|
131
|
+
* `doctor` sets: a bare "not found" for a run the operator watched finish ten minutes ago is the least
|
|
132
|
+
* useful thing this could say.
|
|
133
|
+
*/
|
|
134
|
+
export function resolveSandbox({ jobId, sandboxDir, retentionHours, publish = [], fs, fileExists = existsSync }) {
|
|
135
|
+
if (!jobId) return { refused: "no-job-id", message: "a job id is required (see `pi-dispatch sandbox --list`)" };
|
|
136
|
+
|
|
137
|
+
const manifest = readManifest({ sandboxDir, jobId, ...(fs ? { fs } : {}) });
|
|
138
|
+
if (!manifest) {
|
|
139
|
+
return retentionHours === 0
|
|
140
|
+
? { refused: "retention-off", message: "workspace retention is off — set PI_SANDBOX_RETENTION_HOURS to a positive number to make future runs resurrectable" }
|
|
141
|
+
: { refused: "absent", message: `no retained workspace for ${jobId} — it was swept after ${retentionHours}h, or the run predates retention (\`pi-dispatch sandbox --list\` shows what is left)` };
|
|
142
|
+
}
|
|
143
|
+
if (!manifest.image) {
|
|
144
|
+
return { refused: "no-image", message: `the manifest for ${jobId} names no image, so the sandbox cannot reproduce the run` };
|
|
145
|
+
}
|
|
146
|
+
if (!manifest.workspace || !fileExists(manifest.workspace)) {
|
|
147
|
+
// The common cause for a local run: the operator's folder moved or was deleted. Naming the path is
|
|
148
|
+
// the whole diagnosis, so name it.
|
|
149
|
+
return { refused: "workspace-gone", message: `the workspace for ${jobId} is no longer at ${manifest.workspace} — a local folder that moved cannot be re-opened` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { manifest, name: sandboxContainerName(jobId), publish };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Launch one operator session, attached to the caller's terminal, and resolve its exit code.
|
|
157
|
+
*
|
|
158
|
+
* `spawn`, never `spawnSync`, and `stdio: "inherit"`: the same shape pi itself uses to hand the terminal
|
|
159
|
+
* to `$EDITOR` (`extension-editor.js`), whose comment records why the synchronous form is wrong -- on
|
|
160
|
+
* Windows it can keep libuv's console read active and race the child for input.
|
|
161
|
+
*
|
|
162
|
+
* The caller owns the terminal around this: the CLI simply has one, and the admin panel brackets the call
|
|
163
|
+
* with `tui.stop()`/`tui.start()`.
|
|
164
|
+
*/
|
|
165
|
+
export function launchSandbox({ args, spawnFn = spawn }) {
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
const child = spawnFn("docker", args, { stdio: "inherit" });
|
|
168
|
+
child.on("error", (err) => resolve({ code: null, error: err }));
|
|
169
|
+
child.on("close", (code) => resolve({ code }));
|
|
170
|
+
});
|
|
171
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-scheduler stall accounting -- the money backstop for cron (constitution.md:203-216).
|
|
3
|
+
*
|
|
4
|
+
* BullMQ's `maxStalledCount` does not cover scheduler jobs: `moveStalledJobsToWait` derives
|
|
5
|
+
* `isRepeatableJob` from the job's `rjk` field and skips the stall-fail for a live scheduler, so a
|
|
6
|
+
* wedged scheduled run is re-processed -- paid -- on every stall, indefinitely. `maxStalledCount: 0`
|
|
7
|
+
* bounds ordinary jobs; nothing in BullMQ bounds scheduler jobs. This guard counts stalls per
|
|
8
|
+
* scheduler over a rolling window and tears the scheduler down once the count exceeds a threshold.
|
|
9
|
+
*
|
|
10
|
+
* Injected `redis` (ioredis-compatible), `removeJobScheduler`, and `log` keep the logic testable with
|
|
11
|
+
* no queue, no bullmq import, and no real Valkey.
|
|
12
|
+
*
|
|
13
|
+
* Custom: per-scheduler stall accounting; BullMQ's maxStalledCount does not cover scheduler jobs -- constitution.md:203-216 carve-out ("BullMQ will never do this for us")
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
// The Redis hash of per-scheduler stall counts (field = schedulerId, value = count). Exported so the admin
|
|
17
|
+
// panel can read it (HGETALL) for the cron drill-in without re-deriving the key string.
|
|
18
|
+
export const STALL_KEY = "pi-dispatch:sched-stalls";
|
|
19
|
+
|
|
20
|
+
// A rolling window: the EXPIRE is re-set on every stall, so a scheduler that stops stalling for a full
|
|
21
|
+
// day drops back to zero. This prevents unrelated transient stalls weeks apart from accumulating into a
|
|
22
|
+
// false teardown -- only sustained stalling inside one window trips the threshold.
|
|
23
|
+
const STALL_WINDOW_SECONDS = 24 * 60 * 60;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Build the `stalled` listener. `threshold` is how many stalls a single scheduler may accrue before
|
|
27
|
+
* teardown, compared STRICT `>` to mirror BullMQ's own `stalledCount > maxStalledJobCount`.
|
|
28
|
+
* `removeJobScheduler(schedulerId)` tears a scheduler down; `log(event, fields)` records stable ids
|
|
29
|
+
* only, never task or body content.
|
|
30
|
+
*
|
|
31
|
+
* The returned `onStalled` never rejects: BullMQ's `stalled` event is fire-and-forget (void-invoked),
|
|
32
|
+
* so a rejection here would surface as an unhandled rejection with no handler to catch it.
|
|
33
|
+
*/
|
|
34
|
+
export function makeStallGuard({ redis, threshold, removeJobScheduler, log }) {
|
|
35
|
+
return async function onStalled(jobId) {
|
|
36
|
+
try {
|
|
37
|
+
// Ordinary jobs are bounded by `maxStalledCount: 0`; only scheduler jobs reach this accounting.
|
|
38
|
+
if (!jobId.startsWith("repeat:")) return;
|
|
39
|
+
|
|
40
|
+
const schedulerId = jobId.slice("repeat:".length, jobId.lastIndexOf(":"));
|
|
41
|
+
if (schedulerId === "") {
|
|
42
|
+
// Degenerate `repeat:<n>` / `repeat::<n>` with no scheduler segment: an empty hash field would
|
|
43
|
+
// pool every such id into one counter, so log and skip rather than hincrby an empty key.
|
|
44
|
+
log("scheduler_stall_unparsed", { jobId });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const count = Number(await redis.hincrby(STALL_KEY, schedulerId, 1));
|
|
49
|
+
await redis.expire(STALL_KEY, STALL_WINDOW_SECONDS);
|
|
50
|
+
|
|
51
|
+
if (count > threshold) {
|
|
52
|
+
try {
|
|
53
|
+
await removeJobScheduler(schedulerId);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
// A scheduler already gone (removed concurrently, or between the stall and now) is the goal
|
|
56
|
+
// state, not an error -- swallow it so hdel and the teardown alert still run.
|
|
57
|
+
log("scheduler_teardown_remove_failed", { schedulerId, error: error?.message });
|
|
58
|
+
}
|
|
59
|
+
await redis.hdel(STALL_KEY, schedulerId);
|
|
60
|
+
// The loud log is the "alert" half of the constitution's "removeJobScheduler -- or alert".
|
|
61
|
+
log("scheduler_torn_down", { schedulerId, stalls: count });
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
log("scheduler_stall_guard_error", { jobId, error: error?.message });
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Select the worker's cron schedules from the unified triggers file (DES-CRON-VIA-BULLMQ-SCHEDULER,
|
|
3
|
+
* issue #20). The worker owns exactly the `on.type:"cron"` entries; the receiver owns the webhook types.
|
|
4
|
+
* The shared validator (`triggers.mjs`) parses and validates the WHOLE file fail-loud -- the diagonal,
|
|
5
|
+
* the `id` charset, cron field count -- so this module only selects the cron subset, checks that each
|
|
6
|
+
* `run.folder` exists on disk (the one fs-dependent check the pure validator cannot make), and normalizes
|
|
7
|
+
* to the shape the caller hands to BullMQ's `upsertJobScheduler` (`schedulerId`, `name`, `pattern`, and
|
|
8
|
+
* the `data`/`opts` job template).
|
|
9
|
+
*
|
|
10
|
+
* Fail-loud, like config.mjs: a misconfigured triggers file makes the worker refuse to start with a clear
|
|
11
|
+
* message rather than upserting a broken scheduler that silently never fires. Every load-time rejection is
|
|
12
|
+
* a `configError` so the CLI/entry prints it cleanly and exits non-zero.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync as fsExistsSync, readFileSync as fsReadFileSync } from "node:fs";
|
|
16
|
+
import { configError } from "./config.mjs";
|
|
17
|
+
import { parseTriggers } from "./triggers.mjs";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse, validate, and select the cron schedules from the triggers file named by `config.triggersFile`.
|
|
21
|
+
* Returns `[]` when cron is disabled (`triggersFile` null/absent) -- a worker with no cron triggers is a
|
|
22
|
+
* valid deployment. `readFileSync`/`existsSync` are injectable so tests exercise the full path with no
|
|
23
|
+
* real filesystem.
|
|
24
|
+
*/
|
|
25
|
+
export function loadSchedules(config, { readFileSync = fsReadFileSync, existsSync = fsExistsSync } = {}) {
|
|
26
|
+
const path = config.triggersFile;
|
|
27
|
+
if (path === null || path === undefined) return []; // cron disabled
|
|
28
|
+
|
|
29
|
+
if (!existsSync(path)) {
|
|
30
|
+
throw configError(`triggers file does not exist: ${path}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const triggers = parseTriggers(readFileSync(path, "utf8"), path);
|
|
34
|
+
|
|
35
|
+
return triggers.filter((t) => t.on.type === "cron").map((t) => normalizeCronSchedule(t, path, existsSync));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeCronSchedule({ on, run }, path, existsSync) {
|
|
39
|
+
// The pure validator already guaranteed a non-empty, `:`-free, charset-valid, unique id and a
|
|
40
|
+
// well-formed pattern; folder existence is the one fs-dependent check it deferred to here.
|
|
41
|
+
if (!existsSync(run.folder)) {
|
|
42
|
+
throw configError(`cron trigger "${on.id}": run.folder does not exist: ${run.folder} (${path})`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Absent provider/model/maxTurns stay absent (undefined) so the value resolves at job start against the
|
|
46
|
+
// settings overlay/env, not a default frozen here (INT-CONFIG-OVERLAY-CONTRACT). data key order matches
|
|
47
|
+
// queue.mjs -- the shape the processor's runJob consumes. The three per-trigger fields ride along the same
|
|
48
|
+
// way and are kept adjacent: `github` (the scoped token flag), `packages` (load the operator-staged pi
|
|
49
|
+
// packages) and `image` (which container image this schedule's jobs run in) -- INT-TRIGGERS-FILE-CONTRACT,
|
|
50
|
+
// REQ-GLOBAL-PI-OVERLAY. `image` is a SELECTOR rather than an opt-in: absent does not mean "off", it means
|
|
51
|
+
// the deployment default, resolved at job start (image-preflight.mjs). Undefined drops out at JSON
|
|
52
|
+
// serialization, so an unflagged schedule stays byte-identical to today's. `trigger` is the one
|
|
53
|
+
// cron-only field: it is carried into the local `/job/event.json` (INT-CONTAINER-JOB-INPUTS) so a
|
|
54
|
+
// scheduled job can name its own trigger; the INT-TRIGGERS-FILE-CONTRACT byte-match acceptance is
|
|
55
|
+
// amended for exactly this field.
|
|
56
|
+
const data = { kind: "local", folder: run.folder, flow: run.flow, task: run.task, provider: run.provider, model: run.model, maxTurns: run.maxTurns, github: run.github, packages: run.packages, image: run.image, resume: run.resume, trigger: { id: on.id, pattern: on.pattern } };
|
|
57
|
+
// Retention only; the deterministic repeat:<id>:<millis> jobId supplies dedup, so no jobId here, and
|
|
58
|
+
// scheduler jobs are not retried (DES-CRON-VIA-BULLMQ-SCHEDULER) so no attempts/backoff.
|
|
59
|
+
const opts = { removeOnComplete: { age: 24 * 3600 }, removeOnFail: { age: 7 * 24 * 3600 } };
|
|
60
|
+
|
|
61
|
+
return { schedulerId: on.id, name: "local", pattern: on.pattern, data, opts };
|
|
62
|
+
}
|