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/worker/spawn.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { mkdir } from "node:fs/promises";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
getBotholomewDir,
|
|
5
|
-
getWorkerLogPath,
|
|
6
|
-
getWorkerLogsDir,
|
|
7
|
-
} from "../constants.ts";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { getConfigPath, getWorkerLogPath } from "../constants.ts";
|
|
8
4
|
import { uuidv7 } from "../db/uuid.ts";
|
|
9
5
|
import { logger } from "../utils/logger.ts";
|
|
6
|
+
import { dateForId } from "../utils/v7-date.ts";
|
|
10
7
|
import type { WorkerMode } from "./index.ts";
|
|
11
8
|
|
|
12
9
|
export interface SpawnWorkerOptions {
|
|
@@ -26,16 +23,19 @@ export async function spawnWorker(
|
|
|
26
23
|
projectDir: string,
|
|
27
24
|
options: SpawnWorkerOptions = {},
|
|
28
25
|
): Promise<{ pid: number; workerId: string; logPath: string }> {
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
if (!
|
|
26
|
+
const configPath = getConfigPath(projectDir);
|
|
27
|
+
const initialized = await Bun.file(configPath).exists();
|
|
28
|
+
if (!initialized) {
|
|
32
29
|
logger.error("Project not initialized. Run 'botholomew init' first.");
|
|
33
30
|
process.exit(1);
|
|
34
31
|
}
|
|
35
32
|
|
|
36
33
|
const workerId = uuidv7();
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
// Per-worker log path is derived from the worker id's UTC date so the
|
|
35
|
+
// logs/ tree stays browsable as workers accumulate. Mirrors the threads
|
|
36
|
+
// layout under <projectDir>/threads/.
|
|
37
|
+
const logPath = getWorkerLogPath(projectDir, workerId, dateForId(workerId));
|
|
38
|
+
await mkdir(dirname(logPath), { recursive: true });
|
|
39
39
|
const logFile = Bun.file(logPath);
|
|
40
40
|
|
|
41
41
|
const workerScript = new URL("./run.ts", import.meta.url).pathname;
|
package/src/worker/tick.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import type { McpxClient } from "@evantahler/mcpx";
|
|
2
2
|
import type { BotholomewConfig } from "../config/schemas.ts";
|
|
3
|
-
import {
|
|
3
|
+
import type { Task } from "../tasks/schema.ts";
|
|
4
4
|
import {
|
|
5
5
|
claimNextTask,
|
|
6
6
|
claimSpecificTask,
|
|
7
|
+
releaseTaskLock,
|
|
7
8
|
resetStaleTasks,
|
|
8
|
-
type Task,
|
|
9
9
|
updateTaskStatus,
|
|
10
|
-
} from "../
|
|
11
|
-
import { createThread, endThread, logInteraction } from "../
|
|
10
|
+
} from "../tasks/store.ts";
|
|
11
|
+
import { createThread, endThread, logInteraction } from "../threads/store.ts";
|
|
12
12
|
import { logger } from "../utils/logger.ts";
|
|
13
13
|
import { generateThreadTitle } from "../utils/title.ts";
|
|
14
14
|
import type { WorkerStreamCallbacks } from "./llm.ts";
|
|
@@ -46,9 +46,9 @@ export async function tick(opts: TickOptions): Promise<boolean> {
|
|
|
46
46
|
const tickStart = Date.now();
|
|
47
47
|
logger.phase("tick-start", `#${tickNum}`);
|
|
48
48
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
const resetIds = await resetStaleTasks(
|
|
50
|
+
projectDir,
|
|
51
|
+
config.max_tick_duration_seconds * 3,
|
|
52
52
|
);
|
|
53
53
|
if (resetIds.length > 0) {
|
|
54
54
|
logger.warn(
|
|
@@ -58,15 +58,14 @@ export async function tick(opts: TickOptions): Promise<boolean> {
|
|
|
58
58
|
|
|
59
59
|
if (evalSchedules) {
|
|
60
60
|
try {
|
|
61
|
-
await processSchedules(
|
|
61
|
+
await processSchedules(projectDir, config, workerId);
|
|
62
62
|
} catch (err) {
|
|
63
63
|
logger.error(`Schedule processing failed: ${err}`);
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
// Claim a task
|
|
68
67
|
logger.phase("claiming-task");
|
|
69
|
-
const task = await
|
|
68
|
+
const task = await claimNextTask(projectDir, workerId);
|
|
70
69
|
if (!task) {
|
|
71
70
|
logger.info("No task claimed (queue empty or all blocked)");
|
|
72
71
|
const elapsed = ((Date.now() - tickStart) / 1000).toFixed(1);
|
|
@@ -90,8 +89,7 @@ export async function tick(opts: TickOptions): Promise<boolean> {
|
|
|
90
89
|
|
|
91
90
|
/**
|
|
92
91
|
* Claim and run a single, explicitly-named task. Returns true if the task
|
|
93
|
-
* was claimed and processed, false if it wasn't eligible
|
|
94
|
-
* not pending, or doesn't exist).
|
|
92
|
+
* was claimed and processed, false if it wasn't eligible.
|
|
95
93
|
*/
|
|
96
94
|
export async function runSpecificTask(opts: {
|
|
97
95
|
projectDir: string;
|
|
@@ -102,8 +100,10 @@ export async function runSpecificTask(opts: {
|
|
|
102
100
|
mcpxClient?: McpxClient | null;
|
|
103
101
|
callbacks?: WorkerStreamCallbacks;
|
|
104
102
|
}): Promise<boolean> {
|
|
105
|
-
const task = await
|
|
106
|
-
|
|
103
|
+
const task = await claimSpecificTask(
|
|
104
|
+
opts.projectDir,
|
|
105
|
+
opts.taskId,
|
|
106
|
+
opts.workerId,
|
|
107
107
|
);
|
|
108
108
|
if (!task) {
|
|
109
109
|
logger.warn(
|
|
@@ -138,8 +138,11 @@ async function runClaimedTask(opts: {
|
|
|
138
138
|
}
|
|
139
139
|
callbacks?.onTaskStart(task);
|
|
140
140
|
|
|
141
|
-
const threadId = await
|
|
142
|
-
|
|
141
|
+
const threadId = await createThread(
|
|
142
|
+
projectDir,
|
|
143
|
+
"worker_tick",
|
|
144
|
+
task.id,
|
|
145
|
+
`Working: ${task.name}`,
|
|
143
146
|
);
|
|
144
147
|
|
|
145
148
|
const systemPrompt = await buildSystemPrompt(
|
|
@@ -163,47 +166,40 @@ async function runClaimedTask(opts: {
|
|
|
163
166
|
});
|
|
164
167
|
|
|
165
168
|
const isComplete = result.status === "complete";
|
|
166
|
-
await
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
isComplete ? result.reason : null,
|
|
173
|
-
),
|
|
169
|
+
await updateTaskStatus(
|
|
170
|
+
projectDir,
|
|
171
|
+
task.id,
|
|
172
|
+
result.status,
|
|
173
|
+
isComplete ? null : result.reason,
|
|
174
|
+
isComplete ? result.reason : null,
|
|
174
175
|
);
|
|
175
176
|
|
|
176
|
-
await
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
}),
|
|
182
|
-
);
|
|
177
|
+
await logInteraction(projectDir, threadId, {
|
|
178
|
+
role: "system",
|
|
179
|
+
kind: "status_change",
|
|
180
|
+
content: `Task ${task.id} -> ${result.status}${result.reason ? `: ${result.reason}` : ""}`,
|
|
181
|
+
});
|
|
183
182
|
|
|
184
183
|
logger.info(`Task ${task.id} -> ${result.status}`);
|
|
185
184
|
|
|
186
185
|
void generateThreadTitle(
|
|
187
186
|
config,
|
|
188
|
-
|
|
187
|
+
projectDir,
|
|
189
188
|
threadId,
|
|
190
189
|
`Task: ${task.name}\nDescription: ${task.description}\nOutcome: ${result.status}${result.reason ? ` — ${result.reason}` : ""}`,
|
|
191
190
|
);
|
|
192
191
|
} catch (err) {
|
|
193
|
-
await
|
|
194
|
-
updateTaskStatus(conn, task.id, "failed", String(err), null),
|
|
195
|
-
);
|
|
192
|
+
await updateTaskStatus(projectDir, task.id, "failed", String(err), null);
|
|
196
193
|
|
|
197
|
-
await
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
}),
|
|
203
|
-
);
|
|
194
|
+
await logInteraction(projectDir, threadId, {
|
|
195
|
+
role: "system",
|
|
196
|
+
kind: "status_change",
|
|
197
|
+
content: `Task ${task.id} failed: ${err}`,
|
|
198
|
+
});
|
|
204
199
|
|
|
205
200
|
logger.error(`Task ${task.id} failed: ${err}`);
|
|
206
201
|
} finally {
|
|
207
|
-
await
|
|
202
|
+
await releaseTaskLock(projectDir, task.id);
|
|
203
|
+
await endThread(projectDir, threadId);
|
|
208
204
|
}
|
|
209
205
|
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { readdir, stat, unlink } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getWorkersDir } from "../constants.ts";
|
|
4
|
+
import { atomicWrite, readWithMtime } from "../fs/atomic.ts";
|
|
5
|
+
|
|
6
|
+
export const WORKER_MODES = ["persist", "once"] as const;
|
|
7
|
+
export const WORKER_STATUSES = ["running", "stopped", "dead"] as const;
|
|
8
|
+
|
|
9
|
+
export type WorkerMode = (typeof WORKER_MODES)[number];
|
|
10
|
+
export type WorkerStatus = (typeof WORKER_STATUSES)[number];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Persistent worker record. One JSON file per worker at
|
|
14
|
+
* `<projectDir>/workers/<id>.json`. Heartbeats rewrite the file
|
|
15
|
+
* atomically (write-to-tmp + rename); the file's existence is the pidfile,
|
|
16
|
+
* `last_heartbeat_at` inside is the liveness signal.
|
|
17
|
+
*/
|
|
18
|
+
export interface Worker {
|
|
19
|
+
id: string;
|
|
20
|
+
pid: number;
|
|
21
|
+
hostname: string;
|
|
22
|
+
mode: WorkerMode;
|
|
23
|
+
task_id: string | null;
|
|
24
|
+
status: WorkerStatus;
|
|
25
|
+
started_at: string;
|
|
26
|
+
last_heartbeat_at: string;
|
|
27
|
+
stopped_at: string | null;
|
|
28
|
+
log_path: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function workerFilePath(projectDir: string, id: string): string {
|
|
32
|
+
return join(getWorkersDir(projectDir), `${id}.json`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readWorker(
|
|
36
|
+
projectDir: string,
|
|
37
|
+
id: string,
|
|
38
|
+
): Promise<Worker | null> {
|
|
39
|
+
const file = await readWithMtime(workerFilePath(projectDir, id));
|
|
40
|
+
if (!file) return null;
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(file.content) as Worker;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function writeWorker(projectDir: string, worker: Worker): Promise<void> {
|
|
49
|
+
await atomicWrite(
|
|
50
|
+
workerFilePath(projectDir, worker.id),
|
|
51
|
+
`${JSON.stringify(worker, null, 2)}\n`,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function registerWorker(
|
|
56
|
+
projectDir: string,
|
|
57
|
+
params: {
|
|
58
|
+
id: string;
|
|
59
|
+
pid: number;
|
|
60
|
+
hostname: string;
|
|
61
|
+
mode: WorkerMode;
|
|
62
|
+
taskId?: string | null;
|
|
63
|
+
logPath?: string | null;
|
|
64
|
+
},
|
|
65
|
+
): Promise<Worker> {
|
|
66
|
+
const now = new Date().toISOString();
|
|
67
|
+
const worker: Worker = {
|
|
68
|
+
id: params.id,
|
|
69
|
+
pid: params.pid,
|
|
70
|
+
hostname: params.hostname,
|
|
71
|
+
mode: params.mode,
|
|
72
|
+
task_id: params.taskId ?? null,
|
|
73
|
+
status: "running",
|
|
74
|
+
started_at: now,
|
|
75
|
+
last_heartbeat_at: now,
|
|
76
|
+
stopped_at: null,
|
|
77
|
+
log_path: params.logPath ?? null,
|
|
78
|
+
};
|
|
79
|
+
await writeWorker(projectDir, worker);
|
|
80
|
+
return worker;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Update last_heartbeat_at on a running worker. No-op for stopped/dead
|
|
85
|
+
* workers so a misbehaving heartbeat doesn't resurrect a worker the reaper
|
|
86
|
+
* has retired.
|
|
87
|
+
*/
|
|
88
|
+
export async function heartbeat(projectDir: string, id: string): Promise<void> {
|
|
89
|
+
const worker = await readWorker(projectDir, id);
|
|
90
|
+
if (!worker || worker.status !== "running") return;
|
|
91
|
+
worker.last_heartbeat_at = new Date().toISOString();
|
|
92
|
+
await writeWorker(projectDir, worker);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function markWorkerStopped(
|
|
96
|
+
projectDir: string,
|
|
97
|
+
id: string,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
const worker = await readWorker(projectDir, id);
|
|
100
|
+
if (!worker || worker.status !== "running") return;
|
|
101
|
+
worker.status = "stopped";
|
|
102
|
+
worker.stopped_at = new Date().toISOString();
|
|
103
|
+
await writeWorker(projectDir, worker);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function markWorkerDead(
|
|
107
|
+
projectDir: string,
|
|
108
|
+
id: string,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
const worker = await readWorker(projectDir, id);
|
|
111
|
+
if (!worker) return;
|
|
112
|
+
if (worker.status === "stopped") return; // don't overwrite a clean stop
|
|
113
|
+
worker.status = "dead";
|
|
114
|
+
worker.stopped_at = new Date().toISOString();
|
|
115
|
+
await writeWorker(projectDir, worker);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Walk `workers/`, mark any running worker as dead if its
|
|
120
|
+
* `last_heartbeat_at` is older than `staleAfterSeconds`. Tasks/schedules
|
|
121
|
+
* they held are reclaimed via the lockfile reapers driven by
|
|
122
|
+
* `isWorkerRunning`. Returns the ids that were just marked dead.
|
|
123
|
+
*/
|
|
124
|
+
export async function reapDeadWorkers(
|
|
125
|
+
projectDir: string,
|
|
126
|
+
staleAfterSeconds: number,
|
|
127
|
+
): Promise<string[]> {
|
|
128
|
+
const ids = await listWorkerIds(projectDir);
|
|
129
|
+
const cutoff = Date.now() - staleAfterSeconds * 1000;
|
|
130
|
+
const reaped: string[] = [];
|
|
131
|
+
for (const id of ids) {
|
|
132
|
+
const w = await readWorker(projectDir, id);
|
|
133
|
+
if (!w || w.status !== "running") continue;
|
|
134
|
+
const heartbeatMs = Date.parse(w.last_heartbeat_at);
|
|
135
|
+
if (Number.isFinite(heartbeatMs) && heartbeatMs >= cutoff) continue;
|
|
136
|
+
w.status = "dead";
|
|
137
|
+
w.stopped_at = new Date().toISOString();
|
|
138
|
+
await writeWorker(projectDir, w);
|
|
139
|
+
reaped.push(id);
|
|
140
|
+
}
|
|
141
|
+
return reaped;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function isWorkerRunning(
|
|
145
|
+
projectDir: string,
|
|
146
|
+
id: string,
|
|
147
|
+
): Promise<boolean> {
|
|
148
|
+
const w = await readWorker(projectDir, id);
|
|
149
|
+
return w?.status === "running";
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Delete cleanly-stopped worker JSON files whose `stopped_at` is older
|
|
154
|
+
* than `afterSeconds`. Dead workers are preserved as forensic evidence.
|
|
155
|
+
*/
|
|
156
|
+
export async function pruneStoppedWorkers(
|
|
157
|
+
projectDir: string,
|
|
158
|
+
afterSeconds: number,
|
|
159
|
+
): Promise<string[]> {
|
|
160
|
+
const ids = await listWorkerIds(projectDir);
|
|
161
|
+
const cutoff = Date.now() - afterSeconds * 1000;
|
|
162
|
+
const pruned: string[] = [];
|
|
163
|
+
for (const id of ids) {
|
|
164
|
+
const w = await readWorker(projectDir, id);
|
|
165
|
+
if (!w || w.status !== "stopped" || !w.stopped_at) continue;
|
|
166
|
+
const stoppedMs = Date.parse(w.stopped_at);
|
|
167
|
+
if (Number.isFinite(stoppedMs) && stoppedMs >= cutoff) continue;
|
|
168
|
+
try {
|
|
169
|
+
await unlink(workerFilePath(projectDir, id));
|
|
170
|
+
pruned.push(id);
|
|
171
|
+
} catch {
|
|
172
|
+
// ignore — concurrent delete is fine
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return pruned;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function listWorkers(
|
|
179
|
+
projectDir: string,
|
|
180
|
+
filters?: {
|
|
181
|
+
status?: WorkerStatus;
|
|
182
|
+
limit?: number;
|
|
183
|
+
offset?: number;
|
|
184
|
+
},
|
|
185
|
+
): Promise<Worker[]> {
|
|
186
|
+
const ids = await listWorkerIds(projectDir);
|
|
187
|
+
const out: Worker[] = [];
|
|
188
|
+
for (const id of ids) {
|
|
189
|
+
const w = await readWorker(projectDir, id);
|
|
190
|
+
if (!w) continue;
|
|
191
|
+
if (filters?.status && w.status !== filters.status) continue;
|
|
192
|
+
out.push(w);
|
|
193
|
+
}
|
|
194
|
+
out.sort((a, b) => (a.started_at < b.started_at ? 1 : -1));
|
|
195
|
+
const offset = filters?.offset ?? 0;
|
|
196
|
+
const limit = filters?.limit ?? out.length;
|
|
197
|
+
return out.slice(offset, offset + limit);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function getWorker(
|
|
201
|
+
projectDir: string,
|
|
202
|
+
id: string,
|
|
203
|
+
): Promise<Worker | null> {
|
|
204
|
+
return readWorker(projectDir, id);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function deleteWorker(
|
|
208
|
+
projectDir: string,
|
|
209
|
+
id: string,
|
|
210
|
+
): Promise<boolean> {
|
|
211
|
+
try {
|
|
212
|
+
await unlink(workerFilePath(projectDir, id));
|
|
213
|
+
return true;
|
|
214
|
+
} catch (err) {
|
|
215
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function listWorkerIds(projectDir: string): Promise<string[]> {
|
|
221
|
+
const dir = getWorkersDir(projectDir);
|
|
222
|
+
try {
|
|
223
|
+
const names = await readdir(dir);
|
|
224
|
+
return names
|
|
225
|
+
.filter((n) => n.endsWith(".json"))
|
|
226
|
+
.map((n) => n.slice(0, -".json".length));
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* For tests / doctor: confirm a worker JSON file exists at the expected path.
|
|
235
|
+
*/
|
|
236
|
+
export async function workerFileExists(
|
|
237
|
+
projectDir: string,
|
|
238
|
+
id: string,
|
|
239
|
+
): Promise<boolean> {
|
|
240
|
+
try {
|
|
241
|
+
await stat(workerFilePath(projectDir, id));
|
|
242
|
+
return true;
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
}
|