@coreplane/switchboard 1.239.0 → 1.240.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 (150) hide show
  1. package/dist/assets/config/config.example.yaml +6 -0
  2. package/dist/assets/deploy/cloudflare-memory/worker.ts +115 -1
  3. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +7 -1
  4. package/dist/assets/deploy/cloudflare-resident/worker.ts +223 -70
  5. package/dist/assets/deploy/secrets.manifest.json +1 -1
  6. package/dist/assets/package-lock.json +207 -3
  7. package/dist/assets/package.json +2 -1
  8. package/dist/assets/source.json +3 -3
  9. package/dist/assets/src/core/authz/actor.ts +33 -2
  10. package/dist/assets/src/core/authz/policy.ts +6 -0
  11. package/dist/assets/src/core/coordinator/contract.ts +12 -1
  12. package/dist/assets/src/core/coordinator/driver.ts +13 -0
  13. package/dist/assets/src/core/costs.ts +1114 -0
  14. package/dist/assets/src/core/costsSnapshotStore.ts +209 -0
  15. package/dist/assets/src/core/delivery.ts +4 -11
  16. package/dist/assets/src/core/deliverySnapshotStore.ts +12 -28
  17. package/dist/assets/src/core/ingressTokens.ts +27 -13
  18. package/dist/assets/src/core/runLedger/types.ts +3 -0
  19. package/dist/assets/src/core/runRecord.ts +10 -3
  20. package/dist/assets/src/core/schedules.ts +2 -1
  21. package/dist/assets/src/core/ship/coordinator.ts +70 -0
  22. package/dist/assets/src/core/snapshotAge.ts +30 -0
  23. package/dist/assets/src/core/stateWorkerRef.ts +41 -0
  24. package/dist/assets/src/execution/residentAutoRebuild.ts +73 -0
  25. package/dist/assets/src/execution/residentRefresh.ts +25 -3
  26. package/dist/assets/src/execution/restoreWaiters.ts +61 -0
  27. package/dist/assets/src/mcp/registry.ts +7 -0
  28. package/dist/assets/web/dist/.vite/manifest.json +507 -427
  29. package/dist/assets/web/dist/assets/AppShell-CM_fh-WW.js +1 -0
  30. package/dist/assets/web/dist/assets/CostsPage-BD3w5j_S.js +2 -0
  31. package/dist/assets/web/dist/assets/DeliveryPage-BtvzKA1m.js +1 -0
  32. package/dist/assets/web/dist/assets/HomePage-BU6aRCSe.js +2 -0
  33. package/dist/assets/web/dist/assets/NotFoundPage-DwOX1HaZ.js +1 -0
  34. package/dist/assets/web/dist/assets/{ResidentDetailPage-CrfCd8fx.js → ResidentDetailPage-CUAOh5d9.js} +1 -1
  35. package/dist/assets/web/dist/assets/ResidentsIndexPage-B3KUlYy2.js +1 -0
  36. package/dist/assets/web/dist/assets/RunFoldRow-Dulcbg25.js +1 -0
  37. package/dist/assets/web/dist/assets/RunRoutePage-DGoGrQZq.js +6 -0
  38. package/dist/assets/web/dist/assets/RunsIndexPage-37W7OZbC.js +1 -0
  39. package/dist/assets/web/dist/assets/{RunsTabs-XpCqeWu4.js → RunsTabs-BnkvCBrM.js} +1 -1
  40. package/dist/assets/web/dist/assets/ScheduledPage-C1y9jgAk.js +1 -0
  41. package/dist/assets/web/dist/assets/SettingsPage-ggfB-Rqr.js +1 -0
  42. package/dist/assets/web/dist/assets/SlackMark-VDNs7Vjh.js +1 -0
  43. package/dist/assets/web/dist/assets/Slideover-CX-LEklF.js +1 -0
  44. package/dist/assets/web/dist/assets/{StatusDot-DkXALvKf.js → StatusDot-CiByEtoR.js} +1 -1
  45. package/dist/assets/web/dist/assets/{Tooltip-DtLIyi5P.js → Tooltip-DyRxk8A8.js} +1 -1
  46. package/dist/assets/web/dist/assets/UnitRoutePage-CnWyOizV.js +1 -0
  47. package/dist/assets/web/dist/assets/{angular-html-DYtV9uIN.js → angular-html-DAQ_1T1v.js} +1 -1
  48. package/dist/assets/web/dist/assets/{angular-ts-DF2P25Zp.js → angular-ts-D4kxRVp1.js} +1 -1
  49. package/dist/assets/web/dist/assets/{apl-Ff96wxko.js → apl-Dy07l64y.js} +1 -1
  50. package/dist/assets/web/dist/assets/{astro-LbBnZna-.js → astro-DZIbTlc7.js} +1 -1
  51. package/dist/assets/web/dist/assets/{blade-DltuxeBW.js → blade-CmSXWX3l.js} +1 -1
  52. package/dist/assets/web/dist/assets/{c-DBg7yuPx.js → c-ggzVdJLK.js} +1 -1
  53. package/dist/assets/web/dist/assets/{chapel-C83hUAds.js → chapel-Bo1wktsG.js} +1 -1
  54. package/dist/assets/web/dist/assets/{cobol-DB0yV_2D.js → cobol-DJUmtKM2.js} +1 -1
  55. package/dist/assets/web/dist/assets/{coffee-Bpih6Kzl.js → coffee-BZWOVz4C.js} +1 -1
  56. package/dist/assets/web/dist/assets/{cpp-CU00ZeRn.js → cpp-Dtk39yMw.js} +1 -1
  57. package/dist/assets/web/dist/assets/{crystal-f8ImJkux.js → crystal-CGf_-Zoj.js} +1 -1
  58. package/dist/assets/web/dist/assets/{css-4VrC4ISo.js → css-CX2fcL4u.js} +1 -1
  59. package/dist/assets/web/dist/assets/delivery-CTdOqpVK.js +1 -0
  60. package/dist/assets/web/dist/assets/{dist-DtA7JiH5.js → dist-D8TaC961.js} +2 -2
  61. package/dist/assets/web/dist/assets/durationTone-DXG-3R7_.js +1 -0
  62. package/dist/assets/web/dist/assets/{edge-BwFDzfS_.js → edge-BYS8FtgQ.js} +1 -1
  63. package/dist/assets/web/dist/assets/{elixir-wwIYIpJm.js → elixir-fhUpi3nO.js} +1 -1
  64. package/dist/assets/web/dist/assets/{elm-DyDuM3v3.js → elm-DAQNiwsN.js} +1 -1
  65. package/dist/assets/web/dist/assets/{erb-DFJBEVrh.js → erb-CWzEys6U.js} +1 -1
  66. package/dist/assets/web/dist/assets/eventSource-tRprhqm7.js +1 -0
  67. package/dist/assets/web/dist/assets/favicon-D1hkyC5j.js +1 -0
  68. package/dist/assets/web/dist/assets/{git-rebase-CuDv-44X.js → git-rebase-9rY3p-Rw.js} +1 -1
  69. package/dist/assets/web/dist/assets/{glimmer-js-ZFnrm4J3.js → glimmer-js-tY7hy0nE.js} +1 -1
  70. package/dist/assets/web/dist/assets/{glimmer-ts-XY4Yqshp.js → glimmer-ts-CTrLv5JF.js} +1 -1
  71. package/dist/assets/web/dist/assets/{glsl-xnR8efx0.js → glsl-BRJbiDfq.js} +1 -1
  72. package/dist/assets/web/dist/assets/{graphql-D-gO5tnP.js → graphql-B-MtVLh2.js} +1 -1
  73. package/dist/assets/web/dist/assets/{hack-CSxbW4ax.js → hack-BtIkOypQ.js} +1 -1
  74. package/dist/assets/web/dist/assets/{haml-D4ar42QT.js → haml-CoOH6gfa.js} +1 -1
  75. package/dist/assets/web/dist/assets/{handlebars-CMLbDE7o.js → handlebars-DgrRu-GP.js} +1 -1
  76. package/dist/assets/web/dist/assets/{html-CVekU4Fs.js → html-DZaaI7ca.js} +1 -1
  77. package/dist/assets/web/dist/assets/{html-derivative-St3HHw_N.js → html-derivative-DgHvT-qZ.js} +1 -1
  78. package/dist/assets/web/dist/assets/{http-Mon0DWHS.js → http-CvAd51yz.js} +1 -1
  79. package/dist/assets/web/dist/assets/{hurl-Cbl5IvBs.js → hurl-CXGqAfzx.js} +1 -1
  80. package/dist/assets/web/dist/assets/{localIso-CNA-bhS-.js → indexFormat-B-pd4r7Y.js} +1 -1
  81. package/dist/assets/web/dist/assets/{indexRow-FLJu5mR9.js → indexRow-Cse3JGb0.js} +1 -1
  82. package/dist/assets/web/dist/assets/{java-Bprck1l-.js → java-CIQStIXF.js} +1 -1
  83. package/dist/assets/web/dist/assets/{javascript-C73G1-UR.js → javascript-WbH8s7E3.js} +1 -1
  84. package/dist/assets/web/dist/assets/{jinja-C4DuRxYJ.js → jinja-DzyWEVKC.js} +1 -1
  85. package/dist/assets/web/dist/assets/{jison-B2U03GEC.js → jison-CrT-zCa-.js} +1 -1
  86. package/dist/assets/web/dist/assets/{json-CvTZUDzx.js → json-lHjpjXMQ.js} +1 -1
  87. package/dist/assets/web/dist/assets/{jsx-DMWKCzwo.js → jsx-WTwYCHW6.js} +1 -1
  88. package/dist/assets/web/dist/assets/{julia-Czn7bV_3.js → julia-Bqpk9H7E.js} +1 -1
  89. package/dist/assets/web/dist/assets/{just-BBxpNfvC.js → just-LdXk1r3m.js} +1 -1
  90. package/dist/assets/web/dist/assets/{latex-Bw9yg5mr.js → latex-h_y2A5KD.js} +1 -1
  91. package/dist/assets/web/dist/assets/{liquid-BRCjaydE.js → liquid-CY7bF2_p.js} +1 -1
  92. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
  93. package/dist/assets/web/dist/assets/{lua-DUu2KIC0.js → lua-oQ7XVZOY.js} +1 -1
  94. package/dist/assets/web/dist/assets/main-CGO6fQ8k.js +28 -0
  95. package/dist/assets/web/dist/assets/main-O8UXRuEv.css +1 -0
  96. package/dist/assets/web/dist/assets/{marko-CZJZIlSQ.js → marko-zv_qZ4QV.js} +1 -1
  97. package/dist/assets/web/dist/assets/{mdc-BmClq6fd.js → mdc-Dl4kL_YK.js} +1 -1
  98. package/dist/assets/web/dist/assets/{nginx-Bmt3xSEm.js → nginx-CAHCZj4F.js} +1 -1
  99. package/dist/assets/web/dist/assets/{nim-Da-i0utK.js → nim-BOtmwCJH.js} +1 -1
  100. package/dist/assets/web/dist/assets/{org-BvAre2qP.js → org-DspQk3HQ.js} +1 -1
  101. package/dist/assets/web/dist/assets/{perl-v9WwFFtB.js → perl-CAXmvVl0.js} +1 -1
  102. package/dist/assets/web/dist/assets/{php-DTU4Zyze.js → php-CMUx4VyM.js} +1 -1
  103. package/dist/assets/web/dist/assets/{pug-DhTwEqTO.js → pug-B55xVJTv.js} +1 -1
  104. package/dist/assets/web/dist/assets/{qml-BUD9AC_c.js → qml-DsNKCCRk.js} +1 -1
  105. package/dist/assets/web/dist/assets/{r-CAnu56n7.js → r-DbhP0VvO.js} +1 -1
  106. package/dist/assets/web/dist/assets/{razor-Dn0P4gAq.js → razor-BCpE6P-p.js} +1 -1
  107. package/dist/assets/web/dist/assets/{regexp-CZyBRaVW.js → regexp-BllsjiN4.js} +1 -1
  108. package/dist/assets/web/dist/assets/{rst-Ckyd5UsY.js → rst-CO5Rpq9V.js} +1 -1
  109. package/dist/assets/web/dist/assets/{ruby-BBQ62fsx.js → ruby-C2A4OgQq.js} +1 -1
  110. package/dist/assets/web/dist/assets/{sas-CW35hMxG.js → sas-Butzoyql.js} +1 -1
  111. package/dist/assets/web/dist/assets/{scss-CpV8pF4J.js → scss-PEUwJBpf.js} +1 -1
  112. package/dist/assets/web/dist/assets/settingsApi-priOPiwu.js +1 -0
  113. package/dist/assets/web/dist/assets/{shellscript-CRJvsmE1.js → shellscript-3Yhq0khE.js} +1 -1
  114. package/dist/assets/web/dist/assets/{shellsession-CyriUZxf.js → shellsession-04Hkb7m3.js} +1 -1
  115. package/dist/assets/web/dist/assets/{soy-D5QzYUeg.js → soy-BnId73cP.js} +1 -1
  116. package/dist/assets/web/dist/assets/{sql-CUBf_Mse.js → sql-BKwdc0ch.js} +1 -1
  117. package/dist/assets/web/dist/assets/sseReplay-DutHNghE.js +9 -0
  118. package/dist/assets/web/dist/assets/{stata-B-4n4ITz.js → stata-BPPtNkay.js} +1 -1
  119. package/dist/assets/web/dist/assets/{surrealql-RuWZBLtr.js → surrealql-CDPChcYP.js} +1 -1
  120. package/dist/assets/web/dist/assets/{svelte-CO5YzCsK.js → svelte-B15DIQ-z.js} +1 -1
  121. package/dist/assets/web/dist/assets/{templ-DbkWVaBF.js → templ-D-rp_AiB.js} +1 -1
  122. package/dist/assets/web/dist/assets/{tex-IuxoEcId.js → tex-BnVDCD7p.js} +1 -1
  123. package/dist/assets/web/dist/assets/{ts-tags-g6dGQPjd.js → ts-tags-cpujdyAR.js} +1 -1
  124. package/dist/assets/web/dist/assets/{tsx-WnXwiyH2.js → tsx-D1vO4L72.js} +1 -1
  125. package/dist/assets/web/dist/assets/{twig-Lx1G7PnY.js → twig-oQGnQGRc.js} +1 -1
  126. package/dist/assets/web/dist/assets/{typescript-BQd7biLF.js → typescript-CKSFpXiH.js} +1 -1
  127. package/dist/assets/web/dist/assets/{typst-D2lXPHK7.js → typst-DKHFo8BF.js} +1 -1
  128. package/dist/assets/web/dist/assets/{vue-4wVQD9y9.js → vue-BstokpiS.js} +1 -1
  129. package/dist/assets/web/dist/assets/{vue-html-DZPaRte4.js → vue-html-DpCp0_Sz.js} +1 -1
  130. package/dist/assets/web/dist/assets/{vue-vine-DbmZCmu-.js → vue-vine-y5d5wUb1.js} +1 -1
  131. package/dist/assets/web/dist/assets/{xml-DV16szyo.js → xml-Br2hx5Ax.js} +1 -1
  132. package/dist/assets/web/dist/assets/{xsl-eo5-1sk0.js → xsl-Bc8Cobap.js} +1 -1
  133. package/dist/assets/web/dist/assets/{yaml-BXdX8HZf.js → yaml-LOITy_I1.js} +1 -1
  134. package/dist/cli.js +2314 -1571
  135. package/package.json +1 -1
  136. package/dist/assets/web/dist/assets/AppShell-MYHNxJks.js +0 -1
  137. package/dist/assets/web/dist/assets/CostsPage-DKk9OjB0.js +0 -2
  138. package/dist/assets/web/dist/assets/DeliveryPage-Kz6js49i.js +0 -1
  139. package/dist/assets/web/dist/assets/NotFoundPage-BXLrQQzy.js +0 -1
  140. package/dist/assets/web/dist/assets/ResidentsIndexPage-BdW0k2h3.js +0 -1
  141. package/dist/assets/web/dist/assets/RunFoldRow-CbTd9SiS.js +0 -9
  142. package/dist/assets/web/dist/assets/RunRoutePage-br0H-8Pz.js +0 -6
  143. package/dist/assets/web/dist/assets/RunsIndexPage-Bnzb15zH.js +0 -1
  144. package/dist/assets/web/dist/assets/ScheduledPage-NLhjzHAz.js +0 -1
  145. package/dist/assets/web/dist/assets/SettingsPage-D-i0uVw-.js +0 -1
  146. package/dist/assets/web/dist/assets/UnitRoutePage-DGXukEAH.js +0 -1
  147. package/dist/assets/web/dist/assets/durationTone-CYMux25J.js +0 -1
  148. package/dist/assets/web/dist/assets/favicon-CSt-qDvc.js +0 -1
  149. package/dist/assets/web/dist/assets/main-CFHF2RvQ.css +0 -1
  150. package/dist/assets/web/dist/assets/main-Dht0in4v.js +0 -28
