@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.
Files changed (257) hide show
  1. package/.env.example +94 -4
  2. package/README.md +20 -7
  3. package/deploy/console.service +52 -0
  4. package/deploy/docker-compose.yml +3 -1
  5. package/deploy/pi-dispatch-valkey.container +4 -2
  6. package/deploy/sandbox-gateway.service +23 -0
  7. package/deploy/worker-env-wrapper.cmd +6 -0
  8. package/deploy/worker-env-wrapper.sh +10 -1
  9. package/package.json +117 -19
  10. package/src/agent-snapshot.mjs +49 -0
  11. package/src/answerer-runtime.mjs +194 -0
  12. package/src/answerer.mjs +178 -0
  13. package/src/approval-policy.mjs +76 -0
  14. package/src/approval-runtime.mjs +188 -0
  15. package/src/approval-session.mjs +137 -0
  16. package/src/approval-state.mjs +170 -0
  17. package/src/auth-cli.mjs +86 -0
  18. package/src/auth-connections.mjs +145 -0
  19. package/src/auth-policy.mjs +32 -0
  20. package/src/auth-store.mjs +97 -0
  21. package/src/azure-host.mjs +66 -4
  22. package/src/azure-prompt.mjs +5 -4
  23. package/src/backend-conformance.mjs +34 -2
  24. package/src/backend-local.mjs +103 -34
  25. package/src/backend-openshell.mjs +1540 -0
  26. package/src/backend-podman.mjs +63 -2
  27. package/src/backend-registry.mjs +61 -4
  28. package/src/backends.mjs +395 -6
  29. package/src/bullmq-internals.mjs +208 -0
  30. package/src/canary-cli.mjs +28 -0
  31. package/src/canary-lock.mjs +41 -0
  32. package/src/canary-runtime.mjs +237 -0
  33. package/src/canary.mjs +53 -0
  34. package/src/cancel-cli.mjs +47 -34
  35. package/src/cancel-state.mjs +213 -15
  36. package/src/capabilities.mjs +36 -1
  37. package/src/capacity-cli.mjs +28 -8
  38. package/src/capacity-records.mjs +75 -16
  39. package/src/capacity.mjs +65 -0
  40. package/src/chat-cli.mjs +288 -0
  41. package/src/chat-fork.mjs +547 -0
  42. package/src/chat-mode.mjs +156 -0
  43. package/src/chat-view.mjs +720 -0
  44. package/src/checkpoint-paths.mjs +113 -0
  45. package/src/cli.mjs +128 -41
  46. package/src/comment-tables.mjs +322 -0
  47. package/src/community-auth-adapters.mjs +87 -0
  48. package/src/community-auth-helper.mjs +76 -0
  49. package/src/community-auth-metadata.mjs +6 -0
  50. package/src/community-auth-output-pins.mjs +38 -0
  51. package/src/community-auth-patches.mjs +168 -0
  52. package/src/community-auth-pins.mjs +38 -0
  53. package/src/config.mjs +199 -30
  54. package/src/connection.mjs +83 -7
  55. package/src/console-answer.mjs +113 -0
  56. package/src/console-audit.mjs +604 -0
  57. package/src/console-bootstrap.mjs +159 -0
  58. package/src/console-client.mjs +848 -0
  59. package/src/console-probe.mjs +106 -0
  60. package/src/console-tokens.mjs +459 -0
  61. package/src/container-names.mjs +51 -0
  62. package/src/container-spec.mjs +20 -1
  63. package/src/cpu-reserve.mjs +4 -1
  64. package/src/defer-reasons.mjs +200 -0
  65. package/src/defer-write.mjs +208 -0
  66. package/src/deployment-venue.mjs +23 -6
  67. package/src/docker-run.mjs +11 -0
  68. package/src/doctor-bundles.mjs +130 -0
  69. package/src/doctor-model-proposals.mjs +109 -0
  70. package/src/doctor-openshell.mjs +238 -0
  71. package/src/doctor-results.mjs +602 -0
  72. package/src/doctor-spawner.mjs +215 -0
  73. package/src/doctor.mjs +1663 -198
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +103 -9
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-auth.mjs +4 -0
  80. package/src/forgejo-host.mjs +127 -31
  81. package/src/forgejo-prompt.mjs +5 -4
  82. package/src/forges.mjs +73 -15
  83. package/src/fork-job.mjs +58 -0
  84. package/src/fork-policy.mjs +23 -0
  85. package/src/fork-store.mjs +338 -0
  86. package/src/get-token.mjs +1 -1
  87. package/src/git-dirty.mjs +119 -14
  88. package/src/git-hardening.mjs +85 -0
  89. package/src/github-host.mjs +31 -6
  90. package/src/github-prompt.mjs +7 -16
  91. package/src/gitlab-host.mjs +42 -3
  92. package/src/gitlab-prompt.mjs +5 -4
  93. package/src/held-document.mjs +23 -0
  94. package/src/held-replies.mjs +325 -0
  95. package/src/held-settle.mjs +266 -0
  96. package/src/hook-runs.mjs +188 -0
  97. package/src/host-budget.mjs +8 -5
  98. package/src/host-clone.mjs +72 -0
  99. package/src/host-loads.mjs +557 -0
  100. package/src/host-registry.mjs +66 -6
  101. package/src/host-telemetry.mjs +92 -0
  102. package/src/http-body.mjs +78 -0
  103. package/src/image-preflight.mjs +156 -13
  104. package/src/import-pi.mjs +60 -172
  105. package/src/inbox-relay.mjs +482 -0
  106. package/src/inbox-settle.mjs +196 -0
  107. package/src/index.mjs +740 -129
  108. package/src/init.mjs +35 -5
  109. package/src/job-data-version.mjs +75 -0
  110. package/src/job-id.mjs +30 -15
  111. package/src/job-layout.mjs +22 -0
  112. package/src/job-size.mjs +16 -0
  113. package/src/live-jobs.mjs +14 -6
  114. package/src/live-probes.mjs +6 -2
  115. package/src/live-transcript.mjs +872 -0
  116. package/src/loaded-record.mjs +609 -0
  117. package/src/mcp-config.mjs +1 -1
  118. package/src/mcp-preflight.mjs +36 -5
  119. package/src/model-attempts.mjs +84 -0
  120. package/src/model-catalog.mjs +83 -0
  121. package/src/model-cooldowns.mjs +22 -0
  122. package/src/model-failover-protocol.mjs +11 -0
  123. package/src/model-failover.mjs +134 -0
  124. package/src/model-observation.mjs +50 -0
  125. package/src/model-proposal-protocol.mjs +21 -0
  126. package/src/model-proposals.mjs +75 -0
  127. package/src/model-ref.mjs +67 -10
  128. package/src/model-roles-file.mjs +24 -0
  129. package/src/model-roles.mjs +73 -0
  130. package/src/model-status.mjs +34 -0
  131. package/src/models-json.mjs +4 -1
  132. package/src/money.mjs +1 -1
  133. package/src/oauth-openai.mjs +121 -0
  134. package/src/older-state.mjs +249 -0
  135. package/src/on-failure.mjs +17 -13
  136. package/src/open-browser.mjs +31 -2
  137. package/src/opener-cli.mjs +135 -0
  138. package/src/opener-protocol.mjs +27 -0
  139. package/src/opener-terminal.mjs +128 -0
  140. package/src/openshell-cli.mjs +155 -0
  141. package/src/openshell-policy.mjs +163 -0
  142. package/src/outbox.mjs +14 -0
  143. package/src/output-cap.mjs +1 -0
  144. package/src/package-canary-record.mjs +26 -0
  145. package/src/package-canary.mjs +135 -0
  146. package/src/package-names.mjs +9 -0
  147. package/src/package-stage.mjs +418 -0
  148. package/src/packages.mjs +24 -16
  149. package/src/parked.mjs +2 -1
  150. package/src/pause-windows.mjs +136 -17
  151. package/src/podman-stack.mjs +40 -14
  152. package/src/portfolio-snapshot.mjs +26 -7
  153. package/src/prepare-github.mjs +41 -77
  154. package/src/prepare-local.mjs +32 -2
  155. package/src/prepare.mjs +45 -11
  156. package/src/private-store-boundary.mjs +70 -0
  157. package/src/processor.mjs +1554 -167
  158. package/src/projects.mjs +23 -0
  159. package/src/prompt-fence.mjs +18 -0
  160. package/src/provider-key.mjs +7 -0
  161. package/src/provider-steering.mjs +1 -0
  162. package/src/question-context.mjs +35 -0
  163. package/src/question-group-runtime.mjs +91 -0
  164. package/src/question-groups.mjs +171 -0
  165. package/src/queue.mjs +296 -55
  166. package/src/reason-catalog.mjs +448 -0
  167. package/src/receiver-status.mjs +230 -0
  168. package/src/record-reason.mjs +37 -0
  169. package/src/recovery-queue.mjs +566 -0
  170. package/src/redact-secrets.mjs +219 -0
  171. package/src/reserve-estimate.mjs +101 -0
  172. package/src/reserved-env.mjs +36 -1
  173. package/src/retention.mjs +67 -0
  174. package/src/run-budget-ledger.mjs +283 -0
  175. package/src/run-checkpoint.mjs +627 -0
  176. package/src/run-container.mjs +749 -33
  177. package/src/run-history.mjs +791 -45
  178. package/src/run-mirror.mjs +75 -7
  179. package/src/run-progress.mjs +751 -0
  180. package/src/runtime-observations.mjs +3 -1
  181. package/src/runtime-settings.mjs +272 -37
  182. package/src/sandbox-cli.mjs +78 -2
  183. package/src/sandbox-deployment.mjs +51 -0
  184. package/src/sandbox-gateway-protocol.mjs +378 -0
  185. package/src/sandbox-hosts.mjs +100 -0
  186. package/src/sandbox-inventory.mjs +83 -0
  187. package/src/sandbox-policy.mjs +225 -0
  188. package/src/sandbox-prepare-requests.mjs +145 -0
  189. package/src/sandbox-prepare.mjs +285 -0
  190. package/src/sandbox-refs.mjs +70 -0
  191. package/src/sandbox-session-monitor.mjs +4 -0
  192. package/src/sandbox-session-watch.mjs +124 -0
  193. package/src/sandbox-sessions.mjs +161 -0
  194. package/src/sandbox-store.mjs +63 -6
  195. package/src/sandbox-workspace-guard.mjs +39 -0
  196. package/src/sandbox.mjs +27 -200
  197. package/src/scheduler-stall-guard.mjs +14 -8
  198. package/src/schedules.mjs +19 -6
  199. package/src/secret-bundles.mjs +245 -0
  200. package/src/secret-profiles.mjs +103 -3
  201. package/src/secrets.mjs +19 -9
  202. package/src/service-env.mjs +38 -5
  203. package/src/service.mjs +638 -106
  204. package/src/session-key.mjs +60 -0
  205. package/src/session-store.mjs +168 -10
  206. package/src/setup-contract.mjs +40 -0
  207. package/src/setup-egress.mjs +113 -0
  208. package/src/setup-host-entry.mjs +33 -0
  209. package/src/setup-host.mjs +242 -0
  210. package/src/setup-lock.mjs +41 -0
  211. package/src/size-records.mjs +15 -3
  212. package/src/start.mjs +1353 -135
  213. package/src/status-comment.mjs +415 -0
  214. package/src/stderr-transcript.mjs +318 -0
  215. package/src/steer-ack.mjs +122 -0
  216. package/src/steer-cli.mjs +213 -0
  217. package/src/steer-framing.mjs +185 -0
  218. package/src/steer-inbox.mjs +693 -0
  219. package/src/store-version.mjs +139 -0
  220. package/src/subscription-config.mjs +56 -0
  221. package/src/subscription-limits.mjs +3 -0
  222. package/src/subscription-quota.mjs +259 -0
  223. package/src/subscription-runtime.mjs +123 -0
  224. package/src/subscription-status.mjs +42 -0
  225. package/src/subscription-wait.mjs +11 -0
  226. package/src/subscriptions.mjs +7 -1
  227. package/src/thinking-level.mjs +19 -0
  228. package/src/touch-outbox.mjs +61 -0
  229. package/src/touch-state.mjs +133 -0
  230. package/src/triggers-file.mjs +299 -39
  231. package/src/triggers.mjs +782 -40
  232. package/src/trust-paths.mjs +171 -0
  233. package/src/up.mjs +398 -51
  234. package/src/updates-cli.mjs +61 -0
  235. package/src/updates-look.mjs +127 -0
  236. package/src/updates-reconcile.mjs +182 -0
  237. package/src/updates-subscription-adapters.mjs +8 -0
  238. package/src/updates.mjs +252 -0
  239. package/src/valkey-auth.mjs +32 -9
  240. package/src/valkey-endpoint.mjs +14 -4
  241. package/src/wait-for.mjs +1 -1
  242. package/src/wait-state.mjs +62 -3
  243. package/src/watch-board.mjs +38 -0
  244. package/src/watch-closer.mjs +1 -1
  245. package/src/watch-contract.mjs +102 -0
  246. package/src/watch-decision.mjs +78 -0
  247. package/src/watch-drainer.mjs +14 -0
  248. package/src/watch-issue-adapter.mjs +44 -0
  249. package/src/watch-issue.mjs +111 -0
  250. package/src/watch-record.mjs +10 -0
  251. package/src/watch-runtime.mjs +167 -0
  252. package/src/watch-store.mjs +183 -0
  253. package/src/watch-trace.mjs +34 -0
  254. package/src/workspace-checkpoint.mjs +1750 -0
  255. package/src/zero-touch-fixes.mjs +99 -0
  256. package/src/zero-touch.mjs +225 -0
  257. package/src/repeat-slot.mjs +0 -13
