maestro-agent 0.0.1 → 0.0.2

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 (94) hide show
  1. package/README.md +316 -2
  2. package/bin/maestro.ts +5 -0
  3. package/dist/maestro +0 -0
  4. package/dist/web/assets/Connections-DV2Kql1Z.js +1 -0
  5. package/dist/web/assets/GanttView-CCT_rFpY.js +39 -0
  6. package/dist/web/assets/Home-BFbUIh2z.js +1 -0
  7. package/dist/web/assets/HooksCrons-ASM5-jDm.js +1 -0
  8. package/dist/web/assets/ProjectDetail-KZZi6IAd.js +1 -0
  9. package/dist/web/assets/Roles-KQ94PG3H.js +4 -0
  10. package/dist/web/assets/ScheduledTasks-CdJHJpEV.js +1 -0
  11. package/dist/web/assets/Settings-CTflMta-.js +1 -0
  12. package/dist/web/assets/Skills-D09W1mwX.js +2 -0
  13. package/dist/web/assets/Wizard-CW6B0wc3.js +1 -0
  14. package/dist/web/assets/WorkspaceChat-CthETL_A.js +1 -0
  15. package/dist/web/assets/WorkspaceDashboard-DTAesQuT.js +1 -0
  16. package/dist/web/assets/WorkspaceNew-Em4msIKn.js +1 -0
  17. package/dist/web/assets/WorkspaceProjects-Dxg2BpQy.js +1 -0
  18. package/dist/web/assets/WorkspaceTasks-C20mnnkP.js +1 -0
  19. package/dist/web/assets/index-B1k33vcR.js +11 -0
  20. package/dist/web/assets/index-Bk2hHz7P.css +1 -0
  21. package/dist/web/assets/index-Ddy5AJwx.js +61 -0
  22. package/dist/web/assets/useEventStream-DTID465I.js +1 -0
  23. package/dist/web/index.html +13 -0
  24. package/package.json +49 -6
  25. package/src/api/agents.ts +76 -0
  26. package/src/api/audit.ts +19 -0
  27. package/src/api/autopilot.ts +73 -0
  28. package/src/api/chat.ts +801 -0
  29. package/src/api/chief.ts +84 -0
  30. package/src/api/config.ts +39 -0
  31. package/src/api/gantt.ts +72 -0
  32. package/src/api/hooks.ts +54 -0
  33. package/src/api/inbox.ts +125 -0
  34. package/src/api/lark.ts +32 -0
  35. package/src/api/memory.ts +37 -0
  36. package/src/api/ops.ts +89 -0
  37. package/src/api/projects.ts +105 -0
  38. package/src/api/roles.ts +123 -0
  39. package/src/api/runtimes.ts +62 -0
  40. package/src/api/scheduled-tasks.ts +203 -0
  41. package/src/api/sessions.ts +479 -0
  42. package/src/api/skills.ts +386 -0
  43. package/src/api/tasks.ts +457 -0
  44. package/src/api/telegram.ts +94 -0
  45. package/src/api/templates.ts +36 -0
  46. package/src/api/webhooks.ts +20 -0
  47. package/src/api/workspaces.ts +150 -0
  48. package/src/bridges/lark/index.ts +213 -0
  49. package/src/bridges/telegram/index.ts +273 -0
  50. package/src/bridges/telegram/polling.ts +185 -0
  51. package/src/chat/index.ts +86 -0
  52. package/src/chief/index.ts +461 -0
  53. package/src/core/cli.ts +333 -0
  54. package/src/core/db.ts +53 -0
  55. package/src/core/event-bus.ts +33 -0
  56. package/src/core/index.ts +6 -0
  57. package/src/core/migrations.ts +303 -0
  58. package/src/core/router.ts +69 -0
  59. package/src/core/schema.sql +232 -0
  60. package/src/core/server.ts +308 -0
  61. package/src/core/validate.ts +22 -0
  62. package/src/discovery/index.ts +194 -0
  63. package/src/gateway/adapters/telegram.ts +148 -0
  64. package/src/gateway/index.ts +31 -0
  65. package/src/gateway/manager.ts +176 -0
  66. package/src/gateway/types.ts +77 -0
  67. package/src/inbox/index.ts +500 -0
  68. package/src/ops/artifact-sync.ts +65 -0
  69. package/src/ops/autopilot.ts +338 -0
  70. package/src/ops/gc.ts +252 -0
  71. package/src/ops/index.ts +226 -0
  72. package/src/ops/project-serial.ts +52 -0
  73. package/src/ops/role-dispatch.ts +111 -0
  74. package/src/ops/runtime-scheduler.ts +447 -0
  75. package/src/ops/task-blocking.ts +65 -0
  76. package/src/ops/task-deps.ts +37 -0
  77. package/src/ops/task-workspace.ts +60 -0
  78. package/src/roles/index.ts +258 -0
  79. package/src/roles/prompt-assembler.ts +85 -0
  80. package/src/roles/workspace-role.ts +155 -0
  81. package/src/scheduler/index.ts +461 -0
  82. package/src/session/output-parser.ts +75 -0
  83. package/src/session/realtime-parser.ts +40 -0
  84. package/src/skills/builtin.ts +155 -0
  85. package/src/skills/skill-extractor.ts +452 -0
  86. package/src/skills/skill-md.ts +282 -0
  87. package/src/transport/http-api.ts +75 -0
  88. package/src/transport/index.ts +4 -0
  89. package/src/transport/local-pty.ts +119 -0
  90. package/src/transport/ssh.ts +176 -0
  91. package/src/transport/types.ts +20 -0
  92. package/src/workflows/index.ts +231 -0
  93. package/index.js +0 -1
  94. package/maestro-agent-0.0.1.tgz +0 -0
