@dfsramos/foreman 0.0.0-stage → 0.3.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 (59) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +385 -2
  4. package/dist/agent/adapters/agy.js +96 -0
  5. package/dist/agent/adapters/claude.js +152 -0
  6. package/dist/agent/adapters/codex.js +107 -0
  7. package/dist/agent/adapters/opencode.js +91 -0
  8. package/dist/agent/config.js +169 -0
  9. package/dist/agent/process.js +230 -0
  10. package/dist/agent/prompt.js +184 -0
  11. package/dist/agent/registry.js +57 -0
  12. package/dist/agent/types.js +12 -0
  13. package/dist/cli-args.js +70 -0
  14. package/dist/cli.js +184 -0
  15. package/dist/config.js +102 -0
  16. package/dist/daemon.js +109 -0
  17. package/dist/db.js +467 -0
  18. package/dist/git/taskstore.js +224 -0
  19. package/dist/git/worktree.js +248 -0
  20. package/dist/imports/connections.js +57 -0
  21. package/dist/imports/destination.js +2 -0
  22. package/dist/imports/linear.js +40 -0
  23. package/dist/imports/registry.js +4 -0
  24. package/dist/imports/types.js +1 -0
  25. package/dist/kaneo/client.js +47 -0
  26. package/dist/kaneo/issues.js +138 -0
  27. package/dist/kaneo/workspace.js +24 -0
  28. package/dist/linear/browse.js +55 -0
  29. package/dist/linear/client.js +48 -0
  30. package/dist/linear/issues.js +99 -0
  31. package/dist/linear/workspace.js +30 -0
  32. package/dist/paths.js +12 -0
  33. package/dist/pidfile.js +35 -0
  34. package/dist/projects.js +211 -0
  35. package/dist/review/decide.js +186 -0
  36. package/dist/review/loop.js +114 -0
  37. package/dist/runner.js +1281 -0
  38. package/dist/server/app.js +344 -0
  39. package/dist/server/imports.js +182 -0
  40. package/dist/server/tasks.js +94 -0
  41. package/dist/sources/branch.js +17 -0
  42. package/dist/sources/git-import.js +85 -0
  43. package/dist/sources/git.js +431 -0
  44. package/dist/sources/kaneo.js +156 -0
  45. package/dist/sources/lease.js +38 -0
  46. package/dist/sources/linear.js +186 -0
  47. package/dist/sources/options.js +26 -0
  48. package/dist/sources/registry.js +6 -0
  49. package/dist/sources/taskfile.js +210 -0
  50. package/dist/sources/types.js +1 -0
  51. package/package.json +63 -4
  52. package/public/agents.js +435 -0
  53. package/public/app.js +1200 -0
  54. package/public/imports.js +481 -0
  55. package/public/index.html +19 -0
  56. package/public/projects.js +481 -0
  57. package/public/style.css +1614 -0
  58. package/public/tasks.js +279 -0
  59. package/public/ui.js +165 -0
