farming-code 2.2.28 → 2.2.29
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 +9 -31
- package/README.zh_cn.md +8 -30
- package/THIRD_PARTY_NOTICES.md +25 -6
- package/backend/acp/packaged-claude-acp.js +13 -0
- package/backend/acp/packaged-codex-acp.js +2 -2
- package/backend/acp-runtime.js +37 -2
- package/backend/acp-transcript.js +174 -9
- package/backend/agent-manager.js +27 -2
- package/backend/config-manager.js +44 -0
- package/backend/data/LICENSE.agent-browser +201 -0
- package/backend/data/runtime-dependency-manifest.json +199 -0
- package/backend/data/runtime-dependency-sources.json +5 -0
- package/backend/executable-discovery.js +7 -0
- package/backend/farming-agent-bootstrap.zh_cn.md +4 -2
- package/backend/farming-app-cli.js +160 -64
- package/backend/farming-cli.js +5 -3
- package/backend/index-html.js +13 -0
- package/backend/main-agent-skills.js +6 -10
- package/backend/npm-update-helper.js +43 -6
- package/backend/provider-adapters.js +10 -0
- package/backend/runtime-dependency-manager.js +759 -0
- package/backend/runtime-executable-invocation.js +20 -0
- package/backend/server-process-identity.js +47 -0
- package/backend/server.js +140 -157
- package/backend/slash-command-discovery.js +138 -1
- package/backend/storage-layout.js +15 -0
- package/backend/update-service.js +27 -1
- package/config/farming.install.env.example +4 -0
- package/dist/acp/LICENSE.claude-agent-acp +191 -0
- package/dist/acp/LICENSE.claude-agent-sdk +1 -0
- package/dist/acp/claude-agent-acp-0.59.0.mjs +41606 -0
- package/dist/assets/{App-CM7Cy-l4.css → App-BKNUILuL.css} +1 -1
- package/dist/assets/App-Ex2oVVXO.js +693 -0
- package/dist/assets/{FileEditorMarkdownPreview-DYOMm-b1.js → FileEditorMarkdownPreview-Cs9cLv8a.js} +1 -1
- package/dist/assets/FileEditorPane-CNtuGjQ3.js +2 -0
- package/dist/assets/IconGlyphs-BClgag6E.js +1 -0
- package/dist/assets/ProjectFilesSection-Cf2BEcAN.js +12 -0
- package/dist/assets/ReviewPage-BRuxFaH5.js +3 -0
- package/dist/assets/code-dark-BEZTQwYZ.css +1 -0
- package/dist/assets/{index-BzQO3pUr.js → index-CQNaZc3l.js} +2 -2
- package/dist/assets/main-DK9Sy7-c.css +1 -0
- package/dist/assets/{workspace-editor-monaco-tXA-udGx.js → workspace-editor-monaco-CJNwbpCy.js} +1 -1
- package/dist/assets/workspace-editor-monaco-CO9w8YV0.js +1 -0
- package/dist/assets/workspace-view-state-BjKPYp0E.js +7 -0
- package/dist/index.html +32 -3
- package/extensions/browser/backend/agent-browser-runtime.js +1041 -0
- package/extensions/browser/backend/browser-mcp-server.js +352 -0
- package/extensions/browser/backend/browser-resource-manager.js +899 -114
- package/extensions/browser/backend/browser-resource-store.js +47 -6
- package/extensions/browser/backend/browser-router.js +7 -2
- package/extensions/browser/backend/executable-discovery.js +146 -13
- package/extensions/browser/bin/farming-browser +384 -30
- package/extensions/browser/frontend/BrowserSidebarPortals.tsx +17 -19
- package/extensions/browser/frontend/BrowserViewer.tsx +217 -46
- package/extensions/browser/frontend/browser.css +68 -17
- package/extensions/browser/frontend/types.ts +6 -0
- package/extensions/browser/frontend/useBrowserResources.ts +10 -3
- package/index.html +31 -2
- package/package.json +10 -6
- package/shared/backend-connection-status.d.ts +12 -0
- package/shared/backend-connection-status.js +35 -4
- package/backend/agent-activity.js +0 -35
- package/dist/assets/App-CPG4WcjO.js +0 -658
- package/dist/assets/FileEditorPane-DXXq82aM.js +0 -2
- package/dist/assets/IconGlyphs-DrdPjYXr.js +0 -1
- package/dist/assets/ProjectFilesSection-CP66XVPl.js +0 -12
- package/dist/assets/ReviewPage-w21MEPD6.js +0 -3
- package/dist/assets/code-dark-BDjwj7Um.css +0 -1
- package/dist/assets/main-BlVUXfxa.css +0 -1
- package/dist/assets/workspace-editor-monaco-CFEfFNcA.js +0 -1
- package/dist/assets/workspace-view-state-BS_V8JVs.js +0 -7
- package/extensions/browser/backend/cdp-browser-runtime.js +0 -648
- package/extensions/browser/backend/cdp-client.js +0 -167
- /package/dist/acp/{codex-acp-1.1.4.js → codex-acp-1.1.4.mjs} +0 -0
|
@@ -1,12 +1,24 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
2
|
const path = require('path');
|
|
3
|
+
const crypto = require('crypto');
|
|
3
4
|
const { EventEmitter } = require('events');
|
|
4
5
|
const storageLayout = require('../../../backend/storage-layout');
|
|
6
|
+
const {
|
|
7
|
+
matchingProcessIdentity,
|
|
8
|
+
readServerProcessIdentity,
|
|
9
|
+
} = require('../../../backend/server-process-identity');
|
|
5
10
|
const { BrowserResourceStore, RESOURCE_ID_RE } = require('./browser-resource-store');
|
|
6
|
-
const {
|
|
7
|
-
const {
|
|
11
|
+
const { AgentBrowserRuntime } = require('./agent-browser-runtime');
|
|
12
|
+
const {
|
|
13
|
+
discoverBrowserExecutables,
|
|
14
|
+
discoverBrowserRuntime,
|
|
15
|
+
} = require('./executable-discovery');
|
|
8
16
|
|
|
9
17
|
const MAX_VIEWER_BUFFER_BYTES = 2 * 1024 * 1024;
|
|
18
|
+
const VIEWER_RESIZE_SETTLE_MS = 80;
|
|
19
|
+
const BROWSER_RECOVERY_TIMEOUT_MS = 5_000;
|
|
20
|
+
const BROWSER_RECOVERY_POLL_MS = 100;
|
|
21
|
+
const MAX_UPLOAD_FILES = 20;
|
|
10
22
|
|
|
11
23
|
function publicResource(resource, collectionRevision) {
|
|
12
24
|
return {
|
|
@@ -34,11 +46,32 @@ function browserError(message, status = 400, code = 'BROWSER_INVALID_REQUEST') {
|
|
|
34
46
|
return error;
|
|
35
47
|
}
|
|
36
48
|
|
|
49
|
+
function externalBrowserFailure(action, cause) {
|
|
50
|
+
const error = browserError(
|
|
51
|
+
`${action}; verify the Browser plugin's external CDP address and the browser's /json/version endpoint`,
|
|
52
|
+
500,
|
|
53
|
+
'BROWSER_EXTERNAL_CDP_FAILED',
|
|
54
|
+
);
|
|
55
|
+
error.cause = cause;
|
|
56
|
+
return error;
|
|
57
|
+
}
|
|
58
|
+
|
|
37
59
|
function normalizeUrl(value) {
|
|
38
60
|
const input = String(value || '').trim();
|
|
39
61
|
if (!input) return 'about:blank';
|
|
40
62
|
if (input === 'about:blank') return input;
|
|
41
|
-
|
|
63
|
+
let url = input;
|
|
64
|
+
if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(input)) {
|
|
65
|
+
const authority = input.split(/[/?#]/, 1)[0];
|
|
66
|
+
const hostname = authority.replace(/:\d+$/, '').replace(/^\[|\]$/g, '').toLowerCase();
|
|
67
|
+
const explicitPort = authority.match(/:(\d+)$/)?.[1] || '';
|
|
68
|
+
const isIpLiteral = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname) || authority.startsWith('[');
|
|
69
|
+
const localHost = hostname === 'localhost'
|
|
70
|
+
|| hostname.endsWith('.localhost')
|
|
71
|
+
|| !hostname.includes('.')
|
|
72
|
+
|| isIpLiteral;
|
|
73
|
+
url = `${localHost || (explicitPort && explicitPort !== '443') ? 'http' : 'https'}://${input}`;
|
|
74
|
+
}
|
|
42
75
|
try {
|
|
43
76
|
const parsed = new URL(url);
|
|
44
77
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
@@ -51,36 +84,260 @@ function normalizeUrl(value) {
|
|
|
51
84
|
}
|
|
52
85
|
}
|
|
53
86
|
|
|
87
|
+
function tabResourceName(tab) {
|
|
88
|
+
const title = String(tab?.title || '').trim();
|
|
89
|
+
if (title) return title.slice(0, 120);
|
|
90
|
+
try {
|
|
91
|
+
return new URL(String(tab?.url || '')).hostname.slice(0, 120) || 'Browser';
|
|
92
|
+
} catch {
|
|
93
|
+
return 'Browser';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function pathInside(root, candidate) {
|
|
98
|
+
return candidate === root || candidate.startsWith(`${root}${path.sep}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function resolveWorkspaceInputFile(resource, value) {
|
|
102
|
+
const workspace = fs.realpathSync(resource.workspace);
|
|
103
|
+
const requested = path.resolve(resource.workspace, String(value || ''));
|
|
104
|
+
let resolved;
|
|
105
|
+
try {
|
|
106
|
+
resolved = fs.realpathSync(requested);
|
|
107
|
+
} catch {
|
|
108
|
+
throw browserError(`Upload file does not exist: ${value}`);
|
|
109
|
+
}
|
|
110
|
+
if (!pathInside(workspace, resolved)) {
|
|
111
|
+
throw browserError('Browser uploads must stay inside the Browser Project workspace');
|
|
112
|
+
}
|
|
113
|
+
if (!fs.statSync(resolved).isFile()) {
|
|
114
|
+
throw browserError(`Browser upload path is not a file: ${value}`);
|
|
115
|
+
}
|
|
116
|
+
return resolved;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolveWorkspaceOutputFile(resource, value) {
|
|
120
|
+
const requestedValue = String(value || '').trim();
|
|
121
|
+
if (!requestedValue) throw browserError('Download output path is required');
|
|
122
|
+
const workspace = fs.realpathSync(resource.workspace);
|
|
123
|
+
const requested = path.resolve(resource.workspace, requestedValue);
|
|
124
|
+
if (!pathInside(path.resolve(resource.workspace), requested)) {
|
|
125
|
+
throw browserError('Browser downloads must stay inside the Browser Project workspace');
|
|
126
|
+
}
|
|
127
|
+
let parent;
|
|
128
|
+
try {
|
|
129
|
+
parent = fs.realpathSync(path.dirname(requested));
|
|
130
|
+
} catch {
|
|
131
|
+
throw browserError('Browser download parent directory does not exist');
|
|
132
|
+
}
|
|
133
|
+
if (!pathInside(workspace, parent)) {
|
|
134
|
+
throw browserError('Browser downloads must stay inside the Browser Project workspace');
|
|
135
|
+
}
|
|
136
|
+
if (fs.existsSync(requested)) {
|
|
137
|
+
throw browserError('Browser download target already exists');
|
|
138
|
+
}
|
|
139
|
+
return requested;
|
|
140
|
+
}
|
|
141
|
+
|
|
54
142
|
class BrowserResourceManager extends EventEmitter {
|
|
55
143
|
constructor(options) {
|
|
56
144
|
super();
|
|
57
145
|
this.configDir = options.configDir;
|
|
58
146
|
this.store = options.store || new BrowserResourceStore(options.configDir);
|
|
59
|
-
this.discoverExecutable = options.discoverExecutable || (
|
|
60
|
-
|
|
147
|
+
this.discoverExecutable = options.discoverExecutable || (selection => discoverBrowserRuntime({
|
|
148
|
+
...options,
|
|
149
|
+
...selection,
|
|
150
|
+
configDir: this.configDir,
|
|
151
|
+
}));
|
|
152
|
+
this.discoverBrowserOptions = options.discoverBrowserOptions
|
|
153
|
+
|| (options.discoverExecutable ? () => [] : () => discoverBrowserExecutables(options));
|
|
154
|
+
this.getBrowserSettings = typeof options.getBrowserSettings === 'function'
|
|
155
|
+
? options.getBrowserSettings
|
|
156
|
+
: () => ({ browserSource: 'system', browserExecutablePath: '', browserExternalCdpUrl: '' });
|
|
157
|
+
this.createRuntime = options.createRuntime || (input => new AgentBrowserRuntime(input));
|
|
158
|
+
this.recoverRuntime = options.recoverRuntime || (input => AgentBrowserRuntime.recover(input));
|
|
61
159
|
this.isEnabled = typeof options.isEnabled === 'function' ? options.isEnabled : () => true;
|
|
160
|
+
this.readProcessIdentity = options.readProcessIdentity || readServerProcessIdentity;
|
|
161
|
+
this.killProcessGroup = options.killProcessGroup || ((processGroupId, signal) => process.kill(-processGroupId, signal));
|
|
162
|
+
this.wait = options.wait || (durationMs => new Promise(resolve => setTimeout(resolve, durationMs)));
|
|
163
|
+
this.scheduleTimeout = options.scheduleTimeout || setTimeout;
|
|
164
|
+
this.cancelTimeout = options.cancelTimeout || clearTimeout;
|
|
62
165
|
this.runtimes = new Map();
|
|
166
|
+
this.sessions = new Map();
|
|
63
167
|
this.operations = new Map();
|
|
64
168
|
this.disposed = false;
|
|
169
|
+
this.runtimeCapability = null;
|
|
170
|
+
this.browserOptions = [];
|
|
65
171
|
}
|
|
66
172
|
|
|
67
|
-
init() {
|
|
173
|
+
async init() {
|
|
68
174
|
this.store.init();
|
|
175
|
+
await this.refreshCapability();
|
|
176
|
+
const interrupted = this.store.list().filter(resource =>
|
|
177
|
+
['running', 'starting', 'stopping'].includes(resource.status)
|
|
178
|
+
);
|
|
179
|
+
const groups = new Map();
|
|
180
|
+
for (const resource of interrupted) {
|
|
181
|
+
const key = resource.runtimeKind === 'agent-browser'
|
|
182
|
+
? (resource.sessionId || resource.id)
|
|
183
|
+
: resource.id;
|
|
184
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
185
|
+
groups.get(key).push(resource);
|
|
186
|
+
}
|
|
187
|
+
await Promise.all([...groups.values()].map(resources => {
|
|
188
|
+
const owner = resources.find(resource => resource.processIdentity) || resources[0];
|
|
189
|
+
return this.recoverInterruptedRuntime(owner, resources);
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async recoverInterruptedRuntime(resource, relatedResources = [resource]) {
|
|
194
|
+
if (resource.runtimeKind === 'agent-browser') {
|
|
195
|
+
const capability = this.runtimeCapability || await this.refreshCapability();
|
|
196
|
+
let runtimeError = null;
|
|
197
|
+
if (!capability || capability.error || !capability.agentBrowserPath) {
|
|
198
|
+
runtimeError = new Error(
|
|
199
|
+
capability?.error
|
|
200
|
+
|| 'The exact agent-browser runtime required to clean up this Browser is unavailable',
|
|
201
|
+
);
|
|
202
|
+
} else {
|
|
203
|
+
try {
|
|
204
|
+
await this.recoverRuntime({
|
|
205
|
+
id: resource.sessionId || resource.id,
|
|
206
|
+
generation: resource.sessionGeneration || resource.generation,
|
|
207
|
+
browserKind: resource.browserKind,
|
|
208
|
+
processIdentity: resource.processIdentity,
|
|
209
|
+
configDir: this.configDir,
|
|
210
|
+
profileDir: storageLayout.browserProfileDir(
|
|
211
|
+
this.configDir,
|
|
212
|
+
resource.sessionId || resource.id,
|
|
213
|
+
),
|
|
214
|
+
agentBrowserPath: capability.agentBrowserPath,
|
|
215
|
+
readProcessIdentity: this.readProcessIdentity,
|
|
216
|
+
wait: this.wait,
|
|
217
|
+
});
|
|
218
|
+
} catch (error) {
|
|
219
|
+
runtimeError = error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const related of relatedResources) {
|
|
223
|
+
this.store.update(related.id, {
|
|
224
|
+
status: 'failed',
|
|
225
|
+
error: runtimeError
|
|
226
|
+
? `agent-browser Session cleanup failed: ${runtimeError.message || runtimeError}`
|
|
227
|
+
: 'Farming restarted and cleaned up the previous Browser runtime',
|
|
228
|
+
...(!runtimeError ? { processIdentity: null } : {}),
|
|
229
|
+
tabId: '',
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Migration cleanup for Browser rows created by Farming's former raw-CDP runtime.
|
|
236
|
+
const expected = resource.processIdentity;
|
|
237
|
+
if (!expected) {
|
|
238
|
+
this.store.update(resource.id, {
|
|
239
|
+
status: 'failed',
|
|
240
|
+
error: resource.browserKind === 'external-cdp'
|
|
241
|
+
? 'Farming restarted and disconnected from the external Browser'
|
|
242
|
+
: 'Farming restarted before the Browser runtime identity was committed',
|
|
243
|
+
processIdentity: null,
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const current = await this.readProcessIdentity(expected.pid);
|
|
248
|
+
if (!matchingProcessIdentity(expected, current)) {
|
|
249
|
+
this.store.update(resource.id, {
|
|
250
|
+
status: 'failed',
|
|
251
|
+
error: 'Farming restarted after the previous Browser runtime exited',
|
|
252
|
+
processIdentity: null,
|
|
253
|
+
});
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (expected.processGroupId !== expected.pid) {
|
|
257
|
+
this.store.update(resource.id, {
|
|
258
|
+
status: 'failed',
|
|
259
|
+
error: `Previous Browser process ${expected.pid} has an unsafe process-group identity; stop it manually`,
|
|
260
|
+
});
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
this.killProcessGroup(expected.processGroupId, 'SIGKILL');
|
|
265
|
+
} catch (error) {
|
|
266
|
+
if (error?.code !== 'ESRCH') {
|
|
267
|
+
const permission = error?.code === 'EPERM' || error?.code === 'EACCES';
|
|
268
|
+
this.store.update(resource.id, {
|
|
269
|
+
status: 'failed',
|
|
270
|
+
error: permission
|
|
271
|
+
? `Farming cannot clean up previous Browser process ${expected.pid} because it lacks permission`
|
|
272
|
+
: `Farming could not clean up previous Browser process ${expected.pid}: ${error?.message || error}`,
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const startedAt = Date.now();
|
|
278
|
+
while (matchingProcessIdentity(expected, await this.readProcessIdentity(expected.pid))) {
|
|
279
|
+
if (Date.now() - startedAt >= BROWSER_RECOVERY_TIMEOUT_MS) {
|
|
280
|
+
this.store.update(resource.id, {
|
|
281
|
+
status: 'failed',
|
|
282
|
+
error: `Previous Browser process ${expected.pid} did not exit after SIGKILL`,
|
|
283
|
+
});
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
await this.wait(BROWSER_RECOVERY_POLL_MS);
|
|
287
|
+
}
|
|
288
|
+
this.store.update(resource.id, {
|
|
289
|
+
status: 'failed',
|
|
290
|
+
error: 'Farming restarted and cleaned up the previous Browser runtime',
|
|
291
|
+
processIdentity: null,
|
|
292
|
+
});
|
|
69
293
|
}
|
|
70
294
|
|
|
71
295
|
capability() {
|
|
72
|
-
const executable = this.
|
|
296
|
+
const executable = this.runtimeCapability;
|
|
297
|
+
const runnable = executable && !executable.error;
|
|
73
298
|
const enabled = this.isEnabled() === true;
|
|
299
|
+
const selection = this.browserSelection();
|
|
74
300
|
return {
|
|
75
301
|
enabled,
|
|
76
|
-
available: enabled && Boolean(
|
|
77
|
-
browser:
|
|
302
|
+
available: enabled && Boolean(runnable),
|
|
303
|
+
browser: runnable ? { kind: executable.kind, path: executable.path } : null,
|
|
304
|
+
selection,
|
|
305
|
+
options: this.browserOptions.map(option => ({ kind: option.kind, path: option.path })),
|
|
78
306
|
message: !enabled
|
|
79
307
|
? 'Browser extension is disabled'
|
|
80
|
-
: (executable
|
|
308
|
+
: (executable?.error || (runnable
|
|
309
|
+
? ''
|
|
310
|
+
: 'Install agent-browser and a Chromium-based browser, or configure a loopback external CDP endpoint')),
|
|
81
311
|
};
|
|
82
312
|
}
|
|
83
313
|
|
|
314
|
+
browserSelection(settings = this.getBrowserSettings()) {
|
|
315
|
+
return {
|
|
316
|
+
source: settings?.browserSource === 'external-cdp' ? 'external-cdp' : 'system',
|
|
317
|
+
executablePath: String(settings?.browserExecutablePath || ''),
|
|
318
|
+
externalCdpUrl: String(settings?.browserExternalCdpUrl || 'http://127.0.0.1:9222'),
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async probeCapability(selection = this.browserSelection()) {
|
|
323
|
+
const browserOptions = this.discoverBrowserOptions();
|
|
324
|
+
const selectedOption = browserOptions.find(option => option.path === selection.executablePath);
|
|
325
|
+
const runtimeCapability = await this.discoverExecutable({
|
|
326
|
+
source: selection.source,
|
|
327
|
+
executablePath: selection.executablePath,
|
|
328
|
+
executableKind: selectedOption?.kind,
|
|
329
|
+
externalCdpUrl: selection.externalCdpUrl,
|
|
330
|
+
});
|
|
331
|
+
return { browserOptions, runtimeCapability };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async refreshCapability(selection = this.browserSelection()) {
|
|
335
|
+
const probe = await this.probeCapability(selection);
|
|
336
|
+
this.browserOptions = probe.browserOptions;
|
|
337
|
+
this.runtimeCapability = probe.runtimeCapability;
|
|
338
|
+
return this.runtimeCapability;
|
|
339
|
+
}
|
|
340
|
+
|
|
84
341
|
list() {
|
|
85
342
|
this.requireEnabled();
|
|
86
343
|
return this.store.list().map(resource => publicResource(resource, this.store.revision));
|
|
@@ -116,18 +373,23 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
116
373
|
const title = String(name || '').trim();
|
|
117
374
|
if (!title) throw browserError('Browser name is required');
|
|
118
375
|
const resource = this.requireStored(id);
|
|
119
|
-
const next = this.store.update(resource.id, {
|
|
376
|
+
const next = this.store.update(resource.id, {
|
|
377
|
+
name: title.slice(0, 120),
|
|
378
|
+
autoName: false,
|
|
379
|
+
});
|
|
120
380
|
this.emitResource(next);
|
|
121
381
|
return publicResource(next, this.store.revision);
|
|
122
382
|
}
|
|
123
383
|
|
|
124
384
|
start(id) {
|
|
125
|
-
this.
|
|
385
|
+
this.requireEnabled();
|
|
126
386
|
return this.enqueue(id, async () => {
|
|
127
387
|
const resource = this.requireStored(id);
|
|
128
|
-
const
|
|
129
|
-
if (resource.status === 'running' &&
|
|
130
|
-
|
|
388
|
+
const existingBinding = this.runtimes.get(id);
|
|
389
|
+
if (resource.status === 'running' && existingBinding) {
|
|
390
|
+
return publicResource(resource, this.store.revision);
|
|
391
|
+
}
|
|
392
|
+
if (existingBinding) {
|
|
131
393
|
throw browserError(
|
|
132
394
|
'A previous Browser runtime still owns this resource; stop it before restarting',
|
|
133
395
|
409,
|
|
@@ -137,11 +399,20 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
137
399
|
if (resource.status === 'starting' || resource.status === 'stopping') {
|
|
138
400
|
throw browserError(`Browser is ${resource.status}`, 409, 'BROWSER_BUSY');
|
|
139
401
|
}
|
|
140
|
-
|
|
141
|
-
|
|
402
|
+
if (resource.processIdentity) {
|
|
403
|
+
const blockedIdentity = resource.processIdentity;
|
|
404
|
+
throw browserError(
|
|
405
|
+
`Previous Browser process ${blockedIdentity.pid} still requires cleanup`,
|
|
406
|
+
409,
|
|
407
|
+
'BROWSER_RECOVERY_CLEANUP_REQUIRED',
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
const executable = await this.refreshCapability();
|
|
411
|
+
if (!executable || executable.error) {
|
|
142
412
|
const failed = this.store.update(id, {
|
|
143
413
|
status: 'failed',
|
|
144
|
-
error:
|
|
414
|
+
error: executable?.error
|
|
415
|
+
|| 'Install agent-browser and a Chromium-based browser, or configure a loopback external CDP endpoint',
|
|
145
416
|
});
|
|
146
417
|
this.emitResource(failed);
|
|
147
418
|
throw browserError(failed.error, 503, 'BROWSER_EXECUTABLE_NOT_FOUND');
|
|
@@ -152,51 +423,146 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
152
423
|
status: 'starting',
|
|
153
424
|
generation,
|
|
154
425
|
browserKind: executable.kind,
|
|
426
|
+
runtimeKind: 'agent-browser',
|
|
155
427
|
error: '',
|
|
428
|
+
processIdentity: null,
|
|
156
429
|
});
|
|
157
430
|
this.emitResource(starting);
|
|
431
|
+
|
|
432
|
+
const reusableSession = [...this.sessions.values()].find(session => (
|
|
433
|
+
!session.closing
|
|
434
|
+
&& session.projectRootId === resource.projectRootId
|
|
435
|
+
&& session.browserKind === executable.kind
|
|
436
|
+
));
|
|
437
|
+
if (reusableSession) {
|
|
438
|
+
try {
|
|
439
|
+
let running;
|
|
440
|
+
let binding;
|
|
441
|
+
const operation = (reusableSession.actionChain || Promise.resolve())
|
|
442
|
+
.catch(() => {})
|
|
443
|
+
.then(async () => {
|
|
444
|
+
const tab = await reusableSession.runtime.createTab(
|
|
445
|
+
resource.url,
|
|
446
|
+
executable.kind === 'external-cdp'
|
|
447
|
+
? `farming-${resource.id}-g${generation}`
|
|
448
|
+
: '',
|
|
449
|
+
);
|
|
450
|
+
binding = this.createBinding(reusableSession, {
|
|
451
|
+
...starting,
|
|
452
|
+
tabId: tab.tabId,
|
|
453
|
+
});
|
|
454
|
+
reusableSession.bindings.set(id, binding);
|
|
455
|
+
reusableSession.activeResourceId = id;
|
|
456
|
+
this.runtimes.set(id, binding);
|
|
457
|
+
running = this.store.update(id, {
|
|
458
|
+
status: 'running',
|
|
459
|
+
sessionId: reusableSession.id,
|
|
460
|
+
sessionGeneration: reusableSession.generation,
|
|
461
|
+
tabId: tab.tabId,
|
|
462
|
+
url: tab.url || resource.url,
|
|
463
|
+
title: tab.title || '',
|
|
464
|
+
error: '',
|
|
465
|
+
processIdentity: null,
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
reusableSession.actionChain = operation;
|
|
469
|
+
await operation;
|
|
470
|
+
this.emitResource(running);
|
|
471
|
+
this.broadcastRuntimeState(binding);
|
|
472
|
+
return publicResource(running, this.store.revision);
|
|
473
|
+
} catch (error) {
|
|
474
|
+
const failed = this.store.update(id, {
|
|
475
|
+
status: 'failed',
|
|
476
|
+
error: error?.message || 'Failed to create Browser tab',
|
|
477
|
+
tabId: '',
|
|
478
|
+
});
|
|
479
|
+
this.emitResource(failed);
|
|
480
|
+
throw browserError(failed.error, 500, 'BROWSER_START_FAILED');
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const sessionId = resource.sessionId || id;
|
|
485
|
+
const previousSessionGeneration = this.store.list()
|
|
486
|
+
.filter(candidate => candidate.sessionId === sessionId)
|
|
487
|
+
.reduce((maximum, candidate) => Math.max(maximum, candidate.sessionGeneration || 0), 0);
|
|
488
|
+
const sessionGeneration = previousSessionGeneration + 1;
|
|
158
489
|
const runtime = this.createRuntime({
|
|
159
|
-
id,
|
|
160
|
-
generation,
|
|
490
|
+
id: sessionId,
|
|
491
|
+
generation: sessionGeneration,
|
|
492
|
+
configDir: this.configDir,
|
|
493
|
+
agentBrowserPath: executable.agentBrowserPath,
|
|
161
494
|
executablePath: executable.path,
|
|
162
|
-
|
|
495
|
+
externalCdpUrl: executable.cdpUrl || '',
|
|
496
|
+
profileDir: storageLayout.browserProfileDir(this.configDir, sessionId),
|
|
163
497
|
});
|
|
164
|
-
|
|
165
|
-
|
|
498
|
+
const session = {
|
|
499
|
+
id: sessionId,
|
|
500
|
+
generation: sessionGeneration,
|
|
501
|
+
projectRootId: resource.projectRootId,
|
|
502
|
+
browserKind: executable.kind,
|
|
503
|
+
runtime,
|
|
504
|
+
bindings: new Map(),
|
|
505
|
+
activeResourceId: id,
|
|
506
|
+
processOwnerResourceId: id,
|
|
507
|
+
actionChain: Promise.resolve(),
|
|
508
|
+
reconcilingTabs: Promise.resolve(),
|
|
509
|
+
initializing: true,
|
|
510
|
+
closing: false,
|
|
511
|
+
};
|
|
512
|
+
const binding = this.createBinding(session, starting);
|
|
513
|
+
session.bindings.set(id, binding);
|
|
514
|
+
this.sessions.set(sessionId, session);
|
|
515
|
+
this.runtimes.set(id, binding);
|
|
516
|
+
this.bindSession(session);
|
|
166
517
|
try {
|
|
167
518
|
const metadata = await runtime.start(resource.url);
|
|
168
|
-
|
|
519
|
+
const tabs = await runtime.listTabs();
|
|
520
|
+
const tab = tabs.find(candidate => candidate.active) || tabs[0];
|
|
521
|
+
if (!tab) throw new Error('agent-browser did not report the Browser tab');
|
|
522
|
+
binding.tabId = tab.tabId;
|
|
523
|
+
if (this.runtimes.get(id) !== binding) {
|
|
169
524
|
throw browserError('Browser startup lost runtime ownership', 409, 'BROWSER_START_REPLACED');
|
|
170
525
|
}
|
|
171
526
|
const running = this.store.update(id, {
|
|
172
527
|
status: 'running',
|
|
528
|
+
sessionId,
|
|
529
|
+
sessionGeneration,
|
|
530
|
+
tabId: tab.tabId,
|
|
173
531
|
url: metadata.url || resource.url,
|
|
174
532
|
title: metadata.title || '',
|
|
175
533
|
error: '',
|
|
176
534
|
});
|
|
535
|
+
session.initializing = false;
|
|
177
536
|
this.emitResource(running);
|
|
178
|
-
this.broadcastRuntimeState(
|
|
537
|
+
this.broadcastRuntimeState(binding);
|
|
179
538
|
return publicResource(running, this.store.revision);
|
|
180
539
|
} catch (error) {
|
|
540
|
+
session.initializing = false;
|
|
181
541
|
let cleanupError = null;
|
|
182
542
|
try {
|
|
183
543
|
await runtime.close();
|
|
184
544
|
} catch (closeError) {
|
|
185
545
|
cleanupError = closeError;
|
|
186
546
|
}
|
|
187
|
-
if (!cleanupError && this.runtimes.get(id) ===
|
|
547
|
+
if (!cleanupError && this.runtimes.get(id) === binding) this.runtimes.delete(id);
|
|
548
|
+
if (!cleanupError && this.sessions.get(sessionId) === session) this.sessions.delete(sessionId);
|
|
188
549
|
const current = this.store.get(id);
|
|
550
|
+
const failureMessage = executable.kind === 'external-cdp'
|
|
551
|
+
? externalBrowserFailure('External Browser connection failed', error).message
|
|
552
|
+
: error?.message || 'Failed to start Browser';
|
|
189
553
|
const failed = current?.generation === generation
|
|
190
554
|
? this.store.update(id, {
|
|
191
555
|
status: 'failed',
|
|
192
556
|
error: cleanupError
|
|
193
|
-
? `${
|
|
194
|
-
:
|
|
557
|
+
? `${failureMessage}; cleanup failed`
|
|
558
|
+
: failureMessage,
|
|
559
|
+
tabId: '',
|
|
560
|
+
...(!cleanupError ? { processIdentity: null } : {}),
|
|
195
561
|
})
|
|
196
562
|
: null;
|
|
197
563
|
if (failed) this.emitResource(failed);
|
|
198
564
|
throw browserError(
|
|
199
|
-
failed?.error || error?.message || 'Failed to start
|
|
565
|
+
failed?.error || error?.message || 'Failed to start Browser',
|
|
200
566
|
500,
|
|
201
567
|
'BROWSER_START_FAILED',
|
|
202
568
|
);
|
|
@@ -207,22 +573,76 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
207
573
|
stop(id) {
|
|
208
574
|
this.requireEnabled();
|
|
209
575
|
return this.enqueue(id, async () => {
|
|
210
|
-
this.requireStored(id);
|
|
211
|
-
const
|
|
212
|
-
if (!
|
|
213
|
-
|
|
576
|
+
const resource = this.requireStored(id);
|
|
577
|
+
const binding = this.runtimes.get(id);
|
|
578
|
+
if (!binding) {
|
|
579
|
+
if (resource.processIdentity) {
|
|
580
|
+
await this.recoverInterruptedRuntime(resource);
|
|
581
|
+
const recovered = this.requireStored(id);
|
|
582
|
+
if (recovered.processIdentity) {
|
|
583
|
+
const blockedIdentity = recovered.processIdentity;
|
|
584
|
+
throw browserError(
|
|
585
|
+
recovered.error || `Previous Browser process ${blockedIdentity.pid} still requires cleanup`,
|
|
586
|
+
500,
|
|
587
|
+
'BROWSER_RECOVERY_CLEANUP_REQUIRED',
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
const stopped = this.store.update(id, {
|
|
592
|
+
status: 'stopped',
|
|
593
|
+
error: '',
|
|
594
|
+
processIdentity: null,
|
|
595
|
+
});
|
|
214
596
|
this.emitResource(stopped);
|
|
215
597
|
return publicResource(stopped, this.store.revision);
|
|
216
598
|
}
|
|
217
599
|
const stopping = this.store.update(id, { status: 'stopping', error: '' });
|
|
218
600
|
this.emitResource(stopping);
|
|
219
|
-
this.broadcastRuntimeState(
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
601
|
+
this.broadcastRuntimeState(binding);
|
|
602
|
+
const { session } = binding;
|
|
603
|
+
session.closing = session.bindings.size === 1;
|
|
604
|
+
try {
|
|
605
|
+
const closeOperation = (session.actionChain || Promise.resolve())
|
|
606
|
+
.catch(() => {})
|
|
607
|
+
.then(() => (
|
|
608
|
+
session.bindings.size > 1
|
|
609
|
+
? session.runtime.closeTab(binding.tabId)
|
|
610
|
+
: session.runtime.close()
|
|
611
|
+
));
|
|
612
|
+
session.actionChain = closeOperation;
|
|
613
|
+
await closeOperation;
|
|
614
|
+
} catch (error) {
|
|
615
|
+
session.closing = false;
|
|
616
|
+
if (resource.browserKind === 'external-cdp') {
|
|
617
|
+
throw externalBrowserFailure('External Browser targets could not be closed', error);
|
|
618
|
+
}
|
|
619
|
+
throw error;
|
|
620
|
+
}
|
|
621
|
+
session.bindings.delete(id);
|
|
622
|
+
if (this.runtimes.get(id) === binding) this.runtimes.delete(id);
|
|
623
|
+
if (session.bindings.size === 0 && this.sessions.get(session.id) === session) {
|
|
624
|
+
this.sessions.delete(session.id);
|
|
625
|
+
}
|
|
626
|
+
session.closing = false;
|
|
627
|
+
|
|
628
|
+
if (session.processOwnerResourceId === id && session.bindings.size > 0) {
|
|
629
|
+
const nextOwner = session.bindings.values().next().value;
|
|
630
|
+
session.processOwnerResourceId = nextOwner.id;
|
|
631
|
+
const ownerResource = this.store.update(nextOwner.id, {
|
|
632
|
+
processIdentity: resource.processIdentity,
|
|
633
|
+
});
|
|
634
|
+
this.emitResource(ownerResource);
|
|
635
|
+
this.broadcastRuntimeState(nextOwner);
|
|
636
|
+
}
|
|
637
|
+
const stopped = this.store.update(id, {
|
|
638
|
+
status: 'stopped',
|
|
639
|
+
error: '',
|
|
640
|
+
processIdentity: null,
|
|
641
|
+
tabId: '',
|
|
642
|
+
});
|
|
223
643
|
this.emitResource(stopped);
|
|
224
|
-
this.broadcastRuntimeState(
|
|
225
|
-
|
|
644
|
+
this.broadcastRuntimeState(binding);
|
|
645
|
+
this.releaseViewerState(binding);
|
|
226
646
|
return publicResource(stopped, this.store.revision);
|
|
227
647
|
});
|
|
228
648
|
}
|
|
@@ -230,12 +650,18 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
230
650
|
async delete(id) {
|
|
231
651
|
this.requireEnabled();
|
|
232
652
|
await this.stop(id);
|
|
233
|
-
this.requireStored(id);
|
|
653
|
+
const resource = this.requireStored(id);
|
|
234
654
|
this.store.delete(id);
|
|
235
|
-
const
|
|
655
|
+
const sessionId = resource.sessionId || id;
|
|
656
|
+
const profileDir = storageLayout.browserProfileDir(this.configDir, sessionId);
|
|
236
657
|
const browsersDir = path.resolve(storageLayout.browserResourcesDir(this.configDir));
|
|
237
658
|
const resourceDir = path.resolve(profileDir, '..');
|
|
238
|
-
|
|
659
|
+
const sessionStillReferenced = this.store.list().some(candidate => candidate.sessionId === sessionId);
|
|
660
|
+
if (
|
|
661
|
+
!sessionStillReferenced
|
|
662
|
+
&& resourceDir.startsWith(`${browsersDir}${path.sep}`)
|
|
663
|
+
&& RESOURCE_ID_RE.test(sessionId)
|
|
664
|
+
) {
|
|
239
665
|
fs.rmSync(resourceDir, { recursive: true, force: true });
|
|
240
666
|
}
|
|
241
667
|
this.emit('deleted', { id, collectionRevision: this.store.revision });
|
|
@@ -245,33 +671,33 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
245
671
|
navigate(id, url) {
|
|
246
672
|
this.requireEnabled();
|
|
247
673
|
const normalized = normalizeUrl(url);
|
|
248
|
-
return this.withRuntime(id, async runtime => {
|
|
674
|
+
return this.withRuntime(id, async (runtime, binding) => {
|
|
249
675
|
const metadata = await runtime.navigate(normalized);
|
|
250
|
-
this.updateMetadata(
|
|
676
|
+
this.updateMetadata(binding, metadata);
|
|
251
677
|
return this.get(id);
|
|
252
678
|
});
|
|
253
679
|
}
|
|
254
680
|
|
|
255
681
|
goBack(id) {
|
|
256
|
-
return this.withRuntime(id, async runtime => {
|
|
682
|
+
return this.withRuntime(id, async (runtime, binding) => {
|
|
257
683
|
const metadata = await runtime.goBack();
|
|
258
|
-
this.updateMetadata(
|
|
684
|
+
this.updateMetadata(binding, metadata);
|
|
259
685
|
return this.get(id);
|
|
260
686
|
});
|
|
261
687
|
}
|
|
262
688
|
|
|
263
689
|
goForward(id) {
|
|
264
|
-
return this.withRuntime(id, async runtime => {
|
|
690
|
+
return this.withRuntime(id, async (runtime, binding) => {
|
|
265
691
|
const metadata = await runtime.goForward();
|
|
266
|
-
this.updateMetadata(
|
|
692
|
+
this.updateMetadata(binding, metadata);
|
|
267
693
|
return this.get(id);
|
|
268
694
|
});
|
|
269
695
|
}
|
|
270
696
|
|
|
271
697
|
reload(id) {
|
|
272
|
-
return this.withRuntime(id, async runtime => {
|
|
698
|
+
return this.withRuntime(id, async (runtime, binding) => {
|
|
273
699
|
const metadata = await runtime.reload();
|
|
274
|
-
this.updateMetadata(
|
|
700
|
+
this.updateMetadata(binding, metadata);
|
|
275
701
|
return this.get(id);
|
|
276
702
|
});
|
|
277
703
|
}
|
|
@@ -286,9 +712,74 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
286
712
|
if (kind === 'forward') return this.goForward(id);
|
|
287
713
|
if (kind === 'reload') return this.reload(id);
|
|
288
714
|
if (kind === 'click') return this.withRuntime(id, runtime => runtime.click(input));
|
|
715
|
+
if ([
|
|
716
|
+
'dblclick',
|
|
717
|
+
'hover',
|
|
718
|
+
'focus',
|
|
719
|
+
'check',
|
|
720
|
+
'uncheck',
|
|
721
|
+
'scrollintoview',
|
|
722
|
+
'highlight',
|
|
723
|
+
].includes(kind)) {
|
|
724
|
+
return this.withRuntime(id, runtime => runtime.elementAction(kind, input));
|
|
725
|
+
}
|
|
289
726
|
if (kind === 'type') return this.withRuntime(id, runtime => runtime.type(input, false));
|
|
290
727
|
if (kind === 'fill') return this.withRuntime(id, runtime => runtime.type(input, true));
|
|
728
|
+
if (kind === 'keyboard') return this.withRuntime(id, runtime => runtime.keyboard(input));
|
|
291
729
|
if (kind === 'press') return this.withRuntime(id, runtime => runtime.press(input));
|
|
730
|
+
if (kind === 'select') return this.withRuntime(id, runtime => runtime.select(input));
|
|
731
|
+
if (kind === 'drag') return this.withRuntime(id, runtime => runtime.drag(input));
|
|
732
|
+
if (kind === 'wait') return this.withRuntime(id, runtime => runtime.waitFor(input));
|
|
733
|
+
if (kind === 'get') return this.withRuntime(id, runtime => runtime.get(input));
|
|
734
|
+
if (kind === 'is') return this.withRuntime(id, runtime => runtime.is(input));
|
|
735
|
+
if (kind === 'find') return this.withRuntime(id, runtime => runtime.find(input));
|
|
736
|
+
if (kind === 'eval') return this.withRuntime(id, runtime => runtime.evaluate(input));
|
|
737
|
+
if (kind === 'console' || kind === 'errors') {
|
|
738
|
+
return this.withRuntime(id, runtime => runtime.debugLog(kind, input));
|
|
739
|
+
}
|
|
740
|
+
if (kind === 'network') return this.withRuntime(id, runtime => runtime.network(input));
|
|
741
|
+
if (kind === 'cookies') return this.withRuntime(id, runtime => runtime.cookies(input));
|
|
742
|
+
if (kind === 'storage') return this.withRuntime(id, runtime => runtime.storage(input));
|
|
743
|
+
if (kind === 'frame') return this.withRuntime(id, runtime => runtime.frame(input));
|
|
744
|
+
if (kind === 'dialog') return this.withRuntime(id, runtime => runtime.dialog(input));
|
|
745
|
+
if (kind === 'upload') {
|
|
746
|
+
const resource = this.requireStored(id);
|
|
747
|
+
const requestedFiles = Array.isArray(input?.files) ? input.files : [];
|
|
748
|
+
if (requestedFiles.length === 0 || requestedFiles.length > MAX_UPLOAD_FILES) {
|
|
749
|
+
throw browserError(`Browser upload requires between 1 and ${MAX_UPLOAD_FILES} files`);
|
|
750
|
+
}
|
|
751
|
+
const files = requestedFiles.map(file => resolveWorkspaceInputFile(resource, file));
|
|
752
|
+
return this.withRuntime(id, runtime => runtime.upload({ ...input, files }));
|
|
753
|
+
}
|
|
754
|
+
if (kind === 'download') {
|
|
755
|
+
const resource = this.requireStored(id);
|
|
756
|
+
const target = resolveWorkspaceOutputFile(resource, input?.path);
|
|
757
|
+
return this.withRuntime(id, async runtime => {
|
|
758
|
+
const resourceDir = path.dirname(storageLayout.browserProfileDir(
|
|
759
|
+
this.configDir,
|
|
760
|
+
resource.sessionId || id,
|
|
761
|
+
));
|
|
762
|
+
const downloadDir = path.join(resourceDir, 'downloads');
|
|
763
|
+
fs.mkdirSync(downloadDir, { recursive: true, mode: 0o700 });
|
|
764
|
+
const temporaryPath = path.join(
|
|
765
|
+
downloadDir,
|
|
766
|
+
`${crypto.randomUUID()}-${path.basename(target)}`,
|
|
767
|
+
);
|
|
768
|
+
try {
|
|
769
|
+
await runtime.download({ ...input, outputPath: temporaryPath });
|
|
770
|
+
const stat = fs.statSync(temporaryPath);
|
|
771
|
+
if (!stat.isFile()) throw browserError('Browser download did not produce a regular file');
|
|
772
|
+
fs.copyFileSync(temporaryPath, target, fs.constants.COPYFILE_EXCL);
|
|
773
|
+
return {
|
|
774
|
+
ok: true,
|
|
775
|
+
path: path.relative(resource.workspace, target) || path.basename(target),
|
|
776
|
+
size: stat.size,
|
|
777
|
+
};
|
|
778
|
+
} finally {
|
|
779
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
}
|
|
292
783
|
if (kind === 'scroll') return this.withRuntime(id, async runtime => {
|
|
293
784
|
await runtime.wheel(input);
|
|
294
785
|
return { ok: true };
|
|
@@ -299,15 +790,19 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
299
790
|
attachViewer(id, ws) {
|
|
300
791
|
this.requireEnabled();
|
|
301
792
|
const resource = this.requireStored(id);
|
|
302
|
-
const
|
|
793
|
+
const binding = this.runtimes.get(id);
|
|
303
794
|
ws.send(JSON.stringify({
|
|
304
795
|
type: 'browser-state',
|
|
305
796
|
resource: publicResource(resource, this.store.revision),
|
|
306
797
|
}));
|
|
307
|
-
if (!
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
798
|
+
if (!binding || resource.status !== 'running') return () => {};
|
|
799
|
+
binding.viewers.add(ws);
|
|
800
|
+
if (binding.latestFrame) ws.send(JSON.stringify(binding.latestFrame));
|
|
801
|
+
void this.withRuntime(id, () => {}).catch(error => {
|
|
802
|
+
if (ws.readyState === 1) {
|
|
803
|
+
ws.send(JSON.stringify({ type: 'browser-error', message: error?.message || 'Browser tab failed' }));
|
|
804
|
+
}
|
|
805
|
+
});
|
|
311
806
|
const onMessage = raw => {
|
|
312
807
|
let message;
|
|
313
808
|
try {
|
|
@@ -315,7 +810,10 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
315
810
|
} catch {
|
|
316
811
|
return;
|
|
317
812
|
}
|
|
318
|
-
|
|
813
|
+
const operation = message.type === 'resize'
|
|
814
|
+
? this.scheduleViewerResize(binding, ws, message)
|
|
815
|
+
: this.handleViewerMessage(binding, ws, message);
|
|
816
|
+
void Promise.resolve(operation).catch(error => {
|
|
319
817
|
if (ws.readyState === 1) {
|
|
320
818
|
ws.send(JSON.stringify({ type: 'browser-error', message: error?.message || 'Browser input failed' }));
|
|
321
819
|
}
|
|
@@ -323,29 +821,132 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
323
821
|
};
|
|
324
822
|
ws.on('message', onMessage);
|
|
325
823
|
const detach = () => {
|
|
326
|
-
|
|
824
|
+
binding.viewers.delete(ws);
|
|
825
|
+
binding.viewerGeometries.delete(ws);
|
|
826
|
+
if (binding.viewerViewportOwner === ws) {
|
|
827
|
+
binding.viewerViewportOwner = binding.viewers.values().next().value || null;
|
|
828
|
+
if (binding.viewerViewportOwner) {
|
|
829
|
+
const geometry = binding.viewerGeometries.get(binding.viewerViewportOwner);
|
|
830
|
+
if (geometry) {
|
|
831
|
+
void this.scheduleViewerResize(binding, binding.viewerViewportOwner, geometry).catch(() => {});
|
|
832
|
+
}
|
|
833
|
+
} else {
|
|
834
|
+
this.clearViewerResize(binding);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
327
837
|
ws.off('message', onMessage);
|
|
328
838
|
};
|
|
329
839
|
ws.once('close', detach);
|
|
330
840
|
return detach;
|
|
331
841
|
}
|
|
332
842
|
|
|
333
|
-
handleViewerMessage(
|
|
334
|
-
const
|
|
843
|
+
handleViewerMessage(binding, viewer, message) {
|
|
844
|
+
const resource = this.requireStored(binding.id);
|
|
845
|
+
if (
|
|
846
|
+
resource.status !== 'running'
|
|
847
|
+
|| message.generation !== binding.generation
|
|
848
|
+
|| this.runtimes.get(binding.id) !== binding
|
|
849
|
+
) {
|
|
850
|
+
return Promise.reject(browserError(
|
|
851
|
+
'Browser Viewer input is no longer admitted',
|
|
852
|
+
409,
|
|
853
|
+
'BROWSER_NOT_RUNNING',
|
|
854
|
+
));
|
|
855
|
+
}
|
|
856
|
+
const { session } = binding;
|
|
857
|
+
const next = (session.actionChain || Promise.resolve())
|
|
335
858
|
.catch(() => {})
|
|
336
|
-
.then(() =>
|
|
337
|
-
|
|
859
|
+
.then(async () => {
|
|
860
|
+
await this.activateBinding(binding);
|
|
861
|
+
return this.performViewerMessage(binding, viewer, message);
|
|
862
|
+
});
|
|
863
|
+
session.actionChain = next;
|
|
338
864
|
return next;
|
|
339
865
|
}
|
|
340
866
|
|
|
341
|
-
|
|
342
|
-
|
|
867
|
+
scheduleViewerResize(binding, viewer, message) {
|
|
868
|
+
const resource = this.requireStored(binding.id);
|
|
869
|
+
if (
|
|
870
|
+
resource.status !== 'running'
|
|
871
|
+
|| message.generation !== binding.generation
|
|
872
|
+
|| this.runtimes.get(binding.id) !== binding
|
|
873
|
+
) {
|
|
874
|
+
return Promise.reject(browserError(
|
|
875
|
+
'Browser Viewer resize is no longer admitted',
|
|
876
|
+
409,
|
|
877
|
+
'BROWSER_NOT_RUNNING',
|
|
878
|
+
));
|
|
879
|
+
}
|
|
880
|
+
const width = Math.round(Number(message.width));
|
|
881
|
+
const height = Math.round(Number(message.height));
|
|
882
|
+
const requestedDeviceScaleFactor = Number(message.deviceScaleFactor);
|
|
883
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
884
|
+
return Promise.reject(browserError('Browser Viewer size is invalid'));
|
|
885
|
+
}
|
|
886
|
+
const deviceScaleFactor = Number.isFinite(requestedDeviceScaleFactor)
|
|
887
|
+
? Math.max(1, Math.min(2, requestedDeviceScaleFactor))
|
|
888
|
+
: 1;
|
|
889
|
+
const geometry = {
|
|
890
|
+
type: 'resize',
|
|
891
|
+
generation: message.generation,
|
|
892
|
+
width,
|
|
893
|
+
height,
|
|
894
|
+
deviceScaleFactor,
|
|
895
|
+
};
|
|
896
|
+
binding.viewerGeometries.set(viewer, geometry);
|
|
897
|
+
if (
|
|
898
|
+
message.claim === true
|
|
899
|
+
|| !binding.viewerViewportOwner
|
|
900
|
+
|| !binding.viewers.has(binding.viewerViewportOwner)
|
|
901
|
+
) {
|
|
902
|
+
binding.viewerViewportOwner = viewer;
|
|
903
|
+
}
|
|
904
|
+
if (binding.viewerViewportOwner !== viewer) return Promise.resolve();
|
|
905
|
+
binding.pendingViewerResize = { viewer, geometry };
|
|
906
|
+
if (binding.viewerResizeTimer) this.cancelTimeout(binding.viewerResizeTimer);
|
|
907
|
+
binding.viewerResizeTimer = this.scheduleTimeout(() => {
|
|
908
|
+
binding.viewerResizeTimer = null;
|
|
909
|
+
const pending = binding.pendingViewerResize;
|
|
910
|
+
binding.pendingViewerResize = null;
|
|
911
|
+
if (!pending || binding.viewerViewportOwner !== pending.viewer) return;
|
|
912
|
+
void this.handleViewerMessage(binding, pending.viewer, pending.geometry).catch(error => {
|
|
913
|
+
if (pending.viewer.readyState === 1) {
|
|
914
|
+
pending.viewer.send(JSON.stringify({
|
|
915
|
+
type: 'browser-error',
|
|
916
|
+
message: error?.message || 'Browser resize failed',
|
|
917
|
+
}));
|
|
918
|
+
}
|
|
919
|
+
});
|
|
920
|
+
}, VIEWER_RESIZE_SETTLE_MS);
|
|
921
|
+
binding.viewerResizeTimer.unref?.();
|
|
922
|
+
return Promise.resolve();
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
clearViewerResize(runtime) {
|
|
926
|
+
if (runtime.viewerResizeTimer) this.cancelTimeout(runtime.viewerResizeTimer);
|
|
927
|
+
runtime.viewerResizeTimer = null;
|
|
928
|
+
runtime.pendingViewerResize = null;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
releaseViewerState(runtime) {
|
|
932
|
+
this.clearViewerResize(runtime);
|
|
933
|
+
runtime.viewerGeometries?.clear?.();
|
|
934
|
+
runtime.viewerViewportOwner = null;
|
|
935
|
+
runtime.viewers?.clear?.();
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async performViewerMessage(binding, viewer, message) {
|
|
939
|
+
if (message.generation !== binding.generation || this.runtimes.get(binding.id) !== binding) {
|
|
343
940
|
throw browserError('Browser Viewer generation is stale', 409, 'BROWSER_STALE_GENERATION');
|
|
344
941
|
}
|
|
942
|
+
const runtime = binding.session.runtime;
|
|
345
943
|
if (message.type === 'resize') {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
944
|
+
if (binding.viewerViewportOwner !== viewer) return;
|
|
945
|
+
await runtime.resize({
|
|
946
|
+
width: message.width,
|
|
947
|
+
height: message.height,
|
|
948
|
+
deviceScaleFactor: message.deviceScaleFactor,
|
|
949
|
+
});
|
|
349
950
|
return;
|
|
350
951
|
}
|
|
351
952
|
if (message.type === 'pointer') {
|
|
@@ -367,20 +968,27 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
367
968
|
|
|
368
969
|
async dispose() {
|
|
369
970
|
this.disposed = true;
|
|
370
|
-
const
|
|
371
|
-
const results = await Promise.allSettled(
|
|
971
|
+
const sessions = [...this.sessions.values()];
|
|
972
|
+
const results = await Promise.allSettled(sessions.map(session => session.runtime.close()));
|
|
372
973
|
const failures = results
|
|
373
974
|
.filter(result => result.status === 'rejected')
|
|
374
975
|
.map(result => result.reason?.message || String(result.reason));
|
|
375
976
|
if (failures.length > 0) {
|
|
376
977
|
throw new Error(`Browser runtime cleanup failed: ${failures.join('; ')}`);
|
|
377
978
|
}
|
|
979
|
+
for (const binding of this.runtimes.values()) this.releaseViewerState(binding);
|
|
378
980
|
this.runtimes.clear();
|
|
981
|
+
this.sessions.clear();
|
|
379
982
|
}
|
|
380
983
|
|
|
381
984
|
async stopAll() {
|
|
382
985
|
const ids = [...this.runtimes.keys()];
|
|
383
|
-
const results =
|
|
986
|
+
const results = [];
|
|
987
|
+
for (const id of ids) {
|
|
988
|
+
results.push(await Promise.resolve(this.stop(id))
|
|
989
|
+
.then(value => ({ status: 'fulfilled', value }))
|
|
990
|
+
.catch(reason => ({ status: 'rejected', reason })));
|
|
991
|
+
}
|
|
384
992
|
const failures = results
|
|
385
993
|
.filter(result => result.status === 'rejected')
|
|
386
994
|
.map(result => result.reason?.message || String(result.reason));
|
|
@@ -401,9 +1009,10 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
401
1009
|
|
|
402
1010
|
requireAvailable() {
|
|
403
1011
|
this.requireEnabled();
|
|
404
|
-
|
|
1012
|
+
const executable = this.runtimeCapability;
|
|
1013
|
+
if (!executable || executable.error) {
|
|
405
1014
|
throw browserError(
|
|
406
|
-
'Install a Chromium-based browser
|
|
1015
|
+
executable?.error || 'Install agent-browser and a Chromium-based browser, or configure a loopback external CDP endpoint',
|
|
407
1016
|
503,
|
|
408
1017
|
'BROWSER_EXECUTABLE_NOT_FOUND',
|
|
409
1018
|
);
|
|
@@ -428,91 +1037,266 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
428
1037
|
});
|
|
429
1038
|
}
|
|
430
1039
|
|
|
1040
|
+
createBinding(session, resource) {
|
|
1041
|
+
return {
|
|
1042
|
+
id: resource.id,
|
|
1043
|
+
generation: resource.generation,
|
|
1044
|
+
session,
|
|
1045
|
+
tabId: resource.tabId || '',
|
|
1046
|
+
viewers: new Set(),
|
|
1047
|
+
viewerGeometries: new Map(),
|
|
1048
|
+
viewerViewportOwner: null,
|
|
1049
|
+
viewerResizeTimer: null,
|
|
1050
|
+
pendingViewerResize: null,
|
|
1051
|
+
latestFrame: null,
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
async activateBinding(binding) {
|
|
1056
|
+
const { session } = binding;
|
|
1057
|
+
if (!binding.tabId) throw browserError('Browser tab is unavailable', 409, 'BROWSER_TAB_UNAVAILABLE');
|
|
1058
|
+
if (
|
|
1059
|
+
session.runtime.activeTabId !== binding.tabId
|
|
1060
|
+
|| session.runtime.streamTabId !== binding.tabId
|
|
1061
|
+
) {
|
|
1062
|
+
await session.runtime.switchTab(binding.tabId);
|
|
1063
|
+
}
|
|
1064
|
+
session.activeResourceId = binding.id;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
431
1067
|
withRuntime(id, operation) {
|
|
432
|
-
const
|
|
1068
|
+
const binding = this.runtimes.get(id);
|
|
433
1069
|
const resource = this.requireStored(id);
|
|
434
|
-
if (!
|
|
1070
|
+
if (!binding || resource.status !== 'running') {
|
|
435
1071
|
throw browserError('Browser is not running', 409, 'BROWSER_NOT_RUNNING');
|
|
436
1072
|
}
|
|
437
|
-
const
|
|
1073
|
+
const { session } = binding;
|
|
1074
|
+
const next = (session.actionChain || Promise.resolve())
|
|
438
1075
|
.catch(() => {})
|
|
439
|
-
.then(() =>
|
|
440
|
-
|
|
1076
|
+
.then(async () => {
|
|
1077
|
+
await this.activateBinding(binding);
|
|
1078
|
+
return operation(session.runtime, binding);
|
|
1079
|
+
});
|
|
1080
|
+
session.actionChain = next;
|
|
441
1081
|
return next;
|
|
442
1082
|
}
|
|
443
1083
|
|
|
444
|
-
|
|
1084
|
+
bindSession(session) {
|
|
1085
|
+
const { runtime } = session;
|
|
1086
|
+
runtime.on('process-identity', processIdentity => {
|
|
1087
|
+
const owner = this.store.get(session.processOwnerResourceId);
|
|
1088
|
+
if (
|
|
1089
|
+
this.sessions.get(session.id) !== session
|
|
1090
|
+
|| !owner
|
|
1091
|
+
|| owner.sessionId && owner.sessionId !== session.id
|
|
1092
|
+
) return;
|
|
1093
|
+
const next = this.store.update(owner.id, { processIdentity });
|
|
1094
|
+
this.emitResource(next);
|
|
1095
|
+
});
|
|
445
1096
|
runtime.on('frame', frame => {
|
|
446
|
-
|
|
447
|
-
|
|
1097
|
+
const binding = [...session.bindings.values()]
|
|
1098
|
+
.find(candidate => candidate.tabId === runtime.streamTabId)
|
|
1099
|
+
|| session.bindings.get(session.activeResourceId);
|
|
1100
|
+
if (!binding) return;
|
|
1101
|
+
const resourceFrame = {
|
|
1102
|
+
...frame,
|
|
1103
|
+
generation: binding.generation,
|
|
1104
|
+
};
|
|
1105
|
+
binding.latestFrame = resourceFrame;
|
|
1106
|
+
for (const viewer of binding.viewers) {
|
|
448
1107
|
if (viewer.readyState === 1 && (Number(viewer.bufferedAmount) || 0) <= MAX_VIEWER_BUFFER_BYTES) {
|
|
449
|
-
viewer.send(JSON.stringify(
|
|
1108
|
+
viewer.send(JSON.stringify(resourceFrame));
|
|
450
1109
|
}
|
|
451
1110
|
}
|
|
452
1111
|
});
|
|
453
|
-
runtime.on('metadata', metadata =>
|
|
1112
|
+
runtime.on('metadata', metadata => {
|
|
1113
|
+
const binding = [...session.bindings.values()]
|
|
1114
|
+
.find(candidate => candidate.tabId === runtime.activeTabId)
|
|
1115
|
+
|| session.bindings.get(session.activeResourceId);
|
|
1116
|
+
if (binding) this.updateMetadata(binding, metadata);
|
|
1117
|
+
});
|
|
1118
|
+
runtime.on('tabs', event => {
|
|
1119
|
+
if (session.initializing || session.closing) return;
|
|
1120
|
+
const next = (session.actionChain || Promise.resolve())
|
|
1121
|
+
.catch(() => {})
|
|
1122
|
+
.then(() => this.reconcileTabs(session, event));
|
|
1123
|
+
session.actionChain = next;
|
|
1124
|
+
});
|
|
454
1125
|
runtime.on('error', error => {
|
|
455
|
-
for (const
|
|
456
|
-
|
|
457
|
-
viewer.
|
|
1126
|
+
for (const binding of session.bindings.values()) {
|
|
1127
|
+
for (const viewer of binding.viewers) {
|
|
1128
|
+
if (viewer.readyState === 1) {
|
|
1129
|
+
viewer.send(JSON.stringify({ type: 'browser-error', message: error?.message || 'Browser runtime failed' }));
|
|
1130
|
+
}
|
|
458
1131
|
}
|
|
459
1132
|
}
|
|
460
1133
|
});
|
|
461
1134
|
runtime.once('exit', message => {
|
|
462
|
-
void this.handleRuntimeExit(
|
|
1135
|
+
void this.handleRuntimeExit(session, message);
|
|
463
1136
|
});
|
|
464
1137
|
}
|
|
465
1138
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
const
|
|
1139
|
+
async reconcileTabs(session, event) {
|
|
1140
|
+
if (this.sessions.get(session.id) !== session || session.closing) return;
|
|
1141
|
+
const tabs = Array.isArray(event?.tabs) ? event.tabs.filter(tab => tab.type === 'page') : [];
|
|
1142
|
+
const newlyObservedTabIds = new Set(
|
|
1143
|
+
Array.isArray(event?.newTabIds) ? event.newTabIds.map(String) : [],
|
|
1144
|
+
);
|
|
1145
|
+
const byTabId = new Map([...session.bindings.values()].map(binding => [binding.tabId, binding]));
|
|
1146
|
+
const opener = session.bindings.get(session.activeResourceId) || null;
|
|
1147
|
+
const opened = [];
|
|
1148
|
+
|
|
1149
|
+
for (const tab of tabs) {
|
|
1150
|
+
let binding = byTabId.get(tab.tabId);
|
|
1151
|
+
if (
|
|
1152
|
+
!binding
|
|
1153
|
+
&& newlyObservedTabIds.has(tab.tabId)
|
|
1154
|
+
&& (!session.runtime.externalCdpUrl || event.popupAdmitted)
|
|
1155
|
+
) {
|
|
1156
|
+
session.runtime.ownedTabIds.add(tab.tabId);
|
|
1157
|
+
const created = this.store.createRunningTab({
|
|
1158
|
+
projectRootId: opener?.session.projectRootId || session.projectRootId,
|
|
1159
|
+
workspace: this.store.get(opener?.id)?.workspace
|
|
1160
|
+
|| this.store.list().find(resource => resource.sessionId === session.id)?.workspace,
|
|
1161
|
+
name: tabResourceName(tab),
|
|
1162
|
+
url: tab.url,
|
|
1163
|
+
title: tab.title,
|
|
1164
|
+
browserKind: session.browserKind,
|
|
1165
|
+
sessionId: session.id,
|
|
1166
|
+
sessionGeneration: session.generation,
|
|
1167
|
+
tabId: tab.tabId,
|
|
1168
|
+
});
|
|
1169
|
+
binding = this.createBinding(session, created);
|
|
1170
|
+
session.bindings.set(created.id, binding);
|
|
1171
|
+
this.runtimes.set(created.id, binding);
|
|
1172
|
+
byTabId.set(tab.tabId, binding);
|
|
1173
|
+
this.emitResource(created);
|
|
1174
|
+
opened.push(publicResource(created, this.store.revision));
|
|
1175
|
+
}
|
|
1176
|
+
if (!binding) continue;
|
|
1177
|
+
const current = this.store.get(binding.id);
|
|
1178
|
+
if (!current || current.status !== 'running') continue;
|
|
1179
|
+
if (current.url !== tab.url || current.title !== tab.title) {
|
|
1180
|
+
const updated = this.store.update(binding.id, {
|
|
1181
|
+
url: tab.url || current.url,
|
|
1182
|
+
title: tab.title || '',
|
|
1183
|
+
...(current.autoName ? { name: tabResourceName(tab) } : {}),
|
|
1184
|
+
});
|
|
1185
|
+
this.emitResource(updated);
|
|
1186
|
+
this.broadcastRuntimeState(binding);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
const liveTabIds = new Set(tabs.map(tab => tab.tabId));
|
|
1191
|
+
for (const binding of [...session.bindings.values()]) {
|
|
1192
|
+
if (liveTabIds.has(binding.tabId)) continue;
|
|
1193
|
+
const current = this.store.get(binding.id);
|
|
1194
|
+
if (!current || current.status === 'stopping') continue;
|
|
1195
|
+
session.bindings.delete(binding.id);
|
|
1196
|
+
this.runtimes.delete(binding.id);
|
|
1197
|
+
if (session.processOwnerResourceId === binding.id && session.bindings.size > 0) {
|
|
1198
|
+
const nextOwner = session.bindings.values().next().value;
|
|
1199
|
+
session.processOwnerResourceId = nextOwner.id;
|
|
1200
|
+
const transferred = this.store.update(nextOwner.id, {
|
|
1201
|
+
processIdentity: current.processIdentity,
|
|
1202
|
+
});
|
|
1203
|
+
this.emitResource(transferred);
|
|
1204
|
+
this.broadcastRuntimeState(nextOwner);
|
|
1205
|
+
}
|
|
1206
|
+
const stopped = this.store.update(binding.id, {
|
|
1207
|
+
status: 'stopped',
|
|
1208
|
+
tabId: '',
|
|
1209
|
+
processIdentity: null,
|
|
1210
|
+
error: '',
|
|
1211
|
+
});
|
|
1212
|
+
this.emitResource(stopped);
|
|
1213
|
+
this.broadcastRuntimeState(binding);
|
|
1214
|
+
this.releaseViewerState(binding);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const activeTab = tabs.find(tab => tab.active);
|
|
1218
|
+
const activeBinding = activeTab ? byTabId.get(activeTab.tabId) : null;
|
|
1219
|
+
if (activeBinding) {
|
|
1220
|
+
session.activeResourceId = activeBinding.id;
|
|
1221
|
+
if (session.runtime.streamTabId !== activeBinding.tabId) {
|
|
1222
|
+
await session.runtime.switchTab(activeBinding.tabId);
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
if (opened.length > 0 && opener) {
|
|
1226
|
+
const message = JSON.stringify({
|
|
1227
|
+
type: 'browser-tab-opened',
|
|
1228
|
+
resource: opened.at(-1),
|
|
1229
|
+
});
|
|
1230
|
+
for (const viewer of opener.viewers) {
|
|
1231
|
+
if (viewer.readyState === 1) viewer.send(message);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
updateMetadata(binding, metadata) {
|
|
1237
|
+
const current = this.store.get(binding.id);
|
|
1238
|
+
if (!current || current.generation !== binding.generation || !metadata) return;
|
|
1239
|
+
const next = this.store.update(binding.id, {
|
|
470
1240
|
url: String(metadata.url || current.url),
|
|
471
1241
|
title: String(metadata.title || ''),
|
|
472
1242
|
});
|
|
473
1243
|
this.emitResource(next);
|
|
474
|
-
this.broadcastRuntimeState(
|
|
1244
|
+
this.broadcastRuntimeState(binding);
|
|
475
1245
|
}
|
|
476
1246
|
|
|
477
1247
|
emitResource(resource) {
|
|
478
1248
|
this.emit('resource', publicResource(resource, this.store.revision));
|
|
479
1249
|
}
|
|
480
1250
|
|
|
481
|
-
broadcastRuntimeState(
|
|
482
|
-
const resource = this.store.get(
|
|
1251
|
+
broadcastRuntimeState(binding) {
|
|
1252
|
+
const resource = this.store.get(binding.id);
|
|
483
1253
|
if (!resource) return;
|
|
484
1254
|
const message = JSON.stringify({
|
|
485
1255
|
type: 'browser-state',
|
|
486
1256
|
resource: publicResource(resource, this.store.revision),
|
|
487
1257
|
});
|
|
488
|
-
for (const viewer of
|
|
1258
|
+
for (const viewer of binding.viewers || []) {
|
|
489
1259
|
if (viewer.readyState === 1) viewer.send(message);
|
|
490
1260
|
}
|
|
491
1261
|
}
|
|
492
1262
|
|
|
493
|
-
async handleRuntimeExit(
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
const failed = this.store.update(runtime.id, {
|
|
501
|
-
status: 'failed',
|
|
502
|
-
error: message || 'System browser exited',
|
|
503
|
-
});
|
|
504
|
-
this.emitResource(failed);
|
|
505
|
-
this.broadcastRuntimeState(runtime);
|
|
506
|
-
try {
|
|
507
|
-
await runtime.close();
|
|
508
|
-
if (this.runtimes.get(runtime.id) === runtime) this.runtimes.delete(runtime.id);
|
|
509
|
-
} catch (error) {
|
|
510
|
-
const cleanupFailed = this.store.update(runtime.id, {
|
|
1263
|
+
async handleRuntimeExit(session, message) {
|
|
1264
|
+
if (this.sessions.get(session.id) !== session) return;
|
|
1265
|
+
const failedBindings = [...session.bindings.values()];
|
|
1266
|
+
for (const binding of failedBindings) {
|
|
1267
|
+
const current = this.store.get(binding.id);
|
|
1268
|
+
if (!current) continue;
|
|
1269
|
+
const failed = this.store.update(binding.id, {
|
|
511
1270
|
status: 'failed',
|
|
512
|
-
error:
|
|
1271
|
+
error: current.browserKind === 'external-cdp'
|
|
1272
|
+
? 'External Browser connection exited'
|
|
1273
|
+
: message || 'Browser connection exited',
|
|
513
1274
|
});
|
|
514
|
-
this.emitResource(
|
|
515
|
-
this.broadcastRuntimeState(
|
|
1275
|
+
this.emitResource(failed);
|
|
1276
|
+
this.broadcastRuntimeState(binding);
|
|
1277
|
+
}
|
|
1278
|
+
try {
|
|
1279
|
+
await session.runtime.close();
|
|
1280
|
+
this.sessions.delete(session.id);
|
|
1281
|
+
for (const binding of failedBindings) {
|
|
1282
|
+
this.runtimes.delete(binding.id);
|
|
1283
|
+
this.releaseViewerState(binding);
|
|
1284
|
+
const cleaned = this.store.update(binding.id, { processIdentity: null });
|
|
1285
|
+
if (cleaned) this.emitResource(cleaned);
|
|
1286
|
+
}
|
|
1287
|
+
} catch (error) {
|
|
1288
|
+
for (const binding of failedBindings) {
|
|
1289
|
+
const current = this.store.get(binding.id);
|
|
1290
|
+
if (!current) continue;
|
|
1291
|
+
const cleanupFailed = this.store.update(binding.id, {
|
|
1292
|
+
status: 'failed',
|
|
1293
|
+
error: current.browserKind === 'external-cdp'
|
|
1294
|
+
? `${current.error}; target cleanup failed`
|
|
1295
|
+
: `${current.error}; cleanup failed: ${error?.message || error}`,
|
|
1296
|
+
});
|
|
1297
|
+
this.emitResource(cleanupFailed);
|
|
1298
|
+
this.broadcastRuntimeState(binding);
|
|
1299
|
+
}
|
|
516
1300
|
}
|
|
517
1301
|
}
|
|
518
1302
|
}
|
|
@@ -520,5 +1304,6 @@ class BrowserResourceManager extends EventEmitter {
|
|
|
520
1304
|
module.exports = {
|
|
521
1305
|
BrowserResourceManager,
|
|
522
1306
|
browserError,
|
|
1307
|
+
externalBrowserFailure,
|
|
523
1308
|
normalizeUrl,
|
|
524
1309
|
};
|