@remnic/core 1.1.29 → 1.1.30

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 (98) hide show
  1. package/dist/access-cli.js +13 -13
  2. package/dist/access-http.d.ts +1 -1
  3. package/dist/access-http.js +8 -8
  4. package/dist/access-mcp.d.ts +1 -1
  5. package/dist/access-mcp.js +7 -7
  6. package/dist/access-schema.d.ts +55 -5
  7. package/dist/access-schema.js +4 -2
  8. package/dist/{access-service-CEyV8XJ5.d.ts → access-service-B5hgZPCN.d.ts} +4 -1
  9. package/dist/access-service.d.ts +1 -1
  10. package/dist/access-service.js +5 -5
  11. package/dist/briefing.js +2 -2
  12. package/dist/causal-consolidation.js +3 -3
  13. package/dist/{chunk-25YQM6XW.js → chunk-2IWUMAES.js} +3 -3
  14. package/dist/{chunk-JUYT2J3K.js → chunk-3OWUCDKH.js} +39 -7
  15. package/dist/chunk-3OWUCDKH.js.map +1 -0
  16. package/dist/{chunk-QYHQ2JHL.js → chunk-43PJZYGL.js} +2 -2
  17. package/dist/{chunk-YITUHONZ.js → chunk-4KGVTPGD.js} +2 -2
  18. package/dist/{chunk-TR4DK5OH.js → chunk-76FLAAUC.js} +2 -2
  19. package/dist/{chunk-6BFAEWQS.js → chunk-77H5NU3M.js} +2 -2
  20. package/dist/{chunk-IANK6Y5W.js → chunk-A6KTB5R6.js} +2 -2
  21. package/dist/{chunk-7D6O46PF.js → chunk-BVF3AGJP.js} +2 -2
  22. package/dist/{chunk-4H6DURG6.js → chunk-JA3AK3PT.js} +2 -2
  23. package/dist/{chunk-WDSIV3AK.js → chunk-KRBK4BQH.js} +12 -12
  24. package/dist/{chunk-AMVN77EU.js → chunk-MG7NA5H3.js} +365 -90
  25. package/dist/chunk-MG7NA5H3.js.map +1 -0
  26. package/dist/{chunk-RCZRL5BE.js → chunk-MRILGULB.js} +2 -2
  27. package/dist/{chunk-NW7JW5GA.js → chunk-OC7KHOOX.js} +41 -6
  28. package/dist/chunk-OC7KHOOX.js.map +1 -0
  29. package/dist/{chunk-LCTP7YRU.js → chunk-QKZGQIPJ.js} +16 -7
  30. package/dist/chunk-QKZGQIPJ.js.map +1 -0
  31. package/dist/{chunk-CWWDIQZB.js → chunk-QLLBRHAT.js} +8 -8
  32. package/dist/{chunk-2WIPXV3Y.js → chunk-RR2PKP3I.js} +2 -2
  33. package/dist/{chunk-3F24QTRI.js → chunk-SAZS2QZB.js} +2 -2
  34. package/dist/{chunk-VYU7PXUS.js → chunk-SIC6U3GZ.js} +2 -2
  35. package/dist/{chunk-6CB4E7ZV.js → chunk-UL2NNBUL.js} +4 -4
  36. package/dist/{chunk-F33CJ5CH.js → chunk-VBJ7V5SK.js} +40 -8
  37. package/dist/chunk-VBJ7V5SK.js.map +1 -0
  38. package/dist/{chunk-6WV2HYTZ.js → chunk-W6AQJ2PY.js} +4 -4
  39. package/dist/{chunk-PUXCIHRL.js → chunk-XSZEP4SF.js} +2 -2
  40. package/dist/{cli-BguVmIwO.d.ts → cli-CJKI2JIe.d.ts} +1 -1
  41. package/dist/cli.d.ts +2 -2
  42. package/dist/cli.js +17 -17
  43. package/dist/compounding/engine.js +2 -2
  44. package/dist/connectors/codex-materialize-runner.js +2 -2
  45. package/dist/connectors/index.js +2 -2
  46. package/dist/entity-retrieval.js +2 -2
  47. package/dist/index.d.ts +4 -4
  48. package/dist/index.js +32 -22
  49. package/dist/index.js.map +1 -1
  50. package/dist/maintenance/memory-governance.js +2 -2
  51. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +2 -2
  52. package/dist/maintenance/rebuild-memory-projection.js +3 -3
  53. package/dist/mcp-memory-inspector-app.d.ts +1 -1
  54. package/dist/namespaces/migrate.js +3 -3
  55. package/dist/namespaces/storage.js +2 -2
  56. package/dist/offline-sync.d.ts +49 -1
  57. package/dist/offline-sync.js +13 -1
  58. package/dist/operator-toolkit.js +5 -5
  59. package/dist/orchestrator.js +9 -9
  60. package/dist/semantic-consolidation.js +3 -3
  61. package/dist/semantic-rule-promotion.js +2 -2
  62. package/dist/semantic-rule-verifier.js +2 -2
  63. package/dist/storage.d.ts +5 -0
  64. package/dist/storage.js +1 -1
  65. package/dist/verified-recall.js +2 -2
  66. package/package.json +1 -1
  67. package/src/access-http.test.ts +184 -0
  68. package/src/access-http.ts +37 -0
  69. package/src/access-schema.ts +58 -3
  70. package/src/access-service-namespace.test.ts +56 -1
  71. package/src/access-service-offline-file-content.test.ts +17 -0
  72. package/src/access-service.ts +16 -1
  73. package/src/index.ts +6 -0
  74. package/src/offline-sync.test.ts +1055 -1
  75. package/src/offline-sync.ts +453 -96
  76. package/src/storage.ts +36 -2
  77. package/dist/chunk-AMVN77EU.js.map +0 -1
  78. package/dist/chunk-F33CJ5CH.js.map +0 -1
  79. package/dist/chunk-JUYT2J3K.js.map +0 -1
  80. package/dist/chunk-LCTP7YRU.js.map +0 -1
  81. package/dist/chunk-NW7JW5GA.js.map +0 -1
  82. /package/dist/{chunk-25YQM6XW.js.map → chunk-2IWUMAES.js.map} +0 -0
  83. /package/dist/{chunk-QYHQ2JHL.js.map → chunk-43PJZYGL.js.map} +0 -0
  84. /package/dist/{chunk-YITUHONZ.js.map → chunk-4KGVTPGD.js.map} +0 -0
  85. /package/dist/{chunk-TR4DK5OH.js.map → chunk-76FLAAUC.js.map} +0 -0
  86. /package/dist/{chunk-6BFAEWQS.js.map → chunk-77H5NU3M.js.map} +0 -0
  87. /package/dist/{chunk-IANK6Y5W.js.map → chunk-A6KTB5R6.js.map} +0 -0
  88. /package/dist/{chunk-7D6O46PF.js.map → chunk-BVF3AGJP.js.map} +0 -0
  89. /package/dist/{chunk-4H6DURG6.js.map → chunk-JA3AK3PT.js.map} +0 -0
  90. /package/dist/{chunk-WDSIV3AK.js.map → chunk-KRBK4BQH.js.map} +0 -0
  91. /package/dist/{chunk-RCZRL5BE.js.map → chunk-MRILGULB.js.map} +0 -0
  92. /package/dist/{chunk-CWWDIQZB.js.map → chunk-QLLBRHAT.js.map} +0 -0
  93. /package/dist/{chunk-2WIPXV3Y.js.map → chunk-RR2PKP3I.js.map} +0 -0
  94. /package/dist/{chunk-3F24QTRI.js.map → chunk-SAZS2QZB.js.map} +0 -0
  95. /package/dist/{chunk-VYU7PXUS.js.map → chunk-SIC6U3GZ.js.map} +0 -0
  96. /package/dist/{chunk-6CB4E7ZV.js.map → chunk-UL2NNBUL.js.map} +0 -0
  97. /package/dist/{chunk-6WV2HYTZ.js.map → chunk-W6AQJ2PY.js.map} +0 -0
  98. /package/dist/{chunk-PUXCIHRL.js.map → chunk-XSZEP4SF.js.map} +0 -0
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { createHash } from "node:crypto";
3
- import { mkdir, mkdtemp, readdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
3
+ import { chmod, mkdir, mkdtemp, readdir, readFile, rm, stat, utimes, writeFile } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import test from "node:test";
@@ -10,10 +10,14 @@ import {
10
10
  applyOfflineSyncChangeset,
11
11
  applyOfflineSyncSnapshot,
12
12
  buildOfflineSyncChangeset,
13
+ buildOfflineSyncChangesetFromSnapshot,
13
14
  buildOfflineSyncSnapshot,
15
+ buildOfflineSyncSnapshotFromBase,
14
16
  buildOfflineSyncSnapshotForPaths,
17
+ OFFLINE_SYNC_MAX_MTIME_MS,
15
18
  readOfflineSyncFileContentChunk,
16
19
  summarizeOfflineSyncPendingChanges,
20
+ summarizeOfflineSyncPendingFiles,
17
21
  } from "./offline-sync.js";
18
22
  import { isEncryptedFile } from "./secure-store/secure-fs.js";
19
23
  import { StorageManager } from "./storage.js";
@@ -300,6 +304,315 @@ test("offline sync includes durable runtime state and excludes only transient sy
300
304
  }
301
305
  });
