@coreplane/switchboard 1.252.0 → 1.253.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 (131) hide show
  1. package/dist/assets/config/config.example.yaml +24 -0
  2. package/dist/assets/deploy/cloudflare-memory/runMetricsSink.ts +31 -0
  3. package/dist/assets/deploy/cloudflare-memory/worker.ts +146 -26
  4. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +12 -0
  5. package/dist/assets/deploy/profile.example.json +1 -1
  6. package/dist/assets/package-lock.json +3 -3
  7. package/dist/assets/package.json +1 -1
  8. package/dist/assets/source.json +3 -3
  9. package/dist/assets/src/core/authz/policy.ts +8 -0
  10. package/dist/assets/src/core/budgets.ts +17 -0
  11. package/dist/assets/src/core/coordinator/contract.ts +51 -1
  12. package/dist/assets/src/core/coordinator/driver.ts +102 -11
  13. package/dist/assets/src/core/memory/engine.ts +25 -4
  14. package/dist/assets/src/core/memory/types.ts +6 -2
  15. package/dist/assets/src/core/runEvents.ts +31 -1
  16. package/dist/assets/src/core/runFriction.ts +1 -0
  17. package/dist/assets/src/core/runMetrics.ts +237 -0
  18. package/dist/assets/src/core/runRecord.ts +46 -0
  19. package/dist/assets/src/core/ship/contract.ts +8 -15
  20. package/dist/assets/src/core/ship/coordinator.ts +105 -2
  21. package/dist/assets/src/deploy/profile.ts +16 -0
  22. package/dist/assets/web/dist/.vite/manifest.json +440 -426
  23. package/dist/assets/web/dist/assets/{AppShell-DAtHiuI6.js → AppShell-Bmz8nzz3.js} +1 -1
  24. package/dist/assets/web/dist/assets/{CostsPage-BoKzYa4B.js → CostsPage-CxTKdpzU.js} +1 -1
  25. package/dist/assets/web/dist/assets/{DeliveryPage-3ELQWM0r.js → DeliveryPage-C7jaEJip.js} +1 -1
  26. package/dist/assets/web/dist/assets/HomePage-cWbupQzy.js +3 -0
  27. package/dist/assets/web/dist/assets/{InputMenu-C6aPpe30.js → InputMenu-C9E7YoII.js} +1 -1
  28. package/dist/assets/web/dist/assets/{NotFoundPage-DtE-GgTk.js → NotFoundPage-4nlrq7DP.js} +1 -1
  29. package/dist/assets/web/dist/assets/{PendingTurnRow-ChCQOLgZ.js → PendingTurnRow-C-pUgyyX.js} +1 -1
  30. package/dist/assets/web/dist/assets/PlanePage-BNy4tSgJ.js +1 -0
  31. package/dist/assets/web/dist/assets/{ResidentDetailPage-C9y3nbo8.js → ResidentDetailPage-Dbtiq6_J.js} +1 -1
  32. package/dist/assets/web/dist/assets/{ResidentsIndexPage-i1RG9e7g.js → ResidentsIndexPage-CKtz9H1o.js} +1 -1
  33. package/dist/assets/web/dist/assets/RunFoldRow-sYFtuK2G.js +1 -0
  34. package/dist/assets/web/dist/assets/RunRoutePage-Clh68nw9.js +9 -0
  35. package/dist/assets/web/dist/assets/RunsIndexPage-DPDbcOA_.js +1 -0
  36. package/dist/assets/web/dist/assets/{RunsTabs-YSbUu5py.js → RunsTabs-DotpuCSP.js} +1 -1
  37. package/dist/assets/web/dist/assets/{ScheduledPage-DvYwM2TE.js → ScheduledPage-C8FYoZy5.js} +1 -1
  38. package/dist/assets/web/dist/assets/{SettingSelect-BIzAsLk1.js → SettingSelect-CSiBQCSX.js} +1 -1
  39. package/dist/assets/web/dist/assets/{SettingsPage-Bo6yCyXZ.js → SettingsPage-CkYIhLUl.js} +1 -1
  40. package/dist/assets/web/dist/assets/{StatusDot-CAfS1AUi.js → StatusDot-C0TUgnC1.js} +1 -1
  41. package/dist/assets/web/dist/assets/{Tooltip-tZoum_T-.js → Tooltip-CN5cZdl3.js} +1 -1
  42. package/dist/assets/web/dist/assets/{UnitRoutePage-BmdOHwNn.js → UnitRoutePage-D_EtAl8r.js} +1 -1
  43. package/dist/assets/web/dist/assets/{angular-html-BjeQZdCq.js → angular-html-qW40ZL9x.js} +1 -1
  44. package/dist/assets/web/dist/assets/{angular-ts-ZGdORrL2.js → angular-ts-PUtZU8Cq.js} +1 -1
  45. package/dist/assets/web/dist/assets/{apl-DhvV_X93.js → apl-D3fC7Qjd.js} +1 -1
  46. package/dist/assets/web/dist/assets/{astro-DiRSE4Ug.js → astro-Bi3JFHhh.js} +1 -1
  47. package/dist/assets/web/dist/assets/{blade-D8Fr5Wvq.js → blade-CSpsf9Z9.js} +1 -1
  48. package/dist/assets/web/dist/assets/{c-BwbC64D4.js → c-DE_b1oWP.js} +1 -1
  49. package/dist/assets/web/dist/assets/{chapel-DgmqRK97.js → chapel-CWXd9giL.js} +1 -1
  50. package/dist/assets/web/dist/assets/{cobol-DJod2RNK.js → cobol-CnMOmWNF.js} +1 -1
  51. package/dist/assets/web/dist/assets/{coffee-D1Y2CgBY.js → coffee-B9WvURu8.js} +1 -1
  52. package/dist/assets/web/dist/assets/{cpp-CsO97YOM.js → cpp-BZAJXlWI.js} +1 -1
  53. package/dist/assets/web/dist/assets/{crystal-CJ7m2tOc.js → crystal-Bm48wAlb.js} +1 -1
  54. package/dist/assets/web/dist/assets/{css-B2M-NKoj.js → css-WtMeC3ab.js} +1 -1
  55. package/dist/assets/web/dist/assets/{dist-DfbEpHXR.js → dist-B1Mf2JbM.js} +2 -2
  56. package/dist/assets/web/dist/assets/{edge-CFVW-m9B.js → edge-BmwNa6Ss.js} +1 -1
  57. package/dist/assets/web/dist/assets/{elixir-B2mtRgCr.js → elixir-BotfP-Mn.js} +1 -1
  58. package/dist/assets/web/dist/assets/{elm-2teVAwCi.js → elm-DAFc6laN.js} +1 -1
  59. package/dist/assets/web/dist/assets/{erb-LAbeNqen.js → erb-QkOON3bB.js} +1 -1
  60. package/dist/assets/web/dist/assets/{git-rebase-DnhzH_sp.js → git-rebase-BPs-dd73.js} +1 -1
  61. package/dist/assets/web/dist/assets/{glimmer-js-DPKjXf1-.js → glimmer-js-D8eooepk.js} +1 -1
  62. package/dist/assets/web/dist/assets/{glimmer-ts-CABXnt5z.js → glimmer-ts-B0bRiDVg.js} +1 -1
  63. package/dist/assets/web/dist/assets/{glsl-Dz4-3-gJ.js → glsl-Bv6bMe1n.js} +1 -1
  64. package/dist/assets/web/dist/assets/{graphql-ByWUD1DL.js → graphql-Bh9y-n9o.js} +1 -1
  65. package/dist/assets/web/dist/assets/{hack-CdmOK2K-.js → hack-DSEbn7Ir.js} +1 -1
  66. package/dist/assets/web/dist/assets/{haml-DzegMpd2.js → haml-0NW1-z1f.js} +1 -1
  67. package/dist/assets/web/dist/assets/{handlebars-B5NeiP0e.js → handlebars-BfgCHFbK.js} +1 -1
  68. package/dist/assets/web/dist/assets/{html-Zg_NHLv9.js → html-CQHc0gQ5.js} +1 -1
  69. package/dist/assets/web/dist/assets/{html-derivative-DZf9JNzh.js → html-derivative-B0Tm7wLP.js} +1 -1
  70. package/dist/assets/web/dist/assets/{http-CVFrQZeN.js → http-GAuU3aOF.js} +1 -1
  71. package/dist/assets/web/dist/assets/{hurl-L5brWAJe.js → hurl-CrDFbF5B.js} +1 -1
  72. package/dist/assets/web/dist/assets/indexRow-BxKswdlK.js +1 -0
  73. package/dist/assets/web/dist/assets/{java-hFphyZ4R.js → java-DYv5KhUe.js} +1 -1
  74. package/dist/assets/web/dist/assets/{javascript-CAJO1WKX.js → javascript-32SiLGg0.js} +1 -1
  75. package/dist/assets/web/dist/assets/{jinja-N4A4srXI.js → jinja--gJDUGYa.js} +1 -1
  76. package/dist/assets/web/dist/assets/{jison-D3BZa_iU.js → jison-BlKLGzZH.js} +1 -1
  77. package/dist/assets/web/dist/assets/{json-DxD1Qh8W.js → json-BICanD1N.js} +1 -1
  78. package/dist/assets/web/dist/assets/{jsx-CHhag69S.js → jsx-CgW3-uFT.js} +1 -1
  79. package/dist/assets/web/dist/assets/{julia-CrhkZ6Tt.js → julia-BErRpwwL.js} +1 -1
  80. package/dist/assets/web/dist/assets/{just-D0LFOuhq.js → just-CordHv0h.js} +1 -1
  81. package/dist/assets/web/dist/assets/{latex-BHAlwCXK.js → latex-D7LVPLbi.js} +1 -1
  82. package/dist/assets/web/dist/assets/{liquid-CkaLfCWh.js → liquid-3ehz2i4U.js} +1 -1
  83. package/dist/assets/web/dist/assets/{lua-B1h4aAzP.js → lua-C9ppSVrm.js} +1 -1
  84. package/dist/assets/web/dist/assets/main-Cv0q9y56.js +28 -0
  85. package/dist/assets/web/dist/assets/main-DKFCM-sy.css +1 -0
  86. package/dist/assets/web/dist/assets/{marko-D6CG4hvX.js → marko-xBZLUBEJ.js} +1 -1
  87. package/dist/assets/web/dist/assets/{mdc-CVQzimAU.js → mdc-BJxGoVgD.js} +1 -1
  88. package/dist/assets/web/dist/assets/{nginx-h-9Ir54h.js → nginx-B-sZmpsb.js} +1 -1
  89. package/dist/assets/web/dist/assets/{nim-DHVI30sp.js → nim--s9wRABK.js} +1 -1
  90. package/dist/assets/web/dist/assets/{org-AasAXfMF.js → org-DoUvssoI.js} +1 -1
  91. package/dist/assets/web/dist/assets/{perl-BVi5aTEy.js → perl-B-vGHKOy.js} +1 -1
  92. package/dist/assets/web/dist/assets/{php-qOkCeUKr.js → php-CerWx_Xb.js} +1 -1
  93. package/dist/assets/web/dist/assets/{pug-8TnUGvEX.js → pug-CzKrBMb1.js} +1 -1
  94. package/dist/assets/web/dist/assets/{qml-CpJbhoxn.js → qml-Bs8L8I6k.js} +1 -1
  95. package/dist/assets/web/dist/assets/{r-JkUNcAJW.js → r-w6uG6mob.js} +1 -1
  96. package/dist/assets/web/dist/assets/{razor-CtU4ws5a.js → razor-dz2oj2bd.js} +1 -1
  97. package/dist/assets/web/dist/assets/{regexp-J2zA6ceN.js → regexp-CfQdprnY.js} +1 -1
  98. package/dist/assets/web/dist/assets/{rst-CMCvf-yZ.js → rst-DAOaX2tb.js} +1 -1
  99. package/dist/assets/web/dist/assets/{ruby-ChLBY_iR.js → ruby-QcCe4udB.js} +1 -1
  100. package/dist/assets/web/dist/assets/{sas-cwn4x2VY.js → sas-BrM4lAAc.js} +1 -1
  101. package/dist/assets/web/dist/assets/{scss-jSrJlzLI.js → scss-DVbJXoUb.js} +1 -1
  102. package/dist/assets/web/dist/assets/{shellscript-D5nAd0gs.js → shellscript-09fP2hZf.js} +1 -1
  103. package/dist/assets/web/dist/assets/{shellsession-BZY4-Tdh.js → shellsession-Qgr8xjZF.js} +1 -1
  104. package/dist/assets/web/dist/assets/{soy-COppPOKr.js → soy-DIYeNTuz.js} +1 -1
  105. package/dist/assets/web/dist/assets/{sql-Blc_6dOA.js → sql-CzLR9msV.js} +1 -1
  106. package/dist/assets/web/dist/assets/{sseReplay-DmyMXfRC.js → sseReplay-BYo2EZKy.js} +4 -4
  107. package/dist/assets/web/dist/assets/{stata-DRv3OY-h.js → stata-zCY1ZzH0.js} +1 -1
  108. package/dist/assets/web/dist/assets/{surrealql-qMv_eeJ6.js → surrealql-CJIjTRV4.js} +1 -1
  109. package/dist/assets/web/dist/assets/{svelte-d1Edb6Rz.js → svelte-C5OTPFZg.js} +1 -1
  110. package/dist/assets/web/dist/assets/{templ-CUMSIOXG.js → templ-DYj-UICF.js} +1 -1
  111. package/dist/assets/web/dist/assets/{tex-BGZQA-Ei.js → tex-xcie-3Gd.js} +1 -1
  112. package/dist/assets/web/dist/assets/{ts-tags-Z9ulxVsq.js → ts-tags-CoN86VTt.js} +1 -1
  113. package/dist/assets/web/dist/assets/{tsx-oI4bI3Vf.js → tsx-n3-No_Br.js} +1 -1
  114. package/dist/assets/web/dist/assets/{twig-D_RXwBIX.js → twig-DPiJ7Vp1.js} +1 -1
  115. package/dist/assets/web/dist/assets/{typescript-BgqQDYoN.js → typescript-BP6HBaOp.js} +1 -1
  116. package/dist/assets/web/dist/assets/{typst-5MW-KRDd.js → typst-Do9ZjsVP.js} +1 -1
  117. package/dist/assets/web/dist/assets/{vue-CZhHHsPm.js → vue-CzAeXNRY.js} +1 -1
  118. package/dist/assets/web/dist/assets/{vue-html-DlYy2d8v.js → vue-html-D7iYmYjd.js} +1 -1
  119. package/dist/assets/web/dist/assets/{vue-vine-CJDnx-qn.js → vue-vine-DtrMRsJx.js} +1 -1
  120. package/dist/assets/web/dist/assets/{xml-BRBDk65Q.js → xml-DGJHo1c0.js} +1 -1
  121. package/dist/assets/web/dist/assets/{xsl-BlCykPxo.js → xsl-Q00kvNgW.js} +1 -1
  122. package/dist/assets/web/dist/assets/{yaml-BqW48szf.js → yaml-F-UGKaiO.js} +1 -1
  123. package/dist/cli.js +19192 -17592
  124. package/package.json +1 -1
  125. package/dist/assets/web/dist/assets/HomePage-BG_ok-K2.js +0 -2
  126. package/dist/assets/web/dist/assets/RunFoldRow-D3wVpzBa.js +0 -1
  127. package/dist/assets/web/dist/assets/RunRoutePage-B3IirUVi.js +0 -9
  128. package/dist/assets/web/dist/assets/RunsIndexPage-DiFmtGaJ.js +0 -1
  129. package/dist/assets/web/dist/assets/indexRow-BT0cPVRw.js +0 -1
  130. package/dist/assets/web/dist/assets/main-5Gm_1Gv8.js +0 -28
  131. package/dist/assets/web/dist/assets/main-zbP_dTjR.css +0 -1
