@coreplane/switchboard 1.252.0 → 1.254.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 (143) hide show
  1. package/dist/assets/config/config.example.yaml +54 -1
  2. package/dist/assets/deploy/cloudflare-memory/runMetricsSink.ts +31 -0
  3. package/dist/assets/deploy/cloudflare-memory/worker.ts +455 -32
  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/grants.ts +6 -0
  10. package/dist/assets/src/core/authz/policy.ts +19 -0
  11. package/dist/assets/src/core/budgets.ts +25 -0
  12. package/dist/assets/src/core/coordinator/contract.ts +51 -1
  13. package/dist/assets/src/core/coordinator/driver.ts +111 -11
  14. package/dist/assets/src/core/costs.ts +221 -2
  15. package/dist/assets/src/core/costsSnapshotStore.ts +22 -1
  16. package/dist/assets/src/core/memory/engine.ts +25 -4
  17. package/dist/assets/src/core/memory/types.ts +6 -2
  18. package/dist/assets/src/core/pipelineStanding.ts +294 -0
  19. package/dist/assets/src/core/plane/decide.ts +203 -0
  20. package/dist/assets/src/core/provider.ts +11 -0
  21. package/dist/assets/src/core/runEvents.ts +57 -1
  22. package/dist/assets/src/core/runFriction.ts +1 -0
  23. package/dist/assets/src/core/runLedger/transcript.ts +23 -3
  24. package/dist/assets/src/core/runMetrics.ts +237 -0
  25. package/dist/assets/src/core/runRecord.ts +61 -0
  26. package/dist/assets/src/core/runUsage.ts +41 -9
  27. package/dist/assets/src/core/ship/contract.ts +8 -15
  28. package/dist/assets/src/core/ship/coordinator.ts +150 -11
  29. package/dist/assets/src/deploy/profile.ts +16 -0
  30. package/dist/assets/web/dist/.vite/manifest.json +461 -427
  31. package/dist/assets/web/dist/assets/{AppShell-DAtHiuI6.js → AppShell-CSXDCV4x.js} +1 -1
  32. package/dist/assets/web/dist/assets/CostChart-DsuPS2g7.js +2 -0
  33. package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +1 -0
  34. package/dist/assets/web/dist/assets/{DeliveryPage-3ELQWM0r.js → DeliveryPage-ngPsO2to.js} +1 -1
  35. package/dist/assets/web/dist/assets/HomePage-DvxTHzPx.js +3 -0
  36. package/dist/assets/web/dist/assets/{InputMenu-C6aPpe30.js → InputMenu-BdZXnibG.js} +1 -1
  37. package/dist/assets/web/dist/assets/MetricsPage-o5fSaCTe.js +1 -0
  38. package/dist/assets/web/dist/assets/{NotFoundPage-DtE-GgTk.js → NotFoundPage-DHuWyjlq.js} +1 -1
  39. package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +1 -0
  40. package/dist/assets/web/dist/assets/PlanePage-DpWfiX4C.js +1 -0
  41. package/dist/assets/web/dist/assets/{ResidentDetailPage-C9y3nbo8.js → ResidentDetailPage-DG86v39Y.js} +1 -1
  42. package/dist/assets/web/dist/assets/{ResidentsIndexPage-i1RG9e7g.js → ResidentsIndexPage-x6p689VH.js} +1 -1
  43. package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +1 -0
  44. package/dist/assets/web/dist/assets/RunRoutePage-ysJBY8xQ.js +9 -0
  45. package/dist/assets/web/dist/assets/RunsIndexPage-CuzFchcn.js +1 -0
  46. package/dist/assets/web/dist/assets/{RunsTabs-YSbUu5py.js → RunsTabs-kHbbxVHx.js} +1 -1
  47. package/dist/assets/web/dist/assets/{ScheduledPage-DvYwM2TE.js → ScheduledPage-BuLmfcbG.js} +1 -1
  48. package/dist/assets/web/dist/assets/{SettingSelect-BIzAsLk1.js → SettingSelect-BfID5nF4.js} +1 -1
  49. package/dist/assets/web/dist/assets/{SettingsPage-Bo6yCyXZ.js → SettingsPage-BujWkdU_.js} +1 -1
  50. package/dist/assets/web/dist/assets/{StatusDot-CAfS1AUi.js → StatusDot-C8Bc0pTX.js} +1 -1
  51. package/dist/assets/web/dist/assets/{Tooltip-tZoum_T-.js → Tooltip-BWwJx27K.js} +1 -1
  52. package/dist/assets/web/dist/assets/{UnitRoutePage-BmdOHwNn.js → UnitRoutePage-B9kjA1AT.js} +1 -1
  53. package/dist/assets/web/dist/assets/{angular-html-BjeQZdCq.js → angular-html-C8b5YTHf.js} +1 -1
  54. package/dist/assets/web/dist/assets/{angular-ts-ZGdORrL2.js → angular-ts-C3f4WYNe.js} +1 -1
  55. package/dist/assets/web/dist/assets/{apl-DhvV_X93.js → apl-DSHxUJc3.js} +1 -1
  56. package/dist/assets/web/dist/assets/{astro-DiRSE4Ug.js → astro-BSGCCBP6.js} +1 -1
  57. package/dist/assets/web/dist/assets/{blade-D8Fr5Wvq.js → blade-DFeYrMKk.js} +1 -1
  58. package/dist/assets/web/dist/assets/{c-BwbC64D4.js → c-L_QOnFVv.js} +1 -1
  59. package/dist/assets/web/dist/assets/{chapel-DgmqRK97.js → chapel-DLQWUwhu.js} +1 -1
  60. package/dist/assets/web/dist/assets/{cobol-DJod2RNK.js → cobol-1DlMmrEO.js} +1 -1
  61. package/dist/assets/web/dist/assets/{coffee-D1Y2CgBY.js → coffee-BTB6os6N.js} +1 -1
  62. package/dist/assets/web/dist/assets/{cpp-CsO97YOM.js → cpp-BvJI2z3u.js} +1 -1
  63. package/dist/assets/web/dist/assets/{crystal-CJ7m2tOc.js → crystal-B11wGW_h.js} +1 -1
  64. package/dist/assets/web/dist/assets/{css-B2M-NKoj.js → css-BiIWx5Pw.js} +1 -1
  65. package/dist/assets/web/dist/assets/{dist-DfbEpHXR.js → dist-CpnyQGOb.js} +2 -2
  66. package/dist/assets/web/dist/assets/{edge-CFVW-m9B.js → edge-nSglEXaD.js} +1 -1
  67. package/dist/assets/web/dist/assets/{elixir-B2mtRgCr.js → elixir-x7o8qMl8.js} +1 -1
  68. package/dist/assets/web/dist/assets/{elm-2teVAwCi.js → elm-DGrRyqTQ.js} +1 -1
  69. package/dist/assets/web/dist/assets/{erb-LAbeNqen.js → erb--_dh58Nw.js} +1 -1
  70. package/dist/assets/web/dist/assets/{git-rebase-DnhzH_sp.js → git-rebase-DbpUXwyg.js} +1 -1
  71. package/dist/assets/web/dist/assets/{glimmer-js-DPKjXf1-.js → glimmer-js-BG0CuZG5.js} +1 -1
  72. package/dist/assets/web/dist/assets/{glimmer-ts-CABXnt5z.js → glimmer-ts-CQIl5qyR.js} +1 -1
  73. package/dist/assets/web/dist/assets/{glsl-Dz4-3-gJ.js → glsl-9PJNQTcw.js} +1 -1
  74. package/dist/assets/web/dist/assets/{graphql-ByWUD1DL.js → graphql-CzVTzjEI.js} +1 -1
  75. package/dist/assets/web/dist/assets/{hack-CdmOK2K-.js → hack-BhCmAz3Q.js} +1 -1
  76. package/dist/assets/web/dist/assets/{haml-DzegMpd2.js → haml-BmI12COR.js} +1 -1
  77. package/dist/assets/web/dist/assets/{handlebars-B5NeiP0e.js → handlebars-Cib-ISTS.js} +1 -1
  78. package/dist/assets/web/dist/assets/{html-Zg_NHLv9.js → html-D3lDapvI.js} +1 -1
  79. package/dist/assets/web/dist/assets/{html-derivative-DZf9JNzh.js → html-derivative-DRPY8vOK.js} +1 -1
  80. package/dist/assets/web/dist/assets/{http-CVFrQZeN.js → http-B9VttK6m.js} +1 -1
  81. package/dist/assets/web/dist/assets/{hurl-L5brWAJe.js → hurl-C0saNJYj.js} +1 -1
  82. package/dist/assets/web/dist/assets/indexRow-DABQtONT.js +1 -0
  83. package/dist/assets/web/dist/assets/{java-hFphyZ4R.js → java-BCFbtYAj.js} +1 -1
  84. package/dist/assets/web/dist/assets/{javascript-CAJO1WKX.js → javascript-nwBjnrhh.js} +1 -1
  85. package/dist/assets/web/dist/assets/{jinja-N4A4srXI.js → jinja-CMYO9byN.js} +1 -1
  86. package/dist/assets/web/dist/assets/{jison-D3BZa_iU.js → jison-Cko8HnE_.js} +1 -1
  87. package/dist/assets/web/dist/assets/{json-DxD1Qh8W.js → json-Dzv__YsX.js} +1 -1
  88. package/dist/assets/web/dist/assets/{jsx-CHhag69S.js → jsx-CTFxagih.js} +1 -1
  89. package/dist/assets/web/dist/assets/{julia-CrhkZ6Tt.js → julia-_Xvten1-.js} +1 -1
  90. package/dist/assets/web/dist/assets/{just-D0LFOuhq.js → just-bLps4qOu.js} +1 -1
  91. package/dist/assets/web/dist/assets/{latex-BHAlwCXK.js → latex-D6UaYsUP.js} +1 -1
  92. package/dist/assets/web/dist/assets/{liquid-CkaLfCWh.js → liquid-CoDIeULa.js} +1 -1
  93. package/dist/assets/web/dist/assets/{lua-B1h4aAzP.js → lua-D1KFRnVf.js} +1 -1
  94. package/dist/assets/web/dist/assets/main-C4QfwybO.css +1 -0
  95. package/dist/assets/web/dist/assets/main-Comxmwi4.js +28 -0
  96. package/dist/assets/web/dist/assets/{marko-D6CG4hvX.js → marko-B5N5XcOw.js} +1 -1
  97. package/dist/assets/web/dist/assets/{mdc-CVQzimAU.js → mdc-uxzjQO3q.js} +1 -1
  98. package/dist/assets/web/dist/assets/{nginx-h-9Ir54h.js → nginx-BMJxs2TK.js} +1 -1
  99. package/dist/assets/web/dist/assets/{nim-DHVI30sp.js → nim-DqQ3-8p1.js} +1 -1
  100. package/dist/assets/web/dist/assets/{org-AasAXfMF.js → org-DsY6LK59.js} +1 -1
  101. package/dist/assets/web/dist/assets/{perl-BVi5aTEy.js → perl--pq9iJFp.js} +1 -1
  102. package/dist/assets/web/dist/assets/{php-qOkCeUKr.js → php-RHCMbsM2.js} +1 -1
  103. package/dist/assets/web/dist/assets/{pug-8TnUGvEX.js → pug-CFNq_FCp.js} +1 -1
  104. package/dist/assets/web/dist/assets/{qml-CpJbhoxn.js → qml-C9Acx9E8.js} +1 -1
  105. package/dist/assets/web/dist/assets/{r-JkUNcAJW.js → r-DxlU8FBd.js} +1 -1
  106. package/dist/assets/web/dist/assets/{razor-CtU4ws5a.js → razor-CLJ2kKih.js} +1 -1
  107. package/dist/assets/web/dist/assets/{regexp-J2zA6ceN.js → regexp-CzmdGoEp.js} +1 -1
  108. package/dist/assets/web/dist/assets/{rst-CMCvf-yZ.js → rst-D9yuxBET.js} +1 -1
  109. package/dist/assets/web/dist/assets/{ruby-ChLBY_iR.js → ruby-CIRcEvnD.js} +1 -1
  110. package/dist/assets/web/dist/assets/{sas-cwn4x2VY.js → sas-LOl-uUPh.js} +1 -1
  111. package/dist/assets/web/dist/assets/{scss-jSrJlzLI.js → scss-BX2_sZHY.js} +1 -1
  112. package/dist/assets/web/dist/assets/{shellscript-D5nAd0gs.js → shellscript-4d-QvKkY.js} +1 -1
  113. package/dist/assets/web/dist/assets/{shellsession-BZY4-Tdh.js → shellsession-DhB16UGy.js} +1 -1
  114. package/dist/assets/web/dist/assets/{soy-COppPOKr.js → soy-Cm1xTUBr.js} +1 -1
  115. package/dist/assets/web/dist/assets/{sql-Blc_6dOA.js → sql-BsfcBJdu.js} +1 -1
  116. package/dist/assets/web/dist/assets/{sseReplay-DmyMXfRC.js → sseReplay-IzTdD4-3.js} +5 -5
  117. package/dist/assets/web/dist/assets/{stata-DRv3OY-h.js → stata-Dwat3BSw.js} +1 -1
  118. package/dist/assets/web/dist/assets/{surrealql-qMv_eeJ6.js → surrealql-P4rtxzAW.js} +1 -1
  119. package/dist/assets/web/dist/assets/{svelte-d1Edb6Rz.js → svelte-DTcsi8S8.js} +1 -1
  120. package/dist/assets/web/dist/assets/{templ-CUMSIOXG.js → templ-D-kP0Tg5.js} +1 -1
  121. package/dist/assets/web/dist/assets/{tex-BGZQA-Ei.js → tex-D-4vuedK.js} +1 -1
  122. package/dist/assets/web/dist/assets/{ts-tags-Z9ulxVsq.js → ts-tags-jEEWYxsu.js} +1 -1
  123. package/dist/assets/web/dist/assets/{tsx-oI4bI3Vf.js → tsx-CFnlrVzT.js} +1 -1
  124. package/dist/assets/web/dist/assets/{twig-D_RXwBIX.js → twig-D5rGrPjs.js} +1 -1
  125. package/dist/assets/web/dist/assets/{typescript-BgqQDYoN.js → typescript-nYIQ35or.js} +1 -1
  126. package/dist/assets/web/dist/assets/{typst-5MW-KRDd.js → typst-3-UX8dna.js} +1 -1
  127. package/dist/assets/web/dist/assets/{vue-CZhHHsPm.js → vue-CLIPIX03.js} +1 -1
  128. package/dist/assets/web/dist/assets/{vue-html-DlYy2d8v.js → vue-html-Ci9CpkHU.js} +1 -1
  129. package/dist/assets/web/dist/assets/{vue-vine-CJDnx-qn.js → vue-vine-DL5pEYYk.js} +1 -1
  130. package/dist/assets/web/dist/assets/{xml-BRBDk65Q.js → xml-bO80qikC.js} +1 -1
  131. package/dist/assets/web/dist/assets/{xsl-BlCykPxo.js → xsl-BPze60Ej.js} +1 -1
  132. package/dist/assets/web/dist/assets/{yaml-BqW48szf.js → yaml-BQUSon_o.js} +1 -1
  133. package/dist/cli.js +36552 -33171
  134. package/package.json +1 -1
  135. package/dist/assets/web/dist/assets/CostsPage-BoKzYa4B.js +0 -2
  136. package/dist/assets/web/dist/assets/HomePage-BG_ok-K2.js +0 -2
  137. package/dist/assets/web/dist/assets/PendingTurnRow-ChCQOLgZ.js +0 -1
  138. package/dist/assets/web/dist/assets/RunFoldRow-D3wVpzBa.js +0 -1
  139. package/dist/assets/web/dist/assets/RunRoutePage-B3IirUVi.js +0 -9
  140. package/dist/assets/web/dist/assets/RunsIndexPage-DiFmtGaJ.js +0 -1
  141. package/dist/assets/web/dist/assets/indexRow-BT0cPVRw.js +0 -1
  142. package/dist/assets/web/dist/assets/main-5Gm_1Gv8.js +0 -28
  143. package/dist/assets/web/dist/assets/main-zbP_dTjR.css +0 -1
