@coreplane/switchboard 0.0.0 → 1.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +17 -1
  3. package/dist/assets/.dockerignore +27 -0
  4. package/dist/assets/.env.example +33 -0
  5. package/dist/assets/Dockerfile +111 -0
  6. package/dist/assets/config/config.example.yaml +359 -0
  7. package/dist/assets/deploy/bin/build-stamp.d.mts +15 -0
  8. package/dist/assets/deploy/bin/build-stamp.mjs +98 -0
  9. package/dist/assets/deploy/bin/cf-logs +32 -0
  10. package/dist/assets/deploy/cloudflare/package.json +29 -0
  11. package/dist/assets/deploy/cloudflare/preflight.mjs +243 -0
  12. package/dist/assets/deploy/cloudflare/tsconfig.json +18 -0
  13. package/dist/assets/deploy/cloudflare/worker.ts +382 -0
  14. package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +67 -0
  15. package/dist/assets/deploy/cloudflare/write-build.d.mts +7 -0
  16. package/dist/assets/deploy/cloudflare/write-build.mjs +53 -0
  17. package/dist/assets/deploy/cloudflare-docs/package.json +18 -0
  18. package/dist/assets/deploy/cloudflare-docs/wrangler.template.jsonc +30 -0
  19. package/dist/assets/deploy/cloudflare-memory/package.json +25 -0
  20. package/dist/assets/deploy/cloudflare-memory/tsconfig.json +17 -0
  21. package/dist/assets/deploy/cloudflare-memory/worker.ts +2635 -0
  22. package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +50 -0
  23. package/dist/assets/deploy/cloudflare-resident/Dockerfile +91 -0
  24. package/dist/assets/deploy/cloudflare-resident/gc.ts +287 -0
  25. package/dist/assets/deploy/cloudflare-resident/node-async-hooks.d.ts +11 -0
  26. package/dist/assets/deploy/cloudflare-resident/package.json +29 -0
  27. package/dist/assets/deploy/cloudflare-resident/preflight.mjs +224 -0
  28. package/dist/assets/deploy/cloudflare-resident/tsconfig.json +19 -0
  29. package/dist/assets/deploy/cloudflare-resident/worker.ts +6637 -0
  30. package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +120 -0
  31. package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +67 -0
  32. package/dist/assets/deploy/cloudflare-sandbox/docker-wrapper.sh +37 -0
  33. package/dist/assets/deploy/cloudflare-sandbox/package.json +26 -0
  34. package/dist/assets/deploy/cloudflare-sandbox/tsconfig.json +20 -0
  35. package/dist/assets/deploy/cloudflare-sandbox/worker.ts +410 -0
  36. package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +67 -0
  37. package/dist/assets/deploy/profile.example.json +13 -0
  38. package/dist/assets/deploy/secrets.manifest.json +108 -0
  39. package/dist/assets/docker-entrypoint.sh +15 -0
  40. package/dist/assets/package-lock.json +18407 -0
  41. package/dist/assets/package.json +104 -0
  42. package/dist/assets/project.json +219 -0
  43. package/dist/assets/source.json +5 -0
  44. package/dist/assets/src/core/authz/actor.ts +100 -0
  45. package/dist/assets/src/core/authz/authorize.ts +169 -0
  46. package/dist/assets/src/core/authz/grants.ts +347 -0
  47. package/dist/assets/src/core/authz/policy.ts +281 -0
  48. package/dist/assets/src/core/authz/resource.ts +147 -0
  49. package/dist/assets/src/core/authz/types.ts +164 -0
  50. package/dist/assets/src/core/drain.ts +54 -0
  51. package/dist/assets/src/core/ingressTokens.ts +64 -0
  52. package/dist/assets/src/core/memory/engine.ts +115 -0
  53. package/dist/assets/src/core/memory/scorer.ts +147 -0
  54. package/dist/assets/src/core/memory/types.ts +120 -0
  55. package/dist/assets/src/core/normalizeSpans.ts +299 -0
  56. package/dist/assets/src/core/prDescriptionTypes.ts +54 -0
  57. package/dist/assets/src/core/redact.ts +113 -0
  58. package/dist/assets/src/core/runEvents.ts +537 -0
  59. package/dist/assets/src/core/runFriction.ts +665 -0
  60. package/dist/assets/src/core/runLedger/decisions.ts +126 -0
  61. package/dist/assets/src/core/runLedger/types.ts +177 -0
  62. package/dist/assets/src/core/runRecord.ts +627 -0
  63. package/dist/assets/src/core/runShape.ts +61 -0
  64. package/dist/assets/src/core/schedules.ts +452 -0
  65. package/dist/assets/src/core/time/formatDuration.ts +61 -0
  66. package/dist/assets/src/core/trace/attrs.ts +203 -0
  67. package/dist/assets/src/core/trace/classify.ts +49 -0
  68. package/dist/assets/src/core/trace/clock.ts +6 -0
  69. package/dist/assets/src/core/trace/context.ts +9 -0
  70. package/dist/assets/src/core/trace/ids.ts +23 -0
  71. package/dist/assets/src/core/trace/partition.ts +235 -0
  72. package/dist/assets/src/core/trace/sinks.ts +68 -0
  73. package/dist/assets/src/core/trace/streamSpans.ts +163 -0
  74. package/dist/assets/src/core/trace/traceparent.ts +29 -0
  75. package/dist/assets/src/core/trace/tracer.ts +247 -0
  76. package/dist/assets/src/core/trace/types.ts +125 -0
  77. package/dist/assets/src/core/trace/workerTrace.ts +97 -0
  78. package/dist/assets/src/deploy/buildStamp.ts +93 -0
  79. package/dist/assets/src/deploy/liveGate.ts +203 -0
  80. package/dist/assets/src/deploy/profile.ts +162 -0
  81. package/dist/assets/src/deploy/restart.ts +393 -0
  82. package/dist/assets/src/effort.ts +17 -0
  83. package/dist/assets/src/execution/bashTimeout.ts +78 -0
  84. package/dist/assets/src/execution/bindingPurge.ts +43 -0
  85. package/dist/assets/src/execution/residentBackupTransfer.ts +50 -0
  86. package/dist/assets/src/execution/residentCleanliness.ts +95 -0
  87. package/dist/assets/src/execution/residentCredentials.ts +81 -0
  88. package/dist/assets/src/execution/residentDepCache.ts +321 -0
  89. package/dist/assets/src/execution/residentDepsStore.ts +326 -0
  90. package/dist/assets/src/execution/residentDetach.ts +48 -0
  91. package/dist/assets/src/execution/residentDisk.ts +107 -0
  92. package/dist/assets/src/execution/residentDiskBudget.ts +448 -0
  93. package/dist/assets/src/execution/residentExecWrap.ts +100 -0
  94. package/dist/assets/src/execution/residentHead.ts +85 -0
  95. package/dist/assets/src/execution/residentReadonly.ts +72 -0
  96. package/dist/assets/src/execution/residentRefresh.ts +429 -0
  97. package/dist/assets/src/execution/residentRestoreExtract.ts +130 -0
  98. package/dist/assets/src/execution/residentState.ts +47 -0
  99. package/dist/assets/src/execution/residentStepReport.ts +98 -0
  100. package/dist/assets/src/execution/residentStepTrace.ts +97 -0
  101. package/dist/assets/src/execution/residentSteps.ts +99 -0
  102. package/dist/assets/src/execution/residentText.ts +83 -0
  103. package/dist/assets/src/execution/residentTrace.ts +119 -0
  104. package/dist/assets/src/execution/sandboxEnv.ts +42 -0
  105. package/dist/assets/src/execution/sandboxErrors.ts +159 -0
  106. package/dist/assets/src/execution/sandboxKeepalive.ts +118 -0
  107. package/dist/assets/src/execution/shellQuote.ts +8 -0
  108. package/dist/assets/src/mcp/registry.ts +242 -0
  109. package/dist/assets/src/providers/types.ts +152 -0
  110. package/dist/assets/web/dist/.vite/manifest.json +176 -0
  111. package/dist/assets/web/dist/assets/AppShell-Bk2gbvet.js +1 -0
  112. package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +1 -0
  113. package/dist/assets/web/dist/assets/NotFoundPage-C-BuaSm8.js +1 -0
  114. package/dist/assets/web/dist/assets/ResidentDetailPage-DvQ05AGa.js +1 -0
  115. package/dist/assets/web/dist/assets/ResidentsIndexPage-B3uxKUne.js +1 -0
  116. package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
  117. package/dist/assets/web/dist/assets/RunRoutePage-ty94olNM.js +126 -0
  118. package/dist/assets/web/dist/assets/RunsIndexPage-CM-qxyQm.js +1 -0
  119. package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
  120. package/dist/assets/web/dist/assets/ScheduledPage-C1psvLD4.js +1 -0
  121. package/dist/assets/web/dist/assets/StatusDot-DuoQnQeU.js +1 -0
  122. package/dist/assets/web/dist/assets/Tooltip-BfLPyxQy.js +1 -0
  123. package/dist/assets/web/dist/assets/favicon-DL1rdWJt.js +1 -0
  124. package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
  125. package/dist/assets/web/dist/assets/main-Bnbk_Rsg.js +28 -0
  126. package/dist/assets/web/dist/assets/main-BsBGUyMH.css +2 -0
  127. package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +1 -0
  128. package/dist/assets/web/dist/assets/seed-BglCRKLA.js +6 -0
  129. package/dist/assets/web/dist/assets/wallClock-Ckv3sKoR.js +1 -0
  130. package/dist/cli.js +34494 -0
  131. package/package.json +43 -10
