@coreplane/switchboard 1.239.0 → 1.241.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 (156) hide show
  1. package/dist/assets/config/config.example.yaml +32 -7
  2. package/dist/assets/deploy/cloudflare/worker.ts +77 -18
  3. package/dist/assets/deploy/cloudflare-memory/worker.ts +115 -1
  4. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +7 -1
  5. package/dist/assets/deploy/cloudflare-resident/worker.ts +224 -70
  6. package/dist/assets/deploy/secrets.manifest.json +7 -1
  7. package/dist/assets/package-lock.json +207 -3
  8. package/dist/assets/package.json +2 -1
  9. package/dist/assets/source.json +3 -3
  10. package/dist/assets/src/agents/registry.ts +27 -8
  11. package/dist/assets/src/core/authz/actor.ts +38 -2
  12. package/dist/assets/src/core/authz/grants.ts +20 -9
  13. package/dist/assets/src/core/authz/policy.ts +6 -0
  14. package/dist/assets/src/core/coordinator/contract.ts +49 -1
  15. package/dist/assets/src/core/coordinator/driver.ts +30 -1
  16. package/dist/assets/src/core/coordinator/instancesRoute.ts +72 -0
  17. package/dist/assets/src/core/costs.ts +1119 -0
  18. package/dist/assets/src/core/costsSnapshotStore.ts +209 -0
  19. package/dist/assets/src/core/delivery.ts +4 -11
  20. package/dist/assets/src/core/deliverySnapshotStore.ts +12 -28
  21. package/dist/assets/src/core/ingressTokens.ts +27 -13
  22. package/dist/assets/src/core/runLedger/types.ts +3 -0
  23. package/dist/assets/src/core/runRecord.ts +10 -3
  24. package/dist/assets/src/core/schedules.ts +2 -1
  25. package/dist/assets/src/core/ship/coordinator.ts +92 -7
  26. package/dist/assets/src/core/snapshotAge.ts +30 -0
  27. package/dist/assets/src/core/stateWorkerRef.ts +41 -0
  28. package/dist/assets/src/core/trace/attrs.ts +1 -1
  29. package/dist/assets/src/execution/bindingPurge.ts +2 -2
  30. package/dist/assets/src/execution/residentAutoRebuild.ts +73 -0
  31. package/dist/assets/src/execution/residentRefresh.ts +25 -3
  32. package/dist/assets/src/execution/restoreWaiters.ts +61 -0
  33. package/dist/assets/src/mcp/registry.ts +7 -0
  34. package/dist/assets/web/dist/.vite/manifest.json +509 -427
  35. package/dist/assets/web/dist/assets/AppShell-DEy5jRuS.js +1 -0
  36. package/dist/assets/web/dist/assets/CostsPage-BuHD-Bv7.js +2 -0
  37. package/dist/assets/web/dist/assets/DeliveryPage-BGpPr5xr.js +1 -0
  38. package/dist/assets/web/dist/assets/HomePage-dKBvWh-E.js +2 -0
  39. package/dist/assets/web/dist/assets/NotFoundPage-DtmQM2gD.js +1 -0
  40. package/dist/assets/web/dist/assets/PendingTurnRow-CrEAWx5b.js +1 -0
  41. package/dist/assets/web/dist/assets/{ResidentDetailPage-CrfCd8fx.js → ResidentDetailPage-C-48fxat.js} +1 -1
  42. package/dist/assets/web/dist/assets/ResidentsIndexPage-CBBIjPOa.js +1 -0
  43. package/dist/assets/web/dist/assets/RunFoldRow-B6vxjcBI.js +1 -0
  44. package/dist/assets/web/dist/assets/RunRoutePage-CQjd3Cz-.js +6 -0
  45. package/dist/assets/web/dist/assets/RunsIndexPage-Cy9G6D0p.js +1 -0
  46. package/dist/assets/web/dist/assets/{RunsTabs-XpCqeWu4.js → RunsTabs-CtNGNnhN.js} +1 -1
  47. package/dist/assets/web/dist/assets/ScheduledPage-Ci1Ygqnc.js +1 -0
  48. package/dist/assets/web/dist/assets/SettingsPage-DxmJvAmT.js +1 -0
  49. package/dist/assets/web/dist/assets/SlackMark-VDNs7Vjh.js +1 -0
  50. package/dist/assets/web/dist/assets/{StatusDot-DkXALvKf.js → StatusDot-DPWE1JmO.js} +1 -1
  51. package/dist/assets/web/dist/assets/{Tooltip-DtLIyi5P.js → Tooltip-DKhSRH9t.js} +1 -1
  52. package/dist/assets/web/dist/assets/UnitRoutePage-DEN5bSQf.js +1 -0
  53. package/dist/assets/web/dist/assets/{angular-html-DYtV9uIN.js → angular-html-DgSK1qvr.js} +1 -1
  54. package/dist/assets/web/dist/assets/{angular-ts-DF2P25Zp.js → angular-ts-D3gNdiSG.js} +1 -1
  55. package/dist/assets/web/dist/assets/{apl-Ff96wxko.js → apl-CkHCYM8I.js} +1 -1
  56. package/dist/assets/web/dist/assets/{astro-LbBnZna-.js → astro-DLm45axt.js} +1 -1
  57. package/dist/assets/web/dist/assets/{blade-DltuxeBW.js → blade-BKa-VE-c.js} +1 -1
  58. package/dist/assets/web/dist/assets/{c-DBg7yuPx.js → c-C8NCNWai.js} +1 -1
  59. package/dist/assets/web/dist/assets/{chapel-C83hUAds.js → chapel-DqDQ7IKH.js} +1 -1
  60. package/dist/assets/web/dist/assets/{cobol-DB0yV_2D.js → cobol-j4vCD6AJ.js} +1 -1
  61. package/dist/assets/web/dist/assets/{coffee-Bpih6Kzl.js → coffee-C6oDVH-0.js} +1 -1
  62. package/dist/assets/web/dist/assets/{cpp-CU00ZeRn.js → cpp-Bd3A1Rtc.js} +1 -1
  63. package/dist/assets/web/dist/assets/{crystal-f8ImJkux.js → crystal-CUOgOw0d.js} +1 -1
  64. package/dist/assets/web/dist/assets/{css-4VrC4ISo.js → css-RYljyv7G.js} +1 -1
  65. package/dist/assets/web/dist/assets/delivery-CTdOqpVK.js +1 -0
  66. package/dist/assets/web/dist/assets/{dist-DtA7JiH5.js → dist-BnJXKHCY.js} +2 -2
  67. package/dist/assets/web/dist/assets/durationTone-DXG-3R7_.js +1 -0
  68. package/dist/assets/web/dist/assets/{edge-BwFDzfS_.js → edge-BJhNdQDc.js} +1 -1
  69. package/dist/assets/web/dist/assets/{elixir-wwIYIpJm.js → elixir-g1AWOAfT.js} +1 -1
  70. package/dist/assets/web/dist/assets/{elm-DyDuM3v3.js → elm-CER5e4Dz.js} +1 -1
  71. package/dist/assets/web/dist/assets/{erb-DFJBEVrh.js → erb-D5aOmMQf.js} +1 -1
  72. package/dist/assets/web/dist/assets/eventSource-tRprhqm7.js +1 -0
  73. package/dist/assets/web/dist/assets/favicon-D1hkyC5j.js +1 -0
  74. package/dist/assets/web/dist/assets/{git-rebase-CuDv-44X.js → git-rebase-D6rfV8jp.js} +1 -1
  75. package/dist/assets/web/dist/assets/{glimmer-js-ZFnrm4J3.js → glimmer-js-BvDH3-mC.js} +1 -1
  76. package/dist/assets/web/dist/assets/{glimmer-ts-XY4Yqshp.js → glimmer-ts-DChtNs9V.js} +1 -1
  77. package/dist/assets/web/dist/assets/{glsl-xnR8efx0.js → glsl-BHlsWrxf.js} +1 -1
  78. package/dist/assets/web/dist/assets/{graphql-D-gO5tnP.js → graphql-BsaBHb53.js} +1 -1
  79. package/dist/assets/web/dist/assets/{hack-CSxbW4ax.js → hack-0lbDosCd.js} +1 -1
  80. package/dist/assets/web/dist/assets/{haml-D4ar42QT.js → haml-BKtco2vr.js} +1 -1
  81. package/dist/assets/web/dist/assets/{handlebars-CMLbDE7o.js → handlebars-BE_cj04z.js} +1 -1
  82. package/dist/assets/web/dist/assets/{html-CVekU4Fs.js → html-BNcr8EVd.js} +1 -1
  83. package/dist/assets/web/dist/assets/{html-derivative-St3HHw_N.js → html-derivative-BsJt2Kei.js} +1 -1
  84. package/dist/assets/web/dist/assets/{http-Mon0DWHS.js → http-BR5P8ER_.js} +1 -1
  85. package/dist/assets/web/dist/assets/{hurl-Cbl5IvBs.js → hurl-CtHalpWS.js} +1 -1
  86. package/dist/assets/web/dist/assets/{localIso-CNA-bhS-.js → indexFormat-B-pd4r7Y.js} +1 -1
  87. package/dist/assets/web/dist/assets/{indexRow-FLJu5mR9.js → indexRow-BD1VT8o8.js} +1 -1
  88. package/dist/assets/web/dist/assets/{java-Bprck1l-.js → java-BXVdhN11.js} +1 -1
  89. package/dist/assets/web/dist/assets/{javascript-C73G1-UR.js → javascript-CyoShgNQ.js} +1 -1
  90. package/dist/assets/web/dist/assets/{jinja-C4DuRxYJ.js → jinja-BQYZzHex.js} +1 -1
  91. package/dist/assets/web/dist/assets/{jison-B2U03GEC.js → jison-DqrUa1Eq.js} +1 -1
  92. package/dist/assets/web/dist/assets/{json-CvTZUDzx.js → json-CQiTF9Jj.js} +1 -1
  93. package/dist/assets/web/dist/assets/{jsx-DMWKCzwo.js → jsx-Y16MkCYQ.js} +1 -1
  94. package/dist/assets/web/dist/assets/{julia-Czn7bV_3.js → julia-zn16YONe.js} +1 -1
  95. package/dist/assets/web/dist/assets/{just-BBxpNfvC.js → just-DcVeN_MN.js} +1 -1
  96. package/dist/assets/web/dist/assets/{latex-Bw9yg5mr.js → latex-BNNoF-WP.js} +1 -1
  97. package/dist/assets/web/dist/assets/{liquid-BRCjaydE.js → liquid-DAi6qHzn.js} +1 -1
  98. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
  99. package/dist/assets/web/dist/assets/{lua-DUu2KIC0.js → lua-BswS0axw.js} +1 -1
  100. package/dist/assets/web/dist/assets/main-B3is4sk6.js +28 -0
  101. package/dist/assets/web/dist/assets/main-DP_zSemY.css +1 -0
  102. package/dist/assets/web/dist/assets/{marko-CZJZIlSQ.js → marko-C-4hkmQZ.js} +1 -1
  103. package/dist/assets/web/dist/assets/{mdc-BmClq6fd.js → mdc-0ZaHUkS7.js} +1 -1
  104. package/dist/assets/web/dist/assets/{nginx-Bmt3xSEm.js → nginx-DBDg5tOR.js} +1 -1
  105. package/dist/assets/web/dist/assets/{nim-Da-i0utK.js → nim-BVMz49Yw.js} +1 -1
  106. package/dist/assets/web/dist/assets/{org-BvAre2qP.js → org-1hO_K6ya.js} +1 -1
  107. package/dist/assets/web/dist/assets/{perl-v9WwFFtB.js → perl-CVRQLvVk.js} +1 -1
  108. package/dist/assets/web/dist/assets/{php-DTU4Zyze.js → php-K9nCrB9n.js} +1 -1
  109. package/dist/assets/web/dist/assets/{pug-DhTwEqTO.js → pug-CTxOcmO3.js} +1 -1
  110. package/dist/assets/web/dist/assets/{qml-BUD9AC_c.js → qml-CmCkfG5q.js} +1 -1
  111. package/dist/assets/web/dist/assets/{r-CAnu56n7.js → r-pP47Xn1X.js} +1 -1
  112. package/dist/assets/web/dist/assets/{razor-Dn0P4gAq.js → razor-BpW6r3tC.js} +1 -1
  113. package/dist/assets/web/dist/assets/{regexp-CZyBRaVW.js → regexp-CINgdY4N.js} +1 -1
  114. package/dist/assets/web/dist/assets/{rst-Ckyd5UsY.js → rst-BZIUEq2Q.js} +1 -1
  115. package/dist/assets/web/dist/assets/{ruby-BBQ62fsx.js → ruby-nzAOxz6r.js} +1 -1
  116. package/dist/assets/web/dist/assets/{sas-CW35hMxG.js → sas-QIS1bFth.js} +1 -1
  117. package/dist/assets/web/dist/assets/{scss-CpV8pF4J.js → scss-BuhBBVNt.js} +1 -1
  118. package/dist/assets/web/dist/assets/settingsApi-priOPiwu.js +1 -0
  119. package/dist/assets/web/dist/assets/{shellscript-CRJvsmE1.js → shellscript-CQc1vXbk.js} +1 -1
  120. package/dist/assets/web/dist/assets/{shellsession-CyriUZxf.js → shellsession-CoubCAUv.js} +1 -1
  121. package/dist/assets/web/dist/assets/{soy-D5QzYUeg.js → soy-CPRzlder.js} +1 -1
  122. package/dist/assets/web/dist/assets/{sql-CUBf_Mse.js → sql-W9krb8-9.js} +1 -1
  123. package/dist/assets/web/dist/assets/sseReplay-yji8a1wM.js +9 -0
  124. package/dist/assets/web/dist/assets/{stata-B-4n4ITz.js → stata-CuISJEC0.js} +1 -1
  125. package/dist/assets/web/dist/assets/{surrealql-RuWZBLtr.js → surrealql-CSet7584.js} +1 -1
  126. package/dist/assets/web/dist/assets/{svelte-CO5YzCsK.js → svelte-ChXTSYwK.js} +1 -1
  127. package/dist/assets/web/dist/assets/{templ-DbkWVaBF.js → templ-Bm55v62k.js} +1 -1
  128. package/dist/assets/web/dist/assets/{tex-IuxoEcId.js → tex-CICMX8Gj.js} +1 -1
  129. package/dist/assets/web/dist/assets/{ts-tags-g6dGQPjd.js → ts-tags-CA1UzWyB.js} +1 -1
  130. package/dist/assets/web/dist/assets/{tsx-WnXwiyH2.js → tsx-Dy04HNbv.js} +1 -1
  131. package/dist/assets/web/dist/assets/{twig-Lx1G7PnY.js → twig-BBHsVnVD.js} +1 -1
  132. package/dist/assets/web/dist/assets/{typescript-BQd7biLF.js → typescript-Dvc-wVBT.js} +1 -1
  133. package/dist/assets/web/dist/assets/{typst-D2lXPHK7.js → typst-Dvlqx3_q.js} +1 -1
  134. package/dist/assets/web/dist/assets/{vue-html-DZPaRte4.js → vue-html-D4YxT3An.js} +1 -1
  135. package/dist/assets/web/dist/assets/{vue-vine-DbmZCmu-.js → vue-vine-0YE7uQlH.js} +1 -1
  136. package/dist/assets/web/dist/assets/{vue-4wVQD9y9.js → vue-zWi1MNU2.js} +1 -1
  137. package/dist/assets/web/dist/assets/{xml-DV16szyo.js → xml-gm-iksZB.js} +1 -1
  138. package/dist/assets/web/dist/assets/{xsl-eo5-1sk0.js → xsl-D_W5BcrY.js} +1 -1
  139. package/dist/assets/web/dist/assets/{yaml-BXdX8HZf.js → yaml-CHmZ21wZ.js} +1 -1
  140. package/dist/cli.js +6014 -2362
  141. package/package.json +1 -1
  142. package/dist/assets/web/dist/assets/AppShell-MYHNxJks.js +0 -1
  143. package/dist/assets/web/dist/assets/CostsPage-DKk9OjB0.js +0 -2
  144. package/dist/assets/web/dist/assets/DeliveryPage-Kz6js49i.js +0 -1
  145. package/dist/assets/web/dist/assets/NotFoundPage-BXLrQQzy.js +0 -1
  146. package/dist/assets/web/dist/assets/ResidentsIndexPage-BdW0k2h3.js +0 -1
  147. package/dist/assets/web/dist/assets/RunFoldRow-CbTd9SiS.js +0 -9
  148. package/dist/assets/web/dist/assets/RunRoutePage-br0H-8Pz.js +0 -6
  149. package/dist/assets/web/dist/assets/RunsIndexPage-Bnzb15zH.js +0 -1
  150. package/dist/assets/web/dist/assets/ScheduledPage-NLhjzHAz.js +0 -1
  151. package/dist/assets/web/dist/assets/SettingsPage-D-i0uVw-.js +0 -1
  152. package/dist/assets/web/dist/assets/UnitRoutePage-DGXukEAH.js +0 -1
  153. package/dist/assets/web/dist/assets/durationTone-CYMux25J.js +0 -1
  154. package/dist/assets/web/dist/assets/favicon-CSt-qDvc.js +0 -1
  155. package/dist/assets/web/dist/assets/main-CFHF2RvQ.css +0 -1
  156. package/dist/assets/web/dist/assets/main-Dht0in4v.js +0 -28
