@tea-agent/loop-agent 0.31.1 → 0.32.1

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 (85) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +46 -0
  3. package/README.md +1 -1
  4. package/dist/shared/operator/capabilities.js +22 -1
  5. package/dist/shared/operator/command-lifecycle.js +94 -0
  6. package/dist/shared/operator/index.js +1 -0
  7. package/dist/worker/cli.js +10 -24
  8. package/dist/worker/console/doctor.js +11 -4
  9. package/dist/worker/console/observe-health-match.js +18 -15
  10. package/dist/worker/console/operator-actions.js +2 -1
  11. package/dist/worker/console/static/assets/index-Bpa2qrc-.js +29 -0
  12. package/dist/worker/console/static/assets/index-BqfFDdnG.css +1 -0
  13. package/dist/worker/console/static/index.html +2 -2
  14. package/dist/worker/console/static-src/app/console-types.js +140 -0
  15. package/dist/worker/console/static-src/app/useConsoleShell.js +101 -0
  16. package/dist/worker/console/static-src/app/useOperatorActions.js +304 -0
  17. package/dist/worker/console/static-src/app/usePrdImport.js +171 -0
  18. package/dist/worker/console/static-src/app/useRecoveryActions.js +257 -0
  19. package/dist/worker/console/static-src/app/useRecoveryConsole.js +334 -0
  20. package/dist/worker/console/static-src/app/useTaskWizard.js +229 -0
  21. package/dist/worker/console/static-src/chat-view-types.js +2 -0
  22. package/dist/worker/console/static-src/night/night-types.js +24 -0
  23. package/dist/worker/console/static-src/night/useNightBoard.js +125 -0
  24. package/dist/worker/console/static-src/night/useNightWizard.js +171 -0
  25. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +253 -0
  26. package/dist/worker/console/static-src/operator-chat/format.js +71 -0
  27. package/dist/worker/console/static-src/operator-chat/refs.js +47 -0
  28. package/dist/worker/console/static-src/operator-chat/tools-catalog.js +77 -0
  29. package/dist/worker/console/static-src/operator-chat/types.js +1 -0
  30. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +320 -0
  31. package/dist/worker/console/static-src/operator-chat/useChatStream.js +209 -0
  32. package/dist/worker/console/static-src/operator-chat/useChatThread.js +218 -0
  33. package/dist/worker/console/static-src/operator-chat/useComposer.js +125 -0
  34. package/dist/worker/console/static-src/operator-chat/useInterview.js +108 -0
  35. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +123 -0
  36. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +207 -0
  37. package/dist/worker/feature/acceptance-policy.js +227 -0
  38. package/dist/worker/feature/decision-loader.js +1 -1
  39. package/dist/worker/feature/next-action.js +56 -6
  40. package/dist/worker/feature/profile-schema.js +3 -0
  41. package/dist/worker/feature/reducer.js +1 -0
  42. package/dist/worker/feature/review.js +72 -8
  43. package/dist/worker/feature/scaffold.js +14 -0
  44. package/dist/worker/loop-agent/controller-protocol.js +143 -0
  45. package/dist/worker/materialize/harness-task-lifecycle-probe.js +126 -0
  46. package/dist/worker/materialize/harness-task-lineage.js +220 -0
  47. package/dist/worker/materialize/harness-task-materializer.js +350 -80
  48. package/dist/worker/observability/progress-composite.js +1 -0
  49. package/dist/worker/observability/read-model.js +66 -19
  50. package/dist/worker/pool/attempt-identity.js +41 -0
  51. package/dist/worker/pool/attempt-lease.js +184 -0
  52. package/dist/worker/pool/attempt-transition.js +210 -0
  53. package/dist/worker/pool/begin-attempt-with-lease.js +26 -0
  54. package/dist/worker/pool/begin-attempt.js +35 -0
  55. package/dist/worker/pool/failure-routing.js +49 -0
  56. package/dist/worker/pool/recovery-decision.js +163 -0
  57. package/dist/worker/pool/run-owner-store.js +126 -0
  58. package/dist/worker/pool/run-store.js +32 -46
  59. package/dist/worker/pool/runtime-reconcile-inventory.js +127 -0
  60. package/dist/worker/pool/state-projection.js +57 -0
  61. package/dist/worker/run-task/run-task.js +31 -4
  62. package/dist/worker/runner/run-ready.js +64 -14
  63. package/dist/worker/runner/single-task-attempt.js +42 -13
  64. package/dist/worker/task-graph/acceptance-schema.js +3 -0
  65. package/docs/README.md +3 -1
  66. package/docs/architecture/evolution.md +1 -1
  67. package/docs/operations/README.md +1 -0
  68. package/docs/templates/README.md +1 -0
  69. package/docs/templates/agent-worker-production-readiness-checklist.md +47 -0
  70. package/docs/templates/evaluation/agents-map-slim-v1.md +1 -1
  71. package/docs/templates/evaluation/agents-map-verbose-v0.md +1 -1
  72. package/docs/templates/init-managed-agents.md +1 -1
  73. package/docs/templates/product-line/scaffold-samples/backend-only/acceptance.yaml +3 -0
  74. package/docs/templates/product-line/scaffold-samples/backend-only/feature.yaml +11 -0
  75. package/docs/templates/product-line/scaffold-samples/fe-with-api/acceptance.yaml +3 -0
  76. package/docs/templates/product-line/scaffold-samples/fe-with-api/feature.yaml +11 -0
  77. package/docs/templates/product-line/scaffold-samples/frontend-only/acceptance.yaml +3 -0
  78. package/docs/templates/product-line/scaffold-samples/frontend-only/feature.yaml +11 -0
  79. package/package.json +5 -4
  80. package/skills/agent-worker/SKILL.md +1 -1
  81. package/skills/agent-worker/references/agent-worker-operator.md +1 -1
  82. package/skills/loop-agent/references/command-reference.md +2 -2
  83. package/skills/loop-agent/references/harness-policy.md +1 -1
  84. package/dist/worker/console/static/assets/index-D9gnJn_l.js +0 -29
  85. package/dist/worker/console/static/assets/index-rajoXwkM.css +0 -1
