bosun 0.37.0 → 0.37.2

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.
Files changed (43) hide show
  1. package/.env.example +4 -1
  2. package/agent-tool-config.mjs +338 -0
  3. package/bosun-skills.mjs +59 -4
  4. package/bosun.schema.json +1 -1
  5. package/desktop/launch.mjs +18 -0
  6. package/desktop/main.mjs +52 -13
  7. package/fleet-coordinator.mjs +34 -1
  8. package/kanban-adapter.mjs +30 -3
  9. package/library-manager.mjs +66 -0
  10. package/maintenance.mjs +30 -5
  11. package/monitor.mjs +56 -0
  12. package/package.json +4 -1
  13. package/setup-web-server.mjs +73 -12
  14. package/setup.mjs +3 -3
  15. package/ui/app.js +40 -3
  16. package/ui/components/session-list.js +25 -7
  17. package/ui/components/workspace-switcher.js +48 -1
  18. package/ui/demo.html +176 -0
  19. package/ui/modules/mic-track-registry.js +83 -0
  20. package/ui/modules/settings-schema.js +4 -1
  21. package/ui/modules/state.js +25 -0
  22. package/ui/modules/streaming.js +1 -1
  23. package/ui/modules/voice-barge-in.js +27 -0
  24. package/ui/modules/voice-client-sdk.js +268 -42
  25. package/ui/modules/voice-client.js +665 -61
  26. package/ui/modules/voice-overlay.js +829 -47
  27. package/ui/setup.html +151 -9
  28. package/ui/styles.css +258 -0
  29. package/ui/tabs/chat.js +11 -0
  30. package/ui/tabs/library.js +890 -15
  31. package/ui/tabs/settings.js +51 -11
  32. package/ui/tabs/telemetry.js +327 -105
  33. package/ui/tabs/workflows.js +86 -0
  34. package/ui-server.mjs +1201 -107
  35. package/voice-action-dispatcher.mjs +81 -0
  36. package/voice-agents-sdk.mjs +2 -2
  37. package/voice-relay.mjs +131 -14
  38. package/voice-tools.mjs +475 -9
  39. package/workflow-engine.mjs +54 -0
  40. package/workflow-nodes.mjs +177 -28
  41. package/workflow-templates/github.mjs +205 -94
  42. package/workflow-templates/task-batch.mjs +247 -0
  43. package/workflow-templates.mjs +15 -0
package/voice-tools.mjs CHANGED
@@ -330,6 +330,83 @@ async function getRecentSessionContextSnippet(sessionId, limit = 8) {
330
330
  }
331
331
  }
332
332
 
