cli4ai 1.2.5 → 1.2.7

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.
Files changed (35) hide show
  1. package/dist/cli.js +31 -0
  2. package/dist/commands/add.js +5 -1
  3. package/dist/commands/browse.js +65 -52
  4. package/dist/commands/download.d.ts +7 -0
  5. package/dist/commands/download.js +36 -0
  6. package/dist/commands/routines.js +7 -3
  7. package/dist/commands/secrets.js +37 -24
  8. package/dist/commands/serve.d.ts +4 -0
  9. package/dist/commands/serve.js +19 -1
  10. package/dist/core/execute.d.ts +4 -0
  11. package/dist/core/execute.js +37 -12
  12. package/dist/core/remote-client.d.ts +8 -0
  13. package/dist/core/remote-client.js +67 -0
  14. package/dist/core/routine-engine.d.ts +90 -6
  15. package/dist/core/routine-engine.js +766 -221
  16. package/dist/core/routines.d.ts +5 -0
  17. package/dist/core/routines.js +20 -0
  18. package/dist/core/scheduler.d.ts +19 -0
  19. package/dist/core/scheduler.js +79 -1
  20. package/dist/dashboard/api/endpoints.d.ts +14 -0
  21. package/dist/dashboard/api/endpoints.js +562 -0
  22. package/dist/dashboard/api/websocket.d.ts +133 -0
  23. package/dist/dashboard/api/websocket.js +278 -0
  24. package/dist/dashboard/db/index.d.ts +42 -0
  25. package/dist/dashboard/db/index.js +112 -0
  26. package/dist/dashboard/db/runs.d.ts +170 -0
  27. package/dist/dashboard/db/runs.js +475 -0
  28. package/dist/dashboard/db/schema.d.ts +64 -0
  29. package/dist/dashboard/db/schema.js +157 -0
  30. package/dist/server/service.d.ts +8 -0
  31. package/dist/server/service.js +192 -6
  32. package/package.json +13 -3
  33. package/src/dashboard/public/assets/index-DN1hIAMO.css +1 -0
  34. package/src/dashboard/public/assets/index-pZeAAQwj.js +331 -0
  35. package/src/dashboard/public/index.html +14 -0