@@ -0,0 +1,257 @@
1
+ import { useCallback } from "react";
2
+ import { isAutoRecoveryReason } from "../../recovery-selection.js";
3
+ import { formatDagRerunIneligible, postOperator, requestId, } from "./console-types.js";
4
+ /** Recovery CTA dispatch: diagnose/report/inspect shortcuts, reason-gated
5
+ * confirm flow with dagRerunPlan preflight, and gated reruns/retries. */
6
+ export function useRecoveryActions(params) {
7
+ const { shell, actions, taskId, dagRunId, fromNodeId, featureId, recoveryReason, recoveryReasonTouched, recoveryConfirm, recoveryPlanSummary, setRecoveryConfirm, setRecoveryPlanSummary, setRecoveryReport, setRecoveryReason, setRecoveryReasonTouched, buildCurrentRecoveryReason, openFullCanvas, } = params;
8
+ const { setOpError, setOpErrorCode, setOpMessage, setBusy } = shell;
9
+ const { runAction, isActionSuccess } = actions;
10
+ const handleRecoveryCta = useCallback(async (cta) => {
11
+ // Leaving a confirm flow when picking diagnose / advisory CTAs.
12
+ if (!cta.requiresReason) {
13
+ setRecoveryConfirm(null);
14
+ setRecoveryPlanSummary(null);
15
+ }
16
+ if (cta.action === "doctor") {
17
+ setRecoveryReport(null);
18
+ void runAction("doctor", {});
19
+ return;
20
+ }
21
+ if (cta.action === "dagReport" || cta.id === "report") {
22
+ const runId = dagRunId.trim();
23
+ if (runId) {
24
+ setRecoveryConfirm(null);
25
+ setRecoveryPlanSummary(null);
26
+ const body = await runAction("dagReport", { runId });
27
+ const envelope = (body?.result ?? {});
28
+ const run = (envelope.raw?.runs ?? envelope.runs)?.[0];
29
+ if (body && body.ok !== false && !body.error && run) {
30
+ setRecoveryReport({
31
+ runId: run.runId ?? runId,
32
+ title: run.title,
33
+ status: run.status,
34
+ category: run.productLineFailureCategory,
35
+ recommendation: run.recoveryRecommendation?.summary ??
36
+ run.recommendedFollowUp ??
37
+ undefined,
38
+ });
39
+ setOpMessage(null);
40
+ }
41
+ return;
42
+ }
43
+ if (taskId.trim()) {
44
+ setRecoveryReport(null);
45
+ void runAction("status", { taskId });
46
+ return;
47
+ }
48
+ setOpError("查看报告需要先选择 DAG 运行 ID(或填写任务 ID)");
49
+ return;
50
+ }
51
+ if (cta.action === "observeLink") {
52
+ setRecoveryReport(null);
53
+ void openFullCanvas();
54
+ return;
55
+ }
56
+ if (cta.requiresReason) {
57
+ setRecoveryReport(null);
58
+ setRecoveryConfirm(cta);
59
+ setRecoveryPlanSummary(null);
60
+ // Switching away from an ineligible node-rerun should not keep that error.
61
+ if (cta.id !== "dagRerun") {
62
+ setOpError(null);
63
+ setOpErrorCode(null);
64
+ }
65
+ if (!recoveryReasonTouched || isAutoRecoveryReason(recoveryReason)) {
66
+ setRecoveryReason(buildCurrentRecoveryReason(cta.label));
67
+ setRecoveryReasonTouched(false);
68
+ }
69
+ if (cta.id === "dagRerun" && dagRunId.trim() && fromNodeId.trim()) {
70
+ setBusy(true);
71
+ try {
72
+ const { status, body } = await postOperator("dagRerunPlan", {
73
+ runId: dagRunId.trim(),
74
+ fromNode: fromNodeId.trim(),
75
+ });
76
+ const plan = (body.result ?? {});
77
+ if (plan &&
78
+ typeof plan === "object" &&
79
+ ("eligible" in plan || plan.planHash)) {
80
+ setRecoveryPlanSummary(plan);
81
+ }
82
+ if (status < 400 && body.ok !== false && plan.eligible !== false) {
83
+ setOpMessage(null);
84
+ }
85
+ else if (plan.eligible === false) {
86
+ setOpError(body.error?.message ?? formatDagRerunIneligible(plan));
87
+ }
88
+ else {
89
+ setOpMessage(body.error?.message ??
90
+ "无法预检重跑计划;确认后仍将尝试提交(CLI 可能尚未就绪)");
91
+ }
92
+ }
93
+ finally {
94
+ setBusy(false);
95
+ }
96
+ }
97
+ return;
98
+ }
99
+ setOpError(null);
100
+ setOpErrorCode(null);
101
+ setOpMessage(`${cta.label}:请经 CLI — ${cta.commandHint ?? ""}`);
102
+ }, [
103
+ buildCurrentRecoveryReason,
104
+ dagRunId,
105
+ fromNodeId,
106
+ openFullCanvas,
107
+ recoveryReason,
108
+ recoveryReasonTouched,
109
+ runAction,
110
+ setBusy,
111
+ setOpError,
112
+ setOpErrorCode,
113
+ setOpMessage,
114
+ setRecoveryConfirm,
115
+ setRecoveryPlanSummary,
116
+ setRecoveryReport,
117
+ setRecoveryReason,
118
+ setRecoveryReasonTouched,
119
+ taskId,
120
+ ]);
121
+ const submitRecoveryConfirm = useCallback(async () => {
122
+ const cta = recoveryConfirm;
123
+ if (!cta)
124
+ return;
125
+ const reason = recoveryReason.trim();
126
+ if (!reason) {
127
+ setOpError("恢复操作需要结构化原因");
128
+ return;
129
+ }
130
+ const clientRequestId = requestId(`recovery-${cta.id}`);
131
+ setRecoveryConfirm(null);
132
+ if (cta.id === "dagRerun") {
133
+ const runId = dagRunId.trim();
134
+ const fromNode = fromNodeId.trim();
135
+ if (!runId || !fromNode) {
136
+ setOpError("从节点重跑需要 DAG 运行 ID 与起始节点 ID");
137
+ return;
138
+ }
139
+ let planHash = recoveryPlanSummary?.planHash?.trim();
140
+ let planEligible = recoveryPlanSummary?.eligible;
141
+ if (!planHash || planEligible === undefined) {
142
+ const planBody = await runAction("dagRerunPlan", {
143
+ runId,
144
+ fromNode,
145
+ });
146
+ const plan = (planBody?.result ?? {});
147
+ if (plan.planHash || plan.eligible !== undefined) {
148
+ setRecoveryPlanSummary(plan);
149
+ }
150
+ planHash = plan.planHash?.trim() || planHash;
151
+ planEligible = plan.eligible;
152
+ if (plan.eligible === false) {
153
+ setOpError(planBody?.error?.message ?? formatDagRerunIneligible(plan));
154
+ return;
155
+ }
156
+ if (planBody && planBody.ok === false && !planHash) {
157
+ setOpError(planBody.error?.message ??
158
+ "无法获取 planHash;请确认 loop-agent dag rerun --plan 可用");
159
+ return;
160
+ }
161
+ }
162
+ if (planEligible === false) {
163
+ setOpError(formatDagRerunIneligible(recoveryPlanSummary ?? {}));
164
+ return;
165
+ }
166
+ if (!planHash) {
167
+ setOpError("无法获取 planHash;请确认 loop-agent dag rerun --plan 可用");
168
+ return;
169
+ }
170
+ await runAction("dagRerun", { runId, fromNode, planHash, reason }, { clientRequestId });
171
+ return;
172
+ }
173
+ if (cta.id === "standaloneTaskRerun") {
174
+ const runId = dagRunId.trim();
175
+ if (!runId) {
176
+ setOpError("完整重跑任务需要 DAG 运行 ID");
177
+ return;
178
+ }
179
+ const actionParams = {
180
+ runId,
181
+ reason,
182
+ ...(taskId.trim() ? { taskId: taskId.trim() } : {}),
183
+ };
184
+ const prep = await runAction("prepareMutationGate", {
185
+ action: "standaloneTaskRerun",
186
+ actionParams,
187
+ });
188
+ if (!isActionSuccess(prep))
189
+ return;
190
+ const receipt = (prep?.result ?? prep);
191
+ if (!receipt.receiptId || !receipt.humanGateToken) {
192
+ setOpError("prepareMutationGate 未返回可消费的 Human Gate receipt");
193
+ return;
194
+ }
195
+ await runAction("standaloneTaskRerun", {
196
+ ...actionParams,
197
+ confirmationId: receipt.receiptId,
198
+ humanGateToken: receipt.humanGateToken,
199
+ }, { clientRequestId });
200
+ return;
201
+ }
202
+ if (cta.id === "workerTaskRetry") {
203
+ const id = taskId.trim();
204
+ if (!id) {
205
+ setOpError("重新排队 Worker Task 需要任务 ID");
206
+ return;
207
+ }
208
+ const actionParams = {
209
+ taskId: id,
210
+ reason,
211
+ ...(featureId.trim() ? { featureId: featureId.trim() } : {}),
212
+ };
213
+ const prep = await runAction("prepareMutationGate", {
214
+ action: "workerTaskRetry",
215
+ actionParams,
216
+ });
217
+ if (!isActionSuccess(prep))
218
+ return;
219
+ const receipt = (prep?.result ?? prep);
220
+ if (!receipt.receiptId || !receipt.humanGateToken) {
221
+ setOpError("prepareMutationGate 未返回可消费的 Human Gate receipt");
222
+ return;
223
+ }
224
+ await runAction("workerTaskRetry", {
225
+ ...actionParams,
226
+ confirmationId: receipt.receiptId,
227
+ humanGateToken: receipt.humanGateToken,
228
+ }, { clientRequestId });
229
+ return;
230
+ }
231
+ if (cta.id === "reconcile") {
232
+ const runId = dagRunId.trim();
233
+ if (!runId) {
234
+ setOpError("对账恢复需要 DAG 运行 ID");
235
+ return;
236
+ }
237
+ setOpMessage(`${cta.label}:请经 CLI — ${cta.commandHint ?? ""}`);
238
+ return;
239
+ }
240
+ setOpMessage(`${cta.label}:请经 CLI — ${cta.commandHint ?? ""}`);
241
+ }, [
242
+ dagRunId,
243
+ featureId,
244
+ fromNodeId,
245
+ recoveryConfirm,
246
+ recoveryPlanSummary?.planHash,
247
+ recoveryReason,
248
+ runAction,
249
+ isActionSuccess,
250
+ setOpError,
251
+ setOpMessage,
252
+ setRecoveryConfirm,
253
+ setRecoveryPlanSummary,
254
+ taskId,
255
+ ]);
256
+ return { handleRecoveryCta, submitRecoveryConfirm };
257
+ }
@@ -0,0 +1,334 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { classifyRecoveryFact, consoleRecoveryCtas, partitionRecoveryCtas, recommendedRecoveryCtaId, } from "../../recovery-cta.js";
3
+ import { buildRecoveryReason, isAutoRecoveryReason, pickDefaultRecoveryNodeId, pickDefaultRecoveryRunId, sortRecoveryNodeCandidates, } from "../../recovery-selection.js";
4
+ import { postOperator, RECOVERY_FACT_LABEL, } from "./console-types.js";
5
+ import { useRecoveryActions } from "./useRecoveryActions.js";
6
+ /** Recovery workspace: DAG-run / node candidate selection, fact-classified
7
+ * CTA lanes, structured reason prefill, plan preflight and gated reruns. */
8
+ export function useRecoveryConsole(params) {
9
+ const { shell, actions, wizard } = params;
10
+ const { nav, opError, opErrorCode, lastOpState, setOpError, setObserveBusy } = shell;
11
+ const { taskId } = wizard;
12
+ const [dagRunId, setDagRunId] = useState("");
13
+ const [fromNodeId, setFromNodeId] = useState("");
14
+ const [featureId, setFeatureId] = useState("");
15
+ const [recoveryReason, setRecoveryReason] = useState("");
16
+ const [recoveryReasonTouched, setRecoveryReasonTouched] = useState(false);
17
+ const [recoveryPlanSummary, setRecoveryPlanSummary] = useState(null);
18
+ const [recoveryConfirm, setRecoveryConfirm] = useState(null);
19
+ const [recoveryRuns, setRecoveryRuns] = useState([]);
20
+ const [recoveryNodes, setRecoveryNodes] = useState([]);
21
+ const [recoveryRunsLoading, setRecoveryRunsLoading] = useState(false);
22
+ const [recoveryNodesLoading, setRecoveryNodesLoading] = useState(false);
23
+ const [recoveryRunStatus, setRecoveryRunStatus] = useState(null);
24
+ const [manualRunId, setManualRunId] = useState(false);
25
+ const [manualNodeId, setManualNodeId] = useState(false);
26
+ const [recoveryReport, setRecoveryReport] = useState(null);
27
+ const [observeLinkStatus, setObserveLinkStatus] = useState("idle");
28
+ const [observeLinkMessage, setObserveLinkMessage] = useState(null);
29
+ const [observeUrl, setObserveUrl] = useState(null);
30
+ const [inspectSplitOpen, setInspectSplitOpen] = useState(false);
31
+ const inspectPath = useCallback(() => {
32
+ if (dagRunId.trim()) {
33
+ return `/inspect/#/dag/${encodeURIComponent(dagRunId.trim())}`;
34
+ }
35
+ return "/inspect/#/";
36
+ }, [dagRunId]);
37
+ const openFullInspect = useCallback(() => {
38
+ window.location.assign(inspectPath());
39
+ }, [inspectPath]);
40
+ /** Resolve DAG run id for inspect (side panel or full page). */
41
+ const resolveDagRunForInspect = useCallback(async () => {
42
+ if (dagRunId.trim())
43
+ return dagRunId.trim();
44
+ const id = taskId.trim();
45
+ if (!id) {
46
+ setOpError("打开 DAG 检视需要任务 ID 或 DAG 运行 ID");
47
+ return null;
48
+ }
49
+ setObserveBusy(true);
50
+ setOpError(null);
51
+ try {
52
+ const { status, body } = await postOperator("observeLink", {
53
+ taskId: id,
54
+ });
55
+ const result = (body.result ?? {});
56
+ if (status >= 400 || body.ok === false) {
57
+ setOpError(body.error?.message ?? "无法解析 DAG 画布地址");
58
+ setObserveLinkStatus("unavailable");
59
+ setObserveLinkMessage(body.error?.message ?? null);
60
+ return null;
61
+ }
62
+ const resolvedDag = typeof result.dagRunId === "string" ? result.dagRunId.trim() : "";
63
+ if (resolvedDag) {
64
+ setDagRunId(resolvedDag);
65
+ setObserveLinkStatus("match");
66
+ setObserveLinkMessage(null);
67
+ setObserveUrl(`/inspect/#/dag/${encodeURIComponent(resolvedDag)}`);
68
+ return resolvedDag;
69
+ }
70
+ setOpError("尚未找到该任务的 DAG 运行,请稍后再打开检视");
71
+ setObserveLinkStatus("unavailable");
72
+ setObserveLinkMessage(result.message ?? "未解析到 DAG run");
73
+ return null;
74
+ }
75
+ catch (error) {
76
+ setOpError(error instanceof Error ? error.message : String(error));
77
+ return null;
78
+ }
79
+ finally {
80
+ setObserveBusy(false);
81
+ }
82
+ }, [dagRunId, taskId, setOpError, setObserveBusy]);
83
+ /** Prefer in-page side panel so Operate state is preserved. */
84
+ const openInspectSplit = useCallback(async () => {
85
+ const resolved = await resolveDagRunForInspect();
86
+ if (resolved)
87
+ setInspectSplitOpen(true);
88
+ }, [resolveDagRunForInspect]);
89
+ /** Full DAG canvas — use only when user explicitly leaves Operate. */
90
+ const openFullCanvas = useCallback(async () => {
91
+ const resolved = await resolveDagRunForInspect();
92
+ if (!resolved)
93
+ return;
94
+ window.location.assign(`/inspect/#/dag/${encodeURIComponent(resolved)}`);
95
+ }, [resolveDagRunForInspect]);
96
+ const loadRecoveryRuns = useCallback(async () => {
97
+ setRecoveryRunsLoading(true);
98
+ try {
99
+ const id = taskId.trim();
100
+ const qs = new URLSearchParams();
101
+ if (id)
102
+ qs.set("taskId", id);
103
+ qs.set("limit", "40");
104
+ const res = await fetch(`/api/operator/v1/recovery/candidates?${qs.toString()}`, { credentials: "same-origin" });
105
+ if (!res.ok) {
106
+ setRecoveryRuns([]);
107
+ return;
108
+ }
109
+ const body = (await res.json());
110
+ setRecoveryRuns(Array.isArray(body.runs) ? body.runs : []);
111
+ }
112
+ catch {
113
+ setRecoveryRuns([]);
114
+ }
115
+ finally {
116
+ setRecoveryRunsLoading(false);
117
+ }
118
+ }, [taskId]);
119
+ const loadRecoveryNodes = useCallback(async (runId) => {
120
+ const id = runId.trim();
121
+ if (!id) {
122
+ setRecoveryNodes([]);
123
+ setRecoveryRunStatus(null);
124
+ return;
125
+ }
126
+ setRecoveryNodesLoading(true);
127
+ try {
128
+ const res = await fetch(`/api/dag-runs/${encodeURIComponent(id)}`, {
129
+ credentials: "same-origin",
130
+ });
131
+ if (!res.ok) {
132
+ setRecoveryNodes([]);
133
+ setRecoveryRunStatus(null);
134
+ return;
135
+ }
136
+ const body = (await res.json());
137
+ setRecoveryRunStatus(typeof body.status === "string" ? body.status : null);
138
+ const rawNodes = Array.isArray(body.nodes) ? body.nodes : [];
139
+ const nodes = sortRecoveryNodeCandidates(rawNodes
140
+ .map((n) => {
141
+ const nodeId = (n.nodeId ?? n.id ?? "").trim();
142
+ if (!nodeId)
143
+ return null;
144
+ return {
145
+ nodeId,
146
+ status: n.status,
147
+ label: n.label ?? n.name,
148
+ errorPreview: n.errorPreview,
149
+ };
150
+ })
151
+ .filter((n) => n !== null));
152
+ setRecoveryNodes(nodes);
153
+ }
154
+ catch {
155
+ setRecoveryNodes([]);
156
+ setRecoveryRunStatus(null);
157
+ }
158
+ finally {
159
+ setRecoveryNodesLoading(false);
160
+ }
161
+ }, []);
162
+ useEffect(() => {
163
+ if (nav !== "recovery")
164
+ return;
165
+ void loadRecoveryRuns();
166
+ }, [nav, taskId, loadRecoveryRuns]);
167
+ useEffect(() => {
168
+ if (nav !== "recovery")
169
+ return;
170
+ if (manualRunId)
171
+ return;
172
+ if (recoveryRuns.length === 0)
173
+ return;
174
+ const current = dagRunId.trim();
175
+ const inList = current
176
+ ? recoveryRuns.some((r) => r.dagRunId === current)
177
+ : false;
178
+ if (!current || !inList) {
179
+ const preferred = pickDefaultRecoveryRunId(recoveryRuns);
180
+ if (preferred && preferred !== current)
181
+ setDagRunId(preferred);
182
+ }
183
+ }, [nav, recoveryRuns, dagRunId, manualRunId]);
184
+ useEffect(() => {
185
+ if (nav !== "recovery")
186
+ return;
187
+ void loadRecoveryNodes(dagRunId);
188
+ }, [nav, dagRunId, loadRecoveryNodes]);
189
+ useEffect(() => {
190
+ if (nav !== "recovery")
191
+ return;
192
+ if (manualNodeId)
193
+ return;
194
+ if (recoveryNodes.length === 0)
195
+ return;
196
+ const current = fromNodeId.trim();
197
+ const inList = current
198
+ ? recoveryNodes.some((n) => n.nodeId === current)
199
+ : false;
200
+ if (!current || !inList) {
201
+ const preferred = pickDefaultRecoveryNodeId(recoveryNodes);
202
+ if (preferred && preferred !== current)
203
+ setFromNodeId(preferred);
204
+ }
205
+ }, [nav, recoveryNodes, fromNodeId, manualNodeId]);
206
+ const recoveryFact = classifyRecoveryFact({
207
+ // Selected DAG run status owns the recovery matrix; do not let read actions
208
+ // (dagReport / doctor) flip CTAs via transient lastOpState=succeeded.
209
+ operationState: recoveryRunStatus ? null : lastOpState,
210
+ errorCode: recoveryRunStatus ? null : opErrorCode,
211
+ dagStatus: recoveryRunStatus,
212
+ });
213
+ const recoveryCtas = useMemo(() => consoleRecoveryCtas(recoveryFact), [recoveryFact]);
214
+ const recoveryLanes = useMemo(() => partitionRecoveryCtas(recoveryCtas), [recoveryCtas]);
215
+ const recommendedCtaId = useMemo(() => recommendedRecoveryCtaId(recoveryFact), [recoveryFact]);
216
+ const selectedRecoveryNode = useMemo(() => recoveryNodes.find((n) => n.nodeId === fromNodeId.trim()), [recoveryNodes, fromNodeId]);
217
+ const recoveryConfirmRef = useRef(null);
218
+ useEffect(() => {
219
+ if (!recoveryConfirm)
220
+ return;
221
+ recoveryConfirmRef.current?.scrollIntoView({
222
+ behavior: "smooth",
223
+ block: "nearest",
224
+ });
225
+ }, [recoveryConfirm?.id]);
226
+ const buildCurrentRecoveryReason = useCallback((actionLabel) => buildRecoveryReason({
227
+ actionLabel,
228
+ dagRunId: dagRunId.trim() || undefined,
229
+ fromNodeId: fromNodeId.trim() || undefined,
230
+ runStatus: recoveryRunStatus,
231
+ nodeStatus: selectedRecoveryNode?.status,
232
+ nodeError: selectedRecoveryNode?.errorPreview,
233
+ factClassLabel: RECOVERY_FACT_LABEL[recoveryFact],
234
+ errorMessage: opError,
235
+ errorCode: opErrorCode,
236
+ }), [
237
+ dagRunId,
238
+ fromNodeId,
239
+ opError,
240
+ opErrorCode,
241
+ recoveryFact,
242
+ recoveryRunStatus,
243
+ selectedRecoveryNode?.errorPreview,
244
+ selectedRecoveryNode?.status,
245
+ ]);
246
+ useEffect(() => {
247
+ if (nav !== "recovery")
248
+ return;
249
+ if (recoveryReasonTouched && !isAutoRecoveryReason(recoveryReason))
250
+ return;
251
+ const next = buildCurrentRecoveryReason(recoveryConfirm?.label);
252
+ if (next && next !== recoveryReason)
253
+ setRecoveryReason(next);
254
+ }, [
255
+ nav,
256
+ dagRunId,
257
+ fromNodeId,
258
+ recoveryRunStatus,
259
+ selectedRecoveryNode,
260
+ recoveryFact,
261
+ opError,
262
+ opErrorCode,
263
+ recoveryConfirm?.label,
264
+ recoveryReasonTouched,
265
+ recoveryReason,
266
+ buildCurrentRecoveryReason,
267
+ ]);
268
+ const { handleRecoveryCta, submitRecoveryConfirm } = useRecoveryActions({
269
+ shell,
270
+ actions,
271
+ taskId,
272
+ dagRunId,
273
+ fromNodeId,
274
+ featureId,
275
+ recoveryReason,
276
+ recoveryReasonTouched,
277
+ recoveryConfirm,
278
+ recoveryPlanSummary,
279
+ setRecoveryConfirm,
280
+ setRecoveryPlanSummary,
281
+ setRecoveryReport,
282
+ setRecoveryReason,
283
+ setRecoveryReasonTouched,
284
+ buildCurrentRecoveryReason,
285
+ openFullCanvas,
286
+ });
287
+ return {
288
+ dagRunId,
289
+ setDagRunId,
290
+ fromNodeId,
291
+ setFromNodeId,
292
+ featureId,
293
+ setFeatureId,
294
+ recoveryReason,
295
+ setRecoveryReason,
296
+ recoveryReasonTouched,
297
+ setRecoveryReasonTouched,
298
+ recoveryPlanSummary,
299
+ recoveryConfirm,
300
+ setRecoveryConfirm,
301
+ setRecoveryPlanSummary,
302
+ recoveryRuns,
303
+ recoveryNodes,
304
+ recoveryRunsLoading,
305
+ recoveryNodesLoading,
306
+ recoveryRunStatus,
307
+ manualRunId,
308
+ setManualRunId,
309
+ manualNodeId,
310
+ setManualNodeId,
311
+ recoveryReport,
312
+ setRecoveryReport,
313
+ observeLinkStatus,
314
+ observeLinkMessage,
315
+ observeUrl,
316
+ inspectSplitOpen,
317
+ setInspectSplitOpen,
318
+ inspectPath,
319
+ openFullInspect,
320
+ resolveDagRunForInspect,
321
+ openInspectSplit,
322
+ openFullCanvas,
323
+ loadRecoveryRuns,
324
+ loadRecoveryNodes,
325
+ recoveryFact,
326
+ recoveryCtas,
327
+ recoveryLanes,
328
+ recommendedCtaId,
329
+ recoveryConfirmRef,
330
+ buildCurrentRecoveryReason,
331
+ handleRecoveryCta,
332
+ submitRecoveryConfirm,
333
+ };
334
+ }