package/dist/db.js ADDED
@@ -0,0 +1,467 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { DatabaseSync } from 'node:sqlite';
4
+ import { DEFAULT_IGNORED_AUTHORS, REVIEW_MODES } from './projects.js';
5
+ export const RUN_STATUSES = ['running', 'succeeded', 'failed', 'aborted', 'blocked'];
6
+ function median(values) {
7
+ if (values.length === 0)
8
+ return null;
9
+ const sorted = [...values].sort((a, b) => a - b);
10
+ const middle = Math.floor(sorted.length / 2);
11
+ return sorted.length % 2 === 1 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
12
+ }
13
+ export function summariseAgents(rows) {
14
+ const byAgent = new Map();
15
+ for (const row of rows)
16
+ byAgent.set(row.agent, [...(byAgent.get(row.agent) ?? []), row]);
17
+ return Object.fromEntries([...byAgent].map(([agent, runs]) => {
18
+ const work = runs.filter((run) => run.kind === 'work');
19
+ const count = (status) => work.filter((run) => run.status === status).length;
20
+ return [
21
+ agent,
22
+ {
23
+ work: work.length,
24
+ succeeded: count('succeeded'),
25
+ failed: count('failed'),
26
+ blocked: count('blocked'),
27
+ aborted: count('aborted'),
28
+ reviewRounds: runs.length - work.length,
29
+ costUsd: runs.reduce((sum, run) => sum + (run.costUsd ?? 0), 0),
30
+ medianMinutes: median(work.map((run) => run.minutes)),
31
+ },
32
+ ];
33
+ }));
34
+ }
35
+ const SCHEMA = `
36
+ CREATE TABLE IF NOT EXISTS runs (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ issue_id TEXT NOT NULL,
39
+ identifier TEXT NOT NULL,
40
+ title TEXT NOT NULL,
41
+ url TEXT NOT NULL,
42
+ branch TEXT,
43
+ worktree_path TEXT,
44
+ status TEXT NOT NULL,
45
+ step TEXT NOT NULL,
46
+ started_at TEXT NOT NULL,
47
+ finished_at TEXT,
48
+ pr_url TEXT,
49
+ failure_reason TEXT,
50
+ cost_usd REAL,
51
+ num_turns INTEGER,
52
+ agent_pid INTEGER,
53
+ agent TEXT NOT NULL DEFAULT 'claude',
54
+ usage_delta TEXT,
55
+ project_id INTEGER,
56
+ project_name TEXT,
57
+ kind TEXT NOT NULL DEFAULT 'work',
58
+ round INTEGER
59
+ );
60
+ CREATE TABLE IF NOT EXISTS reviews (
61
+ issue_id TEXT PRIMARY KEY,
62
+ handled_until TEXT NOT NULL,
63
+ rounds INTEGER NOT NULL,
64
+ handed_over INTEGER NOT NULL DEFAULT 0,
65
+ conflict_head TEXT,
66
+ conflict_at TEXT,
67
+ agent_reviewed_head TEXT,
68
+ agent_findings TEXT,
69
+ ci_head TEXT,
70
+ ci_reruns INTEGER NOT NULL DEFAULT 0,
71
+ ci_pending_since TEXT,
72
+ ci_handled INTEGER NOT NULL DEFAULT 0,
73
+ approval_head TEXT,
74
+ ci_not_code TEXT
75
+ );
76
+ CREATE TABLE IF NOT EXISTS projects (
77
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
78
+ name TEXT NOT NULL UNIQUE,
79
+ priority TEXT NOT NULL,
80
+ source_type TEXT NOT NULL,
81
+ source_settings TEXT NOT NULL,
82
+ repo_path TEXT NOT NULL UNIQUE,
83
+ worktree_root TEXT NOT NULL,
84
+ poll_interval_seconds INTEGER NOT NULL,
85
+ agent_timeout_minutes INTEGER NOT NULL,
86
+ agents TEXT NOT NULL,
87
+ autostart INTEGER NOT NULL,
88
+ created_at TEXT NOT NULL,
89
+ review_max_rounds INTEGER NOT NULL DEFAULT 5,
90
+ review_ignore_authors TEXT,
91
+ review_mode TEXT NOT NULL DEFAULT 'none',
92
+ wait_for_ci INTEGER NOT NULL DEFAULT 1
93
+ );
94
+ CREATE TABLE IF NOT EXISTS import_connections (
95
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
96
+ name TEXT NOT NULL UNIQUE,
97
+ type TEXT NOT NULL,
98
+ settings TEXT NOT NULL,
99
+ created_at TEXT NOT NULL
100
+ );
101
+ CREATE TABLE IF NOT EXISTS events (
102
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
103
+ run_id INTEGER NOT NULL REFERENCES runs(id),
104
+ ts TEXT NOT NULL,
105
+ kind TEXT NOT NULL,
106
+ text TEXT NOT NULL
107
+ );
108
+ CREATE INDEX IF NOT EXISTS events_run_id ON events (run_id, id);
109
+ CREATE TABLE IF NOT EXISTS settings (
110
+ key TEXT PRIMARY KEY,
111
+ value TEXT NOT NULL
112
+ );
113
+ `;
114
+ const PATCH_COLUMNS = {
115
+ branch: 'branch',
116
+ worktreePath: 'worktree_path',
117
+ step: 'step',
118
+ prUrl: 'pr_url',
119
+ costUsd: 'cost_usd',
120
+ numTurns: 'num_turns',
121
+ agentPid: 'agent_pid',
122
+ usageDelta: 'usage_delta',
123
+ };
124
+ function toRun(row) {
125
+ return {
126
+ id: Number(row.id),
127
+ issueId: row.issue_id,
128
+ identifier: row.identifier,
129
+ title: row.title,
130
+ url: row.url,
131
+ branch: row.branch,
132
+ worktreePath: row.worktree_path,
133
+ status: row.status,
134
+ step: row.step,
135
+ startedAt: row.started_at,
136
+ finishedAt: row.finished_at,
137
+ prUrl: row.pr_url,
138
+ failureReason: row.failure_reason,
139
+ costUsd: row.cost_usd,
140
+ numTurns: row.num_turns,
141
+ agentPid: row.agent_pid,
142
+ agent: row.agent,
143
+ usageDelta: row.usage_delta ? JSON.parse(row.usage_delta) : null,
144
+ projectId: row.project_id === null || row.project_id === undefined ? null : Number(row.project_id),
145
+ projectName: row.project_name ?? null,
146
+ kind: row.kind ?? 'work',
147
+ round: row.round === null || row.round === undefined ? null : Number(row.round),
148
+ };
149
+ }
150
+ function toImportConnection(row) {
151
+ return {
152
+ id: Number(row.id),
153
+ name: row.name,
154
+ type: row.type,
155
+ settings: JSON.parse(row.settings),
156
+ createdAt: row.created_at,
157
+ };
158
+ }
159
+ function toProject(row) {
160
+ return {
161
+ id: Number(row.id),
162
+ name: row.name,
163
+ priority: row.priority,
164
+ sourceType: row.source_type,
165
+ sourceSettings: JSON.parse(row.source_settings),
166
+ repoPath: row.repo_path,
167
+ worktreeRoot: row.worktree_root,
168
+ pollIntervalSeconds: Number(row.poll_interval_seconds),
169
+ agentTimeoutMinutes: Number(row.agent_timeout_minutes),
170
+ agents: JSON.parse(row.agents),
171
+ autostart: Number(row.autostart) === 1,
172
+ reviewMaxRounds: row.review_max_rounds === undefined || row.review_max_rounds === null ? 5 : Number(row.review_max_rounds),
173
+ // Null for a project saved before the setting existed: the defaults apply.
174
+ reviewIgnoreAuthors: row.review_ignore_authors ? JSON.parse(row.review_ignore_authors) : [...DEFAULT_IGNORED_AUTHORS],
175
+ reviewMode: REVIEW_MODES.includes(row.review_mode) ? row.review_mode : 'none',
176
+ waitForCi: row.wait_for_ci === undefined || row.wait_for_ci === null ? true : Number(row.wait_for_ci) === 1,
177
+ createdAt: row.created_at,
178
+ };
179
+ }
180
+ const projectValues = (input) => [
181
+ input.name,
182
+ input.priority,
183
+ input.sourceType,
184
+ JSON.stringify(input.sourceSettings),
185
+ input.repoPath,
186
+ input.worktreeRoot,
187
+ input.pollIntervalSeconds,
188
+ input.agentTimeoutMinutes,
189
+ JSON.stringify(input.agents),
190
+ input.autostart ? 1 : 0,
191
+ input.reviewMaxRounds,
192
+ JSON.stringify(input.reviewIgnoreAuthors),
193
+ input.reviewMode,
194
+ input.waitForCi ? 1 : 0,
195
+ ];
196
+ function toEvent(row) {
197
+ return {
198
+ id: Number(row.id),
199
+ runId: Number(row.run_id),
200
+ ts: row.ts,
201
+ kind: row.kind,
202
+ text: row.text,
203
+ };
204
+ }
205
+ export function openDb(file, now = () => new Date()) {
206
+ if (file !== ':memory:') {
207
+ fs.mkdirSync(path.dirname(file), { recursive: true });
208
+ }
209
+ const sqlite = new DatabaseSync(file);
210
+ if (file !== ':memory:') {
211
+ // The database holds tracker credentials: keep it readable by its owner only.
212
+ fs.chmodSync(file, 0o600);
213
+ sqlite.exec('PRAGMA journal_mode = WAL');
214
+ }
215
+ sqlite.exec(SCHEMA);
216
+ // Databases created before agent_pid existed.
217
+ const columns = sqlite.prepare('PRAGMA table_info(runs)').all();
218
+ if (!columns.some((column) => column.name === 'agent_pid')) {
219
+ sqlite.exec('ALTER TABLE runs ADD COLUMN agent_pid INTEGER');
220
+ }
221
+ // Runs recorded before other agents existed were all claude's.
222
+ if (!columns.some((column) => column.name === 'agent')) {
223
+ sqlite.exec("ALTER TABLE runs ADD COLUMN agent TEXT NOT NULL DEFAULT 'claude'");
224
+ }
225
+ if (!columns.some((column) => column.name === 'usage_delta')) {
226
+ sqlite.exec('ALTER TABLE runs ADD COLUMN usage_delta TEXT');
227
+ }
228
+ if (!columns.some((column) => column.name === 'project_id')) {
229
+ sqlite.exec('ALTER TABLE runs ADD COLUMN project_id INTEGER');
230
+ sqlite.exec('ALTER TABLE runs ADD COLUMN project_name TEXT');
231
+ }
232
+ if (!columns.some((column) => column.name === 'kind')) {
233
+ sqlite.exec("ALTER TABLE runs ADD COLUMN kind TEXT NOT NULL DEFAULT 'work'");
234
+ sqlite.exec('ALTER TABLE runs ADD COLUMN round INTEGER');
235
+ }
236
+ const reviewColumns = sqlite.prepare('PRAGMA table_info(reviews)').all();
237
+ if (!reviewColumns.some((column) => column.name === 'handed_over')) {
238
+ sqlite.exec('ALTER TABLE reviews ADD COLUMN handed_over INTEGER NOT NULL DEFAULT 0');
239
+ }
240
+ if (!reviewColumns.some((column) => column.name === 'conflict_head')) {
241
+ sqlite.exec('ALTER TABLE reviews ADD COLUMN conflict_head TEXT');
242
+ sqlite.exec('ALTER TABLE reviews ADD COLUMN conflict_at TEXT');
243
+ }
244
+ if (!reviewColumns.some((column) => column.name === 'agent_reviewed_head')) {
245
+ sqlite.exec('ALTER TABLE reviews ADD COLUMN agent_reviewed_head TEXT');
246
+ sqlite.exec('ALTER TABLE reviews ADD COLUMN agent_findings TEXT');
247
+ }
248
+ if (!reviewColumns.some((column) => column.name === 'ci_head')) {
249
+ sqlite.exec('ALTER TABLE reviews ADD COLUMN ci_head TEXT');
250
+ sqlite.exec('ALTER TABLE reviews ADD COLUMN ci_reruns INTEGER NOT NULL DEFAULT 0');
251
+ sqlite.exec('ALTER TABLE reviews ADD COLUMN ci_pending_since TEXT');
252
+ sqlite.exec('ALTER TABLE reviews ADD COLUMN ci_handled INTEGER NOT NULL DEFAULT 0');
253
+ sqlite.exec('ALTER TABLE reviews ADD COLUMN approval_head TEXT');
254
+ sqlite.exec('ALTER TABLE reviews ADD COLUMN ci_not_code TEXT');
255
+ }
256
+ const projectColumns = sqlite.prepare('PRAGMA table_info(projects)').all();
257
+ if (!projectColumns.some((column) => column.name === 'review_max_rounds')) {
258
+ sqlite.exec('ALTER TABLE projects ADD COLUMN review_max_rounds INTEGER NOT NULL DEFAULT 5');
259
+ }
260
+ if (!projectColumns.some((column) => column.name === 'review_ignore_authors')) {
261
+ sqlite.exec('ALTER TABLE projects ADD COLUMN review_ignore_authors TEXT');
262
+ }
263
+ if (!projectColumns.some((column) => column.name === 'review_mode')) {
264
+ sqlite.exec("ALTER TABLE projects ADD COLUMN review_mode TEXT NOT NULL DEFAULT 'none'");
265
+ }
266
+ if (!projectColumns.some((column) => column.name === 'wait_for_ci')) {
267
+ sqlite.exec('ALTER TABLE projects ADD COLUMN wait_for_ci INTEGER NOT NULL DEFAULT 1');
268
+ }
269
+ const requireRun = (id) => {
270
+ const run = db.getRun(id);
271
+ if (!run)
272
+ throw new Error(`Run ${id} not found`);
273
+ return run;
274
+ };
275
+ const db = {
276
+ createRun(issue, agent, project, review) {
277
+ const result = sqlite
278
+ .prepare(`INSERT INTO runs (issue_id, identifier, title, url, status, step, started_at, agent, project_id, project_name, kind, round, branch)
279
+ VALUES (?, ?, ?, ?, 'running', 'claim', ?, ?, ?, ?, ?, ?, ?)`)
280
+ .run(issue.id, issue.identifier, issue.title, issue.url, now().toISOString(), agent, project?.id ?? null, project?.name ?? null, review ? (review.kind ?? 'review') : 'work', review?.round ?? null, review?.branch ?? null);
281
+ return requireRun(Number(result.lastInsertRowid));
282
+ },
283
+ updateRun(id, patch) {
284
+ const entries = Object.entries(patch).filter(([, value]) => value !== undefined);
285
+ if (entries.length > 0) {
286
+ const assignments = entries.map(([key]) => `${PATCH_COLUMNS[key]} = ?`).join(', ');
287
+ const values = entries.map(([, value]) => (typeof value === 'object' && value !== null ? JSON.stringify(value) : (value ?? null)));
288
+ sqlite.prepare(`UPDATE runs SET ${assignments} WHERE id = ?`).run(...values, id);
289
+ }
290
+ return requireRun(id);
291
+ },
292
+ finishRun(id, status, failureReason) {
293
+ sqlite
294
+ .prepare('UPDATE runs SET status = ?, finished_at = ?, failure_reason = ? WHERE id = ?')
295
+ .run(status, now().toISOString(), failureReason ?? null, id);
296
+ return requireRun(id);
297
+ },
298
+ getRun(id) {
299
+ const row = sqlite.prepare('SELECT * FROM runs WHERE id = ?').get(id);
300
+ return row ? toRun(row) : undefined;
301
+ },
302
+ listRuns({ status, projectId, limit, offset }) {
303
+ const conditions = [];
304
+ const params = [];
305
+ if (status) {
306
+ conditions.push('status = ?');
307
+ params.push(status);
308
+ }
309
+ if (projectId !== undefined) {
310
+ conditions.push('project_id = ?');
311
+ params.push(projectId);
312
+ }
313
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
314
+ const rows = sqlite
315
+ .prepare(`SELECT * FROM runs ${where} ORDER BY id DESC LIMIT ? OFFSET ?`)
316
+ .all(...params, limit, offset);
317
+ const count = sqlite.prepare(`SELECT COUNT(*) AS total FROM runs ${where}`).get(...params);
318
+ return { runs: rows.map(toRun), total: Number(count.total) };
319
+ },
320
+ listRunning() {
321
+ const rows = sqlite.prepare("SELECT * FROM runs WHERE status = 'running' ORDER BY id").all();
322
+ return rows.map(toRun);
323
+ },
324
+ countRuns() {
325
+ const counts = { running: 0, succeeded: 0, failed: 0, aborted: 0, blocked: 0 };
326
+ const rows = sqlite.prepare('SELECT status, COUNT(*) AS total FROM runs GROUP BY status').all();
327
+ for (const row of rows)
328
+ counts[row.status] = Number(row.total);
329
+ return counts;
330
+ },
331
+ agentStats() {
332
+ const rows = sqlite
333
+ .prepare(`SELECT agent, kind, status, cost_usd AS costUsd,
334
+ (julianday(finished_at) - julianday(started_at)) * 1440 AS minutes
335
+ FROM runs WHERE status != 'running' AND finished_at IS NOT NULL`)
336
+ .all();
337
+ return summariseAgents(rows.map((row) => ({ ...row, minutes: Math.round(Number(row.minutes)) })));
338
+ },
339
+ addEvent(runId, kind, text) {
340
+ const ts = now().toISOString();
341
+ const result = sqlite
342
+ .prepare('INSERT INTO events (run_id, ts, kind, text) VALUES (?, ?, ?, ?)')
343
+ .run(runId, ts, kind, text);
344
+ return { id: Number(result.lastInsertRowid), runId, ts, kind, text };
345
+ },
346
+ listEvents(runId, afterId = 0) {
347
+ const rows = sqlite
348
+ .prepare('SELECT * FROM events WHERE run_id = ? AND id > ? ORDER BY id')
349
+ .all(runId, afterId);
350
+ return rows.map(toEvent);
351
+ },
352
+ getReview(issueId) {
353
+ const row = sqlite.prepare('SELECT * FROM reviews WHERE issue_id = ?').get(issueId);
354
+ return row
355
+ ? {
356
+ issueId,
357
+ handledUntil: row.handled_until,
358
+ rounds: Number(row.rounds),
359
+ handedOver: Number(row.handed_over) === 1,
360
+ conflictHead: row.conflict_head ?? null,
361
+ conflictAt: row.conflict_at ?? null,
362
+ agentReviewedHead: row.agent_reviewed_head ?? null,
363
+ agentFindings: row.agent_findings ?? null,
364
+ ciHead: row.ci_head ?? null,
365
+ ciReruns: Number(row.ci_reruns ?? 0),
366
+ ciPendingSince: row.ci_pending_since ?? null,
367
+ ciHandled: Number(row.ci_handled) === 1,
368
+ approvalHead: row.approval_head ?? null,
369
+ ciNotCode: row.ci_not_code ?? null,
370
+ }
371
+ : undefined;
372
+ },
373
+ saveReview(progress) {
374
+ sqlite
375
+ .prepare(`INSERT INTO reviews (issue_id, handled_until, rounds, handed_over, conflict_head, conflict_at, agent_reviewed_head, agent_findings,
376
+ ci_head, ci_reruns, ci_pending_since, ci_handled, approval_head, ci_not_code)
377
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
378
+ ON CONFLICT(issue_id) DO UPDATE SET handled_until = excluded.handled_until, rounds = excluded.rounds,
379
+ handed_over = excluded.handed_over, conflict_head = excluded.conflict_head, conflict_at = excluded.conflict_at,
380
+ agent_reviewed_head = excluded.agent_reviewed_head, agent_findings = excluded.agent_findings,
381
+ ci_head = excluded.ci_head, ci_reruns = excluded.ci_reruns, ci_pending_since = excluded.ci_pending_since,
382
+ ci_handled = excluded.ci_handled, approval_head = excluded.approval_head, ci_not_code = excluded.ci_not_code`)
383
+ .run(progress.issueId, progress.handledUntil, progress.rounds, progress.handedOver ? 1 : 0, progress.conflictHead ?? null, progress.conflictAt ?? null, progress.agentReviewedHead ?? null, progress.agentFindings ?? null, progress.ciHead ?? null, progress.ciReruns ?? 0, progress.ciPendingSince ?? null, progress.ciHandled ? 1 : 0, progress.approvalHead ?? null, progress.ciNotCode ?? null);
384
+ },
385
+ authorAgent(issueId) {
386
+ const row = sqlite
387
+ .prepare("SELECT agent FROM runs WHERE issue_id = ? AND kind = 'work' AND status = 'succeeded' ORDER BY id DESC LIMIT 1")
388
+ .get(issueId);
389
+ return row?.agent ?? null;
390
+ },
391
+ lastRunFinishedAt(issueId) {
392
+ const row = sqlite.prepare("SELECT MAX(finished_at) AS at FROM runs WHERE issue_id = ? AND status = 'succeeded'").get(issueId);
393
+ return row?.at ?? null;
394
+ },
395
+ listProjects() {
396
+ return sqlite.prepare('SELECT * FROM projects ORDER BY id').all().map(toProject);
397
+ },
398
+ getProject(id) {
399
+ const row = sqlite.prepare('SELECT * FROM projects WHERE id = ?').get(id);
400
+ return row ? toProject(row) : undefined;
401
+ },
402
+ createProject(input) {
403
+ const result = sqlite
404
+ .prepare(`INSERT INTO projects (name, priority, source_type, source_settings, repo_path, worktree_root,
405
+ poll_interval_seconds, agent_timeout_minutes, agents, autostart, review_max_rounds, review_ignore_authors, review_mode, wait_for_ci, created_at)
406
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
407
+ .run(...projectValues(input), now().toISOString());
408
+ return toProject(sqlite.prepare('SELECT * FROM projects WHERE id = ?').get(Number(result.lastInsertRowid)));
409
+ },
410
+ updateProject(id, input) {
411
+ sqlite
412
+ .prepare(`UPDATE projects SET name = ?, priority = ?, source_type = ?, source_settings = ?, repo_path = ?, worktree_root = ?,
413
+ poll_interval_seconds = ?, agent_timeout_minutes = ?, agents = ?, autostart = ?, review_max_rounds = ?, review_ignore_authors = ?, review_mode = ?, wait_for_ci = ? WHERE id = ?`)
414
+ .run(...projectValues(input), id);
415
+ const project = db.getProject(id);
416
+ if (!project)
417
+ throw new Error(`Project ${id} not found`);
418
+ return project;
419
+ },
420
+ deleteProject(id) {
421
+ sqlite.prepare('DELETE FROM projects WHERE id = ?').run(id);
422
+ },
423
+ listImportConnections() {
424
+ return sqlite.prepare('SELECT * FROM import_connections ORDER BY id').all().map(toImportConnection);
425
+ },
426
+ getImportConnection(id) {
427
+ const row = sqlite.prepare('SELECT * FROM import_connections WHERE id = ?').get(id);
428
+ return row ? toImportConnection(row) : undefined;
429
+ },
430
+ createImportConnection(input) {
431
+ const result = sqlite
432
+ .prepare('INSERT INTO import_connections (name, type, settings, created_at) VALUES (?, ?, ?, ?)')
433
+ .run(input.name, input.type, JSON.stringify(input.settings), now().toISOString());
434
+ return db.getImportConnection(Number(result.lastInsertRowid));
435
+ },
436
+ updateImportConnection(id, input) {
437
+ sqlite.prepare('UPDATE import_connections SET name = ?, type = ?, settings = ? WHERE id = ?').run(input.name, input.type, JSON.stringify(input.settings), id);
438
+ const connection = db.getImportConnection(id);
439
+ if (!connection)
440
+ throw new Error(`Import connection ${id} not found`);
441
+ return connection;
442
+ },
443
+ deleteImportConnection(id) {
444
+ sqlite.prepare('DELETE FROM import_connections WHERE id = ?').run(id);
445
+ },
446
+ adoptRuns(project) {
447
+ const result = sqlite.prepare('UPDATE runs SET project_id = ?, project_name = ? WHERE project_id IS NULL').run(project.id, project.name);
448
+ return Number(result.changes);
449
+ },
450
+ getSetting(key) {
451
+ const row = sqlite.prepare('SELECT value FROM settings WHERE key = ?').get(key);
452
+ return row ? JSON.parse(row.value) : undefined;
453
+ },
454
+ setSetting(key, value) {
455
+ sqlite
456
+ .prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
457
+ .run(key, JSON.stringify(value));
458
+ },
459
+ deleteSetting(key) {
460
+ sqlite.prepare('DELETE FROM settings WHERE key = ?').run(key);
461
+ },
462
+ close() {
463
+ sqlite.close();
464
+ },
465
+ };
466
+ return db;
467
+ }
@@ -0,0 +1,224 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { defaultExec } from './worktree.js';
6
+ export class TaskStoreError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = 'TaskStoreError';
10
+ }
11
+ }
12
+ const MAX_PUSH_ATTEMPTS = 5;
13
+ /** What git says when the remote branch moved between the fetch and the push. */
14
+ const REJECTED_PUSH = /\[rejected\]|fetch first|non-fast-forward|failed to lock|cannot lock ref/;
15
+ const COMMIT_IDENTITY = ['-c', 'user.name=foreman', '-c', `user.email=foreman@${os.hostname()}`];
16
+ const stores = new Map();
17
+ /** The store for a repository and branch under a data directory: one instance per clone, shared by every project that uses it. */
18
+ export function openTaskStore(options) {
19
+ const key = createHash('sha1').update(`${options.repository}\n${options.branch}`).digest('hex').slice(0, 12);
20
+ const dir = path.resolve(options.dataDir, 'tasks', key);
21
+ let store = stores.get(dir);
22
+ if (!store) {
23
+ store = createTaskStore(dir, options);
24
+ stores.set(dir, store);
25
+ }
26
+ return store;
27
+ }
28
+ const stderrOf = (error) => error.stderr?.trim() ?? '';
29
+ const describeFailure = (args, error) => {
30
+ const detail = stderrOf(error) || (error instanceof Error ? error.message : String(error));
31
+ return `git ${args.join(' ')} failed: ${detail}`;
32
+ };
33
+ const errorCode = (error) => error.code;
34
+ function createTaskStore(dir, { repository, branch, exec = defaultExec }) {
35
+ let queue = Promise.resolve();
36
+ let syncedAt = -Infinity;
37
+ /** Runs `work` after everything queued before it, whether that succeeded or not. */
38
+ const enqueue = (work) => {
39
+ const run = queue.then(work);
40
+ queue = run.then(() => undefined, () => undefined);
41
+ return run;
42
+ };
43
+ const run = (args, cwd) => exec('git', args, cwd ? { cwd } : undefined);
44
+ const git = async (args, cwd) => {
45
+ try {
46
+ return (await run(args, cwd)).stdout.trim();
47
+ }
48
+ catch (error) {
49
+ throw new TaskStoreError(describeFailure(args, error));
50
+ }
51
+ };
52
+ /** The absolute path of a file in the clone, refusing one that would leave it. */
53
+ const inside = (file) => {
54
+ const full = path.resolve(dir, file);
55
+ if (full !== dir && !full.startsWith(dir + path.sep))
56
+ throw new TaskStoreError(`${file} is outside the task store`);
57
+ return full;
58
+ };
59
+ const tree = {
60
+ read(file) {
61
+ try {
62
+ return fs.readFileSync(inside(file), 'utf8');
63
+ }
64
+ catch (error) {
65
+ const code = errorCode(error);
66
+ if (code === 'ENOENT' || code === 'EISDIR' || code === 'ENOTDIR')
67
+ return null;
68
+ throw error;
69
+ }
70
+ },
71
+ list(directory) {
72
+ let entries;
73
+ try {
74
+ entries = fs.readdirSync(inside(directory), { withFileTypes: true });
75
+ }
76
+ catch (error) {
77
+ const code = errorCode(error);
78
+ if (code === 'ENOENT' || code === 'ENOTDIR')
79
+ return [];
80
+ throw error;
81
+ }
82
+ return entries
83
+ .filter((entry) => entry.isFile())
84
+ .map((entry) => entry.name)
85
+ .sort();
86
+ },
87
+ };
88
+ const syncNow = async () => {
89
+ if (fs.existsSync(path.join(dir, '.git'))) {
90
+ await git(['fetch', '--quiet', 'origin', branch], dir);
91
+ await git(['reset', '--quiet', '--hard', 'FETCH_HEAD'], dir);
92
+ await git(['clean', '--quiet', '-fd'], dir);
93
+ }
94
+ else {
95
+ // Whatever is there is not a clone (a clone that failed half way, say) and is replaced.
96
+ fs.rmSync(dir, { recursive: true, force: true });
97
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
98
+ await git(['clone', '--quiet', '--single-branch', '--branch', branch, '--no-tags', repository, dir]);
99
+ }
100
+ syncedAt = Date.now();
101
+ };
102
+ /** Writes the changes to the checkout and returns the paths that changed on disk. */
103
+ const apply = (changes) => {
104
+ const touched = [];
105
+ for (const [file, content] of Object.entries(changes)) {
106
+ const full = inside(file);
107
+ if (content === null) {
108
+ // git add refuses a path that matches nothing, so a deletion of what is not there is not a change.
109
+ if (!fs.existsSync(full))
110
+ continue;
111
+ fs.rmSync(full);
112
+ }
113
+ else {
114
+ fs.mkdirSync(path.dirname(full), { recursive: true });
115
+ fs.writeFileSync(full, content);
116
+ }
117
+ touched.push(file);
118
+ }
119
+ return touched;
120
+ };
121
+ /** Whether the index differs from HEAD: `git diff --cached --quiet` exits 1 when it does. */
122
+ const staged = async () => {
123
+ try {
124
+ await run(['diff', '--cached', '--quiet'], dir);
125
+ return false;
126
+ }
127
+ catch (error) {
128
+ if (errorCode(error) === 1)
129
+ return true;
130
+ throw new TaskStoreError(describeFailure(['diff', '--cached', '--quiet'], error));
131
+ }
132
+ };
133
+ const push = async (source = 'HEAD') => {
134
+ const args = ['push', '--quiet', 'origin', `${source}:refs/heads/${branch}`];
135
+ try {
136
+ await run(args, dir);
137
+ return 'pushed';
138
+ }
139
+ catch (error) {
140
+ if (REJECTED_PUSH.test(stderrOf(error)))
141
+ return 'rejected';
142
+ throw new TaskStoreError(describeFailure(args, error));
143
+ }
144
+ };
145
+ /** The branch's sha on the remote, or null when it does not exist. */
146
+ const lsRemote = async () => {
147
+ const output = await git(['ls-remote', '--heads', repository, branch]);
148
+ for (const line of output.split('\n')) {
149
+ const [sha, ref] = line.split('\t');
150
+ // The pattern matches any ref that ends with the name, so the ref is checked in full.
151
+ if (sha && ref === `refs/heads/${branch}`)
152
+ return sha;
153
+ }
154
+ return null;
155
+ };
156
+ return {
157
+ dir,
158
+ sync() {
159
+ return enqueue(syncNow);
160
+ },
161
+ view(maxAgeMs = 0) {
162
+ return enqueue(async () => {
163
+ if (Date.now() - syncedAt > maxAgeMs)
164
+ await syncNow();
165
+ return tree;
166
+ });
167
+ },
168
+ transact(message, fn) {
169
+ return enqueue(async () => {
170
+ for (let attempt = 1; attempt <= MAX_PUSH_ATTEMPTS; attempt += 1) {
171
+ // No rebase: the reset in the sync drops the commit the last attempt made, and `fn` sees origin as it is now.
172
+ await syncNow();
173
+ const changes = fn(tree);
174
+ if (changes === null)
175
+ return false;
176
+ const paths = apply(changes);
177
+ if (paths.length === 0)
178
+ return false;
179
+ await git(['add', '-A', '--', ...paths], dir);
180
+ if (!(await staged()))
181
+ return false;
182
+ await git([...COMMIT_IDENTITY, 'commit', '--quiet', '-m', message], dir);
183
+ if ((await push()) === 'pushed')
184
+ return true;
185
+ }
186
+ throw new TaskStoreError(`Gave up after ${MAX_PUSH_ATTEMPTS} rejected pushes to ${branch}`);
187
+ });
188
+ },
189
+ remoteBranch() {
190
+ return enqueue(lsRemote);
191
+ },
192
+ canPush() {
193
+ return enqueue(async () => {
194
+ // From a stale checkout the push would be refused for being behind, which proves nothing.
195
+ await syncNow();
196
+ await git(['push', '--dry-run', 'origin', `HEAD:refs/heads/${branch}`], dir);
197
+ });
198
+ },
199
+ createBranch(message) {
200
+ return enqueue(async () => {
201
+ if ((await lsRemote()) !== null)
202
+ return 'exists';
203
+ if (!fs.existsSync(path.join(dir, '.git'))) {
204
+ // Set up as the clone the first sync would make, which fetches the branch once it is pushed.
205
+ fs.rmSync(dir, { recursive: true, force: true });
206
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
207
+ await git(['init', '--quiet', '-b', branch, dir]);
208
+ await git(['remote', 'add', '-t', branch, 'origin', repository], dir);
209
+ }
210
+ // The commit is made from an empty index with no checkout; the next sync's reset restores a clone's.
211
+ await git(['read-tree', '--empty'], dir);
212
+ syncedAt = -Infinity;
213
+ const tree = await git(['write-tree'], dir);
214
+ const commit = await git([...COMMIT_IDENTITY, 'commit-tree', tree, '-m', message], dir);
215
+ if ((await push(commit)) === 'pushed')
216
+ return 'created';
217
+ // Rejected: another machine's first commit landed first, unless the branch is still not there.
218
+ if ((await lsRemote()) !== null)
219
+ return 'exists';
220
+ throw new TaskStoreError(`The push creating ${branch} was rejected, but the remote still has no such branch`);
221
+ });
222
+ },
223
+ };
224
+ }