@@ -323,14 +323,24 @@ workspaceDir: ./workspaces
323
323
  # ship:
324
324
  # maxRounds: 3 # review rounds per unit
325
325
  # maxMinutes: 120 # the ship preset's wall-clock budget in minutes (the registry's default)
326
+ # addressSeverity: minor # the severity an approve's findings are held to (blocking|major|minor|nit);
327
+ # # a finding at or above it sends the round into a fix step instead of merge-ready.
328
+ # # Per channel/user: config set … --ship.addressSeverity; per run: severity:<level>.
326
329
 
327
- # `harness` is a retired key (docs/reference/specs/harness-pi.md item 1). It
328
- # moved the presets onto pi one at a time; every preset runs on pi now — the pi
329
- # coding agent started inside the run's own execution container (or as a child
330
- # of the bot for a preset without a workspace) with the run's model-proxy
331
- # bearer as its only key — and there is no other loop. A block written during
332
- # the series still loads when every value is `pi`; `native` names the deleted
333
- # loop and fails the load by name, as does an unknown preset. Remove the block.
330
+ # Which harness each preset's runs are driven by (docs/reference/specs/harness.md
331
+ # item 8): a mapping of preset to a harness's name. Two exist — `pi` (the pi
332
+ # coding agent, the default for every preset) and `opencode` (OpenCode's server,
333
+ # `opencode serve`) — each started inside the run's own execution container (or
334
+ # as a child of the bot for a preset without a workspace) with the run's
335
+ # model-proxy bearer as its only key, every tool call decided in the bot, every
336
+ # event on the run's record. A preset the block does not name runs on pi;
337
+ # nothing defaults to OpenCode. A run keeps the harness it started on, so
338
+ # changing a word moves the next run, never one in flight. Any other word
339
+ # (`native`, the deleted loop; `codex`) fails the load naming the two, as does
340
+ # an unknown preset. How-to: docs/how-to/put-a-preset-on-opencode.md.
341
+ # harness:
342
+ # coding: opencode # coding runs on OpenCode
343
+ # review: pi # the default, written out
334
344
 
