@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.
- package/LICENSE +21 -0
- package/README.md +207 -2
- package/README.zh.md +195 -0
- package/assets/icon.svg +47 -0
- package/cordis.patch.yml +41 -0
- package/lib/bridge/acp-bridge.js +905 -0
- package/lib/bridge/peer-connection.js +730 -0
- package/lib/client.js +2842 -0
- package/lib/core/acp-model-overlay.js +121 -0
- package/lib/core/approval-queue.js +216 -0
- package/lib/core/audit-log.js +322 -0
- package/lib/core/desktop-notify.js +173 -0
- package/lib/core/environment-prompt.js +76 -0
- package/lib/core/relay-supervisor.js +278 -0
- package/lib/core/review-engine.js +679 -0
- package/lib/core/sandbox-diagnosis.js +258 -0
- package/lib/core/sandbox-probe.js +310 -0
- package/lib/core/self-acp-spec.js +117 -0
- package/lib/core/selfcheck-runner.js +150 -0
- package/lib/core/settings-store.js +226 -0
- package/lib/core/shot-gc.js +334 -0
- package/lib/core/task-cwd.js +50 -0
- package/lib/core/task-memory.js +163 -0
- package/lib/core/task-registry.js +238 -0
- package/lib/core/toolcall-cache.js +206 -0
- package/lib/core/transfer-queue.js +58 -0
- package/lib/core/transfer.js +1144 -0
- package/lib/core/worker-arming.js +334 -0
- package/lib/index.js +2207 -0
- package/lib/provider/out-of-process-helpers.js +76 -0
- package/lib/provider/remote-provider.js +261 -0
- package/lib/tools/mediator-shim.mjs +180 -0
- package/lib/tools/prepare-sandbox-mediator.mjs +529 -0
- package/lib/tools/remote-tools.js +680 -0
- package/lib/tools/selfcheck-fix-prompt.md +206 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +72 -4
- package/slave/acp-profile-approval.patch.yml +66 -0
- package/slave/file-transfer.mjs +552 -0
- package/slave/integrity-probe.ps1 +99 -0
- package/slave/make-launch-spec.mjs +184 -0
- package/slave/net-address.mjs +138 -0
- package/slave/node_modules/.package-lock.json +26 -0
- package/slave/node_modules/ws/LICENSE +20 -0
- package/slave/node_modules/ws/README.md +548 -0
- package/slave/node_modules/ws/browser.js +8 -0
- package/slave/node_modules/ws/index.js +22 -0
- package/slave/node_modules/ws/lib/buffer-util.js +131 -0
- package/slave/node_modules/ws/lib/constants.js +19 -0
- package/slave/node_modules/ws/lib/event-target.js +292 -0
- package/slave/node_modules/ws/lib/extension.js +203 -0
- package/slave/node_modules/ws/lib/limiter.js +55 -0
- package/slave/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/slave/node_modules/ws/lib/receiver.js +743 -0
- package/slave/node_modules/ws/lib/sender.js +607 -0
- package/slave/node_modules/ws/lib/stream.js +161 -0
- package/slave/node_modules/ws/lib/subprotocol.js +62 -0
- package/slave/node_modules/ws/lib/validation.js +152 -0
- package/slave/node_modules/ws/lib/websocket-server.js +562 -0
- package/slave/node_modules/ws/lib/websocket.js +1421 -0
- package/slave/node_modules/ws/package.json +74 -0
- package/slave/node_modules/ws/wrapper.mjs +21 -0
- package/slave/pairing.mjs +175 -0
- package/slave/permission-pairing.mjs +240 -0
- package/slave/prune-shots.mjs +170 -0
- package/slave/relay.mjs +1351 -0
- package/slave/repair-workspace-integrity.ps1 +75 -0
- package/slave/screenshot.ps1 +114 -0
- package/slave/selfcheck.ps1 +350 -0
- package/slave/stage-app.ps1 +116 -0
- 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
|
+
}
|