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
@@ -0,0 +1,143 @@
1
+ // Purpose: Provide shared oracle job observability formatting for UI messages, tool responses, and wake-up notifications.
2
+ // Responsibilities: Format job summaries, lifecycle breadcrumbs, submit responses, wake-up notification content, and session status text consistently across channels.
3
+ // Scope: Presentation helpers only; lifecycle mutation, persistence, and browser execution remain in lifecycle/job modules.
4
+ // Usage: Imported by commands, tools, poller, and extension startup/status code to keep detached-oracle messaging consistent.
5
+ // Invariants/Assumptions: Job summaries read from durable job state, and lifecycle event trails are bounded and already normalized by shared lifecycle helpers.
6
+
7
+ import { getLatestOracleJobLifecycleEvent } from "./job-lifecycle-helpers.mjs";
8
+
9
+ /** @typedef {import("./job-observability-helpers.d.mts").OracleJobSummaryLike} OracleJobSummaryLike */
10
+ /** @typedef {import("./job-observability-helpers.d.mts").OracleJobSummaryOptions} OracleJobSummaryOptions */
11
+ /** @typedef {import("./job-observability-helpers.d.mts").OracleStatusCounts} OracleStatusCounts */
12
+ /** @typedef {import("./job-observability-helpers.d.mts").OracleSubmitResponseOptions} OracleSubmitResponseOptions */
13
+ /** @typedef {import("./job-lifecycle-helpers.d.mts").OracleJobLifecycleEvent} OracleJobLifecycleEvent */
14
+
15
+ /**
16
+ * @param {number} bytes
17
+ * @returns {string}
18
+ */
19
+ export function formatBytes(bytes) {
20
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
21
+ const units = ["B", "KiB", "MiB", "GiB", "TiB"];
22
+ let value = bytes;
23
+ let unitIndex = 0;
24
+ while (value >= 1024 && unitIndex < units.length - 1) {
25
+ value /= 1024;
26
+ unitIndex += 1;
27
+ }
28
+ return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
29
+ }
30
+
31
+ /**
32
+ * @param {OracleJobLifecycleEvent | undefined} event
33
+ * @returns {string | undefined}
34
+ */
35
+ export function formatOracleLifecycleEvent(event) {
36
+ if (!event) return undefined;
37
+ return `${event.at} [${event.source}] ${event.message}`;
38
+ }
39
+
40
+ /**
41
+ * @param {Array<{ relativePath: string; bytes: number }>} autoPrunedPrefixes
42
+ * @returns {string | undefined}
43
+ */
44
+ function formatAutoPrunedArchiveMessage(autoPrunedPrefixes) {
45
+ if (autoPrunedPrefixes.length === 0) return undefined;
46
+ return `Archive auto-pruned generic generated-output-name dirs to fit size limit: ${autoPrunedPrefixes.map((entry) => `${entry.relativePath}/ (${formatBytes(entry.bytes)})`).join(", ")}`;
47
+ }
48
+
49
+ /**
50
+ * @param {OracleJobSummaryLike} job
51
+ * @param {OracleJobSummaryOptions} [options]
52
+ * @returns {string}
53
+ */
54
+ export function formatOracleJobSummary(job, options = {}) {
55
+ const latestEvent = options.includeLatestEvent === false ? undefined : formatOracleLifecycleEvent(getLatestOracleJobLifecycleEvent(job));
56
+ return [
57
+ `job: ${job.id}`,
58
+ `status: ${job.status}`,
59
+ `phase: ${job.phase}`,
60
+ `created: ${job.createdAt}`,
61
+ job.queuedAt ? `queued: ${job.queuedAt}` : undefined,
62
+ job.submittedAt ? `submitted: ${job.submittedAt}` : undefined,
63
+ options.queuePosition ? `queue-position: ${options.queuePosition.position} of ${options.queuePosition.depth} global` : undefined,
64
+ `project: ${job.projectId}`,
65
+ `session: ${job.sessionId}`,
66
+ job.completedAt ? `completed: ${job.completedAt}` : undefined,
67
+ job.followUpToJobId ? `follow-up-to: ${job.followUpToJobId}` : undefined,
68
+ job.chatUrl ? `chat: ${job.chatUrl}` : undefined,
69
+ job.conversationId ? `conversation: ${job.conversationId}` : undefined,
70
+ job.responsePath ? `response: ${job.responsePath}` : undefined,
71
+ job.responseFormat ? `response-format: ${job.responseFormat}` : undefined,
72
+ options.artifactsPath ? `artifacts: ${options.artifactsPath}` : undefined,
73
+ typeof job.artifactFailureCount === "number" ? `artifact-failures: ${job.artifactFailureCount}` : undefined,
74
+ options.includeWorkerLogPath === false ? undefined : job.workerLogPath ? `worker-log: ${job.workerLogPath}` : undefined,
75
+ job.lastCleanupAt ? `last-cleanup: ${job.lastCleanupAt}` : undefined,
76
+ job.cleanupWarnings?.length ? `cleanup-warnings: ${job.cleanupWarnings.join(" | ")}` : undefined,
77
+ latestEvent ? `last-event: ${latestEvent}` : undefined,
78
+ job.error ? `error: ${job.error}` : undefined,
79
+ options.responsePreview ? "" : undefined,
80
+ options.responsePreview,
81
+ ]
82
+ .filter(Boolean)
83
+ .join("\n");
84
+ }
85
+
86
+ /**
87
+ * @param {OracleJobSummaryLike} job
88
+ * @param {{ responsePath?: string; artifactsPath?: string }} [options]
89
+ * @returns {string}
90
+ */
91
+ export function buildOracleWakeupNotificationContent(job, options = {}) {
92
+ const responsePath = options.responsePath ?? job.responsePath ?? `response unavailable for ${job.id}`;
93
+ const artifactsPath = options.artifactsPath ?? `artifacts unavailable for ${job.id}`;
94
+ return [
95
+ `Oracle job ${job.id} is ${job.status}.`,
96
+ `Use oracle_read with jobId ${job.id} to open the response and settle wake-up retries.`,
97
+ `Response file: ${responsePath}`,
98
+ `Artifacts: ${artifactsPath}`,
99
+ formatOracleLifecycleEvent(getLatestOracleJobLifecycleEvent(job)) ? `Last event: ${formatOracleLifecycleEvent(getLatestOracleJobLifecycleEvent(job))}` : undefined,
100
+ job.error ? `Error: ${job.error}` : "After oracle_read, continue from the oracle output.",
101
+ ].filter(Boolean).join("\n");
102
+ }
103
+
104
+ /**
105
+ * @param {OracleJobSummaryLike & { promptPath: string; archivePath: string }} job
106
+ * @param {OracleSubmitResponseOptions} options
107
+ * @returns {string}
108
+ */
109
+ export function formatOracleSubmitResponse(job, options) {
110
+ return [
111
+ `${options.queued ? "Oracle job queued" : "Oracle job dispatched"}: ${job.id}`,
112
+ options.queued && options.queuePosition && options.queueDepth ? `Queue position: ${options.queuePosition} of ${options.queueDepth}` : undefined,
113
+ job.followUpToJobId ? `Follow-up to: ${job.followUpToJobId}` : undefined,
114
+ `Prompt: ${job.promptPath}`,
115
+ `Archive: ${job.archivePath}`,
116
+ formatAutoPrunedArchiveMessage(options.autoPrunedPrefixes),
117
+ `Response will be written to: ${job.responsePath}`,
118
+ formatOracleLifecycleEvent(getLatestOracleJobLifecycleEvent(job)) ? `Last event: ${formatOracleLifecycleEvent(getLatestOracleJobLifecycleEvent(job))}` : undefined,
119
+ options.queued ? "The job will start automatically when capacity is available." : undefined,
120
+ "Stop now and wait for the oracle completion wake-up.",
121
+ ]
122
+ .filter(Boolean)
123
+ .join("\n");
124
+ }
125
+
126
+ /**
127
+ * @param {OracleStatusCounts} counts
128
+ * @returns {string}
129
+ */
130
+ export function buildOracleStatusText(counts) {
131
+ if (counts.active > 0 && counts.queued > 0) {
132
+ return `oracle: running (${counts.active}), queued (${counts.queued})`;
133
+ }
134
+ if (counts.active > 0) {
135
+ const suffix = counts.active > 1 ? ` (${counts.active})` : "";
136
+ return `oracle: running${suffix}`;
137
+ }
138
+ if (counts.queued > 0) {
139
+ const suffix = counts.queued > 1 ? ` (${counts.queued})` : "";
140
+ return `oracle: queued${suffix}`;
141
+ }
142
+ return "oracle: ready";
143
+ }
@@ -0,0 +1,20 @@
1
+ export interface OracleTrackedProcessOptions {
2
+ termGraceMs?: number;
3
+ killGraceMs?: number;
4
+ }
5
+
6
+ export interface OracleDetachedProcessHandle {
7
+ pid: number | undefined;
8
+ startedAt?: string;
9
+ }
10
+
11
+ export declare function readProcessStartedAt(pid: number | undefined): string | undefined;
12
+ export declare function isProcessAlive(pid: number | undefined): boolean;
13
+ export declare function isTrackedProcessAlive(pid: number | undefined, startedAt?: string): boolean;
14
+ export declare function waitForProcessStartedAt(pid: number | undefined, timeoutMs?: number): Promise<string | undefined>;
15
+ export declare function terminateTrackedProcess(
16
+ pid: number | undefined,
17
+ startedAt?: string,
18
+ options?: OracleTrackedProcessOptions,
19
+ ): Promise<boolean>;
20
+ export declare function spawnDetachedNodeProcess(scriptPath: string, args?: string[]): Promise<OracleDetachedProcessHandle>;
@@ -0,0 +1,128 @@
1
+ // Purpose: Provide shared process-identity and termination helpers for oracle runtime, worker, and queue coordination.
2
+ // Responsibilities: Read stable process start identities, detect liveness, wait for freshly spawned processes, and terminate tracked processes safely.
3
+ // Scope: Local process coordination only; job-state mutation and queue semantics stay in higher-level helpers.
4
+ // Usage: Imported by lib/jobs.ts, lib/runtime.ts, worker/run-job.mjs, and shared state helpers.
5
+ // Invariants/Assumptions: Process identity is validated with `ps -o lstart=` to defend against PID reuse on macOS.
6
+
7
+ import { spawn, execFileSync } from "node:child_process";
8
+
9
+ /** @typedef {import("./process-helpers.d.mts").OracleTrackedProcessOptions} OracleTrackedProcessOptions */
10
+ /** @typedef {import("./process-helpers.d.mts").OracleDetachedProcessHandle} OracleDetachedProcessHandle */
11
+
12
+ function sleep(ms) {
13
+ return new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+
16
+ /**
17
+ * @param {number | undefined} pid
18
+ * @returns {string | undefined}
19
+ */
20
+ export function readProcessStartedAt(pid) {
21
+ if (!pid || pid <= 0) return undefined;
22
+ try {
23
+ const startedAt = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], { encoding: "utf8" }).trim();
24
+ return startedAt || undefined;
25
+ } catch {
26
+ return undefined;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * @param {number | undefined} pid
32
+ * @returns {boolean}
33
+ */
34
+ export function isProcessAlive(pid) {
35
+ if (!pid || pid <= 0) return false;
36
+ try {
37
+ process.kill(pid, 0);
38
+ return true;
39
+ } catch (error) {
40
+ if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") return false;
41
+ return true;
42
+ }
43
+ }
44
+
45
+ /**
46
+ * @param {number | undefined} pid
47
+ * @param {string | undefined} startedAt
48
+ * @returns {boolean}
49
+ */
50
+ export function isTrackedProcessAlive(pid, startedAt) {
51
+ const currentStartedAt = readProcessStartedAt(pid);
52
+ if (!currentStartedAt) return false;
53
+ return startedAt ? currentStartedAt === startedAt : true;
54
+ }
55
+
56
+ /**
57
+ * @param {number | undefined} pid
58
+ * @param {number} [timeoutMs]
59
+ * @returns {Promise<string | undefined>}
60
+ */
61
+ export async function waitForProcessStartedAt(pid, timeoutMs = 2_000) {
62
+ const deadline = Date.now() + timeoutMs;
63
+ while (Date.now() < deadline) {
64
+ const startedAt = readProcessStartedAt(pid);
65
+ if (startedAt) return startedAt;
66
+ await sleep(100);
67
+ }
68
+ return readProcessStartedAt(pid);
69
+ }
70
+
71
+ /**
72
+ * @param {number | undefined} pid
73
+ * @param {string | undefined} startedAt
74
+ * @param {OracleTrackedProcessOptions} [options]
75
+ * @returns {Promise<boolean>}
76
+ */
77
+ export async function terminateTrackedProcess(pid, startedAt, options = {}) {
78
+ if (!pid || pid <= 0) return true;
79
+ const currentStartedAt = readProcessStartedAt(pid);
80
+ if (!currentStartedAt) return true;
81
+ if (startedAt && currentStartedAt !== startedAt) return false;
82
+
83
+ const termGraceMs = options.termGraceMs ?? 5_000;
84
+ const killGraceMs = options.killGraceMs ?? 2_000;
85
+
86
+ try {
87
+ process.kill(pid, "SIGTERM");
88
+ } catch {
89
+ return !isTrackedProcessAlive(pid, startedAt);
90
+ }
91
+
92
+ const termDeadline = Date.now() + termGraceMs;
93
+ while (Date.now() < termDeadline) {
94
+ if (!isTrackedProcessAlive(pid, startedAt)) return true;
95
+ await sleep(250);
96
+ }
97
+
98
+ try {
99
+ process.kill(pid, "SIGKILL");
100
+ } catch {
101
+ return !isTrackedProcessAlive(pid, startedAt);
102
+ }
103
+
104
+ const killDeadline = Date.now() + killGraceMs;
105
+ while (Date.now() < killDeadline) {
106
+ if (!isTrackedProcessAlive(pid, startedAt)) return true;
107
+ await sleep(250);
108
+ }
109
+
110
+ return !isTrackedProcessAlive(pid, startedAt);
111
+ }
112
+
113
+ /**
114
+ * @param {string} scriptPath
115
+ * @param {string[]} args
116
+ * @returns {Promise<OracleDetachedProcessHandle>}
117
+ */
118
+ export async function spawnDetachedNodeProcess(scriptPath, args = []) {
119
+ const child = spawn(process.execPath, [scriptPath, ...args], {
120
+ detached: true,
121
+ stdio: "ignore",
122
+ });
123
+ child.unref();
124
+ return {
125
+ pid: child.pid,
126
+ startedAt: await waitForProcessStartedAt(child.pid),
127
+ };
128
+ }
@@ -0,0 +1,43 @@
1
+ export const ORACLE_METADATA_WRITE_GRACE_MS: number;
2
+ export const ORACLE_TMP_STATE_DIR_GRACE_MS: number;
3
+
4
+ export declare function hashOracleStateKey(kind: string, key: string): string;
5
+ export declare function getStateLocksDir(stateDir: string): string;
6
+ export declare function getStateLeasesDir(stateDir: string): string;
7
+ export declare function sweepStaleStateLocks(stateDir: string, now?: number): Promise<string[]>;
8
+ export declare function acquireStateLock(
9
+ stateDir: string,
10
+ kind: string,
11
+ key: string,
12
+ metadata: unknown,
13
+ timeoutMs?: number,
14
+ ): Promise<string>;
15
+ export declare function releaseStatePath(path: string | undefined): Promise<void>;
16
+ export declare function withStateLock<T>(
17
+ stateDir: string,
18
+ kind: string,
19
+ key: string,
20
+ metadata: unknown,
21
+ fn: () => Promise<T>,
22
+ timeoutMs?: number,
23
+ ): Promise<T>;
24
+ export declare function createStateLease(
25
+ stateDir: string,
26
+ kind: string,
27
+ key: string,
28
+ metadata: unknown,
29
+ timeoutMs?: number,
30
+ ): Promise<string>;
31
+ export declare function writeStateLeaseMetadata(
32
+ stateDir: string,
33
+ kind: string,
34
+ key: string,
35
+ metadata: unknown,
36
+ ): Promise<string>;
37
+ export declare function readStateLeaseMetadata<T = unknown>(
38
+ stateDir: string,
39
+ kind: string,
40
+ key: string,
41
+ ): Promise<T | undefined>;
42
+ export declare function listStateLeaseMetadata<T = unknown>(stateDir: string, kind: string): T[];
43
+ export declare function releaseStateLease(stateDir: string, kind: string, key: string | undefined): Promise<void>;