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.
- package/README.md +316 -2
- package/bin/maestro.ts +5 -0
- package/dist/maestro +0 -0
- package/dist/web/assets/Connections-DV2Kql1Z.js +1 -0
- package/dist/web/assets/GanttView-CCT_rFpY.js +39 -0
- package/dist/web/assets/Home-BFbUIh2z.js +1 -0
- package/dist/web/assets/HooksCrons-ASM5-jDm.js +1 -0
- package/dist/web/assets/ProjectDetail-KZZi6IAd.js +1 -0
- package/dist/web/assets/Roles-KQ94PG3H.js +4 -0
- package/dist/web/assets/ScheduledTasks-CdJHJpEV.js +1 -0
- package/dist/web/assets/Settings-CTflMta-.js +1 -0
- package/dist/web/assets/Skills-D09W1mwX.js +2 -0
- package/dist/web/assets/Wizard-CW6B0wc3.js +1 -0
- package/dist/web/assets/WorkspaceChat-CthETL_A.js +1 -0
- package/dist/web/assets/WorkspaceDashboard-DTAesQuT.js +1 -0
- package/dist/web/assets/WorkspaceNew-Em4msIKn.js +1 -0
- package/dist/web/assets/WorkspaceProjects-Dxg2BpQy.js +1 -0
- package/dist/web/assets/WorkspaceTasks-C20mnnkP.js +1 -0
- package/dist/web/assets/index-B1k33vcR.js +11 -0
- package/dist/web/assets/index-Bk2hHz7P.css +1 -0
- package/dist/web/assets/index-Ddy5AJwx.js +61 -0
- package/dist/web/assets/useEventStream-DTID465I.js +1 -0
- package/dist/web/index.html +13 -0
- package/package.json +49 -6
- package/src/api/agents.ts +76 -0
- package/src/api/audit.ts +19 -0
- package/src/api/autopilot.ts +73 -0
- package/src/api/chat.ts +801 -0
- package/src/api/chief.ts +84 -0
- package/src/api/config.ts +39 -0
- package/src/api/gantt.ts +72 -0
- package/src/api/hooks.ts +54 -0
- package/src/api/inbox.ts +125 -0
- package/src/api/lark.ts +32 -0
- package/src/api/memory.ts +37 -0
- package/src/api/ops.ts +89 -0
- package/src/api/projects.ts +105 -0
- package/src/api/roles.ts +123 -0
- package/src/api/runtimes.ts +62 -0
- package/src/api/scheduled-tasks.ts +203 -0
- package/src/api/sessions.ts +479 -0
- package/src/api/skills.ts +386 -0
- package/src/api/tasks.ts +457 -0
- package/src/api/telegram.ts +94 -0
- package/src/api/templates.ts +36 -0
- package/src/api/webhooks.ts +20 -0
- package/src/api/workspaces.ts +150 -0
- package/src/bridges/lark/index.ts +213 -0
- package/src/bridges/telegram/index.ts +273 -0
- package/src/bridges/telegram/polling.ts +185 -0
- package/src/chat/index.ts +86 -0
- package/src/chief/index.ts +461 -0
- package/src/core/cli.ts +333 -0
- package/src/core/db.ts +53 -0
- package/src/core/event-bus.ts +33 -0
- package/src/core/index.ts +6 -0
- package/src/core/migrations.ts +303 -0
- package/src/core/router.ts +69 -0
- package/src/core/schema.sql +232 -0
- package/src/core/server.ts +308 -0
- package/src/core/validate.ts +22 -0
- package/src/discovery/index.ts +194 -0
- package/src/gateway/adapters/telegram.ts +148 -0
- package/src/gateway/index.ts +31 -0
- package/src/gateway/manager.ts +176 -0
- package/src/gateway/types.ts +77 -0
- package/src/inbox/index.ts +500 -0
- package/src/ops/artifact-sync.ts +65 -0
- package/src/ops/autopilot.ts +338 -0
- package/src/ops/gc.ts +252 -0
- package/src/ops/index.ts +226 -0
- package/src/ops/project-serial.ts +52 -0
- package/src/ops/role-dispatch.ts +111 -0
- package/src/ops/runtime-scheduler.ts +447 -0
- package/src/ops/task-blocking.ts +65 -0
- package/src/ops/task-deps.ts +37 -0
- package/src/ops/task-workspace.ts +60 -0
- package/src/roles/index.ts +258 -0
- package/src/roles/prompt-assembler.ts +85 -0
- package/src/roles/workspace-role.ts +155 -0
- package/src/scheduler/index.ts +461 -0
- package/src/session/output-parser.ts +75 -0
- package/src/session/realtime-parser.ts +40 -0
- package/src/skills/builtin.ts +155 -0
- package/src/skills/skill-extractor.ts +452 -0
- package/src/skills/skill-md.ts +282 -0
- package/src/transport/http-api.ts +75 -0
- package/src/transport/index.ts +4 -0
- package/src/transport/local-pty.ts +119 -0
- package/src/transport/ssh.ts +176 -0
- package/src/transport/types.ts +20 -0
- package/src/workflows/index.ts +231 -0
- package/index.js +0 -1
- 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
|
+
}
|