bosun 0.41.8 → 0.41.10

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 (58) hide show
  1. package/.env.example +1 -1
  2. package/README.md +23 -1
  3. package/agent/agent-event-bus.mjs +31 -2
  4. package/agent/agent-pool.mjs +275 -40
  5. package/agent/agent-prompts.mjs +9 -1
  6. package/agent/agent-supervisor.mjs +22 -0
  7. package/agent/autofix.mjs +1 -1
  8. package/agent/primary-agent.mjs +115 -5
  9. package/cli.mjs +3 -2
  10. package/config/config.mjs +47 -33
  11. package/config/context-shredding-config.mjs +1 -1
  12. package/config/repo-root.mjs +41 -33
  13. package/desktop/main.mjs +350 -25
  14. package/desktop/preload.cjs +8 -0
  15. package/desktop/preload.mjs +19 -0
  16. package/entrypoint.mjs +332 -0
  17. package/git/sdk-conflict-resolver.mjs +1 -1
  18. package/infra/health-status.mjs +72 -0
  19. package/infra/library-manager.mjs +58 -1
  20. package/infra/maintenance.mjs +1 -2
  21. package/infra/monitor.mjs +26 -8
  22. package/infra/session-tracker.mjs +30 -3
  23. package/package.json +12 -4
  24. package/server/bosun-mcp-server.mjs +1004 -0
  25. package/server/setup-web-server.mjs +288 -259
  26. package/server/ui-server.mjs +1323 -26
  27. package/shell/claude-shell.mjs +14 -1
  28. package/shell/codex-config.mjs +1 -1
  29. package/shell/codex-model-profiles.mjs +170 -30
  30. package/shell/codex-shell.mjs +63 -18
  31. package/shell/opencode-providers.mjs +20 -8
  32. package/task/task-executor.mjs +28 -0
  33. package/task/task-store.mjs +13 -4
  34. package/telegram/telegram-sentinel.mjs +54 -3
  35. package/tools/list-todos.mjs +7 -1
  36. package/ui/app.js +3 -2
  37. package/ui/components/agent-selector.js +127 -0
  38. package/ui/components/session-list.js +15 -10
  39. package/ui/demo-defaults.js +334 -336
  40. package/ui/modules/router.js +2 -0
  41. package/ui/modules/state.js +13 -5
  42. package/ui/tabs/chat.js +3 -0
  43. package/ui/tabs/library.js +284 -52
  44. package/ui/tabs/tasks.js +5 -13
  45. package/ui/tabs/workflows.js +766 -3
  46. package/workflow/workflow-engine.mjs +246 -5
  47. package/workflow/workflow-nodes/definitions.mjs +37 -0
  48. package/workflow/workflow-nodes.mjs +1014 -184
  49. package/workflow/workflow-templates.mjs +0 -5
  50. package/workflow-templates/_helpers.mjs +253 -0
  51. package/workflow-templates/agents.mjs +199 -226
  52. package/workflow-templates/github.mjs +106 -16
  53. package/workflow-templates/sub-workflows.mjs +233 -0
  54. package/workflow-templates/task-execution.mjs +125 -471
  55. package/workflow-templates/task-lifecycle.mjs +11 -48
  56. package/workspace/command-diagnostics.mjs +460 -0
  57. package/workspace/context-cache.mjs +396 -28
  58. package/workspace/worktree-manager.mjs +1 -1
@@ -535,6 +535,8 @@ export class WorkflowContext {
535
535
  this.nodeStatusEvents = [];
536
536
  this.variables = {};
537
537
  this.retryAttempts = new Map();
538
+ this._nodeTimings = Object.create(null);
539
+ this._nodeInputs = Object.create(null);
538
540
  }
539
541
 
540
542
  /** Target repo for multi-repo workspaces (convenience accessor) */
@@ -575,6 +577,31 @@ export class WorkflowContext {
575
577
  return forked;
576
578
  }
577
579
 
