@edgehero/pi-dispatch 2.1.0 → 3.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 +41 -5
- package/README.md +11 -5
- package/deploy/docker-compose.yml +12 -0
- package/deploy/egress-proxy.conf +28 -3
- package/deploy/pi-dispatch-egress-proxy.container +8 -2
- package/package.json +9 -2
- package/src/allocation.mjs +731 -0
- package/src/backends.mjs +243 -0
- package/src/budget.mjs +40 -4
- package/src/cli.mjs +222 -11
- package/src/config.mjs +126 -5
- package/src/daemon-facts.mjs +3 -0
- package/src/deployment-venue.mjs +1 -0
- package/src/doctor.mjs +2280 -195
- package/src/dollar-budget.mjs +373 -0
- package/src/dollar-fingerprint.mjs +83 -0
- package/src/egress-cli.mjs +316 -0
- package/src/egress-proxy-state.mjs +35 -5
- package/src/egress.mjs +12 -0
- package/src/env-allowlist.mjs +125 -6
- package/src/env-file.mjs +194 -25
- package/src/envelope.mjs +413 -0
- package/src/exit-code.mjs +22 -0
- package/src/fleet-lease.mjs +85 -25
- package/src/get-token.mjs +16 -5
- package/src/git-dirty.mjs +67 -0
- package/src/github-app-setup.mjs +6 -3
- package/src/github-host.mjs +5 -3
- package/src/host-pi.mjs +1 -1
- package/src/identity.mjs +2 -1
- package/src/image-preflight.mjs +98 -24
- package/src/image-ref.mjs +37 -0
- package/src/import-pi.mjs +4 -2
- package/src/index.mjs +407 -62
- package/src/init.mjs +18 -0
- package/src/job-id.mjs +26 -3
- package/src/live-probes.mjs +24 -9
- package/src/model-catalog.mjs +297 -0
- package/src/model-endpoints.mjs +671 -0
- package/src/model-ref.mjs +151 -0
- package/src/models-json.mjs +268 -0
- package/src/money.mjs +144 -0
- package/src/octokit-log.mjs +65 -0
- package/src/outbox-plan.mjs +218 -0
- package/src/outbox.mjs +29 -9
- package/src/output-cap.mjs +157 -0
- package/src/pause-windows.mjs +81 -2
- package/src/pi-model-loader.mjs +77 -0
- package/src/podman-stack.mjs +16 -3
- package/src/portfolio-snapshot.mjs +304 -0
- package/src/prepare-local.mjs +247 -12
- package/src/prepare.mjs +35 -3
- package/src/priorities.mjs +569 -0
- package/src/processor.mjs +599 -170
- package/src/project-id.mjs +17 -0
- package/src/projects.mjs +238 -0
- package/src/provider-steering.mjs +179 -65
- package/src/queue.mjs +111 -6
- package/src/reserved-env.mjs +31 -0
- package/src/run-container.mjs +59 -5
- package/src/run-history.mjs +379 -24
- package/src/run-mirror.mjs +30 -0
- package/src/runtime-settings.mjs +104 -9
- package/src/schedules.mjs +33 -1
- package/src/scoped-limits.mjs +447 -27
- package/src/service.mjs +15 -4
- package/src/session-store.mjs +131 -6
- package/src/start.mjs +528 -40
- package/src/triggers-file.mjs +65 -4
- package/src/triggers.mjs +141 -11
- package/src/up.mjs +308 -34
- package/src/valkey-endpoint.mjs +3 -2
package/src/runtime-settings.mjs
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import * as nodeFs from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
3
|
import { ensureUnderAccountRoot } from "./config.mjs";
|
|
4
|
+
import { findDuplicateKey } from "./json-duplicates.mjs";
|
|
5
|
+
import { checkDollarInvariant, parseUsdMicros } from "./money.mjs";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* Runtime-settings overlay: the shared, durable truth between the admin extension and the worker
|
|
7
|
-
* (INT-CONFIG-OVERLAY-CONTRACT, DES-RUNTIME-SETTINGS-FILE-OVERLAY). A flat `settings.json` with
|
|
9
|
+
* (INT-CONFIG-OVERLAY-CONTRACT, DES-RUNTIME-SETTINGS-FILE-OVERLAY). A flat `settings.json` with fourteen
|
|
8
10
|
* optional keys -- `model`, `provider` (non-empty strings), `maxTurns`, `dailyCap`, `weeklyCap`,
|
|
9
11
|
* `monthlyCap`, `maxTokens`, `dailyTokenCap` (int >= 1), `concurrency` (int 1-10), `softHoldPct`
|
|
10
|
-
* (int 1-99)
|
|
11
|
-
* (
|
|
12
|
+
* (int 1-99), and the dollar keys `maxCostUsd`, `dailyCostUsd`, `weeklyCostUsd`, `monthlyCostUsd`
|
|
13
|
+
* (a decimal string or number, `parseUsdMicros`) -- read by the worker at each job start and written
|
|
14
|
+
* atomically by the admin extension (tmp + rename). `maxTokens`/`dailyTokenCap` are the optional token
|
|
15
|
+
* controls (issue #25); the dollar keys are issue #501's (a window needs `maxCostUsd`, checked on the merged
|
|
16
|
+
* values by `resolveSettings`).
|
|
12
17
|
*
|
|
13
18
|
* `readOverlay` NEVER throws: a bad settings file returns a discriminated `{ invalid }` rather than an
|
|
14
19
|
* exception, so the processor RETURNS a policy refusal (`settings-overlay-invalid`) instead of letting
|
|
@@ -25,7 +30,7 @@ import { ensureUnderAccountRoot } from "./config.mjs";
|
|
|
25
30
|
* Custom: overlay validated inline per config.mjs precedent; zod not in deps
|
|
26
31
|
*/
|
|
27
32
|
|
|
28
|
-
export const KNOWN_KEYS = ["model", "provider", "maxTurns", "dailyCap", "weeklyCap", "monthlyCap", "maxTokens", "dailyTokenCap", "concurrency", "softHoldPct"];
|
|
33
|
+
export const KNOWN_KEYS = ["model", "provider", "maxTurns", "dailyCap", "weeklyCap", "monthlyCap", "maxTokens", "dailyTokenCap", "concurrency", "softHoldPct", "maxCostUsd", "dailyCostUsd", "weeklyCostUsd", "monthlyCostUsd"];
|
|
29
34
|
|
|
30
35
|
/**
|
|
31
36
|
* Overlay keys the OVERLAY accepts but no model-callable tool may set. `secretProfiles` maps a profile name
|
|
@@ -43,6 +48,39 @@ export const KNOWN_KEYS = ["model", "provider", "maxTurns", "dailyCap", "weeklyC
|
|
|
43
48
|
*/
|
|
44
49
|
export const OPERATOR_ONLY_KEYS = ["secretProfiles"];
|
|
45
50
|
|
|
51
|
+
// The dollar keys' near-miss spellings (issue #501), already normalised (`overlayKeyShape`), each mapped to the key it
|
|
52
|
+
// meant: the four keys, their env names (`PI_MAX_COST_USD` pasted into settings.json is the likeliest slip of all),
|
|
53
|
+
// the micro-dollar spelling, `costUsd`, and the bare `maxCost` forms.
|
|
54
|
+
const DOLLAR_NEAR_MISSES = Object.freeze({
|
|
55
|
+
maxcostusd: "maxCostUsd", pimaxcostusd: "maxCostUsd", maxcostmicros: "maxCostUsd", costusd: "maxCostUsd", maxcost: "maxCostUsd",
|
|
56
|
+
dailycostusd: "dailyCostUsd", pidailycostusd: "dailyCostUsd", dailycostmicros: "dailyCostUsd", dailycost: "dailyCostUsd",
|
|
57
|
+
weeklycostusd: "weeklyCostUsd", piweeklycostusd: "weeklyCostUsd", weeklycostmicros: "weeklyCostUsd", weeklycost: "weeklyCostUsd",
|
|
58
|
+
monthlycostusd: "monthlyCostUsd", pimonthlycostusd: "monthlyCostUsd", monthlycostmicros: "monthlyCostUsd", monthlycost: "monthlyCostUsd",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// A small FIXED look-alike table, to ASCII: Cyrillic а е о р с у х к м т н в і and Greek ο α ε ρ ν τ κ ι (lowercase
|
|
62
|
+
// forms; the key is lowercased first, which maps their capitals onto these). Fullwidth forms are mapped by code point.
|
|
63
|
+
const LOOK_ALIKES = new Map(Object.entries({
|
|
64
|
+
"\u0430": "a", "\u0435": "e", "\u043E": "o", "\u0440": "p", "\u0441": "c", "\u0443": "y", "\u0445": "x", "\u043A": "k", "\u043C": "m", "\u0442": "t", "\u043D": "h", "\u0432": "b", "\u0456": "i",
|
|
65
|
+
"\u03BF": "o", "\u03B1": "a", "\u03B5": "e", "\u03C1": "p", "\u03BD": "v", "\u03C4": "t", "\u03BA": "k", "\u03B9": "i",
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* An overlay key's shape for the dollar near-miss check (issue #501): fullwidth forms to ASCII, lowercased, the fixed
|
|
70
|
+
* look-alike table applied, and the separators `_`, `-`, `.` and space dropped. Nothing else is removed, so a key with
|
|
71
|
+
* any other character (`host_ü`, `notés`) keeps it and can never EQUAL a target. That is the whole rule: the overlay
|
|
72
|
+
* refuses only an exact match after this, never a subsequence or a distance, because a refusal here stops every job
|
|
73
|
+
* and a false one is worse than a drop. The trigger loader keeps its own, wider sweep (`validateRunModel`): a
|
|
74
|
+
* triggers file is reviewed content refused at load, where a false hit costs an edit, not a day of jobs.
|
|
75
|
+
*/
|
|
76
|
+
function overlayKeyShape(key) {
|
|
77
|
+
let out = "";
|
|
78
|
+
for (const ch of key.replace(/[\uFF01-\uFF5E]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0)).toLowerCase()) {
|
|
79
|
+
out += LOOK_ALIKES.get(ch) ?? ch;
|
|
80
|
+
}
|
|
81
|
+
return out.replace(/[_\-. ]/g, "");
|
|
82
|
+
}
|
|
83
|
+
|
|
46
84
|
function isNonEmptyString(value) {
|
|
47
85
|
return typeof value === "string" && value.trim() !== "";
|
|
48
86
|
}
|
|
@@ -127,14 +165,38 @@ function validateOverlay(candidate, log) {
|
|
|
127
165
|
overlay[key] = { ...value };
|
|
128
166
|
break;
|
|
129
167
|
}
|
|
168
|
+
case "maxCostUsd":
|
|
169
|
+
case "dailyCostUsd":
|
|
170
|
+
case "weeklyCostUsd":
|
|
171
|
+
case "monthlyCostUsd":
|
|
172
|
+
// Issue #501: the per-job dollar cap and the three dollar windows. Kept AS WRITTEN (a string or a number)
|
|
173
|
+
// once it parses, so the file the admin writes back holds what the operator typed, never a converted
|
|
174
|
+
// number; the worker converts it to micro-dollars per job, exactly as it converts env's. A window needs
|
|
175
|
+
// `maxCostUsd`, a cross-key rule this per-key check cannot see: `resolveSettings` applies it to the
|
|
176
|
+
// merged values.
|
|
177
|
+
try {
|
|
178
|
+
parseUsdMicros(value, key);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return { invalid: error.message };
|
|
181
|
+
}
|
|
182
|
+
overlay[key] = value;
|
|
183
|
+
break;
|
|
130
184
|
case "softHoldPct":
|
|
131
185
|
// A percentage of each active cap; 100 would equal the hard wall (no band) and 0 has no meaning,
|
|
132
186
|
// so the enforced band is 1-99. Absence disables the soft-hold entirely.
|
|
133
187
|
if (!isIntInRange(value, 1, 99)) return { invalid: "softHoldPct must be an integer 1-99" };
|
|
134
188
|
overlay[key] = value;
|
|
135
189
|
break;
|
|
136
|
-
default:
|
|
190
|
+
default: {
|
|
191
|
+
// Issue #501: a near miss of a DOLLAR key is refused when its shape (`overlayKeyShape`: case, the
|
|
192
|
+
// separators, a fixed look-alike table) EXACTLY equals a known misspelling: `maxcostusd`, `MaxCostUsd`,
|
|
193
|
+
// `max_cost_usd`, `max<U+0421>ostUsd`, `PI_MAX_COST_USD`, `maxCostMicros`, `costUsd`. Dropped and logged,
|
|
194
|
+
// the operator's cap would not apply while the file reads as setting one. Every other unknown key keeps
|
|
195
|
+
// the forward-compatible drop-and-log, `host_<U+00FC>` and `maxCostCents` included.
|
|
196
|
+
const meant = Object.hasOwn(DOLLAR_NEAR_MISSES, overlayKeyShape(key)) ? DOLLAR_NEAR_MISSES[overlayKeyShape(key)] : undefined;
|
|
197
|
+
if (meant !== undefined) return { invalid: `${key} is not a settings key -- did you mean ${meant}? A dollar cap the worker drops would not apply while the file reads as set` };
|
|
137
198
|
log("settings_overlay_unknown_key", { key });
|
|
199
|
+
}
|
|
138
200
|
}
|
|
139
201
|
}
|
|
140
202
|
return { overlay };
|
|
@@ -163,15 +225,20 @@ export function readOverlay(path, { fs = nodeFs, log = () => {} } = {}) {
|
|
|
163
225
|
} catch {
|
|
164
226
|
return { invalid: "settings file is not valid JSON" };
|
|
165
227
|
}
|
|
228
|
+
// Issue #501: a duplicate key is refused, the triggers file's rule. JSON.parse keeps the LAST value, so
|
|
229
|
+
// `{"maxCostUsd":"1","maxCostUsd":"999"}` would run under $999 while the first line reads as $1. Only the KEY
|
|
230
|
+
// is named, never a value. `writeOverlay` serialises an object, so a file it wrote never has one.
|
|
231
|
+
const duplicate = findDuplicateKey(text);
|
|
232
|
+
if (duplicate) return { invalid: `settings file has a duplicate key ${JSON.stringify(duplicate.key)}: JSON keeps the last value, so the file that reads and the file that runs differ` };
|
|
166
233
|
return validateOverlay(parsed, log);
|
|
167
234
|
}
|
|
168
235
|
|
|
169
236
|
/**
|
|
170
|
-
* Resolve the
|
|
237
|
+
* Resolve the fourteen effective settings from `config` and a validated `overlay`: overlay value where the
|
|
171
238
|
* overlay sets it, else the config value. Precedence is overlay > env > default only -- `config`
|
|
172
|
-
* already carries env > default. `weeklyCap`/`monthlyCap`/`maxTokens`/`dailyTokenCap`/`softHoldPct`
|
|
173
|
-
* resolve to `null` (their config value when unset), meaning that window / cap / band is
|
|
174
|
-
* `job.data` is NOT merged here; that layer is the processor's.
|
|
239
|
+
* already carries env > default. `weeklyCap`/`monthlyCap`/`maxTokens`/`dailyTokenCap`/`softHoldPct` and the
|
|
240
|
+
* four dollar keys may resolve to `null` (their config value when unset), meaning that window / cap / band is
|
|
241
|
+
* disabled. `job.data` is NOT merged here; that layer is the processor's.
|
|
175
242
|
*/
|
|
176
243
|
export function effectiveSettings(config, overlay) {
|
|
177
244
|
const o = overlay ?? {};
|
|
@@ -186,9 +253,37 @@ export function effectiveSettings(config, overlay) {
|
|
|
186
253
|
dailyTokenCap: o.dailyTokenCap ?? config.dailyTokenCap,
|
|
187
254
|
concurrency: o.concurrency ?? config.concurrency,
|
|
188
255
|
softHoldPct: o.softHoldPct ?? config.softHoldPct,
|
|
256
|
+
maxCostUsd: o.maxCostUsd ?? config.maxCostUsd,
|
|
257
|
+
dailyCostUsd: o.dailyCostUsd ?? config.dailyCostUsd,
|
|
258
|
+
weeklyCostUsd: o.weeklyCostUsd ?? config.weeklyCostUsd,
|
|
259
|
+
monthlyCostUsd: o.monthlyCostUsd ?? config.monthlyCostUsd,
|
|
189
260
|
};
|
|
190
261
|
}
|
|
191
262
|
|
|
263
|
+
/**
|
|
264
|
+
* The per-job settings the worker runs with, from `config` and a `readOverlay` result: `{ invalid }` when the
|
|
265
|
+
* overlay is invalid or the MERGED values break a cross-key rule, else the effective settings plus
|
|
266
|
+
* `secretProfiles`. Pure, so the processor's one settings read (start.mjs `getSettings`) is testable without
|
|
267
|
+
* booting a worker.
|
|
268
|
+
*
|
|
269
|
+
* The dollar invariant (issue #501) runs HERE, on merged values, because `validateOverlay` sees one key at a
|
|
270
|
+
* time and the overlay alone: a window in the overlay with the per-job cap in env is valid, and a check of
|
|
271
|
+
* the overlay alone would refuse it. An invalid result becomes the `settings-overlay-invalid` refusal, before
|
|
272
|
+
* any spend.
|
|
273
|
+
*
|
|
274
|
+
* `secretProfiles` (REQ-TRIGGER-SECRETS) rides ALONGSIDE the effective keys rather than inside them:
|
|
275
|
+
* `effectiveSettings` resolves `overlay > env` over a fixed literal whose tests pin its key set, and this key
|
|
276
|
+
* deliberately has no such precedence (a name declared in both sources is refused per delivery, not silently
|
|
277
|
+
* won by either).
|
|
278
|
+
*/
|
|
279
|
+
export function resolveSettings(config, read) {
|
|
280
|
+
if (read.invalid) return { invalid: read.invalid };
|
|
281
|
+
const settings = effectiveSettings(config, read.overlay);
|
|
282
|
+
const broken = checkDollarInvariant(settings);
|
|
283
|
+
if (broken) return broken;
|
|
284
|
+
return { ...settings, secretProfiles: read.overlay?.secretProfiles ?? {} };
|
|
285
|
+
}
|
|
286
|
+
|
|
192
287
|
/**
|
|
193
288
|
* Write `candidate` to `path` atomically. Validates with the same rules as `readOverlay` (an invalid
|
|
194
289
|
* candidate returns `{ invalid }` and touches no file; an empty `{}` candidate is valid), serialises
|
package/src/schedules.mjs
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { existsSync as fsExistsSync, readFileSync as fsReadFileSync } from "node:fs";
|
|
16
16
|
import { isAbsolute } from "node:path";
|
|
17
17
|
import { configError } from "./config.mjs";
|
|
18
|
+
import { triggerCapAboveDeployment } from "./money.mjs";
|
|
18
19
|
import { parseTriggers } from "./triggers.mjs";
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -32,6 +33,16 @@ export function loadSchedules(config, { readFileSync = fsReadFileSync, existsSyn
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
const triggers = parseTriggers(readFileSync(path, "utf8"), path);
|
|
36
|
+
// Issue #501: a trigger's run.maxCostUsd above this deployment's PI_MAX_COST_USD refuses the file, at boot
|
|
37
|
+
// (an operator is present) and on a live reload (the running schedules are kept). Every kind is checked,
|
|
38
|
+
// not only cron: the worker runs the forge jobs too, and this is the one load it does of the file. Here and
|
|
39
|
+
// not in parseTriggers, which is pure and shared with the receiver, which never sees the worker's env. The
|
|
40
|
+
// job itself runs under the smaller of the two whatever this finds (effectiveJobOf), so this is the loud
|
|
41
|
+
// half, never the only one. Env only: an overlay cap can change between jobs and is applied per job.
|
|
42
|
+
const above = triggerCapAboveDeployment(triggers, config.maxCostUsd);
|
|
43
|
+
if (above !== -1) {
|
|
44
|
+
throw configError(`trigger at index ${above}: run.maxCostUsd is above this deployment's PI_MAX_COST_USD. A trigger can only narrow the per-job cost cap, so lower it or remove it (${path})`);
|
|
45
|
+
}
|
|
35
46
|
|
|
36
47
|
return triggers.filter((t) => t.on.type === "cron").map((t) => normalizeCronSchedule(t, path, existsSync, fleet));
|
|
37
48
|
}
|
|
@@ -64,6 +75,23 @@ export function authoredCron(config, { readFileSync = fsReadFileSync, existsSync
|
|
|
64
75
|
}
|
|
65
76
|
}
|
|
66
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The host paths a job container can see (issue #504 part B), for `envelopeInsideJobPaths`: every cron trigger's
|
|
80
|
+
* `run.folder` and every trigger's `run.skillsDir` in the triggers file, the `PI_DISPATCH_RUN_ROOTS` roots and
|
|
81
|
+
* `PI_GLOBAL_PI_DIR`. Read from the FILE, not from this host's served schedules: a folder another host serves today may
|
|
82
|
+
* be this host's after an edit, and the check costs nothing to be wide. Throws when the triggers file does not parse.
|
|
83
|
+
*/
|
|
84
|
+
export function envelopeJobPaths(config, { readFileSync = fsReadFileSync, existsSync = fsExistsSync } = {}) {
|
|
85
|
+
const out = { cronFolders: [], runRoots: [...(config.dispatchRunRoots ?? [])], skillsDirs: [], globalPiDir: config.globalPiDir ?? null };
|
|
86
|
+
const path = config.triggersFile;
|
|
87
|
+
if (path === null || path === undefined || !existsSync(path)) return out;
|
|
88
|
+
for (const t of parseTriggers(readFileSync(path, "utf8"), path)) {
|
|
89
|
+
if (t?.on?.type === "cron" && typeof t.run?.folder === "string") out.cronFolders.push(t.run.folder);
|
|
90
|
+
if (typeof t?.run?.skillsDir === "string") out.skillsDirs.push(t.run.skillsDir);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
67
95
|
/**
|
|
68
96
|
* Split a schedule set into the triggers THIS host serves and the ones it does not (issue #57).
|
|
69
97
|
*
|
|
@@ -150,7 +178,11 @@ function normalizeCronSchedule({ on, run }, path, existsSync, fleet) {
|
|
|
150
178
|
// key. A command trigger carries no flow/task at all (the validator enforces the XOR), so those two
|
|
151
179
|
// keys hold undefined here and drop at JSON serialization -- the command schedule's data is exactly
|
|
152
180
|
// kind/folder/command plus the shared fields.
|
|
153
|
-
|
|
181
|
+
//
|
|
182
|
+
// `portfolio` (issue #505) is conditional like `command`, so an unflagged schedule grows no key, and it rides at JOB
|
|
183
|
+
// level, never inside `trigger`: `trigger` is copied verbatim into `/job/event.json`, and whether a job may send a
|
|
184
|
+
// budget plan is the worker's decision to re-check against the live file, not a fact for the agent to read.
|
|
185
|
+
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.backend !== undefined && { backend: run.backend }), ...(run.excludeTools !== undefined && { excludeTools: run.excludeTools }), ...(run.models !== undefined && { models: run.models }), ...(run.maxCostUsd !== undefined && { maxCostUsd: run.maxCostUsd }), ...(run.skillsDir !== undefined && { skillsDir: run.skillsDir }), ...(run.secrets !== undefined && { secrets: run.secrets }), ...(run.secretsProfile !== undefined && { secretsProfile: run.secretsProfile }), resume: run.resume, ...(run.portfolio !== undefined && { portfolio: run.portfolio }), trigger: { id: on.id, pattern: on.pattern } };
|
|
154
186
|
// Retention only; the deterministic repeat:<id>:<millis> jobId supplies dedup, so no jobId here, and
|
|
155
187
|
// scheduler jobs are not retried (DES-CRON-VIA-BULLMQ-SCHEDULER) so no attempts/backoff.
|
|
156
188
|
const opts = { removeOnComplete: { age: 24 * 3600 }, removeOnFail: { age: 7 * 24 * 3600 } };
|