@coreplane/switchboard 1.214.0 → 1.215.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,14 +21,17 @@ import {
21
21
  applyRetention,
22
22
  clampRetentionPolicy,
23
23
  isRunRecord,
24
+ isRunSession,
24
25
  isRunVisibilityFilter,
25
26
  normalizeStored,
27
+ RETENTION_BOUNDS,
26
28
  RUN_EVENTS_DEFAULT_PAGE,
27
29
  RUN_EVENTS_MAX_PAGE,
28
30
  RUN_ID_PATTERN,
29
31
  clampListLimit,
30
32
  RUN_LIST_MAX_LIMIT,
31
33
  sameStoredVersion,
34
+ SESSION_KEY_PATTERN,
32
35
  storedEventSeqs,
33
36
  utf8ByteLength,
34
37
  MAX_EVENT_BYTES,
@@ -39,6 +42,16 @@ import {
39
42
  type RunVisibilityFilter,
40
43
  type StoredRunEvent,
41
44
  } from "../../src/core/runRecord.ts";
45
+ import {
46
+ attachmentRefsOf,
47
+ DEFAULT_SESSION_LOG_MAX_BYTES,
48
+ droppedToolResultRow,
49
+ planSessionTrim,
50
+ rowKind,
51
+ sessionsToDrop,
52
+ tailCut,
53
+ textOfStoredRow,
54
+ } from "../../src/core/runLedger/sessionLog.ts";
42
55
  import type { RunEvent } from "../../src/core/runEvents.ts";
43
56
  import {
44
57
  checkFence,
@@ -142,8 +155,12 @@ export interface Env {
142
155
  RUNS: DurableObjectNamespace<RunHistoryDO>;
143
156
  /** Runtime config documents (routing-and-config item 12): ONE ConfigDO (named "config"). */
144
157
  CONFIG: DurableObjectNamespace<ConfigDO>;
145
- /** Live-run transcripts (run-history item 32): one RunTranscriptDO per live run, named by run id. */
158
+ /** Live-run transcripts of runs claimed before the session log existed (run-history
159
+ * item 32): one RunTranscriptDO per such run, named by run id. New runs never write here. */
146
160
  RUN_TRANSCRIPTS: DurableObjectNamespace<RunTranscriptDO>;
161
+ /** Session logs (docs/reference/specs/session-log.md): one SessionLogDO per thread and
162
+ * agent, named `<threadKey>:<agent>` — every run of the session appends its rows. */
163
+ SESSION_LOGS: DurableObjectNamespace<SessionLogDO>;
147
164
  /** Delivery snapshots (delivery item 10): ONE DeliveryDO (named "delivery"), one snapshot per repository. */
148
165
  DELIVERY: DurableObjectNamespace<DeliveryDO>;
149
166
  /** The ship coordinator Workflow in the bot's shim Worker (run-history item
@@ -1131,10 +1148,27 @@ export class RunHistoryDO extends DurableObject<Env> {
1131
1148
  );
1132
1149
  if (!columns.has("channel_visibility"))
1133
1150
  this.sql.exec(`ALTER TABLE runs ADD COLUMN channel_visibility TEXT NOT NULL DEFAULT 'unknown'`);
1151
+ // The session a run was a range of (session-log item 7), so the sweep can
1152
+ // tell which sessions still have a kept run; null for a record without one.
1153
+ if (!columns.has("session_key")) this.sql.exec(`ALTER TABLE runs ADD COLUMN session_key TEXT`);
1134
1154
  this.sql.exec(`
1135
1155
  CREATE INDEX IF NOT EXISTS runs_channel_finished ON runs(channel_id, finished_at DESC, run_id DESC);
1136
1156
  CREATE INDEX IF NOT EXISTS runs_visibility_finished ON runs(channel_visibility, finished_at DESC, run_id DESC);
1137
1157
  CREATE INDEX IF NOT EXISTS runs_user_finished ON runs(user_id, finished_at DESC, run_id DESC);
1158
+ CREATE INDEX IF NOT EXISTS runs_session ON runs(session_key);
1159
+ `);
1160
+ // The sessions registry (session-log item 7): every session log a run of
1161
+ // this store claimed, with its thread — the sweep cannot enumerate the
1162
+ // SESSION_LOGS namespace, so this is how it knows which objects exist and
1163
+ // which thread's live row would block a drop.
1164
+ this.sql.exec(`
1165
+ CREATE TABLE IF NOT EXISTS sessions (
1166
+ key TEXT PRIMARY KEY,
1167
+ thread_key TEXT NOT NULL,
1168
+ agent TEXT,
1169
+ last_finished_at INTEGER NOT NULL DEFAULT 0,
1170
+ bytes INTEGER NOT NULL DEFAULT 0
1171
+ );
1138
1172
  `);
1139
1173
  // The live-run ledger (run-history items 28–34): live runs never enter
1140
1174
  // `runs` — that table's finished_at drives retention and listing — they
@@ -1285,6 +1319,21 @@ export class RunHistoryDO extends DurableObject<Env> {
1285
1319
 
1286
1320
  /** One live run per thread (item 29): the UNIQUE on thread_key is the
1287
1321
  * store-level guarantee; the decision names the live run for the steer. */
1322
+ /** A claim naming a session log registers it (session-log item 7), so the
1323
+ * sweep knows the object exists and which thread it belongs to. Inside the
1324
+ * claim's transaction. */
1325
+ private registerSession(req: ClaimRequest): void {
1326
+ const session = req.meta.session;
1327
+ if (!session) return;
1328
+ this.sql.exec(
1329
+ `INSERT INTO sessions (key, thread_key, agent) VALUES (?, ?, ?)
1330
+ ON CONFLICT(key) DO UPDATE SET thread_key = excluded.thread_key, agent = excluded.agent`,
1331
+ session.key,
1332
+ req.threadKey,
1333
+ req.meta.agent ?? null,
1334
+ );
1335
+ }
1336
+
1288
1337
  async claim(req: ClaimRequest, now: number): Promise<ClaimResult> {
1289
1338
  let out: ClaimResult = { ok: true };
1290
1339
  this.ctx.storage.transactionSync(() => {
@@ -1322,10 +1371,12 @@ export class RunHistoryDO extends DurableObject<Env> {
1322
1371
  JSON.stringify(req.state ?? {}),
1323
1372
  req.runId,
1324
1373
  );
1374
+ this.registerSession(req);
1325
1375
  return;
1326
1376
  case "insert":
1327
1377
  break;
1328
1378
  }
1379
+ this.registerSession(req);
1329
1380
  this.sql.exec(
1330
1381
  `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)
1331
1382
  VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)`,
@@ -1503,6 +1554,7 @@ export class RunHistoryDO extends DurableObject<Env> {
1503
1554
  if (!out.ok) return out;
1504
1555
  if ((await this.ctx.storage.getAlarm()) === null)
1505
1556
  await this.ctx.storage.setAlarm(systemClock() + RUN_SWEEP_INTERVAL_MS);
1557
+ await this.refreshSessionBytes(record.session?.key);
1506
1558
  const event = await sendRunFinished(this.env.SHIP_COORDINATOR, record);
1507
1559
  if (event.kind === "failed")
1508
1560
  console.warn(`[runs/finish] ${runId} → ${event.type} not delivered to ${event.instance}: ${event.reason}`);
@@ -1759,15 +1811,16 @@ export class RunHistoryDO extends DurableObject<Env> {
1759
1811
  );
1760
1812
  this.sql.exec(
1761
1813
  `INSERT INTO runs (run_id, label, agent, model, channel_id, user_id, thread_key, channel_visibility, repo, started_at, finished_at, stored_at, status,
1762
- event_count, stored_event_count, truncated, bytes, diagnosis_json, summary_json)
1763
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1814
+ event_count, stored_event_count, truncated, bytes, diagnosis_json, summary_json, session_key)
1815
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1764
1816
  ON CONFLICT(run_id) DO UPDATE SET
1765
1817
  label = excluded.label, agent = excluded.agent, model = excluded.model, channel_id = excluded.channel_id,
1766
1818
  user_id = excluded.user_id, thread_key = excluded.thread_key, channel_visibility = excluded.channel_visibility,
1767
1819
  repo = excluded.repo, started_at = excluded.started_at,
1768
1820
  finished_at = excluded.finished_at, stored_at = excluded.stored_at, status = excluded.status,
1769
1821
  event_count = excluded.event_count, stored_event_count = excluded.stored_event_count, truncated = excluded.truncated,
1770
- bytes = excluded.bytes, diagnosis_json = excluded.diagnosis_json, summary_json = excluded.summary_json`,
1822
+ bytes = excluded.bytes, diagnosis_json = excluded.diagnosis_json, summary_json = excluded.summary_json,
1823
+ session_key = excluded.session_key`,
1771
1824
  stored.id,
1772
1825
  stored.label ?? null,
1773
1826
  stored.agent ?? null,
@@ -1787,7 +1840,21 @@ export class RunHistoryDO extends DurableObject<Env> {
1787
1840
  bytes,
1788
1841
  JSON.stringify(stored.diagnosis),
1789
1842
  JSON.stringify(summary),
1843
+ stored.session?.key ?? null,
1790
1844
  );
1845
+ // The session's registry row learns its newest finish (session-log item
1846
+ // 7); a record that reaches the store without a claim (the plain put
1847
+ // of a detached run) still registers the session it names.
1848
+ if (stored.session) {
1849
+ this.sql.exec(
1850
+ `INSERT INTO sessions (key, thread_key, agent, last_finished_at) VALUES (?, ?, ?, ?)
1851
+ ON CONFLICT(key) DO UPDATE SET last_finished_at = MAX(sessions.last_finished_at, excluded.last_finished_at)`,
1852
+ stored.session.key,
1853
+ stored.threadKey,
1854
+ stored.agent ?? null,
1855
+ finishedAt,
1856
+ );
1857
+ }
1791
1858
  if (!unchanged) {
1792
1859
  this.sql.exec(`DELETE FROM run_events WHERE run_id = ?`, record.id);
1793
1860
  const seqs = storedEventSeqs(events); // the registry's stamps (see runRecord.ts)
@@ -1833,13 +1900,24 @@ export class RunHistoryDO extends DurableObject<Env> {
1833
1900
  const now = systemClock();
1834
1901
  const { policy } = this.policyState();
1835
1902
  let deleted = 0;
1903
+ let candidates: { key: string; threadKey: string }[] = [];
1836
1904
  this.ctx.storage.transactionSync(() => {
1837
1905
  deleted = this.trim(policy, now, undefined).deleted;
1838
1906
  // Orphan sweep: events whose run is gone (defensive — `deleteRuns` pairs
1839
1907
  // the two deletes, so this is a periodic check, not a per-put cost).
1840
1908
  this.sql.exec(`DELETE FROM run_events WHERE run_id NOT IN (SELECT run_id FROM runs)`);
1909
+ // The sessions no kept run names any more (session-log item 7): decided
1910
+ // here, on the rows this transaction leaves; dropped after it.
1911
+ candidates = this.sql
1912
+ .exec<{ key: string; thread_key: string }>(
1913
+ `SELECT key, thread_key FROM sessions
1914
+ WHERE key NOT IN (SELECT session_key FROM runs WHERE session_key IS NOT NULL)`,
1915
+ )
1916
+ .toArray()
1917
+ .map((r) => ({ key: r.key, threadKey: r.thread_key }));
1841
1918
  });
1842
- console.log(`[runs/alarm] swept ${deleted} rows outside policy`);
1919
+ const dropped = await this.sweepSessions(candidates);
1920
+ console.log(`[runs/alarm] swept ${deleted} rows outside policy, dropped ${dropped} session log(s)`);
1843
1921
  await this.ctx.storage.setAlarm(now + RUN_SWEEP_INTERVAL_MS);
1844
1922
  root.end("ok", { swept: deleted });
1845
1923
  } catch (err) {
@@ -1849,6 +1927,54 @@ export class RunHistoryDO extends DurableObject<Env> {
1849
1927
  }
1850
1928
  }
1851
1929
 
1930
+ /** Drop the session logs among `candidates` that still have no kept run and
1931
+ * no live run on their thread (session-log item 7), each object's owner row
1932
+ * first so a late write is refused, then its rows; the registry row goes
1933
+ * once the object is empty. Outside the sweep's transaction — a Durable
1934
+ * Object call cannot run inside one — so each drop awaits, and a claim or a
1935
+ * finish can land between two of them: the decision is therefore taken per
1936
+ * key on what the object reads right before that key's drop, never on the
1937
+ * list the transaction produced. A candidate a live run or a fresh record
1938
+ * has overtaken is skipped and keeps its registry row. Returns how many dropped. */
1939
+ async sweepSessions(candidates: readonly { key: string; threadKey: string }[]): Promise<number> {
1940
+ let dropped = 0;
1941
+ for (const { key, threadKey } of candidates) {
1942
+ const [{ decision }] = sessionsToDrop([
1943
+ {
1944
+ key,
1945
+ hasKeptRun: this.sql.exec(`SELECT 1 FROM runs WHERE session_key = ? LIMIT 1`, key).toArray().length > 0,
1946
+ threadLive:
1947
+ this.sql.exec(`SELECT 1 FROM live_runs WHERE thread_key = ? LIMIT 1`, threadKey).toArray().length > 0,
1948
+ },
1949
+ ]);
1950
+ if (decision !== "drop") continue;
1951
+ try {
1952
+ await this.env.SESSION_LOGS.get(this.env.SESSION_LOGS.idFromName(key)).drop();
1953
+ this.sql.exec(`DELETE FROM sessions WHERE key = ?`, key);
1954
+ dropped++;
1955
+ } catch (err) {
1956
+ console.warn(
1957
+ `[runs/alarm] session log ${key} not dropped: ${err instanceof Error ? err.message : String(err)}`,
1958
+ );
1959
+ }
1960
+ }
1961
+ return dropped;
1962
+ }
1963
+
1964
+ /** The registry row's `bytes` is the object's own count, read after a finish
1965
+ * (the one moment a session's size changes and the store is told). Best
1966
+ * effort: a registry row without a fresh count is a stale number, never a
1967
+ * wrong decision — the sweep decides on run rows and live rows alone. */
1968
+ private async refreshSessionBytes(key: string | undefined): Promise<void> {
1969
+ if (key === undefined) return;
1970
+ try {
1971
+ const bytes = await this.env.SESSION_LOGS.get(this.env.SESSION_LOGS.idFromName(key)).bytes();
1972
+ this.sql.exec(`UPDATE sessions SET bytes = ? WHERE key = ?`, bytes, key);
1973
+ } catch (err) {
1974
+ console.warn(`[runs/finish] session ${key} bytes not read: ${err instanceof Error ? err.message : String(err)}`);
1975
+ }
1976
+ }
1977
+
1852
1978
  // ---- reads ----------------------------------------------------------------
1853
1979
 
1854
1980
  /** The record with its events in seq order, each carrying the `seq` it is
@@ -2457,6 +2583,355 @@ export class RunTranscriptDO extends DurableObject<Env> {
2457
2583
  }
2458
2584
  }
2459
2585
 
2586
+ /** A tool-result marker's size, for the byte policy's first estimate of how
2587
+ * many rows to replace; the pass repeats on the measured total, so the
2588
+ * estimate only decides how many rows one pass tries. */
2589
+ const TRIM_MARKER_BYTES_ESTIMATE = 260;
2590
+
2591
+ type SessionTurnRow = { id: number; idx: number; part: number; json: string; text: string };
2592
+
2593
+ /**
2594
+ * One session log (docs/reference/specs/session-log.md): the transcript rows of every
2595
+ * run of a thread-and-agent session, each at its log index, under the same
2596
+ * `(idx, part)` upsert and owner fence as a run's transcript object — plus a
2597
+ * full-text index over the rows' text, kept in step by hand because an
2598
+ * external-content FTS5 table learns of a replaced row only when told, the
2599
+ * attachments the rows reference, the byte policy that replaces the oldest
2600
+ * tool results with a marker once the log is over its budget, and the
2601
+ * notepad row a later release writes. Nothing here is cleared when a run
2602
+ * finishes; the sweep on `RunHistoryDO` drops the whole object.
2603
+ */
2604
+ export class SessionLogDO extends DurableObject<Env> {
2605
+ private readonly sql: SqlStorage;
2606
+
2607
+ constructor(ctx: DurableObjectState, env: Env) {
2608
+ super(ctx, env);
2609
+ this.sql = ctx.storage.sql;
2610
+ this.sql.exec(`
2611
+ CREATE TABLE IF NOT EXISTS owner (k INTEGER PRIMARY KEY CHECK (k = 1), run_id TEXT NOT NULL, gen TEXT NOT NULL);
2612
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
2613
+ CREATE TABLE IF NOT EXISTS turns (
2614
+ id INTEGER PRIMARY KEY,
2615
+ idx INTEGER NOT NULL,
2616
+ part INTEGER NOT NULL,
2617
+ kind TEXT NOT NULL,
2618
+ bytes INTEGER NOT NULL,
2619
+ trimmed INTEGER NOT NULL DEFAULT 0,
2620
+ json TEXT NOT NULL,
2621
+ text TEXT NOT NULL,
2622
+ UNIQUE (idx, part)
2623
+ );
2624
+ CREATE VIRTUAL TABLE IF NOT EXISTS turns_fts USING fts5(text, content='turns', content_rowid='id');
2625
+ CREATE TABLE IF NOT EXISTS attachments (
2626
+ ref TEXT PRIMARY KEY,
2627
+ media_type TEXT NOT NULL,
2628
+ data TEXT NOT NULL,
2629
+ bytes INTEGER NOT NULL
2630
+ );
2631
+ CREATE TABLE IF NOT EXISTS notepad (k INTEGER PRIMARY KEY CHECK (k = 1), text TEXT NOT NULL, updated_at INTEGER NOT NULL);
2632
+ `);
2633
+ }
2634
+
2635
+ /** The index the next row lands at: one past the newest turn, 0 for an empty
2636
+ * log. (Not named `tail`: the runtime reserves that as a handler name on an
2637
+ * entrypoint and refuses it over RPC.) */
2638
+ async nextIndex(): Promise<{ next: number }> {
2639
+ return { next: this.next() };
2640
+ }
2641
+
2642
+ private next(): number {
2643
+ return this.sql.exec<{ next: number }>(`SELECT COALESCE(MAX(idx) + 1, 0) AS next FROM turns`).one().next;
2644
+ }
2645
+
2646
+ /** The live writer: the run and the generation whose writes land. Replaced
2647
+ * by every claim and reclaim, as the transcript object's owner is. The byte
2648
+ * budget rides along so the object enforces the store's policy on write. */
2649
+ async setOwner(runId: string, gen: string, maxBytes: number = DEFAULT_SESSION_LOG_MAX_BYTES): Promise<{ ok: true }> {
2650
+ this.ctx.storage.transactionSync(() => {
2651
+ this.sql.exec(
2652
+ `INSERT INTO owner (k, run_id, gen) VALUES (1, ?, ?) ON CONFLICT(k) DO UPDATE SET run_id = excluded.run_id, gen = excluded.gen`,
2653
+ runId,
2654
+ gen,
2655
+ );
2656
+ this.sql.exec(
2657
+ `INSERT INTO meta (key, value) VALUES ('max_bytes', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
2658
+ String(maxBytes),
2659
+ );
2660
+ });
2661
+ return { ok: true };
2662
+ }
2663
+
2664
+ async maxBytes(): Promise<number> {
2665
+ const row = this.sql.exec<{ value: string }>(`SELECT value FROM meta WHERE key = 'max_bytes'`).toArray()[0];
2666
+ const n = row ? Number(row.value) : Number.NaN;
2667
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_SESSION_LOG_MAX_BYTES;
2668
+ }
2669
+
2670
+ private owner(): { runId: string; gen: string } | undefined {
2671
+ const row = this.sql
2672
+ .exec<{ run_id: string; gen: string }>(`SELECT run_id, gen FROM owner WHERE k = 1`)
2673
+ .toArray()[0];
2674
+ return row ? { runId: row.run_id, gen: row.gen } : undefined;
2675
+ }
2676
+
2677
+ /** The owner releases the log at its finish so a zombie of a finished run is
2678
+ * refused rather than appending to a session it no longer drives; only the
2679
+ * owner may. The rows stay. */
2680
+ async clearOwner(runId: string, gen: string): Promise<FenceResult> {
2681
+ let out: FenceResult = { ok: true };
2682
+ this.ctx.storage.transactionSync(() => {
2683
+ const owner = this.owner();
2684
+ if (!owner) {
2685
+ out = { ok: false, reason: "unknown-run" };
2686
+ return;
2687
+ }
2688
+ if (owner.runId !== runId || owner.gen !== gen) {
2689
+ out = { ok: false, reason: "fenced" };
2690
+ return;
2691
+ }
2692
+ this.sql.exec(`DELETE FROM owner`);
2693
+ });
2694
+ return out;
2695
+ }
2696
+
2697
+ /** One row into the table and the index. A row already at `(idx, part)` — a
2698
+ * zombie's late write the new generation overwrites — has its index entry
2699
+ * deleted first, then goes; the new row is inserted with its own id and
2700
+ * indexed. */
2701
+ private putRow(row: TranscriptRow, json: string, trimmed: boolean): void {
2702
+ const existing = this.sql
2703
+ .exec<{ id: number; text: string }>(`SELECT id, text FROM turns WHERE idx = ? AND part = ?`, row.idx, row.part)
2704
+ .toArray()[0];
2705
+ if (existing) {
2706
+ this.sql.exec(
2707
+ `INSERT INTO turns_fts (turns_fts, rowid, text) VALUES ('delete', ?, ?)`,
2708
+ existing.id,
2709
+ existing.text,
2710
+ );
2711
+ this.sql.exec(`DELETE FROM turns WHERE id = ?`, existing.id);
2712
+ }
2713
+ const text = textOfStoredRow(json);
2714
+ this.sql.exec(
2715
+ `INSERT INTO turns (idx, part, kind, bytes, trimmed, json, text) VALUES (?, ?, ?, ?, ?, ?, ?)`,
2716
+ row.idx,
2717
+ row.part,
2718
+ rowKind(json),
2719
+ utf8ByteLength(json),
2720
+ trimmed ? 1 : 0,
2721
+ json,
2722
+ text,
2723
+ );
2724
+ const id = this.sql.exec<{ id: number }>(`SELECT last_insert_rowid() AS id`).one().id;
2725
+ this.sql.exec(`INSERT INTO turns_fts (rowid, text) VALUES (?, ?)`, id, text);
2726
+ }
2727
+
2728
+ async write(
2729
+ gen: string,
2730
+ rows: TranscriptRow[],
2731
+ attachments: TranscriptAttachment[],
2732
+ ): Promise<FenceResult & { bytes?: number }> {
2733
+ let out: FenceResult & { bytes?: number } = { ok: true };
2734
+ this.ctx.storage.transactionSync(() => {
2735
+ const owner = this.owner();
2736
+ if (owner === undefined) {
2737
+ out = { ok: false, reason: "unknown-run" };
2738
+ return;
2739
+ }
2740
+ if (owner.gen !== gen) {
2741
+ out = { ok: false, reason: "fenced" };
2742
+ return;
2743
+ }
2744
+ for (const a of attachments) {
2745
+ this.sql.exec(
2746
+ `INSERT OR REPLACE INTO attachments (ref, media_type, data, bytes) VALUES (?, ?, ?, ?)`,
2747
+ a.ref,
2748
+ a.mediaType,
2749
+ a.data,
2750
+ utf8ByteLength(a.data),
2751
+ );
2752
+ }
2753
+ for (const r of rows) this.putRow(r, r.json, false);
2754
+ out = { ok: true, bytes: this.enforceBytePolicy() };
2755
+ });
2756
+ return out;
2757
+ }
2758
+
2759
+ private totalBytes(): number {
2760
+ return (
2761
+ this.sql.exec<{ b: number }>(`SELECT COALESCE(SUM(bytes), 0) AS b FROM turns`).one().b +
2762
+ this.sql.exec<{ b: number }>(`SELECT COALESCE(SUM(bytes), 0) AS b FROM attachments`).one().b
2763
+ );
2764
+ }
2765
+
2766
+ /** Whether any row other than `exceptId` references the attachment `ref`.
2767
+ * Rows carry references inside their JSON, so the check is a substring
2768
+ * match on the quoted field; refs are `t<idx>p<part>`, and the closing quote
2769
+ * keeps `t1p0` from matching `t1p01`. */
2770
+ private referencedElsewhere(ref: string, exceptId: number): boolean {
2771
+ return (
2772
+ this.sql
2773
+ .exec(`SELECT 1 FROM turns WHERE id != ? AND INSTR(json, ?) > 0 LIMIT 1`, exceptId, `"dataRef":"${ref}"`)
2774
+ .toArray().length > 0
2775
+ );
2776
+ }
2777
+
2778
+ /** The bytes a trimmed row would free beyond its own: the attachments only
2779
+ * it references (session-log item 5). */
2780
+ private soleAttachmentBytes(row: { id: number; json: string }): number {
2781
+ let bytes = 0;
2782
+ for (const ref of attachmentRefsOf(row.json)) {
2783
+ if (this.referencedElsewhere(ref, row.id)) continue;
2784
+ bytes +=
2785
+ this.sql.exec<{ bytes: number }>(`SELECT bytes FROM attachments WHERE ref = ?`, ref).toArray()[0]?.bytes ?? 0;
2786
+ }
2787
+ return bytes;
2788
+ }
2789
+
2790
+ /** The byte policy (session-log item 5): over the budget, the oldest tool
2791
+ * results are replaced by a marker, oldest first, each taking with it the
2792
+ * attachments no remaining row references, until the log fits or none is
2793
+ * left; user and assistant text is never dropped, nor an attachment a kept
2794
+ * row still shows. Each pass plans on an estimate of the marker's size and
2795
+ * re-measures, so a pass is never short by more than the estimate's error
2796
+ * and the loop ends when the candidates do. Returns the total after. */
2797
+ private enforceBytePolicy(): number {
2798
+ const max = Number(
2799
+ this.sql.exec<{ value: string }>(`SELECT value FROM meta WHERE key = 'max_bytes'`).toArray()[0]?.value ??
2800
+ DEFAULT_SESSION_LOG_MAX_BYTES,
2801
+ );
2802
+ let total = this.totalBytes();
2803
+ while (total > max) {
2804
+ const candidates = this.sql
2805
+ .exec<{ id: number; bytes: number; json: string }>(
2806
+ `SELECT id, bytes, json FROM turns WHERE kind = 'tool_result' AND trimmed = 0 ORDER BY idx ASC, part ASC`,
2807
+ )
2808
+ .toArray()
2809
+ .map((c) => ({ id: c.id, bytes: c.bytes + this.soleAttachmentBytes(c) }));
2810
+ const ids = planSessionTrim(candidates, total - max, TRIM_MARKER_BYTES_ESTIMATE);
2811
+ if (ids.length === 0) break;
2812
+ for (const id of ids) {
2813
+ const row = this.sql
2814
+ .exec<SessionTurnRow>(`SELECT id, idx, part, json, text FROM turns WHERE id = ?`, id)
2815
+ .toArray()[0];
2816
+ if (!row) continue;
2817
+ const marker = droppedToolResultRow(row.json);
2818
+ if (marker === undefined) {
2819
+ // Not replaceable after all: mark it so the loop never picks it again.
2820
+ this.sql.exec(`UPDATE turns SET trimmed = 1 WHERE id = ?`, id);
2821
+ continue;
2822
+ }
2823
+ const refs = attachmentRefsOf(row.json);
2824
+ this.putRow({ idx: row.idx, part: row.part, json: marker }, marker, true);
2825
+ // The marker references nothing, so an attachment only this row showed is now orphaned.
2826
+ for (const ref of refs) {
2827
+ if (!this.referencedElsewhere(ref, -1)) this.sql.exec(`DELETE FROM attachments WHERE ref = ?`, ref);
2828
+ }
2829
+ }
2830
+ total = this.totalBytes();
2831
+ }
2832
+ return total;
2833
+ }
2834
+
2835
+ /** Every attachment the log holds, by reference. */
2836
+ async attachmentRefs(): Promise<string[]> {
2837
+ return this.sql
2838
+ .exec<{ ref: string }>(`SELECT ref FROM attachments ORDER BY ref`)
2839
+ .toArray()
2840
+ .map((r) => r.ref);
2841
+ }
2842
+
2843
+ /** The rows from `from` to `to` (inclusive; the tail when `to` is absent), in
2844
+ * (idx, part) order, with the attachments those rows reference. */
2845
+ async read(from: number, to?: number): Promise<{ rows: TranscriptRow[]; attachments: TranscriptAttachment[] }> {
2846
+ const rows = this.sql
2847
+ .exec<{ idx: number; part: number; json: string }>(
2848
+ `SELECT idx, part, json FROM turns WHERE idx >= ? AND idx <= ? ORDER BY idx, part`,
2849
+ from,
2850
+ to ?? Number.MAX_SAFE_INTEGER,
2851
+ )
2852
+ .toArray();
2853
+ return { rows, attachments: this.attachmentsOf(rows) };
2854
+ }
2855
+
2856
+ private attachmentsOf(rows: readonly TranscriptRow[]): TranscriptAttachment[] {
2857
+ const refs = [...new Set(rows.flatMap((r) => attachmentRefsOf(r.json)))];
2858
+ if (refs.length === 0) return [];
2859
+ const out: TranscriptAttachment[] = [];
2860
+ for (let i = 0; i < refs.length; i += DO_MAX_BOUND_PARAMETERS) {
2861
+ const batch = refs.slice(i, i + DO_MAX_BOUND_PARAMETERS);
2862
+ out.push(
2863
+ ...this.sql
2864
+ .exec<{ ref: string; media_type: string; data: string }>(
2865
+ `SELECT ref, media_type, data FROM attachments WHERE ref IN (${batch.map(() => "?").join(",")}) ORDER BY ref`,
2866
+ ...batch,
2867
+ )
2868
+ .toArray()
2869
+ .map((a) => ({ ref: a.ref, mediaType: a.media_type, data: a.data })),
2870
+ );
2871
+ }
2872
+ return out;
2873
+ }
2874
+
2875
+ /** The tail a follow-up seeds from (session-log item 4): the newest whole
2876
+ * turns within `maxBytes`, answered oldest first with the first index they
2877
+ * start at; when even the newest turn is over the budget, no rows and the
2878
+ * tail index. */
2879
+ async readTail(
2880
+ maxBytes: number,
2881
+ ): Promise<{ rows: TranscriptRow[]; attachments: TranscriptAttachment[]; from: number }> {
2882
+ const newestFirst = this.sql
2883
+ .exec<{ idx: number; bytes: number }>(`SELECT idx, bytes FROM turns ORDER BY idx DESC, part DESC`)
2884
+ .toArray();
2885
+ const from = tailCut(newestFirst, maxBytes);
2886
+ if (from === undefined) return { rows: [], attachments: [], from: this.next() };
2887
+ return { ...(await this.read(from)), from };
2888
+ }
2889
+
2890
+ /** The rows whose text matches `query`, newest first. */
2891
+ async search(query: string, limit: number): Promise<Array<{ idx: number; part: number; text: string }>> {
2892
+ const match = ftsMatchExpr(query);
2893
+ if (match === null) return [];
2894
+ return this.sql
2895
+ .exec<{ idx: number; part: number; text: string }>(
2896
+ `SELECT t.idx, t.part, t.text FROM turns_fts f JOIN turns t ON t.id = f.rowid
2897
+ WHERE turns_fts MATCH ? ORDER BY t.idx DESC, t.part DESC LIMIT ?`,
2898
+ match,
2899
+ limit,
2900
+ )
2901
+ .toArray();
2902
+ }
2903
+
2904
+ async bytes(): Promise<number> {
2905
+ return this.totalBytes();
2906
+ }
2907
+
2908
+ async rowCount(): Promise<number> {
2909
+ return this.sql.exec<{ n: number }>(`SELECT COUNT(*) AS n FROM turns`).one().n;
2910
+ }
2911
+
2912
+ async notepad(): Promise<{ text: string; updatedAt: number } | null> {
2913
+ const row = this.sql
2914
+ .exec<{ text: string; updated_at: number }>(`SELECT text, updated_at FROM notepad WHERE k = 1`)
2915
+ .toArray()[0];
2916
+ return row ? { text: row.text, updatedAt: row.updated_at } : null;
2917
+ }
2918
+
2919
+ /** The sweep's drop (session-log item 7): the owner first, so a write that
2920
+ * races the drop is refused rather than landing on a log about to go, then
2921
+ * every row, index entry, attachment, the notepad and the budget. */
2922
+ async drop(): Promise<{ ok: true }> {
2923
+ this.ctx.storage.transactionSync(() => {
2924
+ this.sql.exec(`DELETE FROM owner`);
2925
+ this.sql.exec(`INSERT INTO turns_fts (turns_fts) VALUES ('delete-all')`);
2926
+ this.sql.exec(`DELETE FROM turns`);
2927
+ this.sql.exec(`DELETE FROM attachments`);
2928
+ this.sql.exec(`DELETE FROM notepad`);
2929
+ this.sql.exec(`DELETE FROM meta`);
2930
+ });
2931
+ return { ok: true };
2932
+ }
2933
+ }
2934
+
2460
2935
  const LEDGER_ROUTES = new Set([
2461
2936
  "/runs/coordinator/put",
2462
2937
  "/runs/coordinator/replace",
@@ -2482,10 +2957,22 @@ const LEDGER_ROUTES = new Set([
2482
2957
  "/runs/transcript/write",
2483
2958
  "/runs/transcript/read",
2484
2959
  "/runs/transcript/clear",
2960
+ "/runs/session/tail",
2961
+ "/runs/session/owner",
2962
+ "/runs/session/write",
2963
+ "/runs/session/read",
2964
+ "/runs/session/read-tail",
2965
+ "/runs/session/clear-owner",
2485
2966
  ]);
2486
2967
 
2487
2968
  /** Routes whose bodies may carry a record, a transcript chunk, or an event batch. */
2488
- const WIDE_BODY_ROUTES = new Set(["/runs/put", "/runs/finish", "/runs/append", "/runs/transcript/write"]);
2969
+ const WIDE_BODY_ROUTES = new Set([
2970
+ "/runs/put",
2971
+ "/runs/finish",
2972
+ "/runs/append",
2973
+ "/runs/transcript/write",
2974
+ "/runs/session/write",
2975
+ ]);
2489
2976
  /** A delivery snapshot written whole, or a refresh's patch: every merged pull request's reviews and
2490
2977
  * its branch's workflow runs — about 7 KB a pull request (measured: 291 pull requests, 2.1 MB), so
2491
2978
  * a first read at the listing cap is under 6 MB and a busy repository's whole window many MB. */
@@ -2530,6 +3017,8 @@ function parseClaim(b: Record<string, unknown>): Validated<ClaimRequest> {
2530
3017
  // by the finish's send and the refusal a second claim meets: shaped or
2531
3018
  // refused, and both fields or neither — one alone is no tag.
2532
3019
  const meta = r.meta as Record<string, unknown>;
3020
+ if (meta.session !== undefined && !isRunSession(meta.session))
3021
+ return invalid("run.meta.session must name a session log and the run's range in it");
2533
3022
  if ((meta.parentInstanceId === undefined) !== (meta.idempotencyKey === undefined))
2534
3023
  return invalid("run.meta.parentInstanceId and run.meta.idempotencyKey come together or not at all");
2535
3024
  if (meta.parentInstanceId !== undefined) {
@@ -2604,6 +3093,25 @@ function parseTranscriptRows(v: unknown): Validated<TranscriptRow[]> {
2604
3093
  return { ok: true, value: v as TranscriptRow[] };
2605
3094
  }
2606
3095
 
3096
+ function parseSessionKey(v: unknown): Validated<string> {
3097
+ if (typeof v !== "string" || !SESSION_KEY_PATTERN.test(v)) return invalid("key must be a session key");
3098
+ return { ok: true, value: v };
3099
+ }
3100
+
3101
+ function parseLogIndex(v: unknown, name: string): Validated<number> {
3102
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 0) return invalid(`${name} must be an integer >= 0`);
3103
+ return { ok: true, value: v };
3104
+ }
3105
+
3106
+ /** The session log's byte budget on the owner claim: the store's policy field,
3107
+ * clamped into its bounds like every policy field; absent, the default. */
3108
+ function parseSessionMaxBytes(v: unknown): Validated<number> {
3109
+ if (v === undefined) return { ok: true, value: DEFAULT_SESSION_LOG_MAX_BYTES };
3110
+ if (typeof v !== "number" || !Number.isInteger(v) || v < 1) return invalid("maxBytes must be an integer >= 1");
3111
+ const [lo, hi] = RETENTION_BOUNDS.sessionLogMaxBytes;
3112
+ return { ok: true, value: Math.min(hi, Math.max(lo, v)) };
3113
+ }
3114
+
2607
3115
  function parseAttachments(v: unknown): Validated<TranscriptAttachment[]> {
2608
3116
  if (!Array.isArray(v)) return invalid("attachments must be an array");
2609
3117
  for (const a of v) {
@@ -2623,6 +3131,60 @@ async function handleLedger(pathname: string, body: unknown, env: Env): Promise<
2623
3131
  const b = body as Record<string, unknown>;
2624
3132
  const fenced = (r: FenceResult) => (r.ok ? json(r) : json(r, 409));
2625
3133
 
3134
+ if (pathname.startsWith("/runs/session/")) {
3135
+ const key = parseSessionKey(b.key);
3136
+ if (!key.ok) return json({ error: key.error }, 400);
3137
+ const stub = env.SESSION_LOGS.get(env.SESSION_LOGS.idFromName(key.value));
3138
+ if (pathname === "/runs/session/tail") return json(await stub.nextIndex());
3139
+ if (pathname === "/runs/session/owner") {
3140
+ const runId = parseRunId(b.runId);
3141
+ if (!runId.ok) return json({ error: runId.error }, 400);
3142
+ const g = gen(b.gen);
3143
+ if (!g.ok) return json({ error: g.error }, 400);
3144
+ const max = parseSessionMaxBytes(b.maxBytes);
3145
+ if (!max.ok) return json({ error: max.error }, 400);
3146
+ return json(await stub.setOwner(runId.value, g.value, max.value));
3147
+ }
3148
+ if (pathname === "/runs/session/write") {
3149
+ const g = gen(b.gen);
3150
+ if (!g.ok) return json({ error: g.error }, 400);
3151
+ const rows = parseTranscriptRows(b.rows);
3152
+ if (!rows.ok) return json({ error: rows.error }, 400);
3153
+ const attachments = parseAttachments(b.attachments);
3154
+ if (!attachments.ok) return json({ error: attachments.error }, 400);
3155
+ const r = await stub.write(g.value, rows.value, attachments.value);
3156
+ console.log(
3157
+ `[runs/session/write] ${key.value} <- ${rows.value.length} row(s), ${attachments.value.length} attachment(s), ok=${r.ok}`,
3158
+ );
3159
+ return fenced(r);
3160
+ }
3161
+ if (pathname === "/runs/session/read") {
3162
+ const from = parseLogIndex(b.from, "from");
3163
+ if (!from.ok) return json({ error: from.error }, 400);
3164
+ if (b.to !== undefined) {
3165
+ const to = parseLogIndex(b.to, "to");
3166
+ if (!to.ok) return json({ error: to.error }, 400);
3167
+ if (to.value < from.value) return json({ error: "to must be at least from" }, 400);
3168
+ return json(await stub.read(from.value, to.value));
3169
+ }
3170
+ return json(await stub.read(from.value));
3171
+ }
3172
+ if (pathname === "/runs/session/read-tail") {
3173
+ const max = b.maxBytes;
3174
+ if (typeof max !== "number" || !Number.isInteger(max) || max < 1)
3175
+ return json({ error: "maxBytes must be an integer >= 1" }, 400);
3176
+ return json(await stub.readTail(max));
3177
+ }
3178
+ if (pathname === "/runs/session/clear-owner") {
3179
+ const runId = parseRunId(b.runId);
3180
+ if (!runId.ok) return json({ error: runId.error }, 400);
3181
+ const g = gen(b.gen);
3182
+ if (!g.ok) return json({ error: g.error }, 400);
3183
+ return fenced(await stub.clearOwner(runId.value, g.value));
3184
+ }
3185
+ return json({ error: "not found" }, 404);
3186
+ }
3187
+
2626
3188
  if (pathname.startsWith("/runs/transcript/")) {
2627
3189
  const runId = parseRunId(b.runId);
2628
3190
  if (!runId.ok) return json({ error: runId.error }, 400);