botmux 2.88.0 → 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 (178) 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/core/session-create.d.ts +0 -1
  54. package/dist/core/session-create.d.ts.map +1 -1
  55. package/dist/core/session-create.js +0 -3
  56. package/dist/core/session-create.js.map +1 -1
  57. package/dist/core/session-manager.d.ts.map +1 -1
  58. package/dist/core/session-manager.js +3 -2
  59. package/dist/core/session-manager.js.map +1 -1
  60. package/dist/daemon-internal-client-wrapper.d.ts +41 -0
  61. package/dist/daemon-internal-client-wrapper.d.ts.map +1 -0
  62. package/dist/daemon-internal-client-wrapper.js +65 -0
  63. package/dist/daemon-internal-client-wrapper.js.map +1 -0
  64. package/dist/dashboard/aggregator.d.ts +12 -0
  65. package/dist/dashboard/aggregator.d.ts.map +1 -1
  66. package/dist/dashboard/aggregator.js +16 -0
  67. package/dist/dashboard/aggregator.js.map +1 -1
  68. package/dist/dashboard/auth.d.ts +7 -0
  69. package/dist/dashboard/auth.d.ts.map +1 -1
  70. package/dist/dashboard/auth.js +21 -0
  71. package/dist/dashboard/auth.js.map +1 -1
  72. package/dist/dashboard/card-model-types.d.ts +50 -0
  73. package/dist/dashboard/card-model-types.d.ts.map +1 -0
  74. package/dist/dashboard/card-model-types.js +9 -0
  75. package/dist/dashboard/card-model-types.js.map +1 -0
  76. package/dist/dashboard/daemon-internal-api.d.ts +90 -0
  77. package/dist/dashboard/daemon-internal-api.d.ts.map +1 -0
  78. package/dist/dashboard/daemon-internal-api.js +534 -0
  79. package/dist/dashboard/daemon-internal-api.js.map +1 -0
  80. package/dist/dashboard/daemon-internal-auth.d.ts +124 -0
  81. package/dist/dashboard/daemon-internal-auth.d.ts.map +1 -0
  82. package/dist/dashboard/daemon-internal-auth.js +188 -0
  83. package/dist/dashboard/daemon-internal-auth.js.map +1 -0
  84. package/dist/dashboard/daemon-internal-client.d.ts +67 -0
  85. package/dist/dashboard/daemon-internal-client.d.ts.map +1 -0
  86. package/dist/dashboard/daemon-internal-client.js +157 -0
  87. package/dist/dashboard/daemon-internal-client.js.map +1 -0
  88. package/dist/dashboard/dashboard-admins.d.ts +8 -0
  89. package/dist/dashboard/dashboard-admins.d.ts.map +1 -0
  90. package/dist/dashboard/dashboard-admins.js +15 -0
  91. package/dist/dashboard/dashboard-admins.js.map +1 -0
  92. package/dist/dashboard/federation-spoke-api.d.ts +3 -0
  93. package/dist/dashboard/federation-spoke-api.d.ts.map +1 -1
  94. package/dist/dashboard/federation-spoke-api.js +11 -8
  95. package/dist/dashboard/federation-spoke-api.js.map +1 -1
  96. package/dist/dashboard/groups-action-helpers.d.ts +69 -0
  97. package/dist/dashboard/groups-action-helpers.d.ts.map +1 -0
  98. package/dist/dashboard/groups-action-helpers.js +141 -0
  99. package/dist/dashboard/groups-action-helpers.js.map +1 -0
  100. package/dist/dashboard/groups-card-model.d.ts +120 -0
  101. package/dist/dashboard/groups-card-model.d.ts.map +1 -0
  102. package/dist/dashboard/groups-card-model.js +175 -0
  103. package/dist/dashboard/groups-card-model.js.map +1 -0
  104. package/dist/dashboard/schedule-card-model.d.ts +172 -0
  105. package/dist/dashboard/schedule-card-model.d.ts.map +1 -0
  106. package/dist/dashboard/schedule-card-model.js +290 -0
  107. package/dist/dashboard/schedule-card-model.js.map +1 -0
  108. package/dist/dashboard/session-card-model.d.ts +83 -0
  109. package/dist/dashboard/session-card-model.d.ts.map +1 -0
  110. package/dist/dashboard/session-card-model.js +196 -0
  111. package/dist/dashboard/session-card-model.js.map +1 -0
  112. package/dist/dashboard/settings-card-model.d.ts +73 -0
  113. package/dist/dashboard/settings-card-model.d.ts.map +1 -0
  114. package/dist/dashboard/settings-card-model.js +94 -0
  115. package/dist/dashboard/settings-card-model.js.map +1 -0
  116. package/dist/dashboard/settings-owner-resolver.d.ts +32 -0
  117. package/dist/dashboard/settings-owner-resolver.d.ts.map +1 -0
  118. package/dist/dashboard/settings-owner-resolver.js +43 -0
  119. package/dist/dashboard/settings-owner-resolver.js.map +1 -0
  120. package/dist/dashboard/settings-write-applier.d.ts +84 -0
  121. package/dist/dashboard/settings-write-applier.d.ts.map +1 -0
  122. package/dist/dashboard/settings-write-applier.js +119 -0
  123. package/dist/dashboard/settings-write-applier.js.map +1 -0
  124. package/dist/dashboard/workflow-api.d.ts.map +1 -1
  125. package/dist/dashboard/workflow-api.js +38 -119
  126. package/dist/dashboard/workflow-api.js.map +1 -1
  127. package/dist/dashboard/workflow-card-model.d.ts +127 -0
  128. package/dist/dashboard/workflow-card-model.d.ts.map +1 -0
  129. package/dist/dashboard/workflow-card-model.js +186 -0
  130. package/dist/dashboard/workflow-card-model.js.map +1 -0
  131. package/dist/dashboard/workflows-action-helpers.d.ts +105 -0
  132. package/dist/dashboard/workflows-action-helpers.d.ts.map +1 -0
  133. package/dist/dashboard/workflows-action-helpers.js +196 -0
  134. package/dist/dashboard/workflows-action-helpers.js.map +1 -0
  135. package/dist/dashboard.js +196 -271
  136. package/dist/dashboard.js.map +1 -1
  137. package/dist/i18n/en.d.ts.map +1 -1
  138. package/dist/i18n/en.js +266 -0
  139. package/dist/i18n/en.js.map +1 -1
  140. package/dist/i18n/zh.d.ts.map +1 -1
  141. package/dist/i18n/zh.js +266 -0
  142. package/dist/i18n/zh.js.map +1 -1
  143. package/dist/im/lark/card-builder.d.ts +2 -0
  144. package/dist/im/lark/card-builder.d.ts.map +1 -1
  145. package/dist/im/lark/card-builder.js +2 -1
  146. package/dist/im/lark/card-builder.js.map +1 -1
  147. package/dist/im/lark/card-handler.d.ts +54 -2
  148. package/dist/im/lark/card-handler.d.ts.map +1 -1
  149. package/dist/im/lark/card-handler.js +127 -1
  150. package/dist/im/lark/card-handler.js.map +1 -1
  151. package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
  152. package/dist/im/lark/event-dispatcher.js +22 -0
  153. package/dist/im/lark/event-dispatcher.js.map +1 -1
  154. package/dist/im/lark/groups-card.d.ts +90 -0
  155. package/dist/im/lark/groups-card.d.ts.map +1 -0
  156. package/dist/im/lark/groups-card.js +832 -0
  157. package/dist/im/lark/groups-card.js.map +1 -0
  158. package/dist/im/lark/overview-card.d.ts +101 -0
  159. package/dist/im/lark/overview-card.d.ts.map +1 -0
  160. package/dist/im/lark/overview-card.js +475 -0
  161. package/dist/im/lark/overview-card.js.map +1 -0
  162. package/dist/im/lark/schedules-card.d.ts +96 -0
  163. package/dist/im/lark/schedules-card.d.ts.map +1 -0
  164. package/dist/im/lark/schedules-card.js +892 -0
  165. package/dist/im/lark/schedules-card.js.map +1 -0
  166. package/dist/im/lark/sessions-card.d.ts +117 -0
  167. package/dist/im/lark/sessions-card.d.ts.map +1 -0
  168. package/dist/im/lark/sessions-card.js +870 -0
  169. package/dist/im/lark/sessions-card.js.map +1 -0
  170. package/dist/im/lark/settings-card.d.ts +125 -0
  171. package/dist/im/lark/settings-card.d.ts.map +1 -0
  172. package/dist/im/lark/settings-card.js +433 -0
  173. package/dist/im/lark/settings-card.js.map +1 -0
  174. package/dist/im/lark/workflows-card.d.ts +102 -0
  175. package/dist/im/lark/workflows-card.d.ts.map +1 -0
  176. package/dist/im/lark/workflows-card.js +737 -0
  177. package/dist/im/lark/workflows-card.js.map +1 -0
  178. package/package.json +1 -1
