@narumitw/pi-sync 0.49.15 → 0.50.1
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 +49 -16
- package/dist/chunks/attention-widget-Z7G3SYYJ.ts +9 -0
- package/dist/chunks/attention-widget-Z7G3SYYJ.ts.map +7 -0
- package/dist/chunks/chunk-2B2ELUF5.ts +29 -0
- package/dist/chunks/chunk-2B2ELUF5.ts.map +7 -0
- package/dist/chunks/{chunk-ARN2672F.ts → chunk-4CHTZMEO.ts} +23 -55
- package/dist/chunks/chunk-4CHTZMEO.ts.map +7 -0
- package/dist/chunks/chunk-6LP3MCR2.ts +115 -0
- package/dist/chunks/chunk-6LP3MCR2.ts.map +7 -0
- package/dist/chunks/{chunk-JF6MZCT4.ts → chunk-AUSDLD6F.ts} +4 -4
- package/dist/chunks/chunk-AUSDLD6F.ts.map +7 -0
- package/dist/chunks/{chunk-5T442OP3.ts → chunk-C7XY5NF5.ts} +210 -373
- package/dist/chunks/chunk-C7XY5NF5.ts.map +7 -0
- package/dist/chunks/chunk-CCSN3F47.ts +51 -0
- package/dist/chunks/chunk-CCSN3F47.ts.map +7 -0
- package/dist/chunks/{chunk-4U6AFLJB.ts → chunk-CHH26D4F.ts} +3 -2
- package/dist/chunks/chunk-CHH26D4F.ts.map +7 -0
- package/dist/chunks/chunk-CPEHGXJV.ts +50 -0
- package/dist/chunks/chunk-CPEHGXJV.ts.map +7 -0
- package/dist/chunks/{chunk-5MQ76BK5.ts → chunk-D5X27LCA.ts} +2 -2
- package/dist/chunks/chunk-D5X27LCA.ts.map +7 -0
- package/dist/chunks/{chunk-WSOBYHIZ.ts → chunk-ETTRJDNP.ts} +4 -4
- package/dist/chunks/chunk-ETTRJDNP.ts.map +7 -0
- package/dist/chunks/{chunk-44LPGQTI.ts → chunk-G4RNQQZS.ts} +9 -7
- package/dist/chunks/chunk-G4RNQQZS.ts.map +7 -0
- package/dist/chunks/{chunk-5LZALPCD.ts → chunk-HUDQATCL.ts} +25 -25
- package/dist/chunks/chunk-HUDQATCL.ts.map +7 -0
- package/dist/chunks/{chunk-X7SQ2EQM.ts → chunk-IJ6FJFA5.ts} +9 -9
- package/dist/chunks/chunk-IJ6FJFA5.ts.map +7 -0
- package/dist/chunks/{chunk-YQ6UW7IF.ts → chunk-JAAKLL6M.ts} +2 -2
- package/dist/chunks/chunk-JAAKLL6M.ts.map +7 -0
- package/dist/chunks/chunk-KW7YQEZN.ts +1255 -0
- package/dist/chunks/chunk-KW7YQEZN.ts.map +7 -0
- package/dist/chunks/{chunk-DFXBKHGN.ts → chunk-LCRI3ABW.ts} +21 -8
- package/dist/chunks/chunk-LCRI3ABW.ts.map +7 -0
- package/dist/chunks/chunk-LTBWGSBV.ts +12 -0
- package/dist/chunks/chunk-LTBWGSBV.ts.map +7 -0
- package/dist/chunks/chunk-NJFUIVYL.ts +17 -0
- package/dist/chunks/chunk-NJFUIVYL.ts.map +7 -0
- package/dist/chunks/{chunk-HVUSHMVL.ts → chunk-OEX4ABY6.ts} +39 -37
- package/dist/chunks/chunk-OEX4ABY6.ts.map +7 -0
- package/dist/chunks/{chunk-HGV24P4T.ts → chunk-Q2TUM5X6.ts} +3 -3
- package/dist/chunks/chunk-Q2TUM5X6.ts.map +7 -0
- package/dist/chunks/chunk-S7YBZR42.ts +146 -0
- package/dist/chunks/chunk-S7YBZR42.ts.map +7 -0
- package/dist/chunks/{chunk-GLQLN5GV.ts → chunk-SL64RLXR.ts} +8 -6
- package/dist/chunks/chunk-SL64RLXR.ts.map +7 -0
- package/dist/chunks/chunk-SZWBGPID.ts +57 -0
- package/dist/chunks/chunk-SZWBGPID.ts.map +7 -0
- package/dist/chunks/chunk-T2B5IU74.ts +25 -0
- package/dist/chunks/chunk-T2B5IU74.ts.map +7 -0
- package/dist/chunks/chunk-T3HH3UEJ.ts +96 -0
- package/dist/chunks/chunk-T3HH3UEJ.ts.map +7 -0
- package/dist/chunks/chunk-TEN2ZBJV.ts +101 -0
- package/dist/chunks/chunk-TEN2ZBJV.ts.map +7 -0
- package/dist/chunks/chunk-THMF4BVL.ts +18 -0
- package/dist/chunks/chunk-THMF4BVL.ts.map +7 -0
- package/dist/chunks/chunk-Z4DQF6L3.ts +12 -0
- package/dist/chunks/chunk-Z4DQF6L3.ts.map +7 -0
- package/dist/chunks/{chunk-F5QL2GPY.ts → chunk-ZI5MXMEX.ts} +5 -99
- package/dist/chunks/chunk-ZI5MXMEX.ts.map +7 -0
- package/dist/chunks/{file-selection-RKEHALNQ.ts → file-selection-FTTKAOYX.ts} +10 -7
- package/dist/chunks/file-selection-FTTKAOYX.ts.map +7 -0
- package/dist/chunks/{git-backend-E3UOUUOM.ts → git-backend-GMVS4PD4.ts} +39 -14
- package/dist/chunks/git-backend-GMVS4PD4.ts.map +7 -0
- package/dist/chunks/manager-result-dispatcher-FKIBM3GO.ts +22 -0
- package/dist/chunks/manager-ui-R3XPUFFS.ts +1680 -0
- package/dist/chunks/manager-ui-R3XPUFFS.ts.map +7 -0
- package/dist/chunks/remote-selection-ui-D7RBRJNL.ts +21 -0
- package/dist/chunks/{s3-backend-552LXHE4.ts → s3-backend-66O4FGBH.ts} +94 -16
- package/dist/chunks/s3-backend-66O4FGBH.ts.map +7 -0
- package/dist/chunks/{setup-switch-5YDG5YGJ.ts → setup-switch-IS2PEZ3J.ts} +9 -9
- package/dist/chunks/setup-wizard-IUB6ESEU.ts +89 -0
- package/dist/chunks/setup-wizard-IUB6ESEU.ts.map +7 -0
- package/dist/chunks/{snapshot-2RYBUDRA.ts → snapshot-HKEWCVK6.ts} +7 -6
- package/dist/chunks/sync-inspection-CST2KL65.ts +20 -0
- package/dist/chunks/{sync-operations-3LLIHWZO.ts → sync-operations-LBX3GKCM.ts} +369 -289
- package/dist/chunks/sync-operations-LBX3GKCM.ts.map +7 -0
- package/dist/chunks/{sync-state-SRN6YRBK.ts → sync-state-Q2FTXTW6.ts} +6 -6
- package/dist/chunks/sync-state-Q2FTXTW6.ts.map +7 -0
- package/dist/chunks/{webdav-backend-ZD5RHRIU.ts → webdav-backend-ECIKD7SO.ts} +26 -13
- package/dist/chunks/webdav-backend-ECIKD7SO.ts.map +7 -0
- package/dist/index.ts +476 -341
- package/dist/index.ts.map +4 -4
- package/docs/settings.md +30 -6
- package/package.json +1 -1
- package/src/{backend-factory.ts → backends/backend-factory.ts} +4 -4
- package/src/backends/backend-identity.ts +40 -0
- package/src/backends/backend-types.ts +81 -0
- package/src/{git-backend.ts → backends/git/git-backend.ts} +43 -19
- package/src/{git-config.ts → backends/git/git-config.ts} +1 -0
- package/src/{git-storage.ts → backends/git/git-storage.ts} +2 -2
- package/src/{s3-backend.ts → backends/s3/s3-backend.ts} +45 -9
- package/src/{s3-client.ts → backends/s3/s3-client.ts} +56 -8
- package/src/{sync-backend.ts → backends/sync-backend.ts} +1 -1
- package/src/{webdav-backend.ts → backends/webdav/webdav-backend.ts} +22 -10
- package/src/{webdav-client.ts → backends/webdav/webdav-client.ts} +1 -1
- package/src/{webdav-config.ts → backends/webdav/webdav-config.ts} +1 -0
- package/src/commands/command-execution.ts +330 -0
- package/src/commands/command-handler.ts +201 -0
- package/src/commands/command-types.ts +18 -0
- package/src/{command.ts → commands/command.ts} +2 -1
- package/src/commands/setup-completions.ts +6 -0
- package/src/settings/config.ts +238 -0
- package/src/{settings-management.ts → settings/settings-management.ts} +39 -14
- package/src/settings/settings-store.ts +109 -0
- package/src/settings/settings-types.ts +128 -0
- package/src/settings/settings-validation.ts +443 -0
- package/src/snapshot/session-paths.ts +84 -0
- package/src/{snapshot-apply.ts → snapshot/snapshot-apply.ts} +3 -3
- package/src/{snapshot-codec.ts → snapshot/snapshot-codec.ts} +2 -2
- package/src/{snapshot-transaction.ts → snapshot/snapshot-transaction.ts} +5 -4
- package/src/snapshot/snapshot-types.ts +38 -0
- package/src/{snapshot.ts → snapshot/snapshot.ts} +39 -20
- package/src/state/json-file.ts +20 -0
- package/src/{lock.ts → state/lock.ts} +3 -2
- package/src/{operation-availability.ts → state/operation-availability.ts} +2 -2
- package/src/state/state-types.ts +20 -0
- package/src/state/sync-state-store.ts +62 -0
- package/src/sync/automatic-sync.ts +103 -0
- package/src/{remote-snapshot.ts → sync/remote-snapshot.ts} +27 -4
- package/src/{setup-switch.ts → sync/setup-switch.ts} +6 -7
- package/src/sync/signals.ts +10 -0
- package/src/sync/startup-check.ts +128 -0
- package/src/{sync-decision.ts → sync/sync-decision.ts} +6 -3
- package/src/sync/sync-error-guidance.ts +53 -0
- package/src/sync/sync-inspection.ts +51 -0
- package/src/sync/sync-loaders.ts +54 -0
- package/src/{sync-operations.ts → sync/sync-mutations.ts} +34 -325
- package/src/sync/sync-operations.ts +12 -0
- package/src/{sync-policy.ts → sync/sync-policy.ts} +2 -2
- package/src/sync/sync-queries.ts +235 -0
- package/src/{sync-state.ts → sync/sync-state.ts} +5 -4
- package/src/sync-extension.ts +64 -708
- package/src/sync.ts +8 -8
- package/src/types.ts +47 -286
- package/src/ui/attention-widget.ts +1 -0
- package/src/ui/automatic-sync-summary.ts +6 -0
- package/src/{cancellable-operation.ts → ui/cancellable-operation.ts} +4 -4
- package/src/{file-selection.ts → ui/file-selection.ts} +5 -3
- package/src/{manager-attention.ts → ui/manager-attention.ts} +5 -1
- package/src/{manager-recovery.ts → ui/manager-recovery.ts} +6 -2
- package/src/{manager-state.ts → ui/manager-state.ts} +35 -14
- package/src/ui/manager-ui.ts +386 -0
- package/src/{remote-selection-ui.ts → ui/remote-selection-ui.ts} +21 -13
- package/src/{secret-input.ts → ui/secret-input.ts} +22 -4
- package/src/{settings-ui.ts → ui/settings-ui.ts} +17 -13
- package/src/ui/setup/git-ui.ts +293 -0
- package/src/{s3-credentials-ui.ts → ui/setup/s3-credentials-ui.ts} +13 -21
- package/src/ui/setup/s3-ui.ts +217 -0
- package/src/ui/setup/setup-actions.ts +127 -0
- package/src/ui/setup/setup-location-ui.ts +167 -0
- package/src/ui/setup/setup-name-ui.ts +34 -0
- package/src/ui/setup/setup-prompts.ts +92 -0
- package/src/ui/setup/setup-review.ts +70 -0
- package/src/ui/setup/setup-switcher.ts +181 -0
- package/src/ui/setup/setup-wizard.ts +39 -0
- package/src/ui/setup/text-input.ts +78 -0
- package/src/ui/setup/webdav-ui.ts +330 -0
- package/src/{storage-connections-ui.ts → ui/storage-connections-ui.ts} +99 -69
- package/src/ui/sync-attention.ts +187 -0
- package/src/{sync-format.ts → ui/sync-format.ts} +7 -13
- package/src/{sync-resolution-ui.ts → ui/sync-resolution-ui.ts} +4 -3
- package/src/{sync-setups-ui.ts → ui/sync-setups-ui.ts} +23 -10
- package/src/ui/terminal-text.ts +4 -0
- package/dist/chunks/chunk-44LPGQTI.ts.map +0 -7
- package/dist/chunks/chunk-4U6AFLJB.ts.map +0 -7
- package/dist/chunks/chunk-5LZALPCD.ts.map +0 -7
- package/dist/chunks/chunk-5MQ76BK5.ts.map +0 -7
- package/dist/chunks/chunk-5T442OP3.ts.map +0 -7
- package/dist/chunks/chunk-ARN2672F.ts.map +0 -7
- package/dist/chunks/chunk-C7LXZ3UH.ts +0 -167
- package/dist/chunks/chunk-C7LXZ3UH.ts.map +0 -7
- package/dist/chunks/chunk-DFXBKHGN.ts.map +0 -7
- package/dist/chunks/chunk-E4COSV32.ts +0 -82
- package/dist/chunks/chunk-E4COSV32.ts.map +0 -7
- package/dist/chunks/chunk-F5QL2GPY.ts.map +0 -7
- package/dist/chunks/chunk-GLQLN5GV.ts.map +0 -7
- package/dist/chunks/chunk-HGV24P4T.ts.map +0 -7
- package/dist/chunks/chunk-HVUSHMVL.ts.map +0 -7
- package/dist/chunks/chunk-JF6MZCT4.ts.map +0 -7
- package/dist/chunks/chunk-WSOBYHIZ.ts.map +0 -7
- package/dist/chunks/chunk-X7SQ2EQM.ts.map +0 -7
- package/dist/chunks/chunk-YQ6UW7IF.ts.map +0 -7
- package/dist/chunks/file-selection-RKEHALNQ.ts.map +0 -7
- package/dist/chunks/git-backend-E3UOUUOM.ts.map +0 -7
- package/dist/chunks/manager-result-dispatcher-LWPYJAD5.ts +0 -18
- package/dist/chunks/manager-ui-NF4RUHBY.ts +0 -2785
- package/dist/chunks/manager-ui-NF4RUHBY.ts.map +0 -7
- package/dist/chunks/remote-selection-ui-NUF5P4ZJ.ts +0 -17
- package/dist/chunks/s3-backend-552LXHE4.ts.map +0 -7
- package/dist/chunks/sync-operations-3LLIHWZO.ts.map +0 -7
- package/dist/chunks/webdav-backend-ZD5RHRIU.ts.map +0 -7
- package/src/config.ts +0 -923
- package/src/git-ui.ts +0 -307
- package/src/manager-helpers.ts +0 -64
- package/src/manager-ui.ts +0 -997
- package/src/sync-attention.ts +0 -100
- package/src/webdav-ui.ts +0 -421
- /package/dist/chunks/{manager-result-dispatcher-LWPYJAD5.ts.map → manager-result-dispatcher-FKIBM3GO.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-NUF5P4ZJ.ts.map → remote-selection-ui-D7RBRJNL.ts.map} +0 -0
- /package/dist/chunks/{setup-switch-5YDG5YGJ.ts.map → setup-switch-IS2PEZ3J.ts.map} +0 -0
- /package/dist/chunks/{snapshot-2RYBUDRA.ts.map → snapshot-HKEWCVK6.ts.map} +0 -0
- /package/dist/chunks/{sync-state-SRN6YRBK.ts.map → sync-inspection-CST2KL65.ts.map} +0 -0
- /package/src/{git-runner.ts → backends/git/git-runner.ts} +0 -0
- /package/src/{config-errors.ts → settings/config-errors.ts} +0 -0
- /package/src/{config-file.ts → settings/config-file.ts} +0 -0
- /package/src/{snapshot-paths.ts → snapshot/snapshot-paths.ts} +0 -0
- /package/src/{lock-policy.ts → state/lock-policy.ts} +0 -0
- /package/src/{lockfile-fs.ts → state/lockfile-fs.ts} +0 -0
- /package/src/{state-directory.ts → state/state-directory.ts} +0 -0
- /package/src/{sync-errors.ts → sync/sync-errors.ts} +0 -0
- /package/src/{manager-result-dispatcher.ts → ui/manager-result-dispatcher.ts} +0 -0
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import { withLock } from "
|
|
6
|
-
import {
|
|
4
|
+
import { assertWithinRoot, isPathInside } from "../paths.js";
|
|
5
|
+
import { withLock } from "../state/lock.js";
|
|
6
|
+
import { stateDir } from "../state/state-directory.js";
|
|
7
|
+
import { agentDir } from "./session-paths.js";
|
|
7
8
|
import { sessionStorageRoot } from "./snapshot-paths.js";
|
|
8
|
-
import type { SnapshotApplyPlan } from "./types.js";
|
|
9
|
+
import type { SnapshotApplyPlan } from "./snapshot-types.js";
|
|
9
10
|
|
|
10
11
|
const JOURNAL_VERSION = 1;
|
|
11
12
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface SnapshotFile {
|
|
2
|
+
path: string;
|
|
3
|
+
contentBase64: string;
|
|
4
|
+
sha256: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface SnapshotSelection {
|
|
8
|
+
version: 1;
|
|
9
|
+
include: string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Snapshot {
|
|
13
|
+
version: number;
|
|
14
|
+
id: string;
|
|
15
|
+
createdAt: string;
|
|
16
|
+
machine: string;
|
|
17
|
+
/** Backend-scoped remote identity retained in the snapshot wire format. */
|
|
18
|
+
profile: string;
|
|
19
|
+
syncSessions?: boolean;
|
|
20
|
+
/** Portable, credential-free included-content intent. Absent on legacy snapshots. */
|
|
21
|
+
selection?: SnapshotSelection;
|
|
22
|
+
files: SnapshotFile[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SnapshotOptions {
|
|
26
|
+
signal?: AbortSignal;
|
|
27
|
+
include?: string[];
|
|
28
|
+
sessionDir?: string;
|
|
29
|
+
/** Temporary internal projections while snapshot storage remains wire-compatible. */
|
|
30
|
+
syncFiles?: string[];
|
|
31
|
+
syncSessions?: boolean;
|
|
32
|
+
extraFiles?: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SnapshotApplyPlan {
|
|
36
|
+
writes: Array<{ target: string; content: Buffer }>;
|
|
37
|
+
deletes: string[];
|
|
38
|
+
}
|
|
@@ -3,11 +3,11 @@ import type { Dirent } from "node:fs";
|
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
6
|
+
import { isDeniedPath, posixJoin, safeJoin, toPosix } from "../paths.js";
|
|
7
|
+
import { agentDir, configuredSessionDir } from "./session-paths.js";
|
|
8
8
|
import { sessionStorageRoot } from "./snapshot-paths.js";
|
|
9
9
|
|
|
10
|
-
export { isDeniedPath } from "
|
|
10
|
+
export { isDeniedPath } from "../paths.js";
|
|
11
11
|
export { sessionStorageRoot } from "./snapshot-paths.js";
|
|
12
12
|
|
|
13
13
|
import {
|
|
@@ -23,10 +23,10 @@ import {
|
|
|
23
23
|
selectionForSnapshot,
|
|
24
24
|
snapshotSelectionInclude,
|
|
25
25
|
syncIncludeSelection,
|
|
26
|
-
} from "
|
|
27
|
-
import type { Snapshot, SnapshotFile, SnapshotOptions } from "./types.js";
|
|
26
|
+
} from "../sync/sync-policy.js";
|
|
27
|
+
import type { Snapshot, SnapshotFile, SnapshotOptions } from "./snapshot-types.js";
|
|
28
28
|
|
|
29
|
-
export { canonicalSnapshotPathForConfig, isConfiguredSnapshotPath } from "
|
|
29
|
+
export { canonicalSnapshotPathForConfig, isConfiguredSnapshotPath } from "../sync/sync-policy.js";
|
|
30
30
|
|
|
31
31
|
const VERSION = 1;
|
|
32
32
|
const TOP_LEVEL_FILES: readonly string[] = DEFAULT_SYNC_INCLUDE.filter((name) =>
|
|
@@ -96,12 +96,13 @@ export async function createSnapshot(
|
|
|
96
96
|
profile: string,
|
|
97
97
|
options: SnapshotOptions = {},
|
|
98
98
|
): Promise<Snapshot> {
|
|
99
|
+
options.signal?.throwIfAborted();
|
|
99
100
|
const include = effectiveInclude(options);
|
|
100
101
|
const syncSessions = include.includes("sessions");
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
const sessionDir = options.sessionDir ?? (await configuredSessionDir());
|
|
103
|
+
options.signal?.throwIfAborted();
|
|
104
|
+
const files = await collectFiles(agentDir(), { include, sessionDir, signal: options.signal });
|
|
105
|
+
options.signal?.throwIfAborted();
|
|
105
106
|
return {
|
|
106
107
|
version: VERSION,
|
|
107
108
|
id: snapshotId(),
|
|
@@ -127,48 +128,61 @@ export async function collectFiles(
|
|
|
127
128
|
root: string,
|
|
128
129
|
options: SnapshotOptions = {},
|
|
129
130
|
): Promise<SnapshotFile[]> {
|
|
131
|
+
const signal = options.signal;
|
|
132
|
+
signal?.throwIfAborted();
|
|
130
133
|
const results: SnapshotFile[] = [];
|
|
131
134
|
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
135
|
+
signal?.throwIfAborted();
|
|
132
136
|
const selection = syncIncludeSelection(effectiveInclude(options));
|
|
133
137
|
const selectedFiles = new Set<string>(selection.builtIns);
|
|
134
138
|
for (const entry of entries) {
|
|
135
139
|
if (entry.isDirectory() && TOP_LEVEL_DIRS.has(entry.name) && selectedFiles.has(entry.name)) {
|
|
136
|
-
await collectDirectory(results, root, entry.name);
|
|
140
|
+
await collectDirectory(results, root, entry.name, { signal });
|
|
137
141
|
}
|
|
138
142
|
}
|
|
139
143
|
for (const fileName of TOP_LEVEL_FILES) {
|
|
140
144
|
if (!selectedFiles.has(fileName)) continue;
|
|
141
145
|
const entry = selectTopLevelFileEntry(entries, fileName);
|
|
142
|
-
if (entry) await addFile(results, root, entry.name, fileName);
|
|
146
|
+
if (entry) await addFile(results, root, entry.name, fileName, signal);
|
|
143
147
|
}
|
|
144
148
|
for (const relativePath of selection.custom) {
|
|
145
|
-
await collectIncludedPath(results, root, relativePath);
|
|
149
|
+
await collectIncludedPath(results, root, relativePath, signal);
|
|
146
150
|
}
|
|
147
151
|
if (selection.sessions) {
|
|
148
152
|
try {
|
|
149
153
|
await collectDirectory(results, sessionStorageRoot(root, options.sessionDir), "", {
|
|
150
154
|
sessionsOnly: true,
|
|
151
155
|
virtualPrefix: "sessions",
|
|
156
|
+
signal,
|
|
152
157
|
});
|
|
153
158
|
} catch (error) {
|
|
154
159
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
155
160
|
}
|
|
156
161
|
}
|
|
162
|
+
signal?.throwIfAborted();
|
|
157
163
|
return results.sort((left, right) => left.path.localeCompare(right.path));
|
|
158
164
|
}
|
|
159
165
|
|
|
160
|
-
async function collectIncludedPath(
|
|
166
|
+
async function collectIncludedPath(
|
|
167
|
+
results: SnapshotFile[],
|
|
168
|
+
root: string,
|
|
169
|
+
relativePath: string,
|
|
170
|
+
signal?: AbortSignal,
|
|
171
|
+
) {
|
|
172
|
+
signal?.throwIfAborted();
|
|
161
173
|
const absolutePath = safeJoin(root, relativePath);
|
|
162
174
|
try {
|
|
163
175
|
const stat = await fs.lstat(absolutePath);
|
|
164
|
-
|
|
165
|
-
|
|
176
|
+
signal?.throwIfAborted();
|
|
177
|
+
if (stat.isFile()) await addFile(results, root, relativePath, relativePath, signal);
|
|
178
|
+
else if (stat.isDirectory()) await collectDirectory(results, root, relativePath, { signal });
|
|
166
179
|
} catch (error) {
|
|
167
180
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
168
181
|
if (!relativePath.includes("/")) {
|
|
169
182
|
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
183
|
+
signal?.throwIfAborted();
|
|
170
184
|
const entry = selectTopLevelFileEntry(entries, relativePath);
|
|
171
|
-
if (entry) await addFile(results, root, entry.name, relativePath);
|
|
185
|
+
if (entry) await addFile(results, root, entry.name, relativePath, signal);
|
|
172
186
|
}
|
|
173
187
|
}
|
|
174
188
|
}
|
|
@@ -177,10 +191,12 @@ async function collectDirectory(
|
|
|
177
191
|
results: SnapshotFile[],
|
|
178
192
|
root: string,
|
|
179
193
|
relativeDirectory: string,
|
|
180
|
-
options: { sessionsOnly?: boolean; virtualPrefix?: string } = {},
|
|
194
|
+
options: { sessionsOnly?: boolean; virtualPrefix?: string; signal?: AbortSignal } = {},
|
|
181
195
|
) {
|
|
196
|
+
options.signal?.throwIfAborted();
|
|
182
197
|
const absoluteDirectory = path.join(root, relativeDirectory);
|
|
183
198
|
for (const entry of await fs.readdir(absoluteDirectory, { withFileTypes: true })) {
|
|
199
|
+
options.signal?.throwIfAborted();
|
|
184
200
|
const relativePath = relativeDirectory ? posixJoin(relativeDirectory, entry.name) : entry.name;
|
|
185
201
|
const snapshotPath = options.virtualPrefix
|
|
186
202
|
? posixJoin(options.virtualPrefix, relativePath)
|
|
@@ -189,7 +205,7 @@ async function collectDirectory(
|
|
|
189
205
|
if (entry.isDirectory()) {
|
|
190
206
|
await collectDirectory(results, root, relativePath, options);
|
|
191
207
|
} else if (entry.isFile() && (!options.sessionsOnly || isSessionFilePath(snapshotPath))) {
|
|
192
|
-
await addFile(results, root, relativePath, snapshotPath);
|
|
208
|
+
await addFile(results, root, relativePath, snapshotPath, options.signal);
|
|
193
209
|
}
|
|
194
210
|
}
|
|
195
211
|
}
|
|
@@ -199,10 +215,13 @@ async function addFile(
|
|
|
199
215
|
root: string,
|
|
200
216
|
relativePath: string,
|
|
201
217
|
snapshotPath = relativePath,
|
|
218
|
+
signal?: AbortSignal,
|
|
202
219
|
) {
|
|
220
|
+
signal?.throwIfAborted();
|
|
203
221
|
if (!isSafeSnapshotPath(snapshotPath)) return;
|
|
204
222
|
const absolutePath = safeJoin(root, relativePath);
|
|
205
|
-
const content = await fs.readFile(absolutePath);
|
|
223
|
+
const content = await fs.readFile(absolutePath, { signal });
|
|
224
|
+
signal?.throwIfAborted();
|
|
206
225
|
results.push({
|
|
207
226
|
path: snapshotPath,
|
|
208
227
|
contentBase64: content.toString("base64"),
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export async function readJsonIfExists<T>(filePath: string): Promise<T | undefined> {
|
|
6
|
+
try {
|
|
7
|
+
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
|
|
8
|
+
} catch (error) {
|
|
9
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
10
|
+
throw error;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function writeJson(filePath: string, value: unknown) {
|
|
15
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
16
|
+
const temp = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
17
|
+
await fs.writeFile(temp, `${JSON.stringify(value, null, "\t")}\n`, { mode: 0o600 });
|
|
18
|
+
if (process.platform !== "win32") await fs.chmod(temp, 0o600);
|
|
19
|
+
await fs.rename(temp, filePath);
|
|
20
|
+
}
|
|
@@ -2,10 +2,11 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
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
|
-
import {
|
|
5
|
+
import type { CommandOptions } from "../commands/command-types.js";
|
|
6
6
|
import { LOCK_GUARD_STALE_MS, LOCK_GUARD_UPDATE_MS } from "./lock-policy.js";
|
|
7
7
|
import { LOCKFILE_FS_ADAPTER } from "./lockfile-fs.js";
|
|
8
|
-
import type {
|
|
8
|
+
import type { LockFile } from "./state-types.js";
|
|
9
|
+
import { ensureStateDir, lockPath } from "./sync-state-store.js";
|
|
9
10
|
|
|
10
11
|
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
11
12
|
const MAX_PROCESS_ID = 2_147_483_647;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { errorMessage } from "../sync/sync-errors.js";
|
|
1
2
|
import { inspectLock, isLockGuardHeld, isStaleLock, type LockInspection } from "./lock.js";
|
|
2
|
-
import {
|
|
3
|
-
import type { LockFile } from "./types.js";
|
|
3
|
+
import type { LockFile } from "./state-types.js";
|
|
4
4
|
|
|
5
5
|
export type OperationAvailability =
|
|
6
6
|
| { kind: "free" }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface SyncState {
|
|
2
|
+
version: number;
|
|
3
|
+
profile: string;
|
|
4
|
+
lastAppliedSnapshot?: string;
|
|
5
|
+
lastRemoteRevision?: string;
|
|
6
|
+
lastRemoteEtag?: string;
|
|
7
|
+
lastFileHashes: Record<string, string>;
|
|
8
|
+
include?: string[];
|
|
9
|
+
/** Legacy state fields are read only so v3 can detect and replace stale policy state. */
|
|
10
|
+
syncFiles?: string[];
|
|
11
|
+
syncSessions?: boolean;
|
|
12
|
+
extraFiles?: string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface LockFile {
|
|
16
|
+
id: string;
|
|
17
|
+
pid: number;
|
|
18
|
+
command: string;
|
|
19
|
+
startedAt: string;
|
|
20
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { backendIdentityCoordinates } from "../backends/backend-identity.js";
|
|
5
|
+
import { safeName } from "../paths.js";
|
|
6
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
7
|
+
import { readJsonIfExists, writeJson } from "./json-file.js";
|
|
8
|
+
import { stateDir } from "./state-directory.js";
|
|
9
|
+
import type { SyncState } from "./state-types.js";
|
|
10
|
+
|
|
11
|
+
const STATE_VERSION = 2;
|
|
12
|
+
|
|
13
|
+
/** Bounded observation token; do not retain the full baseline in passive UI state. */
|
|
14
|
+
export function syncStateFingerprint(state: SyncState) {
|
|
15
|
+
return createHash("sha256").update(JSON.stringify(state)).digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readState(profile: string): Promise<SyncState> {
|
|
19
|
+
return (
|
|
20
|
+
(await readJsonIfExists<SyncState>(statePath(profile))) ?? {
|
|
21
|
+
version: STATE_VERSION,
|
|
22
|
+
profile,
|
|
23
|
+
lastFileHashes: {},
|
|
24
|
+
}
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function writeState(profile: string, state: SyncState) {
|
|
29
|
+
await writeJson(statePath(profile), state);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function readStateForConfig(config: AnySyncConfig): Promise<SyncState> {
|
|
33
|
+
return (
|
|
34
|
+
(await readJsonIfExists<SyncState>(statePathForConfig(config))) ?? {
|
|
35
|
+
version: STATE_VERSION,
|
|
36
|
+
profile: config.snapshotIdentity,
|
|
37
|
+
lastFileHashes: {},
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function writeStateForConfig(config: AnySyncConfig, state: SyncState) {
|
|
43
|
+
await writeJson(statePathForConfig(config), state);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function statePathForConfig(config: AnySyncConfig) {
|
|
47
|
+
const identity = backendIdentityCoordinates(config);
|
|
48
|
+
const hash = createHash("sha256").update(identity).digest("hex").slice(0, 16);
|
|
49
|
+
return path.join(stateDir(), "setups", `${config.backend.type}-${hash}.state.json`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function statePath(profile: string) {
|
|
53
|
+
return path.join(stateDir(), `${safeName(profile)}.state.json`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function lockPath() {
|
|
57
|
+
return path.join(stateDir(), "lock");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function ensureStateDir() {
|
|
61
|
+
await fs.mkdir(stateDir(), { recursive: true });
|
|
62
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { setSyncSetupCompletions } from "../commands/command.js";
|
|
3
|
+
import type { CommandOptions } from "../commands/command-types.js";
|
|
4
|
+
import { loadConfig, loadPartialConfig } from "../settings/config.js";
|
|
5
|
+
import { isMissingConfigError } from "../settings/config-errors.js";
|
|
6
|
+
import { consumeLocalConfigMigrationNotice } from "../settings/config-file.js";
|
|
7
|
+
import { configuredSyncSetupNames } from "../settings/settings-store.js";
|
|
8
|
+
import { snapshotOptionsForContext } from "../snapshot/session-paths.js";
|
|
9
|
+
import { recoverSnapshotTransactionsOnStartup } from "../snapshot/snapshot-transaction.js";
|
|
10
|
+
import { withLock } from "../state/lock.js";
|
|
11
|
+
import { stateDirectoryMigrationNotice } from "../state/state-directory.js";
|
|
12
|
+
import { ensureStateDir, readStateForConfig } from "../state/sync-state-store.js";
|
|
13
|
+
import { safeTerminalText } from "../ui/terminal-text.js";
|
|
14
|
+
import { throwIfAborted } from "./signals.js";
|
|
15
|
+
import { errorMessage } from "./sync-errors.js";
|
|
16
|
+
import type { SyncLoaders } from "./sync-loaders.js";
|
|
17
|
+
|
|
18
|
+
const AUTO_SYNC_OPTIONS: CommandOptions = {
|
|
19
|
+
yes: true,
|
|
20
|
+
force: false,
|
|
21
|
+
stale: false,
|
|
22
|
+
silent: true,
|
|
23
|
+
reload: false,
|
|
24
|
+
auto: true,
|
|
25
|
+
args: [],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const STATUS_KEY = "sync";
|
|
29
|
+
|
|
30
|
+
export async function startSession(ctx: ExtensionContext, signal: AbortSignal) {
|
|
31
|
+
throwIfAborted(signal);
|
|
32
|
+
const stateNotice = stateDirectoryMigrationNotice();
|
|
33
|
+
if (stateNotice && ctx.hasUI) ctx.ui.notify(stateNotice, "warning");
|
|
34
|
+
// Recovery can restore managed files: finish it before Pi accepts user edits.
|
|
35
|
+
// Unlike remote inspection, it must never be detached behind startup.
|
|
36
|
+
await recoverSnapshotTransactionsOnStartup();
|
|
37
|
+
throwIfAborted(signal);
|
|
38
|
+
try {
|
|
39
|
+
const names = await configuredSyncSetupNames();
|
|
40
|
+
throwIfAborted(signal);
|
|
41
|
+
setSyncSetupCompletions(names);
|
|
42
|
+
} catch {
|
|
43
|
+
if (signal.aborted) return;
|
|
44
|
+
setSyncSetupCompletions([]);
|
|
45
|
+
}
|
|
46
|
+
const migrationNotice = consumeLocalConfigMigrationNotice();
|
|
47
|
+
if (migrationNotice && ctx.hasUI) ctx.ui.notify(migrationNotice, "warning");
|
|
48
|
+
throwIfAborted(signal);
|
|
49
|
+
if (!ctx.hasUI) return;
|
|
50
|
+
try {
|
|
51
|
+
const config = await loadConfig();
|
|
52
|
+
throwIfAborted(signal);
|
|
53
|
+
return config.automatic ? config : undefined;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throwIfAborted(signal);
|
|
56
|
+
if (!isMissingConfigError(error)) {
|
|
57
|
+
ctx.ui.notify(
|
|
58
|
+
`pi-sync startup check skipped: ${safeTerminalText(errorMessage(error))}`,
|
|
59
|
+
"warning",
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function autoPushSessions(
|
|
66
|
+
ctx: ExtensionContext,
|
|
67
|
+
signal: AbortSignal,
|
|
68
|
+
loaders: SyncLoaders,
|
|
69
|
+
) {
|
|
70
|
+
try {
|
|
71
|
+
const partial = await loadPartialConfig();
|
|
72
|
+
throwIfAborted(signal);
|
|
73
|
+
if (!partial.automatic) return;
|
|
74
|
+
if (!partial.include.includes("sessions")) return;
|
|
75
|
+
await ensureStateDir();
|
|
76
|
+
throwIfAborted(signal);
|
|
77
|
+
const config = await loadConfig();
|
|
78
|
+
throwIfAborted(signal);
|
|
79
|
+
if (!config.include.includes("sessions")) return;
|
|
80
|
+
const [operations, snapshotModule, syncStateModule] = await Promise.all([
|
|
81
|
+
loaders.operations(),
|
|
82
|
+
loaders.snapshot(),
|
|
83
|
+
loaders.syncState(),
|
|
84
|
+
]);
|
|
85
|
+
throwIfAborted(signal);
|
|
86
|
+
await withLock("auto-session-push", async () => {
|
|
87
|
+
throwIfAborted(signal);
|
|
88
|
+
const state = await readStateForConfig(config);
|
|
89
|
+
throwIfAborted(signal);
|
|
90
|
+
const local = await snapshotModule.createSnapshot(config.snapshotIdentity, {
|
|
91
|
+
...snapshotOptionsForContext(ctx, config),
|
|
92
|
+
signal,
|
|
93
|
+
});
|
|
94
|
+
throwIfAborted(signal);
|
|
95
|
+
if (!syncStateModule.hasLocalChanges(local, state, config)) return;
|
|
96
|
+
await operations.push(ctx, { ...AUTO_SYNC_OPTIONS, signal }, { config, state, local });
|
|
97
|
+
});
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (signal.aborted || isMissingConfigError(error)) return;
|
|
100
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
101
|
+
ctx.ui.notify(`pi-sync session push skipped: ${errorMessage(error)}`, "warning");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
1
|
+
import type { RemoteHead, SyncBackend } from "../backends/sync-backend.js";
|
|
2
|
+
import { syncConfigReviewFingerprint } from "../settings/config.js";
|
|
3
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
4
|
+
import { filterSnapshotForConfigPolicy } from "../snapshot/snapshot.js";
|
|
5
|
+
import type { Snapshot } from "../snapshot/snapshot-types.js";
|
|
6
|
+
import { safeTerminalText } from "../ui/terminal-text.js";
|
|
4
7
|
import {
|
|
5
8
|
inspectRemoteSelection,
|
|
6
9
|
type RemoteSelectionState,
|
|
@@ -8,7 +11,6 @@ import {
|
|
|
8
11
|
sameSyncInclude,
|
|
9
12
|
snapshotSelectionInclude,
|
|
10
13
|
} from "./sync-policy.js";
|
|
11
|
-
import type { AnySyncConfig, Snapshot } from "./types.js";
|
|
12
14
|
|
|
13
15
|
export async function readSnapshotForHead(
|
|
14
16
|
backend: SyncBackend,
|
|
@@ -58,3 +60,24 @@ export function formatRemoteSelectionStatus(state: RemoteSelectionState | undefi
|
|
|
58
60
|
function safeList(values: readonly string[]) {
|
|
59
61
|
return values.length > 0 ? values.map(safeTerminalText).join(", ") : "none";
|
|
60
62
|
}
|
|
63
|
+
|
|
64
|
+
export async function readRemoteSnapshot(
|
|
65
|
+
backend: SyncBackend,
|
|
66
|
+
config: AnySyncConfig,
|
|
67
|
+
signal?: AbortSignal,
|
|
68
|
+
options: { allowSelectionDifference?: boolean } = {},
|
|
69
|
+
) {
|
|
70
|
+
const head = await backend.readHead(signal);
|
|
71
|
+
if (!head) return { head: undefined, snapshot: undefined, selectionState: undefined };
|
|
72
|
+
const snapshot = await readSnapshotForHead(backend, head, signal);
|
|
73
|
+
const selectionState = inspectRemoteSelection(config.include, snapshot);
|
|
74
|
+
if (!options.allowSelectionDifference && selectionState.kind === "different") {
|
|
75
|
+
const configIdentity = syncConfigReviewFingerprint(config);
|
|
76
|
+
throw remoteSelectionMismatch(config, selectionState.include, configIdentity);
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
head,
|
|
80
|
+
snapshot: filterSnapshotForConfigPolicy(snapshot, config),
|
|
81
|
+
selectionState,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { setSyncSetupCompletions } from "
|
|
2
|
+
import { setSyncSetupCompletions } from "../commands/command.js";
|
|
3
3
|
import {
|
|
4
|
-
configuredSyncSetupNames,
|
|
5
4
|
loadConfig,
|
|
6
|
-
normalizeOnSwitch,
|
|
7
5
|
syncConfigReviewIdentity,
|
|
8
6
|
syncSetupReviewIdentity,
|
|
9
|
-
|
|
10
|
-
} from "
|
|
7
|
+
} from "../settings/config.js";
|
|
8
|
+
import { configuredSyncSetupNames, updateLocalConfig } from "../settings/settings-store.js";
|
|
9
|
+
import type { OnSwitchAction } from "../settings/settings-types.js";
|
|
10
|
+
import { normalizeOnSwitch } from "../settings/settings-validation.js";
|
|
11
|
+
import { safeTerminalText } from "../ui/terminal-text.js";
|
|
11
12
|
import { SetupPullRequiresUiError } from "./sync-errors.js";
|
|
12
|
-
import { safeTerminalText } from "./sync-format.js";
|
|
13
|
-
import type { OnSwitchAction } from "./types.js";
|
|
14
13
|
|
|
15
14
|
export { SetupPullRequiresUiError } from "./sync-errors.js";
|
|
16
15
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function throwIfAborted(signal?: AbortSignal) {
|
|
2
|
+
if (!signal?.aborted) return;
|
|
3
|
+
throw signal.reason instanceof Error
|
|
4
|
+
? signal.reason
|
|
5
|
+
: new DOMException("The operation was aborted", "AbortError");
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function combineSignals(primary: AbortSignal, secondary?: AbortSignal) {
|
|
9
|
+
return secondary ? AbortSignal.any([primary, secondary]) : primary;
|
|
10
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadConfigForCheck, syncCheckConfigFingerprint } from "../settings/config.js";
|
|
3
|
+
import { isMissingConfigError } from "../settings/config-errors.js";
|
|
4
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
5
|
+
import { snapshotOptionsForContext } from "../snapshot/session-paths.js";
|
|
6
|
+
import { withLock } from "../state/lock.js";
|
|
7
|
+
import { withStateDirectoryAccess } from "../state/state-directory.js";
|
|
8
|
+
import { readStateForConfig, syncStateFingerprint } from "../state/sync-state-store.js";
|
|
9
|
+
import type { SyncAttentionController } from "../ui/sync-attention.js";
|
|
10
|
+
import { safeTerminalText } from "../ui/terminal-text.js";
|
|
11
|
+
import { combineSignals, throwIfAborted } from "./signals.js";
|
|
12
|
+
import { errorMessage } from "./sync-errors.js";
|
|
13
|
+
import type { SyncLoaders } from "./sync-loaders.js";
|
|
14
|
+
|
|
15
|
+
const STATUS_KEY = "sync";
|
|
16
|
+
|
|
17
|
+
/** One owned task per extension session, never keyed by the shared headless UI. */
|
|
18
|
+
export function createStartupCheck(
|
|
19
|
+
loaders: SyncLoaders,
|
|
20
|
+
attention: SyncAttentionController,
|
|
21
|
+
timeoutMs = 30_000,
|
|
22
|
+
) {
|
|
23
|
+
let active:
|
|
24
|
+
| {
|
|
25
|
+
controller: AbortController;
|
|
26
|
+
promise: Promise<void>;
|
|
27
|
+
owner: ExtensionContext["sessionManager"];
|
|
28
|
+
}
|
|
29
|
+
| undefined;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
start(ctx: ExtensionContext, sessionSignal: AbortSignal, initialConfig?: AnySyncConfig) {
|
|
33
|
+
if (!ctx.hasUI || sessionSignal.aborted) return;
|
|
34
|
+
if (active) throw new Error("Previous startup check has not been drained.");
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const signal = combineSignals(sessionSignal, controller.signal);
|
|
37
|
+
const task = { controller, owner: ctx.sessionManager, promise: Promise.resolve() };
|
|
38
|
+
active = task;
|
|
39
|
+
const isCurrent = () =>
|
|
40
|
+
!sessionSignal.aborted && active === task && task.owner === ctx.sessionManager;
|
|
41
|
+
const timer = setTimeout(
|
|
42
|
+
() => controller.abort(new DOMException("Startup check timed out", "TimeoutError")),
|
|
43
|
+
timeoutMs,
|
|
44
|
+
);
|
|
45
|
+
let config: AnySyncConfig | undefined;
|
|
46
|
+
let checking = false;
|
|
47
|
+
// Handle every detached rejection here; Pi's awaited event runner cannot catch it.
|
|
48
|
+
task.promise = Promise.resolve()
|
|
49
|
+
.then(async () => {
|
|
50
|
+
try {
|
|
51
|
+
throwIfAborted(signal);
|
|
52
|
+
config = initialConfig ?? (await loadConfigForCheck());
|
|
53
|
+
throwIfAborted(signal);
|
|
54
|
+
if (!config.automatic) return;
|
|
55
|
+
const captured = config;
|
|
56
|
+
const identity = syncCheckConfigFingerprint(captured);
|
|
57
|
+
checking = true;
|
|
58
|
+
ctx.ui.setStatus(STATUS_KEY, "checking in background");
|
|
59
|
+
const { inspectSync } = await loaders.inspection();
|
|
60
|
+
throwIfAborted(signal);
|
|
61
|
+
const currentConfig = await loadConfigForCheck();
|
|
62
|
+
throwIfAborted(signal);
|
|
63
|
+
if (syncCheckConfigFingerprint(currentConfig) !== identity) return;
|
|
64
|
+
// Lock order: state migration guard → sync operation → backend cache.
|
|
65
|
+
// Read heads may write only private cache data. Never call doctor/syncBoth.
|
|
66
|
+
const inspection = await withStateDirectoryAccess(() => {
|
|
67
|
+
throwIfAborted(signal);
|
|
68
|
+
return withLock("startup-check", () => {
|
|
69
|
+
throwIfAborted(signal);
|
|
70
|
+
return inspectSync(captured, snapshotOptionsForContext(ctx, captured), signal);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
throwIfAborted(signal);
|
|
74
|
+
const latest = await loadConfigForCheck();
|
|
75
|
+
throwIfAborted(signal);
|
|
76
|
+
if (syncCheckConfigFingerprint(latest) !== identity) return;
|
|
77
|
+
const state = await withStateDirectoryAccess(() => readStateForConfig(latest));
|
|
78
|
+
throwIfAborted(signal);
|
|
79
|
+
if (syncStateFingerprint(state) !== inspection.stateIdentity || !isCurrent()) return;
|
|
80
|
+
attention.observe({
|
|
81
|
+
setupName: captured.setupName,
|
|
82
|
+
configIdentity: identity,
|
|
83
|
+
checkedAt: new Date().toISOString(),
|
|
84
|
+
inspection,
|
|
85
|
+
});
|
|
86
|
+
if (ctx.mode === "rpc") attention.notifyObservation(ctx);
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (!isCurrent() || sessionSignal.aborted || isMissingConfigError(error)) return;
|
|
89
|
+
if (signal.aborted && controller.signal.reason?.name !== "TimeoutError") return;
|
|
90
|
+
// A settings edit invalidates failures as well as successful observations.
|
|
91
|
+
if (config) {
|
|
92
|
+
const latest = await loadConfigForCheck().catch(() => undefined);
|
|
93
|
+
if (
|
|
94
|
+
!isCurrent() ||
|
|
95
|
+
(signal.aborted && controller.signal.reason?.name !== "TimeoutError")
|
|
96
|
+
)
|
|
97
|
+
return;
|
|
98
|
+
if (
|
|
99
|
+
!latest ||
|
|
100
|
+
syncCheckConfigFingerprint(latest) !== syncCheckConfigFingerprint(config)
|
|
101
|
+
)
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
ctx.ui.notify(
|
|
105
|
+
`pi-sync startup check skipped: ${safeTerminalText(errorMessage(error))}. Run /sync status to retry. No startup transfer was performed.`,
|
|
106
|
+
"warning",
|
|
107
|
+
);
|
|
108
|
+
} finally {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
if (isCurrent() && checking) await attention.publish(ctx, sessionSignal);
|
|
111
|
+
if (active === task) active = undefined;
|
|
112
|
+
}
|
|
113
|
+
})
|
|
114
|
+
.catch(() => {
|
|
115
|
+
// UI teardown can itself throw. No unhandled rejection or retained timer.
|
|
116
|
+
clearTimeout(timer);
|
|
117
|
+
if (active === task) active = undefined;
|
|
118
|
+
});
|
|
119
|
+
},
|
|
120
|
+
async stop() {
|
|
121
|
+
const task = active;
|
|
122
|
+
if (!task) return;
|
|
123
|
+
task.controller.abort(new DOMException("Startup check cancelled", "AbortError"));
|
|
124
|
+
// Drain the actual work, including child/ref cleanup, before foreground guards.
|
|
125
|
+
await task.promise;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { syncConfigReviewIdentity } from "
|
|
1
|
+
import { syncConfigReviewIdentity } from "../settings/config.js";
|
|
2
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
3
|
+
import type { Snapshot } from "../snapshot/snapshot-types.js";
|
|
4
|
+
import type { SyncState } from "../state/state-types.js";
|
|
5
|
+
import { formatDiff, formatSnapshotOnlyDiff } from "../ui/sync-format.js";
|
|
6
|
+
import { safeTerminalText } from "../ui/terminal-text.js";
|
|
2
7
|
import { SyncDecisionRequiredError } from "./sync-errors.js";
|
|
3
|
-
import { formatDiff, formatSnapshotOnlyDiff, safeTerminalText } from "./sync-format.js";
|
|
4
8
|
import { includeFromSelectionConfig } from "./sync-policy.js";
|
|
5
9
|
import { syncPolicyChanged } from "./sync-state.js";
|
|
6
|
-
import type { AnySyncConfig, Snapshot, SyncState } from "./types.js";
|
|
7
10
|
|
|
8
11
|
export type SyncDecisionKind =
|
|
9
12
|
| "remote-or-policy-changed"
|