@@ -0,0 +1,209 @@
1
+ import type { CloudflareUsage, DateRange, LlmCostRow } from "./costs.js";
2
+ import { isRunUsageReport, type RunUsageReport } from "./runUsage.js";
3
+ import {
4
+ DEFAULT_STATE_WORKER_TOKEN_ENV,
5
+ STATE_WORKER_BLOCK_NAMES,
6
+ stateWorkerOf,
7
+ type SecretReader,
8
+ type StateWorkerBlocks,
9
+ } from "./stateWorkerRef.js";
10
+ import { errorSuffix } from "./workerError.js";
11
+
12
+ // Where the costs snapshot lives (docs/reference/specs/costs.md item 6): the
13
+ // billing sources' answers over the page's widest range — Cloudflare's usage
14
+ // rows, Anthropic's daily cost rows, the run history's per-user usage — as the
15
+ // sources gave them, and when they were read. ONE snapshot per installation:
16
+ // the Cloudflare read is account-wide and the LLM rows carry every workspace,
17
+ // so every configured group's report is arithmetic over the same document.
18
+ // Two implementations of one seam (AGENTS.md invariant 2):
19
+ //
20
+ // - `WorkerCostsSnapshotStore` — the production choice — an HTTPS client to
21
+ // the `CostsSnapshotDO` on the state Worker (deploy/cloudflare-memory/, one
22
+ // SQLite Durable Object, the same MEMORY_TOKEN bearer as memory, run history
23
+ // and the delivery snapshot), so a bot restart keeps the snapshot
24
+ // (invariant 6) and the first request after one is served, not computed.
25
+ // - `InMemoryCostsSnapshotStore` — tests, and the process without a state
26
+ // Worker: the snapshot lives as long as the process does.
27
+ //
28
+ // Node-free on purpose: the state Worker imports `isCostsSnapshot` by
29
+ // relative path so the bot and the Worker validate ONE shape.
30
+ //
31
+ // Route contract (JSON in/out, bearer = the Worker's MEMORY_TOKEN):
32
+ // POST /costs/snapshot/get {} → {snapshot: CostsSnapshot | null}
33
+ // POST /costs/snapshot/put {snapshot} → {ok: true} replace the snapshot whole
34
+
35
+ /** The billing sources' answers over the snapshot window, and when they were read. */
36
+ export interface CostsSnapshot {
37
+ /** ISO 8601 — when the read began. */
38
+ takenAt: string;
39
+ /** Who asked for it: `schedule` for the refresh loop, else the caller's label. */
40
+ takenBy: string;
41
+ /** How long the read took, both sources and the run history together. */
42
+ durationMs: number;
43
+ /** The window the rows were read for: `MAX_DAYS` UTC days ending on the take day. */
44
+ range: DateRange;
45
+ /** Every meter Cloudflare bills on, account-wide, as `CloudflareUsageSource` answered. */
46
+ usage: CloudflareUsage;
47
+ /** Anthropic's daily cost rows (the open days estimated); null when no LLM source is configured. */
48
+ llm: LlmCostRow[] | null;
49
+ /** The run history's per-user usage over the window; null with run history off. */
50
+ runUsage: RunUsageReport | null;
51
+ }
52
+
53
+ const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
54
+ const isInstant = (v: unknown): v is string => typeof v === "string" && Number.isFinite(Date.parse(v));
55
+ const isFiniteNumber = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
56
+
57
+ /** The datasets every `CloudflareUsage` carries — the guard checks each is rows with a day. */
58
+ const USAGE_DATASETS = [
59
+ "containers",
60
+ "durableObjectRequests",
61
+ "durableObjectDays",
62
+ "durableObjectStorage",
63
+ "workers",
64
+ "r2Storage",
65
+ "r2Operations",
66
+ "workflows",
67
+ ] as const;
68
+
69
+ function isDatedRows(v: unknown): boolean {
70
+ return (
71
+ Array.isArray(v) &&
72
+ v.every((row) => typeof row === "object" && row !== null && ISO_DAY.test(String((row as { date?: unknown }).date)))
73
+ );
74
+ }
75
+
76
+ function isDateRange(v: unknown): v is DateRange {
77
+ if (typeof v !== "object" || v === null) return false;
78
+ const r = v as Record<string, unknown>;
79
+ return (
80
+ typeof r.from === "string" &&
81
+ ISO_DAY.test(r.from) &&
82
+ typeof r.to === "string" &&
83
+ ISO_DAY.test(r.to) &&
84
+ isFiniteNumber(r.days) &&
85
+ typeof r.partialLastDay === "boolean"
86
+ );
87
+ }
88
+
89
+ function isLlmRows(v: unknown): v is LlmCostRow[] {
90
+ return (
91
+ Array.isArray(v) &&
92
+ v.every((row) => {
93
+ if (typeof row !== "object" || row === null) return false;
94
+ const r = row as Record<string, unknown>;
95
+ return (
96
+ typeof r.date === "string" &&
97
+ ISO_DAY.test(r.date) &&
98
+ (r.workspaceId === null || typeof r.workspaceId === "string") &&
99
+ isFiniteNumber(r.amountUsd)
100
+ );
101
+ })
102
+ );
103
+ }
104
+
105
+ /** The fields the arithmetic reads, present and of the right kind; the rows are the sources'. */
106
+ export function isCostsSnapshot(v: unknown): v is CostsSnapshot {
107
+ if (typeof v !== "object" || v === null) return false;
108
+ const s = v as Record<string, unknown>;
109
+ if (!isInstant(s.takenAt) || typeof s.takenBy !== "string") return false;
110
+ if (!isFiniteNumber(s.durationMs) || s.durationMs < 0) return false;
111
+ if (!isDateRange(s.range)) return false;
112
+ const usage = s.usage as Record<string, unknown> | null | undefined;
113
+ if (typeof usage !== "object" || usage === null) return false;
114
+ if (!USAGE_DATASETS.every((name) => isDatedRows(usage[name]))) return false;
115
+ if (s.llm !== null && !isLlmRows(s.llm)) return false;
116
+ return s.runUsage === null || isRunUsageReport(s.runUsage);
117
+ }
118
+
119
+ export interface CostsSnapshotStore {
120
+ /** The stored snapshot, or undefined when none was ever stored. Throws on failure. */
121
+ get(): Promise<CostsSnapshot | undefined>;
122
+ /** Replace the snapshot whole. Throws on failure; callers warn, never crash. */
123
+ put(snapshot: CostsSnapshot): Promise<void>;
124
+ }
125
+
126
+ export class InMemoryCostsSnapshotStore implements CostsSnapshotStore {
127
+ private stored: CostsSnapshot | undefined;
128
+
129
+ async get(): Promise<CostsSnapshot | undefined> {
130
+ return this.stored ? structuredClone(this.stored) : undefined;
131
+ }
132
+
133
+ async put(snapshot: CostsSnapshot): Promise<void> {
134
+ this.stored = structuredClone(snapshot);
135
+ }
136
+ }
137
+
138
+ /** Per-request ceiling: a ninety-day snapshot is a few hundred KB each way. */
139
+ export const COSTS_SNAPSHOT_WORKER_TIMEOUT_MS = 30_000;
140
+
141
+ export interface WorkerCostsSnapshotStoreOptions {
142
+ /** Base URL of the state Worker. */
143
+ baseUrl: string;
144
+ /** Bearer secret (MEMORY_TOKEN on the Worker). */
145
+ token: string;
146
+ /** Injectable for tests; defaults to global fetch. */
147
+ fetch?: typeof fetch;
148
+ }
149
+
150
+ export class WorkerCostsSnapshotStore implements CostsSnapshotStore {
151
+ private readonly baseUrl: string;
152
+ private readonly fetchImpl: typeof fetch;
153
+
154
+ constructor(private readonly opts: WorkerCostsSnapshotStoreOptions) {
155
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
156
+ this.fetchImpl = opts.fetch ?? fetch;
157
+ }
158
+
159
+ async get(): Promise<CostsSnapshot | undefined> {
160
+ const res = await this.post("/costs/snapshot/get", {});
161
+ if (!res.ok) throw new Error(`state Worker /costs/snapshot/get HTTP ${res.status}${await errorSuffix(res)}`);
162
+ const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
163
+ if (data.snapshot === null || data.snapshot === undefined) return undefined;
164
+ if (!isCostsSnapshot(data.snapshot))
165
+ throw new Error("state Worker /costs/snapshot/get: the answer is not a costs snapshot");
166
+ return data.snapshot;
167
+ }
168
+
169
+ async put(snapshot: CostsSnapshot): Promise<void> {
170
+ const res = await this.post("/costs/snapshot/put", { snapshot });
171
+ if (!res.ok) throw new Error(`state Worker /costs/snapshot/put HTTP ${res.status}${await errorSuffix(res)}`);
172
+ }
173
+
174
+ private post(path: string, body: unknown): Promise<Response> {
175
+ return this.fetchImpl(`${this.baseUrl}${path}`, {
176
+ method: "POST",
177
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.opts.token}` },
178
+ body: JSON.stringify(body),
179
+ signal: AbortSignal.timeout(COSTS_SNAPSHOT_WORKER_TIMEOUT_MS),
180
+ });
181
+ }
182
+ }
183
+
184
+ /** Process-startup store selection (src/index.ts): the Worker store behind
185
+ * whichever `*.worker` block names the state Worker — the snapshot is a cache,
186
+ * so it earns no block of its own — else the in-memory store with a warning
187
+ * naming what a restart then costs. Pure w.r.t. the environment. */
188
+ export function buildCostsSnapshotStore(
189
+ config: StateWorkerBlocks,
190
+ secrets: SecretReader,
191
+ warn: (message: string) => void,
192
+ ): CostsSnapshotStore {
193
+ const worker = stateWorkerOf(config);
194
+ if (!worker) {
195
+ warn(
196
+ `the costs snapshot is kept in memory — a restart loses it and the costs page has nothing to show until the next one is taken. Name the state Worker (${STATE_WORKER_BLOCK_NAMES}) to keep it there.`,
197
+ );
198
+ return new InMemoryCostsSnapshotStore();
199
+ }
200
+ const tokenEnv = worker.tokenEnv ?? DEFAULT_STATE_WORKER_TOKEN_ENV;
201
+ const token = secrets.named(tokenEnv);
202
+ if (!token) {
203
+ warn(
204
+ `the state Worker is configured but ${tokenEnv} is unset — the costs snapshot is kept in memory and a restart loses it. Set ${tokenEnv} to the state Worker's bearer.`,
205
+ );
206
+ return new InMemoryCostsSnapshotStore();
207
+ }
208
+ return new WorkerCostsSnapshotStore({ baseUrl: worker.baseUrl, token: token.reveal() });
209
+ }
@@ -1,3 +1,4 @@
1
+ import { snapshotAgeText } from "./snapshotAge.js";
1
2
  import { systemClock } from "./trace/clock.js";
