botmux 2.33.0 → 2.34.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 (281) hide show
  1. package/README.en.md +12 -1
  2. package/README.md +45 -1
  3. package/dist/adapters/cli/claude-code.d.ts.map +1 -1
  4. package/dist/adapters/cli/claude-code.js +11 -0
  5. package/dist/adapters/cli/claude-code.js.map +1 -1
  6. package/dist/cli/bots-list-output.d.ts +21 -0
  7. package/dist/cli/bots-list-output.d.ts.map +1 -0
  8. package/dist/cli/bots-list-output.js +23 -0
  9. package/dist/cli/bots-list-output.js.map +1 -0
  10. package/dist/cli/workflow.d.ts +13 -0
  11. package/dist/cli/workflow.d.ts.map +1 -0
  12. package/dist/cli/workflow.js +781 -0
  13. package/dist/cli/workflow.js.map +1 -0
  14. package/dist/cli.js +69 -14
  15. package/dist/cli.js.map +1 -1
  16. package/dist/core/command-handler.d.ts.map +1 -1
  17. package/dist/core/command-handler.js +219 -6
  18. package/dist/core/command-handler.js.map +1 -1
  19. package/dist/core/session-manager.d.ts +6 -1
  20. package/dist/core/session-manager.d.ts.map +1 -1
  21. package/dist/core/session-manager.js +22 -12
  22. package/dist/core/session-manager.js.map +1 -1
  23. package/dist/core/worker-pool.d.ts +13 -0
  24. package/dist/core/worker-pool.d.ts.map +1 -1
  25. package/dist/core/worker-pool.js +100 -6
  26. package/dist/core/worker-pool.js.map +1 -1
  27. package/dist/daemon.d.ts +3 -0
  28. package/dist/daemon.d.ts.map +1 -1
  29. package/dist/daemon.js +884 -3
  30. package/dist/daemon.js.map +1 -1
  31. package/dist/dashboard/auth.d.ts +36 -0
  32. package/dist/dashboard/auth.d.ts.map +1 -1
  33. package/dist/dashboard/auth.js +22 -0
  34. package/dist/dashboard/auth.js.map +1 -1
  35. package/dist/dashboard/web/app.js +20 -1
  36. package/dist/dashboard/web/app.js.map +1 -1
  37. package/dist/dashboard/web/i18n.d.ts.map +1 -1
  38. package/dist/dashboard/web/i18n.js +356 -0
  39. package/dist/dashboard/web/i18n.js.map +1 -1
  40. package/dist/dashboard/web/workflow-catalog.d.ts +2 -0
  41. package/dist/dashboard/web/workflow-catalog.d.ts.map +1 -0
  42. package/dist/dashboard/web/workflow-catalog.js +323 -0
  43. package/dist/dashboard/web/workflow-catalog.js.map +1 -0
  44. package/dist/dashboard/web/workflows.d.ts +2 -0
  45. package/dist/dashboard/web/workflows.d.ts.map +1 -0
  46. package/dist/dashboard/web/workflows.js +1618 -0
  47. package/dist/dashboard/web/workflows.js.map +1 -0
  48. package/dist/dashboard/workflow-api.d.ts +23 -0
  49. package/dist/dashboard/workflow-api.d.ts.map +1 -0
  50. package/dist/dashboard/workflow-api.js +463 -0
  51. package/dist/dashboard/workflow-api.js.map +1 -0
  52. package/dist/dashboard-web/app.js +494 -199
  53. package/dist/dashboard-web/index.html +1 -0
  54. package/dist/dashboard-web/style.css +160 -6
  55. package/dist/dashboard-web/terminal-replay.html +227 -0
  56. package/dist/dashboard.js +29 -12
  57. package/dist/dashboard.js.map +1 -1
  58. package/dist/i18n/en.d.ts.map +1 -1
  59. package/dist/i18n/en.js +12 -0
  60. package/dist/i18n/en.js.map +1 -1
  61. package/dist/i18n/zh.d.ts.map +1 -1
  62. package/dist/i18n/zh.js +12 -0
  63. package/dist/i18n/zh.js.map +1 -1
  64. package/dist/im/lark/card-handler.d.ts +3 -0
  65. package/dist/im/lark/card-handler.d.ts.map +1 -1
  66. package/dist/im/lark/card-handler.js +27 -1
  67. package/dist/im/lark/card-handler.js.map +1 -1
  68. package/dist/im/lark/client.d.ts +19 -2
  69. package/dist/im/lark/client.d.ts.map +1 -1
  70. package/dist/im/lark/client.js +21 -2
  71. package/dist/im/lark/client.js.map +1 -1
  72. package/dist/im/lark/workflow-card-handler.d.ts +50 -0
  73. package/dist/im/lark/workflow-card-handler.d.ts.map +1 -0
  74. package/dist/im/lark/workflow-card-handler.js +152 -0
  75. package/dist/im/lark/workflow-card-handler.js.map +1 -0
  76. package/dist/im/lark/workflow-cards.d.ts +46 -0
  77. package/dist/im/lark/workflow-cards.d.ts.map +1 -0
  78. package/dist/im/lark/workflow-cards.js +226 -0
  79. package/dist/im/lark/workflow-cards.js.map +1 -0
  80. package/dist/im/lark/workflow-progress-card.d.ts +76 -0
  81. package/dist/im/lark/workflow-progress-card.d.ts.map +1 -0
  82. package/dist/im/lark/workflow-progress-card.js +279 -0
  83. package/dist/im/lark/workflow-progress-card.js.map +1 -0
  84. package/dist/im/lark/workflow-slash-command.d.ts +92 -0
  85. package/dist/im/lark/workflow-slash-command.d.ts.map +1 -0
  86. package/dist/im/lark/workflow-slash-command.js +185 -0
  87. package/dist/im/lark/workflow-slash-command.js.map +1 -0
  88. package/dist/services/group-creator.d.ts.map +1 -1
  89. package/dist/services/group-creator.js +17 -4
  90. package/dist/services/group-creator.js.map +1 -1
  91. package/dist/services/groups-store.d.ts +11 -0
  92. package/dist/services/groups-store.d.ts.map +1 -1
  93. package/dist/services/groups-store.js +26 -0
  94. package/dist/services/groups-store.js.map +1 -1
  95. package/dist/services/jsonl-cursor.d.ts +12 -0
  96. package/dist/services/jsonl-cursor.d.ts.map +1 -0
  97. package/dist/services/jsonl-cursor.js +45 -0
  98. package/dist/services/jsonl-cursor.js.map +1 -0
  99. package/dist/services/schedule-store.d.ts +35 -0
  100. package/dist/services/schedule-store.d.ts.map +1 -1
  101. package/dist/services/schedule-store.js +108 -1
  102. package/dist/services/schedule-store.js.map +1 -1
  103. package/dist/skills/definitions.d.ts.map +1 -1
  104. package/dist/skills/definitions.js +399 -0
  105. package/dist/skills/definitions.js.map +1 -1
  106. package/dist/types.d.ts +4 -0
  107. package/dist/types.d.ts.map +1 -1
  108. package/dist/utils/cli-usage-limit.d.ts.map +1 -1
  109. package/dist/utils/cli-usage-limit.js +4 -0
  110. package/dist/utils/cli-usage-limit.js.map +1 -1
  111. package/dist/worker.js +118 -14
  112. package/dist/worker.js.map +1 -1
  113. package/dist/workflows/attempt-resume.d.ts +114 -0
  114. package/dist/workflows/attempt-resume.d.ts.map +1 -0
  115. package/dist/workflows/attempt-resume.js +385 -0
  116. package/dist/workflows/attempt-resume.js.map +1 -0
  117. package/dist/workflows/attempt-terminal.d.ts +21 -0
  118. package/dist/workflows/attempt-terminal.d.ts.map +1 -0
  119. package/dist/workflows/attempt-terminal.js +7 -0
  120. package/dist/workflows/attempt-terminal.js.map +1 -0
  121. package/dist/workflows/blob.d.ts +27 -0
  122. package/dist/workflows/blob.d.ts.map +1 -0
  123. package/dist/workflows/blob.js +39 -0
  124. package/dist/workflows/blob.js.map +1 -0
  125. package/dist/workflows/cancel-run.d.ts +45 -0
  126. package/dist/workflows/cancel-run.d.ts.map +1 -0
  127. package/dist/workflows/cancel-run.js +99 -0
  128. package/dist/workflows/cancel-run.js.map +1 -0
  129. package/dist/workflows/cancel.d.ts +111 -0
  130. package/dist/workflows/cancel.d.ts.map +1 -0
  131. package/dist/workflows/cancel.js +120 -0
  132. package/dist/workflows/cancel.js.map +1 -0
  133. package/dist/workflows/catalog.d.ts +60 -0
  134. package/dist/workflows/catalog.d.ts.map +1 -0
  135. package/dist/workflows/catalog.js +119 -0
  136. package/dist/workflows/catalog.js.map +1 -0
  137. package/dist/workflows/cold-attach.d.ts +30 -0
  138. package/dist/workflows/cold-attach.d.ts.map +1 -0
  139. package/dist/workflows/cold-attach.js +40 -0
  140. package/dist/workflows/cold-attach.js.map +1 -0
  141. package/dist/workflows/cold-scan.d.ts +21 -0
  142. package/dist/workflows/cold-scan.d.ts.map +1 -0
  143. package/dist/workflows/cold-scan.js +70 -0
  144. package/dist/workflows/cold-scan.js.map +1 -0
  145. package/dist/workflows/daemon-spawn.d.ts +117 -0
  146. package/dist/workflows/daemon-spawn.d.ts.map +1 -0
  147. package/dist/workflows/daemon-spawn.js +551 -0
  148. package/dist/workflows/daemon-spawn.js.map +1 -0
  149. package/dist/workflows/definition.d.ts +1309 -0
  150. package/dist/workflows/definition.d.ts.map +1 -0
  151. package/dist/workflows/definition.js +334 -0
  152. package/dist/workflows/definition.js.map +1 -0
  153. package/dist/workflows/effect-input.d.ts +4 -0
  154. package/dist/workflows/effect-input.d.ts.map +1 -0
  155. package/dist/workflows/effect-input.js +18 -0
  156. package/dist/workflows/effect-input.js.map +1 -0
  157. package/dist/workflows/events/append.d.ts +77 -0
  158. package/dist/workflows/events/append.d.ts.map +1 -0
  159. package/dist/workflows/events/append.js +214 -0
  160. package/dist/workflows/events/append.js.map +1 -0
  161. package/dist/workflows/events/idempotency.d.ts +77 -0
  162. package/dist/workflows/events/idempotency.d.ts.map +1 -0
  163. package/dist/workflows/events/idempotency.js +116 -0
  164. package/dist/workflows/events/idempotency.js.map +1 -0
  165. package/dist/workflows/events/index.d.ts +7 -0
  166. package/dist/workflows/events/index.d.ts.map +1 -0
  167. package/dist/workflows/events/index.js +7 -0
  168. package/dist/workflows/events/index.js.map +1 -0
  169. package/dist/workflows/events/payloads.d.ts +917 -0
  170. package/dist/workflows/events/payloads.d.ts.map +1 -0
  171. package/dist/workflows/events/payloads.js +337 -0
  172. package/dist/workflows/events/payloads.js.map +1 -0
  173. package/dist/workflows/events/replay.d.ts +238 -0
  174. package/dist/workflows/events/replay.d.ts.map +1 -0
  175. package/dist/workflows/events/replay.js +608 -0
  176. package/dist/workflows/events/replay.js.map +1 -0
  177. package/dist/workflows/events/schema.d.ts +5242 -0
  178. package/dist/workflows/events/schema.d.ts.map +1 -0
  179. package/dist/workflows/events/schema.js +295 -0
  180. package/dist/workflows/events/schema.js.map +1 -0
  181. package/dist/workflows/events/types.d.ts +34 -0
  182. package/dist/workflows/events/types.d.ts.map +1 -0
  183. package/dist/workflows/events/types.js +2 -0
  184. package/dist/workflows/events/types.js.map +1 -0
  185. package/dist/workflows/fanout.d.ts +36 -0
  186. package/dist/workflows/fanout.d.ts.map +1 -0
  187. package/dist/workflows/fanout.js +114 -0
  188. package/dist/workflows/fanout.js.map +1 -0
  189. package/dist/workflows/hostExecutors/botmux-schedule.d.ts +41 -0
  190. package/dist/workflows/hostExecutors/botmux-schedule.d.ts.map +1 -0
  191. package/dist/workflows/hostExecutors/botmux-schedule.js +121 -0
  192. package/dist/workflows/hostExecutors/botmux-schedule.js.map +1 -0
  193. package/dist/workflows/hostExecutors/feishu-im.d.ts +12 -0
  194. package/dist/workflows/hostExecutors/feishu-im.d.ts.map +1 -0
  195. package/dist/workflows/hostExecutors/feishu-im.js +49 -0
  196. package/dist/workflows/hostExecutors/feishu-im.js.map +1 -0
  197. package/dist/workflows/hostExecutors/feishu-reply.d.ts +24 -0
  198. package/dist/workflows/hostExecutors/feishu-reply.d.ts.map +1 -0
  199. package/dist/workflows/hostExecutors/feishu-reply.js +88 -0
  200. package/dist/workflows/hostExecutors/feishu-reply.js.map +1 -0
  201. package/dist/workflows/hostExecutors/feishu-send.d.ts +23 -0
  202. package/dist/workflows/hostExecutors/feishu-send.d.ts.map +1 -0
  203. package/dist/workflows/hostExecutors/feishu-send.js +124 -0
  204. package/dist/workflows/hostExecutors/feishu-send.js.map +1 -0
  205. package/dist/workflows/hostExecutors/index.d.ts +8 -0
  206. package/dist/workflows/hostExecutors/index.d.ts.map +1 -0
  207. package/dist/workflows/hostExecutors/index.js +8 -0
  208. package/dist/workflows/hostExecutors/index.js.map +1 -0
  209. package/dist/workflows/hostExecutors/protocol.d.ts +42 -0
  210. package/dist/workflows/hostExecutors/protocol.d.ts.map +1 -0
  211. package/dist/workflows/hostExecutors/protocol.js +181 -0
  212. package/dist/workflows/hostExecutors/protocol.js.map +1 -0
  213. package/dist/workflows/hostExecutors/registry.d.ts +10 -0
  214. package/dist/workflows/hostExecutors/registry.d.ts.map +1 -0
  215. package/dist/workflows/hostExecutors/registry.js +36 -0
  216. package/dist/workflows/hostExecutors/registry.js.map +1 -0
  217. package/dist/workflows/hostExecutors/types.d.ts +78 -0
  218. package/dist/workflows/hostExecutors/types.d.ts.map +1 -0
  219. package/dist/workflows/hostExecutors/types.js +2 -0
  220. package/dist/workflows/hostExecutors/types.js.map +1 -0
  221. package/dist/workflows/loader.d.ts +16 -0
  222. package/dist/workflows/loader.d.ts.map +1 -0
  223. package/dist/workflows/loader.js +56 -0
  224. package/dist/workflows/loader.js.map +1 -0
  225. package/dist/workflows/loop.d.ts +50 -0
  226. package/dist/workflows/loop.d.ts.map +1 -0
  227. package/dist/workflows/loop.js +350 -0
  228. package/dist/workflows/loop.js.map +1 -0
  229. package/dist/workflows/ops-projection.d.ts +168 -0
  230. package/dist/workflows/ops-projection.d.ts.map +1 -0
  231. package/dist/workflows/ops-projection.js +707 -0
  232. package/dist/workflows/ops-projection.js.map +1 -0
  233. package/dist/workflows/orchestrator.d.ts +107 -0
  234. package/dist/workflows/orchestrator.d.ts.map +1 -0
  235. package/dist/workflows/orchestrator.js +197 -0
  236. package/dist/workflows/orchestrator.js.map +1 -0
  237. package/dist/workflows/output-binding.d.ts +70 -0
  238. package/dist/workflows/output-binding.d.ts.map +1 -0
  239. package/dist/workflows/output-binding.js +265 -0
  240. package/dist/workflows/output-binding.js.map +1 -0
  241. package/dist/workflows/params.d.ts +61 -0
  242. package/dist/workflows/params.d.ts.map +1 -0
  243. package/dist/workflows/params.js +195 -0
  244. package/dist/workflows/params.js.map +1 -0
  245. package/dist/workflows/resume.d.ts +263 -0
  246. package/dist/workflows/resume.d.ts.map +1 -0
  247. package/dist/workflows/resume.js +808 -0
  248. package/dist/workflows/resume.js.map +1 -0
  249. package/dist/workflows/run-id.d.ts +2 -0
  250. package/dist/workflows/run-id.d.ts.map +1 -0
  251. package/dist/workflows/run-id.js +7 -0
  252. package/dist/workflows/run-id.js.map +1 -0
  253. package/dist/workflows/run-init.d.ts +48 -0
  254. package/dist/workflows/run-init.d.ts.map +1 -0
  255. package/dist/workflows/run-init.js +99 -0
  256. package/dist/workflows/run-init.js.map +1 -0
  257. package/dist/workflows/runs-dir.d.ts +4 -0
  258. package/dist/workflows/runs-dir.d.ts.map +1 -0
  259. package/dist/workflows/runs-dir.js +15 -0
  260. package/dist/workflows/runs-dir.js.map +1 -0
  261. package/dist/workflows/runtime.d.ts +211 -0
  262. package/dist/workflows/runtime.d.ts.map +1 -0
  263. package/dist/workflows/runtime.js +594 -0
  264. package/dist/workflows/runtime.js.map +1 -0
  265. package/dist/workflows/spawn-bot.d.ts +165 -0
  266. package/dist/workflows/spawn-bot.d.ts.map +1 -0
  267. package/dist/workflows/spawn-bot.js +215 -0
  268. package/dist/workflows/spawn-bot.js.map +1 -0
  269. package/dist/workflows/system.d.ts +49 -0
  270. package/dist/workflows/system.d.ts.map +1 -0
  271. package/dist/workflows/system.js +48 -0
  272. package/dist/workflows/system.js.map +1 -0
  273. package/dist/workflows/trigger-run.d.ts +70 -0
  274. package/dist/workflows/trigger-run.d.ts.map +1 -0
  275. package/dist/workflows/trigger-run.js +88 -0
  276. package/dist/workflows/trigger-run.js.map +1 -0
  277. package/dist/workflows/wait.d.ts +120 -0
  278. package/dist/workflows/wait.d.ts.map +1 -0
  279. package/dist/workflows/wait.js +181 -0
  280. package/dist/workflows/wait.js.map +1 -0
  281. package/package.json +3 -3
