@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,334 @@
1
+ /**
2
+ * Screenshot housekeeping.
3
+ *
4
+ * WHY THIS EXISTS: every capture leaves a file behind, and a worker screenshot is a
5
+ * full-resolution PNG -- measured at ~9.5 MB each on the reference worker. A session that
6
+ * uses screenshots to drive GUI verification produces them steadily, and nothing ever removed
7
+ * them: the worker's shot directory had reached 56.9 MB across six captures before this was
8
+ * written, and the host's download directory grows the same way.
9
+ *
10
+ * TWO RULES SHAPE THE DESIGN:
11
+ *
12
+ * 1. NEVER THROW. Pruning runs as part of taking a screenshot. A full disk or a locked file
13
+ * must not turn a successful capture into a failed one, so every failure is reported in
14
+ * the result and the caller carries on. The failure mode this avoids -- "I cannot show you
15
+ * the screen because I could not delete an old file" -- is strictly worse than the disk
16
+ * usage it prevents.
17
+ *
18
+ * 2. ONLY TOUCH WHAT THIS PLUGIN WROTE. The host's screenshot directory sits in the system
19
+ * temp directory, which is shared with everything else on the machine. Deleting "some old
20
+ * files" there would be destructive, so a file is only a candidate when its name matches
21
+ * the pattern this plugin generates for that worker.
22
+ *
23
+ * WHY THERE IS ALSO A BYTE BUDGET: a COUNT budget does not bound disk usage, it only bounds the
24
+ * number of files. That is a real distinction and it bit this plugin: the count was 20, so the
25
+ * budget was believed to be "20 captures", while the directory had reached 218 MB -- and on a
26
+ * machine whose captures are larger, or whose operator raised the count, the count says nothing
27
+ * about whether the disk is filling. Byte usage is what the operator actually cares about, so it
28
+ * is both reported (see `scanShotUsage`) and enforced (see `pruneShots`'s `maxBytes`).
29
+ */
30
+ import { readdirSync, statSync, unlinkSync } from 'node:fs'
31
+ import { join } from 'node:path'
32
+
33
+ /** Default number of screenshots kept per worker. */
34
+ export const DEFAULT_KEEP_SHOTS = 20
35
+
36
+ /**
37
+ * Default byte ceiling for one worker's screenshots on the HOST, when the setting is absent.
38
+ *
39
+ * 0 means "no byte budget", which is the shipped default on purpose: the count budget already
40
+ * bounds ordinary use, and an install that upgrades into a byte budget would start deleting
41
+ * captures the operator never agreed to lose. An operator who wants a hard ceiling sets one.
42
+ */
43
+ export const DEFAULT_SHOT_CACHE_BYTES = 0
44
+
45
+ /**
46
+ * One megabyte, for parsing and rendering the operator-facing budget strings.
47
+ *
48
+ * Binary, not decimal: operators compare this number against what Explorer shows, and Explorer
49
+ * reports MiB while calling it MB. Using 1e6 here would make the page disagree with the folder
50
+ * it is describing by ~5%, which reads as a bug in the accounting.
51
+ */
52
+ export const BYTES_PER_MB = 1024 * 1024
53
+
54
+ /**
55
+ * Render a byte count the way the settings page shows it.
56
+ * @param {number} bytes
57
+ * @returns {string}
58
+ */
59
+ export function formatBytes(bytes) {
60
+ const n = Number(bytes)
61
+ if (!Number.isFinite(n) || n <= 0) return '0 B'
62
+ if (n < 1024) return `${Math.round(n)} B`
63
+ if (n < BYTES_PER_MB) return `${(n / 1024).toFixed(1)} KB`
64
+ if (n < BYTES_PER_MB * 1024) return `${(n / BYTES_PER_MB).toFixed(1)} MB`
65
+ return `${(n / (BYTES_PER_MB * 1024)).toFixed(2)} GB`
66
+ }
67
+
68
+ /**
69
+ * Parse an operator-entered budget. Accepts a bare number of BYTES, or a number with a unit
70
+ * suffix (`512MB`, `1.5 GB`, `0`).
71
+ *
72
+ * WHY A PARSER AND NOT JUST A NUMBER FIELD: the unit an operator thinks in is MB, and a field
73
+ * that silently wants bytes invites an off-by-a-million typo that then either deletes nothing or
74
+ * deletes everything. Returns undefined for anything unparseable, so the caller can refuse the
75
+ * save rather than apply a guess.
76
+ *
77
+ * @param {unknown} raw
78
+ * @returns {number | undefined} bytes, or undefined when unusable
79
+ */
80
+ export function parseBytes(raw) {
81
+ if (typeof raw === 'number') return Number.isFinite(raw) ? Math.floor(raw) : undefined
82
+ if (typeof raw !== 'string') return undefined
83
+ const text = raw.trim()
84
+ if (text === '') return undefined
85
+ // A signed value is meaningful (negative disables), so the sign is captured rather than rejected.
86
+ const m = /^(-?\d+(?:\.\d+)?)\s*(b|kb|mb|gb|kib|mib|gib)?$/iu.exec(text)
87
+ if (m === null) return undefined
88
+ const value = Number(m[1])
89
+ if (!Number.isFinite(value)) return undefined
90
+ const unit = (m[2] ?? '').toLowerCase()
91
+ const scale = unit === '' || unit === 'b'
92
+ ? 1
93
+ : unit === 'kb' || unit === 'kib'
94
+ ? 1024
95
+ : unit === 'mb' || unit === 'mib'
96
+ ? BYTES_PER_MB
97
+ : BYTES_PER_MB * 1024
98
+ return Math.floor(value * scale)
99
+ }
100
+
101
+ /**
102
+ * Files this plugin writes look like `<peerId>-<ISO stamp with : and . replaced>.png`.
103
+ * The trailing hyphen matters: without it a worker named `w1` would also match `w12`'s files.
104
+ * @param {string} peerId
105
+ * @returns {RegExp}
106
+ */
107
+ export function shotPattern(peerId) {
108
+ const escaped = String(peerId).replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
109
+ return new RegExp(`^${escaped}-\\d{4}-\\d{2}-\\d{2}T[\\d-]+Z\\.png$`, 'u')
110
+ }
111
+
112
+ /**
113
+ * List this worker's screenshots in `dir`, newest first.
114
+ *
115
+ * Shared by pruning, reporting and clearing so the three can never disagree about which files
116
+ * count as "ours" -- the definition of that set is the whole safety property here, and two
117
+ * copies of it would drift.
118
+ *
119
+ * @param {string} dir
120
+ * @param {string} peerId
121
+ * @returns {{ files: Array<{path: string, name: string, mtimeMs: number, size: number}>, error: string }}
122
+ */
123
+ function listShots(dir, peerId) {
124
+ let names
125
+ try {
126
+ names = readdirSync(dir)
127
+ } catch (error) {
128
+ // A directory that does not exist yet is the normal first-run case, not a failure.
129
+ if (error?.code === 'ENOENT') return { files: [], error: '' }
130
+ return { files: [], error: `cannot list ${dir}: ${error.message}` }
131
+ }
132
+
133
+ const pattern = shotPattern(peerId)
134
+ const files = []
135
+ for (const name of names) {
136
+ if (!pattern.test(name)) continue
137
+ const path = join(dir, name)
138
+ try {
139
+ const stat = statSync(path)
140
+ if (stat.isFile()) files.push({ path, name, mtimeMs: stat.mtimeMs, size: stat.size })
141
+ } catch { /* vanished between listing and stat; nothing to do */ }
142
+ }
143
+
144
+ // Newest first. The name breaks ties: the stamp is ISO, so it sorts correctly, and a
145
+ // filesystem with coarse timestamps can hand out identical mtimes.
146
+ files.sort((a, b) => (b.mtimeMs - a.mtimeMs) || b.name.localeCompare(a.name))
147
+ return { files, error: '' }
148
+ }
149
+
150
+ /**
151
+ * What one worker's screenshots occupy on disk.
152
+ *
153
+ * WHY THIS EXISTS: the count budget was the only thing that was bounded, and it was invisible.
154
+ * An operator whose temp directory had reached 218 MB had no way to see that from the page, and
155
+ * "a limit nobody can see is indistinguishable from no limit" applies doubly to usage.
156
+ *
157
+ * Never throws: this is called on a page load and on a liveness probe, neither of which may fail
158
+ * because a directory was locked.
159
+ *
160
+ * @param {string} dir
161
+ * @param {string} peerId
162
+ * @returns {{ files: number, bytes: number, newestAt: number, oldestAt: number, error: string }}
163
+ */
164
+ export function scanShotUsage(dir, peerId) {
165
+ const empty = { files: 0, bytes: 0, newestAt: 0, oldestAt: 0, error: '' }
166
+ if (typeof dir !== 'string' || dir === '' || peerId === undefined || peerId === null || String(peerId) === '') {
167
+ return empty
168
+ }
169
+ const { files, error } = listShots(dir, String(peerId))
170
+ if (error !== '') return { ...empty, error }
171
+ let bytes = 0
172
+ // `listShots` sorts newest first, so the extremes are the ends of the list rather than a scan.
173
+ for (const file of files) bytes += file.size
174
+ return {
175
+ files: files.length,
176
+ bytes,
177
+ newestAt: files.length === 0 ? 0 : files[0].mtimeMs,
178
+ oldestAt: files.length === 0 ? 0 : files[files.length - 1].mtimeMs,
179
+ error: '',
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Delete all but the newest `keep` screenshots belonging to one worker, then, if `opts.maxBytes`
185
+ * is a positive budget, delete oldest-first until the survivors fit inside it.
186
+ *
187
+ * @param {string} dir
188
+ * @param {string} peerId
189
+ * @param {number} keep how many to retain; a non-finite or negative value disables pruning
190
+ * @param {{ maxBytes?: number, keepNewest?: boolean }} [opts]
191
+ * `maxBytes`: byte ceiling for the survivors; non-finite, negative or 0 means no byte budget.
192
+ * `keepNewest` (default true): never delete the newest capture in the byte pass even if it
193
+ * alone exceeds the budget -- see the note in the body.
194
+ * @returns {{ ok: boolean, removed: number, kept: number, freedBytes: number,
195
+ * overBudgetBytes: number, bytes: number, error: string }}
196
+ */
197
+ export function pruneShots(dir, peerId, keep, opts = {}) {
198
+ const nothing = { ok: true, removed: 0, kept: 0, freedBytes: 0, overBudgetBytes: 0, bytes: 0, error: '' }
199
+ if (typeof dir !== 'string' || dir === '') {
200
+ return { ...nothing, ok: false, error: 'pruneShots: no directory given' }
201
+ }
202
+ const hasCountBudget = Number.isFinite(keep) && keep >= 0
203
+ const maxBytes = Number(opts?.maxBytes)
204
+ // Same rule as the count budget, for the same reason: a missing or zero budget means "do not
205
+ // prune", never "delete everything". The dangerous reading of an absent setting is 0.
206
+ const hasByteBudget = Number.isFinite(maxBytes) && maxBytes > 0
207
+ // "No budget at all" is a no-op, but it is NOT an empty cache. Reporting `kept: 0` here would
208
+ // tell the page that a directory holding forty captures contains none -- two different facts
209
+ // (nothing was pruned / nothing is there) collapsed into one number, and the wrong one. The
210
+ // directory is listed so the count is real; a listing failure still reports zeroes.
211
+ if (!hasCountBudget && !hasByteBudget) {
212
+ const idle = listShots(dir, peerId)
213
+ if (idle.error !== '') return nothing
214
+ let idleBytes = 0
215
+ for (const file of idle.files) idleBytes += file.size
216
+ return { ...nothing, kept: idle.files.length, bytes: idleBytes }
217
+ }
218
+
219
+ const { files, error } = listShots(dir, peerId)
220
+ if (error !== '') return { ...nothing, ok: false, error: `pruneShots: ${error}` }
221
+
222
+ // The count pass first: it is a whole-file decision the operator stated explicitly, and it is
223
+ // cheaper to reason about than a running total.
224
+ const survivors = hasCountBudget ? files.slice(0, Math.max(0, Math.floor(keep))) : [...files]
225
+ const doomed = hasCountBudget ? files.slice(Math.max(0, Math.floor(keep))) : []
226
+
227
+ // The byte pass. `keepNewest` exists because the newest capture is the one the host is usually
228
+ // about to read: deleting it to satisfy a byte ceiling would break the download that produced
229
+ // it, and the master would be left with a screenshot tool that reports success and has no file.
230
+ // So the byte pass may leave the directory over budget by exactly one capture, and it SAYS SO
231
+ // through `overBudgetBytes` rather than silently overshooting.
232
+ //
233
+ // ⚠️ THE `hasByteBudget` TEST IS LOAD-BEARING AND WAS ONCE MISSING. The loop originally read
234
+ // `running > maxBytes` alone. With no byte budget, `maxBytes` is 0, and EVERY non-empty
235
+ // directory is "over" 0 -- so asking for "no ceiling" deleted everything except the newest
236
+ // capture, which is the exact opposite of what a 0 budget means. It survived the unit tests
237
+ // because those passed `keep: Infinity`, which returns at the no-budget guard above and never
238
+ // reaches this loop; the end-to-end probe caught it. A budget of 0 must mean "do not run this
239
+ // pass at all", so the flag is checked here as well as at the guard.
240
+ const keepNewest = opts?.keepNewest !== false
241
+ let running = 0
242
+ for (const file of survivors) running += file.size
243
+ for (let i = survivors.length - 1; hasByteBudget && i >= 0 && running > maxBytes; i -= 1) {
244
+ if (keepNewest && i === 0) break
245
+ doomed.push(survivors[i])
246
+ running -= survivors[i].size
247
+ survivors.splice(i, 1)
248
+ }
249
+
250
+ let removed = 0
251
+ let freedBytes = 0
252
+ let firstError = ''
253
+ for (const file of doomed) {
254
+ try {
255
+ unlinkSync(file.path)
256
+ removed += 1
257
+ freedBytes += file.size
258
+ } catch (error) {
259
+ if (firstError === '') firstError = `cannot delete ${file.name}: ${error.message}`
260
+ }
261
+ }
262
+
263
+ // Bytes are re-derived from the survivors rather than from the running total: a file whose
264
+ // unlink failed is still on disk, and reporting it as freed would be a lie the page repeats.
265
+ let bytes = 0
266
+ for (const file of survivors) bytes += file.size
267
+
268
+ return {
269
+ ok: firstError === '',
270
+ removed,
271
+ kept: survivors.length,
272
+ freedBytes,
273
+ overBudgetBytes: hasByteBudget ? Math.max(0, bytes - maxBytes) : 0,
274
+ bytes,
275
+ error: firstError,
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Delete every screenshot belonging to one worker, or every screenshot for every worker when
281
+ * `peerId` is empty.
282
+ *
283
+ * WHY THIS IS SEPARATE FROM `pruneShots`: "keep the newest N" and "clear this cache" are
284
+ * different operator intentions, and expressing the second as `keep: 0` would silently change
285
+ * meaning the moment someone redefines 0 as "unset". The page has an explicit Clear button, so
286
+ * the code gets an explicit function for it.
287
+ *
288
+ * @param {string} dir
289
+ * @param {string} [peerId] worker to clear; empty/undefined clears every worker's captures
290
+ * @returns {{ ok: boolean, removed: number, freedBytes: number, bytes: number, error: string }}
291
+ */
292
+ export function clearShots(dir, peerId = '') {
293
+ const nothing = { ok: true, removed: 0, freedBytes: 0, bytes: 0, error: '' }
294
+ if (typeof dir !== 'string' || dir === '') {
295
+ return { ...nothing, ok: false, error: 'clearShots: no directory given' }
296
+ }
297
+ let names
298
+ try {
299
+ names = readdirSync(dir)
300
+ } catch (error) {
301
+ if (error?.code === 'ENOENT') return nothing
302
+ return { ...nothing, ok: false, error: `clearShots: cannot list ${dir}: ${error.message}` }
303
+ }
304
+
305
+ // An empty peer id means "every worker", which is how the page offers one button per directory
306
+ // rather than one deletion path per worker. The pattern still gates what may be removed: with
307
+ // no peer id the accepted shape is any `<peerId>-<stamp>.png`, and a foreign file still fails it.
308
+ const pattern = String(peerId ?? '') === ''
309
+ ? /^[^-]+(?:-[^-]+)*-\d{4}-\d{2}-\d{2}T[\d-]+Z\.png$/u
310
+ : shotPattern(String(peerId))
311
+
312
+ let removed = 0
313
+ let freedBytes = 0
314
+ let bytes = 0
315
+ let firstError = ''
316
+ for (const name of names) {
317
+ const path = join(dir, name)
318
+ let stat
319
+ try {
320
+ stat = statSync(path)
321
+ } catch { continue }
322
+ if (!stat.isFile()) continue
323
+ if (!pattern.test(name)) continue
324
+ bytes += stat.size
325
+ try {
326
+ unlinkSync(path)
327
+ removed += 1
328
+ freedBytes += stat.size
329
+ } catch (error) {
330
+ if (firstError === '') firstError = `cannot delete ${name}: ${error.message}`
331
+ }
332
+ }
333
+ return { ok: firstError === '', removed, freedBytes, bytes: bytes - freedBytes, error: firstError }
334
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The one rule about a task's working directory, shared by both ends of a pairing.
3
+ *
4
+ * WHY IT IS SHARED: the directory is a WORKER-side absolute path. The master stores it and sends
5
+ * it as the ACP session's `cwd`; the worker advertises what it should be inside its pairing
6
+ * string. Both ends must agree on what "usable" means -- two copies of the rule would drift, and
7
+ * the drift would look like a worker advertising a path the master then refuses.
8
+ *
9
+ * WHY IT MATTERS MORE THAN IT LOOKS (DESIGN §6.4): this is where the task runs AND the scope the
10
+ * sandbox allows writes to. A relative or empty value does not fail where it is set -- DSH's ACP
11
+ * server rejects a non-absolute `cwd` with an opaque -32602, and an empty one binds the task to
12
+ * the relay process's launch directory, so the task writes somewhere nobody chose.
13
+ *
14
+ * Two messages, one rule: `assertUsableCwd` is worded for the MASTER configuring a worker (it
15
+ * names the field to fix), while `isAbsoluteTaskPath` is the bare predicate the WORKER's own
16
+ * settings page uses to word its own refusal.
17
+ */
18
+
19
+ /**
20
+ * Is this a path a task may be given: absolute, non-empty?
21
+ *
22
+ * Windows drive-absolute (`C:\x`, `C:/x`) and POSIX-absolute (`/x`) only. A UNC path is
23
+ * deliberately not accepted: nothing has been validated against a share, and a task whose scope
24
+ * is a network path is a different security question than this plugin answers.
25
+ *
26
+ * @param {unknown} value
27
+ * @returns {boolean}
28
+ */
29
+ export function isAbsoluteTaskPath(value) {
30
+ if (typeof value !== 'string') return false
31
+ const path = value.trim()
32
+ if (path === '') return false
33
+ return /^[a-zA-Z]:[\\/]/u.test(path) || path.startsWith('/')
34
+ }
35
+
36
+ /**
37
+ * Report why a worker cwd is unusable, or '' when it is fine.
38
+ *
39
+ * @param {string} cwd
40
+ * @returns {string} a human-readable problem, or '' when usable
41
+ */
42
+ export function assertUsableCwd(cwd) {
43
+ if (typeof cwd !== 'string' || cwd.trim() === '') {
44
+ return 'worker cwd is empty -- set `cwd` to an absolute path that exists on the worker'
45
+ }
46
+ if (!isAbsoluteTaskPath(cwd)) {
47
+ return `worker cwd must be an absolute path, got "${cwd}" (a relative path would bind tasks to the relay's launch directory)`
48
+ }
49
+ return ''
50
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * 任务内审批记忆(INTERFACES.md 【A】;DESIGN §8.3)。
3
+ *
4
+ * 纯内存、无依赖、无 I/O。
5
+ *
6
+ * **范围严格限定在单任务内**:跨任务记忆会让「上周批准过写这个目录」静默变成永久授权
7
+ * (DESIGN §8.3「为什么默认单任务内」)。任务结束必须 `dispose()`;`dispose()` 之后
8
+ * `lookup()` 恒为 `'unknown'`,且 `noteApprove()` / `noteReject()` 变成 no-op——
9
+ * 已销毁的记忆绝不能复活授权。
10
+ *
11
+ * 三种状态互不覆盖,查询时**拒绝优先**(fail closed):
12
+ * - `noteReject(key)` → `lookup` = `'rejected'`(即使同一 key 也有批准)
13
+ * - `noteApprove(key, { scope: 'task' })` → `'allowed'`,任务内持续有效
14
+ * - `noteApprove(key, { scope: 'once' })` → `'allowed'`,由 `consumeOnce()` 用掉一次
15
+ */
16
+
17
+ /**
18
+ * 单任务审批记忆。
19
+ */
20
+ export class TaskMemory {
21
+ /** @type {string} */
22
+ #taskId
23
+
24
+ /** @type {Set<string>} */
25
+ #approvals
26
+
27
+ /** @type {Set<string>} */
28
+ #rejections
29
+
30
+ /** @type {Set<string>} */
31
+ #once
32
+
33
+ /** @type {boolean} */
34
+ #disposed
35
+
36
+ /**
37
+ * @param {string} taskId 任务标识(非字符串按 `''` 处理,不抛错)
38
+ */
39
+ constructor(taskId) {
40
+ this.#taskId = typeof taskId === 'string' ? taskId : ''
41
+ this.#approvals = new Set()
42
+ this.#rejections = new Set()
43
+ this.#once = new Set()
44
+ this.#disposed = false
45
+ }
46
+
47
+ /**
48
+ * 是否已 `dispose()`(只读,便于审计/测试)。
49
+ * @returns {boolean}
50
+ */
51
+ get disposed() {
52
+ return this.#disposed
53
+ }
54
+
55
+ /**
56
+ * 记下本次批准。
57
+ *
58
+ * `scope: 'task'`(默认)= 本任务内该类永久有效;`scope: 'once'` = 只放行一次,
59
+ * 由 `consumeOnce()` 消耗。**未识别的 scope 按 `'once'` 处理**(fail closed:宁可
60
+ * 只放行一次,也不让一个拼错的 scope 静默放大成任务级授权)。
61
+ *
62
+ * 已是任务级批准的 key 再记 `'once'` 不生效(任务级已覆盖,无需降级)。
63
+ *
64
+ * @param {string} key 非空字符串(来自 `review-engine.memoryKey`),否则抛 `TypeError`
65
+ * @param {{ scope?: 'task' | 'once' }} [opts]
66
+ * @returns {void}
67
+ */
68
+ noteApprove(key, { scope = 'task' } = {}) {
69
+ const k = requireKey(key, 'noteApprove')
70
+ if (this.#disposed) return
71
+ if (scope === 'task') {
72
+ this.#approvals.add(k)
73
+ this.#once.delete(k)
74
+ return
75
+ }
76
+ if (!this.#approvals.has(k)) this.#once.add(k)
77
+ }
78
+
79
+ /**
80
+ * 记下本次拒绝。同一 key 在本任务内不再询问(规则引擎会直接拒绝)。
81
+ * 拒绝会作废该 key 待用的一次性批准;已记录的任务级批准保留在快照里,
82
+ * 但查询时被拒绝覆盖(`lookup` 恒返回 `'rejected'`)。
83
+ *
84
+ * @param {string} key 非空字符串,否则抛 `TypeError`
85
+ * @returns {void}
86
+ */
87
+ noteReject(key) {
88
+ const k = requireKey(key, 'noteReject')
89
+ if (this.#disposed) return
90
+ this.#rejections.add(k)
91
+ this.#once.delete(k)
92
+ }
93
+
94
+ /**
95
+ * 查询 key 的记忆状态。查询路径**永不抛错**(决策路径不能因为坏输入挂住审批)。
96
+ *
97
+ * @param {unknown} key
98
+ * @returns {'allowed' | 'rejected' | 'unknown'} 已 `dispose()` 或 key 非法时恒为 `'unknown'`
99
+ */
100
+ lookup(key) {
101
+ if (this.#disposed) return 'unknown'
102
+ if (typeof key !== 'string' || key === '') return 'unknown'
103
+ if (this.#rejections.has(key)) return 'rejected'
104
+ if (this.#approvals.has(key)) return 'allowed'
105
+ if (this.#once.has(key)) return 'allowed'
106
+ return 'unknown'
107
+ }
108
+
109
+ /**
110
+ * 消耗一次 `'once'` 批准;返回 `true` 表示这次放行已用掉。
111
+ *
112
+ * `'task'` 作用域的批准不会被消耗(返回 `false`,`lookup` 仍为 `'allowed'`)。
113
+ * 若该 key 同时被拒绝,批准作废且返回 `false`(拒绝优先)。
114
+ *
115
+ * @param {unknown} key
116
+ * @returns {boolean} key 非法、已 `dispose()`、无 `'once'` 批准、或被拒绝时为 `false`
117
+ */
118
+ consumeOnce(key) {
119
+ if (this.#disposed) return false
120
+ if (typeof key !== 'string' || key === '') return false
121
+ if (!this.#once.has(key)) return false
122
+ this.#once.delete(key)
123
+ return !this.#rejections.has(key)
124
+ }
125
+
126
+ /**
127
+ * 快照(每次返回新对象与新数组,调用方改动不影响内部状态)。
128
+ * @returns {{ taskId: string, approvals: string[], rejections: string[], once: string[] }}
129
+ */
130
+ snapshot() {
131
+ return {
132
+ taskId: this.#taskId,
133
+ approvals: [...this.#approvals],
134
+ rejections: [...this.#rejections],
135
+ once: [...this.#once],
136
+ }
137
+ }
138
+
139
+ /**
140
+ * 清空并标记失效。之后 `lookup()` 恒 `'unknown'`,`noteApprove` / `noteReject` 为 no-op,
141
+ * `consumeOnce()` 恒 `false`。幂等。
142
+ * @returns {void}
143
+ */
144
+ dispose() {
145
+ this.#approvals.clear()
146
+ this.#rejections.clear()
147
+ this.#once.clear()
148
+ this.#disposed = true
149
+ }
150
+ }
151
+
152
+ /**
153
+ * 校验记忆键(只用于**写入**路径;查询路径不抛错)。
154
+ * @param {unknown} key
155
+ * @param {string} method
156
+ * @returns {string}
157
+ */
158
+ function requireKey(key, method) {
159
+ if (typeof key !== 'string' || key === '') {
160
+ throw new TypeError(`TaskMemory.${method}(): key must be a non-empty string`)
161
+ }
162
+ return key
163
+ }