@thincoder/core 0.10.5 → 0.10.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/CHANGELOG.md +17 -0
- package/agent-tools/manifest.mjs +7 -0
- package/browser/actions.mjs +163 -0
- package/browser/cdp.mjs +161 -0
- package/browser/clipboard.mjs +142 -0
- package/browser/input-actions.mjs +197 -0
- package/browser/input.mjs +256 -0
- package/browser/launch.mjs +143 -0
- package/browser/queue.mjs +171 -0
- package/browser/session.mjs +494 -0
- package/browser/snapshot.mjs +270 -0
- package/conventions.mjs +1 -1
- package/declaration.mjs +153 -13
- package/memory/code-search.mjs +21 -4
- package/memory/code-sync.mjs +7 -4
- package/memory/docs.mjs +23 -6
- package/memory/file-list.mjs +13 -7
- package/memory/file-walk.mjs +9 -4
- package/memory/sync-tail.mjs +11 -7
- package/package.json +2 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser/queue.mjs — 调度策略:串行队列 ∥ 动作预算表 ∥ 动作控制器(超时 / 取消单点)∥ 步名跟踪
|
|
3
|
+
* (设计权威 = `docs/core/design/BROWSER-TOOL.md` §2.9——单时钟动作预算 + 三层帽;KD-18 ∥ KD-19)。
|
|
4
|
+
*
|
|
5
|
+
* 单时钟:预算 deadline 自**入队**起算(队列等待计入预算——有意);到点 ⇒ 控制器 `abort`(超时因由)。
|
|
6
|
+
* 排队期到点 ⇒ 动作从未起执行(回执步名 `queue wait`);动作内到点 ⇒ 步名 = 在飞阶段 / CDP 方法名。
|
|
7
|
+
* 控制器(超时 ∥ 取消共用——KD-19):abort 后在飞操作以同一因由拒绝,其后一切调用**快速失败**
|
|
8
|
+
* (清理路径不得再起命令——防僵尸命令与下一动作交错)。
|
|
9
|
+
* 步名:`ctl.run(name, fn)` 压帧(栈顶 = `stuck in <步名>` 的读数源);`h.call` 帧 = CDP 方法名(session 侧接线)。
|
|
10
|
+
*/
|
|
11
|
+
import { WAIT_TIMEOUT_DEFAULT, WAIT_TIMEOUT_MAX } from "./actions.mjs"
|
|
12
|
+
|
|
13
|
+
/** 动作预算表(§2.9——机检):动作 ⇒ 预算 ms。wait 无固定值(谓词帽 + 余量——`budgetFor` 解算)。 */
|
|
14
|
+
export const ACTION_BUDGETS = {
|
|
15
|
+
navigate: 45_000, // 含会话自启(12s 启动帽内)+ 导航 + 就绪轮询 + 快照
|
|
16
|
+
click: 30_000,
|
|
17
|
+
evaluate: 30_000,
|
|
18
|
+
screenshot: 30_000,
|
|
19
|
+
close: 30_000,
|
|
20
|
+
press: 30_000,
|
|
21
|
+
hover: 30_000,
|
|
22
|
+
wheel: 30_000,
|
|
23
|
+
mouse: 30_000,
|
|
24
|
+
drag: 30_000,
|
|
25
|
+
touch: 30_000,
|
|
26
|
+
insert: 30_000,
|
|
27
|
+
clipboard: 30_000,
|
|
28
|
+
type: 15_000, // 单页内快动作
|
|
29
|
+
snapshot: 15_000,
|
|
30
|
+
wait: 45_000, // = 谓词帽缺省 30s + 15s 余量(timeoutMs 给出时按帽 + 余量重解——§2.9 表)
|
|
31
|
+
}
|
|
32
|
+
export const BUDGET_FALLBACK_MS = 30_000 // 未在册动作的兜底预算(防御——全部在册动作已列)
|
|
33
|
+
export const BUDGET_MAX_MS = 120_000 // `timeoutMs` override 硬上限(§2.9)
|
|
34
|
+
export const WAIT_MARGIN_MS = 15_000 // wait 预算余量(谓词帽之上——§2.9 表)
|
|
35
|
+
export const QUEUE_WAIT_STEP = "queue wait"
|
|
36
|
+
|
|
37
|
+
/** 预算解算(§2.9):`timeoutMs` = 动作预算 override(硬上限 120s);wait = 谓词帽 + 15s 余量(语义零变)。 */
|
|
38
|
+
export function budgetFor(action, args = {}) {
|
|
39
|
+
const wanted = Number(args?.timeoutMs)
|
|
40
|
+
const given = Number.isFinite(wanted) && wanted > 0
|
|
41
|
+
if (action === "wait") {
|
|
42
|
+
return (given ? Math.min(wanted, WAIT_TIMEOUT_MAX) : WAIT_TIMEOUT_DEFAULT) + WAIT_MARGIN_MS
|
|
43
|
+
}
|
|
44
|
+
if (given) return Math.min(wanted, BUDGET_MAX_MS)
|
|
45
|
+
return ACTION_BUDGETS[action] ?? BUDGET_FALLBACK_MS
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 单动作控制器:超时 / 取消 / 会话丢失共用一个中止点(一个机制多个因由——KD-19)。 */
|
|
49
|
+
export class ActionController {
|
|
50
|
+
constructor({ action, budgetMs }) {
|
|
51
|
+
this.action = action
|
|
52
|
+
this.budgetMs = budgetMs
|
|
53
|
+
this.aborted = false
|
|
54
|
+
this.reason = null
|
|
55
|
+
this.error = null
|
|
56
|
+
this._deadline = Date.now() + budgetMs
|
|
57
|
+
this._steps = []
|
|
58
|
+
this._abortHooks = []
|
|
59
|
+
this._abortP = new Promise((resolve) => { this._resolveAbort = resolve })
|
|
60
|
+
this._timer = setTimeout(() => this.abort("timeout"), budgetMs)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 剩余预算(ms)——动作内调用帽的下界参照(§2.9 层一:动作内调用随控制器)。 */
|
|
64
|
+
remaining() {
|
|
65
|
+
return Math.max(1, this._deadline - Date.now())
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 快速失败(§2.9):abort 之后一切后续调用即刻拒绝(清理路径不得再起命令)。 */
|
|
69
|
+
failIfAborted() {
|
|
70
|
+
if (this.aborted) throw this.error
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 步名帧 + 中止守卫:帧存活期栈顶 = `stuck in <name>` 读数源;work 随中止以同一因由拒绝。 */
|
|
74
|
+
run(name, work) {
|
|
75
|
+
this.failIfAborted()
|
|
76
|
+
this._steps.push(name)
|
|
77
|
+
let out
|
|
78
|
+
try { out = work() } catch (e) { this._pop(name); throw e }
|
|
79
|
+
return this.guard(Promise.resolve(out)).finally(() => this._pop(name))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 中止守卫:在飞工作与中止竞速(未中止 ⇒ 原样返回)。 */
|
|
83
|
+
guard(work) {
|
|
84
|
+
if (this.aborted) return Promise.reject(this.error)
|
|
85
|
+
return Promise.race([work, this._abortP.then(() => Promise.reject(this.error))])
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 受控等待(§2.9 层一:轮询 sleep 同受控)。 */
|
|
89
|
+
async sleep(ms) {
|
|
90
|
+
let timer = null
|
|
91
|
+
try {
|
|
92
|
+
await this.guard(new Promise((resolve) => { timer = setTimeout(resolve, ms) }))
|
|
93
|
+
} finally {
|
|
94
|
+
if (timer) clearTimeout(timer)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 订阅一次中止(发出即退订);返回退订函数。 */
|
|
99
|
+
onAbort(fn) {
|
|
100
|
+
if (this.aborted) { fn(); return () => {} }
|
|
101
|
+
this._abortHooks.push(fn)
|
|
102
|
+
return () => { this._abortHooks = this._abortHooks.filter((hook) => hook !== fn) }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 中止单点:因由 = timeout ∥ cancelled ∥ session-lost(错误句分形——§2.9 / §2.10)。 */
|
|
106
|
+
abort(reason, error = null) {
|
|
107
|
+
if (this.aborted) return this.error
|
|
108
|
+
this.aborted = true
|
|
109
|
+
this.reason = reason
|
|
110
|
+
this.error = error ?? this._makeError(reason)
|
|
111
|
+
if (this._timer) { clearTimeout(this._timer); this._timer = null }
|
|
112
|
+
this._resolveAbort()
|
|
113
|
+
for (const hook of this._abortHooks.splice(0)) { try { hook() } catch { /* 订阅者自担 */ } }
|
|
114
|
+
return this.error
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 取消(§2.11 kill 单点——与超时共用控制器)。 */
|
|
118
|
+
cancel() {
|
|
119
|
+
return this.abort("cancelled")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 动作结束(清钟——不中止)。 */
|
|
123
|
+
finish() {
|
|
124
|
+
if (this._timer) { clearTimeout(this._timer); this._timer = null }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_pop(name) {
|
|
128
|
+
const at = this._steps.lastIndexOf(name)
|
|
129
|
+
if (at >= 0) this._steps.splice(at, 1)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_makeError(reason) {
|
|
133
|
+
const step = this._steps.at(-1) ?? QUEUE_WAIT_STEP
|
|
134
|
+
const message = reason === "cancelled"
|
|
135
|
+
? `${this.action} cancelled (stuck in ${step})`
|
|
136
|
+
: `${this.action} timed out after ${this.budgetMs}ms (stuck in ${step}) — retry the action, or run \`close\` to reset the session`
|
|
137
|
+
const error = new Error(message)
|
|
138
|
+
error.browserAbort = true
|
|
139
|
+
error.abortReason = reason
|
|
140
|
+
return error
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 串行队列(§2.4:批并行调用按调用序排队——单页不引入并发)。 */
|
|
145
|
+
export function createActionQueue() {
|
|
146
|
+
let tail = Promise.resolve()
|
|
147
|
+
const chain = (task) => {
|
|
148
|
+
const next = tail.then(task)
|
|
149
|
+
tail = next.then(() => { /* 保持链活 */ }, () => { /* 失败不断链 */ })
|
|
150
|
+
return next
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
/** 入队动作:`run(ctl)` 起执行;返回终态(回执 ∥ 拒绝)。排队中到点 ⇒ 即时以同失败形拒绝
|
|
154
|
+
* (`queue wait`——动作从未起执行);起执行后由任务自身守卫裁决 —— 回执/拒绝晚于动作收尾
|
|
155
|
+
* (调用方拿到终态时会话侧清理已完成,不留残余控制器)。
|
|
156
|
+
* signal(批 browser-async-fix 最小增补——可选取消柄):abort ⇒ `cancel()`(queued 丢队 ∥ running 展开);缺省 null 旧调用零变。 */
|
|
157
|
+
enqueue(action, args, run, signal = null) {
|
|
158
|
+
const ctl = new ActionController({ action, budgetMs: budgetFor(action, args) })
|
|
159
|
+
if (signal?.aborted) ctl.cancel()
|
|
160
|
+
else signal?.addEventListener("abort", () => ctl.cancel(), { once: true })
|
|
161
|
+
let started = false
|
|
162
|
+
const task = chain(() => { started = true; return run(ctl) }).finally(() => ctl.finish())
|
|
163
|
+
return new Promise((resolve, reject) => {
|
|
164
|
+
task.then(resolve, reject)
|
|
165
|
+
ctl.onAbort(() => { if (!started) reject(ctl.error) })
|
|
166
|
+
})
|
|
167
|
+
},
|
|
168
|
+
/** 队列直通(无预算语义——空闲关等动作外路径)。 */
|
|
169
|
+
chain,
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser/session.mjs — 会话单例:惰性开启 ∥ 串行队列 ∥ 生命周期 ∥ 页原语 ∥ 安全助手 ∥ 分发表 ∥ 会话句柄。
|
|
3
|
+
* 设计权威 = `docs/core/design/BROWSER-TOOL.md` §2.4(会话模型)∥ §2.5(数据流)∥ §2.9–§2.10(动作预算 /
|
|
4
|
+
* 会话健康)∥ §3(安全面)∥ §5(拆分决定)。
|
|
5
|
+
*
|
|
6
|
+
* 单会话/进程(KD-5):同进程所有 agent 共一份浏览器;动作串行(同一队列——批并行调用天然排队)。
|
|
7
|
+
* 硬超时(§2.9):每动作入队即记预算 deadline(队列等待计入——有意),到点由 `queue.mjs` 动作控制器中止
|
|
8
|
+
* (错误句携卡点步名);一切 CDP 命令经本档 `cdpCall` 单点接线(步名 = 方法名 ∥ 中止守卫 ∥ abort 后快速失败)。
|
|
9
|
+
* 会话健康(§2.10):进程事件 ∥ 连接事件 ∥ 入口探针三源;外部关闭 ⇒ `failSession` 单点(拒在飞 ⇒ 态复位
|
|
10
|
+
* ⇒ 释放 profile 锁 ⇒ 置重开注记),下一次动作自愈重开(首回执尾行注记——置位一次,发出即清)。
|
|
11
|
+
* `headless` = 会话开启参数(KD-4:异值报错,不热切);空闲 15 分钟自动关(KD-8)+ 进程退出杀树兜底。
|
|
12
|
+
* 动作实施层 = `actions.mjs`(基线八)+ `input-actions.mjs`(新七)+ `clipboard.mjs`——经会话句柄取能力,
|
|
13
|
+
* 不 import 本档(DAG 单向:`session → {actions, input-actions, clipboard} → {input, snapshot, cdp}`;`session → queue`,§5)。
|
|
14
|
+
*
|
|
15
|
+
* 注入缝(N-BT6):`_deps.openBrowser` ∥ `_deps.killBrowser` ∥ `_deps.saveShot`——缺省 null ⇒
|
|
16
|
+
* 回落真实现(`??`);用例注入后 `finally` 还原。
|
|
17
|
+
*/
|
|
18
|
+
import { mkdirSync, writeFileSync } from "node:fs"
|
|
19
|
+
import { join } from "node:path"
|
|
20
|
+
import { ACTIONS } from "./actions.mjs"
|
|
21
|
+
import { CLIPBOARD_ACTIONS } from "./clipboard.mjs"
|
|
22
|
+
import { INPUT_ACTIONS } from "./input-actions.mjs"
|
|
23
|
+
import { DEFAULT_CALL_TIMEOUT_MS, connectCdp, newTabWebSocketUrl } from "./cdp.mjs"
|
|
24
|
+
import { acquireProfileLock, browserPaths, killBrowser, launchBrowser, releaseProfileLock, resolveBrowser } from "./launch.mjs"
|
|
25
|
+
import { createActionQueue } from "./queue.mjs"
|
|
26
|
+
import {
|
|
27
|
+
COMPACT_ROWS, DEFAULT_MAX, MAX_ELEMENTS, assignRefs, createRefTable, focusExpression, normalizeSnapshot,
|
|
28
|
+
pageInfoExpression, renderSnapshot, snapshotExpression,
|
|
29
|
+
} from "./snapshot.mjs"
|
|
30
|
+
|
|
31
|
+
export const IDLE_MS = 15 * 60 * 1000
|
|
32
|
+
const READY_TIMEOUT_MS = 10_000
|
|
33
|
+
const NAV_SETTLE_MS = 1_000
|
|
34
|
+
const GRACEFUL_CLOSE_MS = 3_000
|
|
35
|
+
const CLOSE_CALL_MS = 3_000 // `Browser.close` 显式帽(§2.9 层二)
|
|
36
|
+
const ENABLE_CALL_MS = 10_000 // `Page/Runtime/Network.enable` 显式帽(§2.9 层二)
|
|
37
|
+
const PROBE_TIMEOUT_MS = 2_000 // 健康探针心跳帽(§2.9 层二 / §2.10 源③)
|
|
38
|
+
const PROBE_FRESH_MS = 5_000 // 距上次成功 CDP 活动 >5s 才做心跳(§2.10 源③)
|
|
39
|
+
const CALL_CAP_SLACK_MS = 1_000 // 动作内调用兜底帽对剩余预算的余量(预算先行:控制器先于兜底帽裁决)
|
|
40
|
+
const REOPEN_NOTE = "[note: the previous browser session was closed externally — a fresh session was opened (page state lost; profile/login kept)]"
|
|
41
|
+
|
|
42
|
+
/** 测试替身缝(缺省 null ⇒ 回落真实现)。 */
|
|
43
|
+
export const _deps = { openBrowser: null, killBrowser: null, saveShot: null }
|
|
44
|
+
|
|
45
|
+
/** 在飞动作的控制器(§2.9——单动作单控制器;串行队列 ⇒ 同刻至多一个)。 */
|
|
46
|
+
let currentController = null
|
|
47
|
+
|
|
48
|
+
const queue = createActionQueue()
|
|
49
|
+
|
|
50
|
+
const sleep = (ms) => (currentController ? currentController.sleep(ms) : new Promise((r) => setTimeout(r, ms)))
|
|
51
|
+
|
|
52
|
+
/** 控制器接线(无控制器 ⇒ 直通):步名帧 + 中止守卫(§2.9)。 */
|
|
53
|
+
function runFrame(name, work) {
|
|
54
|
+
return currentController ? currentController.run(name, work) : work()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const state = {
|
|
58
|
+
cdp: null,
|
|
59
|
+
child: null,
|
|
60
|
+
lockPath: null,
|
|
61
|
+
headless: null,
|
|
62
|
+
table: createRefTable(),
|
|
63
|
+
lastUrl: null,
|
|
64
|
+
lastTitle: null,
|
|
65
|
+
lastActivityAt: 0,
|
|
66
|
+
idleTimer: null,
|
|
67
|
+
reopenNote: false,
|
|
68
|
+
net: { inflight: new Set(), lastEventAt: 0 },
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── 生命周期 ────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/** 真实现:发现 → profile 锁 → spawn → `/json/new` → WebSocket → 三域 enable(§2.4 开启序)。
|
|
74
|
+
* 开启段中途中止 ⇒ 连接复位 + 杀树兜底(含「launch 未返回」窗口:结算即补杀)+ 释放 profile 锁;置重开注记在 ensureSession。 */
|
|
75
|
+
async function openBrowserReal({ headless }) {
|
|
76
|
+
const exe = resolveBrowser()
|
|
77
|
+
const paths = browserPaths()
|
|
78
|
+
acquireProfileLock(paths.lock)
|
|
79
|
+
let child = null, conn = null, launching = null
|
|
80
|
+
try {
|
|
81
|
+
const launched = await runFrame("launch browser", () => (launching = launchBrowser({ exe, headless, profile: paths.profile })))
|
|
82
|
+
child = launched.child
|
|
83
|
+
const wsUrl = await runFrame("connect CDP", () => newTabWebSocketUrl(launched.port))
|
|
84
|
+
conn = await runFrame("connect CDP", () => connectCdp(wsUrl))
|
|
85
|
+
for (const domain of ["Page.enable", "Runtime.enable", "Network.enable"]) {
|
|
86
|
+
await runFrame(domain, () => conn.call(domain, {}, { timeoutMs: ENABLE_CALL_MS }))
|
|
87
|
+
}
|
|
88
|
+
return { cdp: conn, child, headless, lock: paths.lock }
|
|
89
|
+
} catch (e) {
|
|
90
|
+
try { conn?.close() } catch { /* 已断 */ }
|
|
91
|
+
// 中止落在「launch 未返回」窗口 ⇒ child 未赋值:自启仍会结算 ⇒ 结算即补杀(§2.9 开启段中止——防孤儿)
|
|
92
|
+
if (child) { try { (_deps.killBrowser ?? killBrowser)(child) } catch { /* 已退出 */ } }
|
|
93
|
+
else if (launching) launching.then((l) => { try { (_deps.killBrowser ?? killBrowser)(l.child) } catch { /* 已退出 */ } }, () => { /* 自启自败(其内已杀树) */ })
|
|
94
|
+
releaseProfileLock(paths.lock)
|
|
95
|
+
throw e
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 会话态复位(关闭 ∥ 外部关闭共用单点)。 */
|
|
100
|
+
function resetState() {
|
|
101
|
+
disarmIdle()
|
|
102
|
+
state.cdp = null
|
|
103
|
+
state.child = null
|
|
104
|
+
state.lockPath = null
|
|
105
|
+
state.headless = null
|
|
106
|
+
state.lastUrl = null
|
|
107
|
+
state.lastTitle = null
|
|
108
|
+
state.lastActivityAt = 0
|
|
109
|
+
state.table = createRefTable()
|
|
110
|
+
state.net = { inflight: new Set(), lastEventAt: 0 }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function armIdle() {
|
|
114
|
+
if (state.idleTimer) clearTimeout(state.idleTimer)
|
|
115
|
+
state.idleTimer = setTimeout(() => {
|
|
116
|
+
// 走串行队列:空闲关不与在途动作交错(下一动作排在关后——同 profile 不会双开)
|
|
117
|
+
queue.chain(() => closeSession()).catch(() => { /* 空闲关失败不逸出 */ })
|
|
118
|
+
}, IDLE_MS)
|
|
119
|
+
state.idleTimer.unref?.() // 不为一台浏览器吊住宿主进程
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function disarmIdle() {
|
|
123
|
+
if (state.idleTimer) { clearTimeout(state.idleTimer); state.idleTimer = null }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let exitHooked = false
|
|
127
|
+
function registerExitHook() {
|
|
128
|
+
if (exitHooked) return
|
|
129
|
+
exitHooked = true
|
|
130
|
+
process.once("exit", () => { if (state.child) (_deps.killBrowser ?? killBrowser)(state.child) })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function headlessOf(args) {
|
|
134
|
+
return typeof args?.headless === "boolean" ? args.headless : undefined
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── 会话健康(§2.10——外部关闭可感知 + 自愈)────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/** 外部关闭显式失败句(§2.10):在飞动作与入口探针同一句式。 */
|
|
140
|
+
function externalCloseError(cause) {
|
|
141
|
+
const error = new Error(`the browser session was closed externally (${cause}) — run the action again to reopen a fresh session`)
|
|
142
|
+
error.sessionLost = true
|
|
143
|
+
return error
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 处置单点(§2.10):拒在飞(同一因由实例)⇒ 态复位 ⇒ 释放 profile 锁(子进程仍活 ⇒ 杀树兜底)⇒ 置重开注记。
|
|
147
|
+
* 不调 `Browser.close`、不等自退(连接 / 进程已死)。 */
|
|
148
|
+
function failSession(cause) {
|
|
149
|
+
const error = externalCloseError(cause)
|
|
150
|
+
currentController?.abort("session-lost", error)
|
|
151
|
+
const { cdp, child, lockPath } = state
|
|
152
|
+
resetState()
|
|
153
|
+
if (cdp) { try { cdp.close() } catch { /* 已断 */ } }
|
|
154
|
+
if (child) { try { (_deps.killBrowser ?? killBrowser)(child) } catch { /* 已退出 */ } }
|
|
155
|
+
if (lockPath) releaseProfileLock(lockPath)
|
|
156
|
+
state.reopenNote = true
|
|
157
|
+
return error
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 健康接线(§2.10 源①②):子进程 exit/error ∥ WS 断连 ⇒ 即时失败化。 */
|
|
161
|
+
function attachSessionHealth(cdp) {
|
|
162
|
+
const child = state.child
|
|
163
|
+
if (child && typeof child.on === "function") {
|
|
164
|
+
const gone = () => { if (state.child === child) failSession("the browser process exited") }
|
|
165
|
+
child.on("exit", gone)
|
|
166
|
+
child.on("error", gone)
|
|
167
|
+
}
|
|
168
|
+
cdp.onDisconnect?.(() => { if (state.cdp === cdp) failSession("the connection to the browser dropped") })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 入口探针(§2.10 源③):子进程存活 ∧ 连接未关(恒检——零成本);距上次成功活动 >5s ⇒ 心跳 `Browser.getVersion`(2s 帽)。 */
|
|
172
|
+
async function probeSession() {
|
|
173
|
+
const child = state.child
|
|
174
|
+
// 非真实子进程(无事件面的替身)⇒ 存活不可判——跳过该检(真实现恒有 ChildProcess 事件面)
|
|
175
|
+
if (child && typeof child.on === "function" && (child.exitCode !== null || child.signalCode != null)) {
|
|
176
|
+
throw failSession("the browser process exited")
|
|
177
|
+
}
|
|
178
|
+
if (state.cdp?.closed) throw failSession("the connection to the browser dropped")
|
|
179
|
+
if (Date.now() - state.lastActivityAt < PROBE_FRESH_MS) return
|
|
180
|
+
try {
|
|
181
|
+
await cdpCall("Browser.getVersion", {}, { timeoutMs: PROBE_TIMEOUT_MS })
|
|
182
|
+
} catch (e) {
|
|
183
|
+
if (e?.browserAbort || e?.sessionLost) throw e // 预算中止 / 会话已失 ⇒ 原样上报(fresh 判据已由它裁决)
|
|
184
|
+
throw failSession("the browser is not responding")
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** 会话开启(惰性 + headless 一致性判据 + 入口探针);开启段中止 ⇒ 置重开注记(半开态已由
|
|
189
|
+
* `openBrowserReal` 复位)——下一次动作干净重开,不中毒(§2.9 开启段中止)。 */
|
|
190
|
+
async function ensureSession(wanted) {
|
|
191
|
+
if (state.cdp) {
|
|
192
|
+
if (wanted !== undefined && wanted !== state.headless) {
|
|
193
|
+
throw new Error(`session is already running (headless=${state.headless}) — close it first to switch mode`)
|
|
194
|
+
}
|
|
195
|
+
await probeSession()
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
let opened
|
|
199
|
+
try {
|
|
200
|
+
opened = await runFrame("launch browser", () => (_deps.openBrowser ?? openBrowserReal)({ headless: wanted === undefined ? true : wanted }))
|
|
201
|
+
} catch (e) {
|
|
202
|
+
if (e?.browserAbort) state.reopenNote = true
|
|
203
|
+
throw e
|
|
204
|
+
}
|
|
205
|
+
state.cdp = opened.cdp
|
|
206
|
+
state.child = opened.child ?? null
|
|
207
|
+
state.lockPath = opened.lock ?? null
|
|
208
|
+
state.headless = opened.headless === undefined ? (wanted ?? true) : opened.headless
|
|
209
|
+
state.table = createRefTable()
|
|
210
|
+
state.net = { inflight: new Set(), lastEventAt: Date.now() }
|
|
211
|
+
state.lastActivityAt = Date.now()
|
|
212
|
+
const net = state.net
|
|
213
|
+
opened.cdp.on("Network.requestWillBeSent", (p) => { net.inflight.add(p.requestId); net.lastEventAt = Date.now() })
|
|
214
|
+
opened.cdp.on("Network.loadingFinished", (p) => { net.inflight.delete(p.requestId); net.lastEventAt = Date.now() })
|
|
215
|
+
opened.cdp.on("Network.loadingFailed", (p) => { net.inflight.delete(p.requestId); net.lastEventAt = Date.now() })
|
|
216
|
+
attachSessionHealth(opened.cdp)
|
|
217
|
+
registerExitHook()
|
|
218
|
+
armIdle()
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** 等子进程自退(优雅关后)。 */
|
|
222
|
+
async function waitForExit(child, maxMs) {
|
|
223
|
+
const deadline = Date.now() + maxMs
|
|
224
|
+
while (child.exitCode == null && Date.now() < deadline) await sleep(100)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** 关闭会话(幂等);返回是否有会话被关。
|
|
228
|
+
* 先 `Browser.close` 优雅关(浏览器自行落盘 profile——Cookie/localStorage 持久化是 F-BT6 的落盘前提;
|
|
229
|
+
* 实测:直杀树不落盘 ⇒ 二段会话拿不到 Cookie),再等自退,未退才走杀树兜底。
|
|
230
|
+
* 中止(预算 / 取消)⇒ 跳过优雅等待、直接杀树兜底,清理完成后以中止因由上报(§2.9 abort 语义)。 */
|
|
231
|
+
export async function closeSession() {
|
|
232
|
+
const { cdp, child, lockPath } = state
|
|
233
|
+
resetState()
|
|
234
|
+
let aborted = null
|
|
235
|
+
if (cdp) {
|
|
236
|
+
try { await runFrame("Browser.close", () => cdp.call("Browser.close", {}, { timeoutMs: CLOSE_CALL_MS })) }
|
|
237
|
+
catch (e) { if (e?.browserAbort) aborted = e /* 已断 ∥ 不支持 ∥ 中止 ⇒ 转杀树兜底 */ }
|
|
238
|
+
try { cdp.close() } catch { /* 已断 */ }
|
|
239
|
+
}
|
|
240
|
+
if (child) {
|
|
241
|
+
if (!aborted) {
|
|
242
|
+
try { await runFrame("Browser.close", () => waitForExit(child, GRACEFUL_CLOSE_MS)) }
|
|
243
|
+
catch (e) { if (e?.browserAbort) aborted = e }
|
|
244
|
+
}
|
|
245
|
+
if (aborted || child.exitCode == null) {
|
|
246
|
+
try { (_deps.killBrowser ?? killBrowser)(child) } catch { /* 已退出 */ }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (lockPath) releaseProfileLock(lockPath)
|
|
250
|
+
if (aborted) throw aborted
|
|
251
|
+
return Boolean(cdp)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** 审批请示面用的「最近页 URL」(§3.1 文案;无会话 ⇒ null)。 */
|
|
255
|
+
export function lastPageUrl() {
|
|
256
|
+
return state.lastUrl
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── 页原语 ──────────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
/** 一切 CDP 命令的单点(§2.9 层一):步名帧(方法名)∥ 中止守卫 ∥ abort 后快速失败;成功 ⇒ 记活动时刻。
|
|
262
|
+
* 缺省帽(层三 15s)在动作预算内让位于控制器(预算先行——层一优先:兜底帽 = 剩余预算 + 1s 余量);
|
|
263
|
+
* 动作外路径按缺省帽兜底。 */
|
|
264
|
+
function cdpCall(method, params = {}, { timeoutMs } = {}) {
|
|
265
|
+
const cdp = state.cdp
|
|
266
|
+
if (!cdp) throw new Error("no browser session — run navigate first")
|
|
267
|
+
const ctl = currentController
|
|
268
|
+
ctl?.failIfAborted()
|
|
269
|
+
const cap = timeoutMs ?? (ctl ? Math.max(DEFAULT_CALL_TIMEOUT_MS, ctl.remaining() + CALL_CAP_SLACK_MS) : DEFAULT_CALL_TIMEOUT_MS)
|
|
270
|
+
const work = ctl ? ctl.run(method, () => cdp.call(method, params, { timeoutMs: cap })) : cdp.call(method, params, { timeoutMs: cap })
|
|
271
|
+
return Promise.resolve(work).then(
|
|
272
|
+
(result) => { state.lastActivityAt = Date.now(); return result },
|
|
273
|
+
(e) => { if (ctl?.aborted) throw ctl.error; throw e },
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function evalRaw(expression) {
|
|
278
|
+
// CDP 形:`call()` 解到命令的 result(`{result: RemoteObject}`)——页面值在 `result.result.value`
|
|
279
|
+
const r = await cdpCall("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true })
|
|
280
|
+
if (r?.exceptionDetails) throw pageError(exceptionText(r.exceptionDetails)) // 页面侧异常 ⇒ 同失败回执面(带页摘)
|
|
281
|
+
return r?.result?.value
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function exceptionText(details) {
|
|
285
|
+
const raw = details?.exception?.description ?? details?.text ?? "unknown page exception"
|
|
286
|
+
return String(raw).split("\n")[0]
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function pageInfo() {
|
|
290
|
+
const v = await evalRaw(pageInfoExpression())
|
|
291
|
+
const info = { url: String(v?.url ?? ""), title: String(v?.title ?? ""), readyState: String(v?.readyState ?? "") }
|
|
292
|
+
state.lastUrl = info.url
|
|
293
|
+
state.lastTitle = info.title
|
|
294
|
+
return info
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function waitForReady(maxMs = READY_TIMEOUT_MS) {
|
|
298
|
+
return runFrame("waitForReady", async () => {
|
|
299
|
+
const deadline = Date.now() + maxMs
|
|
300
|
+
for (;;) {
|
|
301
|
+
const info = await pageInfo()
|
|
302
|
+
if (info.readyState === "complete" || Date.now() >= deadline) return info
|
|
303
|
+
await sleep(100)
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function waitForUrlChange(before, maxMs = NAV_SETTLE_MS) {
|
|
309
|
+
return runFrame("wait poll", async () => {
|
|
310
|
+
const deadline = Date.now() + maxMs
|
|
311
|
+
for (;;) {
|
|
312
|
+
const info = await pageInfo()
|
|
313
|
+
if (info.url !== before) return info.url
|
|
314
|
+
if (Date.now() >= deadline) return null
|
|
315
|
+
await sleep(100)
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** 取快照 + 分配引用 + 渲染(会话内单一快照点)。 */
|
|
321
|
+
async function takeSnapshot({ selector = null, max = DEFAULT_MAX } = {}) {
|
|
322
|
+
const wanted = Number(max)
|
|
323
|
+
const capped = Math.max(1, Math.min(Number.isFinite(wanted) ? Math.floor(wanted) : DEFAULT_MAX, MAX_ELEMENTS))
|
|
324
|
+
const snap = normalizeSnapshot(await evalRaw(snapshotExpression({ selector, max: capped })))
|
|
325
|
+
if (snap.missing) {
|
|
326
|
+
throw pageError(`snapshot selector "${selector}" matched nothing — pass a CSS selector present on the page`)
|
|
327
|
+
}
|
|
328
|
+
const rows = assignRefs(state.table, snap.elements)
|
|
329
|
+
state.lastUrl = snap.url
|
|
330
|
+
state.lastTitle = snap.title
|
|
331
|
+
return {
|
|
332
|
+
url: snap.url,
|
|
333
|
+
title: snap.title,
|
|
334
|
+
total: snap.total,
|
|
335
|
+
rows,
|
|
336
|
+
text: renderSnapshot({ url: snap.url, title: snap.title, rows, total: snap.total }),
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** ref → 引用表条目(未在表 ⇒ 同 stale 句式——§2.3 失效口径)。 */
|
|
341
|
+
function resolveRef(ref) {
|
|
342
|
+
const entry = state.table.byRef.get(String(ref))
|
|
343
|
+
if (!entry) throw pageError(`ref ${ref} is stale (was "unknown") — run snapshot again`)
|
|
344
|
+
return entry
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** ref → 聚焦(§2.7):表查 + `focusExpression`;不可聚焦 ⇒ 明示拒(不静默错投)。 */
|
|
348
|
+
async function focusRef(ref) {
|
|
349
|
+
const entry = resolveRef(ref)
|
|
350
|
+
const f = await evalRaw(focusExpression(entry.selector, entry.tag))
|
|
351
|
+
if (!f?.found || f.tag) throw pageError(`ref ${ref} is stale (was "${entry.name}") — run snapshot again`)
|
|
352
|
+
if (f.focused === false) throw pageError(`ref ${ref} "${entry.name}" is not focusable — keys would go to another element`)
|
|
353
|
+
return { ref, name: entry.name, label: `${ref} "${entry.name}"` }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── 安全面 ──────────────────────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
/** 域允许清单匹配(§3.2):全等(大小写不敏感)∥ `*.` 前缀 = 子域通配。 */
|
|
359
|
+
export function hostAllowed(host, list) {
|
|
360
|
+
const h = String(host).toLowerCase()
|
|
361
|
+
return list.some((entry) => {
|
|
362
|
+
const pattern = String(entry ?? "").trim().toLowerCase()
|
|
363
|
+
if (!pattern) return false
|
|
364
|
+
if (pattern.startsWith("*.")) return h.endsWith(`.${pattern.slice(2)}`)
|
|
365
|
+
return h === pattern
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function allowDomainsOf(ctx) {
|
|
370
|
+
const list = ctx?.agent?.config?.browser?.allowDomains
|
|
371
|
+
return Array.isArray(list) ? list : []
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** 域强检(navigate 目标 ∥ click/evaluate/type 当前页——§3.2 两点)。 */
|
|
375
|
+
function assertHostAllowed(url, ctx) {
|
|
376
|
+
const list = allowDomainsOf(ctx)
|
|
377
|
+
if (list.length === 0) return
|
|
378
|
+
let host = ""
|
|
379
|
+
try { host = new URL(url).hostname } catch { return }
|
|
380
|
+
if (!hostAllowed(host, list)) {
|
|
381
|
+
throw new Error(`blocked by browser.allowDomains — host "${host}" not allowed`)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** 失败回执(§2.2):`Error: <因由>` + 页摘 + 紧凑清单(≤30 行——页不可读时仅错误行)。 */
|
|
386
|
+
async function failureReceipt(message) {
|
|
387
|
+
const parts = [`Error: ${message}`]
|
|
388
|
+
if (state.cdp) {
|
|
389
|
+
try { parts.push((await takeSnapshot({ max: COMPACT_ROWS })).text) } catch { /* 页不可读 ⇒ 仅错误行 */ }
|
|
390
|
+
}
|
|
391
|
+
return parts.join("\n")
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function pageError(message) {
|
|
395
|
+
const err = new Error(message)
|
|
396
|
+
err.pageReceipt = true
|
|
397
|
+
return err
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** 真落盘(screenshot 缝的缺省实现):`~/.thincoder/browser/shots/shot-<ISO>.png`。 */
|
|
401
|
+
async function saveShotToDisk(buffer) {
|
|
402
|
+
const dir = browserPaths().shots
|
|
403
|
+
mkdirSync(dir, { recursive: true })
|
|
404
|
+
const path = join(dir, `shot-${new Date().toISOString().replace(/[:.]/g, "-")}-${process.pid}.png`)
|
|
405
|
+
writeFileSync(path, buffer)
|
|
406
|
+
return { path, bytes: buffer.length }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── 会话句柄(动作模块的能力面——§5:动作模块不 import 本档)──────────────────
|
|
410
|
+
|
|
411
|
+
/** 等一条 CDP 事件(一次性;超时 / 中止 ⇒ 拒——html5 拖拽拦截链用)。 */
|
|
412
|
+
function once(method, timeoutMs = 10_000) {
|
|
413
|
+
const ctl = currentController
|
|
414
|
+
ctl?.failIfAborted()
|
|
415
|
+
return new Promise((resolve, reject) => {
|
|
416
|
+
if (!state.cdp) { reject(new Error("no browser session")); return }
|
|
417
|
+
let timer = null
|
|
418
|
+
let off = null
|
|
419
|
+
let offAbort = null
|
|
420
|
+
let done = false
|
|
421
|
+
const finish = (fn, value) => {
|
|
422
|
+
if (done) return
|
|
423
|
+
done = true
|
|
424
|
+
if (timer) clearTimeout(timer)
|
|
425
|
+
off?.()
|
|
426
|
+
offAbort?.()
|
|
427
|
+
fn(value)
|
|
428
|
+
}
|
|
429
|
+
off = state.cdp.on(method, (params) => finish(resolve, params))
|
|
430
|
+
offAbort = ctl?.onAbort(() => finish(reject, ctl.error))
|
|
431
|
+
timer = setTimeout(() => finish(reject, new Error(`timed out waiting for ${method}`)), timeoutMs)
|
|
432
|
+
timer.unref?.()
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const sessionHandle = {
|
|
437
|
+
ensureSession: (args) => ensureSession(headlessOf(args)),
|
|
438
|
+
closeSession,
|
|
439
|
+
connection: () => state.cdp,
|
|
440
|
+
call: (method, params) => cdpCall(method, params),
|
|
441
|
+
once: (method, timeoutMs) => runFrame(method, () => once(method, timeoutMs)),
|
|
442
|
+
evalRaw,
|
|
443
|
+
pageInfo,
|
|
444
|
+
waitForReady: (maxMs) => waitForReady(maxMs),
|
|
445
|
+
waitForUrlChange: (before, maxMs) => waitForUrlChange(before, maxMs),
|
|
446
|
+
takeSnapshot,
|
|
447
|
+
resolveRef,
|
|
448
|
+
focusRef,
|
|
449
|
+
assertHostAllowed,
|
|
450
|
+
failureReceipt,
|
|
451
|
+
pageError,
|
|
452
|
+
step: (name, fn) => runFrame(name, fn),
|
|
453
|
+
sleep,
|
|
454
|
+
saveShot: (buffer) => (_deps.saveShot ?? saveShotToDisk)(buffer),
|
|
455
|
+
networkState: () => state.net,
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── 分发表 ∥ 入口 ────────────────────────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
const ACTION_TABLE = { ...ACTIONS, ...INPUT_ACTIONS, ...CLIPBOARD_ACTIONS }
|
|
461
|
+
|
|
462
|
+
async function dispatchAction(action, args, ctx) {
|
|
463
|
+
const handler = ACTION_TABLE[action]
|
|
464
|
+
if (typeof handler !== "function") throw new Error(`unknown browser action "${action}"`)
|
|
465
|
+
return handler(args, ctx, sessionHandle)
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** 自愈重开注记(§2.10):重开后第一条回执尾行注记——置位一次,发出即清。 */
|
|
469
|
+
function sealNote(receipt) {
|
|
470
|
+
if (!state.reopenNote || !state.cdp) return receipt
|
|
471
|
+
state.reopenNote = false
|
|
472
|
+
return `${receipt}\n${REOPEN_NOTE}`
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
async function runSerial(action, args, ctx, ctl) {
|
|
476
|
+
ctl.failIfAborted() // 排队期已到点 ⇒ 动作从未起执行(无展开)
|
|
477
|
+
try {
|
|
478
|
+
return sealNote(await dispatchAction(action, args, ctx))
|
|
479
|
+
} catch (e) {
|
|
480
|
+
if (e?.pageReceipt) return sealNote(await failureReceipt(e.message))
|
|
481
|
+
throw e
|
|
482
|
+
} finally {
|
|
483
|
+
if (state.cdp) armIdle() // 任何动作重置空闲计时(KD-8)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** 动作入口:串行队列 + 单时钟动作预算(§2.9——deadline 自入队起算;批并行调用按调用序排队)。
|
|
488
|
+
* `opts.signal`(批 browser-async-fix 最小增补——后台任务取消柄):abort ⇒ 控制器 `cancel()`(queued 丢队 ∥ running 展开);缺省 null 旧径零变。 */
|
|
489
|
+
export function runAction(action, args, ctx, { signal = null } = {}) {
|
|
490
|
+
return queue.enqueue(action, args, (ctl) => {
|
|
491
|
+
currentController = ctl
|
|
492
|
+
return runSerial(action, args, ctx, ctl).finally(() => { currentController = null })
|
|
493
|
+
}, signal)
|
|
494
|
+
}
|