302
306
 
307
+ test("offline snapshot from base avoids rehashing unchanged files", async () => {
308
+ const root = await tempDir("remnic-offline-fast-base");
309
+ try {
310
+ await write(root, "facts/a.md", "alpha");
311
+ const baseSnapshot = await buildOfflineSyncSnapshot({
312
+ root,
313
+ sourceId: "remote",
314
+ includeContent: false,
315
+ });
316
+ const baseCapturedAt = new Date(Date.now() + 60_000);
317
+ let readCount = 0;
318
+
319
+ const unchanged = await buildOfflineSyncSnapshotFromBase({
320
+ root,
321
+ sourceId: "remote",
322
+ baseFiles: baseSnapshot.files,
323
+ baseCapturedAt,
324
+ includeContent: false,
325
+ readFile: async () => {
326
+ readCount += 1;
327
+ throw new Error("unchanged file should not be read");
328
+ },
329
+ });
330
+
331
+ assert.equal(readCount, 0);
332
+ assert.deepEqual(unchanged.files, baseSnapshot.files);
333
+
334
+ await write(root, "facts/b.md", "beta");
335
+ const changed = await buildOfflineSyncSnapshotFromBase({
336
+ root,
337
+ sourceId: "remote",
338
+ baseFiles: baseSnapshot.files,
339
+ baseCapturedAt,
340
+ includeContent: false,
341
+ readFile: async ({ filePath }) => {
342
+ readCount += 1;
343
+ return readFile(filePath);
344
+ },
345
+ });
346
+
347
+ assert.equal(readCount, 1);
348
+ assert.deepEqual(changed.files.map((file) => file.path), ["facts/a.md", "facts/b.md"]);
349
+
350
+ let digestReadCount = 0;
351
+ const digestSnapshot = await buildOfflineSyncSnapshotFromBase({
352
+ root,
353
+ sourceId: "remote",
354
+ baseFiles: baseSnapshot.files,
355
+ baseCapturedAt,
356
+ includeContent: false,
357
+ readFile: async () => {
358
+ throw new Error("digest hook should avoid buffered reads");
359
+ },
360
+ readFileDigest: async ({ filePath }) => {
361
+ digestReadCount += 1;
362
+ const bytes = await readFile(filePath);
363
+ return {
364
+ sha256: createHash("sha256").update(bytes).digest("hex"),
365
+ bytes: bytes.byteLength,
366
+ };
367
+ },
368
+ });
369
+
370
+ assert.equal(digestReadCount, 1);
371
+ assert.deepEqual(digestSnapshot.files.map((file) => file.path), ["facts/a.md", "facts/b.md"]);
372
+ } finally {
373
+ await rm(root, { recursive: true, force: true });
374
+ }
375
+ });
376
+
377
+ test("offline snapshot from base rehashes same-size rewrites when mtime changes", async () => {
378
+ const root = await tempDir("remnic-offline-fast-base-ctime");
379
+ try {
380
+ await write(root, "facts/a.md", "alpha");
381
+ const filePath = path.join(root, "facts/a.md");
382
+ const baseSnapshot = await buildOfflineSyncSnapshot({
383
+ root,
384
+ sourceId: "remote",
385
+ includeContent: false,
386
+ });
387
+ const baseFile = baseSnapshot.files[0];
388
+ assert.ok(baseFile);
389
+ const baseCapturedAt = new Date();
390
+ await new Promise<void>((resolve) => setTimeout(resolve, 20));
391
+ await write(root, "facts/a.md", "bravo");
392
+ const rewritten = await buildOfflineSyncSnapshot({
393
+ root,
394
+ sourceId: "remote",
395
+ includeContent: false,
396
+ });
397
+ const rewrittenFile = rewritten.files[0];
398
+ assert.ok(rewrittenFile);
399
+ if (Math.abs(rewrittenFile.mtimeMs - baseFile.mtimeMs) <= 1_000) {
400
+ await utimes(filePath, (baseFile.mtimeMs + 2_000) / 1000, (baseFile.mtimeMs + 2_000) / 1000);
401
+ }
402
+
403
+ let readCount = 0;
404
+ const snapshot = await buildOfflineSyncSnapshotFromBase({
405
+ root,
406
+ sourceId: "remote",
407
+ baseFiles: baseSnapshot.files,
408
+ baseCapturedAt,
409
+ includeContent: false,
410
+ readFile: async ({ filePath }) => {
411
+ readCount += 1;
412
+ return readFile(filePath);
413
+ },
414
+ });
415
+
416
+ assert.equal(readCount, 1);
417
+ assert.equal(snapshot.files[0]?.path, "facts/a.md");
418
+ assert.notEqual(snapshot.files[0]?.sha256, baseFile.sha256);
419
+ } finally {
420
+ await rm(root, { recursive: true, force: true });
421
+ }
422
+ });
423
+
424
+ test("offline snapshot from base trusts mtime precision drift without rehashing", async () => {
425
+ const root = await tempDir("remnic-offline-fast-base-large");
426
+ try {
427
+ await write(root, "facts/a.md", "alpha");
428
+ const baseSnapshot = await buildOfflineSyncSnapshot({
429
+ root,
430
+ sourceId: "remote",
431
+ includeContent: false,
432
+ });
433
+ const baseFile = baseSnapshot.files.find((file) => file.path === "facts/a.md");
434
+ assert.ok(baseFile);
435
+ const driftedBase = { ...baseFile, mtimeMs: baseFile.mtimeMs + 500 };
436
+
437
+ const reused = await buildOfflineSyncSnapshotFromBase({
438
+ root,
439
+ sourceId: "remote",
440
+ baseFiles: [driftedBase],
441
+ baseCapturedAt: new Date(Date.now() + 60_000),
442
+ includeContent: false,
443
+ readFileDigest: async () => {
444
+ throw new Error("unchanged file should reuse trusted mtime metadata");
445
+ },
446
+ });
447
+
448
+ assert.deepEqual(reused.files, [driftedBase]);
449
+ } finally {
450
+ await rm(root, { recursive: true, force: true });
451
+ }
452
+ });
453
+
454
+ test("offline snapshot from base rehashes preserved-mtime rewrites when ctime changed after capture", async () => {
455
+ const root = await tempDir("remnic-offline-fast-base-preserved-mtime");
456
+ try {
457
+ await write(root, "facts/a.md", "alpha");
458
+ const filePath = path.join(root, "facts/a.md");
459
+ const baseSnapshot = await buildOfflineSyncSnapshot({
460
+ root,
461
+ sourceId: "remote",
462
+ includeContent: false,
463
+ });
464
+ const baseFile = baseSnapshot.files[0];
465
+ assert.ok(baseFile);
466
+ await write(root, "facts/a.md", "bravo");
467
+ await utimes(filePath, baseFile.mtimeMs / 1000, baseFile.mtimeMs / 1000);
468
+
469
+ let digestReadCount = 0;
470
+ const snapshot = await buildOfflineSyncSnapshotFromBase({
471
+ root,
472
+ sourceId: "remote",
473
+ baseFiles: baseSnapshot.files,
474
+ baseCapturedAt: new Date(0),
475
+ includeContent: false,
476
+ readFileDigest: async ({ filePath }) => {
477
+ digestReadCount += 1;
478
+ const bytes = await readFile(filePath);
479
+ return {
480
+ sha256: createHash("sha256").update(bytes).digest("hex"),
481
+ bytes: bytes.byteLength,
482
+ };
483
+ },
484
+ });
485
+
486
+ assert.equal(digestReadCount, 1);
487
+ assert.equal(snapshot.files[0]?.path, "facts/a.md");
488
+ assert.notEqual(snapshot.files[0]?.sha256, baseFile.sha256);
489
+ } finally {
490
+ await rm(root, { recursive: true, force: true });
491
+ }
492
+ });
493
+
494
+ test("offline snapshot apply preserves incoming mtime for future fast-base reuse", async () => {
495
+ const root = await tempDir("remnic-offline-apply-mtime");
496
+ try {
497
+ const content = Buffer.from("remote fact");
498
+ const remoteMtimeMs = 1_700_000_000_123;
499
+ const capturedAtMs = Date.now() + 60_000;
500
+ const snapshot = {
501
+ format: "remnic.offline-sync.snapshot.v1",
502
+ schemaVersion: 1,
503
+ createdAt: new Date(capturedAtMs).toISOString(),
504
+ sourceId: "remote",
505
+ includeTranscripts: true,
506
+ files: [{
507
+ path: "facts/a.md",
508
+ sha256: createHash("sha256").update(content).digest("hex"),
509
+ bytes: content.byteLength,
510
+ mtimeMs: remoteMtimeMs,
511
+ contentBase64: content.toString("base64"),
512
+ }],
513
+ } as const;
514
+
515
+ const applied = await applyOfflineSyncSnapshot({
516
+ root,
517
+ snapshot,
518
+ baseFiles: [],
519
+ });
520
+ assert.equal(applied.upserted, 1);
521
+ const fileStat = await stat(path.join(root, "facts/a.md"));
522
+ assert.ok(Math.abs(fileStat.mtimeMs - remoteMtimeMs) <= 1_000);
523
+
524
+ const reused = await buildOfflineSyncSnapshotFromBase({
525
+ root,
526
+ sourceId: "local",
527
+ baseFiles: applied.nextBaseFiles,
528
+ baseCapturedAt: new Date(capturedAtMs),
529
+ includeContent: false,
530
+ readFileDigest: async () => {
531
+ throw new Error("applied file should reuse preserved mtime metadata");
532
+ },
533
+ });
534
+ assert.deepEqual(reused.files, applied.nextBaseFiles);
535
+ } finally {
536
+ await rm(root, { recursive: true, force: true });
537
+ }
538
+ });
539
+
540
+ test("offline snapshot apply does not checkpoint a vanished same-hash file", async () => {
541
+ const root = await tempDir("remnic-offline-apply-mtime-vanished");
542
+ try {
543
+ const content = Buffer.from("remote fact");
544
+ const file = {
545
+ path: "facts/a.md",
546
+ sha256: createHash("sha256").update(content).digest("hex"),
547
+ bytes: content.byteLength,
548
+ mtimeMs: 1_700_000_000_123,
549
+ };
550
+
551
+ const applied = await applyOfflineSyncSnapshot({
552
+ root,
553
+ snapshot: {
554
+ format: "remnic.offline-sync.snapshot.v1",
555
+ schemaVersion: 1,
556
+ createdAt: new Date().toISOString(),
557
+ sourceId: "remote",
558
+ includeTranscripts: true,
559
+ files: [file],
560
+ },
561
+ baseFiles: [],
562
+ currentFiles: [file],
563
+ });
564
+
565
+ assert.equal(applied.upserted, 0);
566
+ assert.equal(applied.pendingLocal, 1);
567
+ assert.equal(applied.skipped, 1);
568
+ assert.deepEqual(applied.nextBaseFiles, []);
569
+ } finally {
570
+ await rm(root, { recursive: true, force: true });
571
+ }
572
+ });
573
+
574
+ test("offline changeset rewrites a same-hash file that vanished before mtime alignment", async () => {
575
+ const root = await tempDir("remnic-offline-apply-changeset-mtime-vanished");
576
+ try {
577
+ const content = Buffer.from("remote fact");
578
+ const file = {
579
+ path: "facts/a.md",
580
+ sha256: createHash("sha256").update(content).digest("hex"),
581
+ bytes: content.byteLength,
582
+ mtimeMs: 1_700_000_000_123,
583
+ contentBase64: content.toString("base64"),
584
+ };
585
+
586
+ const applied = await applyOfflineSyncChangeset({
587
+ root,
588
+ changeset: {
589
+ format: "remnic.offline-sync.changeset.v1",
590
+ schemaVersion: 1,
591
+ createdAt: new Date().toISOString(),
592
+ sourceId: "laptop",
593
+ includeTranscripts: true,
594
+ changes: [{
595
+ type: "upsert",
596
+ path: file.path,
597
+ file,
598
+ }],
599
+ },
600
+ currentFiles: [{
601
+ path: file.path,
602
+ sha256: file.sha256,
603
+ bytes: file.bytes,
604
+ mtimeMs: file.mtimeMs,
605
+ }],
606
+ });
607
+
608
+ assert.equal(applied.appliedUpserts, 1);
609
+ assert.equal(applied.skipped, 0);
610
+ assert.equal(await readUtf8(root, file.path), content.toString("utf-8"));
611
+ } finally {
612
+ await rm(root, { recursive: true, force: true });
613
+ }
614
+ });
615
+
303
616
  test("offline sync accepts durable runtime records from older peers", async () => {
304
617
  const root = await tempDir("remnic-offline-legacy-runtime-state");
305
618
  try {
@@ -551,6 +864,23 @@ test("offline sync applies chunked file content with base conflict checks", asyn
551
864
  assert.equal(second.done, true);
552
865
  assert.equal(second.applied, true);
553
866
  assert.equal(await readUtf8(root, "state/lcm.sqlite"), "new durable sqlite content");
867
+ const appliedStat = await stat(path.join(root, "state/lcm.sqlite"));
868
+ assert.ok(Math.abs(appliedStat.mtimeMs - 123) <= 1_000);
869
+
870
+ const retry = await applyOfflineSyncFileContentChunk({
871
+ root,
872
+ sourceId: "laptop",
873
+ path: "state/lcm.sqlite",
874
+ sha256: nextSha,
875
+ bytes: next.byteLength,
876
+ mtimeMs: 123,
877
+ offset: 0,
878
+ baseSha256: oldSha,
879
+ content: next.subarray(0, 8),
880
+ });
881
+ assert.equal(retry.done, true);
882
+ assert.equal(retry.skipped, true);
883
+ assert.equal(retry.conflict, undefined);
554
884
 
555
885
  const conflictContent = Buffer.from("conflicting local sqlite");
556
886
  const conflictSha = createHash("sha256").update(conflictContent).digest("hex");
@@ -566,9 +896,105 @@ test("offline sync applies chunked file content with base conflict checks", asyn
566
896
  content: conflictContent,
567
897
  });
568
898
  assert.equal(conflict.done, true);
899
+ assert.equal(conflict.chunkBytes, 0);
569
900
  assert.equal(conflict.applied, false);
570
901
  assert.equal(conflict.conflict?.reason, "remote_changed_for_local_update");
571
902
  assert.equal(await readUtf8(root, "state/lcm.sqlite"), "new durable sqlite content");
903
+
904
+ const partialConflictContent = Buffer.from("another conflicting sqlite payload");
905
+ const partialConflictSha = createHash("sha256").update(partialConflictContent).digest("hex");
906
+ const partialConflict = await applyOfflineSyncFileContentChunk({
907
+ root,
908
+ sourceId: "laptop",
909
+ path: "state/lcm.sqlite",
910
+ sha256: partialConflictSha,
911
+ bytes: partialConflictContent.byteLength,
912
+ mtimeMs: 789,
913
+ offset: 0,
914
+ baseSha256: oldSha,
915
+ content: partialConflictContent.subarray(0, 8),
916
+ });
917
+ assert.equal(partialConflict.done, true);
918
+ assert.equal(partialConflict.chunkBytes, 0);
919
+ assert.equal(partialConflict.applied, false);
920
+ assert.equal(partialConflict.conflict?.reason, "remote_changed_for_local_update");
921
+ assert.equal(await readUtf8(root, "state/lcm.sqlite"), "new durable sqlite content");
922
+ } finally {
923
+ await rm(root, { recursive: true, force: true });
924
+ }
925
+ });
926
+
927
+ test("offline sync chunked apply lets incoming runtime state replace local conflicts", async () => {
928
+ const root = await tempDir("remnic-offline-file-content-runtime");
929
+ try {
930
+ const relPath = "state/memory-lifecycle-ledger.jsonl";
931
+ await write(root, relPath, "local ledger\n");
932
+ const baseSha = createHash("sha256").update("base ledger\n").digest("hex");
933
+ const next = Buffer.from("remote ledger\n");
934
+ const nextSha = createHash("sha256").update(next).digest("hex");
935
+
936
+ const result = await applyOfflineSyncFileContentChunk({
937
+ root,
938
+ sourceId: "remote",
939
+ path: relPath,
940
+ sha256: nextSha,
941
+ bytes: next.byteLength,
942
+ mtimeMs: 123,
943
+ offset: 0,
944
+ baseSha256: baseSha,
945
+ content: next,
946
+ });
947
+
948
+ assert.equal(result.done, true);
949
+ assert.equal(result.applied, true);
950
+ assert.equal(result.conflict, undefined);
951
+ assert.equal(await readUtf8(root, relPath), "remote ledger\n");
952
+ } finally {
953
+ await rm(root, { recursive: true, force: true });
954
+ }
955
+ });
956
+
957
+ test("offline sync chunked apply skips final conflict when target already matches incoming", async () => {
958
+ const root = await tempDir("remnic-offline-file-content-final-skip");
959
+ try {
960
+ const relPath = "facts/chunked.md";
961
+ await write(root, relPath, "old chunked content");
962
+ const oldSha = createHash("sha256").update("old chunked content").digest("hex");
963
+ const next = Buffer.from("new chunked content that arrives in pieces");
964
+ const nextSha = createHash("sha256").update(next).digest("hex");
965
+
966
+ const first = await applyOfflineSyncFileContentChunk({
967
+ root,
968
+ sourceId: "laptop",
969
+ path: relPath,
970
+ sha256: nextSha,
971
+ bytes: next.byteLength,
972
+ mtimeMs: 456,
973
+ offset: 0,
974
+ baseSha256: oldSha,
975
+ content: next.subarray(0, 8),
976
+ });
977
+ assert.equal(first.done, false);
978
+
979
+ await write(root, relPath, next);
980
+ const second = await applyOfflineSyncFileContentChunk({
981
+ root,
982
+ sourceId: "laptop",
983
+ path: relPath,
984
+ sha256: nextSha,
985
+ bytes: next.byteLength,
986
+ mtimeMs: 456,
987
+ offset: 8,
988
+ baseSha256: oldSha,
989
+ content: next.subarray(8),
990
+ });
991
+
992
+ assert.equal(second.done, true);
993
+ assert.equal(second.applied, false);
994
+ assert.equal(second.skipped, true);
995
+ assert.equal(second.conflict, undefined);
996
+ assert.equal(await readUtf8(root, relPath), next.toString("utf-8"));
997
+ assert.equal((await readdir(path.join(root, ".offline-sync", "uploads"))).length, 0);
572
998
  } finally {
573
999
  await rm(root, { recursive: true, force: true });
574
1000
  }
@@ -704,6 +1130,120 @@ test("offline sync prunes stale staged uploads when starting a new upload", asyn
704
1130
  }
705
1131
  });
