@edgehero/pi-dispatch 4.3.1 → 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 (256) hide show
  1. package/.env.example +84 -3
  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 +190 -29
  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 +1639 -196
  74. package/src/dollar-budget.mjs +212 -3
  75. package/src/endpoint-status.mjs +100 -0
  76. package/src/env-allowlist.mjs +96 -6
  77. package/src/env-file.mjs +6 -4
  78. package/src/file-expect.mjs +47 -0
  79. package/src/forgejo-host.mjs +42 -5
  80. package/src/forgejo-prompt.mjs +5 -4
  81. package/src/forges.mjs +33 -3
  82. package/src/fork-job.mjs +58 -0
  83. package/src/fork-policy.mjs +23 -0
  84. package/src/fork-store.mjs +338 -0
  85. package/src/get-token.mjs +1 -1
  86. package/src/git-dirty.mjs +119 -14
  87. package/src/git-hardening.mjs +85 -0
  88. package/src/github-host.mjs +31 -6
  89. package/src/github-prompt.mjs +7 -16
  90. package/src/gitlab-host.mjs +42 -3
  91. package/src/gitlab-prompt.mjs +5 -4
  92. package/src/held-document.mjs +23 -0
  93. package/src/held-replies.mjs +325 -0
  94. package/src/held-settle.mjs +266 -0
  95. package/src/hook-runs.mjs +188 -0
  96. package/src/host-budget.mjs +8 -5
  97. package/src/host-clone.mjs +72 -0
  98. package/src/host-loads.mjs +557 -0
  99. package/src/host-registry.mjs +66 -6
  100. package/src/host-telemetry.mjs +92 -0
  101. package/src/http-body.mjs +78 -0
  102. package/src/image-preflight.mjs +156 -13
  103. package/src/import-pi.mjs +60 -172
  104. package/src/inbox-relay.mjs +482 -0
  105. package/src/inbox-settle.mjs +196 -0
  106. package/src/index.mjs +734 -129
  107. package/src/init.mjs +35 -5
  108. package/src/job-data-version.mjs +75 -0
  109. package/src/job-id.mjs +30 -15
  110. package/src/job-layout.mjs +22 -0
  111. package/src/job-size.mjs +16 -0
  112. package/src/live-jobs.mjs +14 -6
  113. package/src/live-probes.mjs +6 -2
  114. package/src/live-transcript.mjs +872 -0
  115. package/src/loaded-record.mjs +609 -0
  116. package/src/mcp-config.mjs +1 -1
  117. package/src/mcp-preflight.mjs +36 -5
  118. package/src/model-attempts.mjs +84 -0
  119. package/src/model-catalog.mjs +83 -0
  120. package/src/model-cooldowns.mjs +22 -0
  121. package/src/model-failover-protocol.mjs +11 -0
  122. package/src/model-failover.mjs +134 -0
  123. package/src/model-observation.mjs +50 -0
  124. package/src/model-proposal-protocol.mjs +21 -0
  125. package/src/model-proposals.mjs +75 -0
  126. package/src/model-ref.mjs +67 -10
  127. package/src/model-roles-file.mjs +24 -0
  128. package/src/model-roles.mjs +73 -0
  129. package/src/model-status.mjs +34 -0
  130. package/src/models-json.mjs +4 -1
  131. package/src/money.mjs +1 -1
  132. package/src/oauth-openai.mjs +121 -0
  133. package/src/older-state.mjs +249 -0
  134. package/src/on-failure.mjs +17 -13
  135. package/src/open-browser.mjs +31 -2
  136. package/src/opener-cli.mjs +135 -0
  137. package/src/opener-protocol.mjs +27 -0
  138. package/src/opener-terminal.mjs +128 -0
  139. package/src/openshell-cli.mjs +155 -0
  140. package/src/openshell-policy.mjs +163 -0
  141. package/src/outbox.mjs +14 -0
  142. package/src/output-cap.mjs +1 -0
  143. package/src/package-canary-record.mjs +26 -0
  144. package/src/package-canary.mjs +135 -0
  145. package/src/package-names.mjs +9 -0
  146. package/src/package-stage.mjs +418 -0
  147. package/src/packages.mjs +24 -16
  148. package/src/parked.mjs +2 -1
  149. package/src/pause-windows.mjs +136 -17
  150. package/src/podman-stack.mjs +40 -14
  151. package/src/portfolio-snapshot.mjs +26 -7
  152. package/src/prepare-github.mjs +41 -77
  153. package/src/prepare-local.mjs +32 -2
  154. package/src/prepare.mjs +45 -11
  155. package/src/private-store-boundary.mjs +70 -0
  156. package/src/processor.mjs +1550 -173
  157. package/src/projects.mjs +23 -0
  158. package/src/prompt-fence.mjs +18 -0
  159. package/src/provider-key.mjs +7 -0
  160. package/src/provider-steering.mjs +1 -0
  161. package/src/question-context.mjs +35 -0
  162. package/src/question-group-runtime.mjs +91 -0
  163. package/src/question-groups.mjs +171 -0
  164. package/src/queue.mjs +264 -55
  165. package/src/reason-catalog.mjs +448 -0
  166. package/src/receiver-status.mjs +230 -0
  167. package/src/record-reason.mjs +37 -0
  168. package/src/recovery-queue.mjs +566 -0
  169. package/src/redact-secrets.mjs +219 -0
  170. package/src/reserve-estimate.mjs +101 -0
  171. package/src/reserved-env.mjs +36 -1
  172. package/src/retention.mjs +67 -0
  173. package/src/run-budget-ledger.mjs +283 -0
  174. package/src/run-checkpoint.mjs +627 -0
  175. package/src/run-container.mjs +749 -33
  176. package/src/run-history.mjs +791 -45
  177. package/src/run-mirror.mjs +75 -7
  178. package/src/run-progress.mjs +751 -0
  179. package/src/runtime-observations.mjs +3 -1
  180. package/src/runtime-settings.mjs +272 -37
  181. package/src/sandbox-cli.mjs +78 -2
  182. package/src/sandbox-deployment.mjs +51 -0
  183. package/src/sandbox-gateway-protocol.mjs +378 -0
  184. package/src/sandbox-hosts.mjs +100 -0
  185. package/src/sandbox-inventory.mjs +83 -0
  186. package/src/sandbox-policy.mjs +225 -0
  187. package/src/sandbox-prepare-requests.mjs +145 -0
  188. package/src/sandbox-prepare.mjs +285 -0
  189. package/src/sandbox-refs.mjs +70 -0
  190. package/src/sandbox-session-monitor.mjs +4 -0
  191. package/src/sandbox-session-watch.mjs +124 -0
  192. package/src/sandbox-sessions.mjs +161 -0
  193. package/src/sandbox-store.mjs +63 -6
  194. package/src/sandbox-workspace-guard.mjs +39 -0
  195. package/src/sandbox.mjs +27 -200
  196. package/src/scheduler-stall-guard.mjs +14 -8
  197. package/src/schedules.mjs +19 -6
  198. package/src/secret-bundles.mjs +245 -0
  199. package/src/secret-profiles.mjs +103 -3
  200. package/src/secrets.mjs +19 -9
  201. package/src/service-env.mjs +38 -5
  202. package/src/service.mjs +638 -106
  203. package/src/session-key.mjs +60 -0
  204. package/src/session-store.mjs +168 -10
  205. package/src/setup-contract.mjs +40 -0
  206. package/src/setup-egress.mjs +113 -0
  207. package/src/setup-host-entry.mjs +33 -0
  208. package/src/setup-host.mjs +242 -0
  209. package/src/setup-lock.mjs +41 -0
  210. package/src/size-records.mjs +15 -3
  211. package/src/start.mjs +1353 -135
  212. package/src/status-comment.mjs +415 -0
  213. package/src/stderr-transcript.mjs +318 -0
  214. package/src/steer-ack.mjs +122 -0
  215. package/src/steer-cli.mjs +213 -0
  216. package/src/steer-framing.mjs +185 -0
  217. package/src/steer-inbox.mjs +693 -0
  218. package/src/store-version.mjs +139 -0
  219. package/src/subscription-config.mjs +56 -0
  220. package/src/subscription-limits.mjs +3 -0
  221. package/src/subscription-quota.mjs +259 -0
  222. package/src/subscription-runtime.mjs +123 -0
  223. package/src/subscription-status.mjs +42 -0
  224. package/src/subscription-wait.mjs +11 -0
  225. package/src/subscriptions.mjs +7 -1
  226. package/src/thinking-level.mjs +19 -0
  227. package/src/touch-outbox.mjs +61 -0
  228. package/src/touch-state.mjs +133 -0
  229. package/src/triggers-file.mjs +299 -39
  230. package/src/triggers.mjs +780 -38
  231. package/src/trust-paths.mjs +171 -0
  232. package/src/up.mjs +398 -51
  233. package/src/updates-cli.mjs +61 -0
  234. package/src/updates-look.mjs +127 -0
  235. package/src/updates-reconcile.mjs +182 -0
  236. package/src/updates-subscription-adapters.mjs +8 -0
  237. package/src/updates.mjs +252 -0
  238. package/src/valkey-auth.mjs +32 -9
  239. package/src/valkey-endpoint.mjs +14 -4
  240. package/src/wait-for.mjs +1 -1
  241. package/src/wait-state.mjs +62 -3
  242. package/src/watch-board.mjs +38 -0
  243. package/src/watch-closer.mjs +1 -1
  244. package/src/watch-contract.mjs +102 -0
  245. package/src/watch-decision.mjs +78 -0
  246. package/src/watch-drainer.mjs +14 -0
  247. package/src/watch-issue-adapter.mjs +44 -0
  248. package/src/watch-issue.mjs +111 -0
  249. package/src/watch-record.mjs +10 -0
  250. package/src/watch-runtime.mjs +167 -0
  251. package/src/watch-store.mjs +183 -0
  252. package/src/watch-trace.mjs +34 -0
  253. package/src/workspace-checkpoint.mjs +1750 -0
  254. package/src/zero-touch-fixes.mjs +99 -0
  255. package/src/zero-touch.mjs +225 -0
  256. package/src/repeat-slot.mjs +0 -13