@@ -0,0 +1,203 @@
1
+ // The plane's decider (docs/reference/specs/orchestration-plane.md, the
2
+ // decider items; record 0064, "Where it lives"): one pure function over a
3
+ // closed event union and a closed effect union. The ledger object reads its
4
+ // state, calls `decide` and commits the writes and the effects inside ONE
5
+ // `transactionSync`, so a decision and its consequences land together or not
6
+ // at all. Node-free by design, like `table.ts`: no clock, no io, no ids it
7
+ // did not derive from its inputs — the same event over the same state is the
8
+ // same answer, which is what the shadow comparison and the tests rest on.
9
+
10
+ /** The three stages an ask is judged at (record 0064): the bot's admission
11
+ * door, the plan runner's seed door, the resident's seat. this unit decides the
12
+ * admission stage alone; the others' conditions arrive with their units. */
13
+ export type PlaneStage = "admission" | "runner" | "resident";
14
+
15
+ /** A queue condition: what must become true before the row may run. Each is
16
+ * flipped by an event the plane already sees, never polled. The union grows
17
+ * one member per stage as the later units land. */
18
+ export type PlaneCondition = { kind: "thread_free"; threadKey: string; met: boolean };
19
+
20
+ /** One queued ask: the `plane_queue` row. `position` counts the waiting rows
21
+ * ahead of it on the same conditions when it queued — the number a person is
22
+ * told, never recomputed for them. */
23
+ export interface PlaneQueueRow {
24
+ runId: string;
25
+ requester: string;
26
+ threadKey: string;
27
+ stage: PlaneStage;
28
+ /** The stored request, replayed verbatim when the row is admitted (the transport unit). */
29
+ request: Record<string, unknown>;
30
+ conditions: PlaneCondition[];
31
+ position: number;
32
+ queuedAt: number;
33
+ state: "waiting" | "admitted" | "withdrawn";
34
+ }
35
+
36
+ /** What the decider knows: the queue and the threads with a live run. The
37
+ * object builds it from its own tables inside the same transaction that
38
+ * commits the answer. */
39
+ export interface PlaneState {
40
+ queue: PlaneQueueRow[];
41
+ liveThreads: string[];
42
+ }
43
+
44
+ export function emptyPlaneState(): PlaneState {
45
+ return { queue: [], liveThreads: [] };
46
+ }
47
+
48
+ /** The closed event union. `ask`: may this run start now; `sealed`: a thread's
49
+ * live run ended (the ledger's seal, the event that flips `thread_free`);
50
+ * `withdraw`: the requester gave the wait up (`runs stop` on a queued id, the transport unit). */
51
+ export interface PlaneAskEvent {
52
+ kind: "ask";
53
+ at: number;
54
+ runId: string;
55
+ requester: string;
56
+ threadKey: string;
57
+ stage: PlaneStage;
58
+ request: Record<string, unknown>;
59
+ }
60
+ export type PlaneEvent =
61
+ PlaneAskEvent | { kind: "sealed"; at: number; threadKey: string } | { kind: "withdraw"; at: number; runId: string };
62
+
63
+ /** The closed effect union: what the bot is asked to do, offered on its
64
+ * heartbeat and reclaim answers and acknowledged by id (`/plane/ack`). The
65
+ * id is derived from the run, so a duplicate offer after a roll is the same
66
+ * effect, acknowledged once. The transport unit adds execution; this one only shapes and stores. */
67
+ export type PlaneEffect = {
68
+ id: string;
69
+ kind: "admit";
70
+ runId: string;
71
+ threadKey: string;
72
+ request: Record<string, unknown>;
73
+ };
74
+
75
+ /** What the object must persist beside the returned state — the decider names
76
+ * the rows, the object owns the SQL, both inside one `transactionSync`. */
77
+ export type PlaneWrite =
78
+ | { table: "plane_queue"; op: "put"; row: PlaneQueueRow }
79
+ | { table: "plane_queue"; op: "state"; runId: string; state: PlaneQueueRow["state"] }
80
+ | { table: "plane_effects"; op: "offer"; effect: PlaneEffect; at: number };
81
+
82
+ /** The bot's own outcome for one dispatch, posted to `POST /plane/outcome`
83
+ * under `plane.admission: shadow` (orchestration-plane item 8): `proceeded`, `refused:<code>` or
84
+ * `fell_cold:<token>` — the ledger object logs the decider's word beside it.
85
+ * `runId` is absent when the dispatch never minted one (a refusal). */
86
+ export interface PlaneOutcomePost {
87
+ runId?: string;
88
+ requester: string;
89
+ threadKey: string;
90
+ stage: PlaneStage;
91
+ outcome: string;
92
+ }
93
+
94
+ /** How the bot answers an offered effect (orchestration-plane item 7): `done` and `skipped` close it,
95
+ * `deferred` leaves it on the next heartbeat or reclaim answer. */
96
+ export type PlaneAckOutcome = "done" | "skipped" | "deferred";
97
+
98
+ export interface PlaneDecision {
99
+ state: PlaneState;
100
+ effects: PlaneEffect[];
101
+ writes: PlaneWrite[];
102
+ }
103
+
104
+ /** One event in, the next state out, with the writes and effects that carry
105
+ * it — and nothing else: no transition, no writes (the state comes back as
106
+ * given). Never throws: an event about a run or thread the state does not
107
+ * know is a no-op, because the object replays outcomes from a bot whose view
108
+ * can be older than the tables. */
109
+ export function decide(state: PlaneState, event: PlaneEvent): PlaneDecision {
110
+ switch (event.kind) {
111
+ case "ask":
112
+ return onAsk(state, event);
113
+ case "sealed":
114
+ return onSealed(state, event);
115
+ case "withdraw":
116
+ return onWithdraw(state, event);
117
+ }
118
+ }
119
+
120
+ /** The shadow word for an ask the decider just judged (orchestration-plane item 8): `queued` when the
121
+ * decision holds a waiting row for the run, `proceed` when it holds none —
122
+ * what the object logs beside the bot's own outcome. */
123
+ export function planeAskWordOf(decision: PlaneDecision, runId: string): "proceed" | "queued" {
124
+ const row = decision.state.queue.find((r) => r.runId === runId);
125
+ return row && row.state === "waiting" ? "queued" : "proceed";
126
+ }
127
+
128
+ function onAsk(state: PlaneState, event: PlaneAskEvent): PlaneDecision {
129
+ const threadLive = state.liveThreads.includes(event.threadKey);
130
+ if (!threadLive) return { state, effects: [], writes: [] };
131
+ const waitingAhead = state.queue.filter((r) => r.state === "waiting" && r.threadKey === event.threadKey).length;
132
+ const row: PlaneQueueRow = {
133
+ runId: event.runId,
134
+ requester: event.requester,
135
+ threadKey: event.threadKey,
136
+ stage: event.stage,
137
+ request: event.request,
138
+ conditions: [{ kind: "thread_free", threadKey: event.threadKey, met: false }],
139
+ position: waitingAhead + 1,
140
+ queuedAt: event.at,
141
+ state: "waiting",
142
+ };
143
+ return {
144
+ state: { ...state, queue: [...state.queue, row] },
145
+ effects: [],
146
+ writes: [{ table: "plane_queue", op: "put", row }],
147
+ };
148
+ }
149
+
150
+ function onSealed(state: PlaneState, event: { kind: "sealed"; at: number; threadKey: string }): PlaneDecision {
151
+ const liveThreads = state.liveThreads.filter((t) => t !== event.threadKey);
152
+ if (liveThreads.length === state.liveThreads.length && !hasWaiting(state, event.threadKey))
153
+ return { state, effects: [], writes: [] };
154
+ return walk({ ...state, liveThreads }, event.at);
155
+ }
156
+
157
+ function onWithdraw(state: PlaneState, event: { kind: "withdraw"; at: number; runId: string }): PlaneDecision {
158
+ const row = state.queue.find((r) => r.runId === event.runId && r.state === "waiting");
159
+ if (!row) return { state, effects: [], writes: [] };
160
+ const queue = state.queue.map((r) => (r === row ? { ...r, state: "withdrawn" as const } : r));
161
+ return {
162
+ state: { ...state, queue },
163
+ effects: [],
164
+ writes: [{ table: "plane_queue", op: "state", runId: event.runId, state: "withdrawn" }],
165
+ };
166
+ }
167
+
168
+ function hasWaiting(state: PlaneState, threadKey: string): boolean {
169
+ return state.queue.some((r) => r.state === "waiting" && r.threadKey === threadKey);
170
+ }
171
+
172
+ /** The queue walk (record 0064): oldest first, and the state is re-evaluated
173
+ * after each admission — an admitted run's thread is live again, so a second
174
+ * row on the same thread keeps waiting for the next seal. */
175
+ function walk(state: PlaneState, at: number): PlaneDecision {
176
+ let next = state;
177
+ const effects: PlaneEffect[] = [];
178
+ const writes: PlaneWrite[] = [];
179
+ for (;;) {
180
+ const row = next.queue.find((r) => r.state === "waiting" && conditionsMet(next, r));
181
+ if (!row) break;
182
+ const admitted: PlaneQueueRow = { ...row, state: "admitted" };
183
+ const effect: PlaneEffect = {
184
+ id: `admit:${row.runId}`,
185
+ kind: "admit",
186
+ runId: row.runId,
187
+ threadKey: row.threadKey,
188
+ request: row.request,
189
+ };
190
+ next = {
191
+ queue: next.queue.map((r) => (r === row ? admitted : r)),
192
+ liveThreads: [...next.liveThreads, row.threadKey],
193
+ };
194
+ effects.push(effect);
195
+ writes.push({ table: "plane_queue", op: "state", runId: row.runId, state: "admitted" });
196
+ writes.push({ table: "plane_effects", op: "offer", effect, at });
197
+ }
198
+ return { state: next, effects, writes };
199
+ }
200
+
201
+ function conditionsMet(state: PlaneState, row: PlaneQueueRow): boolean {
202
+ return row.conditions.every((c) => !state.liveThreads.includes(c.threadKey));
203
+ }
@@ -155,8 +155,19 @@ export interface ProviderConfig {
155
155
  apiKeyEnv?: string;
156
156
  /** Base URL for openai-compatible providers (e.g. http://localhost:11434/v1). */
157
157
  baseUrl?: string;
158
+ /** The invoice API the biller's daily tie-out reads (docs/reference/specs/costs.md
159
+ * item 4d): Anthropic's cost report, OpenRouter's activity endpoint, or
160
+ * OpenAI's organization costs. Requires `invoiceKeyEnv`. */
161
+ invoiceApi?: InvoiceApi;
162
+ /** Env var holding that API's key — an admin or management key, never the
163
+ * block's inference key (`apiKeyEnv`). Requires `invoiceApi`. */
164
+ invoiceKeyEnv?: string;
158
165
  }
