maestro-agent 0.0.1 → 0.0.3
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/apple-touch-icon.png +0 -0
- package/dist/web/assets/Connections-BMA04Ycg.js +11 -0
- package/dist/web/assets/GanttView-DXjh0gxg.js +49 -0
- package/dist/web/assets/Home-Ct3Ho0Qt.js +1 -0
- package/dist/web/assets/HooksCrons--0kyVJcR.js +11 -0
- package/dist/web/assets/ProjectDetail-B_IqEpFu.js +1 -0
- package/dist/web/assets/Roles-D1tIQzto.js +24 -0
- package/dist/web/assets/Settings-yts4LUmH.js +11 -0
- package/dist/web/assets/Skills-DbuNLjIV.js +12 -0
- package/dist/web/assets/Wizard-vJol8-Y4.js +11 -0
- package/dist/web/assets/WorkspaceChat-DrsLs4m2.js +56 -0
- package/dist/web/assets/WorkspaceDashboard-B9vgrd2Z.js +6 -0
- package/dist/web/assets/WorkspaceNew-DoNGYHCG.js +1 -0
- package/dist/web/assets/WorkspaceProjects-DDp3mUse.js +6 -0
- package/dist/web/assets/WorkspaceSchedules-BTjmCbYG.js +1 -0
- package/dist/web/assets/WorkspaceTasks-mPU-bhKR.js +41 -0
- package/dist/web/assets/activity-CIA8bIA4.js +6 -0
- package/dist/web/assets/addon-fit-BlxrFPDK.js +1 -0
- package/dist/web/assets/arrow-right-S7ID7nDp.js +6 -0
- package/dist/web/assets/badge-DDTUzWIi.js +1 -0
- package/dist/web/assets/circle-check-B3P1qK0Z.js +6 -0
- package/dist/web/assets/clock-f9aYZox0.js +6 -0
- package/dist/web/assets/index-BRo4Du_s.js +11 -0
- package/dist/web/assets/index-C7kx39S9.js +196 -0
- package/dist/web/assets/index-D6LSdZea.css +1 -0
- package/dist/web/assets/plus-BHnOxbns.js +6 -0
- package/dist/web/assets/refresh-cw-BWX04Hg3.js +6 -0
- package/dist/web/assets/save-BLbb_9xz.js +6 -0
- package/dist/web/assets/sparkles-CDr6Dw1e.js +6 -0
- package/dist/web/assets/trash-2-9-ThEdey.js +6 -0
- package/dist/web/assets/useEventStream-DXt2Hmei.js +1 -0
- package/dist/web/assets/x-DVdKPXXy.js +6 -0
- package/dist/web/assets/xterm-DYP7pi_n.css +32 -0
- package/dist/web/assets/xterm-DlVFs1Kw.js +9 -0
- package/dist/web/favicon-512.png +0 -0
- package/dist/web/favicon.png +0 -0
- package/dist/web/index.html +15 -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
package/src/api/tasks.ts
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { generateId, now } from "../core/db";
|
|
2
|
+
import type { Router } from "../core/router";
|
|
3
|
+
import { body, getIdempotencyKey, json } from "../core/router";
|
|
4
|
+
import type { HubContext } from "../core/server";
|
|
5
|
+
import { required } from "../core/validate";
|
|
6
|
+
import { findProjectExecutionBlocker } from "../ops/project-serial";
|
|
7
|
+
import { resolveAgentForRole } from "../ops/role-dispatch";
|
|
8
|
+
import { getTaskBlockingDetails } from "../ops/task-blocking";
|
|
9
|
+
import { unlockDependents } from "../ops/task-deps";
|
|
10
|
+
import { getWorkflowForTask, listTaskActions, transitionTask, workflowStatusesForProject } from "../workflows";
|
|
11
|
+
import { createSession } from "./sessions";
|
|
12
|
+
|
|
13
|
+
export interface ClaimTaskOptions {
|
|
14
|
+
deferIfProjectBusy?: boolean;
|
|
15
|
+
skipSession?: boolean; // Just assign, don't spawn session (for autopilot pre-assignment)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function claimTaskForAgent(ctx: HubContext, taskId: string, agentId: string, options: ClaimTaskOptions = {}): Promise<
|
|
19
|
+
| { task: any; session_id: string }
|
|
20
|
+
| { task: any; deferred: true; blocked_by_task_id: string }
|
|
21
|
+
| { error: string; status: number }
|
|
22
|
+
> {
|
|
23
|
+
required({ agent_id: agentId }, ["agent_id"]);
|
|
24
|
+
const task = ctx.db.query("SELECT * FROM task WHERE id = ?").get(taskId) as any;
|
|
25
|
+
if (!task) return { error: "Not found", status: 404 };
|
|
26
|
+
const workflow = getWorkflowForTask(ctx.db, taskId);
|
|
27
|
+
const claimAction = workflow.actions.find((action) => action.id === "claim" && action.from.includes(task.status));
|
|
28
|
+
if (!claimAction) return { error: `No claim action is available from ${task.status}`, status: 409 };
|
|
29
|
+
if (task.status !== workflow.initial_status) return { error: `Task is not ${workflow.initial_status}`, status: 409 };
|
|
30
|
+
|
|
31
|
+
const projectBlocker = findProjectExecutionBlocker(ctx.db, {
|
|
32
|
+
projectId: task.project_id,
|
|
33
|
+
excludeTaskId: taskId,
|
|
34
|
+
claimedStatus: claimAction.to,
|
|
35
|
+
});
|
|
36
|
+
if (projectBlocker) {
|
|
37
|
+
if (!options.deferIfProjectBusy) {
|
|
38
|
+
return { error: `Project already has active task: ${projectBlocker.id}`, status: 409 };
|
|
39
|
+
}
|
|
40
|
+
const ts = now();
|
|
41
|
+
ctx.db.run(
|
|
42
|
+
"UPDATE task SET assignee_agent_id = ?, updated_at = ? WHERE id = ? AND status = ?",
|
|
43
|
+
[agentId, ts, taskId, workflow.initial_status],
|
|
44
|
+
);
|
|
45
|
+
const row = ctx.db.query("SELECT * FROM task WHERE id = ?").get(taskId) as any;
|
|
46
|
+
ctx.bus.publish("task.claim_deferred", { id: taskId, agent_id: agentId, blocked_by_task_id: projectBlocker.id });
|
|
47
|
+
return { task: row, deferred: true, blocked_by_task_id: projectBlocker.id };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const token = crypto.randomUUID().slice(0, 8);
|
|
51
|
+
const ts = now();
|
|
52
|
+
ctx.db.run(
|
|
53
|
+
"UPDATE task SET status = ?, assignee_agent_id = ?, claim_token = ?, updated_at = ? WHERE id = ? AND status = ?",
|
|
54
|
+
[claimAction.to, agentId, token, ts, taskId, workflow.initial_status]
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (options.skipSession) {
|
|
58
|
+
const row = ctx.db.query("SELECT * FROM task WHERE id = ?").get(taskId) as any;
|
|
59
|
+
ctx.bus.publish("task.claimed", { id: taskId, agent_id: agentId, session_id: null });
|
|
60
|
+
return { task: row, session_id: "" };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sessionResult = await createSession(ctx, {
|
|
64
|
+
agent_id: agentId,
|
|
65
|
+
task_id: taskId,
|
|
66
|
+
});
|
|
67
|
+
if ("error" in sessionResult) {
|
|
68
|
+
ctx.db.run(
|
|
69
|
+
"UPDATE task SET status = ?, assignee_agent_id = ?, claim_token = NULL, updated_at = ? WHERE id = ? AND claim_token = ?",
|
|
70
|
+
[workflow.initial_status, task.assignee_agent_id || null, now(), taskId, token]
|
|
71
|
+
);
|
|
72
|
+
return { error: sessionResult.error, status: sessionResult.status };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const row = ctx.db.query("SELECT * FROM task WHERE id = ?").get(taskId) as any;
|
|
76
|
+
const session_id = sessionResult.session.id;
|
|
77
|
+
ctx.bus.publish("task.claimed", { id: taskId, agent_id: agentId, session_id });
|
|
78
|
+
return { task: row, session_id };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Ensure the agent assigned to a task is awake and has a running session.
|
|
83
|
+
* Called when a task transitions to an active state (e.g., via Board drag).
|
|
84
|
+
*/
|
|
85
|
+
async function ensureAgentSession(ctx: HubContext, task: any): Promise<void> {
|
|
86
|
+
const agentId = task.assignee_agent_id;
|
|
87
|
+
if (!agentId) return;
|
|
88
|
+
|
|
89
|
+
const agent = ctx.db.query("SELECT * FROM agent WHERE id = ?").get(agentId) as any;
|
|
90
|
+
if (!agent) return;
|
|
91
|
+
|
|
92
|
+
// Wake up offline agent
|
|
93
|
+
if (agent.status === "offline") {
|
|
94
|
+
const ts = now();
|
|
95
|
+
ctx.db.run("UPDATE agent SET status = 'idle', last_active_at = ? WHERE id = ?", [ts, agentId]);
|
|
96
|
+
ctx.bus.publish("agent.woken", { id: agentId, task_id: task.id });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Check if there's already a running session for this task
|
|
100
|
+
const existingSession = ctx.db.query(
|
|
101
|
+
"SELECT id FROM session WHERE agent_id = ? AND task_id = ? AND status = 'running' LIMIT 1"
|
|
102
|
+
).get(agentId, task.id) as any;
|
|
103
|
+
if (existingSession) return;
|
|
104
|
+
|
|
105
|
+
// Create a new session for the task
|
|
106
|
+
const result = await createSession(ctx, { agent_id: agentId, task_id: task.id });
|
|
107
|
+
if (!("error" in result)) {
|
|
108
|
+
ctx.bus.publish("task.session_started", { task_id: task.id, agent_id: agentId, session_id: result.session.id });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function registerTaskRoutes(router: Router, ctx: HubContext) {
|
|
113
|
+
router.post("/api/tasks", async (req) => {
|
|
114
|
+
const {
|
|
115
|
+
project_id,
|
|
116
|
+
title,
|
|
117
|
+
description,
|
|
118
|
+
required_capabilities,
|
|
119
|
+
priority,
|
|
120
|
+
parent_task_id,
|
|
121
|
+
created_by,
|
|
122
|
+
depends_on,
|
|
123
|
+
assignee_role,
|
|
124
|
+
assignee_role_id,
|
|
125
|
+
} = await body(req);
|
|
126
|
+
required({ project_id, title }, ["project_id", "title"]);
|
|
127
|
+
const project = ctx.db.query("SELECT * FROM project WHERE id = ?").get(project_id) as any;
|
|
128
|
+
if (!project) return json({ error: "Project not found" }, 404);
|
|
129
|
+
|
|
130
|
+
let assigneeAgentId: string | null = null;
|
|
131
|
+
let assigneeRoleId: string | null = null;
|
|
132
|
+
if (assignee_role_id || assignee_role) {
|
|
133
|
+
const role = assignee_role_id
|
|
134
|
+
? ctx.db.query("SELECT * FROM role WHERE id = ? AND workspace_id = ?").get(assignee_role_id, project.workspace_id) as any
|
|
135
|
+
: ctx.db.query("SELECT * FROM role WHERE workspace_id = ? AND LOWER(name) = LOWER(?)").get(project.workspace_id, assignee_role) as any;
|
|
136
|
+
if (!role) return json({ error: "Assignee role not found in workspace" }, 400);
|
|
137
|
+
assigneeRoleId = role.id;
|
|
138
|
+
|
|
139
|
+
// Role-centric: find available agent or wake one up
|
|
140
|
+
const resolved = resolveAgentForRole(ctx.db, role.id);
|
|
141
|
+
if (!resolved) return json({ error: `No agents exist for role: ${role.name}` }, 409);
|
|
142
|
+
assigneeAgentId = resolved.id;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const id = generateId("task");
|
|
146
|
+
const ts = now();
|
|
147
|
+
const lineage_depth = parent_task_id
|
|
148
|
+
? ((ctx.db.query("SELECT lineage_depth FROM task WHERE id = ?").get(parent_task_id) as any)?.lineage_depth || 0) + 1
|
|
149
|
+
: 0;
|
|
150
|
+
const deps: string[] = Array.isArray(depends_on) ? depends_on : [];
|
|
151
|
+
const workflowStatuses = workflowStatusesForProject(ctx.db, project_id);
|
|
152
|
+
const initialStatus = deps.length > 0
|
|
153
|
+
? (workflowStatuses.find((status) => status.id === "blocked")?.id || "blocked")
|
|
154
|
+
: (workflowStatuses[0]?.id || "open");
|
|
155
|
+
ctx.db.run(
|
|
156
|
+
`INSERT INTO task (id, project_id, parent_task_id, title, description, status, assignee_role_id, required_capabilities_json, priority, lineage_depth, created_by, created_at, updated_at)
|
|
157
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
158
|
+
[id, project_id, parent_task_id || null, title, description || null, initialStatus, assigneeRoleId, JSON.stringify(required_capabilities || []), priority || 0, lineage_depth, created_by || "user", ts, ts]
|
|
159
|
+
);
|
|
160
|
+
for (const depId of deps) {
|
|
161
|
+
ctx.db.run("INSERT OR IGNORE INTO task_dependency (task_id, depends_on) VALUES (?, ?)", [id, depId]);
|
|
162
|
+
}
|
|
163
|
+
if (assigneeAgentId && deps.length === 0) {
|
|
164
|
+
const result = await claimTaskForAgent(ctx, id, assigneeAgentId, { deferIfProjectBusy: true });
|
|
165
|
+
if ("error" in result) return json({ error: result.error, id, project_id, title, status: initialStatus }, result.status);
|
|
166
|
+
ctx.bus.publish("task.created", { id, project_id, title, assignee_agent_id: assigneeAgentId });
|
|
167
|
+
if ("deferred" in result) {
|
|
168
|
+
return json({ ...result.task, assignment_deferred: true, blocked_by_task_id: result.blocked_by_task_id }, 201);
|
|
169
|
+
}
|
|
170
|
+
return json({ ...result.task, session_id: result.session_id }, 201);
|
|
171
|
+
}
|
|
172
|
+
ctx.bus.publish("task.created", { id, project_id, title });
|
|
173
|
+
return json({ id, project_id, title, status: initialStatus, created_at: ts }, 201);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
router.get("/api/tasks", (req) => {
|
|
177
|
+
const url = new URL(req.url);
|
|
178
|
+
const workspace_id = url.searchParams.get("workspace_id");
|
|
179
|
+
const project_id = url.searchParams.get("project_id");
|
|
180
|
+
const status = url.searchParams.get("status");
|
|
181
|
+
const include_archived = url.searchParams.get("include_archived");
|
|
182
|
+
let sql = `SELECT task.*, project.name AS project_name, (
|
|
183
|
+
SELECT session.id
|
|
184
|
+
FROM session
|
|
185
|
+
WHERE session.task_id = task.id
|
|
186
|
+
ORDER BY session.started_at DESC
|
|
187
|
+
LIMIT 1
|
|
188
|
+
) AS session_id, (
|
|
189
|
+
SELECT session.started_at
|
|
190
|
+
FROM session
|
|
191
|
+
WHERE session.task_id = task.id AND session.status = 'running'
|
|
192
|
+
ORDER BY session.started_at DESC
|
|
193
|
+
LIMIT 1
|
|
194
|
+
) AS session_started_at, (
|
|
195
|
+
SELECT substr(tti.content, 1, 80)
|
|
196
|
+
FROM task_thread_item tti
|
|
197
|
+
WHERE tti.task_id = task.id AND tti.kind = 'agent_output'
|
|
198
|
+
ORDER BY tti.created_at DESC
|
|
199
|
+
LIMIT 1
|
|
200
|
+
) AS last_output FROM task
|
|
201
|
+
LEFT JOIN project ON task.project_id = project.id
|
|
202
|
+
WHERE 1=1`;
|
|
203
|
+
const params: any[] = [];
|
|
204
|
+
if (!include_archived) { sql += " AND task.archived_at IS NULL"; }
|
|
205
|
+
if (workspace_id) { sql += " AND project.workspace_id = ?"; params.push(workspace_id); }
|
|
206
|
+
if (project_id) { sql += " AND task.project_id = ?"; params.push(project_id); }
|
|
207
|
+
if (status) { sql += " AND task.status = ?"; params.push(status); }
|
|
208
|
+
sql += " ORDER BY task.priority DESC, task.created_at DESC";
|
|
209
|
+
const rows = ctx.db.query(sql).all(...params) as any[];
|
|
210
|
+
const blockingByTask = getTaskBlockingDetails(ctx.db, rows.map((row) => row.id));
|
|
211
|
+
return json(rows.map((row) => ({ ...row, ...blockingByTask[row.id] })));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
router.get("/api/tasks/workflow/statuses", (req) => {
|
|
215
|
+
const url = new URL(req.url);
|
|
216
|
+
const projectId = url.searchParams.get("project_id");
|
|
217
|
+
return json(workflowStatusesForProject(ctx.db, projectId));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
router.post("/api/tasks/:id/claim", async (req, params) => {
|
|
221
|
+
const { agent_id } = await body(req);
|
|
222
|
+
required({ agent_id }, ["agent_id"]);
|
|
223
|
+
const idemKey = getIdempotencyKey(req);
|
|
224
|
+
if (idemKey) {
|
|
225
|
+
const existing = ctx.db.query("SELECT payload_json FROM event_log WHERE type = 'idempotency' AND json_extract(payload_json, '$.key') = ? LIMIT 1").get(idemKey) as any;
|
|
226
|
+
if (existing) return json(JSON.parse(existing.payload_json).response);
|
|
227
|
+
}
|
|
228
|
+
const task = ctx.db.query("SELECT * FROM task WHERE id = ?").get(params.id) as any;
|
|
229
|
+
if (!task) return json({ error: "Not found" }, 404);
|
|
230
|
+
const workflow = getWorkflowForTask(ctx.db, params.id);
|
|
231
|
+
if (task.status !== workflow.initial_status) return json({ error: `Task is not ${workflow.initial_status}` }, 409);
|
|
232
|
+
|
|
233
|
+
const result = await claimTaskForAgent(ctx, params.id, agent_id);
|
|
234
|
+
if ("error" in result) return json({ error: result.error }, result.status);
|
|
235
|
+
|
|
236
|
+
const row = result.task;
|
|
237
|
+
const session_id = "session_id" in result ? result.session_id : undefined;
|
|
238
|
+
if (idemKey) {
|
|
239
|
+
ctx.db.run("INSERT INTO event_log (id, type, payload_json, created_at) VALUES (?, 'idempotency', ?, ?)", [
|
|
240
|
+
generateId("evt"), JSON.stringify({ key: idemKey, response: { ...row, session_id } }), now()
|
|
241
|
+
]);
|
|
242
|
+
}
|
|
243
|
+
return json({ ...row, session_id });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
router.get("/api/tasks/:id/actions", (_req, params) => {
|
|
247
|
+
try {
|
|
248
|
+
return json(listTaskActions(ctx.db, params.id));
|
|
249
|
+
} catch (err: any) {
|
|
250
|
+
if (err.message.includes("not found")) return json({ error: "Not found" }, 404);
|
|
251
|
+
return json({ error: err.message }, 400);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
router.post("/api/tasks/:id/transition", async (req, params) => {
|
|
256
|
+
const input = await body(req).catch(() => ({}));
|
|
257
|
+
const actionId = input.action_id || input.action;
|
|
258
|
+
if (!actionId) return json({ error: "action_id is required" }, 400);
|
|
259
|
+
const result = transitionTask(ctx, params.id, actionId, input, { actorId: input.actor_id || input.actor || "user" });
|
|
260
|
+
if (!result.ok) return json({ error: result.error }, result.status);
|
|
261
|
+
|
|
262
|
+
// If transitioning to an active (non-terminal, non-initial) status,
|
|
263
|
+
// ensure the assigned agent is awake and has a running session.
|
|
264
|
+
const { task, action, workflow } = result;
|
|
265
|
+
const isActive = action.to !== workflow.initial_status
|
|
266
|
+
&& action.to !== workflow.blocked_status
|
|
267
|
+
&& !action.terminal
|
|
268
|
+
&& !workflow.statuses.find(s => s.id === action.to)?.terminal;
|
|
269
|
+
|
|
270
|
+
if (isActive && task.assignee_agent_id) {
|
|
271
|
+
await ensureAgentSession(ctx, task);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return json(result);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
router.post("/api/tasks/:id/spawn", async (req, params) => {
|
|
278
|
+
const { title, description, required_capabilities, depends_on, assignee_agent_id } = await body(req);
|
|
279
|
+
required({ title }, ["title"]);
|
|
280
|
+
const parent = ctx.db.query("SELECT * FROM task WHERE id = ?").get(params.id) as any;
|
|
281
|
+
if (!parent) return json({ error: "Parent task not found" }, 404);
|
|
282
|
+
const newDepth = (parent.lineage_depth || 0) + 1;
|
|
283
|
+
if (newDepth > 10) {
|
|
284
|
+
return json({ error: "Max lineage depth (10) exceeded — spawn rejected to prevent avalanche" }, 422);
|
|
285
|
+
}
|
|
286
|
+
const id = generateId("task");
|
|
287
|
+
const ts = now();
|
|
288
|
+
const deps: string[] = Array.isArray(depends_on) ? depends_on : [];
|
|
289
|
+
const workflowStatuses = workflowStatusesForProject(ctx.db, parent.project_id);
|
|
290
|
+
const initialStatus = deps.length > 0
|
|
291
|
+
? (workflowStatuses.find((status) => status.id === "blocked")?.id || "blocked")
|
|
292
|
+
: (workflowStatuses[0]?.id || "open");
|
|
293
|
+
ctx.db.run(
|
|
294
|
+
`INSERT INTO task (id, project_id, parent_task_id, title, description, status, required_capabilities_json, assignee_agent_id, priority, lineage_depth, created_by, created_at, updated_at)
|
|
295
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
296
|
+
[id, parent.project_id, params.id, title, description || null, initialStatus, JSON.stringify(required_capabilities || []), assignee_agent_id || null, parent.priority || 0, newDepth, "agent", ts, ts]
|
|
297
|
+
);
|
|
298
|
+
for (const depId of deps) {
|
|
299
|
+
ctx.db.run("INSERT OR IGNORE INTO task_dependency (task_id, depends_on) VALUES (?, ?)", [id, depId]);
|
|
300
|
+
}
|
|
301
|
+
// Write spawn record in parent thread
|
|
302
|
+
ctx.db.run(
|
|
303
|
+
"INSERT INTO task_thread_item (id, task_id, kind, author, content, ref_id, created_at) VALUES (?, ?, 'spawn_task', 'system', ?, ?, ?)",
|
|
304
|
+
[generateId("ti"), params.id, `Spawned: ${title}`, id, ts]
|
|
305
|
+
);
|
|
306
|
+
ctx.bus.publish("task.created", { id, project_id: parent.project_id, title, parent_task_id: params.id });
|
|
307
|
+
return json({ id, project_id: parent.project_id, parent_task_id: params.id, title, status: initialStatus, lineage_depth: newDepth, created_at: ts }, 201);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
router.post("/api/tasks/:id/artifact", async (req, params) => {
|
|
311
|
+
const { kind, path, url, meta } = await body(req);
|
|
312
|
+
required({ kind }, ["kind"]);
|
|
313
|
+
if (!path && !url) return json({ error: "path or url is required" }, 400);
|
|
314
|
+
const task = ctx.db.query("SELECT * FROM task WHERE id = ?").get(params.id) as any;
|
|
315
|
+
if (!task) return json({ error: "Not found" }, 404);
|
|
316
|
+
const id = generateId("art");
|
|
317
|
+
const ts = now();
|
|
318
|
+
ctx.db.run(
|
|
319
|
+
"INSERT INTO artifact (id, task_id, kind, path, url, meta_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
320
|
+
[id, params.id, kind, path || null, url || null, JSON.stringify(meta || {}), ts]
|
|
321
|
+
);
|
|
322
|
+
ctx.db.run(
|
|
323
|
+
"INSERT INTO task_thread_item (id, task_id, kind, author, content, ref_id, created_at) VALUES (?, ?, 'artifact', 'system', ?, ?, ?)",
|
|
324
|
+
[generateId("ti"), params.id, `Artifact added: ${kind} ${path || url}`, id, ts]
|
|
325
|
+
);
|
|
326
|
+
ctx.bus.publish("task.artifact_added", { id, task_id: params.id, kind });
|
|
327
|
+
return json({ id, task_id: params.id, kind, path, url, created_at: ts }, 201);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
router.post("/api/tasks/:id/comment", async (req, params) => {
|
|
331
|
+
const { author, content } = await body(req);
|
|
332
|
+
required({ content }, ["content"]);
|
|
333
|
+
const idemKey = getIdempotencyKey(req);
|
|
334
|
+
if (idemKey) {
|
|
335
|
+
const existing = ctx.db.query("SELECT payload_json FROM event_log WHERE type = 'idempotency' AND json_extract(payload_json, '$.key') = ? LIMIT 1").get(idemKey) as any;
|
|
336
|
+
if (existing) return json(JSON.parse(existing.payload_json).response);
|
|
337
|
+
}
|
|
338
|
+
const id = generateId("ti");
|
|
339
|
+
const ts = now();
|
|
340
|
+
ctx.db.run(
|
|
341
|
+
"INSERT INTO task_thread_item (id, task_id, kind, author, content, created_at) VALUES (?, ?, 'comment', ?, ?, ?)",
|
|
342
|
+
[id, params.id, author || "user", content, ts]
|
|
343
|
+
);
|
|
344
|
+
if (idemKey) {
|
|
345
|
+
ctx.db.run("INSERT INTO event_log (id, type, payload_json, created_at) VALUES (?, 'idempotency', ?, ?)", [
|
|
346
|
+
generateId("evt"), JSON.stringify({ key: idemKey, response: { id, task_id: params.id, kind: "comment", content, created_at: ts } }), now()
|
|
347
|
+
]);
|
|
348
|
+
}
|
|
349
|
+
return json({ id, task_id: params.id, kind: "comment", content, created_at: ts }, 201);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
router.get("/api/tasks/:id", (_req, params) => {
|
|
353
|
+
const task = ctx.db.query(`SELECT task.*, (
|
|
354
|
+
SELECT session.id
|
|
355
|
+
FROM session
|
|
356
|
+
WHERE session.task_id = task.id
|
|
357
|
+
ORDER BY session.started_at DESC
|
|
358
|
+
LIMIT 1
|
|
359
|
+
) AS session_id FROM task WHERE task.id = ?`).get(params.id);
|
|
360
|
+
if (!task) return json({ error: "Not found" }, 404);
|
|
361
|
+
const thread = ctx.db.query("SELECT * FROM task_thread_item WHERE task_id = ? ORDER BY created_at ASC").all(params.id);
|
|
362
|
+
const blocking = getTaskBlockingDetails(ctx.db, [params.id])[params.id];
|
|
363
|
+
return json({ ...task as any, ...blocking, thread });
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
router.patch("/api/tasks/:id", async (req, params) => {
|
|
367
|
+
const updates = await body(req);
|
|
368
|
+
const existingTask = ctx.db.query("SELECT * FROM task WHERE id = ?").get(params.id) as any;
|
|
369
|
+
if (!existingTask) return json({ error: "Not found" }, 404);
|
|
370
|
+
if (updates.status !== undefined) {
|
|
371
|
+
const workflow = getWorkflowForTask(ctx.db, params.id);
|
|
372
|
+
const allowed = new Set(workflow.statuses.map((status) => status.id));
|
|
373
|
+
if (!allowed.has(updates.status)) {
|
|
374
|
+
return json({ error: `Invalid task status: ${updates.status}. Allowed: ${Array.from(allowed).join(", ")}` }, 400);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const fields: string[] = [];
|
|
378
|
+
const values: any[] = [];
|
|
379
|
+
for (const key of ["title", "description", "status", "priority", "assignee_agent_id", "start_date", "end_date", "progress"]) {
|
|
380
|
+
if (updates[key] !== undefined) {
|
|
381
|
+
fields.push(`${key} = ?`);
|
|
382
|
+
values.push(updates[key]);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (fields.length === 0) return json({ error: "No fields" }, 400);
|
|
386
|
+
fields.push("updated_at = ?");
|
|
387
|
+
values.push(now());
|
|
388
|
+
values.push(params.id);
|
|
389
|
+
ctx.db.run(`UPDATE task SET ${fields.join(", ")} WHERE id = ?`, values);
|
|
390
|
+
const row = ctx.db.query("SELECT * FROM task WHERE id = ?").get(params.id) as any;
|
|
391
|
+
if (updates.status !== undefined && updates.status !== existingTask.status) {
|
|
392
|
+
const workflow = getWorkflowForTask(ctx.db, params.id);
|
|
393
|
+
const payload = { id: params.id, from_status: existingTask.status, to_status: updates.status };
|
|
394
|
+
ctx.bus.publish("task.updated", payload);
|
|
395
|
+
ctx.bus.publish("task.status_changed", payload);
|
|
396
|
+
if (updates.status === workflow.done_status) {
|
|
397
|
+
unlockDependents(ctx, params.id, {
|
|
398
|
+
doneStatus: workflow.done_status,
|
|
399
|
+
blockedStatus: workflow.blocked_status,
|
|
400
|
+
openStatus: workflow.initial_status,
|
|
401
|
+
});
|
|
402
|
+
// Auto-archive completed scheduled task instances
|
|
403
|
+
if (existingTask.created_by === "scheduled_task") {
|
|
404
|
+
ctx.db.run("UPDATE task SET archived_at = ? WHERE id = ?", [now(), params.id]);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// Auto-claim: when dragged from open to in_progress with an assigned agent, start a session
|
|
408
|
+
const claimAction = workflow.actions.find((a) => a.id === "claim");
|
|
409
|
+
if (
|
|
410
|
+
existingTask.status === workflow.initial_status &&
|
|
411
|
+
updates.status === claimAction?.to &&
|
|
412
|
+
row.assignee_agent_id
|
|
413
|
+
) {
|
|
414
|
+
const projectBlocker = findProjectExecutionBlocker(ctx.db, {
|
|
415
|
+
projectId: existingTask.project_id,
|
|
416
|
+
excludeTaskId: params.id,
|
|
417
|
+
claimedStatus: claimAction?.to,
|
|
418
|
+
});
|
|
419
|
+
if (!projectBlocker) {
|
|
420
|
+
const sessionResult = await createSession(ctx, {
|
|
421
|
+
agent_id: row.assignee_agent_id,
|
|
422
|
+
task_id: params.id,
|
|
423
|
+
});
|
|
424
|
+
if (!("error" in sessionResult)) {
|
|
425
|
+
ctx.bus.publish("task.claimed", { id: params.id, agent_id: row.assignee_agent_id, session_id: sessionResult.session.id });
|
|
426
|
+
return json({ ...row, session_id: sessionResult.session.id });
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return json(row);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// POST /api/tasks/:id/review - create review thread item + inbox notification
|
|
435
|
+
router.post("/api/tasks/:id/review", async (req, params) => {
|
|
436
|
+
const { author, body: reviewBody } = await body(req);
|
|
437
|
+
if (!reviewBody) return json({ error: "body is required" }, 400);
|
|
438
|
+
const task = ctx.db.query("SELECT * FROM task WHERE id = ?").get(params.id) as any;
|
|
439
|
+
if (!task) return json({ error: "Not found" }, 404);
|
|
440
|
+
const tiId = generateId("ti");
|
|
441
|
+
const ts = now();
|
|
442
|
+
ctx.db.run(
|
|
443
|
+
"INSERT INTO task_thread_item (id, task_id, kind, author, content, created_at) VALUES (?, ?, 'review', ?, ?, ?)",
|
|
444
|
+
[tiId, params.id, author || "reviewer", reviewBody, ts]
|
|
445
|
+
);
|
|
446
|
+
// Create inbox message for the assignee
|
|
447
|
+
if (task.assignee_agent_id) {
|
|
448
|
+
ctx.db.run(
|
|
449
|
+
`INSERT INTO inbox_message (id, kind, from_actor, to_actor, subject, body, ref_json, status, created_at)
|
|
450
|
+
VALUES (?, 'review', ?, ?, ?, ?, ?, 'unread', ?)`,
|
|
451
|
+
[generateId("msg"), author || "reviewer", task.assignee_agent_id, `Review on: ${task.title}`, reviewBody, JSON.stringify({ task_id: params.id, thread_item_id: tiId }), ts]
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
ctx.bus.publish("task.reviewed", { id: params.id, thread_item_id: tiId });
|
|
455
|
+
return json({ id: tiId, task_id: params.id, kind: "review", content: reviewBody, created_at: ts }, 201);
|
|
456
|
+
});
|
|
457
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Router } from "../core/router";
|
|
2
|
+
import { body, json } from "../core/router";
|
|
3
|
+
import type { HubContext } from "../core/server";
|
|
4
|
+
import {
|
|
5
|
+
getBindingForChat,
|
|
6
|
+
handleTelegramBatchInbox,
|
|
7
|
+
handleTelegramCommand,
|
|
8
|
+
isTelegramCommand,
|
|
9
|
+
pushAgentReplyToTelegram,
|
|
10
|
+
sendTelegramNotification,
|
|
11
|
+
} from "../bridges/telegram";
|
|
12
|
+
import { insertChatMessage, runAgentLoop } from "./chat";
|
|
13
|
+
|
|
14
|
+
export function registerTelegramRoutes(router: Router, ctx: HubContext) {
|
|
15
|
+
router.post("/api/bridges/telegram/events", async (req) => {
|
|
16
|
+
const input = await body(req).catch(() => ({}));
|
|
17
|
+
const secretToken = process.env.MAESTRO_TELEGRAM_SECRET_TOKEN;
|
|
18
|
+
const requestToken = req.headers.get("x-telegram-bot-api-secret-token") || undefined;
|
|
19
|
+
|
|
20
|
+
if (input.message || input.edited_message) {
|
|
21
|
+
const msg = input.message || input.edited_message;
|
|
22
|
+
const chatId = msg?.chat?.id;
|
|
23
|
+
const text = msg?.text || "";
|
|
24
|
+
|
|
25
|
+
// Validate secret token
|
|
26
|
+
if (secretToken && requestToken !== secretToken) {
|
|
27
|
+
return json({ ok: false, error: "Invalid Telegram secret token" }, 401);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!chatId) return json({ ok: false, error: "Missing chat id" }, 400);
|
|
31
|
+
|
|
32
|
+
// If it's a slash command, handle normally
|
|
33
|
+
if (isTelegramCommand(text)) {
|
|
34
|
+
const response = handleTelegramCommand(ctx.db, text, chatId);
|
|
35
|
+
// Execute the sendMessage call
|
|
36
|
+
await executeTelegramResponse(response);
|
|
37
|
+
return json({ ok: true, response });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Non-command message: check if this group is bound to a workspace
|
|
41
|
+
const binding = getBindingForChat(ctx.db, chatId);
|
|
42
|
+
if (binding) {
|
|
43
|
+
// Bridge message to workspace chat
|
|
44
|
+
const humanMsg = insertChatMessage(ctx, binding.workspace_id, "human", "telegram", text);
|
|
45
|
+
ctx.bus.publish("chat.message", {
|
|
46
|
+
workspace_id: binding.workspace_id,
|
|
47
|
+
message_id: humanMsg.id,
|
|
48
|
+
seq: humanMsg.seq,
|
|
49
|
+
sender_type: "human",
|
|
50
|
+
source: "telegram",
|
|
51
|
+
tg_chat_id: String(chatId),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Trigger the agentic loop
|
|
55
|
+
runAgentLoop(ctx, binding.workspace_id).catch((err) => {
|
|
56
|
+
console.error(`[telegram] agent loop error for ${binding.workspace_id}:`, err.message);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return json({ ok: true, bridged: true, workspace_id: binding.workspace_id });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Not bound, show help
|
|
63
|
+
const response = handleTelegramCommand(ctx.db, text, chatId);
|
|
64
|
+
await executeTelegramResponse(response);
|
|
65
|
+
return json({ ok: true, response });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Direct API call (non-webhook)
|
|
69
|
+
return json(handleTelegramCommand(ctx.db, input.text || "", input.chat_id || "user"));
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
router.post("/api/bridges/telegram/inbox/batch", async (req) => {
|
|
73
|
+
const input = await body(req);
|
|
74
|
+
return json(handleTelegramBatchInbox(ctx.db, input));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
router.post("/api/bridges/telegram/notify", async (req) => {
|
|
78
|
+
const input = await body(req);
|
|
79
|
+
const botToken = input.bot_token || process.env.MAESTRO_TELEGRAM_BOT_TOKEN;
|
|
80
|
+
if (!botToken) return json({ error: "Missing Telegram bot token" }, 400);
|
|
81
|
+
return json(await sendTelegramNotification({ ...input, bot_token: botToken }));
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function executeTelegramResponse(response: any) {
|
|
86
|
+
const botToken = process.env.MAESTRO_TELEGRAM_BOT_TOKEN;
|
|
87
|
+
if (!botToken || !response?.payload) return;
|
|
88
|
+
const base = "https://api.telegram.org";
|
|
89
|
+
await fetch(`${base}/bot${botToken}/${response.method}`, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { "Content-Type": "application/json" },
|
|
92
|
+
body: JSON.stringify(response.payload),
|
|
93
|
+
}).catch(() => {});
|
|
94
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import type { Router } from "../core/router";
|
|
4
|
+
import { json, body } from "../core/router";
|
|
5
|
+
import type { HubContext } from "../core/server";
|
|
6
|
+
|
|
7
|
+
export function registerTemplateRoutes(router: Router, ctx: HubContext) {
|
|
8
|
+
const templatesDir = join(ctx.hubDir, "../templates");
|
|
9
|
+
|
|
10
|
+
router.get("/api/templates", () => {
|
|
11
|
+
if (!existsSync(templatesDir)) return json([]);
|
|
12
|
+
const files = readdirSync(templatesDir).filter(f => f.endsWith(".json"));
|
|
13
|
+
const templates = files.map(f => {
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(readFileSync(join(templatesDir, f), "utf-8"));
|
|
16
|
+
} catch { return null; }
|
|
17
|
+
}).filter(Boolean);
|
|
18
|
+
return json(templates);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
router.post("/api/templates", async (req) => {
|
|
22
|
+
const template = await body(req);
|
|
23
|
+
if (!template.name) return json({ error: "name is required" }, 400);
|
|
24
|
+
mkdirSync(templatesDir, { recursive: true });
|
|
25
|
+
const filename = template.name.toLowerCase().replace(/[^a-z0-9]+/g, "-") + ".json";
|
|
26
|
+
writeFileSync(join(templatesDir, filename), JSON.stringify(template, null, 2));
|
|
27
|
+
return json({ ...template, filename }, 201);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
router.get("/api/templates/:name", (_req, params) => {
|
|
31
|
+
const filename = `${params.name}.json`;
|
|
32
|
+
const filePath = join(templatesDir, filename);
|
|
33
|
+
if (!existsSync(filePath)) return json({ error: "Template not found" }, 404);
|
|
34
|
+
return json(JSON.parse(readFileSync(filePath, "utf-8")));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Router } from "../core/router";
|
|
2
|
+
import { json, body } from "../core/router";
|
|
3
|
+
import type { HubContext } from "../core/server";
|
|
4
|
+
import { generateId, now } from "../core/db";
|
|
5
|
+
|
|
6
|
+
export function registerWebhookRoutes(router: Router, ctx: HubContext) {
|
|
7
|
+
// POST /api/webhooks/:source — generic webhook receiver
|
|
8
|
+
router.post("/api/webhooks/:source", async (req, params) => {
|
|
9
|
+
const payload = await body(req);
|
|
10
|
+
const source = params.source; // github, lark, custom, etc.
|
|
11
|
+
const id = generateId("evt");
|
|
12
|
+
const ts = now();
|
|
13
|
+
ctx.db.run(
|
|
14
|
+
"INSERT INTO event_log (id, type, payload_json, created_at) VALUES (?, ?, ?, ?)",
|
|
15
|
+
[id, `webhook.${source}`, JSON.stringify(payload), ts]
|
|
16
|
+
);
|
|
17
|
+
ctx.bus.publish(`webhook.${source}`, { id, source, payload });
|
|
18
|
+
return json({ id, source, received: true }, 201);
|
|
19
|
+
});
|
|
20
|
+
}
|