335
345
  # What the harness writes into pi's per-run settings for every run on pi
336
346
  # (docs/reference/specs/harness-pi.md item 4): pi's compaction thresholds, in
@@ -346,6 +356,18 @@ workspaceDir: ./workspaces
346
356
  # reserveTokens: 150000
347
357
  # keepRecentTokens: 8000
348
358
 
359
+ # What the harness writes into OpenCode's per-run configuration for every run
360
+ # on OpenCode (docs/reference/specs/harness.md item 8): OpenCode's compaction
361
+ # thresholds, in tokens, under OpenCode's own names — `buffer`, the tokens kept
362
+ # free of the model's window before a compaction runs; `keepTokens`, how much
363
+ # of the newest turns it keeps. Unset, OpenCode's defaults stand and the file
364
+ # names no compaction. The OpenCode twin of the `pi` block above; positive
365
+ # integers only, any other shape or key fails the load by name.
366
+ # opencode:
367
+ # compaction:
368
+ # buffer: 20000
369
+ # keepTokens: 8000
370
+
349
371
  # The fan-out cap a spawning run meets (docs/reference/specs/agent-conductor.md).
350
372
  # `agent:conductor` starts child runs as the person who asked — each an
351
373
  # ordinary run in a thread of its own, under that person's permissions — and
