@twinsearth/fang-dsh-plugin 0.0.0-stage → 3.3.4

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 (144) hide show
  1. package/CLA.md +79 -0
  2. package/LICENSE-COMMERCIAL +78 -0
  3. package/LICENSE-CORE +661 -0
  4. package/LICENSE-SDK +21 -0
  5. package/LICENSES/cordis-MIT.txt +39 -0
  6. package/NOTICE +95 -0
  7. package/README.md +158 -2
  8. package/client.js +7287 -0
  9. package/cordis.patch.yml +163 -0
  10. package/defaults/umbrella-rules.yaml +113 -0
  11. package/docs/CONTRACT-INTEROP.md +77 -0
  12. package/docs/CRASH-SAFETY.md +69 -0
  13. package/docs/DECISION-RELAY.md +46 -0
  14. package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
  15. package/docs/FORK-SEED.md +64 -0
  16. package/docs/MAIN-CITY-GATE.md +58 -0
  17. package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
  18. package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
  19. package/docs/PM-DECISION-POLICY.md +61 -0
  20. package/docs/WORKFLOW-UI.md +86 -0
  21. package/icon.svg +8 -0
  22. package/locale/en.json +6 -0
  23. package/locale/zh.json +6 -0
  24. package/package.json +71 -4
  25. package/sdk/README.md +65 -0
  26. package/sdk/examples/status-badge/README.md +16 -0
  27. package/sdk/examples/status-badge/client.js +60 -0
  28. package/sdk/examples/status-badge/cordis.patch.yml +7 -0
  29. package/sdk/examples/status-badge/index.js +12 -0
  30. package/sdk/examples/status-badge/package.json +24 -0
  31. package/sdk/templates/plugin/README.md +36 -0
  32. package/sdk/templates/plugin/client.js +35 -0
  33. package/sdk/templates/plugin/cordis.patch.yml +8 -0
  34. package/sdk/templates/plugin/icon.svg +5 -0
  35. package/sdk/templates/plugin/index.js +23 -0
  36. package/sdk/templates/plugin/locale/en.json +6 -0
  37. package/sdk/templates/plugin/package.json +36 -0
  38. package/src/compose.mjs +67 -0
  39. package/src/contract/VENDOR.json +16 -0
  40. package/src/contract/client.js +93 -0
  41. package/src/contract/constants.js +94 -0
  42. package/src/contract/envelope.js +83 -0
  43. package/src/contract/index.js +20 -0
  44. package/src/contract/negotiate.js +106 -0
  45. package/src/contract/relay.js +223 -0
  46. package/src/contract/server.js +77 -0
  47. package/src/contract/transports.js +319 -0
  48. package/src/index.mjs +11 -0
  49. package/src/kernel/core.mjs +100 -0
  50. package/src/kernel/extensions.mjs +61 -0
  51. package/src/kernel/fsx.mjs +349 -0
  52. package/src/kernel/index.mjs +21 -0
  53. package/src/kernel/ledger.mjs +94 -0
  54. package/src/kernel/paths.mjs +182 -0
  55. package/src/kernel/role.mjs +68 -0
  56. package/src/kernel/rounds.mjs +172 -0
  57. package/src/kernel/schema.mjs +5 -0
  58. package/src/kernel/types.mjs +266 -0
  59. package/src/kernel/vendor/cosmokit.mjs +421 -0
  60. package/src/kernel/vendor/schemastery.mjs +638 -0
  61. package/src/lib/adapter-client.mjs +383 -0
  62. package/src/lib/adapter-protocol.mjs +243 -0
  63. package/src/lib/ai-secret-store.mjs +202 -0
  64. package/src/lib/boot-guard.mjs +186 -0
  65. package/src/lib/branch-store.mjs +84 -0
  66. package/src/lib/branch.mjs +74 -0
  67. package/src/lib/bridge-client.mjs +133 -0
  68. package/src/lib/broker-adapter.mjs +67 -0
  69. package/src/lib/broker.mjs +45 -0
  70. package/src/lib/business-producers.mjs +109 -0
  71. package/src/lib/command-cache.mjs +209 -0
  72. package/src/lib/compat.mjs +170 -0
  73. package/src/lib/composer-journal.mjs +128 -0
  74. package/src/lib/container/index.mjs +806 -0
  75. package/src/lib/decision-events.mjs +129 -0
  76. package/src/lib/decision-ingest-client.mjs +41 -0
  77. package/src/lib/decision-outbox.mjs +129 -0
  78. package/src/lib/decision-policy.mjs +52 -0
  79. package/src/lib/default-essence.mjs +61 -0
  80. package/src/lib/desktop-launch.mjs +138 -0
  81. package/src/lib/essence.mjs +1211 -0
  82. package/src/lib/fork-client.mjs +410 -0
  83. package/src/lib/fork-config-lock.mjs +166 -0
  84. package/src/lib/fork-seed.mjs +304 -0
  85. package/src/lib/fork-sessions.mjs +183 -0
  86. package/src/lib/fork-supervision-settings.mjs +58 -0
  87. package/src/lib/fork-supervisor.mjs +122 -0
  88. package/src/lib/fs-guard.mjs +28 -0
  89. package/src/lib/http-auth.mjs +162 -0
  90. package/src/lib/idle-audit.mjs +178 -0
  91. package/src/lib/interaction-poke.mjs +61 -0
  92. package/src/lib/main-gate.mjs +579 -0
  93. package/src/lib/mirror-etag.mjs +92 -0
  94. package/src/lib/mirror-official-source.mjs +93 -0
  95. package/src/lib/mirror.mjs +478 -0
  96. package/src/lib/mother-link.mjs +156 -0
  97. package/src/lib/mutex.mjs +31 -0
  98. package/src/lib/permissions.mjs +115 -0
  99. package/src/lib/pnpm-store.mjs +189 -0
  100. package/src/lib/preferences.mjs +212 -0
  101. package/src/lib/producer-contract.mjs +162 -0
  102. package/src/lib/reflow.mjs +41 -0
  103. package/src/lib/remote.mjs +408 -0
  104. package/src/lib/restart-contract.mjs +752 -0
  105. package/src/lib/roots.mjs +30 -0
  106. package/src/lib/rule-registry.mjs +1127 -0
  107. package/src/lib/runtime-capture.mjs +307 -0
  108. package/src/lib/semaphore.mjs +34 -0
  109. package/src/lib/session-bundle.mjs +138 -0
  110. package/src/lib/session-format.mjs +672 -0
  111. package/src/lib/sse-parser.mjs +102 -0
  112. package/src/lib/ssrf-guard.mjs +170 -0
  113. package/src/lib/status-store.mjs +257 -0
  114. package/src/lib/task-handle.mjs +622 -0
  115. package/src/lib/ui-actions.mjs +55 -0
  116. package/src/lib/umbrella.mjs +1661 -0
  117. package/src/lib/upgrade-coordinator.mjs +107 -0
  118. package/src/lib/upgrade-status.mjs +43 -0
  119. package/src/plugins/adapter.mjs +1468 -0
  120. package/src/plugins/approver.mjs +1210 -0
  121. package/src/plugins/auditor.mjs +330 -0
  122. package/src/plugins/buffer-keeper.mjs +522 -0
  123. package/src/plugins/decision-center.mjs +2581 -0
  124. package/src/plugins/forker.mjs +1610 -0
  125. package/src/plugins/healer.mjs +702 -0
  126. package/src/plugins/hot-reloader.mjs +274 -0
  127. package/src/plugins/imports.mjs +247 -0
  128. package/src/plugins/orchestrator.mjs +3938 -0
  129. package/src/plugins/restart-guard.mjs +1022 -0
  130. package/src/plugins/router.mjs +714 -0
  131. package/src/plugins/scorer.mjs +661 -0
  132. package/src/plugins/session-bridge.mjs +166 -0
  133. package/src/plugins/session-catalog.mjs +771 -0
  134. package/src/plugins/session-router.mjs +125 -0
  135. package/src/plugins/session-sync.mjs +171 -0
  136. package/src/plugins/umbrella.mjs +419 -0
  137. package/tools/boot-guard.mjs +95 -0
  138. package/tools/decision-ingest.mjs +53 -0
  139. package/tools/desktop-launch.mjs +71 -0
  140. package/tools/fangctl.mjs +408 -0
  141. package/tools/gate/main-city.mjs +204 -0
  142. package/tools/mcp/server.mjs +121 -0
  143. package/tools/mcp/tools.mjs +65 -0
  144. package/tools/recover-queue.mjs +133 -0
