@hope_phenom/dsh-plugin-remote-use 0.0.0-stage → 0.2.24

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 (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +207 -2
  3. package/README.zh.md +195 -0
  4. package/assets/icon.svg +47 -0
  5. package/cordis.patch.yml +41 -0
  6. package/lib/bridge/acp-bridge.js +905 -0
  7. package/lib/bridge/peer-connection.js +730 -0
  8. package/lib/client.js +2842 -0
  9. package/lib/core/acp-model-overlay.js +121 -0
  10. package/lib/core/approval-queue.js +216 -0
  11. package/lib/core/audit-log.js +322 -0
  12. package/lib/core/desktop-notify.js +173 -0
  13. package/lib/core/environment-prompt.js +76 -0
  14. package/lib/core/relay-supervisor.js +278 -0
  15. package/lib/core/review-engine.js +679 -0
  16. package/lib/core/sandbox-diagnosis.js +258 -0
  17. package/lib/core/sandbox-probe.js +310 -0
  18. package/lib/core/self-acp-spec.js +117 -0
  19. package/lib/core/selfcheck-runner.js +150 -0
  20. package/lib/core/settings-store.js +226 -0
  21. package/lib/core/shot-gc.js +334 -0
  22. package/lib/core/task-cwd.js +50 -0
  23. package/lib/core/task-memory.js +163 -0
  24. package/lib/core/task-registry.js +238 -0
  25. package/lib/core/toolcall-cache.js +206 -0
  26. package/lib/core/transfer-queue.js +58 -0
  27. package/lib/core/transfer.js +1144 -0
  28. package/lib/core/worker-arming.js +334 -0
  29. package/lib/index.js +2207 -0
  30. package/lib/provider/out-of-process-helpers.js +76 -0
  31. package/lib/provider/remote-provider.js +261 -0
  32. package/lib/tools/mediator-shim.mjs +180 -0
  33. package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
  34. package/lib/tools/remote-tools.js +680 -0
  35. package/lib/tools/selfcheck-fix-prompt.md +206 -0
  36. package/locale/en.json +6 -0
  37. package/locale/zh.json +6 -0
  38. package/package.json +72 -4
  39. package/slave/acp-profile-approval.patch.yml +66 -0
  40. package/slave/file-transfer.mjs +552 -0
  41. package/slave/integrity-probe.ps1 +99 -0
  42. package/slave/make-launch-spec.mjs +184 -0
  43. package/slave/net-address.mjs +138 -0
  44. package/slave/node_modules/.package-lock.json +26 -0
  45. package/slave/node_modules/ws/LICENSE +20 -0
  46. package/slave/node_modules/ws/README.md +548 -0
  47. package/slave/node_modules/ws/browser.js +8 -0
  48. package/slave/node_modules/ws/index.js +22 -0
  49. package/slave/node_modules/ws/lib/buffer-util.js +131 -0
  50. package/slave/node_modules/ws/lib/constants.js +19 -0
  51. package/slave/node_modules/ws/lib/event-target.js +292 -0
  52. package/slave/node_modules/ws/lib/extension.js +203 -0
  53. package/slave/node_modules/ws/lib/limiter.js +55 -0
  54. package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
  55. package/slave/node_modules/ws/lib/receiver.js +743 -0
  56. package/slave/node_modules/ws/lib/sender.js +607 -0
  57. package/slave/node_modules/ws/lib/stream.js +161 -0
  58. package/slave/node_modules/ws/lib/subprotocol.js +62 -0
  59. package/slave/node_modules/ws/lib/validation.js +152 -0
  60. package/slave/node_modules/ws/lib/websocket-server.js +562 -0
  61. package/slave/node_modules/ws/lib/websocket.js +1421 -0
  62. package/slave/node_modules/ws/package.json +74 -0
  63. package/slave/node_modules/ws/wrapper.mjs +21 -0
  64. package/slave/pairing.mjs +175 -0
  65. package/slave/permission-pairing.mjs +240 -0
  66. package/slave/prune-shots.mjs +170 -0
  67. package/slave/relay.mjs +1351 -0
  68. package/slave/repair-workspace-integrity.ps1 +75 -0
  69. package/slave/screenshot.ps1 +114 -0
  70. package/slave/selfcheck.ps1 +350 -0
  71. package/slave/stage-app.ps1 +116 -0
  72. package/slave/workspace-integrity.mjs +157 -0
@@ -0,0 +1,238 @@
1
+ /**
2
+ * The host-side registry of remote runs.
3
+ *
4
+ * WHY THIS EXISTS: a remote run has no local child session, so DSH writes no
5
+ * `subagent/catalog` entry for it (DESIGN §2.2 C3). `list_agents` and
6
+ * `listChildren` therefore cannot see remote work at all. This registry is that
7
+ * missing visibility channel: the `remote_agents` tool reads it, and the host
8
+ * injects a summary of it into the model's runtime context so the model does not
9
+ * "forget" what it dispatched.
10
+ *
11
+ * State is in-memory plus an optional JSON snapshot, because a remote run is only
12
+ * meaningful while the process that owns the ACP connection is alive.
13
+ *
14
+ * @module dsh-plugin-remote-use/task-registry
15
+ */
16
+
17
+ /** Terminal states settle a task; nothing may move it out of them. */
18
+ const TERMINAL = new Set(['completed', 'error', 'aborted', 'refusal', 'max-tokens', 'cancelled'])
19
+
20
+ /**
21
+ * How much of a settled task's answer to keep, in characters.
22
+ *
23
+ * WHY THIS EXISTS AT ALL: without it a background delegation was a dead end — the task ran,
24
+ * the state was visible, and the ANSWER was unreachable, because the registry kept progress
25
+ * metrics and nothing else (PITFALLS F15). A result you cannot read is not a result.
26
+ *
27
+ * WHY IT IS CAPPED: the registry lives for the whole process and holds up to `maxTasks` entries,
28
+ * so retention has to be bounded or a chatty worker grows it without limit. The cap is generous
29
+ * (64k chars is a long report) and truncation is REPORTED rather than silent, so a reader can
30
+ * tell "this is all of it" from "this is the first 64k".
31
+ */
32
+ export const OUTPUT_LIMIT = 64_000
33
+
34
+ /**
35
+ * @typedef {object} RemoteTask
36
+ * @property {string} taskId
37
+ * @property {string} peerId
38
+ * @property {string} label short human label from the delegation call
39
+ * @property {string} promptDigest first N chars of the prompt, for display
40
+ * @property {string} cwd
41
+ * @property {string} state queued | running | <terminal>
42
+ * @property {number} startedAt
43
+ * @property {number} [endedAt]
44
+ * @property {string} [stopReason]
45
+ * @property {string} [diagnostic]
46
+ * @property {number} toolCallCount
47
+ * @property {string[]} recentTools last few tool names, for at-a-glance status
48
+ * @property {boolean} waitingForApproval
49
+ * @property {{used:number,size:number}} [usage]
50
+ * @property {string} [output] the worker's answer, capped at OUTPUT_LIMIT
51
+ * @property {number} [outputChars] the answer's full length before capping
52
+ */
53
+
54
+ /** @returns {string} a short, sortable, unique-enough task id */
55
+ function makeTaskId() {
56
+ const t = Date.now().toString(36)
57
+ const r = Math.floor(Math.random() * 0x10000).toString(36).padStart(3, '0')
58
+ return `rtask_${t}_${r}`
59
+ }
60
+
61
+ export class TaskRegistry {
62
+ /** @param {{ maxTasks?: number, snapshotPath?: string, now?: () => number }} [opts] */
63
+ constructor(opts = {}) {
64
+ this.maxTasks = opts.maxTasks ?? 200
65
+ this.snapshotPath = opts.snapshotPath ?? ''
66
+ /** @type {Map<string, RemoteTask>} */
67
+ this.tasks = new Map()
68
+ this.now = opts.now ?? (() => Date.now())
69
+ this.seq = 0
70
+ }
71
+
72
+ /**
73
+ * Create a task in the `queued` state.
74
+ * @param {{ peerId: string, label?: string, prompt: string, cwd: string }} input
75
+ * @returns {RemoteTask}
76
+ */
77
+ create(input) {
78
+ this.#evict()
79
+ const task = {
80
+ taskId: makeTaskId(),
81
+ peerId: input.peerId,
82
+ label: input.label ?? '',
83
+ promptDigest: (input.prompt ?? '').slice(0, 160),
84
+ cwd: input.cwd ?? '',
85
+ state: 'queued',
86
+ startedAt: this.now(),
87
+ toolCallCount: 0,
88
+ recentTools: [],
89
+ waitingForApproval: false,
90
+ }
91
+ this.tasks.set(task.taskId, task)
92
+ return task
93
+ }
94
+
95
+ /** @param {string} taskId */
96
+ get(taskId) {
97
+ return this.tasks.get(taskId)
98
+ }
99
+
100
+ /** Mark a task as actually started (the worker accepted it). */
101
+ markRunning(taskId) {
102
+ const t = this.tasks.get(taskId)
103
+ if (t !== undefined && !TERMINAL.has(t.state)) t.state = 'running'
104
+ return t
105
+ }
106
+
107
+ /**
108
+ * Record one observed tool call.
109
+ * @param {string} taskId
110
+ * @param {string} toolName
111
+ */
112
+ noteToolCall(taskId, toolName) {
113
+ const t = this.tasks.get(taskId)
114
+ if (t === undefined) return
115
+ t.toolCallCount += 1
116
+ t.recentTools.push(toolName)
117
+ if (t.recentTools.length > 5) t.recentTools.shift()
118
+ }
119
+
120
+ /** @param {string} taskId */
121
+ setWaitingForApproval(taskId, waiting) {
122
+ const t = this.tasks.get(taskId)
123
+ if (t !== undefined) t.waitingForApproval = waiting === true
124
+ }
125
+
126
+ /** @param {string} taskId */
127
+ setUsage(taskId, usage) {
128
+ const t = this.tasks.get(taskId)
129
+ if (t !== undefined && usage !== undefined) t.usage = usage
130
+ }
131
+
132
+ /**
133
+ * Settle a task. The first settlement wins — a late ACP event must not reopen
134
+ * a finished task.
135
+ *
136
+ * `final.output` is the worker's answer text. It is the ONLY thing that makes a background
137
+ * delegation useful after the fact, so it is retained here rather than only being handed
138
+ * back through the provider's result promise (which a background caller has already
139
+ * stopped awaiting).
140
+ *
141
+ * @param {string} taskId
142
+ * @param {{ state: string, stopReason?: string, diagnostic?: string, output?: string }} final
143
+ */
144
+ settle(taskId, final) {
145
+ const t = this.tasks.get(taskId)
146
+ if (t === undefined) return t
147
+ if (TERMINAL.has(t.state)) return t
148
+ t.state = TERMINAL.has(final.state) ? final.state : 'error'
149
+ t.stopReason = final.stopReason ?? t.state
150
+ if (final.diagnostic !== undefined) t.diagnostic = final.diagnostic
151
+ if (typeof final.output === 'string' && final.output !== '') {
152
+ t.output = final.output.length > OUTPUT_LIMIT ? final.output.slice(0, OUTPUT_LIMIT) : final.output
153
+ t.outputChars = final.output.length
154
+ }
155
+ t.endedAt = this.now()
156
+ t.waitingForApproval = false
157
+ return t
158
+ }
159
+
160
+ /** @returns {RemoteTask[]} newest first */
161
+ list() {
162
+ return [...this.tasks.values()].sort((a, b) => b.startedAt - a.startedAt)
163
+ }
164
+
165
+ /**
166
+ * Tasks as the settings page needs them: state and progress, WITHOUT the answer text.
167
+ *
168
+ * The page polls its route on a timer and renders a five-column table. Sending every retained
169
+ * answer with every poll would put megabytes on the wire for data nothing draws, so the bulky
170
+ * field is dropped here rather than at the route (which would have to know the field's name).
171
+ * The model still reads answers through `remote_agents`, straight off the registry.
172
+ *
173
+ * @param {number} [limit] how many tasks to return, newest first
174
+ * @returns {object[]}
175
+ */
176
+ listForUi(limit = 50) {
177
+ return this.list().slice(0, limit).map((t) => ({
178
+ taskId: t.taskId,
179
+ peerId: t.peerId,
180
+ label: t.label,
181
+ promptDigest: t.promptDigest,
182
+ cwd: t.cwd,
183
+ state: t.state,
184
+ startedAt: t.startedAt,
185
+ endedAt: t.endedAt,
186
+ stopReason: t.stopReason,
187
+ diagnostic: t.diagnostic,
188
+ toolCallCount: t.toolCallCount,
189
+ recentTools: t.recentTools,
190
+ waitingForApproval: t.waitingForApproval,
191
+ usage: t.usage,
192
+ // Not the answer itself, but its size: the page can say "this run produced something"
193
+ // without shipping it.
194
+ outputChars: t.outputChars,
195
+ }))
196
+ }
197
+
198
+ /** @param {string} [peerId] */
199
+ listByPeer(peerId) {
200
+ return this.list().filter((t) => peerId === undefined || t.peerId === peerId)
201
+ }
202
+
203
+ /** Running or queued tasks — what the model needs to remember. */
204
+ active() {
205
+ return this.list().filter((t) => !TERMINAL.has(t.state))
206
+ }
207
+
208
+ /** @returns {number} count of active tasks for a peer (used for the serial queue) */
209
+ activeCountForPeer(peerId) {
210
+ return this.active().filter((t) => t.peerId === peerId).length
211
+ }
212
+
213
+ /**
214
+ * A compact multi-line summary for runtime-context injection. Returns '' when
215
+ * there is nothing to report, so callers can skip injecting an empty section.
216
+ */
217
+ summary() {
218
+ const act = this.active()
219
+ if (act.length === 0) return ''
220
+ const lines = act.map((t) => {
221
+ const age = Math.round((this.now() - t.startedAt) / 1000)
222
+ const tools = t.recentTools.length === 0 ? '' : ` tools=[${t.recentTools.join(',')}]`
223
+ const wait = t.waitingForApproval ? ' WAITING-FOR-APPROVAL' : ''
224
+ return `- ${t.taskId} peer=${t.peerId} state=${t.state} ${age}s tools=${t.toolCallCount}${tools}${wait}${t.label === '' ? '' : ` label="${t.label}"`}`
225
+ })
226
+ return [`Remote tasks in flight (${act.length}):`, ...lines].join('\n')
227
+ }
228
+
229
+ /** Drop the oldest settled tasks once the cap is exceeded. */
230
+ #evict() {
231
+ if (this.tasks.size < this.maxTasks) return
232
+ const settled = this.list().filter((t) => TERMINAL.has(t.state)).reverse()
233
+ while (this.tasks.size >= this.maxTasks && settled.length > 0) {
234
+ const victim = settled.shift()
235
+ if (victim !== undefined) this.tasks.delete(victim.taskId)
236
+ }
237
+ }
238
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * `toolCallId` → `{ toolName, rawInput, status }` 缓存(INTERFACES.md 【A】)。
3
+ *
4
+ * 纯内存、无依赖、无 I/O。
5
+ *
6
+ * 为什么这是必需品(DESIGN §10.0.1 实测):ACP 的 `session/request_permission`
7
+ * **只带 `toolCallId`**,不带工具名、参数,也不带申请的沙箱档位。主机的档位检查
8
+ * (`review-engine`)只能从 `tool_call` / `tool_call_update` 通知里提前缓存下来的
9
+ * `rawInput.sandbox_permissions` 取值。缓存未命中 → 审批无法判断 → 必须拒绝。
10
+ *
11
+ * 因此本类把两件事做成**可观测事实**:未命中计数 `misses` 与淘汰计数 `evictions`。
12
+ */
13
+
14
+ /** 默认容量上限(INTERFACES.md 规定默认 512)。 */
15
+ const DEFAULT_MAX_ENTRIES = 512
16
+
17
+ /** 通知里没带 status 时的占位值(ACP 的初始状态即 pending)。 */
18
+ const DEFAULT_STATUS = 'pending'
19
+
20
+ /**
21
+ * @typedef {object} ToolCallRecord
22
+ * @property {string | undefined} toolName 来自 `tool_call` 通知的 `title`
23
+ * @property {unknown} rawInput 完整参数(档位检查的唯一数据来源)
24
+ * @property {string} status `pending` | `in_progress` | `completed` | `failed` | 未知名
25
+ */
26
+
27
+ /**
28
+ * `toolCallId` 缓存。容量满时按 LRU(最久未使用)淘汰。
29
+ *
30
+ * 最近使用顺序:`note()` / `update()` 写入的记录移到队尾;`get()` 命中也把记录移到队尾。
31
+ * 理由是一次审批往往发生在 `tool_call` 之后的若干个 `tool_call_update` 之后。
32
+ */
33
+ export class ToolCallCache {
34
+ /** @type {Map<string, ToolCallRecord>} */
35
+ #entries
36
+
37
+ /** @type {number} */
38
+ #maxEntries
39
+
40
+ /** @type {number} */
41
+ #misses
42
+
43
+ /** @type {number} */
44
+ #evictions
45
+
46
+ /**
47
+ * @param {{ maxEntries?: number }} [opts] 容量上限,默认 512;非正整数按默认处理
48
+ */
49
+ constructor(opts) {
50
+ const max = isPlainObject(opts) ? opts.maxEntries : undefined
51
+ this.#maxEntries = Number.isInteger(max) && max > 0 ? max : DEFAULT_MAX_ENTRIES
52
+ this.#entries = new Map()
53
+ this.#misses = 0
54
+ this.#evictions = 0
55
+ }
56
+
57
+ /**
58
+ * 容量上限(只读,便于审计/配置页展示)。
59
+ * @returns {number}
60
+ */
61
+ get maxEntries() {
62
+ return this.#maxEntries
63
+ }
64
+
65
+ /**
66
+ * 累计未命中次数:`get()` 查不到记录(含非法 `toolCallId`)就 +1。
67
+ * 这是审计告警位——命中率低意味着审批决策拿不到判断依据。**不因 `clear()` 归零**。
68
+ * @returns {number}
69
+ */
70
+ get misses() {
71
+ return this.#misses
72
+ }
73
+
74
+ /**
75
+ * 当前记录条数。
76
+ * @returns {number}
77
+ */
78
+ get size() {
79
+ return this.#entries.size
80
+ }
81
+
82
+ /**
83
+ * 累计因容量上限淘汰的条数(「把淘汰记为可观测事实」)。**不因 `clear()` 归零**。
84
+ * @returns {number}
85
+ */
86
+ get evictions() {
87
+ return this.#evictions
88
+ }
89
+
90
+ /**
91
+ * 从 ACP 的 `tool_call` 通知记录一条:`title` 即工具名。
92
+ *
93
+ * 重复 `note` 同一个 id 时按「只覆盖显式给出的字段」合并,避免空通知把已有
94
+ * `title` / `rawInput` 抹掉(那会让后续档位检查失效)。
95
+ *
96
+ * 兼容性别名:同时接受 `toolName`(INTERFACES【B】的 `UpdateEvent.tool` 用的就是
97
+ * 这个名字,见 `bridge/acp-bridge.js` 的 `#toolView`)。契约字段是 `title`,
98
+ * 两者任一有效即可,避免调用方传错字段名时**静默**丢掉工具名。
99
+ *
100
+ * @param {string} toolCallId 非空字符串,否则抛 `TypeError`(调用方 bug,尽早暴露)
101
+ * @param {{ title?: string, toolName?: string, rawInput?: unknown, status?: string }} [patch]
102
+ * @returns {void}
103
+ */
104
+ note(toolCallId, patch = {}) {
105
+ const id = requireId(toolCallId, 'note')
106
+ this.#merge(id, patch)
107
+ }
108
+
109
+ /**
110
+ * 从 `tool_call_update` 更新 `status` / 补丁:**不覆盖** `title` / `rawInput`,
111
+ * 除非本次显式给出。未知 `toolCallId` 时按同样的合并语义新建一条记录
112
+ * (丢弃比丢失事实更糟;即使只有 status,`rawInput` 仍为 `undefined`,
113
+ * 审批侧照样按缓存缺失拒绝)。
114
+ *
115
+ * 注意:`update()` 未命中**不计入** `misses`——`misses` 只统计审批查询(`get`)失败。
116
+ *
117
+ * @param {string} toolCallId 非空字符串,否则抛 `TypeError`
118
+ * @param {{ status?: string, title?: string, toolName?: string, rawInput?: unknown }} [patch]
119
+ * @returns {void}
120
+ */
121
+ update(toolCallId, patch = {}) {
122
+ const id = requireId(toolCallId, 'update')
123
+ this.#merge(id, patch)
124
+ }
125
+
126
+ /**
127
+ * 读取缓存。**命中会把记录移到 LRU 队尾**。返回副本,调用方改动不影响缓存内部。
128
+ *
129
+ * @param {unknown} toolCallId
130
+ * @returns {ToolCallRecord | undefined} 未命中返回 `undefined`(同时 `misses` +1)
131
+ */
132
+ get(toolCallId) {
133
+ if (typeof toolCallId !== 'string' || toolCallId === '') {
134
+ this.#misses += 1
135
+ return undefined
136
+ }
137
+ const record = this.#entries.get(toolCallId)
138
+ if (record === undefined) {
139
+ this.#misses += 1
140
+ return undefined
141
+ }
142
+ this.#entries.delete(toolCallId)
143
+ this.#entries.set(toolCallId, record)
144
+ return { ...record }
145
+ }
146
+
147
+ /**
148
+ * 清空全部记录。**不**重置 `misses` / `evictions`(审计计数器是累计的)。
149
+ * @returns {void}
150
+ */
151
+ clear() {
152
+ this.#entries.clear()
153
+ }
154
+
155
+ /**
156
+ * 合并写入 + 维持 LRU 与容量上限。
157
+ * @param {string} id
158
+ * @param {{ title?: string, toolName?: string, rawInput?: unknown, status?: string }} patch
159
+ * @returns {void}
160
+ */
161
+ #merge(id, patch) {
162
+ const safe = isPlainObject(patch) ? patch : {}
163
+ const existing = this.#entries.get(id)
164
+ const name = [safe.title, safe.toolName].find(
165
+ (value) => typeof value === 'string' && value.trim() !== '',
166
+ )
167
+ /** @type {ToolCallRecord} */
168
+ const record = {
169
+ toolName: name ?? existing?.toolName,
170
+ rawInput: safe.rawInput !== undefined ? safe.rawInput : existing?.rawInput,
171
+ status:
172
+ typeof safe.status === 'string' && safe.status.trim() !== ''
173
+ ? safe.status
174
+ : (existing?.status ?? DEFAULT_STATUS),
175
+ }
176
+ if (existing !== undefined) this.#entries.delete(id)
177
+ this.#entries.set(id, record)
178
+ while (this.#entries.size > this.#maxEntries) {
179
+ const oldest = this.#entries.keys().next()
180
+ if (oldest.done === true) break
181
+ this.#entries.delete(oldest.value)
182
+ this.#evictions += 1
183
+ }
184
+ }
185
+ }
186
+
187
+ /**
188
+ * 校验 `toolCallId`。
189
+ * @param {unknown} toolCallId
190
+ * @param {string} method
191
+ * @returns {string}
192
+ */
193
+ function requireId(toolCallId, method) {
194
+ if (typeof toolCallId !== 'string' || toolCallId === '') {
195
+ throw new TypeError(`ToolCallCache.${method}(): toolCallId must be a non-empty string`)
196
+ }
197
+ return toolCallId
198
+ }
199
+
200
+ /**
201
+ * @param {unknown} value
202
+ * @returns {boolean}
203
+ */
204
+ function isPlainObject(value) {
205
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
206
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * One serial lane per peer, for file transfers.
3
+ *
4
+ * WHY THIS IS NOT THE TASK QUEUE. `RemoteProvider`'s `serialQueue` exists because a desktop is an
5
+ * exclusive resource: two tasks driving one mouse fight, so they run one at a time. Transfers must
6
+ * NOT share that lane, because a 2 GiB copy would then occupy the worker's GUI slot and block the
7
+ * visual work it was meant to support -- and the whole point of moving bytes over the relay's HTTP
8
+ * surface instead of through the ACP session is that a copy never touches the GUI at all.
9
+ *
10
+ * They still need *a* lane of their own: two transfers to one peer make the disk seek between two
11
+ * streams and halve both. So: one transfer at a time per peer, on a queue of its own.
12
+ *
13
+ * A failed transfer must not poison the lane. The stored chain swallows the rejection -- the caller
14
+ * still sees its own error from `run`'s return value -- because an unhandled rejection left on a
15
+ * shared chain would fail the *next* transfer for a reason that has nothing to do with it.
16
+ */
17
+ export function makeTransferQueue() {
18
+ /** @type {Map<string, Promise<void>>} */
19
+ const lanes = new Map()
20
+ /** @type {Map<string, number>} */
21
+ const busy = new Map()
22
+
23
+ const settle = (peerId) => () => {
24
+ const left = (busy.get(peerId) ?? 1) - 1
25
+ if (left <= 0) busy.delete(peerId)
26
+ else busy.set(peerId, left)
27
+ }
28
+
29
+ return {
30
+ /**
31
+ * Run `job` after everything already queued for this peer.
32
+ *
33
+ * @template T
34
+ * @param {string} peerId
35
+ * @param {() => Promise<T>} job
36
+ * @returns {Promise<T>} rejects with the job's own error, if it has one
37
+ */
38
+ run(peerId, job) {
39
+ busy.set(peerId, (busy.get(peerId) ?? 0) + 1)
40
+ const previous = lanes.get(peerId) ?? Promise.resolve()
41
+ // `then(job, job)`: the next job runs whether the previous one resolved or rejected.
42
+ const next = previous.then(job, job)
43
+ lanes.set(peerId, next.then(() => {}, () => {}))
44
+ return next.finally(settle(peerId))
45
+ },
46
+
47
+ /**
48
+ * How many transfers are queued or running for a peer.
49
+ *
50
+ * Read BEFORE `run` to report "waited behind N transfers". Without that number a slow copy is
51
+ * indistinguishable from a broken one -- the operator watches a tool call hang and cannot tell
52
+ * whether it is queued, transferring, or stuck.
53
+ */
54
+ depth(peerId) {
55
+ return busy.get(peerId) ?? 0
56
+ },
57
+ }
58
+ }