@@ -0,0 +1,208 @@
1
+ /**
2
+ * BullMQ's internals, in one place (issue #761): every fact this project reads or writes past BullMQ's public API. The
3
+ * key names (`bull:<queue>:<type>`), the job hash's field names, the meta hash's flags, the delayed set's score, the
4
+ * `repeat:<scheduler>:<slot>` id a job scheduler mints (and this project's hand-fire id, `manual:<id>:<ms>`, which is
5
+ * built to the same shape), and the two rules a custom job id must pass. Every other file imports them from here, so a
6
+ * BullMQ bump that moves one of them is a change to one file, and `worker/test/bullmq-internals.test.mjs` refuses a
7
+ * second spelling anywhere in the source trees.
8
+ *
9
+ * Each fact was read from the shipped files of bullmq `BULLMQ_VERIFIED_VERSION`, cited beside it, and is pinned three
10
+ * ways: the unit test holds the text of each cited file, the integration test holds the behaviour against a real
11
+ * Valkey, and a pins test holds every workspace's bullmq, ioredis and cron-parser pin to the lockfile and to that
12
+ * version. A bump re-reads the cited files; the tests say which one moved.
13
+ *
14
+ * A LEAF: it imports nothing, so the receiver and the admin console import it (`@edgehero/pi-dispatch/bullmq-internals`)
15
+ * without the worker's dependencies. It never names the library in a string either: valkey-clients.test.mjs allows that
16
+ * only in the files that construct the library's classes.
17
+ *
18
+ * What stays where it is: BullMQ's public API (`Queue`, `Worker`, `getJobs`, the state names), which semver governs and
19
+ * a bump's changelog announces, and this project's own names (`QUEUE`, `hostQueueName`, `sanitizeJobId`).
20
+ */
21
+
22
+ /** The bullmq release every fact below was read from. The installed package must be this version (the pins test). */
23
+ export const BULLMQ_VERIFIED_VERSION = "5.80.4";
24
+
25
+ /** The key prefix every queue's keys share (queue-keys.js: `constructor(prefix = 'bull')`). */
26
+ export const BULL_PREFIX = "bull";
27
+
28
+ /** The per-queue keys this project reads, each `bull:<queue>:<type>` (queue-keys.js `getKeys`). */
29
+ export const QUEUE_KEY = Object.freeze({ meta: "meta", wait: "wait", paused: "paused", delayed: "delayed", prioritized: "prioritized", repeat: "repeat" });
30
+
31
+ /** A queue's key for `type` (queue-keys.js `toKey`: `${prefix}:${name}:${type}`). A job's own hash is `type` = its id. */
32
+ export const queueKey = (queueName, type, prefix = BULL_PREFIX) => `${prefix}:${queueName}:${type}`;
33
+
34
+ /** A queue's key for `type`: the queue's own `toKey` where it has one (it knows its prefix), else `queueKey`. */
35
+ export function queueKeyOf(q, type) {
36
+ return typeof q?.toKey === "function" ? q.toKey(type) : queueKey(q?.name, type);
37
+ }
38
+
39
+ /** A job's own hash in queue `q`: BullMQ keys it by the bare id under the queue's prefix. */
40
+ export const jobKeyOf = (q, jobId) => queueKeyOf(q, jobId);
41
+
42
+ /**
43
+ * The fields of a job's own hash this project reads, as job.js `fromJSON` reads them: its kind (`name`), its data, its
44
+ * enqueue instant, its dedup id (`deid`), its scheduler (`rjk`), its attempts (`atm`, and the older `attemptsMade`,
45
+ * which `fromJSON` reads first) and its stall count (`stc`).
46
+ */
47
+ export const JOB_FIELD = Object.freeze({ name: "name", data: "data", timestamp: "timestamp", dedupId: "deid", schedulerKey: "rjk", attempts: "atm", attemptsOld: "attemptsMade", stalls: "stc" });
48
+
49
+ /** The meta hash's fields: the pause flag (pause-7.lua writes `1`, a resume deletes it) and the library's version. */
50
+ export const META_FIELD = Object.freeze({ paused: "paused", version: "version" });
51
+
52
+ /**
53
+ * Whether a queue's meta `paused` field says paused, as BullMQ reads it: the field's PRESENCE (`HEXISTS` in
54
+ * isQueuePaused.lua, and getTargetQueueList.lua, where any string is true), whatever it holds, so `"0"` and `""` are
55
+ * paused too. `cmd` is the command the reader sent: `HEXISTS` answers 1 for a field that is there, `HGET` the value,
56
+ * null for none.
57
+ */
58
+ export function isPausedFlag(cmd, value) {
59
+ if (cmd === "hexists") return value === 1 || value === "1";
60
+ return value !== null && value !== undefined;
61
+ }
62
+
63
+ /**
64
+ * The version in a meta hash's `version` field (`libName:version`, queue.js `metaValues`), or null. NOT a fleet fact:
65
+ * every process that constructs a Queue writes it, so in a fleet of mixed versions it holds whichever wrote last. Only
66
+ * the integration test reads it, to prove the library it ran against is the verified one.
67
+ */
68
+ export function metaVersionOf(raw) {
69
+ const m = typeof raw === "string" ? /^bullmq:(\d+\.\d+\.\d+)$/.exec(raw) : null;
70
+ return m ? m[1] : null;
71
+ }
72
+
73
+ /** A SCAN pattern for the meta key of every queue whose name starts with `namePrefix`. */
74
+ export const metaKeyPattern = (namePrefix) => queueKey(`${namePrefix}*`, QUEUE_KEY.meta);
75
+
76
+ /**
77
+ * The queue a meta key belongs to (`bull:<name>:meta`), or null. A queue's name holds no `:` (queue-base.js refuses
78
+ * one), so a key whose middle does is some other key a pattern's `*` matched: a job's own hash, whose custom id may
79
+ * hold two colons (`bull:pi-jobs:a:b:meta`).
80
+ */
81
+ export function queueNameOfMetaKey(key) {
82
+ if (typeof key !== "string") return null;
83
+ const head = `${BULL_PREFIX}:`;
84
+ const tail = `:${QUEUE_KEY.meta}`;
85
+ if (!key.startsWith(head) || !key.endsWith(tail) || key.length <= head.length + tail.length) return null;
86
+ const name = key.slice(head.length, -tail.length);
87
+ return name.includes(":") ? null : name;
88
+ }
89
+
90
+ /** A delayed job's score is `due * 4096 + a counter` (getDelayedScore.lua: `delayedTimestamp * 0x1000`). */
91
+ export const DELAYED_SCORE_SHIFT = 4096;
92
+
93
+ /**
94
+ * The latest due time `dueOfScore` passes on: a copy of `DEFER_TIME_MAX_MS` (defer-reasons.mjs, which says why), pinned
95
+ * equal to it by a test, because this module imports nothing.
96
+ */
97
+ export const SCORE_DUE_MAX_MS = 253402300799999;
98
+
99
+ /**
100
+ * A delayed zset score's due time in ms, or null for anything that is not a non-negative number whose quotient is an
101
+ * instant a console can draw (at most `SCORE_DUE_MAX_MS`): a score anything with the URL wrote can be any double, and a
102
+ * due time past that range has no date to show. Only a number or a non-empty string is a score: `Number(null)` and
103
+ * `Number("")` are 0, and a ZSCORE of a missing member (null) must read as no due time, never as one at the epoch.
104
+ */
105
+ export const dueOfScore = (score) => {
106
+ if (typeof score !== "number" && !(typeof score === "string" && score.trim() !== "")) return null;
107
+ const n = Number(score);
108
+ if (!Number.isFinite(n) || n < 0) return null;
109
+ const due = Math.floor(n / DELAYED_SCORE_SHIFT);
110
+ return Number.isSafeInteger(due) && due <= SCORE_DUE_MAX_MS ? due : null;
111
+ };
112
+
113
+ /** The prefix of every job a job scheduler mints (job-scheduler.js `getSchedulerNextJobId`, addJobScheduler-11.lua). */
114
+ export const SCHEDULER_JOB_PREFIX = "repeat";
115
+
116
+ /**
117
+ * The prefix of a cron trigger's run fired by hand (`manualTriggerJobId`, job-id.mjs). This project's own, but built
118
+ * to BullMQ's shape: a custom id with a `:` must split into exactly three parts (`customJobIdAllowed`), as a
119
+ * scheduler's does, so every reader of a cron run reads both with one grammar.
120
+ */
121
+ export const MANUAL_JOB_PREFIX = "manual";
122
+
123
+ /** The most digits a slot holds: a JavaScript Date's last instant, 8.64e15 ms, is 16 digits. */
124
+ export const SLOT_DIGITS_MAX = 16;
125
+
126
+ /** What a scheduler's run id adds to its scheduler's id: the prefix, two colons and a slot. */
127
+ export const SCHEDULER_JOB_ID_OVERHEAD = `${SCHEDULER_JOB_PREFIX}::`.length + SLOT_DIGITS_MAX;
128
+
129
+ /**
130
+ * The longest scheduler id a reader of Valkey or a surface that prints one takes, and the charset (the triggers
131
+ * loader's `ID_CHARSET`, which refuses a `:`). Bounded where it is read, because anything holding the Valkey's URL can
132
+ * write a scheduler key or a job id, and a surface prints it in running text. 200, not the loader's `CRON_ID_MAX`: a
133
+ * scheduler that an earlier release upserted for a longer id is still in Valkey after an upgrade (every release before
134
+ * issue #761 took a cron id of any length), and a surface must still name it so an operator can find and remove it. A
135
+ * reader of run RECORDS takes any length (`cronRunOf` without `idMax`): a record is this deployment's own history.
136
+ */
137
+ export const SCHEDULER_ID_MAX = 200;
138
+ export const SCHEDULER_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
139
+
140
+ /** The job id a scheduler mints for its run at `ms`: `repeat:<id>:<ms>` (job-scheduler.js, addJobScheduler-11.lua). */
141
+ export const schedulerJobId = (schedulerId, ms) => `${SCHEDULER_JOB_PREFIX}:${schedulerId}:${ms}`;
142
+
143
+ /** A cron run's job id: the prefix, the trigger's id in the loader's charset, and a slot in decimal with no leading 0. */
144
+ const CRON_RUN_RE = /^(repeat|manual):([A-Za-z0-9._-]+):(0|[1-9][0-9]{0,15})$/;
145
+
146
+ /**
147
+ * A cron trigger's run, from its job id: `{ kind, id, ms }`, `kind` `"repeat"` (its scheduler minted it) or `"manual"`
148
+ * (fired by hand), `id` the trigger's (the scheduler's) id and `ms` the slot. Null for any other id. Strict: both
149
+ * writers mint the slot from a number, so it is plain digits, and the id is in the loader's charset, which has held
150
+ * since the first release. `ms` past `Number.MAX_SAFE_INTEGER` (no Date reaches it) is not exact, so a caller that
151
+ * makes an instant of it checks `Number.isSafeInteger`.
152
+ *
153
+ * `idMax` bounds the id for a reader of Valkey or a surface (`SCHEDULER_ID_MAX`); a reader of run records passes none,
154
+ * since every release before issue #761 took a cron id of any length and its records must still be read.
155
+ */
156
+ export function cronRunOf(jobId, { idMax = Infinity } = {}) {
157
+ const m = typeof jobId === "string" ? CRON_RUN_RE.exec(jobId) : null;
158
+ if (m === null || m[2].length > idMax) return null;
159
+ return { kind: m[1], id: m[2], ms: Number(m[3]) };
160
+ }
161
+
162
+ /** The two job id prefixes of a cron trigger's runs, `repeat:<id>:` and `manual:<id>:`, for a scan by prefix. */
163
+ export const cronRunPrefixes = (id) => [`${SCHEDULER_JOB_PREFIX}:${id}:`, `${MANUAL_JOB_PREFIX}:${id}:`];
164
+
165
+ /**
166
+ * Whether a job id is in a scheduler's namespace (`repeat:`), whether or not the rest parses: the stall guard counts
167
+ * such a job and logs one it cannot parse. A hand-fired `manual:` run is not a scheduler's job.
168
+ */
169
+ export const isSchedulerJobId = (jobId) => typeof jobId === "string" && jobId.startsWith(`${SCHEDULER_JOB_PREFIX}:`);
170
+
171
+ /**
172
+ * The scheduled-for instant of a scheduler's run, read from its id (DES-CRON-VIA-BULLMQ-SCHEDULER), or null. A
173
+ * `manual:` run has no scheduled instant (`manualTriggerJobId` floors its time to the minute), so it reads as null.
174
+ */
175
+ export function scheduledForMillis(jobId) {
176
+ const run = cronRunOf(jobId);
177
+ return run !== null && run.kind === SCHEDULER_JOB_PREFIX ? run.ms : null;
178
+ }
179
+
180
+ /**
181
+ * Whether a `repeat:` job is its scheduler's CURRENT next run: BullMQ names that run `repeat:<key>:<next>`, where
182
+ * `<next>` is the scheduler's score in the queue's repeat zset (isJobSchedulerJob.lua). Only that run is "scheduled,
183
+ * not in line": a `repeat:` job a gate deferred after the scheduler minted its successor is a real run that waits.
184
+ */
185
+ export function isSchedulerNext(jobId, rjk, nextRaw) {
186
+ if (typeof jobId !== "string" || typeof rjk !== "string" || rjk === "") return false;
187
+ let next;
188
+ if (typeof nextRaw === "number") {
189
+ if (!Number.isFinite(nextRaw)) return false;
190
+ next = String(nextRaw);
191
+ } else if (typeof nextRaw === "string" && nextRaw !== "") {
192
+ next = nextRaw;
193
+ } else {
194
+ return false;
195
+ }
196
+ return jobId === schedulerJobId(rjk, next);
197
+ }
198
+
199
+ /**
200
+ * Whether `queue.add` takes `id` as a custom job id (job.js `validateOptions`): not an integer's own spelling ("Custom
201
+ * Id cannot be integers", `${parseInt(id, 10)}` === id, so `"NaN"` too), and a `:` only when the id splits into exactly
202
+ * three parts ("Custom Id cannot contain :"), the shape a scheduler's own ids have. An empty id is no custom id at all.
203
+ */
204
+ export function customJobIdAllowed(id) {
205
+ if (typeof id !== "string" || id === "") return false;
206
+ if (`${parseInt(id, 10)}` === id) return false;
207
+ return !id.includes(":") || id.split(":").length === 3;
208
+ }
@@ -0,0 +1,28 @@
1
+ import { parseArgs } from "node:util";
2
+ import { canaryOptions, canaryText, executeCanary } from "./canary.mjs";
3
+
4
+ export const CANARY_ENV_KEYS = Object.freeze(["PI_WORKER_NAME", "PI_LOGS_DIR", "PI_JOBS_DIR", "PI_SANDBOX_DIR", "PI_SANDBOX_RETENTION_HOURS", "PI_SETTINGS_FILE", "PI_TRIGGERS_FILE", "PI_BACKENDS", "PI_JOB_IMAGE", "PI_MAX_COST_USD", "PI_DAILY_COST_USD", "PI_WEEKLY_COST_USD", "PI_MONTHLY_COST_USD", "PI_DAILY_CAP", "PI_WEEKLY_CAP", "PI_MONTHLY_CAP", "PI_SOFT_HOLD_PCT", "PI_DAILY_TOKEN_CAP", "PI_MAX_TOKENS", "PI_MAX_TURNS", "PI_MODEL", "PI_PROVIDER", "PI_SCOPED_LIMITS_FILE", "PI_PAUSE_WINDOWS_FILE", "PI_ENVELOPE_FILE", "PI_HOST_MEMORY_BUDGET", "PI_HOST_CPU_BUDGET", "PI_HOST_RESERVE_MEMORY", "PI_HOST_RESERVE_CPUS", "PI_MODEL_ENDPOINTS_FILE", "PI_AUTH_FROM_PI", "PI_AUTH_CONNECTIONS_FILE"]);
5
+ const PRIVATE_ENV_KEYS = Object.freeze(["VALKEY_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN", "OPENAI_API_KEY"]);
6
+
7
+ export async function runCanary(args, { env = process.env, write = (s) => process.stdout.write(s), errWrite = (s) => process.stderr.write(s), deploymentEnv = (e) => ({ env: e }), runtimeFactory, now = Date.now } = {}) {
8
+ let runtime;
9
+ try {
10
+ const { values } = parseArgs({ args, allowPositionals: false, options: { role: { type: "string" }, model: { type: "string" }, flow: { type: "string" }, count: { type: "string", default: "3" }, "cap-usd": { type: "string" }, json: { type: "boolean", default: false } } });
11
+ const options = canaryOptions({ ...values, capUsd: values["cap-usd"] });
12
+ const deployment = deploymentEnv(env, CANARY_ENV_KEYS);
13
+ if (deployment.problem) throw new Error(deployment.problem);
14
+ const credentials = deploymentEnv(deployment.env, PRIVATE_ENV_KEYS);
15
+ if (credentials.problem) throw new Error("canary provider credentials or Valkey configuration cannot be reconciled between this shell and .env; make those sources agree");
16
+ const create = runtimeFactory ?? (await import("./canary-runtime.mjs")).createCanaryRuntime;
17
+ runtime = await create({ env: credentials.env, options, now });
18
+ const report = await executeCanary(options, { ...runtime, now });
19
+ write(values.json ? `${JSON.stringify(report)}\n` : canaryText(report));
20
+ return report.rows.every((r) => r.candidate.outcome === "completed") ? 0 : 2;
21
+ } catch (error) {
22
+ // Runtime errors are deliberately fixed messages: git, Docker and provider errors may carry text from a job.
23
+ errWrite(`error: ${error.message}\n`);
24
+ return 1;
25
+ } finally {
26
+ await runtime?.close?.();
27
+ }
28
+ }
@@ -0,0 +1,41 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { metaKeyPattern, queueNameOfMetaKey } from "./bullmq-internals.mjs";
3
+
4
+ export const CANARY_LOCK_KEY = "dispatch:canary:exclusive";
5
+ export const CANARY_LEASE_MS = 60_000;
6
+ const RENEW = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('PEXPIRE', KEYS[1], ARGV[2]) else return 0 end";
7
+ const RELEASE = "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end";
8
+
9
+ /** Worker pickup asks before any paid work. An unavailable store throws, never admits. */
10
+ export async function readBusy(redis) {
11
+ return Boolean(await redis.get(CANARY_LOCK_KEY));
12
+ }
13
+
14
+ /** The lease owner alone may renew or release it; a timed-out owner cannot erase a successor. */
15
+ export async function acquireCanaryLease(redis, { token = randomUUID() } = {}) {
16
+ if (await redis.set(CANARY_LOCK_KEY, token, "PX", CANARY_LEASE_MS, "NX") !== "OK") return null;
17
+ return {
18
+ async renew() { return Number(await redis.eval(RENEW, 1, CANARY_LOCK_KEY, token, CANARY_LEASE_MS)) === 1; },
19
+ async release() { return Number(await redis.eval(RELEASE, 1, CANARY_LOCK_KEY, token)) === 1; },
20
+ };
21
+ }
22
+
23
+ /** Discover host queues as well as the shared queue, then ask BullMQ's public active-count API. */
24
+ export async function canaryQueueNames(redis) {
25
+ const names = new Set(["pi-jobs"]);
26
+ let cursor = "0", scanned = 0, pages = 0;
27
+ do {
28
+ // MATCH limits returned keys, not the work SCAN does across a shared database.
29
+ if (++pages > 512) throw new Error("canary queue discovery exceeded its bound");
30
+ const next = await redis.scan(cursor, "MATCH", metaKeyPattern("pi-jobs"), "COUNT", 100);
31
+ cursor = next[0];
32
+ for (const key of next[1]) {
33
+ if (++scanned > 4096) throw new Error("canary queue discovery exceeded its bound");
34
+ // The queue's name between BullMQ's `bull:` and `:meta`, or null for a key the pattern's `*` matched that is no
35
+ // queue's meta key: a job's own hash whose three-part custom id ends in `meta` (`bull:pi-jobs:a:b:meta`).
36
+ const name = queueNameOfMetaKey(key);
37
+ if (name !== null && /^pi-jobs(?:@[A-Za-z0-9._-]{1,64})?$/.test(name)) names.add(name);
38
+ }
39
+ } while (cursor !== "0");
40
+ return [...names];
41
+ }
@@ -0,0 +1,237 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import * as fs from "node:fs";
4
+ import { join } from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import { loadConfig, ensureJobsDir } from "./config.mjs";
7
+ import { readOverlay, resolveSettings } from "./runtime-settings.mjs";
8
+ import { readManifest, sandboxExpiry, sandboxManifestNewer } from "./sandbox-store.mjs";
9
+ import { builtinModel, isBuiltinModel } from "./model-catalog.mjs";
10
+ import { resolveProviderCredential } from "./env-allowlist.mjs";
11
+ import { splitModelEntry } from "./model-ref.mjs";
12
+ import { makeImagePreflight } from "./image-preflight.mjs";
13
+ import { makeRunContainer } from "./run-container.mjs";
14
+ import { makeLogSink, makeRecordWriter, buildRecord, recordReadable } from "./run-history.mjs";
15
+ import { materializePiDir } from "./materialize.mjs";
16
+ import { runJob } from "./processor.mjs";
17
+ import { dollarWindowCaps } from "./dollar-budget.mjs";
18
+ import { makeRedisClient, parseConnection } from "./connection.mjs";
19
+ import { makeQueue } from "./queue.mjs";
20
+ import { acquireCanaryLease, canaryQueueNames } from "./canary-lock.mjs";
21
+ import { MODEL_ROLE_RE } from "./model-roles.mjs";
22
+ import { parseTriggersDocument } from "./triggers.mjs";
23
+ import { CONTAINER_HOME } from "./container-spec.mjs";
24
+ import { readAuthPolicy } from "./subscription-config.mjs";
25
+
26
+ // env-internal PATH: host git executable lookup in a clean subprocess environment.
27
+ const exec = promisify(execFile);
28
+ // The same pinned artifact as deploy/docker-compose.yml. A contract test holds the copy to it.
29
+ export const CANARY_PROXY_IMAGE = "ubuntu/squid@sha256:6a097f68bae708cedbabd6188d68c7e2e7a38cedd05a176e1cc0ba29e3bbe029";
30
+ const PROVIDER_HOSTS = Object.freeze({ anthropic: "api.anthropic.com", openai: "api.openai.com" });
31
+ const SHA = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
32
+ const MAX_SOURCE_BYTES = 256 * 1024 * 1024;
33
+ const fail = (message) => { throw new Error(message); };
34
+
35
+ export function canaryProxyPolicy(provider) {
36
+ const host = PROVIDER_HOSTS[provider];
37
+ if (!host) fail("canary supports first-party anthropic and openai only; gateways and custom endpoints cannot establish a no-forge boundary");
38
+ return `http_port 3128\naccess_log none\ncache_log /dev/null\npid_filename /tmp/squid.pid\nacl CONNECT method CONNECT\nacl tls port 443\nacl provider dstdomain ${host}\nacl private dst 0.0.0.0/8 10.0.0.0/8 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 ::1 ::/128 fc00::/7 fe80::/10\nhttp_access deny provider private\nhttp_access allow CONNECT tls provider\nhttp_access deny all\ncache deny all\n`;
39
+ }
40
+
41
+ /** Read regular files without following a job-authored link, bounded before allocating its contents. */
42
+ function regular(path, max = 8 * 1024 * 1024) {
43
+ const fd = fs.openSync(path, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
44
+ try {
45
+ const st = fs.fstatSync(fd);
46
+ if (!st.isFile() || st.size > max) fail("canary source contains an unsupported file");
47
+ return fs.readFileSync(fd);
48
+ } finally { fs.closeSync(fd); }
49
+ }
50
+
51
+ /** Only ordinary object files; no alternates, symlinks, hooks or original config enter the new repository. */
52
+ export function copyCanaryObjects(source, destination) {
53
+ let bytes = 0, files = 0;
54
+ function copy(from, to, relative = "") {
55
+ if (!fs.lstatSync(from).isDirectory()) fail("canary source objects must be directories without links");
56
+ fs.mkdirSync(to, { recursive: true, mode: 0o700 });
57
+ for (const name of fs.readdirSync(from)) {
58
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || name === ".." || name === ".") fail("canary source has an invalid object path");
59
+ const path = relative ? `${relative}/${name}` : name;
60
+ if (path === "info/alternates" || path === "info/http-alternates") fail("canary source uses alternate object stores; retain a self-contained source");
61
+ const src = join(from, name), dest = join(to, name), st = fs.lstatSync(src);
62
+ if (st.isDirectory()) copy(src, dest, path);
63
+ else {
64
+ if (!st.isFile() || ++files > 100_000 || (bytes += st.size) > MAX_SOURCE_BYTES) fail("canary source object store is unsupported or too large");
65
+ fs.writeFileSync(dest, regular(src, MAX_SOURCE_BYTES), { mode: 0o600 });
66
+ }
67
+ }
68
+ }
69
+ copy(source, destination);
70
+ }
71
+
72
+ function sourceFor(record, config, now) {
73
+ const source = readManifest({ sandboxDir: config.sandboxDir, jobId: record.jobId });
74
+ if (!source || source.jobId !== record.jobId || sandboxExpiry(source, { at: now(), retentionHours: config.sandboxRetentionHours }).expired) fail("a selected source sandbox is absent or expired; enable retention and run the flow again");
75
+ // Issue #761: a manifest a newer pi-dispatch kept may have reshaped the fields read below (its venue, its base commit,
76
+ // its workspace), the reason `sandboxSyncRefusal` refuses to reopen one. Before any of them is read.
77
+ if (sandboxManifestNewer(source)) fail("a selected source sandbox was kept by a newer pi-dispatch; run the canary with that release");
78
+ if (source.backend !== "local" || !SHA.test(source.baseSha ?? "")) fail("a selected source has no retained base commit on the local venue; run the flow again with this version");
79
+ if (!source.workspace || !fs.lstatSync(source.workspace).isDirectory() || !fs.lstatSync(join(source.workspace, ".git")).isDirectory()) fail("a selected source has no self-contained retained git repository");
80
+ // These files were mounted read-only to the original job. Never reconstruct a task from its PII-free record.
81
+ if (record.bundle || record.secretsProfile || record.session?.resumed === true || record.fork || record.recoveryOf) fail("canary cannot replay a source requiring secrets, a previous session, recovery or a fork");
82
+ const roles = JSON.parse(regular(join(source.dir, "model-roles.json")));
83
+ if (roles.version !== 1 || !roles.roles || !Object.hasOwn(roles.roles, record.modelRole)) fail("canary source has no retained role snapshot; run the flow again");
84
+ return { ...source, roles: roles.roles, prompt: regular(join(source.dir, "prompt.md")), event: regular(join(source.dir, "event.json")) };
85
+ }
86
+
87
+ export async function createCanaryRuntime({ env, options, now = Date.now, execCommand = exec, redisFactory = makeRedisClient, queueFactory = makeQueue, imagePreflightFactory = makeImagePreflight, containerFactory = makeRunContainer, runJobFn = runJob }) {
88
+ const config = loadConfig(env);
89
+ if (config.authConnectionsFile) {
90
+ let policy;
91
+ try { policy = readAuthPolicy(config.authConnectionsFile); }
92
+ catch { fail("canary cannot validate the subscription connection policy; fix the reviewed file before retrying"); }
93
+ if (policy.bindings[splitModelEntry(options.model)?.provider]) fail("canary does not support a bound subscription connection; it cannot substitute an environment API key");
94
+ }
95
+ const settings = resolveSettings(config, readOverlay(config.settingsFile));
96
+ if (settings.invalid) fail("canary settings overlay is invalid; run doctor");
97
+ if (config.backends.length !== 1 || config.defaultBackend !== "local") fail("canary currently requires the local Docker venue");
98
+ if (!process.getuid?.() || !process.getgid?.()) fail("canary requires a non-root local account");
99
+ if (config.envelopeFile || config.scopedLimitsFile || config.pauseWindowsFile !== null || env.PI_HOST_MEMORY_BUDGET || env.PI_HOST_CPU_BUDGET || env.PI_HOST_RESERVE_MEMORY || env.PI_HOST_RESERVE_CPUS) fail("canary cannot bypass envelope, scoped, pause-window or host-capacity admission; use a deployment without those policies");
100
+ if (env.PI_MODEL_ENDPOINTS_FILE) fail("canary cannot use declared custom endpoints; use a first-party-only deployment");
101
+ if (!MODEL_ROLE_RE.test(options.role)) fail("canary role is invalid");
102
+ let declared;
103
+ try { declared = parseTriggersDocument(fs.readFileSync(config.triggersFile ?? "triggers.json", "utf8"), config.triggersFile ?? "triggers.json").modelRoles; }
104
+ catch { fail("canary cannot read the reviewed modelRoles map; check triggers.json"); }
105
+ if (!Object.values(declared ?? {}).some((map) => Object.hasOwn(map, options.role))) fail("canary role is not declared in modelRoles");
106
+ const { provider, model } = splitModelEntry(options.model);
107
+ const policy = canaryProxyPolicy(provider);
108
+ const builtin = builtinModel(provider, model);
109
+ if (!isBuiltinModel(provider, model, { chatOnly: true }) || new URL(builtin.baseUrl).hostname !== PROVIDER_HOSTS[provider]) fail("canary candidate must be a pinned first-party chat model");
110
+ const credential = resolveProviderCredential({ provider, hostEnv: env, authFromPi: config.authFromPi });
111
+ if (!credential || !Object.values(credential).some(Boolean)) fail("canary provider credential is missing; configure the first-party provider key");
112
+ const providerEnv = credential;
113
+ let records;
114
+ // A record a newer pi-dispatch wrote is left out here (issue #761, `recordReadable`): the role and flow a source is chosen
115
+ // by are not core fields, so they may mean something else in it. `selectCanarySources` asks again, for any caller.
116
+ try { records = fs.readdirSync(config.logsDir).filter((n) => /^[A-Za-z0-9._-]+\.json$/.test(n)).flatMap((n) => { try { return [JSON.parse(regular(join(config.logsDir, n), 256 * 1024))]; } catch { return []; } }).filter((record) => recordReadable(record)); }
117
+ catch { fail("canary run history cannot be read; run this command on the host retaining the originals"); }
118
+ ensureJobsDir(config.jobsDir, { env });
119
+ const root = fs.mkdtempSync(join(config.jobsDir, "canary-"));
120
+ const name = `pi-dispatch-canary-${randomUUID()}`;
121
+ const proxy = `${name}-proxy`, outNetwork = `${name}-out`;
122
+ const run = async (args) => {
123
+ try { return await execCommand("docker", args, { timeout: 30_000, maxBuffer: 1024 * 1024 }); }
124
+ catch { fail("canary Docker operation failed; check the local daemon and pinned images"); }
125
+ };
126
+ let redis, queues = [], proxyMade = false, outMade = false, lease = null, renewTimer = null, leaseLost = false, interrupted = false;
127
+ const active = new Set();
128
+ const stops = new Set();
129
+ const removeContainer = async (container) => {
130
+ try { await run(["rm", "-f", container]); }
131
+ catch {
132
+ // --rm may already have removed it. Only a successful daemon listing proves absence.
133
+ const listed = await run(["ps", "-aq", "--filter", `name=^/${container}$`]);
134
+ if (listed.stdout.trim() !== "") fail("canary container remains after cleanup");
135
+ }
136
+ };
137
+ const close = async () => {
138
+ if (renewTimer) clearInterval(renewTimer);
139
+ const leftovers = [];
140
+ for (const container of active) await removeContainer(container).catch(() => leftovers.push(container));
141
+ if (proxyMade) await removeContainer(proxy).catch(() => leftovers.push(proxy));
142
+ if (outMade) await run(["network", "rm", outNetwork]).catch(() => leftovers.push(outNetwork));
143
+ for (const queue of queues) await queue.close().catch(() => {});
144
+ if (lease && !leftovers.length) await lease.release().catch(() => leftovers.push("canary lease (expires in 60 seconds)"));
145
+ redis?.disconnect();
146
+ if (leftovers.length) fail(`canary cleanup failed; remove these Docker resources before another experiment: ${leftovers.join(", ")}`);
147
+ fs.rmSync(root, { recursive: true, force: true });
148
+ };
149
+ try {
150
+ // Proxy validation and the model/image checks run before the first count or dollar reservation.
151
+ await run(["image", "inspect", CANARY_PROXY_IMAGE]);
152
+ const preflight = imagePreflightFactory({ image: config.jobImage });
153
+ const checked = await preflight({ kind: "local", models: [options.model], maxCostMicros: 1 });
154
+ if (!checked.ok || !["costCap", "modelPolicy", "modelRoles", "exitAuth", "anyUid"].every((c) => checked.capabilities?.includes(c))) fail("canary job image must declare costCap, modelPolicy, modelRoles, exitAuth and anyUid; rebuild the job image");
155
+ redis = redisFactory(config.valkeyUrl, { failFast: true, lazyConnect: true });
156
+ redis.on("error", () => {});
157
+ if (typeof redis.connect === "function") await redis.connect();
158
+ await redis.ping();
159
+ lease = await acquireCanaryLease(redis);
160
+ if (!lease) fail("another canary owns the deployment admission lease; wait for it to finish");
161
+ const loseLease = () => { leaseLost = true; for (const stop of stops) stop(); };
162
+ renewTimer = setInterval(() => { void lease.renew().then((ok) => { if (!ok) loseLease(); }, loseLease); }, 10_000);
163
+ queues = (await canaryQueueNames(redis)).map((name) => queueFactory(parseConnection(config.valkeyUrl, { failFast: true }), { name }));
164
+ for (const queue of queues) if ((await queue.getJobCounts("active")).active !== 0) fail("canary refused while jobs are active; wait for those runs to finish");
165
+ const unpaused = async () => {
166
+ if (interrupted) fail("canary experiment was stopped; no further replay was started");
167
+ if (leaseLost || !await lease.renew()) { loseLease(); fail("canary lost its admission lease; the experiment was stopped"); }
168
+ for (const queue of queues) if (await queue.isPaused()) fail("canary refused while dispatch is paused; resume it explicitly first");
169
+ };
170
+ await unpaused();
171
+ const sink = makeLogSink({ logsDir: config.logsDir, enabled: false });
172
+ const writer = makeRecordWriter({ logsDir: config.logsDir });
173
+ const createProxy = async () => {
174
+ if (proxyMade) return;
175
+ fs.writeFileSync(join(root, "squid.conf"), policy, { mode: 0o644 });
176
+ await run(["network", "create", outNetwork]); outMade = true;
177
+ await run(["run", "--pull=never", "--rm", "-d", "--name", proxy, "--network", outNetwork, "--user", "proxy", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--entrypoint", "/usr/sbin/squid", "--mount", `type=bind,source=${join(root, "squid.conf")},target=/etc/squid/squid.conf,readonly`, CANARY_PROXY_IMAGE, "-f", "/etc/squid/squid.conf", "-NYC"]); proxyMade = true;
178
+ await run(["exec", proxy, "squid", "-k", "parse"]);
179
+ };
180
+ const prepareSource = async (source) => {
181
+ const jobDir = fs.mkdtempSync(join(root, "job-")), workspace = join(jobDir, "workspace");
182
+ const gitEnv = { PATH: process.env.PATH, HOME: root, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_TERMINAL_PROMPT: "0" };
183
+ const git = async (args) => { try { return await execCommand("git", args, { env: gitEnv, timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); } catch { fail("canary base commit cannot be restored from its retained object store"); } };
184
+ fs.mkdirSync(workspace);
185
+ await git(["init", "--template=", workspace]);
186
+ copyCanaryObjects(join(source.workspace, ".git", "objects"), join(workspace, ".git", "objects"));
187
+ await git(["-C", workspace, "-c", "core.hooksPath=/dev/null", "checkout", "--detach", source.baseSha]);
188
+ fs.writeFileSync(join(jobDir, "prompt.md"), source.prompt, { mode: 0o400 });
189
+ fs.writeFileSync(join(jobDir, "event.json"), source.event, { mode: 0o400 });
190
+ const materialized = await materializePiDir({ gitDir: workspace, sha: source.baseSha, destDir: jobDir });
191
+ if (materialized.outcome === "policy") fail("canary base instructions exceed the materializer limits");
192
+ try { regular(join(jobDir, "pi", "skills", options.flow, "SKILL.md")); }
193
+ catch { fail("canary flow must be present in the retained base repository; injected and overlay-only flows are unsupported"); }
194
+ return { ...source, prepared: { jobDir, workspace, sha: source.baseSha } };
195
+ };
196
+ return {
197
+ records, deploymentCapUsd: settings.maxCostUsd, close,
198
+ inspectSource: async (record) => {
199
+ const source = sourceFor(record, config, now);
200
+ for (const entry of Object.values({ ...source.roles, [options.role]: options.model })) {
201
+ const ref = splitModelEntry(entry);
202
+ if (ref.provider !== provider || !isBuiltinModel(ref.provider, ref.model, { chatOnly: true }) || new URL(builtinModel(ref.provider, ref.model).baseUrl).hostname !== PROVIDER_HOSTS[provider]) fail("canary source roles need endpoints outside the isolated provider policy; use a single first-party provider");
203
+ }
204
+ return prepareSource(source);
205
+ },
206
+ execute: async ({ source, original, jobId, maxCostMicros }) => {
207
+ await unpaused();
208
+ await createProxy();
209
+ const { jobDir, workspace } = source.prepared;
210
+ const modelRoles = { ...source.roles, [options.role]: options.model };
211
+ const job = { kind: "local", folder: workspace, flow: options.flow, provider, model, modelRole: options.role, modelRoles, models: [...new Set([options.model, ...Object.values(modelRoles)])], maxCostMicros, maxTurns: settings.maxTurns, maxTokens: settings.maxTokens, packages: false, canary: { sourceJobId: original.jobId, role: options.role, candidate: options.model } };
212
+ const startedAt = new Date(now()).toISOString();
213
+ let result, error;
214
+ try {
215
+ const runContainer = containerFactory({ image: config.jobImage, hostEnv: providerEnv, allowGlobalExtensions: false, packagePaths: [], egress: true, egressProxy: proxy, onOutput: () => {}, openJobLog: sink });
216
+ result = await runJobFn(job, { redis, caps: { day: settings.dailyCap, week: settings.weeklyCap, month: settings.monthlyCap }, softHoldPct: settings.softHoldPct, tokenCap: settings.dailyTokenCap, dollarCaps: dollarWindowCaps(settings), now: new Date(now()), imagePreflight: async () => checked, checkProviderCredential: () => ({ ok: true }), prepareWorkspace: async () => ({ jobDir, workspace, sha: source.baseSha }), runContainer: async (args) => {
217
+ const controller = new AbortController();
218
+ const stop = () => { interrupted = true; if (!controller.signal.aborted) { controller.abort(); void run(["stop", "-t", "5", jobId]).catch(() => {}); } };
219
+ stops.add(stop);
220
+ const timer = setTimeout(stop, 30 * 60_000);
221
+ const pausePoll = setInterval(() => { void unpaused().catch(stop); }, 1000);
222
+ process.once("SIGINT", stop); process.once("SIGTERM", stop);
223
+ active.add(jobId);
224
+ try { return await runContainer({ ...args, name: jobId, signal: controller.signal, exitAuth: true, jobId, user: `${process.getuid()}:${process.getgid()}`, home: CONTAINER_HOME }); }
225
+ finally { stops.delete(stop); clearTimeout(timer); clearInterval(pausePoll); process.removeListener("SIGINT", stop); process.removeListener("SIGTERM", stop); await removeContainer(jobId).then(() => active.delete(jobId), () => {}); }
226
+ }, comment: async () => {}, collectChain: async () => ({ enqueued: 0, refused: 0 }), cleanup: async () => {} });
227
+ if (result) result = { ...result, modelRole: options.role };
228
+ return result;
229
+ } catch (e) { error = e; throw new Error("canary replay failed; inspect its run record before retrying"); }
230
+ finally {
231
+ await writer(buildRecord({ job: { id: jobId, data: job }, result, error, startedAt, endedAt: new Date(now()).toISOString(), host: config.workerName, defaultBackend: "local" }));
232
+ if (!active.has(jobId)) fs.rmSync(jobDir, { recursive: true, force: true });
233
+ }
234
+ },
235
+ };
236
+ } catch (error) { await close(); throw error; }
237
+ }
package/src/canary.mjs ADDED
@@ -0,0 +1,53 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { modelListProblem } from "./model-ref.mjs";
3
+ import { parseUsdMicros, optionalUsdMicros } from "./money.mjs";
4
+ import { MODEL_ROLE_RE } from "./model-roles.mjs";
5
+ import { SKILL_NAME_RE } from "./flow-gate.mjs";
6
+ import { recordReadable } from "./run-history.mjs";
7
+
8
+ /** A canary pays for fresh isolated runs, never for a modification to the role map. */
9
+ export function canaryOptions({ role, model, flow, count = 3, capUsd }) {
10
+ if (typeof role !== "string" || !MODEL_ROLE_RE.test(role) || role === "inherit") throw new Error("--role must name a configured model role");
11
+ if (modelListProblem([model]) !== null) throw new Error("--model must be provider/model");
12
+ if (typeof flow !== "string" || !SKILL_NAME_RE.test(flow)) throw new Error("--flow must name a flow");
13
+ if (!Number.isInteger(Number(count)) || Number(count) < 1 || Number(count) > 10) throw new Error("--count must be 1 to 10");
14
+ const capMicros = parseUsdMicros(capUsd, "--cap-usd");
15
+ return { role, model, flow, count: Number(count), capMicros };
16
+ }
17
+
18
+ /**
19
+ * The completed runs of `options.flow` under `options.role` a canary replays, newest first. A record a newer pi-dispatch
20
+ * wrote is skipped (issue #761, `recordReadable`): only its core fields keep their meaning in every version, and the
21
+ * role, the flow and the cost read here are not among them.
22
+ */
23
+ export function selectCanarySources(records, options, { now, windowMs = 7 * 86400_000 }) {
24
+ return records.filter((r) => r && recordReadable(r) && !r.canary && r.modelRole === options.role && r.flow === options.flow && r.outcome === "completed" && typeof r.jobId === "string" && /^[A-Za-z0-9._:-]{1,200}$/.test(r.jobId) && Number.isFinite(Date.parse(r.endedAt)) && Date.parse(r.endedAt) <= now && Date.parse(r.endedAt) >= now - windowMs)
25
+ .sort((a, b) => Date.parse(b.endedAt) - Date.parse(a.endedAt) || a.jobId.localeCompare(b.jobId)).slice(0, options.count);
26
+ }
27
+
28
+ /**
29
+ * Allocate the whole experiment before starting. Unreported spend never buys another replay: each
30
+ * original gets at most its fixed share, even if a prior container died without a trustworthy meter.
31
+ * Every source is checked first, so an expired last source cannot strand a paid partial experiment.
32
+ */
33
+ export async function executeCanary(options, { records, now = Date.now, inspectSource, execute, deploymentCapUsd = null, id = () => `canary-${randomUUID()}` }) {
34
+ const originals = selectCanarySources(records, options, { now: now() });
35
+ if (originals.length === 0) throw new Error("no completed jobs of this flow in the last seven days; run the flow with sandbox retention enabled first");
36
+ const sources = [];
37
+ for (const original of originals) sources.push(await inspectSource(original));
38
+ const share = Math.floor(options.capMicros / sources.length);
39
+ const perJob = Math.min(share, optionalUsdMicros(deploymentCapUsd, "maxCostUsd") ?? share);
40
+ if (perJob < 1) throw new Error("the canary cap is too small to allocate one micro-dollar per job");
41
+ const rows = [];
42
+ for (let i = 0; i < sources.length; i++) {
43
+ const jobId = id();
44
+ const result = await execute({ source: sources[i], original: originals[i], jobId, options, maxCostMicros: perJob });
45
+ rows.push({ sourceJobId: originals[i].jobId, jobId, original: { model: `${originals[i].provider}/${originals[i].model}`, outcome: originals[i].outcome, cost: originals[i].tokens?.cost ?? null }, candidate: { model: options.model, outcome: result.outcome, reason: result.reason ?? null, cost: result.tokens?.cost ?? null }, capMicros: perJob });
46
+ }
47
+ return { role: options.role, flow: options.flow, model: options.model, capMicros: options.capMicros, allocatedMicros: perJob * rows.length, whatIf: `/insights?whatif=${encodeURIComponent(options.model)}&flow=${encodeURIComponent(options.flow)}`, rows };
48
+ }
49
+
50
+ export function canaryText(report) {
51
+ const money = (value) => typeof value === "number" && Number.isFinite(value) ? `$${value.toFixed(6)}` : "unknown";
52
+ return [`Canary ${report.role}: ${report.model}, cap $${(report.capMicros / 1e6).toFixed(6)}`, "Original run | original cost / outcome | candidate run | candidate cost / outcome", ...report.rows.map((r) => `${r.sourceJobId} | ${money(r.original.cost)} / ${r.original.outcome} | ${r.jobId} | ${money(r.candidate.cost)} / ${r.candidate.outcome}${r.candidate.reason ? ` (${r.candidate.reason})` : ""}`), `What-if: ${report.whatIf}`, "The role map is unchanged. Workspaces are disposable; forge access is blocked.", ""].join("\n");
53
+ }