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.
Files changed (35) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +27 -8
  3. package/docs/ORACLE_DESIGN.md +14 -8
  4. package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +276 -0
  5. package/extensions/oracle/index.ts +8 -1
  6. package/extensions/oracle/lib/commands.ts +25 -29
  7. package/extensions/oracle/lib/config.ts +56 -2
  8. package/extensions/oracle/lib/jobs.ts +134 -219
  9. package/extensions/oracle/lib/locks.ts +41 -209
  10. package/extensions/oracle/lib/poller.ts +38 -52
  11. package/extensions/oracle/lib/queue.ts +75 -112
  12. package/extensions/oracle/lib/runtime.ts +102 -19
  13. package/extensions/oracle/lib/tools.ts +663 -294
  14. package/extensions/oracle/shared/job-coordination-helpers.d.mts +84 -0
  15. package/extensions/oracle/shared/job-coordination-helpers.mjs +168 -0
  16. package/extensions/oracle/shared/job-lifecycle-helpers.d.mts +131 -0
  17. package/extensions/oracle/shared/job-lifecycle-helpers.mjs +390 -0
  18. package/extensions/oracle/shared/job-observability-helpers.d.mts +60 -0
  19. package/extensions/oracle/shared/job-observability-helpers.mjs +161 -0
  20. package/extensions/oracle/shared/process-helpers.d.mts +20 -0
  21. package/extensions/oracle/shared/process-helpers.mjs +128 -0
  22. package/extensions/oracle/shared/state-coordination-helpers.d.mts +43 -0
  23. package/extensions/oracle/shared/state-coordination-helpers.mjs +381 -0
  24. package/extensions/oracle/worker/artifact-heuristics.mjs +5 -0
  25. package/extensions/oracle/worker/auth-bootstrap.mjs +125 -134
  26. package/extensions/oracle/worker/auth-cookie-policy.mjs +5 -0
  27. package/extensions/oracle/worker/auth-flow-helpers.d.mts +41 -0
  28. package/extensions/oracle/worker/auth-flow-helpers.mjs +165 -0
  29. package/extensions/oracle/worker/chatgpt-flow-helpers.d.mts +13 -0
  30. package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +85 -0
  31. package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +93 -9
  32. package/extensions/oracle/worker/run-job.mjs +166 -274
  33. package/extensions/oracle/worker/state-locks.mjs +31 -216
  34. package/package.json +4 -3
  35. package/prompts/oracle.md +16 -10
@@ -1,8 +1,15 @@
1
- import { randomUUID, createHash } from "node:crypto";
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 { basename, dirname, join } from "node:path";
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 { status?: string; cleanupWarnings?: unknown; cleanupPending?: unknown };
114
- return ["preparing", "submitted", "waiting"].includes(job.status || "") || job.cleanupPending === true || (Array.isArray(job.cleanupWarnings) && job.cleanupWarnings.length > 0);
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", reject);
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(config: OracleConfig, runtimeProfileDir: string): Promise<string | undefined> {
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
- if (!existsSync(seedDir)) {
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
- }