amalgm 0.1.156 → 0.1.157

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.156",
3
+ "version": "0.1.157",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -186,6 +186,11 @@ function migrate(database = openLocalDb()) {
186
186
  next_attempt_at TEXT,
187
187
  last_error TEXT,
188
188
  snapshot_key TEXT,
189
+ -- Promotion fence: MAX(journal_id) when the snapshot was captured.
190
+ -- Local mutations journaled past this cursor are not in the snapshot;
191
+ -- acknowledgePromotion re-homes them onto the shared channel so edits
192
+ -- made while sharing was in flight reach the cloud outbox.
193
+ journal_cursor INTEGER NOT NULL DEFAULT 0,
189
194
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
190
195
  updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
191
196
  );
@@ -604,6 +609,9 @@ function migrate(database = openLocalDb()) {
604
609
  // untouched while new SDK mutations carry their stable identity.
605
610
  ensureColumn(database, 'event_log', 'mutation_id', 'TEXT');
606
611
  ensureColumn(database, 'event_log', 'shared_resource_id', 'TEXT');
612
+ // Wave-3 canaries created shared_resource_promotions before the fence
613
+ // cursor existed.
614
+ ensureColumn(database, 'shared_resource_promotions', 'journal_cursor', 'INTEGER NOT NULL DEFAULT 0');
607
615
  require('./migrations').runLegacyMigrations(database);
608
616
  }
609
617
 
@@ -47,6 +47,7 @@ const path = require('path');
47
47
  const { cachedStatement, ensureDocStatesSchema, openLocalDb } = require('./db');
48
48
  const { appendStateEvent, currentSeq } = require('./events');
49
49
  const { merge3 } = require('./merge3');
50
+ const { trace } = require('./trace');
50
51
 
51
52
  const DOC_RESOURCE_PREFIX = 'doc:';
52
53
  const TEXT_KEY = 'content';
@@ -208,13 +209,12 @@ function resolveDocPath(pathInput) {
208
209
  }
209
210
 
210
211
  /**
211
- * Splice `nextText` into ytext as a minimal edit (common prefix/suffix kept),
212
- * backing off surrogate-pair boundaries so we never split a character.
212
+ * The minimal edit turning `current` into `nextText`: common prefix and
213
+ * suffix kept, backed off surrogate-pair boundaries so a character is never
214
+ * split. `[start, endCurrent)` is the replaced region of `current`;
215
+ * `[start, endNext)` is its replacement in `nextText`.
213
216
  */
