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
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
// Purpose: Centralize oracle job lifecycle state transitions, invariants, and durable transition breadcrumbs.
|
|
2
|
+
// Responsibilities: Define valid phase/status relationships, apply lifecycle mutations, append bounded lifecycle events, and normalize cleanup/notification/wake-up state changes.
|
|
3
|
+
// Scope: Pure job-state reducers only; persistence, locking, browser work, and UI delivery stay in higher-level modules.
|
|
4
|
+
// Usage: Imported by extension lib code and worker code so all lifecycle transitions share the same invariants and event semantics.
|
|
5
|
+
// Invariants/Assumptions: Phase/status pairs must stay aligned, terminal jobs own completedAt, and cleanupPending is only legal for terminal states.
|
|
6
|
+
|
|
7
|
+
/** @typedef {import("./job-lifecycle-helpers.d.mts").OracleJobLifecycleEvent} OracleJobLifecycleEvent */
|
|
8
|
+
/** @typedef {import("./job-lifecycle-helpers.d.mts").OracleJobPhase} OracleJobPhase */
|
|
9
|
+
/** @typedef {import("./job-lifecycle-helpers.d.mts").OracleJobStatus} OracleJobStatus */
|
|
10
|
+
/** @typedef {import("./job-lifecycle-helpers.d.mts").OracleLifecycleTrackedJobLike} OracleLifecycleTrackedJobLike */
|
|
11
|
+
|
|
12
|
+
export const ACTIVE_ORACLE_JOB_STATUSES = Object.freeze(["preparing", "submitted", "waiting"]);
|
|
13
|
+
export const OPEN_ORACLE_JOB_STATUSES = Object.freeze(["queued", ...ACTIVE_ORACLE_JOB_STATUSES]);
|
|
14
|
+
export const TERMINAL_ORACLE_JOB_STATUSES = Object.freeze(["complete", "failed", "cancelled"]);
|
|
15
|
+
export const MAX_ORACLE_JOB_LIFECYCLE_EVENTS = 64;
|
|
16
|
+
|
|
17
|
+
/** @type {Record<OracleJobPhase, OracleJobStatus>} */
|
|
18
|
+
const PHASE_STATUS = Object.freeze({
|
|
19
|
+
queued: "queued",
|
|
20
|
+
submitted: "submitted",
|
|
21
|
+
cloning_runtime: "waiting",
|
|
22
|
+
launching_browser: "waiting",
|
|
23
|
+
verifying_auth: "waiting",
|
|
24
|
+
configuring_model: "waiting",
|
|
25
|
+
uploading_archive: "waiting",
|
|
26
|
+
awaiting_response: "waiting",
|
|
27
|
+
extracting_response: "waiting",
|
|
28
|
+
downloading_artifacts: "waiting",
|
|
29
|
+
complete: "complete",
|
|
30
|
+
complete_with_artifact_errors: "complete",
|
|
31
|
+
failed: "failed",
|
|
32
|
+
cancelled: "cancelled",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {OracleJobPhase} phase
|
|
37
|
+
* @returns {OracleJobStatus}
|
|
38
|
+
*/
|
|
39
|
+
export function getOracleJobStatusForPhase(phase) {
|
|
40
|
+
return PHASE_STATUS[phase];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @template {OracleLifecycleTrackedJobLike} TJob
|
|
45
|
+
* @param {TJob} job
|
|
46
|
+
* @returns {TJob}
|
|
47
|
+
*/
|
|
48
|
+
export function assertValidOracleJobState(job) {
|
|
49
|
+
const expectedStatus = PHASE_STATUS[job.phase];
|
|
50
|
+
if (!expectedStatus) {
|
|
51
|
+
throw new Error(`Invalid oracle job state: unknown phase ${String(job.phase)}`);
|
|
52
|
+
}
|
|
53
|
+
if (job.status !== expectedStatus) {
|
|
54
|
+
throw new Error(`Invalid oracle job state: phase ${job.phase} requires status ${expectedStatus}, got ${job.status}`);
|
|
55
|
+
}
|
|
56
|
+
if (job.status === "queued" && !job.queuedAt) {
|
|
57
|
+
throw new Error("Invalid oracle job state: queued jobs must record queuedAt");
|
|
58
|
+
}
|
|
59
|
+
if (["submitted", "waiting"].includes(job.status) && !job.submittedAt) {
|
|
60
|
+
throw new Error(`Invalid oracle job state: ${job.status} jobs must record submittedAt`);
|
|
61
|
+
}
|
|
62
|
+
if (TERMINAL_ORACLE_JOB_STATUSES.includes(job.status) && !job.completedAt) {
|
|
63
|
+
throw new Error(`Invalid oracle job state: terminal job ${job.status} must record completedAt`);
|
|
64
|
+
}
|
|
65
|
+
if (job.completedAt && !TERMINAL_ORACLE_JOB_STATUSES.includes(job.status)) {
|
|
66
|
+
throw new Error(`Invalid oracle job state: non-terminal job ${job.status} cannot record completedAt`);
|
|
67
|
+
}
|
|
68
|
+
if (job.cleanupPending && !TERMINAL_ORACLE_JOB_STATUSES.includes(job.status)) {
|
|
69
|
+
throw new Error(`Invalid oracle job state: non-terminal job ${job.status} cannot be cleanupPending`);
|
|
70
|
+
}
|
|
71
|
+
return job;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @param {Pick<OracleLifecycleTrackedJobLike, "phase" | "status" | "lifecycleEvents">} job
|
|
76
|
+
* @param {Omit<OracleJobLifecycleEvent, "status" | "phase"> & { status?: OracleJobStatus; phase?: OracleJobPhase }} event
|
|
77
|
+
* @returns {OracleJobLifecycleEvent[]}
|
|
78
|
+
*/
|
|
79
|
+
function nextLifecycleEvents(job, event) {
|
|
80
|
+
const entry = {
|
|
81
|
+
at: event.at,
|
|
82
|
+
source: event.source,
|
|
83
|
+
kind: event.kind,
|
|
84
|
+
message: event.message,
|
|
85
|
+
status: event.status ?? job.status,
|
|
86
|
+
phase: event.phase ?? job.phase,
|
|
87
|
+
};
|
|
88
|
+
const events = [...(job.lifecycleEvents || []), entry];
|
|
89
|
+
return events.slice(-MAX_ORACLE_JOB_LIFECYCLE_EVENTS);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @template {OracleLifecycleTrackedJobLike} TJob
|
|
94
|
+
* @param {TJob} job
|
|
95
|
+
* @param {Omit<OracleJobLifecycleEvent, "status" | "phase"> & { status?: OracleJobStatus; phase?: OracleJobPhase }} event
|
|
96
|
+
* @returns {TJob}
|
|
97
|
+
*/
|
|
98
|
+
export function appendOracleJobLifecycleEvent(job, event) {
|
|
99
|
+
return assertValidOracleJobState({
|
|
100
|
+
...job,
|
|
101
|
+
lifecycleEvents: nextLifecycleEvents(job, event),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {Pick<OracleLifecycleTrackedJobLike, "lifecycleEvents">} job
|
|
107
|
+
* @returns {OracleJobLifecycleEvent | undefined}
|
|
108
|
+
*/
|
|
109
|
+
export function getLatestOracleJobLifecycleEvent(job) {
|
|
110
|
+
const events = job.lifecycleEvents || [];
|
|
111
|
+
return events.length > 0 ? events[events.length - 1] : undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {Pick<OracleLifecycleTrackedJobLike, "lifecycleEvents">} job
|
|
116
|
+
* @returns {OracleJobLifecycleEvent | undefined}
|
|
117
|
+
*/
|
|
118
|
+
export function getLatestOracleTerminalLifecycleEvent(job) {
|
|
119
|
+
const events = job.lifecycleEvents || [];
|
|
120
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
121
|
+
const event = events[index];
|
|
122
|
+
if (event?.kind === "phase" && TERMINAL_ORACLE_JOB_STATUSES.includes(event.status)) return event;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* @template {OracleLifecycleTrackedJobLike} TJob
|
|
129
|
+
* @param {TJob} job
|
|
130
|
+
* @param {{ at?: string; source?: string; message?: string }} [options]
|
|
131
|
+
* @returns {TJob}
|
|
132
|
+
*/
|
|
133
|
+
export function markOracleJobCreated(job, options = {}) {
|
|
134
|
+
const at = options.at ?? job.createdAt;
|
|
135
|
+
return appendOracleJobLifecycleEvent(assertValidOracleJobState(job), {
|
|
136
|
+
at,
|
|
137
|
+
source: options.source ?? "oracle:create",
|
|
138
|
+
kind: "created",
|
|
139
|
+
message: options.message ?? `Job created in ${job.status} state.`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* @template {OracleLifecycleTrackedJobLike} TJob
|
|
145
|
+
* @param {TJob} job
|
|
146
|
+
* @param {OracleJobPhase} phase
|
|
147
|
+
* @param {{ at?: string; source?: string; message?: string; patch?: Partial<TJob>; clearNotificationClaim?: boolean }} [options]
|
|
148
|
+
* @returns {TJob}
|
|
149
|
+
*/
|
|
150
|
+
export function transitionOracleJobPhase(job, phase, options = {}) {
|
|
151
|
+
const at = options.at ?? new Date().toISOString();
|
|
152
|
+
const patch = options.patch || {};
|
|
153
|
+
const status = patch.status ?? getOracleJobStatusForPhase(phase);
|
|
154
|
+
if (status !== getOracleJobStatusForPhase(phase)) {
|
|
155
|
+
throw new Error(`Invalid oracle job transition: phase ${phase} requires status ${getOracleJobStatusForPhase(phase)}, got ${String(status)}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** @type {TJob} */
|
|
159
|
+
const next = {
|
|
160
|
+
...job,
|
|
161
|
+
...patch,
|
|
162
|
+
status,
|
|
163
|
+
phase,
|
|
164
|
+
phaseAt: at,
|
|
165
|
+
...(phase === "queued" ? { queuedAt: patch.queuedAt ?? job.queuedAt ?? at } : {}),
|
|
166
|
+
...(["submitted", "waiting"].includes(status) || patch.submittedAt !== undefined || job.submittedAt !== undefined
|
|
167
|
+
? { submittedAt: patch.submittedAt ?? job.submittedAt ?? at }
|
|
168
|
+
: {}),
|
|
169
|
+
...(TERMINAL_ORACLE_JOB_STATUSES.includes(status)
|
|
170
|
+
? { completedAt: patch.completedAt ?? job.completedAt ?? at }
|
|
171
|
+
: { completedAt: patch.completedAt ?? job.completedAt }),
|
|
172
|
+
...(options.clearNotificationClaim
|
|
173
|
+
? { notifyClaimedAt: undefined, notifyClaimedBy: undefined }
|
|
174
|
+
: {}),
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const validated = assertValidOracleJobState(next);
|
|
178
|
+
const changed = job.phase !== validated.phase || job.status !== validated.status || Boolean(options.message);
|
|
179
|
+
if (!changed) return validated;
|
|
180
|
+
|
|
181
|
+
return appendOracleJobLifecycleEvent(validated, {
|
|
182
|
+
at,
|
|
183
|
+
source: options.source ?? "oracle:lifecycle",
|
|
184
|
+
kind: "phase",
|
|
185
|
+
message: options.message ?? `Transitioned to ${validated.phase} (${validated.status}).`,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @template {OracleLifecycleTrackedJobLike} TJob
|
|
191
|
+
* @param {TJob} job
|
|
192
|
+
* @param {string[]} warnings
|
|
193
|
+
* @param {{ at?: string; source?: string; message?: string }} [options]
|
|
194
|
+
* @returns {TJob}
|
|
195
|
+
*/
|
|
196
|
+
export function applyOracleJobCleanupWarnings(job, warnings, options = {}) {
|
|
197
|
+
if (warnings.length === 0) return assertValidOracleJobState(job);
|
|
198
|
+
const at = options.at ?? new Date().toISOString();
|
|
199
|
+
const next = assertValidOracleJobState({
|
|
200
|
+
...job,
|
|
201
|
+
cleanupPending: false,
|
|
202
|
+
cleanupWarnings: Array.from(new Set([...(job.cleanupWarnings || []), ...warnings])),
|
|
203
|
+
lastCleanupAt: at,
|
|
204
|
+
error: [job.error, ...warnings].filter(Boolean).join("\n"),
|
|
205
|
+
});
|
|
206
|
+
return appendOracleJobLifecycleEvent(next, {
|
|
207
|
+
at,
|
|
208
|
+
source: options.source ?? "oracle:cleanup",
|
|
209
|
+
kind: "cleanup",
|
|
210
|
+
message: options.message ?? `Cleanup completed with ${warnings.length} warning(s).`,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* @template {OracleLifecycleTrackedJobLike} TJob
|
|
216
|
+
* @param {TJob} job
|
|
217
|
+
* @param {{ at?: string; source?: string; message?: string }} [options]
|
|
218
|
+
* @returns {TJob}
|
|
219
|
+
*/
|
|
220
|
+
export function clearOracleJobCleanupState(job, options = {}) {
|
|
221
|
+
const at = options.at ?? new Date().toISOString();
|
|
222
|
+
const next = assertValidOracleJobState({
|
|
223
|
+
...job,
|
|
224
|
+
cleanupPending: false,
|
|
225
|
+
cleanupWarnings: undefined,
|
|
226
|
+
lastCleanupAt: at,
|
|
227
|
+
});
|
|
228
|
+
return appendOracleJobLifecycleEvent(next, {
|
|
229
|
+
at,
|
|
230
|
+
source: options.source ?? "oracle:cleanup",
|
|
231
|
+
kind: "cleanup",
|
|
232
|
+
message: options.message ?? "Cleanup finished without warnings.",
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* @template {OracleLifecycleTrackedJobLike} TJob
|
|
238
|
+
* @param {TJob} job
|
|
239
|
+
* @param {string} claimedBy
|
|
240
|
+
* @param {string} [at]
|
|
241
|
+
* @returns {TJob}
|
|
242
|
+
*/
|
|
243
|
+
export function claimOracleJobNotification(job, claimedBy, at = new Date().toISOString()) {
|
|
244
|
+
return assertValidOracleJobState({
|
|
245
|
+
...job,
|
|
246
|
+
notifyClaimedBy: claimedBy,
|
|
247
|
+
notifyClaimedAt: at,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* @template {OracleLifecycleTrackedJobLike} TJob
|
|
253
|
+
* @param {TJob} job
|
|
254
|
+
* @param {{ at?: string; source?: string; notificationSessionKey: string; notificationSessionFile?: string }} options
|
|
255
|
+
* @returns {TJob}
|
|
256
|
+
*/
|
|
257
|
+
export function recordOracleJobNotificationTarget(job, options) {
|
|
258
|
+
const at = options.at ?? new Date().toISOString();
|
|
259
|
+
const next = assertValidOracleJobState({
|
|
260
|
+
...job,
|
|
261
|
+
notificationSessionKey: options.notificationSessionKey,
|
|
262
|
+
notificationSessionFile: options.notificationSessionFile,
|
|
263
|
+
});
|
|
264
|
+
return appendOracleJobLifecycleEvent(next, {
|
|
265
|
+
at,
|
|
266
|
+
source: options.source ?? "oracle:poller",
|
|
267
|
+
kind: "notification",
|
|
268
|
+
message: `Notification target recorded for ${options.notificationSessionKey}.`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* @template {OracleLifecycleTrackedJobLike} TJob
|
|
274
|
+
* @param {TJob} job
|
|
275
|
+
* @param {{ at?: string; source?: string; notificationEntryId?: string; notificationSessionKey?: string; notificationSessionFile?: string }} [options]
|
|
276
|
+
* @returns {TJob}
|
|
277
|
+
*/
|
|
278
|
+
export function markOracleJobNotified(job, options = {}) {
|
|
279
|
+
const at = options.at ?? new Date().toISOString();
|
|
280
|
+
const next = assertValidOracleJobState({
|
|
281
|
+
...job,
|
|
282
|
+
notifiedAt: at,
|
|
283
|
+
notificationEntryId: options.notificationEntryId ?? job.notificationEntryId,
|
|
284
|
+
notificationSessionKey: options.notificationSessionKey ?? job.notificationSessionKey,
|
|
285
|
+
notificationSessionFile: options.notificationSessionFile ?? job.notificationSessionFile,
|
|
286
|
+
wakeupAttemptCount: 0,
|
|
287
|
+
wakeupLastRequestedAt: undefined,
|
|
288
|
+
wakeupSettledAt: undefined,
|
|
289
|
+
notifyClaimedAt: undefined,
|
|
290
|
+
notifyClaimedBy: undefined,
|
|
291
|
+
});
|
|
292
|
+
return appendOracleJobLifecycleEvent(next, {
|
|
293
|
+
at,
|
|
294
|
+
source: options.source ?? "oracle:poller",
|
|
295
|
+
kind: "notification",
|
|
296
|
+
message: "Notification delivery recorded.",
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* @template {OracleLifecycleTrackedJobLike} TJob
|
|
302
|
+
* @param {TJob} job
|
|
303
|
+
* @returns {TJob}
|
|
304
|
+
*/
|
|
305
|
+
export function releaseOracleJobNotificationClaim(job) {
|
|
306
|
+
return assertValidOracleJobState({
|
|
307
|
+
...job,
|
|
308
|
+
notifyClaimedAt: undefined,
|
|
309
|
+
notifyClaimedBy: undefined,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* @template {OracleLifecycleTrackedJobLike} TJob
|
|
315
|
+
* @param {TJob} job
|
|
316
|
+
* @param {{ at?: string; source?: string }} [options]
|
|
317
|
+
* @returns {TJob}
|
|
318
|
+
*/
|
|
319
|
+
export function noteOracleJobWakeupRequested(job, options = {}) {
|
|
320
|
+
const at = options.at ?? new Date().toISOString();
|
|
321
|
+
const next = assertValidOracleJobState({
|
|
322
|
+
...job,
|
|
323
|
+
wakeupAttemptCount: (job.wakeupAttemptCount ?? 0) + 1,
|
|
324
|
+
wakeupLastRequestedAt: at,
|
|
325
|
+
});
|
|
326
|
+
return appendOracleJobLifecycleEvent(next, {
|
|
327
|
+
at,
|
|
328
|
+
source: options.source ?? "oracle:poller",
|
|
329
|
+
kind: "wakeup",
|
|
330
|
+
message: `Wake-up reminder requested (attempt ${next.wakeupAttemptCount}).`,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* @template {OracleLifecycleTrackedJobLike} TJob
|
|
336
|
+
* @param {TJob} job
|
|
337
|
+
* @param {{ source: string; at?: string; sessionFile?: string; sessionKey?: string; allowBeforeFirstAttempt?: boolean }} options
|
|
338
|
+
* @returns {TJob}
|
|
339
|
+
*/
|
|
340
|
+
export function markOracleJobWakeupSettled(job, options) {
|
|
341
|
+
const at = options.at ?? new Date().toISOString();
|
|
342
|
+
const beforeFirstAttempt = !job.wakeupLastRequestedAt && (job.wakeupAttemptCount ?? 0) === 0;
|
|
343
|
+
|
|
344
|
+
if (job.wakeupSettledAt) {
|
|
345
|
+
const next = assertValidOracleJobState({
|
|
346
|
+
...job,
|
|
347
|
+
wakeupSettledSource: job.wakeupSettledSource ?? options.source,
|
|
348
|
+
wakeupSettledSessionFile: job.wakeupSettledSessionFile ?? options.sessionFile,
|
|
349
|
+
wakeupSettledSessionKey: job.wakeupSettledSessionKey ?? options.sessionKey,
|
|
350
|
+
wakeupSettledBeforeFirstAttempt: job.wakeupSettledBeforeFirstAttempt ?? beforeFirstAttempt,
|
|
351
|
+
});
|
|
352
|
+
return appendOracleJobLifecycleEvent(next, {
|
|
353
|
+
at,
|
|
354
|
+
source: options.source,
|
|
355
|
+
kind: "wakeup",
|
|
356
|
+
message: `Wake-up already settled via ${next.wakeupSettledSource ?? options.source}.`,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (beforeFirstAttempt && !options.allowBeforeFirstAttempt) {
|
|
361
|
+
const observed = assertValidOracleJobState({
|
|
362
|
+
...job,
|
|
363
|
+
wakeupObservedAt: job.wakeupObservedAt ?? at,
|
|
364
|
+
wakeupObservedSource: job.wakeupObservedSource ?? options.source,
|
|
365
|
+
wakeupObservedSessionFile: job.wakeupObservedSessionFile ?? options.sessionFile,
|
|
366
|
+
wakeupObservedSessionKey: job.wakeupObservedSessionKey ?? options.sessionKey,
|
|
367
|
+
});
|
|
368
|
+
return appendOracleJobLifecycleEvent(observed, {
|
|
369
|
+
at,
|
|
370
|
+
source: options.source,
|
|
371
|
+
kind: "wakeup",
|
|
372
|
+
message: `Wake-up observed before the first reminder attempt via ${options.source}.`,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const settled = assertValidOracleJobState({
|
|
377
|
+
...job,
|
|
378
|
+
wakeupSettledAt: at,
|
|
379
|
+
wakeupSettledSource: options.source,
|
|
380
|
+
wakeupSettledSessionFile: options.sessionFile,
|
|
381
|
+
wakeupSettledSessionKey: options.sessionKey,
|
|
382
|
+
wakeupSettledBeforeFirstAttempt: beforeFirstAttempt,
|
|
383
|
+
});
|
|
384
|
+
return appendOracleJobLifecycleEvent(settled, {
|
|
385
|
+
at,
|
|
386
|
+
source: options.source,
|
|
387
|
+
kind: "wakeup",
|
|
388
|
+
message: `Wake-up settled via ${options.source}.`,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { OracleJobLifecycleEvent } from "./job-lifecycle-helpers.d.mts";
|
|
2
|
+
|
|
3
|
+
export interface OracleJobSummaryLike {
|
|
4
|
+
id: string;
|
|
5
|
+
status: string;
|
|
6
|
+
phase: string;
|
|
7
|
+
createdAt: string;
|
|
8
|
+
queuedAt?: string;
|
|
9
|
+
submittedAt?: string;
|
|
10
|
+
completedAt?: string;
|
|
11
|
+
projectId: string;
|
|
12
|
+
sessionId: string;
|
|
13
|
+
followUpToJobId?: string;
|
|
14
|
+
chatUrl?: string;
|
|
15
|
+
conversationId?: string;
|
|
16
|
+
responsePath?: string;
|
|
17
|
+
responseFormat?: string;
|
|
18
|
+
artifactFailureCount?: number;
|
|
19
|
+
lastCleanupAt?: string;
|
|
20
|
+
cleanupWarnings?: string[];
|
|
21
|
+
error?: string;
|
|
22
|
+
workerLogPath?: string;
|
|
23
|
+
lifecycleEvents?: OracleJobLifecycleEvent[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface OracleQueuePositionLike {
|
|
27
|
+
position: number;
|
|
28
|
+
depth: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface OracleJobSummaryOptions {
|
|
32
|
+
queuePosition?: OracleQueuePositionLike;
|
|
33
|
+
artifactsPath?: string;
|
|
34
|
+
responsePreview?: string;
|
|
35
|
+
responseAvailable?: boolean;
|
|
36
|
+
includeLatestEvent?: boolean;
|
|
37
|
+
includeWorkerLogPath?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface OracleSubmitResponseOptions {
|
|
41
|
+
autoPrunedPrefixes: Array<{ relativePath: string; bytes: number }>;
|
|
42
|
+
queued: boolean;
|
|
43
|
+
queuePosition?: number;
|
|
44
|
+
queueDepth?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface OracleStatusCounts {
|
|
48
|
+
active: number;
|
|
49
|
+
queued: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export declare function formatBytes(bytes: number): string;
|
|
53
|
+
export declare function formatOracleLifecycleEvent(event: OracleJobLifecycleEvent | undefined): string | undefined;
|
|
54
|
+
export declare function formatOracleJobSummary(job: OracleJobSummaryLike, options?: OracleJobSummaryOptions): string;
|
|
55
|
+
export declare function buildOracleWakeupNotificationContent(
|
|
56
|
+
job: OracleJobSummaryLike,
|
|
57
|
+
options?: { responsePath?: string; responseAvailable?: boolean; artifactsPath?: string },
|
|
58
|
+
): string;
|
|
59
|
+
export declare function formatOracleSubmitResponse(job: OracleJobSummaryLike & { promptPath: string; archivePath: string }, options: OracleSubmitResponseOptions): string;
|
|
60
|
+
export declare function buildOracleStatusText(counts: OracleStatusCounts): string;
|
|
@@ -0,0 +1,161 @@
|
|
|
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, getLatestOracleTerminalLifecycleEvent } 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 latestEventRaw = options.includeLatestEvent === false ? undefined : getLatestOracleJobLifecycleEvent(job);
|
|
56
|
+
const terminalEventRaw = getLatestOracleTerminalLifecycleEvent(job);
|
|
57
|
+
const latestEvent = formatOracleLifecycleEvent(latestEventRaw);
|
|
58
|
+
const terminalEvent = formatOracleLifecycleEvent(terminalEventRaw);
|
|
59
|
+
const sameEvent = Boolean(
|
|
60
|
+
latestEventRaw && terminalEventRaw &&
|
|
61
|
+
latestEventRaw.at === terminalEventRaw.at &&
|
|
62
|
+
latestEventRaw.source === terminalEventRaw.source &&
|
|
63
|
+
latestEventRaw.kind === terminalEventRaw.kind &&
|
|
64
|
+
latestEventRaw.message === terminalEventRaw.message,
|
|
65
|
+
);
|
|
66
|
+
const responseLine = options.responseAvailable === true
|
|
67
|
+
? job.responsePath ? `response: ${job.responsePath}` : undefined
|
|
68
|
+
: job.responsePath ? "response: unavailable yet" : undefined;
|
|
69
|
+
const responseFormatLine = options.responseAvailable === true && job.responseFormat ? `response-format: ${job.responseFormat}` : undefined;
|
|
70
|
+
const latestEventLabel = latestEventRaw?.kind === "wakeup" ? "wakeup-event" : "last-event";
|
|
71
|
+
return [
|
|
72
|
+
`job: ${job.id}`,
|
|
73
|
+
`status: ${job.status}`,
|
|
74
|
+
`phase: ${job.phase}`,
|
|
75
|
+
`created: ${job.createdAt}`,
|
|
76
|
+
job.queuedAt ? `queued: ${job.queuedAt}` : undefined,
|
|
77
|
+
job.submittedAt ? `submitted: ${job.submittedAt}` : undefined,
|
|
78
|
+
options.queuePosition ? `queue-position: ${options.queuePosition.position} of ${options.queuePosition.depth} global` : undefined,
|
|
79
|
+
`project: ${job.projectId}`,
|
|
80
|
+
`session: ${job.sessionId}`,
|
|
81
|
+
job.completedAt ? `completed: ${job.completedAt}` : undefined,
|
|
82
|
+
job.followUpToJobId ? `follow-up-to: ${job.followUpToJobId}` : undefined,
|
|
83
|
+
job.chatUrl ? `chat: ${job.chatUrl}` : undefined,
|
|
84
|
+
job.conversationId ? `conversation: ${job.conversationId}` : undefined,
|
|
85
|
+
responseLine,
|
|
86
|
+
responseFormatLine,
|
|
87
|
+
options.artifactsPath ? `artifacts: ${options.artifactsPath}` : undefined,
|
|
88
|
+
typeof job.artifactFailureCount === "number" ? `artifact-failures: ${job.artifactFailureCount}` : undefined,
|
|
89
|
+
options.includeWorkerLogPath === false ? undefined : job.workerLogPath ? `worker-log: ${job.workerLogPath}` : undefined,
|
|
90
|
+
job.lastCleanupAt ? `last-cleanup: ${job.lastCleanupAt}` : undefined,
|
|
91
|
+
job.cleanupWarnings?.length ? `cleanup-warnings: ${job.cleanupWarnings.join(" | ")}` : undefined,
|
|
92
|
+
terminalEvent ? `terminal-event: ${terminalEvent}` : undefined,
|
|
93
|
+
latestEvent && !sameEvent ? `${latestEventLabel}: ${latestEvent}` : undefined,
|
|
94
|
+
job.error ? `error: ${job.error}` : undefined,
|
|
95
|
+
options.responsePreview ? "" : undefined,
|
|
96
|
+
options.responsePreview,
|
|
97
|
+
]
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* @param {OracleJobSummaryLike} job
|
|
104
|
+
* @param {{ responsePath?: string; responseAvailable?: boolean; artifactsPath?: string }} [options]
|
|
105
|
+
* @returns {string}
|
|
106
|
+
*/
|
|
107
|
+
export function buildOracleWakeupNotificationContent(job, options = {}) {
|
|
108
|
+
const responseLine = options.responseAvailable === false
|
|
109
|
+
? "Response file: unavailable yet"
|
|
110
|
+
: `Response file: ${options.responsePath ?? job.responsePath ?? `response unavailable for ${job.id}`}`;
|
|
111
|
+
const artifactsPath = options.artifactsPath ?? `artifacts unavailable for ${job.id}`;
|
|
112
|
+
return [
|
|
113
|
+
`Oracle job ${job.id} is ${job.status}.`,
|
|
114
|
+
`Use oracle_read with jobId ${job.id} to open the response and settle wake-up retries.`,
|
|
115
|
+
responseLine,
|
|
116
|
+
`Artifacts: ${artifactsPath}`,
|
|
117
|
+
formatOracleLifecycleEvent(getLatestOracleJobLifecycleEvent(job)) ? `Last event: ${formatOracleLifecycleEvent(getLatestOracleJobLifecycleEvent(job))}` : undefined,
|
|
118
|
+
job.error ? `Error: ${job.error}` : "After oracle_read, continue from the oracle output.",
|
|
119
|
+
].filter(Boolean).join("\n");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {OracleJobSummaryLike & { promptPath: string; archivePath: string }} job
|
|
124
|
+
* @param {OracleSubmitResponseOptions} options
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
export function formatOracleSubmitResponse(job, options) {
|
|
128
|
+
return [
|
|
129
|
+
`${options.queued ? "Oracle job queued" : "Oracle job dispatched"}: ${job.id}`,
|
|
130
|
+
options.queued && options.queuePosition && options.queueDepth ? `Queue position: ${options.queuePosition} of ${options.queueDepth}` : undefined,
|
|
131
|
+
job.followUpToJobId ? `Follow-up to: ${job.followUpToJobId}` : undefined,
|
|
132
|
+
`Prompt: ${job.promptPath}`,
|
|
133
|
+
`Archive: ${job.archivePath}`,
|
|
134
|
+
formatAutoPrunedArchiveMessage(options.autoPrunedPrefixes),
|
|
135
|
+
`Response will be written to: ${job.responsePath}`,
|
|
136
|
+
formatOracleLifecycleEvent(getLatestOracleJobLifecycleEvent(job)) ? `Last event: ${formatOracleLifecycleEvent(getLatestOracleJobLifecycleEvent(job))}` : undefined,
|
|
137
|
+
options.queued ? "The job will start automatically when capacity is available." : undefined,
|
|
138
|
+
"Stop now and wait for the oracle completion wake-up.",
|
|
139
|
+
]
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
.join("\n");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {OracleStatusCounts} counts
|
|
146
|
+
* @returns {string}
|
|
147
|
+
*/
|
|
148
|
+
export function buildOracleStatusText(counts) {
|
|
149
|
+
if (counts.active > 0 && counts.queued > 0) {
|
|
150
|
+
return `oracle: running (${counts.active}), queued (${counts.queued})`;
|
|
151
|
+
}
|
|
152
|
+
if (counts.active > 0) {
|
|
153
|
+
const suffix = counts.active > 1 ? ` (${counts.active})` : "";
|
|
154
|
+
return `oracle: running${suffix}`;
|
|
155
|
+
}
|
|
156
|
+
if (counts.queued > 0) {
|
|
157
|
+
const suffix = counts.queued > 1 ? ` (${counts.queued})` : "";
|
|
158
|
+
return `oracle: queued${suffix}`;
|
|
159
|
+
}
|
|
160
|
+
return "oracle: ready";
|
|
161
|
+
}
|
|
@@ -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>;
|