@dimina-kit/fs-core 0.2.0-dev.20260710085051 → 0.3.0-dev.6-dev.20260711133419

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 (51) hide show
  1. package/README.md +205 -18
  2. package/dist/client.js +23 -11
  3. package/dist/disk-mirror.js +2 -2
  4. package/dist/fs-core.worker.js +101 -21
  5. package/dist/sync/binary-sidecar.js +147 -0
  6. package/dist/sync/sync-engine.js +119 -169
  7. package/dist/sync/watch-expander.js +202 -0
  8. package/dist/worker-files.cjs +32 -0
  9. package/dist/worker-files.js +35 -0
  10. package/dist/worker-lib/.tsbuildinfo +1 -0
  11. package/dist/worker-lib/engine-shared.d.ts +79 -0
  12. package/dist/worker-lib/engine-shared.d.ts.map +1 -0
  13. package/dist/worker-lib/engine-shared.js +6 -3
  14. package/dist/worker-lib/paths.d.ts +4 -0
  15. package/dist/worker-lib/paths.d.ts.map +1 -0
  16. package/dist/worker-lib/protocol.d.ts +119 -0
  17. package/dist/worker-lib/protocol.d.ts.map +1 -0
  18. package/dist/worker-lib/protocol.js +73 -0
  19. package/dist/worker-lib/rpc-types.d.ts +68 -0
  20. package/dist/worker-lib/rpc-types.d.ts.map +1 -0
  21. package/dist/worker-lib/wal-codec.d.ts +58 -0
  22. package/dist/worker-lib/wal-codec.d.ts.map +1 -0
  23. package/dist/worker-lib/wal-codec.js +8 -5
  24. package/dist/zip.js +1 -1
  25. package/package.json +25 -2
  26. package/src/__checks__/types-smoke.ts +61 -0
  27. package/src/agent-tools.ts +3 -3
  28. package/src/client-retry.test.ts +76 -0
  29. package/src/client.ts +112 -45
  30. package/src/disk-mirror.ts +2 -2
  31. package/src/fs-core-handover.test.ts +215 -0
  32. package/src/fs-core-opid-replay.test.ts +158 -0
  33. package/src/fs-core-recovery-lock.test.ts +225 -0
  34. package/src/fs-core-recovery.ts +115 -13
  35. package/src/fs-core-write-ops.ts +14 -11
  36. package/src/fs-core.worker.ts +26 -11
  37. package/src/worker-files.test.ts +39 -0
  38. package/src/worker-files.ts +43 -0
  39. package/src/worker-lib/engine-shared.ts +19 -5
  40. package/src/worker-lib/protocol.test.ts +37 -0
  41. package/src/worker-lib/protocol.ts +184 -0
  42. package/src/worker-lib/wal-codec.ts +8 -5
  43. package/src/zip.ts +1 -1
  44. package/sync/binary-sidecar.test.ts +150 -0
  45. package/sync/binary-sidecar.ts +187 -0
  46. package/sync/sync-engine-degraded.test.ts +309 -0
  47. package/sync/sync-engine.test.ts +20 -91
  48. package/sync/sync-engine.ts +134 -181
  49. package/sync/truth-port.ts +3 -3
  50. package/sync/watch-expander.test.ts +96 -0
  51. package/sync/watch-expander.ts +236 -0
@@ -1,7 +1,7 @@
1
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
2
+ * Sync arbitration engine — the memfs<->disk synchronization core shared by
3
+ * every host that pairs an fs-core ledger with an external truth source.
4
+ * A host wires a `client` (the
5
5
  * fs-core ledger: write/rm/read/ls) and a `TruthPort` (its external truth
6
6
  * source — devtools' `/__fs` bridge + SSE watch, or a future FSA
7
7
  * local-directory adapter) and gets back an engine that keeps the ledger,
@@ -10,54 +10,43 @@
10
10
  * host's own audit-turn surface talks to `client` directly for that (see
11
11
  * wal-audit.ts).
12
12
  *
13
- * Echo judgement (both directions):
13
+ * Echo judgement (both directions, content comparison against the ledger's
14
+ * own record):
14
15
  * - Outbound (`onHumanSave`): before recording, compare the saved text
15
16
  * against the ledger's current record for that path — identical content
16
17
  * 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.
