@narumitw/pi-sync 0.54.2 → 0.54.3

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/README.md +19 -11
  2. package/dist/chunks/{chunk-ORQRHEEO.ts → chunk-26PMXY7E.ts} +3 -3
  3. package/dist/chunks/{chunk-SDZCTCYQ.ts → chunk-2FETCGGT.ts} +2 -2
  4. package/dist/chunks/{chunk-MYPSMJHJ.ts → chunk-35GM6XKM.ts} +275 -151
  5. package/dist/chunks/chunk-35GM6XKM.ts.map +7 -0
  6. package/dist/chunks/{chunk-V6VZ5TLG.ts → chunk-A3TFXY4O.ts} +2 -2
  7. package/dist/chunks/{chunk-5RZPINUQ.ts → chunk-AAZDBIBJ.ts} +2 -2
  8. package/dist/chunks/{chunk-5ZWQ33XL.ts → chunk-CAGJOKB7.ts} +5 -5
  9. package/dist/chunks/{chunk-QE7NZZWT.ts → chunk-E6ONFG57.ts} +15 -2
  10. package/dist/chunks/{chunk-QE7NZZWT.ts.map → chunk-E6ONFG57.ts.map} +2 -2
  11. package/dist/chunks/{chunk-IJ6VV6FN.ts → chunk-GC3QOL6J.ts} +2 -2
  12. package/dist/chunks/{chunk-VHXBCEVF.ts → chunk-HMXUKU43.ts} +3 -3
  13. package/dist/chunks/{chunk-4SBTAVFL.ts → chunk-I4JYVFUS.ts} +5 -6
  14. package/dist/chunks/chunk-I4JYVFUS.ts.map +7 -0
  15. package/dist/chunks/{chunk-2DI6PDZV.ts → chunk-ITFR6NNQ.ts} +14 -2
  16. package/dist/chunks/chunk-ITFR6NNQ.ts.map +7 -0
  17. package/dist/chunks/{chunk-3B4Z2SKV.ts → chunk-KATKRR3D.ts} +18 -11
  18. package/dist/chunks/chunk-KATKRR3D.ts.map +7 -0
  19. package/dist/chunks/{chunk-CL7BZRRQ.ts → chunk-KGE47GCZ.ts} +2 -2
  20. package/dist/chunks/{chunk-CHSIAC4N.ts → chunk-R35N4CL3.ts} +2 -2
  21. package/dist/chunks/chunk-UZPMDLTL.ts +54 -0
  22. package/dist/chunks/chunk-UZPMDLTL.ts.map +7 -0
  23. package/dist/chunks/{chunk-4QVPTIZB.ts → chunk-VCL7TZYW.ts} +2 -2
  24. package/dist/chunks/{chunk-WFKUKVCP.ts → chunk-XU2VXGQV.ts} +14 -8
  25. package/dist/chunks/chunk-XU2VXGQV.ts.map +7 -0
  26. package/dist/chunks/{chunk-UQ3ALBIB.ts → chunk-YTHQEW35.ts} +3 -3
  27. package/dist/chunks/{conflict-artifacts-BB2FZMIE.ts → conflict-artifacts-4CZFB7WI.ts} +3 -3
  28. package/dist/chunks/{conflict-review-4PLVLMRO.ts → conflict-review-ZOTOHL2E.ts} +11 -10
  29. package/dist/chunks/{conflict-review-4PLVLMRO.ts.map → conflict-review-ZOTOHL2E.ts.map} +1 -1
  30. package/dist/chunks/{file-selection-WZX7HAY2.ts → file-selection-UNHBVVON.ts} +3 -3
  31. package/dist/chunks/{manager-result-dispatcher-6MQKFCWB.ts → manager-result-dispatcher-HVRTPEVK.ts} +7 -7
  32. package/dist/chunks/{manager-ui-IURS4WXY.ts → manager-ui-ZIN4BUSF.ts} +297 -136
  33. package/dist/chunks/manager-ui-ZIN4BUSF.ts.map +7 -0
  34. package/dist/chunks/{merge-baseline-store-EVQAAML6.ts → merge-baseline-store-ICXVTCML.ts} +5 -3
  35. package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts → remote-selection-ui-H6AEVA3Q.ts} +6 -6
  36. package/dist/chunks/{s3-backend-AQGV6LFT.ts → s3-backend-Y4P3O5B5.ts} +2 -2
  37. package/dist/chunks/{setup-switch-KTUWL6DE.ts → setup-switch-ZMW5C4M7.ts} +4 -4
  38. package/dist/chunks/{setup-wizard-GSDPDFC4.ts → setup-wizard-VW7F3JN4.ts} +5 -5
  39. package/dist/chunks/{sync-inspection-K45QVLHB.ts → sync-inspection-3EMS2J5B.ts} +5 -4
  40. package/dist/chunks/{sync-operations-47OUX76C.ts → sync-operations-2CWST333.ts} +140 -24
  41. package/dist/chunks/sync-operations-2CWST333.ts.map +7 -0
  42. package/dist/index.ts +103 -68
  43. package/dist/index.ts.map +2 -2
  44. package/docs/settings.md +47 -10
  45. package/docs/tui-contract.md +61 -0
  46. package/package.json +1 -1
  47. package/src/commands/command-execution.ts +2 -1
  48. package/src/commands/command-types.ts +4 -0
  49. package/src/commands/command.ts +3 -1
  50. package/src/settings/config.ts +13 -10
  51. package/src/settings/settings-store.ts +1 -0
  52. package/src/settings/settings-types.ts +2 -0
  53. package/src/settings/settings-validation.ts +3 -0
  54. package/src/state/merge-baseline-store.ts +18 -0
  55. package/src/sync/apply-review.ts +63 -0
  56. package/src/sync/automatic-sync.ts +45 -33
  57. package/src/sync/merged-sync.ts +202 -104
  58. package/src/sync/startup-check.ts +39 -21
  59. package/src/sync/startup-timings.ts +90 -0
  60. package/src/sync/sync-inspection.ts +8 -4
  61. package/src/sync/sync-mutations.ts +49 -11
  62. package/src/sync/sync-queries.ts +14 -3
  63. package/src/ui/manager-state.ts +10 -6
  64. package/src/ui/manager-ui.ts +57 -7
  65. package/src/ui/merge-review.ts +3 -3
  66. package/src/ui/merged-sync-summary.ts +2 -18
  67. package/src/ui/settings-frame.ts +55 -0
  68. package/src/ui/settings-ui.ts +141 -43
  69. package/src/ui/setup/setup-prompts.ts +4 -3
  70. package/src/ui/sync-behavior.ts +15 -0
  71. package/src/ui/sync-format.ts +69 -38
  72. package/src/ui/transfer-changes.ts +33 -0
  73. package/src/ui/transfer-review.ts +27 -0
  74. package/dist/chunks/chunk-2DI6PDZV.ts.map +0 -7
  75. package/dist/chunks/chunk-3B4Z2SKV.ts.map +0 -7
  76. package/dist/chunks/chunk-4SBTAVFL.ts.map +0 -7
  77. package/dist/chunks/chunk-MYPSMJHJ.ts.map +0 -7
  78. package/dist/chunks/chunk-WFKUKVCP.ts.map +0 -7
  79. package/dist/chunks/manager-ui-IURS4WXY.ts.map +0 -7
  80. package/dist/chunks/sync-operations-47OUX76C.ts.map +0 -7
  81. /package/dist/chunks/{chunk-ORQRHEEO.ts.map → chunk-26PMXY7E.ts.map} +0 -0
  82. /package/dist/chunks/{chunk-SDZCTCYQ.ts.map → chunk-2FETCGGT.ts.map} +0 -0
  83. /package/dist/chunks/{chunk-V6VZ5TLG.ts.map → chunk-A3TFXY4O.ts.map} +0 -0
  84. /package/dist/chunks/{chunk-5RZPINUQ.ts.map → chunk-AAZDBIBJ.ts.map} +0 -0
  85. /package/dist/chunks/{chunk-5ZWQ33XL.ts.map → chunk-CAGJOKB7.ts.map} +0 -0
  86. /package/dist/chunks/{chunk-IJ6VV6FN.ts.map → chunk-GC3QOL6J.ts.map} +0 -0
  87. /package/dist/chunks/{chunk-VHXBCEVF.ts.map → chunk-HMXUKU43.ts.map} +0 -0
  88. /package/dist/chunks/{chunk-CL7BZRRQ.ts.map → chunk-KGE47GCZ.ts.map} +0 -0
  89. /package/dist/chunks/{chunk-CHSIAC4N.ts.map → chunk-R35N4CL3.ts.map} +0 -0
  90. /package/dist/chunks/{chunk-4QVPTIZB.ts.map → chunk-VCL7TZYW.ts.map} +0 -0
  91. /package/dist/chunks/{chunk-UQ3ALBIB.ts.map → chunk-YTHQEW35.ts.map} +0 -0
  92. /package/dist/chunks/{conflict-artifacts-BB2FZMIE.ts.map → conflict-artifacts-4CZFB7WI.ts.map} +0 -0
  93. /package/dist/chunks/{file-selection-WZX7HAY2.ts.map → file-selection-UNHBVVON.ts.map} +0 -0
  94. /package/dist/chunks/{manager-result-dispatcher-6MQKFCWB.ts.map → manager-result-dispatcher-HVRTPEVK.ts.map} +0 -0
  95. /package/dist/chunks/{merge-baseline-store-EVQAAML6.ts.map → merge-baseline-store-ICXVTCML.ts.map} +0 -0
  96. /package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts.map → remote-selection-ui-H6AEVA3Q.ts.map} +0 -0
  97. /package/dist/chunks/{s3-backend-AQGV6LFT.ts.map → s3-backend-Y4P3O5B5.ts.map} +0 -0
  98. /package/dist/chunks/{setup-switch-KTUWL6DE.ts.map → setup-switch-ZMW5C4M7.ts.map} +0 -0
  99. /package/dist/chunks/{setup-wizard-GSDPDFC4.ts.map → setup-wizard-VW7F3JN4.ts.map} +0 -0
  100. /package/dist/chunks/{sync-inspection-K45QVLHB.ts.map → sync-inspection-3EMS2J5B.ts.map} +0 -0
