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.
- package/README.md +34 -0
- package/bin/aboss.js +288 -0
- package/client/dist/assets/index-C1wFD_Vo.css +1 -0
- package/client/dist/assets/index-DBj1Ujlx.js +137 -0
- package/client/dist/index.html +34 -0
- package/package.json +64 -0
- package/server/analysis/daily-aggregator.js +258 -0
- package/server/analysis/difficulty.js +129 -0
- package/server/analysis/dimensions/ai-knowledge.js +172 -0
- package/server/analysis/dimensions/ai-tools.js +161 -0
- package/server/analysis/dimensions/judgement.js +107 -0
- package/server/analysis/dimensions/llm-merge.js +57 -0
- package/server/analysis/dimensions/output-quality.js +167 -0
- package/server/analysis/dimensions/problem-definition.js +104 -0
- package/server/analysis/dimensions/system-thinking.js +225 -0
- package/server/analysis/evidence-builder.js +104 -0
- package/server/analysis/job.js +273 -0
- package/server/analysis/report-builder.js +581 -0
- package/server/analysis/scoring-v2.js +72 -0
- package/server/analysis/text-signals.js +179 -0
- package/server/analysis/thresholds-v2.js +358 -0
- package/server/api/advice.js +124 -0
- package/server/api/analysis.js +141 -0
- package/server/api/execution.js +330 -0
- package/server/api/metrics.js +277 -0
- package/server/api/overview.js +308 -0
- package/server/api/project.js +255 -0
- package/server/api/reports.js +125 -0
- package/server/api/sessions.js +118 -0
- package/server/api/settings.js +119 -0
- package/server/db/connection.js +175 -0
- package/server/db/queries.js +1051 -0
- package/server/db/schema.js +487 -0
- package/server/etl/active-time.js +150 -0
- package/server/etl/backfill-subagents.js +178 -0
- package/server/etl/claude-code.js +826 -0
- package/server/etl/detect.js +341 -0
- package/server/etl/judge-filter.js +117 -0
- package/server/etl/opencode.js +606 -0
- package/server/execution/job.js +662 -0
- package/server/execution/prompt.js +227 -0
- package/server/execution/runner.js +218 -0
- package/server/index.js +94 -0
- package/server/llm/advice-prompt.js +339 -0
- package/server/llm/advice.js +384 -0
- package/server/llm/analysis-prompt.js +162 -0
- package/server/llm/cli-runner.js +249 -0
- package/server/llm/judge-prompts.js +179 -0
- package/server/llm/judge.js +118 -0
- package/server/llm/project-advice-prompt.js +332 -0
- package/server/llm/project-advice.js +491 -0
- package/server/llm/session-analyzer.js +122 -0
- 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
|
+
};
|