2
3
 
3
4
  // Delivery indicators: what the run history and the pull requests' own facts
@@ -553,17 +554,9 @@ function indicatorLines(x: DeliveryIndicators): string[] {
553
554
  ];
554
555
  }
555
556
 
556
- /** `just now`, `12 minutes ago`, `3 hours ago`, `2 days ago` — how old the snapshot is, for a footer or a head line. */
557
- export function snapshotAgeText(snapshotAt: string, nowMs: number): string {
558
- const minutes = Math.max(0, Math.floor((nowMs - Date.parse(snapshotAt)) / 60_000));
559
- if (!Number.isFinite(minutes)) return "";
560
- const unit = (n: number, word: string): string => `${n} ${word}${n === 1 ? "" : "s"} ago`;
561
- if (minutes < 1) return "just now";
562
- if (minutes < 90) return unit(minutes, "minute");
563
- const hours = Math.round(minutes / 60);
564
- if (hours < 48) return unit(hours, "hour");
565
- return unit(Math.round(hours / 24), "day");
566
- }
557
+ /** `just now`, `12 minutes ago`, `3 hours ago`, `2 days ago` — how old the snapshot is, for a footer or a head line
558
+ * (src/core/snapshotAge.ts, shared with the costs page). */
559
+ export { snapshotAgeText };
567
560
 
