bosun 0.40.18 → 0.40.21
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/agent/agent-endpoint.mjs +1443 -1429
- package/bench/benchmark-mode.mjs +253 -0
- package/bench/benchmark-registry.mjs +347 -0
- package/bench/swebench/README.md +91 -0
- package/bench/swebench/bosun-swebench.mjs +456 -0
- package/infra/library-manager.mjs +360 -20
- package/infra/monitor.mjs +124 -50
- package/kanban/vibe-kanban-wrapper.mjs +0 -0
- package/package.json +6 -2
- package/server/ui-server.mjs +653 -0
- package/task/task-claims.mjs +43 -7
- package/task/task-cli-bin.mjs +8 -0
- package/task/task-cli.mjs +23 -7
- package/task/task-store.mjs +1 -1
- package/telegram/get-telegram-chat-id.mjs +0 -0
- package/telegram/telegram-sentinel.mjs +0 -0
- package/ui/app.js +2 -0
- package/ui/demo.html +470 -1
- package/ui/modules/router.js +2 -0
- package/ui/modules/state.js +21 -0
- package/ui/tabs/benchmarks.js +845 -0
- package/ui/tabs/library.js +173 -14
- package/workflow/workflow-engine.mjs +6 -0
- package/workflow/workflow-nodes.mjs +208 -5
- package/workspace/shared-state-manager.mjs +70 -8
- package/workspace/shared-workspace-cli.mjs +0 -0
- package/ui/components/chat-view.js.bak +0 -1
- package/ui/tabs/infra.js.bak +0 -1
package/agent/agent-endpoint.mjs
CHANGED
|
@@ -1,1431 +1,1445 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* agent-endpoint.mjs — Lightweight HTTP server for agent self-reporting
|
|
3
|
-
*
|
|
4
|
-
* Agents running in worktrees use this REST API to tell the orchestrator
|
|
5
|
-
* "I'm done" / "I hit an error" / "I'm still alive" without polling.
|
|
6
|
-
*
|
|
7
|
-
* Features:
|
|
8
|
-
* - Node.js built-in `http.createServer` — zero external dependencies
|
|
9
|
-
* - Binds to 127.0.0.1 on configurable port (AGENT_ENDPOINT_PORT or 18432)
|
|
10
|
-
* - JSON request/response, CORS for localhost
|
|
11
|
-
* - 30s request timeout, 1MB max body
|
|
12
|
-
* - Callback hooks for monitor integration
|
|
13
|
-
*
|
|
14
|
-
* EXPORTS:
|
|
15
|
-
* AgentEndpoint — Main class
|
|
16
|
-
* createAgentEndpoint() — Factory function
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { createServer } from "node:http";
|
|
20
|
-
import { resolve, dirname } from "node:path";
|
|
21
|
-
import { fileURLToPath } from "node:url";
|
|
22
|
-
import { writeFileSync, mkdirSync, unlinkSync, readFileSync } from "node:fs";
|
|
23
|
-
import { randomUUID } from "node:crypto";
|
|
24
|
-
|
|
25
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
26
|
-
const __dirname = dirname(__filename);
|
|
27
|
-
|
|
28
|
-
const TAG = "[agent-endpoint]";
|
|
29
|
-
|
|
30
|
-
const DEFAULT_PORT = 18432;
|
|
31
|
-
const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
|
|
32
|
-
const REQUEST_TIMEOUT_MS = 30_000; // 30 seconds
|
|
1
|
+
/**
|
|
2
|
+
* agent-endpoint.mjs — Lightweight HTTP server for agent self-reporting
|
|
3
|
+
*
|
|
4
|
+
* Agents running in worktrees use this REST API to tell the orchestrator
|
|
5
|
+
* "I'm done" / "I hit an error" / "I'm still alive" without polling.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Node.js built-in `http.createServer` — zero external dependencies
|
|
9
|
+
* - Binds to 127.0.0.1 on configurable port (AGENT_ENDPOINT_PORT or 18432)
|
|
10
|
+
* - JSON request/response, CORS for localhost
|
|
11
|
+
* - 30s request timeout, 1MB max body
|
|
12
|
+
* - Callback hooks for monitor integration
|
|
13
|
+
*
|
|
14
|
+
* EXPORTS:
|
|
15
|
+
* AgentEndpoint — Main class
|
|
16
|
+
* createAgentEndpoint() — Factory function
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { createServer } from "node:http";
|
|
20
|
+
import { resolve, dirname } from "node:path";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { writeFileSync, mkdirSync, unlinkSync, readFileSync } from "node:fs";
|
|
23
|
+
import { randomUUID } from "node:crypto";
|
|
24
|
+
|
|
25
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
26
|
+
const __dirname = dirname(__filename);
|
|
27
|
+
|
|
28
|
+
const TAG = "[agent-endpoint]";
|
|
29
|
+
|
|
30
|
+
const DEFAULT_PORT = 18432;
|
|
31
|
+
const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
|
|
32
|
+
const REQUEST_TIMEOUT_MS = 30_000; // 30 seconds
|
|
33
33
|
const ACCESS_DENIED_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes
|
|
34
|
-
const BOSUN_ROOT_HINT = __dirname.toLowerCase().replace(/\\/g, '/');
|
|
35
|
-
|
|
36
|
-
// Valid status transitions when an agent self-reports
|
|
37
|
-
const VALID_TRANSITIONS = {
|
|
38
|
-
inprogress: ["inreview", "blocked", "done"],
|
|
39
|
-
inreview: ["done"],
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
// Track ports where we lack permission to terminate the owning process.
|
|
43
|
-
const accessDeniedPorts = new Map();
|
|
44
|
-
const accessDeniedCooldownWarnAt = new Map();
|
|
45
|
-
const accessDeniedCachePath = resolve(
|
|
46
|
-
__dirname,
|
|
47
|
-
"..", ".cache",
|
|
48
|
-
"agent-endpoint-access-denied.json",
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
function loadAccessDeniedCache() {
|
|
52
|
-
try {
|
|
53
|
-
const raw = readFileSync(accessDeniedCachePath, "utf8");
|
|
54
|
-
const data = JSON.parse(raw);
|
|
55
|
-
if (!data || typeof data !== "object") return;
|
|
56
|
-
Object.entries(data).forEach(([port, ts]) => {
|
|
57
|
-
const parsedPort = Number(port);
|
|
58
|
-
const parsedTs = Number(ts);
|
|
59
|
-
if (Number.isFinite(parsedPort) && Number.isFinite(parsedTs)) {
|
|
60
|
-
accessDeniedPorts.set(parsedPort, parsedTs);
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
} catch {
|
|
64
|
-
// Ignore missing/invalid cache
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function persistAccessDeniedCache() {
|
|
69
|
-
try {
|
|
70
|
-
mkdirSync(dirname(accessDeniedCachePath), { recursive: true });
|
|
71
|
-
const payload = Object.fromEntries(accessDeniedPorts.entries());
|
|
72
|
-
writeFileSync(accessDeniedCachePath, JSON.stringify(payload, null, 2));
|
|
73
|
-
} catch (err) {
|
|
74
|
-
console.warn(
|
|
75
|
-
`${TAG} Failed to persist access-denied cache: ${err.message || err}`,
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function pruneAccessDeniedCache() {
|
|
81
|
-
const now = Date.now();
|
|
82
|
-
let changed = false;
|
|
83
|
-
for (const [port, ts] of accessDeniedPorts.entries()) {
|
|
84
|
-
if (!ts || now - ts > ACCESS_DENIED_COOLDOWN_MS) {
|
|
85
|
-
accessDeniedPorts.delete(port);
|
|
86
|
-
accessDeniedCooldownWarnAt.delete(port);
|
|
87
|
-
changed = true;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
if (changed) persistAccessDeniedCache();
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function shouldLogAccessDeniedCooldown(port, nowMs = Date.now()) {
|
|
94
|
-
const lastWarnAt = accessDeniedCooldownWarnAt.get(port);
|
|
95
|
-
if (Number.isFinite(lastWarnAt) && nowMs - lastWarnAt < ACCESS_DENIED_COOLDOWN_MS) {
|
|
96
|
-
return false;
|
|
97
|
-
}
|
|
98
|
-
accessDeniedCooldownWarnAt.set(port, nowMs);
|
|
99
|
-
return true;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
loadAccessDeniedCache();
|
|
103
|
-
|
|
104
|
-
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Parse JSON body from an incoming request with size limit.
|
|
108
|
-
* @param {import("node:http").IncomingMessage} req
|
|
109
|
-
* @returns {Promise<object>}
|
|
110
|
-
*/
|
|
111
|
-
function parseBody(req) {
|
|
112
|
-
return new Promise((resolve, reject) => {
|
|
113
|
-
const chunks = [];
|
|
114
|
-
let size = 0;
|
|
115
|
-
|
|
116
|
-
req.on("data", (chunk) => {
|
|
117
|
-
size += chunk.length;
|
|
118
|
-
if (size > MAX_BODY_SIZE) {
|
|
119
|
-
req.destroy();
|
|
120
|
-
reject(new Error("Request body too large"));
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
chunks.push(chunk);
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
req.on("end", () => {
|
|
127
|
-
const raw = Buffer.concat(chunks).toString("utf8");
|
|
128
|
-
if (!raw || raw.trim() === "") {
|
|
129
|
-
resolve({});
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
try {
|
|
133
|
-
resolve(JSON.parse(raw));
|
|
134
|
-
} catch (err) {
|
|
135
|
-
// Include preview of malformed JSON for debugging (truncate to 200 chars)
|
|
136
|
-
const preview = raw.length > 200 ? raw.slice(0, 200) + "..." : raw;
|
|
137
|
-
reject(
|
|
138
|
-
new Error(`Invalid JSON body: ${err.message} — Preview: ${preview}`),
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
req.on("error", reject);
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Send a JSON response.
|
|
149
|
-
* @param {import("node:http").ServerResponse} res
|
|
150
|
-
* @param {number} status
|
|
151
|
-
* @param {object} body
|
|
152
|
-
*/
|
|
153
|
-
function sendJson(res, status, body) {
|
|
154
|
-
const payload = JSON.stringify(body);
|
|
155
|
-
res.writeHead(status, {
|
|
156
|
-
"Content-Type": "application/json",
|
|
157
|
-
"Access-Control-Allow-Origin": "http://localhost",
|
|
158
|
-
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
159
|
-
"Access-Control-Allow-Headers": "Content-Type",
|
|
160
|
-
"Cache-Control": "no-store",
|
|
161
|
-
});
|
|
162
|
-
res.end(payload);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Extract a task ID from a URL pathname like /api/tasks/:id/...
|
|
167
|
-
* @param {string} pathname
|
|
168
|
-
* @returns {string|null}
|
|
169
|
-
*/
|
|
170
|
-
function extractTaskId(pathname) {
|
|
171
|
-
const match = pathname.match(/^\/api\/tasks\/([^/]+)/);
|
|
172
|
-
return match ? match[1] : null;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function isAlreadyExitedProcessError(err) {
|
|
176
|
-
const detail = [err?.stderr, err?.stdout, err?.message]
|
|
177
|
-
.map((part) => String(part || ""))
|
|
178
|
-
.join("\n")
|
|
179
|
-
.toLowerCase();
|
|
180
|
-
return (
|
|
181
|
-
detail.includes("no running instance of the task") ||
|
|
182
|
-
detail.includes("no running instance") ||
|
|
183
|
-
detail.includes("no such process") ||
|
|
184
|
-
detail.includes("cannot find the process") ||
|
|
185
|
-
detail.includes("not found") ||
|
|
186
|
-
detail.includes("esrch")
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function normalizeCommandLine(commandLine) {
|
|
191
|
-
return String(commandLine || "").toLowerCase().replace(/\\/g, "/").trim();
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function isLikelyBosunCommandLine(commandLine) {
|
|
195
|
-
const normalized = normalizeCommandLine(commandLine);
|
|
196
|
-
if (!normalized) return false;
|
|
197
|
-
|
|
198
|
-
if (normalized.includes(BOSUN_ROOT_HINT)) return true;
|
|
199
|
-
|
|
200
|
-
if (
|
|
201
|
-
normalized.includes("/bosun/") &&
|
|
202
|
-
(normalized.includes("monitor.mjs") ||
|
|
203
|
-
normalized.includes("cli.mjs") ||
|
|
204
|
-
normalized.includes("agent-endpoint.mjs") ||
|
|
205
|
-
normalized.includes("ve-orchestrator"))
|
|
206
|
-
) {
|
|
207
|
-
return true;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Dev-mode often launches monitor as node monitor.mjs from bosun root.
|
|
211
|
-
if (/\bnode(?:\.exe)?\b/.test(normalized) && /\bmonitor\.mjs\b/.test(normalized)) {
|
|
212
|
-
return true;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
return false;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function summarizeCommandLine(commandLine, maxLen = 140) {
|
|
219
|
-
const compact = String(commandLine || "").replace(/\s+/g, " ").trim();
|
|
220
|
-
if (!compact) return "command line unavailable";
|
|
221
|
-
if (compact.length <= maxLen) return compact;
|
|
222
|
-
return compact.slice(0, maxLen) + "...";
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ── AgentEndpoint Class ─────────────────────────────────────────────────────
|
|
226
|
-
|
|
227
|
-
export class AgentEndpoint {
|
|
228
|
-
/**
|
|
229
|
-
* @param {object} options
|
|
230
|
-
* @param {number} [options.port] — Listen port (default: env or 18432)
|
|
231
|
-
* @param {
|
|
232
|
-
* @param {
|
|
233
|
-
* @param {Function} [options.
|
|
234
|
-
* @param {Function} [options.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
this.
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
this.
|
|
250
|
-
this.
|
|
251
|
-
this.
|
|
252
|
-
this.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
//
|
|
566
|
-
if (
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
return await this.
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
if (method === "
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
return await this.
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
sendJson(res, 200, {
|
|
839
|
-
} catch (err) {
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
sendJson(res,
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
});
|
|
889
|
-
return;
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
|
|
1040
|
-
//
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
}
|
|
1049
|
-
} catch {
|
|
1050
|
-
|
|
1051
|
-
}
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
//
|
|
1109
|
-
task = this._taskStore.
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
if (
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
//
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
34
|
+
const BOSUN_ROOT_HINT = __dirname.toLowerCase().replace(/\\/g, '/');
|
|
35
|
+
|
|
36
|
+
// Valid status transitions when an agent self-reports
|
|
37
|
+
const VALID_TRANSITIONS = {
|
|
38
|
+
inprogress: ["inreview", "blocked", "done"],
|
|
39
|
+
inreview: ["done"],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Track ports where we lack permission to terminate the owning process.
|
|
43
|
+
const accessDeniedPorts = new Map();
|
|
44
|
+
const accessDeniedCooldownWarnAt = new Map();
|
|
45
|
+
const accessDeniedCachePath = resolve(
|
|
46
|
+
__dirname,
|
|
47
|
+
"..", ".cache",
|
|
48
|
+
"agent-endpoint-access-denied.json",
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
function loadAccessDeniedCache() {
|
|
52
|
+
try {
|
|
53
|
+
const raw = readFileSync(accessDeniedCachePath, "utf8");
|
|
54
|
+
const data = JSON.parse(raw);
|
|
55
|
+
if (!data || typeof data !== "object") return;
|
|
56
|
+
Object.entries(data).forEach(([port, ts]) => {
|
|
57
|
+
const parsedPort = Number(port);
|
|
58
|
+
const parsedTs = Number(ts);
|
|
59
|
+
if (Number.isFinite(parsedPort) && Number.isFinite(parsedTs)) {
|
|
60
|
+
accessDeniedPorts.set(parsedPort, parsedTs);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
} catch {
|
|
64
|
+
// Ignore missing/invalid cache
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function persistAccessDeniedCache() {
|
|
69
|
+
try {
|
|
70
|
+
mkdirSync(dirname(accessDeniedCachePath), { recursive: true });
|
|
71
|
+
const payload = Object.fromEntries(accessDeniedPorts.entries());
|
|
72
|
+
writeFileSync(accessDeniedCachePath, JSON.stringify(payload, null, 2));
|
|
73
|
+
} catch (err) {
|
|
74
|
+
console.warn(
|
|
75
|
+
`${TAG} Failed to persist access-denied cache: ${err.message || err}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function pruneAccessDeniedCache() {
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
let changed = false;
|
|
83
|
+
for (const [port, ts] of accessDeniedPorts.entries()) {
|
|
84
|
+
if (!ts || now - ts > ACCESS_DENIED_COOLDOWN_MS) {
|
|
85
|
+
accessDeniedPorts.delete(port);
|
|
86
|
+
accessDeniedCooldownWarnAt.delete(port);
|
|
87
|
+
changed = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (changed) persistAccessDeniedCache();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function shouldLogAccessDeniedCooldown(port, nowMs = Date.now()) {
|
|
94
|
+
const lastWarnAt = accessDeniedCooldownWarnAt.get(port);
|
|
95
|
+
if (Number.isFinite(lastWarnAt) && nowMs - lastWarnAt < ACCESS_DENIED_COOLDOWN_MS) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
accessDeniedCooldownWarnAt.set(port, nowMs);
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
loadAccessDeniedCache();
|
|
103
|
+
|
|
104
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parse JSON body from an incoming request with size limit.
|
|
108
|
+
* @param {import("node:http").IncomingMessage} req
|
|
109
|
+
* @returns {Promise<object>}
|
|
110
|
+
*/
|
|
111
|
+
function parseBody(req) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const chunks = [];
|
|
114
|
+
let size = 0;
|
|
115
|
+
|
|
116
|
+
req.on("data", (chunk) => {
|
|
117
|
+
size += chunk.length;
|
|
118
|
+
if (size > MAX_BODY_SIZE) {
|
|
119
|
+
req.destroy();
|
|
120
|
+
reject(new Error("Request body too large"));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
chunks.push(chunk);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
req.on("end", () => {
|
|
127
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
128
|
+
if (!raw || raw.trim() === "") {
|
|
129
|
+
resolve({});
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
resolve(JSON.parse(raw));
|
|
134
|
+
} catch (err) {
|
|
135
|
+
// Include preview of malformed JSON for debugging (truncate to 200 chars)
|
|
136
|
+
const preview = raw.length > 200 ? raw.slice(0, 200) + "..." : raw;
|
|
137
|
+
reject(
|
|
138
|
+
new Error(`Invalid JSON body: ${err.message} — Preview: ${preview}`),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
req.on("error", reject);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Send a JSON response.
|
|
149
|
+
* @param {import("node:http").ServerResponse} res
|
|
150
|
+
* @param {number} status
|
|
151
|
+
* @param {object} body
|
|
152
|
+
*/
|
|
153
|
+
function sendJson(res, status, body) {
|
|
154
|
+
const payload = JSON.stringify(body);
|
|
155
|
+
res.writeHead(status, {
|
|
156
|
+
"Content-Type": "application/json",
|
|
157
|
+
"Access-Control-Allow-Origin": "http://localhost",
|
|
158
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
159
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
160
|
+
"Cache-Control": "no-store",
|
|
161
|
+
});
|
|
162
|
+
res.end(payload);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Extract a task ID from a URL pathname like /api/tasks/:id/...
|
|
167
|
+
* @param {string} pathname
|
|
168
|
+
* @returns {string|null}
|
|
169
|
+
*/
|
|
170
|
+
function extractTaskId(pathname) {
|
|
171
|
+
const match = pathname.match(/^\/api\/tasks\/([^/]+)/);
|
|
172
|
+
return match ? match[1] : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function isAlreadyExitedProcessError(err) {
|
|
176
|
+
const detail = [err?.stderr, err?.stdout, err?.message]
|
|
177
|
+
.map((part) => String(part || ""))
|
|
178
|
+
.join("\n")
|
|
179
|
+
.toLowerCase();
|
|
180
|
+
return (
|
|
181
|
+
detail.includes("no running instance of the task") ||
|
|
182
|
+
detail.includes("no running instance") ||
|
|
183
|
+
detail.includes("no such process") ||
|
|
184
|
+
detail.includes("cannot find the process") ||
|
|
185
|
+
detail.includes("not found") ||
|
|
186
|
+
detail.includes("esrch")
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function normalizeCommandLine(commandLine) {
|
|
191
|
+
return String(commandLine || "").toLowerCase().replace(/\\/g, "/").trim();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isLikelyBosunCommandLine(commandLine) {
|
|
195
|
+
const normalized = normalizeCommandLine(commandLine);
|
|
196
|
+
if (!normalized) return false;
|
|
197
|
+
|
|
198
|
+
if (normalized.includes(BOSUN_ROOT_HINT)) return true;
|
|
199
|
+
|
|
200
|
+
if (
|
|
201
|
+
normalized.includes("/bosun/") &&
|
|
202
|
+
(normalized.includes("monitor.mjs") ||
|
|
203
|
+
normalized.includes("cli.mjs") ||
|
|
204
|
+
normalized.includes("agent-endpoint.mjs") ||
|
|
205
|
+
normalized.includes("ve-orchestrator"))
|
|
206
|
+
) {
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Dev-mode often launches monitor as node monitor.mjs from bosun root.
|
|
211
|
+
if (/\bnode(?:\.exe)?\b/.test(normalized) && /\bmonitor\.mjs\b/.test(normalized)) {
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function summarizeCommandLine(commandLine, maxLen = 140) {
|
|
219
|
+
const compact = String(commandLine || "").replace(/\s+/g, " ").trim();
|
|
220
|
+
if (!compact) return "command line unavailable";
|
|
221
|
+
if (compact.length <= maxLen) return compact;
|
|
222
|
+
return compact.slice(0, maxLen) + "...";
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── AgentEndpoint Class ─────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
export class AgentEndpoint {
|
|
228
|
+
/**
|
|
229
|
+
* @param {object} options
|
|
230
|
+
* @param {number} [options.port] — Listen port (default: env or 18432)
|
|
231
|
+
* @param {boolean} [options.allowConflictKill] — Allow forced cleanup of conflicting listeners
|
|
232
|
+
* @param {object} [options.taskStore] — Task store instance (kanban adapter)
|
|
233
|
+
* @param {Function} [options.onTaskComplete] — (taskId, data) => void
|
|
234
|
+
* @param {Function} [options.onTaskError] — (taskId, data) => void
|
|
235
|
+
* @param {Function} [options.onStatusChange] — (taskId, newStatus, source) => void
|
|
236
|
+
*/
|
|
237
|
+
constructor(options = {}) {
|
|
238
|
+
const configuredPort =
|
|
239
|
+
options.port ??
|
|
240
|
+
(process.env.AGENT_ENDPOINT_PORT != null
|
|
241
|
+
? Number(process.env.AGENT_ENDPOINT_PORT)
|
|
242
|
+
: process.env.BOSUN_AGENT_ENDPOINT_PORT != null
|
|
243
|
+
? Number(process.env.BOSUN_AGENT_ENDPOINT_PORT)
|
|
244
|
+
: DEFAULT_PORT);
|
|
245
|
+
this._port =
|
|
246
|
+
Number.isInteger(configuredPort) && configuredPort > 0 && configuredPort <= 65535
|
|
247
|
+
? configuredPort
|
|
248
|
+
: DEFAULT_PORT;
|
|
249
|
+
this._allowConflictKill = options.allowConflictKill === true;
|
|
250
|
+
this._taskStore = options.taskStore || null;
|
|
251
|
+
this._onTaskComplete = options.onTaskComplete || null;
|
|
252
|
+
this._onTaskError = options.onTaskError || null;
|
|
253
|
+
this._onStatusChange = options.onStatusChange || null;
|
|
254
|
+
this._onPauseTasks = options.onPauseTasks || null;
|
|
255
|
+
this._onResumeTasks = options.onResumeTasks || null;
|
|
256
|
+
this._getExecutorStatus = options.getExecutorStatus || null;
|
|
257
|
+
this._server = null;
|
|
258
|
+
this._running = false;
|
|
259
|
+
this._startedAt = null;
|
|
260
|
+
this._portFilePath = resolve(__dirname, "..", ".cache", "agent-endpoint-port");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ── Lifecycle ───────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Start the HTTP server.
|
|
267
|
+
* @returns {Promise<void>}
|
|
268
|
+
*/
|
|
269
|
+
async start() {
|
|
270
|
+
if (this._running) return;
|
|
271
|
+
|
|
272
|
+
const MAX_PORT_RETRIES = 5;
|
|
273
|
+
let lastErr;
|
|
274
|
+
pruneAccessDeniedCache();
|
|
275
|
+
|
|
276
|
+
for (let attempt = 0; attempt < MAX_PORT_RETRIES; attempt++) {
|
|
277
|
+
const port = this._port + attempt;
|
|
278
|
+
try {
|
|
279
|
+
await this._tryListen(port);
|
|
280
|
+
this._port = port; // update in case we incremented
|
|
281
|
+
return;
|
|
282
|
+
} catch (err) {
|
|
283
|
+
lastErr = err;
|
|
284
|
+
if (err.code === "EADDRINUSE") {
|
|
285
|
+
if (!this._allowConflictKill) {
|
|
286
|
+
console.warn(
|
|
287
|
+
`${TAG} Port ${port} in use (attempt ${attempt + 1}/${MAX_PORT_RETRIES}), skipping forced kill and trying next port`,
|
|
288
|
+
);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const deniedAt = accessDeniedPorts.get(port);
|
|
292
|
+
const nowMs = Date.now();
|
|
293
|
+
if (deniedAt && nowMs - deniedAt < ACCESS_DENIED_COOLDOWN_MS) {
|
|
294
|
+
if (shouldLogAccessDeniedCooldown(port, nowMs)) {
|
|
295
|
+
console.warn(
|
|
296
|
+
`${TAG} Port ${port} in use and kill blocked (access denied). Skipping retry during cooldown.`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
console.warn(
|
|
302
|
+
`${TAG} Port ${port} in use (attempt ${attempt + 1}/${MAX_PORT_RETRIES}), trying to free it...`,
|
|
303
|
+
);
|
|
304
|
+
// Try to kill the process holding the port (Windows)
|
|
305
|
+
await this._killProcessOnPort(port);
|
|
306
|
+
// Retry same port once after kill
|
|
307
|
+
try {
|
|
308
|
+
await this._tryListen(port);
|
|
309
|
+
this._port = port;
|
|
310
|
+
return;
|
|
311
|
+
} catch (retryErr) {
|
|
312
|
+
if (retryErr.code === "EADDRINUSE") {
|
|
313
|
+
console.warn(
|
|
314
|
+
`${TAG} Port ${port} still in use after kill, trying next port`,
|
|
315
|
+
);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
throw retryErr;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
throw err;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// All retries exhausted — start without endpoint (non-fatal)
|
|
326
|
+
console.error(
|
|
327
|
+
`${TAG} Could not bind to any port after ${MAX_PORT_RETRIES} attempts: ${lastErr?.message}`,
|
|
328
|
+
);
|
|
329
|
+
console.warn(
|
|
330
|
+
`${TAG} Running WITHOUT agent endpoint — agents can still work via poll-based completion`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Attempt to listen on a specific port. Returns a promise.
|
|
336
|
+
* @param {number} port
|
|
337
|
+
* @returns {Promise<void>}
|
|
338
|
+
*/
|
|
339
|
+
_tryListen(port) {
|
|
340
|
+
return new Promise((resolveStart, rejectStart) => {
|
|
341
|
+
const server = createServer((req, res) => this._handleRequest(req, res));
|
|
342
|
+
server.setTimeout(REQUEST_TIMEOUT_MS);
|
|
343
|
+
|
|
344
|
+
server.on("timeout", (socket) => {
|
|
345
|
+
console.log(`${TAG} Request timed out, destroying socket`);
|
|
346
|
+
socket.destroy();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
server.on("error", (err) => {
|
|
350
|
+
if (!this._running) {
|
|
351
|
+
rejectStart(err);
|
|
352
|
+
} else {
|
|
353
|
+
console.error(`${TAG} Server error:`, err.message);
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
server.listen(port, "127.0.0.1", () => {
|
|
358
|
+
this._server = server;
|
|
359
|
+
this._running = true;
|
|
360
|
+
this._startedAt = Date.now();
|
|
361
|
+
console.log(`${TAG} Listening on 127.0.0.1:${port}`);
|
|
362
|
+
this._writePortFile();
|
|
363
|
+
resolveStart();
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Attempt to kill whatever process is holding a port (Windows netstat+taskkill).
|
|
370
|
+
* @param {number} port
|
|
371
|
+
* @returns {Promise<void>}
|
|
372
|
+
*/
|
|
373
|
+
async _killProcessOnPort(port) {
|
|
374
|
+
try {
|
|
375
|
+
const { spawnSync } = await import("node:child_process");
|
|
376
|
+
const portNumber = Number.parseInt(String(port), 10);
|
|
377
|
+
if (!Number.isInteger(portNumber) || portNumber <= 0 || portNumber > 65535) {
|
|
378
|
+
throw new Error(`invalid port: ${port}`);
|
|
379
|
+
}
|
|
380
|
+
const isWindows = process.platform === "win32";
|
|
381
|
+
let output;
|
|
382
|
+
const pids = new Set();
|
|
383
|
+
|
|
384
|
+
// PIDs we must NEVER kill — ourselves, our parent (cli.mjs fork host),
|
|
385
|
+
// and any ancestor in the same process tree. lsof can return these when
|
|
386
|
+
// the listening socket fd is inherited across fork().
|
|
387
|
+
const protectedPids = new Set([
|
|
388
|
+
String(process.pid),
|
|
389
|
+
String(process.ppid),
|
|
390
|
+
]);
|
|
391
|
+
|
|
392
|
+
const readProcessCommandLine = (pid) => {
|
|
393
|
+
try {
|
|
394
|
+
if (isWindows) {
|
|
395
|
+
const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}" -ErrorAction SilentlyContinue; if ($p) { $p.CommandLine }`;
|
|
396
|
+
const result = spawnSync(
|
|
397
|
+
"powershell",
|
|
398
|
+
["-NoProfile", "-Command", query],
|
|
399
|
+
{
|
|
400
|
+
encoding: "utf8",
|
|
401
|
+
timeout: 5000,
|
|
402
|
+
windowsHide: true,
|
|
403
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
404
|
+
},
|
|
405
|
+
);
|
|
406
|
+
if (result.error || result.status !== 0) return "";
|
|
407
|
+
return String(result.stdout || "").trim();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const result = spawnSync("ps", ["-p", String(pid), "-o", "args="], {
|
|
411
|
+
encoding: "utf8",
|
|
412
|
+
timeout: 5000,
|
|
413
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
414
|
+
});
|
|
415
|
+
if (result.error || result.status !== 0) return "";
|
|
416
|
+
return String(result.stdout || "").trim();
|
|
417
|
+
} catch {
|
|
418
|
+
return "";
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
if (isWindows) {
|
|
423
|
+
// Windows: netstat -ano then filter in-process to avoid shell command injection.
|
|
424
|
+
const netstatRes = spawnSync("netstat", ["-ano"], {
|
|
425
|
+
encoding: "utf8",
|
|
426
|
+
timeout: 5000,
|
|
427
|
+
windowsHide: true,
|
|
428
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
429
|
+
});
|
|
430
|
+
if (netstatRes.error) throw netstatRes.error;
|
|
431
|
+
if (netstatRes.status !== 0) {
|
|
432
|
+
throw new Error(
|
|
433
|
+
String(
|
|
434
|
+
netstatRes.stderr ||
|
|
435
|
+
netstatRes.stdout ||
|
|
436
|
+
`netstat exited with status ${netstatRes.status}`,
|
|
437
|
+
).trim(),
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
output = String(netstatRes.stdout || "").trim();
|
|
441
|
+
const lines = output
|
|
442
|
+
.split("\n")
|
|
443
|
+
.filter(
|
|
444
|
+
(line) => line.includes("LISTENING") && line.includes(`:${portNumber}`),
|
|
445
|
+
);
|
|
446
|
+
for (const line of lines) {
|
|
447
|
+
const parts = line.trim().split(/\s+/);
|
|
448
|
+
const pid = parts[parts.length - 1];
|
|
449
|
+
if (pid && /^\d+$/.test(pid) && !protectedPids.has(pid)) {
|
|
450
|
+
pids.add(pid);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
} else {
|
|
454
|
+
// Linux/macOS: lsof -i
|
|
455
|
+
const lsofRes = spawnSync("lsof", ["-ti", `:${portNumber}`], {
|
|
456
|
+
encoding: "utf8",
|
|
457
|
+
timeout: 5000,
|
|
458
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
459
|
+
});
|
|
460
|
+
if (lsofRes.error) throw lsofRes.error;
|
|
461
|
+
// lsof returns exit code 1 when no processes found (port is free)
|
|
462
|
+
if (lsofRes.status === 1) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (lsofRes.status !== 0) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
String(
|
|
468
|
+
lsofRes.stderr ||
|
|
469
|
+
lsofRes.stdout ||
|
|
470
|
+
`lsof exited with status ${lsofRes.status}`,
|
|
471
|
+
).trim(),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
output = String(lsofRes.stdout || "").trim();
|
|
475
|
+
const pidList = output.split("\n").filter((pid) => pid.trim());
|
|
476
|
+
for (const pid of pidList) {
|
|
477
|
+
if (pid && /^\d+$/.test(pid) && !protectedPids.has(pid)) {
|
|
478
|
+
pids.add(pid);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (pidList.length > 0 && pids.size === 0) {
|
|
482
|
+
console.log(
|
|
483
|
+
`${TAG} Port ${portNumber} held by own process tree (PIDs: ${pidList.join(", ")}) — skipping kill`,
|
|
484
|
+
);
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const killEligiblePids = new Set();
|
|
490
|
+
for (const pid of pids) {
|
|
491
|
+
const commandLine = readProcessCommandLine(pid);
|
|
492
|
+
if (!isLikelyBosunCommandLine(commandLine)) {
|
|
493
|
+
console.warn(
|
|
494
|
+
`${TAG} Port ${port} held by non-bosun PID ${pid} (${summarizeCommandLine(commandLine)}); skipping forced kill`,
|
|
495
|
+
);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
killEligiblePids.add(pid);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (killEligiblePids.size === 0) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
for (const pid of killEligiblePids) {
|
|
506
|
+
console.log(`${TAG} Sending SIGTERM to stale process PID ${pid} on port ${port}`);
|
|
507
|
+
try {
|
|
508
|
+
if (isWindows) {
|
|
509
|
+
const killRes = spawnSync(
|
|
510
|
+
"taskkill",
|
|
511
|
+
["/F", "/PID", String(pid)],
|
|
512
|
+
{
|
|
513
|
+
encoding: "utf8",
|
|
514
|
+
timeout: 5000,
|
|
515
|
+
windowsHide: true,
|
|
516
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
517
|
+
},
|
|
518
|
+
);
|
|
519
|
+
if (killRes.error) {
|
|
520
|
+
throw killRes.error;
|
|
521
|
+
}
|
|
522
|
+
if (killRes.status !== 0) {
|
|
523
|
+
const err = new Error(
|
|
524
|
+
String(
|
|
525
|
+
killRes.stderr ||
|
|
526
|
+
killRes.stdout ||
|
|
527
|
+
`taskkill exited with status ${killRes.status}`,
|
|
528
|
+
).trim(),
|
|
529
|
+
);
|
|
530
|
+
err.stderr = killRes.stderr;
|
|
531
|
+
err.stdout = killRes.stdout;
|
|
532
|
+
throw err;
|
|
533
|
+
}
|
|
534
|
+
} else {
|
|
535
|
+
// Graceful SIGTERM first — only escalate to SIGKILL if still alive
|
|
536
|
+
process.kill(Number(pid), "SIGTERM");
|
|
537
|
+
}
|
|
538
|
+
} catch (killErr) {
|
|
539
|
+
/* may already be dead — log for diagnostics */
|
|
540
|
+
const stderrText = String(killErr.stderr || killErr.message || "");
|
|
541
|
+
const stderrLower = stderrText.toLowerCase();
|
|
542
|
+
if (isAlreadyExitedProcessError(killErr)) {
|
|
543
|
+
console.log(
|
|
544
|
+
`${TAG} PID ${pid} already exited before kill on port ${port}; continuing`,
|
|
545
|
+
);
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (
|
|
549
|
+
stderrLower.includes("access is denied") ||
|
|
550
|
+
stderrLower.includes("operation not permitted")
|
|
551
|
+
) {
|
|
552
|
+
accessDeniedPorts.set(port, Date.now());
|
|
553
|
+
accessDeniedCooldownWarnAt.delete(port);
|
|
554
|
+
persistAccessDeniedCache();
|
|
555
|
+
}
|
|
556
|
+
console.warn(
|
|
557
|
+
`${TAG} kill PID ${pid} failed: ${killErr.stderr?.trim() || killErr.message || "unknown error"}`,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Give the SIGTERM'd processes time to exit gracefully
|
|
563
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
564
|
+
|
|
565
|
+
// Escalate: check if any are still alive and SIGKILL them
|
|
566
|
+
if (!isWindows) {
|
|
567
|
+
for (const pid of killEligiblePids) {
|
|
568
|
+
try {
|
|
569
|
+
process.kill(Number(pid), 0); // probe — throws if dead
|
|
570
|
+
console.warn(`${TAG} PID ${pid} still alive after SIGTERM — sending SIGKILL`);
|
|
571
|
+
process.kill(Number(pid), "SIGKILL");
|
|
572
|
+
} catch {
|
|
573
|
+
/* already dead — good */
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
577
|
+
}
|
|
578
|
+
} catch (outerErr) {
|
|
579
|
+
// Commands may fail if port already free
|
|
580
|
+
if (outerErr.status !== 1) {
|
|
581
|
+
// status 1 = no matching entries (port already free)
|
|
582
|
+
console.warn(
|
|
583
|
+
`${TAG} _killProcessOnPort(${port}) failed: ${outerErr.message || "unknown error"}`,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Stop the HTTP server.
|
|
590
|
+
* @returns {Promise<void>}
|
|
591
|
+
*/
|
|
592
|
+
stop() {
|
|
593
|
+
if (!this._running || !this._server) return Promise.resolve();
|
|
594
|
+
|
|
595
|
+
return new Promise((resolveStop) => {
|
|
596
|
+
this._running = false;
|
|
597
|
+
this._removePortFile();
|
|
598
|
+
this._server.close(() => {
|
|
599
|
+
console.log(`${TAG} Server stopped`);
|
|
600
|
+
resolveStop();
|
|
601
|
+
});
|
|
602
|
+
// Force-close lingering connections after 5s
|
|
603
|
+
setTimeout(() => {
|
|
604
|
+
resolveStop();
|
|
605
|
+
}, 5000);
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** @returns {number} */
|
|
610
|
+
getPort() {
|
|
611
|
+
return this._port;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** @returns {boolean} */
|
|
615
|
+
isRunning() {
|
|
616
|
+
return this._running;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Lightweight status for diagnostics (/agents).
|
|
621
|
+
* @returns {{ running: boolean, port: number, startedAt: number|null, uptimeMs: number }}
|
|
622
|
+
*/
|
|
623
|
+
getStatus() {
|
|
624
|
+
return {
|
|
625
|
+
running: this._running,
|
|
626
|
+
port: this._port,
|
|
627
|
+
startedAt: this._startedAt || null,
|
|
628
|
+
uptimeMs:
|
|
629
|
+
this._running && this._startedAt
|
|
630
|
+
? Math.max(0, Date.now() - this._startedAt)
|
|
631
|
+
: 0,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ── Port Discovery File ─────────────────────────────────────────────────
|
|
636
|
+
|
|
637
|
+
_writePortFile() {
|
|
638
|
+
try {
|
|
639
|
+
const dir = dirname(this._portFilePath);
|
|
640
|
+
mkdirSync(dir, { recursive: true });
|
|
641
|
+
writeFileSync(this._portFilePath, String(this._port));
|
|
642
|
+
console.log(
|
|
643
|
+
`${TAG} Port file written: ${this._portFilePath} → ${this._port}`,
|
|
644
|
+
);
|
|
645
|
+
} catch (err) {
|
|
646
|
+
console.error(`${TAG} Failed to write port file:`, err.message);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
_removePortFile() {
|
|
651
|
+
try {
|
|
652
|
+
unlinkSync(this._portFilePath);
|
|
653
|
+
} catch {
|
|
654
|
+
// Ignore — file may already be gone
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ── Request Router ──────────────────────────────────────────────────────
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* @param {import("node:http").IncomingMessage} req
|
|
662
|
+
* @param {import("node:http").ServerResponse} res
|
|
663
|
+
*/
|
|
664
|
+
async _handleRequest(req, res) {
|
|
665
|
+
// Handle CORS preflight
|
|
666
|
+
if (req.method === "OPTIONS") {
|
|
667
|
+
sendJson(res, 204, {});
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
672
|
+
const pathname = url.pathname;
|
|
673
|
+
const method = req.method;
|
|
674
|
+
|
|
675
|
+
try {
|
|
676
|
+
// ── Static routes ───────────────────────────────────────────────
|
|
677
|
+
if (method === "GET" && pathname === "/health") {
|
|
678
|
+
return this._handleHealth(res);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (method === "GET" && pathname === "/api/status") {
|
|
682
|
+
return this._handleStatus(res);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (method === "GET" && pathname === "/api/tasks") {
|
|
686
|
+
return await this._handleListTasks(url, res);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (method === "POST" && pathname === "/api/tasks/create") {
|
|
690
|
+
const body = await parseBody(req);
|
|
691
|
+
return await this._handleCreateTask(body, res);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (method === "GET" && pathname === "/api/tasks/stats") {
|
|
695
|
+
return await this._handleTaskStats(res);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (method === "POST" && pathname === "/api/tasks/import") {
|
|
699
|
+
const body = await parseBody(req);
|
|
700
|
+
return await this._handleImportTasks(body, res);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
if (method === "GET" && pathname === "/api/executor") {
|
|
704
|
+
return this._handleExecutorStatus(res);
|
|
705
|
+
}
|
|
706
|
+
if (method === "POST" && pathname === "/api/executor/pause") {
|
|
707
|
+
return await this._handlePauseTasks(res);
|
|
708
|
+
}
|
|
709
|
+
if (method === "POST" && pathname === "/api/executor/resume") {
|
|
710
|
+
return await this._handleResumeTasks(res);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ── Task-specific routes ────────────────────────────────────────
|
|
714
|
+
const taskId = extractTaskId(pathname);
|
|
715
|
+
|
|
716
|
+
if (taskId) {
|
|
717
|
+
if (method === "GET" && pathname === `/api/tasks/${taskId}`) {
|
|
718
|
+
return await this._handleGetTask(taskId, res);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
if (method === "POST" && pathname === `/api/tasks/${taskId}/status`) {
|
|
722
|
+
const body = await parseBody(req);
|
|
723
|
+
return await this._handleStatusChange(taskId, body, res);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (
|
|
727
|
+
method === "POST" &&
|
|
728
|
+
pathname === `/api/tasks/${taskId}/heartbeat`
|
|
729
|
+
) {
|
|
730
|
+
const body = await parseBody(req);
|
|
731
|
+
return await this._handleHeartbeat(taskId, body, res);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (method === "POST" && pathname === `/api/tasks/${taskId}/complete`) {
|
|
735
|
+
const body = await parseBody(req);
|
|
736
|
+
return await this._handleComplete(taskId, body, res);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (method === "POST" && pathname === `/api/tasks/${taskId}/error`) {
|
|
740
|
+
const body = await parseBody(req);
|
|
741
|
+
return await this._handleError(taskId, body, res);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (method === "POST" && pathname === `/api/tasks/${taskId}/update`) {
|
|
745
|
+
const body = await parseBody(req);
|
|
746
|
+
return await this._handleUpdateTask(taskId, body, res);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (method === "DELETE" && pathname === `/api/tasks/${taskId}`) {
|
|
750
|
+
return await this._handleDeleteTask(taskId, res);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// ── 404 ─────────────────────────────────────────────────────────
|
|
755
|
+
sendJson(res, 404, { error: "Not found", path: pathname });
|
|
756
|
+
} catch (err) {
|
|
757
|
+
console.error(`${TAG} ${method} ${pathname} error:`, err.message);
|
|
758
|
+
sendJson(res, 500, { error: err.message || "Internal server error" });
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// ── Route Handlers ──────────────────────────────────────────────────────
|
|
763
|
+
|
|
764
|
+
_handleHealth(res) {
|
|
765
|
+
const uptimeSeconds =
|
|
766
|
+
this._startedAt != null
|
|
767
|
+
? Math.floor((Date.now() - this._startedAt) / 1000)
|
|
768
|
+
: 0;
|
|
769
|
+
sendJson(res, 200, { ok: true, uptime: uptimeSeconds });
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
_handleStatus(res) {
|
|
773
|
+
const uptimeSeconds =
|
|
774
|
+
this._startedAt != null
|
|
775
|
+
? Math.floor((Date.now() - this._startedAt) / 1000)
|
|
776
|
+
: 0;
|
|
777
|
+
const storeStats = this._taskStore
|
|
778
|
+
? { connected: true }
|
|
779
|
+
: { connected: false };
|
|
780
|
+
|
|
781
|
+
sendJson(res, 200, {
|
|
782
|
+
executor: { running: this._running, port: this._port },
|
|
783
|
+
store: storeStats,
|
|
784
|
+
uptime: uptimeSeconds,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
async _handleListTasks(url, res) {
|
|
789
|
+
if (!this._taskStore) {
|
|
790
|
+
sendJson(res, 503, { error: "Task store not configured" });
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const statusFilter = url.searchParams.get("status") || undefined;
|
|
795
|
+
|
|
796
|
+
try {
|
|
797
|
+
let tasks;
|
|
798
|
+
if (typeof this._taskStore.listTasks === "function") {
|
|
799
|
+
// kanban adapter-style store
|
|
800
|
+
tasks = await this._taskStore.listTasks(null, { status: statusFilter });
|
|
801
|
+
} else if (typeof this._taskStore.list === "function") {
|
|
802
|
+
tasks = await this._taskStore.list({ status: statusFilter });
|
|
803
|
+
} else {
|
|
804
|
+
sendJson(res, 501, { error: "Task store does not support listing" });
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const taskList = Array.isArray(tasks) ? tasks : [];
|
|
809
|
+
sendJson(res, 200, { tasks: taskList, count: taskList.length });
|
|
810
|
+
} catch (err) {
|
|
811
|
+
console.error(`${TAG} listTasks error:`, err.message);
|
|
812
|
+
sendJson(res, 500, { error: `Failed to list tasks: ${err.message}` });
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async _handleGetTask(taskId, res) {
|
|
817
|
+
if (!this._taskStore) {
|
|
818
|
+
sendJson(res, 503, { error: "Task store not configured" });
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
try {
|
|
823
|
+
let task;
|
|
824
|
+
if (typeof this._taskStore.getTask === "function") {
|
|
825
|
+
task = await this._taskStore.getTask(taskId);
|
|
826
|
+
} else if (typeof this._taskStore.get === "function") {
|
|
827
|
+
task = await this._taskStore.get(taskId);
|
|
828
|
+
} else {
|
|
829
|
+
sendJson(res, 501, { error: "Task store does not support get" });
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (!task) {
|
|
834
|
+
sendJson(res, 404, { error: "Task not found" });
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
sendJson(res, 200, { task });
|
|
839
|
+
} catch (err) {
|
|
840
|
+
console.error(`${TAG} getTask(${taskId}) error:`, err.message);
|
|
841
|
+
sendJson(res, 404, { error: "Task not found" });
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
_handleExecutorStatus(res) {
|
|
846
|
+
if (typeof this._getExecutorStatus !== "function") {
|
|
847
|
+
sendJson(res, 503, { error: "Executor status provider not configured" });
|
|
848
|
+
return;
|
|
849
|
+
}
|
|
850
|
+
try {
|
|
851
|
+
const status = this._getExecutorStatus() || {};
|
|
852
|
+
sendJson(res, 200, { ok: true, status });
|
|
853
|
+
} catch (err) {
|
|
854
|
+
sendJson(res, 500, {
|
|
855
|
+
error: `Failed to get executor status: ${err.message}`,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async _handlePauseTasks(res) {
|
|
861
|
+
if (typeof this._onPauseTasks !== "function") {
|
|
862
|
+
sendJson(res, 503, { error: "Pause control not configured" });
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
try {
|
|
866
|
+
const result = await this._onPauseTasks();
|
|
867
|
+
sendJson(res, 200, { ok: true, result: result ?? { paused: true } });
|
|
868
|
+
} catch (err) {
|
|
869
|
+
sendJson(res, 500, { error: `Failed to pause tasks: ${err.message}` });
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
async _handleResumeTasks(res) {
|
|
874
|
+
if (typeof this._onResumeTasks !== "function") {
|
|
875
|
+
sendJson(res, 503, { error: "Resume control not configured" });
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
try {
|
|
879
|
+
const result = await this._onResumeTasks();
|
|
880
|
+
sendJson(res, 200, { ok: true, result: result ?? { paused: false } });
|
|
881
|
+
} catch (err) {
|
|
882
|
+
sendJson(res, 500, { error: `Failed to resume tasks: ${err.message}` });
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
async _handleStatusChange(taskId, body, res) {
|
|
887
|
+
if (!this._taskStore) {
|
|
888
|
+
sendJson(res, 503, { error: "Task store not configured" });
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const { status, message } = body;
|
|
893
|
+
if (!status) {
|
|
894
|
+
sendJson(res, 400, { error: "Missing 'status' in body" });
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const allowed = ["inreview", "done", "blocked"];
|
|
899
|
+
if (!allowed.includes(status)) {
|
|
900
|
+
sendJson(res, 400, {
|
|
901
|
+
error: `Invalid status '${status}'. Allowed: ${allowed.join(", ")}`,
|
|
902
|
+
});
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Validate transition
|
|
907
|
+
try {
|
|
908
|
+
let currentTask;
|
|
909
|
+
if (typeof this._taskStore.getTask === "function") {
|
|
910
|
+
currentTask = await this._taskStore.getTask(taskId);
|
|
911
|
+
} else if (typeof this._taskStore.get === "function") {
|
|
912
|
+
currentTask = await this._taskStore.get(taskId);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (currentTask) {
|
|
916
|
+
const currentStatus = currentTask.status || "unknown";
|
|
917
|
+
const validNext = VALID_TRANSITIONS[currentStatus];
|
|
918
|
+
if (validNext && !validNext.includes(status)) {
|
|
919
|
+
sendJson(res, 409, {
|
|
920
|
+
error: `Invalid transition: ${currentStatus} → ${status}. Allowed: ${validNext.join(", ")}`,
|
|
921
|
+
});
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
} catch {
|
|
926
|
+
// If we can't fetch current task, proceed anyway
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
try {
|
|
930
|
+
let updatedTask;
|
|
931
|
+
if (typeof this._taskStore.updateTaskStatus === "function") {
|
|
932
|
+
updatedTask = await this._taskStore.updateTaskStatus(taskId, status);
|
|
933
|
+
} else if (typeof this._taskStore.update === "function") {
|
|
934
|
+
updatedTask = await this._taskStore.update(taskId, { status });
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
console.log(
|
|
938
|
+
`${TAG} Task ${taskId} status → ${status} (source=agent)${message ? ` msg="${message}"` : ""}`,
|
|
939
|
+
);
|
|
940
|
+
|
|
941
|
+
if (this._onStatusChange) {
|
|
942
|
+
try {
|
|
943
|
+
await this._onStatusChange(taskId, status, "agent");
|
|
944
|
+
} catch (err) {
|
|
945
|
+
console.error(`${TAG} onStatusChange callback error:`, err.message);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
sendJson(res, 200, {
|
|
950
|
+
ok: true,
|
|
951
|
+
task: updatedTask || { id: taskId, status },
|
|
952
|
+
});
|
|
953
|
+
} catch (err) {
|
|
954
|
+
console.error(`${TAG} statusChange(${taskId}) error:`, err.message);
|
|
955
|
+
sendJson(res, 500, { error: `Failed to update status: ${err.message}` });
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
async _handleHeartbeat(taskId, body, res) {
|
|
960
|
+
const timestamp = new Date().toISOString();
|
|
961
|
+
const { message } = body;
|
|
962
|
+
|
|
963
|
+
console.log(
|
|
964
|
+
`${TAG} Heartbeat from task ${taskId}${message ? `: ${message}` : ""}`,
|
|
965
|
+
);
|
|
966
|
+
|
|
967
|
+
// Try to update lastActivityAt on the task if the store supports it
|
|
968
|
+
if (this._taskStore) {
|
|
969
|
+
try {
|
|
970
|
+
if (typeof this._taskStore.update === "function") {
|
|
971
|
+
await this._taskStore.update(taskId, { lastActivityAt: timestamp });
|
|
972
|
+
} else if (typeof this._taskStore.updateTaskStatus === "function") {
|
|
973
|
+
// kanban adapter doesn't have a generic update, but heartbeat is still recorded
|
|
974
|
+
}
|
|
975
|
+
} catch {
|
|
976
|
+
// Non-critical — heartbeat is logged regardless
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
sendJson(res, 200, { ok: true, timestamp });
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async _handleComplete(taskId, body, res) {
|
|
984
|
+
const { hasCommits, branch, prUrl, output, prNumber } = body;
|
|
985
|
+
|
|
986
|
+
console.log(
|
|
987
|
+
`${TAG} Task ${taskId} complete: hasCommits=${!!hasCommits}, branch=${branch || "none"}, pr=${prUrl || "none"}`,
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
if (this._taskStore) {
|
|
991
|
+
try {
|
|
992
|
+
if (typeof this._taskStore.recordAgentAttempt === "function") {
|
|
993
|
+
await this._taskStore.recordAgentAttempt(taskId, {
|
|
994
|
+
output,
|
|
995
|
+
hasCommits: !!hasCommits,
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (typeof this._taskStore.update === "function") {
|
|
1000
|
+
const updates = {};
|
|
1001
|
+
if (branch) updates.branchName = branch;
|
|
1002
|
+
if (prUrl) updates.prUrl = prUrl;
|
|
1003
|
+
if (prNumber) updates.prNumber = prNumber;
|
|
1004
|
+
if (Object.keys(updates).length > 0) {
|
|
1005
|
+
await this._taskStore.update(taskId, updates);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
} catch (err) {
|
|
1009
|
+
console.warn(
|
|
1010
|
+
`${TAG} Failed to record completion details for ${taskId}: ${err.message || err}`,
|
|
1011
|
+
);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
let nextAction = "cooldown";
|
|
1016
|
+
|
|
1017
|
+
if (hasCommits) {
|
|
1018
|
+
nextAction = "review";
|
|
1019
|
+
|
|
1020
|
+
// Update task status to inreview
|
|
1021
|
+
if (this._taskStore) {
|
|
1022
|
+
try {
|
|
1023
|
+
if (typeof this._taskStore.updateTaskStatus === "function") {
|
|
1024
|
+
await this._taskStore.updateTaskStatus(taskId, "inreview");
|
|
1025
|
+
} else if (typeof this._taskStore.update === "function") {
|
|
1026
|
+
await this._taskStore.update(taskId, { status: "inreview" });
|
|
1027
|
+
}
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
console.error(
|
|
1030
|
+
`${TAG} Failed to set task ${taskId} to inreview:`,
|
|
1031
|
+
err.message,
|
|
1032
|
+
);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
} else {
|
|
1036
|
+
// No commits — record the attempt but don't change status
|
|
1037
|
+
console.log(`${TAG} Task ${taskId} completed with no commits`);
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
// Fire callback
|
|
1041
|
+
if (this._onTaskComplete) {
|
|
1042
|
+
try {
|
|
1043
|
+
await this._onTaskComplete(taskId, {
|
|
1044
|
+
hasCommits,
|
|
1045
|
+
branch,
|
|
1046
|
+
prUrl,
|
|
1047
|
+
output,
|
|
1048
|
+
});
|
|
1049
|
+
} catch (err) {
|
|
1050
|
+
console.error(`${TAG} onTaskComplete callback error:`, err.message);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
// Retrieve updated task for response
|
|
1055
|
+
let task = { id: taskId };
|
|
1056
|
+
if (this._taskStore) {
|
|
1057
|
+
try {
|
|
1058
|
+
if (typeof this._taskStore.getTask === "function") {
|
|
1059
|
+
task = (await this._taskStore.getTask(taskId)) || task;
|
|
1060
|
+
} else if (typeof this._taskStore.get === "function") {
|
|
1061
|
+
task = (await this._taskStore.get(taskId)) || task;
|
|
1062
|
+
}
|
|
1063
|
+
} catch {
|
|
1064
|
+
// Use fallback
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
sendJson(res, 200, { ok: true, task, nextAction });
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
// ── Task CRUD Handlers ──────────────────────────────────────────────────
|
|
1072
|
+
|
|
1073
|
+
async _handleCreateTask(body, res) {
|
|
1074
|
+
if (!this._taskStore) {
|
|
1075
|
+
sendJson(res, 503, { error: "Task store not configured" });
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const { title, description, status, priority, tags, baseBranch, base_branch, workspace, repository, repositories, implementation_steps, acceptance_criteria, verification, draft } = body;
|
|
1080
|
+
|
|
1081
|
+
if (!title) {
|
|
1082
|
+
sendJson(res, 400, { error: "Missing 'title' in body" });
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
try {
|
|
1087
|
+
// Build description from structured fields if provided
|
|
1088
|
+
let fullDescription = description || "";
|
|
1089
|
+
if (implementation_steps?.length || acceptance_criteria?.length || verification?.length) {
|
|
1090
|
+
const parts = [fullDescription];
|
|
1091
|
+
if (implementation_steps?.length) {
|
|
1092
|
+
parts.push("", "## Implementation Steps");
|
|
1093
|
+
for (const step of implementation_steps) parts.push(`- ${step}`);
|
|
1094
|
+
}
|
|
1095
|
+
if (acceptance_criteria?.length) {
|
|
1096
|
+
parts.push("", "## Acceptance Criteria");
|
|
1097
|
+
for (const c of acceptance_criteria) parts.push(`- ${c}`);
|
|
1098
|
+
}
|
|
1099
|
+
if (verification?.length) {
|
|
1100
|
+
parts.push("", "## Verification");
|
|
1101
|
+
for (const v of verification) parts.push(`- ${v}`);
|
|
1102
|
+
}
|
|
1103
|
+
fullDescription = parts.join("\n");
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
let task;
|
|
1107
|
+
if (typeof this._taskStore.createTask === "function") {
|
|
1108
|
+
// kanban adapter — handles ID generation
|
|
1109
|
+
task = await this._taskStore.createTask(null, {
|
|
1110
|
+
title,
|
|
1111
|
+
description: fullDescription,
|
|
1112
|
+
status: status || "draft",
|
|
1113
|
+
priority: priority || "medium",
|
|
1114
|
+
tags: tags || [],
|
|
1115
|
+
baseBranch: baseBranch || base_branch || "main",
|
|
1116
|
+
workspace: workspace || "",
|
|
1117
|
+
repository: repository || "",
|
|
1118
|
+
repositories: repositories || [],
|
|
1119
|
+
draft: draft ?? (status === "draft" || !status),
|
|
1120
|
+
});
|
|
1121
|
+
} else if (typeof this._taskStore.addTask === "function") {
|
|
1122
|
+
// raw task-store
|
|
1123
|
+
task = this._taskStore.addTask({
|
|
1124
|
+
id: randomUUID(),
|
|
1125
|
+
title,
|
|
1126
|
+
description: fullDescription,
|
|
1127
|
+
status: status || "draft",
|
|
1128
|
+
priority: priority || "medium",
|
|
1129
|
+
tags: tags || [],
|
|
1130
|
+
baseBranch: baseBranch || base_branch || "main",
|
|
1131
|
+
workspace: workspace || "",
|
|
1132
|
+
repository: repository || "",
|
|
1133
|
+
repositories: repositories || [],
|
|
1134
|
+
draft: draft ?? (status === "draft" || !status),
|
|
1135
|
+
});
|
|
1136
|
+
} else {
|
|
1137
|
+
sendJson(res, 501, { error: "Task store does not support creation" });
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (!task) {
|
|
1142
|
+
sendJson(res, 500, { error: "Failed to create task" });
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
console.log(`${TAG} Created task ${task.id}: ${task.title}`);
|
|
1147
|
+
sendJson(res, 201, { ok: true, task });
|
|
1148
|
+
} catch (err) {
|
|
1149
|
+
console.error(`${TAG} createTask error:`, err.message);
|
|
1150
|
+
sendJson(res, 500, { error: `Failed to create task: ${err.message}` });
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
async _handleUpdateTask(taskId, body, res) {
|
|
1155
|
+
if (!this._taskStore) {
|
|
1156
|
+
sendJson(res, 503, { error: "Task store not configured" });
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
try {
|
|
1161
|
+
// Verify task exists
|
|
1162
|
+
let existing = null;
|
|
1163
|
+
if (typeof this._taskStore.getTask === "function") {
|
|
1164
|
+
existing = await this._taskStore.getTask(taskId);
|
|
1165
|
+
} else if (typeof this._taskStore.get === "function") {
|
|
1166
|
+
existing = await this._taskStore.get(taskId);
|
|
1167
|
+
}
|
|
1168
|
+
if (!existing) {
|
|
1169
|
+
sendJson(res, 404, { error: "Task not found" });
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
const updates = { ...body };
|
|
1174
|
+
delete updates.id; // Never allow ID change
|
|
1175
|
+
|
|
1176
|
+
// Normalize base_branch → baseBranch
|
|
1177
|
+
if (updates.base_branch) {
|
|
1178
|
+
updates.baseBranch = updates.base_branch;
|
|
1179
|
+
delete updates.base_branch;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
// Handle status change with history tracking
|
|
1183
|
+
if (updates.status && updates.status !== existing.status) {
|
|
1184
|
+
if (typeof this._taskStore.updateTaskStatus === "function") {
|
|
1185
|
+
await this._taskStore.updateTaskStatus(taskId, updates.status);
|
|
1186
|
+
}
|
|
1187
|
+
delete updates.status;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
// Apply remaining updates
|
|
1191
|
+
let updatedTask;
|
|
1192
|
+
if (Object.keys(updates).length > 0) {
|
|
1193
|
+
if (typeof this._taskStore.updateTask === "function") {
|
|
1194
|
+
updatedTask = await this._taskStore.updateTask(taskId, updates);
|
|
1195
|
+
} else if (typeof this._taskStore.update === "function") {
|
|
1196
|
+
updatedTask = await this._taskStore.update(taskId, updates);
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Re-fetch to get final state
|
|
1201
|
+
if (typeof this._taskStore.getTask === "function") {
|
|
1202
|
+
updatedTask = await this._taskStore.getTask(taskId);
|
|
1203
|
+
} else if (typeof this._taskStore.get === "function") {
|
|
1204
|
+
updatedTask = await this._taskStore.get(taskId);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
console.log(`${TAG} Updated task ${taskId}`);
|
|
1208
|
+
sendJson(res, 200, { ok: true, task: updatedTask || { id: taskId } });
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
console.error(`${TAG} updateTask(${taskId}) error:`, err.message);
|
|
1211
|
+
sendJson(res, 500, { error: `Failed to update task: ${err.message}` });
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
async _handleDeleteTask(taskId, res) {
|
|
1216
|
+
if (!this._taskStore) {
|
|
1217
|
+
sendJson(res, 503, { error: "Task store not configured" });
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
try {
|
|
1222
|
+
let removed = false;
|
|
1223
|
+
if (typeof this._taskStore.deleteTask === "function") {
|
|
1224
|
+
await this._taskStore.deleteTask(taskId);
|
|
1225
|
+
removed = true;
|
|
1226
|
+
} else if (typeof this._taskStore.removeTask === "function") {
|
|
1227
|
+
removed = this._taskStore.removeTask(taskId);
|
|
1228
|
+
} else {
|
|
1229
|
+
sendJson(res, 501, { error: "Task store does not support deletion" });
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
if (!removed) {
|
|
1234
|
+
sendJson(res, 404, { error: "Task not found" });
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
console.log(`${TAG} Deleted task ${taskId}`);
|
|
1239
|
+
sendJson(res, 200, { ok: true, deleted: taskId });
|
|
1240
|
+
} catch (err) {
|
|
1241
|
+
console.error(`${TAG} deleteTask(${taskId}) error:`, err.message);
|
|
1242
|
+
sendJson(res, 500, { error: `Failed to delete task: ${err.message}` });
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
async _handleTaskStats(res) {
|
|
1247
|
+
if (!this._taskStore) {
|
|
1248
|
+
sendJson(res, 503, { error: "Task store not configured" });
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
try {
|
|
1253
|
+
let stats;
|
|
1254
|
+
if (typeof this._taskStore.getStats === "function") {
|
|
1255
|
+
stats = this._taskStore.getStats();
|
|
1256
|
+
} else {
|
|
1257
|
+
// Compute from list
|
|
1258
|
+
let tasks = [];
|
|
1259
|
+
if (typeof this._taskStore.listTasks === "function") {
|
|
1260
|
+
tasks = await this._taskStore.listTasks(null, {});
|
|
1261
|
+
} else if (typeof this._taskStore.list === "function") {
|
|
1262
|
+
tasks = await this._taskStore.list({});
|
|
1263
|
+
}
|
|
1264
|
+
const list = Array.isArray(tasks) ? tasks : [];
|
|
1265
|
+
stats = {
|
|
1266
|
+
draft: list.filter((t) => t.status === "draft").length,
|
|
1267
|
+
todo: list.filter((t) => t.status === "todo").length,
|
|
1268
|
+
inprogress: list.filter((t) => t.status === "inprogress").length,
|
|
1269
|
+
inreview: list.filter((t) => t.status === "inreview").length,
|
|
1270
|
+
done: list.filter((t) => t.status === "done").length,
|
|
1271
|
+
blocked: list.filter((t) => t.status === "blocked").length,
|
|
1272
|
+
total: list.length,
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
sendJson(res, 200, { ok: true, stats });
|
|
1277
|
+
} catch (err) {
|
|
1278
|
+
console.error(`${TAG} taskStats error:`, err.message);
|
|
1279
|
+
sendJson(res, 500, { error: `Failed to get stats: ${err.message}` });
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
async _handleImportTasks(body, res) {
|
|
1284
|
+
if (!this._taskStore) {
|
|
1285
|
+
sendJson(res, 503, { error: "Task store not configured" });
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const tasks = body.tasks || body.backlog || (Array.isArray(body) ? body : null);
|
|
1290
|
+
if (!tasks || !Array.isArray(tasks)) {
|
|
1291
|
+
sendJson(res, 400, { error: "Body must contain 'tasks' array" });
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const results = { created: [], failed: [] };
|
|
1296
|
+
for (const t of tasks) {
|
|
1297
|
+
try {
|
|
1298
|
+
// Recursively use our create handler logic
|
|
1299
|
+
let fullDescription = t.description || "";
|
|
1300
|
+
if (t.implementation_steps?.length || t.acceptance_criteria?.length || t.verification?.length) {
|
|
1301
|
+
const parts = [fullDescription];
|
|
1302
|
+
if (t.implementation_steps?.length) {
|
|
1303
|
+
parts.push("", "## Implementation Steps");
|
|
1304
|
+
for (const step of t.implementation_steps) parts.push(`- ${step}`);
|
|
1305
|
+
}
|
|
1306
|
+
if (t.acceptance_criteria?.length) {
|
|
1307
|
+
parts.push("", "## Acceptance Criteria");
|
|
1308
|
+
for (const c of t.acceptance_criteria) parts.push(`- ${c}`);
|
|
1309
|
+
}
|
|
1310
|
+
if (t.verification?.length) {
|
|
1311
|
+
parts.push("", "## Verification");
|
|
1312
|
+
for (const v of t.verification) parts.push(`- ${v}`);
|
|
1313
|
+
}
|
|
1314
|
+
fullDescription = parts.join("\n");
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
let task;
|
|
1318
|
+
if (typeof this._taskStore.createTask === "function") {
|
|
1319
|
+
task = await this._taskStore.createTask(null, {
|
|
1320
|
+
title: t.title,
|
|
1321
|
+
description: fullDescription,
|
|
1322
|
+
status: t.status || "draft",
|
|
1323
|
+
priority: t.priority || "medium",
|
|
1324
|
+
tags: t.tags || [],
|
|
1325
|
+
baseBranch: t.baseBranch || t.base_branch || "main",
|
|
1326
|
+
workspace: t.workspace || "",
|
|
1327
|
+
repository: t.repository || "",
|
|
1328
|
+
draft: t.draft ?? true,
|
|
1329
|
+
});
|
|
1330
|
+
} else if (typeof this._taskStore.addTask === "function") {
|
|
1331
|
+
task = this._taskStore.addTask({
|
|
1332
|
+
id: randomUUID(),
|
|
1333
|
+
title: t.title,
|
|
1334
|
+
description: fullDescription,
|
|
1335
|
+
status: t.status || "draft",
|
|
1336
|
+
priority: t.priority || "medium",
|
|
1337
|
+
tags: t.tags || [],
|
|
1338
|
+
baseBranch: t.baseBranch || t.base_branch || "main",
|
|
1339
|
+
workspace: t.workspace || "",
|
|
1340
|
+
repository: t.repository || "",
|
|
1341
|
+
draft: t.draft ?? true,
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
if (task) {
|
|
1346
|
+
results.created.push({ id: task.id, title: task.title });
|
|
1347
|
+
} else {
|
|
1348
|
+
results.failed.push({ title: t.title, error: "addTask returned null" });
|
|
1349
|
+
}
|
|
1350
|
+
} catch (err) {
|
|
1351
|
+
results.failed.push({ title: t.title || "untitled", error: err.message });
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
console.log(`${TAG} Import: ${results.created.length} created, ${results.failed.length} failed`);
|
|
1356
|
+
sendJson(res, 200, {
|
|
1357
|
+
ok: true,
|
|
1358
|
+
created: results.created.length,
|
|
1359
|
+
failed: results.failed.length,
|
|
1360
|
+
results,
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
async _handleError(taskId, body, res) {
|
|
1365
|
+
const { error: errorMsg, pattern, output } = body;
|
|
1366
|
+
|
|
1367
|
+
if (!errorMsg) {
|
|
1368
|
+
sendJson(res, 400, { error: "Missing 'error' in body" });
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
const validPatterns = [
|
|
1373
|
+
"plan_stuck",
|
|
1374
|
+
"rate_limit",
|
|
1375
|
+
"token_overflow",
|
|
1376
|
+
"api_error",
|
|
1377
|
+
];
|
|
1378
|
+
if (pattern && !validPatterns.includes(pattern)) {
|
|
1379
|
+
console.log(
|
|
1380
|
+
`${TAG} Task ${taskId} error with unknown pattern '${pattern}': ${errorMsg}`,
|
|
1381
|
+
);
|
|
1382
|
+
} else {
|
|
1383
|
+
console.log(
|
|
1384
|
+
`${TAG} Task ${taskId} error${pattern ? ` (${pattern})` : ""}: ${errorMsg}`,
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
if (this._taskStore) {
|
|
1389
|
+
try {
|
|
1390
|
+
if (typeof this._taskStore.recordAgentAttempt === "function") {
|
|
1391
|
+
await this._taskStore.recordAgentAttempt(taskId, {
|
|
1392
|
+
output,
|
|
1393
|
+
error: errorMsg,
|
|
1394
|
+
hasCommits: false,
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
if (
|
|
1398
|
+
pattern &&
|
|
1399
|
+
typeof this._taskStore.recordErrorPattern === "function"
|
|
1400
|
+
) {
|
|
1401
|
+
await this._taskStore.recordErrorPattern(taskId, pattern);
|
|
1402
|
+
}
|
|
1403
|
+
} catch (err) {
|
|
1404
|
+
console.warn(
|
|
1405
|
+
`${TAG} Failed to record error details for ${taskId}: ${err.message || err}`,
|
|
1406
|
+
);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Determine action based on pattern
|
|
1411
|
+
let action = "retry";
|
|
1412
|
+
if (pattern === "rate_limit") {
|
|
1413
|
+
action = "cooldown";
|
|
1414
|
+
} else if (pattern === "token_overflow") {
|
|
1415
|
+
action = "blocked";
|
|
1416
|
+
} else if (pattern === "plan_stuck") {
|
|
1417
|
+
action = "retry";
|
|
1418
|
+
} else if (pattern === "api_error") {
|
|
1419
|
+
action = "cooldown";
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
// Fire callback
|
|
1423
|
+
if (this._onTaskError) {
|
|
1424
|
+
try {
|
|
1425
|
+
await this._onTaskError(taskId, { error: errorMsg, pattern });
|
|
1426
|
+
} catch (err) {
|
|
1427
|
+
console.error(`${TAG} onTaskError callback error:`, err.message);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
sendJson(res, 200, { ok: true, action });
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
// ── Factory ─────────────────────────────────────────────────────────────────
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* Create an AgentEndpoint instance.
|
|
1439
|
+
* @param {object} [options] — Same as AgentEndpoint constructor
|
|
1440
|
+
* @returns {AgentEndpoint}
|
|
1441
|
+
*/
|
|
1442
|
+
export function createAgentEndpoint(options) {
|
|
1443
|
+
return new AgentEndpoint(options);
|
|
1444
|
+
}
|
|
1431
1445
|
|