@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.
- 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,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous, transaction-local write helpers.
|
|
3
|
+
*
|
|
4
|
+
* bun-sqlite Drizzle transactions are SYNCHRONOUS: `db.transaction(fn,
|
|
5
|
+
* { behavior })` runs fn inside bun:sqlite's native transaction and does not
|
|
6
|
+
* await a returned promise (drizzle-orm/bun-sqlite/session.js). An async
|
|
7
|
+
* callback commits at its first `await` and runs the rest outside the
|
|
8
|
+
* transaction. Every helper here therefore takes the transaction handle and
|
|
9
|
+
* returns synchronously, using only `.run()`, `.all()` and `.get()`. Never
|
|
10
|
+
* call the async actions (usage.ts, trace-events.ts, pi-agent-sessions.ts)
|
|
11
|
+
* from inside a transaction; the async actions stay as they are for every
|
|
12
|
+
* other caller.
|
|
13
|
+
*/
|
|
14
|
+
import { and, asc, eq, sql } from "drizzle-orm";
|
|
15
|
+
import type { TraceEvent } from "@codecaine-ai/agent-kernel-protocol";
|
|
16
|
+
import type { KernelDatabase } from "../client";
|
|
17
|
+
import { agentRuns, type RunStatus } from "../schema/agent-runs";
|
|
18
|
+
import { containers } from "../schema/containers";
|
|
19
|
+
import {
|
|
20
|
+
piAgentSessions,
|
|
21
|
+
type SessionStatus,
|
|
22
|
+
} from "../schema/pi-agent-sessions";
|
|
23
|
+
import { traceBlobs } from "../schema/trace-blobs";
|
|
24
|
+
import { traceEvents } from "../schema/trace-events";
|
|
25
|
+
import type {
|
|
26
|
+
NewAgentRun,
|
|
27
|
+
NewPiAgentSession,
|
|
28
|
+
NewTraceEventRow,
|
|
29
|
+
TraceBlobInput,
|
|
30
|
+
} from "../types";
|
|
31
|
+
import { isSqliteBusyError } from "../upgrade";
|
|
32
|
+
import type { UsageDelta } from "./usage";
|
|
33
|
+
|
|
34
|
+
/** The transaction handle `db.transaction` passes to its (synchronous) callback. */
|
|
35
|
+
export type KernelTx = Parameters<Parameters<KernelDatabase["transaction"]>[0]>[0];
|
|
36
|
+
|
|
37
|
+
/** SQLITE_BUSY on BEGIN IMMEDIATE is retried with these waits, outside the transaction. */
|
|
38
|
+
const BUSY_RETRY_DELAYS_MS = [50, 100, 200, 400, 800] as const;
|
|
39
|
+
|
|
40
|
+
function sleep(ms: number): Promise<void> {
|
|
41
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Run a synchronous callback in one `BEGIN IMMEDIATE` transaction. Any throw
|
|
46
|
+
* inside rolls everything back and rejects. SQLITE_BUSY is retried 5 times
|
|
47
|
+
* (50, 100, 200, 400, 800 ms); a rolled-back attempt left nothing behind, so
|
|
48
|
+
* rerunning it is safe. The rest parameter rejects promise-returning
|
|
49
|
+
* callbacks at compile time.
|
|
50
|
+
*/
|
|
51
|
+
export async function runImmediateTransaction<T>(
|
|
52
|
+
db: KernelDatabase,
|
|
53
|
+
fn: (tx: KernelTx) => T,
|
|
54
|
+
..._syncOnly: T extends PromiseLike<unknown> ? [never] : []
|
|
55
|
+
): Promise<T> {
|
|
56
|
+
for (let attempt = 0; ; attempt++) {
|
|
57
|
+
try {
|
|
58
|
+
return db.transaction(fn, { behavior: "immediate" });
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const delay = BUSY_RETRY_DELAYS_MS[attempt];
|
|
61
|
+
if (!isSqliteBusyError(error) || delay === undefined) throw error;
|
|
62
|
+
await sleep(delay);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Column row for one envelope. The transport-only piSessionUuid is resolved
|
|
69
|
+
* into the pi_session_id column at write time.
|
|
70
|
+
*/
|
|
71
|
+
export function traceEventToRow(e: TraceEvent): NewTraceEventRow {
|
|
72
|
+
return {
|
|
73
|
+
eventId: e.eventId,
|
|
74
|
+
containerId: e.containerId,
|
|
75
|
+
runId: e.runId ?? null,
|
|
76
|
+
piSessionId: e.piSessionUuid ?? null,
|
|
77
|
+
agentId: e.agentId ?? null,
|
|
78
|
+
userId: e.userId ?? null,
|
|
79
|
+
type: e.type,
|
|
80
|
+
source: e.source,
|
|
81
|
+
traceLevel: e.traceLevel,
|
|
82
|
+
eventData: e.eventData,
|
|
83
|
+
spanId: e.spanId ?? null,
|
|
84
|
+
parentEventId: e.parentEventId ?? null,
|
|
85
|
+
timestamp: e.timestamp,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** INSERT … ON CONFLICT(event_id) DO NOTHING. Returns the number of rows inserted. */
|
|
90
|
+
export function insertTraceEventsTx(tx: KernelTx, events: TraceEvent[]): number {
|
|
91
|
+
if (events.length === 0) return 0;
|
|
92
|
+
return tx
|
|
93
|
+
.insert(traceEvents)
|
|
94
|
+
.values(events.map(traceEventToRow))
|
|
95
|
+
.onConflictDoNothing({ target: traceEvents.eventId })
|
|
96
|
+
.returning({ eventId: traceEvents.eventId })
|
|
97
|
+
.all().length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Content-addressed, immutable: an existing hash is left untouched. */
|
|
101
|
+
export function upsertTraceBlobsTx(tx: KernelTx, blobs: TraceBlobInput[]): void {
|
|
102
|
+
if (blobs.length === 0) return;
|
|
103
|
+
tx.insert(traceBlobs)
|
|
104
|
+
.values(blobs)
|
|
105
|
+
.onConflictDoNothing({ target: traceBlobs.hash })
|
|
106
|
+
.run();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Insert a session, or refresh the descriptive fields that are defined on
|
|
111
|
+
* `row` (same conflict rules as upsertPiAgentSession; created_at is kept).
|
|
112
|
+
*/
|
|
113
|
+
export function upsertPiAgentSessionTx(tx: KernelTx, row: NewPiAgentSession): void {
|
|
114
|
+
tx.insert(piAgentSessions)
|
|
115
|
+
.values(row)
|
|
116
|
+
.onConflictDoUpdate({
|
|
117
|
+
target: piAgentSessions.id,
|
|
118
|
+
set: {
|
|
119
|
+
containerId: row.containerId,
|
|
120
|
+
...(row.parentSessionId !== undefined && {
|
|
121
|
+
parentSessionId: row.parentSessionId,
|
|
122
|
+
}),
|
|
123
|
+
...(row.parentToolUseId !== undefined && {
|
|
124
|
+
parentToolUseId: row.parentToolUseId,
|
|
125
|
+
}),
|
|
126
|
+
...(row.agentName !== undefined && { agentName: row.agentName }),
|
|
127
|
+
...(row.displayLabel !== undefined && { displayLabel: row.displayLabel }),
|
|
128
|
+
...(row.model !== undefined && { model: row.model }),
|
|
129
|
+
...(row.promptHash !== undefined && { promptHash: row.promptHash }),
|
|
130
|
+
...(row.status !== undefined && { status: row.status }),
|
|
131
|
+
...(row.phase !== undefined && { phase: row.phase }),
|
|
132
|
+
...(row.endedAt !== undefined && { endedAt: row.endedAt }),
|
|
133
|
+
...(row.kind !== undefined && { kind: row.kind }),
|
|
134
|
+
},
|
|
135
|
+
})
|
|
136
|
+
.run();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Plain insert: a duplicate run id or a missing parent run throws (FK). */
|
|
140
|
+
export function insertAgentRunTx(tx: KernelTx, row: NewAgentRun): void {
|
|
141
|
+
tx.insert(agentRuns).values(row).run();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Set a run's status, end time and (optionally) outbound event. With
|
|
146
|
+
* `onlyIfStatus`, only a run currently in that status changes. Returns the
|
|
147
|
+
* number of rows changed.
|
|
148
|
+
*/
|
|
149
|
+
export function setRunStatusTx(
|
|
150
|
+
tx: KernelTx,
|
|
151
|
+
args: {
|
|
152
|
+
runId: string;
|
|
153
|
+
status: RunStatus;
|
|
154
|
+
endedAt: string;
|
|
155
|
+
outboundEventId?: string;
|
|
156
|
+
onlyIfStatus?: RunStatus;
|
|
157
|
+
},
|
|
158
|
+
): number {
|
|
159
|
+
const where =
|
|
160
|
+
args.onlyIfStatus === undefined
|
|
161
|
+
? eq(agentRuns.id, args.runId)
|
|
162
|
+
: and(eq(agentRuns.id, args.runId), eq(agentRuns.status, args.onlyIfStatus));
|
|
163
|
+
return tx
|
|
164
|
+
.update(agentRuns)
|
|
165
|
+
.set({
|
|
166
|
+
status: args.status,
|
|
167
|
+
endedAt: args.endedAt,
|
|
168
|
+
...(args.outboundEventId !== undefined && {
|
|
169
|
+
outboundEventId: args.outboundEventId,
|
|
170
|
+
}),
|
|
171
|
+
})
|
|
172
|
+
.where(where)
|
|
173
|
+
.returning({ id: agentRuns.id })
|
|
174
|
+
.all().length;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** `endedAt: null` clears the end time (a session reopened for a new attempt). */
|
|
178
|
+
export function setSessionStatusTx(
|
|
179
|
+
tx: KernelTx,
|
|
180
|
+
args: { sessionId: string; status: SessionStatus; endedAt: string | null },
|
|
181
|
+
): void {
|
|
182
|
+
tx.update(piAgentSessions)
|
|
183
|
+
.set({ status: args.status, endedAt: args.endedAt })
|
|
184
|
+
.where(eq(piAgentSessions.id, args.sessionId))
|
|
185
|
+
.run();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Additive usage rollup for one run, its session and its container in one
|
|
190
|
+
* go (the transaction-local twin of usage.ts). Cost columns stay NULL unless
|
|
191
|
+
* a cost is present.
|
|
192
|
+
*/
|
|
193
|
+
export function applyUsageTx(
|
|
194
|
+
tx: KernelTx,
|
|
195
|
+
ids: { runId: string; sessionId: string; containerId: string },
|
|
196
|
+
delta: UsageDelta,
|
|
197
|
+
): void {
|
|
198
|
+
tx.update(agentRuns)
|
|
199
|
+
.set({
|
|
200
|
+
usageInputTokens: sql`${agentRuns.usageInputTokens} + ${delta.inputTokens}`,
|
|
201
|
+
usageOutputTokens: sql`${agentRuns.usageOutputTokens} + ${delta.outputTokens}`,
|
|
202
|
+
usageCacheRead: sql`${agentRuns.usageCacheRead} + ${delta.cacheReadTokens}`,
|
|
203
|
+
usageCacheWrite: sql`${agentRuns.usageCacheWrite} + ${delta.cacheWriteTokens}`,
|
|
204
|
+
...(delta.costEstimate !== undefined && {
|
|
205
|
+
usageCostEstimate: sql`coalesce(${agentRuns.usageCostEstimate}, 0) + ${delta.costEstimate}`,
|
|
206
|
+
}),
|
|
207
|
+
})
|
|
208
|
+
.where(eq(agentRuns.id, ids.runId))
|
|
209
|
+
.run();
|
|
210
|
+
|
|
211
|
+
tx.update(piAgentSessions)
|
|
212
|
+
.set({
|
|
213
|
+
usageInputTokens: sql`${piAgentSessions.usageInputTokens} + ${delta.inputTokens}`,
|
|
214
|
+
usageOutputTokens: sql`${piAgentSessions.usageOutputTokens} + ${delta.outputTokens}`,
|
|
215
|
+
})
|
|
216
|
+
.where(eq(piAgentSessions.id, ids.sessionId))
|
|
217
|
+
.run();
|
|
218
|
+
|
|
219
|
+
tx.update(containers)
|
|
220
|
+
.set({
|
|
221
|
+
usageInputTokens: sql`${containers.usageInputTokens} + ${delta.inputTokens}`,
|
|
222
|
+
usageOutputTokens: sql`${containers.usageOutputTokens} + ${delta.outputTokens}`,
|
|
223
|
+
usageCacheRead: sql`${containers.usageCacheRead} + ${delta.cacheReadTokens}`,
|
|
224
|
+
usageCacheWrite: sql`${containers.usageCacheWrite} + ${delta.cacheWriteTokens}`,
|
|
225
|
+
...(delta.costEstimate !== undefined && {
|
|
226
|
+
usageCostEstimate: sql`coalesce(${containers.usageCostEstimate}, 0) + ${delta.costEstimate}`,
|
|
227
|
+
}),
|
|
228
|
+
})
|
|
229
|
+
.where(eq(containers.id, ids.containerId))
|
|
230
|
+
.run();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** A session's runs, oldest first. `inboundEventId` locates each run's start event. */
|
|
234
|
+
export function listRunsForSessionTx(
|
|
235
|
+
tx: KernelTx,
|
|
236
|
+
sessionId: string,
|
|
237
|
+
): Array<{
|
|
238
|
+
id: string;
|
|
239
|
+
status: RunStatus;
|
|
240
|
+
startedAt: string;
|
|
241
|
+
inboundEventId: string | null;
|
|
242
|
+
}> {
|
|
243
|
+
return tx
|
|
244
|
+
.select({
|
|
245
|
+
id: agentRuns.id,
|
|
246
|
+
status: agentRuns.status,
|
|
247
|
+
startedAt: agentRuns.startedAt,
|
|
248
|
+
inboundEventId: agentRuns.inboundEventId,
|
|
249
|
+
})
|
|
250
|
+
.from(agentRuns)
|
|
251
|
+
.where(eq(agentRuns.piSessionId, sessionId))
|
|
252
|
+
.orderBy(asc(agentRuns.startedAt), asc(agentRuns.id))
|
|
253
|
+
.all();
|
|
254
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage rollup write path (Phase 2) — dumb additive SQL increments.
|
|
3
|
+
*
|
|
4
|
+
* Turn usage lands on the run row as each pi_turn_end arrives; run totals are
|
|
5
|
+
* folded into the session and container rows when the run closes. costEstimate
|
|
6
|
+
* columns are nullable — increments treat NULL as 0 and only touch the column
|
|
7
|
+
* when a delta is present, so rows with no cost data stay NULL.
|
|
8
|
+
*/
|
|
9
|
+
import { eq, sql } from "drizzle-orm";
|
|
10
|
+
import type { KernelDatabase } from "../client";
|
|
11
|
+
import { agentRuns } from "../schema/agent-runs";
|
|
12
|
+
import { containers } from "../schema/containers";
|
|
13
|
+
import { piAgentSessions } from "../schema/pi-agent-sessions";
|
|
14
|
+
|
|
15
|
+
export interface UsageDelta {
|
|
16
|
+
inputTokens: number;
|
|
17
|
+
outputTokens: number;
|
|
18
|
+
cacheReadTokens: number;
|
|
19
|
+
cacheWriteTokens: number;
|
|
20
|
+
costEstimate?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Additively increment a run's usage rollup columns (one pi_turn_end). */
|
|
24
|
+
export async function updateRunUsage(
|
|
25
|
+
db: KernelDatabase,
|
|
26
|
+
runId: string,
|
|
27
|
+
delta: UsageDelta,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
await db
|
|
30
|
+
.update(agentRuns)
|
|
31
|
+
.set({
|
|
32
|
+
usageInputTokens: sql`${agentRuns.usageInputTokens} + ${delta.inputTokens}`,
|
|
33
|
+
usageOutputTokens: sql`${agentRuns.usageOutputTokens} + ${delta.outputTokens}`,
|
|
34
|
+
usageCacheRead: sql`${agentRuns.usageCacheRead} + ${delta.cacheReadTokens}`,
|
|
35
|
+
usageCacheWrite: sql`${agentRuns.usageCacheWrite} + ${delta.cacheWriteTokens}`,
|
|
36
|
+
...(delta.costEstimate !== undefined && {
|
|
37
|
+
usageCostEstimate: sql`coalesce(${agentRuns.usageCostEstimate}, 0) + ${delta.costEstimate}`,
|
|
38
|
+
}),
|
|
39
|
+
})
|
|
40
|
+
.where(eq(agentRuns.id, runId));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fold run totals into the session rollup. Sessions carry input/output
|
|
45
|
+
* columns only (see pi_agent_sessions schema).
|
|
46
|
+
*/
|
|
47
|
+
export async function incrementSessionUsage(
|
|
48
|
+
db: KernelDatabase,
|
|
49
|
+
piSessionId: string,
|
|
50
|
+
delta: { inputTokens: number; outputTokens: number },
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
await db
|
|
53
|
+
.update(piAgentSessions)
|
|
54
|
+
.set({
|
|
55
|
+
usageInputTokens: sql`${piAgentSessions.usageInputTokens} + ${delta.inputTokens}`,
|
|
56
|
+
usageOutputTokens: sql`${piAgentSessions.usageOutputTokens} + ${delta.outputTokens}`,
|
|
57
|
+
})
|
|
58
|
+
.where(eq(piAgentSessions.id, piSessionId));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Fold run totals into the container rollup. */
|
|
62
|
+
export async function incrementContainerUsage(
|
|
63
|
+
db: KernelDatabase,
|
|
64
|
+
containerId: string,
|
|
65
|
+
delta: UsageDelta,
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
await db
|
|
68
|
+
.update(containers)
|
|
69
|
+
.set({
|
|
70
|
+
usageInputTokens: sql`${containers.usageInputTokens} + ${delta.inputTokens}`,
|
|
71
|
+
usageOutputTokens: sql`${containers.usageOutputTokens} + ${delta.outputTokens}`,
|
|
72
|
+
usageCacheRead: sql`${containers.usageCacheRead} + ${delta.cacheReadTokens}`,
|
|
73
|
+
usageCacheWrite: sql`${containers.usageCacheWrite} + ${delta.cacheWriteTokens}`,
|
|
74
|
+
...(delta.costEstimate !== undefined && {
|
|
75
|
+
usageCostEstimate: sql`coalesce(${containers.usageCostEstimate}, 0) + ${delta.costEstimate}`,
|
|
76
|
+
}),
|
|
77
|
+
})
|
|
78
|
+
.where(eq(containers.id, containerId));
|
|
79
|
+
}
|
package/src/bootstrap.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema bootstrap — CREATE TABLE IF NOT EXISTS statements mirroring the
|
|
3
|
+
* Drizzle SQLite schema in ./schema. No migration tooling: the schema is
|
|
4
|
+
* created idempotently on kernel start against the local trace.db, then
|
|
5
|
+
* ./upgrade adds any column an older database lacks.
|
|
6
|
+
*/
|
|
7
|
+
import { sql } from "drizzle-orm";
|
|
8
|
+
import type { KernelDatabase } from "./client";
|
|
9
|
+
import { upgradeKernelObservabilitySchema } from "./upgrade";
|
|
10
|
+
|
|
11
|
+
export async function ensureKernelObservabilitySchema(
|
|
12
|
+
db: KernelDatabase,
|
|
13
|
+
): Promise<void> {
|
|
14
|
+
db.run(sql`
|
|
15
|
+
CREATE TABLE IF NOT EXISTS containers (
|
|
16
|
+
id TEXT PRIMARY KEY,
|
|
17
|
+
kernel_id TEXT NOT NULL,
|
|
18
|
+
kind TEXT NOT NULL,
|
|
19
|
+
app_key TEXT NOT NULL,
|
|
20
|
+
label TEXT,
|
|
21
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
22
|
+
parent_container_id TEXT REFERENCES containers(id),
|
|
23
|
+
phase TEXT,
|
|
24
|
+
phase_vocabulary TEXT,
|
|
25
|
+
working_dir TEXT,
|
|
26
|
+
metadata TEXT,
|
|
27
|
+
usage_input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
28
|
+
usage_output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
29
|
+
usage_cache_read INTEGER NOT NULL DEFAULT 0,
|
|
30
|
+
usage_cache_write INTEGER NOT NULL DEFAULT 0,
|
|
31
|
+
usage_cost_estimate REAL,
|
|
32
|
+
created_at TEXT NOT NULL,
|
|
33
|
+
started_at TEXT,
|
|
34
|
+
ended_at TEXT,
|
|
35
|
+
UNIQUE (kernel_id, kind, app_key)
|
|
36
|
+
)
|
|
37
|
+
`);
|
|
38
|
+
|
|
39
|
+
db.run(sql`
|
|
40
|
+
CREATE TABLE IF NOT EXISTS pi_agent_sessions (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
container_id TEXT NOT NULL REFERENCES containers(id),
|
|
43
|
+
parent_session_id TEXT REFERENCES pi_agent_sessions(id),
|
|
44
|
+
parent_tool_use_id TEXT,
|
|
45
|
+
agent_name TEXT NOT NULL,
|
|
46
|
+
display_label TEXT,
|
|
47
|
+
model TEXT,
|
|
48
|
+
prompt_hash TEXT,
|
|
49
|
+
status TEXT NOT NULL,
|
|
50
|
+
phase TEXT,
|
|
51
|
+
usage_input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
52
|
+
usage_output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
53
|
+
created_at TEXT NOT NULL,
|
|
54
|
+
ended_at TEXT,
|
|
55
|
+
kind TEXT NOT NULL DEFAULT 'pi'
|
|
56
|
+
)
|
|
57
|
+
`);
|
|
58
|
+
|
|
59
|
+
db.run(sql`
|
|
60
|
+
CREATE TABLE IF NOT EXISTS agent_runs (
|
|
61
|
+
id TEXT PRIMARY KEY,
|
|
62
|
+
pi_session_id TEXT NOT NULL REFERENCES pi_agent_sessions(id),
|
|
63
|
+
container_id TEXT NOT NULL REFERENCES containers(id),
|
|
64
|
+
parent_run_id TEXT REFERENCES agent_runs(id),
|
|
65
|
+
parent_tool_use_id TEXT,
|
|
66
|
+
agent_name TEXT NOT NULL,
|
|
67
|
+
trigger TEXT NOT NULL,
|
|
68
|
+
inbound_event_id TEXT,
|
|
69
|
+
outbound_event_id TEXT,
|
|
70
|
+
display_label TEXT,
|
|
71
|
+
phase TEXT,
|
|
72
|
+
status TEXT NOT NULL,
|
|
73
|
+
usage_input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
74
|
+
usage_output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
75
|
+
usage_cache_read INTEGER NOT NULL DEFAULT 0,
|
|
76
|
+
usage_cache_write INTEGER NOT NULL DEFAULT 0,
|
|
77
|
+
usage_cost_estimate REAL,
|
|
78
|
+
started_at TEXT NOT NULL,
|
|
79
|
+
ended_at TEXT
|
|
80
|
+
)
|
|
81
|
+
`);
|
|
82
|
+
|
|
83
|
+
db.run(sql`
|
|
84
|
+
CREATE TABLE IF NOT EXISTS trace_events (
|
|
85
|
+
event_id TEXT PRIMARY KEY,
|
|
86
|
+
container_id TEXT NOT NULL,
|
|
87
|
+
run_id TEXT,
|
|
88
|
+
pi_session_id TEXT,
|
|
89
|
+
agent_id TEXT,
|
|
90
|
+
user_id TEXT,
|
|
91
|
+
type TEXT NOT NULL,
|
|
92
|
+
source TEXT NOT NULL,
|
|
93
|
+
trace_level INTEGER NOT NULL,
|
|
94
|
+
event_data TEXT NOT NULL,
|
|
95
|
+
span_id TEXT,
|
|
96
|
+
parent_event_id TEXT,
|
|
97
|
+
timestamp TEXT NOT NULL
|
|
98
|
+
)
|
|
99
|
+
`);
|
|
100
|
+
|
|
101
|
+
db.run(sql`
|
|
102
|
+
CREATE TABLE IF NOT EXISTS trace_blobs (
|
|
103
|
+
hash TEXT PRIMARY KEY,
|
|
104
|
+
kind TEXT NOT NULL,
|
|
105
|
+
mime_type TEXT NOT NULL,
|
|
106
|
+
byte_length INTEGER NOT NULL,
|
|
107
|
+
data BLOB NOT NULL,
|
|
108
|
+
created_at TEXT NOT NULL
|
|
109
|
+
)
|
|
110
|
+
`);
|
|
111
|
+
|
|
112
|
+
db.run(sql`
|
|
113
|
+
CREATE TABLE IF NOT EXISTS prompt_revisions (
|
|
114
|
+
hash TEXT PRIMARY KEY,
|
|
115
|
+
agent_name TEXT NOT NULL,
|
|
116
|
+
schema_version TEXT NOT NULL,
|
|
117
|
+
document TEXT NOT NULL,
|
|
118
|
+
rendered_text TEXT NOT NULL,
|
|
119
|
+
source TEXT NOT NULL,
|
|
120
|
+
created_at TEXT NOT NULL
|
|
121
|
+
)
|
|
122
|
+
`);
|
|
123
|
+
|
|
124
|
+
const indexes = [
|
|
125
|
+
sql`CREATE INDEX IF NOT EXISTS idx_events_container_ts ON trace_events (container_id, timestamp)`,
|
|
126
|
+
sql`CREATE INDEX IF NOT EXISTS idx_events_run ON trace_events (run_id)`,
|
|
127
|
+
sql`CREATE INDEX IF NOT EXISTS idx_events_pi_session ON trace_events (pi_session_id)`,
|
|
128
|
+
sql`CREATE INDEX IF NOT EXISTS ix_containers_parent_container_id ON containers (parent_container_id)`,
|
|
129
|
+
sql`CREATE INDEX IF NOT EXISTS ix_pi_agent_sessions_container_id ON pi_agent_sessions (container_id)`,
|
|
130
|
+
sql`CREATE INDEX IF NOT EXISTS ix_pi_agent_sessions_parent_session_id ON pi_agent_sessions (parent_session_id)`,
|
|
131
|
+
sql`CREATE INDEX IF NOT EXISTS ix_agent_runs_pi_session_id ON agent_runs (pi_session_id)`,
|
|
132
|
+
sql`CREATE INDEX IF NOT EXISTS ix_agent_runs_container_id ON agent_runs (container_id)`,
|
|
133
|
+
sql`CREATE INDEX IF NOT EXISTS ix_agent_runs_parent_run_id ON agent_runs (parent_run_id)`,
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
for (const statement of indexes) {
|
|
137
|
+
db.run(statement);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Columns added after a table shipped: CREATE TABLE IF NOT EXISTS leaves an
|
|
141
|
+
// existing table untouched, so existing databases gain them here.
|
|
142
|
+
await upgradeKernelObservabilitySchema(db);
|
|
143
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLite client helper — one database file per kernel.
|
|
3
|
+
*
|
|
4
|
+
* Standard path convention: <root>/.agent-kernel/trace.db. WAL mode is
|
|
5
|
+
* enabled on open so readers (viewer) never block the writer (kernel).
|
|
6
|
+
*/
|
|
7
|
+
import { Database } from "bun:sqlite";
|
|
8
|
+
import { mkdirSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { drizzle, type BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
|
|
11
|
+
|
|
12
|
+
/** Standard per-kernel database file, relative to the kernel's root dir. */
|
|
13
|
+
export const KERNEL_DB_RELATIVE_PATH = ".agent-kernel/trace.db";
|
|
14
|
+
|
|
15
|
+
/** The Drizzle handle every db action is typed against (SQLite-first). */
|
|
16
|
+
export type KernelDatabase = BunSQLiteDatabase<Record<string, never>>;
|
|
17
|
+
|
|
18
|
+
export interface KernelDatabaseHandle {
|
|
19
|
+
db: KernelDatabase;
|
|
20
|
+
/** Absolute or caller-provided path of the opened database file. */
|
|
21
|
+
path: string;
|
|
22
|
+
close: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Resolve the standard trace.db path under a kernel root directory. */
|
|
26
|
+
export function kernelDatabasePath(rootDir: string): string {
|
|
27
|
+
return join(rootDir, KERNEL_DB_RELATIVE_PATH);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Open (creating if needed) a kernel SQLite database: mkdir -p the parent
|
|
32
|
+
* directory, open the file, enable WAL, and return a Drizzle handle plus
|
|
33
|
+
* close(). Schema creation is separate — see ensureKernelObservabilitySchema.
|
|
34
|
+
*/
|
|
35
|
+
export function openKernelDatabase(opts: { path: string }): KernelDatabaseHandle {
|
|
36
|
+
mkdirSync(dirname(opts.path), { recursive: true });
|
|
37
|
+
const sqlite = new Database(opts.path, { create: true });
|
|
38
|
+
sqlite.exec("PRAGMA journal_mode = WAL;");
|
|
39
|
+
sqlite.exec("PRAGMA foreign_keys = ON;");
|
|
40
|
+
const db = drizzle(sqlite);
|
|
41
|
+
return {
|
|
42
|
+
db,
|
|
43
|
+
path: opts.path,
|
|
44
|
+
close: () => sqlite.close(),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Open an existing kernel SQLite database without allowing mutations.
|
|
50
|
+
*
|
|
51
|
+
* Unlike openKernelDatabase, this does not create the parent directory or
|
|
52
|
+
* database, set write-related PRAGMAs, or initialize the observability schema.
|
|
53
|
+
*/
|
|
54
|
+
export function openKernelDatabaseReadOnly(
|
|
55
|
+
dbPath: string,
|
|
56
|
+
): KernelDatabaseHandle {
|
|
57
|
+
const sqlite = new Database(dbPath, { readonly: true });
|
|
58
|
+
const db = drizzle(sqlite);
|
|
59
|
+
return {
|
|
60
|
+
db,
|
|
61
|
+
path: dbPath,
|
|
62
|
+
close: () => sqlite.close(),
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export * from "./schema";
|
|
2
|
+
export {
|
|
3
|
+
SESSION_KIND,
|
|
4
|
+
type SessionKind,
|
|
5
|
+
} from "./schema/pi-agent-sessions";
|
|
6
|
+
export * from "./types";
|
|
7
|
+
export * from "./actions";
|
|
8
|
+
export * from "./bootstrap";
|
|
9
|
+
export * from "./upgrade";
|
|
10
|
+
export * from "./read-compat";
|
|
11
|
+
export * from "./client";
|
|
12
|
+
export * from "./manifest";
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local kernel manifest — replaces the old kernel_registrations table.
|
|
3
|
+
*
|
|
4
|
+
* With one SQLite database per kernel there is no shared plane to register
|
|
5
|
+
* with; a kernel just writes a small JSON manifest next to its trace.db:
|
|
6
|
+
* <root>/.agent-kernel/kernel.json.
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
10
|
+
import { basename, dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export const KERNEL_MANIFEST_RELATIVE_PATH = ".agent-kernel/kernel.json";
|
|
13
|
+
|
|
14
|
+
interface KernelManifestBase {
|
|
15
|
+
kernelId: string;
|
|
16
|
+
displayName: string;
|
|
17
|
+
/** Directory Pi writes its durable JSONL transcripts into. */
|
|
18
|
+
piSessionsDir: string;
|
|
19
|
+
/** Base URL of a viewer serving this kernel, when one is running. */
|
|
20
|
+
viewerBaseUrl?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Legacy manifest shape, retained for backward-compatible reads. */
|
|
24
|
+
export interface KernelManifestV1 extends KernelManifestBase {
|
|
25
|
+
manifestVersion?: 1;
|
|
26
|
+
kernelRoot?: undefined;
|
|
27
|
+
dbPath?: undefined;
|
|
28
|
+
catalogRoots?: undefined;
|
|
29
|
+
readApiBaseUrl?: undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Self-describing kernel manifest written by current harnesses. */
|
|
33
|
+
export interface KernelManifest extends KernelManifestBase {
|
|
34
|
+
manifestVersion: 2;
|
|
35
|
+
/** Absolute path to this kernel's .agent-kernel directory. */
|
|
36
|
+
kernelRoot: string;
|
|
37
|
+
/** Absolute path to this kernel's trace database. */
|
|
38
|
+
dbPath: string;
|
|
39
|
+
/** Absolute paths to the agent-catalog roots exposed by this kernel. */
|
|
40
|
+
catalogRoots: string[];
|
|
41
|
+
/** Base URL of the harness read/catalog API, when one is running. */
|
|
42
|
+
readApiBaseUrl?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ReadKernelManifest = KernelManifest | KernelManifestV1;
|
|
46
|
+
|
|
47
|
+
/** Resolve the manifest path under a kernel root directory. */
|
|
48
|
+
export function kernelManifestPath(rootDir: string): string {
|
|
49
|
+
return join(rootDir, KERNEL_MANIFEST_RELATIVE_PATH);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Atomically write <dir>/.agent-kernel/kernel.json (creating the directory if
|
|
54
|
+
* needed). Returns the path written.
|
|
55
|
+
*/
|
|
56
|
+
export async function writeKernelManifest(
|
|
57
|
+
dir: string,
|
|
58
|
+
manifest: KernelManifest,
|
|
59
|
+
): Promise<string> {
|
|
60
|
+
const path = kernelManifestPath(dir);
|
|
61
|
+
const manifestDir = dirname(path);
|
|
62
|
+
const temporaryPath = join(
|
|
63
|
+
manifestDir,
|
|
64
|
+
`.${basename(path)}.${process.pid}.${randomUUID()}.tmp`,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
await mkdir(manifestDir, { recursive: true });
|
|
68
|
+
let replaced = false;
|
|
69
|
+
try {
|
|
70
|
+
await writeFile(
|
|
71
|
+
temporaryPath,
|
|
72
|
+
`${JSON.stringify(manifest, null, 2)}\n`,
|
|
73
|
+
{ encoding: "utf-8", flag: "wx" },
|
|
74
|
+
);
|
|
75
|
+
await rename(temporaryPath, path);
|
|
76
|
+
replaced = true;
|
|
77
|
+
} finally {
|
|
78
|
+
if (!replaced) {
|
|
79
|
+
// Best-effort cleanup must not replace the original write/rename error.
|
|
80
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return path;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read <dir>/.agent-kernel/kernel.json. Returns undefined when the manifest
|
|
88
|
+
* does not exist; throws on malformed JSON or a missing required field.
|
|
89
|
+
*/
|
|
90
|
+
export async function readKernelManifest(
|
|
91
|
+
dir: string,
|
|
92
|
+
): Promise<ReadKernelManifest | undefined> {
|
|
93
|
+
const path = kernelManifestPath(dir);
|
|
94
|
+
let raw: string;
|
|
95
|
+
try {
|
|
96
|
+
raw = await readFile(path, "utf-8");
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
103
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
104
|
+
throw new Error(`Kernel manifest at ${path} must be a JSON object`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const field of ["kernelId", "displayName", "piSessionsDir"] as const) {
|
|
108
|
+
if (typeof parsed[field] !== "string" || parsed[field].length === 0) {
|
|
109
|
+
throw new Error(`Kernel manifest at ${path} is missing "${field}"`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (
|
|
114
|
+
parsed.manifestVersion !== undefined &&
|
|
115
|
+
parsed.manifestVersion !== 1 &&
|
|
116
|
+
parsed.manifestVersion !== 2
|
|
117
|
+
) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Kernel manifest at ${path} has unsupported "manifestVersion"`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (parsed.manifestVersion === 2) {
|
|
124
|
+
for (const field of ["kernelRoot", "dbPath"] as const) {
|
|
125
|
+
if (typeof parsed[field] !== "string" || parsed[field].length === 0) {
|
|
126
|
+
throw new Error(`Kernel manifest at ${path} is missing "${field}"`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (
|
|
130
|
+
!Array.isArray(parsed.catalogRoots) ||
|
|
131
|
+
parsed.catalogRoots.some(
|
|
132
|
+
(root) => typeof root !== "string" || root.length === 0,
|
|
133
|
+
)
|
|
134
|
+
) {
|
|
135
|
+
throw new Error(`Kernel manifest at ${path} is missing "catalogRoots"`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return parsed as unknown as ReadKernelManifest;
|
|
140
|
+
}
|