bosun 0.35.3 → 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/package.json +1 -1
- package/session-tracker.mjs +55 -1
- package/ui/demo.html +49 -0
- package/ui/tabs/workflows.js +83 -0
- package/ui-server.mjs +95 -1
- package/workflow-templates/github.mjs +1 -1
- package/workflow-templates.mjs +216 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bosun",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.4",
|
|
4
4
|
"description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache 2.0",
|
package/session-tracker.mjs
CHANGED
|
@@ -218,6 +218,21 @@ export class SessionTracker {
|
|
|
218
218
|
? this.#maxMessages
|
|
219
219
|
: session.maxMessages;
|
|
220
220
|
|
|
221
|
+
if (typeof event === "string" && event.trim()) {
|
|
222
|
+
const msg = {
|
|
223
|
+
type: "system",
|
|
224
|
+
content: event.trim().slice(0, MAX_MESSAGE_CHARS),
|
|
225
|
+
timestamp: new Date().toISOString(),
|
|
226
|
+
};
|
|
227
|
+
session.messages.push(msg);
|
|
228
|
+
if (Number.isFinite(maxMessages) && maxMessages > 0) {
|
|
229
|
+
while (session.messages.length > maxMessages) session.messages.shift();
|
|
230
|
+
}
|
|
231
|
+
this.#markDirty(taskId);
|
|
232
|
+
emitSessionEvent(session, msg);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
221
236
|
// Direct message format (role/content)
|
|
222
237
|
if (event && event.role && event.content !== undefined) {
|
|
223
238
|
const msg = {
|
|
@@ -761,7 +776,7 @@ export class SessionTracker {
|
|
|
761
776
|
};
|
|
762
777
|
|
|
763
778
|
// ── Codex SDK events ──
|
|
764
|
-
if (event.type === "item.completed" && event.item) {
|
|
779
|
+
if ((event.type === "item.completed" || event.type === "item.updated") && event.item) {
|
|
765
780
|
const item = event.item;
|
|
766
781
|
const itemType = String(item.type || "").toLowerCase();
|
|
767
782
|
|
|
@@ -823,6 +838,20 @@ ${output}`
|
|
|
823
838
|
};
|
|
824
839
|
}
|
|
825
840
|
|
|
841
|
+
if (
|
|
842
|
+
itemType === "agent_message" &&
|
|
843
|
+
event.type === "item.updated" &&
|
|
844
|
+
(item.text || item.delta)
|
|
845
|
+
) {
|
|
846
|
+
const partial = toText(item.text || item.delta);
|
|
847
|
+
if (!partial) return null;
|
|
848
|
+
return {
|
|
849
|
+
type: "agent_message",
|
|
850
|
+
content: partial.slice(0, MAX_MESSAGE_CHARS),
|
|
851
|
+
timestamp: ts,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
826
855
|
if (itemType === "file_change") {
|
|
827
856
|
const changes = Array.isArray(item.changes)
|
|
828
857
|
? item.changes
|
|
@@ -876,6 +905,31 @@ ${items.join("\n")}` : "todo updated";
|
|
|
876
905
|
return null; // Skip other item types
|
|
877
906
|
}
|
|
878
907
|
|
|
908
|
+
if (event.type === "item.started" && event.item) {
|
|
909
|
+
const item = event.item;
|
|
910
|
+
const itemType = String(item.type || "").toLowerCase();
|
|
911
|
+
|
|
912
|
+
if (itemType === "command_execution") {
|
|
913
|
+
const command = toText(item.command || item.input || "").trim();
|
|
914
|
+
return {
|
|
915
|
+
type: "tool_call",
|
|
916
|
+
content: command || "(command)",
|
|
917
|
+
timestamp: ts,
|
|
918
|
+
meta: { toolName: "command_execution" },
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
if (itemType === "reasoning") {
|
|
923
|
+
const detail = toText(item.text || item.summary || "").trim();
|
|
924
|
+
if (!detail) return null;
|
|
925
|
+
return {
|
|
926
|
+
type: "system",
|
|
927
|
+
content: detail.slice(0, MAX_MESSAGE_CHARS),
|
|
928
|
+
timestamp: ts,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
879
933
|
if (event.type === "assistant.message" && event.data?.content) {
|
|
880
934
|
return {
|
|
881
935
|
type: "agent_message",
|
package/ui/demo.html
CHANGED
|
@@ -3073,6 +3073,25 @@
|
|
|
3073
3073
|
}),
|
|
3074
3074
|
};
|
|
3075
3075
|
}
|
|
3076
|
+
if (route === '/api/workflows/template-updates') {
|
|
3077
|
+
const updates = (STATE.workflows || [])
|
|
3078
|
+
.map((wf) => {
|
|
3079
|
+
const state = wf?.metadata?.templateState || null;
|
|
3080
|
+
if (!state?.templateId) return null;
|
|
3081
|
+
return {
|
|
3082
|
+
workflowId: wf.id,
|
|
3083
|
+
workflowName: wf.name,
|
|
3084
|
+
templateId: state.templateId,
|
|
3085
|
+
templateName: state.templateName || state.templateId,
|
|
3086
|
+
updateAvailable: state.updateAvailable === true,
|
|
3087
|
+
isCustomized: state.isCustomized === true,
|
|
3088
|
+
templateVersion: state.templateVersion || null,
|
|
3089
|
+
installedTemplateVersion: state.installedTemplateVersion || null,
|
|
3090
|
+
};
|
|
3091
|
+
})
|
|
3092
|
+
.filter(Boolean);
|
|
3093
|
+
return { ok: true, updates };
|
|
3094
|
+
}
|
|
3076
3095
|
if (route.startsWith('/api/workflows/runs/')) {
|
|
3077
3096
|
const runId = decodeURIComponent(route.replace('/api/workflows/runs/', '')).trim();
|
|
3078
3097
|
const run = STATE.workflowRuns.find((item) => item.runId === runId);
|
|
@@ -3124,6 +3143,36 @@
|
|
|
3124
3143
|
const segs = route.replace('/api/workflows/', '').split('/');
|
|
3125
3144
|
const wfId = segs[0];
|
|
3126
3145
|
const action = segs[1] || '';
|
|
3146
|
+
if (action === 'template-update') {
|
|
3147
|
+
const wf = STATE.workflows.find(w => w.id === wfId);
|
|
3148
|
+
if (!wf) return { ok: false, error: 'Workflow not found' };
|
|
3149
|
+
if ((body?.mode || 'replace') === 'copy') {
|
|
3150
|
+
const copy = JSON.parse(JSON.stringify(wf));
|
|
3151
|
+
copy.id = 'wf-' + Math.random().toString(36).slice(2, 9);
|
|
3152
|
+
copy.name = `${wf.name} (Updated)`;
|
|
3153
|
+
copy.metadata = {
|
|
3154
|
+
...(copy.metadata || {}),
|
|
3155
|
+
updatedAt: new Date().toISOString(),
|
|
3156
|
+
templateState: {
|
|
3157
|
+
...(copy.metadata?.templateState || {}),
|
|
3158
|
+
updateAvailable: false,
|
|
3159
|
+
isCustomized: false,
|
|
3160
|
+
},
|
|
3161
|
+
};
|
|
3162
|
+
STATE.workflows.push(copy);
|
|
3163
|
+
return { ok: true, workflow: copy };
|
|
3164
|
+
}
|
|
3165
|
+
wf.metadata = {
|
|
3166
|
+
...(wf.metadata || {}),
|
|
3167
|
+
updatedAt: new Date().toISOString(),
|
|
3168
|
+
templateState: {
|
|
3169
|
+
...(wf.metadata?.templateState || {}),
|
|
3170
|
+
updateAvailable: false,
|
|
3171
|
+
isCustomized: false,
|
|
3172
|
+
},
|
|
3173
|
+
};
|
|
3174
|
+
return { ok: true, workflow: wf };
|
|
3175
|
+
}
|
|
3127
3176
|
if (action === 'execute') {
|
|
3128
3177
|
const run = { runId: 'run-' + Math.random().toString(36).slice(2, 6), workflowId: wfId, status: 'completed', nodeCount: 6, duration: 30000 + Math.floor(Math.random() * 30000), errorCount: 0, startedAt: new Date().toISOString() };
|
|
3129
3178
|
STATE.workflowRuns.unshift(run);
|
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
|
@@ -406,6 +406,27 @@ async function getWorkflowEngineModule() {
|
|
|
406
406
|
if (result.errors.length) {
|
|
407
407
|
console.warn("[workflows] Default template install errors:", result.errors);
|
|
408
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
|
+
}
|
|
409
430
|
} catch (err) {
|
|
410
431
|
console.warn("[workflows] Default template install failed:", err.message);
|
|
411
432
|
} finally {
|
|
@@ -6258,6 +6279,9 @@ async function handleApi(req, res, url) {
|
|
|
6258
6279
|
const wfMod = await getWorkflowEngine();
|
|
6259
6280
|
if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
|
|
6260
6281
|
const engine = wfMod.getWorkflowEngine();
|
|
6282
|
+
if (typeof _wfTemplates?.applyWorkflowTemplateState === "function") {
|
|
6283
|
+
_wfTemplates.applyWorkflowTemplateState(body);
|
|
6284
|
+
}
|
|
6261
6285
|
const saved = await engine.save(body);
|
|
6262
6286
|
jsonResponse(res, 200, { ok: true, workflow: saved });
|
|
6263
6287
|
} catch (err) {
|
|
@@ -6294,6 +6318,65 @@ async function handleApi(req, res, url) {
|
|
|
6294
6318
|
return;
|
|
6295
6319
|
}
|
|
6296
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
|
+
|
|
6297
6380
|
if (path === "/api/workflows/node-types") {
|
|
6298
6381
|
try {
|
|
6299
6382
|
const wfMod = await getWorkflowEngine();
|
|
@@ -7436,7 +7519,16 @@ async function handleApi(req, res, url) {
|
|
|
7436
7519
|
const ev = event || err;
|
|
7437
7520
|
if (!ev) return;
|
|
7438
7521
|
try {
|
|
7439
|
-
|
|
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
|
+
}
|
|
7440
7532
|
} catch {
|
|
7441
7533
|
/* best-effort — never crash the agent loop */
|
|
7442
7534
|
}
|
|
@@ -7450,6 +7542,8 @@ async function handleApi(req, res, url) {
|
|
|
7450
7542
|
sessionType: "primary",
|
|
7451
7543
|
mode: messageMode,
|
|
7452
7544
|
model: messageModel,
|
|
7545
|
+
persistent: true,
|
|
7546
|
+
sendRawEvents: true,
|
|
7453
7547
|
attachments,
|
|
7454
7548
|
attachmentsAppended,
|
|
7455
7549
|
onEvent: streamOnEvent,
|
|
@@ -747,7 +747,7 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
|
|
|
747
747
|
" } else if(hasPend){",
|
|
748
748
|
" pending.push(pr.number);",
|
|
749
749
|
" } else if(checks.length>0&&!hasFixLabel){",
|
|
750
|
-
"
|
|
750
|
+
" /* CI all-passing, no conflicts, not draft — a review candidate */",
|
|
751
751
|
" readyCandidates.push({n:pr.number,branch:pr.headRefName,base:pr.baseRefName,url:pr.url,title:pr.title});",
|
|
752
752
|
" }",
|
|
753
753
|
" }",
|
package/workflow-templates.mjs
CHANGED
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
* listTemplates() — List all available templates
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
-
import { randomUUID } from "node:crypto";
|
|
32
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
33
33
|
|
|
34
34
|
// ── Re-export helpers for external consumers ────────────────────────────────
|
|
35
35
|
export { node, edge, resetLayout } from "./workflow-templates/_helpers.mjs";
|
|
@@ -176,6 +176,216 @@ export const WORKFLOW_TEMPLATES = Object.freeze([
|
|
|
176
176
|
const _TEMPLATE_BY_ID = new Map(
|
|
177
177
|
WORKFLOW_TEMPLATES.map((template) => [template.id, template]),
|
|
178
178
|
);
|
|
179
|
+
const TEMPLATE_STATE_VERSION = 1;
|
|
180
|
+
|
|
181
|
+
function stableNormalize(value) {
|
|
182
|
+
if (Array.isArray(value)) {
|
|
183
|
+
return value.map((entry) => stableNormalize(entry));
|
|
184
|
+
}
|
|
185
|
+
if (value && typeof value === "object") {
|
|
186
|
+
const normalized = {};
|
|
187
|
+
for (const key of Object.keys(value).sort()) {
|
|
188
|
+
normalized[key] = stableNormalize(value[key]);
|
|
189
|
+
}
|
|
190
|
+
return normalized;
|
|
191
|
+
}
|
|
192
|
+
return value;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function stableStringify(value) {
|
|
196
|
+
return JSON.stringify(stableNormalize(value));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function hashContent(value) {
|
|
200
|
+
return createHash("sha256").update(stableStringify(value)).digest("hex");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function toWorkflowFingerprintPayload(def = {}) {
|
|
204
|
+
return {
|
|
205
|
+
name: def.name || "",
|
|
206
|
+
description: def.description || "",
|
|
207
|
+
category: def.category || "custom",
|
|
208
|
+
trigger: def.trigger || "",
|
|
209
|
+
variables: def.variables || {},
|
|
210
|
+
nodes: def.nodes || [],
|
|
211
|
+
edges: def.edges || [],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function computeWorkflowFingerprint(def = {}) {
|
|
216
|
+
return hashContent(toWorkflowFingerprintPayload(def));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function cloneTemplateDefinition(template) {
|
|
220
|
+
return JSON.parse(JSON.stringify(template));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function getTemplateVersion(templateId) {
|
|
224
|
+
const template = getTemplate(templateId);
|
|
225
|
+
if (!template) return null;
|
|
226
|
+
return computeWorkflowFingerprint(template).slice(0, 12);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function deriveTemplateState(def, template) {
|
|
230
|
+
const nowIso = new Date().toISOString();
|
|
231
|
+
const currentFingerprint = computeWorkflowFingerprint(def);
|
|
232
|
+
const templateFingerprint = computeWorkflowFingerprint(template);
|
|
233
|
+
const previousState = def?.metadata?.templateState || {};
|
|
234
|
+
|
|
235
|
+
const installedTemplateFingerprint = typeof previousState.installedTemplateFingerprint === "string"
|
|
236
|
+
? previousState.installedTemplateFingerprint
|
|
237
|
+
: (currentFingerprint === templateFingerprint ? templateFingerprint : null);
|
|
238
|
+
|
|
239
|
+
const installedFingerprint = typeof previousState.installedFingerprint === "string"
|
|
240
|
+
? previousState.installedFingerprint
|
|
241
|
+
: currentFingerprint;
|
|
242
|
+
|
|
243
|
+
const isCustomized = currentFingerprint !== installedFingerprint;
|
|
244
|
+
const updateAvailable = installedTemplateFingerprint
|
|
245
|
+
? installedTemplateFingerprint !== templateFingerprint
|
|
246
|
+
: false;
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
stateVersion: TEMPLATE_STATE_VERSION,
|
|
250
|
+
templateId: template.id,
|
|
251
|
+
templateName: template.name,
|
|
252
|
+
templateVersion: templateFingerprint.slice(0, 12),
|
|
253
|
+
templateFingerprint,
|
|
254
|
+
installedTemplateFingerprint,
|
|
255
|
+
installedTemplateVersion: installedTemplateFingerprint
|
|
256
|
+
? installedTemplateFingerprint.slice(0, 12)
|
|
257
|
+
: null,
|
|
258
|
+
installedFingerprint,
|
|
259
|
+
currentFingerprint,
|
|
260
|
+
isCustomized,
|
|
261
|
+
updateAvailable,
|
|
262
|
+
refreshedAt: nowIso,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function applyWorkflowTemplateState(def = {}) {
|
|
267
|
+
if (!def || typeof def !== "object") return def;
|
|
268
|
+
const templateId = String(def?.metadata?.installedFrom || "").trim();
|
|
269
|
+
if (!templateId) return def;
|
|
270
|
+
const template = getTemplate(templateId);
|
|
271
|
+
if (!template) return def;
|
|
272
|
+
if (!def.metadata || typeof def.metadata !== "object") def.metadata = {};
|
|
273
|
+
def.metadata.templateState = deriveTemplateState(def, template);
|
|
274
|
+
return def;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function makeUpdatedWorkflowFromTemplate(existing, template, mode = "replace") {
|
|
278
|
+
const templateClone = cloneTemplateDefinition(template);
|
|
279
|
+
const nowIso = new Date().toISOString();
|
|
280
|
+
const mergedVariables = {
|
|
281
|
+
...(templateClone.variables || {}),
|
|
282
|
+
...(existing.variables || {}),
|
|
283
|
+
};
|
|
284
|
+
const next = {
|
|
285
|
+
...templateClone,
|
|
286
|
+
id: mode === "copy" ? randomUUID() : existing.id,
|
|
287
|
+
name: mode === "copy" ? `${existing.name} (Updated)` : existing.name,
|
|
288
|
+
enabled: existing.enabled !== false,
|
|
289
|
+
variables: mergedVariables,
|
|
290
|
+
metadata: {
|
|
291
|
+
...(existing.metadata || {}),
|
|
292
|
+
...(templateClone.metadata || {}),
|
|
293
|
+
installedFrom: template.id,
|
|
294
|
+
templateUpdatedAt: nowIso,
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
delete next.metadata.templateState;
|
|
298
|
+
if (mode === "copy") {
|
|
299
|
+
next.metadata.createdAt = nowIso;
|
|
300
|
+
next.metadata.updatedAt = nowIso;
|
|
301
|
+
}
|
|
302
|
+
return applyWorkflowTemplateState(next);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function updateWorkflowFromTemplate(engine, workflowId, opts = {}) {
|
|
306
|
+
const mode = String(opts.mode || "replace").toLowerCase();
|
|
307
|
+
if (!["replace", "copy"].includes(mode)) {
|
|
308
|
+
throw new Error(`Unsupported template update mode "${mode}"`);
|
|
309
|
+
}
|
|
310
|
+
const existing = engine.get(workflowId);
|
|
311
|
+
if (!existing) throw new Error(`Workflow "${workflowId}" not found`);
|
|
312
|
+
const templateId = String(existing?.metadata?.installedFrom || "").trim();
|
|
313
|
+
if (!templateId) throw new Error(`Workflow "${workflowId}" is not template-backed`);
|
|
314
|
+
const template = getTemplate(templateId);
|
|
315
|
+
if (!template) throw new Error(`Template "${templateId}" not found`);
|
|
316
|
+
|
|
317
|
+
const hydrated = applyWorkflowTemplateState(existing);
|
|
318
|
+
if (mode === "replace" && hydrated?.metadata?.templateState?.isCustomized && opts.force !== true) {
|
|
319
|
+
throw new Error("Workflow has custom changes; pass force=true to replace it");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const next = makeUpdatedWorkflowFromTemplate(hydrated, template, mode);
|
|
323
|
+
return engine.save(next);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function reconcileInstalledTemplates(engine, opts = {}) {
|
|
327
|
+
const autoUpdateUnmodified = opts.autoUpdateUnmodified !== false;
|
|
328
|
+
const workflows = engine.list();
|
|
329
|
+
const result = {
|
|
330
|
+
scanned: 0,
|
|
331
|
+
metadataUpdated: 0,
|
|
332
|
+
autoUpdated: 0,
|
|
333
|
+
updateAvailable: [],
|
|
334
|
+
customized: [],
|
|
335
|
+
updatedWorkflowIds: [],
|
|
336
|
+
errors: [],
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
for (const summary of workflows) {
|
|
340
|
+
const wfId = summary?.id;
|
|
341
|
+
if (!wfId) continue;
|
|
342
|
+
const def = engine.get(wfId);
|
|
343
|
+
if (!def?.metadata?.installedFrom) continue;
|
|
344
|
+
result.scanned += 1;
|
|
345
|
+
|
|
346
|
+
try {
|
|
347
|
+
const before = stableStringify(def.metadata?.templateState || null);
|
|
348
|
+
applyWorkflowTemplateState(def);
|
|
349
|
+
const state = def.metadata?.templateState || null;
|
|
350
|
+
const after = stableStringify(state);
|
|
351
|
+
if (before !== after) {
|
|
352
|
+
engine.save(def);
|
|
353
|
+
result.metadataUpdated += 1;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (!state) continue;
|
|
357
|
+
if (state.isCustomized) {
|
|
358
|
+
result.customized.push({
|
|
359
|
+
workflowId: def.id,
|
|
360
|
+
name: def.name,
|
|
361
|
+
templateId: state.templateId,
|
|
362
|
+
updateAvailable: state.updateAvailable === true,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
if (state.updateAvailable === true) {
|
|
366
|
+
result.updateAvailable.push({
|
|
367
|
+
workflowId: def.id,
|
|
368
|
+
name: def.name,
|
|
369
|
+
templateId: state.templateId,
|
|
370
|
+
isCustomized: state.isCustomized === true,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (autoUpdateUnmodified && state.updateAvailable === true && state.isCustomized !== true) {
|
|
375
|
+
const saved = updateWorkflowFromTemplate(engine, def.id, { mode: "replace", force: true });
|
|
376
|
+
result.autoUpdated += 1;
|
|
377
|
+
result.updatedWorkflowIds.push(saved.id);
|
|
378
|
+
}
|
|
379
|
+
} catch (err) {
|
|
380
|
+
result.errors.push({
|
|
381
|
+
workflowId: wfId,
|
|
382
|
+
error: err.message,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return result;
|
|
388
|
+
}
|
|
179
389
|
|
|
180
390
|
/**
|
|
181
391
|
* Setup workflow profiles used by `bosun --setup`.
|
|
@@ -287,6 +497,7 @@ export function getTemplate(id) {
|
|
|
287
497
|
export function listTemplates() {
|
|
288
498
|
return WORKFLOW_TEMPLATES.map((t) => {
|
|
289
499
|
const cat = TEMPLATE_CATEGORIES[t.category] || TEMPLATE_CATEGORIES.custom;
|
|
500
|
+
const fingerprint = computeWorkflowFingerprint(t);
|
|
290
501
|
return {
|
|
291
502
|
id: t.id,
|
|
292
503
|
name: t.name,
|
|
@@ -298,6 +509,8 @@ export function listTemplates() {
|
|
|
298
509
|
tags: t.metadata?.tags || [],
|
|
299
510
|
nodeCount: t.nodes?.length || 0,
|
|
300
511
|
edgeCount: t.edges?.length || 0,
|
|
512
|
+
version: fingerprint.slice(0, 12),
|
|
513
|
+
fingerprint,
|
|
301
514
|
replaces: t.metadata?.replaces || null,
|
|
302
515
|
recommended: t.recommended === true,
|
|
303
516
|
enabled: t.enabled !== false,
|
|
@@ -384,7 +597,7 @@ export function installTemplate(templateId, engine, overrides = {}) {
|
|
|
384
597
|
}
|
|
385
598
|
|
|
386
599
|
// Deep clone
|
|
387
|
-
const def =
|
|
600
|
+
const def = cloneTemplateDefinition(template);
|
|
388
601
|
def.id = randomUUID(); // New unique ID
|
|
389
602
|
def.metadata = {
|
|
390
603
|
...def.metadata,
|
|
@@ -398,6 +611,7 @@ export function installTemplate(templateId, engine, overrides = {}) {
|
|
|
398
611
|
def.variables = { ...def.variables, ...overrides };
|
|
399
612
|
}
|
|
400
613
|
|
|
614
|
+
applyWorkflowTemplateState(def);
|
|
401
615
|
return engine.save(def);
|
|
402
616
|
}
|
|
403
617
|
|