706
1132
 
1133
+ test("offline snapshot apply follows remote deletions for remote-authoritative runtime state", async () => {
1134
+ const root = await tempDir("remnic-offline-runtime-delete");
1135
+ try {
1136
+ const relPath = "state/buffer.json";
1137
+ const baseContent = Buffer.from("{\"turns\":[]}");
1138
+ const localContent = Buffer.from("{\"turns\":[\"local\"]}");
1139
+ await write(root, relPath, localContent);
1140
+
1141
+ const pull = await applyOfflineSyncSnapshot({
1142
+ root,
1143
+ snapshot: {
1144
+ format: "remnic.offline-sync.snapshot.v1",
1145
+ schemaVersion: 1,
1146
+ createdAt: new Date().toISOString(),
1147
+ sourceId: "remote",
1148
+ includeTranscripts: true,
1149
+ files: [],
1150
+ },
1151
+ baseFiles: [{
1152
+ path: relPath,
1153
+ sha256: createHash("sha256").update(baseContent).digest("hex"),
1154
+ bytes: baseContent.byteLength,
1155
+ mtimeMs: 0,
1156
+ }],
1157
+ });
1158
+
1159
+ assert.equal(pull.deleted, 1);
1160
+ assert.equal(pull.conflicts.length, 0);
1161
+ assert.deepEqual(pull.nextBaseFiles, []);
1162
+ await assert.rejects(
1163
+ () => readFile(path.join(root, relPath)),
1164
+ /ENOENT/,
1165
+ );
1166
+ } finally {
1167
+ await rm(root, { recursive: true, force: true });
1168
+ }
1169
+ });
1170
+
1171
+ test("offline snapshot apply preserves new local runtime files without a base", async () => {
1172
+ const root = await tempDir("remnic-offline-runtime-local-create");
1173
+ try {
1174
+ const relPath = "state/buffer.json";
1175
+ const localContent = "{\"turns\":[\"local\"]}";
1176
+ await write(root, relPath, localContent);
1177
+
1178
+ const pull = await applyOfflineSyncSnapshot({
1179
+ root,
1180
+ snapshot: {
1181
+ format: "remnic.offline-sync.snapshot.v1",
1182
+ schemaVersion: 1,
1183
+ createdAt: new Date().toISOString(),
1184
+ sourceId: "remote",
1185
+ includeTranscripts: true,
1186
+ files: [],
1187
+ },
1188
+ baseFiles: [],
1189
+ });
1190
+
1191
+ assert.equal(pull.deleted, 0);
1192
+ assert.equal(pull.pendingLocal, 1);
1193
+ assert.equal(pull.skipped, 1);
1194
+ assert.equal(pull.conflicts.length, 0);
1195
+ assert.deepEqual(pull.nextBaseFiles, []);
1196
+ assert.equal(await readUtf8(root, relPath), localContent);
1197
+ } finally {
1198
+ await rm(root, { recursive: true, force: true });
1199
+ }
1200
+ });
1201
+
1202
+ test("offline snapshot apply restores locally deleted remote-authoritative runtime files", async () => {
1203
+ const root = await tempDir("remnic-offline-runtime-local-delete-restore");
1204
+ try {
1205
+ const relPath = "state/buffer.json";
1206
+ const remoteContent = Buffer.from("{\"turns\":[]}");
1207
+ const remoteFile = {
1208
+ path: relPath,
1209
+ sha256: createHash("sha256").update(remoteContent).digest("hex"),
1210
+ bytes: remoteContent.byteLength,
1211
+ mtimeMs: 1,
1212
+ contentBase64: remoteContent.toString("base64"),
1213
+ };
1214
+
1215
+ const pull = await applyOfflineSyncSnapshot({
1216
+ root,
1217
+ snapshot: {
1218
+ format: "remnic.offline-sync.snapshot.v1",
1219
+ schemaVersion: 1,
1220
+ createdAt: new Date().toISOString(),
1221
+ sourceId: "remote",
1222
+ includeTranscripts: true,
1223
+ files: [remoteFile],
1224
+ },
1225
+ baseFiles: [{
1226
+ path: remoteFile.path,
1227
+ sha256: remoteFile.sha256,
1228
+ bytes: remoteFile.bytes,
1229
+ mtimeMs: remoteFile.mtimeMs,
1230
+ }],
1231
+ });
1232
+
1233
+ assert.equal(pull.upserted, 1);
1234
+ assert.equal(pull.conflicts.length, 0);
1235
+ assert.equal(await readUtf8(root, relPath), remoteContent.toString("utf-8"));
1236
+ assert.deepEqual(pull.nextBaseFiles, [{
1237
+ path: remoteFile.path,
1238
+ sha256: remoteFile.sha256,
1239
+ bytes: remoteFile.bytes,
1240
+ mtimeMs: remoteFile.mtimeMs,
1241
+ }]);
1242
+ } finally {
1243
+ await rm(root, { recursive: true, force: true });
1244
+ }
1245
+ });
1246
+
707
1247
  test("offline changeset pushes local edits when the remote is still at the shared base", async () => {
708
1248
  const remote = await tempDir("remnic-offline-remote");
709
1249
  const local = await tempDir("remnic-offline-local");
@@ -803,6 +1343,68 @@ test("offline changeset can exclude directly pushed large files without reading
803
1343
  }
804
1344
  });