580
+ /** Set a timing field for a node (startedAt / endedAt) */
581
+ setNodeTiming(nodeId, field, value) {
582
+ const key = String(nodeId);
583
+ const fieldKey = String(field);
584
+ if (!this._nodeTimings[key] || typeof this._nodeTimings[key] !== "object") {
585
+ this._nodeTimings[key] = Object.create(null);
586
+ }
587
+ this._nodeTimings[key][fieldKey] = value;
588
+ }
589
+
590
+ /** Get timing data for a node */
591
+ getNodeTiming(nodeId) {
592
+ return this._nodeTimings[String(nodeId)] || null;
593
+ }
594
+
595
+ /** Store the resolved input snapshot for a node */
596
+ setNodeInput(nodeId, input) {
597
+ this._nodeInputs[String(nodeId)] = input;
598
+ }
599
+
600
+ /** Get the stored input snapshot for a node */
601
+ getNodeInput(nodeId) {
602
+ return this._nodeInputs[String(nodeId)] || null;
603
+ }
604
+
578
605
  /** Set output from a node */
579
606
  setNodeOutput(nodeId, output) {
580
607
  this.nodeOutputs.set(nodeId, output);
@@ -665,6 +692,8 @@ export class WorkflowContext {
665
692
  logs: this.logs,
666
693
  errors: this.errors,
667
694
  nodeStatusEvents: this.nodeStatusEvents,
695
+ nodeTimings: { ...this._nodeTimings },
696
+ nodeInputs: { ...this._nodeInputs },
668
697
  };
669
698
  }
670
699
  }