@@ -167,6 +167,12 @@ defaults:
167
167
  # verbosity: verbose # hear every acknowledgement of what the bot does for you
168
168
  # harness:
169
169
  # coding: opencode
170
+ # # The person's GitHub author binding (record 0062; authorization.md item 18):
171
+ # # the login their commits are authored as. A bare login here is resolved to
172
+ # # { login, id } once at first use; `config set user --user <id> --github
173
+ # # <login>` (identity:write holders) stores the pair. Never set by the person
174
+ # # themselves; `config clear me` keeps it. One login/id binds one person.
175
+ # github: octocat
170
176
 
171
177
  # Authorization (docs/reference/specs/authorization.md item 9, docs/reference/authorization.md):
172
178
  # two blocks. `restrict` names what is CLOSED unless granted — an agent listed
@@ -388,6 +394,8 @@ workspaceDir: ./workspaces
388
394
  # ship:
389
395
  # maxRounds: 3 # review rounds per unit
390
396
  # maxMinutes: 240 # the ship preset's wall-clock budget in minutes (the registry's default)
397
+ # idleDays: 0 # days a ship unit idles instead of ending (record 0051): 0–365, 0 = today's
398
+ # # endings; also per channel/user (`channels.<id>.ship.idleDays`), user > channel > org
391
399
 
