@narumitw/pi-sync 0.54.2 → 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 +40 -15
- 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-IJ6VV6FN.ts → chunk-AAEEHR7H.ts} +31 -87
- package/dist/chunks/chunk-AAEEHR7H.ts.map +7 -0
- package/dist/chunks/{chunk-CHSIAC4N.ts → chunk-B4QZBDBT.ts} +2 -2
- package/dist/chunks/{chunk-CL7BZRRQ.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-VHXBCEVF.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-UQ3ALBIB.ts → chunk-GP5DU62N.ts} +8 -9
- package/dist/chunks/chunk-GP5DU62N.ts.map +7 -0
- package/dist/chunks/{chunk-5RZPINUQ.ts → chunk-GSNPASKB.ts} +2 -2
- package/dist/chunks/{chunk-QE7NZZWT.ts → chunk-IWZH5KYS.ts} +23 -7
- package/dist/chunks/chunk-IWZH5KYS.ts.map +7 -0
- package/dist/chunks/{chunk-5ZWQ33XL.ts → chunk-JJTARUSO.ts} +15 -14
- package/dist/chunks/chunk-JJTARUSO.ts.map +7 -0
- package/dist/chunks/{chunk-2DI6PDZV.ts → chunk-NLWS55B6.ts} +15 -2
- package/dist/chunks/chunk-NLWS55B6.ts.map +7 -0
- package/dist/chunks/{chunk-SDZCTCYQ.ts → chunk-OEWQVWLC.ts} +4 -4
- package/dist/chunks/{chunk-MYPSMJHJ.ts → chunk-OTZRCKYP.ts} +380 -510
- package/dist/chunks/chunk-OTZRCKYP.ts.map +7 -0
- package/dist/chunks/{chunk-WFKUKVCP.ts → chunk-OWBAZA6J.ts} +24 -12
- 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-3B4Z2SKV.ts → chunk-QO333NM4.ts} +49 -21
- package/dist/chunks/chunk-QO333NM4.ts.map +7 -0
- package/dist/chunks/{chunk-4SBTAVFL.ts → chunk-QSHMOL3I.ts} +22 -24
- 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-4QVPTIZB.ts → chunk-UPNVIUBN.ts} +3 -3
- package/dist/chunks/chunk-UZPMDLTL.ts +54 -0
- package/dist/chunks/chunk-UZPMDLTL.ts.map +7 -0
- 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-V6VZ5TLG.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-BB2FZMIE.ts → conflict-artifacts-FXFIIBCB.ts} +8 -7
- package/dist/chunks/{conflict-review-4PLVLMRO.ts → conflict-review-6WIEQLXW.ts} +31 -24
- package/dist/chunks/{conflict-review-4PLVLMRO.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-WZX7HAY2.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-6MQKFCWB.ts → manager-result-dispatcher-AGBBVDDS.ts} +10 -9
- package/dist/chunks/{manager-ui-IURS4WXY.ts → manager-ui-KRAW3NE2.ts} +464 -164
- package/dist/chunks/manager-ui-KRAW3NE2.ts.map +7 -0
- package/dist/chunks/{merge-baseline-store-EVQAAML6.ts → merge-baseline-store-RFCAH7KL.ts} +7 -5
- package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts → remote-selection-ui-5725JDNC.ts} +9 -8
- package/dist/chunks/remote-selection-ui-5725JDNC.ts.map +7 -0
- package/dist/chunks/{s3-backend-AQGV6LFT.ts → s3-backend-Y7WSCLJI.ts} +38 -38
- package/dist/chunks/s3-backend-Y7WSCLJI.ts.map +7 -0
- package/dist/chunks/{setup-switch-KTUWL6DE.ts → setup-switch-E7PHKI6J.ts} +7 -6
- package/dist/chunks/setup-switch-E7PHKI6J.ts.map +7 -0
- package/dist/chunks/{setup-wizard-GSDPDFC4.ts → setup-wizard-65DROCIJ.ts} +14 -13
- package/dist/chunks/setup-wizard-65DROCIJ.ts.map +7 -0
- package/dist/chunks/{sync-inspection-K45QVLHB.ts → sync-inspection-XFJOAMJD.ts} +9 -8
- package/dist/chunks/sync-inspection-XFJOAMJD.ts.map +7 -0
- package/dist/chunks/{sync-operations-47OUX76C.ts → sync-operations-HDUDT5PD.ts} +188 -73
- 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 +246 -131
- 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 +131 -11
- package/docs/tui-contract.md +61 -0
- 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 +7 -3
- package/src/commands/command-handler.ts +15 -3
- package/src/commands/command-types.ts +10 -0
- package/src/commands/command.ts +4 -1
- package/src/settings/config.ts +20 -10
- package/src/settings/settings-store.ts +1 -34
- package/src/settings/settings-types.ts +10 -0
- package/src/settings/settings-validation.ts +4 -1
- package/src/snapshot/snapshot-codec.ts +1 -5
- package/src/state/lock.ts +13 -10
- package/src/state/merge-baseline-store.ts +19 -1
- 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/apply-review.ts +63 -0
- package/src/sync/automatic-sync.ts +46 -33
- 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 +268 -112
- 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 +51 -29
- package/src/sync/startup-timings.ts +90 -0
- package/src/sync/sync-inspection.ts +16 -5
- package/src/sync/sync-mutations.ts +55 -28
- package/src/sync/sync-queries.ts +19 -3
- 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 +12 -6
- package/src/ui/manager-ui.ts +57 -7
- package/src/ui/merge-review.ts +3 -3
- package/src/ui/merged-sync-summary.ts +2 -18
- package/src/ui/remote-selection-ui.ts +5 -5
- package/src/ui/settings-frame.ts +55 -0
- package/src/ui/settings-ui.ts +223 -43
- 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-prompts.ts +4 -3
- package/src/ui/setup/setup-switcher.ts +2 -0
- package/src/ui/sync-behavior.ts +15 -0
- package/src/ui/sync-format.ts +69 -38
- package/src/ui/sync-setups-ui.ts +3 -5
- package/src/ui/transfer-changes.ts +33 -0
- package/src/ui/transfer-review.ts +27 -0
- package/dist/chunks/chunk-2DI6PDZV.ts.map +0 -7
- package/dist/chunks/chunk-3B4Z2SKV.ts.map +0 -7
- package/dist/chunks/chunk-4SBTAVFL.ts.map +0 -7
- package/dist/chunks/chunk-5ZWQ33XL.ts.map +0 -7
- package/dist/chunks/chunk-BVVP33U5.ts.map +0 -7
- package/dist/chunks/chunk-IJ6VV6FN.ts.map +0 -7
- package/dist/chunks/chunk-MYPSMJHJ.ts.map +0 -7
- package/dist/chunks/chunk-ORQRHEEO.ts +0 -362
- package/dist/chunks/chunk-ORQRHEEO.ts.map +0 -7
- package/dist/chunks/chunk-QE7NZZWT.ts.map +0 -7
- package/dist/chunks/chunk-UQ3ALBIB.ts.map +0 -7
- package/dist/chunks/chunk-V6VZ5TLG.ts.map +0 -7
- package/dist/chunks/chunk-WFKUKVCP.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-IURS4WXY.ts.map +0 -7
- package/dist/chunks/s3-backend-AQGV6LFT.ts.map +0 -7
- package/dist/chunks/setup-wizard-GSDPDFC4.ts.map +0 -7
- package/dist/chunks/sync-operations-47OUX76C.ts.map +0 -7
- package/dist/chunks/webdav-backend-TBILVEQK.ts.map +0 -7
- /package/dist/chunks/{conflict-artifacts-BB2FZMIE.ts.map → agent-candidate-3JN6DJKT.ts.map} +0 -0
- /package/dist/chunks/{chunk-CHSIAC4N.ts.map → chunk-B4QZBDBT.ts.map} +0 -0
- /package/dist/chunks/{chunk-CL7BZRRQ.ts.map → chunk-CRKTAPSL.ts.map} +0 -0
- /package/dist/chunks/{chunk-VHXBCEVF.ts.map → chunk-EUMINX7L.ts.map} +0 -0
- /package/dist/chunks/{chunk-5RZPINUQ.ts.map → chunk-GSNPASKB.ts.map} +0 -0
- /package/dist/chunks/{chunk-SDZCTCYQ.ts.map → chunk-OEWQVWLC.ts.map} +0 -0
- /package/dist/chunks/{chunk-4QVPTIZB.ts.map → chunk-UPNVIUBN.ts.map} +0 -0
- /package/dist/chunks/{manager-result-dispatcher-6MQKFCWB.ts.map → config-KGIYTTXQ.ts.map} +0 -0
- /package/dist/chunks/{merge-baseline-store-EVQAAML6.ts.map → conflict-artifacts-FXFIIBCB.ts.map} +0 -0
- /package/dist/chunks/{remote-selection-ui-6LJJ4IMQ.ts.map → continuous-record-LFRVXQBD.ts.map} +0 -0
- /package/dist/chunks/{file-selection-WZX7HAY2.ts.map → file-selection-3JJYOVNR.ts.map} +0 -0
- /package/dist/chunks/{setup-switch-KTUWL6DE.ts.map → manager-result-dispatcher-AGBBVDDS.ts.map} +0 -0
- /package/dist/chunks/{sync-inspection-K45QVLHB.ts.map → merge-baseline-store-RFCAH7KL.ts.map} +0 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { lstatSync, readFileSync, rmdirSync, type Stats, stat } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import lockfile from "proper-lockfile";
|
|
6
|
+
import { withLock } from "../state/lock.js";
|
|
7
|
+
import { LOCKFILE_FS_ADAPTER } from "../state/lockfile-fs.js";
|
|
8
|
+
import {
|
|
9
|
+
continuousRoot,
|
|
10
|
+
prepareContinuousRoot,
|
|
11
|
+
readContinuousPrivate,
|
|
12
|
+
writeContinuousPrivate,
|
|
13
|
+
} from "./continuous-storage.js";
|
|
14
|
+
|
|
15
|
+
const STALE_MS = 30_000;
|
|
16
|
+
const localLeases = new Set<string>();
|
|
17
|
+
const futureGuards = new Map<string, { identity: string; since: number }>();
|
|
18
|
+
interface Owner {
|
|
19
|
+
version: 1;
|
|
20
|
+
id: string;
|
|
21
|
+
pid: number;
|
|
22
|
+
binding: string;
|
|
23
|
+
}
|
|
24
|
+
export interface ContinuousLease {
|
|
25
|
+
signal: AbortSignal;
|
|
26
|
+
assert(): void;
|
|
27
|
+
release(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
function validOwner(value: unknown): value is Owner {
|
|
30
|
+
if (!value || typeof value !== "object") return false;
|
|
31
|
+
const owner = value as Owner;
|
|
32
|
+
return (
|
|
33
|
+
Object.keys(owner).sort().join(",") === "binding,id,pid,version" &&
|
|
34
|
+
owner.version === 1 &&
|
|
35
|
+
typeof owner.id === "string" &&
|
|
36
|
+
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/u.test(owner.id) &&
|
|
37
|
+
Number.isSafeInteger(owner.pid) &&
|
|
38
|
+
owner.pid > 0 &&
|
|
39
|
+
owner.pid <= 2147483647 &&
|
|
40
|
+
typeof owner.binding === "string" &&
|
|
41
|
+
/^[a-f0-9]{64}$/u.test(owner.binding)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Advisory only: never authority for a transfer or lease takeover. */
|
|
46
|
+
export async function continuousLeaseOwner(binding: string): Promise<string> {
|
|
47
|
+
const owner = await readContinuousPrivate("owner.json");
|
|
48
|
+
if (owner === undefined) return "Local scheduler lease: not observed; waiting for an eligible session";
|
|
49
|
+
if (!validOwner(owner)) throw new Error("Invalid continuous sync owner");
|
|
50
|
+
try {
|
|
51
|
+
const guard = await fs.lstat(path.join(continuousRoot(), "scheduler.lock"));
|
|
52
|
+
if (
|
|
53
|
+
guard.isDirectory() &&
|
|
54
|
+
!guard.isSymbolicLink() &&
|
|
55
|
+
Date.now() - guard.mtimeMs <= STALE_MS &&
|
|
56
|
+
owner.binding === binding
|
|
57
|
+
)
|
|
58
|
+
return `Local scheduler lease: observed for pid ${owner.pid}; this can be another session`;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
61
|
+
}
|
|
62
|
+
return "Local scheduler lease: not observed; waiting for an eligible session";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Caller holds state-directory access during acquisition; the renewable guard prevents live migration. */
|
|
66
|
+
export async function acquireContinuousLease(binding: string): Promise<ContinuousLease | undefined> {
|
|
67
|
+
await prepareContinuousRoot();
|
|
68
|
+
const file = path.join(continuousRoot(), "scheduler");
|
|
69
|
+
const guardPath = `${file}.lock`;
|
|
70
|
+
if (localLeases.has(file)) return undefined;
|
|
71
|
+
const previousOwner = await readContinuousPrivate("owner.json");
|
|
72
|
+
if (previousOwner !== undefined && !validOwner(previousOwner))
|
|
73
|
+
throw new Error("Invalid continuous sync owner; evidence retained.");
|
|
74
|
+
if (localLeases.has(file)) return undefined;
|
|
75
|
+
localLeases.add(file);
|
|
76
|
+
const acquiredAt = performance.now();
|
|
77
|
+
const ownerPath = path.join(continuousRoot(), "owner.json");
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const owner: Owner = { version: 1, id: randomUUID(), pid: process.pid, binding };
|
|
80
|
+
let identity: { dev: number; ino: number } | undefined;
|
|
81
|
+
let published = false;
|
|
82
|
+
// proper-lockfile also uses rmdirSync at process exit. Fence both cleanup paths.
|
|
83
|
+
const removeGuard = (target: string) => {
|
|
84
|
+
if (identity) {
|
|
85
|
+
const current = lstatSync(target);
|
|
86
|
+
if (current.dev !== identity.dev || current.ino !== identity.ino) return;
|
|
87
|
+
}
|
|
88
|
+
let current: unknown;
|
|
89
|
+
try {
|
|
90
|
+
const record = lstatSync(ownerPath);
|
|
91
|
+
if (
|
|
92
|
+
!record.isFile() ||
|
|
93
|
+
record.isSymbolicLink() ||
|
|
94
|
+
record.nlink !== 1 ||
|
|
95
|
+
record.size > 4096 ||
|
|
96
|
+
(process.platform !== "win32" && record.mode & 0o077)
|
|
97
|
+
)
|
|
98
|
+
return;
|
|
99
|
+
current = JSON.parse(readFileSync(ownerPath, "utf8"));
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
102
|
+
}
|
|
103
|
+
if (published && current === undefined) return;
|
|
104
|
+
if (
|
|
105
|
+
current !== undefined &&
|
|
106
|
+
(!validOwner(current) ||
|
|
107
|
+
(current.id !== owner.id && (published || current.id !== (previousOwner as Owner | undefined)?.id)))
|
|
108
|
+
)
|
|
109
|
+
return;
|
|
110
|
+
rmdirSync(target);
|
|
111
|
+
};
|
|
112
|
+
const adapter = {
|
|
113
|
+
...LOCKFILE_FS_ADAPTER,
|
|
114
|
+
stat: (target: string, callback: (error: NodeJS.ErrnoException | null, value?: Stats) => void) =>
|
|
115
|
+
stat(target, (error, value) => {
|
|
116
|
+
if (!error && value.mtimeMs > Date.now() + STALE_MS) {
|
|
117
|
+
const identity = `${value.dev}:${value.ino}:${value.mtimeMs}`;
|
|
118
|
+
const previous = futureGuards.get(target);
|
|
119
|
+
if (!previous || previous.identity !== identity)
|
|
120
|
+
futureGuards.set(target, { identity, since: performance.now() });
|
|
121
|
+
else if (performance.now() - previous.since > STALE_MS) value.mtime = new Date(Date.now() - STALE_MS - 1);
|
|
122
|
+
} else futureGuards.delete(target);
|
|
123
|
+
callback(error, value);
|
|
124
|
+
}),
|
|
125
|
+
rmdirSync: removeGuard,
|
|
126
|
+
rmdir: (target: string, callback: (error?: NodeJS.ErrnoException | null) => void) => {
|
|
127
|
+
try {
|
|
128
|
+
removeGuard(target);
|
|
129
|
+
callback();
|
|
130
|
+
} catch (error) {
|
|
131
|
+
callback(error as NodeJS.ErrnoException);
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
let compromised = false;
|
|
136
|
+
let unlock: () => Promise<void>;
|
|
137
|
+
try {
|
|
138
|
+
unlock = await lockfile.lock(file, {
|
|
139
|
+
fs: adapter,
|
|
140
|
+
realpath: false,
|
|
141
|
+
stale: STALE_MS,
|
|
142
|
+
update: 10_000,
|
|
143
|
+
retries: 0,
|
|
144
|
+
onCompromised: () => {
|
|
145
|
+
compromised = true;
|
|
146
|
+
controller.abort(new DOMException("Continuous sync ownership lost", "AbortError"));
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
} catch (error) {
|
|
150
|
+
localLeases.delete(file);
|
|
151
|
+
if ((error as NodeJS.ErrnoException).code === "ELOCKED") return undefined;
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
let released = false;
|
|
155
|
+
const release = async () => {
|
|
156
|
+
if (released) return;
|
|
157
|
+
released = true;
|
|
158
|
+
controller.abort(new DOMException("Continuous sync owner released", "AbortError"));
|
|
159
|
+
try {
|
|
160
|
+
if (!compromised) await unlock();
|
|
161
|
+
} finally {
|
|
162
|
+
localLeases.delete(file);
|
|
163
|
+
futureGuards.delete(guardPath);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
try {
|
|
167
|
+
identity = await fs.lstat(guardPath);
|
|
168
|
+
await withLock("continuous-owner", async () => {
|
|
169
|
+
const previous = await readContinuousPrivate("owner.json");
|
|
170
|
+
if (previous !== undefined && !validOwner(previous))
|
|
171
|
+
throw new Error("Invalid continuous sync owner; evidence retained.");
|
|
172
|
+
if (
|
|
173
|
+
(previous as Owner | undefined)?.id !== (previousOwner as Owner | undefined)?.id ||
|
|
174
|
+
performance.now() - acquiredAt > STALE_MS
|
|
175
|
+
)
|
|
176
|
+
throw new Error("Continuous lease generation changed before publication.");
|
|
177
|
+
const current = lstatSync(guardPath);
|
|
178
|
+
if (controller.signal.aborted || current.dev !== identity?.dev || current.ino !== identity.ino)
|
|
179
|
+
throw new Error("Continuous lease changed before publication.");
|
|
180
|
+
await writeContinuousPrivate("owner.json", owner);
|
|
181
|
+
published = true;
|
|
182
|
+
});
|
|
183
|
+
const assert = () => {
|
|
184
|
+
controller.signal.throwIfAborted();
|
|
185
|
+
try {
|
|
186
|
+
const root = lstatSync(continuousRoot());
|
|
187
|
+
if (!root.isDirectory() || root.isSymbolicLink() || (process.platform !== "win32" && root.mode & 0o077))
|
|
188
|
+
throw new Error("Unsafe lease root");
|
|
189
|
+
const stat = lstatSync(guardPath);
|
|
190
|
+
if (
|
|
191
|
+
stat.isSymbolicLink() ||
|
|
192
|
+
!stat.isDirectory() ||
|
|
193
|
+
stat.dev !== identity?.dev ||
|
|
194
|
+
stat.ino !== identity.ino ||
|
|
195
|
+
Date.now() - stat.mtimeMs > STALE_MS
|
|
196
|
+
)
|
|
197
|
+
throw new Error("Lease guard changed");
|
|
198
|
+
const record = lstatSync(ownerPath);
|
|
199
|
+
if (
|
|
200
|
+
!record.isFile() ||
|
|
201
|
+
record.isSymbolicLink() ||
|
|
202
|
+
record.nlink !== 1 ||
|
|
203
|
+
record.size > 4096 ||
|
|
204
|
+
(process.platform !== "win32" && record.mode & 0o077)
|
|
205
|
+
)
|
|
206
|
+
throw new Error("Unsafe owner record");
|
|
207
|
+
const value: unknown = JSON.parse(readFileSync(ownerPath, "utf8"));
|
|
208
|
+
if (!validOwner(value) || value.id !== owner.id || value.binding !== binding || value.pid !== process.pid)
|
|
209
|
+
throw new Error("Lease owner changed");
|
|
210
|
+
} catch {
|
|
211
|
+
controller.abort(new DOMException("Continuous sync ownership lost", "AbortError"));
|
|
212
|
+
controller.signal.throwIfAborted();
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
assert();
|
|
216
|
+
return { signal: controller.signal, assert, release };
|
|
217
|
+
} catch (error) {
|
|
218
|
+
await release();
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
2
|
+
import { withLock } from "../state/lock.js";
|
|
3
|
+
import { withStateDirectoryAccess } from "../state/state-directory.js";
|
|
4
|
+
import { ContinuousError, type ContinuousErrorKind } from "./continuous-errors.js";
|
|
5
|
+
import { type ContinuousLease, continuousLeaseOwner } from "./continuous-lease.js";
|
|
6
|
+
import { continuousBinding, readContinuousPrivate, writeContinuousPrivate } from "./continuous-storage.js";
|
|
7
|
+
|
|
8
|
+
export const REQUEST_WINDOW_MS = 15 * 60_000;
|
|
9
|
+
export interface ContinuousRecord {
|
|
10
|
+
version: 1;
|
|
11
|
+
binding: string;
|
|
12
|
+
pending: boolean;
|
|
13
|
+
phase: "waiting" | "syncing" | "retry" | "blocked";
|
|
14
|
+
next: number;
|
|
15
|
+
failures: number;
|
|
16
|
+
evidence?: string;
|
|
17
|
+
recovery?: string;
|
|
18
|
+
notice?: string;
|
|
19
|
+
configuration?: string;
|
|
20
|
+
error?: ContinuousErrorKind;
|
|
21
|
+
checked?: number;
|
|
22
|
+
transferred?: number;
|
|
23
|
+
calls: { key: string; at: number }[];
|
|
24
|
+
attempts: Record<string, number>;
|
|
25
|
+
}
|
|
26
|
+
const digest = (value: unknown) => typeof value === "string" && /^[a-f0-9]{64}$/u.test(value);
|
|
27
|
+
const timestamp = (value: unknown) =>
|
|
28
|
+
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 8_640_000_000_000_000;
|
|
29
|
+
export function validateContinuousRecord(value: unknown, binding: string): asserts value is ContinuousRecord {
|
|
30
|
+
const record = value as ContinuousRecord;
|
|
31
|
+
if (
|
|
32
|
+
!record ||
|
|
33
|
+
typeof record !== "object" ||
|
|
34
|
+
record.version !== 1 ||
|
|
35
|
+
record.binding !== binding ||
|
|
36
|
+
Object.keys(record).some(
|
|
37
|
+
(key) =>
|
|
38
|
+
![
|
|
39
|
+
"version",
|
|
40
|
+
"binding",
|
|
41
|
+
"pending",
|
|
42
|
+
"phase",
|
|
43
|
+
"next",
|
|
44
|
+
"failures",
|
|
45
|
+
"evidence",
|
|
46
|
+
"recovery",
|
|
47
|
+
"notice",
|
|
48
|
+
"configuration",
|
|
49
|
+
"error",
|
|
50
|
+
"checked",
|
|
51
|
+
"transferred",
|
|
52
|
+
"calls",
|
|
53
|
+
"attempts",
|
|
54
|
+
].includes(key),
|
|
55
|
+
) ||
|
|
56
|
+
typeof record.pending !== "boolean" ||
|
|
57
|
+
!["waiting", "syncing", "retry", "blocked"].includes(record.phase) ||
|
|
58
|
+
!timestamp(record.next) ||
|
|
59
|
+
!timestamp(record.failures) ||
|
|
60
|
+
record.failures > 30 ||
|
|
61
|
+
(record.evidence !== undefined && !digest(record.evidence)) ||
|
|
62
|
+
(record.recovery !== undefined && !digest(record.recovery)) ||
|
|
63
|
+
(record.notice !== undefined && !digest(record.notice)) ||
|
|
64
|
+
(record.configuration !== undefined && !digest(record.configuration)) ||
|
|
65
|
+
(record.error !== undefined &&
|
|
66
|
+
![
|
|
67
|
+
"transient",
|
|
68
|
+
"contention",
|
|
69
|
+
"stale",
|
|
70
|
+
"authentication",
|
|
71
|
+
"storage",
|
|
72
|
+
"unsafe",
|
|
73
|
+
"model",
|
|
74
|
+
"budget",
|
|
75
|
+
"unknown",
|
|
76
|
+
].includes(record.error)) ||
|
|
77
|
+
(record.checked !== undefined && !timestamp(record.checked)) ||
|
|
78
|
+
(record.transferred !== undefined && !timestamp(record.transferred)) ||
|
|
79
|
+
!Array.isArray(record.calls) ||
|
|
80
|
+
record.calls.length > 2 ||
|
|
81
|
+
record.calls.some(
|
|
82
|
+
(call) => !call || Object.keys(call).sort().join(",") !== "at,key" || !digest(call.key) || !timestamp(call.at),
|
|
83
|
+
) ||
|
|
84
|
+
!record.attempts ||
|
|
85
|
+
typeof record.attempts !== "object" ||
|
|
86
|
+
Array.isArray(record.attempts) ||
|
|
87
|
+
Object.entries(record.attempts).some(
|
|
88
|
+
([key, count]) => !digest(key) || !Number.isInteger(count) || count < 1 || count > 2,
|
|
89
|
+
) ||
|
|
90
|
+
record.calls.some(
|
|
91
|
+
(call) => (record.attempts[call.key] ?? 0) < record.calls.filter((other) => other.key === call.key).length,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
throw new Error("Invalid continuous sync state; evidence retained.");
|
|
95
|
+
}
|
|
96
|
+
export async function readContinuousRecord(binding: string): Promise<ContinuousRecord> {
|
|
97
|
+
const value = await readContinuousPrivate(`${binding}.json`);
|
|
98
|
+
if (value === undefined)
|
|
99
|
+
return { version: 1, binding, pending: true, phase: "waiting", next: 0, failures: 0, calls: [], attempts: {} };
|
|
100
|
+
validateContinuousRecord(value, binding);
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
export async function updateContinuousRecord<T>(
|
|
104
|
+
binding: string,
|
|
105
|
+
lease: ContinuousLease,
|
|
106
|
+
update: (record: ContinuousRecord) => T,
|
|
107
|
+
): Promise<T> {
|
|
108
|
+
return withStateDirectoryAccess(() =>
|
|
109
|
+
withLock("continuous-state", async () => {
|
|
110
|
+
lease.assert();
|
|
111
|
+
const record = await readContinuousRecord(binding);
|
|
112
|
+
lease.assert();
|
|
113
|
+
const result = update(record);
|
|
114
|
+
validateContinuousRecord(record, binding);
|
|
115
|
+
lease.assert();
|
|
116
|
+
await writeContinuousPrivate(`${binding}.json`, record);
|
|
117
|
+
lease.assert();
|
|
118
|
+
return result;
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
export function normalizeContinuousClock(record: ContinuousRecord, now: number) {
|
|
123
|
+
// A backward wall-clock jump cannot grant more calls: retain them for a full window.
|
|
124
|
+
record.calls = record.calls
|
|
125
|
+
.map((call) => ({ ...call, at: Math.min(call.at, now) }))
|
|
126
|
+
.filter((call) => now - call.at < REQUEST_WINDOW_MS);
|
|
127
|
+
record.next = Math.min(record.next, now + (record.error === "budget" ? REQUEST_WINDOW_MS : 300_000));
|
|
128
|
+
}
|
|
129
|
+
export async function reserveContinuousRequest(
|
|
130
|
+
binding: string,
|
|
131
|
+
lease: ContinuousLease,
|
|
132
|
+
key: string,
|
|
133
|
+
clock: () => number = Date.now,
|
|
134
|
+
) {
|
|
135
|
+
if (!digest(key)) throw new Error("Invalid continuous candidate key.");
|
|
136
|
+
let failure: ContinuousError | undefined;
|
|
137
|
+
await updateContinuousRecord(binding, lease, (record) => {
|
|
138
|
+
const now = clock();
|
|
139
|
+
normalizeContinuousClock(record, now);
|
|
140
|
+
if ((record.attempts[key] ?? 0) >= 2)
|
|
141
|
+
failure = new ContinuousError(
|
|
142
|
+
"model",
|
|
143
|
+
"Automatic request limit reached for this evidence. Use Sync now for an explicit retry.",
|
|
144
|
+
);
|
|
145
|
+
else if (record.calls.length >= 2)
|
|
146
|
+
failure = new ContinuousError(
|
|
147
|
+
"budget",
|
|
148
|
+
"Automatic request window is full.",
|
|
149
|
+
Math.max(0, (record.calls[0]?.at ?? now) + REQUEST_WINDOW_MS - now),
|
|
150
|
+
);
|
|
151
|
+
else {
|
|
152
|
+
record.attempts[key] = (record.attempts[key] ?? 0) + 1;
|
|
153
|
+
record.calls.push({ key, at: now });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
if (failure) throw failure;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Read-only status: no directory creation, lease, mutation, request reservation or provider call. */
|
|
160
|
+
export async function continuousStatusLines(config: AnySyncConfig): Promise<string[]> {
|
|
161
|
+
if (!config.continuous) return ["Continuous sync: Off"];
|
|
162
|
+
if (!config.agentMode) return ["Continuous sync: paused; enable Agent mode first"];
|
|
163
|
+
try {
|
|
164
|
+
const record = await readContinuousRecord(await continuousBinding(config));
|
|
165
|
+
return [
|
|
166
|
+
`Continuous sync: ${record.phase}${record.error ? ` (${record.error})` : ""}`,
|
|
167
|
+
await continuousLeaseOwner(record.binding),
|
|
168
|
+
`Last successful background check: ${record.checked ? new Date(record.checked).toISOString() : "not yet checked"}`,
|
|
169
|
+
`Last successful background transfer: ${record.transferred ? new Date(record.transferred).toISOString() : "none"}`,
|
|
170
|
+
...(record.phase === "retry" ? [`Next background attempt: ${new Date(record.next).toISOString()}`] : []),
|
|
171
|
+
...(record.phase === "blocked"
|
|
172
|
+
? ["Background sync needs changed evidence or repair. Review /sync, or use Sync now for an explicit retry."]
|
|
173
|
+
: []),
|
|
174
|
+
"Monitoring requires an eligible open Pi session. Status does not spend model requests or clear retry limits.",
|
|
175
|
+
];
|
|
176
|
+
} catch {
|
|
177
|
+
return ["Continuous sync: private state unavailable; retain evidence and repair access before retrying."];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { backendIdentityCoordinates } from "../backends/backend-identity.js";
|
|
6
|
+
import type { AnySyncConfig } from "../settings/settings-types.js";
|
|
7
|
+
import { writeJson } from "../state/json-file.js";
|
|
8
|
+
import { stateDir } from "../state/state-directory.js";
|
|
9
|
+
import { ensureStateDir } from "../state/sync-state-store.js";
|
|
10
|
+
|
|
11
|
+
export function hashContinuous(value: unknown) {
|
|
12
|
+
return createHash("sha256")
|
|
13
|
+
.update(JSON.stringify(value) ?? "null")
|
|
14
|
+
.digest("hex");
|
|
15
|
+
}
|
|
16
|
+
export async function continuousBinding(config: AnySyncConfig) {
|
|
17
|
+
return hashContinuous([await fs.realpath(getAgentDir()), config.setupName, backendIdentityCoordinates(config)]);
|
|
18
|
+
}
|
|
19
|
+
export function continuousRoot() {
|
|
20
|
+
return path.join(stateDir(), "continuous");
|
|
21
|
+
}
|
|
22
|
+
export async function prepareContinuousRoot() {
|
|
23
|
+
await ensureStateDir();
|
|
24
|
+
await fs.mkdir(continuousRoot(), { recursive: true, mode: 0o700 });
|
|
25
|
+
await checkContinuousRoot();
|
|
26
|
+
}
|
|
27
|
+
export async function checkContinuousRoot() {
|
|
28
|
+
const stat = await fs.lstat(continuousRoot());
|
|
29
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (process.platform !== "win32" && stat.mode & 0o077))
|
|
30
|
+
throw new Error("Unsafe continuous sync directory; evidence retained.");
|
|
31
|
+
}
|
|
32
|
+
export async function readContinuousPrivate(name: string): Promise<unknown> {
|
|
33
|
+
try {
|
|
34
|
+
await checkContinuousRoot();
|
|
35
|
+
const file = path.join(continuousRoot(), name);
|
|
36
|
+
const stat = await fs.lstat(file);
|
|
37
|
+
if (
|
|
38
|
+
!stat.isFile() ||
|
|
39
|
+
stat.isSymbolicLink() ||
|
|
40
|
+
stat.nlink !== 1 ||
|
|
41
|
+
stat.size > 131072 ||
|
|
42
|
+
(process.platform !== "win32" && stat.mode & 0o077)
|
|
43
|
+
)
|
|
44
|
+
throw new Error("Unsafe continuous sync record; evidence retained.");
|
|
45
|
+
return JSON.parse(await fs.readFile(file, "utf8"));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export async function writeContinuousPrivate(name: string, value: unknown) {
|
|
52
|
+
await checkContinuousRoot();
|
|
53
|
+
await readContinuousPrivate(name); // Never replace unsafe or malformed evidence.
|
|
54
|
+
await writeJson(path.join(continuousRoot(), name), value, { maxBytes: 131072 });
|
|
55
|
+
}
|