@davideasden/pi-undo 0.1.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/LICENSE +21 -0
- package/README.md +84 -0
- package/extensions/pi-undo.ts +133 -0
- package/package.json +54 -0
- package/src/atomic-fs.ts +156 -0
- package/src/controller.ts +598 -0
- package/src/encoding.ts +620 -0
- package/src/git-runner.ts +308 -0
- package/src/journal.ts +297 -0
- package/src/model.ts +160 -0
- package/src/mutation-journal.ts +229 -0
- package/src/path-safety.ts +121 -0
- package/src/pi-runtime.ts +415 -0
- package/src/quarantine.ts +591 -0
- package/src/recovery.ts +143 -0
- package/src/restore-engine.ts +1184 -0
- package/src/root-discovery.ts +388 -0
- package/src/session-state.ts +448 -0
- package/src/snapshot-store.ts +1279 -0
- package/src/status-reporter.ts +80 -0
- package/src/workspace-lock.ts +407 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { OperationResult } from "./controller.ts";
|
|
2
|
+
|
|
3
|
+
export type RefillResult = "written" | "skipped" | "requested" | "unsupported";
|
|
4
|
+
|
|
5
|
+
export interface StatusContext {
|
|
6
|
+
readonly mode: "tui" | "rpc" | "print" | "json";
|
|
7
|
+
readonly ui: {
|
|
8
|
+
setStatus(key: string, text: string | undefined): void;
|
|
9
|
+
notify(message: string, type?: "info" | "warning" | "error" | string): void;
|
|
10
|
+
getEditorText(): string;
|
|
11
|
+
setEditorText(text: string): void;
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 统一管理 footer、通知与 prompt 回填,避免状态文本泄露运行时细节。 */
|
|
16
|
+
export class StatusReporter {
|
|
17
|
+
private readonly context: StatusContext;
|
|
18
|
+
|
|
19
|
+
constructor(context: StatusContext) {
|
|
20
|
+
this.context = context;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
setReady(undoCount: number, redoCount: number): void {
|
|
24
|
+
this.setStatus(`ready undo:${undoCount} redo:${redoCount}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
setPhase(text: string): void {
|
|
28
|
+
this.setStatus(sanitize(text));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
setRecoveryRequired(
|
|
32
|
+
reason: string,
|
|
33
|
+
details?: { readonly files?: number; readonly opId?: string },
|
|
34
|
+
): void {
|
|
35
|
+
if (details?.files !== undefined && details.opId !== undefined) {
|
|
36
|
+
this.setStatus(`recovery_required files:${details.files} op:${sanitize(details.opId)}`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
this.setStatus(`recovery required: ${sanitize(reason)}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
clear(): void {
|
|
43
|
+
this.context.ui.setStatus("pi-undo", undefined);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
result(result: OperationResult): void {
|
|
47
|
+
const details = result.message === undefined ? "" : ` ${sanitize(result.message)}`;
|
|
48
|
+
const message = sanitize(`${result.code} files:${result.changedFiles}${details}`);
|
|
49
|
+
const type = result.code === "ok" || result.code === "noop"
|
|
50
|
+
? "info"
|
|
51
|
+
: result.code === "recovery_required" ? "error" : "warning";
|
|
52
|
+
this.context.ui.notify(message, type);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
refillPrompt(text: string): RefillResult {
|
|
56
|
+
if (this.context.mode === "print" || this.context.mode === "json") return "unsupported";
|
|
57
|
+
if (this.context.mode === "rpc") {
|
|
58
|
+
this.context.ui.setEditorText(text);
|
|
59
|
+
return "requested";
|
|
60
|
+
}
|
|
61
|
+
if (this.context.ui.getEditorText().length > 0) return "skipped";
|
|
62
|
+
this.context.ui.setEditorText(text);
|
|
63
|
+
return this.context.ui.getEditorText() === text ? "written" : "skipped";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private setStatus(text: string): void {
|
|
67
|
+
this.context.ui.setStatus("pi-undo", sanitize(text));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sanitize(value: string): string {
|
|
72
|
+
return value
|
|
73
|
+
.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, "")
|
|
74
|
+
.replace(/\b(?:Bearer\s+\S+|sk-[A-Za-z0-9_-]{8,})/gi, "<redacted>")
|
|
75
|
+
.replace(/(?:\/[A-Za-z0-9._-]+){2,}/g, "<path>")
|
|
76
|
+
.replace(/[\u0000-\u001F\u007F]+/g, " ")
|
|
77
|
+
.replace(/\s+/g, " ")
|
|
78
|
+
.trim()
|
|
79
|
+
.slice(0, 120);
|
|
80
|
+
}
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const LEGACY_OWNER_FILE = "owner.json";
|
|
7
|
+
const OWNER_PREFIX = "owner.";
|
|
8
|
+
const OWNER_SUFFIX = ".json";
|
|
9
|
+
const DEFAULT_LEASE_MS = 30_000;
|
|
10
|
+
const DEFAULT_RETRY_MS = 50;
|
|
11
|
+
const DEFAULT_ACQUIRE_TIMEOUT_MS = 30_000;
|
|
12
|
+
const inProcessQueues = new Map<string, Promise<void>>();
|
|
13
|
+
|
|
14
|
+
interface LockOwner {
|
|
15
|
+
readonly pid: number;
|
|
16
|
+
readonly processStartedAt: number;
|
|
17
|
+
readonly workspaceIdentity: string;
|
|
18
|
+
readonly nonce: string;
|
|
19
|
+
readonly leaseExpiresAt: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ValidOwnerState {
|
|
23
|
+
readonly kind: "valid";
|
|
24
|
+
readonly fileName: string;
|
|
25
|
+
readonly owner: LockOwner;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type OwnerState =
|
|
29
|
+
| ValidOwnerState
|
|
30
|
+
| { readonly kind: "missing" }
|
|
31
|
+
| { readonly kind: "invalid" };
|
|
32
|
+
|
|
33
|
+
export interface WorkspaceLockOptions {
|
|
34
|
+
readonly lockRoot?: string;
|
|
35
|
+
readonly leaseMs?: number;
|
|
36
|
+
readonly retryMs?: number;
|
|
37
|
+
readonly acquireTimeoutMs?: number;
|
|
38
|
+
readonly clock?: () => number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type WorkspaceLockErrorCode = "lock_timeout" | "lock_compromised";
|
|
42
|
+
|
|
43
|
+
export class WorkspaceLockError extends Error {
|
|
44
|
+
readonly code: WorkspaceLockErrorCode;
|
|
45
|
+
|
|
46
|
+
constructor(code: WorkspaceLockErrorCode, message: string, options?: ErrorOptions) {
|
|
47
|
+
super(message, options);
|
|
48
|
+
this.name = "WorkspaceLockError";
|
|
49
|
+
this.code = code;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface WorkspaceLock {
|
|
54
|
+
withLock<T>(workspaceIdentity: string, fn: () => Promise<T>): Promise<T>;
|
|
55
|
+
acquire(workspaceIdentity: string): Promise<WorkspaceLockLease>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface WorkspaceLockLease {
|
|
59
|
+
release(): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class WorkspaceLock {
|
|
63
|
+
private readonly lockRoot: string;
|
|
64
|
+
private readonly leaseMs: number;
|
|
65
|
+
private readonly retryMs: number;
|
|
66
|
+
private readonly acquireTimeoutMs: number;
|
|
67
|
+
private readonly clock: () => number;
|
|
68
|
+
|
|
69
|
+
constructor(options: WorkspaceLockOptions = {}) {
|
|
70
|
+
this.lockRoot = options.lockRoot ?? join(tmpdir(), "pi-undo-workspace-locks");
|
|
71
|
+
this.leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;
|
|
72
|
+
this.retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
|
|
73
|
+
this.acquireTimeoutMs = options.acquireTimeoutMs ?? DEFAULT_ACQUIRE_TIMEOUT_MS;
|
|
74
|
+
this.clock = options.clock ?? Date.now;
|
|
75
|
+
assertPositive(this.leaseMs, "leaseMs");
|
|
76
|
+
assertPositive(this.retryMs, "retryMs");
|
|
77
|
+
assertPositive(this.acquireTimeoutMs, "acquireTimeoutMs");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async withLock<T>(workspaceIdentity: string, fn: () => Promise<T>): Promise<T> {
|
|
81
|
+
if (workspaceIdentity.length === 0) {
|
|
82
|
+
throw new WorkspaceLockError("lock_compromised", "workspace identity 不能为空");
|
|
83
|
+
}
|
|
84
|
+
return enqueue(workspaceIdentity, async () => {
|
|
85
|
+
const lease = await this.acquire(workspaceIdentity);
|
|
86
|
+
try {
|
|
87
|
+
return await fn();
|
|
88
|
+
} finally {
|
|
89
|
+
await lease.release();
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async acquire(workspaceIdentity: string): Promise<WorkspaceLockLease> {
|
|
95
|
+
if (workspaceIdentity.length === 0) {
|
|
96
|
+
throw new WorkspaceLockError("lock_compromised", "workspace identity 不能为空");
|
|
97
|
+
}
|
|
98
|
+
await mkdir(this.lockRoot, { recursive: true });
|
|
99
|
+
const lockDirectory = workspaceLockPath(this.lockRoot, workspaceIdentity);
|
|
100
|
+
const deadline = this.clock() + this.acquireTimeoutMs;
|
|
101
|
+
|
|
102
|
+
while (true) {
|
|
103
|
+
const owner: LockOwner = {
|
|
104
|
+
pid: process.pid,
|
|
105
|
+
processStartedAt: currentProcessStartedAt(this.clock()),
|
|
106
|
+
workspaceIdentity,
|
|
107
|
+
nonce: randomBytes(16).toString("hex"),
|
|
108
|
+
leaseExpiresAt: this.clock() + this.leaseMs,
|
|
109
|
+
};
|
|
110
|
+
const candidateDirectory = `${lockDirectory}.candidate.${process.pid}.${owner.nonce}`;
|
|
111
|
+
const ownerFile = ownerFileName(owner.nonce);
|
|
112
|
+
let published = false;
|
|
113
|
+
try {
|
|
114
|
+
await mkdir(candidateDirectory);
|
|
115
|
+
await writeOwnerInitial(candidateDirectory, ownerFile, owner);
|
|
116
|
+
await rename(candidateDirectory, lockDirectory);
|
|
117
|
+
published = true;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (!await pathExists(lockDirectory)) {
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
} finally {
|
|
123
|
+
if (!published) {
|
|
124
|
+
await rm(candidateDirectory, { recursive: true, force: true }).catch(() => {});
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (published) {
|
|
129
|
+
return this.startLease(lockDirectory, ownerFile, owner);
|
|
130
|
+
}
|
|
131
|
+
if (await this.reclaimStaleLease(lockDirectory, workspaceIdentity)) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (this.clock() >= deadline) {
|
|
135
|
+
throw new WorkspaceLockError("lock_timeout", "workspace lock 获取超时");
|
|
136
|
+
}
|
|
137
|
+
await delay(this.retryMs);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private startLease(
|
|
142
|
+
lockDirectory: string,
|
|
143
|
+
ownerFile: string,
|
|
144
|
+
owner: LockOwner,
|
|
145
|
+
): { release(): Promise<void> } {
|
|
146
|
+
let renewal = Promise.resolve();
|
|
147
|
+
let compromised: WorkspaceLockError | undefined;
|
|
148
|
+
const heartbeat = setInterval(() => {
|
|
149
|
+
renewal = renewal.then(async () => {
|
|
150
|
+
if (compromised !== undefined) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
await this.renew(lockDirectory, ownerFile, owner);
|
|
155
|
+
} catch (error) {
|
|
156
|
+
compromised = asCompromised(error);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
}, Math.max(10, Math.floor(this.leaseMs / 3)));
|
|
160
|
+
heartbeat.unref();
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
release: async () => {
|
|
164
|
+
clearInterval(heartbeat);
|
|
165
|
+
await renewal;
|
|
166
|
+
if (compromised !== undefined) {
|
|
167
|
+
throw compromised;
|
|
168
|
+
}
|
|
169
|
+
await releaseOwner(lockDirectory, ownerFile, owner.nonce);
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private async renew(lockDirectory: string, ownerFile: string, owner: LockOwner): Promise<void> {
|
|
175
|
+
const current = await readOwnerState(lockDirectory);
|
|
176
|
+
if (!sameOwner(current, ownerFile, owner.nonce)) {
|
|
177
|
+
throw new WorkspaceLockError("lock_compromised", "workspace lock owner 已变化");
|
|
178
|
+
}
|
|
179
|
+
const temporaryFile = `.renew.${owner.nonce}.${randomBytes(8).toString("hex")}`;
|
|
180
|
+
const temporaryPath = join(lockDirectory, temporaryFile);
|
|
181
|
+
try {
|
|
182
|
+
await writeFile(
|
|
183
|
+
temporaryPath,
|
|
184
|
+
serializeOwner({ ...owner, leaseExpiresAt: this.clock() + this.leaseMs }),
|
|
185
|
+
{ flag: "wx", mode: 0o600 },
|
|
186
|
+
);
|
|
187
|
+
const verified = await readOwnerState(lockDirectory, new Set([temporaryFile]));
|
|
188
|
+
if (!sameOwner(verified, ownerFile, owner.nonce)) {
|
|
189
|
+
throw new WorkspaceLockError("lock_compromised", "workspace lock owner 续租校验失败");
|
|
190
|
+
}
|
|
191
|
+
await rename(temporaryPath, join(lockDirectory, ownerFile));
|
|
192
|
+
} finally {
|
|
193
|
+
await rm(temporaryPath, { force: true }).catch(() => {});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private async reclaimStaleLease(lockDirectory: string, workspaceIdentity: string): Promise<boolean> {
|
|
198
|
+
const state = await readOwnerState(lockDirectory);
|
|
199
|
+
if (state.kind === "invalid") {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
if (state.kind === "missing") {
|
|
203
|
+
const metadata = await stat(lockDirectory).catch(() => null);
|
|
204
|
+
if (metadata === null) {
|
|
205
|
+
return true;
|
|
206
|
+
}
|
|
207
|
+
if (this.clock() - metadata.mtimeMs <= this.leaseMs) {
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
return removeEmptyDirectory(lockDirectory);
|
|
211
|
+
}
|
|
212
|
+
if (
|
|
213
|
+
state.owner.workspaceIdentity !== workspaceIdentity ||
|
|
214
|
+
state.owner.leaseExpiresAt > this.clock() ||
|
|
215
|
+
!ownerProcessIsConfirmedDead(state.owner)
|
|
216
|
+
) {
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const verified = await readOwnerState(lockDirectory);
|
|
221
|
+
if (!sameOwner(verified, state.fileName, state.owner.nonce)) {
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
await unlink(join(lockDirectory, state.fileName));
|
|
226
|
+
} catch (error) {
|
|
227
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
return removeEmptyDirectory(lockDirectory);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function workspaceLockPath(lockRoot: string, workspaceIdentity: string): string {
|
|
237
|
+
const digest = createHash("sha256").update(workspaceIdentity).digest("hex");
|
|
238
|
+
return join(lockRoot, `${digest}.lock`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function enqueue<T>(workspaceIdentity: string, fn: () => Promise<T>): Promise<T> {
|
|
242
|
+
const previous = inProcessQueues.get(workspaceIdentity) ?? Promise.resolve();
|
|
243
|
+
let resolveCurrent: (() => void) | undefined;
|
|
244
|
+
const current = new Promise<void>((resolve) => {
|
|
245
|
+
resolveCurrent = resolve;
|
|
246
|
+
});
|
|
247
|
+
inProcessQueues.set(workspaceIdentity, current);
|
|
248
|
+
await previous;
|
|
249
|
+
try {
|
|
250
|
+
return await fn();
|
|
251
|
+
} finally {
|
|
252
|
+
resolveCurrent?.();
|
|
253
|
+
if (inProcessQueues.get(workspaceIdentity) === current) {
|
|
254
|
+
inProcessQueues.delete(workspaceIdentity);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function readOwnerState(lockDirectory: string, ignoredFiles = new Set<string>()): Promise<OwnerState> {
|
|
260
|
+
let entries;
|
|
261
|
+
try {
|
|
262
|
+
entries = await readdir(lockDirectory, { withFileTypes: true });
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
265
|
+
return { kind: "missing" };
|
|
266
|
+
}
|
|
267
|
+
return { kind: "invalid" };
|
|
268
|
+
}
|
|
269
|
+
const visible = entries.filter((entry) => !ignoredFiles.has(entry.name));
|
|
270
|
+
if (visible.length === 0) {
|
|
271
|
+
return { kind: "missing" };
|
|
272
|
+
}
|
|
273
|
+
if (visible.length !== 1 || !visible[0].isFile() || !isOwnerFileName(visible[0].name)) {
|
|
274
|
+
return { kind: "invalid" };
|
|
275
|
+
}
|
|
276
|
+
const fileName = visible[0].name;
|
|
277
|
+
try {
|
|
278
|
+
const value: unknown = JSON.parse(await readFile(join(lockDirectory, fileName), "utf8"));
|
|
279
|
+
if (!isLockOwner(value) || (fileName !== LEGACY_OWNER_FILE && fileName !== ownerFileName(value.nonce))) {
|
|
280
|
+
return { kind: "invalid" };
|
|
281
|
+
}
|
|
282
|
+
return { kind: "valid", fileName, owner: value };
|
|
283
|
+
} catch {
|
|
284
|
+
return { kind: "invalid" };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function writeOwnerInitial(lockDirectory: string, fileName: string, owner: LockOwner): Promise<void> {
|
|
289
|
+
await writeFile(join(lockDirectory, fileName), serializeOwner(owner), { flag: "wx", mode: 0o600 });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function releaseOwner(lockDirectory: string, ownerFile: string, nonce: string): Promise<void> {
|
|
293
|
+
const current = await readOwnerState(lockDirectory);
|
|
294
|
+
if (!sameOwner(current, ownerFile, nonce)) {
|
|
295
|
+
throw new WorkspaceLockError("lock_compromised", "workspace lock release 时 owner 已变化");
|
|
296
|
+
}
|
|
297
|
+
await unlink(join(lockDirectory, ownerFile));
|
|
298
|
+
if (!await removeEmptyDirectory(lockDirectory)) {
|
|
299
|
+
const replacement = await readOwnerState(lockDirectory);
|
|
300
|
+
if (replacement.kind === "valid" && replacement.owner.nonce !== nonce) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
throw new WorkspaceLockError("lock_compromised", "workspace lock release 时目录已被复用");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function removeEmptyDirectory(directory: string): Promise<boolean> {
|
|
308
|
+
try {
|
|
309
|
+
await rmdir(directory);
|
|
310
|
+
return true;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
if (hasErrorCode(error, "ENOTEMPTY") || hasErrorCode(error, "EEXIST")) {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
323
|
+
try {
|
|
324
|
+
await stat(path);
|
|
325
|
+
return true;
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (hasErrorCode(error, "ENOENT")) {
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function sameOwner(state: OwnerState, fileName: string, nonce: string): state is ValidOwnerState {
|
|
335
|
+
return state.kind === "valid" && state.fileName === fileName && state.owner.nonce === nonce;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function isOwnerFileName(fileName: string): boolean {
|
|
339
|
+
return fileName === LEGACY_OWNER_FILE || (
|
|
340
|
+
fileName.startsWith(OWNER_PREFIX) &&
|
|
341
|
+
fileName.endsWith(OWNER_SUFFIX) &&
|
|
342
|
+
/^[0-9a-f]{32}$/.test(fileName.slice(OWNER_PREFIX.length, -OWNER_SUFFIX.length))
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function ownerFileName(nonce: string): string {
|
|
347
|
+
return `${OWNER_PREFIX}${nonce}${OWNER_SUFFIX}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function serializeOwner(owner: LockOwner): string {
|
|
351
|
+
return `${JSON.stringify(owner)}\n`;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function isLockOwner(value: unknown): value is LockOwner {
|
|
355
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
const record = value as Record<string, unknown>;
|
|
359
|
+
return (
|
|
360
|
+
typeof record.pid === "number" &&
|
|
361
|
+
Number.isInteger(record.pid) &&
|
|
362
|
+
typeof record.processStartedAt === "number" &&
|
|
363
|
+
Number.isFinite(record.processStartedAt) &&
|
|
364
|
+
typeof record.workspaceIdentity === "string" &&
|
|
365
|
+
record.workspaceIdentity.length > 0 &&
|
|
366
|
+
typeof record.nonce === "string" &&
|
|
367
|
+
record.nonce.length > 0 &&
|
|
368
|
+
typeof record.leaseExpiresAt === "number" &&
|
|
369
|
+
Number.isFinite(record.leaseExpiresAt)
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function currentProcessStartedAt(now: number): number {
|
|
374
|
+
return Math.floor(now - process.uptime() * 1_000);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function ownerProcessIsConfirmedDead(owner: LockOwner): boolean {
|
|
378
|
+
if (owner.pid <= 0 || !Number.isFinite(owner.processStartedAt)) {
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
try {
|
|
382
|
+
process.kill(owner.pid, 0);
|
|
383
|
+
return false;
|
|
384
|
+
} catch (error) {
|
|
385
|
+
return hasErrorCode(error, "ESRCH");
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function asCompromised(error: unknown): WorkspaceLockError {
|
|
390
|
+
return error instanceof WorkspaceLockError
|
|
391
|
+
? error
|
|
392
|
+
: new WorkspaceLockError("lock_compromised", "workspace lock 续租失败", { cause: error });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function delay(milliseconds: number): Promise<void> {
|
|
396
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function assertPositive(value: number, name: string): void {
|
|
400
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
401
|
+
throw new RangeError(`${name} 必须是正数`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
|
406
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
407
|
+
}
|