@coreplane/switchboard 0.0.0 → 1.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +18 -1
- package/dist/assets/.dockerignore +27 -0
- package/dist/assets/.env.example +33 -0
- package/dist/assets/Dockerfile +111 -0
- package/dist/assets/config/config.example.yaml +359 -0
- package/dist/assets/deploy/bin/build-stamp.d.mts +15 -0
- package/dist/assets/deploy/bin/build-stamp.mjs +98 -0
- package/dist/assets/deploy/bin/cf-logs +32 -0
- package/dist/assets/deploy/cloudflare/package.json +29 -0
- package/dist/assets/deploy/cloudflare/preflight.mjs +243 -0
- package/dist/assets/deploy/cloudflare/tsconfig.json +18 -0
- package/dist/assets/deploy/cloudflare/worker.ts +382 -0
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +67 -0
- package/dist/assets/deploy/cloudflare/write-build.d.mts +7 -0
- package/dist/assets/deploy/cloudflare/write-build.mjs +53 -0
- package/dist/assets/deploy/cloudflare-docs/package.json +18 -0
- package/dist/assets/deploy/cloudflare-docs/wrangler.template.jsonc +30 -0
- package/dist/assets/deploy/cloudflare-memory/package.json +25 -0
- package/dist/assets/deploy/cloudflare-memory/tsconfig.json +17 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +2635 -0
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +50 -0
- package/dist/assets/deploy/cloudflare-resident/Dockerfile +91 -0
- package/dist/assets/deploy/cloudflare-resident/gc.ts +287 -0
- package/dist/assets/deploy/cloudflare-resident/node-async-hooks.d.ts +11 -0
- package/dist/assets/deploy/cloudflare-resident/package.json +29 -0
- package/dist/assets/deploy/cloudflare-resident/preflight.mjs +224 -0
- package/dist/assets/deploy/cloudflare-resident/tsconfig.json +19 -0
- package/dist/assets/deploy/cloudflare-resident/worker.ts +6637 -0
- package/dist/assets/deploy/cloudflare-resident/wrangler.template.jsonc +120 -0
- package/dist/assets/deploy/cloudflare-sandbox/Dockerfile +67 -0
- package/dist/assets/deploy/cloudflare-sandbox/docker-wrapper.sh +37 -0
- package/dist/assets/deploy/cloudflare-sandbox/package.json +26 -0
- package/dist/assets/deploy/cloudflare-sandbox/tsconfig.json +20 -0
- package/dist/assets/deploy/cloudflare-sandbox/worker.ts +410 -0
- package/dist/assets/deploy/cloudflare-sandbox/wrangler.template.jsonc +67 -0
- package/dist/assets/deploy/profile.example.json +13 -0
- package/dist/assets/deploy/secrets.manifest.json +108 -0
- package/dist/assets/docker-entrypoint.sh +15 -0
- package/dist/assets/package-lock.json +18407 -0
- package/dist/assets/package.json +104 -0
- package/dist/assets/project.json +219 -0
- package/dist/assets/source.json +5 -0
- package/dist/assets/src/core/authz/actor.ts +100 -0
- package/dist/assets/src/core/authz/authorize.ts +169 -0
- package/dist/assets/src/core/authz/grants.ts +347 -0
- package/dist/assets/src/core/authz/policy.ts +281 -0
- package/dist/assets/src/core/authz/resource.ts +147 -0
- package/dist/assets/src/core/authz/types.ts +164 -0
- package/dist/assets/src/core/drain.ts +54 -0
- package/dist/assets/src/core/ingressTokens.ts +64 -0
- package/dist/assets/src/core/memory/engine.ts +115 -0
- package/dist/assets/src/core/memory/scorer.ts +147 -0
- package/dist/assets/src/core/memory/types.ts +120 -0
- package/dist/assets/src/core/normalizeSpans.ts +299 -0
- package/dist/assets/src/core/prDescriptionTypes.ts +54 -0
- package/dist/assets/src/core/redact.ts +113 -0
- package/dist/assets/src/core/runEvents.ts +537 -0
- package/dist/assets/src/core/runFriction.ts +665 -0
- package/dist/assets/src/core/runLedger/decisions.ts +126 -0
- package/dist/assets/src/core/runLedger/types.ts +177 -0
- package/dist/assets/src/core/runRecord.ts +627 -0
- package/dist/assets/src/core/runShape.ts +61 -0
- package/dist/assets/src/core/schedules.ts +452 -0
- package/dist/assets/src/core/time/formatDuration.ts +61 -0
- package/dist/assets/src/core/trace/attrs.ts +203 -0
- package/dist/assets/src/core/trace/classify.ts +49 -0
- package/dist/assets/src/core/trace/clock.ts +6 -0
- package/dist/assets/src/core/trace/context.ts +9 -0
- package/dist/assets/src/core/trace/ids.ts +23 -0
- package/dist/assets/src/core/trace/partition.ts +235 -0
- package/dist/assets/src/core/trace/sinks.ts +68 -0
- package/dist/assets/src/core/trace/streamSpans.ts +163 -0
- package/dist/assets/src/core/trace/traceparent.ts +29 -0
- package/dist/assets/src/core/trace/tracer.ts +247 -0
- package/dist/assets/src/core/trace/types.ts +125 -0
- package/dist/assets/src/core/trace/workerTrace.ts +97 -0
- package/dist/assets/src/deploy/buildStamp.ts +93 -0
- package/dist/assets/src/deploy/liveGate.ts +203 -0
- package/dist/assets/src/deploy/profile.ts +162 -0
- package/dist/assets/src/deploy/restart.ts +393 -0
- package/dist/assets/src/effort.ts +17 -0
- package/dist/assets/src/execution/bashTimeout.ts +78 -0
- package/dist/assets/src/execution/bindingPurge.ts +43 -0
- package/dist/assets/src/execution/residentBackupTransfer.ts +50 -0
- package/dist/assets/src/execution/residentCleanliness.ts +95 -0
- package/dist/assets/src/execution/residentCredentials.ts +81 -0
- package/dist/assets/src/execution/residentDepCache.ts +321 -0
- package/dist/assets/src/execution/residentDepsStore.ts +326 -0
- package/dist/assets/src/execution/residentDetach.ts +48 -0
- package/dist/assets/src/execution/residentDisk.ts +107 -0
- package/dist/assets/src/execution/residentDiskBudget.ts +448 -0
- package/dist/assets/src/execution/residentExecWrap.ts +100 -0
- package/dist/assets/src/execution/residentHead.ts +85 -0
- package/dist/assets/src/execution/residentReadonly.ts +72 -0
- package/dist/assets/src/execution/residentRefresh.ts +429 -0
- package/dist/assets/src/execution/residentRestoreExtract.ts +130 -0
- package/dist/assets/src/execution/residentState.ts +47 -0
- package/dist/assets/src/execution/residentStepReport.ts +98 -0
- package/dist/assets/src/execution/residentStepTrace.ts +97 -0
- package/dist/assets/src/execution/residentSteps.ts +99 -0
- package/dist/assets/src/execution/residentText.ts +83 -0
- package/dist/assets/src/execution/residentTrace.ts +119 -0
- package/dist/assets/src/execution/sandboxEnv.ts +42 -0
- package/dist/assets/src/execution/sandboxErrors.ts +159 -0
- package/dist/assets/src/execution/sandboxKeepalive.ts +118 -0
- package/dist/assets/src/execution/shellQuote.ts +8 -0
- package/dist/assets/src/mcp/registry.ts +242 -0
- package/dist/assets/src/providers/types.ts +152 -0
- package/dist/assets/web/dist/.vite/manifest.json +176 -0
- package/dist/assets/web/dist/assets/AppShell-Bk2gbvet.js +1 -0
- package/dist/assets/web/dist/assets/CostsPage-CTZcMYYx.js +1 -0
- package/dist/assets/web/dist/assets/NotFoundPage-C-BuaSm8.js +1 -0
- package/dist/assets/web/dist/assets/ResidentDetailPage-D3shEnzl.js +1 -0
- package/dist/assets/web/dist/assets/ResidentsIndexPage-DWIubQ05.js +1 -0
- package/dist/assets/web/dist/assets/RunRoutePage-BMjuE-oX.js +126 -0
- package/dist/assets/web/dist/assets/RunRoutePage-XVFj0XDc.css +1 -0
- package/dist/assets/web/dist/assets/RunsIndexPage-C3_jYIo0.js +1 -0
- package/dist/assets/web/dist/assets/RunsTabs-C4krAL9o.js +1 -0
- package/dist/assets/web/dist/assets/ScheduledPage-g1W58mtN.js +1 -0
- package/dist/assets/web/dist/assets/StatusDot-DcPRw3zu.js +1 -0
- package/dist/assets/web/dist/assets/Tooltip-DJUkMYjo.js +1 -0
- package/dist/assets/web/dist/assets/favicon-DL1rdWJt.js +1 -0
- package/dist/assets/web/dist/assets/localIso-L06jV29p.js +1 -0
- package/dist/assets/web/dist/assets/main-BsBGUyMH.css +2 -0
- package/dist/assets/web/dist/assets/main-CyM5f4JC.js +28 -0
- package/dist/assets/web/dist/assets/residentDiskBudget-BMBKlYRH.js +1 -0
- package/dist/assets/web/dist/assets/seed-BglCRKLA.js +6 -0
- package/dist/assets/web/dist/assets/wallClock-Ckv3sKoR.js +1 -0
- package/dist/cli.js +34494 -0
- 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>;
|