agentboss 0.1.0

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 (53) hide show
  1. package/README.md +34 -0
  2. package/bin/aboss.js +288 -0
  3. package/client/dist/assets/index-C1wFD_Vo.css +1 -0
  4. package/client/dist/assets/index-DBj1Ujlx.js +137 -0
  5. package/client/dist/index.html +34 -0
  6. package/package.json +64 -0
  7. package/server/analysis/daily-aggregator.js +258 -0
  8. package/server/analysis/difficulty.js +129 -0
  9. package/server/analysis/dimensions/ai-knowledge.js +172 -0
  10. package/server/analysis/dimensions/ai-tools.js +161 -0
  11. package/server/analysis/dimensions/judgement.js +107 -0
  12. package/server/analysis/dimensions/llm-merge.js +57 -0
  13. package/server/analysis/dimensions/output-quality.js +167 -0
  14. package/server/analysis/dimensions/problem-definition.js +104 -0
  15. package/server/analysis/dimensions/system-thinking.js +225 -0
  16. package/server/analysis/evidence-builder.js +104 -0
  17. package/server/analysis/job.js +273 -0
  18. package/server/analysis/report-builder.js +581 -0
  19. package/server/analysis/scoring-v2.js +72 -0
  20. package/server/analysis/text-signals.js +179 -0
  21. package/server/analysis/thresholds-v2.js +358 -0
  22. package/server/api/advice.js +124 -0
  23. package/server/api/analysis.js +141 -0
  24. package/server/api/execution.js +330 -0
  25. package/server/api/metrics.js +277 -0
  26. package/server/api/overview.js +308 -0
  27. package/server/api/project.js +255 -0
  28. package/server/api/reports.js +125 -0
  29. package/server/api/sessions.js +118 -0
  30. package/server/api/settings.js +119 -0
  31. package/server/db/connection.js +175 -0
  32. package/server/db/queries.js +1051 -0
  33. package/server/db/schema.js +487 -0
  34. package/server/etl/active-time.js +150 -0
  35. package/server/etl/backfill-subagents.js +178 -0
  36. package/server/etl/claude-code.js +826 -0
  37. package/server/etl/detect.js +341 -0
  38. package/server/etl/judge-filter.js +117 -0
  39. package/server/etl/opencode.js +606 -0
  40. package/server/execution/job.js +662 -0
  41. package/server/execution/prompt.js +227 -0
  42. package/server/execution/runner.js +218 -0
  43. package/server/index.js +94 -0
  44. package/server/llm/advice-prompt.js +339 -0
  45. package/server/llm/advice.js +384 -0
  46. package/server/llm/analysis-prompt.js +162 -0
  47. package/server/llm/cli-runner.js +249 -0
  48. package/server/llm/judge-prompts.js +179 -0
  49. package/server/llm/judge.js +118 -0
  50. package/server/llm/project-advice-prompt.js +332 -0
  51. package/server/llm/project-advice.js +491 -0
  52. package/server/llm/session-analyzer.js +122 -0
  53. package/server/utils/project.js +80 -0
