@narumitw/pi-sync 0.54.3 → 0.56.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 +21 -4
- 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 +84 -1
- package/package.json +3 -2
- 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
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadConfigForCheck, syncCheckConfigFingerprint } from "../settings/config.js";
|
|
3
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
4
|
+
import { snapshotOptionsForContext } from "../snapshot/session-paths.js";
|
|
5
|
+
import { withLock } from "../state/lock.js";
|
|
6
|
+
import { withStateDirectoryAccess } from "../state/state-directory.js";
|
|
7
|
+
import { readStateForConfig } from "../state/sync-state-store.js";
|
|
8
|
+
import { setSyncStatus } from "../ui/sync-status.js";
|
|
9
|
+
import { withAgentSyncLock } from "./agent-sync.js";
|
|
10
|
+
import { ContinuousError, continuousFailure } from "./continuous-errors.js";
|
|
11
|
+
import { acquireContinuousLease, type ContinuousLease } from "./continuous-lease.js";
|
|
12
|
+
import {
|
|
13
|
+
normalizeContinuousClock,
|
|
14
|
+
readContinuousRecord,
|
|
15
|
+
reserveContinuousRequest,
|
|
16
|
+
updateContinuousRecord,
|
|
17
|
+
} from "./continuous-record.js";
|
|
18
|
+
import { continuousBinding, hashContinuous } from "./continuous-storage.js";
|
|
19
|
+
import { watchContinuous } from "./continuous-watch.js";
|
|
20
|
+
import { readMergeJournal } from "./merge-journal.js";
|
|
21
|
+
import type { SyncLoaders } from "./sync-loaders.js";
|
|
22
|
+
import { captureMutationOwner } from "./sync-local.js";
|
|
23
|
+
import { fileHashMap } from "./sync-state.js";
|
|
24
|
+
|
|
25
|
+
export function continuousEnabled(config: Pick<AnySyncConfig, "continuous" | "agentMode">) {
|
|
26
|
+
return Boolean(config.continuous && config.agentMode);
|
|
27
|
+
}
|
|
28
|
+
export interface ContinuousClock {
|
|
29
|
+
now(): number;
|
|
30
|
+
random(): number;
|
|
31
|
+
schedule(ms: number, run: () => void, kind?: "wait" | "authorization"): () => void;
|
|
32
|
+
}
|
|
33
|
+
const systemClock: ContinuousClock = {
|
|
34
|
+
now: () => Date.now(),
|
|
35
|
+
random: () => Math.random(),
|
|
36
|
+
schedule: (ms, run) => {
|
|
37
|
+
const timer = setTimeout(run, ms);
|
|
38
|
+
timer.unref?.();
|
|
39
|
+
return () => clearTimeout(timer);
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
const LOCAL_CHECK_MS = 5_000;
|
|
43
|
+
const POLL_MS = 60_000;
|
|
44
|
+
const DEBOUNCE_MS = 1_000;
|
|
45
|
+
|
|
46
|
+
/** Created on first enabled session. No module/factory-owned timers or watchers. */
|
|
47
|
+
export function createContinuousSync(loaders: SyncLoaders, clock: ContinuousClock = systemClock) {
|
|
48
|
+
const repairNotices = new WeakSet<ExtensionContext["sessionManager"]>();
|
|
49
|
+
let task:
|
|
50
|
+
| { owner: ExtensionContext["sessionManager"]; controller: AbortController; promise: Promise<void>; clear(): void }
|
|
51
|
+
| undefined;
|
|
52
|
+
return {
|
|
53
|
+
start(ctx: ExtensionContext, sessionSignal: AbortSignal, explicitRetry = false) {
|
|
54
|
+
if (!ctx.hasUI || (ctx.mode !== "tui" && ctx.mode !== "rpc") || sessionSignal.aborted || task) return;
|
|
55
|
+
const controller = new AbortController();
|
|
56
|
+
const signal = AbortSignal.any([sessionSignal, controller.signal]);
|
|
57
|
+
const validateContext = captureMutationOwner(ctx);
|
|
58
|
+
const current = {
|
|
59
|
+
owner: ctx.sessionManager,
|
|
60
|
+
controller,
|
|
61
|
+
promise: Promise.resolve(),
|
|
62
|
+
clear: () => {
|
|
63
|
+
try {
|
|
64
|
+
validateContext();
|
|
65
|
+
setSyncStatus(ctx, undefined);
|
|
66
|
+
} catch {
|
|
67
|
+
/* replaced session */
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
task = current;
|
|
72
|
+
current.promise = (async () => {
|
|
73
|
+
let explicit = explicitRetry;
|
|
74
|
+
while (!signal.aborted) {
|
|
75
|
+
try {
|
|
76
|
+
await run(ctx, signal, explicit);
|
|
77
|
+
return;
|
|
78
|
+
} catch {
|
|
79
|
+
if (signal.aborted) return;
|
|
80
|
+
try {
|
|
81
|
+
validateContext();
|
|
82
|
+
setSyncStatus(ctx, "sync blocked: private state or settings need repair");
|
|
83
|
+
if (!repairNotices.has(ctx.sessionManager)) {
|
|
84
|
+
repairNotices.add(ctx.sessionManager);
|
|
85
|
+
ctx.ui.notify(
|
|
86
|
+
"Continuous sync blocked: private state or settings need repair. Use /sync. Evidence was retained.",
|
|
87
|
+
"warning",
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
explicit = false;
|
|
95
|
+
// Only local prerequisites are retried here. No remote/model request can
|
|
96
|
+
// pass a malformed settings/state read. A repair can resume monitoring.
|
|
97
|
+
await wait(clock, LOCAL_CHECK_MS, signal);
|
|
98
|
+
}
|
|
99
|
+
})()
|
|
100
|
+
.catch(() => {
|
|
101
|
+
/* Cancellation/UI disposal must not leak a detached rejection. */
|
|
102
|
+
})
|
|
103
|
+
.finally(() => {
|
|
104
|
+
if (task === current) task = undefined;
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
async stop(owner?: ExtensionContext["sessionManager"]) {
|
|
108
|
+
const current = task;
|
|
109
|
+
if (!current || (owner && current.owner !== owner)) return;
|
|
110
|
+
current.controller.abort(new DOMException("Continuous sync interrupted", "AbortError"));
|
|
111
|
+
current.clear();
|
|
112
|
+
await current.promise;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
async function run(ctx: ExtensionContext, signal: AbortSignal, explicitRetry: boolean) {
|
|
117
|
+
const validateOwner = captureMutationOwner(ctx, signal);
|
|
118
|
+
try {
|
|
119
|
+
while (!signal.aborted) {
|
|
120
|
+
validateOwner();
|
|
121
|
+
const config = await loadConfigForCheck();
|
|
122
|
+
validateOwner();
|
|
123
|
+
if (!continuousEnabled(config)) return;
|
|
124
|
+
try {
|
|
125
|
+
if (ctx.isIdle()) {
|
|
126
|
+
let lease: ContinuousLease | undefined;
|
|
127
|
+
let binding = "";
|
|
128
|
+
try {
|
|
129
|
+
await withStateDirectoryAccess(async () => {
|
|
130
|
+
validateOwner();
|
|
131
|
+
binding = await continuousBinding(config);
|
|
132
|
+
validateOwner();
|
|
133
|
+
lease = await acquireContinuousLease(binding);
|
|
134
|
+
validateOwner();
|
|
135
|
+
});
|
|
136
|
+
if (lease) {
|
|
137
|
+
await monitor(ctx, config, binding, lease, signal, explicitRetry);
|
|
138
|
+
explicitRetry = false;
|
|
139
|
+
} else setSyncStatus(ctx, "sync waiting: another local owner");
|
|
140
|
+
} finally {
|
|
141
|
+
await lease?.release();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (signal.aborted) return;
|
|
146
|
+
const failure = continuousFailure(error);
|
|
147
|
+
if (!failure.retry && !(error instanceof Error && error.name === "AbortError")) throw error;
|
|
148
|
+
setSyncStatus(ctx, `sync retry scheduled: ${failure.kind}`);
|
|
149
|
+
}
|
|
150
|
+
validateOwner();
|
|
151
|
+
if (!continuousEnabled(await loadConfigForCheck())) return;
|
|
152
|
+
validateOwner();
|
|
153
|
+
await wait(clock, LOCAL_CHECK_MS, signal);
|
|
154
|
+
}
|
|
155
|
+
} finally {
|
|
156
|
+
// Shutdown/replacement owns clearing the old UI; do not call it from a stale continuation.
|
|
157
|
+
if (!signal.aborted) {
|
|
158
|
+
try {
|
|
159
|
+
validateOwner();
|
|
160
|
+
setSyncStatus(ctx, undefined);
|
|
161
|
+
} catch {
|
|
162
|
+
/* stale context */
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function monitor(
|
|
169
|
+
ctx: ExtensionContext,
|
|
170
|
+
initial: AnySyncConfig,
|
|
171
|
+
binding: string,
|
|
172
|
+
lease: ContinuousLease,
|
|
173
|
+
parent: AbortSignal,
|
|
174
|
+
explicitRetry: boolean,
|
|
175
|
+
) {
|
|
176
|
+
const signal = AbortSignal.any([parent, lease.signal]);
|
|
177
|
+
const validateOwner = captureMutationOwner(ctx, signal);
|
|
178
|
+
let config = initial;
|
|
179
|
+
let closeWatch = () => {};
|
|
180
|
+
let wake = () => {};
|
|
181
|
+
let dirtyAt: number | undefined;
|
|
182
|
+
let cycle: AbortController | undefined;
|
|
183
|
+
const changed = (settings: boolean) => {
|
|
184
|
+
dirtyAt = clock.now();
|
|
185
|
+
if (settings) cycle?.abort(new DOMException("Continuous settings changed", "AbortError"));
|
|
186
|
+
wake();
|
|
187
|
+
};
|
|
188
|
+
const validate = () => {
|
|
189
|
+
validateOwner();
|
|
190
|
+
lease.assert();
|
|
191
|
+
if (!ctx.isIdle()) throw new DOMException("Owning session became busy", "AbortError");
|
|
192
|
+
};
|
|
193
|
+
const stopWatch = () => {
|
|
194
|
+
closeWatch();
|
|
195
|
+
wake();
|
|
196
|
+
};
|
|
197
|
+
signal.addEventListener("abort", stopWatch, { once: true });
|
|
198
|
+
let pollDue = 0;
|
|
199
|
+
try {
|
|
200
|
+
closeWatch = await watchContinuous(config, changed);
|
|
201
|
+
validate();
|
|
202
|
+
while (!signal.aborted && ctx.isIdle()) {
|
|
203
|
+
validate();
|
|
204
|
+
const latest = await loadConfigForCheck();
|
|
205
|
+
validate();
|
|
206
|
+
if (!continuousEnabled(latest) || (await continuousBinding(latest)) !== binding) return;
|
|
207
|
+
validate();
|
|
208
|
+
if (syncCheckConfigFingerprint(latest) !== syncCheckConfigFingerprint(config)) {
|
|
209
|
+
closeWatch();
|
|
210
|
+
config = latest;
|
|
211
|
+
closeWatch = await watchContinuous(config, changed);
|
|
212
|
+
validate();
|
|
213
|
+
pollDue = 0;
|
|
214
|
+
}
|
|
215
|
+
const record = await readContinuousRecord(binding);
|
|
216
|
+
validate();
|
|
217
|
+
const now = clock.now();
|
|
218
|
+
const oldClock = JSON.stringify([record.next, record.calls]);
|
|
219
|
+
normalizeContinuousClock(record, now);
|
|
220
|
+
if (oldClock !== JSON.stringify([record.next, record.calls])) {
|
|
221
|
+
await updateContinuousRecord(binding, lease, (current) => normalizeContinuousClock(current, now));
|
|
222
|
+
validate();
|
|
223
|
+
}
|
|
224
|
+
const configuration = hashContinuous([{ ...config, showStatus: undefined, debug: undefined }, ctx.model]);
|
|
225
|
+
const controlChanged = record.configuration !== configuration;
|
|
226
|
+
if (controlChanged || explicitRetry) {
|
|
227
|
+
record.next = 0;
|
|
228
|
+
pollDue = 0;
|
|
229
|
+
}
|
|
230
|
+
const hintDue = dirtyAt === undefined ? Number.POSITIVE_INFINITY : dirtyAt + DEBOUNCE_MS;
|
|
231
|
+
const due = Math.max(record.error === "budget" ? 0 : record.next, Math.min(pollDue, hintDue));
|
|
232
|
+
if (now >= due) {
|
|
233
|
+
const force = explicitRetry || controlChanged;
|
|
234
|
+
explicitRetry = false;
|
|
235
|
+
const expectedModel = hashContinuous(ctx.model);
|
|
236
|
+
const expectedConfig = syncCheckConfigFingerprint(config);
|
|
237
|
+
dirtyAt = undefined;
|
|
238
|
+
cycle = new AbortController();
|
|
239
|
+
const cancelDeadline = clock.schedule(30_000, () =>
|
|
240
|
+
cycle?.abort(new DOMException("Continuous cycle timed out", "TimeoutError")),
|
|
241
|
+
);
|
|
242
|
+
const guard = new AbortController();
|
|
243
|
+
const guardSignal = AbortSignal.any([signal, cycle.signal, guard.signal]);
|
|
244
|
+
const activeCycle = cycle;
|
|
245
|
+
// Transfer entrypoints supply each fresh settings read. Never let a new
|
|
246
|
+
// transfer adopt authorization that changed after this cycle's inspection.
|
|
247
|
+
const validateCycle = (latest?: AnySyncConfig) => {
|
|
248
|
+
validate();
|
|
249
|
+
if (
|
|
250
|
+
(latest && syncCheckConfigFingerprint(latest) !== expectedConfig) ||
|
|
251
|
+
hashContinuous(ctx.model) !== expectedModel
|
|
252
|
+
)
|
|
253
|
+
activeCycle.abort(new DOMException("Continuous authorization changed", "AbortError"));
|
|
254
|
+
activeCycle.signal.throwIfAborted();
|
|
255
|
+
};
|
|
256
|
+
const guardTask = (async () => {
|
|
257
|
+
while (!guardSignal.aborted) {
|
|
258
|
+
await wait(clock, LOCAL_CHECK_MS, guardSignal, undefined, "authorization");
|
|
259
|
+
if (guardSignal.aborted) return;
|
|
260
|
+
try {
|
|
261
|
+
validate();
|
|
262
|
+
const latest = await loadConfigForCheck();
|
|
263
|
+
if (guardSignal.aborted) return;
|
|
264
|
+
validateCycle(latest);
|
|
265
|
+
} catch {
|
|
266
|
+
activeCycle.abort(new DOMException("Continuous authorization unavailable", "AbortError"));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
})().catch(() => {});
|
|
270
|
+
try {
|
|
271
|
+
await performCycle(
|
|
272
|
+
ctx,
|
|
273
|
+
config,
|
|
274
|
+
binding,
|
|
275
|
+
lease,
|
|
276
|
+
AbortSignal.any([signal, cycle.signal]),
|
|
277
|
+
validateCycle,
|
|
278
|
+
configuration,
|
|
279
|
+
force,
|
|
280
|
+
);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (signal.aborted || !ctx.isIdle()) return;
|
|
283
|
+
// A settings hint interrupts the old authorization; load it afresh, not as a failed transfer.
|
|
284
|
+
if (cycle.signal.aborted && cycle.signal.reason?.name !== "TimeoutError") {
|
|
285
|
+
pollDue = 0;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const failure = continuousFailure(error);
|
|
289
|
+
let announce = false;
|
|
290
|
+
await updateContinuousRecord(binding, lease, (current) => {
|
|
291
|
+
const now = clock.now();
|
|
292
|
+
normalizeContinuousClock(current, now);
|
|
293
|
+
const notice = hashContinuous([failure.kind, current.evidence, configuration]);
|
|
294
|
+
announce = current.notice !== notice;
|
|
295
|
+
if (!failure.retry) current.notice = notice;
|
|
296
|
+
current.configuration = configuration;
|
|
297
|
+
current.pending = true;
|
|
298
|
+
current.error = failure.kind;
|
|
299
|
+
current.phase = failure.retry ? "retry" : "blocked";
|
|
300
|
+
current.failures = Math.min(30, current.failures + 1);
|
|
301
|
+
const base = Math.min(300_000, 5_000 * 2 ** Math.min(6, current.failures - 1));
|
|
302
|
+
const delay = Math.min(300_000, base + Math.floor(clock.random() * base * 0.2));
|
|
303
|
+
current.next = now + Math.max(delay, failure.delay ?? 0);
|
|
304
|
+
});
|
|
305
|
+
validate();
|
|
306
|
+
if (announce && !failure.retry)
|
|
307
|
+
ctx.ui.notify(
|
|
308
|
+
`Continuous sync blocked (${failure.kind}). Review /sync or repair the setup. Evidence was retained.`,
|
|
309
|
+
"warning",
|
|
310
|
+
);
|
|
311
|
+
} finally {
|
|
312
|
+
cancelDeadline();
|
|
313
|
+
guard.abort();
|
|
314
|
+
await guardTask;
|
|
315
|
+
cycle = undefined;
|
|
316
|
+
}
|
|
317
|
+
pollDue = clock.now() + POLL_MS + Math.floor(clock.random() * 6_000);
|
|
318
|
+
closeWatch();
|
|
319
|
+
closeWatch = () => {};
|
|
320
|
+
closeWatch = await watchContinuous(config, changed);
|
|
321
|
+
validate();
|
|
322
|
+
}
|
|
323
|
+
validate();
|
|
324
|
+
const state = await readContinuousRecord(binding);
|
|
325
|
+
validate();
|
|
326
|
+
setSyncStatus(
|
|
327
|
+
ctx,
|
|
328
|
+
state.phase === "blocked"
|
|
329
|
+
? `sync blocked: ${state.error}`
|
|
330
|
+
: state.phase === "retry"
|
|
331
|
+
? `sync retry scheduled: ${state.error}`
|
|
332
|
+
: "sync waiting for changes",
|
|
333
|
+
);
|
|
334
|
+
if (state.phase === "retry") pollDue = Math.min(pollDue, state.next);
|
|
335
|
+
const next = Math.max(
|
|
336
|
+
state.error === "budget" ? 0 : state.next,
|
|
337
|
+
Math.min(pollDue, dirtyAt === undefined ? Number.POSITIVE_INFINITY : dirtyAt + DEBOUNCE_MS),
|
|
338
|
+
);
|
|
339
|
+
await wait(clock, Math.max(1, Math.min(LOCAL_CHECK_MS, next - clock.now())), signal, (callback) => {
|
|
340
|
+
wake = callback;
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
} finally {
|
|
344
|
+
signal.removeEventListener("abort", stopWatch);
|
|
345
|
+
closeWatch();
|
|
346
|
+
cycle?.abort();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function performCycle(
|
|
351
|
+
ctx: ExtensionContext,
|
|
352
|
+
config: AnySyncConfig,
|
|
353
|
+
binding: string,
|
|
354
|
+
lease: ContinuousLease,
|
|
355
|
+
signal: AbortSignal,
|
|
356
|
+
validateOwner: (config?: AnySyncConfig) => void,
|
|
357
|
+
configuration: string,
|
|
358
|
+
force: boolean,
|
|
359
|
+
) {
|
|
360
|
+
const validate = (latest?: AnySyncConfig) => {
|
|
361
|
+
signal.throwIfAborted();
|
|
362
|
+
validateOwner(latest);
|
|
363
|
+
};
|
|
364
|
+
validate();
|
|
365
|
+
const current = await readContinuousRecord(binding);
|
|
366
|
+
validate();
|
|
367
|
+
// A newly pending journal may prove an uncertain commit. It is never authority to republish.
|
|
368
|
+
const pending = await guardedLock("continuous-journal", () => readMergeJournal(config));
|
|
369
|
+
validate();
|
|
370
|
+
if (
|
|
371
|
+
!force &&
|
|
372
|
+
!pending &&
|
|
373
|
+
current.phase === "blocked" &&
|
|
374
|
+
["authentication", "storage", "unknown"].includes(current.error ?? "")
|
|
375
|
+
)
|
|
376
|
+
return;
|
|
377
|
+
setSyncStatus(ctx, "sync checking");
|
|
378
|
+
const inspectionModule = await loaders.inspection();
|
|
379
|
+
validate();
|
|
380
|
+
const observed = await guardedLock("continuous-check", async () => {
|
|
381
|
+
validate();
|
|
382
|
+
const inspection = await inspectionModule.inspectSync(config, snapshotOptionsForContext(ctx, config), signal);
|
|
383
|
+
validate();
|
|
384
|
+
let localIdentity = inspection.localIdentity;
|
|
385
|
+
if (!localIdentity) {
|
|
386
|
+
const snapshotModule = await loaders.snapshot();
|
|
387
|
+
validate();
|
|
388
|
+
const local = await snapshotModule.createSnapshot(config.snapshotIdentity, {
|
|
389
|
+
...snapshotOptionsForContext(ctx, config),
|
|
390
|
+
signal,
|
|
391
|
+
});
|
|
392
|
+
validate();
|
|
393
|
+
localIdentity = hashContinuous(fileHashMap(local));
|
|
394
|
+
}
|
|
395
|
+
const journal = await readMergeJournal(config);
|
|
396
|
+
validate();
|
|
397
|
+
return { inspection, journal, localIdentity };
|
|
398
|
+
});
|
|
399
|
+
validate();
|
|
400
|
+
const evidence = hashContinuous([
|
|
401
|
+
configuration,
|
|
402
|
+
observed.localIdentity,
|
|
403
|
+
observed.inspection.head?.revision,
|
|
404
|
+
observed.inspection.stateIdentity,
|
|
405
|
+
]);
|
|
406
|
+
const recovery = observed.journal ? hashContinuous([evidence, observed.journal]) : undefined;
|
|
407
|
+
const blocked =
|
|
408
|
+
!force &&
|
|
409
|
+
current.evidence === evidence &&
|
|
410
|
+
((current.phase === "blocked" && (!recovery || recovery === current.recovery)) ||
|
|
411
|
+
(current.error === "budget" && current.next > clock.now() && !observed.journal));
|
|
412
|
+
await updateContinuousRecord(binding, lease, (record) => {
|
|
413
|
+
if (record.configuration !== configuration) {
|
|
414
|
+
record.failures = 0;
|
|
415
|
+
record.next = 0;
|
|
416
|
+
}
|
|
417
|
+
record.configuration = configuration;
|
|
418
|
+
record.checked = clock.now();
|
|
419
|
+
record.evidence = evidence;
|
|
420
|
+
if (!blocked) {
|
|
421
|
+
if (record.phase !== "blocked") record.phase = "syncing";
|
|
422
|
+
record.pending = true;
|
|
423
|
+
if (recovery) record.recovery = recovery;
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
validate();
|
|
427
|
+
if (blocked) return;
|
|
428
|
+
if (
|
|
429
|
+
!observed.journal &&
|
|
430
|
+
(observed.inspection.firstSync ||
|
|
431
|
+
!observed.inspection.head ||
|
|
432
|
+
observed.inspection.selectionState?.kind !== "same")
|
|
433
|
+
)
|
|
434
|
+
throw new ContinuousError("unsafe", "Continuous sync needs an accepted baseline and matching remote selection.");
|
|
435
|
+
let transferred = false;
|
|
436
|
+
if (observed.journal || observed.inspection.localChanged || observed.inspection.remoteChanged) {
|
|
437
|
+
const operations = await loaders.operations();
|
|
438
|
+
validate();
|
|
439
|
+
setSyncStatus(ctx, "sync syncing");
|
|
440
|
+
const result = await withStateDirectoryAccess(() =>
|
|
441
|
+
withAgentSyncLock(
|
|
442
|
+
ctx,
|
|
443
|
+
{
|
|
444
|
+
args: [],
|
|
445
|
+
yes: true,
|
|
446
|
+
force: false,
|
|
447
|
+
stale: false,
|
|
448
|
+
silent: true,
|
|
449
|
+
reload: false,
|
|
450
|
+
auto: true,
|
|
451
|
+
signal,
|
|
452
|
+
background: {
|
|
453
|
+
validate,
|
|
454
|
+
reserve: (key) => reserveContinuousRequest(binding, lease, key, () => clock.now()),
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
(options) => operations.syncBoth(ctx, options),
|
|
458
|
+
),
|
|
459
|
+
);
|
|
460
|
+
validate();
|
|
461
|
+
transferred = result === "applied";
|
|
462
|
+
}
|
|
463
|
+
const state = await guardedLock("continuous-result", () => readStateForConfig(config));
|
|
464
|
+
validate();
|
|
465
|
+
const unresolved = state.unresolved;
|
|
466
|
+
let announcePartial = false;
|
|
467
|
+
await updateContinuousRecord(binding, lease, (record) => {
|
|
468
|
+
record.phase = unresolved?.length ? "blocked" : "waiting";
|
|
469
|
+
record.pending = Boolean(unresolved?.length);
|
|
470
|
+
record.failures = 0;
|
|
471
|
+
record.next = 0;
|
|
472
|
+
if (unresolved?.length) {
|
|
473
|
+
record.error = "unsafe";
|
|
474
|
+
const notice = hashContinuous(["partial", unresolved, configuration]);
|
|
475
|
+
announcePartial = record.notice !== notice;
|
|
476
|
+
record.notice = notice;
|
|
477
|
+
} else {
|
|
478
|
+
delete record.error;
|
|
479
|
+
delete record.notice;
|
|
480
|
+
}
|
|
481
|
+
if (transferred) record.transferred = clock.now();
|
|
482
|
+
});
|
|
483
|
+
validate();
|
|
484
|
+
if (announcePartial)
|
|
485
|
+
ctx.ui.notify(
|
|
486
|
+
"Continuous sync retained unresolved groups. Review /sync conflicts; independent partial progress was preserved.",
|
|
487
|
+
"warning",
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function guardedLock<T>(command: string, run: () => Promise<T>) {
|
|
493
|
+
return withStateDirectoryAccess(() => withLock(command, run));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function wait(
|
|
497
|
+
clock: ContinuousClock,
|
|
498
|
+
ms: number,
|
|
499
|
+
signal: AbortSignal,
|
|
500
|
+
expose?: (wake: () => void) => void,
|
|
501
|
+
kind: "wait" | "authorization" = "wait",
|
|
502
|
+
) {
|
|
503
|
+
signal.throwIfAborted();
|
|
504
|
+
return new Promise<void>((resolve) => {
|
|
505
|
+
const done = () => {
|
|
506
|
+
cancel();
|
|
507
|
+
signal.removeEventListener("abort", done);
|
|
508
|
+
expose?.(() => {});
|
|
509
|
+
resolve();
|
|
510
|
+
};
|
|
511
|
+
const cancel = clock.schedule(ms, done, kind);
|
|
512
|
+
signal.addEventListener("abort", done, { once: true });
|
|
513
|
+
expose?.(done);
|
|
514
|
+
if (signal.aborted) done();
|
|
515
|
+
});
|
|
516
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type FSWatcher, watch } from "node:fs";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { isDeniedPath } from "../paths.js";
|
|
6
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
7
|
+
|
|
8
|
+
/** Hints only. The scheduler's periodic snapshot scan remains required. */
|
|
9
|
+
export async function watchContinuous(
|
|
10
|
+
config: AnySyncConfig,
|
|
11
|
+
changed: (settings: boolean) => void,
|
|
12
|
+
): Promise<() => void> {
|
|
13
|
+
const root = getAgentDir();
|
|
14
|
+
const watchers: FSWatcher[] = [];
|
|
15
|
+
const selected = config.include.map((item) => path.resolve(root, item));
|
|
16
|
+
const relevant = (target: string) =>
|
|
17
|
+
selected.some(
|
|
18
|
+
(item) => target === item || target.startsWith(`${item}${path.sep}`) || item.startsWith(`${target}${path.sep}`),
|
|
19
|
+
);
|
|
20
|
+
const add = (directory: string, recursive: boolean) => {
|
|
21
|
+
try {
|
|
22
|
+
const watcher = watch(directory, { recursive, persistent: false }, (_event, file) => {
|
|
23
|
+
// Unknown names can be our own private writes. Periodic scans cover them.
|
|
24
|
+
if (!file) return;
|
|
25
|
+
const target = path.resolve(directory, String(file));
|
|
26
|
+
if (target === path.join(root, "pi-sync.json")) changed(true);
|
|
27
|
+
else if (isDeniedPath(path.relative(root, target))) return;
|
|
28
|
+
else if (relevant(target)) changed(false);
|
|
29
|
+
});
|
|
30
|
+
watcher.on("error", () => watcher.close());
|
|
31
|
+
watchers.push(watcher);
|
|
32
|
+
} catch {
|
|
33
|
+
/* Periodic scans cover unavailable or unsupported watchers. */
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
add(root, false);
|
|
37
|
+
const directories = new Set<string>();
|
|
38
|
+
try {
|
|
39
|
+
for (const target of selected) {
|
|
40
|
+
try {
|
|
41
|
+
const stat = await fs.lstat(target);
|
|
42
|
+
if (stat.isSymbolicLink()) continue;
|
|
43
|
+
const directory = stat.isDirectory() ? target : path.dirname(target);
|
|
44
|
+
if (directory === root || directories.has(directory)) continue;
|
|
45
|
+
directories.add(directory);
|
|
46
|
+
add(directory, stat.isDirectory());
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") continue;
|
|
49
|
+
// Missing targets are found by the parent hint or the next periodic scan.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return () => {
|
|
53
|
+
for (const watcher of watchers) watcher.close();
|
|
54
|
+
};
|
|
55
|
+
} catch (error) {
|
|
56
|
+
for (const watcher of watchers) watcher.close();
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|