@remnic/core 1.1.29 → 1.1.31

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 (100) hide show
  1. package/dist/access-cli.js +13 -13
  2. package/dist/access-http.d.ts +2 -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-CkZyb35d.d.ts} +10 -2
  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-6CB4E7ZV.js → chunk-3ZLVGM76.js} +4 -4
  15. package/dist/{chunk-QYHQ2JHL.js → chunk-43PJZYGL.js} +2 -2
  16. package/dist/{chunk-YITUHONZ.js → chunk-4KGVTPGD.js} +2 -2
  17. package/dist/{chunk-TR4DK5OH.js → chunk-76FLAAUC.js} +2 -2
  18. package/dist/{chunk-6BFAEWQS.js → chunk-77H5NU3M.js} +2 -2
  19. package/dist/{chunk-IANK6Y5W.js → chunk-A6KTB5R6.js} +2 -2
  20. package/dist/{chunk-7D6O46PF.js → chunk-BVF3AGJP.js} +2 -2
  21. package/dist/{chunk-4H6DURG6.js → chunk-JA3AK3PT.js} +2 -2
  22. package/dist/{chunk-RCZRL5BE.js → chunk-MRILGULB.js} +2 -2
  23. package/dist/{chunk-CWWDIQZB.js → chunk-QLLBRHAT.js} +8 -8
  24. package/dist/{chunk-2WIPXV3Y.js → chunk-RR2PKP3I.js} +2 -2
  25. package/dist/{chunk-3F24QTRI.js → chunk-SAZS2QZB.js} +2 -2
  26. package/dist/{chunk-VYU7PXUS.js → chunk-SIC6U3GZ.js} +2 -2
  27. package/dist/{chunk-WDSIV3AK.js → chunk-TPU5L5EY.js} +12 -12
  28. package/dist/{chunk-AMVN77EU.js → chunk-U7EJOMFC.js} +371 -91
  29. package/dist/chunk-U7EJOMFC.js.map +1 -0
  30. package/dist/{chunk-F33CJ5CH.js → chunk-VBJ7V5SK.js} +40 -8
  31. package/dist/chunk-VBJ7V5SK.js.map +1 -0
  32. package/dist/{chunk-6WV2HYTZ.js → chunk-W6AQJ2PY.js} +4 -4
  33. package/dist/{chunk-PUXCIHRL.js → chunk-XSZEP4SF.js} +2 -2
  34. package/dist/{chunk-NW7JW5GA.js → chunk-YROHKYBY.js} +41 -6
  35. package/dist/chunk-YROHKYBY.js.map +1 -0
  36. package/dist/{chunk-JUYT2J3K.js → chunk-YU5KIWYQ.js} +136 -8
  37. package/dist/chunk-YU5KIWYQ.js.map +1 -0
  38. package/dist/{chunk-LCTP7YRU.js → chunk-ZAVUCJ4H.js} +38 -7
  39. package/dist/chunk-ZAVUCJ4H.js.map +1 -0
  40. package/dist/{cli-BguVmIwO.d.ts → cli-kuh9PwZ5.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 +34 -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 +56 -1
  57. package/dist/offline-sync.js +15 -1
  58. package/dist/operator-toolkit.js +5 -5
  59. package/dist/orchestrator.js +9 -9
  60. package/dist/schemas.d.ts +22 -22
  61. package/dist/semantic-consolidation.js +3 -3
  62. package/dist/semantic-rule-promotion.js +2 -2
  63. package/dist/semantic-rule-verifier.js +2 -2
  64. package/dist/storage.d.ts +5 -0
  65. package/dist/storage.js +1 -1
  66. package/dist/transfer/types.d.ts +12 -12
  67. package/dist/verified-recall.js +2 -2
  68. package/package.json +1 -1
  69. package/src/access-http.test.ts +355 -0
  70. package/src/access-http.ts +149 -1
  71. package/src/access-schema.ts +58 -3
  72. package/src/access-service-namespace.test.ts +56 -1
  73. package/src/access-service-offline-file-content.test.ts +17 -0
  74. package/src/access-service.ts +47 -1
  75. package/src/index.ts +7 -0
  76. package/src/offline-sync.test.ts +1055 -1
  77. package/src/offline-sync.ts +465 -97
  78. package/src/storage.ts +36 -2
  79. package/dist/chunk-AMVN77EU.js.map +0 -1
  80. package/dist/chunk-F33CJ5CH.js.map +0 -1
  81. package/dist/chunk-JUYT2J3K.js.map +0 -1
  82. package/dist/chunk-LCTP7YRU.js.map +0 -1
  83. package/dist/chunk-NW7JW5GA.js.map +0 -1
  84. /package/dist/{chunk-25YQM6XW.js.map → chunk-2IWUMAES.js.map} +0 -0
  85. /package/dist/{chunk-6CB4E7ZV.js.map → chunk-3ZLVGM76.js.map} +0 -0
  86. /package/dist/{chunk-QYHQ2JHL.js.map → chunk-43PJZYGL.js.map} +0 -0
  87. /package/dist/{chunk-YITUHONZ.js.map → chunk-4KGVTPGD.js.map} +0 -0
  88. /package/dist/{chunk-TR4DK5OH.js.map → chunk-76FLAAUC.js.map} +0 -0
  89. /package/dist/{chunk-6BFAEWQS.js.map → chunk-77H5NU3M.js.map} +0 -0
  90. /package/dist/{chunk-IANK6Y5W.js.map → chunk-A6KTB5R6.js.map} +0 -0
  91. /package/dist/{chunk-7D6O46PF.js.map → chunk-BVF3AGJP.js.map} +0 -0
  92. /package/dist/{chunk-4H6DURG6.js.map → chunk-JA3AK3PT.js.map} +0 -0
  93. /package/dist/{chunk-RCZRL5BE.js.map → chunk-MRILGULB.js.map} +0 -0
  94. /package/dist/{chunk-CWWDIQZB.js.map → chunk-QLLBRHAT.js.map} +0 -0
  95. /package/dist/{chunk-2WIPXV3Y.js.map → chunk-RR2PKP3I.js.map} +0 -0
  96. /package/dist/{chunk-3F24QTRI.js.map → chunk-SAZS2QZB.js.map} +0 -0
  97. /package/dist/{chunk-VYU7PXUS.js.map → chunk-SIC6U3GZ.js.map} +0 -0
  98. /package/dist/{chunk-WDSIV3AK.js.map → chunk-TPU5L5EY.js.map} +0 -0
  99. /package/dist/{chunk-6WV2HYTZ.js.map → chunk-W6AQJ2PY.js.map} +0 -0
  100. /package/dist/{chunk-PUXCIHRL.js.map → chunk-XSZEP4SF.js.map} +0 -0
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { createReadStream } from "node:fs";
2
3
  import {
3
4
  lstat,
4
5
  mkdir,
@@ -9,6 +10,7 @@ import {
9
10
  rm,
10
11
  stat,
11
12
  unlink,
13
+ utimes,
12
14
  writeFile,
13
15
  } from "node:fs/promises";
14
16
  import path from "node:path";
@@ -31,6 +33,8 @@ export const OFFLINE_SYNC_STATE_VERSION = 1;
31
33
  export const OFFLINE_SYNC_FILE_CONTENT_MAX_CHUNK_BYTES = 64 * 1024 * 1024;
32
34
  export const OFFLINE_SYNC_FILE_CONTENT_TRANSFER_CHUNK_BYTES = 8 * 1024 * 1024;
33
35
  export const OFFLINE_SYNC_APPLY_MAX_BODY_BYTES = 16 * 1024 * 1024;
36
+ export const OFFLINE_SYNC_SNAPSHOT_BASE_MAX_BODY_BYTES = 64 * 1024 * 1024;
37
+ export const OFFLINE_SYNC_MAX_MTIME_MS = 8_640_000_000_000_000;
34
38
 
35
39
  export interface OfflineSyncFileState {
36
40
  path: string;
@@ -44,6 +48,11 @@ export interface OfflineSyncFileRecord extends OfflineSyncFileState {
44
48
  contentBase64?: string;
45
49
  }
46
50
 
51
+ export interface OfflineSyncFileDigest {
52
+ sha256: string;
53
+ bytes: number;
54
+ }
55
+
47
56
  export interface OfflineSyncSnapshot {
48
57
  format: typeof OFFLINE_SYNC_SNAPSHOT_FORMAT;
49
58
  schemaVersion: 1;
@@ -115,6 +124,7 @@ export interface OfflineSyncApplyChangesetResult {
115
124
  skipped: number;
116
125
  conflicts: OfflineSyncConflict[];
117
126
  currentFiles: OfflineSyncFileState[];
127
+ currentFilesComplete?: boolean;
118
128
  }
119
129
 
120
130
  export interface OfflineSyncChangesetSummary {
@@ -172,10 +182,12 @@ interface OfflineSyncFileRecordOptions {
172
182
  filePath: string;
173
183
  includeContent: boolean;
174
184
  readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
185
+ readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
175
186
  }
176
187
 
177
188
  const SYNC_INTERNAL_DIR = ".offline-sync";
178
189
  const OFFLINE_SYNC_UPLOAD_STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1000;
190
+ const OFFLINE_SYNC_FAST_BASE_MTIME_TOLERANCE_MS = 1_000;
179
191
  const EXCLUDED_FILE_NAMES = new Set([
180
192
  ".sync-state.json",
181
193
  ]);
@@ -223,6 +235,14 @@ function assertNonNegativeFinite(value: unknown, field: string): number {
223
235
  return value;
224
236
  }
225
237
 
238
+ function assertOfflineSyncMtimeMs(value: unknown, field: string): number {
239
+ const mtimeMs = assertNonNegativeFinite(value, field);
240
+ if (mtimeMs > OFFLINE_SYNC_MAX_MTIME_MS) {
241
+ throw new Error(`${field} must be within JavaScript Date range`);
242
+ }
243
+ return mtimeMs;
244
+ }
245
+
226
246
  function assertBoolean(value: unknown, field: string): boolean {
227
247
  if (typeof value !== "boolean") {
228
248
  throw new Error(`${field} must be a boolean`);
@@ -247,7 +267,7 @@ function normalizeFileState(input: unknown, fieldPrefix: string): OfflineSyncFil
247
267
  path: relPath,
248
268
  sha256: assertSha256(obj.sha256, `${fieldPrefix}.sha256`),
249
269
  bytes: assertNonNegativeInteger(obj.bytes, `${fieldPrefix}.bytes`),
250
- mtimeMs: assertNonNegativeFinite(obj.mtimeMs, `${fieldPrefix}.mtimeMs`),
270
+ mtimeMs: assertOfflineSyncMtimeMs(obj.mtimeMs, `${fieldPrefix}.mtimeMs`),
251
271
  };
252
272
  }
253
273
 
@@ -454,6 +474,23 @@ function isCanonicalRuntimeStatePath(parts: string[]): boolean {
454
474
  return parts[0] === "namespaces" && parts.length >= 4 && parts[2] === "state";
455
475
  }
456
476
 
477
+ const REMOTE_AUTHORITATIVE_RUNTIME_STATE_FILES = new Set([
478
+ ".artifact-write-version.log",
479
+ ".memory-status-version.log",
480
+ "buffer.json",
481
+ "embeddings.json",
482
+ "index_time.json",
483
+ "last_recall.json",
484
+ "memory-lifecycle-ledger.jsonl",
485
+ "recall_impressions.jsonl",
486
+ ]);
487
+
488
+ export function shouldPreferIncomingOfflineRuntimeFile(relPosix: string): boolean {
489
+ const parts = relPosix.split("/");
490
+ const basename = parts[parts.length - 1] ?? "";
491
+ return isCanonicalRuntimeStatePath(parts) && REMOTE_AUTHORITATIVE_RUNTIME_STATE_FILES.has(basename);
492
+ }
493
+
457
494
  function filterBaseFilesForMode(
458
495
  files: readonly OfflineSyncFileState[],
459
496
  includeTranscripts: boolean,
@@ -461,21 +498,70 @@ function filterBaseFilesForMode(
461
498
  return files.filter((file) => !shouldExcludeRelPath(file.path, includeTranscripts));
462
499
  }
463
500
 
501
+ function canReuseFastBaseFileState(
502
+ baseEntry: OfflineSyncFileState,
503
+ st: { size: number; mtimeMs: number; ctimeMs: number },
504
+ baseCapturedAtMs: number | null,
505
+ ): boolean {
506
+ if (baseEntry.bytes !== st.size) return false;
507
+ if (Math.abs(baseEntry.mtimeMs - st.mtimeMs) > OFFLINE_SYNC_FAST_BASE_MTIME_TOLERANCE_MS) {
508
+ return false;
509
+ }
510
+ if (baseCapturedAtMs === null) return false;
511
+ return st.ctimeMs - baseCapturedAtMs <= OFFLINE_SYNC_FAST_BASE_MTIME_TOLERANCE_MS;
512
+ }
513
+
514
+ async function canReuseFastBaseFileStateFromDisk(
515
+ baseEntry: OfflineSyncFileState,
516
+ filePath: string,
517
+ st: { size: number; mtimeMs: number; ctimeMs: number },
518
+ baseCapturedAtMs: number | null,
519
+ ): Promise<boolean> {
520
+ if (!canReuseFastBaseFileState(baseEntry, st, baseCapturedAtMs)) return false;
521
+ return !(await fileIsSecureStoreEncrypted(filePath).catch(() => true));
522
+ }
523
+
464
524
  async function readOfflineSyncFileRecord(
465
525
  options: OfflineSyncFileRecordOptions,
466
526
  ): Promise<OfflineSyncFileRecord> {
467
527
  const relPath = validateArchiveRelativePath(options.relPath, "offlineSyncFile.path");
468
- const bytes = options.readFile
469
- ? await options.readFile({ root: options.root.abs, path: relPath, filePath: options.filePath })
470
- : await readFile(options.filePath);
471
- const digest = sha256Buffer(bytes);
528
+ let content: Buffer | null = null;
529
+ let digest: OfflineSyncFileDigest;
530
+ if (options.includeContent) {
531
+ content = options.readFile
532
+ ? await options.readFile({ root: options.root.abs, path: relPath, filePath: options.filePath })
533
+ : await readFile(options.filePath);
534
+ digest = sha256Buffer(content);
535
+ } else if (options.readFileDigest) {
536
+ digest = await options.readFileDigest({ root: options.root.abs, path: relPath, filePath: options.filePath });
537
+ } else if (options.readFile) {
538
+ content = await options.readFile({ root: options.root.abs, path: relPath, filePath: options.filePath });
539
+ digest = sha256Buffer(content);
540
+ content = null;
541
+ } else {
542
+ digest = await sha256File(options.filePath);
543
+ }
472
544
  const st = await stat(options.filePath);
473
545
  return {
474
546
  path: relPath,
475
547
  sha256: digest.sha256,
476
548
  bytes: digest.bytes,
477
549
  mtimeMs: st.mtimeMs,
478
- ...(options.includeContent ? { contentBase64: bytes.toString("base64") } : {}),
550
+ ...(content ? { contentBase64: content.toString("base64") } : {}),
551
+ };
552
+ }
553
+
554
+ async function sha256File(filePath: string): Promise<OfflineSyncFileDigest> {
555
+ const hash = createHash("sha256");
556
+ let bytes = 0;
557
+ for await (const chunk of createReadStream(filePath)) {
558
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
559
+ hash.update(buffer);
560
+ bytes += buffer.length;
561
+ }
562
+ return {
563
+ sha256: hash.digest("hex"),
564
+ bytes,
479
565
  };
480
566
  }
481
567
 
@@ -508,6 +594,44 @@ async function readPlainFileContentChunk(options: {
508
594
  }
509
595
  }
510
596
 
597
+ export async function* iterateOfflineSyncSnapshotFileRecords(options: {
598
+ root: string;
599
+ includeContent?: boolean;
600
+ includeTranscripts?: boolean;
601
+ readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
602
+ readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
603
+ }): AsyncIterable<OfflineSyncFileRecord> {
604
+ const rootAbs = path.resolve(options.root);
605
+ const root = await prepareSafeArchiveRoot(rootAbs, "iterateOfflineSyncSnapshotFileRecords", "root");
606
+ const includeTranscripts = options.includeTranscripts !== false;
607
+
608
+ async function* walk(dirAbs: string): AsyncIterable<OfflineSyncFileRecord> {
609
+ let entries = await readdir(dirAbs, { withFileTypes: true });
610
+ entries = entries.sort((left, right) => left.name.localeCompare(right.name));
611
+ for (const entry of entries) {
612
+ const abs = path.join(dirAbs, entry.name);
613
+ const relPosix = path.relative(root.abs, abs).split(path.sep).join("/");
614
+ if (shouldExcludeRelPath(relPosix, includeTranscripts)) continue;
615
+ if (entry.isSymbolicLink()) continue;
616
+ if (entry.isDirectory()) {
617
+ yield* walk(abs);
618
+ continue;
619
+ }
620
+ if (!entry.isFile()) continue;
621
+ yield await readOfflineSyncFileRecord({
622
+ root,
623
+ relPath: relPosix,
624
+ filePath: abs,
625
+ includeContent: options.includeContent === true,
626
+ readFile: options.readFile,
627
+ readFileDigest: options.readFileDigest,
628
+ });
629
+ }
630
+ }
631
+
632
+ yield* walk(root.abs);
633
+ }
634
+
511
635
  export async function buildOfflineSyncSnapshot(options: {
512
636
  root: string;
513
637
  sourceId: string;
@@ -515,10 +639,44 @@ export async function buildOfflineSyncSnapshot(options: {
515
639
  includeTranscripts?: boolean;
516
640
  now?: Date;
517
641
  readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
642
+ readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
643
+ }): Promise<OfflineSyncSnapshot> {
644
+ const includeTranscripts = options.includeTranscripts !== false;
645
+ const files: OfflineSyncFileRecord[] = [];
646
+ for await (const file of iterateOfflineSyncSnapshotFileRecords(options)) files.push(file);
647
+
648
+ return {
649
+ format: OFFLINE_SYNC_SNAPSHOT_FORMAT,
650
+ schemaVersion: 1,
651
+ createdAt: (options.now ?? new Date()).toISOString(),
652
+ sourceId: normalizeSourceId(options.sourceId, "sourceId"),
653
+ includeTranscripts,
654
+ files: files.sort(compareByPath),
655
+ };
656
+ }
657
+
658
+ export async function buildOfflineSyncSnapshotFromBase(options: {
659
+ root: string;
660
+ sourceId: string;
661
+ baseFiles?: readonly OfflineSyncFileState[];
662
+ baseCapturedAt?: Date;
663
+ includeContent?: boolean;
664
+ includeTranscripts?: boolean;
665
+ now?: Date;
666
+ readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
667
+ readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
518
668
  }): Promise<OfflineSyncSnapshot> {
519
669
  const rootAbs = path.resolve(options.root);
520
- const root = await prepareSafeArchiveRoot(rootAbs, "buildOfflineSyncSnapshot", "root");
670
+ const root = await prepareSafeArchiveRoot(rootAbs, "buildOfflineSyncSnapshotFromBase", "root");
521
671
  const includeTranscripts = options.includeTranscripts !== false;
672
+ const base = byPath(filterBaseFilesForMode(
673
+ normalizeFileStates(options.baseFiles),
674
+ includeTranscripts,
675
+ ));
676
+ const rawBaseCapturedAtMs = options.baseCapturedAt?.getTime();
677
+ const baseCapturedAtMs = rawBaseCapturedAtMs !== undefined && Number.isFinite(rawBaseCapturedAtMs)
678
+ ? rawBaseCapturedAtMs
679
+ : null;
522
680
  const files: OfflineSyncFileRecord[] = [];
523
681
 
524
682
  async function walk(dirAbs: string): Promise<void> {
@@ -534,12 +692,24 @@ export async function buildOfflineSyncSnapshot(options: {
534
692
  continue;
535
693
  }
536
694
  if (!entry.isFile()) continue;
695
+ const st = await stat(abs);
696
+ const baseEntry = base.get(relPosix);
697
+ if (
698
+ options.includeContent !== true &&
699
+ baseEntry &&
700
+ baseCapturedAtMs !== null &&
701
+ await canReuseFastBaseFileStateFromDisk(baseEntry, abs, st, baseCapturedAtMs)
702
+ ) {
703
+ files.push(baseEntry);
704
+ continue;
705
+ }
537
706
  files.push(await readOfflineSyncFileRecord({
538
707
  root,
539
708
  relPath: relPosix,
540
709
  filePath: abs,
541
710
  includeContent: options.includeContent === true,
542
711
  readFile: options.readFile,
712
+ readFileDigest: options.readFileDigest,
543
713
  }));
544
714
  }
545
715
  }
@@ -564,6 +734,7 @@ export async function buildOfflineSyncSnapshotForPaths(options: {
564
734
  includeTranscripts?: boolean;
565
735
  now?: Date;
566
736
  readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
737
+ readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
567
738
  }): Promise<OfflineSyncSnapshot> {
568
739
  const rootAbs = path.resolve(options.root);
569
740
  const root = await prepareSafeArchiveRoot(rootAbs, "buildOfflineSyncSnapshotForPaths", "root");
@@ -590,6 +761,7 @@ export async function buildOfflineSyncSnapshotForPaths(options: {
590
761
  filePath,
591
762
  includeContent: options.includeContent === true,
592
763
  readFile: options.readFile,
764
+ readFileDigest: options.readFileDigest,
593
765
  }));
594
766
  }
595
767
 
@@ -682,6 +854,42 @@ export async function buildOfflineSyncChangeset(options: {
682
854
  root: string;
683
855
  sourceId: string;
684
856
  baseFiles?: readonly OfflineSyncFileState[];
857
+ baseCapturedAt?: Date;
858
+ excludePaths?: readonly string[];
859
+ includeTranscripts?: boolean;
860
+ now?: Date;
861
+ readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
862
+ readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
863
+ }): Promise<OfflineSyncChangeset> {
864
+ const includeTranscripts = options.includeTranscripts !== false;
865
+ const current = await buildOfflineSyncSnapshotFromBase({
866
+ root: options.root,
867
+ sourceId: options.sourceId,
868
+ baseFiles: options.baseFiles,
869
+ baseCapturedAt: options.baseCapturedAt,
870
+ includeContent: false,
871
+ includeTranscripts,
872
+ now: options.now,
873
+ readFile: options.readFile,
874
+ readFileDigest: options.readFileDigest,
875
+ });
876
+ return buildOfflineSyncChangesetFromSnapshot({
877
+ root: options.root,
878
+ sourceId: options.sourceId,
879
+ baseFiles: options.baseFiles,
880
+ currentFiles: current.files,
881
+ excludePaths: options.excludePaths,
882
+ includeTranscripts,
883
+ now: options.now,
884
+ readFile: options.readFile,
885
+ });
886
+ }
887
+
888
+ export async function buildOfflineSyncChangesetFromSnapshot(options: {
889
+ root: string;
890
+ sourceId: string;
891
+ currentFiles: readonly OfflineSyncFileState[];
892
+ baseFiles?: readonly OfflineSyncFileState[];
685
893
  excludePaths?: readonly string[];
686
894
  includeTranscripts?: boolean;
687
895
  now?: Date;
@@ -695,19 +903,18 @@ export async function buildOfflineSyncChangeset(options: {
695
903
  normalizeFileStates(options.baseFiles),
696
904
  includeTranscripts,
697
905
  ));
698
- const current = await buildOfflineSyncSnapshot({
699
- root: options.root,
700
- sourceId: options.sourceId,
701
- includeContent: false,
906
+ const currentMap = byPath(filterBaseFilesForMode(
907
+ normalizeFileStates(options.currentFiles),
702
908
  includeTranscripts,
703
- now: options.now,
704
- readFile: options.readFile,
705
- });
706
- const currentMap = byPath(current.files);
909
+ ));
707
910
  const changes: OfflineSyncChange[] = [];
708
911
 
709
912
  for (const relPath of unionPaths(base, currentMap)) {
710
913
  if (excludedPaths.has(relPath)) continue;
914
+ // Runtime state is remote-authoritative in offline sync: local edits and
915
+ // deletes are not pushed; the pull phase restores or removes these files
916
+ // from the remote snapshot.
917
+ if (shouldPreferIncomingOfflineRuntimeFile(relPath)) continue;
711
918
  const baseEntry = base.get(relPath);
712
919
  const currentEntry = currentMap.get(relPath);
713
920
  if (currentEntry && currentEntry.sha256 !== baseEntry?.sha256) {
@@ -746,7 +953,7 @@ export async function buildOfflineSyncChangeset(options: {
746
953
  schemaVersion: 1,
747
954
  createdAt: (options.now ?? new Date()).toISOString(),
748
955
  sourceId: normalizeSourceId(options.sourceId, "sourceId"),
749
- includeTranscripts: current.includeTranscripts,
956
+ includeTranscripts,
750
957
  changes: changes.sort(compareByPath),
751
958
  };
752
959
  }
@@ -767,28 +974,50 @@ export async function summarizeOfflineSyncPendingChanges(options: {
767
974
  root: string;
768
975
  sourceId: string;
769
976
  baseFiles?: readonly OfflineSyncFileState[];
977
+ baseCapturedAt?: Date;
770
978
  includeTranscripts?: boolean;
771
979
  now?: Date;
772
980
  readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
981
+ readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
773
982
  }): Promise<OfflineSyncChangesetSummary> {
774
983
  const includeTranscripts = options.includeTranscripts !== false;
775
- const base = byPath(filterBaseFilesForMode(
776
- normalizeFileStates(options.baseFiles),
777
- includeTranscripts,
778
- ));
779
- const current = await buildOfflineSyncSnapshot({
984
+ const current = await buildOfflineSyncSnapshotFromBase({
780
985
  root: options.root,
781
986
  sourceId: options.sourceId,
987
+ baseFiles: options.baseFiles,
988
+ baseCapturedAt: options.baseCapturedAt,
782
989
  includeContent: false,
783
990
  includeTranscripts,
784
991
  now: options.now,
785
992
  readFile: options.readFile,
993
+ readFileDigest: options.readFileDigest,
994
+ });
995
+ return summarizeOfflineSyncPendingFiles({
996
+ baseFiles: options.baseFiles,
997
+ currentFiles: current.files,
998
+ includeTranscripts,
786
999
  });
787
- const currentMap = byPath(current.files);
1000
+ }
1001
+
1002
+ export function summarizeOfflineSyncPendingFiles(options: {
1003
+ baseFiles?: readonly OfflineSyncFileState[];
1004
+ currentFiles: readonly OfflineSyncFileState[];
1005
+ includeTranscripts?: boolean;
1006
+ }): OfflineSyncChangesetSummary {
1007
+ const includeTranscripts = options.includeTranscripts !== false;
1008
+ const base = byPath(filterBaseFilesForMode(
1009
+ normalizeFileStates(options.baseFiles),
1010
+ includeTranscripts,
1011
+ ));
1012
+ const currentMap = byPath(filterBaseFilesForMode(
1013
+ normalizeFileStates(options.currentFiles),
1014
+ includeTranscripts,
1015
+ ));
788
1016
  let upserts = 0;
789
1017
  let deletes = 0;
790
1018
 
791
1019
  for (const relPath of unionPaths(base, currentMap)) {
1020
+ if (shouldPreferIncomingOfflineRuntimeFile(relPath)) continue;
792
1021
  const baseEntry = base.get(relPath);
793
1022
  const currentEntry = currentMap.get(relPath);
794
1023
  if (currentEntry && currentEntry.sha256 !== baseEntry?.sha256) {
@@ -811,8 +1040,12 @@ export async function applyOfflineSyncSnapshot(options: {
811
1040
  root: string;
812
1041
  snapshot: unknown;
813
1042
  baseFiles?: readonly OfflineSyncFileState[];
1043
+ currentFiles?: readonly OfflineSyncFileState[];
1044
+ deferredPaths?: readonly string[];
1045
+ allowMissingConflictContent?: boolean;
814
1046
  writeConflictCopies?: boolean;
815
1047
  readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
1048
+ readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
816
1049
  writeFile?: (target: OfflineSyncFileWriteTarget) => Promise<void>;
817
1050
  deleteFile?: (target: OfflineSyncFileTarget) => Promise<void>;
818
1051
  }): Promise<OfflineSyncApplySnapshotResult> {
@@ -826,32 +1059,66 @@ export async function applyOfflineSyncSnapshot(options: {
826
1059
  requireContent: false,
827
1060
  });
828
1061
  const root = await ensureSyncRoot(options.root, "applyOfflineSyncSnapshot");
829
- const current = await buildOfflineSyncSnapshot({
830
- root: root.abs,
831
- sourceId: "local",
832
- includeContent: false,
833
- includeTranscripts: snapshot.includeTranscripts,
834
- readFile: options.readFile,
835
- });
836
- const currentMap = byPath(current.files);
1062
+ const currentFiles = options.currentFiles
1063
+ ? filterBaseFilesForMode(normalizeFileStates(options.currentFiles), snapshot.includeTranscripts).sort(compareByPath)
1064
+ : (await buildOfflineSyncSnapshot({
1065
+ root: root.abs,
1066
+ sourceId: "local",
1067
+ includeContent: false,
1068
+ includeTranscripts: snapshot.includeTranscripts,
1069
+ readFile: options.readFile,
1070
+ readFileDigest: options.readFileDigest,
1071
+ })).files;
1072
+ const currentMap = byPath(currentFiles);
1073
+ const deferredPaths = new Set(options.deferredPaths ?? []);
837
1074
  const nextBase = new Map(baseMap);
838
1075
  const conflicts: OfflineSyncConflict[] = [];
839
1076
  let upserted = 0;
840
1077
  let deleted = 0;
841
1078
  let skipped = 0;
842
1079
  let pendingLocal = 0;
1080
+ const conflictIncomingBuffer = (relPath: string): Buffer | undefined => {
1081
+ if (options.writeConflictCopies === false) return undefined;
1082
+ const buffer = incomingBuffers.get(relPath);
1083
+ if (buffer || options.allowMissingConflictContent === true) return buffer;
1084
+ return requiredBuffer(incomingBuffers, relPath);
1085
+ };
843
1086
 
844
1087
  for (const relPath of unionPaths(baseMap, incomingMap, currentMap)) {
845
1088
  const base = baseMap.get(relPath);
846
1089
  const incoming = incomingMap.get(relPath);
847
1090
  const currentEntry = currentMap.get(relPath);
848
1091
 
1092
+ if (deferredPaths.has(relPath)) {
1093
+ if (base) nextBase.set(relPath, base);
1094
+ else nextBase.delete(relPath);
1095
+ skipped += 1;
1096
+ continue;
1097
+ }
1098
+
849
1099
  if (incoming) {
850
1100
  if (currentEntry?.sha256 === incoming.sha256) {
851
- nextBase.set(relPath, toFileState(incoming));
1101
+ if (await setSafeFileMtime(root, relPath, incoming.mtimeMs)) {
1102
+ nextBase.set(relPath, toFileState(incoming));
1103
+ } else {
1104
+ if (base) nextBase.set(relPath, base);
1105
+ else nextBase.delete(relPath);
1106
+ pendingLocal += 1;
1107
+ }
852
1108
  skipped += 1;
853
1109
  continue;
854
1110
  }
1111
+ if (shouldPreferIncomingOfflineRuntimeFile(relPath) && currentEntry && base && incoming.sha256 === base.sha256) {
1112
+ nextBase.set(relPath, base);
1113
+ skipped += 1;
1114
+ continue;
1115
+ }
1116
+ if (shouldPreferIncomingOfflineRuntimeFile(relPath)) {
1117
+ await writeSafeFile(root, relPath, requiredBuffer(incomingBuffers, relPath), options.writeFile, incoming.mtimeMs);
1118
+ nextBase.set(relPath, toFileState(incoming));
1119
+ upserted += 1;
1120
+ continue;
1121
+ }
855
1122
  if (!currentEntry && base && incoming.sha256 === base.sha256) {
856
1123
  nextBase.set(relPath, base);
857
1124
  pendingLocal += 1;
@@ -865,9 +1132,7 @@ export async function applyOfflineSyncSnapshot(options: {
865
1132
  reason: "local_deleted_remote_modified",
866
1133
  baseSha256: base.sha256,
867
1134
  incomingSha256: incoming.sha256,
868
- incomingBuffer: options.writeConflictCopies === false
869
- ? incomingBuffers.get(relPath)
870
- : requiredBuffer(incomingBuffers, relPath),
1135
+ incomingBuffer: conflictIncomingBuffer(relPath),
871
1136
  writeConflictCopies: options.writeConflictCopies !== false,
872
1137
  sourceId: snapshot.sourceId,
873
1138
  writeFile: options.writeFile,
@@ -876,13 +1141,13 @@ export async function applyOfflineSyncSnapshot(options: {
876
1141
  continue;
877
1142
  }
878
1143
  if (!currentEntry && !base) {
879
- await writeSafeFile(root, relPath, requiredBuffer(incomingBuffers, relPath), options.writeFile);
1144
+ await writeSafeFile(root, relPath, requiredBuffer(incomingBuffers, relPath), options.writeFile, incoming.mtimeMs);
880
1145
  nextBase.set(relPath, toFileState(incoming));
881
1146
  upserted += 1;
882
1147
  continue;
883
1148
  }
884
1149
  if (base && currentEntry && currentEntry.sha256 === base.sha256) {
885
- await writeSafeFile(root, relPath, requiredBuffer(incomingBuffers, relPath), options.writeFile);
1150
+ await writeSafeFile(root, relPath, requiredBuffer(incomingBuffers, relPath), options.writeFile, incoming.mtimeMs);
886
1151
  nextBase.set(relPath, toFileState(incoming));
887
1152
  upserted += 1;
888
1153
  continue;
@@ -900,9 +1165,7 @@ export async function applyOfflineSyncSnapshot(options: {
900
1165
  baseSha256: base?.sha256,
901
1166
  localSha256: currentEntry?.sha256,
902
1167
  incomingSha256: incoming.sha256,
903
- incomingBuffer: options.writeConflictCopies === false
904
- ? incomingBuffers.get(relPath)
905
- : requiredBuffer(incomingBuffers, relPath),
1168
+ incomingBuffer: conflictIncomingBuffer(relPath),
906
1169
  writeConflictCopies: options.writeConflictCopies !== false,
907
1170
  sourceId: snapshot.sourceId,
908
1171
  writeFile: options.writeFile,
@@ -916,6 +1179,17 @@ export async function applyOfflineSyncSnapshot(options: {
916
1179
  skipped += 1;
917
1180
  continue;
918
1181
  }
1182
+ if (shouldPreferIncomingOfflineRuntimeFile(relPath) && base) {
1183
+ await deleteSafeFile(root, relPath, options.deleteFile);
1184
+ nextBase.delete(relPath);
1185
+ deleted += 1;
1186
+ continue;
1187
+ }
1188
+ if (shouldPreferIncomingOfflineRuntimeFile(relPath)) {
1189
+ pendingLocal += 1;
1190
+ skipped += 1;
1191
+ continue;
1192
+ }
919
1193
  if (base && currentEntry.sha256 === base.sha256) {
920
1194
  await deleteSafeFile(root, relPath, options.deleteFile);
921
1195
  nextBase.delete(relPath);
@@ -949,8 +1223,11 @@ export async function applyOfflineSyncSnapshot(options: {
949
1223
  export async function applyOfflineSyncChangeset(options: {
950
1224
  root: string;
951
1225
  changeset: unknown;
1226
+ currentFiles?: readonly OfflineSyncFileState[];
1227
+ returnCurrentFiles?: boolean;
952
1228
  writeConflictCopies?: boolean;
953
1229
  readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
1230
+ readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
954
1231
  writeFile?: (target: OfflineSyncFileWriteTarget) => Promise<void>;
955
1232
  deleteFile?: (target: OfflineSyncFileTarget) => Promise<void>;
956
1233
  }): Promise<OfflineSyncApplyChangesetResult> {
@@ -970,14 +1247,18 @@ export async function applyOfflineSyncChangeset(options: {
970
1247
  .filter((change): change is Extract<OfflineSyncChange, { type: "upsert" }> => change.type === "upsert")
971
1248
  .map((change) => change.file);
972
1249
  const incomingBuffers = verifyRecordContents(records, "offline sync changeset");
973
- const current = await buildOfflineSyncSnapshot({
974
- root: root.abs,
975
- sourceId: "local",
976
- includeContent: false,
977
- includeTranscripts: changeset.includeTranscripts,
978
- readFile: options.readFile,
979
- });
980
- const currentMap = byPath(current.files);
1250
+ const currentFiles = options.currentFiles
1251
+ ? filterBaseFilesForMode(normalizeFileStates(options.currentFiles), changeset.includeTranscripts).sort(compareByPath)
1252
+ : (await buildOfflineSyncSnapshotForPaths({
1253
+ root: root.abs,
1254
+ sourceId: "local",
1255
+ paths: changeset.changes.map((change) => change.path),
1256
+ includeContent: false,
1257
+ includeTranscripts: changeset.includeTranscripts,
1258
+ readFile: options.readFile,
1259
+ readFileDigest: options.readFileDigest,
1260
+ })).files;
1261
+ const currentMap = byPath(currentFiles);
981
1262
  const conflicts: OfflineSyncConflict[] = [];
982
1263
  let appliedUpserts = 0;
983
1264
  let appliedDeletes = 0;
@@ -987,12 +1268,18 @@ export async function applyOfflineSyncChangeset(options: {
987
1268
  const currentEntry = currentMap.get(change.path);
988
1269
  if (change.type === "upsert") {
989
1270
  if (currentEntry?.sha256 === change.file.sha256) {
990
- skipped += 1;
1271
+ if (await setSafeFileMtime(root, change.path, change.file.mtimeMs)) {
1272
+ skipped += 1;
1273
+ } else {
1274
+ await writeSafeFile(root, change.path, requiredBuffer(incomingBuffers, change.path), options.writeFile, change.file.mtimeMs);
1275
+ currentMap.set(change.path, toFileState(change.file));
1276
+ appliedUpserts += 1;
1277
+ }
991
1278
  continue;
992
1279
  }
993
1280
  if (!change.baseSha256) {
994
1281
  if (!currentEntry) {
995
- await writeSafeFile(root, change.path, requiredBuffer(incomingBuffers, change.path), options.writeFile);
1282
+ await writeSafeFile(root, change.path, requiredBuffer(incomingBuffers, change.path), options.writeFile, change.file.mtimeMs);
996
1283
  currentMap.set(change.path, toFileState(change.file));
997
1284
  appliedUpserts += 1;
998
1285
  continue;
@@ -1011,7 +1298,7 @@ export async function applyOfflineSyncChangeset(options: {
1011
1298
  continue;
1012
1299
  }
1013
1300
  if (currentEntry?.sha256 === change.baseSha256) {
1014
- await writeSafeFile(root, change.path, requiredBuffer(incomingBuffers, change.path), options.writeFile);
1301
+ await writeSafeFile(root, change.path, requiredBuffer(incomingBuffers, change.path), options.writeFile, change.file.mtimeMs);
1015
1302
  currentMap.set(change.path, toFileState(change.file));
1016
1303
  appliedUpserts += 1;
1017
1304
  continue;
@@ -1054,7 +1341,17 @@ export async function applyOfflineSyncChangeset(options: {
1054
1341
  appliedDeletes,
1055
1342
  skipped,
1056
1343
  conflicts,
1057
- currentFiles: [...currentMap.values()].sort(compareByPath),
1344
+ currentFiles: options.returnCurrentFiles === false
1345
+ ? [...currentMap.values()].sort(compareByPath)
1346
+ : (await buildOfflineSyncSnapshot({
1347
+ root: root.abs,
1348
+ sourceId: "local",
1349
+ includeContent: false,
1350
+ includeTranscripts: changeset.includeTranscripts,
1351
+ readFile: options.readFile,
1352
+ readFileDigest: options.readFileDigest,
1353
+ })).files,
1354
+ ...(options.returnCurrentFiles === false ? { currentFilesComplete: false } : {}),
1058
1355
  };
1059
1356
  }
1060
1357
 
@@ -1125,10 +1422,12 @@ async function writeSafeFile(
1125
1422
  relPath: string,
1126
1423
  content: Buffer,
1127
1424
  writeFileHook?: (target: OfflineSyncFileWriteTarget) => Promise<void>,
1425
+ mtimeMs?: number,
1128
1426
  ): Promise<void> {
1129
1427
  const target = await resolveSafeArchiveTarget(root, relPath);
1130
1428
  if (writeFileHook) {
1131
1429
  await writeFileHook({ root: root.abs, path: relPath, filePath: target, content });
1430
+ await setSafeFileMtime(root, relPath, mtimeMs);
1132
1431
  return;
1133
1432
  }
1134
1433
  await mkdir(path.dirname(target), { recursive: true });
@@ -1146,12 +1445,33 @@ async function writeSafeFile(
1146
1445
  throw new Error(`offline sync target is a symlink: ${relPath}`);
1147
1446
  }
1148
1447
  await rename(tmp, target);
1448
+ await setSafeFileMtime(root, relPath, mtimeMs);
1149
1449
  } catch (error) {
1150
1450
  await unlink(tmp).catch(() => {});
1151
1451
  throw error;
1152
1452
  }
1153
1453
  }
1154
1454
 
1455
+ async function setSafeFileMtime(
1456
+ root: SafeArchiveRoot,
1457
+ relPath: string,
1458
+ mtimeMs: number | undefined,
1459
+ ): Promise<boolean> {
1460
+ if (mtimeMs === undefined) return true;
1461
+ const target = await resolveSafeArchiveTarget(root, relPath);
1462
+ const targetStat = await lstat(target).catch((error: unknown) => {
1463
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
1464
+ throw error;
1465
+ });
1466
+ if (!targetStat) return false;
1467
+ if (targetStat.isSymbolicLink()) {
1468
+ throw new Error(`offline sync target is a symlink: ${relPath}`);
1469
+ }
1470
+ const mtime = new Date(assertOfflineSyncMtimeMs(mtimeMs, "mtimeMs"));
1471
+ await utimes(target, mtime, mtime);
1472
+ return true;
1473
+ }
1474
+
1155
1475
  export async function applyOfflineSyncFileContentChunk(options: {
1156
1476
  root: string;
1157
1477
  sourceId: string;
@@ -1164,6 +1484,7 @@ export async function applyOfflineSyncFileContentChunk(options: {
1164
1484
  baseSha256?: string;
1165
1485
  includeTranscripts?: boolean;
1166
1486
  readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>;
1487
+ readFileDigest?: (target: OfflineSyncFileTarget) => Promise<OfflineSyncFileDigest>;
1167
1488
  writeFile?: (target: OfflineSyncFileWriteTarget) => Promise<void>;
1168
1489
  writeStagingFile?: (target: OfflineSyncFileStagingWriteTarget) => Promise<void>;
1169
1490
  writeFileChunks?: (target: OfflineSyncFileWriteChunksTarget) => Promise<void>;
@@ -1177,13 +1498,14 @@ export async function applyOfflineSyncFileContentChunk(options: {
1177
1498
  }
1178
1499
  const sha256 = assertSha256(options.sha256, "sha256");
1179
1500
  const bytes = assertNonNegativeInteger(options.bytes, "bytes");
1180
- const mtimeMs = assertNonNegativeFinite(options.mtimeMs, "mtimeMs");
1501
+ const mtimeMs = assertOfflineSyncMtimeMs(options.mtimeMs, "mtimeMs");
1181
1502
  const offset = options.offset === undefined
1182
1503
  ? 0
1183
1504
  : assertNonNegativeInteger(options.offset, "offset");
1184
1505
  const baseSha256 = options.baseSha256 === undefined
1185
1506
  ? undefined
1186
1507
  : assertSha256(options.baseSha256, "baseSha256");
1508
+ const preferIncomingRuntimeFile = shouldPreferIncomingOfflineRuntimeFile(relPath);
1187
1509
  if (!Buffer.isBuffer(options.content)) {
1188
1510
  throw new Error("content must be a Buffer");
1189
1511
  }
@@ -1204,8 +1526,87 @@ export async function applyOfflineSyncFileContentChunk(options: {
1204
1526
  if (options.writeFile && !options.writeStagingFile) {
1205
1527
  throw new Error("offline sync upload storage hooks require writeStagingFile");
1206
1528
  }
1529
+ const baseResult = {
1530
+ path: relPath,
1531
+ sha256,
1532
+ bytes,
1533
+ mtimeMs,
1534
+ offset,
1535
+ chunkBytes: options.content.length,
1536
+ done: offset + options.content.length === bytes,
1537
+ };
1538
+ const currentFileConflict = async (
1539
+ currentFile: OfflineSyncFileState | undefined,
1540
+ ): Promise<{ conflict: OfflineSyncConflict; currentFile?: OfflineSyncFileState } | null> => {
1541
+ if (!baseSha256 && currentFile && !preferIncomingRuntimeFile) {
1542
+ const conflict = await recordConflict({
1543
+ root,
1544
+ relPath,
1545
+ reason: "remote_exists_for_local_create",
1546
+ localSha256: currentFile.sha256,
1547
+ incomingSha256: sha256,
1548
+ writeConflictCopies: false,
1549
+ sourceId,
1550
+ writeFile: options.writeFile,
1551
+ });
1552
+ return {
1553
+ conflict,
1554
+ currentFile,
1555
+ };
1556
+ }
1557
+ if (baseSha256 && currentFile?.sha256 !== baseSha256 && !preferIncomingRuntimeFile) {
1558
+ const conflict = await recordConflict({
1559
+ root,
1560
+ relPath,
1561
+ reason: currentFile ? "remote_changed_for_local_update" : "remote_deleted_for_local_update",
1562
+ baseSha256,
1563
+ localSha256: currentFile?.sha256,
1564
+ incomingSha256: sha256,
1565
+ writeConflictCopies: false,
1566
+ sourceId,
1567
+ writeFile: options.writeFile,
1568
+ });
1569
+ return {
1570
+ conflict,
1571
+ ...(currentFile ? { currentFile } : {}),
1572
+ };
1573
+ }
1574
+ return null;
1575
+ };
1207
1576
  if (offset === 0) {
1208
1577
  await pruneOfflineUploadStaging(root);
1578
+ const currentSnapshot = await buildOfflineSyncSnapshotForPaths({
1579
+ root: root.abs,
1580
+ sourceId: "local",
1581
+ paths: [relPath],
1582
+ includeContent: false,
1583
+ includeTranscripts,
1584
+ readFile: options.readFile,
1585
+ readFileDigest: options.readFileDigest,
1586
+ });
1587
+ const currentFile = currentSnapshot.files[0];
1588
+ if (currentFile?.sha256 === sha256) {
1589
+ await setSafeFileMtime(root, relPath, mtimeMs);
1590
+ return {
1591
+ ...baseResult,
1592
+ done: true,
1593
+ chunkBytes: 0,
1594
+ applied: false,
1595
+ skipped: true,
1596
+ currentFile: toFileState(currentFile),
1597
+ };
1598
+ }
1599
+ const conflictResult = await currentFileConflict(currentFile ? toFileState(currentFile) : undefined);
1600
+ if (conflictResult) {
1601
+ return {
1602
+ ...baseResult,
1603
+ done: true,
1604
+ chunkBytes: 0,
1605
+ applied: false,
1606
+ skipped: false,
1607
+ ...conflictResult,
1608
+ };
1609
+ }
1209
1610
  }
1210
1611
 
1211
1612
  const upload = await writeOfflineUploadChunk({
@@ -1220,16 +1621,7 @@ export async function applyOfflineSyncFileContentChunk(options: {
1220
1621
  writeFile: options.writeFile,
1221
1622
  writeStagingFile: options.writeStagingFile,
1222
1623
  });
1223
- const done = offset + options.content.length === bytes;
1224
- const baseResult = {
1225
- path: relPath,
1226
- sha256,
1227
- bytes,
1228
- mtimeMs,
1229
- offset,
1230
- chunkBytes: options.content.length,
1231
- done,
1232
- };
1624
+ const done = baseResult.done;
1233
1625
  if (!done) {
1234
1626
  return {
1235
1627
  ...baseResult,
@@ -1255,6 +1647,7 @@ export async function applyOfflineSyncFileContentChunk(options: {
1255
1647
  includeContent: false,
1256
1648
  includeTranscripts,
1257
1649
  readFile: options.readFile,
1650
+ readFileDigest: options.readFileDigest,
1258
1651
  });
1259
1652
  const currentFile = currentSnapshot.files[0];
1260
1653
  const uploadedState: OfflineSyncFileState = {
@@ -1266,54 +1659,26 @@ export async function applyOfflineSyncFileContentChunk(options: {
1266
1659
 
1267
1660
  try {
1268
1661
  if (currentFile?.sha256 === sha256) {
1662
+ await setSafeFileMtime(root, relPath, mtimeMs);
1269
1663
  return {
1270
1664
  ...baseResult,
1271
1665
  applied: false,
1272
1666
  skipped: true,
1273
- currentFile: toFileState(currentFile),
1274
- };
1275
- }
1276
- if (!baseSha256 && currentFile) {
1277
- const conflict = await recordConflict({
1278
- root,
1279
- relPath,
1280
- reason: "remote_exists_for_local_create",
1281
- localSha256: currentFile.sha256,
1282
- incomingSha256: sha256,
1283
- writeConflictCopies: false,
1284
- sourceId,
1285
- writeFile: options.writeFile,
1286
- });
1287
- return {
1288
- ...baseResult,
1289
- applied: false,
1290
- skipped: false,
1291
- conflict,
1292
- currentFile: toFileState(currentFile),
1667
+ currentFile: uploadedState,
1293
1668
  };
1294
1669
  }
1295
- if (baseSha256 && currentFile?.sha256 !== baseSha256) {
1296
- const conflict = await recordConflict({
1297
- root,
1298
- relPath,
1299
- reason: currentFile ? "remote_changed_for_local_update" : "remote_deleted_for_local_update",
1300
- baseSha256,
1301
- localSha256: currentFile?.sha256,
1302
- incomingSha256: sha256,
1303
- writeConflictCopies: false,
1304
- sourceId,
1305
- writeFile: options.writeFile,
1306
- });
1670
+
1671
+ const conflictResult = await currentFileConflict(currentFile ? toFileState(currentFile) : undefined);
1672
+ if (conflictResult) {
1307
1673
  return {
1308
1674
  ...baseResult,
1309
1675
  applied: false,
1310
1676
  skipped: false,
1311
- conflict,
1312
- ...(currentFile ? { currentFile: toFileState(currentFile) } : {}),
1677
+ ...conflictResult,
1313
1678
  };
1314
1679
  }
1315
1680
 
1316
- await writeSafeFileFromUpload(root, relPath, upload, options.readFile, options.writeFileChunks);
1681
+ await writeSafeFileFromUpload(root, relPath, upload, options.readFile, options.writeFileChunks, mtimeMs);
1317
1682
  return {
1318
1683
  ...baseResult,
1319
1684
  applied: true,
@@ -1513,11 +1878,13 @@ async function writeSafeFileFromUpload(
1513
1878
  upload: OfflineUploadStaging,
1514
1879
  readFile?: (target: OfflineSyncFileTarget) => Promise<Buffer>,
1515
1880
  writeFileChunks?: (target: OfflineSyncFileWriteChunksTarget) => Promise<void>,
1881
+ mtimeMs?: number,
1516
1882
  ): Promise<void> {
1517
1883
  const target = await resolveSafeArchiveTarget(root, relPath);
1518
1884
  const chunks = readOfflineUploadStagingChunks({ root, upload, readFile });
1519
1885
  if (writeFileChunks) {
1520
1886
  await writeFileChunks({ root: root.abs, path: relPath, filePath: target, chunks });
1887
+ await setSafeFileMtime(root, relPath, mtimeMs);
1521
1888
  return;
1522
1889
  }
1523
1890
 
@@ -1540,6 +1907,7 @@ async function writeSafeFileFromUpload(
1540
1907
  throw new Error(`offline sync target is a symlink: ${relPath}`);
1541
1908
  }
1542
1909
  await rename(tmp, target);
1910
+ await setSafeFileMtime(root, relPath, mtimeMs);
1543
1911
  } catch (error) {
1544
1912
  await handle.close().catch(() => {});
1545
1913
  await unlink(tmp).catch(() => {});