18
+ * write. The onHumanSave accounting step and inbound batches run through
19
+ * the same `ledgerTurn` FIFO, so an inbound notification for a path whose
20
+ * save is still in flight always observes the completed ledger write and
21
+ * absorbs itself via that same comparison.
22
+ * - Inbound (`changes` batches, via `handleInboundPath`): truth-source
23
+ * bytes equal to the ledger's record are the echo of our own last write —
24
+ * dropped with no ledger write and no editor refresh.
25
+ * This engine serves 'push' ports only (the port notifies; the host's
26
+ * outbound writes go through `onHumanSave`). A future 'poll' host whose
27
+ * outbound scan runs outside this module must own its own one-shot echo
28
+ * suppression see the README's「两套磁盘机制的划界」section for the
29
+ * invariants such an adapter has to satisfy.
32
30
  *
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.
31
+ * Degradation is loud (`onDegraded`): a dead watcher and a path that failed
32
+ * to sync (truth-source read failure, or a ledger write/rm failure while
33
+ * reconciling) are surfaced to the host through {@link SyncDegradation} in
34
+ * addition to the console warning silently skipping them would let the
35
+ * ledger drift with no operator-visible signal.
48
36
  *
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.
37
+ * Binary layering: classification (NUL sniff), the `rel -> {size, sha256}`
38
+ * index, and echo judgement (size+hash equality instead of a ledger content
39
+ * compare) are all owned by ./binary-sidecar.tssee that module's doc; this
40
+ * engine holds an INDEX-ONLY sidecar. A binary file never reaches the fs-core
41
+ * ledger (`client.write`/`read`). What/why this is narrower than the text
42
+ * path: binary changes get NO WAL audit and NO rollback (the audit turn
43
+ * surface — `diff`/`restore` — is a string-content contract; the engine does
44
+ * not support an agent writing binary inside a turn), and the sidecar is
45
+ * session-scoped — cleared and rebuilt from scratch on every
46
+ * `populateLedger()`, exactly like the ledger's own text reconciliation.
59
47
  */
60
48
 
49
+ import { createBinarySidecar, looksBinary } from './binary-sidecar.js'
61
50
  import type { TruthPort } from './truth-port.js'
62
51
 
63
52
  export type { TruthPort, TruthPortCapabilities } from './truth-port.js'
@@ -77,6 +66,20 @@ export interface SyncClientLike {
77
66
  ls(): Promise<{ paths: string[] }>
78
67
  }
79
68
 
69
+ /**
70
+ * A host-visible sync degradation (see the module doc's "Degradation is
71
+ * loud" paragraph):
72
+ * - `watcher-dead`: the port's change subscription died — from now on the
73
+ * ledger only reflects the open-time mirror plus already-processed
74
+ * batches.
75
+ * - `path-sync-failed`: one path failed to reconcile and was skipped;
76
+ * `stage` says where — `truth-read` (the truth source's read rejected
77
+ * transiently) or `reconcile` (the ledger write/rm itself failed).
78
+ */
79
+ export type SyncDegradation =
80
+ | { kind: 'watcher-dead' }
81
+ | { kind: 'path-sync-failed'; rel: string; stage: 'truth-read' | 'reconcile'; error: unknown }
82
+
80
83
  export interface SyncEngineOptions {
81
84
  /**
82
85
  * Host seam: push an inbound change into the live editor buffer.
@@ -84,6 +87,12 @@ export interface SyncEngineOptions {
84
87
  * records the change but the editor buffer is left untouched.
85
88
  */
86
89
  applyToEditor?: (rel: string, bytes: Uint8Array | null) => Promise<void>
90
+ /**
91
+ * Host seam: surface a {@link SyncDegradation}. Called in addition to the
92
+ * console warning, never instead of it; a throwing callback is the host's
93
+ * own bug and never breaks the engine.
94
+ */
95
+ onDegraded?: (degradation: SyncDegradation) => void
87
96
  }
88
97
 