214
- function spliceTextInto(Y, doc, ytext, nextText, origin) {
215
- const current = ytext.toString();
216
- if (current === nextText) return false;
217
-
217
+ function spliceBounds(current, nextText) {
218
218
  let start = 0;
219
219
  const maxStart = Math.min(current.length, nextText.length);
220
220
  while (start < maxStart && current[start] === nextText[start]) start += 1;
@@ -230,6 +230,29 @@ function spliceTextInto(Y, doc, ytext, nextText, origin) {
230
230
  endCurrent += 1;
231
231
  endNext += 1;
232
232
  }
233
+ return { start, endCurrent, endNext };
234
+ }
235
+
236
+ // Whether replacing `current` with `nextText` deletes any text. This is the
237
+ // exact question the overwrite guards care about: an insertion-only change
238
+ // (append, new paragraph, edits at several points) destroys nothing, so
239
+ // there is nothing to preserve and no reason to surface it. "Deletes
240
+ // nothing" means every character of `current` survives, in order, in
241
+ // `nextText` — a subsequence scan, so multi-point insertions count as
242
+ // preserving, not just a single splice.
243
+ function overwriteDeletesText(current, nextText) {
244
+ let kept = 0;
245
+ for (let j = 0; kept < current.length && j < nextText.length; j += 1) {
246
+ if (current[kept] === nextText[j]) kept += 1;
247
+ }
248
+ return kept < current.length;
249
+ }
250
+
251
+ // Splice `nextText` into ytext as the minimal edit from spliceBounds.
252
+ function spliceTextInto(Y, doc, ytext, nextText, origin) {
253
+ const current = ytext.toString();
254
+ if (current === nextText) return false;
255
+ const { start, endCurrent, endNext } = spliceBounds(current, nextText);
233
256
 
234
257
  doc.transact(() => {
235
258
  if (endCurrent > start) ytext.delete(start, endCurrent - start);
@@ -242,6 +265,98 @@ function isLowSurrogate(code) {
242
265
  return code >= 0xdc00 && code <= 0xdfff;
243
266
  }
244
267
 
268
+ /**
269
+ * Splice `nextText` into the live doc and hand back the exact update it
270
+ * produced, ready for commitDocMutation. The live doc's own clientID keeps
271
+ * the Yjs state vector at one entry per open session — a fresh doc per splice
272
+ * would grow it forever. The in-memory apply preceding the journal insert is
273
+ * safe: nothing persists before materialization, so a death in the gap leaves
274
+ * disk ahead of the persisted state and reopen simply re-reconciles (a write
275
+ * API caller got no acknowledgement and retries). Returns null when the text
276
+ * is already current.
277
+ */
278
+ function spliceDocText(Y, entry, nextText, source) {
279
+ const origin = { amalgmMutation: true, source, patch: null };
280
+ const changed = spliceTextInto(Y, entry.doc, entry.doc.getText(TEXT_KEY), nextText, origin);
281
+ if (!changed) return null;
282
+ return { mutationId: newMutationId(), update: origin.patch.yjs, bytes: null };
283
+ }
284
+
285
+ /**
286
+ * The one door. Every locally produced document edit — a client Yjs update,
287
+ * a whole-text API write, a disk reconcile — becomes exactly one journal
288
+ * mutation: durable row, then materialization, then the projected event;
289
+ * shared docs land in the cloud outbox by the same row. `bytes` is null when
290
+ * the update came from spliceDocText — the live doc already holds it and
291
+ * materialization only persists; client updates arrive as bytes and are
292
+ * applied here. `entry.pendingConflict` (set by the merge that produced this
293
+ * edit) rides the projected event and is consumed here.
294
+ */
295
+ function commitDocMutation(Y, entry, { mutationId, update, bytes }, source) {
296
+ // Finish before starting: a journal row whose materialization failed (a
297
+ // full disk, a permission flip) is completed here — original mutation id,
298
+ // event, cloud-outbox eligibility — before any new edit is journaled. This
299
+ // is what keeps journal order and the cloud stream gap-free; if the disk is
300
+ // still broken, the new edit fails too instead of shipping past a hole.
301
+ recoverPendingDocMutations(Y, entry);
302
+ const replica = require('./replicas').getReplicaByLocalResource(entry.resource);
303
+ trace('doc-commit', {
304
+ mutationId,
305
+ source,
306
+ path: entry.path,
307
+ shared: replica?.resourceId,
308
+ });
309
+ const origin = { amalgmMutation: true, source, patch: null };
310
+ return require('./mutations').commitLocalMutation({
311
+ channelId: replica ? `resource:${replica.resourceId}` : entry.resource,
312
+ localResource: entry.resource,
313
+ sharedResourceId: replica?.resourceId,
314
+ mutationId,
315
+ deviceId: replica ? process.env.AMALGM_DEVICE_ID : null,
316
+ authorityEpoch: replica?.authorityEpoch || 0,
317
+ baseCloudVersion: replica?.appliedVersion || 0,
318
+ contract: replica?.contract || 'text-yjs@1',
319
+ schemaVersion: replica?.schemaVersion || 1,
320
+ operationKind: 'yjs.update',
321
+ operation: { kind: 'yjs.update', update },
322
+ }, {
323
+ materialize() {
324
+ if (bytes) Y.applyUpdate(entry.doc, new Uint8Array(bytes), origin);
325
+ persistDocState(Y, entry);
326
+ if (replica) {
327
+ // Shared files have a strict saved-local boundary: the user-visible
328
+ // file is current before the event/HTTP acknowledgement.
329
+ flushDocToDisk(Y, entry);
330
+ } else {
331
+ scheduleDiskWrite(Y, entry);
332
+ }
333
+ },
334
+ event: () => {
335
+ const patch = origin.patch || { yjs: update };
336
+ if (entry.pendingConflict) {
337
+ patch.conflict = entry.pendingConflict;
338
+ entry.pendingConflict = null;
339
+ }
340
+ return { resource: entry.resource, op: 'update', id: entry.path, patch, source };
341
+ },
342
+ });
343
+ }
344
+
345
+ // A merge where every incoming hunk conflicted leaves the doc text unchanged:
346
+ // there is no edit to journal, but the conflict (and its sidecar) must still
347
+ // reach subscribed surfaces.
348
+ function publishConflictOnly(entry, source) {
349
+ if (!entry.pendingConflict) return;
350
+ appendStateEvent({
351
+ resource: entry.resource,
352
+ op: 'update',
353
+ id: entry.path,
354
+ patch: { conflict: entry.pendingConflict },
355
+ source,
356
+ });
357
+ entry.pendingConflict = null;
358
+ }
359
+
245
360
  // Schema is ensured once at loadDocEntry; every entry that reaches here came
246
361
  // through it.
247
362
  function persistDocState(Y, entry) {
@@ -263,21 +378,52 @@ function scheduleDiskWrite(Y, entry) {
263
378
  if (entry.diskWriteTimer) return;
264
379
  entry.diskWriteTimer = setTimeout(() => {
265
380
  entry.diskWriteTimer = null;
266
- flushDocToDisk(Y, entry);
381
+ // The debounced write-back has no caller to answer to. A failure here
382
+ // loses nothing durable — the acknowledged state lives in SQLite — so it
383
+ // is logged and the next flush (edit, close, reopen) retries.
384
+ try {
385
+ flushDocToDisk(Y, entry);
386
+ } catch (error) {
387
+ console.warn(`[LiveDoc] Debounced write-back failed for "${entry.path}":`, error?.message || error);
388
+ }
267
389
  }, DISK_WRITE_DEBOUNCE_MS);
268
390
  if (typeof entry.diskWriteTimer.unref === 'function') entry.diskWriteTimer.unref();
269
391
  }
270
392
 
393
+ // Replace-by-rename so the file is always either the old text or the new
394
+ // text, never a torn partial write. The rename replaces the inode, so the
395
+ // original's permission bits must be carried over explicitly — chmod, not an
396
+ // open-mode, because open(2) modes are masked by the umask. An executable
397
+ // script stays executable through a live edit.
398
+ function writeFileAtomic(filePath, text) {
399
+ const tmpPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.amalgm-write`);
400
+ let mode = null;
401
+ try {
402
+ mode = fs.statSync(filePath).mode;
403
+ } catch (error) {
404
+ if (error.code !== 'ENOENT') throw error;
405
+ }
406
+ fs.writeFileSync(tmpPath, text, 'utf8');
407
+ if (mode != null) fs.chmodSync(tmpPath, mode);
408
+ fs.renameSync(tmpPath, filePath);
409
+ }
410
+
411
+ // Throws on failure: "saved locally" must mean the file is really current.
412
+ // Callers that acknowledge an edit (mutation materialization) propagate the
413
+ // failure so the journal row is never completed and no event is projected;
414
+ // only fire-and-forget callers (debounce timer, close) may catch and log.
271
415
  function flushDocToDisk(Y, entry) {
416
+ const text = entry.doc.getText(TEXT_KEY).toString();
417
+ let onDisk = null;
272
418
  try {
273
- const text = entry.doc.getText(TEXT_KEY).toString();
274
- const onDisk = fs.readFileSync(entry.path, 'utf8');
275
- if (onDisk !== text) fs.writeFileSync(entry.path, text, 'utf8');
276
- setLastDiskText(entry, text, 'flush');
277
- persistDocState(Y, entry);
419
+ onDisk = fs.readFileSync(entry.path, 'utf8');
278
420
  } catch (error) {
279
- console.warn(`[LiveDoc] Failed to flush "${entry.path}" to disk:`, error?.message || error);
421
+ // A deleted file is not a failed save — the write below recreates it.
422
+ if (error.code !== 'ENOENT') throw error;
280
423
  }
424
+ if (onDisk !== text) writeFileAtomic(entry.path, text);
425
+ setLastDiskText(entry, text, 'flush');
426
+ persistDocState(Y, entry);
281
427
  }
282
428
 
283
429
  function isConflictSidecar(name) {
@@ -349,13 +495,15 @@ function reconcileFromDisk(Y, entry) {
349
495
  at: new Date().toISOString(),
350
496
  };
351
497
  }
352
- } else if (ours !== onDisk && liveEditsSinceLastDiskState(entry)) {
353
- // Wholesale accept, but the text it replaces was produced through the
354
- // live channel and already flushed (ours === lastDiskText, so the merge
355
- // gate above sees no divergence — this was the silent-loss window). A
356
- // raw fs write carries no base, so lineage is genuinely ambiguous: the
357
- // writer keeps the freedom to rewrite the file, but the replaced live
358
- // text is preserved first and the overwrite is surfaced, never silent.
498
+ } else if (liveEditsSinceLastDiskState(entry) && overwriteDeletesText(ours, onDisk)) {
499
+ // Wholesale accept, but the incoming write DELETES text that arrived
500
+ // through the live channel and was already flushed (ours ===
501
+ // lastDiskText, so the merge gate above sees no divergence — this was
502
+ // the silent-loss window). A raw fs write carries no base, so lineage
503
+ // is genuinely ambiguous: the writer keeps the freedom to rewrite the
504
+ // file, but the replaced live text is preserved first and the
505
+ // overwrite is surfaced, never silent. An insertion-only write (a vim
506
+ // append, a new paragraph) destroys nothing and applies silently.
359
507
  const sidecarPath = writePreservationSidecar(
360
508
  entry,
361
509
  ours,
@@ -369,27 +517,18 @@ function reconcileFromDisk(Y, entry) {
369
517
  };
370
518
  }
371
519
  setLastDiskText(entry, onDisk, 'disk');
372
- // origin 'disk' keeps the update handler from writing the same bytes back.
373
- const spliced = spliceTextInto(Y, entry.doc, ytext, nextText, 'disk');
374
- if (!spliced && entry.pendingConflict) {
375
- // Every external hunk conflicted, so the doc text is unchanged and no
376
- // update event fired — publish the conflict on its own.
377
- appendStateEvent({
378
- resource: entry.resource,
379
- op: 'update',
380
- id: entry.path,
381
- patch: { conflict: entry.pendingConflict },
382
- source: 'doc:disk',
383
- });
384
- entry.pendingConflict = null;
520
+ const edit = spliceDocText(Y, entry, nextText, 'doc:disk');
521
+ if (edit) {
522
+ // The external edit walks through the same door as a client mutation:
523
+ // journal row, materialization (which writes any merged text back — the
524
+ // file artifact follows the doc), event, and the cloud outbox when the
525
+ // doc is shared.
526
+ commitDocMutation(Y, entry, edit, 'doc:disk');
527
+ } else {
528
+ publishConflictOnly(entry, 'doc:disk');
529
+ // Even a no-op still refreshed lastDiskText; record its hash.
530
+ persistDocState(Y, entry);
385
531
  }
386
- // A merge that diverged from what's on disk must write back: disk still
387
- // holds THEIRS (preserved in the sidecar when it conflicted); the file
388
- // artifact follows the doc.
389
- if (nextText !== onDisk) scheduleDiskWrite(Y, entry);
390
- // Persist even for a no-op splice: the refreshed lastDiskText hash still
391
- // needs recording.
392
- persistDocState(Y, entry);
393
532
  } catch (error) {
394
533
  // File deleted or unreadable — the doc simply stops following the disk.
395
534
  console.warn(`[LiveDoc] Reconcile from disk failed for "${entry.path}":`, error?.message || error);
@@ -493,7 +632,13 @@ function closeDocEntry(Y, entry) {
493
632
  clearTimeout(entry.reconcileTimer);
494
633
  entry.reconcileTimer = null;
495
634
  }
496
- flushDocToDisk(Y, entry);
635
+ // Close must complete even when the disk is unwritable: every acknowledged
636
+ // edit is already durable in SQLite, and reopen flushes the file current.
637
+ try {
638
+ flushDocToDisk(Y, entry);
639
+ } catch (error) {
640
+ console.warn(`[LiveDoc] Close flush failed for "${entry.path}":`, error?.message || error);
641
+ }
497
642
  unwatchDocFile(entry);
498
643
  for (const alias of entry.pathAliases) resolvedPathCache.delete(alias);
499
644
  entry.doc.destroy();
@@ -612,38 +757,15 @@ function loadDocEntry(pathInput) {
612
757
  }
613
758
  }
614
759
 
615
- // Every applied change — client update, disk reconcile, or seed — flows
616
- // through this one pipeline: event out, then (except disk-origin) disk out.
617
- // State persistence is NOT per update: the durability contract only needs
618
- // the state durable before the caller acknowledges, so each mutation entry
619
- // point (applyDocUpdates, reconcileFromDisk, flushDocToDisk) persists once
620
- // when it is done. A kill — even SIGKILL — still never loses an
621
- // acknowledged edit; at worst the disk artifact lags by the debounce window
622
- // until the doc is next opened.
760
+ // One door: every change to this doc is applied by a journal-owned commit
761
+ // (client mutation, whole-text write, disk reconcile, cloud ingest,
762
+ // snapshot install, recovery), and that commit materializes state and
763
+ // projects the event itself. The observer's only job is handing the exact
764
+ // emitted update bytes back to the commit that applied it. Assigning to a
765
+ // non-object origin throws in strict mode — any future path that mutates
766
+ // the doc outside the door fails loudly instead of losing its event.
623
767
  doc.on('update', (update, origin) => {
624
- const patch = { yjs: Buffer.from(update).toString('base64') };
625
- // Disk reconciles and versioned writes both splice synchronously right
626
- // after setting pendingConflict, so the conflict rides this event.
627
- if ((origin === 'disk' || origin === 'write') && entry.pendingConflict) {
628
- patch.conflict = entry.pendingConflict;
629
- entry.pendingConflict = null;
630
- }
631
- // SDK mutations own their journal row and event projection explicitly:
632
- // durable journal first, then Y.Doc + file materialization, then event.
633
- // The observer still captures the exact emitted update, but must not
634
- // append an early event or schedule the shared file write after ack.
635
- if (origin && typeof origin === 'object' && origin.amalgmMutation === true) {
636
- origin.patch = patch;
637
- return;
638
- }
639
- appendStateEvent({
640
- resource: entry.resource,
641
- op: 'update',
642
- id: entry.path,
643
- patch,
644
- source: origin === 'disk' ? 'doc:disk' : origin === 'write' ? 'doc:write' : 'doc:update',
645
- });
646
- if (origin !== 'disk') scheduleDiskWrite(Y, entry);
768
+ origin.patch = { yjs: Buffer.from(update).toString('base64') };
647
769
  });
648
770
 
649
771
  // Disk at open: an externally changed file folds in as an edit, but a file
@@ -743,6 +865,12 @@ function writeDocText(pathInput, textInput, options = {}) {
743
865
 
744
866
  const entry = loadDocEntry(pathInput);
745
867
  entry.lastAccess = Date.now();
868
+ // Every respond() below acknowledges saved state, so it must stand on a
869
+ // settled journal — most pointedly the noop return: a retry of a write
870
+ // whose disk flush failed finds the text already in memory, and without
871
+ // this the original mutation would stay stuck while the caller is told all
872
+ // is well. (Edits that DO reach commitDocMutation settle there.)
873
+ recoverPendingDocMutations(Y, entry);
746
874
  const ytext = entry.doc.getText(TEXT_KEY);
747
875
  const ours = ytext.toString();
748
876
 
@@ -788,10 +916,14 @@ function writeDocText(pathInput, textInput, options = {}) {
788
916
  }
789
917
  } else {
790
918
  // No base declared: same conservative posture as a raw disk write —
791
- // silent when the replaced text is just the last disk state, preserved
792
- // and surfaced when live-channel edits would be destroyed.
919
+ // silent when nothing is destroyed or the replaced text is just the last
920
+ // disk state, preserved and surfaced when live-channel edits would be
921
+ // deleted.
793
922
  merge = 'overwrite';
794
- if (ours !== entry.lastDiskText || liveEditsSinceLastDiskState(entry)) {
923
+ if (
924
+ overwriteDeletesText(ours, textInput)
925
+ && (ours !== entry.lastDiskText || liveEditsSinceLastDiskState(entry))
926
+ ) {
795
927
  const sidecarPath = writePreservationSidecar(
796
928
  entry,
797
929
  ours,
@@ -802,22 +934,9 @@ function writeDocText(pathInput, textInput, options = {}) {
802
934
  }
803
935
 
804
936
  entry.pendingConflict = conflict;
805
- // origin 'write' rides the conflict on the emitted doc event and schedules
806
- // the disk write-back (unlike 'disk', which must not echo to disk).
807
- const spliced = spliceTextInto(Y, entry.doc, ytext, nextText, 'write');
808
- if (!spliced && entry.pendingConflict) {
809
- // Every incoming hunk conflicted: the doc text is unchanged, no update
810
- // event fired — publish the conflict on its own (mirrors reconcile).
811
- appendStateEvent({
812
- resource: entry.resource,
813
- op: 'update',
814
- id: entry.path,
815
- patch: { conflict: entry.pendingConflict },
816
- source: 'doc:write',
817
- });
818
- entry.pendingConflict = null;
819
- }
820
- persistDocState(Y, entry);
937
+ const edit = spliceDocText(Y, entry, nextText, 'doc:write');
938
+ if (edit) commitDocMutation(Y, entry, edit, 'doc:write');
939
+ else publishConflictOnly(entry, 'doc:write');
821
940
  return respond(merge, nextText, conflict);
822
941
  }
823
942
 
@@ -853,47 +972,7 @@ function applyDocMutations(pathInput, mutationsInput) {
853
972
  if (mutations.length === 0) return { applied: 0, seq: currentSeq(), mutationIds: [] };
854
973
  const entry = loadDocEntry(pathInput);
855
974
  entry.lastAccess = Date.now();
856
- const results = [];
857
-
858
- for (const mutation of mutations) {
859
- const replica = require('./replicas').getReplicaByLocalResource(entry.resource);
860
- const operation = { kind: 'yjs.update', update: mutation.update };
861
- const origin = { amalgmMutation: true, source: 'local', patch: null };
862
- const result = require('./mutations').commitLocalMutation({
863
- channelId: replica ? `resource:${replica.resourceId}` : entry.resource,
864
- localResource: entry.resource,
865
- sharedResourceId: replica?.resourceId,
866
- mutationId: mutation.mutationId,
867
- deviceId: replica ? process.env.AMALGM_DEVICE_ID : null,
868
- authorityEpoch: replica?.authorityEpoch || 0,
869
- baseCloudVersion: replica?.appliedVersion || 0,
870
- contract: replica?.contract || 'text-yjs@1',
871
- schemaVersion: replica?.schemaVersion || 1,
872
- operationKind: 'yjs.update',
873
- operation,
874
- }, {
875
- materialize() {
876
- Y.applyUpdate(entry.doc, new Uint8Array(mutation.bytes), origin);
877
- persistDocState(Y, entry);
878
- if (replica) {
879
- // Shared files have a strict saved-local boundary: the user-visible
880
- // file is current before the event/HTTP acknowledgement.
881
- flushDocToDisk(Y, entry);
882
- } else {
883
- // Preserve the existing local-only write-back behavior.
884
- scheduleDiskWrite(Y, entry);
885
- }
886
- },
887
- event: () => ({
888
- resource: entry.resource,
889
- op: 'update',
890
- id: entry.path,
891
- patch: origin.patch || { yjs: mutation.update },
892
- source: 'doc:update',
893
- }),
894
- });
895
- results.push(result);
896
- }
975
+ const results = mutations.map((mutation) => commitDocMutation(Y, entry, mutation, 'doc:update'));
897
976
 
898
977
  return {
899
978
  applied: mutations.length,
@@ -14,6 +14,7 @@
14
14
 
15
15
  const { cachedStatement, openLocalDb } = require('./db');
16
16
  const { insertStateEvent, publishStateEvent } = require('./events');
17
+ const { trace } = require('./trace');
17
18
 
18
19
  const MAX_MUTATION_BYTES = 4 * 1024 * 1024;
19
20
  const DEFAULT_CLAIM_LIMIT = 100;
@@ -253,6 +254,11 @@ function journalMutation(input, options = {}) {
253
254
 
254
255
  const row = cachedStatement(database, 'SELECT * FROM mutation_journal WHERE journal_id = ?')
255
256
  .get(Number(info.lastInsertRowid));
257
+ trace('journal', {
258
+ mutationId: mutation.mutationId,
259
+ channel: mutation.channelId,
260
+ shared: mutation.sharedResourceId,
261
+ });
256
262
  return { mutation: normalizeJournalRow(row), duplicate: false };
257
263
  })();
258
264
  }
@@ -343,6 +349,12 @@ function completeMaterializedMutation(channelIdInput, mutationIdInput, eventInpu
343
349
  advanceReplicaCursor(database, row.shared_resource_id);
344
350
  }
345
351
 
352
+ trace('materialized', {
353
+ mutationId: row.mutation_id,
354
+ origin: row.mutation_origin,
355
+ state: nextState,
356
+ eventSeq: event.seq,
357
+ });
346
358
  return {
347
359
  mutation: normalizeJournalRow(
348
360
  cachedStatement(database, 'SELECT * FROM mutation_journal WHERE journal_id = ?')
@@ -492,6 +504,7 @@ function journalCloudMutation(input, options = {}) {
492
504
  now,
493
505
  now,
494
506
  );
507
+ trace('cloud-ingest', { mutationId: mutation.mutationId, shared: sharedResourceId, version });
495
508
  return {
496
509
  mutation: normalizeJournalRow(
497
510
  cachedStatement(database, 'SELECT * FROM mutation_journal WHERE journal_id = ?')
@@ -629,6 +642,11 @@ function claimCloudMutations(options = {}) {
629
642
  return rows.map((row) => {
630
643
  claim.run(leaseUntil, now, row.journal_id);
631
644
  const mutation = normalizeJournalRow(read.get(row.journal_id));
645
+ trace('claim', {
646
+ mutationId: mutation.mutationId,
647
+ shared: mutation.sharedResourceId,
648
+ attempt: mutation.attemptCount,
649
+ });
632
650
  return { mutation, envelope: cloudEnvelope(mutation) };
633
651
  });
634
652
  })();
@@ -677,6 +695,7 @@ function acknowledgeCloudMutation(sharedResourceIdInput, mutationIdInput, acknow
677
695
  WHERE journal_id = ?
678
696
  `).run(version, committedAt, now, row.journal_id);
679
697
  advanceReplicaCursor(database, sharedResourceId);
698
+ trace('cloud-ack', { mutationId, shared: sharedResourceId, version });
680
699
  return {
681
700
  mutation: normalizeJournalRow(
682
701
  cachedStatement(database, 'SELECT * FROM mutation_journal WHERE journal_id = ?')
@@ -709,6 +728,7 @@ function failCloudMutation(sharedResourceIdInput, mutationIdInput, failure, opti
709
728
  updated_at = ?
710
729
  WHERE journal_id = ?
711
730
  `).run(retryable ? 'syncing' : 'rejected', retryAt, message, now, row.journal_id);
731
+ trace('cloud-fail', { mutationId, shared: sharedResourceId, retryable, message });
712
732
  return normalizeJournalRow(
713
733
  cachedStatement(database, 'SELECT * FROM mutation_journal WHERE journal_id = ?')
714
734
  .get(row.journal_id),
@@ -5,6 +5,7 @@ const path = require('path');
5
5
  const { cachedStatement, openLocalDb } = require('./db');
6
6
  const { appendStateEvent } = require('./events');
7
7
  const { registerReplica } = require('./replicas');
8
+ const { trace } = require('./trace');
8
9
 
9
10
  const SHARED_RESOURCE_ID = /^shr_[A-Za-z0-9_-]{16,128}$/;
10
11
  const PROMOTION_ID = /^[A-Za-z0-9_-]{16,160}$/;
@@ -41,6 +42,7 @@ function normalizeRow(row) {
41
42
  nextAttemptAt: row.next_attempt_at || null,
42
43
  lastError: row.last_error || null,
43
44
  snapshotKey: row.snapshot_key || null,
45
+ journalCursor: Number(row.journal_cursor || 0),
44
46
  createdAt: row.created_at,
45
47
  updatedAt: row.updated_at,
46
48
  };
@@ -82,6 +84,14 @@ function enqueuePromotion(input, options = {}) {
82
84
  return { promotion: row, duplicate: true };
83
85
  }
84
86
 
87
+ // The fence: every local mutation already journaled is contained in the
88
+ // snapshot captured above; anything journaled past this cursor happened
89
+ // while the promotion was in flight and is re-homed at acknowledgement.
90
+ const journalCursor = Number(
91
+ cachedStatement(database, 'SELECT COALESCE(MAX(journal_id), 0) AS cursor FROM mutation_journal')
92
+ .get().cursor,
93
+ );
94
+
85
95
  const now = new Date().toISOString();
86
96
  cachedStatement(database, `
87
97
  INSERT INTO shared_resource_promotions (
@@ -97,9 +107,10 @@ function enqueuePromotion(input, options = {}) {
97
107
  content_bytes,
98
108
  name,
99
109
  media_type,
110
+ journal_cursor,
100
111
  created_at,
101
112
  updated_at
102
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
113
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
103
114
  `).run(
104
115
  promotionId,
105
116
  resourceId,
@@ -113,6 +124,7 @@ function enqueuePromotion(input, options = {}) {
113
124
  contentBytes,
114
125
  input?.name || path.basename(localPath),
115
126
  input?.mediaType || 'application/vnd.amalgm.text-yjs-snapshot',
127
+ journalCursor,
116
128
  now,
117
129
  now,
118
130
  );
@@ -121,6 +133,12 @@ function enqueuePromotion(input, options = {}) {
121
133
  .get(promotionId),
122
134
  );
123
135
 
136
+ trace('promotion-enqueue', {
137
+ promotionId,
138
+ resourceId,
139
+ path: localPath,
140
+ cursor: journalCursor,
141
+ });
124
142
  // Wake the already-open internal Local Live stream used by the tunnel
125
143
  // process. This is a control event, not a cloud mutation/version.
126
144
  appendStateEvent({
@@ -196,7 +214,7 @@ function acknowledgePromotion(promotionIdInput, acknowledgement, options = {}) {
196
214
  }
197
215
  if (promotion.state === 'promoted') return { promotion, duplicate: true };
198
216
 
199
- registerReplica({
217
+ const { replica } = registerReplica({
200
218
  resourceId: promotion.resourceId,
201
219
  localResource: promotion.localResource,
202
220
  localPath: promotion.localPath,
@@ -209,6 +227,40 @@ function acknowledgePromotion(promotionIdInput, acknowledgement, options = {}) {
209
227
  }, { database });
210
228
 
211
229
  const now = new Date().toISOString();
230
+
231
+ // Close the promotion fence: edits journaled while the promotion was in
232
+ // flight are local-only rows past the enqueue cursor. Re-home them onto
233
+ // the shared channel — their base is the promoted snapshot — so the
234
+ // normal outbox claim delivers them. Their journal identity, order, and
235
+ // already-projected local events are untouched.
236
+ const rehomed = cachedStatement(database, `
237
+ UPDATE mutation_journal
238
+ SET channel_id = ?,
239
+ shared_resource_id = ?,
240
+ device_id = COALESCE(device_id, ?),
241
+ authority_epoch = ?,
242
+ base_cloud_version = ?,
243
+ updated_at = ?
244
+ WHERE local_resource = ?
245
+ AND shared_resource_id IS NULL
246
+ AND mutation_origin = 'local'
247
+ AND journal_id > ?
248
+ `).run(
249
+ `resource:${replica.resourceId}`,
250
+ replica.resourceId,
251
+ process.env.AMALGM_DEVICE_ID || null,
252
+ replica.authorityEpoch,
253
+ replica.snapshotVersion,
254
+ now,
255
+ promotion.localResource,
256
+ promotion.journalCursor,
257
+ );
258
+ trace('promotion-ack', {
259
+ promotionId,
260
+ resourceId: replica.resourceId,
261
+ epoch: replica.authorityEpoch,
262
+ rehomed: rehomed.changes,
263
+ });
212
264
  cachedStatement(database, `
213
265
  UPDATE shared_resource_promotions
214
266
  SET promotion_state = 'promoted',
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  const { cachedStatement, openLocalDb } = require('./db');
12
+ const { trace } = require('./trace');
12
13
 
13
14
  const SHARED_RESOURCE_ID = /^shr_[A-Za-z0-9_-]{16,128}$/;
14
15
  const CONTRACT = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
@@ -126,6 +127,7 @@ function registerReplica(input, options = {}) {
126
127
  }
127
128
  throw error;
128
129
  }
130
+ trace('replica-bind', { resourceId, path: localPath, epoch: authorityEpoch });
129
131
  return {
130
132
  replica: normalizeReplica(
131
133
  cachedStatement(database, 'SELECT * FROM shared_resource_replicas WHERE resource_id = ?')
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared-realtime lifecycle tracing. Off unless AMALGM_SHARED_TRACE=1 — the
5
+ * guard is one boolean, so instrumented paths cost nothing in normal
6
+ * operation, and the instrumentation ships in production for live debugging
7
+ * instead of being stripped before release.
8
+ *
9
+ * Every line is `[SharedTrace] <step> key=value …` with stable identifiers
10
+ * (mutationId, resourceId, promotionId), so grepping one id reconstructs the
11
+ * full journey of one edit: doc-commit → journal → materialized → claim →
12
+ * cloud-ack (or cloud-ingest → materialized on the recipient).
13
+ */
14
+
15
+ const enabled = process.env.AMALGM_SHARED_TRACE === '1';
16
+
17
+ function trace(step, fields = {}) {
18
+ if (!enabled) return;
19
+ let line = `[SharedTrace] ${step}`;
20
+ for (const [key, value] of Object.entries(fields)) {
21
+ if (value !== undefined && value !== null) line += ` ${key}=${value}`;
22
+ }
23
+ console.log(line);
24
+ }
25
+
26
+ module.exports = { trace };
@@ -176,6 +176,67 @@ test('agent rewrites with no live edits since last sync stay silent: no sidecar,
176
176
  assert.equal(conflictEventsFor(opened.resource, before).length, 0, 'no conflict events either');
177
177
  });
178
178
 
179
+ test('a raw append after flushed live edits applies silently: nothing destroyed, no sidecar', async () => {
180
+ const target = writeDoc('vim-append.md', 'v1\n');
181
+ const opened = docs.openDoc(target);
182
+ const client = clientFromState(opened.state);
183
+
184
+ docs.applyDocUpdates(target, [
185
+ encodeLocalEdit(client, (ytext) => ytext.insert('v1'.length, ' human')),
186
+ ]);
187
+ await sleep(500);
188
+ assert.equal(fs.readFileSync(target, 'utf8'), 'v1 human\n', 'precondition: live edit flushed');
189
+
190
+ // A vim user reads the CURRENT file and appends: every existing character
191
+ // survives, so there is nothing to preserve and nothing to surface.
192
+ const before = currentSeq();
193
+ fs.writeFileSync(target, 'v1 human\nFrom vim\n', 'utf8');
194
+ await sleep(700);
195
+
196
+ assert.equal(docs.getDocText(target), 'v1 human\nFrom vim\n');
197
+ assert.equal(sidecarsFor(target).length, 0, 'an insertion-only write is not a conflict');
198
+ assert.equal(conflictEventsFor(opened.resource, before).length, 0);
199
+ });
200
+
201
+ test('insertions at several points still delete nothing: silent, no sidecar', async () => {
202
+ const target = writeDoc('multi-insert.md', 'm1\nm2\nm3\n');
203
+ const opened = docs.openDoc(target);
204
+ const client = clientFromState(opened.state);
205
+
206
+ docs.applyDocUpdates(target, [
207
+ encodeLocalEdit(client, (ytext) => ytext.insert('m1\nm2'.length, ' human')),
208
+ ]);
209
+ await sleep(500);
210
+ assert.equal(fs.readFileSync(target, 'utf8'), 'm1\nm2 human\nm3\n');
211
+
212
+ // Not a single splice: new text lands both between m1/m2 and after m3.
213
+ // Every original character survives in order — nothing was destroyed.
214
+ const before = currentSeq();
215
+ fs.writeFileSync(target, 'm1\ninserted\nm2 human\nm3\nappended\n', 'utf8');
216
+ await sleep(700);
217
+
218
+ assert.equal(docs.getDocText(target), 'm1\ninserted\nm2 human\nm3\nappended\n');
219
+ assert.equal(sidecarsFor(target).length, 0, 'multi-point insertion is not a conflict');
220
+ assert.equal(conflictEventsFor(opened.resource, before).length, 0);
221
+ });
222
+
223
+ test('an unversioned API append that preserves all live text applies silently', async () => {
224
+ const target = writeDoc('api-append.md', 'p1\n');
225
+ const opened = docs.openDoc(target);
226
+ const client = clientFromState(opened.state);
227
+
228
+ // Live edit still inside the debounce window — the strictest case.
229
+ docs.applyDocUpdates(target, [
230
+ encodeLocalEdit(client, (ytext) => ytext.insert('p1'.length, ' human')),
231
+ ]);
232
+ const result = docs.writeDocText(target, 'p1 human\nappended\n');
233
+ assert.equal(result.merge, 'overwrite');
234
+ assert.equal(result.conflict, null, 'nothing was deleted, so nothing to surface');
235
+ assert.equal(docs.getDocText(target), 'p1 human\nappended\n');
236
+ assert.equal(sidecarsFor(target).length, 0);
237
+ await sleep(500);
238
+ });
239
+
179
240
  test('unflushed live edits still merge exactly as before (step-5 regression guard)', async () => {
180
241
  const base = 'k1\nk2\nk3\nk4\nk5\n';
181
242
  const target = writeDoc('unflushed-window.md', base);
@@ -20,6 +20,7 @@ const {
20
20
  claimCloudMutations,
21
21
  getMutation,
22
22
  journalMutation,
23
+ listMutations,
23
24
  } = require('../state/mutations');
24
25
  const {
25
26
  acknowledgePromotion,
@@ -239,6 +240,13 @@ test('reopen recovers a journaled shared edit that died before materialization',
239
240
  listEventsAfter(0).filter((event) => event.mutationId === mutationId).length,
240
241
  1,
241
242
  );
243
+
244
+ // Drain the outbox so later tests observe only their own deliveries.
245
+ const [claim] = claimCloudMutations().filter((item) => item.envelope.mutationId === mutationId);
246
+ acknowledgeCloudMutation(resourceId, claim.envelope.mutationId, {
247
+ version: 4,
248
+ committedAt: '2026-07-14T20:00:04.000Z',
249
+ });
242
250
  });
243
251
 
244
252
  test('authorized snapshot attachment creates the recipient file before its UI event', () => {
@@ -281,3 +289,177 @@ test('authorized snapshot attachment creates the recipient file before its UI ev
281
289
  resourceId: recipientResourceId,
282
290
  }).duplicate, true);
283
291
  });
292
+
293
+ function sleep(ms) {
294
+ return new Promise((resolve) => { setTimeout(resolve, ms); });
295
+ }
296
+
297
+ function sha256(text) {
298
+ return require('crypto').createHash('sha256').update(text, 'utf8').digest('hex');
299
+ }
300
+
301
+ // One door: an agent writing the shared file directly (no doc API at all)
302
+ // must produce a cloud-deliverable mutation, not just a local event.
303
+ test('an external disk write on a shared doc enters the cloud outbox', async () => {
304
+ docs.openDoc(target);
305
+ const beforeText = fs.readFileSync(target, 'utf8');
306
+ fs.writeFileSync(target, `${beforeText}From vim\n`, 'utf8');
307
+ await sleep(700);
308
+
309
+ assert.equal(docs.getDocText(target), `${beforeText}From vim\n`);
310
+ const claims = claimCloudMutations().filter((claim) => claim.envelope.resourceId === resourceId);
311
+ assert.equal(claims.length, 1, 'the raw disk write must be claimable for cloud delivery');
312
+ assert.equal(claims[0].envelope.operationKind, 'yjs.update');
313
+ const event = listEventsAfter(0).find((e) => e.mutationId === claims[0].envelope.mutationId);
314
+ assert.equal(event.source, 'doc:disk');
315
+ acknowledgeCloudMutation(resourceId, claims[0].envelope.mutationId, {
316
+ version: getReplicaByResourceId(resourceId).appliedVersion + 1,
317
+ committedAt: '2026-07-15T20:00:00.000Z',
318
+ });
319
+ });
320
+
321
+ // One door: the whole-text write API (agents/tools) on a shared doc.
322
+ test('a versioned whole-text write on a shared doc enters the cloud outbox', () => {
323
+ const ours = docs.getDocText(target);
324
+ const written = docs.writeDocText(target, `${ours}From the write API\n`, { baseHash: sha256(ours) });
325
+ assert.equal(written.merge, 'clean');
326
+
327
+ const claims = claimCloudMutations().filter((claim) => claim.envelope.resourceId === resourceId);
328
+ assert.equal(claims.length, 1, 'the API write must be claimable for cloud delivery');
329
+ const event = listEventsAfter(0).find((e) => e.mutationId === claims[0].envelope.mutationId);
330
+ assert.equal(event.source, 'doc:write');
331
+ acknowledgeCloudMutation(resourceId, claims[0].envelope.mutationId, {
332
+ version: getReplicaByResourceId(resourceId).appliedVersion + 1,
333
+ committedAt: '2026-07-15T20:00:01.000Z',
334
+ });
335
+ });
336
+
337
+ // Promotion fence: edits made while sharing is being set up may not strand.
338
+ test('edits journaled during the promotion window are re-homed into the outbox', () => {
339
+ const fenceResourceId = 'shr_fencefencefence1';
340
+ const fencePath = path.join(docDir, 'fence.md');
341
+ fs.writeFileSync(fencePath, 'fence base\n', 'utf8');
342
+ const opened = docs.openDoc(fencePath);
343
+
344
+ enqueuePromotion({
345
+ promotionId: 'prm_fencefencefence1',
346
+ resourceId: fenceResourceId,
347
+ localResource: opened.resource,
348
+ localPath: opened.path,
349
+ contract: 'text-yjs@1',
350
+ schemaVersion: 1,
351
+ snapshotBase64: opened.state,
352
+ contentBytes: fs.statSync(fencePath).size,
353
+ });
354
+
355
+ // The user keeps typing while the promotion is in flight: no replica yet,
356
+ // so this journals as a local-only mutation.
357
+ const client = clientFromState(opened.state);
358
+ const update = captureEdit(client, (text) => text.insert(text.length, 'typed during promotion\n'));
359
+ docs.applyDocMutations(fencePath, [{ mutationId: 'mut_during_promotion1', update }]);
360
+ assert.equal(getMutation(opened.resource, 'mut_during_promotion1').sharedResourceId, null);
361
+
362
+ const [claimed] = claimPromotions();
363
+ acknowledgePromotion('prm_fencefencefence1', {
364
+ resourceId: fenceResourceId,
365
+ authorityEpoch: 1,
366
+ headVersion: 0,
367
+ snapshotVersion: 0,
368
+ snapshotKey: `shared/${fenceResourceId}/snapshots/v0/${claimed.frame.snapshotChecksum}.bin`,
369
+ snapshotChecksum: claimed.frame.snapshotChecksum,
370
+ });
371
+
372
+ const rehomed = getMutation(`resource:${fenceResourceId}`, 'mut_during_promotion1');
373
+ assert.equal(rehomed.sharedResourceId, fenceResourceId);
374
+ assert.equal(rehomed.authorityEpoch, 1);
375
+ assert.equal(rehomed.baseCloudVersion, 0);
376
+ assert.equal(rehomed.deviceId, process.env.AMALGM_DEVICE_ID);
377
+ const claims = claimCloudMutations().filter((claim) => claim.envelope.resourceId === fenceResourceId);
378
+ assert.deepEqual(claims.map((claim) => claim.envelope.mutationId), ['mut_during_promotion1']);
379
+ });
380
+
381
+ // Truthful saves: a failed file write must fail the edit — no journal
382
+ // completion, no event, no acknowledgement — and recover once disk works.
383
+ test('a failed shared-file write reports failure and recovers on reopen', async () => {
384
+ const beforeEvents = listEventsAfter(0).length;
385
+ const client = clientFromState(docs.openDoc(target).state);
386
+ const update = captureEdit(client, (text) => text.insert(text.length, 'Unwritable\n'));
387
+
388
+ fs.chmodSync(docDir, 0o555);
389
+ try {
390
+ assert.throws(
391
+ () => docs.applyDocMutations(target, [{ mutationId: 'mut_unwritable_00001', update }]),
392
+ /EACCES|EPERM/,
393
+ );
394
+ } finally {
395
+ fs.chmodSync(docDir, 0o755);
396
+ }
397
+ assert.equal(listEventsAfter(0).length, beforeEvents, 'a failed save must not project an event');
398
+ const stalled = getMutation(`resource:${resourceId}`, 'mut_unwritable_00001');
399
+ assert.equal(stalled.state, 'saving-local');
400
+ assert.match(stalled.lastError, /EACCES|EPERM/);
401
+
402
+ docs.closeAllDocs();
403
+ docs.openDoc(target);
404
+ assert.equal(getMutation(`resource:${resourceId}`, 'mut_unwritable_00001').state, 'saved-local');
405
+ assert.match(fs.readFileSync(target, 'utf8'), /Unwritable\n$/);
406
+
407
+ // Drain the outbox so later tests observe only their own deliveries.
408
+ claimCloudMutations();
409
+ acknowledgeCloudMutation(resourceId, 'mut_unwritable_00001', {
410
+ version: getReplicaByResourceId(resourceId).appliedVersion + 1,
411
+ committedAt: '2026-07-15T20:00:01.500Z',
412
+ });
413
+ });
414
+
415
+ // Truthful saves, the retry-while-open half: after a failed flush the desired
416
+ // text already lives in memory, so a naive retry looks like a noop. The retry
417
+ // must instead settle the ORIGINAL mutation — file current, journal row
418
+ // completed, exactly one event, exactly one cloud delivery — before answering.
419
+ test('retrying a failed whole-text write settles the original mutation: disk, journal, event, outbox agree', () => {
420
+ const ours = docs.getDocText(target);
421
+ const nextText = `${ours}Retry survives\n`;
422
+ const baseHash = sha256(ours);
423
+ const beforeDisk = fs.readFileSync(target, 'utf8');
424
+
425
+ fs.chmodSync(docDir, 0o555);
426
+ try {
427
+ assert.throws(() => docs.writeDocText(target, nextText, { baseHash }), /EACCES|EPERM/);
428
+ } finally {
429
+ fs.chmodSync(docDir, 0o755);
430
+ }
431
+ assert.equal(fs.readFileSync(target, 'utf8'), beforeDisk, 'failed save leaves the file untouched');
432
+ const [stuck] = listMutations({ state: 'saving-local' });
433
+ assert.ok(stuck, 'the failed write is journaled, not lost');
434
+
435
+ const retried = docs.writeDocText(target, nextText, { baseHash });
436
+ assert.equal(retried.sha256, sha256(nextText));
437
+ assert.equal(fs.readFileSync(target, 'utf8'), nextText, 'retry really saved the file');
438
+ assert.equal(getMutation(stuck.channelId, stuck.mutationId).state, 'saved-local');
439
+ assert.equal(
440
+ listEventsAfter(0).filter((event) => event.mutationId === stuck.mutationId).length,
441
+ 1,
442
+ 'exactly one projected event for the settled mutation',
443
+ );
444
+ const claims = claimCloudMutations().filter((claim) => claim.envelope.resourceId === resourceId);
445
+ assert.deepEqual(claims.map((claim) => claim.envelope.mutationId), [stuck.mutationId]);
446
+ acknowledgeCloudMutation(resourceId, stuck.mutationId, {
447
+ version: getReplicaByResourceId(resourceId).appliedVersion + 1,
448
+ committedAt: '2026-07-15T20:00:02.000Z',
449
+ });
450
+ });
451
+
452
+ // A live edit must not strip the file's permission bits: replace-by-rename
453
+ // carries the original mode onto the replacement inode.
454
+ test('an executable file stays executable through a live write', () => {
455
+ fs.chmodSync(target, 0o755);
456
+ const ours = docs.getDocText(target);
457
+ docs.writeDocText(target, `${ours}chmod check\n`, { baseHash: sha256(ours) });
458
+ assert.equal(fs.statSync(target).mode & 0o777, 0o755);
459
+
460
+ const claims = claimCloudMutations().filter((claim) => claim.envelope.resourceId === resourceId);
461
+ acknowledgeCloudMutation(resourceId, claims[0].envelope.mutationId, {
462
+ version: getReplicaByResourceId(resourceId).appliedVersion + 1,
463
+ committedAt: '2026-07-15T20:00:03.000Z',
464
+ });
465
+ });