@csdwd/ai-teams-server 0.1.0
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/dist/index.js +2364 -0
- package/package.json +51 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2364 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import Fastify from "fastify";
|
|
9
|
+
import swagger from "@fastify/swagger";
|
|
10
|
+
import swaggerUi from "@fastify/swagger-ui";
|
|
11
|
+
import websocket from "@fastify/websocket";
|
|
12
|
+
import fastifyStatic from "@fastify/static";
|
|
13
|
+
|
|
14
|
+
// ../../packages/shared/dist/index.js
|
|
15
|
+
var ProtocolError = class extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "ProtocolError";
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
function parseJsonMessage(raw) {
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(raw);
|
|
24
|
+
} catch {
|
|
25
|
+
throw new ProtocolError("Message is not valid JSON.");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function parseLeaderToServerMessage(value) {
|
|
29
|
+
const message = objectValue(value, "message");
|
|
30
|
+
const type = stringField(message, "type");
|
|
31
|
+
if (type === "command.dispatch") {
|
|
32
|
+
return {
|
|
33
|
+
type,
|
|
34
|
+
atAgents: agentTargetField(message, "atAgents"),
|
|
35
|
+
prompt: nonEmptyStringField(message, "prompt"),
|
|
36
|
+
workspace: optionalStringField(message, "workspace"),
|
|
37
|
+
timeoutSec: optionalPositiveNumberField(message, "timeoutSec")
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
if (type === "command.send") {
|
|
41
|
+
return {
|
|
42
|
+
type,
|
|
43
|
+
employeeId: nonEmptyStringField(message, "employeeId"),
|
|
44
|
+
prompt: nonEmptyStringField(message, "prompt"),
|
|
45
|
+
workspace: optionalStringField(message, "workspace"),
|
|
46
|
+
timeoutSec: optionalPositiveNumberField(message, "timeoutSec")
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (type === "command.broadcast") {
|
|
50
|
+
return {
|
|
51
|
+
type,
|
|
52
|
+
prompt: nonEmptyStringField(message, "prompt"),
|
|
53
|
+
workspace: optionalStringField(message, "workspace"),
|
|
54
|
+
timeoutSec: optionalPositiveNumberField(message, "timeoutSec")
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (type === "task.cancel") {
|
|
58
|
+
return { type, taskId: nonEmptyStringField(message, "taskId") };
|
|
59
|
+
}
|
|
60
|
+
throw new ProtocolError(`Unsupported leader message type: ${type}`);
|
|
61
|
+
}
|
|
62
|
+
function parseEmployeeToServerMessage(value) {
|
|
63
|
+
const message = objectValue(value, "message");
|
|
64
|
+
const type = stringField(message, "type");
|
|
65
|
+
if (type === "agent.register") {
|
|
66
|
+
return {
|
|
67
|
+
type,
|
|
68
|
+
employeeId: nonEmptyStringField(message, "employeeId"),
|
|
69
|
+
name: nonEmptyStringField(message, "name"),
|
|
70
|
+
machineId: nonEmptyStringField(message, "machineId"),
|
|
71
|
+
hostname: nonEmptyStringField(message, "hostname"),
|
|
72
|
+
labels: stringArrayField(message, "labels"),
|
|
73
|
+
activeMainTaskId: optionalNullableStringField(message, "activeMainTaskId"),
|
|
74
|
+
activeQueueTaskId: optionalNullableStringField(message, "activeQueueTaskId"),
|
|
75
|
+
lastOutputSeq: optionalNonNegativeNumberField(message, "lastOutputSeq")
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (type === "agent.heartbeat") {
|
|
79
|
+
return { type, employeeId: nonEmptyStringField(message, "employeeId") };
|
|
80
|
+
}
|
|
81
|
+
if (type === "agent.request_task") {
|
|
82
|
+
return { type, employeeId: nonEmptyStringField(message, "employeeId") };
|
|
83
|
+
}
|
|
84
|
+
if (type === "task.accepted" || type === "task.cancelled") {
|
|
85
|
+
return { type, taskId: nonEmptyStringField(message, "taskId") };
|
|
86
|
+
}
|
|
87
|
+
if (type === "task.started") {
|
|
88
|
+
return {
|
|
89
|
+
type,
|
|
90
|
+
taskId: nonEmptyStringField(message, "taskId"),
|
|
91
|
+
pid: nonNegativeNumberField(message, "pid"),
|
|
92
|
+
sessionId: optionalNullableStringField(message, "sessionId")
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (type === "task.output") {
|
|
96
|
+
const stream = stringField(message, "stream");
|
|
97
|
+
if (stream !== "stdout" && stream !== "stderr") {
|
|
98
|
+
throw new ProtocolError("task.output.stream must be stdout or stderr.");
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
type,
|
|
102
|
+
taskId: nonEmptyStringField(message, "taskId"),
|
|
103
|
+
stream,
|
|
104
|
+
seq: positiveIntegerField(message, "seq"),
|
|
105
|
+
content: stringField(message, "content")
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
if (type === "task.completed") {
|
|
109
|
+
return {
|
|
110
|
+
type,
|
|
111
|
+
taskId: nonEmptyStringField(message, "taskId"),
|
|
112
|
+
exitCode: nonNegativeNumberField(message, "exitCode"),
|
|
113
|
+
summary: optionalStringField(message, "summary"),
|
|
114
|
+
durationMs: optionalNonNegativeNumberField(message, "durationMs"),
|
|
115
|
+
durationApiMs: optionalNonNegativeNumberField(message, "durationApiMs"),
|
|
116
|
+
numTurns: optionalNonNegativeNumberField(message, "numTurns"),
|
|
117
|
+
totalCostUsd: optionalNonNegativeNumberField(message, "totalCostUsd"),
|
|
118
|
+
usageInputTokens: optionalNonNegativeNumberField(message, "usageInputTokens"),
|
|
119
|
+
usageOutputTokens: optionalNonNegativeNumberField(message, "usageOutputTokens"),
|
|
120
|
+
usageCacheReadTokens: optionalNonNegativeNumberField(message, "usageCacheReadTokens"),
|
|
121
|
+
usageCacheCreationTokens: optionalNonNegativeNumberField(message, "usageCacheCreationTokens")
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
if (type === "task.failed") {
|
|
125
|
+
return { type, taskId: nonEmptyStringField(message, "taskId"), error: nonEmptyStringField(message, "error") };
|
|
126
|
+
}
|
|
127
|
+
throw new ProtocolError(`Unsupported employee message type: ${type}`);
|
|
128
|
+
}
|
|
129
|
+
function objectValue(value, label) {
|
|
130
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
131
|
+
throw new ProtocolError(`${label} must be an object.`);
|
|
132
|
+
}
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
function stringField(record, key) {
|
|
136
|
+
const value = record[key];
|
|
137
|
+
if (typeof value !== "string") {
|
|
138
|
+
throw new ProtocolError(`${key} must be a string.`);
|
|
139
|
+
}
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
function nonEmptyStringField(record, key) {
|
|
143
|
+
const value = stringField(record, key).trim();
|
|
144
|
+
if (!value) {
|
|
145
|
+
throw new ProtocolError(`${key} must not be empty.`);
|
|
146
|
+
}
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
function optionalStringField(record, key) {
|
|
150
|
+
const value = record[key];
|
|
151
|
+
if (value === void 0) {
|
|
152
|
+
return void 0;
|
|
153
|
+
}
|
|
154
|
+
if (typeof value !== "string") {
|
|
155
|
+
throw new ProtocolError(`${key} must be a string.`);
|
|
156
|
+
}
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
function optionalNullableStringField(record, key) {
|
|
160
|
+
const value = record[key];
|
|
161
|
+
if (value === void 0 || value === null) {
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
if (typeof value !== "string") {
|
|
165
|
+
throw new ProtocolError(`${key} must be a string or null.`);
|
|
166
|
+
}
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
function stringArrayField(record, key) {
|
|
170
|
+
const value = record[key];
|
|
171
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
172
|
+
throw new ProtocolError(`${key} must be a string array.`);
|
|
173
|
+
}
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
function agentTargetField(record, key) {
|
|
177
|
+
const value = record[key];
|
|
178
|
+
if (value === "queue") {
|
|
179
|
+
return "queue";
|
|
180
|
+
}
|
|
181
|
+
if (value === "all") {
|
|
182
|
+
return "all";
|
|
183
|
+
}
|
|
184
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item.trim())) {
|
|
185
|
+
throw new ProtocolError(`${key} must be "queue", "all", or a non-empty string array.`);
|
|
186
|
+
}
|
|
187
|
+
return value.map((item) => item.trim());
|
|
188
|
+
}
|
|
189
|
+
function positiveNumberField(record, key) {
|
|
190
|
+
const value = record[key];
|
|
191
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
192
|
+
throw new ProtocolError(`${key} must be a positive number.`);
|
|
193
|
+
}
|
|
194
|
+
return value;
|
|
195
|
+
}
|
|
196
|
+
function optionalPositiveNumberField(record, key) {
|
|
197
|
+
if (record[key] === void 0) {
|
|
198
|
+
return void 0;
|
|
199
|
+
}
|
|
200
|
+
return positiveNumberField(record, key);
|
|
201
|
+
}
|
|
202
|
+
function nonNegativeNumberField(record, key) {
|
|
203
|
+
const value = record[key];
|
|
204
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
205
|
+
throw new ProtocolError(`${key} must be a non-negative number.`);
|
|
206
|
+
}
|
|
207
|
+
return value;
|
|
208
|
+
}
|
|
209
|
+
function optionalNonNegativeNumberField(record, key) {
|
|
210
|
+
if (record[key] === void 0) {
|
|
211
|
+
return void 0;
|
|
212
|
+
}
|
|
213
|
+
return nonNegativeNumberField(record, key);
|
|
214
|
+
}
|
|
215
|
+
function positiveIntegerField(record, key) {
|
|
216
|
+
const value = positiveNumberField(record, key);
|
|
217
|
+
if (!Number.isInteger(value)) {
|
|
218
|
+
throw new ProtocolError(`${key} must be an integer.`);
|
|
219
|
+
}
|
|
220
|
+
return value;
|
|
221
|
+
}
|
|
222
|
+
function isEncryptedEnvelope(value) {
|
|
223
|
+
return typeof value === "object" && value !== null && value.encrypted === true && typeof value.iv === "string" && typeof value.ciphertext === "string" && typeof value.tag === "string";
|
|
224
|
+
}
|
|
225
|
+
function parseEncryptionKey(hex) {
|
|
226
|
+
const key = Buffer.from(hex, "hex");
|
|
227
|
+
if (key.length !== 32) {
|
|
228
|
+
throw new Error("AI_TEAMS_ENCRYPTION_KEY must be 32 bytes (64 hex characters).");
|
|
229
|
+
}
|
|
230
|
+
return key;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// src/db.ts
|
|
234
|
+
import { DatabaseSync } from "node:sqlite";
|
|
235
|
+
import pg from "pg";
|
|
236
|
+
var SqliteDatabase = class _SqliteDatabase {
|
|
237
|
+
db;
|
|
238
|
+
constructor(db) {
|
|
239
|
+
this.db = db;
|
|
240
|
+
}
|
|
241
|
+
static toSqlite(sql, params) {
|
|
242
|
+
let idx = 0;
|
|
243
|
+
const converted = sql.replace(/\$\d+/g, () => {
|
|
244
|
+
idx += 1;
|
|
245
|
+
return "?";
|
|
246
|
+
});
|
|
247
|
+
return { sql: converted, params: params ?? [] };
|
|
248
|
+
}
|
|
249
|
+
async run(sql, params) {
|
|
250
|
+
const { sql: q, params: p } = _SqliteDatabase.toSqlite(sql, params);
|
|
251
|
+
this.db.prepare(q).run(...p);
|
|
252
|
+
}
|
|
253
|
+
async get(sql, params) {
|
|
254
|
+
const { sql: q, params: p } = _SqliteDatabase.toSqlite(sql, params);
|
|
255
|
+
return this.db.prepare(q).get(...p);
|
|
256
|
+
}
|
|
257
|
+
async all(sql, params) {
|
|
258
|
+
const { sql: q, params: p } = _SqliteDatabase.toSqlite(sql, params);
|
|
259
|
+
return this.db.prepare(q).all(...p);
|
|
260
|
+
}
|
|
261
|
+
async close() {
|
|
262
|
+
this.db.close();
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
var PostgresDatabase = class {
|
|
266
|
+
pool;
|
|
267
|
+
constructor(pool) {
|
|
268
|
+
this.pool = pool;
|
|
269
|
+
}
|
|
270
|
+
async run(sql, params) {
|
|
271
|
+
await this.pool.query(sql, params ?? []);
|
|
272
|
+
}
|
|
273
|
+
async get(sql, params) {
|
|
274
|
+
const result = await this.pool.query(sql, params ?? []);
|
|
275
|
+
return result.rows[0] ?? void 0;
|
|
276
|
+
}
|
|
277
|
+
async all(sql, params) {
|
|
278
|
+
const result = await this.pool.query(sql, params ?? []);
|
|
279
|
+
return result.rows;
|
|
280
|
+
}
|
|
281
|
+
async close() {
|
|
282
|
+
await this.pool.end();
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
async function createDatabaseFromEnv(env) {
|
|
286
|
+
const databaseUrl = env.DATABASE_URL;
|
|
287
|
+
if (databaseUrl) {
|
|
288
|
+
const pool = new pg.Pool({ connectionString: databaseUrl });
|
|
289
|
+
return new PostgresDatabase(pool);
|
|
290
|
+
}
|
|
291
|
+
const dataDir = env.DATA_DIR ?? process.cwd();
|
|
292
|
+
const dbPath = env.DB_PATH ?? `${dataDir}/ai-teams.db`;
|
|
293
|
+
const { mkdirSync } = await import("node:fs");
|
|
294
|
+
const { dirname } = await import("node:path");
|
|
295
|
+
mkdirSync(dirname(dbPath), { recursive: true });
|
|
296
|
+
const db = new DatabaseSync(dbPath);
|
|
297
|
+
return new SqliteDatabase(db);
|
|
298
|
+
}
|
|
299
|
+
async function initDb(db) {
|
|
300
|
+
await db.run(`
|
|
301
|
+
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
302
|
+
key TEXT PRIMARY KEY,
|
|
303
|
+
value TEXT NOT NULL
|
|
304
|
+
)
|
|
305
|
+
`);
|
|
306
|
+
await db.run(`
|
|
307
|
+
INSERT INTO schema_meta (key, value)
|
|
308
|
+
VALUES ('version', '2')
|
|
309
|
+
ON CONFLICT(key) DO UPDATE SET value = '2'
|
|
310
|
+
`);
|
|
311
|
+
await db.run(`
|
|
312
|
+
CREATE TABLE IF NOT EXISTS employees (
|
|
313
|
+
id TEXT PRIMARY KEY,
|
|
314
|
+
payload_json TEXT NOT NULL
|
|
315
|
+
)
|
|
316
|
+
`);
|
|
317
|
+
await db.run(`
|
|
318
|
+
CREATE TABLE IF NOT EXISTS tasks (
|
|
319
|
+
id TEXT PRIMARY KEY,
|
|
320
|
+
leader_command_id TEXT NOT NULL,
|
|
321
|
+
employee_id TEXT,
|
|
322
|
+
session_id TEXT,
|
|
323
|
+
target_mode TEXT NOT NULL DEFAULT 'queue',
|
|
324
|
+
prompt TEXT NOT NULL,
|
|
325
|
+
workspace TEXT,
|
|
326
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
327
|
+
timeout_sec INTEGER NOT NULL DEFAULT 1800,
|
|
328
|
+
cli_config TEXT,
|
|
329
|
+
priority INTEGER NOT NULL DEFAULT 1,
|
|
330
|
+
required_labels TEXT,
|
|
331
|
+
created_at TEXT NOT NULL,
|
|
332
|
+
started_at TEXT,
|
|
333
|
+
finished_at TEXT,
|
|
334
|
+
exit_code INTEGER,
|
|
335
|
+
summary TEXT,
|
|
336
|
+
error TEXT,
|
|
337
|
+
duration_ms INTEGER,
|
|
338
|
+
duration_api_ms INTEGER,
|
|
339
|
+
num_turns INTEGER,
|
|
340
|
+
total_cost_usd REAL,
|
|
341
|
+
usage_input_tokens INTEGER,
|
|
342
|
+
usage_output_tokens INTEGER,
|
|
343
|
+
usage_cache_read_tokens INTEGER,
|
|
344
|
+
usage_cache_creation_tokens INTEGER
|
|
345
|
+
)
|
|
346
|
+
`);
|
|
347
|
+
await db.run(`
|
|
348
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)
|
|
349
|
+
`);
|
|
350
|
+
await db.run(`
|
|
351
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_employee ON tasks(employee_id)
|
|
352
|
+
`);
|
|
353
|
+
await db.run(`
|
|
354
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_leader_cmd ON tasks(leader_command_id)
|
|
355
|
+
`);
|
|
356
|
+
await db.run(`
|
|
357
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_id)
|
|
358
|
+
`);
|
|
359
|
+
await db.run(`
|
|
360
|
+
CREATE INDEX IF NOT EXISTS idx_tasks_created ON tasks(created_at)
|
|
361
|
+
`);
|
|
362
|
+
await db.run(`
|
|
363
|
+
CREATE TABLE IF NOT EXISTS task_logs (
|
|
364
|
+
task_id TEXT NOT NULL,
|
|
365
|
+
seq INTEGER NOT NULL,
|
|
366
|
+
payload_json TEXT NOT NULL,
|
|
367
|
+
PRIMARY KEY (task_id, seq)
|
|
368
|
+
)
|
|
369
|
+
`);
|
|
370
|
+
await db.run(`
|
|
371
|
+
CREATE TABLE IF NOT EXISTS task_webhooks (
|
|
372
|
+
task_id TEXT PRIMARY KEY,
|
|
373
|
+
webhook_url TEXT NOT NULL
|
|
374
|
+
)
|
|
375
|
+
`);
|
|
376
|
+
}
|
|
377
|
+
async function hydrateState(db, state, defaultTimeoutSec, maxLogChunksPerTask) {
|
|
378
|
+
const employeeRows = await db.all("SELECT payload_json FROM employees");
|
|
379
|
+
for (const row of employeeRows) {
|
|
380
|
+
const employee = JSON.parse(row.payload_json);
|
|
381
|
+
employee.status = "offline";
|
|
382
|
+
state.employees.set(employee.id, employee);
|
|
383
|
+
}
|
|
384
|
+
const taskRows = await db.all("SELECT * FROM tasks");
|
|
385
|
+
for (const row of taskRows) {
|
|
386
|
+
const task = dbRowToTask(row, defaultTimeoutSec);
|
|
387
|
+
state.tasks.set(task.id, task);
|
|
388
|
+
if (task.status === "queued") {
|
|
389
|
+
if (task.targetMode === "queue" || !task.employeeId) {
|
|
390
|
+
state.sharedTaskQueue.push(task.id);
|
|
391
|
+
} else {
|
|
392
|
+
const queue = state.taskQueues.get(task.employeeId) ?? [];
|
|
393
|
+
queue.push(task.id);
|
|
394
|
+
state.taskQueues.set(task.employeeId, queue);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const logRows = await db.all(
|
|
399
|
+
`SELECT payload_json FROM (
|
|
400
|
+
SELECT task_id, seq, payload_json,
|
|
401
|
+
ROW_NUMBER() OVER (PARTITION BY task_id ORDER BY seq DESC) AS rn
|
|
402
|
+
FROM task_logs
|
|
403
|
+
) sub
|
|
404
|
+
WHERE rn <= $1
|
|
405
|
+
ORDER BY task_id ASC, seq ASC`,
|
|
406
|
+
[maxLogChunksPerTask]
|
|
407
|
+
);
|
|
408
|
+
for (const row of logRows) {
|
|
409
|
+
const chunk = JSON.parse(row.payload_json);
|
|
410
|
+
const history = state.taskLogs.get(chunk.taskId) ?? [];
|
|
411
|
+
history.push(chunk);
|
|
412
|
+
state.taskLogs.set(chunk.taskId, history);
|
|
413
|
+
}
|
|
414
|
+
const webhookRows = await db.all("SELECT task_id, webhook_url FROM task_webhooks");
|
|
415
|
+
for (const row of webhookRows) {
|
|
416
|
+
state.taskWebhooks.set(row.task_id, row.webhook_url);
|
|
417
|
+
}
|
|
418
|
+
const cursorRow = await db.get("SELECT value FROM schema_meta WHERE key = 'sharedQueueCursor'");
|
|
419
|
+
if (cursorRow) {
|
|
420
|
+
state.sharedQueueCursor = Number(cursorRow.value) || 0;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
async function persistEmployee(db, employee) {
|
|
424
|
+
await db.run(
|
|
425
|
+
`INSERT INTO employees (id, payload_json)
|
|
426
|
+
VALUES ($1, $2)
|
|
427
|
+
ON CONFLICT(id) DO UPDATE SET payload_json = excluded.payload_json`,
|
|
428
|
+
[employee.id, JSON.stringify(employee)]
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
var TASK_COLUMNS = [
|
|
432
|
+
"id",
|
|
433
|
+
"leader_command_id",
|
|
434
|
+
"employee_id",
|
|
435
|
+
"session_id",
|
|
436
|
+
"target_mode",
|
|
437
|
+
"prompt",
|
|
438
|
+
"workspace",
|
|
439
|
+
"status",
|
|
440
|
+
"timeout_sec",
|
|
441
|
+
"cli_config",
|
|
442
|
+
"priority",
|
|
443
|
+
"required_labels",
|
|
444
|
+
"created_at",
|
|
445
|
+
"started_at",
|
|
446
|
+
"finished_at",
|
|
447
|
+
"exit_code",
|
|
448
|
+
"summary",
|
|
449
|
+
"error",
|
|
450
|
+
"duration_ms",
|
|
451
|
+
"duration_api_ms",
|
|
452
|
+
"num_turns",
|
|
453
|
+
"total_cost_usd",
|
|
454
|
+
"usage_input_tokens",
|
|
455
|
+
"usage_output_tokens",
|
|
456
|
+
"usage_cache_read_tokens",
|
|
457
|
+
"usage_cache_creation_tokens"
|
|
458
|
+
];
|
|
459
|
+
var TASK_PLACEHOLDERS = TASK_COLUMNS.map((_, i) => `$${i + 1}`).join(", ");
|
|
460
|
+
var TASK_UPDATE_SET = TASK_COLUMNS.slice(1).map((col) => `${col} = excluded.${col}`).join(", ");
|
|
461
|
+
async function persistTask(db, task) {
|
|
462
|
+
const values = taskToDbValues(task);
|
|
463
|
+
await db.run(
|
|
464
|
+
`INSERT INTO tasks (${TASK_COLUMNS.join(", ")})
|
|
465
|
+
VALUES (${TASK_PLACEHOLDERS})
|
|
466
|
+
ON CONFLICT(id) DO UPDATE SET ${TASK_UPDATE_SET}`,
|
|
467
|
+
values
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
async function persistTaskWebhook(db, taskId, webhookUrl) {
|
|
471
|
+
await db.run(
|
|
472
|
+
`INSERT INTO task_webhooks (task_id, webhook_url)
|
|
473
|
+
VALUES ($1, $2)
|
|
474
|
+
ON CONFLICT(task_id) DO UPDATE SET webhook_url = excluded.webhook_url`,
|
|
475
|
+
[taskId, webhookUrl]
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
async function persistSharedQueueCursor(db, cursor) {
|
|
479
|
+
await db.run(
|
|
480
|
+
`INSERT INTO schema_meta (key, value)
|
|
481
|
+
VALUES ('sharedQueueCursor', $1)
|
|
482
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
483
|
+
[String(cursor)]
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
async function persistTaskLog(db, chunk, maxLogChunksPerTask) {
|
|
487
|
+
await db.run(
|
|
488
|
+
`INSERT INTO task_logs (task_id, seq, payload_json)
|
|
489
|
+
VALUES ($1, $2, $3)
|
|
490
|
+
ON CONFLICT(task_id, seq) DO UPDATE SET payload_json = excluded.payload_json`,
|
|
491
|
+
[chunk.taskId, chunk.seq, JSON.stringify(chunk)]
|
|
492
|
+
);
|
|
493
|
+
await db.run(
|
|
494
|
+
`DELETE FROM task_logs
|
|
495
|
+
WHERE task_id = $1
|
|
496
|
+
AND seq NOT IN (
|
|
497
|
+
SELECT seq FROM task_logs
|
|
498
|
+
WHERE task_id = $2
|
|
499
|
+
ORDER BY seq DESC
|
|
500
|
+
LIMIT $3
|
|
501
|
+
)`,
|
|
502
|
+
[chunk.taskId, chunk.taskId, maxLogChunksPerTask]
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
async function queryTasks(db, filters) {
|
|
506
|
+
const conditions = [];
|
|
507
|
+
const params = [];
|
|
508
|
+
let paramIdx = 1;
|
|
509
|
+
if (filters.status) {
|
|
510
|
+
conditions.push(`status = $${paramIdx++}`);
|
|
511
|
+
params.push(filters.status);
|
|
512
|
+
}
|
|
513
|
+
if (filters.employeeId) {
|
|
514
|
+
conditions.push(`employee_id = $${paramIdx++}`);
|
|
515
|
+
params.push(filters.employeeId);
|
|
516
|
+
}
|
|
517
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
518
|
+
const limit = filters.limit ?? 50;
|
|
519
|
+
const offset = filters.offset ?? 0;
|
|
520
|
+
params.push(limit, offset);
|
|
521
|
+
return db.all(
|
|
522
|
+
`SELECT * FROM tasks ${where} ORDER BY created_at DESC LIMIT $${paramIdx++} OFFSET $${paramIdx++}`,
|
|
523
|
+
params
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
async function getTaskById(db, taskId) {
|
|
527
|
+
return db.get("SELECT * FROM tasks WHERE id = $1", [taskId]);
|
|
528
|
+
}
|
|
529
|
+
async function deleteTask(db, taskId) {
|
|
530
|
+
const row = await db.get("SELECT status FROM tasks WHERE id = $1", [taskId]);
|
|
531
|
+
if (!row) return false;
|
|
532
|
+
if (!["completed", "failed", "cancelled", "timeout"].includes(row.status)) return false;
|
|
533
|
+
await db.run("DELETE FROM task_logs WHERE task_id = $1", [taskId]);
|
|
534
|
+
await db.run("DELETE FROM task_webhooks WHERE task_id = $1", [taskId]);
|
|
535
|
+
await db.run("DELETE FROM tasks WHERE id = $1", [taskId]);
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
async function updateTaskFields(db, taskId, fields) {
|
|
539
|
+
const entries = Object.entries(fields);
|
|
540
|
+
if (entries.length === 0) return getTaskById(db, taskId);
|
|
541
|
+
const setClauses = entries.map(([key], i) => `${toSnakeCase(key)} = $${i + 2}`).join(", ");
|
|
542
|
+
const values = entries.map(([, val]) => val);
|
|
543
|
+
await db.run(
|
|
544
|
+
`UPDATE tasks SET ${setClauses} WHERE id = $1`,
|
|
545
|
+
[taskId, ...values]
|
|
546
|
+
);
|
|
547
|
+
return getTaskById(db, taskId);
|
|
548
|
+
}
|
|
549
|
+
function dbRowToTask(row, defaultTimeoutSec) {
|
|
550
|
+
return {
|
|
551
|
+
id: row.id,
|
|
552
|
+
leaderCommandId: row.leader_command_id,
|
|
553
|
+
employeeId: row.employee_id,
|
|
554
|
+
sessionId: row.session_id,
|
|
555
|
+
targetMode: row.target_mode || (row.employee_id ? "direct" : "queue"),
|
|
556
|
+
prompt: row.prompt,
|
|
557
|
+
workspace: row.workspace,
|
|
558
|
+
timeoutSec: row.timeout_sec ?? defaultTimeoutSec,
|
|
559
|
+
cliConfig: row.cli_config ? JSON.parse(row.cli_config) : null,
|
|
560
|
+
priority: row.priority ?? 1,
|
|
561
|
+
requiredLabels: row.required_labels ? JSON.parse(row.required_labels) : null,
|
|
562
|
+
status: row.status || "queued",
|
|
563
|
+
createdAt: row.created_at,
|
|
564
|
+
startedAt: row.started_at,
|
|
565
|
+
finishedAt: row.finished_at,
|
|
566
|
+
exitCode: row.exit_code,
|
|
567
|
+
summary: row.summary,
|
|
568
|
+
error: row.error,
|
|
569
|
+
durationMs: row.duration_ms,
|
|
570
|
+
durationApiMs: row.duration_api_ms,
|
|
571
|
+
numTurns: row.num_turns,
|
|
572
|
+
totalCostUsd: row.total_cost_usd,
|
|
573
|
+
usageInputTokens: row.usage_input_tokens,
|
|
574
|
+
usageOutputTokens: row.usage_output_tokens,
|
|
575
|
+
usageCacheReadTokens: row.usage_cache_read_tokens,
|
|
576
|
+
usageCacheCreationTokens: row.usage_cache_creation_tokens
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
function taskToDbValues(task) {
|
|
580
|
+
return [
|
|
581
|
+
task.id,
|
|
582
|
+
task.leaderCommandId,
|
|
583
|
+
task.employeeId,
|
|
584
|
+
task.sessionId,
|
|
585
|
+
task.targetMode,
|
|
586
|
+
task.prompt,
|
|
587
|
+
task.workspace,
|
|
588
|
+
task.status,
|
|
589
|
+
task.timeoutSec,
|
|
590
|
+
task.cliConfig ? JSON.stringify(task.cliConfig) : null,
|
|
591
|
+
task.priority,
|
|
592
|
+
task.requiredLabels ? JSON.stringify(task.requiredLabels) : null,
|
|
593
|
+
task.createdAt,
|
|
594
|
+
task.startedAt,
|
|
595
|
+
task.finishedAt,
|
|
596
|
+
task.exitCode,
|
|
597
|
+
task.summary,
|
|
598
|
+
task.error,
|
|
599
|
+
task.durationMs,
|
|
600
|
+
task.durationApiMs,
|
|
601
|
+
task.numTurns,
|
|
602
|
+
task.totalCostUsd,
|
|
603
|
+
task.usageInputTokens,
|
|
604
|
+
task.usageOutputTokens,
|
|
605
|
+
task.usageCacheReadTokens,
|
|
606
|
+
task.usageCacheCreationTokens
|
|
607
|
+
];
|
|
608
|
+
}
|
|
609
|
+
function toSnakeCase(str) {
|
|
610
|
+
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// src/schemas.ts
|
|
614
|
+
var errorResponseSchema = {
|
|
615
|
+
type: "object",
|
|
616
|
+
required: ["error"],
|
|
617
|
+
properties: {
|
|
618
|
+
error: { type: "string" }
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
var taskStatusSchema = {
|
|
622
|
+
type: "string",
|
|
623
|
+
enum: ["queued", "dispatched", "accepted", "running", "completed", "failed", "cancelled", "timeout"]
|
|
624
|
+
};
|
|
625
|
+
var nullableNumber = { anyOf: [{ type: "number" }, { type: "null" }] };
|
|
626
|
+
var nullableString = { anyOf: [{ type: "string" }, { type: "null" }] };
|
|
627
|
+
var taskCliConfigSchema = {
|
|
628
|
+
type: "object",
|
|
629
|
+
properties: {
|
|
630
|
+
model: { type: "string" },
|
|
631
|
+
permissionMode: { type: "string" },
|
|
632
|
+
maxTurns: { type: "number" },
|
|
633
|
+
systemPrompt: { type: "string" },
|
|
634
|
+
appendSystemPrompt: { type: "string" },
|
|
635
|
+
allowedTools: { type: "array", items: { type: "string" } },
|
|
636
|
+
disallowedTools: { type: "array", items: { type: "string" } },
|
|
637
|
+
extraArgs: { type: "array", items: { type: "string" } }
|
|
638
|
+
}
|
|
639
|
+
};
|
|
640
|
+
var taskRecordSchema = {
|
|
641
|
+
type: "object",
|
|
642
|
+
required: [
|
|
643
|
+
"id",
|
|
644
|
+
"leaderCommandId",
|
|
645
|
+
"employeeId",
|
|
646
|
+
"sessionId",
|
|
647
|
+
"targetMode",
|
|
648
|
+
"prompt",
|
|
649
|
+
"workspace",
|
|
650
|
+
"timeoutSec",
|
|
651
|
+
"cliConfig",
|
|
652
|
+
"priority",
|
|
653
|
+
"requiredLabels",
|
|
654
|
+
"status",
|
|
655
|
+
"createdAt",
|
|
656
|
+
"startedAt",
|
|
657
|
+
"finishedAt",
|
|
658
|
+
"exitCode",
|
|
659
|
+
"summary",
|
|
660
|
+
"error",
|
|
661
|
+
"durationMs",
|
|
662
|
+
"durationApiMs",
|
|
663
|
+
"numTurns",
|
|
664
|
+
"totalCostUsd",
|
|
665
|
+
"usageInputTokens",
|
|
666
|
+
"usageOutputTokens",
|
|
667
|
+
"usageCacheReadTokens",
|
|
668
|
+
"usageCacheCreationTokens"
|
|
669
|
+
],
|
|
670
|
+
properties: {
|
|
671
|
+
id: { type: "string" },
|
|
672
|
+
leaderCommandId: { type: "string" },
|
|
673
|
+
employeeId: nullableString,
|
|
674
|
+
sessionId: nullableString,
|
|
675
|
+
targetMode: { type: "string", enum: ["queue", "direct", "broadcast"] },
|
|
676
|
+
prompt: { type: "string" },
|
|
677
|
+
workspace: nullableString,
|
|
678
|
+
timeoutSec: { type: "number" },
|
|
679
|
+
cliConfig: { anyOf: [taskCliConfigSchema, { type: "null" }] },
|
|
680
|
+
priority: { type: "integer", minimum: 0, maximum: 3 },
|
|
681
|
+
requiredLabels: { anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }] },
|
|
682
|
+
status: taskStatusSchema,
|
|
683
|
+
createdAt: { type: "string", format: "date-time" },
|
|
684
|
+
startedAt: { anyOf: [{ type: "string", format: "date-time" }, { type: "null" }] },
|
|
685
|
+
finishedAt: { anyOf: [{ type: "string", format: "date-time" }, { type: "null" }] },
|
|
686
|
+
exitCode: nullableNumber,
|
|
687
|
+
summary: nullableString,
|
|
688
|
+
error: nullableString,
|
|
689
|
+
durationMs: nullableNumber,
|
|
690
|
+
durationApiMs: nullableNumber,
|
|
691
|
+
numTurns: nullableNumber,
|
|
692
|
+
totalCostUsd: nullableNumber,
|
|
693
|
+
usageInputTokens: nullableNumber,
|
|
694
|
+
usageOutputTokens: nullableNumber,
|
|
695
|
+
usageCacheReadTokens: nullableNumber,
|
|
696
|
+
usageCacheCreationTokens: nullableNumber
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
var employeeSnapshotSchema = {
|
|
700
|
+
type: "object",
|
|
701
|
+
required: [
|
|
702
|
+
"id",
|
|
703
|
+
"name",
|
|
704
|
+
"machineId",
|
|
705
|
+
"hostname",
|
|
706
|
+
"labels",
|
|
707
|
+
"status",
|
|
708
|
+
"mainTaskId",
|
|
709
|
+
"mainTaskPrompt",
|
|
710
|
+
"queueTaskId",
|
|
711
|
+
"queueTaskPrompt",
|
|
712
|
+
"lastSeenAt"
|
|
713
|
+
],
|
|
714
|
+
properties: {
|
|
715
|
+
id: { type: "string" },
|
|
716
|
+
name: { type: "string" },
|
|
717
|
+
machineId: { type: "string" },
|
|
718
|
+
hostname: { type: "string" },
|
|
719
|
+
labels: { type: "array", items: { type: "string" } },
|
|
720
|
+
status: { type: "string", enum: ["online", "offline"] },
|
|
721
|
+
mainTaskId: { anyOf: [{ type: "string" }, { type: "null" }] },
|
|
722
|
+
mainTaskPrompt: { anyOf: [{ type: "string" }, { type: "null" }] },
|
|
723
|
+
queueTaskId: { anyOf: [{ type: "string" }, { type: "null" }] },
|
|
724
|
+
queueTaskPrompt: { anyOf: [{ type: "string" }, { type: "null" }] },
|
|
725
|
+
lastSeenAt: { type: "string", format: "date-time" }
|
|
726
|
+
}
|
|
727
|
+
};
|
|
728
|
+
var taskOutputChunkSchema = {
|
|
729
|
+
type: "object",
|
|
730
|
+
required: ["taskId", "employeeId", "stream", "seq", "content", "createdAt"],
|
|
731
|
+
properties: {
|
|
732
|
+
taskId: { type: "string" },
|
|
733
|
+
employeeId: { type: "string" },
|
|
734
|
+
stream: { type: "string", enum: ["stdout", "stderr"] },
|
|
735
|
+
seq: { type: "number" },
|
|
736
|
+
content: { type: "string" },
|
|
737
|
+
createdAt: { type: "string", format: "date-time" }
|
|
738
|
+
}
|
|
739
|
+
};
|
|
740
|
+
var snapshotSchema = {
|
|
741
|
+
type: "object",
|
|
742
|
+
required: ["employees", "tasks", "logs"],
|
|
743
|
+
properties: {
|
|
744
|
+
employees: { type: "array", items: employeeSnapshotSchema },
|
|
745
|
+
tasks: { type: "array", items: taskRecordSchema },
|
|
746
|
+
logs: {
|
|
747
|
+
type: "object",
|
|
748
|
+
additionalProperties: {
|
|
749
|
+
type: "array",
|
|
750
|
+
items: taskOutputChunkSchema
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
var restTaskRequestSchema = {
|
|
756
|
+
type: "object",
|
|
757
|
+
required: ["prompt"],
|
|
758
|
+
properties: {
|
|
759
|
+
atAgents: {
|
|
760
|
+
anyOf: [
|
|
761
|
+
{ type: "string", enum: ["queue", "all"] },
|
|
762
|
+
{ type: "array", items: { type: "string" }, minItems: 1 }
|
|
763
|
+
],
|
|
764
|
+
default: "queue"
|
|
765
|
+
},
|
|
766
|
+
prompt: { type: "string", minLength: 1 },
|
|
767
|
+
workspace: { type: "string" },
|
|
768
|
+
timeoutSec: { type: "number", minimum: 1 },
|
|
769
|
+
priority: { type: "integer", minimum: 0, maximum: 3 },
|
|
770
|
+
requiredLabels: { type: "array", items: { type: "string" } },
|
|
771
|
+
cliConfig: taskCliConfigSchema,
|
|
772
|
+
webhook: {
|
|
773
|
+
anyOf: [
|
|
774
|
+
{ type: "string", format: "uri" },
|
|
775
|
+
{
|
|
776
|
+
type: "object",
|
|
777
|
+
required: ["url"],
|
|
778
|
+
properties: {
|
|
779
|
+
url: { type: "string", format: "uri" }
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
]
|
|
783
|
+
},
|
|
784
|
+
webHook: {
|
|
785
|
+
anyOf: [{ type: "string", format: "uri" }, { type: "object", required: ["url"], properties: { url: { type: "string" } } }]
|
|
786
|
+
},
|
|
787
|
+
webhookUrl: { type: "string", format: "uri" }
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
var restTaskAcceptedSchema = {
|
|
791
|
+
type: "object",
|
|
792
|
+
required: ["status", "leaderCommandId", "tasks"],
|
|
793
|
+
properties: {
|
|
794
|
+
status: { type: "string", enum: ["accepted"] },
|
|
795
|
+
leaderCommandId: { type: "string" },
|
|
796
|
+
tasks: { type: "array", items: taskRecordSchema }
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
var sessionHistoryMessageSchema = {
|
|
800
|
+
type: "object",
|
|
801
|
+
required: ["type", "role", "taskId", "content", "createdAt"],
|
|
802
|
+
properties: {
|
|
803
|
+
type: { type: "string", enum: ["task.prompt", "task.output", "task.result"] },
|
|
804
|
+
role: { type: "string", enum: ["user", "assistant", "system"] },
|
|
805
|
+
taskId: { type: "string" },
|
|
806
|
+
stream: { type: "string", enum: ["stdout", "stderr"] },
|
|
807
|
+
seq: { type: "number" },
|
|
808
|
+
content: { type: "string" },
|
|
809
|
+
createdAt: { type: "string", format: "date-time" }
|
|
810
|
+
}
|
|
811
|
+
};
|
|
812
|
+
var sessionHistorySchema = {
|
|
813
|
+
type: "object",
|
|
814
|
+
required: ["sessionId", "tasks", "messages"],
|
|
815
|
+
properties: {
|
|
816
|
+
sessionId: { type: "string" },
|
|
817
|
+
tasks: { type: "array", items: taskRecordSchema },
|
|
818
|
+
messages: { type: "array", items: sessionHistoryMessageSchema }
|
|
819
|
+
}
|
|
820
|
+
};
|
|
821
|
+
var taskListResponseSchema = {
|
|
822
|
+
type: "object",
|
|
823
|
+
required: ["tasks"],
|
|
824
|
+
properties: {
|
|
825
|
+
tasks: { type: "array", items: taskRecordSchema }
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
var taskPatchSchema = {
|
|
829
|
+
type: "object",
|
|
830
|
+
properties: {
|
|
831
|
+
status: { type: "string", enum: ["cancelled"] },
|
|
832
|
+
timeoutSec: { type: "number", minimum: 1 },
|
|
833
|
+
cliConfig: taskCliConfigSchema
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
var claudeSessionItemSchema = {
|
|
837
|
+
type: "object",
|
|
838
|
+
required: ["id", "sizeBytes", "modifiedAt", "lineCount"],
|
|
839
|
+
properties: {
|
|
840
|
+
id: { type: "string" },
|
|
841
|
+
sizeBytes: { type: "number" },
|
|
842
|
+
modifiedAt: { type: "string", format: "date-time" },
|
|
843
|
+
lineCount: { type: "number" },
|
|
844
|
+
firstUserMessage: nullableString,
|
|
845
|
+
latestUserMessage: nullableString
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
var claudeSessionsResponseSchema = {
|
|
849
|
+
type: "object",
|
|
850
|
+
required: ["employeeId", "workspace", "sessions"],
|
|
851
|
+
properties: {
|
|
852
|
+
employeeId: { type: "string" },
|
|
853
|
+
workspace: nullableString,
|
|
854
|
+
activeSessionId: nullableString,
|
|
855
|
+
sessions: { type: "array", items: claudeSessionItemSchema }
|
|
856
|
+
}
|
|
857
|
+
};
|
|
858
|
+
function parseRestTaskRequest(body) {
|
|
859
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
860
|
+
throw new Error("Request body must be a JSON object.");
|
|
861
|
+
}
|
|
862
|
+
const request = body;
|
|
863
|
+
const command = parseLeaderToServerMessage({
|
|
864
|
+
type: "command.dispatch",
|
|
865
|
+
atAgents: request.atAgents ?? "queue",
|
|
866
|
+
prompt: request.prompt,
|
|
867
|
+
workspace: request.workspace,
|
|
868
|
+
timeoutSec: request.timeoutSec
|
|
869
|
+
});
|
|
870
|
+
const webhookUrl = parseWebhookUrl(request.webhook ?? request.webHook ?? request.webhookUrl);
|
|
871
|
+
return {
|
|
872
|
+
command,
|
|
873
|
+
webhookUrl,
|
|
874
|
+
cliConfig: request.cliConfig ?? void 0,
|
|
875
|
+
priority: typeof request.priority === "number" ? request.priority : void 0,
|
|
876
|
+
requiredLabels: Array.isArray(request.requiredLabels) ? request.requiredLabels : void 0
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
function parseWebhookUrl(value) {
|
|
880
|
+
if (value === void 0 || value === null || value === "") {
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
const rawUrl = typeof value === "string" ? value : typeof value === "object" && !Array.isArray(value) && typeof value.url === "string" ? value.url : null;
|
|
884
|
+
if (!rawUrl) {
|
|
885
|
+
throw new Error("webhook must be a URL string or an object with a url string.");
|
|
886
|
+
}
|
|
887
|
+
const url = new URL(rawUrl);
|
|
888
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
889
|
+
throw new Error("webhook URL must use http or https.");
|
|
890
|
+
}
|
|
891
|
+
return url.toString();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// src/dispatch.ts
|
|
895
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
896
|
+
import WebSocket from "ws";
|
|
897
|
+
var TERMINAL_STATUSES = /* @__PURE__ */ new Set(["completed", "failed", "cancelled", "timeout"]);
|
|
898
|
+
function nowIso() {
|
|
899
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
900
|
+
}
|
|
901
|
+
function sendJson(socket, payload, encryptor) {
|
|
902
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
903
|
+
const plain = JSON.stringify(payload);
|
|
904
|
+
socket.send(encryptor ? encryptor.encrypt(plain) : plain);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
function extractDoneSessionId(content) {
|
|
908
|
+
const match = content.match(/^\[done\]\s+session_id:\s*(\S+)/m);
|
|
909
|
+
return match?.[1] ?? null;
|
|
910
|
+
}
|
|
911
|
+
function createDispatch(ctx) {
|
|
912
|
+
const { state, db, log } = ctx;
|
|
913
|
+
function broadcastToLeaders(payload) {
|
|
914
|
+
for (const socket of state.leaderSockets) {
|
|
915
|
+
sendJson(socket, payload, ctx.encryptor);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
function upsertEmployee(employee) {
|
|
919
|
+
state.employees.set(employee.id, employee);
|
|
920
|
+
persistEmployee(db, employee).catch((error) => log.error({ error, employeeId: employee.id }, "Failed to persist employee"));
|
|
921
|
+
broadcastToLeaders({ type: "employee.upsert", employee });
|
|
922
|
+
}
|
|
923
|
+
function resetHeartbeatTimer(employeeId) {
|
|
924
|
+
const existing = state.heartbeatTimers.get(employeeId);
|
|
925
|
+
if (existing) clearTimeout(existing);
|
|
926
|
+
state.heartbeatTimers.set(employeeId, setTimeout(() => {
|
|
927
|
+
state.heartbeatTimers.delete(employeeId);
|
|
928
|
+
const employee = state.employees.get(employeeId);
|
|
929
|
+
if (!employee || employee.status === "offline") return;
|
|
930
|
+
log.warn({ employeeId }, "Agent heartbeat timeout, marking offline");
|
|
931
|
+
employee.status = "offline";
|
|
932
|
+
upsertEmployee(employee);
|
|
933
|
+
const queueTaskId = employee.queueTaskId;
|
|
934
|
+
if (queueTaskId) {
|
|
935
|
+
const task = state.tasks.get(queueTaskId);
|
|
936
|
+
if (task && (task.status === "queued" || task.status === "dispatched")) {
|
|
937
|
+
task.status = "queued";
|
|
938
|
+
task.employeeId = null;
|
|
939
|
+
task.startedAt = null;
|
|
940
|
+
upsertTask(task);
|
|
941
|
+
state.sharedTaskQueue.push(task.id);
|
|
942
|
+
log.info({ taskId: task.id }, "Reclaimed queue task from unresponsive agent");
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
setQueueTask(employeeId, null, null);
|
|
946
|
+
dispatchSharedQueuedTasks();
|
|
947
|
+
}, 3e4));
|
|
948
|
+
}
|
|
949
|
+
function upsertTask(task) {
|
|
950
|
+
state.tasks.set(task.id, task);
|
|
951
|
+
persistTask(db, task).catch((error) => log.error({ error, taskId: task.id }, "Failed to persist task"));
|
|
952
|
+
broadcastToLeaders({ type: "task.upsert", task });
|
|
953
|
+
}
|
|
954
|
+
function signWebhookPayload(body) {
|
|
955
|
+
const hmac = createHmac("sha256", ctx.authToken);
|
|
956
|
+
hmac.update(body);
|
|
957
|
+
return `sha256=${hmac.digest("hex")}`;
|
|
958
|
+
}
|
|
959
|
+
async function deliverWebhook(webhookUrl, body, signature, taskId, attempt = 1) {
|
|
960
|
+
try {
|
|
961
|
+
const response = await fetch(webhookUrl, {
|
|
962
|
+
method: "POST",
|
|
963
|
+
headers: {
|
|
964
|
+
"content-type": "application/json",
|
|
965
|
+
"x-ai-teams-signature": signature
|
|
966
|
+
},
|
|
967
|
+
body
|
|
968
|
+
});
|
|
969
|
+
if (!response.ok && attempt < 3) {
|
|
970
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
971
|
+
return deliverWebhook(webhookUrl, body, signature, taskId, attempt + 1);
|
|
972
|
+
}
|
|
973
|
+
if (!response.ok) {
|
|
974
|
+
log.warn({ taskId, webhookUrl, status: response.status, attempt }, "Task webhook returned non-2xx after retries");
|
|
975
|
+
} else if (attempt > 1) {
|
|
976
|
+
log.info({ taskId, webhookUrl, attempt }, "Task webhook succeeded on retry");
|
|
977
|
+
}
|
|
978
|
+
} catch (error) {
|
|
979
|
+
if (attempt < 3) {
|
|
980
|
+
await new Promise((resolve) => setTimeout(resolve, 1e3));
|
|
981
|
+
return deliverWebhook(webhookUrl, body, signature, taskId, attempt + 1);
|
|
982
|
+
}
|
|
983
|
+
log.warn({ taskId, webhookUrl, error, attempt }, "Task webhook delivery failed after retries");
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
function postTaskWebhook(task, event, extra = {}) {
|
|
987
|
+
const webhookUrl = state.taskWebhooks.get(task.id);
|
|
988
|
+
if (!webhookUrl) {
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
const payload = {
|
|
992
|
+
event,
|
|
993
|
+
timestamp: nowIso(),
|
|
994
|
+
task,
|
|
995
|
+
...extra
|
|
996
|
+
};
|
|
997
|
+
const body = JSON.stringify(payload);
|
|
998
|
+
void deliverWebhook(webhookUrl, body, signWebhookPayload(body), task.id);
|
|
999
|
+
}
|
|
1000
|
+
function clearTaskTimeout(taskId) {
|
|
1001
|
+
const timer = state.taskTimeouts.get(taskId);
|
|
1002
|
+
if (timer) {
|
|
1003
|
+
clearTimeout(timer);
|
|
1004
|
+
state.taskTimeouts.delete(taskId);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
function setMainTask(employeeId, taskId, prompt) {
|
|
1008
|
+
const employee = state.employees.get(employeeId);
|
|
1009
|
+
if (!employee) {
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
employee.mainTaskId = taskId;
|
|
1013
|
+
employee.mainTaskPrompt = prompt;
|
|
1014
|
+
employee.lastSeenAt = nowIso();
|
|
1015
|
+
upsertEmployee(employee);
|
|
1016
|
+
}
|
|
1017
|
+
function setQueueTask(employeeId, taskId, prompt) {
|
|
1018
|
+
const employee = state.employees.get(employeeId);
|
|
1019
|
+
if (!employee) {
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
employee.queueTaskId = taskId;
|
|
1023
|
+
employee.queueTaskPrompt = prompt;
|
|
1024
|
+
employee.lastSeenAt = nowIso();
|
|
1025
|
+
upsertEmployee(employee);
|
|
1026
|
+
}
|
|
1027
|
+
function markTaskFailed(taskId, error) {
|
|
1028
|
+
const task = state.tasks.get(taskId);
|
|
1029
|
+
if (!task || TERMINAL_STATUSES.has(task.status)) {
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
clearTaskTimeout(taskId);
|
|
1033
|
+
task.status = "failed";
|
|
1034
|
+
task.error = error;
|
|
1035
|
+
task.summary = task.summary ?? error;
|
|
1036
|
+
task.finishedAt = nowIso();
|
|
1037
|
+
log.warn({ taskId, employeeId: task.employeeId, error }, "Task failed");
|
|
1038
|
+
upsertTask(task);
|
|
1039
|
+
postTaskWebhook(task, "task.failed");
|
|
1040
|
+
if (task.employeeId) {
|
|
1041
|
+
const employeeId = task.employeeId;
|
|
1042
|
+
const isMainSlot = task.targetMode !== "queue";
|
|
1043
|
+
if (isMainSlot) {
|
|
1044
|
+
setMainTask(employeeId, null, null);
|
|
1045
|
+
dispatchNextMainQueuedTask(employeeId);
|
|
1046
|
+
} else {
|
|
1047
|
+
setQueueTask(employeeId, null, null);
|
|
1048
|
+
dispatchNextQueuedTask(employeeId);
|
|
1049
|
+
}
|
|
1050
|
+
} else {
|
|
1051
|
+
removeFromSharedQueue(task.id);
|
|
1052
|
+
dispatchSharedQueuedTasks();
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
function appendTaskLog(chunk) {
|
|
1056
|
+
const task = state.tasks.get(chunk.taskId);
|
|
1057
|
+
if (!task || TERMINAL_STATUSES.has(task.status)) {
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
const doneSessionId = extractDoneSessionId(chunk.content);
|
|
1061
|
+
if (!task.sessionId && doneSessionId) {
|
|
1062
|
+
task.sessionId = doneSessionId;
|
|
1063
|
+
upsertTask(task);
|
|
1064
|
+
}
|
|
1065
|
+
const history = state.taskLogs.get(chunk.taskId) ?? [];
|
|
1066
|
+
history.push(chunk);
|
|
1067
|
+
if (history.length > ctx.maxLogChunksPerTask) {
|
|
1068
|
+
history.splice(0, history.length - ctx.maxLogChunksPerTask);
|
|
1069
|
+
}
|
|
1070
|
+
state.taskLogs.set(chunk.taskId, history);
|
|
1071
|
+
persistTaskLog(db, chunk, ctx.maxLogChunksPerTask).catch((error) => log.error({ error, taskId: chunk.taskId, seq: chunk.seq }, "Failed to persist task log"));
|
|
1072
|
+
broadcastToLeaders({ type: "task.output", chunk });
|
|
1073
|
+
postTaskWebhook(task, "task.output", { chunk });
|
|
1074
|
+
}
|
|
1075
|
+
function markTaskTimeout(task) {
|
|
1076
|
+
const current = state.tasks.get(task.id);
|
|
1077
|
+
if (!current || TERMINAL_STATUSES.has(current.status)) {
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
const socket = task.employeeId ? state.agentSockets.get(task.employeeId) : void 0;
|
|
1081
|
+
if (socket) {
|
|
1082
|
+
sendJson(socket, { type: "task.cancel", taskId: task.id }, ctx.encryptor);
|
|
1083
|
+
}
|
|
1084
|
+
current.status = "timeout";
|
|
1085
|
+
current.finishedAt = nowIso();
|
|
1086
|
+
current.error = `\u4EFB\u52A1\u8D85\u8FC7 ${task.timeoutSec} \u79D2\u672A\u5B8C\u6210\uFF0C\u5DF2\u8D85\u65F6\u3002`;
|
|
1087
|
+
log.warn({ taskId: task.id, employeeId: task.employeeId, timeoutSec: task.timeoutSec }, "Task timed out");
|
|
1088
|
+
current.summary = current.error;
|
|
1089
|
+
upsertTask(current);
|
|
1090
|
+
postTaskWebhook(current, "task.timeout");
|
|
1091
|
+
if (task.employeeId) {
|
|
1092
|
+
const isMainSlot = task.targetMode !== "queue";
|
|
1093
|
+
if (isMainSlot) {
|
|
1094
|
+
setMainTask(task.employeeId, null, null);
|
|
1095
|
+
} else {
|
|
1096
|
+
setQueueTask(task.employeeId, null, null);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
clearTaskTimeout(task.id);
|
|
1100
|
+
if (task.employeeId) {
|
|
1101
|
+
const isMainSlot = task.targetMode !== "queue";
|
|
1102
|
+
if (isMainSlot) {
|
|
1103
|
+
dispatchNextMainQueuedTask(task.employeeId);
|
|
1104
|
+
} else {
|
|
1105
|
+
dispatchNextQueuedTask(task.employeeId);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
function enqueueSharedTask(taskId) {
|
|
1110
|
+
if (state.sharedTaskQueue.includes(taskId)) return;
|
|
1111
|
+
const task = state.tasks.get(taskId);
|
|
1112
|
+
if (!task) {
|
|
1113
|
+
state.sharedTaskQueue.push(taskId);
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
let insertAt = state.sharedTaskQueue.length;
|
|
1117
|
+
for (let i = 0; i < state.sharedTaskQueue.length; i++) {
|
|
1118
|
+
const existing = state.tasks.get(state.sharedTaskQueue[i]);
|
|
1119
|
+
if (existing && task.priority > existing.priority) {
|
|
1120
|
+
insertAt = i;
|
|
1121
|
+
break;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
state.sharedTaskQueue.splice(insertAt, 0, taskId);
|
|
1125
|
+
}
|
|
1126
|
+
function removeFromSharedQueue(taskId) {
|
|
1127
|
+
const index = state.sharedTaskQueue.indexOf(taskId);
|
|
1128
|
+
if (index !== -1) {
|
|
1129
|
+
state.sharedTaskQueue.splice(index, 1);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
function pickAvailableEmployeeIdForQueue(requiredLabels) {
|
|
1133
|
+
let available = [...state.employees.values()].filter((employee2) => {
|
|
1134
|
+
const socket = state.agentSockets.get(employee2.id);
|
|
1135
|
+
if (employee2.status !== "online" || employee2.queueTaskId || socket?.readyState !== WebSocket.OPEN) {
|
|
1136
|
+
return false;
|
|
1137
|
+
}
|
|
1138
|
+
if (requiredLabels && requiredLabels.length > 0) {
|
|
1139
|
+
return requiredLabels.every((label) => employee2.labels.includes(label));
|
|
1140
|
+
}
|
|
1141
|
+
return true;
|
|
1142
|
+
});
|
|
1143
|
+
if (available.length === 0) {
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
available.sort((a, b) => {
|
|
1147
|
+
const loadA = (a.mainTaskId ? 1 : 0) + (a.queueTaskId ? 1 : 0);
|
|
1148
|
+
const loadB = (b.mainTaskId ? 1 : 0) + (b.queueTaskId ? 1 : 0);
|
|
1149
|
+
if (loadA !== loadB) return loadA - loadB;
|
|
1150
|
+
return a.id.localeCompare(b.id);
|
|
1151
|
+
});
|
|
1152
|
+
const minLoad = (available[0].mainTaskId ? 1 : 0) + (available[0].queueTaskId ? 1 : 0);
|
|
1153
|
+
const lightest = available.filter((e) => (e.mainTaskId ? 1 : 0) + (e.queueTaskId ? 1 : 0) === minLoad);
|
|
1154
|
+
const employee = lightest[state.sharedQueueCursor % lightest.length];
|
|
1155
|
+
state.sharedQueueCursor = (state.sharedQueueCursor + 1) % Math.max(lightest.length, 1);
|
|
1156
|
+
void persistSharedQueueCursor(db, state.sharedQueueCursor);
|
|
1157
|
+
return employee.id;
|
|
1158
|
+
}
|
|
1159
|
+
function dispatchSharedQueuedTask(preferredEmployeeId) {
|
|
1160
|
+
if (state.sharedTaskQueue.length === 0) {
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
let firstTask = null;
|
|
1164
|
+
for (const tid of state.sharedTaskQueue) {
|
|
1165
|
+
const t = state.tasks.get(tid);
|
|
1166
|
+
if (t && t.status === "queued") {
|
|
1167
|
+
firstTask = t;
|
|
1168
|
+
break;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
const requiredLabels = firstTask?.requiredLabels;
|
|
1172
|
+
const employeeId = preferredEmployeeId ?? pickAvailableEmployeeIdForQueue(requiredLabels);
|
|
1173
|
+
if (!employeeId) {
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
const employee = state.employees.get(employeeId);
|
|
1177
|
+
if (!employee || employee.queueTaskId || employee.status !== "online") {
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
while (state.sharedTaskQueue.length > 0) {
|
|
1181
|
+
const taskId = state.sharedTaskQueue.shift();
|
|
1182
|
+
const task = state.tasks.get(taskId);
|
|
1183
|
+
if (task && task.status === "queued" && task.targetMode === "queue") {
|
|
1184
|
+
dispatchTask(task, employeeId);
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
function dispatchSharedQueuedTasks() {
|
|
1190
|
+
for (let index = 0; index < state.employees.size && state.sharedTaskQueue.length > 0; index += 1) {
|
|
1191
|
+
const employeeId = pickAvailableEmployeeIdForQueue();
|
|
1192
|
+
if (!employeeId) {
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
dispatchSharedQueuedTask(employeeId);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
function dispatchTask(task, assignedEmployeeId = task.employeeId) {
|
|
1199
|
+
const isQueueSlot = task.targetMode === "queue";
|
|
1200
|
+
if (!assignedEmployeeId) {
|
|
1201
|
+
if (isQueueSlot) {
|
|
1202
|
+
enqueueSharedTask(task.id);
|
|
1203
|
+
dispatchSharedQueuedTasks();
|
|
1204
|
+
} else {
|
|
1205
|
+
markTaskFailed(task.id, "\u975E\u961F\u5217\u4EFB\u52A1\u5FC5\u987B\u6307\u5B9A\u76EE\u6807\u5458\u5DE5\u3002");
|
|
1206
|
+
}
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
const employee = state.employees.get(assignedEmployeeId);
|
|
1210
|
+
const socket = state.agentSockets.get(assignedEmployeeId);
|
|
1211
|
+
if (!employee || employee.status !== "online" || !socket) {
|
|
1212
|
+
markTaskFailed(task.id, "\u76EE\u6807\u5458\u5DE5\u5F53\u524D\u79BB\u7EBF\uFF0C\u4EFB\u52A1\u672A\u80FD\u5206\u53D1\u3002");
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
const currentSlotTaskId = isQueueSlot ? employee.queueTaskId : employee.mainTaskId;
|
|
1216
|
+
if (currentSlotTaskId && currentSlotTaskId !== task.id) {
|
|
1217
|
+
const queueMap = isQueueSlot ? state.taskQueues : state.mainTaskQueues;
|
|
1218
|
+
const queue = queueMap.get(assignedEmployeeId) ?? [];
|
|
1219
|
+
queue.push(task.id);
|
|
1220
|
+
queueMap.set(assignedEmployeeId, queue);
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
task.employeeId = assignedEmployeeId;
|
|
1224
|
+
task.status = "dispatched";
|
|
1225
|
+
log.info({ taskId: task.id, employeeId: assignedEmployeeId, targetMode: task.targetMode, prompt: task.prompt.slice(0, 80) }, "Task dispatched");
|
|
1226
|
+
upsertTask(task);
|
|
1227
|
+
if (isQueueSlot) {
|
|
1228
|
+
setQueueTask(assignedEmployeeId, task.id, task.prompt);
|
|
1229
|
+
} else {
|
|
1230
|
+
setMainTask(assignedEmployeeId, task.id, task.prompt);
|
|
1231
|
+
}
|
|
1232
|
+
clearTaskTimeout(task.id);
|
|
1233
|
+
state.taskTimeouts.set(task.id, setTimeout(() => markTaskTimeout(task), task.timeoutSec * 1e3));
|
|
1234
|
+
sendJson(socket, {
|
|
1235
|
+
type: "task.dispatch",
|
|
1236
|
+
taskId: task.id,
|
|
1237
|
+
leaderCommandId: task.leaderCommandId,
|
|
1238
|
+
employeeId: assignedEmployeeId,
|
|
1239
|
+
targetMode: task.targetMode,
|
|
1240
|
+
prompt: task.prompt,
|
|
1241
|
+
workspace: task.workspace,
|
|
1242
|
+
timeoutSec: task.timeoutSec,
|
|
1243
|
+
cliConfig: task.cliConfig
|
|
1244
|
+
}, ctx.encryptor);
|
|
1245
|
+
}
|
|
1246
|
+
function dispatchNextQueuedTask(employeeId) {
|
|
1247
|
+
const employee = state.employees.get(employeeId);
|
|
1248
|
+
if (!employee || employee.queueTaskId || employee.status !== "online") {
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
const queue = state.taskQueues.get(employeeId);
|
|
1252
|
+
if (!queue || queue.length === 0) {
|
|
1253
|
+
dispatchSharedQueuedTasks();
|
|
1254
|
+
return;
|
|
1255
|
+
}
|
|
1256
|
+
while (queue.length > 0) {
|
|
1257
|
+
const taskId = queue.shift();
|
|
1258
|
+
if (queue.length === 0) {
|
|
1259
|
+
state.taskQueues.delete(employeeId);
|
|
1260
|
+
}
|
|
1261
|
+
const task = state.tasks.get(taskId);
|
|
1262
|
+
if (task && task.status === "queued") {
|
|
1263
|
+
dispatchTask(task, employeeId);
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
dispatchSharedQueuedTasks();
|
|
1268
|
+
}
|
|
1269
|
+
function dispatchNextMainQueuedTask(employeeId) {
|
|
1270
|
+
const employee = state.employees.get(employeeId);
|
|
1271
|
+
if (!employee || employee.mainTaskId || employee.status !== "online") {
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
const queue = state.mainTaskQueues.get(employeeId);
|
|
1275
|
+
if (!queue || queue.length === 0) {
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1278
|
+
while (queue.length > 0) {
|
|
1279
|
+
const taskId = queue.shift();
|
|
1280
|
+
if (queue.length === 0) {
|
|
1281
|
+
state.mainTaskQueues.delete(employeeId);
|
|
1282
|
+
}
|
|
1283
|
+
const task = state.tasks.get(taskId);
|
|
1284
|
+
if (task && task.status === "queued") {
|
|
1285
|
+
dispatchTask(task, employeeId);
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
function createTask(employeeId, prompt, workspace, timeoutSec, leaderCommandId, targetMode, webhookUrl, cliConfig, priority, requiredLabels) {
|
|
1291
|
+
const task = {
|
|
1292
|
+
id: randomUUID(),
|
|
1293
|
+
leaderCommandId: leaderCommandId ?? randomUUID(),
|
|
1294
|
+
employeeId,
|
|
1295
|
+
sessionId: null,
|
|
1296
|
+
targetMode,
|
|
1297
|
+
prompt,
|
|
1298
|
+
workspace: workspace?.trim() || null,
|
|
1299
|
+
timeoutSec: timeoutSec ?? ctx.defaultTimeoutSec,
|
|
1300
|
+
cliConfig: cliConfig && typeof cliConfig === "object" && !Array.isArray(cliConfig) ? cliConfig : null,
|
|
1301
|
+
priority: priority ?? 1,
|
|
1302
|
+
requiredLabels: requiredLabels ?? null,
|
|
1303
|
+
status: "queued",
|
|
1304
|
+
createdAt: nowIso(),
|
|
1305
|
+
startedAt: null,
|
|
1306
|
+
finishedAt: null,
|
|
1307
|
+
exitCode: null,
|
|
1308
|
+
summary: null,
|
|
1309
|
+
error: null,
|
|
1310
|
+
durationMs: null,
|
|
1311
|
+
durationApiMs: null,
|
|
1312
|
+
numTurns: null,
|
|
1313
|
+
totalCostUsd: null,
|
|
1314
|
+
usageInputTokens: null,
|
|
1315
|
+
usageOutputTokens: null,
|
|
1316
|
+
usageCacheReadTokens: null,
|
|
1317
|
+
usageCacheCreationTokens: null
|
|
1318
|
+
};
|
|
1319
|
+
if (webhookUrl) {
|
|
1320
|
+
state.taskWebhooks.set(task.id, webhookUrl);
|
|
1321
|
+
persistTaskWebhook(db, task.id, webhookUrl).catch((error) => log.error({ error, taskId: task.id }, "Failed to persist task webhook"));
|
|
1322
|
+
}
|
|
1323
|
+
upsertTask(task);
|
|
1324
|
+
log.info({ taskId: task.id, targetMode, employeeId, prompt: prompt.slice(0, 80) }, "Task created");
|
|
1325
|
+
if (targetMode === "queue") {
|
|
1326
|
+
enqueueSharedTask(task.id);
|
|
1327
|
+
dispatchSharedQueuedTasks();
|
|
1328
|
+
} else {
|
|
1329
|
+
dispatchTask(task);
|
|
1330
|
+
}
|
|
1331
|
+
return task;
|
|
1332
|
+
}
|
|
1333
|
+
function resolveTargetIds(target) {
|
|
1334
|
+
if (target === "queue") {
|
|
1335
|
+
return [];
|
|
1336
|
+
}
|
|
1337
|
+
if (target === "all") {
|
|
1338
|
+
return [...state.employees.values()].map((employee) => employee.id);
|
|
1339
|
+
}
|
|
1340
|
+
return [...new Set(target)];
|
|
1341
|
+
}
|
|
1342
|
+
function dispatchLeaderCommand(message, webhookUrl, cliConfig, priority, requiredLabels) {
|
|
1343
|
+
const leaderCommandId = randomUUID();
|
|
1344
|
+
if (message.atAgents === "queue") {
|
|
1345
|
+
return {
|
|
1346
|
+
ok: true,
|
|
1347
|
+
leaderCommandId,
|
|
1348
|
+
tasks: [createTask(null, message.prompt, message.workspace, message.timeoutSec, leaderCommandId, "queue", webhookUrl, cliConfig, message.priority, message.requiredLabels)]
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
const targetIds = resolveTargetIds(message.atAgents);
|
|
1352
|
+
if (targetIds.length === 0) {
|
|
1353
|
+
return {
|
|
1354
|
+
ok: false,
|
|
1355
|
+
code: "no_target_agents",
|
|
1356
|
+
message: "\u6CA1\u6709\u53EF\u7528\u7684\u76EE\u6807\u5458\u5DE5\u3002"
|
|
1357
|
+
};
|
|
1358
|
+
}
|
|
1359
|
+
const targetMode = message.atAgents === "all" ? "broadcast" : "direct";
|
|
1360
|
+
return {
|
|
1361
|
+
ok: true,
|
|
1362
|
+
leaderCommandId,
|
|
1363
|
+
tasks: targetIds.map(
|
|
1364
|
+
(employeeId) => createTask(employeeId, message.prompt, message.workspace, message.timeoutSec, leaderCommandId, targetMode, webhookUrl, cliConfig, message.priority, message.requiredLabels)
|
|
1365
|
+
)
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1368
|
+
function handleRegister(message, socket) {
|
|
1369
|
+
const previous = state.employees.get(message.employeeId);
|
|
1370
|
+
const previousMainTaskId = previous?.mainTaskId ?? null;
|
|
1371
|
+
const previousQueueTaskId = previous?.queueTaskId ?? null;
|
|
1372
|
+
const activeMainTaskId = message.activeMainTaskId ?? null;
|
|
1373
|
+
const activeQueueTaskId = message.activeQueueTaskId ?? null;
|
|
1374
|
+
const disconnectTimer = state.disconnectTimers.get(message.employeeId);
|
|
1375
|
+
if (disconnectTimer) {
|
|
1376
|
+
clearTimeout(disconnectTimer);
|
|
1377
|
+
state.disconnectTimers.delete(message.employeeId);
|
|
1378
|
+
}
|
|
1379
|
+
state.agentSockets.set(message.employeeId, socket);
|
|
1380
|
+
state.socketToEmployeeId.set(socket, message.employeeId);
|
|
1381
|
+
let mainTaskId = null;
|
|
1382
|
+
let mainTaskPrompt = null;
|
|
1383
|
+
let queueTaskId = null;
|
|
1384
|
+
let queueTaskPrompt = null;
|
|
1385
|
+
if (previousMainTaskId && activeMainTaskId !== previousMainTaskId) {
|
|
1386
|
+
markTaskFailed(previousMainTaskId, "\u5458\u5DE5\u91CD\u8FDE\u65F6\u672A\u6062\u590D\u539F\u8FD0\u884C\u7684\u4E3B\u4EFB\u52A1\u3002");
|
|
1387
|
+
} else if (activeMainTaskId) {
|
|
1388
|
+
const activeTask = state.tasks.get(activeMainTaskId);
|
|
1389
|
+
if (activeTask && activeTask.employeeId === message.employeeId && !TERMINAL_STATUSES.has(activeTask.status)) {
|
|
1390
|
+
mainTaskId = activeTask.id;
|
|
1391
|
+
mainTaskPrompt = activeTask.prompt;
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
if (previousQueueTaskId && activeQueueTaskId !== previousQueueTaskId) {
|
|
1395
|
+
markTaskFailed(previousQueueTaskId, "\u5458\u5DE5\u91CD\u8FDE\u65F6\u672A\u6062\u590D\u539F\u8FD0\u884C\u7684\u961F\u5217\u4EFB\u52A1\u3002");
|
|
1396
|
+
} else if (activeQueueTaskId) {
|
|
1397
|
+
const activeTask = state.tasks.get(activeQueueTaskId);
|
|
1398
|
+
if (activeTask && activeTask.employeeId === message.employeeId && !TERMINAL_STATUSES.has(activeTask.status)) {
|
|
1399
|
+
queueTaskId = activeTask.id;
|
|
1400
|
+
queueTaskPrompt = activeTask.prompt;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
upsertEmployee({
|
|
1404
|
+
id: message.employeeId,
|
|
1405
|
+
name: message.name,
|
|
1406
|
+
machineId: message.machineId,
|
|
1407
|
+
hostname: message.hostname,
|
|
1408
|
+
labels: message.labels,
|
|
1409
|
+
status: "online",
|
|
1410
|
+
mainTaskId,
|
|
1411
|
+
mainTaskPrompt,
|
|
1412
|
+
queueTaskId,
|
|
1413
|
+
queueTaskPrompt,
|
|
1414
|
+
lastSeenAt: nowIso()
|
|
1415
|
+
});
|
|
1416
|
+
if (!mainTaskId) {
|
|
1417
|
+
dispatchNextMainQueuedTask(message.employeeId);
|
|
1418
|
+
}
|
|
1419
|
+
if (!queueTaskId) {
|
|
1420
|
+
dispatchNextQueuedTask(message.employeeId);
|
|
1421
|
+
}
|
|
1422
|
+
resetHeartbeatTimer(message.employeeId);
|
|
1423
|
+
}
|
|
1424
|
+
function taskBelongsToSocket(taskId, employeeId, socket) {
|
|
1425
|
+
const socketEmployeeId = state.socketToEmployeeId.get(socket);
|
|
1426
|
+
if (!socketEmployeeId || employeeId && employeeId !== socketEmployeeId) {
|
|
1427
|
+
return false;
|
|
1428
|
+
}
|
|
1429
|
+
const task = state.tasks.get(taskId);
|
|
1430
|
+
return Boolean(task && task.employeeId === socketEmployeeId);
|
|
1431
|
+
}
|
|
1432
|
+
function handleAgentMessage(message, socket) {
|
|
1433
|
+
if (message.type === "agent.register") {
|
|
1434
|
+
handleRegister(message, socket);
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
const socketEmployeeId = state.socketToEmployeeId.get(socket);
|
|
1438
|
+
if (!socketEmployeeId) {
|
|
1439
|
+
sendJson(socket, {
|
|
1440
|
+
type: "server.error",
|
|
1441
|
+
code: "agent_not_registered",
|
|
1442
|
+
message: "Agent must register before sending task events."
|
|
1443
|
+
}, ctx.encryptor);
|
|
1444
|
+
socket.close(1008, "agent_not_registered");
|
|
1445
|
+
return;
|
|
1446
|
+
}
|
|
1447
|
+
if (message.type === "agent.heartbeat") {
|
|
1448
|
+
if (message.employeeId !== socketEmployeeId) {
|
|
1449
|
+
socket.close(1008, "employee_mismatch");
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
const employee = state.employees.get(message.employeeId);
|
|
1453
|
+
if (employee) {
|
|
1454
|
+
employee.lastSeenAt = nowIso();
|
|
1455
|
+
employee.status = "online";
|
|
1456
|
+
upsertEmployee(employee);
|
|
1457
|
+
resetHeartbeatTimer(message.employeeId);
|
|
1458
|
+
}
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
if (message.type === "agent.request_task") {
|
|
1462
|
+
if (message.employeeId !== socketEmployeeId) {
|
|
1463
|
+
socket.close(1008, "employee_mismatch");
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
dispatchNextQueuedTask(message.employeeId);
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
if (!taskBelongsToSocket(message.taskId, socketEmployeeId, socket)) {
|
|
1470
|
+
socket.close(1008, "task_owner_mismatch");
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
const task = state.tasks.get(message.taskId);
|
|
1474
|
+
if (!task || TERMINAL_STATUSES.has(task.status)) {
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
switch (message.type) {
|
|
1478
|
+
case "task.accepted":
|
|
1479
|
+
task.status = "accepted";
|
|
1480
|
+
upsertTask(task);
|
|
1481
|
+
log.info({ taskId: task.id, employeeId: socketEmployeeId }, "Task accepted");
|
|
1482
|
+
break;
|
|
1483
|
+
case "task.started":
|
|
1484
|
+
task.status = "running";
|
|
1485
|
+
task.sessionId = message.sessionId ?? task.sessionId;
|
|
1486
|
+
task.startedAt = task.startedAt ?? nowIso();
|
|
1487
|
+
upsertTask(task);
|
|
1488
|
+
postTaskWebhook(task, "task.started");
|
|
1489
|
+
log.info({ taskId: task.id, employeeId: socketEmployeeId, sessionId: task.sessionId, pid: message.pid }, "Task started");
|
|
1490
|
+
break;
|
|
1491
|
+
case "task.output":
|
|
1492
|
+
if (!task.employeeId) {
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
appendTaskLog({
|
|
1496
|
+
taskId: message.taskId,
|
|
1497
|
+
employeeId: task.employeeId,
|
|
1498
|
+
stream: message.stream,
|
|
1499
|
+
seq: message.seq,
|
|
1500
|
+
content: message.content,
|
|
1501
|
+
createdAt: nowIso()
|
|
1502
|
+
});
|
|
1503
|
+
break;
|
|
1504
|
+
case "task.completed":
|
|
1505
|
+
task.status = "completed";
|
|
1506
|
+
clearTaskTimeout(task.id);
|
|
1507
|
+
task.finishedAt = nowIso();
|
|
1508
|
+
task.exitCode = message.exitCode;
|
|
1509
|
+
task.summary = message.summary ?? "\u4EFB\u52A1\u6267\u884C\u5B8C\u6210\u3002";
|
|
1510
|
+
task.error = null;
|
|
1511
|
+
task.durationMs = message.durationMs ?? task.durationMs;
|
|
1512
|
+
task.durationApiMs = message.durationApiMs ?? task.durationApiMs;
|
|
1513
|
+
task.numTurns = message.numTurns ?? task.numTurns;
|
|
1514
|
+
task.totalCostUsd = message.totalCostUsd ?? task.totalCostUsd;
|
|
1515
|
+
task.usageInputTokens = message.usageInputTokens ?? task.usageInputTokens;
|
|
1516
|
+
task.usageOutputTokens = message.usageOutputTokens ?? task.usageOutputTokens;
|
|
1517
|
+
task.usageCacheReadTokens = message.usageCacheReadTokens ?? task.usageCacheReadTokens;
|
|
1518
|
+
task.usageCacheCreationTokens = message.usageCacheCreationTokens ?? task.usageCacheCreationTokens;
|
|
1519
|
+
upsertTask(task);
|
|
1520
|
+
postTaskWebhook(task, "task.completed");
|
|
1521
|
+
log.info({ taskId: task.id, employeeId: task.employeeId, exitCode: task.exitCode, durationMs: task.durationMs, numTurns: task.numTurns, totalCostUsd: task.totalCostUsd }, "Task completed");
|
|
1522
|
+
if (task.employeeId) {
|
|
1523
|
+
const isMainSlot = task.targetMode !== "queue";
|
|
1524
|
+
if (isMainSlot) {
|
|
1525
|
+
setMainTask(task.employeeId, null, null);
|
|
1526
|
+
dispatchNextMainQueuedTask(task.employeeId);
|
|
1527
|
+
} else {
|
|
1528
|
+
setQueueTask(task.employeeId, null, null);
|
|
1529
|
+
dispatchNextQueuedTask(task.employeeId);
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
break;
|
|
1533
|
+
case "task.failed":
|
|
1534
|
+
markTaskFailed(message.taskId, message.error);
|
|
1535
|
+
break;
|
|
1536
|
+
case "task.cancelled":
|
|
1537
|
+
task.status = "cancelled";
|
|
1538
|
+
clearTaskTimeout(task.id);
|
|
1539
|
+
task.finishedAt = nowIso();
|
|
1540
|
+
task.summary = "\u4EFB\u52A1\u5DF2\u53D6\u6D88\u3002";
|
|
1541
|
+
log.info({ taskId: task.id, employeeId: task.employeeId }, "Task cancelled");
|
|
1542
|
+
upsertTask(task);
|
|
1543
|
+
postTaskWebhook(task, "task.cancelled");
|
|
1544
|
+
if (task.employeeId) {
|
|
1545
|
+
const isMainSlot = task.targetMode !== "queue";
|
|
1546
|
+
if (isMainSlot) {
|
|
1547
|
+
setMainTask(task.employeeId, null, null);
|
|
1548
|
+
dispatchNextMainQueuedTask(task.employeeId);
|
|
1549
|
+
} else {
|
|
1550
|
+
setQueueTask(task.employeeId, null, null);
|
|
1551
|
+
dispatchNextQueuedTask(task.employeeId);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
break;
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
function cancelTaskById(taskId) {
|
|
1558
|
+
const task = state.tasks.get(taskId);
|
|
1559
|
+
if (!task) {
|
|
1560
|
+
return { ok: false, code: "not_found", message: "\u4EFB\u52A1\u4E0D\u5B58\u5728\u3002" };
|
|
1561
|
+
}
|
|
1562
|
+
if (TERMINAL_STATUSES.has(task.status)) {
|
|
1563
|
+
return { ok: false, code: "already_terminal", message: `\u4EFB\u52A1\u5DF2\u5904\u4E8E\u7EC8\u6001 ${task.status}\uFF0C\u65E0\u6CD5\u53D6\u6D88\u3002` };
|
|
1564
|
+
}
|
|
1565
|
+
if (task.status === "queued") {
|
|
1566
|
+
if (task.employeeId) {
|
|
1567
|
+
const isMainSlot = task.targetMode !== "queue";
|
|
1568
|
+
const queueMap = isMainSlot ? state.mainTaskQueues : state.taskQueues;
|
|
1569
|
+
const queue = queueMap.get(task.employeeId);
|
|
1570
|
+
if (queue) {
|
|
1571
|
+
const idx = queue.indexOf(task.id);
|
|
1572
|
+
if (idx !== -1) {
|
|
1573
|
+
queue.splice(idx, 1);
|
|
1574
|
+
}
|
|
1575
|
+
if (queue.length === 0) {
|
|
1576
|
+
queueMap.delete(task.employeeId);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
} else {
|
|
1580
|
+
removeFromSharedQueue(task.id);
|
|
1581
|
+
}
|
|
1582
|
+
task.status = "cancelled";
|
|
1583
|
+
task.finishedAt = nowIso();
|
|
1584
|
+
task.summary = "\u4EFB\u52A1\u5DF2\u53D6\u6D88\u3002";
|
|
1585
|
+
upsertTask(task);
|
|
1586
|
+
postTaskWebhook(task, "task.cancelled");
|
|
1587
|
+
return { ok: true, task };
|
|
1588
|
+
}
|
|
1589
|
+
if (!task.employeeId) {
|
|
1590
|
+
markTaskFailed(task.id, "\u4EFB\u52A1\u5C1A\u672A\u5206\u914D\u7ED9\u5458\u5DE5\uFF0C\u65E0\u6CD5\u53D6\u6D88\u8FD0\u884C\u4E2D\u7684\u8FDB\u7A0B\u3002");
|
|
1591
|
+
return { ok: true, task: state.tasks.get(task.id) };
|
|
1592
|
+
}
|
|
1593
|
+
const agentSocket = state.agentSockets.get(task.employeeId);
|
|
1594
|
+
if (!agentSocket) {
|
|
1595
|
+
markTaskFailed(task.id, "\u5458\u5DE5\u5DF2\u79BB\u7EBF\uFF0C\u65E0\u6CD5\u53D6\u6D88\u8FD0\u884C\u4E2D\u7684\u8FDB\u7A0B\u3002");
|
|
1596
|
+
return { ok: true, task: state.tasks.get(task.id) };
|
|
1597
|
+
}
|
|
1598
|
+
sendJson(agentSocket, { type: "task.cancel", taskId: task.id }, ctx.encryptor);
|
|
1599
|
+
return { ok: true, task };
|
|
1600
|
+
}
|
|
1601
|
+
function handleLeaderMessage(message, socket) {
|
|
1602
|
+
switch (message.type) {
|
|
1603
|
+
case "command.dispatch": {
|
|
1604
|
+
const result = dispatchLeaderCommand(message);
|
|
1605
|
+
if (!result.ok) {
|
|
1606
|
+
sendJson(socket, {
|
|
1607
|
+
type: "command.error",
|
|
1608
|
+
code: result.code,
|
|
1609
|
+
message: result.message
|
|
1610
|
+
}, ctx.encryptor);
|
|
1611
|
+
}
|
|
1612
|
+
break;
|
|
1613
|
+
}
|
|
1614
|
+
case "command.send":
|
|
1615
|
+
createTask(message.employeeId, message.prompt, message.workspace, message.timeoutSec, void 0, "direct");
|
|
1616
|
+
break;
|
|
1617
|
+
case "command.broadcast":
|
|
1618
|
+
handleLeaderMessage(
|
|
1619
|
+
{
|
|
1620
|
+
type: "command.dispatch",
|
|
1621
|
+
atAgents: "all",
|
|
1622
|
+
prompt: message.prompt,
|
|
1623
|
+
workspace: message.workspace,
|
|
1624
|
+
timeoutSec: message.timeoutSec
|
|
1625
|
+
},
|
|
1626
|
+
socket
|
|
1627
|
+
);
|
|
1628
|
+
break;
|
|
1629
|
+
case "task.cancel":
|
|
1630
|
+
cancelTaskById(message.taskId);
|
|
1631
|
+
break;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
return {
|
|
1635
|
+
dispatchLeaderCommand,
|
|
1636
|
+
handleAgentMessage,
|
|
1637
|
+
handleLeaderMessage,
|
|
1638
|
+
cancelTaskById
|
|
1639
|
+
};
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// src/state-store.ts
|
|
1643
|
+
function createInMemoryStateStore() {
|
|
1644
|
+
return {
|
|
1645
|
+
agentSockets: /* @__PURE__ */ new Map(),
|
|
1646
|
+
leaderSockets: /* @__PURE__ */ new Set(),
|
|
1647
|
+
employees: /* @__PURE__ */ new Map(),
|
|
1648
|
+
tasks: /* @__PURE__ */ new Map(),
|
|
1649
|
+
taskLogs: /* @__PURE__ */ new Map(),
|
|
1650
|
+
taskWebhooks: /* @__PURE__ */ new Map(),
|
|
1651
|
+
socketToEmployeeId: /* @__PURE__ */ new WeakMap(),
|
|
1652
|
+
taskTimeouts: /* @__PURE__ */ new Map(),
|
|
1653
|
+
disconnectTimers: /* @__PURE__ */ new Map(),
|
|
1654
|
+
heartbeatTimers: /* @__PURE__ */ new Map(),
|
|
1655
|
+
taskQueues: /* @__PURE__ */ new Map(),
|
|
1656
|
+
mainTaskQueues: /* @__PURE__ */ new Map(),
|
|
1657
|
+
sharedTaskQueue: [],
|
|
1658
|
+
sharedQueueCursor: 0
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
// src/crypto.ts
|
|
1663
|
+
import crypto from "node:crypto";
|
|
1664
|
+
var ALGORITHM = "aes-256-gcm";
|
|
1665
|
+
var IV_LENGTH = 12;
|
|
1666
|
+
var TAG_LENGTH = 16;
|
|
1667
|
+
function createEncryptor(encryptionKeyHex) {
|
|
1668
|
+
if (!encryptionKeyHex) {
|
|
1669
|
+
return {
|
|
1670
|
+
encrypt(plainText) {
|
|
1671
|
+
return plainText;
|
|
1672
|
+
},
|
|
1673
|
+
decrypt(raw) {
|
|
1674
|
+
return raw;
|
|
1675
|
+
}
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
const key = parseEncryptionKey(encryptionKeyHex);
|
|
1679
|
+
return {
|
|
1680
|
+
encrypt(plainText) {
|
|
1681
|
+
const iv = crypto.randomBytes(IV_LENGTH);
|
|
1682
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
|
|
1683
|
+
const encrypted = Buffer.concat([cipher.update(plainText, "utf8"), cipher.final()]);
|
|
1684
|
+
const tag = cipher.getAuthTag();
|
|
1685
|
+
const envelope = {
|
|
1686
|
+
encrypted: true,
|
|
1687
|
+
iv: iv.toString("base64"),
|
|
1688
|
+
ciphertext: encrypted.toString("base64"),
|
|
1689
|
+
tag: tag.toString("base64")
|
|
1690
|
+
};
|
|
1691
|
+
return JSON.stringify(envelope);
|
|
1692
|
+
},
|
|
1693
|
+
decrypt(raw) {
|
|
1694
|
+
const parsed = JSON.parse(raw);
|
|
1695
|
+
if (!isEncryptedEnvelope(parsed)) {
|
|
1696
|
+
return raw;
|
|
1697
|
+
}
|
|
1698
|
+
const iv = Buffer.from(parsed.iv, "base64");
|
|
1699
|
+
const ciphertext = Buffer.from(parsed.ciphertext, "base64");
|
|
1700
|
+
const tag = Buffer.from(parsed.tag, "base64");
|
|
1701
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH });
|
|
1702
|
+
decipher.setAuthTag(tag);
|
|
1703
|
+
return decipher.update(ciphertext) + decipher.final("utf8");
|
|
1704
|
+
}
|
|
1705
|
+
};
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// src/index.ts
|
|
1709
|
+
var DEFAULT_PORT = 3789;
|
|
1710
|
+
function isAuthorized(authToken, rawUrl, headers) {
|
|
1711
|
+
const bearer = typeof headers.authorization === "string" ? headers.authorization : "";
|
|
1712
|
+
const tokenFromHeader = bearer.startsWith("Bearer ") ? bearer.slice("Bearer ".length) : "";
|
|
1713
|
+
const tokenFromQuery = new URL(rawUrl, "http://localhost").searchParams.get("token") ?? "";
|
|
1714
|
+
return tokenFromHeader === authToken || tokenFromQuery === authToken;
|
|
1715
|
+
}
|
|
1716
|
+
async function createAiTeamsServer(options) {
|
|
1717
|
+
if (!options.authToken) {
|
|
1718
|
+
throw new Error("AI_TEAMS_AUTH_TOKEN is required.");
|
|
1719
|
+
}
|
|
1720
|
+
const defaultTimeoutSec = options.defaultTimeoutSec ?? 1800;
|
|
1721
|
+
const disconnectGraceMs = options.disconnectGraceMs ?? 15e3;
|
|
1722
|
+
const maxLogChunksPerTask = options.maxLogChunksPerTask ?? 400;
|
|
1723
|
+
const dataDir = options.dataDir ?? path.join(process.cwd(), "data");
|
|
1724
|
+
const dbPath = options.dbPath ?? path.join(dataDir, "ai-teams.db");
|
|
1725
|
+
let closing = false;
|
|
1726
|
+
const state = createInMemoryStateStore();
|
|
1727
|
+
const db = await createDatabaseFromEnv({
|
|
1728
|
+
AI_TEAMS_AUTH_TOKEN: options.authToken,
|
|
1729
|
+
DATA_DIR: options.dataDir,
|
|
1730
|
+
DB_PATH: options.dbPath,
|
|
1731
|
+
DATABASE_URL: process.env.DATABASE_URL
|
|
1732
|
+
});
|
|
1733
|
+
await initDb(db);
|
|
1734
|
+
await hydrateState(db, state, defaultTimeoutSec, maxLogChunksPerTask);
|
|
1735
|
+
const logLevel = options.logLevel || process.env.LOG_LEVEL || "info";
|
|
1736
|
+
const logDir = options.logDir || process.env.LOG_DIR;
|
|
1737
|
+
let loggerConfig = { level: logLevel };
|
|
1738
|
+
if (logDir && options.logger !== false) {
|
|
1739
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
1740
|
+
loggerConfig = {
|
|
1741
|
+
level: logLevel,
|
|
1742
|
+
transport: {
|
|
1743
|
+
targets: [
|
|
1744
|
+
{ target: "pino/file", options: { destination: 1 }, level: logLevel },
|
|
1745
|
+
{ target: "pino/file", options: { destination: path.join(logDir, "server.log") }, level: logLevel }
|
|
1746
|
+
]
|
|
1747
|
+
}
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
const app = Fastify({ logger: options.logger === false ? false : loggerConfig });
|
|
1751
|
+
await app.register(websocket);
|
|
1752
|
+
await app.register(swagger, {
|
|
1753
|
+
openapi: {
|
|
1754
|
+
info: {
|
|
1755
|
+
title: "AI Teams Server API",
|
|
1756
|
+
description: "REST API for submitting AI Teams tasks and reading runtime state.",
|
|
1757
|
+
version: "0.1.0"
|
|
1758
|
+
},
|
|
1759
|
+
components: {
|
|
1760
|
+
securitySchemes: {
|
|
1761
|
+
bearerAuth: {
|
|
1762
|
+
type: "http",
|
|
1763
|
+
scheme: "bearer"
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
},
|
|
1767
|
+
security: [{ bearerAuth: [] }]
|
|
1768
|
+
}
|
|
1769
|
+
});
|
|
1770
|
+
await app.register(swaggerUi, {
|
|
1771
|
+
routePrefix: "/docs",
|
|
1772
|
+
uiConfig: {
|
|
1773
|
+
docExpansion: "list",
|
|
1774
|
+
deepLinking: false
|
|
1775
|
+
},
|
|
1776
|
+
staticCSP: true
|
|
1777
|
+
});
|
|
1778
|
+
const webDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "web");
|
|
1779
|
+
if (fs.existsSync(webDir)) {
|
|
1780
|
+
await app.register(fastifyStatic, { root: webDir, prefix: "/" });
|
|
1781
|
+
app.setNotFoundHandler((_, reply) => {
|
|
1782
|
+
reply.sendFile("index.html");
|
|
1783
|
+
});
|
|
1784
|
+
app.log.info({ webDir }, "Web UI enabled");
|
|
1785
|
+
}
|
|
1786
|
+
const dispatchCtx = {
|
|
1787
|
+
state,
|
|
1788
|
+
db,
|
|
1789
|
+
log: app.log,
|
|
1790
|
+
authToken: options.authToken,
|
|
1791
|
+
defaultTimeoutSec,
|
|
1792
|
+
maxLogChunksPerTask,
|
|
1793
|
+
disconnectGraceMs,
|
|
1794
|
+
encryptor: createEncryptor(process.env.AI_TEAMS_ENCRYPTION_KEY)
|
|
1795
|
+
};
|
|
1796
|
+
const { dispatchLeaderCommand, handleAgentMessage, handleLeaderMessage, cancelTaskById } = createDispatch(dispatchCtx);
|
|
1797
|
+
function buildSnapshot() {
|
|
1798
|
+
return {
|
|
1799
|
+
employees: [...state.employees.values()],
|
|
1800
|
+
tasks: [...state.tasks.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
|
|
1801
|
+
logs: Object.fromEntries(state.taskLogs.entries())
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
function buildSessionHistory(sessionId) {
|
|
1805
|
+
const extractDoneSessionId2 = (content) => {
|
|
1806
|
+
const match = content.match(/^\[done\]\s+session_id:\s*(\S+)/m);
|
|
1807
|
+
return match?.[1] ?? null;
|
|
1808
|
+
};
|
|
1809
|
+
const tasks = [...state.tasks.values()].filter((task) => task.sessionId === sessionId || (state.taskLogs.get(task.id) ?? []).some((chunk) => extractDoneSessionId2(chunk.content) === sessionId)).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
1810
|
+
const messages = tasks.flatMap((task) => {
|
|
1811
|
+
const taskLogs = [...state.taskLogs.get(task.id) ?? []].sort((a, b) => a.seq - b.seq);
|
|
1812
|
+
const outputMessages = taskLogs.map((chunk) => ({
|
|
1813
|
+
type: "task.output",
|
|
1814
|
+
role: "assistant",
|
|
1815
|
+
taskId: task.id,
|
|
1816
|
+
stream: chunk.stream,
|
|
1817
|
+
seq: chunk.seq,
|
|
1818
|
+
content: chunk.content,
|
|
1819
|
+
createdAt: chunk.createdAt
|
|
1820
|
+
}));
|
|
1821
|
+
const resultMessage = task.finishedAt && (task.summary || task.error) ? [
|
|
1822
|
+
{
|
|
1823
|
+
type: "task.result",
|
|
1824
|
+
role: task.status === "failed" || task.status === "timeout" ? "system" : "assistant",
|
|
1825
|
+
taskId: task.id,
|
|
1826
|
+
content: task.summary || task.error || task.status,
|
|
1827
|
+
createdAt: task.finishedAt
|
|
1828
|
+
}
|
|
1829
|
+
] : [];
|
|
1830
|
+
return [
|
|
1831
|
+
{
|
|
1832
|
+
type: "task.prompt",
|
|
1833
|
+
role: "user",
|
|
1834
|
+
taskId: task.id,
|
|
1835
|
+
content: task.prompt,
|
|
1836
|
+
createdAt: task.createdAt
|
|
1837
|
+
},
|
|
1838
|
+
...outputMessages,
|
|
1839
|
+
...resultMessage
|
|
1840
|
+
];
|
|
1841
|
+
});
|
|
1842
|
+
return { sessionId, tasks, messages };
|
|
1843
|
+
}
|
|
1844
|
+
function extractUserMessage(line) {
|
|
1845
|
+
try {
|
|
1846
|
+
const obj = JSON.parse(line);
|
|
1847
|
+
if (obj.type === "human" || obj.role === "user" || obj.message?.role === "user") {
|
|
1848
|
+
const content = obj.message?.content ?? obj.content;
|
|
1849
|
+
if (typeof content === "string") return content.slice(0, 200);
|
|
1850
|
+
if (Array.isArray(content)) {
|
|
1851
|
+
const text = content.filter((c) => typeof c.text === "string").map((c) => c.text).join("\n");
|
|
1852
|
+
return text.slice(0, 200) || null;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
return null;
|
|
1856
|
+
} catch {
|
|
1857
|
+
return null;
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
function buildClaudeSessions(employeeId) {
|
|
1861
|
+
const employee = state.employees.get(employeeId);
|
|
1862
|
+
const workspace = [...state.tasks.values()].filter((t) => t.employeeId === employeeId && t.workspace).sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]?.workspace ?? null;
|
|
1863
|
+
if (!workspace) {
|
|
1864
|
+
return { employeeId, workspace: null, activeSessionId: null, sessions: [] };
|
|
1865
|
+
}
|
|
1866
|
+
const encodedPath = workspace.replace(/\//g, "-");
|
|
1867
|
+
const claudeProjectsDir = path.join(os.homedir(), ".claude", "projects", encodedPath);
|
|
1868
|
+
let entries;
|
|
1869
|
+
try {
|
|
1870
|
+
entries = fs.readdirSync(claudeProjectsDir, { withFileTypes: true });
|
|
1871
|
+
} catch {
|
|
1872
|
+
return { employeeId, workspace, activeSessionId: null, sessions: [] };
|
|
1873
|
+
}
|
|
1874
|
+
const jsonlFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".jsonl")).sort((a, b) => b.name.localeCompare(a.name));
|
|
1875
|
+
const sessions = jsonlFiles.map((entry) => {
|
|
1876
|
+
const filePath = path.join(claudeProjectsDir, entry.name);
|
|
1877
|
+
const stat = fs.statSync(filePath);
|
|
1878
|
+
const sessionId = entry.name.replace(/\.jsonl$/, "");
|
|
1879
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
1880
|
+
const lines = content.split("\n").filter(Boolean);
|
|
1881
|
+
const lineCount = lines.length;
|
|
1882
|
+
let firstUserMessage = null;
|
|
1883
|
+
let latestUserMessage = null;
|
|
1884
|
+
for (const line of lines) {
|
|
1885
|
+
const msg = extractUserMessage(line);
|
|
1886
|
+
if (msg && !firstUserMessage) firstUserMessage = msg;
|
|
1887
|
+
if (msg) latestUserMessage = msg;
|
|
1888
|
+
}
|
|
1889
|
+
return { id: sessionId, sizeBytes: stat.size, modifiedAt: stat.mtime.toISOString(), lineCount, firstUserMessage, latestUserMessage };
|
|
1890
|
+
});
|
|
1891
|
+
let activeSessionId = null;
|
|
1892
|
+
const agentStatePath = path.join(workspace, ".ai-teams", "agents", employeeId, "session-state.json");
|
|
1893
|
+
try {
|
|
1894
|
+
const raw = fs.readFileSync(agentStatePath, "utf8");
|
|
1895
|
+
activeSessionId = JSON.parse(raw).claudeSessionId ?? null;
|
|
1896
|
+
} catch {
|
|
1897
|
+
}
|
|
1898
|
+
return { employeeId, workspace, activeSessionId, sessions };
|
|
1899
|
+
}
|
|
1900
|
+
app.addHook("preHandler", async (request, reply) => {
|
|
1901
|
+
if (request.url.startsWith("/ws/") || request.url.startsWith("/docs")) {
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
if (!isAuthorized(options.authToken, request.url, request.headers)) {
|
|
1905
|
+
app.log.warn({ url: request.url, ip: request.ip }, "Unauthorized request");
|
|
1906
|
+
await reply.code(401).send({ error: "unauthorized" });
|
|
1907
|
+
}
|
|
1908
|
+
});
|
|
1909
|
+
app.get(
|
|
1910
|
+
"/health",
|
|
1911
|
+
{
|
|
1912
|
+
schema: {
|
|
1913
|
+
tags: ["system"],
|
|
1914
|
+
summary: "Check server health",
|
|
1915
|
+
response: {
|
|
1916
|
+
200: {
|
|
1917
|
+
type: "object",
|
|
1918
|
+
required: ["status", "timestamp", "dbPath", "employees", "leaders", "tasks"],
|
|
1919
|
+
properties: {
|
|
1920
|
+
status: { type: "string", enum: ["ok"] },
|
|
1921
|
+
timestamp: { type: "string", format: "date-time" },
|
|
1922
|
+
dbPath: { type: "string" },
|
|
1923
|
+
employees: { type: "number" },
|
|
1924
|
+
leaders: { type: "number" },
|
|
1925
|
+
tasks: { type: "number" }
|
|
1926
|
+
}
|
|
1927
|
+
},
|
|
1928
|
+
401: errorResponseSchema
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
},
|
|
1932
|
+
async () => ({
|
|
1933
|
+
status: "ok",
|
|
1934
|
+
timestamp: nowIso(),
|
|
1935
|
+
dbPath,
|
|
1936
|
+
employees: state.employees.size,
|
|
1937
|
+
leaders: state.leaderSockets.size,
|
|
1938
|
+
tasks: state.tasks.size
|
|
1939
|
+
})
|
|
1940
|
+
);
|
|
1941
|
+
app.get(
|
|
1942
|
+
"/api/snapshot",
|
|
1943
|
+
{
|
|
1944
|
+
schema: {
|
|
1945
|
+
tags: ["tasks"],
|
|
1946
|
+
summary: "Get current employees, tasks, and task logs",
|
|
1947
|
+
response: {
|
|
1948
|
+
200: snapshotSchema,
|
|
1949
|
+
401: errorResponseSchema
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
},
|
|
1953
|
+
async () => buildSnapshot()
|
|
1954
|
+
);
|
|
1955
|
+
app.get(
|
|
1956
|
+
"/api/sessions/:sessionId/history",
|
|
1957
|
+
{
|
|
1958
|
+
schema: {
|
|
1959
|
+
tags: ["sessions"],
|
|
1960
|
+
summary: "Get conversation history by session ID",
|
|
1961
|
+
description: "Returns task prompts, task output chunks, and terminal summaries/errors for the specified AI Teams Claude session ID.",
|
|
1962
|
+
params: {
|
|
1963
|
+
type: "object",
|
|
1964
|
+
required: ["sessionId"],
|
|
1965
|
+
properties: {
|
|
1966
|
+
sessionId: { type: "string", minLength: 1 }
|
|
1967
|
+
}
|
|
1968
|
+
},
|
|
1969
|
+
response: {
|
|
1970
|
+
200: sessionHistorySchema,
|
|
1971
|
+
401: errorResponseSchema
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
},
|
|
1975
|
+
async (request) => buildSessionHistory(request.params.sessionId)
|
|
1976
|
+
);
|
|
1977
|
+
app.get(
|
|
1978
|
+
"/api/employees/:employeeId/claude-sessions",
|
|
1979
|
+
{
|
|
1980
|
+
schema: {
|
|
1981
|
+
tags: ["employees"],
|
|
1982
|
+
summary: "List local Claude Code sessions for an agent's workspace",
|
|
1983
|
+
params: {
|
|
1984
|
+
type: "object",
|
|
1985
|
+
required: ["employeeId"],
|
|
1986
|
+
properties: { employeeId: { type: "string", minLength: 1 } }
|
|
1987
|
+
},
|
|
1988
|
+
response: {
|
|
1989
|
+
200: claudeSessionsResponseSchema,
|
|
1990
|
+
404: errorResponseSchema,
|
|
1991
|
+
401: errorResponseSchema
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
},
|
|
1995
|
+
async (request, reply) => {
|
|
1996
|
+
const employee = state.employees.get(request.params.employeeId);
|
|
1997
|
+
if (!employee) {
|
|
1998
|
+
return reply.code(404).send({ error: "Employee not found." });
|
|
1999
|
+
}
|
|
2000
|
+
return buildClaudeSessions(request.params.employeeId);
|
|
2001
|
+
}
|
|
2002
|
+
);
|
|
2003
|
+
app.post(
|
|
2004
|
+
"/api/tasks",
|
|
2005
|
+
{
|
|
2006
|
+
schema: {
|
|
2007
|
+
tags: ["tasks"],
|
|
2008
|
+
summary: "Submit a task",
|
|
2009
|
+
description: "Submit a task to the shared queue, all agents, or one or more selected agents. Optional webhook receives task lifecycle callbacks.",
|
|
2010
|
+
body: restTaskRequestSchema,
|
|
2011
|
+
response: {
|
|
2012
|
+
202: restTaskAcceptedSchema,
|
|
2013
|
+
400: {
|
|
2014
|
+
type: "object",
|
|
2015
|
+
required: ["status", "code", "message"],
|
|
2016
|
+
properties: {
|
|
2017
|
+
status: { type: "string", enum: ["rejected"] },
|
|
2018
|
+
code: { type: "string" },
|
|
2019
|
+
message: { type: "string" }
|
|
2020
|
+
}
|
|
2021
|
+
},
|
|
2022
|
+
401: errorResponseSchema
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
},
|
|
2026
|
+
async (request, reply) => {
|
|
2027
|
+
try {
|
|
2028
|
+
const { command, webhookUrl, cliConfig, priority, requiredLabels } = parseRestTaskRequest(request.body);
|
|
2029
|
+
const result = dispatchLeaderCommand(command, webhookUrl, cliConfig, priority, requiredLabels);
|
|
2030
|
+
if (!result.ok) {
|
|
2031
|
+
return reply.code(400).send({
|
|
2032
|
+
status: "rejected",
|
|
2033
|
+
code: result.code,
|
|
2034
|
+
message: result.message
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
return reply.code(202).send({
|
|
2038
|
+
status: "accepted",
|
|
2039
|
+
leaderCommandId: result.leaderCommandId,
|
|
2040
|
+
tasks: result.tasks
|
|
2041
|
+
});
|
|
2042
|
+
} catch (error) {
|
|
2043
|
+
return reply.code(400).send({
|
|
2044
|
+
status: "rejected",
|
|
2045
|
+
code: "invalid_request",
|
|
2046
|
+
message: error instanceof Error ? error.message : "Invalid task request."
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
);
|
|
2051
|
+
app.get(
|
|
2052
|
+
"/api/tasks",
|
|
2053
|
+
{
|
|
2054
|
+
schema: {
|
|
2055
|
+
tags: ["tasks"],
|
|
2056
|
+
summary: "List tasks with optional filters",
|
|
2057
|
+
querystring: {
|
|
2058
|
+
type: "object",
|
|
2059
|
+
properties: {
|
|
2060
|
+
status: { type: "string", enum: ["queued", "dispatched", "accepted", "running", "completed", "failed", "cancelled", "timeout"] },
|
|
2061
|
+
employeeId: { type: "string" },
|
|
2062
|
+
limit: { type: "number", minimum: 1, maximum: 100 },
|
|
2063
|
+
offset: { type: "number", minimum: 0 }
|
|
2064
|
+
}
|
|
2065
|
+
},
|
|
2066
|
+
response: {
|
|
2067
|
+
200: taskListResponseSchema,
|
|
2068
|
+
401: errorResponseSchema
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
},
|
|
2072
|
+
async (request) => {
|
|
2073
|
+
const rows = await queryTasks(db, {
|
|
2074
|
+
status: request.query.status,
|
|
2075
|
+
employeeId: request.query.employeeId,
|
|
2076
|
+
limit: request.query.limit,
|
|
2077
|
+
offset: request.query.offset
|
|
2078
|
+
});
|
|
2079
|
+
return { tasks: rows.map((row) => dbRowToTask(row, defaultTimeoutSec)) };
|
|
2080
|
+
}
|
|
2081
|
+
);
|
|
2082
|
+
app.get(
|
|
2083
|
+
"/api/tasks/:taskId",
|
|
2084
|
+
{
|
|
2085
|
+
schema: {
|
|
2086
|
+
tags: ["tasks"],
|
|
2087
|
+
summary: "Get a single task by ID",
|
|
2088
|
+
params: {
|
|
2089
|
+
type: "object",
|
|
2090
|
+
required: ["taskId"],
|
|
2091
|
+
properties: { taskId: { type: "string", minLength: 1 } }
|
|
2092
|
+
},
|
|
2093
|
+
response: {
|
|
2094
|
+
200: taskRecordSchema,
|
|
2095
|
+
404: errorResponseSchema,
|
|
2096
|
+
401: errorResponseSchema
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
},
|
|
2100
|
+
async (request, reply) => {
|
|
2101
|
+
const row = await getTaskById(db, request.params.taskId);
|
|
2102
|
+
if (!row) {
|
|
2103
|
+
return reply.code(404).send({ error: "Task not found." });
|
|
2104
|
+
}
|
|
2105
|
+
return dbRowToTask(row, defaultTimeoutSec);
|
|
2106
|
+
}
|
|
2107
|
+
);
|
|
2108
|
+
app.patch(
|
|
2109
|
+
"/api/tasks/:taskId",
|
|
2110
|
+
{
|
|
2111
|
+
schema: {
|
|
2112
|
+
tags: ["tasks"],
|
|
2113
|
+
summary: "Update a task",
|
|
2114
|
+
params: {
|
|
2115
|
+
type: "object",
|
|
2116
|
+
required: ["taskId"],
|
|
2117
|
+
properties: { taskId: { type: "string", minLength: 1 } }
|
|
2118
|
+
},
|
|
2119
|
+
body: taskPatchSchema,
|
|
2120
|
+
response: {
|
|
2121
|
+
200: taskRecordSchema,
|
|
2122
|
+
404: errorResponseSchema,
|
|
2123
|
+
401: errorResponseSchema
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
},
|
|
2127
|
+
async (request, reply) => {
|
|
2128
|
+
const allowed = /* @__PURE__ */ new Set(["status", "timeoutSec", "cliConfig"]);
|
|
2129
|
+
const fields = {};
|
|
2130
|
+
for (const [key, value] of Object.entries(request.body)) {
|
|
2131
|
+
if (allowed.has(key)) {
|
|
2132
|
+
fields[key] = value;
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
2135
|
+
const row = await updateTaskFields(db, request.params.taskId, fields);
|
|
2136
|
+
if (!row) {
|
|
2137
|
+
return reply.code(404).send({ error: "Task not found." });
|
|
2138
|
+
}
|
|
2139
|
+
return dbRowToTask(row, defaultTimeoutSec);
|
|
2140
|
+
}
|
|
2141
|
+
);
|
|
2142
|
+
app.post(
|
|
2143
|
+
"/api/tasks/:taskId/cancel",
|
|
2144
|
+
{
|
|
2145
|
+
schema: {
|
|
2146
|
+
tags: ["tasks"],
|
|
2147
|
+
summary: "Cancel a task",
|
|
2148
|
+
description: "Cancel a queued or running task. For running tasks, notifies the agent to terminate the claude process.",
|
|
2149
|
+
params: {
|
|
2150
|
+
type: "object",
|
|
2151
|
+
required: ["taskId"],
|
|
2152
|
+
properties: { taskId: { type: "string", minLength: 1 } }
|
|
2153
|
+
},
|
|
2154
|
+
response: {
|
|
2155
|
+
200: taskRecordSchema,
|
|
2156
|
+
404: errorResponseSchema,
|
|
2157
|
+
409: errorResponseSchema,
|
|
2158
|
+
401: errorResponseSchema
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
},
|
|
2162
|
+
async (request, reply) => {
|
|
2163
|
+
const result = cancelTaskById(request.params.taskId);
|
|
2164
|
+
if (!result.ok) {
|
|
2165
|
+
const code = result.code === "not_found" ? 404 : 409;
|
|
2166
|
+
return reply.code(code).send({ error: result.message });
|
|
2167
|
+
}
|
|
2168
|
+
return result.task;
|
|
2169
|
+
}
|
|
2170
|
+
);
|
|
2171
|
+
app.delete(
|
|
2172
|
+
"/api/tasks/:taskId",
|
|
2173
|
+
{
|
|
2174
|
+
schema: {
|
|
2175
|
+
tags: ["tasks"],
|
|
2176
|
+
summary: "Delete a task (only terminal status)",
|
|
2177
|
+
params: {
|
|
2178
|
+
type: "object",
|
|
2179
|
+
required: ["taskId"],
|
|
2180
|
+
properties: { taskId: { type: "string", minLength: 1 } }
|
|
2181
|
+
},
|
|
2182
|
+
response: {
|
|
2183
|
+
200: { type: "object", required: ["deleted"], properties: { deleted: { type: "boolean" } } },
|
|
2184
|
+
404: errorResponseSchema,
|
|
2185
|
+
409: errorResponseSchema,
|
|
2186
|
+
401: errorResponseSchema
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
},
|
|
2190
|
+
async (request, reply) => {
|
|
2191
|
+
const deleted = await deleteTask(db, request.params.taskId);
|
|
2192
|
+
if (!deleted) {
|
|
2193
|
+
const row = await getTaskById(db, request.params.taskId);
|
|
2194
|
+
if (!row) {
|
|
2195
|
+
return reply.code(404).send({ error: "Task not found." });
|
|
2196
|
+
}
|
|
2197
|
+
return reply.code(409).send({ error: "Task is not in a terminal status." });
|
|
2198
|
+
}
|
|
2199
|
+
return { deleted: true };
|
|
2200
|
+
}
|
|
2201
|
+
);
|
|
2202
|
+
app.get("/ws/agent", { websocket: true }, (socket, request) => {
|
|
2203
|
+
if (!isAuthorized(options.authToken, request.url, request.headers)) {
|
|
2204
|
+
socket.close(1008, "unauthorized");
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
app.log.info("Agent connected");
|
|
2208
|
+
socket.on("message", (raw) => {
|
|
2209
|
+
try {
|
|
2210
|
+
const decrypted = dispatchCtx.encryptor.decrypt(raw.toString());
|
|
2211
|
+
const message = parseEmployeeToServerMessage(parseJsonMessage(decrypted));
|
|
2212
|
+
handleAgentMessage(message, socket);
|
|
2213
|
+
} catch (error) {
|
|
2214
|
+
app.log.error({ error }, "Failed to parse agent message");
|
|
2215
|
+
socket.close(1008, "invalid_message");
|
|
2216
|
+
}
|
|
2217
|
+
});
|
|
2218
|
+
socket.on("close", () => {
|
|
2219
|
+
if (closing) {
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
const employeeId = state.socketToEmployeeId.get(socket);
|
|
2223
|
+
if (!employeeId || state.agentSockets.get(employeeId) !== socket) {
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
state.agentSockets.delete(employeeId);
|
|
2227
|
+
state.socketToEmployeeId.delete(socket);
|
|
2228
|
+
const heartbeatTimer = state.heartbeatTimers.get(employeeId);
|
|
2229
|
+
if (heartbeatTimer) {
|
|
2230
|
+
clearTimeout(heartbeatTimer);
|
|
2231
|
+
state.heartbeatTimers.delete(employeeId);
|
|
2232
|
+
}
|
|
2233
|
+
const employee = state.employees.get(employeeId);
|
|
2234
|
+
if (employee) {
|
|
2235
|
+
employee.status = "offline";
|
|
2236
|
+
employee.lastSeenAt = nowIso();
|
|
2237
|
+
for (const leaderSocket of state.leaderSockets) {
|
|
2238
|
+
sendJson(leaderSocket, { type: "employee.upsert", employee }, dispatchCtx.encryptor);
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
app.log.info({ employeeId }, "Agent disconnected");
|
|
2242
|
+
});
|
|
2243
|
+
});
|
|
2244
|
+
app.get("/ws/leader", { websocket: true }, (socket, request) => {
|
|
2245
|
+
if (!isAuthorized(options.authToken, request.url, request.headers)) {
|
|
2246
|
+
socket.close(1008, "unauthorized");
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
state.leaderSockets.add(socket);
|
|
2250
|
+
sendJson(socket, { type: "snapshot", snapshot: buildSnapshot() }, dispatchCtx.encryptor);
|
|
2251
|
+
app.log.info("Leader connected");
|
|
2252
|
+
socket.on("message", (raw) => {
|
|
2253
|
+
try {
|
|
2254
|
+
const decrypted = dispatchCtx.encryptor.decrypt(raw.toString());
|
|
2255
|
+
const message = parseLeaderToServerMessage(parseJsonMessage(decrypted));
|
|
2256
|
+
handleLeaderMessage(message, socket);
|
|
2257
|
+
} catch (error) {
|
|
2258
|
+
app.log.error({ error }, "Failed to parse leader message");
|
|
2259
|
+
sendJson(socket, {
|
|
2260
|
+
type: "command.error",
|
|
2261
|
+
code: "invalid_message",
|
|
2262
|
+
message: error instanceof Error ? error.message : "Invalid leader message."
|
|
2263
|
+
}, dispatchCtx.encryptor);
|
|
2264
|
+
}
|
|
2265
|
+
});
|
|
2266
|
+
socket.on("close", () => {
|
|
2267
|
+
state.leaderSockets.delete(socket);
|
|
2268
|
+
app.log.info("Leader disconnected");
|
|
2269
|
+
});
|
|
2270
|
+
});
|
|
2271
|
+
return {
|
|
2272
|
+
app,
|
|
2273
|
+
buildSnapshot,
|
|
2274
|
+
close: async () => {
|
|
2275
|
+
closing = true;
|
|
2276
|
+
for (const timer of state.taskTimeouts.values()) {
|
|
2277
|
+
clearTimeout(timer);
|
|
2278
|
+
}
|
|
2279
|
+
for (const timer of state.disconnectTimers.values()) {
|
|
2280
|
+
clearTimeout(timer);
|
|
2281
|
+
}
|
|
2282
|
+
for (const timer of state.heartbeatTimers.values()) {
|
|
2283
|
+
clearTimeout(timer);
|
|
2284
|
+
}
|
|
2285
|
+
await app.close();
|
|
2286
|
+
db.close();
|
|
2287
|
+
}
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
2290
|
+
function readOptionsFromEnv(env = process.env) {
|
|
2291
|
+
return {
|
|
2292
|
+
authToken: env.AI_TEAMS_AUTH_TOKEN ?? "",
|
|
2293
|
+
port: Number(env.PORT ?? env.AI_TEAMS_SERVER_PORT) || DEFAULT_PORT,
|
|
2294
|
+
host: env.HOST || "0.0.0.0",
|
|
2295
|
+
dataDir: env.DATA_DIR,
|
|
2296
|
+
dbPath: env.DB_PATH,
|
|
2297
|
+
defaultTimeoutSec: Number(env.DEFAULT_TIMEOUT_SEC) || 1800,
|
|
2298
|
+
disconnectGraceMs: Number(env.DISCONNECT_GRACE_MS) || 15e3,
|
|
2299
|
+
maxLogChunksPerTask: Number(env.MAX_LOG_CHUNKS_PER_TASK) || 400,
|
|
2300
|
+
logLevel: env.LOG_LEVEL,
|
|
2301
|
+
logDir: env.LOG_DIR
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
async function startServer(options = readOptionsFromEnv()) {
|
|
2305
|
+
const server = await createAiTeamsServer(options);
|
|
2306
|
+
await server.app.listen({ port: options.port ?? DEFAULT_PORT, host: options.host ?? "0.0.0.0" });
|
|
2307
|
+
return server;
|
|
2308
|
+
}
|
|
2309
|
+
var isCli = process.argv[1] === fileURLToPath(import.meta.url);
|
|
2310
|
+
if (isCli) {
|
|
2311
|
+
let getArgValue = function(name) {
|
|
2312
|
+
const idx = args.indexOf(name);
|
|
2313
|
+
if (idx === -1) return void 0;
|
|
2314
|
+
return args[idx + 1];
|
|
2315
|
+
};
|
|
2316
|
+
getArgValue2 = getArgValue;
|
|
2317
|
+
const args = process.argv.slice(2);
|
|
2318
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
2319
|
+
console.log(`ai-teams-server \u2014 AI Teams \u4E2D\u592E\u670D\u52A1\u5668
|
|
2320
|
+
|
|
2321
|
+
\u7528\u6CD5: ai-teams-server [\u9009\u9879]
|
|
2322
|
+
|
|
2323
|
+
\u9009\u9879:
|
|
2324
|
+
--token <token> \u8BA4\u8BC1 Token (\u5FC5\u586B\uFF0C\u6216\u8BBE AI_TEAMS_AUTH_TOKEN)
|
|
2325
|
+
--port <port> \u670D\u52A1\u7AEF\u53E3 (\u9ED8\u8BA4 3789)
|
|
2326
|
+
--host <host> \u7ED1\u5B9A\u5730\u5740 (\u9ED8\u8BA4 0.0.0.0)
|
|
2327
|
+
--data-dir <dir> \u6570\u636E\u76EE\u5F55
|
|
2328
|
+
--db-path <path> \u6570\u636E\u5E93\u8DEF\u5F84
|
|
2329
|
+
--log-level <level> \u65E5\u5FD7\u7EA7\u522B trace/debug/info/warn/error (\u9ED8\u8BA4 info)
|
|
2330
|
+
--log-dir <dir> \u65E5\u5FD7\u6587\u4EF6\u76EE\u5F55 (\u4E0D\u8BBE\u5219\u4EC5\u8F93\u51FA\u5230 stdout)
|
|
2331
|
+
-h, --help \u663E\u793A\u5E2E\u52A9
|
|
2332
|
+
`);
|
|
2333
|
+
process.exit(0);
|
|
2334
|
+
}
|
|
2335
|
+
const cliToken = getArgValue("--token");
|
|
2336
|
+
const cliPort = getArgValue("--port");
|
|
2337
|
+
const cliHost = getArgValue("--host");
|
|
2338
|
+
const cliDataDir = getArgValue("--data-dir");
|
|
2339
|
+
const cliDbPath = getArgValue("--db-path");
|
|
2340
|
+
const cliLogLevel = getArgValue("--log-level");
|
|
2341
|
+
const cliLogDir = getArgValue("--log-dir");
|
|
2342
|
+
if (cliToken) process.env.AI_TEAMS_AUTH_TOKEN = cliToken;
|
|
2343
|
+
if (cliPort) process.env.AI_TEAMS_SERVER_PORT = cliPort;
|
|
2344
|
+
if (cliHost) process.env.HOST = cliHost;
|
|
2345
|
+
if (cliDataDir) process.env.DATA_DIR = cliDataDir;
|
|
2346
|
+
if (cliDbPath) process.env.DB_PATH = cliDbPath;
|
|
2347
|
+
if (cliLogLevel) process.env.LOG_LEVEL = cliLogLevel;
|
|
2348
|
+
if (cliLogDir) process.env.LOG_DIR = cliLogDir;
|
|
2349
|
+
const options = readOptionsFromEnv();
|
|
2350
|
+
if (!options.authToken) {
|
|
2351
|
+
console.error("\u9519\u8BEF: \u9700\u8981\u8BA4\u8BC1 Token\u3002\u4F7F\u7528 --token <token> \u6216\u8BBE\u7F6E AI_TEAMS_AUTH_TOKEN \u73AF\u5883\u53D8\u91CF\u3002");
|
|
2352
|
+
process.exit(1);
|
|
2353
|
+
}
|
|
2354
|
+
startServer(options).catch((error) => {
|
|
2355
|
+
console.error(error);
|
|
2356
|
+
process.exit(1);
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
var getArgValue2;
|
|
2360
|
+
export {
|
|
2361
|
+
createAiTeamsServer,
|
|
2362
|
+
readOptionsFromEnv,
|
|
2363
|
+
startServer
|
|
2364
|
+
};
|