333
+ function parseObjectArg(value, fieldName) {
334
+ if (value == null || value === "") return {};
335
+ if (typeof value === "object" && !Array.isArray(value)) return value;
336
+ if (typeof value === "string") {
337
+ try {
338
+ const parsed = JSON.parse(value);
339
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
340
+ return parsed;
341
+ }
342
+ throw new Error(`${fieldName} must decode to a JSON object`);
343
+ } catch (err) {
344
+ throw new Error(`Invalid JSON for ${fieldName}: ${err.message}`);
345
+ }
346
+ }
347
+ throw new Error(`${fieldName} must be an object or JSON string`);
348
+ }
349
+
350
+ function parseAnyJson(text) {
351
+ const raw = String(text || "").trim();
352
+ if (!raw) return null;
353
+ try {
354
+ return JSON.parse(raw);
355
+ } catch {
356
+ // fall through
357
+ }
358
+ const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)```/i);
359
+ if (fenced?.[1]) {
360
+ try {
361
+ return JSON.parse(fenced[1].trim());
362
+ } catch {
363
+ // fall through
364
+ }
365
+ }
366
+ const firstObj = raw.indexOf("{");
367
+ const lastObj = raw.lastIndexOf("}");
368
+ if (firstObj >= 0 && lastObj > firstObj) {
369
+ try {
370
+ return JSON.parse(raw.slice(firstObj, lastObj + 1));
371
+ } catch {
372
+ // fall through
373
+ }
374
+ }
375
+ return null;
376
+ }
377
+
378
+ function normalizeWorkflowDefinition(def, args = {}) {
379
+ const input = def && typeof def === "object" && !Array.isArray(def) ? { ...def } : {};
380
+ if (typeof args.workflowId === "string" && args.workflowId.trim()) {
381
+ input.id = args.workflowId.trim();
382
+ } else if (typeof args.id === "string" && args.id.trim()) {
383
+ input.id = args.id.trim();
384
+ }
385
+ if (!input.name) {
386
+ const fallbackName = String(args.name || "").trim();
387
+ input.name = fallbackName || input.id || "Voice Workflow";
388
+ }
389
+ if (typeof args.description === "string" && args.description.trim()) {
390
+ input.description = args.description.trim();
391
+ }
392
+ if (!Array.isArray(input.nodes)) input.nodes = [];
393
+ if (!Array.isArray(input.edges)) input.edges = [];
394
+ if (!Array.isArray(input.triggers) && input.triggers != null) input.triggers = [];
395
+ if (!input.variables || typeof input.variables !== "object" || Array.isArray(input.variables)) {
396
+ input.variables = {};
397
+ }
398
+ if (!input.metadata || typeof input.metadata !== "object" || Array.isArray(input.metadata)) {
399
+ input.metadata = {};
400
+ }
401
+ input.metadata.updatedBy = "voice-tool";
402
+ if (typeof args.enabled === "boolean") {
403
+ input.enabled = args.enabled;
404
+ } else if (input.enabled == null) {
405
+ input.enabled = false;
406
+ }
407
+ return input;
408
+ }
409
+
333
410
  function normalizeCandidatePath(input) {
334
411
  if (!input) return "";
335
412
  try {
@@ -810,6 +887,90 @@ const TOOL_DEFS = [
810
887
  description: "List available workflow templates and installed workflow definitions.",
811
888
  parameters: { type: "object", properties: {} },
812
889
  },
890
+ {
891
+ type: "function",
892
+ name: "create_workflow",
893
+ description: "Create a new workflow from a JSON definition (or create a blank workflow by name).",
894
+ parameters: {
895
+ type: "object",
896
+ properties: {
897
+ definition: {
898
+ description: "Workflow definition object (or JSON string)",
899
+ },
900
+ name: { type: "string", description: "Workflow name (used for blank workflow creation)" },
901
+ description: { type: "string", description: "Workflow description" },
902
+ enabled: { type: "boolean", description: "Whether workflow should be enabled on save. Default: false" },
903
+ workflowId: { type: "string", description: "Optional explicit workflow id" },
904
+ },
905
+ },
906
+ },
907
+ {
908
+ type: "function",
909
+ name: "update_workflow_definition",
910
+ description: "Update an existing workflow definition (merge patch by default, or replace).",
911
+ parameters: {
912
+ type: "object",
913
+ properties: {
914
+ workflowId: { type: "string", description: "Workflow id to update" },
915
+ patch: {
916
+ description: "Partial workflow object patch (or JSON string)",
917
+ },
918
+ replace: {
919
+ type: "boolean",
920
+ description: "Replace the definition instead of merge-patch. Default: false",
921
+ },
922
+ },
923
+ required: ["workflowId"],
924
+ },
925
+ },
926
+ {
927
+ type: "function",
928
+ name: "delete_workflow",
929
+ description: "Delete a workflow definition by id.",
930
+ parameters: {
931
+ type: "object",
932
+ properties: {
933
+ workflowId: { type: "string", description: "Workflow id" },
934
+ },
935
+ required: ["workflowId"],
936
+ },
937
+ },
938
+ {
939
+ type: "function",
940
+ name: "create_workflow_from_template",
941
+ description: "Install a workflow template as a new workflow instance, with optional variable overrides.",
942
+ parameters: {
943
+ type: "object",
944
+ properties: {
945
+ templateId: { type: "string", description: "Workflow template id" },
946
+ overrides: {
947
+ description: "Variable override object (or JSON string)",
948
+ },
949
+ executeAfterCreate: { type: "boolean", description: "Run the new workflow immediately. Default: false" },
950
+ input: { description: "Input object (or JSON string) for executeAfterCreate" },
951
+ },
952
+ required: ["templateId"],
953
+ },
954
+ },
955
+ {
956
+ type: "function",
957
+ name: "generate_workflow_with_agent",
958
+ description: "Ask the coding agent to generate a workflow JSON, then optionally save it.",
959
+ parameters: {
960
+ type: "object",
961
+ properties: {
962
+ prompt: { type: "string", description: "What workflow to generate" },
963
+ save: { type: "boolean", description: "Save generated workflow automatically. Default: true" },
964
+ enabled: { type: "boolean", description: "When saving, set enabled state. Default: false" },
965
+ executor: {
966
+ type: "string",
967
+ enum: ["codex-sdk", "copilot-sdk", "claude-sdk", "gemini-sdk", "opencode-sdk"],
968
+ description: "Agent executor to use for generation",
969
+ },
970
+ },
971
+ required: ["prompt"],
972
+ },
973
+ },
813
974
  {
814
975
  type: "function",
815
976
  name: "get_workflow_definition",
@@ -826,6 +987,20 @@ const TOOL_DEFS = [
826
987
  required: ["workflowId"],
827
988
  },
828
989
  },
990
+ {
991
+ type: "function",
992
+ name: "execute_workflow",
993
+ description: "Execute a workflow now with optional input payload.",
994
+ parameters: {
995
+ type: "object",
996
+ properties: {
997
+ workflowId: { type: "string", description: "Workflow id to run" },
998
+ input: { description: "Input payload object (or JSON string)" },
999
+ force: { type: "boolean", description: "Force run even if workflow is disabled. Default: false" },
1000
+ },
1001
+ required: ["workflowId"],
1002
+ },
1003
+ },
829
1004
  {
830
1005
  type: "function",
831
1006
  name: "list_workflow_runs",
@@ -867,10 +1042,22 @@ const TOOL_DEFS = [
867
1042
  required: ["runId"],
868
1043
  },
869
1044
  },
1045
+ {
1046
+ type: "function",
1047
+ name: "analyze_workflow",
1048
+ description: "Analyze workflow health using structure + recent run history.",
1049
+ parameters: {
1050
+ type: "object",
1051
+ properties: {
1052
+ workflowId: { type: "string", description: "Workflow id. Omit to analyze multiple workflows." },
1053
+ limit: { type: "number", description: "Max runs used for analysis. Default: 30" },
1054
+ },
1055
+ },
1056
+ },
870
1057
  {
871
1058
  type: "function",
872
1059
  name: "retry_workflow_run",
873
- description: "Retry a failed workflow run.",
1060
+ description: "Retry workflow run: from_failed (only failed runs) or from_scratch (any run).",
874
1061
  parameters: {
875
1062
  type: "object",
876
1063
  properties: {
@@ -1837,10 +2024,12 @@ const TOOL_HANDLERS = {
1837
2024
  id: w.id,
1838
2025
  name: w.name || w.id,
1839
2026
  enabled: w.enabled !== false,
1840
- triggerCount: Array.isArray(w.triggers) ? w.triggers.length : 0,
1841
- nodeCount: Array.isArray(w.nodes) ? w.nodes.length : 0,
1842
- edgeCount: Array.isArray(w.edges) ? w.edges.length : 0,
1843
- updatedAt: w.updatedAt || null,
2027
+ triggerCount: Array.isArray(w.triggers)
2028
+ ? w.triggers.length
2029
+ : (w.trigger ? 1 : 0),
2030
+ nodeCount: Number(w.nodeCount || (Array.isArray(w.nodes) ? w.nodes.length : 0)),
2031
+ edgeCount: Number(w.edgeCount || (Array.isArray(w.edges) ? w.edges.length : 0)),
2032
+ updatedAt: w?.metadata?.updatedAt || w.updatedAt || null,
1844
2033
  })),
1845
2034
  };
1846
2035
  } catch {
@@ -1852,16 +2041,200 @@ const TOOL_HANDLERS = {
1852
2041
  id: w.id,
1853
2042
  name: w.name || w.id,
1854
2043
  enabled: w.enabled !== false,
1855
- triggerCount: Array.isArray(w.triggers) ? w.triggers.length : 0,
1856
- nodeCount: Array.isArray(w.nodes) ? w.nodes.length : 0,
1857
- edgeCount: Array.isArray(w.edges) ? w.edges.length : 0,
1858
- updatedAt: w.updatedAt || null,
2044
+ triggerCount: Array.isArray(w.triggers)
2045
+ ? w.triggers.length
2046
+ : (w.trigger ? 1 : 0),
2047
+ nodeCount: Number(w.nodeCount || (Array.isArray(w.nodes) ? w.nodes.length : 0)),
2048
+ edgeCount: Number(w.edgeCount || (Array.isArray(w.edges) ? w.edges.length : 0)),
2049
+ updatedAt: w?.metadata?.updatedAt || w.updatedAt || null,
1859
2050
  })),
1860
2051
  error: "Workflow templates not available.",
1861
2052
  };
1862
2053
  }
1863
2054
  },
1864
2055
 
2056
+ async create_workflow(args = {}) {
2057
+ const wfEngineMod = await getWorkflowEngineModule();
2058
+ const engine = typeof wfEngineMod.getWorkflowEngine === "function"
2059
+ ? wfEngineMod.getWorkflowEngine()
2060
+ : null;
2061
+ if (!engine?.save) return { ok: false, error: "Workflow engine is unavailable." };
2062
+
2063
+ const hasDefinition = args.definition != null && args.definition !== "";
2064
+ const defInput = hasDefinition
2065
+ ? parseObjectArg(args.definition, "definition")
2066
+ : {};
2067
+ const normalized = normalizeWorkflowDefinition(defInput, args);
2068
+ const saved = engine.save(normalized);
2069
+ return {
2070
+ ok: true,
2071
+ workflow: {
2072
+ id: saved.id,
2073
+ name: saved.name || saved.id,
2074
+ enabled: saved.enabled !== false,
2075
+ nodeCount: Array.isArray(saved.nodes) ? saved.nodes.length : 0,
2076
+ edgeCount: Array.isArray(saved.edges) ? saved.edges.length : 0,
2077
+ updatedAt: saved?.metadata?.updatedAt || null,
2078
+ },
2079
+ };
2080
+ },
2081
+
2082
+ async update_workflow_definition(args = {}) {
2083
+ const workflowId = String(args.workflowId || args.id || "").trim();
2084
+ if (!workflowId) return { ok: false, error: "workflowId is required." };
2085
+ const wfEngineMod = await getWorkflowEngineModule();
2086
+ const engine = typeof wfEngineMod.getWorkflowEngine === "function"
2087
+ ? wfEngineMod.getWorkflowEngine()
2088
+ : null;
2089
+ if (!engine?.get || !engine?.save) return { ok: false, error: "Workflow engine is unavailable." };
2090
+ const existing = engine.get(workflowId);
2091
+ if (!existing) return { ok: false, error: `Workflow "${workflowId}" not found.` };
2092
+
2093
+ const patchObj = parseObjectArg(
2094
+ args.patch ?? args.definition ?? args.workflow ?? {},
2095
+ "patch",
2096
+ );
2097
+ const replace = args.replace === true;
2098
+ const merged = replace
2099
+ ? { ...patchObj }
2100
+ : {
2101
+ ...existing,
2102
+ ...patchObj,
2103
+ metadata: {
2104
+ ...(existing.metadata || {}),
2105
+ ...(patchObj.metadata || {}),
2106
+ },
2107
+ };
2108
+ const normalized = normalizeWorkflowDefinition(merged, {
2109
+ ...args,
2110
+ workflowId,
2111
+ name: merged.name || existing.name,
2112
+ });
2113
+ const saved = engine.save(normalized);
2114
+ return {
2115
+ ok: true,
2116
+ workflow: {
2117
+ id: saved.id,
2118
+ name: saved.name || saved.id,
2119
+ enabled: saved.enabled !== false,
2120
+ nodeCount: Array.isArray(saved.nodes) ? saved.nodes.length : 0,
2121
+ edgeCount: Array.isArray(saved.edges) ? saved.edges.length : 0,
2122
+ updatedAt: saved?.metadata?.updatedAt || null,
2123
+ },
2124
+ };
2125
+ },
2126
+
2127
+ async delete_workflow(args = {}) {
2128
+ const workflowId = String(args.workflowId || args.id || "").trim();
2129
+ if (!workflowId) return { ok: false, error: "workflowId is required." };
2130
+ const wfEngineMod = await getWorkflowEngineModule();
2131
+ const engine = typeof wfEngineMod.getWorkflowEngine === "function"
2132
+ ? wfEngineMod.getWorkflowEngine()
2133
+ : null;
2134
+ if (!engine?.delete) return { ok: false, error: "Workflow engine is unavailable." };
2135
+ const deleted = await engine.delete(workflowId);
2136
+ return { ok: Boolean(deleted), workflowId, deleted: Boolean(deleted) };
2137
+ },
2138
+
2139
+ async create_workflow_from_template(args = {}) {
2140
+ const templateId = String(args.templateId || args.id || "").trim();
2141
+ if (!templateId) return { ok: false, error: "templateId is required." };
2142
+ const wfEngineMod = await getWorkflowEngineModule();
2143
+ const engine = typeof wfEngineMod.getWorkflowEngine === "function"
2144
+ ? wfEngineMod.getWorkflowEngine()
2145
+ : null;
2146
+ if (!engine?.save || !engine?.execute) return { ok: false, error: "Workflow engine is unavailable." };
2147
+ const wfTemplates = await import("./workflow-templates.mjs");
2148
+ if (typeof wfTemplates.installTemplate !== "function") {
2149
+ return { ok: false, error: "Workflow template installer is unavailable." };
2150
+ }
2151
+ const overrides = parseObjectArg(args.overrides ?? {}, "overrides");
2152
+ const saved = wfTemplates.installTemplate(templateId, engine, overrides);
2153
+ const executeAfterCreate = args.executeAfterCreate === true;
2154
+ let run = null;
2155
+ if (executeAfterCreate) {
2156
+ const input = parseObjectArg(args.input ?? {}, "input");
2157
+ const ctx = await engine.execute(saved.id, input);
2158
+ run = {
2159
+ runId: ctx?.id || null,
2160
+ status: Array.isArray(ctx?.errors) && ctx.errors.length > 0 ? "failed" : "completed",
2161
+ errorCount: Array.isArray(ctx?.errors) ? ctx.errors.length : 0,
2162
+ };
2163
+ }
2164
+ return {
2165
+ ok: true,
2166
+ workflow: {
2167
+ id: saved.id,
2168
+ name: saved.name || saved.id,
2169
+ enabled: saved.enabled !== false,
2170
+ installedFrom: saved?.metadata?.installedFrom || templateId,
2171
+ nodeCount: Array.isArray(saved.nodes) ? saved.nodes.length : 0,
2172
+ edgeCount: Array.isArray(saved.edges) ? saved.edges.length : 0,
2173
+ },
2174
+ run,
2175
+ };
2176
+ },
2177
+
2178
+ async generate_workflow_with_agent(args = {}, context = {}) {
2179
+ const prompt = String(args.prompt || "").trim();
2180
+ if (!prompt) return { ok: false, error: "prompt is required." };
2181
+ const cfg = loadConfig();
2182
+ const requestedExecutor = String(
2183
+ args.executor || context?.executor || cfg.voice?.delegateExecutor || cfg.primaryAgent || "codex-sdk",
2184
+ ).trim().toLowerCase();
2185
+ const executor = VALID_EXECUTORS.has(requestedExecutor)
2186
+ ? requestedExecutor
2187
+ : (VALID_EXECUTORS.has(cfg.primaryAgent) ? cfg.primaryAgent : "codex-sdk");
2188
+
2189
+ const generationPrompt =
2190
+ "Generate a Bosun workflow JSON definition only. " +
2191
+ "Return strict JSON with keys: name, description, enabled, variables, nodes, edges, triggers, metadata. " +
2192
+ "Do not include markdown fences.\n\n" +
2193
+ `User request:\n${prompt}`;
2194
+ const result = await execPooledPrompt(generationPrompt, {
2195
+ sdk: executor,
2196
+ mode: "agent",
2197
+ sessionId: context?.sessionId || `voice-workflow-generate-${Date.now()}`,
2198
+ metadata: { source: "voice-workflow-generator" },
2199
+ timeoutMs: 45_000,
2200
+ });
2201
+ const text = typeof result === "string"
2202
+ ? result
2203
+ : result?.finalResponse || result?.text || result?.message || JSON.stringify(result);
2204
+ const parsed = parseAnyJson(text);
2205
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
2206
+ return {
2207
+ ok: false,
2208
+ error: "Agent did not return valid workflow JSON.",
2209
+ raw: String(text || "").slice(0, 2000),
2210
+ };
2211
+ }
2212
+ const normalized = normalizeWorkflowDefinition(parsed, {
2213
+ enabled: typeof args.enabled === "boolean" ? args.enabled : false,
2214
+ });
2215
+ const shouldSave = args.save !== false;
2216
+ if (!shouldSave) {
2217
+ return { ok: true, saved: false, workflow: normalized };
2218
+ }
2219
+ const wfEngineMod = await getWorkflowEngineModule();
2220
+ const engine = typeof wfEngineMod.getWorkflowEngine === "function"
2221
+ ? wfEngineMod.getWorkflowEngine()
2222
+ : null;
2223
+ if (!engine?.save) return { ok: false, error: "Workflow engine is unavailable." };
2224
+ const saved = engine.save(normalized);
2225
+ return {
2226
+ ok: true,
2227
+ saved: true,
2228
+ workflow: {
2229
+ id: saved.id,
2230
+ name: saved.name || saved.id,
2231
+ enabled: saved.enabled !== false,
2232
+ nodeCount: Array.isArray(saved.nodes) ? saved.nodes.length : 0,
2233
+ edgeCount: Array.isArray(saved.edges) ? saved.edges.length : 0,
2234
+ },
2235
+ };
2236
+ },
2237
+
1865
2238
  async get_workflow_definition(args = {}) {
1866
2239
  const workflowId = String(args.workflowId || args.id || "").trim();
1867
2240
  if (!workflowId) return { ok: false, error: "workflowId is required." };
@@ -1885,6 +2258,31 @@ const TOOL_HANDLERS = {
1885
2258
  };
1886
2259
  },
1887
2260
 
2261
+ async execute_workflow(args = {}) {
2262
+ const workflowId = String(args.workflowId || args.id || "").trim();
2263
+ if (!workflowId) return { ok: false, error: "workflowId is required." };
2264
+ const wfEngineMod = await getWorkflowEngineModule();
2265
+ const engine = typeof wfEngineMod.getWorkflowEngine === "function"
2266
+ ? wfEngineMod.getWorkflowEngine()
2267
+ : null;
2268
+ if (!engine?.execute) return { ok: false, error: "Workflow engine is unavailable." };
2269
+ const input = parseObjectArg(args.input ?? {}, "input");
2270
+ const force = args.force === true;
2271
+ const ctx = await engine.execute(workflowId, input, { force });
2272
+ return {
2273
+ ok: true,
2274
+ run: {
2275
+ runId: ctx?.id || null,
2276
+ workflowId,
2277
+ status: Array.isArray(ctx?.errors) && ctx.errors.length > 0 ? "failed" : "completed",
2278
+ startedAt: Number(ctx?.startedAt) || null,
2279
+ endedAt: Number(ctx?.endedAt) || Date.now(),
2280
+ duration: Number(ctx?.duration) || null,
2281
+ errorCount: Array.isArray(ctx?.errors) ? ctx.errors.length : 0,
2282
+ },
2283
+ };
2284
+ },
2285
+
1888
2286
  async list_workflow_runs(args = {}) {
1889
2287
  const workflowId = String(args.workflowId || args.id || "").trim();
1890
2288
  const statusFilter = String(args.status || "").trim().toLowerCase();
@@ -1991,6 +2389,15 @@ const TOOL_HANDLERS = {
1991
2389
  if (!engine?.retryRun) {
1992
2390
  return { ok: false, error: "Workflow retry is unavailable." };
1993
2391
  }
2392
+ const currentRun = engine?.getRunDetail ? engine.getRunDetail(runId) : null;
2393
+ if (!currentRun) return { ok: false, error: `Workflow run "${runId}" not found.` };
2394
+ const currentStatus = String(currentRun?.status || "").trim().toLowerCase();
2395
+ if (mode === "from_failed" && currentStatus !== "failed") {
2396
+ return {
2397
+ ok: false,
2398
+ error: `retry mode "from_failed" requires a failed run. Current status is "${currentRun?.status || "unknown"}".`,
2399
+ };
2400
+ }
1994
2401
  const result = await engine.retryRun(runId, { mode });
1995
2402
  return {
1996
2403
  ok: true,
@@ -2001,6 +2408,65 @@ const TOOL_HANDLERS = {
2001
2408
  };
2002
2409
  },
2003
2410
 
2411
+ async analyze_workflow(args = {}) {
2412
+ const wfEngineMod = await getWorkflowEngineModule();
2413
+ const engine = typeof wfEngineMod.getWorkflowEngine === "function"
2414
+ ? wfEngineMod.getWorkflowEngine()
2415
+ : null;
2416
+ if (!engine?.list || !engine?.getRunHistory) {
2417
+ return { ok: false, error: "Workflow engine is unavailable." };
2418
+ }
2419
+ const workflowId = String(args.workflowId || args.id || "").trim();
2420
+ const rawLimit = Number(args.limit);
2421
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0
2422
+ ? Math.min(200, Math.floor(rawLimit))
2423
+ : 30;
2424
+ const targets = workflowId
2425
+ ? [engine.get(workflowId)].filter(Boolean)
2426
+ : engine.list().map((w) => engine.get(w.id)).filter(Boolean);
2427
+ if (workflowId && !targets.length) {
2428
+ return { ok: false, error: `Workflow "${workflowId}" not found.` };
2429
+ }
2430
+ const analyses = targets.map((wf) => {
2431
+ const runs = engine.getRunHistory(wf.id, limit) || [];
2432
+ const byStatus = {};
2433
+ for (const run of runs) {
2434
+ const status = String(run?.status || "unknown").trim().toLowerCase();
2435
+ byStatus[status] = (byStatus[status] || 0) + 1;
2436
+ }
2437
+ const failedCount = Number(byStatus.failed || 0);
2438
+ const completedCount = Number(byStatus.completed || 0);
2439
+ const evaluated = failedCount + completedCount;
2440
+ const failureRate = evaluated > 0 ? failedCount / evaluated : null;
2441
+ const latest = runs[0] || null;
2442
+ return {
2443
+ workflowId: wf.id,
2444
+ name: wf.name || wf.id,
2445
+ enabled: wf.enabled !== false,
2446
+ nodeCount: Array.isArray(wf.nodes) ? wf.nodes.length : Number(wf.nodeCount || 0),
2447
+ edgeCount: Array.isArray(wf.edges) ? wf.edges.length : Number(wf.edgeCount || 0),
2448
+ runCount: runs.length,
2449
+ byStatus,
2450
+ failureRate,
2451
+ latestRun: latest
2452
+ ? {
2453
+ runId: latest.runId || null,
2454
+ status: latest.status || "unknown",
2455
+ startedAt: latest.startedAt || null,
2456
+ duration: latest.duration ?? null,
2457
+ errorCount: latest.errorCount ?? 0,
2458
+ isStuck: latest.isStuck === true,
2459
+ }
2460
+ : null,
2461
+ };
2462
+ });
2463
+ return {
2464
+ ok: true,
2465
+ count: analyses.length,
2466
+ analyses,
2467
+ };
2468
+ },
2469
+
2004
2470
  async list_skills() {
2005
2471
  try {
2006
2472
  const skills = await import("./bosun-skills.mjs");
@@ -899,6 +899,60 @@ export class WorkflowEngine extends EventEmitter {
899
899
  return triggered;
900
900
  }
901
901
 
902
+ // ── Schedule trigger evaluation ──────────────────────────────────────────
903
+
904
+ /**
905
+ * Evaluate all workflows that use `trigger.schedule` or `trigger.scheduled_once`.
906
+ * Unlike evaluateTriggers() (event-driven), this is polling-based and should
907
+ * be called periodically (e.g. every 60s) by the monitor.
908
+ *
909
+ * Returns an array of { workflowId, triggeredBy } for workflows whose
910
+ * schedule interval has elapsed since their last completed run.
911
+ */
912
+ evaluateScheduleTriggers() {
913
+ if (!this._loaded) this.load();
914
+
915
+ const triggered = [];
916
+ const runIndex = this._readRunIndex();
917
+
918
+ for (const [id, def] of this._workflows) {
919
+ if (def.enabled === false) continue;
920
+
921
+ // Skip workflows that are already running
922
+ const alreadyRunning = Array.from(this._activeRuns.values()).some(
923
+ (info) => info?.workflowId === id,
924
+ );
925
+ if (alreadyRunning) continue;
926
+
927
+ const triggerNodes = (def.nodes || []).filter(
928
+ (n) => n.type === "trigger.schedule" || n.type === "trigger.scheduled_once",
929
+ );
930
+
931
+ for (const tNode of triggerNodes) {
932
+ const intervalMs = Number(tNode.config?.intervalMs) || 3600000;
933
+
934
+ // Find the most recent completed run for this workflow
935
+ let lastRunAt = 0;
936
+ for (const entry of runIndex) {
937
+ if (entry?.workflowId !== id) continue;
938
+ const ts = Number(entry?.startedAt || entry?.completedAt || 0);
939
+ if (ts > lastRunAt) lastRunAt = ts;
940
+ }
941
+
942
+ const elapsed = Date.now() - lastRunAt;
943
+ if (elapsed >= intervalMs) {
944
+ triggered.push({ workflowId: id, triggeredBy: tNode.id });
945
+
946
+ // For scheduled_once, only fire if never run before
947
+ if (tNode.type === "trigger.scheduled_once" && lastRunAt > 0) {
948
+ triggered.pop(); // undo — already ran once
949
+ }
950
+ }
951
+ }
952
+ }
953
+ return triggered;
954
+ }
955
+
902
956
  /** Get status of active runs */
903
957
  getActiveRuns() {
904
958
  return Array.from(this._activeRuns.entries())