@dimina-kit/fs-core 0.2.0-dev.20260711062001 → 0.3.0-dev.6-dev.20260711140249

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
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Binary side-car — the authoritative owner of "binary files live OUTSIDE the
3
+ * fs-core string ledger". The ledger's write/read surface is a string-content
4
+ * contract (WAL audit, diff/restore all reason over text), so every host that
5
+ * meets a binary file needs the same three things this module owns exactly
6
+ * once:
7
+ *
8
+ * - classification: {@link looksBinary} (a NUL byte in the first 8192 bytes);
9
+ * - a unified index: `rel -> { size, sha256 }`, maintained for every entry
10
+ * regardless of whether bytes are retained;
11
+ * - echo judgement: {@link BinarySidecar.put} compares size+sha256 against
12
+ * the prior entry and reports `false` (unchanged) for a re-arrival of the
13
+ * same bytes — the exact judgement the sync engine uses to absorb a binary
14
+ * file's own write echo.
15
+ *
16
+ * Two host shapes, one abstraction:
17
+ * - dimina-kit's sync engine (sync-engine.ts) keeps an INDEX-ONLY sidecar —
18
+ * it never needs the bytes back, only membership + echo judgement;
19
+ * - a host whose ledger snapshot must be re-joined with the binary set
20
+ * (qdmp-web-workbench feeding compile/export/disk-mirror) constructs it
21
+ * with `retainBytes: true` and uses {@link BinarySidecar.overlay} as the
22
+ * ONE place that merges "ledger text + binary side-car" (ledger text wins
23
+ * on a path present in both — the ledger is the authority for anything it
24
+ * holds).
25
+ *
26
+ * Change events (`onChange`) fire on every effective mutation (`put` that
27
+ * actually changed, `remove` of an existing entry) — `clear()`/`reset()` are
28
+ * session reseeds (project switch / ledger repopulate) and deliberately do
29
+ * NOT emit per-entry removals; a subscriber that survives a reseed should
30
+ * re-read the sidecar wholesale, same as it re-reads the ledger.
31
+ */
32
+ import { sha256hex } from '#worker-lib/wal-codec.js';
33
+ const BINARY_SNIFF_BYTES = 8192;
34
+ /** True when the first {@link BINARY_SNIFF_BYTES} of `bytes` contain a NUL
35
+ * byte — the classification gate for the whole binary layering. */
36
+ export function looksBinary(bytes) {
37
+ const len = Math.min(bytes.length, BINARY_SNIFF_BYTES);
38
+ for (let i = 0; i < len; i++) {
39
+ if (bytes[i] === 0)
40
+ return true;
41
+ }
42
+ return false;
43
+ }
44
+ export function createBinarySidecar(opts = {}) {
45
+ const retainBytes = opts.retainBytes === true;
46
+ // `let` (not const): reset() swaps in fully-built replacement maps in one
47
+ // synchronous step — see its body.
48
+ let index = new Map();
49
+ let store = new Map();
50
+ const changeCbs = new Set();
51
+ /**
52
+ * FIFO serializing every MUTATION (put/remove/clear/reset) in caller order.
53
+ * Without it, a put whose (async) hashing straddles an in-flight reset()
54
+ * lands on maps the reset is about to swap away — the mutation silently
55
+ * vanishes — and two overlapping resets resolve as "last swapper wins"
56
+ * instead of "last caller wins". Serialized, a mutation issued after
57
+ * reset() runs after it and lands in the NEW session; one issued before is
58
+ * superseded by it — nothing races across a session boundary. READS
59
+ * (has/entry/bytes/keys/overlay/toRecord) stay synchronous against the
60
+ * currently visible maps: reset's one-step swap keeps them consistent.
61
+ * The chain swallows step rejections so one failed mutation cannot wedge
62
+ * the queue.
63
+ */
64
+ let mutationTurn = Promise.resolve();
65
+ function enqueueMutation(fn) {
66
+ const next = mutationTurn.then(fn, fn);
67
+ mutationTurn = next.catch(() => { });
68
+ return next;
69
+ }
70
+ function emit(rel, bytes) {
71
+ for (const cb of changeCbs) {
72
+ try {
73
+ cb(rel, bytes);
74
+ }
75
+ catch {
76
+ // A subscriber's failure is its own — it must not break the mutation
77
+ // that already happened, nor starve later subscribers.
78
+ }
79
+ }
80
+ }
81
+ return {
82
+ put(rel, bytes) {
83
+ return enqueueMutation(async () => {
84
+ const sha256 = await sha256hex(bytes);
85
+ const prior = index.get(rel);
86
+ if (prior && prior.size === bytes.length && prior.sha256 === sha256)
87
+ return false;
88
+ index.set(rel, { size: bytes.length, sha256 });
89
+ if (retainBytes)
90
+ store.set(rel, bytes);
91
+ emit(rel, bytes);
92
+ return true;
93
+ });
94
+ },
95
+ remove(rel) {
96
+ return enqueueMutation(() => {
97
+ const existed = index.delete(rel);
98
+ store.delete(rel);
99
+ if (existed)
100
+ emit(rel, null);
101
+ return existed;
102
+ });
103
+ },
104
+ has: (rel) => index.has(rel),
105
+ entry: (rel) => index.get(rel),
106
+ bytes: (rel) => store.get(rel),
107
+ keys: () => [...index.keys()],
108
+ get size() {
109
+ return index.size;
110
+ },
111
+ clear() {
112
+ return enqueueMutation(() => {
113
+ index.clear();
114
+ store.clear();
115
+ });
116
+ },
117
+ reset(files) {
118
+ return enqueueMutation(async () => {
119
+ // Atomic to readers: hashing runs against LOCAL maps and the visible
120
+ // state is swapped in one synchronous step at the end — a concurrent
121
+ // overlay()/toRecord()/entry() sees either the old set or the new
122
+ // set, never an empty or half-built one. Silent by design (see the
123
+ // module doc): a session reseed fires no per-entry events. Ordering
124
+ // vs other mutations comes from the mutation FIFO above.
125
+ const nextIndex = new Map();
126
+ const nextStore = new Map();
127
+ for (const [rel, bytes] of Object.entries(files)) {
128
+ nextIndex.set(rel, { size: bytes.length, sha256: await sha256hex(bytes) });
129
+ if (retainBytes)
130
+ nextStore.set(rel, bytes);
131
+ }
132
+ index = nextIndex;
133
+ store = nextStore;
134
+ });
135
+ },
136
+ toRecord: () => Object.fromEntries(store),
137
+ overlay(files) {
138
+ if (!retainBytes)
139
+ throw new Error('overlay() requires a retainBytes sidecar — an index-only sidecar has no bytes to merge');
140
+ return { ...Object.fromEntries(store), ...files };
141
+ },
142
+ onChange(cb) {
143
+ changeCbs.add(cb);
144
+ return () => changeCbs.delete(cb);
145
+ },
146
+ };
147
+ }
@@ -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,76 +10,42 @@
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
- const BINARY_SNIFF_BYTES = 8192;
61
- /** True when the first `BINARY_SNIFF_BYTES` of `bytes` contain a NUL byte. */
62
- function looksBinary(bytes) {
63
- const len = Math.min(bytes.length, BINARY_SNIFF_BYTES);
64
- for (let i = 0; i < len; i++) {
65
- if (bytes[i] === 0)
66
- return true;
67
- }
68
- return false;
69
- }
70
- async function sha256hex(bytes) {
71
- const digest = await crypto.subtle.digest('SHA-256', bytes);
72
- return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
73
- }
74
- /** Byte-for-byte equality — used by `consumeInboundEcho`'s binary-content match. */
75
- function bytesEqual(a, b) {
76
- if (a.length !== b.length)
77
- return false;
78
- for (let i = 0; i < a.length; i++)
79
- if (a[i] !== b[i])
80
- return false;
81
- return true;
82
- }
48
+ import { createBinarySidecar, looksBinary } from './binary-sidecar.js';
83
49
  /**
84
50
  * True when `error` denotes "path does not exist" per the TruthPort error
85
51
  * contract (see truth-port.ts): `error.code === 'not-found'` or
@@ -93,28 +59,22 @@ function isNotFoundError(error) {
93
59
  export function createSyncEngine(client, port, opts = {}) {
94
60
  const decoder = new TextDecoder();
95
61
  const applyToEditor = opts.applyToEditor;
96
- /**
97
- * Paths with an `onHumanSave` write in flight see the module doc's
98
- * "Echo judgement" section. Registered synchronously when `onHumanSave` is
99
- * called and cleared unconditionally (`finally`) once that write's own
100
- * ledgerTurn slot finishes, success or failure.
101
- */
102
- const pendingWrite = new Set();
62
+ /** Surface a degradation to the host — a throwing callback is the host's
63
+ * own bug and must not break the reconciliation that reported it. */
64
+ function degrade(degradation) {
65
+ try {
66
+ opts.onDegraded?.(degradation);
67
+ }
68
+ catch (e) {
69
+ console.warn('[fs-core/sync] onDegraded callback threw', e);
70
+ }
71
+ }
103
72
  /**
104
73
  * Binary files never enter the fs-core ledger — see the module doc's
105
- * "Binary layering" section. Session-scoped: rebuilt from scratch on every
106
- * `populateLedger()`.
74
+ * "Binary layering" section. Index-only (this engine never needs the bytes
75
+ * back); session-scoped: rebuilt from scratch on every `populateLedger()`.
107
76
  */
