@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.
- package/dist/assets/config/config.example.yaml +54 -1
- package/dist/assets/deploy/cloudflare-memory/runMetricsSink.ts +31 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +455 -32
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +12 -0
- package/dist/assets/deploy/profile.example.json +1 -1
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/core/authz/grants.ts +6 -0
- package/dist/assets/src/core/authz/policy.ts +19 -0
- package/dist/assets/src/core/budgets.ts +25 -0
- package/dist/assets/src/core/coordinator/contract.ts +51 -1
- package/dist/assets/src/core/coordinator/driver.ts +111 -11
- package/dist/assets/src/core/costs.ts +221 -2
- package/dist/assets/src/core/costsSnapshotStore.ts +22 -1
- package/dist/assets/src/core/memory/engine.ts +25 -4
- package/dist/assets/src/core/memory/types.ts +6 -2
- package/dist/assets/src/core/pipelineStanding.ts +294 -0
- package/dist/assets/src/core/plane/decide.ts +203 -0
- package/dist/assets/src/core/provider.ts +11 -0
- package/dist/assets/src/core/runEvents.ts +57 -1
- package/dist/assets/src/core/runFriction.ts +1 -0
- package/dist/assets/src/core/runLedger/transcript.ts +23 -3
- package/dist/assets/src/core/runMetrics.ts +237 -0
- package/dist/assets/src/core/runRecord.ts +61 -0
- package/dist/assets/src/core/runUsage.ts +41 -9
- package/dist/assets/src/core/ship/contract.ts +8 -15
- package/dist/assets/src/core/ship/coordinator.ts +150 -11
- package/dist/assets/src/deploy/profile.ts +16 -0
- package/dist/assets/web/dist/.vite/manifest.json +461 -427
- package/dist/assets/web/dist/assets/{AppShell-DAtHiuI6.js → AppShell-CSXDCV4x.js} +1 -1
- package/dist/assets/web/dist/assets/CostChart-DsuPS2g7.js +2 -0
- package/dist/assets/web/dist/assets/CostsPage-BuKjw3nv.js +1 -0
- package/dist/assets/web/dist/assets/{DeliveryPage-3ELQWM0r.js → DeliveryPage-ngPsO2to.js} +1 -1
- package/dist/assets/web/dist/assets/HomePage-DvxTHzPx.js +3 -0
- package/dist/assets/web/dist/assets/{InputMenu-C6aPpe30.js → InputMenu-BdZXnibG.js} +1 -1
- package/dist/assets/web/dist/assets/MetricsPage-o5fSaCTe.js +1 -0
- package/dist/assets/web/dist/assets/{NotFoundPage-DtE-GgTk.js → NotFoundPage-DHuWyjlq.js} +1 -1
- package/dist/assets/web/dist/assets/PendingTurnRow-ZYIRCCZ2.js +1 -0
- package/dist/assets/web/dist/assets/PlanePage-DpWfiX4C.js +1 -0
- package/dist/assets/web/dist/assets/{ResidentDetailPage-C9y3nbo8.js → ResidentDetailPage-DG86v39Y.js} +1 -1
- package/dist/assets/web/dist/assets/{ResidentsIndexPage-i1RG9e7g.js → ResidentsIndexPage-x6p689VH.js} +1 -1
- package/dist/assets/web/dist/assets/RunFoldRow-DG29LOTs.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-ysJBY8xQ.js +9 -0
- package/dist/assets/web/dist/assets/RunsIndexPage-CuzFchcn.js +1 -0
- package/dist/assets/web/dist/assets/{RunsTabs-YSbUu5py.js → RunsTabs-kHbbxVHx.js} +1 -1
- package/dist/assets/web/dist/assets/{ScheduledPage-DvYwM2TE.js → ScheduledPage-BuLmfcbG.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingSelect-BIzAsLk1.js → SettingSelect-BfID5nF4.js} +1 -1
- package/dist/assets/web/dist/assets/{SettingsPage-Bo6yCyXZ.js → SettingsPage-BujWkdU_.js} +1 -1
- package/dist/assets/web/dist/assets/{StatusDot-CAfS1AUi.js → StatusDot-C8Bc0pTX.js} +1 -1
- package/dist/assets/web/dist/assets/{Tooltip-tZoum_T-.js → Tooltip-BWwJx27K.js} +1 -1
- package/dist/assets/web/dist/assets/{UnitRoutePage-BmdOHwNn.js → UnitRoutePage-B9kjA1AT.js} +1 -1
- package/dist/assets/web/dist/assets/{angular-html-BjeQZdCq.js → angular-html-C8b5YTHf.js} +1 -1
- package/dist/assets/web/dist/assets/{angular-ts-ZGdORrL2.js → angular-ts-C3f4WYNe.js} +1 -1
- package/dist/assets/web/dist/assets/{apl-DhvV_X93.js → apl-DSHxUJc3.js} +1 -1
- package/dist/assets/web/dist/assets/{astro-DiRSE4Ug.js → astro-BSGCCBP6.js} +1 -1
- package/dist/assets/web/dist/assets/{blade-D8Fr5Wvq.js → blade-DFeYrMKk.js} +1 -1
- package/dist/assets/web/dist/assets/{c-BwbC64D4.js → c-L_QOnFVv.js} +1 -1
- package/dist/assets/web/dist/assets/{chapel-DgmqRK97.js → chapel-DLQWUwhu.js} +1 -1
- package/dist/assets/web/dist/assets/{cobol-DJod2RNK.js → cobol-1DlMmrEO.js} +1 -1
- package/dist/assets/web/dist/assets/{coffee-D1Y2CgBY.js → coffee-BTB6os6N.js} +1 -1
- package/dist/assets/web/dist/assets/{cpp-CsO97YOM.js → cpp-BvJI2z3u.js} +1 -1
- package/dist/assets/web/dist/assets/{crystal-CJ7m2tOc.js → crystal-B11wGW_h.js} +1 -1
- package/dist/assets/web/dist/assets/{css-B2M-NKoj.js → css-BiIWx5Pw.js} +1 -1
- package/dist/assets/web/dist/assets/{dist-DfbEpHXR.js → dist-CpnyQGOb.js} +2 -2
- package/dist/assets/web/dist/assets/{edge-CFVW-m9B.js → edge-nSglEXaD.js} +1 -1
- package/dist/assets/web/dist/assets/{elixir-B2mtRgCr.js → elixir-x7o8qMl8.js} +1 -1
- package/dist/assets/web/dist/assets/{elm-2teVAwCi.js → elm-DGrRyqTQ.js} +1 -1
- package/dist/assets/web/dist/assets/{erb-LAbeNqen.js → erb--_dh58Nw.js} +1 -1
- package/dist/assets/web/dist/assets/{git-rebase-DnhzH_sp.js → git-rebase-DbpUXwyg.js} +1 -1
- package/dist/assets/web/dist/assets/{glimmer-js-DPKjXf1-.js → glimmer-js-BG0CuZG5.js} +1 -1
- package/dist/assets/web/dist/assets/{glimmer-ts-CABXnt5z.js → glimmer-ts-CQIl5qyR.js} +1 -1
- package/dist/assets/web/dist/assets/{glsl-Dz4-3-gJ.js → glsl-9PJNQTcw.js} +1 -1
- package/dist/assets/web/dist/assets/{graphql-ByWUD1DL.js → graphql-CzVTzjEI.js} +1 -1
- package/dist/assets/web/dist/assets/{hack-CdmOK2K-.js → hack-BhCmAz3Q.js} +1 -1
- package/dist/assets/web/dist/assets/{haml-DzegMpd2.js → haml-BmI12COR.js} +1 -1
- package/dist/assets/web/dist/assets/{handlebars-B5NeiP0e.js → handlebars-Cib-ISTS.js} +1 -1
- package/dist/assets/web/dist/assets/{html-Zg_NHLv9.js → html-D3lDapvI.js} +1 -1
- package/dist/assets/web/dist/assets/{html-derivative-DZf9JNzh.js → html-derivative-DRPY8vOK.js} +1 -1
- package/dist/assets/web/dist/assets/{http-CVFrQZeN.js → http-B9VttK6m.js} +1 -1
- package/dist/assets/web/dist/assets/{hurl-L5brWAJe.js → hurl-C0saNJYj.js} +1 -1
- package/dist/assets/web/dist/assets/indexRow-DABQtONT.js +1 -0
- package/dist/assets/web/dist/assets/{java-hFphyZ4R.js → java-BCFbtYAj.js} +1 -1
- package/dist/assets/web/dist/assets/{javascript-CAJO1WKX.js → javascript-nwBjnrhh.js} +1 -1
- package/dist/assets/web/dist/assets/{jinja-N4A4srXI.js → jinja-CMYO9byN.js} +1 -1
- package/dist/assets/web/dist/assets/{jison-D3BZa_iU.js → jison-Cko8HnE_.js} +1 -1
- package/dist/assets/web/dist/assets/{json-DxD1Qh8W.js → json-Dzv__YsX.js} +1 -1
- package/dist/assets/web/dist/assets/{jsx-CHhag69S.js → jsx-CTFxagih.js} +1 -1
- package/dist/assets/web/dist/assets/{julia-CrhkZ6Tt.js → julia-_Xvten1-.js} +1 -1
- package/dist/assets/web/dist/assets/{just-D0LFOuhq.js → just-bLps4qOu.js} +1 -1
- package/dist/assets/web/dist/assets/{latex-BHAlwCXK.js → latex-D6UaYsUP.js} +1 -1
- package/dist/assets/web/dist/assets/{liquid-CkaLfCWh.js → liquid-CoDIeULa.js} +1 -1
- package/dist/assets/web/dist/assets/{lua-B1h4aAzP.js → lua-D1KFRnVf.js} +1 -1
- package/dist/assets/web/dist/assets/main-C4QfwybO.css +1 -0
- package/dist/assets/web/dist/assets/main-Comxmwi4.js +28 -0
- package/dist/assets/web/dist/assets/{marko-D6CG4hvX.js → marko-B5N5XcOw.js} +1 -1
- package/dist/assets/web/dist/assets/{mdc-CVQzimAU.js → mdc-uxzjQO3q.js} +1 -1
- package/dist/assets/web/dist/assets/{nginx-h-9Ir54h.js → nginx-BMJxs2TK.js} +1 -1
- package/dist/assets/web/dist/assets/{nim-DHVI30sp.js → nim-DqQ3-8p1.js} +1 -1
- package/dist/assets/web/dist/assets/{org-AasAXfMF.js → org-DsY6LK59.js} +1 -1
- package/dist/assets/web/dist/assets/{perl-BVi5aTEy.js → perl--pq9iJFp.js} +1 -1
- package/dist/assets/web/dist/assets/{php-qOkCeUKr.js → php-RHCMbsM2.js} +1 -1
- package/dist/assets/web/dist/assets/{pug-8TnUGvEX.js → pug-CFNq_FCp.js} +1 -1
- package/dist/assets/web/dist/assets/{qml-CpJbhoxn.js → qml-C9Acx9E8.js} +1 -1
- package/dist/assets/web/dist/assets/{r-JkUNcAJW.js → r-DxlU8FBd.js} +1 -1
- package/dist/assets/web/dist/assets/{razor-CtU4ws5a.js → razor-CLJ2kKih.js} +1 -1
- package/dist/assets/web/dist/assets/{regexp-J2zA6ceN.js → regexp-CzmdGoEp.js} +1 -1
- package/dist/assets/web/dist/assets/{rst-CMCvf-yZ.js → rst-D9yuxBET.js} +1 -1
- package/dist/assets/web/dist/assets/{ruby-ChLBY_iR.js → ruby-CIRcEvnD.js} +1 -1
- package/dist/assets/web/dist/assets/{sas-cwn4x2VY.js → sas-LOl-uUPh.js} +1 -1
- package/dist/assets/web/dist/assets/{scss-jSrJlzLI.js → scss-BX2_sZHY.js} +1 -1
- package/dist/assets/web/dist/assets/{shellscript-D5nAd0gs.js → shellscript-4d-QvKkY.js} +1 -1
- package/dist/assets/web/dist/assets/{shellsession-BZY4-Tdh.js → shellsession-DhB16UGy.js} +1 -1
- package/dist/assets/web/dist/assets/{soy-COppPOKr.js → soy-Cm1xTUBr.js} +1 -1
- package/dist/assets/web/dist/assets/{sql-Blc_6dOA.js → sql-BsfcBJdu.js} +1 -1
- package/dist/assets/web/dist/assets/{sseReplay-DmyMXfRC.js → sseReplay-IzTdD4-3.js} +5 -5
- package/dist/assets/web/dist/assets/{stata-DRv3OY-h.js → stata-Dwat3BSw.js} +1 -1
- package/dist/assets/web/dist/assets/{surrealql-qMv_eeJ6.js → surrealql-P4rtxzAW.js} +1 -1
- package/dist/assets/web/dist/assets/{svelte-d1Edb6Rz.js → svelte-DTcsi8S8.js} +1 -1
- package/dist/assets/web/dist/assets/{templ-CUMSIOXG.js → templ-D-kP0Tg5.js} +1 -1
- package/dist/assets/web/dist/assets/{tex-BGZQA-Ei.js → tex-D-4vuedK.js} +1 -1
- package/dist/assets/web/dist/assets/{ts-tags-Z9ulxVsq.js → ts-tags-jEEWYxsu.js} +1 -1
- package/dist/assets/web/dist/assets/{tsx-oI4bI3Vf.js → tsx-CFnlrVzT.js} +1 -1
- package/dist/assets/web/dist/assets/{twig-D_RXwBIX.js → twig-D5rGrPjs.js} +1 -1
- package/dist/assets/web/dist/assets/{typescript-BgqQDYoN.js → typescript-nYIQ35or.js} +1 -1
- package/dist/assets/web/dist/assets/{typst-5MW-KRDd.js → typst-3-UX8dna.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-CZhHHsPm.js → vue-CLIPIX03.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-html-DlYy2d8v.js → vue-html-Ci9CpkHU.js} +1 -1
- package/dist/assets/web/dist/assets/{vue-vine-CJDnx-qn.js → vue-vine-DL5pEYYk.js} +1 -1
- package/dist/assets/web/dist/assets/{xml-BRBDk65Q.js → xml-bO80qikC.js} +1 -1
- package/dist/assets/web/dist/assets/{xsl-BlCykPxo.js → xsl-BPze60Ej.js} +1 -1
- package/dist/assets/web/dist/assets/{yaml-BqW48szf.js → yaml-BQUSon_o.js} +1 -1
- package/dist/cli.js +36552 -33171
- package/package.json +1 -1
- package/dist/assets/web/dist/assets/CostsPage-BoKzYa4B.js +0 -2
- package/dist/assets/web/dist/assets/HomePage-BG_ok-K2.js +0 -2
- package/dist/assets/web/dist/assets/PendingTurnRow-ChCQOLgZ.js +0 -1
- package/dist/assets/web/dist/assets/RunFoldRow-D3wVpzBa.js +0 -1
- package/dist/assets/web/dist/assets/RunRoutePage-B3IirUVi.js +0 -9
- package/dist/assets/web/dist/assets/RunsIndexPage-DiFmtGaJ.js +0 -1
- package/dist/assets/web/dist/assets/indexRow-BT0cPVRw.js +0 -1
- package/dist/assets/web/dist/assets/main-5Gm_1Gv8.js +0 -28
- 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
|
-
| {
|
|
129
|
-
|
|
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
|
+
}
|