@@ -0,0 +1,892 @@
1
+ /**
2
+ * Schedules 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 pause/resume, delivery-mode
6
+ * switching, and back actions. Run-now remains outside this card because it is
7
+ * a one-shot side effect rather than a reversible state change.
8
+ *
9
+ * Pause/resume callbacks block on Route B and return a rebuilt card on success.
10
+ * Pause can be rendered from the pre-POST snapshot plus `enabled:false`; resume
11
+ * refetches because scheduler-owned nextRunAt calculation must remain
12
+ * authoritative. Failures return toast-only and leave the card unchanged.
13
+ *
14
+ * Security:
15
+ * - `invokerOpenId` pins callbacks to the admin who opened the card.
16
+ * - sender union_id never lands on `action.value`.
17
+ * - command entry and callbacks both enforce the dashboard admin gate.
18
+ * - row ids are routing keys only; Route B still enforces row ownership.
19
+ * - write actions re-run the model availability matrix before POSTing.
20
+ */
21
+ import { isDashboardAdmin } from '../../dashboard/dashboard-admins.js';
22
+ import { computeDeliveryButtonAvailability, computeButtonAvailability, normalizeScheduleDelivery, paginateSchedules, toScheduleDetailDto, toScheduleRowDto, } from '../../dashboard/schedule-card-model.js';
23
+ import { t } from '../../i18n/index.js';
24
+ export const SCHEDULES_ACTION_REFRESH = 'dash_schedules_refresh';
25
+ export const SCHEDULES_ACTION_PAGE = 'dash_schedules_page';
26
+ export const SCHEDULES_ACTION_DETAIL = 'dash_schedules_detail';
27
+ export const SCHEDULES_ACTION_PAUSE = 'dash_schedules_pause';
28
+ export const SCHEDULES_ACTION_RESUME = 'dash_schedules_resume';
29
+ export const SCHEDULES_ACTION_DELIVERY = 'dash_schedules_delivery';
30
+ export const SCHEDULES_ACTION_BACK_TO_LIST = 'dash_schedules_back_to_list';
31
+ /** Action emitted by "🔙 返回总览" on overview-origin sub-cards. Same string
32
+ * as overview-card's OVERVIEW_ACTION_REFRESH (kept in sync; we don't import
33
+ * to avoid a circular dep). */
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. */
38
+ const JUMP_PAGE_MAX_OPTIONS = 50;
39
+ function toneIcon(tone) {
40
+ switch (tone) {
41
+ case 'success': return '🟢';
42
+ case 'info': return '🔵';
43
+ case 'warning': return '🟡';
44
+ case 'danger': return '🔴';
45
+ case 'neutral': return '⚪';
46
+ default: return '⚫';
47
+ }
48
+ }
49
+ function routeBWriteFailureReason(resp) {
50
+ const body = (resp.body ?? {});
51
+ if (resp.status !== 200)
52
+ return String(body.error ?? `http_${resp.status}`);
53
+ if (body.ok === false)
54
+ return String(body.error ?? 'ok_false');
55
+ return undefined;
56
+ }
57
+ /**
58
+ * Sort schedules per the Web UI semantics:
59
+ * - enabled first (paused tasks sink),
60
+ * - then by earliest `nextRunAt` (ISO string compare works because ISO
61
+ * strings sort lexicographically same as chronologically).
62
+ * - tasks without `nextRunAt` sort last within their enabled group.
63
+ */
64
+ function sortForList(tasks) {
65
+ return tasks.slice().sort((a, b) => {
66
+ if (a.enabled !== b.enabled)
67
+ return a.enabled ? -1 : 1;
68
+ const an = a.nextRunAt ?? '';
69
+ const bn = b.nextRunAt ?? '';
70
+ if (an === bn)
71
+ return 0;
72
+ if (!an)
73
+ return 1;
74
+ if (!bn)
75
+ return -1;
76
+ return an < bn ? -1 : 1;
77
+ });
78
+ }
79
+ /** Build the schedules list card JSON. Pure (sorts + paginates + renders). */
80
+ export function buildSchedulesCard(tasks, opts, nowMs) {
81
+ const effectivePageSize = typeof opts.pageSize === 'number' && Number.isFinite(opts.pageSize) && opts.pageSize > 0
82
+ ? Math.floor(opts.pageSize)
83
+ : PAGE_SIZE;
84
+ const sorted = sortForList(tasks);
85
+ const { items, total, page, totalPages } = paginateSchedules(sorted, opts.page, effectivePageSize);
86
+ // Plumb origin + page_size + scope onto every button.value so refresh/
87
+ // page/detail/detail-back rebuilds keep the same drilldown state.
88
+ const navFields = {};
89
+ if (opts.origin === 'overview')
90
+ navFields.origin = 'overview';
91
+ if (effectivePageSize !== PAGE_SIZE)
92
+ navFields.page_size = String(effectivePageSize);
93
+ if (opts.scope === 'global')
94
+ navFields.dashboard_scope = 'global';
95
+ const enabledCount = sorted.filter(t => t.enabled).length;
96
+ const pausedCount = total - enabledCount;
97
+ const elements = [];
98
+ elements.push({
99
+ tag: 'div',
100
+ text: {
101
+ tag: 'lark_md',
102
+ content: t('card.dashboard.schedules.count_summary', {
103
+ enabled: String(enabledCount),
104
+ paused: String(pausedCount),
105
+ page: String(page),
106
+ totalPages: String(totalPages),
107
+ }, opts.locale),
108
+ },
109
+ });
110
+ elements.push({ tag: 'hr' });
111
+ if (items.length === 0) {
112
+ elements.push({
113
+ tag: 'div',
114
+ text: {
115
+ tag: 'lark_md',
116
+ content: t('card.dashboard.schedules.empty', undefined, opts.locale),
117
+ },
118
+ });
119
+ }
120
+ else {
121
+ for (const task of items) {
122
+ const dto = toScheduleRowDto(task, { nowMs });
123
+ // Row text element.
124
+ elements.push(renderRow(dto, opts.locale, opts.scope === 'global'));
125
+ // Per-row action element holding ONLY the "📂 详情" button.
126
+ // Keeping each row's actions in its own `action` element (rather than
127
+ // one shared element for the whole page) makes the visual layout
128
+ // align with the row above and lets us pass the row's schedule id
129
+ // through `value.schedule_id` as the routing key.
130
+ elements.push({
131
+ tag: 'action',
132
+ actions: [
133
+ {
134
+ tag: 'button',
135
+ text: { tag: 'plain_text', content: t('card.dashboard.schedules.row_detail', undefined, opts.locale) },
136
+ type: 'default',
137
+ value: {
138
+ action: SCHEDULES_ACTION_DETAIL,
139
+ invoker_open_id: opts.invokerOpenId,
140
+ schedule_id: dto.id,
141
+ page: String(page),
142
+ ...navFields,
143
+ },
144
+ },
145
+ ],
146
+ });
147
+ }
148
+ }
149
+ elements.push({ tag: 'hr' });
150
+ // Pagination + refresh.
151
+ const actions = [];
152
+ if (totalPages > 1) {
153
+ actions.push({
154
+ tag: 'button',
155
+ text: { tag: 'plain_text', content: t('card.dashboard.schedules.prev', undefined, opts.locale) },
156
+ type: 'default',
157
+ disabled: page <= 1,
158
+ value: {
159
+ action: SCHEDULES_ACTION_PAGE,
160
+ invoker_open_id: opts.invokerOpenId,
161
+ page: String(Math.max(1, page - 1)),
162
+ ...navFields,
163
+ },
164
+ });
165
+ actions.push({
166
+ tag: 'button',
167
+ text: { tag: 'plain_text', content: t('card.dashboard.schedules.next', undefined, opts.locale) },
168
+ type: 'default',
169
+ disabled: page >= totalPages,
170
+ value: {
171
+ action: SCHEDULES_ACTION_PAGE,
172
+ invoker_open_id: opts.invokerOpenId,
173
+ page: String(Math.min(totalPages, page + 1)),
174
+ ...navFields,
175
+ },
176
+ });
177
+ // "Jump to page" select — same action as prev/next, page comes via
178
+ // action.option. Handler reads `value.page ?? action.option ?? '1'`.
179
+ // Capped at JUMP_PAGE_MAX_OPTIONS to keep payload small.
180
+ if (totalPages > 2 && totalPages <= JUMP_PAGE_MAX_OPTIONS) {
181
+ const options = Array.from({ length: totalPages }, (_, i) => {
182
+ const n = i + 1;
183
+ return {
184
+ text: { tag: 'plain_text', content: t('card.dashboard.schedules.jump_page', { n: String(n), total: String(totalPages) }, opts.locale) },
185
+ value: String(n),
186
+ };
187
+ });
188
+ actions.push({
189
+ tag: 'select_static',
190
+ placeholder: {
191
+ tag: 'plain_text',
192
+ content: t('card.dashboard.schedules.jump_page', { n: String(page), total: String(totalPages) }, opts.locale),
193
+ },
194
+ initial_option: String(page),
195
+ options,
196
+ value: {
197
+ action: SCHEDULES_ACTION_PAGE,
198
+ invoker_open_id: opts.invokerOpenId,
199
+ ...navFields,
200
+ },
201
+ });
202
+ }
203
+ }
204
+ actions.push({
205
+ tag: 'button',
206
+ text: { tag: 'plain_text', content: t('card.dashboard.schedules.refresh', undefined, opts.locale) },
207
+ type: 'default',
208
+ value: {
209
+ action: SCHEDULES_ACTION_REFRESH,
210
+ invoker_open_id: opts.invokerOpenId,
211
+ ...navFields,
212
+ },
213
+ });
214
+ // Overview drilldown only — back-to-overview reuses overview-refresh
215
+ // action; card-handler routes by action prefix.
216
+ if (opts.origin === 'overview') {
217
+ actions.push({
218
+ tag: 'button',
219
+ text: { tag: 'plain_text', content: t('card.dashboard.overview.back_button', undefined, opts.locale) },
220
+ type: 'default',
221
+ value: {
222
+ action: BACK_TO_OVERVIEW_ACTION,
223
+ invoker_open_id: opts.invokerOpenId,
224
+ },
225
+ });
226
+ }
227
+ elements.push({ tag: 'action', actions });
228
+ elements.push({
229
+ tag: 'note',
230
+ elements: [
231
+ { tag: 'lark_md', content: t('card.dashboard.settings.footer.security', undefined, opts.locale) },
232
+ ],
233
+ });
234
+ return JSON.stringify({
235
+ config: { wide_screen_mode: true },
236
+ header: {
237
+ title: { tag: 'plain_text', content: t('card.dashboard.schedules.title', undefined, opts.locale) },
238
+ template: 'blue',
239
+ },
240
+ elements,
241
+ });
242
+ }
243
+ function renderRow(row, locale, showBotLabel) {
244
+ const icon = toneIcon(row.dot.tone);
245
+ const errorGlyph = row.errorIndicator ? ' ⚠️' : '';
246
+ // Primary: status icon + bold name + short id (8 chars) for traceability —
247
+ // schedules can share names (e.g. two `daily-ping` cron tasks in different
248
+ // chats), so we surface a stable disambiguator.
249
+ // Secondary: kind · displayExpr · next · last (· repeat counter if finite)
250
+ const shortId = row.id ? row.id.slice(0, 8) : '';
251
+ const repeatStr = row.repeat
252
+ ? row.repeat.times === null
253
+ ? `${row.repeat.completed}/∞`
254
+ : `${row.repeat.completed}/${row.repeat.times}`
255
+ : undefined;
256
+ const secondaryParts = [
257
+ row.kind,
258
+ row.displayExpr,
259
+ t('card.dashboard.schedules.next_label', { rel: row.nextRunRelative }, locale),
260
+ t('card.dashboard.schedules.last_label', { rel: row.lastRunRelative }, locale),
261
+ ];
262
+ if (repeatStr) {
263
+ secondaryParts.push(t('card.dashboard.schedules.repeat_label', { repeat: repeatStr }, locale));
264
+ }
265
+ // In global scope, prefix rows with the owning bot so same-name schedules
266
+ // remain distinguishable.
267
+ // Preference: row.raw.botName → larkAppId short-suffix → '—'.
268
+ if (showBotLabel) {
269
+ const botLabel = botLabelFromRow(row);
270
+ secondaryParts.unshift(t('card.dashboard.schedules.bot_label', { bot: botLabel }, locale));
271
+ }
272
+ const idSuffix = shortId ? ` <font color="grey">${escapeLarkMd(shortId)}</font>` : '';
273
+ return {
274
+ tag: 'div',
275
+ text: {
276
+ tag: 'lark_md',
277
+ content: `${icon} **${escapeLarkMd(row.name)}**${idSuffix}${errorGlyph}` +
278
+ `\n<font color="grey">${escapeLarkMd(secondaryParts.join(' · '))}</font>`,
279
+ },
280
+ };
281
+ }
282
+ /** Resolve the human label for a schedule's owning bot. */
283
+ function botLabelFromRow(row) {
284
+ const raw = row.raw;
285
+ if (typeof raw.botName === 'string' && raw.botName.length > 0)
286
+ return raw.botName;
287
+ if (typeof raw.larkAppId === 'string' && raw.larkAppId.length > 0) {
288
+ return `bot:${raw.larkAppId.slice(-6)}`;
289
+ }
290
+ return '—';
291
+ }
292
+ /** Resolve the owning bot label for a schedule detail row. */
293
+ function botLabelFromDetail(detail) {
294
+ const raw = detail.raw;
295
+ if (typeof raw.botName === 'string' && raw.botName.length > 0)
296
+ return raw.botName;
297
+ if (typeof detail.larkAppId === 'string' && detail.larkAppId.length > 0) {
298
+ return `bot:${detail.larkAppId.slice(-6)}`;
299
+ }
300
+ if (typeof raw.larkAppId === 'string' && raw.larkAppId.length > 0) {
301
+ return `bot:${raw.larkAppId.slice(-6)}`;
302
+ }
303
+ return '—';
304
+ }
305
+ function deliveryLabel(deliver, locale) {
306
+ switch (deliver) {
307
+ case 'new-topic':
308
+ return t('card.dashboard.schedules.delivery.new_topic', undefined, locale);
309
+ case 'local':
310
+ return t('card.dashboard.schedules.delivery.local', undefined, locale);
311
+ case 'origin':
312
+ default:
313
+ return t('card.dashboard.schedules.delivery.origin', undefined, locale);
314
+ }
315
+ }
316
+ function nextDeliveryTarget(deliver) {
317
+ return deliver === 'new-topic' ? 'origin' : 'new-topic';
318
+ }
319
+ /**
320
+ * Build the schedule detail card: task metadata, next-run preview, reversible
321
+ * state controls, delivery-mode switch, and back action.
322
+ *
323
+ * Pause and resume are mutually exclusive per the model matrix:
324
+ * enabled === true → pause clickable, resume disabled (alreadyEnabled)
325
+ * enabled === false → pause disabled (alreadyPaused), resume clickable
326
+ *
327
+ * The disabled button is rendered with `disabled:true` and a small reason
328
+ * note next to it via `mapPauseDisabledReason` / `mapResumeDisabledReason`.
329
+ */
330
+ export function buildSchedulesDetailCard(detail, opts) {
331
+ const elements = [];
332
+ // ─── Title — status dot + bold name + id monospace ─────────────────────
333
+ const enabledIcon = detail.enabled
334
+ ? (detail.errorIndicator ? '🔴' : '🟢')
335
+ : '⚪';
336
+ elements.push({
337
+ tag: 'div',
338
+ text: {
339
+ tag: 'lark_md',
340
+ content: `${enabledIcon} **${escapeLarkMd(detail.name)}**` +
341
+ `\n\`${escapeLarkMd(detail.id)}\``,
342
+ },
343
+ });
344
+ elements.push({ tag: 'hr' });
345
+ // ─── Key/value block ───────────────────────────────────────────────────
346
+ const enabledLabel = detail.enabled
347
+ ? t('card.dashboard.schedules.detail.enabled.active', undefined, opts.locale)
348
+ : t('card.dashboard.schedules.detail.enabled.paused', undefined, opts.locale);
349
+ const infoLines = [];
350
+ infoLines.push(t('card.dashboard.schedules.detail.name_label', { name: escapeLarkMd(detail.name) }, opts.locale));
351
+ infoLines.push(t('card.dashboard.schedules.detail.enabled_label', { status: enabledLabel }, opts.locale));
352
+ infoLines.push(t('card.dashboard.schedules.detail.kind_label', { kind: escapeLarkMd(detail.kind) }, opts.locale));
353
+ infoLines.push(t('card.dashboard.schedules.detail.display_label', { expr: escapeLarkMd(detail.displayExpr) }, opts.locale));
354
+ infoLines.push(t('card.dashboard.schedules.detail.delivery_label', { delivery: escapeLarkMd(deliveryLabel(detail.deliver, opts.locale)) }, opts.locale));
355
+ if (opts.scope === 'global') {
356
+ infoLines.push(t('card.dashboard.schedules.detail.owner_label', {
357
+ bot: escapeLarkMd(botLabelFromDetail(detail)),
358
+ chat: escapeLarkMd(detail.chatId ?? '—'),
359
+ }, opts.locale));
360
+ }
361
+ infoLines.push(t('card.dashboard.schedules.detail.next_label', { time: escapeLarkMd(detail.nextRunAt ?? '—') }, opts.locale));
362
+ infoLines.push(t('card.dashboard.schedules.detail.last_label', { time: escapeLarkMd(detail.lastRunAt ?? '—') }, opts.locale));
363
+ if (detail.lastStatus) {
364
+ infoLines.push(t('card.dashboard.schedules.detail.status_label', { status: escapeLarkMd(detail.lastStatus) }, opts.locale));
365
+ }
366
+ if (detail.repeat) {
367
+ const repeatStr = detail.repeat.times === null
368
+ ? `${detail.repeat.completed}/∞`
369
+ : `${detail.repeat.completed}/${detail.repeat.times}`;
370
+ infoLines.push(t('card.dashboard.schedules.detail.repeat_label', { repeat: repeatStr }, opts.locale));
371
+ }
372
+ if (detail.prompt) {
373
+ // `detail.prompt` is already truncated by `toScheduleDetailDto`. When the
374
+ // raw prompt was longer than `promptTruncateAt` the DTO appends `…`, so
375
+ // we can render it verbatim and rely on that visible marker.
376
+ infoLines.push(t('card.dashboard.schedules.detail.prompt_label', { prompt: escapeLarkMd(detail.prompt) }, opts.locale));
377
+ }
378
+ elements.push({
379
+ tag: 'div',
380
+ text: {
381
+ tag: 'lark_md',
382
+ content: infoLines.map(l => `<font color="grey">${l}</font>`).join('\n'),
383
+ },
384
+ });
385
+ // ─── Next runs section (if any) ────────────────────────────────────────
386
+ if (detail.nextRuns.length > 0) {
387
+ elements.push({ tag: 'hr' });
388
+ elements.push({
389
+ tag: 'div',
390
+ text: {
391
+ tag: 'lark_md',
392
+ content: `**${t('card.dashboard.schedules.detail.next_runs_header', undefined, opts.locale)}**\n` +
393
+ detail.nextRuns.map(iso => `- ${escapeLarkMd(iso)}`).join('\n'),
394
+ },
395
+ });
396
+ }
397
+ elements.push({ tag: 'hr' });
398
+ // Threaded nav state — pause/resume rebuild this same detail card on
399
+ // success, so users may then press 🔙 返回 and expect the drilldown list.
400
+ const navFields = {};
401
+ if (opts.origin === 'overview')
402
+ navFields.origin = 'overview';
403
+ if (typeof opts.sourcePage === 'number' && Number.isFinite(opts.sourcePage) && opts.sourcePage >= 1) {
404
+ navFields.page = String(Math.floor(opts.sourcePage));
405
+ }
406
+ if (typeof opts.pageSize === 'number'
407
+ && Number.isFinite(opts.pageSize)
408
+ && opts.pageSize > 0
409
+ && opts.pageSize !== PAGE_SIZE) {
410
+ navFields.page_size = String(Math.floor(opts.pageSize));
411
+ }
412
+ if (opts.scope === 'global')
413
+ navFields.dashboard_scope = 'global';
414
+ // ─── Action row — pause / resume + delivery (mutually exclusive) + back ─
415
+ const pauseEnabled = detail.actions.pause.enabled === true;
416
+ const resumeEnabled = detail.actions.resume.enabled === true;
417
+ const currentDelivery = normalizeScheduleDelivery(detail.raw.deliver);
418
+ const deliveryTarget = nextDeliveryTarget(currentDelivery);
419
+ const deliveryButtonState = computeDeliveryButtonAvailability(detail.raw, deliveryTarget);
420
+ const deliveryEnabled = deliveryButtonState.enabled === true;
421
+ const pauseButton = {
422
+ tag: 'button',
423
+ text: { tag: 'plain_text', content: t('card.dashboard.schedules.btn.pause', undefined, opts.locale) },
424
+ type: pauseEnabled ? 'primary' : 'default',
425
+ value: {
426
+ action: SCHEDULES_ACTION_PAUSE,
427
+ invoker_open_id: opts.invokerOpenId,
428
+ schedule_id: detail.id,
429
+ ...navFields,
430
+ },
431
+ };
432
+ if (!pauseEnabled)
433
+ pauseButton.disabled = true;
434
+ const resumeButton = {
435
+ tag: 'button',
436
+ text: { tag: 'plain_text', content: t('card.dashboard.schedules.btn.resume', undefined, opts.locale) },
437
+ type: resumeEnabled ? 'primary' : 'default',
438
+ value: {
439
+ action: SCHEDULES_ACTION_RESUME,
440
+ invoker_open_id: opts.invokerOpenId,
441
+ schedule_id: detail.id,
442
+ ...navFields,
443
+ },
444
+ };
445
+ if (!resumeEnabled)
446
+ resumeButton.disabled = true;
447
+ const deliveryButton = {
448
+ tag: 'button',
449
+ text: {
450
+ tag: 'plain_text',
451
+ content: t(deliveryTarget === 'new-topic'
452
+ ? 'card.dashboard.schedules.btn.use_new_topic'
453
+ : 'card.dashboard.schedules.btn.use_origin', undefined, opts.locale),
454
+ },
455
+ type: deliveryEnabled ? 'primary' : 'default',
456
+ value: {
457
+ action: SCHEDULES_ACTION_DELIVERY,
458
+ invoker_open_id: opts.invokerOpenId,
459
+ schedule_id: detail.id,
460
+ target_delivery: deliveryTarget,
461
+ ...navFields,
462
+ },
463
+ };
464
+ if (!deliveryEnabled)
465
+ deliveryButton.disabled = true;
466
+ elements.push({
467
+ tag: 'action',
468
+ actions: [
469
+ pauseButton,
470
+ resumeButton,
471
+ deliveryButton,
472
+ {
473
+ tag: 'button',
474
+ text: { tag: 'plain_text', content: t('card.dashboard.schedules.btn.back', undefined, opts.locale) },
475
+ type: 'default',
476
+ value: {
477
+ action: SCHEDULES_ACTION_BACK_TO_LIST,
478
+ invoker_open_id: opts.invokerOpenId,
479
+ ...navFields,
480
+ },
481
+ },
482
+ ],
483
+ });
484
+ // Surface reasonKey notes for whichever button is disabled. Pause/resume
485
+ // are mutually exclusive today, but render both branches defensively in case
486
+ // the matrix evolves.
487
+ if (!pauseEnabled) {
488
+ const reasonKey = mapPauseDisabledReason(detail.actions.pause.reasonKey);
489
+ if (reasonKey) {
490
+ elements.push({
491
+ tag: 'note',
492
+ elements: [
493
+ { tag: 'lark_md', content: t(reasonKey, undefined, opts.locale) },
494
+ ],
495
+ });
496
+ }
497
+ }
498
+ if (!resumeEnabled) {
499
+ const reasonKey = mapResumeDisabledReason(detail.actions.resume.reasonKey);
500
+ if (reasonKey) {
501
+ elements.push({
502
+ tag: 'note',
503
+ elements: [
504
+ { tag: 'lark_md', content: t(reasonKey, undefined, opts.locale) },
505
+ ],
506
+ });
507
+ }
508
+ }
509
+ if (!deliveryEnabled) {
510
+ const reasonKey = mapDeliveryDisabledReason(deliveryButtonState.reasonKey);
511
+ if (reasonKey) {
512
+ elements.push({
513
+ tag: 'note',
514
+ elements: [
515
+ { tag: 'lark_md', content: t(reasonKey, undefined, opts.locale) },
516
+ ],
517
+ });
518
+ }
519
+ }
520
+ // Footer security note (mirrors list card).
521
+ elements.push({
522
+ tag: 'note',
523
+ elements: [
524
+ { tag: 'lark_md', content: t('card.dashboard.settings.footer.security', undefined, opts.locale) },
525
+ ],
526
+ });
527
+ return JSON.stringify({
528
+ config: { wide_screen_mode: true },
529
+ header: {
530
+ title: { tag: 'plain_text', content: t('card.dashboard.schedules.detail.title', undefined, opts.locale) },
531
+ template: 'blue',
532
+ },
533
+ elements,
534
+ });
535
+ }
536
+ /** Map pause-disabled reason keys to card i18n keys. */
537
+ function mapPauseDisabledReason(reasonKey) {
538
+ switch (reasonKey) {
539
+ case 'schedules.action.pause.alreadyPaused':
540
+ return 'card.dashboard.schedules.pause.disabled.alreadyPaused';
541
+ default:
542
+ return undefined;
543
+ }
544
+ }
545
+ /** Map resume-disabled reason keys to card i18n keys. */
546
+ function mapResumeDisabledReason(reasonKey) {
547
+ switch (reasonKey) {
548
+ case 'schedules.action.resume.alreadyEnabled':
549
+ return 'card.dashboard.schedules.resume.disabled.alreadyEnabled';
550
+ default:
551
+ return undefined;
552
+ }
553
+ }
554
+ function mapDeliveryDisabledReason(reasonKey) {
555
+ switch (reasonKey) {
556
+ case 'schedules.action.delivery.local':
557
+ return 'card.dashboard.schedules.delivery.disabled.local';
558
+ case 'schedules.action.delivery.alreadyOrigin':
559
+ return 'card.dashboard.schedules.delivery.disabled.alreadyOrigin';
560
+ case 'schedules.action.delivery.alreadyNewTopic':
561
+ return 'card.dashboard.schedules.delivery.disabled.alreadyNewTopic';
562
+ default:
563
+ return undefined;
564
+ }
565
+ }
566
+ /**
567
+ * Sanitize user-/CLI-supplied text for inclusion in lark_md. See
568
+ * `sessions-card.ts:escapeLarkMd` for the order rationale: `&` first to
569
+ * avoid `&` → `&amp;` then `<` → `&lt;` getting re-encoded as `&amp;lt;`.
570
+ */
571
+ function escapeLarkMd(text) {
572
+ return text
573
+ .replace(/&/g, '&amp;')
574
+ .replace(/</g, '&lt;')
575
+ .replace(/>/g, '&gt;')
576
+ .replace(/([*_~`])/g, '\\$1');
577
+ }
578
+ function ackToast(textKey, locale) {
579
+ return { toast: { type: 'info', content: t(textKey, undefined, locale) } };
580
+ }
581
+ function errorToast(textKey, params, locale) {
582
+ return { toast: { type: 'error', content: t(textKey, params, locale) } };
583
+ }
584
+ /** Dispatch a `dash_schedules_*` action callback. Mirrors sessions-card. */
585
+ export async function handleSchedulesCardAction(data, larkAppId, deps) {
586
+ const locale = deps.locale ?? 'zh';
587
+ const value = (data.action?.value ?? {});
588
+ const operatorOpenId = data.operator?.open_id;
589
+ const action = value.action;
590
+ // ─── 1) Invoker lock — fail-closed ──────────────────────────────────
591
+ const invokerOpenId = value.invoker_open_id;
592
+ if (typeof invokerOpenId !== 'string' || !invokerOpenId) {
593
+ return ackToast('card.dashboard.settings.not_invoker', locale);
594
+ }
595
+ if (typeof operatorOpenId !== 'string' || !operatorOpenId) {
596
+ return ackToast('card.dashboard.settings.not_invoker', locale);
597
+ }
598
+ if (invokerOpenId !== operatorOpenId) {
599
+ return ackToast('card.dashboard.settings.not_invoker', locale);
600
+ }
601
+ // ─── 2) Per-bot admin gate ──────────────────────────────────────────
602
+ if (!isDashboardAdmin(larkAppId, operatorOpenId, deps)) {
603
+ return ackToast('card.dashboard.settings.owner_only', locale);
604
+ }
605
+ // Validate the action BEFORE creating the Route B client — an unknown
606
+ // action shouldn't even open a connection.
607
+ const validActions = new Set([
608
+ SCHEDULES_ACTION_REFRESH,
609
+ SCHEDULES_ACTION_PAGE,
610
+ SCHEDULES_ACTION_DETAIL,
611
+ SCHEDULES_ACTION_PAUSE,
612
+ SCHEDULES_ACTION_RESUME,
613
+ SCHEDULES_ACTION_DELIVERY,
614
+ SCHEDULES_ACTION_BACK_TO_LIST,
615
+ ]);
616
+ if (!validActions.has(action)) {
617
+ return ackToast('card.dashboard.settings.invalid_action', locale);
618
+ }
619
+ const client = deps.createClient(larkAppId);
620
+ const now = () => (deps.nowMs ? deps.nowMs() : Date.now());
621
+ // ─── Nav state (overview drilldown + global scope) ──────────────────
622
+ // Threaded by buildSchedulesCard onto every button.value; we parse here
623
+ // so the rebuild path keeps the same shape (5/page + 🔙 返回总览 +
624
+ // global scope).
625
+ const navOrigin = value.origin === 'overview' ? 'overview' : undefined;
626
+ const parsedPageSize = Number.parseInt(value.page_size ?? '', 10);
627
+ const navPageSize = Number.isFinite(parsedPageSize) && parsedPageSize > 0 ? parsedPageSize : undefined;
628
+ const parsedNavPage = Number.parseInt(value.page ?? '', 10);
629
+ const navPage = Number.isFinite(parsedNavPage) && parsedNavPage >= 1 ? parsedNavPage : undefined;
630
+ const navScope = value.dashboard_scope === 'global' ? 'global' : undefined;
631
+ const listPathSuffix = navScope === 'global' ? '?scope=global' : '';
632
+ const writePathSuffix = navScope === 'global' ? '?scope=global' : '';
633
+ // ─── 3a) DETAIL — open the per-schedule detail card ─────────────────
634
+ if (action === SCHEDULES_ACTION_DETAIL) {
635
+ const scheduleId = value.schedule_id;
636
+ if (typeof scheduleId !== 'string' || !scheduleId) {
637
+ return errorToast('card.dashboard.schedules.schedule_not_found', undefined, locale);
638
+ }
639
+ const r = await safeGetSchedulesList(client, locale, listPathSuffix);
640
+ if ('errorResult' in r)
641
+ return r.errorResult;
642
+ const row = r.tasks.find(t => t.id === scheduleId);
643
+ if (!row) {
644
+ return errorToast('card.dashboard.schedules.schedule_not_found', undefined, locale);
645
+ }
646
+ const detail = toScheduleDetailDto(row, { nowMs: now() });
647
+ const cardJson = buildSchedulesDetailCard(detail, {
648
+ invokerOpenId: operatorOpenId,
649
+ locale,
650
+ origin: navOrigin,
651
+ pageSize: navPageSize,
652
+ sourcePage: navPage,
653
+ scope: navScope,
654
+ });
655
+ return { card: { type: 'raw', data: JSON.parse(cardJson) } };
656
+ }
657
+ // ─── 3b) PAUSE / RESUME — synchronous toggle ────────────────────────
658
+ if (action === SCHEDULES_ACTION_PAUSE || action === SCHEDULES_ACTION_RESUME) {
659
+ const scheduleId = value.schedule_id;
660
+ if (typeof scheduleId !== 'string' || !scheduleId) {
661
+ return errorToast('card.dashboard.schedules.schedule_not_found', undefined, locale);
662
+ }
663
+ const verb = action === SCHEDULES_ACTION_PAUSE ? 'pause' : 'resume';
664
+ const failedKey = verb === 'pause'
665
+ ? 'card.dashboard.schedules.pause_failed'
666
+ : 'card.dashboard.schedules.resume_failed';
667
+ // Pre-POST snapshot — needed both to confirm the schedule still exists
668
+ // AND to synthesize the new-state row in-process so we don't pay the
669
+ // cost (or race) of a second GET on the happy pause path.
670
+ const pre = await safeGetSchedulesList(client, locale, listPathSuffix);
671
+ if ('errorResult' in pre)
672
+ return pre.errorResult;
673
+ const before = pre.tasks.find(t => t.id === scheduleId);
674
+ if (!before) {
675
+ return errorToast('card.dashboard.schedules.schedule_not_found', undefined, locale);
676
+ }
677
+ // Client-side `disabled` is UX only; replayed or crafted callbacks can
678
+ // still arrive. Re-run the same availability matrix server-side and
679
+ // fail closed before POSTing.
680
+ const beforeMatrix = computeButtonAvailability(before);
681
+ const buttonState = verb === 'pause' ? beforeMatrix.pause : beforeMatrix.resume;
682
+ if (buttonState.enabled !== true) {
683
+ // Reuse the same reasonKey → i18n mapping the builder uses
684
+ // for the inline disabled-button note so toast text matches what
685
+ // the user already sees on the card. NEVER POST; NEVER redraw.
686
+ const mappedKey = verb === 'pause'
687
+ ? mapPauseDisabledReason(buttonState.reasonKey) ?? failedKey
688
+ : mapResumeDisabledReason(buttonState.reasonKey) ?? failedKey;
689
+ return errorToast(mappedKey, undefined, locale);
690
+ }
691
+ // Route B owner routing is the authority on whether this schedule can be
692
+ // toggled; the IM layer only sanitizes the routing key and state matrix.
693
+ let resp;
694
+ try {
695
+ resp = await client.request({
696
+ method: 'POST',
697
+ path: `/__daemon/schedules/${encodeURIComponent(scheduleId)}/${verb}${writePathSuffix}`,
698
+ });
699
+ }
700
+ catch (e) {
701
+ return errorToast(failedKey, { reason: e.message }, locale);
702
+ }
703
+ const failureReason = routeBWriteFailureReason(resp);
704
+ if (failureReason) {
705
+ // Preserve user state — do NOT redraw card on failure.
706
+ return errorToast(failedKey, { reason: failureReason }, locale);
707
+ }
708
+ if (verb === 'pause') {
709
+ // Pause makes nextRunAt irrelevant — the scheduler stops emitting
710
+ // until the task is resumed — so an in-process overlay is safe.
711
+ const synth = { ...before, enabled: false };
712
+ const detail = toScheduleDetailDto(synth, { nowMs: now() });
713
+ const cardJson = buildSchedulesDetailCard(detail, {
714
+ invokerOpenId: operatorOpenId,
715
+ locale,
716
+ origin: navOrigin,
717
+ pageSize: navPageSize,
718
+ sourcePage: navPage,
719
+ scope: navScope,
720
+ });
721
+ return { card: { type: 'raw', data: JSON.parse(cardJson) } };
722
+ }
723
+ // RESUME — nextRunAt MUST be recomputed by the real scheduler's
724
+ // computeNextRun (cron / interval logic lives in scheduler.ts). We
725
+ // do a 2nd GET to read the freshly-recomputed row. If that GET
726
+ // fails or the row is missing, fall back to a `{...before, enabled:
727
+ // true}` synth — the row will still be functional, just with a
728
+ // possibly-stale nextRunAt for one render cycle. The next user
729
+ // interaction (refresh / back-to-list / another detail click) will
730
+ // converge it.
731
+ const postRefetch = await safeGetSchedulesList(client, locale, listPathSuffix);
732
+ let after;
733
+ if ('errorResult' in postRefetch) {
734
+ after = undefined;
735
+ }
736
+ else {
737
+ after = postRefetch.tasks.find(t => t.id === scheduleId);
738
+ }
739
+ if (!after) {
740
+ // Resume succeeded upstream but refetch could not surface the new row.
741
+ // Render a valid card; nextRunAt may be stale until the next refresh.
742
+ after = { ...before, enabled: true };
743
+ }
744
+ const detail = toScheduleDetailDto(after, { nowMs: now() });
745
+ const cardJson = buildSchedulesDetailCard(detail, {
746
+ invokerOpenId: operatorOpenId,
747
+ locale,
748
+ origin: navOrigin,
749
+ pageSize: navPageSize,
750
+ sourcePage: navPage,
751
+ scope: navScope,
752
+ });
753
+ return { card: { type: 'raw', data: JSON.parse(cardJson) } };
754
+ }
755
+ // ─── 3c) DELIVERY — switch origin ↔ new-topic synchronously ──────────
756
+ if (action === SCHEDULES_ACTION_DELIVERY) {
757
+ const scheduleId = value.schedule_id;
758
+ if (typeof scheduleId !== 'string' || !scheduleId) {
759
+ return errorToast('card.dashboard.schedules.schedule_not_found', undefined, locale);
760
+ }
761
+ const targetDelivery = value.target_delivery === 'origin' || value.target_delivery === 'new-topic'
762
+ ? value.target_delivery
763
+ : undefined;
764
+ if (!targetDelivery) {
765
+ return ackToast('card.dashboard.settings.invalid_action', locale);
766
+ }
767
+ const failedKey = 'card.dashboard.schedules.delivery_failed';
768
+ const pre = await safeGetSchedulesList(client, locale, listPathSuffix);
769
+ if ('errorResult' in pre)
770
+ return pre.errorResult;
771
+ const before = pre.tasks.find(t => t.id === scheduleId);
772
+ if (!before) {
773
+ return errorToast('card.dashboard.schedules.schedule_not_found', undefined, locale);
774
+ }
775
+ // Server-side action matrix for delivery mirrors pause/resume: the card
776
+ // paint is UX only. Re-read the latest row and verify this exact target
777
+ // is still a legal transition before calling the owner daemon.
778
+ const deliveryState = computeDeliveryButtonAvailability(before, targetDelivery);
779
+ if (deliveryState.enabled !== true) {
780
+ const mappedKey = mapDeliveryDisabledReason(deliveryState.reasonKey) ?? failedKey;
781
+ return errorToast(mappedKey, undefined, locale);
782
+ }
783
+ let resp;
784
+ try {
785
+ resp = await client.request({
786
+ method: 'POST',
787
+ path: `/__daemon/schedules/${encodeURIComponent(scheduleId)}/delivery${writePathSuffix}`,
788
+ });
789
+ }
790
+ catch (e) {
791
+ return errorToast(failedKey, { reason: e.message }, locale);
792
+ }
793
+ const failureReason = routeBWriteFailureReason(resp);
794
+ if (failureReason) {
795
+ return errorToast(failedKey, { reason: failureReason }, locale);
796
+ }
797
+ const responseDeliver = resp.body?.deliver === 'origin' ||
798
+ resp.body?.deliver === 'new-topic'
799
+ ? resp.body.deliver
800
+ : targetDelivery;
801
+ const postRefetch = await safeGetSchedulesList(client, locale, listPathSuffix);
802
+ let after;
803
+ if (!('errorResult' in postRefetch)) {
804
+ after = postRefetch.tasks.find(t => t.id === scheduleId);
805
+ }
806
+ if (!after) {
807
+ // Delivery toggle succeeded upstream but the refetch was stale/missing.
808
+ // Keep the card interactive by rendering a synth row with the known
809
+ // target mode; the next refresh converges to the owner daemon snapshot.
810
+ after = { ...before, deliver: responseDeliver };
811
+ }
812
+ const detail = toScheduleDetailDto(after, { nowMs: now() });
813
+ const cardJson = buildSchedulesDetailCard(detail, {
814
+ invokerOpenId: operatorOpenId,
815
+ locale,
816
+ origin: navOrigin,
817
+ pageSize: navPageSize,
818
+ sourcePage: navPage,
819
+ scope: navScope,
820
+ });
821
+ return { card: { type: 'raw', data: JSON.parse(cardJson) } };
822
+ }
823
+ // ─── 3d) BACK TO LIST — rebuild list card at the source page ─────────
824
+ if (action === SCHEDULES_ACTION_BACK_TO_LIST) {
825
+ const r = await safeGetSchedulesList(client, locale, listPathSuffix);
826
+ if ('errorResult' in r)
827
+ return r.errorResult;
828
+ const cardJson = buildSchedulesCard(r.tasks, {
829
+ invokerOpenId: operatorOpenId,
830
+ locale,
831
+ page: navPage ?? 1,
832
+ pageSize: navPageSize,
833
+ origin: navOrigin,
834
+ scope: navScope,
835
+ }, now());
836
+ return { card: { type: 'raw', data: JSON.parse(cardJson) } };
837
+ }
838
+ // ─── 3e) REFRESH + PAGE actions ─────────────────────────────────────
839
+ // `action` is already constrained to validActions above; the only ones
840
+ // left here are REFRESH + PAGE (the other 4 returned early).
841
+ let page = 1;
842
+ if (action === SCHEDULES_ACTION_PAGE) {
843
+ // Page comes from value.page (prev/next button) OR action.option
844
+ // (select_static "jump to page" picker). Same action key, different
845
+ // dispatch field — handler converges on one branch.
846
+ const raw = value.page ?? data.action?.option ?? '1';
847
+ const parsed = Number.parseInt(raw, 10);
848
+ if (Number.isFinite(parsed) && parsed >= 1)
849
+ page = parsed;
850
+ }
851
+ const r = await safeGetSchedulesList(client, locale, listPathSuffix);
852
+ if ('errorResult' in r)
853
+ return r.errorResult;
854
+ const cardJson = buildSchedulesCard(r.tasks, {
855
+ invokerOpenId: operatorOpenId,
856
+ locale,
857
+ page,
858
+ pageSize: navPageSize,
859
+ origin: navOrigin,
860
+ scope: navScope,
861
+ }, now());
862
+ return {
863
+ card: { type: 'raw', data: JSON.parse(cardJson) },
864
+ };
865
+ }
866
+ /**
867
+ * GET `/__daemon/schedules-list` and surface non-200 / network errors as
868
+ * caller-facing error toasts. Returns either `{ tasks }` or
869
+ * `{ errorResult }` — exactly one is set.
870
+ *
871
+ * createDaemonClient.request does NOT throw on 4xx/5xx — it resolves with
872
+ * the response. If we only catch throws we'd silently render an empty list
873
+ * when Route B returns 500/401, masking a real backend failure as "no
874
+ * schedules". So check `status !== 200` explicitly and surface as an
875
+ * error toast.
876
+ */
877
+ async function safeGetSchedulesList(client, locale, pathSuffix = '') {
878
+ let r;
879
+ try {
880
+ r = await client.request({ method: 'GET', path: `/__daemon/schedules-list${pathSuffix}` });
881
+ }
882
+ catch (e) {
883
+ return { errorResult: errorToast('card.dashboard.schedules.list_failed', { reason: e.message }, locale) };
884
+ }
885
+ if (r.status !== 200) {
886
+ const reason = String(r.body?.error ?? `http_${r.status}`);
887
+ return { errorResult: errorToast('card.dashboard.schedules.list_failed', { reason }, locale) };
888
+ }
889
+ const tasks = (r.body?.schedules) ?? [];
890
+ return { tasks };
891
+ }
892
+ //# sourceMappingURL=schedules-card.js.map