805
1345
 
1346
+ test("offline changeset skips local edits to remote-authoritative runtime state", async () => {
1347
+ const local = await tempDir("remnic-offline-runtime-authoritative-push");
1348
+ try {
1349
+ const relPath = "state/buffer.json";
1350
+ const baseContent = Buffer.from("{\"turns\":[]}");
1351
+ const localContent = Buffer.from("{\"turns\":[\"local\"]}");
1352
+ await write(local, relPath, localContent);
1353
+ const baseFile = {
1354
+ path: relPath,
1355
+ sha256: createHash("sha256").update(baseContent).digest("hex"),
1356
+ bytes: baseContent.byteLength,
1357
+ mtimeMs: 1,
1358
+ };
1359
+ const currentFile = {
1360
+ path: relPath,
1361
+ sha256: createHash("sha256").update(localContent).digest("hex"),
1362
+ bytes: localContent.byteLength,
1363
+ mtimeMs: 2,
1364
+ };
1365
+
1366
+ assert.equal(
1367
+ (await summarizeOfflineSyncPendingChanges({
1368
+ root: local,
1369
+ sourceId: "laptop",
1370
+ baseFiles: [baseFile],
1371
+ })).total,
1372
+ 0,
1373
+ );
1374
+
1375
+ const changeset = await buildOfflineSyncChangesetFromSnapshot({
1376
+ root: local,
1377
+ sourceId: "laptop",
1378
+ baseFiles: [baseFile],
1379
+ currentFiles: [currentFile],
1380
+ readFile: async () => {
1381
+ throw new Error("runtime state should not be read for push");
1382
+ },
1383
+ });
1384
+ assert.equal(changeset.changes.length, 0);
1385
+
1386
+ assert.equal(
1387
+ summarizeOfflineSyncPendingFiles({
1388
+ baseFiles: [baseFile],
1389
+ currentFiles: [],
1390
+ }).total,
1391
+ 0,
1392
+ );
1393
+ const deleteChangeset = await buildOfflineSyncChangesetFromSnapshot({
1394
+ root: local,
1395
+ sourceId: "laptop",
1396
+ baseFiles: [baseFile],
1397
+ currentFiles: [],
1398
+ readFile: async () => {
1399
+ throw new Error("deleted runtime state should not be read for push");
1400
+ },
1401
+ });
1402
+ assert.equal(deleteChangeset.changes.length, 0);
1403
+ } finally {
1404
+ await rm(local, { recursive: true, force: true });
1405
+ }
1406
+ });
1407
+
806
1408
  test("offline pull accepts metadata-only snapshots when files are unchanged", async () => {
807
1409
  const remote = await tempDir("remnic-offline-metadata-remote");
808
1410
  const local = await tempDir("remnic-offline-metadata-local");
@@ -838,6 +1440,92 @@ test("offline pull accepts metadata-only snapshots when files are unchanged", as
838
1440
  }
839
1441
  });
