bosun 0.42.0 → 0.42.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 (63) hide show
  1. package/.env.example +12 -0
  2. package/README.md +2 -0
  3. package/agent/agent-pool.mjs +34 -1
  4. package/agent/agent-work-report.mjs +89 -3
  5. package/agent/analyze-agent-work-helpers.mjs +14 -0
  6. package/agent/analyze-agent-work.mjs +23 -3
  7. package/agent/primary-agent.mjs +23 -1
  8. package/bosun-tui.mjs +4 -3
  9. package/bosun.schema.json +1 -1
  10. package/config/config.mjs +58 -0
  11. package/config/workspace-health.mjs +36 -6
  12. package/git/diff-stats.mjs +550 -124
  13. package/github/github-app-auth.mjs +9 -5
  14. package/infra/maintenance.mjs +13 -6
  15. package/infra/monitor.mjs +398 -10
  16. package/infra/runtime-accumulator.mjs +9 -1
  17. package/infra/session-tracker.mjs +163 -1
  18. package/infra/tui-bridge.mjs +415 -0
  19. package/infra/worktree-recovery-state.mjs +159 -0
  20. package/kanban/kanban-adapter.mjs +41 -8
  21. package/lib/repo-map.mjs +411 -0
  22. package/package.json +140 -137
  23. package/server/ui-server.mjs +953 -59
  24. package/shell/codex-config.mjs +34 -8
  25. package/task/task-cli.mjs +93 -19
  26. package/task/task-executor.mjs +397 -8
  27. package/task/task-store.mjs +194 -1
  28. package/telegram/telegram-bot.mjs +267 -18
  29. package/tools/vitest-runner.mjs +108 -0
  30. package/tui/app.mjs +252 -148
  31. package/tui/components/status-header.mjs +88 -131
  32. package/tui/lib/ws-bridge.mjs +125 -35
  33. package/tui/screens/agents-screen-helpers.mjs +219 -0
  34. package/tui/screens/agents.mjs +287 -270
  35. package/tui/screens/status.mjs +51 -189
  36. package/tui/screens/tasks.mjs +41 -253
  37. package/ui/app.js +52 -23
  38. package/ui/components/chat-view.js +263 -84
  39. package/ui/components/diff-viewer.js +324 -140
  40. package/ui/components/kanban-board.js +13 -9
  41. package/ui/components/session-list.js +111 -41
  42. package/ui/demo-defaults.js +481 -59
  43. package/ui/demo.html +32 -0
  44. package/ui/modules/session-api.js +320 -5
  45. package/ui/modules/stream-timeline.js +356 -0
  46. package/ui/modules/telegram.js +5 -2
  47. package/ui/modules/worktree-recovery.js +85 -0
  48. package/ui/styles.css +44 -0
  49. package/ui/tabs/chat.js +19 -4
  50. package/ui/tabs/dashboard.js +22 -0
  51. package/ui/tabs/infra.js +25 -0
  52. package/ui/tabs/tasks.js +119 -11
  53. package/voice/voice-auth-manager.mjs +10 -5
  54. package/workflow/workflow-engine.mjs +179 -1
  55. package/workflow/workflow-nodes.mjs +872 -16
  56. package/workflow/workflow-templates.mjs +4 -0
  57. package/workflow-templates/github.mjs +2 -1
  58. package/workflow-templates/planning.mjs +2 -1
  59. package/workflow-templates/sub-workflows.mjs +10 -0
  60. package/workflow-templates/task-batch.mjs +9 -8
  61. package/workflow-templates/task-execution.mjs +30 -12
  62. package/workflow-templates/task-lifecycle.mjs +59 -4
  63. package/workspace/shared-knowledge.mjs +409 -155
package/ui/tabs/tasks.js CHANGED
@@ -66,6 +66,7 @@ import {
66
66
  SkeletonCard,
67
67
  EmptyState
68
68
  } from "../components/shared.js";
