@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,35 @@
1
+ /**
2
+ * Read compatibility with databases that predate a column.
3
+ *
4
+ * Read-only handles (viewers, the read API) cannot upgrade the schema, so
5
+ * selecting a column that an older database lacks would fail. Readers select
6
+ * through these concrete Drizzle selections instead, which substitute the
7
+ * column's default when it is missing and keep the declared row types.
8
+ */
9
+ import { getTableColumns, sql, type SQL } from "drizzle-orm";
10
+ import type { KernelDatabase } from "./client";
11
+ import { piAgentSessions, type SessionKind } from "./schema/pi-agent-sessions";
12
+ import { hasSessionKindColumn } from "./upgrade";
13
+
14
+ const { kind: _kind, ...piAgentSessionBaseColumns } =
15
+ getTableColumns(piAgentSessions);
16
+
17
+ export type PiAgentSessionSelection = typeof piAgentSessionBaseColumns & {
18
+ kind: SQL.Aliased<SessionKind>;
19
+ };
20
+
21
+ /**
22
+ * Every pi_agent_sessions column, with `kind` read as 'pi' when the column
23
+ * does not exist yet. Rows keep the PiAgentSession shape:
24
+ * `db.select(piAgentSessionSelection(db)).from(piAgentSessions)`.
25
+ */
26
+ export function piAgentSessionSelection(
27
+ db: KernelDatabase,
28
+ ): PiAgentSessionSelection {
29
+ return {
30
+ ...piAgentSessionBaseColumns,
31
+ kind: hasSessionKindColumn(db)
32
+ ? sql<SessionKind>`${piAgentSessions.kind}`.as("kind")
33
+ : sql<SessionKind>`'pi'`.as("kind"),
34
+ };
35
+ }
@@ -0,0 +1,80 @@
1
+ import {
2
+ foreignKey,
3
+ index,
4
+ integer,
5
+ real,
6
+ sqliteTable,
7
+ text,
8
+ } from "drizzle-orm/sqlite-core";
9
+ import { containers } from "./containers";
10
+ import { piAgentSessions } from "./pi-agent-sessions";
11
+
12
+ /** What opened the run. Closed vocabulary per the identity model. */
13
+ export const RUN_TRIGGER = {
14
+ OPERATOR: "operator",
15
+ PARENT_TOOL: "parent-tool",
16
+ STEER: "steer",
17
+ RESUME: "resume",
18
+ SYSTEM: "system",
19
+ /** A model node describing a finished parent run. */
20
+ POST_RUN: "post-run",
21
+ /** A decision node judging something on behalf of its caller. */
22
+ JUDGE: "judge",
23
+ } as const;
24
+
25
+ export type RunTrigger = (typeof RUN_TRIGGER)[keyof typeof RUN_TRIGGER];
26
+
27
+ /** Run status. Closed vocabulary: every run reaches a terminal status. */
28
+ export const RUN_STATUS = {
29
+ RUNNING: "running",
30
+ DONE: "done",
31
+ ERROR: "error",
32
+ ABORTED: "aborted",
33
+ TURN_LIMIT: "turn-limit",
34
+ } as const;
35
+
36
+ export type RunStatus = (typeof RUN_STATUS)[keyof typeof RUN_STATUS];
37
+
38
+ /**
39
+ * One processing loop: message in -> response out. A session holds many runs.
40
+ * inbound_event_id is the message that opened the run; outbound_event_id the
41
+ * response that closed it.
42
+ */
43
+ export const agentRuns = sqliteTable(
44
+ "agent_runs",
45
+ {
46
+ id: text("id").primaryKey(),
47
+ piSessionId: text("pi_session_id")
48
+ .notNull()
49
+ .references(() => piAgentSessions.id),
50
+ containerId: text("container_id")
51
+ .notNull()
52
+ .references(() => containers.id),
53
+ parentRunId: text("parent_run_id"),
54
+ parentToolUseId: text("parent_tool_use_id"),
55
+ agentName: text("agent_name").notNull(),
56
+ trigger: text("trigger").$type<RunTrigger>().notNull(),
57
+ inboundEventId: text("inbound_event_id"),
58
+ outboundEventId: text("outbound_event_id"),
59
+ displayLabel: text("display_label"),
60
+ phase: text("phase"),
61
+ status: text("status").$type<RunStatus>().notNull(),
62
+ usageInputTokens: integer("usage_input_tokens").notNull().default(0),
63
+ usageOutputTokens: integer("usage_output_tokens").notNull().default(0),
64
+ usageCacheRead: integer("usage_cache_read").notNull().default(0),
65
+ usageCacheWrite: integer("usage_cache_write").notNull().default(0),
66
+ usageCostEstimate: real("usage_cost_estimate"),
67
+ startedAt: text("started_at").notNull(),
68
+ endedAt: text("ended_at"),
69
+ },
70
+ (table) => [
71
+ index("ix_agent_runs_pi_session_id").on(table.piSessionId),
72
+ index("ix_agent_runs_container_id").on(table.containerId),
73
+ index("ix_agent_runs_parent_run_id").on(table.parentRunId),
74
+ foreignKey({
75
+ columns: [table.parentRunId],
76
+ foreignColumns: [table.id],
77
+ name: "agent_runs_parent_run_id_fkey",
78
+ }),
79
+ ],
80
+ );
@@ -0,0 +1,72 @@
1
+ import {
2
+ foreignKey,
3
+ index,
4
+ integer,
5
+ real,
6
+ sqliteTable,
7
+ text,
8
+ unique,
9
+ } from "drizzle-orm/sqlite-core";
10
+
11
+ /**
12
+ * Container status vocabulary. Open string — the kind/status vocabulary
13
+ * belongs to the host app; these are the kernel's conventional values.
14
+ */
15
+ export const CONTAINER_STATUS = {
16
+ ACTIVE: "active",
17
+ DONE: "done",
18
+ ERROR: "error",
19
+ ABORTED: "aborted",
20
+ } as const;
21
+
22
+ export type ContainerStatus =
23
+ | (typeof CONTAINER_STATUS)[keyof typeof CONTAINER_STATUS]
24
+ | (string & {});
25
+
26
+ /**
27
+ * The single grouping primitive. Identified deterministically from
28
+ * (kernel_id, kind, app_key) — same identity always upserts the same row.
29
+ * Usage rollup columns land in Phase 1 and are populated in Phase 2.
30
+ */
31
+ export const containers = sqliteTable(
32
+ "containers",
33
+ {
34
+ id: text("id").primaryKey(),
35
+ kernelId: text("kernel_id").notNull(),
36
+ kind: text("kind").notNull(),
37
+ /** JSON array of app-defined key segments. */
38
+ appKey: text("app_key", { mode: "json" }).$type<string[]>().notNull(),
39
+ label: text("label"),
40
+ status: text("status")
41
+ .$type<ContainerStatus>()
42
+ .notNull()
43
+ .default(CONTAINER_STATUS.ACTIVE),
44
+ parentContainerId: text("parent_container_id"),
45
+ phase: text("phase"),
46
+ phaseVocabulary: text("phase_vocabulary", { mode: "json" }).$type<string[]>(),
47
+ workingDir: text("working_dir"),
48
+ /** Opaque to the kernel. */
49
+ metadata: text("metadata", { mode: "json" }).$type<Record<string, unknown>>(),
50
+ usageInputTokens: integer("usage_input_tokens").notNull().default(0),
51
+ usageOutputTokens: integer("usage_output_tokens").notNull().default(0),
52
+ usageCacheRead: integer("usage_cache_read").notNull().default(0),
53
+ usageCacheWrite: integer("usage_cache_write").notNull().default(0),
54
+ usageCostEstimate: real("usage_cost_estimate"),
55
+ createdAt: text("created_at").notNull(),
56
+ startedAt: text("started_at"),
57
+ endedAt: text("ended_at"),
58
+ },
59
+ (table) => [
60
+ unique("ux_containers_kernel_kind_app_key").on(
61
+ table.kernelId,
62
+ table.kind,
63
+ table.appKey,
64
+ ),
65
+ index("ix_containers_parent_container_id").on(table.parentContainerId),
66
+ foreignKey({
67
+ columns: [table.parentContainerId],
68
+ foreignColumns: [table.id],
69
+ name: "containers_parent_container_id_fkey",
70
+ }),
71
+ ],
72
+ );
@@ -0,0 +1,31 @@
1
+ /**
2
+ * SQLite schema (the only dialect). One database file per kernel:
3
+ * .agent-kernel/trace.db, opened with Bun's built-in SQLite driver
4
+ * through Drizzle (see ../client.ts).
5
+ */
6
+ export {
7
+ containers,
8
+ CONTAINER_STATUS,
9
+ type ContainerStatus,
10
+ } from "./containers";
11
+ export {
12
+ piAgentSessions,
13
+ SESSION_KIND,
14
+ SESSION_STATUS,
15
+ type SessionKind,
16
+ type SessionStatus,
17
+ } from "./pi-agent-sessions";
18
+ export {
19
+ agentRuns,
20
+ RUN_STATUS,
21
+ RUN_TRIGGER,
22
+ type RunStatus,
23
+ type RunTrigger,
24
+ } from "./agent-runs";
25
+ export { traceEvents } from "./trace-events";
26
+ export { traceBlobs } from "./trace-blobs";
27
+ export {
28
+ promptRevisions,
29
+ PROMPT_REVISION_SOURCE,
30
+ type PromptRevisionSource,
31
+ } from "./prompt-revisions";
@@ -0,0 +1,82 @@
1
+ import {
2
+ foreignKey,
3
+ index,
4
+ integer,
5
+ sqliteTable,
6
+ text,
7
+ } from "drizzle-orm/sqlite-core";
8
+ import { containers } from "./containers";
9
+
10
+ /**
11
+ * Session status vocabulary. Open string; conventional kernel values below.
12
+ * A session is "active" while its agent can still receive runs.
13
+ */
14
+ export const SESSION_STATUS = {
15
+ ACTIVE: "active",
16
+ ENDED: "ended",
17
+ ERROR: "error",
18
+ } as const;
19
+
20
+ export type SessionStatus =
21
+ | (typeof SESSION_STATUS)[keyof typeof SESSION_STATUS]
22
+ | (string & {});
23
+
24
+ /**
25
+ * What produced the session. "pi" is a Pi agent conversation; "call" and
26
+ * "decision" are model-node sessions (one structured request or one
27
+ * classification per run). Open string; rows written before the column
28
+ * existed read as "pi".
29
+ */
30
+ export const SESSION_KIND = {
31
+ PI: "pi",
32
+ CALL: "call",
33
+ DECISION: "decision",
34
+ } as const;
35
+
36
+ export type SessionKind =
37
+ | (typeof SESSION_KIND)[keyof typeof SESSION_KIND]
38
+ | (string & {});
39
+
40
+ /**
41
+ * One Pi conversation. The system prompt is frozen at session creation, so
42
+ * prompt_hash lives here (populated from Phase 3). A subagent's session
43
+ * carries parent_session_id and parent_tool_use_id.
44
+ */
45
+ export const piAgentSessions = sqliteTable(
46
+ "pi_agent_sessions",
47
+ {
48
+ id: text("id").primaryKey(),
49
+ containerId: text("container_id")
50
+ .notNull()
51
+ .references(() => containers.id),
52
+ parentSessionId: text("parent_session_id"),
53
+ /** Set when spawned by a parent's tool call. */
54
+ parentToolUseId: text("parent_tool_use_id"),
55
+ agentName: text("agent_name").notNull(),
56
+ displayLabel: text("display_label"),
57
+ model: text("model"),
58
+ /** Phase 3: prompt revision hash ("pk1-<sha256>") at session creation. */
59
+ promptHash: text("prompt_hash"),
60
+ status: text("status").$type<SessionStatus>().notNull(),
61
+ phase: text("phase"),
62
+ usageInputTokens: integer("usage_input_tokens").notNull().default(0),
63
+ usageOutputTokens: integer("usage_output_tokens").notNull().default(0),
64
+ createdAt: text("created_at").notNull(),
65
+ endedAt: text("ended_at"),
66
+ /**
67
+ * Added after the table shipped: existing databases gain it through
68
+ * upgradeKernelObservabilitySchema, and readers go through
69
+ * piAgentSessionSelection so un-upgraded read-only handles still work.
70
+ */
71
+ kind: text("kind").$type<SessionKind>().notNull().default(SESSION_KIND.PI),
72
+ },
73
+ (table) => [
74
+ index("ix_pi_agent_sessions_container_id").on(table.containerId),
75
+ index("ix_pi_agent_sessions_parent_session_id").on(table.parentSessionId),
76
+ foreignKey({
77
+ columns: [table.parentSessionId],
78
+ foreignColumns: [table.id],
79
+ name: "pi_agent_sessions_parent_session_id_fkey",
80
+ }),
81
+ ],
82
+ );
@@ -0,0 +1,34 @@
1
+ import { sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+
3
+ /** Where a prompt revision came from. */
4
+ export const PROMPT_REVISION_SOURCE = {
5
+ REGISTRY_BOOT: "registry-boot",
6
+ LAB_SAVE: "lab-save",
7
+ DISK_SYNC: "disk-sync",
8
+ /** Direct mutation committed by the Prompt Kit MCP shared store. */
9
+ PROMPT_KIT_MCP: "prompt-kit-mcp",
10
+ MIGRATION: "migration",
11
+ /** Accepted prompt-editor agent proposal applied by the Phase 2 apply path. */
12
+ AGENT_RUN: "agent-run",
13
+ } as const;
14
+
15
+ export type PromptRevisionSource =
16
+ (typeof PROMPT_REVISION_SOURCE)[keyof typeof PROMPT_REVISION_SOURCE];
17
+
18
+ /**
19
+ * Content-addressed prompt revisions. The table lands in Phase 1;
20
+ * Phase 3 (prompt.json + revisions) populates it.
21
+ */
22
+ export const promptRevisions = sqliteTable("prompt_revisions", {
23
+ /** "pk1-<sha256>" of the canonicalized PromptDocument. */
24
+ hash: text("hash").primaryKey(),
25
+ agentName: text("agent_name").notNull(),
26
+ /** e.g. "prompt-kit/v1". */
27
+ schemaVersion: text("schema_version").notNull(),
28
+ /** Canonical JSON PromptDocument. */
29
+ document: text("document").notNull(),
30
+ /** XML-tagged Markdown rendered at save time. */
31
+ renderedText: text("rendered_text").notNull(),
32
+ source: text("source").$type<PromptRevisionSource>().notNull(),
33
+ createdAt: text("created_at").notNull(),
34
+ });
@@ -0,0 +1,20 @@
1
+ import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+
3
+ /**
4
+ * Content-addressed blob store for binary/JSON trace payloads — images
5
+ * captured from agent conversations, sanitized message JSON, system prompt
6
+ * text. Rows are immutable and keyed by content hash, so inserts are
7
+ * idempotent and identical payloads dedupe for free.
8
+ */
9
+ export const traceBlobs = sqliteTable("trace_blobs", {
10
+ /** "b1-<sha256hex>" of the raw bytes. */
11
+ hash: text("hash").primaryKey(),
12
+ /** "image" | "message" | "text" | "tools" (open string). */
13
+ kind: text("kind").notNull(),
14
+ mimeType: text("mime_type").notNull(),
15
+ byteLength: integer("byte_length").notNull(),
16
+ /** Raw payload bytes. */
17
+ data: blob("data", { mode: "buffer" }).notNull(),
18
+ /** ISO-8601, caller-supplied like the other tables. */
19
+ createdAt: text("created_at").notNull(),
20
+ });
@@ -0,0 +1,30 @@
1
+ import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
2
+
3
+ /**
4
+ * The event log. container_id is the single required grouping identity;
5
+ * run/session/agent/user linkage is stamped when known at emit time.
6
+ * event_id is the idempotent insert key (INSERT OR IGNORE).
7
+ */
8
+ export const traceEvents = sqliteTable(
9
+ "trace_events",
10
+ {
11
+ eventId: text("event_id").primaryKey(),
12
+ containerId: text("container_id").notNull(),
13
+ runId: text("run_id"),
14
+ piSessionId: text("pi_session_id"),
15
+ agentId: text("agent_id"),
16
+ userId: text("user_id"),
17
+ type: text("type").notNull(),
18
+ source: text("source").notNull(),
19
+ traceLevel: integer("trace_level").notNull(),
20
+ eventData: text("event_data", { mode: "json" }).notNull(),
21
+ spanId: text("span_id"),
22
+ parentEventId: text("parent_event_id"),
23
+ timestamp: text("timestamp").notNull(),
24
+ },
25
+ (table) => [
26
+ index("idx_events_container_ts").on(table.containerId, table.timestamp),
27
+ index("idx_events_run").on(table.runId),
28
+ index("idx_events_pi_session").on(table.piSessionId),
29
+ ],
30
+ );
package/src/types.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type {
2
+ agentRuns,
3
+ containers,
4
+ piAgentSessions,
5
+ promptRevisions,
6
+ traceBlobs,
7
+ traceEvents,
8
+ } from "./schema";
9
+
10
+ export type Container = typeof containers.$inferSelect;
11
+ export type NewContainer = typeof containers.$inferInsert;
12
+
13
+ export type TraceEventRow = typeof traceEvents.$inferSelect;
14
+ export type NewTraceEventRow = typeof traceEvents.$inferInsert;
15
+
16
+ export type PiAgentSession = typeof piAgentSessions.$inferSelect;
17
+ export type NewPiAgentSession = typeof piAgentSessions.$inferInsert;
18
+
19
+ export type AgentRun = typeof agentRuns.$inferSelect;
20
+ export type NewAgentRun = typeof agentRuns.$inferInsert;
21
+
22
+ export type PromptRevision = typeof promptRevisions.$inferSelect;
23
+ export type NewPromptRevision = typeof promptRevisions.$inferInsert;
24
+
25
+ export type TraceBlob = typeof traceBlobs.$inferSelect;
26
+ export type NewTraceBlob = typeof traceBlobs.$inferInsert;
27
+
28
+ /** One blob to store through the transaction-local write path (content-addressed by `hash`). */
29
+ export type TraceBlobInput = NewTraceBlob;
package/src/upgrade.ts ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Idempotent, additive schema upgrades for databases created before a column
3
+ * existed. No migration tooling: each step probes the live schema and applies
4
+ * a metadata-only change when it is missing.
5
+ *
6
+ * `ALTER TABLE … ADD COLUMN` with a constant default rewrites no rows in
7
+ * SQLite, so it is safe on very large databases. No index is added for the
8
+ * new column: CREATE INDEX would scan the whole table.
9
+ */
10
+ import { sql } from "drizzle-orm";
11
+ import type { KernelDatabase } from "./client";
12
+
13
+ export interface SchemaUpgradeReport {
14
+ /** Steps that added something (including a column another handle added concurrently). */
15
+ applied: string[];
16
+ /** Steps with nothing to do, or that could not run on a read-only handle. */
17
+ skipped: string[];
18
+ /** True when a needed step could not run because the handle is read-only. */
19
+ readOnly: boolean;
20
+ }
21
+
22
+ const SESSION_KIND_STEP = "pi_agent_sessions.kind";
23
+
24
+ /** SQLITE_BUSY retries for the ALTER: 5 retries, 100 ms apart. */
25
+ const BUSY_RETRIES = 5;
26
+ const BUSY_RETRY_DELAY_MS = 100;
27
+
28
+ /**
29
+ * Handles known to have pi_agent_sessions.kind. Only positive results are
30
+ * cached: a column is never removed, but a read-only handle must notice as
31
+ * soon as another process adds it.
32
+ */
33
+ const sessionKindColumnKnown = new WeakMap<KernelDatabase, true>();
34
+
35
+ /**
36
+ * The error and its causes: Drizzle wraps driver errors from `db.run()` in a
37
+ * DrizzleError whose `cause` is the bun:sqlite error.
38
+ */
39
+ function errorChain(error: unknown): unknown[] {
40
+ const chain: unknown[] = [];
41
+ let current = error;
42
+ while (current !== undefined && current !== null && chain.length < 5) {
43
+ chain.push(current);
44
+ current = (current as { cause?: unknown }).cause;
45
+ }
46
+ return chain;
47
+ }
48
+
49
+ /** True when the error (or a cause) has a SQLite code starting with `prefix`. */
50
+ function hasSqliteCode(error: unknown, prefix: string): boolean {
51
+ return errorChain(error).some((link) => {
52
+ const code = (link as { code?: unknown }).code;
53
+ return typeof code === "string" && code.startsWith(prefix);
54
+ });
55
+ }
56
+
57
+ /** SQLITE_BUSY (or an extended BUSY code) anywhere in the cause chain. */
58
+ export function isSqliteBusyError(error: unknown): boolean {
59
+ return hasSqliteCode(error, "SQLITE_BUSY");
60
+ }
61
+
62
+ function isSqliteReadOnlyError(error: unknown): boolean {
63
+ return hasSqliteCode(error, "SQLITE_READONLY");
64
+ }
65
+
66
+ function isDuplicateColumnError(error: unknown): boolean {
67
+ return errorChain(error).some(
68
+ (link) => link instanceof Error && /duplicate column name/i.test(link.message),
69
+ );
70
+ }
71
+
72
+ function sleep(ms: number): Promise<void> {
73
+ return new Promise((resolve) => setTimeout(resolve, ms));
74
+ }
75
+
76
+ function tableColumns(db: KernelDatabase, table: string): string[] {
77
+ return db
78
+ .all<{ name: string }>(sql`PRAGMA table_info(${sql.identifier(table)})`)
79
+ .map((column) => column.name);
80
+ }
81
+
82
+ /**
83
+ * True when pi_agent_sessions has `kind`. Safe on read-only handles. Caches
84
+ * only a POSITIVE result per handle; a negative result re-probes
85
+ * `PRAGMA table_info` on every call (microseconds), so a long-lived read-only
86
+ * handle sees the column as soon as another process upgrades the database.
87
+ */
88
+ export function hasSessionKindColumn(db: KernelDatabase): boolean {
89
+ if (sessionKindColumnKnown.has(db)) return true;
90
+ const present = tableColumns(db, "pi_agent_sessions").includes("kind");
91
+ if (present) sessionKindColumnKnown.set(db, true);
92
+ return present;
93
+ }
94
+
95
+ /**
96
+ * Bring an existing kernel database up to the current schema. Idempotent;
97
+ * a duplicate-column error from a concurrent upgrader counts as applied,
98
+ * SQLITE_BUSY is retried, and a read-only handle is reported (not thrown).
99
+ * Any other failure throws.
100
+ */
101
+ export async function upgradeKernelObservabilitySchema(
102
+ db: KernelDatabase,
103
+ ): Promise<SchemaUpgradeReport> {
104
+ const report: SchemaUpgradeReport = { applied: [], skipped: [], readOnly: false };
105
+
106
+ const columns = tableColumns(db, "pi_agent_sessions");
107
+ // No table yet: the bootstrap CREATE (which already has `kind`) makes it.
108
+ if (columns.length === 0 || columns.includes("kind")) {
109
+ if (columns.length > 0) sessionKindColumnKnown.set(db, true);
110
+ report.skipped.push(SESSION_KIND_STEP);
111
+ return report;
112
+ }
113
+
114
+ for (let attempt = 0; ; attempt++) {
115
+ try {
116
+ db.run(
117
+ sql`ALTER TABLE pi_agent_sessions ADD COLUMN kind TEXT NOT NULL DEFAULT 'pi'`,
118
+ );
119
+ break;
120
+ } catch (error) {
121
+ if (isDuplicateColumnError(error)) break;
122
+ if (isSqliteReadOnlyError(error)) {
123
+ report.readOnly = true;
124
+ report.skipped.push(SESSION_KIND_STEP);
125
+ return report;
126
+ }
127
+ if (isSqliteBusyError(error) && attempt < BUSY_RETRIES) {
128
+ await sleep(BUSY_RETRY_DELAY_MS);
129
+ continue;
130
+ }
131
+ throw error;
132
+ }
133
+ }
134
+
135
+ sessionKindColumnKnown.set(db, true);
136
+ report.applied.push(SESSION_KIND_STEP);
137
+ return report;
138
+ }