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

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,314 @@
1
+ /**
2
+ * Read API helpers — container-first. All reads are keyed by containerId;
3
+ * there is no app-session identity in this package.
4
+ */
5
+ import { and, asc, count, desc, eq, gt, inArray, max, type SQL } from "drizzle-orm";
6
+
7
+ import type { KernelDatabase } from "../client";
8
+ import { piAgentSessionSelection } from "../read-compat";
9
+ import { agentRuns } from "../schema/agent-runs";
10
+ import { containers } from "../schema/containers";
11
+ import { piAgentSessions } from "../schema/pi-agent-sessions";
12
+ import { traceEvents } from "../schema/trace-events";
13
+ import type {
14
+ AgentRun,
15
+ Container,
16
+ PiAgentSession,
17
+ TraceEventRow,
18
+ } from "../types";
19
+
20
+ export interface KernelTraceReadOptions {
21
+ after?: string | null;
22
+ limit?: number;
23
+ maxContainers?: number;
24
+ }
25
+
26
+ /**
27
+ * `kind` is optional here so callers that build rows by hand (fixtures in
28
+ * linked consumers) keep compiling; reads always fill it, and a missing
29
+ * value means "pi".
30
+ */
31
+ export interface PiAgentSessionWithEventCount
32
+ extends Omit<PiAgentSession, "kind"> {
33
+ kind?: PiAgentSession["kind"];
34
+ eventCount: number;
35
+ }
36
+
37
+ export interface KernelTraceReadRows {
38
+ rootContainer: Container;
39
+ containers: Container[];
40
+ piSessions: PiAgentSessionWithEventCount[];
41
+ agentRuns: AgentRun[];
42
+ events: TraceEventRow[];
43
+ }
44
+
45
+ export interface KernelTraceDeleteResult {
46
+ containerIds: string[];
47
+ piSessionIds: string[];
48
+ deleted: {
49
+ traceEvents: number;
50
+ agentRuns: number;
51
+ piAgentSessions: number;
52
+ containers: number;
53
+ };
54
+ }
55
+
56
+ function clampLimit(limit: number | undefined, fallback: number, max: number): number {
57
+ if (limit === undefined || !Number.isFinite(limit)) return fallback;
58
+ return Math.max(1, Math.min(Math.floor(limit), max));
59
+ }
60
+
61
+ export interface SessionContainerStatsRow {
62
+ container: Container;
63
+ piSessionCount: number;
64
+ eventCount: number;
65
+ latestEventAt: string | null;
66
+ }
67
+
68
+ export interface ListSessionContainersOptions {
69
+ /** Scope to one kernel's containers. */
70
+ kernelId?: string;
71
+ /** Container kind to list; defaults to "session". */
72
+ kind?: string;
73
+ limit?: number;
74
+ }
75
+
76
+ /**
77
+ * Containers of kind "session" (newest first) with the per-container pi
78
+ * session count, event count, and latest event timestamp — the rows behind
79
+ * the trace-sessions index.
80
+ */
81
+ export async function listSessionContainersWithStats(
82
+ db: KernelDatabase,
83
+ opts: ListSessionContainersOptions = {},
84
+ ): Promise<SessionContainerStatsRow[]> {
85
+ const limit = clampLimit(opts.limit, 100, 500);
86
+ const kind = opts.kind ?? "session";
87
+ const conditions: SQL[] = [eq(containers.kind, kind)];
88
+ if (opts.kernelId) conditions.push(eq(containers.kernelId, opts.kernelId));
89
+
90
+ const rows = await db
91
+ .select()
92
+ .from(containers)
93
+ .where(and(...conditions))
94
+ .orderBy(desc(containers.createdAt))
95
+ .limit(limit);
96
+ if (rows.length === 0) return [];
97
+
98
+ const containerIds = rows.map((row) => row.id);
99
+ const piCounts = await db
100
+ .select({ containerId: piAgentSessions.containerId, count: count() })
101
+ .from(piAgentSessions)
102
+ .where(inArray(piAgentSessions.containerId, containerIds))
103
+ .groupBy(piAgentSessions.containerId);
104
+ const eventStats = await db
105
+ .select({
106
+ containerId: traceEvents.containerId,
107
+ count: count(),
108
+ latestEventAt: max(traceEvents.timestamp),
109
+ })
110
+ .from(traceEvents)
111
+ .where(inArray(traceEvents.containerId, containerIds))
112
+ .groupBy(traceEvents.containerId);
113
+
114
+ const piCountByContainer = new Map(
115
+ piCounts.map((row) => [row.containerId, Number(row.count ?? 0)]),
116
+ );
117
+ const eventStatsByContainer = new Map(
118
+ eventStats.map((row) => [
119
+ row.containerId,
120
+ { count: Number(row.count ?? 0), latestEventAt: row.latestEventAt ?? null },
121
+ ]),
122
+ );
123
+
124
+ return rows.map((container) => {
125
+ const stats = eventStatsByContainer.get(container.id);
126
+ return {
127
+ container,
128
+ piSessionCount: piCountByContainer.get(container.id) ?? 0,
129
+ eventCount: stats?.count ?? 0,
130
+ latestEventAt: stats?.latestEventAt ?? null,
131
+ };
132
+ });
133
+ }
134
+
135
+ /** Breadth-first walk of a container subtree, root first. */
136
+ export async function listContainerTree(
137
+ db: KernelDatabase,
138
+ rootContainerId: string,
139
+ opts: { maxContainers?: number } = {},
140
+ ): Promise<Container[]> {
141
+ const maxContainers = clampLimit(opts.maxContainers, 500, 5000);
142
+ const [root] = await db
143
+ .select()
144
+ .from(containers)
145
+ .where(eq(containers.id, rootContainerId))
146
+ .limit(1);
147
+
148
+ if (!root) return [];
149
+
150
+ const rows: Container[] = [root];
151
+ const seen = new Set<string>([root.id]);
152
+ let frontier = [root.id];
153
+
154
+ while (frontier.length > 0 && rows.length < maxContainers) {
155
+ const remaining = maxContainers - rows.length;
156
+ const children = await db
157
+ .select()
158
+ .from(containers)
159
+ .where(inArray(containers.parentContainerId, frontier))
160
+ .orderBy(asc(containers.createdAt))
161
+ .limit(remaining);
162
+
163
+ frontier = [];
164
+ for (const child of children) {
165
+ if (seen.has(child.id)) continue;
166
+ seen.add(child.id);
167
+ rows.push(child);
168
+ frontier.push(child.id);
169
+ }
170
+ }
171
+
172
+ return rows;
173
+ }
174
+
175
+ /**
176
+ * Everything the viewer needs for one container subtree: the container rows,
177
+ * their sessions (with event counts), runs, and events — keyed by containerId
178
+ * only.
179
+ */
180
+ export async function getKernelTraceReadRows(
181
+ db: KernelDatabase,
182
+ rootContainerId: string,
183
+ opts: KernelTraceReadOptions = {},
184
+ ): Promise<KernelTraceReadRows | undefined> {
185
+ const containerRows = await listContainerTree(db, rootContainerId, {
186
+ maxContainers: opts.maxContainers,
187
+ });
188
+ const rootContainer = containerRows[0];
189
+ if (!rootContainer) return undefined;
190
+
191
+ const containerIds = containerRows.map((container) => container.id);
192
+ const limit = clampLimit(opts.limit, 5000, 10000);
193
+
194
+ const piSessionRows: PiAgentSession[] = await db
195
+ .select(piAgentSessionSelection(db))
196
+ .from(piAgentSessions)
197
+ .where(inArray(piAgentSessions.containerId, containerIds))
198
+ .orderBy(asc(piAgentSessions.createdAt));
199
+
200
+ const piSessionIds = piSessionRows.map((pi) => pi.id);
201
+ const eventCountRows =
202
+ piSessionIds.length > 0
203
+ ? await db
204
+ .select({
205
+ piSessionId: traceEvents.piSessionId,
206
+ eventCount: count(),
207
+ })
208
+ .from(traceEvents)
209
+ .where(inArray(traceEvents.piSessionId, piSessionIds))
210
+ .groupBy(traceEvents.piSessionId)
211
+ : [];
212
+ const eventCountsByPiSession = new Map<string, number>();
213
+ for (const row of eventCountRows) {
214
+ if (row.piSessionId) {
215
+ eventCountsByPiSession.set(row.piSessionId, Number(row.eventCount ?? 0));
216
+ }
217
+ }
218
+ const piSessions: PiAgentSessionWithEventCount[] = piSessionRows.map(
219
+ (session) => ({
220
+ ...session,
221
+ eventCount: eventCountsByPiSession.get(session.id) ?? 0,
222
+ }),
223
+ );
224
+
225
+ const runRows: AgentRun[] = await db
226
+ .select()
227
+ .from(agentRuns)
228
+ .where(inArray(agentRuns.containerId, containerIds))
229
+ .orderBy(asc(agentRuns.startedAt));
230
+
231
+ const eventConditions: SQL[] = [
232
+ inArray(traceEvents.containerId, containerIds),
233
+ ];
234
+ if (opts.after) eventConditions.push(gt(traceEvents.timestamp, opts.after));
235
+
236
+ const eventRows: TraceEventRow[] = await db
237
+ .select()
238
+ .from(traceEvents)
239
+ .where(and(...eventConditions))
240
+ .orderBy(asc(traceEvents.timestamp), asc(traceEvents.eventId))
241
+ .limit(limit);
242
+
243
+ return {
244
+ rootContainer,
245
+ containers: containerRows,
246
+ piSessions,
247
+ agentRuns: runRows,
248
+ events: eventRows,
249
+ };
250
+ }
251
+
252
+ /** Delete a container subtree and all rows hanging off it. */
253
+ export async function deleteKernelTraceRows(
254
+ db: KernelDatabase,
255
+ rootContainerId: string,
256
+ opts: Pick<KernelTraceReadOptions, "maxContainers"> = {},
257
+ ): Promise<KernelTraceDeleteResult | undefined> {
258
+ const containerRows = await listContainerTree(db, rootContainerId, {
259
+ maxContainers: opts.maxContainers,
260
+ });
261
+ const rootContainer = containerRows[0];
262
+ if (!rootContainer) return undefined;
263
+
264
+ const containerIds = containerRows.map((container) => container.id);
265
+
266
+ return db.transaction(async (tx) => {
267
+ const piSessionRows = await tx
268
+ .select({ id: piAgentSessions.id })
269
+ .from(piAgentSessions)
270
+ .where(inArray(piAgentSessions.containerId, containerIds));
271
+ const piSessionIds = piSessionRows.map((session) => session.id);
272
+
273
+ const eventConditions: SQL[] = [
274
+ inArray(traceEvents.containerId, containerIds),
275
+ ];
276
+ const deletedEvents = await tx
277
+ .delete(traceEvents)
278
+ .where(and(...eventConditions))
279
+ .returning({ eventId: traceEvents.eventId });
280
+
281
+ const deletedRuns = await tx
282
+ .delete(agentRuns)
283
+ .where(inArray(agentRuns.containerId, containerIds))
284
+ .returning({ id: agentRuns.id });
285
+
286
+ const deletedPiSessions =
287
+ piSessionIds.length > 0
288
+ ? await tx
289
+ .delete(piAgentSessions)
290
+ .where(inArray(piAgentSessions.id, piSessionIds))
291
+ .returning({ id: piAgentSessions.id })
292
+ : [];
293
+
294
+ const deletedContainers: Array<{ id: string }> = [];
295
+ for (const container of [...containerRows].reverse()) {
296
+ const rows = await tx
297
+ .delete(containers)
298
+ .where(eq(containers.id, container.id))
299
+ .returning({ id: containers.id });
300
+ deletedContainers.push(...rows);
301
+ }
302
+
303
+ return {
304
+ containerIds,
305
+ piSessionIds,
306
+ deleted: {
307
+ traceEvents: deletedEvents.length,
308
+ agentRuns: deletedRuns.length,
309
+ piAgentSessions: deletedPiSessions.length,
310
+ containers: deletedContainers.length,
311
+ },
312
+ };
313
+ });
314
+ }
@@ -0,0 +1,71 @@
1
+ import { createHash } from "node:crypto";
2
+ import { eq, inArray } from "drizzle-orm";
3
+ import type { KernelDatabase } from "../client";
4
+ import { traceBlobs } from "../schema/trace-blobs";
5
+ import type { NewTraceBlob, TraceBlob } from "../types";
6
+
7
+ /**
8
+ * Content address for trace blob bytes: "b1-" + sha256hex of the raw bytes.
9
+ * The kernel capture code hashes payloads with this before storing them.
10
+ */
11
+ export function hashTraceBlobBytes(bytes: Uint8Array): string {
12
+ return `b1-${createHash("sha256").update(bytes).digest("hex")}`;
13
+ }
14
+
15
+ /**
16
+ * Idempotent multi-row insert keyed by hash. Blobs are content-addressed and
17
+ * immutable, so a conflicting insert is benign — the existing row (including
18
+ * its createdAt) is left untouched. Returns the number of rows actually
19
+ * inserted (0 when every blob already existed).
20
+ */
21
+ export async function upsertTraceBlobs(
22
+ db: KernelDatabase,
23
+ blobs: NewTraceBlob[],
24
+ ): Promise<number> {
25
+ if (blobs.length === 0) return 0;
26
+
27
+ const inserted = await db
28
+ .insert(traceBlobs)
29
+ .values(blobs)
30
+ .onConflictDoNothing({ target: traceBlobs.hash })
31
+ .returning({ hash: traceBlobs.hash });
32
+ return inserted.length;
33
+ }
34
+
35
+ export async function getTraceBlob(
36
+ db: KernelDatabase,
37
+ hash: string,
38
+ ): Promise<TraceBlob | null> {
39
+ const [row] = await db
40
+ .select()
41
+ .from(traceBlobs)
42
+ .where(eq(traceBlobs.hash, hash))
43
+ .limit(1);
44
+ return row ?? null;
45
+ }
46
+
47
+ /** Blob row metadata — everything except the data payload. */
48
+ export type TraceBlobMeta = Omit<TraceBlob, "data">;
49
+
50
+ /**
51
+ * Metadata rows (no data column) for the given hashes, for existence and
52
+ * size checks without pulling payload bytes off disk. Missing hashes are
53
+ * simply absent from the result.
54
+ */
55
+ export async function getTraceBlobMetas(
56
+ db: KernelDatabase,
57
+ hashes: string[],
58
+ ): Promise<TraceBlobMeta[]> {
59
+ if (hashes.length === 0) return [];
60
+
61
+ return db
62
+ .select({
63
+ hash: traceBlobs.hash,
64
+ kind: traceBlobs.kind,
65
+ mimeType: traceBlobs.mimeType,
66
+ byteLength: traceBlobs.byteLength,
67
+ createdAt: traceBlobs.createdAt,
68
+ })
69
+ .from(traceBlobs)
70
+ .where(inArray(traceBlobs.hash, hashes));
71
+ }
@@ -0,0 +1,158 @@
1
+ import { and, asc, eq, gt, inArray, sql } from "drizzle-orm";
2
+ import type { TraceEvent } from "@codecaine-ai/agent-kernel-protocol";
3
+ import type { KernelDatabase } from "../client";
4
+ import { traceEvents } from "../schema/trace-events";
5
+ import type { TraceEventRow } from "../types";
6
+ import { runImmediateTransaction, traceEventToRow } from "./tx-local";
7
+
8
+ /**
9
+ * Idempotent batch insert keyed by event_id (INSERT OR IGNORE) — replaying
10
+ * a batch never duplicates rows. The transport-only piSessionUuid on the
11
+ * envelope is resolved into the pi_session_id column at write time.
12
+ */
13
+ export async function insertTraceEventsBatch(
14
+ db: KernelDatabase,
15
+ events: TraceEvent[],
16
+ ): Promise<number> {
17
+ if (events.length === 0) return 0;
18
+
19
+ const inserted = await db
20
+ .insert(traceEvents)
21
+ .values(events.map(traceEventToRow))
22
+ .onConflictDoNothing({ target: traceEvents.eventId })
23
+ .returning({ eventId: traceEvents.eventId });
24
+ return inserted.length;
25
+ }
26
+
27
+ export interface ListTraceEventsOptions {
28
+ typeFilter?: string[];
29
+ after?: string;
30
+ limit?: number;
31
+ }
32
+
33
+ export async function listTraceEventsForContainer(
34
+ db: KernelDatabase,
35
+ containerId: string,
36
+ opts: ListTraceEventsOptions = {},
37
+ ): Promise<TraceEventRow[]> {
38
+ const { typeFilter, after, limit = 100 } = opts;
39
+
40
+ const conditions = [eq(traceEvents.containerId, containerId)];
41
+ if (typeFilter && typeFilter.length > 0) {
42
+ conditions.push(inArray(traceEvents.type, typeFilter));
43
+ }
44
+ if (after) {
45
+ conditions.push(gt(traceEvents.timestamp, after));
46
+ }
47
+
48
+ return db
49
+ .select()
50
+ .from(traceEvents)
51
+ .where(and(...conditions))
52
+ .orderBy(asc(traceEvents.timestamp), asc(traceEvents.eventId))
53
+ .limit(Math.min(limit, 1000));
54
+ }
55
+
56
+ export async function listTraceEventsForRun(
57
+ db: KernelDatabase,
58
+ runId: string,
59
+ opts: ListTraceEventsOptions = {},
60
+ ): Promise<TraceEventRow[]> {
61
+ const { typeFilter, after, limit = 100 } = opts;
62
+
63
+ const conditions = [eq(traceEvents.runId, runId)];
64
+ if (typeFilter && typeFilter.length > 0) {
65
+ conditions.push(inArray(traceEvents.type, typeFilter));
66
+ }
67
+ if (after) {
68
+ conditions.push(gt(traceEvents.timestamp, after));
69
+ }
70
+
71
+ return db
72
+ .select()
73
+ .from(traceEvents)
74
+ .where(and(...conditions))
75
+ .orderBy(asc(traceEvents.timestamp), asc(traceEvents.eventId))
76
+ .limit(Math.min(limit, 1000));
77
+ }
78
+
79
+ /** Envelope for a stored row; pi_session_id travels back as piSessionUuid. */
80
+ function traceEventFromRow(row: TraceEventRow): TraceEvent {
81
+ return {
82
+ eventId: row.eventId,
83
+ containerId: row.containerId,
84
+ type: row.type,
85
+ source: row.source,
86
+ traceLevel: row.traceLevel as TraceEvent["traceLevel"],
87
+ eventData: row.eventData as TraceEvent["eventData"],
88
+ timestamp: row.timestamp,
89
+ ...(row.runId !== null && { runId: row.runId }),
90
+ ...(row.piSessionId !== null && { piSessionUuid: row.piSessionId }),
91
+ ...(row.agentId !== null && { agentId: row.agentId }),
92
+ ...(row.userId !== null && { userId: row.userId }),
93
+ ...(row.spanId !== null && { spanId: row.spanId }),
94
+ ...(row.parentEventId !== null && { parentEventId: row.parentEventId }),
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Every event of one run as envelopes, oldest first (uses idx_events_run).
100
+ * Unlike listTraceEventsForRun there is no row limit: a model-node replay
101
+ * rebuilds its result from the whole run.
102
+ */
103
+ export async function getTraceEventsForRun(
104
+ db: KernelDatabase,
105
+ runId: string,
106
+ types?: readonly string[],
107
+ ): Promise<TraceEvent[]> {
108
+ const conditions = [eq(traceEvents.runId, runId)];
109
+ if (types && types.length > 0) {
110
+ conditions.push(inArray(traceEvents.type, [...types]));
111
+ }
112
+ const rows = await db
113
+ .select()
114
+ .from(traceEvents)
115
+ .where(and(...conditions))
116
+ .orderBy(asc(traceEvents.timestamp), asc(traceEvents.eventId));
117
+ return rows.map(traceEventFromRow);
118
+ }
119
+
120
+ /**
121
+ * Promotable upsert for nested tool ends: inserts, or replaces an existing
122
+ * row with the same event_id only when the stored row's event_data.timing is
123
+ * 'approximate' and the new one is 'live'. Any other conflict keeps the
124
+ * stored row.
125
+ *
126
+ * INSERT … ON CONFLICT(event_id) DO UPDATE
127
+ * SET event_data = excluded.event_data, timestamp = excluded.timestamp
128
+ * WHERE json_extract(trace_events.event_data, '$.timing') = 'approximate'
129
+ * AND json_extract(excluded.event_data, '$.timing') = 'live'
130
+ */
131
+ export async function upsertPromotableTraceEvent(
132
+ db: KernelDatabase,
133
+ event: TraceEvent,
134
+ ): Promise<"inserted" | "promoted" | "kept"> {
135
+ return runImmediateTransaction(db, (tx) => {
136
+ const existed =
137
+ tx
138
+ .select({ eventId: traceEvents.eventId })
139
+ .from(traceEvents)
140
+ .where(eq(traceEvents.eventId, event.eventId))
141
+ .get() !== undefined;
142
+ const written = tx
143
+ .insert(traceEvents)
144
+ .values(traceEventToRow(event))
145
+ .onConflictDoUpdate({
146
+ target: traceEvents.eventId,
147
+ set: {
148
+ eventData: sql`excluded.event_data`,
149
+ timestamp: sql`excluded.timestamp`,
150
+ },
151
+ setWhere: sql`json_extract(${traceEvents.eventData}, '$.timing') = 'approximate' AND json_extract(excluded.event_data, '$.timing') = 'live'`,
152
+ })
153
+ .returning({ eventId: traceEvents.eventId })
154
+ .all();
155
+ if (!existed) return "inserted" as const;
156
+ return written.length > 0 ? ("promoted" as const) : ("kept" as const);
157
+ });
158
+ }