@@ -0,0 +1,475 @@
1
+ /**
2
+ * Run History Database Operations
3
+ *
4
+ * CRUD operations for runs, steps, and logs.
5
+ */
6
+ import { randomUUID } from 'crypto';
7
+ import { getDatabase } from './index.js';
8
+ // ═══════════════════════════════════════════════════════════════════════════
9
+ // RUN OPERATIONS
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+ /**
12
+ * Create a new run record
13
+ */
14
+ export function createRun(input) {
15
+ try {
16
+ const db = getDatabase();
17
+ const id = randomUUID();
18
+ const now = new Date().toISOString();
19
+ const stmt = db.prepare(`
20
+ INSERT INTO runs (
21
+ id, routine_name, routine_path, started_at, status,
22
+ trigger_type, retry_attempt, vars_json, created_at
23
+ ) VALUES (?, ?, ?, ?, 'running', ?, ?, ?, ?)
24
+ `);
25
+ stmt.run(id, input.routineName, input.routinePath ?? null, now, input.triggerType, input.retryAttempt ?? 0, input.vars ? JSON.stringify(input.vars) : null, now);
26
+ return getRun(id);
27
+ }
28
+ catch (error) {
29
+ throw new Error(`Failed to create run: ${error instanceof Error ? error.message : String(error)}`);
30
+ }
31
+ }
32
+ /**
33
+ * Get a run by ID
34
+ */
35
+ export function getRun(id) {
36
+ const db = getDatabase();
37
+ const stmt = db.prepare('SELECT * FROM runs WHERE id = ?');
38
+ return stmt.get(id) ?? null;
39
+ }
40
+ /**
41
+ * Get a run with all its steps and logs
42
+ */
43
+ export function getRunWithDetails(id) {
44
+ const run = getRun(id);
45
+ if (!run)
46
+ return null;
47
+ const steps = getRunSteps(id);
48
+ const logs = getRunLogs(id, { limit: 5000 }); // Include up to 5000 log lines
49
+ const logCount = logs.length;
50
+ return {
51
+ ...run,
52
+ steps,
53
+ logs,
54
+ logCount,
55
+ };
56
+ }
57
+ /**
58
+ * Update a run record
59
+ */
60
+ export function updateRun(id, input) {
61
+ try {
62
+ const db = getDatabase();
63
+ const updates = [];
64
+ const values = [];
65
+ if (input.status !== undefined) {
66
+ updates.push('status = ?');
67
+ values.push(input.status);
68
+ }
69
+ if (input.exitCode !== undefined) {
70
+ updates.push('exit_code = ?');
71
+ values.push(input.exitCode);
72
+ }
73
+ if (input.finishedAt !== undefined) {
74
+ updates.push('finished_at = ?');
75
+ values.push(input.finishedAt);
76
+ }
77
+ if (input.durationMs !== undefined) {
78
+ updates.push('duration_ms = ?');
79
+ values.push(input.durationMs);
80
+ }
81
+ if (updates.length === 0) {
82
+ return getRun(id);
83
+ }
84
+ values.push(id);
85
+ const stmt = db.prepare(`UPDATE runs SET ${updates.join(', ')} WHERE id = ?`);
86
+ stmt.run(...values);
87
+ return getRun(id);
88
+ }
89
+ catch (error) {
90
+ throw new Error(`Failed to update run ${id}: ${error instanceof Error ? error.message : String(error)}`);
91
+ }
92
+ }
93
+ /**
94
+ * Finish a run (set status, exit code, timing)
95
+ */
96
+ export function finishRun(id, status, exitCode) {
97
+ const run = getRun(id);
98
+ if (!run)
99
+ return null;
100
+ const finishedAt = new Date().toISOString();
101
+ const startedAt = new Date(run.started_at).getTime();
102
+ const durationMs = Date.now() - startedAt;
103
+ return updateRun(id, {
104
+ status,
105
+ exitCode,
106
+ finishedAt,
107
+ durationMs,
108
+ });
109
+ }
110
+ /**
111
+ * List runs with optional filters
112
+ */
113
+ export function listRuns(filters = {}) {
114
+ const db = getDatabase();
115
+ const conditions = [];
116
+ const values = [];
117
+ if (filters.routineName) {
118
+ conditions.push('routine_name = ?');
119
+ values.push(filters.routineName);
120
+ }
121
+ if (filters.status) {
122
+ conditions.push('status = ?');
123
+ values.push(filters.status);
124
+ }
125
+ if (filters.triggerType) {
126
+ conditions.push('trigger_type = ?');
127
+ values.push(filters.triggerType);
128
+ }
129
+ if (filters.startedAfter) {
130
+ conditions.push('started_at >= ?');
131
+ values.push(filters.startedAfter);
132
+ }
133
+ if (filters.startedBefore) {
134
+ conditions.push('started_at <= ?');
135
+ values.push(filters.startedBefore);
136
+ }
137
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
138
+ const limit = filters.limit ?? 50;
139
+ const offset = filters.offset ?? 0;
140
+ const stmt = db.prepare(`
141
+ SELECT * FROM runs
142
+ ${where}
143
+ ORDER BY started_at DESC
144
+ LIMIT ? OFFSET ?
145
+ `);
146
+ values.push(limit, offset);
147
+ return stmt.all(...values);
148
+ }
149
+ /**
150
+ * Count runs matching filters
151
+ */
152
+ export function countRuns(filters = {}) {
153
+ const db = getDatabase();
154
+ const conditions = [];
155
+ const values = [];
156
+ if (filters.routineName) {
157
+ conditions.push('routine_name = ?');
158
+ values.push(filters.routineName);
159
+ }
160
+ if (filters.status) {
161
+ conditions.push('status = ?');
162
+ values.push(filters.status);
163
+ }
164
+ if (filters.triggerType) {
165
+ conditions.push('trigger_type = ?');
166
+ values.push(filters.triggerType);
167
+ }
168
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
169
+ const stmt = db.prepare(`SELECT COUNT(*) as count FROM runs ${where}`);
170
+ const result = stmt.get(...values);
171
+ return result.count;
172
+ }
173
+ /**
174
+ * Delete a run and all its associated data
175
+ */
176
+ export function deleteRun(id) {
177
+ const db = getDatabase();
178
+ const stmt = db.prepare('DELETE FROM runs WHERE id = ?');
179
+ const result = stmt.run(id);
180
+ return result.changes > 0;
181
+ }
182
+ /**
183
+ * Delete old runs (for cleanup)
184
+ */
185
+ export function deleteOldRuns(olderThanDays) {
186
+ const db = getDatabase();
187
+ const cutoff = new Date();
188
+ cutoff.setDate(cutoff.getDate() - olderThanDays);
189
+ const stmt = db.prepare('DELETE FROM runs WHERE started_at < ?');
190
+ const result = stmt.run(cutoff.toISOString());
191
+ return result.changes;
192
+ }
193
+ // ═══════════════════════════════════════════════════════════════════════════
194
+ // STEP OPERATIONS
195
+ // ═══════════════════════════════════════════════════════════════════════════
196
+ /**
197
+ * Create a step record
198
+ */
199
+ export function createStep(input) {
200
+ try {
201
+ const db = getDatabase();
202
+ const id = randomUUID();
203
+ const now = new Date().toISOString();
204
+ const stmt = db.prepare(`
205
+ INSERT INTO run_steps (
206
+ id, run_id, step_id, step_type, status, exit_code, duration_ms,
207
+ stdout, stderr, json_output, error_code, error_message, created_at
208
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
209
+ `);
210
+ stmt.run(id, input.runId, input.stepId, input.stepType, input.status, input.exitCode ?? null, input.durationMs ?? null, input.stdout ?? null, input.stderr ?? null, input.jsonOutput ? JSON.stringify(input.jsonOutput) : null, input.errorCode ?? null, input.errorMessage ?? null, now);
211
+ return getStep(id);
212
+ }
213
+ catch (error) {
214
+ throw new Error(`Failed to create step ${input.stepId} for run ${input.runId}: ${error instanceof Error ? error.message : String(error)}`);
215
+ }
216
+ }
217
+ /**
218
+ * Get a step by ID
219
+ */
220
+ export function getStep(id) {
221
+ const db = getDatabase();
222
+ const stmt = db.prepare('SELECT * FROM run_steps WHERE id = ?');
223
+ return stmt.get(id) ?? null;
224
+ }
225
+ /**
226
+ * Get all steps for a run
227
+ */
228
+ export function getRunSteps(runId) {
229
+ const db = getDatabase();
230
+ const stmt = db.prepare('SELECT * FROM run_steps WHERE run_id = ? ORDER BY created_at');
231
+ return stmt.all(runId);
232
+ }
233
+ /**
234
+ * Find a step by runId and stepId
235
+ */
236
+ export function findStepByRunAndStepId(runId, stepId) {
237
+ const db = getDatabase();
238
+ const stmt = db.prepare('SELECT * FROM run_steps WHERE run_id = ? AND step_id = ?');
239
+ return stmt.get(runId, stepId) ?? null;
240
+ }
241
+ /**
242
+ * Upsert a step (create or update)
243
+ */
244
+ export function upsertStep(input) {
245
+ const existing = findStepByRunAndStepId(input.runId, input.stepId);
246
+ if (existing) {
247
+ // Update existing step
248
+ return updateStep(existing.id, {
249
+ status: input.status,
250
+ exitCode: input.exitCode,
251
+ durationMs: input.durationMs,
252
+ stdout: input.stdout,
253
+ stderr: input.stderr,
254
+ jsonOutput: input.jsonOutput,
255
+ errorCode: input.errorCode,
256
+ errorMessage: input.errorMessage,
257
+ });
258
+ }
259
+ else {
260
+ // Create new step
261
+ return createStep(input);
262
+ }
263
+ }
264
+ /**
265
+ * Update a step
266
+ */
267
+ export function updateStep(id, updates) {
268
+ const db = getDatabase();
269
+ const fields = [];
270
+ const values = [];
271
+ if (updates.status !== undefined) {
272
+ fields.push('status = ?');
273
+ values.push(updates.status);
274
+ }
275
+ if (updates.exitCode !== undefined) {
276
+ fields.push('exit_code = ?');
277
+ values.push(updates.exitCode);
278
+ }
279
+ if (updates.durationMs !== undefined) {
280
+ fields.push('duration_ms = ?');
281
+ values.push(updates.durationMs);
282
+ }
283
+ if (updates.stdout !== undefined) {
284
+ fields.push('stdout = ?');
285
+ values.push(updates.stdout);
286
+ }
287
+ if (updates.stderr !== undefined) {
288
+ fields.push('stderr = ?');
289
+ values.push(updates.stderr);
290
+ }
291
+ if (updates.jsonOutput !== undefined) {
292
+ fields.push('json_output = ?');
293
+ values.push(JSON.stringify(updates.jsonOutput));
294
+ }
295
+ if (updates.errorCode !== undefined) {
296
+ fields.push('error_code = ?');
297
+ values.push(updates.errorCode);
298
+ }
299
+ if (updates.errorMessage !== undefined) {
300
+ fields.push('error_message = ?');
301
+ values.push(updates.errorMessage);
302
+ }
303
+ if (fields.length === 0)
304
+ return getStep(id);
305
+ values.push(id);
306
+ const stmt = db.prepare(`UPDATE run_steps SET ${fields.join(', ')} WHERE id = ?`);
307
+ stmt.run(...values);
308
+ return getStep(id);
309
+ }
310
+ // ═══════════════════════════════════════════════════════════════════════════
311
+ // LOG OPERATIONS
312
+ // ═══════════════════════════════════════════════════════════════════════════
313
+ /**
314
+ * Append a log line
315
+ */
316
+ export function appendLog(input) {
317
+ const db = getDatabase();
318
+ const now = new Date().toISOString();
319
+ const stmt = db.prepare(`
320
+ INSERT INTO run_logs (run_id, timestamp, stream, line, step_id)
321
+ VALUES (?, ?, ?, ?, ?)
322
+ `);
323
+ stmt.run(input.runId, now, input.stream, input.line, input.stepId ?? null);
324
+ }
325
+ /**
326
+ * Append multiple log lines efficiently
327
+ */
328
+ export function appendLogs(logs) {
329
+ const db = getDatabase();
330
+ const now = new Date().toISOString();
331
+ const stmt = db.prepare(`
332
+ INSERT INTO run_logs (run_id, timestamp, stream, line, step_id)
333
+ VALUES (?, ?, ?, ?, ?)
334
+ `);
335
+ const insertMany = db.transaction((logs) => {
336
+ for (const log of logs) {
337
+ stmt.run(log.runId, now, log.stream, log.line, log.stepId ?? null);
338
+ }
339
+ });
340
+ insertMany(logs);
341
+ }
342
+ /**
343
+ * Get logs for a run
344
+ */
345
+ export function getRunLogs(runId, options = {}) {
346
+ const db = getDatabase();
347
+ const { limit = 1000, offset = 0, stream } = options;
348
+ const conditions = ['run_id = ?'];
349
+ const values = [runId];
350
+ if (stream) {
351
+ conditions.push('stream = ?');
352
+ values.push(stream);
353
+ }
354
+ values.push(limit, offset);
355
+ const stmt = db.prepare(`
356
+ SELECT * FROM run_logs
357
+ WHERE ${conditions.join(' AND ')}
358
+ ORDER BY id ASC
359
+ LIMIT ? OFFSET ?
360
+ `);
361
+ return stmt.all(...values);
362
+ }
363
+ /**
364
+ * Get log count for a run
365
+ */
366
+ export function getRunLogCount(runId) {
367
+ const db = getDatabase();
368
+ const stmt = db.prepare('SELECT COUNT(*) as count FROM run_logs WHERE run_id = ?');
369
+ const result = stmt.get(runId);
370
+ return result.count;
371
+ }
372
+ /**
373
+ * Get the last N logs for a run (for real-time tailing)
374
+ */
375
+ export function getRecentLogs(runId, count = 100) {
376
+ const db = getDatabase();
377
+ const stmt = db.prepare(`
378
+ SELECT * FROM (
379
+ SELECT * FROM run_logs
380
+ WHERE run_id = ?
381
+ ORDER BY id DESC
382
+ LIMIT ?
383
+ ) ORDER BY id ASC
384
+ `);
385
+ return stmt.all(runId, count);
386
+ }
387
+ /**
388
+ * Get metrics for a routine
389
+ */
390
+ export function getRoutineMetrics(routineName) {
391
+ const db = getDatabase();
392
+ const statsStmt = db.prepare(`
393
+ SELECT
394
+ COUNT(*) as total_runs,
395
+ SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successful_runs,
396
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed_runs,
397
+ AVG(duration_ms) as avg_duration_ms,
398
+ MIN(duration_ms) as min_duration_ms,
399
+ MAX(duration_ms) as max_duration_ms
400
+ FROM runs
401
+ WHERE routine_name = ? AND status != 'running'
402
+ `);
403
+ const stats = statsStmt.get(routineName);
404
+ const lastRunStmt = db.prepare(`
405
+ SELECT started_at, status FROM runs
406
+ WHERE routine_name = ?
407
+ ORDER BY started_at DESC
408
+ LIMIT 1
409
+ `);
410
+ const lastRun = lastRunStmt.get(routineName);
411
+ return {
412
+ routineName,
413
+ totalRuns: stats.total_runs,
414
+ successfulRuns: stats.successful_runs,
415
+ failedRuns: stats.failed_runs,
416
+ successRate: stats.total_runs > 0 ? stats.successful_runs / stats.total_runs : 0,
417
+ avgDurationMs: stats.avg_duration_ms,
418
+ minDurationMs: stats.min_duration_ms,
419
+ maxDurationMs: stats.max_duration_ms,
420
+ lastRunAt: lastRun?.started_at ?? null,
421
+ lastStatus: lastRun?.status ?? null,
422
+ };
423
+ }
424
+ /**
425
+ * Get metrics for all routines
426
+ */
427
+ export function getAllRoutineMetrics() {
428
+ const db = getDatabase();
429
+ const routinesStmt = db.prepare(`
430
+ SELECT DISTINCT routine_name FROM runs ORDER BY routine_name
431
+ `);
432
+ const routines = routinesStmt.all();
433
+ return routines.map((r) => getRoutineMetrics(r.routine_name));
434
+ }
435
+ export function getDashboardStats() {
436
+ const db = getDatabase();
437
+ const today = new Date();
438
+ today.setHours(0, 0, 0, 0);
439
+ const todayStr = today.toISOString();
440
+ const weekAgo = new Date();
441
+ weekAgo.setDate(weekAgo.getDate() - 7);
442
+ weekAgo.setHours(0, 0, 0, 0);
443
+ const weekAgoStr = weekAgo.toISOString();
444
+ const stats = db
445
+ .prepare(`
446
+ SELECT
447
+ COUNT(*) as total_runs,
448
+ SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) as running_count,
449
+ SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success_count,
450
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failure_count,
451
+ AVG(CASE WHEN duration_ms IS NOT NULL THEN duration_ms ELSE NULL END) as avg_duration_ms
452
+ FROM runs
453
+ `)
454
+ .get();
455
+ const todayStats = db
456
+ .prepare(`SELECT COUNT(*) as runs_today FROM runs WHERE started_at >= ?`)
457
+ .get(todayStr);
458
+ const weekStats = db
459
+ .prepare(`SELECT COUNT(*) as runs_this_week FROM runs WHERE started_at >= ?`)
460
+ .get(weekAgoStr);
461
+ const totalRuns = stats?.total_runs || 0;
462
+ const successCount = stats?.success_count || 0;
463
+ const failureCount = stats?.failure_count || 0;
464
+ const runningCount = stats?.running_count || 0;
465
+ return {
466
+ totalRuns,
467
+ successCount,
468
+ failureCount,
469
+ runningCount,
470
+ successRate: totalRuns > 0 ? (successCount / totalRuns) * 100 : 0,
471
+ avgDurationMs: stats?.avg_duration_ms || 0,
472
+ runsToday: todayStats?.runs_today || 0,
473
+ runsThisWeek: weekStats?.runs_this_week || 0,
474
+ };
475
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Dashboard Database Schema
3
+ *
4
+ * Defines all tables for storing run history, steps, logs, and metrics.
5
+ */
6
+ import type Database from 'better-sqlite3';
7
+ export type RunStatus = 'running' | 'success' | 'failed';
8
+ export type StepStatus = 'success' | 'failed' | 'skipped' | 'caught' | 'running';
9
+ export type TriggerType = 'scheduled' | 'manual' | 'api';
10
+ export type LogStream = 'stdout' | 'stderr';
11
+ export interface RunRecord {
12
+ id: string;
13
+ routine_name: string;
14
+ routine_path: string | null;
15
+ started_at: string;
16
+ finished_at: string | null;
17
+ status: RunStatus;
18
+ exit_code: number | null;
19
+ duration_ms: number | null;
20
+ trigger_type: TriggerType;
21
+ retry_attempt: number;
22
+ vars_json: string | null;
23
+ created_at: string;
24
+ }
25
+ export interface RunStepRecord {
26
+ id: string;
27
+ run_id: string;
28
+ step_id: string;
29
+ step_type: string;
30
+ status: StepStatus;
31
+ exit_code: number | null;
32
+ duration_ms: number | null;
33
+ stdout: string | null;
34
+ stderr: string | null;
35
+ json_output: string | null;
36
+ error_code: string | null;
37
+ error_message: string | null;
38
+ created_at: string;
39
+ }
40
+ export interface RunLogRecord {
41
+ id: number;
42
+ run_id: string;
43
+ timestamp: string;
44
+ stream: LogStream;
45
+ line: string;
46
+ step_id: string | null;
47
+ }
48
+ export interface MetricRecord {
49
+ id: number;
50
+ routine_name: string;
51
+ period: 'hour' | 'day' | 'week';
52
+ period_start: string;
53
+ total_runs: number;
54
+ successful_runs: number;
55
+ failed_runs: number;
56
+ avg_duration_ms: number | null;
57
+ min_duration_ms: number | null;
58
+ max_duration_ms: number | null;
59
+ }
60
+ /**
61
+ * Initialize the database schema.
62
+ * Creates all tables if they don't exist.
63
+ */
64
+ export declare function initSchema(db: Database.Database): void;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Dashboard Database Schema
3
+ *
4
+ * Defines all tables for storing run history, steps, logs, and metrics.
5
+ */
6
+ // ═══════════════════════════════════════════════════════════════════════════
7
+ // SCHEMA VERSION
8
+ // ═══════════════════════════════════════════════════════════════════════════
9
+ const SCHEMA_VERSION = 1;
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+ // SCHEMA INITIALIZATION
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+ /**
14
+ * Initialize the database schema.
15
+ * Creates all tables if they don't exist.
16
+ */
17
+ export function initSchema(db) {
18
+ // Check if we need to migrate
19
+ const version = getSchemaVersion(db);
20
+ if (version === 0) {
21
+ // Fresh install - create all tables
22
+ createTables(db);
23
+ setSchemaVersion(db, SCHEMA_VERSION);
24
+ }
25
+ else if (version < SCHEMA_VERSION) {
26
+ // Run migrations
27
+ migrate(db, version, SCHEMA_VERSION);
28
+ setSchemaVersion(db, SCHEMA_VERSION);
29
+ }
30
+ }
31
+ /**
32
+ * Get current schema version
33
+ */
34
+ function getSchemaVersion(db) {
35
+ try {
36
+ const result = db.prepare('SELECT version FROM schema_version').get();
37
+ return result?.version ?? 0;
38
+ }
39
+ catch {
40
+ // Table doesn't exist
41
+ return 0;
42
+ }
43
+ }
44
+ /**
45
+ * Set schema version
46
+ */
47
+ function setSchemaVersion(db, version) {
48
+ db.exec(`
49
+ CREATE TABLE IF NOT EXISTS schema_version (
50
+ version INTEGER NOT NULL
51
+ );
52
+ DELETE FROM schema_version;
53
+ `);
54
+ db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(version);
55
+ }
56
+ /**
57
+ * Create all tables
58
+ */
59
+ function createTables(db) {
60
+ db.exec(`
61
+ -- ═══════════════════════════════════════════════════════════════════════
62
+ -- RUNS TABLE
63
+ -- ═══════════════════════════════════════════════════════════════════════
64
+ CREATE TABLE IF NOT EXISTS runs (
65
+ id TEXT PRIMARY KEY,
66
+ routine_name TEXT NOT NULL,
67
+ routine_path TEXT,
68
+ started_at TEXT NOT NULL,
69
+ finished_at TEXT,
70
+ status TEXT NOT NULL CHECK (status IN ('running', 'success', 'failed')),
71
+ exit_code INTEGER,
72
+ duration_ms INTEGER,
73
+ trigger_type TEXT NOT NULL CHECK (trigger_type IN ('scheduled', 'manual', 'api')),
74
+ retry_attempt INTEGER DEFAULT 0,
75
+ vars_json TEXT,
76
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
77
+ );
78
+
79
+ CREATE INDEX IF NOT EXISTS idx_runs_routine_name ON runs(routine_name);
80
+ CREATE INDEX IF NOT EXISTS idx_runs_status ON runs(status);
81
+ CREATE INDEX IF NOT EXISTS idx_runs_started_at ON runs(started_at DESC);
82
+ CREATE INDEX IF NOT EXISTS idx_runs_trigger_type ON runs(trigger_type);
83
+ CREATE INDEX IF NOT EXISTS idx_runs_routine_status ON runs(routine_name, status);
84
+
85
+ -- ═══════════════════════════════════════════════════════════════════════
86
+ -- RUN STEPS TABLE
87
+ -- ═══════════════════════════════════════════════════════════════════════
88
+ CREATE TABLE IF NOT EXISTS run_steps (
89
+ id TEXT PRIMARY KEY,
90
+ run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
91
+ step_id TEXT NOT NULL,
92
+ step_type TEXT NOT NULL,
93
+ status TEXT NOT NULL CHECK (status IN ('success', 'failed', 'skipped', 'caught', 'running')),
94
+ exit_code INTEGER,
95
+ duration_ms INTEGER,
96
+ stdout TEXT,
97
+ stderr TEXT,
98
+ json_output TEXT,
99
+ error_code TEXT,
100
+ error_message TEXT,
101
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
102
+ );
103
+
104
+ CREATE INDEX IF NOT EXISTS idx_run_steps_run_id ON run_steps(run_id);
105
+
106
+ -- ═══════════════════════════════════════════════════════════════════════
107
+ -- RUN LOGS TABLE (for streaming logs)
108
+ -- ═══════════════════════════════════════════════════════════════════════
109
+ CREATE TABLE IF NOT EXISTS run_logs (
110
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
111
+ run_id TEXT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
112
+ timestamp TEXT NOT NULL,
113
+ stream TEXT NOT NULL CHECK (stream IN ('stdout', 'stderr')),
114
+ line TEXT NOT NULL,
115
+ step_id TEXT
116
+ );
117
+
118
+ CREATE INDEX IF NOT EXISTS idx_run_logs_run_id ON run_logs(run_id);
119
+ CREATE INDEX IF NOT EXISTS idx_run_logs_timestamp ON run_logs(timestamp);
120
+ CREATE INDEX IF NOT EXISTS idx_run_logs_run_id_stream ON run_logs(run_id, stream);
121
+
122
+ -- ═══════════════════════════════════════════════════════════════════════
123
+ -- METRICS TABLE (aggregated stats)
124
+ -- ═══════════════════════════════════════════════════════════════════════
125
+ CREATE TABLE IF NOT EXISTS metrics (
126
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
127
+ routine_name TEXT NOT NULL,
128
+ period TEXT NOT NULL CHECK (period IN ('hour', 'day', 'week')),
129
+ period_start TEXT NOT NULL,
130
+ total_runs INTEGER DEFAULT 0,
131
+ successful_runs INTEGER DEFAULT 0,
132
+ failed_runs INTEGER DEFAULT 0,
133
+ avg_duration_ms REAL,
134
+ min_duration_ms INTEGER,
135
+ max_duration_ms INTEGER,
136
+ UNIQUE(routine_name, period, period_start)
137
+ );
138
+
139
+ CREATE INDEX IF NOT EXISTS idx_metrics_routine ON metrics(routine_name);
140
+ CREATE INDEX IF NOT EXISTS idx_metrics_period ON metrics(period, period_start);
141
+ `);
142
+ }
143
+ /**
144
+ * Run migrations between versions
145
+ */
146
+ function migrate(db, fromVersion, toVersion) {
147
+ // Future migrations go here
148
+ // Example:
149
+ // if (fromVersion < 2 && toVersion >= 2) {
150
+ // db.exec('ALTER TABLE runs ADD COLUMN new_field TEXT');
151
+ // }
152
+ // For now, just recreate tables if version mismatch
153
+ // In production, this would be proper migrations
154
+ if (fromVersion < toVersion) {
155
+ createTables(db);
156
+ }
157
+ }