ndomo 0.1.0 → 0.2.1

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 (65) hide show
  1. package/.env.example +4 -0
  2. package/README.es.md +29 -23
  3. package/README.md +64 -24
  4. package/bun.lock +447 -0
  5. package/docs/configuration.md +4 -4
  6. package/docs/installation.md +53 -34
  7. package/docs/installer.md +164 -0
  8. package/docs/integrations.md +1 -1
  9. package/docs/web-ui.md +124 -0
  10. package/package.json +43 -4
  11. package/scripts/install.sh +28 -0
  12. package/scripts/smoke-install.sh +47 -0
  13. package/scripts/smoke-web.sh +335 -0
  14. package/src/cli/__tests__/install.test.ts +733 -0
  15. package/src/cli/index.ts +8 -0
  16. package/src/cli/install.ts +1273 -0
  17. package/src/config/__tests__/schema.test.ts +223 -0
  18. package/src/config/schema.ts +129 -16
  19. package/src/http/__tests__/auth.test.ts +10 -10
  20. package/src/http/__tests__/spa.test.ts +296 -0
  21. package/src/http/auth.ts +8 -1
  22. package/src/http/server.ts +71 -2
  23. package/.bun-version +0 -1
  24. package/.dockerignore +0 -79
  25. package/.editorconfig +0 -18
  26. package/.github/CODEOWNERS +0 -8
  27. package/.github/ISSUE_TEMPLATE/bug_report.yml +0 -62
  28. package/.github/ISSUE_TEMPLATE/config.yml +0 -2
  29. package/.github/ISSUE_TEMPLATE/feature_request.yml +0 -34
  30. package/.github/dependabot.yml +0 -36
  31. package/.github/pull_request_template.md +0 -24
  32. package/.github/release.yml +0 -30
  33. package/.github/workflows/gitleaks.yml +0 -28
  34. package/.github/workflows/release-please.yml +0 -27
  35. package/.github/workflows/smoke.yml +0 -29
  36. package/.husky/commit-msg +0 -1
  37. package/CHANGELOG.md +0 -114
  38. package/Dockerfile +0 -32
  39. package/bin/ndomo-analyses.ts +0 -4
  40. package/bin/ndomo-status.ts +0 -4
  41. package/biome.json +0 -57
  42. package/commitlint.config.js +0 -3
  43. package/opencode.json +0 -5
  44. package/release-please-config.json +0 -11
  45. package/scripts/dev-bust-cache.sh +0 -164
  46. package/scripts/smoke-e2e.ts +0 -704
  47. package/scripts/smoke-hot.ts +0 -417
  48. package/scripts/smoke-v4.ts +0 -256
  49. package/scripts/smoke-v5.ts +0 -397
  50. package/scripts/uninstall.sh +0 -224
  51. package/src/index.ts +0 -37
  52. package/src/lib.ts +0 -65
  53. package/src/mem/scoped.ts +0 -65
  54. package/src/orchestrator/background.test.ts +0 -268
  55. package/src/orchestrator/background.ts +0 -293
  56. package/src/orchestrator/memory-hook.ts +0 -182
  57. package/src/orchestrator/reconciler.ts +0 -123
  58. package/src/orchestrator/scheduler.test.ts +0 -300
  59. package/src/orchestrator/scheduler.ts +0 -243
  60. package/src/plugin.test.ts +0 -2574
  61. package/src/plugin.ts +0 -1690
  62. package/src/worktrees/manager.ts +0 -236
  63. package/src/worktrees/state.ts +0 -87
  64. package/tests/integration/ranger-flow.test.ts +0 -257
  65. package/tsconfig.json +0 -31
