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/roles.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { existsSync, 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
|
+
import { generateId, now } from "../core/db";
|
|
7
|
+
import { syncRolesFromDirectory, buildFullRolePrompt } from "../roles";
|
|
8
|
+
import { createWorkspaceRole } from "../roles/workspace-role";
|
|
9
|
+
|
|
10
|
+
export function registerRoleRoutes(router: Router, ctx: HubContext) {
|
|
11
|
+
router.get("/api/roles", () => {
|
|
12
|
+
return json(ctx.db.query("SELECT * FROM role ORDER BY id ASC").all());
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
router.post("/api/roles/reload", () => {
|
|
16
|
+
const rolesDir = join(ctx.hubDir, "roles");
|
|
17
|
+
if (!existsSync(rolesDir)) return json({ roles: [] });
|
|
18
|
+
const roles = syncRolesFromDirectory(ctx.db, rolesDir);
|
|
19
|
+
return json({ roles });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Get role.md raw content for editing
|
|
23
|
+
router.get("/api/roles/:id/content", (_req, params) => {
|
|
24
|
+
const role = ctx.db.query("SELECT * FROM role WHERE id = ?").get(params.id) as any;
|
|
25
|
+
if (!role) return json({ error: "Not found" }, 404);
|
|
26
|
+
if (!role.role_md_path || !existsSync(role.role_md_path)) {
|
|
27
|
+
return json({ error: "Role file not found" }, 404);
|
|
28
|
+
}
|
|
29
|
+
const content = readFileSync(role.role_md_path, "utf-8");
|
|
30
|
+
const rolesDir = join(ctx.hubDir, "roles");
|
|
31
|
+
const fullPrompt = buildFullRolePrompt(rolesDir, role.role_md_path);
|
|
32
|
+
return json({ content, full_prompt: fullPrompt, path: role.role_md_path });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Update role.md content (triggers hot-reload on next poll)
|
|
36
|
+
router.put("/api/roles/:id/content", async (req, params) => {
|
|
37
|
+
const role = ctx.db.query("SELECT * FROM role WHERE id = ?").get(params.id) as any;
|
|
38
|
+
if (!role) return json({ error: "Not found" }, 404);
|
|
39
|
+
if (!role.role_md_path) return json({ error: "Role has no file path" }, 400);
|
|
40
|
+
const { content } = await body(req);
|
|
41
|
+
if (!content) return json({ error: "content is required" }, 400);
|
|
42
|
+
writeFileSync(role.role_md_path, content, "utf-8");
|
|
43
|
+
// Force reload
|
|
44
|
+
const rolesDir = join(ctx.hubDir, "roles");
|
|
45
|
+
syncRolesFromDirectory(ctx.db, rolesDir);
|
|
46
|
+
const updated = ctx.db.query("SELECT * FROM role WHERE id = ?").get(params.id);
|
|
47
|
+
return json(updated);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// DELETE /api/roles/:id
|
|
51
|
+
router.delete("/api/roles/:id", (_req, params) => {
|
|
52
|
+
const role = ctx.db.query("SELECT * FROM role WHERE id = ?").get(params.id) as any;
|
|
53
|
+
if (!role) return json({ error: "Not found" }, 404);
|
|
54
|
+
const bound = ctx.db.query("SELECT id FROM agent WHERE role_id = ? AND status != 'offline' LIMIT 1").get(params.id);
|
|
55
|
+
if (bound) return json({ error: "Role has active agents bound" }, 409);
|
|
56
|
+
ctx.db.run("DELETE FROM role WHERE id = ?", [params.id]);
|
|
57
|
+
if (role.role_md_path && existsSync(role.role_md_path)) {
|
|
58
|
+
const { unlinkSync } = require("fs");
|
|
59
|
+
try { unlinkSync(role.role_md_path); } catch {}
|
|
60
|
+
}
|
|
61
|
+
ctx.bus.publish("role.deleted", { id: params.id });
|
|
62
|
+
return json({ id: params.id, deleted: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// POST /api/workspaces/:wid/roles - upload role.md for a workspace
|
|
66
|
+
router.post("/api/workspaces/:wid/roles", async (req, params) => {
|
|
67
|
+
const { id, name, content, runtime_id, capabilities, preferred_runtimes, headcount, project_id } = await body(req);
|
|
68
|
+
if (!name) return json({ error: "name is required" }, 400);
|
|
69
|
+
const created = createWorkspaceRole(ctx.db, {
|
|
70
|
+
workspaceId: params.wid,
|
|
71
|
+
projectId: project_id || null,
|
|
72
|
+
id,
|
|
73
|
+
name,
|
|
74
|
+
capabilities: Array.isArray(capabilities) ? capabilities : [],
|
|
75
|
+
preferredRuntimes: Array.isArray(preferred_runtimes) ? preferred_runtimes : [],
|
|
76
|
+
headcount: Number(headcount || 1),
|
|
77
|
+
runtimeId: runtime_id || null,
|
|
78
|
+
hubDir: ctx.hubDir,
|
|
79
|
+
}, ctx.bus?.publish ? ctx.bus.publish.bind(ctx.bus) : undefined);
|
|
80
|
+
|
|
81
|
+
if (content) {
|
|
82
|
+
writeFileSync(created.roleMdPath, content, "utf-8");
|
|
83
|
+
syncRolesFromDirectory(ctx.db, join(ctx.hubDir, "roles"));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return json({ ...created.role, agents: created.agents }, 201);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// GET /api/workspaces/:wid/roles
|
|
90
|
+
router.get("/api/workspaces/:wid/roles", (_req, params) => {
|
|
91
|
+
const roles = ctx.db.query("SELECT * FROM role WHERE workspace_id = ? ORDER BY id ASC").all(params.wid);
|
|
92
|
+
return json(roles);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Instantiate agents for a role
|
|
96
|
+
router.post("/api/roles/:id/instantiate", async (req, params) => {
|
|
97
|
+
const role = ctx.db.query("SELECT * FROM role WHERE id = ?").get(params.id) as any;
|
|
98
|
+
if (!role) return json({ error: "Role not found" }, 404);
|
|
99
|
+
const { runtime_id, count } = await body(req);
|
|
100
|
+
if (!runtime_id) return json({ error: "runtime_id is required" }, 400);
|
|
101
|
+
|
|
102
|
+
const runtime = ctx.db.query("SELECT * FROM agent_runtime WHERE id = ?").get(runtime_id) as any;
|
|
103
|
+
if (!runtime) return json({ error: "Runtime not found" }, 404);
|
|
104
|
+
|
|
105
|
+
const headcount = count || role.headcount || 1;
|
|
106
|
+
const agents: any[] = [];
|
|
107
|
+
|
|
108
|
+
for (let i = 0; i < headcount; i++) {
|
|
109
|
+
const agentId = generateId("agent");
|
|
110
|
+
const ts = now();
|
|
111
|
+
const name = `${role.name}-${i + 1}`;
|
|
112
|
+
const workdir = null;
|
|
113
|
+
ctx.db.run(
|
|
114
|
+
"INSERT INTO agent (id, role_id, runtime_id, name, workdir, status, created_at, last_active_at) VALUES (?, ?, ?, ?, ?, 'idle', ?, ?)",
|
|
115
|
+
[agentId, role.id, runtime_id, name, workdir, ts, ts]
|
|
116
|
+
);
|
|
117
|
+
agents.push({ id: agentId, name, role_id: role.id, runtime_id, status: "idle", workdir });
|
|
118
|
+
ctx.bus.publish("agent.spawned", { id: agentId, name, runtime_id });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return json(agents, 201);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
import { required } from "../core/validate";
|
|
6
|
+
import { checkRuntimeHealth, runtimeWaterline } from "../ops";
|
|
7
|
+
import { registerDiscoveredRuntimes } from "../discovery";
|
|
8
|
+
|
|
9
|
+
export function registerRuntimeRoutes(router: Router, ctx: HubContext) {
|
|
10
|
+
router.get("/api/runtimes", () => {
|
|
11
|
+
const rows = (ctx.db.query("SELECT * FROM agent_runtime ORDER BY created_at DESC").all() as any[])
|
|
12
|
+
.map((runtime) => ({ ...runtime, waterline: runtimeWaterline(ctx.db, runtime.id, Number(runtime.capacity ?? -1)) }));
|
|
13
|
+
return json(rows);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
router.post("/api/runtimes", async (req) => {
|
|
17
|
+
const { type, transport, cmd, target, capacity, capabilities, version } = await body(req);
|
|
18
|
+
required({ type, transport }, ["type", "transport"]);
|
|
19
|
+
const id = generateId("runtime");
|
|
20
|
+
const ts = now();
|
|
21
|
+
ctx.db.run(
|
|
22
|
+
"INSERT INTO agent_runtime (id, type, transport, cmd, target, version, capacity, status, capabilities_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'online', ?, ?)",
|
|
23
|
+
[id, type, transport, cmd || null, target || null, version || null, capacity ?? -1, JSON.stringify(capabilities || []), ts]
|
|
24
|
+
);
|
|
25
|
+
return json({ id, type, transport, version: version || null, status: "online", created_at: ts }, 201);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
router.post("/api/runtimes/:id/healthcheck", async (_req, params) => {
|
|
29
|
+
const runtime = ctx.db.query("SELECT * FROM agent_runtime WHERE id = ?").get(params.id) as any;
|
|
30
|
+
if (!runtime) return json({ error: "Not found" }, 404);
|
|
31
|
+
return json(await checkRuntimeHealth(ctx.db, runtime));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
router.patch("/api/runtimes/:id", async (req, params) => {
|
|
35
|
+
const runtime = ctx.db.query("SELECT * FROM agent_runtime WHERE id = ?").get(params.id) as any;
|
|
36
|
+
if (!runtime) return json({ error: "Not found" }, 404);
|
|
37
|
+
const { capacity, oversubscription_factor } = await body(req);
|
|
38
|
+
if (capacity !== undefined) {
|
|
39
|
+
ctx.db.run("UPDATE agent_runtime SET capacity = ? WHERE id = ?", [capacity, params.id]);
|
|
40
|
+
}
|
|
41
|
+
if (oversubscription_factor !== undefined) {
|
|
42
|
+
ctx.db.run("UPDATE agent_runtime SET oversubscription_factor = ? WHERE id = ?", [oversubscription_factor, params.id]);
|
|
43
|
+
}
|
|
44
|
+
const updated = ctx.db.query("SELECT * FROM agent_runtime WHERE id = ?").get(params.id) as any;
|
|
45
|
+
return json({ ...updated, waterline: runtimeWaterline(ctx.db, updated.id, Number(updated.capacity ?? -1)) });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// 重新探测本机 Runtimes,新发现的自动注册
|
|
49
|
+
router.post("/api/runtimes/discover", async () => {
|
|
50
|
+
try {
|
|
51
|
+
const result = await registerDiscoveredRuntimes(ctx.db);
|
|
52
|
+
return json({
|
|
53
|
+
discovered: result.discovered.length,
|
|
54
|
+
added: result.added.length,
|
|
55
|
+
skipped: result.skipped.length,
|
|
56
|
+
new_runtimes: result.added,
|
|
57
|
+
});
|
|
58
|
+
} catch (err: any) {
|
|
59
|
+
return json({ error: `Discovery failed: ${err.message || err}` }, 500);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { Router } from "../core/router";
|
|
2
|
+
import { body, json } from "../core/router";
|
|
3
|
+
import type { HubContext } from "../core/server";
|
|
4
|
+
import { generateId, now } from "../core/db";
|
|
5
|
+
import { computeNextFireAt, runDueScheduledTasks, validateScheduleInput } from "../scheduler";
|
|
6
|
+
|
|
7
|
+
export function registerScheduledTaskRoutes(router: Router, ctx: HubContext) {
|
|
8
|
+
router.get("/api/scheduled-tasks", (req) => {
|
|
9
|
+
const url = new URL(req.url);
|
|
10
|
+
const workspaceId = url.searchParams.get("workspace_id");
|
|
11
|
+
const enabled = url.searchParams.get("enabled");
|
|
12
|
+
let sql = `
|
|
13
|
+
SELECT
|
|
14
|
+
scheduled_task.*,
|
|
15
|
+
project.name AS project_name,
|
|
16
|
+
role.name AS resolved_role_name
|
|
17
|
+
FROM scheduled_task
|
|
18
|
+
LEFT JOIN project ON project.id = scheduled_task.project_id
|
|
19
|
+
LEFT JOIN role ON role.id = scheduled_task.assignee_role_id
|
|
20
|
+
WHERE 1=1`;
|
|
21
|
+
const params: any[] = [];
|
|
22
|
+
if (workspaceId) {
|
|
23
|
+
sql += " AND scheduled_task.workspace_id = ?";
|
|
24
|
+
params.push(workspaceId);
|
|
25
|
+
}
|
|
26
|
+
if (enabled !== null) {
|
|
27
|
+
sql += " AND scheduled_task.enabled = ?";
|
|
28
|
+
params.push(enabled === "true" || enabled === "1" ? 1 : 0);
|
|
29
|
+
}
|
|
30
|
+
sql += " ORDER BY scheduled_task.enabled DESC, scheduled_task.next_fire_at ASC, scheduled_task.created_at DESC";
|
|
31
|
+
return json(ctx.db.query(sql).all(...params));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
router.post("/api/scheduled-tasks", async (req) => {
|
|
35
|
+
const input = await body(req);
|
|
36
|
+
const normalized = normalizeScheduledTaskInput(ctx, input);
|
|
37
|
+
if ("error" in normalized) return json({ error: normalized.error }, normalized.status);
|
|
38
|
+
|
|
39
|
+
const id = generateId("sched");
|
|
40
|
+
const ts = now();
|
|
41
|
+
const nextFireAt = computeNextFireAt(normalized, ts);
|
|
42
|
+
ctx.db.run(
|
|
43
|
+
`INSERT INTO scheduled_task (
|
|
44
|
+
id, workspace_id, project_id, title, description, priority, schedule_type, cron_expr, run_at,
|
|
45
|
+
assignee_role_id, assignee_role_name, enabled, next_fire_at, created_by, created_at, updated_at
|
|
46
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
47
|
+
[
|
|
48
|
+
id,
|
|
49
|
+
normalized.workspace_id,
|
|
50
|
+
normalized.project_id,
|
|
51
|
+
normalized.title,
|
|
52
|
+
normalized.description || null,
|
|
53
|
+
normalized.priority,
|
|
54
|
+
normalized.schedule_type,
|
|
55
|
+
normalized.cron_expr || null,
|
|
56
|
+
normalized.run_at || null,
|
|
57
|
+
normalized.assignee_role_id || null,
|
|
58
|
+
normalized.assignee_role_name || null,
|
|
59
|
+
normalized.enabled,
|
|
60
|
+
nextFireAt,
|
|
61
|
+
normalized.created_by,
|
|
62
|
+
ts,
|
|
63
|
+
ts,
|
|
64
|
+
],
|
|
65
|
+
);
|
|
66
|
+
ctx.bus?.publish?.("scheduled_task.created", { id, workspace_id: normalized.workspace_id, next_fire_at: nextFireAt });
|
|
67
|
+
return json(ctx.db.query("SELECT * FROM scheduled_task WHERE id = ?").get(id), 201);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
router.patch("/api/scheduled-tasks/:id", async (req, params) => {
|
|
71
|
+
const existing = ctx.db.query("SELECT * FROM scheduled_task WHERE id = ?").get(params.id) as any;
|
|
72
|
+
if (!existing) return json({ error: "Not found" }, 404);
|
|
73
|
+
const input = await body(req);
|
|
74
|
+
const merged = {
|
|
75
|
+
...existing,
|
|
76
|
+
...input,
|
|
77
|
+
workspace_id: input.workspace_id || existing.workspace_id,
|
|
78
|
+
project_id: input.project_id || existing.project_id,
|
|
79
|
+
};
|
|
80
|
+
const normalized = normalizeScheduledTaskInput(ctx, merged, existing);
|
|
81
|
+
if ("error" in normalized) return json({ error: normalized.error }, normalized.status);
|
|
82
|
+
|
|
83
|
+
const ts = now();
|
|
84
|
+
const scheduleChanged = input.schedule_type !== undefined || input.cron_expr !== undefined || input.run_at !== undefined || input.enabled !== undefined;
|
|
85
|
+
const nextFireAt = normalized.enabled
|
|
86
|
+
? (scheduleChanged ? computeNextFireAt(normalized, ts) : existing.next_fire_at)
|
|
87
|
+
: null;
|
|
88
|
+
ctx.db.run(
|
|
89
|
+
`UPDATE scheduled_task SET
|
|
90
|
+
workspace_id = ?, project_id = ?, title = ?, description = ?, priority = ?, schedule_type = ?,
|
|
91
|
+
cron_expr = ?, run_at = ?, assignee_role_id = ?, assignee_role_name = ?, enabled = ?,
|
|
92
|
+
next_fire_at = ?, updated_at = ?
|
|
93
|
+
WHERE id = ?`,
|
|
94
|
+
[
|
|
95
|
+
normalized.workspace_id,
|
|
96
|
+
normalized.project_id,
|
|
97
|
+
normalized.title,
|
|
98
|
+
normalized.description || null,
|
|
99
|
+
normalized.priority,
|
|
100
|
+
normalized.schedule_type,
|
|
101
|
+
normalized.cron_expr || null,
|
|
102
|
+
normalized.run_at || null,
|
|
103
|
+
normalized.assignee_role_id || null,
|
|
104
|
+
normalized.assignee_role_name || null,
|
|
105
|
+
normalized.enabled,
|
|
106
|
+
nextFireAt,
|
|
107
|
+
ts,
|
|
108
|
+
params.id,
|
|
109
|
+
],
|
|
110
|
+
);
|
|
111
|
+
ctx.bus?.publish?.("scheduled_task.updated", { id: params.id, workspace_id: normalized.workspace_id, next_fire_at: nextFireAt });
|
|
112
|
+
return json(ctx.db.query("SELECT * FROM scheduled_task WHERE id = ?").get(params.id));
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
router.delete("/api/scheduled-tasks/:id", (_req, params) => {
|
|
116
|
+
const existing = ctx.db.query("SELECT * FROM scheduled_task WHERE id = ?").get(params.id) as any;
|
|
117
|
+
if (!existing) return json({ error: "Not found" }, 404);
|
|
118
|
+
ctx.db.run("DELETE FROM scheduled_task WHERE id = ?", [params.id]);
|
|
119
|
+
ctx.bus?.publish?.("scheduled_task.deleted", { id: params.id, workspace_id: existing.workspace_id });
|
|
120
|
+
return json({ id: params.id, deleted: true });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
router.post("/api/scheduled-tasks/run", async (req) => {
|
|
124
|
+
const input = await body(req).catch(() => ({}));
|
|
125
|
+
return json(await runDueScheduledTasks(ctx, input.now || now()));
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function normalizeScheduledTaskInput(ctx: HubContext, input: any, existing?: any):
|
|
130
|
+
| {
|
|
131
|
+
workspace_id: string;
|
|
132
|
+
project_id: string;
|
|
133
|
+
title: string;
|
|
134
|
+
description: string;
|
|
135
|
+
priority: number;
|
|
136
|
+
schedule_type: "cron" | "once";
|
|
137
|
+
cron_expr: string | null;
|
|
138
|
+
run_at: number | null;
|
|
139
|
+
assignee_role_id: string | null;
|
|
140
|
+
assignee_role_name: string | null;
|
|
141
|
+
enabled: number;
|
|
142
|
+
created_by: string;
|
|
143
|
+
}
|
|
144
|
+
| { error: string; status: number } {
|
|
145
|
+
const workspaceId = String(input.workspace_id || "").trim();
|
|
146
|
+
if (!workspaceId) return { error: "workspace_id is required", status: 400 };
|
|
147
|
+
const workspace = ctx.db.query("SELECT * FROM workspace WHERE id = ?").get(workspaceId) as any;
|
|
148
|
+
if (!workspace) return { error: "Workspace not found", status: 404 };
|
|
149
|
+
|
|
150
|
+
const projectId = resolveProjectId(ctx, workspaceId, input.project_id);
|
|
151
|
+
if (!projectId) return { error: "project_id is required when the workspace has no projects", status: 400 };
|
|
152
|
+
|
|
153
|
+
const title = String(input.title || "").trim();
|
|
154
|
+
if (!title) return { error: "title is required", status: 400 };
|
|
155
|
+
|
|
156
|
+
let scheduleType: "cron" | "once";
|
|
157
|
+
try {
|
|
158
|
+
scheduleType = validateScheduleInput(input) as "cron" | "once";
|
|
159
|
+
} catch (err: any) {
|
|
160
|
+
return { error: err.message, status: 400 };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const role = resolveRole(ctx, workspaceId, input.assignee_role_id, input.assignee_role || input.assignee_role_name);
|
|
164
|
+
if ("error" in role) return role;
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
workspace_id: workspaceId,
|
|
168
|
+
project_id: projectId,
|
|
169
|
+
title,
|
|
170
|
+
description: String(input.description ?? existing?.description ?? ""),
|
|
171
|
+
priority: Number(input.priority ?? existing?.priority ?? 0),
|
|
172
|
+
schedule_type: scheduleType,
|
|
173
|
+
cron_expr: scheduleType === "cron" ? String(input.cron_expr || "").trim() : null,
|
|
174
|
+
run_at: scheduleType === "once" ? Number(input.run_at) : null,
|
|
175
|
+
assignee_role_id: role.role?.id || null,
|
|
176
|
+
assignee_role_name: role.role?.name || String(input.assignee_role_name || input.assignee_role || "").trim() || null,
|
|
177
|
+
enabled: input.enabled === undefined ? Number(existing?.enabled ?? 1) : (input.enabled ? 1 : 0),
|
|
178
|
+
created_by: String(input.created_by || existing?.created_by || "user"),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function resolveProjectId(ctx: HubContext, workspaceId: string, projectId?: string) {
|
|
183
|
+
if (projectId) {
|
|
184
|
+
const project = ctx.db.query("SELECT id FROM project WHERE id = ? AND workspace_id = ?").get(projectId, workspaceId) as any;
|
|
185
|
+
return project?.id || null;
|
|
186
|
+
}
|
|
187
|
+
const project = ctx.db.query("SELECT id FROM project WHERE workspace_id = ? ORDER BY created_at ASC LIMIT 1").get(workspaceId) as any;
|
|
188
|
+
return project?.id || null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function resolveRole(ctx: HubContext, workspaceId: string, roleId?: string | null, roleName?: string | null) {
|
|
192
|
+
if (roleId) {
|
|
193
|
+
const role = ctx.db.query("SELECT * FROM role WHERE id = ? AND workspace_id = ?").get(roleId, workspaceId) as any;
|
|
194
|
+
return role ? { role } : { error: "Assignee role not found", status: 404 };
|
|
195
|
+
}
|
|
196
|
+
if (roleName) {
|
|
197
|
+
const role = ctx.db.query(
|
|
198
|
+
"SELECT * FROM role WHERE workspace_id = ? AND LOWER(name) = LOWER(?) ORDER BY created_at ASC LIMIT 1",
|
|
199
|
+
).get(workspaceId, roleName) as any;
|
|
200
|
+
return role ? { role } : { error: "Assignee role not found", status: 404 };
|
|
201
|
+
}
|
|
202
|
+
return { role: null };
|
|
203
|
+
}
|