botholomew 0.12.3 → 0.13.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 (104) hide show
  1. package/README.md +91 -68
  2. package/package.json +3 -3
  3. package/src/chat/agent.ts +42 -82
  4. package/src/chat/session.ts +29 -25
  5. package/src/commands/capabilities.ts +1 -1
  6. package/src/commands/context.ts +177 -926
  7. package/src/commands/db.ts +9 -13
  8. package/src/commands/init.ts +4 -1
  9. package/src/commands/nuke.ts +57 -90
  10. package/src/commands/schedule.ts +103 -124
  11. package/src/commands/skill.ts +2 -2
  12. package/src/commands/task.ts +86 -95
  13. package/src/commands/thread.ts +107 -112
  14. package/src/commands/worker.ts +88 -88
  15. package/src/constants.ts +93 -16
  16. package/src/context/capabilities.ts +10 -10
  17. package/src/context/fetcher.ts +9 -10
  18. package/src/context/reindex.ts +189 -0
  19. package/src/context/store.ts +630 -0
  20. package/src/db/doctor.ts +1 -8
  21. package/src/db/embeddings.ts +227 -175
  22. package/src/db/sql/19-disk_backed_index.sql +36 -0
  23. package/src/db/sql/20-drop_db_tables_for_files.sql +19 -0
  24. package/src/fs/atomic.ts +217 -0
  25. package/src/fs/compat.ts +86 -0
  26. package/src/fs/sandbox.ts +279 -0
  27. package/src/init/index.ts +69 -52
  28. package/src/init/templates.ts +1 -1
  29. package/src/mcpx/client.ts +1 -1
  30. package/src/schedules/schema.ts +19 -0
  31. package/src/schedules/store.ts +296 -0
  32. package/src/skills/commands.ts +1 -3
  33. package/src/tasks/schema.ts +47 -0
  34. package/src/tasks/store.ts +486 -0
  35. package/src/threads/store.ts +559 -0
  36. package/src/tools/capabilities/refresh.ts +42 -21
  37. package/src/tools/context/pipe.ts +15 -71
  38. package/src/tools/context/update-beliefs.ts +3 -3
  39. package/src/tools/context/update-goals.ts +3 -3
  40. package/src/tools/dir/create.ts +26 -23
  41. package/src/tools/dir/size.ts +46 -17
  42. package/src/tools/dir/tree.ts +73 -279
  43. package/src/tools/file/copy.ts +50 -24
  44. package/src/tools/file/count-lines.ts +34 -10
  45. package/src/tools/file/delete.ts +44 -23
  46. package/src/tools/file/edit.ts +39 -14
  47. package/src/tools/file/exists.ts +12 -26
  48. package/src/tools/file/info.ts +25 -85
  49. package/src/tools/file/move.ts +39 -24
  50. package/src/tools/file/read.ts +32 -80
  51. package/src/tools/file/write.ts +14 -91
  52. package/src/tools/registry.ts +3 -7
  53. package/src/tools/schedule/create.ts +2 -2
  54. package/src/tools/schedule/list.ts +7 -3
  55. package/src/tools/search/fuse.ts +12 -33
  56. package/src/tools/search/index.ts +36 -43
  57. package/src/tools/search/regexp.ts +29 -17
  58. package/src/tools/search/semantic.ts +137 -51
  59. package/src/tools/skill/delete.ts +1 -1
  60. package/src/tools/skill/list.ts +1 -1
  61. package/src/tools/skill/write.ts +1 -1
  62. package/src/tools/task/create.ts +41 -16
  63. package/src/tools/task/delete.ts +3 -3
  64. package/src/tools/task/list.ts +6 -3
  65. package/src/tools/task/update.ts +31 -9
  66. package/src/tools/task/view.ts +6 -6
  67. package/src/tools/thread/list.ts +2 -2
  68. package/src/tools/thread/search.ts +208 -0
  69. package/src/tools/thread/view.ts +50 -5
  70. package/src/tools/worker/spawn.ts +28 -14
  71. package/src/tui/App.tsx +12 -19
  72. package/src/tui/components/ContextPanel.tsx +83 -316
  73. package/src/tui/components/SchedulePanel.tsx +34 -48
  74. package/src/tui/components/StatusBar.tsx +15 -15
  75. package/src/tui/components/TaskPanel.tsx +34 -38
  76. package/src/tui/components/ThreadPanel.tsx +29 -38
  77. package/src/tui/components/WorkerPanel.tsx +21 -19
  78. package/src/tui/markdown.ts +2 -8
  79. package/src/types/file-imports.d.ts +9 -0
  80. package/src/utils/title.ts +5 -7
  81. package/src/utils/v7-date.ts +47 -0
  82. package/src/worker/heartbeat.ts +46 -24
  83. package/src/worker/index.ts +13 -15
  84. package/src/worker/llm.ts +30 -37
  85. package/src/worker/prompt.ts +19 -41
  86. package/src/worker/schedules.ts +48 -69
  87. package/src/worker/spawn.ts +11 -11
  88. package/src/worker/tick.ts +39 -43
  89. package/src/workers/store.ts +247 -0
  90. package/src/commands/tools.ts +0 -367
  91. package/src/context/describer.ts +0 -140
  92. package/src/context/drives.ts +0 -110
  93. package/src/context/ingest.ts +0 -162
  94. package/src/context/refresh.ts +0 -183
  95. package/src/db/context.ts +0 -637
  96. package/src/db/daemon-state.ts +0 -6
  97. package/src/db/reembed.ts +0 -113
  98. package/src/db/schedules.ts +0 -213
  99. package/src/db/tasks.ts +0 -347
  100. package/src/db/threads.ts +0 -276
  101. package/src/db/workers.ts +0 -212
  102. package/src/tools/context/list-drives.ts +0 -36
  103. package/src/tools/context/refresh.ts +0 -165
  104. package/src/tools/context/search.ts +0 -54
