@codecaine-ai/agent-kernel-db 0.0.0-stage → 0.0.3

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.
@@ -0,0 +1,414 @@
1
+ /**
2
+ * Acknowledged writes for model-node sessions (kernel.call / kernel.decide).
3
+ *
4
+ * Each export is an awaited wrapper around ONE synchronous transaction that
5
+ * throws on failure, so a node's intent (start) and outcome (completion) are
6
+ * either fully committed or not at all. Transaction bodies are synchronous
7
+ * and use only the transaction-local helpers in ./tx-local (.run/.all/.get);
8
+ * never `await` inside them and never call the async actions from them.
9
+ */
10
+ import { eq } from "drizzle-orm";
11
+ import {
12
+ createCallEndEvent,
13
+ kernelNodeEventId,
14
+ type CallEndData,
15
+ type CallStartData,
16
+ type TraceEvent,
17
+ } from "@codecaine-ai/agent-kernel-protocol";
18
+ import type { KernelDatabase } from "../client";
19
+ import { agentRuns, RUN_STATUS, type RunTrigger } from "../schema/agent-runs";
20
+ import { SESSION_STATUS } from "../schema/pi-agent-sessions";
21
+ import { traceEvents } from "../schema/trace-events";
22
+ import type { TraceBlobInput } from "../types";
23
+ import {
24
+ applyUsageTx,
25
+ insertAgentRunTx,
26
+ insertTraceEventsTx,
27
+ listRunsForSessionTx,
28
+ runImmediateTransaction,
29
+ setRunStatusTx,
30
+ setSessionStatusTx,
31
+ upsertPiAgentSessionTx,
32
+ upsertTraceBlobsTx,
33
+ type KernelTx,
34
+ } from "./tx-local";
35
+ import type { UsageDelta } from "./usage";
36
+
37
+ /**
38
+ * A "running" run stays owned by its original attempt until its recorded
39
+ * operation deadline (call_start.deadline_at) plus this grace has passed.
40
+ */
41
+ export const NODE_STALE_GRACE_MS = 60_000;
42
+
43
+ /** error.kind on the call_end that recovery synthesizes for an abandoned run. */
44
+ export const ABANDONED_ERROR_KIND = "abandoned";
45
+
46
+ export interface NodeStartInput {
47
+ kind: "call" | "decision";
48
+ /** Deterministic when requestId is given, else random. */
49
+ sessionId: string;
50
+ /** Allocated by the lifecycle before the claim, so call_start's id is known. */
51
+ runId: string;
52
+ requestId?: string;
53
+ containerId: string;
54
+ agentName: string;
55
+ displayLabel?: string | null;
56
+ model: string;
57
+ promptHash: string;
58
+ parentRunId?: string | null;
59
+ parentToolUseId?: string | null;
60
+ trigger: RunTrigger;
61
+ startedAt: string;
62
+ /** Its eventId becomes agent_runs.inbound_event_id. */
63
+ startEvent: TraceEvent;
64
+ startBlobs: TraceBlobInput[];
65
+ /** Fallback for runs written without deadline_at. */
66
+ staleAfterMs: number;
67
+ nowMs: number;
68
+ /** Operation deadline (ISO); stamped into the start event as deadline_at. */
69
+ deadlineAt: string;
70
+ /**
71
+ * The canonical request fingerprint (requestId claims only). Stamped into the
72
+ * start event as `request_fingerprint` and compared, inside the claim, with
73
+ * every run of the session: a different one is a `mismatch` and nothing is
74
+ * written. Runs recorded without one are not compared.
75
+ */
76
+ requestFingerprint?: string;
77
+ }
78
+
79
+ export type NodeClaim =
80
+ /** A prior run of this request finished "done"; nothing was written. */
81
+ | { kind: "replay"; runId: string }
82
+ /** A fresh "running" run exists (another instance owns it); nothing was written. */
83
+ | { kind: "in-flight"; runId: string }
84
+ | { kind: "claimed"; runId: string; attempt: number; abandonedRunIds: string[] }
85
+ /** A run of this request recorded a different request fingerprint; nothing was written. */
86
+ | { kind: "mismatch"; runId: string };
87
+
88
+ export interface NodeCompletion {
89
+ runId: string;
90
+ sessionId: string;
91
+ containerId: string;
92
+ runStatus: "done" | "error" | "aborted";
93
+ sessionStatus: "ended" | "error";
94
+ endedAt: string;
95
+ /** Snapshots, turns, decision_made, call_end (call_end last). */
96
+ events: TraceEvent[];
97
+ blobs: TraceBlobInput[];
98
+ usage: UsageDelta | null;
99
+ }
100
+
101
+ /** The run a completion targeted was no longer "running" (it was abandoned meanwhile). */
102
+ export class NodeRunNotRunningError extends Error {
103
+ readonly runId: string;
104
+ constructor(runId: string) {
105
+ super(`node run ${runId} is no longer running; completion rolled back`);
106
+ this.name = "NodeRunNotRunningError";
107
+ this.runId = runId;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Claim and start one node attempt in ONE synchronous `BEGIN IMMEDIATE`
113
+ * transaction:
114
+ * 1. list the session's runs (only when requestId is given);
115
+ * 1a. with a requestFingerprint, any run whose call_start recorded a
116
+ * different fingerprint → `mismatch` (nothing written, before replay,
117
+ * recovery or insertion, so a rejected request never blocks the original);
118
+ * 2. any "done" run → `replay` (nothing written);
119
+ * 3. a "running" run is fresh while now ≤ its call_start.deadline_at + 60 s
120
+ * (runs without deadline_at: startedAt + staleAfterMs) → `in-flight`
121
+ * (nothing written); every running run past that point is abandoned in
122
+ * this same transaction (synthesized aborted call_end, run "aborted",
123
+ * session kept "active" for the new attempt);
124
+ * 4. upsert the session ("active", kind), insert the run ("running",
125
+ * inbound_event_id = startEvent.eventId), the start blobs and the start
126
+ * event → `claimed` with attempt = runs.length + 1.
127
+ * The start event is stored with `deadline_at` (and, with a requestId,
128
+ * `attempt`) stamped into its data, since the attempt is only known here.
129
+ * SQLITE_BUSY is retried outside the transaction; any other throw rolls
130
+ * everything back and rejects (the caller maps it to row-write-failed). The
131
+ * engine may be invoked only after a `claimed` result.
132
+ */
133
+ export async function claimAndStartNode(
134
+ db: KernelDatabase,
135
+ input: NodeStartInput,
136
+ ): Promise<NodeClaim> {
137
+ return runImmediateTransaction(db, (tx) => claimTx(tx, input));
138
+ }
139
+
140
+ /**
141
+ * Persist a node's outcome in ONE synchronous transaction: blobs, events,
142
+ * usage, then the run status (only if still "running"; outbound = the
143
+ * call_end id) and the session status. If the run is no longer running the
144
+ * transaction throws NodeRunNotRunningError and rolls back, so a run is never
145
+ * "done" unless its outcome events, blobs and usage committed with it.
146
+ */
147
+ export async function persistNodeCompletion(
148
+ db: KernelDatabase,
149
+ c: NodeCompletion,
150
+ ): Promise<void> {
151
+ const callEnd = [...c.events].reverse().find((e) => e.type === "call_end");
152
+ if (!callEnd) {
153
+ throw new Error(`persistNodeCompletion: run ${c.runId} has no call_end event`);
154
+ }
155
+ await runImmediateTransaction(db, (tx) =>
156
+ completeTx(tx, c, callEnd.eventId),
157
+ );
158
+ }
159
+
160
+ /**
161
+ * Standalone recovery for a run left "running": one transaction that
162
+ * inserts the deterministic aborted call_end (insert-or-ignore, so a real
163
+ * call_end is kept), sets the run "aborted" and the session "error". A run
164
+ * that is not running is left untouched. Ids and times of the written rows
165
+ * come from the run row itself.
166
+ */
167
+ export async function abandonNodeRun(
168
+ db: KernelDatabase,
169
+ args: { runId: string; sessionId: string; containerId: string; at: string },
170
+ ): Promise<void> {
171
+ await runImmediateTransaction(db, (tx) => {
172
+ abandonRunTx(tx, {
173
+ runId: args.runId,
174
+ at: args.at,
175
+ sessionStatus: SESSION_STATUS.ERROR,
176
+ });
177
+ });
178
+ }
179
+
180
+ // ─── Transaction bodies (synchronous) ───────────────────────────────────────
181
+
182
+ function claimTx(tx: KernelTx, input: NodeStartInput): NodeClaim {
183
+ const runs =
184
+ input.requestId === undefined ? [] : listRunsForSessionTx(tx, input.sessionId);
185
+
186
+ if (input.requestFingerprint !== undefined) {
187
+ for (const run of runs) {
188
+ const stored = readStartEvent(tx, run.inboundEventId)?.data.request_fingerprint;
189
+ if (typeof stored === "string" && stored !== input.requestFingerprint) {
190
+ return { kind: "mismatch", runId: run.id };
191
+ }
192
+ }
193
+ }
194
+
195
+ const done = runs.filter((run) => run.status === RUN_STATUS.DONE).at(-1);
196
+ if (done) return { kind: "replay", runId: done.id };
197
+
198
+ const running = runs.filter((run) => run.status === RUN_STATUS.RUNNING);
199
+ const fresh = running.find(
200
+ (run) => input.nowMs <= staleAtMs(tx, run, input.staleAfterMs),
201
+ );
202
+ if (fresh) return { kind: "in-flight", runId: fresh.id };
203
+
204
+ const abandonedAt = new Date(input.nowMs).toISOString();
205
+ for (const run of running) {
206
+ abandonRunTx(tx, {
207
+ runId: run.id,
208
+ at: abandonedAt,
209
+ sessionStatus: SESSION_STATUS.ACTIVE,
210
+ });
211
+ }
212
+
213
+ const attempt = runs.length + 1;
214
+ upsertPiAgentSessionTx(tx, {
215
+ id: input.sessionId,
216
+ containerId: input.containerId,
217
+ agentName: input.agentName,
218
+ displayLabel: input.displayLabel ?? null,
219
+ model: input.model,
220
+ promptHash: input.promptHash,
221
+ status: SESSION_STATUS.ACTIVE,
222
+ kind: input.kind,
223
+ createdAt: input.startedAt,
224
+ endedAt: null,
225
+ });
226
+ insertAgentRunTx(tx, {
227
+ id: input.runId,
228
+ piSessionId: input.sessionId,
229
+ containerId: input.containerId,
230
+ parentRunId: input.parentRunId ?? null,
231
+ parentToolUseId: input.parentToolUseId ?? null,
232
+ agentName: input.agentName,
233
+ trigger: input.trigger,
234
+ inboundEventId: input.startEvent.eventId,
235
+ displayLabel: input.displayLabel ?? null,
236
+ status: RUN_STATUS.RUNNING,
237
+ startedAt: input.startedAt,
238
+ });
239
+ upsertTraceBlobsTx(tx, input.startBlobs);
240
+ const inserted = insertTraceEventsTx(tx, [
241
+ stampStartEvent(input.startEvent, {
242
+ deadlineAt: input.deadlineAt,
243
+ attempt: input.requestId === undefined ? undefined : attempt,
244
+ requestFingerprint: input.requestFingerprint,
245
+ }),
246
+ ]);
247
+ if (inserted !== 1) {
248
+ throw new Error(
249
+ `claimAndStartNode: start event ${input.startEvent.eventId} already exists`,
250
+ );
251
+ }
252
+
253
+ return {
254
+ kind: "claimed",
255
+ runId: input.runId,
256
+ attempt,
257
+ abandonedRunIds: running.map((run) => run.id),
258
+ };
259
+ }
260
+
261
+ function completeTx(tx: KernelTx, c: NodeCompletion, callEndId: string): void {
262
+ upsertTraceBlobsTx(tx, c.blobs);
263
+ insertTraceEventsTx(tx, c.events);
264
+ if (c.usage) {
265
+ applyUsageTx(
266
+ tx,
267
+ { runId: c.runId, sessionId: c.sessionId, containerId: c.containerId },
268
+ c.usage,
269
+ );
270
+ }
271
+ const changed = setRunStatusTx(tx, {
272
+ runId: c.runId,
273
+ status: c.runStatus,
274
+ endedAt: c.endedAt,
275
+ outboundEventId: callEndId,
276
+ onlyIfStatus: RUN_STATUS.RUNNING,
277
+ });
278
+ if (changed === 0) throw new NodeRunNotRunningError(c.runId);
279
+ setSessionStatusTx(tx, {
280
+ sessionId: c.sessionId,
281
+ status: c.sessionStatus,
282
+ endedAt: c.endedAt,
283
+ });
284
+ }
285
+
286
+ /**
287
+ * Abandon one "running" run: synthesized aborted call_end (insert-or-ignore),
288
+ * run "aborted" (only if still running), session set to `sessionStatus`
289
+ * ("active" keeps it open for a new attempt). Returns false when the run is
290
+ * missing or not running (nothing written).
291
+ */
292
+ function abandonRunTx(
293
+ tx: KernelTx,
294
+ args: {
295
+ runId: string;
296
+ at: string;
297
+ sessionStatus: typeof SESSION_STATUS.ACTIVE | typeof SESSION_STATUS.ERROR;
298
+ },
299
+ ): boolean {
300
+ const run = tx
301
+ .select({
302
+ id: agentRuns.id,
303
+ piSessionId: agentRuns.piSessionId,
304
+ containerId: agentRuns.containerId,
305
+ agentName: agentRuns.agentName,
306
+ inboundEventId: agentRuns.inboundEventId,
307
+ status: agentRuns.status,
308
+ startedAt: agentRuns.startedAt,
309
+ })
310
+ .from(agentRuns)
311
+ .where(eq(agentRuns.id, args.runId))
312
+ .get();
313
+ if (!run || run.status !== RUN_STATUS.RUNNING) return false;
314
+
315
+ const start = readStartEvent(tx, run.inboundEventId);
316
+ const startData = start?.data;
317
+ const startMs = Date.parse(start?.timestamp ?? run.startedAt);
318
+ const atMs = Date.parse(args.at);
319
+ // Keep the end after its start even when the caller's clock lags.
320
+ const endMs =
321
+ Number.isFinite(startMs) && Number.isFinite(atMs) ? Math.max(atMs, startMs + 1) : atMs;
322
+ const endedAt = Number.isFinite(endMs) ? new Date(endMs).toISOString() : args.at;
323
+
324
+ const data: CallEndData = {
325
+ run_id: run.id,
326
+ node_kind: startData?.node_kind ?? "call",
327
+ function_name: startData?.function_name ?? run.agentName,
328
+ status: "aborted",
329
+ error: {
330
+ kind: ABANDONED_ERROR_KIND,
331
+ message: "run was still running past its operation deadline; recovered",
332
+ },
333
+ attempts: 0,
334
+ duration_ms: Number.isFinite(startMs) && Number.isFinite(endMs) ? endMs - startMs : 0,
335
+ ...(startData?.gate_span_id !== undefined && {
336
+ gate_span_id: startData.gate_span_id,
337
+ }),
338
+ };
339
+ const callEnd = createCallEndEvent(
340
+ { containerId: run.containerId, runId: run.id, piSessionUuid: run.piSessionId },
341
+ data,
342
+ {
343
+ eventId: kernelNodeEventId(run.id, 0, "call_end"),
344
+ parentEventId: run.inboundEventId ?? undefined,
345
+ timestamp: endedAt,
346
+ },
347
+ );
348
+
349
+ insertTraceEventsTx(tx, [callEnd]);
350
+ setRunStatusTx(tx, {
351
+ runId: run.id,
352
+ status: RUN_STATUS.ABORTED,
353
+ endedAt,
354
+ outboundEventId: callEnd.eventId,
355
+ onlyIfStatus: RUN_STATUS.RUNNING,
356
+ });
357
+ setSessionStatusTx(tx, {
358
+ sessionId: run.piSessionId,
359
+ status: args.sessionStatus,
360
+ endedAt: args.sessionStatus === SESSION_STATUS.ACTIVE ? null : endedAt,
361
+ });
362
+ return true;
363
+ }
364
+
365
+ /** The run's call_start (via inbound_event_id), if it was written. */
366
+ function readStartEvent(
367
+ tx: KernelTx,
368
+ eventId: string | null,
369
+ ): { data: Partial<CallStartData>; timestamp: string } | undefined {
370
+ if (!eventId) return undefined;
371
+ const row = tx
372
+ .select({
373
+ type: traceEvents.type,
374
+ eventData: traceEvents.eventData,
375
+ timestamp: traceEvents.timestamp,
376
+ })
377
+ .from(traceEvents)
378
+ .where(eq(traceEvents.eventId, eventId))
379
+ .get();
380
+ if (!row || row.type !== "call_start") return undefined;
381
+ const data =
382
+ typeof row.eventData === "object" && row.eventData !== null
383
+ ? (row.eventData as Partial<CallStartData>)
384
+ : {};
385
+ return { data, timestamp: row.timestamp };
386
+ }
387
+
388
+ /** Epoch ms after which a "running" run may be abandoned. */
389
+ function staleAtMs(
390
+ tx: KernelTx,
391
+ run: { startedAt: string; inboundEventId: string | null },
392
+ staleAfterMs: number,
393
+ ): number {
394
+ const deadline = readStartEvent(tx, run.inboundEventId)?.data.deadline_at;
395
+ const deadlineMs = typeof deadline === "string" ? Date.parse(deadline) : Number.NaN;
396
+ if (Number.isFinite(deadlineMs)) return deadlineMs + NODE_STALE_GRACE_MS;
397
+ return Date.parse(run.startedAt) + staleAfterMs;
398
+ }
399
+
400
+ function stampStartEvent(
401
+ event: TraceEvent,
402
+ stamp: { deadlineAt: string; attempt: number | undefined; requestFingerprint: string | undefined },
403
+ ): TraceEvent {
404
+ if (typeof event.eventData !== "object" || event.eventData === null) return event;
405
+ return {
406
+ ...event,
407
+ eventData: {
408
+ ...event.eventData,
409
+ deadline_at: stamp.deadlineAt,
410
+ ...(stamp.attempt !== undefined && { attempt: stamp.attempt }),
411
+ ...(stamp.requestFingerprint !== undefined && { request_fingerprint: stamp.requestFingerprint }),
412
+ },
413
+ };
414
+ }
@@ -0,0 +1,93 @@
1
+ import { asc, eq } from "drizzle-orm";
2
+ import type { KernelDatabase } from "../client";
3
+ import { piAgentSessionSelection } from "../read-compat";
4
+ import { piAgentSessions, type SessionStatus } from "../schema/pi-agent-sessions";
5
+ import type { NewPiAgentSession, PiAgentSession } from "../types";
6
+
7
+ export async function upsertPiAgentSession(
8
+ db: KernelDatabase,
9
+ data: NewPiAgentSession,
10
+ ): Promise<PiAgentSession> {
11
+ const [row] = await db
12
+ .insert(piAgentSessions)
13
+ .values(data)
14
+ .onConflictDoUpdate({
15
+ target: piAgentSessions.id,
16
+ set: {
17
+ containerId: data.containerId,
18
+ ...(data.parentSessionId !== undefined && {
19
+ parentSessionId: data.parentSessionId,
20
+ }),
21
+ ...(data.parentToolUseId !== undefined && {
22
+ parentToolUseId: data.parentToolUseId,
23
+ }),
24
+ ...(data.displayLabel !== undefined && { displayLabel: data.displayLabel }),
25
+ ...(data.model !== undefined && { model: data.model }),
26
+ ...(data.promptHash !== undefined && { promptHash: data.promptHash }),
27
+ ...(data.status !== undefined && { status: data.status }),
28
+ ...(data.phase !== undefined && { phase: data.phase }),
29
+ ...(data.endedAt !== undefined && { endedAt: data.endedAt }),
30
+ ...(data.kind !== undefined && { kind: data.kind }),
31
+ },
32
+ })
33
+ .returning();
34
+ return row;
35
+ }
36
+
37
+ /** Link a child session to the parent session + tool call that spawned it. */
38
+ export async function setParentSession(
39
+ db: KernelDatabase,
40
+ childSessionId: string,
41
+ parentSessionId: string,
42
+ parentToolUseId?: string,
43
+ ): Promise<PiAgentSession | undefined> {
44
+ const [row] = await db
45
+ .update(piAgentSessions)
46
+ .set({
47
+ parentSessionId,
48
+ ...(parentToolUseId !== undefined && { parentToolUseId }),
49
+ })
50
+ .where(eq(piAgentSessions.id, childSessionId))
51
+ .returning();
52
+ return row;
53
+ }
54
+
55
+ export async function updatePiAgentSessionStatus(
56
+ db: KernelDatabase,
57
+ piSessionId: string,
58
+ status: SessionStatus,
59
+ endedAt?: string,
60
+ ): Promise<PiAgentSession | undefined> {
61
+ const [row] = await db
62
+ .update(piAgentSessions)
63
+ .set({
64
+ status,
65
+ ...(endedAt !== undefined && { endedAt }),
66
+ })
67
+ .where(eq(piAgentSessions.id, piSessionId))
68
+ .returning();
69
+ return row;
70
+ }
71
+
72
+ export async function getPiAgentSession(
73
+ db: KernelDatabase,
74
+ piSessionId: string,
75
+ ): Promise<PiAgentSession | undefined> {
76
+ const [row] = await db
77
+ .select(piAgentSessionSelection(db))
78
+ .from(piAgentSessions)
79
+ .where(eq(piAgentSessions.id, piSessionId))
80
+ .limit(1);
81
+ return row;
82
+ }
83
+
84
+ export async function listPiAgentSessionsForContainer(
85
+ db: KernelDatabase,
86
+ containerId: string,
87
+ ): Promise<PiAgentSession[]> {
88
+ return db
89
+ .select(piAgentSessionSelection(db))
90
+ .from(piAgentSessions)
91
+ .where(eq(piAgentSessions.containerId, containerId))
92
+ .orderBy(asc(piAgentSessions.createdAt));
93
+ }
@@ -0,0 +1,122 @@
1
+ import { eq, sql } from "drizzle-orm";
2
+ import type { KernelDatabase } from "../client";
3
+ import { agentRuns } from "../schema/agent-runs";
4
+ import { piAgentSessions } from "../schema/pi-agent-sessions";
5
+ import {
6
+ promptRevisions,
7
+ type PromptRevisionSource,
8
+ } from "../schema/prompt-revisions";
9
+ import type { PromptRevision } from "../types";
10
+
11
+ export interface UpsertPromptRevisionInput {
12
+ /** "pk1-<sha256>" of the canonicalized PromptDocument. */
13
+ hash: string;
14
+ agentName: string;
15
+ /** e.g. "prompt-kit/v1". */
16
+ schemaVersion: string;
17
+ /** Canonical JSON PromptDocument. */
18
+ document: string;
19
+ /** XML-tagged Markdown rendered at save time. */
20
+ renderedText: string;
21
+ source: PromptRevisionSource;
22
+ createdAt: string;
23
+ }
24
+
25
+ /**
26
+ * Idempotent on hash: a revision is content-addressed and immutable, so a
27
+ * conflicting insert leaves the existing row (including its createdAt and
28
+ * source) untouched and returns it.
29
+ */
30
+ export async function upsertPromptRevision(
31
+ db: KernelDatabase,
32
+ input: UpsertPromptRevisionInput,
33
+ ): Promise<PromptRevision> {
34
+ const [inserted] = await db
35
+ .insert(promptRevisions)
36
+ .values(input)
37
+ .onConflictDoNothing({ target: promptRevisions.hash })
38
+ .returning();
39
+ if (inserted) return inserted;
40
+ const [existing] = await db
41
+ .select()
42
+ .from(promptRevisions)
43
+ .where(eq(promptRevisions.hash, input.hash))
44
+ .limit(1);
45
+ return existing;
46
+ }
47
+
48
+ export async function getPromptRevision(
49
+ db: KernelDatabase,
50
+ hash: string,
51
+ ): Promise<PromptRevision | undefined> {
52
+ const [row] = await db
53
+ .select()
54
+ .from(promptRevisions)
55
+ .where(eq(promptRevisions.hash, hash))
56
+ .limit(1);
57
+ return row;
58
+ }
59
+
60
+ export async function listPromptRevisionsForAgent(
61
+ db: KernelDatabase,
62
+ agentName: string,
63
+ ): Promise<PromptRevision[]> {
64
+ return db
65
+ .select()
66
+ .from(promptRevisions)
67
+ .where(eq(promptRevisions.agentName, agentName));
68
+ }
69
+
70
+ /**
71
+ * Run analytics for one prompt revision (Phase 5). Sessions record which
72
+ * revision they ran (pi_agent_sessions.prompt_hash, frozen at session
73
+ * creation), so per-revision run stats are one join.
74
+ */
75
+ export interface PromptRevisionStats {
76
+ /** Runs executed against sessions carrying this prompt hash. */
77
+ runs: number;
78
+ /** Sum of input + output tokens across those runs. */
79
+ totalTokens: number;
80
+ /** Average input + output tokens per run (0 when there are no runs). */
81
+ avgTokens: number;
82
+ /**
83
+ * Sum of run cost estimates. NULL when no run has a cost estimate (cost
84
+ * data is optional — it depends on a kernel-config price table).
85
+ */
86
+ cost: number | null;
87
+ /** Runs that ended in status 'error', 'turn-limit', or 'aborted'. */
88
+ failures: number;
89
+ }
90
+
91
+ /**
92
+ * Aggregate agent_runs joined to pi_agent_sessions on prompt_hash:
93
+ *
94
+ * SELECT count(r.id), sum(in+out), avg(in+out), sum(cost),
95
+ * sum(status IN ('error','turn-limit','aborted'))
96
+ * FROM agent_runs r JOIN pi_agent_sessions s ON s.id = r.pi_session_id
97
+ * WHERE s.prompt_hash = :hash;
98
+ */
99
+ export async function getPromptRevisionStats(
100
+ db: KernelDatabase,
101
+ hash: string,
102
+ ): Promise<PromptRevisionStats> {
103
+ const tokens = sql<number>`${agentRuns.usageInputTokens} + ${agentRuns.usageOutputTokens}`;
104
+ const [row] = await db
105
+ .select({
106
+ runs: sql<number>`count(${agentRuns.id})`,
107
+ totalTokens: sql<number>`coalesce(sum(${tokens}), 0)`,
108
+ avgTokens: sql<number | null>`avg(${tokens})`,
109
+ cost: sql<number | null>`sum(${agentRuns.usageCostEstimate})`,
110
+ failures: sql<number>`coalesce(sum(CASE WHEN ${agentRuns.status} IN ('error', 'turn-limit', 'aborted') THEN 1 ELSE 0 END), 0)`,
111
+ })
112
+ .from(agentRuns)
113
+ .innerJoin(piAgentSessions, eq(piAgentSessions.id, agentRuns.piSessionId))
114
+ .where(eq(piAgentSessions.promptHash, hash));
115
+ return {
116
+ runs: row?.runs ?? 0,
117
+ totalTokens: row?.totalTokens ?? 0,
118
+ avgTokens: row?.avgTokens ?? 0,
119
+ cost: row?.cost ?? null,
120
+ failures: row?.failures ?? 0,
121
+ };
122
+ }