@@ -0,0 +1,42 @@
1
+ import { SUBSCRIPTION_CONNECTION_LIMIT, SUBSCRIPTION_QUOTA_BYTES } from "./subscription-limits.mjs";
2
+
3
+ // One maximum quota record per connection plus its bounded safe projection fields.
4
+ export const SUBSCRIPTION_STATUS_BYTES = SUBSCRIPTION_CONNECTION_LIMIT * (SUBSCRIPTION_QUOTA_BYTES + 16_384);
5
+ export const subscriptionStatusKey = (host) => `dispatch:subscription-status:${host}`;
6
+ const SAFE_HOST = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
7
+ /** A bounded, ephemeral projection. Credential data and binding digests never enter this keyspace. */
8
+ export async function publishSubscriptionStatus(redis, host, runtime) {
9
+ if (!SAFE_HOST.test(host)) throw new Error("invalid subscription owner host");
10
+ const status = await runtime.status();
11
+ const connections = status.connections.map(({ accountGroup, generation, quota, ...row }) => ({ ...row, ...(quota ? { quota: Object.fromEntries(Object.entries(quota).filter(([key]) => !["accountDigest", "generation", "bucket"].includes(key))) } : {}) }));
12
+ const payload = JSON.stringify({ ...status, connections });
13
+ if (connections.length > SUBSCRIPTION_CONNECTION_LIMIT || Buffer.byteLength(payload) > SUBSCRIPTION_STATUS_BYTES) throw new Error("subscription status exceeds its bounds");
14
+ await redis.set(subscriptionStatusKey(host), payload, "PX", 90_000);
15
+ }
16
+ export async function readSubscriptionStatus(redis, hosts, { timeoutMs = 2500 } = {}) {
17
+ const requested = [...new Set(hosts)].filter((host) => SAFE_HOST.test(host));
18
+ const names = requested.slice(0, 64);
19
+ let timer;
20
+ let results;
21
+ try { results = await Promise.race([Promise.all(names.map((name) => redis.get(subscriptionStatusKey(name)))), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("subscription status unavailable")), timeoutMs); timer.unref?.(); })]); }
22
+ finally { clearTimeout(timer); }
23
+ let partial = requested.length > names.length || names.length === 0 || results.some((raw) => raw === null);
24
+ const connections = new Map();
25
+ for (const [index, raw] of results.entries()) {
26
+ if (raw === null) continue;
27
+ try {
28
+ if (Buffer.byteLength(raw) > SUBSCRIPTION_STATUS_BYTES) throw new Error("oversized status");
29
+ const value = JSON.parse(raw);
30
+ if (value.state !== "ready" || !Array.isArray(value.connections) || value.connections.length > SUBSCRIPTION_CONNECTION_LIMIT) throw new Error("invalid status");
31
+ if (value.partial !== false) partial = true;
32
+ for (const row of value.connections) {
33
+ if (!row || !SAFE_HOST.test(row.ownerHost) || !SAFE_HOST.test(row.id)) { partial = true; continue; }
34
+ const key = `${row.ownerHost}:${row.id}`;
35
+ const owner = row.ownerHost === names[index];
36
+ if (owner || !connections.has(key)) connections.set(key, { owner, row: owner ? row : { ...row, state: "other-host" } });
37
+ }
38
+ } catch { partial = true; }
39
+ }
40
+ if ([...connections.values()].some((value) => !value.owner)) partial = true;
41
+ return { state: partial ? "partial" : "ready", partial, connections: [...connections.values()].map((value) => value.row) };
42
+ }
@@ -0,0 +1,11 @@
1
+ /** Host-only control flow for a determinate, free subscription admission delay. Never queued authority. */
2
+ export class SubscriptionWait extends Error {
3
+ constructor({ wait, until }) {
4
+ if (!["subscription-auth", "subscription-quota", "subscription-owner"].includes(wait) || !Number.isSafeInteger(until) || until < 0 || until > 253_402_300_799_999) throw new TypeError("invalid subscription admission wait");
5
+ super("subscription admission must wait");
6
+ this.name = "SubscriptionWait";
7
+ this.wait = wait;
8
+ this.until = until;
9
+ this.budgetReserved = false;
10
+ }
11
+ }
@@ -92,7 +92,13 @@ export function parseSubscriptions(text, path) {
92
92
  throw configError(`subscriptions file must declare an integer "version" >= 1 (got ${JSON.stringify(version)}): ${path}`);
93
93
  }