package/src/db/reembed.ts DELETED
@@ -1,113 +0,0 @@
1
- import type { BotholomewConfig } from "../config/schemas.ts";
2
- import { embed } from "../context/embedder.ts";
3
- import { logger } from "../utils/logger.ts";
4
- import { withDb } from "./connection.ts";
5
- import { rebuildSearchIndex } from "./embeddings.ts";
6
-
7
- interface PendingRow {
8
- id: string;
9
- chunk_content: string | null;
10
- title: string;
11
- description: string;
12
- drive: string | null;
13
- path: string | null;
14
- }
15
-
16
- const BATCH_SIZE = 32;
17
-
18
- function buildEmbeddingInput(row: PendingRow): string {
19
- const parts: string[] = [];
20
- if (row.title) parts.push(`Title: ${row.title}`);
21
- if (row.description) parts.push(`Description: ${row.description}`);
22
- if (row.drive && row.path) parts.push(`Source: ${row.drive}:${row.path}`);
23
- if (row.chunk_content) parts.push(row.chunk_content);
24
- return parts.join("\n");
25
- }
26
-
27
- interface ReembedOptions {
28
- /**
29
- * `"missing"` (default) — only re-embed rows where `embedding IS NULL`.
30
- * `"all"` — re-embed every row, including ones that already have a vector.
31
- * Use this after changing `embedding_model` so old vectors don't
32
- * sit alongside new ones in a different space.
33
- */
34
- mode?: "missing" | "all";
35
- }
36
-
37
- /**
38
- * Recompute embeddings for rows in the embeddings table.
39
- *
40
- * Default mode (`"missing"`) only touches NULL rows — the case after migration
41
- * 18 leaves existing rows with no vector. The `context reembed` CLI command
42
- * passes `mode: "all"` to force a full rebuild after the user changes
43
- * `embedding_model`.
44
- *
45
- * Each batch is its own withDb so the file lock releases between embedding
46
- * calls — long sweeps don't block other workers from acquiring the DB.
47
- */
48
- export async function reembedMissingVectors(
49
- dbPath: string,
50
- config: Required<BotholomewConfig>,
51
- options: ReembedOptions = {},
52
- ): Promise<void> {
53
- const mode = options.mode ?? "missing";
54
- const filter = mode === "all" ? "" : "WHERE embedding IS NULL";
55
-
56
- const total = await withDb(dbPath, async (conn) => {
57
- const row = await conn.queryGet<{ count: number }>(
58
- `SELECT count(*)::INTEGER AS count FROM embeddings ${filter}`,
59
- );
60
- return row?.count ?? 0;
61
- });
62
-
63
- if (total === 0) {
64
- logger.info("No embeddings to recompute.");
65
- return;
66
- }
67
-
68
- logger.info(
69
- `re-embedding ${total} row${total === 1 ? "" : "s"} with model ${config.embedding_model}`,
70
- );
71
-
72
- let processed = 0;
73
- while (processed < total) {
74
- const batch = await withDb(dbPath, async (conn) => {
75
- const offsetClause = mode === "all" ? `LIMIT ?1 OFFSET ?2` : `LIMIT ?1`;
76
- const sql = `SELECT e.id, e.chunk_content, e.title, e.description, ci.drive, ci.path
77
- FROM embeddings e
78
- LEFT JOIN context_items ci ON ci.id = e.context_item_id
79
- ${filter}
80
- ORDER BY e.id
81
- ${offsetClause}`;
82
- return mode === "all"
83
- ? conn.queryAll<PendingRow>(sql, BATCH_SIZE, processed)
84
- : conn.queryAll<PendingRow>(sql, BATCH_SIZE);
85
- });
86
-
87
- if (batch.length === 0) break;
88
-
89
- const inputs = batch.map(buildEmbeddingInput);
90
- const vectors = await embed(inputs, config);
91
-
92
- await withDb(dbPath, async (conn) => {
93
- for (let i = 0; i < batch.length; i++) {
94
- const row = batch[i];
95
- const vec = vectors[i];
96
- if (!row || !vec) continue;
97
- await conn.queryRun(
98
- `UPDATE embeddings
99
- SET embedding = ?1::FLOAT[${config.embedding_dimension}]
100
- WHERE id = ?2`,
101
- vec,
102
- row.id,
103
- );
104
- }
105
- });
106
-
107
- processed += batch.length;
108
- logger.info(` re-embedded ${processed}/${total}`);
109
- }
110
-
111
- await withDb(dbPath, (conn) => rebuildSearchIndex(conn));
112
- logger.success(`re-embed complete (${processed} rows)`);
113
- }
@@ -1,213 +0,0 @@
1
- import type { DbConnection } from "./connection.ts";
2
- import { buildSetClauses, buildWhereClause, sanitizeInt } from "./query.ts";
3
- import { uuidv7 } from "./uuid.ts";
4
-
5
- export interface Schedule {
6
- id: string;
7
- name: string;
8
- description: string;
9
- frequency: string;
10
- last_run_at: Date | null;
11
- enabled: boolean;
12
- claimed_by: string | null;
13
- claimed_at: Date | null;
14
- created_at: Date;
15
- updated_at: Date;
16
- }
17
-
18
- interface ScheduleRow {
19
- id: string;
20
- name: string;
21
- description: string;
22
- frequency: string;
23
- last_run_at: string | null;
24
- enabled: boolean;
25
- claimed_by: string | null;
26
- claimed_at: string | null;
27
- created_at: string;
28
- updated_at: string;
29
- }
30
-
31
- function rowToSchedule(row: ScheduleRow): Schedule {
32
- return {
33
- id: row.id,
34
- name: row.name,
35
- description: row.description,
36
- frequency: row.frequency,
37
- last_run_at: row.last_run_at ? new Date(row.last_run_at) : null,
38
- enabled: !!row.enabled,
39
- claimed_by: row.claimed_by ?? null,
40
- claimed_at: row.claimed_at ? new Date(row.claimed_at) : null,
41
- created_at: new Date(row.created_at),
42
- updated_at: new Date(row.updated_at),
43
- };
44
- }
45
-
46
- export async function createSchedule(
47
- db: DbConnection,
48
- params: {
49
- name: string;
50
- description?: string;
51
- frequency: string;
52
- },
53
- ): Promise<Schedule> {
54
- const id = uuidv7();
55
- const row = await db.queryGet<ScheduleRow>(
56
- `INSERT INTO schedules (id, name, description, frequency)
57
- VALUES (?1, ?2, ?3, ?4)
58
- RETURNING *`,
59
- id,
60
- params.name,
61
- params.description ?? "",
62
- params.frequency,
63
- );
64
- if (!row) throw new Error("INSERT did not return a row");
65
- return rowToSchedule(row);
66
- }
67
-
68
- export async function getSchedule(
69
- db: DbConnection,
70
- id: string,
71
- ): Promise<Schedule | null> {
72
- const row = await db.queryGet<ScheduleRow>(
73
- "SELECT * FROM schedules WHERE id = ?1",
74
- id,
75
- );
76
- return row ? rowToSchedule(row) : null;
77
- }
78
-
79
- export async function listSchedules(
80
- db: DbConnection,
81
- filters?: { enabled?: boolean; limit?: number; offset?: number },
82
- ): Promise<Schedule[]> {
83
- const { where, params } = buildWhereClause([
84
- [
85
- "enabled",
86
- filters?.enabled !== undefined ? (filters.enabled ? 1 : 0) : undefined,
87
- ],
88
- ]);
89
- const limit = filters?.limit ? `LIMIT ${sanitizeInt(filters.limit)}` : "";
90
- const offset = filters?.offset ? `OFFSET ${sanitizeInt(filters.offset)}` : "";
91
-
92
- const rows = await db.queryAll<ScheduleRow>(
93
- `SELECT * FROM schedules ${where}
94
- ORDER BY created_at ASC, id ASC
95
- ${limit} ${offset}`,
96
- ...params,
97
- );
98
- return rows.map(rowToSchedule);
99
- }
100
-
101
- export async function updateSchedule(
102
- db: DbConnection,
103
- id: string,
104
- updates: Partial<
105
- Pick<Schedule, "name" | "description" | "frequency" | "enabled">
106
- >,
107
- ): Promise<Schedule | null> {
108
- const { setClauses, params } = buildSetClauses([
109
- ["name", updates.name],
110
- ["description", updates.description],
111
- ["frequency", updates.frequency],
112
- [
113
- "enabled",
114
- updates.enabled !== undefined ? (updates.enabled ? 1 : 0) : undefined,
115
- ],
116
- ]);
117
-
118
- if (setClauses.length === 0) {
119
- return getSchedule(db, id);
120
- }
121
-
122
- setClauses.push("updated_at = current_timestamp::VARCHAR");
123
- params.push(id);
124
-
125
- const row = await db.queryGet<ScheduleRow>(
126
- `UPDATE schedules SET ${setClauses.join(", ")} WHERE id = ?${params.length} RETURNING *`,
127
- ...params,
128
- );
129
- return row ? rowToSchedule(row) : null;
130
- }
131
-
132
- export async function deleteSchedule(
133
- db: DbConnection,
134
- id: string,
135
- ): Promise<boolean> {
136
- const result = await db.queryRun("DELETE FROM schedules WHERE id = ?1", id);
137
- return result.changes > 0;
138
- }
139
-
140
- export async function deleteAllSchedules(db: DbConnection): Promise<number> {
141
- const result = await db.queryRun("DELETE FROM schedules");
142
- return result.changes;
143
- }
144
-
145
- export async function markScheduleRun(
146
- db: DbConnection,
147
- id: string,
148
- ): Promise<void> {
149
- await db.queryRun(
150
- `UPDATE schedules SET last_run_at = current_timestamp::VARCHAR, updated_at = current_timestamp::VARCHAR WHERE id = ?1`,
151
- id,
152
- );
153
- }
154
-
155
- /**
156
- * Atomically claim a schedule for evaluation. Returns the schedule if
157
- * successfully claimed, or null if another worker already holds the claim
158
- * or the schedule ran too recently to re-evaluate.
159
- *
160
- * `staleAfterSeconds`: how long an existing claim is considered still-held
161
- * before another worker may steal it (protects against crashed claimers).
162
- * `minIntervalSeconds`: minimum gap since `last_run_at` before re-evaluation.
163
- */
164
- export async function claimSchedule(
165
- db: DbConnection,
166
- id: string,
167
- claimedBy: string,
168
- opts: { staleAfterSeconds: number; minIntervalSeconds: number },
169
- ): Promise<Schedule | null> {
170
- const row = await db.queryGet<ScheduleRow>(
171
- `UPDATE schedules
172
- SET claimed_by = ?1,
173
- claimed_at = current_timestamp::VARCHAR
174
- WHERE id = ?2
175
- AND enabled = true
176
- AND (
177
- claimed_by IS NULL
178
- OR claimed_at IS NULL
179
- OR claimed_at::TIMESTAMP
180
- < current_timestamp - to_seconds(CAST(?3 AS BIGINT))
181
- )
182
- AND (
183
- last_run_at IS NULL
184
- OR last_run_at::TIMESTAMP
185
- < current_timestamp - to_seconds(CAST(?4 AS BIGINT))
186
- )
187
- RETURNING *`,
188
- claimedBy,
189
- id,
190
- opts.staleAfterSeconds,
191
- opts.minIntervalSeconds,
192
- );
193
- return row ? rowToSchedule(row) : null;
194
- }
195
-
196
- /**
197
- * Release a schedule claim without modifying `last_run_at`. Safe to call
198
- * even if the claim has already expired — the WHERE guard ensures we only
199
- * clear our own claim.
200
- */
201
- export async function releaseSchedule(
202
- db: DbConnection,
203
- id: string,
204
- claimedBy: string,
205
- ): Promise<void> {
206
- await db.queryRun(
207
- `UPDATE schedules
208
- SET claimed_by = NULL, claimed_at = NULL
209
- WHERE id = ?1 AND claimed_by = ?2`,
210
- id,
211
- claimedBy,
212
- );
213
- }
package/src/db/tasks.ts DELETED
@@ -1,347 +0,0 @@
1
- import type { DbConnection } from "./connection.ts";
2
- import { buildSetClauses, buildWhereClause, sanitizeInt } from "./query.ts";
3
- import { uuidv7 } from "./uuid.ts";
4
-
5
- export const TASK_PRIORITIES = ["low", "medium", "high"] as const;
6
- export const TASK_STATUSES = [
7
- "pending",
8
- "in_progress",
9
- "failed",
10
- "complete",
11
- "waiting",
12
- ] as const;
13
-
14
- export interface Task {
15
- id: string;
16
- name: string;
17
- description: string;
18
- priority: (typeof TASK_PRIORITIES)[number];
19
- status: (typeof TASK_STATUSES)[number];
20
- waiting_reason: string | null;
21
- claimed_by: string | null;
22
- claimed_at: Date | null;
23
- blocked_by: string[];
24
- context_ids: string[];
25
- output: string | null;
26
- created_at: Date;
27
- updated_at: Date;
28
- }
29
-
30
- interface TaskRow {
31
- id: string;
32
- name: string;
33
- description: string;
34
- priority: string;
35
- status: string;
36
- waiting_reason: string | null;
37
- claimed_by: string | null;
38
- claimed_at: string | null;
39
- blocked_by: string;
40
- context_ids: string;
41
- output: string | null;
42
- created_at: string;
43
- updated_at: string;
44
- }
45
-
46
- function rowToTask(row: TaskRow): Task {
47
- return {
48
- id: row.id,
49
- name: row.name,
50
- description: row.description,
51
- priority: row.priority as Task["priority"],
52
- status: row.status as Task["status"],
53
- waiting_reason: row.waiting_reason,
54
- claimed_by: row.claimed_by,
55
- claimed_at: row.claimed_at ? new Date(row.claimed_at) : null,
56
- blocked_by: JSON.parse(row.blocked_by || "[]"),
57
- context_ids: JSON.parse(row.context_ids || "[]"),
58
- output: row.output,
59
- created_at: new Date(row.created_at),
60
- updated_at: new Date(row.updated_at),
61
- };
62
- }
63
-
64
- export async function createTask(
65
- db: DbConnection,
66
- params: {
67
- name: string;
68
- description?: string;
69
- priority?: Task["priority"];
70
- blocked_by?: string[];
71
- context_ids?: string[];
72
- },
73
- ): Promise<Task> {
74
- const id = uuidv7();
75
- const blockedByArr = params.blocked_by ?? [];
76
- await validateBlockedBy(db, id, blockedByArr);
77
- const blockedBy = JSON.stringify(blockedByArr);
78
- const contextIds = JSON.stringify(params.context_ids ?? []);
79
-
80
- const row = await db.queryGet<TaskRow>(
81
- `INSERT INTO tasks (id, name, description, priority, blocked_by, context_ids)
82
- VALUES (?1, ?2, ?3, ?4, ?5, ?6)
83
- RETURNING *`,
84
- id,
85
- params.name,
86
- params.description ?? "",
87
- params.priority ?? "medium",
88
- blockedBy,
89
- contextIds,
90
- );
91
- if (!row) throw new Error("INSERT did not return a row");
92
- return rowToTask(row);
93
- }
94
-
95
- export async function getTask(
96
- db: DbConnection,
97
- id: string,
98
- ): Promise<Task | null> {
99
- const row = await db.queryGet<TaskRow>(
100
- "SELECT * FROM tasks WHERE id = ?1",
101
- id,
102
- );
103
- return row ? rowToTask(row) : null;
104
- }
105
-
106
- export async function listTasks(
107
- db: DbConnection,
108
- filters?: {
109
- status?: Task["status"];
110
- priority?: Task["priority"];
111
- limit?: number;
112
- offset?: number;
113
- },
114
- ): Promise<Task[]> {
115
- const { where, params } = buildWhereClause([
116
- ["status", filters?.status],
117
- ["priority", filters?.priority],
118
- ]);
119
- const limit = filters?.limit ? `LIMIT ${sanitizeInt(filters.limit)}` : "";
120
- const offset = filters?.offset ? `OFFSET ${sanitizeInt(filters.offset)}` : "";
121
-
122
- const rows = await db.queryAll<TaskRow>(
123
- `SELECT * FROM tasks ${where}
124
- ORDER BY created_at DESC, id DESC
125
- ${limit} ${offset}`,
126
- ...params,
127
- );
128
- return rows.map(rowToTask);
129
- }
130
-
131
- export async function updateTaskStatus(
132
- db: DbConnection,
133
- id: string,
134
- status: Task["status"],
135
- reason?: string | null,
136
- output?: string | null,
137
- ): Promise<void> {
138
- await db.queryRun(
139
- `UPDATE tasks
140
- SET status = ?1, waiting_reason = ?2, output = ?3, updated_at = current_timestamp::VARCHAR
141
- WHERE id = ?4`,
142
- status,
143
- reason ?? null,
144
- output ?? null,
145
- id,
146
- );
147
- }
148
-
149
- export async function validateBlockedBy(
150
- db: DbConnection,
151
- taskId: string,
152
- blockedBy: string[],
153
- ): Promise<void> {
154
- if (blockedBy.length === 0) return;
155
-
156
- // Check for direct self-reference
157
- if (blockedBy.includes(taskId)) {
158
- throw new Error(`Circular dependency: task ${taskId} cannot block itself`);
159
- }
160
-
161
- // DFS through transitive blocked_by chains
162
- const visited = new Set<string>();
163
-
164
- async function dfs(currentId: string): Promise<void> {
165
- if (visited.has(currentId)) return;
166
- visited.add(currentId);
167
-
168
- const task = await getTask(db, currentId);
169
- if (!task) return;
170
-
171
- for (const dep of task.blocked_by) {
172
- if (dep === taskId) {
173
- throw new Error(
174
- `Circular dependency: adding blocked_by would create cycle involving task ${taskId}`,
175
- );
176
- }
177
- await dfs(dep);
178
- }
179
- }
180
-
181
- for (const blockerId of blockedBy) {
182
- await dfs(blockerId);
183
- }
184
- }
185
-
186
- export async function updateTask(
187
- db: DbConnection,
188
- id: string,
189
- updates: Partial<
190
- Pick<Task, "name" | "description" | "priority" | "status" | "blocked_by">
191
- >,
192
- ): Promise<Task | null> {
193
- if (updates.blocked_by !== undefined) {
194
- await validateBlockedBy(db, id, updates.blocked_by);
195
- }
196
-
197
- const { setClauses, params } = buildSetClauses([
198
- ["name", updates.name],
199
- ["description", updates.description],
200
- ["priority", updates.priority],
201
- ["status", updates.status],
202
- [
203
- "blocked_by",
204
- updates.blocked_by !== undefined
205
- ? JSON.stringify(updates.blocked_by)
206
- : undefined,
207
- ],
208
- ]);
209
-
210
- if (setClauses.length === 0) {
211
- return getTask(db, id);
212
- }
213
-
214
- setClauses.push("updated_at = current_timestamp::VARCHAR");
215
- params.push(id);
216
-
217
- const row = await db.queryGet<TaskRow>(
218
- `UPDATE tasks SET ${setClauses.join(", ")} WHERE id = ?${params.length} RETURNING *`,
219
- ...params,
220
- );
221
- return row ? rowToTask(row) : null;
222
- }
223
-
224
- export async function deleteTask(
225
- db: DbConnection,
226
- id: string,
227
- ): Promise<boolean> {
228
- const result = await db.queryRun("DELETE FROM tasks WHERE id = ?1", id);
229
- return result.changes > 0;
230
- }
231
-
232
- export async function deleteAllTasks(db: DbConnection): Promise<number> {
233
- const result = await db.queryRun("DELETE FROM tasks");
234
- return result.changes;
235
- }
236
-
237
- export async function resetTask(
238
- db: DbConnection,
239
- id: string,
240
- ): Promise<Task | null> {
241
- const row = await db.queryGet<TaskRow>(
242
- `UPDATE tasks
243
- SET status = 'pending', claimed_by = NULL, claimed_at = NULL,
244
- waiting_reason = NULL, output = NULL, updated_at = current_timestamp::VARCHAR
245
- WHERE id = ?1
246
- RETURNING *`,
247
- id,
248
- );
249
- return row ? rowToTask(row) : null;
250
- }
251
-
252
- export async function resetStaleTasks(
253
- db: DbConnection,
254
- timeoutSeconds: number,
255
- ): Promise<string[]> {
256
- const rows = await db.queryAll<{ id: string }>(
257
- `UPDATE tasks
258
- SET status = 'pending',
259
- claimed_by = NULL,
260
- claimed_at = NULL,
261
- updated_at = current_timestamp::VARCHAR
262
- WHERE status = 'in_progress'
263
- AND claimed_at IS NOT NULL
264
- AND claimed_at::TIMESTAMP < current_timestamp - to_seconds(CAST(?1 AS BIGINT))
265
- RETURNING id`,
266
- timeoutSeconds,
267
- );
268
- return rows.map((r) => r.id);
269
- }
270
-
271
- export async function claimNextTask(
272
- db: DbConnection,
273
- claimedBy: string,
274
- ): Promise<Task | null> {
275
- // Find highest-priority unblocked pending task
276
- // Use application-level filtering for blocked_by since DuckDB doesn't have json_each
277
- const allPending = await db.queryAll<TaskRow>(
278
- `SELECT * FROM tasks
279
- WHERE status = 'pending'
280
- ORDER BY
281
- CASE priority WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 END,
282
- created_at ASC`,
283
- );
284
-
285
- for (const row of allPending) {
286
- const blockedBy: string[] = JSON.parse(row.blocked_by || "[]");
287
- if (blockedBy.length > 0) {
288
- // Check if all blockers are complete
289
- let allComplete = true;
290
- for (const blockerId of blockedBy) {
291
- const blocker = await db.queryGet<{ status: string }>(
292
- "SELECT status FROM tasks WHERE id = ?1",
293
- blockerId,
294
- );
295
- if (!blocker || blocker.status !== "complete") {
296
- allComplete = false;
297
- break;
298
- }
299
- }
300
- if (!allComplete) continue;
301
- }
302
-
303
- // Attempt atomic claim — RETURNING confirms we actually got it
304
- const claimed = await db.queryGet<TaskRow>(
305
- `UPDATE tasks
306
- SET status = 'in_progress',
307
- claimed_by = ?1,
308
- claimed_at = current_timestamp::VARCHAR,
309
- updated_at = current_timestamp::VARCHAR
310
- WHERE id = ?2 AND status = 'pending'
311
- RETURNING *`,
312
- claimedBy,
313
- row.id,
314
- );
315
-
316
- if (claimed) {
317
- return rowToTask(claimed);
318
- }
319
- // Another process claimed it — try next candidate
320
- }
321
-
322
- return null;
323
- }
324
-
325
- /**
326
- * Atomically claim a specific task by id. Returns the task if successfully
327
- * claimed, or null if the task doesn't exist, is already claimed, or isn't
328
- * in `pending` state.
329
- */
330
- export async function claimSpecificTask(
331
- db: DbConnection,
332
- taskId: string,
333
- claimedBy: string,
334
- ): Promise<Task | null> {
335
- const row = await db.queryGet<TaskRow>(
336
- `UPDATE tasks
337
- SET status = 'in_progress',
338
- claimed_by = ?1,
339
- claimed_at = current_timestamp::VARCHAR,
340
- updated_at = current_timestamp::VARCHAR
341
- WHERE id = ?2 AND status = 'pending'
342
- RETURNING *`,
343
- claimedBy,
344
- taskId,
345
- );
346
- return row ? rowToTask(row) : null;
347
- }