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.
Files changed (37) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +7 -0
  3. package/docs/ORACLE_DESIGN.md +1 -1
  4. package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +249 -0
  5. package/docs/ORACLE_RECOVERY_DRILL.md +5 -4
  6. package/extensions/oracle/index.ts +8 -1
  7. package/extensions/oracle/lib/commands.ts +11 -24
  8. package/extensions/oracle/lib/config.ts +5 -0
  9. package/extensions/oracle/lib/jobs.ts +117 -217
  10. package/extensions/oracle/lib/locks.ts +41 -209
  11. package/extensions/oracle/lib/poller.ts +14 -51
  12. package/extensions/oracle/lib/queue.ts +75 -112
  13. package/extensions/oracle/lib/runtime.ts +60 -14
  14. package/extensions/oracle/lib/tools.ts +70 -67
  15. package/extensions/oracle/shared/job-coordination-helpers.d.mts +84 -0
  16. package/extensions/oracle/shared/job-coordination-helpers.mjs +168 -0
  17. package/extensions/oracle/shared/job-lifecycle-helpers.d.mts +130 -0
  18. package/extensions/oracle/shared/job-lifecycle-helpers.mjs +377 -0
  19. package/extensions/oracle/shared/job-observability-helpers.d.mts +59 -0
  20. package/extensions/oracle/shared/job-observability-helpers.mjs +143 -0
  21. package/extensions/oracle/shared/process-helpers.d.mts +20 -0
  22. package/extensions/oracle/shared/process-helpers.mjs +128 -0
  23. package/extensions/oracle/shared/state-coordination-helpers.d.mts +43 -0
  24. package/extensions/oracle/shared/state-coordination-helpers.mjs +381 -0
  25. package/extensions/oracle/worker/artifact-heuristics.mjs +5 -0
  26. package/extensions/oracle/worker/auth-bootstrap.mjs +100 -139
  27. package/extensions/oracle/worker/auth-cookie-policy.mjs +5 -0
  28. package/extensions/oracle/worker/auth-flow-helpers.d.mts +41 -0
  29. package/extensions/oracle/worker/auth-flow-helpers.mjs +165 -0
  30. package/extensions/oracle/worker/chatgpt-flow-helpers.d.mts +13 -0
  31. package/extensions/oracle/worker/chatgpt-flow-helpers.mjs +85 -0
  32. package/extensions/oracle/worker/chatgpt-ui-helpers.d.mts +33 -0
  33. package/extensions/oracle/worker/chatgpt-ui-helpers.mjs +292 -0
  34. package/extensions/oracle/worker/run-job.mjs +235 -380
  35. package/extensions/oracle/worker/state-locks.mjs +31 -216
  36. package/package.json +14 -5
  37. package/prompts/oracle.md +1 -1