94
94
  if (version > SUBSCRIPTIONS_VERSION) {
95
- throw configError(`subscriptions file written by a newer pi-dispatch (version ${version}; this build understands ${SUBSCRIPTIONS_VERSION}): ${path}`);
95
+ // Marked `newer`, as `parseTriggersDocument` marks its own (issue #761), so the admin's writer refuses this
96
+ // file for the reason the views give by this flag alone: a reading of `version` of its own can disagree at an
97
+ // edge such as 1e300 (`Number.isSafeInteger` against the `Number.isInteger` above), and a match on these words
98
+ // would break the day they are reworded.
99
+ const error = configError(`subscriptions file written by a newer pi-dispatch (version ${version}; this build understands ${SUBSCRIPTIONS_VERSION}): ${path}`);
100
+ error.newer = true;
101
+ throw error;
96
102
  }
97
103
 
98
104
  const entries = parsed.subscriptions;
@@ -0,0 +1,19 @@
1
+ import { clampThinkingLevel } from "@earendil-works/pi-ai/models";
2
+ import { modelEntryOf } from "./model-endpoints.mjs";
3
+ import { FORK_THINKING_LEVELS } from "./fork-job.mjs";
4
+
5
+ /** The pinned provider composer's two reasoning fields, including its per-level override merge. */
6
+ export function thinkingModel({ models, provider, modelId, builtinModel = () => null }) {
7
+ const { defined, override } = modelEntryOf(models, provider, modelId);
8
+ const base = defined ? { reasoning: defined.reasoning ?? false, thinkingLevelMap: defined.thinkingLevelMap } : builtinModel(provider, modelId);
9
+ if (!base) return null;
10
+ return {
11
+ reasoning: override?.reasoning ?? base.reasoning,
12
+ thinkingLevelMap: override?.thinkingLevelMap ? { ...base.thinkingLevelMap, ...override.thinkingLevelMap } : base.thinkingLevelMap,
13
+ };
14
+ }
15
+ export function thinkingLevelAllowed(job, { models, builtinModel } = {}) {
16
+ if (!FORK_THINKING_LEVELS.includes(job.thinkingLevel)) return false;
17
+ const model = thinkingModel({ models, provider: job.provider, modelId: job.model, builtinModel });
18
+ return model !== null && clampThinkingLevel(model, job.thinkingLevel) === job.thinkingLevel;
19
+ }
@@ -0,0 +1,61 @@
1
+ import * as nodeFs from "node:fs";
2
+ import { join } from "node:path";
3
+ import { cleanTouch } from "./touch-state.mjs";
4
+
5
+ /**
6
+ * An applied action must never be repeated merely because its metric did not publish. The durable intent is written
7
+ * before acting, marked applied before publication, and only applied entries are replayed. An interrupted pending
8
+ * entry is unknown until its owner supplies proof; it cannot be guessed successful from an audit row.
9
+ */
10
+ export function makeTouchOutbox({ dir, store, fs = nodeFs, log = () => {} } = {}) {
11
+ const path = (id) => join(dir, `${id}.json`);
12
+ function save(event, state) {
13
+ if (!dir) return false;
14
+ try { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); fs.writeFileSync(`${path(event.id)}.tmp`, JSON.stringify({ state, event }), { mode: 0o600 }); fs.renameSync(`${path(event.id)}.tmp`, path(event.id)); return true; } catch { log("touch_outbox_unavailable", {}); return false; }
15
+ }
16
+ function drop(event) { try { fs.unlinkSync(path(event.id)); } catch {} }
17
+ return {
18
+ async begin(value) {
19
+ const event = cleanTouch(value);
20
+ if (!event) return { error: "touch-event-invalid" };
21
+ const durable = save(event, "pending");
22
+ const indexed = await store.begin(event);
23
+ return { event, durable, ...(indexed.ok ? { ok: true } : { error: indexed.error }) };
24
+ },
25
+ async applied(intent) {
26
+ if (!intent?.event) return { error: "touch-event-invalid" };
27
+ const durable = save(intent.event, "applied");
28
+ const result = await store.write(intent.event);
29
+ if (result.ok) drop(intent.event);
30
+ if (!result.ok) log("touch_record_pending", {});
31
+ return result.ok ? result : { error: result.error, durable };
32
+ },
33
+ async abort(intent) { if (!intent?.event) return; await store.abort(intent.event); drop(intent.event); },
34
+ async repair() {
35
+ const result = { repaired: 0, pending: 0, invalid: 0 };
36
+ let names; try { names = fs.readdirSync(dir); } catch { return result; }
37
+ for (const name of names) {
38
+ if (!/^[a-f0-9]{64}\.json$/.test(name)) continue;
39
+ let row; try { const stat = fs.lstatSync(join(dir, name)); if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) throw new Error(); row = JSON.parse(fs.readFileSync(join(dir, name), "utf8")); } catch { result.invalid++; continue; }
40
+ const event = cleanTouch(row.event);
41
+ if (!event || `${event.id}.json` !== name) { result.invalid++; continue; }
42
+ if (row.state !== "applied") { result.pending++; await store.begin(event); continue; }
43
+ const sent = await store.write(event);
44
+ if (sent.ok) { drop(event); result.repaired++; }
45
+ }
46
+ return result;
47
+ },
48
+ };
49
+ }
50
+
51
+ /** The action runs exactly once. A metric failure is an explicit warning, never a reason to retry its side effect. */
52
+ export async function trackTouchAction({ store, outbox = null, event, act, succeeded = (result) => result?.ok === true }) {
53
+ if (!store && !outbox) return act();
54
+ const writer = outbox ?? { begin: (e) => store.begin(e), applied: (intent) => store.write(intent.event), abort: (intent) => store.abort(intent.event) };
55
+ const intent = await writer.begin(event);
56
+ let result;
57
+ try { result = await act(); } catch (error) { await writer.abort(intent); throw error; }
58
+ if (!succeeded(result)) { await writer.abort(intent); return result; }
59
+ const tracked = await writer.applied(intent);
60
+ return tracked?.ok ? result : { ...result, touchWarning: "touch-tracking-incomplete" };
61
+ }
@@ -0,0 +1,133 @@
1
+ import { createHash } from "node:crypto";
2
+ import { normalize } from "node:path";
3
+
4
+ /** Closed intervention and terminal-wait facts. Text belongs to the run's private transcript, never this index. */
5
+ export const TOUCH_CAUSES = Object.freeze(["question-answer", "approval-decision", "rerun", "config-fix", "steer", "cancel"]);
6
+ export const WAIT_END_REASONS = Object.freeze(["approval-expired", "approval-policy-changed"]);
7
+ export const TOUCH_INDEX = "touch:index";
8
+ export const TOUCH_META = "touch:meta";
9
+ export const TOUCH_PENDING = "touch:pending";
10
+ export const TOUCH_MAX_EVENTS = 30_000;
11
+ export const TOUCH_MAX_RETENTION_DAYS = 365;
12
+ const ID = /^[A-Za-z0-9._:-]{1,200}$/;
13
+ const HASH = /^[a-f0-9]{64}$/;
14
+ const PROJECT = /^[a-z0-9][a-z0-9_-]{0,63}$/;
15
+ const TARGET = /^[A-Za-z0-9._:/#! -]{1,300}$/;
16
+ const ms = (v) => Number.isSafeInteger(v) && v >= 0;
17
+ const digest = (v) => createHash("sha256").update(v).digest("hex");
18
+ export const touchEventKey = (id) => `touch:event:${id}`;
19
+ export const touchTtlMs = (retentionDays = 30) => (Number(retentionDays) > 0 ? Math.min(Number(retentionDays), TOUCH_MAX_RETENTION_DAYS) : TOUCH_MAX_RETENTION_DAYS) * 86400_000;
20
+
21
+ /** An opaque host-authored identity keeps local folders with the same basename apart without recording a path. */
22
+ export function touchScopeOf(job, host = null) {
23
+ const data = job?.data ?? job ?? {};
24
+ if (HASH.test(data.touchScopeId ?? "")) return data.touchScopeId;
25
+ if (data.kind === "local" || data.folder) return digest(`local\0${host ?? ""}\0${typeof data.folder === "string" ? normalize(data.folder) : ""}`);
26
+ return digest(`${data.kind ?? "unknown"}\0${data.baseUrl ?? data.host ?? ""}\0${data.repo ?? data.repository ?? ""}`);
27
+ }
28
+
29
+ /** Rebuild, never spread: even an invalid optional field refuses the event rather than carrying arbitrary text. */
30
+ export function cleanTouch(value) {
31
+ if (!value || typeof value !== "object" || Array.isArray(value) || !ID.test(value.runId ?? "") || !ms(value.at)) return null;
32
+ if (value.type !== undefined && !["stop", "touch", "wait-end"].includes(value.type)) return null;
33
+ if (value.type === "wait-end" ? value.cause !== undefined || !WAIT_END_REASONS.includes(value.reason) : value.type !== "stop" && !TOUCH_CAUSES.includes(value.cause)) return null;
34
+ if (value.answerSource !== undefined && !["forge", "console", "panel", "draft"].includes(value.answerSource)) return null;
35
+ if (value.draftRunId !== undefined && !ID.test(value.draftRunId)) return null;
36
+ if (value.scopeId !== undefined && value.scopeId !== null && !HASH.test(value.scopeId)) return null;
37
+ if (value.project !== undefined && value.project !== null && !PROJECT.test(value.project)) return null;
38
+ if (value.target !== undefined && value.target !== null && !TARGET.test(value.target)) return null;
39
+ for (const key of ["endedAt", "parkedAt", "receivedAt"]) if (value[key] !== undefined && value[key] !== null && !ms(value[key])) return null;
40
+ const type = value.type === "stop" ? "stop" : value.type === "wait-end" ? "wait-end" : "touch";
41
+ const id = HASH.test(value.id ?? "") ? value.id : digest(`${type}\0${value.runId}\0${value.cause ?? value.reason ?? ""}\0${value.nonce ?? value.at}`);
42
+ return { v: 1, id, type, runId: value.runId, ...(type === "touch" ? { cause: value.cause } : {}), ...(type === "wait-end" ? { reason: value.reason } : {}), at: value.at,
43
+ ...(value.scopeId ? { scopeId: value.scopeId } : {}), ...(value.project ? { project: value.project } : {}),
44
+ ...(value.target ? { target: value.target } : {}), ...(value.answerSource ? { answerSource: value.answerSource } : {}), ...(value.draftRunId ? { draftRunId: value.draftRunId } : {}), ...Object.fromEntries(["endedAt", "parkedAt", "receivedAt"].filter((k) => ms(value[k])).map((k) => [k, value[k]])) };
45
+ }
46
+
47
+ const INIT = `
48
+ if redis.call("EXISTS", KEYS[1]) == 0 then redis.call("HSET", KEYS[1], "since", ARGV[1]) end
49
+ redis.call("PEXPIRE", KEYS[1], ARGV[2])
50
+ return 1
51
+ `;
52
+ const WRITE = `
53
+ if redis.call("EXISTS", KEYS[2]) == 0 or (redis.call("EXISTS", KEYS[1]) == 0 and redis.call("HGET", KEYS[2], "indexed") == "1") then
54
+ redis.call("DEL", KEYS[2]); redis.call("HSET", KEYS[2], "since", ARGV[1])
55
+ end
56
+ local added = redis.call("SET", KEYS[3], ARGV[4], "NX", "PX", ARGV[3])
57
+ redis.call("ZADD", KEYS[1], ARGV[2], ARGV[5])
58
+ redis.call("HSET", KEYS[2], "indexed", "1")
59
+ local aged = redis.call("ZREMRANGEBYSCORE", KEYS[1], "-inf", "(" .. ARGV[6])
60
+ if aged > 0 then local prior = tonumber(redis.call("HGET", KEYS[2], "horizon") or "0"); redis.call("HSET", KEYS[2], "horizon", math.max(prior, tonumber(ARGV[6]))) end
61
+ local n = redis.call("ZCARD", KEYS[1])
62
+ if n > tonumber(ARGV[7]) then
63
+ local removed = redis.call("ZRANGE", KEYS[1], 0, n-tonumber(ARGV[7])-1, "WITHSCORES")
64
+ if #removed > 0 then local prior = tonumber(redis.call("HGET", KEYS[2], "horizon") or "0"); redis.call("HSET", KEYS[2], "horizon", math.max(prior, tonumber(removed[#removed]))) end
65
+ redis.call("ZREMRANGEBYRANK", KEYS[1], 0, n-tonumber(ARGV[7])-1)
66
+ end
67
+ redis.call("PEXPIRE", KEYS[1], ARGV[3]); redis.call("PEXPIRE", KEYS[2], ARGV[3])
68
+ redis.call("HDEL", KEYS[4], ARGV[5])
69
+ return added and 1 or 0
70
+ `;
71
+ const READ = `return {redis.call("HGETALL", KEYS[1]), redis.call("ZRANGE", KEYS[2], 0, -1), redis.call("HLEN", KEYS[3]), redis.call("EXISTS", KEYS[2])}`;
72
+ export const TOUCH_SCRIPTS = Object.freeze({ INIT, WRITE, READ });
73
+
74
+ function bounded(task, timeoutMs) {
75
+ let timer;
76
+ return Promise.race([Promise.resolve().then(task), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("touch-timeout")), timeoutMs); })]).finally(() => clearTimeout(timer));
77
+ }
78
+
79
+ /** Shared by every producer. Unknown or unavailable tracking is a named state; it never becomes zero touches. */
80
+ export function makeTouchStore({ redis, now = Date.now, retentionDays = 30, timeoutMs = 2000, prefix = "" } = {}) {
81
+ const ttl = touchTtlMs(retentionDays);
82
+ const key = (name) => `${prefix}${name}`;
83
+ return {
84
+ async initialize() {
85
+ try { await bounded(() => redis.eval(INIT, 1, key(TOUCH_META), now(), ttl), timeoutMs); return { ok: true }; } catch { return { error: "touch-index-unavailable" }; }
86
+ },
87
+ async begin(value) {
88
+ const event = cleanTouch(value);
89
+ if (!event) return { error: "touch-event-invalid" };
90
+ try { await bounded(() => redis.hset(key(TOUCH_PENDING), event.id, event.at), timeoutMs); await bounded(() => redis.pexpire(key(TOUCH_PENDING), ttl), timeoutMs); return { ok: true, event }; } catch { return { error: "touch-index-unavailable", event }; }
91
+ },
92
+ async abort(value) {
93
+ const event = cleanTouch(value);
94
+ if (!event) return { error: "touch-event-invalid" };
95
+ try { await bounded(() => redis.hdel(key(TOUCH_PENDING), event.id), timeoutMs); return { ok: true }; } catch { return { error: "touch-index-unavailable" }; }
96
+ },
97
+ async write(value) {
98
+ const event = cleanTouch(value);
99
+ if (!event) return { error: "touch-event-invalid" };
100
+ try {
101
+ const added = await bounded(() => redis.eval(WRITE, 4, key(TOUCH_INDEX), key(TOUCH_META), key(touchEventKey(event.id)), key(TOUCH_PENDING), now(), event.at, ttl, JSON.stringify(event), event.id, now() - ttl, TOUCH_MAX_EVENTS), timeoutMs);
102
+ return { ok: true, added: added === 1, event };
103
+ } catch { return { error: "touch-index-unavailable" }; }
104
+ },
105
+ async read() {
106
+ try {
107
+ const raw = await bounded(() => redis.eval(READ, 3, key(TOUCH_META), key(TOUCH_INDEX), key(TOUCH_PENDING)), timeoutMs);
108
+ if (!Array.isArray(raw) || !Array.isArray(raw[0]) || !Array.isArray(raw[1])) return { events: [], coverage: { fromMs: now(), partial: true, reasons: ["touch-index-unavailable"] } };
109
+ const meta = Object.fromEntries(raw[0].reduce((xs, x, i, all) => i % 2 === 0 ? [...xs, [x, all[i + 1]]] : xs, []));
110
+ const since = Number(meta.since);
111
+ const reasons = [];
112
+ if (!ms(since)) reasons.push("touch-tracking-not-started");
113
+ // A surviving epoch cannot vouch for an index that was lost. Diagnose it at READ time, before
114
+ // the next touch repairs the epoch; otherwise an idle fleet falsely reads the missing events as zero.
115
+ const indexMissing = meta.indexed === "1" && Number(raw[3]) !== 1;
116
+ if (indexMissing) reasons.push("touch-index-lost");
117
+ const unresolved = Number(raw[2]) > 0;
118
+ if (unresolved) reasons.push("touch-actions-unresolved");
119
+ const events = [];
120
+ let missingAt = 0;
121
+ for (let offset = 0; offset < raw[1].length; offset += 500) {
122
+ const rows = await bounded(() => redis.mget(...raw[1].slice(offset, offset + 500).map((id) => key(touchEventKey(id)))), timeoutMs);
123
+ for (const body of rows) {
124
+ let event; try { event = cleanTouch(JSON.parse(body)); } catch {}
125
+ if (event) events.push(event); else missingAt = now();
126
+ }
127
+ }
128
+ if (missingAt) reasons.push("touch-events-missing");
129
+ return { events, coverage: { fromMs: Math.max(ms(since) ? since : now(), Number(meta.horizon) || 0, now() - ttl, missingAt, indexMissing || unresolved ? now() : 0), partial: reasons.length > 0, reasons } };
130
+ } catch { return { events: [], coverage: { fromMs: now(), partial: true, reasons: ["touch-index-unavailable"] } }; }
131
+ },
132
+ };
133
+ }