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,447 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { generateId, now } from "../core/db";
3
+ import { getWorkflowForTask } from "../workflows";
4
+
5
+ export interface RuntimeSchedulerOptions {
6
+ oversubscription_factor?: number; // 默认 1.0(不超分)
7
+ queue_timeout_ms?: number; // 排队超时,默认 30000
8
+ }
9
+
10
+ export const RUNTIME_TASK_POLL_INTERVAL_MS = 3_000;
11
+ export const AGENT_HEARTBEAT_INTERVAL_MS = 15_000;
12
+ export const DEFAULT_TASK_TIMEOUT_MS = 5 * 60 * 1000;
13
+ export const DEFAULT_HEARTBEAT_TIMEOUT_MS = 30_000;
14
+
15
+ type SchedulerLogger = Pick<Console, "info" | "warn" | "error">;
16
+
17
+ export interface RuntimeMaintenanceOptions {
18
+ task_timeout_ms?: number;
19
+ heartbeat_timeout_ms?: number;
20
+ clock?: () => number;
21
+ logger?: SchedulerLogger;
22
+ activeSessionIds?: Iterable<string>;
23
+ publish?: (type: string, payload: unknown) => void;
24
+ }
25
+
26
+ export interface RuntimeMaintenanceResult {
27
+ scanned: number;
28
+ recovered_tasks: string[];
29
+ interrupted_sessions: string[];
30
+ offline_agents: string[];
31
+ heartbeat_agents: string[];
32
+ runtime_ids: string[];
33
+ }
34
+
35
+ export interface QueueEntry {
36
+ resolve: () => void;
37
+ reject: (error: Error) => void;
38
+ timer: Timer;
39
+ }
40
+
41
+ export type SessionQueue = Map<string, QueueEntry[]>;
42
+
43
+ export function createSessionQueue(): SessionQueue {
44
+ return new Map();
45
+ }
46
+
47
+ export function effectiveCapacity(capacity: number, factor = 1.0): number {
48
+ if (capacity < 0) return Infinity;
49
+ return Math.ceil(capacity * factor);
50
+ }
51
+
52
+ export function runningSessionCount(db: Database, runtimeId: string): number {
53
+ const row = db.query(`
54
+ SELECT COUNT(*) AS count
55
+ FROM session
56
+ JOIN agent ON agent.id = session.agent_id
57
+ WHERE agent.runtime_id = ?
58
+ AND session.status = 'running'
59
+ `).get(runtimeId) as { count: number };
60
+ return row.count;
61
+ }
62
+
63
+ export function isAtCapacity(db: Database, runtimeId: string, capacity: number, factor = 1.0): boolean {
64
+ const effective = effectiveCapacity(capacity, factor);
65
+ if (!isFinite(effective)) return false;
66
+ return runningSessionCount(db, runtimeId) >= effective;
67
+ }
68
+
69
+ export function waitForSlot(
70
+ queue: SessionQueue,
71
+ runtimeId: string,
72
+ timeoutMs = 30000,
73
+ ): Promise<void> {
74
+ return new Promise((resolve, reject) => {
75
+ let entry: QueueEntry;
76
+ const timer = setTimeout(() => {
77
+ removeFromQueue(queue, runtimeId, entry);
78
+ reject(new Error("Queue timeout: runtime capacity not freed in time"));
79
+ }, timeoutMs);
80
+
81
+ entry = {
82
+ resolve: () => {
83
+ clearTimeout(timer);
84
+ resolve();
85
+ },
86
+ reject,
87
+ timer,
88
+ };
89
+
90
+ if (!queue.has(runtimeId)) queue.set(runtimeId, []);
91
+ queue.get(runtimeId)!.push(entry);
92
+ });
93
+ }
94
+
95
+ export function notifyNextInQueue(queue: SessionQueue, runtimeId: string) {
96
+ const entries = queue.get(runtimeId);
97
+ if (!entries || entries.length === 0) return;
98
+ const next = entries.shift()!;
99
+ clearTimeout(next.timer);
100
+ next.resolve();
101
+ if (entries.length === 0) queue.delete(runtimeId);
102
+ }
103
+
104
+ function removeFromQueue(queue: SessionQueue, runtimeId: string, entry: QueueEntry) {
105
+ const entries = queue.get(runtimeId);
106
+ if (!entries) return;
107
+ const index = entries.indexOf(entry);
108
+ if (index !== -1) entries.splice(index, 1);
109
+ if (entries.length === 0) queue.delete(runtimeId);
110
+ }
111
+
112
+ export function sendHeartbeat(db: Database, agentId: string, options: RuntimeMaintenanceOptions = {}) {
113
+ const ts = getTimestamp(options);
114
+ db.run(
115
+ `UPDATE agent
116
+ SET last_active_at = ?,
117
+ status = CASE WHEN status = 'offline' THEN 'idle' ELSE status END
118
+ WHERE id = ?`,
119
+ [ts, agentId],
120
+ );
121
+ return { agent_id: agentId, heartbeat_at: ts };
122
+ }
123
+
124
+ export function sendHeartbeatsForRunningSessions(
125
+ db: Database,
126
+ activeSessionIds: Iterable<string>,
127
+ options: RuntimeMaintenanceOptions = {},
128
+ ): RuntimeMaintenanceResult {
129
+ const sessionIds = Array.from(new Set(activeSessionIds));
130
+ const result = emptyResult();
131
+ if (sessionIds.length === 0) return result;
132
+
133
+ const placeholders = sessionIds.map(() => "?").join(", ");
134
+ const rows = db.query(`
135
+ SELECT DISTINCT session.agent_id, agent.runtime_id
136
+ FROM session
137
+ JOIN agent ON agent.id = session.agent_id
138
+ WHERE session.status = 'running'
139
+ AND session.id IN (${placeholders})
140
+ `).all(...sessionIds) as any[];
141
+
142
+ for (const row of rows) {
143
+ sendHeartbeat(db, row.agent_id, options);
144
+ result.heartbeat_agents.push(row.agent_id);
145
+ if (row.runtime_id) addUnique(result.runtime_ids, row.runtime_id);
146
+ }
147
+ return result;
148
+ }
149
+
150
+ export function pollClaimedTasks(db: Database, options: RuntimeMaintenanceOptions = {}): RuntimeMaintenanceResult {
151
+ const ts = getTimestamp(options);
152
+ const taskCutoff = ts - getTaskTimeout(options);
153
+ const heartbeatCutoff = ts - getHeartbeatTimeout(options);
154
+ const activeSessionSet = options.activeSessionIds
155
+ ? new Set(Array.from(options.activeSessionIds))
156
+ : null;
157
+ const result = emptyResult();
158
+
159
+ const rows = db.query(`
160
+ SELECT
161
+ task.id,
162
+ task.status,
163
+ task.assignee_agent_id,
164
+ task.updated_at,
165
+ agent.status AS agent_status,
166
+ agent.last_active_at,
167
+ agent.runtime_id,
168
+ (
169
+ SELECT session.id
170
+ FROM session
171
+ WHERE session.task_id = task.id
172
+ AND session.status = 'running'
173
+ ORDER BY session.started_at DESC
174
+ LIMIT 1
175
+ ) AS session_id
176
+ FROM task
177
+ LEFT JOIN agent ON agent.id = task.assignee_agent_id
178
+ WHERE task.status IN ('claimed', 'in_progress')
179
+ `).all() as any[];
180
+
181
+ result.scanned = rows.length;
182
+ for (const row of rows) {
183
+ const hasRunningSession = Boolean(row.session_id);
184
+ const missingTrackedProcess = Boolean(activeSessionSet && row.session_id && !activeSessionSet.has(row.session_id));
185
+ const agentTimedOut = !row.assignee_agent_id
186
+ || row.agent_status === "offline"
187
+ || Number(row.last_active_at || 0) <= heartbeatCutoff;
188
+ const taskTimedOut = Number(row.updated_at || 0) <= taskCutoff;
189
+
190
+ let reason: string | null = null;
191
+ if (!hasRunningSession && taskTimedOut) reason = "orphaned_task";
192
+ else if (missingTrackedProcess && taskTimedOut) reason = "missing_tracked_process";
193
+ else if (taskTimedOut && agentTimedOut) reason = "task_and_heartbeat_timeout";
194
+ if (!reason) continue;
195
+
196
+ recoverTask(db, row, reason, ts, result, options);
197
+ }
198
+
199
+ if (result.recovered_tasks.length > 0) {
200
+ options.logger?.warn?.(
201
+ `[runtime-scheduler] poll recovered ${result.recovered_tasks.length}/${result.scanned} claimed task(s)`,
202
+ );
203
+ }
204
+ return result;
205
+ }
206
+
207
+ export function markHeartbeatTimedOutAgents(
208
+ db: Database,
209
+ options: RuntimeMaintenanceOptions = {},
210
+ ): RuntimeMaintenanceResult {
211
+ const ts = getTimestamp(options);
212
+ const cutoff = ts - getHeartbeatTimeout(options);
213
+ const result = emptyResult();
214
+ // Only timeout agents that are actively working (busy/running).
215
+ // Idle agents don't have running sessions to send heartbeats,
216
+ // so they should NOT be marked offline due to heartbeat timeout.
217
+ const agents = db.query(`
218
+ SELECT id, runtime_id, status, last_active_at
219
+ FROM agent
220
+ WHERE status NOT IN ('offline', 'idle')
221
+ AND last_active_at <= ?
222
+ `).all(cutoff) as any[];
223
+
224
+ result.scanned = agents.length;
225
+ for (const agent of agents) {
226
+ db.run("UPDATE agent SET status = 'offline', last_active_at = ? WHERE id = ?", [ts, agent.id]);
227
+ result.offline_agents.push(agent.id);
228
+ if (agent.runtime_id) addUnique(result.runtime_ids, agent.runtime_id);
229
+
230
+ const sessions = db.query(
231
+ "SELECT id FROM session WHERE agent_id = ? AND status = 'running'",
232
+ ).all(agent.id) as any[];
233
+ db.run("UPDATE session SET status = 'interrupted', ended_at = ? WHERE agent_id = ? AND status = 'running'", [
234
+ ts,
235
+ agent.id,
236
+ ]);
237
+ for (const session of sessions) result.interrupted_sessions.push(session.id);
238
+
239
+ const tasks = db.query(`
240
+ SELECT id, status, assignee_agent_id, updated_at, ? AS runtime_id
241
+ FROM task
242
+ WHERE assignee_agent_id = ?
243
+ AND status IN ('claimed', 'in_progress')
244
+ `).all(agent.runtime_id || null, agent.id) as any[];
245
+ for (const task of tasks) {
246
+ recoverTask(db, task, "heartbeat_timeout", ts, result, options);
247
+ }
248
+
249
+ audit(db, "runtime_scheduler.agent_offline", agent.id, {
250
+ last_active_at: agent.last_active_at,
251
+ heartbeat_timeout_ms: getHeartbeatTimeout(options),
252
+ });
253
+ options.publish?.("agent.offline", { id: agent.id, last_active_at: agent.last_active_at });
254
+ }
255
+
256
+ if (result.offline_agents.length > 0) {
257
+ options.logger?.warn?.(
258
+ `[runtime-scheduler] heartbeat timeout: marked ${result.offline_agents.length} agent(s) offline`,
259
+ );
260
+ }
261
+ return result;
262
+ }
263
+
264
+ export function startRuntimeScheduler(
265
+ ctx: {
266
+ db: Database;
267
+ sessions: Map<string, unknown>;
268
+ sessionQueue: SessionQueue;
269
+ bus?: { publish: (type: string, payload: unknown) => void };
270
+ },
271
+ options: RuntimeMaintenanceOptions & {
272
+ poll_interval_ms?: number;
273
+ heartbeat_interval_ms?: number;
274
+ } = {},
275
+ ) {
276
+ const logger = options.logger || console;
277
+ const pollIntervalMs = options.poll_interval_ms ?? RUNTIME_TASK_POLL_INTERVAL_MS;
278
+ const heartbeatIntervalMs = options.heartbeat_interval_ms ?? AGENT_HEARTBEAT_INTERVAL_MS;
279
+ const publish = options.publish || ((type: string, payload: unknown) => ctx.bus?.publish(type, payload));
280
+
281
+ const runPoll = () => {
282
+ try {
283
+ const result = pollClaimedTasks(ctx.db, {
284
+ ...options,
285
+ logger,
286
+ publish,
287
+ activeSessionIds: ctx.sessions.keys(),
288
+ });
289
+ releaseRuntimeQueues(ctx.sessionQueue, result.runtime_ids);
290
+ } catch (err) {
291
+ logger.error("[runtime-scheduler] task poll error:", err);
292
+ }
293
+ };
294
+
295
+ const runHeartbeat = () => {
296
+ try {
297
+ const heartbeat = sendHeartbeatsForRunningSessions(ctx.db, ctx.sessions.keys(), { ...options, logger });
298
+ const timeout = markHeartbeatTimedOutAgents(ctx.db, { ...options, logger, publish });
299
+ releaseRuntimeQueues(ctx.sessionQueue, [...heartbeat.runtime_ids, ...timeout.runtime_ids]);
300
+ if (heartbeat.heartbeat_agents.length > 0 || timeout.offline_agents.length > 0) {
301
+ logger.info(
302
+ `[runtime-scheduler] heartbeat: sent=${heartbeat.heartbeat_agents.length} offline=${timeout.offline_agents.length}`,
303
+ );
304
+ }
305
+ } catch (err) {
306
+ logger.error("[runtime-scheduler] heartbeat error:", err);
307
+ }
308
+ };
309
+
310
+ logger.info(
311
+ `[runtime-scheduler] started poll=${pollIntervalMs}ms heartbeat=${heartbeatIntervalMs}ms task_timeout=${getTaskTimeout(options)}ms heartbeat_timeout=${getHeartbeatTimeout(options)}ms`,
312
+ );
313
+
314
+ const pollTimer = setInterval(runPoll, pollIntervalMs);
315
+ const heartbeatTimer = setInterval(runHeartbeat, heartbeatIntervalMs);
316
+ pollTimer.unref?.();
317
+ heartbeatTimer.unref?.();
318
+
319
+ runPoll();
320
+ runHeartbeat();
321
+
322
+ return {
323
+ stop() {
324
+ clearInterval(pollTimer);
325
+ clearInterval(heartbeatTimer);
326
+ },
327
+ };
328
+ }
329
+
330
+ function recoverTask(
331
+ db: Database,
332
+ task: any,
333
+ reason: string,
334
+ ts: number,
335
+ result: RuntimeMaintenanceResult,
336
+ options: RuntimeMaintenanceOptions,
337
+ ) {
338
+ // Check recovery count — if already recovered 3+ times, abandon the task instead of looping
339
+ const MAX_RECOVERY_ATTEMPTS = 3;
340
+ const recoveryCount = (db.query(
341
+ "SELECT COUNT(*) as cnt FROM task_thread_item WHERE task_id = ? AND kind = 'scheduler_recovery'",
342
+ ).get(task.id) as any)?.cnt ?? 0;
343
+
344
+ if (recoveryCount >= MAX_RECOVERY_ATTEMPTS) {
345
+ // Too many recoveries — mark task as abandoned to break the loop
346
+ db.run("UPDATE session SET status = 'interrupted', ended_at = ? WHERE task_id = ? AND status = 'running'", [
347
+ ts,
348
+ task.id,
349
+ ]);
350
+ db.run(
351
+ "UPDATE task SET status = 'abandoned', assignee_agent_id = NULL, claim_token = NULL, updated_at = ? WHERE id = ?",
352
+ [ts, task.id],
353
+ );
354
+ if (task.assignee_agent_id) {
355
+ db.run(
356
+ `UPDATE agent SET status = 'idle', last_active_at = ? WHERE id = ? AND status != 'offline'
357
+ AND NOT EXISTS (SELECT 1 FROM session WHERE agent_id = ? AND status = 'running')`,
358
+ [ts, task.assignee_agent_id, task.assignee_agent_id],
359
+ );
360
+ }
361
+ result.recovered_tasks.push(task.id);
362
+ if (task.runtime_id) addUnique(result.runtime_ids, task.runtime_id);
363
+ db.run(
364
+ "INSERT INTO task_thread_item (id, task_id, kind, author, content, created_at) VALUES (?, ?, 'scheduler_recovery', 'system', ?, ?)",
365
+ [generateId("ti"), task.id, `Abandoned after ${recoveryCount} failed recovery attempts: ${reason}`, ts],
366
+ );
367
+ options.publish?.("task.abandoned", { id: task.id, reason, recovery_count: recoveryCount });
368
+ return;
369
+ }
370
+
371
+ const workflow = getWorkflowForTask(db, task.id);
372
+ db.run("UPDATE session SET status = 'interrupted', ended_at = ? WHERE task_id = ? AND status = 'running'", [
373
+ ts,
374
+ task.id,
375
+ ]);
376
+ db.run(
377
+ "UPDATE task SET status = ?, assignee_agent_id = NULL, claim_token = NULL, updated_at = ? WHERE id = ?",
378
+ [workflow.initial_status, ts, task.id],
379
+ );
380
+ if (task.assignee_agent_id) {
381
+ db.run(
382
+ `UPDATE agent
383
+ SET status = 'idle', last_active_at = ?
384
+ WHERE id = ?
385
+ AND status != 'offline'
386
+ AND NOT EXISTS (
387
+ SELECT 1 FROM session
388
+ WHERE agent_id = ?
389
+ AND status = 'running'
390
+ )`,
391
+ [ts, task.assignee_agent_id, task.assignee_agent_id],
392
+ );
393
+ }
394
+
395
+ result.recovered_tasks.push(task.id);
396
+ if (task.runtime_id) addUnique(result.runtime_ids, task.runtime_id);
397
+ audit(db, "runtime_scheduler.task_recovered", task.id, {
398
+ previous_status: task.status,
399
+ assignee_agent_id: task.assignee_agent_id || null,
400
+ reason,
401
+ });
402
+ db.run(
403
+ "INSERT INTO task_thread_item (id, task_id, kind, author, content, created_at) VALUES (?, ?, 'scheduler_recovery', 'system', ?, ?)",
404
+ [generateId("ti"), task.id, `Recovered by runtime scheduler: ${reason}`, ts],
405
+ );
406
+ options.publish?.("task.recovered", { id: task.id, reason, previous_status: task.status });
407
+ }
408
+
409
+ function emptyResult(): RuntimeMaintenanceResult {
410
+ return {
411
+ scanned: 0,
412
+ recovered_tasks: [],
413
+ interrupted_sessions: [],
414
+ offline_agents: [],
415
+ heartbeat_agents: [],
416
+ runtime_ids: [],
417
+ };
418
+ }
419
+
420
+ function releaseRuntimeQueues(queue: SessionQueue, runtimeIds: string[]) {
421
+ for (const runtimeId of new Set(runtimeIds)) {
422
+ notifyNextInQueue(queue, runtimeId);
423
+ }
424
+ }
425
+
426
+ function audit(db: Database, action: string, target: string, payload: unknown) {
427
+ db.run(
428
+ "INSERT INTO audit_log (id, actor, action, target, payload_json, created_at) VALUES (?, 'system', ?, ?, ?, ?)",
429
+ [generateId("audit"), action, target, JSON.stringify(payload), now()],
430
+ );
431
+ }
432
+
433
+ function getTimestamp(options: RuntimeMaintenanceOptions) {
434
+ return options.clock?.() ?? now();
435
+ }
436
+
437
+ function getTaskTimeout(options: RuntimeMaintenanceOptions) {
438
+ return options.task_timeout_ms ?? DEFAULT_TASK_TIMEOUT_MS;
439
+ }
440
+
441
+ function getHeartbeatTimeout(options: RuntimeMaintenanceOptions) {
442
+ return options.heartbeat_timeout_ms ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
443
+ }
444
+
445
+ function addUnique(values: string[], value: string) {
446
+ if (!values.includes(value)) values.push(value);
447
+ }
@@ -0,0 +1,65 @@
1
+ import type { Database } from "bun:sqlite";
2
+
3
+ export interface TaskDependencySummary {
4
+ id: string;
5
+ title: string;
6
+ status: string;
7
+ }
8
+
9
+ export interface TaskBlockingDetails {
10
+ dependencies: TaskDependencySummary[];
11
+ blocking_dependencies: TaskDependencySummary[];
12
+ blocked_reason: string | null;
13
+ }
14
+
15
+ export function getTaskBlockingDetails(db: Database, taskIds: string[]): Record<string, TaskBlockingDetails> {
16
+ const result: Record<string, TaskBlockingDetails> = {};
17
+ const uniqueIds = [...new Set(taskIds.filter(Boolean))];
18
+ for (const id of uniqueIds) {
19
+ result[id] = {
20
+ dependencies: [],
21
+ blocking_dependencies: [],
22
+ blocked_reason: null,
23
+ };
24
+ }
25
+ if (uniqueIds.length === 0) return result;
26
+
27
+ const placeholders = uniqueIds.map(() => "?").join(",");
28
+ const dependencyRows = db.query(
29
+ `SELECT td.task_id, t.id, t.title, t.status
30
+ FROM task_dependency td
31
+ JOIN task t ON t.id = td.depends_on
32
+ WHERE td.task_id IN (${placeholders})
33
+ ORDER BY t.created_at ASC`,
34
+ ).all(...uniqueIds) as Array<{ task_id: string; id: string; title: string; status: string }>;
35
+
36
+ for (const row of dependencyRows) {
37
+ const dependency = {
38
+ id: row.id,
39
+ title: row.title,
40
+ status: row.status,
41
+ };
42
+ result[row.task_id].dependencies.push(dependency);
43
+ if (row.status !== "done") {
44
+ result[row.task_id].blocking_dependencies.push(dependency);
45
+ }
46
+ }
47
+
48
+ const reasonRows = db.query(
49
+ `SELECT task_id, content
50
+ FROM task_thread_item
51
+ WHERE task_id IN (${placeholders})
52
+ AND kind = 'workflow_transition'
53
+ AND content LIKE 'block:%'
54
+ ORDER BY created_at DESC`,
55
+ ).all(...uniqueIds) as Array<{ task_id: string; content: string }>;
56
+
57
+ for (const row of reasonRows) {
58
+ const details = result[row.task_id];
59
+ if (details && details.blocked_reason === null) {
60
+ details.blocked_reason = row.content.replace(/^block:\s*/i, "").trim() || null;
61
+ }
62
+ }
63
+
64
+ return result;
65
+ }
@@ -0,0 +1,37 @@
1
+ import type { HubContext } from "../core/server";
2
+ import { now } from "../core/db";
3
+
4
+ /**
5
+ * After a task is marked done, check if any downstream tasks
6
+ * (that depend on it) can now be unblocked.
7
+ */
8
+ export function unlockDependents(ctx: HubContext, completedTaskId: string, opts: {
9
+ doneStatus?: string;
10
+ blockedStatus?: string;
11
+ openStatus?: string;
12
+ } = {}) {
13
+ const doneStatus = opts.doneStatus || "done";
14
+ const blockedStatus = opts.blockedStatus || "blocked";
15
+ const openStatus = opts.openStatus || "open";
16
+ const dependents = ctx.db.query(
17
+ "SELECT task_id FROM task_dependency WHERE depends_on = ?"
18
+ ).all(completedTaskId) as { task_id: string }[];
19
+
20
+ for (const { task_id } of dependents) {
21
+ // Check if ALL dependencies of this downstream task are done
22
+ const pending = ctx.db.query(`
23
+ SELECT td.depends_on FROM task_dependency td
24
+ JOIN task t ON t.id = td.depends_on
25
+ WHERE td.task_id = ? AND t.status != ?
26
+ `).all(task_id, doneStatus);
27
+
28
+ if (pending.length === 0) {
29
+ const task = ctx.db.query("SELECT * FROM task WHERE id = ?").get(task_id) as any;
30
+ if (task && task.status === blockedStatus) {
31
+ const ts = now();
32
+ ctx.db.run("UPDATE task SET status = ?, updated_at = ? WHERE id = ?", [openStatus, ts, task_id]);
33
+ ctx.bus.publish("task.unblocked", { id: task_id, unblocked_by: completedTaskId });
34
+ }
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,60 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { existsSync, mkdirSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { now } from "../core/db";
5
+
6
+ /**
7
+ * Creates an isolated working directory for a task.
8
+ * Structure: <hubDir>/workspaces/<taskId>/
9
+ * ├── workspace/ — actual CWD for agent execution
10
+ * ├── logs/ — execution logs
11
+ * └── artifacts/ — build products marked for preservation
12
+ */
13
+ export function createTaskWorkDir(db: Database, hubDir: string, taskId: string): string {
14
+ const baseDir = join(hubDir, "workspaces", taskId);
15
+ const workspaceDir = join(baseDir, "workspace");
16
+ const logsDir = join(baseDir, "logs");
17
+ const artifactsDir = join(baseDir, "artifacts");
18
+
19
+ mkdirSync(workspaceDir, { recursive: true });
20
+ mkdirSync(logsDir, { recursive: true });
21
+ mkdirSync(artifactsDir, { recursive: true });
22
+
23
+ db.run("UPDATE task SET work_dir = ?, updated_at = ? WHERE id = ?", [baseDir, now(), taskId]);
24
+
25
+ return baseDir;
26
+ }
27
+
28
+ /**
29
+ * Returns the working directory for a task, creating it if it doesn't exist.
30
+ */
31
+ export function ensureTaskWorkDir(db: Database, hubDir: string, taskId: string): string {
32
+ const task = db.query("SELECT work_dir FROM task WHERE id = ?").get(taskId) as any;
33
+ if (task?.work_dir && existsSync(task.work_dir)) {
34
+ return task.work_dir;
35
+ }
36
+ return createTaskWorkDir(db, hubDir, taskId);
37
+ }
38
+
39
+ /**
40
+ * Removes the working directory for a task.
41
+ */
42
+ export function removeTaskWorkDir(db: Database, taskId: string): boolean {
43
+ const task = db.query("SELECT work_dir FROM task WHERE id = ?").get(taskId) as any;
44
+ if (!task?.work_dir) return false;
45
+
46
+ if (existsSync(task.work_dir)) {
47
+ rmSync(task.work_dir, { recursive: true, force: true });
48
+ }
49
+ db.run("UPDATE task SET work_dir = NULL, updated_at = ? WHERE id = ?", [now(), taskId]);
50
+ return true;
51
+ }
52
+
53
+ /**
54
+ * Returns info about a task's working directory.
55
+ */
56
+ export function getTaskWorkDirInfo(hubDir: string, taskId: string) {
57
+ const baseDir = join(hubDir, "workspaces", taskId);
58
+ const exists = existsSync(baseDir);
59
+ return { taskId, path: baseDir, exists };
60
+ }