69
+ import { DiffViewer } from "../components/diff-viewer.js";
69
70
  import {
70
71
  SegmentedControl,
71
72
  SearchInput,
@@ -85,6 +86,7 @@ import {
85
86
  Paper, CircularProgress, Skeleton, Alert, Switch, FormControlLabel,
86
87
  Menu as MuiMenu, Fab, Table, TableBody, TableCell, TableContainer,
87
88
  TableHead, TableRow, TableSortLabel, ToggleButton, ToggleButtonGroup, Badge,
89
+ Autocomplete,
88
90
  } from "@mui/material";
89
91
 
90
92
  /* ─── View mode toggle ─── */
@@ -1245,9 +1247,12 @@ function buildTaskRelatedLinks(task) {
1245
1247
  "";
1246
1248
  const prNumber =
1247
1249
  task?.prNumber ||
1250
+ task?.pr ||
1248
1251
  task?.pr_number ||
1249
1252
  task?.meta?.prNumber ||
1253
+ task?.meta?.pr ||
1250
1254
  task?.meta?.pr_number ||
1255
+ task?.meta?.pr?.number ||
1251
1256
  "";
1252
1257
  const prUrl =
1253
1258
  task?.prUrl ||
@@ -1258,13 +1263,72 @@ function buildTaskRelatedLinks(task) {
1258
1263
  "";
1259
1264
  const baseBranch = getTaskBaseBranch(task);
1260
1265
 
1261
- if (branch) links.push({ kind: "Branch", value: branch, url: "" });
1266
+ if (branch) links.push({ kind: "Branch", value: branch, url: "", emphasis: true });
1262
1267
  if (baseBranch) links.push({ kind: "Base", value: baseBranch, url: "" });
1263
- if (prNumber) links.push({ kind: "PR", value: `#${prNumber}`, url: prUrl || "" });
1268
+ if (prNumber) links.push({ kind: "PR", value: `#${prNumber}`, url: prUrl || "", emphasis: true });
1264
1269
  if (prUrl) links.push({ kind: "PR URL", value: prUrl, url: prUrl });
1265
1270
  return links;
1266
1271
  }
1267
1272
 
1273
+ function renderTaskRelatedLinks(relatedLinks, { onReviewDiff = null } = {}) {
1274
+ if (!Array.isArray(relatedLinks) || !relatedLinks.length) {
1275
+ if (!onReviewDiff) return "No branch or PR links recorded.";
1276
+ return html`
1277
+ <div style=${{ display: "flex", flexWrap: "wrap", gap: "8px", alignItems: "center" }}>
1278
+ <button
1279
+ type="button"
1280
+ class="task-related-link-chip"
1281
+ onClick=${onReviewDiff}
1282
+ >
1283
+ ${resolveIcon("edit") || "✎"} Review Diff
1284
+ </button>
1285
+ </div>
1286
+ `;
1287
+ }
1288
+
1289
+ return html`
1290
+ <div style=${{ display: "flex", flexWrap: "wrap", gap: "8px", alignItems: "center" }}>
1291
+ ${relatedLinks.map((item, index) => html`
1292
+ ${item.url
1293
+ ? html`
1294
+ <a
1295
+ key=${`task-link-${index}`}
1296
+ class="task-related-link-chip"
1297
+ data-emphasis=${item.emphasis ? "true" : "false"}
1298
+ href=${item.url}
1299
+ target="_blank"
1300
+ rel="noopener noreferrer"
1301
+ >
1302
+ <span class="task-related-link-kind">${item.kind}</span>
1303
+ <span class="task-related-link-value">${item.value}</span>
1304
+ </a>
1305
+ `
1306
+ : html`
1307
+ <span
1308
+ key=${`task-link-${index}`}
1309
+ class="task-related-link-chip"
1310
+ data-emphasis=${item.emphasis ? "true" : "false"}
1311
+ >
1312
+ <span class="task-related-link-kind">${item.kind}</span>
1313
+ <span class="task-related-link-value">${item.value}</span>
1314
+ </span>
1315
+ `}
1316
+ `)}
1317
+ ${onReviewDiff && html`
1318
+ <button
1319
+ type="button"
1320
+ class="task-related-link-chip"
1321
+ data-emphasis="true"
1322
+ onClick=${onReviewDiff}
1323
+ >
1324
+ <span class="task-related-link-kind">Review</span>
1325
+ <span class="task-related-link-value">Open Diff</span>
1326
+ </button>
1327
+ `}
1328
+ </div>
1329
+ `;
1330
+ }
1331
+
1268
1332
  function buildTaskAgentList(task) {
1269
1333
  const values = [];
1270
1334
  const pushValue = (value) => {
@@ -1522,13 +1586,40 @@ export function StartTaskModal({
1522
1586
  <${Stack} spacing=${2}>
1523
1587
  ${(allowTaskIdInput || !task?.id) &&
1524
1588
  html`
1525
- <${TextField}
1526
- label="Task ID"
1527
- placeholder="e.g. task-123"
1589
+ <${Autocomplete}
1590
+ freeSolo
1528
1591
  size="small"
1529
1592
  fullWidth
1530
- value=${taskIdInput}
1531
- onChange=${(e) => setTaskIdInput(e.target.value)}
1593
+ options=${(() => {
1594
+ const STARTABLE = new Set(["draft", "backlog", "open", "new", "todo", "blocked", "error", "failed"]);
1595
+
1596
+ const getGroup = (s) => {
1597
+ const lower = (s || "").toLowerCase();
1598
+ if (lower === "draft") return "Draft";
1599
+ if (["blocked", "error", "failed"].includes(lower)) return "Blocked";
1600
+ return "Todo";
1601
+ };
1602
+ return (tasksData.value || [])
1603
+ .filter(t => STARTABLE.has((t.status || "").toLowerCase()))
1604
+ .map(t => ({ id: t.id, title: t.title || "(untitled)", status: t.status, group: getGroup(t.status) }))
1605
+ .sort((a, b) => a.group.localeCompare(b.group) || (a.title || "").localeCompare(b.title || ""));
1606
+ })()}
1607
+ groupBy=${(opt) => opt.group || ""}
1608
+ getOptionLabel=${(opt) => typeof opt === "string" ? opt : opt.title ? `${opt.title} (${opt.id})` : opt.id || ""}
1609
+ isOptionEqualToValue=${(opt, val) => opt.id === (typeof val === "string" ? val : val?.id)}
1610
+ inputValue=${taskIdInput}
1611
+ onInputChange=${(_, val) => setTaskIdInput(val || "")}
1612
+ onChange=${(_, val) => {
1613
+ if (val && typeof val === "object" && val.id) {
1614
+ setTaskIdInput(val.id);
1615
+ } else if (typeof val === "string") {
1616
+ setTaskIdInput(val);
1617
+ }
1618
+ }}
1619
+ renderInput=${(params) => html`<${TextField} ...${params} label="Task ID" placeholder="Search or enter task ID" />`}
1620
+ renderOption=${(props, opt) => html`<li ...${props} key=${opt.id}><${Box} sx=${{ display: "flex", flexDirection: "column" }}><${Typography} variant="body2">${opt.title}<//><${Typography} variant="caption" color="text.secondary">${opt.id}<//><//>
1621
+ </li>`}
1622
+ disablePortal
1532
1623
  />
1533
1624
  `}
1534
1625
  <${TextField}
@@ -2684,10 +2775,14 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
2684
2775
  task?.id,
2685
2776
  task?.branch,
2686
2777
  task?.branchName,
2778
+ task?.pr,
2687
2779
  task?.prNumber,
2688
2780
  task?.prUrl,
2689
2781
  task?.meta,
2690
2782
  ]);
2783
+ const handleOpenReviewDiff = useCallback(() => {
2784
+ setActiveTab("diff");
2785
+ }, []);
2691
2786
  const taskAgents = useMemo(() => buildTaskAgentList(task), [
2692
2787
  task?.id,
2693
2788
  task?.assignee,
@@ -3454,6 +3549,9 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
3454
3549
  ${resolveIcon("clock") || "⏱"} History
3455
3550
  ${historyEntries.length > 0 && html`<span class="task-tab-count">${historyEntries.length}</span>`}
3456
3551
  </button>
3552
+ <button class="task-tab-btn" data-active=${activeTab === "diff"} onClick=${() => setActiveTab("diff")}>
3553
+ ${resolveIcon("edit") || "✎"} Diff
3554
+ </button>
3457
3555
  </div>
3458
3556
 
3459
3557
  ${/* ── Content Body ───────────────────────────────────────────── */ ""}
@@ -3812,7 +3910,7 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
3812
3910
  </div>
3813
3911
  <div class="task-comment-item">
3814
3912
  <div class="task-comment-meta">Branch / PR</div>
3815
- <div class="task-comment-body">${relatedLinks.length ? relatedLinks.map((item) => `${item.kind}: ${item.value}`).join(" · ") : "No branch or PR links recorded."}</div>
3913
+ <div class="task-comment-body">${renderTaskRelatedLinks(relatedLinks, { onReviewDiff: handleOpenReviewDiff })}</div>
3816
3914
  </div>
3817
3915
  </div>
3818
3916
  </div>
@@ -4580,6 +4678,18 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
4580
4678
  </div>`}
4581
4679
 
4582
4680
  ${/* ── HISTORY TAB ─────────────────────────────────────────────── */ ""}
4681
+ ${activeTab === "diff" && html`
4682
+ <div class="task-comments-block modal-form-span jira-panel">
4683
+ <div class="task-attachments-title">Review Diff</div>
4684
+ <div style=${{ display: "flex", flexDirection: "column", gap: "12px" }}>
4685
+ <div class="task-comment-meta">
4686
+ Compare the task branch or linked session against its recorded base so completed PRs stay reviewable from the task itself.
4687
+ </div>
4688
+ <${DiffViewer} taskId=${task?.id || ""} title=${task?.title || "Task Diff"} />
4689
+ </div>
4690
+ </div>
4691
+ `}
4692
+
4583
4693
  ${activeTab === "history" && html`<div style="display:contents;">
4584
4694
 
4585
4695
  ${historyEntries.length > 0 ? html`
@@ -4607,9 +4717,7 @@ export function TaskDetailModal({ task, onClose, onStart, presentation = "modal"
4607
4717
  <div class="task-comment-item" key=${`link-${index}`}>
4608
4718
  <div class="task-comment-meta">${item.kind}</div>
4609
4719
  <div class="task-comment-body">
4610
- ${item.url
4611
- ? html`<a href=${item.url} target="_blank" rel="noopener">${item.value}</a>`
4612
- : item.value}
4720
+ ${renderTaskRelatedLinks([item])}
4613
4721
  </div>
4614
4722
  </div>
4615
4723
  `)}
@@ -15,6 +15,14 @@ function normalizeProvider(provider) {
15
15
  return String(provider || "").trim().toLowerCase();
16
16
  }
17
17
 
18
+ function normalizeEnvValue(value) {
19
+ const normalized = String(value ?? "").trim();
20
+ if (!normalized) return "";
21
+ const lowered = normalized.toLowerCase();
22
+ if (lowered === "undefined" || lowered === "null") return "";
23
+ return normalized;
24
+ }
25
+
18
26
  function escapeHtml(value) {
19
27
  return String(value ?? "")
20
28
  .replace(/&/g, "&amp;")
@@ -102,7 +110,7 @@ export function resolveVoiceOAuthToken(provider, forceReload = false) {
102
110
  if (!normalizedProvider) return null;
103
111
 
104
112
  const envToken = getProviderEnvCandidates(normalizedProvider)
105
- .map((token) => String(token || "").trim())
113
+ .map((token) => normalizeEnvValue(token))
106
114
  .find(Boolean);
107
115
  if (envToken) {
108
116
  return {
@@ -212,11 +220,8 @@ function normalizeScopeList(scopes) {
212
220
  }
213
221
 
214
222
  function envOrDefault(name, fallback = "") {
215
- const raw = process.env[name];
216
- const value = String(raw ?? "").trim();
223
+ const value = normalizeEnvValue(process.env[name]);
217
224
  if (!value) return fallback;
218
- const normalized = value.toLowerCase();
219
- if (normalized === "undefined" || normalized === "null") return fallback;
220
225
  return value;
221
226
  }
222
227
 
@@ -43,6 +43,7 @@ import {
43
43
  } from "../infra/test-runtime.mjs";
44
44
  import { getTemplate } from "./workflow-templates.mjs";
45
45
  import { WorkflowExecutionLedger } from "./execution-ledger.mjs";
46
+ import { buildWorkflowStatusPayload } from "../infra/tui-bridge.mjs";
46
47
 
47
48
  // Lazy-loaded workspace manager for workspace-aware scheduling
48
49
  let _workspaceManagerMod = null;
@@ -65,6 +66,7 @@ function ensureWorkspaceManagerSync() {
65
66
  const TAG = "[workflow-engine]";
66
67
  const WORKFLOW_DIR_NAME = "workflows";
67
68
  const WORKFLOW_RUNS_DIR = "workflow-runs";
69
+ const WORKFLOW_TRAJECTORIES_DIR = "trajectories";
68
70
  function readBoundedEnvInt(name, fallback, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
69
71
  const parsed = Number(process.env[name]);
70
72
  if (!Number.isFinite(parsed)) return fallback;
@@ -723,6 +725,15 @@ export class WorkflowContext {
723
725
  nodeInputs: { ...this._nodeInputs },
724
726
  dagState: this.data?._dagState || null,
725
727
  issueAdvisor: this.data?._issueAdvisor || null,
728
+ replayTrajectory: this.data?._replayTrajectory || null,
729
+ stepSummaries: Array.isArray(this.data?._replayTrajectory?.steps)
730
+ ? this.data._replayTrajectory.steps.map((s) => ({
731
+ nodeId: s.nodeId,
732
+ label: s.label,
733
+ status: s.status,
734
+ summary: s.summary,
735
+ }))
736
+ : [],
726
737
  };
727
738
  }
728
739
  }
@@ -783,6 +794,12 @@ export class WorkflowEngine extends EventEmitter {
783
794
  // Lazy-load workspace manager for schedule evaluation
784
795
  void ensureWorkspaceManager().catch(() => {});
785
796
  }
797
+ _emitWorkflowStatus(payload = {}) {
798
+ const event = buildWorkflowStatusPayload(payload);
799
+ if (!event.runId || !event.workflowId || !event.eventType) return;
800
+ this.emit("workflow:status", event);
801
+ }
802
+
786
803
 
787
804
  _initializeDagState(def, ctx, extra = {}) {
788
805
  const dependencyMap = new Map();
@@ -913,6 +930,57 @@ export class WorkflowEngine extends EventEmitter {
913
930
  return issueAdvisor;
914
931
  }
915
932
 
933
+ _buildStepSummary(node, { status = null, result = undefined, error = null } = {}) {
934
+ const nodeId = String(node?.id || "").trim() || null;
935
+ const label = String(node?.label || nodeId || "step").trim();
936
+ const normalizedStatus = String(status || "unknown").trim().toLowerCase() || "unknown";
937
+ let detail = null;
938
+ if (error) {
939
+ detail = String(error).trim();
940
+ } else if (result !== undefined) {
941
+ detail = this._summarizeTaskTraceNodeResult(result);
942
+ }
943
+ if (!detail) {
944
+ detail = normalizedStatus === "completed"
945
+ ? "Step completed successfully."
946
+ : (normalizedStatus === "failed" ? "Step failed." : `Step ${normalizedStatus}.`);
947
+ }
948
+ return {
949
+ nodeId,
950
+ label,
951
+ status: normalizedStatus,
952
+ summary: `${label}: ${detail}`,
953
+ };
954
+ }
955
+
956
+ _appendReplayTrajectoryStep(ctx, node, { status = null, result = undefined, error = null, attempt = undefined } = {}) {
957
+ if (!ctx || !node?.id) return;
958
+ const timing = ctx.getNodeTiming(node.id) || {};
959
+ const outputSummary = result !== undefined ? this._summarizeTaskTraceNodeResult(result) : null;
960
+ const replay =
961
+ ctx.data?._replayTrajectory && typeof ctx.data._replayTrajectory === "object"
962
+ ? ctx.data._replayTrajectory
963
+ : { runId: ctx.id, restoredFrom: ctx.data?._restoredFrom || null, steps: [] };
964
+ const step = {
965
+ nodeId: node.id,
966
+ type: node.type || null,
967
+ label: node.label || null,
968
+ status: status || null,
969
+ attempt: Number.isFinite(Number(attempt)) ? Number(attempt) : ctx.getRetryCount(node.id),
970
+ startedAt: Number.isFinite(Number(timing.startedAt)) ? Number(timing.startedAt) : null,
971
+ endedAt: Number.isFinite(Number(timing.endedAt)) ? Number(timing.endedAt) : null,
972
+ input: ctx.getNodeInput(node.id) || null,
973
+ outputSummary,
974
+ error: error ? String(error) : null,
975
+ summary: this._buildStepSummary(node, { status, result, error }).summary,
976
+ };
977
+ replay.runId = ctx.id;
978
+ replay.restoredFrom = ctx.data?._restoredFrom || replay.restoredFrom || null;
979
+ replay.steps = Array.isArray(replay.steps) ? replay.steps.filter((entry) => entry?.nodeId !== node.id) : [];
980
+ replay.steps.push(step);
981
+ ctx.data._replayTrajectory = replay;
982
+ }
983
+
916
984
  _recordDagNodeOutcome(ctx, node, {
917
985
  status,
918
986
  result = undefined,
@@ -942,6 +1010,7 @@ export class WorkflowEngine extends EventEmitter {
942
1010
  : undefined,
943
1011
  });
944
1012
  ctx.annotateDagNode(node.id, nodePatch);
1013
+ this._appendReplayTrajectoryStep(ctx, node, { status, result, error, attempt });
945
1014
  const workflowStatus = error
946
1015
  ? WorkflowStatus.FAILED
947
1016
  : (ctx.data?._workflowTerminalStatus || WorkflowStatus.RUNNING);
@@ -1386,6 +1455,7 @@ export class WorkflowEngine extends EventEmitter {
1386
1455
 
1387
1456
  // ── Execution ─────────────────────────────────────────────────────────
1388
1457
 
1458
+
1389
1459
  /**
1390
1460
  * Execute a workflow with given input data.
1391
1461
  * @param {string} workflowId
@@ -1520,6 +1590,16 @@ export class WorkflowEngine extends EventEmitter {
1520
1590
  this._persistActiveRunState(runId, workflowId, def.name, ctx);
1521
1591
 
1522
1592
  this.emit("run:start", { runId, workflowId, name: def.name });
1593
+ this._emitWorkflowStatus({
1594
+ runId,
1595
+ workflowId,
1596
+ workflowName: def.name,
1597
+ eventType: "run:start",
1598
+ status: WorkflowStatus.RUNNING,
1599
+ meta: {
1600
+ triggerSource: ctx.data?._triggerSource || null,
1601
+ },
1602
+ });
1523
1603
  this._recordLedgerEvent({
1524
1604
  eventType: "run.start",
1525
1605
  runId,
@@ -1560,7 +1640,30 @@ export class WorkflowEngine extends EventEmitter {
1560
1640
  const status = this._resolveWorkflowStatus(ctx);
1561
1641
  this._activeRuns.get(runId).status = status;
1562
1642
  this._refreshDagState(ctx, status);
1643
+ const terminalError = Array.isArray(ctx.errors) && ctx.errors.length
1644
+ ? String(ctx.errors[ctx.errors.length - 1]?.error || "").trim()
1645
+ : "";
1646
+ if (status === WorkflowStatus.FAILED && terminalError) {
1647
+ this.emit("run:error", { runId, workflowId, error: terminalError });
1648
+ this._emitWorkflowStatus({
1649
+ runId,
1650
+ workflowId,
1651
+ workflowName: def.name,
1652
+ eventType: "run:error",
1653
+ status: WorkflowStatus.FAILED,
1654
+ error: terminalError,
1655
+ durationMs: Date.now() - ctx.startedAt,
1656
+ });
1657
+ }
1563
1658
  this.emit("run:end", { runId, workflowId, status, duration: Date.now() - ctx.startedAt });
1659
+ this._emitWorkflowStatus({
1660
+ runId,
1661
+ workflowId,
1662
+ workflowName: def.name,
1663
+ eventType: "run:end",
1664
+ status,
1665
+ durationMs: Date.now() - ctx.startedAt,
1666
+ });
1564
1667
  this._recordLedgerEvent({
1565
1668
  eventType: "run.end",
1566
1669
  runId,
@@ -1590,6 +1693,15 @@ export class WorkflowEngine extends EventEmitter {
1590
1693
  this._activeRuns.get(runId).status = WorkflowStatus.FAILED;
1591
1694
  this._refreshDagState(ctx, WorkflowStatus.FAILED);
1592
1695
  this.emit("run:error", { runId, workflowId, error: err.message });
1696
+ this._emitWorkflowStatus({
1697
+ runId,
1698
+ workflowId,
1699
+ workflowName: def.name,
1700
+ eventType: "run:error",
1701
+ status: WorkflowStatus.FAILED,
1702
+ error: err.message,
1703
+ durationMs: Date.now() - ctx.startedAt,
1704
+ });
1593
1705
  this._recordLedgerEvent({
1594
1706
  eventType: "run.error",
1595
1707
  runId,
@@ -1754,6 +1866,14 @@ export class WorkflowEngine extends EventEmitter {
1754
1866
  });
1755
1867
  this._persistActiveRunState(retryRunId, workflowId, def.name, ctx);
1756
1868
  this.emit("run:start", { runId: retryRunId, workflowId, name: def.name, retryOf: runId, mode });
1869
+ this._emitWorkflowStatus({
1870
+ runId: retryRunId,
1871
+ workflowId,
1872
+ workflowName: def.name,
1873
+ eventType: "run:start",
1874
+ status: WorkflowStatus.RUNNING,
1875
+ meta: { retryOf: runId, mode },
1876
+ });
1757
1877
  this._recordLedgerEvent({
1758
1878
  eventType: "run.start",
1759
1879
  runId: retryRunId,
@@ -1799,6 +1919,15 @@ export class WorkflowEngine extends EventEmitter {
1799
1919
  retryOf: runId,
1800
1920
  mode,
1801
1921
  });
1922
+ this._emitWorkflowStatus({
1923
+ runId: retryRunId,
1924
+ workflowId,
1925
+ workflowName: def.name,
1926
+ eventType: "run:end",
1927
+ status,
1928
+ durationMs: Date.now() - ctx.startedAt,
1929
+ meta: { retryOf: runId, mode },
1930
+ });
1802
1931
  this._recordLedgerEvent({
1803
1932
  eventType: "run.end",
1804
1933
  runId: retryRunId,
@@ -1829,6 +1958,16 @@ export class WorkflowEngine extends EventEmitter {
1829
1958
  this._activeRuns.get(retryRunId).status = WorkflowStatus.FAILED;
1830
1959
  this._refreshDagState(ctx, WorkflowStatus.FAILED);
1831
1960
  this.emit("run:error", { runId: retryRunId, workflowId, error: err.message, retryOf: runId });
1961
+ this._emitWorkflowStatus({
1962
+ runId: retryRunId,
1963
+ workflowId,
1964
+ workflowName: def.name,
1965
+ eventType: "run:error",
1966
+ status: WorkflowStatus.FAILED,
1967
+ error: err.message,
1968
+ durationMs: Date.now() - ctx.startedAt,
1969
+ meta: { retryOf: runId, mode },
1970
+ });
1832
1971
  this._recordLedgerEvent({
1833
1972
  eventType: "run.error",
1834
1973
  runId: retryRunId,
@@ -2636,9 +2775,16 @@ export class WorkflowEngine extends EventEmitter {
2636
2775
  const detail = run.detail || {};
2637
2776
  const workflowId = run.workflowId || detail.data?._workflowId;
2638
2777
  const snapshotsDir = resolve(this.runsDir, "snapshots");
2778
+ const trajectoriesDir = resolve(this.runsDir, WORKFLOW_TRAJECTORIES_DIR);
2639
2779
  mkdirSync(snapshotsDir, { recursive: true });
2780
+ mkdirSync(trajectoriesDir, { recursive: true });
2640
2781
  const snapshotId = runId;
2641
2782
  const snapshotPath = resolve(snapshotsDir, `${snapshotId}.json`);
2783
+ const trajectoryPath = resolve(trajectoriesDir, `${snapshotId}.json`);
2784
+ const replayTrajectory =
2785
+ detail.replayTrajectory && typeof detail.replayTrajectory === "object"
2786
+ ? detail.replayTrajectory
2787
+ : { runId, restoredFrom: detail?.data?._restoredFrom || null, steps: [] };
2642
2788
  const snapshot = {
2643
2789
  snapshotId,
2644
2790
  runId,
@@ -2651,9 +2797,12 @@ export class WorkflowEngine extends EventEmitter {
2651
2797
  retryAttempts: detail.retryAttempts || {},
2652
2798
  variables: detail.data || {},
2653
2799
  errors: detail.errors || [],
2800
+ replayTrajectory,
2801
+ stepSummaries: detail.stepSummaries || [],
2654
2802
  };
2655
2803
  writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2), "utf8");
2656
- return { snapshotId, path: snapshotPath };
2804
+ writeFileSync(trajectoryPath, JSON.stringify(replayTrajectory, null, 2), "utf8");
2805
+ return { snapshotId, path: snapshotPath, trajectoryPath };
2657
2806
  }
2658
2807
 
2659
2808
  /**
@@ -2693,6 +2842,13 @@ export class WorkflowEngine extends EventEmitter {
2693
2842
  ctx.data._workflowId = workflowId;
2694
2843
  ctx.data._workflowName = def.name;
2695
2844
  ctx.data._restoredFrom = snapshotId;
2845
+ ctx.data._replayTrajectory = {
2846
+ runId: ctx.id,
2847
+ restoredFrom: snapshotId,
2848
+ steps: Array.isArray(snapshot.replayTrajectory?.steps)
2849
+ ? snapshot.replayTrajectory.steps.map((step) => ({ ...step }))
2850
+ : [],
2851
+ };
2696
2852
  ctx.variables = { ...def.variables, ...(opts.variables || {}) };
2697
2853
 
2698
2854
  // Pre-seed completed nodes from snapshot
@@ -2716,6 +2872,14 @@ export class WorkflowEngine extends EventEmitter {
2716
2872
  status: WorkflowStatus.RUNNING,
2717
2873
  });
2718
2874
  this.emit("run:start", { runId: retryRunId, workflowId, name: def.name, restoredFrom: snapshotId });
2875
+ this._emitWorkflowStatus({
2876
+ runId: retryRunId,
2877
+ workflowId,
2878
+ workflowName: def.name,
2879
+ eventType: "run:start",
2880
+ status: WorkflowStatus.RUNNING,
2881
+ meta: { restoredFrom: snapshotId },
2882
+ });
2719
2883
 
2720
2884
  try {
2721
2885
  const adjacency = this._buildAdjacency(def);
@@ -2746,11 +2910,13 @@ export class WorkflowEngine extends EventEmitter {
2746
2910
  try {
2747
2911
  const data = JSON.parse(readFileSync(resolve(snapshotsDir, file), "utf8"));
2748
2912
  if (workflowId && data.workflowId !== workflowId) continue;
2913
+ const trajectoryFile = resolve(snapshotsDir, "..", WORKFLOW_TRAJECTORIES_DIR, `${data.snapshotId}.json`);
2749
2914
  snapshots.push({
2750
2915
  snapshotId: data.snapshotId,
2751
2916
  runId: data.runId,
2752
2917
  workflowId: data.workflowId,
2753
2918
  createdAt: data.createdAt,
2919
+ hasTrajectory: existsSync(trajectoryFile),
2754
2920
  });
2755
2921
  } catch {
2756
2922
  // skip corrupt snapshot files
@@ -3053,6 +3219,17 @@ export class WorkflowEngine extends EventEmitter {
3053
3219
  status: NodeStatus.COMPLETED,
3054
3220
  output: result,
3055
3221
  });
3222
+ this._emitWorkflowStatus({
3223
+ runId: ctx.id,
3224
+ workflowId,
3225
+ workflowName,
3226
+ eventType: "node:complete",
3227
+ status: NodeStatus.COMPLETED,
3228
+ nodeId,
3229
+ nodeType: node?.type || null,
3230
+ nodeLabel: node?.label || null,
3231
+ meta: { attempt: ctx.getRetryCount(nodeId) },
3232
+ });
3056
3233
  this._recordLedgerEvent({
3057
3234
  eventType: "node.completed",
3058
3235
  runId: ctx.id,
@@ -4368,3 +4545,4 @@ export function listWorkflows(opts) { return getWorkflowEngine(opts).list(); }
4368
4545
  export function getWorkflow(id, opts) { return getWorkflowEngine(opts).get(id); }
4369
4546
  export async function executeWorkflow(id, data, opts) { return getWorkflowEngine(opts).execute(id, data, opts); }
4370
4547
  export async function retryWorkflowRun(runId, retryOpts, engineOpts) { return getWorkflowEngine(engineOpts).retryRun(runId, retryOpts); }
4548
+