publishport-opencli 1.8.5-pp.6 → 1.8.5-pp.7
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/dist/src/browser-session-lock.d.ts +12 -0
- package/dist/src/browser-session-lock.js +118 -0
- package/dist/src/browser-session-lock.test.d.ts +1 -0
- package/dist/src/browser-session-lock.test.js +67 -0
- package/dist/src/cli.js +6 -1
- package/dist/src/cli.test.js +3 -3
- package/dist/src/commands/auth.js +12 -4
- package/dist/src/commands/daemon.d.ts +16 -0
- package/dist/src/commands/daemon.js +42 -0
- package/dist/src/execution.js +25 -3
- package/dist/src/execution.test.js +144 -23
- package/package.json +1 -1
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 在机器级浏览器会话锁的保护下执行 fn。并发调用会排队串行;顺序调用无等待。
|
|
3
|
+
* fn 抛出会原样冒泡,锁始终在 finally 里释放。
|
|
4
|
+
*/
|
|
5
|
+
export declare function withBrowserSessionLock<T>(fn: () => Promise<T>): Promise<T>;
|
|
6
|
+
/** 条件加锁:locked 为真时走串行锁,否则直接执行(前台/交互命令用独立窗口,无需串行)。 */
|
|
7
|
+
export declare function withBrowserSessionLockIf<T>(locked: boolean, fn: () => Promise<T>): Promise<T>;
|
|
8
|
+
export declare const __lockInternals: {
|
|
9
|
+
LOCK_PATH: string;
|
|
10
|
+
ACQUIRE_TIMEOUT_MS: number;
|
|
11
|
+
STALE_AFTER_MS: number;
|
|
12
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// [pp-only] 机器级浏览器会话串行锁。
|
|
2
|
+
//
|
|
3
|
+
// PublishPort 的后台自动化窗口物理上一次只能安全服务一条命令:多条命令并发驱动同一个
|
|
4
|
+
// 窗口时,官方扩展的 rebind/关窗逻辑会把正在跑的命令的调试器扯掉(表现为
|
|
5
|
+
// `Debugger is not attached` / `Detached while handling command`),还会 race 出多余窗口。
|
|
6
|
+
// 由于并发的浏览器命令是各自独立的 opencli 进程,进程内的锁不够,必须跨进程串行。
|
|
7
|
+
//
|
|
8
|
+
// 这里用一个 O_EXCL 锁文件做机器级互斥:并发命令排队,一条条跑完,复用同一个窗口,
|
|
9
|
+
// 既不 churn 也不互相拆台。顺序命令锁立即可得、无影响。
|
|
10
|
+
//
|
|
11
|
+
// 稳定性要点(针对历史上「陈旧锁导致持续 DEVICE_BUSY」的坑):
|
|
12
|
+
// - 锁文件写入持有者 PID;等待方发现持有者进程已死(process.kill(pid,0) 抛 ESRCH)
|
|
13
|
+
// 立即抢占,不会被崩溃残留的锁永久卡死;
|
|
14
|
+
// - 再加一层 mtime 兜底:锁文件过老(默认 10min)也视为陈旧,防 PID 复用的极端情况;
|
|
15
|
+
// - 进程正常退出 finally 删锁;异常退出靠上面两层兜底自愈。
|
|
16
|
+
import { openSync, closeSync, writeSync, readFileSync, unlinkSync, statSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { homedir } from 'node:os';
|
|
19
|
+
const LOCK_DIR = join(homedir(), '.opencli');
|
|
20
|
+
const LOCK_PATH = join(LOCK_DIR, 'browser-adapter-session.lock');
|
|
21
|
+
// 等待前面排队命令的最长时间。设得比单条浏览器命令的运行上限更宽,好让后面的命令
|
|
22
|
+
// 排到队即可,而不是提前放弃。持有者若卡死,会先命中它自己命令的超时而释放锁。
|
|
23
|
+
const ACQUIRE_TIMEOUT_MS = 180_000;
|
|
24
|
+
// mtime 兜底:锁文件比这更老就视为陈旧可抢(正常命令远到不了这个量级)。
|
|
25
|
+
const STALE_AFTER_MS = 10 * 60_000;
|
|
26
|
+
const POLL_MS = 60;
|
|
27
|
+
function sleep(ms) {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
+
}
|
|
30
|
+
function isAlive(pid) {
|
|
31
|
+
try {
|
|
32
|
+
process.kill(pid, 0);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
// ESRCH = 进程不存在;EPERM = 存在但无权限发信号(仍算活着)。
|
|
37
|
+
return err?.code === 'EPERM';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function readLockPid() {
|
|
41
|
+
try {
|
|
42
|
+
const pid = parseInt(readFileSync(LOCK_PATH, 'utf8').trim(), 10);
|
|
43
|
+
return Number.isFinite(pid) ? pid : null;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** 原子占锁:O_EXCL 创建成功即获得锁。已存在返回 false(不抛)。 */
|
|
50
|
+
function tryAcquire() {
|
|
51
|
+
try {
|
|
52
|
+
const fd = openSync(LOCK_PATH, 'wx');
|
|
53
|
+
try {
|
|
54
|
+
writeSync(fd, String(process.pid));
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
closeSync(fd);
|
|
58
|
+
}
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
if (err?.code === 'EEXIST')
|
|
63
|
+
return false;
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** 持有者已死 / 锁文件过老 → 删掉陈旧锁,让后续 tryAcquire 抢占。 */
|
|
68
|
+
function reapStaleLock() {
|
|
69
|
+
try {
|
|
70
|
+
const pid = readLockPid();
|
|
71
|
+
const age = Date.now() - statSync(LOCK_PATH).mtimeMs;
|
|
72
|
+
if ((pid !== null && !isAlive(pid)) || age > STALE_AFTER_MS) {
|
|
73
|
+
unlinkSync(LOCK_PATH);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// 锁文件刚被别人释放/删除:忽略,下一轮 tryAcquire 会重试。
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 在机器级浏览器会话锁的保护下执行 fn。并发调用会排队串行;顺序调用无等待。
|
|
82
|
+
* fn 抛出会原样冒泡,锁始终在 finally 里释放。
|
|
83
|
+
*/
|
|
84
|
+
export async function withBrowserSessionLock(fn) {
|
|
85
|
+
try {
|
|
86
|
+
mkdirSync(LOCK_DIR, { recursive: true });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// 目录已存在或无法创建(后者会在 openSync 时暴露真实错误)。
|
|
90
|
+
}
|
|
91
|
+
const deadline = Date.now() + ACQUIRE_TIMEOUT_MS;
|
|
92
|
+
while (!tryAcquire()) {
|
|
93
|
+
reapStaleLock();
|
|
94
|
+
if (tryAcquire())
|
|
95
|
+
break;
|
|
96
|
+
if (Date.now() >= deadline) {
|
|
97
|
+
throw new Error(`browser session lock busy: waited ${Math.round(ACQUIRE_TIMEOUT_MS / 1000)}s for another browser command to finish`);
|
|
98
|
+
}
|
|
99
|
+
await sleep(POLL_MS);
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
return await fn();
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
try {
|
|
106
|
+
unlinkSync(LOCK_PATH);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// 已被陈旧抢占逻辑删除或从未创建:无所谓。
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** 条件加锁:locked 为真时走串行锁,否则直接执行(前台/交互命令用独立窗口,无需串行)。 */
|
|
114
|
+
export async function withBrowserSessionLockIf(locked, fn) {
|
|
115
|
+
return locked ? withBrowserSessionLock(fn) : fn();
|
|
116
|
+
}
|
|
117
|
+
// 供测试用的常量导出。
|
|
118
|
+
export const __lockInternals = { LOCK_PATH, ACQUIRE_TIMEOUT_MS, STALE_AFTER_MS };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { withBrowserSessionLock, withBrowserSessionLockIf, __lockInternals } from './browser-session-lock.js';
|
|
4
|
+
const LOCK = __lockInternals.LOCK_PATH;
|
|
5
|
+
function cleanupLock() {
|
|
6
|
+
try {
|
|
7
|
+
if (existsSync(LOCK))
|
|
8
|
+
unlinkSync(LOCK);
|
|
9
|
+
}
|
|
10
|
+
catch { /* ignore */ }
|
|
11
|
+
}
|
|
12
|
+
describe('browser-session-lock', () => {
|
|
13
|
+
afterEach(cleanupLock);
|
|
14
|
+
it('serializes concurrent holders — no overlap', async () => {
|
|
15
|
+
const events = [];
|
|
16
|
+
let active = 0;
|
|
17
|
+
let maxActive = 0;
|
|
18
|
+
const task = (id) => withBrowserSessionLock(async () => {
|
|
19
|
+
active++;
|
|
20
|
+
maxActive = Math.max(maxActive, active);
|
|
21
|
+
events.push(`enter:${id}`);
|
|
22
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
23
|
+
events.push(`exit:${id}`);
|
|
24
|
+
active--;
|
|
25
|
+
});
|
|
26
|
+
await Promise.all([task('a'), task('b'), task('c')]);
|
|
27
|
+
// 任一时刻只有一个持有者 → 临界区不重叠。
|
|
28
|
+
expect(maxActive).toBe(1);
|
|
29
|
+
// enter/exit 必须严格配对相邻(a 进 a 出、b 进 b 出…),不会交错。
|
|
30
|
+
for (let i = 0; i < events.length; i += 2) {
|
|
31
|
+
expect(events[i]).toMatch(/^enter:/);
|
|
32
|
+
expect(events[i + 1]).toMatch(/^exit:/);
|
|
33
|
+
expect(events[i].split(':')[1]).toBe(events[i + 1].split(':')[1]);
|
|
34
|
+
}
|
|
35
|
+
// 锁在最后被释放。
|
|
36
|
+
expect(existsSync(LOCK)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
it('releases the lock even when the body throws', async () => {
|
|
39
|
+
await expect(withBrowserSessionLock(async () => { throw new Error('boom'); })).rejects.toThrow('boom');
|
|
40
|
+
expect(existsSync(LOCK)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
it('steals a stale lock whose owner PID is dead', async () => {
|
|
43
|
+
// 写一个持有者 PID = 一个几乎不可能存在的进程号的锁文件(模拟崩溃残留)。
|
|
44
|
+
writeFileSync(LOCK, '2147483646');
|
|
45
|
+
let ran = false;
|
|
46
|
+
await withBrowserSessionLock(async () => { ran = true; });
|
|
47
|
+
expect(ran).toBe(true);
|
|
48
|
+
expect(existsSync(LOCK)).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
it('withBrowserSessionLockIf(false) runs without touching the lock file', async () => {
|
|
51
|
+
let ran = false;
|
|
52
|
+
await withBrowserSessionLockIf(false, async () => {
|
|
53
|
+
ran = true;
|
|
54
|
+
// 未加锁:临界区内锁文件不应存在。
|
|
55
|
+
expect(existsSync(LOCK)).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
expect(ran).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
it('withBrowserSessionLockIf(true) does acquire the lock', async () => {
|
|
60
|
+
let sawLock = false;
|
|
61
|
+
await withBrowserSessionLockIf(true, async () => {
|
|
62
|
+
sawLock = existsSync(LOCK);
|
|
63
|
+
});
|
|
64
|
+
expect(sawLock).toBe(true);
|
|
65
|
+
expect(existsSync(LOCK)).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
});
|
package/dist/src/cli.js
CHANGED
|
@@ -32,7 +32,7 @@ import { buildHtmlTreeJs } from './browser/html-tree.js';
|
|
|
32
32
|
import { buildExtractHtmlJs, runExtractFromHtml } from './browser/extract.js';
|
|
33
33
|
import { analyzeSite } from './browser/analyze.js';
|
|
34
34
|
import { registerAuthCommands } from './commands/auth.js';
|
|
35
|
-
import { daemonRestart, daemonStatus, daemonStop } from './commands/daemon.js';
|
|
35
|
+
import { daemonRestart, daemonStatus, daemonStop, daemonWarm } from './commands/daemon.js';
|
|
36
36
|
import { log } from './logger.js';
|
|
37
37
|
import { bindTab, BrowserCommandError, fetchDaemonStatus, sendCommand } from './browser/daemon-client.js';
|
|
38
38
|
import { aliasForContextId, loadProfileConfig, renameProfile, resolveProfileContextId, setDefaultProfile } from './browser/profile.js';
|
|
@@ -3155,6 +3155,11 @@ cli({
|
|
|
3155
3155
|
.command('restart')
|
|
3156
3156
|
.description('Restart the daemon')
|
|
3157
3157
|
.action(async () => { await daemonRestart(); });
|
|
3158
|
+
daemonCmd
|
|
3159
|
+
.command('warm')
|
|
3160
|
+
.description('Pre-open the background automation window (keeps a tab alive for reuse)')
|
|
3161
|
+
.option('--url <url>', 'http(s) info page to keep in the warm tab (falls back to about:blank)')
|
|
3162
|
+
.action(async (opts) => { await daemonWarm(opts.url); });
|
|
3158
3163
|
// ── External CLIs ─────────────────────────────────────────────────────────
|
|
3159
3164
|
const externalClis = loadExternalClis();
|
|
3160
3165
|
const externalCmd = program
|
package/dist/src/cli.test.js
CHANGED
|
@@ -55,7 +55,7 @@ describe('createProgram root help descriptions', () => {
|
|
|
55
55
|
expect(descriptionFor(program, 'plugin')).toBe('create, install, list, uninstall, update');
|
|
56
56
|
expect(descriptionFor(program, 'adapter')).toBe('eject, reset, status');
|
|
57
57
|
expect(descriptionFor(program, 'profile')).toBe('list, rename, use');
|
|
58
|
-
expect(descriptionFor(program, 'daemon')).toBe('restart, status, stop');
|
|
58
|
+
expect(descriptionFor(program, 'daemon')).toBe('restart, status, stop, warm');
|
|
59
59
|
expect(descriptionFor(program, 'external')).toBe('install, list, register');
|
|
60
60
|
});
|
|
61
61
|
it('renders auth namespace structured help', () => {
|
|
@@ -623,11 +623,11 @@ describe('createProgram root help descriptions', () => {
|
|
|
623
623
|
command: 'opencli daemon',
|
|
624
624
|
usage: 'opencli daemon <command> [args] [options]',
|
|
625
625
|
description: 'Manage the opencli daemon',
|
|
626
|
-
command_count:
|
|
626
|
+
command_count: 4,
|
|
627
627
|
namespace_options: [],
|
|
628
628
|
structured_help: { usage: 'opencli daemon --help -f yaml' },
|
|
629
629
|
});
|
|
630
|
-
expect(data.commands.map((cmd) => cmd.name)).toEqual(['restart', 'status', 'stop']);
|
|
630
|
+
expect(data.commands.map((cmd) => cmd.name)).toEqual(['restart', 'status', 'stop', 'warm']);
|
|
631
631
|
expect(data.global_options.map((option) => option.name)).toEqual(expect.arrayContaining(['version', 'profile']));
|
|
632
632
|
}
|
|
633
633
|
finally {
|
|
@@ -222,6 +222,16 @@ function refreshRowForError(site, entry, error) {
|
|
|
222
222
|
error: code ? `${code}: ${message}` : message,
|
|
223
223
|
};
|
|
224
224
|
}
|
|
225
|
+
// [pp-only] auth 探测默认走 persistent —— 桌面每 20s 的 quick/full 探测若用 ephemeral
|
|
226
|
+
// 会爆一批临时窗口又关,是「窗口不停开又关」的一大来源。persistent 让轮询复用同一批
|
|
227
|
+
// 常驻标签(每平台一个,官方扩展永不 idle 关闭),零 churn。keepTab 跟随 session。
|
|
228
|
+
// 与 resolveSiteSession 一致:默认 persistent,不依赖 env;仅
|
|
229
|
+
// PUBLISHPORT_SITE_SESSION=ephemeral 时才显式退回一次性会话。
|
|
230
|
+
function authProbeSessionOpts() {
|
|
231
|
+
return process.env.PUBLISHPORT_SITE_SESSION === 'ephemeral'
|
|
232
|
+
? { siteSession: 'ephemeral', keepTab: 'false' }
|
|
233
|
+
: { siteSession: 'persistent', keepTab: 'true' };
|
|
234
|
+
}
|
|
225
235
|
async function runQuick(cmd, opts) {
|
|
226
236
|
try {
|
|
227
237
|
const loaded = await loadLazyCommand(cmd);
|
|
@@ -247,8 +257,7 @@ async function runQuick(cmd, opts) {
|
|
|
247
257
|
};
|
|
248
258
|
}
|
|
249
259
|
const result = await executeCommand(quickCmd, { timeout: opts.timeoutSeconds }, false, {
|
|
250
|
-
|
|
251
|
-
keepTab: 'false',
|
|
260
|
+
...authProbeSessionOpts(),
|
|
252
261
|
windowMode: 'background',
|
|
253
262
|
...(opts.profile ? { profile: opts.profile } : {}),
|
|
254
263
|
});
|
|
@@ -277,8 +286,7 @@ async function runFull(cmd, opts) {
|
|
|
277
286
|
const loaded = await loadLazyCommand(cmd);
|
|
278
287
|
const fullCmd = withTimeoutArg(loaded, opts.timeoutSeconds);
|
|
279
288
|
const result = await executeCommand(fullCmd, { timeout: opts.timeoutSeconds }, false, {
|
|
280
|
-
|
|
281
|
-
keepTab: 'false',
|
|
289
|
+
...authProbeSessionOpts(),
|
|
282
290
|
windowMode: 'background',
|
|
283
291
|
...(opts.profile ? { profile: opts.profile } : {}),
|
|
284
292
|
});
|
|
@@ -3,7 +3,23 @@
|
|
|
3
3
|
* opencli daemon status — show daemon state
|
|
4
4
|
* opencli daemon stop — graceful shutdown
|
|
5
5
|
* opencli daemon restart — graceful shutdown, then start a fresh daemon
|
|
6
|
+
* opencli daemon warm — [pp-only] 预热后台自动化窗口(挂一个持久空白标签)
|
|
6
7
|
*/
|
|
7
8
|
export declare function daemonStatus(): Promise<void>;
|
|
8
9
|
export declare function daemonStop(): Promise<void>;
|
|
9
10
|
export declare function daemonRestart(): Promise<void>;
|
|
11
|
+
/**
|
|
12
|
+
* [pp-only] 预热后台自动化窗口。
|
|
13
|
+
*
|
|
14
|
+
* 用一个持久(persistent)会话在后台打开自动化窗口并挂一个标签,让「后台常驻窗口」在
|
|
15
|
+
* **任何真实命令之前**就已经存在——之后各平台命令只是往这个窗口里加/复用标签,永远不会
|
|
16
|
+
* 「一有动作就弹一个新窗口」。桌面客户端在启动、且检测到扩展已连接后调用一次即可。
|
|
17
|
+
*
|
|
18
|
+
* `url` 传一个说明页(如 https://publishport.app/automation)时,这个常驻标签会停在
|
|
19
|
+
* 说明页上,告诉用户「此窗口用于自动化、请勿关闭」——用户不关它,Chrome 与扩展就一直
|
|
20
|
+
* 在线,PublishPort 也能稳定检测到浏览器。不传则回退到 about:blank。
|
|
21
|
+
*
|
|
22
|
+
* 窗口走 background 模式(不聚焦、不抢焦点),标签因 persistent 永不 idle 关闭,会一直
|
|
23
|
+
* 挂着。重复调用是幂等的(复用同一个已存在的窗口/标签)。
|
|
24
|
+
*/
|
|
25
|
+
export declare function daemonWarm(url?: string): Promise<void>;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* opencli daemon status — show daemon state
|
|
4
4
|
* opencli daemon stop — graceful shutdown
|
|
5
5
|
* opencli daemon restart — graceful shutdown, then start a fresh daemon
|
|
6
|
+
* opencli daemon warm — [pp-only] 预热后台自动化窗口(挂一个持久空白标签)
|
|
6
7
|
*/
|
|
7
8
|
import { fetchDaemonStatus, requestDaemonShutdown } from '../browser/daemon-client.js';
|
|
8
9
|
import { restartDaemon } from '../browser/daemon-lifecycle.js';
|
|
@@ -10,6 +11,7 @@ import { formatDuration } from '../download/progress.js';
|
|
|
10
11
|
import { log } from '../logger.js';
|
|
11
12
|
import { PKG_VERSION } from '../version.js';
|
|
12
13
|
import { formatDaemonVersion, isDaemonStale } from '../browser/daemon-version.js';
|
|
14
|
+
import { browserSession, getBrowserFactory } from '../runtime.js';
|
|
13
15
|
export async function daemonStatus() {
|
|
14
16
|
const status = await fetchDaemonStatus();
|
|
15
17
|
if (!status) {
|
|
@@ -96,3 +98,43 @@ export async function daemonRestart() {
|
|
|
96
98
|
log.warn('Daemon is running, but the Browser Bridge extension has not connected yet.');
|
|
97
99
|
}
|
|
98
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* [pp-only] 预热后台自动化窗口。
|
|
103
|
+
*
|
|
104
|
+
* 用一个持久(persistent)会话在后台打开自动化窗口并挂一个标签,让「后台常驻窗口」在
|
|
105
|
+
* **任何真实命令之前**就已经存在——之后各平台命令只是往这个窗口里加/复用标签,永远不会
|
|
106
|
+
* 「一有动作就弹一个新窗口」。桌面客户端在启动、且检测到扩展已连接后调用一次即可。
|
|
107
|
+
*
|
|
108
|
+
* `url` 传一个说明页(如 https://publishport.app/automation)时,这个常驻标签会停在
|
|
109
|
+
* 说明页上,告诉用户「此窗口用于自动化、请勿关闭」——用户不关它,Chrome 与扩展就一直
|
|
110
|
+
* 在线,PublishPort 也能稳定检测到浏览器。不传则回退到 about:blank。
|
|
111
|
+
*
|
|
112
|
+
* 窗口走 background 模式(不聚焦、不抢焦点),标签因 persistent 永不 idle 关闭,会一直
|
|
113
|
+
* 挂着。重复调用是幂等的(复用同一个已存在的窗口/标签)。
|
|
114
|
+
*/
|
|
115
|
+
export async function daemonWarm(url) {
|
|
116
|
+
const status = await fetchDaemonStatus();
|
|
117
|
+
if (!status) {
|
|
118
|
+
// 没连上扩展时 browserSession 会自行拉起 daemon 并等待;但扩展没连上就没法建窗,
|
|
119
|
+
// 这里只提示、不报错,交由桌面在扩展就绪后重试。
|
|
120
|
+
log.warn('Daemon/extension not ready; skip warm (retry after the browser extension connects).');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!status.extensionConnected) {
|
|
124
|
+
log.warn('Browser extension not connected yet; skip warm.');
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
// 扩展只放行 http/https 导航;其它一律回退 about:blank,避免报错。
|
|
128
|
+
const target = url && /^https?:\/\//i.test(url) ? url : 'about:blank';
|
|
129
|
+
const BrowserFactory = getBrowserFactory('__warm__');
|
|
130
|
+
try {
|
|
131
|
+
await browserSession(BrowserFactory, async (page) => {
|
|
132
|
+
// 一次导航即可强制扩展创建自动化窗口 + 标签并登记 persistent 租约。
|
|
133
|
+
await page.goto(target).catch(() => { });
|
|
134
|
+
}, { session: 'site:__warm__', windowMode: 'background', surface: 'adapter', siteSession: 'persistent' });
|
|
135
|
+
log.success(`Automation window warmed (${target}, kept alive for reuse).`);
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
log.warn(`Warm failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
package/dist/src/execution.js
CHANGED
|
@@ -18,6 +18,7 @@ import { executePipeline } from './pipeline/index.js';
|
|
|
18
18
|
import { adapterLoadError, ArgumentError, CommandExecutionError, attachTraceReceipt, getErrorMessage } from './errors.js';
|
|
19
19
|
import { shouldUseBrowserSession } from './capabilityRouting.js';
|
|
20
20
|
import { getBrowserFactory, browserSession, runWithTimeout, DEFAULT_BROWSER_COMMAND_TIMEOUT } from './runtime.js';
|
|
21
|
+
import { withBrowserSessionLockIf } from './browser-session-lock.js';
|
|
21
22
|
import { resolveProfileContextId } from './browser/profile.js';
|
|
22
23
|
import { emitHook } from './hooks.js';
|
|
23
24
|
import { log } from './logger.js';
|
|
@@ -219,7 +220,10 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
|
|
|
219
220
|
const session = resolveAdapterBrowserSession(cmd, siteSession);
|
|
220
221
|
const keepTab = resolveKeepTab(siteSession, opts.keepTab);
|
|
221
222
|
const windowMode = resolveBrowserWindowMode(cmd.defaultWindowMode ?? 'background', opts.windowMode);
|
|
222
|
-
|
|
223
|
+
// [pp-only] 后台自动化命令共用同一个窗口,物理上一次只能安全跑一条——用机器级锁
|
|
224
|
+
// 把并发命令串行化(排队复用同一窗口),避免互相拆窗/扯掉调试器。前台/交互命令走
|
|
225
|
+
// 独立窗口,不参与串行。见 browser-session-lock.ts。
|
|
226
|
+
result = await withBrowserSessionLockIf(windowMode !== 'foreground', () => browserSession(BrowserFactory, async (page) => {
|
|
223
227
|
const observation = traceMode === 'off'
|
|
224
228
|
? null
|
|
225
229
|
: new ObservationSession({
|
|
@@ -334,7 +338,7 @@ export async function executeCommand(cmd, rawKwargs, debug = false, opts = {}) {
|
|
|
334
338
|
await page.closeWindow?.().catch(() => { });
|
|
335
339
|
throw err;
|
|
336
340
|
}
|
|
337
|
-
}, { session, cdpEndpoint, contextId, windowMode, surface: 'adapter', siteSession });
|
|
341
|
+
}, { session, cdpEndpoint, contextId, windowMode, surface: 'adapter', siteSession }));
|
|
338
342
|
}
|
|
339
343
|
else {
|
|
340
344
|
// Non-browser commands: enforce a timeout only when the command exposes
|
|
@@ -451,8 +455,26 @@ function normalizeSiteSession(raw) {
|
|
|
451
455
|
return raw;
|
|
452
456
|
throw new ArgumentError(`--site-session must be one of: ephemeral, persistent. Received: "${String(raw)}"`);
|
|
453
457
|
}
|
|
458
|
+
// [pp-only] PublishPort 是「单机单用户 + 复用真实登录态」模型:它希望所有浏览器
|
|
459
|
+
// 命令长期复用同一个后台自动化窗口,而不是上游默认的 ephemeral(每条命令开标签→
|
|
460
|
+
// 干活→30s idle 后关标签,AI 命令间隔一超过 30s 就变成「窗口又开又关」)。
|
|
461
|
+
// 官方扩展对 persistent 会话本来就永不 idle 关闭、且 SW 重启后能从存活标签反查回
|
|
462
|
+
// 窗口复用——所以只要把默认会话抬成 persistent,无需改扩展即可复用窗口。
|
|
463
|
+
//
|
|
464
|
+
// 关键:默认就是 persistent,**不依赖任何环境变量**。早先版本靠桌面注入
|
|
465
|
+
// PUBLISHPORT_SITE_SESSION=persistent 才开启,实测太脆——同一台机可能同时跑着
|
|
466
|
+
// 「注入了 env 的 dev 构建」和「没注入的正式构建」,后者 fallback 回 ephemeral 照样
|
|
467
|
+
// churn。所以直接把 fork 默认焊成 persistent;只保留 PUBLISHPORT_SITE_SESSION=ephemeral
|
|
468
|
+
// 作为显式反向开关(极少用),供个别场景强制回到一次性会话。
|
|
469
|
+
// 优先级:显式 `--site-session` flag > 环境变量(仅用于强制 ephemeral)> 适配器 cmd.siteSession > 'persistent'。
|
|
470
|
+
function envDefaultSiteSession() {
|
|
471
|
+
const raw = process.env.PUBLISHPORT_SITE_SESSION;
|
|
472
|
+
if (raw === 'persistent' || raw === 'ephemeral')
|
|
473
|
+
return raw;
|
|
474
|
+
return null;
|
|
475
|
+
}
|
|
454
476
|
function resolveSiteSession(cmd, rawOption) {
|
|
455
|
-
return normalizeSiteSession(rawOption) ?? cmd.siteSession ?? '
|
|
477
|
+
return normalizeSiteSession(rawOption) ?? envDefaultSiteSession() ?? cmd.siteSession ?? 'persistent';
|
|
456
478
|
}
|
|
457
479
|
function resolveAdapterBrowserSession(cmd, siteSession) {
|
|
458
480
|
if (siteSession === 'persistent')
|
|
@@ -164,7 +164,12 @@ describe('executeCommand — non-browser timeout', () => {
|
|
|
164
164
|
expect(closeWindow).not.toHaveBeenCalled();
|
|
165
165
|
vi.restoreAllMocks();
|
|
166
166
|
});
|
|
167
|
-
|
|
167
|
+
// [pp-only] PublishPort 把默认会话焊成 persistent(见 resolveSiteSession),所以
|
|
168
|
+
// 无 flag、无 env、适配器也没标 siteSession 时,浏览器命令默认复用同一个 site 会话、
|
|
169
|
+
// 保留标签、跨命令复用窗口 —— 而不是上游默认的一次性 ephemeral。
|
|
170
|
+
it('defaults browser commands to persistent site sessions (pp-only)', async () => {
|
|
171
|
+
const prev = process.env.PUBLISHPORT_SITE_SESSION;
|
|
172
|
+
delete process.env.PUBLISHPORT_SITE_SESSION; // 确保不受外部 env 干扰,测的是「纯默认」
|
|
168
173
|
const closeWindow = vi.fn().mockResolvedValue(undefined);
|
|
169
174
|
const mockPage = { closeWindow };
|
|
170
175
|
const sessionOpts = [];
|
|
@@ -173,26 +178,66 @@ describe('executeCommand — non-browser timeout', () => {
|
|
|
173
178
|
sessionOpts.push(opts ?? {});
|
|
174
179
|
return fn(mockPage);
|
|
175
180
|
});
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
181
|
+
try {
|
|
182
|
+
const cmd = cli({
|
|
183
|
+
site: 'test-execution',
|
|
184
|
+
name: 'site-session-default', access: 'read',
|
|
185
|
+
description: 'test default persistent browser session',
|
|
186
|
+
browser: true,
|
|
187
|
+
strategy: Strategy.PUBLIC,
|
|
188
|
+
func: async () => [{ ok: true }],
|
|
189
|
+
});
|
|
190
|
+
await executeCommand(cmd, {});
|
|
191
|
+
await executeCommand(cmd, {});
|
|
192
|
+
expect(sessionOpts).toHaveLength(2);
|
|
193
|
+
// 两条命令落到同一个稳定 site 会话(无 UUID)→ 复用同一标签/窗口。
|
|
194
|
+
expect(sessionOpts[0]).toMatchObject({ session: 'site:test-execution', windowMode: 'background', siteSession: 'persistent' });
|
|
195
|
+
expect(sessionOpts[1]).toMatchObject({ session: 'site:test-execution', windowMode: 'background', siteSession: 'persistent' });
|
|
196
|
+
// persistent → keepTab=true → 命令跑完不关窗。
|
|
197
|
+
expect(closeWindow).not.toHaveBeenCalled();
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
if (prev === undefined)
|
|
201
|
+
delete process.env.PUBLISHPORT_SITE_SESSION;
|
|
202
|
+
else
|
|
203
|
+
process.env.PUBLISHPORT_SITE_SESSION = prev;
|
|
204
|
+
vi.restoreAllMocks();
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
// [pp-only] 反向开关:PUBLISHPORT_SITE_SESSION=ephemeral 时退回一次性会话。
|
|
208
|
+
it('env PUBLISHPORT_SITE_SESSION=ephemeral forces one-shot sessions back', async () => {
|
|
209
|
+
const prev = process.env.PUBLISHPORT_SITE_SESSION;
|
|
210
|
+
process.env.PUBLISHPORT_SITE_SESSION = 'ephemeral';
|
|
211
|
+
const closeWindow = vi.fn().mockResolvedValue(undefined);
|
|
212
|
+
const mockPage = { closeWindow };
|
|
213
|
+
const sessionOpts = [];
|
|
214
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
215
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn, opts) => {
|
|
216
|
+
sessionOpts.push(opts ?? {});
|
|
217
|
+
return fn(mockPage);
|
|
183
218
|
});
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
219
|
+
try {
|
|
220
|
+
const cmd = cli({
|
|
221
|
+
site: 'test-execution',
|
|
222
|
+
name: 'site-session-force-ephemeral', access: 'read',
|
|
223
|
+
description: 'test env forces ephemeral',
|
|
224
|
+
browser: true,
|
|
225
|
+
strategy: Strategy.PUBLIC,
|
|
226
|
+
func: async () => [{ ok: true }],
|
|
227
|
+
});
|
|
228
|
+
await executeCommand(cmd, {});
|
|
229
|
+
await executeCommand(cmd, {});
|
|
230
|
+
expect(sessionOpts[0]?.session).toMatch(/^site:test-execution:/);
|
|
231
|
+
expect(sessionOpts[0]?.session).not.toBe(sessionOpts[1]?.session);
|
|
232
|
+
expect(closeWindow).toHaveBeenCalledTimes(2);
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
if (prev === undefined)
|
|
236
|
+
delete process.env.PUBLISHPORT_SITE_SESSION;
|
|
237
|
+
else
|
|
238
|
+
process.env.PUBLISHPORT_SITE_SESSION = prev;
|
|
239
|
+
vi.restoreAllMocks();
|
|
240
|
+
}
|
|
196
241
|
});
|
|
197
242
|
it('lets user --site-session ephemeral override adapter persistent metadata', async () => {
|
|
198
243
|
const closeWindow = vi.fn().mockResolvedValue(undefined);
|
|
@@ -223,6 +268,78 @@ describe('executeCommand — non-browser timeout', () => {
|
|
|
223
268
|
vi.restoreAllMocks();
|
|
224
269
|
}
|
|
225
270
|
});
|
|
271
|
+
// [pp-only] PUBLISHPORT_SITE_SESSION 环境变量把默认会话抬成 persistent,且刻意
|
|
272
|
+
// 压过 cmd.siteSession,好让 auth quickCheck 那种把 ephemeral 焊死的命令也复用窗口。
|
|
273
|
+
it('env PUBLISHPORT_SITE_SESSION=persistent lifts an ephemeral-baked command to persistent', async () => {
|
|
274
|
+
const closeWindow = vi.fn().mockResolvedValue(undefined);
|
|
275
|
+
const mockPage = { closeWindow };
|
|
276
|
+
const sessionOpts = [];
|
|
277
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
278
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn, opts) => {
|
|
279
|
+
sessionOpts.push(opts ?? {});
|
|
280
|
+
return fn(mockPage);
|
|
281
|
+
});
|
|
282
|
+
const prev = process.env.PUBLISHPORT_SITE_SESSION;
|
|
283
|
+
process.env.PUBLISHPORT_SITE_SESSION = 'persistent';
|
|
284
|
+
try {
|
|
285
|
+
const cmd = cli({
|
|
286
|
+
site: 'test-execution',
|
|
287
|
+
name: 'site-session-env-persistent', access: 'read',
|
|
288
|
+
description: 'test env-driven persistent session',
|
|
289
|
+
browser: true,
|
|
290
|
+
strategy: Strategy.PUBLIC,
|
|
291
|
+
siteSession: 'ephemeral',
|
|
292
|
+
func: async () => [{ ok: true }],
|
|
293
|
+
});
|
|
294
|
+
await executeCommand(cmd, {});
|
|
295
|
+
await executeCommand(cmd, {});
|
|
296
|
+
expect(sessionOpts).toHaveLength(2);
|
|
297
|
+
expect(sessionOpts[0]).toMatchObject({ session: 'site:test-execution', siteSession: 'persistent' });
|
|
298
|
+
expect(sessionOpts[1]).toMatchObject({ session: 'site:test-execution', siteSession: 'persistent' });
|
|
299
|
+
// persistent → keepTab=true → 窗口不释放,跨命令复用同一标签。
|
|
300
|
+
expect(closeWindow).not.toHaveBeenCalled();
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
if (prev === undefined)
|
|
304
|
+
delete process.env.PUBLISHPORT_SITE_SESSION;
|
|
305
|
+
else
|
|
306
|
+
process.env.PUBLISHPORT_SITE_SESSION = prev;
|
|
307
|
+
vi.restoreAllMocks();
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
it('explicit --site-session ephemeral still overrides the env persistent default', async () => {
|
|
311
|
+
const closeWindow = vi.fn().mockResolvedValue(undefined);
|
|
312
|
+
const mockPage = { closeWindow };
|
|
313
|
+
const sessionOpts = [];
|
|
314
|
+
vi.spyOn(capRouting, 'shouldUseBrowserSession').mockReturnValue(true);
|
|
315
|
+
vi.spyOn(runtime, 'browserSession').mockImplementation(async (_Factory, fn, opts) => {
|
|
316
|
+
sessionOpts.push(opts ?? {});
|
|
317
|
+
return fn(mockPage);
|
|
318
|
+
});
|
|
319
|
+
const prev = process.env.PUBLISHPORT_SITE_SESSION;
|
|
320
|
+
process.env.PUBLISHPORT_SITE_SESSION = 'persistent';
|
|
321
|
+
try {
|
|
322
|
+
const cmd = cli({
|
|
323
|
+
site: 'test-execution',
|
|
324
|
+
name: 'site-session-env-flag-override', access: 'read',
|
|
325
|
+
description: 'test explicit flag beats env',
|
|
326
|
+
browser: true,
|
|
327
|
+
strategy: Strategy.PUBLIC,
|
|
328
|
+
func: async () => [{ ok: true }],
|
|
329
|
+
});
|
|
330
|
+
await executeCommand(cmd, {}, false, { siteSession: 'ephemeral' });
|
|
331
|
+
expect(sessionOpts).toHaveLength(1);
|
|
332
|
+
expect(sessionOpts[0]?.session).toMatch(/^site:test-execution:/);
|
|
333
|
+
expect(closeWindow).toHaveBeenCalledTimes(1);
|
|
334
|
+
}
|
|
335
|
+
finally {
|
|
336
|
+
if (prev === undefined)
|
|
337
|
+
delete process.env.PUBLISHPORT_SITE_SESSION;
|
|
338
|
+
else
|
|
339
|
+
process.env.PUBLISHPORT_SITE_SESSION = prev;
|
|
340
|
+
vi.restoreAllMocks();
|
|
341
|
+
}
|
|
342
|
+
});
|
|
226
343
|
it('skips repeated domain pre-navigation for persistent site sessions', async () => {
|
|
227
344
|
const closeWindow = vi.fn().mockResolvedValue(undefined);
|
|
228
345
|
const goto = vi.fn().mockResolvedValue(undefined);
|
|
@@ -366,7 +483,9 @@ describe('executeCommand — non-browser timeout', () => {
|
|
|
366
483
|
strategy: Strategy.PUBLIC,
|
|
367
484
|
func: async () => { throw new Error('adapter failure'); },
|
|
368
485
|
});
|
|
369
|
-
|
|
486
|
+
// [pp-only] 默认已是 persistent(不关窗);本例专测「失败路径会释放标签」,故显式
|
|
487
|
+
// 走 ephemeral 才有 closeWindow 可断言。
|
|
488
|
+
await expect(executeCommand(cmd, {}, false, { siteSession: 'ephemeral' })).rejects.toThrow('adapter failure');
|
|
370
489
|
expect(closeWindow).toHaveBeenCalledTimes(1);
|
|
371
490
|
vi.restoreAllMocks();
|
|
372
491
|
});
|
|
@@ -512,7 +631,8 @@ describe('executeCommand — non-browser timeout', () => {
|
|
|
512
631
|
strategy: Strategy.PUBLIC,
|
|
513
632
|
func: async () => { throw new Error('adapter failure'); },
|
|
514
633
|
});
|
|
515
|
-
|
|
634
|
+
// [pp-only] 默认 persistent 不关窗;本例断言失败路径 closeWindow,故显式 ephemeral。
|
|
635
|
+
const thrown = await executeCommand(cmd, {}, false, { trace: 'retain-on-failure', siteSession: 'ephemeral' }).catch((err) => err);
|
|
516
636
|
expect(thrown).toBeInstanceOf(Error);
|
|
517
637
|
expect(thrown.message).toContain('adapter failure');
|
|
518
638
|
const tracesRoot = path.join(baseDir, 'profiles', 'default', 'traces');
|
|
@@ -572,7 +692,8 @@ describe('executeCommand — non-browser timeout', () => {
|
|
|
572
692
|
strategy: Strategy.PUBLIC,
|
|
573
693
|
func: async () => [{ ok: true }],
|
|
574
694
|
});
|
|
575
|
-
|
|
695
|
+
// [pp-only] 默认 persistent 不关窗;本例断言成功路径 closeWindow,故显式 ephemeral。
|
|
696
|
+
await expect(executeCommand(cmd, {}, false, { trace: 'on', onTraceExport, siteSession: 'ephemeral' })).resolves.toEqual([{ ok: true }]);
|
|
576
697
|
const stderr = stderrSpy.mock.calls.flat().join('\n');
|
|
577
698
|
expect(stderr).toContain('OpenCLI trace artifact:');
|
|
578
699
|
const tracesRoot = path.join(baseDir, 'profiles', 'default', 'traces');
|