@narumitw/pi-sync 0.54.1 → 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.
- package/README.md +19 -11
- package/dist/chunks/{chunk-ORQRHEEO.ts → chunk-26PMXY7E.ts} +3 -3
- package/dist/chunks/{chunk-SDZCTCYQ.ts → chunk-2FETCGGT.ts} +2 -2
- package/dist/chunks/{chunk-HUTYAPFP.ts → chunk-35GM6XKM.ts} +275 -151
- package/dist/chunks/chunk-35GM6XKM.ts.map +7 -0
- package/dist/chunks/{chunk-V6VZ5TLG.ts → chunk-A3TFXY4O.ts} +2 -2
- package/dist/chunks/{chunk-5RZPINUQ.ts → chunk-AAZDBIBJ.ts} +2 -2
- package/dist/chunks/{chunk-5ZWQ33XL.ts → chunk-CAGJOKB7.ts} +5 -5
- package/dist/chunks/{chunk-QE7NZZWT.ts → chunk-E6ONFG57.ts} +15 -2
- package/dist/chunks/{chunk-QE7NZZWT.ts.map → chunk-E6ONFG57.ts.map} +2 -2
- package/dist/chunks/{chunk-IJ6VV6FN.ts → chunk-GC3QOL6J.ts} +2 -2
- package/dist/chunks/{chunk-VHXBCEVF.ts → chunk-HMXUKU43.ts} +3 -3
- package/dist/chunks/{chunk-4SBTAVFL.ts → chunk-I4JYVFUS.ts} +5 -6
- package/dist/chunks/chunk-I4JYVFUS.ts.map +7 -0
- package/dist/chunks/{chunk-2DI6PDZV.ts → chunk-ITFR6NNQ.ts} +14 -2
- package/dist/chunks/chunk-ITFR6NNQ.ts.map +7 -0
- package/dist/chunks/{chunk-3B4Z2SKV.ts → chunk-KATKRR3D.ts} +18 -11
- package/dist/chunks/chunk-KATKRR3D.ts.map +7 -0
- package/dist/chunks/{chunk-ANWNQIR7.ts → chunk-KGE47GCZ.ts} +4 -4
- package/dist/chunks/chunk-KGE47GCZ.ts.map +7 -0
- package/dist/chunks/{chunk-CHSIAC4N.ts → chunk-R35N4CL3.ts} +2 -2
- package/dist/chunks/chunk-UZPMDLTL.ts +54 -0
- package/dist/chunks/chunk-UZPMDLTL.ts.map +7 -0
- package/dist/chunks/{chunk-4QVPTIZB.ts → chunk-VCL7TZYW.ts} +2 -2
- package/dist/chunks/{chunk-WFKUKVCP.ts → chunk-XU2VXGQV.ts} +14 -8
- package/dist/chunks/chunk-XU2VXGQV.ts.map +7 -0
- package/dist/chunks/{chunk-UQ3ALBIB.ts → chunk-YTHQEW35.ts} +3 -3
- package/dist/chunks/{conflict-artifacts-BB2FZMIE.ts → conflict-artifacts-4CZFB7WI.ts} +3 -3
- package/dist/chunks/{conflict-review-JRFJIBEN.ts → conflict-review-ZOTOHL2E.ts} +11 -10
- package/dist/chunks/{conflict-review-JRFJIBEN.ts.map → conflict-review-ZOTOHL2E.ts.map} +1 -1
- package/dist/chunks/{file-selection-WZX7HAY2.ts → file-selection-UNHBVVON.ts} +3 -3
- package/dist/chunks/{manager-result-dispatcher-6MQKFCWB.ts → manager-result-dispatcher-HVRTPEVK.ts} +7 -7
- package/dist/chunks/{manager-ui-IURS4WXY.ts → manager-ui-ZIN4BUSF.ts} +297 -136
- package/dist/chunks/manager-ui-ZIN4BUSF.ts.map +7 -0
- package/dist/chunks/{merge-baseline-store-EVQAAML6.ts → merge-baseline-store-ICXVTCML.ts} +5 -3
- package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts → remote-selection-ui-H6AEVA3Q.ts} +6 -6
- package/dist/chunks/{s3-backend-AQGV6LFT.ts → s3-backend-Y4P3O5B5.ts} +2 -2
- package/dist/chunks/{setup-switch-KTUWL6DE.ts → setup-switch-ZMW5C4M7.ts} +4 -4
- package/dist/chunks/{setup-wizard-GSDPDFC4.ts → setup-wizard-VW7F3JN4.ts} +5 -5
- package/dist/chunks/{sync-inspection-K45QVLHB.ts → sync-inspection-3EMS2J5B.ts} +5 -4
- package/dist/chunks/{sync-operations-QQ2GXKVC.ts → sync-operations-2CWST333.ts} +140 -24
- package/dist/chunks/sync-operations-2CWST333.ts.map +7 -0
- package/dist/index.ts +103 -68
- package/dist/index.ts.map +2 -2
- package/docs/settings.md +47 -10
- package/docs/tui-contract.md +61 -0
- package/package.json +1 -1
- package/src/commands/command-execution.ts +2 -1
- package/src/commands/command-types.ts +4 -0
- package/src/commands/command.ts +3 -1
- package/src/settings/config.ts +13 -10
- package/src/settings/settings-store.ts +1 -0
- package/src/settings/settings-types.ts +2 -0
- package/src/settings/settings-validation.ts +3 -0
- package/src/snapshot/snapshot-transaction.ts +4 -2
- package/src/state/merge-baseline-store.ts +18 -0
- package/src/sync/apply-review.ts +63 -0
- package/src/sync/automatic-sync.ts +45 -33
- package/src/sync/merged-sync.ts +202 -104
- package/src/sync/startup-check.ts +39 -21
- package/src/sync/startup-timings.ts +90 -0
- package/src/sync/sync-inspection.ts +8 -4
- package/src/sync/sync-mutations.ts +49 -11
- package/src/sync/sync-queries.ts +14 -3
- package/src/ui/manager-state.ts +10 -6
- package/src/ui/manager-ui.ts +57 -7
- package/src/ui/merge-review.ts +3 -3
- package/src/ui/merged-sync-summary.ts +2 -18
- package/src/ui/settings-frame.ts +55 -0
- package/src/ui/settings-ui.ts +141 -43
- package/src/ui/setup/setup-prompts.ts +4 -3
- package/src/ui/sync-behavior.ts +15 -0
- package/src/ui/sync-format.ts +69 -38
- package/src/ui/transfer-changes.ts +33 -0
- package/src/ui/transfer-review.ts +27 -0
- package/dist/chunks/chunk-2DI6PDZV.ts.map +0 -7
- package/dist/chunks/chunk-3B4Z2SKV.ts.map +0 -7
- package/dist/chunks/chunk-4SBTAVFL.ts.map +0 -7
- package/dist/chunks/chunk-ANWNQIR7.ts.map +0 -7
- package/dist/chunks/chunk-HUTYAPFP.ts.map +0 -7
- package/dist/chunks/chunk-WFKUKVCP.ts.map +0 -7
- package/dist/chunks/manager-ui-IURS4WXY.ts.map +0 -7
- package/dist/chunks/sync-operations-QQ2GXKVC.ts.map +0 -7
- /package/dist/chunks/{chunk-ORQRHEEO.ts.map → chunk-26PMXY7E.ts.map} +0 -0
- /package/dist/chunks/{chunk-SDZCTCYQ.ts.map → chunk-2FETCGGT.ts.map} +0 -0
- /package/dist/chunks/{chunk-V6VZ5TLG.ts.map → chunk-A3TFXY4O.ts.map} +0 -0
- /package/dist/chunks/{chunk-5RZPINUQ.ts.map → chunk-AAZDBIBJ.ts.map} +0 -0
- /package/dist/chunks/{chunk-5ZWQ33XL.ts.map → chunk-CAGJOKB7.ts.map} +0 -0
- /package/dist/chunks/{chunk-IJ6VV6FN.ts.map → chunk-GC3QOL6J.ts.map} +0 -0
- /package/dist/chunks/{chunk-VHXBCEVF.ts.map → chunk-HMXUKU43.ts.map} +0 -0
- /package/dist/chunks/{chunk-CHSIAC4N.ts.map → chunk-R35N4CL3.ts.map} +0 -0
- /package/dist/chunks/{chunk-4QVPTIZB.ts.map → chunk-VCL7TZYW.ts.map} +0 -0
- /package/dist/chunks/{chunk-UQ3ALBIB.ts.map → chunk-YTHQEW35.ts.map} +0 -0
- /package/dist/chunks/{conflict-artifacts-BB2FZMIE.ts.map → conflict-artifacts-4CZFB7WI.ts.map} +0 -0
- /package/dist/chunks/{file-selection-WZX7HAY2.ts.map → file-selection-UNHBVVON.ts.map} +0 -0
- /package/dist/chunks/{manager-result-dispatcher-6MQKFCWB.ts.map → manager-result-dispatcher-HVRTPEVK.ts.map} +0 -0
- /package/dist/chunks/{merge-baseline-store-EVQAAML6.ts.map → merge-baseline-store-ICXVTCML.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts.map → remote-selection-ui-H6AEVA3Q.ts.map} +0 -0
- /package/dist/chunks/{s3-backend-AQGV6LFT.ts.map → s3-backend-Y4P3O5B5.ts.map} +0 -0
- /package/dist/chunks/{setup-switch-KTUWL6DE.ts.map → setup-switch-ZMW5C4M7.ts.map} +0 -0
- /package/dist/chunks/{setup-wizard-GSDPDFC4.ts.map → setup-wizard-VW7F3JN4.ts.map} +0 -0
- /package/dist/chunks/{sync-inspection-K45QVLHB.ts.map → sync-inspection-3EMS2J5B.ts.map} +0 -0
package/src/sync/merged-sync.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
97
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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 =
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
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
|
|
226
|
-
config,
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
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)) !==
|
|
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
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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(
|
|
535
|
+
const atCommitState = syncStateFingerprint(
|
|
536
|
+
await timings.measure("transfer state read", () => readStateForConfig(config)),
|
|
537
|
+
);
|
|
464
538
|
await validate();
|
|
465
|
-
const atCommit = await
|
|
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
|
|
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
|
|
486
|
-
|
|
487
|
-
|
|
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
|
|
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
|
|
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)) !==
|
|
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
|
|
599
|
-
|
|
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
|
|
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
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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
|
|
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
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
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
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
|
86
|
-
|
|
87
|
-
return withLock("startup-check", () => {
|
|
88
|
+
const inspection = await timings.measure("inspection and guards", () =>
|
|
89
|
+
withStateDirectoryAccess(() => {
|
|
88
90
|
throwIfAborted(signal);
|
|
89
|
-
return
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
})
|