840
1442
 
1443
+ test("offline pull lets incoming runtime state replace local runtime conflicts", async () => {
1444
+ const root = await tempDir("remnic-offline-runtime-authoritative-pull");
1445
+ try {
1446
+ const relPath = "state/buffer.json";
1447
+ const baseContent = Buffer.from("{\"turns\":[]}");
1448
+ const localContent = Buffer.from("{\"turns\":[\"local\"]}");
1449
+ const remoteContent = Buffer.from("{\"turns\":[\"remote\"]}");
1450
+ await write(root, relPath, localContent);
1451
+ const baseFile = {
1452
+ path: relPath,
1453
+ sha256: createHash("sha256").update(baseContent).digest("hex"),
1454
+ bytes: baseContent.byteLength,
1455
+ mtimeMs: 1,
1456
+ };
1457
+ const incomingFile = {
1458
+ path: relPath,
1459
+ sha256: createHash("sha256").update(remoteContent).digest("hex"),
1460
+ bytes: remoteContent.byteLength,
1461
+ mtimeMs: 2,
1462
+ contentBase64: remoteContent.toString("base64"),
1463
+ };
1464
+
1465
+ const pull = await applyOfflineSyncSnapshot({
1466
+ root,
1467
+ snapshot: {
1468
+ format: "remnic.offline-sync.snapshot.v1",
1469
+ schemaVersion: 1,
1470
+ createdAt: new Date().toISOString(),
1471
+ sourceId: "remote",
1472
+ includeTranscripts: true,
1473
+ files: [incomingFile],
1474
+ },
1475
+ baseFiles: [baseFile],
1476
+ });
1477
+
1478
+ assert.equal(pull.upserted, 1);
1479
+ assert.equal(pull.conflicts.length, 0);
1480
+ assert.equal(await readUtf8(root, relPath), remoteContent.toString("utf-8"));
1481
+ assert.deepEqual(pull.nextBaseFiles, [{
1482
+ path: relPath,
1483
+ sha256: incomingFile.sha256,
1484
+ bytes: incomingFile.bytes,
1485
+ mtimeMs: incomingFile.mtimeMs,
1486
+ }]);
1487
+ } finally {
1488
+ await rm(root, { recursive: true, force: true });
1489
+ }
1490
+ });
1491
+
1492
+ test("offline pull skips local runtime drift when remote still matches base", async () => {
1493
+ const root = await tempDir("remnic-offline-runtime-authoritative-base");
1494
+ try {
1495
+ const relPath = "state/buffer.json";
1496
+ const baseContent = Buffer.from("{\"turns\":[]}");
1497
+ const localContent = Buffer.from("{\"turns\":[\"local\"]}");
1498
+ await write(root, relPath, localContent);
1499
+ const baseFile = {
1500
+ path: relPath,
1501
+ sha256: createHash("sha256").update(baseContent).digest("hex"),
1502
+ bytes: baseContent.byteLength,
1503
+ mtimeMs: 1,
1504
+ };
1505
+
1506
+ const pull = await applyOfflineSyncSnapshot({
1507
+ root,
1508
+ snapshot: {
1509
+ format: "remnic.offline-sync.snapshot.v1",
1510
+ schemaVersion: 1,
1511
+ createdAt: new Date().toISOString(),
1512
+ sourceId: "remote",
1513
+ includeTranscripts: true,
1514
+ files: [baseFile],
1515
+ },
1516
+ baseFiles: [baseFile],
1517
+ });
1518
+
1519
+ assert.equal(pull.upserted, 0);
1520
+ assert.equal(pull.skipped, 1);
1521
+ assert.equal(pull.pendingLocal, 0);
1522
+ assert.equal(pull.conflicts.length, 0);
1523
+ assert.equal(await readUtf8(root, relPath), localContent.toString("utf-8"));
1524
+ } finally {
1525
+ await rm(root, { recursive: true, force: true });
1526
+ }
1527
+ });
1528
+
841
1529
  test("offline pull applies snapshots with content only for remote-changed files", async () => {
842
1530
  const remote = await tempDir("remnic-offline-partial-remote");
843
1531
  const local = await tempDir("remnic-offline-partial-local");
@@ -934,6 +1622,46 @@ test("offline pull preserves local edits when both sides changed since the base"
934
1622
  }
935
1623
  });
