@smithers-orchestrator/cli 0.16.0

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 (110) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +55 -0
  3. package/src/AgentAvailability.ts +13 -0
  4. package/src/AgentAvailabilityStatus.ts +5 -0
  5. package/src/AggregateNodeDetailParams.ts +5 -0
  6. package/src/AskOptions.ts +12 -0
  7. package/src/ChatAttemptMeta.ts +7 -0
  8. package/src/ChatAttemptRow.ts +12 -0
  9. package/src/ChatOutputEvent.ts +6 -0
  10. package/src/DiffBundleLike.ts +6 -0
  11. package/src/DiscoveredWorkflow.ts +9 -0
  12. package/src/EnrichedNodeDetail.ts +60 -0
  13. package/src/EventCategory.ts +18 -0
  14. package/src/FindDbWaitOptions.ts +4 -0
  15. package/src/FormatEventLineOptions.ts +4 -0
  16. package/src/HijackCandidate.ts +11 -0
  17. package/src/HijackLaunchSpec.ts +6 -0
  18. package/src/InitWorkflowPackOptions.ts +4 -0
  19. package/src/InitWorkflowPackResult.ts +6 -0
  20. package/src/NativeHijackEngine.ts +8 -0
  21. package/src/NodeDetailAttempt.ts +22 -0
  22. package/src/NodeDetailTokenUsage.ts +11 -0
  23. package/src/NodeDetailToolCall.ts +12 -0
  24. package/src/ParsedNodeOutputEvent.ts +9 -0
  25. package/src/RenderNodeDetailOptions.ts +4 -0
  26. package/src/RunAutoResumeSkipReason.ts +4 -0
  27. package/src/RunDiffCommandInput.ts +13 -0
  28. package/src/RunDiffCommandResult.ts +3 -0
  29. package/src/RunOutputCommandInput.ts +12 -0
  30. package/src/RunOutputCommandResult.ts +3 -0
  31. package/src/RunRewindCommandInput.ts +14 -0
  32. package/src/RunRewindCommandResult.ts +3 -0
  33. package/src/RunTreeCommandInput.ts +14 -0
  34. package/src/RunTreeCommandResult.ts +3 -0
  35. package/src/SmithersEventType.ts +3 -0
  36. package/src/SupervisorOptions.ts +33 -0
  37. package/src/SupervisorPollSummary.ts +6 -0
  38. package/src/TreeRenderOptions.ts +5 -0
  39. package/src/WatchLoopOptions.ts +9 -0
  40. package/src/WatchLoopResult.ts +8 -0
  41. package/src/WatchRenderContext.ts +4 -0
  42. package/src/WhyBlocker.ts +17 -0
  43. package/src/WhyBlockerKind.ts +9 -0
  44. package/src/WhyDiagnosis.ts +10 -0
  45. package/src/WorkflowCta.ts +4 -0
  46. package/src/WorkflowSourceType.ts +1 -0
  47. package/src/agent-detection.js +257 -0
  48. package/src/ask.js +491 -0
  49. package/src/chat.js +226 -0
  50. package/src/diff.js +221 -0
  51. package/src/event-categories.js +141 -0
  52. package/src/find-db.js +93 -0
  53. package/src/format.js +272 -0
  54. package/src/hijack-session.js +207 -0
  55. package/src/hijack.js +226 -0
  56. package/src/index.d.ts +1 -0
  57. package/src/index.js +4868 -0
  58. package/src/mcp/SemanticMcpServerOptions.ts +4 -0
  59. package/src/mcp/SemanticToolCallResult.ts +14 -0
  60. package/src/mcp/SemanticToolContext.ts +6 -0
  61. package/src/mcp/SemanticToolDefinition.ts +13 -0
  62. package/src/mcp/SemanticToolError.ts +6 -0
  63. package/src/mcp/semantic-server.js +41 -0
  64. package/src/mcp/semantic-tools.js +1242 -0
  65. package/src/node-detail.js +682 -0
  66. package/src/output.js +111 -0
  67. package/src/resume-detached.js +37 -0
  68. package/src/rewind.js +88 -0
  69. package/src/scheduler.js +112 -0
  70. package/src/smithersRuntime.js +63 -0
  71. package/src/supervisor.js +418 -0
  72. package/src/tree.js +307 -0
  73. package/src/tui/app.jsx +139 -0
  74. package/src/tui/app.tsx +5 -0
  75. package/src/tui/components/AskModal.jsx +109 -0
  76. package/src/tui/components/AskModal.tsx +3 -0
  77. package/src/tui/components/AttentionPane.jsx +112 -0
  78. package/src/tui/components/AttentionPane.tsx +6 -0
  79. package/src/tui/components/ChatPane.jsx +57 -0
  80. package/src/tui/components/ChatPane.tsx +7 -0
  81. package/src/tui/components/CronList.jsx +87 -0
  82. package/src/tui/components/CronList.tsx +5 -0
  83. package/src/tui/components/DetailsPane.jsx +96 -0
  84. package/src/tui/components/DetailsPane.tsx +7 -0
  85. package/src/tui/components/FramesPane.jsx +147 -0
  86. package/src/tui/components/FramesPane.tsx +8 -0
  87. package/src/tui/components/LogsPane.jsx +46 -0
  88. package/src/tui/components/LogsPane.tsx +6 -0
  89. package/src/tui/components/MetricsPane.jsx +108 -0
  90. package/src/tui/components/MetricsPane.tsx +5 -0
  91. package/src/tui/components/NodeDetailView.jsx +284 -0
  92. package/src/tui/components/NodeDetailView.tsx +7 -0
  93. package/src/tui/components/NodeInspector.jsx +51 -0
  94. package/src/tui/components/NodeInspector.tsx +7 -0
  95. package/src/tui/components/RunDetailView.jsx +190 -0
  96. package/src/tui/components/RunDetailView.tsx +7 -0
  97. package/src/tui/components/RunsList.jsx +184 -0
  98. package/src/tui/components/RunsList.tsx +7 -0
  99. package/src/tui/components/SqliteBrowser.jsx +131 -0
  100. package/src/tui/components/SqliteBrowser.tsx +5 -0
  101. package/src/tui/components/WorkflowLauncher.jsx +63 -0
  102. package/src/tui/components/WorkflowLauncher.tsx +3 -0
  103. package/src/util/CliErrorMapping.ts +7 -0
  104. package/src/util/CliExitCode.ts +10 -0
  105. package/src/util/errorMessage.js +212 -0
  106. package/src/util/exitCodes.js +18 -0
  107. package/src/watch.js +128 -0
  108. package/src/why-diagnosis.js +1000 -0
  109. package/src/workflow-pack.js +2151 -0
  110. package/src/workflows.js +122 -0