@@ -0,0 +1,2635 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+ import type { MemoryCandidate, MemoryRecord } from "../../src/core/memory/types.ts";
3
+ import {
4
+ DEFAULT_SCOPE_CAP,
5
+ mintRecord,
6
+ normalizeText,
7
+ planEviction,
8
+ planWrite,
9
+ rankRecords,
10
+ } from "../../src/core/memory/engine.ts";
11
+ import { tokenize } from "../../src/core/memory/scorer.ts";
12
+ import { FIRING_DETAIL_MAX, isScheduleFiring, type ScheduleFiring } from "../../src/core/schedules.ts";
13
+ import {
14
+ applyRetention,
15
+ clampRetentionPolicy,
16
+ isRunRecord,
17
+ isRunVisibilityFilter,
18
+ normalizeStored,
19
+ RUN_EVENTS_DEFAULT_PAGE,
20
+ RUN_EVENTS_MAX_PAGE,
21
+ RUN_ID_PATTERN,
22
+ clampListLimit,
23
+ RUN_LIST_MAX_LIMIT,
24
+ sameStoredVersion,
25
+ storedEventSeqs,
26
+ utf8ByteLength,
27
+ MAX_EVENT_BYTES,
28
+ type RetentionPolicy,
29
+ type RunListItem,
30
+ type RunListOptions,
31
+ type RunRecord,
32
+ type RunVisibilityFilter,
33
+ type StoredRunEvent,
34
+ } from "../../src/core/runRecord.ts";
35
+ import type { RunEvent } from "../../src/core/runEvents.ts";
36
+ import {
37
+ checkFence,
38
+ decideClaim,
39
+ decideClaimWrite,
40
+ phaseTransition,
41
+ reclaimPhase,
42
+ selectReclaim,
43
+ } from "../../src/core/runLedger/decisions.ts";
44
+ import {
45
+ GEN_PATTERN,
46
+ type ClaimRequest,
47
+ type ClaimResult,
48
+ type FenceResult,
49
+ type LivePhase,
50
+ type LiveRunRow,
51
+ type ReclaimedRun,
52
+ type RunState,
53
+ type StepRecord,
54
+ type StopMode,
55
+ type TranscriptAttachment,
56
+ type TranscriptRow,
57
+ } from "../../src/core/runLedger/types.ts";
58
+ import {
59
+ isMcpTicket,
60
+ isSealedCredential,
61
+ MCP_TICKET_STATES,
62
+ type McpTicket,
63
+ type McpTicketState,
64
+ type SealedCredential,
65
+ } from "../../src/mcp/registry.ts";
66
+ import { injectedBuildStamp } from "../../src/deploy/buildStamp.ts";
67
+ import { systemClock } from "../../src/core/trace/clock.ts";
68
+ import { createTracer } from "../../src/core/trace/tracer.ts";
69
+ import { startAdoptedRoot, workerLogSink } from "../../src/core/trace/workerTrace.ts";
70
+
71
+ /** The commit this bundle was built from, injected by the deploy
72
+ * (`deploy/bin/build-stamp.mjs`) and answered on GET /healthz as `build`. */
73
+ const BUILD = injectedBuildStamp();
74
+
75
+ // The Worker's own spans (docs/reference/specs/tracing.md item 22): one `state.fetch` root
76
+ // per authenticated request, joining the bot's trace, on a `slow` log sink
77
+ // whose filter drops the line a refusal would leave.
78
+ const tracer = createTracer({ clock: systemClock });
79
+ const traceSinks = [workerLogSink((line) => console.log(line))];
80
+
81
+ // Memory Worker: the durable backend behind the bot's WorkerMemoryStore
82
+ // (src/core/memory/workerStore.ts) — cross-session memory (docs/reference/specs/memory.md). One
83
+ // SQLite-backed Durable Object per scopeKey (the DO name IS the scope key), so
84
+ // a scope's records live in one database that survives every bot restart
85
+ // (AGENTS.md invariant 6) and cross-scope reads are impossible by construction.
86
+ //
87
+ // The ranking and dedup/supersede rules are NOT reimplemented here: this file
88
+ // imports the pure engine from src/core/memory/ by relative path (bundled by
89
+ // wrangler), so the durable store and the in-process store run one algorithm.
90
+ // SQLite's job is persistence plus an FTS5 candidate prefilter; the engine
91
+ // re-checks whole-token relevance and orders by keyword+recency.
92
+ //
93
+ // Route surface (JSON in/out; bearer MEMORY_TOKEN on everything but /healthz):
94
+ // POST /retrieve {scopeKey, query, limit} → {records: MemoryRecord[]}
95
+ // POST /write {scopeKey, records: MemoryCandidate[]} → {ok, inserted, deduped, superseded}
96
+ // GET /healthz → {ok:true} (deploy wake ping; touches no DO)
97
+ // Scheduled-firing routes (the record behind the /runs Scheduled panel;
98
+ // written by the bot's Worker shim after every cron firing, read by the bot's
99
+ // WorkerScheduleStore, src/core/scheduleStore.ts): ONE ScheduleDO, bounded per schedule.
100
+ // POST /schedules/record {firing: ScheduleFiring} → {ok:true, retained}
101
+ // POST /schedules/latest {} → {firings: ScheduleFiring[]} (newest per schedule)
102
+ // Run history routes (the durable RunStore behind the bot's WorkerRunStore,
103
+ // src/core/runStoreWorker.ts; docs/reference/specs/run-history.md): one RunHistoryDO per
104
+ // store key, owning the retention policy. Same bearer; /runs/put has its own 2 MiB
105
+ // body fence (a record is budgeted to 1.5 MiB upstream), every other route
106
+ // keeps the 512 KB one.
107
+ // POST /runs/put {storeKey, record, policy?, policyUpdatedAt?} → {ok, retained, stored, rewritten}
108
+ // POST /runs/get {storeKey, id} → {record: RunRecord | null} (unknown/expired: null, 200)
109
+ // POST /runs/list {storeKey, limit?, before?, beforeId?, sinceMs?, agent?, channel?}
110
+ // → {items: RunListItem[], nextBefore?: {finishedAt, id}} (cursor = the last row's list key)
111
+ // POST /runs/events {storeKey, id, afterSeq?, limit?} → {events: (RunEvent & {seq})[] | null, nextAfterSeq?}
112
+ // (`events: null` when the run is unknown or hidden by retention; `seq` is the registry's stamp)
113
+ // POST /runs/delete {storeKey, id} → {ok: true, deleted}
114
+ // GET /healthz → {ok: true, build: {commit, builtAt?}, features: ["memory", "schedules", "runs", "config"]}
115
+ //
116
+ // SECURITY: bearer comparison is constant-time (same helper as the resident
117
+ // Worker); an unset/empty secret grants nothing (fail closed); every body field
118
+ // is validated with size caps before it reaches storage.
119
+
120
+ export interface Env {
121
+ MEMORY: DurableObjectNamespace<MemoryDO>;
122
+ /** Scheduled firings: ONE ScheduleDO (named "schedules") — the record behind the /runs Scheduled panel. */
123
+ SCHEDULES: DurableObjectNamespace<ScheduleDO>;
124
+ /** Run history: one RunHistoryDO per store key (`runs:default`). */
125
+ RUNS: DurableObjectNamespace<RunHistoryDO>;
126
+ /** Runtime config documents (routing-and-config item 12): ONE ConfigDO (named "config"). */
127
+ CONFIG: DurableObjectNamespace<ConfigDO>;
128
+ /** Live-run transcripts (run-history item 32): one RunTranscriptDO per live run, named by run id. */
129
+ RUN_TRANSCRIPTS: DurableObjectNamespace<RunTranscriptDO>;
130
+ MEMORY_TOKEN?: string;
131
+ }
132
+
133
+ /** The single ConfigDO's name. */
134
+ const CONFIG_OBJECT = "config";
135
+ /** A config document key: short, lowercase, like `overrides`. */
136
+ const CONFIG_KEY_RE = /^[a-z][a-z0-9-]{0,63}$/;
137
+
138
+ /** The single ScheduleDO's name — every schedule's firings live in one object. */
139
+ const SCHEDULES_OBJECT = "schedules";
140
+
141
+ /** Retrieval limit ceiling (the bot's default is 8). */
142
+ const MAX_LIMIT = 50;
143
+ /** Candidates per write batch (the reflection pass emits ≤6). */
144
+ const MAX_BATCH = 50;
145
+ const MAX_TEXT_CHARS = 4000;
146
+ const MAX_KEYWORDS = 20;
147
+ const MAX_KEYWORD_CHARS = 64;
148
+ const MAX_QUERY_CHARS = 4000;
149
+ const MAX_KEY_CHARS = 200;
150
+ /** FTS candidate pool per retrieval: ~5× the requested limit gives the engine's
151
+ * re-rank slack to disagree with bm25, and the floor hands the engine EVERY
152
+ * match in a scope with ≤50 hits — small scopes rank exactly as the engine
153
+ * alone decides. Was a flat 500 recency-ordered rows; ordering candidates by
154
+ * bm25 instead means a relevant-but-old record can no longer be starved out
155
+ * of the pool by recent weak matches. */
156
+ const FTS_CANDIDATES_PER_LIMIT = 5;
157
+ const FTS_CANDIDATES_FLOOR = 50;
158
+ /** MATCH terms per query: the N longest distinct tokens (ties by first
159
+ * appearance). A 4000-char query would otherwise become a several-hundred-term
160
+ * OR the FTS index must union on every retrieval; longer tokens are the
161
+ * selective ones — the `[a-z0-9]+` tokenizer's 1–3-char tokens are mostly
162
+ * stopwords ("a", "the", "to"). Realistic queries have far fewer distinct
163
+ * tokens and are untouched; the engine still ranks with the FULL query, so the
164
+ * cap only shapes which rows can become candidates. */
165
+ const MAX_MATCH_TOKENS = 24;
166
+ /** Request body ceiling, checked against Content-Length before parsing. A full
167
+ * batch (50 × 4000-char texts + keywords + envelope) fits comfortably. */
168
+ const MAX_BODY_BYTES = 512 * 1024;
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Durable Object: one per scopeKey
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /** A stored row. `keywords` is JSON text; nullable optionals are NULL. (A type
175
+ * alias, not an interface: SqlStorage's row constraint needs the implicit
176
+ * index signature only aliases get.) */
177
+ type Row = {
178
+ id: string;
179
+ seq: number;
180
+ scope_key: string;
181
+ kind: string;
182
+ text: string;
183
+ keywords: string;
184
+ source_thread_key: string;
185
+ source_run_id: string | null;
186
+ created_at: number;
187
+ last_used_at: number | null;
188
+ use_count: number;
189
+ confidence: number | null;
190
+ supersedes: string | null;
191
+ status: string;
192
+ };
193
+
194
+ export class MemoryDO extends DurableObject<Env> {
195
+ private readonly sql: SqlStorage;
196
+
197
+ constructor(ctx: DurableObjectState, env: Env) {
198
+ super(ctx, env);
199
+ this.sql = ctx.storage.sql;
200
+ // Idempotent schema — CREATE … IF NOT EXISTS is this DO's one migration
201
+ // path, re-applied on every start and safe over live data. `norm` is the
202
+ // dedup key (normalizeText) so a dedup lookup is an indexed hit;
203
+ // records_active_seq serves list()'s newest-first page and
204
+ // records_active_used the status-prefixed scans (active count, eviction
205
+ // fetch); records_fts holds text + keywords for the
206
+ // whole-token candidate prefilter (unicode61 tokenizer ≈ the engine's
207
+ // tokenize; the engine re-verifies every hit).
208
+ this.sql.exec(`
209
+ CREATE TABLE IF NOT EXISTS records (
210
+ id TEXT PRIMARY KEY,
211
+ seq INTEGER NOT NULL,
212
+ scope_key TEXT NOT NULL,
213
+ kind TEXT NOT NULL,
214
+ text TEXT NOT NULL,
215
+ norm TEXT NOT NULL,
216
+ keywords TEXT NOT NULL,
217
+ source_thread_key TEXT NOT NULL,
218
+ source_run_id TEXT,
219
+ created_at INTEGER NOT NULL,
220
+ last_used_at INTEGER,
221
+ use_count INTEGER NOT NULL DEFAULT 0,
222
+ confidence REAL,
223
+ supersedes TEXT,
224
+ status TEXT NOT NULL
225
+ );
226
+ CREATE INDEX IF NOT EXISTS records_status_norm ON records(status, norm);
227
+ CREATE INDEX IF NOT EXISTS records_active_seq ON records(status, seq DESC);
228
+ CREATE INDEX IF NOT EXISTS records_active_used ON records(status, last_used_at DESC, created_at DESC);
229
+ CREATE VIRTUAL TABLE IF NOT EXISTS records_fts USING fts5(id UNINDEXED, body);
230
+ `);
231
+ this.reconcileFts();
232
+ }
233
+
234
+ /** Reconcile records_fts down to exactly the active rows. Forget, supersede,
235
+ * and evict delete their FTS entry inline; this is the one-time cleanup of
236
+ * the dead rows older deploys left behind, kept on every start as a
237
+ * self-healing invariant. Idempotent, and O(active rows) once clean (the
238
+ * scan is over the FTS table, which then holds only active rows — bounded by
239
+ * the scope cap), so it stays cheap forever. Returns rows removed. */
240
+ reconcileFts(): number {
241
+ return this.sql.exec(`DELETE FROM records_fts WHERE id NOT IN (SELECT id FROM records WHERE status = 'active')`)
242
+ .rowsWritten;
243
+ }
244
+
245
+ /** Rank the scope's active records for `query` (engine rules), bump usage on
246
+ * the returned ones, return them. */
247
+ async retrieve(scopeKey: string, query: string, limit: number): Promise<MemoryRecord[]> {
248
+ const match = ftsMatchExpr(query);
249
+ if (match === null) return [];
250
+ // Candidates ordered by bm25 (best match first — fts5's bm25() is
251
+ // more-negative-is-better, so ascending), NOT by recency: recency ordering
252
+ // let recent weak matches starve a relevant-but-old record out of the pool
253
+ // before the engine ever saw it. bm25 only chooses which rows reach the
254
+ // engine; the shared rankRecords still decides the final order — one
255
+ // algorithm with the in-process store.
256
+ const rows = this.sql
257
+ .exec<Row>(
258
+ `SELECT r.* FROM records r
259
+ JOIN records_fts f ON f.id = r.id
260
+ WHERE r.status = 'active' AND records_fts MATCH ?
261
+ ORDER BY bm25(records_fts)
262
+ LIMIT ?`,
263
+ match,
264
+ Math.max(FTS_CANDIDATES_FLOOR, limit * FTS_CANDIDATES_PER_LIMIT),
265
+ )
266
+ .toArray();
267
+ const now = systemClock();
268
+ const ranked = rankRecords(rows.map(toRecord), query, now, limit);
269
+ if (ranked.length > 0) {
270
+ // One batched usage bump for the returned set (ids are server-minted and
271
+ // parameterized; at most MAX_LIMIT of them), not a statement per row.
272
+ this.sql.exec(
273
+ `UPDATE records SET last_used_at = ?, use_count = use_count + 1 WHERE id IN (${ranked.map(() => "?").join(", ")})`,
274
+ now,
275
+ ...ranked.map((r) => r.id),
276
+ );
277
+ for (const r of ranked) {
278
+ r.lastUsedAt = now;
279
+ r.useCount += 1;
280
+ }
281
+ }
282
+ return ranked;
283
+ }
284
+
285
+ /** Apply the engine's write plan per candidate against the scope's ACTIVE
286
+ * rows.
287
+ *
288
+ * Atomicity rests on two explicit facts, not on luck:
289
+ * 1. The whole batch runs inside `transactionSync`: the read of active rows,
290
+ * the `MAX(seq)+1` base, and every UPDATE/INSERT commit together or not
291
+ * at all — an isolate evicted mid-batch can never leave a superseded row
292
+ * without its correction, and every statement inside is synchronous.
293
+ * 2. A DO executes one JS turn at a time; with no `await` anywhere in this
294
+ * method (transactionSync forbids one) no other request on this scope can
295
+ * interleave between the seq read and the inserts. (Input gates are NOT
296
+ * the mechanism — they only fence async storage writes.)
297
+ * The concurrent-writers test in worker.test.ts guards both. */
298
+ async write(
299
+ scopeKey: string,
300
+ candidates: MemoryCandidate[],
301
+ cap: number = DEFAULT_SCOPE_CAP,
302
+ ): Promise<{ inserted: number; deduped: number; superseded: number; evicted: number }> {
303
+ const counts = { inserted: 0, deduped: 0, superseded: 0, evicted: 0 };
304
+ if (candidates.length === 0) return counts;
305
+ this.ctx.storage.transactionSync(() => {
306
+ let seq = this.sql.exec<{ next: number }>(`SELECT COALESCE(MAX(seq), -1) + 1 AS next FROM records`).one().next;
307
+ const now = systemClock();
308
+ for (const cand of candidates) {
309
+ // Targeted lookups, never a full-active scan: planWrite
310
+ // only ever inspects (a) the active row `supersedes` names — its
311
+ // supersede target AND its whole dedup pool — or (b) the active rows
312
+ // whose norm equals the candidate's (the dedup key, an indexed hit on
313
+ // records_status_norm; ordered by seq so with duplicate-norm actives —
314
+ // the engine's collision case — the earliest still takes the dedup
315
+ // bump, exactly as the full-set scan did). Reads inside transactionSync
316
+ // see the batch's own earlier inserts and flips, so later candidates
317
+ // still dedup/supersede against them. The branch matches planWrite's
318
+ // own TRUTHINESS test: `supersedes: ""` passes validation but means NO
319
+ // supersede to the engine, so it must dedup against the norm pool —
320
+ // an `!== undefined` branch here would hand it an empty pool and
321
+ // insert a duplicate active row.
322
+ const relevant = (
323
+ cand.supersedes
324
+ ? this.sql.exec<Row>(`SELECT * FROM records WHERE id = ? AND status = 'active'`, cand.supersedes)
325
+ : this.sql.exec<Row>(
326
+ `SELECT * FROM records WHERE status = 'active' AND norm = ? ORDER BY seq`,
327
+ normalizeText(cand.text),
328
+ )
329
+ )
330
+ .toArray()
331
+ .map(toRecord);
332
+ const plan = planWrite(relevant, cand, (c) => mintRecord(scopeKey, seq++, now, c));
333
+ if (plan.action === "dedup") {
334
+ this.sql.exec(`UPDATE records SET use_count = use_count + 1 WHERE id = ?`, plan.target.id);
335
+ counts.deduped++;
336
+ continue;
337
+ }
338
+ if (plan.supersede) {
339
+ this.sql.exec(`UPDATE records SET status = 'superseded' WHERE id = ?`, plan.supersede.id);
340
+ // Soft delete for the record row, hard delete for its FTS entry: a
341
+ // superseded row must stop matching queries at the source.
342
+ this.sql.exec(`DELETE FROM records_fts WHERE id = ?`, plan.supersede.id);
343
+ counts.superseded++;
344
+ }
345
+ const r = plan.record;
346
+ this.sql.exec(
347
+ `INSERT INTO records (id, seq, scope_key, kind, text, norm, keywords, source_thread_key, source_run_id,
348
+ created_at, last_used_at, use_count, confidence, supersedes, status)
349
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 0, ?, ?, 'active')`,
350
+ r.id,
351
+ seq - 1,
352
+ r.scopeKey,
353
+ r.kind,
354
+ r.text,
355
+ normalizeText(r.text),
356
+ JSON.stringify(r.keywords),
357
+ r.sourceThreadKey,
358
+ r.sourceRunId ?? null,
359
+ r.createdAt,
360
+ r.confidence ?? null,
361
+ r.supersedes ?? null,
362
+ );
363
+ this.sql.exec(`INSERT INTO records_fts (id, body) VALUES (?, ?)`, r.id, `${r.text} ${r.keywords.join(" ")}`);
364
+ counts.inserted++;
365
+ }
366
+ // Per-scope cap, inside the same transaction: the batch never
367
+ // commits with the scope over the cap. Soft delete — rows stay (their
368
+ // FTS entries do not). The full active set is fetched only when
369
+ // the indexed COUNT says the scope is over the cap — the common
370
+ // under-cap batch does no full scan.
371
+ const activeCount = this.sql
372
+ .exec<{ n: number }>(`SELECT COUNT(*) AS n FROM records WHERE status = 'active'`)
373
+ .one().n;
374
+ if (activeCount > cap) {
375
+ const active = this.sql.exec<Row>(`SELECT * FROM records WHERE status = 'active'`).toArray().map(toRecord);
376
+ for (const victim of planEviction(active, cap)) {
377
+ this.sql.exec(`UPDATE records SET status = 'evicted' WHERE id = ? AND status = 'active'`, victim.id);
378
+ this.sql.exec(`DELETE FROM records_fts WHERE id = ?`, victim.id);
379
+ counts.evicted++;
380
+ }
381
+ }
382
+ });
383
+ return counts;
384
+ }
385
+
386
+ /** Human view (docs/reference/specs/memory.md item 24): the scope's ACTIVE rows, newest first, no usage
387
+ * bump. With `query`, only rows an FTS token hits (the same quoted-OR MATCH
388
+ * as retrieve, so user text never reaches the FTS parser as syntax); a
389
+ * query with no tokens lists nothing. */
390
+ async list(_scopeKey: string, limit: number, query?: string): Promise<MemoryRecord[]> {
391
+ if (query === undefined) {
392
+ return this.sql
393
+ .exec<Row>(`SELECT * FROM records WHERE status = 'active' ORDER BY seq DESC LIMIT ?`, limit)
394
+ .toArray()
395
+ .map(toRecord);
396
+ }
397
+ const match = ftsMatchExpr(query);
398
+ if (match === null) return [];
399
+ return this.sql
400
+ .exec<Row>(
401
+ `SELECT r.* FROM records r
402
+ JOIN records_fts f ON f.id = r.id
403
+ WHERE r.status = 'active' AND records_fts MATCH ?
404
+ ORDER BY r.seq DESC
405
+ LIMIT ?`,
406
+ match,
407
+ limit,
408
+ )
409
+ .toArray()
410
+ .map(toRecord);
411
+ }
412
+
413
+ /** Human control: soft-delete one ACTIVE row (`status = 'forgotten'`;
414
+ * the row and its provenance stay). Returns whether a row changed. The DO
415
+ * IS the scope, so an id from another scope simply matches nothing here. */
416
+ async forget(_scopeKey: string, id: string): Promise<boolean> {
417
+ return this.ctx.storage.transactionSync(() => {
418
+ const flipped =
419
+ this.sql.exec(`UPDATE records SET status = 'forgotten' WHERE id = ? AND status = 'active'`, id).rowsWritten > 0;
420
+ // Soft delete for the record row, hard delete for its FTS entry:
421
+ // one sync transaction, so no crash can strand a dead FTS row (and the
422
+ // start-time reconciliation would heal it anyway).
423
+ if (flipped) this.sql.exec(`DELETE FROM records_fts WHERE id = ?`, id);
424
+ return flipped;
425
+ });
426
+ }
427
+ }
428
+
429
+ /** Build the FTS5 MATCH expression for a query: each engine token (`[a-z0-9]+`
430
+ * by construction) quoted and OR-joined, so operators, parentheses and colons
431
+ * in user text can never reach the FTS query parser as syntax. At most the
432
+ * MAX_MATCH_TOKENS longest distinct tokens are used (see the constant's note);
433
+ * `null` when the query has no tokens. Shared by retrieve and list — the one
434
+ * place user text becomes a MATCH. */
435
+ function ftsMatchExpr(query: string): string | null {
436
+ const tokens = [...new Set(tokenize(query))];
437
+ if (tokens.length === 0) return null;
438
+ const kept =
439
+ tokens.length <= MAX_MATCH_TOKENS
440
+ ? tokens
441
+ : tokens
442
+ .map((t, i) => [t, i] as const)
443
+ .sort((a, b) => b[0].length - a[0].length || a[1] - b[1])
444
+ .slice(0, MAX_MATCH_TOKENS)
445
+ .map(([t]) => t);
446
+ return kept.map((t) => `"${t}"`).join(" OR ");
447
+ }
448
+
449
+ /** Row → wire record. Optional fields are OMITTED when NULL (never `null` on
450
+ * the wire — the bot's record type has them as `?: T`). */
451
+ function toRecord(row: Row): MemoryRecord {
452
+ const r: MemoryRecord = {
453
+ id: row.id,
454
+ scopeKey: row.scope_key,
455
+ kind: row.kind as MemoryRecord["kind"],
456
+ text: row.text,
457
+ keywords: JSON.parse(row.keywords) as string[],
458
+ sourceThreadKey: row.source_thread_key,
459
+ createdAt: row.created_at,
460
+ useCount: row.use_count,
461
+ status: row.status as MemoryRecord["status"],
462
+ };
463
+ if (row.source_run_id !== null) r.sourceRunId = row.source_run_id;
464
+ if (row.last_used_at !== null) r.lastUsedAt = row.last_used_at;
465
+ if (row.confidence !== null) r.confidence = row.confidence;
466
+ if (row.supersedes !== null) r.supersedes = row.supersedes;
467
+ return r;
468
+ }
469
+
470
+ // ---------------------------------------------------------------------------
471
+ // Scheduled firings — the durable record behind the /runs Scheduled panel
472
+ // ---------------------------------------------------------------------------
473
+
474
+ /** Firings kept per schedule; the oldest fall off. A weekly job needs ~2 years. */
475
+ const SCHEDULE_MAX_FIRINGS = 100;
476
+
477
+ type FiringRow = {
478
+ record: string;
479
+ };
480
+
481
+ /**
482
+ * ScheduleDO: one SQLite Durable Object holding every schedule's firings. The
483
+ * Worker shim (deploy/cloudflare/worker.ts) appends one `ScheduleFiring` per
484
+ * cron firing — including firings that produced NO run (misconfigured, ingress
485
+ * error) — and the bot's /runs page reads the newest per schedule. Append-only
486
+ * per firing (two firings at one instant are two rows; the later write is the
487
+ * later id), bounded per schedule.
488
+ */
489
+ export class ScheduleDO extends DurableObject<Env> {
490
+ private readonly sql: SqlStorage;
491
+
492
+ constructor(ctx: DurableObjectState, env: Env) {
493
+ super(ctx, env);
494
+ this.sql = ctx.storage.sql;
495
+ this.sql.exec(`
496
+ CREATE TABLE IF NOT EXISTS firings (
497
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
498
+ schedule TEXT NOT NULL,
499
+ fired_at INTEGER NOT NULL,
500
+ record TEXT NOT NULL
501
+ );
502
+ CREATE INDEX IF NOT EXISTS firings_schedule_time ON firings(schedule, fired_at DESC, id DESC);
503
+ `);
504
+ }
505
+
506
+ /** Append one firing, then trim that schedule to the newest SCHEDULE_MAX_FIRINGS.
507
+ * One sync transaction. Returns how many firings the schedule retains. */
508
+ async record(firing: ScheduleFiring): Promise<number> {
509
+ let retained = 0;
510
+ this.ctx.storage.transactionSync(() => {
511
+ this.sql.exec(
512
+ `INSERT INTO firings (schedule, fired_at, record) VALUES (?, ?, ?)`,
513
+ firing.schedule,
514
+ firing.firedAt,
515
+ JSON.stringify(firing),
516
+ );
517
+ this.sql.exec(
518
+ `DELETE FROM firings WHERE schedule = ? AND id NOT IN (
519
+ SELECT id FROM firings WHERE schedule = ? ORDER BY fired_at DESC, id DESC LIMIT ?)`,
520
+ firing.schedule,
521
+ firing.schedule,
522
+ SCHEDULE_MAX_FIRINGS,
523
+ );
524
+ retained = this.sql
525
+ .exec<{ n: number }>(`SELECT COUNT(*) AS n FROM firings WHERE schedule = ?`, firing.schedule)
526
+ .one().n;
527
+ });
528
+ return retained;
529
+ }
530
+
531
+ /** The newest firing of every schedule (by fired_at, then insertion order). */
532
+ async latest(): Promise<ScheduleFiring[]> {
533
+ const rows = this.sql
534
+ .exec<FiringRow>(
535
+ `SELECT f.record AS record FROM firings f
536
+ WHERE f.id = (SELECT g.id FROM firings g WHERE g.schedule = f.schedule ORDER BY g.fired_at DESC, g.id DESC LIMIT 1)
537
+ ORDER BY f.schedule`,
538
+ )
539
+ .toArray();
540
+ const out: ScheduleFiring[] = [];
541
+ for (const row of rows) {
542
+ try {
543
+ const parsed: unknown = JSON.parse(row.record);
544
+ if (isScheduleFiring(parsed)) out.push(parsed);
545
+ } catch {
546
+ // a corrupt row is skipped, never fatal
547
+ }
548
+ }
549
+ return out;
550
+ }
551
+ }
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // Durable Object: runtime config documents (docs/reference/specs/routing-and-config.md item
555
+ // 10). ONE object, a table of small JSON documents by key — today the bot's
556
+ // `overrides` document (chat-set channel/user settings) — each with a version
557
+ // for optimistic concurrency: a `put` whose `expectedVersion` is stale is a 409,
558
+ // never a silent clobber (the bot and the CLI both write this document).
559
+
560
+ export class ConfigDO extends DurableObject<Env> {
561
+ private readonly sql: SqlStorage;
562
+
563
+ constructor(ctx: DurableObjectState, env: Env) {
564
+ super(ctx, env);
565
+ this.sql = ctx.storage.sql;
566
+ this.sql.exec(`
567
+ CREATE TABLE IF NOT EXISTS documents (
568
+ key TEXT PRIMARY KEY,
569
+ version INTEGER NOT NULL,
570
+ body TEXT NOT NULL,
571
+ updated_at INTEGER NOT NULL
572
+ );
573
+ CREATE TABLE IF NOT EXISTS secrets (
574
+ server_id TEXT PRIMARY KEY,
575
+ sealed TEXT NOT NULL
576
+ );
577
+ CREATE TABLE IF NOT EXISTS tickets (
578
+ nonce TEXT PRIMARY KEY,
579
+ server_id TEXT NOT NULL,
580
+ expires_at INTEGER NOT NULL,
581
+ ticket TEXT NOT NULL
582
+ );
583
+ `);
584
+ }
585
+
586
+ // ---- MCP sealed credentials + connect tickets (docs/reference/specs/mcp-tools.md items 15–16).
587
+ // Ciphertext the bot sealed — opaque here — and one-time tickets: the two
588
+ // things a config document must never carry, kept beside it on this object.
589
+
590
+ async putSecret(sealed: SealedCredential): Promise<void> {
591
+ this.sql.exec(
592
+ `INSERT OR REPLACE INTO secrets (server_id, sealed) VALUES (?, ?)`,
593
+ sealed.serverId,
594
+ JSON.stringify(sealed),
595
+ );
596
+ }
597
+
598
+ async getSecret(serverId: string): Promise<SealedCredential | null> {
599
+ const row = this.sql
600
+ .exec<{ sealed: string }>(`SELECT sealed FROM secrets WHERE server_id = ?`, serverId)
601
+ .toArray()[0];
602
+ return row ? parseStored(row.sealed, isSealedCredential) : null;
603
+ }
604
+
605
+ async deleteSecret(serverId: string): Promise<boolean> {
606
+ const had = this.sql.exec<{ n: number }>(`SELECT COUNT(*) AS n FROM secrets WHERE server_id = ?`, serverId).one().n;
607
+ this.sql.exec(`DELETE FROM secrets WHERE server_id = ?`, serverId);
608
+ return had > 0;
609
+ }
610
+
611
+ /** Insert or replace; tickets expired more than a day ago are swept on every write. */
612
+ async putTicket(ticket: McpTicket, now: number): Promise<void> {
613
+ this.ctx.storage.transactionSync(() => {
614
+ this.sql.exec(
615
+ `INSERT OR REPLACE INTO tickets (nonce, server_id, expires_at, ticket) VALUES (?, ?, ?, ?)`,
616
+ ticket.nonce,
617
+ ticket.serverId,
618
+ ticket.expiresAt,
619
+ JSON.stringify(ticket),
620
+ );
621
+ this.sql.exec(`DELETE FROM tickets WHERE expires_at < ?`, now - 24 * 3600_000);
622
+ });
623
+ }
624
+
625
+ async getTicket(nonce: string): Promise<McpTicket | null> {
626
+ const row = this.sql.exec<{ ticket: string }>(`SELECT ticket FROM tickets WHERE nonce = ?`, nonce).toArray()[0];
627
+ return row ? parseStored(row.ticket, isMcpTicket) : null;
628
+ }
629
+
630
+ /** Compare-and-swap: write `ticket` only while the stored row is still in
631
+ * `fromState`. One transaction on a single-threaded object, so of two
632
+ * concurrent opens/completions exactly one is applied — "single-use" is a
633
+ * property of the store, not of request timing. */
634
+ async transitionTicket(ticket: McpTicket, fromState: McpTicketState): Promise<boolean> {
635
+ return this.ctx.storage.transactionSync(() => {
636
+ const row = this.sql
637
+ .exec<{ ticket: string }>(`SELECT ticket FROM tickets WHERE nonce = ?`, ticket.nonce)
638
+ .toArray()[0];
639
+ const stored = row ? parseStored(row.ticket, isMcpTicket) : null;
640
+ if (!stored || stored.state !== fromState) return false;
641
+ this.sql.exec(
642
+ `UPDATE tickets SET server_id = ?, expires_at = ?, ticket = ? WHERE nonce = ?`,
643
+ ticket.serverId,
644
+ ticket.expiresAt,
645
+ JSON.stringify(ticket),
646
+ ticket.nonce,
647
+ );
648
+ return true;
649
+ });
650
+ }
651
+
652
+ async get(key: string): Promise<{ document: unknown; version: number }> {
653
+ const row = this.sql
654
+ .exec<{ version: number; body: string }>(`SELECT version, body FROM documents WHERE key = ?`, key)
655
+ .toArray()[0];
656
+ if (!row) return { document: null, version: 0 };
657
+ try {
658
+ return { document: JSON.parse(row.body) as unknown, version: row.version };
659
+ } catch {
660
+ return { document: null, version: row.version };
661
+ }
662
+ }
663
+
664
+ /** Replace the document iff its stored version equals `expectedVersion`
665
+ * (0 = not yet stored). Returns the new version, or the current one on conflict. */
666
+ async put(
667
+ key: string,
668
+ document: unknown,
669
+ expectedVersion: number,
670
+ now: number,
671
+ ): Promise<{ ok: true; version: number } | { ok: false; version: number }> {
672
+ let outcome: { ok: true; version: number } | { ok: false; version: number } = { ok: false, version: 0 };
673
+ this.ctx.storage.transactionSync(() => {
674
+ const row = this.sql.exec<{ version: number }>(`SELECT version FROM documents WHERE key = ?`, key).toArray()[0];
675
+ const current = row?.version ?? 0;
676
+ if (current !== expectedVersion) {
677
+ outcome = { ok: false, version: current };
678
+ return;
679
+ }
680
+ const next = current + 1;
681
+ this.sql.exec(
682
+ `INSERT OR REPLACE INTO documents (key, version, body, updated_at) VALUES (?, ?, ?, ?)`,
683
+ key,
684
+ next,
685
+ JSON.stringify(document),
686
+ now,
687
+ );
688
+ outcome = { ok: true, version: next };
689
+ });
690
+ return outcome;
691
+ }
692
+ }
693
+
694
+ function parseStored<T>(text: string, guard: (v: unknown) => v is T): T | null {
695
+ try {
696
+ const parsed: unknown = JSON.parse(text);
697
+ return guard(parsed) ? parsed : null;
698
+ } catch {
699
+ return null;
700
+ }
701
+ }
702
+
703
+ /** Documents are small; a body over this is refused before storage. */
704
+ const MAX_CONFIG_DOCUMENT_BYTES = 256 * 1024;
705
+
706
+ const CONFIG_ROUTES = new Set([
707
+ "/config/get",
708
+ "/config/put",
709
+ "/config/secrets/put",
710
+ "/config/secrets/get",
711
+ "/config/secrets/delete",
712
+ "/config/tickets/put",
713
+ "/config/tickets/get",
714
+ "/config/tickets/transition",
715
+ ]);
716
+ const TICKET_STATES: ReadonlySet<string> = new Set<McpTicketState>(MCP_TICKET_STATES);
717
+
718
+ async function handleConfig(pathname: string, body: unknown, env: Env): Promise<Response> {
719
+ const b = (typeof body === "object" && body !== null ? body : {}) as Record<string, unknown>;
720
+ const dO = env.CONFIG.get(env.CONFIG.idFromName(CONFIG_OBJECT));
721
+ // MCP secrets + tickets (opaque to this Worker beyond shape).
722
+ switch (pathname) {
723
+ case "/config/secrets/put": {
724
+ if (!isSealedCredential(b.sealed)) return json({ error: "sealed must be a SealedCredential" }, 400);
725
+ await dO.putSecret(b.sealed);
726
+ console.log(`[config/secrets/put] ${b.sealed.serverId} key=${b.sealed.keyId}`);
727
+ return json({ ok: true });
728
+ }
729
+ case "/config/secrets/get": {
730
+ if (typeof b.serverId !== "string" || !b.serverId) return json({ error: "serverId required" }, 400);
731
+ return json({ sealed: await dO.getSecret(b.serverId) });
732
+ }
733
+ case "/config/secrets/delete": {
734
+ if (typeof b.serverId !== "string" || !b.serverId) return json({ error: "serverId required" }, 400);
735
+ return json({ ok: true, removed: await dO.deleteSecret(b.serverId) });
736
+ }
737
+ case "/config/tickets/put": {
738
+ if (!isMcpTicket(b.ticket)) return json({ error: "ticket must be an McpTicket" }, 400);
739
+ await dO.putTicket(b.ticket, systemClock());
740
+ console.log(`[config/tickets/put] ${b.ticket.serverId} state=${b.ticket.state}`);
741
+ return json({ ok: true });
742
+ }
743
+ case "/config/tickets/get": {
744
+ if (typeof b.nonce !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(b.nonce))
745
+ return json({ error: "nonce malformed" }, 400);
746
+ return json({ ticket: await dO.getTicket(b.nonce) });
747
+ }
748
+ case "/config/tickets/transition": {
749
+ if (!isMcpTicket(b.ticket)) return json({ error: "ticket must be an McpTicket" }, 400);
750
+ if (typeof b.fromState !== "string" || !TICKET_STATES.has(b.fromState))
751
+ return json({ error: "fromState must be a ticket state" }, 400);
752
+ const applied = await dO.transitionTicket(b.ticket, b.fromState as McpTicketState);
753
+ console.log(
754
+ `[config/tickets/transition] ${b.ticket.serverId} ${b.fromState}→${b.ticket.state} applied=${applied}`,
755
+ );
756
+ return json({ ok: true, applied });
757
+ }
758
+ default:
759
+ break;
760
+ }
761
+ if (typeof b.key !== "string" || !CONFIG_KEY_RE.test(b.key))
762
+ return json({ error: "key must be a short lowercase slug" }, 400);
763
+ if (pathname === "/config/get") {
764
+ return json(await dO.get(b.key));
765
+ }
766
+ if (pathname === "/config/put") {
767
+ if (typeof b.document !== "object" || b.document === null || Array.isArray(b.document))
768
+ return json({ error: "document must be a JSON object" }, 400);
769
+ if (typeof b.expectedVersion !== "number" || !Number.isInteger(b.expectedVersion) || b.expectedVersion < 0)
770
+ return json({ error: "expectedVersion must be a non-negative integer" }, 400);
771
+ if (new TextEncoder().encode(JSON.stringify(b.document)).byteLength > MAX_CONFIG_DOCUMENT_BYTES)
772
+ return json({ error: `document must be at most ${MAX_CONFIG_DOCUMENT_BYTES} bytes` }, 413);
773
+ const out = await dO.put(b.key, b.document, b.expectedVersion, systemClock());
774
+ if (!out.ok) return json({ error: "version conflict", version: out.version }, 409);
775
+ console.log(`[config/put] ${b.key} v${out.version}`);
776
+ return json({ ok: true, version: out.version });
777
+ }
778
+ return json({ error: "not found" }, 404);
779
+ }
780
+
781
+ function parseScheduleFiring(body: unknown): Validated<ScheduleFiring> {
782
+ if (typeof body !== "object" || body === null) return invalid("body must be a JSON object");
783
+ const f = (body as Record<string, unknown>).firing;
784
+ if (!isScheduleFiring(f))
785
+ return invalid("firing must be a ScheduleFiring (schedule, firedAt, outcome[, runId, detail])");
786
+ if (f.schedule.length > MAX_KEY_CHARS) return invalid(`firing.schedule must be at most ${MAX_KEY_CHARS} characters`);
787
+ if (f.runId !== undefined && f.runId.length > MAX_KEY_CHARS)
788
+ return invalid(`firing.runId must be at most ${MAX_KEY_CHARS} characters`);
789
+ if (f.detail !== undefined && f.detail.length > FIRING_DETAIL_MAX)
790
+ return invalid(`firing.detail must be at most ${FIRING_DETAIL_MAX} characters`);
791
+ return { ok: true, value: f };
792
+ }
793
+
794
+ // ---------------------------------------------------------------------------
795
+ // Durable Object: one run history per store key
796
+ // ---------------------------------------------------------------------------
797
+
798
+ // Cloudflare Durable Object SQLite limits (developers.cloudflare.com/durable-objects/platform/limits/;
799
+ // re-read them when a bound below looks wrong): 100 bound parameters per query; 100 KB per SQL statement;
800
+ // 2 MB per string/BLOB/row; 100 columns per table; 10 GB storage per object
801
+ // (Workers Paid). Consequences here: event inserts carry 3 parameters per row,
802
+ // so a batch is 33 rows (99 parameters); deletions by id list are batched at
803
+ // 100 ids; an event is capped to 64 KiB upstream (MAX_EVENT_BYTES) so no row
804
+ // nears 2 MB; and `maxBytes` is clamped to 8 GiB (RETENTION_BOUNDS), under the
805
+ // 10 GB per-object ceiling.
806
+ const DO_MAX_BOUND_PARAMETERS = 100;
807
+ /** Rows per `INSERT INTO run_events` statement: floor(100 / 3 parameters). */
808
+ export const RUN_EVENT_INSERT_BATCH = Math.floor(DO_MAX_BOUND_PARAMETERS / 3);
809
+ /** Ids per `DELETE ... WHERE run_id IN (...)` statement. */
810
+ const RUN_DELETE_BATCH = DO_MAX_BOUND_PARAMETERS;
811
+ /** Rows a single `put` may delete while trimming (the deletion fence): a
812
+ * policy shrink dropping thousands of runs is spread over successive puts and
813
+ * the 6 h alarm, so no single write stalls. Reads hide them immediately. */
814
+ const RUN_TRIM_FENCE = 500;
815
+ /** How often `alarm()` sweeps everything outside the retention policy. */
816
+ const RUN_SWEEP_INTERVAL_MS = 6 * 3600_000;
817
+ /** `finishedAt` further ahead of the DO clock than this is clamped (a skewed bot clock). */
818
+ const RUN_MAX_FUTURE_MS = 24 * 3600_000;
819
+ /** Request body ceiling for `/runs/put` (a record is budgeted to 1.5 MiB upstream). */
820
+ const MAX_RUN_PUT_BODY_BYTES = 2 * 1024 * 1024;
821
+ const POLICY_KEY = "policy";
822
+
823
+ type RunRow = {
824
+ run_id: string;
825
+ agent: string | null;
826
+ channel_id: string | null;
827
+ finished_at: number;
828
+ bytes: number;
829
+ event_count: number;
830
+ summary_json: string;
831
+ };
832
+
833
+ interface StoredPolicy {
834
+ policy: RetentionPolicy;
835
+ policyUpdatedAt: number;
836
+ }
837
+
838
+ export interface RunPolicyProposal {
839
+ policy: Partial<RetentionPolicy>;
840
+ policyUpdatedAt: number;
841
+ }
842
+
843
+ /** What retention needs from a `runs` row. */
844
+ type RetentionRow = { run_id: string; finished_at: number; bytes: number };
845
+
846
+ /** A `live_runs` row as SQLite returns it. */
847
+ type LiveRow = {
848
+ run_id: string;
849
+ thread_key: string;
850
+ owner_gen: string;
851
+ lease_until: number;
852
+ started_at: number;
853
+ phase: string;
854
+ stop: string | null;
855
+ meta_json: string;
856
+ card_json: string | null;
857
+ system_text: string;
858
+ tools_json: string;
859
+ state_json: string;
860
+ };
861
+
862
+ function rowToLive(r: LiveRow): LiveRunRow {
863
+ return {
864
+ runId: r.run_id,
865
+ threadKey: r.thread_key,
866
+ ownerGen: r.owner_gen,
867
+ leaseUntil: r.lease_until,
868
+ startedAt: r.started_at,
869
+ phase: r.phase as LivePhase,
870
+ stop: (r.stop as StopMode | null) ?? null,
871
+ meta: JSON.parse(r.meta_json) as LiveRunRow["meta"],
872
+ card: r.card_json ? (JSON.parse(r.card_json) as LiveRunRow["card"]) : null,
873
+ system: r.system_text,
874
+ tools: JSON.parse(r.tools_json) as LiveRunRow["tools"],
875
+ state: JSON.parse(r.state_json) as RunState,
876
+ };
877
+ }
878
+
879
+ type HeartbeatAnswer = FenceResult & { stop?: StopMode | null; phase?: LivePhase };
880
+
881
+ export class RunHistoryDO extends DurableObject<Env> {
882
+ private readonly sql: SqlStorage;
883
+
884
+ constructor(ctx: DurableObjectState, env: Env) {
885
+ super(ctx, env);
886
+ this.sql = ctx.storage.sql;
887
+ // Idempotent schema. `runs` carries the listing columns plus the record
888
+ // minus its events as JSON (`summary_json`, what `list` returns); events
889
+ // live one per row keyed (run_id, seq) so a 5000-event run is paged, never
890
+ // loaded whole to answer a listing. `meta` holds the persisted policy.
891
+ this.sql.exec(`
892
+ CREATE TABLE IF NOT EXISTS runs (
893
+ run_id TEXT PRIMARY KEY,
894
+ label TEXT,
895
+ agent TEXT,
896
+ model TEXT,
897
+ channel_id TEXT NOT NULL,
898
+ user_id TEXT NOT NULL,
899
+ thread_key TEXT NOT NULL,
900
+ channel_visibility TEXT NOT NULL DEFAULT 'unknown',
901
+ repo TEXT,
902
+ started_at INTEGER NOT NULL,
903
+ finished_at INTEGER NOT NULL,
904
+ stored_at INTEGER NOT NULL,
905
+ status TEXT NOT NULL,
906
+ event_count INTEGER NOT NULL,
907
+ stored_event_count INTEGER NOT NULL,
908
+ truncated INTEGER NOT NULL,
909
+ bytes INTEGER NOT NULL,
910
+ diagnosis_json TEXT NOT NULL,
911
+ summary_json TEXT NOT NULL
912
+ );
913
+ CREATE INDEX IF NOT EXISTS runs_finished ON runs(finished_at);
914
+ CREATE TABLE IF NOT EXISTS run_events (
915
+ run_id TEXT NOT NULL,
916
+ seq INTEGER NOT NULL,
917
+ json TEXT NOT NULL,
918
+ PRIMARY KEY (run_id, seq)
919
+ );
920
+ CREATE TABLE IF NOT EXISTS meta (
921
+ key TEXT PRIMARY KEY,
922
+ value TEXT NOT NULL
923
+ );
924
+ `);
925
+ // The one column migration this DO has (the run-visibility stamp): a table
926
+ // created before the visibility stamp gains the column with `unknown` for
927
+ // every existing row — so a run written before the stamp is never public.
928
+ // Then the indexes the visibility predicate's leaves walk (`channel_id IN`,
929
+ // `channel_visibility IN`, `user_id =`), each ordered like the page.
930
+ const columns = new Set(
931
+ this.sql
932
+ .exec<{ name: string }>(`PRAGMA table_info(runs)`)
933
+ .toArray()
934
+ .map((c) => c.name),
935
+ );
936
+ if (!columns.has("channel_visibility"))
937
+ this.sql.exec(`ALTER TABLE runs ADD COLUMN channel_visibility TEXT NOT NULL DEFAULT 'unknown'`);
938
+ this.sql.exec(`
939
+ CREATE INDEX IF NOT EXISTS runs_channel_finished ON runs(channel_id, finished_at DESC, run_id DESC);
940
+ CREATE INDEX IF NOT EXISTS runs_visibility_finished ON runs(channel_visibility, finished_at DESC, run_id DESC);
941
+ CREATE INDEX IF NOT EXISTS runs_user_finished ON runs(user_id, finished_at DESC, run_id DESC);
942
+ `);
943
+ // The live-run ledger (run-history items 28–34): live runs never enter
944
+ // `runs` — that table's finished_at drives retention and listing — they
945
+ // live here until `finish` moves them across in one transaction.
946
+ this.sql.exec(`
947
+ CREATE TABLE IF NOT EXISTS live_runs (
948
+ run_id TEXT PRIMARY KEY,
949
+ thread_key TEXT NOT NULL UNIQUE,
950
+ owner_gen TEXT NOT NULL,
951
+ lease_until INTEGER NOT NULL,
952
+ started_at INTEGER NOT NULL,
953
+ phase TEXT NOT NULL,
954
+ stop TEXT,
955
+ meta_json TEXT NOT NULL,
956
+ card_json TEXT,
957
+ system_text TEXT NOT NULL,
958
+ tools_json TEXT NOT NULL,
959
+ state_json TEXT NOT NULL
960
+ );
961
+ CREATE TABLE IF NOT EXISTS run_steps (
962
+ run_id TEXT NOT NULL,
963
+ step INTEGER NOT NULL,
964
+ json TEXT NOT NULL,
965
+ PRIMARY KEY (run_id, step)
966
+ );
967
+ CREATE TABLE IF NOT EXISTS run_inbox (
968
+ run_id TEXT NOT NULL,
969
+ seq INTEGER NOT NULL,
970
+ json TEXT NOT NULL,
971
+ PRIMARY KEY (run_id, seq)
972
+ );
973
+ CREATE TABLE IF NOT EXISTS run_jobs (
974
+ run_id TEXT NOT NULL,
975
+ kind TEXT NOT NULL,
976
+ json TEXT NOT NULL,
977
+ PRIMARY KEY (run_id, kind)
978
+ );
979
+ `);
980
+ }
981
+
982
+ // ---- the live-run ledger (run-history items 28–34) --------------------------
983
+
984
+ private liveRow(runId: string): LiveRunRow | undefined {
985
+ const r = this.sql.exec<LiveRow>(`SELECT * FROM live_runs WHERE run_id = ?`, runId).toArray()[0];
986
+ return r ? rowToLive(r) : undefined;
987
+ }
988
+
989
+ private liveByThread(threadKey: string): LiveRunRow | undefined {
990
+ const r = this.sql.exec<LiveRow>(`SELECT * FROM live_runs WHERE thread_key = ?`, threadKey).toArray()[0];
991
+ return r ? rowToLive(r) : undefined;
992
+ }
993
+
994
+ /** One live run per thread (item 29): the UNIQUE on thread_key is the
995
+ * store-level guarantee; the decision names the live run for the steer. */
996
+ async claim(req: ClaimRequest, now: number): Promise<ClaimResult> {
997
+ let out: ClaimResult = { ok: true };
998
+ this.ctx.storage.transactionSync(() => {
999
+ const existing = this.liveByThread(req.threadKey);
1000
+ out = decideClaim(
1001
+ existing
1002
+ ? {
1003
+ runId: existing.runId,
1004
+ agent: existing.meta.agent,
1005
+ startedAt: existing.startedAt,
1006
+ ownerGen: existing.ownerGen,
1007
+ }
1008
+ : undefined,
1009
+ req,
1010
+ );
1011
+ if (!out.ok) return;
1012
+ switch (decideClaimWrite(existing, req)) {
1013
+ case "keep":
1014
+ return;
1015
+ case "refresh":
1016
+ this.sql.exec(`UPDATE live_runs SET lease_until = ? WHERE run_id = ?`, now + req.leaseMs, req.runId);
1017
+ return;
1018
+ case "promote":
1019
+ // The prompt landed on the owner's own attaching row (item 42): the
1020
+ // claim the dispatcher always made, applied in place — identity,
1021
+ // thread and start stay; the row goes live.
1022
+ this.sql.exec(
1023
+ `UPDATE live_runs SET lease_until = ?, phase = 'live', meta_json = ?, card_json = ?, system_text = ?, tools_json = ?, state_json = ? WHERE run_id = ?`,
1024
+ now + req.leaseMs,
1025
+ JSON.stringify(req.meta),
1026
+ req.card ? JSON.stringify(req.card) : null,
1027
+ req.system,
1028
+ JSON.stringify(req.tools),
1029
+ JSON.stringify(req.state ?? {}),
1030
+ req.runId,
1031
+ );
1032
+ return;
1033
+ case "insert":
1034
+ break;
1035
+ }
1036
+ this.sql.exec(
1037
+ `INSERT INTO live_runs (run_id, thread_key, owner_gen, lease_until, started_at, phase, stop, meta_json, card_json, system_text, tools_json, state_json)
1038
+ VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)`,
1039
+ req.runId,
1040
+ req.threadKey,
1041
+ req.gen,
1042
+ now + req.leaseMs,
1043
+ req.startedAt,
1044
+ req.phase ?? "live",
1045
+ JSON.stringify(req.meta),
1046
+ req.card ? JSON.stringify(req.card) : null,
1047
+ req.system,
1048
+ JSON.stringify(req.tools),
1049
+ JSON.stringify(req.state ?? {}),
1050
+ );
1051
+ });
1052
+ return out;
1053
+ }
1054
+
1055
+ /** Extends the lease iff the caller owns the run; answers what another generation asked for. */
1056
+ async heartbeat(runId: string, gen: string, leaseMs: number, now: number): Promise<HeartbeatAnswer> {
1057
+ let out: HeartbeatAnswer = { ok: false, reason: "unknown-run" };
1058
+ this.ctx.storage.transactionSync(() => {
1059
+ const row = this.liveRow(runId);
1060
+ const fence = checkFence(row, gen);
1061
+ if (!fence.ok || !row) {
1062
+ out = fence;
1063
+ return;
1064
+ }
1065
+ this.sql.exec(`UPDATE live_runs SET lease_until = ? WHERE run_id = ?`, now + leaseMs, runId);
1066
+ out = { ok: true, stop: row.stop, phase: row.phase };
1067
+ });
1068
+ return out;
1069
+ }
1070
+
1071
+ /** Append events with their registry seq (item 30). Fenced. */
1072
+ async appendEvents(runId: string, gen: string, events: Array<{ seq: number; json: string }>): Promise<FenceResult> {
1073
+ let out: FenceResult = { ok: true };
1074
+ this.ctx.storage.transactionSync(() => {
1075
+ out = checkFence(this.liveRow(runId), gen);
1076
+ if (!out.ok) return;
1077
+ for (let i = 0; i < events.length; i += RUN_EVENT_INSERT_BATCH) {
1078
+ const batch = events.slice(i, i + RUN_EVENT_INSERT_BATCH);
1079
+ const params: (string | number)[] = [];
1080
+ for (const e of batch) params.push(runId, e.seq, e.json);
1081
+ this.sql.exec(
1082
+ `INSERT OR REPLACE INTO run_events (run_id, seq, json) VALUES ${batch.map(() => "(?, ?, ?)").join(",")}`,
1083
+ ...params,
1084
+ );
1085
+ }
1086
+ });
1087
+ return out;
1088
+ }
1089
+
1090
+ /** The step record (item 31), written by the client AFTER the transcript turns. Fenced. */
1091
+ async recordStep(runId: string, gen: string, record: StepRecord): Promise<FenceResult> {
1092
+ let out: FenceResult = { ok: true };
1093
+ this.ctx.storage.transactionSync(() => {
1094
+ out = checkFence(this.liveRow(runId), gen);
1095
+ if (!out.ok) return;
1096
+ this.sql.exec(
1097
+ `INSERT OR REPLACE INTO run_steps (run_id, step, json) VALUES (?, ?, ?)`,
1098
+ runId,
1099
+ record.step,
1100
+ JSON.stringify(record),
1101
+ );
1102
+ });
1103
+ return out;
1104
+ }
1105
+
1106
+ async setState(runId: string, gen: string, state: RunState): Promise<FenceResult> {
1107
+ let out: FenceResult = { ok: true };
1108
+ this.ctx.storage.transactionSync(() => {
1109
+ out = checkFence(this.liveRow(runId), gen);
1110
+ if (!out.ok) return;
1111
+ this.sql.exec(`UPDATE live_runs SET state_json = ? WHERE run_id = ?`, JSON.stringify(state), runId);
1112
+ });
1113
+ return out;
1114
+ }
1115
+
1116
+ /** Any generation: a steer arrives on whichever container is up. */
1117
+ async pushInbox(runId: string, message: Record<string, unknown>): Promise<{ ok: boolean; seq?: number }> {
1118
+ let out: { ok: boolean; seq?: number } = { ok: false };
1119
+ this.ctx.storage.transactionSync(() => {
1120
+ if (!this.liveRow(runId)) return;
1121
+ const last = this.sql
1122
+ .exec<{ m: number | null }>(`SELECT MAX(seq) AS m FROM run_inbox WHERE run_id = ?`, runId)
1123
+ .one().m;
1124
+ const seq = (last ?? 0) + 1;
1125
+ this.sql.exec(`INSERT INTO run_inbox (run_id, seq, json) VALUES (?, ?, ?)`, runId, seq, JSON.stringify(message));
1126
+ out = { ok: true, seq };
1127
+ });
1128
+ return out;
1129
+ }
1130
+
1131
+ /** The inbox past a seq (run-history item 40): the resume's re-read at adopt. */
1132
+ async readInbox(runId: string, afterSeq: number): Promise<{ seq: number; message: Record<string, unknown> }[]> {
1133
+ return this.sql
1134
+ .exec<{ seq: number; json: string }>(
1135
+ `SELECT seq, json FROM run_inbox WHERE run_id = ? AND seq > ? ORDER BY seq ASC`,
1136
+ runId,
1137
+ afterSeq,
1138
+ )
1139
+ .toArray()
1140
+ .map((r) => ({ seq: r.seq, message: JSON.parse(r.json) as Record<string, unknown> }));
1141
+ }
1142
+
1143
+ async requestStop(runId: string, mode: StopMode, now: number): Promise<{ ok: boolean; ownerLive?: boolean }> {
1144
+ let out: { ok: boolean; ownerLive?: boolean } = { ok: false };
1145
+ this.ctx.storage.transactionSync(() => {
1146
+ const row = this.liveRow(runId);
1147
+ if (!row) return;
1148
+ this.sql.exec(`UPDATE live_runs SET stop = ? WHERE run_id = ?`, mode, runId);
1149
+ out = { ok: true, ownerLive: row.leaseUntil > now };
1150
+ });
1151
+ return out;
1152
+ }
1153
+
1154
+ /** SIGTERM: mark this generation's live runs for the next one (item 33). */
1155
+ async handoff(gen: string, runIds: string[]): Promise<{ marked: string[] }> {
1156
+ const marked: string[] = [];
1157
+ this.ctx.storage.transactionSync(() => {
1158
+ for (const id of runIds) {
1159
+ const row = this.liveRow(id);
1160
+ if (row && row.ownerGen === gen && phaseTransition(row.phase, "handoff")) {
1161
+ this.sql.exec(`UPDATE live_runs SET phase = 'handoff' WHERE run_id = ?`, id);
1162
+ marked.push(id);
1163
+ }
1164
+ }
1165
+ });
1166
+ return { marked };
1167
+ }
1168
+
1169
+ /** CAS live → finishing, taken before the reply (item 33). Fenced. */
1170
+ async finishing(runId: string, gen: string): Promise<FenceResult> {
1171
+ let out: FenceResult = { ok: true };
1172
+ this.ctx.storage.transactionSync(() => {
1173
+ const row = this.liveRow(runId);
1174
+ const fence = checkFence(row, gen);
1175
+ if (!fence.ok || !row) {
1176
+ out = fence;
1177
+ return;
1178
+ }
1179
+ if (!phaseTransition(row.phase, "finishing")) {
1180
+ out = { ok: false, reason: "fenced" };
1181
+ return;
1182
+ }
1183
+ this.sql.exec(`UPDATE live_runs SET phase = 'finishing' WHERE run_id = ?`, runId);
1184
+ });
1185
+ return out;
1186
+ }
1187
+
1188
+ /** The finished record replaces the live rows in ONE transaction (item 33). Fenced. */
1189
+ async finish(
1190
+ runId: string,
1191
+ gen: string,
1192
+ record: RunRecord,
1193
+ proposal?: RunPolicyProposal,
1194
+ ): Promise<FenceResult & { stored?: boolean }> {
1195
+ let out: FenceResult & { stored?: boolean } = { ok: true };
1196
+ this.ctx.storage.transactionSync(() => {
1197
+ const fence = checkFence(this.liveRow(runId), gen);
1198
+ if (!fence.ok) {
1199
+ out = fence;
1200
+ return;
1201
+ }
1202
+ const put = this.upsertInTransaction(record, proposal);
1203
+ this.deleteLiveRows([runId]);
1204
+ out = { ok: true, stored: put.stored };
1205
+ });
1206
+ if ((await this.ctx.storage.getAlarm()) === null)
1207
+ await this.ctx.storage.setAlarm(systemClock() + RUN_SWEEP_INTERVAL_MS);
1208
+ return out;
1209
+ }
1210
+
1211
+ /** The live rows go with no record (item 42): a reserved run that never
1212
+ * started. Fenced. */
1213
+ async abandon(runId: string, gen: string): Promise<FenceResult> {
1214
+ let out: FenceResult = { ok: true };
1215
+ this.ctx.storage.transactionSync(() => {
1216
+ out = checkFence(this.liveRow(runId), gen);
1217
+ if (!out.ok) return;
1218
+ this.deleteLiveRows([runId]);
1219
+ });
1220
+ return out;
1221
+ }
1222
+
1223
+ private deleteLiveRows(runIds: string[]): void {
1224
+ for (const id of runIds) {
1225
+ this.sql.exec(`DELETE FROM live_runs WHERE run_id = ?`, id);
1226
+ this.sql.exec(`DELETE FROM run_steps WHERE run_id = ?`, id);
1227
+ this.sql.exec(`DELETE FROM run_inbox WHERE run_id = ?`, id);
1228
+ this.sql.exec(`DELETE FROM run_jobs WHERE run_id = ?`, id);
1229
+ }
1230
+ }
1231
+
1232
+ /** A booting generation takes every expired or handed-off run (item 31),
1233
+ * atomically, with what a resume needs. */
1234
+ async reclaim(gen: string, now: number, leaseMs: number): Promise<ReclaimedRun[]> {
1235
+ const out: ReclaimedRun[] = [];
1236
+ this.ctx.storage.transactionSync(() => {
1237
+ const rows = this.sql.exec<LiveRow>(`SELECT * FROM live_runs`).toArray().map(rowToLive);
1238
+ for (const row of selectReclaim(rows, now, gen)) {
1239
+ const phase = reclaimPhase(row.phase);
1240
+ this.sql.exec(
1241
+ `UPDATE live_runs SET owner_gen = ?, lease_until = ?, phase = ? WHERE run_id = ?`,
1242
+ gen,
1243
+ now + leaseMs,
1244
+ phase,
1245
+ row.runId,
1246
+ );
1247
+ const stepRow = this.sql
1248
+ .exec<{ json: string }>(`SELECT json FROM run_steps WHERE run_id = ? ORDER BY step DESC LIMIT 1`, row.runId)
1249
+ .toArray()[0];
1250
+ const lastStep = stepRow ? (JSON.parse(stepRow.json) as StepRecord) : null;
1251
+ const consumed = lastStep?.inboxConsumedSeq ?? 0;
1252
+ const inbox = this.sql
1253
+ .exec<{ seq: number; json: string }>(
1254
+ `SELECT seq, json FROM run_inbox WHERE run_id = ? AND seq > ? ORDER BY seq ASC`,
1255
+ row.runId,
1256
+ consumed,
1257
+ )
1258
+ .toArray()
1259
+ .map((r) => ({ seq: r.seq, message: JSON.parse(r.json) as Record<string, unknown> }));
1260
+ const jobs = this.sql
1261
+ .exec<{ kind: string; json: string }>(`SELECT kind, json FROM run_jobs WHERE run_id = ?`, row.runId)
1262
+ .toArray()
1263
+ .map((r) => ({ kind: r.kind, payload: JSON.parse(r.json) as unknown }));
1264
+ out.push({
1265
+ row: { ...row, ownerGen: gen, leaseUntil: now + leaseMs, phase },
1266
+ reclaimedFrom: row.phase,
1267
+ lastStep,
1268
+ inbox,
1269
+ jobs,
1270
+ });
1271
+ }
1272
+ });
1273
+ return out;
1274
+ }
1275
+
1276
+ async listLive(): Promise<LiveRunRow[]> {
1277
+ return this.sql.exec<LiveRow>(`SELECT * FROM live_runs ORDER BY started_at ASC`).toArray().map(rowToLive);
1278
+ }
1279
+
1280
+ /** The events a live run has appended so far (item 30), in seq order — what
1281
+ * a reclaim closes an unresumable run's record with. The finished-runs
1282
+ * reads never see a live run, so this is the one way at its events. */
1283
+ async liveEvents(runId: string): Promise<StoredRunEvent[]> {
1284
+ return parseEventRows(this.eventRows(runId, 0, Number.MAX_SAFE_INTEGER));
1285
+ }
1286
+
1287
+ // ---- policy ---------------------------------------------------------------
1288
+
1289
+ /** The persisted policy (defaults until the first proposal lands). */
1290
+ policyState(): StoredPolicy {
1291
+ const row = this.sql.exec<{ value: string }>(`SELECT value FROM meta WHERE key = ?`, POLICY_KEY).toArray()[0];
1292
+ if (!row) return { policy: clampRetentionPolicy({}), policyUpdatedAt: 0 };
1293
+ try {
1294
+ const parsed = JSON.parse(row.value) as Partial<RetentionPolicy> & { policyUpdatedAt?: number };
1295
+ const at =
1296
+ typeof parsed.policyUpdatedAt === "number" && Number.isFinite(parsed.policyUpdatedAt)
1297
+ ? parsed.policyUpdatedAt
1298
+ : 0;
1299
+ return { policy: clampRetentionPolicy(parsed), policyUpdatedAt: at };
1300
+ } catch {
1301
+ return { policy: clampRetentionPolicy({}), policyUpdatedAt: 0 };
1302
+ }
1303
+ }
1304
+
1305
+ /** Accept a proposal only when strictly newer than the stored one; its stamp
1306
+ * is clamped to the DO clock so a skewed proposer cannot lock the policy. */
1307
+ private applyProposal(proposal: RunPolicyProposal, now: number): StoredPolicy {
1308
+ const current = this.policyState();
1309
+ const stamp = Math.min(proposal.policyUpdatedAt, now);
1310
+ if (stamp <= current.policyUpdatedAt) return current;
1311
+ const next: StoredPolicy = { policy: clampRetentionPolicy(proposal.policy), policyUpdatedAt: stamp };
1312
+ this.sql.exec(
1313
+ `INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
1314
+ POLICY_KEY,
1315
+ JSON.stringify({ ...next.policy, policyUpdatedAt: next.policyUpdatedAt }),
1316
+ );
1317
+ return next;
1318
+ }
1319
+
1320
+ // ---- retention ------------------------------------------------------------
1321
+
1322
+ /** The ids the policy keeps among `rows`, computed by the ONE shared
1323
+ * retention function over each row's (id, finishedAt, bytes). */
1324
+ private static keptIds(rows: readonly RetentionRow[], policy: RetentionPolicy, now: number): Set<string> {
1325
+ const kept = applyRetention(
1326
+ rows.map((r) => ({ id: r.run_id, finishedAt: r.finished_at, bytes: r.bytes })),
1327
+ policy,
1328
+ now,
1329
+ );
1330
+ return new Set(kept.map((r) => r.id));
1331
+ }
1332
+
1333
+ /** Every row, oldest first (`finished_at ASC, run_id ASC`) — the deletion order. */
1334
+ private retentionRows(): RetentionRow[] {
1335
+ return this.sql
1336
+ .exec<RetentionRow>(`SELECT run_id, finished_at, bytes FROM runs ORDER BY finished_at ASC, run_id ASC`)
1337
+ .toArray();
1338
+ }
1339
+
1340
+ /**
1341
+ * Whether ONE row is kept, without materializing the table — the same answer
1342
+ * `applyRetention` gives for it, decided in its order: (1) finished before
1343
+ * the `retentionDays` cutoff → out; (2) rows ranked ahead of it (newest
1344
+ * first: `finished_at DESC, run_id DESC`, among those inside the cutoff) must
1345
+ * number fewer than `maxRuns`; (3) their bytes plus its own must fit
1346
+ * `maxBytes` (bytes are non-negative, so the cumulative total is monotone and
1347
+ * "the first row over the budget and everything after it" reduces to this one
1348
+ * inequality). Ids are ASCII (`RUN_ID_PATTERN`), so SQLite's binary `run_id`
1349
+ * order is the JS string order `newestFirst` uses.
1350
+ */
1351
+ private isKept(row: RetentionRow, policy: RetentionPolicy, now: number): boolean {
1352
+ const cutoff = now - policy.retentionDays * 86_400_000;
1353
+ if (row.finished_at < cutoff) return false;
1354
+ const ahead = this.sql
1355
+ .exec<{ n: number; b: number }>(
1356
+ `SELECT COUNT(*) AS n, COALESCE(SUM(bytes), 0) AS b FROM runs
1357
+ WHERE finished_at >= ? AND (finished_at > ? OR (finished_at = ? AND run_id > ?))`,
1358
+ cutoff,
1359
+ row.finished_at,
1360
+ row.finished_at,
1361
+ row.run_id,
1362
+ )
1363
+ .one();
1364
+ return ahead.n < policy.maxRuns && ahead.b + row.bytes <= policy.maxBytes;
1365
+ }
1366
+
1367
+ private deleteRuns(ids: readonly string[]): void {
1368
+ for (let i = 0; i < ids.length; i += RUN_DELETE_BATCH) {
1369
+ const batch = ids.slice(i, i + RUN_DELETE_BATCH);
1370
+ const marks = batch.map(() => "?").join(",");
1371
+ this.sql.exec(`DELETE FROM run_events WHERE run_id IN (${marks})`, ...batch);
1372
+ this.sql.exec(`DELETE FROM runs WHERE run_id IN (${marks})`, ...batch);
1373
+ }
1374
+ }
1375
+
1376
+ /** Delete rows outside policy, oldest first, at most `fence` of them (all
1377
+ * when `fence` is undefined). `first`, when outside policy, is always
1378
+ * deleted — the record just written must not survive its own put as a
1379
+ * hidden row. One scan of `runs` feeds both the kept set and the deletion
1380
+ * order. Returns how many rows were deleted and the kept ids — which are
1381
+ * exactly the rows retained after the delete: the kept set is the newest
1382
+ * prefix of the age-filtered order, and only rows outside it were removed,
1383
+ * so re-running retention on what remains selects the same rows. */
1384
+ private trim(
1385
+ policy: RetentionPolicy,
1386
+ now: number,
1387
+ fence: number | undefined,
1388
+ first?: string,
1389
+ ): { deleted: number; kept: Set<string> } {
1390
+ const rows = this.retentionRows();
1391
+ const kept = RunHistoryDO.keptIds(rows, policy, now);
1392
+ const outside = rows.map((r) => r.run_id).filter((id) => !kept.has(id) && id !== first);
1393
+ const firstDoomed = first !== undefined && !kept.has(first);
1394
+ const doomed = firstDoomed ? [first, ...outside] : outside;
1395
+ const victims = fence === undefined ? doomed : doomed.slice(0, Math.max(fence, firstDoomed ? 1 : 0));
1396
+ this.deleteRuns(victims);
1397
+ if (victims.length < doomed.length)
1398
+ console.log(
1399
+ `[runs/trim] deletion fence: ${victims.length} of ${doomed.length} rows outside policy deleted this put`,
1400
+ );
1401
+ return { deleted: victims.length, kept };
1402
+ }
1403
+
1404
+ // ---- writes ---------------------------------------------------------------
1405
+
1406
+ /** Upsert one record and trim, in ONE sync transaction (see MemoryDO.write for
1407
+ * why this is atomic and un-interleavable). Event rows are rewritten only
1408
+ * when the stored version changed (`event_count`, `finished_at`, `bytes`) —
1409
+ * an identical retry is a no-op on `run_events`. `stored: false` when the
1410
+ * record itself fell outside the (possibly just-updated) policy: it was
1411
+ * written and deleted in the same transaction, so nothing of it remains. */
1412
+ async put(
1413
+ record: RunRecord,
1414
+ proposal?: RunPolicyProposal,
1415
+ ): Promise<{ ok: true; retained: number; stored: boolean; rewritten: boolean }> {
1416
+ let result = { ok: true as const, retained: 0, stored: false, rewritten: false };
1417
+ this.ctx.storage.transactionSync(() => {
1418
+ result = this.upsertInTransaction(record, proposal);
1419
+ });
1420
+ if ((await this.ctx.storage.getAlarm()) === null)
1421
+ await this.ctx.storage.setAlarm(systemClock() + RUN_SWEEP_INTERVAL_MS);
1422
+ return result;
1423
+ }
1424
+
1425
+ /** The body of `put`, for a caller already inside `transactionSync` — the
1426
+ * ledger's `finish` writes the finished record and deletes the live rows in
1427
+ * ONE transaction (run-history item 33), so this cannot open its own. */
1428
+ private upsertInTransaction(
1429
+ record: RunRecord,
1430
+ proposal?: RunPolicyProposal,
1431
+ ): { ok: true; retained: number; stored: boolean; rewritten: boolean } {
1432
+ {
1433
+ const now = systemClock();
1434
+ const policy = proposal ? this.applyProposal(proposal, now).policy : this.policyState().policy;
1435
+ const finishedAt = Math.min(record.finishedAt, now + RUN_MAX_FUTURE_MS);
1436
+ // The tracing stamps get the same skew clamp (docs/reference/specs/tracing.md).
1437
+ const stored: RunRecord = {
1438
+ ...record,
1439
+ finishedAt,
1440
+ ...(record.receivedAt !== undefined
1441
+ ? { receivedAt: Math.min(record.receivedAt, now + RUN_MAX_FUTURE_MS) }
1442
+ : {}),
1443
+ ...(record.sealedAt !== undefined ? { sealedAt: Math.min(record.sealedAt, now + RUN_MAX_FUTURE_MS) } : {}),
1444
+ };
1445
+ const { events, ...summary } = stored;
1446
+ const bytes = utf8ByteLength(JSON.stringify(stored));
1447
+ const existing = this.sql
1448
+ .exec<{ event_count: number; finished_at: number; bytes: number }>(
1449
+ `SELECT event_count, finished_at, bytes FROM runs WHERE run_id = ?`,
1450
+ record.id,
1451
+ )
1452
+ .toArray()[0];
1453
+ const unchanged =
1454
+ existing !== undefined &&
1455
+ sameStoredVersion(
1456
+ { eventCount: existing.event_count, finishedAt: existing.finished_at, bytes: existing.bytes },
1457
+ { eventCount: stored.eventCount, finishedAt, bytes },
1458
+ );
1459
+ this.sql.exec(
1460
+ `INSERT INTO runs (run_id, label, agent, model, channel_id, user_id, thread_key, channel_visibility, repo, started_at, finished_at, stored_at, status,
1461
+ event_count, stored_event_count, truncated, bytes, diagnosis_json, summary_json)
1462
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1463
+ ON CONFLICT(run_id) DO UPDATE SET
1464
+ label = excluded.label, agent = excluded.agent, model = excluded.model, channel_id = excluded.channel_id,
1465
+ user_id = excluded.user_id, thread_key = excluded.thread_key, channel_visibility = excluded.channel_visibility,
1466
+ repo = excluded.repo, started_at = excluded.started_at,
1467
+ finished_at = excluded.finished_at, stored_at = excluded.stored_at, status = excluded.status,
1468
+ event_count = excluded.event_count, stored_event_count = excluded.stored_event_count, truncated = excluded.truncated,
1469
+ bytes = excluded.bytes, diagnosis_json = excluded.diagnosis_json, summary_json = excluded.summary_json`,
1470
+ stored.id,
1471
+ stored.label ?? null,
1472
+ stored.agent ?? null,
1473
+ stored.model ?? null,
1474
+ stored.channelId,
1475
+ stored.userId,
1476
+ stored.threadKey,
1477
+ stored.channelVisibility ?? "unknown",
1478
+ stored.repo ?? null,
1479
+ stored.startedAt,
1480
+ finishedAt,
1481
+ now,
1482
+ stored.status,
1483
+ stored.eventCount,
1484
+ stored.storedEventCount,
1485
+ stored.truncated ? 1 : 0,
1486
+ bytes,
1487
+ JSON.stringify(stored.diagnosis),
1488
+ JSON.stringify(summary),
1489
+ );
1490
+ if (!unchanged) {
1491
+ this.sql.exec(`DELETE FROM run_events WHERE run_id = ?`, record.id);
1492
+ const seqs = storedEventSeqs(events); // the registry's stamps (see runRecord.ts)
1493
+ for (let i = 0; i < events.length; i += RUN_EVENT_INSERT_BATCH) {
1494
+ const batch = events.slice(i, i + RUN_EVENT_INSERT_BATCH);
1495
+ const params: (string | number)[] = [];
1496
+ batch.forEach((e, j) => params.push(record.id, seqs[i + j], JSON.stringify(e)));
1497
+ this.sql.exec(
1498
+ `INSERT INTO run_events (run_id, seq, json) VALUES ${batch.map(() => "(?, ?, ?)").join(",")}`,
1499
+ ...params,
1500
+ );
1501
+ }
1502
+ }
1503
+ // The just-written row is either kept or was deleted by the trim (it is
1504
+ // always `first`), so kept membership IS whether it is still stored.
1505
+ const { kept } = this.trim(policy, now, RUN_TRIM_FENCE, record.id);
1506
+ return {
1507
+ ok: true as const,
1508
+ retained: kept.size,
1509
+ stored: kept.has(record.id),
1510
+ rewritten: existing !== undefined && !unchanged,
1511
+ };
1512
+ }
1513
+ }
1514
+
1515
+ /** Remove a run and its events. Returns whether a run row existed. */
1516
+ async delete(id: string): Promise<boolean> {
1517
+ let deleted = false;
1518
+ this.ctx.storage.transactionSync(() => {
1519
+ deleted = this.sql.exec<{ n: number }>(`SELECT COUNT(*) AS n FROM runs WHERE run_id = ?`, id).one().n === 1;
1520
+ this.deleteRuns([id]);
1521
+ });
1522
+ return deleted;
1523
+ }
1524
+
1525
+ /** Every 6 h: delete everything outside policy (no fence — this is where a
1526
+ * large shrink finishes), sweep orphaned events, then re-arm. */
1527
+ async alarm(): Promise<void> {
1528
+ // The sweep nobody asked for is a root of its own (docs/reference/specs/tracing.md
1529
+ // item 25): `state.alarm`, ending with how many rows it swept.
1530
+ const root = startAdoptedRoot(tracer, "state.alarm", { sinks: traceSinks });
1531
+ try {
1532
+ const now = systemClock();
1533
+ const { policy } = this.policyState();
1534
+ let deleted = 0;
1535
+ this.ctx.storage.transactionSync(() => {
1536
+ deleted = this.trim(policy, now, undefined).deleted;
1537
+ // Orphan sweep: events whose run is gone (defensive — `deleteRuns` pairs
1538
+ // the two deletes, so this is a periodic check, not a per-put cost).
1539
+ this.sql.exec(`DELETE FROM run_events WHERE run_id NOT IN (SELECT run_id FROM runs)`);
1540
+ });
1541
+ console.log(`[runs/alarm] swept ${deleted} rows outside policy`);
1542
+ await this.ctx.storage.setAlarm(now + RUN_SWEEP_INTERVAL_MS);
1543
+ root.end("ok", { swept: deleted });
1544
+ } catch (err) {
1545
+ root.fail(err);
1546
+ root.end("error");
1547
+ throw err;
1548
+ }
1549
+ }
1550
+
1551
+ // ---- reads ----------------------------------------------------------------
1552
+
1553
+ /** The record with its events in seq order, each carrying the `seq` it is
1554
+ * stored under (the registry's stamp — see `eventSeqs`), or null when
1555
+ * unknown or outside policy — one not-found shape. A corrupt event row is skipped. */
1556
+ async get(id: string): Promise<RunRecord | null> {
1557
+ const now = systemClock();
1558
+ const row = this.sql
1559
+ .exec<RunRow>(
1560
+ `SELECT run_id, agent, channel_id, finished_at, bytes, event_count, summary_json FROM runs WHERE run_id = ?`,
1561
+ id,
1562
+ )
1563
+ .toArray()[0];
1564
+ if (!row || !this.isKept(row, this.policyState().policy, now)) return null;
1565
+ const summary = parseSummary(row);
1566
+ if (!summary) return null;
1567
+ const events: RunEvent[] = parseEventRows(this.eventRows(id, 0, Number.MAX_SAFE_INTEGER));
1568
+ return { ...summary, events };
1569
+ }
1570
+
1571
+ private eventRows(id: string, afterSeq: number, limit: number): EventRow[] {
1572
+ return this.sql
1573
+ .exec<EventRow>(
1574
+ `SELECT seq, json FROM run_events WHERE run_id = ? AND seq > ? ORDER BY seq ASC LIMIT ?`,
1575
+ id,
1576
+ afterSeq,
1577
+ limit,
1578
+ )
1579
+ .toArray();
1580
+ }
1581
+
1582
+ /** A page of events with seq > afterSeq. `nextAfterSeq` is set when more
1583
+ * rows follow (the cursor is the last seq READ, so a skipped corrupt row
1584
+ * never stalls paging). Unknown or expired run → null (the same not-found
1585
+ * as `get`); a run with nothing past `afterSeq` → an empty page. One query
1586
+ * reads `limit + 1` rows: the page is the first `limit`, the extra row only
1587
+ * says that more follow. */
1588
+ async events(
1589
+ id: string,
1590
+ afterSeq: number,
1591
+ limit: number,
1592
+ ): Promise<{ events: StoredRunEvent[]; nextAfterSeq?: number } | null> {
1593
+ const row = this.sql
1594
+ .exec<RetentionRow>(`SELECT run_id, finished_at, bytes FROM runs WHERE run_id = ?`, id)
1595
+ .toArray()[0];
1596
+ if (!row || !this.isKept(row, this.policyState().policy, systemClock())) return null;
1597
+ const rows = this.eventRows(id, afterSeq, limit + 1);
1598
+ const page = rows.slice(0, limit);
1599
+ const out: { events: StoredRunEvent[]; nextAfterSeq?: number } = { events: parseEventRows(page) };
1600
+ if (rows.length > limit) out.nextAfterSeq = page[page.length - 1].seq;
1601
+ return out;
1602
+ }
1603
+
1604
+ /** The record minus its events (the listing row, `bytes` included), or null
1605
+ * when unknown or outside policy — the same not-found as `get`. No event row
1606
+ * is touched: the read for callers that need identity, status, or the
1607
+ * diagnosis but not the event set. */
1608
+ async summary(id: string): Promise<RunListItem | null> {
1609
+ const row = this.sql
1610
+ .exec<RunRow>(
1611
+ `SELECT run_id, agent, channel_id, finished_at, bytes, event_count, summary_json FROM runs WHERE run_id = ?`,
1612
+ id,
1613
+ )
1614
+ .toArray()[0];
1615
+ if (!row || !this.isKept(row, this.policyState().policy, systemClock())) return null;
1616
+ const summary = parseSummary(row);
1617
+ return summary ? { ...summary, bytes: row.bytes } : null;
1618
+ }
1619
+
1620
+ /**
1621
+ * Newest first (finished_at desc, run_id desc) among the rows the policy
1622
+ * keeps, filtered, capped at RUN_LIST_MAX_LIMIT. The `before`/`beforeId`
1623
+ * cursor is the previous page's last row: rows strictly after it in the list
1624
+ * order (`finished_at < before`, or equal with `run_id < beforeId`), so
1625
+ * same-millisecond siblings are never skipped; `before` alone falls back to
1626
+ * `finished_at < before`. `nextBefore` is the last row's key when this page
1627
+ * was full.
1628
+ *
1629
+ * Two paths, decided by ONE aggregate over the in-policy rows (`COUNT(*)`,
1630
+ * `SUM(bytes)` where `finished_at >= cutoff`): when both are within
1631
+ * `maxRuns`/`maxBytes` every in-cutoff row is kept, so the page is ONE indexed
1632
+ * query (age cutoff, filters, cursor, order, `LIMIT`) — no table scan. Only
1633
+ * when a bound is exceeded is the kept set computed (`retentionRows` +
1634
+ * `applyRetention`, the same function `put`/`alarm` trim with) and the rows
1635
+ * walked until the page fills; the kept set is the newest prefix of the
1636
+ * in-cutoff order, so the walk stops at the first row outside it.
1637
+ *
1638
+ * `visibleTo` — the caller's authorization predicate (authorization.md item
1639
+ * 6) — is compiled into the same WHERE clause (`visibilitySql`): its leaves
1640
+ * become `channel_id IN (…)`, `channel_visibility IN (…)`, `user_id = ?`,
1641
+ * `repo IN (…)`, each backed by an index, so the actor's view is one more
1642
+ * indexed filter on the page query, never a post-filter. `none` answers an
1643
+ * empty page without a query.
1644
+ */
1645
+ async list(q: RunListOptions): Promise<{ items: RunListItem[]; nextBefore?: { finishedAt: number; id: string } }> {
1646
+ const now = systemClock();
1647
+ const limit = clampListLimit(q.limit);
1648
+ if (q.visibleTo?.kind === "none") return { items: [] };
1649
+ const { policy } = this.policyState();
1650
+ const cutoff = now - policy.retentionDays * 86_400_000;
1651
+ const inPolicy = this.sql
1652
+ .exec<{ n: number; b: number }>(
1653
+ `SELECT COUNT(*) AS n, COALESCE(SUM(bytes), 0) AS b FROM runs WHERE finished_at >= ?`,
1654
+ cutoff,
1655
+ )
1656
+ .one();
1657
+ const boundExceeded = inPolicy.n > policy.maxRuns || inPolicy.b > policy.maxBytes;
1658
+ const kept = boundExceeded ? RunHistoryDO.keptIds(this.retentionRows(), policy, now) : null;
1659
+ const before = q.before ?? Number.MAX_SAFE_INTEGER;
1660
+ const where = [`(finished_at < ? OR (finished_at = ? AND run_id < ?))`, `finished_at >= ?`];
1661
+ // no beforeId → no row satisfies `run_id < ''`: the equality branch is inert
1662
+ const params: (string | number)[] = [before, before, q.beforeId ?? "", Math.max(q.sinceMs ?? 0, cutoff)];
1663
+ if (q.agent !== undefined) {
1664
+ where.push(`agent = ?`);
1665
+ params.push(q.agent);
1666
+ }
1667
+ if (q.channel !== undefined) {
1668
+ where.push(`channel_id = ?`);
1669
+ params.push(q.channel);
1670
+ }
1671
+ if (q.visibleTo !== undefined && q.visibleTo.kind !== "all") where.push(visibilitySql(q.visibleTo, params));
1672
+ const select = `SELECT run_id, agent, channel_id, finished_at, bytes, event_count, summary_json FROM runs WHERE ${where.join(" AND ")} ORDER BY finished_at DESC, run_id DESC`;
1673
+ // `LIMIT` holds on the over-bound path too: the kept set is the newest
1674
+ // prefix of this same ordering, so the first `limit` rows are the page (or
1675
+ // the walk stops early at the first evicted row) — never a full table load.
1676
+ const rows = this.sql.exec<RunRow>(`${select} LIMIT ?`, ...params, limit).toArray();
1677
+ const items: RunListItem[] = [];
1678
+ for (const row of rows) {
1679
+ if (items.length >= limit) break;
1680
+ if (kept !== null && !kept.has(row.run_id)) break; // kept is a newest-first prefix: nothing older is kept either
1681
+ const summary = parseSummary(row);
1682
+ if (summary) items.push({ ...summary, bytes: row.bytes });
1683
+ }
1684
+ const out: { items: RunListItem[]; nextBefore?: { finishedAt: number; id: string } } = { items };
1685
+ if (items.length === limit) {
1686
+ const last = items[items.length - 1];
1687
+ out.nextBefore = { finishedAt: last.finishedAt, id: last.id };
1688
+ }
1689
+ return out;
1690
+ }
1691
+ }
1692
+
1693
+ type EventRow = { seq: number; json: string };
1694
+
1695
+ /** A visibility filter as one SQL boolean over the `runs` columns, its values
1696
+ * appended to `params` — the same truth table as `matchesVisibility`
1697
+ * (runRecord.ts). An empty `IN ()` list and an empty `or` are `0` (nothing),
1698
+ * an empty `and` is `0` too (fail-closed, like the reference evaluator). The
1699
+ * body validator (`isRunVisibilityFilter`) already bounded depth and width. */
1700
+ function visibilitySql(f: RunVisibilityFilter, params: (string | number)[]): string {
1701
+ const inList = (column: string, values: readonly string[]): string => {
1702
+ if (values.length === 0) return "0";
1703
+ params.push(...values);
1704
+ return `${column} IN (${values.map(() => "?").join(",")})`;
1705
+ };
1706
+ switch (f.kind) {
1707
+ case "none":
1708
+ return "0";
1709
+ case "all":
1710
+ return "1";
1711
+ case "channels-in":
1712
+ return inList("channel_id", f.channelIds);
1713
+ case "visibility-in":
1714
+ return inList("channel_visibility", f.visibilities);
1715
+ case "repos-in":
1716
+ return inList("repo", f.repos);
1717
+ case "user-is":
1718
+ params.push(f.userId);
1719
+ return "user_id = ?";
1720
+ case "or":
1721
+ return f.of.length === 0 ? "0" : `(${f.of.map((p) => visibilitySql(p, params)).join(" OR ")})`;
1722
+ case "and":
1723
+ return f.of.length === 0 ? "0" : `(${f.of.map((p) => visibilitySql(p, params)).join(" AND ")})`;
1724
+ }
1725
+ }
1726
+
1727
+ /** Event rows → stored events. A corrupt row is skipped, never fatal — the rest of the run still reads. */
1728
+ function parseEventRows(rows: readonly EventRow[]): StoredRunEvent[] {
1729
+ const out: StoredRunEvent[] = [];
1730
+ for (const r of rows) {
1731
+ try {
1732
+ const parsed: unknown = JSON.parse(r.json);
1733
+ if (typeof parsed === "object" && parsed !== null && typeof (parsed as { type?: unknown }).type === "string") {
1734
+ out.push({ ...(parsed as RunEvent), seq: r.seq });
1735
+ }
1736
+ } catch {
1737
+ // skipped
1738
+ }
1739
+ }
1740
+ return out;
1741
+ }
1742
+
1743
+ /** The stored summary (record minus events); null when the row is unreadable. */
1744
+ function parseSummary(row: RunRow): Omit<RunRecord, "events"> | null {
1745
+ try {
1746
+ const parsed: unknown = JSON.parse(row.summary_json);
1747
+ return isRunRecord({ ...(parsed as object), events: [] })
1748
+ ? normalizeStored(parsed as Omit<RunRecord, "events">)
1749
+ : null;
1750
+ } catch {
1751
+ return null;
1752
+ }
1753
+ }
1754
+
1755
+ function parseRunId(v: unknown): Validated<string> {
1756
+ if (typeof v !== "string" || !RUN_ID_PATTERN.test(v)) return invalid("id must match ^[A-Za-z0-9_-]{1,64}$");
1757
+ return { ok: true, value: v };
1758
+ }
1759
+
1760
+ function parseStoreKey(b: Record<string, unknown>): Validated<string> {
1761
+ return parseScopeKey(b.storeKey, "storeKey");
1762
+ }
1763
+
1764
+ function parsePositiveInt(v: unknown, name: string, max: number): Validated<number> {
1765
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 1 || v > max)
1766
+ return invalid(`${name} must be an integer between 1 and ${max}`);
1767
+ return { ok: true, value: v };
1768
+ }
1769
+
1770
+ function parseRunPut(body: unknown): Validated<{ storeKey: string; record: RunRecord; proposal?: RunPolicyProposal }> {
1771
+ if (typeof body !== "object" || body === null) return invalid("body must be a JSON object");
1772
+ const b = body as Record<string, unknown>;
1773
+ const key = parseStoreKey(b);
1774
+ if (!key.ok) return key;
1775
+ if (!isRunRecord(b.record)) return invalid("record must be a RunRecord");
1776
+ const out: { storeKey: string; record: RunRecord; proposal?: RunPolicyProposal } = {
1777
+ storeKey: key.value,
1778
+ record: b.record,
1779
+ };
1780
+ if (b.policy !== undefined) {
1781
+ if (typeof b.policy !== "object" || b.policy === null) return invalid("policy must be an object");
1782
+ const p = b.policy as Record<string, unknown>;
1783
+ const policy: Partial<RetentionPolicy> = {};
1784
+ for (const field of ["retentionDays", "maxRuns", "maxBytes"] as const) {
1785
+ const v = p[field];
1786
+ if (v === undefined) continue;
1787
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 1)
1788
+ return invalid(`policy.${field} must be an integer >= 1`);
1789
+ policy[field] = v;
1790
+ }
1791
+ if (typeof b.policyUpdatedAt !== "number" || !Number.isFinite(b.policyUpdatedAt) || b.policyUpdatedAt < 0) {
1792
+ return invalid("policyUpdatedAt must be a non-negative number when a policy is proposed");
1793
+ }
1794
+ out.proposal = { policy, policyUpdatedAt: b.policyUpdatedAt };
1795
+ }
1796
+ return { ok: true, value: out };
1797
+ }
1798
+
1799
+ /** `{storeKey, id}` — the body of /runs/get, /runs/summary and /runs/delete, and the base of /runs/events. */
1800
+ function parseRunTarget(body: unknown): Validated<{ storeKey: string; id: string }> {
1801
+ if (typeof body !== "object" || body === null) return invalid("body must be a JSON object");
1802
+ const b = body as Record<string, unknown>;
1803
+ const key = parseStoreKey(b);
1804
+ if (!key.ok) return key;
1805
+ const id = parseRunId(b.id);
1806
+ if (!id.ok) return id;
1807
+ return { ok: true, value: { storeKey: key.value, id: id.value } };
1808
+ }
1809
+
1810
+ function parseRunEvents(body: unknown): Validated<{ storeKey: string; id: string; afterSeq: number; limit: number }> {
1811
+ const base = parseRunTarget(body);
1812
+ if (!base.ok) return base;
1813
+ const b = body as Record<string, unknown>;
1814
+ let afterSeq = 0;
1815
+ if (b.afterSeq !== undefined) {
1816
+ if (typeof b.afterSeq !== "number" || !Number.isInteger(b.afterSeq) || b.afterSeq < 0)
1817
+ return invalid("afterSeq must be a non-negative integer");
1818
+ afterSeq = b.afterSeq;
1819
+ }
1820
+ let limit = RUN_EVENTS_DEFAULT_PAGE;
1821
+ if (b.limit !== undefined) {
1822
+ const l = parsePositiveInt(b.limit, "limit", RUN_EVENTS_MAX_PAGE);
1823
+ if (!l.ok) return l;
1824
+ limit = l.value;
1825
+ }
1826
+ return { ok: true, value: { ...base.value, afterSeq, limit } };
1827
+ }
1828
+
1829
+ function parseRunList(body: unknown): Validated<{ storeKey: string; query: RunListOptions }> {
1830
+ if (typeof body !== "object" || body === null) return invalid("body must be a JSON object");
1831
+ const b = body as Record<string, unknown>;
1832
+ const key = parseStoreKey(b);
1833
+ if (!key.ok) return key;
1834
+ const query: RunListOptions = {};
1835
+ if (b.limit !== undefined) {
1836
+ // Over-asking is not an error: the cap is the contract (`limit: 1000` → 200 rows).
1837
+ if (typeof b.limit !== "number" || !Number.isInteger(b.limit) || b.limit < 1)
1838
+ return invalid("limit must be a positive integer");
1839
+ query.limit = Math.min(b.limit, RUN_LIST_MAX_LIMIT);
1840
+ }
1841
+ for (const field of ["before", "sinceMs"] as const) {
1842
+ const v = b[field];
1843
+ if (v === undefined) continue;
1844
+ if (typeof v !== "number" || !Number.isFinite(v)) return invalid(`${field} must be a number`);
1845
+ query[field] = v;
1846
+ }
1847
+ if (b.beforeId !== undefined) {
1848
+ const id = parseRunId(b.beforeId);
1849
+ if (!id.ok) return invalid("beforeId must match ^[A-Za-z0-9_-]{1,64}$");
1850
+ query.beforeId = id.value;
1851
+ }
1852
+ for (const field of ["agent", "channel"] as const) {
1853
+ const v = b[field];
1854
+ if (v === undefined) continue;
1855
+ if (typeof v !== "string" || v.length > MAX_KEY_CHARS)
1856
+ return invalid(`${field} must be a string of at most ${MAX_KEY_CHARS} characters`);
1857
+ query[field] = v;
1858
+ }
1859
+ if (b.visibleTo !== undefined) {
1860
+ // A malformed filter is a 400, never "all": the bot degrades to live rows
1861
+ // rather than the DO widening what an actor may see.
1862
+ if (!isRunVisibilityFilter(b.visibleTo)) return invalid("visibleTo must be a run visibility filter");
1863
+ if (boundParameters(b.visibleTo) > DO_MAX_BOUND_PARAMETERS - RUN_LIST_BASE_PARAMETERS)
1864
+ return invalid(`visibleTo names more than ${DO_MAX_BOUND_PARAMETERS - RUN_LIST_BASE_PARAMETERS} ids`);
1865
+ query.visibleTo = b.visibleTo;
1866
+ }
1867
+ return { ok: true, value: { storeKey: key.value, query } };
1868
+ }
1869
+
1870
+ /** Parameters the page query binds before any filter: the cursor pair (3) and the age floor (1),
1871
+ * plus `agent`, `channel`, and the LIMIT at most — the headroom `visibleTo` must fit under. */
1872
+ const RUN_LIST_BASE_PARAMETERS = 7;
1873
+
1874
+ /** How many `?` a filter binds (one per id, one per user). */
1875
+ function boundParameters(f: RunVisibilityFilter): number {
1876
+ switch (f.kind) {
1877
+ case "none":
1878
+ case "all":
1879
+ return 0;
1880
+ case "channels-in":
1881
+ return f.channelIds.length;
1882
+ case "visibility-in":
1883
+ return f.visibilities.length;
1884
+ case "repos-in":
1885
+ return f.repos.length;
1886
+ case "user-is":
1887
+ return 1;
1888
+ case "or":
1889
+ case "and":
1890
+ return f.of.reduce((n, p) => n + boundParameters(p), 0);
1891
+ }
1892
+ }
1893
+
1894
+ // ---------------------------------------------------------------------------
1895
+ // Auth (mirrors the resident Worker)
1896
+ // ---------------------------------------------------------------------------
1897
+
1898
+ function bearerToken(request: Request): string | null {
1899
+ const header = request.headers.get("authorization");
1900
+ if (!header || !header.startsWith("Bearer ")) return null;
1901
+ return header.slice("Bearer ".length);
1902
+ }
1903
+
1904
+ /** Constant-time byte comparison; the length early-return leaks only length. */
1905
+ function timingSafeEqual(a: string, b: string): boolean {
1906
+ const enc = new TextEncoder();
1907
+ const ab = enc.encode(a);
1908
+ const bb = enc.encode(b);
1909
+ if (ab.length !== bb.length) return false;
1910
+ let diff = 0;
1911
+ for (let i = 0; i < ab.length; i++) diff |= ab[i] ^ bb[i];
1912
+ return diff === 0;
1913
+ }
1914
+
1915
+ /** Fail closed: an unset/empty secret grants nothing. */
1916
+ function authorized(env: Env, request: Request): boolean {
1917
+ const token = bearerToken(request);
1918
+ return !!token && !!env.MEMORY_TOKEN && timingSafeEqual(token, env.MEMORY_TOKEN);
1919
+ }
1920
+
1921
+ // ---------------------------------------------------------------------------
1922
+ // Request validation
1923
+ // ---------------------------------------------------------------------------
1924
+
1925
+ type Validated<T> = { ok: true; value: T } | { ok: false; error: string };
1926
+
1927
+ function invalid<T>(error: string): Validated<T> {
1928
+ return { ok: false, error };
1929
+ }
1930
+
1931
+ /** A scope key is an opaque namespaced id (`org:acme`): non-empty,
1932
+ * bounded, no whitespace or control characters. `fieldName` names the body
1933
+ * field in the error (the run routes call theirs
1934
+ * `storeKey`). */
1935
+ function parseScopeKey(v: unknown, fieldName = "scopeKey"): Validated<string> {
1936
+ if (typeof v !== "string" || v.length === 0) return invalid(`${fieldName} must be a non-empty string`);
1937
+ if (v.length > MAX_KEY_CHARS) return invalid(`${fieldName} must be at most ${MAX_KEY_CHARS} characters`);
1938
+ if (/[\s\p{C}]/u.test(v)) return invalid(`${fieldName} must not contain whitespace or control characters`);
1939
+ return { ok: true, value: v };
1940
+ }
1941
+
1942
+ function parseLimit(v: unknown): Validated<number> {
1943
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 1 || v > MAX_LIMIT) {
1944
+ return invalid(`limit must be an integer between 1 and ${MAX_LIMIT}`);
1945
+ }
1946
+ return { ok: true, value: v };
1947
+ }
1948
+
1949
+ /** `POST /list {scopeKey, limit, query?}`. */
1950
+ function parseList(body: unknown): Validated<{ scopeKey: string; limit: number; query?: string }> {
1951
+ if (typeof body !== "object" || body === null) return invalid("body must be a JSON object");
1952
+ const b = body as Record<string, unknown>;
1953
+ const scope = parseScopeKey(b.scopeKey);
1954
+ if (!scope.ok) return scope;
1955
+ const limit = parseLimit(b.limit);
1956
+ if (!limit.ok) return limit;
1957
+ if (b.query !== undefined) {
1958
+ if (typeof b.query !== "string") return invalid("query must be a string");
1959
+ if (b.query.length > MAX_QUERY_CHARS) return invalid(`query must be at most ${MAX_QUERY_CHARS} characters`);
1960
+ }
1961
+ return {
1962
+ ok: true,
1963
+ value: { scopeKey: scope.value, limit: limit.value, ...(typeof b.query === "string" ? { query: b.query } : {}) },
1964
+ };
1965
+ }
1966
+
1967
+ /** `POST /forget {scopeKey, id}`: the id is an opaque key, same caps as scopeKey. */
1968
+ function parseForget(body: unknown): Validated<{ scopeKey: string; id: string }> {
1969
+ if (typeof body !== "object" || body === null) return invalid("body must be a JSON object");
1970
+ const b = body as Record<string, unknown>;
1971
+ const scope = parseScopeKey(b.scopeKey);
1972
+ if (!scope.ok) return scope;
1973
+ if (typeof b.id !== "string" || b.id.length === 0 || b.id.length > MAX_KEY_CHARS || /[\s\p{Cc}]/u.test(b.id)) {
1974
+ return invalid(`id must be a non-empty string of at most ${MAX_KEY_CHARS} characters with no whitespace`);
1975
+ }
1976
+ return { ok: true, value: { scopeKey: scope.value, id: b.id } };
1977
+ }
1978
+
1979
+ function parseRetrieve(body: unknown): Validated<{ scopeKey: string; query: string; limit: number }> {
1980
+ if (typeof body !== "object" || body === null) return invalid("body must be a JSON object");
1981
+ const b = body as Record<string, unknown>;
1982
+ const scope = parseScopeKey(b.scopeKey);
1983
+ if (!scope.ok) return scope;
1984
+ if (typeof b.query !== "string") return invalid("query must be a string");
1985
+ if (b.query.length > MAX_QUERY_CHARS) return invalid(`query must be at most ${MAX_QUERY_CHARS} characters`);
1986
+ if (typeof b.limit !== "number" || !Number.isInteger(b.limit) || b.limit < 1 || b.limit > MAX_LIMIT) {
1987
+ return invalid(`limit must be an integer between 1 and ${MAX_LIMIT}`);
1988
+ }
1989
+ return { ok: true, value: { scopeKey: scope.value, query: b.query, limit: b.limit } };
1990
+ }
1991
+
1992
+ function parseCandidate(v: unknown, i: number): Validated<MemoryCandidate> {
1993
+ const at = `records[${i}]`;
1994
+ if (typeof v !== "object" || v === null) return invalid(`${at} must be an object`);
1995
+ const c = v as Record<string, unknown>;
1996
+ if (c.kind !== "fact" && c.kind !== "summary") return invalid(`${at}.kind must be "fact" or "summary"`);
1997
+ if (typeof c.text !== "string" || c.text.trim().length === 0) return invalid(`${at}.text must be a non-empty string`);
1998
+ if (c.text.length > MAX_TEXT_CHARS) return invalid(`${at}.text must be at most ${MAX_TEXT_CHARS} characters`);
1999
+ if (
2000
+ typeof c.sourceThreadKey !== "string" ||
2001
+ c.sourceThreadKey.length === 0 ||
2002
+ c.sourceThreadKey.length > MAX_KEY_CHARS
2003
+ ) {
2004
+ return invalid(`${at}.sourceThreadKey must be a non-empty string`);
2005
+ }
2006
+ const out: MemoryCandidate = { kind: c.kind, text: c.text, sourceThreadKey: c.sourceThreadKey };
2007
+ if (c.keywords !== undefined) {
2008
+ if (
2009
+ !Array.isArray(c.keywords) ||
2010
+ c.keywords.length > MAX_KEYWORDS ||
2011
+ !c.keywords.every((k) => typeof k === "string" && k.length > 0 && k.length <= MAX_KEYWORD_CHARS)
2012
+ ) {
2013
+ return invalid(`${at}.keywords must be an array of at most ${MAX_KEYWORDS} short strings`);
2014
+ }
2015
+ out.keywords = c.keywords as string[];
2016
+ }
2017
+ if (c.sourceRunId !== undefined) {
2018
+ if (typeof c.sourceRunId !== "string" || c.sourceRunId.length > MAX_KEY_CHARS)
2019
+ return invalid(`${at}.sourceRunId must be a string`);
2020
+ out.sourceRunId = c.sourceRunId;
2021
+ }
2022
+ if (c.confidence !== undefined) {
2023
+ if (typeof c.confidence !== "number" || !Number.isFinite(c.confidence) || c.confidence < 0 || c.confidence > 1) {
2024
+ return invalid(`${at}.confidence must be a number in [0, 1]`);
2025
+ }
2026
+ out.confidence = c.confidence;
2027
+ }
2028
+ if (c.supersedes !== undefined) {
2029
+ if (typeof c.supersedes !== "string" || c.supersedes.length > MAX_KEY_CHARS)
2030
+ return invalid(`${at}.supersedes must be a string`);
2031
+ out.supersedes = c.supersedes;
2032
+ }
2033
+ return { ok: true, value: out };
2034
+ }
2035
+
2036
+ /** Upper bound on a caller-supplied per-scope cap. */
2037
+ const MAX_SCOPE_CAP = 10_000;
2038
+
2039
+ function parseWrite(body: unknown): Validated<{ scopeKey: string; records: MemoryCandidate[]; cap?: number }> {
2040
+ if (typeof body !== "object" || body === null) return invalid("body must be a JSON object");
2041
+ const b = body as Record<string, unknown>;
2042
+ const scope = parseScopeKey(b.scopeKey);
2043
+ if (!scope.ok) return scope;
2044
+ let cap: number | undefined;
2045
+ if (b.cap !== undefined) {
2046
+ if (typeof b.cap !== "number" || !Number.isInteger(b.cap) || b.cap < 1 || b.cap > MAX_SCOPE_CAP) {
2047
+ return invalid(`cap must be an integer between 1 and ${MAX_SCOPE_CAP}`);
2048
+ }
2049
+ cap = b.cap;
2050
+ }
2051
+ if (!Array.isArray(b.records)) return invalid("records must be an array");
2052
+ if (b.records.length > MAX_BATCH) return invalid(`records must hold at most ${MAX_BATCH} candidates`);
2053
+ const records: MemoryCandidate[] = [];
2054
+ for (let i = 0; i < b.records.length; i++) {
2055
+ const c = parseCandidate(b.records[i], i);
2056
+ if (!c.ok) return c;
2057
+ records.push(c.value);
2058
+ }
2059
+ return { ok: true, value: { scopeKey: scope.value, records, ...(cap !== undefined ? { cap } : {}) } };
2060
+ }
2061
+
2062
+ // ---------------------------------------------------------------------------
2063
+ // Worker entry
2064
+ // ---------------------------------------------------------------------------
2065
+
2066
+ function json(data: unknown, status = 200): Response {
2067
+ return new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } });
2068
+ }
2069
+
2070
+ // ---------------------------------------------------------------------------
2071
+ // Live-run transcripts (run-history item 32)
2072
+ // ---------------------------------------------------------------------------
2073
+
2074
+ /** One object per LIVE run, named by run id: the raw transcript a resumed run
2075
+ * continues from, one row per content part (never near the 2 MB row limit),
2076
+ * attachments over the reference threshold stored once. Fenced by its own
2077
+ * `owner` row — set at claim, replaced by reclaim — because this object and
2078
+ * the history object commit independently, and a zombie generation whose
2079
+ * history write is about to be refused must not land transcript rows either. */
2080
+ export class RunTranscriptDO extends DurableObject<Env> {
2081
+ private readonly sql: SqlStorage;
2082
+
2083
+ constructor(ctx: DurableObjectState, env: Env) {
2084
+ super(ctx, env);
2085
+ this.sql = ctx.storage.sql;
2086
+ this.sql.exec(`
2087
+ CREATE TABLE IF NOT EXISTS owner (k INTEGER PRIMARY KEY CHECK (k = 1), gen TEXT NOT NULL);
2088
+ CREATE TABLE IF NOT EXISTS run_messages (
2089
+ idx INTEGER NOT NULL,
2090
+ part INTEGER NOT NULL,
2091
+ json TEXT NOT NULL,
2092
+ PRIMARY KEY (idx, part)
2093
+ );
2094
+ CREATE TABLE IF NOT EXISTS attachments (
2095
+ ref TEXT PRIMARY KEY,
2096
+ media_type TEXT NOT NULL,
2097
+ data TEXT NOT NULL
2098
+ );
2099
+ `);
2100
+ }
2101
+
2102
+ async setOwner(gen: string): Promise<{ ok: true }> {
2103
+ this.sql.exec(`INSERT INTO owner (k, gen) VALUES (1, ?) ON CONFLICT(k) DO UPDATE SET gen = excluded.gen`, gen);
2104
+ return { ok: true };
2105
+ }
2106
+
2107
+ private owner(): string | undefined {
2108
+ return this.sql.exec<{ gen: string }>(`SELECT gen FROM owner WHERE k = 1`).toArray()[0]?.gen;
2109
+ }
2110
+
2111
+ async write(gen: string, rows: TranscriptRow[], attachments: TranscriptAttachment[]): Promise<FenceResult> {
2112
+ let out: FenceResult = { ok: true };
2113
+ this.ctx.storage.transactionSync(() => {
2114
+ const owner = this.owner();
2115
+ if (owner === undefined) {
2116
+ out = { ok: false, reason: "unknown-run" };
2117
+ return;
2118
+ }
2119
+ if (owner !== gen) {
2120
+ out = { ok: false, reason: "fenced" };
2121
+ return;
2122
+ }
2123
+ for (const a of attachments) {
2124
+ this.sql.exec(
2125
+ `INSERT OR REPLACE INTO attachments (ref, media_type, data) VALUES (?, ?, ?)`,
2126
+ a.ref,
2127
+ a.mediaType,
2128
+ a.data,
2129
+ );
2130
+ }
2131
+ for (const r of rows) {
2132
+ this.sql.exec(`INSERT OR REPLACE INTO run_messages (idx, part, json) VALUES (?, ?, ?)`, r.idx, r.part, r.json);
2133
+ }
2134
+ });
2135
+ return out;
2136
+ }
2137
+
2138
+ async read(): Promise<{ rows: TranscriptRow[]; attachments: TranscriptAttachment[] }> {
2139
+ const rows = this.sql
2140
+ .exec<{ idx: number; part: number; json: string }>(`SELECT idx, part, json FROM run_messages ORDER BY idx, part`)
2141
+ .toArray();
2142
+ const attachments = this.sql
2143
+ .exec<{ ref: string; media_type: string; data: string }>(`SELECT ref, media_type, data FROM attachments`)
2144
+ .toArray()
2145
+ .map((a) => ({ ref: a.ref, mediaType: a.media_type, data: a.data }));
2146
+ return { rows, attachments };
2147
+ }
2148
+
2149
+ async clear(): Promise<{ ok: true }> {
2150
+ this.ctx.storage.transactionSync(() => {
2151
+ this.sql.exec(`DELETE FROM run_messages`);
2152
+ this.sql.exec(`DELETE FROM attachments`);
2153
+ this.sql.exec(`DELETE FROM owner`);
2154
+ });
2155
+ return { ok: true };
2156
+ }
2157
+ }
2158
+
2159
+ const LEDGER_ROUTES = new Set([
2160
+ "/runs/claim",
2161
+ "/runs/heartbeat",
2162
+ "/runs/append",
2163
+ "/runs/step",
2164
+ "/runs/state",
2165
+ "/runs/inbox",
2166
+ "/runs/inbox/read",
2167
+ "/runs/stop",
2168
+ "/runs/handoff",
2169
+ "/runs/finishing",
2170
+ "/runs/finish",
2171
+ "/runs/abandon",
2172
+ "/runs/reclaim",
2173
+ "/runs/live",
2174
+ "/runs/live-events",
2175
+ "/runs/transcript/owner",
2176
+ "/runs/transcript/write",
2177
+ "/runs/transcript/read",
2178
+ "/runs/transcript/clear",
2179
+ ]);
2180
+
2181
+ /** Routes whose bodies may carry a record, a transcript chunk, or an event batch. */
2182
+ const WIDE_BODY_ROUTES = new Set(["/runs/put", "/runs/finish", "/runs/append", "/runs/transcript/write"]);
2183
+
2184
+ const gen = (v: unknown): Validated<string> =>
2185
+ typeof v === "string" && GEN_PATTERN.test(v)
2186
+ ? { ok: true, value: v }
2187
+ : invalid("gen must match the generation pattern");
2188
+
2189
+ function parseLeaseMs(v: unknown): Validated<number> {
2190
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 1_000 || v > 3_600_000) {
2191
+ return invalid("leaseMs must be an integer between 1000 and 3600000");
2192
+ }
2193
+ return { ok: true, value: v };
2194
+ }
2195
+
2196
+ function parseClaim(b: Record<string, unknown>): Validated<ClaimRequest> {
2197
+ const run = b.run;
2198
+ if (typeof run !== "object" || run === null) return invalid("run must be an object");
2199
+ const r = run as Record<string, unknown>;
2200
+ const runId = parseRunId(r.runId);
2201
+ if (!runId.ok) return runId;
2202
+ const g = gen(r.gen);
2203
+ if (!g.ok) return g;
2204
+ const lease = parseLeaseMs(r.leaseMs);
2205
+ if (!lease.ok) return lease;
2206
+ if (typeof r.threadKey !== "string" || r.threadKey.length === 0 || r.threadKey.length > 256) {
2207
+ return invalid("run.threadKey must be a non-empty string");
2208
+ }
2209
+ if (typeof r.startedAt !== "number" || !Number.isFinite(r.startedAt))
2210
+ return invalid("run.startedAt must be a number");
2211
+ if (typeof r.meta !== "object" || r.meta === null) return invalid("run.meta must be an object");
2212
+ if (typeof r.system !== "string") return invalid("run.system must be a string");
2213
+ if (!Array.isArray(r.tools)) return invalid("run.tools must be an array");
2214
+ if (r.card !== undefined && r.card !== null) {
2215
+ const c = r.card as Record<string, unknown>;
2216
+ if (typeof c.channel !== "string" || typeof c.ts !== "string") return invalid("run.card must be {channel, ts}");
2217
+ }
2218
+ if (r.state !== undefined && (typeof r.state !== "object" || r.state === null))
2219
+ return invalid("run.state must be an object");
2220
+ if (r.phase !== undefined && r.phase !== "attaching" && r.phase !== "live")
2221
+ return invalid("run.phase must be attaching or live");
2222
+ return {
2223
+ ok: true,
2224
+ value: {
2225
+ runId: runId.value,
2226
+ threadKey: r.threadKey,
2227
+ gen: g.value,
2228
+ leaseMs: lease.value,
2229
+ startedAt: r.startedAt,
2230
+ meta: r.meta as ClaimRequest["meta"],
2231
+ card: (r.card as ClaimRequest["card"]) ?? null,
2232
+ system: r.system,
2233
+ tools: r.tools as ClaimRequest["tools"],
2234
+ ...(r.state ? { state: r.state as RunState } : {}),
2235
+ ...(r.phase !== undefined ? { phase: r.phase as "attaching" | "live" } : {}),
2236
+ },
2237
+ };
2238
+ }
2239
+
2240
+ function parseStep(v: unknown): Validated<StepRecord> {
2241
+ if (typeof v !== "object" || v === null) return invalid("record must be an object");
2242
+ const s = v as Record<string, unknown>;
2243
+ for (const k of ["step", "seq", "turnIndex", "inboxConsumedSeq", "remainingMs", "turn", "iteration"] as const) {
2244
+ if (typeof s[k] !== "number" || !Number.isInteger(s[k]) || (s[k] as number) < 0) {
2245
+ return invalid(`record.${k} must be a non-negative integer`);
2246
+ }
2247
+ }
2248
+ if (!Array.isArray(s.inFlight)) return invalid("record.inFlight must be an array");
2249
+ for (const c of s.inFlight) {
2250
+ const call = c as Record<string, unknown>;
2251
+ if (typeof call?.callId !== "string" || typeof call?.tool !== "string")
2252
+ return invalid("record.inFlight entries must be {callId, tool}");
2253
+ }
2254
+ return { ok: true, value: s as unknown as StepRecord };
2255
+ }
2256
+
2257
+ function parseTranscriptRows(v: unknown): Validated<TranscriptRow[]> {
2258
+ if (!Array.isArray(v)) return invalid("rows must be an array");
2259
+ for (const r of v) {
2260
+ const row = r as Record<string, unknown>;
2261
+ if (
2262
+ typeof row?.idx !== "number" ||
2263
+ !Number.isInteger(row.idx) ||
2264
+ row.idx < 0 ||
2265
+ typeof row?.part !== "number" ||
2266
+ !Number.isInteger(row.part) ||
2267
+ row.part < 0 ||
2268
+ typeof row?.json !== "string"
2269
+ ) {
2270
+ return invalid("rows entries must be {idx, part, json}");
2271
+ }
2272
+ }
2273
+ return { ok: true, value: v as TranscriptRow[] };
2274
+ }
2275
+
2276
+ function parseAttachments(v: unknown): Validated<TranscriptAttachment[]> {
2277
+ if (!Array.isArray(v)) return invalid("attachments must be an array");
2278
+ for (const a of v) {
2279
+ const att = a as Record<string, unknown>;
2280
+ if (typeof att?.ref !== "string" || typeof att?.mediaType !== "string" || typeof att?.data !== "string") {
2281
+ return invalid("attachments entries must be {ref, mediaType, data}");
2282
+ }
2283
+ }
2284
+ return { ok: true, value: v as TranscriptAttachment[] };
2285
+ }
2286
+
2287
+ /** The ledger routes (run-history items 28–34). Bodies are validated before
2288
+ * any object call; fenced answers are 409 with the reason; observability
2289
+ * lines carry ids and counts only. */
2290
+ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<Response> {
2291
+ if (typeof body !== "object" || body === null) return json({ error: "body must be a JSON object" }, 400);
2292
+ const b = body as Record<string, unknown>;
2293
+ const fenced = (r: FenceResult) => (r.ok ? json(r) : json(r, 409));
2294
+
2295
+ if (pathname.startsWith("/runs/transcript/")) {
2296
+ const runId = parseRunId(b.runId);
2297
+ if (!runId.ok) return json({ error: runId.error }, 400);
2298
+ const stub = env.RUN_TRANSCRIPTS.get(env.RUN_TRANSCRIPTS.idFromName(runId.value));
2299
+ if (pathname === "/runs/transcript/owner") {
2300
+ const g = gen(b.gen);
2301
+ if (!g.ok) return json({ error: g.error }, 400);
2302
+ return json(await stub.setOwner(g.value));
2303
+ }
2304
+ if (pathname === "/runs/transcript/write") {
2305
+ const g = gen(b.gen);
2306
+ if (!g.ok) return json({ error: g.error }, 400);
2307
+ const rows = parseTranscriptRows(b.rows);
2308
+ if (!rows.ok) return json({ error: rows.error }, 400);
2309
+ const attachments = parseAttachments(b.attachments);
2310
+ if (!attachments.ok) return json({ error: attachments.error }, 400);
2311
+ const r = await stub.write(g.value, rows.value, attachments.value);
2312
+ console.log(
2313
+ `[runs/transcript/write] ${runId.value} <- ${rows.value.length} row(s), ${attachments.value.length} attachment(s), ok=${r.ok}`,
2314
+ );
2315
+ return fenced(r);
2316
+ }
2317
+ if (pathname === "/runs/transcript/read") return json(await stub.read());
2318
+ return json(await stub.clear());
2319
+ }
2320
+
2321
+ const key = parseStoreKey(b);
2322
+ if (!key.ok) return json({ error: key.error }, 400);
2323
+ const stub = env.RUNS.get(env.RUNS.idFromName(key.value));
2324
+ const now = systemClock();
2325
+
2326
+ if (pathname === "/runs/claim") {
2327
+ const req = parseClaim(b);
2328
+ if (!req.ok) return json({ error: req.error }, 400);
2329
+ const r = await stub.claim(req.value, now);
2330
+ console.log(
2331
+ `[runs/claim] ${key.value} ${req.value.runId} on ${req.value.threadKey} → ${r.ok ? "claimed" : r.reason}`,
2332
+ );
2333
+ return r.ok ? json(r) : json(r, 409);
2334
+ }
2335
+ if (pathname === "/runs/live") return json({ runs: await stub.listLive() });
2336
+ if (pathname === "/runs/reclaim") {
2337
+ const g = gen(b.gen);
2338
+ if (!g.ok) return json({ error: g.error }, 400);
2339
+ const lease = parseLeaseMs(b.leaseMs);
2340
+ if (!lease.ok) return json({ error: lease.error }, 400);
2341
+ const at = typeof b.now === "number" && Number.isFinite(b.now) ? b.now : now;
2342
+ // The RPC type mapping reads the row's open-ended JSON fields as
2343
+ // unserializable; the values are plain JSON, so the cast only restores the
2344
+ // declared shape.
2345
+ const runs = (await stub.reclaim(g.value, at, lease.value)) as unknown as ReclaimedRun[];
2346
+ console.log(`[runs/reclaim] ${key.value} ${g.value} took ${runs.length} run(s)`);
2347
+ return json({ runs });
2348
+ }
2349
+ if (pathname === "/runs/handoff") {
2350
+ const g = gen(b.gen);
2351
+ if (!g.ok) return json({ error: g.error }, 400);
2352
+ const ids: unknown[] = Array.isArray(b.runIds) ? b.runIds : [];
2353
+ if (!Array.isArray(b.runIds) || !ids.every((id) => typeof id === "string" && RUN_ID_PATTERN.test(id))) {
2354
+ return json({ error: "runIds must be an array of run ids" }, 400);
2355
+ }
2356
+ const runIds = ids as string[];
2357
+ const r = await stub.handoff(g.value, runIds);
2358
+ console.log(`[runs/handoff] ${key.value} ${g.value} marked ${r.marked.length}/${runIds.length}`);
2359
+ return json(r);
2360
+ }
2361
+
2362
+ const runId = parseRunId(b.runId);
2363
+ if (!runId.ok) return json({ error: runId.error }, 400);
2364
+ if (pathname === "/runs/inbox") {
2365
+ if (typeof b.message !== "object" || b.message === null) return json({ error: "message must be an object" }, 400);
2366
+ return json(await stub.pushInbox(runId.value, b.message as Record<string, unknown>));
2367
+ }
2368
+ if (pathname === "/runs/live-events") return json({ events: await stub.liveEvents(runId.value) });
2369
+ if (pathname === "/runs/inbox/read") {
2370
+ const after = b.afterSeq === undefined ? 0 : b.afterSeq;
2371
+ if (typeof after !== "number" || !Number.isInteger(after) || after < 0) {
2372
+ return json({ error: "afterSeq must be a non-negative integer" }, 400);
2373
+ }
2374
+ return json({ items: await stub.readInbox(runId.value, after) });
2375
+ }
2376
+ if (pathname === "/runs/stop") {
2377
+ if (b.mode !== "soft" && b.mode !== "hard") return json({ error: "mode must be soft or hard" }, 400);
2378
+ return json(await stub.requestStop(runId.value, b.mode, now));
2379
+ }
2380
+
2381
+ const g = gen(b.gen);
2382
+ if (!g.ok) return json({ error: g.error }, 400);
2383
+ if (pathname === "/runs/heartbeat") {
2384
+ const lease = parseLeaseMs(b.leaseMs);
2385
+ if (!lease.ok) return json({ error: lease.error }, 400);
2386
+ const r = await stub.heartbeat(runId.value, g.value, lease.value, now);
2387
+ return r.ok ? json(r) : json(r, 409);
2388
+ }
2389
+ if (pathname === "/runs/append") {
2390
+ if (!Array.isArray(b.events)) return json({ error: "events must be an array" }, 400);
2391
+ const events: Array<{ seq: number; json: string }> = [];
2392
+ for (const e of b.events) {
2393
+ const ev = e as Record<string, unknown>;
2394
+ if (typeof ev?.seq !== "number" || !Number.isInteger(ev.seq) || ev.seq < 1)
2395
+ return json({ error: "every event needs an integer seq ≥ 1" }, 400);
2396
+ const text = JSON.stringify(e);
2397
+ if (utf8ByteLength(text) > MAX_EVENT_BYTES)
2398
+ return json({ error: `event ${ev.seq} exceeds ${MAX_EVENT_BYTES} bytes` }, 400);
2399
+ events.push({ seq: ev.seq, json: text });
2400
+ }
2401
+ const r = await stub.appendEvents(runId.value, g.value, events);
2402
+ console.log(`[runs/append] ${key.value} ${runId.value} <- ${events.length} event(s), ok=${r.ok}`);
2403
+ return fenced(r);
2404
+ }
2405
+ if (pathname === "/runs/step") {
2406
+ const record = parseStep(b.record);
2407
+ if (!record.ok) return json({ error: record.error }, 400);
2408
+ return fenced(await stub.recordStep(runId.value, g.value, record.value));
2409
+ }
2410
+ if (pathname === "/runs/state") {
2411
+ if (typeof b.state !== "object" || b.state === null) return json({ error: "state must be an object" }, 400);
2412
+ return fenced(await stub.setState(runId.value, g.value, b.state as RunState));
2413
+ }
2414
+ if (pathname === "/runs/finishing") return fenced(await stub.finishing(runId.value, g.value));
2415
+ if (pathname === "/runs/abandon") {
2416
+ const r = await stub.abandon(runId.value, g.value);
2417
+ console.log(`[runs/abandon] ${key.value} ${runId.value} ok=${r.ok}${r.ok ? "" : ` ${r.reason}`}`);
2418
+ return fenced(r);
2419
+ }
2420
+ if (pathname === "/runs/finish") {
2421
+ const parsed = parseRunPut({ ...b, storeKey: key.value });
2422
+ if (!parsed.ok) return json({ error: parsed.error }, 400);
2423
+ if (parsed.value.record.id !== runId.value) return json({ error: "record.id must equal runId" }, 400);
2424
+ const r = await stub.finish(runId.value, g.value, parsed.value.record, parsed.value.proposal);
2425
+ console.log(`[runs/finish] ${key.value} ${runId.value} ok=${r.ok}${r.ok ? ` stored=${r.stored}` : ` ${r.reason}`}`);
2426
+ return r.ok ? json(r) : json(r, 409);
2427
+ }
2428
+ return json({ error: "not found" }, 404);
2429
+ }
2430
+
2431
+ /** The `/runs/*` routes. Observability lines carry ids + counts only —
2432
+ * never event text. A bad `id` is 400 before any DO call. */
2433
+ async function handleRuns(pathname: string, body: unknown, env: Env): Promise<Response> {
2434
+ const stub = (key: string) => env.RUNS.get(env.RUNS.idFromName(key));
2435
+ if (LEDGER_ROUTES.has(pathname)) return handleLedger(pathname, body, env);
2436
+ if (pathname === "/runs/put") {
2437
+ const parsed = parseRunPut(body);
2438
+ if (!parsed.ok) return json({ error: parsed.error }, 400);
2439
+ const { storeKey, record, proposal } = parsed.value;
2440
+ const result = await stub(storeKey).put(record, proposal);
2441
+ console.log(
2442
+ `[runs/put] ${storeKey} <- ${record.id} (${record.storedEventCount} events, stored=${result.stored}, ${result.retained} retained)`,
2443
+ );
2444
+ return json(result);
2445
+ }
2446
+ if (pathname === "/runs/get") {
2447
+ const parsed = parseRunTarget(body);
2448
+ if (!parsed.ok) return json({ error: parsed.error }, 400);
2449
+ const record = await stub(parsed.value.storeKey).get(parsed.value.id);
2450
+ console.log(
2451
+ `[runs/get] ${parsed.value.storeKey} ${parsed.value.id} -> ${record ? `${record.events.length} events` : "not found"}`,
2452
+ );
2453
+ return json({ record });
2454
+ }
2455
+ if (pathname === "/runs/summary") {
2456
+ const parsed = parseRunTarget(body);
2457
+ if (!parsed.ok) return json({ error: parsed.error }, 400);
2458
+ const summary = await stub(parsed.value.storeKey).summary(parsed.value.id);
2459
+ console.log(`[runs/summary] ${parsed.value.storeKey} ${parsed.value.id} -> ${summary ? "found" : "not found"}`);
2460
+ return json({ summary });
2461
+ }
2462
+ if (pathname === "/runs/list") {
2463
+ const parsed = parseRunList(body);
2464
+ if (!parsed.ok) return json({ error: parsed.error }, 400);
2465
+ const result = await stub(parsed.value.storeKey).list(parsed.value.query);
2466
+ console.log(`[runs/list] ${parsed.value.storeKey} -> ${result.items.length} runs`);
2467
+ return json(result);
2468
+ }
2469
+ if (pathname === "/runs/events") {
2470
+ const parsed = parseRunEvents(body);
2471
+ if (!parsed.ok) return json({ error: parsed.error }, 400);
2472
+ const { storeKey, id, afterSeq, limit } = parsed.value;
2473
+ const result = await stub(storeKey).events(id, afterSeq, limit);
2474
+ console.log(
2475
+ `[runs/events] ${storeKey} ${id} after ${afterSeq} -> ${result ? `${result.events.length} events` : "not found"}`,
2476
+ );
2477
+ return json(result ?? { events: null });
2478
+ }
2479
+ // /runs/delete
2480
+ const parsed = parseRunTarget(body);
2481
+ if (!parsed.ok) return json({ error: parsed.error }, 400);
2482
+ const deleted = await stub(parsed.value.storeKey).delete(parsed.value.id);
2483
+ console.log(`[runs/delete] ${parsed.value.storeKey} ${parsed.value.id} -> deleted=${deleted}`);
2484
+ return json({ ok: true, deleted });
2485
+ }
2486
+
2487
+ const ROUTES = new Set([
2488
+ ...CONFIG_ROUTES,
2489
+ "/retrieve",
2490
+ "/write",
2491
+ "/list",
2492
+ "/forget",
2493
+ "/schedules/record",
2494
+ "/schedules/latest",
2495
+ "/runs/put",
2496
+ "/runs/get",
2497
+ "/runs/summary",
2498
+ "/runs/list",
2499
+ "/runs/events",
2500
+ "/runs/delete",
2501
+ ...LEDGER_ROUTES,
2502
+ ]);
2503
+
2504
+ /** The two decisions `fetch` makes once and hands down: is the path one of
2505
+ * ours, and did the bearer check out. `handleRequest` answers from them in the
2506
+ * order it always did (404, then 405, then 401) and never re-decides. */
2507
+ interface Admission {
2508
+ known: boolean;
2509
+ authorized: boolean;
2510
+ }
2511
+
2512
+ /** Every request, once `fetch` has decided whether it gets a root. */
2513
+ async function handleRequest(request: Request, env: Env, admission: Admission): Promise<Response> {
2514
+ const url = new URL(request.url);
2515
+ if (url.pathname === "/healthz" && request.method === "GET")
2516
+ return json({ ok: true, build: BUILD, features: ["memory", "schedules", "runs", "config"] });
2517
+ if (!admission.known) return json({ error: "not found" }, 404);
2518
+ if (request.method !== "POST") return json({ error: "method not allowed" }, 405);
2519
+ if (!admission.authorized) return json({ error: "unauthorized" }, 401);
2520
+
2521
+ // Size fence BEFORE parsing: a caller holding a valid bearer still can't
2522
+ // make us JSON-parse an oversized body just to be told 400 by the field
2523
+ // caps. Content-Length must be a plain digit string (RFC 9110) — that
2524
+ // rules out the absent header of a chunked/streamed body, a blank value,
2525
+ // and forms `Number()` would accept ("0x1000", "5e2", "12.5"); each is
2526
+ // 411 Length Required. A well-formed length over the cap is 413. Every
2527
+ // legitimate client (WorkerMemoryStore) sends a sized JSON body. The cap
2528
+ // is per route — decided AFTER routing and before the parse: /runs/put
2529
+ // carries a whole run record (budgeted to 1.5 MiB upstream) and gets 2 MiB;
2530
+ // every other route keeps the 512 KB fence.
2531
+ const header = request.headers.get("content-length");
2532
+ if (header === null || !/^\d+$/.test(header.trim())) {
2533
+ return json({ error: "body must declare a numeric Content-Length" }, 411);
2534
+ }
2535
+ // The ledger's bulk routes carry a finished record, a transcript chunk, or
2536
+ // an event batch (32 × 64 KiB) and share /runs/put's fence.
2537
+ const maxBodyBytes = WIDE_BODY_ROUTES.has(url.pathname) ? MAX_RUN_PUT_BODY_BYTES : MAX_BODY_BYTES;
2538
+ if (Number(header) > maxBodyBytes) {
2539
+ return json({ error: `body must be at most ${maxBodyBytes} bytes` }, 413);
2540
+ }
2541
+
2542
+ let body: unknown;
2543
+ try {
2544
+ body = await request.json();
2545
+ } catch {
2546
+ return json({ error: "body must be valid JSON" }, 400);
2547
+ }
2548
+
2549
+ if (url.pathname === "/schedules/record") {
2550
+ const parsed = parseScheduleFiring(body);
2551
+ if (!parsed.ok) return json({ error: parsed.error }, 400);
2552
+ const firing = parsed.value;
2553
+ const retained = await env.SCHEDULES.get(env.SCHEDULES.idFromName(SCHEDULES_OBJECT)).record(firing);
2554
+ console.log(
2555
+ `[schedules/record] ${firing.schedule} ${firing.outcome}${firing.runId ? ` run ${firing.runId}` : ""} (${retained} retained)`,
2556
+ );
2557
+ return json({ ok: true, retained });
2558
+ }
2559
+ if (url.pathname === "/schedules/latest") {
2560
+ const firings = await env.SCHEDULES.get(env.SCHEDULES.idFromName(SCHEDULES_OBJECT)).latest();
2561
+ console.log(`[schedules/latest] -> ${firings.length} schedules`);
2562
+ return json({ firings });
2563
+ }
2564
+ if (url.pathname.startsWith("/runs/")) return handleRuns(url.pathname, body, env);
2565
+ if (url.pathname.startsWith("/config/")) return handleConfig(url.pathname, body, env);
2566
+
2567
+ if (url.pathname === "/retrieve") {
2568
+ const parsed = parseRetrieve(body);
2569
+ if (!parsed.ok) return json({ error: parsed.error }, 400);
2570
+ const { scopeKey, query, limit } = parsed.value;
2571
+ const stub = env.MEMORY.get(env.MEMORY.idFromName(scopeKey));
2572
+ const records = await stub.retrieve(scopeKey, query, limit);
2573
+ // Observability (counts + scopeKey only, never record content/PII): makes
2574
+ // `wrangler tail switchboard-memory` show retrieve traffic and depth.
2575
+ console.log(`[retrieve] ${scopeKey} -> ${records.length} records`);
2576
+ return json({ records });
2577
+ }
2578
+
2579
+ if (url.pathname === "/list") {
2580
+ const parsed = parseList(body);
2581
+ if (!parsed.ok) return json({ error: parsed.error }, 400);
2582
+ const { scopeKey, limit, query } = parsed.value;
2583
+ const records = await env.MEMORY.get(env.MEMORY.idFromName(scopeKey)).list(scopeKey, limit, query);
2584
+ console.log(`[list] ${scopeKey} -> ${records.length} records`);
2585
+ return json({ records });
2586
+ }
2587
+ if (url.pathname === "/forget") {
2588
+ const parsed = parseForget(body);
2589
+ if (!parsed.ok) return json({ error: parsed.error }, 400);
2590
+ const { scopeKey, id } = parsed.value;
2591
+ const forgotten = await env.MEMORY.get(env.MEMORY.idFromName(scopeKey)).forget(scopeKey, id);
2592
+ // Observability: scope + id only (ids carry no record text).
2593
+ console.log(`[forget] ${scopeKey} ${id} -> ${forgotten}`);
2594
+ return json({ ok: true, forgotten });
2595
+ }
2596
+
2597
+ const parsed = parseWrite(body);
2598
+ if (!parsed.ok) return json({ error: parsed.error }, 400);
2599
+ const { scopeKey, records, cap } = parsed.value;
2600
+ const stub = env.MEMORY.get(env.MEMORY.idFromName(scopeKey));
2601
+ const counts = await stub.write(scopeKey, records, cap);
2602
+ // Observability (counts + scopeKey only, never record content/PII): confirms
2603
+ // the reflection write fired, how many candidates it carried, and whether
2604
+ // the per-scope cap evicted anything.
2605
+ console.log(
2606
+ `[write] ${scopeKey} <- ${records.length} candidates${counts.evicted > 0 ? ` (evicted ${counts.evicted})` : ""}`,
2607
+ );
2608
+ return json({ ok: true, ...counts });
2609
+ }
2610
+
2611
+ export default {
2612
+ async fetch(request: Request, env: Env): Promise<Response> {
2613
+ // One `state.fetch` root per authenticated, routed request (docs/reference/specs/
2614
+ // tracing.md item 22), adopting the bot's trace context — never before the
2615
+ // bearer checked out, so a refusal, an unknown path or the unauthenticated
2616
+ // /healthz leaves no line and the route attr is always a word from ROUTES.
2617
+ const url = new URL(request.url);
2618
+ const admission: Admission = { known: ROUTES.has(url.pathname), authorized: authorized(env, request) };
2619
+ if (!admission.known || !admission.authorized) return handleRequest(request, env, admission);
2620
+ const root = startAdoptedRoot(tracer, "state.fetch", {
2621
+ sinks: traceSinks,
2622
+ traceparent: request.headers.get("traceparent"),
2623
+ attrs: { route: url.pathname },
2624
+ });
2625
+ try {
2626
+ const res = await handleRequest(request, env, admission);
2627
+ root.end(res.status >= 500 ? "error" : "ok", { httpStatus: res.status });
2628
+ return res;
2629
+ } catch (err) {
2630
+ root.fail(err);
2631
+ root.end("error");
2632
+ throw err;
2633
+ }
2634
+ },
2635
+ } satisfies ExportedHandler<Env>;