@coreplane/switchboard 1.213.0 → 1.215.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/config/config.example.yaml +7 -1
- package/dist/assets/deploy/cloudflare/artifactsCopy.ts +180 -0
- package/dist/assets/deploy/cloudflare/ensure-bucket.mjs +71 -0
- package/dist/assets/deploy/cloudflare/package.json +1 -1
- package/dist/assets/deploy/cloudflare/worker.ts +19 -1
- package/dist/assets/deploy/cloudflare/wrangler.template.jsonc +15 -0
- package/dist/assets/deploy/cloudflare-memory/worker.ts +568 -6
- package/dist/assets/deploy/cloudflare-memory/wrangler.template.jsonc +11 -2
- package/dist/assets/deploy/profile.example.json +1 -1
- package/dist/assets/package-lock.json +3 -3
- package/dist/assets/package.json +1 -1
- package/dist/assets/source.json +3 -3
- package/dist/assets/src/agents/registry.ts +39 -14
- package/dist/assets/src/config/profile.ts +8 -5
- package/dist/assets/src/core/coordinator/contract.ts +6 -0
- package/dist/assets/src/core/runEvents.ts +18 -9
- package/dist/assets/src/core/runFriction.ts +9 -2
- package/dist/assets/src/core/runLedger/sessionLog.ts +207 -0
- package/dist/assets/src/core/runLedger/transcript.ts +187 -0
- package/dist/assets/src/core/runLedger/types.ts +25 -4
- package/dist/assets/src/core/runRecord.ts +62 -1
- package/dist/assets/src/core/ship/coordinator.ts +70 -33
- package/dist/assets/src/core/trace/workerTrace.ts +2 -0
- package/dist/assets/src/deploy/profile.ts +11 -0
- package/dist/assets/src/execution/residentInstanceId.ts +11 -3
- package/dist/assets/src/execution/residentText.ts +17 -2
- package/dist/assets/src/providers/types.ts +6 -0
- package/dist/cli.js +1689 -627
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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([
|
|
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);
|