@@ -0,0 +1,338 @@
1
+ import { claimTaskForAgent } from "../api/tasks";
2
+ import { now } from "../core/db";
3
+ import type { HubContext } from "../core/server";
4
+ import { executeHooksForEvent, runDueCrons, runDueScheduledTasks } from "../scheduler";
5
+ import { extractSkillFromTranscript } from "../skills/skill-extractor";
6
+ import { findRoleAndAgentForTask, resolveAgentForRole } from "./role-dispatch";
7
+
8
+ // ─── Types ───────────────────────────────────────────────────────────────────
9
+
10
+ export interface AutopilotConfig {
11
+ enabled: boolean;
12
+ tick_interval_ms: number; // Main loop interval (default: 30s)
13
+ auto_dispatch: boolean; // Auto-assign open tasks to idle agents
14
+ auto_crons: boolean; // Auto-run due crons
15
+ auto_scheduled_tasks: boolean; // Auto-run due scheduled tasks
16
+ webhook_bridge: boolean; // Bridge webhook events to hook_bindings
17
+ skill_extraction: boolean; // Auto-extract skills from completed sessions
18
+ max_concurrent_dispatches: number; // Max tasks to dispatch per tick
19
+ }
20
+
21
+ export interface AutopilotStatus {
22
+ running: boolean;
23
+ config: AutopilotConfig;
24
+ stats: AutopilotStats;
25
+ last_tick_at: number | null;
26
+ }
27
+
28
+ export interface AutopilotStats {
29
+ ticks: number;
30
+ crons_executed: number;
31
+ scheduled_tasks_fired: number;
32
+ tasks_dispatched: number;
33
+ webhooks_bridged: number;
34
+ skills_extracted: number;
35
+ errors: number;
36
+ started_at: number;
37
+ }
38
+
39
+ export interface AutopilotTickResult {
40
+ crons: { executed: number; blocked: number } | null;
41
+ scheduled_tasks: { fired: number; created: number; assigned: number; blocked: number } | null;
42
+ dispatched: { matched: number; claimed: number; failed: number } | null;
43
+ timestamp: number;
44
+ }
45
+
46
+ const DEFAULT_CONFIG: AutopilotConfig = {
47
+ enabled: true,
48
+ tick_interval_ms: 30_000,
49
+ auto_dispatch: true,
50
+ auto_crons: true,
51
+ auto_scheduled_tasks: true,
52
+ webhook_bridge: true,
53
+ skill_extraction: true,
54
+ max_concurrent_dispatches: 5,
55
+ };
56
+
57
+ // ─── Autopilot Engine ────────────────────────────────────────────────────────
58
+
59
+ export class Autopilot {
60
+ private timer: ReturnType<typeof setInterval> | null = null;
61
+ private config: AutopilotConfig;
62
+ private stats: AutopilotStats;
63
+ private lastTickAt: number | null = null;
64
+ private busListeners: Array<{ event: string; handler: (...args: any[]) => void }> = [];
65
+
66
+ constructor(private ctx: HubContext, config?: Partial<AutopilotConfig>) {
67
+ this.config = { ...DEFAULT_CONFIG, ...config };
68
+ this.stats = {
69
+ ticks: 0,
70
+ crons_executed: 0,
71
+ scheduled_tasks_fired: 0,
72
+ tasks_dispatched: 0,
73
+ webhooks_bridged: 0,
74
+ skills_extracted: 0,
75
+ errors: 0,
76
+ started_at: now(),
77
+ };
78
+ }
79
+
80
+ start(): void {
81
+ if (this.timer) return;
82
+ if (!this.config.enabled) return;
83
+
84
+ // Start tick loop
85
+ this.timer = setInterval(() => this.tick().catch((err) => {
86
+ console.error("[autopilot] tick error:", err);
87
+ this.stats.errors++;
88
+ }), this.config.tick_interval_ms);
89
+ (this.timer as any).unref?.();
90
+
91
+ // Bridge webhook events to hook system
92
+ if (this.config.webhook_bridge) {
93
+ this.setupWebhookBridge();
94
+ }
95
+
96
+ // Bridge task.created to auto-dispatch
97
+ if (this.config.auto_dispatch) {
98
+ this.setupAutoDispatchListener();
99
+ }
100
+
101
+ // Extract skills from completed sessions
102
+ if (this.config.skill_extraction) {
103
+ this.setupSkillExtractionListener();
104
+ }
105
+ }
106
+
107
+ stop(): void {
108
+ if (this.timer) {
109
+ clearInterval(this.timer);
110
+ this.timer = null;
111
+ }
112
+ // Remove bus listeners
113
+ for (const { event, handler } of this.busListeners) {
114
+ this.ctx.bus.removeListener(event, handler);
115
+ }
116
+ this.busListeners = [];
117
+ }
118
+
119
+ isRunning(): boolean {
120
+ return this.timer !== null;
121
+ }
122
+
123
+ getStatus(): AutopilotStatus {
124
+ return {
125
+ running: this.isRunning(),
126
+ config: { ...this.config },
127
+ stats: { ...this.stats },
128
+ last_tick_at: this.lastTickAt,
129
+ };
130
+ }
131
+
132
+ updateConfig(updates: Partial<AutopilotConfig>): void {
133
+ const wasRunning = this.isRunning();
134
+ const oldTickInterval = this.config.tick_interval_ms;
135
+
136
+ this.config = { ...this.config, ...updates };
137
+
138
+ if (wasRunning && !this.config.enabled) {
139
+ this.stop();
140
+ } else if (!wasRunning && this.config.enabled) {
141
+ this.start();
142
+ } else if (wasRunning && this.config.tick_interval_ms !== oldTickInterval) {
143
+ // Restart with new interval
144
+ this.stop();
145
+ this.start();
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Execute a single tick manually (useful for testing / manual trigger).
151
+ */
152
+ async tick(): Promise<AutopilotTickResult> {
153
+ const timestamp = now();
154
+ this.lastTickAt = timestamp;
155
+ this.stats.ticks++;
156
+
157
+ const result: AutopilotTickResult = {
158
+ crons: null,
159
+ scheduled_tasks: null,
160
+ dispatched: null,
161
+ timestamp,
162
+ };
163
+
164
+ // 1. Run due crons
165
+ if (this.config.auto_crons) {
166
+ try {
167
+ const cronsResult = runDueCrons(this.ctx.db, timestamp);
168
+ result.crons = cronsResult;
169
+ this.stats.crons_executed += cronsResult.executed;
170
+ } catch (err) {
171
+ console.error("[autopilot] crons error:", err);
172
+ this.stats.errors++;
173
+ }
174
+ }
175
+
176
+ // 2. Run due scheduled tasks
177
+ if (this.config.auto_scheduled_tasks) {
178
+ try {
179
+ const stResult = await runDueScheduledTasks(this.ctx, timestamp);
180
+ result.scheduled_tasks = stResult;
181
+ this.stats.scheduled_tasks_fired += stResult.fired;
182
+ } catch (err) {
183
+ console.error("[autopilot] scheduled tasks error:", err);
184
+ this.stats.errors++;
185
+ }
186
+ }
187
+
188
+ // 3. Auto-dispatch unassigned tasks
189
+ if (this.config.auto_dispatch) {
190
+ try {
191
+ const dispatched = await this.dispatchOpenTasks();
192
+ result.dispatched = dispatched;
193
+ this.stats.tasks_dispatched += dispatched.claimed;
194
+ } catch (err) {
195
+ console.error("[autopilot] dispatch error:", err);
196
+ this.stats.errors++;
197
+ }
198
+ }
199
+
200
+ return result;
201
+ }
202
+
203
+ // ─── Auto-Dispatch Logic ─────────────────────────────────────────────────
204
+
205
+ private async dispatchOpenTasks(): Promise<{ matched: number; claimed: number; failed: number }> {
206
+ const result = { matched: 0, claimed: 0, failed: 0 };
207
+
208
+ // Find open tasks without assignee, ordered by priority
209
+ // Tasks with assignee_role_id get priority — the Role is already determined
210
+ const openTasks = this.ctx.db.query(
211
+ `SELECT t.id, t.project_id, t.assignee_role_id, t.required_capabilities_json
212
+ FROM task t
213
+ WHERE t.status = 'open'
214
+ AND t.assignee_agent_id IS NULL
215
+ ORDER BY t.priority DESC, t.created_at ASC
216
+ LIMIT ?`
217
+ ).all(this.config.max_concurrent_dispatches) as any[];
218
+
219
+ for (const task of openTasks) {
220
+ const resolved = this.resolveForTask(task);
221
+ if (!resolved) continue;
222
+
223
+ result.matched++;
224
+ try {
225
+ // Record the role assignment if not already set
226
+ if (resolved.role_id && !task.assignee_role_id) {
227
+ this.ctx.db.run("UPDATE task SET assignee_role_id = ? WHERE id = ?", [resolved.role_id, task.id]);
228
+ }
229
+ const claimResult = await claimTaskForAgent(this.ctx, task.id, resolved.agent_id, { deferIfProjectBusy: true, skipSession: true });
230
+ if ("error" in claimResult) {
231
+ result.failed++;
232
+ } else {
233
+ result.claimed++;
234
+ }
235
+ } catch {
236
+ result.failed++;
237
+ }
238
+ }
239
+
240
+ return result;
241
+ }
242
+
243
+ /**
244
+ * Role-centric resolution: task → Role → Agent (wake if needed).
245
+ * If task already has assignee_role_id, use that role directly.
246
+ * Otherwise, find the best matching Role in the workspace.
247
+ */
248
+ private resolveForTask(task: any): { role_id: string; agent_id: string } | null {
249
+ // If task already has a designated role, resolve agent under that role
250
+ if (task.assignee_role_id) {
251
+ const agent = resolveAgentForRole(this.ctx.db, task.assignee_role_id);
252
+ if (agent) return { role_id: task.assignee_role_id, agent_id: agent.id };
253
+ return null;
254
+ }
255
+
256
+ // Otherwise find best Role + Agent via capabilities matching
257
+ const match = findRoleAndAgentForTask(this.ctx, task);
258
+ if (match) return { role_id: match.role_id, agent_id: match.agent.id };
259
+ return null;
260
+ }
261
+
262
+ // ─── Event Bridge ────────────────────────────────────────────────────────
263
+
264
+ private setupWebhookBridge(): void {
265
+ const bridgeHandler = (event: string) => {
266
+ const listener = (payload: any) => {
267
+ try {
268
+ executeHooksForEvent(this.ctx.db, event, payload || {});
269
+ this.stats.webhooks_bridged++;
270
+ } catch (err) {
271
+ console.error(`[autopilot] webhook bridge error for ${event}:`, err);
272
+ this.stats.errors++;
273
+ }
274
+ };
275
+ this.ctx.bus.on(event, listener);
276
+ this.busListeners.push({ event, handler: listener });
277
+ };
278
+
279
+ // Bridge common webhook events
280
+ for (const source of ["webhook.github", "webhook.lark", "webhook.custom", "webhook.linear"]) {
281
+ bridgeHandler(source);
282
+ }
283
+ }
284
+
285
+ private setupAutoDispatchListener(): void {
286
+ const handler = async (payload: any) => {
287
+ // When a new task is created, attempt to dispatch it via Role
288
+ // Skip if task was already assigned by the creator
289
+ if (!payload?.id || payload.assignee_agent_id) return;
290
+ try {
291
+ const task = this.ctx.db.query(
292
+ "SELECT id, project_id, assignee_role_id, required_capabilities_json FROM task WHERE id = ? AND status = 'open' AND assignee_agent_id IS NULL"
293
+ ).get(payload.id) as any;
294
+ if (!task) return;
295
+
296
+ const resolved = this.resolveForTask(task);
297
+ if (!resolved) return;
298
+
299
+ // Record role assignment
300
+ if (!task.assignee_role_id) {
301
+ this.ctx.db.run("UPDATE task SET assignee_role_id = ? WHERE id = ?", [resolved.role_id, task.id]);
302
+ }
303
+
304
+ const result = await claimTaskForAgent(this.ctx, task.id, resolved.agent_id, { deferIfProjectBusy: true, skipSession: true });
305
+ if (!("error" in result)) {
306
+ this.stats.tasks_dispatched++;
307
+ }
308
+ } catch (err) {
309
+ console.error("[autopilot] auto-dispatch on task.created error:", err);
310
+ }
311
+ };
312
+
313
+ this.ctx.bus.on("task.created", handler);
314
+ this.busListeners.push({ event: "task.created", handler });
315
+ }
316
+
317
+ private setupSkillExtractionListener(): void {
318
+ const handler = (payload: any) => {
319
+ if (!payload?.id) return;
320
+ // Async extraction — don't block event loop
321
+ setTimeout(() => {
322
+ try {
323
+ const result = extractSkillFromTranscript(this.ctx, payload.id);
324
+ if (result.extracted) {
325
+ this.stats.skills_extracted++;
326
+ console.log(`[autopilot] skill extracted: ${result.skill_name} from session ${payload.id}`);
327
+ }
328
+ } catch (err) {
329
+ console.error("[autopilot] skill extraction error:", err);
330
+ this.stats.errors++;
331
+ }
332
+ }, 1000); // Delay to ensure transcript is fully flushed
333
+ };
334
+
335
+ this.ctx.bus.on("session.ended", handler);
336
+ this.busListeners.push({ event: "session.ended", handler });
337
+ }
338
+ }
package/src/ops/gc.ts ADDED
@@ -0,0 +1,252 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { existsSync, readdirSync, rmSync, statSync } from "fs";
3
+ import { join } from "path";
4
+ import { now } from "../core/db";
5
+
6
+ export interface GcConfig {
7
+ retentionDays: number;
8
+ maxDiskMb: number;
9
+ protectedPatterns: string[];
10
+ }
11
+
12
+ const DEFAULT_CONFIG: GcConfig = {
13
+ retentionDays: 7,
14
+ maxDiskMb: 1024,
15
+ protectedPatterns: ["*.log", "report.*"],
16
+ };
17
+
18
+ export interface GcResult {
19
+ level: "artifact" | "full" | "orphan";
20
+ removed: string[];
21
+ skipped: string[];
22
+ freedBytes: number;
23
+ }
24
+
25
+ // Patterns for build artifacts that can be safely removed
26
+ const ARTIFACT_PATTERNS = [
27
+ "node_modules",
28
+ "dist",
29
+ ".build",
30
+ "__pycache__",
31
+ ".next",
32
+ ".turbo",
33
+ "target", // Rust/Java
34
+ "build",
35
+ ".cache",
36
+ ];
37
+
38
+ /**
39
+ * Level 1: Artifact-only GC
40
+ * Removes build artifacts (node_modules, dist, .build etc.) from completed task dirs.
41
+ * Preserves logs and user-marked artifacts.
42
+ */
43
+ export function gcArtifacts(db: Database, hubDir: string, config?: Partial<GcConfig>): GcResult {
44
+ const cfg = { ...DEFAULT_CONFIG, ...config };
45
+ const workspacesDir = join(hubDir, "workspaces");
46
+ if (!existsSync(workspacesDir)) return { level: "artifact", removed: [], skipped: [], freedBytes: 0 };
47
+
48
+ const completedTaskIds = getCompletedTaskIds(db);
49
+ const removed: string[] = [];
50
+ const skipped: string[] = [];
51
+ let freedBytes = 0;
52
+
53
+ for (const taskId of listTaskDirs(workspacesDir)) {
54
+ if (!completedTaskIds.has(taskId)) {
55
+ skipped.push(taskId);
56
+ continue;
57
+ }
58
+
59
+ const taskWorkspace = join(workspacesDir, taskId, "workspace");
60
+ if (!existsSync(taskWorkspace)) continue;
61
+
62
+ for (const pattern of ARTIFACT_PATTERNS) {
63
+ const target = join(taskWorkspace, pattern);
64
+ if (existsSync(target)) {
65
+ const size = getDirSize(target);
66
+ rmSync(target, { recursive: true, force: true });
67
+ freedBytes += size;
68
+ removed.push(`${taskId}/${pattern}`);
69
+ }
70
+ }
71
+ }
72
+
73
+ return { level: "artifact", removed, skipped, freedBytes };
74
+ }
75
+
76
+ /**
77
+ * Level 2: Full GC
78
+ * Removes entire task directories for completed tasks beyond retention period.
79
+ */
80
+ export function gcFull(db: Database, hubDir: string, config?: Partial<GcConfig>): GcResult {
81
+ const cfg = { ...DEFAULT_CONFIG, ...config };
82
+ const workspacesDir = join(hubDir, "workspaces");
83
+ if (!existsSync(workspacesDir)) return { level: "full", removed: [], skipped: [], freedBytes: 0 };
84
+
85
+ const retentionMs = cfg.retentionDays * 24 * 60 * 60 * 1000;
86
+ const cutoff = now() - retentionMs;
87
+ const expiredTaskIds = getExpiredTaskIds(db, cutoff);
88
+ const removed: string[] = [];
89
+ const skipped: string[] = [];
90
+ let freedBytes = 0;
91
+
92
+ for (const taskId of listTaskDirs(workspacesDir)) {
93
+ if (!expiredTaskIds.has(taskId)) {
94
+ skipped.push(taskId);
95
+ continue;
96
+ }
97
+
98
+ const taskDir = join(workspacesDir, taskId);
99
+ const size = getDirSize(taskDir);
100
+ rmSync(taskDir, { recursive: true, force: true });
101
+ db.run("UPDATE task SET work_dir = NULL, updated_at = ? WHERE id = ?", [now(), taskId]);
102
+ freedBytes += size;
103
+ removed.push(taskId);
104
+ }
105
+
106
+ return { level: "full", removed, skipped, freedBytes };
107
+ }
108
+
109
+ /**
110
+ * Level 3: Orphan GC
111
+ * Removes directories that have no corresponding task in the DB.
112
+ * Typically run on server startup.
113
+ */
114
+ export function gcOrphans(db: Database, hubDir: string): GcResult {
115
+ const workspacesDir = join(hubDir, "workspaces");
116
+ if (!existsSync(workspacesDir)) return { level: "orphan", removed: [], skipped: [], freedBytes: 0 };
117
+
118
+ const allTaskIds = new Set(
119
+ (db.query("SELECT id FROM task").all() as any[]).map((r) => r.id)
120
+ );
121
+
122
+ const removed: string[] = [];
123
+ const skipped: string[] = [];
124
+ let freedBytes = 0;
125
+
126
+ for (const dirName of listTaskDirs(workspacesDir)) {
127
+ if (allTaskIds.has(dirName)) {
128
+ skipped.push(dirName);
129
+ continue;
130
+ }
131
+
132
+ const orphanDir = join(workspacesDir, dirName);
133
+ const size = getDirSize(orphanDir);
134
+ rmSync(orphanDir, { recursive: true, force: true });
135
+ freedBytes += size;
136
+ removed.push(dirName);
137
+ }
138
+
139
+ return { level: "orphan", removed, skipped, freedBytes };
140
+ }
141
+
142
+ /**
143
+ * Run all GC levels in order: orphan -> artifact -> full.
144
+ */
145
+ export function gcAll(db: Database, hubDir: string, config?: Partial<GcConfig>): GcResult[] {
146
+ return [
147
+ gcOrphans(db, hubDir),
148
+ gcArtifacts(db, hubDir, config),
149
+ gcFull(db, hubDir, config),
150
+ ];
151
+ }
152
+
153
+ /**
154
+ * Dry-run: report what would be cleaned without removing anything.
155
+ */
156
+ export function gcDryRun(db: Database, hubDir: string, config?: Partial<GcConfig>) {
157
+ const cfg = { ...DEFAULT_CONFIG, ...config };
158
+ const workspacesDir = join(hubDir, "workspaces");
159
+ if (!existsSync(workspacesDir)) return { orphans: [], artifacts: [], expired: [], totalBytes: 0 };
160
+
161
+ const allTaskIds = new Set((db.query("SELECT id FROM task").all() as any[]).map((r) => r.id));
162
+ const completedTaskIds = getCompletedTaskIds(db);
163
+ const retentionMs = cfg.retentionDays * 24 * 60 * 60 * 1000;
164
+ const cutoff = now() - retentionMs;
165
+ const expiredTaskIds = getExpiredTaskIds(db, cutoff);
166
+
167
+ const orphans: string[] = [];
168
+ const artifacts: { taskId: string; pattern: string; bytes: number }[] = [];
169
+ const expired: string[] = [];
170
+ let totalBytes = 0;
171
+
172
+ for (const dirName of listTaskDirs(workspacesDir)) {
173
+ const taskDir = join(workspacesDir, dirName);
174
+
175
+ if (!allTaskIds.has(dirName)) {
176
+ const size = getDirSize(taskDir);
177
+ orphans.push(dirName);
178
+ totalBytes += size;
179
+ continue;
180
+ }
181
+
182
+ if (expiredTaskIds.has(dirName)) {
183
+ const size = getDirSize(taskDir);
184
+ expired.push(dirName);
185
+ totalBytes += size;
186
+ continue;
187
+ }
188
+
189
+ if (completedTaskIds.has(dirName)) {
190
+ const taskWorkspace = join(taskDir, "workspace");
191
+ if (existsSync(taskWorkspace)) {
192
+ for (const pattern of ARTIFACT_PATTERNS) {
193
+ const target = join(taskWorkspace, pattern);
194
+ if (existsSync(target)) {
195
+ const size = getDirSize(target);
196
+ artifacts.push({ taskId: dirName, pattern, bytes: size });
197
+ totalBytes += size;
198
+ }
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ return { orphans, artifacts, expired, totalBytes };
205
+ }
206
+
207
+ // --- Helpers ---
208
+
209
+ function getCompletedTaskIds(db: Database): Set<string> {
210
+ return new Set(
211
+ (db.query("SELECT id FROM task WHERE status IN ('done', 'cancelled')").all() as any[]).map((r) => r.id)
212
+ );
213
+ }
214
+
215
+ function getExpiredTaskIds(db: Database, cutoff: number): Set<string> {
216
+ return new Set(
217
+ (db.query(
218
+ "SELECT id FROM task WHERE status IN ('done', 'cancelled') AND updated_at <= ?"
219
+ ).all(cutoff) as any[]).map((r) => r.id)
220
+ );
221
+ }
222
+
223
+ function listTaskDirs(workspacesDir: string): string[] {
224
+ try {
225
+ return readdirSync(workspacesDir, { withFileTypes: true })
226
+ .filter((d) => d.isDirectory())
227
+ .map((d) => d.name);
228
+ } catch {
229
+ return [];
230
+ }
231
+ }
232
+
233
+ function getDirSize(dirPath: string): number {
234
+ try {
235
+ const stat = statSync(dirPath);
236
+ if (!stat.isDirectory()) return stat.size;
237
+
238
+ let total = 0;
239
+ const entries = readdirSync(dirPath, { withFileTypes: true });
240
+ for (const entry of entries) {
241
+ const fullPath = join(dirPath, entry.name);
242
+ if (entry.isDirectory()) {
243
+ total += getDirSize(fullPath);
244
+ } else {
245
+ try { total += statSync(fullPath).size; } catch {}
246
+ }
247
+ }
248
+ return total;
249
+ } catch {
250
+ return 0;
251
+ }
252
+ }