392
400
  # Which harness each preset's runs are driven by, deployment-wide
393
401
  # (docs/reference/specs/harness.md item 8): a mapping of preset to a harness's
@@ -454,10 +462,16 @@ workspaceDir: ./workspaces
454
462
  # `tool` (default) forces a call to the router's `route` tool, whose schema is
455
463
  # the answer, so prose cannot occur; `text` is the one-JSON-object text contract
456
464
  # alone — set it for a provider or model that cannot take a forced tool call.
465
+ # `operator` is the one door's flag (routing-and-config item 29): `off`
466
+ # (default) — the operator never runs; `shadow` — one operator turn per
467
+ # admitted chat event, ahead of stage A, its decision written beside the
468
+ # routed request in the run store and nothing run from it; `on` — the
469
+ # operator's decision is what runs. `shadow` runs even under `auto: false`.
457
470
  # routing:
458
471
  # auto: false
459
472
  # model: anthropic/claude-haiku-4-5
460
473
  # answer: text
474
+ # operator: shadow
461
475
 
462
476
  # The thread-reply intake gate (docs/decisions/0058-a-thread-reply-is-read-before-it-is-answered-intake-decides-whether-the-bot-was-addressed.md;
463
477
  # docs/reference/specs/routing-and-config.md item 27). How an unmentioned reply
@@ -519,6 +533,16 @@ workspaceDir: ./workspaces
519
533
  # cacheRead: 0.125 # never $0, on the costs page and on a run's own page
520
534
  # cacheWrite: 0
521
535
 
536
+ # Run metrics (docs/reference/specs/run-metrics.md). Optional. The Analytics Engine
537
+ # dataset every finished run's point lands in — the SAME name the deployment
538
+ # profile's `metrics.dataset` binds on the state Worker as RUN_METRICS (the bot
539
+ # warns at boot when the two differ; neither side refuses to start). The reader
540
+ # over the dataset — `metrics trend` and the /metrics page, behind the `metrics`
541
+ # capability — is later work of the same decision; until it lands, only `dataset`
542
+ # is read, for that boot comparison.
543
+ # metrics:
544
+ # dataset: switchboard_runs
545
+
522
546
  # Delivery indicators: GET /delivery and `delivery report` (docs/reference/specs/delivery.md).
523
547
  # Optional. Needs a GitHub credential (the App triple or GH_TOKEN); read-only on GitHub.
524
548
  # delivery:
@@ -0,0 +1,31 @@
1
+ import type { RunMetricsPoint } from "../../src/core/runMetrics.ts";
2
+
3
+ // The run-metrics sink (docs/reference/specs/run-metrics.md): where the
4
+ // RunHistoryDO writes the one point of a run whose row turned final, after the
5
+ // record's commit. Two implementations behind one seam (AGENTS.md invariant 2):
6
+ // the Workers Analytics Engine dataset when the Worker is deployed with the
7
+ // optional `RUN_METRICS` binding, the null sink otherwise — so a Worker without
8
+ // the binding behaves byte-identically, the same shape as `SHIP_COORDINATOR?`.
9
+ // The write is advisory on the caller's side too: one try, one console.warn,
10
+ // never a failed put or finish.
11
+
12
+ export interface RunMetricsSink {
13
+ write(point: RunMetricsPoint): void;
14
+ }
15
+
16
+ /** The dataset the deploy bound: `writeDataPoint` is synchronous and does not
17
+ * throw on a platform-side drop; the caller's try covers a missing or
18
+ * misconfigured binding. */
19
+ export class AnalyticsEngineSink implements RunMetricsSink {
20
+ constructor(private readonly dataset: AnalyticsEngineDataset) {}
21
+ write(point: RunMetricsPoint): void {
22
+ this.dataset.writeDataPoint({ indexes: point.indexes, blobs: point.blobs, doubles: point.doubles });
23
+ }
24
+ }
25
+
26
+ /** No binding: points are dropped — the off state. */
27
+ export class NullSink implements RunMetricsSink {
28
+ write(): void {
29
+ // nothing is written without the binding
30
+ }
31
+ }
@@ -119,6 +119,8 @@ import {
119
119
  type McpTicketState,
120
120
  type SealedCredential,
121
121
  } from "../../src/mcp/registry.ts";
122
+ import { isRunMetricsPoint, pointTurnsFinal, type RunMetricsPoint } from "../../src/core/runMetrics.ts";
123
+ import { AnalyticsEngineSink, NullSink, type RunMetricsSink } from "./runMetricsSink.ts";
122
124
  import { injectedBuildStamp } from "../../src/deploy/buildStamp.ts";
123
125
  import { systemClock } from "../../src/core/trace/clock.ts";
124
126
  import { createTracer } from "../../src/core/trace/tracer.ts";
@@ -148,7 +150,7 @@ const traceSinks = [workerLogSink((line) => console.log(line))];
148
150
  //
149
151
  // Route surface (JSON in/out; bearer MEMORY_TOKEN on everything but /healthz):
150
152
  // POST /retrieve {scopeKey, query, limit} → {records: MemoryRecord[]}
151
- // POST /write {scopeKey, records: MemoryCandidate[]} → {ok, inserted, deduped, superseded}
153
+ // POST /write {scopeKey, records: MemoryCandidate[]} → {ok, inserted, deduped, restated, superseded, evicted}
152
154
  // POST /sweep {scopeKey, dryRun?} → {ok, swept} (+ ids under dryRun — the marked rows, flipped to `swept`)
153
155
  // GET /healthz → {ok:true} (deploy wake ping; touches no DO)
154
156
  // Scheduled-firing routes (the record behind the /runs Scheduled panel;
@@ -161,7 +163,7 @@ const traceSinks = [workerLogSink((line) => console.log(line))];
161
163
  // store key, owning the retention policy. Same bearer; /runs/put has its own 2 MiB
162
164
  // body fence (a record is budgeted to 1.5 MiB upstream), every other route
163
165
  // keeps the 512 KB one.
164
- // POST /runs/put {storeKey, record, policy?, policyUpdatedAt?} → {ok, retained, stored, rewritten}
166
+ // POST /runs/put {storeKey, record, policy?, policyUpdatedAt?, point?} → {ok, retained, stored, rewritten}
165
167
  // POST /runs/get {storeKey, id} → {record: RunRecord | null} (unknown/expired: null, 200)
166
168
  // POST /runs/list {storeKey, limit?, before?, beforeId?, sinceMs?, agent?, channel?, threadKey?, parentRunId?}
167
169
  // → {items: RunListItem[], nextBefore?: {finishedAt, id}} (cursor = the last row's list key)
@@ -198,6 +200,14 @@ export interface Env {
198
200
  * binding is a cross-script one, and the class must exist on the bot before
199
201
  * the state Worker may name it), and a finish then commits with no event. */
200
202
  SHIP_COORDINATOR?: Workflow;
203
+ /** The run-metrics dataset (docs/reference/specs/run-metrics.md): where the
204
+ * RunHistoryDO writes one point per run whose row turned final. Optional
205
+ * like SHIP_COORDINATOR: this Worker deploys without it and every answer is
206
+ * byte-identical — the NullSink swallows the points. */
207
+ RUN_METRICS?: AnalyticsEngineDataset;
208
+ /** The dataset's name, rendered beside the binding, so `/healthz` can answer
209
+ * `runMetrics:<dataset>` and the bot's boot probe can compare names. */
210
+ RUN_METRICS_DATASET?: string;
201
211
  MEMORY_TOKEN?: string;
202
212
  }
203
213
 