@@ -386,6 +408,9 @@ workspaceDir: ./workspaces
386
408
  # cloudflareAccountName: acme-infra # optional: how the page names the account beside its share (else the id's first hex)
387
409
  # cloudflareTokenEnv: CF_ANALYTICS_TOKEN # token with Account Analytics:Read
388
410
  # anthropicAdminKeyEnv: ANTHROPIC_ADMIN_KEY # optional: sk-ant-admin… for LLM spend
411
+ # snapshot: # the page serves a snapshot of both billing sources, never a live read;
412
+ # everyHours: 24 # how often the bot reads them (1–168, default 24). `costs snapshot`
413
+ # # takes one now. Kept on the state Worker any `*.worker` block names
389
414
  # groups:
390
415
  # myapp:
391
416
  # label: My App
@@ -17,8 +17,11 @@ import {
17
17
  createInstanceResponse,
18
18
  instanceStatusResponse,
19
19
  isInstanceNotFound,
20
+ parseInstanceEventPath,
20
21
  parseInstanceStatusPath,
21
22
  parseCreateInstanceRequest,
23
+ parseSendEventRequest,
24
+ sendEventResponse,
22
25
  parseSubjectAuthorization,
23
26
  type CreateInstanceOutcome,
24
27
  } from "../../src/core/coordinator/instancesRoute.ts";
