@narumitw/pi-sync 0.54.3 → 0.57.0
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 +38 -11
- package/dist/chunks/agent-candidate-3JN6DJKT.ts +26 -0
- package/dist/chunks/agent-plan-QCUJWBP4.ts +99 -0
- package/dist/chunks/agent-plan-QCUJWBP4.ts.map +7 -0
- package/dist/chunks/agent-runner-E2JU2VZ6.ts +182 -0
- package/dist/chunks/agent-runner-E2JU2VZ6.ts.map +7 -0
- package/dist/chunks/agent-store-A3F6IYWZ.ts +152 -0
- package/dist/chunks/agent-store-A3F6IYWZ.ts.map +7 -0
- package/dist/chunks/chunk-5Q5SSKNG.ts +128 -0
- package/dist/chunks/chunk-5Q5SSKNG.ts.map +7 -0
- package/dist/chunks/chunk-5XZOC7FD.ts +215 -0
- package/dist/chunks/chunk-5XZOC7FD.ts.map +7 -0
- package/dist/chunks/{chunk-GC3QOL6J.ts → chunk-AAEEHR7H.ts} +31 -87
- package/dist/chunks/chunk-AAEEHR7H.ts.map +7 -0
- package/dist/chunks/{chunk-R35N4CL3.ts → chunk-B4QZBDBT.ts} +2 -2
- package/dist/chunks/{chunk-KGE47GCZ.ts → chunk-CRKTAPSL.ts} +8 -8
- package/dist/chunks/chunk-DT7M4VQS.ts +69 -0
- package/dist/chunks/chunk-DT7M4VQS.ts.map +7 -0
- package/dist/chunks/chunk-ED3TYIVW.ts +229 -0
- package/dist/chunks/chunk-ED3TYIVW.ts.map +7 -0
- package/dist/chunks/{chunk-HMXUKU43.ts → chunk-EUMINX7L.ts} +6 -6
- package/dist/chunks/chunk-FQOSHGJ7.ts +215 -0
- package/dist/chunks/chunk-FQOSHGJ7.ts.map +7 -0
- package/dist/chunks/chunk-GNH73BJ5.ts +153 -0
- package/dist/chunks/chunk-GNH73BJ5.ts.map +7 -0
- package/dist/chunks/{chunk-YTHQEW35.ts → chunk-GP5DU62N.ts} +8 -9
- package/dist/chunks/chunk-GP5DU62N.ts.map +7 -0
- package/dist/chunks/{chunk-AAZDBIBJ.ts → chunk-GSNPASKB.ts} +2 -2
- package/dist/chunks/{chunk-E6ONFG57.ts → chunk-IWZH5KYS.ts} +11 -8
- package/dist/chunks/chunk-IWZH5KYS.ts.map +7 -0
- package/dist/chunks/{chunk-CAGJOKB7.ts → chunk-JJTARUSO.ts} +15 -14
- package/dist/chunks/chunk-JJTARUSO.ts.map +7 -0
- package/dist/chunks/{chunk-ITFR6NNQ.ts → chunk-NLWS55B6.ts} +2 -1
- package/dist/chunks/{chunk-ITFR6NNQ.ts.map → chunk-NLWS55B6.ts.map} +2 -2
- package/dist/chunks/{chunk-2FETCGGT.ts → chunk-OEWQVWLC.ts} +4 -4
- package/dist/chunks/{chunk-35GM6XKM.ts → chunk-OTZRCKYP.ts} +128 -382
- package/dist/chunks/chunk-OTZRCKYP.ts.map +7 -0
- package/dist/chunks/{chunk-XU2VXGQV.ts → chunk-OWBAZA6J.ts} +14 -8
- package/dist/chunks/chunk-OWBAZA6J.ts.map +7 -0
- package/dist/chunks/chunk-PRUEIIK7.ts +24 -0
- package/dist/chunks/chunk-PRUEIIK7.ts.map +7 -0
- package/dist/chunks/chunk-QGK445K7.ts +21 -0
- package/dist/chunks/chunk-QGK445K7.ts.map +7 -0
- package/dist/chunks/{chunk-KATKRR3D.ts → chunk-QO333NM4.ts} +32 -11
- package/dist/chunks/chunk-QO333NM4.ts.map +7 -0
- package/dist/chunks/{chunk-I4JYVFUS.ts → chunk-QSHMOL3I.ts} +21 -22
- package/dist/chunks/chunk-QSHMOL3I.ts.map +7 -0
- package/dist/chunks/chunk-S2FMH5FT.ts +97 -0
- package/dist/chunks/chunk-S2FMH5FT.ts.map +7 -0
- package/dist/chunks/{chunk-BVVP33U5.ts → chunk-SWRVXVFD.ts} +12 -9
- package/dist/chunks/chunk-SWRVXVFD.ts.map +7 -0
- package/dist/chunks/{chunk-ZX2Q6THB.ts → chunk-UBV24CG4.ts} +4 -5
- package/dist/chunks/chunk-UBV24CG4.ts.map +7 -0
- package/dist/chunks/chunk-UOHR7LRO.ts +70 -0
- package/dist/chunks/chunk-UOHR7LRO.ts.map +7 -0
- package/dist/chunks/{chunk-VCL7TZYW.ts → chunk-UPNVIUBN.ts} +3 -3
- package/dist/chunks/chunk-VJN7KU4J.ts +350 -0
- package/dist/chunks/chunk-VJN7KU4J.ts.map +7 -0
- package/dist/chunks/chunk-YHT4YTYG.ts +31 -0
- package/dist/chunks/chunk-YHT4YTYG.ts.map +7 -0
- package/dist/chunks/{chunk-A3TFXY4O.ts → chunk-YK52VDXG.ts} +6 -5
- package/dist/chunks/chunk-YK52VDXG.ts.map +7 -0
- package/dist/chunks/config-KGIYTTXQ.ts +37 -0
- package/dist/chunks/{conflict-artifacts-4CZFB7WI.ts → conflict-artifacts-FXFIIBCB.ts} +8 -7
- package/dist/chunks/{conflict-review-ZOTOHL2E.ts → conflict-review-6WIEQLXW.ts} +31 -25
- package/dist/chunks/{conflict-review-ZOTOHL2E.ts.map → conflict-review-6WIEQLXW.ts.map} +1 -1
- package/dist/chunks/continuous-record-LFRVXQBD.ts +30 -0
- package/dist/chunks/continuous-sync-AWAAOXK2.ts +545 -0
- package/dist/chunks/continuous-sync-AWAAOXK2.ts.map +7 -0
- package/dist/chunks/{file-selection-UNHBVVON.ts → file-selection-3JJYOVNR.ts} +5 -5
- package/dist/chunks/{git-backend-FD7AVSK6.ts → git-backend-KYDZ673X.ts} +28 -32
- package/dist/chunks/git-backend-KYDZ673X.ts.map +7 -0
- package/dist/chunks/{manager-result-dispatcher-HVRTPEVK.ts → manager-result-dispatcher-AGBBVDDS.ts} +10 -9
- package/dist/chunks/{manager-ui-ZIN4BUSF.ts → manager-ui-KRAW3NE2.ts} +184 -45
- package/dist/chunks/manager-ui-KRAW3NE2.ts.map +7 -0
- package/dist/chunks/{merge-baseline-store-ICXVTCML.ts → merge-baseline-store-RFCAH7KL.ts} +5 -5
- package/dist/chunks/{remote-selection-ui-H6AEVA3Q.ts → remote-selection-ui-5725JDNC.ts} +9 -8
- package/dist/chunks/remote-selection-ui-5725JDNC.ts.map +7 -0
- package/dist/chunks/{s3-backend-Y4P3O5B5.ts → s3-backend-Y7WSCLJI.ts} +38 -38
- package/dist/chunks/s3-backend-Y7WSCLJI.ts.map +7 -0
- package/dist/chunks/{setup-switch-ZMW5C4M7.ts → setup-switch-E7PHKI6J.ts} +7 -6
- package/dist/chunks/setup-switch-E7PHKI6J.ts.map +7 -0
- package/dist/chunks/{setup-wizard-VW7F3JN4.ts → setup-wizard-65DROCIJ.ts} +14 -13
- package/dist/chunks/setup-wizard-65DROCIJ.ts.map +7 -0
- package/dist/chunks/{sync-inspection-3EMS2J5B.ts → sync-inspection-XFJOAMJD.ts} +8 -8
- package/dist/chunks/sync-inspection-XFJOAMJD.ts.map +7 -0
- package/dist/chunks/{sync-operations-2CWST333.ts → sync-operations-HDUDT5PD.ts} +62 -63
- package/dist/chunks/sync-operations-HDUDT5PD.ts.map +7 -0
- package/dist/chunks/{webdav-backend-TBILVEQK.ts → webdav-backend-6K26F6LT.ts} +39 -48
- package/dist/chunks/webdav-backend-6K26F6LT.ts.map +7 -0
- package/dist/index.ts +165 -85
- package/dist/index.ts.map +3 -3
- package/docs/agent-mode-verification.md +99 -0
- package/docs/continuous-sync-verification.md +87 -0
- package/docs/settings.md +32 -247
- package/package.json +11 -5
- package/skills/configure-pi-sync/SKILL.md +54 -0
- package/skills/configure-pi-sync/references/configuration.md +650 -0
- package/src/backends/git/git-backend.ts +1 -4
- package/src/backends/git/git-runner.ts +1 -5
- package/src/backends/s3/s3-backend.ts +3 -25
- package/src/backends/s3/s3-client.ts +14 -2
- package/src/backends/snapshot-pointer.ts +27 -0
- package/src/backends/webdav/webdav-backend.ts +3 -29
- package/src/backends/webdav/webdav-client.ts +11 -7
- package/src/commands/command-execution.ts +5 -2
- package/src/commands/command-handler.ts +15 -3
- package/src/commands/command-types.ts +6 -0
- package/src/commands/command.ts +1 -0
- package/src/settings/config.ts +7 -0
- package/src/settings/settings-store.ts +0 -34
- package/src/settings/settings-types.ts +8 -0
- package/src/settings/settings-validation.ts +1 -1
- package/src/snapshot/snapshot-codec.ts +1 -5
- package/src/state/lock.ts +13 -10
- package/src/state/merge-baseline-store.ts +2 -2
- package/src/state/state-directory.ts +8 -1
- package/src/sync/agent-candidate.ts +251 -0
- package/src/sync/agent-plan.ts +87 -0
- package/src/sync/agent-policy.ts +13 -0
- package/src/sync/agent-request.ts +17 -0
- package/src/sync/agent-runner.ts +188 -0
- package/src/sync/agent-store.ts +151 -0
- package/src/sync/agent-sync.ts +91 -0
- package/src/sync/automatic-sync.ts +2 -1
- package/src/sync/continuous-errors.ts +85 -0
- package/src/sync/continuous-lease.ts +221 -0
- package/src/sync/continuous-record.ts +179 -0
- package/src/sync/continuous-storage.ts +55 -0
- package/src/sync/continuous-sync.ts +516 -0
- package/src/sync/continuous-watch.ts +59 -0
- package/src/sync/merged-sync.ts +70 -12
- package/src/sync/remote-snapshot.ts +2 -3
- package/src/sync/settings-merge.ts +19 -3
- package/src/sync/setup-switch.ts +1 -5
- package/src/sync/startup-check.ts +15 -11
- package/src/sync/sync-inspection.ts +8 -1
- package/src/sync/sync-mutations.ts +6 -17
- package/src/sync/sync-queries.ts +5 -0
- package/src/sync/text-merge.ts +3 -3
- package/src/sync-extension.ts +75 -19
- package/src/ui/agent-mode-setting.ts +91 -0
- package/src/ui/manager-state.ts +2 -0
- package/src/ui/remote-selection-ui.ts +5 -5
- package/src/ui/settings-ui.ts +83 -1
- package/src/ui/setup/s3-credentials-ui.ts +1 -5
- package/src/ui/setup/setup-name-ui.ts +2 -5
- package/src/ui/setup/setup-switcher.ts +2 -0
- package/src/ui/sync-setups-ui.ts +3 -5
- package/dist/chunks/chunk-26PMXY7E.ts +0 -362
- package/dist/chunks/chunk-26PMXY7E.ts.map +0 -7
- package/dist/chunks/chunk-35GM6XKM.ts.map +0 -7
- package/dist/chunks/chunk-A3TFXY4O.ts.map +0 -7
- package/dist/chunks/chunk-BVVP33U5.ts.map +0 -7
- package/dist/chunks/chunk-CAGJOKB7.ts.map +0 -7
- package/dist/chunks/chunk-E6ONFG57.ts.map +0 -7
- package/dist/chunks/chunk-GC3QOL6J.ts.map +0 -7
- package/dist/chunks/chunk-I4JYVFUS.ts.map +0 -7
- package/dist/chunks/chunk-KATKRR3D.ts.map +0 -7
- package/dist/chunks/chunk-XU2VXGQV.ts.map +0 -7
- package/dist/chunks/chunk-YTHQEW35.ts.map +0 -7
- package/dist/chunks/chunk-ZX2Q6THB.ts.map +0 -7
- package/dist/chunks/git-backend-FD7AVSK6.ts.map +0 -7
- package/dist/chunks/manager-ui-ZIN4BUSF.ts.map +0 -7
- package/dist/chunks/s3-backend-Y4P3O5B5.ts.map +0 -7
- package/dist/chunks/setup-wizard-VW7F3JN4.ts.map +0 -7
- package/dist/chunks/sync-operations-2CWST333.ts.map +0 -7
- package/dist/chunks/webdav-backend-TBILVEQK.ts.map +0 -7
- /package/dist/chunks/{conflict-artifacts-4CZFB7WI.ts.map → agent-candidate-3JN6DJKT.ts.map} +0 -0
- /package/dist/chunks/{chunk-R35N4CL3.ts.map → chunk-B4QZBDBT.ts.map} +0 -0
- /package/dist/chunks/{chunk-KGE47GCZ.ts.map → chunk-CRKTAPSL.ts.map} +0 -0
- /package/dist/chunks/{chunk-HMXUKU43.ts.map → chunk-EUMINX7L.ts.map} +0 -0
- /package/dist/chunks/{chunk-AAZDBIBJ.ts.map → chunk-GSNPASKB.ts.map} +0 -0
- /package/dist/chunks/{chunk-2FETCGGT.ts.map → chunk-OEWQVWLC.ts.map} +0 -0
- /package/dist/chunks/{chunk-VCL7TZYW.ts.map → chunk-UPNVIUBN.ts.map} +0 -0
- /package/dist/chunks/{manager-result-dispatcher-HVRTPEVK.ts.map → config-KGIYTTXQ.ts.map} +0 -0
- /package/dist/chunks/{merge-baseline-store-ICXVTCML.ts.map → conflict-artifacts-FXFIIBCB.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-H6AEVA3Q.ts.map → continuous-record-LFRVXQBD.ts.map} +0 -0
- /package/dist/chunks/{file-selection-UNHBVVON.ts.map → file-selection-3JJYOVNR.ts.map} +0 -0
- /package/dist/chunks/{setup-switch-ZMW5C4M7.ts.map → manager-result-dispatcher-AGBBVDDS.ts.map} +0 -0
- /package/dist/chunks/{sync-inspection-3EMS2J5B.ts.map → merge-baseline-store-RFCAH7KL.ts.map} +0 -0
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { posixJoin } from "../../paths.js";
|
|
5
5
|
import type { Snapshot, SnapshotFile } from "../../snapshot/snapshot-types.js";
|
|
6
6
|
import { stateDir } from "../../state/state-directory.js";
|
|
7
|
+
import { throwIfAborted } from "../../sync/signals.js";
|
|
7
8
|
import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
|
|
8
9
|
import type { ResolvedGitBackend } from "../backend-types.js";
|
|
9
10
|
import {
|
|
@@ -781,7 +782,3 @@ function sha256(value: Buffer) {
|
|
|
781
782
|
function abortReason(signal: AbortSignal) {
|
|
782
783
|
return signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
783
784
|
}
|
|
784
|
-
|
|
785
|
-
function throwIfAborted(signal?: AbortSignal) {
|
|
786
|
-
if (signal?.aborted) throw abortReason(signal);
|
|
787
|
-
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import process from "node:process";
|
|
3
|
+
import { throwIfAborted } from "../../sync/signals.js";
|
|
3
4
|
|
|
4
5
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
5
6
|
const DEFAULT_OUTPUT_LIMIT = 1024 * 1024;
|
|
@@ -252,8 +253,3 @@ export async function readGitBlobs(
|
|
|
252
253
|
});
|
|
253
254
|
return parseGitBlobBatch(result.stdout, objects.length, options.maxOutputBytes);
|
|
254
255
|
}
|
|
255
|
-
|
|
256
|
-
function throwIfAborted(signal?: AbortSignal) {
|
|
257
|
-
if (!signal?.aborted) return;
|
|
258
|
-
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
259
|
-
}
|
|
@@ -4,9 +4,11 @@ import { sessionTokenWarnings } from "../../settings/config.js";
|
|
|
4
4
|
import { decodeSnapshot, encodeSnapshot } from "../../snapshot/snapshot-codec.js";
|
|
5
5
|
import type { Snapshot } from "../../snapshot/snapshot-types.js";
|
|
6
6
|
import { validatePortableSnapshot } from "../../sync/local-fields.js";
|
|
7
|
+
import { throwIfAborted } from "../../sync/signals.js";
|
|
7
8
|
import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
|
|
8
9
|
import { portableSnapshotSelection } from "../../sync/sync-policy.js";
|
|
9
10
|
import type { LatestPointer, RemoteObject, ResolvedS3Backend } from "../backend-types.js";
|
|
11
|
+
import { createLatestPointer, remoteHistoryEntry } from "../snapshot-pointer.js";
|
|
10
12
|
import {
|
|
11
13
|
type BackendDiagnostic,
|
|
12
14
|
type ExpectedRemoteHead,
|
|
@@ -318,16 +320,7 @@ export function storageRoot(config: ResolvedS3Backend) {
|
|
|
318
320
|
}
|
|
319
321
|
|
|
320
322
|
export function pointerFor(config: ResolvedS3Backend, snapshot: Snapshot, checksum: string): LatestPointer {
|
|
321
|
-
return
|
|
322
|
-
version: VERSION,
|
|
323
|
-
profile: config.destination.namespace,
|
|
324
|
-
snapshot: snapshot.id,
|
|
325
|
-
sha256: checksum,
|
|
326
|
-
createdAt: snapshot.createdAt,
|
|
327
|
-
machine: snapshot.machine,
|
|
328
|
-
syncSessions: snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")),
|
|
329
|
-
...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
|
|
330
|
-
};
|
|
323
|
+
return createLatestPointer(config.destination.namespace, snapshot, checksum);
|
|
331
324
|
}
|
|
332
325
|
|
|
333
326
|
export function s3BackendIdentity(config: ResolvedS3Backend) {
|
|
@@ -365,16 +358,6 @@ function trimSlashes(value: string) {
|
|
|
365
358
|
return value.replace(/^\/+|\/+$/g, "");
|
|
366
359
|
}
|
|
367
360
|
|
|
368
|
-
function remoteHistoryEntry(pointer: LatestPointer): RemoteHistoryEntry {
|
|
369
|
-
return {
|
|
370
|
-
snapshotRef: pointer.snapshot,
|
|
371
|
-
snapshotId: pointer.snapshot,
|
|
372
|
-
createdAt: pointer.createdAt,
|
|
373
|
-
machine: pointer.machine,
|
|
374
|
-
syncSessions: pointer.syncSessions === true,
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
|
|
378
361
|
function remoteHead(pointer: LatestPointer, identity: string, etag?: string): RemoteHead {
|
|
379
362
|
return {
|
|
380
363
|
snapshotRef: pointer.snapshot,
|
|
@@ -529,11 +512,6 @@ function hasControlCharacter(value: string) {
|
|
|
529
512
|
});
|
|
530
513
|
}
|
|
531
514
|
|
|
532
|
-
function throwIfAborted(signal?: AbortSignal) {
|
|
533
|
-
if (!signal?.aborted) return;
|
|
534
|
-
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
535
|
-
}
|
|
536
|
-
|
|
537
515
|
function sha256(value: Buffer) {
|
|
538
516
|
return createHash("sha256").update(value).digest("hex");
|
|
539
517
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, createHmac } from "node:crypto";
|
|
2
2
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
3
|
import { encodeKey, posixJoin } from "../../paths.js";
|
|
4
|
+
import { retryAfter } from "../../sync/continuous-errors.js";
|
|
4
5
|
import type { RemoteObject, ResolvedS3Backend } from "../backend-types.js";
|
|
5
6
|
|
|
6
7
|
const MAX_JSON_RESPONSE_BYTES = 1024 * 1024;
|
|
@@ -34,6 +35,7 @@ export class S3HttpError extends Error {
|
|
|
34
35
|
constructor(
|
|
35
36
|
message: string,
|
|
36
37
|
readonly status: number,
|
|
38
|
+
readonly retryAfterMs?: number,
|
|
37
39
|
) {
|
|
38
40
|
super(message);
|
|
39
41
|
this.name = "S3HttpError";
|
|
@@ -81,7 +83,11 @@ export class S3Client {
|
|
|
81
83
|
const object = await this.request("GET", key);
|
|
82
84
|
if (object.status === 404) return { missing: true };
|
|
83
85
|
if (!object.ok) {
|
|
84
|
-
throw new S3HttpError(
|
|
86
|
+
throw new S3HttpError(
|
|
87
|
+
`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`,
|
|
88
|
+
object.status,
|
|
89
|
+
retryAfter(object),
|
|
90
|
+
);
|
|
85
91
|
}
|
|
86
92
|
const body = await readBoundedText(object, MAX_JSON_RESPONSE_BYTES, "S3 JSON response");
|
|
87
93
|
// R2 can intermittently return an empty 200 body (read-after-write
|
|
@@ -111,7 +117,11 @@ export class S3Client {
|
|
|
111
117
|
const object = await this.request("GET", key);
|
|
112
118
|
if (object.status === 404) return { missing: true };
|
|
113
119
|
if (!object.ok) {
|
|
114
|
-
throw new S3HttpError(
|
|
120
|
+
throw new S3HttpError(
|
|
121
|
+
`S3 GET failed (${object.status}): ${await this.readErrorText(object)}`,
|
|
122
|
+
object.status,
|
|
123
|
+
retryAfter(object),
|
|
124
|
+
);
|
|
115
125
|
}
|
|
116
126
|
const buffer = await readBoundedBuffer(object, MAX_SNAPSHOT_RESPONSE_BYTES, "S3 snapshot response");
|
|
117
127
|
// R2 can intermittently return an empty 200 body on a long-lived
|
|
@@ -158,6 +168,7 @@ export class S3Client {
|
|
|
158
168
|
throw new S3HttpError(
|
|
159
169
|
`S3 PUT failed (${response.status}): ${await this.readErrorText(response)}`,
|
|
160
170
|
response.status,
|
|
171
|
+
retryAfter(response),
|
|
161
172
|
);
|
|
162
173
|
}
|
|
163
174
|
await response.body?.cancel();
|
|
@@ -169,6 +180,7 @@ export class S3Client {
|
|
|
169
180
|
throw new S3HttpError(
|
|
170
181
|
`S3 DELETE failed (${response.status}): ${await this.readErrorText(response)}`,
|
|
171
182
|
response.status,
|
|
183
|
+
retryAfter(response),
|
|
172
184
|
);
|
|
173
185
|
}
|
|
174
186
|
await response.body?.cancel();
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Snapshot } from "../snapshot/snapshot-types.js";
|
|
2
|
+
import type { LatestPointer } from "./backend-types.js";
|
|
3
|
+
import type { RemoteHistoryEntry } from "./sync-backend.js";
|
|
4
|
+
|
|
5
|
+
/** Shared latest/history metadata for the S3 and WebDAV object-store format. */
|
|
6
|
+
export function createLatestPointer(namespace: string, snapshot: Snapshot, checksum: string): LatestPointer {
|
|
7
|
+
return {
|
|
8
|
+
version: 1,
|
|
9
|
+
profile: namespace,
|
|
10
|
+
snapshot: snapshot.id,
|
|
11
|
+
sha256: checksum,
|
|
12
|
+
createdAt: snapshot.createdAt,
|
|
13
|
+
machine: snapshot.machine,
|
|
14
|
+
syncSessions: snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")),
|
|
15
|
+
...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function remoteHistoryEntry(pointer: LatestPointer): RemoteHistoryEntry {
|
|
20
|
+
return {
|
|
21
|
+
snapshotRef: pointer.snapshot,
|
|
22
|
+
snapshotId: pointer.snapshot,
|
|
23
|
+
createdAt: pointer.createdAt,
|
|
24
|
+
machine: pointer.machine,
|
|
25
|
+
syncSessions: pointer.syncSessions === true,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -2,9 +2,11 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { decodeSnapshot, encodeSnapshot } from "../../snapshot/snapshot-codec.js";
|
|
3
3
|
import type { Snapshot } from "../../snapshot/snapshot-types.js";
|
|
4
4
|
import { validatePortableSnapshot } from "../../sync/local-fields.js";
|
|
5
|
+
import { throwIfAborted } from "../../sync/signals.js";
|
|
5
6
|
import { syncErrorGuidance } from "../../sync/sync-error-guidance.js";
|
|
6
7
|
import { portableSnapshotSelection } from "../../sync/sync-policy.js";
|
|
7
8
|
import type { LatestPointer, RemoteObject, ResolvedWebDavBackend } from "../backend-types.js";
|
|
9
|
+
import { createLatestPointer, remoteHistoryEntry } from "../snapshot-pointer.js";
|
|
8
10
|
import {
|
|
9
11
|
type BackendDiagnostic,
|
|
10
12
|
type ExpectedRemoteHead,
|
|
@@ -84,7 +86,7 @@ export class WebDavSyncBackend implements SyncBackend {
|
|
|
84
86
|
await client.ensureCollection(snapshotsPath(this.config));
|
|
85
87
|
const encoded = await encodeSnapshot(snapshot);
|
|
86
88
|
throwIfAborted(options.signal);
|
|
87
|
-
const pointer =
|
|
89
|
+
const pointer = createLatestPointer(this.config.destination.namespace, snapshot, sha256(encoded));
|
|
88
90
|
const stagedPath = snapshotPath(this.config, snapshot.id);
|
|
89
91
|
try {
|
|
90
92
|
await client.putBuffer(stagedPath, encoded, "application/gzip", { ifAbsent: true });
|
|
@@ -392,19 +394,6 @@ function webDavStorageLocation(config: ResolvedWebDavBackend) {
|
|
|
392
394
|
return `${url.host} · ${config.destination.path}`;
|
|
393
395
|
}
|
|
394
396
|
|
|
395
|
-
function pointerFor(config: ResolvedWebDavBackend, snapshot: Snapshot, checksum: string): LatestPointer {
|
|
396
|
-
return {
|
|
397
|
-
version: VERSION,
|
|
398
|
-
profile: config.destination.namespace,
|
|
399
|
-
snapshot: snapshot.id,
|
|
400
|
-
sha256: checksum,
|
|
401
|
-
createdAt: snapshot.createdAt,
|
|
402
|
-
machine: snapshot.machine,
|
|
403
|
-
syncSessions: snapshot.syncSessions === true || snapshot.files.some((file) => file.path.startsWith("sessions/")),
|
|
404
|
-
...(snapshot.selection === undefined ? {} : { selection: snapshot.selection }),
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
|
|
408
397
|
function remoteHead(pointer: LatestPointer, identity: string, etag?: string): RemoteHead {
|
|
409
398
|
return {
|
|
410
399
|
snapshotRef: pointer.snapshot,
|
|
@@ -490,16 +479,6 @@ function requireHistory(value: { version: number; snapshots: LatestPointer[] } |
|
|
|
490
479
|
return pointers;
|
|
491
480
|
}
|
|
492
481
|
|
|
493
|
-
function remoteHistoryEntry(pointer: LatestPointer): RemoteHistoryEntry {
|
|
494
|
-
return {
|
|
495
|
-
snapshotRef: pointer.snapshot,
|
|
496
|
-
snapshotId: pointer.snapshot,
|
|
497
|
-
createdAt: pointer.createdAt,
|
|
498
|
-
machine: pointer.machine,
|
|
499
|
-
syncSessions: pointer.syncSessions === true,
|
|
500
|
-
};
|
|
501
|
-
}
|
|
502
|
-
|
|
503
482
|
function assertSnapshotIdentity(snapshot: Snapshot, namespace: string) {
|
|
504
483
|
validatePortableSnapshot(snapshot);
|
|
505
484
|
if (
|
|
@@ -637,11 +616,6 @@ function sha256(value: Buffer) {
|
|
|
637
616
|
return createHash("sha256").update(value).digest("hex");
|
|
638
617
|
}
|
|
639
618
|
|
|
640
|
-
function throwIfAborted(signal?: AbortSignal) {
|
|
641
|
-
if (!signal?.aborted) return;
|
|
642
|
-
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
643
|
-
}
|
|
644
|
-
|
|
645
619
|
function errorMessage(error: unknown) {
|
|
646
620
|
if (error instanceof WebDavHttpError) return error.message;
|
|
647
621
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { XMLParser } from "fast-xml-parser";
|
|
2
|
+
import { ContinuousError, continuousFailure, retryAfter } from "../../sync/continuous-errors.js";
|
|
3
|
+
import { throwIfAborted } from "../../sync/signals.js";
|
|
2
4
|
import type { RemoteObject, ResolvedWebDavBackend } from "../backend-types.js";
|
|
3
5
|
|
|
4
6
|
const JSON_LIMIT = 1024 * 1024;
|
|
@@ -24,6 +26,7 @@ export class WebDavHttpError extends Error {
|
|
|
24
26
|
message: string,
|
|
25
27
|
readonly status: number,
|
|
26
28
|
options?: ErrorOptions,
|
|
29
|
+
readonly retryAfterMs?: number,
|
|
27
30
|
) {
|
|
28
31
|
super(message, options);
|
|
29
32
|
this.name = "WebDavHttpError";
|
|
@@ -231,7 +234,12 @@ export class WebDavClient {
|
|
|
231
234
|
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
|
|
232
235
|
.trim();
|
|
233
236
|
const detail = body ? `: ${redactText(body, this.config)}` : "";
|
|
234
|
-
throw new WebDavHttpError(
|
|
237
|
+
throw new WebDavHttpError(
|
|
238
|
+
`WebDAV ${action} failed with HTTP ${response.status}${detail}`,
|
|
239
|
+
response.status,
|
|
240
|
+
undefined,
|
|
241
|
+
retryAfter(response),
|
|
242
|
+
);
|
|
235
243
|
}
|
|
236
244
|
}
|
|
237
245
|
|
|
@@ -342,7 +350,8 @@ function safeUrl(url: URL) {
|
|
|
342
350
|
|
|
343
351
|
function redactError(error: unknown, config: ResolvedWebDavBackend) {
|
|
344
352
|
const message = error instanceof Error ? error.message : String(error);
|
|
345
|
-
|
|
353
|
+
const failure = continuousFailure(error);
|
|
354
|
+
return new ContinuousError(failure.kind, redactText(message, config), failure.delay);
|
|
346
355
|
}
|
|
347
356
|
|
|
348
357
|
function redactText(value: string, config: ResolvedWebDavBackend) {
|
|
@@ -359,8 +368,3 @@ function redactText(value: string, config: ResolvedWebDavBackend) {
|
|
|
359
368
|
function escapeRegExp(value: string) {
|
|
360
369
|
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
361
370
|
}
|
|
362
|
-
|
|
363
|
-
function throwIfAborted(signal?: AbortSignal) {
|
|
364
|
-
if (!signal?.aborted) return;
|
|
365
|
-
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
366
|
-
}
|
|
@@ -5,6 +5,7 @@ import { localConfigTemplate, readLocalConfigObject } from "../settings/settings
|
|
|
5
5
|
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
6
6
|
import { unlock, withLock } from "../state/lock.js";
|
|
7
7
|
import { migrateLegacyStateDirectory, stateDirectoryMigrationNotice } from "../state/state-directory.js";
|
|
8
|
+
import { withAgentSyncLock } from "../sync/agent-sync.js";
|
|
8
9
|
import { throwIfAborted } from "../sync/signals.js";
|
|
9
10
|
import { syncErrorGuidance } from "../sync/sync-error-guidance.js";
|
|
10
11
|
import { isSyncDecisionRequiredError, SetupPullRequiresUiError } from "../sync/sync-errors.js";
|
|
@@ -49,7 +50,7 @@ export async function executeRecoveryCommand(
|
|
|
49
50
|
const outcome = await withLock("pull", () => operations.pull(ctx, options));
|
|
50
51
|
return { kind: "completed", ...(outcome ? { outcome } : {}) };
|
|
51
52
|
}
|
|
52
|
-
await
|
|
53
|
+
await withAgentSyncLock(ctx, options, (current) => operations.syncBoth(ctx, current));
|
|
53
54
|
return { kind: "completed" };
|
|
54
55
|
} catch (error) {
|
|
55
56
|
if (signal?.aborted) return { kind: "failed" };
|
|
@@ -156,7 +157,7 @@ export async function executeCommand(
|
|
|
156
157
|
case "sync": {
|
|
157
158
|
const operations = await loaders.operations();
|
|
158
159
|
throwIfAborted(options.signal);
|
|
159
|
-
await
|
|
160
|
+
await withAgentSyncLock(ctx, options, (current) => operations.syncBoth(ctx, current));
|
|
160
161
|
return { kind: "completed" };
|
|
161
162
|
}
|
|
162
163
|
case "history": {
|
|
@@ -258,6 +259,8 @@ async function showConfig(ctx: ExtensionCommandContext, options: CommandOptions)
|
|
|
258
259
|
`settings field merge: ${config.mergeSettings ? "On (experimental)" : "Off"}`,
|
|
259
260
|
`machine-local settings.json fields: ${(config.localFields ?? []).map(safeTerminalText).join(", ") || "none"}`,
|
|
260
261
|
`startup transfer: ${startupTransferSummary(config)}`,
|
|
262
|
+
`agent mode: ${config.agentMode ? "On (experimental)" : "Off"}`,
|
|
263
|
+
`continuous sync: ${config.continuous ? (config.agentMode ? "On (experimental)" : "paused; Agent mode is Off") : "Off"}`,
|
|
261
264
|
`included content: ${config.include.join(", ") || "none"}`,
|
|
262
265
|
`sessions: ${config.include.includes("sessions") ? "included" : "not included"}`,
|
|
263
266
|
`settings file: ${localConfigPath()}`,
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
syncAttentionMatchesConfig,
|
|
14
14
|
} from "../ui/sync-attention.js";
|
|
15
15
|
import { setSyncStatus } from "../ui/sync-status.js";
|
|
16
|
-
import { parseOptions, splitArgs } from "./command.js";
|
|
16
|
+
import { parseOptions, splitArgs, validateCommandOptions } from "./command.js";
|
|
17
17
|
import { executeCommand, executeRecoveryCommand } from "./command-execution.js";
|
|
18
18
|
|
|
19
19
|
export async function handleCommand(
|
|
@@ -22,6 +22,7 @@ export async function handleCommand(
|
|
|
22
22
|
sessionSignal: AbortSignal,
|
|
23
23
|
loaders: SyncLoaders,
|
|
24
24
|
attention: SyncAttentionController,
|
|
25
|
+
onExplicitSync?: (setup?: string) => void,
|
|
25
26
|
) {
|
|
26
27
|
await reconcileObservation(attention, sessionSignal);
|
|
27
28
|
if (sessionSignal.aborted) return;
|
|
@@ -31,8 +32,18 @@ export async function handleCommand(
|
|
|
31
32
|
onCommit?: () => void,
|
|
32
33
|
target?: string,
|
|
33
34
|
onDialog?: (active: boolean) => void,
|
|
34
|
-
) =>
|
|
35
|
-
|
|
35
|
+
) => {
|
|
36
|
+
const [command, ...args] = splitArgs(route);
|
|
37
|
+
if (command === "sync") {
|
|
38
|
+
try {
|
|
39
|
+
const options = parseOptions(args);
|
|
40
|
+
validateCommandOptions("sync", options);
|
|
41
|
+
onExplicitSync?.(target ?? options.setup);
|
|
42
|
+
} catch {
|
|
43
|
+
/* Invalid routes do not request an automatic retry. */
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return executeCommand(
|
|
36
47
|
route,
|
|
37
48
|
ctx,
|
|
38
49
|
combineSignals(sessionSignal, signal),
|
|
@@ -41,6 +52,7 @@ export async function handleCommand(
|
|
|
41
52
|
target,
|
|
42
53
|
onDialog,
|
|
43
54
|
);
|
|
55
|
+
};
|
|
44
56
|
if (!rawArgs.trim()) {
|
|
45
57
|
try {
|
|
46
58
|
const { showSyncManager } = await import("../ui/manager-ui.js");
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
2
|
+
import type { AgentAcceptance } from "../sync/agent-request.js";
|
|
1
3
|
import type { StartupTimings } from "../sync/startup-timings.js";
|
|
2
4
|
|
|
3
5
|
export interface CommandOptions {
|
|
@@ -12,6 +14,10 @@ export interface CommandOptions {
|
|
|
12
14
|
onCommit?: () => void;
|
|
13
15
|
/** In-memory startup diagnostics only; never persisted or sent to a backend. */
|
|
14
16
|
startupTimings?: StartupTimings;
|
|
17
|
+
/** Internal validated resolver result; never parsed from command arguments. */
|
|
18
|
+
agentAcceptance?: AgentAcceptance;
|
|
19
|
+
/** Internal cycle authorization, ownership and durable request reservation; never command arguments. */
|
|
20
|
+
background?: { validate(config?: AnySyncConfig): void; reserve(key: string): Promise<void> };
|
|
15
21
|
args: string[];
|
|
16
22
|
}
|
|
17
23
|
|
package/src/commands/command.ts
CHANGED
|
@@ -236,6 +236,7 @@ export function usage() {
|
|
|
236
236
|
`Automatic sync: ${AUTOMATIC_SYNC_DESCRIPTION}`,
|
|
237
237
|
`Startup transfer: ${STARTUP_TRANSFER_DESCRIPTION}`,
|
|
238
238
|
"Automatic transfer at startup is a separate opt-in: one conflict-free established-baseline transfer at idle in TUI/RPC, with conditional/lease publication and no resource reload.",
|
|
239
|
+
"Experimental Continuous sync is a separate default-Off opt-in in Settings. It requires Agent mode, monitors while Pi is open, and can incur recurring network/model costs. Safety blocks and durable automatic request limits remain.",
|
|
239
240
|
"Settings: use /sync init or edit storage connections and sync setups in ~/.pi/agent/pi-sync.json (or the configured Pi agent directory). Version 1 and version 2 settings are unsupported and are never rewritten.",
|
|
240
241
|
].join("\n");
|
|
241
242
|
}
|
package/src/settings/config.ts
CHANGED
|
@@ -80,6 +80,8 @@ export async function loadPartialConfig(setupName?: string): Promise<PartialConf
|
|
|
80
80
|
include: [...config.include],
|
|
81
81
|
automatic: config.automatic,
|
|
82
82
|
automaticTransfer: config.automaticTransfer ?? false,
|
|
83
|
+
agentMode: config.agentMode ?? false,
|
|
84
|
+
continuous: config.continuous ?? false,
|
|
83
85
|
onSwitch: config.onSwitch,
|
|
84
86
|
showStatus: config.showStatus,
|
|
85
87
|
};
|
|
@@ -115,10 +117,13 @@ export function syncConfigReviewIdentity(config: AnySyncConfig) {
|
|
|
115
117
|
config.include,
|
|
116
118
|
config.automatic,
|
|
117
119
|
config.automaticTransfer ?? false,
|
|
120
|
+
config.agentMode ?? false,
|
|
118
121
|
config.localFields,
|
|
119
122
|
config.mergeSettings ?? false,
|
|
120
123
|
config.mergeContent ?? false,
|
|
121
124
|
config.partialSync ?? false,
|
|
125
|
+
// Preserve existing Off identities and cached candidates across the upgrade.
|
|
126
|
+
...(config.continuous ? ["continuous"] : []),
|
|
122
127
|
]);
|
|
123
128
|
}
|
|
124
129
|
|
|
@@ -166,6 +171,8 @@ function resolveSyncConfig(
|
|
|
166
171
|
include,
|
|
167
172
|
automatic: setup.sync.automatic,
|
|
168
173
|
automaticTransfer: setup.sync.automaticTransfer ?? false,
|
|
174
|
+
agentMode: setup.sync.agentMode ?? false,
|
|
175
|
+
continuous: setup.sync.continuous ?? false,
|
|
169
176
|
localFields: setup.sync.localFields === undefined ? undefined : normalizeLocalFields(setup.sync.localFields),
|
|
170
177
|
mergeSettings: setup.sync.mergeSettings ?? false,
|
|
171
178
|
mergeContent: setup.sync.mergeContent ?? false,
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import fs from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
4
1
|
import { localConfigPath, readMigratingLocalConfigDocument, updateLocalConfigDocument } from "./config-file.js";
|
|
5
2
|
import type { OnSwitchAction, PiSyncSettingsV3 } from "./settings-types.js";
|
|
6
3
|
import { DEFAULT_ON_SWITCH, validateSettingsDocument } from "./settings-validation.js";
|
|
@@ -63,34 +60,3 @@ export function updateLocalConfig(update: (current: PiSyncSettingsV3) => PiSyncS
|
|
|
63
60
|
async function performLocalConfigUpdate(update: (current: PiSyncSettingsV3) => PiSyncSettingsV3, signal?: AbortSignal) {
|
|
64
61
|
return updateLocalConfigDocument(localConfigTemplate(), update, validateSettingsDocument, signal);
|
|
65
62
|
}
|
|
66
|
-
|
|
67
|
-
export async function writeLocalConfigObject(value: PiSyncSettingsV3 | Record<string, unknown>) {
|
|
68
|
-
validateSettingsDocument(value as Record<string, unknown>);
|
|
69
|
-
const configPath = localConfigPath();
|
|
70
|
-
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
71
|
-
try {
|
|
72
|
-
const stat = await fs.lstat(configPath);
|
|
73
|
-
if (stat.isSymbolicLink()) throw new Error(`Refusing to overwrite symlinked pi-sync settings: ${configPath}`);
|
|
74
|
-
if (!stat.isFile()) throw new Error(`pi-sync settings are not a regular file: ${configPath}`);
|
|
75
|
-
} catch (error) {
|
|
76
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
77
|
-
}
|
|
78
|
-
const temporaryPath = path.join(
|
|
79
|
-
path.dirname(configPath),
|
|
80
|
-
`.${path.basename(configPath)}.${process.pid}.${randomUUID()}.tmp`,
|
|
81
|
-
);
|
|
82
|
-
let handle: fs.FileHandle | undefined;
|
|
83
|
-
try {
|
|
84
|
-
handle = await fs.open(temporaryPath, "wx", 0o600);
|
|
85
|
-
await handle.writeFile(`${JSON.stringify(value, null, "\t")}\n`, "utf8");
|
|
86
|
-
if (process.platform !== "win32") await handle.chmod(0o600);
|
|
87
|
-
await handle.sync();
|
|
88
|
-
await handle.close();
|
|
89
|
-
handle = undefined;
|
|
90
|
-
await fs.rename(temporaryPath, configPath);
|
|
91
|
-
} catch (error) {
|
|
92
|
-
await handle?.close().catch(() => undefined);
|
|
93
|
-
await fs.rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
94
|
-
throw error;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
@@ -81,6 +81,10 @@ export interface SyncPolicySettings {
|
|
|
81
81
|
automatic: boolean;
|
|
82
82
|
/** Explicit opt-in, separate from the legacy observe/shutdown policy. */
|
|
83
83
|
automaticTransfer?: boolean;
|
|
84
|
+
/** Experimental autonomous merged-sync authorization. */
|
|
85
|
+
agentMode?: boolean;
|
|
86
|
+
/** Separate explicit authorization for recurring background work. */
|
|
87
|
+
continuous?: boolean;
|
|
84
88
|
/** Version 4: settings.json root fields never uploaded. */
|
|
85
89
|
localFields?: string[];
|
|
86
90
|
mergeSettings?: boolean;
|
|
@@ -116,6 +120,8 @@ export interface SyncConfig<Backend extends ResolvedSyncBackend = ResolvedS3Back
|
|
|
116
120
|
include: string[];
|
|
117
121
|
automatic: boolean;
|
|
118
122
|
automaticTransfer?: boolean;
|
|
123
|
+
agentMode?: boolean;
|
|
124
|
+
continuous?: boolean;
|
|
119
125
|
localFields?: string[];
|
|
120
126
|
mergeSettings?: boolean;
|
|
121
127
|
mergeContent?: boolean;
|
|
@@ -139,6 +145,8 @@ export interface PartialConfig {
|
|
|
139
145
|
include: string[];
|
|
140
146
|
automatic: boolean;
|
|
141
147
|
automaticTransfer?: boolean;
|
|
148
|
+
agentMode?: boolean;
|
|
149
|
+
continuous?: boolean;
|
|
142
150
|
onSwitch: OnSwitchAction;
|
|
143
151
|
showStatus: boolean;
|
|
144
152
|
bucket?: string;
|
|
@@ -219,7 +219,7 @@ function validateSyncSetup(name: string, value: Record<string, unknown>, connect
|
|
|
219
219
|
const localFields = normalizeLocalFields(sync.localFields);
|
|
220
220
|
if (localFields.length > 0 && !include.includes("settings.json"))
|
|
221
221
|
throw new Error("Nonempty localFields requires settings.json in sync.include.");
|
|
222
|
-
for (const key of ["mergeContent", "partialSync"])
|
|
222
|
+
for (const key of ["mergeContent", "partialSync", "agentMode", "continuous"])
|
|
223
223
|
if (sync[key] !== undefined && typeof sync[key] !== "boolean") throw new Error(`${key} must be boolean.`);
|
|
224
224
|
if (sync.mergeSettings !== undefined && typeof sync.mergeSettings !== "boolean")
|
|
225
225
|
throw new Error("mergeSettings must be boolean.");
|
|
@@ -2,6 +2,7 @@ import { Readable, Writable } from "node:stream";
|
|
|
2
2
|
import { pipeline } from "node:stream/promises";
|
|
3
3
|
import { promisify } from "node:util";
|
|
4
4
|
import { createGunzip, gzip } from "node:zlib";
|
|
5
|
+
import { throwIfAborted } from "../sync/signals.js";
|
|
5
6
|
import { snapshotSelectionInclude } from "../sync/sync-policy.js";
|
|
6
7
|
import type { Snapshot } from "./snapshot-types.js";
|
|
7
8
|
|
|
@@ -47,8 +48,3 @@ export async function decodeSnapshot(
|
|
|
47
48
|
snapshotSelectionInclude(parsed);
|
|
48
49
|
return parsed;
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
-
function throwIfAborted(signal?: AbortSignal) {
|
|
52
|
-
if (!signal?.aborted) return;
|
|
53
|
-
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
54
|
-
}
|
package/src/state/lock.ts
CHANGED
|
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import lockfile from "proper-lockfile";
|
|
5
5
|
import type { CommandOptions } from "../commands/command-types.js";
|
|
6
|
+
import { throwIfAborted } from "../sync/signals.js";
|
|
6
7
|
import { LOCK_GUARD_STALE_MS, LOCK_GUARD_UPDATE_MS } from "./lock-policy.js";
|
|
7
8
|
import { LOCKFILE_FS_ADAPTER } from "./lockfile-fs.js";
|
|
8
9
|
import type { LockFile } from "./state-types.js";
|
|
@@ -57,8 +58,11 @@ export async function withLock<T>(
|
|
|
57
58
|
inspection = { status: "missing" };
|
|
58
59
|
}
|
|
59
60
|
if (inspection.status === "valid") {
|
|
60
|
-
throw
|
|
61
|
-
|
|
61
|
+
throw Object.assign(
|
|
62
|
+
new Error(
|
|
63
|
+
`pi-sync is already running (${inspection.lock.command}, pid ${inspection.lock.pid}, started ${inspection.lock.startedAt}).`,
|
|
64
|
+
),
|
|
65
|
+
{ code: "ELOCKED" },
|
|
62
66
|
);
|
|
63
67
|
}
|
|
64
68
|
if (inspection.status === "unreadable") {
|
|
@@ -220,11 +224,6 @@ async function unlockGuarded(ctx: ExtensionCommandContext, options: CommandOptio
|
|
|
220
224
|
}
|
|
221
225
|
}
|
|
222
226
|
|
|
223
|
-
function throwIfAborted(signal?: AbortSignal) {
|
|
224
|
-
if (!signal?.aborted) return;
|
|
225
|
-
throw signal.reason instanceof Error ? signal.reason : new DOMException("The operation was aborted", "AbortError");
|
|
226
|
-
}
|
|
227
|
-
|
|
228
227
|
async function releaseGuard(guard: Guard) {
|
|
229
228
|
try {
|
|
230
229
|
await guard.release();
|
|
@@ -261,11 +260,15 @@ async function describeHeldLock() {
|
|
|
261
260
|
return new Error("pi-sync lock owner exited; retry shortly while the lock guard expires.");
|
|
262
261
|
}
|
|
263
262
|
if (current) {
|
|
264
|
-
return
|
|
265
|
-
`Pi-sync is currently running (${current.command}, pid ${current.pid}, started ${current.startedAt})
|
|
263
|
+
return Object.assign(
|
|
264
|
+
new Error(`Pi-sync is currently running (${current.command}, pid ${current.pid}, started ${current.startedAt}).`),
|
|
265
|
+
{ code: "ELOCKED" },
|
|
266
266
|
);
|
|
267
267
|
}
|
|
268
|
-
return
|
|
268
|
+
return Object.assign(
|
|
269
|
+
new Error("Pi-sync is currently running (lock metadata is unreadable or still being written)."),
|
|
270
|
+
{ code: "ELOCKED" },
|
|
271
|
+
);
|
|
269
272
|
}
|
|
270
273
|
|
|
271
274
|
function isLockHeldError(error: unknown) {
|
|
@@ -66,7 +66,7 @@ export async function stageMergeBaseline(
|
|
|
66
66
|
return (
|
|
67
67
|
!collisions.has(file.path) &&
|
|
68
68
|
accepted.lastFileHashes[file.path] === file.sha256 &&
|
|
69
|
-
(file.path === "settings.json" || config.mergeContent) &&
|
|
69
|
+
(file.path === "settings.json" || config.mergeContent || (config.agentMode && isMergeTextPath(file.path))) &&
|
|
70
70
|
eligible(file)
|
|
71
71
|
);
|
|
72
72
|
});
|
|
@@ -153,7 +153,7 @@ export async function hasCurrentMergeBaseline(config: AnySyncConfig, snapshot: S
|
|
|
153
153
|
const hashes = new Map(ancestors.map((file) => [file.path, file.sha256]));
|
|
154
154
|
return snapshot.files.every(
|
|
155
155
|
(file) =>
|
|
156
|
-
!(file.path === "settings.json" || config.mergeContent) ||
|
|
156
|
+
!(file.path === "settings.json" || config.mergeContent || (config.agentMode && isMergeTextPath(file.path))) ||
|
|
157
157
|
!eligible(file) ||
|
|
158
158
|
hashes.get(file.path) === file.sha256,
|
|
159
159
|
);
|
|
@@ -79,7 +79,14 @@ export async function migrateLegacyStateDirectory(): Promise<StateDirectoryPrepa
|
|
|
79
79
|
realpath: false,
|
|
80
80
|
stale: LOCK_GUARD_STALE_MS,
|
|
81
81
|
});
|
|
82
|
-
|
|
82
|
+
// Idle continuous owners do not retain the exclusive migration guard.
|
|
83
|
+
// A live renewable lease still prevents moving their private state root.
|
|
84
|
+
const schedulerHeld = await lockfile.check(path.join(legacyStateDir(), "continuous", "scheduler"), {
|
|
85
|
+
fs: LOCKFILE_FS_ADAPTER,
|
|
86
|
+
realpath: false,
|
|
87
|
+
stale: LOCK_GUARD_STALE_MS,
|
|
88
|
+
});
|
|
89
|
+
if (legacyGuardHeld || schedulerHeld || (await pathExists(legacyLock))) {
|
|
83
90
|
return {
|
|
84
91
|
status: "deferred",
|
|
85
92
|
message:
|