@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.
- package/CLA.md +79 -0
- package/LICENSE-COMMERCIAL +78 -0
- package/LICENSE-CORE +661 -0
- package/LICENSE-SDK +21 -0
- package/LICENSES/cordis-MIT.txt +39 -0
- package/NOTICE +95 -0
- package/README.md +158 -2
- package/client.js +7287 -0
- package/cordis.patch.yml +163 -0
- package/defaults/umbrella-rules.yaml +113 -0
- package/docs/CONTRACT-INTEROP.md +77 -0
- package/docs/CRASH-SAFETY.md +69 -0
- package/docs/DECISION-RELAY.md +46 -0
- package/docs/DSH-ARCHITECTURE-MAP.md +73 -0
- package/docs/FORK-SEED.md +64 -0
- package/docs/MAIN-CITY-GATE.md +58 -0
- package/docs/OVERLAY-MASTER-SWITCH.md +35 -0
- package/docs/PACKAGE-RENAME-v3.3.3.md +40 -0
- package/docs/PM-DECISION-POLICY.md +61 -0
- package/docs/WORKFLOW-UI.md +86 -0
- package/icon.svg +8 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +71 -4
- package/sdk/README.md +65 -0
- package/sdk/examples/status-badge/README.md +16 -0
- package/sdk/examples/status-badge/client.js +60 -0
- package/sdk/examples/status-badge/cordis.patch.yml +7 -0
- package/sdk/examples/status-badge/index.js +12 -0
- package/sdk/examples/status-badge/package.json +24 -0
- package/sdk/templates/plugin/README.md +36 -0
- package/sdk/templates/plugin/client.js +35 -0
- package/sdk/templates/plugin/cordis.patch.yml +8 -0
- package/sdk/templates/plugin/icon.svg +5 -0
- package/sdk/templates/plugin/index.js +23 -0
- package/sdk/templates/plugin/locale/en.json +6 -0
- package/sdk/templates/plugin/package.json +36 -0
- package/src/compose.mjs +67 -0
- package/src/contract/VENDOR.json +16 -0
- package/src/contract/client.js +93 -0
- package/src/contract/constants.js +94 -0
- package/src/contract/envelope.js +83 -0
- package/src/contract/index.js +20 -0
- package/src/contract/negotiate.js +106 -0
- package/src/contract/relay.js +223 -0
- package/src/contract/server.js +77 -0
- package/src/contract/transports.js +319 -0
- package/src/index.mjs +11 -0
- package/src/kernel/core.mjs +100 -0
- package/src/kernel/extensions.mjs +61 -0
- package/src/kernel/fsx.mjs +349 -0
- package/src/kernel/index.mjs +21 -0
- package/src/kernel/ledger.mjs +94 -0
- package/src/kernel/paths.mjs +182 -0
- package/src/kernel/role.mjs +68 -0
- package/src/kernel/rounds.mjs +172 -0
- package/src/kernel/schema.mjs +5 -0
- package/src/kernel/types.mjs +266 -0
- package/src/kernel/vendor/cosmokit.mjs +421 -0
- package/src/kernel/vendor/schemastery.mjs +638 -0
- package/src/lib/adapter-client.mjs +383 -0
- package/src/lib/adapter-protocol.mjs +243 -0
- package/src/lib/ai-secret-store.mjs +202 -0
- package/src/lib/boot-guard.mjs +186 -0
- package/src/lib/branch-store.mjs +84 -0
- package/src/lib/branch.mjs +74 -0
- package/src/lib/bridge-client.mjs +133 -0
- package/src/lib/broker-adapter.mjs +67 -0
- package/src/lib/broker.mjs +45 -0
- package/src/lib/business-producers.mjs +109 -0
- package/src/lib/command-cache.mjs +209 -0
- package/src/lib/compat.mjs +170 -0
- package/src/lib/composer-journal.mjs +128 -0
- package/src/lib/container/index.mjs +806 -0
- package/src/lib/decision-events.mjs +129 -0
- package/src/lib/decision-ingest-client.mjs +41 -0
- package/src/lib/decision-outbox.mjs +129 -0
- package/src/lib/decision-policy.mjs +52 -0
- package/src/lib/default-essence.mjs +61 -0
- package/src/lib/desktop-launch.mjs +138 -0
- package/src/lib/essence.mjs +1211 -0
- package/src/lib/fork-client.mjs +410 -0
- package/src/lib/fork-config-lock.mjs +166 -0
- package/src/lib/fork-seed.mjs +304 -0
- package/src/lib/fork-sessions.mjs +183 -0
- package/src/lib/fork-supervision-settings.mjs +58 -0
- package/src/lib/fork-supervisor.mjs +122 -0
- package/src/lib/fs-guard.mjs +28 -0
- package/src/lib/http-auth.mjs +162 -0
- package/src/lib/idle-audit.mjs +178 -0
- package/src/lib/interaction-poke.mjs +61 -0
- package/src/lib/main-gate.mjs +579 -0
- package/src/lib/mirror-etag.mjs +92 -0
- package/src/lib/mirror-official-source.mjs +93 -0
- package/src/lib/mirror.mjs +478 -0
- package/src/lib/mother-link.mjs +156 -0
- package/src/lib/mutex.mjs +31 -0
- package/src/lib/permissions.mjs +115 -0
- package/src/lib/pnpm-store.mjs +189 -0
- package/src/lib/preferences.mjs +212 -0
- package/src/lib/producer-contract.mjs +162 -0
- package/src/lib/reflow.mjs +41 -0
- package/src/lib/remote.mjs +408 -0
- package/src/lib/restart-contract.mjs +752 -0
- package/src/lib/roots.mjs +30 -0
- package/src/lib/rule-registry.mjs +1127 -0
- package/src/lib/runtime-capture.mjs +307 -0
- package/src/lib/semaphore.mjs +34 -0
- package/src/lib/session-bundle.mjs +138 -0
- package/src/lib/session-format.mjs +672 -0
- package/src/lib/sse-parser.mjs +102 -0
- package/src/lib/ssrf-guard.mjs +170 -0
- package/src/lib/status-store.mjs +257 -0
- package/src/lib/task-handle.mjs +622 -0
- package/src/lib/ui-actions.mjs +55 -0
- package/src/lib/umbrella.mjs +1661 -0
- package/src/lib/upgrade-coordinator.mjs +107 -0
- package/src/lib/upgrade-status.mjs +43 -0
- package/src/plugins/adapter.mjs +1468 -0
- package/src/plugins/approver.mjs +1210 -0
- package/src/plugins/auditor.mjs +330 -0
- package/src/plugins/buffer-keeper.mjs +522 -0
- package/src/plugins/decision-center.mjs +2581 -0
- package/src/plugins/forker.mjs +1610 -0
- package/src/plugins/healer.mjs +702 -0
- package/src/plugins/hot-reloader.mjs +274 -0
- package/src/plugins/imports.mjs +247 -0
- package/src/plugins/orchestrator.mjs +3938 -0
- package/src/plugins/restart-guard.mjs +1022 -0
- package/src/plugins/router.mjs +714 -0
- package/src/plugins/scorer.mjs +661 -0
- package/src/plugins/session-bridge.mjs +166 -0
- package/src/plugins/session-catalog.mjs +771 -0
- package/src/plugins/session-router.mjs +125 -0
- package/src/plugins/session-sync.mjs +171 -0
- package/src/plugins/umbrella.mjs +419 -0
- package/tools/boot-guard.mjs +95 -0
- package/tools/decision-ingest.mjs +53 -0
- package/tools/desktop-launch.mjs +71 -0
- package/tools/fangctl.mjs +408 -0
- package/tools/gate/main-city.mjs +204 -0
- package/tools/mcp/server.mjs +121 -0
- package/tools/mcp/tools.mjs +65 -0
- 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 }
|