@@ -381,8 +391,8 @@ export class MemoryDO extends DurableObject<Env> {
381
391
  scopeKey: string,
382
392
  candidates: MemoryCandidate[],
383
393
  cap: number = DEFAULT_SCOPE_CAP,
384
- ): Promise<{ inserted: number; deduped: number; superseded: number; evicted: number }> {
385
- const counts = { inserted: 0, deduped: 0, superseded: 0, evicted: 0 };
394
+ ): Promise<{ inserted: number; deduped: number; restated: number; superseded: number; evicted: number }> {
395
+ const counts = { inserted: 0, deduped: 0, restated: 0, superseded: 0, evicted: 0 };
386
396
  if (candidates.length === 0) return counts;
387
397
  this.ctx.storage.transactionSync(() => {
388
398
  let seq = this.sql.exec<{ next: number }>(`SELECT COALESCE(MAX(seq), -1) + 1 AS next FROM records`).one().next;
@@ -400,18 +410,41 @@ export class MemoryDO extends DurableObject<Env> {
400
410
  // own TRUTHINESS test: `supersedes: ""` passes validation but means NO
401
411
  // supersede to the engine, so it must dedup against the norm pool —
402
412
  // an `!== undefined` branch here would hand it an empty pool and
403
- // insert a duplicate active row.
404
- const relevant = (
405
- cand.supersedes
406
- ? this.sql.exec<Row>(`SELECT * FROM records WHERE id = ? AND status = 'active'`, cand.supersedes)
407
- : this.sql.exec<Row>(
408
- `SELECT * FROM records WHERE status = 'active' AND norm = ? ORDER BY seq`,
409
- normalizeText(cand.text),
413
+ // insert a duplicate active row. A `restates` id is looked up first
414
+ // (the restate target); when it misses — not active, or another
415
+ // scope's id, which this DO simply doesn't hold — the candidate takes
416
+ // today's pool, so planWrite falls through to dedup-or-insert.
417
+ const restatePool = cand.restates
418
+ ? this.sql
419
+ .exec<Row>(`SELECT * FROM records WHERE id = ? AND status = 'active'`, cand.restates)
420
+ .toArray()
421
+ .map(toRecord)
422
+ : [];
423
+ const relevant =
424
+ restatePool.length > 0
425
+ ? restatePool
426
+ : (cand.supersedes
427
+ ? this.sql.exec<Row>(`SELECT * FROM records WHERE id = ? AND status = 'active'`, cand.supersedes)
428
+ : this.sql.exec<Row>(
429
+ `SELECT * FROM records WHERE status = 'active' AND norm = ? ORDER BY seq`,
430
+ normalizeText(cand.text),
431
+ )
410
432
  )
411
- )
412
- .toArray()
413
- .map(toRecord);
433
+ .toArray()
434
+ .map(toRecord);
414
435
  const plan = planWrite(relevant, cand, (c) => mintRecord(scopeKey, seq++, now, c));
436
+ if (plan.action === "restate") {
437
+ // The shown record is refreshed in place; COALESCE keeps the stored
438
+ // confidence when the plan carries none (neither side had a value).
439
+ this.sql.exec(
440
+ `UPDATE records SET use_count = use_count + 1, last_used_at = ?, confidence = COALESCE(?, confidence) WHERE id = ?`,
441
+ now,
442
+ plan.confidence ?? null,
443
+ plan.target.id,
444
+ );
445
+ counts.restated++;
446
+ continue;
447
+ }
415
448
  if (plan.action === "dedup") {
416
449
  this.sql.exec(`UPDATE records SET use_count = use_count + 1 WHERE id = ?`, plan.target.id);
417
450
  counts.deduped++;
@@ -1485,10 +1518,15 @@ type HeartbeatAnswer = FenceResult & { stop?: StopMode | null; phase?: LivePhase
1485
1518
 
1486
1519
  export class RunHistoryDO extends DurableObject<Env> {
1487
1520
  private readonly sql: SqlStorage;
1521
+ /** Where a turned-final run's point goes (run-metrics.md): the Analytics
1522
+ * Engine dataset when the deploy bound one, the NullSink otherwise —
1523
+ * selected once at construction, the `SHIP_COORDINATOR?` shape. */
1524
+ private readonly metrics: RunMetricsSink;
1488
1525
 
1489
1526
  constructor(ctx: DurableObjectState, env: Env) {
1490
1527
  super(ctx, env);
1491
1528
  this.sql = ctx.storage.sql;
1529
+ this.metrics = env.RUN_METRICS !== undefined ? new AnalyticsEngineSink(env.RUN_METRICS) : new NullSink();
1492
1530
  // Idempotent schema. `runs` carries the listing columns plus the record
1493
1531
  // minus its events as JSON (`summary_json`, what `list` returns); events
1494
1532
  // live one per row keyed (run_id, seq) so a 5000-event run is paged, never
@@ -2063,8 +2101,10 @@ export class RunHistoryDO extends DurableObject<Env> {
2063
2101
  gen: string,
2064
2102
  record: RunRecord,
2065
2103
  proposal?: RunPolicyProposal,
2104
+ point?: RunMetricsPoint,
2066
2105
  ): Promise<FenceResult & { stored?: boolean; event?: RunFinishedSend["kind"] }> {
2067
2106
  let out: FenceResult & { stored?: boolean } = { ok: true };
2107
+ let turnedFinal = false;
2068
2108
  this.ctx.storage.transactionSync(() => {
2069
2109
  const fence = checkFence(this.liveRow(runId), gen);
2070
2110
  if (!fence.ok) {
@@ -2073,9 +2113,14 @@ export class RunHistoryDO extends DurableObject<Env> {
2073
2113
  }
2074
2114
  const put = this.upsertInTransaction(record, proposal);
2075
2115
  this.deleteLiveRows([runId]);
2116
+ turnedFinal = put.turnedFinal;
2076
2117
  out = { ok: true, stored: put.stored };
2077
2118
  });
2078
2119
  if (!out.ok) return out;
2120
+ // The point after the commit, never inside it (`sendRunFinished`'s placement):
2121
+ // the finish usually replaces the start tombstone, so this is where most
2122
+ // runs are counted (run-metrics.md item 2).
2123
+ this.writeMetricsPoint(runId, point, turnedFinal && out.stored === true);
2079
2124
  if ((await this.ctx.storage.getAlarm()) === null)
2080
2125
  await this.ctx.storage.setAlarm(systemClock() + RUN_SWEEP_INTERVAL_MS);
2081
2126
  await this.refreshSessionBytes(record.session?.key);
@@ -2338,15 +2383,21 @@ export class RunHistoryDO extends DurableObject<Env> {
2338
2383
  * when the stored version changed (`event_count`, `finished_at`, `bytes`) —
2339
2384
  * an identical retry is a no-op on `run_events`. `stored: false` when the
2340
2385
  * record itself fell outside the (possibly just-updated) policy: it was
2341
- * written and deleted in the same transaction, so nothing of it remains. */
2386
+ * written and deleted in the same transaction, so nothing of it remains.
2387
+ * `point` is the record's metrics point, computed by the client
2388
+ * (run-metrics.md): written to the sink AFTER the commit, only when the row
2389
+ * turned final and the record was stored — `turnedFinal` stays internal (the
2390
+ * route strips it), so the wire answer is exactly the shape it always was. */
2342
2391
  async put(
2343
2392
  record: RunRecord,
2344
2393
  proposal?: RunPolicyProposal,
2345
- ): Promise<{ ok: true; retained: number; stored: boolean; rewritten: boolean }> {
2346
- let result = { ok: true as const, retained: 0, stored: false, rewritten: false };
2394
+ point?: RunMetricsPoint,
2395
+ ): Promise<{ ok: true; retained: number; stored: boolean; rewritten: boolean; turnedFinal: boolean }> {
2396
+ let result = { ok: true as const, retained: 0, stored: false, rewritten: false, turnedFinal: false };
2347
2397
  this.ctx.storage.transactionSync(() => {
2348
2398
  result = this.upsertInTransaction(record, proposal);
2349
2399
  });
2400
+ this.writeMetricsPoint(record.id, point, result.turnedFinal && result.stored);
2350
2401
  // A coordinator child closed OUTSIDE the ledger's finish — the run loop or
2351
2402
  // a reclaim writing an `interrupted` record, the pi harness's typed restart,
2352
2403
  // a resume abandoning a lost workspace — still wakes its parent's wait at
@@ -2370,7 +2421,7 @@ export class RunHistoryDO extends DurableObject<Env> {
2370
2421
  private upsertInTransaction(
2371
2422
  record: RunRecord,
2372
2423
  proposal?: RunPolicyProposal,
2373
- ): { ok: true; retained: number; stored: boolean; rewritten: boolean } {
2424
+ ): { ok: true; retained: number; stored: boolean; rewritten: boolean; turnedFinal: boolean } {
2374
2425
  {
2375
2426
  const now = systemClock();
2376
2427
  const policy = proposal ? this.applyProposal(proposal, now).policy : this.policyState().policy;
@@ -2387,11 +2438,29 @@ export class RunHistoryDO extends DurableObject<Env> {
2387
2438
  const { events, ...summary } = stored;
2388
2439
  const bytes = utf8ByteLength(JSON.stringify(stored));
2389
2440
  const existing = this.sql
2390
- .exec<{ event_count: number; finished_at: number; bytes: number }>(
2391
- `SELECT event_count, finished_at, bytes FROM runs WHERE run_id = ?`,
2441
+ .exec<{ event_count: number; finished_at: number; bytes: number; summary_json: string }>(
2442
+ `SELECT event_count, finished_at, bytes, summary_json FROM runs WHERE run_id = ?`,
2392
2443
  record.id,
2393
2444
  )
2394
2445
  .toArray()[0];
2446
+ // The one field of the stored summary the emission rule reads (run-metrics.md
2447
+ // item 2): the row's `provisional`, parsed alone — never deserialized whole.
2448
+ const existingProvisional = existing !== undefined && summaryIsProvisional(existing.summary_json);
2449
+ const turnedFinal = pointTurnsFinal(
2450
+ existing !== undefined ? { provisional: existingProvisional } : undefined,
2451
+ stored,
2452
+ );
2453
+ // A provisional record never overwrites a final row (run-history.md item 27;
2454
+ // run-metrics.md item 3): a start tombstone sitting in retry backoff or a
2455
+ // drain upgrade racing a fast finish would otherwise land after the finish
2456
+ // record, overwrite it with `interrupted` — and let the run's point be
2457
+ // written twice when a later final write turned the row "final" again.
2458
+ // Answered as stored, with nothing written: the row, its events and the
2459
+ // sessions table stay exactly as the final write left them.
2460
+ if (stored.provisional === true && existing !== undefined && !existingProvisional) {
2461
+ const retained = this.sql.exec<{ n: number }>(`SELECT COUNT(*) AS n FROM runs`).one().n;
2462
+ return { ok: true as const, retained, stored: true, rewritten: false, turnedFinal: false };
2463
+ }
2395
2464
  const unchanged =
2396
2465
  existing !== undefined &&
2397
2466
  sameStoredVersion(
@@ -2471,10 +2540,25 @@ export class RunHistoryDO extends DurableObject<Env> {
2471
2540
  retained: kept.size,
2472
2541
  stored: kept.has(record.id),
2473
2542
  rewritten: existing !== undefined && !unchanged,
2543
+ turnedFinal,
2474
2544
  };
2475
2545
  }
2476
2546
  }
2477
2547
 
2548
+ /** One point per run whose row turned final, AFTER the commit (run-metrics.md
2549
+ * item 2) — advisory: a throwing sink leaves the answer exactly as a
2550
+ * recording one would, and says so in one warn line with the run id and the
2551
+ * error's constructor name, never the point's contents. */
2552
+ private writeMetricsPoint(runId: string, point: RunMetricsPoint | undefined, turnedFinal: boolean): void {
2553
+ if (point === undefined || !turnedFinal) return;
2554
+ try {
2555
+ this.metrics.write(point);
2556
+ } catch (err) {
2557
+ const kind = err instanceof Error ? err.constructor.name : "Error";
2558
+ console.warn(`[runs/metrics] ${runId} point not written: ${kind}`);
2559
+ }
2560
+ }
2561
+
2478
2562
  /** Remove a run and its events. Returns whether a run row existed. */
2479
2563
  async delete(id: string): Promise<boolean> {
2480
2564
  let deleted = false;
@@ -2924,6 +3008,17 @@ function parseSummary(row: RunRow): Omit<RunRecord, "events"> | null {
2924
3008
  }
2925
3009
  }
2926
3010
 
3011
+ /** The one field of a stored row's summary the emission rule reads: whether the
3012
+ * row is a provisional tombstone. Deliberately not a full `RunRecord` parse
3013
+ * (run-metrics.md item 2) — this runs inside every upsert's transaction. */
3014
+ function summaryIsProvisional(summaryJson: string): boolean {
3015
+ try {
3016
+ return (JSON.parse(summaryJson) as { provisional?: unknown }).provisional === true;
3017
+ } catch {
3018
+ return false;
3019
+ }
3020
+ }
3021
+
2927
3022
  function parseRunId(v: unknown): Validated<string> {
2928
3023
  if (typeof v !== "string" || !RUN_ID_PATTERN.test(v)) return invalid("id must match ^[A-Za-z0-9_-]{1,64}$");
2929
3024
  return { ok: true, value: v };
@@ -2939,16 +3034,24 @@ function parsePositiveInt(v: unknown, name: string, max: number): Validated<numb
2939
3034
  return { ok: true, value: v };
2940
3035
  }
2941
3036
 
2942
- function parseRunPut(body: unknown): Validated<{ storeKey: string; record: RunRecord; proposal?: RunPolicyProposal }> {
3037
+ function parseRunPut(
3038
+ body: unknown,
3039
+ ): Validated<{ storeKey: string; record: RunRecord; proposal?: RunPolicyProposal; point?: RunMetricsPoint }> {
2943
3040
  if (typeof body !== "object" || body === null) return invalid("body must be a JSON object");
2944
3041
  const b = body as Record<string, unknown>;
2945
3042
  const key = parseStoreKey(b);
2946
3043
  if (!key.ok) return key;
2947
3044
  if (!isRunRecord(b.record)) return invalid("record must be a RunRecord");
2948
- const out: { storeKey: string; record: RunRecord; proposal?: RunPolicyProposal } = {
3045
+ const out: { storeKey: string; record: RunRecord; proposal?: RunPolicyProposal; point?: RunMetricsPoint } = {
2949
3046
  storeKey: key.value,
2950
3047
  record: b.record,
2951
3048
  };
3049
+ // The record's metrics point (run-metrics.md item 1), validated at the door
3050
+ // like everything else that reaches storage.
3051
+ if (b.point !== undefined) {
3052
+ if (!isRunMetricsPoint(b.point)) return invalid("point must be a RunMetricsPoint");
3053
+ out.point = b.point;
3054
+ }
2952
3055
  if (b.policy !== undefined) {
2953
3056
  if (typeof b.policy !== "object" || b.policy === null) return invalid("policy must be an object");
2954
3057
  const p = b.policy as Record<string, unknown>;
@@ -3259,6 +3362,11 @@ function parseCandidate(v: unknown, i: number): Validated<MemoryCandidate> {
3259
3362
  return invalid(`${at}.supersedes must be a string`);
3260
3363
  out.supersedes = c.supersedes;
3261
3364
  }
3365
+ if (c.restates !== undefined) {
3366
+ if (typeof c.restates !== "string" || c.restates.length > MAX_KEY_CHARS)
3367
+ return invalid(`${at}.restates must be a string`);
3368
+ out.restates = c.restates;
3369
+ }
3262
3370
  return { ok: true, value: out };
3263
3371
  }
3264
3372
 
@@ -4302,7 +4410,7 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
4302
4410
  const parsed = parseRunPut({ ...b, storeKey: key.value });
4303
4411
  if (!parsed.ok) return json({ error: parsed.error }, 400);
4304
4412
  if (parsed.value.record.id !== runId.value) return json({ error: "record.id must equal runId" }, 400);
4305
- const r = await stub.finish(runId.value, g.value, parsed.value.record, parsed.value.proposal);
4413
+ const r = await stub.finish(runId.value, g.value, parsed.value.record, parsed.value.proposal, parsed.value.point);
4306
4414
  console.log(
4307
4415
  `[runs/finish] ${key.value} ${runId.value} ok=${r.ok}${r.ok ? ` stored=${r.stored} event=${r.event}` : ` ${r.reason}`}`,
4308
4416
  );
@@ -4319,8 +4427,10 @@ async function handleRuns(pathname: string, body: unknown, env: Env): Promise<Re
4319
4427
  if (pathname === "/runs/put") {
4320
4428
  const parsed = parseRunPut(body);
4321
4429
  if (!parsed.ok) return json({ error: parsed.error }, 400);
4322
- const { storeKey, record, proposal } = parsed.value;
4323
- const result = await stub(storeKey).put(record, proposal);
4430
+ const { storeKey, record, proposal, point } = parsed.value;
4431
+ // `turnedFinal` stays internal: the wire answer is exactly the shape it
4432
+ // always was, binding or no binding (run-metrics.md item 4).
4433
+ const { turnedFinal: _turnedFinal, ...result } = await stub(storeKey).put(record, proposal, point);
4324
4434
  console.log(
4325
4435
  `[runs/put] ${storeKey} <- ${record.id} (${record.storedEventCount} events, stored=${result.stored}, ${result.retained} retained)`,
4326
4436
  );
@@ -4404,11 +4514,21 @@ interface Admission {
4404
4514
  authorized: boolean;
4405
4515
  }
4406
4516
 
4517
+ /** What this deploy carries, for the bot's boot probe: the fixed route set,
4518
+ * plus `runMetrics:<dataset>` when the deploy bound the Analytics Engine
4519
+ * dataset (run-metrics.md item 5) — the name from the `RUN_METRICS_DATASET`
4520
+ * var rendered beside the binding, so the probe can compare it to the bot's. */
4521
+ export function featuresOf(env: Pick<Env, "RUN_METRICS" | "RUN_METRICS_DATASET">): string[] {
4522
+ const features = ["memory", "schedules", "runs", "config", "delivery", "costs"];
4523
+ if (env.RUN_METRICS !== undefined) features.push(`runMetrics:${env.RUN_METRICS_DATASET ?? "unknown"}`);
4524
+ return features;
4525
+ }
4526
+
4407
4527
  /** Every request, once `fetch` has decided whether it gets a root. */
4408
4528
  async function handleRequest(request: Request, env: Env, admission: Admission): Promise<Response> {
4409
4529
  const url = new URL(request.url);
4410
4530
  if (url.pathname === "/healthz" && request.method === "GET")
4411
- return json({ ok: true, build: BUILD, features: ["memory", "schedules", "runs", "config", "delivery", "costs"] });
4531
+ return json({ ok: true, build: BUILD, features: featuresOf(env) });
4412
4532
  if (!admission.known) return json({ error: "not found" }, 404);
4413
4533
  if (request.method !== "POST") return json({ error: "method not allowed" }, 405);
4414
4534
  if (!admission.authorized) return json({ error: "unauthorized" }, 401);
@@ -66,6 +66,18 @@
66
66
  "script_name": "{{bot.script}}"
67
67
  }
68
68
  ],
69
+ // {{#if metrics}}
70
+ // The run-metrics dataset (docs/reference/specs/run-metrics.md): where RunHistoryDO
71
+ // writes a turned-final run's point, bound only when the deployment profile names a
72
+ // dataset (`metrics.dataset`) — nothing is created ahead of the deploy; the platform
73
+ // creates the dataset on first write. Without it the Worker deploys without the
74
+ // binding and writes no points.
75
+ "analytics_engine_datasets": [{ "binding": "RUN_METRICS", "dataset": "{{metrics.dataset}}" }],
76
+ // The dataset's name beside its binding (rendered together): /healthz `features`
77
+ // says `runMetrics:<dataset>` from it, so the bot's boot probe can compare the
78
+ // name its own config carries with the one this deploy bound.
79
+ "vars": { "RUN_METRICS_DATASET": "{{metrics.dataset}}" },
80
+ // {{/if}}
69
81
  // SQLite-backed DOs (durable across restarts — AGENTS.md invariant 6).
70
82
  "migrations": [
71
83
  { "tag": "v1", "new_sqlite_classes": ["MemoryDO"] },
@@ -1,5 +1,5 @@
1
1
  {
2
- "$comment": "The deployment profile: where THIS installation runs. Copy to deploy/profile.json and fill it in (or run `switchboard deploy init`). The account is your Cloudflare account id; every hostname must be under `zone`, a zone in that account, unless the Worker names its own `zone` (also in the account); `workers.bot` is the one required Worker — leave `memory`, `resident` or `sandbox` out and `deploy plan` has no step for them (a bot-only profile is a one-step plan; without `memory` the config is not pushed anywhere). The project's docs site is not a Worker of an installation: it is the project's website, deployed by the project's own CI from project.json, so there is no `docs` entry. `configSource` is where `deploy all` reads the bot's runtime config from before building the image — a path, `github://owner/repo/path@ref` (needs CONFIG_REPO_TOKEN), or `op://Vault/Item/field` (needs OP_SERVICE_ACCOUNT_TOKEN); `secretsSource` is where `secrets put` reads values from — a directory of <NAME> files (the default when absent) or `op://Vault/Item`. `images` is where the bot, resident and sandbox container images come from: `registry` — the release's published images, copied once per version into your account registry by `deploy all` itself (or `deploy images` ahead of it) over HTTPS and referenced from there — no Docker anywhere, a Cloudflare API token with Containers Edit in CLOUDFLARE_API_TOKEN for the copy (an installation deploying published images; what `init` writes from the published package); or `build` (the default when absent) — each Worker's Dockerfile, built by wrangler where `deploy all` runs (a checkout; the project's own production). `artifacts` (optional) names the R2 bucket a run's files move through (docs/reference/specs/execution.md item 20): with it the bot Worker binds the bucket and `deploy` creates it before the upload (the credential then needs Workers R2 Storage: Edit); the bot's runtime config `artifacts.r2.bucket` must say the same name. `deploy plan` reads this example when no profile exists; `deploy all` refuses it.",
2
+ "$comment": "The deployment profile: where THIS installation runs. Copy to deploy/profile.json and fill it in (or run `switchboard deploy init`). The account is your Cloudflare account id; every hostname must be under `zone`, a zone in that account, unless the Worker names its own `zone` (also in the account); `workers.bot` is the one required Worker — leave `memory`, `resident` or `sandbox` out and `deploy plan` has no step for them (a bot-only profile is a one-step plan; without `memory` the config is not pushed anywhere). The project's docs site is not a Worker of an installation: it is the project's website, deployed by the project's own CI from project.json, so there is no `docs` entry. `configSource` is where `deploy all` reads the bot's runtime config from before building the image — a path, `github://owner/repo/path@ref` (needs CONFIG_REPO_TOKEN), or `op://Vault/Item/field` (needs OP_SERVICE_ACCOUNT_TOKEN); `secretsSource` is where `secrets put` reads values from — a directory of <NAME> files (the default when absent) or `op://Vault/Item`. `images` is where the bot, resident and sandbox container images come from: `registry` — the release's published images, copied once per version into your account registry by `deploy all` itself (or `deploy images` ahead of it) over HTTPS and referenced from there — no Docker anywhere, a Cloudflare API token with Containers Edit in CLOUDFLARE_API_TOKEN for the copy (an installation deploying published images; what `init` writes from the published package); or `build` (the default when absent) — each Worker's Dockerfile, built by wrangler where `deploy all` runs (a checkout; the project's own production). `artifacts` (optional) names the R2 bucket a run's files move through (docs/reference/specs/execution.md item 20): with it the bot Worker binds the bucket and `deploy` creates it before the upload (the credential then needs Workers R2 Storage: Edit); the bot's runtime config `artifacts.r2.bucket` must say the same name. `metrics` (optional) names the Analytics Engine dataset every finished run's point is written to (docs/reference/specs/run-metrics.md): the state Worker's template then binds it as `RUN_METRICS` with the name beside it in `RUN_METRICS_DATASET` — nothing is created ahead of the deploy, the platform creates the dataset on first write — and the bot's runtime config `metrics.dataset` must say the same name (the bot warns at boot when the two differ). `deploy plan` reads this example when no profile exists; `deploy all` refuses it.",
3
3
  "account": "00000000000000000000000000000000",
4
4
  "zone": "example.com",
5
5
  "workers": {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.252.0",
3
+ "version": "1.253.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "switchboard",
9
- "version": "1.252.0",
9
+ "version": "1.253.0",
10
10
  "license": "Apache-2.0",
11
11
  "workspaces": [
12
12
  "web",
@@ -20445,7 +20445,7 @@
20445
20445
  },
20446
20446
  "packages/switchboard": {
20447
20447
  "name": "@coreplane/switchboard",
20448
- "version": "1.252.0",
20448
+ "version": "1.253.0",
20449
20449
  "license": "Apache-2.0",
20450
20450
  "dependencies": {
20451
20451
  "@earendil-works/pi-ai": "0.85.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchboard",
3
- "version": "1.252.0",
3
+ "version": "1.253.0",
4
4
  "private": true,
5
5
  "description": "Mention it in Slack and an agent reviews the PR, ships the fix, or answers the question — on the model you choose, with its tools running where you decide.",
6
6
  "license": "Apache-2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.252.0",
3
- "commit": "0c2a0a9cdd4afd1a0def0d43ed7a14928c32101e",
4
- "builtAt": "2026-09-19T03:40:43.236Z"
2
+ "version": "1.253.0",
3
+ "commit": "c96eda5a0bced3e6e6347180846163cc19b28c49",
4
+ "builtAt": "2026-09-19T15:36:27.120Z"
5
5
  }
@@ -141,6 +141,14 @@ export const POLICY: readonly Rule[] = [
141
141
  { action: "config:write", resource: "config-scope", resourceKind: "thread", when: [grant("config:write")] },
142
142
  // A user edits only their own scope.
143
143
  { action: "config:write", resource: "config-scope", resourceKind: "user", when: [IS_SELF] },
144
+ // The author binding's one trusted write (record 0062, authorization.md item
145
+ // 18): `config set user --github` binds a person to a GitHub login, so it is
146
+ // its own action — `identity:write`, held by `all` and by a named grants
147
+ // entry, never a baseline — not the channel-config right: a `config:write`
148
+ // holder could otherwise author commits as any unbound person. The is-self
149
+ // row above does not carry it: a person never binds themself (`config set me
150
+ // --github` is refused at the registry door before this table is asked).
151
+ { action: "identity:write", resource: "config-scope", resourceKind: "user", when: [grant("identity:write")] },
144
152
 
145
153
  // ── agents ───────────────────────────────────────────────────────────────
146
154
  // `agent:run:*` covers every agent through wildcard coverage (grants.ts).
@@ -175,6 +175,9 @@ export const HOSTED_DEADLINE_MARGIN_MINUTES = 60;
175
175
  * and the pause before a busy spawn is asked again. */
176
176
  export const SHIP_WAIT = { marginMinutes: 5, chunkMinutes: 5, mergeChunkMinutes: 5, busyRetryMinutes: 2 } as const;
177
177
 
178
+ /** The plane's table (docs/decisions/0064): how long a finished run stays on it. */
179
+ export const PLANE = { recentMinutes: 60 } as const;
180
+
178
181
  /** The named amounts a lease holds back, in minutes. Each stands for a step
179
182
  * every run or round pays: `provision` is attach and restore before the
180
183
  * harness's clock starts; `writeUp` is the final answer after the loop ends;
@@ -461,3 +464,17 @@ export const DEFAULT_GRANT: Grant = { renewals: 0 };
461
464
  * hand-over is now `renewals: 5` — the longest problem a person hands over
462
465
  * in one message before a plan should carry it. */
463
466
  export const GRANT_RENEWALS_MAX = 12;
467
+
468
+ // ---- the idle unit (record 0051: a pipeline idles instead of ending) ----
469
+
470
+ /** `ship.idleDays`' default: zero — today's endings byte for byte. It moves to
471
+ * seven only in the unit that lands after the baseline and the billing answer
472
+ * are recorded (record 0051's plan), never before. */
473
+ export const IDLE_DAYS_DEFAULT = 0;
474
+ /** The most days one idle may wait: the platform's own `waitForEvent` ceiling
475
+ * (a year), which the wait must pass explicitly — its default is 24 hours. */
476
+ export const IDLE_DAYS_MAX = 365;
477
+ /** The most wakes one idle answers before it expires: a bound on how long a
478
+ * busy thread can keep an instance parked (read by the wait, this plan's
479
+ * fifth unit). */
480
+ export const IDLE_WAKES_MAX = 100;