pi-oracle 0.3.4 → 0.5.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/CHANGELOG.md +38 -0
- package/README.md +27 -8
- package/docs/ORACLE_DESIGN.md +14 -8
- package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +276 -0
- package/extensions/oracle/index.ts +8 -1
- package/extensions/oracle/lib/commands.ts +25 -29
- package/extensions/oracle/lib/config.ts +56 -2
- package/extensions/oracle/lib/jobs.ts +134 -219
- package/extensions/oracle/lib/locks.ts +41 -209
- package/extensions/oracle/lib/poller.ts +38 -52
- package/extensions/oracle/lib/queue.ts +75 -112
- package/extensions/oracle/lib/runtime.ts +102 -19
- package/extensions/oracle/lib/tools.ts +663 -294
- package/extensions/oracle/shared/job-coordination-helpers.d.mts +84 -0
- package/extensions/oracle/shared/job-coordination-helpers.mjs +168 -0
- package/extensions/oracle/shared/job-lifecycle-helpers.d.mts +131 -0
- package/extensions/oracle/shared/job-lifecycle-helpers.mjs +390 -0
- package/extensions/oracle/shared/job-observability-helpers.d.mts +60 -0
- package/extensions/oracle/shared/job-observability-helpers.mjs +161 -0
- package/extensions/oracle/shared/process-helpers.d.mts +20 -0
- package/extensions/oracle/shared/process-helpers.mjs +128 -0
- package/extensions/oracle/shared/state-coordination-helpers.d.mts +43 -0
- package/extensions/oracle/shared/state-coordination-helpers.mjs +381 -0
- package/extensions/oracle/worker/artifact-heuristics.mjs +5 -0
- package/extensions/oracle/worker/auth-bootstrap.mjs +125 -134
- package/extensions/oracle/worker/auth-cookie-policy.mjs +5 -0
- package/extensions/oracle/worker/auth-flow-helpers.d.mts +41 -0
- package/extensions/oracle/worker/auth-flow-helpers.mjs +165 -0
- package/extensions/oracle/worker/chatgpt-flow-helpers.d.mts +13 -0
- package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +85 -0
- package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +93 -9
- package/extensions/oracle/worker/run-job.mjs +166 -274
- package/extensions/oracle/worker/state-locks.mjs +31 -216
- package/package.json +4 -3
- package/prompts/oracle.md +16 -10
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
// Purpose: Manage oracle browser runtime allocation, lease admission, seed/runtime profile handling, and runtime cleanup for the extension side.
|
|
2
|
+
// Responsibilities: Allocate runtimes, enforce persisted-session requirements, acquire/release runtime and conversation leases, and clean up runtime artifacts safely.
|
|
3
|
+
// Scope: Extension-side runtime coordination only; shared concurrency/process primitives live in extensions/oracle/shared.
|
|
4
|
+
// Usage: Imported by jobs, tools, and queue logic to provision or tear down isolated oracle browser runtimes.
|
|
5
|
+
// Invariants/Assumptions: Lease metadata is the admission source of truth, tracked worker identity checks defend against PID reuse, and runtime cleanup always attempts lease release.
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
2
7
|
import { spawn } from "node:child_process";
|
|
3
|
-
import { existsSync, realpathSync, readFileSync } from "node:fs";
|
|
4
|
-
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
5
|
-
import {
|
|
8
|
+
import { constants as fsConstants, existsSync, realpathSync, readFileSync } from "node:fs";
|
|
9
|
+
import { access, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
import { jobBlocksAdmission } from "../shared/job-coordination-helpers.mjs";
|
|
12
|
+
import { isTrackedProcessAlive } from "../shared/process-helpers.mjs";
|
|
6
13
|
import type { OracleConfig } from "./config.js";
|
|
7
14
|
import { createLease, listLeaseMetadata, readLeaseMetadata, releaseLease, withAuthLock } from "./locks.js";
|
|
8
15
|
|
|
@@ -12,6 +19,8 @@ const ORACLE_JOBS_DIR = process.env.PI_ORACLE_JOBS_DIR?.trim() || DEFAULT_ORACLE
|
|
|
12
19
|
const AGENT_BROWSER_BIN = [process.env.AGENT_BROWSER_PATH, "/opt/homebrew/bin/agent-browser", "/usr/local/bin/agent-browser"].find(
|
|
13
20
|
(candidate) => typeof candidate === "string" && candidate && existsSync(candidate),
|
|
14
21
|
) || "agent-browser";
|
|
22
|
+
const PROFILE_CLONE_TIMEOUT_MS = 120_000;
|
|
23
|
+
const ORACLE_SUBPROCESS_KILL_GRACE_MS = 2_000;
|
|
15
24
|
|
|
16
25
|
export interface OracleRuntimeLeaseMetadata {
|
|
17
26
|
jobId: string;
|
|
@@ -89,6 +98,45 @@ export function authSessionName(config: OracleConfig): string {
|
|
|
89
98
|
return `${config.browser.sessionPrefix}-auth`;
|
|
90
99
|
}
|
|
91
100
|
|
|
101
|
+
function missingAuthSeedProfileMessage(seedDir: string): string {
|
|
102
|
+
return `Oracle auth seed profile not found: ${seedDir}. Run /oracle-auth first.`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function invalidAuthSeedProfileTypeMessage(seedDir: string): string {
|
|
106
|
+
return `Oracle auth seed profile is not a directory: ${seedDir}. Remove the invalid path or rerun /oracle-auth.`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function unreadableAuthSeedProfileMessage(seedDir: string): string {
|
|
110
|
+
return `Oracle auth seed profile is not readable: ${seedDir}. Fix its permissions or rerun /oracle-auth.`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function assertOracleAuthSeedProfileReady(config: OracleConfig): Promise<void> {
|
|
114
|
+
const seedDir = config.browser.authSeedProfileDir;
|
|
115
|
+
let seedStats;
|
|
116
|
+
try {
|
|
117
|
+
seedStats = await stat(seedDir);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "";
|
|
120
|
+
if (code === "ENOENT") throw new Error(missingAuthSeedProfileMessage(seedDir));
|
|
121
|
+
if (code === "EACCES" || code === "EPERM") throw new Error(unreadableAuthSeedProfileMessage(seedDir));
|
|
122
|
+
throw new Error(`Failed to inspect oracle auth seed profile ${seedDir}: ${error instanceof Error ? error.message : String(error)}`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!seedStats.isDirectory()) {
|
|
126
|
+
throw new Error(invalidAuthSeedProfileTypeMessage(seedDir));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await access(seedDir, fsConstants.R_OK | fsConstants.X_OK);
|
|
131
|
+
} catch {
|
|
132
|
+
throw new Error(unreadableAuthSeedProfileMessage(seedDir));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function assertOracleSubmitPrerequisites(config: OracleConfig): Promise<void> {
|
|
137
|
+
await assertOracleAuthSeedProfileReady(config);
|
|
138
|
+
}
|
|
139
|
+
|
|
92
140
|
export function getSeedGeneration(config: OracleConfig): string | undefined {
|
|
93
141
|
const path = join(config.browser.authSeedProfileDir, SEED_GENERATION_FILE);
|
|
94
142
|
if (!existsSync(path)) return undefined;
|
|
@@ -110,8 +158,18 @@ function activeJobExists(jobId: string): boolean {
|
|
|
110
158
|
const path = join(ORACLE_JOBS_DIR, `oracle-${jobId}`, "job.json");
|
|
111
159
|
if (!existsSync(path)) return false;
|
|
112
160
|
try {
|
|
113
|
-
const job = JSON.parse(readFileSync(path, "utf8")) as {
|
|
114
|
-
|
|
161
|
+
const job = JSON.parse(readFileSync(path, "utf8")) as {
|
|
162
|
+
status?: string;
|
|
163
|
+
cleanupPending?: unknown;
|
|
164
|
+
workerPid?: unknown;
|
|
165
|
+
workerStartedAt?: unknown;
|
|
166
|
+
};
|
|
167
|
+
return jobBlocksAdmission({
|
|
168
|
+
status: typeof job.status === "string" ? job.status : undefined,
|
|
169
|
+
cleanupPending: job.cleanupPending === true,
|
|
170
|
+
workerPid: typeof job.workerPid === "number" ? job.workerPid : undefined,
|
|
171
|
+
workerStartedAt: typeof job.workerStartedAt === "string" ? job.workerStartedAt : undefined,
|
|
172
|
+
}, isTrackedProcessAlive);
|
|
115
173
|
} catch {
|
|
116
174
|
return false;
|
|
117
175
|
}
|
|
@@ -198,31 +256,62 @@ function profileCloneArgs(config: OracleConfig, sourceDir: string, destinationDi
|
|
|
198
256
|
return ["-R", sourceDir, destinationDir];
|
|
199
257
|
}
|
|
200
258
|
|
|
201
|
-
async function spawnCp(args: string[]): Promise<void> {
|
|
259
|
+
async function spawnCp(args: string[], options?: { timeoutMs?: number }): Promise<void> {
|
|
202
260
|
await new Promise<void>((resolve, reject) => {
|
|
203
261
|
const child = spawn("cp", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
204
262
|
let stderr = "";
|
|
263
|
+
let timedOut = false;
|
|
264
|
+
let killTimer: NodeJS.Timeout | undefined;
|
|
265
|
+
let killGraceTimer: NodeJS.Timeout | undefined;
|
|
266
|
+
|
|
267
|
+
const clearTimers = () => {
|
|
268
|
+
if (killTimer) clearTimeout(killTimer);
|
|
269
|
+
if (killGraceTimer) clearTimeout(killGraceTimer);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
if ((options?.timeoutMs ?? 0) > 0) {
|
|
273
|
+
killTimer = setTimeout(() => {
|
|
274
|
+
timedOut = true;
|
|
275
|
+
child.kill("SIGTERM");
|
|
276
|
+
killGraceTimer = setTimeout(() => {
|
|
277
|
+
child.kill("SIGKILL");
|
|
278
|
+
}, ORACLE_SUBPROCESS_KILL_GRACE_MS);
|
|
279
|
+
killGraceTimer.unref?.();
|
|
280
|
+
}, options?.timeoutMs);
|
|
281
|
+
killTimer.unref?.();
|
|
282
|
+
}
|
|
283
|
+
|
|
205
284
|
child.stderr.on("data", (data) => {
|
|
206
285
|
stderr += String(data);
|
|
207
286
|
});
|
|
208
|
-
child.on("error",
|
|
287
|
+
child.on("error", (error) => {
|
|
288
|
+
clearTimers();
|
|
289
|
+
reject(error);
|
|
290
|
+
});
|
|
209
291
|
child.on("close", (code) => {
|
|
292
|
+
clearTimers();
|
|
293
|
+
if (timedOut) {
|
|
294
|
+
reject(new Error(stderr || `cp timed out after ${options?.timeoutMs}ms`));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
210
297
|
if (code === 0) resolve();
|
|
211
298
|
else reject(new Error(stderr || `cp exited with code ${code}`));
|
|
212
299
|
});
|
|
213
300
|
});
|
|
214
301
|
}
|
|
215
302
|
|
|
216
|
-
export async function cloneSeedProfileToRuntime(
|
|
303
|
+
export async function cloneSeedProfileToRuntime(
|
|
304
|
+
config: OracleConfig,
|
|
305
|
+
runtimeProfileDir: string,
|
|
306
|
+
options?: { cpTimeoutMs?: number },
|
|
307
|
+
): Promise<string | undefined> {
|
|
217
308
|
const seedDir = config.browser.authSeedProfileDir;
|
|
218
|
-
|
|
219
|
-
throw new Error(`Oracle auth seed profile not found: ${seedDir}. Run /oracle-auth first.`);
|
|
220
|
-
}
|
|
309
|
+
await assertOracleAuthSeedProfileReady(config);
|
|
221
310
|
|
|
222
311
|
await withAuthLock({ runtimeProfileDir, seedDir }, async () => {
|
|
223
312
|
await rm(runtimeProfileDir, { recursive: true, force: true }).catch(() => undefined);
|
|
224
313
|
await mkdir(dirname(runtimeProfileDir), { recursive: true, mode: 0o700 }).catch(() => undefined);
|
|
225
|
-
await spawnCp(profileCloneArgs(config, seedDir, runtimeProfileDir));
|
|
314
|
+
await spawnCp(profileCloneArgs(config, seedDir, runtimeProfileDir), { timeoutMs: options?.cpTimeoutMs ?? PROFILE_CLONE_TIMEOUT_MS });
|
|
226
315
|
});
|
|
227
316
|
|
|
228
317
|
return getSeedGeneration(config);
|
|
@@ -286,9 +375,6 @@ export async function cleanupRuntimeArtifacts(runtime: {
|
|
|
286
375
|
report.warnings.push(`Failed to remove runtime profile ${runtime.runtimeProfileDir}: ${error.message}`);
|
|
287
376
|
});
|
|
288
377
|
}
|
|
289
|
-
if (report.warnings.length > 0) {
|
|
290
|
-
return report;
|
|
291
|
-
}
|
|
292
378
|
if (runtime.conversationId) {
|
|
293
379
|
report.attempted.push("conversationLease");
|
|
294
380
|
}
|
|
@@ -305,6 +391,3 @@ export async function cleanupRuntimeArtifacts(runtime: {
|
|
|
305
391
|
return report;
|
|
306
392
|
}
|
|
307
393
|
|
|
308
|
-
export function stableProjectLabel(projectId: string): string {
|
|
309
|
-
return basename(projectId) || createHash("sha256").update(projectId).digest("hex").slice(0, 8);
|
|
310
|
-
}
|