159
166
 
167
+ /** The invoice APIs a provider block may name (`invoiceApi`). */
168
+ export const INVOICE_APIS = ["anthropic-cost-report", "openrouter-activity", "openai-costs"] as const;
169
+ export type InvoiceApi = (typeof INVOICE_APIS)[number];
170
+
160
171
  /** The wire a block speaks: its `wire` when declared, else its legacy `type`. */
161
172
  export function wireOf(block: Pick<ProviderConfig, "type" | "wire">): Wire {
162
173
  return block.wire ?? WIRE_ALIASES[block.type] ?? "openai-chat";
@@ -207,6 +207,13 @@ export type RunNoteKind =
207
207
  * the summary names the branch. Published by the post-step, so a unit
208
208
  * that ends without a pull request says why on the record and the card. */
209
209
  | "pr_not_opened"
210
+ /** A coding run's pull request was opened or edited, but its head moved
211
+ * before the post-step's head pin and the second identity rewrite answered
212
+ * `unreadable` (docs/reference/specs/agent-coding.md item 2, record 0062):
213
+ * the open cannot be undone, so the summary names the reason the new tip's
214
+ * identities could not be verified and the reply carries the same warning.
215
+ * Published by the post-step. */
216
+ | "pr_head_unverified"
210
217
  /** A review run's post-step posted nothing to the pull request — a guard's
211
218
  * refusal, an opt-out, no pull request resolved, GitHub's own error — and
212
219
  * the summary names the pull request (when one was resolved) and the
@@ -275,6 +282,14 @@ export type RunNoteKind =
275
282
  * there was nothing to push), so a re-issue starts from the partial work
276
283
  * (docs/reference/specs/agent-ship.md item 8). */
277
284
  | "budget_salvage"
285
+ /** A pi run's compaction failed for good — the provider refused the summary
286
+ * (harness-pi.md item 7) — and the run loop treated it as a checkpoint
287
+ * signal: the tracked changes were committed and pushed to the run's own
288
+ * branch (a `pushed_head` event, `by: "salvage"`), or the note says plainly
289
+ * that the tree held nothing, so a context that overflows before the
290
+ * wind-down loses no work. The summary names the compaction failure.
291
+ * Published by the run loop's hook, which the pi harness awaits. */
292
+ | "compaction_salvage"
278
293
  /** The loop's end found a tool call in flight and ended it, so the write-up
279
294
  * keeps its allowance (decision 0046, unit seven; harness-pi item 6). The
280
295
  * summary names the tools. Published by the harness beside the budget note. */
@@ -323,6 +338,7 @@ export const RUN_NOTE_KINDS = [
323
338
  "work_left_behind",
324
339
  "workspace_torn_down",
325
340
  "pr_not_opened",
341
+ "pr_head_unverified",
326
342
  "review_not_posted",
327
343
  "compacted",
328
344
  "harness_error",
@@ -333,6 +349,7 @@ export const RUN_NOTE_KINDS = [
333
349
  "tool_unnamed",
334
350
  "directory_reached",
335
351
  "budget_salvage",
352
+ "compaction_salvage",
336
353
  "stuck_loop",
337
354
  "decline_cascade",
338
355
  ] as const satisfies readonly RunNoteKind[];
@@ -405,7 +422,11 @@ export type ShipRoundOutcome =
405
422
  | "stopped"
406
423
  /** The coding round ended at its lease with the unit unfinished and the row
407
424
  * showing progress, and the grant renewed: the next segment opens (decision 0046). */
408
- | "continued";
425
+ | "continued"
426
+ /** The unit idles instead of ending (record 0051): emitted once the wake
427
+ * lands (that plan's fifth unit); in the vocabulary now so rows written
428
+ * then read beside today's — the old kinds stay for rows already written. */
429
+ | "idle";
409
430
 
410
431
  /**
411
432
  * One event in a run's stream. `seq` is stamped by `RunRegistry.publish` — a
@@ -844,6 +865,10 @@ export type RunEvent =
844
865
  * its own branches past the tree (docs/reference/specs/resident-repos.md
845
866
  * item 16). Absent from an event a build before it recorded. */
846
867
  head?: string;
868
+ /** How many of the run's commits the identity rewrite re-authored before
869
+ * the open (record 0062; docs/reference/specs/agent-coding.md item 2).
870
+ * Absent when none were, and from an event a build before it recorded. */
871
+ rewritten?: number;
847
872
  seq?: number;
848
873
  at?: number;
849
874
  }
@@ -1003,6 +1028,37 @@ export type RunEvent =
1003
1028
  seq?: number;
1004
1029
  at?: number;
1005
1030
  }
