execution-log.ts
1 import fs from 'fs' 2 import path from 'path' 3 import Database from 'better-sqlite3' 4 import { genId } from '@/lib/id' 5 6 // --------------------------------------------------------------------------- 7 // Types 8 // --------------------------------------------------------------------------- 9 10 export type LogCategory = 11 | 'trigger' // what kicked off the action 12 | 'decision' // reasoning / model choice 13 | 'tool_call' // tool invocation with input 14 | 'tool_result' // tool output 15 | 'outbound' // messages sent to users/platforms 16 | 'file_op' // file read/write/delete with checksums 17 | 'commit' // git commit activity 18 | 'error' // errors during execution 19 | 'mission_start' // new mission/goal started 20 | 'mission_checkpoint' // periodic mission state snapshot 21 | 'mission_complete' // mission reached ok status 22 | 'budget_warning' // mission approaching or exceeding budget 23 | 'loop_detection' // repeated tool call pattern detected 24 | 'peer_query' 25 | 'delegation_start' 26 | 'delegation_complete' 27 | 'delegation_fail' 28 | 'swarm_spawn' 29 | 'swarm_complete' 30 | 'chatroom_message' 31 | 'supervisor_incident' 32 | 'heartbeat_failure' 33 | 'coordination' 34 35 export interface ExecutionLogEntry { 36 id: string 37 sessionId: string 38 runId: string | null 39 agentId: string | null 40 category: LogCategory 41 summary: string 42 detail: Record<string, unknown> | null 43 ts: number 44 } 45 46 export interface LogQueryOpts { 47 sessionId?: string 48 agentId?: string 49 runId?: string 50 category?: LogCategory 51 since?: number 52 until?: number 53 limit?: number 54 offset?: number 55 search?: string 56 } 57 58 // --------------------------------------------------------------------------- 59 // Database setup 60 // --------------------------------------------------------------------------- 61 62 import { DATA_DIR } from './data-dir' 63 if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }) 64 65 const DB_PATH = path.join(DATA_DIR, 'logs.db') 66 67 let _db: Database.Database | null = null 68 69 function getDb(): Database.Database { 70 if (_db) return _db 71 _db = new Database(DB_PATH) 72 _db.pragma('journal_mode = WAL') 73 _db.pragma('busy_timeout = 5000') 74 _db.exec(` 75 CREATE TABLE IF NOT EXISTS execution_logs ( 76 id TEXT PRIMARY KEY, 77 session_id TEXT NOT NULL, 78 run_id TEXT, 79 agent_id TEXT, 80 category TEXT NOT NULL, 81 summary TEXT NOT NULL, 82 detail TEXT, 83 ts INTEGER NOT NULL 84 ) 85 `) 86 _db.exec(`CREATE INDEX IF NOT EXISTS idx_logs_session ON execution_logs(session_id, ts)`) 87 _db.exec(`CREATE INDEX IF NOT EXISTS idx_logs_agent ON execution_logs(agent_id, ts)`) 88 _db.exec(`CREATE INDEX IF NOT EXISTS idx_logs_run ON execution_logs(run_id)`) 89 _db.exec(`CREATE INDEX IF NOT EXISTS idx_logs_cat ON execution_logs(category)`) 90 return _db 91 } 92 93 // --------------------------------------------------------------------------- 94 // Write 95 // --------------------------------------------------------------------------- 96 97 const insertStmt = () => 98 getDb().prepare( 99 `INSERT INTO execution_logs (id, session_id, run_id, agent_id, category, summary, detail, ts) 100 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 101 ) 102 103 export function logExecution( 104 sessionId: string, 105 category: LogCategory, 106 summary: string, 107 opts?: { 108 runId?: string | null 109 agentId?: string | null 110 detail?: Record<string, unknown> 111 }, 112 ): string { 113 const id = genId(8) 114 const ts = Date.now() 115 try { 116 insertStmt().run( 117 id, 118 sessionId, 119 opts?.runId ?? null, 120 opts?.agentId ?? null, 121 category, 122 summary, 123 opts?.detail ? JSON.stringify(opts.detail) : null, 124 ts, 125 ) 126 } catch { 127 // Non-critical — never block agent execution for logging failures 128 } 129 return id 130 } 131 132 // Batch insert for bulk writes (e.g. file ops) 133 export function logExecutionBatch( 134 entries: Array<{ 135 sessionId: string 136 category: LogCategory 137 summary: string 138 runId?: string | null 139 agentId?: string | null 140 detail?: Record<string, unknown> 141 }>, 142 ): void { 143 const db = getDb() 144 const stmt = db.prepare( 145 `INSERT INTO execution_logs (id, session_id, run_id, agent_id, category, summary, detail, ts) 146 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 147 ) 148 const ts = Date.now() 149 const tx = db.transaction(() => { 150 for (const e of entries) { 151 stmt.run( 152 genId(8), 153 e.sessionId, 154 e.runId ?? null, 155 e.agentId ?? null, 156 e.category, 157 e.summary, 158 e.detail ? JSON.stringify(e.detail) : null, 159 ts, 160 ) 161 } 162 }) 163 try { tx() } catch { /* non-critical */ } 164 } 165 166 // --------------------------------------------------------------------------- 167 // Read / Query 168 // --------------------------------------------------------------------------- 169 170 export function queryLogs(opts: LogQueryOpts): ExecutionLogEntry[] { 171 const conditions: string[] = [] 172 const params: unknown[] = [] 173 174 if (opts.sessionId) { 175 conditions.push('session_id = ?') 176 params.push(opts.sessionId) 177 } 178 if (opts.agentId) { 179 conditions.push('agent_id = ?') 180 params.push(opts.agentId) 181 } 182 if (opts.runId) { 183 conditions.push('run_id = ?') 184 params.push(opts.runId) 185 } 186 if (opts.category) { 187 conditions.push('category = ?') 188 params.push(opts.category) 189 } 190 if (opts.since) { 191 conditions.push('ts >= ?') 192 params.push(opts.since) 193 } 194 if (opts.until) { 195 conditions.push('ts <= ?') 196 params.push(opts.until) 197 } 198 if (opts.search) { 199 conditions.push('(summary LIKE ? OR detail LIKE ?)') 200 const pattern = `%${opts.search}%` 201 params.push(pattern, pattern) 202 } 203 204 const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' 205 const limit = opts.limit ?? 200 206 const offset = opts.offset ?? 0 207 208 const rows = getDb() 209 .prepare(`SELECT * FROM execution_logs ${where} ORDER BY ts DESC LIMIT ? OFFSET ?`) 210 .all(...params, limit, offset) as Array<{ 211 id: string 212 session_id: string 213 run_id: string | null 214 agent_id: string | null 215 category: string 216 summary: string 217 detail: string | null 218 ts: number 219 }> 220 221 return rows.map((r) => ({ 222 id: r.id, 223 sessionId: r.session_id, 224 runId: r.run_id, 225 agentId: r.agent_id, 226 category: r.category as LogCategory, 227 summary: r.summary, 228 detail: r.detail ? JSON.parse(r.detail) : null, 229 ts: r.ts, 230 })) 231 } 232 233 export function countLogs(opts: Omit<LogQueryOpts, 'limit' | 'offset'>): number { 234 const conditions: string[] = [] 235 const params: unknown[] = [] 236 237 if (opts.sessionId) { conditions.push('session_id = ?'); params.push(opts.sessionId) } 238 if (opts.agentId) { conditions.push('agent_id = ?'); params.push(opts.agentId) } 239 if (opts.runId) { conditions.push('run_id = ?'); params.push(opts.runId) } 240 if (opts.category) { conditions.push('category = ?'); params.push(opts.category) } 241 if (opts.since) { conditions.push('ts >= ?'); params.push(opts.since) } 242 if (opts.until) { conditions.push('ts <= ?'); params.push(opts.until) } 243 if (opts.search) { 244 conditions.push('(summary LIKE ? OR detail LIKE ?)') 245 const pattern = `%${opts.search}%` 246 params.push(pattern, pattern) 247 } 248 249 const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '' 250 const row = getDb() 251 .prepare(`SELECT COUNT(*) as cnt FROM execution_logs ${where}`) 252 .get(...params) as { cnt: number } 253 return row.cnt 254 } 255 256 // --------------------------------------------------------------------------- 257 // Clear 258 // --------------------------------------------------------------------------- 259 260 export function clearLogs(sessionId?: string): number { 261 if (sessionId) { 262 const result = getDb().prepare('DELETE FROM execution_logs WHERE session_id = ?').run(sessionId) 263 return result.changes 264 } 265 const result = getDb().prepare('DELETE FROM execution_logs').run() 266 return result.changes 267 } 268 269 export function clearLogsByAge(maxAgeMs: number): number { 270 const cutoff = Date.now() - maxAgeMs 271 const result = getDb().prepare('DELETE FROM execution_logs WHERE ts < ?').run(cutoff) 272 return result.changes 273 }