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.
- package/.env.example +12 -0
- package/README.md +2 -0
- package/agent/agent-pool.mjs +34 -1
- package/agent/agent-work-report.mjs +89 -3
- package/agent/analyze-agent-work-helpers.mjs +14 -0
- package/agent/analyze-agent-work.mjs +23 -3
- package/agent/primary-agent.mjs +23 -1
- package/bosun-tui.mjs +4 -3
- package/bosun.schema.json +1 -1
- package/config/config.mjs +58 -0
- package/config/workspace-health.mjs +36 -6
- package/git/diff-stats.mjs +550 -124
- package/github/github-app-auth.mjs +9 -5
- package/infra/maintenance.mjs +13 -6
- package/infra/monitor.mjs +398 -10
- package/infra/runtime-accumulator.mjs +9 -1
- package/infra/session-tracker.mjs +163 -1
- package/infra/tui-bridge.mjs +415 -0
- package/infra/worktree-recovery-state.mjs +159 -0
- package/kanban/kanban-adapter.mjs +41 -8
- package/lib/repo-map.mjs +411 -0
- package/package.json +140 -137
- package/server/ui-server.mjs +953 -59
- package/shell/codex-config.mjs +34 -8
- package/task/task-cli.mjs +93 -19
- package/task/task-executor.mjs +397 -8
- package/task/task-store.mjs +194 -1
- package/telegram/telegram-bot.mjs +267 -18
- package/tools/vitest-runner.mjs +108 -0
- package/tui/app.mjs +252 -148
- package/tui/components/status-header.mjs +88 -131
- package/tui/lib/ws-bridge.mjs +125 -35
- package/tui/screens/agents-screen-helpers.mjs +219 -0
- package/tui/screens/agents.mjs +287 -270
- package/tui/screens/status.mjs +51 -189
- package/tui/screens/tasks.mjs +41 -253
- package/ui/app.js +52 -23
- package/ui/components/chat-view.js +263 -84
- package/ui/components/diff-viewer.js +324 -140
- package/ui/components/kanban-board.js +13 -9
- package/ui/components/session-list.js +111 -41
- package/ui/demo-defaults.js +481 -59
- package/ui/demo.html +32 -0
- package/ui/modules/session-api.js +320 -5
- package/ui/modules/stream-timeline.js +356 -0
- package/ui/modules/telegram.js +5 -2
- package/ui/modules/worktree-recovery.js +85 -0
- package/ui/styles.css +44 -0
- package/ui/tabs/chat.js +19 -4
- package/ui/tabs/dashboard.js +22 -0
- package/ui/tabs/infra.js +25 -0
- package/ui/tabs/tasks.js +119 -11
- package/voice/voice-auth-manager.mjs +10 -5
- package/workflow/workflow-engine.mjs +179 -1
- package/workflow/workflow-nodes.mjs +872 -16
- package/workflow/workflow-templates.mjs +4 -0
- package/workflow-templates/github.mjs +2 -1
- package/workflow-templates/planning.mjs +2 -1
- package/workflow-templates/sub-workflows.mjs +10 -0
- package/workflow-templates/task-batch.mjs +9 -8
- package/workflow-templates/task-execution.mjs +30 -12
- package/workflow-templates/task-lifecycle.mjs +59 -4
- 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
|
-
<${
|
|
1526
|
-
|
|
1527
|
-
placeholder="e.g. task-123"
|
|
1589
|
+
<${Autocomplete}
|
|
1590
|
+
freeSolo
|
|
1528
1591
|
size="small"
|
|
1529
1592
|
fullWidth
|
|
1530
|
-
|
|
1531
|
-
|
|
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
|
|
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
|
|
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, "&")
|
|
@@ -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) =>
|
|
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
|
|
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
|
-
|
|
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
|
+
|