pi-oracle 0.3.3 → 0.4.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 +33 -0
- package/README.md +7 -0
- package/docs/ORACLE_DESIGN.md +1 -1
- package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +249 -0
- package/docs/ORACLE_RECOVERY_DRILL.md +5 -4
- package/extensions/oracle/index.ts +8 -1
- package/extensions/oracle/lib/commands.ts +11 -24
- package/extensions/oracle/lib/config.ts +5 -0
- package/extensions/oracle/lib/jobs.ts +117 -217
- package/extensions/oracle/lib/locks.ts +41 -209
- package/extensions/oracle/lib/poller.ts +14 -51
- package/extensions/oracle/lib/queue.ts +75 -112
- package/extensions/oracle/lib/runtime.ts +60 -14
- package/extensions/oracle/lib/tools.ts +70 -67
- 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 +130 -0
- package/extensions/oracle/shared/job-lifecycle-helpers.mjs +377 -0
- package/extensions/oracle/shared/job-observability-helpers.d.mts +59 -0
- package/extensions/oracle/shared/job-observability-helpers.mjs +143 -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 +100 -139
- 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.d.mts +33 -0
- package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +292 -0
- package/extensions/oracle/worker/run-job.mjs +235 -380
- package/extensions/oracle/worker/state-locks.mjs +31 -216
- package/package.json +14 -5
- package/prompts/oracle.md +1 -1
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// Purpose: Provide shared atomic lock/lease state helpers for oracle coordination across extension and worker processes.
|
|
2
|
+
// Responsibilities: Create lock/lease directories atomically, publish metadata safely, reclaim stale incomplete state, and enumerate lease metadata.
|
|
3
|
+
// Scope: Filesystem-backed concurrency primitives only; higher-level admission and queue behavior stays in wrapper modules.
|
|
4
|
+
// Usage: Imported by lib/locks.ts and worker/state-locks.mjs so both layers share identical crash-recovery semantics.
|
|
5
|
+
// Invariants/Assumptions: State lives under a private per-machine directory, and final published state dirs must never appear without complete metadata.
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
9
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
10
|
+
import { basename, join } from "node:path";
|
|
11
|
+
import { isProcessAlive } from "./process-helpers.mjs";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_WAIT_MS = 30_000;
|
|
14
|
+
const POLL_MS = 200;
|
|
15
|
+
export const ORACLE_METADATA_WRITE_GRACE_MS = 1_000;
|
|
16
|
+
/** Incomplete `.tmp-*` dirs are in-flight atomic creates; a 1s grace is too short under multi-process sweep + slow FS. */
|
|
17
|
+
export const ORACLE_TMP_STATE_DIR_GRACE_MS = 60_000;
|
|
18
|
+
|
|
19
|
+
function sleep(ms) {
|
|
20
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} path
|
|
25
|
+
* @returns {Promise<void>}
|
|
26
|
+
*/
|
|
27
|
+
async function ensurePrivateDir(path) {
|
|
28
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
29
|
+
await chmod(path, 0o700).catch(() => undefined);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} kind
|
|
34
|
+
* @param {string} key
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function hashOracleStateKey(kind, key) {
|
|
38
|
+
return `${kind}-${createHash("sha256").update(key).digest("hex").slice(0, 24)}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} stateDir
|
|
43
|
+
* @returns {string}
|
|
44
|
+
*/
|
|
45
|
+
export function getStateLocksDir(stateDir) {
|
|
46
|
+
return join(stateDir, "locks");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} stateDir
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function getStateLeasesDir(stateDir) {
|
|
54
|
+
return join(stateDir, "leases");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {string} stateDir
|
|
59
|
+
* @param {string} kind
|
|
60
|
+
* @param {string} key
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
function lockPath(stateDir, kind, key) {
|
|
64
|
+
return join(getStateLocksDir(stateDir), hashOracleStateKey(kind, key));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {string} stateDir
|
|
69
|
+
* @param {string} kind
|
|
70
|
+
* @param {string} key
|
|
71
|
+
* @returns {string}
|
|
72
|
+
*/
|
|
73
|
+
function leasePath(stateDir, kind, key) {
|
|
74
|
+
return join(getStateLeasesDir(stateDir), hashOracleStateKey(kind, key));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param {string} path
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
function getMetadataPath(path) {
|
|
82
|
+
return join(path, "metadata.json");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} path
|
|
87
|
+
* @param {unknown} metadata
|
|
88
|
+
* @returns {Promise<void>}
|
|
89
|
+
*/
|
|
90
|
+
async function writeMetadata(path, metadata) {
|
|
91
|
+
const targetPath = getMetadataPath(path);
|
|
92
|
+
const tempPath = join(path, `metadata.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
|
|
93
|
+
await writeFile(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
94
|
+
await chmod(tempPath, 0o600).catch(() => undefined);
|
|
95
|
+
await rename(tempPath, targetPath);
|
|
96
|
+
await chmod(targetPath, 0o600).catch(() => undefined);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {string} parentDir
|
|
101
|
+
* @param {string} finalPath
|
|
102
|
+
* @param {unknown} metadata
|
|
103
|
+
* @returns {Promise<void>}
|
|
104
|
+
*/
|
|
105
|
+
async function createStateDirAtomically(parentDir, finalPath, metadata) {
|
|
106
|
+
const tempPath = join(parentDir, `.tmp-${basename(finalPath)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`);
|
|
107
|
+
await mkdir(tempPath, { recursive: false, mode: 0o700 });
|
|
108
|
+
try {
|
|
109
|
+
await writeMetadata(tempPath, metadata);
|
|
110
|
+
await rename(tempPath, finalPath);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
await rm(tempPath, { recursive: true, force: true }).catch(() => undefined);
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* @param {string} path
|
|
119
|
+
* @returns {"present" | "missing" | "invalid"}
|
|
120
|
+
*/
|
|
121
|
+
function getMetadataState(path) {
|
|
122
|
+
const metadataPath = getMetadataPath(path);
|
|
123
|
+
if (!existsSync(metadataPath)) return "missing";
|
|
124
|
+
try {
|
|
125
|
+
JSON.parse(readFileSync(metadataPath, "utf8"));
|
|
126
|
+
return "present";
|
|
127
|
+
} catch {
|
|
128
|
+
return "invalid";
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {string} path
|
|
134
|
+
* @param {number} [now]
|
|
135
|
+
* @returns {boolean}
|
|
136
|
+
*/
|
|
137
|
+
function isIncompleteStateDirStale(path, now = Date.now()) {
|
|
138
|
+
try {
|
|
139
|
+
const stats = statSync(path);
|
|
140
|
+
const baselineMs = Math.max(stats.mtimeMs, stats.ctimeMs);
|
|
141
|
+
const graceMs = basename(path).startsWith(".tmp-") ? ORACLE_TMP_STATE_DIR_GRACE_MS : ORACLE_METADATA_WRITE_GRACE_MS;
|
|
142
|
+
return now - baselineMs >= graceMs;
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {string} path
|
|
150
|
+
* @returns {number | undefined}
|
|
151
|
+
*/
|
|
152
|
+
function readLockProcessPid(path) {
|
|
153
|
+
const metadataPath = getMetadataPath(path);
|
|
154
|
+
if (!existsSync(metadataPath)) return undefined;
|
|
155
|
+
try {
|
|
156
|
+
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
|
|
157
|
+
return typeof metadata?.processPid === "number" && Number.isInteger(metadata.processPid) && metadata.processPid > 0
|
|
158
|
+
? metadata.processPid
|
|
159
|
+
: undefined;
|
|
160
|
+
} catch {
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {unknown} error
|
|
167
|
+
* @returns {boolean}
|
|
168
|
+
*/
|
|
169
|
+
function isStateDirExistsError(error) {
|
|
170
|
+
return Boolean(error && typeof error === "object" && "code" in error && (error.code === "EEXIST" || error.code === "ENOTEMPTY"));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* @param {string} path
|
|
175
|
+
* @param {number} [now]
|
|
176
|
+
* @returns {Promise<boolean>}
|
|
177
|
+
*/
|
|
178
|
+
async function maybeReclaimIncompleteStateDir(path, now = Date.now()) {
|
|
179
|
+
if (getMetadataState(path) === "present") return false;
|
|
180
|
+
if (!isIncompleteStateDirStale(path, now)) return false;
|
|
181
|
+
await rm(path, { recursive: true, force: true }).catch(() => undefined);
|
|
182
|
+
return true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {string} path
|
|
187
|
+
* @param {number} [now]
|
|
188
|
+
* @returns {Promise<boolean>}
|
|
189
|
+
*/
|
|
190
|
+
async function maybeReclaimStaleLock(path, now = Date.now()) {
|
|
191
|
+
if (await maybeReclaimIncompleteStateDir(path, now)) return true;
|
|
192
|
+
const processPid = readLockProcessPid(path);
|
|
193
|
+
if (!processPid || isProcessAlive(processPid)) return false;
|
|
194
|
+
await rm(path, { recursive: true, force: true }).catch(() => undefined);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @param {string} stateDir
|
|
200
|
+
* @param {number} [now]
|
|
201
|
+
* @returns {Promise<string[]>}
|
|
202
|
+
*/
|
|
203
|
+
export async function sweepStaleStateLocks(stateDir, now = Date.now()) {
|
|
204
|
+
const dir = getStateLocksDir(stateDir);
|
|
205
|
+
if (!existsSync(dir)) return [];
|
|
206
|
+
const removed = [];
|
|
207
|
+
for (const name of readdirSync(dir)) {
|
|
208
|
+
const path = join(dir, name);
|
|
209
|
+
if (await maybeReclaimStaleLock(path, now)) {
|
|
210
|
+
removed.push(path);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return removed;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* @param {string} stateDir
|
|
218
|
+
* @param {string} kind
|
|
219
|
+
* @param {string} key
|
|
220
|
+
* @param {unknown} metadata
|
|
221
|
+
* @param {number} [timeoutMs]
|
|
222
|
+
* @returns {Promise<string>}
|
|
223
|
+
*/
|
|
224
|
+
export async function acquireStateLock(stateDir, kind, key, metadata, timeoutMs = DEFAULT_WAIT_MS) {
|
|
225
|
+
const parentDir = getStateLocksDir(stateDir);
|
|
226
|
+
const path = join(parentDir, hashOracleStateKey(kind, key));
|
|
227
|
+
const deadline = Date.now() + timeoutMs;
|
|
228
|
+
await ensurePrivateDir(stateDir);
|
|
229
|
+
await ensurePrivateDir(parentDir);
|
|
230
|
+
|
|
231
|
+
while (Date.now() < deadline) {
|
|
232
|
+
try {
|
|
233
|
+
await createStateDirAtomically(parentDir, path, metadata);
|
|
234
|
+
return path;
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (!isStateDirExistsError(error)) throw error;
|
|
237
|
+
if (await maybeReclaimStaleLock(path)) continue;
|
|
238
|
+
}
|
|
239
|
+
await sleep(POLL_MS);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
throw new Error(`Timed out waiting for oracle ${kind} lock: ${key}`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* @param {string | undefined} path
|
|
247
|
+
* @returns {Promise<void>}
|
|
248
|
+
*/
|
|
249
|
+
export async function releaseStatePath(path) {
|
|
250
|
+
if (!path) return;
|
|
251
|
+
await rm(path, { recursive: true, force: true }).catch(() => undefined);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @template T
|
|
256
|
+
* @param {string} stateDir
|
|
257
|
+
* @param {string} kind
|
|
258
|
+
* @param {string} key
|
|
259
|
+
* @param {unknown} metadata
|
|
260
|
+
* @param {() => Promise<T>} fn
|
|
261
|
+
* @param {number} [timeoutMs]
|
|
262
|
+
* @returns {Promise<T>}
|
|
263
|
+
*/
|
|
264
|
+
export async function withStateLock(stateDir, kind, key, metadata, fn, timeoutMs = DEFAULT_WAIT_MS) {
|
|
265
|
+
const handle = await acquireStateLock(stateDir, kind, key, metadata, timeoutMs);
|
|
266
|
+
try {
|
|
267
|
+
return await fn();
|
|
268
|
+
} finally {
|
|
269
|
+
await releaseStatePath(handle);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @param {string} stateDir
|
|
275
|
+
* @param {string} kind
|
|
276
|
+
* @param {string} key
|
|
277
|
+
* @param {unknown} metadata
|
|
278
|
+
* @param {number} [timeoutMs]
|
|
279
|
+
* @returns {Promise<string>}
|
|
280
|
+
*/
|
|
281
|
+
export async function createStateLease(stateDir, kind, key, metadata, timeoutMs = DEFAULT_WAIT_MS) {
|
|
282
|
+
const parentDir = getStateLeasesDir(stateDir);
|
|
283
|
+
const path = join(parentDir, hashOracleStateKey(kind, key));
|
|
284
|
+
const deadline = Date.now() + timeoutMs;
|
|
285
|
+
await ensurePrivateDir(stateDir);
|
|
286
|
+
await ensurePrivateDir(parentDir);
|
|
287
|
+
|
|
288
|
+
while (Date.now() < deadline) {
|
|
289
|
+
try {
|
|
290
|
+
await createStateDirAtomically(parentDir, path, metadata);
|
|
291
|
+
return path;
|
|
292
|
+
} catch (error) {
|
|
293
|
+
if (!isStateDirExistsError(error)) throw error;
|
|
294
|
+
if (await maybeReclaimIncompleteStateDir(path)) continue;
|
|
295
|
+
if (getMetadataState(path) === "present") throw error;
|
|
296
|
+
}
|
|
297
|
+
await sleep(POLL_MS);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
throw new Error(`Timed out waiting for oracle ${kind} lease: ${key}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* @param {string} stateDir
|
|
305
|
+
* @param {string} kind
|
|
306
|
+
* @param {string} key
|
|
307
|
+
* @param {unknown} metadata
|
|
308
|
+
* @returns {Promise<string>}
|
|
309
|
+
*/
|
|
310
|
+
export async function writeStateLeaseMetadata(stateDir, kind, key, metadata) {
|
|
311
|
+
const parentDir = getStateLeasesDir(stateDir);
|
|
312
|
+
const path = join(parentDir, hashOracleStateKey(kind, key));
|
|
313
|
+
await ensurePrivateDir(stateDir);
|
|
314
|
+
await ensurePrivateDir(parentDir);
|
|
315
|
+
if (existsSync(path)) {
|
|
316
|
+
await chmod(path, 0o700).catch(() => undefined);
|
|
317
|
+
await writeMetadata(path, metadata);
|
|
318
|
+
return path;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
await createStateDirAtomically(parentDir, path, metadata);
|
|
322
|
+
} catch (error) {
|
|
323
|
+
if (!isStateDirExistsError(error)) throw error;
|
|
324
|
+
if (await maybeReclaimIncompleteStateDir(path)) {
|
|
325
|
+
await createStateDirAtomically(parentDir, path, metadata);
|
|
326
|
+
} else {
|
|
327
|
+
await writeMetadata(path, metadata);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return path;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* @template T
|
|
335
|
+
* @param {string} stateDir
|
|
336
|
+
* @param {string} kind
|
|
337
|
+
* @param {string} key
|
|
338
|
+
* @returns {Promise<T | undefined>}
|
|
339
|
+
*/
|
|
340
|
+
export async function readStateLeaseMetadata(stateDir, kind, key) {
|
|
341
|
+
const path = getMetadataPath(leasePath(stateDir, kind, key));
|
|
342
|
+
if (!existsSync(path)) return undefined;
|
|
343
|
+
try {
|
|
344
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
345
|
+
} catch {
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* @template T
|
|
352
|
+
* @param {string} stateDir
|
|
353
|
+
* @param {string} kind
|
|
354
|
+
* @returns {T[]}
|
|
355
|
+
*/
|
|
356
|
+
export function listStateLeaseMetadata(stateDir, kind) {
|
|
357
|
+
const dir = getStateLeasesDir(stateDir);
|
|
358
|
+
if (!existsSync(dir)) return [];
|
|
359
|
+
return readdirSync(dir)
|
|
360
|
+
.filter((name) => name.startsWith(`${kind}-`))
|
|
361
|
+
.map((name) => join(dir, name, "metadata.json"))
|
|
362
|
+
.filter((path) => existsSync(path))
|
|
363
|
+
.flatMap((path) => {
|
|
364
|
+
try {
|
|
365
|
+
return [JSON.parse(readFileSync(path, "utf8"))];
|
|
366
|
+
} catch {
|
|
367
|
+
return [];
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* @param {string} stateDir
|
|
374
|
+
* @param {string} kind
|
|
375
|
+
* @param {string | undefined} key
|
|
376
|
+
* @returns {Promise<void>}
|
|
377
|
+
*/
|
|
378
|
+
export async function releaseStateLease(stateDir, kind, key) {
|
|
379
|
+
if (!key) return;
|
|
380
|
+
await rm(leasePath(stateDir, kind, key), { recursive: true, force: true }).catch(() => undefined);
|
|
381
|
+
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// Purpose: Parse agent-browser snapshots and infer likely downloadable artifact labels from response UI structure.
|
|
2
|
+
// Responsibilities: Extract snapshot entries, detect candidate filenames, and partition strong versus suspicious artifact signals.
|
|
3
|
+
// Scope: Pure heuristic parsing only; browser interaction and artifact download orchestration stay in the worker.
|
|
4
|
+
// Usage: Imported by worker runtime code, UI helper modules, and sanity tests to keep artifact detection behavior deterministic.
|
|
5
|
+
// Invariants/Assumptions: Snapshot text comes from agent-browser `snapshot -i`, and heuristics should prefer false negatives over noisy false positives.
|
|
1
6
|
export const FILE_LABEL_PATTERN_SOURCE = String.raw`(?:^|[^A-Za-z0-9._~/-])((?:(?:[A-Za-z]:)?[\\/]|[.~][\\/])?(?:[^\\/\s"'<>|]+[\\/])*[^\\/\s"'<>|]+\.[A-Za-z0-9]{1,12})(?=$|[^A-Za-z0-9._~/-])`;
|
|
2
7
|
const FILE_LABEL_PATTERN = new RegExp(FILE_LABEL_PATTERN_SOURCE, "g");
|
|
3
8
|
export const GENERIC_ARTIFACT_LABELS = ["ATTACHED", "DONE"];
|