lark-remote 0.1.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 (201) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +162 -0
  4. package/README.md +161 -0
  5. package/dist/binder.d.ts +57 -0
  6. package/dist/binder.js +105 -0
  7. package/dist/bridge/index.d.ts +338 -0
  8. package/dist/bridge/index.js +1281 -0
  9. package/dist/bridge/queue-manager.d.ts +277 -0
  10. package/dist/bridge/queue-manager.js +729 -0
  11. package/dist/card/bash-card-session.d.ts +78 -0
  12. package/dist/card/bash-card-session.js +174 -0
  13. package/dist/card/bash-renderer.d.ts +24 -0
  14. package/dist/card/bash-renderer.js +187 -0
  15. package/dist/card/card-budget.d.ts +31 -0
  16. package/dist/card/card-budget.js +280 -0
  17. package/dist/card/card-session.d.ts +83 -0
  18. package/dist/card/card-session.js +185 -0
  19. package/dist/card/card-shared.d.ts +28 -0
  20. package/dist/card/card-shared.js +99 -0
  21. package/dist/card/collapsible.d.ts +26 -0
  22. package/dist/card/collapsible.js +87 -0
  23. package/dist/card/run-card-session.d.ts +78 -0
  24. package/dist/card/run-card-session.js +165 -0
  25. package/dist/card/run-renderer.d.ts +66 -0
  26. package/dist/card/run-renderer.js +841 -0
  27. package/dist/card/run-state.d.ts +103 -0
  28. package/dist/card/run-state.js +313 -0
  29. package/dist/card/text-truncate.d.ts +56 -0
  30. package/dist/card/text-truncate.js +184 -0
  31. package/dist/card/time.d.ts +1 -0
  32. package/dist/card/time.js +14 -0
  33. package/dist/card/tool-render.d.ts +25 -0
  34. package/dist/card/tool-render.js +176 -0
  35. package/dist/cli.d.ts +28 -0
  36. package/dist/cli.js +89 -0
  37. package/dist/common/guards.d.ts +37 -0
  38. package/dist/common/guards.js +65 -0
  39. package/dist/config/codex-config.d.ts +113 -0
  40. package/dist/config/codex-config.js +428 -0
  41. package/dist/config/dir.d.ts +17 -0
  42. package/dist/config/dir.js +81 -0
  43. package/dist/config/index.d.ts +219 -0
  44. package/dist/config/index.js +388 -0
  45. package/dist/config/kimi-config.d.ts +33 -0
  46. package/dist/config/kimi-config.js +111 -0
  47. package/dist/config/opencode-config.d.ts +20 -0
  48. package/dist/config/opencode-config.js +124 -0
  49. package/dist/config/pi-config.d.ts +49 -0
  50. package/dist/config/pi-config.js +151 -0
  51. package/dist/config/wizard.d.ts +12 -0
  52. package/dist/config/wizard.js +84 -0
  53. package/dist/connector/file-limits.d.ts +7 -0
  54. package/dist/connector/file-limits.js +8 -0
  55. package/dist/connector/index.d.ts +86 -0
  56. package/dist/connector/index.js +340 -0
  57. package/dist/error-classification.d.ts +15 -0
  58. package/dist/error-classification.js +24 -0
  59. package/dist/index.d.ts +1 -0
  60. package/dist/index.js +580 -0
  61. package/dist/instance-lock.d.ts +30 -0
  62. package/dist/instance-lock.js +184 -0
  63. package/dist/logger/index.d.ts +57 -0
  64. package/dist/logger/index.js +178 -0
  65. package/dist/order/index.d.ts +27 -0
  66. package/dist/order/index.js +90 -0
  67. package/dist/persistence/atomic-write.d.ts +10 -0
  68. package/dist/persistence/atomic-write.js +65 -0
  69. package/dist/persistence/load-json-file.d.ts +9 -0
  70. package/dist/persistence/load-json-file.js +23 -0
  71. package/dist/restart.d.ts +24 -0
  72. package/dist/restart.js +73 -0
  73. package/dist/router/card-helpers.d.ts +28 -0
  74. package/dist/router/card-helpers.js +65 -0
  75. package/dist/router/config/claude.d.ts +14 -0
  76. package/dist/router/config/claude.js +72 -0
  77. package/dist/router/config/codex.d.ts +22 -0
  78. package/dist/router/config/codex.js +100 -0
  79. package/dist/router/config/common/render.d.ts +16 -0
  80. package/dist/router/config/common/render.js +158 -0
  81. package/dist/router/config/index.d.ts +18 -0
  82. package/dist/router/config/index.js +38 -0
  83. package/dist/router/config/kimi.d.ts +21 -0
  84. package/dist/router/config/kimi.js +63 -0
  85. package/dist/router/config/opencode.d.ts +12 -0
  86. package/dist/router/config/opencode.js +44 -0
  87. package/dist/router/config/pi.d.ts +12 -0
  88. package/dist/router/config/pi.js +50 -0
  89. package/dist/router/config/types.d.ts +43 -0
  90. package/dist/router/config/types.js +8 -0
  91. package/dist/router/index.d.ts +318 -0
  92. package/dist/router/index.js +2715 -0
  93. package/dist/router/order-exec-dispatch.d.ts +30 -0
  94. package/dist/router/order-exec-dispatch.js +48 -0
  95. package/dist/router/utils.d.ts +44 -0
  96. package/dist/router/utils.js +116 -0
  97. package/dist/runner/bash/index.d.ts +4 -0
  98. package/dist/runner/bash/index.js +5 -0
  99. package/dist/runner/bash/runner.d.ts +55 -0
  100. package/dist/runner/bash/runner.js +213 -0
  101. package/dist/runner/claude/index.d.ts +4 -0
  102. package/dist/runner/claude/index.js +5 -0
  103. package/dist/runner/claude/runner.d.ts +29 -0
  104. package/dist/runner/claude/runner.js +83 -0
  105. package/dist/runner/codex/argv.d.ts +33 -0
  106. package/dist/runner/codex/argv.js +40 -0
  107. package/dist/runner/codex/index.d.ts +1 -0
  108. package/dist/runner/codex/index.js +2 -0
  109. package/dist/runner/codex/jsonl.d.ts +37 -0
  110. package/dist/runner/codex/jsonl.js +221 -0
  111. package/dist/runner/codex/runner.d.ts +65 -0
  112. package/dist/runner/codex/runner.js +124 -0
  113. package/dist/runner/common/exec-translator.d.ts +73 -0
  114. package/dist/runner/common/exec-translator.js +105 -0
  115. package/dist/runner/common/jsonl-stream.d.ts +39 -0
  116. package/dist/runner/common/jsonl-stream.js +203 -0
  117. package/dist/runner/common/process-stopper.d.ts +28 -0
  118. package/dist/runner/common/process-stopper.js +79 -0
  119. package/dist/runner/common/runner-utils.d.ts +21 -0
  120. package/dist/runner/common/runner-utils.js +65 -0
  121. package/dist/runner/common/spawn-heartbeat.d.ts +33 -0
  122. package/dist/runner/common/spawn-heartbeat.js +51 -0
  123. package/dist/runner/common/spawning-runner.d.ts +321 -0
  124. package/dist/runner/common/spawning-runner.js +634 -0
  125. package/dist/runner/common/usage.d.ts +31 -0
  126. package/dist/runner/common/usage.js +29 -0
  127. package/dist/runner/index.d.ts +19 -0
  128. package/dist/runner/index.js +24 -0
  129. package/dist/runner/kimi/index.d.ts +1 -0
  130. package/dist/runner/kimi/index.js +2 -0
  131. package/dist/runner/kimi/jsonl.d.ts +28 -0
  132. package/dist/runner/kimi/jsonl.js +143 -0
  133. package/dist/runner/kimi/runner.d.ts +50 -0
  134. package/dist/runner/kimi/runner.js +105 -0
  135. package/dist/runner/opencode/argv.d.ts +32 -0
  136. package/dist/runner/opencode/argv.js +35 -0
  137. package/dist/runner/opencode/index.d.ts +1 -0
  138. package/dist/runner/opencode/index.js +2 -0
  139. package/dist/runner/opencode/jsonl.d.ts +68 -0
  140. package/dist/runner/opencode/jsonl.js +295 -0
  141. package/dist/runner/opencode/runner.d.ts +63 -0
  142. package/dist/runner/opencode/runner.js +93 -0
  143. package/dist/runner/pi/index.d.ts +1 -0
  144. package/dist/runner/pi/index.js +2 -0
  145. package/dist/runner/pi/jsonl.d.ts +128 -0
  146. package/dist/runner/pi/jsonl.js +249 -0
  147. package/dist/runner/pi/runner.d.ts +37 -0
  148. package/dist/runner/pi/runner.js +76 -0
  149. package/dist/runner/registry.d.ts +54 -0
  150. package/dist/runner/registry.js +75 -0
  151. package/dist/runner/resolve-agent-choices.d.ts +7 -0
  152. package/dist/runner/resolve-agent-choices.js +76 -0
  153. package/dist/runner/sync-agent-choices.d.ts +6 -0
  154. package/dist/runner/sync-agent-choices.js +73 -0
  155. package/dist/runner/types.d.ts +250 -0
  156. package/dist/runner/types.js +8 -0
  157. package/dist/session/claude/index.d.ts +1 -0
  158. package/dist/session/claude/index.js +2 -0
  159. package/dist/session/claude/session-reader.d.ts +34 -0
  160. package/dist/session/claude/session-reader.js +46 -0
  161. package/dist/session/claude/sessions.d.ts +53 -0
  162. package/dist/session/claude/sessions.js +528 -0
  163. package/dist/session/codex/index.d.ts +1 -0
  164. package/dist/session/codex/index.js +2 -0
  165. package/dist/session/codex/rollout-reader.d.ts +88 -0
  166. package/dist/session/codex/rollout-reader.js +473 -0
  167. package/dist/session/codex/sessions.d.ts +21 -0
  168. package/dist/session/codex/sessions.js +40 -0
  169. package/dist/session/common/catch-up.d.ts +12 -0
  170. package/dist/session/common/catch-up.js +31 -0
  171. package/dist/session/common/constants.d.ts +11 -0
  172. package/dist/session/common/constants.js +12 -0
  173. package/dist/session/common/content-blocks.d.ts +37 -0
  174. package/dist/session/common/content-blocks.js +68 -0
  175. package/dist/session/common/jsonl.d.ts +77 -0
  176. package/dist/session/common/jsonl.js +351 -0
  177. package/dist/session/common/usage-accumulator.d.ts +58 -0
  178. package/dist/session/common/usage-accumulator.js +72 -0
  179. package/dist/session/index.d.ts +7 -0
  180. package/dist/session/index.js +11 -0
  181. package/dist/session/kimi/index.d.ts +1 -0
  182. package/dist/session/kimi/index.js +2 -0
  183. package/dist/session/kimi/sessions.d.ts +81 -0
  184. package/dist/session/kimi/sessions.js +527 -0
  185. package/dist/session/opencode/index.d.ts +1 -0
  186. package/dist/session/opencode/index.js +2 -0
  187. package/dist/session/opencode/sessions.d.ts +81 -0
  188. package/dist/session/opencode/sessions.js +388 -0
  189. package/dist/session/pi/index.d.ts +1 -0
  190. package/dist/session/pi/index.js +2 -0
  191. package/dist/session/pi/sessions.d.ts +27 -0
  192. package/dist/session/pi/sessions.js +399 -0
  193. package/dist/session/registry.d.ts +12 -0
  194. package/dist/session/registry.js +20 -0
  195. package/dist/session/session-store.d.ts +86 -0
  196. package/dist/session/session-store.js +450 -0
  197. package/dist/startup-contact.d.ts +18 -0
  198. package/dist/startup-contact.js +51 -0
  199. package/dist/workspace/index.d.ts +19 -0
  200. package/dist/workspace/index.js +53 -0
  201. package/package.json +93 -0