package/src/lib.ts DELETED
@@ -1,65 +0,0 @@
1
- /**
2
- * ndomo — OpenCode multi-agent orchestrator.
3
- *
4
- * Entry point that re-exports all public APIs.
5
- *
6
- * @example
7
- * ```ts
8
- * import { routeTask, BackgroundDispatcher, cavemanCompress } from "ndomo";
9
- * ```
10
- */
11
-
12
- // Memory: scoped tag helpers
13
- export {
14
- getAllTags,
15
- getProjectTag,
16
- getUserTag,
17
- memoryAddOptions,
18
- memorySearchOptions,
19
- } from "./mem/scoped.ts";
20
-
21
- // Orchestrator: background dispatcher
22
- export {
23
- BackgroundDispatcher,
24
- type BackgroundTask,
25
- type DispatchOptions,
26
- } from "./orchestrator/background.ts";
27
- // Orchestrator: memory hooks
28
- export {
29
- cavemanCompress,
30
- type MemoryEntry,
31
- prepareForMemory,
32
- shouldStoreMemory,
33
- } from "./orchestrator/memory-hook.ts";
34
- // Orchestrator: result reconciliation
35
- export {
36
- type ReconciliationReport,
37
- reconcileResults,
38
- type TaskResult,
39
- } from "./orchestrator/reconciler.ts";
40
- // Orchestrator: scheduler
41
- export {
42
- canRunParallel,
43
- type RoutingDecision,
44
- routeTask,
45
- type TaskRequest,
46
- } from "./orchestrator/scheduler.ts";
47
-
48
- // Worktrees: git worktree manager
49
- export {
50
- cleanup,
51
- createWorktree,
52
- getWorktree,
53
- listActive,
54
- loadState,
55
- removeWorktree,
56
- saveState,
57
- type Worktree,
58
- type WorktreeState,
59
- } from "./worktrees/manager.ts";
60
-
61
- // Worktrees: integrity verification
62
- export {
63
- type IntegrityReport,
64
- verifyIntegrity,
65
- } from "./worktrees/state.ts";
package/src/mem/scoped.ts DELETED
@@ -1,65 +0,0 @@
1
- /**
2
- * Project-scoped memory helpers wrapping opencode-mem/tags.
3
- * Provides convenience functions for tag resolution and memory operations.
4
- */
5
-
6
- import { getProjectTagInfo, getTags, getUserTagInfo } from "opencode-mem/tags";
7
-
8
- /**
9
- * Get the project tag for the current (or specified) working directory.
10
- *
11
- * @param cwd - Working directory. Defaults to process.cwd().
12
- * @returns Project tag string (e.g. "project:ndomo:abc123").
13
- */
14
- export function getProjectTag(cwd?: string): string {
15
- return getProjectTagInfo(cwd ?? process.cwd()).tag;
16
- }
17
-
18
- /**
19
- * Get the user tag for the current system user.
20
- *
21
- * @returns User tag string (e.g. "user:nico:xyz789").
22
- */
23
- export function getUserTag(): string {
24
- return getUserTagInfo().tag;
25
- }
26
-
27
- /**
28
- * Get both user and project tags for a directory.
29
- *
30
- * @param cwd - Working directory. Defaults to process.cwd().
31
- * @returns Object with user and project TagInfo.
32
- */
33
- export function getAllTags(cwd?: string) {
34
- return getTags(cwd ?? process.cwd());
35
- }
36
-
37
- /**
38
- * Build options object for a memory search operation.
39
- *
40
- * @param query - Search query text.
41
- * @param scope - Search scope: "project" (default) or "all-projects".
42
- * @returns Options object for the opencode-mem search API.
43
- */
44
- export function memorySearchOptions(query: string, scope: "project" | "all-projects" = "project") {
45
- return {
46
- mode: "search" as const,
47
- query,
48
- scope,
49
- };
50
- }
51
-
52
- /**
53
- * Build options object for a memory add operation.
54
- *
55
- * @param content - Content to store in memory.
56
- * @param topic - Topic/category for the memory entry.
57
- * @returns Options object for the opencode-mem add API.
58
- */
59
- export function memoryAddOptions(content: string, topic: string) {
60
- return {
61
- mode: "add" as const,
62
- content,
63
- topic,
64
- };
65
- }
@@ -1,268 +0,0 @@
1
- import { Database } from "bun:sqlite";
2
- import { beforeEach, describe, expect, test } from "bun:test";
3
- import { runMigrations } from "../db/migrations.ts";
4
- import { BackgroundDispatcher } from "./background.ts";
5
-
6
- /** UUID v4 regex: 8-4-4-4-12 hex chars. */
7
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
8
-
9
- function createTestDb(): Database {
10
- const db = new Database(":memory:");
11
- db.exec("PRAGMA foreign_keys = ON");
12
- runMigrations(db);
13
- return db;
14
- }
15
-
16
- describe("BackgroundDispatcher", () => {
17
- let db: Database;
18
- let dispatcher: BackgroundDispatcher;
19
-
20
- beforeEach(() => {
21
- db = createTestDb();
22
- dispatcher = new BackgroundDispatcher(db);
23
- });
24
-
25
- // 1. dispatch returns a UUID v4
26
- test("dispatch returns a UUID v4", () => {
27
- const id = dispatcher.dispatch({ agent: "scout", description: "Find auth flow" });
28
- expect(id).toMatch(UUID_RE);
29
- });
30
-
31
- // 2. getStatus returns dispatched task with status='pending'
32
- test("getStatus returns pending task after dispatch", () => {
33
- const id = dispatcher.dispatch({ agent: "scout", description: "Find auth flow" });
34
- const task = dispatcher.getStatus(id);
35
- expect(task).toBeDefined();
36
- expect(task!.id).toBe(id);
37
- expect(task!.status).toBe("pending");
38
- expect(task!.agent).toBe("scout");
39
- expect(task!.description).toBe("Find auth flow");
40
- expect(task!.createdAt).toBeGreaterThan(0);
41
- });
42
-
43
- // 3. markRunning transitions to 'running' + sets sessionId + startedAt
44
- test("markRunning transitions to running with sessionId", () => {
45
- const id = dispatcher.dispatch({ agent: "smith", description: "Refactor utils" });
46
- dispatcher.markRunning(id, "session-abc");
47
- const task = dispatcher.getStatus(id);
48
- expect(task!.status).toBe("running");
49
- expect(task!.sessionId).toBe("session-abc");
50
- expect(task!.startedAt).toBeGreaterThan(0);
51
- });
52
-
53
- // 4. markComplete transitions to 'completed' + sets result + completedAt
54
- test("markComplete transitions to completed with result", () => {
55
- const id = dispatcher.dispatch({ agent: "smith", description: "Fix bug" });
56
- dispatcher.markRunning(id, "session-1");
57
- dispatcher.markComplete(id, "Fixed in src/utils.ts");
58
- const task = dispatcher.getStatus(id);
59
- expect(task!.status).toBe("completed");
60
- expect(task!.result).toBe("Fixed in src/utils.ts");
61
- expect(task!.completedAt).toBeGreaterThan(0);
62
- });
63
-
64
- // 5. markFailed transitions to 'failed' + sets error + completedAt
65
- test("markFailed transitions to failed with error", () => {
66
- const id = dispatcher.dispatch({ agent: "scout", description: "Scan deps" });
67
- dispatcher.markFailed(id, "timeout after 30s");
68
- const task = dispatcher.getStatus(id);
69
- expect(task!.status).toBe("failed");
70
- expect(task!.result).toBe("timeout after 30s");
71
- expect(task!.completedAt).toBeGreaterThan(0);
72
- });
73
-
74
- // 6. getActive returns only pending+running (not completed/failed/cancelled)
75
- test("getActive returns only pending and running tasks", () => {
76
- const id1 = dispatcher.dispatch({ agent: "a", description: "task1" });
77
- const id2 = dispatcher.dispatch({ agent: "a", description: "task2" });
78
- const id3 = dispatcher.dispatch({ agent: "a", description: "task3" });
79
- const id4 = dispatcher.dispatch({ agent: "a", description: "task4" });
80
-
81
- dispatcher.markRunning(id2, "s1");
82
- dispatcher.markComplete(id3, "done");
83
- dispatcher.markFailed(id4, "err");
84
-
85
- const active = dispatcher.getActive();
86
- const activeIds = active.map((t) => t.id);
87
- expect(activeIds).toContain(id1);
88
- expect(activeIds).toContain(id2);
89
- expect(activeIds).not.toContain(id3);
90
- expect(activeIds).not.toContain(id4);
91
- expect(active).toHaveLength(2);
92
- });
93
-
94
- // 7. reconcile returns completed+failed tasks
95
- test("reconcile returns completed and failed tasks", () => {
96
- const id1 = dispatcher.dispatch({ agent: "a", description: "t1" });
97
- const id2 = dispatcher.dispatch({ agent: "a", description: "t2" });
98
- const id3 = dispatcher.dispatch({ agent: "a", description: "t3" });
99
-
100
- dispatcher.markComplete(id1, "ok");
101
- dispatcher.markFailed(id2, "err");
102
- // id3 stays pending
103
-
104
- const finished = dispatcher.reconcile();
105
- const finishedIds = finished.map((t) => t.id);
106
- expect(finishedIds).toContain(id1);
107
- expect(finishedIds).toContain(id2);
108
- expect(finishedIds).not.toContain(id3);
109
- expect(finished).toHaveLength(2);
110
- });
111
-
112
- // 8. remove deletes the task
113
- test("remove deletes the task", () => {
114
- const id = dispatcher.dispatch({ agent: "a", description: "disposable" });
115
- expect(dispatcher.getStatus(id)).toBeDefined();
116
- dispatcher.remove(id);
117
- expect(dispatcher.getStatus(id)).toBeUndefined();
118
- });
119
-
120
- // 9. stats returns correct counts per status
121
- test("stats returns correct counts per status", () => {
122
- dispatcher.dispatch({ agent: "a", description: "p1" });
123
- const id2 = dispatcher.dispatch({ agent: "a", description: "p2" });
124
- const id3 = dispatcher.dispatch({ agent: "a", description: "p3" });
125
- const id4 = dispatcher.dispatch({ agent: "a", description: "p4" });
126
- const id5 = dispatcher.dispatch({ agent: "a", description: "p5" });
127
-
128
- dispatcher.markRunning(id2, "s");
129
- dispatcher.markComplete(id3, "ok");
130
- dispatcher.markFailed(id4, "err");
131
- dispatcher.cancel(id5);
132
-
133
- const s = dispatcher.stats();
134
- expect(s.pending).toBe(1);
135
- expect(s.running).toBe(1);
136
- expect(s.completed).toBe(1);
137
- expect(s.failed).toBe(1);
138
- expect(s.cancelled).toBe(1);
139
- });
140
-
141
- // 10. cancel on pending task → returns true, status='cancelled'
142
- test("cancel on pending task returns true and sets cancelled", () => {
143
- const id = dispatcher.dispatch({ agent: "a", description: "cancel me" });
144
- const result = dispatcher.cancel(id);
145
- expect(result).toBe(true);
146
- const task = dispatcher.getStatus(id);
147
- expect(task!.status).toBe("cancelled");
148
- expect(task!.completedAt).toBeGreaterThan(0);
149
- });
150
-
151
- // 11. cancel on completed task → returns false (already terminal)
152
- test("cancel on completed task returns false", () => {
153
- const id = dispatcher.dispatch({ agent: "a", description: "done already" });
154
- dispatcher.markComplete(id, "finished");
155
- const result = dispatcher.cancel(id);
156
- expect(result).toBe(false);
157
- const task = dispatcher.getStatus(id);
158
- expect(task!.status).toBe("completed"); // unchanged
159
- });
160
-
161
- // 12. listByAgent returns only tasks for that agent, newest first
162
- test("listByAgent filters by agent and orders by created_at DESC", () => {
163
- dispatcher.dispatch({ agent: "scout", description: "first" });
164
- dispatcher.dispatch({ agent: "smith", description: "other" });
165
- dispatcher.dispatch({ agent: "scout", description: "third" });
166
-
167
- const scoutTasks = dispatcher.listByAgent("scout");
168
- expect(scoutTasks).toHaveLength(2);
169
- // All returned tasks belong to scout
170
- expect(scoutTasks.every((t) => t.agent === "scout")).toBe(true);
171
- const descriptions = scoutTasks.map((t) => t.description).sort();
172
- expect(descriptions).toEqual(["first", "third"]);
173
- // smith tasks not included
174
- expect(scoutTasks.some((t) => t.agent === "smith")).toBe(false);
175
- });
176
-
177
- // 13. dispatch with files+worktree options → persisted and retrievable
178
- test("dispatch with files and worktree persists them", () => {
179
- const id = dispatcher.dispatch({
180
- agent: "painter",
181
- description: "redesign nav",
182
- files: ["src/nav.ts", "src/sidebar.ts"],
183
- worktree: "/tmp/wt/nav-redesign",
184
- });
185
- const task = dispatcher.getStatus(id);
186
- expect(task!.files).toEqual(["src/nav.ts", "src/sidebar.ts"]);
187
- expect(task!.worktree).toBe("/tmp/wt/nav-redesign");
188
- });
189
-
190
- // 15. finalize deletes terminal tasks older than threshold
191
- test("finalize deletes terminal tasks older than threshold", () => {
192
- const now = Date.now();
193
- const twoHoursAgo = now - 2 * 60 * 60 * 1000;
194
- const thirtyMinAgo = now - 30 * 60 * 1000;
195
- const insertSql = `INSERT INTO background_tasks
196
- (id, agent, description, status, started_at, completed_at, created_at)
197
- VALUES (?, ?, ?, ?, ?, ?, ?)`;
198
- db.prepare(insertSql).run(
199
- "old1",
200
- "a",
201
- "t1",
202
- "completed",
203
- twoHoursAgo,
204
- twoHoursAgo,
205
- twoHoursAgo,
206
- );
207
- db.prepare(insertSql).run("old2", "a", "t2", "failed", twoHoursAgo, twoHoursAgo, twoHoursAgo);
208
- db.prepare(insertSql).run(
209
- "old3",
210
- "a",
211
- "t3",
212
- "cancelled",
213
- twoHoursAgo,
214
- twoHoursAgo,
215
- twoHoursAgo,
216
- );
217
- db.prepare(insertSql).run(
218
- "fresh",
219
- "a",
220
- "t4",
221
- "completed",
222
- thirtyMinAgo,
223
- thirtyMinAgo,
224
- thirtyMinAgo,
225
- );
226
- db.prepare(insertSql).run("live", "a", "t5", "running", thirtyMinAgo, null, thirtyMinAgo);
227
-
228
- const deleted = dispatcher.finalize(60 * 60 * 1000); // 1h cutoff
229
- expect(deleted).toBe(3);
230
-
231
- expect(dispatcher.getStatus("old1")).toBeUndefined();
232
- expect(dispatcher.getStatus("old2")).toBeUndefined();
233
- expect(dispatcher.getStatus("old3")).toBeUndefined();
234
- expect(dispatcher.getStatus("fresh")?.status).toBe("completed");
235
- expect(dispatcher.getStatus("live")?.status).toBe("running");
236
- });
237
-
238
- test("finalize is a no-op when no terminal tasks exceed threshold", () => {
239
- const id = dispatcher.dispatch({ agent: "a", description: "fresh" });
240
- dispatcher.markComplete(id, "done");
241
-
242
- const deleted = dispatcher.finalize(60 * 60 * 1000);
243
- expect(deleted).toBe(0);
244
- expect(dispatcher.getStatus(id)).toBeDefined();
245
- });
246
-
247
- test("finalize does NOT delete pending or running tasks regardless of age", () => {
248
- const id = dispatcher.dispatch({ agent: "a", description: "stale pending" });
249
- // Manually backdate created_at to long ago — finalize must not touch it
250
- const longAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
251
- db.prepare("UPDATE background_tasks SET created_at = ? WHERE id = ?").run(longAgo, id);
252
-
253
- const deleted = dispatcher.finalize(1000); // 1 second cutoff
254
- expect(deleted).toBe(0);
255
- expect(dispatcher.getStatus(id)?.status).toBe("pending");
256
- });
257
-
258
- // 14. Persistence: new BackgroundDispatcher with SAME db → task still exists
259
- test("new BackgroundDispatcher with same db sees existing tasks", () => {
260
- const id = dispatcher.dispatch({ agent: "scout", description: "persist me" });
261
- // Create a NEW dispatcher with the same db
262
- const dispatcher2 = new BackgroundDispatcher(db);
263
- const task = dispatcher2.getStatus(id);
264
- expect(task).toBeDefined();
265
- expect(task!.description).toBe("persist me");
266
- expect(task!.status).toBe("pending");
267
- });
268
- });
@@ -1,293 +0,0 @@
1
- /**
2
- * Background task dispatcher for the ndomo orchestrator.
3
- * Tracks state of tasks delegated to specialist agents.
4
- *
5
- * DB-backed via bun:sqlite — persists across restarts.
6
- * The actual OpenCode task tool call is made by the foreman prompt —
7
- * this class is a pure state tracker, not an I/O layer.
8
- */
9
-
10
- import type { Database } from "bun:sqlite";
11
-
12
- /** Current state of a background task. */
13
- export interface BackgroundTask {
14
- /** Unique task identifier (UUID v4). */
15
- id: string;
16
- /** Agent handling this task. */
17
- agent: string;
18
- /** What the agent is doing. */
19
- description: string;
20
- /** Lifecycle status. */
21
- status: "pending" | "running" | "completed" | "failed" | "cancelled";
22
- /** OpenCode session ID once the task is dispatched. */
23
- sessionId?: string;
24
- /** Agent output after completion. */
25
- result?: string;
26
- /** Epoch ms when the task was dispatched. */
27
- startedAt?: number;
28
- /** Epoch ms when the task finished (success or failure). */
29
- completedAt?: number;
30
- /** Epoch ms when the task was created. */
31
- createdAt: number;
32
- /** Files the agent should focus on. */
33
- files?: string[];
34
- /** Git worktree path for isolation. */
35
- worktree?: string;
36
- }
37
-
38
- /** Options for dispatching a new background task. */
39
- export interface DispatchOptions {
40
- /** Which agent should handle this. */
41
- agent: string;
42
- /** Task description for the agent. */
43
- description: string;
44
- /** Files the agent should focus on. */
45
- files?: string[];
46
- /** Git worktree path for isolation (optional). */
47
- worktree?: string;
48
- }
49
-
50
- /** DB row shape from background_tasks table (snake_case). */
51
- interface BackgroundTaskRow {
52
- id: string;
53
- agent: string;
54
- description: string;
55
- status: string;
56
- session_id: string | null;
57
- result: string | null;
58
- started_at: number | null;
59
- completed_at: number | null;
60
- created_at: number;
61
- files: string | null;
62
- worktree: string | null;
63
- }
64
-
65
- /**
66
- * Background task state tracker backed by bun:sqlite.
67
- *
68
- * Usage:
69
- * ```ts
70
- * const dispatcher = new BackgroundDispatcher(db);
71
- * const id = dispatcher.dispatch({ agent: "scout", description: "Find auth flow" });
72
- * // ... later, when the task completes ...
73
- * dispatcher.markComplete(id, "Found auth in src/auth/middleware.ts");
74
- * ```
75
- */
76
- export class BackgroundDispatcher {
77
- constructor(private db: Database) {}
78
-
79
- /**
80
- * Convert a DB row to a BackgroundTask object.
81
- * Deserializes JSON fields (files).
82
- */
83
- private rowToTask(row: BackgroundTaskRow): BackgroundTask {
84
- const task: BackgroundTask = {
85
- id: row.id,
86
- agent: row.agent,
87
- description: row.description,
88
- status: row.status as BackgroundTask["status"],
89
- createdAt: row.created_at,
90
- };
91
- if (row.session_id != null) task.sessionId = row.session_id;
92
- if (row.result != null) task.result = row.result;
93
- if (row.started_at != null) task.startedAt = row.started_at;
94
- if (row.completed_at != null) task.completedAt = row.completed_at;
95
- if (row.files != null) {
96
- try {
97
- task.files = JSON.parse(row.files) as string[];
98
- } catch {
99
- task.files = [];
100
- }
101
- }
102
- if (row.worktree != null) task.worktree = row.worktree;
103
- return task;
104
- }
105
-
106
- /**
107
- * Register a new background task and return its ID.
108
- * The task starts in "pending" status — the foreman is responsible
109
- * for calling markRunning() once the OpenCode task tool is invoked.
110
- *
111
- * @param options - Task configuration.
112
- * @returns Unique task ID (UUID v4).
113
- */
114
- dispatch(options: DispatchOptions): string {
115
- const id = crypto.randomUUID();
116
- this.db
117
- .query(
118
- "INSERT INTO background_tasks (id, agent, description, status, files, worktree) VALUES (?, ?, ?, 'pending', ?, ?)",
119
- )
120
- .run(
121
- id,
122
- options.agent,
123
- options.description,
124
- options.files ? JSON.stringify(options.files) : null,
125
- options.worktree ?? null,
126
- );
127
- return id;
128
- }
129
-
130
- /**
131
- * Transition a task to "running" status.
132
- *
133
- * @param taskId - Task to update.
134
- * @param sessionId - OpenCode session ID.
135
- */
136
- markRunning(taskId: string, sessionId: string): void {
137
- this.db
138
- .query(
139
- "UPDATE background_tasks SET status = 'running', session_id = ?, started_at = ? WHERE id = ?",
140
- )
141
- .run(sessionId, Date.now(), taskId);
142
- }
143
-
144
- /**
145
- * Get the current state of a task.
146
- *
147
- * @param taskId - Task ID to look up.
148
- * @returns Task state or undefined if not found.
149
- */
150
- getStatus(taskId: string): BackgroundTask | undefined {
151
- const row = this.db
152
- .query("SELECT * FROM background_tasks WHERE id = ?")
153
- .get(taskId) as BackgroundTaskRow | null;
154
- return row ? this.rowToTask(row) : undefined;
155
- }
156
-
157
- /**
158
- * Get all tasks that are currently pending or running.
159
- *
160
- * @returns Array of active tasks.
161
- */
162
- getActive(): BackgroundTask[] {
163
- const rows = this.db
164
- .query("SELECT * FROM background_tasks WHERE status IN ('pending', 'running')")
165
- .all() as BackgroundTaskRow[];
166
- return rows.map((r) => this.rowToTask(r));
167
- }
168
-
169
- /**
170
- * Mark a task as successfully completed.
171
- *
172
- * @param taskId - Task to update.
173
- * @param result - Agent output.
174
- */
175
- markComplete(taskId: string, result: string): void {
176
- this.db
177
- .query(
178
- "UPDATE background_tasks SET status = 'completed', result = ?, completed_at = ? WHERE id = ?",
179
- )
180
- .run(result, Date.now(), taskId);
181
- }
182
-
183
- /**
184
- * Mark a task as failed.
185
- *
186
- * @param taskId - Task to update.
187
- * @param error - Error description.
188
- */
189
- markFailed(taskId: string, error: string): void {
190
- this.db
191
- .query(
192
- "UPDATE background_tasks SET status = 'failed', result = ?, completed_at = ? WHERE id = ?",
193
- )
194
- .run(error, Date.now(), taskId);
195
- }
196
-
197
- /**
198
- * Return completed or failed tasks that haven't been reconciled yet.
199
- * After calling this, the caller should process results and clear them.
200
- *
201
- * @returns Array of finished tasks.
202
- */
203
- reconcile(): BackgroundTask[] {
204
- const rows = this.db
205
- .query("SELECT * FROM background_tasks WHERE status IN ('completed', 'failed')")
206
- .all() as BackgroundTaskRow[];
207
- return rows.map((r) => this.rowToTask(r));
208
- }
209
-
210
- /**
211
- * Remove a task from tracking (after reconciliation).
212
- *
213
- * @param taskId - Task to remove.
214
- */
215
- remove(taskId: string): void {
216
- this.db.query("DELETE FROM background_tasks WHERE id = ?").run(taskId);
217
- }
218
-
219
- /**
220
- * Get count of tasks by status.
221
- */
222
- stats(): {
223
- pending: number;
224
- running: number;
225
- completed: number;
226
- failed: number;
227
- cancelled: number;
228
- } {
229
- const rows = this.db
230
- .query("SELECT status, COUNT(*) as count FROM background_tasks GROUP BY status")
231
- .all() as Array<{ status: string; count: number }>;
232
-
233
- const counts = { pending: 0, running: 0, completed: 0, failed: 0, cancelled: 0 };
234
- for (const row of rows) {
235
- if (row.status in counts) {
236
- counts[row.status as keyof typeof counts] = row.count;
237
- }
238
- }
239
- return counts;
240
- }
241
-
242
- /**
243
- * Cancel a pending or running task.
244
- *
245
- * @param taskId - Task to cancel.
246
- * @returns true if the task was cancelled, false if it was already terminal.
247
- */
248
- cancel(taskId: string): boolean {
249
- const result = this.db
250
- .query(
251
- "UPDATE background_tasks SET status = 'cancelled', completed_at = ? WHERE id = ? AND status IN ('pending', 'running')",
252
- )
253
- .run(Date.now(), taskId);
254
- return result.changes > 0;
255
- }
256
-
257
- /**
258
- * Delete terminal (completed/failed/cancelled) tasks older than `maxAgeMs`.
259
- * Prevents unbounded growth of background_tasks on long-running installs.
260
- *
261
- * Pending and running tasks are NEVER finalized regardless of age — they're
262
- * still live work. `maxAgeMs` is measured from `completed_at`, so a task that
263
- * just finished a moment ago is safe.
264
- *
265
- * @param maxAgeMs - Maximum age in milliseconds for terminal tasks.
266
- * @returns Number of rows deleted.
267
- */
268
- finalize(maxAgeMs: number): number {
269
- const cutoff = Date.now() - maxAgeMs;
270
- const result = this.db
271
- .query(
272
- `DELETE FROM background_tasks
273
- WHERE status IN ('completed', 'failed', 'cancelled')
274
- AND completed_at IS NOT NULL
275
- AND completed_at < ?`,
276
- )
277
- .run(cutoff);
278
- return result.changes;
279
- }
280
-
281
- /**
282
- * List all tasks for a specific agent, newest first.
283
- *
284
- * @param agent - Agent name to filter by.
285
- * @returns Array of tasks for that agent.
286
- */
287
- listByAgent(agent: string): BackgroundTask[] {
288
- const rows = this.db
289
- .query("SELECT * FROM background_tasks WHERE agent = ? ORDER BY created_at DESC")
290
- .all(agent) as BackgroundTaskRow[];
291
- return rows.map((r) => this.rowToTask(r));
292
- }
293
- }