botholomew 0.12.5 → 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.
- package/README.md +91 -68
- package/package.json +2 -2
- package/src/chat/agent.ts +42 -82
- package/src/chat/session.ts +29 -25
- package/src/commands/capabilities.ts +1 -1
- package/src/commands/context.ts +177 -926
- package/src/commands/db.ts +9 -13
- package/src/commands/init.ts +4 -1
- package/src/commands/nuke.ts +57 -90
- package/src/commands/schedule.ts +103 -124
- package/src/commands/skill.ts +2 -2
- package/src/commands/task.ts +86 -95
- package/src/commands/thread.ts +107 -112
- package/src/commands/worker.ts +88 -88
- package/src/constants.ts +93 -16
- package/src/context/capabilities.ts +10 -10
- package/src/context/fetcher.ts +9 -10
- package/src/context/reindex.ts +189 -0
- package/src/context/store.ts +630 -0
- package/src/db/doctor.ts +1 -8
- package/src/db/embeddings.ts +227 -175
- package/src/db/sql/19-disk_backed_index.sql +36 -0
- package/src/db/sql/20-drop_db_tables_for_files.sql +19 -0
- package/src/fs/atomic.ts +217 -0
- package/src/fs/compat.ts +86 -0
- package/src/fs/sandbox.ts +279 -0
- package/src/init/index.ts +69 -52
- package/src/init/templates.ts +1 -1
- package/src/mcpx/client.ts +1 -1
- package/src/schedules/schema.ts +19 -0
- package/src/schedules/store.ts +296 -0
- package/src/skills/commands.ts +1 -3
- package/src/tasks/schema.ts +47 -0
- package/src/tasks/store.ts +486 -0
- package/src/threads/store.ts +559 -0
- package/src/tools/capabilities/refresh.ts +42 -21
- package/src/tools/context/pipe.ts +15 -71
- package/src/tools/context/update-beliefs.ts +3 -3
- package/src/tools/context/update-goals.ts +3 -3
- package/src/tools/dir/create.ts +26 -23
- package/src/tools/dir/size.ts +46 -17
- package/src/tools/dir/tree.ts +73 -279
- package/src/tools/file/copy.ts +50 -24
- package/src/tools/file/count-lines.ts +34 -10
- package/src/tools/file/delete.ts +44 -23
- package/src/tools/file/edit.ts +39 -14
- package/src/tools/file/exists.ts +12 -26
- package/src/tools/file/info.ts +25 -85
- package/src/tools/file/move.ts +39 -24
- package/src/tools/file/read.ts +32 -80
- package/src/tools/file/write.ts +14 -91
- package/src/tools/registry.ts +3 -7
- package/src/tools/schedule/create.ts +2 -2
- package/src/tools/schedule/list.ts +7 -3
- package/src/tools/search/fuse.ts +12 -33
- package/src/tools/search/index.ts +36 -43
- package/src/tools/search/regexp.ts +29 -17
- package/src/tools/search/semantic.ts +137 -51
- package/src/tools/skill/delete.ts +1 -1
- package/src/tools/skill/list.ts +1 -1
- package/src/tools/skill/write.ts +1 -1
- package/src/tools/task/create.ts +41 -16
- package/src/tools/task/delete.ts +3 -3
- package/src/tools/task/list.ts +6 -3
- package/src/tools/task/update.ts +31 -9
- package/src/tools/task/view.ts +6 -6
- package/src/tools/thread/list.ts +2 -2
- package/src/tools/thread/search.ts +208 -0
- package/src/tools/thread/view.ts +50 -5
- package/src/tools/worker/spawn.ts +28 -14
- package/src/tui/App.tsx +12 -19
- package/src/tui/components/ContextPanel.tsx +83 -316
- package/src/tui/components/SchedulePanel.tsx +34 -48
- package/src/tui/components/StatusBar.tsx +15 -15
- package/src/tui/components/TaskPanel.tsx +34 -38
- package/src/tui/components/ThreadPanel.tsx +29 -38
- package/src/tui/components/WorkerPanel.tsx +21 -19
- package/src/tui/markdown.ts +2 -8
- package/src/utils/title.ts +5 -7
- package/src/utils/v7-date.ts +47 -0
- package/src/worker/heartbeat.ts +46 -24
- package/src/worker/index.ts +13 -15
- package/src/worker/llm.ts +30 -37
- package/src/worker/prompt.ts +19 -41
- package/src/worker/schedules.ts +48 -69
- package/src/worker/spawn.ts +11 -11
- package/src/worker/tick.ts +39 -43
- package/src/workers/store.ts +247 -0
- package/src/commands/tools.ts +0 -367
- package/src/context/describer.ts +0 -140
- package/src/context/drives.ts +0 -110
- package/src/context/ingest.ts +0 -162
- package/src/context/refresh.ts +0 -183
- package/src/db/context.ts +0 -637
- package/src/db/daemon-state.ts +0 -6
- package/src/db/reembed.ts +0 -113
- package/src/db/schedules.ts +0 -213
- package/src/db/tasks.ts +0 -347
- package/src/db/threads.ts +0 -276
- package/src/db/workers.ts +0 -212
- package/src/tools/context/list-drives.ts +0 -36
- package/src/tools/context/refresh.ts +0 -165
- 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
|
-
}
|
package/src/db/schedules.ts
DELETED
|
@@ -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
|
-
}
|