@edgehero/pi-dispatch 4.3.0 → 5.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/.env.example +94 -4
- package/README.md +20 -7
- package/deploy/console.service +52 -0
- package/deploy/docker-compose.yml +3 -1
- package/deploy/pi-dispatch-valkey.container +4 -2
- package/deploy/sandbox-gateway.service +23 -0
- package/deploy/worker-env-wrapper.cmd +6 -0
- package/deploy/worker-env-wrapper.sh +10 -1
- package/package.json +117 -19
- package/src/agent-snapshot.mjs +49 -0
- package/src/answerer-runtime.mjs +194 -0
- package/src/answerer.mjs +178 -0
- package/src/approval-policy.mjs +76 -0
- package/src/approval-runtime.mjs +188 -0
- package/src/approval-session.mjs +137 -0
- package/src/approval-state.mjs +170 -0
- package/src/auth-cli.mjs +86 -0
- package/src/auth-connections.mjs +145 -0
- package/src/auth-policy.mjs +32 -0
- package/src/auth-store.mjs +97 -0
- package/src/azure-host.mjs +66 -4
- package/src/azure-prompt.mjs +5 -4
- package/src/backend-conformance.mjs +34 -2
- package/src/backend-local.mjs +103 -34
- package/src/backend-openshell.mjs +1540 -0
- package/src/backend-podman.mjs +63 -2
- package/src/backend-registry.mjs +61 -4
- package/src/backends.mjs +395 -6
- package/src/bullmq-internals.mjs +208 -0
- package/src/canary-cli.mjs +28 -0
- package/src/canary-lock.mjs +41 -0
- package/src/canary-runtime.mjs +237 -0
- package/src/canary.mjs +53 -0
- package/src/cancel-cli.mjs +47 -34
- package/src/cancel-state.mjs +213 -15
- package/src/capabilities.mjs +36 -1
- package/src/capacity-cli.mjs +28 -8
- package/src/capacity-records.mjs +75 -16
- package/src/capacity.mjs +65 -0
- package/src/chat-cli.mjs +288 -0
- package/src/chat-fork.mjs +547 -0
- package/src/chat-mode.mjs +156 -0
- package/src/chat-view.mjs +720 -0
- package/src/checkpoint-paths.mjs +113 -0
- package/src/cli.mjs +128 -41
- package/src/comment-tables.mjs +322 -0
- package/src/community-auth-adapters.mjs +87 -0
- package/src/community-auth-helper.mjs +76 -0
- package/src/community-auth-metadata.mjs +6 -0
- package/src/community-auth-output-pins.mjs +38 -0
- package/src/community-auth-patches.mjs +168 -0
- package/src/community-auth-pins.mjs +38 -0
- package/src/config.mjs +199 -30
- package/src/connection.mjs +83 -7
- package/src/console-answer.mjs +113 -0
- package/src/console-audit.mjs +604 -0
- package/src/console-bootstrap.mjs +159 -0
- package/src/console-client.mjs +848 -0
- package/src/console-probe.mjs +106 -0
- package/src/console-tokens.mjs +459 -0
- package/src/container-names.mjs +51 -0
- package/src/container-spec.mjs +20 -1
- package/src/cpu-reserve.mjs +4 -1
- package/src/defer-reasons.mjs +200 -0
- package/src/defer-write.mjs +208 -0
- package/src/deployment-venue.mjs +23 -6
- package/src/docker-run.mjs +11 -0
- package/src/doctor-bundles.mjs +130 -0
- package/src/doctor-model-proposals.mjs +109 -0
- package/src/doctor-openshell.mjs +238 -0
- package/src/doctor-results.mjs +602 -0
- package/src/doctor-spawner.mjs +215 -0
- package/src/doctor.mjs +1663 -198
- package/src/dollar-budget.mjs +212 -3
- package/src/endpoint-status.mjs +100 -0
- package/src/env-allowlist.mjs +103 -9
- package/src/env-file.mjs +6 -4
- package/src/file-expect.mjs +47 -0
- package/src/forgejo-auth.mjs +4 -0
- package/src/forgejo-host.mjs +127 -31
- package/src/forgejo-prompt.mjs +5 -4
- package/src/forges.mjs +73 -15
- package/src/fork-job.mjs +58 -0
- package/src/fork-policy.mjs +23 -0
- package/src/fork-store.mjs +338 -0
- package/src/get-token.mjs +1 -1
- package/src/git-dirty.mjs +119 -14
- package/src/git-hardening.mjs +85 -0
- package/src/github-host.mjs +31 -6
- package/src/github-prompt.mjs +7 -16
- package/src/gitlab-host.mjs +42 -3
- package/src/gitlab-prompt.mjs +5 -4
- package/src/held-document.mjs +23 -0
- package/src/held-replies.mjs +325 -0
- package/src/held-settle.mjs +266 -0
- package/src/hook-runs.mjs +188 -0
- package/src/host-budget.mjs +8 -5
- package/src/host-clone.mjs +72 -0
- package/src/host-loads.mjs +557 -0
- package/src/host-registry.mjs +66 -6
- package/src/host-telemetry.mjs +92 -0
- package/src/http-body.mjs +78 -0
- package/src/image-preflight.mjs +156 -13
- package/src/import-pi.mjs +60 -172
- package/src/inbox-relay.mjs +482 -0
- package/src/inbox-settle.mjs +196 -0
- package/src/index.mjs +740 -129
- package/src/init.mjs +35 -5
- package/src/job-data-version.mjs +75 -0
- package/src/job-id.mjs +30 -15
- package/src/job-layout.mjs +22 -0
- package/src/job-size.mjs +16 -0
- package/src/live-jobs.mjs +14 -6
- package/src/live-probes.mjs +6 -2
- package/src/live-transcript.mjs +872 -0
- package/src/loaded-record.mjs +609 -0
- package/src/mcp-config.mjs +1 -1
- package/src/mcp-preflight.mjs +36 -5
- package/src/model-attempts.mjs +84 -0
- package/src/model-catalog.mjs +83 -0
- package/src/model-cooldowns.mjs +22 -0
- package/src/model-failover-protocol.mjs +11 -0
- package/src/model-failover.mjs +134 -0
- package/src/model-observation.mjs +50 -0
- package/src/model-proposal-protocol.mjs +21 -0
- package/src/model-proposals.mjs +75 -0
- package/src/model-ref.mjs +67 -10
- package/src/model-roles-file.mjs +24 -0
- package/src/model-roles.mjs +73 -0
- package/src/model-status.mjs +34 -0
- package/src/models-json.mjs +4 -1
- package/src/money.mjs +1 -1
- package/src/oauth-openai.mjs +121 -0
- package/src/older-state.mjs +249 -0
- package/src/on-failure.mjs +17 -13
- package/src/open-browser.mjs +31 -2
- package/src/opener-cli.mjs +135 -0
- package/src/opener-protocol.mjs +27 -0
- package/src/opener-terminal.mjs +128 -0
- package/src/openshell-cli.mjs +155 -0
- package/src/openshell-policy.mjs +163 -0
- package/src/outbox.mjs +14 -0
- package/src/output-cap.mjs +1 -0
- package/src/package-canary-record.mjs +26 -0
- package/src/package-canary.mjs +135 -0
- package/src/package-names.mjs +9 -0
- package/src/package-stage.mjs +418 -0
- package/src/packages.mjs +24 -16
- package/src/parked.mjs +2 -1
- package/src/pause-windows.mjs +136 -17
- package/src/podman-stack.mjs +40 -14
- package/src/portfolio-snapshot.mjs +26 -7
- package/src/prepare-github.mjs +41 -77
- package/src/prepare-local.mjs +32 -2
- package/src/prepare.mjs +45 -11
- package/src/private-store-boundary.mjs +70 -0
- package/src/processor.mjs +1554 -167
- package/src/projects.mjs +23 -0
- package/src/prompt-fence.mjs +18 -0
- package/src/provider-key.mjs +7 -0
- package/src/provider-steering.mjs +1 -0
- package/src/question-context.mjs +35 -0
- package/src/question-group-runtime.mjs +91 -0
- package/src/question-groups.mjs +171 -0
- package/src/queue.mjs +296 -55
- package/src/reason-catalog.mjs +448 -0
- package/src/receiver-status.mjs +230 -0
- package/src/record-reason.mjs +37 -0
- package/src/recovery-queue.mjs +566 -0
- package/src/redact-secrets.mjs +219 -0
- package/src/reserve-estimate.mjs +101 -0
- package/src/reserved-env.mjs +36 -1
- package/src/retention.mjs +67 -0
- package/src/run-budget-ledger.mjs +283 -0
- package/src/run-checkpoint.mjs +627 -0
- package/src/run-container.mjs +749 -33
- package/src/run-history.mjs +791 -45
- package/src/run-mirror.mjs +75 -7
- package/src/run-progress.mjs +751 -0
- package/src/runtime-observations.mjs +3 -1
- package/src/runtime-settings.mjs +272 -37
- package/src/sandbox-cli.mjs +78 -2
- package/src/sandbox-deployment.mjs +51 -0
- package/src/sandbox-gateway-protocol.mjs +378 -0
- package/src/sandbox-hosts.mjs +100 -0
- package/src/sandbox-inventory.mjs +83 -0
- package/src/sandbox-policy.mjs +225 -0
- package/src/sandbox-prepare-requests.mjs +145 -0
- package/src/sandbox-prepare.mjs +285 -0
- package/src/sandbox-refs.mjs +70 -0
- package/src/sandbox-session-monitor.mjs +4 -0
- package/src/sandbox-session-watch.mjs +124 -0
- package/src/sandbox-sessions.mjs +161 -0
- package/src/sandbox-store.mjs +63 -6
- package/src/sandbox-workspace-guard.mjs +39 -0
- package/src/sandbox.mjs +27 -200
- package/src/scheduler-stall-guard.mjs +14 -8
- package/src/schedules.mjs +19 -6
- package/src/secret-bundles.mjs +245 -0
- package/src/secret-profiles.mjs +103 -3
- package/src/secrets.mjs +19 -9
- package/src/service-env.mjs +38 -5
- package/src/service.mjs +638 -106
- package/src/session-key.mjs +60 -0
- package/src/session-store.mjs +168 -10
- package/src/setup-contract.mjs +40 -0
- package/src/setup-egress.mjs +113 -0
- package/src/setup-host-entry.mjs +33 -0
- package/src/setup-host.mjs +242 -0
- package/src/setup-lock.mjs +41 -0
- package/src/size-records.mjs +15 -3
- package/src/start.mjs +1353 -135
- package/src/status-comment.mjs +415 -0
- package/src/stderr-transcript.mjs +318 -0
- package/src/steer-ack.mjs +122 -0
- package/src/steer-cli.mjs +213 -0
- package/src/steer-framing.mjs +185 -0
- package/src/steer-inbox.mjs +693 -0
- package/src/store-version.mjs +139 -0
- package/src/subscription-config.mjs +56 -0
- package/src/subscription-limits.mjs +3 -0
- package/src/subscription-quota.mjs +259 -0
- package/src/subscription-runtime.mjs +123 -0
- package/src/subscription-status.mjs +42 -0
- package/src/subscription-wait.mjs +11 -0
- package/src/subscriptions.mjs +7 -1
- package/src/thinking-level.mjs +19 -0
- package/src/touch-outbox.mjs +61 -0
- package/src/touch-state.mjs +133 -0
- package/src/triggers-file.mjs +299 -39
- package/src/triggers.mjs +782 -40
- package/src/trust-paths.mjs +171 -0
- package/src/up.mjs +398 -51
- package/src/updates-cli.mjs +61 -0
- package/src/updates-look.mjs +127 -0
- package/src/updates-reconcile.mjs +182 -0
- package/src/updates-subscription-adapters.mjs +8 -0
- package/src/updates.mjs +252 -0
- package/src/valkey-auth.mjs +32 -9
- package/src/valkey-endpoint.mjs +14 -4
- package/src/wait-for.mjs +1 -1
- package/src/wait-state.mjs +62 -3
- package/src/watch-board.mjs +38 -0
- package/src/watch-closer.mjs +1 -1
- package/src/watch-contract.mjs +102 -0
- package/src/watch-decision.mjs +78 -0
- package/src/watch-drainer.mjs +14 -0
- package/src/watch-issue-adapter.mjs +44 -0
- package/src/watch-issue.mjs +111 -0
- package/src/watch-record.mjs +10 -0
- package/src/watch-runtime.mjs +167 -0
- package/src/watch-store.mjs +183 -0
- package/src/watch-trace.mjs +34 -0
- package/src/workspace-checkpoint.mjs +1750 -0
- package/src/zero-touch-fixes.mjs +99 -0
- package/src/zero-touch.mjs +225 -0
- package/src/repeat-slot.mjs +0 -13
package/src/triggers-file.mjs
CHANGED
|
@@ -35,18 +35,20 @@
|
|
|
35
35
|
* hook with nobody at the keyboard, and the thing it races (an operator edit, a sibling job's
|
|
36
36
|
* disarm) clears in milliseconds.
|
|
37
37
|
*
|
|
38
|
-
* `disarmTrigger` is also deliberately NARROWER than `writeTriggers`: it refuses
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
38
|
+
* `disarmTrigger` is also deliberately NARROWER than `writeTriggers`: it refuses a MISSING file too, where the operator
|
|
39
|
+
* CRUD path scaffolds one (a missing file plus "add trigger" is a first trigger), since a disarm has nothing to add to
|
|
40
|
+
* nothing. A file neither can READ or PARSE, both refuse: not being able to see or understand a file is no reason to
|
|
41
|
+
* think it empty, and overwriting it would destroy the operator's trigger set.
|
|
42
42
|
*
|
|
43
43
|
* Custom: exclusive-create lockfile per session-store.mjs precedent; no proper-lockfile dependency
|
|
44
44
|
* (repo keeps runtime deps minimal, and the two-writer case needs no lease/renewal machinery)
|
|
45
45
|
*/
|
|
46
46
|
|
|
47
47
|
import nodeFs from "node:fs";
|
|
48
|
-
import { parseTriggers } from "./triggers.mjs";
|
|
48
|
+
import { parseTriggers, parseTriggersDocument, triggersFileNewer, triggersFileVersionFor, triggersFileVersionProblem } from "./triggers.mjs";
|
|
49
|
+
import { heldDocument } from "./held-document.mjs";
|
|
49
50
|
import { findDuplicateKey } from "./json-duplicates.mjs";
|
|
51
|
+
import { expectRefusal } from "./file-expect.mjs";
|
|
50
52
|
|
|
51
53
|
/**
|
|
52
54
|
* A lock older than this is a crashed writer's, not a live one's: every write under it is a read,
|
|
@@ -136,9 +138,42 @@ function releaseLock(fd, triggersPath, fs, log) {
|
|
|
136
138
|
}
|
|
137
139
|
|
|
138
140
|
/** The one serializer: 2-space plus trailing newline, byte-identical to what the admin always wrote --
|
|
139
|
-
* both live-reload watchers and the operator's own diff read this file, so its shape is a contract.
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
* both live-reload watchers and the operator's own diff read this file, so its shape is a contract.
|
|
142
|
+
*
|
|
143
|
+
* `doc` is the parsed document the write started from, and every top-level key it carries besides
|
|
144
|
+
* `triggers` is written back unchanged, in its original order (issue #704). The file is a shared
|
|
145
|
+
* document, not a triggers-only one: the project secret bundles live beside the array, and a writer
|
|
146
|
+
* that rebuilt the file as `{ triggers }` deleted them on every console edit and every one-shot disarm,
|
|
147
|
+
* silently. `triggers` keeps its position when the document had it, and leads when it did not (a file
|
|
148
|
+
* started from nothing, which is the historical file). A file whose only key is `triggers` comes
|
|
149
|
+
* out byte-identical to what this function always wrote.
|
|
150
|
+
*
|
|
151
|
+
* `version` (issue #761): when given, the writer OWNS the file's `version` and sets it to this value, in place when
|
|
152
|
+
* the document had one and first when it is added, and deletes it at 1 (version 1 is written as nothing). When not
|
|
153
|
+
* given (the disarm, whose whole write authority is one added key), `version` rides along like any other key. */
|
|
154
|
+
function serialize(triggersArray, doc = null, { version = null } = {}) {
|
|
155
|
+
const rest = doc !== null && typeof doc === "object" && !Array.isArray(doc) ? doc : {};
|
|
156
|
+
let next = Object.hasOwn(rest, "triggers") ? { ...rest, triggers: triggersArray } : { triggers: triggersArray, ...rest };
|
|
157
|
+
if (version !== null) {
|
|
158
|
+
const { version: _owned, ...without } = next;
|
|
159
|
+
if (version <= 1) next = without;
|
|
160
|
+
else if (Object.hasOwn(next, "version")) next = { ...next, version };
|
|
161
|
+
else next = { version, ...without };
|
|
162
|
+
}
|
|
163
|
+
return `${JSON.stringify(next, null, 2)}\n`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The exact text `writeTriggers` writes for the entries `next` over the document `nextDocument`: `serialize`, with the
|
|
168
|
+
* `version` the result needs under `versions` (the five tables of `triggersFileVersionFor`; `supported` is ignored).
|
|
169
|
+
* Exported for the console's pre-checks (issue #823), which preview a change and parse it before the operator confirms
|
|
170
|
+
* it: a document they serialized themselves carries no stamp, so under a version rule it reads as a version 1 file
|
|
171
|
+
* holding a version 2 value and is refused although this writer would write it correctly, and its preview keeps a
|
|
172
|
+
* hand-written `"version": 1` that this writer drops. One function for both, so the preview cannot drift from the write.
|
|
173
|
+
*/
|
|
174
|
+
export function triggersFileText(next, nextDocument, versions = {}) {
|
|
175
|
+
const rest = nextDocument !== null && typeof nextDocument === "object" && !Array.isArray(nextDocument) ? nextDocument : {};
|
|
176
|
+
return serialize(next, nextDocument, { version: triggersFileVersionFor({ ...rest, triggers: next }, versions) });
|
|
142
177
|
}
|
|
143
178
|
|
|
144
179
|
/** tmp + rename with writeOverlay's single EPERM retry (a Windows AV/indexer briefly holding the
|
|
@@ -189,11 +224,31 @@ function discardTmp(fs, tmp) {
|
|
|
189
224
|
* human keypress is the approval and CONST-TRIGGER-AUTHOR-GATE's principle holds. The worker's disarm
|
|
190
225
|
* does NOT come through here -- `disarmTrigger` below is its own, narrower entry.
|
|
191
226
|
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
227
|
+
* Only a file that holds nothing starts from an empty set: a missing one, or one of nothing but whitespace or a
|
|
228
|
+
* byte-order mark. A file that is there and cannot be read (EIO, EACCES, EISDIR) refuses with
|
|
229
|
+
* `{ invalid, unreadable: true }`, and one that holds something this writer cannot keep (`heldDocument`) with
|
|
230
|
+
* `{ invalid, malformed: true }`, both before `mutate` runs and with the file untouched.
|
|
231
|
+
* Returns `{ ok: true }`, or `{ invalid }` for a validation failure, a held lock or that read. A failure of the lock,
|
|
232
|
+
* the tmp file or the rename throws, the contract this function has always had: all three happen beside the file, so
|
|
233
|
+
* the old file is whole after any of them.
|
|
234
|
+
*
|
|
235
|
+
* `expect` (issue #642): the `{ path, text }` snapshots the change was built from (the admin's `writeInputs`, taken
|
|
236
|
+
* before an operator's confirm, e.g. the console's preview). Re-read INSIDE the lock, right before the rename, so the
|
|
237
|
+
* re-check and the rename are one critical section against every other writer of this file (the disarm included): a
|
|
238
|
+
* difference refuses with nothing written. Without it the write is byte-identical to what it always was; the lock
|
|
239
|
+
* already serialises a single read-modify-write, and `expect` widens that to the whole preview-to-confirm dialog.
|
|
240
|
+
*
|
|
241
|
+
* A file written by a newer pi-dispatch (issue #761) is refused BEFORE `mutate` and `mutateDocument` run, with
|
|
242
|
+
* `{ invalid, newer: true }` and the file untouched: this build cannot know what the newer one's fields mean, and the
|
|
243
|
+
* writer's own stamp (the lowest version the content needs) would otherwise LOWER the version under content it does
|
|
244
|
+
* not understand, after which the final validation would pass. Judged on the RAW `version` only, never the full
|
|
245
|
+
* parser, so a file with one bad entry can still be fixed; a broken `version` is overwritten by the writer's own. Then
|
|
246
|
+
* the writer owns `version`: it writes `triggersFileVersionFor` of the result (`triggersFileText`). `supported`,
|
|
247
|
+
* `runTable`, `blockTable`, `entryValueTable`, `runValueTable` and `blockValueTable` are the version rules, injectable
|
|
248
|
+
* for tests.
|
|
195
249
|
*/
|
|
196
|
-
export function writeTriggers({ triggersPath, mutate, fs = nodeFs, log = () => {}, now = () => Date.now() }) {
|
|
250
|
+
export function writeTriggers({ triggersPath, mutate, mutateDocument = null, expect = null, fs = nodeFs, log = () => {}, now = () => Date.now(), supported = undefined, runTable = undefined, blockTable = undefined, entryValueTable = undefined, runValueTable = undefined, blockValueTable = undefined }) {
|
|
251
|
+
const versions = { supported, runTable, blockTable, entryValueTable, runValueTable, blockValueTable };
|
|
197
252
|
const fd = takeLock(triggersPath, fs, log, now);
|
|
198
253
|
if (fd === null) {
|
|
199
254
|
// Immediate, not retried: the callers sit on the pi TUI event loop, and the holder is a write
|
|
@@ -202,40 +257,63 @@ export function writeTriggers({ triggersPath, mutate, fs = nodeFs, log = () => {
|
|
|
202
257
|
}
|
|
203
258
|
try {
|
|
204
259
|
let current = [];
|
|
260
|
+
// The whole parsed document, so the write keeps every top-level key it does not own (issue #704).
|
|
261
|
+
let doc = null;
|
|
205
262
|
// The text ONLY IF THE PARSE ACCEPTED IT, which is the scanner's whole contract: it never has to
|
|
206
263
|
// decide whether malformed input is malformed, so it is not asked. Assigning it before the parse
|
|
207
264
|
// would hand it a truncated file and get back a confident answer about a key, when what happened is
|
|
208
265
|
// that the file was cut in half.
|
|
209
266
|
let parsedText = null;
|
|
267
|
+
// Only a file that holds nothing may start from nothing. A file that is there and could not be read is not an empty one: folded
|
|
268
|
+
// into "missing", one failed read made `current` empty, the `expect` re-read below then saw the file as it was and
|
|
269
|
+
// passed, and the rename replaced every trigger with what `mutate` made of nothing, on an `{ ok: true }`. Returned, not
|
|
270
|
+
// thrown, so every caller says it in the words it says any refusal in, and before `mutate`, which is owed a real list.
|
|
271
|
+
let raw = null;
|
|
210
272
|
try {
|
|
211
|
-
|
|
212
|
-
|
|
273
|
+
raw = fs.readFileSync(triggersPath, "utf8");
|
|
274
|
+
} catch (err) {
|
|
275
|
+
if (err?.code !== "ENOENT") return { invalid: `the triggers file ${triggersPath} could not be read (${typeof err?.code === "string" ? err.code : "read-error"}), so its triggers cannot be kept; nothing was written`, unreadable: true };
|
|
276
|
+
}
|
|
277
|
+
if (raw !== null && raw.trim() !== "") {
|
|
278
|
+
// This used to start from empty for anything that did not parse, so one stray comma and the next add replaced every
|
|
279
|
+
// trigger with one, on an `{ ok: true }`, under a preview whose sentences never said so, only its diff. What the file holds is the
|
|
280
|
+
// operator's to fix; a refusal costs them the add and nothing else.
|
|
281
|
+
const { value, problem } = heldDocument(raw, "triggers");
|
|
282
|
+
// A newer release may mean something by that shape, and the answer to that is upgrading, below.
|
|
283
|
+
const newerFirst = value !== undefined && triggersFileVersionProblem(value, { supported: versions.supported })?.newer;
|
|
284
|
+
if (problem && !newerFirst) return { invalid: `the triggers file ${triggersPath} ${problem}, so its triggers cannot be kept; nothing was written. Fix the file, or move it aside to start from an empty one`, malformed: true };
|
|
213
285
|
parsedText = raw;
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
// Missing/invalid file: start from empty; the validated atomic write below repairs it.
|
|
286
|
+
doc = value;
|
|
287
|
+
if (Array.isArray(value.triggers)) current = value.triggers;
|
|
217
288
|
}
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
// PARSES is not missing, so "start from empty" would not repair it, it would delete the operator's
|
|
221
|
-
// trigger set; and rebuilding from `current` writes back the winning value with the shadowed one
|
|
222
|
-
// silently gone, which turns a divergence a reviewer could still find into one nobody ever can.
|
|
289
|
+
// A duplicate key (issue #313) gets its own refusal: rebuilding from `current` writes back the winning value with the
|
|
290
|
+
// shadowed one silently gone, which turns a divergence a reviewer could still find into one nobody ever can.
|
|
223
291
|
if (parsedText !== null) {
|
|
224
292
|
const duplicate = findDuplicateKey(parsedText);
|
|
225
293
|
if (duplicate) {
|
|
226
294
|
return { invalid: `triggers file has a duplicate key ${JSON.stringify(duplicate.key)} at ${duplicate.at}; refusing to rewrite a file whose reviewed value and running value differ: ${triggersPath}` };
|
|
227
295
|
}
|
|
228
296
|
}
|
|
297
|
+
const newer = triggersFileVersionProblem(doc, { supported: versions.supported });
|
|
298
|
+
if (newer?.newer) return { invalid: `${newer.message}, so nothing was written and nothing in it was lost; change it with that release: ${triggersPath}`, newer: true };
|
|
229
299
|
const next = mutate(current.map((t) => ({ ...t })));
|
|
230
|
-
const
|
|
300
|
+
const nextDocument = typeof mutateDocument === "function" ? mutateDocument(JSON.parse(JSON.stringify(doc ?? { triggers: current }))) : doc;
|
|
301
|
+
const text = triggersFileText(next, nextDocument, versions);
|
|
231
302
|
try {
|
|
232
|
-
parseTriggers(text, triggersPath); // the loaders' own validator -- never write a file they would reject
|
|
303
|
+
parseTriggers(text, triggersPath, versions); // the loaders' own validator -- never write a file they would reject
|
|
233
304
|
} catch (e) {
|
|
234
305
|
return { invalid: e?.message ?? String(e) };
|
|
235
306
|
}
|
|
236
307
|
const tmp = tmpPathFor(triggersPath);
|
|
237
308
|
try {
|
|
238
309
|
fs.writeFileSync(tmp, text, { mode: 0o644 });
|
|
310
|
+
// After the tmp is written and before the rename, under the lock: nothing between this read and the rename
|
|
311
|
+
// can change the file except a writer that broke the lock (the stale-takeover residual in the header).
|
|
312
|
+
const changed = expectRefusal(fs, expect);
|
|
313
|
+
if (changed) {
|
|
314
|
+
discardTmp(fs, tmp);
|
|
315
|
+
return { invalid: changed };
|
|
316
|
+
}
|
|
239
317
|
renameIntoPlace(fs, tmp, triggersPath);
|
|
240
318
|
} catch (err) {
|
|
241
319
|
discardTmp(fs, tmp);
|
|
@@ -289,9 +367,9 @@ export async function disarmTrigger({ triggersPath, index, number, flow, command
|
|
|
289
367
|
currentText = fs.readFileSync(triggersPath, "utf8");
|
|
290
368
|
raw = JSON.parse(currentText);
|
|
291
369
|
} catch (err) {
|
|
292
|
-
// NEVER
|
|
293
|
-
//
|
|
294
|
-
// the operator CRUD path, where "missing" means "first trigger".
|
|
370
|
+
// NEVER start from empty here: overwriting a file we could not read, to record one disarm,
|
|
371
|
+
// would destroy the operator's trigger set. writeTriggers starts from empty only for a file
|
|
372
|
+
// that holds nothing, on the operator CRUD path, where "missing" means "first trigger".
|
|
295
373
|
return { invalid: `triggers file unreadable (${err?.code ?? "parse-error"}), disarm not written: ${triggersPath}` };
|
|
296
374
|
}
|
|
297
375
|
// And a duplicate key is the destruction that comment thought it had closed (issue #313). This
|
|
@@ -326,11 +404,20 @@ export async function disarmTrigger({ triggersPath, index, number, flow, command
|
|
|
326
404
|
return { already: true };
|
|
327
405
|
}
|
|
328
406
|
entry.on.disarmed = { at, ...(jobId !== undefined && { jobId }) };
|
|
329
|
-
|
|
407
|
+
// Every other top-level key rides along unchanged (issue #704), `version` included (issue #761): the disarm adds
|
|
408
|
+
// one key to one entry and owns nothing else. `serialize` over the RAW document keeps every top-level block,
|
|
409
|
+
// every key inside an entry and inside its `run`, and `version`, as they were read.
|
|
410
|
+
const text = serialize(entries, raw);
|
|
330
411
|
try {
|
|
331
412
|
parseTriggers(text, triggersPath); // the shared validator, same rule as every write
|
|
332
413
|
} catch (e) {
|
|
333
|
-
|
|
414
|
+
// A file written by a newer pi-dispatch is the one refusal the disarm does NOT take (issue #761): the validator
|
|
415
|
+
// stops at its `version` and judges nothing below it, and the identity checks above have already found an armed
|
|
416
|
+
// one-shot at this index for this job. Refused, the one-shot would stay armed, and an upgraded worker or
|
|
417
|
+
// receiver would fire it again: a second paid run. Writing the one key this writer owns, with everything else
|
|
418
|
+
// as it was read, loses nothing the newer release wrote, which is the whole of why other writers refuse such a
|
|
419
|
+
// file. Any other refusal (a malformed file) is the validator's, as before.
|
|
420
|
+
if (e?.newer !== true) return { invalid: e?.message ?? String(e) };
|
|
334
421
|
}
|
|
335
422
|
const tmp = tmpPathFor(triggersPath);
|
|
336
423
|
try {
|
|
@@ -404,7 +491,7 @@ export function makeDisarmOnce({ triggersPath, fs = nodeFs, log = () => {}, disa
|
|
|
404
491
|
}
|
|
405
492
|
|
|
406
493
|
/**
|
|
407
|
-
* The pre-spend check's factory (issue #231): `(job, { queueJobId }) => { ok } | { refused, at, jobId }`.
|
|
494
|
+
* The pre-spend check's factory (issue #231): `(job, { queueJobId }) => { ok } | { refused, at, jobId } | { newer }`.
|
|
408
495
|
* Refuses ONLY on positive FOREIGN disarmed evidence -- a mark whose jobId is this very queue job
|
|
409
496
|
* means BullMQ's second attempt of the delivery that spent the trigger, which must still run
|
|
410
497
|
* (without the exception, a disarm on attempt one's failure record silently turns attempts:2 into
|
|
@@ -413,6 +500,7 @@ export function makeDisarmOnce({ triggersPath, fs = nodeFs, log = () => {}, disa
|
|
|
413
500
|
* gone, entry changed -- is "run": fail-open, the disarm writer owns the loud refusals, and in the
|
|
414
501
|
* compose topology (single-file :ro bind mount pinned to a dead inode, so the receiver never sees
|
|
415
502
|
* the disarm until restart) this check IS the once-enforcement layer, which is why it exists at all.
|
|
503
|
+
* The one exception to fail-open is a file written by a newer pi-dispatch (issue #761): `{ newer: true }`, below.
|
|
416
504
|
*/
|
|
417
505
|
export function makeCheckOnceSpent({ triggersPath, fs = nodeFs }) {
|
|
418
506
|
return async function checkOnceSpent(job, { queueJobId } = {}) {
|
|
@@ -426,6 +514,10 @@ export function makeCheckOnceSpent({ triggersPath, fs = nodeFs }) {
|
|
|
426
514
|
command: job?.command,
|
|
427
515
|
fs,
|
|
428
516
|
});
|
|
517
|
+
// Issue #761: NOT fail-open. A file written by a newer pi-dispatch may hold this one-shot's disarm in a shape this
|
|
518
|
+
// build cannot read (a renamed key, a mark this build would not see as one), so "run" could fire a spent one-shot
|
|
519
|
+
// again. The caller defers the job until a build that reads it.
|
|
520
|
+
if (state.state === "newer") return { newer: true };
|
|
429
521
|
if (state.state !== "disarmed") return { ok: true };
|
|
430
522
|
if (state.jobId !== null && state.jobId === queueJobId) return { ok: true }; // our own earlier attempt
|
|
431
523
|
return { refused: true, at: state.at, jobId: state.jobId };
|
|
@@ -454,7 +546,8 @@ export function makeCheckOnceSpent({ triggersPath, fs = nodeFs }) {
|
|
|
454
546
|
*
|
|
455
547
|
* FAIL-OPEN throughout, `readDisarmState`'s posture and for its reason: an unreadable or changed file means
|
|
456
548
|
* "run", because a broken read must never wedge every job. Only a positive, identity-confirmed mismatch --
|
|
457
|
-
* this entry authored conditions, this job carries none -- refuses.
|
|
549
|
+
* this entry authored conditions, this job carries none -- refuses. The one answer that is neither is a file written by
|
|
550
|
+
* a newer pi-dispatch (issue #761): `{ newer: true }`, which the caller turns into a deferral, never a refusal.
|
|
458
551
|
*/
|
|
459
552
|
/**
|
|
460
553
|
* The authored NARROWING fields the skew check covers besides `waitFor` (issues #502, #501): a `run` key whose absence
|
|
@@ -471,6 +564,8 @@ export function makeCheckOnceSpent({ triggersPath, fs = nodeFs }) {
|
|
|
471
564
|
*/
|
|
472
565
|
export const AUTHORED_NARROWING_FIELDS = Object.freeze([
|
|
473
566
|
Object.freeze({ key: "models", authored: (v) => Array.isArray(v) && v.length > 0 }),
|
|
567
|
+
// A stale receiver dropping the reviewed retry order must not silently disable failover.
|
|
568
|
+
Object.freeze({ key: "fallbackModels", authored: (v) => Array.isArray(v) && v.length > 0 }),
|
|
474
569
|
// Issue #501: a trigger's per-job dollar cap. Dropped by a receiver from before #501 (which tolerated the key as
|
|
475
570
|
// unknown), the job would run under the deployment's wider cap, or with none, on a clean record. Any present
|
|
476
571
|
// value counts as authored; whether it is a valid amount is the loader's question, not this one.
|
|
@@ -482,6 +577,25 @@ export const AUTHORED_NARROWING_FIELDS = Object.freeze([
|
|
|
482
577
|
// the key as unknown), the job runs without it: the agent cannot ask, and the record reads like a clean run. Only
|
|
483
578
|
// `true` arms it, and the job data carries it only then, so `false` is nothing authored.
|
|
484
579
|
Object.freeze({ key: "awaitInput", authored: (v) => v === true }),
|
|
580
|
+
// Issue #644: the progress tool and the status comment. Dropped by a receiver from before #644 (which tolerates the
|
|
581
|
+
// keys as unknown), the job runs without the tool or without the comment the trigger promised, on a clean record.
|
|
582
|
+
// Only `true` arms either, and the job data carries each only then, so `false` is nothing authored.
|
|
583
|
+
Object.freeze({ key: "progress", authored: (v) => v === true }),
|
|
584
|
+
Object.freeze({ key: "statusComment", authored: (v) => v === true }),
|
|
585
|
+
// Issue #645: durable runs. Dropped by a receiver from before #645 (which tolerates the key as unknown), a run the
|
|
586
|
+
// worker loses fails instead of continuing from its checkpoint, the opposite of what the trigger promised. Only
|
|
587
|
+
// `true` arms it, and the job data carries it only then, so `false` is nothing authored.
|
|
588
|
+
Object.freeze({ key: "recover", authored: (v) => v === true }),
|
|
589
|
+
// Issue #646: steering a running job. Dropped by a receiver from before #646 (which tolerates the key as unknown), the
|
|
590
|
+
// run starts with no inbox, so an operator's steering message is refused as for an unarmed job while the trigger
|
|
591
|
+
// promised it would be heard. Only `true` arms it, and the job data carries it only then, so `false` is nothing
|
|
592
|
+
// authored.
|
|
593
|
+
Object.freeze({ key: "steer", authored: (v) => v === true }),
|
|
594
|
+
// Issue #800: what a trigger's jobs do not load and which tools they lose. Dropped by a service below the version floor, or a
|
|
595
|
+
// current one reading a dead inode through compose's single-file mount, the job loads what the file takes away and keeps the
|
|
596
|
+
// tool, on a clean record.
|
|
597
|
+
Object.freeze({ key: "excludeExtensions", authored: (v) => Array.isArray(v) && v.length > 0 }),
|
|
598
|
+
Object.freeze({ key: "excludeExtensionTools", authored: (v) => Array.isArray(v) && v.length > 0 }),
|
|
485
599
|
]);
|
|
486
600
|
|
|
487
601
|
export function makeCheckWaitSkew({ triggersPath, fs = nodeFs }) {
|
|
@@ -492,18 +606,23 @@ export function makeCheckWaitSkew({ triggersPath, fs = nodeFs }) {
|
|
|
492
606
|
// stat replaces a read-and-parse on the hot path. The residual is millisecond mtime granularity: two
|
|
493
607
|
// writes inside one millisecond would serve a stale parse for one job, which fails OPEN like every other
|
|
494
608
|
// uncertainty here.
|
|
495
|
-
let cache = null; // { mtimeMs, size, triggers }
|
|
609
|
+
let cache = null; // { mtimeMs, size, triggers, newer }
|
|
610
|
+
// `{ triggers, newer }`: the raw entries (null when unreadable), and whether the file was written by a newer
|
|
611
|
+
// pi-dispatch (issue #761), judged by the loader's one rule (`triggersFileNewer`).
|
|
496
612
|
const read = () => {
|
|
497
613
|
try {
|
|
498
614
|
const st = fs.statSync?.(triggersPath);
|
|
499
|
-
if (cache && st && cache.mtimeMs === st.mtimeMs && cache.size === st.size) return cache
|
|
500
|
-
const
|
|
615
|
+
if (cache && st && cache.mtimeMs === st.mtimeMs && cache.size === st.size) return cache;
|
|
616
|
+
const text = fs.readFileSync(triggersPath, "utf8");
|
|
617
|
+
const raw = JSON.parse(text);
|
|
501
618
|
const triggers = Array.isArray(raw?.triggers) ? raw.triggers : null;
|
|
502
|
-
|
|
503
|
-
|
|
619
|
+
// The TEXT, so a duplicate `version` is broken here as it is to the loader, never newer.
|
|
620
|
+
const newer = triggersFileNewer(text);
|
|
621
|
+
if (st) cache = { mtimeMs: st.mtimeMs, size: st.size, triggers, newer };
|
|
622
|
+
return { triggers, newer };
|
|
504
623
|
} catch {
|
|
505
624
|
cache = null;
|
|
506
|
-
return null;
|
|
625
|
+
return { triggers: null, newer: false };
|
|
507
626
|
}
|
|
508
627
|
};
|
|
509
628
|
|
|
@@ -517,12 +636,17 @@ export function makeCheckWaitSkew({ triggersPath, fs = nodeFs }) {
|
|
|
517
636
|
// Cron and CLI jobs carry no matched index, and cannot carry `waitFor` at all.
|
|
518
637
|
const index = job?.trigger?.matched?.index;
|
|
519
638
|
if (!Number.isInteger(index)) return { ok: true };
|
|
639
|
+
// Issue #761: read BEFORE the arrived-everything shortcut below, because a file written by a newer pi-dispatch may
|
|
640
|
+
// author a narrowing field this build has no row for, which no job field can show missing. Not fail-open: the
|
|
641
|
+
// caller defers the job (`{ newer: true }`) until a build that reads the file. The read is the cached stat.
|
|
642
|
+
const file = read();
|
|
643
|
+
if (file.newer) return { newer: true };
|
|
520
644
|
// The field arrived. Whether its conditions are SATISFIED is the gate's business, not this check's.
|
|
521
645
|
const waitArrived = Array.isArray(job?.waitFor) && job.waitFor.length > 0;
|
|
522
646
|
const missing = AUTHORED_NARROWING_FIELDS.filter((row) => job?.[row.key] === undefined || job[row.key] === null);
|
|
523
647
|
if (waitArrived && missing.length === 0) return { ok: true };
|
|
524
648
|
|
|
525
|
-
const triggers =
|
|
649
|
+
const triggers = file.triggers;
|
|
526
650
|
if (triggers === null) return { ok: true };
|
|
527
651
|
const entry = triggers[index];
|
|
528
652
|
const authored = entry?.run?.waitFor;
|
|
@@ -577,13 +701,149 @@ export function makeCheckPortfolioFlag({ triggersPath, fs = nodeFs }) {
|
|
|
577
701
|
};
|
|
578
702
|
}
|
|
579
703
|
|
|
704
|
+
/**
|
|
705
|
+
* The worker's read of the project secret bundles (issue #704, INT-TRIGGERS-FILE-CONTRACT `secretBundles`):
|
|
706
|
+
* `(job, { project }) => answer`, from the SAME file the one-shot, skew and portfolio checks read
|
|
707
|
+
* (`PI_TRIGGERS_FILE`, else `./triggers.json` in the worker's working directory).
|
|
708
|
+
*
|
|
709
|
+
* THE WORKER READS THE BINDING ITSELF, never the receiver, and that is the design rather than a shortcut. A
|
|
710
|
+
* bundle is not a field of a trigger: it applies to every job of a project, cron and forge alike, so there is no
|
|
711
|
+
* job field a receiver could carry it in. And a field the receiver carried would be droppable by a receiver
|
|
712
|
+
* older than it (the `AUTHORED_NARROWING_FIELDS` class), while a binding the worker reads cannot be dropped or
|
|
713
|
+
* forged by any receiver. `run.secretBundle: false` is read here for the same reason, from the entry the job came
|
|
714
|
+
* from, so the opt-out needs no receiver floor either.
|
|
715
|
+
*
|
|
716
|
+
* Answers, and why each is its own:
|
|
717
|
+
* - `{ none: true }`: no bundle names this project, or the job has no project. Every other job of the deployment.
|
|
718
|
+
* - `{ id, bundle, optedOut, entry }`: the project's bundle, and whether the job's trigger opted out.
|
|
719
|
+
* `entry` is `"cron"`, `"forge"` or null (a job with no trigger: the processor skips it as `manual` anyway).
|
|
720
|
+
* - `{ id, bundle, triggerChanged: true }`: the job came from a trigger, and no entry in the file is that
|
|
721
|
+
* trigger any more, so whether it opted out is UNKNOWN. The processor refuses rather than guessing: granting
|
|
722
|
+
* would widen a job whose trigger may have said `false`, and running without would be a job missing the
|
|
723
|
+
* environment its project grants. The refusal says to re-run it, the skew check's posture.
|
|
724
|
+
* - `{ unreadable }`: the file has never been read and validated in this process, and cannot be now, and the job
|
|
725
|
+
* HAS a project. The processor retries such a job (never refuses it, never runs it without its bundle).
|
|
726
|
+
*
|
|
727
|
+
* THE LAST GOOD READ (issue #704 review round 1), the receiver's posture toward its own triggers. A file that cannot be
|
|
728
|
+
* read or does not validate NOW is answered from the last version that did, logged once per failure reason
|
|
729
|
+
* (`secret_bundles_last_good`), so an operator's broken save neither drops a bundle silently (a host that declares no
|
|
730
|
+
* bundle profile used to run a bundled job without it and say nothing) nor refuses jobs that can never be repaired by
|
|
731
|
+
* a retry (a host that declares one used to refuse every trigger job, project-less ones included, and a refused
|
|
732
|
+
* webhook delivery is gone). Only a process that has NEVER had a good read retries, and only a job with a project:
|
|
733
|
+
* which bundle it would hold is then unknowable, while a job in no project can hold none.
|
|
734
|
+
*
|
|
735
|
+
* A MISSING file (ENOENT) depends on the host (review round 2). On a host that declares a bundle profile
|
|
736
|
+
* (`bundlesDeclared()`), it is a failure like any other: such a host means to serve bundles, and a missing file there
|
|
737
|
+
* is a misconfiguration (a wrong `PI_TRIGGERS_FILE`, a file not copied to this host) that must not run a bundled
|
|
738
|
+
* project's jobs without their environment, so it falls back to the last good read, else retries. On a host that
|
|
739
|
+
* declares NONE it is a good read of no bundles, the overlay's rule for a missing settings file: such a host could
|
|
740
|
+
* serve no bundle anyway, and every host without a triggers file would otherwise retry every project job forever.
|
|
741
|
+
* The residual is named (DES-PROJECT-SECRET-BUNDLES): a host with neither a bundle profile nor the file runs a
|
|
742
|
+
* bundled project's job without the bundle, and doctor warns on a multi-host worker with projects and no file.
|
|
743
|
+
*
|
|
744
|
+
* Parsed with the shared validator (`parseTriggersDocument`), so a file the receiver would refuse is one this
|
|
745
|
+
* refuses too, and cached by mtime and size, `makeCheckWaitSkew`'s hot-path rule: this runs for every job.
|
|
746
|
+
*
|
|
747
|
+
* The identity of the job's entry is checked on the RAW entries, never the normalized ones, because the loader
|
|
748
|
+
* collapses a DISARMED one-shot to a sentinel and a retry of the very job that spent it would otherwise read as a
|
|
749
|
+
* changed trigger. A cron job is found by its id and must agree on folder, flow, command and task
|
|
750
|
+
* (`makeCheckPortfolioFlag`'s rule); a forge job by its matched index, agreeing on kind, `on.type`, command, and
|
|
751
|
+
* flow except on a comment trigger, whose phrase may name another flow (`<phrase> <flow>`) without changing
|
|
752
|
+
* which entry authorized it.
|
|
753
|
+
*/
|
|
754
|
+
export function makeFindSecretBundle({ triggersPath, fs = nodeFs, log = () => {}, bundlesDeclared = () => false }) {
|
|
755
|
+
let cache = null; // { mtimeMs, size } of the text `lastGood` came from
|
|
756
|
+
let lastGood = null; // { entries, bundles }
|
|
757
|
+
let failing = null; // the failure reason last logged, so a broken file logs once, not once per job
|
|
758
|
+
const fail = (why) => {
|
|
759
|
+
cache = null;
|
|
760
|
+
if (failing !== why) {
|
|
761
|
+
failing = why;
|
|
762
|
+
log("secret_bundles_last_good", { why, kept: lastGood !== null });
|
|
763
|
+
}
|
|
764
|
+
return lastGood ?? { unreadable: why };
|
|
765
|
+
};
|
|
766
|
+
const good = (doc, st) => {
|
|
767
|
+
lastGood = doc;
|
|
768
|
+
failing = null;
|
|
769
|
+
cache = st ? { mtimeMs: st.mtimeMs, size: st.size } : null;
|
|
770
|
+
return doc;
|
|
771
|
+
};
|
|
772
|
+
// No file: no bundles on a host that declares none, a failure on one that declares any (see above).
|
|
773
|
+
const missing = () => {
|
|
774
|
+
let declared = false;
|
|
775
|
+
try {
|
|
776
|
+
declared = bundlesDeclared() === true;
|
|
777
|
+
} catch {
|
|
778
|
+
declared = false;
|
|
779
|
+
}
|
|
780
|
+
return declared ? fail("absent") : good({ entries: [], bundles: {} }, null);
|
|
781
|
+
};
|
|
782
|
+
const read = () => {
|
|
783
|
+
if (typeof triggersPath !== "string" || triggersPath === "") return missing();
|
|
784
|
+
let st = null;
|
|
785
|
+
try {
|
|
786
|
+
st = fs.statSync?.(triggersPath) ?? null;
|
|
787
|
+
} catch (err) {
|
|
788
|
+
if (err?.code === "ENOENT") return missing();
|
|
789
|
+
return fail(typeof err?.code === "string" ? err.code : "read-error");
|
|
790
|
+
}
|
|
791
|
+
if (cache && lastGood && st && cache.mtimeMs === st.mtimeMs && cache.size === st.size) return lastGood;
|
|
792
|
+
let text;
|
|
793
|
+
try {
|
|
794
|
+
text = fs.readFileSync(triggersPath, "utf8");
|
|
795
|
+
} catch (err) {
|
|
796
|
+
if (err?.code === "ENOENT") return missing();
|
|
797
|
+
return fail(typeof err?.code === "string" ? err.code : "read-error");
|
|
798
|
+
}
|
|
799
|
+
try {
|
|
800
|
+
const { secretBundles } = parseTriggersDocument(text, triggersPath);
|
|
801
|
+
return good({ entries: JSON.parse(text).triggers, bundles: secretBundles }, st);
|
|
802
|
+
} catch {
|
|
803
|
+
return fail("invalid");
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
|
|
807
|
+
return function findSecretBundle(job, { project = null } = {}) {
|
|
808
|
+
if (typeof project !== "string" || project === "") return { none: true };
|
|
809
|
+
const doc = read();
|
|
810
|
+
if (doc.unreadable !== undefined) return doc;
|
|
811
|
+
const id = Object.keys(doc.bundles).find((key) => doc.bundles[key].project === project);
|
|
812
|
+
if (id === undefined) return { none: true };
|
|
813
|
+
const bundle = doc.bundles[id];
|
|
814
|
+
const trigger = job?.trigger;
|
|
815
|
+
if (trigger === undefined || trigger === null) return { id, bundle, optedOut: false, entry: null };
|
|
816
|
+
const entries = Array.isArray(doc.entries) ? doc.entries : [];
|
|
817
|
+
const matched = trigger.matched;
|
|
818
|
+
let raw;
|
|
819
|
+
let entry;
|
|
820
|
+
if (Number.isInteger(matched?.index)) {
|
|
821
|
+
entry = "forge";
|
|
822
|
+
const candidate = entries[matched.index];
|
|
823
|
+
const sameType = typeof matched.type !== "string" || candidate?.on?.type === matched.type;
|
|
824
|
+
const sameFlow = matched.type === "comment" || candidate?.run?.flow === job.flow;
|
|
825
|
+
if (candidate?.run?.kind === job.kind && sameType && sameFlow && candidate?.run?.command === job.command) raw = candidate;
|
|
826
|
+
} else if (typeof trigger.id === "string") {
|
|
827
|
+
entry = "cron";
|
|
828
|
+
raw = entries.find((t) => t?.on?.type === "cron" && t.on.id === trigger.id && ["folder", "flow", "command", "task"].every((k) => t.run?.[k] === job[k]));
|
|
829
|
+
}
|
|
830
|
+
if (raw === undefined) return { id, bundle, triggerChanged: true };
|
|
831
|
+
return { id, bundle, optedOut: raw.run?.secretBundle === false, entry };
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
580
835
|
export function readDisarmState({ triggersPath, index, number, flow, command, fs = nodeFs }) {
|
|
581
836
|
let raw;
|
|
837
|
+
let text;
|
|
582
838
|
try {
|
|
583
|
-
|
|
839
|
+
text = fs.readFileSync(triggersPath, "utf8");
|
|
840
|
+
raw = JSON.parse(text);
|
|
584
841
|
} catch (err) {
|
|
585
842
|
return { state: "unknown", reason: err?.code ?? "parse-error" };
|
|
586
843
|
}
|
|
844
|
+
// Before any entry is read (issue #761): a newer file's entries may mean something else, so nothing in it is evidence.
|
|
845
|
+
// Judged on the TEXT, so a duplicate `version` reads as the loader reads it: broken, not newer.
|
|
846
|
+
if (triggersFileNewer(text)) return { state: "newer" };
|
|
587
847
|
const entry = Array.isArray(raw?.triggers) ? raw.triggers[index] : undefined;
|
|
588
848
|
if (!entry || typeof entry !== "object" || entry.on?.once !== true || entry.on?.number !== number) {
|
|
589
849
|
return { state: "unknown", reason: "entry-changed" };
|