@jackwener/opencli 1.2.0 → 1.2.2
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/.github/workflows/build-extension.yml +3 -3
- package/.github/workflows/ci.yml +6 -6
- package/.github/workflows/doc-check.yml +3 -3
- package/.github/workflows/e2e-headed.yml +2 -2
- package/.github/workflows/pkg-pr-new.yml +2 -2
- package/.github/workflows/release.yml +3 -3
- package/.github/workflows/security.yml +2 -2
- package/README.md +4 -0
- package/README.zh-CN.md +4 -0
- package/dist/browser/daemon-client.d.ts +3 -2
- package/dist/browser/daemon-client.js +17 -4
- package/dist/browser/mcp.js +3 -1
- package/dist/browser/page.js +9 -2
- package/dist/daemon.js +21 -0
- package/dist/pipeline/executor.d.ts +2 -0
- package/dist/pipeline/executor.js +53 -14
- package/extension/dist/background.js +91 -23
- package/extension/src/background.ts +64 -18
- package/extension/src/cdp.ts +42 -1
- package/package.json +1 -1
- package/src/browser/daemon-client.ts +20 -5
- package/src/browser/mcp.ts +3 -1
- package/src/browser/page.ts +7 -2
- package/src/daemon.ts +23 -0
- package/src/pipeline/executor.ts +61 -16
|
@@ -15,10 +15,10 @@ jobs:
|
|
|
15
15
|
runs-on: ubuntu-latest
|
|
16
16
|
steps:
|
|
17
17
|
- name: Checkout Code
|
|
18
|
-
uses: actions/checkout@
|
|
18
|
+
uses: actions/checkout@v6
|
|
19
19
|
|
|
20
20
|
- name: Setup Node.js
|
|
21
|
-
uses: actions/setup-node@
|
|
21
|
+
uses: actions/setup-node@v6
|
|
22
22
|
with:
|
|
23
23
|
node-version: 20
|
|
24
24
|
cache: 'npm'
|
|
@@ -69,7 +69,7 @@ jobs:
|
|
|
69
69
|
|
|
70
70
|
- name: Attach to GitHub Release
|
|
71
71
|
if: startsWith(github.ref, 'refs/tags/')
|
|
72
|
-
uses: softprops/action-gh-release@v2
|
|
72
|
+
uses: softprops/action-gh-release@v2.6.1
|
|
73
73
|
with:
|
|
74
74
|
files: |
|
|
75
75
|
opencli-extension.zip
|
package/.github/workflows/ci.yml
CHANGED
|
@@ -18,9 +18,9 @@ jobs:
|
|
|
18
18
|
build:
|
|
19
19
|
runs-on: ubuntu-latest
|
|
20
20
|
steps:
|
|
21
|
-
- uses: actions/checkout@
|
|
21
|
+
- uses: actions/checkout@v6
|
|
22
22
|
|
|
23
|
-
- uses: actions/setup-node@
|
|
23
|
+
- uses: actions/setup-node@v6
|
|
24
24
|
with:
|
|
25
25
|
node-version: '22'
|
|
26
26
|
cache: 'npm'
|
|
@@ -43,9 +43,9 @@ jobs:
|
|
|
43
43
|
node-version: ['20', '22']
|
|
44
44
|
shard: [1, 2]
|
|
45
45
|
steps:
|
|
46
|
-
- uses: actions/checkout@
|
|
46
|
+
- uses: actions/checkout@v6
|
|
47
47
|
|
|
48
|
-
- uses: actions/setup-node@
|
|
48
|
+
- uses: actions/setup-node@v6
|
|
49
49
|
with:
|
|
50
50
|
node-version: ${{ matrix.node-version }}
|
|
51
51
|
cache: 'npm'
|
|
@@ -62,9 +62,9 @@ jobs:
|
|
|
62
62
|
needs: build
|
|
63
63
|
runs-on: ubuntu-latest
|
|
64
64
|
steps:
|
|
65
|
-
- uses: actions/checkout@
|
|
65
|
+
- uses: actions/checkout@v6
|
|
66
66
|
|
|
67
|
-
- uses: actions/setup-node@
|
|
67
|
+
- uses: actions/setup-node@v6
|
|
68
68
|
with:
|
|
69
69
|
node-version: '22'
|
|
70
70
|
cache: 'npm'
|
|
@@ -13,7 +13,7 @@ jobs:
|
|
|
13
13
|
doc-coverage:
|
|
14
14
|
runs-on: ubuntu-latest
|
|
15
15
|
steps:
|
|
16
|
-
- uses: actions/checkout@
|
|
16
|
+
- uses: actions/checkout@v6
|
|
17
17
|
|
|
18
18
|
- name: Check adapter doc coverage
|
|
19
19
|
run: bash scripts/check-doc-coverage.sh --strict
|
|
@@ -22,9 +22,9 @@ jobs:
|
|
|
22
22
|
docs-build:
|
|
23
23
|
runs-on: ubuntu-latest
|
|
24
24
|
steps:
|
|
25
|
-
- uses: actions/checkout@
|
|
25
|
+
- uses: actions/checkout@v6
|
|
26
26
|
|
|
27
|
-
- uses: actions/setup-node@
|
|
27
|
+
- uses: actions/setup-node@v6
|
|
28
28
|
with:
|
|
29
29
|
node-version: '22'
|
|
30
30
|
cache: 'npm'
|
|
@@ -13,9 +13,9 @@ jobs:
|
|
|
13
13
|
if: ${{ vars.PKG_PR_NEW_ENABLED == 'true' }}
|
|
14
14
|
runs-on: ubuntu-latest
|
|
15
15
|
steps:
|
|
16
|
-
- uses: actions/checkout@
|
|
16
|
+
- uses: actions/checkout@v6
|
|
17
17
|
|
|
18
|
-
- uses: actions/setup-node@
|
|
18
|
+
- uses: actions/setup-node@v6
|
|
19
19
|
with:
|
|
20
20
|
node-version: '22'
|
|
21
21
|
cache: 'npm'
|
|
@@ -13,9 +13,9 @@ jobs:
|
|
|
13
13
|
release:
|
|
14
14
|
runs-on: ubuntu-latest
|
|
15
15
|
steps:
|
|
16
|
-
- uses: actions/checkout@
|
|
16
|
+
- uses: actions/checkout@v6
|
|
17
17
|
|
|
18
|
-
- uses: actions/setup-node@
|
|
18
|
+
- uses: actions/setup-node@v6
|
|
19
19
|
with:
|
|
20
20
|
node-version: '22'
|
|
21
21
|
registry-url: 'https://registry.npmjs.org'
|
|
@@ -27,7 +27,7 @@ jobs:
|
|
|
27
27
|
run: npx tsc --noEmit
|
|
28
28
|
|
|
29
29
|
- name: Create GitHub Release
|
|
30
|
-
uses: softprops/action-gh-release@v2
|
|
30
|
+
uses: softprops/action-gh-release@v2.6.1
|
|
31
31
|
with:
|
|
32
32
|
generate_release_notes: true
|
|
33
33
|
|
package/README.md
CHANGED
|
@@ -342,6 +342,10 @@ git push --follow-tags
|
|
|
342
342
|
|
|
343
343
|
The CI will automatically build, create a GitHub release, and publish to npm.
|
|
344
344
|
|
|
345
|
+
## Star History
|
|
346
|
+
|
|
347
|
+
[](https://star-history.com/#jackwener/opencli&Date)
|
|
348
|
+
|
|
345
349
|
## License
|
|
346
350
|
|
|
347
351
|
[Apache-2.0](./LICENSE)
|
package/README.zh-CN.md
CHANGED
|
@@ -325,6 +325,10 @@ npm version minor # 0.1.0 → 0.2.0
|
|
|
325
325
|
git push --follow-tags
|
|
326
326
|
```
|
|
327
327
|
|
|
328
|
+
## Star History
|
|
329
|
+
|
|
330
|
+
[](https://star-history.com/#jackwener/opencli&Date)
|
|
331
|
+
|
|
328
332
|
## License
|
|
329
333
|
|
|
330
334
|
[Apache-2.0](./LICENSE)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Provides a typed send() function that posts a Command and returns a Result.
|
|
5
5
|
*/
|
|
6
|
+
import type { BrowserSessionInfo } from '../types.js';
|
|
6
7
|
export interface DaemonCommand {
|
|
7
8
|
id: string;
|
|
8
9
|
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions';
|
|
@@ -33,8 +34,8 @@ export declare function isDaemonRunning(): Promise<boolean>;
|
|
|
33
34
|
export declare function isExtensionConnected(): Promise<boolean>;
|
|
34
35
|
/**
|
|
35
36
|
* Send a command to the daemon and wait for a result.
|
|
36
|
-
* Retries up to
|
|
37
|
+
* Retries up to 4 times: network errors retry at 500ms,
|
|
38
|
+
* transient extension errors retry at 1500ms.
|
|
37
39
|
*/
|
|
38
40
|
export declare function sendCommand(action: DaemonCommand['action'], params?: Omit<DaemonCommand, 'id' | 'action'>): Promise<unknown>;
|
|
39
41
|
export declare function listSessions(): Promise<BrowserSessionInfo[]>;
|
|
40
|
-
import type { BrowserSessionInfo } from '../types.js';
|
|
@@ -44,13 +44,15 @@ export async function isExtensionConnected() {
|
|
|
44
44
|
}
|
|
45
45
|
/**
|
|
46
46
|
* Send a command to the daemon and wait for a result.
|
|
47
|
-
* Retries up to
|
|
47
|
+
* Retries up to 4 times: network errors retry at 500ms,
|
|
48
|
+
* transient extension errors retry at 1500ms.
|
|
48
49
|
*/
|
|
49
50
|
export async function sendCommand(action, params = {}) {
|
|
50
|
-
const
|
|
51
|
-
const command = { id, action, ...params };
|
|
52
|
-
const maxRetries = 3;
|
|
51
|
+
const maxRetries = 4;
|
|
53
52
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
53
|
+
// Generate a fresh ID per attempt to avoid daemon-side duplicate detection
|
|
54
|
+
const id = generateId();
|
|
55
|
+
const command = { id, action, ...params };
|
|
54
56
|
try {
|
|
55
57
|
const controller = new AbortController();
|
|
56
58
|
const timer = setTimeout(() => controller.abort(), 30000);
|
|
@@ -63,6 +65,17 @@ export async function sendCommand(action, params = {}) {
|
|
|
63
65
|
clearTimeout(timer);
|
|
64
66
|
const result = (await res.json());
|
|
65
67
|
if (!result.ok) {
|
|
68
|
+
// Check if error is a transient extension issue worth retrying
|
|
69
|
+
const errMsg = result.error ?? '';
|
|
70
|
+
const isTransient = errMsg.includes('Extension disconnected')
|
|
71
|
+
|| errMsg.includes('Extension not connected')
|
|
72
|
+
|| errMsg.includes('attach failed')
|
|
73
|
+
|| errMsg.includes('no longer exists');
|
|
74
|
+
if (isTransient && attempt < maxRetries) {
|
|
75
|
+
// Longer delay for extension recovery (service worker restart)
|
|
76
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
66
79
|
throw new Error(result.error ?? 'Daemon command failed');
|
|
67
80
|
}
|
|
68
81
|
return result.data;
|
package/dist/browser/mcp.js
CHANGED
|
@@ -49,7 +49,9 @@ export class BrowserBridge {
|
|
|
49
49
|
this._state = 'closed';
|
|
50
50
|
}
|
|
51
51
|
async _ensureDaemon(timeoutSeconds) {
|
|
52
|
-
|
|
52
|
+
// Use default if not provided, zero, or negative
|
|
53
|
+
const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
|
|
54
|
+
const timeoutMs = effectiveSeconds * 1000;
|
|
53
55
|
if (await isExtensionConnected())
|
|
54
56
|
return;
|
|
55
57
|
if (await isDaemonRunning()) {
|
package/dist/browser/page.js
CHANGED
|
@@ -164,12 +164,19 @@ export class Page {
|
|
|
164
164
|
}
|
|
165
165
|
async closeTab(index) {
|
|
166
166
|
await sendCommand('tabs', { op: 'close', ...this._workspaceOpt(), ...(index !== undefined ? { index } : {}) });
|
|
167
|
+
// Invalidate cached tabId — the closed tab might have been our active one.
|
|
168
|
+
// We can't know for sure (close-by-index doesn't return tabId), so reset.
|
|
169
|
+
this._tabId = undefined;
|
|
167
170
|
}
|
|
168
171
|
async newTab() {
|
|
169
|
-
await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() });
|
|
172
|
+
const result = await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() });
|
|
173
|
+
if (result?.tabId)
|
|
174
|
+
this._tabId = result.tabId;
|
|
170
175
|
}
|
|
171
176
|
async selectTab(index) {
|
|
172
|
-
await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() });
|
|
177
|
+
const result = await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() });
|
|
178
|
+
if (result?.selected)
|
|
179
|
+
this._tabId = result.selected;
|
|
173
180
|
}
|
|
174
181
|
async networkRequests(includeStatic = false) {
|
|
175
182
|
const code = networkRequestsJs(includeStatic);
|
package/dist/daemon.js
CHANGED
|
@@ -118,6 +118,25 @@ const wss = new WebSocketServer({ server: httpServer, path: '/ext' });
|
|
|
118
118
|
wss.on('connection', (ws) => {
|
|
119
119
|
console.error('[daemon] Extension connected');
|
|
120
120
|
extensionWs = ws;
|
|
121
|
+
// ── Heartbeat: ping every 15s, close if 2 pongs missed ──
|
|
122
|
+
let missedPongs = 0;
|
|
123
|
+
const heartbeatInterval = setInterval(() => {
|
|
124
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
125
|
+
clearInterval(heartbeatInterval);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (missedPongs >= 2) {
|
|
129
|
+
console.error('[daemon] Extension heartbeat lost, closing connection');
|
|
130
|
+
clearInterval(heartbeatInterval);
|
|
131
|
+
ws.terminate();
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
missedPongs++;
|
|
135
|
+
ws.ping();
|
|
136
|
+
}, 15000);
|
|
137
|
+
ws.on('pong', () => {
|
|
138
|
+
missedPongs = 0;
|
|
139
|
+
});
|
|
121
140
|
ws.on('message', (data) => {
|
|
122
141
|
try {
|
|
123
142
|
const msg = JSON.parse(data.toString());
|
|
@@ -142,6 +161,7 @@ wss.on('connection', (ws) => {
|
|
|
142
161
|
});
|
|
143
162
|
ws.on('close', () => {
|
|
144
163
|
console.error('[daemon] Extension disconnected');
|
|
164
|
+
clearInterval(heartbeatInterval);
|
|
145
165
|
if (extensionWs === ws) {
|
|
146
166
|
extensionWs = null;
|
|
147
167
|
// Reject all pending requests since the extension is gone
|
|
@@ -153,6 +173,7 @@ wss.on('connection', (ws) => {
|
|
|
153
173
|
}
|
|
154
174
|
});
|
|
155
175
|
ws.on('error', () => {
|
|
176
|
+
clearInterval(heartbeatInterval);
|
|
156
177
|
if (extensionWs === ws)
|
|
157
178
|
extensionWs = null;
|
|
158
179
|
});
|
|
@@ -5,5 +5,7 @@ import type { IPage } from '../types.js';
|
|
|
5
5
|
export interface PipelineContext {
|
|
6
6
|
args?: Record<string, unknown>;
|
|
7
7
|
debug?: boolean;
|
|
8
|
+
/** Max retry attempts per step (default: 2 for browser steps, 0 for others) */
|
|
9
|
+
stepRetries?: number;
|
|
8
10
|
}
|
|
9
11
|
export declare function executePipeline(page: IPage | null, pipeline: unknown[], ctx?: PipelineContext): Promise<unknown>;
|
|
@@ -4,31 +4,70 @@
|
|
|
4
4
|
import { getStep } from './registry.js';
|
|
5
5
|
import { log } from '../logger.js';
|
|
6
6
|
import { ConfigError } from '../errors.js';
|
|
7
|
+
/** Steps that interact with the browser and may fail transiently */
|
|
8
|
+
const BROWSER_STEPS = new Set(['navigate', 'evaluate', 'click', 'type', 'press', 'wait', 'snapshot', 'scroll']);
|
|
7
9
|
export async function executePipeline(page, pipeline, ctx = {}) {
|
|
8
10
|
const args = ctx.args ?? {};
|
|
9
11
|
const debug = ctx.debug ?? false;
|
|
10
12
|
let data = null;
|
|
11
13
|
const total = pipeline.length;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
try {
|
|
15
|
+
for (let i = 0; i < pipeline.length; i++) {
|
|
16
|
+
const step = pipeline[i];
|
|
17
|
+
if (!step || typeof step !== 'object')
|
|
18
|
+
continue;
|
|
19
|
+
for (const [op, params] of Object.entries(step)) {
|
|
20
|
+
if (debug)
|
|
21
|
+
debugStepStart(i + 1, total, op, params);
|
|
22
|
+
const handler = getStep(op);
|
|
23
|
+
if (handler) {
|
|
24
|
+
data = await executeStepWithRetry(handler, page, params, data, args, op, ctx.stepRetries);
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
throw new ConfigError(`Unknown pipeline step "${op}" at index ${i}.`, 'Check the YAML pipeline step name or register the custom step before execution.');
|
|
28
|
+
}
|
|
29
|
+
if (debug)
|
|
30
|
+
debugStepResult(op, data);
|
|
22
31
|
}
|
|
23
|
-
|
|
24
|
-
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
// Attempt cleanup: close automation window on pipeline failure
|
|
36
|
+
if (page && typeof page.closeWindow === 'function') {
|
|
37
|
+
try {
|
|
38
|
+
await page.closeWindow();
|
|
25
39
|
}
|
|
26
|
-
|
|
27
|
-
debugStepResult(op, data);
|
|
40
|
+
catch { /* ignore */ }
|
|
28
41
|
}
|
|
42
|
+
throw err;
|
|
29
43
|
}
|
|
30
44
|
return data;
|
|
31
45
|
}
|
|
46
|
+
async function executeStepWithRetry(handler, page, params, data, args, op, configRetries) {
|
|
47
|
+
const maxRetries = configRetries ?? (BROWSER_STEPS.has(op) ? 2 : 0);
|
|
48
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
49
|
+
try {
|
|
50
|
+
return await handler(page, params, data, args);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (attempt >= maxRetries)
|
|
54
|
+
throw err;
|
|
55
|
+
// Only retry on transient browser errors
|
|
56
|
+
const msg = err instanceof Error ? err.message : '';
|
|
57
|
+
const isTransient = msg.includes('Extension disconnected')
|
|
58
|
+
|| msg.includes('attach failed')
|
|
59
|
+
|| msg.includes('no longer exists')
|
|
60
|
+
|| msg.includes('CDP connection')
|
|
61
|
+
|| msg.includes('Daemon command failed');
|
|
62
|
+
if (!isTransient)
|
|
63
|
+
throw err;
|
|
64
|
+
// Brief delay before retry
|
|
65
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Unreachable
|
|
69
|
+
throw new Error(`Step "${op}" failed after ${maxRetries} retries`);
|
|
70
|
+
}
|
|
32
71
|
function debugStepStart(stepNum, total, op, params) {
|
|
33
72
|
let preview = '';
|
|
34
73
|
if (typeof params === 'string') {
|
|
@@ -5,8 +5,33 @@ const WS_RECONNECT_BASE_DELAY = 2e3;
|
|
|
5
5
|
const WS_RECONNECT_MAX_DELAY = 6e4;
|
|
6
6
|
|
|
7
7
|
const attached = /* @__PURE__ */ new Set();
|
|
8
|
+
function isDebuggableUrl$1(url) {
|
|
9
|
+
if (!url) return false;
|
|
10
|
+
return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://");
|
|
11
|
+
}
|
|
8
12
|
async function ensureAttached(tabId) {
|
|
9
|
-
|
|
13
|
+
try {
|
|
14
|
+
const tab = await chrome.tabs.get(tabId);
|
|
15
|
+
if (!isDebuggableUrl$1(tab.url)) {
|
|
16
|
+
attached.delete(tabId);
|
|
17
|
+
throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? "unknown"}`);
|
|
18
|
+
}
|
|
19
|
+
} catch (e) {
|
|
20
|
+
if (e instanceof Error && e.message.startsWith("Cannot debug tab")) throw e;
|
|
21
|
+
attached.delete(tabId);
|
|
22
|
+
throw new Error(`Tab ${tabId} no longer exists`);
|
|
23
|
+
}
|
|
24
|
+
if (attached.has(tabId)) {
|
|
25
|
+
try {
|
|
26
|
+
await chrome.debugger.sendCommand({ tabId }, "Runtime.evaluate", {
|
|
27
|
+
expression: "1",
|
|
28
|
+
returnByValue: true
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
} catch {
|
|
32
|
+
attached.delete(tabId);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
10
35
|
try {
|
|
11
36
|
await chrome.debugger.attach({ tabId }, "1.3");
|
|
12
37
|
} catch (e) {
|
|
@@ -89,6 +114,17 @@ function registerListeners() {
|
|
|
89
114
|
chrome.debugger.onDetach.addListener((source) => {
|
|
90
115
|
if (source.tabId) attached.delete(source.tabId);
|
|
91
116
|
});
|
|
117
|
+
chrome.tabs.onUpdated.addListener((tabId, info) => {
|
|
118
|
+
if (info.url && !isDebuggableUrl$1(info.url)) {
|
|
119
|
+
if (attached.has(tabId)) {
|
|
120
|
+
attached.delete(tabId);
|
|
121
|
+
try {
|
|
122
|
+
chrome.debugger.detach({ tabId });
|
|
123
|
+
} catch {
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
92
128
|
}
|
|
93
129
|
|
|
94
130
|
let ws = null;
|
|
@@ -161,7 +197,7 @@ function scheduleReconnect() {
|
|
|
161
197
|
}, delay);
|
|
162
198
|
}
|
|
163
199
|
const automationSessions = /* @__PURE__ */ new Map();
|
|
164
|
-
const WINDOW_IDLE_TIMEOUT =
|
|
200
|
+
const WINDOW_IDLE_TIMEOUT = 12e4;
|
|
165
201
|
function getWorkspaceKey(workspace) {
|
|
166
202
|
return workspace?.trim() || "default";
|
|
167
203
|
}
|
|
@@ -265,17 +301,30 @@ async function handleCommand(cmd) {
|
|
|
265
301
|
};
|
|
266
302
|
}
|
|
267
303
|
}
|
|
268
|
-
function
|
|
304
|
+
function isDebuggableUrl(url) {
|
|
269
305
|
if (!url) return false;
|
|
270
306
|
return !url.startsWith("chrome://") && !url.startsWith("chrome-extension://");
|
|
271
307
|
}
|
|
272
308
|
async function resolveTabId(tabId, workspace) {
|
|
273
|
-
if (tabId !== void 0)
|
|
309
|
+
if (tabId !== void 0) {
|
|
310
|
+
try {
|
|
311
|
+
const tab = await chrome.tabs.get(tabId);
|
|
312
|
+
if (isDebuggableUrl(tab.url)) return tabId;
|
|
313
|
+
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
|
|
314
|
+
} catch {
|
|
315
|
+
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
274
318
|
const windowId = await getAutomationWindow(workspace);
|
|
275
319
|
const tabs = await chrome.tabs.query({ windowId });
|
|
276
|
-
const
|
|
277
|
-
if (
|
|
278
|
-
|
|
320
|
+
const debuggableTab = tabs.find((t) => t.id && isDebuggableUrl(t.url));
|
|
321
|
+
if (debuggableTab?.id) return debuggableTab.id;
|
|
322
|
+
const reuseTab = tabs.find((t) => t.id);
|
|
323
|
+
if (reuseTab?.id) {
|
|
324
|
+
await chrome.tabs.update(reuseTab.id, { url: "about:blank" });
|
|
325
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
326
|
+
return reuseTab.id;
|
|
327
|
+
}
|
|
279
328
|
const newTab = await chrome.tabs.create({ windowId, url: "about:blank", active: true });
|
|
280
329
|
if (!newTab.id) throw new Error("Failed to create tab in automation window");
|
|
281
330
|
return newTab.id;
|
|
@@ -292,7 +341,7 @@ async function listAutomationTabs(workspace) {
|
|
|
292
341
|
}
|
|
293
342
|
async function listAutomationWebTabs(workspace) {
|
|
294
343
|
const tabs = await listAutomationTabs(workspace);
|
|
295
|
-
return tabs.filter((tab) =>
|
|
344
|
+
return tabs.filter((tab) => isDebuggableUrl(tab.url));
|
|
296
345
|
}
|
|
297
346
|
async function handleExec(cmd, workspace) {
|
|
298
347
|
if (!cmd.code) return { id: cmd.id, ok: false, error: "Missing code" };
|
|
@@ -307,28 +356,47 @@ async function handleExec(cmd, workspace) {
|
|
|
307
356
|
async function handleNavigate(cmd, workspace) {
|
|
308
357
|
if (!cmd.url) return { id: cmd.id, ok: false, error: "Missing url" };
|
|
309
358
|
const tabId = await resolveTabId(cmd.tabId, workspace);
|
|
310
|
-
await chrome.tabs.
|
|
359
|
+
const beforeTab = await chrome.tabs.get(tabId);
|
|
360
|
+
const beforeUrl = beforeTab.url ?? "";
|
|
361
|
+
const targetUrl = cmd.url;
|
|
362
|
+
await chrome.tabs.update(tabId, { url: targetUrl });
|
|
363
|
+
let timedOut = false;
|
|
311
364
|
await new Promise((resolve) => {
|
|
312
|
-
|
|
313
|
-
|
|
365
|
+
let urlChanged = false;
|
|
366
|
+
const listener = (id, info, tab2) => {
|
|
367
|
+
if (id !== tabId) return;
|
|
368
|
+
if (info.url && info.url !== beforeUrl) {
|
|
369
|
+
urlChanged = true;
|
|
370
|
+
}
|
|
371
|
+
if (urlChanged && info.status === "complete") {
|
|
372
|
+
chrome.tabs.onUpdated.removeListener(listener);
|
|
314
373
|
resolve();
|
|
315
|
-
return;
|
|
316
374
|
}
|
|
317
|
-
|
|
318
|
-
|
|
375
|
+
};
|
|
376
|
+
chrome.tabs.onUpdated.addListener(listener);
|
|
377
|
+
setTimeout(async () => {
|
|
378
|
+
try {
|
|
379
|
+
const currentTab = await chrome.tabs.get(tabId);
|
|
380
|
+
if (currentTab.url !== beforeUrl && currentTab.status === "complete") {
|
|
319
381
|
chrome.tabs.onUpdated.removeListener(listener);
|
|
320
382
|
resolve();
|
|
321
383
|
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
384
|
+
} catch {
|
|
385
|
+
}
|
|
386
|
+
}, 100);
|
|
387
|
+
setTimeout(() => {
|
|
388
|
+
chrome.tabs.onUpdated.removeListener(listener);
|
|
389
|
+
timedOut = true;
|
|
390
|
+
console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
|
|
391
|
+
resolve();
|
|
392
|
+
}, 15e3);
|
|
329
393
|
});
|
|
330
394
|
const tab = await chrome.tabs.get(tabId);
|
|
331
|
-
return {
|
|
395
|
+
return {
|
|
396
|
+
id: cmd.id,
|
|
397
|
+
ok: true,
|
|
398
|
+
data: { title: tab.title, url: tab.url, tabId, timedOut }
|
|
399
|
+
};
|
|
332
400
|
}
|
|
333
401
|
async function handleTabs(cmd, workspace) {
|
|
334
402
|
switch (cmd.op) {
|
|
@@ -425,7 +493,7 @@ async function handleSessions(cmd) {
|
|
|
425
493
|
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
|
|
426
494
|
workspace,
|
|
427
495
|
windowId: session.windowId,
|
|
428
|
-
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) =>
|
|
496
|
+
tabCount: (await chrome.tabs.query({ windowId: session.windowId })).filter((tab) => isDebuggableUrl(tab.url)).length,
|
|
429
497
|
idleMsRemaining: Math.max(0, session.idleDeadlineAt - now)
|
|
430
498
|
})));
|
|
431
499
|
return { id: cmd.id, ok: true, data };
|
|
@@ -97,7 +97,7 @@ type AutomationSession = {
|
|
|
97
97
|
};
|
|
98
98
|
|
|
99
99
|
const automationSessions = new Map<string, AutomationSession>();
|
|
100
|
-
const WINDOW_IDLE_TIMEOUT =
|
|
100
|
+
const WINDOW_IDLE_TIMEOUT = 120000; // 120s — longer to survive slow pipelines
|
|
101
101
|
|
|
102
102
|
function getWorkspaceKey(workspace?: string): string {
|
|
103
103
|
return workspace?.trim() || 'default';
|
|
@@ -238,7 +238,20 @@ function isDebuggableUrl(url?: string): boolean {
|
|
|
238
238
|
* Otherwise, find or create a tab in the dedicated automation window.
|
|
239
239
|
*/
|
|
240
240
|
async function resolveTabId(tabId: number | undefined, workspace: string): Promise<number> {
|
|
241
|
-
|
|
241
|
+
// Even when an explicit tabId is provided, validate it is still debuggable.
|
|
242
|
+
// This prevents issues when extensions hijack the tab URL to chrome-extension://
|
|
243
|
+
// or when the tab has been closed by the user.
|
|
244
|
+
if (tabId !== undefined) {
|
|
245
|
+
try {
|
|
246
|
+
const tab = await chrome.tabs.get(tabId);
|
|
247
|
+
if (isDebuggableUrl(tab.url)) return tabId;
|
|
248
|
+
// Tab exists but URL is not debuggable — fall through to auto-resolve
|
|
249
|
+
console.warn(`[opencli] Tab ${tabId} URL is not debuggable (${tab.url}), re-resolving`);
|
|
250
|
+
} catch {
|
|
251
|
+
// Tab was closed — fall through to auto-resolve
|
|
252
|
+
console.warn(`[opencli] Tab ${tabId} no longer exists, re-resolving`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
242
255
|
|
|
243
256
|
// Get (or create) the automation window
|
|
244
257
|
const windowId = await getAutomationWindow(workspace);
|
|
@@ -255,6 +268,8 @@ async function resolveTabId(tabId: number | undefined, workspace: string): Promi
|
|
|
255
268
|
const reuseTab = tabs.find(t => t.id);
|
|
256
269
|
if (reuseTab?.id) {
|
|
257
270
|
await chrome.tabs.update(reuseTab.id, { url: 'about:blank' });
|
|
271
|
+
// Wait briefly for the navigation to take effect
|
|
272
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
258
273
|
return reuseTab.id;
|
|
259
274
|
}
|
|
260
275
|
|
|
@@ -294,31 +309,62 @@ async function handleExec(cmd: Command, workspace: string): Promise<Result> {
|
|
|
294
309
|
async function handleNavigate(cmd: Command, workspace: string): Promise<Result> {
|
|
295
310
|
if (!cmd.url) return { id: cmd.id, ok: false, error: 'Missing url' };
|
|
296
311
|
const tabId = await resolveTabId(cmd.tabId, workspace);
|
|
297
|
-
await chrome.tabs.update(tabId, { url: cmd.url });
|
|
298
312
|
|
|
299
|
-
//
|
|
313
|
+
// Capture the current URL before navigation to detect actual URL change
|
|
314
|
+
const beforeTab = await chrome.tabs.get(tabId);
|
|
315
|
+
const beforeUrl = beforeTab.url ?? '';
|
|
316
|
+
const targetUrl = cmd.url;
|
|
317
|
+
|
|
318
|
+
await chrome.tabs.update(tabId, { url: targetUrl });
|
|
319
|
+
|
|
320
|
+
// Wait for: 1) URL to change from the old URL, 2) tab.status === 'complete'
|
|
321
|
+
// This avoids the race where 'complete' fires for the OLD URL (e.g. about:blank)
|
|
322
|
+
let timedOut = false;
|
|
300
323
|
await new Promise<void>((resolve) => {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
324
|
+
let urlChanged = false;
|
|
325
|
+
|
|
326
|
+
const listener = (id: number, info: chrome.tabs.TabChangeInfo, tab: chrome.tabs.Tab) => {
|
|
327
|
+
if (id !== tabId) return;
|
|
328
|
+
|
|
329
|
+
// Track URL change (new URL differs from the one before navigation)
|
|
330
|
+
if (info.url && info.url !== beforeUrl) {
|
|
331
|
+
urlChanged = true;
|
|
332
|
+
}
|
|
304
333
|
|
|
305
|
-
|
|
306
|
-
|
|
334
|
+
// Only resolve when both URL has changed AND status is complete
|
|
335
|
+
if (urlChanged && info.status === 'complete') {
|
|
336
|
+
chrome.tabs.onUpdated.removeListener(listener);
|
|
337
|
+
resolve();
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
chrome.tabs.onUpdated.addListener(listener);
|
|
341
|
+
|
|
342
|
+
// Also check if the tab already navigated (e.g. instant cache hit)
|
|
343
|
+
setTimeout(async () => {
|
|
344
|
+
try {
|
|
345
|
+
const currentTab = await chrome.tabs.get(tabId);
|
|
346
|
+
if (currentTab.url !== beforeUrl && currentTab.status === 'complete') {
|
|
307
347
|
chrome.tabs.onUpdated.removeListener(listener);
|
|
308
348
|
resolve();
|
|
309
349
|
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
350
|
+
} catch { /* tab gone */ }
|
|
351
|
+
}, 100);
|
|
352
|
+
|
|
353
|
+
// Timeout fallback with warning
|
|
354
|
+
setTimeout(() => {
|
|
355
|
+
chrome.tabs.onUpdated.removeListener(listener);
|
|
356
|
+
timedOut = true;
|
|
357
|
+
console.warn(`[opencli] Navigate to ${targetUrl} timed out after 15s`);
|
|
358
|
+
resolve();
|
|
359
|
+
}, 15000);
|
|
318
360
|
});
|
|
319
361
|
|
|
320
362
|
const tab = await chrome.tabs.get(tabId);
|
|
321
|
-
return {
|
|
363
|
+
return {
|
|
364
|
+
id: cmd.id,
|
|
365
|
+
ok: true,
|
|
366
|
+
data: { title: tab.title, url: tab.url, tabId, timedOut },
|
|
367
|
+
};
|
|
322
368
|
}
|
|
323
369
|
|
|
324
370
|
async function handleTabs(cmd: Command, workspace: string): Promise<Result> {
|
package/extension/src/cdp.ts
CHANGED
|
@@ -8,8 +8,40 @@
|
|
|
8
8
|
|
|
9
9
|
const attached = new Set<number>();
|
|
10
10
|
|
|
11
|
+
/** Check if a URL can be attached via CDP */
|
|
12
|
+
function isDebuggableUrl(url?: string): boolean {
|
|
13
|
+
if (!url) return false;
|
|
14
|
+
return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
|
|
15
|
+
}
|
|
16
|
+
|
|
11
17
|
async function ensureAttached(tabId: number): Promise<void> {
|
|
12
|
-
|
|
18
|
+
// Verify the tab URL is debuggable before attempting attach
|
|
19
|
+
try {
|
|
20
|
+
const tab = await chrome.tabs.get(tabId);
|
|
21
|
+
if (!isDebuggableUrl(tab.url)) {
|
|
22
|
+
// Invalidate cache if previously attached
|
|
23
|
+
attached.delete(tabId);
|
|
24
|
+
throw new Error(`Cannot debug tab ${tabId}: URL is ${tab.url ?? 'unknown'}`);
|
|
25
|
+
}
|
|
26
|
+
} catch (e) {
|
|
27
|
+
// Re-throw our own error, catch only chrome.tabs.get failures
|
|
28
|
+
if (e instanceof Error && e.message.startsWith('Cannot debug tab')) throw e;
|
|
29
|
+
attached.delete(tabId);
|
|
30
|
+
throw new Error(`Tab ${tabId} no longer exists`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (attached.has(tabId)) {
|
|
34
|
+
// Verify the debugger is still actually attached by sending a harmless command
|
|
35
|
+
try {
|
|
36
|
+
await chrome.debugger.sendCommand({ tabId }, 'Runtime.evaluate', {
|
|
37
|
+
expression: '1', returnByValue: true,
|
|
38
|
+
});
|
|
39
|
+
return; // Still attached and working
|
|
40
|
+
} catch {
|
|
41
|
+
// Stale cache entry — need to re-attach
|
|
42
|
+
attached.delete(tabId);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
13
45
|
|
|
14
46
|
try {
|
|
15
47
|
await chrome.debugger.attach({ tabId }, '1.3');
|
|
@@ -122,4 +154,13 @@ export function registerListeners(): void {
|
|
|
122
154
|
chrome.debugger.onDetach.addListener((source) => {
|
|
123
155
|
if (source.tabId) attached.delete(source.tabId);
|
|
124
156
|
});
|
|
157
|
+
// Invalidate attached cache when tab URL changes to non-debuggable
|
|
158
|
+
chrome.tabs.onUpdated.addListener((tabId, info) => {
|
|
159
|
+
if (info.url && !isDebuggableUrl(info.url)) {
|
|
160
|
+
if (attached.has(tabId)) {
|
|
161
|
+
attached.delete(tabId);
|
|
162
|
+
try { chrome.debugger.detach({ tabId }); } catch { /* ignore */ }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
});
|
|
125
166
|
}
|
package/package.json
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
const DAEMON_PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
|
|
8
8
|
const DAEMON_URL = `http://127.0.0.1:${DAEMON_PORT}`;
|
|
9
9
|
|
|
10
|
+
import type { BrowserSessionInfo } from '../types.js';
|
|
11
|
+
|
|
10
12
|
let _idCounter = 0;
|
|
11
13
|
|
|
12
14
|
function generateId(): string {
|
|
@@ -69,17 +71,19 @@ export async function isExtensionConnected(): Promise<boolean> {
|
|
|
69
71
|
|
|
70
72
|
/**
|
|
71
73
|
* Send a command to the daemon and wait for a result.
|
|
72
|
-
* Retries up to
|
|
74
|
+
* Retries up to 4 times: network errors retry at 500ms,
|
|
75
|
+
* transient extension errors retry at 1500ms.
|
|
73
76
|
*/
|
|
74
77
|
export async function sendCommand(
|
|
75
78
|
action: DaemonCommand['action'],
|
|
76
79
|
params: Omit<DaemonCommand, 'id' | 'action'> = {},
|
|
77
80
|
): Promise<unknown> {
|
|
78
|
-
const
|
|
79
|
-
const command: DaemonCommand = { id, action, ...params };
|
|
80
|
-
const maxRetries = 3;
|
|
81
|
+
const maxRetries = 4;
|
|
81
82
|
|
|
82
83
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
84
|
+
// Generate a fresh ID per attempt to avoid daemon-side duplicate detection
|
|
85
|
+
const id = generateId();
|
|
86
|
+
const command: DaemonCommand = { id, action, ...params };
|
|
83
87
|
try {
|
|
84
88
|
const controller = new AbortController();
|
|
85
89
|
const timer = setTimeout(() => controller.abort(), 30000);
|
|
@@ -95,6 +99,17 @@ export async function sendCommand(
|
|
|
95
99
|
const result = (await res.json()) as DaemonResult;
|
|
96
100
|
|
|
97
101
|
if (!result.ok) {
|
|
102
|
+
// Check if error is a transient extension issue worth retrying
|
|
103
|
+
const errMsg = result.error ?? '';
|
|
104
|
+
const isTransient = errMsg.includes('Extension disconnected')
|
|
105
|
+
|| errMsg.includes('Extension not connected')
|
|
106
|
+
|| errMsg.includes('attach failed')
|
|
107
|
+
|| errMsg.includes('no longer exists');
|
|
108
|
+
if (isTransient && attempt < maxRetries) {
|
|
109
|
+
// Longer delay for extension recovery (service worker restart)
|
|
110
|
+
await new Promise(r => setTimeout(r, 1500));
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
98
113
|
throw new Error(result.error ?? 'Daemon command failed');
|
|
99
114
|
}
|
|
100
115
|
|
|
@@ -117,4 +132,4 @@ export async function listSessions(): Promise<BrowserSessionInfo[]> {
|
|
|
117
132
|
const result = await sendCommand('sessions');
|
|
118
133
|
return Array.isArray(result) ? result : [];
|
|
119
134
|
}
|
|
120
|
-
|
|
135
|
+
|
package/src/browser/mcp.ts
CHANGED
|
@@ -55,7 +55,9 @@ export class BrowserBridge {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
private async _ensureDaemon(timeoutSeconds?: number): Promise<void> {
|
|
58
|
-
|
|
58
|
+
// Use default if not provided, zero, or negative
|
|
59
|
+
const effectiveSeconds = (timeoutSeconds && timeoutSeconds > 0) ? timeoutSeconds : Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000);
|
|
60
|
+
const timeoutMs = effectiveSeconds * 1000;
|
|
59
61
|
|
|
60
62
|
if (await isExtensionConnected()) return;
|
|
61
63
|
if (await isDaemonRunning()) {
|
package/src/browser/page.ts
CHANGED
|
@@ -186,14 +186,19 @@ export class Page implements IPage {
|
|
|
186
186
|
|
|
187
187
|
async closeTab(index?: number): Promise<void> {
|
|
188
188
|
await sendCommand('tabs', { op: 'close', ...this._workspaceOpt(), ...(index !== undefined ? { index } : {}) });
|
|
189
|
+
// Invalidate cached tabId — the closed tab might have been our active one.
|
|
190
|
+
// We can't know for sure (close-by-index doesn't return tabId), so reset.
|
|
191
|
+
this._tabId = undefined;
|
|
189
192
|
}
|
|
190
193
|
|
|
191
194
|
async newTab(): Promise<void> {
|
|
192
|
-
await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() });
|
|
195
|
+
const result = await sendCommand('tabs', { op: 'new', ...this._workspaceOpt() }) as { tabId?: number };
|
|
196
|
+
if (result?.tabId) this._tabId = result.tabId;
|
|
193
197
|
}
|
|
194
198
|
|
|
195
199
|
async selectTab(index: number): Promise<void> {
|
|
196
|
-
await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() });
|
|
200
|
+
const result = await sendCommand('tabs', { op: 'select', index, ...this._workspaceOpt() }) as { selected?: number };
|
|
201
|
+
if (result?.selected) this._tabId = result.selected;
|
|
197
202
|
}
|
|
198
203
|
|
|
199
204
|
async networkRequests(includeStatic: boolean = false): Promise<unknown[]> {
|
package/src/daemon.ts
CHANGED
|
@@ -142,6 +142,27 @@ wss.on('connection', (ws: WebSocket) => {
|
|
|
142
142
|
console.error('[daemon] Extension connected');
|
|
143
143
|
extensionWs = ws;
|
|
144
144
|
|
|
145
|
+
// ── Heartbeat: ping every 15s, close if 2 pongs missed ──
|
|
146
|
+
let missedPongs = 0;
|
|
147
|
+
const heartbeatInterval = setInterval(() => {
|
|
148
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
149
|
+
clearInterval(heartbeatInterval);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (missedPongs >= 2) {
|
|
153
|
+
console.error('[daemon] Extension heartbeat lost, closing connection');
|
|
154
|
+
clearInterval(heartbeatInterval);
|
|
155
|
+
ws.terminate();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
missedPongs++;
|
|
159
|
+
ws.ping();
|
|
160
|
+
}, 15000);
|
|
161
|
+
|
|
162
|
+
ws.on('pong', () => {
|
|
163
|
+
missedPongs = 0;
|
|
164
|
+
});
|
|
165
|
+
|
|
145
166
|
ws.on('message', (data: RawData) => {
|
|
146
167
|
try {
|
|
147
168
|
const msg = JSON.parse(data.toString());
|
|
@@ -168,6 +189,7 @@ wss.on('connection', (ws: WebSocket) => {
|
|
|
168
189
|
|
|
169
190
|
ws.on('close', () => {
|
|
170
191
|
console.error('[daemon] Extension disconnected');
|
|
192
|
+
clearInterval(heartbeatInterval);
|
|
171
193
|
if (extensionWs === ws) {
|
|
172
194
|
extensionWs = null;
|
|
173
195
|
// Reject all pending requests since the extension is gone
|
|
@@ -180,6 +202,7 @@ wss.on('connection', (ws: WebSocket) => {
|
|
|
180
202
|
});
|
|
181
203
|
|
|
182
204
|
ws.on('error', () => {
|
|
205
|
+
clearInterval(heartbeatInterval);
|
|
183
206
|
if (extensionWs === ws) extensionWs = null;
|
|
184
207
|
});
|
|
185
208
|
});
|
package/src/pipeline/executor.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Pipeline executor: runs YAML pipeline steps sequentially.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
|
|
6
6
|
import type { IPage } from '../types.js';
|
|
7
7
|
import { getStep, type StepHandler } from './registry.js';
|
|
8
8
|
import { log } from '../logger.js';
|
|
@@ -11,8 +11,13 @@ import { ConfigError } from '../errors.js';
|
|
|
11
11
|
export interface PipelineContext {
|
|
12
12
|
args?: Record<string, unknown>;
|
|
13
13
|
debug?: boolean;
|
|
14
|
+
/** Max retry attempts per step (default: 2 for browser steps, 0 for others) */
|
|
15
|
+
stepRetries?: number;
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
/** Steps that interact with the browser and may fail transiently */
|
|
19
|
+
const BROWSER_STEPS = new Set(['navigate', 'evaluate', 'click', 'type', 'press', 'wait', 'snapshot', 'scroll']);
|
|
20
|
+
|
|
16
21
|
export async function executePipeline(
|
|
17
22
|
page: IPage | null,
|
|
18
23
|
pipeline: unknown[],
|
|
@@ -23,28 +28,68 @@ export async function executePipeline(
|
|
|
23
28
|
let data: unknown = null;
|
|
24
29
|
const total = pipeline.length;
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
try {
|
|
32
|
+
for (let i = 0; i < pipeline.length; i++) {
|
|
33
|
+
const step = pipeline[i];
|
|
34
|
+
if (!step || typeof step !== 'object') continue;
|
|
35
|
+
for (const [op, params] of Object.entries(step)) {
|
|
36
|
+
if (debug) debugStepStart(i + 1, total, op, params);
|
|
31
37
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
const handler = getStep(op);
|
|
39
|
+
if (handler) {
|
|
40
|
+
data = await executeStepWithRetry(handler, page, params, data, args, op, ctx.stepRetries);
|
|
41
|
+
} else {
|
|
42
|
+
throw new ConfigError(
|
|
43
|
+
`Unknown pipeline step "${op}" at index ${i}.`,
|
|
44
|
+
'Check the YAML pipeline step name or register the custom step before execution.',
|
|
45
|
+
);
|
|
46
|
+
}
|
|
41
47
|
|
|
42
|
-
|
|
48
|
+
if (debug) debugStepResult(op, data);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
} catch (err) {
|
|
52
|
+
// Attempt cleanup: close automation window on pipeline failure
|
|
53
|
+
if (page && typeof (page as unknown as Record<string, unknown>).closeWindow === 'function') {
|
|
54
|
+
try { await (page as unknown as { closeWindow: () => Promise<void> }).closeWindow(); } catch { /* ignore */ }
|
|
43
55
|
}
|
|
56
|
+
throw err;
|
|
44
57
|
}
|
|
45
58
|
return data;
|
|
46
59
|
}
|
|
47
60
|
|
|
61
|
+
async function executeStepWithRetry(
|
|
62
|
+
handler: StepHandler,
|
|
63
|
+
page: IPage | null,
|
|
64
|
+
params: unknown,
|
|
65
|
+
data: unknown,
|
|
66
|
+
args: Record<string, unknown>,
|
|
67
|
+
op: string,
|
|
68
|
+
configRetries?: number,
|
|
69
|
+
): Promise<unknown> {
|
|
70
|
+
const maxRetries = configRetries ?? (BROWSER_STEPS.has(op) ? 2 : 0);
|
|
71
|
+
|
|
72
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
73
|
+
try {
|
|
74
|
+
return await handler(page, params, data, args);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (attempt >= maxRetries) throw err;
|
|
77
|
+
// Only retry on transient browser errors
|
|
78
|
+
const msg = err instanceof Error ? err.message : '';
|
|
79
|
+
const isTransient = msg.includes('Extension disconnected')
|
|
80
|
+
|| msg.includes('attach failed')
|
|
81
|
+
|| msg.includes('no longer exists')
|
|
82
|
+
|| msg.includes('CDP connection')
|
|
83
|
+
|| msg.includes('Daemon command failed');
|
|
84
|
+
if (!isTransient) throw err;
|
|
85
|
+
// Brief delay before retry
|
|
86
|
+
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Unreachable
|
|
90
|
+
throw new Error(`Step "${op}" failed after ${maxRetries} retries`);
|
|
91
|
+
}
|
|
92
|
+
|
|
48
93
|
function debugStepStart(stepNum: number, total: number, op: string, params: unknown): void {
|
|
49
94
|
let preview = '';
|
|
50
95
|
if (typeof params === 'string') {
|