@@ -0,0 +1,522 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Fang contributors — see LICENSE-CORE.
3
+ // dsh-Fang 2.7 — fang-buffer-keeper: watch a buffer directory and judge it in three
4
+ // states before anything is promoted out of it.
5
+ //
6
+ // settled — nothing changed since the last flush/baseline
7
+ // writing — a change arrived less than settleMs ago (a writer is still active)
8
+ // pending-high-risk— the change went quiet but has not been flushed yet (or a flush
9
+ // gave up while the directory kept changing)
10
+ //
11
+ // Watch mode is `fs.watch(dir, {recursive:true})`; when recursive watching is not
12
+ // available on this platform/FS the keeper degrades to polling `fsx.digestTree(dir)`
13
+ // every pollMs — same states, only latency differs. All watchers/timers are owned by
14
+ // `ctx.effect` disposers, so unloading the plugin cannot leak a handle.
15
+ import fs from 'node:fs'
16
+ import path from 'node:path'
17
+ import {
18
+ AUDIT,
19
+ BUFFER_STATE,
20
+ SERVICE,
21
+ bus,
22
+ diffDigests,
23
+ digestTree,
24
+ ensureDir,
25
+ iso,
26
+ nowMs,
27
+ roleGate,
28
+ writeFileAtomic,
29
+ z,
30
+ } from '../kernel/index.mjs'
31
+ import {
32
+ applyWriteRequest,
33
+ normalizeDeclared,
34
+ validateWriteRequest,
35
+ } from '../lib/container/index.mjs'
36
+
37
+ export const name = 'fang-buffer-keeper'
38
+ export const inject = []
39
+
40
+ const EVENTS = { settled: 'fang/buffer-settled' }
41
+
42
+ const DEFAULTS = {
43
+ root: '',
44
+ settleMs: 750,
45
+ pollMs: 250,
46
+ watch: true,
47
+ flushTimeoutMs: 0,
48
+ }
49
+
50
+ /** v2 declared write proxy defaults (docs/CONTRACTS-V2.md §5.6). */
51
+ const PROXY_DEFAULTS = {
52
+ enabled: false,
53
+ maxBytes: 1048576,
54
+ }
55
+
56
+ /** Ring size of the in-memory proxy log (proxyLog returns the newest entries). */
57
+ const PROXY_LOG_LIMIT = 200
58
+
59
+ /** Files whose content directly changes what the host loads / how it boots. */
60
+ const HIGH_RISK_RE = /(^|\/)(package\.json|cordis[^/]*\.ya?ml|\.env[^/]*)$/i
61
+
62
+ export const Config = z
63
+ .object({
64
+ root: z.string().default('').description('监视根目录(空 = 由 start({dir}) 给)'),
65
+ settleMs: z.natural().default(750).description('静默多久算 settled(毫秒)'),
66
+ pollMs: z.natural().default(250).description('退化轮询间隔(毫秒)'),
67
+ watch: z.boolean().default(true).description('false = 强制走轮询(用于验证退化路径)'),
68
+ flushTimeoutMs: z.natural().default(0).description('flush 硬上限(毫秒,0 = 自动 4×strictMs)'),
69
+ proxy: z
70
+ .object({
71
+ enabled: z.boolean().default(false).description('启用申报写代理(容器/不可信代码只能经此写 bufferRoot)'),
72
+ maxBytes: z.natural().default(1048576).description('单个写请求上限(字节,默认 1MB)'),
73
+ })
74
+ .default({})
75
+ .description('申报写代理配置(v2 追加,默认关闭)'),
76
+ })
77
+ .description('fang-buffer-keeper 配置')
78
+
79
+ const msgOf = (e) => String(e && e.message ? e.message : e)
80
+
81
+ export function apply(ctx, config) {
82
+ const gate = roleGate('bufferKeeper', { cfgRole: config?.role, logger: ctx?.logger })
83
+ if (!gate.active) return undefined
84
+ const cfg = { ...DEFAULTS, ...(config ?? {}) }
85
+ const proxyCfg = { ...PROXY_DEFAULTS, ...(cfg.proxy && typeof cfg.proxy === 'object' ? cfg.proxy : {}) }
86
+ cfg.proxy = proxyCfg
87
+ const logger = ctx?.logger ?? { debug() {}, info() {}, warn() {}, error() {} }
88
+ const safeGet = (key) => {
89
+ try {
90
+ return ctx?.get?.(key)
91
+ } catch {
92
+ return undefined
93
+ }
94
+ }
95
+ const audit = (kind, data) => {
96
+ try {
97
+ const auditor = safeGet(SERVICE.auditor)
98
+ if (auditor && typeof auditor.append === 'function') return auditor.append(kind, data)
99
+ } catch {
100
+ /* auditing must never block a flush */
101
+ }
102
+ return null
103
+ }
104
+ const emit = (topic, payload) => {
105
+ try {
106
+ return bus.emit(topic, payload)
107
+ } catch {
108
+ return 0
109
+ }
110
+ }
111
+
112
+ let dir = ''
113
+ let mode = 'idle' // 'watch' | 'poll' | 'idle'
114
+ let watcher = null
115
+ let pollTimer = null
116
+ let pollSnapshot = {}
117
+ let baselineDigests = {}
118
+ let settledDigests = {}
119
+ let lastEventAt = null
120
+ let lastFlushAt = null
121
+ let forcedHighRisk = false
122
+ let dirty = new Set()
123
+ let flushQueue = Promise.resolve()
124
+
125
+ // ---- v2 declared write proxy state (append-only; v1 state above is untouched) ----
126
+ let declaredWrites = []
127
+ const proxyEntries = []
128
+
129
+ const settleMs = () => Math.max(1, Math.floor(Number(cfg.settleMs) || DEFAULTS.settleMs))
130
+ const pollMs = () => Math.max(10, Math.floor(Number(cfg.pollMs) || DEFAULTS.pollMs))
131
+
132
+ const normalizeRel = (filename) => {
133
+ if (filename === null || filename === undefined || filename === '') return '*'
134
+ return String(filename).replace(/\\/g, '/').replace(/^\/+/, '')
135
+ }
136
+ const noteChange = (filename) => {
137
+ lastEventAt = nowMs()
138
+ dirty.add(normalizeRel(filename))
139
+ }
140
+ const closeWatcher = () => {
141
+ const w = watcher
142
+ watcher = null
143
+ if (w) {
144
+ try {
145
+ w.close()
146
+ } catch {
147
+ /* already closed */
148
+ }
149
+ }
150
+ }
151
+ const clearPoll = () => {
152
+ if (pollTimer) {
153
+ clearInterval(pollTimer)
154
+ pollTimer = null
155
+ }
156
+ }
157
+ const stopWatching = () => {
158
+ closeWatcher()
159
+ clearPoll()
160
+ mode = 'idle'
161
+ }
162
+
163
+ /** Degraded mode: poll digestTree; identical state machine, coarser latency. */
164
+ const startPoll = () => {
165
+ stopWatching()
166
+ mode = 'poll'
167
+ pollSnapshot = digestTree(dir)
168
+ pollTimer = setInterval(() => {
169
+ if (!dir) return
170
+ const cur = digestTree(dir)
171
+ const diff = diffDigests(pollSnapshot, cur)
172
+ const touched = [...diff.added, ...diff.changed, ...diff.removed]
173
+ if (touched.length) {
174
+ for (const rel of touched) dirty.add(normalizeRel(rel))
175
+ lastEventAt = nowMs()
176
+ }
177
+ pollSnapshot = cur
178
+ }, pollMs())
179
+ if (typeof pollTimer.unref === 'function') pollTimer.unref()
180
+ }
181
+
182
+ const startWatch = () => {
183
+ if (cfg.watch !== false) {
184
+ try {
185
+ watcher = fs.watch(dir, { recursive: true }, (eventType, filename) => {
186
+ try {
187
+ noteChange(filename)
188
+ } catch {
189
+ /* a malformed watch event must not kill the keeper */
190
+ }
191
+ })
192
+ watcher.on('error', () => {
193
+ closeWatcher()
194
+ try {
195
+ startPoll()
196
+ } catch {
197
+ mode = 'idle'
198
+ }
199
+ })
200
+ mode = 'watch'
201
+ return
202
+ } catch (e) {
203
+ logger.warn?.(`[fang-buffer-keeper] fs.watch unavailable, falling back to polling: ${msgOf(e)}`)
204
+ closeWatcher()
205
+ }
206
+ }
207
+ startPoll()
208
+ }
209
+
210
+ /**
211
+ * Begin watching a directory. Re-starting the same live dir is a no-op.
212
+ * @returns {{ok:boolean, dir:string, mode?:string, already?:boolean, error?:string}}
213
+ */
214
+ const start = (opts = {}) => {
215
+ const raw = String(opts?.dir ?? cfg.root ?? '').trim()
216
+ if (!raw) return { ok: false, dir: '', error: 'no-dir' }
217
+ const next = path.resolve(raw)
218
+ if (dir === next && mode !== 'idle') return { ok: true, dir, mode, already: true }
219
+ stop()
220
+ ensureDir(next)
221
+ dir = next
222
+ lastEventAt = null
223
+ lastFlushAt = null
224
+ forcedHighRisk = false
225
+ dirty = new Set()
226
+ baselineDigests = digestTree(dir)
227
+ settledDigests = { ...baselineDigests }
228
+ startWatch()
229
+ return { ok: true, dir, mode }
230
+ }
231
+
232
+ /** Stop watching; service stays callable and reports the settled empty state. */
233
+ const stop = () => {
234
+ const previous = dir
235
+ stopWatching()
236
+ dir = ''
237
+ dirty = new Set()
238
+ lastEventAt = null
239
+ lastFlushAt = null
240
+ forcedHighRisk = false
241
+ baselineDigests = {}
242
+ settledDigests = {}
243
+ pollSnapshot = {}
244
+ return { ok: true, dir: previous }
245
+ }
246
+
247
+ const currentDiff = () => {
248
+ const digests = dir ? digestTree(dir) : {}
249
+ const diff = diffDigests(settledDigests, digests)
250
+ const pending = [...diff.added, ...diff.changed, ...diff.removed].sort()
251
+ return { digests, diff, pending }
252
+ }
253
+
254
+ /**
255
+ * @returns {{state:string, dir:string, lastEventAt:number|null, pending:string[], mode:string}}
256
+ */
257
+ const state = () => {
258
+ if (!dir) {
259
+ return { state: BUFFER_STATE.SETTLED, dir: '', lastEventAt: null, pending: [], highRisk: [], mode }
260
+ }
261
+ const { pending } = currentDiff()
262
+ if (!pending.length) {
263
+ return { state: BUFFER_STATE.SETTLED, dir, lastEventAt, pending: [], highRisk: [], mode, lastFlushAt }
264
+ }
265
+ // A pending change can be observed before any watcher/poller event timestamp exists
266
+ // (the digest fallback in currentDiff() sees it first). Stamping the observation time
267
+ // keeps the quiet window honest: without it, the quiet window has no start bound and
268
+ // state() could report pending-high-risk with a null lastEventAt.
269
+ if (lastEventAt === null) lastEventAt = nowMs()
270
+ const quietMs = nowMs() - lastEventAt
271
+ const judged =
272
+ forcedHighRisk || quietMs >= settleMs()
273
+ ? BUFFER_STATE.PENDING_HIGH_RISK
274
+ : BUFFER_STATE.WRITING
275
+ return {
276
+ state: judged,
277
+ dir,
278
+ lastEventAt,
279
+ pending,
280
+ pendingCount: pending.length,
281
+ quietMs,
282
+ settleMs: settleMs(),
283
+ highRisk: pending.filter((rel) => HIGH_RISK_RE.test(rel)),
284
+ mode,
285
+ lastFlushAt,
286
+ }
287
+ }
288
+
289
+ const flushOnce = (opts = {}) =>
290
+ new Promise((resolve) => {
291
+ const strictMs = Math.max(1, Math.floor(Number(opts?.strictMs ?? settleMs()) || settleMs()))
292
+ if (!dir) {
293
+ return resolve({ state: BUFFER_STATE.SETTLED, flushed: [], timedOut: false, dir: '' })
294
+ }
295
+ const begin = nowMs()
296
+ const configured = Math.max(0, Math.floor(Number(cfg.flushTimeoutMs) || 0))
297
+ const hardMs = configured > 0 ? configured : Math.max(strictMs * 4, 250)
298
+ const finish = (result) => resolve({ dir, strictMs, ...result })
299
+ const tick = () => {
300
+ if (!dir) return finish({ state: BUFFER_STATE.SETTLED, flushed: [], timedOut: false })
301
+ const { digests, pending } = currentDiff()
302
+ if (!pending.length) {
303
+ forcedHighRisk = false
304
+ return finish({ state: BUFFER_STATE.SETTLED, flushed: [], timedOut: false })
305
+ }
306
+ const quietMs = nowMs() - (lastEventAt ?? begin)
307
+ if (quietMs >= strictMs) {
308
+ settledDigests = digests
309
+ dirty = new Set()
310
+ forcedHighRisk = false
311
+ lastFlushAt = nowMs()
312
+ emit(EVENTS.settled, { dir, changed: pending })
313
+ audit(AUDIT.BUFFER_FLUSH, { dir, changed: pending, count: pending.length, strictMs })
314
+ return finish({ state: BUFFER_STATE.SETTLED, flushed: pending, timedOut: false })
315
+ }
316
+ if (nowMs() - begin >= hardMs) {
317
+ forcedHighRisk = true
318
+ return finish({
319
+ state: BUFFER_STATE.PENDING_HIGH_RISK,
320
+ flushed: [],
321
+ timedOut: true,
322
+ pending,
323
+ hardMs,
324
+ })
325
+ }
326
+ setTimeout(tick, Math.max(10, Math.min(50, Math.floor(strictMs / 4))))
327
+ }
328
+ tick()
329
+ })
330
+
331
+ /** Serialized so two concurrent flushes cannot interleave their digest bookkeeping. */
332
+ const flush = (opts = {}) => {
333
+ const run = () => flushOnce(opts)
334
+ const p = flushQueue.then(run, run)
335
+ flushQueue = p.then(
336
+ () => undefined,
337
+ () => undefined,
338
+ )
339
+ return p
340
+ }
341
+
342
+ /** Compare the live tree with the last baseline() (NOT with the last flush). */
343
+ const scan = () => {
344
+ const digests = dir ? digestTree(dir) : {}
345
+ const diff = diffDigests(baselineDigests, digests)
346
+ return {
347
+ dir,
348
+ digests,
349
+ changed: diff.changed,
350
+ added: diff.added,
351
+ removed: diff.removed,
352
+ same: diff.same,
353
+ count: Object.keys(digests).length,
354
+ baselineCount: Object.keys(baselineDigests).length,
355
+ }
356
+ }
357
+
358
+ /** Re-snapshot the reference point that scan() compares against. */
359
+ const baseline = () => {
360
+ baselineDigests = dir ? digestTree(dir) : {}
361
+ return { ok: true, dir, count: Object.keys(baselineDigests).length, at: iso() }
362
+ }
363
+
364
+ // -------------------------------------------------------------------------
365
+ // v2 declared write proxy (append-only addition; docs/CONTRACTS-V2.md §5.6)
366
+ // Only writes that (a) stay inside the watched buffer root, (b) are declared in
367
+ // the manifest allowlist and (c) are <= maxBytes are applied — via kernel fsx
368
+ // atomic write — and every attempt is logged + audited.
369
+ // -------------------------------------------------------------------------
370
+
371
+ const proxyEnabled = () => proxyCfg.enabled === true
372
+ const proxyMaxBytes = () => {
373
+ const n = Number(proxyCfg.maxBytes)
374
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : PROXY_DEFAULTS.maxBytes
375
+ }
376
+ /** The buffer root the proxy is allowed to write into (live dir, else configured root). */
377
+ const proxyBufferRoot = () => {
378
+ const raw = String(dir || cfg.root || '').trim()
379
+ return raw ? path.resolve(raw) : ''
380
+ }
381
+ const noteProxy = (entry) => {
382
+ try {
383
+ proxyEntries.push({ at: iso(), ...entry })
384
+ while (proxyEntries.length > PROXY_LOG_LIMIT) proxyEntries.shift()
385
+ } catch {
386
+ /* the proxy log must never block a write decision */
387
+ }
388
+ return entry
389
+ }
390
+
391
+ /** @returns {{enabled:boolean, bufferRoot:string, declaredCount:number}} */
392
+ const proxyState = () => ({
393
+ enabled: proxyEnabled(),
394
+ bufferRoot: proxyBufferRoot(),
395
+ declaredCount: declaredWrites.length,
396
+ })
397
+
398
+ /**
399
+ * Replace the declared-writes manifest. Entries are `{path, reason}` (or plain paths).
400
+ * Malformed / escaping entries are dropped (they could never match a safe request).
401
+ * @returns {{ok:boolean, count:number, reason?:string}}
402
+ */
403
+ const setDeclared = (declared) => {
404
+ if (!Array.isArray(declared)) return { ok: false, count: declaredWrites.length, reason: 'bad-declared' }
405
+ declaredWrites = normalizeDeclared(declared)
406
+ return { ok: true, count: declaredWrites.length }
407
+ }
408
+
409
+ /**
410
+ * Apply one write request inside the buffer root.
411
+ * @param {{op:string, path:string, data:string|Buffer, encoding?:string, reqId?:string}} req
412
+ * @returns {{ok:boolean, target?:string, bytes?:number, reason?:string}}
413
+ */
414
+ const proxyWrite = (req) => {
415
+ const request = req && typeof req === 'object' ? req : {}
416
+ const reqId = request.reqId ?? null
417
+ const reqPath = typeof request.path === 'string' ? request.path : ''
418
+ if (!proxyEnabled()) {
419
+ noteProxy({ op: 'write', reqId, path: reqPath, ok: false, reason: 'proxy-disabled' })
420
+ return { ok: false, reason: 'proxy-disabled' }
421
+ }
422
+ const root = proxyBufferRoot()
423
+ if (!root) {
424
+ noteProxy({ op: 'write', reqId, path: reqPath, ok: false, reason: 'no-buffer-root' })
425
+ return { ok: false, reason: 'no-buffer-root' }
426
+ }
427
+ const check = validateWriteRequest({
428
+ bufferRoot: root,
429
+ declared: declaredWrites,
430
+ req: request,
431
+ maxBytes: proxyMaxBytes(),
432
+ })
433
+ if (!check.ok) {
434
+ audit(AUDIT.ROUTE, {
435
+ op: 'write-proxy',
436
+ decision: 'deny',
437
+ path: reqPath,
438
+ reqId,
439
+ reason: check.reason,
440
+ bufferRoot: root,
441
+ })
442
+ noteProxy({ op: 'write', reqId, path: reqPath, ok: false, reason: check.reason })
443
+ return { ok: false, reason: check.reason }
444
+ }
445
+ const applied = applyWriteRequest({
446
+ bufferRoot: root,
447
+ declared: declaredWrites,
448
+ req: request,
449
+ maxBytes: proxyMaxBytes(),
450
+ fsx: { writeFileAtomic },
451
+ })
452
+ if (!applied.ok) {
453
+ audit(AUDIT.ROUTE, {
454
+ op: 'write-proxy',
455
+ decision: 'deny',
456
+ path: reqPath,
457
+ reqId,
458
+ reason: applied.reason,
459
+ bufferRoot: root,
460
+ })
461
+ noteProxy({ op: 'write', reqId, path: reqPath, ok: false, reason: applied.reason })
462
+ return { ok: false, reason: applied.reason }
463
+ }
464
+ audit(AUDIT.BUFFER_FLUSH, {
465
+ op: 'write-proxy',
466
+ path: check.path,
467
+ target: applied.target,
468
+ bytes: applied.bytes,
469
+ reqId,
470
+ bufferRoot: root,
471
+ })
472
+ noteProxy({ op: 'write', reqId, path: check.path, ok: true, target: applied.target, bytes: applied.bytes })
473
+ return { ok: true, target: applied.target, bytes: applied.bytes }
474
+ }
475
+
476
+ /**
477
+ * Newest-first proxy log.
478
+ * @param {{limit?:number}} [opts]
479
+ * @returns {object[]}
480
+ */
481
+ const proxyLog = (opts = {}) => {
482
+ const raw = Number(opts?.limit)
483
+ const limit = Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 20
484
+ return proxyEntries.slice(-limit).reverse().map((entry) => ({ ...entry }))
485
+ }
486
+
487
+ const service = {
488
+ start,
489
+ stop,
490
+ state,
491
+ flush,
492
+ scan,
493
+ baseline,
494
+ // v2 append-only surface:
495
+ proxyState,
496
+ setDeclared,
497
+ proxyWrite,
498
+ proxyLog,
499
+ get dir() {
500
+ return dir
501
+ },
502
+ get mode() {
503
+ return mode
504
+ },
505
+ get config() {
506
+ return { ...cfg, proxy: { ...proxyCfg } }
507
+ },
508
+ }
509
+
510
+ ctx.effect(() => ctx.provide(SERVICE.buffer, service))
511
+ ctx.effect(() => () => {
512
+ stopWatching()
513
+ dir = ''
514
+ dirty = new Set()
515
+ // v2 proxy state released with the fiber (no user-declared grants survive an unload)
516
+ declaredWrites = []
517
+ proxyEntries.length = 0
518
+ })
519
+ return service
520
+ }
521
+
522
+ export const _internals = { HIGH_RISK_RE, DEFAULTS, EVENTS, PROXY_DEFAULTS, PROXY_LOG_LIMIT }