@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.
- package/LICENSE +21 -0
- package/README.md +82 -2
- package/package.json +38 -4
- package/src/actions/agent-runs.ts +146 -0
- package/src/actions/containers.ts +138 -0
- package/src/actions/index.ts +10 -0
- package/src/actions/node-sessions.ts +414 -0
- package/src/actions/pi-agent-sessions.ts +93 -0
- package/src/actions/prompt-revisions.ts +122 -0
- package/src/actions/read-api.ts +314 -0
- package/src/actions/trace-blobs.ts +71 -0
- package/src/actions/trace-events.ts +158 -0
- package/src/actions/tx-local.ts +254 -0
- package/src/actions/usage.ts +79 -0
- package/src/bootstrap.ts +143 -0
- package/src/client.ts +64 -0
- package/src/index.ts +12 -0
- package/src/manifest.ts +140 -0
- package/src/read-compat.ts +35 -0
- package/src/schema/agent-runs.ts +80 -0
- package/src/schema/containers.ts +72 -0
- package/src/schema/index.ts +31 -0
- package/src/schema/pi-agent-sessions.ts +82 -0
- package/src/schema/prompt-revisions.ts +34 -0
- package/src/schema/trace-blobs.ts +20 -0
- package/src/schema/trace-events.ts +30 -0
- package/src/types.ts +29 -0
- package/src/upgrade.ts +138 -0
|
@@ -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
|
+
}
|