@dimina-kit/fs-core 0.2.0-dev.20260710085051
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 +45 -0
- package/dist/agent-tools.js +52 -0
- package/dist/client.js +234 -0
- package/dist/disk-mirror.js +97 -0
- package/dist/fs-core.worker.js +1001 -0
- package/dist/fs-query.worker.js +115 -0
- package/dist/sync/sync-engine.js +381 -0
- package/dist/sync/truth-port.js +1 -0
- package/dist/worker-lib/engine-shared.js +25 -0
- package/dist/worker-lib/paths.js +17 -0
- package/dist/worker-lib/rpc-types.js +1 -0
- package/dist/worker-lib/wal-codec.js +111 -0
- package/dist/zip.js +60 -0
- package/package.json +75 -0
- package/src/__checks__/types-smoke.ts +23 -0
- package/src/agent-tools.test.ts +151 -0
- package/src/agent-tools.ts +117 -0
- package/src/client.test.ts +110 -0
- package/src/client.ts +284 -0
- package/src/disk-mirror.test.ts +190 -0
- package/src/disk-mirror.ts +119 -0
- package/src/fs-core-recovery.ts +280 -0
- package/src/fs-core-write-ops.ts +402 -0
- package/src/fs-core.worker.ts +182 -0
- package/src/fs-query.worker.ts +152 -0
- package/src/import-smoke.test.ts +40 -0
- package/src/worker-lib/engine-shared.test.ts +30 -0
- package/src/worker-lib/engine-shared.ts +74 -0
- package/src/worker-lib/paths.test.ts +39 -0
- package/src/worker-lib/paths.ts +14 -0
- package/src/worker-lib/rpc-types.ts +67 -0
- package/src/worker-lib/wal-codec.test.ts +83 -0
- package/src/worker-lib/wal-codec.ts +130 -0
- package/src/zip.test.ts +124 -0
- package/src/zip.ts +60 -0
- package/sync/sync-engine.test.ts +695 -0
- package/sync/sync-engine.ts +459 -0
- package/sync/truth-port.ts +72 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync arbitration engine — the memfs<->disk synchronization core factored
|
|
3
|
+
* out of dimina-kit's workbench `wal-audit.ts` (see
|
|
4
|
+
* devtools-fs-core-feasibility.md §7+§8). A host wires a `client` (the
|
|
5
|
+
* fs-core ledger: write/rm/read/ls) and a `TruthPort` (its external truth
|
|
6
|
+
* source — devtools' `/__fs` bridge + SSE watch, or a future FSA
|
|
7
|
+
* local-directory adapter) and gets back an engine that keeps the ledger,
|
|
8
|
+
* the external truth, and — via `applyToEditor` — the live editor buffer
|
|
9
|
+
* reconciled. `kernel` and turn enforcement never enter this module: a
|
|
10
|
+
* host's own audit-turn surface talks to `client` directly for that (see
|
|
11
|
+
* wal-audit.ts).
|
|
12
|
+
*
|
|
13
|
+
* Echo judgement (both directions):
|
|
14
|
+
* - Outbound (`onHumanSave`): before recording, compare the saved text
|
|
15
|
+
* against the ledger's current record for that path — identical content
|
|
16
|
+
* is a no-op (the ledger already reflects it), skipping a redundant
|
|
17
|
+
* write.
|
|
18
|
+
* - Inbound (`changes` batches, via `handleInboundPath`): first check
|
|
19
|
+
* `pendingWrite` — an entry there was registered by an `onHumanSave` still
|
|
20
|
+
* in flight for the same path, and its presence means this inbound
|
|
21
|
+
* notification is that write's own echo, absorbed with no ledger write
|
|
22
|
+
* and no editor refresh. This check is a structural no-op for a 'push'
|
|
23
|
+
* port (devtools' SSE): registration and clearing both happen inside the
|
|
24
|
+
* SAME `ledgerTurn` FIFO slot as the write they guard (see
|
|
25
|
+
* `onHumanSave`), so by the time any later-queued inbound turn for that
|
|
26
|
+
* path runs, the entry is already gone — the branch exists for a future
|
|
27
|
+
* 'poll' host whose change detection runs OUTSIDE this FIFO (e.g. an
|
|
28
|
+
* mtime/size sweep) and can therefore observe the entry while the write
|
|
29
|
+
* is still in flight. When `pendingWrite` misses, the engine falls back
|
|
30
|
+
* to today's content comparison (truth-source bytes vs. the ledger's own
|
|
31
|
+
* record) — the only judgement a push host ever actually exercises.
|
|
32
|
+
*
|
|
33
|
+
* Inbound-echo consumption (`consumeInboundEcho`, `inboundApplied`): a 'poll'
|
|
34
|
+
* host (e.g. the web local-directory adapter) drives its OWN outbound path
|
|
35
|
+
* OUTSIDE this module — it reads the ledger and writes the truth source
|
|
36
|
+
* directly, not through `onHumanSave` — so a change this engine just applied
|
|
37
|
+
* INBOUND (disk -> ledger, via `handleInboundPath`) can race that host's
|
|
38
|
+
* outbound scan and get echoed straight back to disk before the poll
|
|
39
|
+
* baseline ever settles. `inboundApplied` (`rel -> {kind:'text'|'binary'|
|
|
40
|
+
* 'delete', ...}`) records exactly what `handleInboundPath` just applied,
|
|
41
|
+
* overwriting any prior entry for the same path; `consumeInboundEcho(rel,
|
|
42
|
+
* content)` lets such a host check "is this outbound write about to re-emit
|
|
43
|
+
* an inbound change I haven't published yet?" immediately BEFORE writing to
|
|
44
|
+
* the truth source, consuming (clearing) the record on a match so it is a
|
|
45
|
+
* one-shot suppression, not a standing content cache. A miss (different
|
|
46
|
+
* content, or no record at all) returns false and leaves any existing record
|
|
47
|
+
* untouched — it is not this call's echo to consume.
|
|
48
|
+
*
|
|
49
|
+
* Binary layering (v1): the first 8192 bytes of a file are sniffed for a NUL
|
|
50
|
+
* byte to classify it binary. A binary file never reaches the fs-core
|
|
51
|
+
* ledger (`client.write`/`read`) — it is tracked only in the in-memory
|
|
52
|
+
* `binaryIndex` (`rel -> { size, sha256 }`), and echo judgement for it is
|
|
53
|
+
* size+hash equality instead of a ledger content compare. What/why this is
|
|
54
|
+
* narrower than the text path: binary changes get NO WAL audit and NO
|
|
55
|
+
* rollback (the audit turn surface — `diff`/`restore` — is a string-content
|
|
56
|
+
* contract; v1 does not support an agent writing binary inside a turn), and
|
|
57
|
+
* `binaryIndex` is session-scoped — it is cleared and rebuilt from scratch on
|
|
58
|
+
* every `populateLedger()`, exactly like the ledger's own text reconciliation.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
import type { TruthPort } from './truth-port.js'
|
|
62
|
+
|
|
63
|
+
export type { TruthPort, TruthPortCapabilities } from './truth-port.js'
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The slice of the fs-core ledger client the sync engine calls. Kept narrow
|
|
67
|
+
* (vs. the full `ProjectFsClient` surface) so a host/test double only has to
|
|
68
|
+
* implement what this module actually uses. Turn enforcement
|
|
69
|
+
* (turnBegin/turnEnd/diff/restore) and destroy() are NOT part of this
|
|
70
|
+
* surface — they belong to a host's own audit-turn surface, which talks to
|
|
71
|
+
* the real client directly (see dimina-kit workbench's wal-audit.ts).
|
|
72
|
+
*/
|
|
73
|
+
export interface SyncClientLike {
|
|
74
|
+
write(path: string, content: string, opts?: Record<string, unknown>): Promise<unknown>
|
|
75
|
+
rm(path: string, opts?: Record<string, unknown>): Promise<unknown>
|
|
76
|
+
read(path: string): Promise<{ content: string; rev?: number }>
|
|
77
|
+
ls(): Promise<{ paths: string[] }>
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface SyncEngineOptions {
|
|
81
|
+
/**
|
|
82
|
+
* Host seam: push an inbound change into the live editor buffer.
|
|
83
|
+
* `bytes === null` means the path was deleted. Omitted — the ledger still
|
|
84
|
+
* records the change but the editor buffer is left untouched.
|
|
85
|
+
*/
|
|
86
|
+
applyToEditor?: (rel: string, bytes: Uint8Array | null) => Promise<void>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface SyncEngine {
|
|
90
|
+
/**
|
|
91
|
+
* Walk the port's tree into the ledger and reconcile residue: ledger paths
|
|
92
|
+
* absent from the walk are removed, so the ledger ends up exactly matching
|
|
93
|
+
* the truth source. Call once (per session) before `start()`.
|
|
94
|
+
*/
|
|
95
|
+
populateLedger(): Promise<void>
|
|
96
|
+
/**
|
|
97
|
+
* Record a human save's content in the ledger — skipped when it already
|
|
98
|
+
* matches the ledger's current record for that path. Call AFTER the save
|
|
99
|
+
* has landed at the truth source: this is best-effort accounting, never a
|
|
100
|
+
* gate on the save itself.
|
|
101
|
+
*
|
|
102
|
+
* `content` accepts either the already-decoded text (legacy string path)
|
|
103
|
+
* or the raw saved bytes (`Uint8Array`) — pass raw bytes so the engine can
|
|
104
|
+
* sniff for binary content (see this module's "Binary layering" doc) and
|
|
105
|
+
* route it to the session-scoped `binaryIndex` instead of the ledger.
|
|
106
|
+
*/
|
|
107
|
+
onHumanSave(rel: string, content: string | Uint8Array): Promise<void>
|
|
108
|
+
/**
|
|
109
|
+
* One-shot check for a 'poll' host's own outbound path (see this module's
|
|
110
|
+
* "Inbound-echo consumption" doc): call this BEFORE writing `content` (the
|
|
111
|
+
* decoded text, raw bytes, or `null` for a delete) for `rel` out to the
|
|
112
|
+
* truth source. Returns `true` when it exactly matches what
|
|
113
|
+
* `handleInboundPath` just applied FROM that same truth source — that
|
|
114
|
+
* write would be a pure echo, so the caller should skip it — and clears
|
|
115
|
+
* the record so it cannot match again (one-shot, not a standing cache).
|
|
116
|
+
* Returns `false` on any mismatch or when there is no record at all,
|
|
117
|
+
* leaving any existing record untouched.
|
|
118
|
+
*/
|
|
119
|
+
consumeInboundEcho(rel: string, content: string | Uint8Array | null): boolean
|
|
120
|
+
/** Subscribe to `port.changes` and start reconciling inbound batches. */
|
|
121
|
+
start(): void
|
|
122
|
+
/** Tear down the `port.changes` subscription. Idempotent. */
|
|
123
|
+
stop(): void
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const BINARY_SNIFF_BYTES = 8192
|
|
127
|
+
|
|
128
|
+
/** True when the first `BINARY_SNIFF_BYTES` of `bytes` contain a NUL byte. */
|
|
129
|
+
function looksBinary(bytes: Uint8Array): boolean {
|
|
130
|
+
const len = Math.min(bytes.length, BINARY_SNIFF_BYTES)
|
|
131
|
+
for (let i = 0; i < len; i++) {
|
|
132
|
+
if (bytes[i] === 0) return true
|
|
133
|
+
}
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function sha256hex(bytes: Uint8Array): Promise<string> {
|
|
138
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes as BufferSource)
|
|
139
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Byte-for-byte equality — used by `consumeInboundEcho`'s binary-content match. */
|
|
143
|
+
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
144
|
+
if (a.length !== b.length) return false
|
|
145
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* True when `error` denotes "path does not exist" per the TruthPort error
|
|
151
|
+
* contract (see truth-port.ts): `error.code === 'not-found'` or
|
|
152
|
+
* `error.status === 404`. Every other rejection is "unavailable" (transient
|
|
153
|
+
* I/O failure, permission loss, dead connection, ...).
|
|
154
|
+
*/
|
|
155
|
+
function isNotFoundError(error: unknown): boolean {
|
|
156
|
+
const e = error as { code?: unknown; status?: unknown } | null | undefined
|
|
157
|
+
return Boolean(e) && (e!.code === 'not-found' || e!.status === 404)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
type InboundApplied =
|
|
161
|
+
| { kind: 'text'; text: string }
|
|
162
|
+
| { kind: 'binary'; bytes: Uint8Array }
|
|
163
|
+
| { kind: 'delete' }
|
|
164
|
+
|
|
165
|
+
export function createSyncEngine(
|
|
166
|
+
client: SyncClientLike,
|
|
167
|
+
port: TruthPort,
|
|
168
|
+
opts: SyncEngineOptions = {},
|
|
169
|
+
): SyncEngine {
|
|
170
|
+
const decoder = new TextDecoder()
|
|
171
|
+
const applyToEditor = opts.applyToEditor
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Paths with an `onHumanSave` write in flight — see the module doc's
|
|
175
|
+
* "Echo judgement" section. Registered synchronously when `onHumanSave` is
|
|
176
|
+
* called and cleared unconditionally (`finally`) once that write's own
|
|
177
|
+
* ledgerTurn slot finishes, success or failure.
|
|
178
|
+
*/
|
|
179
|
+
const pendingWrite = new Set<string>()
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Binary files never enter the fs-core ledger — see the module doc's
|
|
183
|
+
* "Binary layering" section. Session-scoped: rebuilt from scratch on every
|
|
184
|
+
* `populateLedger()`.
|
|
185
|
+
*/
|
|
186
|
+
const binaryIndex = new Map<string, { size: number; sha256: string }>()
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* `rel -> { kind: 'text', text } | { kind: 'binary', bytes } | { kind: 'delete' }`
|
|
190
|
+
* — see the module doc's "Inbound-echo consumption" section. Written by
|
|
191
|
+
* `handleInboundPath` immediately after it actually applies an inbound
|
|
192
|
+
* change (ledger write/rm, or a binary/delete index update); consumed
|
|
193
|
+
* (checked + cleared on a match) by `consumeInboundEcho`. Session-scoped,
|
|
194
|
+
* same as `binaryIndex` — cleared on every `populateLedger()`.
|
|
195
|
+
*/
|
|
196
|
+
const inboundApplied = new Map<string, InboundApplied>()
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* FIFO queue serializing every compare-then-record against the ledger
|
|
200
|
+
* (inbound change batches AND the onHumanSave accounting step). Without it
|
|
201
|
+
* the two race: an inbound echo's compare-read can run while an
|
|
202
|
+
* onHumanSave ledger write is still in flight, see stale content, and
|
|
203
|
+
* re-record the identical bytes. Serialized, the loser of the race sees
|
|
204
|
+
* the winner's write and absorbs it as an echo. The chain swallows step
|
|
205
|
+
* rejections (each caller handles its own errors), so one failed step can
|
|
206
|
+
* never wedge the queue.
|
|
207
|
+
*/
|
|
208
|
+
let ledgerTurn: Promise<unknown> = Promise.resolve()
|
|
209
|
+
function enqueueLedgerTurn<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
210
|
+
const next = ledgerTurn.then(fn, fn) as Promise<T>
|
|
211
|
+
ledgerTurn = next.catch(() => {})
|
|
212
|
+
return next
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Walk the port's tree into the ledger, then reconcile: ledger paths the
|
|
216
|
+
* walk did not produce are residue (e.g. from a previous session under the
|
|
217
|
+
* same persisted ledger identity) and are removed so the ledger ends up
|
|
218
|
+
* exactly matching the walked tree. */
|
|
219
|
+
async function seedFromDisk(): Promise<void> {
|
|
220
|
+
binaryIndex.clear()
|
|
221
|
+
inboundApplied.clear()
|
|
222
|
+
const seen = new Set<string>()
|
|
223
|
+
await port.walk(async (rel, bytes) => {
|
|
224
|
+
seen.add(rel)
|
|
225
|
+
if (looksBinary(bytes)) {
|
|
226
|
+
binaryIndex.set(rel, { size: bytes.length, sha256: await sha256hex(bytes) })
|
|
227
|
+
return // binary never enters the ledger
|
|
228
|
+
}
|
|
229
|
+
await client.write(rel, decoder.decode(bytes), { actor: 'human' })
|
|
230
|
+
})
|
|
231
|
+
const { paths } = await client.ls()
|
|
232
|
+
for (const p of paths) {
|
|
233
|
+
// Residue from a previous session, OR a path that used to be
|
|
234
|
+
// ledgered as text but is now classified binary (migration cleanup —
|
|
235
|
+
// binaryIndex.has(p) is authoritative once the walk above ran).
|
|
236
|
+
if (!seen.has(p) || binaryIndex.has(p)) await client.rm(p, { actor: 'human' })
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Process one inbound path. `pendingWrite` is checked first (see module
|
|
242
|
+
* doc); a miss falls back to content comparison: identical content is the
|
|
243
|
+
* echo of our own last write and is dropped with no ledger write and no
|
|
244
|
+
* `applyToEditor` call. A `port.read` rejection classified as not-found
|
|
245
|
+
* (see truth-port.ts) is a deletion; any other rejection
|
|
246
|
+
* ("unavailable") is transient and skips the path entirely — inferring a
|
|
247
|
+
* deletion from it would rm the ledger record and close the file in the
|
|
248
|
+
* editor while it still exists at the truth source.
|
|
249
|
+
*/
|
|
250
|
+
async function handleInboundPath(rel: string): Promise<void> {
|
|
251
|
+
if (pendingWrite.has(rel)) {
|
|
252
|
+
pendingWrite.delete(rel)
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
let bytes: Uint8Array | null
|
|
257
|
+
try {
|
|
258
|
+
bytes = await port.read(rel)
|
|
259
|
+
} catch (e) {
|
|
260
|
+
if (!isNotFoundError(e)) {
|
|
261
|
+
console.warn('[fs-core/sync] transient port read failure, skipping', rel, e)
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
bytes = null
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (bytes === null) {
|
|
268
|
+
// Deletion. A path the binaryIndex knows about was never in the
|
|
269
|
+
// ledger (it never took the client.write path), so removal here is
|
|
270
|
+
// index-only — no client.rm.
|
|
271
|
+
if (binaryIndex.has(rel)) {
|
|
272
|
+
binaryIndex.delete(rel)
|
|
273
|
+
inboundApplied.set(rel, { kind: 'delete' })
|
|
274
|
+
await applyToEditor?.(rel, null)
|
|
275
|
+
return
|
|
276
|
+
}
|
|
277
|
+
let ledgerContent: string | undefined
|
|
278
|
+
try {
|
|
279
|
+
ledgerContent = (await client.read(rel)).content
|
|
280
|
+
} catch {
|
|
281
|
+
ledgerContent = undefined
|
|
282
|
+
}
|
|
283
|
+
if (ledgerContent === undefined) return // already absent from both sides
|
|
284
|
+
await client.rm(rel, { actor: 'human' })
|
|
285
|
+
inboundApplied.set(rel, { kind: 'delete' })
|
|
286
|
+
await applyToEditor?.(rel, null)
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (looksBinary(bytes)) {
|
|
291
|
+
const prior = binaryIndex.get(rel)
|
|
292
|
+
const sha256 = await sha256hex(bytes)
|
|
293
|
+
if (prior && prior.size === bytes.length && prior.sha256 === sha256) return // echo: same bytes already indexed
|
|
294
|
+
binaryIndex.set(rel, { size: bytes.length, sha256 })
|
|
295
|
+
inboundApplied.set(rel, { kind: 'binary', bytes })
|
|
296
|
+
await applyToEditor?.(rel, bytes)
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let ledgerContent: string | undefined
|
|
301
|
+
try {
|
|
302
|
+
ledgerContent = (await client.read(rel)).content
|
|
303
|
+
} catch {
|
|
304
|
+
// Not in the ledger yet (or a transient read failure) — treated the
|
|
305
|
+
// same as "no prior recorded content", so a real file at the truth
|
|
306
|
+
// source always gets recorded/applied below rather than silently
|
|
307
|
+
// dropped.
|
|
308
|
+
ledgerContent = undefined
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const text = decoder.decode(bytes)
|
|
312
|
+
if (ledgerContent !== undefined && text === ledgerContent) return // echo of our own write
|
|
313
|
+
|
|
314
|
+
await client.write(rel, text, { actor: 'human' })
|
|
315
|
+
inboundApplied.set(rel, { kind: 'text', text })
|
|
316
|
+
await applyToEditor?.(rel, bytes)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* One-shot check for a 'poll' host's own outbound path: "is `content` (the
|
|
321
|
+
* bytes/text about to be written to the truth source for `rel`, or `null`
|
|
322
|
+
* for a delete) exactly what `handleInboundPath` just applied FROM that
|
|
323
|
+
* same truth source?" A match means writing it back out would be a pure
|
|
324
|
+
* echo — the host should skip the write entirely — and the record is
|
|
325
|
+
* cleared (consumed) so it cannot match again. See the module doc's
|
|
326
|
+
* "Inbound-echo consumption" section for why a push host (devtools) never
|
|
327
|
+
* needs this: its outbound path goes through `onHumanSave`/`pendingWrite`
|
|
328
|
+
* instead.
|
|
329
|
+
*/
|
|
330
|
+
function consumeInboundEcho(rel: string, content: string | Uint8Array | null): boolean {
|
|
331
|
+
const entry = inboundApplied.get(rel)
|
|
332
|
+
if (!entry) return false
|
|
333
|
+
let matches: boolean
|
|
334
|
+
if (content === null) matches = entry.kind === 'delete'
|
|
335
|
+
else if (typeof content === 'string') matches = entry.kind === 'text' && entry.text === content
|
|
336
|
+
else matches = entry.kind === 'binary' && bytesEqual(entry.bytes, content)
|
|
337
|
+
if (matches) inboundApplied.delete(rel)
|
|
338
|
+
return matches
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* A batch is trusted as-is: the port adapter (see truth-port.ts's
|
|
343
|
+
* `changes` doc) is responsible for turning a watcher's coalesced/lossy
|
|
344
|
+
* events into the actual set of paths worth re-examining BEFORE calling
|
|
345
|
+
* this engine — devtools' adapter (wal-audit.ts +
|
|
346
|
+
* wal-audit-watch-expand.ts) does that via a stat-level disk compare
|
|
347
|
+
* against a session index, so paths arriving here have already earned
|
|
348
|
+
* their re-examination and no further expansion happens at this layer.
|
|
349
|
+
*/
|
|
350
|
+
async function handleBatch(paths: string[]): Promise<void> {
|
|
351
|
+
for (const rel of paths) {
|
|
352
|
+
try {
|
|
353
|
+
// Per-path (not per-batch) queue turns, so a long batch cannot
|
|
354
|
+
// starve an interleaved onHumanSave accounting step of its FIFO
|
|
355
|
+
// position.
|
|
356
|
+
await enqueueLedgerTurn(() => handleInboundPath(rel))
|
|
357
|
+
} catch (e) {
|
|
358
|
+
console.warn('[fs-core/sync] disk sync failed for', rel, e)
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
let pendingInboundPaths = new Set<string>()
|
|
364
|
+
let inboundBatchRunning = false
|
|
365
|
+
function enqueueInboundBatch(paths: string[]): void {
|
|
366
|
+
for (const rel of paths) pendingInboundPaths.add(rel)
|
|
367
|
+
if (inboundBatchRunning) return
|
|
368
|
+
inboundBatchRunning = true
|
|
369
|
+
void (async () => {
|
|
370
|
+
try {
|
|
371
|
+
while (pendingInboundPaths.size) {
|
|
372
|
+
const raw = [...pendingInboundPaths]
|
|
373
|
+
pendingInboundPaths = new Set<string>()
|
|
374
|
+
await handleBatch(raw)
|
|
375
|
+
}
|
|
376
|
+
} finally {
|
|
377
|
+
inboundBatchRunning = false
|
|
378
|
+
if (pendingInboundPaths.size) enqueueInboundBatch([])
|
|
379
|
+
}
|
|
380
|
+
})()
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let stopWatching = () => {}
|
|
384
|
+
|
|
385
|
+
/** Subscribe to `port.changes`. `active` gates BOTH callbacks so a
|
|
386
|
+
* late/duplicate event delivered after `onDead` (or after a fresh
|
|
387
|
+
* `start()` superseded this subscription) is a guaranteed no-op, not just
|
|
388
|
+
* best-effort. */
|
|
389
|
+
function start(): void {
|
|
390
|
+
let active = true
|
|
391
|
+
const dispose = port.changes(
|
|
392
|
+
(paths) => {
|
|
393
|
+
if (!active) return
|
|
394
|
+
enqueueInboundBatch(paths)
|
|
395
|
+
},
|
|
396
|
+
() => {
|
|
397
|
+
if (!active) return
|
|
398
|
+
active = false
|
|
399
|
+
console.warn('[fs-core/sync] watcher died — reverting to open-time mirror only')
|
|
400
|
+
dispose()
|
|
401
|
+
},
|
|
402
|
+
)
|
|
403
|
+
stopWatching = () => {
|
|
404
|
+
active = false
|
|
405
|
+
dispose()
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function stop(): void {
|
|
410
|
+
stopWatching()
|
|
411
|
+
stopWatching = () => {}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Outbound accounting for a human save: registers `rel` in `pendingWrite`
|
|
416
|
+
* for the duration of the ledger compare-then-write (see module doc),
|
|
417
|
+
* skips the ledger write when the saved text already matches the ledger's
|
|
418
|
+
* record, and runs inside the same `ledgerTurn` FIFO as inbound batches so
|
|
419
|
+
* the two can never interleave. Errors propagate to the caller — a host's
|
|
420
|
+
* onSave wrapper decides whether a ledger-write failure should be
|
|
421
|
+
* swallowed (best-effort accounting must never unwind a save that already
|
|
422
|
+
* landed at the truth source).
|
|
423
|
+
*
|
|
424
|
+
* `content` may be the decoded text (string, unchanged legacy path) or the
|
|
425
|
+
* raw saved bytes (`Uint8Array`) — passing raw bytes lets this function
|
|
426
|
+
* sniff for binary (see module doc's "Binary layering" section) before
|
|
427
|
+
* deciding whether to decode. A binary save skips the ledger write
|
|
428
|
+
* entirely and only updates `binaryIndex`.
|
|
429
|
+
*/
|
|
430
|
+
async function onHumanSave(rel: string, content: string | Uint8Array): Promise<void> {
|
|
431
|
+
pendingWrite.add(rel)
|
|
432
|
+
try {
|
|
433
|
+
if (content instanceof Uint8Array && looksBinary(content)) {
|
|
434
|
+
await enqueueLedgerTurn(async () => {
|
|
435
|
+
binaryIndex.set(rel, { size: content.length, sha256: await sha256hex(content) })
|
|
436
|
+
})
|
|
437
|
+
return
|
|
438
|
+
}
|
|
439
|
+
const text = typeof content === 'string' ? content : decoder.decode(content)
|
|
440
|
+
await enqueueLedgerTurn(async () => {
|
|
441
|
+
const unchanged = await client
|
|
442
|
+
.read(rel)
|
|
443
|
+
.then((r) => r.content === text)
|
|
444
|
+
.catch(() => false)
|
|
445
|
+
if (!unchanged) await client.write(rel, text, { actor: 'human' })
|
|
446
|
+
})
|
|
447
|
+
} finally {
|
|
448
|
+
pendingWrite.delete(rel)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
populateLedger: seedFromDisk,
|
|
454
|
+
onHumanSave,
|
|
455
|
+
consumeInboundEcho,
|
|
456
|
+
start,
|
|
457
|
+
stop,
|
|
458
|
+
}
|
|
459
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TruthPort: the external-truth-source adapter the sync engine (see
|
|
3
|
+
* sync-engine.ts) talks to. Concrete adapters live with their host: devtools'
|
|
4
|
+
* is `/__fs` HTTP bridge reads/writes + an SSE change stream (workbench's
|
|
5
|
+
* wal-audit.ts); a future web local-directory adapter would wrap the File
|
|
6
|
+
* System Access API with poll-based change detection instead.
|
|
7
|
+
*
|
|
8
|
+
* Error contract for `read`/`delete`: a rejection MUST be classifiable as
|
|
9
|
+
* either "not-found" (the path does not exist — safe to treat as a deletion)
|
|
10
|
+
* or "unavailable" (anything else: a transient I/O failure, a permission
|
|
11
|
+
* loss, a dead connection, ...). A rejection counts as not-found when either:
|
|
12
|
+
* - `error.code === 'not-found'`, or
|
|
13
|
+
* - `error.status === 404`
|
|
14
|
+
* every other rejection shape is unavailable. The engine never infers a
|
|
15
|
+
* deletion from an "unavailable" rejection.
|
|
16
|
+
*/
|
|
17
|
+
export interface TruthPortCapabilities {
|
|
18
|
+
/**
|
|
19
|
+
* How this port delivers change notifications: 'push' (an event stream —
|
|
20
|
+
* devtools' SSE `/__fs/watch`) or 'poll' (no push channel; a future FSA
|
|
21
|
+
* local-directory adapter would need its own polling loop outside the
|
|
22
|
+
* engine). The two channels do not commit to equivalent latency/ordering
|
|
23
|
+
* guarantees.
|
|
24
|
+
*/
|
|
25
|
+
watch: 'push' | 'poll'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TruthPort {
|
|
29
|
+
readonly capabilities: TruthPortCapabilities
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read a path's current bytes. Rejects per the error contract above: a
|
|
33
|
+
* not-found rejection means the path does not exist; any other rejection
|
|
34
|
+
* is unavailable (transient) and must NOT be read as a deletion.
|
|
35
|
+
*/
|
|
36
|
+
read(rel: string): Promise<Uint8Array>
|
|
37
|
+
|
|
38
|
+
/** Write bytes to a path (create or overwrite). */
|
|
39
|
+
write(rel: string, bytes: Uint8Array): Promise<void>
|
|
40
|
+
|
|
41
|
+
/** Delete a path. Same not-found/unavailable error contract as {@link read}. */
|
|
42
|
+
delete(rel: string): Promise<void>
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Walk every file under the port's root, calling `onFile` once per file
|
|
46
|
+
* (awaited before moving on to the next). Directories are not reported
|
|
47
|
+
* individually — only the files within them.
|
|
48
|
+
*/
|
|
49
|
+
walk(onFile: (rel: string, bytes: Uint8Array) => Promise<void>): Promise<void>
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Subscribe to inbound change batches. `onBatch` receives a batch of
|
|
53
|
+
* changed relative paths; `onDead` fires at most once when the change
|
|
54
|
+
* channel is permanently gone (the host should treat this as a downgrade
|
|
55
|
+
* to no live sync, not a transient hiccup — see sync-engine.ts's `stop`).
|
|
56
|
+
* Returns a `dispose` that tears down the subscription.
|
|
57
|
+
*
|
|
58
|
+
* Compensation for a lossy/coalescing underlying watcher (e.g. macOS
|
|
59
|
+
* FSEvents merging a write burst into ancestor-directory events, or
|
|
60
|
+
* dropping some of a burst entirely) is THIS PORT'S responsibility, not
|
|
61
|
+
* the engine's: the adapter is the one that knows its own watcher's
|
|
62
|
+
* failure modes and how to recover the true changed set (typically:
|
|
63
|
+
* treat a reported path as a scope hint, stat-compare the disk under
|
|
64
|
+
* that scope against a session index, and report every path whose stat
|
|
65
|
+
* actually moved, plus ledger paths in scope that vanished from disk).
|
|
66
|
+
* `handleBatch` in sync-engine.ts takes `onBatch`'s paths at face value
|
|
67
|
+
* and does no further expansion — see devtools' adapter
|
|
68
|
+
* (dimina-kit workbench's wal-audit.ts + wal-audit-watch-expand.ts) for a
|
|
69
|
+
* concrete implementation of this contract.
|
|
70
|
+
*/
|
|
71
|
+
changes(onBatch: (paths: string[]) => void, onDead: () => void): () => void
|
|
72
|
+
}
|