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
package/src/ops/index.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { generateId, now } from "../core/db";
|
|
5
|
+
|
|
6
|
+
export function recoverInterruptedSessions(db: Database) {
|
|
7
|
+
const ts = now();
|
|
8
|
+
const running = db.query("SELECT * FROM session WHERE status = 'running'").all() as any[];
|
|
9
|
+
for (const session of running) {
|
|
10
|
+
db.run("UPDATE session SET status = 'interrupted', ended_at = ? WHERE id = ?", [ts, session.id]);
|
|
11
|
+
db.run("UPDATE agent SET status = 'idle', last_active_at = ? WHERE id = ?", [ts, session.agent_id]);
|
|
12
|
+
audit(db, "system", "ops.session_recovered", session.id, { previous_status: "running" });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Recover stale orphaned tasks: tasks stuck in claimed/in_progress with no running session
|
|
16
|
+
const taskTimeoutMs = 5 * 60 * 1000;
|
|
17
|
+
const staleTaskCutoff = ts - taskTimeoutMs;
|
|
18
|
+
const orphanedTasks = db.query(`
|
|
19
|
+
SELECT t.id, t.status, t.assignee_agent_id FROM task t
|
|
20
|
+
WHERE t.status IN ('claimed', 'in_progress')
|
|
21
|
+
AND t.updated_at <= ?
|
|
22
|
+
AND NOT EXISTS (
|
|
23
|
+
SELECT 1 FROM session s
|
|
24
|
+
WHERE s.task_id = t.id AND s.status = 'running'
|
|
25
|
+
)
|
|
26
|
+
`).all(staleTaskCutoff) as any[];
|
|
27
|
+
for (const task of orphanedTasks) {
|
|
28
|
+
db.run(
|
|
29
|
+
"UPDATE task SET status = 'open', assignee_agent_id = NULL, claim_token = NULL, updated_at = ? WHERE id = ?",
|
|
30
|
+
[ts, task.id],
|
|
31
|
+
);
|
|
32
|
+
audit(db, "system", "ops.task_recovered", task.id, { previous_status: task.status, task_timeout_ms: taskTimeoutMs });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { recovered: running.length, tasks_reset: orphanedTasks.length };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function runtimeWaterline(db: Database, runtimeId: string, capacity: number) {
|
|
39
|
+
const running = (db.query(`
|
|
40
|
+
SELECT COUNT(*) AS count
|
|
41
|
+
FROM session
|
|
42
|
+
JOIN agent ON agent.id = session.agent_id
|
|
43
|
+
WHERE agent.runtime_id = ? AND session.status = 'running'
|
|
44
|
+
`).get(runtimeId) as any).count;
|
|
45
|
+
return {
|
|
46
|
+
running,
|
|
47
|
+
capacity,
|
|
48
|
+
utilization: capacity > 0 ? running / capacity : 0,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function checkRuntimeHealth(db: Database, runtime: any) {
|
|
53
|
+
let status = "online";
|
|
54
|
+
let detail = "ok";
|
|
55
|
+
if (runtime.transport === "local-pty" && runtime.cmd) {
|
|
56
|
+
const result = await Bun.spawn(["sh", "-c", `command -v ${shellQuote(runtime.cmd)}`]).exited;
|
|
57
|
+
status = result === 0 ? "online" : "offline";
|
|
58
|
+
detail = result === 0 ? "command found" : "command not found";
|
|
59
|
+
} else if ((runtime.transport === "ssh" || runtime.transport === "http-api") && !runtime.target) {
|
|
60
|
+
status = "offline";
|
|
61
|
+
detail = "missing target";
|
|
62
|
+
}
|
|
63
|
+
db.run("UPDATE agent_runtime SET status = ? WHERE id = ?", [status, runtime.id]);
|
|
64
|
+
return { id: runtime.id, status, detail, waterline: runtimeWaterline(db, runtime.id, Number(runtime.capacity ?? -1)) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createBackup(hubDir: string) {
|
|
68
|
+
const backupDir = join(hubDir, "backups");
|
|
69
|
+
mkdirSync(backupDir, { recursive: true });
|
|
70
|
+
const source = join(hubDir, "maestro.db");
|
|
71
|
+
const path = join(backupDir, `maestro-${now()}.db`);
|
|
72
|
+
const db = new Database(source);
|
|
73
|
+
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
74
|
+
db.close();
|
|
75
|
+
copyFileSync(source, path);
|
|
76
|
+
return { path };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function restoreBackup(hubDir: string, path: string) {
|
|
80
|
+
if (!existsSync(path)) throw new Error(`Backup not found: ${path}`);
|
|
81
|
+
copyFileSync(path, join(hubDir, "maestro.db"));
|
|
82
|
+
return { restored: true, path };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function recordAgentExit(db: Database, input: { agentId: string; sessionId: string; code: number }) {
|
|
86
|
+
const agent = db.query("SELECT * FROM agent WHERE id = ?").get(input.agentId) as any;
|
|
87
|
+
if (!agent) return { restart: false, attempts: 0 };
|
|
88
|
+
const metrics = parseMetrics(agent.metrics_json);
|
|
89
|
+
const attempts = input.code === 0 ? 0 : Number(metrics.restart_attempts || 0) + 1;
|
|
90
|
+
metrics.restart_attempts = attempts;
|
|
91
|
+
db.run("UPDATE agent SET metrics_json = ? WHERE id = ?", [JSON.stringify(metrics), input.agentId]);
|
|
92
|
+
|
|
93
|
+
if (input.code !== 0 && attempts >= 3) {
|
|
94
|
+
createInbox(db, "escalation", "hub", "user", "Agent restart failed", `Agent ${input.agentId} crashed ${attempts} time(s).`, {
|
|
95
|
+
agent_id: input.agentId,
|
|
96
|
+
session_id: input.sessionId,
|
|
97
|
+
code: input.code,
|
|
98
|
+
});
|
|
99
|
+
return { restart: false, attempts };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { restart: input.code !== 0, attempts };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function opsStats(db: Database) {
|
|
106
|
+
const sessions = db.query("SELECT * FROM session").all() as any[];
|
|
107
|
+
const runtimes = (db.query("SELECT * FROM agent_runtime ORDER BY created_at DESC").all() as any[])
|
|
108
|
+
.map((runtime) => ({ ...runtime, waterline: runtimeWaterline(db, runtime.id, Number(runtime.capacity ?? -1)) }));
|
|
109
|
+
|
|
110
|
+
// Better cost estimation based on session duration
|
|
111
|
+
let totalDurationMs = 0;
|
|
112
|
+
for (const session of sessions) {
|
|
113
|
+
if (session.started_at && session.ended_at) {
|
|
114
|
+
totalDurationMs += session.ended_at - session.started_at;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const estimatedTokens = Math.max(sessions.length * 1000, Math.ceil(totalDurationMs / 60000) * 500);
|
|
118
|
+
const estimatedUsd = Math.round(estimatedTokens * 0.000015 * 100) / 100; // ~$15/1M tokens avg
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
cost: {
|
|
122
|
+
sessions: sessions.length,
|
|
123
|
+
estimated_tokens: estimatedTokens,
|
|
124
|
+
estimated_usd: estimatedUsd,
|
|
125
|
+
total_duration_min: Math.round(totalDurationMs / 60000),
|
|
126
|
+
},
|
|
127
|
+
runtimes,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function smokeRuntimes(db: Database, runtimes: any[]) {
|
|
132
|
+
const ts = now();
|
|
133
|
+
let sessions = 0;
|
|
134
|
+
for (const runtime of runtimes) {
|
|
135
|
+
const runtimeId = generateId("runtime");
|
|
136
|
+
const agentId = generateId("agent");
|
|
137
|
+
const sessionId = generateId("sess");
|
|
138
|
+
db.run(
|
|
139
|
+
"INSERT INTO agent_runtime (id, type, transport, cmd, target, capacity, status, capabilities_json, created_at) VALUES (?, ?, ?, ?, ?, 1, 'online', '[]', ?)",
|
|
140
|
+
[runtimeId, runtime.type, runtime.transport, runtime.cmd || null, runtime.target || null, ts],
|
|
141
|
+
);
|
|
142
|
+
db.run(
|
|
143
|
+
"INSERT INTO agent (id, runtime_id, name, status, metrics_json, created_at, last_active_at) VALUES (?, ?, ?, 'idle', '{}', ?, ?)",
|
|
144
|
+
[agentId, runtimeId, `${runtime.type}-smoke`, ts, ts],
|
|
145
|
+
);
|
|
146
|
+
db.run(
|
|
147
|
+
"INSERT INTO session (id, agent_id, pty_pid, transcript_path, status, started_at, ended_at) VALUES (?, ?, 0, NULL, 'ended', ?, ?)",
|
|
148
|
+
[sessionId, agentId, ts, ts],
|
|
149
|
+
);
|
|
150
|
+
sessions++;
|
|
151
|
+
}
|
|
152
|
+
return { status: "passed", runtimes: runtimes.length, sessions };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export async function soakRuntimes(hubDir: string, db: Database, input: { runtimes: any[]; duration_ms?: number; fast_forward?: boolean }) {
|
|
156
|
+
const startedAt = now();
|
|
157
|
+
const smoke = await smokeRuntimes(db, input.runtimes || []);
|
|
158
|
+
const durationMs = Number(input.duration_ms ?? 24 * 60 * 60 * 1000);
|
|
159
|
+
if (durationMs > 0) await Bun.sleep(input.fast_forward ? Math.min(durationMs, 100) : durationMs);
|
|
160
|
+
const endedAt = now();
|
|
161
|
+
const observedDurationMs = endedAt - startedAt;
|
|
162
|
+
const complete = !input.fast_forward && observedDurationMs >= durationMs;
|
|
163
|
+
const evidenceDir = join(hubDir, "evidence");
|
|
164
|
+
mkdirSync(evidenceDir, { recursive: true });
|
|
165
|
+
const evidence = {
|
|
166
|
+
status: complete ? smoke.status : "simulated",
|
|
167
|
+
requested_duration_ms: durationMs,
|
|
168
|
+
observed_duration_ms: observedDurationMs,
|
|
169
|
+
fast_forward: Boolean(input.fast_forward),
|
|
170
|
+
complete,
|
|
171
|
+
started_at: startedAt,
|
|
172
|
+
ended_at: endedAt,
|
|
173
|
+
runtimes: input.runtimes || [],
|
|
174
|
+
sessions: smoke.sessions,
|
|
175
|
+
};
|
|
176
|
+
const evidencePath = join(evidenceDir, `soak-${startedAt}.json`);
|
|
177
|
+
writeFileSync(evidencePath, JSON.stringify(evidence, null, 2));
|
|
178
|
+
return {
|
|
179
|
+
...smoke,
|
|
180
|
+
status: evidence.status,
|
|
181
|
+
duration_ms: durationMs,
|
|
182
|
+
observed_duration_ms: observedDurationMs,
|
|
183
|
+
complete,
|
|
184
|
+
evidence_path: evidencePath,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function latestSoakEvidence(hubDir: string) {
|
|
189
|
+
const evidenceDir = join(hubDir, "evidence");
|
|
190
|
+
if (!existsSync(evidenceDir)) return null;
|
|
191
|
+
const files = readdirSync(evidenceDir)
|
|
192
|
+
.filter((file) => file.startsWith("soak-") && file.endsWith(".json"))
|
|
193
|
+
.sort();
|
|
194
|
+
const latest = files.at(-1);
|
|
195
|
+
if (!latest) return null;
|
|
196
|
+
const path = join(evidenceDir, latest);
|
|
197
|
+
return { path, ...JSON.parse(readFileSync(path, "utf-8")) };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function audit(db: Database, actor: string, action: string, target: string, payload: unknown) {
|
|
201
|
+
db.run(
|
|
202
|
+
"INSERT INTO audit_log (id, actor, action, target, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
203
|
+
[generateId("audit"), actor, action, target, JSON.stringify(payload), now()],
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function createInbox(db: Database, kind: string, fromActor: string, toActor: string, subject: string, body: string, ref: unknown) {
|
|
208
|
+
db.run(
|
|
209
|
+
`INSERT INTO inbox_message (id, kind, from_actor, to_actor, subject, body, ref_json, status, created_at)
|
|
210
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'unread', ?)`,
|
|
211
|
+
[generateId("msg"), kind, fromActor, toActor, subject, body, JSON.stringify(ref), now()],
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function parseMetrics(raw: string | null | undefined) {
|
|
216
|
+
try {
|
|
217
|
+
return JSON.parse(raw || "{}");
|
|
218
|
+
} catch {
|
|
219
|
+
return {};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function shellQuote(value: string) {
|
|
224
|
+
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) return value;
|
|
225
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
226
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
|
|
3
|
+
export interface ProjectTaskRow {
|
|
4
|
+
id: string;
|
|
5
|
+
project_id?: string | null;
|
|
6
|
+
status?: string | null;
|
|
7
|
+
assignee_agent_id?: string | null;
|
|
8
|
+
updated_at?: number | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function projectExecutionStatuses(
|
|
12
|
+
claimedStatus?: string | null,
|
|
13
|
+
): string[] {
|
|
14
|
+
const statuses = new Set(["claimed", "in_progress"]);
|
|
15
|
+
if (claimedStatus) statuses.add(claimedStatus);
|
|
16
|
+
return [...statuses];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function findProjectExecutionBlocker(
|
|
20
|
+
db: Database,
|
|
21
|
+
input: {
|
|
22
|
+
projectId?: string | null;
|
|
23
|
+
excludeTaskId?: string | null;
|
|
24
|
+
claimedStatus?: string | null;
|
|
25
|
+
},
|
|
26
|
+
): ProjectTaskRow | null {
|
|
27
|
+
if (!input.projectId) return null;
|
|
28
|
+
const statuses = projectExecutionStatuses(input.claimedStatus);
|
|
29
|
+
const statusPlaceholders = statuses.map(() => "?").join(", ");
|
|
30
|
+
const excludeClause = input.excludeTaskId ? "AND id != ?" : "";
|
|
31
|
+
const params = input.excludeTaskId
|
|
32
|
+
? [input.projectId, input.excludeTaskId, ...statuses]
|
|
33
|
+
: [input.projectId, ...statuses];
|
|
34
|
+
|
|
35
|
+
return db
|
|
36
|
+
.query(
|
|
37
|
+
`SELECT * FROM task
|
|
38
|
+
WHERE project_id = ?
|
|
39
|
+
${excludeClause}
|
|
40
|
+
AND status IN (${statusPlaceholders})
|
|
41
|
+
ORDER BY updated_at ASC
|
|
42
|
+
LIMIT 1`,
|
|
43
|
+
)
|
|
44
|
+
.get(...params) as ProjectTaskRow | null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isTaskAssignedToAgent(
|
|
48
|
+
task: ProjectTaskRow,
|
|
49
|
+
agentId?: string | null,
|
|
50
|
+
): boolean {
|
|
51
|
+
return !task.assignee_agent_id || task.assignee_agent_id === agentId;
|
|
52
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { now } from "../core/db";
|
|
3
|
+
import type { HubContext } from "../core/server";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Role-centric agent resolution.
|
|
7
|
+
*
|
|
8
|
+
* Principle: Tasks are assigned to Roles. A Role is always "online" (logical entity).
|
|
9
|
+
* An Agent is merely the execution tool. When a Role receives work, it picks the best
|
|
10
|
+
* available Agent — or wakes one up if all are offline.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface ResolvedAgent {
|
|
14
|
+
id: string;
|
|
15
|
+
woken: boolean; // true if the agent was offline and was reactivated
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Find or wake an Agent under the given Role.
|
|
20
|
+
*
|
|
21
|
+
* Priority:
|
|
22
|
+
* 1. idle agent (no active session) — best candidate
|
|
23
|
+
* 2. idle agent (has running session but can accept more) — if capacity allows
|
|
24
|
+
* 3. offline agent — wake it up (set status to idle)
|
|
25
|
+
*
|
|
26
|
+
* Returns null only if the role has zero agents at all.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveAgentForRole(
|
|
29
|
+
db: Database,
|
|
30
|
+
roleId: string,
|
|
31
|
+
): ResolvedAgent | null {
|
|
32
|
+
// 1. Find idle agent without a running session
|
|
33
|
+
const idleAgent = db.query(
|
|
34
|
+
`SELECT a.id FROM agent a
|
|
35
|
+
LEFT JOIN session s ON s.agent_id = a.id AND s.status = 'running'
|
|
36
|
+
WHERE a.role_id = ? AND a.status = 'idle' AND s.id IS NULL
|
|
37
|
+
ORDER BY a.last_active_at DESC
|
|
38
|
+
LIMIT 1`
|
|
39
|
+
).get(roleId) as { id: string } | null;
|
|
40
|
+
|
|
41
|
+
if (idleAgent) return { id: idleAgent.id, woken: false };
|
|
42
|
+
|
|
43
|
+
// 2. Find idle agent that still has capacity (running sessions < capacity)
|
|
44
|
+
const idleWithCapacity = db.query(
|
|
45
|
+
`SELECT a.id FROM agent a
|
|
46
|
+
JOIN agent_runtime ar ON ar.id = a.runtime_id
|
|
47
|
+
LEFT JOIN (
|
|
48
|
+
SELECT agent_id, COUNT(*) as cnt FROM session WHERE status = 'running' GROUP BY agent_id
|
|
49
|
+
) sc ON sc.agent_id = a.id
|
|
50
|
+
WHERE a.role_id = ? AND a.status = 'idle'
|
|
51
|
+
AND (ar.capacity < 0 OR COALESCE(sc.cnt, 0) < ar.capacity)
|
|
52
|
+
ORDER BY COALESCE(sc.cnt, 0) ASC, a.last_active_at DESC
|
|
53
|
+
LIMIT 1`
|
|
54
|
+
).get(roleId) as { id: string } | null;
|
|
55
|
+
|
|
56
|
+
if (idleWithCapacity) return { id: idleWithCapacity.id, woken: false };
|
|
57
|
+
|
|
58
|
+
// 3. Wake an offline agent
|
|
59
|
+
const offlineAgent = db.query(
|
|
60
|
+
`SELECT id FROM agent WHERE role_id = ? AND status = 'offline' ORDER BY last_active_at DESC LIMIT 1`
|
|
61
|
+
).get(roleId) as { id: string } | null;
|
|
62
|
+
|
|
63
|
+
if (offlineAgent) {
|
|
64
|
+
const ts = now();
|
|
65
|
+
db.run("UPDATE agent SET status = 'idle', last_active_at = ? WHERE id = ?", [ts, offlineAgent.id]);
|
|
66
|
+
return { id: offlineAgent.id, woken: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Given a task (with optional required_capabilities), find the best Role in the workspace
|
|
74
|
+
* that can handle it, then resolve an Agent under that Role.
|
|
75
|
+
*/
|
|
76
|
+
export function findRoleAndAgentForTask(
|
|
77
|
+
ctx: HubContext,
|
|
78
|
+
task: { project_id: string; required_capabilities_json?: string },
|
|
79
|
+
): { role_id: string; agent: ResolvedAgent } | null {
|
|
80
|
+
const project = ctx.db.query("SELECT workspace_id FROM project WHERE id = ?").get(task.project_id) as any;
|
|
81
|
+
if (!project) return null;
|
|
82
|
+
|
|
83
|
+
const requiredCaps: string[] = safeJsonParse(task.required_capabilities_json);
|
|
84
|
+
|
|
85
|
+
// Find roles in this workspace that have agents
|
|
86
|
+
const roles = ctx.db.query(
|
|
87
|
+
`SELECT DISTINCT r.id, r.capabilities_json
|
|
88
|
+
FROM role r
|
|
89
|
+
JOIN agent a ON a.role_id = r.id
|
|
90
|
+
WHERE r.workspace_id = ?`
|
|
91
|
+
).all(project.workspace_id) as Array<{ id: string; capabilities_json: string }>;
|
|
92
|
+
|
|
93
|
+
for (const role of roles) {
|
|
94
|
+
// Check capabilities match
|
|
95
|
+
if (requiredCaps.length > 0) {
|
|
96
|
+
const roleCaps: string[] = safeJsonParse(role.capabilities_json);
|
|
97
|
+
const hasAll = requiredCaps.every((cap) => roleCaps.includes(cap));
|
|
98
|
+
if (!hasAll) continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const agent = resolveAgentForRole(ctx.db, role.id);
|
|
102
|
+
if (agent) return { role_id: role.id, agent };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function safeJsonParse(val: string | null | undefined): string[] {
|
|
109
|
+
if (!val) return [];
|
|
110
|
+
try { return JSON.parse(val); } catch { return []; }
|
|
111
|
+
}
|