89
98
  export interface SyncEngine {
@@ -105,47 +114,12 @@ export interface SyncEngine {
105
114
  * route it to the session-scoped `binaryIndex` instead of the ledger.
106
115
  */
107
116
  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
117
  /** Subscribe to `port.changes` and start reconciling inbound batches. */
121
118
  start(): void
122
119
  /** Tear down the `port.changes` subscription. Idempotent. */
123
120
  stop(): void
124
121
  }
125
122
 
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
123
  /**
150
124
  * True when `error` denotes "path does not exist" per the TruthPort error
151
125
  * contract (see truth-port.ts): `error.code === 'not-found'` or
@@ -157,11 +131,6 @@ function isNotFoundError(error: unknown): boolean {
157
131
  return Boolean(e) && (e!.code === 'not-found' || e!.status === 404)
158
132
  }
159
133
 
160
- type InboundApplied =
161
- | { kind: 'text'; text: string }
162
- | { kind: 'binary'; bytes: Uint8Array }
163
- | { kind: 'delete' }
164
-
165
134
  export function createSyncEngine(
166
135
  client: SyncClientLike,
167
136
  port: TruthPort,
@@ -170,30 +139,22 @@ export function createSyncEngine(
170
139
  const decoder = new TextDecoder()
171
140
  const applyToEditor = opts.applyToEditor
172
141
 
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>()
142
+ /** Surface a degradation to the host — a throwing callback is the host's
143
+ * own bug and must not break the reconciliation that reported it. */
144
+ function degrade(degradation: SyncDegradation): void {
145
+ try {
146
+ opts.onDegraded?.(degradation)
147
+ } catch (e) {
148
+ console.warn('[fs-core/sync] onDegraded callback threw', e)
149
+ }
150
+ }
180
151
 
181
152
  /**
182
153
  * 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()`.
154
+ * "Binary layering" section. Index-only (this engine never needs the bytes
155
+ * back); session-scoped: rebuilt from scratch on every `populateLedger()`.
195
156
  */
196
- const inboundApplied = new Map<string, InboundApplied>()
157
+ const binaryIndex = createBinarySidecar()
197
158
 
198
159
  /**
199
160
  * FIFO queue serializing every compare-then-record against the ledger
@@ -217,13 +178,12 @@ export function createSyncEngine(
217
178
  * same persisted ledger identity) and are removed so the ledger ends up
218
179
  * exactly matching the walked tree. */
219
180
  async function seedFromDisk(): Promise<void> {
220
- binaryIndex.clear()
221
- inboundApplied.clear()
181
+ await binaryIndex.clear()
222
182
  const seen = new Set<string>()
223
183
  await port.walk(async (rel, bytes) => {
224
184
  seen.add(rel)
225
185
  if (looksBinary(bytes)) {
226
- binaryIndex.set(rel, { size: bytes.length, sha256: await sha256hex(bytes) })
186
+ await binaryIndex.put(rel, bytes)
227
187
  return // binary never enters the ledger
228
188
  }
229
189
  await client.write(rel, decoder.decode(bytes), { actor: 'human' })
@@ -238,27 +198,23 @@ export function createSyncEngine(
238
198
  }
239
199
 
240
200
  /**
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
201
+ * Process one inbound path via content comparison: identical content is
202
+ * the echo of our own last write and is dropped with no ledger write and
203
+ * no `applyToEditor` call. A `port.read` rejection classified as not-found
245
204
  * (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.
205
+ * ("unavailable") is transient and skips the path entirely (surfaced as a
206
+ * `truth-read` degradation) — inferring a deletion from it would rm the
207
+ * ledger record and close the file in the editor while it still exists at
208
+ * the truth source.
249
209
  */
250
210
  async function handleInboundPath(rel: string): Promise<void> {
251
- if (pendingWrite.has(rel)) {
252
- pendingWrite.delete(rel)
253
- return
254
- }
255
-
256
211
  let bytes: Uint8Array | null
257
212
  try {
258
213
  bytes = await port.read(rel)
259
214
  } catch (e) {
260
215
  if (!isNotFoundError(e)) {
261
216
  console.warn('[fs-core/sync] transient port read failure, skipping', rel, e)
217
+ degrade({ kind: 'path-sync-failed', rel, stage: 'truth-read', error: e })
262
218
  return
263
219
  }
264
220
  bytes = null
@@ -269,30 +225,35 @@ export function createSyncEngine(
269
225
  // ledger (it never took the client.write path), so removal here is
270
226
  // index-only — no client.rm.
271
227
  if (binaryIndex.has(rel)) {
272
- binaryIndex.delete(rel)
273
- inboundApplied.set(rel, { kind: 'delete' })
228
+ await binaryIndex.remove(rel)
274
229
  await applyToEditor?.(rel, null)
275
230
  return
276
231
  }
277
232
  let ledgerContent: string | undefined
278
233
  try {
279
234
  ledgerContent = (await client.read(rel)).content
280
- } catch {
235
+ } catch (e) {
236
+ if (!isNotFoundError(e)) {
237
+ // A transient ledger read failure is NOT "already absent" — rm'ing
238
+ // (or silently skipping) on unknown ledger state would either drop
239
+ // a record we could not confirm or leave it stale with no signal.
240
+ // Skip the path and surface it; the next change event retries.
241
+ console.warn('[fs-core/sync] ledger read failed while confirming a deletion, skipping', rel, e)
242
+ degrade({ kind: 'path-sync-failed', rel, stage: 'reconcile', error: e })
243
+ return
244
+ }
281
245
  ledgerContent = undefined
282
246
  }
283
247
  if (ledgerContent === undefined) return // already absent from both sides
284
248
  await client.rm(rel, { actor: 'human' })
285
- inboundApplied.set(rel, { kind: 'delete' })
286
249
  await applyToEditor?.(rel, null)
287
250
  return
288
251
  }
289
252
 
290
253
  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 })
254
+ // Echo judgement is the sidecar's put(): `false` = same size+sha256
255
+ // already indexed, i.e. the echo of our own last write.
256
+ if (!(await binaryIndex.put(rel, bytes))) return
296
257
  await applyToEditor?.(rel, bytes)
297
258
  return
298
259
  }
@@ -312,39 +273,16 @@ export function createSyncEngine(
312
273
  if (ledgerContent !== undefined && text === ledgerContent) return // echo of our own write
313
274
 
314
275
  await client.write(rel, text, { actor: 'human' })
315
- inboundApplied.set(rel, { kind: 'text', text })
316
276
  await applyToEditor?.(rel, bytes)
317
277
  }
318
278
 
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
279
  /**
342
280
  * A batch is trusted as-is: the port adapter (see truth-port.ts's
343
281
  * `changes` doc) is responsible for turning a watcher's coalesced/lossy
344
282
  * 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
283
+ * this engine — devtools' adapter (dimina-kit workbench's wal-audit.ts +
284
+ * this package's ./watch-expander.ts) does that via a stat-level disk
285
+ * compare against a session index, so paths arriving here have already earned
348
286
  * their re-examination and no further expansion happens at this layer.
349
287
  */
350
288
  async function handleBatch(paths: string[]): Promise<void> {
@@ -356,6 +294,7 @@ export function createSyncEngine(
356
294
  await enqueueLedgerTurn(() => handleInboundPath(rel))
357
295
  } catch (e) {
358
296
  console.warn('[fs-core/sync] disk sync failed for', rel, e)
297
+ degrade({ kind: 'path-sync-failed', rel, stage: 'reconcile', error: e })
359
298
  }
360
299
  }
361
300
  }
@@ -385,10 +324,27 @@ export function createSyncEngine(
385
324
  /** Subscribe to `port.changes`. `active` gates BOTH callbacks so a
386
325
  * late/duplicate event delivered after `onDead` (or after a fresh
387
326
  * `start()` superseded this subscription) is a guaranteed no-op, not just
388
- * best-effort. */
327
+ * best-effort. The subscription is torn down through `disposeOnce`: the
328
+ * TruthPort contract does not require an idempotent dispose, and a host's
329
+ * `onDegraded` callback may synchronously call `engine.stop()` — without
330
+ * the guard that re-enters the same dispose a second time. */
389
331
  function start(): void {
390
332
  let active = true
391
- const dispose = port.changes(
333
+ let disposed = false
334
+ // "dispose requested" and "dispose implementation available" are split:
335
+ // a port may fire onDead synchronously DURING the changes() call, before
336
+ // its dispose function even exists — the request is recorded via
337
+ // `disposed` and the implementation is invoked right after the
338
+ // subscription call returns it (see below). Declared-then-assigned (not
339
+ // `const`) precisely so that a during-subscription disposeOnce() reads
340
+ // `undefined` instead of hitting a temporal dead zone.
341
+ let disposeImpl: (() => void) | undefined = undefined
342
+ function disposeOnce(): void {
343
+ if (disposed) return
344
+ disposed = true
345
+ disposeImpl?.()
346
+ }
347
+ disposeImpl = port.changes(
392
348
  (paths) => {
393
349
  if (!active) return
394
350
  enqueueInboundBatch(paths)
@@ -397,12 +353,16 @@ export function createSyncEngine(
397
353
  if (!active) return
398
354
  active = false
399
355
  console.warn('[fs-core/sync] watcher died — reverting to open-time mirror only')
400
- dispose()
356
+ disposeOnce()
357
+ degrade({ kind: 'watcher-dead' })
401
358
  },
402
359
  )
360
+ // Dispose was requested during subscription (synchronous onDead): the
361
+ // implementation exists now — honor the request exactly once.
362
+ if (disposed) disposeImpl()
403
363
  stopWatching = () => {
404
364
  active = false
405
- dispose()
365
+ disposeOnce()
406
366
  }
407
367
  }
408
368
 
@@ -412,12 +372,11 @@ export function createSyncEngine(
412
372
  }
413
373
 
414
374
  /**
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
375
+ * Outbound accounting for a human save: skips the ledger write when the
376
+ * saved text already matches the ledger's record, and runs inside the same
377
+ * `ledgerTurn` FIFO as inbound batches so the two can never interleave
378
+ * (see module doc's "Echo judgement"). Errors propagate to the caller a
379
+ * host's onSave wrapper decides whether a ledger-write failure should be
421
380
  * swallowed (best-effort accounting must never unwind a save that already
422
381
  * landed at the truth source).
423
382
  *
@@ -428,31 +387,25 @@ export function createSyncEngine(
428
387
  * entirely and only updates `binaryIndex`.
429
388
  */
430
389
  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)
390
+ if (content instanceof Uint8Array && looksBinary(content)) {
440
391
  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' })
392
+ await binaryIndex.put(rel, content)
446
393
  })
447
- } finally {
448
- pendingWrite.delete(rel)
394
+ return
449
395
  }
396
+ const text = typeof content === 'string' ? content : decoder.decode(content)
397
+ await enqueueLedgerTurn(async () => {
398
+ const unchanged = await client
399
+ .read(rel)
400
+ .then((r) => r.content === text)
401
+ .catch(() => false)
402
+ if (!unchanged) await client.write(rel, text, { actor: 'human' })
403
+ })
450
404
  }
451
405
 
452
406
  return {
453
407
  populateLedger: seedFromDisk,
454
408
  onHumanSave,
455
- consumeInboundEcho,
456
409
  start,
457
410
  stop,
458
411
  }
@@ -64,9 +64,9 @@ export interface TruthPort {
64
64
  * that scope against a session index, and report every path whose stat
65
65
  * actually moved, plus ledger paths in scope that vanished from disk).
66
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.
67
+ * and does no further expansion — ./watch-expander.ts is the optional
68
+ * helper implementing exactly that recovery; see devtools' adapter
69
+ * (dimina-kit workbench's wal-audit.ts) for a concrete assembly.
70
70
  */
71
71
  changes(onBatch: (paths: string[]) => void, onDead: () => void): () => void
72
72
  }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Unit tests for the watch-batch expander's stat-diff contract, against a
3
+ * fake stat-capable readdir. The full black-box behavior (through a real
4
+ * TruthPort assembly) stays covered by dimina-kit workbench's
5
+ * wal-audit-disk-stat-expansion.test.ts — these pin the module's own
6
+ * algorithmic promises at the fs-core boundary.
7
+ */
8
+ import { describe, expect, it } from 'vitest'
9
+ import { createWatchExpander } from './watch-expander.js'
10
+ import type { WatchExpanderEntry } from './watch-expander.js'
11
+
12
+ /** In-memory tree: rel -> {size, mtimeMs}. Directories are implied by their
13
+ * children's paths, same as a real filesystem listing would show them. */
14
+ function makeDisk(files: Record<string, { size: number; mtimeMs: number }>) {
15
+ const readdir = async (rel: string): Promise<WatchExpanderEntry[]> => {
16
+ const prefix = rel === '.' || rel === '' ? '' : `${rel}/`
17
+ const names = new Map<string, WatchExpanderEntry>()
18
+ let matchedScope = prefix === ''
19
+ for (const [p, stat] of Object.entries(files)) {
20
+ if (!p.startsWith(prefix)) continue
21
+ matchedScope = true
22
+ const rest = p.slice(prefix.length)
23
+ const slash = rest.indexOf('/')
24
+ if (slash === -1) names.set(rest, [rest, 1, stat.size, stat.mtimeMs])
25
+ else names.set(rest.slice(0, slash), [rest.slice(0, slash), 2])
26
+ }
27
+ // A path that is neither a file nor an ancestor of one does not exist —
28
+ // and listing a FILE path is an error too, like a real readdir(file).
29
+ if (!matchedScope || rel in files) throw new Error(`ENOTDIR/ENOENT: ${rel}`)
30
+ return [...names.values()]
31
+ }
32
+ return { files, readdir }
33
+ }
34
+
35
+ describe('createWatchExpander', () => {
36
+ it('reports only the stat-changed file for a coalesced directory event (after warm-up)', async () => {
37
+ const disk = makeDisk({
38
+ 'src/a.txt': { size: 3, mtimeMs: 100 },
39
+ 'src/b.txt': { size: 3, mtimeMs: 100 },
40
+ 'src/c.txt': { size: 3, mtimeMs: 100 },
41
+ })
42
+ const ex = createWatchExpander(disk.readdir)
43
+ await ex.warmFromDisk()
44
+ disk.files['src/b.txt'] = { size: 4, mtimeMs: 200 }
45
+ const out = await ex.expandWatchBatch(['src'], ['src/a.txt', 'src/b.txt', 'src/c.txt'])
46
+ expect(out).toEqual(['src/b.txt'])
47
+ })
48
+
49
+ it('recovers a coalesced deletion: ledger paths missing from the listed scope are reported', async () => {
50
+ const disk = makeDisk({ 'src/a.txt': { size: 3, mtimeMs: 100 } })
51
+ const ex = createWatchExpander(disk.readdir)
52
+ await ex.warmFromDisk()
53
+ // rm -rf took b.txt and c.txt; the watcher only named the directory.
54
+ const out = await ex.expandWatchBatch(['src'], ['src/a.txt', 'src/b.txt', 'src/c.txt'])
55
+ expect(out.sort()).toEqual(['src/b.txt', 'src/c.txt'])
56
+ })
57
+
58
+ it("expands the '.' overflow rescan against the whole tree + whole ledger", async () => {
59
+ const disk = makeDisk({ 'kept.txt': { size: 1, mtimeMs: 1 }, 'new.txt': { size: 1, mtimeMs: 1 } })
60
+ const ex = createWatchExpander(disk.readdir)
61
+ // warm only knows kept.txt
62
+ delete disk.files['new.txt']
63
+ await ex.warmFromDisk()
64
+ disk.files['new.txt'] = { size: 1, mtimeMs: 1 }
65
+ const out = await ex.expandWatchBatch(['.'], ['kept.txt', 'gone.txt'])
66
+ expect(out.sort()).toEqual(['gone.txt', 'new.txt'])
67
+ })
68
+
69
+ it('always reports a point-named plain file (content can change without a stat move)', async () => {
70
+ const disk = makeDisk({ 'a.txt': { size: 3, mtimeMs: 100 } })
71
+ const ex = createWatchExpander(disk.readdir)
72
+ await ex.warmFromDisk()
73
+ // stat unchanged — the file itself must still be reported.
74
+ const out = await ex.expandWatchBatch(['a.txt'], ['a.txt'])
75
+ expect(out).toEqual(['a.txt'])
76
+ })
77
+
78
+ it('excludes a point-named live directory UNLESS the ledger still records that exact path', async () => {
79
+ const disk = makeDisk({ 'src/a.txt': { size: 3, mtimeMs: 100 } })
80
+ const ex = createWatchExpander(disk.readdir)
81
+ await ex.warmFromDisk()
82
+ expect(await ex.expandWatchBatch(['src'], ['src/a.txt'])).toEqual([])
83
+ // Same-named file→directory replacement: a stale ledger FILE record at
84
+ // 'src' must be reported so the engine's read can retire it.
85
+ expect(await ex.expandWatchBatch(['src'], ['src', 'src/a.txt'])).toEqual(['src'])
86
+ })
87
+
88
+ it('resetIndex() forgets the session index: every disk file reports again', async () => {
89
+ const disk = makeDisk({ 'src/a.txt': { size: 3, mtimeMs: 100 } })
90
+ const ex = createWatchExpander(disk.readdir)
91
+ await ex.warmFromDisk()
92
+ expect(await ex.expandWatchBatch(['src'], ['src/a.txt'])).toEqual([])
93
+ ex.resetIndex()
94
+ expect(await ex.expandWatchBatch(['src'], ['src/a.txt'])).toEqual(['src/a.txt'])
95
+ })
96
+ })