@@ -0,0 +1,662 @@
1
+ /**
2
+ * Execution job orchestration.
3
+ *
4
+ * Coordinates the lifecycle of an advice-execution run:
5
+ * • parses advice_key into the right AdviceItem
6
+ * • enforces project-path validity + whitelist
7
+ * • enforces a single global running job
8
+ * • spawns the runner asynchronously and writes results back to
9
+ * execution_run row
10
+ * • exposes read / cancel / orphan-cleanup helpers for the API + boot path
11
+ *
12
+ * Spec: docs/superpowers/specs/2026-06-13-advice-execution-design.md §8
13
+ *
14
+ * @author Felix
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const path = require('path');
21
+ const crypto = require('crypto');
22
+
23
+ const {
24
+ queryAll,
25
+ queryOne,
26
+ getSessionById,
27
+ getMessagesBySession,
28
+ getSetting,
29
+ setSetting,
30
+ } = require('../db/queries');
31
+ const { loadAdvice } = require('../llm/advice');
32
+ const { loadProjectAdvice } = require('../llm/project-advice');
33
+ const { saveDb } = require('../db/connection');
34
+ const { runExecutor, canSpawn } = require('./runner');
35
+ const {
36
+ buildExecutionPrompt,
37
+ buildProjectExecutionPrompt,
38
+ } = require('./prompt');
39
+ const { canonicalProject } = require('../utils/project');
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // In-process global lock
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /**
46
+ * The currently-running execution. null when idle.
47
+ *
48
+ * Shape:
49
+ * { runId, scope, scopeId, cancel: ()=>void, sessionId? }
50
+ *
51
+ * `sessionId` is kept on session-scope runs for backwards-compat with
52
+ * the API response shape; project-scope runs leave it null.
53
+ */
54
+ let _current = null;
55
+
56
+ function isBusy() { return _current !== null; }
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Whitelist (stored in user_settings.execution_allowed_paths)
60
+ // ---------------------------------------------------------------------------
61
+
62
+ const WHITELIST_KEY = 'execution_allowed_paths';
63
+
64
+ function normalisePath(p) {
65
+ if (typeof p !== 'string' || !p.trim()) return null;
66
+ try { return path.resolve(p.trim()); } catch { return null; }
67
+ }
68
+
69
+ function getWhitelist(db) {
70
+ const raw = getSetting(db, WHITELIST_KEY);
71
+ if (!raw) return [];
72
+ return raw.split('\n').map((s) => s.trim()).filter(Boolean);
73
+ }
74
+
75
+ function setWhitelist(db, paths) {
76
+ const uniq = Array.from(new Set(paths.map(normalisePath).filter(Boolean)));
77
+ setSetting(db, WHITELIST_KEY, uniq.join('\n'));
78
+ return uniq;
79
+ }
80
+
81
+ function addToWhitelist(db, p) {
82
+ const np = normalisePath(p);
83
+ if (!np) return getWhitelist(db);
84
+ const cur = getWhitelist(db);
85
+ if (cur.some((x) => normalisePath(x) === np)) return cur;
86
+ cur.push(np);
87
+ return setWhitelist(db, cur);
88
+ }
89
+
90
+ function removeFromWhitelist(db, p) {
91
+ const np = normalisePath(p);
92
+ if (!np) return getWhitelist(db);
93
+ const cur = getWhitelist(db).filter((x) => normalisePath(x) !== np);
94
+ setWhitelist(db, cur);
95
+ return cur;
96
+ }
97
+
98
+ function isWhitelisted(db, p) {
99
+ const np = normalisePath(p);
100
+ if (!np) return false;
101
+ return getWhitelist(db).some((x) => normalisePath(x) === np);
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Path validation
106
+ // ---------------------------------------------------------------------------
107
+
108
+ function isValidProjectPath(p) {
109
+ if (typeof p !== 'string' || !p) return false;
110
+ try {
111
+ const st = fs.statSync(p);
112
+ return st.isDirectory();
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Advice item resolution
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /**
123
+ * Parse 'category.index' → { category, index } or null.
124
+ */
125
+ function parseAdviceKey(adviceKey) {
126
+ if (typeof adviceKey !== 'string') return null;
127
+ const m = adviceKey.match(/^([a-z_]+)\.(\d+)$/);
128
+ if (!m) return null;
129
+ return { category: m[1], index: Number(m[2]) };
130
+ }
131
+
132
+ /**
133
+ * Locate the AdviceItem inside the cached advice payload. Returns the
134
+ * item with .category attached for convenience, or null.
135
+ *
136
+ * Works for both session advice (`session_analysis.llm_advice`) and
137
+ * project advice (`project_advice.llm_advice`) — both wrap items inside
138
+ * `{ categories: { [cat]: [items] } }` with identical AdviceItem shape.
139
+ */
140
+ function findAdviceItem(advice, adviceKey) {
141
+ const parsed = parseAdviceKey(adviceKey);
142
+ if (!parsed) return null;
143
+ if (!advice || !advice.categories) return null;
144
+ const arr = advice.categories[parsed.category];
145
+ if (!Array.isArray(arr)) return null;
146
+ const it = arr[parsed.index];
147
+ if (!it) return null;
148
+ return { ...it, category: parsed.category };
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Recent user messages (context for executor)
153
+ // ---------------------------------------------------------------------------
154
+
155
+ function fetchRecentUserMessages(db, sessionId, limit = 5) {
156
+ const all = getMessagesBySession(db, sessionId);
157
+ const users = all.filter((m) => m.role === 'user' && m.text);
158
+ return users.slice(-limit).map((m) => ({ role: 'user', text: m.text || '' }));
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // DB writes
163
+ // ---------------------------------------------------------------------------
164
+
165
+ function insertRun(db, row) {
166
+ db.run(
167
+ `INSERT INTO execution_run
168
+ (id, session_id, advice_key, advice_snapshot, project, executor,
169
+ status, started_at, ended_at, exit_code, stdout, stderr, error, duration_ms,
170
+ scope, scope_id, scope_meta)
171
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
172
+ [
173
+ row.id,
174
+ row.session_id || '', // NOT NULL column — '' for project-scope rows
175
+ row.advice_key,
176
+ row.advice_snapshot,
177
+ row.project,
178
+ row.executor,
179
+ row.status,
180
+ row.started_at || null,
181
+ row.ended_at || null,
182
+ row.exit_code ?? null,
183
+ row.stdout || null,
184
+ row.stderr || null,
185
+ row.error || null,
186
+ row.duration_ms ?? null,
187
+ row.scope || 'session',
188
+ row.scope_id || row.session_id || '',
189
+ row.scope_meta || null,
190
+ ]
191
+ );
192
+ }
193
+
194
+ function updateRun(db, runId, patch) {
195
+ const fields = [];
196
+ const values = [];
197
+ for (const [k, v] of Object.entries(patch)) {
198
+ fields.push(`${k} = ?`);
199
+ values.push(v ?? null);
200
+ }
201
+ values.push(runId);
202
+ db.run(
203
+ `UPDATE execution_run SET ${fields.join(', ')} WHERE id = ?`,
204
+ values
205
+ );
206
+ try { saveDb(); } catch { /* auto-save will catch up */ }
207
+ }
208
+
209
+ function getRun(db, runId) {
210
+ return queryOne(db, 'SELECT * FROM execution_run WHERE id = ?', [runId]);
211
+ }
212
+
213
+ function listRunsForAdvice(db, sessionId) {
214
+ // Session-scope runs only. Older rows (pre-scope migration) have
215
+ // scope = NULL but a valid session_id, so we treat NULL as 'session'.
216
+ const rows = queryAll(
217
+ db,
218
+ `SELECT * FROM execution_run
219
+ WHERE session_id = ?
220
+ AND (scope IS NULL OR scope = 'session')
221
+ ORDER BY advice_key, started_at DESC, id DESC`,
222
+ [sessionId]
223
+ );
224
+ const out = {};
225
+ for (const r of rows) {
226
+ (out[r.advice_key] || (out[r.advice_key] = [])).push(r);
227
+ }
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * Project-scope counterpart of listRunsForAdvice. Looks up all runs
233
+ * triggered from a given project advice (project + scope + window).
234
+ *
235
+ * @param {object} db
236
+ * @param {string} projectKey canonical project path
237
+ * @param {string} scope 'daily' | 'weekly' | 'all'
238
+ * @param {string} windowFrom
239
+ * @param {string} windowTo
240
+ * @returns {Object<string, Object[]>} { 'cost.0': [run, ...], ... }
241
+ */
242
+ function listRunsForProjectAdvice(db, projectKey, scope, windowFrom, windowTo) {
243
+ const project = canonicalProject(projectKey);
244
+ const scopeId = makeProjectScopeId(project, scope, windowFrom, windowTo);
245
+ const rows = queryAll(
246
+ db,
247
+ `SELECT * FROM execution_run
248
+ WHERE scope = 'project' AND scope_id = ?
249
+ ORDER BY advice_key, started_at DESC, id DESC`,
250
+ [scopeId]
251
+ );
252
+ const out = {};
253
+ for (const r of rows) {
254
+ (out[r.advice_key] || (out[r.advice_key] = [])).push(r);
255
+ }
256
+ return out;
257
+ }
258
+
259
+ /**
260
+ * Synthetic scope_id for project runs. Encodes everything that
261
+ * identifies the source project_advice row, so two different windows of
262
+ * the same project don't share their run history.
263
+ */
264
+ function makeProjectScopeId(project, scope, windowFrom, windowTo) {
265
+ return `project::${project}::${scope}::${windowFrom || ''}::${windowTo || ''}`;
266
+ }
267
+
268
+ function getCurrentRunning(db) {
269
+ return queryOne(
270
+ db,
271
+ `SELECT * FROM execution_run
272
+ WHERE status IN ('pending','running')
273
+ ORDER BY started_at DESC LIMIT 1`
274
+ );
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // Orphan cleanup — called at server boot
279
+ // ---------------------------------------------------------------------------
280
+
281
+ function cleanupOrphans(db) {
282
+ const orphans = queryAll(
283
+ db,
284
+ `SELECT id FROM execution_run WHERE status IN ('pending','running')`
285
+ );
286
+ if (!orphans.length) return 0;
287
+ const now = new Date().toISOString();
288
+ for (const o of orphans) {
289
+ db.run(
290
+ `UPDATE execution_run
291
+ SET status='failed', error='aboss-restarted', ended_at=?
292
+ WHERE id=?`,
293
+ [now, o.id]
294
+ );
295
+ }
296
+ try { saveDb(); } catch { /* noop */ }
297
+ return orphans.length;
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Public: startExecution
302
+ // ---------------------------------------------------------------------------
303
+
304
+ /**
305
+ * Start a new execution run for the given advice item.
306
+ *
307
+ * @param {object} db
308
+ * @param {object} opts
309
+ * @param {string} opts.sessionId
310
+ * @param {string} opts.adviceKey 'category.index'
311
+ * @param {string} [opts.executor] override the item's preferred executor
312
+ * @param {boolean} [opts.ephemeral=false] true → don't add project to whitelist
313
+ *
314
+ * @returns {Promise<
315
+ * { ok:true, runId, run: row }
316
+ * | { ok:false, reason:'already-running'|'no-session'|'no-advice'|'no-advice-item'
317
+ * |'not-actionable'|'invalid-project-path'|'not-whitelisted'
318
+ * |'no-cli'|'internal',
319
+ * extra?: any, error?: string }
320
+ * >}
321
+ */
322
+ async function startExecution(db, opts) {
323
+ const { sessionId, adviceKey, executor: executorOpt, ephemeral = false } = opts || {};
324
+ try {
325
+ // 1. Global lock (in-process is authoritative; DB query is a backup
326
+ // for the race where _current was reset but a crashed run wasn't
327
+ // cleaned up. cleanupOrphans on boot makes this rare.)
328
+ const busy = checkBusy(db);
329
+ if (busy) return busy;
330
+
331
+ // 2. Session must exist.
332
+ const session = getSessionById(db, sessionId);
333
+ if (!session) return { ok: false, reason: 'no-session' };
334
+
335
+ // 3. Advice cache must exist + item must be findable.
336
+ const advice = loadAdvice(db, sessionId);
337
+ if (!advice) return { ok: false, reason: 'no-advice' };
338
+ const item = findAdviceItem(advice, adviceKey);
339
+ if (!item) return { ok: false, reason: 'no-advice-item' };
340
+
341
+ // 4. Item must be actionable.
342
+ if (item.actionable !== true) {
343
+ return { ok: false, reason: 'not-actionable' };
344
+ }
345
+
346
+ // 5. Project path validity.
347
+ const project = session.project;
348
+ if (!isValidProjectPath(project)) {
349
+ return { ok: false, reason: 'invalid-project-path', extra: { project } };
350
+ }
351
+
352
+ // 6. Whitelist (with optional ephemeral bypass).
353
+ if (!ephemeral && !isWhitelisted(db, project)) {
354
+ return { ok: false, reason: 'not-whitelisted', extra: { project } };
355
+ }
356
+
357
+ // 7. Executor choice & availability.
358
+ const executor = executorOpt || item.executor || 'opencode';
359
+ if (executor !== 'opencode' && executor !== 'claude') {
360
+ return { ok: false, reason: 'no-cli', extra: { executor } };
361
+ }
362
+ const cliOk = await canSpawn(executor);
363
+ if (!cliOk) return { ok: false, reason: 'no-cli', extra: { executor } };
364
+
365
+ // 8. Insert the row in 'pending' state.
366
+ const runId = crypto.randomUUID();
367
+ insertRun(db, {
368
+ id: runId,
369
+ session_id: sessionId,
370
+ advice_key: adviceKey,
371
+ advice_snapshot: JSON.stringify(item),
372
+ project,
373
+ executor,
374
+ status: 'pending',
375
+ scope: 'session',
376
+ scope_id: sessionId,
377
+ scope_meta: null,
378
+ });
379
+ try { saveDb(); } catch {}
380
+
381
+ // 9. Fire-and-forget the run. Resolves the promise with runId NOW.
382
+ const recentUserMessages = fetchRecentUserMessages(db, sessionId);
383
+ _current = { runId, scope: 'session', scopeId: sessionId, sessionId, cancel: null };
384
+
385
+ const promptBuilder = () => buildExecutionPrompt({
386
+ advice: item,
387
+ session: {
388
+ project: session.project,
389
+ title: session.title,
390
+ model: session.model,
391
+ durationMinutes: session.duration_minutes,
392
+ messageCount: session.message_count,
393
+ },
394
+ recentUserMessages,
395
+ });
396
+
397
+ spawnRunAsync(db, { runId, executor, cwd: project, promptBuilder });
398
+
399
+ const row = getRun(db, runId);
400
+ return { ok: true, runId, run: row };
401
+ } catch (err) {
402
+ return { ok: false, reason: 'internal', error: err && err.message };
403
+ }
404
+ }
405
+
406
+ // ---------------------------------------------------------------------------
407
+ // Public: startProjectExecution
408
+ // ---------------------------------------------------------------------------
409
+
410
+ /**
411
+ * Start a new execution run for an item in a project-level advice payload.
412
+ *
413
+ * Mirrors startExecution but resolves the AdviceItem from project_advice
414
+ * rather than session_analysis.llm_advice, and spawns the executor in
415
+ * the project root (the same place session-scope runs target).
416
+ *
417
+ * Shares the global busy lock with session-scope runs (per design — only
418
+ * one opencode/claude subprocess at a time touching the user's files).
419
+ *
420
+ * @param {object} db
421
+ * @param {object} opts
422
+ * @param {string} opts.project raw or canonical project path
423
+ * @param {string} opts.scope 'daily' | 'weekly' | 'all'
424
+ * @param {string} [opts.windowFrom]
425
+ * @param {string} [opts.windowTo]
426
+ * @param {string} opts.adviceKey 'category.index'
427
+ * @param {string} [opts.executor]
428
+ * @param {boolean} [opts.ephemeral=false]
429
+ *
430
+ * @returns {Promise<
431
+ * { ok:true, runId, run: row }
432
+ * | { ok:false, reason: string, extra?: any, error?: string }
433
+ * >}
434
+ */
435
+ async function startProjectExecution(db, opts) {
436
+ const {
437
+ project: projectRaw, scope, windowFrom = '', windowTo = '',
438
+ adviceKey, executor: executorOpt, ephemeral = false,
439
+ } = opts || {};
440
+ try {
441
+ const project = canonicalProject(projectRaw || '');
442
+ if (!project) return { ok: false, reason: 'no-project' };
443
+ if (!scope || (scope !== 'all' && (!windowFrom || !windowTo))) {
444
+ return { ok: false, reason: 'no-window' };
445
+ }
446
+
447
+ // 1. Global lock — shared with session-scope runs.
448
+ const busy = checkBusy(db);
449
+ if (busy) return busy;
450
+
451
+ // 2. Project advice cache must exist (per spec: meta-analysis is
452
+ // user-triggered, so the absence means "go generate it first").
453
+ const cached = loadProjectAdvice(
454
+ db, project, scope,
455
+ scope === 'all' ? '' : windowFrom,
456
+ scope === 'all' ? '' : windowTo
457
+ );
458
+ if (!cached || !cached.payload) {
459
+ return { ok: false, reason: 'no-advice' };
460
+ }
461
+ const item = findAdviceItem(cached.payload, adviceKey);
462
+ if (!item) return { ok: false, reason: 'no-advice-item' };
463
+
464
+ // 3. Actionable check.
465
+ if (item.actionable !== true) {
466
+ return { ok: false, reason: 'not-actionable' };
467
+ }
468
+
469
+ // 4. Project path validity (the canonical path must actually be a dir
470
+ // on disk — guards against deleted / renamed projects).
471
+ if (!isValidProjectPath(project)) {
472
+ return { ok: false, reason: 'invalid-project-path', extra: { project } };
473
+ }
474
+
475
+ // 5. Whitelist.
476
+ if (!ephemeral && !isWhitelisted(db, project)) {
477
+ return { ok: false, reason: 'not-whitelisted', extra: { project } };
478
+ }
479
+
480
+ // 6. Executor.
481
+ const executor = executorOpt || item.executor || 'opencode';
482
+ if (executor !== 'opencode' && executor !== 'claude') {
483
+ return { ok: false, reason: 'no-cli', extra: { executor } };
484
+ }
485
+ const cliOk = await canSpawn(executor);
486
+ if (!cliOk) return { ok: false, reason: 'no-cli', extra: { executor } };
487
+
488
+ // 7. Insert pending row.
489
+ const runId = crypto.randomUUID();
490
+ const scopeId = makeProjectScopeId(project, scope, windowFrom, windowTo);
491
+ const scopeMeta = JSON.stringify({ scope, windowFrom, windowTo, project });
492
+ insertRun(db, {
493
+ id: runId,
494
+ session_id: '',
495
+ advice_key: adviceKey,
496
+ advice_snapshot: JSON.stringify(item),
497
+ project,
498
+ executor,
499
+ status: 'pending',
500
+ scope: 'project',
501
+ scope_id: scopeId,
502
+ scope_meta: scopeMeta,
503
+ });
504
+ try { saveDb(); } catch {}
505
+
506
+ // 8. Mark current + spawn.
507
+ _current = { runId, scope: 'project', scopeId, sessionId: null, cancel: null };
508
+ const promptBuilder = () => buildProjectExecutionPrompt({
509
+ advice: item,
510
+ project: {
511
+ path: project,
512
+ scope,
513
+ windowFrom: scope === 'all' ? '' : windowFrom,
514
+ windowTo: scope === 'all' ? '' : windowTo,
515
+ sessionCount: cached.sessionCount,
516
+ },
517
+ crossSessionPatterns: Array.isArray(cached.payload.crossSessionPatterns)
518
+ ? cached.payload.crossSessionPatterns
519
+ : [],
520
+ });
521
+
522
+ spawnRunAsync(db, { runId, executor, cwd: project, promptBuilder });
523
+
524
+ const row = getRun(db, runId);
525
+ return { ok: true, runId, run: row };
526
+ } catch (err) {
527
+ return { ok: false, reason: 'internal', error: err && err.message };
528
+ }
529
+ }
530
+
531
+ // ---------------------------------------------------------------------------
532
+ // Shared spawn / busy helpers
533
+ // ---------------------------------------------------------------------------
534
+
535
+ /**
536
+ * Returns a failure result if anything is busy, otherwise null.
537
+ * Both in-process lock (authoritative) and DB row (belt + braces).
538
+ */
539
+ function checkBusy(db) {
540
+ if (isBusy()) {
541
+ return {
542
+ ok: false, reason: 'already-running',
543
+ extra: {
544
+ runId: _current.runId,
545
+ scope: _current.scope,
546
+ scopeId: _current.scopeId,
547
+ sessionId: _current.sessionId,
548
+ },
549
+ };
550
+ }
551
+ const dbRunning = getCurrentRunning(db);
552
+ if (dbRunning) {
553
+ return {
554
+ ok: false, reason: 'already-running',
555
+ extra: {
556
+ runId: dbRunning.id,
557
+ scope: dbRunning.scope || 'session',
558
+ scopeId: dbRunning.scope_id || dbRunning.session_id,
559
+ sessionId: dbRunning.session_id || null,
560
+ },
561
+ };
562
+ }
563
+ return null;
564
+ }
565
+
566
+ /**
567
+ * Run the executor asynchronously (fire-and-forget) and write the final
568
+ * row state back to the DB. Used by both session and project start
569
+ * paths so the success/failure handling stays identical.
570
+ */
571
+ function spawnRunAsync(db, { runId, executor, cwd, promptBuilder }) {
572
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
573
+ (async () => {
574
+ try {
575
+ updateRun(db, runId, { status: 'running', started_at: new Date().toISOString() });
576
+
577
+ const prompt = promptBuilder();
578
+
579
+ const result = await runExecutor({
580
+ executor,
581
+ prompt,
582
+ cwd,
583
+ onSpawn: (cancel) => {
584
+ if (_current && _current.runId === runId) {
585
+ _current.cancel = cancel;
586
+ }
587
+ },
588
+ });
589
+
590
+ const ended = new Date().toISOString();
591
+ if (result.ok) {
592
+ updateRun(db, runId, {
593
+ status: 'success',
594
+ ended_at: ended,
595
+ exit_code: result.exitCode,
596
+ stdout: result.stdout,
597
+ stderr: result.stderr,
598
+ duration_ms: result.durationMs,
599
+ error: null,
600
+ });
601
+ } else {
602
+ const status = result.reason === 'cancelled' ? 'cancelled' : 'failed';
603
+ updateRun(db, runId, {
604
+ status,
605
+ ended_at: ended,
606
+ exit_code: result.exitCode,
607
+ stdout: result.stdout,
608
+ stderr: result.stderr,
609
+ duration_ms: result.durationMs,
610
+ error: result.reason,
611
+ });
612
+ }
613
+ } catch (err) {
614
+ // runner never throws — belt-and-braces.
615
+ updateRun(db, runId, {
616
+ status: 'failed',
617
+ ended_at: new Date().toISOString(),
618
+ error: 'internal',
619
+ stderr: (err && err.message) || String(err),
620
+ });
621
+ } finally {
622
+ _current = null;
623
+ }
624
+ })();
625
+ }
626
+
627
+ /**
628
+ * Cancel the run if it's the current in-flight one. Returns
629
+ * `{ ok:false, reason:'not-running' }` otherwise.
630
+ */
631
+ async function cancelRun(db, runId) {
632
+ if (!_current || _current.runId !== runId) {
633
+ return { ok: false, reason: 'not-running' };
634
+ }
635
+ if (typeof _current.cancel !== 'function') {
636
+ return { ok: false, reason: 'not-running' };
637
+ }
638
+ _current.cancel();
639
+ // Don't await — runner's exit handler will write the row with status=cancelled.
640
+ return { ok: true };
641
+ }
642
+
643
+ module.exports = {
644
+ startExecution,
645
+ startProjectExecution,
646
+ cancelRun,
647
+ getRun,
648
+ listRunsForAdvice,
649
+ listRunsForProjectAdvice,
650
+ getCurrentRunning,
651
+ cleanupOrphans,
652
+ // whitelist
653
+ getWhitelist,
654
+ addToWhitelist,
655
+ removeFromWhitelist,
656
+ isWhitelisted,
657
+ // helpers exported for tests / api
658
+ parseAdviceKey,
659
+ findAdviceItem,
660
+ isValidProjectPath,
661
+ makeProjectScopeId,
662
+ };