/ src / lib / server / execution-log.ts
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  }