1031
+ /** The operator's decision beside the routed request ([record 0057](../../docs/decisions/0057-the-operator-is-the-one-door-a-model-binds-every-chat-input-and-deterministic-code-authorizes-fences-and-executes.md);
1032
+ * the one-door plan's operator unit; run-history item 60): one per admitted chat
1033
+ * event under `routing.operator: shadow` or `on`, published beside the
1034
+ * `route` event. The decision is binds, a question or a refusal; a bind's
1035
+ * `line` is redacted and cut like the receipt (`ROUTE_RECEIPT_CAP`), never
1036
+ * the message text; `intake` carries the intake gate's verdict when the
1037
+ * gate is present; `latencyMs` and `outputTokens` feed the replay's median
1038
+ * rows. Under `shadow` nothing runs from it. Additive: unknown → ignored. */
1039
+ | {
1040
+ type: "operator";
1041
+ mode: "shadow" | "on";
1042
+ outcome: "binds" | "question" | "refusal";
1043
+ reason: string;
1044
+ binds?: ReadonlyArray<{ line: string; reason: string }>;
1045
+ question?: string;
1046
+ /** A question's proposed line, redacted and cut like the receipt — what
1047
+ * the next turn's "yes" binds (`bindFromAnswer`). */
1048
+ proposal?: string;
1049
+ refusalCause?: string;
1050
+ refusalText?: string;
1051
+ /** A refusal the seam itself produced (a non-decision answer, a wrong
1052
+ * tool, a transport failure) — never the model's decision: under `on`
1053
+ * the dispatcher falls back to the readers' route for that event, this
1054
+ * event recorded on the run that then runs. */
1055
+ fallback?: true;
1056
+ intake?: { verdict: string; reason: string };
1057
+ latencyMs?: number;
1058
+ outputTokens?: number;
1059
+ seq?: number;
1060
+ at?: number;
1061
+ }
1006
1062
  /** A refusal the door made ([record 0054](../../docs/decisions/0054-a-refusal-the-person-caused-is-one-question-with-a-best-guess.md),
1007
1063
  * as amended: every refusal is a run record; run-history.md item 2): the
1008
1064
  * code, its one cause, and the sentence the person read — redacted and
@@ -431,6 +431,7 @@ export function analyzeRunFriction(events: readonly RunEvent[], opts: FrictionOp
431
431
  ev.type === "ship_round" ||
432
432
  ev.type === "ship_unit" ||
433
433
  ev.type === "route" ||
434
+ ev.type === "operator" ||
434
435
  ev.type === "refusal" ||
435
436
  ev.type === "reference" ||
436
437
  ev.type === "lease" ||
@@ -125,8 +125,24 @@ export interface AssembledCompaction {
125
125
  }
126
126
 
127
127
  export type AssembledTranscript =
128
- | { complete: true; turns: number; messages: ChatMessage[]; compactions: AssembledCompaction[] }
129
- | { complete: false; turns: number; messages: ChatMessage[]; compactions: AssembledCompaction[]; gap: string };
128
+ | {
129
+ complete: true;
130
+ turns: number;
131
+ messages: ChatMessage[];
132
+ compactions: AssembledCompaction[];
133
+ /** Each message's author (`StoredPart.actor`), aligned with `messages`;
134
+ * present only when at least one row carries one — the verifier selects
135
+ * the author's own turns by it (record 0057). */
136
+ actors?: (string | undefined)[];
137
+ }
138
+ | {
139
+ complete: false;
140
+ turns: number;
141
+ messages: ChatMessage[];
142
+ compactions: AssembledCompaction[];
143
+ actors?: (string | undefined)[];
144
+ gap: string;
145
+ };
130
146
 