@@ -2051,7 +2080,199 @@ export class WorkflowEngine extends EventEmitter {
2051
2080
  ? detail.data._taskWorkflowEvents
2052
2081
  : [];
2053
2082
  return events.map((event) => ({ ...event }));
2054
- } // ── Internal DAG Execution ────────────────────────────────────────────
2083
+ }
2084
+
2085
+ /**
2086
+ * Get detailed forensics for a single node in a run.
2087
+ * @param {string} runId
2088
+ * @param {string} nodeId
2089
+ * @returns {object|null}
2090
+ */
2091
+ getNodeForensics(runId, nodeId) {
2092
+ const run = this.getRunDetail(runId);
2093
+ if (!run) return null;
2094
+ const detail = run.detail || {};
2095
+ const nodeStatuses = detail.nodeStatuses || {};
2096
+ if (!(nodeId in nodeStatuses)) return null;
2097
+
2098
+ const timings = detail.nodeTimings?.[nodeId] || {};
2099
+ const startedAt = timings.startedAt || null;
2100
+ const endedAt = timings.endedAt || null;
2101
+ const durationMs = startedAt && endedAt ? Math.max(0, endedAt - startedAt) : null;
2102
+
2103
+ return {
2104
+ nodeId,
2105
+ status: nodeStatuses[nodeId] || null,
2106
+ startedAt,
2107
+ endedAt,
2108
+ durationMs,
2109
+ input: detail.nodeInputs?.[nodeId] || null,
2110
+ output: detail.nodeOutputs?.[nodeId] || null,
2111
+ errors: (detail.errors || []).filter((e) => e.nodeId === nodeId),
2112
+ retryAttempts: detail.retryAttempts?.[nodeId] || 0,
2113
+ statusEvents: (detail.nodeStatusEvents || []).filter((e) => e.nodeId === nodeId),
2114
+ };
2115
+ }
2116
+
2117
+ /**
2118
+ * Get forensics for all nodes in a run.
2119
+ * @param {string} runId
2120
+ * @returns {object|null}
2121
+ */
2122
+ getRunForensics(runId) {
2123
+ const run = this.getRunDetail(runId);
2124
+ if (!run) return null;
2125
+ const detail = run.detail || {};
2126
+ const nodeStatuses = detail.nodeStatuses || {};
2127
+ const nodes = {};
2128
+ for (const nodeId of Object.keys(nodeStatuses)) {
2129
+ nodes[nodeId] = this.getNodeForensics(runId, nodeId);
2130
+ }
2131
+ return {
2132
+ runId,
2133
+ status: run.status || null,
2134
+ startedAt: detail.startedAt || null,
2135
+ endedAt: detail.endedAt || null,
2136
+ durationMs: detail.duration || null,
2137
+ nodes,
2138
+ };
2139
+ }
2140
+
2141
+ /**
2142
+ * Create a snapshot of a completed run for later restore.
2143
+ * @param {string} runId
2144
+ * @returns {{ snapshotId: string, path: string }|null}
2145
+ */
2146
+ createRunSnapshot(runId) {
2147
+ const run = this.getRunDetail(runId);
2148
+ if (!run) return null;
2149
+ const detail = run.detail || {};
2150
+ const workflowId = run.workflowId || detail.data?._workflowId;
2151
+ const snapshotsDir = resolve(this.runsDir, "snapshots");
2152
+ mkdirSync(snapshotsDir, { recursive: true });
2153
+ const snapshotId = runId;
2154
+ const snapshotPath = resolve(snapshotsDir, `${snapshotId}.json`);
2155
+ const snapshot = {
2156
+ snapshotId,
2157
+ runId,
2158
+ workflowId,
2159
+ createdAt: Date.now(),
2160
+ nodeStatuses: detail.nodeStatuses || {},
2161
+ nodeOutputs: detail.nodeOutputs || {},
2162
+ nodeTimings: detail.nodeTimings || {},
2163
+ nodeInputs: detail.nodeInputs || {},
2164
+ retryAttempts: detail.retryAttempts || {},
2165
+ variables: detail.data || {},
2166
+ errors: detail.errors || [],
2167
+ };
2168
+ writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), "utf8");
2169
+ return { snapshotId, path: snapshotPath };
2170
+ }
2171
+
2172
+ /**
2173
+ * Restore a run from a snapshot — creates a new execution pre-seeded
2174
+ * with completed node state from the snapshot.
2175
+ * @param {string} snapshotId
2176
+ * @param {object} [opts]
2177
+ * @param {object} [opts.variables] - Override variables
2178
+ * @returns {Promise<object>}
2179
+ */
2180
+ async restoreFromSnapshot(snapshotId, opts = {}) {
2181
+ const snapshotPath = resolve(this.runsDir, "snapshots", `${snapshotId}.json`);
2182
+ if (!existsSync(snapshotPath)) {
2183
+ throw new Error(`Snapshot "${snapshotId}" not found`);
2184
+ }
2185
+ const snapshot = JSON.parse(readFileSync(snapshotPath, "utf8"));
2186
+ const workflowId = snapshot.workflowId;
2187
+ const def = this.get(workflowId);
2188
+ if (!def) {
2189
+ throw new Error(`Workflow "${workflowId}" no longer exists — cannot restore`);
2190
+ }
2191
+
2192
+ const inputData = {
2193
+ ...def.variables,
2194
+ ...(snapshot.variables || {}),
2195
+ ...(opts.variables || {}),
2196
+ _workflowId: workflowId,
2197
+ _workflowName: def.name,
2198
+ _restoredFrom: snapshotId,
2199
+ };
2200
+
2201
+ // Remove internal keys that should be regenerated
2202
+ delete inputData._workflowId;
2203
+ delete inputData._workflowName;
2204
+
2205
+ const ctx = new WorkflowContext(inputData);
2206
+ ctx.data._workflowId = workflowId;
2207
+ ctx.data._workflowName = def.name;
2208
+ ctx.data._restoredFrom = snapshotId;
2209
+ ctx.variables = { ...def.variables, ...(opts.variables || {}) };
2210
+
2211
+ // Pre-seed completed nodes from snapshot
2212
+ const nodeStatuses = snapshot.nodeStatuses || {};
2213
+ const nodeOutputs = snapshot.nodeOutputs || {};
2214
+ for (const [nodeId, status] of Object.entries(nodeStatuses)) {
2215
+ if (status === "completed") {
2216
+ ctx.setNodeStatus(nodeId, NodeStatus.COMPLETED);
2217
+ if (nodeOutputs[nodeId] !== undefined) {
2218
+ ctx.setNodeOutput(nodeId, nodeOutputs[nodeId]);
2219
+ }
2220
+ }
2221
+ }
2222
+
2223
+ const retryRunId = ctx.id;
2224
+ this._activeRuns.set(retryRunId, {
2225
+ workflowId,
2226
+ workflowName: def.name,
2227
+ ctx,
2228
+ startedAt: ctx.startedAt,
2229
+ status: WorkflowStatus.RUNNING,
2230
+ });
2231
+ this.emit("run:start", { runId: retryRunId, workflowId, name: def.name, restoredFrom: snapshotId });
2232
+
2233
+ try {
2234
+ const adjacency = this._buildAdjacency(def);
2235
+ const entryNodes = this._findEntryNodes(def);
2236
+ await this._executeDag(def, entryNodes, adjacency, ctx, opts);
2237
+ const finalStatus = ctx.errors.length > 0 ? WorkflowStatus.FAILED : WorkflowStatus.COMPLETED;
2238
+ this._persistRun(retryRunId, workflowId, ctx);
2239
+ this._activeRuns.delete(retryRunId);
2240
+ return { runId: retryRunId, snapshotId, workflowId, ctx, status: finalStatus };
2241
+ } catch (err) {
2242
+ this._persistRun(retryRunId, workflowId, ctx);
2243
+ this._activeRuns.delete(retryRunId);
2244
+ throw err;
2245
+ }
2246
+ }
2247
+
2248
+ /**
2249
+ * List available snapshots, optionally filtered by workflowId.
2250
+ * @param {string} [workflowId]
2251
+ * @returns {Array<object>}
2252
+ */
2253
+ listSnapshots(workflowId) {
2254
+ const snapshotsDir = resolve(this.runsDir, "snapshots");
2255
+ if (!existsSync(snapshotsDir)) return [];
2256
+ const files = readdirSync(snapshotsDir).filter((f) => f.endsWith(".json"));
2257
+ const snapshots = [];
2258
+ for (const file of files) {
2259
+ try {
2260
+ const data = JSON.parse(readFileSync(resolve(snapshotsDir, file), "utf8"));
2261
+ if (workflowId && data.workflowId !== workflowId) continue;
2262
+ snapshots.push({
2263
+ snapshotId: data.snapshotId,
2264
+ runId: data.runId,
2265
+ workflowId: data.workflowId,
2266
+ createdAt: data.createdAt,
2267
+ });
2268
+ } catch {
2269
+ // skip corrupt snapshot files
2270
+ }
2271
+ }
2272
+ return snapshots.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
2273
+ }
2274
+
2275
+ // ── Internal DAG Execution ────────────────────────────────────────────
2055
2276
 
