pi-oracle 0.7.10 → 0.7.12
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 +23 -0
- package/README.md +13 -13
- package/docs/ORACLE_DESIGN.md +6 -5
- package/docs/ORACLE_ISOLATED_PI_VALIDATION.md +1 -1
- package/docs/platform-smoke.md +2 -2
- package/extensions/oracle/lib/archive.ts +723 -0
- package/extensions/oracle/lib/config.ts +16 -7
- package/extensions/oracle/lib/jobs.ts +2 -1
- package/extensions/oracle/lib/provider-capabilities.ts +41 -0
- package/extensions/oracle/lib/runtime.ts +9 -5
- package/extensions/oracle/lib/tools.ts +10 -576
- package/package.json +3 -3
- package/platform-smoke.config.mjs +1 -1
- package/scripts/oracle-real-smoke.mjs +4 -2
- package/scripts/platform-smoke/targets.mjs +1 -1
- package/scripts/platform-smoke.mjs +1 -1
|
@@ -4,13 +4,10 @@
|
|
|
4
4
|
// Usage: Imported by the oracle extension entrypoint and sanity tests to register tools against the pi API.
|
|
5
5
|
// Invariants/Assumptions: The pi runtime validates TypeBox schemas before execute, while execute owns semantic normalization.
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
|
-
import {
|
|
8
|
-
import { once } from "node:events";
|
|
9
|
-
import { createReadStream } from "node:fs";
|
|
10
|
-
import { lstat, mkdtemp, readdir, readlink, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { rename, rm, stat } from "node:fs/promises";
|
|
11
8
|
import { tmpdir } from "node:os";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { createArchive, type ArchiveCreationResult, type ArchiveSizeBreakdownRow } from "./archive.js";
|
|
14
11
|
import { runOracleAuthBootstrap } from "./auth.js";
|
|
15
12
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
13
|
import { Type } from "typebox";
|
|
@@ -45,7 +42,6 @@ import {
|
|
|
45
42
|
pruneTerminalOracleJobs,
|
|
46
43
|
reconcileStaleOracleJobs,
|
|
47
44
|
resolveArchiveInputs,
|
|
48
|
-
sha256File,
|
|
49
45
|
shouldAdvanceQueueAfterCancellation,
|
|
50
46
|
spawnWorker,
|
|
51
47
|
terminateWorkerPid,
|
|
@@ -53,6 +49,7 @@ import {
|
|
|
53
49
|
type OracleJob,
|
|
54
50
|
} from "./jobs.js";
|
|
55
51
|
import { getQueuePosition, promoteQueuedJobs, promoteQueuedJobsWithinAdmissionLock } from "./queue.js";
|
|
52
|
+
import { resolveOracleProviderArchivePlan } from "./provider-capabilities.js";
|
|
56
53
|
import { refreshOracleStatus } from "./poller.js";
|
|
57
54
|
import {
|
|
58
55
|
allocateRuntime,
|
|
@@ -124,568 +121,8 @@ const ORACLE_CANCEL_PARAMS = Type.Object({
|
|
|
124
121
|
jobId: Type.String({ description: "Oracle job id." }),
|
|
125
122
|
});
|
|
126
123
|
|
|
127
|
-
const CHATGPT_MAX_ARCHIVE_BYTES = 250 * 1024 * 1024;
|
|
128
|
-
const GROK_MAX_ARCHIVE_BYTES = 200 * 1024 * 1024;
|
|
129
|
-
const MAX_ARCHIVE_BYTES = CHATGPT_MAX_ARCHIVE_BYTES;
|
|
130
124
|
const MAX_QUEUED_JOBS_PER_ACTIVE_RUNTIME = 1;
|
|
131
|
-
const MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME =
|
|
132
|
-
const ARCHIVE_COMMAND_TIMEOUT_MS = 120_000;
|
|
133
|
-
const ARCHIVE_COMMAND_KILL_GRACE_MS = 2_000;
|
|
134
|
-
const ARCHIVE_PIPE_FAILURE_ERROR_CODES = new Set(["EPIPE", "ERR_STREAM_DESTROYED"]);
|
|
135
|
-
|
|
136
|
-
const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE = new Set([
|
|
137
|
-
".git",
|
|
138
|
-
".hg",
|
|
139
|
-
".svn",
|
|
140
|
-
".pi",
|
|
141
|
-
".oracle-context",
|
|
142
|
-
".cursor",
|
|
143
|
-
".artifacts",
|
|
144
|
-
".crabbox",
|
|
145
|
-
"node_modules",
|
|
146
|
-
"target",
|
|
147
|
-
".venv",
|
|
148
|
-
"venv",
|
|
149
|
-
"__pycache__",
|
|
150
|
-
".pytest_cache",
|
|
151
|
-
".mypy_cache",
|
|
152
|
-
".ruff_cache",
|
|
153
|
-
".tox",
|
|
154
|
-
".nox",
|
|
155
|
-
".hypothesis",
|
|
156
|
-
".next",
|
|
157
|
-
".nuxt",
|
|
158
|
-
".svelte-kit",
|
|
159
|
-
".turbo",
|
|
160
|
-
".parcel-cache",
|
|
161
|
-
".cache",
|
|
162
|
-
".gradle",
|
|
163
|
-
".terraform",
|
|
164
|
-
"DerivedData",
|
|
165
|
-
".build",
|
|
166
|
-
".pnpm-store",
|
|
167
|
-
".serverless",
|
|
168
|
-
".aws-sam",
|
|
169
|
-
"secrets",
|
|
170
|
-
".secrets",
|
|
171
|
-
]);
|
|
172
|
-
const DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_AT_REPO_ROOT = new Set(["coverage", "htmlcov", "tmp", "temp", ".tmp", "dist", "build", "out"]);
|
|
173
|
-
const DEFAULT_ARCHIVE_EXCLUDED_FILES = new Set([
|
|
174
|
-
".coverage",
|
|
175
|
-
".DS_Store",
|
|
176
|
-
".env",
|
|
177
|
-
".netrc",
|
|
178
|
-
".npmrc",
|
|
179
|
-
".pypirc",
|
|
180
|
-
".scratchpad.md",
|
|
181
|
-
"Thumbs.db",
|
|
182
|
-
"id_dsa",
|
|
183
|
-
"id_ecdsa",
|
|
184
|
-
"id_ed25519",
|
|
185
|
-
"id_rsa",
|
|
186
|
-
]);
|
|
187
|
-
const DEFAULT_ARCHIVE_EXCLUDED_SUFFIXES = [".db", ".key", ".p12", ".pfx", ".pyc", ".pyd", ".pyo", ".pem", ".sqlite", ".sqlite3", ".tsbuildinfo", ".tfstate"];
|
|
188
|
-
const DEFAULT_ARCHIVE_EXCLUDED_SUBSTRINGS = [".tfstate."];
|
|
189
|
-
const DEFAULT_ARCHIVE_EXCLUDED_ENV_ALLOWLIST = new Set([".env.dist", ".env.example", ".env.sample", ".env.template"]);
|
|
190
|
-
const DEFAULT_ARCHIVE_EXCLUDED_PATH_SEQUENCES = [[".yarn", "cache"]] as const;
|
|
191
|
-
const ADAPTIVE_ARCHIVE_PRUNE_DIR_NAMES_ANYWHERE = new Set(["build", "dist", "out", "coverage", "htmlcov", "tmp", "temp", ".tmp"]);
|
|
192
|
-
const ADAPTIVE_ARCHIVE_PRUNE_PROTECTED_ANCESTOR_DIR_NAMES = new Set(["src", "source", "sources", "lib"]);
|
|
193
|
-
|
|
194
|
-
type ArchiveSizeBreakdownRow = { relativePath: string; bytes: number };
|
|
195
|
-
type ArchiveCreationResult = {
|
|
196
|
-
sha256: string;
|
|
197
|
-
archiveBytes: number;
|
|
198
|
-
initialArchiveBytes?: number;
|
|
199
|
-
autoPrunedPrefixes: ArchiveSizeBreakdownRow[];
|
|
200
|
-
includedEntries: string[];
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
function appendArchiveEntries(target: string[], source: Iterable<string>): void {
|
|
204
|
-
for (const entry of source) target.push(entry);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function getErrorCode(error: unknown): string | undefined {
|
|
208
|
-
return error && typeof error === "object" && "code" in error && typeof error.code === "string"
|
|
209
|
-
? error.code
|
|
210
|
-
: undefined;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function mergeArchiveEntryGroups(groups: Iterable<Iterable<string>>): string[] {
|
|
214
|
-
const merged: string[] = [];
|
|
215
|
-
for (const group of groups) appendArchiveEntries(merged, group);
|
|
216
|
-
return merged;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export function mergeArchiveEntryGroupsForTesting(groups: Iterable<Iterable<string>>): string[] {
|
|
220
|
-
return mergeArchiveEntryGroups(groups);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function pathContainsSequence(relativePath: string, sequence: readonly string[]): boolean {
|
|
224
|
-
const segments = relativePath.split("/").filter(Boolean);
|
|
225
|
-
if (sequence.length === 0 || segments.length < sequence.length) return false;
|
|
226
|
-
for (let index = 0; index <= segments.length - sequence.length; index += 1) {
|
|
227
|
-
if (sequence.every((segment, offset) => segments[index + offset] === segment)) return true;
|
|
228
|
-
}
|
|
229
|
-
return false;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function getRelativeDepth(relativePath: string): number {
|
|
233
|
-
return relativePath.split("/").filter(Boolean).length;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function formatBytes(bytes: number): string {
|
|
237
|
-
return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function formatDirectoryLabel(relativePath: string): string {
|
|
241
|
-
return relativePath.endsWith("/") ? relativePath : `${relativePath}/`;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function summarizeByKey(
|
|
245
|
-
entrySizes: ArchiveSizeBreakdownRow[],
|
|
246
|
-
keyForEntry: (relativePath: string) => string | undefined,
|
|
247
|
-
limit = 7,
|
|
248
|
-
): ArchiveSizeBreakdownRow[] {
|
|
249
|
-
const totals = new Map<string, number>();
|
|
250
|
-
for (const entry of entrySizes) {
|
|
251
|
-
const key = keyForEntry(entry.relativePath);
|
|
252
|
-
if (!key) continue;
|
|
253
|
-
totals.set(key, (totals.get(key) ?? 0) + entry.bytes);
|
|
254
|
-
}
|
|
255
|
-
return [...totals.entries()]
|
|
256
|
-
.map(([relativePath, bytes]) => ({ relativePath, bytes }))
|
|
257
|
-
.sort((left, right) => right.bytes - left.bytes || left.relativePath.localeCompare(right.relativePath))
|
|
258
|
-
.slice(0, limit);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function summarizeTopLevelIncludedPaths(entrySizes: ArchiveSizeBreakdownRow[]): ArchiveSizeBreakdownRow[] {
|
|
262
|
-
return summarizeByKey(entrySizes, (relativePath) => {
|
|
263
|
-
const [topLevel, ...rest] = relativePath.split("/").filter(Boolean);
|
|
264
|
-
if (!topLevel) return undefined;
|
|
265
|
-
return rest.length > 0 ? `${topLevel}/` : topLevel;
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
function getAdaptivePrunePrefix(relativePath: string): string | undefined {
|
|
270
|
-
const segments = relativePath.split("/").filter(Boolean);
|
|
271
|
-
for (let index = 0; index < segments.length - 1; index += 1) {
|
|
272
|
-
const name = segments[index];
|
|
273
|
-
if (!ADAPTIVE_ARCHIVE_PRUNE_DIR_NAMES_ANYWHERE.has(name)) continue;
|
|
274
|
-
const ancestors = segments.slice(0, index);
|
|
275
|
-
if (ancestors.some((segment) => ADAPTIVE_ARCHIVE_PRUNE_PROTECTED_ANCESTOR_DIR_NAMES.has(segment))) continue;
|
|
276
|
-
return segments.slice(0, index + 1).join("/");
|
|
277
|
-
}
|
|
278
|
-
return undefined;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function summarizeAdaptivePruneCandidates(
|
|
282
|
-
entrySizes: ArchiveSizeBreakdownRow[],
|
|
283
|
-
minimumBytes = 0,
|
|
284
|
-
): ArchiveSizeBreakdownRow[] {
|
|
285
|
-
return summarizeByKey(entrySizes, getAdaptivePrunePrefix, Number.POSITIVE_INFINITY).filter((entry) => entry.bytes >= minimumBytes);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function pruneEntriesByPrefix(entries: string[], prefix: string): string[] {
|
|
289
|
-
return entries.filter((entry) => entry !== prefix && !entry.startsWith(`${prefix}/`));
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function shouldExcludeArchivePath(relativePath: string, isDirectory: boolean, options?: { forceInclude?: boolean }): boolean {
|
|
293
|
-
const normalized = posix.normalize(relativePath).replace(/^\.\//, "");
|
|
294
|
-
if (!normalized || normalized === ".") return false;
|
|
295
|
-
if (options?.forceInclude) return false;
|
|
296
|
-
const name = basename(normalized);
|
|
297
|
-
if (DEFAULT_ARCHIVE_EXCLUDED_PATH_SEQUENCES.some((sequence) => pathContainsSequence(normalized, sequence))) return true;
|
|
298
|
-
if (isDirectory) {
|
|
299
|
-
if (DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_ANYWHERE.has(name)) return true;
|
|
300
|
-
if (getRelativeDepth(normalized) === 1 && DEFAULT_ARCHIVE_EXCLUDED_DIR_NAMES_AT_REPO_ROOT.has(name)) return true;
|
|
301
|
-
return false;
|
|
302
|
-
}
|
|
303
|
-
if (DEFAULT_ARCHIVE_EXCLUDED_FILES.has(name)) return true;
|
|
304
|
-
if (name.startsWith(".env.") && !DEFAULT_ARCHIVE_EXCLUDED_ENV_ALLOWLIST.has(name)) return true;
|
|
305
|
-
if (DEFAULT_ARCHIVE_EXCLUDED_SUFFIXES.some((suffix) => name.endsWith(suffix))) return true;
|
|
306
|
-
if (DEFAULT_ARCHIVE_EXCLUDED_SUBSTRINGS.some((needle) => name.includes(needle))) return true;
|
|
307
|
-
return false;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
async function isSymlinkToDirectory(path: string): Promise<boolean> {
|
|
311
|
-
try {
|
|
312
|
-
return (await stat(path)).isDirectory();
|
|
313
|
-
} catch {
|
|
314
|
-
return false;
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
async function shouldExcludeArchiveChild(
|
|
319
|
-
absolutePath: string,
|
|
320
|
-
relativePath: string,
|
|
321
|
-
child: { isDirectory(): boolean; isSymbolicLink(): boolean },
|
|
322
|
-
options?: { forceInclude?: boolean },
|
|
323
|
-
): Promise<boolean> {
|
|
324
|
-
const isDirectoryLike = child.isDirectory() || (child.isSymbolicLink() && await isSymlinkToDirectory(absolutePath));
|
|
325
|
-
return shouldExcludeArchivePath(relativePath, isDirectoryLike, options);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
async function expandArchiveEntries(cwd: string, relativePath: string, options?: { forceIncludeSubtree?: boolean }): Promise<string[]> {
|
|
329
|
-
const normalized = posix.normalize(relativePath).replace(/^\.\//, "");
|
|
330
|
-
if (normalized === ".") {
|
|
331
|
-
const children = await readdir(cwd, { withFileTypes: true });
|
|
332
|
-
const results: string[] = [];
|
|
333
|
-
for (const child of children.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
334
|
-
const childRelative = child.name;
|
|
335
|
-
if (await shouldExcludeArchiveChild(join(cwd, childRelative), childRelative, child)) continue;
|
|
336
|
-
if (child.isDirectory()) appendArchiveEntries(results, await expandArchiveEntries(cwd, childRelative));
|
|
337
|
-
else results.push(childRelative);
|
|
338
|
-
}
|
|
339
|
-
return results;
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
const absolute = join(cwd, normalized);
|
|
343
|
-
const entry = await lstat(absolute);
|
|
344
|
-
if (!entry.isDirectory()) return [normalized];
|
|
345
|
-
if (shouldExcludeArchivePath(normalized, true, { forceInclude: options?.forceIncludeSubtree })) return [];
|
|
346
|
-
|
|
347
|
-
const children = await readdir(absolute, { withFileTypes: true });
|
|
348
|
-
const results: string[] = [];
|
|
349
|
-
for (const child of children.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
350
|
-
const childRelative = posix.join(normalized, child.name);
|
|
351
|
-
if (await shouldExcludeArchiveChild(join(cwd, childRelative), childRelative, child, { forceInclude: options?.forceIncludeSubtree })) continue;
|
|
352
|
-
if (child.isDirectory()) appendArchiveEntries(results, await expandArchiveEntries(cwd, childRelative, { forceIncludeSubtree: options?.forceIncludeSubtree }));
|
|
353
|
-
else results.push(childRelative);
|
|
354
|
-
}
|
|
355
|
-
return results;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
async function resolveExpandedArchiveEntriesFromInputs(
|
|
359
|
-
cwd: string,
|
|
360
|
-
entries: Array<{ absolute: string; relative: string }>,
|
|
361
|
-
): Promise<string[]> {
|
|
362
|
-
const expandedGroups = await Promise.all(entries.map(async (entry) => {
|
|
363
|
-
const statEntry = await lstat(entry.absolute);
|
|
364
|
-
const forceIncludeSubtree = statEntry.isDirectory() && entry.relative !== "." && shouldExcludeArchivePath(entry.relative, true);
|
|
365
|
-
return expandArchiveEntries(cwd, entry.relative, { forceIncludeSubtree });
|
|
366
|
-
}));
|
|
367
|
-
return Array.from(new Set(mergeArchiveEntryGroups(expandedGroups))).sort();
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
export async function resolveExpandedArchiveEntries(cwd: string, files: string[]): Promise<string[]> {
|
|
371
|
-
return resolveExpandedArchiveEntriesFromInputs(cwd, resolveArchiveInputs(cwd, files));
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function isWholeRepoArchiveSelection(entries: Array<{ absolute: string; relative: string }>): boolean {
|
|
375
|
-
return entries.length === 1 && entries[0]?.relative === ".";
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
async function measureArchiveEntrySizes(cwd: string, entries: string[]): Promise<ArchiveSizeBreakdownRow[]> {
|
|
379
|
-
return Promise.all(entries.map(async (relativePath) => ({ relativePath, bytes: (await lstat(join(cwd, relativePath))).size })));
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function formatArchiveOversizeError(args: {
|
|
383
|
-
archiveBytes: number;
|
|
384
|
-
maxBytes: number;
|
|
385
|
-
entrySizes: ArchiveSizeBreakdownRow[];
|
|
386
|
-
autoPrunedPrefixes: ArchiveSizeBreakdownRow[];
|
|
387
|
-
adaptivePruneMinBytes?: number;
|
|
388
|
-
}): string {
|
|
389
|
-
const topLevel = summarizeTopLevelIncludedPaths(args.entrySizes);
|
|
390
|
-
const adaptiveCandidates = summarizeAdaptivePruneCandidates(args.entrySizes, args.adaptivePruneMinBytes).slice(0, 7);
|
|
391
|
-
return [
|
|
392
|
-
`Oracle archive exceeds provider upload limit (${formatBytes(args.maxBytes)}) after default exclusions${args.autoPrunedPrefixes.length > 0 ? " and automatic generic generated-output-dir pruning" : ""}.`,
|
|
393
|
-
`The local archive measured ${formatBytes(args.archiveBytes)} (${args.archiveBytes} bytes), so submission stopped before dispatch.`,
|
|
394
|
-
args.autoPrunedPrefixes.length > 0 ? "Automatically pruned generic generated-output paths before failing:" : undefined,
|
|
395
|
-
...args.autoPrunedPrefixes.map((entry) => `- ${formatDirectoryLabel(entry.relativePath)} — ${formatBytes(entry.bytes)}`),
|
|
396
|
-
topLevel.length > 0 ? "Approx top-level included sizes:" : undefined,
|
|
397
|
-
...topLevel.map((entry) => `- ${entry.relativePath} — ${formatBytes(entry.bytes)}`),
|
|
398
|
-
adaptiveCandidates.length > 0 ? "Largest remaining generic generated-output-dir candidates:" : undefined,
|
|
399
|
-
...adaptiveCandidates.map((entry) => `- ${formatDirectoryLabel(entry.relativePath)} — ${formatBytes(entry.bytes)}`),
|
|
400
|
-
"Recommended retry order: (1) remove the largest obviously irrelevant/generated/history/export content, (2) if it still does not fit, keep only the directly relevant subtrees plus adjacent docs/tests/config, (3) if it still does not fit, explain what was cut before asking the user.",
|
|
401
|
-
]
|
|
402
|
-
.filter(Boolean)
|
|
403
|
-
.join("\n");
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function writeOctal(value: number, width: number): Buffer {
|
|
407
|
-
const text = Math.max(0, Math.floor(value)).toString(8).slice(-(width - 1)).padStart(width - 1, "0") + "\0";
|
|
408
|
-
return Buffer.from(text, "ascii");
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
function writeTarName(header: Buffer, name: string): void {
|
|
412
|
-
const normalized = name.replaceAll("\\", "/");
|
|
413
|
-
const nameBytes = Buffer.byteLength(normalized);
|
|
414
|
-
if (nameBytes <= 100) {
|
|
415
|
-
header.write(normalized, 0, 100, "utf8");
|
|
416
|
-
return;
|
|
417
|
-
}
|
|
418
|
-
const parts = normalized.split("/");
|
|
419
|
-
const fileName = parts.pop() || "";
|
|
420
|
-
const prefix = parts.join("/");
|
|
421
|
-
if (Buffer.byteLength(fileName) > 100 || Buffer.byteLength(prefix) > 155) {
|
|
422
|
-
throw new Error(`archive path is too long for portable tar header: ${normalized}`);
|
|
423
|
-
}
|
|
424
|
-
header.write(fileName, 0, 100, "utf8");
|
|
425
|
-
header.write(prefix, 345, 155, "utf8");
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
function buildTarHeader(name: string, options: { mode: number; size: number; mtimeMs: number; type: "file" | "directory" | "symlink"; linkName?: string }): Buffer {
|
|
429
|
-
const header = Buffer.alloc(512);
|
|
430
|
-
writeTarName(header, options.type === "directory" && !name.endsWith("/") ? `${name}/` : name);
|
|
431
|
-
writeOctal(options.mode & 0o7777, 8).copy(header, 100);
|
|
432
|
-
writeOctal(0, 8).copy(header, 108);
|
|
433
|
-
writeOctal(0, 8).copy(header, 116);
|
|
434
|
-
writeOctal(options.size, 12).copy(header, 124);
|
|
435
|
-
writeOctal(Math.floor(options.mtimeMs / 1000), 12).copy(header, 136);
|
|
436
|
-
Buffer.from(" ", "ascii").copy(header, 148);
|
|
437
|
-
header[156] = options.type === "directory" ? 53 : options.type === "symlink" ? 50 : 48;
|
|
438
|
-
if (options.linkName) header.write(options.linkName.replaceAll("\\", "/"), 157, 100, "utf8");
|
|
439
|
-
header.write("ustar", 257, 6, "ascii");
|
|
440
|
-
header.write("00", 263, 2, "ascii");
|
|
441
|
-
let checksum = 0;
|
|
442
|
-
for (const byte of header) checksum += byte;
|
|
443
|
-
const checksumText = checksum.toString(8).padStart(6, "0");
|
|
444
|
-
header.write(`${checksumText}\0 `, 148, 8, "ascii");
|
|
445
|
-
return header;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
async function writeChunk(stream: NodeJS.WritableStream, chunk: Buffer): Promise<void> {
|
|
449
|
-
if (!stream.write(chunk)) await once(stream, "drain");
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
async function writeWindowsTarArchiveToZstd(cwd: string, entries: string[], archivePath: string, timeout: AbortSignal): Promise<void> {
|
|
453
|
-
const scrubbedEnv = sweetCookieSafeStoragePasswordScrubbedEnv(process.env);
|
|
454
|
-
const zstd = spawn("zstd", ["-19", "-T0", "-f", "-o", archivePath], {
|
|
455
|
-
cwd,
|
|
456
|
-
env: scrubbedEnv,
|
|
457
|
-
stdio: ["pipe", "ignore", "pipe"],
|
|
458
|
-
signal: timeout,
|
|
459
|
-
});
|
|
460
|
-
let stderr = "";
|
|
461
|
-
zstd.stderr?.on("data", (chunk) => {
|
|
462
|
-
stderr += String(chunk);
|
|
463
|
-
});
|
|
464
|
-
try {
|
|
465
|
-
for (const entry of entries) {
|
|
466
|
-
const normalizedEntry = entry.replaceAll("\\", "/");
|
|
467
|
-
const absolutePath = join(cwd, normalizedEntry);
|
|
468
|
-
const info = await lstat(absolutePath);
|
|
469
|
-
if (info.isSymbolicLink()) {
|
|
470
|
-
await writeChunk(zstd.stdin, buildTarHeader(normalizedEntry, { mode: info.mode, size: 0, mtimeMs: info.mtimeMs, type: "symlink", linkName: await readlink(absolutePath) }));
|
|
471
|
-
continue;
|
|
472
|
-
}
|
|
473
|
-
if (info.isDirectory()) {
|
|
474
|
-
await writeChunk(zstd.stdin, buildTarHeader(normalizedEntry, { mode: info.mode, size: 0, mtimeMs: info.mtimeMs, type: "directory" }));
|
|
475
|
-
continue;
|
|
476
|
-
}
|
|
477
|
-
if (!info.isFile()) continue;
|
|
478
|
-
await writeChunk(zstd.stdin, buildTarHeader(normalizedEntry, { mode: info.mode, size: info.size, mtimeMs: info.mtimeMs, type: "file" }));
|
|
479
|
-
for await (const chunk of createReadStream(absolutePath)) {
|
|
480
|
-
await writeChunk(zstd.stdin, Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
481
|
-
}
|
|
482
|
-
const padding = info.size % 512 === 0 ? 0 : 512 - (info.size % 512);
|
|
483
|
-
if (padding > 0) await writeChunk(zstd.stdin, Buffer.alloc(padding));
|
|
484
|
-
}
|
|
485
|
-
await writeChunk(zstd.stdin, Buffer.alloc(1024));
|
|
486
|
-
zstd.stdin.end();
|
|
487
|
-
} catch (error) {
|
|
488
|
-
zstd.stdin.destroy();
|
|
489
|
-
zstd.kill();
|
|
490
|
-
throw error;
|
|
491
|
-
}
|
|
492
|
-
const code = await new Promise<number | null>((resolve, reject) => {
|
|
493
|
-
zstd.once("error", reject);
|
|
494
|
-
zstd.once("close", resolve);
|
|
495
|
-
});
|
|
496
|
-
if (code !== 0) throw new Error(`zstd archive compression failed with status ${code}: ${stderr.trim()}`);
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
async function writeArchiveFile(
|
|
500
|
-
cwd: string,
|
|
501
|
-
entries: string[],
|
|
502
|
-
archivePath: string,
|
|
503
|
-
listPath: string,
|
|
504
|
-
options?: { commandTimeoutMs?: number },
|
|
505
|
-
): Promise<number> {
|
|
506
|
-
await writeFile(listPath, Buffer.from(`${entries.join("\0")}\0`), { mode: 0o600 });
|
|
507
|
-
await rm(archivePath, { force: true }).catch(() => undefined);
|
|
508
|
-
|
|
509
|
-
if (process.platform === "win32") {
|
|
510
|
-
const timeoutController = new AbortController();
|
|
511
|
-
const timeout = setTimeout(() => timeoutController.abort(), options?.commandTimeoutMs ?? ARCHIVE_COMMAND_TIMEOUT_MS);
|
|
512
|
-
try {
|
|
513
|
-
await writeWindowsTarArchiveToZstd(cwd, entries, archivePath, timeoutController.signal);
|
|
514
|
-
return (await stat(archivePath)).size;
|
|
515
|
-
} catch (error) {
|
|
516
|
-
if (timeoutController.signal.aborted) {
|
|
517
|
-
throw new Error(`Oracle archive subprocess timed out after ${options?.commandTimeoutMs ?? ARCHIVE_COMMAND_TIMEOUT_MS}ms`);
|
|
518
|
-
}
|
|
519
|
-
throw error;
|
|
520
|
-
} finally {
|
|
521
|
-
clearTimeout(timeout);
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
await new Promise<void>((resolvePromise, rejectPromise) => {
|
|
526
|
-
const scrubbedEnv = sweetCookieSafeStoragePasswordScrubbedEnv();
|
|
527
|
-
const tarArgs = ["--null", "-cf", "-", "-C", cwd, "-T", basename(listPath)];
|
|
528
|
-
const tar = spawn(process.env.PI_ORACLE_TEST_TAR_BIN ?? "tar", tarArgs, {
|
|
529
|
-
cwd: dirname(listPath),
|
|
530
|
-
env: scrubbedEnv,
|
|
531
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
532
|
-
});
|
|
533
|
-
const zstd = spawn(process.env.PI_ORACLE_TEST_ZSTD_BIN ?? "zstd", ["-19", "-T0", "-f", "-o", archivePath], {
|
|
534
|
-
env: scrubbedEnv,
|
|
535
|
-
stdio: ["pipe", "ignore", "pipe"],
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
let stderr = "";
|
|
539
|
-
let settled = false;
|
|
540
|
-
let timedOut = false;
|
|
541
|
-
let timeout: NodeJS.Timeout | undefined;
|
|
542
|
-
let killGraceTimer: NodeJS.Timeout | undefined;
|
|
543
|
-
let tarCode: number | null | undefined;
|
|
544
|
-
let zstdCode: number | null | undefined;
|
|
545
|
-
|
|
546
|
-
const clearTimers = () => {
|
|
547
|
-
if (timeout) clearTimeout(timeout);
|
|
548
|
-
if (killGraceTimer) clearTimeout(killGraceTimer);
|
|
549
|
-
};
|
|
550
|
-
|
|
551
|
-
const terminateChildren = () => {
|
|
552
|
-
tar.kill("SIGTERM");
|
|
553
|
-
zstd.kill("SIGTERM");
|
|
554
|
-
killGraceTimer = setTimeout(() => {
|
|
555
|
-
tar.kill("SIGKILL");
|
|
556
|
-
zstd.kill("SIGKILL");
|
|
557
|
-
}, ARCHIVE_COMMAND_KILL_GRACE_MS);
|
|
558
|
-
killGraceTimer.unref?.();
|
|
559
|
-
};
|
|
560
|
-
|
|
561
|
-
const finish = (error?: Error) => {
|
|
562
|
-
if (settled) return;
|
|
563
|
-
if (error) {
|
|
564
|
-
settled = true;
|
|
565
|
-
clearTimers();
|
|
566
|
-
terminateChildren();
|
|
567
|
-
rejectPromise(error);
|
|
568
|
-
return;
|
|
569
|
-
}
|
|
570
|
-
if (tarCode === undefined || zstdCode === undefined) return;
|
|
571
|
-
settled = true;
|
|
572
|
-
clearTimers();
|
|
573
|
-
if (timedOut) {
|
|
574
|
-
rejectPromise(new Error(stderr || `Oracle archive subprocess timed out after ${options?.commandTimeoutMs ?? ARCHIVE_COMMAND_TIMEOUT_MS}ms`));
|
|
575
|
-
return;
|
|
576
|
-
}
|
|
577
|
-
if (tarCode === 0 && zstdCode === 0) resolvePromise();
|
|
578
|
-
else rejectPromise(new Error(stderr || `archive command failed (tar=${tarCode}, zstd=${zstdCode})`));
|
|
579
|
-
};
|
|
580
|
-
|
|
581
|
-
const handlePipeError = (error: unknown) => {
|
|
582
|
-
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
583
|
-
if (ARCHIVE_PIPE_FAILURE_ERROR_CODES.has(getErrorCode(normalized) ?? "")) {
|
|
584
|
-
stderr = `${stderr}${stderr ? "\n" : ""}${normalized.message}`;
|
|
585
|
-
tar.stdout.unpipe(zstd.stdin);
|
|
586
|
-
terminateChildren();
|
|
587
|
-
finish();
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
finish(normalized);
|
|
591
|
-
};
|
|
592
|
-
|
|
593
|
-
const commandTimeoutMs = options?.commandTimeoutMs ?? ARCHIVE_COMMAND_TIMEOUT_MS;
|
|
594
|
-
if (commandTimeoutMs > 0) {
|
|
595
|
-
timeout = setTimeout(() => {
|
|
596
|
-
timedOut = true;
|
|
597
|
-
stderr = `${stderr}${stderr ? "\n" : ""}Oracle archive subprocess timed out after ${commandTimeoutMs}ms`;
|
|
598
|
-
terminateChildren();
|
|
599
|
-
}, commandTimeoutMs);
|
|
600
|
-
timeout.unref?.();
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
tar.stderr.on("data", (data) => {
|
|
604
|
-
stderr += String(data);
|
|
605
|
-
});
|
|
606
|
-
zstd.stderr.on("data", (data) => {
|
|
607
|
-
stderr += String(data);
|
|
608
|
-
});
|
|
609
|
-
tar.on("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
|
|
610
|
-
zstd.on("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
|
|
611
|
-
tar.stdout.on("error", handlePipeError);
|
|
612
|
-
zstd.stdin.on("error", handlePipeError);
|
|
613
|
-
tar.on("close", (code) => {
|
|
614
|
-
tarCode = code;
|
|
615
|
-
finish();
|
|
616
|
-
});
|
|
617
|
-
zstd.on("close", (code) => {
|
|
618
|
-
zstdCode = code;
|
|
619
|
-
finish();
|
|
620
|
-
});
|
|
621
|
-
tar.stdout.pipe(zstd.stdin);
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
return (await stat(archivePath)).size;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
export async function createArchiveForTesting(
|
|
628
|
-
cwd: string,
|
|
629
|
-
files: string[],
|
|
630
|
-
archivePath: string,
|
|
631
|
-
options?: { maxBytes?: number; adaptivePruneMinBytes?: number; commandTimeoutMs?: number },
|
|
632
|
-
): Promise<ArchiveCreationResult> {
|
|
633
|
-
const archiveInputs = resolveArchiveInputs(cwd, files);
|
|
634
|
-
const wholeRepoSelection = isWholeRepoArchiveSelection(archiveInputs);
|
|
635
|
-
let expandedEntries = await resolveExpandedArchiveEntriesFromInputs(cwd, archiveInputs);
|
|
636
|
-
if (expandedEntries.length === 0) {
|
|
637
|
-
throw new Error("Oracle archive inputs are empty after default exclusions");
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
const listDir = await mkdtemp(join(tmpdir(), "oracle-filelist-"));
|
|
641
|
-
const listPath = join(listDir, "files.list");
|
|
642
|
-
const maxBytes = options?.maxBytes ?? MAX_ARCHIVE_BYTES;
|
|
643
|
-
const adaptivePruneMinBytes = options?.adaptivePruneMinBytes ?? 0;
|
|
644
|
-
const autoPrunedPrefixes: ArchiveSizeBreakdownRow[] = [];
|
|
645
|
-
let initialArchiveBytes: number | undefined;
|
|
646
|
-
|
|
647
|
-
try {
|
|
648
|
-
while (true) {
|
|
649
|
-
if (expandedEntries.length === 0) {
|
|
650
|
-
throw new Error("Oracle archive inputs are empty after default exclusions and automatic size pruning");
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
const archiveBytes = await writeArchiveFile(cwd, expandedEntries, archivePath, listPath, { commandTimeoutMs: options?.commandTimeoutMs });
|
|
654
|
-
if (archiveBytes <= maxBytes) {
|
|
655
|
-
return {
|
|
656
|
-
sha256: await sha256File(archivePath),
|
|
657
|
-
archiveBytes,
|
|
658
|
-
initialArchiveBytes,
|
|
659
|
-
autoPrunedPrefixes,
|
|
660
|
-
includedEntries: [...expandedEntries],
|
|
661
|
-
};
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
if (initialArchiveBytes === undefined) initialArchiveBytes = archiveBytes;
|
|
665
|
-
const entrySizes = await measureArchiveEntrySizes(cwd, expandedEntries);
|
|
666
|
-
if (!wholeRepoSelection) {
|
|
667
|
-
throw new Error(formatArchiveOversizeError({ archiveBytes, maxBytes, entrySizes, autoPrunedPrefixes, adaptivePruneMinBytes }));
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
const nextCandidate = summarizeAdaptivePruneCandidates(entrySizes, adaptivePruneMinBytes).find(
|
|
671
|
-
(entry) => !autoPrunedPrefixes.some((pruned) => pruned.relativePath === entry.relativePath),
|
|
672
|
-
);
|
|
673
|
-
if (!nextCandidate) {
|
|
674
|
-
throw new Error(formatArchiveOversizeError({ archiveBytes, maxBytes, entrySizes, autoPrunedPrefixes, adaptivePruneMinBytes }));
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
autoPrunedPrefixes.push(nextCandidate);
|
|
678
|
-
expandedEntries = pruneEntriesByPrefix(expandedEntries, nextCandidate.relativePath);
|
|
679
|
-
}
|
|
680
|
-
} finally {
|
|
681
|
-
await rm(listDir, { recursive: true, force: true }).catch(() => undefined);
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
async function createArchive(cwd: string, files: string[], archivePath: string, maxBytes = MAX_ARCHIVE_BYTES): Promise<ArchiveCreationResult> {
|
|
686
|
-
return createArchiveForTesting(cwd, files, archivePath, { maxBytes });
|
|
687
|
-
}
|
|
688
|
-
|
|
125
|
+
const MAX_QUEUED_ARCHIVE_BYTES_PER_ACTIVE_RUNTIME = resolveOracleProviderArchivePlan("chatgpt").maxArchiveBytes;
|
|
689
126
|
function normalizeOracleProvider(value: unknown, fallback: OracleProvider, toolName = "oracle_submit"): OracleProvider {
|
|
690
127
|
if (value === undefined) return fallback;
|
|
691
128
|
if (typeof value !== "string") throw new Error(`${toolName} provider must be a string`);
|
|
@@ -703,10 +140,6 @@ function normalizeGrokMode(value: unknown, fallback: "heavy"): "heavy" {
|
|
|
703
140
|
throw new Error(`Unknown Grok oracle mode: ${value}. Only heavy is currently supported.`);
|
|
704
141
|
}
|
|
705
142
|
|
|
706
|
-
function getProviderMaxArchiveBytes(provider: "chatgpt" | "grok"): number {
|
|
707
|
-
return provider === "grok" ? GROK_MAX_ARCHIVE_BYTES : CHATGPT_MAX_ARCHIVE_BYTES;
|
|
708
|
-
}
|
|
709
|
-
|
|
710
143
|
export interface QueuedArchivePressure {
|
|
711
144
|
queuedJobs: number;
|
|
712
145
|
queuedArchiveBytes: number;
|
|
@@ -1308,7 +741,7 @@ async function runOraclePreflight(ctx: ExtensionContext, params: { provider?: un
|
|
|
1308
741
|
}
|
|
1309
742
|
|
|
1310
743
|
try {
|
|
1311
|
-
await assertOracleSubmitPrerequisites(config);
|
|
744
|
+
await assertOracleSubmitPrerequisites(config, provider);
|
|
1312
745
|
} catch (error) {
|
|
1313
746
|
const errorDetails = buildOracleToolErrorDetails("oracle_preflight", error, asRecord(params) ?? {});
|
|
1314
747
|
return {
|
|
@@ -1452,7 +885,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string, authWo
|
|
|
1452
885
|
const targetChatUrl = target.chatUrl;
|
|
1453
886
|
// Validate caller-specified archive paths before surfacing unrelated local setup failures such as a missing auth seed profile.
|
|
1454
887
|
resolveArchiveInputs(projectCwd, params.files);
|
|
1455
|
-
await assertOracleSubmitPrerequisites(config);
|
|
888
|
+
await assertOracleSubmitPrerequisites(config, provider);
|
|
1456
889
|
try {
|
|
1457
890
|
await withGlobalReconcileLock({ processPid: process.pid, source: "oracle_submit", cwd: projectCwd }, async () => {
|
|
1458
891
|
await reconcileStaleOracleJobs();
|
|
@@ -1463,7 +896,8 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string, authWo
|
|
|
1463
896
|
}
|
|
1464
897
|
|
|
1465
898
|
const jobId = randomUUID();
|
|
1466
|
-
const
|
|
899
|
+
const archivePlan = resolveOracleProviderArchivePlan(selection.provider);
|
|
900
|
+
const tempArchivePath = join(tmpdir(), `oracle-archive-${jobId}.${archivePlan.archiveExtension}`);
|
|
1467
901
|
const runtime = allocateRuntime(config);
|
|
1468
902
|
let job: OracleJob | undefined;
|
|
1469
903
|
let archive: ArchiveCreationResult | undefined;
|
|
@@ -1475,7 +909,7 @@ export function registerOracleTools(pi: ExtensionAPI, workerPath: string, authWo
|
|
|
1475
909
|
let spawnedWorker: Awaited<ReturnType<typeof spawnWorker>> | undefined;
|
|
1476
910
|
|
|
1477
911
|
try {
|
|
1478
|
-
archive = await createArchive(projectCwd, params.files, tempArchivePath,
|
|
912
|
+
archive = await createArchive(projectCwd, params.files, tempArchivePath, archivePlan.maxArchiveBytes, archivePlan.archiveFormat);
|
|
1479
913
|
const currentArchive = archive;
|
|
1480
914
|
await withLock("admission", "global", { jobId, processPid: process.pid }, async () => {
|
|
1481
915
|
await promoteQueuedJobsWithinAdmissionLock({ workerPath, source: "oracle_submit" });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-oracle",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.12",
|
|
4
4
|
"description": "ChatGPT and Grok web-oracle extension for pi with isolated browser auth, async jobs, and project-context archives.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"license": "MIT",
|
|
@@ -80,8 +80,8 @@
|
|
|
80
80
|
"protobufjs": "7.6.1"
|
|
81
81
|
},
|
|
82
82
|
"devDependencies": {
|
|
83
|
-
"@earendil-works/pi-ai": "0.79.
|
|
84
|
-
"@earendil-works/pi-coding-agent": "0.79.
|
|
83
|
+
"@earendil-works/pi-ai": "0.79.4",
|
|
84
|
+
"@earendil-works/pi-coding-agent": "0.79.4",
|
|
85
85
|
"@types/node": "^22.19.19",
|
|
86
86
|
"esbuild": "^0.28.0",
|
|
87
87
|
"tsx": "^4.22.3",
|