botholomew 0.7.8 → 0.7.9
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 +2 -0
- package/package.json +1 -1
- package/src/chat/agent.ts +68 -35
- package/src/chat/session.ts +27 -31
- package/src/commands/daemon.ts +15 -2
- package/src/commands/schedule.ts +10 -1
- package/src/commands/skill.ts +18 -3
- package/src/commands/task.ts +28 -4
- package/src/commands/thread.ts +3 -1
- package/src/commands/tools.ts +2 -0
- package/src/commands/with-db.ts +8 -9
- package/src/context/fetcher.ts +1 -0
- package/src/context/ingest.ts +3 -1
- package/src/daemon/index.ts +6 -5
- package/src/daemon/llm.ts +68 -42
- package/src/daemon/prompt.ts +6 -4
- package/src/daemon/schedules.ts +15 -10
- package/src/daemon/tick.ts +54 -38
- package/src/db/connection.ts +143 -14
- package/src/db/schedules.ts +7 -3
- package/src/db/tasks.ts +4 -4
- package/src/db/threads.ts +6 -4
- package/src/tools/tool.ts +8 -0
- package/src/tui/App.tsx +16 -11
- package/src/tui/components/ContextPanel.tsx +19 -15
- package/src/tui/components/SchedulePanel.tsx +15 -9
- package/src/tui/components/StatusBar.tsx +8 -6
- package/src/tui/components/TaskPanel.tsx +6 -6
- package/src/tui/components/ThreadPanel.tsx +29 -19
- package/src/utils/title.ts +5 -3
package/src/daemon/llm.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
} from "@anthropic-ai/sdk/resources/messages";
|
|
8
8
|
import type { McpxClient } from "@evantahler/mcpx";
|
|
9
9
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
10
|
-
import
|
|
10
|
+
import { withDb } from "../db/connection.ts";
|
|
11
11
|
import { getTask, type Task } from "../db/tasks.ts";
|
|
12
12
|
import { logInteraction } from "../db/threads.ts";
|
|
13
13
|
import { registerAllTools } from "../tools/registry.ts";
|
|
@@ -44,32 +44,32 @@ export async function runAgentLoop(input: {
|
|
|
44
44
|
systemPrompt: string;
|
|
45
45
|
task: Task;
|
|
46
46
|
config: Required<BotholomewConfig>;
|
|
47
|
-
|
|
47
|
+
dbPath: string;
|
|
48
48
|
threadId: string;
|
|
49
49
|
projectDir: string;
|
|
50
50
|
mcpxClient?: McpxClient | null;
|
|
51
51
|
callbacks?: DaemonStreamCallbacks;
|
|
52
52
|
}): Promise<AgentLoopResult> {
|
|
53
|
-
const {
|
|
54
|
-
|
|
53
|
+
const {
|
|
54
|
+
systemPrompt,
|
|
55
|
+
task,
|
|
56
|
+
config,
|
|
57
|
+
dbPath,
|
|
58
|
+
threadId,
|
|
59
|
+
projectDir,
|
|
60
|
+
callbacks,
|
|
61
|
+
} = input;
|
|
55
62
|
|
|
56
63
|
const client = new Anthropic({
|
|
57
64
|
apiKey: config.anthropic_api_key || undefined,
|
|
58
65
|
});
|
|
59
66
|
|
|
60
|
-
const toolCtx: ToolContext = {
|
|
61
|
-
conn,
|
|
62
|
-
projectDir,
|
|
63
|
-
config,
|
|
64
|
-
mcpxClient: input.mcpxClient ?? null,
|
|
65
|
-
};
|
|
66
|
-
|
|
67
67
|
// Build predecessor context from completed blocking tasks
|
|
68
68
|
let predecessorContext = "";
|
|
69
69
|
if (task.blocked_by.length > 0) {
|
|
70
70
|
const predecessorOutputs: string[] = [];
|
|
71
71
|
for (const blockerId of task.blocked_by) {
|
|
72
|
-
const blocker = await getTask(conn, blockerId);
|
|
72
|
+
const blocker = await withDb(dbPath, (conn) => getTask(conn, blockerId));
|
|
73
73
|
if (blocker?.output) {
|
|
74
74
|
predecessorOutputs.push(
|
|
75
75
|
`### ${blocker.name} (${blocker.id})\n${blocker.output}`,
|
|
@@ -86,11 +86,13 @@ export async function runAgentLoop(input: {
|
|
|
86
86
|
const messages: MessageParam[] = [{ role: "user", content: userMessage }];
|
|
87
87
|
|
|
88
88
|
// Log the initial user message
|
|
89
|
-
await
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
89
|
+
await withDb(dbPath, (conn) =>
|
|
90
|
+
logInteraction(conn, threadId, {
|
|
91
|
+
role: "user",
|
|
92
|
+
kind: "message",
|
|
93
|
+
content: userMessage,
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
94
96
|
|
|
95
97
|
clearLargeResults();
|
|
96
98
|
const daemonTools = toAnthropicTools();
|
|
@@ -144,13 +146,15 @@ export async function runAgentLoop(input: {
|
|
|
144
146
|
// Log assistant text blocks
|
|
145
147
|
for (const block of response.content) {
|
|
146
148
|
if (block.type === "text" && block.text) {
|
|
147
|
-
await
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
149
|
+
await withDb(dbPath, (conn) =>
|
|
150
|
+
logInteraction(conn, threadId, {
|
|
151
|
+
role: "assistant",
|
|
152
|
+
kind: "message",
|
|
153
|
+
content: block.text,
|
|
154
|
+
durationMs,
|
|
155
|
+
tokenCount,
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
154
158
|
}
|
|
155
159
|
}
|
|
156
160
|
|
|
@@ -173,20 +177,30 @@ export async function runAgentLoop(input: {
|
|
|
173
177
|
for (const toolUse of toolUseBlocks) {
|
|
174
178
|
const toolInput = JSON.stringify(toolUse.input);
|
|
175
179
|
callbacks?.onToolStart(toolUse.name, toolInput);
|
|
176
|
-
await
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
180
|
+
await withDb(dbPath, (conn) =>
|
|
181
|
+
logInteraction(conn, threadId, {
|
|
182
|
+
role: "assistant",
|
|
183
|
+
kind: "tool_use",
|
|
184
|
+
content: `Calling ${toolUse.name}`,
|
|
185
|
+
toolName: toolUse.name,
|
|
186
|
+
toolInput,
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
183
189
|
}
|
|
184
190
|
|
|
185
|
-
// Execute all tools in parallel
|
|
191
|
+
// Execute all tools in parallel. Each tool call opens its own short-lived
|
|
192
|
+
// connection (or none, if the tool uses dbPath internally) via
|
|
193
|
+
// executeToolCall — so parallel tool calls share the process-local
|
|
194
|
+
// DuckDB instance and release the file lock as soon as they finish.
|
|
186
195
|
const execResults = await Promise.all(
|
|
187
196
|
toolUseBlocks.map(async (toolUse) => {
|
|
188
197
|
const start = Date.now();
|
|
189
|
-
const result = await executeToolCall(toolUse,
|
|
198
|
+
const result = await executeToolCall(toolUse, {
|
|
199
|
+
dbPath,
|
|
200
|
+
projectDir,
|
|
201
|
+
config,
|
|
202
|
+
mcpxClient: input.mcpxClient ?? null,
|
|
203
|
+
});
|
|
190
204
|
const elapsed = Date.now() - start;
|
|
191
205
|
callbacks?.onToolEnd(
|
|
192
206
|
toolUse.name,
|
|
@@ -201,13 +215,15 @@ export async function runAgentLoop(input: {
|
|
|
201
215
|
// Log results and collect tool_result messages
|
|
202
216
|
const toolResults: ToolResultBlockParam[] = [];
|
|
203
217
|
for (const { toolUse, result, durationMs } of execResults) {
|
|
204
|
-
await
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
218
|
+
await withDb(dbPath, (conn) =>
|
|
219
|
+
logInteraction(conn, threadId, {
|
|
220
|
+
role: "tool",
|
|
221
|
+
kind: "tool_result",
|
|
222
|
+
content: result.output,
|
|
223
|
+
toolName: toolUse.name,
|
|
224
|
+
durationMs,
|
|
225
|
+
}),
|
|
226
|
+
);
|
|
211
227
|
|
|
212
228
|
if (result.terminal && result.agentResult) {
|
|
213
229
|
return result.agentResult;
|
|
@@ -234,9 +250,16 @@ interface ToolCallResult {
|
|
|
234
250
|
agentResult?: AgentLoopResult;
|
|
235
251
|
}
|
|
236
252
|
|
|
253
|
+
interface ToolCallCtx {
|
|
254
|
+
dbPath: string;
|
|
255
|
+
projectDir: string;
|
|
256
|
+
config: Required<BotholomewConfig>;
|
|
257
|
+
mcpxClient: McpxClient | null;
|
|
258
|
+
}
|
|
259
|
+
|
|
237
260
|
async function executeToolCall(
|
|
238
261
|
toolUse: ToolUseBlock,
|
|
239
|
-
|
|
262
|
+
baseCtx: ToolCallCtx,
|
|
240
263
|
): Promise<ToolCallResult> {
|
|
241
264
|
const tool = getTool(toolUse.name);
|
|
242
265
|
if (!tool) {
|
|
@@ -261,7 +284,10 @@ async function executeToolCall(
|
|
|
261
284
|
|
|
262
285
|
let result: unknown;
|
|
263
286
|
try {
|
|
264
|
-
result = await
|
|
287
|
+
result = await withDb(baseCtx.dbPath, (conn) => {
|
|
288
|
+
const ctx: ToolContext = { ...baseCtx, conn };
|
|
289
|
+
return tool.execute(parsed.data, ctx);
|
|
290
|
+
});
|
|
265
291
|
} catch (err) {
|
|
266
292
|
return {
|
|
267
293
|
output: `Tool ${toolUse.name} threw an error: ${err}. You may retry with different parameters or try an alternative approach.`,
|
package/src/daemon/prompt.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
4
4
|
import { getBotholomewDir } from "../constants.ts";
|
|
5
5
|
import { embedSingle } from "../context/embedder.ts";
|
|
6
|
-
import
|
|
6
|
+
import { withDb } from "../db/connection.ts";
|
|
7
7
|
import { hybridSearch } from "../db/embeddings.ts";
|
|
8
8
|
import type { Task } from "../db/tasks.ts";
|
|
9
9
|
import { parseContextFile } from "../utils/frontmatter.ts";
|
|
@@ -89,7 +89,7 @@ export function buildMetaHeader(projectDir: string): string[] {
|
|
|
89
89
|
export async function buildSystemPrompt(
|
|
90
90
|
projectDir: string,
|
|
91
91
|
task?: Task,
|
|
92
|
-
|
|
92
|
+
dbPath?: string,
|
|
93
93
|
_config?: Required<BotholomewConfig>,
|
|
94
94
|
options?: { hasMcpTools?: boolean },
|
|
95
95
|
): Promise<string> {
|
|
@@ -107,11 +107,13 @@ export async function buildSystemPrompt(
|
|
|
107
107
|
parts.push(...(await loadPersistentContext(projectDir, taskKeywords)));
|
|
108
108
|
|
|
109
109
|
// Relevant context from embeddings search
|
|
110
|
-
if (task &&
|
|
110
|
+
if (task && dbPath && _config?.openai_api_key) {
|
|
111
111
|
try {
|
|
112
112
|
const query = `${task.name} ${task.description}`;
|
|
113
113
|
const queryVec = await embedSingle(query, _config);
|
|
114
|
-
const results = await
|
|
114
|
+
const results = await withDb(dbPath, (conn) =>
|
|
115
|
+
hybridSearch(conn, query, queryVec, 5),
|
|
116
|
+
);
|
|
115
117
|
|
|
116
118
|
if (results.length > 0) {
|
|
117
119
|
parts.push("## Relevant Context");
|
package/src/daemon/schedules.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Anthropic from "@anthropic-ai/sdk";
|
|
2
2
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
3
|
-
import
|
|
3
|
+
import { withDb } from "../db/connection.ts";
|
|
4
4
|
import {
|
|
5
5
|
listSchedules,
|
|
6
6
|
markScheduleRun,
|
|
@@ -105,14 +105,17 @@ Is this schedule due to run? If yes, what tasks should be created?`;
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export async function processSchedules(
|
|
108
|
-
|
|
108
|
+
dbPath: string,
|
|
109
109
|
config: Required<BotholomewConfig>,
|
|
110
110
|
): Promise<void> {
|
|
111
|
-
const schedules = await
|
|
111
|
+
const schedules = await withDb(dbPath, (conn) =>
|
|
112
|
+
listSchedules(conn, { enabled: true }),
|
|
113
|
+
);
|
|
112
114
|
if (schedules.length === 0) return;
|
|
113
115
|
|
|
114
116
|
for (const schedule of schedules) {
|
|
115
117
|
try {
|
|
118
|
+
// LLM evaluation does no DB work — no connection held here.
|
|
116
119
|
const evaluation = await evaluateSchedule(config, schedule);
|
|
117
120
|
|
|
118
121
|
if (!evaluation.isDue) {
|
|
@@ -128,16 +131,18 @@ export async function processSchedules(
|
|
|
128
131
|
.map((i: number) => createdIds[i])
|
|
129
132
|
.filter(Boolean) as string[];
|
|
130
133
|
|
|
131
|
-
const task = await
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
const task = await withDb(dbPath, (conn) =>
|
|
135
|
+
createTask(conn, {
|
|
136
|
+
name: taskDef.name,
|
|
137
|
+
description: taskDef.description,
|
|
138
|
+
priority: taskDef.priority,
|
|
139
|
+
blocked_by: blockedBy,
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
137
142
|
createdIds.push(task.id);
|
|
138
143
|
}
|
|
139
144
|
|
|
140
|
-
await markScheduleRun(conn, schedule.id);
|
|
145
|
+
await withDb(dbPath, (conn) => markScheduleRun(conn, schedule.id));
|
|
141
146
|
logger.info(
|
|
142
147
|
`Schedule "${schedule.name}" fired, created ${createdIds.length} task(s)`,
|
|
143
148
|
);
|
package/src/daemon/tick.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { McpxClient } from "@evantahler/mcpx";
|
|
2
2
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
3
|
-
import
|
|
3
|
+
import { withDb } from "../db/connection.ts";
|
|
4
4
|
import { listSchedules } from "../db/schedules.ts";
|
|
5
5
|
import {
|
|
6
6
|
claimNextTask,
|
|
@@ -17,7 +17,7 @@ import { processSchedules } from "./schedules.ts";
|
|
|
17
17
|
|
|
18
18
|
export async function tick(
|
|
19
19
|
projectDir: string,
|
|
20
|
-
|
|
20
|
+
dbPath: string,
|
|
21
21
|
config: Required<BotholomewConfig>,
|
|
22
22
|
mcpxClient?: McpxClient | null,
|
|
23
23
|
callbacks?: DaemonStreamCallbacks,
|
|
@@ -27,9 +27,8 @@ export async function tick(
|
|
|
27
27
|
logger.phase("tick-start", `#${tickNum}`);
|
|
28
28
|
|
|
29
29
|
// Reset stale tasks stuck in in_progress
|
|
30
|
-
const resetIds = await
|
|
31
|
-
conn,
|
|
32
|
-
config.max_tick_duration_seconds * 3,
|
|
30
|
+
const resetIds = await withDb(dbPath, (conn) =>
|
|
31
|
+
resetStaleTasks(conn, config.max_tick_duration_seconds * 3),
|
|
33
32
|
);
|
|
34
33
|
if (resetIds.length > 0) {
|
|
35
34
|
logger.warn(
|
|
@@ -37,12 +36,16 @@ export async function tick(
|
|
|
37
36
|
);
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
// Process schedules (may create new tasks)
|
|
41
|
-
|
|
39
|
+
// Process schedules (may create new tasks). Check enabled count so we only
|
|
40
|
+
// log the phase when there's work to evaluate — the call itself is a no-op
|
|
41
|
+
// otherwise.
|
|
42
|
+
const enabledSchedules = await withDb(dbPath, (conn) =>
|
|
43
|
+
listSchedules(conn, { enabled: true }),
|
|
44
|
+
);
|
|
42
45
|
if (enabledSchedules.length > 0) {
|
|
43
46
|
logger.phase("evaluating-schedules", `${enabledSchedules.length} enabled`);
|
|
44
47
|
try {
|
|
45
|
-
await processSchedules(
|
|
48
|
+
await processSchedules(dbPath, config);
|
|
46
49
|
} catch (err) {
|
|
47
50
|
logger.error(`Schedule processing failed: ${err}`);
|
|
48
51
|
}
|
|
@@ -50,7 +53,7 @@ export async function tick(
|
|
|
50
53
|
|
|
51
54
|
// Claim a task
|
|
52
55
|
logger.phase("claiming-task");
|
|
53
|
-
const task = await claimNextTask(conn);
|
|
56
|
+
const task = await withDb(dbPath, (conn) => claimNextTask(conn));
|
|
54
57
|
if (!task) {
|
|
55
58
|
logger.info("No task claimed (queue empty or all blocked)");
|
|
56
59
|
const elapsed = ((Date.now() - tickStart) / 1000).toFixed(1);
|
|
@@ -62,67 +65,80 @@ export async function tick(
|
|
|
62
65
|
callbacks?.onTaskStart(task);
|
|
63
66
|
|
|
64
67
|
// Create a thread for this tick
|
|
65
|
-
const threadId = await
|
|
66
|
-
conn,
|
|
67
|
-
"daemon_tick",
|
|
68
|
-
task.id,
|
|
69
|
-
`Working: ${task.name}`,
|
|
68
|
+
const threadId = await withDb(dbPath, (conn) =>
|
|
69
|
+
createThread(conn, "daemon_tick", task.id, `Working: ${task.name}`),
|
|
70
70
|
);
|
|
71
71
|
|
|
72
72
|
// Build system prompt (includes task-relevant context from embeddings)
|
|
73
|
-
const systemPrompt = await buildSystemPrompt(
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
const systemPrompt = await buildSystemPrompt(
|
|
74
|
+
projectDir,
|
|
75
|
+
task,
|
|
76
|
+
dbPath,
|
|
77
|
+
config,
|
|
78
|
+
{
|
|
79
|
+
hasMcpTools: mcpxClient != null,
|
|
80
|
+
},
|
|
81
|
+
);
|
|
76
82
|
|
|
77
83
|
try {
|
|
78
84
|
const result = await runAgentLoop({
|
|
79
85
|
systemPrompt,
|
|
80
86
|
task,
|
|
81
87
|
config,
|
|
82
|
-
|
|
88
|
+
dbPath,
|
|
83
89
|
threadId,
|
|
84
90
|
projectDir,
|
|
85
91
|
mcpxClient,
|
|
86
92
|
callbacks,
|
|
87
93
|
});
|
|
88
94
|
|
|
95
|
+
// Update task status and store output. Only completed tasks have an
|
|
96
|
+
// `output`; waiting/failed tasks put their reason in `waiting_reason`.
|
|
89
97
|
const isComplete = result.status === "complete";
|
|
90
|
-
await
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
await withDb(dbPath, (conn) =>
|
|
99
|
+
updateTaskStatus(
|
|
100
|
+
conn,
|
|
101
|
+
task.id,
|
|
102
|
+
result.status,
|
|
103
|
+
isComplete ? null : result.reason,
|
|
104
|
+
isComplete ? result.reason : null,
|
|
105
|
+
),
|
|
96
106
|
);
|
|
97
107
|
|
|
98
108
|
// Log the status change
|
|
99
|
-
await
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
109
|
+
await withDb(dbPath, (conn) =>
|
|
110
|
+
logInteraction(conn, threadId, {
|
|
111
|
+
role: "system",
|
|
112
|
+
kind: "status_change",
|
|
113
|
+
content: `Task ${task.id} -> ${result.status}${result.reason ? `: ${result.reason}` : ""}`,
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
104
116
|
|
|
105
117
|
logger.info(`Task ${task.id} -> ${result.status}`);
|
|
106
118
|
|
|
107
|
-
// Generate a descriptive title for the thread
|
|
119
|
+
// Generate a descriptive title for the thread (fire-and-forget)
|
|
108
120
|
void generateThreadTitle(
|
|
109
121
|
config,
|
|
110
|
-
|
|
122
|
+
dbPath,
|
|
111
123
|
threadId,
|
|
112
124
|
`Task: ${task.name}\nDescription: ${task.description}\nOutcome: ${result.status}${result.reason ? ` — ${result.reason}` : ""}`,
|
|
113
125
|
);
|
|
114
126
|
} catch (err) {
|
|
115
|
-
await
|
|
127
|
+
await withDb(dbPath, (conn) =>
|
|
128
|
+
updateTaskStatus(conn, task.id, "failed", String(err), null),
|
|
129
|
+
);
|
|
116
130
|
|
|
117
|
-
await
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
131
|
+
await withDb(dbPath, (conn) =>
|
|
132
|
+
logInteraction(conn, threadId, {
|
|
133
|
+
role: "system",
|
|
134
|
+
kind: "status_change",
|
|
135
|
+
content: `Task ${task.id} failed: ${err}`,
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
122
138
|
|
|
123
139
|
logger.error(`Task ${task.id} failed: ${err}`);
|
|
124
140
|
} finally {
|
|
125
|
-
await endThread(conn, threadId);
|
|
141
|
+
await withDb(dbPath, (conn) => endThread(conn, threadId));
|
|
126
142
|
}
|
|
127
143
|
|
|
128
144
|
const elapsed = ((Date.now() - tickStart) / 1000).toFixed(1);
|
package/src/db/connection.ts
CHANGED
|
@@ -11,12 +11,20 @@ export class DbConnection {
|
|
|
11
11
|
// biome-ignore lint/suspicious/noExplicitAny: DuckDB internal types
|
|
12
12
|
private conn: any;
|
|
13
13
|
// biome-ignore lint/suspicious/noExplicitAny: DuckDB internal types
|
|
14
|
-
private
|
|
14
|
+
private readonly ownedInstance: any;
|
|
15
|
+
private readonly dbPath: string;
|
|
16
|
+
private closed = false;
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
+
constructor(
|
|
19
|
+
// biome-ignore lint/suspicious/noExplicitAny: DuckDB internal types
|
|
20
|
+
conn: any,
|
|
21
|
+
// biome-ignore lint/suspicious/noExplicitAny: DuckDB internal types
|
|
22
|
+
ownedInstance: any,
|
|
23
|
+
dbPath: string,
|
|
24
|
+
) {
|
|
18
25
|
this.conn = conn;
|
|
19
|
-
this.
|
|
26
|
+
this.ownedInstance = ownedInstance;
|
|
27
|
+
this.dbPath = dbPath;
|
|
20
28
|
}
|
|
21
29
|
|
|
22
30
|
/** Execute raw SQL with no return value. */
|
|
@@ -62,10 +70,22 @@ export class DbConnection {
|
|
|
62
70
|
return { changes: result.rowsChanged };
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
/**
|
|
73
|
+
/**
|
|
74
|
+
* Disconnect and release this connection's share of the DuckDB instance.
|
|
75
|
+
* For file-backed DBs, the instance is closed (and the OS file lock
|
|
76
|
+
* released) once every overlapping connection in this process has closed.
|
|
77
|
+
* For `:memory:` DBs, the instance is owned by this connection and closed
|
|
78
|
+
* immediately.
|
|
79
|
+
*/
|
|
66
80
|
close(): void {
|
|
81
|
+
if (this.closed) return;
|
|
82
|
+
this.closed = true;
|
|
67
83
|
this.conn.disconnectSync();
|
|
68
|
-
this.
|
|
84
|
+
if (this.ownedInstance) {
|
|
85
|
+
this.ownedInstance.closeSync();
|
|
86
|
+
} else {
|
|
87
|
+
releaseInstance(this.dbPath);
|
|
88
|
+
}
|
|
69
89
|
}
|
|
70
90
|
}
|
|
71
91
|
|
|
@@ -98,31 +118,140 @@ function flattenParams(params: SqlParam[]): SqlParam[] {
|
|
|
98
118
|
return params.map((p) => (Array.isArray(p) ? JSON.stringify(p) : p));
|
|
99
119
|
}
|
|
100
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Refcounted, process-local cache of open DuckDB instances keyed by dbPath.
|
|
123
|
+
*
|
|
124
|
+
* DuckDB's file lock is held at the instance level, so we must close the
|
|
125
|
+
* instance — not just the connection — to let another process acquire the
|
|
126
|
+
* writer lock. At the same time, opening two instances for the same file
|
|
127
|
+
* from one process is unsafe. This cache resolves both: overlapping
|
|
128
|
+
* `getConnection` calls in the same process share a single instance; once
|
|
129
|
+
* every connection has closed, the instance is closed and evicted, which
|
|
130
|
+
* releases the OS file lock.
|
|
131
|
+
*
|
|
132
|
+
* `:memory:` paths bypass the cache so each test/caller gets its own
|
|
133
|
+
* isolated in-memory database.
|
|
134
|
+
*/
|
|
135
|
+
interface CachedInstance {
|
|
136
|
+
// biome-ignore lint/suspicious/noExplicitAny: DuckDB internal types
|
|
137
|
+
instance: any;
|
|
138
|
+
refCount: number;
|
|
139
|
+
}
|
|
140
|
+
const instanceCache = new Map<string, CachedInstance>();
|
|
141
|
+
const pendingInstance = new Map<string, Promise<CachedInstance>>();
|
|
142
|
+
|
|
143
|
+
function isMemoryPath(path: string): boolean {
|
|
144
|
+
return path === ":memory:" || path.startsWith(":memory:");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function acquireSharedInstance(dbPath: string): Promise<CachedInstance> {
|
|
148
|
+
const existing = instanceCache.get(dbPath);
|
|
149
|
+
if (existing) {
|
|
150
|
+
existing.refCount += 1;
|
|
151
|
+
return existing;
|
|
152
|
+
}
|
|
153
|
+
const inFlight = pendingInstance.get(dbPath);
|
|
154
|
+
if (inFlight) {
|
|
155
|
+
const cached = await inFlight;
|
|
156
|
+
cached.refCount += 1;
|
|
157
|
+
return cached;
|
|
158
|
+
}
|
|
159
|
+
const creation = (async () => {
|
|
160
|
+
const instance = await DuckDBInstance.create(dbPath);
|
|
161
|
+
const cached: CachedInstance = { instance, refCount: 1 };
|
|
162
|
+
instanceCache.set(dbPath, cached);
|
|
163
|
+
return cached;
|
|
164
|
+
})();
|
|
165
|
+
pendingInstance.set(dbPath, creation);
|
|
166
|
+
try {
|
|
167
|
+
return await creation;
|
|
168
|
+
} finally {
|
|
169
|
+
pendingInstance.delete(dbPath);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function releaseInstance(dbPath: string): void {
|
|
174
|
+
const cached = instanceCache.get(dbPath);
|
|
175
|
+
if (!cached) return;
|
|
176
|
+
cached.refCount -= 1;
|
|
177
|
+
if (cached.refCount <= 0) {
|
|
178
|
+
instanceCache.delete(dbPath);
|
|
179
|
+
cached.instance.closeSync();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
101
183
|
export async function getConnection(dbPath?: string): Promise<DbConnection> {
|
|
102
|
-
const
|
|
103
|
-
const conn = await instance.connect();
|
|
184
|
+
const path = dbPath ?? ":memory:";
|
|
104
185
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
186
|
+
if (isMemoryPath(path)) {
|
|
187
|
+
const instance = await DuckDBInstance.create(path);
|
|
188
|
+
const conn = await instance.connect();
|
|
189
|
+
await conn.run("INSTALL vss; LOAD vss;");
|
|
190
|
+
await conn.run("SET hnsw_enable_experimental_persistence = true;");
|
|
191
|
+
return new DbConnection(conn, instance, path);
|
|
192
|
+
}
|
|
109
193
|
|
|
110
|
-
|
|
194
|
+
const cached = await acquireSharedInstance(path);
|
|
195
|
+
try {
|
|
196
|
+
const conn = await cached.instance.connect();
|
|
197
|
+
// INSTALL is a no-op after the first successful install (the extension
|
|
198
|
+
// is persisted to the user's DuckDB extension directory). LOAD is
|
|
199
|
+
// cheap per connection.
|
|
200
|
+
await conn.run("INSTALL vss; LOAD vss;");
|
|
201
|
+
await conn.run("SET hnsw_enable_experimental_persistence = true;");
|
|
202
|
+
return new DbConnection(conn, null, path);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
releaseInstance(path);
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
111
207
|
}
|
|
112
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Open a DuckDB connection for a single logical unit of work and guarantee
|
|
211
|
+
* it is closed afterward. Retries on lock conflicts so two processes that
|
|
212
|
+
* race on the file lock cooperate instead of failing hard.
|
|
213
|
+
*
|
|
214
|
+
* Prefer one `withDb` per logical operation. The file lock is only released
|
|
215
|
+
* when every connection (across this process's overlapping callers) has
|
|
216
|
+
* been closed, so holding the connection across non-DB work (LLM calls,
|
|
217
|
+
* network I/O, filesystem walks) keeps other processes blocked.
|
|
218
|
+
*/
|
|
219
|
+
export async function withDb<T>(
|
|
220
|
+
dbPath: string,
|
|
221
|
+
fn: (conn: DbConnection) => Promise<T>,
|
|
222
|
+
): Promise<T> {
|
|
223
|
+
const conn = await withRetry(() => getConnection(dbPath));
|
|
224
|
+
try {
|
|
225
|
+
return await fn(conn);
|
|
226
|
+
} finally {
|
|
227
|
+
conn.close();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Retry `fn` with exponential backoff when it fails with a DuckDB file-lock
|
|
233
|
+
* conflict ("Conflicting lock is held…"). Other errors propagate immediately.
|
|
234
|
+
*/
|
|
113
235
|
export async function withRetry<T>(
|
|
114
236
|
fn: () => Promise<T>,
|
|
115
|
-
maxRetries =
|
|
237
|
+
maxRetries = 8,
|
|
116
238
|
): Promise<T> {
|
|
117
239
|
let lastError: unknown;
|
|
118
240
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
119
241
|
try {
|
|
120
242
|
return await fn();
|
|
121
243
|
} catch (err) {
|
|
244
|
+
if (!isLockConflict(err)) throw err;
|
|
122
245
|
lastError = err;
|
|
123
246
|
if (attempt === maxRetries - 1) throw err;
|
|
247
|
+
// 100, 200, 400, 800, 1600, 3200, 6400, 12800 — up to ~25s total
|
|
124
248
|
await Bun.sleep(100 * 2 ** attempt);
|
|
125
249
|
}
|
|
126
250
|
}
|
|
127
251
|
throw lastError;
|
|
128
252
|
}
|
|
253
|
+
|
|
254
|
+
function isLockConflict(err: unknown): boolean {
|
|
255
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
256
|
+
return msg.includes("Conflicting lock") || msg.includes("could not be set");
|
|
257
|
+
}
|
package/src/db/schedules.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DbConnection } from "./connection.ts";
|
|
2
|
-
import { buildSetClauses, buildWhereClause } from "./query.ts";
|
|
2
|
+
import { buildSetClauses, buildWhereClause, sanitizeInt } from "./query.ts";
|
|
3
3
|
import { uuidv7 } from "./uuid.ts";
|
|
4
4
|
|
|
5
5
|
export interface Schedule {
|
|
@@ -72,7 +72,7 @@ export async function getSchedule(
|
|
|
72
72
|
|
|
73
73
|
export async function listSchedules(
|
|
74
74
|
db: DbConnection,
|
|
75
|
-
filters?: { enabled?: boolean },
|
|
75
|
+
filters?: { enabled?: boolean; limit?: number; offset?: number },
|
|
76
76
|
): Promise<Schedule[]> {
|
|
77
77
|
const { where, params } = buildWhereClause([
|
|
78
78
|
[
|
|
@@ -80,9 +80,13 @@ export async function listSchedules(
|
|
|
80
80
|
filters?.enabled !== undefined ? (filters.enabled ? 1 : 0) : undefined,
|
|
81
81
|
],
|
|
82
82
|
]);
|
|
83
|
+
const limit = filters?.limit ? `LIMIT ${sanitizeInt(filters.limit)}` : "";
|
|
84
|
+
const offset = filters?.offset ? `OFFSET ${sanitizeInt(filters.offset)}` : "";
|
|
83
85
|
|
|
84
86
|
const rows = await db.queryAll<ScheduleRow>(
|
|
85
|
-
`SELECT * FROM schedules ${where}
|
|
87
|
+
`SELECT * FROM schedules ${where}
|
|
88
|
+
ORDER BY created_at ASC, id ASC
|
|
89
|
+
${limit} ${offset}`,
|
|
86
90
|
...params,
|
|
87
91
|
);
|
|
88
92
|
return rows.map(rowToSchedule);
|