108
- const binaryIndex = new Map();
109
- /**
110
- * `rel -> { kind: 'text', text } | { kind: 'binary', bytes } | { kind: 'delete' }`
111
- * — see the module doc's "Inbound-echo consumption" section. Written by
112
- * `handleInboundPath` immediately after it actually applies an inbound
113
- * change (ledger write/rm, or a binary/delete index update); consumed
114
- * (checked + cleared on a match) by `consumeInboundEcho`. Session-scoped,
115
- * same as `binaryIndex` — cleared on every `populateLedger()`.
116
- */
117
- const inboundApplied = new Map();
77
+ const binaryIndex = createBinarySidecar();
118
78
  /**
119
79
  * FIFO queue serializing every compare-then-record against the ledger
120
80
  * (inbound change batches AND the onHumanSave accounting step). Without it
@@ -136,13 +96,12 @@ export function createSyncEngine(client, port, opts = {}) {
136
96
  * same persisted ledger identity) and are removed so the ledger ends up
137
97
  * exactly matching the walked tree. */
138
98
  async function seedFromDisk() {
139
- binaryIndex.clear();
140
- inboundApplied.clear();
99
+ await binaryIndex.clear();
141
100
  const seen = new Set();
142
101
  await port.walk(async (rel, bytes) => {
143
102
  seen.add(rel);
144
103
  if (looksBinary(bytes)) {
145
- binaryIndex.set(rel, { size: bytes.length, sha256: await sha256hex(bytes) });
104
+ await binaryIndex.put(rel, bytes);
146
105
  return; // binary never enters the ledger
147
106
  }
148
107
  await client.write(rel, decoder.decode(bytes), { actor: 'human' });
@@ -157,20 +116,16 @@ export function createSyncEngine(client, port, opts = {}) {
157
116
  }
158
117
  }
159
118
  /**
160
- * Process one inbound path. `pendingWrite` is checked first (see module
161
- * doc); a miss falls back to content comparison: identical content is the
162
- * echo of our own last write and is dropped with no ledger write and no
163
- * `applyToEditor` call. A `port.read` rejection classified as not-found
119
+ * Process one inbound path via content comparison: identical content is
120
+ * the echo of our own last write and is dropped with no ledger write and
121
+ * no `applyToEditor` call. A `port.read` rejection classified as not-found
164
122
  * (see truth-port.ts) is a deletion; any other rejection
165
- * ("unavailable") is transient and skips the path entirely inferring a
166
- * deletion from it would rm the ledger record and close the file in the
167
- * editor while it still exists at the truth source.
123
+ * ("unavailable") is transient and skips the path entirely (surfaced as a
124
+ * `truth-read` degradation) — inferring a deletion from it would rm the
125
+ * ledger record and close the file in the editor while it still exists at
126
+ * the truth source.
168
127
  */
169
128
  async function handleInboundPath(rel) {
170
- if (pendingWrite.has(rel)) {
171
- pendingWrite.delete(rel);
172
- return;
173
- }
174
129
  let bytes;
175
130
  try {
176
131
  bytes = await port.read(rel);
@@ -178,6 +133,7 @@ export function createSyncEngine(client, port, opts = {}) {
178
133
  catch (e) {
179
134
  if (!isNotFoundError(e)) {
180
135
  console.warn('[fs-core/sync] transient port read failure, skipping', rel, e);
136
+ degrade({ kind: 'path-sync-failed', rel, stage: 'truth-read', error: e });
181
137
  return;
182
138
  }
183
139
  bytes = null;
@@ -187,8 +143,7 @@ export function createSyncEngine(client, port, opts = {}) {
187
143
  // ledger (it never took the client.write path), so removal here is
188
144
  // index-only — no client.rm.
189
145
  if (binaryIndex.has(rel)) {
190
- binaryIndex.delete(rel);
191
- inboundApplied.set(rel, { kind: 'delete' });
146
+ await binaryIndex.remove(rel);
192
147
  await applyToEditor?.(rel, null);
193
148
  return;
194
149
  }
@@ -196,23 +151,29 @@ export function createSyncEngine(client, port, opts = {}) {
196
151
  try {
197
152
  ledgerContent = (await client.read(rel)).content;
198
153
  }
199
- catch {
154
+ catch (e) {
155
+ if (!isNotFoundError(e)) {
156
+ // A transient ledger read failure is NOT "already absent" — rm'ing
157
+ // (or silently skipping) on unknown ledger state would either drop
158
+ // a record we could not confirm or leave it stale with no signal.
159
+ // Skip the path and surface it; the next change event retries.
160
+ console.warn('[fs-core/sync] ledger read failed while confirming a deletion, skipping', rel, e);
161
+ degrade({ kind: 'path-sync-failed', rel, stage: 'reconcile', error: e });
162
+ return;
163
+ }
200
164
  ledgerContent = undefined;
201
165
  }
202
166
  if (ledgerContent === undefined)
203
167
  return; // already absent from both sides
204
168
  await client.rm(rel, { actor: 'human' });
205
- inboundApplied.set(rel, { kind: 'delete' });
206
169
  await applyToEditor?.(rel, null);
207
170
  return;
208
171
  }
209
172
  if (looksBinary(bytes)) {
210
- const prior = binaryIndex.get(rel);
211
- const sha256 = await sha256hex(bytes);
212
- if (prior && prior.size === bytes.length && prior.sha256 === sha256)
213
- return; // echo: same bytes already indexed
214
- binaryIndex.set(rel, { size: bytes.length, sha256 });
215
- inboundApplied.set(rel, { kind: 'binary', bytes });
173
+ // Echo judgement is the sidecar's put(): `false` = same size+sha256
174
+ // already indexed, i.e. the echo of our own last write.
175
+ if (!(await binaryIndex.put(rel, bytes)))
176
+ return;
216
177
  await applyToEditor?.(rel, bytes);
217
178
  return;
218
179
  }
@@ -231,42 +192,15 @@ export function createSyncEngine(client, port, opts = {}) {
231
192
  if (ledgerContent !== undefined && text === ledgerContent)
232
193
  return; // echo of our own write
233
194
  await client.write(rel, text, { actor: 'human' });
234
- inboundApplied.set(rel, { kind: 'text', text });
235
195
  await applyToEditor?.(rel, bytes);
236
196
  }
237
- /**
238
- * One-shot check for a 'poll' host's own outbound path: "is `content` (the
239
- * bytes/text about to be written to the truth source for `rel`, or `null`
240
- * for a delete) exactly what `handleInboundPath` just applied FROM that
241
- * same truth source?" A match means writing it back out would be a pure
242
- * echo — the host should skip the write entirely — and the record is
243
- * cleared (consumed) so it cannot match again. See the module doc's
244
- * "Inbound-echo consumption" section for why a push host (devtools) never
245
- * needs this: its outbound path goes through `onHumanSave`/`pendingWrite`
246
- * instead.
247
- */
248
- function consumeInboundEcho(rel, content) {
249
- const entry = inboundApplied.get(rel);
250
- if (!entry)
251
- return false;
252
- let matches;
253
- if (content === null)
254
- matches = entry.kind === 'delete';
255
- else if (typeof content === 'string')
256
- matches = entry.kind === 'text' && entry.text === content;
257
- else
258
- matches = entry.kind === 'binary' && bytesEqual(entry.bytes, content);
259
- if (matches)
260
- inboundApplied.delete(rel);
261
- return matches;
262
- }
263
197
  /**
264
198
  * A batch is trusted as-is: the port adapter (see truth-port.ts's
265
199
  * `changes` doc) is responsible for turning a watcher's coalesced/lossy
266
200
  * events into the actual set of paths worth re-examining BEFORE calling
267
- * this engine — devtools' adapter (wal-audit.ts +
268
- * wal-audit-watch-expand.ts) does that via a stat-level disk compare
269
- * against a session index, so paths arriving here have already earned
201
+ * this engine — devtools' adapter (dimina-kit workbench's wal-audit.ts +
202
+ * this package's ./watch-expander.ts) does that via a stat-level disk
203
+ * compare against a session index, so paths arriving here have already earned
270
204
  * their re-examination and no further expansion happens at this layer.
271
205
  */
272
206
  async function handleBatch(paths) {
@@ -279,6 +213,7 @@ export function createSyncEngine(client, port, opts = {}) {
279
213
  }
280
214
  catch (e) {
281
215
  console.warn('[fs-core/sync] disk sync failed for', rel, e);
216
+ degrade({ kind: 'path-sync-failed', rel, stage: 'reconcile', error: e });
282
217
  }
283
218
  }
284
219
  }
@@ -309,10 +244,28 @@ export function createSyncEngine(client, port, opts = {}) {
309
244
  /** Subscribe to `port.changes`. `active` gates BOTH callbacks so a
310
245
  * late/duplicate event delivered after `onDead` (or after a fresh
311
246
  * `start()` superseded this subscription) is a guaranteed no-op, not just
312
- * best-effort. */
247
+ * best-effort. The subscription is torn down through `disposeOnce`: the
248
+ * TruthPort contract does not require an idempotent dispose, and a host's
249
+ * `onDegraded` callback may synchronously call `engine.stop()` — without
250
+ * the guard that re-enters the same dispose a second time. */
313
251
  function start() {
314
252
  let active = true;
315
- const dispose = port.changes((paths) => {
253
+ let disposed = false;
254
+ // "dispose requested" and "dispose implementation available" are split:
255
+ // a port may fire onDead synchronously DURING the changes() call, before
256
+ // its dispose function even exists — the request is recorded via
257
+ // `disposed` and the implementation is invoked right after the
258
+ // subscription call returns it (see below). Declared-then-assigned (not
259
+ // `const`) precisely so that a during-subscription disposeOnce() reads
260
+ // `undefined` instead of hitting a temporal dead zone.
261
+ let disposeImpl = undefined;
262
+ function disposeOnce() {
263
+ if (disposed)
264
+ return;
265
+ disposed = true;
266
+ disposeImpl?.();
267
+ }
268
+ disposeImpl = port.changes((paths) => {
316
269
  if (!active)
317
270
  return;
318
271
  enqueueInboundBatch(paths);
@@ -321,11 +274,16 @@ export function createSyncEngine(client, port, opts = {}) {
321
274
  return;
322
275
  active = false;
323
276
  console.warn('[fs-core/sync] watcher died — reverting to open-time mirror only');
324
- dispose();
277
+ disposeOnce();
278
+ degrade({ kind: 'watcher-dead' });
325
279
  });
280
+ // Dispose was requested during subscription (synchronous onDead): the
281
+ // implementation exists now — honor the request exactly once.
282
+ if (disposed)
283
+ disposeImpl();
326
284
  stopWatching = () => {
327
285
  active = false;
328
- dispose();
286
+ disposeOnce();
329
287
  };
330
288
  }
331
289
  function stop() {
@@ -333,12 +291,11 @@ export function createSyncEngine(client, port, opts = {}) {
333
291
  stopWatching = () => { };
334
292
  }
335
293
  /**
336
- * Outbound accounting for a human save: registers `rel` in `pendingWrite`
337
- * for the duration of the ledger compare-then-write (see module doc),
338
- * skips the ledger write when the saved text already matches the ledger's
339
- * record, and runs inside the same `ledgerTurn` FIFO as inbound batches so
340
- * the two can never interleave. Errors propagate to the caller — a host's
341
- * onSave wrapper decides whether a ledger-write failure should be
294
+ * Outbound accounting for a human save: skips the ledger write when the
295
+ * saved text already matches the ledger's record, and runs inside the same
296
+ * `ledgerTurn` FIFO as inbound batches so the two can never interleave
297
+ * (see module doc's "Echo judgement"). Errors propagate to the caller a
298
+ * host's onSave wrapper decides whether a ledger-write failure should be
342
299
  * swallowed (best-effort accounting must never unwind a save that already
343
300
  * landed at the truth source).
344
301
  *
@@ -349,32 +306,25 @@ export function createSyncEngine(client, port, opts = {}) {
349
306
  * entirely and only updates `binaryIndex`.
350
307
  */
351
308
  async function onHumanSave(rel, content) {
352
- pendingWrite.add(rel);
353
- try {
354
- if (content instanceof Uint8Array && looksBinary(content)) {
355
- await enqueueLedgerTurn(async () => {
356
- binaryIndex.set(rel, { size: content.length, sha256: await sha256hex(content) });
357
- });
358
- return;
359
- }
360
- const text = typeof content === 'string' ? content : decoder.decode(content);
309
+ if (content instanceof Uint8Array && looksBinary(content)) {
361
310
  await enqueueLedgerTurn(async () => {
362
- const unchanged = await client
363
- .read(rel)
364
- .then((r) => r.content === text)
365
- .catch(() => false);
366
- if (!unchanged)
367
- await client.write(rel, text, { actor: 'human' });
311
+ await binaryIndex.put(rel, content);
368
312
  });
313
+ return;
369
314
  }
370
- finally {
371
- pendingWrite.delete(rel);
372
- }
315
+ const text = typeof content === 'string' ? content : decoder.decode(content);
316
+ await enqueueLedgerTurn(async () => {
317
+ const unchanged = await client
318
+ .read(rel)
319
+ .then((r) => r.content === text)
320
+ .catch(() => false);
321
+ if (!unchanged)
322
+ await client.write(rel, text, { actor: 'human' });
323
+ });
373
324
  }
374
325
  return {
375
326
  populateLedger: seedFromDisk,
376
327
  onHumanSave,
377
- consumeInboundEcho,
378
328
  start,
379
329
  stop,
380
330
  };