@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
|
@@ -78,6 +78,18 @@ import {
|
|
|
78
78
|
selectReclaim,
|
|
79
79
|
} from "../../src/core/runLedger/decisions.ts";
|
|
80
80
|
import { intakeReceiptRetentionMs } from "../../src/core/budgets.ts";
|
|
81
|
+
import {
|
|
82
|
+
decide,
|
|
83
|
+
planeAskWordOf,
|
|
84
|
+
type PlaneAckOutcome,
|
|
85
|
+
type PlaneEffect,
|
|
86
|
+
type PlaneEvent,
|
|
87
|
+
type PlaneOutcomePost,
|
|
88
|
+
type PlaneQueueRow,
|
|
89
|
+
type PlaneStage,
|
|
90
|
+
type PlaneState,
|
|
91
|
+
type PlaneWrite,
|
|
92
|
+
} from "../../src/core/plane/decide.ts";
|
|
81
93
|
import {
|
|
82
94
|
IDEMPOTENCY_KEY_PATTERN,
|
|
83
95
|
capThreadEvent,
|
|
@@ -119,6 +131,8 @@ import {
|
|
|
119
131
|
type McpTicketState,
|
|
120
132
|
type SealedCredential,
|
|
121
133
|
} from "../../src/mcp/registry.ts";
|
|
134
|
+
import { isRunMetricsPoint, pointTurnsFinal, type RunMetricsPoint } from "../../src/core/runMetrics.ts";
|
|
135
|
+
import { AnalyticsEngineSink, NullSink, type RunMetricsSink } from "./runMetricsSink.ts";
|
|
122
136
|
import { injectedBuildStamp } from "../../src/deploy/buildStamp.ts";
|
|
123
137
|
import { systemClock } from "../../src/core/trace/clock.ts";
|
|
124
138
|
import { createTracer } from "../../src/core/trace/tracer.ts";
|
|
@@ -148,7 +162,7 @@ const traceSinks = [workerLogSink((line) => console.log(line))];
|
|
|
148
162
|
//
|
|
149
163
|
// Route surface (JSON in/out; bearer MEMORY_TOKEN on everything but /healthz):
|
|
150
164
|
// POST /retrieve {scopeKey, query, limit} → {records: MemoryRecord[]}
|
|
151
|
-
// POST /write {scopeKey, records: MemoryCandidate[]} → {ok, inserted, deduped, superseded}
|
|
165
|
+
// POST /write {scopeKey, records: MemoryCandidate[]} → {ok, inserted, deduped, restated, superseded, evicted}
|
|
152
166
|
// POST /sweep {scopeKey, dryRun?} → {ok, swept} (+ ids under dryRun — the marked rows, flipped to `swept`)
|
|
153
167
|
// GET /healthz → {ok:true} (deploy wake ping; touches no DO)
|
|
154
168
|
// Scheduled-firing routes (the record behind the /runs Scheduled panel;
|
|
@@ -161,7 +175,7 @@ const traceSinks = [workerLogSink((line) => console.log(line))];
|
|
|
161
175
|
// store key, owning the retention policy. Same bearer; /runs/put has its own 2 MiB
|
|
162
176
|
// body fence (a record is budgeted to 1.5 MiB upstream), every other route
|
|
163
177
|
// keeps the 512 KB one.
|
|
164
|
-
// POST /runs/put {storeKey, record, policy?, policyUpdatedAt?} → {ok, retained, stored, rewritten}
|
|
178
|
+
// POST /runs/put {storeKey, record, policy?, policyUpdatedAt?, point?} → {ok, retained, stored, rewritten}
|
|
165
179
|
// POST /runs/get {storeKey, id} → {record: RunRecord | null} (unknown/expired: null, 200)
|
|
166
180
|
// POST /runs/list {storeKey, limit?, before?, beforeId?, sinceMs?, agent?, channel?, threadKey?, parentRunId?}
|
|
167
181
|
// → {items: RunListItem[], nextBefore?: {finishedAt, id}} (cursor = the last row's list key)
|
|
@@ -198,6 +212,14 @@ export interface Env {
|
|
|
198
212
|
* binding is a cross-script one, and the class must exist on the bot before
|
|
199
213
|
* the state Worker may name it), and a finish then commits with no event. */
|
|
200
214
|
SHIP_COORDINATOR?: Workflow;
|
|
215
|
+
/** The run-metrics dataset (docs/reference/specs/run-metrics.md): where the
|
|
216
|
+
* RunHistoryDO writes one point per run whose row turned final. Optional
|
|
217
|
+
* like SHIP_COORDINATOR: this Worker deploys without it and every answer is
|
|
218
|
+
* byte-identical — the NullSink swallows the points. */
|
|
219
|
+
RUN_METRICS?: AnalyticsEngineDataset;
|
|
220
|
+
/** The dataset's name, rendered beside the binding, so `/healthz` can answer
|
|
221
|
+
* `runMetrics:<dataset>` and the bot's boot probe can compare names. */
|
|
222
|
+
RUN_METRICS_DATASET?: string;
|
|
201
223
|
MEMORY_TOKEN?: string;
|
|
202
224
|
}
|
|
203
225
|
|
|
@@ -381,8 +403,8 @@ export class MemoryDO extends DurableObject<Env> {
|
|
|
381
403
|
scopeKey: string,
|
|
382
404
|
candidates: MemoryCandidate[],
|
|
383
405
|
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 };
|
|
406
|
+
): Promise<{ inserted: number; deduped: number; restated: number; superseded: number; evicted: number }> {
|
|
407
|
+
const counts = { inserted: 0, deduped: 0, restated: 0, superseded: 0, evicted: 0 };
|
|
386
408
|
if (candidates.length === 0) return counts;
|
|
387
409
|
this.ctx.storage.transactionSync(() => {
|
|
388
410
|
let seq = this.sql.exec<{ next: number }>(`SELECT COALESCE(MAX(seq), -1) + 1 AS next FROM records`).one().next;
|
|
@@ -400,18 +422,41 @@ export class MemoryDO extends DurableObject<Env> {
|
|
|
400
422
|
// own TRUTHINESS test: `supersedes: ""` passes validation but means NO
|
|
401
423
|
// supersede to the engine, so it must dedup against the norm pool —
|
|
402
424
|
// an `!== undefined` branch here would hand it an empty pool and
|
|
403
|
-
// insert a duplicate active row.
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
425
|
+
// insert a duplicate active row. A `restates` id is looked up first
|
|
426
|
+
// (the restate target); when it misses — not active, or another
|
|
427
|
+
// scope's id, which this DO simply doesn't hold — the candidate takes
|
|
428
|
+
// today's pool, so planWrite falls through to dedup-or-insert.
|
|
429
|
+
const restatePool = cand.restates
|
|
430
|
+
? this.sql
|
|
431
|
+
.exec<Row>(`SELECT * FROM records WHERE id = ? AND status = 'active'`, cand.restates)
|
|
432
|
+
.toArray()
|
|
433
|
+
.map(toRecord)
|
|
434
|
+
: [];
|
|
435
|
+
const relevant =
|
|
436
|
+
restatePool.length > 0
|
|
437
|
+
? restatePool
|
|
438
|
+
: (cand.supersedes
|
|
439
|
+
? this.sql.exec<Row>(`SELECT * FROM records WHERE id = ? AND status = 'active'`, cand.supersedes)
|
|
440
|
+
: this.sql.exec<Row>(
|
|
441
|
+
`SELECT * FROM records WHERE status = 'active' AND norm = ? ORDER BY seq`,
|
|
442
|
+
normalizeText(cand.text),
|
|
443
|
+
)
|
|
410
444
|
)
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
.map(toRecord);
|
|
445
|
+
.toArray()
|
|
446
|
+
.map(toRecord);
|
|
414
447
|
const plan = planWrite(relevant, cand, (c) => mintRecord(scopeKey, seq++, now, c));
|
|
448
|
+
if (plan.action === "restate") {
|
|
449
|
+
// The shown record is refreshed in place; COALESCE keeps the stored
|
|
450
|
+
// confidence when the plan carries none (neither side had a value).
|
|
451
|
+
this.sql.exec(
|
|
452
|
+
`UPDATE records SET use_count = use_count + 1, last_used_at = ?, confidence = COALESCE(?, confidence) WHERE id = ?`,
|
|
453
|
+
now,
|
|
454
|
+
plan.confidence ?? null,
|
|
455
|
+
plan.target.id,
|
|
456
|
+
);
|
|
457
|
+
counts.restated++;
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
415
460
|
if (plan.action === "dedup") {
|
|
416
461
|
this.sql.exec(`UPDATE records SET use_count = use_count + 1 WHERE id = ?`, plan.target.id);
|
|
417
462
|
counts.deduped++;
|
|
@@ -1173,13 +1218,16 @@ export class CostsSnapshotDO extends DurableObject<Env> {
|
|
|
1173
1218
|
`);
|
|
1174
1219
|
}
|
|
1175
1220
|
|
|
1176
|
-
/** Replace the snapshot whole: every part rewritten in one transaction.
|
|
1221
|
+
/** Replace the snapshot whole: every part rewritten in one transaction. Each
|
|
1222
|
+
* logical part is its own named row — `invoices` included, never a rest-spread
|
|
1223
|
+
* into the meta row that would silently absorb future fields. */
|
|
1177
1224
|
async put(snapshot: CostsSnapshot): Promise<void> {
|
|
1178
|
-
const { usage, llm, runUsage, ...meta } = snapshot;
|
|
1225
|
+
const { usage, llm, invoices, runUsage, ...meta } = snapshot;
|
|
1179
1226
|
const rows: Array<[string, unknown]> = [
|
|
1180
1227
|
["meta", meta],
|
|
1181
1228
|
...USAGE_PARTS.map((name): [string, unknown] => [`usage.${name}`, usage[name]]),
|
|
1182
1229
|
["llm", llm],
|
|
1230
|
+
...(invoices !== undefined ? [["invoices", invoices] as [string, unknown]] : []),
|
|
1183
1231
|
["runUsage", runUsage],
|
|
1184
1232
|
];
|
|
1185
1233
|
this.ctx.storage.transactionSync(() => {
|
|
@@ -1204,10 +1252,12 @@ export class CostsSnapshotDO extends DurableObject<Env> {
|
|
|
1204
1252
|
return body === undefined ? undefined : (JSON.parse(body) as unknown);
|
|
1205
1253
|
};
|
|
1206
1254
|
const usage = Object.fromEntries(USAGE_PARTS.map((name) => [name, read(`usage.${name}`)]));
|
|
1255
|
+
const invoices = read("invoices");
|
|
1207
1256
|
const snapshot = {
|
|
1208
1257
|
...(JSON.parse(meta) as Record<string, unknown>),
|
|
1209
1258
|
usage,
|
|
1210
1259
|
llm: read("llm"),
|
|
1260
|
+
...(invoices !== undefined ? { invoices } : {}),
|
|
1211
1261
|
runUsage: read("runUsage"),
|
|
1212
1262
|
};
|
|
1213
1263
|
return isCostsSnapshot(snapshot) ? snapshot : null;
|
|
@@ -1481,14 +1531,32 @@ function rowToLive(r: LiveRow): LiveRunRow {
|
|
|
1481
1531
|
};
|
|
1482
1532
|
}
|
|
1483
1533
|
|
|
1484
|
-
type HeartbeatAnswer = FenceResult & { stop?: StopMode | null; phase?: LivePhase };
|
|
1534
|
+
type HeartbeatAnswer = FenceResult & { stop?: StopMode | null; phase?: LivePhase; effects?: PlaneEffect[] };
|
|
1535
|
+
|
|
1536
|
+
/** Whether the bot's outcome and the decider's word agree (orchestration-plane item 8): `proceeded`
|
|
1537
|
+
* beside `proceed`, and a thread-live refusal beside `queued` — the refusal
|
|
1538
|
+
* IS the queue position the plane would hold. `null` for a pair the decider
|
|
1539
|
+
* does not model yet: logged, never counted. */
|
|
1540
|
+
function planeAgreementOf(outcome: string, decider: "proceed" | "queued"): boolean | null {
|
|
1541
|
+
if (outcome === "proceeded") return decider === "proceed";
|
|
1542
|
+
if (outcome === "refused:thread-live") return decider === "queued";
|
|
1543
|
+
return null;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/** The most effects one answer carries (record 0064; orchestration-plane item 7): the rest ride the next heartbeat. */
|
|
1547
|
+
const PLANE_EFFECTS_PER_ANSWER = 32;
|
|
1485
1548
|
|
|
1486
1549
|
export class RunHistoryDO extends DurableObject<Env> {
|
|
1487
1550
|
private readonly sql: SqlStorage;
|
|
1551
|
+
/** Where a turned-final run's point goes (run-metrics.md): the Analytics
|
|
1552
|
+
* Engine dataset when the deploy bound one, the NullSink otherwise —
|
|
1553
|
+
* selected once at construction, the `SHIP_COORDINATOR?` shape. */
|
|
1554
|
+
private readonly metrics: RunMetricsSink;
|
|
1488
1555
|
|
|
1489
1556
|
constructor(ctx: DurableObjectState, env: Env) {
|
|
1490
1557
|
super(ctx, env);
|
|
1491
1558
|
this.sql = ctx.storage.sql;
|
|
1559
|
+
this.metrics = env.RUN_METRICS !== undefined ? new AnalyticsEngineSink(env.RUN_METRICS) : new NullSink();
|
|
1492
1560
|
// Idempotent schema. `runs` carries the listing columns plus the record
|
|
1493
1561
|
// minus its events as JSON (`summary_json`, what `list` returns); events
|
|
1494
1562
|
// live one per row keyed (run_id, seq) so a 5000-event run is paged, never
|
|
@@ -1643,6 +1711,215 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1643
1711
|
PRIMARY KEY (instance_id, unit, seq)
|
|
1644
1712
|
);
|
|
1645
1713
|
`);
|
|
1714
|
+
// The orchestration plane's tables (record 0064; orchestration-plane.md item 7):
|
|
1715
|
+
// the queue with its conditions, the reservations, the quiet and pressure
|
|
1716
|
+
// windows, the watches' findings, the offered effects and the residents'
|
|
1717
|
+
// levels. This unit opens them all so a later Worker and an earlier one agree on
|
|
1718
|
+
// the schema; only the queue and the effects are written yet.
|
|
1719
|
+
this.sql.exec(`
|
|
1720
|
+
CREATE TABLE IF NOT EXISTS plane_queue (
|
|
1721
|
+
run_id TEXT PRIMARY KEY,
|
|
1722
|
+
requester TEXT NOT NULL,
|
|
1723
|
+
thread_key TEXT NOT NULL,
|
|
1724
|
+
stage TEXT NOT NULL,
|
|
1725
|
+
request_json TEXT NOT NULL,
|
|
1726
|
+
conditions_json TEXT NOT NULL,
|
|
1727
|
+
position_at INTEGER NOT NULL,
|
|
1728
|
+
queued_at INTEGER NOT NULL,
|
|
1729
|
+
state TEXT NOT NULL
|
|
1730
|
+
);
|
|
1731
|
+
CREATE INDEX IF NOT EXISTS plane_queue_waiting ON plane_queue(state, queued_at);
|
|
1732
|
+
CREATE TABLE IF NOT EXISTS plane_reservations (
|
|
1733
|
+
kind TEXT NOT NULL,
|
|
1734
|
+
key TEXT NOT NULL,
|
|
1735
|
+
run_id TEXT NOT NULL,
|
|
1736
|
+
at INTEGER NOT NULL,
|
|
1737
|
+
PRIMARY KEY (kind, key)
|
|
1738
|
+
);
|
|
1739
|
+
CREATE TABLE IF NOT EXISTS plane_windows (
|
|
1740
|
+
kind TEXT NOT NULL,
|
|
1741
|
+
key TEXT NOT NULL,
|
|
1742
|
+
phase TEXT NOT NULL,
|
|
1743
|
+
opened_at INTEGER NOT NULL,
|
|
1744
|
+
reason_json TEXT NOT NULL,
|
|
1745
|
+
PRIMARY KEY (kind, key)
|
|
1746
|
+
);
|
|
1747
|
+
CREATE TABLE IF NOT EXISTS plane_findings (
|
|
1748
|
+
id TEXT PRIMARY KEY,
|
|
1749
|
+
watch TEXT NOT NULL,
|
|
1750
|
+
subject TEXT NOT NULL,
|
|
1751
|
+
timeline_json TEXT NOT NULL,
|
|
1752
|
+
filed_at INTEGER NOT NULL
|
|
1753
|
+
);
|
|
1754
|
+
CREATE TABLE IF NOT EXISTS plane_effects (
|
|
1755
|
+
id TEXT PRIMARY KEY,
|
|
1756
|
+
body_json TEXT NOT NULL,
|
|
1757
|
+
offered_at INTEGER NOT NULL,
|
|
1758
|
+
acked_at INTEGER
|
|
1759
|
+
);
|
|
1760
|
+
CREATE INDEX IF NOT EXISTS plane_effects_open ON plane_effects(acked_at, offered_at);
|
|
1761
|
+
CREATE TABLE IF NOT EXISTS plane_levels (
|
|
1762
|
+
resident TEXT NOT NULL,
|
|
1763
|
+
name TEXT NOT NULL,
|
|
1764
|
+
side TEXT NOT NULL,
|
|
1765
|
+
reported_at INTEGER NOT NULL,
|
|
1766
|
+
generation TEXT NOT NULL,
|
|
1767
|
+
PRIMARY KEY (resident, name)
|
|
1768
|
+
);
|
|
1769
|
+
`);
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
// ---- the orchestration plane (record 0064; orchestration-plane.md) ----------
|
|
1773
|
+
|
|
1774
|
+
/** The decider's state, read inside the caller's `transactionSync`: the
|
|
1775
|
+
* queue oldest first and the threads a live row holds. `excludeRunId` drops
|
|
1776
|
+
* that run's own live row from the view — a shadow post judged after the
|
|
1777
|
+
* dispatch it describes claimed the thread must not read its own claim as
|
|
1778
|
+
* "thread live" (orchestration-plane item 8). */
|
|
1779
|
+
private planeState(excludeRunId?: string): PlaneState {
|
|
1780
|
+
const queue = this.sql
|
|
1781
|
+
.exec<{
|
|
1782
|
+
run_id: string;
|
|
1783
|
+
requester: string;
|
|
1784
|
+
thread_key: string;
|
|
1785
|
+
stage: string;
|
|
1786
|
+
request_json: string;
|
|
1787
|
+
conditions_json: string;
|
|
1788
|
+
position_at: number;
|
|
1789
|
+
queued_at: number;
|
|
1790
|
+
state: string;
|
|
1791
|
+
}>(`SELECT * FROM plane_queue ORDER BY queued_at ASC, run_id ASC`)
|
|
1792
|
+
.toArray()
|
|
1793
|
+
.map((r): PlaneQueueRow => ({
|
|
1794
|
+
runId: r.run_id,
|
|
1795
|
+
requester: r.requester,
|
|
1796
|
+
threadKey: r.thread_key,
|
|
1797
|
+
stage: r.stage as PlaneStage,
|
|
1798
|
+
request: JSON.parse(r.request_json) as Record<string, unknown>,
|
|
1799
|
+
conditions: JSON.parse(r.conditions_json) as PlaneQueueRow["conditions"],
|
|
1800
|
+
position: r.position_at,
|
|
1801
|
+
queuedAt: r.queued_at,
|
|
1802
|
+
state: r.state as PlaneQueueRow["state"],
|
|
1803
|
+
}));
|
|
1804
|
+
const liveThreads = this.sql
|
|
1805
|
+
.exec<{ thread_key: string }>(`SELECT thread_key FROM live_runs WHERE run_id IS NOT ?`, excludeRunId ?? null)
|
|
1806
|
+
.toArray()
|
|
1807
|
+
.map((r) => r.thread_key);
|
|
1808
|
+
return { queue, liveThreads };
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
/** The decider's writes, applied inside the same `transactionSync` that read
|
|
1812
|
+
* the state — the decision and its consequences land together (orchestration-plane item 6). */
|
|
1813
|
+
private applyPlaneWrites(writes: PlaneWrite[]): void {
|
|
1814
|
+
for (const w of writes) {
|
|
1815
|
+
if (w.table === "plane_queue" && w.op === "put") {
|
|
1816
|
+
this.sql.exec(
|
|
1817
|
+
`INSERT OR REPLACE INTO plane_queue
|
|
1818
|
+
(run_id, requester, thread_key, stage, request_json, conditions_json, position_at, queued_at, state)
|
|
1819
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
1820
|
+
w.row.runId,
|
|
1821
|
+
w.row.requester,
|
|
1822
|
+
w.row.threadKey,
|
|
1823
|
+
w.row.stage,
|
|
1824
|
+
JSON.stringify(w.row.request),
|
|
1825
|
+
JSON.stringify(w.row.conditions),
|
|
1826
|
+
w.row.position,
|
|
1827
|
+
w.row.queuedAt,
|
|
1828
|
+
w.row.state,
|
|
1829
|
+
);
|
|
1830
|
+
} else if (w.table === "plane_queue" && w.op === "state") {
|
|
1831
|
+
this.sql.exec(`UPDATE plane_queue SET state = ? WHERE run_id = ?`, w.state, w.runId);
|
|
1832
|
+
} else {
|
|
1833
|
+
// An offer keeps its first `offered_at`: a re-decided admit after a
|
|
1834
|
+
// roll is the same effect, not a younger one.
|
|
1835
|
+
this.sql.exec(
|
|
1836
|
+
`INSERT OR IGNORE INTO plane_effects (id, body_json, offered_at, acked_at) VALUES (?, ?, ?, NULL)`,
|
|
1837
|
+
w.effect.id,
|
|
1838
|
+
JSON.stringify(w.effect),
|
|
1839
|
+
w.at,
|
|
1840
|
+
);
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
/** Apply one plane event: state read, decider, writes and effects in ONE
|
|
1846
|
+
* `transactionSync` (orchestration-plane item 6). The transport unit's routes feed it; the tests pin the atomicity. */
|
|
1847
|
+
planeApply(event: PlaneEvent): { effects: PlaneEffect[] } {
|
|
1848
|
+
let effects: PlaneEffect[] = [];
|
|
1849
|
+
this.ctx.storage.transactionSync(() => {
|
|
1850
|
+
const decision = decide(this.planeState(), event);
|
|
1851
|
+
this.applyPlaneWrites(decision.writes);
|
|
1852
|
+
effects = decision.effects;
|
|
1853
|
+
});
|
|
1854
|
+
return { effects };
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
/** The unacknowledged effects, oldest first, at most `PLANE_EFFECTS_PER_ANSWER`
|
|
1858
|
+
* (orchestration-plane item 7) — what every heartbeat and reclaim answer carries. Public: the
|
|
1859
|
+
* reclaim route composes it beside the runs it took. */
|
|
1860
|
+
openPlaneEffects(): PlaneEffect[] {
|
|
1861
|
+
return this.sql
|
|
1862
|
+
.exec<{ body_json: string }>(
|
|
1863
|
+
`SELECT body_json FROM plane_effects WHERE acked_at IS NULL ORDER BY offered_at ASC, id ASC LIMIT ?`,
|
|
1864
|
+
PLANE_EFFECTS_PER_ANSWER,
|
|
1865
|
+
)
|
|
1866
|
+
.toArray()
|
|
1867
|
+
.map((r) => JSON.parse(r.body_json) as PlaneEffect);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
/** Shadow (orchestration-plane item 8): the bot's own outcome for one dispatch, judged beside the
|
|
1871
|
+
* decider's word for the same ask. Nothing runs and nothing queues from
|
|
1872
|
+
* the decider here — the comparison is logged, and a disagreement bumps a
|
|
1873
|
+
* per-condition counter in `meta` for the `plane disagreements` line. An
|
|
1874
|
+
* outcome the decider does not model yet (an allowlist refusal, a cold
|
|
1875
|
+
* fall) is logged uncounted. The post is fired without an await, so it can
|
|
1876
|
+
* arrive AFTER the dispatch it describes claimed this thread's `live_runs`
|
|
1877
|
+
* row; a post carrying the run's own id excludes that row from the live
|
|
1878
|
+
* view so the run's own claim never reads as a false disagreement. */
|
|
1879
|
+
planeOutcome(
|
|
1880
|
+
post: PlaneOutcomePost,
|
|
1881
|
+
now: number,
|
|
1882
|
+
): { ok: true; decider: "proceed" | "queued"; agreed: boolean | null } {
|
|
1883
|
+
let decider: "proceed" | "queued" = "proceed";
|
|
1884
|
+
let agreed: boolean | null = null;
|
|
1885
|
+
this.ctx.storage.transactionSync(() => {
|
|
1886
|
+
const ask: PlaneEvent = {
|
|
1887
|
+
kind: "ask",
|
|
1888
|
+
at: now,
|
|
1889
|
+
runId: post.runId ?? `ask:${post.threadKey}:${now}`,
|
|
1890
|
+
requester: post.requester,
|
|
1891
|
+
threadKey: post.threadKey,
|
|
1892
|
+
stage: post.stage,
|
|
1893
|
+
request: {},
|
|
1894
|
+
};
|
|
1895
|
+
decider = planeAskWordOf(decide(this.planeState(post.runId), ask), ask.runId);
|
|
1896
|
+
agreed = planeAgreementOf(post.outcome, decider);
|
|
1897
|
+
if (agreed === false) this.bumpPlaneDisagreement("thread_free");
|
|
1898
|
+
});
|
|
1899
|
+
console.log(
|
|
1900
|
+
`[plane/outcome] ${post.threadKey} ${post.stage} bot=${post.outcome} decider=${decider} agreed=${agreed ?? "uncompared"}`,
|
|
1901
|
+
);
|
|
1902
|
+
return { ok: true, decider, agreed };
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
/** The per-condition disagreement counts (orchestration-plane item 8), kept in `meta` so the table's
|
|
1906
|
+
* later `plane disagreements` line can read them. */
|
|
1907
|
+
private bumpPlaneDisagreement(condition: string): void {
|
|
1908
|
+
const row = this.sql
|
|
1909
|
+
.exec<{ value: string }>(`SELECT value FROM meta WHERE key = 'plane_disagreements'`)
|
|
1910
|
+
.toArray()[0];
|
|
1911
|
+
const counts = row ? (JSON.parse(row.value) as Record<string, number>) : {};
|
|
1912
|
+
counts[condition] = (counts[condition] ?? 0) + 1;
|
|
1913
|
+
this.sql.exec(`INSERT OR REPLACE INTO meta (key, value) VALUES ('plane_disagreements', ?)`, JSON.stringify(counts));
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/** An effect's acknowledgement by id (orchestration-plane item 7): `done` and `skipped` close it,
|
|
1917
|
+
* `deferred` leaves it offered for the next answer. An unknown id is a
|
|
1918
|
+
* no-op — the bot may ack an effect an older table never held. */
|
|
1919
|
+
planeAck(id: string, outcome: PlaneAckOutcome, now: number): { ok: true } {
|
|
1920
|
+
if (outcome !== "deferred")
|
|
1921
|
+
this.sql.exec(`UPDATE plane_effects SET acked_at = ? WHERE id = ? AND acked_at IS NULL`, now, id);
|
|
1922
|
+
return { ok: true };
|
|
1646
1923
|
}
|
|
1647
1924
|
|
|
1648
1925
|
// ---- the coordinator's parent records (run-history item 49) -----------------
|
|
@@ -1931,7 +2208,10 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
1931
2208
|
return;
|
|
1932
2209
|
}
|
|
1933
2210
|
this.sql.exec(`UPDATE live_runs SET lease_until = ? WHERE run_id = ?`, now + leaseMs, runId);
|
|
1934
|
-
|
|
2211
|
+
// The plane's open effects ride every owner's heartbeat answer (record
|
|
2212
|
+
// 0064; orchestration-plane item 7) — empty until a unit writes them, but always present, so the
|
|
2213
|
+
// client's ack loop needs no version probe.
|
|
2214
|
+
out = { ok: true, stop: row.stop, phase: row.phase, effects: this.openPlaneEffects() };
|
|
1935
2215
|
});
|
|
1936
2216
|
return out;
|
|
1937
2217
|
}
|
|
@@ -2063,8 +2343,10 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2063
2343
|
gen: string,
|
|
2064
2344
|
record: RunRecord,
|
|
2065
2345
|
proposal?: RunPolicyProposal,
|
|
2346
|
+
point?: RunMetricsPoint,
|
|
2066
2347
|
): Promise<FenceResult & { stored?: boolean; event?: RunFinishedSend["kind"] }> {
|
|
2067
2348
|
let out: FenceResult & { stored?: boolean } = { ok: true };
|
|
2349
|
+
let turnedFinal = false;
|
|
2068
2350
|
this.ctx.storage.transactionSync(() => {
|
|
2069
2351
|
const fence = checkFence(this.liveRow(runId), gen);
|
|
2070
2352
|
if (!fence.ok) {
|
|
@@ -2073,9 +2355,14 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2073
2355
|
}
|
|
2074
2356
|
const put = this.upsertInTransaction(record, proposal);
|
|
2075
2357
|
this.deleteLiveRows([runId]);
|
|
2358
|
+
turnedFinal = put.turnedFinal;
|
|
2076
2359
|
out = { ok: true, stored: put.stored };
|
|
2077
2360
|
});
|
|
2078
2361
|
if (!out.ok) return out;
|
|
2362
|
+
// The point after the commit, never inside it (`sendRunFinished`'s placement):
|
|
2363
|
+
// the finish usually replaces the start tombstone, so this is where most
|
|
2364
|
+
// runs are counted (run-metrics.md item 2).
|
|
2365
|
+
this.writeMetricsPoint(runId, point, turnedFinal && out.stored === true);
|
|
2079
2366
|
if ((await this.ctx.storage.getAlarm()) === null)
|
|
2080
2367
|
await this.ctx.storage.setAlarm(systemClock() + RUN_SWEEP_INTERVAL_MS);
|
|
2081
2368
|
await this.refreshSessionBytes(record.session?.key);
|
|
@@ -2338,15 +2625,21 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2338
2625
|
* when the stored version changed (`event_count`, `finished_at`, `bytes`) —
|
|
2339
2626
|
* an identical retry is a no-op on `run_events`. `stored: false` when the
|
|
2340
2627
|
* record itself fell outside the (possibly just-updated) policy: it was
|
|
2341
|
-
* written and deleted in the same transaction, so nothing of it remains.
|
|
2628
|
+
* written and deleted in the same transaction, so nothing of it remains.
|
|
2629
|
+
* `point` is the record's metrics point, computed by the client
|
|
2630
|
+
* (run-metrics.md): written to the sink AFTER the commit, only when the row
|
|
2631
|
+
* turned final and the record was stored — `turnedFinal` stays internal (the
|
|
2632
|
+
* route strips it), so the wire answer is exactly the shape it always was. */
|
|
2342
2633
|
async put(
|
|
2343
2634
|
record: RunRecord,
|
|
2344
2635
|
proposal?: RunPolicyProposal,
|
|
2345
|
-
|
|
2346
|
-
|
|
2636
|
+
point?: RunMetricsPoint,
|
|
2637
|
+
): Promise<{ ok: true; retained: number; stored: boolean; rewritten: boolean; turnedFinal: boolean }> {
|
|
2638
|
+
let result = { ok: true as const, retained: 0, stored: false, rewritten: false, turnedFinal: false };
|
|
2347
2639
|
this.ctx.storage.transactionSync(() => {
|
|
2348
2640
|
result = this.upsertInTransaction(record, proposal);
|
|
2349
2641
|
});
|
|
2642
|
+
this.writeMetricsPoint(record.id, point, result.turnedFinal && result.stored);
|
|
2350
2643
|
// A coordinator child closed OUTSIDE the ledger's finish — the run loop or
|
|
2351
2644
|
// a reclaim writing an `interrupted` record, the pi harness's typed restart,
|
|
2352
2645
|
// a resume abandoning a lost workspace — still wakes its parent's wait at
|
|
@@ -2370,7 +2663,7 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2370
2663
|
private upsertInTransaction(
|
|
2371
2664
|
record: RunRecord,
|
|
2372
2665
|
proposal?: RunPolicyProposal,
|
|
2373
|
-
): { ok: true; retained: number; stored: boolean; rewritten: boolean } {
|
|
2666
|
+
): { ok: true; retained: number; stored: boolean; rewritten: boolean; turnedFinal: boolean } {
|
|
2374
2667
|
{
|
|
2375
2668
|
const now = systemClock();
|
|
2376
2669
|
const policy = proposal ? this.applyProposal(proposal, now).policy : this.policyState().policy;
|
|
@@ -2387,11 +2680,29 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2387
2680
|
const { events, ...summary } = stored;
|
|
2388
2681
|
const bytes = utf8ByteLength(JSON.stringify(stored));
|
|
2389
2682
|
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 = ?`,
|
|
2683
|
+
.exec<{ event_count: number; finished_at: number; bytes: number; summary_json: string }>(
|
|
2684
|
+
`SELECT event_count, finished_at, bytes, summary_json FROM runs WHERE run_id = ?`,
|
|
2392
2685
|
record.id,
|
|
2393
2686
|
)
|
|
2394
2687
|
.toArray()[0];
|
|
2688
|
+
// The one field of the stored summary the emission rule reads (run-metrics.md
|
|
2689
|
+
// item 2): the row's `provisional`, parsed alone — never deserialized whole.
|
|
2690
|
+
const existingProvisional = existing !== undefined && summaryIsProvisional(existing.summary_json);
|
|
2691
|
+
const turnedFinal = pointTurnsFinal(
|
|
2692
|
+
existing !== undefined ? { provisional: existingProvisional } : undefined,
|
|
2693
|
+
stored,
|
|
2694
|
+
);
|
|
2695
|
+
// A provisional record never overwrites a final row (run-history.md item 27;
|
|
2696
|
+
// run-metrics.md item 3): a start tombstone sitting in retry backoff or a
|
|
2697
|
+
// drain upgrade racing a fast finish would otherwise land after the finish
|
|
2698
|
+
// record, overwrite it with `interrupted` — and let the run's point be
|
|
2699
|
+
// written twice when a later final write turned the row "final" again.
|
|
2700
|
+
// Answered as stored, with nothing written: the row, its events and the
|
|
2701
|
+
// sessions table stay exactly as the final write left them.
|
|
2702
|
+
if (stored.provisional === true && existing !== undefined && !existingProvisional) {
|
|
2703
|
+
const retained = this.sql.exec<{ n: number }>(`SELECT COUNT(*) AS n FROM runs`).one().n;
|
|
2704
|
+
return { ok: true as const, retained, stored: true, rewritten: false, turnedFinal: false };
|
|
2705
|
+
}
|
|
2395
2706
|
const unchanged =
|
|
2396
2707
|
existing !== undefined &&
|
|
2397
2708
|
sameStoredVersion(
|
|
@@ -2471,10 +2782,25 @@ export class RunHistoryDO extends DurableObject<Env> {
|
|
|
2471
2782
|
retained: kept.size,
|
|
2472
2783
|
stored: kept.has(record.id),
|
|
2473
2784
|
rewritten: existing !== undefined && !unchanged,
|
|
2785
|
+
turnedFinal,
|
|
2474
2786
|
};
|
|
2475
2787
|
}
|
|
2476
2788
|
}
|
|
2477
2789
|
|
|
2790
|
+
/** One point per run whose row turned final, AFTER the commit (run-metrics.md
|
|
2791
|
+
* item 2) — advisory: a throwing sink leaves the answer exactly as a
|
|
2792
|
+
* recording one would, and says so in one warn line with the run id and the
|
|
2793
|
+
* error's constructor name, never the point's contents. */
|
|
2794
|
+
private writeMetricsPoint(runId: string, point: RunMetricsPoint | undefined, turnedFinal: boolean): void {
|
|
2795
|
+
if (point === undefined || !turnedFinal) return;
|
|
2796
|
+
try {
|
|
2797
|
+
this.metrics.write(point);
|
|
2798
|
+
} catch (err) {
|
|
2799
|
+
const kind = err instanceof Error ? err.constructor.name : "Error";
|
|
2800
|
+
console.warn(`[runs/metrics] ${runId} point not written: ${kind}`);
|
|
2801
|
+
}
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2478
2804
|
/** Remove a run and its events. Returns whether a run row existed. */
|
|
2479
2805
|
async delete(id: string): Promise<boolean> {
|
|
2480
2806
|
let deleted = false;
|
|
@@ -2924,6 +3250,17 @@ function parseSummary(row: RunRow): Omit<RunRecord, "events"> | null {
|
|
|
2924
3250
|
}
|
|
2925
3251
|
}
|
|
2926
3252
|
|
|
3253
|
+
/** The one field of a stored row's summary the emission rule reads: whether the
|
|
3254
|
+
* row is a provisional tombstone. Deliberately not a full `RunRecord` parse
|
|
3255
|
+
* (run-metrics.md item 2) — this runs inside every upsert's transaction. */
|
|
3256
|
+
function summaryIsProvisional(summaryJson: string): boolean {
|
|
3257
|
+
try {
|
|
3258
|
+
return (JSON.parse(summaryJson) as { provisional?: unknown }).provisional === true;
|
|
3259
|
+
} catch {
|
|
3260
|
+
return false;
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
|
|
2927
3264
|
function parseRunId(v: unknown): Validated<string> {
|
|
2928
3265
|
if (typeof v !== "string" || !RUN_ID_PATTERN.test(v)) return invalid("id must match ^[A-Za-z0-9_-]{1,64}$");
|
|
2929
3266
|
return { ok: true, value: v };
|
|
@@ -2939,16 +3276,24 @@ function parsePositiveInt(v: unknown, name: string, max: number): Validated<numb
|
|
|
2939
3276
|
return { ok: true, value: v };
|
|
2940
3277
|
}
|
|
2941
3278
|
|
|
2942
|
-
function parseRunPut(
|
|
3279
|
+
function parseRunPut(
|
|
3280
|
+
body: unknown,
|
|
3281
|
+
): Validated<{ storeKey: string; record: RunRecord; proposal?: RunPolicyProposal; point?: RunMetricsPoint }> {
|
|
2943
3282
|
if (typeof body !== "object" || body === null) return invalid("body must be a JSON object");
|
|
2944
3283
|
const b = body as Record<string, unknown>;
|
|
2945
3284
|
const key = parseStoreKey(b);
|
|
2946
3285
|
if (!key.ok) return key;
|
|
2947
3286
|
if (!isRunRecord(b.record)) return invalid("record must be a RunRecord");
|
|
2948
|
-
const out: { storeKey: string; record: RunRecord; proposal?: RunPolicyProposal } = {
|
|
3287
|
+
const out: { storeKey: string; record: RunRecord; proposal?: RunPolicyProposal; point?: RunMetricsPoint } = {
|
|
2949
3288
|
storeKey: key.value,
|
|
2950
3289
|
record: b.record,
|
|
2951
3290
|
};
|
|
3291
|
+
// The record's metrics point (run-metrics.md item 1), validated at the door
|
|
3292
|
+
// like everything else that reaches storage.
|
|
3293
|
+
if (b.point !== undefined) {
|
|
3294
|
+
if (!isRunMetricsPoint(b.point)) return invalid("point must be a RunMetricsPoint");
|
|
3295
|
+
out.point = b.point;
|
|
3296
|
+
}
|
|
2952
3297
|
if (b.policy !== undefined) {
|
|
2953
3298
|
if (typeof b.policy !== "object" || b.policy === null) return invalid("policy must be an object");
|
|
2954
3299
|
const p = b.policy as Record<string, unknown>;
|
|
@@ -3259,6 +3604,11 @@ function parseCandidate(v: unknown, i: number): Validated<MemoryCandidate> {
|
|
|
3259
3604
|
return invalid(`${at}.supersedes must be a string`);
|
|
3260
3605
|
out.supersedes = c.supersedes;
|
|
3261
3606
|
}
|
|
3607
|
+
if (c.restates !== undefined) {
|
|
3608
|
+
if (typeof c.restates !== "string" || c.restates.length > MAX_KEY_CHARS)
|
|
3609
|
+
return invalid(`${at}.restates must be a string`);
|
|
3610
|
+
out.restates = c.restates;
|
|
3611
|
+
}
|
|
3262
3612
|
return { ok: true, value: out };
|
|
3263
3613
|
}
|
|
3264
3614
|
|
|
@@ -3823,6 +4173,60 @@ const LEDGER_ROUTES = new Set([
|
|
|
3823
4173
|
"/runs/session/notepad/write",
|
|
3824
4174
|
]);
|
|
3825
4175
|
|
|
4176
|
+
/** The plane's routes (record 0064; orchestration-plane items 7 and 8): the shadow outcome post and the
|
|
4177
|
+
* effect acknowledgement. Both land on the ledger object of the given store
|
|
4178
|
+
* key, like every `/runs/*` route. */
|
|
4179
|
+
const PLANE_ROUTES = new Set(["/plane/outcome", "/plane/ack"]);
|
|
4180
|
+
|
|
4181
|
+
const PLANE_STAGES = new Set(["admission", "runner", "resident"]);
|
|
4182
|
+
const PLANE_OUTCOME = /^(proceeded|refused:[a-z0-9-]+|fell_cold:[a-z0-9_-]+)$/;
|
|
4183
|
+
const PLANE_ACK_OUTCOMES = new Set(["done", "skipped", "deferred"]);
|
|
4184
|
+
|
|
4185
|
+
/** The `/plane/*` routes: validated before any object call, ids and words
|
|
4186
|
+
* only on the log lines. */
|
|
4187
|
+
async function handlePlane(pathname: string, body: unknown, env: Env): Promise<Response> {
|
|
4188
|
+
if (typeof body !== "object" || body === null) return json({ error: "body must be a JSON object" }, 400);
|
|
4189
|
+
const b = body as Record<string, unknown>;
|
|
4190
|
+
const key = parseStoreKey(b);
|
|
4191
|
+
if (!key.ok) return json({ error: key.error }, 400);
|
|
4192
|
+
const stub = env.RUNS.get(env.RUNS.idFromName(key.value));
|
|
4193
|
+
const now = systemClock();
|
|
4194
|
+
if (pathname === "/plane/outcome") {
|
|
4195
|
+
if (typeof b.threadKey !== "string" || b.threadKey.length === 0)
|
|
4196
|
+
return json({ error: "threadKey must be a non-empty string" }, 400);
|
|
4197
|
+
if (typeof b.requester !== "string" || b.requester.length === 0)
|
|
4198
|
+
return json({ error: "requester must be a non-empty string" }, 400);
|
|
4199
|
+
if (typeof b.stage !== "string" || !PLANE_STAGES.has(b.stage))
|
|
4200
|
+
return json({ error: "stage must be admission, runner or resident" }, 400);
|
|
4201
|
+
if (typeof b.outcome !== "string" || !PLANE_OUTCOME.test(b.outcome))
|
|
4202
|
+
return json({ error: "outcome must be proceeded, refused:<code> or fell_cold:<token>" }, 400);
|
|
4203
|
+
let runId: string | undefined;
|
|
4204
|
+
if (b.runId !== undefined) {
|
|
4205
|
+
const parsed = parseRunId(b.runId);
|
|
4206
|
+
if (!parsed.ok) return json({ error: parsed.error }, 400);
|
|
4207
|
+
runId = parsed.value;
|
|
4208
|
+
}
|
|
4209
|
+
const r = await stub.planeOutcome(
|
|
4210
|
+
{
|
|
4211
|
+
...(runId !== undefined ? { runId } : {}),
|
|
4212
|
+
requester: b.requester,
|
|
4213
|
+
threadKey: b.threadKey,
|
|
4214
|
+
stage: b.stage as PlaneStage,
|
|
4215
|
+
outcome: b.outcome,
|
|
4216
|
+
},
|
|
4217
|
+
now,
|
|
4218
|
+
);
|
|
4219
|
+
return json(r);
|
|
4220
|
+
}
|
|
4221
|
+
if (pathname === "/plane/ack") {
|
|
4222
|
+
if (typeof b.id !== "string" || b.id.length === 0) return json({ error: "id must be a non-empty string" }, 400);
|
|
4223
|
+
if (typeof b.outcome !== "string" || !PLANE_ACK_OUTCOMES.has(b.outcome))
|
|
4224
|
+
return json({ error: "outcome must be done, skipped or deferred" }, 400);
|
|
4225
|
+
return json(await stub.planeAck(b.id, b.outcome as PlaneAckOutcome, now));
|
|
4226
|
+
}
|
|
4227
|
+
return json({ error: "not found" }, 404);
|
|
4228
|
+
}
|
|
4229
|
+
|
|
3826
4230
|
/** Routes whose bodies may carry a record, a transcript chunk, or an event batch. */
|
|
3827
4231
|
const WIDE_BODY_ROUTES = new Set([
|
|
3828
4232
|
"/runs/put",
|
|
@@ -4129,7 +4533,9 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
|
|
|
4129
4533
|
// declared shape.
|
|
4130
4534
|
const runs = (await stub.reclaim(g.value, at, lease.value)) as unknown as ReclaimedRun[];
|
|
4131
4535
|
console.log(`[runs/reclaim] ${key.value} ${g.value} took ${runs.length} run(s)`);
|
|
4132
|
-
|
|
4536
|
+
// The reclaim sweep's answer carries the plane's open effects like every
|
|
4537
|
+
// heartbeat answer does (record 0064; orchestration-plane item 7) — empty until a unit writes them.
|
|
4538
|
+
return json({ runs, effects: await stub.openPlaneEffects() });
|
|
4133
4539
|
}
|
|
4134
4540
|
if (pathname === "/runs/handoff") {
|
|
4135
4541
|
const g = gen(b.gen);
|
|
@@ -4264,7 +4670,10 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
|
|
|
4264
4670
|
if (pathname === "/runs/heartbeat") {
|
|
4265
4671
|
const lease = parseLeaseMs(b.leaseMs);
|
|
4266
4672
|
if (!lease.ok) return json({ error: lease.error }, 400);
|
|
4267
|
-
|
|
4673
|
+
// The RPC type mapping reads the effects' open-ended `request` JSON as
|
|
4674
|
+
// unserializable; the values are plain JSON, so the cast only restores the
|
|
4675
|
+
// declared shape (as the reclaim route's does).
|
|
4676
|
+
const r = (await stub.heartbeat(runId.value, g.value, lease.value, now)) as unknown as HeartbeatAnswer;
|
|
4268
4677
|
return r.ok ? json(r) : json(r, 409);
|
|
4269
4678
|
}
|
|
4270
4679
|
if (pathname === "/runs/append") {
|
|
@@ -4302,7 +4711,7 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
|
|
|
4302
4711
|
const parsed = parseRunPut({ ...b, storeKey: key.value });
|
|
4303
4712
|
if (!parsed.ok) return json({ error: parsed.error }, 400);
|
|
4304
4713
|
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);
|
|
4714
|
+
const r = await stub.finish(runId.value, g.value, parsed.value.record, parsed.value.proposal, parsed.value.point);
|
|
4306
4715
|
console.log(
|
|
4307
4716
|
`[runs/finish] ${key.value} ${runId.value} ok=${r.ok}${r.ok ? ` stored=${r.stored} event=${r.event}` : ` ${r.reason}`}`,
|
|
4308
4717
|
);
|
|
@@ -4319,8 +4728,10 @@ async function handleRuns(pathname: string, body: unknown, env: Env): Promise<Re
|
|
|
4319
4728
|
if (pathname === "/runs/put") {
|
|
4320
4729
|
const parsed = parseRunPut(body);
|
|
4321
4730
|
if (!parsed.ok) return json({ error: parsed.error }, 400);
|
|
4322
|
-
const { storeKey, record, proposal } = parsed.value;
|
|
4323
|
-
|
|
4731
|
+
const { storeKey, record, proposal, point } = parsed.value;
|
|
4732
|
+
// `turnedFinal` stays internal: the wire answer is exactly the shape it
|
|
4733
|
+
// always was, binding or no binding (run-metrics.md item 4).
|
|
4734
|
+
const { turnedFinal: _turnedFinal, ...result } = await stub(storeKey).put(record, proposal, point);
|
|
4324
4735
|
console.log(
|
|
4325
4736
|
`[runs/put] ${storeKey} <- ${record.id} (${record.storedEventCount} events, stored=${result.stored}, ${result.retained} retained)`,
|
|
4326
4737
|
);
|
|
@@ -4394,6 +4805,7 @@ const ROUTES = new Set([
|
|
|
4394
4805
|
"/runs/usage",
|
|
4395
4806
|
"/runs/delete",
|
|
4396
4807
|
...LEDGER_ROUTES,
|
|
4808
|
+
...PLANE_ROUTES,
|
|
4397
4809
|
]);
|
|
4398
4810
|
|
|
4399
4811
|
/** The two decisions `fetch` makes once and hands down: is the path one of
|
|
@@ -4404,11 +4816,21 @@ interface Admission {
|
|
|
4404
4816
|
authorized: boolean;
|
|
4405
4817
|
}
|
|
4406
4818
|
|
|
4819
|
+
/** What this deploy carries, for the bot's boot probe: the fixed route set,
|
|
4820
|
+
* plus `runMetrics:<dataset>` when the deploy bound the Analytics Engine
|
|
4821
|
+
* dataset (run-metrics.md item 5) — the name from the `RUN_METRICS_DATASET`
|
|
4822
|
+
* var rendered beside the binding, so the probe can compare it to the bot's. */
|
|
4823
|
+
export function featuresOf(env: Pick<Env, "RUN_METRICS" | "RUN_METRICS_DATASET">): string[] {
|
|
4824
|
+
const features = ["memory", "schedules", "runs", "config", "delivery", "costs", "plane"];
|
|
4825
|
+
if (env.RUN_METRICS !== undefined) features.push(`runMetrics:${env.RUN_METRICS_DATASET ?? "unknown"}`);
|
|
4826
|
+
return features;
|
|
4827
|
+
}
|
|
4828
|
+
|
|
4407
4829
|
/** Every request, once `fetch` has decided whether it gets a root. */
|
|
4408
4830
|
async function handleRequest(request: Request, env: Env, admission: Admission): Promise<Response> {
|
|
4409
4831
|
const url = new URL(request.url);
|
|
4410
4832
|
if (url.pathname === "/healthz" && request.method === "GET")
|
|
4411
|
-
return json({ ok: true, build: BUILD, features:
|
|
4833
|
+
return json({ ok: true, build: BUILD, features: featuresOf(env) });
|
|
4412
4834
|
if (!admission.known) return json({ error: "not found" }, 404);
|
|
4413
4835
|
if (request.method !== "POST") return json({ error: "method not allowed" }, 405);
|
|
4414
4836
|
if (!admission.authorized) return json({ error: "unauthorized" }, 401);
|
|
@@ -4458,6 +4880,7 @@ async function handleRequest(request: Request, env: Env, admission: Admission):
|
|
|
4458
4880
|
return json({ firings });
|
|
4459
4881
|
}
|
|
4460
4882
|
if (url.pathname.startsWith("/runs/")) return handleRuns(url.pathname, body, env);
|
|
4883
|
+
if (url.pathname.startsWith("/plane/")) return handlePlane(url.pathname, body, env);
|
|
4461
4884
|
if (url.pathname.startsWith("/config/")) return handleConfig(url.pathname, body, env);
|
|
4462
4885
|
if (url.pathname.startsWith("/delivery/")) return handleDelivery(url.pathname, body, env);
|
|
4463
4886
|
if (url.pathname.startsWith("/costs/")) return handleCosts(url.pathname, body, env);
|