568
561
  /** `<YYYY-MM-DD> <hh:mm> UTC` — an instant as the text spells one. */
569
562
  const clock = (iso: string): string => `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
@@ -1,6 +1,15 @@
1
1
  import { REPO_SLUG, type DeliveryRange, type PullRequestFacts } from "./delivery.js";
2
+ import {
3
+ DEFAULT_STATE_WORKER_TOKEN_ENV,
4
+ STATE_WORKER_BLOCK_NAMES,
5
+ stateWorkerOf,
6
+ type SecretReader,
7
+ type StateWorkerBlocks,
8
+ } from "./stateWorkerRef.js";
2
9
  import { errorSuffix } from "./workerError.js";
3
10
 
11
+ export type { SecretReader, StateWorkerBlocks, StateWorkerRef } from "./stateWorkerRef.js";
12
+
4
13
  // Where a repository's delivery snapshot lives (docs/reference/specs/delivery.md item 10):
5
14
  // the merged pull requests' facts over the snapshot window, as GitHub gave
6
15
  // them, and when they were read. Two implementations of one seam (AGENTS.md
@@ -207,29 +216,6 @@ export class WorkerDeliverySnapshotStore implements DeliverySnapshotStore {
207
216
  }
208
217
  }
209
218
 
210
- /** A `{ baseUrl, tokenEnv? }` reference to the state Worker, as every `*.worker` config block spells it. */
211
- export interface StateWorkerRef {
212
- baseUrl: string;
213
- /** Env var holding the bearer; default MEMORY_TOKEN. */
214
- tokenEnv?: string;
215
- }
216
-
217
- /** The config blocks that may name the state Worker — one Worker, referenced from each capability that uses it. */
218
- export interface StateWorkerBlocks {
219
- runtimeOverrides?: { worker?: StateWorkerRef };
220
- runHistory?: { worker?: StateWorkerRef };
221
- schedules?: { worker?: StateWorkerRef };
222
- memory?: { worker?: StateWorkerRef };
223
- }
224
-
225
- const DEFAULT_TOKEN_ENV = "MEMORY_TOKEN";
226
-
227
- /** The slice of the process's `Secrets` the builder reads (src/secrets.ts; named
228
- * structurally so this module stays free of the bot's Node-only imports for the Worker). */
229
- export interface SecretReader {
230
- named(name: string): { reveal(): string } | undefined;
231
- }
232
-
233
219
  /** Process-startup store selection (src/index.ts, the CLI): the Worker store
234
220
  * behind whichever `*.worker` block names the state Worker — the snapshot is
235
221
  * a cache, so it earns no block of its own — else the in-memory store with a
@@ -239,16 +225,14 @@ export function buildDeliverySnapshotStore(
239
225
  secrets: SecretReader,
240
226
  warn: (message: string) => void,
241
227
  ): DeliverySnapshotStore {
242
- const worker = [config.runtimeOverrides, config.runHistory, config.schedules, config.memory].find(
243
- (block) => block?.worker?.baseUrl,
244
- )?.worker;
228
+ const worker = stateWorkerOf(config);
245
229
  if (!worker) {
246
230
  warn(
247
- "delivery snapshots are kept in memory — a restart loses them and the first request after one reads GitHub live. Name the state Worker (runHistory.worker, runtimeOverrides.worker, schedules.worker or memory.worker) to keep them on it.",
231
+ `delivery snapshots are kept in memory — a restart loses them and the first request after one reads GitHub live. Name the state Worker (${STATE_WORKER_BLOCK_NAMES}) to keep them on it.`,
248
232
  );
249
233
  return new InMemoryDeliverySnapshotStore();
250
234
  }
251
- const tokenEnv = worker.tokenEnv ?? DEFAULT_TOKEN_ENV;
235
+ const tokenEnv = worker.tokenEnv ?? DEFAULT_STATE_WORKER_TOKEN_ENV;
252
236
  const token = secrets.named(tokenEnv);
253
237
  if (!token) {
254
238
  warn(
@@ -2,22 +2,29 @@
2
2
  // (src/channels/http.ts, src/channels/mcp.ts) and the Cloudflare Worker shim
3
3
  // (deploy/cloudflare/worker.ts, which fires scheduled runs through /ingress)
4
4
  // read `SWITCHBOARD_INGRESS_TOKENS` with ONE parser. The shape is
5
- // {"<raw bearer token>": {"subject": "alice", "channel": "ops"}, ...}
5
+ // {"<raw bearer token>": {"subject": "alice", "channel": "ops", "email": "alice@example.com"}, ...}
6
6
  // A token is a CREDENTIAL, nothing more: what its bearer may do — start a run
7
7
  // (`dispatch`), read runs, anything else — is the `grants` entry for
8
8
  // `http:<subject>` / `mcp:<subject>` in config.yaml (docs/reference/specs/authorization.md
9
- // item 9). Absent, empty, or malformed => an empty map. Callers treat an empty
10
- // map as "ingress disabled" (fail-closed) — this module never decides that, it
11
- // only parses. Token material is never logged here; the caller may log the reason.
9
+ // item 9). `email` names the PERSON the credential belongs to (identity, never
10
+ // authority: the run is theirs, the grants stay the credential's —
11
+ // authorization.md item 15). Absent, empty, or malformed => an empty map.
12
+ // Callers treat an empty map as "ingress disabled" (fail-closed) — this module
13
+ // never decides that, it only parses. Token material is never logged here; the
14
+ // caller may log the reason.
12
15
 
13
- /** The identity a token maps to. `subject` becomes the platform-namespaced user
14
- * id (`http:<subject>`); an optional `channel` names the channel a dispatch
15
- * through this token is recorded under (`http:<channel>`) — routing, not a
16
- * grant. These two are the whole identity: any other field in an entry is
17
- * ignored, so nothing in the token map can widen what the grants entry says. */
16
+ /** The identity a token maps to. `subject` becomes the platform-namespaced
17
+ * credential id (`http:<subject>`); an optional `channel` names the channel a
18
+ * dispatch through this token is recorded under (`http:<channel>`) — routing,
19
+ * not a grant; an optional `email` names the person the credential is bound
20
+ * to, resolved to their Slack identity by the adapter (`boundRequester`) so
21
+ * the run is requested by the person and the credential is `authenticatedAs`.
22
+ * These three are the whole identity: any other field in an entry is ignored,
23
+ * so nothing in the token map can widen what the grants entry says. */
18
24
  export interface IngressIdentity {
19
25
  subject: string;
20
26
  channel?: string;
27
+ email?: string;
21
28
  }
22
29
 
23
30
  export type IngressTokenMap = Record<string, IngressIdentity>;
@@ -28,9 +35,10 @@ export type ParsedIngressTokens =
28
35
  | { ok: false; reason: string; tokens: IngressTokenMap };
29
36
 
30
37
  /** Parse the raw env value. Entries with an empty token, a non-object value, a
31
- * missing/empty `subject`, or a non-string `channel` are skipped; the rest are
32
- * kept, each reduced to `{ subject, channel? }`. `ok: false` only for a value
33
- * that is not a JSON object at all. */
38
+ * missing/empty `subject`, a non-string `channel` or a non-string `email` are
39
+ * skipped; the rest are kept, each reduced to `{ subject, channel?, email? }`
40
+ * (an `email` without an `@` is dropped from the entry, never the entry).
41
+ * `ok: false` only for a value that is not a JSON object at all. */
34
42
  export function parseIngressTokenMap(raw: string | undefined): ParsedIngressTokens {
35
43
  if (!raw || raw.trim() === "") return { ok: true, tokens: {} };
36
44
  let parsed: unknown;
@@ -49,9 +57,15 @@ export function parseIngressTokenMap(raw: string | undefined): ParsedIngressToke
49
57
  const v = value as Record<string, unknown>;
50
58
  const subject = v.subject;
51
59
  const channel = v.channel;
60
+ const email = v.email;
52
61
  if (typeof subject !== "string" || subject === "") continue;
53
62
  if (channel !== undefined && typeof channel !== "string") continue;
54
- tokens[token] = typeof channel === "string" ? { subject, channel } : { subject };
63
+ if (email !== undefined && typeof email !== "string") continue;
64
+ tokens[token] = {
65
+ subject,
66
+ ...(typeof channel === "string" ? { channel } : {}),
67
+ ...(typeof email === "string" && email.includes("@") ? { email: email.trim().toLowerCase() } : {}),
68
+ };
55
69
  }
56
70
  return { ok: true, tokens };
57
71
  }
@@ -53,6 +53,9 @@ export interface LiveRunMeta {
53
53
  repo?: string;
54
54
  sourceUrl?: string;
55
55
  userName?: string;
56
+ /** The bound credential behind the person (authorization.md item 15): a
57
+ * resume or restart dispatches under ITS grants again, never the person's. */
58
+ authenticatedAs?: string;
56
59
  effort?: string;
57
60
  ref?: string;
58
61
  headSha?: string;
@@ -69,12 +69,18 @@ export interface RunRecord {
69
69
  model?: string;
70
70
  /** Platform-namespaced ids (AGENTS.md invariant 4). */
71
71
  channelId: string;
72
- /** The person the run was for — the message's sender, or the person an app
73
- * relayed it for (slack-channel.md item 13); `slack:bot:<id>` only when no
74
- * person could be found behind an app's post. */
72
+ /** The person the run was for — the message's sender, the person an app
73
+ * relayed it for (slack-channel.md item 13), or the person a bound
74
+ * credential belongs to (authorization.md item 15); `slack:bot:<id>` only
75
+ * when no person could be found behind an app's post. */
75
76
  userId: string;
76
77
  /** The app that posted the request for `userId`, by display name, when it was not their own message. */
77
78
  relayedBy?: string;
79
+ /** The credential that authenticated the request (`http:<subject>`,
80
+ * `mcp:<subject>`, `cli:local`) when it was bound to the person `userId`
81
+ * names — the run is theirs, the grants were the credential's. Absent when
82
+ * the sender and the credential are one. */
83
+ authenticatedAs?: string;
78
84
  threadKey: string;
79
85
  /** How the run's channel may travel (authorization): stamped at dispatch
80
86
  * from the `ChannelDirectory`, read by `member-of` (a `public` run is
@@ -767,6 +773,7 @@ export function isRunRecord(v: unknown): v is RunRecord {
767
773
  return false;
768
774
  if (typeof r.channelId !== "string" || typeof r.userId !== "string" || typeof r.threadKey !== "string") return false;
769
775
  if (r.relayedBy !== undefined && typeof r.relayedBy !== "string") return false;
776
+ if (r.authenticatedAs !== undefined && typeof r.authenticatedAs !== "string") return false;
770
777
  // Absent on records written before the stamp existed (read as `unknown`); present → a known value.
771
778
  if (r.channelVisibility !== undefined && !CHANNEL_VISIBILITIES.includes(r.channelVisibility as ChannelVisibility))
772
779
  return false;
@@ -415,8 +415,9 @@ export function watchdogFiring(
415
415
  const errors = result.results.filter((r) => r.error !== undefined);
416
416
  const created = result.results.filter((r) => instanceActionOf(r.instance) === "created").length;
417
417
  const timedOut = result.results.filter((r) => r.action === "provision-timed-out").length;
418
+ const rebuilt = result.results.filter((r) => r.action === "auto-rebuilt").length;
418
419
  const disk = fullestDisk(result.results);
419
- const counts = `${result.count}/${result.cap} residents · ${created} refreshed · ${timedOut} timed out · ${errors.length} errors${disk ? ` · disk max ${disk}` : ""}`;
420
+ const counts = `${result.count}/${result.cap} residents · ${created} refreshed · ${timedOut} timed out · ${errors.length} errors${rebuilt > 0 ? ` · ${rebuilt} rebuilt` : ""}${disk ? ` · disk max ${disk}` : ""}`;
420
421
  const first = errors[0];
421
422
  return {
422
423
  schedule: schedule.name,
@@ -554,6 +554,27 @@ export type CoordinatorNote =
554
554
  * person can see whether the cap or the child is the problem. */
555
555
  export type ShipBudgetSpent = Readonly<Record<"coding" | "review" | "waiting", number>>;
556
556
 
557
+ /** The severity ship must address before an approve stands (agent-ship item
558
+ * 9's gate): an approve carrying a finding at or above this level continues
559
+ * into the findings step exactly as a request_changes does. Ordered most to
560
+ * least severe. */
561
+ export const ADDRESS_SEVERITIES = ["blocking", "major", "minor", "nit"] as const;
562
+ export type AddressSeverity = (typeof ADDRESS_SEVERITIES)[number];
563
+ /** Where the level in force came from, most specific wins: a `severity:` directive on the request (`run`), the user's or the channel's config scope, the org's `ship.addressSeverity` (or its default). */
564
+ export type AddressSeveritySource = "org" | "channel" | "user" | "run";
565
+ export const DEFAULT_ADDRESS_SEVERITY: AddressSeverity = "minor";
566
+ export const isAddressSeverity = (v: unknown): v is AddressSeverity =>
567
+ (ADDRESS_SEVERITIES as readonly unknown[]).includes(v);
568
+
569
+ const severityRank = (s: AddressSeverity): number => ADDRESS_SEVERITIES.indexOf(s);
570
+ /** The findings the gate acts on at `level`: at or above it. Every parsed
571
+ * finding carries one of the ladder's four levels — the verdict parser
572
+ * (`parseVerdictInput`) drops an entry with any other severity, `fyi` or none,
573
+ * with a note — so nothing outside the ladder ever reaches the gate. */
574
+ export function findingsAtOrAbove(findings: readonly Finding[], level: AddressSeverity): Finding[] {
575
+ return findings.filter((f) => severityRank(f.severity) <= severityRank(level));
576
+ }
577
+
557
578
  export interface UnitPipelineInput {
558
579
  unit: { id: string; branch: string };
559
580
  repo: string;
@@ -567,6 +588,12 @@ export interface UnitPipelineInput {
567
588
  * `runner` (a seeded plan, under its grant) or `person` (a task, or a
568
589
  * record without the field). */
569
590
  merge: "runner" | "person";
591
+ /** The severity to address: resolved once by the hand-off —
592
+ * directive > user > channel > org — and written on the instance beside
593
+ * `merge`, so the machine reads one value. Absent reads as the default. */
594
+ addressSeverity?: AddressSeverity;
595
+ /** Which layer set the level in force; named in the round header and the ending. */
596
+ addressSeveritySource?: AddressSeveritySource;
570
597
  /** The instance's mark (agent-ship item 16): a generated one-unit plan — a
571
598
  * `plan` with an id and no `path` — whose unit runs in the requesting
572
599
  * thread and is re-issued with the request's own text, never a plan path. */
@@ -960,6 +987,34 @@ function settleReview(
960
987
  },
961
988
  notes,
962
989
  );
990
+ // The severity gate: an approve carrying a finding at or
991
+ // above the level in force does not end the unit — the round continues
992
+ // into the findings step exactly as a request_changes does, and only an
993
+ // approve whose findings all sit below the level stands as merge-ready.
994
+ const level = next.input.addressSeverity ?? DEFAULT_ADDRESS_SEVERITY;
995
+ const gated = findingsAtOrAbove(verdict.findings, level);
996
+ if (gated.length > 0) {
997
+ if (mode !== undefined)
998
+ return end(
999
+ next,
1000
+ {
1001
+ kind: "stopped",
1002
+ mode,
1003
+ round,
1004
+ reviewRounds: next.reviewRounds,
1005
+ ...(facts.finalReply !== undefined ? { finalReply: facts.finalReply } : {}),
1006
+ ...(facts.reviewPosted === true ? { postedReview: true } : {}),
1007
+ },
1008
+ notes,
1009
+ );
1010
+ if (next.reviewRounds >= next.input.caps.maxRounds)
1011
+ return end(
1012
+ next,
1013
+ { kind: "round_cap", maxRounds: next.input.caps.maxRounds, reviewRounds: next.reviewRounds },
1014
+ notes,
1015
+ );
1016
+ return enterRound(next, { index: round.index, kind: "findings" }, notes);
1017
+ }
963
1018
  const pr = next.pr!;
964
1019
  if (next.input.merge !== "runner")
965
1020
  return end(next, { kind: "merge_ready", pr, reviewRounds: next.reviewRounds }, notes);
@@ -1383,6 +1438,17 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1383
1438
  const declined = [...(s.dispositionsByRound[e.reviewRounds - 1] ?? [])].filter((d) => d.disposition === "declined");
1384
1439
  const declinedLine = `Declined findings: ${declined.length > 0 ? declined.map((d) => `${d.findingId}${d.note ? ` — ${d.note}` : ""}`).join("; ") : "none"}`;
1385
1440
  const verdictLine = `Verdict: LGTM${s.lastVerdictSummary ? ` — ${s.lastVerdictSummary}` : ""}`;
1441
+ // The level in force and its source, plus the findings it left
1442
+ // below the gate on the approved round — named so a skipped finding is a
1443
+ // stated decision, never a silent one.
1444
+ const level = s.input.addressSeverity ?? DEFAULT_ADDRESS_SEVERITY;
1445
+ const levelLine = `Severity addressed: ${level} and above (set by ${s.input.addressSeveritySource ?? "org"}).`;
1446
+ const lastFindings = s.findingsByRound[e.reviewRounds] ?? [];
1447
+ const skipped = lastFindings.filter((f) => !findingsAtOrAbove([f], level).length);
1448
+ const skippedLine =
1449
+ skipped.length > 0
1450
+ ? `Findings below ${level}, left as-is: ${skipped.map((f) => `${f.id} (${f.severity}) — ${f.title}`).join("; ")}`
1451
+ : undefined;
1386
1452
  const join = (parts: Array<string | undefined>) => parts.filter(Boolean).join("\n\n");
1387
1453
  switch (e.kind) {
1388
1454
  case "merged":
@@ -1391,12 +1457,16 @@ export function renderUnitReport(s: UnitPipelineState, facts?: MergeReadyFacts):
1391
1457
  return [
1392
1458
  `✅ Merged after ${rounds}: ${e.pr.url} (squash \`${e.sha.slice(0, 7)}\`) — merged by the plan runner under \`plan:merge\`: the review approved at this head and the guards were green.`,
1393
1459
  verdictLine,
1460
+ levelLine,
1461
+ ...(skippedLine ? [skippedLine] : []),
1394
1462
  declinedLine,
1395
1463
  ].join("\n");