2056
2277
  _buildAdjacency(def) {
2057
2278
  const adj = new Map();
@@ -2639,6 +2860,9 @@ export class WorkflowEngine extends EventEmitter {
2639
2860
  // Resolve config templates against context
2640
2861
  const resolvedConfig = this._resolveConfig(node.config || {}, ctx);
2641
2862
 
2863
+ // Capture resolved input snapshot for forensics
2864
+ ctx.setNodeInput(node.id, resolvedConfig);
2865
+
2642
2866
  // Dry run — skip capability checks and handler execution.
2643
2867
  // Services aren't needed for simulation; this keeps dry-run tests fast.
2644
2868
  if (opts.dryRun) {
@@ -2668,6 +2892,7 @@ export class WorkflowEngine extends EventEmitter {
2668
2892
  // Execute with timeout — clear timer on completion to avoid resource leaks
2669
2893
  const timeout = resolveNodeTimeoutMs(node, resolvedConfig);
2670
2894
  let timer;
2895
+ ctx.setNodeTiming(node.id, "startedAt", Date.now());
2671
2896
  try {
2672
2897
  const result = await Promise.race([
2673
2898
  handler.execute(
@@ -2679,7 +2904,11 @@ export class WorkflowEngine extends EventEmitter {
2679
2904
  timer = setTimeout(() => reject(new Error(`Node "${node.label || node.id}" timed out after ${timeout}ms`)), timeout);
2680
2905
  }),
2681
2906
  ]);
2907
+ ctx.setNodeTiming(node.id, "endedAt", Date.now());
2682
2908
  return result;
2909
+ } catch (err) {
2910
+ ctx.setNodeTiming(node.id, "endedAt", Date.now());
2911
+ throw err;
2683
2912
  } finally {
2684
2913
  clearTimeout(timer);
2685
2914
  }
@@ -2969,6 +3198,16 @@ export class WorkflowEngine extends EventEmitter {
2969
3198
  normalized.activeNodeCount = 0;
2970
3199
  }
2971
3200
  if (normalized.status !== WorkflowStatus.RUNNING) {
3201
+ normalized.activeNodeCount = 0;
3202
+ if (!Number.isFinite(Number(normalized.endedAt))) {
3203
+ const fallbackEndedAt = Math.max(
3204
+ Number(normalized.interruptedAt) || 0,
3205
+ Number(normalized.lastProgressAt) || 0,
3206
+ Number(normalized.lastLogAt) || 0,
3207
+ Number(normalized.startedAt) || 0,
3208
+ );
3209
+ normalized.endedAt = fallbackEndedAt > 0 ? fallbackEndedAt : null;
3210
+ }
2972
3211
  normalized.isStuck = false;
2973
3212
  normalized.stuckMs = 0;
2974
3213
  return normalized;
@@ -3172,6 +3411,10 @@ export class WorkflowEngine extends EventEmitter {
3172
3411
  summary.status = WorkflowStatus.PAUSED;
3173
3412
  summary.resumable = canResume;
3174
3413
  summary.interruptedAt = now;
3414
+ summary.activeNodeCount = 0;
3415
+ if (!Number.isFinite(Number(summary.endedAt))) {
3416
+ summary.endedAt = now;
3417
+ }
3175
3418
  if (!canResume) summary.resumeResult = "recovery_cap_exceeded";
3176
3419
  }
3177
3420
  if (canResume && !forceResumable) resumableStaleRunsAssigned += 1;
@@ -3253,7 +3496,8 @@ export class WorkflowEngine extends EventEmitter {
3253
3496
  this._resumingRuns = true;
3254
3497
 
3255
3498
  try {
3256
- const runs = this._readRunIndex().filter(
3499
+ const allRuns = this._readRunIndex();
3500
+ const runs = allRuns.filter(
3257
3501
  (r) => r.status === WorkflowStatus.PAUSED && r.resumable,
3258
3502
  );
3259
3503
 
@@ -3272,9 +3516,6 @@ export class WorkflowEngine extends EventEmitter {
3272
3516
  // and mark older duplicates as not-resumable before we even try them.
3273
3517
  const runDetailCache = new Map(); // runId → parsed detail
3274
3518
  const latestByTaskId = new Map(); // taskId → run entry (highest startedAt)
3275
-
3276
- const allRuns = this._readRunIndex();
3277
-
3278
3519
  for (const run of allRuns) {
3279
3520
  const dp = resolve(this.runsDir, `${run.runId}.json`);
3280
3521
  if (!existsSync(dp)) continue;
@@ -542,6 +542,43 @@ async function createKanbanTaskWithProject(kanban, taskData = {}, projectIdValue
542
542
  payload.projectId = resolvedProjectId;
543
543
  }
544
544
 
545
+ const createTaskParamNames = (() => {
546
+ try {
547
+ const inspectTarget =
548
+ typeof kanban.createTask?.getMockImplementation === "function"
549
+ ? kanban.createTask.getMockImplementation() || kanban.createTask
550
+ : kanban.createTask;
551
+ const source = Function.prototype.toString.call(inspectTarget);
552
+ const parenMatch = source.match(/^[^(]*\(([^)]*)\)/s);
553
+ if (parenMatch) {
554
+ return String(parenMatch[1] || "")
555
+ .split(",")
556
+ .map((entry) =>
557
+ String(entry || "")
558
+ .trim()
559
+ .replace(/^\.{3}/, "")
560
+ .replace(/\s*=.*$/s, "")
561
+ .trim(),
562
+ )
563
+ .filter(Boolean);
564
+ }
565
+ const arrowMatch = source.match(/^(?:async\s+)?([A-Za-z_$][\w$]*)\s*=>/);
566
+ if (arrowMatch?.[1]) return [arrowMatch[1]];
567
+ } catch {
568
+ // Fall back to the project-aware signature when adapter source is opaque.
569
+ }
570
+ return [];
571
+ })();
572
+ const firstParamName = String(createTaskParamNames[0] || "").toLowerCase();
573
+ const payloadOnlyCreateTask =
574
+ createTaskParamNames.length === 1 &&
575
+ /(task|payload|spec|data)/i.test(firstParamName) &&
576
+ !/project/i.test(firstParamName);
577
+
578
+ if (payloadOnlyCreateTask) {
579
+ return kanban.createTask(payload);
580
+ }
581
+
545
582
  const taskPayload = { ...payload };
546
583
  delete taskPayload.projectId;
547
584
  return kanban.createTask(resolvedProjectId, taskPayload);