936
1624
 
1625
+ test("offline pull can record conflicts without hydrating oversized incoming content", async () => {
1626
+ const remote = await tempDir("remnic-offline-metadata-conflict-remote");
1627
+ const local = await tempDir("remnic-offline-metadata-conflict-local");
1628
+ try {
1629
+ await write(remote, "state/lcm.sqlite", "base");
1630
+ const initial = await buildOfflineSyncSnapshot({
1631
+ root: remote,
1632
+ sourceId: "remote",
1633
+ includeContent: true,
1634
+ });
1635
+ const firstPull = await applyOfflineSyncSnapshot({
1636
+ root: local,
1637
+ snapshot: initial,
1638
+ });
1639
+
1640
+ await write(local, "state/lcm.sqlite", "local edit");
1641
+ await write(remote, "state/lcm.sqlite", "remote edit");
1642
+ const metadataOnly = await buildOfflineSyncSnapshot({
1643
+ root: remote,
1644
+ sourceId: "remote",
1645
+ includeContent: false,
1646
+ });
1647
+
1648
+ const secondPull = await applyOfflineSyncSnapshot({
1649
+ root: local,
1650
+ snapshot: metadataOnly,
1651
+ baseFiles: firstPull.nextBaseFiles,
1652
+ allowMissingConflictContent: true,
1653
+ });
1654
+
1655
+ assert.equal(secondPull.conflicts.length, 1);
1656
+ assert.equal(secondPull.conflicts[0]?.reason, "both_modified");
1657
+ assert.equal(secondPull.conflicts[0]?.conflictPath, undefined);
1658
+ assert.equal(await readUtf8(local, "state/lcm.sqlite"), "local edit");
1659
+ } finally {
1660
+ await rm(remote, { recursive: true, force: true });
1661
+ await rm(local, { recursive: true, force: true });
1662
+ }
1663
+ });
1664
+
937
1665
  test("offline push preserves remote edits when both sides changed since the base", async () => {
938
1666
  const remote = await tempDir("remnic-offline-push-conflict-remote");
939
1667
  const local = await tempDir("remnic-offline-push-conflict-local");
@@ -1141,6 +1869,80 @@ test("offline payloads require explicit includeTranscripts booleans", async () =
1141
1869
  }
1142
1870
  });
1143
1871
 