1396
1464
  case "merge_ready":
1397
1465
  return [
1398
1466
  `✅ Merge-ready after ${rounds}: ${e.pr.url}`,
1399
1467
  verdictLine,
1468
+ levelLine,
1469
+ ...(skippedLine ? [skippedLine] : []),
1400
1470
  declinedLine,
1401
1471
  // What the driver read at the approved head when it composed this
1402
1472
  // ending (agent-ship item 9): a merge that already happened is named
@@ -0,0 +1,30 @@
1
+ // How old a snapshot is, in the words every page that serves one uses (the
2
+ // delivery page, the costs page): shared so the two never drift. Node-free.
3
+
4
+ /** `just now`, `n minutes ago`, `n hours ago`, `n days ago` — the snapshot's age at `nowMs`; empty for an unparsable instant. */
5
+ export function snapshotAgeText(snapshotAt: string, nowMs: number): string {
6
+ const minutes = Math.max(0, Math.floor((nowMs - Date.parse(snapshotAt)) / 60_000));
7
+ if (!Number.isFinite(minutes)) return "";
8
+ const unit = (n: number, word: string): string => `${n} ${word}${n === 1 ? "" : "s"} ago`;
9
+ if (minutes < 1) return "just now";
10
+ if (minutes < 90) return unit(minutes, "minute");
11
+ const hours = Math.round(minutes / 60);
12
+ if (hours < 48) return unit(hours, "hour");
13
+ return unit(Math.round(hours / 24), "day");
14
+ }
15
+
16
+ /** `in n minutes`, `in n hours`, `in n days` — how far off an instant is at `nowMs`; once it has passed,
17
+ * `due now` for the first minute and `due n minutes ago` after (so an overdue take reads as overdue,
18
+ * not as forever imminent); empty for an unparsable instant. */
19
+ export function untilText(at: string, nowMs: number): string {
20
+ const diffMs = Date.parse(at) - nowMs;
21
+ if (!Number.isFinite(diffMs)) return "";
22
+ if (diffMs <= -60_000) return `due ${snapshotAgeText(at, nowMs)}`;
23
+ if (diffMs < 60_000) return "due now";
24
+ const minutes = Math.floor(diffMs / 60_000);
25
+ const unit = (n: number, word: string): string => `in ${n} ${word}${n === 1 ? "" : "s"}`;
26
+ if (minutes < 90) return unit(minutes, "minute");
27
+ const hours = Math.round(minutes / 60);
28
+ if (hours < 48) return unit(hours, "hour");
29
+ return unit(Math.round(hours / 24), "day");
30
+ }
@@ -0,0 +1,41 @@
1
+ // The one state Worker (deploy/cloudflare-memory/), as the config names it: any
2
+ // `*.worker` block may carry its base URL and the env var holding its bearer,
3
+ // and a capability that keeps a cache there (a delivery snapshot, a costs
4
+ // snapshot) reads whichever block names it — a cache earns no block of its own.
5
+ //
6
+ // Node-free on purpose: the snapshot stores that import this are shared with
7
+ // the Worker by relative path.
8
+
9
+ /** A `{ baseUrl, tokenEnv? }` reference to the state Worker, as every `*.worker` config block spells it. */
10
+ export interface StateWorkerRef {
11
+ baseUrl: string;
12
+ /** Env var holding the bearer; default MEMORY_TOKEN. */
13
+ tokenEnv?: string;
14
+ }
15
+
16
+ /** The config blocks that may name the state Worker — one Worker, referenced from each capability that uses it. */
17
+ export interface StateWorkerBlocks {
18
+ runtimeOverrides?: { worker?: StateWorkerRef };
19
+ runHistory?: { worker?: StateWorkerRef };
20
+ schedules?: { worker?: StateWorkerRef };
21
+ memory?: { worker?: StateWorkerRef };
22
+ }
23
+
24
+ /** The env var holding the state Worker's bearer when a block names none. */
25
+ export const DEFAULT_STATE_WORKER_TOKEN_ENV = "MEMORY_TOKEN";
26
+
27
+ /** The slice of the process's `Secrets` a store builder reads (src/secrets.ts; named
28
+ * structurally so the builders stay free of the bot's Node-only imports for the Worker). */
29
+ export interface SecretReader {
30
+ named(name: string): { reveal(): string } | undefined;
31
+ }
32
+
33
+ /** The first `*.worker` block that names the state Worker, in the order the blocks are listed above. */
34
+ export function stateWorkerOf(config: StateWorkerBlocks): StateWorkerRef | undefined {
35
+ return [config.runtimeOverrides, config.runHistory, config.schedules, config.memory].find(
36
+ (block) => block?.worker?.baseUrl,
37
+ )?.worker;
38
+ }
39
+
40
+ /** The names a `*.worker` block may carry the state Worker under, for a warning that says where to put one. */
41
+ export const STATE_WORKER_BLOCK_NAMES = "runHistory.worker, runtimeOverrides.worker, schedules.worker or memory.worker";