@@ -0,0 +1,729 @@
1
+ import { getLogger } from '../logger/index.js';
2
+ /**
3
+ * Per-workspace cap on the number of tasks WAITING to execute (review P2-5).
4
+ * Without a bound, a message flood grows `queuedTasks` / the promise chain /
5
+ * each task's captured `msg.content` closure without limit, and every enqueued
6
+ * task fires a queue card (amplifying outbound API calls). When the waiting
7
+ * queue is already at this depth, a new task is rejected with a visible card
8
+ * instead of being queued. The currently-executing task is NOT counted (it is
9
+ * tracked in `pendingOrExecutingCount`, not `queuedTasks`), so the cap is on
10
+ * backlog, not throughput. Owner is a single user, so 50 is ample headroom.
11
+ */
12
+ const MAX_WAITING_QUEUE = 50;
13
+ /**
14
+ * QueueManager handles the per-workspace serial processing queue.
15
+ * Manages task queuing, cancellation, and queue status card rendering.
16
+ */
17
+ export class QueueManager {
18
+ /** Per-workspace (cwd) serial queues. Key = cwd, value = tail of promise chain. */
19
+ queues = new Map();
20
+ /** Per-workspace queued tasks for queue display cards. Key = cwd, value = task list. */
21
+ queuedTasks = new Map();
22
+ /**
23
+ * P3-5: per-workspace `messageId → QueuedTask` index mirroring `queuedTasks`.
24
+ * O(1) lookup for the task-start removal path (replacing the old double
25
+ * `find`+`findIndex` O(N) scan) and for `getQueuedTask`/`removeFromQueue`/
26
+ * `updateQueuedTaskMessage`. The ordered array remains the source of truth
27
+ * (position display depends on order); the index must be kept in sync on
28
+ * every mutation (push / splice / shift) via the `indexAdd`/`indexRemove`
29
+ * helpers. Same messageId is never enqueued twice in one workspace
30
+ * (Feishu dedup + internal-key dedup), so no overwrite concern.
31
+ */
32
+ taskIndex = new Map();
33
+ /**
34
+ * Map from user messageId to the queue card's Feishu send promise. Storing
35
+ * the promise (not the resolved id) closes the race where the card send is
36
+ * still in flight when the task begins: the begin path awaits the stored
37
+ * promise, so a late-arriving card id still gets reconciled to the
38
+ * executing/cancelled state. The promise never rejects (send failures
39
+ * resolve undefined), so awaiting it is always safe.
40
+ */
41
+ queueCardMessages = new Map();
42
+ /** Track number of executing tasks per workspace (for queue card display). */
43
+ pendingOrExecutingCount = new Map();
44
+ /**
45
+ * Monotonic counter minting a unique slot id per enqueued task. A slot
46
+ * identifies one task's execution period for the interrupt bookkeeping in
47
+ * `executingSlot`/`interruptedSlots` — replacing the unowned skip-credit
48
+ * counter, which any settle could consume and which therefore leaked a
49
+ * re-armed count after repeated resets (see A4 anchor).
50
+ */
51
+ slotCounter = 0;
52
+ /**
53
+ * Per-workspace slot id of the task currently executing. Set in the begin
54
+ * path right before the task runs; deleted when that task's settle fires.
55
+ * `resetExecutingCount` reads it to bind the interrupt credit to exactly
56
+ * the interrupted task's slot.
57
+ */
58
+ executingSlot = new Map();
59
+ /**
60
+ * Per-workspace set of slot ids whose task was interrupted by
61
+ * `resetExecutingCount`. When such a slot settles, its decrement is skipped
62
+ * (the reset already zeroed the count). A slot is granted at most once —
63
+ * repeated resets of the same executing task do not mint extra credits —
64
+ * which prevents a normal task's settle from consuming a credit instead of
65
+ * decrementing, leaking the re-armed count to 1.
66
+ */
67
+ interruptedSlots = new Map();
68
+ /** Per-workspace messageId → replacement closure, set by queue.edit + immediate. */
69
+ taskReplacements = new Map();
70
+ /**
71
+ * MessageIds of tasks that have actually begun executing (cancellation check
72
+ * passed, task removed from the queue). Entries are sticky: they remain
73
+ * after the task settles (A21). `handleQueueImmediate` step 6 uses this to
74
+ * distinguish "target was cancelled (never began)" from "target began while
75
+ * the interrupt was in flight" so the final feedback can report the true
76
+ * state instead of telling a running task's user that nothing was
77
+ * scheduled. A task that began and then settled quickly must still report
78
+ * "已开始执行", which requires the marker to survive settle. Message ids are
79
+ * globally unique and never reused, so a sticky marker cannot misclassify a
80
+ * later task; the set is bounded-pruned on insert to prevent unbounded
81
+ * growth. Removed/cancelled tasks never enter the set (cancellation happens
82
+ * before begin); `removeFromQueue` therefore needs no cleanup here.
83
+ */
84
+ beganMessageIds = new Set();
85
+ /** Callback to check if workspace has an active run */
86
+ isWorkspaceRunning;
87
+ /** Callback to send card updates */
88
+ sendCard;
89
+ /** Callback to update existing card */
90
+ updateCard;
91
+ constructor(isWorkspaceRunning, sendCard, updateCard) {
92
+ this.isWorkspaceRunning = isWorkspaceRunning;
93
+ this.sendCard = sendCard;
94
+ this.updateCard = updateCard;
95
+ }
96
+ /** P3-5: register a queued task in the per-workspace `messageId → task` index. */
97
+ indexAdd(workspace, task) {
98
+ let idx = this.taskIndex.get(workspace);
99
+ if (!idx) {
100
+ idx = new Map();
101
+ this.taskIndex.set(workspace, idx);
102
+ }
103
+ idx.set(task.messageId, task);
104
+ }
105
+ /** P3-5: drop a task from the per-workspace index by messageId. */
106
+ indexRemove(workspace, messageId) {
107
+ this.taskIndex.get(workspace)?.delete(messageId);
108
+ }
109
+ /** P3-5: O(1) lookup of a queued task by messageId via the index. */
110
+ indexGet(workspace, messageId) {
111
+ return this.taskIndex.get(workspace)?.get(messageId);
112
+ }
113
+ /**
114
+ * Enqueue a task into the workspace-level serial queue.
115
+ * Each workspace has its own serial queue for parallel execution across workspaces.
116
+ */
117
+ enqueue(workspace, task, opts) {
118
+ // Guard: reject non-function tasks that would poison the queue chain
119
+ if (typeof task !== 'function') {
120
+ getLogger().warn('[queue-manager] enqueue ignored task is not a function, workspace=', workspace);
121
+ return;
122
+ }
123
+ // Mint a unique slot id for this task's execution period (regardless of
124
+ // taskMeta). The slot binds begin/settle bookkeeping to this task, so an
125
+ // interrupt credit granted by `resetExecutingCount` can only be consumed
126
+ // by the interrupted task's own settle.
127
+ const slotId = ++this.slotCounter;
128
+ // Track this task in the queue for display card
129
+ const taskMeta = opts?.taskMeta;
130
+ let messagePreview;
131
+ if (taskMeta) {
132
+ messagePreview = taskMeta.messagePreview;
133
+ const queuedTask = {
134
+ userId: taskMeta.userId,
135
+ chatId: taskMeta.chatId,
136
+ messageId: taskMeta.messageId,
137
+ feishuReplyTo: taskMeta.feishuReplyTo,
138
+ workspace,
139
+ timestamp: Date.now(),
140
+ messagePreview,
141
+ binding: taskMeta.binding,
142
+ };
143
+ // Check if there are tasks waiting BEFORE adding this one
144
+ const currentExecutingCount = this.pendingOrExecutingCount.get(workspace) ?? 0;
145
+ const currentQueueLength = this.queuedTasks.get(workspace)?.length ?? 0;
146
+ const hasWaitingTasks = currentExecutingCount > 0 || currentQueueLength > 0;
147
+ // P2-5: bound the waiting backlog. When the queue is full, reject the
148
+ // task with a visible card instead of appending it (which would grow
149
+ // the promise chain + closures unboundedly under a message flood and
150
+ // fire another queue card). Do NOT increment pendingOrExecutingCount —
151
+ // a rejected task never executes, so it must not occupy a slot.
152
+ if (currentQueueLength >= MAX_WAITING_QUEUE) {
153
+ getLogger().warn(`[queue-manager] queue full workspace=${workspace} depth=${currentQueueLength} rejecting task messageId=${taskMeta.messageId}`);
154
+ void this.sendCard(taskMeta.chatId, {
155
+ schema: '2.0',
156
+ config: { wide_screen_mode: true },
157
+ header: {
158
+ template: 'red',
159
+ title: { tag: 'plain_text', content: '🚫 队列已满' },
160
+ },
161
+ body: {
162
+ elements: [
163
+ {
164
+ tag: 'div',
165
+ text: {
166
+ tag: 'lark_md',
167
+ content: `当前 workspace 已有 ${MAX_WAITING_QUEUE} 条消息排队等待,为防止积压已拒收本条消息。请等待队列消化后重发,或用 \`/stop\` 清空当前任务。`,
168
+ },
169
+ },
170
+ ],
171
+ },
172
+ }, { replyTo: taskMeta.feishuReplyTo ?? taskMeta.messageId }).catch((err) => {
173
+ getLogger().error('[queue-manager] failed to send queue-full rejection card:', err);
174
+ });
175
+ return;
176
+ }
177
+ // Add to workspace queue list
178
+ let taskList = this.queuedTasks.get(workspace);
179
+ if (!taskList) {
180
+ taskList = [];
181
+ this.queuedTasks.set(workspace, taskList);
182
+ }
183
+ taskList.push(queuedTask);
184
+ this.indexAdd(workspace, queuedTask);
185
+ // Increment executing count SYNCHRONOUSLY so subsequent enqueues see it
186
+ this.pendingOrExecutingCount.set(workspace, currentExecutingCount + 1);
187
+ // Only send the queue card if the task actually has to wait.
188
+ if (hasWaitingTasks) {
189
+ void this.sendQueueStatusCard(workspace, taskMeta.chatId, taskMeta.messageId, messagePreview,
190
+ // Reply to the real Feishu message id when the queue key is an
191
+ // internal key (order.exec); otherwise messageId IS the Feishu id.
192
+ taskMeta.feishuReplyTo);
193
+ }
194
+ getLogger().debug(`[queue-manager] enqueue task queued workspace=${workspace} queueCard=${hasWaitingTasks} executing=${currentExecutingCount + 1} queueLen=${currentQueueLength + 1}`);
195
+ }
196
+ // Get or create the queue for this workspace
197
+ let queue = this.queues.get(workspace);
198
+ if (!queue) {
199
+ queue = Promise.resolve();
200
+ this.queues.set(workspace, queue);
201
+ }
202
+ getLogger().debug(`[queue-manager] enqueue workspace=${workspace}`);
203
+ // Capture messageId for cancellation guard
204
+ const messageId = taskMeta?.messageId;
205
+ // messagePreview already captured above (outside taskMeta block)
206
+ const newQueue = queue
207
+ .then(() => {
208
+ getLogger().debug(`[queue-manager] task begin workspace=${workspace} messageId=${messageId}`);
209
+ // Live preview for the executing card: read from the QueuedTask at
210
+ // begin time, not the enqueue-closure `taskMeta` (which is frozen).
211
+ // `updateQueuedTaskMessage` (queue.edit/queue.input) mutates the live
212
+ // task, so a task edited while queued must show the edited content
213
+ // when it naturally starts.
214
+ let livePreview = taskMeta?.messagePreview ?? '';
215
+ // Check if this task was cancelled before executing
216
+ if (messageId) {
217
+ // P3-5: O(1) index lookup replaces the old `find` + `findIndex`
218
+ // double O(N) scan. The ordered array still drives removal (splice
219
+ // preserves queue order for position display); the index stays in
220
+ // sync via indexRemove.
221
+ const task = this.indexGet(workspace, messageId);
222
+ if (!task) {
223
+ getLogger().debug(`[queue-manager] task skipped (cancelled) workspace=${workspace} messageId=${messageId}`);
224
+ return;
225
+ }
226
+ livePreview = task.messagePreview;
227
+ // Remove this task's metadata by messageId
228
+ const taskList = this.queuedTasks.get(workspace);
229
+ const idx = taskList?.findIndex((t) => t.messageId === messageId) ?? -1;
230
+ if (taskList && idx >= 0) {
231
+ taskList.splice(idx, 1);
232
+ }
233
+ this.indexRemove(workspace, messageId);
234
+ // Cancellation check passed and the task has been removed: it is
235
+ // about to run, so record it as began for queue.immediate feedback.
236
+ // Bounded: ids are only consulted for the current immediate target,
237
+ // so pruning the oldest entries is safe.
238
+ if (this.beganMessageIds.size >= 10_000) {
239
+ const oldest = this.beganMessageIds.values().next().value;
240
+ if (oldest !== undefined)
241
+ this.beganMessageIds.delete(oldest);
242
+ }
243
+ this.beganMessageIds.add(messageId);
244
+ }
245
+ // Consume any one-shot replacement closure registered by
246
+ // `setTaskReplacement` (queue.edit + queue.immediate). The replacement
247
+ // swaps in the edited content at this task's original queue slot, so
248
+ // tasks queued behind it still run after it. Deleted here (one-shot)
249
+ // so a stale closure can never run on a later task.
250
+ let replacement;
251
+ if (messageId) {
252
+ const workspaceReplacements = this.taskReplacements.get(workspace);
253
+ replacement = workspaceReplacements?.get(messageId);
254
+ if (replacement) {
255
+ workspaceReplacements?.delete(messageId);
256
+ if (workspaceReplacements && workspaceReplacements.size === 0) {
257
+ this.taskReplacements.delete(workspace);
258
+ }
259
+ }
260
+ }
261
+ // Update queue card to "executing" status BEFORE running the task.
262
+ // This must happen after the cancellation check: a skipped/cancelled
263
+ // task must keep its "❌ 已撤销" card, not be flipped to executing.
264
+ if (messageId) {
265
+ void this.updateQueueCardToExecuting(workspace, messageId, livePreview, true);
266
+ }
267
+ // Re-arm the pending/executing count for every task that is about to
268
+ // run. `resetExecutingCount` (external interrupt) zeroes the count,
269
+ // and the interrupted task's settle consumes the skip-credit — so a
270
+ // task enqueued BEFORE the interrupt can begin with count 0. Without
271
+ // this, a running task is invisible to later enqueues and they
272
+ // silently skip the "⏳ 消息排队中" card. This applies regardless of
273
+ // taskMeta: resetExecutingCount can clear the count for any workspace,
274
+ // and a task that resumes after an interrupt must re-arm even when it
275
+ // carries no metadata.
276
+ const currentCount = this.pendingOrExecutingCount.get(workspace) ?? 0;
277
+ if (currentCount < 1) {
278
+ this.pendingOrExecutingCount.set(workspace, 1);
279
+ getLogger().debug(`[queue-manager] re-armed pendingOrExecutingCount workspace=${workspace} (interrupt resume)`);
280
+ }
281
+ // Mark this task's slot as the current execution period before running
282
+ // it. `resetExecutingCount` reads this marker to grant the interrupt
283
+ // credit to exactly this task; the settle removes the marker.
284
+ this.executingSlot.set(workspace, slotId);
285
+ return replacement ? replacement() : task();
286
+ })
287
+ .then(() => {
288
+ getLogger().debug(`[queue-manager] task end workspace=${workspace}`);
289
+ // beganMessageIds entry intentionally retained: sticky by design
290
+ // (A21) — the marker records "has ever begun", not "is currently
291
+ // running", so a task that began and settled quickly still reports
292
+ // "已开始执行" to queue.immediate.
293
+ this.decrementExecutingCount(workspace, slotId);
294
+ })
295
+ .catch((err) => {
296
+ getLogger().error('[queue-manager] queue task error:', err);
297
+ // Same sticky rationale as the success settle: the marker survives
298
+ // even when the task errors after beginning (A21).
299
+ this.decrementExecutingCount(workspace, slotId);
300
+ });
301
+ this.queues.set(workspace, newQueue);
302
+ }
303
+ /**
304
+ * Execute a task immediately without going through the queue.
305
+ * Used for / commands that should respond immediately.
306
+ */
307
+ enqueueImmediate(workspace, task) {
308
+ getLogger().debug(`[queue-manager] enqueueImmediate workspace=${workspace}`);
309
+ void task().catch((err) => getLogger().error('[queue-manager] immediate task error:', err));
310
+ }
311
+ /**
312
+ * One-shot: replace the execution closure of an existing queued task in
313
+ * place, preserving its queue position (tasks queued behind it still run
314
+ * after it). Consumed when the task's slot begins; removed if cancelled.
315
+ */
316
+ setTaskReplacement(workspace, messageId, task) {
317
+ let workspaceReplacements = this.taskReplacements.get(workspace);
318
+ if (!workspaceReplacements) {
319
+ workspaceReplacements = new Map();
320
+ this.taskReplacements.set(workspace, workspaceReplacements);
321
+ }
322
+ workspaceReplacements.set(messageId, task);
323
+ getLogger().debug(`[queue-manager] set task replacement workspace=${workspace} messageId=${messageId}`);
324
+ }
325
+ /** Send a queue status card showing current queue position and actions. */
326
+ async sendQueueStatusCard(workspace, chatId, replyToMessageId, messagePreview,
327
+ /** Feishu reply target when `replyToMessageId` (the queue key) is an
328
+ * internal key, not a valid Feishu message id. Defaults to the queue key
329
+ * for hand-typed messages. */
330
+ feishuReplyTo) {
331
+ const taskList = this.queuedTasks.get(workspace) ?? [];
332
+ const positionInQueue = taskList.findIndex((t) => t.chatId === chatId && t.messageId === replyToMessageId);
333
+ const actualPosition = positionInQueue + 1;
334
+ const tasksAhead = actualPosition - 1;
335
+ const card = {
336
+ schema: '2.0',
337
+ config: { wide_screen_mode: true },
338
+ header: {
339
+ template: 'orange',
340
+ title: { tag: 'plain_text', content: '⏳ 消息排队中' },
341
+ },
342
+ body: {
343
+ elements: this.buildQueueStatusCardElements(workspace, actualPosition, tasksAhead, replyToMessageId, messagePreview),
344
+ },
345
+ };
346
+ // The Feishu reply target must be a real Feishu message id. The queue
347
+ // dedup key (`replyToMessageId`) is an internal key for card-action
348
+ // dispatches (order.exec), so reply to `feishuReplyTo` when provided.
349
+ const feishuReplyTarget = feishuReplyTo ?? replyToMessageId;
350
+ // Create the send promise and register it in the mapping BEFORE awaiting:
351
+ // the begin path reads this mapping synchronously, so a late-resolving
352
+ // send must still be reconcilable once its card id arrives.
353
+ const sendPromise = this.sendCard(chatId, card, { replyTo: feishuReplyTarget }).catch((err) => {
354
+ getLogger().error('[queue-manager] failed to send queue status card:', err);
355
+ return undefined;
356
+ });
357
+ this.queueCardMessages.set(replyToMessageId, sendPromise);
358
+ const messageId = await sendPromise;
359
+ if (messageId === undefined) {
360
+ // Send failed — the stored promise resolved undefined and no card
361
+ // exists; drop the mapping so it does not accumulate across retried
362
+ // failures.
363
+ this.queueCardMessages.delete(replyToMessageId);
364
+ }
365
+ return messageId;
366
+ }
367
+ /** Get queue info for a workspace. */
368
+ getQueueInfo(workspace) {
369
+ const taskList = this.queuedTasks.get(workspace) ?? [];
370
+ return {
371
+ position: taskList.length,
372
+ tasksAhead: Math.max(0, taskList.length - 1),
373
+ isRunning: this.isWorkspaceRunning(workspace),
374
+ };
375
+ }
376
+ /** Build queue status card elements. */
377
+ buildQueueStatusCardElements(workspace, actualPosition, tasksAhead, messageId, messagePreview) {
378
+ const workspaceName = workspace.split('/').pop() ?? workspace;
379
+ const isRunning = this.isWorkspaceRunning(workspace);
380
+ const elements = [
381
+ {
382
+ tag: 'div',
383
+ text: {
384
+ tag: 'lark_md',
385
+ content: `**当前 Workspace:** \`${workspaceName}\`\n**位置:** 第 ${actualPosition} 位\n**前面还有:** ${tasksAhead} 条消息在排队`,
386
+ },
387
+ },
388
+ { tag: 'hr' },
389
+ {
390
+ tag: 'div',
391
+ text: {
392
+ tag: 'lark_md',
393
+ content: `💡 消息将按顺序执行…当前正在处理: ${isRunning ? '有任务运行中' : '空闲'}`,
394
+ },
395
+ },
396
+ { tag: 'hr' },
397
+ ];
398
+ // Show message preview with edit button (only if not executing)
399
+ if (messagePreview) {
400
+ elements.push({
401
+ tag: 'div',
402
+ text: { tag: 'lark_md', content: `📝 \`${messagePreview}\`` },
403
+ });
404
+ // Edit button: a pending (queued) task is always editable. The
405
+ // executing/cancelled states render via dedicated card builders that
406
+ // hard-disable all buttons, so this builder only serves pending cards.
407
+ elements.push({
408
+ tag: 'button',
409
+ text: { tag: 'plain_text', content: '✏️ 编辑' },
410
+ type: 'default',
411
+ size: 'small',
412
+ behaviors: [{ type: 'callback', value: { cmd: 'queue.edit', workspace, messageId } }],
413
+ });
414
+ elements.push({ tag: 'hr' });
415
+ }
416
+ // Action buttons
417
+ elements.push(...this.buildQueueActionButtons(workspace, messageId, false));
418
+ return elements;
419
+ }
420
+ /**
421
+ * Build the 撤销/立即执行 action button pair for a queue card.
422
+ *
423
+ * Both buttons route to `queue.cancel` / `queue.immediate` callbacks with
424
+ * the same workspace/messageId; only the `disabled` flag varies by card
425
+ * state — pending cards enable both, executing/cancelled cards disable
426
+ * both. Centralizing the pair here eliminates the 3-way duplication
427
+ * between `buildQueueStatusCardElements`, `updateQueueCardToExecuting`,
428
+ * and `updateQueueCardToCancelled` (Clean Code P2-1).
429
+ */
430
+ buildQueueActionButtons(workspace, messageId, disabled) {
431
+ return [
432
+ {
433
+ tag: 'button',
434
+ text: { tag: 'plain_text', content: '❌ 撤销' },
435
+ type: 'danger',
436
+ disabled,
437
+ behaviors: [{ type: 'callback', value: { cmd: 'queue.cancel', workspace, messageId } }],
438
+ },
439
+ {
440
+ tag: 'button',
441
+ text: { tag: 'plain_text', content: '⚡ 立即执行' },
442
+ type: 'primary',
443
+ disabled,
444
+ behaviors: [{ type: 'callback', value: { cmd: 'queue.immediate', workspace, messageId } }],
445
+ },
446
+ ];
447
+ }
448
+ /**
449
+ * Update queue card to "started executing" status when task begins.
450
+ *
451
+ * `started` tells the method whether the caller has already confirmed the
452
+ * task really began (begin path: cancellation check passed and the task has
453
+ * been removed from the queue). When `started` is false (queue.immediate
454
+ * mark path), a membership guard re-checks the task right before the card
455
+ * update: if the task was removed/cancelled while the send was in flight,
456
+ * the card must keep its "❌ 已撤销" state instead of being flipped back to
457
+ * executing. The mapping is deleted in finally in both cases.
458
+ */
459
+ async updateQueueCardToExecuting(workspace, messageId, messagePreview, started = false) {
460
+ const cardMessageId = await this.queueCardMessages.get(messageId);
461
+ if (!cardMessageId) {
462
+ getLogger().debug(`[queue-manager] no queue card to update, messageId=${messageId}`);
463
+ return;
464
+ }
465
+ const workspaceName = workspace.split('/').pop() ?? workspace;
466
+ try {
467
+ // Prefer the live preview: an edited task's messagePreview is updated in
468
+ // place, and the passed-in snapshot may be stale if the card send was in
469
+ // flight while the user edited (A19). The begin path (started=true) already
470
+ // passes the live preview captured at begin (A12), so the fallback is only
471
+ // exercised when the task is no longer queued.
472
+ const liveTask = this.indexGet(workspace, messageId);
473
+ const stillQueued = liveTask !== undefined;
474
+ if (!stillQueued && !started) {
475
+ getLogger().debug(`[queue-manager] queue card update to executing skipped (task no longer queued and not started) messageId=${messageId}`);
476
+ return;
477
+ }
478
+ const previewForCard = liveTask?.messagePreview ?? messagePreview;
479
+ const card = {
480
+ schema: '2.0',
481
+ config: { wide_screen_mode: true },
482
+ header: {
483
+ template: 'green',
484
+ title: { tag: 'plain_text', content: '▶️ 已开始执行' },
485
+ },
486
+ body: {
487
+ elements: [
488
+ {
489
+ tag: 'div',
490
+ text: { tag: 'lark_md', content: `**当前 Workspace:** \`${workspaceName}\`` },
491
+ },
492
+ { tag: 'hr' },
493
+ { tag: 'div', text: { tag: 'lark_md', content: `📝 \`${previewForCard}\`` } },
494
+ { tag: 'hr' },
495
+ ...this.buildQueueActionButtons(workspace, messageId, true),
496
+ ],
497
+ },
498
+ };
499
+ await this.updateCard(cardMessageId, card);
500
+ getLogger().debug(`[queue-manager] queue card updated to executing messageId=${messageId}`);
501
+ }
502
+ catch (err) {
503
+ getLogger().warn('[queue-manager] failed to update queue card to executing:', err);
504
+ }
505
+ finally {
506
+ this.queueCardMessages.delete(messageId);
507
+ }
508
+ }
509
+ /** Remove a task from the queue by messageId. Returns true if found and removed. */
510
+ removeFromQueue(workspace, messageId) {
511
+ // Drop any one-shot replacement for this task: a cancelled/removed task
512
+ // must never leave a closure behind that could execute later.
513
+ const workspaceReplacements = this.taskReplacements.get(workspace);
514
+ if (workspaceReplacements?.delete(messageId) && workspaceReplacements.size === 0) {
515
+ this.taskReplacements.delete(workspace);
516
+ }
517
+ const taskList = this.queuedTasks.get(workspace);
518
+ if (!taskList)
519
+ return false;
520
+ // P3-5: index is the O(1) presence check; array splice keeps order.
521
+ if (!this.indexGet(workspace, messageId))
522
+ return false;
523
+ const index = taskList.findIndex((t) => t.messageId === messageId);
524
+ if (index >= 0) {
525
+ taskList.splice(index, 1);
526
+ }
527
+ this.indexRemove(workspace, messageId);
528
+ // Note: queueCardMessages mapping is NOT deleted here.
529
+ // The caller (handleQueueCancel) will call updateQueueCardToCancelled
530
+ // which will clean up the mapping after updating the card.
531
+ getLogger().info(`[queue-manager] removed from queue workspace=${workspace} messageId=${messageId}`);
532
+ return true;
533
+ }
534
+ /** Update queue card to "cancelled" status when user clicks 撤销. */
535
+ async updateQueueCardToCancelled(workspace, messageId) {
536
+ const cardMessageId = await this.queueCardMessages.get(messageId);
537
+ if (!cardMessageId) {
538
+ getLogger().debug(`[queue-manager] no queue card to update (cancelled), messageId=${messageId}`);
539
+ return;
540
+ }
541
+ const workspaceName = workspace.split('/').pop() ?? workspace;
542
+ const card = {
543
+ schema: '2.0',
544
+ config: { wide_screen_mode: true },
545
+ header: {
546
+ template: 'gray',
547
+ title: { tag: 'plain_text', content: '❌ 已撤销' },
548
+ },
549
+ body: {
550
+ elements: [
551
+ {
552
+ tag: 'div',
553
+ text: { tag: 'lark_md', content: `**当前 Workspace:** \`${workspaceName}\`` },
554
+ },
555
+ { tag: 'hr' },
556
+ { tag: 'div', text: { tag: 'lark_md', content: `📝 该消息已从队列中撤销` } },
557
+ { tag: 'hr' },
558
+ ...this.buildQueueActionButtons(workspace, messageId, true),
559
+ ],
560
+ },
561
+ };
562
+ try {
563
+ await this.updateCard(cardMessageId, card);
564
+ getLogger().debug(`[queue-manager] queue card updated to cancelled, messageId=${messageId}`);
565
+ }
566
+ catch (err) {
567
+ getLogger().warn('[queue-manager] failed to update queue card to cancelled:', err);
568
+ }
569
+ finally {
570
+ this.queueCardMessages.delete(messageId);
571
+ }
572
+ }
573
+ /** Get task metadata from queue. */
574
+ getQueuedTask(workspace, messageId) {
575
+ // P3-5: O(1) index lookup instead of array `find`.
576
+ return this.indexGet(workspace, messageId);
577
+ }
578
+ /**
579
+ * Whether a task with the given messageId has begun executing (cancellation
580
+ * check passed, task removed from the queue). Sticky: true even after the
581
+ * task settles, so queue.immediate can distinguish "began (possibly already
582
+ * completed)" from "never began / cancelled".
583
+ */
584
+ hasBegan(messageId) {
585
+ return this.beganMessageIds.has(messageId);
586
+ }
587
+ /** Get all queued tasks for a workspace. Returns a copy to prevent aliasing bugs. */
588
+ getQueuedTasks(workspace) {
589
+ return [...(this.queuedTasks.get(workspace) ?? [])];
590
+ }
591
+ /** Update the messagePreview for a queued task. Returns true if found and updated. */
592
+ updateQueuedTaskMessage(workspace, messageId, newMessage) {
593
+ // P3-5: O(1) index lookup instead of array `find`.
594
+ const task = this.indexGet(workspace, messageId);
595
+ if (!task)
596
+ return false;
597
+ task.messagePreview = newMessage;
598
+ task.editedMessage = newMessage;
599
+ getLogger().info(`[queue-manager] updated messagePreview workspace=${workspace} messageId=${messageId}`);
600
+ return true;
601
+ }
602
+ /**
603
+ * Build the orange queue card reflecting the edited message content. Returns
604
+ * the card object (sent as the cardAction callback response `card.data`,
605
+ * which Feishu renders in place) or null if the task is no longer queued.
606
+ *
607
+ * Why a callback-response card instead of a PATCH updateCard API call:
608
+ * when handleQueueInput returns a { toast } callback response, Feishu uses
609
+ * that response to render the clicked card; a response without a `card`
610
+ * field leaves the card in its pre-click (edit) state, overriding the
611
+ * concurrent PATCH updateCard result. Returning { card } in the callback
612
+ * response updates the card synchronously with no API race.
613
+ */
614
+ buildQueueCardForEdit(workspace, messageId, newMessagePreview) {
615
+ const taskList = this.queuedTasks.get(workspace) ?? [];
616
+ const positionInQueue = taskList.findIndex((t) => t.messageId === messageId);
617
+ if (positionInQueue < 0) {
618
+ getLogger().info(`[queue-manager] task not in queue (build edit card), messageId=${messageId}`);
619
+ return null;
620
+ }
621
+ const actualPosition = positionInQueue + 1;
622
+ const tasksAhead = positionInQueue;
623
+ return {
624
+ schema: '2.0',
625
+ config: { wide_screen_mode: true },
626
+ header: {
627
+ template: 'orange',
628
+ title: { tag: 'plain_text', content: '⏳ 消息排队中' },
629
+ },
630
+ body: {
631
+ elements: this.buildQueueStatusCardElements(workspace, actualPosition, tasksAhead, messageId, newMessagePreview),
632
+ },
633
+ };
634
+ }
635
+ /**
636
+ * Decrement `pendingOrExecutingCount` for a workspace, honoring per-slot
637
+ * interrupt bookkeeping.
638
+ *
639
+ * Called from the queue chain's `.then()`/`.catch()` settle with the slot
640
+ * id minted at enqueue time. Three cases:
641
+ *
642
+ * 1. The slot is the workspace's current executing slot → its execution
643
+ * period ends (`executingSlot` marker removed).
644
+ * 2. The slot is in `interruptedSlots` (its task was interrupted by
645
+ * `resetExecutingCount`) → skip the decrement: the reset already zeroed
646
+ * the count, so a stale decrement could wrongly zero it again while a
647
+ * newer task (enqueued after the reset) is still running.
648
+ * 3. Otherwise → normal decrement.
649
+ */
650
+ decrementExecutingCount(workspace, slotId) {
651
+ if (this.executingSlot.get(workspace) === slotId) {
652
+ this.executingSlot.delete(workspace);
653
+ }
654
+ const interrupted = this.interruptedSlots.get(workspace);
655
+ if (interrupted?.has(slotId)) {
656
+ interrupted.delete(slotId);
657
+ if (interrupted.size === 0) {
658
+ this.interruptedSlots.delete(workspace);
659
+ }
660
+ getLogger().debug(`[queue-manager] skip decrement (interrupted slot) workspace=${workspace} slot=${slotId}`);
661
+ return;
662
+ }
663
+ const count = this.pendingOrExecutingCount.get(workspace) ?? 1;
664
+ this.pendingOrExecutingCount.set(workspace, Math.max(0, count - 1));
665
+ }
666
+ /**
667
+ * Reset the executing task count for a workspace.
668
+ *
669
+ * This should be called when a running task is interrupted externally
670
+ * (e.g., via /stop command or "立即执行" button) so that the queue
671
+ * correctly reflects that no tasks are currently executing.
672
+ *
673
+ * Without this reset, the pendingOrExecutingCount would remain > 0 even after
674
+ * the task process is killed, causing subsequent messages to incorrectly
675
+ * show as "排队中" because the queue thinks a task is still running.
676
+ *
677
+ * Also marks the currently executing task's slot as interrupted, so its
678
+ * eventual `.then()`/`.catch()` settle skips the decrement (which could
679
+ * otherwise zero the count while a newer task is still running). The mark
680
+ * is granted at most once per slot: repeated resets of the same executing
681
+ * task do not accumulate credits.
682
+ *
683
+ * `expectedSlot` binds the reset to the task that was actually interrupted:
684
+ * the caller captures the executing slot BEFORE stopping the runner, and
685
+ * the stop window may outlive the interrupted task's settle (the chain can
686
+ * advance to a NEW task, which then owns `executingSlot`). When the current
687
+ * slot no longer matches the interrupted task's slot, the interrupted task
688
+ * already decremented normally — resetting now would zero the count of the
689
+ * running successor and mark ITS slot interrupted, hiding it from the
690
+ * queue card (A22).
691
+ */
692
+ resetExecutingCount(workspace, expectedSlot) {
693
+ const currentSlot = this.executingSlot.get(workspace);
694
+ if (currentSlot !== expectedSlot) {
695
+ getLogger().debug(`[queue-manager] resetExecutingCount skip (stopped task settled, slot advanced) workspace=${workspace} expectedSlot=${expectedSlot} currentSlot=${currentSlot ?? 'none'}`);
696
+ return;
697
+ }
698
+ // P3#9: only reset and grant an interrupt slot when there is actually a
699
+ // pending/executing task.
700
+ const currentCount = this.pendingOrExecutingCount.get(workspace) ?? 0;
701
+ if (currentCount === 0) {
702
+ getLogger().debug(`[queue-manager] resetExecutingCount no-op (count already 0) workspace=${workspace}`);
703
+ return;
704
+ }
705
+ // Bind the interrupt credit to the currently executing task's slot (if
706
+ // any; defensive against a count without an executing task). `Set.add`
707
+ // dedupes repeated resets of the same slot.
708
+ const slot = currentSlot;
709
+ this.pendingOrExecutingCount.set(workspace, 0);
710
+ if (slot !== undefined) {
711
+ let interrupted = this.interruptedSlots.get(workspace);
712
+ if (!interrupted) {
713
+ interrupted = new Set();
714
+ this.interruptedSlots.set(workspace, interrupted);
715
+ }
716
+ interrupted.add(slot);
717
+ getLogger().debug(`[queue-manager] reset pendingOrExecutingCount workspace=${workspace} interruptedSlot=${slot}`);
718
+ return;
719
+ }
720
+ getLogger().debug(`[queue-manager] reset pendingOrExecutingCount workspace=${workspace} (no executing slot)`);
721
+ }
722
+ /**
723
+ * The slot id of the task currently executing in this workspace (if any).
724
+ */
725
+ getExecutingSlot(workspace) {
726
+ return this.executingSlot.get(workspace);
727
+ }
728
+ }
729
+ //# sourceMappingURL=queue-manager.js.map