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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.35.3",
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",
@@ -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);
@@ -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
- tracker.recordEvent(sessionId, ev);
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
- " // CI all-passing, no conflicts, not draft — a review candidate",
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
  " }",
@@ -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 = JSON.parse(JSON.stringify(template));
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