1872
+ test("offline payloads reject mtimes outside JavaScript Date range", async () => {
1873
+ const root = await tempDir("remnic-offline-invalid-mtime");
1874
+ try {
1875
+ const content = Buffer.from("remote");
1876
+ const sha256 = createHash("sha256").update(content).digest("hex");
1877
+ const mtimeMs = OFFLINE_SYNC_MAX_MTIME_MS + 1;
1878
+
1879
+ await assert.rejects(
1880
+ () =>
1881
+ applyOfflineSyncSnapshot({
1882
+ root,
1883
+ snapshot: {
1884
+ format: "remnic.offline-sync.snapshot.v1",
1885
+ schemaVersion: 1,
1886
+ createdAt: new Date().toISOString(),
1887
+ sourceId: "remote",
1888
+ includeTranscripts: true,
1889
+ files: [{
1890
+ path: "facts/remote.md",
1891
+ sha256,
1892
+ bytes: content.byteLength,
1893
+ mtimeMs,
1894
+ contentBase64: content.toString("base64"),
1895
+ }],
1896
+ },
1897
+ }),
1898
+ /files\[0\]\.mtimeMs must be within JavaScript Date range/,
1899
+ );
1900
+
1901
+ await assert.rejects(
1902
+ () =>
1903
+ applyOfflineSyncChangeset({
1904
+ root,
1905
+ changeset: {
1906
+ format: "remnic.offline-sync.changeset.v1",
1907
+ schemaVersion: 1,
1908
+ createdAt: new Date().toISOString(),
1909
+ sourceId: "laptop",
1910
+ includeTranscripts: true,
1911
+ changes: [{
1912
+ type: "upsert",
1913
+ path: "facts/remote.md",
1914
+ file: {
1915
+ path: "facts/remote.md",
1916
+ sha256,
1917
+ bytes: content.byteLength,
1918
+ mtimeMs,
1919
+ contentBase64: content.toString("base64"),
1920
+ },
1921
+ }],
1922
+ },
1923
+ }),
1924
+ /offline sync changeset invalid: changes\[0\]\.file\.mtimeMs must be within JavaScript Date range/,
1925
+ );
1926
+
1927
+ await assert.rejects(
1928
+ () =>
1929
+ applyOfflineSyncFileContentChunk({
1930
+ root,
1931
+ sourceId: "remote",
1932
+ path: "facts/remote.md",
1933
+ sha256,
1934
+ bytes: content.byteLength,
1935
+ mtimeMs,
1936
+ offset: 0,
1937
+ content,
1938
+ }),
1939
+ /mtimeMs must be within JavaScript Date range/,
1940
+ );
1941
+ } finally {
1942
+ await rm(root, { recursive: true, force: true });
1943
+ }
1944
+ });
1945
+
1144
1946
  test("offline payloads reject excluded internal paths", async () => {
1145
1947
  const root = await tempDir("remnic-offline-internal-path-invalid");
1146
1948
  try {
@@ -1225,6 +2027,13 @@ test("offline sync applies and snapshots through secure storage hooks", async ()
1225
2027
  (await storage.readOfflineSyncFile(path.join(root, "facts", "secure.md"))).toString("utf8"),
1226
2028
  "secret fact",
1227
2029
  );
2030
+ assert.deepEqual(
2031
+ await storage.digestOfflineSyncFile(path.join(root, "facts", "secure.md")),
2032
+ {
2033
+ sha256: createHash("sha256").update("secret fact").digest("hex"),
2034
+ bytes: Buffer.byteLength("secret fact"),
2035
+ },
2036
+ );
1228
2037
 
1229
2038
  const snapshot = await buildOfflineSyncSnapshot({
1230
2039
  root,
@@ -1237,6 +2046,21 @@ test("offline sync applies and snapshots through secure storage hooks", async ()
1237
2046
  "secret fact",
1238
2047
  );
1239
2048
  assert.equal(snapshot.files[0]?.bytes, Buffer.byteLength("secret fact"));
2049
+ const encryptedStat = await stat(path.join(root, "facts", "secure.md"));
2050
+ assert.notEqual(snapshot.files[0]?.bytes, encryptedStat.size);
2051
+ let digestReads = 0;
2052
+ const fastBase = await buildOfflineSyncSnapshotFromBase({
2053
+ root,
2054
+ sourceId: "remote",
2055
+ baseFiles: snapshot.files,
2056
+ baseCapturedAt: new Date(),
2057
+ readFileDigest: async ({ filePath }) => {
2058
+ digestReads += 1;
2059
+ return storage.digestOfflineSyncFile(filePath);
2060
+ },
2061
+ });
2062
+ assert.equal(digestReads, 1);
2063
+ assert.deepEqual(fastBase.files, snapshot.files.map(({ contentBase64: _contentBase64, ...file }) => file));
1240
2064
 
1241
2065
  const sqlite = Buffer.from("streamed durable sqlite content");
1242
2066
  const sqliteSha = createHash("sha256").update(sqlite).digest("hex");
@@ -1250,6 +2074,7 @@ test("offline sync applies and snapshots through secure storage hooks", async ()
1250
2074
  offset: 0,
1251
2075
  content: sqlite.subarray(0, 8),
1252
2076
  readFile: async ({ filePath }) => storage.readOfflineSyncFile(filePath),
2077
+ readFileDigest: async ({ filePath }) => storage.digestOfflineSyncFile(filePath),
1253
2078
  writeFile: async ({ filePath, content }) => storage.writeOfflineSyncFile(filePath, content),
1254
2079
  writeStagingFile: async ({ filePath, content }) => storage.writeOfflineSyncStagingFile(filePath, content),
1255
2080
  writeFileChunks: async ({ filePath, chunks }) => storage.writeOfflineSyncFileChunks(filePath, chunks),
@@ -1265,6 +2090,7 @@ test("offline sync applies and snapshots through secure storage hooks", async ()
1265
2090
  offset: 8,
1266
2091
  content: sqlite.subarray(8),
1267
2092
  readFile: async ({ filePath }) => storage.readOfflineSyncFile(filePath),
2093
+ readFileDigest: async ({ filePath }) => storage.digestOfflineSyncFile(filePath),
1268
2094
  writeFile: async ({ filePath, content }) => storage.writeOfflineSyncFile(filePath, content),
1269
2095
  writeStagingFile: async ({ filePath, content }) => storage.writeOfflineSyncStagingFile(filePath, content),
1270
2096
  writeFileChunks: async ({ filePath, chunks }) => storage.writeOfflineSyncFileChunks(filePath, chunks),
@@ -1282,6 +2108,47 @@ test("offline sync applies and snapshots through secure storage hooks", async ()
1282
2108
  }
1283
2109
  });
1284
2110
 
2111
+ test("offline snapshot fast-base rehashes when encrypted header probe fails", async () => {
2112
+ const root = await tempDir("remnic-offline-fast-base-probe-error");
2113
+ const relPath = "facts/same-size.md";
2114
+ const oldContent = Buffer.from("old!");
2115
+ const newContent = Buffer.from("new!");
2116
+ const filePath = path.join(root, relPath);
2117
+ try {
2118
+ await write(root, relPath, newContent);
2119
+ await chmod(filePath, 0o000);
2120
+ const st = await stat(filePath);
2121
+ let digestReads = 0;
2122
+
2123
+ const snapshot = await buildOfflineSyncSnapshotFromBase({
2124
+ root,
2125
+ sourceId: "remote",
2126
+ baseFiles: [{
2127
+ path: relPath,
2128
+ sha256: createHash("sha256").update(oldContent).digest("hex"),
2129
+ bytes: newContent.byteLength,
2130
+ mtimeMs: st.mtimeMs,
2131
+ }],
2132
+ baseCapturedAt: new Date(),
2133
+ readFileDigest: async ({ path: targetPath, filePath: targetFilePath }) => {
2134
+ assert.equal(targetPath, relPath);
2135
+ assert.equal(targetFilePath, filePath);
2136
+ digestReads += 1;
2137
+ return {
2138
+ sha256: createHash("sha256").update(newContent).digest("hex"),
2139
+ bytes: newContent.byteLength,
2140
+ };
2141
+ },
2142
+ });
2143
+
2144
+ assert.equal(digestReads, 1);
2145
+ assert.equal(snapshot.files[0]?.sha256, createHash("sha256").update(newContent).digest("hex"));
2146
+ } finally {
2147
+ await chmod(filePath, 0o600).catch(() => {});
2148
+ await rm(root, { recursive: true, force: true });
2149
+ }
2150
+ });
2151
+
1285
2152
  test("offline storage writes invalidate fact hash readiness for rebuild", async () => {
1286
2153
  const root = await tempDir("remnic-offline-hash-index-local");
1287
2154
  const source = await tempDir("remnic-offline-hash-index-source");
@@ -1323,6 +2190,193 @@ test("offline storage writes invalidate fact hash readiness for rebuild", async
1323
2190
  }
1324
2191
  });
1325
2192
 
2193
+ test("offline changeset apply fingerprints only changed paths when current files are omitted", async () => {
2194
+ const root = await tempDir("remnic-offline-apply-path-scoped");
2195
+ try {
2196
+ const oldContent = Buffer.from("old target");
2197
+ const newContent = Buffer.from("new target");
2198
+ await write(root, "facts/target.md", oldContent);
2199
+ await write(root, "facts/unrelated.md", "do not scan me");
2200
+ const digestPaths: string[] = [];
2201
+
2202
+ const apply = await applyOfflineSyncChangeset({
2203
+ root,
2204
+ changeset: {
2205
+ format: "remnic.offline-sync.changeset.v1",
2206
+ schemaVersion: 1,
2207
+ createdAt: new Date().toISOString(),
2208
+ sourceId: "laptop",
2209
+ includeTranscripts: true,
2210
+ changes: [{
2211
+ type: "upsert",
2212
+ path: "facts/target.md",
2213
+ baseSha256: createHash("sha256").update(oldContent).digest("hex"),
2214
+ file: {
2215
+ path: "facts/target.md",
2216
+ sha256: createHash("sha256").update(newContent).digest("hex"),
2217
+ bytes: newContent.byteLength,
2218
+ mtimeMs: Date.now(),
2219
+ contentBase64: newContent.toString("base64"),
2220
+ },
2221
+ }],
2222
+ },
2223
+ returnCurrentFiles: false,
2224
+ readFileDigest: async ({ path: relPath, filePath }) => {
2225
+ digestPaths.push(relPath);
2226
+ if (relPath === "facts/unrelated.md") {
2227
+ throw new Error("apply scanned an unrelated file");
2228
+ }
2229
+ const content = await readFile(filePath);
2230
+ return {
2231
+ sha256: createHash("sha256").update(content).digest("hex"),
2232
+ bytes: content.byteLength,
2233
+ };
2234
+ },
2235
+ });
2236
+
2237
+ assert.equal(apply.appliedUpserts, 1);
2238
+ assert.equal(apply.conflicts.length, 0);
2239
+ assert.equal(apply.currentFilesComplete, false);
2240
+ assert.deepEqual(digestPaths, ["facts/target.md"]);
2241
+ assert.equal(await readUtf8(root, "facts/target.md"), "new target");
2242
+ } finally {
2243
+ await rm(root, { recursive: true, force: true });
2244
+ }
2245
+ });
2246
+
2247
+ test("offline changeset apply returns full current files by default", async () => {
2248
+ const root = await tempDir("remnic-offline-apply-full-result");
2249
+ try {
2250
+ const oldContent = Buffer.from("old target");
2251
+ const newContent = Buffer.from("new target");
2252
+ await write(root, "facts/target.md", oldContent);
2253
+ await write(root, "facts/unrelated.md", "keep me");
2254
+
2255
+ const apply = await applyOfflineSyncChangeset({
2256
+ root,
2257
+ changeset: {
2258
+ format: "remnic.offline-sync.changeset.v1",
2259
+ schemaVersion: 1,
2260
+ createdAt: new Date().toISOString(),
2261
+ sourceId: "laptop",
2262
+ includeTranscripts: true,
2263
+ changes: [{
2264
+ type: "upsert",
2265
+ path: "facts/target.md",
2266
+ baseSha256: createHash("sha256").update(oldContent).digest("hex"),
2267
+ file: {
2268
+ path: "facts/target.md",
2269
+ sha256: createHash("sha256").update(newContent).digest("hex"),
2270
+ bytes: newContent.byteLength,
2271
+ mtimeMs: Date.now(),
2272
+ contentBase64: newContent.toString("base64"),
2273
+ },
2274
+ }],
2275
+ },
2276
+ });
2277
+
2278
+ assert.equal(apply.currentFilesComplete, undefined);
2279
+ assert.deepEqual(
2280
+ apply.currentFiles.map((entry) => entry.path),
2281
+ ["facts/target.md", "facts/unrelated.md"],
2282
+ );
2283
+ } finally {
2284
+ await rm(root, { recursive: true, force: true });
2285
+ }
2286
+ });
2287
+
2288
+ test("offline changeset apply refreshes full current files when caller supplied conflict snapshot", async () => {
2289
+ const root = await tempDir("remnic-offline-apply-supplied-current-result");
2290
+ try {
2291
+ const oldContent = Buffer.from("old target");
2292
+ const newContent = Buffer.from("new target");
2293
+ await write(root, "facts/target.md", oldContent);
2294
+ await write(root, "facts/unrelated.md", "keep me");
2295
+
2296
+ const apply = await applyOfflineSyncChangeset({
2297
+ root,
2298
+ currentFiles: [{
2299
+ path: "facts/target.md",
2300
+ sha256: createHash("sha256").update(oldContent).digest("hex"),
2301
+ bytes: oldContent.byteLength,
2302
+ mtimeMs: 0,
2303
+ }],
2304
+ changeset: {
2305
+ format: "remnic.offline-sync.changeset.v1",
2306
+ schemaVersion: 1,
2307
+ createdAt: new Date().toISOString(),
2308
+ sourceId: "laptop",
2309
+ includeTranscripts: true,
2310
+ changes: [{
2311
+ type: "upsert",
2312
+ path: "facts/target.md",
2313
+ baseSha256: createHash("sha256").update(oldContent).digest("hex"),
2314
+ file: {
2315
+ path: "facts/target.md",
2316
+ sha256: createHash("sha256").update(newContent).digest("hex"),
2317
+ bytes: newContent.byteLength,
2318
+ mtimeMs: Date.now(),
2319
+ contentBase64: newContent.toString("base64"),
2320
+ },
2321
+ }],
2322
+ },
2323
+ });
2324
+
2325
+ assert.equal(apply.currentFilesComplete, undefined);
2326
+ assert.deepEqual(
2327
+ apply.currentFiles.map((entry) => entry.path),
2328
+ ["facts/target.md", "facts/unrelated.md"],
2329
+ );
2330
+ } finally {
2331
+ await rm(root, { recursive: true, force: true });
2332
+ }
2333
+ });
2334
+
2335
+ test("offline snapshot apply defers volatile remote paths without changing local files", async () => {
2336
+ const root = await tempDir("remnic-offline-deferred-remote");
2337
+ try {
2338
+ const relPath = "state/memory-lifecycle-ledger.jsonl";
2339
+ const localContent = Buffer.from("base ledger\n");
2340
+ const remoteContent = Buffer.from("remote ledger changed during fetch\n");
2341
+ await write(root, relPath, localContent);
2342
+ const baseFile = {
2343
+ path: relPath,
2344
+ sha256: createHash("sha256").update(localContent).digest("hex"),
2345
+ bytes: localContent.byteLength,
2346
+ mtimeMs: 1,
2347
+ };
2348
+ const incomingFile = {
2349
+ path: relPath,
2350
+ sha256: createHash("sha256").update(remoteContent).digest("hex"),
2351
+ bytes: remoteContent.byteLength,
2352
+ mtimeMs: 2,
2353
+ };
2354
+
2355
+ const pull = await applyOfflineSyncSnapshot({
2356
+ root,
2357
+ snapshot: {
2358
+ format: "remnic.offline-sync.snapshot.v1",
2359
+ schemaVersion: 1,
2360
+ createdAt: new Date().toISOString(),
2361
+ sourceId: "remote",
2362
+ includeTranscripts: true,
2363
+ files: [incomingFile],
2364
+ },
2365
+ baseFiles: [baseFile],
2366
+ deferredPaths: [relPath],
2367
+ });
2368
+
2369
+ assert.equal(await readUtf8(root, relPath), localContent.toString("utf-8"));
2370
+ assert.equal(pull.upserted, 0);
2371
+ assert.equal(pull.deleted, 0);
2372
+ assert.equal(pull.skipped, 1);
2373
+ assert.equal(pull.conflicts.length, 0);
2374
+ assert.deepEqual(pull.nextBaseFiles, [baseFile]);
2375
+ } finally {
2376
+ await rm(root, { recursive: true, force: true });
2377
+ }
2378
+ });
2379
+
1326
2380
  test("offline changeset validation reports client input errors with an offline sync prefix", async () => {
1327
2381
  const root = await tempDir("remnic-offline-invalid-changeset");
1328
2382
  try {