botmux 2.88.1 → 2.89.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 (171) hide show
  1. package/dist/bot-registry.d.ts +2 -0
  2. package/dist/bot-registry.d.ts.map +1 -1
  3. package/dist/bot-registry.js +4 -0
  4. package/dist/bot-registry.js.map +1 -1
  5. package/dist/cli/dashboard-endpoint.d.ts.map +1 -1
  6. package/dist/cli/dashboard-endpoint.js +9 -3
  7. package/dist/cli/dashboard-endpoint.js.map +1 -1
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +11 -3
  10. package/dist/cli.js.map +1 -1
  11. package/dist/core/command-handler.d.ts.map +1 -1
  12. package/dist/core/command-handler.js +9 -1
  13. package/dist/core/command-handler.js.map +1 -1
  14. package/dist/core/dashboard-command/groups.d.ts +20 -0
  15. package/dist/core/dashboard-command/groups.d.ts.map +1 -0
  16. package/dist/core/dashboard-command/groups.js +52 -0
  17. package/dist/core/dashboard-command/groups.js.map +1 -0
  18. package/dist/core/dashboard-command/index.d.ts +30 -0
  19. package/dist/core/dashboard-command/index.d.ts.map +1 -0
  20. package/dist/core/dashboard-command/index.js +81 -0
  21. package/dist/core/dashboard-command/index.js.map +1 -0
  22. package/dist/core/dashboard-command/overview.d.ts +29 -0
  23. package/dist/core/dashboard-command/overview.d.ts.map +1 -0
  24. package/dist/core/dashboard-command/overview.js +61 -0
  25. package/dist/core/dashboard-command/overview.js.map +1 -0
  26. package/dist/core/dashboard-command/owner-gate.d.ts +41 -0
  27. package/dist/core/dashboard-command/owner-gate.d.ts.map +1 -0
  28. package/dist/core/dashboard-command/owner-gate.js +40 -0
  29. package/dist/core/dashboard-command/owner-gate.js.map +1 -0
  30. package/dist/core/dashboard-command/schedules.d.ts +20 -0
  31. package/dist/core/dashboard-command/schedules.d.ts.map +1 -0
  32. package/dist/core/dashboard-command/schedules.js +47 -0
  33. package/dist/core/dashboard-command/schedules.js.map +1 -0
  34. package/dist/core/dashboard-command/sessions.d.ts +22 -0
  35. package/dist/core/dashboard-command/sessions.d.ts.map +1 -0
  36. package/dist/core/dashboard-command/sessions.js +43 -0
  37. package/dist/core/dashboard-command/sessions.js.map +1 -0
  38. package/dist/core/dashboard-command/settings.d.ts +21 -0
  39. package/dist/core/dashboard-command/settings.d.ts.map +1 -0
  40. package/dist/core/dashboard-command/settings.js +55 -0
  41. package/dist/core/dashboard-command/settings.js.map +1 -0
  42. package/dist/core/dashboard-command/stub.d.ts +16 -0
  43. package/dist/core/dashboard-command/stub.d.ts.map +1 -0
  44. package/dist/core/dashboard-command/stub.js +28 -0
  45. package/dist/core/dashboard-command/stub.js.map +1 -0
  46. package/dist/core/dashboard-command/workflows.d.ts +20 -0
  47. package/dist/core/dashboard-command/workflows.d.ts.map +1 -0
  48. package/dist/core/dashboard-command/workflows.js +45 -0
  49. package/dist/core/dashboard-command/workflows.js.map +1 -0
  50. package/dist/core/passthrough-commands.d.ts.map +1 -1
  51. package/dist/core/passthrough-commands.js +1 -1
  52. package/dist/core/passthrough-commands.js.map +1 -1
  53. package/dist/daemon-internal-client-wrapper.d.ts +41 -0
  54. package/dist/daemon-internal-client-wrapper.d.ts.map +1 -0
  55. package/dist/daemon-internal-client-wrapper.js +65 -0
  56. package/dist/daemon-internal-client-wrapper.js.map +1 -0
  57. package/dist/dashboard/aggregator.d.ts +12 -0
  58. package/dist/dashboard/aggregator.d.ts.map +1 -1
  59. package/dist/dashboard/aggregator.js +16 -0
  60. package/dist/dashboard/aggregator.js.map +1 -1
  61. package/dist/dashboard/auth.d.ts +7 -0
  62. package/dist/dashboard/auth.d.ts.map +1 -1
  63. package/dist/dashboard/auth.js +21 -0
  64. package/dist/dashboard/auth.js.map +1 -1
  65. package/dist/dashboard/card-model-types.d.ts +50 -0
  66. package/dist/dashboard/card-model-types.d.ts.map +1 -0
  67. package/dist/dashboard/card-model-types.js +9 -0
  68. package/dist/dashboard/card-model-types.js.map +1 -0
  69. package/dist/dashboard/daemon-internal-api.d.ts +90 -0
  70. package/dist/dashboard/daemon-internal-api.d.ts.map +1 -0
  71. package/dist/dashboard/daemon-internal-api.js +534 -0
  72. package/dist/dashboard/daemon-internal-api.js.map +1 -0
  73. package/dist/dashboard/daemon-internal-auth.d.ts +124 -0
  74. package/dist/dashboard/daemon-internal-auth.d.ts.map +1 -0
  75. package/dist/dashboard/daemon-internal-auth.js +188 -0
  76. package/dist/dashboard/daemon-internal-auth.js.map +1 -0
  77. package/dist/dashboard/daemon-internal-client.d.ts +67 -0
  78. package/dist/dashboard/daemon-internal-client.d.ts.map +1 -0
  79. package/dist/dashboard/daemon-internal-client.js +157 -0
  80. package/dist/dashboard/daemon-internal-client.js.map +1 -0
  81. package/dist/dashboard/dashboard-admins.d.ts +8 -0
  82. package/dist/dashboard/dashboard-admins.d.ts.map +1 -0
  83. package/dist/dashboard/dashboard-admins.js +15 -0
  84. package/dist/dashboard/dashboard-admins.js.map +1 -0
  85. package/dist/dashboard/federation-spoke-api.d.ts +3 -0
  86. package/dist/dashboard/federation-spoke-api.d.ts.map +1 -1
  87. package/dist/dashboard/federation-spoke-api.js +11 -8
  88. package/dist/dashboard/federation-spoke-api.js.map +1 -1
  89. package/dist/dashboard/groups-action-helpers.d.ts +69 -0
  90. package/dist/dashboard/groups-action-helpers.d.ts.map +1 -0
  91. package/dist/dashboard/groups-action-helpers.js +141 -0
  92. package/dist/dashboard/groups-action-helpers.js.map +1 -0
  93. package/dist/dashboard/groups-card-model.d.ts +120 -0
  94. package/dist/dashboard/groups-card-model.d.ts.map +1 -0
  95. package/dist/dashboard/groups-card-model.js +175 -0
  96. package/dist/dashboard/groups-card-model.js.map +1 -0
  97. package/dist/dashboard/schedule-card-model.d.ts +172 -0
  98. package/dist/dashboard/schedule-card-model.d.ts.map +1 -0
  99. package/dist/dashboard/schedule-card-model.js +290 -0
  100. package/dist/dashboard/schedule-card-model.js.map +1 -0
  101. package/dist/dashboard/session-card-model.d.ts +83 -0
  102. package/dist/dashboard/session-card-model.d.ts.map +1 -0
  103. package/dist/dashboard/session-card-model.js +196 -0
  104. package/dist/dashboard/session-card-model.js.map +1 -0
  105. package/dist/dashboard/settings-card-model.d.ts +73 -0
  106. package/dist/dashboard/settings-card-model.d.ts.map +1 -0
  107. package/dist/dashboard/settings-card-model.js +94 -0
  108. package/dist/dashboard/settings-card-model.js.map +1 -0
  109. package/dist/dashboard/settings-owner-resolver.d.ts +32 -0
  110. package/dist/dashboard/settings-owner-resolver.d.ts.map +1 -0
  111. package/dist/dashboard/settings-owner-resolver.js +43 -0
  112. package/dist/dashboard/settings-owner-resolver.js.map +1 -0
  113. package/dist/dashboard/settings-write-applier.d.ts +84 -0
  114. package/dist/dashboard/settings-write-applier.d.ts.map +1 -0
  115. package/dist/dashboard/settings-write-applier.js +119 -0
  116. package/dist/dashboard/settings-write-applier.js.map +1 -0
  117. package/dist/dashboard/workflow-api.d.ts.map +1 -1
  118. package/dist/dashboard/workflow-api.js +38 -119
  119. package/dist/dashboard/workflow-api.js.map +1 -1
  120. package/dist/dashboard/workflow-card-model.d.ts +127 -0
  121. package/dist/dashboard/workflow-card-model.d.ts.map +1 -0
  122. package/dist/dashboard/workflow-card-model.js +186 -0
  123. package/dist/dashboard/workflow-card-model.js.map +1 -0
  124. package/dist/dashboard/workflows-action-helpers.d.ts +105 -0
  125. package/dist/dashboard/workflows-action-helpers.d.ts.map +1 -0
  126. package/dist/dashboard/workflows-action-helpers.js +196 -0
  127. package/dist/dashboard/workflows-action-helpers.js.map +1 -0
  128. package/dist/dashboard.js +196 -269
  129. package/dist/dashboard.js.map +1 -1
  130. package/dist/i18n/en.d.ts.map +1 -1
  131. package/dist/i18n/en.js +266 -0
  132. package/dist/i18n/en.js.map +1 -1
  133. package/dist/i18n/zh.d.ts.map +1 -1
  134. package/dist/i18n/zh.js +266 -0
  135. package/dist/i18n/zh.js.map +1 -1
  136. package/dist/im/lark/card-builder.d.ts +2 -0
  137. package/dist/im/lark/card-builder.d.ts.map +1 -1
  138. package/dist/im/lark/card-builder.js +2 -1
  139. package/dist/im/lark/card-builder.js.map +1 -1
  140. package/dist/im/lark/card-handler.d.ts +54 -2
  141. package/dist/im/lark/card-handler.d.ts.map +1 -1
  142. package/dist/im/lark/card-handler.js +127 -1
  143. package/dist/im/lark/card-handler.js.map +1 -1
  144. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  145. package/dist/im/lark/event-dispatcher.js +22 -0
  146. package/dist/im/lark/event-dispatcher.js.map +1 -1
  147. package/dist/im/lark/groups-card.d.ts +90 -0
  148. package/dist/im/lark/groups-card.d.ts.map +1 -0
  149. package/dist/im/lark/groups-card.js +832 -0
  150. package/dist/im/lark/groups-card.js.map +1 -0
  151. package/dist/im/lark/overview-card.d.ts +101 -0
  152. package/dist/im/lark/overview-card.d.ts.map +1 -0
  153. package/dist/im/lark/overview-card.js +475 -0
  154. package/dist/im/lark/overview-card.js.map +1 -0
  155. package/dist/im/lark/schedules-card.d.ts +96 -0
  156. package/dist/im/lark/schedules-card.d.ts.map +1 -0
  157. package/dist/im/lark/schedules-card.js +892 -0
  158. package/dist/im/lark/schedules-card.js.map +1 -0
  159. package/dist/im/lark/sessions-card.d.ts +117 -0
  160. package/dist/im/lark/sessions-card.d.ts.map +1 -0
  161. package/dist/im/lark/sessions-card.js +870 -0
  162. package/dist/im/lark/sessions-card.js.map +1 -0
  163. package/dist/im/lark/settings-card.d.ts +125 -0
  164. package/dist/im/lark/settings-card.d.ts.map +1 -0
  165. package/dist/im/lark/settings-card.js +433 -0
  166. package/dist/im/lark/settings-card.js.map +1 -0
  167. package/dist/im/lark/workflows-card.d.ts +102 -0
  168. package/dist/im/lark/workflows-card.d.ts.map +1 -0
  169. package/dist/im/lark/workflows-card.js +737 -0
  170. package/dist/im/lark/workflows-card.js.map +1 -0
  171. package/package.json +1 -1