@@ -22,7 +22,12 @@ import {
22
22
  scanSnapshot,
23
23
  } from "../snapshot/snapshot.js";
24
24
  import type { Snapshot } from "../snapshot/snapshot-types.js";
25
- import { pruneMergeBaselines, readMergeAncestors, stageMergeBaseline } from "../state/merge-baseline-store.js";
25
+ import {
26
+ hasCurrentMergeBaseline,
27
+ pruneMergeBaselines,
28
+ readMergeAncestors,
29
+ stageMergeBaseline,
30
+ } from "../state/merge-baseline-store.js";
26
31
  import {
27
32
  readStateForConfig,
28
33
  statePathForConfig,
@@ -60,6 +65,7 @@ import {
60
65
  requireCompatibleRemoteSelection,
61
66
  } from "./remote-snapshot.js";
62
67
  import { resolveSettingsConflicts } from "./settings-conflicts.js";
68
+ import { createStartupTimings, type StartupTimings } from "./startup-timings.js";
63
69
  import { createSyncDecision } from "./sync-decision.js";
64
70
  import { backupLocal, captureMutationOwner, protectedSessionPaths } from "./sync-local.js";
65
71
  import { inspectRemoteSelection } from "./sync-policy.js";
@@ -75,6 +81,7 @@ export async function mergeSync(
75
81
  factory: SyncBackendFactory = createSyncBackend,
76
82
  resolution?: ConflictResolution,
77
83
  ) {
84
+ const timings = options.startupTimings ?? createStartupTimings();
78
85
  const validateOwner = captureMutationOwner(ctx, options.signal);
79
86
  const isCurrent = () => {
80
87
  try {
@@ -93,33 +100,36 @@ export async function mergeSync(
93
100
  sessionDir: sessionRoot,
94
101
  signal: options.signal,
95
102
  };
96
- const validate = async () => {
97
- validateOwner();
98
- const latest = await loadConfig(options.setup);
99
- validateOwner();
100
- if (configToken !== syncCheckConfigFingerprint(latest))
101
- throw new Error("Sync setup or settings changed during transfer; reopen sync.");
102
- if (sessionRoot !== undefined) {
103
- const currentRoot = await effectiveSessionRoot(ctx);
103
+ const validate = () =>
104
+ timings.measure("transfer authorization", async () => {
104
105
  validateOwner();
105
- if (currentRoot !== sessionRoot)
106
- throw new Error("Session storage root changed during transfer; evidence retained for review.");
107
- }
108
- if (options.auto && (!latest.automaticTransfer || !ctx.isIdle()))
109
- throw new Error("Automatic transfer is no longer authorized at this idle boundary.");
110
- };
106
+ const latest = await loadConfig(options.setup);
107
+ validateOwner();
108
+ if (configToken !== syncCheckConfigFingerprint(latest))
109
+ throw new Error("Sync setup or settings changed during transfer; reopen sync.");
110
+ if (sessionRoot !== undefined) {
111
+ const currentRoot = await effectiveSessionRoot(ctx);
112
+ validateOwner();
113
+ if (currentRoot !== sessionRoot)
114
+ throw new Error("Session storage root changed during transfer; evidence retained for review.");
115
+ }
116
+ if (options.auto && (!latest.automaticTransfer || !ctx.isIdle()))
117
+ throw new Error("Automatic transfer is no longer authorized at this idle boundary.");
118
+ });
111
119
  await validate();
112
- const backend = await factory(config);
120
+ const backend = await timings.measure("transfer backend initialization", () => factory(config));
113
121
  await validate();
114
122
  if (options.auto && backend.capability === "read-check-write-verify") {
115
123
  throw new Error(
116
124
  "Automatic transfer requires conditional publication or a lease; this backend supports only read/check/write/verify. Use manual sync.",
117
125
  );
118
126
  }
119
- const pending = await readMergeJournal(config);
127
+ const pending = await timings.measure("transfer journal read", () => readMergeJournal(config));
120
128
  await validate();
121
129
  if (pending) {
122
- const recovered = await completeJournal(ctx, config, backend, pending, validate, options.signal, options.auto);
130
+ const recovered = await timings.measure("transfer journal recovery", () =>
131
+ completeJournal(ctx, config, backend, pending, validate, timings, options.signal, options.auto),
132
+ );
123
133
  if (!recovered) {
124
134
  if ((options.auto || !options.silent) && !options.signal?.aborted)
125
135
  ctx.ui.notify(
@@ -138,18 +148,57 @@ export async function mergeSync(
138
148
  try {
139
149
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
140
150
  await validate();
141
- const state = await readStateForConfig(config);
151
+ const state = await timings.measure("transfer state read", () => readStateForConfig(config));
142
152
  if (state.unresolved?.length && !config.partialSync)
143
153
  throw new Error(
144
154
  "Partial progress remains unresolved; re-enable partial sync or review an explicit force direction.",
145
155
  );
146
- const localRaw = await createSnapshot(config.snapshotIdentity, snapshotOptions);
156
+ const localRaw = await timings.measure("transfer local snapshot", () =>
157
+ createSnapshot(config.snapshotIdentity, snapshotOptions),
158
+ );
147
159
  const local = portableSnapshot(localRaw, config.localFields);
148
160
  if (!sameLocalFields(state.localFields, config.localFields))
149
161
  throw new Error("Local-field rules changed; review and confirm a directional migration first.");
150
- const head = await backend.readHead(options.signal);
162
+ const head = await timings.measure("transfer remote head", () => backend.readHead(options.signal));
151
163
  await validate();
152
- const rawRemote = head ? await readSnapshotForHead(backend, head, options.signal) : undefined;
164
+ // This is a read-only shortcut, never authority to apply or publish. Pending
165
+ // journals were handled above. Legacy/partial baselines retain the full path.
166
+ if (
167
+ options.auto &&
168
+ !resolution &&
169
+ !config.partialSync &&
170
+ head &&
171
+ state.lastAppliedSnapshot &&
172
+ state.lastRemoteRevision &&
173
+ !state.unresolved?.length &&
174
+ head.snapshotId === state.lastAppliedSnapshot &&
175
+ backend.sameRevision(head.revision, state.lastRemoteRevision) &&
176
+ inspectRemoteSelection(config.include, { selection: head.selection, files: [] }).kind === "same" &&
177
+ matchesAcceptedState(state, config, head, local)
178
+ ) {
179
+ const unchanged = await timings.measure("transfer unchanged verification", async () => {
180
+ if (!(await hasCurrentMergeBaseline(config, local, state))) return false;
181
+ await validate();
182
+ const currentLocal = await timings.measure("transfer local snapshot", () =>
183
+ createSnapshot(config.snapshotIdentity, snapshotOptions),
184
+ );
185
+ await validate();
186
+ const currentState = await timings.measure("transfer state read", () => readStateForConfig(config));
187
+ await validate();
188
+ return (
189
+ sameHashes(fileHashMap(currentLocal), fileHashMap(localRaw)) &&
190
+ syncStateFingerprint(currentState) === syncStateFingerprint(state)
191
+ );
192
+ });
193
+ await validate();
194
+ if (unchanged) {
195
+ timings.markUnchanged();
196
+ return "applied" as const;
197
+ }
198
+ }
199
+ const rawRemote = head
200
+ ? await timings.measure("transfer snapshot download", () => readSnapshotForHead(backend, head, options.signal))
201
+ : undefined;
153
202
  await validate();
154
203
  if (rawRemote) {
155
204
  const files = [...local.files, ...rawRemote.files];
@@ -167,12 +216,15 @@ export async function mergeSync(
167
216
  : undefined;
168
217
  if (rawRemote) {
169
218
  requireCompatibleRemoteSelection(config, rawRemote);
170
- const validation = planFileMerge({
171
- baseline: state.lastFileHashes,
172
- local: local.files,
173
- remote: rawRemote.files,
174
- selectionCompatible: true,
175
- });
219
+ const validation = await timings.measure("transfer merge planning", () =>
220
+ planFileMerge({
221
+ baseline: state.lastFileHashes,
222
+ local: local.files,
223
+ remote: rawRemote.files,
224
+ selectionCompatible: true,
225
+ }),
226
+ );
227
+ await validate();
176
228
  const managedPaths = new Set([
177
229
  ...Object.keys(state.lastFileHashes),
178
230
  ...[...local.files, ...(remote?.files ?? [])].map((file) => file.path),
@@ -206,30 +258,32 @@ export async function mergeSync(
206
258
  throw review("The established remote is missing; choose an explicit recovery direction.", "remote-empty");
207
259
  const selectionState = inspectRemoteSelection(config.include, rawRemote);
208
260
  const selectionCompatible = selectionState.kind === "same";
209
- let plan = planFileMerge({
210
- baseline: state.lastFileHashes,
211
- local: local.files,
212
- remote: remote.files,
213
- selectionCompatible,
214
- protectedPaths: protectedSessionPaths(ctx, sessionRoot),
215
- });
261
+ let plan = await timings.measure("transfer merge planning", () =>
262
+ planFileMerge({
263
+ baseline: state.lastFileHashes,
264
+ local: local.files,
265
+ remote: remote.files,
266
+ selectionCompatible,
267
+ protectedPaths: protectedSessionPaths(ctx, sessionRoot),
268
+ }),
269
+ );
270
+ await validate();
216
271
  let resolved = { plan, fields: [] as string[] };
217
272
  if (config.mergeSettings) {
218
273
  try {
219
- resolved = await resolveSettingsConflicts(config, state, local, remote, plan);
274
+ resolved = await timings.measure("transfer settings merge", () =>
275
+ resolveSettingsConflicts(config, state, local, remote, plan),
276
+ );
220
277
  } catch (error) {
221
278
  if (!config.partialSync) throw error;
222
279
  }
223
280
  }
224
281
  await validate();
225
- plan = await resolveContentConflicts(
226
- config,
227
- state,
228
- local,
229
- remote,
230
- resolved.plan,
231
- protectedSessionPaths(ctx, sessionRoot),
232
- { textBudget, signal: options.signal },
282
+ plan = await timings.measure("transfer content merge", () =>
283
+ resolveContentConflicts(config, state, local, remote, resolved.plan, protectedSessionPaths(ctx, sessionRoot), {
284
+ textBudget,
285
+ signal: options.signal,
286
+ }),
233
287
  );
234
288
  await validate();
235
289
  if (resolution) {
@@ -353,40 +407,49 @@ export async function mergeSync(
353
407
  Boolean(config.partialSync && rawRemote.version !== 3);
354
408
  const apply = !sameHashes(fileHashMap(localRaw), fileHashMap(after));
355
409
  if (!publish && !apply) {
356
- const beforeEvidence = await backend.readHead(options.signal);
410
+ const beforeEvidence = await timings.measure("transfer remote head", () => backend.readHead(options.signal));
357
411
  await validate();
358
412
  if (!beforeEvidence || !backend.sameRevision(beforeEvidence.revision, head.revision))
359
413
  throw new Error("Remote changed before baseline acceptance; retry from a fresh observation.");
360
414
  progress = await persistProgress();
361
415
  await validate();
362
- const currentLocal = await createSnapshot(config.snapshotIdentity, snapshotOptions);
416
+ const currentLocal = await timings.measure("transfer local snapshot", () =>
417
+ createSnapshot(config.snapshotIdentity, snapshotOptions),
418
+ );
363
419
  await validate();
364
420
  if (
365
421
  !sameHashes(fileHashMap(currentLocal), fileHashMap(localRaw)) ||
366
- syncStateFingerprint(await readStateForConfig(config)) !== syncStateFingerprint(state)
422
+ syncStateFingerprint(await timings.measure("transfer state read", () => readStateForConfig(config))) !==
423
+ syncStateFingerprint(state)
367
424
  )
368
425
  throw new Error(
369
426
  "Local content or baseline changed before baseline acceptance; retry from a fresh observation.",
370
427
  );
371
428
  await validate();
372
- const currentHead = await backend.readHead(options.signal);
429
+ const currentHead = await timings.measure("transfer remote head", () => backend.readHead(options.signal));
373
430
  await validate();
374
431
  if (!currentHead || !backend.sameRevision(currentHead.revision, head.revision))
375
432
  throw new Error("Remote changed before baseline acceptance; retry from a fresh observation.");
376
- await writeAcceptedState(config, head, accepted, validate, progress);
377
- await pruneMergeBaselines(
378
- config,
379
- acceptedState(config, head, accepted, progress),
380
- captureMutationOwner(ctx, options.signal),
433
+ await timings.measure("transfer baseline write", () =>
434
+ writeAcceptedState(config, head, accepted, validate, progress),
435
+ );
436
+ await timings.measure("transfer cleanup", () =>
437
+ pruneMergeBaselines(
438
+ config,
439
+ acceptedState(config, head, accepted, progress),
440
+ captureMutationOwner(ctx, options.signal),
441
+ ),
381
442
  );
382
443
  await validate();
383
- await pruneCompletedConflicts(
384
- config,
385
- backend.identity,
386
- acceptedState(config, head, accepted, progress),
387
- accepted,
388
- remote,
389
- captureMutationOwner(ctx, options.signal),
444
+ await timings.measure("transfer cleanup", () =>
445
+ pruneCompletedConflicts(
446
+ config,
447
+ backend.identity,
448
+ acceptedState(config, head, accepted, progress),
449
+ accepted,
450
+ remote,
451
+ captureMutationOwner(ctx, options.signal),
452
+ ),
390
453
  );
391
454
  await validate();
392
455
  if (!options.silent)
@@ -398,7 +461,9 @@ export async function mergeSync(
398
461
  );
399
462
  return "applied" as const;
400
463
  }
401
- await preflightMergedTargets(localRaw, after, snapshotOptions, ctx.sessionManager.getSessionFile?.());
464
+ await timings.measure("transfer preflight", () =>
465
+ preflightMergedTargets(localRaw, after, snapshotOptions, ctx.sessionManager.getSessionFile?.()),
466
+ );
402
467
  await validate();
403
468
  if (!config.skipSecretScan && scanSnapshot(upload).length)
404
469
  throw new Error("Refusing to merge possible secrets. Review managed content before syncing.");
@@ -430,19 +495,26 @@ export async function mergeSync(
430
495
  return "cancelled" as const;
431
496
  }
432
497
  await validate();
433
- const refreshed = await createSnapshot(config.snapshotIdentity, snapshotOptions);
498
+ const refreshed = await timings.measure("transfer local snapshot", () =>
499
+ createSnapshot(config.snapshotIdentity, snapshotOptions),
500
+ );
434
501
  if (!sameHashes(fileHashMap(refreshed), fileHashMap(localRaw)))
435
502
  throw new Error("Local content changed during review; no merged transfer was performed.");
436
- if (syncStateFingerprint(await readStateForConfig(config)) !== syncStateFingerprint(state))
503
+ if (
504
+ syncStateFingerprint(await timings.measure("transfer state read", () => readStateForConfig(config))) !==
505
+ syncStateFingerprint(state)
506
+ )
437
507
  throw new Error("Sync baseline changed during review; retry from a fresh observation.");
438
508
  await validate();
439
- const refreshedHead = await backend.readHead(options.signal);
509
+ const refreshedHead = await timings.measure("transfer remote head", () => backend.readHead(options.signal));
440
510
  await validate();
441
511
  if (!refreshedHead || !backend.sameRevision(refreshedHead.revision, head.revision))
442
512
  throw new Error("Remote changed during review; no merged transfer was performed.");
443
513
  progress = await persistProgress();
444
514
  await validate();
445
- const backup = await backupLocal(config.snapshotIdentity, snapshotOptions, options.signal);
515
+ const backup = await timings.measure("transfer backup", () =>
516
+ backupLocal(config.snapshotIdentity, snapshotOptions, options.signal),
517
+ );
446
518
  await validate();
447
519
  const journal: MergeJournal = {
448
520
  version: 1,
@@ -458,11 +530,15 @@ export async function mergeSync(
458
530
  ...(sessionRoot !== undefined ? { sessionRoot } : {}),
459
531
  ...(!publish ? { committedHead: head, applyOnly: true } : {}),
460
532
  };
461
- await writeMergeJournal(config, journal);
533
+ await timings.measure("transfer journal write", () => writeMergeJournal(config, journal));
462
534
  // No backend call has begun: a stale candidate can be retired without ambiguous publication.
463
- const atCommitState = syncStateFingerprint(await readStateForConfig(config));
535
+ const atCommitState = syncStateFingerprint(
536
+ await timings.measure("transfer state read", () => readStateForConfig(config)),
537
+ );
464
538
  await validate();
465
- const atCommit = await createSnapshot(config.snapshotIdentity, snapshotOptions);
539
+ const atCommit = await timings.measure("transfer local snapshot", () =>
540
+ createSnapshot(config.snapshotIdentity, snapshotOptions),
541
+ );
466
542
  await validate();
467
543
  if (!sameHashes(fileHashMap(atCommit), fileHashMap(localRaw)) || atCommitState !== journal.stateIdentity) {
468
544
  await validate();
@@ -473,7 +549,9 @@ export async function mergeSync(
473
549
  }
474
550
  // Backup/journal awaits can also change physical identity without changing any bytes.
475
551
  try {
476
- await preflightMergedTargets(atCommit, after, snapshotOptions, ctx.sessionManager.getSessionFile?.());
552
+ await timings.measure("transfer preflight", () =>
553
+ preflightMergedTargets(atCommit, after, snapshotOptions, ctx.sessionManager.getSessionFile?.()),
554
+ );
477
555
  await validate();
478
556
  } catch (error) {
479
557
  await validate();
@@ -482,13 +560,15 @@ export async function mergeSync(
482
560
  }
483
561
  try {
484
562
  if (publish) {
485
- const result = await backend.publishSnapshot(upload, expectedRemoteHead(head), {
486
- signal: options.signal,
487
- onCommit: options.onCommit,
488
- });
563
+ const result = await timings.measure("transfer remote publication", () =>
564
+ backend.publishSnapshot(upload, expectedRemoteHead(head), {
565
+ signal: options.signal,
566
+ onCommit: options.onCommit,
567
+ }),
568
+ );
489
569
  journal.committedHead = result.head;
490
570
  journal.warnings = result.warnings;
491
- await writeMergeJournal(config, journal);
571
+ await timings.measure("transfer journal write", () => writeMergeJournal(config, journal));
492
572
  } else options.onCommit?.();
493
573
  } catch (error) {
494
574
  if (
@@ -504,7 +584,9 @@ export async function mergeSync(
504
584
  // Every uncertain/after-commit outcome retains evidence and is never blindly retried.
505
585
  throw mergeTransferError(error, config.backend);
506
586
  }
507
- const completed = await completeJournal(ctx, config, backend, journal, validate, options.signal, options.auto);
587
+ const completed = await timings.measure("transfer journal recovery", () =>
588
+ completeJournal(ctx, config, backend, journal, validate, timings, options.signal, options.auto),
589
+ );
508
590
  if (!completed) {
509
591
  if (!options.signal?.aborted && (options.auto || !options.silent))
510
592
  ctx.ui.notify(
@@ -529,7 +611,7 @@ export async function mergeSync(
529
611
  return "applied" as const;
530
612
  }
531
613
  } finally {
532
- await releaseConflictArtifacts(config, ownedStatePath, createdArtifacts);
614
+ await timings.measure("transfer cleanup", () => releaseConflictArtifacts(config, ownedStatePath, createdArtifacts));
533
615
  }
534
616
  }
535
617
 
@@ -539,6 +621,7 @@ async function completeJournal(
539
621
  backend: SyncBackend,
540
622
  journal: MergeJournal,
541
623
  validate: () => Promise<void>,
624
+ timings: StartupTimings,
542
625
  signal?: AbortSignal,
543
626
  auto = false,
544
627
  ) {
@@ -558,7 +641,7 @@ async function completeJournal(
558
641
  );
559
642
  }
560
643
  const snapshotOptions = { ...snapshotOptionsForContext(ctx, config), sessionDir: sessionRoot, signal };
561
- const head = await backend.readHead(signal);
644
+ const head = await timings.measure("transfer remote head", () => backend.readHead(signal));
562
645
  if (
563
646
  !journal.committedHead &&
564
647
  head &&
@@ -566,10 +649,13 @@ async function completeJournal(
566
649
  backend.matchesUncommittedRecoveryHead?.(head, journal.expectedHead))
567
650
  ) {
568
651
  await validate();
569
- const local = await createSnapshot(config.snapshotIdentity, snapshotOptions);
652
+ const local = await timings.measure("transfer local snapshot", () =>
653
+ createSnapshot(config.snapshotIdentity, snapshotOptions),
654
+ );
570
655
  if (
571
656
  !sameHashes(fileHashMap(local), fileHashMap(journal.before)) ||
572
- syncStateFingerprint(await readStateForConfig(config)) !== journal.stateIdentity
657
+ syncStateFingerprint(await timings.measure("transfer state read", () => readStateForConfig(config))) !==
658
+ journal.stateIdentity
573
659
  ) {
574
660
  throw new Error(
575
661
  "Uncertain publication is not active, but local content or baseline changed; keep the journal for reviewed recovery.",
@@ -582,7 +668,7 @@ async function completeJournal(
582
668
  // Baseline publication is the durable completion boundary. A later remote revision
583
669
  // cannot undo that acceptance; retire only the journal for the recorded commit.
584
670
  if (journal.committedHead && (!head || !backend.sameRevision(head.revision, journal.committedHead.revision))) {
585
- const state = await readStateForConfig(config);
671
+ const state = await timings.measure("transfer state read", () => readStateForConfig(config));
586
672
  await validate();
587
673
  if (
588
674
  matchesAcceptedState(state, config, journal.committedHead, journal.accepted ?? journal.after, journal.progress)
@@ -595,8 +681,10 @@ async function completeJournal(
595
681
  }
596
682
  }
597
683
  if (journal.applyOnly && (!head || !backend.sameRevision(head.revision, journal.expectedHead.revision))) {
598
- const local = await createSnapshot(config.snapshotIdentity, snapshotOptions);
599
- const state = await readStateForConfig(config);
684
+ const local = await timings.measure("transfer local snapshot", () =>
685
+ createSnapshot(config.snapshotIdentity, snapshotOptions),
686
+ );
687
+ const state = await timings.measure("transfer state read", () => readStateForConfig(config));
600
688
  await validate();
601
689
  if (
602
690
  sameHashes(fileHashMap(local), fileHashMap(journal.before)) &&
@@ -616,14 +704,16 @@ async function completeJournal(
616
704
  "Publication outcome cannot be reconciled with the current remote head. Journal and backup retained; do not retry publication or restore old bytes blindly.",
617
705
  );
618
706
  }
619
- const observed = await readSnapshotForHead(backend, head, signal);
707
+ const observed = await timings.measure("transfer snapshot download", () =>
708
+ readSnapshotForHead(backend, head, signal),
709
+ );
620
710
  requireCompatibleRemoteSelection(config, observed);
621
711
  if (
622
712
  !sameHashes(fileHashMap(observed), fileHashMap(journal.upload)) ||
623
713
  JSON.stringify(observed.selection) !== JSON.stringify(journal.upload.selection)
624
714
  )
625
715
  throw new Error("Remote snapshot does not match the recorded merge publication.");
626
- const state = await readStateForConfig(config);
716
+ const state = await timings.measure("transfer state read", () => readStateForConfig(config));
627
717
  if (matchesAcceptedState(state, config, head, journal.accepted ?? journal.after, journal.progress)) {
628
718
  await validate();
629
719
  await clearMergeJournal(config);
@@ -646,36 +736,44 @@ async function completeJournal(
646
736
  validateOwner();
647
737
  if (auto && !ctx.isIdle()) throw new Error("Automatic file apply is no longer idle; evidence retained.");
648
738
  };
649
- await applyMergedSnapshot(
650
- journal.before,
651
- journal.after,
652
- protectedSessionPaths(ctx, sessionRoot),
653
- { ...snapshotOptions, validateMutation },
654
- validate,
655
- ctx.sessionManager.getSessionFile?.(),
739
+ await timings.measure("transfer local apply", () =>
740
+ applyMergedSnapshot(
741
+ journal.before,
742
+ journal.after,
743
+ protectedSessionPaths(ctx, sessionRoot),
744
+ { ...snapshotOptions, validateMutation },
745
+ validate,
746
+ ctx.sessionManager.getSessionFile?.(),
747
+ ),
656
748
  );
657
749
  await validate();
658
- const headAtAcceptance = await backend.readHead(signal);
750
+ const headAtAcceptance = await timings.measure("transfer remote head", () => backend.readHead(signal));
659
751
  await validate();
660
752
  if (!headAtAcceptance || !backend.sameRevision(headAtAcceptance.revision, head.revision))
661
753
  throw new Error("Remote changed during merged apply; journal and backup retained for review.");
662
- await writeAcceptedState(config, head, journal.accepted ?? journal.after, validate, journal.progress);
754
+ await timings.measure("transfer baseline write", () =>
755
+ writeAcceptedState(config, head, journal.accepted ?? journal.after, validate, journal.progress),
756
+ );
663
757
  await validate();
664
758
  await clearMergeJournal(config);
665
759
  await validate();
666
- await pruneMergeBaselines(
667
- config,
668
- acceptedState(config, head, journal.accepted ?? journal.after, journal.progress),
669
- captureMutationOwner(ctx, signal),
760
+ await timings.measure("transfer cleanup", () =>
761
+ pruneMergeBaselines(
762
+ config,
763
+ acceptedState(config, head, journal.accepted ?? journal.after, journal.progress),
764
+ captureMutationOwner(ctx, signal),
765
+ ),
670
766
  );
671
767
  await validate();
672
- await pruneCompletedConflicts(
673
- config,
674
- backend.identity,
675
- acceptedState(config, head, journal.accepted ?? journal.after, journal.progress),
676
- journal.accepted ?? journal.after,
677
- journal.upload,
678
- captureMutationOwner(ctx, signal),
768
+ await timings.measure("transfer cleanup", () =>
769
+ pruneCompletedConflicts(
770
+ config,
771
+ backend.identity,
772
+ acceptedState(config, head, journal.accepted ?? journal.after, journal.progress),
773
+ journal.accepted ?? journal.after,
774
+ journal.upload,
775
+ captureMutationOwner(ctx, signal),
776
+ ),
679
777
  );
680
778
  await validate();
681
779
  return true;
@@ -10,6 +10,7 @@ import type { SyncAttentionController } from "../ui/sync-attention.js";
10
10
  import { setSyncStatus } from "../ui/sync-status.js";
11
11
  import { safeTerminalText } from "../ui/terminal-text.js";
12
12
  import { combineSignals, throwIfAborted } from "./signals.js";
13
+ import { createStartupTimings } from "./startup-timings.js";
13
14
  import { errorMessage } from "./sync-errors.js";
14
15
  import type { SyncLoaders } from "./sync-loaders.js";
15
16
 
@@ -46,6 +47,7 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
46
47
  return;
47
48
  }
48
49
  if (active) throw new Error("Previous startup check has not been drained.");
50
+ const timings = createStartupTimings();
49
51
  const controller = new AbortController();
50
52
  const signal = combineSignals(sessionSignal, controller.signal);
51
53
  const task = {
@@ -62,6 +64,7 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
62
64
  );
63
65
  let config: AnySyncConfig | undefined;
64
66
  let checking = false;
67
+ let stopped = false;
65
68
  // Handle every detached rejection here; Pi's awaited event runner cannot catch it.
66
69
  task.promise = Promise.resolve()
67
70
  .then(async () => {
@@ -75,20 +78,22 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
75
78
  const identity = syncCheckConfigFingerprint(captured);
76
79
  checking = true;
77
80
  setSyncStatus(ctx, "sync ...");
78
- const { inspectSync } = await loaders.inspection();
81
+ const { inspectSync } = await timings.measure("inspection import", () => loaders.inspection());
79
82
  throwIfAborted(signal);
80
83
  const currentConfig = await loadConfigForCheck();
81
84
  throwIfAborted(signal);
82
85
  if (syncCheckConfigFingerprint(currentConfig) !== identity) return;
83
86
  // Lock order: state migration guard → sync operation → backend cache.
84
87
  // Read heads may write only private cache data. Never call doctor/syncBoth.
85
- const inspection = await withStateDirectoryAccess(() => {
86
- throwIfAborted(signal);
87
- return withLock("startup-check", () => {
88
+ const inspection = await timings.measure("inspection and guards", () =>
89
+ withStateDirectoryAccess(() => {
88
90
  throwIfAborted(signal);
89
- return inspectSync(captured, snapshotOptionsForContext(ctx, captured), signal);
90
- });
91
- });
91
+ return withLock("startup-check", () => {
92
+ throwIfAborted(signal);
93
+ return inspectSync(captured, snapshotOptionsForContext(ctx, captured), signal, undefined, timings);
94
+ });
95
+ }),
96
+ );
92
97
  throwIfAborted(signal);
93
98
  const latest = await loadConfigForCheck();
94
99
  throwIfAborted(signal);
@@ -109,26 +114,29 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
109
114
  inspection.head &&
110
115
  inspection.selectionState?.kind === "same"
111
116
  ) {
112
- const operations = await loaders.operations();
117
+ const operations = await timings.measure("operations import", () => loaders.operations());
113
118
  throwIfAborted(signal);
114
119
  if (!isCurrent()) return;
115
120
  if (!ctx.isIdle()) {
116
121
  pending = { ctx, signal: sessionSignal, config: captured };
117
122
  return;
118
123
  }
119
- await withStateDirectoryAccess(() =>
120
- withLock("startup-transfer", () =>
121
- operations.syncBoth(ctx, {
122
- args: [],
123
- yes: true,
124
- force: false,
125
- stale: false,
126
- silent: true,
127
- reload: false,
128
- auto: true,
129
- signal,
130
- onCommit: () => attention.clearObservation(),
131
- }),
124
+ await timings.measure("automatic transfer", () =>
125
+ withStateDirectoryAccess(() =>
126
+ withLock("startup-transfer", () =>
127
+ operations.syncBoth(ctx, {
128
+ args: [],
129
+ yes: true,
130
+ force: false,
131
+ stale: false,
132
+ silent: true,
133
+ reload: false,
134
+ auto: true,
135
+ signal,
136
+ startupTimings: timings,
137
+ onCommit: () => attention.clearObservation(),
138
+ }),
139
+ ),
132
140
  ),
133
141
  );
134
142
  throwIfAborted(signal);
@@ -145,6 +153,7 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
145
153
  });
146
154
  if (ctx.mode === "rpc") attention.notifyObservation(ctx);
147
155
  } catch (error) {
156
+ stopped = true;
148
157
  if (!isCurrent() || sessionSignal.aborted || isMissingConfigError(error)) return;
149
158
  if (signal.aborted && controller.signal.reason?.name !== "TimeoutError") return;
150
159
  // A settings edit invalidates failures as well as successful observations.
@@ -177,6 +186,15 @@ export function createStartupCheck(loaders: SyncLoaders, attention: SyncAttentio
177
186
  }
178
187
  } else setSyncStatus(ctx, undefined);
179
188
  }
189
+ if (isCurrent()) {
190
+ timings.publish(
191
+ ctx,
192
+ config?.debug ?? false,
193
+ sessionSignal,
194
+ "background startup",
195
+ signal.aborted || stopped ? "stopped" : "completed",
196
+ );
197
+ }
180
198
  if (active === task) active = undefined;
181
199
  }
182
200
  })