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.
@@ -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";
@@ -43,6 +43,7 @@ import {
43
43
  PR_CONFLICT_RESOLVER_TEMPLATE,
44
44
  STALE_PR_REAPER_TEMPLATE,
45
45
  RELEASE_DRAFTER_TEMPLATE,
46
+ BOSUN_PR_WATCHDOG_TEMPLATE,
46
47
  } from "./workflow-templates/github.mjs";
47
48
 
48
49
  // Agents
@@ -95,6 +96,7 @@ export {
95
96
  PR_CONFLICT_RESOLVER_TEMPLATE,
96
97
  STALE_PR_REAPER_TEMPLATE,
97
98
  RELEASE_DRAFTER_TEMPLATE,
99
+ BOSUN_PR_WATCHDOG_TEMPLATE,
98
100
  FRONTEND_AGENT_TEMPLATE,
99
101
  REVIEW_AGENT_TEMPLATE,
100
102
  CUSTOM_AGENT_TEMPLATE,
@@ -141,6 +143,7 @@ export const WORKFLOW_TEMPLATES = Object.freeze([
141
143
  PR_CONFLICT_RESOLVER_TEMPLATE,
142
144
  STALE_PR_REAPER_TEMPLATE,
143
145
  RELEASE_DRAFTER_TEMPLATE,
146
+ BOSUN_PR_WATCHDOG_TEMPLATE,
144
147
  // ── Agents ──
145
148
  REVIEW_AGENT_TEMPLATE,
146
149
  FRONTEND_AGENT_TEMPLATE,
@@ -173,6 +176,216 @@ export const WORKFLOW_TEMPLATES = Object.freeze([
173
176
  const _TEMPLATE_BY_ID = new Map(
174
177
  WORKFLOW_TEMPLATES.map((template) => [template.id, template]),
175
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
+ }
176
389
 
177
390
  /**
178
391
  * Setup workflow profiles used by `bosun --setup`.
@@ -284,6 +497,7 @@ export function getTemplate(id) {
284
497
  export function listTemplates() {
285
498
  return WORKFLOW_TEMPLATES.map((t) => {
286
499
  const cat = TEMPLATE_CATEGORIES[t.category] || TEMPLATE_CATEGORIES.custom;
500
+ const fingerprint = computeWorkflowFingerprint(t);
287
501
  return {
288
502
  id: t.id,
289
503
  name: t.name,
@@ -295,6 +509,8 @@ export function listTemplates() {
295
509
  tags: t.metadata?.tags || [],
296
510
  nodeCount: t.nodes?.length || 0,
297
511
  edgeCount: t.edges?.length || 0,
512
+ version: fingerprint.slice(0, 12),
513
+ fingerprint,
298
514
  replaces: t.metadata?.replaces || null,
299
515
  recommended: t.recommended === true,
300
516
  enabled: t.enabled !== false,
@@ -381,7 +597,7 @@ export function installTemplate(templateId, engine, overrides = {}) {
381
597
  }
382
598
 
383
599
  // Deep clone
384
- const def = JSON.parse(JSON.stringify(template));
600
+ const def = cloneTemplateDefinition(template);
385
601
  def.id = randomUUID(); // New unique ID
386
602
  def.metadata = {
387
603
  ...def.metadata,
@@ -395,6 +611,7 @@ export function installTemplate(templateId, engine, overrides = {}) {
395
611
  def.variables = { ...def.variables, ...overrides };
396
612
  }
397
613
 
614
+ applyWorkflowTemplateState(def);
398
615
  return engine.save(def);
399
616
  }
400
617