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,461 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { generateId, now } from "../core/db";
|
|
3
|
+
import type { HubContext } from "../core/server";
|
|
4
|
+
import { proposeRoster } from "../chief";
|
|
5
|
+
import { claimTaskForAgent } from "../api/tasks";
|
|
6
|
+
|
|
7
|
+
export interface ExecutionResult {
|
|
8
|
+
executed: number;
|
|
9
|
+
blocked: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ScheduledTaskExecutionResult {
|
|
13
|
+
fired: number;
|
|
14
|
+
created: number;
|
|
15
|
+
assigned: number;
|
|
16
|
+
blocked: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function executeHooksForEvent(db: Database, event: string, payload: Record<string, any>): ExecutionResult {
|
|
20
|
+
const hooks = db.query("SELECT * FROM hook_binding WHERE event = ? AND enabled = 1 ORDER BY created_at ASC").all(event) as any[];
|
|
21
|
+
const result = { executed: 0, blocked: 0 };
|
|
22
|
+
|
|
23
|
+
for (const hook of hooks) {
|
|
24
|
+
if (!scopeMatches(hook, payload)) continue;
|
|
25
|
+
if (hook.when_expr && !evaluateWhenExpression(hook.when_expr, payload)) continue;
|
|
26
|
+
const action = parseAction(hook.action);
|
|
27
|
+
const guard = checkExecutionGuards(db, hook, action, payload);
|
|
28
|
+
if (!guard.allowed) {
|
|
29
|
+
result.blocked++;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const outcome = executeAction(db, action, payload);
|
|
33
|
+
result.executed += outcome.executed;
|
|
34
|
+
result.blocked += outcome.blocked;
|
|
35
|
+
if (outcome.executed > 0) recordGuardExecution(db, hook, action, payload);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function checkExecutionGuards(db: Database, binding: any, action: any, payload: Record<string, any>) {
|
|
42
|
+
const idempotencyKey = renderTemplate(action.idempotency_key, payload);
|
|
43
|
+
if (idempotencyKey) {
|
|
44
|
+
const exists = db.query("SELECT id FROM event_log WHERE type = 'hook.idempotency' AND payload_json = ? LIMIT 1").get(idempotencyKey);
|
|
45
|
+
if (exists) return { allowed: false };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const rateLimit = action.rate_limit;
|
|
49
|
+
if (rateLimit?.max && rateLimit?.window_ms) {
|
|
50
|
+
const since = now() - Number(rateLimit.window_ms);
|
|
51
|
+
const count = (db.query(
|
|
52
|
+
"SELECT COUNT(*) AS count FROM event_log WHERE type = 'hook.fired' AND payload_json = ? AND created_at >= ?",
|
|
53
|
+
).get(binding.id, since) as any).count;
|
|
54
|
+
if (count >= Number(rateLimit.max)) {
|
|
55
|
+
disableHookAndAlert(db, binding.id, `Hook ${binding.id} exceeded rate limit`);
|
|
56
|
+
return { allowed: false };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { allowed: true };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function recordGuardExecution(db: Database, binding: any, action: any, payload: Record<string, any>) {
|
|
64
|
+
const ts = now();
|
|
65
|
+
db.run("INSERT INTO event_log (id, type, payload_json, created_at) VALUES (?, 'hook.fired', ?, ?)", [
|
|
66
|
+
generateId("evt"),
|
|
67
|
+
binding.id,
|
|
68
|
+
ts,
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
const idempotencyKey = renderTemplate(action.idempotency_key, payload);
|
|
72
|
+
if (idempotencyKey) {
|
|
73
|
+
db.run("INSERT INTO event_log (id, type, payload_json, created_at) VALUES (?, 'hook.idempotency', ?, ?)", [
|
|
74
|
+
generateId("evt"),
|
|
75
|
+
idempotencyKey,
|
|
76
|
+
ts,
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function disableHookAndAlert(db: Database, hookId: string, body: string) {
|
|
82
|
+
const ts = now();
|
|
83
|
+
db.run("UPDATE hook_binding SET enabled = 0 WHERE id = ?", [hookId]);
|
|
84
|
+
db.run(
|
|
85
|
+
`INSERT INTO inbox_message (id, kind, from_actor, to_actor, subject, body, ref_json, status, created_at)
|
|
86
|
+
VALUES (?, 'escalation', 'scheduler', 'user', 'Hook rate limit exceeded', ?, ?, 'unread', ?)`,
|
|
87
|
+
[generateId("msg"), body, JSON.stringify({ hook_id: hookId }), ts],
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function evaluateWhenExpression(expr: string, payload: Record<string, any>): boolean {
|
|
92
|
+
return expr
|
|
93
|
+
.split("&&")
|
|
94
|
+
.map((part) => part.trim())
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.every((part) => evaluateComparison(part, payload));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function evaluateComparison(expr: string, payload: Record<string, any>): boolean {
|
|
100
|
+
const match = expr.match(/^([A-Za-z0-9_.]+)\s*(==|!=)\s*['"]?([^'"]+)['"]?$/);
|
|
101
|
+
if (!match) return false;
|
|
102
|
+
const [, path, op, expected] = match;
|
|
103
|
+
const actual = getPath(payload, path);
|
|
104
|
+
return op === "==" ? String(actual) === expected : String(actual) !== expected;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function getPath(source: Record<string, any>, path: string): unknown {
|
|
108
|
+
return path.split(".").reduce((value: any, key) => value?.[key], source);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderTemplate(template: string | undefined, payload: Record<string, any>): string | null {
|
|
112
|
+
if (!template) return null;
|
|
113
|
+
return template.replace(/\{\{([A-Za-z0-9_.]+)\}\}/g, (_match, path) => String(getPath(payload, path) ?? ""));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function runDueCrons(db: Database, firedAt = now()): ExecutionResult {
|
|
117
|
+
const crons = db.query("SELECT * FROM cron_binding WHERE enabled = 1 ORDER BY created_at ASC").all() as any[];
|
|
118
|
+
const result = { executed: 0, blocked: 0 };
|
|
119
|
+
|
|
120
|
+
for (const cron of crons) {
|
|
121
|
+
if (!isCronDue(cron, firedAt)) continue;
|
|
122
|
+
const action = parseAction(cron.action);
|
|
123
|
+
const outcome = executeAction(db, action, { scope: cron.scope, scope_id: cron.scope_id });
|
|
124
|
+
result.executed += outcome.executed;
|
|
125
|
+
result.blocked += outcome.blocked;
|
|
126
|
+
if (outcome.executed > 0) {
|
|
127
|
+
db.run("UPDATE cron_binding SET last_fired_at = ? WHERE id = ?", [firedAt, cron.id]);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function runDueScheduledTasks(ctx: HubContext, firedAt = now()): Promise<ScheduledTaskExecutionResult> {
|
|
135
|
+
const rows = ctx.db.query(
|
|
136
|
+
`SELECT * FROM scheduled_task
|
|
137
|
+
WHERE enabled = 1 AND next_fire_at IS NOT NULL AND next_fire_at <= ?
|
|
138
|
+
ORDER BY next_fire_at ASC, created_at ASC`,
|
|
139
|
+
).all(firedAt) as any[];
|
|
140
|
+
const result = { fired: 0, created: 0, assigned: 0, blocked: 0 };
|
|
141
|
+
|
|
142
|
+
for (const scheduled of rows) {
|
|
143
|
+
result.fired++;
|
|
144
|
+
|
|
145
|
+
// Dedup: skip if there's already a pending/active task from this scheduled_task
|
|
146
|
+
const existingTask = ctx.db.query(
|
|
147
|
+
`SELECT id FROM task
|
|
148
|
+
WHERE created_by = 'scheduled_task'
|
|
149
|
+
AND project_id = ?
|
|
150
|
+
AND title = ?
|
|
151
|
+
AND status IN ('open', 'claimed', 'in_progress')
|
|
152
|
+
LIMIT 1`,
|
|
153
|
+
).get(scheduled.project_id, scheduled.title) as any;
|
|
154
|
+
if (existingTask) {
|
|
155
|
+
result.blocked++;
|
|
156
|
+
// Still advance next_fire_at so we don't re-fire immediately
|
|
157
|
+
const nextFireAt = scheduled.schedule_type === "cron"
|
|
158
|
+
? computeNextFireAt({ schedule_type: "cron", cron_expr: scheduled.cron_expr }, firedAt)
|
|
159
|
+
: null;
|
|
160
|
+
const enabled = scheduled.schedule_type === "once" ? 0 : scheduled.enabled;
|
|
161
|
+
ctx.db.run(
|
|
162
|
+
`UPDATE scheduled_task SET enabled = ?, last_fired_at = ?, next_fire_at = ?, updated_at = ? WHERE id = ?`,
|
|
163
|
+
[enabled, firedAt, nextFireAt, now(), scheduled.id],
|
|
164
|
+
);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const role = resolveScheduledRole(ctx.db, scheduled);
|
|
169
|
+
const agent = role ? resolveAgentForRole(ctx.db, role.id) : null;
|
|
170
|
+
const taskId = generateId("task");
|
|
171
|
+
const ts = now();
|
|
172
|
+
ctx.db.run(
|
|
173
|
+
`INSERT INTO task (id, project_id, title, description, status, priority, assignee_agent_id, required_capabilities_json, lineage_depth, created_by, created_at, updated_at)
|
|
174
|
+
VALUES (?, ?, ?, ?, 'open', ?, NULL, '[]', 0, 'scheduled_task', ?, ?)`,
|
|
175
|
+
[
|
|
176
|
+
taskId,
|
|
177
|
+
scheduled.project_id,
|
|
178
|
+
scheduled.title,
|
|
179
|
+
scheduled.description || null,
|
|
180
|
+
scheduled.priority || 0,
|
|
181
|
+
ts,
|
|
182
|
+
ts,
|
|
183
|
+
],
|
|
184
|
+
);
|
|
185
|
+
result.created++;
|
|
186
|
+
ctx.bus?.publish?.("task.created", {
|
|
187
|
+
id: taskId,
|
|
188
|
+
project_id: scheduled.project_id,
|
|
189
|
+
title: scheduled.title,
|
|
190
|
+
scheduled_task_id: scheduled.id,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (agent) {
|
|
194
|
+
const claimResult = await claimTaskForAgent(ctx, taskId, agent.id, { deferIfProjectBusy: true });
|
|
195
|
+
if ("error" in claimResult || "deferred" in claimResult) {
|
|
196
|
+
result.blocked++;
|
|
197
|
+
} else {
|
|
198
|
+
result.assigned++;
|
|
199
|
+
}
|
|
200
|
+
} else if (scheduled.assignee_role_id || scheduled.assignee_role_name) {
|
|
201
|
+
result.blocked++;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const nextFireAt = scheduled.schedule_type === "cron"
|
|
205
|
+
? computeNextFireAt({ schedule_type: "cron", cron_expr: scheduled.cron_expr }, firedAt)
|
|
206
|
+
: null;
|
|
207
|
+
|
|
208
|
+
if (scheduled.schedule_type === "cron" && nextFireAt === null) {
|
|
209
|
+
// Cannot determine next fire time — disable the task to prevent silent death
|
|
210
|
+
ctx.db.run(
|
|
211
|
+
`UPDATE scheduled_task
|
|
212
|
+
SET enabled = 0, last_fired_at = ?, next_fire_at = NULL, last_task_id = ?, updated_at = ?
|
|
213
|
+
WHERE id = ?`,
|
|
214
|
+
[firedAt, taskId, ts, scheduled.id],
|
|
215
|
+
);
|
|
216
|
+
ctx.db.run(
|
|
217
|
+
"INSERT INTO event_log (id, type, payload_json, created_at) VALUES (?, 'scheduled_task.disabled', ?, ?)",
|
|
218
|
+
[
|
|
219
|
+
generateId("evt"),
|
|
220
|
+
JSON.stringify({ scheduled_task_id: scheduled.id, reason: "no_next_fire_at", cron_expr: scheduled.cron_expr }),
|
|
221
|
+
ts,
|
|
222
|
+
],
|
|
223
|
+
);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const enabled = scheduled.schedule_type === "once" ? 0 : scheduled.enabled;
|
|
228
|
+
ctx.db.run(
|
|
229
|
+
`UPDATE scheduled_task
|
|
230
|
+
SET enabled = ?, last_fired_at = ?, next_fire_at = ?, last_task_id = ?, updated_at = ?
|
|
231
|
+
WHERE id = ?`,
|
|
232
|
+
[enabled, firedAt, nextFireAt, taskId, ts, scheduled.id],
|
|
233
|
+
);
|
|
234
|
+
ctx.db.run(
|
|
235
|
+
"INSERT INTO event_log (id, type, payload_json, created_at) VALUES (?, 'scheduled_task.fired', ?, ?)",
|
|
236
|
+
[
|
|
237
|
+
generateId("evt"),
|
|
238
|
+
JSON.stringify({ scheduled_task_id: scheduled.id, task_id: taskId, agent_id: agent?.id || null }),
|
|
239
|
+
ts,
|
|
240
|
+
],
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return result;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function computeNextFireAt(
|
|
248
|
+
input: { schedule_type: string; cron_expr?: string | null; run_at?: number | null },
|
|
249
|
+
from = now(),
|
|
250
|
+
): number | null {
|
|
251
|
+
if (input.schedule_type === "once") {
|
|
252
|
+
const runAt = Number(input.run_at || 0);
|
|
253
|
+
return runAt > 0 ? runAt : null;
|
|
254
|
+
}
|
|
255
|
+
if (input.schedule_type !== "cron") return null;
|
|
256
|
+
const parsed = parseCronExpression(input.cron_expr || "");
|
|
257
|
+
let cursor = Math.floor(from / 60_000) * 60_000 + 60_000;
|
|
258
|
+
const deadline = cursor + 366 * 24 * 60 * 60_000;
|
|
259
|
+
while (cursor <= deadline) {
|
|
260
|
+
if (cronMatches(parsed, new Date(cursor))) return cursor;
|
|
261
|
+
cursor += 60_000;
|
|
262
|
+
}
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function validateScheduleInput(input: { schedule_type?: string; cron_expr?: string | null; run_at?: number | null }) {
|
|
267
|
+
const scheduleType = input.schedule_type || (input.cron_expr ? "cron" : "once");
|
|
268
|
+
if (scheduleType !== "cron" && scheduleType !== "once") {
|
|
269
|
+
throw new Error("schedule_type must be cron or once");
|
|
270
|
+
}
|
|
271
|
+
if (scheduleType === "cron") {
|
|
272
|
+
parseCronExpression(input.cron_expr || "");
|
|
273
|
+
} else if (!Number(input.run_at || 0)) {
|
|
274
|
+
throw new Error("run_at is required for one-time scheduled tasks");
|
|
275
|
+
}
|
|
276
|
+
return scheduleType;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function executeAction(db: Database, action: any, payload: Record<string, any>): ExecutionResult {
|
|
280
|
+
if (action.type === "spawn_task") {
|
|
281
|
+
return spawnTask(db, action, payload);
|
|
282
|
+
}
|
|
283
|
+
if (action.type === "create_task") {
|
|
284
|
+
return createTask(db, action, payload);
|
|
285
|
+
}
|
|
286
|
+
if (action.type === "chief_heartbeat") {
|
|
287
|
+
return chiefHeartbeat(db, action, payload);
|
|
288
|
+
}
|
|
289
|
+
return { executed: 0, blocked: 1 };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function spawnTask(db: Database, action: any, payload: Record<string, any>): ExecutionResult {
|
|
293
|
+
const parentId = payload.task_id || action.parent_task_id;
|
|
294
|
+
const parent = db.query("SELECT * FROM task WHERE id = ?").get(parentId) as any;
|
|
295
|
+
if (!parent) return { executed: 0, blocked: 1 };
|
|
296
|
+
if ((parent.lineage_depth || 0) >= 10) return { executed: 0, blocked: 1 };
|
|
297
|
+
insertTask(db, {
|
|
298
|
+
project_id: parent.project_id,
|
|
299
|
+
parent_task_id: parent.id,
|
|
300
|
+
title: action.title || `Follow up: ${parent.title}`,
|
|
301
|
+
required_capabilities: action.skill ? [action.skill] : [],
|
|
302
|
+
lineage_depth: (parent.lineage_depth || 0) + 1,
|
|
303
|
+
created_by: "hook",
|
|
304
|
+
});
|
|
305
|
+
return { executed: 1, blocked: 0 };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function createTask(db: Database, action: any, payload: Record<string, any>): ExecutionResult {
|
|
309
|
+
const projectId = action.project_id || (payload.scope === "project" ? payload.scope_id : null);
|
|
310
|
+
if (!projectId || !action.title) return { executed: 0, blocked: 1 };
|
|
311
|
+
insertTask(db, {
|
|
312
|
+
project_id: projectId,
|
|
313
|
+
parent_task_id: null,
|
|
314
|
+
title: action.title,
|
|
315
|
+
required_capabilities: action.skill ? [action.skill] : [],
|
|
316
|
+
lineage_depth: 0,
|
|
317
|
+
created_by: "cron",
|
|
318
|
+
});
|
|
319
|
+
return { executed: 1, blocked: 0 };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function chiefHeartbeat(db: Database, action: any, payload: Record<string, any>): ExecutionResult {
|
|
323
|
+
const workspaceId = action.workspace_id || (payload.scope === "workspace" ? payload.scope_id : null);
|
|
324
|
+
const workspaces = workspaceId
|
|
325
|
+
? [{ id: workspaceId }]
|
|
326
|
+
: db.query("SELECT id FROM workspace WHERE status = 'active'").all() as Array<{ id: string }>;
|
|
327
|
+
let executed = 0;
|
|
328
|
+
let blocked = 0;
|
|
329
|
+
for (const workspace of workspaces) {
|
|
330
|
+
const exists = db.query("SELECT id FROM workspace WHERE id = ?").get(workspace.id);
|
|
331
|
+
if (!exists) {
|
|
332
|
+
blocked++;
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
proposeRoster(db, workspace.id);
|
|
336
|
+
executed++;
|
|
337
|
+
}
|
|
338
|
+
return { executed, blocked };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function insertTask(
|
|
342
|
+
db: Database,
|
|
343
|
+
input: {
|
|
344
|
+
project_id: string;
|
|
345
|
+
parent_task_id: string | null;
|
|
346
|
+
title: string;
|
|
347
|
+
required_capabilities: string[];
|
|
348
|
+
lineage_depth: number;
|
|
349
|
+
created_by: string;
|
|
350
|
+
},
|
|
351
|
+
) {
|
|
352
|
+
const ts = now();
|
|
353
|
+
db.run(
|
|
354
|
+
`INSERT INTO task (id, project_id, parent_task_id, title, description, status, required_capabilities_json, priority, lineage_depth, created_by, created_at, updated_at)
|
|
355
|
+
VALUES (?, ?, ?, ?, '', 'open', ?, 0, ?, ?, ?, ?)`,
|
|
356
|
+
[
|
|
357
|
+
generateId("task"),
|
|
358
|
+
input.project_id,
|
|
359
|
+
input.parent_task_id,
|
|
360
|
+
input.title,
|
|
361
|
+
JSON.stringify(input.required_capabilities),
|
|
362
|
+
input.lineage_depth,
|
|
363
|
+
input.created_by,
|
|
364
|
+
ts,
|
|
365
|
+
ts,
|
|
366
|
+
],
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function resolveScheduledRole(db: Database, scheduled: any) {
|
|
371
|
+
if (scheduled.assignee_role_id) {
|
|
372
|
+
const role = db.query("SELECT * FROM role WHERE id = ? AND workspace_id = ?").get(scheduled.assignee_role_id, scheduled.workspace_id) as any;
|
|
373
|
+
if (role) return role;
|
|
374
|
+
}
|
|
375
|
+
if (scheduled.assignee_role_name) {
|
|
376
|
+
return db.query(
|
|
377
|
+
"SELECT * FROM role WHERE workspace_id = ? AND LOWER(name) = LOWER(?) ORDER BY created_at ASC LIMIT 1",
|
|
378
|
+
).get(scheduled.workspace_id, scheduled.assignee_role_name) as any;
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function resolveAgentForRole(db: Database, roleId: string) {
|
|
384
|
+
return db.query(
|
|
385
|
+
`SELECT * FROM agent
|
|
386
|
+
WHERE role_id = ? AND status != 'offline'
|
|
387
|
+
ORDER BY CASE status WHEN 'idle' THEN 0 WHEN 'working' THEN 1 ELSE 2 END, last_active_at DESC
|
|
388
|
+
LIMIT 1`,
|
|
389
|
+
).get(roleId) as any;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function scopeMatches(binding: any, payload: Record<string, any>): boolean {
|
|
393
|
+
if (binding.scope === "project") return binding.scope_id === payload.project_id;
|
|
394
|
+
if (binding.scope === "task") return binding.scope_id === payload.task_id;
|
|
395
|
+
if (binding.scope === "workspace") return binding.scope_id === payload.workspace_id;
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function parseAction(raw: string) {
|
|
400
|
+
try {
|
|
401
|
+
return JSON.parse(raw);
|
|
402
|
+
} catch {
|
|
403
|
+
return { type: raw };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function isCronDue(cron: any, firedAt: number): boolean {
|
|
408
|
+
if (!cron.last_fired_at) return true;
|
|
409
|
+
if (cron.cron_expr === "* * * * *") return firedAt - cron.last_fired_at >= 60_000;
|
|
410
|
+
return firedAt > cron.last_fired_at;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function parseCronExpression(expr: string): number[][] {
|
|
414
|
+
const parts = expr.trim().split(/\s+/);
|
|
415
|
+
if (parts.length !== 5) throw new Error("cron_expr must use 5 fields: minute hour day-of-month month day-of-week");
|
|
416
|
+
return [
|
|
417
|
+
parseCronField(parts[0], 0, 59),
|
|
418
|
+
parseCronField(parts[1], 0, 23),
|
|
419
|
+
parseCronField(parts[2], 1, 31),
|
|
420
|
+
parseCronField(parts[3], 1, 12),
|
|
421
|
+
parseCronField(parts[4], 0, 7),
|
|
422
|
+
];
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function parseCronField(raw: string, min: number, max: number): number[] {
|
|
426
|
+
const values = new Set<number>();
|
|
427
|
+
for (const part of raw.split(",")) {
|
|
428
|
+
const value = part.trim();
|
|
429
|
+
if (!value) throw new Error("Invalid cron field");
|
|
430
|
+
const [rangeRaw, stepRaw] = value.split("/");
|
|
431
|
+
const step = stepRaw === undefined ? 1 : Number(stepRaw);
|
|
432
|
+
if (!Number.isInteger(step) || step <= 0) throw new Error(`Invalid cron step: ${value}`);
|
|
433
|
+
let start: number;
|
|
434
|
+
let end: number;
|
|
435
|
+
if (rangeRaw === "*") {
|
|
436
|
+
start = min;
|
|
437
|
+
end = max;
|
|
438
|
+
} else if (rangeRaw.includes("-")) {
|
|
439
|
+
const [a, b] = rangeRaw.split("-").map(Number);
|
|
440
|
+
start = a;
|
|
441
|
+
end = b;
|
|
442
|
+
} else {
|
|
443
|
+
start = Number(rangeRaw);
|
|
444
|
+
end = start;
|
|
445
|
+
}
|
|
446
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < min || end > max || start > end) {
|
|
447
|
+
throw new Error(`Invalid cron field: ${value}`);
|
|
448
|
+
}
|
|
449
|
+
for (let i = start; i <= end; i += step) values.add(i === 7 && min === 0 && max === 7 ? 0 : i);
|
|
450
|
+
}
|
|
451
|
+
return [...values].sort((a, b) => a - b);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function cronMatches(parsed: number[][], date: Date): boolean {
|
|
455
|
+
const [minutes, hours, days, months, dows] = parsed;
|
|
456
|
+
return minutes.includes(date.getMinutes())
|
|
457
|
+
&& hours.includes(date.getHours())
|
|
458
|
+
&& days.includes(date.getDate())
|
|
459
|
+
&& months.includes(date.getMonth() + 1)
|
|
460
|
+
&& dows.includes(date.getDay());
|
|
461
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export interface ParsedTranscript {
|
|
2
|
+
summary: string | null;
|
|
3
|
+
filesChanged: string[];
|
|
4
|
+
artifacts: Array<{ kind: string; path: string }>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Parse a session transcript to extract structured results.
|
|
9
|
+
* Uses heuristics to find summary text and file operations.
|
|
10
|
+
*/
|
|
11
|
+
export function parseTranscript(transcript: string): ParsedTranscript {
|
|
12
|
+
const filesChanged = extractFilesChanged(transcript);
|
|
13
|
+
const summary = extractSummary(transcript);
|
|
14
|
+
const artifacts = extractArtifacts(transcript);
|
|
15
|
+
return { summary, filesChanged, artifacts };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function extractFilesChanged(text: string): string[] {
|
|
19
|
+
const files = new Set<string>();
|
|
20
|
+
const patterns = [
|
|
21
|
+
/(?:Write|Edit|Read)\(file_path="([^"]+)"/g,
|
|
22
|
+
/(?:Write|Edit)\s*\(\s*file_path\s*[:=]\s*"([^"]+)"/g,
|
|
23
|
+
/file_path="([^"]+)"/g,
|
|
24
|
+
];
|
|
25
|
+
for (const pattern of patterns) {
|
|
26
|
+
for (const m of text.matchAll(pattern)) {
|
|
27
|
+
if (m[1] && !m[1].includes("test")) files.add(m[1]);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return [...files];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function extractSummary(text: string): string | null {
|
|
34
|
+
if (!text.trim()) return null;
|
|
35
|
+
|
|
36
|
+
// Strategy 1: Look for explicit summary heading
|
|
37
|
+
const summaryMatch = text.match(/##\s*Summary\s*\n([\s\S]+?)(?=\n##|\n---|$)/i);
|
|
38
|
+
if (summaryMatch) {
|
|
39
|
+
return summaryMatch[1].trim().slice(0, 1000);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Strategy 2: Take the last meaningful paragraph (likely the conclusion)
|
|
43
|
+
const lines = text.trim().split("\n");
|
|
44
|
+
const blocks: string[] = [];
|
|
45
|
+
let current: string[] = [];
|
|
46
|
+
for (const line of lines) {
|
|
47
|
+
if (line.trim() === "") {
|
|
48
|
+
if (current.length > 0) {
|
|
49
|
+
blocks.push(current.join("\n"));
|
|
50
|
+
current = [];
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
current.push(line);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (current.length > 0) blocks.push(current.join("\n"));
|
|
57
|
+
|
|
58
|
+
// Filter out tool call blocks, keep text blocks
|
|
59
|
+
const textBlocks = blocks.filter(
|
|
60
|
+
(b) => !b.startsWith("{") && !b.includes("file_path=") && b.length > 20
|
|
61
|
+
);
|
|
62
|
+
if (textBlocks.length === 0) return null;
|
|
63
|
+
|
|
64
|
+
const last = textBlocks[textBlocks.length - 1];
|
|
65
|
+
return last.slice(0, 1000);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function extractArtifacts(text: string): Array<{ kind: string; path: string }> {
|
|
69
|
+
const artifacts: Array<{ kind: string; path: string }> = [];
|
|
70
|
+
const writePattern = /Write\(file_path="([^"]+)"/g;
|
|
71
|
+
for (const m of text.matchAll(writePattern)) {
|
|
72
|
+
artifacts.push({ kind: "file_created", path: m[1] });
|
|
73
|
+
}
|
|
74
|
+
return artifacts;
|
|
75
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
|
|
3
|
+
export interface SessionEvent {
|
|
4
|
+
type: string;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parses a stream of text chunks and emits structured JSON-line events.
|
|
10
|
+
* Events must be a single JSON object per line with a "type" field.
|
|
11
|
+
*/
|
|
12
|
+
export class RealtimeParser extends EventEmitter {
|
|
13
|
+
private buffer = "";
|
|
14
|
+
|
|
15
|
+
feed(chunk: string): void {
|
|
16
|
+
this.buffer += chunk;
|
|
17
|
+
const lines = this.buffer.split("\n");
|
|
18
|
+
// Keep last partial line in buffer
|
|
19
|
+
this.buffer = lines.pop() || "";
|
|
20
|
+
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
const trimmed = line.trim();
|
|
23
|
+
if (!trimmed.startsWith("{")) continue;
|
|
24
|
+
try {
|
|
25
|
+
const obj = JSON.parse(trimmed);
|
|
26
|
+
if (typeof obj === "object" && obj !== null && typeof obj.type === "string") {
|
|
27
|
+
this.emit("event", obj as SessionEvent);
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// Not valid JSON, skip
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
flush(): void {
|
|
36
|
+
if (this.buffer.trim()) {
|
|
37
|
+
this.feed("\n");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|