@@ -1,237 +1,52 @@
1
- import { createHash } from "node:crypto";
2
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
- import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
- import { basename, join } from "node:path";
5
-
6
- const DEFAULT_WAIT_MS = 30_000;
7
- const POLL_MS = 200;
8
- export const ORACLE_METADATA_WRITE_GRACE_MS = 1_000;
9
- export const ORACLE_TMP_STATE_DIR_GRACE_MS = 60_000;
10
-
11
- async function sleep(ms) {
12
- await new Promise((resolve) => setTimeout(resolve, ms));
13
- }
14
-
15
- async function ensurePrivateDir(path) {
16
- await mkdir(path, { recursive: true, mode: 0o700 });
17
- await chmod(path, 0o700).catch(() => undefined);
18
- }
19
-
20
- function leaseKey(kind, key) {
21
- return `${kind}-${createHash("sha256").update(key).digest("hex").slice(0, 24)}`;
22
- }
23
-
24
- function getLocksDir(stateDir) {
25
- return join(stateDir, "locks");
26
- }
27
-
28
- function getLeasesDir(stateDir) {
29
- return join(stateDir, "leases");
30
- }
31
-
32
- function lockPath(stateDir, kind, key) {
33
- return join(getLocksDir(stateDir), leaseKey(kind, key));
34
- }
35
-
36
- function leasePath(stateDir, kind, key) {
37
- return join(getLeasesDir(stateDir), leaseKey(kind, key));
38
- }
39
-
40
- function getMetadataPath(path) {
41
- return join(path, "metadata.json");
42
- }
43
-
44
- async function writeMetadata(path, metadata) {
45
- const targetPath = getMetadataPath(path);
46
- const tempPath = join(path, `metadata.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`);
47
- await writeFile(tempPath, `${JSON.stringify(metadata, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
48
- await chmod(tempPath, 0o600).catch(() => undefined);
49
- await rename(tempPath, targetPath);
50
- await chmod(targetPath, 0o600).catch(() => undefined);
51
- }
52
-
53
- async function createStateDirAtomically(parentDir, finalPath, metadata) {
54
- const tempPath = join(parentDir, `.tmp-${basename(finalPath)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}`);
55
- await mkdir(tempPath, { recursive: false, mode: 0o700 });
56
- try {
57
- await writeMetadata(tempPath, metadata);
58
- await rename(tempPath, finalPath);
59
- } catch (error) {
60
- await rm(tempPath, { recursive: true, force: true }).catch(() => undefined);
61
- throw error;
62
- }
63
- }
64
-
65
- function getMetadataState(path) {
66
- const metadataPath = getMetadataPath(path);
67
- if (!existsSync(metadataPath)) return "missing";
68
- try {
69
- JSON.parse(readFileSync(metadataPath, "utf8"));
70
- return "present";
71
- } catch {
72
- return "invalid";
73
- }
74
- }
75
-
76
- function isIncompleteStateDirStale(path, now = Date.now()) {
77
- try {
78
- const stats = statSync(path);
79
- const baselineMs = Math.max(stats.mtimeMs, stats.ctimeMs);
80
- const graceMs = basename(path).startsWith(".tmp-") ? ORACLE_TMP_STATE_DIR_GRACE_MS : ORACLE_METADATA_WRITE_GRACE_MS;
81
- return now - baselineMs >= graceMs;
82
- } catch {
83
- return false;
84
- }
85
- }
86
-
87
- function isProcessAlive(pid) {
88
- try {
89
- process.kill(pid, 0);
90
- return true;
91
- } catch (error) {
92
- if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false;
93
- return true;
94
- }
95
- }
96
-
97
- function isStateDirExistsError(error) {
98
- return Boolean(error && typeof error === "object" && "code" in error && (error.code === "EEXIST" || error.code === "ENOTEMPTY"));
99
- }
100
-
101
- async function readLockProcessPid(path) {
102
- const metadataPath = getMetadataPath(path);
103
- if (!existsSync(metadataPath)) return undefined;
104
- try {
105
- const metadata = JSON.parse(await readFile(metadataPath, "utf8"));
106
- return typeof metadata?.processPid === "number" && Number.isInteger(metadata.processPid) && metadata.processPid > 0
107
- ? metadata.processPid
108
- : undefined;
109
- } catch {
110
- return undefined;
111
- }
112
- }
113
-
114
- async function maybeReclaimIncompleteStateDir(path, now = Date.now()) {
115
- if (getMetadataState(path) === "present") return false;
116
- if (!isIncompleteStateDirStale(path, now)) return false;
117
- await rm(path, { recursive: true, force: true }).catch(() => undefined);
118
- return true;
119
- }
120
-
121
- async function maybeReclaimStaleLock(path, now = Date.now()) {
122
- if (await maybeReclaimIncompleteStateDir(path, now)) return true;
123
- const processPid = await readLockProcessPid(path);
124
- if (!processPid || isProcessAlive(processPid)) return false;
125
- await rm(path, { recursive: true, force: true }).catch(() => undefined);
126
- return true;
127
- }
128
-
129
- export async function acquireLock(stateDir, kind, key, metadata, timeoutMs = DEFAULT_WAIT_MS) {
130
- const parentDir = getLocksDir(stateDir);
131
- const path = join(parentDir, leaseKey(kind, key));
132
- const deadline = Date.now() + timeoutMs;
133
- await ensurePrivateDir(stateDir);
134
- await ensurePrivateDir(parentDir);
135
-
136
- while (Date.now() < deadline) {
137
- try {
138
- await createStateDirAtomically(parentDir, path, metadata);
139
- return path;
140
- } catch (error) {
141
- if (!isStateDirExistsError(error)) throw error;
142
- if (await maybeReclaimStaleLock(path)) continue;
143
- }
144
- await sleep(POLL_MS);
145
- }
146
-
147
- throw new Error(`Timed out waiting for oracle ${kind} lock: ${key}`);
1
+ // Purpose: Provide worker-facing wrappers around the shared oracle state lock/lease coordination helpers.
2
+ // Responsibilities: Bind shared state helpers to the worker call signatures and re-export crash-recovery constants.
3
+ // Scope: Worker wrapper only; atomic lock/lease behavior lives in shared/state-coordination-helpers.mjs.
4
+ // Usage: Imported by worker entrypoints that need per-state-dir locks or lease metadata persistence.
5
+ // Invariants/Assumptions: Callers pass the oracle state directory explicitly so worker tests can isolate state safely.
6
+
7
+ import {
8
+ acquireStateLock,
9
+ createStateLease,
10
+ listStateLeaseMetadata,
11
+ ORACLE_METADATA_WRITE_GRACE_MS,
12
+ ORACLE_TMP_STATE_DIR_GRACE_MS,
13
+ readStateLeaseMetadata,
14
+ releaseStateLease,
15
+ releaseStatePath,
16
+ withStateLock,
17
+ writeStateLeaseMetadata,
18
+ } from "../shared/state-coordination-helpers.mjs";
19
+
20
+ export { ORACLE_METADATA_WRITE_GRACE_MS, ORACLE_TMP_STATE_DIR_GRACE_MS };
21
+
22
+ export async function acquireLock(stateDir, kind, key, metadata, timeoutMs) {
23
+ return acquireStateLock(stateDir, kind, key, metadata, timeoutMs);
148
24
  }
149
25
 
150
26
  export async function releaseLock(path) {
151
- if (!path) return;
152
- await rm(path, { recursive: true, force: true }).catch(() => undefined);
27
+ await releaseStatePath(path);
153
28
  }
154
29
 
155
30
  export async function withLock(stateDir, kind, key, metadata, fn, timeoutMs) {
156
- const handle = await acquireLock(stateDir, kind, key, metadata, timeoutMs);
157
- try {
158
- return await fn();
159
- } finally {
160
- await releaseLock(handle);
161
- }
31
+ return withStateLock(stateDir, kind, key, metadata, fn, timeoutMs);
162
32
  }
163
33
 
164
- export async function createLease(stateDir, kind, key, metadata, timeoutMs = DEFAULT_WAIT_MS) {
165
- const parentDir = getLeasesDir(stateDir);
166
- const path = join(parentDir, leaseKey(kind, key));
167
- const deadline = Date.now() + timeoutMs;
168
- await ensurePrivateDir(stateDir);
169
- await ensurePrivateDir(parentDir);
170
-
171
- while (Date.now() < deadline) {
172
- try {
173
- await createStateDirAtomically(parentDir, path, metadata);
174
- return path;
175
- } catch (error) {
176
- if (!isStateDirExistsError(error)) throw error;
177
- if (await maybeReclaimIncompleteStateDir(path)) continue;
178
- if (getMetadataState(path) === "present") throw error;
179
- }
180
- await sleep(POLL_MS);
181
- }
182
-
183
- throw new Error(`Timed out waiting for oracle ${kind} lease: ${key}`);
34
+ export async function createLease(stateDir, kind, key, metadata, timeoutMs) {
35
+ return createStateLease(stateDir, kind, key, metadata, timeoutMs);
184
36
  }
185
37
 
186
38
  export async function writeLeaseMetadata(stateDir, kind, key, metadata) {
187
- const parentDir = getLeasesDir(stateDir);
188
- const path = join(parentDir, leaseKey(kind, key));
189
- await ensurePrivateDir(stateDir);
190
- await ensurePrivateDir(parentDir);
191
- if (existsSync(path)) {
192
- await chmod(path, 0o700).catch(() => undefined);
193
- await writeMetadata(path, metadata);
194
- return path;
195
- }
196
- try {
197
- await createStateDirAtomically(parentDir, path, metadata);
198
- } catch (error) {
199
- if (!isStateDirExistsError(error)) throw error;
200
- if (await maybeReclaimIncompleteStateDir(path)) {
201
- await createStateDirAtomically(parentDir, path, metadata);
202
- } else {
203
- await writeMetadata(path, metadata);
204
- }
205
- }
206
- return path;
39
+ return writeStateLeaseMetadata(stateDir, kind, key, metadata);
207
40
  }
208
41
 
209
42
  export async function readLeaseMetadata(stateDir, kind, key) {
210
- const path = getMetadataPath(leasePath(stateDir, kind, key));
211
- if (!existsSync(path)) return undefined;
212
- try {
213
- return JSON.parse(await readFile(path, "utf8"));
214
- } catch {
215
- return undefined;
216
- }
43
+ return readStateLeaseMetadata(stateDir, kind, key);
217
44
  }
218
45
 
219
46
  export function listLeaseMetadata(stateDir, kind) {
220
- const dir = getLeasesDir(stateDir);
221
- if (!existsSync(dir)) return [];
222
- return readdirSync(dir)
223
- .filter((name) => name.startsWith(`${kind}-`))
224
- .map((name) => join(dir, name, "metadata.json"))
225
- .filter((path) => existsSync(path))
226
- .flatMap((path) => {
227
- try {
228
- return [JSON.parse(readFileSync(path, "utf8"))];
229
- } catch {
230
- return [];
231
- }
232
- });
47
+ return listStateLeaseMetadata(stateDir, kind);
233
48
  }
234
49
 
235
50
  export async function releaseLease(stateDir, kind, key) {
236
- await rm(leasePath(stateDir, kind, key), { recursive: true, force: true }).catch(() => undefined);
51
+ await releaseStateLease(stateDir, kind, key);
237
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-oracle",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
4
4
  "description": "ChatGPT web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
5
5
  "private": false,
6
6
  "license": "MIT",
@@ -42,16 +42,22 @@
42
42
  ]
43
43
  },
44
44
  "scripts": {
45
- "check:oracle-extension": "node --check extensions/oracle/worker/run-job.mjs && node --check extensions/oracle/worker/state-locks.mjs && node --check extensions/oracle/worker/artifact-heuristics.mjs && node --check extensions/oracle/worker/auth-cookie-policy.mjs && node --check extensions/oracle/worker/auth-bootstrap.mjs && esbuild extensions/oracle/index.ts --bundle --platform=node --format=esm --external:@mariozechner/pi-coding-agent --external:@mariozechner/pi-ai --external:@sinclair/typebox --outfile=/tmp/pi-oracle-extension-check.js",
45
+ "check:oracle-extension": "node --check extensions/oracle/shared/process-helpers.mjs && node --check extensions/oracle/shared/state-coordination-helpers.mjs && node --check extensions/oracle/shared/job-coordination-helpers.mjs && node --check extensions/oracle/shared/job-lifecycle-helpers.mjs && node --check extensions/oracle/shared/job-observability-helpers.mjs && node --check extensions/oracle/worker/run-job.mjs && node --check extensions/oracle/worker/state-locks.mjs && node --check extensions/oracle/worker/artifact-heuristics.mjs && node --check extensions/oracle/worker/chatgpt-ui-helpers.mjs && node --check extensions/oracle/worker/chatgpt-flow-helpers.mjs && node --check extensions/oracle/worker/auth-flow-helpers.mjs && node --check extensions/oracle/worker/auth-cookie-policy.mjs && node --check extensions/oracle/worker/auth-bootstrap.mjs && esbuild extensions/oracle/index.ts --bundle --platform=node --format=esm --external:@mariozechner/pi-coding-agent --external:@mariozechner/pi-ai --external:@sinclair/typebox --outfile=/tmp/pi-oracle-extension-check.js",
46
46
  "typecheck": "tsc --noEmit -p tsconfig.json",
47
+ "typecheck:worker-helpers": "tsc --noEmit -p tsconfig.worker-helpers.json",
47
48
  "sanity:oracle": "node scripts/oracle-sanity-runner.mjs",
48
49
  "pack:check": "npm pack --dry-run",
49
- "verify:oracle": "npm run check:oracle-extension && npm run typecheck && npm run sanity:oracle && npm run pack:check"
50
+ "verify:oracle": "npm run check:oracle-extension && npm run typecheck && npm run typecheck:worker-helpers && npm run sanity:oracle && npm run pack:check",
51
+ "test": "npm run verify:oracle",
52
+ "prepublishOnly": "npm run verify:oracle"
50
53
  },
51
54
  "dependencies": {
52
55
  "@sinclair/typebox": "^0.34.49",
53
56
  "@steipete/sweet-cookie": "^0.2.0"
54
57
  },
58
+ "overrides": {
59
+ "basic-ftp": "^5.2.2"
60
+ },
55
61
  "devDependencies": {
56
62
  "@mariozechner/pi-ai": "^0.65.2",
57
63
  "@mariozechner/pi-coding-agent": "^0.65.2",
@@ -61,6 +67,9 @@
61
67
  "typescript": "^5.9.3"
62
68
  },
63
69
  "engines": {
64
- "node": ">=20"
65
- }
70
+ "node": ">=22"
71
+ },
72
+ "os": [
73
+ "darwin"
74
+ ]
66
75
  }
package/prompts/oracle.md CHANGED
@@ -22,7 +22,7 @@ Oracle model (`oracle_submit`):
22
22
 
23
23
  Rules:
24
24
  - Always include an archive. Do not submit without context files.
25
- - By default, include the whole repository by passing `.`. Default archive exclusions apply automatically, including common bulky outputs and obvious credentials/private data like `.env` files, key material, credential dotfiles, local database files, and root `secrets/` directories.
25
+ - By default, include the whole repository by passing `.`. Default archive exclusions apply automatically, including common bulky outputs and obvious credentials/private data like `.env` files, key material, credential dotfiles, local database files, and nested `secrets/` directories anywhere in the repo.
26
26
  - Only limit file selection if the user explicitly requests it, if the task is clearly scoped to a smaller area, or if privacy/sensitivity requires it.
27
27
  - For very targeted asks like reviewing one function or explaining one stack trace, a smaller archive is preferable.
28
28
  - If the request depends on git state or pending changes (for example code review, ship readiness, or release approval), create a tracked diff bundle file inside the repo (for example under `.pi/`) containing `git status` plus `git diff` output, include that file in the archive, and tell the oracle to use it because the `.git` directory is not included in oracle exports.