fraim 2.0.170 → 2.0.173
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/src/ai-hub/hosts.js +227 -6
- package/dist/src/ai-hub/server.js +1014 -35
- package/dist/src/cli/commands/add-ide.js +4 -2
- package/dist/src/cli/commands/cleanup-artifacts.js +38 -0
- package/dist/src/cli/commands/init-project.js +12 -5
- package/dist/src/cli/commands/setup.js +1 -1
- package/dist/src/cli/commands/sync.js +74 -7
- package/dist/src/cli/doctor/checks/ide-config-checks.js +2 -2
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/mcp/ide-formats.js +10 -2
- package/dist/src/cli/setup/auto-mcp-setup.js +4 -2
- package/dist/src/cli/setup/ide-detector.js +26 -0
- package/dist/src/cli/setup/ide-global-integration.js +6 -2
- package/dist/src/cli/setup/ide-invocation-surfaces.js +12 -4
- package/dist/src/cli/setup/mcp-config-generator.js +12 -1
- package/dist/src/cli/utils/agent-adapters.js +42 -17
- package/dist/src/cli/utils/fraim-gitignore.js +13 -0
- package/dist/src/cli/utils/remote-sync.js +129 -53
- package/dist/src/cli/utils/user-config.js +12 -0
- package/dist/src/config/ai-manager-hiring.js +121 -0
- package/dist/src/config/compat.js +16 -0
- package/dist/src/config/feature-flags.js +25 -0
- package/dist/src/config/persona-capability-bundles.js +273 -0
- package/dist/src/config/persona-hiring.js +270 -0
- package/dist/src/config/portfolio-slug-overrides.js +17 -0
- package/dist/src/config/pricing.js +37 -0
- package/dist/src/config/stripe.js +43 -0
- package/dist/src/core/fraim-config-schema.generated.js +8 -2
- package/dist/src/core/utils/local-registry-resolver.js +26 -0
- package/dist/src/core/utils/project-fraim-paths.js +89 -2
- package/dist/src/first-run/session-service.js +12 -3
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +255 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +41 -81
- package/dist/src/local-mcp-server/stdio-server.js +42 -7
- package/package.json +5 -1
- package/public/ai-hub/index.html +205 -89
- package/public/ai-hub/review.css +12 -0
- package/public/ai-hub/script.js +1734 -253
- package/public/ai-hub/styles.css +473 -6
|
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
36
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
37
|
};
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.AiHubServer = void 0;
|
|
39
|
+
exports.AiHubServer = exports.HostConfigStore = exports.DeploymentStore = void 0;
|
|
40
40
|
exports.findAvailablePort = findAvailablePort;
|
|
41
41
|
exports.findAvailablePortExcluding = findAvailablePortExcluding;
|
|
42
42
|
const express_1 = __importDefault(require("express"));
|
|
@@ -166,6 +166,13 @@ class AiHubRunRegistry {
|
|
|
166
166
|
this.children.set(run.id, child);
|
|
167
167
|
return run;
|
|
168
168
|
}
|
|
169
|
+
attachChildIfRunning(runId, child) {
|
|
170
|
+
const run = this.runs.get(runId);
|
|
171
|
+
if (!run || run.status !== 'running')
|
|
172
|
+
return false;
|
|
173
|
+
this.children.set(runId, child);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
169
176
|
update(runId, updater) {
|
|
170
177
|
const run = this.runs.get(runId);
|
|
171
178
|
if (!run) {
|
|
@@ -227,6 +234,114 @@ function safeHttpUrl(value) {
|
|
|
227
234
|
return null;
|
|
228
235
|
}
|
|
229
236
|
}
|
|
237
|
+
// ─── Issue #578: Deployment + Host stores ─────────────────────────────────────
|
|
238
|
+
const VALID_EMPLOYEE_IDS = ['codex', 'claude', 'gemini', 'copilot'];
|
|
239
|
+
class DeploymentStore {
|
|
240
|
+
constructor(filePath) {
|
|
241
|
+
this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-deployments.json');
|
|
242
|
+
}
|
|
243
|
+
load() {
|
|
244
|
+
try {
|
|
245
|
+
if (!fs_1.default.existsSync(this.filePath))
|
|
246
|
+
return [];
|
|
247
|
+
return JSON.parse(fs_1.default.readFileSync(this.filePath, 'utf8'));
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
save(deployments) {
|
|
254
|
+
fs_1.default.mkdirSync(path_1.default.dirname(this.filePath), { recursive: true });
|
|
255
|
+
fs_1.default.writeFileSync(this.filePath, JSON.stringify(deployments, null, 2));
|
|
256
|
+
}
|
|
257
|
+
create(deployment) {
|
|
258
|
+
const list = this.load();
|
|
259
|
+
list.push(deployment);
|
|
260
|
+
this.save(list);
|
|
261
|
+
return deployment;
|
|
262
|
+
}
|
|
263
|
+
update(id, updater) {
|
|
264
|
+
const list = this.load();
|
|
265
|
+
const dep = list.find((d) => d.id === id);
|
|
266
|
+
if (!dep)
|
|
267
|
+
return false;
|
|
268
|
+
updater(dep);
|
|
269
|
+
dep.updatedAt = new Date().toISOString();
|
|
270
|
+
this.save(list);
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
delete(id) {
|
|
274
|
+
const list = this.load();
|
|
275
|
+
const next = list.filter((d) => d.id !== id);
|
|
276
|
+
if (next.length === list.length)
|
|
277
|
+
return false;
|
|
278
|
+
this.save(next);
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
exports.DeploymentStore = DeploymentStore;
|
|
283
|
+
class HostConfigStore {
|
|
284
|
+
constructor(filePath) {
|
|
285
|
+
this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-hosts.json');
|
|
286
|
+
}
|
|
287
|
+
load() {
|
|
288
|
+
try {
|
|
289
|
+
if (!fs_1.default.existsSync(this.filePath))
|
|
290
|
+
return [];
|
|
291
|
+
return JSON.parse(fs_1.default.readFileSync(this.filePath, 'utf8'));
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
save(hosts) {
|
|
298
|
+
fs_1.default.mkdirSync(path_1.default.dirname(this.filePath), { recursive: true });
|
|
299
|
+
fs_1.default.writeFileSync(this.filePath, JSON.stringify(hosts, null, 2));
|
|
300
|
+
}
|
|
301
|
+
add(host) {
|
|
302
|
+
const list = this.load();
|
|
303
|
+
list.push(host);
|
|
304
|
+
this.save(list);
|
|
305
|
+
return host;
|
|
306
|
+
}
|
|
307
|
+
delete(id) {
|
|
308
|
+
const list = this.load();
|
|
309
|
+
const next = list.filter((h) => h.id !== id);
|
|
310
|
+
if (next.length === list.length)
|
|
311
|
+
return false;
|
|
312
|
+
this.save(next);
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
exports.HostConfigStore = HostConfigStore;
|
|
317
|
+
async function pingHost(host) {
|
|
318
|
+
const start = Date.now();
|
|
319
|
+
try {
|
|
320
|
+
const resp = await fetch(`${host.url.replace(/\/$/, '')}/health`, {
|
|
321
|
+
signal: AbortSignal.timeout(5000),
|
|
322
|
+
headers: host.authToken ? { 'X-Hub-Auth': host.authToken } : {},
|
|
323
|
+
});
|
|
324
|
+
const latencyMs = Date.now() - start;
|
|
325
|
+
return {
|
|
326
|
+
id: host.id,
|
|
327
|
+
label: host.label,
|
|
328
|
+
url: host.url,
|
|
329
|
+
status: resp.ok ? 'online' : 'degraded',
|
|
330
|
+
latencyMs,
|
|
331
|
+
lastPingAt: new Date().toISOString(),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
return {
|
|
336
|
+
id: host.id,
|
|
337
|
+
label: host.label,
|
|
338
|
+
url: host.url,
|
|
339
|
+
status: 'offline',
|
|
340
|
+
latencyMs: null,
|
|
341
|
+
lastPingAt: new Date().toISOString(),
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
}
|
|
230
345
|
function normalizeReviewArtifact(raw, index = 0) {
|
|
231
346
|
if (!raw || typeof raw !== 'object')
|
|
232
347
|
return null;
|
|
@@ -289,6 +404,114 @@ function normalizeReviewHandoff(raw) {
|
|
|
289
404
|
}
|
|
290
405
|
return null;
|
|
291
406
|
}
|
|
407
|
+
const DELEGATION_TASK_STATUSES = new Set([
|
|
408
|
+
'planned',
|
|
409
|
+
'running',
|
|
410
|
+
'submitted',
|
|
411
|
+
'reviewed',
|
|
412
|
+
'blocked',
|
|
413
|
+
'completed',
|
|
414
|
+
'failed',
|
|
415
|
+
]);
|
|
416
|
+
function cleanString(value) {
|
|
417
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
418
|
+
}
|
|
419
|
+
function cleanNullableString(value) {
|
|
420
|
+
const cleaned = cleanString(value);
|
|
421
|
+
return cleaned.length > 0 ? cleaned : null;
|
|
422
|
+
}
|
|
423
|
+
function normalizeDelegationLedger(raw) {
|
|
424
|
+
if (!raw || typeof raw !== 'object')
|
|
425
|
+
return null;
|
|
426
|
+
const value = raw;
|
|
427
|
+
// Accept delegationRequired:true OR presence of goal/objective + tasks/jobs/groups (AI may omit the flag).
|
|
428
|
+
const hasFlag = value.delegationRequired === true;
|
|
429
|
+
const objective = cleanString((value.objective || value.goal));
|
|
430
|
+
if (!hasFlag && !objective)
|
|
431
|
+
return null;
|
|
432
|
+
if (!objective)
|
|
433
|
+
return null;
|
|
434
|
+
// Flatten tasks from multiple schemas:
|
|
435
|
+
// canonical: tasks/jobs: [{title, taskId, personaKey, jobId, instructions, dependsOn}]
|
|
436
|
+
// group-based: groups: [{runs: [{run_id, job, persona, briefing, expected_artifact}]}]
|
|
437
|
+
// with optional depends_on referencing other run_id values
|
|
438
|
+
let rawTasks = [];
|
|
439
|
+
const rawJobs = (value.jobs || value.tasks);
|
|
440
|
+
if (Array.isArray(rawJobs)) {
|
|
441
|
+
rawTasks = rawJobs;
|
|
442
|
+
}
|
|
443
|
+
else if (Array.isArray(value.groups)) {
|
|
444
|
+
// Flatten groups[].runs into a linear task list, resolving depends_on from group membership.
|
|
445
|
+
let groupDepIds = [];
|
|
446
|
+
for (const group of value.groups) {
|
|
447
|
+
const runs = Array.isArray(group.runs) ? group.runs : [];
|
|
448
|
+
const currentIds = runs.map((r) => cleanString(r.run_id) || '').filter(Boolean);
|
|
449
|
+
for (const run of runs) {
|
|
450
|
+
// Synthesise a canonical task from the run schema.
|
|
451
|
+
const dependsOnRaw = Array.isArray(group.depends_on) ? group.depends_on.map(String) : groupDepIds.length ? groupDepIds : [];
|
|
452
|
+
rawTasks.push({
|
|
453
|
+
taskId: run.run_id,
|
|
454
|
+
title: cleanString(run.briefing)?.slice(0, 80) || cleanString(run.job) || 'Task',
|
|
455
|
+
personaKey: run.persona,
|
|
456
|
+
jobId: run.job,
|
|
457
|
+
instructions: run.briefing,
|
|
458
|
+
dependsOn: dependsOnRaw,
|
|
459
|
+
status: 'planned',
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
groupDepIds = currentIds;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const tasks = rawTasks.map((task, index) => {
|
|
466
|
+
if (!task || typeof task !== 'object')
|
|
467
|
+
return null;
|
|
468
|
+
const rawTask = task;
|
|
469
|
+
// Accept title or first 80 chars of briefing/instructions
|
|
470
|
+
const title = cleanString((rawTask.title || rawTask.briefing || rawTask.instructions))?.slice(0, 80);
|
|
471
|
+
if (!title)
|
|
472
|
+
return null;
|
|
473
|
+
const taskId = cleanString((rawTask.taskId || rawTask.run_id || rawTask.id))
|
|
474
|
+
|| title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
475
|
+
|| `task-${index + 1}`;
|
|
476
|
+
const rawStatus = cleanString(rawTask.status);
|
|
477
|
+
const status = DELEGATION_TASK_STATUSES.has(rawStatus) ? rawStatus : 'planned';
|
|
478
|
+
const artifacts = Array.isArray(rawTask.artifacts)
|
|
479
|
+
? rawTask.artifacts.map((a, ai) => normalizeReviewArtifact(a, ai)).filter((a) => Boolean(a))
|
|
480
|
+
: [];
|
|
481
|
+
const reviewHandoff = normalizeReviewHandoff(rawTask.reviewHandoff);
|
|
482
|
+
// Accept dependsOn or depends_on as string array
|
|
483
|
+
const rawDeps = rawTask.dependsOn || rawTask.depends_on;
|
|
484
|
+
return {
|
|
485
|
+
taskId,
|
|
486
|
+
title,
|
|
487
|
+
status,
|
|
488
|
+
personaKey: cleanNullableString((rawTask.personaKey || rawTask.persona)),
|
|
489
|
+
jobId: cleanNullableString((rawTask.jobId || rawTask.job || rawTask.job_id)),
|
|
490
|
+
reviewJobId: cleanNullableString(rawTask.reviewJobId),
|
|
491
|
+
reviewType: cleanNullableString(rawTask.reviewType),
|
|
492
|
+
instructions: cleanString((rawTask.instructions || rawTask.briefing)) || undefined,
|
|
493
|
+
latestSummary: cleanString(rawTask.latestSummary) || undefined,
|
|
494
|
+
hostThreadId: cleanNullableString(rawTask.hostThreadId),
|
|
495
|
+
hostSessionId: cleanNullableString(rawTask.hostSessionId),
|
|
496
|
+
runId: cleanNullableString(rawTask.runId),
|
|
497
|
+
conversationId: cleanNullableString(rawTask.conversationId),
|
|
498
|
+
dependsOn: Array.isArray(rawDeps) ? rawDeps.map(String).filter(Boolean) : [],
|
|
499
|
+
artifacts,
|
|
500
|
+
reviewHandoff,
|
|
501
|
+
};
|
|
502
|
+
}).filter((task) => Boolean(task));
|
|
503
|
+
if (tasks.length === 0)
|
|
504
|
+
return null;
|
|
505
|
+
return {
|
|
506
|
+
delegationRequired: true,
|
|
507
|
+
objective,
|
|
508
|
+
orchestratorPersonaKey: cleanNullableString((value.orchestratorPersonaKey || value.manager_persona)),
|
|
509
|
+
rootRunId: cleanNullableString(value.rootRunId),
|
|
510
|
+
managerRunId: cleanNullableString((value.managerRunId || value.manager_session)),
|
|
511
|
+
latestSummary: cleanString(value.latestSummary) || undefined,
|
|
512
|
+
tasks,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
292
515
|
function extractReviewHandoffFromText(text) {
|
|
293
516
|
if (!text || !/reviewRequired|reviewTarget|review_handoff/i.test(text))
|
|
294
517
|
return null;
|
|
@@ -313,36 +536,102 @@ function extractReviewHandoffFromText(text) {
|
|
|
313
536
|
}
|
|
314
537
|
return null;
|
|
315
538
|
}
|
|
316
|
-
|
|
539
|
+
function extractDelegationLedgerFromText(text) {
|
|
540
|
+
if (!text || !/delegationRequired|delegation_ledger|delegationLedger|"goal"\s*:.*"groups"\s*:/is.test(text))
|
|
541
|
+
return null;
|
|
542
|
+
const candidates = [];
|
|
543
|
+
for (const match of String(text).matchAll(/```(?:json)?\s*([\s\S]*?)```/gi))
|
|
544
|
+
candidates.push(match[1]);
|
|
545
|
+
const tagged = String(text).match(/<delegation_ledger>\s*([\s\S]*?)\s*<\/delegation_ledger>/i);
|
|
546
|
+
if (tagged)
|
|
547
|
+
candidates.push(tagged[1]);
|
|
548
|
+
const inline = String(text).match(/(\{\s*"delegationRequired"[\s\S]*\})/i);
|
|
549
|
+
if (inline)
|
|
550
|
+
candidates.push(inline[1]);
|
|
551
|
+
for (const candidate of candidates) {
|
|
552
|
+
try {
|
|
553
|
+
const ledger = normalizeDelegationLedger(JSON.parse(candidate.trim()));
|
|
554
|
+
if (ledger)
|
|
555
|
+
return ledger;
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
// Malformed delegation snippets are ignored; the host transcript remains available in Micro-manage.
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
317
563
|
const ARTIFACT_EXCLUDE_RE = /(^|[\\/])(retrospectives|evidence|learnings|mocks|raw|archive)[\\/]/i;
|
|
318
|
-
function
|
|
564
|
+
function extractReviewArtifactsFromText(text, projectPath) {
|
|
319
565
|
if (!text)
|
|
320
|
-
return
|
|
321
|
-
const
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
566
|
+
return [];
|
|
567
|
+
const artifacts = [];
|
|
568
|
+
const seen = new Set();
|
|
569
|
+
const matches = String(text)
|
|
570
|
+
.split(/[\s`"'()<>{}\[\],;:]+/)
|
|
571
|
+
.filter((token) => /^(docs|public|src|tests)[\\/]/.test(token));
|
|
572
|
+
for (const candidate of matches) {
|
|
573
|
+
const relativePath = candidate.replace(/[.)]+$/g, '').replace(/\\/g, '/');
|
|
574
|
+
if (!/\.[A-Za-z0-9]+$/.test(relativePath))
|
|
575
|
+
continue;
|
|
576
|
+
if (ARTIFACT_EXCLUDE_RE.test(relativePath))
|
|
577
|
+
continue;
|
|
578
|
+
const parts = relativePath.split('/').filter(Boolean);
|
|
579
|
+
const name = parts[parts.length - 1] || relativePath;
|
|
580
|
+
const artifactPath = path_1.default.isAbsolute(relativePath) ? relativePath : path_1.default.join(projectPath, relativePath);
|
|
581
|
+
if (seen.has(artifactPath))
|
|
582
|
+
continue;
|
|
583
|
+
seen.add(artifactPath);
|
|
584
|
+
artifacts.push({ type: path_1.default.extname(name).replace(/^\./, '') || 'file', label: name, path: artifactPath });
|
|
585
|
+
}
|
|
586
|
+
return artifacts;
|
|
331
587
|
}
|
|
332
588
|
function applyReviewProjection(run, text) {
|
|
589
|
+
const delegation = extractDelegationLedgerFromText(text);
|
|
590
|
+
if (delegation) {
|
|
591
|
+
run.delegation = {
|
|
592
|
+
...delegation,
|
|
593
|
+
rootRunId: delegation.rootRunId || run.id,
|
|
594
|
+
managerRunId: delegation.managerRunId || run.id,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
333
597
|
const handoff = extractReviewHandoffFromText(text);
|
|
334
598
|
if (handoff) {
|
|
335
599
|
run.reviewHandoff = handoff;
|
|
336
600
|
run.artifacts = handoff.artifacts;
|
|
337
601
|
return;
|
|
338
602
|
}
|
|
339
|
-
const
|
|
340
|
-
if (!
|
|
603
|
+
const artifacts = extractReviewArtifactsFromText(text, run.projectPath);
|
|
604
|
+
if (!artifacts.length)
|
|
341
605
|
return;
|
|
342
606
|
run.artifacts = run.artifacts || [];
|
|
343
|
-
|
|
344
|
-
run.artifacts.
|
|
607
|
+
for (const artifact of artifacts) {
|
|
608
|
+
if (!run.artifacts.some((entry) => (entry.path && artifact.path && entry.path === artifact.path) || (entry.url && artifact.url && entry.url === artifact.url))) {
|
|
609
|
+
run.artifacts.push(artifact);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
function mergeReviewArtifacts(existing, discovered) {
|
|
614
|
+
const merged = [...(existing || [])];
|
|
615
|
+
for (const artifact of discovered) {
|
|
616
|
+
if (!merged.some((entry) => (entry.path && artifact.path && entry.path === artifact.path) || (entry.url && artifact.url && entry.url === artifact.url))) {
|
|
617
|
+
merged.push(artifact);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return merged;
|
|
621
|
+
}
|
|
622
|
+
function extractRunReviewArtifacts(run) {
|
|
623
|
+
const discovered = [];
|
|
624
|
+
for (const message of run.messages || []) {
|
|
625
|
+
if (message.role !== 'employee')
|
|
626
|
+
continue;
|
|
627
|
+
discovered.push(...extractReviewArtifactsFromText(message.text, run.projectPath));
|
|
628
|
+
}
|
|
629
|
+
for (const event of run.events || []) {
|
|
630
|
+
if (event.channel !== 'stdout')
|
|
631
|
+
continue;
|
|
632
|
+
discovered.push(...extractReviewArtifactsFromText(event.text, run.projectPath));
|
|
345
633
|
}
|
|
634
|
+
return mergeReviewArtifacts(run.artifacts, discovered);
|
|
346
635
|
}
|
|
347
636
|
function emptyTotals() {
|
|
348
637
|
return {
|
|
@@ -408,6 +697,12 @@ function applyAgentIdentitySignal(run, identity) {
|
|
|
408
697
|
run.agentName = identity.agentName;
|
|
409
698
|
run.agentModel = identity.agentModel;
|
|
410
699
|
}
|
|
700
|
+
function stripStructuredHostPayloads(text) {
|
|
701
|
+
return text
|
|
702
|
+
.replace(/<delegation_ledger>\s*[\s\S]*?\s*<\/delegation_ledger>/gi, '')
|
|
703
|
+
.replace(/<review_handoff>\s*[\s\S]*?\s*<\/review_handoff>/gi, '')
|
|
704
|
+
.trim();
|
|
705
|
+
}
|
|
411
706
|
function appendHostMessage(run, hostId, event, channel) {
|
|
412
707
|
if (!event.message || channel !== 'stdout')
|
|
413
708
|
return;
|
|
@@ -417,10 +712,17 @@ function appendHostMessage(run, hostId, event, channel) {
|
|
|
417
712
|
if (last?.role === 'employee') {
|
|
418
713
|
last.text = `${last.text}\n${event.message}`;
|
|
419
714
|
last.createdAt = new Date().toISOString();
|
|
715
|
+
applyReviewProjection(run, last.text);
|
|
716
|
+
last.text = stripStructuredHostPayloads(last.text);
|
|
717
|
+
if (!last.text)
|
|
718
|
+
run.messages.pop();
|
|
420
719
|
return;
|
|
421
720
|
}
|
|
422
721
|
}
|
|
423
|
-
|
|
722
|
+
const displayMessage = stripStructuredHostPayloads(event.message);
|
|
723
|
+
if (!displayMessage)
|
|
724
|
+
return;
|
|
725
|
+
run.messages.push((0, hosts_1.createHubMessage)('employee', displayMessage));
|
|
424
726
|
}
|
|
425
727
|
// Apply a parsed seekMentoring tool-use signal from the host stream to
|
|
426
728
|
// the run state. Returns the updated currentPhase.
|
|
@@ -437,6 +739,16 @@ function applySeekMentoringSignal(run, signal) {
|
|
|
437
739
|
return;
|
|
438
740
|
if (signal.reviewHandoff)
|
|
439
741
|
run.reviewHandoff = signal.reviewHandoff;
|
|
742
|
+
if (signal.delegationLedger &&
|
|
743
|
+
run.jobId === 'fully-delegate' &&
|
|
744
|
+
signal.phaseStatus === 'complete' &&
|
|
745
|
+
(signal.phaseId === 'confirm-or-fallback' || signal.phaseId === 'create-delegation-graph')) {
|
|
746
|
+
run.delegation = {
|
|
747
|
+
...signal.delegationLedger,
|
|
748
|
+
rootRunId: signal.delegationLedger.rootRunId || run.id,
|
|
749
|
+
managerRunId: signal.delegationLedger.managerRunId || run.id,
|
|
750
|
+
};
|
|
751
|
+
}
|
|
440
752
|
// Discriminant signals are routing hints only — they don't move the
|
|
441
753
|
// tracker, but they do change which phase id will be considered
|
|
442
754
|
// reachable next time stages are derived. Persist on the run.
|
|
@@ -792,9 +1104,13 @@ function countMarkdownFilesRecursive(dirPath) {
|
|
|
792
1104
|
return total;
|
|
793
1105
|
}
|
|
794
1106
|
class AiHubServer {
|
|
1107
|
+
get hubBase() {
|
|
1108
|
+
return process.env.FRAIM_HUB_BASE_URL || `http://127.0.0.1:${this.httpPort}`;
|
|
1109
|
+
}
|
|
795
1110
|
constructor(options = {}) {
|
|
796
1111
|
this.app = (0, express_1.default)();
|
|
797
1112
|
this.runRegistry = new AiHubRunRegistry();
|
|
1113
|
+
this.cronHandles = new Map();
|
|
798
1114
|
this.projectPath = options.projectPath || process.cwd();
|
|
799
1115
|
this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
|
|
800
1116
|
this.conversationStore = options.conversationStore || new conversation_store_1.AiHubConversationStore();
|
|
@@ -817,7 +1133,9 @@ class AiHubServer {
|
|
|
817
1133
|
this.dbService = createDefaultDbService();
|
|
818
1134
|
this.ownsDbService = this.dbService !== undefined;
|
|
819
1135
|
}
|
|
820
|
-
this.
|
|
1136
|
+
this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
|
|
1137
|
+
this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
|
|
1138
|
+
this.app.use(express_1.default.json({ limit: '10mb' }));
|
|
821
1139
|
if (this.dbService) {
|
|
822
1140
|
const { registerPaymentRoutes } = require('../routes/payment-routes');
|
|
823
1141
|
registerPaymentRoutes(this.app, () => this.paymentRepo ?? null, this.dbService);
|
|
@@ -961,6 +1279,8 @@ class AiHubServer {
|
|
|
961
1279
|
// so it's known even before the browser is running.
|
|
962
1280
|
process.env.FRAIM_BROWSER_CDP_ENDPOINT = this.managedBrowser.cdpEndpoint();
|
|
963
1281
|
process.env.FRAIM_HUB_BASE_URL = `http://127.0.0.1:${port}`;
|
|
1282
|
+
// Issue #578: rehydrate active scheduled deployments from disk.
|
|
1283
|
+
this.rehydrateScheduledDeployments();
|
|
964
1284
|
// Start HTTPS server when a cert bundle and port are provided.
|
|
965
1285
|
// Word Online requires HTTPS; the HTTPS server shares the same Express app
|
|
966
1286
|
// so all routes (including /word-taskpane/*) are available over both protocols.
|
|
@@ -987,10 +1307,19 @@ class AiHubServer {
|
|
|
987
1307
|
}
|
|
988
1308
|
async stop() {
|
|
989
1309
|
const closeServer = (srv) => new Promise((resolve, reject) => {
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1310
|
+
const closable = srv;
|
|
1311
|
+
closable.closeIdleConnections?.();
|
|
1312
|
+
const forceCloseTimer = setTimeout(() => {
|
|
1313
|
+
closable.closeIdleConnections?.();
|
|
1314
|
+
closable.closeAllConnections?.();
|
|
1315
|
+
}, 250);
|
|
1316
|
+
srv.close((error) => {
|
|
1317
|
+
clearTimeout(forceCloseTimer);
|
|
1318
|
+
if (error)
|
|
1319
|
+
reject(error);
|
|
1320
|
+
else
|
|
1321
|
+
resolve();
|
|
1322
|
+
});
|
|
994
1323
|
});
|
|
995
1324
|
if (this.httpsServer) {
|
|
996
1325
|
await closeServer(this.httpsServer);
|
|
@@ -1000,6 +1329,11 @@ class AiHubServer {
|
|
|
1000
1329
|
await closeServer(this.httpServer);
|
|
1001
1330
|
this.httpServer = undefined;
|
|
1002
1331
|
}
|
|
1332
|
+
// Issue #578: stop all active scheduled deployments.
|
|
1333
|
+
for (const [, task] of this.cronHandles) {
|
|
1334
|
+
task.stop();
|
|
1335
|
+
}
|
|
1336
|
+
this.cronHandles.clear();
|
|
1003
1337
|
// #521: tear down the shared browser if WE launched it (stop() no-ops on a
|
|
1004
1338
|
// browser the manager owns).
|
|
1005
1339
|
this.managedBrowser.stop();
|
|
@@ -1094,8 +1428,16 @@ class AiHubServer {
|
|
|
1094
1428
|
})),
|
|
1095
1429
|
artifacts: run.artifacts || [],
|
|
1096
1430
|
reviewHandoff: run.reviewHandoff || null,
|
|
1431
|
+
delegation: run.delegation || null,
|
|
1432
|
+
delegationTaskId: run.delegationTaskId || null,
|
|
1433
|
+
managedByRunId: run.managedByRunId || null,
|
|
1434
|
+
managedByPersonaKey: run.managedByPersonaKey || null,
|
|
1435
|
+
humanCoachingDisabled: run.humanCoachingDisabled || false,
|
|
1436
|
+
managedReviewStatus: run.managedReviewStatus || null,
|
|
1097
1437
|
compareMode: run.runRole === 'fraim' && run.compareRunId ? 'ab' : undefined,
|
|
1098
1438
|
compareRunId: run.compareRunId || null,
|
|
1439
|
+
// Issue #578: preserve trigger source so the UI can render the chip.
|
|
1440
|
+
sourceTrigger: run.sourceTrigger,
|
|
1099
1441
|
};
|
|
1100
1442
|
}
|
|
1101
1443
|
persistRunConversation(run, activeId) {
|
|
@@ -1109,6 +1451,280 @@ class AiHubServer {
|
|
|
1109
1451
|
// Issue #512 (S3, R13) — derive the four Get-started step states, then let any
|
|
1110
1452
|
// persisted `true` in ~/.fraim/{install-state,preferences}.json override the
|
|
1111
1453
|
// derivation (so a completed step stays completed even if its signal vanishes).
|
|
1454
|
+
maybeStartDelegatedChildRuns(managerRun) {
|
|
1455
|
+
const ledger = managerRun.delegation;
|
|
1456
|
+
if (!ledger || managerRun.managedByRunId)
|
|
1457
|
+
return;
|
|
1458
|
+
managerRun.orchestratedDelegationTaskIds = managerRun.orchestratedDelegationTaskIds || [];
|
|
1459
|
+
const started = new Set(managerRun.orchestratedDelegationTaskIds);
|
|
1460
|
+
for (const task of ledger.tasks || []) {
|
|
1461
|
+
if (!task.taskId || started.has(task.taskId))
|
|
1462
|
+
continue;
|
|
1463
|
+
if (!task.jobId || !this.delegationDependenciesSatisfied(ledger, task.dependsOn || []))
|
|
1464
|
+
continue;
|
|
1465
|
+
if (!task.personaKey)
|
|
1466
|
+
task.personaKey = getProtectedPersonaForHubJob(task.jobId);
|
|
1467
|
+
this.startDelegatedChildRun(managerRun, task.taskId);
|
|
1468
|
+
started.add(task.taskId);
|
|
1469
|
+
managerRun.orchestratedDelegationTaskIds.push(task.taskId);
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
delegationDependenciesSatisfied(ledger, dependsOn) {
|
|
1473
|
+
if (!dependsOn.length)
|
|
1474
|
+
return true;
|
|
1475
|
+
const terminal = new Set(['reviewed', 'completed']);
|
|
1476
|
+
return dependsOn.every((dependency) => {
|
|
1477
|
+
const matched = ledger.tasks.find((task) => task.taskId === dependency || task.jobId === dependency);
|
|
1478
|
+
return !!matched && terminal.has(matched.status);
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
startDelegatedChildRun(managerRun, taskId) {
|
|
1482
|
+
const ledger = managerRun.delegation;
|
|
1483
|
+
const task = ledger?.tasks.find((entry) => entry.taskId === taskId);
|
|
1484
|
+
if (!ledger || !task || !task.jobId)
|
|
1485
|
+
return;
|
|
1486
|
+
if (!task.personaKey)
|
|
1487
|
+
task.personaKey = getProtectedPersonaForHubJob(task.jobId);
|
|
1488
|
+
const resolvedJob = this.resolveHubJob(managerRun.projectPath, task.jobId);
|
|
1489
|
+
if (!resolvedJob) {
|
|
1490
|
+
task.status = 'blocked';
|
|
1491
|
+
task.latestSummary = `Delegation blocked: job "${task.jobId}" is not available in this project.`;
|
|
1492
|
+
managerRun.events.push((0, hosts_1.createHubEvent)('system', task.latestSummary));
|
|
1493
|
+
this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
1496
|
+
const childConversationId = task.conversationId || `${managerRun.id}-${task.taskId}`;
|
|
1497
|
+
const now = new Date().toISOString();
|
|
1498
|
+
const childInstructions = [
|
|
1499
|
+
task.instructions || task.latestSummary || `Complete the delegated workstream: ${task.title}.`,
|
|
1500
|
+
'',
|
|
1501
|
+
task.personaKey
|
|
1502
|
+
? `You are working as ${task.personaKey} for the manager job.`
|
|
1503
|
+
: 'You are working as the specialist assigned by this delegated job.',
|
|
1504
|
+
`Parent objective: ${ledger.objective}.`,
|
|
1505
|
+
task.reviewJobId ? `Manager review route: this output should be reviewed using ${task.reviewJobId}.` : '',
|
|
1506
|
+
'Submit a concise deliverable summary and any artifact references back to Mandy.',
|
|
1507
|
+
'This is a delegated subtask, not an end-to-end FRAIM submission workflow. After you provide the deliverable for Mandy, stop. Do not create evidence docs, open or update PRs, update GitHub issues, or ask the human for review.',
|
|
1508
|
+
'Do not ask the human for coaching; Mandy is your manager for this workstream.',
|
|
1509
|
+
].join('\n');
|
|
1510
|
+
const prepared = this.prepareStartPayload(managerRun.projectPath, managerRun.hostId, task.jobId, childInstructions);
|
|
1511
|
+
const childRun = {
|
|
1512
|
+
id: (0, crypto_1.randomUUID)(),
|
|
1513
|
+
conversationId: childConversationId,
|
|
1514
|
+
conversationTitle: task.title,
|
|
1515
|
+
jobTitle: task.title,
|
|
1516
|
+
jobId: prepared.jobId || task.jobId,
|
|
1517
|
+
hostId: managerRun.hostId,
|
|
1518
|
+
projectPath: managerRun.projectPath,
|
|
1519
|
+
status: 'running',
|
|
1520
|
+
createdAt: now,
|
|
1521
|
+
updatedAt: now,
|
|
1522
|
+
messages: [(0, hosts_1.createHubMessage)('manager', `Mandy delegated: ${task.title}\n\n${childInstructions}`)],
|
|
1523
|
+
events: [(0, hosts_1.createHubEvent)('system', `Mandy started delegated workstream ${task.taskId} for ${task.personaKey || task.jobId}.`)],
|
|
1524
|
+
currentPhase: null,
|
|
1525
|
+
phaseHistory: [],
|
|
1526
|
+
totals: emptyTotals(),
|
|
1527
|
+
lastStatusChangeAt: now,
|
|
1528
|
+
personaKey: task.personaKey,
|
|
1529
|
+
delegationTaskId: task.taskId,
|
|
1530
|
+
managedByRunId: managerRun.id,
|
|
1531
|
+
managedByPersonaKey: managerRun.personaKey || ledger.orchestratorPersonaKey || 'mandy',
|
|
1532
|
+
humanCoachingDisabled: true,
|
|
1533
|
+
};
|
|
1534
|
+
task.runId = childRun.id;
|
|
1535
|
+
task.conversationId = childConversationId;
|
|
1536
|
+
task.status = 'running';
|
|
1537
|
+
this.runRegistry.create(childRun, {});
|
|
1538
|
+
this.persistRunConversation(childRun);
|
|
1539
|
+
this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
|
|
1540
|
+
const child = this.hostRuntime.startRun(managerRun.hostId, managerRun.projectPath, prepared.message, {
|
|
1541
|
+
onEvent: (event, channel) => {
|
|
1542
|
+
this.runRegistry.update(childRun.id, (current) => {
|
|
1543
|
+
if (event.sessionId)
|
|
1544
|
+
current.sessionId = event.sessionId;
|
|
1545
|
+
appendHostMessage(current, managerRun.hostId, event, channel);
|
|
1546
|
+
if (event.raw) {
|
|
1547
|
+
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
1548
|
+
applyReviewProjection(current, event.raw);
|
|
1549
|
+
}
|
|
1550
|
+
if (event.agentIdentity)
|
|
1551
|
+
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
1552
|
+
if (event.seekMentoring)
|
|
1553
|
+
applySeekMentoringSignal(current, event.seekMentoring);
|
|
1554
|
+
if (event.usage)
|
|
1555
|
+
applyUsageSignal(current, event.usage);
|
|
1556
|
+
});
|
|
1557
|
+
const updatedChild = this.runRegistry.get(childRun.id);
|
|
1558
|
+
if (updatedChild)
|
|
1559
|
+
this.persistRunConversation(updatedChild);
|
|
1560
|
+
},
|
|
1561
|
+
onExit: (exitCode) => {
|
|
1562
|
+
this.runRegistry.update(childRun.id, (current) => {
|
|
1563
|
+
current.exitCode = exitCode;
|
|
1564
|
+
current.status = exitCode === 0 ? 'completed' : 'failed';
|
|
1565
|
+
current.events.push((0, hosts_1.createHubEvent)('system', `Delegated workstream exited with code ${exitCode ?? 'unknown'}.`));
|
|
1566
|
+
});
|
|
1567
|
+
const updatedChild = this.runRegistry.get(childRun.id);
|
|
1568
|
+
if (updatedChild) {
|
|
1569
|
+
this.persistRunConversation(updatedChild);
|
|
1570
|
+
this.markDelegatedChildSubmitted(managerRun.id, updatedChild);
|
|
1571
|
+
this.notifyManagerOfDelegatedChild(managerRun.id, updatedChild);
|
|
1572
|
+
}
|
|
1573
|
+
this.runRegistry.dispose(childRun.id);
|
|
1574
|
+
},
|
|
1575
|
+
}, childRun.sessionId);
|
|
1576
|
+
this.runRegistry.attachChildIfRunning(childRun.id, child);
|
|
1577
|
+
}
|
|
1578
|
+
notifyManagerOfDelegatedChild(managerRunId, childRun) {
|
|
1579
|
+
const managerRun = this.runRegistry.get(managerRunId);
|
|
1580
|
+
if (!managerRun) {
|
|
1581
|
+
childRun.events.push((0, hosts_1.createHubEvent)('system', 'Delegated deliverable could not be routed because the manager run is unavailable.'));
|
|
1582
|
+
this.persistRunConversation(childRun);
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
if (!managerRun.sessionId || managerRun.status === 'running') {
|
|
1586
|
+
this.enqueueDelegatedReview(managerRun, childRun, !managerRun.sessionId ? 'manager session is unavailable' : 'manager is still running');
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
const latest = [...(childRun.messages || [])].reverse().find((message) => message.role === 'employee')?.text || '';
|
|
1590
|
+
const artifacts = childRun.reviewHandoff?.artifacts?.map((artifact) => artifact.label || artifact.path || artifact.url).filter(Boolean).join(', ') || 'none reported';
|
|
1591
|
+
const reviewPrompt = [
|
|
1592
|
+
`Delegated workstream submitted by ${childRun.personaKey || 'a peer agent'}: ${childRun.conversationTitle || childRun.jobTitle || childRun.jobId}.`,
|
|
1593
|
+
`Status: ${childRun.status}.`,
|
|
1594
|
+
`Latest deliverable summary: ${latest || 'No employee summary captured.'}`,
|
|
1595
|
+
`Artifacts: ${artifacts}.`,
|
|
1596
|
+
childRun.delegationTaskId ? this.delegatedReviewRouteLine(managerRun, childRun.delegationTaskId) : '',
|
|
1597
|
+
'',
|
|
1598
|
+
'As Mandy, review this child deliverable. If it is acceptable, say it is reviewed for synthesis. If it needs correction, write specific coaching feedback for that child workstream.',
|
|
1599
|
+
].join('\n');
|
|
1600
|
+
this.continueManagerRunForDelegation(managerRun, reviewPrompt, `Mandy reviews ${childRun.conversationTitle || childRun.jobTitle || childRun.jobId}`, childRun);
|
|
1601
|
+
}
|
|
1602
|
+
delegatedReviewRouteLine(managerRun, taskId) {
|
|
1603
|
+
const task = managerRun.delegation?.tasks.find((entry) => entry.taskId === taskId);
|
|
1604
|
+
if (!task?.reviewJobId)
|
|
1605
|
+
return '';
|
|
1606
|
+
return `Declared manager review job: ${task.reviewJobId}${task.reviewType ? ` (${task.reviewType})` : ''}. Use that review standard when judging the deliverable.`;
|
|
1607
|
+
}
|
|
1608
|
+
enqueueDelegatedReview(managerRun, childRun, reason) {
|
|
1609
|
+
managerRun.pendingDelegatedReviewChildRunIds = managerRun.pendingDelegatedReviewChildRunIds || [];
|
|
1610
|
+
if (!managerRun.pendingDelegatedReviewChildRunIds.includes(childRun.id)) {
|
|
1611
|
+
managerRun.pendingDelegatedReviewChildRunIds.push(childRun.id);
|
|
1612
|
+
managerRun.events.push((0, hosts_1.createHubEvent)('system', `Delegated deliverable queued for Mandy review because ${reason}.`));
|
|
1613
|
+
}
|
|
1614
|
+
childRun.events.push((0, hosts_1.createHubEvent)('system', `Deliverable submitted to Mandy; review is queued because ${reason}.`));
|
|
1615
|
+
this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
|
|
1616
|
+
this.persistRunConversation(childRun);
|
|
1617
|
+
}
|
|
1618
|
+
drainPendingDelegatedReviews(managerRun) {
|
|
1619
|
+
if (managerRun.status === 'running' || !managerRun.sessionId)
|
|
1620
|
+
return;
|
|
1621
|
+
const queue = managerRun.pendingDelegatedReviewChildRunIds || [];
|
|
1622
|
+
while (queue.length > 0) {
|
|
1623
|
+
const childRunId = queue.shift();
|
|
1624
|
+
const childRun = this.runRegistry.get(childRunId);
|
|
1625
|
+
if (!childRun || childRun.managedReviewStatus === 'reviewed')
|
|
1626
|
+
continue;
|
|
1627
|
+
managerRun.pendingDelegatedReviewChildRunIds = queue;
|
|
1628
|
+
this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
|
|
1629
|
+
this.notifyManagerOfDelegatedChild(managerRun.id, childRun);
|
|
1630
|
+
return;
|
|
1631
|
+
}
|
|
1632
|
+
managerRun.pendingDelegatedReviewChildRunIds = queue;
|
|
1633
|
+
this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
|
|
1634
|
+
}
|
|
1635
|
+
markDelegatedChildSubmitted(managerRunId, childRun) {
|
|
1636
|
+
const managerRun = this.runRegistry.get(managerRunId);
|
|
1637
|
+
if (!managerRun?.delegation)
|
|
1638
|
+
return;
|
|
1639
|
+
const task = managerRun.delegation.tasks.find((entry) => entry.taskId === childRun.delegationTaskId || entry.runId === childRun.id);
|
|
1640
|
+
if (!task)
|
|
1641
|
+
return;
|
|
1642
|
+
task.status = childRun.status === 'completed' ? 'submitted' : 'failed';
|
|
1643
|
+
const latest = [...(childRun.messages || [])].reverse().find((message) => message.role === 'employee')?.text || '';
|
|
1644
|
+
if (latest)
|
|
1645
|
+
task.latestSummary = latest;
|
|
1646
|
+
if (childRun.reviewHandoff)
|
|
1647
|
+
task.reviewHandoff = childRun.reviewHandoff;
|
|
1648
|
+
const artifacts = childRun.reviewHandoff?.artifacts?.length ? childRun.reviewHandoff.artifacts : childRun.artifacts || [];
|
|
1649
|
+
if (artifacts.length)
|
|
1650
|
+
task.artifacts = artifacts;
|
|
1651
|
+
this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
|
|
1652
|
+
}
|
|
1653
|
+
markDelegatedChildReviewed(childRun, managerRun) {
|
|
1654
|
+
const reviewText = [...(managerRun.messages || [])].reverse().find((message) => message.role === 'employee')?.text || '';
|
|
1655
|
+
childRun.managedReviewStatus = 'reviewed';
|
|
1656
|
+
childRun.updatedAt = new Date().toISOString();
|
|
1657
|
+
if (reviewText) {
|
|
1658
|
+
childRun.messages.push((0, hosts_1.createHubMessage)('manager', `Mandy reviewed this workstream:\n\n${reviewText}`));
|
|
1659
|
+
}
|
|
1660
|
+
childRun.events.push((0, hosts_1.createHubEvent)('system', 'Mandy reviewed the delegated deliverable.'));
|
|
1661
|
+
this.persistRunConversation(childRun);
|
|
1662
|
+
const task = managerRun.delegation?.tasks.find((entry) => entry.taskId === childRun.delegationTaskId || entry.runId === childRun.id);
|
|
1663
|
+
if (task) {
|
|
1664
|
+
task.status = 'reviewed';
|
|
1665
|
+
task.latestSummary = reviewText || task.latestSummary;
|
|
1666
|
+
if (childRun.reviewHandoff)
|
|
1667
|
+
task.reviewHandoff = childRun.reviewHandoff;
|
|
1668
|
+
const artifacts = childRun.reviewHandoff?.artifacts?.length ? childRun.reviewHandoff.artifacts : childRun.artifacts || [];
|
|
1669
|
+
if (artifacts.length)
|
|
1670
|
+
task.artifacts = artifacts;
|
|
1671
|
+
}
|
|
1672
|
+
this.maybeStartDelegatedChildRuns(managerRun);
|
|
1673
|
+
}
|
|
1674
|
+
continueManagerRunForDelegation(managerRun, instructions, display, reviewedChildRun) {
|
|
1675
|
+
if (!managerRun.sessionId || managerRun.status === 'running')
|
|
1676
|
+
return;
|
|
1677
|
+
const prepared = this.prepareContinueMessage(managerRun, instructions);
|
|
1678
|
+
this.runRegistry.update(managerRun.id, (current) => {
|
|
1679
|
+
current.status = 'running';
|
|
1680
|
+
current.messages.push((0, hosts_1.createHubMessage)('manager', display));
|
|
1681
|
+
current.events.push((0, hosts_1.createHubEvent)('system', 'Delegated child output routed back to Mandy for review.'));
|
|
1682
|
+
});
|
|
1683
|
+
const started = this.runRegistry.get(managerRun.id);
|
|
1684
|
+
if (started)
|
|
1685
|
+
this.persistRunConversation(started, started.conversationId || started.id);
|
|
1686
|
+
this.runRegistry.create(managerRun, {});
|
|
1687
|
+
const child = this.hostRuntime.continueRun(managerRun.hostId, managerRun.projectPath, managerRun.sessionId, prepared.message, {
|
|
1688
|
+
onEvent: (event, channel) => {
|
|
1689
|
+
this.runRegistry.update(managerRun.id, (current) => {
|
|
1690
|
+
if (event.sessionId)
|
|
1691
|
+
current.sessionId = event.sessionId;
|
|
1692
|
+
appendHostMessage(current, managerRun.hostId, event, channel);
|
|
1693
|
+
if (event.raw) {
|
|
1694
|
+
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
1695
|
+
applyReviewProjection(current, event.raw);
|
|
1696
|
+
}
|
|
1697
|
+
if (event.agentIdentity)
|
|
1698
|
+
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
1699
|
+
if (event.seekMentoring)
|
|
1700
|
+
applySeekMentoringSignal(current, event.seekMentoring);
|
|
1701
|
+
if (event.usage)
|
|
1702
|
+
applyUsageSignal(current, event.usage);
|
|
1703
|
+
});
|
|
1704
|
+
const updated = this.runRegistry.get(managerRun.id);
|
|
1705
|
+
if (updated)
|
|
1706
|
+
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
1707
|
+
},
|
|
1708
|
+
onExit: (exitCode) => {
|
|
1709
|
+
this.runRegistry.update(managerRun.id, (current) => {
|
|
1710
|
+
current.exitCode = exitCode;
|
|
1711
|
+
current.status = exitCode === 0 ? 'completed' : 'failed';
|
|
1712
|
+
current.events.push((0, hosts_1.createHubEvent)('system', `Mandy review turn exited with code ${exitCode ?? 'unknown'}.`));
|
|
1713
|
+
});
|
|
1714
|
+
const updated = this.runRegistry.get(managerRun.id);
|
|
1715
|
+
if (updated) {
|
|
1716
|
+
if (exitCode === 0 && reviewedChildRun)
|
|
1717
|
+
this.markDelegatedChildReviewed(reviewedChildRun, updated);
|
|
1718
|
+
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
1719
|
+
}
|
|
1720
|
+
this.runRegistry.dispose(managerRun.id);
|
|
1721
|
+
const latestManager = this.runRegistry.get(managerRun.id);
|
|
1722
|
+
if (latestManager)
|
|
1723
|
+
this.drainPendingDelegatedReviews(latestManager);
|
|
1724
|
+
},
|
|
1725
|
+
});
|
|
1726
|
+
this.runRegistry.attachChildIfRunning(managerRun.id, child);
|
|
1727
|
+
}
|
|
1112
1728
|
computeFirstRun(projectPath, jobCount, personas) {
|
|
1113
1729
|
const tc = (0, learning_context_builder_1.resolveTeamContextFiles)(projectPath);
|
|
1114
1730
|
// company = any organization-layer context written by org onboarding.
|
|
@@ -1827,8 +2443,10 @@ class AiHubServer {
|
|
|
1827
2443
|
applyUsageSignal(current, event.usage);
|
|
1828
2444
|
});
|
|
1829
2445
|
const updated = this.runRegistry.get(run.id);
|
|
1830
|
-
if (updated)
|
|
2446
|
+
if (updated) {
|
|
2447
|
+
this.maybeStartDelegatedChildRuns(updated);
|
|
1831
2448
|
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
2449
|
+
}
|
|
1832
2450
|
},
|
|
1833
2451
|
onExit: (exitCode) => {
|
|
1834
2452
|
this.runRegistry.update(run.id, (current) => {
|
|
@@ -1844,12 +2462,17 @@ class AiHubServer {
|
|
|
1844
2462
|
}
|
|
1845
2463
|
});
|
|
1846
2464
|
const updated = this.runRegistry.get(run.id);
|
|
1847
|
-
if (updated)
|
|
2465
|
+
if (updated) {
|
|
2466
|
+
this.maybeStartDelegatedChildRuns(updated);
|
|
1848
2467
|
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
2468
|
+
}
|
|
1849
2469
|
this.runRegistry.dispose(run.id);
|
|
2470
|
+
const latest = this.runRegistry.get(run.id);
|
|
2471
|
+
if (latest)
|
|
2472
|
+
this.drainPendingDelegatedReviews(latest);
|
|
1850
2473
|
},
|
|
1851
2474
|
}, run.sessionId);
|
|
1852
|
-
this.runRegistry.
|
|
2475
|
+
this.runRegistry.attachChildIfRunning(run.id, child);
|
|
1853
2476
|
// Issue #442: spawn the Direct run via startDirectRun so CliHostRuntime
|
|
1854
2477
|
// uses buildDirectStartPlan (--strict-mcp-config, raw stdin) rather than
|
|
1855
2478
|
// the FRAIM-wrapping buildStartPlan path.
|
|
@@ -1878,7 +2501,7 @@ class AiHubServer {
|
|
|
1878
2501
|
this.runRegistry.dispose(directId);
|
|
1879
2502
|
},
|
|
1880
2503
|
}, directRun.sessionId);
|
|
1881
|
-
this.runRegistry.
|
|
2504
|
+
this.runRegistry.attachChildIfRunning(directRun.id, directChild);
|
|
1882
2505
|
}
|
|
1883
2506
|
const existingPreferences = this.preferencesStore.load(projectPath);
|
|
1884
2507
|
this.preferencesStore.remember({
|
|
@@ -2009,8 +2632,10 @@ class AiHubServer {
|
|
|
2009
2632
|
applyUsageSignal(current, event.usage);
|
|
2010
2633
|
});
|
|
2011
2634
|
const updated = this.runRegistry.get(run.id);
|
|
2012
|
-
if (updated)
|
|
2635
|
+
if (updated) {
|
|
2636
|
+
this.maybeStartDelegatedChildRuns(updated);
|
|
2013
2637
|
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
2638
|
+
}
|
|
2014
2639
|
},
|
|
2015
2640
|
onExit: (exitCode) => {
|
|
2016
2641
|
this.runRegistry.update(run.id, (current) => {
|
|
@@ -2019,12 +2644,17 @@ class AiHubServer {
|
|
|
2019
2644
|
current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
|
|
2020
2645
|
});
|
|
2021
2646
|
const updated = this.runRegistry.get(run.id);
|
|
2022
|
-
if (updated)
|
|
2647
|
+
if (updated) {
|
|
2648
|
+
this.maybeStartDelegatedChildRuns(updated);
|
|
2023
2649
|
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
2650
|
+
}
|
|
2024
2651
|
this.runRegistry.dispose(run.id);
|
|
2652
|
+
const latest = this.runRegistry.get(run.id);
|
|
2653
|
+
if (latest)
|
|
2654
|
+
this.drainPendingDelegatedReviews(latest);
|
|
2025
2655
|
},
|
|
2026
2656
|
});
|
|
2027
|
-
this.runRegistry.
|
|
2657
|
+
this.runRegistry.attachChildIfRunning(run.id, child);
|
|
2028
2658
|
const refreshed = this.runRegistry.get(run.id);
|
|
2029
2659
|
res.json(refreshed ? this.enrichRunForResponse(refreshed) : refreshed);
|
|
2030
2660
|
}
|
|
@@ -2110,9 +2740,12 @@ class AiHubServer {
|
|
|
2110
2740
|
if (updated)
|
|
2111
2741
|
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
2112
2742
|
this.runRegistry.dispose(run.id);
|
|
2743
|
+
const latest = this.runRegistry.get(run.id);
|
|
2744
|
+
if (latest)
|
|
2745
|
+
this.drainPendingDelegatedReviews(latest);
|
|
2113
2746
|
},
|
|
2114
2747
|
});
|
|
2115
|
-
this.runRegistry.
|
|
2748
|
+
this.runRegistry.attachChildIfRunning(run.id, child);
|
|
2116
2749
|
res.status(201).json(this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run));
|
|
2117
2750
|
}
|
|
2118
2751
|
catch (error) {
|
|
@@ -2158,7 +2791,7 @@ class AiHubServer {
|
|
|
2158
2791
|
this.runRegistry.dispose(run.id);
|
|
2159
2792
|
},
|
|
2160
2793
|
});
|
|
2161
|
-
this.runRegistry.
|
|
2794
|
+
this.runRegistry.attachChildIfRunning(run.id, child);
|
|
2162
2795
|
const refreshed = this.runRegistry.get(run.id);
|
|
2163
2796
|
res.json(refreshed ? this.enrichRunForResponse(refreshed) : refreshed);
|
|
2164
2797
|
}
|
|
@@ -2181,6 +2814,27 @@ class AiHubServer {
|
|
|
2181
2814
|
runs = runs.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit);
|
|
2182
2815
|
return res.json(runs.map((r) => this.enrichRunForResponse(r)));
|
|
2183
2816
|
});
|
|
2817
|
+
// ─── Issue #578: /api/ai-hub/runs/merged must be registered BEFORE :runId ──
|
|
2818
|
+
this.app.get('/api/ai-hub/runs/merged', async (req, res) => {
|
|
2819
|
+
const local = this.runRegistry.all().map((r) => this.enrichRunForResponse(r));
|
|
2820
|
+
const hosts = this.hostConfigStore.load();
|
|
2821
|
+
const remoteResults = await Promise.allSettled(hosts.map(async (host) => {
|
|
2822
|
+
const url = `${host.url.replace(/\/$/, '')}/api/ai-hub/runs`;
|
|
2823
|
+
const resp = await fetch(url, {
|
|
2824
|
+
signal: AbortSignal.timeout(8000),
|
|
2825
|
+
headers: host.authToken ? { 'X-Hub-Auth': host.authToken } : {},
|
|
2826
|
+
});
|
|
2827
|
+
if (!resp.ok)
|
|
2828
|
+
return [];
|
|
2829
|
+
return resp.json();
|
|
2830
|
+
}));
|
|
2831
|
+
const remote = remoteResults
|
|
2832
|
+
.filter((r) => r.status === 'fulfilled')
|
|
2833
|
+
.flatMap((r) => r.value);
|
|
2834
|
+
const merged = [...local, ...remote].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
2835
|
+
return res.json(merged);
|
|
2836
|
+
});
|
|
2837
|
+
// GET /api/ai-hub/runs/:runId — registered AFTER /merged to avoid shadowing.
|
|
2184
2838
|
this.app.get('/api/ai-hub/runs/:runId', (req, res) => {
|
|
2185
2839
|
const run = this.runRegistry.get(req.params.runId);
|
|
2186
2840
|
if (!run) {
|
|
@@ -2188,6 +2842,216 @@ class AiHubServer {
|
|
|
2188
2842
|
}
|
|
2189
2843
|
return res.json(this.enrichRunForResponse(run));
|
|
2190
2844
|
});
|
|
2845
|
+
// ─── Issue #578: Scheduled + Reactive Employees ───────────────────────────
|
|
2846
|
+
// POST /api/ai-hub/schedules — create a recurring scheduled deployment.
|
|
2847
|
+
this.app.post('/api/ai-hub/schedules', (req, res) => {
|
|
2848
|
+
const { label, jobId, projectPath, hostId, cronExpr, instructions, outputChannel, allowConcurrent } = req.body ?? {};
|
|
2849
|
+
if (!label || !jobId || !cronExpr) {
|
|
2850
|
+
return res.status(400).json({ error: 'label, jobId, and cronExpr are required.' });
|
|
2851
|
+
}
|
|
2852
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
2853
|
+
const cronLib = require('node-cron');
|
|
2854
|
+
if (!cronLib.validate(cronExpr)) {
|
|
2855
|
+
return res.status(400).json({ error: 'Invalid cron expression.' });
|
|
2856
|
+
}
|
|
2857
|
+
const validEmployees = VALID_EMPLOYEE_IDS;
|
|
2858
|
+
const resolvedHostId = validEmployees.includes(hostId) ? hostId : 'claude';
|
|
2859
|
+
const now = new Date().toISOString();
|
|
2860
|
+
const deployment = {
|
|
2861
|
+
id: (0, crypto_1.randomUUID)(),
|
|
2862
|
+
type: 'scheduled',
|
|
2863
|
+
label,
|
|
2864
|
+
jobId,
|
|
2865
|
+
projectPath: ensureDirectoryPath(projectPath || this.projectPath),
|
|
2866
|
+
hostId: resolvedHostId,
|
|
2867
|
+
cronExpr,
|
|
2868
|
+
instructions: typeof instructions === 'string' ? instructions : undefined,
|
|
2869
|
+
outputChannel: typeof outputChannel === 'string' ? outputChannel : undefined,
|
|
2870
|
+
allowConcurrent: allowConcurrent === true,
|
|
2871
|
+
active: true,
|
|
2872
|
+
createdAt: now,
|
|
2873
|
+
updatedAt: now,
|
|
2874
|
+
};
|
|
2875
|
+
this.deploymentStore.create(deployment);
|
|
2876
|
+
this.scheduleDeployment(deployment);
|
|
2877
|
+
return res.status(201).json(deployment);
|
|
2878
|
+
});
|
|
2879
|
+
// GET /api/ai-hub/schedules — list all scheduled deployments.
|
|
2880
|
+
this.app.get('/api/ai-hub/schedules', (_req, res) => {
|
|
2881
|
+
return res.json(this.deploymentStore.load().filter((d) => d.type === 'scheduled'));
|
|
2882
|
+
});
|
|
2883
|
+
// DELETE /api/ai-hub/schedules/:id — remove a scheduled deployment.
|
|
2884
|
+
this.app.delete('/api/ai-hub/schedules/:id', (req, res) => {
|
|
2885
|
+
const { id } = req.params;
|
|
2886
|
+
const task = this.cronHandles.get(id);
|
|
2887
|
+
if (task) {
|
|
2888
|
+
task.stop();
|
|
2889
|
+
this.cronHandles.delete(id);
|
|
2890
|
+
}
|
|
2891
|
+
const deleted = this.deploymentStore.delete(id);
|
|
2892
|
+
if (!deleted)
|
|
2893
|
+
return res.status(404).json({ error: 'Deployment not found.' });
|
|
2894
|
+
return res.json({ ok: true });
|
|
2895
|
+
});
|
|
2896
|
+
// PUT /api/ai-hub/schedules/:id — update an existing scheduled deployment.
|
|
2897
|
+
this.app.put('/api/ai-hub/schedules/:id', (req, res) => {
|
|
2898
|
+
const { id } = req.params;
|
|
2899
|
+
const { label, jobId, cronExpr, hostId, instructions, allowConcurrent } = req.body ?? {};
|
|
2900
|
+
if (cronExpr !== undefined) {
|
|
2901
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
2902
|
+
const cronLib = require('node-cron');
|
|
2903
|
+
if (!cronLib.validate(cronExpr)) {
|
|
2904
|
+
return res.status(400).json({ error: 'Invalid cron expression.' });
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
const validEmployees = VALID_EMPLOYEE_IDS;
|
|
2908
|
+
let updated = null;
|
|
2909
|
+
const ok = this.deploymentStore.update(id, (dep) => {
|
|
2910
|
+
if (label !== undefined)
|
|
2911
|
+
dep.label = label;
|
|
2912
|
+
if (jobId !== undefined)
|
|
2913
|
+
dep.jobId = jobId;
|
|
2914
|
+
if (cronExpr !== undefined)
|
|
2915
|
+
dep.cronExpr = cronExpr;
|
|
2916
|
+
if (hostId !== undefined && validEmployees.includes(hostId))
|
|
2917
|
+
dep.hostId = hostId;
|
|
2918
|
+
if (instructions !== undefined)
|
|
2919
|
+
dep.instructions = typeof instructions === 'string' ? instructions : undefined;
|
|
2920
|
+
if (allowConcurrent !== undefined)
|
|
2921
|
+
dep.allowConcurrent = allowConcurrent === true;
|
|
2922
|
+
updated = dep;
|
|
2923
|
+
});
|
|
2924
|
+
if (!ok || !updated)
|
|
2925
|
+
return res.status(404).json({ error: 'Deployment not found.' });
|
|
2926
|
+
const oldTask = this.cronHandles.get(id);
|
|
2927
|
+
if (oldTask) {
|
|
2928
|
+
oldTask.stop();
|
|
2929
|
+
this.cronHandles.delete(id);
|
|
2930
|
+
}
|
|
2931
|
+
this.scheduleDeployment(updated);
|
|
2932
|
+
return res.json(updated);
|
|
2933
|
+
});
|
|
2934
|
+
// POST /api/ai-hub/webhooks — register an inbound webhook deployment.
|
|
2935
|
+
this.app.post('/api/ai-hub/webhooks', (req, res) => {
|
|
2936
|
+
const { label, jobId, projectPath, hostId, instructions, outputChannel, allowConcurrent } = req.body ?? {};
|
|
2937
|
+
if (!label || !jobId) {
|
|
2938
|
+
return res.status(400).json({ error: 'label and jobId are required.' });
|
|
2939
|
+
}
|
|
2940
|
+
const validEmployees = VALID_EMPLOYEE_IDS;
|
|
2941
|
+
const resolvedHostId = validEmployees.includes(hostId) ? hostId : 'claude';
|
|
2942
|
+
const now = new Date().toISOString();
|
|
2943
|
+
const deployment = {
|
|
2944
|
+
id: (0, crypto_1.randomUUID)(),
|
|
2945
|
+
type: 'webhook',
|
|
2946
|
+
label,
|
|
2947
|
+
jobId,
|
|
2948
|
+
projectPath: ensureDirectoryPath(projectPath || this.projectPath),
|
|
2949
|
+
hostId: resolvedHostId,
|
|
2950
|
+
instructions: typeof instructions === 'string' ? instructions : undefined,
|
|
2951
|
+
outputChannel: typeof outputChannel === 'string' ? outputChannel : undefined,
|
|
2952
|
+
allowConcurrent: allowConcurrent === true,
|
|
2953
|
+
active: true,
|
|
2954
|
+
createdAt: now,
|
|
2955
|
+
updatedAt: now,
|
|
2956
|
+
};
|
|
2957
|
+
this.deploymentStore.create(deployment);
|
|
2958
|
+
return res.status(201).json({ ...deployment, inboundUrl: `${this.hubBase}/api/ai-hub/webhooks/${deployment.id}/inbound` });
|
|
2959
|
+
});
|
|
2960
|
+
// GET /api/ai-hub/webhooks — list all webhook deployments.
|
|
2961
|
+
this.app.get('/api/ai-hub/webhooks', (_req, res) => {
|
|
2962
|
+
const hubBase = this.hubBase;
|
|
2963
|
+
return res.json(this.deploymentStore.load()
|
|
2964
|
+
.filter((d) => d.type === 'webhook')
|
|
2965
|
+
.map((d) => ({ ...d, inboundUrl: `${hubBase}/api/ai-hub/webhooks/${d.id}/inbound` })));
|
|
2966
|
+
});
|
|
2967
|
+
// DELETE /api/ai-hub/webhooks/:id — remove a webhook deployment.
|
|
2968
|
+
this.app.delete('/api/ai-hub/webhooks/:id', (req, res) => {
|
|
2969
|
+
const deleted = this.deploymentStore.delete(req.params.id);
|
|
2970
|
+
if (!deleted)
|
|
2971
|
+
return res.status(404).json({ error: 'Deployment not found.' });
|
|
2972
|
+
return res.json({ ok: true });
|
|
2973
|
+
});
|
|
2974
|
+
// PUT /api/ai-hub/webhooks/:id — update an existing webhook deployment.
|
|
2975
|
+
this.app.put('/api/ai-hub/webhooks/:id', (req, res) => {
|
|
2976
|
+
const { id } = req.params;
|
|
2977
|
+
const { label, jobId, hostId, instructions, allowConcurrent } = req.body ?? {};
|
|
2978
|
+
const validEmployees = VALID_EMPLOYEE_IDS;
|
|
2979
|
+
let updated = null;
|
|
2980
|
+
const ok = this.deploymentStore.update(id, (dep) => {
|
|
2981
|
+
if (label !== undefined)
|
|
2982
|
+
dep.label = label;
|
|
2983
|
+
if (jobId !== undefined)
|
|
2984
|
+
dep.jobId = jobId;
|
|
2985
|
+
if (hostId !== undefined && validEmployees.includes(hostId))
|
|
2986
|
+
dep.hostId = hostId;
|
|
2987
|
+
if (instructions !== undefined)
|
|
2988
|
+
dep.instructions = typeof instructions === 'string' ? instructions : undefined;
|
|
2989
|
+
if (allowConcurrent !== undefined)
|
|
2990
|
+
dep.allowConcurrent = allowConcurrent === true;
|
|
2991
|
+
updated = dep;
|
|
2992
|
+
});
|
|
2993
|
+
if (!ok || !updated)
|
|
2994
|
+
return res.status(404).json({ error: 'Deployment not found.' });
|
|
2995
|
+
return res.json({ ...updated, inboundUrl: `${this.hubBase}/api/ai-hub/webhooks/${id}/inbound` });
|
|
2996
|
+
});
|
|
2997
|
+
// POST /api/ai-hub/webhooks/:id/inbound — webhook inbound trigger from external systems.
|
|
2998
|
+
this.app.post('/api/ai-hub/webhooks/:id/inbound', async (req, res) => {
|
|
2999
|
+
const deployments = this.deploymentStore.load();
|
|
3000
|
+
const deployment = deployments.find((d) => d.id === req.params.id && d.type === 'webhook' && d.active);
|
|
3001
|
+
if (!deployment) {
|
|
3002
|
+
return res.status(404).json({ error: 'Webhook not found or inactive.' });
|
|
3003
|
+
}
|
|
3004
|
+
try {
|
|
3005
|
+
const run = await this.fireDeploymentRun(deployment, req.body);
|
|
3006
|
+
return res.status(202).json({ runId: run.id, status: run.status });
|
|
3007
|
+
}
|
|
3008
|
+
catch (err) {
|
|
3009
|
+
const msg = err instanceof Error ? err.message : 'Failed to start run.';
|
|
3010
|
+
return res.status(500).json({ error: msg });
|
|
3011
|
+
}
|
|
3012
|
+
});
|
|
3013
|
+
// GET /api/ai-hub/hosts — list registered remote hosts with health status.
|
|
3014
|
+
this.app.get('/api/ai-hub/hosts', async (_req, res) => {
|
|
3015
|
+
const hosts = this.hostConfigStore.load();
|
|
3016
|
+
const healthResults = await Promise.allSettled(hosts.map((h) => pingHost(h)));
|
|
3017
|
+
const health = healthResults.map((r, i) => r.status === 'fulfilled'
|
|
3018
|
+
? r.value
|
|
3019
|
+
: { id: hosts[i].id, label: hosts[i].label, url: hosts[i].url, status: 'offline', latencyMs: null, lastPingAt: new Date().toISOString() });
|
|
3020
|
+
return res.json(health);
|
|
3021
|
+
});
|
|
3022
|
+
// POST /api/ai-hub/hosts — register a named remote hub host.
|
|
3023
|
+
this.app.post('/api/ai-hub/hosts', (req, res) => {
|
|
3024
|
+
const { label, url, authToken } = req.body ?? {};
|
|
3025
|
+
const validUrl = safeHttpUrl(url);
|
|
3026
|
+
if (!label || !validUrl) {
|
|
3027
|
+
return res.status(400).json({ error: 'label and a valid http(s) url are required.' });
|
|
3028
|
+
}
|
|
3029
|
+
const host = {
|
|
3030
|
+
id: (0, crypto_1.randomUUID)(),
|
|
3031
|
+
label,
|
|
3032
|
+
url: validUrl,
|
|
3033
|
+
authToken: typeof authToken === 'string' && authToken ? authToken : undefined,
|
|
3034
|
+
createdAt: new Date().toISOString(),
|
|
3035
|
+
};
|
|
3036
|
+
this.hostConfigStore.add(host);
|
|
3037
|
+
return res.status(201).json({ id: host.id, label: host.label, url: host.url, createdAt: host.createdAt });
|
|
3038
|
+
});
|
|
3039
|
+
// DELETE /api/ai-hub/hosts/:id — remove a named remote host.
|
|
3040
|
+
this.app.delete('/api/ai-hub/hosts/:id', (req, res) => {
|
|
3041
|
+
const deleted = this.hostConfigStore.delete(req.params.id);
|
|
3042
|
+
if (!deleted)
|
|
3043
|
+
return res.status(404).json({ error: 'Host not found.' });
|
|
3044
|
+
return res.json({ ok: true });
|
|
3045
|
+
});
|
|
3046
|
+
// GET /api/ai-hub/hosts/:id/health — ping a single host.
|
|
3047
|
+
this.app.get('/api/ai-hub/hosts/:id/health', async (req, res) => {
|
|
3048
|
+
const host = this.hostConfigStore.load().find((h) => h.id === req.params.id);
|
|
3049
|
+
if (!host)
|
|
3050
|
+
return res.status(404).json({ error: 'Host not found.' });
|
|
3051
|
+
const health = await pingHost(host);
|
|
3052
|
+
return res.json(health);
|
|
3053
|
+
});
|
|
3054
|
+
// ─── End Issue #578 ───────────────────────────────────────────────────────
|
|
2191
3055
|
// -------------------------------------------------------------------------
|
|
2192
3056
|
// Issue #489: POST /api/trigger
|
|
2193
3057
|
// Stable API endpoint for extension surfaces (Office add-ins, browser
|
|
@@ -2270,7 +3134,7 @@ class AiHubServer {
|
|
|
2270
3134
|
},
|
|
2271
3135
|
}, run.sessionId);
|
|
2272
3136
|
// Update the registry entry with the real child process handle.
|
|
2273
|
-
this.runRegistry.
|
|
3137
|
+
this.runRegistry.attachChildIfRunning(run.id, child);
|
|
2274
3138
|
return res.json({ runId: run.id, status: 'started', employee: employeeId, job: jobName });
|
|
2275
3139
|
}
|
|
2276
3140
|
catch (error) {
|
|
@@ -2286,6 +3150,121 @@ class AiHubServer {
|
|
|
2286
3150
|
process.env.FRAIM_BROWSER_CDP_ENDPOINT = result.endpoint;
|
|
2287
3151
|
return { endpoint: result.endpoint, reused: result.reused, channel: result.channel };
|
|
2288
3152
|
}
|
|
3153
|
+
// ─── Issue #578: Scheduled deployment helpers ─────────────────────────────
|
|
3154
|
+
rehydrateScheduledDeployments() {
|
|
3155
|
+
const active = this.deploymentStore.load().filter((d) => d.type === 'scheduled' && d.active);
|
|
3156
|
+
for (const dep of active) {
|
|
3157
|
+
this.scheduleDeployment(dep);
|
|
3158
|
+
}
|
|
3159
|
+
if (active.length > 0) {
|
|
3160
|
+
console.log(`[ai-hub] rehydrated ${active.length} scheduled deployment(s)`);
|
|
3161
|
+
}
|
|
3162
|
+
}
|
|
3163
|
+
scheduleDeployment(deployment) {
|
|
3164
|
+
if (!deployment.cronExpr)
|
|
3165
|
+
return;
|
|
3166
|
+
try {
|
|
3167
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
3168
|
+
const cron = require('node-cron');
|
|
3169
|
+
if (!cron.validate(deployment.cronExpr)) {
|
|
3170
|
+
console.warn(`[ai-hub] invalid cronExpr for deployment ${deployment.id}: ${deployment.cronExpr}`);
|
|
3171
|
+
return;
|
|
3172
|
+
}
|
|
3173
|
+
const task = cron.schedule(deployment.cronExpr, async () => {
|
|
3174
|
+
try {
|
|
3175
|
+
await this.fireDeploymentRun(deployment);
|
|
3176
|
+
}
|
|
3177
|
+
catch (err) {
|
|
3178
|
+
console.warn(`[ai-hub] scheduled deployment ${deployment.id} fire failed:`, err);
|
|
3179
|
+
}
|
|
3180
|
+
});
|
|
3181
|
+
this.cronHandles.set(deployment.id, task);
|
|
3182
|
+
}
|
|
3183
|
+
catch (err) {
|
|
3184
|
+
console.warn('[ai-hub] node-cron not available — scheduled deployments require node-cron:', err);
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
async fireDeploymentRun(deployment, webhookBody) {
|
|
3188
|
+
// Overlapping run guard: if the prior run is still active and allowConcurrent is false, skip.
|
|
3189
|
+
if (!deployment.allowConcurrent && deployment.activeRunId) {
|
|
3190
|
+
const active = this.runRegistry.get(deployment.activeRunId);
|
|
3191
|
+
if (active && active.status === 'running') {
|
|
3192
|
+
console.log(`[ai-hub] deployment ${deployment.id} skipped — prior run ${deployment.activeRunId} still running`);
|
|
3193
|
+
return active;
|
|
3194
|
+
}
|
|
3195
|
+
}
|
|
3196
|
+
const employees = this.hostRuntime.detectEmployees();
|
|
3197
|
+
const employee = employees.find((e) => e.id === deployment.hostId);
|
|
3198
|
+
if (!employee?.available) {
|
|
3199
|
+
throw new Error(`Employee ${deployment.hostId} is not available for scheduled/webhook run.`);
|
|
3200
|
+
}
|
|
3201
|
+
const instructions = [
|
|
3202
|
+
deployment.instructions ?? `/fraim ${deployment.jobId}`,
|
|
3203
|
+
webhookBody ? `\n\nInbound payload:\n${JSON.stringify(webhookBody, null, 2)}` : '',
|
|
3204
|
+
].join('').trim();
|
|
3205
|
+
const startTimestamp = new Date().toISOString();
|
|
3206
|
+
const run = {
|
|
3207
|
+
id: (0, crypto_1.randomUUID)(),
|
|
3208
|
+
jobId: deployment.jobId,
|
|
3209
|
+
hostId: deployment.hostId,
|
|
3210
|
+
projectPath: deployment.projectPath,
|
|
3211
|
+
status: 'running',
|
|
3212
|
+
sourceTrigger: deployment.type === 'scheduled' ? 'scheduled' : 'webhook',
|
|
3213
|
+
createdAt: startTimestamp,
|
|
3214
|
+
updatedAt: startTimestamp,
|
|
3215
|
+
messages: [(0, hosts_1.createHubMessage)('manager', instructions)],
|
|
3216
|
+
events: [(0, hosts_1.createHubEvent)('system', `Triggered by deployment: ${deployment.label} (${deployment.type})`)],
|
|
3217
|
+
currentPhase: null,
|
|
3218
|
+
phaseHistory: [],
|
|
3219
|
+
totals: emptyTotals(),
|
|
3220
|
+
lastStatusChangeAt: startTimestamp,
|
|
3221
|
+
// Deployment runs group under the selected employee (hostId), not the job's
|
|
3222
|
+
// protected persona. Leaving personaKey unset lets conversationRecordFromRun
|
|
3223
|
+
// fall through to agentName (= hostId), which is what the rail groups by.
|
|
3224
|
+
personaKey: null,
|
|
3225
|
+
};
|
|
3226
|
+
// Pre-register before startRun so synchronous onEvent calls (e.g. FakeHostRuntime)
|
|
3227
|
+
// can call runRegistry.update without "Run not found" throws.
|
|
3228
|
+
this.runRegistry.create(run, {});
|
|
3229
|
+
const child = this.hostRuntime.startRun(deployment.hostId, deployment.projectPath, instructions, {
|
|
3230
|
+
onEvent: (event, channel) => {
|
|
3231
|
+
this.runRegistry.update(run.id, (current) => {
|
|
3232
|
+
if (event.sessionId)
|
|
3233
|
+
current.sessionId = event.sessionId;
|
|
3234
|
+
appendHostMessage(current, deployment.hostId, event, channel);
|
|
3235
|
+
if (event.raw) {
|
|
3236
|
+
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
3237
|
+
applyReviewProjection(current, event.raw);
|
|
3238
|
+
}
|
|
3239
|
+
if (event.agentIdentity)
|
|
3240
|
+
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
3241
|
+
if (event.seekMentoring)
|
|
3242
|
+
applySeekMentoringSignal(current, event.seekMentoring);
|
|
3243
|
+
if (event.usage)
|
|
3244
|
+
applyUsageSignal(current, event.usage);
|
|
3245
|
+
});
|
|
3246
|
+
const updated = this.runRegistry.get(run.id);
|
|
3247
|
+
if (updated)
|
|
3248
|
+
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
3249
|
+
},
|
|
3250
|
+
onExit: (exitCode) => {
|
|
3251
|
+
this.runRegistry.update(run.id, (r) => {
|
|
3252
|
+
r.exitCode = exitCode;
|
|
3253
|
+
r.status = exitCode === 0 ? 'completed' : 'failed';
|
|
3254
|
+
r.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
|
|
3255
|
+
});
|
|
3256
|
+
const updated = this.runRegistry.get(run.id);
|
|
3257
|
+
if (updated)
|
|
3258
|
+
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
3259
|
+
this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = undefined; });
|
|
3260
|
+
this.runRegistry.dispose(run.id);
|
|
3261
|
+
},
|
|
3262
|
+
});
|
|
3263
|
+
this.runRegistry.create(run, child);
|
|
3264
|
+
this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = run.id; });
|
|
3265
|
+
return run;
|
|
3266
|
+
}
|
|
3267
|
+
// ─── End Issue #578 helpers ───────────────────────────────────────────────
|
|
2289
3268
|
// Issue #347 — assemble the read-side projection of a run. Stages are
|
|
2290
3269
|
// derived from job frontmatter + visited phases; totalDurationMs ticks
|
|
2291
3270
|
// forward while the run is still running so the UI's totals line
|
|
@@ -2326,7 +3305,7 @@ class AiHubServer {
|
|
|
2326
3305
|
liveTotals.waitingDurationMs = Math.max(0, liveTotals.waitingDurationMs - overflow);
|
|
2327
3306
|
}
|
|
2328
3307
|
}
|
|
2329
|
-
return { ...run, stages, totals: liveTotals };
|
|
3308
|
+
return { ...run, stages, totals: liveTotals, artifacts: extractRunReviewArtifacts(run) };
|
|
2330
3309
|
}
|
|
2331
3310
|
}
|
|
2332
3311
|
exports.AiHubServer = AiHubServer;
|