@vellumai/cli 0.10.12-dev.202607250140.9979030 → 0.10.12-staging.2
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/node_modules/@vellumai/service-contracts/package.json +0 -1
- package/package.json +1 -1
- package/src/lib/hatch-local.ts +86 -98
- package/src/lib/local.ts +2 -20
- package/node_modules/@vellumai/service-contracts/src/__tests__/secret-detection.test.ts +0 -449
- package/node_modules/@vellumai/service-contracts/src/secret-detection.ts +0 -468
- package/src/__tests__/local-hatch-lock.test.ts +0 -163
- package/src/lib/local-hatch-lock.ts +0 -322
|
@@ -1,322 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from "child_process";
|
|
2
|
-
import { createHash, randomUUID } from "crypto";
|
|
3
|
-
import {
|
|
4
|
-
closeSync,
|
|
5
|
-
mkdirSync,
|
|
6
|
-
openSync,
|
|
7
|
-
readFileSync,
|
|
8
|
-
statSync,
|
|
9
|
-
unlinkSync,
|
|
10
|
-
utimesSync,
|
|
11
|
-
writeFileSync,
|
|
12
|
-
} from "fs";
|
|
13
|
-
import { hostname, userInfo } from "os";
|
|
14
|
-
import { dirname, join } from "path";
|
|
15
|
-
|
|
16
|
-
const DEFAULT_TIMEOUT_MS = 240_000;
|
|
17
|
-
const DEFAULT_RETRY_MS = 100;
|
|
18
|
-
const MAX_LOCK_AGE_MS = DEFAULT_TIMEOUT_MS - 60_000;
|
|
19
|
-
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
20
|
-
const LIVE_OWNER_RECOVERY_GRACE_MS = 10_000;
|
|
21
|
-
|
|
22
|
-
interface LocalHatchLockOptions {
|
|
23
|
-
lockPath?: string;
|
|
24
|
-
liveOwnerRecoveryGraceMs?: number;
|
|
25
|
-
timeoutMs?: number;
|
|
26
|
-
retryMs?: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface LockOwner {
|
|
30
|
-
pid: number;
|
|
31
|
-
processStartedAt?: string;
|
|
32
|
-
token: string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
interface LockSnapshot {
|
|
36
|
-
owner?: LockOwner;
|
|
37
|
-
signature: string;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function resolveLocalHatchLockPath(): string {
|
|
41
|
-
const machineKey = createHash("sha256")
|
|
42
|
-
.update(hostname())
|
|
43
|
-
.digest("hex")
|
|
44
|
-
.slice(0, 12);
|
|
45
|
-
return join(
|
|
46
|
-
userInfo().homedir,
|
|
47
|
-
".cache",
|
|
48
|
-
"vellum",
|
|
49
|
-
"runtime",
|
|
50
|
-
machineKey,
|
|
51
|
-
"local-hatch.lock",
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function processIsAlive(pid: number): boolean {
|
|
56
|
-
try {
|
|
57
|
-
process.kill(pid, 0);
|
|
58
|
-
return true;
|
|
59
|
-
} catch (error) {
|
|
60
|
-
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function processStartedAt(pid: number): string | undefined {
|
|
65
|
-
try {
|
|
66
|
-
const value = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
67
|
-
encoding: "utf-8",
|
|
68
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
69
|
-
timeout: 1_000,
|
|
70
|
-
}).trim();
|
|
71
|
-
return value || undefined;
|
|
72
|
-
} catch {
|
|
73
|
-
return undefined;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function parseOwner(raw: string): LockOwner | undefined {
|
|
78
|
-
try {
|
|
79
|
-
const parsed = JSON.parse(raw) as {
|
|
80
|
-
pid?: unknown;
|
|
81
|
-
processStartedAt?: unknown;
|
|
82
|
-
token?: unknown;
|
|
83
|
-
};
|
|
84
|
-
if (
|
|
85
|
-
typeof parsed.pid === "number" &&
|
|
86
|
-
Number.isInteger(parsed.pid) &&
|
|
87
|
-
parsed.pid > 0 &&
|
|
88
|
-
typeof parsed.token === "string" &&
|
|
89
|
-
parsed.token.length > 0
|
|
90
|
-
) {
|
|
91
|
-
return {
|
|
92
|
-
pid: parsed.pid,
|
|
93
|
-
processStartedAt:
|
|
94
|
-
typeof parsed.processStartedAt === "string" &&
|
|
95
|
-
parsed.processStartedAt.length > 0
|
|
96
|
-
? parsed.processStartedAt
|
|
97
|
-
: undefined,
|
|
98
|
-
token: parsed.token,
|
|
99
|
-
};
|
|
100
|
-
}
|
|
101
|
-
} catch {}
|
|
102
|
-
return undefined;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function readSnapshot(path: string): LockSnapshot | undefined {
|
|
106
|
-
try {
|
|
107
|
-
const raw = readFileSync(path, "utf-8");
|
|
108
|
-
return {
|
|
109
|
-
owner: parseOwner(raw),
|
|
110
|
-
signature: createHash("sha256").update(raw).digest("hex"),
|
|
111
|
-
};
|
|
112
|
-
} catch {
|
|
113
|
-
return undefined;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function readOwner(path: string): LockOwner | undefined {
|
|
118
|
-
return readSnapshot(path)?.owner;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
function fileAgeMs(path: string): number | undefined {
|
|
122
|
-
try {
|
|
123
|
-
return Date.now() - statSync(path).mtimeMs;
|
|
124
|
-
} catch {
|
|
125
|
-
return undefined;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const expiredLiveOwners = new Map<string, number>();
|
|
130
|
-
|
|
131
|
-
function ownerIsActive(
|
|
132
|
-
path: string,
|
|
133
|
-
owner: LockOwner | undefined,
|
|
134
|
-
liveOwnerRecoveryGraceMs: number,
|
|
135
|
-
): boolean {
|
|
136
|
-
const ageMs = fileAgeMs(path);
|
|
137
|
-
if (!owner) {
|
|
138
|
-
return ageMs !== undefined && ageMs < MAX_LOCK_AGE_MS;
|
|
139
|
-
}
|
|
140
|
-
if (!processIsAlive(owner.pid)) {
|
|
141
|
-
return false;
|
|
142
|
-
}
|
|
143
|
-
if (owner.processStartedAt) {
|
|
144
|
-
const currentStartedAt = processStartedAt(owner.pid);
|
|
145
|
-
if (currentStartedAt) {
|
|
146
|
-
expiredLiveOwners.delete(owner.token);
|
|
147
|
-
return currentStartedAt === owner.processStartedAt;
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
if (ageMs !== undefined && ageMs < MAX_LOCK_AGE_MS) {
|
|
151
|
-
expiredLiveOwners.delete(owner.token);
|
|
152
|
-
return true;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const firstExpiredAt = expiredLiveOwners.get(owner.token) ?? Date.now();
|
|
156
|
-
expiredLiveOwners.set(owner.token, firstExpiredAt);
|
|
157
|
-
if (Date.now() - firstExpiredAt < liveOwnerRecoveryGraceMs) {
|
|
158
|
-
return true;
|
|
159
|
-
}
|
|
160
|
-
return false;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function writeOwnerFile(path: string, owner: LockOwner): void {
|
|
164
|
-
const fd = openSync(path, "wx", 0o600);
|
|
165
|
-
try {
|
|
166
|
-
writeFileSync(fd, JSON.stringify(owner) + "\n");
|
|
167
|
-
} finally {
|
|
168
|
-
closeSync(fd);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function acquireRecoveryClaim(
|
|
173
|
-
lockPath: string,
|
|
174
|
-
staleSignature: string,
|
|
175
|
-
owner: LockOwner,
|
|
176
|
-
liveOwnerRecoveryGraceMs: number,
|
|
177
|
-
): (() => void) | undefined {
|
|
178
|
-
const recoveryKey = staleSignature.slice(0, 16);
|
|
179
|
-
for (let generation = 0; generation < 100; generation++) {
|
|
180
|
-
const claimPath = `${lockPath}.recovery-${recoveryKey}-${generation}`;
|
|
181
|
-
try {
|
|
182
|
-
writeOwnerFile(claimPath, owner);
|
|
183
|
-
return () => releaseLock(claimPath, owner.token);
|
|
184
|
-
} catch (error) {
|
|
185
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
186
|
-
throw error;
|
|
187
|
-
}
|
|
188
|
-
if (
|
|
189
|
-
ownerIsActive(claimPath, readOwner(claimPath), liveOwnerRecoveryGraceMs)
|
|
190
|
-
) {
|
|
191
|
-
return undefined;
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
return undefined;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function removeStaleLock(
|
|
199
|
-
lockPath: string,
|
|
200
|
-
recoveryOwner: LockOwner,
|
|
201
|
-
liveOwnerRecoveryGraceMs: number,
|
|
202
|
-
): boolean {
|
|
203
|
-
const staleSnapshot = readSnapshot(lockPath);
|
|
204
|
-
if (!staleSnapshot) {
|
|
205
|
-
return true;
|
|
206
|
-
}
|
|
207
|
-
if (ownerIsActive(lockPath, staleSnapshot.owner, liveOwnerRecoveryGraceMs)) {
|
|
208
|
-
return false;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const releaseRecoveryClaim = acquireRecoveryClaim(
|
|
212
|
-
lockPath,
|
|
213
|
-
staleSnapshot.signature,
|
|
214
|
-
recoveryOwner,
|
|
215
|
-
liveOwnerRecoveryGraceMs,
|
|
216
|
-
);
|
|
217
|
-
if (!releaseRecoveryClaim) {
|
|
218
|
-
return false;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
try {
|
|
222
|
-
const currentSnapshot = readSnapshot(lockPath);
|
|
223
|
-
if (!currentSnapshot) {
|
|
224
|
-
return true;
|
|
225
|
-
}
|
|
226
|
-
if (currentSnapshot.signature !== staleSnapshot.signature) {
|
|
227
|
-
return false;
|
|
228
|
-
}
|
|
229
|
-
if (
|
|
230
|
-
ownerIsActive(lockPath, currentSnapshot.owner, liveOwnerRecoveryGraceMs)
|
|
231
|
-
) {
|
|
232
|
-
return false;
|
|
233
|
-
}
|
|
234
|
-
try {
|
|
235
|
-
unlinkSync(lockPath);
|
|
236
|
-
return true;
|
|
237
|
-
} catch (error) {
|
|
238
|
-
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
239
|
-
}
|
|
240
|
-
} finally {
|
|
241
|
-
releaseRecoveryClaim();
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function releaseLock(lockPath: string, token: string): void {
|
|
246
|
-
if (readOwner(lockPath)?.token !== token) {
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
try {
|
|
250
|
-
unlinkSync(lockPath);
|
|
251
|
-
} catch (error) {
|
|
252
|
-
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
253
|
-
throw error;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function refreshLock(lockPath: string, token: string): void {
|
|
259
|
-
if (readOwner(lockPath)?.token !== token) {
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
try {
|
|
263
|
-
const now = new Date();
|
|
264
|
-
utimesSync(lockPath, now, now);
|
|
265
|
-
} catch {}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Serialize local assistant startup across CLI processes. Port availability is
|
|
270
|
-
* machine-wide even when callers use different lockfile or XDG directories,
|
|
271
|
-
* so allocation must remain exclusive until the new services have bound their
|
|
272
|
-
* ports and the assistant entry has been persisted.
|
|
273
|
-
*/
|
|
274
|
-
export async function withLocalHatchLock<T>(
|
|
275
|
-
action: () => Promise<T>,
|
|
276
|
-
options: LocalHatchLockOptions = {},
|
|
277
|
-
): Promise<T> {
|
|
278
|
-
const lockPath = options.lockPath ?? resolveLocalHatchLockPath();
|
|
279
|
-
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
280
|
-
const retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
|
|
281
|
-
const liveOwnerRecoveryGraceMs =
|
|
282
|
-
options.liveOwnerRecoveryGraceMs ?? LIVE_OWNER_RECOVERY_GRACE_MS;
|
|
283
|
-
const deadline = Date.now() + timeoutMs;
|
|
284
|
-
const owner: LockOwner = {
|
|
285
|
-
pid: process.pid,
|
|
286
|
-
processStartedAt: processStartedAt(process.pid),
|
|
287
|
-
token: `${process.pid}-${randomUUID()}`,
|
|
288
|
-
};
|
|
289
|
-
|
|
290
|
-
mkdirSync(dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
291
|
-
|
|
292
|
-
while (true) {
|
|
293
|
-
try {
|
|
294
|
-
writeOwnerFile(lockPath, owner);
|
|
295
|
-
} catch (error) {
|
|
296
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
|
|
297
|
-
throw error;
|
|
298
|
-
}
|
|
299
|
-
if (removeStaleLock(lockPath, owner, liveOwnerRecoveryGraceMs)) {
|
|
300
|
-
continue;
|
|
301
|
-
}
|
|
302
|
-
if (Date.now() >= deadline) {
|
|
303
|
-
throw new Error(
|
|
304
|
-
`Timed out waiting for another local assistant to finish starting (${lockPath})`,
|
|
305
|
-
);
|
|
306
|
-
}
|
|
307
|
-
await new Promise((resolve) => setTimeout(resolve, retryMs));
|
|
308
|
-
continue;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const heartbeat = setInterval(
|
|
312
|
-
() => refreshLock(lockPath, owner.token),
|
|
313
|
-
HEARTBEAT_INTERVAL_MS,
|
|
314
|
-
);
|
|
315
|
-
try {
|
|
316
|
-
return await action();
|
|
317
|
-
} finally {
|
|
318
|
-
clearInterval(heartbeat);
|
|
319
|
-
releaseLock(lockPath, owner.token);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
}
|