@@ -87,6 +90,7 @@ export interface Env {
87
90
  DASHBOARD_TOKEN?: string; // dashboard auth `token` strategy: the bearer (the default env name; config may name another)
88
91
  SWITCHBOARD_INGRESS_TOKENS?: string; // enables HTTP /ingress + MCP /mcp (JSON token→identity map); the `cron` entry is what scheduled runs present; an entry whose `http:<subject>` actor holds `deploy:write` in the bot's config may POST /admin/restart
89
92
  BRAVE_SEARCH_API_KEY?: string; // web_search backend (Brave); web_fetch works without it
93
+ GITHUB_WEBHOOK_SECRET?: string; // check-run intake: signs POST /webhooks/github; absent, the intake answers 503 disabled
90
94
  CF_ANALYTICS_TOKEN?: string; // costs dash: Cloudflare API token, Account Analytics:Read only
91
95
  ANTHROPIC_ADMIN_KEY?: string; // costs dash (optional): Anthropic Admin API key for the LLM cost report
92
96
  MEMORY_TOKEN?: string; // durable memory + friction ledger + schedule firings + MCP registry: bearer for the state Worker
@@ -125,6 +129,7 @@ const FORWARDED_OPTIONAL = [
125
129
  "ANTHROPIC_ADMIN_KEY",
126
130
  "SWITCHBOARD_INGRESS_TOKENS",
127
131
  "BRAVE_SEARCH_API_KEY",
132
+ "GITHUB_WEBHOOK_SECRET",
128
133
  "MEMORY_TOKEN",
129
134
  "MCP_CREDENTIAL_KEY",
130
135
  "MCP_ACCESS_CLIENT_ID",
@@ -407,6 +412,57 @@ async function handleCoordinatorInstanceStatus(request: Request, env: Env, id: s
407
412
  return json(res.status, res.body);
408
413
  }
409
414
 
415
+ /** `POST /admin/coordinator/instances/<id>/events` — the shim's event relay
416
+ * (docs/reference/specs/http-ingress.md item 12): the bot's check-run intake
417
+ * holds no Workflow binding, so its `checks-settled-<head>` send crosses here
418
+ * and this Worker's own `SHIP_COORDINATOR` binding delivers it. The same door
419
+ * as the create and the status: the bearer in the map, the bot's `authorize`
420
+ * answer. The engine's `instance.not_found` is 404; any other refusal (an
421
+ * instance that already ended) is 502 by reason — the sender treats both as a
422
+ * failed send and the driver's bounded wait is the fallback. */
423
+ async function handleCoordinatorInstanceEvent(request: Request, env: Env, id: string): Promise<Response> {
424
+ const json = (status: number, body: Record<string, unknown>) =>
425
+ new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
426
+ if (request.method !== "POST")
427
+ return json(405, {
428
+ ok: false,
429
+ error: `method not allowed: ${request.method} — ${COORDINATOR_INSTANCES_PATH}/<id>/events answers POST only`,
430
+ });
431
+ const authorization = request.headers.get("authorization") ?? undefined;
432
+ const authn = authenticateIngressBearer(authorization, env.SWITCHBOARD_INGRESS_TOKENS, "coordinator");
433
+ if (!authn.ok) {
434
+ console.warn(`[coordinator] instance event ${authn.status} — ${authn.reason}`);
435
+ return json(authn.status, { ok: false, error: authn.reason });
436
+ }
437
+ let answer: Response;
438
+ try {
439
+ answer = await getContainer(env.SWITCHBOARD, INSTANCE).fetch(
440
+ new Request(`${INTERNAL}${COORDINATOR_AUTHORIZE_PATH}`, {
441
+ method: "POST",
442
+ headers: { authorization: authorization ?? "" },
443
+ }),
444
+ );
445
+ } catch (err) {
446
+ const reason = err instanceof Error ? err.message : String(err);
447
+ return json(503, { ok: false, error: `coordinator disabled: the bot could not be asked (${reason})` });
448
+ }
449
+ const auth = parseSubjectAuthorization(answer.status, await answer.text().catch(() => ""));
450
+ if (!auth.ok) return json(auth.status, { ok: false, error: auth.reason });
451
+ const parsed = parseSendEventRequest(await request.text().catch(() => ""));
452
+ if (!parsed.ok) return json(400, { ok: false, error: parsed.reason });
453
+ let outcome: Parameters<typeof sendEventResponse>[0];
454
+ try {
455
+ await (await env.SHIP_COORDINATOR.get(id)).sendEvent({ type: parsed.type, payload: parsed.payload });
456
+ outcome = { kind: "sent", id };
457
+ } catch (err) {
458
+ const reason = err instanceof Error ? err.message : String(err);
459
+ outcome = isInstanceNotFound(reason) ? { kind: "absent", id } : { kind: "failed", id, reason };
460
+ }
461
+ const res = sendEventResponse(outcome);
462
+ console.log(`[coordinator] ${auth.subject} → instance ${id} event ${parsed.type}: ${outcome.kind}`);
463
+ return json(res.status, res.body);
464
+ }
465
+
410
466
  /** Record a firing on the state Worker's ScheduleDO (the /runs Scheduled panel
411
467
  * reads it). Best-effort: a failure here is a log line — the run itself (if
412
468
  * any) already happened and is its own record. */
@@ -440,29 +496,32 @@ export default {
440
496
  // The routes the Worker answers itself — the restart, the coordinator's
441
497
  // instance creation and an instance's status, all over bindings only this
442
498
  // Worker holds; everything else is the container's.
443
- const statusId = parseInstanceStatusPath(pathname);
499
+ const eventsId = parseInstanceEventPath(pathname);
500
+ const statusId = eventsId === undefined ? parseInstanceStatusPath(pathname) : undefined;
444
501
  const res =
445
502
  pathname === "/admin/restart"
446
503
  ? await handleAdminRestart(forwarded, env)
447
504
  : pathname === COORDINATOR_INSTANCES_PATH
448
505
  ? await handleCoordinatorInstances(forwarded, env)
449
- : statusId !== undefined
450
- ? await handleCoordinatorInstanceStatus(forwarded, env, statusId)
451
- : pathname === COPY_PATH
452
- ? // The artifact copy (artifactsCopy.ts): the R2 binding and the Slack
453
- // token are this Worker's; the bot only asks, with its copy bearer.
454
- // `forwarded`, never `inbound`: `withTraceContext` rebuilt the request
455
- // with `new Request(inbound, …)`, which takes the body stream with it —
456
- // `inbound.text()` is empty afterwards and the route read "not JSON" live.
457
- await handleArtifactsCopy(forwarded, {
458
- bucket: env.ARTIFACTS,
459
- bucketName: env.ARTIFACTS_BUCKET_NAME,
460
- copyToken: env.ARTIFACTS_COPY_TOKEN,
461
- slackToken: env.SLACK_BOT_TOKEN,
462
- fetch: (input, init) => fetch(input, init),
463
- lengthPipe: (size) => new FixedLengthStream(size),
464
- })
465
- : withLength(await getContainer(env.SWITCHBOARD, INSTANCE).fetch(forwarded));
506
+ : eventsId !== undefined
507
+ ? await handleCoordinatorInstanceEvent(forwarded, env, eventsId)
508
+ : statusId !== undefined
509
+ ? await handleCoordinatorInstanceStatus(forwarded, env, statusId)
510
+ : pathname === COPY_PATH
511
+ ? // The artifact copy (artifactsCopy.ts): the R2 binding and the Slack
512
+ // token are this Worker's; the bot only asks, with its copy bearer.
513
+ // `forwarded`, never `inbound`: `withTraceContext` rebuilt the request
514
+ // with `new Request(inbound, …)`, which takes the body stream with it —
515
+ // `inbound.text()` is empty afterwards and the route read "not JSON" live.
516
+ await handleArtifactsCopy(forwarded, {
517
+ bucket: env.ARTIFACTS,
518
+ bucketName: env.ARTIFACTS_BUCKET_NAME,
519
+ copyToken: env.ARTIFACTS_COPY_TOKEN,
520
+ slackToken: env.SLACK_BOT_TOKEN,
521
+ fetch: (input, init) => fetch(input, init),
522
+ lengthPipe: (size) => new FixedLengthStream(size),
523
+ })
524
+ : withLength(await getContainer(env.SWITCHBOARD, INSTANCE).fetch(forwarded));
466
525
  root.end(res.status >= 500 ? "error" : "ok", { httpStatus: res.status });
467
526
  return res;
468
527
  } catch (err) {
@@ -10,6 +10,7 @@ import {
10
10
  } from "../../src/core/memory/engine.ts";
11
11
  import { tokenize } from "../../src/core/memory/scorer.ts";
12
12
  import { FIRING_DETAIL_MAX, isScheduleFiring, type ScheduleFiring } from "../../src/core/schedules.ts";
13
+ import { isCostsSnapshot, type CostsSnapshot } from "../../src/core/costsSnapshotStore.ts";
13
14
  import { REPO_SLUG } from "../../src/core/delivery.ts";
14
15
  import {
15
16
  isDeliverySnapshot,
@@ -176,6 +177,8 @@ export interface Env {
176
177
  SESSION_LOGS: DurableObjectNamespace<SessionLogDO>;
177
178
  /** Delivery snapshots (delivery item 10): ONE DeliveryDO (named "delivery"), one snapshot per repository. */
178
179
  DELIVERY: DurableObjectNamespace<DeliveryDO>;
180
+ /** The costs snapshot (costs item 6): ONE CostsSnapshotDO (named "costs"), one snapshot per installation. */
181
+ COSTS: DurableObjectNamespace<CostsSnapshotDO>;
179
182
  /** The ship coordinator Workflow in the bot's shim Worker (run-history item
180
183
  * 47): where `RunHistoryDO.finish` sends `run-finished-<runId>` for a record
181
184
  * carrying `parentInstanceId`. Optional: this Worker deploys without it (the
@@ -188,6 +191,9 @@ export interface Env {
188
191
  /** The single DeliveryDO's name — every repository's snapshot lives in one object. */
189
192
  const DELIVERY_OBJECT = "delivery";
190
193
 
194
+ /** The single CostsSnapshotDO's name — the installation has one costs snapshot. */
195
+ const COSTS_OBJECT = "costs";
196
+
191
197
  /** The single ConfigDO's name. */
192
198
  const CONFIG_OBJECT = "config";
193
199
  /** A config document key: short, lowercase, like `overrides`. */
@@ -929,6 +935,110 @@ async function handleDelivery(pathname: string, body: unknown, env: Env): Promis
929
935
  return json({ error: "not found" }, 404);
930
936
  }
931
937
 
938
+ // ---------------------------------------------------------------------------
939
+ // The costs snapshot (docs/reference/specs/costs.md item 6)
940
+ // ---------------------------------------------------------------------------
941
+
942
+ /** The datasets a snapshot's `usage` carries, each stored as its own row. */
943
+ const USAGE_PARTS = [
944
+ "containers",
945
+ "durableObjectRequests",
946
+ "durableObjectDays",
947
+ "durableObjectStorage",
948
+ "workers",
949
+ "r2Storage",
950
+ "r2Operations",
951
+ "workflows",
952
+ ] as const;
953
+
954
+ /**
955
+ * The installation's one costs snapshot: both billing sources' rows over the
956
+ * page's widest range plus the run history's per-user usage, and when they
957
+ * were read (`CostsSnapshot`, the shape the bot validates too). Stored as one
958
+ * row per part — the meta, each usage dataset, the LLM rows, the run usage —
959
+ * so no single value nears the row limit as the window's rows grow; replaced
960
+ * whole on every put, in one transaction, and read back as one document.
961
+ */
962
+ export class CostsSnapshotDO extends DurableObject<Env> {
963
+ private readonly sql: SqlStorage;
964
+
965
+ constructor(ctx: DurableObjectState, env: Env) {
966
+ super(ctx, env);
967
+ this.sql = ctx.storage.sql;
968
+ this.sql.exec(`
969
+ CREATE TABLE IF NOT EXISTS parts (
970
+ part TEXT PRIMARY KEY,
971
+ body TEXT NOT NULL
972
+ );
973
+ `);
974
+ }
975
+
976
+ /** Replace the snapshot whole: every part rewritten in one transaction. */
977
+ async put(snapshot: CostsSnapshot): Promise<void> {
978
+ const { usage, llm, runUsage, ...meta } = snapshot;
979
+ const rows: Array<[string, unknown]> = [
980
+ ["meta", meta],
981
+ ...USAGE_PARTS.map((name): [string, unknown] => [`usage.${name}`, usage[name]]),
982
+ ["llm", llm],
983
+ ["runUsage", runUsage],
984
+ ];
985
+ this.ctx.storage.transactionSync(() => {
986
+ this.sql.exec(`DELETE FROM parts`);
987
+ for (const [part, body] of rows)
988
+ this.sql.exec(`INSERT INTO parts (part, body) VALUES (?, ?)`, part, JSON.stringify(body));
989
+ });
990
+ }
991
+
992
+ /** The stored snapshot reassembled from its parts, or null when none was stored (or the parts do not make one). */
993
+ async get(): Promise<CostsSnapshot | null> {
994
+ const parts = new Map(
995
+ this.sql
996
+ .exec<{ part: string; body: string }>(`SELECT part, body FROM parts`)
997
+ .toArray()
998
+ .map((r) => [r.part, r.body]),
999
+ );
1000
+ const meta = parts.get("meta");
1001
+ if (meta === undefined) return null;
1002
+ const read = (part: string): unknown => {
1003
+ const body = parts.get(part);
1004
+ return body === undefined ? undefined : (JSON.parse(body) as unknown);
1005
+ };
1006
+ const usage = Object.fromEntries(USAGE_PARTS.map((name) => [name, read(`usage.${name}`)]));
1007
+ const snapshot = {
1008
+ ...(JSON.parse(meta) as Record<string, unknown>),
1009
+ usage,
1010
+ llm: read("llm"),
1011
+ runUsage: read("runUsage"),
1012
+ };
1013
+ return isCostsSnapshot(snapshot) ? snapshot : null;
1014
+ }
1015
+ }
1016
+
1017
+ const COSTS_ROUTES = new Set(["/costs/snapshot/get", "/costs/snapshot/put"]);
1018
+
1019
+ async function handleCosts(pathname: string, body: unknown, env: Env): Promise<Response> {
1020
+ const b = (typeof body === "object" && body !== null ? body : {}) as Record<string, unknown>;
1021
+ const dO = env.COSTS.get(env.COSTS.idFromName(COSTS_OBJECT));
1022
+ if (pathname === "/costs/snapshot/get") {
1023
+ const snapshot = await dO.get();
1024
+ console.log(
1025
+ `[costs/snapshot/get] -> ${snapshot ? `snapshot taken ${snapshot.takenAt} by ${snapshot.takenBy}` : "none"}`,
1026
+ );
1027
+ return json({ snapshot });
1028
+ }
1029
+ if (pathname === "/costs/snapshot/put") {
1030
+ if (!isCostsSnapshot(b.snapshot))
1031
+ return json(
1032
+ { error: "snapshot must be a CostsSnapshot (takenAt, takenBy, durationMs, range, usage, llm, runUsage)" },
1033
+ 400,
1034
+ );
1035
+ await dO.put(b.snapshot);
1036
+ console.log(`[costs/snapshot/put] <- snapshot taken ${b.snapshot.takenAt} by ${b.snapshot.takenBy}`);
1037
+ return json({ ok: true });
1038
+ }
1039
+ return json({ error: "not found" }, 404);
1040
+ }
1041
+
932
1042
  /** Documents are small; a body over this is refused before storage. */
933
1043
  const MAX_CONFIG_DOCUMENT_BYTES = 256 * 1024;
934
1044
 
@@ -3229,6 +3339,8 @@ const MAX_SNAPSHOT_BODY_BYTES = 16 * 1024 * 1024;
3229
3339
  function bodyFenceFor(pathname: string): number {
3230
3340
  if (WIDE_BODY_ROUTES.has(pathname)) return MAX_RUN_PUT_BODY_BYTES;
3231
3341
  if (pathname === "/delivery/put" || pathname === "/delivery/merge") return MAX_SNAPSHOT_BODY_BYTES;
3342
+ // A ninety-day costs snapshot is a few hundred KB today and grows with the account's rows.
3343
+ if (pathname === "/costs/snapshot/put") return MAX_SNAPSHOT_BODY_BYTES;
3232
3344
  return MAX_BODY_BYTES;
3233
3345
  }
3234
3346
 
@@ -3707,6 +3819,7 @@ async function handleRuns(pathname: string, body: unknown, env: Env): Promise<Re
3707
3819
  const ROUTES = new Set([
3708
3820
  ...CONFIG_ROUTES,
3709
3821
  ...DELIVERY_ROUTES,
3822
+ ...COSTS_ROUTES,
3710
3823
  "/retrieve",
3711
3824
  "/write",
3712
3825
  "/list",
@@ -3735,7 +3848,7 @@ interface Admission {
3735
3848
  async function handleRequest(request: Request, env: Env, admission: Admission): Promise<Response> {
3736
3849
  const url = new URL(request.url);
3737
3850
  if (url.pathname === "/healthz" && request.method === "GET")
3738
- return json({ ok: true, build: BUILD, features: ["memory", "schedules", "runs", "config", "delivery"] });
3851
+ return json({ ok: true, build: BUILD, features: ["memory", "schedules", "runs", "config", "delivery", "costs"] });
3739
3852
  if (!admission.known) return json({ error: "not found" }, 404);
3740
3853
  if (request.method !== "POST") return json({ error: "method not allowed" }, 405);
3741
3854
  if (!admission.authorized) return json({ error: "unauthorized" }, 401);
@@ -3787,6 +3900,7 @@ async function handleRequest(request: Request, env: Env, admission: Admission):
3787
3900
  if (url.pathname.startsWith("/runs/")) return handleRuns(url.pathname, body, env);
3788
3901
  if (url.pathname.startsWith("/config/")) return handleConfig(url.pathname, body, env);
3789
3902
  if (url.pathname.startsWith("/delivery/")) return handleDelivery(url.pathname, body, env);
3903
+ if (url.pathname.startsWith("/costs/")) return handleCosts(url.pathname, body, env);
3790
3904
 
3791
3905
  if (url.pathname === "/retrieve") {
3792
3906
  const parsed = parseRetrieve(body);
@@ -36,6 +36,11 @@
36
36
  // repository — the merged pull requests' facts over the window, one row
37
37
  // each, and when they were read (docs/reference/specs/delivery.md item 10).
38
38
  { "name": "DELIVERY", "class_name": "DeliveryDO" },
39
+ // ONE CostsSnapshotDO (named "costs"): the installation's costs snapshot —
40
+ // both billing sources' rows over the costs page's widest range and the run
41
+ // history's per-user usage, one row per part, replaced whole on every take
42
+ // (docs/reference/specs/costs.md item 6).
43
+ { "name": "COSTS", "class_name": "CostsSnapshotDO" },
39
44
  // One SessionLogDO per session — a thread and an agent (the DO name is
40
45
  // `<threadKey>:<agent>`): the transcript rows of every run of the
41
46
  // session, a full-text index over them, and the notepad
@@ -74,6 +79,7 @@
74
79
  // retired with its rows — docs/reference/specs/self-improvement.md item 1.
75
80
  { "tag": "v7", "deleted_classes": ["FrictionDO"] },
76
81
  { "tag": "v8", "new_sqlite_classes": ["DeliveryDO"] },
77
- { "tag": "v9", "new_sqlite_classes": ["SessionLogDO"] }
82
+ { "tag": "v9", "new_sqlite_classes": ["SessionLogDO"] },
83
+ { "tag": "v10", "new_sqlite_classes": ["CostsSnapshotDO"] }
78
84
  ]
79
85
  }