@@ -0,0 +1,1618 @@
1
+ // Dashboard workflow Run List / Detail pages.
2
+ //
3
+ // Polls /api/workflows/runs every 5s while visible. Each row links to
4
+ // #/workflows/<runId> — the Run Detail page (B path) hooks into the
5
+ // same hash route.
6
+ import { t } from './ui.js';
7
+ function pageHtml() {
8
+ const statusOptions = [
9
+ ['', t('workflow.filter.nonTerminal')],
10
+ ['all', t('workflow.filter.all')],
11
+ ['pending', statusLabel('pending')],
12
+ ['running', statusLabel('running')],
13
+ ['waiting', statusLabel('waiting')],
14
+ ['succeeded', statusLabel('succeeded')],
15
+ ['failed', statusLabel('failed')],
16
+ ['cancelled', statusLabel('cancelled')],
17
+ ];
18
+ return `
19
+ <nav class="wf-subnav">
20
+ <a href="#/workflows" class="active" data-i18n="workflow.subnav.runs">${escapeHtml(t('workflow.subnav.runs'))}</a>
21
+ <a href="#/workflows/catalog" data-i18n="workflow.subnav.catalog">${escapeHtml(t('workflow.subnav.catalog'))}</a>
22
+ </nav>
23
+ <form id="wf-filters" class="filters">
24
+ <input type="search" name="q" placeholder="${escapeHtml(t('workflow.searchPlaceholder'))}" />
25
+ <select name="status">
26
+ ${statusOptions.map(([value, label]) => `<option value="${escapeHtml(value)}">${escapeHtml(label)}</option>`).join('')}
27
+ </select>
28
+ <span id="wf-last-load" class="muted"></span>
29
+ </form>
30
+ <table>
31
+ <thead><tr>
32
+ <th>${escapeHtml(t('workflow.table.run'))}</th><th>${escapeHtml(t('workflow.table.workflow'))}</th><th>${escapeHtml(t('workflow.table.status'))}</th>
33
+ <th>${escapeHtml(t('workflow.table.lastSeq'))}</th><th>${escapeHtml(t('workflow.table.dangling'))}</th><th>${escapeHtml(t('workflow.table.updated'))}</th>
34
+ <th>${escapeHtml(t('workflow.table.chatApp'))}</th>
35
+ </tr></thead>
36
+ <tbody id="wf-tbody"></tbody>
37
+ </table>
38
+ `;
39
+ }
40
+ const POLL_MS = 5000;
41
+ const DETAIL_POLL_MS = 2000;
42
+ const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
43
+ function escapeHtml(s) {
44
+ return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
45
+ }
46
+ function fmtUpdated(ms) {
47
+ const d = new Date(ms);
48
+ const now = Date.now();
49
+ const diff = now - ms;
50
+ if (diff < 60_000)
51
+ return t('time.secondsAgo', { value: Math.max(1, Math.floor(diff / 1000)) });
52
+ if (diff < 3_600_000)
53
+ return t('time.minutesAgo', { value: Math.floor(diff / 60_000) });
54
+ if (diff < 86_400_000)
55
+ return t('time.hoursAgo', { value: Math.floor(diff / 3_600_000) });
56
+ return d.toISOString().slice(0, 19).replace('T', ' ');
57
+ }
58
+ function statusBadge(status) {
59
+ const cls = TERMINAL.has(status) ? 'wf-status terminal' : 'wf-status live';
60
+ return `<span class="${cls} wf-status-${escapeHtml(status)}">${escapeHtml(statusLabel(status))}</span>`;
61
+ }
62
+ function statusLabel(status) {
63
+ const key = `workflow.status.${status}`;
64
+ const label = t(key);
65
+ return label === key ? status : label;
66
+ }
67
+ export function renderWorkflowsPage(root) {
68
+ const detailMatch = location.hash.match(/^#\/workflows\/([^?#]+)(?:\?([^#]*))?$/);
69
+ if (detailMatch) {
70
+ const params = new URLSearchParams(detailMatch[2] ?? '');
71
+ return renderWorkflowDetailPage(root, decodeURIComponent(detailMatch[1]), {
72
+ focusAttemptId: params.get('attempt') ?? undefined,
73
+ });
74
+ }
75
+ return renderWorkflowListPage(root);
76
+ }
77
+ function renderWorkflowListPage(root) {
78
+ root.innerHTML = pageHtml();
79
+ const tbody = root.querySelector('#wf-tbody');
80
+ const form = root.querySelector('#wf-filters');
81
+ const lastLoadEl = root.querySelector('#wf-last-load');
82
+ let cache = [];
83
+ let timer = null;
84
+ let inflight = false;
85
+ let lastErr = null;
86
+ let disposed = false;
87
+ function applyFilters(rows) {
88
+ const f = new FormData(form);
89
+ const q = (f.get('q') ?? '').trim().toLowerCase();
90
+ if (!q)
91
+ return rows;
92
+ return rows.filter((r) => r.runId.toLowerCase().includes(q) ||
93
+ r.workflowId.toLowerCase().includes(q) ||
94
+ (r.chatId ?? '').toLowerCase().includes(q));
95
+ }
96
+ function rerender() {
97
+ const rows = applyFilters(cache);
98
+ if (rows.length === 0) {
99
+ tbody.innerHTML = `<tr><td colspan="7" class="empty">${lastErr
100
+ ? escapeHtml(t('workflow.list.failedLoad', { error: lastErr }))
101
+ : cache.length === 0
102
+ ? escapeHtml(t('workflow.list.noRuns'))
103
+ : escapeHtml(t('workflow.list.noFilterMatch'))}</td></tr>`;
104
+ return;
105
+ }
106
+ tbody.innerHTML = rows
107
+ .map((r) => {
108
+ const dangling = `${r.dEf}/${r.dAct}/${r.dWait}`;
109
+ const danglingCls = r.dEf + r.dAct + r.dWait > 0 ? 'wf-dangling has' : 'wf-dangling none';
110
+ const chatBits = [];
111
+ if (r.chatId)
112
+ chatBits.push(escapeHtml(r.chatId));
113
+ if (r.larkAppId)
114
+ chatBits.push(`<span class="muted">${escapeHtml(r.larkAppId)}</span>`);
115
+ const chatCell = chatBits.length > 0 ? chatBits.join('<br/>') : '—';
116
+ const errorSummary = renderRunErrorSummary(r);
117
+ return `<tr data-runid="${escapeHtml(r.runId)}">
118
+ <td><a href="#/workflows/${encodeURIComponent(r.runId)}"><code>${escapeHtml(r.runId)}</code></a></td>
119
+ <td>${escapeHtml(r.workflowId)}</td>
120
+ <td>${statusBadge(r.status)}${r.failedNodeId ? ` <span class="muted">(${escapeHtml(r.failedNodeId)})</span>` : ''}${errorSummary}</td>
121
+ <td>${r.lastSeq}</td>
122
+ <td class="${danglingCls}">${dangling}</td>
123
+ <td title="${escapeHtml(new Date(r.updatedAt).toISOString())}">${fmtUpdated(r.updatedAt)}</td>
124
+ <td>${chatCell}</td>
125
+ </tr>`;
126
+ })
127
+ .join('');
128
+ }
129
+ function setStatusLine() {
130
+ if (lastErr) {
131
+ lastLoadEl.textContent = t('workflow.list.error', { error: lastErr });
132
+ lastLoadEl.classList.add('error');
133
+ }
134
+ else {
135
+ lastLoadEl.textContent = t('workflow.list.loaded', {
136
+ count: cache.length,
137
+ time: new Date().toLocaleTimeString(),
138
+ });
139
+ lastLoadEl.classList.remove('error');
140
+ }
141
+ }
142
+ async function poll() {
143
+ if (disposed || inflight)
144
+ return;
145
+ if (document.hidden)
146
+ return;
147
+ inflight = true;
148
+ try {
149
+ const status = form.elements.namedItem('status')?.value ?? '';
150
+ const params = new URLSearchParams();
151
+ if (status === 'all')
152
+ params.set('all', '1');
153
+ else if (status)
154
+ params.set('status', status);
155
+ const url = '/api/workflows/runs' + (params.toString() ? `?${params}` : '');
156
+ const r = await fetch(url);
157
+ if (!r.ok) {
158
+ lastErr = `HTTP ${r.status}`;
159
+ cache = [];
160
+ }
161
+ else {
162
+ const body = (await r.json());
163
+ cache = body.runs ?? [];
164
+ lastErr = null;
165
+ }
166
+ }
167
+ catch (e) {
168
+ lastErr = e?.message ?? String(e);
169
+ cache = [];
170
+ }
171
+ finally {
172
+ inflight = false;
173
+ if (!disposed) {
174
+ rerender();
175
+ setStatusLine();
176
+ }
177
+ }
178
+ }
179
+ function scheduleNext() {
180
+ if (timer !== null)
181
+ window.clearTimeout(timer);
182
+ timer = window.setTimeout(async () => {
183
+ await poll();
184
+ if (!disposed)
185
+ scheduleNext();
186
+ }, POLL_MS);
187
+ }
188
+ function onVisibility() {
189
+ if (document.hidden)
190
+ return;
191
+ void poll();
192
+ }
193
+ form.addEventListener('input', () => {
194
+ rerender();
195
+ // Re-fetch immediately when status filter changes so the server-side
196
+ // filter applies; client-side `q` is row-local and doesn't need network.
197
+ });
198
+ form.addEventListener('change', (e) => {
199
+ if (e.target.getAttribute('name') === 'status') {
200
+ void poll();
201
+ }
202
+ });
203
+ document.addEventListener('visibilitychange', onVisibility);
204
+ // initial fetch + loop
205
+ void poll().then(() => {
206
+ if (!disposed)
207
+ scheduleNext();
208
+ });
209
+ // Cleanup hook — caller can dispose when navigating away.
210
+ return () => {
211
+ disposed = true;
212
+ if (timer !== null)
213
+ window.clearTimeout(timer);
214
+ document.removeEventListener('visibilitychange', onVisibility);
215
+ };
216
+ }
217
+ function renderWorkflowDetailPage(root, runId, opts = {}) {
218
+ root.innerHTML = `
219
+ <div class="wf-detail-head">
220
+ <a class="btn-link" href="#/workflows">${escapeHtml(t('workflow.detail.back'))}</a>
221
+ <div>
222
+ <h2><code>${escapeHtml(runId)}</code></h2>
223
+ <div id="wf-detail-subtitle" class="muted">${escapeHtml(t('workflow.detail.loading'))}</div>
224
+ </div>
225
+ <button id="wf-cancel-run" type="button" class="contrast" hidden>${escapeHtml(t('workflow.detail.cancel'))}</button>
226
+ <span id="wf-detail-refresh" class="muted"></span>
227
+ </div>
228
+ <section id="wf-detail-error" class="hint-warn" hidden></section>
229
+ <section id="wf-cancel-status" class="hint-ok" hidden></section>
230
+ <section id="wf-summary" class="wf-summary-grid"></section>
231
+ <section id="wf-dangling-panel"></section>
232
+ <section class="wf-panel">
233
+ <div class="wf-panel-title">
234
+ <h3>${escapeHtml(t('workflow.detail.parallel'))}</h3>
235
+ <span id="wf-parallel-meta" class="muted"></span>
236
+ </div>
237
+ <div id="wf-parallel-view"></div>
238
+ </section>
239
+ <section class="wf-panel">
240
+ <div class="wf-panel-title">
241
+ <h3>${escapeHtml(t('workflow.detail.nodes'))}</h3>
242
+ </div>
243
+ <div class="wf-table-scroll">
244
+ <table>
245
+ <thead><tr>
246
+ <th>${escapeHtml(t('workflow.detail.node'))}</th><th>${escapeHtml(t('workflow.detail.nodeStatus'))}</th><th>${escapeHtml(t('workflow.detail.activity'))}</th><th>${escapeHtml(t('workflow.detail.activityStatus'))}</th>
247
+ <th>${escapeHtml(t('workflow.detail.attempts'))}</th><th>${escapeHtml(t('workflow.detail.current'))}</th><th>${escapeHtml(t('workflow.detail.detail'))}</th>
248
+ </tr></thead>
249
+ <tbody id="wf-node-tbody"></tbody>
250
+ </table>
251
+ </div>
252
+ </section>
253
+ <section class="wf-panel">
254
+ <div class="wf-panel-title">
255
+ <h3>${escapeHtml(t('workflow.detail.nodeIO'))}</h3>
256
+ </div>
257
+ <div id="wf-io-list" class="wf-io-list"></div>
258
+ </section>
259
+ <section class="wf-panel">
260
+ <div class="wf-panel-title">
261
+ <h3>${escapeHtml(t('workflow.detail.timeline'))}</h3>
262
+ <button id="wf-load-older" type="button" hidden>${escapeHtml(t('workflow.detail.loadOlder'))}</button>
263
+ </div>
264
+ <div class="wf-table-scroll wf-timeline-scroll">
265
+ <table>
266
+ <thead><tr>
267
+ <th>${escapeHtml(t('workflow.detail.seq'))}</th><th>${escapeHtml(t('workflow.detail.event'))}</th><th>${escapeHtml(t('workflow.detail.actor'))}</th><th>${escapeHtml(t('workflow.detail.node'))}</th><th>${escapeHtml(t('workflow.detail.activity'))}</th><th>${escapeHtml(t('workflow.detail.error'))}</th><th>${escapeHtml(t('workflow.detail.time'))}</th>
268
+ </tr></thead>
269
+ <tbody id="wf-event-tbody"></tbody>
270
+ </table>
271
+ </div>
272
+ <div id="wf-event-meta" class="muted"></div>
273
+ </section>
274
+ `;
275
+ const subtitle = root.querySelector('#wf-detail-subtitle');
276
+ const refresh = root.querySelector('#wf-detail-refresh');
277
+ const errorEl = root.querySelector('#wf-detail-error');
278
+ const cancelStatusEl = root.querySelector('#wf-cancel-status');
279
+ const summaryEl = root.querySelector('#wf-summary');
280
+ const danglingEl = root.querySelector('#wf-dangling-panel');
281
+ const parallelEl = root.querySelector('#wf-parallel-view');
282
+ const parallelMeta = root.querySelector('#wf-parallel-meta');
283
+ const nodeTbody = root.querySelector('#wf-node-tbody');
284
+ const ioList = root.querySelector('#wf-io-list');
285
+ const timelineScroll = root.querySelector('.wf-timeline-scroll');
286
+ const eventTbody = root.querySelector('#wf-event-tbody');
287
+ const eventMeta = root.querySelector('#wf-event-meta');
288
+ const cancelBtn = root.querySelector('#wf-cancel-run');
289
+ const loadOlder = root.querySelector('#wf-load-older');
290
+ let snapshot = null;
291
+ let events = [];
292
+ let eventIds = new Set();
293
+ let oldestSeq = null;
294
+ let newestSeq = null;
295
+ let hasOlder = false;
296
+ let totalCount = 0;
297
+ let timer = null;
298
+ let disposed = false;
299
+ let inflight = false;
300
+ let canceling = false;
301
+ const openIOBlocks = new Set();
302
+ const ioScrollTops = new Map();
303
+ const approvalComments = new Map();
304
+ const approvalStatuses = new Map();
305
+ const resolvingWaits = new Set();
306
+ const resumeSessions = new Map();
307
+ const resumePending = new Set();
308
+ const resumeErrors = new Map();
309
+ // Per-card surgical update state. innerHTML-wiping the whole IO
310
+ // list every 2s polls causes terminal iframes inside to be torn off
311
+ // the document — the browser then discards the iframe's browsing
312
+ // context, and the next render reloads its src, which the user sees
313
+ // as flicker. Instead we keep each <article> stable across polls
314
+ // and only innerHTML-wipe the head/grid sub-containers; the
315
+ // terminal slot (with its iframe) is never detached. Keyed by
316
+ // activityId or nodeId so retries don't recreate the card.
317
+ const cardElements = new Map();
318
+ let timelineScrollTop = 0;
319
+ let focusAttemptId = opts.focusAttemptId;
320
+ function setError(message) {
321
+ if (!message) {
322
+ errorEl.hidden = true;
323
+ errorEl.textContent = '';
324
+ return;
325
+ }
326
+ errorEl.hidden = false;
327
+ errorEl.textContent = message;
328
+ }
329
+ function setCancelStatus(message) {
330
+ if (!message) {
331
+ cancelStatusEl.hidden = true;
332
+ cancelStatusEl.textContent = '';
333
+ return;
334
+ }
335
+ cancelStatusEl.hidden = false;
336
+ cancelStatusEl.textContent = message;
337
+ }
338
+ async function fetchSnapshot() {
339
+ const res = await fetch(`/api/workflows/runs/${encodeURIComponent(runId)}/snapshot`);
340
+ if (res.status === 404) {
341
+ throw new Error(t('workflow.detail.unknownRun'));
342
+ }
343
+ if (!res.ok)
344
+ throw new Error(t('workflow.detail.snapshotHttp', { status: res.status }));
345
+ snapshot = (await res.json());
346
+ }
347
+ async function fetchEvents(params) {
348
+ const res = await fetch(`/api/workflows/runs/${encodeURIComponent(runId)}/events?${params}`);
349
+ if (res.status === 404)
350
+ throw new Error(t('workflow.detail.unknownRun'));
351
+ if (!res.ok)
352
+ throw new Error(t('workflow.detail.eventsHttp', { status: res.status }));
353
+ return (await res.json());
354
+ }
355
+ function mergeEvents(incoming, direction) {
356
+ const fresh = incoming.filter((ev) => {
357
+ if (eventIds.has(ev.eventId))
358
+ return false;
359
+ eventIds.add(ev.eventId);
360
+ return true;
361
+ });
362
+ if (fresh.length === 0)
363
+ return;
364
+ events = direction === 'prepend' ? [...fresh, ...events] : [...events, ...fresh];
365
+ events.sort((a, b) => eventSeqFromId(a.eventId) - eventSeqFromId(b.eventId));
366
+ }
367
+ async function initialLoad() {
368
+ await fetchSnapshot();
369
+ const win = await fetchEvents(new URLSearchParams({ tail: '100' }));
370
+ events = [];
371
+ eventIds = new Set();
372
+ mergeEvents(win.events, 'append');
373
+ oldestSeq = win.oldestSeq;
374
+ newestSeq = win.newestSeq;
375
+ hasOlder = win.hasOlder;
376
+ totalCount = win.totalCount;
377
+ rerender();
378
+ }
379
+ async function poll() {
380
+ if (disposed || inflight || document.hidden)
381
+ return;
382
+ inflight = true;
383
+ try {
384
+ await fetchSnapshot();
385
+ if (newestSeq !== null) {
386
+ const win = await fetchEvents(new URLSearchParams({ afterSeq: String(newestSeq), limit: '200' }));
387
+ mergeEvents(win.events, 'append');
388
+ if (win.newestSeq !== null)
389
+ newestSeq = win.newestSeq;
390
+ if (oldestSeq === null && win.oldestSeq !== null)
391
+ oldestSeq = win.oldestSeq;
392
+ totalCount = win.totalCount;
393
+ }
394
+ else {
395
+ const win = await fetchEvents(new URLSearchParams({ tail: '1' }));
396
+ mergeEvents(win.events, 'append');
397
+ oldestSeq = win.oldestSeq;
398
+ newestSeq = win.newestSeq;
399
+ hasOlder = win.hasOlder;
400
+ totalCount = win.totalCount;
401
+ }
402
+ setError(null);
403
+ rerender();
404
+ }
405
+ catch (err) {
406
+ setError(err?.message ?? String(err));
407
+ }
408
+ finally {
409
+ inflight = false;
410
+ }
411
+ }
412
+ async function loadOlderEvents() {
413
+ if (oldestSeq === null || !hasOlder)
414
+ return;
415
+ loadOlder.disabled = true;
416
+ try {
417
+ const win = await fetchEvents(new URLSearchParams({ beforeSeq: String(oldestSeq), limit: '100' }));
418
+ mergeEvents(win.events, 'prepend');
419
+ if (win.oldestSeq !== null)
420
+ oldestSeq = win.oldestSeq;
421
+ hasOlder = win.hasOlder;
422
+ totalCount = win.totalCount;
423
+ setError(null);
424
+ rerender();
425
+ }
426
+ catch (err) {
427
+ setError(err?.message ?? String(err));
428
+ }
429
+ finally {
430
+ loadOlder.disabled = false;
431
+ }
432
+ }
433
+ async function cancelRun() {
434
+ if (!snapshot || TERMINAL.has(snapshot.run.status) || canceling)
435
+ return;
436
+ if (!snapshot.chatBinding?.larkAppId) {
437
+ setError(t('workflow.detail.cancelUnavailable', { runId }));
438
+ return;
439
+ }
440
+ const dangling = danglingSummary(snapshot);
441
+ const message = t('workflow.detail.cancelConfirm', { runId, ...dangling });
442
+ if (!window.confirm(message))
443
+ return;
444
+ canceling = true;
445
+ cancelBtn.disabled = true;
446
+ try {
447
+ const res = await fetch(`/api/workflows/runs/${encodeURIComponent(runId)}/cancel`, {
448
+ method: 'POST',
449
+ headers: { 'content-type': 'application/json' },
450
+ body: JSON.stringify({ reason: 'cancelled via dashboard' }),
451
+ });
452
+ if (res.status === 401) {
453
+ throw new Error(t('workflow.detail.writeAccessCancel'));
454
+ }
455
+ const body = (await res.json().catch(() => ({})));
456
+ if (!res.ok || !body.ok) {
457
+ throw new Error(body.hint ?? body.error ?? t('workflow.detail.cancelHttp', { status: res.status }));
458
+ }
459
+ setCancelStatus(body.pending ? t('workflow.detail.cancelPending') : null);
460
+ setError(null);
461
+ await poll();
462
+ }
463
+ catch (err) {
464
+ setError(err?.message ?? String(err));
465
+ }
466
+ finally {
467
+ canceling = false;
468
+ cancelBtn.disabled = false;
469
+ rerender();
470
+ }
471
+ }
472
+ async function startResumeSession(attemptId, activityId) {
473
+ if (resumePending.has(attemptId))
474
+ return;
475
+ resumePending.add(attemptId);
476
+ resumeErrors.delete(attemptId);
477
+ rerender();
478
+ try {
479
+ const res = await fetch(`/api/workflows/runs/${encodeURIComponent(runId)}` +
480
+ `/attempts/${encodeURIComponent(activityId)}` +
481
+ `/${encodeURIComponent(attemptId)}/resume`, {
482
+ method: 'POST',
483
+ headers: { 'content-type': 'application/json' },
484
+ body: JSON.stringify({}),
485
+ });
486
+ if (res.status === 401) {
487
+ throw new Error(t('workflow.detail.writeAccessResume'));
488
+ }
489
+ const body = (await res.json().catch(() => ({})));
490
+ if (!res.ok || !body.ok || !body.resumeId || !body.url) {
491
+ throw new Error(body.hint ?? body.message ?? body.error ?? t('workflow.detail.resumeStartFailed', { status: res.status }));
492
+ }
493
+ resumeSessions.set(attemptId, { resumeId: body.resumeId, url: body.url });
494
+ }
495
+ catch (err) {
496
+ const message = err?.message ?? String(err);
497
+ resumeErrors.set(attemptId, message);
498
+ }
499
+ finally {
500
+ resumePending.delete(attemptId);
501
+ rerender();
502
+ }
503
+ }
504
+ async function endResumeSession(attemptId, activityId) {
505
+ if (resumePending.has(attemptId))
506
+ return;
507
+ resumePending.add(attemptId);
508
+ resumeErrors.delete(attemptId);
509
+ rerender();
510
+ try {
511
+ const res = await fetch(`/api/workflows/runs/${encodeURIComponent(runId)}` +
512
+ `/attempts/${encodeURIComponent(activityId)}` +
513
+ `/${encodeURIComponent(attemptId)}/resume/end`, {
514
+ method: 'POST',
515
+ headers: { 'content-type': 'application/json' },
516
+ body: JSON.stringify({ reason: 'ended_by_dashboard' }),
517
+ });
518
+ if (res.status === 401) {
519
+ throw new Error(t('workflow.detail.writeAccessResume'));
520
+ }
521
+ const body = (await res.json().catch(() => ({})));
522
+ if (!res.ok || !body.ok) {
523
+ // `resume_not_running` after a manual server-side end is benign —
524
+ // drop the local session so the iframe reverts to replay.
525
+ if (body.error === 'resume_not_running') {
526
+ resumeSessions.delete(attemptId);
527
+ }
528
+ else {
529
+ throw new Error(body.hint ?? body.message ?? body.error ?? t('workflow.detail.resumeEndFailed', { status: res.status }));
530
+ }
531
+ }
532
+ else {
533
+ resumeSessions.delete(attemptId);
534
+ }
535
+ }
536
+ catch (err) {
537
+ const message = err?.message ?? String(err);
538
+ resumeErrors.set(attemptId, message);
539
+ }
540
+ finally {
541
+ resumePending.delete(attemptId);
542
+ rerender();
543
+ }
544
+ }
545
+ async function resolveHumanGate(attemptId, action) {
546
+ if (resolvingWaits.has(attemptId))
547
+ return;
548
+ resolvingWaits.add(attemptId);
549
+ approvalStatuses.delete(attemptId);
550
+ rerender();
551
+ try {
552
+ const comment = approvalComments.get(attemptId)?.trim() || undefined;
553
+ const res = await fetch(`/api/workflows/runs/${encodeURIComponent(runId)}/${action}`, {
554
+ method: 'POST',
555
+ headers: { 'content-type': 'application/json' },
556
+ body: JSON.stringify({ comment }),
557
+ });
558
+ if (res.status === 401) {
559
+ throw new Error(t('workflow.detail.writeAccessApproval'));
560
+ }
561
+ const body = (await res.json().catch(() => ({})));
562
+ if (!res.ok || !body.ok) {
563
+ throw new Error(body.hint ?? body.message ?? body.error ?? t('workflow.detail.actionHttp', { action, status: res.status }));
564
+ }
565
+ const label = action === 'approve' ? t('workflow.detail.approved') : t('workflow.detail.rejected');
566
+ approvalStatuses.set(attemptId, {
567
+ kind: 'ok',
568
+ text: body.alreadyTerminal
569
+ ? t('workflow.detail.alreadyTerminal', { label })
570
+ : body.pending
571
+ ? t('workflow.detail.workflowContinue', { label })
572
+ : t('workflow.detail.workflowRefreshing', { label }),
573
+ });
574
+ setError(null);
575
+ await poll();
576
+ }
577
+ catch (err) {
578
+ const message = err?.message ?? String(err);
579
+ approvalStatuses.set(attemptId, { kind: 'error', text: message });
580
+ setError(message);
581
+ }
582
+ finally {
583
+ resolvingWaits.delete(attemptId);
584
+ rerender();
585
+ }
586
+ }
587
+ function rerender() {
588
+ if (!snapshot)
589
+ return;
590
+ timelineScrollTop = timelineScroll.scrollTop;
591
+ const run = snapshot.run;
592
+ if (TERMINAL.has(run.status))
593
+ setCancelStatus(null);
594
+ subtitle.innerHTML = `${escapeHtml(run.workflowId ?? '?')} · ${statusBadge(run.status)} · lastSeq ${snapshot.lastSeq}`;
595
+ refresh.textContent = t('workflow.detail.refreshed', { time: new Date().toLocaleTimeString() });
596
+ cancelBtn.hidden = TERMINAL.has(run.status);
597
+ cancelBtn.disabled = canceling || !snapshot.chatBinding?.larkAppId;
598
+ cancelBtn.textContent = snapshot.chatBinding?.larkAppId
599
+ ? t('workflow.detail.cancel')
600
+ : t('workflow.detail.cliCancelOnly');
601
+ cancelBtn.title = snapshot.chatBinding?.larkAppId
602
+ ? t('workflow.detail.cancelTitle')
603
+ : t('workflow.detail.cliCancelTitle', { runId });
604
+ renderSummary(summaryEl, snapshot);
605
+ renderDangling(danglingEl, snapshot);
606
+ renderParallelTimeline(parallelEl, parallelMeta, snapshot, events);
607
+ renderNodeActivityRows(nodeTbody, snapshot);
608
+ const focusConsumed = renderNodeIO(ioList, snapshot, openIOBlocks, ioScrollTops, {
609
+ comments: approvalComments,
610
+ statuses: approvalStatuses,
611
+ resolving: resolvingWaits,
612
+ onResolve: resolveHumanGate,
613
+ }, {
614
+ sessions: resumeSessions,
615
+ pending: resumePending,
616
+ errors: resumeErrors,
617
+ onStart: startResumeSession,
618
+ onEnd: endResumeSession,
619
+ }, focusAttemptId, cardElements);
620
+ if (focusConsumed)
621
+ focusAttemptId = undefined;
622
+ renderEvents(eventTbody, events);
623
+ timelineScroll.scrollTop = timelineScrollTop;
624
+ loadOlder.hidden = !hasOlder;
625
+ eventMeta.textContent = t('workflow.detail.eventsLoaded', { loaded: events.length, total: totalCount });
626
+ }
627
+ function scheduleNext() {
628
+ if (timer !== null)
629
+ window.clearTimeout(timer);
630
+ if (snapshot && TERMINAL.has(snapshot.run.status)) {
631
+ timer = null;
632
+ return;
633
+ }
634
+ timer = window.setTimeout(async () => {
635
+ await poll();
636
+ if (!disposed)
637
+ scheduleNext();
638
+ }, DETAIL_POLL_MS);
639
+ }
640
+ function onVisibility() {
641
+ if (document.hidden)
642
+ return;
643
+ void poll().then(() => {
644
+ if (!disposed && timer === null)
645
+ scheduleNext();
646
+ });
647
+ }
648
+ loadOlder.addEventListener('click', () => void loadOlderEvents());
649
+ cancelBtn.addEventListener('click', () => void cancelRun());
650
+ document.addEventListener('visibilitychange', onVisibility);
651
+ void initialLoad()
652
+ .then(() => {
653
+ setError(null);
654
+ if (!disposed)
655
+ scheduleNext();
656
+ })
657
+ .catch((err) => {
658
+ setError(err?.message ?? String(err));
659
+ subtitle.textContent = t('workflow.detail.loadFailed');
660
+ });
661
+ return () => {
662
+ disposed = true;
663
+ if (timer !== null)
664
+ window.clearTimeout(timer);
665
+ document.removeEventListener('visibilitychange', onVisibility);
666
+ };
667
+ }
668
+ function renderSummary(el, snap) {
669
+ const r = snap.run;
670
+ const items = [
671
+ [t('workflow.summary.workflow'), escapeHtml(r.workflowId ?? '?')],
672
+ [t('workflow.summary.status'), statusBadge(r.status)],
673
+ [t('workflow.summary.lastSeq'), String(snap.lastSeq)],
674
+ [t('workflow.summary.updated'), escapeHtml(new Date(snap.updatedAt).toLocaleString())],
675
+ [t('workflow.summary.revision'), escapeHtml(short(r.revisionId))],
676
+ [t('workflow.summary.initiator'), escapeHtml(r.initiator ?? '-')],
677
+ ];
678
+ if (r.failedNodeId)
679
+ items.push([t('workflow.summary.failedNode'), escapeHtml(r.failedNodeId)]);
680
+ if (r.cancelOriginEventId)
681
+ items.push([t('workflow.summary.cancelOrigin'), escapeHtml(r.cancelOriginEventId)]);
682
+ if (snap.chatBinding) {
683
+ items.push([t('workflow.summary.chat'), `<code>${escapeHtml(snap.chatBinding.chatId)}</code>`]);
684
+ items.push([t('workflow.summary.app'), `<code>${escapeHtml(snap.chatBinding.larkAppId)}</code>`]);
685
+ }
686
+ el.innerHTML = items
687
+ .map(([label, value]) => `<div class="wf-summary-item"><span>${label}</span><strong>${value}</strong></div>`)
688
+ .join('');
689
+ }
690
+ function renderRunErrorSummary(run) {
691
+ if (!run.errorCode)
692
+ return '';
693
+ const message = run.errorMessage ? ` — ${shortText(run.errorMessage, 96)}` : '';
694
+ return `<div class="wf-run-error">
695
+ <span class="muted error">${escapeHtml(run.errorCode)}</span>${escapeHtml(message)}
696
+ </div>`;
697
+ }
698
+ function danglingSummary(snap) {
699
+ const d = snap.dangling;
700
+ return {
701
+ total: new Set([
702
+ ...d.activities,
703
+ ...d.effectAttempted,
704
+ ...d.waits,
705
+ ...d.cancels,
706
+ ]).size,
707
+ effects: d.effectAttempted.length,
708
+ activities: d.activities.length,
709
+ waits: d.waits.length,
710
+ cancels: d.cancels.length,
711
+ };
712
+ }
713
+ function renderDangling(el, snap) {
714
+ const d = snap.dangling;
715
+ const groups = [
716
+ [t('workflow.dangling.activities'), d.activities],
717
+ [t('workflow.dangling.effects'), d.effectAttempted],
718
+ [t('workflow.dangling.waits'), d.waits],
719
+ [t('workflow.dangling.cancels'), d.cancels],
720
+ ];
721
+ const total = new Set(groups.flatMap(([, xs]) => xs)).size;
722
+ el.className = total > 0 ? 'wf-panel wf-dangling-panel has' : 'wf-panel wf-dangling-panel';
723
+ if (total === 0) {
724
+ el.innerHTML = `<div class="wf-panel-title"><h3>${escapeHtml(t('workflow.detail.dangling'))}</h3></div><div class="muted">${escapeHtml(t('workflow.detail.noDangling'))}</div>`;
725
+ return;
726
+ }
727
+ el.innerHTML = `<div class="wf-panel-title"><h3>${escapeHtml(t('workflow.detail.dangling'))}</h3><span class="wf-dangling has">${total}</span></div>
728
+ <div class="wf-dangling-grid">
729
+ ${groups
730
+ .map(([name, xs]) => `<div><strong>${name}</strong>${xs.length === 0
731
+ ? `<div class="muted">${escapeHtml(t('workflow.detail.none'))}</div>`
732
+ : `<ul>${xs.map((x) => `<li><code>${escapeHtml(x)}</code></li>`).join('')}</ul>`}</div>`)
733
+ .join('')}
734
+ </div>`;
735
+ }
736
+ function renderParallelTimeline(el, metaEl, snap, events) {
737
+ const items = buildAttemptTimeline(events, snap);
738
+ if (items.length === 0) {
739
+ metaEl.textContent = '';
740
+ el.innerHTML = `<div class="empty">${escapeHtml(t('workflow.detail.noParallelData'))}</div>`;
741
+ return;
742
+ }
743
+ const now = Date.now();
744
+ const start = Math.min(...items.map((item) => item.startedAt));
745
+ const end = Math.max(...items.map((item) => item.endedAt ?? now), start + 1000);
746
+ const duration = Math.max(1, end - start);
747
+ const maxParallel = maxConcurrency(items, now);
748
+ const running = items.filter((item) => !item.endedAt && (item.status === 'running' || item.status === 'effectAttempting')).length;
749
+ metaEl.textContent = t('workflow.detail.parallelMeta', {
750
+ count: items.length,
751
+ max: maxParallel,
752
+ running,
753
+ });
754
+ const rows = items
755
+ .sort((a, b) => a.startedAt - b.startedAt || a.activityId.localeCompare(b.activityId))
756
+ .map((item) => renderParallelRow(item, start, duration, now))
757
+ .join('');
758
+ el.innerHTML = `<div class="wf-parallel-axis">
759
+ <span title="${escapeHtml(new Date(start).toISOString())}">${escapeHtml(formatClock(start))}</span>
760
+ <span title="${escapeHtml(new Date(end).toISOString())}">${escapeHtml(formatClock(end))}</span>
761
+ </div>
762
+ <div class="wf-parallel-list">${rows}</div>`;
763
+ }
764
+ function buildAttemptTimeline(events, snap) {
765
+ const byAttempt = new Map();
766
+ const activityOwner = new Map(snap.activities.map((activity) => [activity.activityId, activity.ownerNodeId]));
767
+ for (const event of [...events].sort((a, b) => eventSeqFromId(a.eventId) - eventSeqFromId(b.eventId))) {
768
+ const payload = payloadRecord(event);
769
+ if (!payload)
770
+ continue;
771
+ const activityId = typeof payload.activityId === 'string' ? payload.activityId : undefined;
772
+ const attemptId = typeof payload.attemptId === 'string' ? payload.attemptId : undefined;
773
+ if (!activityId || !attemptId)
774
+ continue;
775
+ let item = byAttempt.get(attemptId);
776
+ if (event.type === 'attemptCreated') {
777
+ const attemptNumber = typeof payload.attemptNumber === 'number' ? payload.attemptNumber : undefined;
778
+ const nodeId = typeof payload.nodeId === 'string' ? payload.nodeId : activityOwner.get(activityId);
779
+ item = {
780
+ nodeId,
781
+ activityId,
782
+ attemptId,
783
+ attemptNumber,
784
+ status: 'pending',
785
+ startedAt: event.timestamp,
786
+ };
787
+ byAttempt.set(attemptId, item);
788
+ continue;
789
+ }
790
+ if (!item) {
791
+ item = {
792
+ nodeId: activityOwner.get(activityId),
793
+ activityId,
794
+ attemptId,
795
+ status: 'pending',
796
+ startedAt: event.timestamp,
797
+ };
798
+ byAttempt.set(attemptId, item);
799
+ }
800
+ if (event.type === 'activityRunning') {
801
+ item.status = 'running';
802
+ item.runningAt = event.timestamp;
803
+ }
804
+ else if (event.type === 'effectAttempted') {
805
+ item.status = 'effectAttempting';
806
+ }
807
+ else if (event.type === 'activityWaiting' || event.type === 'waitCreated') {
808
+ item.status = 'waiting';
809
+ }
810
+ else if (isTerminalActivityEvent(event.type)) {
811
+ item.status = terminalStatusForEvent(event.type);
812
+ item.endedAt = event.timestamp;
813
+ item.endType = event.type;
814
+ }
815
+ }
816
+ return [...byAttempt.values()];
817
+ }
818
+ function renderParallelRow(item, start, duration, now) {
819
+ const end = item.endedAt ?? now;
820
+ const left = clamp(((item.startedAt - start) / duration) * 100, 0, 100);
821
+ const width = clamp(((Math.max(end, item.startedAt + 1) - item.startedAt) / duration) * 100, 0.7, 100 - left);
822
+ const label = item.nodeId ?? item.activityId;
823
+ const attempt = item.attemptNumber !== undefined ? `#${item.attemptNumber}` : short(item.attemptId);
824
+ const title = [
825
+ `${label} ${item.status}`,
826
+ `${new Date(item.startedAt).toISOString()} → ${item.endedAt ? new Date(item.endedAt).toISOString() : t('workflow.detail.parallelNow')}`,
827
+ item.endType ? `end: ${item.endType}` : undefined,
828
+ ].filter(Boolean).join('\n');
829
+ return `<div class="wf-parallel-row">
830
+ <div class="wf-parallel-label">
831
+ <code>${escapeHtml(label)}</code>
832
+ <span class="muted">${escapeHtml(item.activityId)} · ${escapeHtml(attempt)}</span>
833
+ </div>
834
+ <div class="wf-parallel-track">
835
+ <div class="wf-parallel-bar wf-parallel-${escapeHtml(item.status)}" style="left:${left.toFixed(3)}%;width:${width.toFixed(3)}%;" title="${escapeHtml(title)}">
836
+ <span>${escapeHtml(statusLabel(item.status))}</span>
837
+ </div>
838
+ </div>
839
+ </div>`;
840
+ }
841
+ function maxConcurrency(items, now) {
842
+ const points = [];
843
+ for (const item of items) {
844
+ points.push({ time: item.startedAt, delta: 1 });
845
+ points.push({ time: item.endedAt ?? now, delta: -1 });
846
+ }
847
+ points.sort((a, b) => a.time - b.time || b.delta - a.delta);
848
+ let current = 0;
849
+ let max = 0;
850
+ for (const point of points) {
851
+ current += point.delta;
852
+ max = Math.max(max, current);
853
+ }
854
+ return max;
855
+ }
856
+ function isTerminalActivityEvent(type) {
857
+ return type === 'activitySucceeded' ||
858
+ type === 'activityFailed' ||
859
+ type === 'activityTimedOut' ||
860
+ type === 'activityCanceled';
861
+ }
862
+ function terminalStatusForEvent(type) {
863
+ if (type === 'activitySucceeded')
864
+ return 'succeeded';
865
+ if (type === 'activityCanceled')
866
+ return 'cancelled';
867
+ if (type === 'activityTimedOut')
868
+ return 'timedOut';
869
+ return 'failed';
870
+ }
871
+ function renderNodeActivityRows(tbody, snap) {
872
+ const byId = new Map(snap.activities.map((a) => [a.activityId, a]));
873
+ const used = new Set();
874
+ const rows = [];
875
+ for (const node of snap.nodes) {
876
+ const activity = (node.activityId ? byId.get(node.activityId) : undefined) ??
877
+ snap.activities.find((a) => a.ownerNodeId === node.nodeId);
878
+ if (activity)
879
+ used.add(activity.activityId);
880
+ rows.push(renderNodeActivityRow(node, activity));
881
+ }
882
+ for (const activity of snap.activities) {
883
+ if (used.has(activity.activityId))
884
+ continue;
885
+ rows.push(renderNodeActivityRow(undefined, activity));
886
+ }
887
+ tbody.innerHTML = rows.length > 0
888
+ ? rows.join('')
889
+ : `<tr><td colspan="7" class="empty">${escapeHtml(t('workflow.detail.noNodes'))}</td></tr>`;
890
+ }
891
+ function renderNodeActivityRow(node, activity) {
892
+ const latest = activity?.attempts[activity.attempts.length - 1];
893
+ return `<tr>
894
+ <td>${node ? `<code>${escapeHtml(node.nodeId)}</code>` : '<span class="muted">-</span>'}</td>
895
+ <td>${node ? statusBadge(node.status) : '<span class="muted">-</span>'}</td>
896
+ <td>${activity ? `<code>${escapeHtml(activity.activityId)}</code>` : '<span class="muted">-</span>'}</td>
897
+ <td>${activity ? statusBadge(activity.status) : '<span class="muted">-</span>'}</td>
898
+ <td>${activity?.attempts.length ?? 0}</td>
899
+ <td>${latest ? `<code>${escapeHtml(latest.attemptId)}</code>` : '<span class="muted">-</span>'}</td>
900
+ <td>${latest ? renderAttemptDetail(latest) : `<span class="muted">${escapeHtml(t('workflow.detail.idle'))}</span>`}</td>
901
+ </tr>`;
902
+ }
903
+ function renderNodeIO(el, snap, openBlocks, scrollTops, approval, resume, focusAttemptId, cardElements) {
904
+ syncIOBlockState(el, openBlocks, scrollTops);
905
+ syncApprovalComments(el, approval.comments);
906
+ const focusHasTerminal = !!(focusAttemptId && snap.attemptIO?.[focusAttemptId]?.terminal);
907
+ if (focusHasTerminal && focusAttemptId) {
908
+ openBlocks.add(ioBlockKey(focusAttemptId, t('workflow.detail.liveTerminal')));
909
+ }
910
+ // Per-card surgical update — we never `innerHTML=`-wipe the parent
911
+ // `el` because doing so detaches every terminal iframe from the
912
+ // document, which in all major browsers discards the iframe's
913
+ // browsing context (the WebSocket included) and the next paint
914
+ // reloads the iframe → terminal flickers. Instead we keep each
915
+ // <article> stable across polls and only innerHTML-wipe the
916
+ // per-card head/grid sub-containers; the terminal-slot div (whose
917
+ // iframe holds the live WebSocket) is never detached.
918
+ const descriptors = buildCardDescriptors(snap);
919
+ const seenKeys = new Set();
920
+ if (cardElements) {
921
+ for (const desc of descriptors) {
922
+ seenKeys.add(desc.key);
923
+ let entry = cardElements.get(desc.key);
924
+ if (!entry) {
925
+ entry = createCardEntry(desc.key);
926
+ cardElements.set(desc.key, entry);
927
+ el.appendChild(entry.article);
928
+ }
929
+ updateCardEntry(entry, desc, openBlocks, approval, resume, focusAttemptId);
930
+ }
931
+ for (const [key, entry] of Array.from(cardElements)) {
932
+ if (seenKeys.has(key))
933
+ continue;
934
+ entry.article.remove();
935
+ cardElements.delete(key);
936
+ }
937
+ if (descriptors.length === 0) {
938
+ // Empty-state placeholder. Use a sentinel child rather than
939
+ // wiping innerHTML so any (hypothetically lingering) terminal
940
+ // iframe wouldn't be detached. Cards are already cleared via
941
+ // the loop above, so it's safe to add the placeholder here.
942
+ if (!el.querySelector('.wf-io-empty-placeholder')) {
943
+ const empty = document.createElement('div');
944
+ empty.className = 'empty wf-io-empty-placeholder';
945
+ empty.textContent = t('workflow.detail.noNodeIO');
946
+ el.appendChild(empty);
947
+ }
948
+ }
949
+ else {
950
+ el.querySelector('.wf-io-empty-placeholder')?.remove();
951
+ }
952
+ }
953
+ else {
954
+ // No cache provided (unit tests etc.): fall back to bulk innerHTML.
955
+ const cards = [];
956
+ for (const desc of descriptors) {
957
+ cards.push(renderIOCardHtml(desc, openBlocks, approval, resume, focusAttemptId));
958
+ }
959
+ el.innerHTML = cards.length > 0
960
+ ? cards.join('')
961
+ : `<div class="empty">${escapeHtml(t('workflow.detail.noNodeIO'))}</div>`;
962
+ }
963
+ restoreIOBlockScroll(el, scrollTops);
964
+ const focusVisible = scrollFocusedAttemptIntoView(el, focusAttemptId);
965
+ attachIOBlockToggleTracking(el, openBlocks);
966
+ attachIOBlockScrollTracking(el, scrollTops);
967
+ attachApprovalControls(el, approval);
968
+ attachResumeControls(el, resume);
969
+ // Keep the deeplink focus active until terminal.json is visible. The
970
+ // progress card link can appear at activityRunning, a little before the
971
+ // worker emits ready and writes the terminal sidecar; a later poll should
972
+ // still auto-open the terminal block when it arrives.
973
+ return focusVisible && focusHasTerminal;
974
+ }
975
+ function buildCardDescriptors(snap) {
976
+ const byId = new Map(snap.activities.map((a) => [a.activityId, a]));
977
+ const used = new Set();
978
+ const out = [];
979
+ for (const node of snap.nodes) {
980
+ const activity = (node.activityId ? byId.get(node.activityId) : undefined) ??
981
+ snap.activities.find((a) => a.ownerNodeId === node.nodeId);
982
+ if (!activity) {
983
+ out.push({ key: `node:${node.nodeId}`, node });
984
+ continue;
985
+ }
986
+ used.add(activity.activityId);
987
+ out.push({
988
+ key: `activity:${activity.activityId}`,
989
+ node,
990
+ activity,
991
+ io: snap.attemptIO?.[latestAttempt(activity)?.attemptId ?? ''],
992
+ });
993
+ }
994
+ for (const activity of snap.activities) {
995
+ if (used.has(activity.activityId))
996
+ continue;
997
+ out.push({
998
+ key: `activity:${activity.activityId}`,
999
+ activity,
1000
+ io: snap.attemptIO?.[latestAttempt(activity)?.attemptId ?? ''],
1001
+ });
1002
+ }
1003
+ return out;
1004
+ }
1005
+ function createCardEntry(key) {
1006
+ const article = document.createElement('article');
1007
+ article.className = 'wf-io-card';
1008
+ article.dataset.wfCardKey = key;
1009
+ const head = document.createElement('div');
1010
+ head.className = 'wf-io-card-head';
1011
+ const terminalSlot = document.createElement('div');
1012
+ terminalSlot.className = 'wf-io-terminal-slot';
1013
+ const grid = document.createElement('div');
1014
+ grid.className = 'wf-io-grid';
1015
+ article.appendChild(head);
1016
+ article.appendChild(terminalSlot);
1017
+ article.appendChild(grid);
1018
+ return { article, head, terminalSlot, grid, currentTerminalUrl: null };
1019
+ }
1020
+ function updateCardEntry(entry, desc, openBlocks, approval, resume, focusAttemptId) {
1021
+ const attempt = latestAttempt(desc.activity);
1022
+ const title = desc.node?.nodeId ?? desc.activity?.ownerNodeId ?? desc.activity?.activityId ?? 'unknown';
1023
+ const focusMatch = !!(attempt && attempt.attemptId === focusAttemptId);
1024
+ entry.article.classList.toggle('is-focused', focusMatch);
1025
+ if (attempt) {
1026
+ entry.article.dataset.wfAttemptCard = attempt.attemptId;
1027
+ }
1028
+ else {
1029
+ delete entry.article.dataset.wfAttemptCard;
1030
+ }
1031
+ // Head: header (title + status badges) + meta (attemptId) + approval controls.
1032
+ // These all re-render every poll; no live state survives in them.
1033
+ const controls = renderApprovalControls(attempt, approval);
1034
+ entry.head.innerHTML = `
1035
+ <header>
1036
+ <div>
1037
+ <strong><code>${escapeHtml(title)}</code></strong>
1038
+ <span class="muted">${desc.activity ? escapeHtml(desc.activity.activityId) : escapeHtml(t('workflow.detail.notDispatched'))}</span>
1039
+ </div>
1040
+ <div>${desc.node ? statusBadge(desc.node.status) : ''} ${desc.activity ? statusBadge(desc.activity.status) : ''}</div>
1041
+ </header>
1042
+ <div class="wf-io-meta">
1043
+ ${attempt ? `${escapeHtml(t('workflow.detail.attempt'))} <code>${escapeHtml(attempt.attemptId)}</code>` : escapeHtml(t('workflow.detail.noAttempt'))}
1044
+ </div>
1045
+ ${controls}
1046
+ `;
1047
+ // Terminal slot: only re-render when the URL changes (worker
1048
+ // restart / attempt-id flip / live → replay handoff / resume start
1049
+ // or end). The iframe DOM node stays live through every same-URL
1050
+ // poll so its WebSocket (live / resume) / streaming xterm parser
1051
+ // (replay) isn't torn down.
1052
+ const desiredSurface = computeTerminalUrl(attempt, desc.activity, desc.io?.terminal, resume);
1053
+ const desiredUrl = desiredSurface?.url ?? null;
1054
+ if (desiredUrl !== entry.currentTerminalUrl) {
1055
+ if (desiredSurface === null) {
1056
+ entry.terminalSlot.innerHTML = '';
1057
+ }
1058
+ else {
1059
+ entry.terminalSlot.innerHTML = renderTerminalBlockHtml(desc.key, attempt, desc.activity, desc.io?.terminal, desiredSurface, openBlocks, resume);
1060
+ }
1061
+ entry.currentTerminalUrl = desiredUrl;
1062
+ }
1063
+ else if (desiredSurface !== null && desc.io?.terminal) {
1064
+ // Same URL — refresh only the summary meta (status badge text)
1065
+ // and resume action button state (pending / error) inline. The
1066
+ // iframe itself is left alone to preserve its browsing context.
1067
+ const summary = entry.terminalSlot.querySelector('details.wf-terminal-block > summary');
1068
+ if (summary) {
1069
+ const label = terminalSurfaceLabel(desiredSurface.kind);
1070
+ summary.innerHTML = `${escapeHtml(label)} ${terminalMeta(attempt, desc.io.terminal)}`;
1071
+ }
1072
+ if (attempt) {
1073
+ refreshResumeActions(entry.terminalSlot, attempt, desc.activity, desc.io.terminal, desiredSurface, resume);
1074
+ }
1075
+ }
1076
+ // Grid: previews other than terminal. Wipe + rebuild — no live
1077
+ // resources here so flicker doesn't matter.
1078
+ const keyPrefix = attempt?.attemptId ?? desc.activity?.activityId ?? desc.node?.nodeId ?? 'unknown';
1079
+ entry.grid.innerHTML = `
1080
+ ${renderPreviewBlock(keyPrefix, t('workflow.detail.authoredInput'), desc.io?.input, openBlocks)}
1081
+ ${renderPreviewBlock(keyPrefix, t('workflow.detail.resolvedInput'), desc.io?.resolvedInput, openBlocks)}
1082
+ ${renderPreviewBlock(keyPrefix, t('workflow.detail.output'), desc.io?.output, openBlocks)}
1083
+ ${renderPreviewBlock(keyPrefix, t('workflow.detail.executionLog'), desc.io?.log, openBlocks)}
1084
+ ${desc.io?.waitPrompt ? renderPreviewBlock(keyPrefix, t('workflow.detail.waitPrompt'), desc.io.waitPrompt, openBlocks) : ''}
1085
+ `;
1086
+ }
1087
+ function computeTerminalUrl(attempt, activity, terminal, resume) {
1088
+ if (!terminal)
1089
+ return null;
1090
+ if (terminal.error)
1091
+ return null;
1092
+ if (isLiveTerminal(attempt, terminal)) {
1093
+ return { kind: 'live', url: terminalReadOnlyUrl(terminal) };
1094
+ }
1095
+ if (!attempt || !activity)
1096
+ return null;
1097
+ if (!isReplayableTerminal(attempt, terminal))
1098
+ return null;
1099
+ const runId = currentRunIdFromHash();
1100
+ if (!runId)
1101
+ return null;
1102
+ // Active resume session for this attempt swaps the iframe to the
1103
+ // resume worker's write-token PTY URL — keep the download link
1104
+ // pointing at the original terminal.log so users can still grab the
1105
+ // pre-resume transcript.
1106
+ const active = resume?.sessions.get(attempt.attemptId);
1107
+ if (active) {
1108
+ return {
1109
+ kind: 'resume',
1110
+ url: active.url,
1111
+ resumeId: active.resumeId,
1112
+ downloadUrl: terminalLogDownloadUrl(runId, activity.activityId, attempt.attemptId),
1113
+ };
1114
+ }
1115
+ return {
1116
+ kind: 'replay',
1117
+ url: terminalReplayPageUrl(runId, activity.activityId, attempt.attemptId, !!terminal.hasPtyLog),
1118
+ downloadUrl: terminalLogDownloadUrl(runId, activity.activityId, attempt.attemptId),
1119
+ };
1120
+ }
1121
+ function renderTerminalBlockHtml(keyPrefix, attempt, activity, terminal, surface, openBlocks, resume) {
1122
+ if (!terminal)
1123
+ return '';
1124
+ const label = terminalSurfaceLabel(surface.kind);
1125
+ const key = ioBlockKey(keyPrefix, label);
1126
+ const meta = terminalMeta(attempt, terminal);
1127
+ const openInTabLabel = terminalOpenInTabLabel(surface.kind);
1128
+ const downloadAction = (surface.kind === 'replay' || surface.kind === 'resume')
1129
+ ? `<a class="btn-link" href="${escapeHtml(surface.downloadUrl)}" download>${escapeHtml(t('workflow.detail.downloadFullLog'))}</a>`
1130
+ : '';
1131
+ const resumeAction = attempt
1132
+ ? renderResumeButtonHtml(attempt, activity, terminal, surface, resume)
1133
+ : '';
1134
+ const resumeStatus = attempt ? renderResumeStatusHtml(attempt.attemptId, resume) : '';
1135
+ return `<details class="wf-io-block wf-terminal-block" data-io-key="${escapeHtml(key)}"${openBlocks.has(key) ? ' open' : ''}>
1136
+ <summary>${escapeHtml(label)} ${meta}</summary>
1137
+ <div class="wf-terminal-actions">
1138
+ <a class="btn-link" href="${escapeHtml(surface.url)}" target="_blank" rel="noreferrer">${escapeHtml(openInTabLabel)}</a>
1139
+ ${downloadAction}
1140
+ ${resumeAction}
1141
+ </div>
1142
+ ${resumeStatus}
1143
+ <iframe class="wf-terminal-frame" src="${escapeHtml(surface.url)}" title="${escapeHtml(label)}" loading="lazy"></iframe>
1144
+ </details>`;
1145
+ }
1146
+ function terminalSurfaceLabel(kind) {
1147
+ if (kind === 'live')
1148
+ return t('workflow.detail.liveTerminal');
1149
+ if (kind === 'resume')
1150
+ return t('workflow.detail.terminalResume');
1151
+ return t('workflow.detail.terminalReplay');
1152
+ }
1153
+ function terminalOpenInTabLabel(kind) {
1154
+ if (kind === 'live')
1155
+ return t('workflow.detail.openTerminalNewTab');
1156
+ if (kind === 'resume')
1157
+ return t('workflow.detail.openResumeNewTab');
1158
+ return t('workflow.detail.openReplayNewTab');
1159
+ }
1160
+ /**
1161
+ * CLI capability split — mirrors `src/workflows/attempt-resume.ts`:
1162
+ * - REQUIRES native cliSessionId: adapter has no botmux-sessionId fallback,
1163
+ * so resume can't proceed unless `cli_session_id` IPC was captured.
1164
+ * - USES botmux sessionId: adapter resumes by the original attempt sessionId
1165
+ * (now passed through as `originalSessionId` in worker init by codex's
1166
+ * server-side fix). cliSessionId is consumed when present but optional.
1167
+ * - Anything else (opencode / gemini / ...) is `resume_unsupported_cli`
1168
+ * server-side.
1169
+ */
1170
+ const RESUME_REQUIRES_CLI_SESSION_ID = new Set(['antigravity', 'cursor']);
1171
+ const RESUME_USES_SESSION_ID = new Set(['aiden', 'coco', 'claude-code', 'codex']);
1172
+ function isResumeCapableCli(cliId) {
1173
+ return !!cliId && (RESUME_USES_SESSION_ID.has(cliId) || RESUME_REQUIRES_CLI_SESSION_ID.has(cliId));
1174
+ }
1175
+ function cliRequiresNativeSessionId(cliId) {
1176
+ return !!cliId && RESUME_REQUIRES_CLI_SESSION_ID.has(cliId);
1177
+ }
1178
+ function renderResumeButtonHtml(attempt, activity, terminal, surface, resume) {
1179
+ if (!resume)
1180
+ return '';
1181
+ if (surface.kind === 'live')
1182
+ return '';
1183
+ if (!activity)
1184
+ return '';
1185
+ const active = surface.kind === 'resume';
1186
+ const pending = resume.pending.has(attempt.attemptId);
1187
+ const dataAttrs = `data-wf-resume-attempt="${escapeHtml(attempt.attemptId)}" ` +
1188
+ `data-wf-resume-activity="${escapeHtml(activity.activityId)}"`;
1189
+ // data-wf-resume-button is the stable marker used by refreshResumeActions
1190
+ // to locate any prior resume button in-place — including disabled variants
1191
+ // that don't carry attempt/activity ids — so re-renders replace instead of
1192
+ // appending and creating duplicates each poll.
1193
+ if (active) {
1194
+ return `<button type="button" class="btn-link" data-wf-resume-button="1" data-wf-resume-action="end" ${dataAttrs}${pending ? ' disabled' : ''}>${escapeHtml(pending ? t('workflow.detail.resumeEnding') : t('workflow.detail.endResumeSession'))}</button>`;
1195
+ }
1196
+ if (!isResumeCapableCli(terminal.cliId)) {
1197
+ return `<button type="button" class="btn-link" data-wf-resume-button="1" disabled title="${escapeHtml(t('workflow.detail.resumeUnsupportedCli', { cliId: terminal.cliId ?? '?' }))}">${escapeHtml(t('workflow.detail.resumeSession'))}</button>`;
1198
+ }
1199
+ // Only CLIs that have NO botmux-sessionId fallback (antigravity, cursor)
1200
+ // hard-require cliSessionId — aiden / coco / claude-code / codex resume
1201
+ // through the original attempt sessionId on the server side now.
1202
+ if (cliRequiresNativeSessionId(terminal.cliId) && !terminal.cliSessionId) {
1203
+ return `<button type="button" class="btn-link" data-wf-resume-button="1" disabled title="${escapeHtml(t('workflow.detail.resumeMissingCliSession'))}">${escapeHtml(t('workflow.detail.resumeSession'))}</button>`;
1204
+ }
1205
+ return `<button type="button" class="btn-link" data-wf-resume-button="1" data-wf-resume-action="start" ${dataAttrs}${pending ? ' disabled' : ''}>${escapeHtml(pending ? t('workflow.detail.resumeStarting') : t('workflow.detail.resumeSession'))}</button>`;
1206
+ }
1207
+ function renderResumeStatusHtml(attemptId, resume) {
1208
+ if (!resume)
1209
+ return '';
1210
+ const err = resume.errors.get(attemptId);
1211
+ if (err) {
1212
+ return `<div class="hint-warn wf-resume-status" data-wf-resume-status="${escapeHtml(attemptId)}">${escapeHtml(err)}</div>`;
1213
+ }
1214
+ return '';
1215
+ }
1216
+ /**
1217
+ * Legacy bulk renderer kept for the no-cache fallback path (tests).
1218
+ * The live dashboard always goes through the surgical update path.
1219
+ */
1220
+ function renderIOCardHtml(desc, openBlocks, approval, resume, focusAttemptId) {
1221
+ const attempt = latestAttempt(desc.activity);
1222
+ const title = desc.node?.nodeId ?? desc.activity?.ownerNodeId ?? desc.activity?.activityId ?? 'unknown';
1223
+ const keyPrefix = attempt?.attemptId ?? desc.activity?.activityId ?? desc.node?.nodeId ?? 'unknown';
1224
+ const controls = renderApprovalControls(attempt, approval);
1225
+ const focusClass = attempt?.attemptId === focusAttemptId ? ' is-focused' : '';
1226
+ const attemptAttr = attempt ? ` data-wf-attempt-card="${escapeHtml(attempt.attemptId)}"` : '';
1227
+ const terminalSurface = computeTerminalUrl(attempt, desc.activity, desc.io?.terminal, resume);
1228
+ const terminalHtml = terminalSurface
1229
+ ? renderTerminalBlockHtml(keyPrefix, attempt, desc.activity, desc.io?.terminal, terminalSurface, openBlocks, resume)
1230
+ : '';
1231
+ return `<article class="wf-io-card${focusClass}" data-wf-card-key="${escapeHtml(desc.key)}"${attemptAttr}>
1232
+ <div class="wf-io-card-head">
1233
+ <header>
1234
+ <div>
1235
+ <strong><code>${escapeHtml(title)}</code></strong>
1236
+ <span class="muted">${desc.activity ? escapeHtml(desc.activity.activityId) : escapeHtml(t('workflow.detail.notDispatched'))}</span>
1237
+ </div>
1238
+ <div>${desc.node ? statusBadge(desc.node.status) : ''} ${desc.activity ? statusBadge(desc.activity.status) : ''}</div>
1239
+ </header>
1240
+ <div class="wf-io-meta">
1241
+ ${attempt ? `${escapeHtml(t('workflow.detail.attempt'))} <code>${escapeHtml(attempt.attemptId)}</code>` : escapeHtml(t('workflow.detail.noAttempt'))}
1242
+ </div>
1243
+ ${controls}
1244
+ </div>
1245
+ <div class="wf-io-terminal-slot">${terminalHtml}</div>
1246
+ <div class="wf-io-grid">
1247
+ ${renderPreviewBlock(keyPrefix, t('workflow.detail.authoredInput'), desc.io?.input, openBlocks)}
1248
+ ${renderPreviewBlock(keyPrefix, t('workflow.detail.resolvedInput'), desc.io?.resolvedInput, openBlocks)}
1249
+ ${renderPreviewBlock(keyPrefix, t('workflow.detail.output'), desc.io?.output, openBlocks)}
1250
+ ${renderPreviewBlock(keyPrefix, t('workflow.detail.executionLog'), desc.io?.log, openBlocks)}
1251
+ ${desc.io?.waitPrompt ? renderPreviewBlock(keyPrefix, t('workflow.detail.waitPrompt'), desc.io.waitPrompt, openBlocks) : ''}
1252
+ </div>
1253
+ </article>`;
1254
+ }
1255
+ function latestAttempt(activity) {
1256
+ return activity?.attempts[activity.attempts.length - 1];
1257
+ }
1258
+ function terminalMeta(attempt, terminal) {
1259
+ const bits = [];
1260
+ if (terminal.error)
1261
+ bits.push(t('workflow.detail.error'));
1262
+ else
1263
+ bits.push(terminal.status === 'live' ? t('workflow.detail.terminalLive') : t('workflow.detail.terminalClosedShort'));
1264
+ if (attempt?.status)
1265
+ bits.push(attempt.status);
1266
+ if (terminal.webPort > 0)
1267
+ bits.push(`:${terminal.webPort}`);
1268
+ return `<span class="muted">${escapeHtml(bits.join(' · '))}</span>`;
1269
+ }
1270
+ function isLiveTerminal(attempt, terminal) {
1271
+ return terminal.status === 'live' &&
1272
+ terminal.webPort > 0 &&
1273
+ (attempt?.status === 'pending' || attempt?.status === 'running' || attempt?.status === 'effectAttempting');
1274
+ }
1275
+ /**
1276
+ * Replay-eligibility: attempt has reached a terminal state and a worker
1277
+ * actually started (sessionId / startedAt non-empty) — meaning a
1278
+ * `terminal.log` file likely exists on disk for the replay HTML to fetch.
1279
+ * The replay page itself handles a 404 gracefully, but gating here keeps
1280
+ * us from rendering an iframe that's guaranteed to show "no log".
1281
+ */
1282
+ function isReplayableTerminal(attempt, terminal) {
1283
+ const isAttemptTerminal = attempt.status === 'succeeded' ||
1284
+ attempt.status === 'failed' ||
1285
+ attempt.status === 'cancelled' ||
1286
+ attempt.status === 'timedOut';
1287
+ if (!isAttemptTerminal)
1288
+ return false;
1289
+ const hadWorker = !!(terminal.sessionId || terminal.startedAt);
1290
+ return hadWorker;
1291
+ }
1292
+ function terminalReadOnlyUrl(terminal) {
1293
+ const host = window.location.hostname || '127.0.0.1';
1294
+ return `http://${host}:${terminal.webPort}`;
1295
+ }
1296
+ function terminalReplayPageUrl(runId, activityId, attemptId, hasPtyLog) {
1297
+ const qs = new URLSearchParams({ runId, activityId, attemptId });
1298
+ if (hasPtyLog)
1299
+ qs.set('hasPtyLog', '1');
1300
+ return `/assets/terminal-replay.html?${qs.toString()}`;
1301
+ }
1302
+ function terminalLogDownloadUrl(runId, activityId, attemptId) {
1303
+ return (`/api/workflows/runs/${encodeURIComponent(runId)}` +
1304
+ `/attempts/${encodeURIComponent(activityId)}` +
1305
+ `/${encodeURIComponent(attemptId)}/terminal-log/raw?download=1`);
1306
+ }
1307
+ function currentRunIdFromHash() {
1308
+ const m = window.location.hash.match(/^#\/workflows\/([^/?#]+)/);
1309
+ if (!m)
1310
+ return null;
1311
+ try {
1312
+ return decodeURIComponent(m[1]);
1313
+ }
1314
+ catch {
1315
+ return null;
1316
+ }
1317
+ }
1318
+ function renderApprovalControls(attempt, approval) {
1319
+ if (!isOpenHumanGateAttempt(attempt))
1320
+ return '';
1321
+ const attemptId = attempt.attemptId;
1322
+ const comment = approval.comments.get(attemptId) ?? '';
1323
+ const resolving = approval.resolving.has(attemptId);
1324
+ const status = approval.statuses.get(attemptId);
1325
+ const statusClass = status?.kind === 'error' ? 'hint-warn' : 'hint-ok';
1326
+ return `<div class="wf-approval-box" data-wf-approval="${escapeHtml(attemptId)}">
1327
+ <label>
1328
+ <span>${escapeHtml(t('workflow.detail.approvalComment'))}</span>
1329
+ <textarea class="wf-approval-comment" data-wf-approval-comment="${escapeHtml(attemptId)}" rows="2" placeholder="${escapeHtml(t('workflow.detail.optionalComment'))}"${resolving ? ' disabled' : ''}>${escapeHtml(comment)}</textarea>
1330
+ </label>
1331
+ <div class="wf-approval-actions">
1332
+ <button type="button" class="primary" data-wf-approval-action="approve" data-wf-attempt-id="${escapeHtml(attemptId)}"${resolving ? ' disabled' : ''}>${escapeHtml(t('workflow.detail.approve'))}</button>
1333
+ <button type="button" data-wf-approval-action="reject" data-wf-attempt-id="${escapeHtml(attemptId)}"${resolving ? ' disabled' : ''}>${escapeHtml(t('workflow.detail.reject'))}</button>
1334
+ ${resolving ? `<span class="muted">${escapeHtml(t('workflow.detail.submitting'))}</span>` : ''}
1335
+ </div>
1336
+ ${status ? `<div class="${statusClass} wf-approval-status">${escapeHtml(status.text)}</div>` : ''}
1337
+ </div>`;
1338
+ }
1339
+ function isOpenHumanGateAttempt(attempt) {
1340
+ return !!attempt &&
1341
+ attempt.status === 'waiting' &&
1342
+ attempt.wait?.waitKind === 'human-gate' &&
1343
+ !attempt.wait.resolution;
1344
+ }
1345
+ function syncApprovalComments(root, comments) {
1346
+ root.querySelectorAll('textarea[data-wf-approval-comment]').forEach((el) => {
1347
+ const key = el.dataset.wfApprovalComment;
1348
+ if (!key)
1349
+ return;
1350
+ comments.set(key, el.value);
1351
+ });
1352
+ }
1353
+ function attachResumeControls(root, resume) {
1354
+ root.querySelectorAll('button[data-wf-resume-action][data-wf-resume-attempt][data-wf-resume-activity]').forEach((button) => {
1355
+ if (button.dataset.wfResumeBound === '1')
1356
+ return;
1357
+ button.dataset.wfResumeBound = '1';
1358
+ button.addEventListener('click', () => {
1359
+ const attemptId = button.dataset.wfResumeAttempt;
1360
+ const activityId = button.dataset.wfResumeActivity;
1361
+ const action = button.dataset.wfResumeAction;
1362
+ if (!attemptId || !activityId)
1363
+ return;
1364
+ if (action === 'start')
1365
+ void resume.onStart(attemptId, activityId);
1366
+ else if (action === 'end')
1367
+ void resume.onEnd(attemptId, activityId);
1368
+ });
1369
+ });
1370
+ }
1371
+ function refreshResumeActions(slot, attempt, activity, terminal, surface, resume) {
1372
+ const actions = slot.querySelector('.wf-terminal-actions');
1373
+ if (!actions)
1374
+ return;
1375
+ // Re-render the resume button area in-place so the surrounding
1376
+ // anchors (open-in-tab + download) stay stable. We tag-select the
1377
+ // existing button (if any) and replace its outerHTML so the new
1378
+ // listener pickup goes through the same data-wf-resume-bound gate.
1379
+ const existingButton = actions.querySelector('button[data-wf-resume-button="1"]');
1380
+ const html = renderResumeButtonHtml(attempt, activity, terminal, surface, resume);
1381
+ if (existingButton) {
1382
+ existingButton.outerHTML = html;
1383
+ }
1384
+ else if (html) {
1385
+ actions.insertAdjacentHTML('beforeend', html);
1386
+ }
1387
+ // Update / clear inline error hint.
1388
+ const details = slot.querySelector('details.wf-terminal-block');
1389
+ if (details) {
1390
+ const existingStatus = details.querySelector('.wf-resume-status');
1391
+ const statusHtml = renderResumeStatusHtml(attempt.attemptId, resume);
1392
+ if (existingStatus) {
1393
+ existingStatus.outerHTML = statusHtml;
1394
+ }
1395
+ else if (statusHtml) {
1396
+ actions.insertAdjacentHTML('afterend', statusHtml);
1397
+ }
1398
+ }
1399
+ attachResumeControls(slot, resume);
1400
+ }
1401
+ function attachApprovalControls(root, approval) {
1402
+ root.querySelectorAll('textarea[data-wf-approval-comment]').forEach((el) => {
1403
+ const key = el.dataset.wfApprovalComment;
1404
+ if (!key)
1405
+ return;
1406
+ el.addEventListener('input', () => {
1407
+ approval.comments.set(key, el.value);
1408
+ });
1409
+ });
1410
+ root.querySelectorAll('button[data-wf-approval-action][data-wf-attempt-id]').forEach((button) => {
1411
+ button.addEventListener('click', () => {
1412
+ const attemptId = button.dataset.wfAttemptId;
1413
+ const action = button.dataset.wfApprovalAction;
1414
+ if (!attemptId || (action !== 'approve' && action !== 'reject'))
1415
+ return;
1416
+ void approval.onResolve(attemptId, action);
1417
+ });
1418
+ });
1419
+ }
1420
+ function renderPreviewBlock(keyPrefix, label, preview, openBlocks) {
1421
+ const key = ioBlockKey(keyPrefix, label);
1422
+ return `<details class="wf-io-block" data-io-key="${escapeHtml(key)}"${openBlocks.has(key) ? ' open' : ''}>
1423
+ <summary>${escapeHtml(label)} ${previewMeta(preview)}</summary>
1424
+ ${renderPreview(preview)}
1425
+ </details>`;
1426
+ }
1427
+ function ioBlockKey(keyPrefix, label) {
1428
+ return `${keyPrefix}:${label}`;
1429
+ }
1430
+ function scrollFocusedAttemptIntoView(root, focusAttemptId) {
1431
+ if (!focusAttemptId)
1432
+ return false;
1433
+ for (const card of root.querySelectorAll('[data-wf-attempt-card]')) {
1434
+ if (card.dataset.wfAttemptCard !== focusAttemptId)
1435
+ continue;
1436
+ card.scrollIntoView({ block: 'center' });
1437
+ return true;
1438
+ }
1439
+ return false;
1440
+ }
1441
+ function syncIOBlockState(root, openBlocks, scrollTops) {
1442
+ root.querySelectorAll('details.wf-io-block[data-io-key]').forEach((el) => {
1443
+ const key = el.dataset.ioKey;
1444
+ if (!key)
1445
+ return;
1446
+ if (el.open)
1447
+ openBlocks.add(key);
1448
+ else
1449
+ openBlocks.delete(key);
1450
+ const pre = el.querySelector('.wf-io-pre');
1451
+ if (pre)
1452
+ scrollTops.set(key, pre.scrollTop);
1453
+ });
1454
+ }
1455
+ function attachIOBlockToggleTracking(root, openBlocks) {
1456
+ root.querySelectorAll('details.wf-io-block[data-io-key]').forEach((el) => {
1457
+ if (el.dataset.ioToggleBound === '1')
1458
+ return;
1459
+ el.dataset.ioToggleBound = '1';
1460
+ el.addEventListener('toggle', () => {
1461
+ const key = el.dataset.ioKey;
1462
+ if (!key)
1463
+ return;
1464
+ if (el.open)
1465
+ openBlocks.add(key);
1466
+ else
1467
+ openBlocks.delete(key);
1468
+ });
1469
+ });
1470
+ }
1471
+ function restoreIOBlockScroll(root, scrollTops) {
1472
+ root.querySelectorAll('details.wf-io-block[data-io-key]').forEach((el) => {
1473
+ const key = el.dataset.ioKey;
1474
+ if (!key)
1475
+ return;
1476
+ const top = scrollTops.get(key);
1477
+ if (top === undefined)
1478
+ return;
1479
+ const pre = el.querySelector('.wf-io-pre');
1480
+ if (pre)
1481
+ pre.scrollTop = top;
1482
+ });
1483
+ }
1484
+ function attachIOBlockScrollTracking(root, scrollTops) {
1485
+ root.querySelectorAll('details.wf-io-block[data-io-key]').forEach((el) => {
1486
+ const key = el.dataset.ioKey;
1487
+ if (!key)
1488
+ return;
1489
+ const pre = el.querySelector('.wf-io-pre');
1490
+ if (!pre)
1491
+ return;
1492
+ if (pre.dataset.ioScrollBound === '1')
1493
+ return;
1494
+ pre.dataset.ioScrollBound = '1';
1495
+ pre.addEventListener('scroll', () => {
1496
+ scrollTops.set(key, pre.scrollTop);
1497
+ });
1498
+ });
1499
+ }
1500
+ function previewMeta(preview) {
1501
+ if (!preview)
1502
+ return `<span class="muted">${escapeHtml(t('workflow.detail.empty'))}</span>`;
1503
+ const bits = [];
1504
+ if (preview.outputBytes !== undefined)
1505
+ bits.push(`${preview.outputBytes}B`);
1506
+ if (preview.truncated)
1507
+ bits.push(t('workflow.detail.truncated'));
1508
+ if (preview.error)
1509
+ bits.push(t('workflow.detail.error'));
1510
+ if (preview.outputHash)
1511
+ bits.push(short(preview.outputHash));
1512
+ return bits.length ? `<span class="muted">${escapeHtml(bits.join(' · '))}</span>` : '';
1513
+ }
1514
+ function renderPreview(preview) {
1515
+ if (!preview)
1516
+ return `<div class="muted wf-io-empty">${escapeHtml(t('workflow.detail.noData'))}</div>`;
1517
+ const body = preview.value !== undefined
1518
+ ? JSON.stringify(preview.value, null, 2)
1519
+ : preview.text ?? '';
1520
+ const error = preview.error ? `<div class="muted error">${escapeHtml(preview.error)}</div>` : '';
1521
+ if (!body)
1522
+ return `${error}<div class="muted wf-io-empty">${escapeHtml(t('workflow.detail.noPreview'))}</div>`;
1523
+ return `${error}<pre class="wf-io-pre">${escapeHtml(body)}</pre>`;
1524
+ }
1525
+ function renderAttemptDetail(at) {
1526
+ const parts = [];
1527
+ if (at.effectAttempted)
1528
+ parts.push(`${escapeHtml(t('workflow.detail.effect'))} ${escapeHtml(at.effectAttempted.provider)}`);
1529
+ if (at.wait) {
1530
+ const res = at.wait.resolution
1531
+ ? `${at.wait.resolution.kind}${at.wait.resolution.resolution ? ':' + at.wait.resolution.resolution : ''}`
1532
+ : t('workflow.detail.open');
1533
+ parts.push(`${escapeHtml(t('workflow.detail.wait'))} ${escapeHtml(at.wait.waitKind)} ${escapeHtml(res)}`);
1534
+ if (at.wait.deadlineAt !== undefined) {
1535
+ parts.push(`${escapeHtml(t('workflow.detail.deadline'))} ${escapeHtml(formatClock(at.wait.deadlineAt))}`);
1536
+ }
1537
+ }
1538
+ if (at.error) {
1539
+ const tag = `${at.error.errorCode}${at.error.errorClass ? ` · ${at.error.errorClass}` : ''}`;
1540
+ parts.push(`<span class="muted error">${escapeHtml(tag)}</span>`);
1541
+ if (at.error.errorMessage) {
1542
+ parts.push(`<span class="error wf-error-msg">${escapeHtml(at.error.errorMessage)}</span>`);
1543
+ }
1544
+ }
1545
+ if (at.output)
1546
+ parts.push(`${escapeHtml(t('workflow.detail.output'))} ${escapeHtml(short(at.output.outputHash))}`);
1547
+ if (at.runningMs !== undefined)
1548
+ parts.push(`${at.runningMs}ms`);
1549
+ return parts.length > 0 ? parts.join('<br/>') : '<span class="muted">-</span>';
1550
+ }
1551
+ function renderEvents(tbody, events) {
1552
+ tbody.innerHTML =
1553
+ events.length > 0
1554
+ ? events.map(renderEventRow).join('')
1555
+ : `<tr><td colspan="7" class="empty">${escapeHtml(t('workflow.detail.noEvents'))}</td></tr>`;
1556
+ }
1557
+ function renderEventRow(ev) {
1558
+ const ctx = extractEventContext(ev.payload);
1559
+ return `<tr>
1560
+ <td>${eventSeqFromId(ev.eventId)}</td>
1561
+ <td><code>${escapeHtml(ev.type)}</code></td>
1562
+ <td>${escapeHtml(ev.actor)}</td>
1563
+ <td>${ctx.nodeId ? `<code>${escapeHtml(ctx.nodeId)}</code>` : '-'}</td>
1564
+ <td>${ctx.activityId ? `<code>${escapeHtml(ctx.activityId)}</code>` : '-'}</td>
1565
+ <td>${ctx.errorCode ? `<span class="muted error">${escapeHtml(ctx.errorCode)}</span>` : '-'}</td>
1566
+ <td title="${escapeHtml(new Date(ev.timestamp).toISOString())}">${escapeHtml(formatClock(ev.timestamp))}</td>
1567
+ </tr>`;
1568
+ }
1569
+ // Browser-side copies of ops-projection helpers. Keep these tiny to avoid
1570
+ // pulling the Node/Zod projection module into the dashboard bundle.
1571
+ function eventSeqFromId(eventId) {
1572
+ const dash = eventId.lastIndexOf('-');
1573
+ if (dash < 0)
1574
+ return 0;
1575
+ const n = Number(eventId.slice(dash + 1));
1576
+ return Number.isFinite(n) ? n : 0;
1577
+ }
1578
+ function extractEventContext(payload) {
1579
+ if (!payload || typeof payload !== 'object' || 'ref' in payload)
1580
+ return {};
1581
+ const p = payload;
1582
+ const out = {};
1583
+ if (typeof p.nodeId === 'string')
1584
+ out.nodeId = p.nodeId;
1585
+ if (typeof p.activityId === 'string')
1586
+ out.activityId = p.activityId;
1587
+ if (typeof p.failedNodeId === 'string')
1588
+ out.nodeId = p.failedNodeId;
1589
+ const err = p.error;
1590
+ if (err && typeof err === 'object' && 'errorCode' in err) {
1591
+ out.errorCode = String(err.errorCode);
1592
+ }
1593
+ return out;
1594
+ }
1595
+ function payloadRecord(ev) {
1596
+ if (!ev.payload || typeof ev.payload !== 'object' || 'ref' in ev.payload)
1597
+ return null;
1598
+ return ev.payload;
1599
+ }
1600
+ function clamp(value, min, max) {
1601
+ return Math.min(max, Math.max(min, value));
1602
+ }
1603
+ function short(value) {
1604
+ if (!value)
1605
+ return '-';
1606
+ return value.length > 18 ? value.slice(0, 10) + '...' + value.slice(-6) : value;
1607
+ }
1608
+ function shortText(value, max) {
1609
+ return value.length > max ? value.slice(0, max - 1) + '…' : value;
1610
+ }
1611
+ function formatClock(ms) {
1612
+ return new Date(ms).toLocaleTimeString([], {
1613
+ hour: '2-digit',
1614
+ minute: '2-digit',
1615
+ second: '2-digit',
1616
+ });
1617
+ }
1618
+ //# sourceMappingURL=workflows.js.map