@steipete/oracle 0.17.3 → 0.19.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/README.md +1 -1
- package/dist/bin/oracle-cli.js +75 -64
- package/dist/bin/oracle-mcp.js +0 -0
- package/dist/src/browser/actions/archiveConversation.js +31 -7
- package/dist/src/browser/actions/assistantResponse.js +43 -1
- package/dist/src/browser/actions/attachmentContext.js +239 -0
- package/dist/src/browser/actions/attachmentDataTransfer.js +19 -1
- package/dist/src/browser/actions/attachmentEvidence.js +78 -0
- package/dist/src/browser/actions/attachmentProgress.js +41 -0
- package/dist/src/browser/actions/attachmentPrompt.js +130 -0
- package/dist/src/browser/actions/attachments.js +306 -355
- package/dist/src/browser/actions/deepResearch.js +2 -1
- package/dist/src/browser/actions/modelSelection.js +13 -3
- package/dist/src/browser/actions/navigation.js +1 -1
- package/dist/src/browser/actions/promptComposer.js +361 -186
- package/dist/src/browser/actions/remoteFileTransfer.js +4 -2
- package/dist/src/browser/actions/thinkingStatus.js +2 -5
- package/dist/src/browser/actions/thinkingTime.js +267 -15
- package/dist/src/browser/attachRunning.js +46 -2
- package/dist/src/browser/attachmentValidation.js +53 -0
- package/dist/src/browser/cancellation.js +105 -0
- package/dist/src/browser/chatgptImages.js +2 -0
- package/dist/src/browser/chatgptThrottle.js +104 -0
- package/dist/src/browser/chromeLifecycle.js +267 -43
- package/dist/src/browser/config.js +15 -3
- package/dist/src/browser/detect.js +1 -1
- package/dist/src/browser/index.js +447 -263
- package/dist/src/browser/modelDisplay.js +9 -0
- package/dist/src/browser/pageActions.js +2 -2
- package/dist/src/browser/policies.js +6 -2
- package/dist/src/browser/profileState.js +114 -9
- package/dist/src/browser/projectSourcesRunner.js +88 -17
- package/dist/src/browser/prompt.js +272 -113
- package/dist/src/browser/providers/chatgptDomProvider.js +2 -0
- package/dist/src/browser/reattach.js +14 -4
- package/dist/src/browser/sessionRunner.js +71 -11
- package/dist/src/browser/tabLeaseRegistry.js +414 -38
- package/dist/src/browser/utils.js +20 -2
- package/dist/src/cli/browserConfig.js +40 -15
- package/dist/src/cli/browserDefaults.js +13 -0
- package/dist/src/cli/browserTabs.js +12 -29
- package/dist/src/cli/detachedSession.js +69 -0
- package/dist/src/cli/dryRun.js +58 -47
- package/dist/src/cli/harvestIntegrity.js +140 -0
- package/dist/src/cli/projectSources.js +5 -3
- package/dist/src/cli/serveBrowserConfig.js +24 -0
- package/dist/src/cli/sessionDisplay.js +9 -2
- package/dist/src/cli/sessionRunner.js +91 -7
- package/dist/src/config.js +1 -0
- package/dist/src/gemini-web/client.js +67 -21
- package/dist/src/gemini-web/executor.js +4 -0
- package/dist/src/gemini-web/http.js +15 -0
- package/dist/src/gemini-web/models.js +15 -11
- package/dist/src/mcp/server.js +11 -14
- package/dist/src/mcp/tools/chatgptImage.js +4 -4
- package/dist/src/mcp/tools/consult.js +97 -26
- package/dist/src/mcp/tools/projectSources.js +4 -6
- package/dist/src/mcp/tools/sessionResources.js +1 -1
- package/dist/src/mcp/tools/sessions.js +2 -2
- package/dist/src/mcp/tools/wait.js +182 -0
- package/dist/src/mcp/types.js +7 -0
- package/dist/src/oracle/errors.js +11 -0
- package/dist/src/oracle/providerRoutePlan.js +3 -2
- package/dist/src/remote/client.js +70 -6
- package/dist/src/remote/health.js +6 -2
- package/dist/src/remote/runSlots.js +83 -0
- package/dist/src/remote/server.js +351 -72
- package/dist/src/sessionManager.js +36 -1
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/dist/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/package.json +25 -23
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/CodeResources +0 -0
- package/vendor/oracle-notifier/OracleNotifier.app/Contents/MacOS/OracleNotifier +0 -0
- package/dist/bin/oracle.js +0 -683
- package/dist/docs-site/.nojekyll +0 -0
- package/dist/docs-site/CNAME +0 -1
- package/dist/docs-site/RELEASING.html +0 -410
- package/dist/docs-site/agents.html +0 -374
- package/dist/docs-site/anthropic.html +0 -368
- package/dist/docs-site/bridge.html +0 -400
- package/dist/docs-site/browser-mode.html +0 -588
- package/dist/docs-site/chromium-forks.html +0 -347
- package/dist/docs-site/cli-reference.html +0 -346
- package/dist/docs-site/configuration.html +0 -436
- package/dist/docs-site/favicon.svg +0 -14
- package/dist/docs-site/followup.html +0 -368
- package/dist/docs-site/gemini.html +0 -379
- package/dist/docs-site/grok.html +0 -325
- package/dist/docs-site/index.html +0 -360
- package/dist/docs-site/install.html +0 -335
- package/dist/docs-site/linux.html +0 -321
- package/dist/docs-site/llms.txt +0 -43
- package/dist/docs-site/manual-tests.html +0 -595
- package/dist/docs-site/mcp.html +0 -368
- package/dist/docs-site/multimodel.html +0 -364
- package/dist/docs-site/mythical-pro-agents.html +0 -360
- package/dist/docs-site/notifier.html +0 -338
- package/dist/docs-site/openai-endpoints.html +0 -387
- package/dist/docs-site/openrouter.html +0 -344
- package/dist/docs-site/quickstart.html +0 -369
- package/dist/docs-site/refactor/ux.html +0 -532
- package/dist/docs-site/sessions.html +0 -388
- package/dist/docs-site/social-card.png +0 -0
- package/dist/docs-site/social-card.svg +0 -79
- package/dist/docs-site/spec.html +0 -363
- package/dist/docs-site/testing.html +0 -320
- package/dist/docs-site/tui-debug.html +0 -326
- package/dist/docs-site/windows-work.html +0 -323
- package/dist/docs-site/windows.html +0 -320
- package/dist/markdansi/types/index.js +0 -4
- package/dist/oracle/bin/oracle-cli.js +0 -472
- package/dist/oracle/src/browser/actions/assistantResponse.js +0 -471
- package/dist/oracle/src/browser/actions/attachments.js +0 -82
- package/dist/oracle/src/browser/actions/modelSelection.js +0 -190
- package/dist/oracle/src/browser/actions/navigation.js +0 -75
- package/dist/oracle/src/browser/actions/promptComposer.js +0 -167
- package/dist/oracle/src/browser/chromeLifecycle.js +0 -104
- package/dist/oracle/src/browser/config.js +0 -33
- package/dist/oracle/src/browser/constants.js +0 -40
- package/dist/oracle/src/browser/cookies.js +0 -210
- package/dist/oracle/src/browser/domDebug.js +0 -36
- package/dist/oracle/src/browser/index.js +0 -331
- package/dist/oracle/src/browser/pageActions.js +0 -5
- package/dist/oracle/src/browser/prompt.js +0 -88
- package/dist/oracle/src/browser/promptSummary.js +0 -20
- package/dist/oracle/src/browser/sessionRunner.js +0 -80
- package/dist/oracle/src/browser/types.js +0 -1
- package/dist/oracle/src/browser/utils.js +0 -62
- package/dist/oracle/src/browserMode.js +0 -1
- package/dist/oracle/src/cli/browserConfig.js +0 -44
- package/dist/oracle/src/cli/dryRun.js +0 -59
- package/dist/oracle/src/cli/engine.js +0 -17
- package/dist/oracle/src/cli/errorUtils.js +0 -9
- package/dist/oracle/src/cli/help.js +0 -70
- package/dist/oracle/src/cli/markdownRenderer.js +0 -15
- package/dist/oracle/src/cli/options.js +0 -103
- package/dist/oracle/src/cli/promptRequirement.js +0 -14
- package/dist/oracle/src/cli/rootAlias.js +0 -30
- package/dist/oracle/src/cli/sessionCommand.js +0 -77
- package/dist/oracle/src/cli/sessionDisplay.js +0 -270
- package/dist/oracle/src/cli/sessionRunner.js +0 -94
- package/dist/oracle/src/heartbeat.js +0 -43
- package/dist/oracle/src/oracle/client.js +0 -48
- package/dist/oracle/src/oracle/config.js +0 -29
- package/dist/oracle/src/oracle/errors.js +0 -101
- package/dist/oracle/src/oracle/files.js +0 -220
- package/dist/oracle/src/oracle/format.js +0 -33
- package/dist/oracle/src/oracle/fsAdapter.js +0 -7
- package/dist/oracle/src/oracle/oscProgress.js +0 -60
- package/dist/oracle/src/oracle/request.js +0 -48
- package/dist/oracle/src/oracle/run.js +0 -444
- package/dist/oracle/src/oracle/tokenStats.js +0 -39
- package/dist/oracle/src/oracle/types.js +0 -1
- package/dist/oracle/src/oracle.js +0 -9
- package/dist/oracle/src/sessionManager.js +0 -205
- package/dist/oracle/src/version.js +0 -39
- package/dist/scripts/chrome/browser-tools.js +0 -295
- package/dist/src/browser/chromeCookies.js +0 -312
- package/dist/src/browser/keytarShim.js +0 -56
- package/dist/src/browser/profileSync.js +0 -141
- package/dist/src/browser/windowsCookies.js +0 -219
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { lstat, mkdir, readFile, realpath, rename, rm, rmdir, stat, writeFile, } from "node:fs/promises";
|
|
5
|
+
import { isProcessAlive, readProcessStartTimeMs } from "./profileState.js";
|
|
5
6
|
import { delay } from "./utils.js";
|
|
6
7
|
export const DEFAULT_MAX_CONCURRENT_CHATGPT_TABS = 3;
|
|
7
8
|
const REGISTRY_FILENAME = "oracle-tab-leases.json";
|
|
8
9
|
const REGISTRY_LOCK_DIRNAME = "oracle-tab-leases.lock";
|
|
9
10
|
const DEFAULT_POLL_MS = 1000;
|
|
10
|
-
const DEFAULT_STALE_MS =
|
|
11
|
+
const DEFAULT_STALE_MS = 2 * 60 * 1000;
|
|
12
|
+
const LEASE_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
11
13
|
const REGISTRY_LOCK_TIMEOUT_MS = 10_000;
|
|
14
|
+
const REGISTRY_LOCK_OWNER_FILENAME = "owner.json";
|
|
15
|
+
const REGISTRY_RECOVERY_POLL_MS = 50;
|
|
16
|
+
const LEGACY_OWNERLESS_LOCK_STALE_MS = 5 * 60 * 1000;
|
|
17
|
+
const PROCESS_START_TIME_TOLERANCE_MS = 10_000;
|
|
18
|
+
const REGISTRY_RENAME_RETRY_ATTEMPTS = 6;
|
|
12
19
|
export function normalizeMaxConcurrentTabs(value) {
|
|
13
20
|
if (value === undefined || value === null) {
|
|
14
21
|
return DEFAULT_MAX_CONCURRENT_CHATGPT_TABS;
|
|
@@ -26,17 +33,21 @@ export async function acquireBrowserTabLease(profileDir, options, deps = {}) {
|
|
|
26
33
|
const staleMs = Math.max(60_000, options.staleMs ?? DEFAULT_STALE_MS);
|
|
27
34
|
const now = deps.now ?? Date.now;
|
|
28
35
|
const pid = deps.pid ?? process.pid;
|
|
36
|
+
const processStartedAtMs = await (deps.readProcessStartTimeMs ?? readProcessStartTimeMs)(pid);
|
|
29
37
|
const leaseId = randomUUID();
|
|
30
38
|
const startedAt = now();
|
|
31
39
|
let warned = false;
|
|
32
40
|
let lastHeartbeatAt = 0;
|
|
33
41
|
for (;;) {
|
|
42
|
+
options.signal?.throwIfAborted();
|
|
34
43
|
const acquired = await withRegistryLock(profileDir, async () => {
|
|
44
|
+
options.signal?.throwIfAborted();
|
|
35
45
|
const registry = await readRegistry(profileDir);
|
|
36
|
-
const active = pruneStaleLeases(registry.leases, {
|
|
46
|
+
const active = await pruneStaleLeases(registry.leases, {
|
|
37
47
|
nowMs: now(),
|
|
38
48
|
staleMs,
|
|
39
49
|
isProcessAlive: deps.isProcessAlive ?? isProcessAlive,
|
|
50
|
+
readProcessStartTimeMs: deps.readProcessStartTimeMs ?? readProcessStartTimeMs,
|
|
40
51
|
});
|
|
41
52
|
if (active.length >= maxConcurrentTabs) {
|
|
42
53
|
if (active.length !== registry.leases.length) {
|
|
@@ -48,6 +59,7 @@ export async function acquireBrowserTabLease(profileDir, options, deps = {}) {
|
|
|
48
59
|
const lease = {
|
|
49
60
|
id: leaseId,
|
|
50
61
|
pid,
|
|
62
|
+
...(processStartedAtMs === null ? {} : { processStartedAtMs }),
|
|
51
63
|
sessionId: options.sessionId,
|
|
52
64
|
chromeHost: options.chromeHost,
|
|
53
65
|
chromePort: options.chromePort,
|
|
@@ -56,13 +68,51 @@ export async function acquireBrowserTabLease(profileDir, options, deps = {}) {
|
|
|
56
68
|
};
|
|
57
69
|
await writeRegistry(profileDir, { version: 1, leases: [...active, lease] });
|
|
58
70
|
return lease;
|
|
59
|
-
});
|
|
71
|
+
}, options.signal);
|
|
60
72
|
if (acquired) {
|
|
61
73
|
options.logger?.(`[browser] Acquired ChatGPT browser slot ${leaseId.slice(0, 8)} (${maxConcurrentTabs} max).`);
|
|
74
|
+
let released = false;
|
|
75
|
+
let heartbeatInFlight = false;
|
|
76
|
+
let heartbeatPromise = null;
|
|
77
|
+
let heartbeatWarningEmitted = false;
|
|
78
|
+
const heartbeat = setInterval(() => {
|
|
79
|
+
if (released || heartbeatInFlight)
|
|
80
|
+
return;
|
|
81
|
+
heartbeatInFlight = true;
|
|
82
|
+
const heartbeatWork = updateBrowserTabLease(profileDir, leaseId, {})
|
|
83
|
+
.then(() => {
|
|
84
|
+
heartbeatWarningEmitted = false;
|
|
85
|
+
})
|
|
86
|
+
.catch((error) => {
|
|
87
|
+
if (!heartbeatWarningEmitted) {
|
|
88
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
89
|
+
options.logger?.(`[browser] ChatGPT browser slot heartbeat failed; preserving the slot until stale grace expires: ${message}`);
|
|
90
|
+
heartbeatWarningEmitted = true;
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
.finally(() => {
|
|
94
|
+
heartbeatInFlight = false;
|
|
95
|
+
if (heartbeatPromise === heartbeatWork)
|
|
96
|
+
heartbeatPromise = null;
|
|
97
|
+
});
|
|
98
|
+
heartbeatPromise = heartbeatWork;
|
|
99
|
+
}, Math.max(10, deps.heartbeatMs ?? LEASE_HEARTBEAT_INTERVAL_MS));
|
|
100
|
+
heartbeat.unref();
|
|
62
101
|
return {
|
|
63
102
|
id: leaseId,
|
|
64
|
-
release: async (releaseOptions) =>
|
|
65
|
-
|
|
103
|
+
release: async (releaseOptions) => {
|
|
104
|
+
if (released)
|
|
105
|
+
return;
|
|
106
|
+
released = true;
|
|
107
|
+
clearInterval(heartbeat);
|
|
108
|
+
await heartbeatPromise?.catch(() => undefined);
|
|
109
|
+
await releaseBrowserTabLease(profileDir, leaseId, options.logger, releaseOptions);
|
|
110
|
+
},
|
|
111
|
+
update: async (patch) => {
|
|
112
|
+
if (released)
|
|
113
|
+
return;
|
|
114
|
+
await updateBrowserTabLease(profileDir, leaseId, patch);
|
|
115
|
+
},
|
|
66
116
|
};
|
|
67
117
|
}
|
|
68
118
|
const elapsed = now() - startedAt;
|
|
@@ -74,12 +124,15 @@ export async function acquireBrowserTabLease(profileDir, options, deps = {}) {
|
|
|
74
124
|
if (timeoutMs > 0 && elapsed >= timeoutMs) {
|
|
75
125
|
throw new Error(`Timed out waiting for ChatGPT browser slot after ${Math.round(elapsed / 1000)}s (${maxConcurrentTabs} max).`);
|
|
76
126
|
}
|
|
77
|
-
await delay(timeoutMs > 0 ? Math.min(pollMs, timeoutMs - elapsed) : pollMs);
|
|
127
|
+
await delay(timeoutMs > 0 ? Math.min(pollMs, timeoutMs - elapsed) : pollMs, options.signal);
|
|
78
128
|
}
|
|
79
129
|
}
|
|
80
130
|
export async function updateBrowserTabLease(profileDir, leaseId, patch) {
|
|
81
131
|
await withRegistryLock(profileDir, async () => {
|
|
82
132
|
const registry = await readRegistry(profileDir);
|
|
133
|
+
if (!registry.leases.some((lease) => lease.id === leaseId)) {
|
|
134
|
+
throw new Error(`Oracle ChatGPT browser slot ${leaseId.slice(0, 8)} is no longer owned by this controller.`);
|
|
135
|
+
}
|
|
83
136
|
const leases = registry.leases.map((lease) => lease.id === leaseId
|
|
84
137
|
? { ...lease, ...patch, id: lease.id, updatedAt: new Date().toISOString() }
|
|
85
138
|
: lease);
|
|
@@ -89,26 +142,44 @@ export async function updateBrowserTabLease(profileDir, leaseId, patch) {
|
|
|
89
142
|
export async function releaseBrowserTabLease(profileDir, leaseId, logger, options = {}) {
|
|
90
143
|
await withRegistryLock(profileDir, async () => {
|
|
91
144
|
const registry = await readRegistry(profileDir);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
145
|
+
// Release is a safety-critical reference-count decrement. Never prune other
|
|
146
|
+
// controllers here: a transient cross-process liveness false-negative would
|
|
147
|
+
// make this run look like the final lease and allow it to terminate shared
|
|
148
|
+
// Chrome underneath live tabs. Stale records are reclaimed by a later acquire.
|
|
149
|
+
const releasedLease = registry.leases.find((lease) => lease.id === leaseId);
|
|
150
|
+
if (!releasedLease) {
|
|
151
|
+
throw new Error(`Oracle ChatGPT browser slot ${leaseId.slice(0, 8)} was already lost; refusing final shared-Chrome cleanup.`);
|
|
152
|
+
}
|
|
153
|
+
const leases = registry.leases.filter((lease) => lease.id !== leaseId);
|
|
98
154
|
await writeRegistry(profileDir, { version: 1, leases });
|
|
99
|
-
|
|
100
|
-
|
|
155
|
+
logger?.(`[browser] ChatGPT browser slot ${leaseId.slice(0, 8)} release decision: ` +
|
|
156
|
+
`isLastLease=${leases.length === 0}; remaining=${leases.length}; ` +
|
|
157
|
+
`remainingSessions=${formatRemainingLeaseSessions(leases)}.`);
|
|
158
|
+
await options.onRelease?.({
|
|
159
|
+
isLastLease: leases.length === 0,
|
|
160
|
+
releasedLease,
|
|
161
|
+
remainingLeases: leases,
|
|
162
|
+
});
|
|
163
|
+
});
|
|
101
164
|
logger?.(`[browser] Released ChatGPT browser slot ${leaseId.slice(0, 8)}.`);
|
|
102
165
|
}
|
|
166
|
+
function formatRemainingLeaseSessions(leases) {
|
|
167
|
+
if (leases.length === 0)
|
|
168
|
+
return "none";
|
|
169
|
+
return leases
|
|
170
|
+
.map((lease) => `${lease.id.slice(0, 8)}:${lease.sessionId ?? "unknown"}:pid${lease.pid}:target${lease.chromeTargetId ?? "pending"}`)
|
|
171
|
+
.join(",");
|
|
172
|
+
}
|
|
103
173
|
export async function hasOtherActiveBrowserTabLeases(profileDir, leaseId, options = {}) {
|
|
104
174
|
const now = options.now ?? Date.now;
|
|
105
175
|
const staleMs = Math.max(60_000, options.staleMs ?? DEFAULT_STALE_MS);
|
|
106
176
|
return withRegistryLock(profileDir, async () => {
|
|
107
177
|
const registry = await readRegistry(profileDir);
|
|
108
|
-
const active = pruneStaleLeases(registry.leases, {
|
|
178
|
+
const active = await pruneStaleLeases(registry.leases, {
|
|
109
179
|
nowMs: now(),
|
|
110
180
|
staleMs,
|
|
111
181
|
isProcessAlive: options.isProcessAlive ?? isProcessAlive,
|
|
182
|
+
readProcessStartTimeMs: options.readProcessStartTimeMs ?? readProcessStartTimeMs,
|
|
112
183
|
});
|
|
113
184
|
if (active.length !== registry.leases.length) {
|
|
114
185
|
await writeRegistry(profileDir, { version: 1, leases: active });
|
|
@@ -116,66 +187,366 @@ export async function hasOtherActiveBrowserTabLeases(profileDir, leaseId, option
|
|
|
116
187
|
return active.some((lease) => lease.id !== leaseId);
|
|
117
188
|
});
|
|
118
189
|
}
|
|
119
|
-
async function withRegistryLock(profileDir, callback) {
|
|
190
|
+
async function withRegistryLock(profileDir, callback, signal) {
|
|
120
191
|
const lockDir = path.join(profileDir, REGISTRY_LOCK_DIRNAME);
|
|
192
|
+
const lockId = randomUUID();
|
|
193
|
+
const processStartedAtMs = await readProcessStartTimeMs(process.pid);
|
|
121
194
|
const startedAt = Date.now();
|
|
122
195
|
for (;;) {
|
|
196
|
+
signal?.throwIfAborted();
|
|
123
197
|
try {
|
|
198
|
+
// mkdir is exclusive on every platform; rename can replace a live empty
|
|
199
|
+
// directory on POSIX (including locks held by older Oracle controllers).
|
|
124
200
|
await mkdir(lockDir, { recursive: false });
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
const code = error.code;
|
|
204
|
+
if (code !== "EEXIST" && code !== "EPERM" && code !== "ENOTEMPTY") {
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
const owner = await readRegistryLockOwner(lockDir);
|
|
208
|
+
if (owner && !(await isRegistryLockOwnerAlive(owner))) {
|
|
209
|
+
await recoverDeadRegistryLock(profileDir, lockDir, owner);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (!owner && (await isRegistryOwnerFileMissing(lockDir))) {
|
|
213
|
+
const identity = await readRegistryLockIdentity(lockDir);
|
|
214
|
+
if (identity && Date.now() - identity.mtimeMs >= LEGACY_OWNERLESS_LOCK_STALE_MS) {
|
|
215
|
+
await recoverOwnerlessRegistryLock(profileDir, lockDir, identity);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (Date.now() - startedAt > REGISTRY_LOCK_TIMEOUT_MS) {
|
|
220
|
+
throw new Error(`Timed out waiting for Oracle tab lease registry lock ${lockDir}; preserving the existing lock to avoid split-brain cleanup.`);
|
|
221
|
+
}
|
|
222
|
+
await delay(50, signal);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const acquiredIdentity = await readRegistryLockIdentity(lockDir);
|
|
226
|
+
const temporaryOwner = path.join(lockDir, `${lockId}.tmp`);
|
|
227
|
+
try {
|
|
228
|
+
// Publish only complete ownership evidence. A crash before rename leaves
|
|
229
|
+
// an ownerless lock, which the aged-lock recovery path can safely reclaim.
|
|
230
|
+
await writeFile(temporaryOwner, `${JSON.stringify({
|
|
231
|
+
id: lockId,
|
|
232
|
+
pid: process.pid,
|
|
233
|
+
createdAt: new Date().toISOString(),
|
|
234
|
+
...(processStartedAtMs === null ? {} : { processStartedAtMs }),
|
|
235
|
+
})}\n`, { encoding: "utf8", flag: "wx" });
|
|
236
|
+
await renameRegistryFileWithRetry(temporaryOwner, path.join(lockDir, REGISTRY_LOCK_OWNER_FILENAME));
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
await rm(temporaryOwner, { force: true }).catch(() => undefined);
|
|
240
|
+
await removeRegistryLockIfOwned(lockDir, lockId).catch(() => undefined);
|
|
241
|
+
if (acquiredIdentity &&
|
|
242
|
+
sameRegistryLockDirectory(await readRegistryLockIdentity(lockDir), acquiredIdentity)) {
|
|
243
|
+
// Only an unchanged empty directory can be removed without an owner.
|
|
244
|
+
await rmdir(lockDir).catch(() => undefined);
|
|
245
|
+
}
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
try {
|
|
251
|
+
return await callback();
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
await removeRegistryLockIfOwned(lockDir, lockId, { requireOwned: true });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
async function readRegistryLockOwner(lockDir, readFileImpl = readFile) {
|
|
258
|
+
try {
|
|
259
|
+
const raw = await readFileImpl(path.join(lockDir, REGISTRY_LOCK_OWNER_FILENAME), "utf8");
|
|
260
|
+
const parsed = JSON.parse(raw);
|
|
261
|
+
if (typeof parsed.id !== "string" ||
|
|
262
|
+
typeof parsed.pid !== "number" ||
|
|
263
|
+
!Number.isSafeInteger(parsed.pid) ||
|
|
264
|
+
parsed.pid <= 0 ||
|
|
265
|
+
(parsed.processStartedAtMs !== undefined &&
|
|
266
|
+
(typeof parsed.processStartedAtMs !== "number" ||
|
|
267
|
+
!Number.isFinite(parsed.processStartedAtMs)))) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
id: parsed.id,
|
|
272
|
+
pid: parsed.pid,
|
|
273
|
+
...(typeof parsed.createdAt === "string" ? { createdAt: parsed.createdAt } : {}),
|
|
274
|
+
...(typeof parsed.processStartedAtMs === "number"
|
|
275
|
+
? { processStartedAtMs: parsed.processStartedAtMs }
|
|
276
|
+
: {}),
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function isRegistryLockOwnerAlive(owner, deps = { isProcessAlive, readProcessStartTimeMs }) {
|
|
284
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
285
|
+
const alive = deps.isProcessAlive(owner.pid);
|
|
286
|
+
const actualStartedAtMs = await deps.readProcessStartTimeMs(owner.pid);
|
|
287
|
+
// A positive independent identity probe outranks a transient native PID miss.
|
|
288
|
+
if (actualStartedAtMs !== null) {
|
|
289
|
+
if (owner.processStartedAtMs !== undefined) {
|
|
290
|
+
return (Math.abs(actualStartedAtMs - owner.processStartedAtMs) <= PROCESS_START_TIME_TOLERANCE_MS);
|
|
291
|
+
}
|
|
292
|
+
const createdAt = owner.createdAt ? Date.parse(owner.createdAt) : Number.NaN;
|
|
293
|
+
return (!Number.isFinite(createdAt) ||
|
|
294
|
+
actualStartedAtMs <= createdAt + PROCESS_START_TIME_TOLERANCE_MS);
|
|
295
|
+
}
|
|
296
|
+
if (alive)
|
|
297
|
+
return true;
|
|
298
|
+
if (attempt === 0)
|
|
299
|
+
await delay(REGISTRY_RECOVERY_POLL_MS);
|
|
300
|
+
}
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
async function isRegistryOwnerFileMissing(lockDir) {
|
|
304
|
+
try {
|
|
305
|
+
await lstat(path.join(lockDir, REGISTRY_LOCK_OWNER_FILENAME));
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
return error.code === "ENOENT";
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
async function readRegistryLockIdentity(lockDir) {
|
|
313
|
+
try {
|
|
314
|
+
const value = await stat(lockDir);
|
|
315
|
+
return {
|
|
316
|
+
dev: value.dev,
|
|
317
|
+
ino: value.ino,
|
|
318
|
+
birthtimeMs: value.birthtimeMs,
|
|
319
|
+
mtimeMs: value.mtimeMs,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
function sameRegistryLockIdentity(left, right) {
|
|
327
|
+
return sameRegistryLockDirectory(left, right) && left?.mtimeMs === right.mtimeMs;
|
|
328
|
+
}
|
|
329
|
+
function sameRegistryLockDirectory(left, right) {
|
|
330
|
+
return (left !== null &&
|
|
331
|
+
left.dev === right.dev &&
|
|
332
|
+
left.ino === right.ino &&
|
|
333
|
+
left.birthtimeMs === right.birthtimeMs);
|
|
334
|
+
}
|
|
335
|
+
async function removeRegistryLockIfOwned(lockDir, lockId, options = {}) {
|
|
336
|
+
const owner = await readRegistryLockOwnerWithRetry(lockDir, options.readFileImpl);
|
|
337
|
+
const lockIdentity = owner ? null : await readRegistryLockIdentity(lockDir);
|
|
338
|
+
if (!owner) {
|
|
339
|
+
if (lockIdentity && options.requireOwned) {
|
|
340
|
+
throw new Error(`Unable to verify ownership while releasing Oracle tab lease registry lock ${lockDir}.`);
|
|
341
|
+
}
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
if (owner?.id !== lockId) {
|
|
345
|
+
if (options.requireOwned) {
|
|
346
|
+
throw new Error(`Oracle tab lease registry lock ${lockDir} changed owners before release; preserving it.`);
|
|
347
|
+
}
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
await removeRegistryLockDirectoryWithRetry(lockDir, options.removeImpl);
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
async function readRegistryLockOwnerWithRetry(lockDir, readFileImpl = readFile) {
|
|
354
|
+
for (let attempt = 0;; attempt += 1) {
|
|
355
|
+
const owner = await readRegistryLockOwner(lockDir, readFileImpl);
|
|
356
|
+
if (owner)
|
|
357
|
+
return owner;
|
|
358
|
+
const identity = await readRegistryLockIdentity(lockDir);
|
|
359
|
+
if (!identity || attempt + 1 >= REGISTRY_RENAME_RETRY_ATTEMPTS)
|
|
360
|
+
return null;
|
|
361
|
+
await delay(25 * (attempt + 1));
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
async function removeRegistryLockDirectoryWithRetry(lockDir, removeImpl = rm) {
|
|
365
|
+
for (let attempt = 0;; attempt += 1) {
|
|
366
|
+
try {
|
|
367
|
+
await removeImpl(lockDir, { recursive: true, force: true });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
const code = error.code;
|
|
372
|
+
const transient = code === "EPERM" || code === "EBUSY" || code === "EACCES";
|
|
373
|
+
if (!transient || attempt + 1 >= REGISTRY_RENAME_RETRY_ATTEMPTS)
|
|
374
|
+
throw error;
|
|
375
|
+
await delay(50 * (attempt + 1));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
export async function removeRegistryLockIfOwnedForTest(lockDir, lockId, options = {}) {
|
|
380
|
+
return removeRegistryLockIfOwned(lockDir, lockId, { ...options, requireOwned: true });
|
|
381
|
+
}
|
|
382
|
+
async function recoverDeadRegistryLock(profileDir, lockDir, observedOwner) {
|
|
383
|
+
await withRegistryRecoveryLock(profileDir, async () => {
|
|
384
|
+
// Re-read after acquiring the crash-safe recovery mutex. Another process may
|
|
385
|
+
// already have reaped the dead lock and installed a live owner.
|
|
386
|
+
const currentOwner = await readRegistryLockOwner(lockDir);
|
|
387
|
+
if (currentOwner?.id !== observedOwner.id ||
|
|
388
|
+
currentOwner.pid !== observedOwner.pid ||
|
|
389
|
+
currentOwner.createdAt !== observedOwner.createdAt ||
|
|
390
|
+
currentOwner.processStartedAtMs !== observedOwner.processStartedAtMs ||
|
|
391
|
+
(await isRegistryLockOwnerAlive(currentOwner))) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
await removeRegistryLockIfOwned(lockDir, currentOwner.id);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
async function recoverOwnerlessRegistryLock(profileDir, lockDir, observedIdentity) {
|
|
398
|
+
await withRegistryRecoveryLock(profileDir, async () => {
|
|
399
|
+
if ((await readRegistryLockOwner(lockDir)) || !(await isRegistryOwnerFileMissing(lockDir)))
|
|
400
|
+
return;
|
|
401
|
+
const currentIdentity = await readRegistryLockIdentity(lockDir);
|
|
402
|
+
if (!sameRegistryLockIdentity(currentIdentity, observedIdentity) ||
|
|
403
|
+
Date.now() - observedIdentity.mtimeMs < LEGACY_OWNERLESS_LOCK_STALE_MS) {
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
async function withRegistryRecoveryLock(profileDir, callback) {
|
|
410
|
+
const identity = await canonicalProfileIdentity(profileDir);
|
|
411
|
+
const digest = createHash("sha256").update(identity).digest("hex").slice(0, 24);
|
|
412
|
+
const windowsEndpoint = `\\\\.\\pipe\\oracle-tab-lease-recovery-${digest}`;
|
|
413
|
+
const posixPort = 20_000 + (Number.parseInt(digest.slice(0, 8), 16) % 40_000);
|
|
414
|
+
const endpoint = process.platform === "win32" ? windowsEndpoint : `127.0.0.1:${posixPort}`;
|
|
415
|
+
const startedAt = Date.now();
|
|
416
|
+
let server = null;
|
|
417
|
+
for (;;) {
|
|
418
|
+
const candidate = net.createServer((socket) => socket.destroy());
|
|
419
|
+
try {
|
|
420
|
+
await new Promise((resolve, reject) => {
|
|
421
|
+
candidate.once("error", reject);
|
|
422
|
+
const listenOptions = process.platform === "win32"
|
|
423
|
+
? windowsEndpoint
|
|
424
|
+
: { host: "127.0.0.1", port: posixPort, exclusive: true };
|
|
425
|
+
candidate.listen(listenOptions, () => {
|
|
426
|
+
candidate.removeListener("error", reject);
|
|
427
|
+
resolve();
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
server = candidate;
|
|
125
431
|
break;
|
|
126
432
|
}
|
|
127
433
|
catch (error) {
|
|
128
|
-
|
|
434
|
+
candidate.close();
|
|
435
|
+
if (error.code !== "EADDRINUSE") {
|
|
129
436
|
throw error;
|
|
130
437
|
}
|
|
131
438
|
if (Date.now() - startedAt > REGISTRY_LOCK_TIMEOUT_MS) {
|
|
132
|
-
|
|
133
|
-
continue;
|
|
439
|
+
throw new Error(`Timed out waiting for Oracle tab lease recovery mutex ${endpoint}.`);
|
|
134
440
|
}
|
|
135
|
-
await delay(
|
|
441
|
+
await delay(REGISTRY_RECOVERY_POLL_MS);
|
|
136
442
|
}
|
|
137
443
|
}
|
|
138
444
|
try {
|
|
139
445
|
return await callback();
|
|
140
446
|
}
|
|
141
447
|
finally {
|
|
142
|
-
|
|
448
|
+
if (server) {
|
|
449
|
+
await new Promise((resolve) => server?.close(() => resolve()));
|
|
450
|
+
}
|
|
143
451
|
}
|
|
144
452
|
}
|
|
453
|
+
async function canonicalProfileIdentity(profileDir) {
|
|
454
|
+
let resolved = path.resolve(profileDir);
|
|
455
|
+
try {
|
|
456
|
+
resolved = await realpath(resolved);
|
|
457
|
+
}
|
|
458
|
+
catch {
|
|
459
|
+
// The profile normally exists before lease acquisition. Keep an absolute,
|
|
460
|
+
// normalized fallback so an introspection failure remains fail-closed.
|
|
461
|
+
}
|
|
462
|
+
resolved = path.normalize(resolved);
|
|
463
|
+
if (process.platform === "win32") {
|
|
464
|
+
if (/^\\\\\?\\UNC\\/iu.test(resolved)) {
|
|
465
|
+
resolved = `\\\\${resolved.slice(8)}`;
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
resolved = resolved.replace(/^\\\\\?\\/u, "");
|
|
469
|
+
}
|
|
470
|
+
resolved = resolved.toLowerCase();
|
|
471
|
+
}
|
|
472
|
+
const root = path.parse(resolved).root;
|
|
473
|
+
while (resolved.length > root.length && /[\\/]$/u.test(resolved)) {
|
|
474
|
+
resolved = resolved.slice(0, -1);
|
|
475
|
+
}
|
|
476
|
+
return resolved;
|
|
477
|
+
}
|
|
478
|
+
export async function canonicalProfileIdentityForTest(profileDir) {
|
|
479
|
+
return canonicalProfileIdentity(profileDir);
|
|
480
|
+
}
|
|
145
481
|
async function readRegistry(profileDir) {
|
|
146
482
|
try {
|
|
147
483
|
const raw = await readFile(registryPath(profileDir), "utf8");
|
|
148
484
|
const parsed = JSON.parse(raw);
|
|
149
485
|
if (!Array.isArray(parsed.leases)) {
|
|
150
|
-
|
|
486
|
+
throw new Error("Oracle tab lease registry is missing its leases array.");
|
|
487
|
+
}
|
|
488
|
+
if (!parsed.leases.every(isLeaseRecord)) {
|
|
489
|
+
throw new Error("Oracle tab lease registry contains an invalid lease record.");
|
|
151
490
|
}
|
|
152
491
|
return {
|
|
153
492
|
version: 1,
|
|
154
|
-
leases: parsed.leases
|
|
493
|
+
leases: parsed.leases,
|
|
155
494
|
};
|
|
156
495
|
}
|
|
157
|
-
catch {
|
|
158
|
-
|
|
496
|
+
catch (error) {
|
|
497
|
+
if (error.code === "ENOENT") {
|
|
498
|
+
return { version: 1, leases: [] };
|
|
499
|
+
}
|
|
500
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
501
|
+
throw new Error(`Unable to read Oracle tab lease registry: ${message}`, { cause: error });
|
|
159
502
|
}
|
|
160
503
|
}
|
|
161
504
|
async function writeRegistry(profileDir, registry) {
|
|
162
505
|
await mkdir(profileDir, { recursive: true });
|
|
163
|
-
|
|
506
|
+
const destination = registryPath(profileDir);
|
|
507
|
+
const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`;
|
|
508
|
+
try {
|
|
509
|
+
await writeFile(temporary, `${JSON.stringify(registry, null, 2)}\n`, "utf8");
|
|
510
|
+
await renameRegistryFileWithRetry(temporary, destination);
|
|
511
|
+
}
|
|
512
|
+
finally {
|
|
513
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
async function renameRegistryFileWithRetry(source, destination) {
|
|
517
|
+
for (let attempt = 0;; attempt += 1) {
|
|
518
|
+
try {
|
|
519
|
+
await rename(source, destination);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
const code = error.code;
|
|
524
|
+
const transient = code === "EPERM" || code === "EBUSY" || code === "EACCES";
|
|
525
|
+
if (!transient || attempt + 1 >= REGISTRY_RENAME_RETRY_ATTEMPTS)
|
|
526
|
+
throw error;
|
|
527
|
+
await delay(25 * (attempt + 1));
|
|
528
|
+
}
|
|
529
|
+
}
|
|
164
530
|
}
|
|
165
531
|
function registryPath(profileDir) {
|
|
166
532
|
return path.join(profileDir, REGISTRY_FILENAME);
|
|
167
533
|
}
|
|
168
|
-
function pruneStaleLeases(leases, options) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
534
|
+
async function pruneStaleLeases(leases, options) {
|
|
535
|
+
const active = [];
|
|
536
|
+
for (const lease of leases) {
|
|
173
537
|
const updatedAt = Date.parse(lease.updatedAt);
|
|
174
|
-
|
|
175
|
-
|
|
538
|
+
// One cross-process liveness probe is not trustworthy enough to delete a
|
|
539
|
+
// fresh foreign lease. Active controllers refresh updatedAt every 15s; only
|
|
540
|
+
// a missed heartbeat beyond the grace period permits identity verification.
|
|
541
|
+
// Invalid timestamps fail closed.
|
|
542
|
+
if (!Number.isFinite(updatedAt) || options.nowMs - updatedAt <= options.staleMs) {
|
|
543
|
+
active.push(lease);
|
|
544
|
+
continue;
|
|
176
545
|
}
|
|
177
|
-
|
|
178
|
-
|
|
546
|
+
if (await isRegistryLockOwnerAlive(lease, options))
|
|
547
|
+
active.push(lease);
|
|
548
|
+
}
|
|
549
|
+
return active;
|
|
179
550
|
}
|
|
180
551
|
function isLeaseRecord(value) {
|
|
181
552
|
if (!value || typeof value !== "object")
|
|
@@ -183,6 +554,11 @@ function isLeaseRecord(value) {
|
|
|
183
554
|
const record = value;
|
|
184
555
|
return (typeof record.id === "string" &&
|
|
185
556
|
typeof record.pid === "number" &&
|
|
557
|
+
Number.isSafeInteger(record.pid) &&
|
|
558
|
+
record.pid > 0 &&
|
|
559
|
+
(record.processStartedAtMs === undefined ||
|
|
560
|
+
(typeof record.processStartedAtMs === "number" &&
|
|
561
|
+
Number.isFinite(record.processStartedAtMs))) &&
|
|
186
562
|
typeof record.createdAt === "string" &&
|
|
187
563
|
typeof record.updatedAt === "string");
|
|
188
564
|
}
|
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
export { parseDuration } from "../duration.js";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { currentBrowserAbortSignal } from "./cancellation.js";
|
|
3
|
+
export function delay(ms, signal = currentBrowserAbortSignal()) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
if (signal?.aborted) {
|
|
6
|
+
reject(signal.reason);
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
const timer = setTimeout(() => {
|
|
10
|
+
signal?.removeEventListener("abort", onAbort);
|
|
11
|
+
resolve();
|
|
12
|
+
}, ms);
|
|
13
|
+
const onAbort = () => {
|
|
14
|
+
clearTimeout(timer);
|
|
15
|
+
signal?.removeEventListener("abort", onAbort);
|
|
16
|
+
reject(signal?.reason);
|
|
17
|
+
};
|
|
18
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
19
|
+
});
|
|
4
20
|
}
|
|
5
21
|
export function estimateTokenCount(text) {
|
|
6
22
|
if (!text) {
|
|
@@ -18,6 +34,8 @@ export async function withRetries(task, options = {}) {
|
|
|
18
34
|
return await task();
|
|
19
35
|
}
|
|
20
36
|
catch (error) {
|
|
37
|
+
if (error instanceof Error && ["AbortError", "BrowserRunCancelledError"].includes(error.name))
|
|
38
|
+
throw error;
|
|
21
39
|
if (attempt === retries) {
|
|
22
40
|
throw error;
|
|
23
41
|
}
|