bosun 0.35.2 → 0.35.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/agent-hooks.mjs +7 -1
- package/agent-pool.mjs +16 -0
- package/agent-prompts.mjs +190 -4
- package/agent-sdk.mjs +6 -1
- package/agent-work-analyzer.mjs +48 -9
- package/autofix.mjs +32 -18
- package/bosun.schema.json +1 -1
- package/kanban-adapter.mjs +62 -12
- package/monitor.mjs +25 -6
- package/opencode-shell.mjs +881 -0
- package/package.json +5 -2
- package/primary-agent.mjs +43 -0
- package/session-tracker.mjs +55 -1
- package/setup.mjs +33 -4
- package/task-executor.mjs +43 -14
- package/ui/app.js +10 -7
- package/ui/components/chat-view.js +31 -9
- package/ui/components/session-list.js +20 -4
- package/ui/demo.html +49 -0
- package/ui/modules/router.js +2 -0
- package/ui/tabs/agents.js +66 -8
- package/ui/tabs/workflows.js +83 -0
- package/ui-server.mjs +236 -5
- package/workflow-engine.mjs +664 -10
- package/workflow-nodes.mjs +250 -1
- package/workflow-templates/github.mjs +389 -71
- package/workflow-templates/planning.mjs +31 -11
- package/workflow-templates.mjs +219 -2
package/ui/tabs/agents.js
CHANGED
|
@@ -1503,14 +1503,7 @@ export function AgentsTab() {
|
|
|
1503
1503
|
</div>
|
|
1504
1504
|
|
|
1505
1505
|
<div class="fleet-span">
|
|
1506
|
-
|
|
1507
|
-
slots=${slots}
|
|
1508
|
-
onOpenWorkspace=${openWorkspace}
|
|
1509
|
-
onForceStop=${handleForceStop}
|
|
1510
|
-
/>
|
|
1511
|
-
</div>
|
|
1512
|
-
|
|
1513
|
-
${agents.length > 0 &&
|
|
1506
|
+
${agents.length > 0 &&
|
|
1514
1507
|
html`
|
|
1515
1508
|
<div class="fleet-span">
|
|
1516
1509
|
<${Collapsible} title="Agent Threads" defaultOpen=${false}>
|
|
@@ -1971,3 +1964,68 @@ function FleetSessionsPanel({ slots, onOpenWorkspace, onForceStop }) {
|
|
|
1971
1964
|
<//>
|
|
1972
1965
|
`;
|
|
1973
1966
|
}
|
|
1967
|
+
|
|
1968
|
+
/* ─── Fleet Sessions Tab (standalone) ─── */
|
|
1969
|
+
export function FleetSessionsTab() {
|
|
1970
|
+
const executor = executorData.value;
|
|
1971
|
+
const execData = executor?.data;
|
|
1972
|
+
const slots = execData?.slots || [];
|
|
1973
|
+
|
|
1974
|
+
useEffect(() => {
|
|
1975
|
+
let active = true;
|
|
1976
|
+
const refreshTaskSessions = () => {
|
|
1977
|
+
if (!active) return;
|
|
1978
|
+
loadSessions({ type: "task" });
|
|
1979
|
+
};
|
|
1980
|
+
refreshTaskSessions();
|
|
1981
|
+
const interval = setInterval(refreshTaskSessions, 5000);
|
|
1982
|
+
return () => {
|
|
1983
|
+
active = false;
|
|
1984
|
+
clearInterval(interval);
|
|
1985
|
+
};
|
|
1986
|
+
}, []);
|
|
1987
|
+
|
|
1988
|
+
/* Force stop a specific agent slot */
|
|
1989
|
+
const handleForceStop = async (slot) => {
|
|
1990
|
+
const ok = await showConfirm(
|
|
1991
|
+
`Force-stop agent working on "${truncate(slot.taskTitle || slot.taskId || "task", 40)}"?`,
|
|
1992
|
+
);
|
|
1993
|
+
if (!ok) return;
|
|
1994
|
+
haptic("heavy");
|
|
1995
|
+
try {
|
|
1996
|
+
await apiFetch("/api/executor/stop-slot", {
|
|
1997
|
+
method: "POST",
|
|
1998
|
+
body: JSON.stringify({ slotIndex: slot.index, taskId: slot.taskId }),
|
|
1999
|
+
});
|
|
2000
|
+
showToast("Stop signal sent", "success");
|
|
2001
|
+
scheduleRefresh(200);
|
|
2002
|
+
} catch {
|
|
2003
|
+
/* toast via apiFetch */
|
|
2004
|
+
}
|
|
2005
|
+
};
|
|
2006
|
+
|
|
2007
|
+
/* Open workspace viewer for an agent */
|
|
2008
|
+
const [selectedAgent, setSelectedAgent] = useState(null);
|
|
2009
|
+
const openWorkspace = (slot, i) => {
|
|
2010
|
+
haptic();
|
|
2011
|
+
setSelectedAgent({ ...slot, index: i });
|
|
2012
|
+
};
|
|
2013
|
+
|
|
2014
|
+
return html`
|
|
2015
|
+
<div class="fleet-layout">
|
|
2016
|
+
<div class="fleet-span">
|
|
2017
|
+
<${FleetSessionsPanel}
|
|
2018
|
+
slots=${slots}
|
|
2019
|
+
onOpenWorkspace=${openWorkspace}
|
|
2020
|
+
onForceStop=${handleForceStop}
|
|
2021
|
+
/>
|
|
2022
|
+
</div>
|
|
2023
|
+
</div>
|
|
2024
|
+
${selectedAgent && html`
|
|
2025
|
+
<${WorkspaceViewer}
|
|
2026
|
+
agent=${selectedAgent}
|
|
2027
|
+
onClose=${() => setSelectedAgent(null)}
|
|
2028
|
+
/>
|
|
2029
|
+
`}
|
|
2030
|
+
`;
|
|
2031
|
+
}
|
package/ui/tabs/workflows.js
CHANGED
|
@@ -184,6 +184,29 @@ async function installTemplate(templateId) {
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
|
|
187
|
+
async function applyTemplateUpdate(workflowId, mode = "replace", force = false) {
|
|
188
|
+
try {
|
|
189
|
+
const data = await apiFetch(`/api/workflows/${encodeURIComponent(workflowId)}/template-update`, {
|
|
190
|
+
method: "POST",
|
|
191
|
+
headers: { "Content-Type": "application/json" },
|
|
192
|
+
body: JSON.stringify({ mode, force }),
|
|
193
|
+
});
|
|
194
|
+
if (data?.workflow) {
|
|
195
|
+
showToast(
|
|
196
|
+
mode === "copy"
|
|
197
|
+
? "Updated template copy created"
|
|
198
|
+
: "Workflow updated to latest template",
|
|
199
|
+
"success",
|
|
200
|
+
);
|
|
201
|
+
loadWorkflows();
|
|
202
|
+
return data.workflow;
|
|
203
|
+
}
|
|
204
|
+
} catch (err) {
|
|
205
|
+
showToast(`Template update failed: ${err.message}`, "error");
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
187
210
|
async function loadRuns(workflowId) {
|
|
188
211
|
try {
|
|
189
212
|
const url = workflowId
|
|
@@ -1570,6 +1593,11 @@ function WorkflowListView() {
|
|
|
1570
1593
|
</h3>
|
|
1571
1594
|
<div style="display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));">
|
|
1572
1595
|
${wfs.map(wf => html`
|
|
1596
|
+
${(() => {
|
|
1597
|
+
const templateState = wf.metadata?.templateState || null;
|
|
1598
|
+
const hasTemplateUpdate = templateState?.updateAvailable === true;
|
|
1599
|
+
const isCustomizedTemplate = templateState?.isCustomized === true;
|
|
1600
|
+
return html`
|
|
1573
1601
|
<div key=${wf.id} class="wf-card" style="background: var(--color-bg-secondary, #1a1f2e); border-radius: 12px; padding: 14px; border: 1px solid var(--color-border, #2a3040); cursor: pointer; transition: border-color 0.15s;"
|
|
1574
1602
|
onClick=${() => {
|
|
1575
1603
|
apiFetch("/api/workflows/" + wf.id).then(d => {
|
|
@@ -1583,17 +1611,70 @@ function WorkflowListView() {
|
|
|
1583
1611
|
<span class="wf-badge" style="background: ${wf.enabled ? '#10b98130' : '#6b728030'}; color: ${wf.enabled ? '#10b981' : '#6b7280'}; font-size: 10px;">
|
|
1584
1612
|
${wf.enabled ? "Active" : "Paused"}
|
|
1585
1613
|
</span>
|
|
1614
|
+
${templateState?.templateId && html`
|
|
1615
|
+
<span class="wf-badge" style="background: #3b82f620; color: #60a5fa; font-size: 10px;">
|
|
1616
|
+
Template
|
|
1617
|
+
</span>
|
|
1618
|
+
`}
|
|
1619
|
+
${isCustomizedTemplate && html`
|
|
1620
|
+
<span class="wf-badge" style="background: #f59e0b20; color: #f59e0b; font-size: 10px;">
|
|
1621
|
+
Customized
|
|
1622
|
+
</span>
|
|
1623
|
+
`}
|
|
1624
|
+
${hasTemplateUpdate && html`
|
|
1625
|
+
<span class="wf-badge" style="background: #ef444420; color: #f87171; font-size: 10px;">
|
|
1626
|
+
Update Available
|
|
1627
|
+
</span>
|
|
1628
|
+
`}
|
|
1586
1629
|
</div>
|
|
1587
1630
|
${wf.description && html`
|
|
1588
1631
|
<div style="font-size: 12px; color: var(--color-text-secondary, #8b95a5); margin-bottom: 8px; line-height: 1.4;">
|
|
1589
1632
|
${wf.description.slice(0, 120)}${wf.description.length > 120 ? "…" : ""}
|
|
1590
1633
|
</div>
|
|
1591
1634
|
`}
|
|
1635
|
+
${templateState?.templateId && html`
|
|
1636
|
+
<div style="font-size: 11px; color: var(--color-text-secondary, #7f8aa0); margin-bottom: 8px;">
|
|
1637
|
+
${templateState.templateName || templateState.templateId}
|
|
1638
|
+
${templateState.installedTemplateVersion && templateState.templateVersion && templateState.installedTemplateVersion !== templateState.templateVersion && html`
|
|
1639
|
+
<span> · v${templateState.installedTemplateVersion} → v${templateState.templateVersion}</span>
|
|
1640
|
+
`}
|
|
1641
|
+
</div>
|
|
1642
|
+
`}
|
|
1592
1643
|
<div style="display: flex; gap: 8px; align-items: center; font-size: 11px; color: var(--color-text-secondary, #6b7280);">
|
|
1593
1644
|
<span>${wf.nodeCount || 0} nodes</span>
|
|
1594
1645
|
<span>·</span>
|
|
1595
1646
|
<span>${wf.category || "custom"}</span>
|
|
1596
1647
|
<div style="flex: 1;"></div>
|
|
1648
|
+
${hasTemplateUpdate && html`
|
|
1649
|
+
<button
|
|
1650
|
+
class="wf-btn wf-btn-sm"
|
|
1651
|
+
style="font-size: 11px; border-color: #f59e0b80; color: #f59e0b;"
|
|
1652
|
+
onClick=${async (e) => {
|
|
1653
|
+
e.stopPropagation();
|
|
1654
|
+
if (!isCustomizedTemplate) {
|
|
1655
|
+
await applyTemplateUpdate(wf.id, "replace", true);
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
const choice = window.prompt(
|
|
1659
|
+
"Template update available for customized workflow.\nType 'copy' to create an updated copy, or 'replace' to overwrite this workflow.",
|
|
1660
|
+
"copy",
|
|
1661
|
+
);
|
|
1662
|
+
const normalized = String(choice || "").trim().toLowerCase();
|
|
1663
|
+
if (normalized === "copy") {
|
|
1664
|
+
await applyTemplateUpdate(wf.id, "copy", false);
|
|
1665
|
+
return;
|
|
1666
|
+
}
|
|
1667
|
+
if (normalized === "replace") {
|
|
1668
|
+
const ok = window.confirm("Replace this customized workflow with latest template? This cannot be undone.");
|
|
1669
|
+
if (!ok) return;
|
|
1670
|
+
await applyTemplateUpdate(wf.id, "replace", true);
|
|
1671
|
+
}
|
|
1672
|
+
}}
|
|
1673
|
+
>
|
|
1674
|
+
<span class="icon-inline">${resolveIcon("refresh")}</span>
|
|
1675
|
+
Update
|
|
1676
|
+
</button>
|
|
1677
|
+
`}
|
|
1597
1678
|
<button
|
|
1598
1679
|
class="wf-btn wf-btn-sm"
|
|
1599
1680
|
style="font-size: 11px;"
|
|
@@ -1624,6 +1705,8 @@ function WorkflowListView() {
|
|
|
1624
1705
|
</button>
|
|
1625
1706
|
</div>
|
|
1626
1707
|
</div>
|
|
1708
|
+
`;
|
|
1709
|
+
})()}
|
|
1627
1710
|
`)}
|
|
1628
1711
|
</div>
|
|
1629
1712
|
</div>
|
package/ui-server.mjs
CHANGED
|
@@ -355,6 +355,15 @@ async function getWorkflowEngineModule() {
|
|
|
355
355
|
};
|
|
356
356
|
_wfEngine.getWorkflowEngine({ services });
|
|
357
357
|
_wfServicesReady = true;
|
|
358
|
+
|
|
359
|
+
// Resume any runs that were interrupted by a previous shutdown.
|
|
360
|
+
// This must happen AFTER services are wired so node executors work.
|
|
361
|
+
const engine = _wfEngine.getWorkflowEngine();
|
|
362
|
+
if (typeof engine.resumeInterruptedRuns === "function") {
|
|
363
|
+
engine.resumeInterruptedRuns().catch((err) => {
|
|
364
|
+
console.warn("[workflows] Failed to resume interrupted runs:", err.message);
|
|
365
|
+
});
|
|
366
|
+
}
|
|
358
367
|
} catch (err) {
|
|
359
368
|
console.warn("[workflows] services setup failed (engine still usable):", err.message);
|
|
360
369
|
}
|
|
@@ -397,6 +406,27 @@ async function getWorkflowEngineModule() {
|
|
|
397
406
|
if (result.errors.length) {
|
|
398
407
|
console.warn("[workflows] Default template install errors:", result.errors);
|
|
399
408
|
}
|
|
409
|
+
if (typeof _wfTemplates.reconcileInstalledTemplates === "function") {
|
|
410
|
+
const reconcile = _wfTemplates.reconcileInstalledTemplates(engine, {
|
|
411
|
+
autoUpdateUnmodified: true,
|
|
412
|
+
});
|
|
413
|
+
if (reconcile.autoUpdated > 0) {
|
|
414
|
+
console.log(
|
|
415
|
+
`[workflows] Auto-updated ${reconcile.autoUpdated} unmodified template workflow(s) to latest`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
if (reconcile.customized.length > 0) {
|
|
419
|
+
const pending = reconcile.customized.filter((entry) => entry.updateAvailable).length;
|
|
420
|
+
if (pending > 0) {
|
|
421
|
+
console.log(
|
|
422
|
+
`[workflows] ${pending} customized template workflow(s) have updates available`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (reconcile.errors.length > 0) {
|
|
427
|
+
console.warn("[workflows] Template reconcile errors:", reconcile.errors);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
400
430
|
} catch (err) {
|
|
401
431
|
console.warn("[workflows] Default template install failed:", err.message);
|
|
402
432
|
} finally {
|
|
@@ -1361,6 +1391,28 @@ let uiInstanceLockHeld = false;
|
|
|
1361
1391
|
const logStreamers = new Map();
|
|
1362
1392
|
let uiDeps = {};
|
|
1363
1393
|
|
|
1394
|
+
/**
|
|
1395
|
+
* Resolve the execPrimaryPrompt function. Prefers the injected dependency,
|
|
1396
|
+
* falls back to importing directly from primary-agent.mjs so the chat
|
|
1397
|
+
* agent works even when the UI server starts standalone.
|
|
1398
|
+
*/
|
|
1399
|
+
let _fallbackExecPrimaryPrompt = null;
|
|
1400
|
+
async function resolveExecPrimaryPrompt() {
|
|
1401
|
+
if (typeof uiDeps.execPrimaryPrompt === "function") return uiDeps.execPrimaryPrompt;
|
|
1402
|
+
if (_fallbackExecPrimaryPrompt) return _fallbackExecPrimaryPrompt;
|
|
1403
|
+
try {
|
|
1404
|
+
const mod = await import("./primary-agent.mjs");
|
|
1405
|
+
if (typeof mod.execPrimaryPrompt === "function") {
|
|
1406
|
+
_fallbackExecPrimaryPrompt = mod.execPrimaryPrompt;
|
|
1407
|
+
console.log("[ui-server] loaded execPrimaryPrompt fallback from primary-agent.mjs");
|
|
1408
|
+
return _fallbackExecPrimaryPrompt;
|
|
1409
|
+
}
|
|
1410
|
+
} catch (err) {
|
|
1411
|
+
console.warn("[ui-server] failed to load execPrimaryPrompt fallback:", err.message);
|
|
1412
|
+
}
|
|
1413
|
+
return null;
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1364
1416
|
/**
|
|
1365
1417
|
* Resolve the bosun config directory. Falls back through:
|
|
1366
1418
|
* 1. uiDeps.configDir (injected at server start)
|
|
@@ -6227,6 +6279,9 @@ async function handleApi(req, res, url) {
|
|
|
6227
6279
|
const wfMod = await getWorkflowEngine();
|
|
6228
6280
|
if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
|
|
6229
6281
|
const engine = wfMod.getWorkflowEngine();
|
|
6282
|
+
if (typeof _wfTemplates?.applyWorkflowTemplateState === "function") {
|
|
6283
|
+
_wfTemplates.applyWorkflowTemplateState(body);
|
|
6284
|
+
}
|
|
6230
6285
|
const saved = await engine.save(body);
|
|
6231
6286
|
jsonResponse(res, 200, { ok: true, workflow: saved });
|
|
6232
6287
|
} catch (err) {
|
|
@@ -6263,6 +6318,65 @@ async function handleApi(req, res, url) {
|
|
|
6263
6318
|
return;
|
|
6264
6319
|
}
|
|
6265
6320
|
|
|
6321
|
+
if (path === "/api/workflows/template-updates") {
|
|
6322
|
+
try {
|
|
6323
|
+
const wfMod = await getWorkflowEngine();
|
|
6324
|
+
if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
|
|
6325
|
+
const engine = wfMod.getWorkflowEngine();
|
|
6326
|
+
if (typeof _wfTemplates?.reconcileInstalledTemplates === "function") {
|
|
6327
|
+
_wfTemplates.reconcileInstalledTemplates(engine, {
|
|
6328
|
+
autoUpdateUnmodified: true,
|
|
6329
|
+
});
|
|
6330
|
+
}
|
|
6331
|
+
const updates = engine
|
|
6332
|
+
.list()
|
|
6333
|
+
.map((wf) => {
|
|
6334
|
+
const state = wf.metadata?.templateState || null;
|
|
6335
|
+
if (!state?.templateId) return null;
|
|
6336
|
+
return {
|
|
6337
|
+
workflowId: wf.id,
|
|
6338
|
+
workflowName: wf.name,
|
|
6339
|
+
templateId: state.templateId,
|
|
6340
|
+
templateName: state.templateName || state.templateId,
|
|
6341
|
+
updateAvailable: state.updateAvailable === true,
|
|
6342
|
+
isCustomized: state.isCustomized === true,
|
|
6343
|
+
templateVersion: state.templateVersion || null,
|
|
6344
|
+
installedTemplateVersion: state.installedTemplateVersion || null,
|
|
6345
|
+
};
|
|
6346
|
+
})
|
|
6347
|
+
.filter(Boolean);
|
|
6348
|
+
jsonResponse(res, 200, { ok: true, updates });
|
|
6349
|
+
} catch (err) {
|
|
6350
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
6351
|
+
}
|
|
6352
|
+
return;
|
|
6353
|
+
}
|
|
6354
|
+
|
|
6355
|
+
if (path.startsWith("/api/workflows/") && path.endsWith("/template-update")) {
|
|
6356
|
+
try {
|
|
6357
|
+
const wfMod = await getWorkflowEngine();
|
|
6358
|
+
if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
|
|
6359
|
+
const engine = wfMod.getWorkflowEngine();
|
|
6360
|
+
const workflowId = decodeURIComponent(path.split("/")[3] || "");
|
|
6361
|
+
if (!workflowId) {
|
|
6362
|
+
jsonResponse(res, 400, { ok: false, error: "Missing workflow id" });
|
|
6363
|
+
return;
|
|
6364
|
+
}
|
|
6365
|
+
const body = await readJsonBody(req).catch(() => ({}));
|
|
6366
|
+
const mode = String(body?.mode || "replace").toLowerCase();
|
|
6367
|
+
const force = body?.force === true;
|
|
6368
|
+
if (typeof _wfTemplates?.updateWorkflowFromTemplate !== "function") {
|
|
6369
|
+
jsonResponse(res, 503, { ok: false, error: "Template update service unavailable" });
|
|
6370
|
+
return;
|
|
6371
|
+
}
|
|
6372
|
+
const workflow = _wfTemplates.updateWorkflowFromTemplate(engine, workflowId, { mode, force });
|
|
6373
|
+
jsonResponse(res, 200, { ok: true, workflow });
|
|
6374
|
+
} catch (err) {
|
|
6375
|
+
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
6376
|
+
}
|
|
6377
|
+
return;
|
|
6378
|
+
}
|
|
6379
|
+
|
|
6266
6380
|
if (path === "/api/workflows/node-types") {
|
|
6267
6381
|
try {
|
|
6268
6382
|
const wfMod = await getWorkflowEngine();
|
|
@@ -6302,11 +6416,68 @@ async function handleApi(req, res, url) {
|
|
|
6302
6416
|
const wfMod = await getWorkflowEngine();
|
|
6303
6417
|
if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
|
|
6304
6418
|
const engine = wfMod.getWorkflowEngine();
|
|
6305
|
-
const
|
|
6419
|
+
const subPath = path.replace("/api/workflows/runs/", "");
|
|
6420
|
+
const segments = subPath.split("/").map(decodeURIComponent);
|
|
6421
|
+
const runId = (segments[0] || "").trim();
|
|
6422
|
+
const action = (segments[1] || "").trim();
|
|
6423
|
+
|
|
6306
6424
|
if (!runId) {
|
|
6307
6425
|
jsonResponse(res, 400, { ok: false, error: "runId is required" });
|
|
6308
6426
|
return;
|
|
6309
6427
|
}
|
|
6428
|
+
|
|
6429
|
+
// ── POST /api/workflows/runs/:id/retry ──────────────────────────
|
|
6430
|
+
// Manual retry endpoint. Accepts { mode: "from_failed" | "from_scratch" }.
|
|
6431
|
+
// If mode is omitted, returns available retry options so the UI can
|
|
6432
|
+
// present a choice to the user.
|
|
6433
|
+
if (action === "retry" && req.method === "POST") {
|
|
6434
|
+
const run = engine.getRunDetail ? engine.getRunDetail(runId) : null;
|
|
6435
|
+
if (!run) {
|
|
6436
|
+
jsonResponse(res, 404, { ok: false, error: "Workflow run not found" });
|
|
6437
|
+
return;
|
|
6438
|
+
}
|
|
6439
|
+
if (run.status !== "failed") {
|
|
6440
|
+
jsonResponse(res, 400, { ok: false, error: `Run status is "${run.status}" — only failed runs can be retried` });
|
|
6441
|
+
return;
|
|
6442
|
+
}
|
|
6443
|
+
const body = await readJsonBody(req);
|
|
6444
|
+
const mode = body?.mode;
|
|
6445
|
+
if (!mode) {
|
|
6446
|
+
// No mode specified — return available retry options so the UI can
|
|
6447
|
+
// present a picker (from scratch vs from failed step).
|
|
6448
|
+
const failedNodes = [];
|
|
6449
|
+
const nodeStatuses = run.detail?.nodeStatuses || {};
|
|
6450
|
+
for (const [nodeId, status] of Object.entries(nodeStatuses)) {
|
|
6451
|
+
if (status === "failed") failedNodes.push(nodeId);
|
|
6452
|
+
}
|
|
6453
|
+
jsonResponse(res, 200, {
|
|
6454
|
+
ok: true,
|
|
6455
|
+
runId,
|
|
6456
|
+
status: run.status,
|
|
6457
|
+
options: [
|
|
6458
|
+
{ mode: "from_failed", label: "Retry from last failed step", failedNodes },
|
|
6459
|
+
{ mode: "from_scratch", label: "Retry from scratch" },
|
|
6460
|
+
],
|
|
6461
|
+
});
|
|
6462
|
+
return;
|
|
6463
|
+
}
|
|
6464
|
+
if (mode !== "from_failed" && mode !== "from_scratch") {
|
|
6465
|
+
jsonResponse(res, 400, { ok: false, error: `Invalid mode "${mode}". Use "from_failed" or "from_scratch".` });
|
|
6466
|
+
return;
|
|
6467
|
+
}
|
|
6468
|
+
const result = await engine.retryRun(runId, { mode });
|
|
6469
|
+
const retryStatus = result.ctx?.errors?.length > 0 ? "failed" : "completed";
|
|
6470
|
+
jsonResponse(res, 200, {
|
|
6471
|
+
ok: true,
|
|
6472
|
+
retryRunId: result.retryRunId,
|
|
6473
|
+
originalRunId: result.originalRunId,
|
|
6474
|
+
mode: result.mode,
|
|
6475
|
+
status: retryStatus,
|
|
6476
|
+
});
|
|
6477
|
+
return;
|
|
6478
|
+
}
|
|
6479
|
+
|
|
6480
|
+
// ── GET /api/workflows/runs/:id ─────────────────────────────────
|
|
6310
6481
|
const run = engine.getRunDetail ? engine.getRunDetail(runId) : null;
|
|
6311
6482
|
if (!run) {
|
|
6312
6483
|
jsonResponse(res, 404, { ok: false, error: "Workflow run not found" });
|
|
@@ -7217,7 +7388,26 @@ async function handleApi(req, res, url) {
|
|
|
7217
7388
|
jsonResponse(res, 404, { ok: false, error: "Session not found" });
|
|
7218
7389
|
return;
|
|
7219
7390
|
}
|
|
7220
|
-
|
|
7391
|
+
// Support ?limit=N&offset=N for message pagination
|
|
7392
|
+
const reqUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`);
|
|
7393
|
+
const limitParam = reqUrl.searchParams.get("limit");
|
|
7394
|
+
const offsetParam = reqUrl.searchParams.get("offset");
|
|
7395
|
+
if (limitParam) {
|
|
7396
|
+
const limit = Math.max(1, Math.min(Number(limitParam) || 20, 500));
|
|
7397
|
+
const allMessages = session.messages || [];
|
|
7398
|
+
const total = allMessages.length;
|
|
7399
|
+
const offset = offsetParam != null
|
|
7400
|
+
? Math.max(0, Math.min(Number(offsetParam) || 0, total))
|
|
7401
|
+
: Math.max(0, total - limit);
|
|
7402
|
+
const sliced = allMessages.slice(offset, offset + limit);
|
|
7403
|
+
jsonResponse(res, 200, {
|
|
7404
|
+
ok: true,
|
|
7405
|
+
session: { ...session, messages: sliced },
|
|
7406
|
+
pagination: { total, offset, limit, hasMore: offset > 0 },
|
|
7407
|
+
});
|
|
7408
|
+
} else {
|
|
7409
|
+
jsonResponse(res, 200, { ok: true, session });
|
|
7410
|
+
}
|
|
7221
7411
|
} catch (err) {
|
|
7222
7412
|
jsonResponse(res, 500, { ok: false, error: err.message });
|
|
7223
7413
|
}
|
|
@@ -7306,12 +7496,44 @@ async function handleApi(req, res, url) {
|
|
|
7306
7496
|
const messageModel = body?.model || undefined;
|
|
7307
7497
|
|
|
7308
7498
|
// Forward to primary agent if applicable (exec records user + assistant events)
|
|
7309
|
-
|
|
7499
|
+
let exec = session.type === "primary" ? uiDeps.execPrimaryPrompt : null;
|
|
7500
|
+
// Fallback: resolve execPrimaryPrompt from primary-agent.mjs if not injected
|
|
7501
|
+
if (!exec && session.type === "primary") {
|
|
7502
|
+
exec = await resolveExecPrimaryPrompt();
|
|
7503
|
+
}
|
|
7310
7504
|
if (exec) {
|
|
7311
7505
|
// Don't record user event here — execPrimaryPrompt records it
|
|
7312
7506
|
// Respond immediately so the UI doesn't block on agent execution
|
|
7313
7507
|
jsonResponse(res, 200, { ok: true, messageId });
|
|
7314
7508
|
broadcastUiEvent(["sessions"], "invalidate", { reason: "session-message", sessionId });
|
|
7509
|
+
|
|
7510
|
+
// Build an onEvent callback so intermediate SDK events (thinking,
|
|
7511
|
+
// tool calls, code edits, etc.) are streamed to the UI in real-time
|
|
7512
|
+
// via the existing session-tracker → WebSocket listener pipeline.
|
|
7513
|
+
// Without this, chat/telegram dispatches only show the final
|
|
7514
|
+
// user+assistant pair instead of the full thought stream that Flows
|
|
7515
|
+
// clients see.
|
|
7516
|
+
const streamOnEvent = (err, event) => {
|
|
7517
|
+
// The adapters call onEvent(err, event) or onEvent(event).
|
|
7518
|
+
// Normalise both calling conventions.
|
|
7519
|
+
const ev = event || err;
|
|
7520
|
+
if (!ev) return;
|
|
7521
|
+
try {
|
|
7522
|
+
if (typeof ev === "string") {
|
|
7523
|
+
tracker.recordEvent(sessionId, {
|
|
7524
|
+
role: "system",
|
|
7525
|
+
type: "system",
|
|
7526
|
+
content: ev,
|
|
7527
|
+
timestamp: new Date().toISOString(),
|
|
7528
|
+
});
|
|
7529
|
+
} else {
|
|
7530
|
+
tracker.recordEvent(sessionId, ev);
|
|
7531
|
+
}
|
|
7532
|
+
} catch {
|
|
7533
|
+
/* best-effort — never crash the agent loop */
|
|
7534
|
+
}
|
|
7535
|
+
};
|
|
7536
|
+
|
|
7315
7537
|
// Fire-and-forget: run agent asynchronously so the request handler
|
|
7316
7538
|
// doesn't block and the agent doesn't appear "busy" to subsequent
|
|
7317
7539
|
// messages from chat, telegram, portal, or any other source.
|
|
@@ -7320,8 +7542,11 @@ async function handleApi(req, res, url) {
|
|
|
7320
7542
|
sessionType: "primary",
|
|
7321
7543
|
mode: messageMode,
|
|
7322
7544
|
model: messageModel,
|
|
7545
|
+
persistent: true,
|
|
7546
|
+
sendRawEvents: true,
|
|
7323
7547
|
attachments,
|
|
7324
7548
|
attachmentsAppended,
|
|
7549
|
+
onEvent: streamOnEvent,
|
|
7325
7550
|
}).then(() => {
|
|
7326
7551
|
broadcastUiEvent(["sessions"], "invalidate", { reason: "agent-response", sessionId });
|
|
7327
7552
|
}).catch((execErr) => {
|
|
@@ -7335,14 +7560,20 @@ async function handleApi(req, res, url) {
|
|
|
7335
7560
|
broadcastUiEvent(["sessions"], "invalidate", { reason: "agent-error", sessionId });
|
|
7336
7561
|
});
|
|
7337
7562
|
} else {
|
|
7338
|
-
// No agent — record user event and
|
|
7563
|
+
// No agent available — record user event and notify user
|
|
7339
7564
|
tracker.recordEvent(sessionId, {
|
|
7340
7565
|
role: "user",
|
|
7341
7566
|
content: messageContent,
|
|
7342
7567
|
attachments,
|
|
7343
7568
|
timestamp: new Date().toISOString(),
|
|
7344
7569
|
});
|
|
7345
|
-
|
|
7570
|
+
tracker.recordEvent(sessionId, {
|
|
7571
|
+
role: "system",
|
|
7572
|
+
type: "error",
|
|
7573
|
+
content: "⚠️ No agent is available to process this message. The primary agent may not be initialized — try restarting bosun or check the Logs tab for details.",
|
|
7574
|
+
timestamp: new Date().toISOString(),
|
|
7575
|
+
});
|
|
7576
|
+
jsonResponse(res, 200, { ok: true, messageId, warning: "no_agent_available" });
|
|
7346
7577
|
broadcastUiEvent(["sessions"], "invalidate", { reason: "session-message", sessionId });
|
|
7347
7578
|
}
|
|
7348
7579
|
} catch (err) {
|