@@ -0,0 +1,1000 @@
1
+ // @smithers-type-exports-begin
2
+ /** @typedef {import("./WhyBlockerKind.ts").WhyBlockerKind} WhyBlockerKind */
3
+ // @smithers-type-exports-end
4
+
5
+ import { Effect } from "effect";
6
+ import { isRunHeartbeatFresh } from "@smithers-orchestrator/engine";
7
+ import { computeRetryDelayMs } from "@smithers-orchestrator/scheduler/computeRetryDelayMs";
8
+ import { SmithersError } from "@smithers-orchestrator/errors";
9
+ import { formatAge } from "./format.js";
10
+ /** @typedef {import("@smithers-orchestrator/db/adapter").SmithersDb} SmithersDb */
11
+ /** @typedef {import("./WhyBlocker.ts").WhyBlocker} WhyBlocker */
12
+ /** @typedef {import("./WhyDiagnosis.ts").WhyDiagnosis} WhyDiagnosis */
13
+
14
+ const RECENT_EVENTS_LIMIT = 50;
15
+ const MAX_CTA_COMMANDS = 5;
16
+ /**
17
+ * @param {string} nodeId
18
+ * @param {number} iteration
19
+ */
20
+ function nodeKey(nodeId, iteration) {
21
+ return `${nodeId}::${iteration}`;
22
+ }
23
+ /**
24
+ * @param {string} nodeId
25
+ */
26
+ function logicalNodeId(nodeId) {
27
+ const marker = nodeId.indexOf("@@");
28
+ return marker >= 0 ? nodeId.slice(0, marker) : nodeId;
29
+ }
30
+ /**
31
+ * @param {unknown} value
32
+ * @returns {value is Record<string, unknown>}
33
+ */
34
+ function isRecord(value) {
35
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
36
+ }
37
+ /**
38
+ * @param {string | null | undefined} raw
39
+ * @returns {Record<string, unknown>}
40
+ */
41
+ function parseObjectJson(raw) {
42
+ if (!raw)
43
+ return {};
44
+ try {
45
+ const parsed = JSON.parse(raw);
46
+ return isRecord(parsed) ? parsed : {};
47
+ }
48
+ catch {
49
+ return {};
50
+ }
51
+ }
52
+ /**
53
+ * @param {unknown} value
54
+ * @returns {number | null}
55
+ */
56
+ function parseNumber(value) {
57
+ if (typeof value === "number" && Number.isFinite(value))
58
+ return value;
59
+ if (typeof value === "string") {
60
+ const parsed = Number(value);
61
+ if (Number.isFinite(parsed))
62
+ return parsed;
63
+ }
64
+ return null;
65
+ }
66
+ /**
67
+ * @param {unknown} value
68
+ * @returns {string | null}
69
+ */
70
+ function parseString(value) {
71
+ if (typeof value !== "string")
72
+ return null;
73
+ const trimmed = value.trim();
74
+ return trimmed.length > 0 ? trimmed : null;
75
+ }
76
+ /**
77
+ * @param {unknown} value
78
+ * @returns {boolean}
79
+ */
80
+ function parseBoolean(value) {
81
+ if (typeof value === "boolean")
82
+ return value;
83
+ if (typeof value === "string") {
84
+ return value === "true" || value === "1";
85
+ }
86
+ return false;
87
+ }
88
+ /**
89
+ * @param {unknown} raw
90
+ * @returns {string[]}
91
+ */
92
+ function parseStringArray(raw) {
93
+ if (typeof raw !== "string")
94
+ return [];
95
+ const trimmed = raw.trim();
96
+ if (!trimmed)
97
+ return [];
98
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
99
+ try {
100
+ const parsed = JSON.parse(trimmed);
101
+ if (Array.isArray(parsed)) {
102
+ return parsed
103
+ .filter((entry) => typeof entry === "string")
104
+ .map((entry) => entry.trim())
105
+ .filter(Boolean);
106
+ }
107
+ }
108
+ catch {
109
+ // fall through to comma parsing
110
+ }
111
+ }
112
+ if (trimmed.includes(",")) {
113
+ return trimmed
114
+ .split(",")
115
+ .map((entry) => entry.trim())
116
+ .filter(Boolean);
117
+ }
118
+ return [trimmed];
119
+ }
120
+ /**
121
+ * @param {unknown} raw
122
+ * @returns {RetryPolicy | undefined}
123
+ */
124
+ function parseRetryPolicy(raw) {
125
+ if (typeof raw !== "string" || raw.trim().length === 0)
126
+ return undefined;
127
+ try {
128
+ const parsed = JSON.parse(raw);
129
+ if (!isRecord(parsed))
130
+ return undefined;
131
+ const initialDelayMs = parseNumber(parsed.initialDelayMs);
132
+ const backoffRaw = parseString(parsed.backoff);
133
+ const backoff = backoffRaw === "fixed" || backoffRaw === "linear" || backoffRaw === "exponential"
134
+ ? backoffRaw
135
+ : undefined;
136
+ if (initialDelayMs == null && !backoff)
137
+ return undefined;
138
+ return {
139
+ ...(initialDelayMs != null ? { initialDelayMs: Math.max(0, Math.floor(initialDelayMs)) } : {}),
140
+ ...(backoff ? { backoff } : {}),
141
+ };
142
+ }
143
+ catch {
144
+ return undefined;
145
+ }
146
+ }
147
+ /**
148
+ * @param {string | null | undefined} raw
149
+ * @returns {string | null}
150
+ */
151
+ function parseErrorSummary(raw) {
152
+ if (!raw)
153
+ return null;
154
+ try {
155
+ const parsed = JSON.parse(raw);
156
+ if (typeof parsed === "string")
157
+ return parsed;
158
+ if (isRecord(parsed)) {
159
+ const name = parseString(parsed.name);
160
+ const message = parseString(parsed.message);
161
+ if (name && message)
162
+ return `${name}: ${message}`;
163
+ if (message)
164
+ return message;
165
+ return JSON.stringify(parsed);
166
+ }
167
+ return String(parsed);
168
+ }
169
+ catch {
170
+ return raw;
171
+ }
172
+ }
173
+ /**
174
+ * @param {number} ms
175
+ * @returns {string}
176
+ */
177
+ function formatDuration(ms) {
178
+ if (ms <= 0)
179
+ return "0s";
180
+ const seconds = Math.floor(ms / 1000);
181
+ if (seconds < 60)
182
+ return `${seconds}s`;
183
+ const minutes = Math.floor(seconds / 60);
184
+ if (minutes < 60)
185
+ return `${minutes}m ${seconds % 60}s`;
186
+ const hours = Math.floor(minutes / 60);
187
+ if (hours < 24)
188
+ return `${hours}h ${minutes % 60}m`;
189
+ const days = Math.floor(hours / 24);
190
+ return `${days}d ${hours % 24}h`;
191
+ }
192
+ /**
193
+ * @param {number} now
194
+ * @param {Array<number | null | undefined>} ...candidates
195
+ * @returns {number}
196
+ */
197
+ function waitingSinceFallback(now, ...candidates) {
198
+ for (const value of candidates) {
199
+ if (typeof value === "number" && Number.isFinite(value))
200
+ return value;
201
+ }
202
+ return now;
203
+ }
204
+ /**
205
+ * @param {string | null} [metaJson]
206
+ * @returns {TimerSnapshot | null}
207
+ */
208
+ function parseTimerSnapshot(metaJson) {
209
+ const meta = parseObjectJson(metaJson);
210
+ const timer = isRecord(meta.timer) ? meta.timer : null;
211
+ if (!timer)
212
+ return null;
213
+ const timerId = parseString(timer.timerId);
214
+ const firesAtMs = parseNumber(timer.firesAtMs);
215
+ if (!timerId || firesAtMs == null)
216
+ return null;
217
+ return {
218
+ timerId,
219
+ firesAtMs: Math.floor(firesAtMs),
220
+ };
221
+ }
222
+ /**
223
+ * @param {DbEventRow} row
224
+ * @returns {Record<string, unknown> | null}
225
+ */
226
+ function parseEventPayload(row) {
227
+ try {
228
+ const payload = JSON.parse(row.payloadJson);
229
+ return isRecord(payload) ? payload : null;
230
+ }
231
+ catch {
232
+ return null;
233
+ }
234
+ }
235
+ /**
236
+ * @param {string | null | undefined} xmlJson
237
+ * @returns {Map<string, DescriptorMetadata>}
238
+ */
239
+ function parseFrameDescriptorMetadata(xmlJson) {
240
+ const metadata = new Map();
241
+ if (!xmlJson)
242
+ return metadata;
243
+ let parsed;
244
+ try {
245
+ parsed = JSON.parse(xmlJson);
246
+ }
247
+ catch {
248
+ return metadata;
249
+ }
250
+ if (!isRecord(parsed) || parsed.kind !== "element") {
251
+ // Non-XML frame payloads (e.g. patch blobs) are ignored.
252
+ return metadata;
253
+ }
254
+ /**
255
+ * @param {unknown} node
256
+ */
257
+ const walk = (node) => {
258
+ if (!isRecord(node))
259
+ return;
260
+ if (node.kind !== "element")
261
+ return;
262
+ const tag = parseString(node.tag) ?? "";
263
+ const props = isRecord(node.props) ? node.props : {};
264
+ const kind = tag === "smithers:task"
265
+ ? "task"
266
+ : tag === "smithers:wait-for-event"
267
+ ? "wait-for-event"
268
+ : tag === "smithers:timer"
269
+ ? "timer"
270
+ : tag === "smithers:subflow"
271
+ ? "subflow"
272
+ : "unknown";
273
+ if (kind !== "unknown") {
274
+ const id = parseString(props.id);
275
+ if (id) {
276
+ metadata.set(id, {
277
+ nodeId: id,
278
+ kind,
279
+ label: parseString(props.label),
280
+ dependsOn: parseStringArray(props.dependsOn),
281
+ continueOnFail: parseBoolean(props.continueOnFail),
282
+ retries: (() => {
283
+ const retries = parseNumber(props.retries);
284
+ return retries == null ? null : Math.max(0, Math.floor(retries));
285
+ })(),
286
+ heartbeatTimeoutMs: (() => {
287
+ const timeout = parseNumber(props.heartbeatTimeoutMs) ??
288
+ parseNumber(props.heartbeatTimeout);
289
+ return timeout == null || timeout <= 0
290
+ ? null
291
+ : Math.floor(timeout);
292
+ })(),
293
+ retryPolicy: parseRetryPolicy(props.retryPolicy),
294
+ eventName: parseString(props.__smithersEventName) ??
295
+ parseString(props.event) ??
296
+ null,
297
+ correlationId: parseString(props.__smithersCorrelationId) ??
298
+ parseString(props.correlationId) ??
299
+ null,
300
+ onTimeout: parseString(props.__smithersOnTimeout) ??
301
+ parseString(props.onTimeout) ??
302
+ null,
303
+ timerDuration: parseString(props.__smithersTimerDuration) ??
304
+ parseString(props.duration) ??
305
+ null,
306
+ timerUntil: parseString(props.__smithersTimerUntil) ??
307
+ parseString(props.until) ??
308
+ null,
309
+ });
310
+ }
311
+ }
312
+ const children = Array.isArray(node.children) ? node.children : [];
313
+ for (const child of children) {
314
+ walk(child);
315
+ }
316
+ };
317
+ walk(parsed);
318
+ return metadata;
319
+ }
320
+ /**
321
+ * @param {Map<string, DescriptorMetadata>} metadataById
322
+ * @param {string} nodeId
323
+ * @returns {DescriptorMetadata | undefined}
324
+ */
325
+ function resolveDescriptorMetadata(metadataById, nodeId) {
326
+ return metadataById.get(nodeId) ?? metadataById.get(logicalNodeId(nodeId));
327
+ }
328
+ /**
329
+ * @param {DescriptorMetadata | undefined} descriptor
330
+ * @param {DbAttemptRow | undefined} attempt
331
+ * @returns {number | null}
332
+ */
333
+ function resolveHeartbeatTimeoutMs(descriptor, attempt) {
334
+ if (descriptor?.heartbeatTimeoutMs != null) {
335
+ return descriptor.heartbeatTimeoutMs;
336
+ }
337
+ if (!attempt?.metaJson)
338
+ return null;
339
+ const meta = parseObjectJson(attempt.metaJson);
340
+ const timeout = parseNumber(meta.heartbeatTimeoutMs) ??
341
+ parseNumber(meta.heartbeatTimeout);
342
+ if (timeout == null || timeout <= 0)
343
+ return null;
344
+ return Math.floor(timeout);
345
+ }
346
+ /**
347
+ * @param {DbNodeRow} node
348
+ * @param {DbAttemptRow[]} attempts
349
+ * @param {DescriptorMetadata | undefined} descriptor
350
+ * @returns {RetryInsight | null}
351
+ */
352
+ function buildRetryInsight(node, attempts, descriptor) {
353
+ if (attempts.length === 0)
354
+ return null;
355
+ const failedAttempts = attempts.filter((attempt) => attempt.state === "failed");
356
+ if (failedAttempts.length === 0)
357
+ return null;
358
+ failedAttempts.sort((a, b) => b.attempt - a.attempt);
359
+ const newestAttempt = attempts[0];
360
+ const latestFailed = failedAttempts[0];
361
+ const latestFailedMeta = parseObjectJson(latestFailed.metaJson);
362
+ const newestMeta = parseObjectJson(newestAttempt.metaJson);
363
+ const retriesFromDescriptor = descriptor?.retries ?? null;
364
+ const retriesFromAttempt = parseNumber(newestMeta.retries) ??
365
+ parseNumber(latestFailedMeta.retries);
366
+ const retries = retriesFromDescriptor != null
367
+ ? retriesFromDescriptor
368
+ : retriesFromAttempt != null
369
+ ? Math.max(0, Math.floor(retriesFromAttempt))
370
+ : null;
371
+ const maxAttempts = retries != null ? retries + 1 : null;
372
+ const failedCount = failedAttempts.length;
373
+ const exhausted = maxAttempts != null ? failedCount >= maxAttempts : node.state === "failed";
374
+ const retrying = !exhausted &&
375
+ (node.state === "pending" ||
376
+ node.state === "in-progress" ||
377
+ node.state === "waiting-approval" ||
378
+ node.state === "waiting-event" ||
379
+ node.state === "waiting-timer");
380
+ const retryPolicy = descriptor?.retryPolicy ??
381
+ (() => {
382
+ const candidate = newestMeta.retryPolicy ?? latestFailedMeta.retryPolicy;
383
+ if (!isRecord(candidate))
384
+ return undefined;
385
+ const initialDelayMs = parseNumber(candidate.initialDelayMs);
386
+ const backoffRaw = parseString(candidate.backoff);
387
+ const backoff = backoffRaw === "fixed" || backoffRaw === "linear" || backoffRaw === "exponential"
388
+ ? backoffRaw
389
+ : undefined;
390
+ if (initialDelayMs == null && !backoff)
391
+ return undefined;
392
+ return {
393
+ ...(initialDelayMs != null ? { initialDelayMs: Math.max(0, Math.floor(initialDelayMs)) } : {}),
394
+ ...(backoff ? { backoff } : {}),
395
+ };
396
+ })();
397
+ let nextRetryAtMs = null;
398
+ const lastFinishedAtMs = typeof latestFailed.finishedAtMs === "number"
399
+ ? latestFailed.finishedAtMs
400
+ : typeof latestFailed.startedAtMs === "number"
401
+ ? latestFailed.startedAtMs
402
+ : null;
403
+ if (retrying && retryPolicy && lastFinishedAtMs != null) {
404
+ const delayMs = computeRetryDelayMs(retryPolicy, latestFailed.attempt);
405
+ if (delayMs > 0) {
406
+ nextRetryAtMs = lastFinishedAtMs + delayMs;
407
+ }
408
+ }
409
+ return {
410
+ failedCount,
411
+ maxAttempts,
412
+ lastError: parseErrorSummary(latestFailed.errorJson),
413
+ lastFailedAtMs: lastFinishedAtMs,
414
+ exhausted,
415
+ retrying,
416
+ nextRetryAtMs,
417
+ };
418
+ }
419
+ /**
420
+ * @param {DbNodeRow} node
421
+ * @param {DescriptorMetadata | undefined} descriptor
422
+ * @param {DbAttemptRow[]} attempts
423
+ * @param {ParsedEvent[]} events
424
+ * @returns {{ signalName: string | null; correlationId: string | null }}
425
+ */
426
+ function computeSignalName(node, descriptor, attempts, events) {
427
+ let signalName = descriptor?.eventName ?? null;
428
+ let correlationId = descriptor?.correlationId ?? null;
429
+ for (const attempt of attempts) {
430
+ const meta = parseObjectJson(attempt.metaJson);
431
+ signalName =
432
+ signalName ??
433
+ parseString(meta.eventName) ??
434
+ parseString(meta.event) ??
435
+ parseString(meta.signalName) ??
436
+ parseString(meta.signal) ??
437
+ null;
438
+ correlationId =
439
+ correlationId ??
440
+ parseString(meta.correlationId) ??
441
+ null;
442
+ }
443
+ for (let index = events.length - 1; index >= 0; index -= 1) {
444
+ const event = events[index];
445
+ const payload = event.payload;
446
+ if (!payload)
447
+ continue;
448
+ if (parseString(payload.nodeId) !== node.nodeId)
449
+ continue;
450
+ const iteration = parseNumber(payload.iteration);
451
+ if (iteration != null && Math.floor(iteration) !== node.iteration)
452
+ continue;
453
+ signalName =
454
+ signalName ??
455
+ parseString(payload.eventName) ??
456
+ parseString(payload.event) ??
457
+ parseString(payload.signalName) ??
458
+ parseString(payload.signal) ??
459
+ null;
460
+ correlationId =
461
+ correlationId ??
462
+ parseString(payload.correlationId) ??
463
+ null;
464
+ if (signalName && correlationId)
465
+ break;
466
+ }
467
+ return { signalName, correlationId };
468
+ }
469
+ /**
470
+ * @param {DbNodeRow} node
471
+ * @param {DbAttemptRow[]} attempts
472
+ * @param {ParsedEvent[]} events
473
+ * @returns {TimerSnapshot | null}
474
+ */
475
+ function computeTimerSnapshot(node, attempts, events) {
476
+ for (const attempt of attempts) {
477
+ const parsed = parseTimerSnapshot(attempt.metaJson);
478
+ if (parsed)
479
+ return parsed;
480
+ }
481
+ for (let index = events.length - 1; index >= 0; index -= 1) {
482
+ const event = events[index];
483
+ const payload = event.payload;
484
+ if (!payload)
485
+ continue;
486
+ const payloadNodeId = parseString(payload.nodeId) ?? parseString(payload.timerId);
487
+ if (payloadNodeId !== node.nodeId)
488
+ continue;
489
+ const firesAtMs = parseNumber(payload.firesAtMs);
490
+ if (firesAtMs == null)
491
+ continue;
492
+ return { timerId: node.nodeId, firesAtMs: Math.floor(firesAtMs) };
493
+ }
494
+ return null;
495
+ }
496
+ /**
497
+ * @param {DbNodeRow[]} nodes
498
+ * @returns {string | null}
499
+ */
500
+ function firstCurrentNode(nodes) {
501
+ const inProgress = nodes
502
+ .filter((node) => node.state === "in-progress")
503
+ .sort((a, b) => (b.updatedAtMs ?? 0) - (a.updatedAtMs ?? 0));
504
+ if (inProgress.length > 0)
505
+ return inProgress[0].nodeId;
506
+ const pending = nodes
507
+ .filter((node) => node.state === "pending")
508
+ .sort((a, b) => (b.updatedAtMs ?? 0) - (a.updatedAtMs ?? 0));
509
+ return pending[0]?.nodeId ?? null;
510
+ }
511
+ /**
512
+ * @param {RetryInsight} insight
513
+ * @param {number} nowMs
514
+ * @returns {string}
515
+ */
516
+ function describeRetryContext(insight, nowMs) {
517
+ const lines = [];
518
+ const attemptCountLabel = insight.maxAttempts != null
519
+ ? `attempt ${insight.failedCount} of ${insight.maxAttempts}`
520
+ : `attempt ${insight.failedCount}`;
521
+ if (insight.lastError) {
522
+ lines.push(`Previous attempt failed (${attemptCountLabel}):`);
523
+ lines.push(` ${insight.lastError}`);
524
+ }
525
+ else {
526
+ lines.push(`Previous attempt failed (${attemptCountLabel}).`);
527
+ }
528
+ if (insight.retrying) {
529
+ if (insight.nextRetryAtMs != null && insight.nextRetryAtMs > nowMs) {
530
+ lines.push(`Retrying automatically in ${formatDuration(insight.nextRetryAtMs - nowMs)}`);
531
+ }
532
+ else {
533
+ lines.push("Retrying automatically");
534
+ }
535
+ }
536
+ return lines.join("\n");
537
+ }
538
+ /**
539
+ * @param {string} value
540
+ * @returns {string}
541
+ */
542
+ function shellEscape(value) {
543
+ if (/^[a-zA-Z0-9._/:-]+$/.test(value))
544
+ return value;
545
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
546
+ }
547
+ /**
548
+ * @param {DbRunRow} run
549
+ */
550
+ function buildResumeUnblocker(run, force = false) {
551
+ const workflowArg = run.workflowPath ? shellEscape(run.workflowPath) : "<workflow>";
552
+ const forceFlag = force ? " --force true" : "";
553
+ return `smithers up ${workflowArg} --run-id ${run.runId} --resume true${forceFlag}`;
554
+ }
555
+ /**
556
+ * @param {DbRunRow} run
557
+ * @param {string} nodeId
558
+ * @param {number} iteration
559
+ */
560
+ function buildRetryTaskUnblocker(run, nodeId, iteration, force = false) {
561
+ const workflowArg = run.workflowPath ? shellEscape(run.workflowPath) : "<workflow>";
562
+ const forceFlag = force ? " --force true" : "";
563
+ return `smithers retry-task ${workflowArg} --run-id ${run.runId} --node-id ${shellEscape(nodeId)} --iteration ${iteration}${forceFlag}`;
564
+ }
565
+ /**
566
+ * @param {WhyBlocker[]} blockers
567
+ * @returns {WhyBlocker[]}
568
+ */
569
+ function dedupeBlockers(blockers) {
570
+ const seen = new Set();
571
+ const deduped = [];
572
+ for (const blocker of blockers) {
573
+ const key = `${blocker.kind}:${blocker.nodeId}:${blocker.iteration ?? 0}`;
574
+ if (seen.has(key))
575
+ continue;
576
+ seen.add(key);
577
+ deduped.push(blocker);
578
+ }
579
+ return deduped;
580
+ }
581
+ /**
582
+ * @param {{ run: DbRunRow; nodes: DbNodeRow[]; approvals: DbApprovalRow[]; attempts: DbAttemptRow[]; events: DbEventRow[]; lastFrame: DbFrameRow | undefined; nowMs: number; }} params
583
+ * @returns {WhyDiagnosis}
584
+ */
585
+ function buildDiagnosis(params) {
586
+ const { run, nodes, approvals, attempts, events, lastFrame, nowMs, } = params;
587
+ const runId = run.runId;
588
+ const status = run.status === "continued" && run.finishedAtMs == null
589
+ ? "running"
590
+ : String(run.status ?? "unknown");
591
+ const descriptorMetadata = parseFrameDescriptorMetadata(lastFrame?.xmlJson);
592
+ const parsedEvents = events.map((row) => ({
593
+ row,
594
+ payload: parseEventPayload(row),
595
+ }));
596
+ const nodesByKey = new Map();
597
+ const nodesByLogicalId = new Map();
598
+ for (const node of nodes) {
599
+ const key = nodeKey(node.nodeId, node.iteration ?? 0);
600
+ nodesByKey.set(key, node);
601
+ const logical = logicalNodeId(node.nodeId);
602
+ const existing = nodesByLogicalId.get(logical);
603
+ if (existing) {
604
+ existing.push(node);
605
+ }
606
+ else {
607
+ nodesByLogicalId.set(logical, [node]);
608
+ }
609
+ }
610
+ for (const group of nodesByLogicalId.values()) {
611
+ group.sort((left, right) => (right.updatedAtMs ?? 0) - (left.updatedAtMs ?? 0));
612
+ }
613
+ const attemptsByNode = new Map();
614
+ for (const attempt of attempts) {
615
+ const key = nodeKey(attempt.nodeId, attempt.iteration ?? 0);
616
+ const existing = attemptsByNode.get(key);
617
+ if (existing) {
618
+ existing.push(attempt);
619
+ }
620
+ else {
621
+ attemptsByNode.set(key, [attempt]);
622
+ }
623
+ }
624
+ for (const group of attemptsByNode.values()) {
625
+ group.sort((a, b) => b.attempt - a.attempt);
626
+ }
627
+ const retryInsightsByNode = new Map();
628
+ for (const node of nodes) {
629
+ const key = nodeKey(node.nodeId, node.iteration ?? 0);
630
+ const insight = buildRetryInsight(node, attemptsByNode.get(key) ?? [], resolveDescriptorMetadata(descriptorMetadata, node.nodeId));
631
+ if (insight)
632
+ retryInsightsByNode.set(key, insight);
633
+ }
634
+ const blockers = [];
635
+ for (const approval of approvals) {
636
+ if (approval.status !== "requested")
637
+ continue;
638
+ const key = nodeKey(approval.nodeId, approval.iteration ?? 0);
639
+ const node = nodesByKey.get(key);
640
+ const retryInsight = retryInsightsByNode.get(key);
641
+ const waitingSince = waitingSinceFallback(nowMs, approval.requestedAtMs, node?.updatedAtMs, run.startedAtMs, run.createdAtMs);
642
+ const contextParts = [];
643
+ if (retryInsight && !retryInsight.exhausted) {
644
+ contextParts.push(describeRetryContext(retryInsight, nowMs));
645
+ }
646
+ contextParts.push(`Deny instead: smithers deny ${runId} --node ${approval.nodeId} --iteration ${approval.iteration ?? 0}`);
647
+ blockers.push({
648
+ kind: "waiting-approval",
649
+ nodeId: approval.nodeId,
650
+ iteration: approval.iteration ?? 0,
651
+ reason: "Approval requested — no decision yet",
652
+ waitingSince,
653
+ unblocker: approvals.length > 1
654
+ ? `smithers approve ${runId} --node ${approval.nodeId} --iteration ${approval.iteration ?? 0}`
655
+ : `smithers approve ${runId}`,
656
+ context: contextParts.join("\n\n"),
657
+ ...(retryInsight
658
+ ? {
659
+ attempt: retryInsight.failedCount,
660
+ maxAttempts: retryInsight.maxAttempts,
661
+ }
662
+ : {}),
663
+ });
664
+ }
665
+ for (const node of nodes.filter((entry) => entry.state === "waiting-event")) {
666
+ const key = nodeKey(node.nodeId, node.iteration ?? 0);
667
+ const descriptor = resolveDescriptorMetadata(descriptorMetadata, node.nodeId);
668
+ const nodeAttempts = attemptsByNode.get(key) ?? [];
669
+ const { signalName, correlationId } = computeSignalName(node, descriptor, nodeAttempts, parsedEvents);
670
+ const signalArg = signalName ? shellEscape(signalName) : "<signal-name>";
671
+ const correlationFlag = correlationId ? ` --correlation ${shellEscape(correlationId)}` : "";
672
+ const retryInsight = retryInsightsByNode.get(key);
673
+ const contextParts = [];
674
+ if (correlationId) {
675
+ contextParts.push(`Correlation: ${correlationId}`);
676
+ }
677
+ if (descriptor?.onTimeout) {
678
+ contextParts.push(`On timeout: ${descriptor.onTimeout}`);
679
+ }
680
+ if (retryInsight && !retryInsight.exhausted) {
681
+ contextParts.push(describeRetryContext(retryInsight, nowMs));
682
+ }
683
+ blockers.push({
684
+ kind: "waiting-event",
685
+ nodeId: node.nodeId,
686
+ iteration: node.iteration ?? 0,
687
+ reason: signalName
688
+ ? `waiting for signal '${signalName}'`
689
+ : "waiting for signal",
690
+ waitingSince: waitingSinceFallback(nowMs, node.updatedAtMs, run.startedAtMs, run.createdAtMs),
691
+ unblocker: `smithers signal ${runId} ${signalArg} --data '{}'${correlationFlag}`,
692
+ ...(contextParts.length > 0 ? { context: contextParts.join("\n") } : {}),
693
+ signalName,
694
+ ...(retryInsight
695
+ ? {
696
+ attempt: retryInsight.failedCount,
697
+ maxAttempts: retryInsight.maxAttempts,
698
+ }
699
+ : {}),
700
+ });
701
+ }
702
+ for (const node of nodes.filter((entry) => entry.state === "waiting-timer")) {
703
+ const key = nodeKey(node.nodeId, node.iteration ?? 0);
704
+ const snapshot = computeTimerSnapshot(node, attemptsByNode.get(key) ?? [], parsedEvents);
705
+ const firesAtMs = snapshot?.firesAtMs ?? null;
706
+ const remainingMs = firesAtMs == null ? null : Math.max(0, firesAtMs - nowMs);
707
+ const timerLabel = snapshot?.timerId ?? node.nodeId;
708
+ const contextParts = [];
709
+ if (firesAtMs != null) {
710
+ contextParts.push(`Fires at: ${new Date(firesAtMs).toISOString()}`);
711
+ contextParts.push(`Time remaining: ${formatDuration(Math.max(0, firesAtMs - nowMs))}`);
712
+ }
713
+ blockers.push({
714
+ kind: "waiting-timer",
715
+ nodeId: node.nodeId,
716
+ iteration: node.iteration ?? 0,
717
+ reason: `waiting for timer '${timerLabel}'`,
718
+ waitingSince: waitingSinceFallback(nowMs, node.updatedAtMs, run.startedAtMs, run.createdAtMs),
719
+ unblocker: buildResumeUnblocker(run),
720
+ ...(contextParts.length > 0 ? { context: contextParts.join("\n") } : {}),
721
+ firesAtMs,
722
+ remainingMs,
723
+ });
724
+ }
725
+ for (const node of nodes.filter((entry) => entry.state === "in-progress")) {
726
+ const key = nodeKey(node.nodeId, node.iteration ?? 0);
727
+ const nodeAttempts = attemptsByNode.get(key) ?? [];
728
+ const inProgressAttempt = nodeAttempts.find((attempt) => attempt.state === "in-progress");
729
+ if (!inProgressAttempt)
730
+ continue;
731
+ const descriptor = resolveDescriptorMetadata(descriptorMetadata, node.nodeId);
732
+ const heartbeatTimeoutMs = resolveHeartbeatTimeoutMs(descriptor, inProgressAttempt);
733
+ if (heartbeatTimeoutMs == null)
734
+ continue;
735
+ const lastHeartbeatAtMs = typeof inProgressAttempt.heartbeatAtMs === "number"
736
+ ? inProgressAttempt.heartbeatAtMs
737
+ : typeof inProgressAttempt.startedAtMs === "number"
738
+ ? inProgressAttempt.startedAtMs
739
+ : null;
740
+ if (lastHeartbeatAtMs == null)
741
+ continue;
742
+ const staleForMs = Math.max(0, nowMs - lastHeartbeatAtMs);
743
+ if (staleForMs <= heartbeatTimeoutMs)
744
+ continue;
745
+ blockers.push({
746
+ kind: "stale-task-heartbeat",
747
+ nodeId: node.nodeId,
748
+ iteration: node.iteration ?? 0,
749
+ reason: `task ${node.nodeId} hasn't heartbeated in ${formatDuration(staleForMs)} (timeout: ${formatDuration(heartbeatTimeoutMs)})`,
750
+ waitingSince: waitingSinceFallback(nowMs, lastHeartbeatAtMs, node.updatedAtMs, run.startedAtMs),
751
+ unblocker: buildRetryTaskUnblocker(run, node.nodeId, node.iteration ?? 0, run.status === "running"),
752
+ context: `Attempt ${inProgressAttempt.attempt}`,
753
+ attempt: inProgressAttempt.attempt,
754
+ maxAttempts: descriptor?.retries != null ? descriptor.retries + 1 : null,
755
+ });
756
+ }
757
+ for (const node of nodes.filter((entry) => entry.state === "failed")) {
758
+ const key = nodeKey(node.nodeId, node.iteration ?? 0);
759
+ const insight = retryInsightsByNode.get(key);
760
+ if (!insight)
761
+ continue;
762
+ if (!insight.exhausted && status !== "failed")
763
+ continue;
764
+ blockers.push({
765
+ kind: "retries-exhausted",
766
+ nodeId: node.nodeId,
767
+ iteration: node.iteration ?? 0,
768
+ reason: insight.lastError
769
+ ? `All retries exhausted. Last error: ${insight.lastError}`
770
+ : "All retries exhausted.",
771
+ waitingSince: waitingSinceFallback(nowMs, insight.lastFailedAtMs, node.updatedAtMs, run.finishedAtMs, run.startedAtMs),
772
+ unblocker: buildResumeUnblocker(run),
773
+ context: insight.maxAttempts != null
774
+ ? `Attempt ${insight.failedCount} of ${insight.maxAttempts}`
775
+ : `Attempt ${insight.failedCount}`,
776
+ attempt: insight.failedCount,
777
+ maxAttempts: insight.maxAttempts,
778
+ });
779
+ }
780
+ const primaryBlockedNodes = new Set(blockers.map((blocker) => nodeKey(blocker.nodeId, blocker.iteration ?? 0)));
781
+ for (const node of nodes) {
782
+ const key = nodeKey(node.nodeId, node.iteration ?? 0);
783
+ if (primaryBlockedNodes.has(key))
784
+ continue;
785
+ const insight = retryInsightsByNode.get(key);
786
+ if (!insight || insight.exhausted || !insight.retrying)
787
+ continue;
788
+ blockers.push({
789
+ kind: "retry-backoff",
790
+ nodeId: node.nodeId,
791
+ iteration: node.iteration ?? 0,
792
+ reason: insight.nextRetryAtMs != null && insight.nextRetryAtMs > nowMs
793
+ ? `Previous attempt failed — retrying automatically in ${formatDuration(insight.nextRetryAtMs - nowMs)}`
794
+ : "Previous attempt failed — retrying automatically",
795
+ waitingSince: waitingSinceFallback(nowMs, insight.lastFailedAtMs, node.updatedAtMs, run.startedAtMs),
796
+ unblocker: buildRetryTaskUnblocker(run, node.nodeId, node.iteration ?? 0, run.status === "running"),
797
+ context: describeRetryContext(insight, nowMs),
798
+ attempt: insight.failedCount,
799
+ maxAttempts: insight.maxAttempts,
800
+ });
801
+ }
802
+ for (const node of nodes.filter((entry) => entry.state === "pending")) {
803
+ const descriptor = resolveDescriptorMetadata(descriptorMetadata, node.nodeId);
804
+ const dependsOn = descriptor?.dependsOn ?? [];
805
+ if (dependsOn.length === 0)
806
+ continue;
807
+ for (const dependencyId of dependsOn) {
808
+ const candidateNodes = nodesByLogicalId.get(logicalNodeId(dependencyId)) ?? [];
809
+ const failedDependency = candidateNodes.find((candidate) => candidate.state === "failed");
810
+ if (!failedDependency)
811
+ continue;
812
+ const failedDescriptor = resolveDescriptorMetadata(descriptorMetadata, failedDependency.nodeId);
813
+ if (failedDescriptor?.continueOnFail)
814
+ continue;
815
+ blockers.push({
816
+ kind: "dependency-failed",
817
+ nodeId: node.nodeId,
818
+ iteration: node.iteration ?? 0,
819
+ reason: `Node ${node.nodeId} is blocked because dependency ${failedDependency.nodeId} failed.`,
820
+ waitingSince: waitingSinceFallback(nowMs, node.updatedAtMs, failedDependency.updatedAtMs, run.startedAtMs),
821
+ unblocker: buildResumeUnblocker(run),
822
+ dependencyNodeId: failedDependency.nodeId,
823
+ });
824
+ break;
825
+ }
826
+ }
827
+ if (status === "running" && !isRunHeartbeatFresh(run, nowMs)) {
828
+ const lastHeartbeatAtMs = typeof run.heartbeatAtMs === "number" ? run.heartbeatAtMs : null;
829
+ blockers.push({
830
+ kind: "stale-heartbeat",
831
+ nodeId: "(run-level)",
832
+ iteration: null,
833
+ reason: lastHeartbeatAtMs != null
834
+ ? `Run appears orphaned (last heartbeat ${formatDuration(Math.max(0, nowMs - lastHeartbeatAtMs))} ago)`
835
+ : "Run appears orphaned (no heartbeat recorded)",
836
+ waitingSince: waitingSinceFallback(nowMs, lastHeartbeatAtMs, run.startedAtMs, run.createdAtMs),
837
+ unblocker: buildResumeUnblocker(run, true),
838
+ });
839
+ }
840
+ const dedupedBlockers = dedupeBlockers(blockers);
841
+ let summary;
842
+ if (status === "finished") {
843
+ summary = "Run is finished, nothing is blocked.";
844
+ }
845
+ else if (status === "cancelled") {
846
+ summary =
847
+ typeof run.finishedAtMs === "number"
848
+ ? `Run was cancelled at ${new Date(run.finishedAtMs).toISOString()}.`
849
+ : "Run was cancelled.";
850
+ }
851
+ else if (status === "running" &&
852
+ isRunHeartbeatFresh(run, nowMs) &&
853
+ dedupedBlockers.length === 0) {
854
+ const currentNode = firstCurrentNode(nodes);
855
+ summary = currentNode
856
+ ? `Run is executing normally. Currently on node ${currentNode}.`
857
+ : "Run is executing normally.";
858
+ }
859
+ else if (dedupedBlockers.length === 0) {
860
+ summary = `Run is ${status}. No blockers were identified.`;
861
+ }
862
+ else {
863
+ summary = `Run ${runId} is ${status}`;
864
+ }
865
+ return {
866
+ runId,
867
+ status,
868
+ summary,
869
+ generatedAtMs: nowMs,
870
+ blockers: dedupedBlockers.sort((left, right) => left.waitingSince - right.waitingSince),
871
+ currentNodeId: firstCurrentNode(nodes),
872
+ };
873
+ }
874
+ /**
875
+ * @param {SmithersDb} adapter
876
+ * @param {string} runId
877
+ * @returns {Effect.Effect<WhyDiagnosis, SmithersError>}
878
+ */
879
+ export function diagnoseRunEffect(adapter, runId, nowMs = Date.now()) {
880
+ return Effect.withLogSpan("why:diagnose")(Effect.gen(function* () {
881
+ const [run, nodes, approvals, attempts, lastSeq, lastFrame] = yield* Effect.all([
882
+ adapter.getRunEffect(runId),
883
+ adapter.listNodesEffect(runId),
884
+ adapter.listPendingApprovalsEffect(runId),
885
+ adapter.listAttemptsForRunEffect(runId),
886
+ adapter.getLastEventSeqEffect(runId),
887
+ adapter.getLastFrameEffect(runId),
888
+ ]);
889
+ if (!run) {
890
+ return yield* Effect.fail(new SmithersError("RUN_NOT_FOUND", `Run not found: ${runId}`));
891
+ }
892
+ const afterSeq = Math.max(-1, (lastSeq ?? -1) - RECENT_EVENTS_LIMIT);
893
+ const events = yield* adapter.listEventHistoryEffect(runId, {
894
+ afterSeq,
895
+ limit: RECENT_EVENTS_LIMIT,
896
+ });
897
+ const diagnosis = buildDiagnosis({
898
+ run: run,
899
+ nodes: nodes ?? [],
900
+ approvals: approvals ?? [],
901
+ attempts: attempts ?? [],
902
+ events: events ?? [],
903
+ lastFrame: lastFrame,
904
+ nowMs,
905
+ });
906
+ return yield* Effect.succeed(diagnosis).pipe(Effect.annotateLogs({
907
+ status: diagnosis.status,
908
+ blockerCount: diagnosis.blockers.length,
909
+ }));
910
+ })).pipe(Effect.annotateLogs({ runId }));
911
+ }
912
+ /**
913
+ * @param {WhyDiagnosis} diagnosis
914
+ * @returns {string}
915
+ */
916
+ export function renderWhyDiagnosisHuman(diagnosis) {
917
+ if (diagnosis.status === "finished") {
918
+ return "Run is finished, nothing is blocked.";
919
+ }
920
+ if (diagnosis.status === "cancelled") {
921
+ return diagnosis.summary;
922
+ }
923
+ if (diagnosis.status === "running" &&
924
+ diagnosis.blockers.length === 0 &&
925
+ diagnosis.summary.startsWith("Run is executing normally")) {
926
+ return diagnosis.summary;
927
+ }
928
+ const lines = [];
929
+ lines.push(`Run ${diagnosis.runId} is ${diagnosis.status}`);
930
+ if (diagnosis.blockers.length === 0) {
931
+ lines.push("");
932
+ lines.push(diagnosis.summary);
933
+ return lines.join("\n");
934
+ }
935
+ for (const blocker of diagnosis.blockers) {
936
+ lines.push("");
937
+ lines.push(` Blocked node: ${blocker.nodeId} (iteration ${blocker.iteration ?? 0})`);
938
+ lines.push(` Waiting since: ${formatAge(blocker.waitingSince)} (${new Date(blocker.waitingSince).toISOString()})`);
939
+ lines.push(` Reason: ${blocker.reason}`);
940
+ lines.push(` Unblock: ${blocker.unblocker}`);
941
+ if (typeof blocker.firesAtMs === "number") {
942
+ lines.push(` Fires at: ${new Date(blocker.firesAtMs).toISOString()}`);
943
+ }
944
+ if (typeof blocker.remainingMs === "number") {
945
+ lines.push(` Time remaining:${blocker.remainingMs >= 0 ? " " : ""}${formatDuration(Math.max(0, blocker.remainingMs))}`);
946
+ }
947
+ if (blocker.context) {
948
+ lines.push("");
949
+ for (const line of blocker.context.split("\n")) {
950
+ lines.push(` ${line}`);
951
+ }
952
+ }
953
+ }
954
+ return lines.join("\n");
955
+ }
956
+ /**
957
+ * @param {string} command
958
+ * @returns {string}
959
+ */
960
+ function stripSmithersPrefix(command) {
961
+ return command.startsWith("smithers ") ? command.slice("smithers ".length) : command;
962
+ }
963
+ /**
964
+ * @param {WhyDiagnosis} diagnosis
965
+ * @returns {Array<{ command: string; description: string }>}
966
+ */
967
+ export function diagnosisCtaCommands(diagnosis) {
968
+ const mapping = {
969
+ "waiting-approval": "Approve pending gate",
970
+ "waiting-event": "Send expected signal",
971
+ "waiting-timer": "Resume once timer is due",
972
+ "stale-task-heartbeat": "Retry timed-out task",
973
+ "retry-backoff": "Retry blocked node",
974
+ "retries-exhausted": "Resume run after fixing failure",
975
+ "stale-heartbeat": "Force resume orphaned run",
976
+ "dependency-failed": "Resume after dependency fix",
977
+ };
978
+ const unique = new Map();
979
+ for (const blocker of diagnosis.blockers) {
980
+ const command = stripSmithersPrefix(blocker.unblocker);
981
+ if (!command || command.includes("<"))
982
+ continue;
983
+ if (!unique.has(command)) {
984
+ unique.set(command, {
985
+ command,
986
+ description: mapping[blocker.kind] ?? "Unblock run",
987
+ });
988
+ }
989
+ if (unique.size >= MAX_CTA_COMMANDS)
990
+ break;
991
+ }
992
+ const ctas = [...unique.values()];
993
+ ctas.push({ command: `inspect ${diagnosis.runId}`, description: "Inspect run state" }, { command: `logs ${diagnosis.runId}`, description: "Tail run logs" });
994
+ const deduped = new Map();
995
+ for (const entry of ctas) {
996
+ if (!deduped.has(entry.command))
997
+ deduped.set(entry.command, entry);
998
+ }
999
+ return [...deduped.values()].slice(0, MAX_CTA_COMMANDS + 2);
1000
+ }