131
147
  /** Rows (any order) → the turns, contiguous from `base` (a log index; 0 for a
132
148
  * run's own object), as a conversation counted from 0. `turns` counts every
@@ -150,6 +166,8 @@ export function assembleTranscript(
150
166
  }
151
167
  const messages: ChatMessage[] = [];
152
168
  const compactions: AssembledCompaction[] = [];
169
+ const actorList: (string | undefined)[] = [];
170
+ const actorsOrNone = () => (actorList.some((a) => a !== undefined) ? { actors: actorList } : {});
153
171
  /** The `messages` index each turn index landed at (a compaction row lands nowhere). */
154
172
  const messageIndexOf = new Map<number, number>();
155
173
  const gap = (why: string): AssembledTranscript => ({
@@ -157,6 +175,7 @@ export function assembleTranscript(
157
175
  turns: messages.length + compactions.length,
158
176
  messages,
159
177
  compactions,
178
+ ...actorsOrNone(),
160
179
  gap: why,
161
180
  });
162
181
  const turnCount = byTurn.size === 0 ? 0 : Math.max(...byTurn.keys()) + 1;
@@ -187,11 +206,12 @@ export function assembleTranscript(
187
206
  }
188
207
  messageIndexOf.set(idx, messages.length);
189
208
  messages.push({ role: role ?? "user", content });
209
+ actorList.push(first && !("compaction" in first) ? first.actor : undefined);
190
210
  }