@@ -0,0 +1,737 @@
1
+ /**
2
+ * Workflows dashboard card.
3
+ *
4
+ * The list view is compact, paginated, refreshable, and read-only except for
5
+ * per-row detail entry. The detail view exposes cancel and back actions.
6
+ *
7
+ * Cancel callbacks block on Route B and then refetch because a workflow can
8
+ * land in its terminal state asynchronously. If refetch fails or the row
9
+ * disappears, the card falls back to a cancelled-state synth so the user still
10
+ * gets a usable detail view, possibly stale for one render. Failures return
11
+ * toast-only.
12
+ *
13
+ * Security:
14
+ * - `invokerOpenId` pins callbacks to the admin who opened the card.
15
+ * - sender union_id never lands on `action.value`.
16
+ * - command entry and callbacks both enforce the dashboard admin gate.
17
+ * - row ids are routing keys only; Route B still enforces row ownership.
18
+ * - cancel re-runs the model availability matrix and owner-routability check
19
+ * before POSTing.
20
+ */
21
+ import { isDashboardAdmin } from '../../dashboard/dashboard-admins.js';
22
+ import { computeActionAvailability, projectRunDetailDto, projectRunRowDto, } from '../../dashboard/workflow-card-model.js';
23
+ import { t } from '../../i18n/index.js';
24
+ export const WORKFLOWS_ACTION_REFRESH = 'dash_workflows_refresh';
25
+ export const WORKFLOWS_ACTION_PAGE = 'dash_workflows_page';
26
+ export const WORKFLOWS_ACTION_DETAIL = 'dash_workflows_detail';
27
+ export const WORKFLOWS_ACTION_CANCEL = 'dash_workflows_cancel';
28
+ export const WORKFLOWS_ACTION_BACK_TO_LIST = 'dash_workflows_back_to_list';
29
+ /** Action emitted by "🔙 返回总览" on overview-origin sub-cards. Same string
30
+ * as overview-card's OVERVIEW_ACTION_REFRESH (kept in sync; we don't import
31
+ * to avoid a circular dep). card-handler routes by action prefix, so
32
+ * dispatch lands on the overview handler regardless of which sub-card
33
+ * emitted it. */
34
+ const BACK_TO_OVERVIEW_ACTION = 'dash_overview_refresh';
35
+ /** Default page size for standalone and overview-drilldown list cards. */
36
+ const PAGE_SIZE = 5;
37
+ /** Hard cap on `select_static` jump-page option count. Lark caps select
38
+ * options around this; we also keep payload small. Above the cap we fall
39
+ * back to prev/next only. */
40
+ const JUMP_PAGE_MAX_OPTIONS = 50;
41
+ /** Mapping from `StatusDot.tone` to a stable colour-emoji prefix. Pure. */
42
+ function toneIcon(tone) {
43
+ switch (tone) {
44
+ case 'success': return '🟢';
45
+ case 'info': return '🔵';
46
+ case 'warning': return '🟡';
47
+ case 'danger': return '🔴';
48
+ case 'neutral': return '⚪';
49
+ default: return '⚫';
50
+ }
51
+ }
52
+ /** Short status badge used after the title — '[running]' / '[failed]' etc. */
53
+ function statusBadge(status) {
54
+ return `[${status}]`;
55
+ }
56
+ /** Relative-time formatter: 'just now' / 'Ns ago' / 'Nm ago' / 'Nh ago' / 'Nd ago'. */
57
+ function formatRelative(fromMs, nowMs) {
58
+ if (typeof fromMs !== 'number' || !Number.isFinite(fromMs) || fromMs <= 0)
59
+ return '—';
60
+ const diff = nowMs - fromMs;
61
+ if (!Number.isFinite(diff) || diff < 0)
62
+ return 'just now';
63
+ const sec = Math.floor(diff / 1000);
64
+ if (sec < 60)
65
+ return `${sec}s ago`;
66
+ const min = Math.floor(sec / 60);
67
+ if (min < 60)
68
+ return `${min}m ago`;
69
+ const hour = Math.floor(min / 60);
70
+ if (hour < 24)
71
+ return `${hour}h ago`;
72
+ const day = Math.floor(hour / 24);
73
+ return `${day}d ago`;
74
+ }
75
+ /** Tally counts across the run pool.
76
+ *
77
+ * running = pending | running | waiting
78
+ * done = succeeded
79
+ * failed = failed | cancelled
80
+ */
81
+ function countByStatus(rows) {
82
+ let running = 0, done = 0, failed = 0;
83
+ for (const r of rows) {
84
+ switch (r.status) {
85
+ case 'running':
86
+ case 'waiting':
87
+ case 'pending':
88
+ running += 1;
89
+ break;
90
+ case 'succeeded':
91
+ done += 1;
92
+ break;
93
+ case 'failed':
94
+ case 'cancelled':
95
+ failed += 1;
96
+ break;
97
+ }
98
+ }
99
+ return { running, done, failed };
100
+ }
101
+ /** Paginate without re-sorting; Route B owns the canonical workflow order. */
102
+ function paginate(items, page, pageSize) {
103
+ const total = items.length;
104
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
105
+ let active = Number.isFinite(page) ? Math.floor(page) : 1;
106
+ if (active < 1)
107
+ active = 1;
108
+ if (active > totalPages)
109
+ active = totalPages;
110
+ const start = (active - 1) * pageSize;
111
+ return {
112
+ items: items.slice(start, start + pageSize),
113
+ page: active,
114
+ totalPages,
115
+ total,
116
+ };
117
+ }
118
+ /** Build the workflows list card JSON. Pure (paginates + renders).
119
+ * Server-side listRuns order is preserved verbatim — no client-side resort.
120
+ * See module docblock for the rationale. */
121
+ export function buildWorkflowsCard(rows, opts, nowMs) {
122
+ const effectivePageSize = typeof opts.pageSize === 'number' && Number.isFinite(opts.pageSize) && opts.pageSize > 0
123
+ ? Math.floor(opts.pageSize)
124
+ : PAGE_SIZE;
125
+ const { running, done, failed } = countByStatus(rows);
126
+ // Server order from listRuns is preserved verbatim — paginate directly.
127
+ const paged = paginate(rows, opts.page, effectivePageSize);
128
+ // Plumb origin + page_size + scope into every button.value so refresh/
129
+ // page/detail/detail-back rebuilds keep the same dashboard context.
130
+ const navFields = {};
131
+ if (opts.origin === 'overview')
132
+ navFields.origin = 'overview';
133
+ if (effectivePageSize !== PAGE_SIZE)
134
+ navFields.page_size = String(effectivePageSize);
135
+ if (opts.scope === 'global')
136
+ navFields.dashboard_scope = 'global';
137
+ const elements = [];
138
+ // Sub-header — counts + page indicator.
139
+ elements.push({
140
+ tag: 'div',
141
+ text: {
142
+ tag: 'lark_md',
143
+ content: t('card.dashboard.workflows.count_summary', {
144
+ running: String(running),
145
+ done: String(done),
146
+ failed: String(failed),
147
+ page: String(paged.page),
148
+ totalPages: String(paged.totalPages),
149
+ }, opts.locale),
150
+ },
151
+ });
152
+ elements.push({ tag: 'hr' });
153
+ if (paged.items.length === 0) {
154
+ elements.push({
155
+ tag: 'div',
156
+ text: {
157
+ tag: 'lark_md',
158
+ content: t('card.dashboard.workflows.empty', undefined, opts.locale),
159
+ },
160
+ });
161
+ }
162
+ else {
163
+ for (const run of paged.items) {
164
+ const dto = projectRunRowDto(run);
165
+ elements.push(renderRow(dto, opts.locale, nowMs));
166
+ // Per-row action element holding ONLY the "📂 详情" button.
167
+ // Each row owns its own action block so its run_id can flow through
168
+ // `value.run_id` as the routing key.
169
+ elements.push({
170
+ tag: 'action',
171
+ actions: [
172
+ {
173
+ tag: 'button',
174
+ text: { tag: 'plain_text', content: t('card.dashboard.workflows.row_detail', undefined, opts.locale) },
175
+ type: 'default',
176
+ value: {
177
+ action: WORKFLOWS_ACTION_DETAIL,
178
+ invoker_open_id: opts.invokerOpenId,
179
+ run_id: dto.runId,
180
+ page: String(paged.page),
181
+ ...navFields,
182
+ },
183
+ },
184
+ ],
185
+ });
186
+ }
187
+ }
188
+ elements.push({ tag: 'hr' });
189
+ // Pagination + refresh.
190
+ const actions = [];
191
+ if (paged.totalPages > 1) {
192
+ actions.push({
193
+ tag: 'button',
194
+ text: { tag: 'plain_text', content: t('card.dashboard.workflows.prev', undefined, opts.locale) },
195
+ type: 'default',
196
+ disabled: paged.page <= 1,
197
+ value: {
198
+ action: WORKFLOWS_ACTION_PAGE,
199
+ invoker_open_id: opts.invokerOpenId,
200
+ page: String(Math.max(1, paged.page - 1)),
201
+ ...navFields,
202
+ },
203
+ });
204
+ actions.push({
205
+ tag: 'button',
206
+ text: { tag: 'plain_text', content: t('card.dashboard.workflows.next', undefined, opts.locale) },
207
+ type: 'default',
208
+ disabled: paged.page >= paged.totalPages,
209
+ value: {
210
+ action: WORKFLOWS_ACTION_PAGE,
211
+ invoker_open_id: opts.invokerOpenId,
212
+ page: String(Math.min(paged.totalPages, paged.page + 1)),
213
+ ...navFields,
214
+ },
215
+ });
216
+ // "Jump to page" select — same action as prev/next, page comes via
217
+ // action.option instead of value.page. Handler reads `value.page ??
218
+ // action.option ?? '1'` so both paths converge on one branch. Capped at
219
+ // JUMP_PAGE_MAX_OPTIONS to keep payload small / inside Lark's option
220
+ // limit (above the cap, prev/next still works).
221
+ if (paged.totalPages > 2 && paged.totalPages <= JUMP_PAGE_MAX_OPTIONS) {
222
+ const options = Array.from({ length: paged.totalPages }, (_, i) => {
223
+ const n = i + 1;
224
+ return {
225
+ text: { tag: 'plain_text', content: t('card.dashboard.workflows.jump_page', { n: String(n), total: String(paged.totalPages) }, opts.locale) },
226
+ value: String(n),
227
+ };
228
+ });
229
+ actions.push({
230
+ tag: 'select_static',
231
+ placeholder: {
232
+ tag: 'plain_text',
233
+ content: t('card.dashboard.workflows.jump_page', { n: String(paged.page), total: String(paged.totalPages) }, opts.locale),
234
+ },
235
+ initial_option: String(paged.page),
236
+ options,
237
+ value: {
238
+ action: WORKFLOWS_ACTION_PAGE,
239
+ invoker_open_id: opts.invokerOpenId,
240
+ ...navFields,
241
+ },
242
+ });
243
+ }
244
+ }
245
+ actions.push({
246
+ tag: 'button',
247
+ text: { tag: 'plain_text', content: t('card.dashboard.workflows.refresh', undefined, opts.locale) },
248
+ type: 'default',
249
+ value: {
250
+ action: WORKFLOWS_ACTION_REFRESH,
251
+ invoker_open_id: opts.invokerOpenId,
252
+ ...navFields,
253
+ },
254
+ });
255
+ // Overview drilldown only — "🔙 返回总览" reuses the overview-refresh
256
+ // action; card-handler routes by action prefix, so dispatch lands on
257
+ // overview-card.ts which rebuilds the parent card cleanly.
258
+ if (opts.origin === 'overview') {
259
+ actions.push({
260
+ tag: 'button',
261
+ text: { tag: 'plain_text', content: t('card.dashboard.overview.back_button', undefined, opts.locale) },
262
+ type: 'default',
263
+ value: {
264
+ action: BACK_TO_OVERVIEW_ACTION,
265
+ invoker_open_id: opts.invokerOpenId,
266
+ },
267
+ });
268
+ }
269
+ elements.push({ tag: 'action', actions });
270
+ elements.push({
271
+ tag: 'note',
272
+ elements: [
273
+ { tag: 'lark_md', content: t('card.dashboard.settings.footer.security', undefined, opts.locale) },
274
+ ],
275
+ });
276
+ return JSON.stringify({
277
+ config: { wide_screen_mode: true },
278
+ header: {
279
+ title: { tag: 'plain_text', content: t('card.dashboard.workflows.title', undefined, opts.locale) },
280
+ template: 'blue',
281
+ },
282
+ elements,
283
+ });
284
+ }
285
+ function renderRow(row, locale, nowMs) {
286
+ const icon = toneIcon(row.dot.tone);
287
+ // Primary: status icon + bold (workflowId || runId[0..8]) + ' ' + [status]
288
+ const primaryLabel = row.workflowId && row.workflowId.length > 0
289
+ ? row.workflowId
290
+ : row.runId.slice(0, 8);
291
+ const badge = statusBadge(row.status);
292
+ // Secondary: progress + started + updated. Only show if at least one is present.
293
+ const raw = row.raw;
294
+ const secondaryParts = [];
295
+ if (typeof raw.nodesDone === 'number' && Number.isFinite(raw.nodesDone) &&
296
+ typeof raw.nodesTotal === 'number' && Number.isFinite(raw.nodesTotal) && raw.nodesTotal > 0) {
297
+ secondaryParts.push(t('card.dashboard.workflows.progress_label', {
298
+ done: String(raw.nodesDone),
299
+ total: String(raw.nodesTotal),
300
+ }, locale));
301
+ }
302
+ if (typeof raw.startedAt === 'number' && Number.isFinite(raw.startedAt)) {
303
+ secondaryParts.push(t('card.dashboard.workflows.started_label', { rel: formatRelative(raw.startedAt, nowMs) }, locale));
304
+ }
305
+ if (typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt)) {
306
+ secondaryParts.push(t('card.dashboard.workflows.updated_label', { rel: formatRelative(raw.updatedAt, nowMs) }, locale));
307
+ }
308
+ const secondary = secondaryParts.length > 0
309
+ ? `\n<font color="grey">${escapeLarkMd(secondaryParts.join(' · '))}</font>`
310
+ : '';
311
+ return {
312
+ tag: 'div',
313
+ text: {
314
+ tag: 'lark_md',
315
+ content: `${icon} **${escapeLarkMd(primaryLabel)}** ${escapeLarkMd(badge)}` + secondary,
316
+ },
317
+ };
318
+ }
319
+ /**
320
+ * Build the workflow detail card: run metadata, optional node progress, cancel
321
+ * action, and back action.
322
+ *
323
+ * The cancel button is disabled when EITHER:
324
+ * - `computeActionAvailability(status).cancel.enabled === false`
325
+ * (terminal status: succeeded / failed / cancelled), OR
326
+ * - `chatBinding.larkAppId` is missing — no routable owner for Route B to
327
+ * proxy to. The `runCancel` helper would 409 `needs_cli_cancel` here, so
328
+ * we fail-closed at the UI layer too.
329
+ * Both disabled reasons surface as inline notes next to the button.
330
+ */
331
+ export function buildWorkflowsDetailCard(detail, opts) {
332
+ const nowMs = opts.nowMs ?? Date.now();
333
+ const elements = [];
334
+ // ─── Title — status dot + bold workflow label + runId monospace ────────
335
+ const icon = toneIcon(detail.dot.tone);
336
+ const titleLabel = detail.workflowId && detail.workflowId.length > 0
337
+ ? detail.workflowId
338
+ : detail.runId.slice(0, 8);
339
+ elements.push({
340
+ tag: 'div',
341
+ text: {
342
+ tag: 'lark_md',
343
+ content: `${icon} **${escapeLarkMd(titleLabel)}** ${escapeLarkMd(statusBadge(detail.status))}` +
344
+ `\n\`${escapeLarkMd(detail.runId)}\``,
345
+ },
346
+ });
347
+ elements.push({ tag: 'hr' });
348
+ // ─── Key/value block ───────────────────────────────────────────────────
349
+ const infoLines = [];
350
+ if (detail.workflowId) {
351
+ infoLines.push(t('card.dashboard.workflows.detail.workflow_label', { workflowId: escapeLarkMd(detail.workflowId) }, opts.locale));
352
+ }
353
+ infoLines.push(t('card.dashboard.workflows.detail.run_label', { runId: escapeLarkMd(detail.runId) }, opts.locale));
354
+ infoLines.push(t('card.dashboard.workflows.detail.status_label', { status: escapeLarkMd(detail.status) }, opts.locale));
355
+ infoLines.push(t('card.dashboard.workflows.detail.started_label', { rel: escapeLarkMd(formatRelative(detail.startedAtMs, nowMs)) }, opts.locale));
356
+ infoLines.push(t('card.dashboard.workflows.detail.updated_label', { rel: escapeLarkMd(formatRelative(detail.updatedAtMs, nowMs)) }, opts.locale));
357
+ // finished_at only surfaces on terminal states.
358
+ if (typeof detail.finishedAtMs === 'number' && Number.isFinite(detail.finishedAtMs)) {
359
+ infoLines.push(t('card.dashboard.workflows.detail.finished_label', { rel: escapeLarkMd(formatRelative(detail.finishedAtMs, nowMs)) }, opts.locale));
360
+ }
361
+ if (detail.elapsedLabel) {
362
+ infoLines.push(t('card.dashboard.workflows.detail.elapsed_label', { elapsed: escapeLarkMd(detail.elapsedLabel) }, opts.locale));
363
+ }
364
+ if (detail.progressLabel) {
365
+ infoLines.push(t('card.dashboard.workflows.detail.progress_label', { progress: escapeLarkMd(detail.progressLabel) }, opts.locale));
366
+ }
367
+ if (detail.chatBinding) {
368
+ const chatId = detail.chatBinding.chatId ?? '';
369
+ const larkAppId = detail.chatBinding.larkAppId ?? '';
370
+ const chatLabel = larkAppId
371
+ ? `${chatId} · ${larkAppId}`
372
+ : chatId;
373
+ if (chatLabel) {
374
+ infoLines.push(t('card.dashboard.workflows.detail.chat_label', { chat: escapeLarkMd(chatLabel) }, opts.locale));
375
+ }
376
+ }
377
+ elements.push({
378
+ tag: 'div',
379
+ text: {
380
+ tag: 'lark_md',
381
+ content: infoLines.map(l => `<font color="grey">${l}</font>`).join('\n'),
382
+ },
383
+ });
384
+ // ─── Nodes section (if any) ────────────────────────────────────────────
385
+ if (detail.nodes.length > 0) {
386
+ elements.push({ tag: 'hr' });
387
+ elements.push({
388
+ tag: 'div',
389
+ text: {
390
+ tag: 'lark_md',
391
+ content: `**${t('card.dashboard.workflows.detail.nodes_header', undefined, opts.locale)}**\n` +
392
+ detail.nodes.map(n => {
393
+ const name = n.name ?? n.nodeId;
394
+ const statusBit = n.status ? ` [${escapeLarkMd(n.status)}]` : '';
395
+ return `- ${n.index}. ${escapeLarkMd(name)}${statusBit}`;
396
+ }).join('\n'),
397
+ },
398
+ });
399
+ }
400
+ elements.push({ tag: 'hr' });
401
+ // ─── Action row — cancel (danger w/ confirm) + back ────────────────────
402
+ // Two-condition disable: terminal status OR missing routable owner.
403
+ const matrixAllows = detail.actions.cancel.enabled === true;
404
+ const hasOwner = typeof detail.chatBinding?.larkAppId === 'string' && detail.chatBinding.larkAppId.length > 0;
405
+ const cancelEnabled = matrixAllows && hasOwner;
406
+ // Threaded nav state — successful cancel rebuilds this same detail card,
407
+ // and the user may then press 🔙 返回 to go back to the drilldown list,
408
+ // which still needs origin/pageSize. So plumb the same fields onto both
409
+ // the cancel button (round-trip preservation) and the back button.
410
+ const backNav = {};
411
+ if (opts.origin === 'overview')
412
+ backNav.origin = 'overview';
413
+ if (typeof opts.sourcePage === 'number' && Number.isFinite(opts.sourcePage) && opts.sourcePage >= 1) {
414
+ backNav.page = String(Math.floor(opts.sourcePage));
415
+ }
416
+ if (typeof opts.pageSize === 'number'
417
+ && Number.isFinite(opts.pageSize)
418
+ && opts.pageSize > 0
419
+ && opts.pageSize !== PAGE_SIZE) {
420
+ backNav.page_size = String(Math.floor(opts.pageSize));
421
+ }
422
+ if (opts.scope === 'global')
423
+ backNav.dashboard_scope = 'global';
424
+ const cancelButton = {
425
+ tag: 'button',
426
+ text: { tag: 'plain_text', content: t('card.dashboard.workflows.btn.cancel', undefined, opts.locale) },
427
+ type: 'danger',
428
+ value: {
429
+ action: WORKFLOWS_ACTION_CANCEL,
430
+ invoker_open_id: opts.invokerOpenId,
431
+ run_id: detail.runId,
432
+ ...backNav,
433
+ },
434
+ };
435
+ if (cancelEnabled) {
436
+ // Feishu V1 card schema confirm dialog. Only attach when the button is
437
+ // actually clickable — there is no value in confirming a disabled button.
438
+ cancelButton.confirm = {
439
+ title: { tag: 'plain_text', content: t('card.dashboard.workflows.confirm.cancel.title', undefined, opts.locale) },
440
+ text: {
441
+ tag: 'plain_text',
442
+ content: t('card.dashboard.workflows.confirm.cancel.text', { runId: detail.runId }, opts.locale),
443
+ },
444
+ };
445
+ }
446
+ else {
447
+ cancelButton.disabled = true;
448
+ }
449
+ elements.push({
450
+ tag: 'action',
451
+ actions: [
452
+ cancelButton,
453
+ {
454
+ tag: 'button',
455
+ text: { tag: 'plain_text', content: t('card.dashboard.workflows.btn.back', undefined, opts.locale) },
456
+ type: 'default',
457
+ value: {
458
+ action: WORKFLOWS_ACTION_BACK_TO_LIST,
459
+ invoker_open_id: opts.invokerOpenId,
460
+ ...backNav,
461
+ },
462
+ },
463
+ ],
464
+ });
465
+ // Surface inline reason next to a disabled cancel button. Owner-missing
466
+ // takes precedence over terminal — a terminal run with no owner is still
467
+ // primarily "no routable owner" from the dashboard's POV. The mapped key
468
+ // is one of:
469
+ // card.dashboard.workflows.cancel.disabled.alreadyTerminal
470
+ // card.dashboard.workflows.cancel.disabled.noOwner
471
+ if (!cancelEnabled) {
472
+ const reasonKey = !hasOwner
473
+ ? 'card.dashboard.workflows.cancel.disabled.noOwner'
474
+ : mapCancelDisabledReason(detail.actions.cancel.reasonKey);
475
+ if (reasonKey) {
476
+ elements.push({
477
+ tag: 'note',
478
+ elements: [
479
+ { tag: 'lark_md', content: t(reasonKey, undefined, opts.locale) },
480
+ ],
481
+ });
482
+ }
483
+ }
484
+ // Footer security note (mirrors list card).
485
+ elements.push({
486
+ tag: 'note',
487
+ elements: [
488
+ { tag: 'lark_md', content: t('card.dashboard.settings.footer.security', undefined, opts.locale) },
489
+ ],
490
+ });
491
+ return JSON.stringify({
492
+ config: { wide_screen_mode: true },
493
+ header: {
494
+ title: { tag: 'plain_text', content: t('card.dashboard.workflows.detail.title', undefined, opts.locale) },
495
+ template: 'blue',
496
+ },
497
+ elements,
498
+ });
499
+ }
500
+ /** Map cancel-disabled reason keys to card i18n keys. */
501
+ function mapCancelDisabledReason(reasonKey) {
502
+ switch (reasonKey) {
503
+ case 'workflows.action.cancel.terminal':
504
+ return 'card.dashboard.workflows.cancel.disabled.alreadyTerminal';
505
+ default:
506
+ return undefined;
507
+ }
508
+ }
509
+ /**
510
+ * Sanitize user-/CLI-supplied text for inclusion in lark_md. See
511
+ * `sessions-card.ts:escapeLarkMd` for the order rationale: `&` first to
512
+ * avoid `&` → `&amp;` then `<` → `&lt;` getting re-encoded as `&amp;lt;`.
513
+ */
514
+ function escapeLarkMd(text) {
515
+ return text
516
+ .replace(/&/g, '&amp;')
517
+ .replace(/</g, '&lt;')
518
+ .replace(/>/g, '&gt;')
519
+ .replace(/([*_~`])/g, '\\$1');
520
+ }
521
+ function ackToast(textKey, locale) {
522
+ return { toast: { type: 'info', content: t(textKey, undefined, locale) } };
523
+ }
524
+ function errorToast(textKey, params, locale) {
525
+ return { toast: { type: 'error', content: t(textKey, params, locale) } };
526
+ }
527
+ /** Dispatch a `dash_workflows_*` action callback. */
528
+ export async function handleWorkflowsCardAction(data, larkAppId, deps) {
529
+ const locale = deps.locale ?? 'zh';
530
+ const value = (data.action?.value ?? {});
531
+ const operatorOpenId = data.operator?.open_id;
532
+ const action = value.action;
533
+ // ─── 1) Invoker lock — fail-closed ──────────────────────────────────
534
+ const invokerOpenId = value.invoker_open_id;
535
+ if (typeof invokerOpenId !== 'string' || !invokerOpenId) {
536
+ return ackToast('card.dashboard.settings.not_invoker', locale);
537
+ }
538
+ if (typeof operatorOpenId !== 'string' || !operatorOpenId) {
539
+ return ackToast('card.dashboard.settings.not_invoker', locale);
540
+ }
541
+ if (invokerOpenId !== operatorOpenId) {
542
+ return ackToast('card.dashboard.settings.not_invoker', locale);
543
+ }
544
+ // ─── 2) Per-bot admin gate ──────────────────────────────────────────
545
+ if (!isDashboardAdmin(larkAppId, operatorOpenId, deps)) {
546
+ return ackToast('card.dashboard.settings.owner_only', locale);
547
+ }
548
+ // Validate the action BEFORE creating the Route B client — an unknown
549
+ // action shouldn't even open a connection.
550
+ const validActions = new Set([
551
+ WORKFLOWS_ACTION_REFRESH,
552
+ WORKFLOWS_ACTION_PAGE,
553
+ WORKFLOWS_ACTION_DETAIL,
554
+ WORKFLOWS_ACTION_CANCEL,
555
+ WORKFLOWS_ACTION_BACK_TO_LIST,
556
+ ]);
557
+ if (!validActions.has(action)) {
558
+ return ackToast('card.dashboard.settings.invalid_action', locale);
559
+ }
560
+ const client = deps.createClient(larkAppId);
561
+ const now = () => (deps.nowMs ? deps.nowMs() : Date.now());
562
+ // ─── Nav state (overview drilldown) ─────────────────────────────────
563
+ // Threaded by buildWorkflowsCard onto every button.value; we parse here
564
+ // so the rebuild path keeps the same shape (5/page + 🔙 返回总览).
565
+ const navOrigin = value.origin === 'overview' ? 'overview' : undefined;
566
+ const parsedPageSize = Number.parseInt(value.page_size ?? '', 10);
567
+ const navPageSize = Number.isFinite(parsedPageSize) && parsedPageSize > 0 ? parsedPageSize : undefined;
568
+ const parsedNavPage = Number.parseInt(value.page ?? '', 10);
569
+ const navPage = Number.isFinite(parsedNavPage) && parsedNavPage >= 1 ? parsedNavPage : undefined;
570
+ const navScope = value.dashboard_scope === 'global' ? 'global' : undefined;
571
+ const listPathSuffix = navScope === 'global' ? '&scope=global' : '';
572
+ const writePathSuffix = navScope === 'global' ? '?scope=global' : '';
573
+ // ─── 3a) DETAIL — open the per-run detail card ──────────────────────
574
+ if (action === WORKFLOWS_ACTION_DETAIL) {
575
+ const runId = value.run_id;
576
+ if (typeof runId !== 'string' || !runId) {
577
+ return errorToast('card.dashboard.workflows.workflow_not_found', undefined, locale);
578
+ }
579
+ const r = await safeGetWorkflowsList(client, locale, listPathSuffix);
580
+ if ('errorResult' in r)
581
+ return r.errorResult;
582
+ const row = r.runs.find(x => x.runId === runId);
583
+ if (!row) {
584
+ return errorToast('card.dashboard.workflows.workflow_not_found', undefined, locale);
585
+ }
586
+ const detail = projectRunDetailDto(row, { nowMs: now() });
587
+ const cardJson = buildWorkflowsDetailCard(detail, {
588
+ invokerOpenId: operatorOpenId,
589
+ locale,
590
+ nowMs: now(),
591
+ origin: navOrigin,
592
+ pageSize: navPageSize,
593
+ sourcePage: navPage,
594
+ scope: navScope,
595
+ });
596
+ return { card: { type: 'raw', data: JSON.parse(cardJson) } };
597
+ }
598
+ // ─── 3b) CANCEL — refetch-based cancel, then redraw detail ──────────
599
+ if (action === WORKFLOWS_ACTION_CANCEL) {
600
+ const runId = value.run_id;
601
+ if (typeof runId !== 'string' || !runId) {
602
+ return errorToast('card.dashboard.workflows.workflow_not_found', undefined, locale);
603
+ }
604
+ // Pre-POST snapshot confirms the run still exists and lets us re-run the
605
+ // action matrix plus owner-routability checks against fresh state.
606
+ const pre = await safeGetWorkflowsList(client, locale, listPathSuffix);
607
+ if ('errorResult' in pre)
608
+ return pre.errorResult;
609
+ const before = pre.runs.find(x => x.runId === runId);
610
+ if (!before) {
611
+ return errorToast('card.dashboard.workflows.workflow_not_found', undefined, locale);
612
+ }
613
+ // Both disabled conditions fail closed before POSTing:
614
+ // (a) the model matrix allows cancel for this status,
615
+ // (b) chatBinding.larkAppId is present so Route B can route to an owner.
616
+ // Surface the same i18n keys the builder uses for disabled-button notes.
617
+ const availability = computeActionAvailability(before.status);
618
+ if (availability.cancel.enabled !== true) {
619
+ const mappedKey = mapCancelDisabledReason(availability.cancel.reasonKey)
620
+ ?? 'card.dashboard.workflows.cancel.disabled.alreadyTerminal';
621
+ return errorToast(mappedKey, undefined, locale);
622
+ }
623
+ if (typeof before.chatBinding?.larkAppId !== 'string' || before.chatBinding.larkAppId.length === 0) {
624
+ return errorToast('card.dashboard.workflows.cancel.disabled.noOwner', undefined, locale);
625
+ }
626
+ // POST cancel. Route B owner routing is the authority on whether this
627
+ // run can be cancelled; the IM layer only sanitizes the
628
+ // routing key + does the matrix check above.
629
+ let resp;
630
+ try {
631
+ resp = await client.request({
632
+ method: 'POST',
633
+ path: `/__daemon/workflows-runs/${encodeURIComponent(runId)}/cancel${writePathSuffix}`,
634
+ });
635
+ }
636
+ catch (e) {
637
+ return errorToast('card.dashboard.workflows.cancel_failed', { reason: e.message }, locale);
638
+ }
639
+ if (resp.status !== 200) {
640
+ const body = (resp.body ?? {});
641
+ const reason = String(body.error ?? `http_${resp.status}`);
642
+ // Preserve user state — do NOT redraw card on failure.
643
+ return errorToast('card.dashboard.workflows.cancel_failed', { reason }, locale);
644
+ }
645
+ // Cancel may land terminal on a separate tick. Refetch and re-project the
646
+ // fresh row; fall back to a possibly stale cancelled synth if the row is
647
+ // temporarily absent.
648
+ const postRefetch = await safeGetWorkflowsList(client, locale, listPathSuffix);
649
+ let after;
650
+ if ('errorResult' in postRefetch) {
651
+ after = undefined;
652
+ }
653
+ else {
654
+ after = postRefetch.runs.find(x => x.runId === runId);
655
+ }
656
+ if (!after) {
657
+ after = { ...before, status: 'cancelled' };
658
+ }
659
+ const detail = projectRunDetailDto(after, { nowMs: now() });
660
+ const cardJson = buildWorkflowsDetailCard(detail, {
661
+ invokerOpenId: operatorOpenId,
662
+ locale,
663
+ nowMs: now(),
664
+ origin: navOrigin,
665
+ pageSize: navPageSize,
666
+ sourcePage: navPage,
667
+ scope: navScope,
668
+ });
669
+ return { card: { type: 'raw', data: JSON.parse(cardJson) } };
670
+ }
671
+ // ─── 3c) BACK TO LIST — rebuild list card at the source page ─────────
672
+ if (action === WORKFLOWS_ACTION_BACK_TO_LIST) {
673
+ const r = await safeGetWorkflowsList(client, locale, listPathSuffix);
674
+ if ('errorResult' in r)
675
+ return r.errorResult;
676
+ const cardJson = buildWorkflowsCard(r.runs, {
677
+ invokerOpenId: operatorOpenId,
678
+ locale,
679
+ page: navPage ?? 1,
680
+ pageSize: navPageSize,
681
+ origin: navOrigin,
682
+ scope: navScope,
683
+ }, now());
684
+ return { card: { type: 'raw', data: JSON.parse(cardJson) } };
685
+ }
686
+ // ─── 3d) REFRESH + PAGE actions ───────────────────────────
687
+ // `action` is already constrained to validActions above; the only ones
688
+ // left here are REFRESH + PAGE (the other 3 returned early).
689
+ let page = 1;
690
+ if (action === WORKFLOWS_ACTION_PAGE) {
691
+ // Page comes from value.page (prev/next button) OR action.option
692
+ // (select_static "jump to page" picker). Same action key, different
693
+ // dispatch field — handler converges on one branch.
694
+ const raw = value.page ?? data.action?.option ?? '1';
695
+ const parsed = Number.parseInt(raw, 10);
696
+ if (Number.isFinite(parsed) && parsed >= 1)
697
+ page = parsed;
698
+ }
699
+ const r = await safeGetWorkflowsList(client, locale, listPathSuffix);
700
+ if ('errorResult' in r)
701
+ return r.errorResult;
702
+ const cardJson = buildWorkflowsCard(r.runs, {
703
+ invokerOpenId: operatorOpenId,
704
+ locale,
705
+ page,
706
+ pageSize: navPageSize,
707
+ origin: navOrigin,
708
+ scope: navScope,
709
+ }, now());
710
+ return {
711
+ card: { type: 'raw', data: JSON.parse(cardJson) },
712
+ };
713
+ }
714
+ /**
715
+ * GET `/__daemon/workflows-runs-snapshot?all=1` and surface non-200 / network
716
+ * errors as caller-facing error toasts. `?all=1` is required because default
717
+ * listRuns hides terminal runs (succeeded/failed/cancelled), so the
718
+ * counters and the detail/cancel paths would miss recently-terminated runs
719
+ * without it. Returns either `{ runs }` or `{ errorResult }` — exactly one
720
+ * is set.
721
+ */
722
+ async function safeGetWorkflowsList(client, locale, pathSuffix = '') {
723
+ let r;
724
+ try {
725
+ r = await client.request({ method: 'GET', path: `/__daemon/workflows-runs-snapshot?all=1${pathSuffix}` });
726
+ }
727
+ catch (e) {
728
+ return { errorResult: errorToast('card.dashboard.workflows.list_failed', { reason: e.message }, locale) };
729
+ }
730
+ if (r.status !== 200) {
731
+ const reason = String(r.body?.error ?? `http_${r.status}`);
732
+ return { errorResult: errorToast('card.dashboard.workflows.list_failed', { reason }, locale) };
733
+ }
734
+ const runs = (r.body?.runs) ?? [];
735
+ return { runs };
736
+ }
737
+ //# sourceMappingURL=workflows-card.js.map