pi-oracle 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.
@@ -0,0 +1,534 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { spawn, execFileSync } from "node:child_process";
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { chmod, mkdir, readFile, rename, writeFile } from "node:fs/promises";
5
+ import { join, resolve } from "node:path";
6
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
7
+ import type { OracleConfig, OracleEffort, OracleModelFamily } from "./config.js";
8
+ import { withJobLock } from "./locks.js";
9
+ import { cleanupRuntimeArtifacts, getProjectId, getSessionId, parseConversationId } from "./runtime.js";
10
+
11
+ export type OracleJobStatus = "preparing" | "submitted" | "waiting" | "complete" | "failed" | "cancelled";
12
+ export type OracleJobPhase =
13
+ | "submitted"
14
+ | "cloning_runtime"
15
+ | "launching_browser"
16
+ | "verifying_auth"
17
+ | "configuring_model"
18
+ | "uploading_archive"
19
+ | "awaiting_response"
20
+ | "extracting_response"
21
+ | "downloading_artifacts"
22
+ | "complete"
23
+ | "complete_with_artifact_errors"
24
+ | "failed"
25
+ | "cancelled";
26
+
27
+ export const ACTIVE_ORACLE_JOB_STATUSES: OracleJobStatus[] = ["preparing", "submitted", "waiting"];
28
+ export const ORACLE_MISSING_WORKER_GRACE_MS = 30_000;
29
+ export const ORACLE_STALE_HEARTBEAT_MS = 3 * 60 * 1000;
30
+ export const ORACLE_NOTIFICATION_CLAIM_TTL_MS = 60_000;
31
+
32
+ export function isActiveOracleJob(job: Pick<OracleJob, "status">): boolean {
33
+ return ACTIVE_ORACLE_JOB_STATUSES.includes(job.status);
34
+ }
35
+
36
+ function readProcessStartedAt(pid: number | undefined): string | undefined {
37
+ if (!pid || pid <= 0) return undefined;
38
+ try {
39
+ const startedAt = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }).trim();
40
+ return startedAt || undefined;
41
+ } catch {
42
+ return undefined;
43
+ }
44
+ }
45
+
46
+ async function waitForProcessStartedAt(pid: number | undefined, timeoutMs = 2_000): Promise<string | undefined> {
47
+ const deadline = Date.now() + timeoutMs;
48
+ while (Date.now() < deadline) {
49
+ const startedAt = readProcessStartedAt(pid);
50
+ if (startedAt) return startedAt;
51
+ await sleep(100);
52
+ }
53
+ return readProcessStartedAt(pid);
54
+ }
55
+
56
+ export function isWorkerProcessAlive(pid: number | undefined, startedAt?: string): boolean {
57
+ const currentStartedAt = readProcessStartedAt(pid);
58
+ if (!currentStartedAt) return false;
59
+ return startedAt ? currentStartedAt === startedAt : true;
60
+ }
61
+
62
+ export interface OracleArtifactRecord {
63
+ displayName?: string;
64
+ fileName?: string;
65
+ sourcePath?: string;
66
+ copiedPath?: string;
67
+ url?: string;
68
+ state?: number | string;
69
+ size?: number;
70
+ sha256?: string;
71
+ detectedType?: string;
72
+ unconfirmed?: boolean;
73
+ error?: string;
74
+ downloadId?: string;
75
+ matchesUploadedArchive?: boolean;
76
+ }
77
+
78
+ export interface OracleJob {
79
+ id: string;
80
+ status: OracleJobStatus;
81
+ phase: OracleJobPhase;
82
+ phaseAt: string;
83
+ createdAt: string;
84
+ submittedAt?: string;
85
+ completedAt?: string;
86
+ heartbeatAt?: string;
87
+ cwd: string;
88
+ projectId: string;
89
+ sessionId: string;
90
+ originSessionFile?: string;
91
+ requestSource: "command" | "tool";
92
+ chatModelFamily: OracleModelFamily;
93
+ effort?: OracleEffort;
94
+ autoSwitchToThinking?: boolean;
95
+ followUpToJobId?: string;
96
+ chatUrl?: string;
97
+ conversationId?: string;
98
+ responsePath?: string;
99
+ responseFormat?: "text/plain";
100
+ artifactPaths: string[];
101
+ artifactsManifestPath?: string;
102
+ archivePath: string;
103
+ archiveSha256?: string;
104
+ archiveDeletedAfterUpload: boolean;
105
+ notifiedAt?: string;
106
+ notifyClaimedAt?: string;
107
+ notifyClaimedBy?: string;
108
+ artifactFailureCount?: number;
109
+ error?: string;
110
+ promptPath: string;
111
+ reasoningPath?: string;
112
+ logsDir: string;
113
+ workerLogPath: string;
114
+ workerPid?: number;
115
+ workerNonce?: string;
116
+ workerStartedAt?: string;
117
+ runtimeId: string;
118
+ runtimeSessionName: string;
119
+ runtimeProfileDir: string;
120
+ seedGeneration?: string;
121
+ config: OracleConfig;
122
+ }
123
+
124
+ export interface OracleSubmitInput {
125
+ prompt: string;
126
+ files: string[];
127
+ modelFamily: OracleModelFamily;
128
+ effort?: OracleEffort;
129
+ autoSwitchToThinking?: boolean;
130
+ followUpToJobId?: string;
131
+ chatUrl?: string;
132
+ requestSource: "command" | "tool";
133
+ }
134
+
135
+ export interface OracleRuntimeAllocation {
136
+ runtimeId: string;
137
+ runtimeSessionName: string;
138
+ runtimeProfileDir: string;
139
+ seedGeneration?: string;
140
+ }
141
+
142
+ export function getSessionFile(ctx: ExtensionContext): string | undefined {
143
+ const manager = ctx.sessionManager as unknown as { getSessionFile?: () => string | undefined };
144
+ return manager.getSessionFile?.();
145
+ }
146
+
147
+ export function getJobDir(id: string): string {
148
+ return join("/tmp", `oracle-${id}`);
149
+ }
150
+
151
+ export function listOracleJobDirs(): string[] {
152
+ if (!existsSync("/tmp")) return [];
153
+ return readdirSync("/tmp")
154
+ .filter((name) => name.startsWith("oracle-"))
155
+ .map((name) => join("/tmp", name))
156
+ .filter((path) => existsSync(join(path, "job.json")));
157
+ }
158
+
159
+ export function readJob(jobDirOrId: string): OracleJob | undefined {
160
+ const jobDir = jobDirOrId.startsWith("/tmp/oracle-") ? jobDirOrId : getJobDir(jobDirOrId);
161
+ const jobPath = join(jobDir, "job.json");
162
+ if (!existsSync(jobPath)) return undefined;
163
+ try {
164
+ return JSON.parse(readFileSync(jobPath, "utf8")) as OracleJob;
165
+ } catch {
166
+ return undefined;
167
+ }
168
+ }
169
+
170
+ export function listJobsForCwd(cwd: string): OracleJob[] {
171
+ const projectId = getProjectId(cwd);
172
+ return listOracleJobDirs()
173
+ .map((dir) => readJob(dir))
174
+ .filter((job): job is OracleJob => Boolean(job && job.projectId === projectId))
175
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
176
+ }
177
+
178
+ async function writeJobUnlocked(job: OracleJob): Promise<void> {
179
+ const jobDir = getJobDir(job.id);
180
+ const jobPath = join(jobDir, "job.json");
181
+ const tmpPath = `${jobPath}.${process.pid}.${Date.now()}.tmp`;
182
+ await mkdir(jobDir, { recursive: true, mode: 0o700 });
183
+ await chmod(jobDir, 0o700).catch(() => undefined);
184
+ await writeFile(tmpPath, `${JSON.stringify(job, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
185
+ await chmod(tmpPath, 0o600).catch(() => undefined);
186
+ await rename(tmpPath, jobPath);
187
+ await chmod(jobPath, 0o600).catch(() => undefined);
188
+ }
189
+
190
+ export async function writeJob(job: OracleJob): Promise<void> {
191
+ await withJobLock(job.id, { processPid: process.pid, action: "writeJob" }, async () => {
192
+ await writeJobUnlocked(job);
193
+ });
194
+ }
195
+
196
+ export async function updateJob(id: string, mutate: (job: OracleJob) => OracleJob): Promise<OracleJob> {
197
+ return withJobLock(id, { processPid: process.pid, action: "updateJob" }, async () => {
198
+ const current = readJob(id);
199
+ if (!current) throw new Error(`Oracle job not found: ${id}`);
200
+ const next = mutate(current);
201
+ await writeJobUnlocked(next);
202
+ return next;
203
+ });
204
+ }
205
+
206
+ function sleep(ms: number): Promise<void> {
207
+ return new Promise((resolve) => setTimeout(resolve, ms));
208
+ }
209
+
210
+ function parseTimestamp(value: string | undefined): number | undefined {
211
+ if (!value) return undefined;
212
+ const parsed = Date.parse(value);
213
+ return Number.isFinite(parsed) ? parsed : undefined;
214
+ }
215
+
216
+ export function withJobPhase<T extends Pick<OracleJob, "phase" | "phaseAt">>(
217
+ phase: OracleJobPhase,
218
+ patch?: Omit<Partial<OracleJob>, "phase" | "phaseAt">,
219
+ at = new Date().toISOString(),
220
+ ): Partial<OracleJob> {
221
+ return {
222
+ ...(patch || {}),
223
+ phase,
224
+ phaseAt: at,
225
+ };
226
+ }
227
+
228
+ function isTerminalOracleJobStatus(status: OracleJobStatus): boolean {
229
+ return status === "complete" || status === "failed" || status === "cancelled";
230
+ }
231
+
232
+ export async function terminateWorkerPid(
233
+ pid: number | undefined,
234
+ startedAt?: string,
235
+ options?: { termGraceMs?: number; killGraceMs?: number },
236
+ ): Promise<boolean> {
237
+ if (!pid || pid <= 0) return true;
238
+ const currentStartedAt = readProcessStartedAt(pid);
239
+ if (!currentStartedAt) return true;
240
+ if (startedAt && currentStartedAt !== startedAt) return false;
241
+
242
+ const termGraceMs = options?.termGraceMs ?? 5000;
243
+ const killGraceMs = options?.killGraceMs ?? 2000;
244
+
245
+ try {
246
+ process.kill(pid, "SIGTERM");
247
+ } catch {
248
+ return !isWorkerProcessAlive(pid, startedAt);
249
+ }
250
+
251
+ const termDeadline = Date.now() + termGraceMs;
252
+ while (Date.now() < termDeadline) {
253
+ if (!isWorkerProcessAlive(pid, startedAt)) return true;
254
+ await sleep(250);
255
+ }
256
+
257
+ try {
258
+ process.kill(pid, "SIGKILL");
259
+ } catch {
260
+ return !isWorkerProcessAlive(pid, startedAt);
261
+ }
262
+
263
+ const killDeadline = Date.now() + killGraceMs;
264
+ while (Date.now() < killDeadline) {
265
+ if (!isWorkerProcessAlive(pid, startedAt)) return true;
266
+ await sleep(250);
267
+ }
268
+
269
+ return !isWorkerProcessAlive(pid, startedAt);
270
+ }
271
+
272
+ export function getStaleOracleJobReason(job: OracleJob, now = Date.now()): string | undefined {
273
+ if (!isActiveOracleJob(job)) return undefined;
274
+
275
+ const heartbeatMs = parseTimestamp(job.heartbeatAt);
276
+ const submittedMs = parseTimestamp(job.submittedAt);
277
+ const createdMs = parseTimestamp(job.createdAt);
278
+ const baselineMs = heartbeatMs ?? submittedMs ?? createdMs;
279
+ if (!baselineMs) return "Oracle job has no valid timestamps";
280
+
281
+ if (!job.workerPid) {
282
+ if (now - baselineMs > ORACLE_MISSING_WORKER_GRACE_MS) {
283
+ return "Oracle job is active but has no worker PID";
284
+ }
285
+ return undefined;
286
+ }
287
+
288
+ const currentStartedAt = readProcessStartedAt(job.workerPid);
289
+ if (!currentStartedAt) {
290
+ return `Oracle worker PID ${job.workerPid} is no longer running`;
291
+ }
292
+
293
+ if (job.workerStartedAt && currentStartedAt !== job.workerStartedAt) {
294
+ return `Oracle worker PID ${job.workerPid} no longer matches the recorded process identity`;
295
+ }
296
+
297
+ if (now - baselineMs > ORACLE_STALE_HEARTBEAT_MS) {
298
+ return `Oracle worker heartbeat is stale (${Math.round((now - baselineMs) / 1000)}s since last update)`;
299
+ }
300
+
301
+ return undefined;
302
+ }
303
+
304
+ async function cleanupJobResources(job: OracleJob): Promise<void> {
305
+ await cleanupRuntimeArtifacts({
306
+ runtimeId: job.runtimeId,
307
+ runtimeProfileDir: job.runtimeProfileDir,
308
+ runtimeSessionName: job.runtimeSessionName,
309
+ conversationId: job.conversationId,
310
+ }).catch(() => undefined);
311
+ }
312
+
313
+ export async function reconcileStaleOracleJobs(): Promise<OracleJob[]> {
314
+ const repaired: OracleJob[] = [];
315
+ const now = Date.now();
316
+
317
+ for (const jobDir of listOracleJobDirs()) {
318
+ const job = readJob(jobDir);
319
+ if (!job) continue;
320
+ const staleReason = getStaleOracleJobReason(job, now);
321
+ if (!staleReason) continue;
322
+
323
+ const terminated = await terminateWorkerPid(job.workerPid, job.workerStartedAt);
324
+ const suffix = job.workerPid
325
+ ? terminated
326
+ ? ` Terminated stale worker PID ${job.workerPid}.`
327
+ : ` Failed to terminate stale worker PID ${job.workerPid}.`
328
+ : "";
329
+
330
+ const repairedJob = await updateJob(job.id, (current) => ({
331
+ ...current,
332
+ ...withJobPhase("failed", {
333
+ status: "failed",
334
+ completedAt: new Date(now).toISOString(),
335
+ heartbeatAt: new Date(now).toISOString(),
336
+ notifyClaimedAt: undefined,
337
+ notifyClaimedBy: undefined,
338
+ error: current.error
339
+ ? `${current.error}\nRecovered stale job: ${staleReason}.${suffix}`.trim()
340
+ : `Recovered stale job: ${staleReason}.${suffix}`.trim(),
341
+ }, new Date(now).toISOString()),
342
+ }));
343
+ await cleanupJobResources(repairedJob);
344
+ repaired.push(repairedJob);
345
+ }
346
+
347
+ return repaired;
348
+ }
349
+
350
+ export async function sha256File(path: string): Promise<string> {
351
+ const buffer = await readFile(path);
352
+ return createHash("sha256").update(buffer).digest("hex");
353
+ }
354
+
355
+ export async function tryClaimNotification(jobId: string, claimedBy: string, now = new Date().toISOString()): Promise<OracleJob | undefined> {
356
+ return withJobLock(jobId, { processPid: process.pid, action: "tryClaimNotification", claimedBy }, async () => {
357
+ const current = readJob(jobId);
358
+ if (!current) return undefined;
359
+ if (!isTerminalOracleJobStatus(current.status)) return undefined;
360
+ if (current.notifiedAt) return undefined;
361
+
362
+ const claimedAtMs = parseTimestamp(current.notifyClaimedAt);
363
+ const claimIsLive =
364
+ current.notifyClaimedBy &&
365
+ current.notifyClaimedBy !== claimedBy &&
366
+ claimedAtMs !== undefined &&
367
+ Date.now() - claimedAtMs < ORACLE_NOTIFICATION_CLAIM_TTL_MS;
368
+ if (claimIsLive) return undefined;
369
+
370
+ const next: OracleJob = {
371
+ ...current,
372
+ notifyClaimedBy: claimedBy,
373
+ notifyClaimedAt: now,
374
+ };
375
+ await writeJobUnlocked(next);
376
+ return next;
377
+ });
378
+ }
379
+
380
+ export async function markJobNotified(jobId: string, claimedBy: string, at = new Date().toISOString()): Promise<OracleJob> {
381
+ return withJobLock(jobId, { processPid: process.pid, action: "markJobNotified", claimedBy }, async () => {
382
+ const current = readJob(jobId);
383
+ if (!current) throw new Error(`Oracle job not found: ${jobId}`);
384
+ const next: OracleJob = {
385
+ ...current,
386
+ notifiedAt: current.notifiedAt || at,
387
+ notifyClaimedAt: undefined,
388
+ notifyClaimedBy: undefined,
389
+ };
390
+ await writeJobUnlocked(next);
391
+ return next;
392
+ });
393
+ }
394
+
395
+ export async function releaseNotificationClaim(jobId: string, claimedBy: string): Promise<OracleJob | undefined> {
396
+ return withJobLock(jobId, { processPid: process.pid, action: "releaseNotificationClaim", claimedBy }, async () => {
397
+ const current = readJob(jobId);
398
+ if (!current) return undefined;
399
+ if (current.notifyClaimedBy && current.notifyClaimedBy !== claimedBy) return current;
400
+ const next: OracleJob = {
401
+ ...current,
402
+ notifyClaimedAt: undefined,
403
+ notifyClaimedBy: undefined,
404
+ };
405
+ await writeJobUnlocked(next);
406
+ return next;
407
+ });
408
+ }
409
+
410
+ export async function cancelOracleJob(id: string, reason = "Cancelled by user"): Promise<OracleJob> {
411
+ const current = readJob(id);
412
+ if (!current) throw new Error(`Oracle job not found: ${id}`);
413
+ if (!isActiveOracleJob(current)) return current;
414
+
415
+ const terminated = await terminateWorkerPid(current.workerPid, current.workerStartedAt);
416
+ const now = new Date().toISOString();
417
+ const cancelled = await updateJob(id, (job) => ({
418
+ ...job,
419
+ ...withJobPhase(terminated ? "cancelled" : "failed", {
420
+ status: terminated ? "cancelled" : "failed",
421
+ completedAt: now,
422
+ heartbeatAt: now,
423
+ notifyClaimedAt: undefined,
424
+ notifyClaimedBy: undefined,
425
+ error: terminated ? reason : `${reason}; worker PID ${job.workerPid ?? "unknown"} did not exit`,
426
+ }, now),
427
+ }));
428
+ await cleanupJobResources(cancelled);
429
+ return cancelled;
430
+ }
431
+
432
+ export async function createJob(
433
+ id: string,
434
+ input: OracleSubmitInput,
435
+ cwd: string,
436
+ originSessionFile: string | undefined,
437
+ config: OracleConfig,
438
+ runtime: OracleRuntimeAllocation,
439
+ ): Promise<OracleJob> {
440
+ const jobDir = getJobDir(id);
441
+ const logsDir = join(jobDir, "logs");
442
+ const workerLogPath = join(logsDir, "worker.log");
443
+ const promptPath = join(jobDir, "prompt.md");
444
+ const archivePath = join(jobDir, `context-${id}.tar.zst`);
445
+ const responsePath = join(jobDir, "response.md");
446
+ const reasoningPath = join(jobDir, "reasoning.md");
447
+ const artifactsManifestPath = join(jobDir, "artifacts.json");
448
+ const projectId = getProjectId(cwd);
449
+ const sessionId = getSessionId(originSessionFile, projectId);
450
+ const conversationId = parseConversationId(input.chatUrl);
451
+
452
+ await mkdir(jobDir, { recursive: true, mode: 0o700 });
453
+ await chmod(jobDir, 0o700).catch(() => undefined);
454
+ await mkdir(join(jobDir, "artifacts"), { recursive: true, mode: 0o700 });
455
+ await chmod(join(jobDir, "artifacts"), 0o700).catch(() => undefined);
456
+ await mkdir(logsDir, { recursive: true, mode: 0o700 });
457
+ await chmod(logsDir, 0o700).catch(() => undefined);
458
+ await writeFile(promptPath, input.prompt, { encoding: "utf8", mode: 0o600 });
459
+ await chmod(promptPath, 0o600).catch(() => undefined);
460
+
461
+ const now = new Date().toISOString();
462
+ const job: OracleJob = {
463
+ id,
464
+ status: "submitted",
465
+ phase: "submitted",
466
+ phaseAt: now,
467
+ createdAt: now,
468
+ submittedAt: now,
469
+ cwd,
470
+ projectId,
471
+ sessionId,
472
+ originSessionFile,
473
+ requestSource: input.requestSource,
474
+ chatModelFamily: input.modelFamily,
475
+ effort: input.effort,
476
+ autoSwitchToThinking: input.autoSwitchToThinking,
477
+ followUpToJobId: input.followUpToJobId,
478
+ chatUrl: input.followUpToJobId ? input.chatUrl : undefined,
479
+ conversationId,
480
+ responseFormat: "text/plain",
481
+ artifactPaths: [],
482
+ archivePath,
483
+ archiveDeletedAfterUpload: false,
484
+ promptPath,
485
+ responsePath,
486
+ reasoningPath,
487
+ artifactsManifestPath,
488
+ logsDir,
489
+ workerLogPath,
490
+ runtimeId: runtime.runtimeId,
491
+ runtimeSessionName: runtime.runtimeSessionName,
492
+ runtimeProfileDir: runtime.runtimeProfileDir,
493
+ seedGeneration: runtime.seedGeneration,
494
+ config,
495
+ };
496
+
497
+ await writeJob(job);
498
+ return job;
499
+ }
500
+
501
+ export function resolveArchiveInputs(cwd: string, files: string[]): { absolute: string; relative: string }[] {
502
+ if (files.length === 0) {
503
+ throw new Error("oracle_submit requires at least one file or directory to archive");
504
+ }
505
+
506
+ return files.map((file) => {
507
+ const absolute = resolve(cwd, file);
508
+ const relative = absolute.startsWith(`${cwd}/`) ? absolute.slice(cwd.length + 1) : absolute === cwd ? "." : "";
509
+ if (!relative) {
510
+ throw new Error(`Archive input must be inside the project cwd: ${file}`);
511
+ }
512
+ if (!existsSync(absolute)) {
513
+ throw new Error(`Archive input does not exist: ${file}`);
514
+ }
515
+ return { absolute, relative };
516
+ });
517
+ }
518
+
519
+ export async function spawnWorker(
520
+ workerPath: string,
521
+ jobId: string,
522
+ ): Promise<{ pid: number | undefined; nonce: string; startedAt: string | undefined }> {
523
+ const nonce = randomUUID();
524
+ const child = spawn(process.execPath, [workerPath, jobId, nonce], {
525
+ detached: true,
526
+ stdio: "ignore",
527
+ });
528
+ child.unref();
529
+ return {
530
+ pid: child.pid,
531
+ nonce,
532
+ startedAt: await waitForProcessStartedAt(child.pid),
533
+ };
534
+ }
@@ -0,0 +1,164 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdirSync, readdirSync, readFileSync } from "node:fs";
4
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+
7
+ const ORACLE_STATE_DIR = "/tmp/pi-oracle-state";
8
+ const LOCKS_DIR = join(ORACLE_STATE_DIR, "locks");
9
+ const LEASES_DIR = join(ORACLE_STATE_DIR, "leases");
10
+ const DEFAULT_WAIT_MS = 30_000;
11
+ const POLL_MS = 200;
12
+
13
+ export interface OracleLockHandle {
14
+ path: string;
15
+ }
16
+
17
+ function ensureDirSync(path: string): void {
18
+ mkdirSync(path, { recursive: true, mode: 0o700 });
19
+ }
20
+
21
+ function leaseKey(kind: string, key: string): string {
22
+ return `${kind}-${createHash("sha256").update(key).digest("hex").slice(0, 24)}`;
23
+ }
24
+
25
+ export function getOracleStateDir(): string {
26
+ ensureDirSync(ORACLE_STATE_DIR);
27
+ return ORACLE_STATE_DIR;
28
+ }
29
+
30
+ export function getLocksDir(): string {
31
+ ensureDirSync(LOCKS_DIR);
32
+ return LOCKS_DIR;
33
+ }
34
+
35
+ export function getLeasesDir(): string {
36
+ ensureDirSync(LEASES_DIR);
37
+ return LEASES_DIR;
38
+ }
39
+
40
+ function lockPath(kind: string, key: string): string {
41
+ return join(getLocksDir(), leaseKey(kind, key));
42
+ }
43
+
44
+ function leasePath(kind: string, key: string): string {
45
+ return join(getLeasesDir(), leaseKey(kind, key));
46
+ }
47
+
48
+ async function sleep(ms: number): Promise<void> {
49
+ await new Promise((resolve) => setTimeout(resolve, ms));
50
+ }
51
+
52
+ async function writeMetadata(path: string, metadata: unknown): Promise<void> {
53
+ await writeFile(join(path, "metadata.json"), `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
54
+ }
55
+
56
+ export async function acquireLock(
57
+ kind: string,
58
+ key: string,
59
+ metadata: unknown,
60
+ options?: { timeoutMs?: number },
61
+ ): Promise<OracleLockHandle> {
62
+ const path = lockPath(kind, key);
63
+ const timeoutMs = options?.timeoutMs ?? DEFAULT_WAIT_MS;
64
+ const deadline = Date.now() + timeoutMs;
65
+
66
+ while (Date.now() < deadline) {
67
+ try {
68
+ await mkdir(path, { recursive: false, mode: 0o700 });
69
+ await writeMetadata(path, metadata);
70
+ return { path };
71
+ } catch (error) {
72
+ if (!(error && typeof error === "object" && "code" in error && error.code === "EEXIST")) throw error;
73
+ }
74
+ await sleep(POLL_MS);
75
+ }
76
+
77
+ throw new Error(`Timed out waiting for oracle ${kind} lock: ${key}`);
78
+ }
79
+
80
+ export async function releaseLock(handle: OracleLockHandle | undefined): Promise<void> {
81
+ if (!handle) return;
82
+ await rm(handle.path, { recursive: true, force: true }).catch(() => undefined);
83
+ }
84
+
85
+ export async function withLock<T>(
86
+ kind: string,
87
+ key: string,
88
+ metadata: unknown,
89
+ fn: () => Promise<T>,
90
+ options?: { timeoutMs?: number },
91
+ ): Promise<T> {
92
+ const handle = await acquireLock(kind, key, metadata, options);
93
+ try {
94
+ return await fn();
95
+ } finally {
96
+ await releaseLock(handle);
97
+ }
98
+ }
99
+
100
+ export function isLockTimeoutError(error: unknown, kind?: string, key?: string): boolean {
101
+ if (!(error instanceof Error)) return false;
102
+ const expected = `Timed out waiting for oracle ${kind ?? ""} lock: ${key ?? ""}`.trim();
103
+ return kind && key ? error.message === expected : /^Timed out waiting for oracle .+ lock: .+$/i.test(error.message);
104
+ }
105
+
106
+ export async function withAuthLock<T>(metadata: unknown, fn: () => Promise<T>): Promise<T> {
107
+ return withLock("auth", "global", metadata, fn, { timeoutMs: 10 * 60 * 1000 });
108
+ }
109
+
110
+ export async function withGlobalReconcileLock<T>(
111
+ metadata: unknown,
112
+ fn: () => Promise<T>,
113
+ options?: { timeoutMs?: number },
114
+ ): Promise<T> {
115
+ return withLock("reconcile", "global", metadata, fn, { timeoutMs: options?.timeoutMs ?? 30_000 });
116
+ }
117
+
118
+ export async function withGlobalScanLock<T>(
119
+ metadata: unknown,
120
+ fn: () => Promise<T>,
121
+ options?: { timeoutMs?: number },
122
+ ): Promise<T> {
123
+ return withLock("scan", "global", metadata, fn, { timeoutMs: options?.timeoutMs ?? 5_000 });
124
+ }
125
+
126
+ export async function withJobLock<T>(jobId: string, metadata: unknown, fn: () => Promise<T>): Promise<T> {
127
+ return withLock("job", jobId, metadata, fn, { timeoutMs: 10_000 });
128
+ }
129
+
130
+ export async function createLease(kind: string, key: string, metadata: unknown): Promise<string> {
131
+ const path = leasePath(kind, key);
132
+ await mkdir(path, { recursive: false, mode: 0o700 });
133
+ await writeMetadata(path, metadata);
134
+ return path;
135
+ }
136
+
137
+ export async function readLeaseMetadata<T = unknown>(kind: string, key: string): Promise<T | undefined> {
138
+ const path = join(leasePath(kind, key), "metadata.json");
139
+ if (!existsSync(path)) return undefined;
140
+ try {
141
+ return JSON.parse(await readFile(path, "utf8")) as T;
142
+ } catch {
143
+ return undefined;
144
+ }
145
+ }
146
+
147
+ export async function releaseLease(kind: string, key: string): Promise<void> {
148
+ await rm(leasePath(kind, key), { recursive: true, force: true }).catch(() => undefined);
149
+ }
150
+
151
+ export function listLeaseMetadata<T = unknown>(kind: string): T[] {
152
+ const dir = getLeasesDir();
153
+ return readdirSync(dir)
154
+ .filter((name) => name.startsWith(`${kind}-`))
155
+ .map((name) => join(dir, name, "metadata.json"))
156
+ .filter((path) => existsSync(path))
157
+ .flatMap((path) => {
158
+ try {
159
+ return [JSON.parse(readFileSync(path, "utf8")) as T];
160
+ } catch {
161
+ return [];
162
+ }
163
+ });
164
+ }