191
211
  for (const c of compactions) {
192
212
  if (c.entry.keptFrom === undefined) continue;
193
213
  const kept = messageIndexOf.get(c.entry.keptFrom - base);
194
214
  if (kept !== undefined) c.keptBefore = kept;
195
215
  }
196
- return { complete: true, turns: messages.length + compactions.length, messages, compactions };
216
+ return { complete: true, turns: messages.length + compactions.length, messages, compactions, ...actorsOrNone() };
197
217
  }
@@ -0,0 +1,237 @@
1
+ import { FRICTION_CATEGORIES, type CategoryTotals, type FrictionCategory } from "./runFriction.js";
2
+ import { llmUsdOfUsage, type ModelPriceTable } from "./modelPricing.js";
3
+ import { emptyUsage, UNKNOWN_AGENT } from "./runUsage.js";
4
+ import { utf8ByteLength, type RunRecord } from "./runRecord.js";
5
+
6
+ // Run metrics (docs/reference/specs/run-metrics.md; docs/decisions/0063-every-finished-run-writes-one-metrics-point-and-a-metrics-page-reads-the-trend.md):
7
+ // one flat point per finished run, written to a Workers Analytics Engine
8
+ // dataset beside the record's commit. This file is the point's contract, shared
9
+ // by both sides of the wire — the bot's store/ledger clients compute the point
10
+ // (`pointOf`), the state Worker validates it (`isRunMetricsPoint`) and decides
11
+ // whether the row turned final (`pointTurnsFinal`) — so it is node-free like
12
+ // `runRecord.ts`: no Node built-ins, no I/O, no clock.
13
+
14
+ /** A point as the platform's `writeDataPoint` takes it: one index (the
15
+ * sampling key), positional string blobs and positional doubles. */
16
+ export interface RunMetricsPoint {
17
+ indexes: [string];
18
+ blobs: string[];
19
+ doubles: number[];
20
+ }
21
+
22
+ /** The platform's byte cap per blob (and per index); `pointOf` truncates. */
23
+ export const MAX_POINT_BLOB_BYTES = 96;
24
+
25
+ /** The point's schema word, `blob1` — bump it when a position changes meaning. */
26
+ export const POINT_SCHEMA = "1";
27
+
28
+ const BLOB_NAMES = [
29
+ "schema",
30
+ "agent",
31
+ "preset",
32
+ "model",
33
+ "status",
34
+ "failure kind",
35
+ "dominant friction",
36
+ "channel",
37
+ "repository",
38
+ "machine class",
39
+ "route class",
40
+ "reply",
41
+ "lineage",
42
+ "requester",
43
+ "identity",
44
+ "run id",
45
+ ] as const;
46
+
47
+ const DOUBLE_NAMES = [
48
+ "wall",
49
+ "getting ready",
50
+ "thinking",
51
+ "tools",
52
+ "finishing up",
53
+ "overhead",
54
+ "not recorded",
55
+ "not loaded",
56
+ "turns",
57
+ "input tokens",
58
+ "output tokens",
59
+ "cache read tokens",
60
+ "cache write tokens",
61
+ "dollars",
62
+ "steps",
63
+ "tool calls",
64
+ "events",
65
+ "unpriced tokens",
66
+ "minutes",
67
+ "finished at",
68
+ ] as const;
69
+
70
+ export type PointBlobName = (typeof BLOB_NAMES)[number];
71
+ export type PointDoubleName = (typeof DOUBLE_NAMES)[number];
72
+
73
+ /** The ONE ordered table naming every position once: `blobs[i]` is the meaning
74
+ * of `blob<i+1>`, `doubles[i]` of `double<i+1>`, `index[0]` of `index1`. The
75
+ * writer builds from it (`pointOf` fills a `Record<name, value>` and maps it
76
+ * through this order, so a position added here without a value fails to
77
+ * compile) and the reader's SQL aliases from it — a query names a column
78
+ * through its name here, never a magic number. */
79
+ export const POINT_COLUMNS = {
80
+ index: ["agent"] as const,
81
+ blobs: BLOB_NAMES,
82
+ doubles: DOUBLE_NAMES,
83
+ };
84
+
85
+ /** The 1-based platform column of a named blob: `"status"` → `"blob5"`. */
86
+ export function blobColumn(name: PointBlobName): string {
87
+ return `blob${POINT_COLUMNS.blobs.indexOf(name) + 1}`;
88
+ }
89
+
90
+ /** The 1-based platform column of a named double: `"dollars"` → `"double14"`. */
91
+ export function doubleColumn(name: PointDoubleName): string {
92
+ return `double${POINT_COLUMNS.doubles.indexOf(name) + 1}`;
93
+ }
94
+
95
+ /** A point's blob by its `POINT_COLUMNS` name — the tests' and readers' accessor. */
96
+ export function blobOf(point: RunMetricsPoint, name: PointBlobName): string {
97
+ return point.blobs[POINT_COLUMNS.blobs.indexOf(name)];
98
+ }
99
+
100
+ /** A point's double by its `POINT_COLUMNS` name. */
101
+ export function doubleOf(point: RunMetricsPoint, name: PointDoubleName): number {
102
+ return point.doubles[POINT_COLUMNS.doubles.indexOf(name)];
103
+ }
104
+
105
+ /** Truncate to the platform's per-blob byte cap (multi-byte safe: cut by
106
+ * characters until the UTF-8 size fits — blob values are ids and enum words,
107
+ * so the loop is theoretical). */
108
+ function capBlob(s: string): string {
109
+ let out = s;
110
+ while (utf8ByteLength(out) > MAX_POINT_BLOB_BYTES) out = out.slice(0, -1);
111
+ return out;
112
+ }
113
+
114
+ const isFinite_ = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v);
115
+
116
+ /** The dominant friction category of a stored diagnosis: the `FrictionCategory`
117
+ * with the largest `durationMs` among those with a count (first in
118
+ * `FRICTION_CATEGORIES` order on a tie); empty when no category has a count. */
119
+ export function dominantFriction(byCategory: Partial<Record<FrictionCategory, CategoryTotals>>): FrictionCategory | "" {
120
+ let best: FrictionCategory | "" = "";
121
+ let bestMs = -1;
122
+ for (const c of FRICTION_CATEGORIES) {
123
+ const t = byCategory[c];
124
+ if (!t || t.count <= 0) continue;
125
+ if (t.durationMs > bestMs) {
126
+ best = c;
127
+ bestMs = t.durationMs;
128
+ }
129
+ }
130
+ return best;
131
+ }
132
+
133
+ /**
134
+ * The record's metrics point, or `undefined` for a record still in its
135
+ * provisional window (`provisional: true`) — and for nothing else: a final
136
+ * record whose `finishedAt` equals its `startedAt` is counted with a zero
137
+ * wall. Reads ONLY the record's typed dimensions and counters — never
138
+ * `activity`, `label`, `events`, `verdict`, `handoff`, `dispositions` or
139
+ * `diagnosis.verdict` — so no free text reaches the dataset. Dollars are
140
+ * priced at finish through the same table `RunsService` prices reads with
141
+ * (`llmUsdOfUsage`); an unpriced model's tokens land in `unpriced tokens`,
142
+ * never in `dollars` as $0.
143
+ */
144
+ export function pointOf(record: RunRecord, prices?: ModelPriceTable): RunMetricsPoint | undefined {
145
+ if (record.provisional === true) return undefined;
146
+ const usage = record.usage ?? emptyUsage();
147
+ const priced = llmUsdOfUsage(usage, prices);
148
+ let inputTokens = 0;
149
+ let outputTokens = 0;
150
+ let cacheReadTokens = 0;
151
+ let cacheWriteTokens = 0;
152
+ for (const m of Object.values(usage.byModel)) {
153
+ inputTokens += m.inputTokens;
154
+ outputTokens += m.outputTokens;
155
+ cacheReadTokens += m.cacheReadTokens;
156
+ cacheWriteTokens += m.cacheWriteTokens;
157
+ }
158
+ const shape = record.diagnosis.shape;
159
+ const blobs: Record<PointBlobName, string> = {
160
+ schema: POINT_SCHEMA,
161
+ agent: record.agent ?? "",
162
+ preset: record.profile?.preset ?? "",
163
+ model: record.model ?? "",
164
+ status: record.status,
165
+ "failure kind": record.failure?.kind ?? "",
166
+ "dominant friction": dominantFriction(record.diagnosis.byCategory),
167
+ channel: record.channelId,
168
+ repository: record.repo ?? "",
169
+ "machine class": record.profile?.machine ?? "",
170
+ "route class": record.route !== undefined ? "routed" : "chosen",
171
+ reply: record.replyOk === true ? "ok" : record.replyOk === false ? "failed" : "none",
172
+ lineage: record.parentRunId !== undefined ? "child" : "root",
173
+ requester: record.userId,
174
+ identity: record.profile?.identity ?? "",
175
+ "run id": record.id,
176
+ };
177
+ const doubles: Record<PointDoubleName, number> = {
178
+ wall: record.finishedAt - (record.receivedAt ?? record.startedAt),
179
+ "getting ready": shape?.gettingReadyMs ?? 0,
180
+ thinking: shape?.thinkingMs ?? 0,
181
+ tools: shape?.toolsMs ?? 0,
182
+ "finishing up": shape?.finishingUpMs ?? 0,
183
+ overhead: shape?.overheadMs ?? 0,
184
+ "not recorded": shape?.notRecordedMs ?? 0,
185
+ "not loaded": shape?.notLoadedMs ?? 0,
186
+ turns: usage.turns,
187
+ "input tokens": inputTokens,
188
+ "output tokens": outputTokens,
189
+ "cache read tokens": cacheReadTokens,
190
+ "cache write tokens": cacheWriteTokens,
191
+ dollars: priced.usd,
192
+ steps: record.stepCount ?? 0,
193
+ "tool calls": isFinite_(record.diagnosis.toolCalls) ? record.diagnosis.toolCalls : 0,
194
+ events: record.eventCount,
195
+ "unpriced tokens": priced.unpricedTokens,
196
+ minutes: record.profile?.minutes ?? 0,
197
+ "finished at": record.finishedAt,
198
+ };
199
+ return {
200
+ indexes: [capBlob(record.agent ?? UNKNOWN_AGENT)],
201
+ blobs: POINT_COLUMNS.blobs.map((n) => capBlob(blobs[n])),
202
+ doubles: POINT_COLUMNS.doubles.map((n) => doubles[n]),
203
+ };
204
+ }
205
+
206
+ /** Structural check on a point from outside the process (the `/runs/put` and
207
+ * `/runs/finish` bodies): exactly one index, exactly `POINT_COLUMNS.blobs.length`
208
+ * strings each at most `MAX_POINT_BLOB_BYTES` bytes, exactly
209
+ * `POINT_COLUMNS.doubles.length` finite numbers — and nothing else. */
210
+ export function isRunMetricsPoint(v: unknown): v is RunMetricsPoint {
211
+ if (typeof v !== "object" || v === null) return false;
212
+ const p = v as Record<string, unknown>;
213
+ if (!Array.isArray(p.indexes) || p.indexes.length !== 1) return false;
214
+ const index = p.indexes[0];
215
+ if (typeof index !== "string" || utf8ByteLength(index) > MAX_POINT_BLOB_BYTES) return false;
216
+ if (!Array.isArray(p.blobs) || p.blobs.length !== POINT_COLUMNS.blobs.length) return false;
217
+ if (!p.blobs.every((b) => typeof b === "string" && utf8ByteLength(b) <= MAX_POINT_BLOB_BYTES)) return false;
218
+ if (!Array.isArray(p.doubles) || p.doubles.length !== POINT_COLUMNS.doubles.length) return false;
219
+ return p.doubles.every(isFinite_);
220
+ }
221
+
222
+ /**
223
+ * The emission rule's one question, answered inside the store's transaction:
224
+ * did this write turn the run's row final? True exactly when the stored record
225
+ * is final (`provisional` absent) and the existing row was absent or itself
226
+ * provisional. There is deliberately no `finishedAt > startedAt` term: a plain
227
+ * `put` of an `interrupted` record over no row is a run counted once, and a
228
+ * final rewrite over a final row (the review artifact, an identical retry) is
229
+ * false however its stamps compare.
230
+ */
231
+ export function pointTurnsFinal(
232
+ existing: { provisional?: boolean } | undefined,
233
+ stored: Pick<RunRecord, "provisional">,
234
+ ): boolean {
235
+ if (stored.provisional === true) return false;
236
+ return existing === undefined || existing.provisional === true;
237
+ }