@danceiny/gotry 0.0.1-rc.18 → 0.0.1-rc.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -16
- package/README.zh-CN.md +37 -15
- package/bin/gotry-bootstrap.js +236 -3
- package/bin/gotry-inner.js +66 -20
- package/bin/gotry-runtime-resolution.d.ts +1 -1
- package/bin/gotry-runtime-resolution.js +7 -8
- package/cordis.gotry-patch.yml +15 -5
- package/data/stations-12306-verify.json +524 -0
- package/dist/capabilities/agent-reach.js +4 -4
- package/dist/capabilities/channel-health.js +103 -0
- package/dist/capabilities/channel-registry.js +213 -0
- package/dist/capabilities/doctor.js +285 -0
- package/dist/capabilities/effect.js +178 -5
- package/dist/capabilities/flyai.js +13 -0
- package/dist/capabilities/session/adapters/ctrip-hotel.js +207 -0
- package/dist/capabilities/session/adapters/rail-12306.js +285 -0
- package/dist/capabilities/session/extension-bridge.js +14 -1
- package/dist/capabilities/session/extension-channel.js +4 -2
- package/dist/capabilities/session/extension-distribution.js +1 -1
- package/dist/capabilities/session-search.js +311 -0
- package/dist/capabilities/visa-policy.js +126 -0
- package/dist/scripts/agent-planning-turn-deadline-e2e.js +304 -0
- package/dist/scripts/agent-planning-turn-deadline-tests.js +281 -0
- package/dist/scripts/benchmark-environment-bridge-e2e.js +5 -10
- package/dist/scripts/benchmark-environment-bridge-tests.js +148 -142
- package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +26 -26
- package/dist/scripts/{booking-copilot-availability-policy-v2-tests.js → booking-copilot-availability-policy-tests.js} +115 -115
- package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +41 -1
- package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +75 -120
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +370 -61
- package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +2 -2
- package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +7 -3
- package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +15 -9
- package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +19 -11
- package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +16 -52
- package/dist/scripts/booking-copilot-runtime-proof-tests.js +3953 -155
- package/dist/scripts/booking-copilot-server-proof-tests.js +58 -23
- package/dist/scripts/booking-copilot-startup-proof-tests.js +10 -119
- package/dist/scripts/booking-surface-contract-proof-tests.js +1154 -326
- package/dist/scripts/bootstrap-tests.js +87 -2
- package/dist/scripts/build-metrics-report.js +339 -0
- package/dist/scripts/channel-probe-tests.js +148 -0
- package/dist/scripts/channel-probe.js +252 -0
- package/dist/scripts/channel-registry-tests.js +262 -0
- package/dist/scripts/doctor-tests.js +121 -0
- package/dist/scripts/effect-tests.js +274 -1
- package/dist/scripts/extension-tests.js +129 -11
- package/dist/scripts/fact-gate-tests.js +91 -2
- package/dist/scripts/flyai-tests.js +17 -1
- package/dist/scripts/hbcli-e2e-tests.js +1 -3
- package/dist/scripts/metrics-report-tests.js +288 -0
- package/dist/scripts/persona-surface-guard-tests.js +19 -0
- package/dist/scripts/session-tests.js +401 -1
- package/dist/scripts/smoke.js +100 -83
- package/dist/scripts/turn-handoff-collect-tests.js +216 -0
- package/dist/scripts/turn-handoff-collect.js +189 -0
- package/dist/scripts/turn-policy-tests.js +56 -0
- package/dist/scripts/typed-contract-canary.js +261 -0
- package/dist/scripts/visa-policy-tests.js +75 -0
- package/dist/scripts/wish-channel-gate-tests.js +111 -0
- package/dist/src/artifact-gate.js +91 -3
- package/dist/src/benchmark-agent-conformance.js +7 -4
- package/dist/src/benchmark-environment-bridge.js +68 -7
- package/dist/src/bookable-facts.js +62 -3
- package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
- package/dist/src/booking-surface/canonical-schema.js +8 -22
- package/dist/src/booking-surface/contracts.js +70 -19
- package/dist/src/booking-surface/dsh-planner.js +310 -177
- package/dist/src/booking-surface/dsh-plugin.js +3 -3
- package/dist/src/booking-surface/index.js +1 -5
- package/dist/src/booking-surface/profile.js +1 -1
- package/dist/src/booking-surface/runtime.js +1682 -247
- package/dist/src/booking-surface/server.js +371 -185
- package/dist/src/booking-surface/startup.js +17 -27
- package/dist/src/booking-surface/validation.js +270 -760
- package/dist/src/index.js +735 -149
- package/dist/src/turn-deadline.js +292 -0
- package/dist/src/turn-policy.js +146 -0
- package/dist/src/wish-pool.js +5 -0
- package/extension/background.js +23 -4
- package/extension/content-main.js +47 -9
- package/extension/manifest.json +27 -9
- package/package.json +6 -26
- package/schemas/booking.surface.schema.json +2593 -0
- package/ts/capabilities/agent-reach.ts +4 -4
- package/ts/capabilities/flyai.ts +21 -3
- package/ts/capabilities/session/action-cache.ts +1 -1
- package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
- package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
- package/ts/capabilities/session/extension-bridge.ts +32 -2
- package/ts/capabilities/session/extension-channel.ts +9 -8
- package/ts/capabilities/session/extension-distribution.ts +3 -1
- package/ts/capabilities/session/health-watch.ts +1 -1
- package/ts/capabilities/session/read-guard.ts +1 -1
- package/ts/capabilities/session/wizard.ts +1 -1
- package/ts/capabilities/session-search.ts +323 -1
- package/ts/package.json +1 -0
- package/ts/scripts/turn-handoff-collect.ts +178 -0
- package/ts/src/artifact-gate.ts +82 -2
- package/ts/src/benchmark-agent-conformance.ts +14 -6
- package/ts/src/benchmark-environment-bridge.ts +29 -10
- package/ts/src/bookable-facts.ts +109 -5
- package/ts/src/booking-saga.ts +1 -1
- package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
- package/ts/src/booking-surface/canonical-schema.js +8 -22
- package/ts/src/booking-surface/contracts.ts +278 -239
- package/ts/src/booking-surface/dsh-planner.ts +333 -194
- package/ts/src/booking-surface/dsh-plugin.js +3 -3
- package/ts/src/booking-surface/index.ts +1 -5
- package/ts/src/booking-surface/profile.ts +11 -11
- package/ts/src/booking-surface/runtime.ts +1351 -351
- package/ts/src/booking-surface/server.ts +282 -175
- package/ts/src/booking-surface/startup.ts +36 -48
- package/ts/src/booking-surface/validation.ts +194 -442
- package/ts/src/contracts.ts +1 -1
- package/ts/src/index.ts +489 -171
- package/ts/src/loop.ts +1 -1
- package/ts/src/state-ledger.ts +1 -1
- package/ts/src/turn-deadline.ts +361 -0
- package/ts/src/turn-policy.ts +154 -0
- package/ts/src/wish-pool.ts +17 -4
- package/dist/scripts/agent-planning-budget-e2e.js +0 -227
- package/dist/scripts/agent-planning-budget-tests.js +0 -173
- package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
- package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
- package/dist/src/booking-surface/contracts-v2.js +0 -89
- package/dist/src/booking-surface/runtime-v2.js +0 -1771
- package/dist/src/booking-surface/server-v2.js +0 -334
- package/dist/src/booking-surface/validation-v2.js +0 -319
- package/dist/src/tool-budget.js +0 -136
- package/schemas/booking.surface.v1.schema.json +0 -927
- package/schemas/booking.surface.v2.schema.json +0 -61
- package/ts/src/booking-surface/contracts-v2.ts +0 -118
- package/ts/src/booking-surface/runtime-v2.ts +0 -1466
- package/ts/src/booking-surface/server-v2.ts +0 -247
- package/ts/src/booking-surface/validation-v2.ts +0 -205
- package/ts/src/tool-budget.ts +0 -165
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 会话扩展桥(extension-bridge,RFC user-session-data-rfc.md §2.2 通道 C——2026-08-29 founder 定案为 PRIMARY 传输)。
|
|
2
|
+
* 会话扩展桥(extension-bridge,RFC docs/rfc/user-session-data-rfc.md §2.2 通道 C——2026-08-29 founder 定案为 PRIMARY 传输)。
|
|
3
3
|
*
|
|
4
4
|
* 形态:Node http 服务,懒启动单例,只绑 127.0.0.1,端口池 8791-8795(与 extension/manifest.json
|
|
5
5
|
* host_permissions 一一对应,防漂移测试守住);**零新依赖**(node:http 手写,对齐已退役 shell 薄壳
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
import { randomUUID } from 'node:crypto'
|
|
25
|
+
import { existsSync } from 'node:fs'
|
|
26
|
+
import { homedir } from 'node:os'
|
|
27
|
+
import { join } from 'node:path'
|
|
25
28
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
|
26
29
|
|
|
27
30
|
/** 桥端口池(extension/manifest.json host_permissions 必须同面;§38 断言) */
|
|
@@ -41,6 +44,32 @@ export const EXTENSION_ORIGIN_STORE = `chrome-extension://${EXTENSION_ID_STORE}`
|
|
|
41
44
|
export const EXTENSION_STORE_URL = `https://chromewebstore.google.com/detail/gotry-session-bridge/${EXTENSION_ID_STORE}`
|
|
42
45
|
/** 桥信任的扩展 Origin 白名单:unpacked(固定 key)+ 商店版,双通道同一扩展同源信任 */
|
|
43
46
|
export const EXTENSION_ORIGINS: readonly string[] = [EXTENSION_ORIGIN, EXTENSION_ORIGIN_STORE]
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 本地 unpacked 通道是否已落位(~/.gotry/extension/manifest.json)。
|
|
50
|
+
* 商店版没有本地文件面(装在 Chrome 里),离线不可探测——本函数只回答「本地通道在不在」,
|
|
51
|
+
* 商店版用户在这里恒 false,这正是 D-24 自适应文案的判定输入。
|
|
52
|
+
*/
|
|
53
|
+
export function localExtensionInstalled(home = homedir()): boolean {
|
|
54
|
+
return existsSync(join(home, '.gotry', 'extension', 'manifest.json'))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* needs-extension 自适应摘要(issue #117,D-24 残量):
|
|
59
|
+
* - 本地 unpacked 已落位 → 完整双通道指引(商店 + GitHub/开发者模式本地通道);
|
|
60
|
+
* - 未落位(商店版用户,或完全未装)→ 只推商店一键装,并给「已装商店版?打开 Chrome 即可」
|
|
61
|
+
* 提示——**不再出现开发者模式/本地通道文案**(已装商店版的用户不该再被指导 load unpacked)。
|
|
62
|
+
* 纯函数(默认 HOME 可注入)。
|
|
63
|
+
*/
|
|
64
|
+
export function needsExtensionSummary(opts: { localInstalled?: boolean; home?: string } = {}): string {
|
|
65
|
+
const local = opts.localInstalled ?? localExtensionInstalled(opts.home)
|
|
66
|
+
if (local) {
|
|
67
|
+
return 'GoTry Session Bridge 扩展未连接(未安装或已停用)。推荐 Chrome 应用商店一键装(自动更新) '
|
|
68
|
+
+ `${EXTENSION_STORE_URL} ;或 npx gotry setup 落位后 chrome://extensions 开发者模式「加载已解压的扩展程序」指向 ~/.gotry/extension`
|
|
69
|
+
}
|
|
70
|
+
return 'GoTry Session Bridge 扩展未连接。若你已从 Chrome 商店安装(自动更新):打开 Chrome 并确认扩展已启用即可自动连接,无需再装。'
|
|
71
|
+
+ `若尚未安装:应用商店一键装 ${EXTENSION_STORE_URL} (装完零弹窗,浏览器自己当安装器)`
|
|
72
|
+
}
|
|
44
73
|
/** 扩展在线判定:/health 心跳 ≤30s 一次 + 轮询重连,1.5 倍容差 */
|
|
45
74
|
export const EXTENSION_CONNECTED_WINDOW_MS = 45_000
|
|
46
75
|
/** /jobs 长轮询 hold 上限(必须 < MV3 SW 30s 存活窗口) */
|
|
@@ -292,7 +321,8 @@ export async function createSessionBridge(opts: SessionBridgeOptions = {}): Prom
|
|
|
292
321
|
settle({
|
|
293
322
|
ok: false,
|
|
294
323
|
reason: 'extension-not-connected',
|
|
295
|
-
|
|
324
|
+
// D-24 自适应文案(issue #117):按本地通道是否落位决定是否提及开发者模式
|
|
325
|
+
summary: needsExtensionSummary(),
|
|
296
326
|
})
|
|
297
327
|
}
|
|
298
328
|
}, 250)
|
|
@@ -10,15 +10,16 @@
|
|
|
10
10
|
* 检索页无回包不是车道失败:与 CDP 车道同语义(到点无 hints 命中 ⇒ miss,页标题命挑战 ⇒ challenged)。
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { getOrCreateSessionBridge,
|
|
13
|
+
import { getOrCreateSessionBridge, needsExtensionSummary, type SessionJobHandle } from './extension-bridge.ts'
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
/**
|
|
16
|
+
* needs-extension 用户门文案(D-24 自适应,issue #117):按本地通道是否落位自动跳过
|
|
17
|
+
* 开发者模式/本地通道指引——商店版用户只看到商店一键装与「已装即可」提示。
|
|
18
|
+
* 旧静态常量已退役:静态文案无法感知安装状态,正是 D-24 残量的病灶。
|
|
19
|
+
*/
|
|
20
|
+
export function needsExtensionHint(): string {
|
|
21
|
+
return needsExtensionSummary()
|
|
22
|
+
}
|
|
22
23
|
|
|
23
24
|
export type BridgeFailureKind = 'bridge-unavailable' | 'extension-not-connected' | 'timeout' | 'job-error'
|
|
24
25
|
|
|
@@ -104,7 +104,9 @@ export function parseDistManifest(raw: string): ExtensionDistManifest {
|
|
|
104
104
|
const tarballSha256 = obj.tarballSha256
|
|
105
105
|
const zip = obj.zip
|
|
106
106
|
const zipSha256 = obj.zipSha256
|
|
107
|
-
|
|
107
|
+
// Chrome manifest version 形态:1-4 段点分整数(扩展版本跟随 gotry 主版本
|
|
108
|
+
// 0.0.1-rc.N → 四段投影 0.0.1.N,展示名走 version_name 原样)
|
|
109
|
+
if (typeof version !== 'string' || !/^\d+(\.\d+){1,3}$/.test(version)) throw new Error('dist-manifest.version 缺失或非 1-4 段点分整数')
|
|
108
110
|
if (tarball !== DIST_ASSET_TARBALL) throw new Error(`dist-manifest.tarball 资产名漂移(期望 ${DIST_ASSET_TARBALL})`)
|
|
109
111
|
if (zip !== DIST_ASSET_STORE_ZIP) throw new Error(`dist-manifest.zip 资产名漂移(期望 ${DIST_ASSET_STORE_ZIP})`)
|
|
110
112
|
if (typeof tarballSha256 !== 'string' || !SHA256_RE.test(tarballSha256)) throw new Error('dist-manifest.tarballSha256 非 64-hex')
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 扩展健康检查 + 自动重放(issue #21 onboarding UX,P3.6 gotry-session-onboarding-goal,
|
|
3
|
-
* RFC user-session-data-rfc.md §3.3)。
|
|
3
|
+
* RFC docs/rfc/user-session-data-rfc.md §3.3)。
|
|
4
4
|
*
|
|
5
5
|
* 动机:首次 `sessionFlightSearch` 遇 `needs-extension` 时,旧版要求用户装完扩展后**自己重跑命令**;
|
|
6
6
|
* 本模块把「用户手工重跑」改成「后台有界轮询 + 扩展一就位自动重放同一 query_id」——
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* cdp(attach 日常 Chrome,Chrome 144+ 每连接弹权限框)降为显式后备
|
|
7
7
|
* (`GOTRY_SESSION_TRANSPORT=cdp` opt-in,诊断/测试用);persistent 仅测试。
|
|
8
8
|
*
|
|
9
|
-
* 证据链(L4 增补):[会话:ctrip-flight@ts] = 用户本人会话内实时检索,非官方 API;
|
|
9
|
+
* 证据链(L4 增补):[会话:ctrip-flight@ts] / [会话:ctrip-hotel@ts] = 用户本人会话内实时检索,非官方 API;
|
|
10
10
|
* 风控命中(verdict='challenged')= degraded,绝不重试、绝不绕过(合规支柱②)。
|
|
11
11
|
* 节律(§3.4):同站点 ≥30s 间隔 + 单调冷却;超间隔返回 verdict='cooldown'。
|
|
12
12
|
* 永不抛错;扩展车道 fail-closed(桥/扩展不可用即 verdict,零花费);测试/巡检用隔离 profile 与 stateRoot。
|
|
@@ -17,6 +17,8 @@ import { extensionCookieNames, extensionSearchJob, classifyBridgeFailure } from
|
|
|
17
17
|
import { appendFileSync, mkdirSync } from 'node:fs'
|
|
18
18
|
import { dirname } from 'node:path'
|
|
19
19
|
import { buildEntryUrl, NETWORK_HINTS, parseBatchSearch, LOGIN_COOKIE_NAMES, SITE_DOMAIN, type SessionFlightOption } from './session/adapters/ctrip-flight.ts'
|
|
20
|
+
import { buildHotelEntryUrl, parseCtripHotelList, HOTEL_SITE_HOST, type SessionHotelOption } from './session/adapters/ctrip-hotel.ts'
|
|
21
|
+
import { buildTrainEntryUrl, parseLeftTicketQuery, TRAIN_SITE_HOST, type SessionTrainOption } from './session/adapters/rail-12306.ts'
|
|
20
22
|
import { EXTENSION_STORE_URL } from './session/extension-bridge.ts'
|
|
21
23
|
|
|
22
24
|
export type SessionVerdict = 'hit' | 'miss' | 'error' | 'challenged' | 'cooldown' | 'needs-login' | 'needs-attach' | 'needs-extension'
|
|
@@ -109,6 +111,326 @@ export function appendExtensionAudit(auditPath: string | undefined, entry: { kin
|
|
|
109
111
|
} catch { /* 审计失败不阻塞检索 */ }
|
|
110
112
|
}
|
|
111
113
|
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// 酒店会话检索(2026-09-03 实装;与机票同构:节律闸/登录闸/needs-extension 映射/
|
|
116
|
+
// 挑战红线/证据链。酒店页与机票页同属 ctrip.com 风控域,但通道键独立——
|
|
117
|
+
// 各自 30s 预算已足够礼貌,跨通道合并预算留首个真会话风控观测后校准)
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
export interface SessionHotelQuery {
|
|
121
|
+
/** 目的地(中文);城市码表未收录时须带 cityId */
|
|
122
|
+
to: string
|
|
123
|
+
/** 显式城市 id(携程/trip.com 酒店 list 页 URL 的 city= 数字;覆盖码表) */
|
|
124
|
+
cityId?: number | string
|
|
125
|
+
/** YYYY-MM-DD */
|
|
126
|
+
checkIn?: string
|
|
127
|
+
/** YYYY-MM-DD(与 checkIn 成对) */
|
|
128
|
+
checkOut?: string
|
|
129
|
+
adults?: number
|
|
130
|
+
/** 隔离 profile 目录(测试必传;默认 /tmp 专用目录) */
|
|
131
|
+
profileDir?: string
|
|
132
|
+
headless?: boolean
|
|
133
|
+
/** ReadGuard 审计路径(测试传隔离 stateRoot 下) */
|
|
134
|
+
auditPath?: string
|
|
135
|
+
/** 等嗅探回包的上限,默认 30_000(酒店列表接口比机票慢) */
|
|
136
|
+
timeoutMs?: number
|
|
137
|
+
/** 允许匿名实例(默认 false);true 仅适配器链路自检,证据链标 anonymous */
|
|
138
|
+
allowAnonymous?: boolean
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface SessionHotelResult {
|
|
142
|
+
ok: boolean
|
|
143
|
+
via: 'session-ctrip-hotel' | 'session-ctrip-hotel-error'
|
|
144
|
+
evidence: string
|
|
145
|
+
latencyMs: number
|
|
146
|
+
verdict: SessionVerdict
|
|
147
|
+
hotels?: SessionHotelOption[]
|
|
148
|
+
error?: string
|
|
149
|
+
/** needs-extension 时给出 Chrome Web Store URL(dsh UI 渲成可点链接) */
|
|
150
|
+
installUrl?: string
|
|
151
|
+
installAction?: 'add-to-chrome'
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 酒店城市码表未收录时的人话指引(纯函数,测试锚点) */
|
|
155
|
+
export function hotelCityUnresolvedHint(cities: string[]): string {
|
|
156
|
+
return `城市 ${cities.join('/')} 不在携程酒店城市码表——先 web 搜「hotels.ctrip.com ${cities[0] ?? ''} 酒店」拿到 list 页 URL 里的 city= 数字,带 cityId 重试`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function sessionHotelSearch(q: SessionHotelQuery): Promise<SessionHotelResult> {
|
|
160
|
+
const started = Date.now()
|
|
161
|
+
const ts = new Date().toISOString()
|
|
162
|
+
const site = 'ctrip-hotel'
|
|
163
|
+
const err = (verdict: SessionVerdict, error: string): SessionHotelResult => ({
|
|
164
|
+
ok: false, via: 'session-ctrip-hotel-error', evidence: `[会话:${site}@error@${ts}] ${error}`, latencyMs: Date.now() - started, verdict, error,
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// 日期对闸(与 flyai hotel 同口径):成对且 YYYY-MM-DD;过去日期不发上游
|
|
168
|
+
if ((q.checkIn ? 1 : 0) !== (q.checkOut ? 1 : 0) || (q.checkIn && !/^\d{4}-\d{2}-\d{2}$/.test(q.checkIn))) {
|
|
169
|
+
return err('error', 'checkIn/checkOut 须成对且为 YYYY-MM-DD(未定档期可不带日期)')
|
|
170
|
+
}
|
|
171
|
+
if (q.checkIn) {
|
|
172
|
+
const now = new Date()
|
|
173
|
+
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
|
174
|
+
if (q.checkIn < today || (q.checkOut ?? '') < q.checkIn) {
|
|
175
|
+
return err('error', `入住 ${q.checkIn}/退房 ${q.checkOut ?? ''} 不是未来合法区间(今天 ${today})——向用户确认日期后再查`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 节律闸:超间隔即拒,不发起导航
|
|
180
|
+
const last = lastCallAt.get(site) ?? 0
|
|
181
|
+
if (Date.now() - last < MIN_INTERVAL_MS) {
|
|
182
|
+
return err('cooldown', `rate limit: last call ${Date.now() - last}ms ago, min ${MIN_INTERVAL_MS}ms`)
|
|
183
|
+
}
|
|
184
|
+
lastCallAt.set(site, Date.now())
|
|
185
|
+
|
|
186
|
+
const entry = buildHotelEntryUrl({ to: q.to, cityId: q.cityId, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults })
|
|
187
|
+
if (!entry.ok || !entry.url) {
|
|
188
|
+
return err('error', hotelCityUnresolvedHint(entry.unresolved ?? [q.to]))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const mode = resolveTransportMode(q.profileDir)
|
|
192
|
+
|
|
193
|
+
if (mode === 'extension') {
|
|
194
|
+
// ① 登录态快查(与机票同账号体系,票据 cookie 名一致)
|
|
195
|
+
const login = await extensionCookieNames({ site, domain: SITE_DOMAIN.replace(/^\./, ''), ticketNames: LOGIN_COOKIE_NAMES })
|
|
196
|
+
if (!login.ok) {
|
|
197
|
+
const verdict = classifyBridgeFailure(login.kind)
|
|
198
|
+
if (verdict === 'needs-extension') {
|
|
199
|
+
return { ok: false, via: 'session-ctrip-hotel-error', evidence: '[会话:ctrip-hotel-needs-extension@ts]', latencyMs: Date.now() - started, verdict, error: login.summary, installUrl: EXTENSION_STORE_URL, installAction: 'add-to-chrome' as const }
|
|
200
|
+
}
|
|
201
|
+
return err(verdict, login.summary)
|
|
202
|
+
}
|
|
203
|
+
if (login.tickets.length === 0 && !q.allowAnonymous) {
|
|
204
|
+
return err('needs-login', '未检出你本人登录态——调用 gotry_session_login 为用户打开携程登录入口(登录在携程官网完成;gotry 永不经手密码/验证码/cookie 值)')
|
|
205
|
+
}
|
|
206
|
+
// ② 检索 job:后台标签 + 被动嗅探(URL hint + 形状兜底;扩展零写行为)
|
|
207
|
+
const r = await extensionSearchJob({ site, url: entry.url, timeoutMs: q.timeoutMs })
|
|
208
|
+
appendExtensionAudit(q.auditPath, {
|
|
209
|
+
kind: 'extension-session-job', site, url: entry.url, jobId: 'search',
|
|
210
|
+
result: r.ok ? (r.timedOut ? 'timeout' : `body ${r.body.length}B title="${r.title.slice(0, 60)}"`) : `${r.kind}:${r.summary.slice(0, 120)}`,
|
|
211
|
+
})
|
|
212
|
+
if (!r.ok) {
|
|
213
|
+
const verdict = classifyBridgeFailure(r.kind)
|
|
214
|
+
if (verdict === 'needs-extension') {
|
|
215
|
+
return { ok: false, via: 'session-ctrip-hotel-error', evidence: '[会话:ctrip-hotel-needs-extension@ts]', latencyMs: Date.now() - started, verdict, error: r.summary, installUrl: EXTENSION_STORE_URL, installAction: 'add-to-chrome' as const }
|
|
216
|
+
}
|
|
217
|
+
return err(verdict, r.summary)
|
|
218
|
+
}
|
|
219
|
+
const title = r.title
|
|
220
|
+
const head = r.body.slice(0, 5000)
|
|
221
|
+
if (CHALLENGE_RE.test(title + head)) {
|
|
222
|
+
return err('challenged', `风控/验证码命中(title=${title.slice(0, 60)});按红线不重试不绕过,交还用户`)
|
|
223
|
+
}
|
|
224
|
+
const hotels = parseCtripHotelList(r.body)
|
|
225
|
+
const verdict: SessionVerdict = hotels.length > 0 ? 'hit' : 'miss'
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
via: 'session-ctrip-hotel',
|
|
229
|
+
evidence: `[会话:${site}@${ts}] ${hotels.length} hotels;transport=extension(被动嗅探,零系统弹窗;扩展零写行为=物理只读)${q.allowAnonymous ? ';anonymous=自检态' : ''}`,
|
|
230
|
+
latencyMs: Date.now() - started,
|
|
231
|
+
verdict,
|
|
232
|
+
hotels,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// cdp/persistent 车道:与机票同构——挂监听等 NETWORK hint 回包(酒店用 HOTEL hints + 形状兜底)
|
|
237
|
+
const t = await openSession({ profileDir: q.profileDir, headless: q.headless, auditPath: q.auditPath, mode: mode === 'persistent' ? 'persistent' : 'cdp', newPage: true })
|
|
238
|
+
if (!t.ok) {
|
|
239
|
+
return err(classifyTransportFailure(t.summary, q.profileDir === undefined), t.summary)
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
const loggedIn = async (): Promise<boolean> => {
|
|
243
|
+
const cookies = await t.browser.cookies().catch(() => [])
|
|
244
|
+
return cookies.some((c) => c.domain.includes(SITE_DOMAIN.replace(/^\./, '')) && LOGIN_COOKIE_NAMES.includes(c.name))
|
|
245
|
+
}
|
|
246
|
+
if (!(await loggedIn()) && !q.allowAnonymous) {
|
|
247
|
+
return err('needs-login', '未检出你本人登录态——调用 gotry_session_login 为用户打开携程登录入口(登录在携程官网完成;gotry 永不经手密码/验证码/cookie 值)')
|
|
248
|
+
}
|
|
249
|
+
let settled = false
|
|
250
|
+
let body = ''
|
|
251
|
+
const heard = new Promise<void>((resolve) => {
|
|
252
|
+
t.page.on('response', async (res) => {
|
|
253
|
+
if (settled) return
|
|
254
|
+
const u = res.url()
|
|
255
|
+
const hostHit = u.includes(HOTEL_SITE_HOST)
|
|
256
|
+
if (!hostHit) return
|
|
257
|
+
try {
|
|
258
|
+
const text = await res.text()
|
|
259
|
+
if (text && (hostHit && /hotels\.ctrip\.com\/(hotels\/api|domestic\/pc\/api)|GetHotelListBySOA|GetHotelListByCity|HotelSearch|hotelsearch/i.test(u) || text.length <= 2_000_000 && /"hotelList"|"hotelMatchInfos"|"hotelName"/.test(text))) {
|
|
260
|
+
body = text
|
|
261
|
+
settled = true
|
|
262
|
+
resolve()
|
|
263
|
+
}
|
|
264
|
+
} catch { /* 流式/竞态不可读则继续等下一个 */ }
|
|
265
|
+
})
|
|
266
|
+
setTimeout(() => resolve(), q.timeoutMs ?? 30_000)
|
|
267
|
+
})
|
|
268
|
+
await t.page.goto(entry.url, { waitUntil: 'domcontentloaded', timeout: 30_000 })
|
|
269
|
+
await heard
|
|
270
|
+
const title = await t.page.title().catch(() => '')
|
|
271
|
+
const headHtml = (await t.page.content().catch(() => '')).slice(0, 5000)
|
|
272
|
+
if (CHALLENGE_RE.test(title + headHtml)) {
|
|
273
|
+
return err('challenged', `风控/验证码命中(title=${title.slice(0, 60)});按红线不重试不绕过,交还用户`)
|
|
274
|
+
}
|
|
275
|
+
const hotels = parseCtripHotelList(body)
|
|
276
|
+
const verdict: SessionVerdict = hotels.length > 0 ? 'hit' : 'miss'
|
|
277
|
+
return {
|
|
278
|
+
ok: true,
|
|
279
|
+
via: 'session-ctrip-hotel',
|
|
280
|
+
evidence: `[会话:${site}@${ts}] ${hotels.length} hotels;guard blocked=${t.guard.blockedCount()}/${t.guard.requestCount()}${q.allowAnonymous ? ';anonymous=自检态' : ''}`,
|
|
281
|
+
latencyMs: Date.now() - started,
|
|
282
|
+
verdict,
|
|
283
|
+
hotels,
|
|
284
|
+
}
|
|
285
|
+
} catch (e) {
|
|
286
|
+
return err('error', e instanceof Error ? e.message.slice(0, 200) : String(e))
|
|
287
|
+
} finally {
|
|
288
|
+
await t.close()
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// 火车会话检索(2026-09-03 实装;12306 余票查询是公开面——登录只关系下单,
|
|
295
|
+
// 无账号数据过手,故无登录闸;扩展照样只读被动嗅探,证据链标注「公开查询面」)
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
export interface SessionTrainQuery {
|
|
299
|
+
from: string
|
|
300
|
+
to: string
|
|
301
|
+
/** YYYY-MM-DD */
|
|
302
|
+
date: string
|
|
303
|
+
/** 显式城市电报码(覆盖码表;kyfw 查询页 URL fs=城市,XXX 的三位码) */
|
|
304
|
+
fromStationTelecode?: string
|
|
305
|
+
toStationTelecode?: string
|
|
306
|
+
/** 隔离 profile 目录(测试必传;默认 /tmp 专用目录) */
|
|
307
|
+
profileDir?: string
|
|
308
|
+
headless?: boolean
|
|
309
|
+
/** ReadGuard 审计路径(测试传隔离 stateRoot 下) */
|
|
310
|
+
auditPath?: string
|
|
311
|
+
/** 等嗅探回包的上限,默认 25_000 */
|
|
312
|
+
timeoutMs?: number
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface SessionTrainResult {
|
|
316
|
+
ok: boolean
|
|
317
|
+
via: 'session-train-12306' | 'session-train-12306-error'
|
|
318
|
+
evidence: string
|
|
319
|
+
latencyMs: number
|
|
320
|
+
verdict: SessionVerdict
|
|
321
|
+
trains?: SessionTrainOption[]
|
|
322
|
+
error?: string
|
|
323
|
+
/** needs-extension 时给出 Chrome Web Store URL(dsh UI 渲成可点链接) */
|
|
324
|
+
installUrl?: string
|
|
325
|
+
installAction?: 'add-to-chrome'
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** 车站电报码表未收录时的人话指引(纯函数,测试锚点) */
|
|
329
|
+
export function trainStationUnresolvedHint(cities: string[]): string {
|
|
330
|
+
return `城市 ${cities.join('/')} 不在 12306 城市电报码表——web 搜「12306 ${cities[0] ?? ''} 余票」拿到 kyfw 查询页 URL 里的 fs=城市,XXX 三位码,带 fromStationTelecode/toStationTelecode 重试`
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export async function sessionTrainSearch(q: SessionTrainQuery): Promise<SessionTrainResult> {
|
|
334
|
+
const started = Date.now()
|
|
335
|
+
const ts = new Date().toISOString()
|
|
336
|
+
const site = 'train-12306'
|
|
337
|
+
const err = (verdict: SessionVerdict, error: string): SessionTrainResult => ({
|
|
338
|
+
ok: false, via: 'session-train-12306-error', evidence: `[会话:${site}@error@${ts}] ${error}`, latencyMs: Date.now() - started, verdict, error,
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
// 节律闸:超间隔即拒,不发起导航
|
|
342
|
+
const last = lastCallAt.get(site) ?? 0
|
|
343
|
+
if (Date.now() - last < MIN_INTERVAL_MS) {
|
|
344
|
+
return err('cooldown', `rate limit: last call ${Date.now() - last}ms ago, min ${MIN_INTERVAL_MS}ms`)
|
|
345
|
+
}
|
|
346
|
+
lastCallAt.set(site, Date.now())
|
|
347
|
+
|
|
348
|
+
const entry = buildTrainEntryUrl({ from: q.from, to: q.to, date: q.date, fromStationTelecode: q.fromStationTelecode, toStationTelecode: q.toStationTelecode })
|
|
349
|
+
if (!entry.ok || !entry.url) {
|
|
350
|
+
return err('error', trainStationUnresolvedHint(entry.unresolved ?? [q.from, q.to]))
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const mode = resolveTransportMode(q.profileDir)
|
|
354
|
+
|
|
355
|
+
if (mode === 'extension') {
|
|
356
|
+
// 无登录闸(公开查询面):直接发起检索 job
|
|
357
|
+
const r = await extensionSearchJob({ site, url: entry.url, timeoutMs: q.timeoutMs })
|
|
358
|
+
appendExtensionAudit(q.auditPath, {
|
|
359
|
+
kind: 'extension-session-job', site, url: entry.url, jobId: 'search',
|
|
360
|
+
result: r.ok ? (r.timedOut ? 'timeout' : `body ${r.body.length}B title="${r.title.slice(0, 60)}"`) : `${r.kind}:${r.summary.slice(0, 120)}`,
|
|
361
|
+
})
|
|
362
|
+
if (!r.ok) {
|
|
363
|
+
const verdict = classifyBridgeFailure(r.kind)
|
|
364
|
+
if (verdict === 'needs-extension') {
|
|
365
|
+
return { ok: false, via: 'session-train-12306-error', evidence: '[会话:train-12306-needs-extension@ts]', latencyMs: Date.now() - started, verdict, error: r.summary, installUrl: EXTENSION_STORE_URL, installAction: 'add-to-chrome' as const }
|
|
366
|
+
}
|
|
367
|
+
return err(verdict, r.summary)
|
|
368
|
+
}
|
|
369
|
+
const title = r.title
|
|
370
|
+
const head = r.body.slice(0, 5000)
|
|
371
|
+
if (CHALLENGE_RE.test(title + head)) {
|
|
372
|
+
return err('challenged', `风控/验证码命中(title=${title.slice(0, 60)});按红线不重试不绕过,交还用户`)
|
|
373
|
+
}
|
|
374
|
+
const trains = parseLeftTicketQuery(r.body, entry.url)
|
|
375
|
+
const verdict: SessionVerdict = trains.length > 0 ? 'hit' : 'miss'
|
|
376
|
+
return {
|
|
377
|
+
ok: true,
|
|
378
|
+
via: 'session-train-12306',
|
|
379
|
+
evidence: `[会话:${site}@${ts}] ${trains.length} trains;transport=extension(公开查询面,被动嗅探,零系统弹窗;扩展零写行为=物理只读)`,
|
|
380
|
+
latencyMs: Date.now() - started,
|
|
381
|
+
verdict,
|
|
382
|
+
trains,
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// cdp/persistent 车道:与机/酒同构
|
|
387
|
+
const t = await openSession({ profileDir: q.profileDir, headless: q.headless, auditPath: q.auditPath, mode: mode === 'persistent' ? 'persistent' : 'cdp', newPage: true })
|
|
388
|
+
if (!t.ok) {
|
|
389
|
+
return err(classifyTransportFailure(t.summary, q.profileDir === undefined), t.summary)
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
let settled = false
|
|
393
|
+
let body = ''
|
|
394
|
+
const heard = new Promise<void>((resolve) => {
|
|
395
|
+
t.page.on('response', async (res) => {
|
|
396
|
+
if (settled) return
|
|
397
|
+
const u = res.url()
|
|
398
|
+
if (!/leftTicket\/query/i.test(u)) return
|
|
399
|
+
try {
|
|
400
|
+
const text = await res.text()
|
|
401
|
+
if (text) {
|
|
402
|
+
body = text
|
|
403
|
+
settled = true
|
|
404
|
+
resolve()
|
|
405
|
+
}
|
|
406
|
+
} catch { /* 流式/竞态不可读则继续等下一个 */ }
|
|
407
|
+
})
|
|
408
|
+
setTimeout(() => resolve(), q.timeoutMs ?? 25_000)
|
|
409
|
+
})
|
|
410
|
+
await t.page.goto(entry.url, { waitUntil: 'domcontentloaded', timeout: 30_000 })
|
|
411
|
+
await heard
|
|
412
|
+
const title = await t.page.title().catch(() => '')
|
|
413
|
+
const headHtml = (await t.page.content().catch(() => '')).slice(0, 5000)
|
|
414
|
+
if (CHALLENGE_RE.test(title + headHtml)) {
|
|
415
|
+
return err('challenged', `风控/验证码命中(title=${title.slice(0, 60)});按红线不重试不绕过,交还用户`)
|
|
416
|
+
}
|
|
417
|
+
const trains = parseLeftTicketQuery(body, entry.url)
|
|
418
|
+
const verdict: SessionVerdict = trains.length > 0 ? 'hit' : 'miss'
|
|
419
|
+
return {
|
|
420
|
+
ok: true,
|
|
421
|
+
via: 'session-train-12306',
|
|
422
|
+
evidence: `[会话:${site}@${ts}] ${trains.length} trains(公开查询面);guard blocked=${t.guard.blockedCount()}/${t.guard.requestCount()}`,
|
|
423
|
+
latencyMs: Date.now() - started,
|
|
424
|
+
verdict,
|
|
425
|
+
trains,
|
|
426
|
+
}
|
|
427
|
+
} catch (e) {
|
|
428
|
+
return err('error', e instanceof Error ? e.message.slice(0, 200) : String(e))
|
|
429
|
+
} finally {
|
|
430
|
+
await t.close()
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
112
434
|
export async function sessionFlightSearch(q: SessionFlightQuery): Promise<SessionSearchResult> {
|
|
113
435
|
const started = Date.now()
|
|
114
436
|
const ts = new Date().toISOString()
|
package/ts/package.json
CHANGED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 后台深度规划工单收集器(ADR-24 v2 收集闭环;async-collect 的 turn-handoff 对位)。
|
|
3
|
+
*
|
|
4
|
+
* 「一小时后回来看看」的后台半段:加载 open 工单 → 以 GOTRY_HANDOFF_CHILD=1
|
|
5
|
+
* 派生 headless 规划会话(唯一出口 converge,不回递归)→ 捕获最终答复为
|
|
6
|
+
* 交付物 → settleTurnHandoffTicket 结算(交付物 .deliverable.md + 工单状态
|
|
7
|
+
* 原子更新)。终态幂等:已 settled/failed 的工单复诵交付物与终态 JSON,零
|
|
8
|
+
* 重算、零再花。最后一行固定输出 gotry_turn_handoff_terminal.v1 JSON;
|
|
9
|
+
* succeeded exit 0,failed exit 2。
|
|
10
|
+
*
|
|
11
|
+
* 运行(在 ts/ 下):
|
|
12
|
+
* npx tsx scripts/turn-handoff-collect.ts <ticketId> [stateRoot]
|
|
13
|
+
* npx tsx scripts/turn-handoff-collect.ts --all [stateRoot]
|
|
14
|
+
* 环境变量:
|
|
15
|
+
* GOTRY_HANDOFF_PLANNER_BIN 规划器二进制(默认 <repo>/bin/gotry-inner.js)
|
|
16
|
+
* GOTRY_HANDOFF_PLANNER_TIMEOUT_MS 规划器硬上限(默认 900_000)
|
|
17
|
+
*
|
|
18
|
+
* v1 诚实边界:工单只携带用户原文,规划会话是全新上下文(fresh cwd/DSH_HOME)
|
|
19
|
+
* ——原会话中已读的工作区文件/日历结论不随单迁移,由规划器按需重取。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { spawn } from 'node:child_process'
|
|
23
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
24
|
+
import { tmpdir } from 'node:os'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
import {
|
|
27
|
+
listTurnHandoffTickets,
|
|
28
|
+
settleTurnHandoffTicket,
|
|
29
|
+
TURN_HANDOFF_SCHEMA,
|
|
30
|
+
type TurnHandoffTicket,
|
|
31
|
+
} from '../src/turn-deadline.ts'
|
|
32
|
+
|
|
33
|
+
const TURN_HANDOFF_TERMINAL_SCHEMA = 'gotry_turn_handoff_terminal.v1' as const
|
|
34
|
+
const ROOT = join(import.meta.dirname, '..', '..')
|
|
35
|
+
|
|
36
|
+
interface TerminalOutcome {
|
|
37
|
+
schema: typeof TURN_HANDOFF_TERMINAL_SCHEMA
|
|
38
|
+
ticket_id: string
|
|
39
|
+
status: 'succeeded' | 'failed'
|
|
40
|
+
deliverable_path?: string
|
|
41
|
+
error?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function emitTerminal(outcome: TerminalOutcome): never {
|
|
45
|
+
console.log(JSON.stringify(outcome))
|
|
46
|
+
process.exit(outcome.status === 'succeeded' ? 0 : 2)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function loadTicket(stateRoot: string, ticketId: string): TurnHandoffTicket | null {
|
|
50
|
+
try {
|
|
51
|
+
const path = join(stateRoot === '.' ? process.cwd() : stateRoot, 'gotry-state', 'turn-handoffs', `${ticketId}.json`)
|
|
52
|
+
const ticket = JSON.parse(readFileSync(path, 'utf-8')) as TurnHandoffTicket
|
|
53
|
+
return ticket?.schema === TURN_HANDOFF_SCHEMA ? ticket : null
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function replay(ticket: TurnHandoffTicket, stateRoot: string): never {
|
|
60
|
+
const dir = join(stateRoot === '.' ? process.cwd() : stateRoot, 'gotry-state', 'turn-handoffs')
|
|
61
|
+
emitTerminal({
|
|
62
|
+
schema: TURN_HANDOFF_TERMINAL_SCHEMA,
|
|
63
|
+
ticket_id: ticket.id,
|
|
64
|
+
status: ticket.status === 'settled' ? 'succeeded' : 'failed',
|
|
65
|
+
...(ticket.deliverableFile ? { deliverable_path: join(dir, ticket.deliverableFile) } : {}),
|
|
66
|
+
...(ticket.error ? { error: ticket.error } : {}),
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function runPlanner(ticket: TurnHandoffTicket): Promise<{ ok: boolean; output: string; error?: string }> {
|
|
71
|
+
const plannerBin = process.env.GOTRY_HANDOFF_PLANNER_BIN ?? join(ROOT, 'bin', 'gotry-inner.js')
|
|
72
|
+
if (!existsSync(plannerBin)) {
|
|
73
|
+
return { ok: false, output: '', error: `planner binary unavailable: ${plannerBin}` }
|
|
74
|
+
}
|
|
75
|
+
const timeoutMs = Number.parseInt(process.env.GOTRY_HANDOFF_PLANNER_TIMEOUT_MS ?? '', 10) || 900_000
|
|
76
|
+
const dshHome = mkdtempSync(join(tmpdir(), 'gotry-handoff-child-dsh-'))
|
|
77
|
+
const childCwd = mkdtempSync(join(tmpdir(), 'gotry-handoff-child-cwd-'))
|
|
78
|
+
try {
|
|
79
|
+
const childEnv: NodeJS.ProcessEnv = { ...process.env }
|
|
80
|
+
childEnv.DSH_HOME = dshHome
|
|
81
|
+
// 递归防护(核心):子会话唯一出口 converge + 长 leash;同时清掉父进程的
|
|
82
|
+
// 数值 pin 与工单根,子会话不得再 handoff、也不受测试阈值影响。
|
|
83
|
+
childEnv.GOTRY_HANDOFF_CHILD = '1'
|
|
84
|
+
delete childEnv.GOTRY_TURN_DEADLINE_SOFT_MS
|
|
85
|
+
delete childEnv.GOTRY_TURN_DEADLINE_HARD_MS
|
|
86
|
+
delete childEnv.GOTRY_TURN_HANDOFF_ROOT
|
|
87
|
+
// .js/.mjs/.cjs 经当前 node 派生(测试假 planner 无执行位也成立);
|
|
88
|
+
// 其余(gotry-inner.js 之外的 .bin shim、全局命令)直接执行。
|
|
89
|
+
const isJsScript = /\.(js|mjs|cjs)$/.test(plannerBin)
|
|
90
|
+
const child = isJsScript
|
|
91
|
+
? spawn(process.execPath, [plannerBin, ticket.userMessage], { cwd: childCwd, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
92
|
+
: spawn(plannerBin, [ticket.userMessage], { cwd: childCwd, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
93
|
+
let output = ''
|
|
94
|
+
child.stdout.on('data', chunk => { output += chunk.toString() })
|
|
95
|
+
child.stderr.on('data', chunk => { output += chunk.toString() })
|
|
96
|
+
const code = await new Promise<number | null>(resolve => {
|
|
97
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); resolve(null) }, timeoutMs)
|
|
98
|
+
child.once('exit', c => { clearTimeout(timer); resolve(c) })
|
|
99
|
+
})
|
|
100
|
+
if (code === null) return { ok: false, output: '', error: `planner timed out after ${timeoutMs}ms` }
|
|
101
|
+
if (code !== 0) return { ok: false, output: '', error: `planner exited ${code}: ${output.trim().slice(-300)}` }
|
|
102
|
+
const deliverable = output.trim()
|
|
103
|
+
if (!deliverable) return { ok: false, output: '', error: 'planner produced empty output' }
|
|
104
|
+
return { ok: true, output: deliverable }
|
|
105
|
+
} finally {
|
|
106
|
+
await new Promise<void>(resolve => setTimeout(resolve, 200))
|
|
107
|
+
rmSync(dshHome, { recursive: true, force: true })
|
|
108
|
+
rmSync(childCwd, { recursive: true, force: true })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function collectOne(stateRoot: string, ticketId: string): Promise<void> {
|
|
113
|
+
const ticket = loadTicket(stateRoot, ticketId)
|
|
114
|
+
if (!ticket) {
|
|
115
|
+
console.error(`工单 ${ticketId} 不存在(${stateRoot}/gotry-state/turn-handoffs/)`)
|
|
116
|
+
process.exit(1)
|
|
117
|
+
}
|
|
118
|
+
if (ticket.status !== 'open') {
|
|
119
|
+
console.error(`工单 ${ticketId} 已是终态 ${ticket.status},复诵不重算:`)
|
|
120
|
+
replay(ticket, stateRoot)
|
|
121
|
+
}
|
|
122
|
+
const result = await runPlanner(ticket)
|
|
123
|
+
const deliverable = result.ok
|
|
124
|
+
? `# 后台深度规划交付:${ticket.objective.slice(0, 60)}\n(工单 ${ticket.id},原请求 ${ticket.requestedAt})\n\n${result.output}`
|
|
125
|
+
: `# 后台深度规划失败:${ticket.objective.slice(0, 60)}\n(工单 ${ticket.id},原请求 ${ticket.requestedAt})\n\n未产出交付物:${result.error ?? '未知原因'}。这趟规划没有完成——请重新发起,失败原因如上,无部分结果可交付。`
|
|
126
|
+
const { deliverablePath } = await settleTurnHandoffTicket(
|
|
127
|
+
stateRoot, ticket, result.ok ? 'settled' : 'failed', deliverable, result.ok ? undefined : result.error,
|
|
128
|
+
)
|
|
129
|
+
console.error(`工单 ${ticket.id} ${result.ok ? '已交付' : '失败'}:${deliverablePath}`)
|
|
130
|
+
emitTerminal({
|
|
131
|
+
schema: TURN_HANDOFF_TERMINAL_SCHEMA,
|
|
132
|
+
ticket_id: ticket.id,
|
|
133
|
+
status: result.ok ? 'succeeded' : 'failed',
|
|
134
|
+
...(result.ok
|
|
135
|
+
? { deliverable_path: deliverablePath }
|
|
136
|
+
: { error: result.error ?? 'unknown' }),
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function collectAll(stateRoot: string): Promise<void> {
|
|
141
|
+
const open = (await listTurnHandoffTickets(stateRoot)).filter(t => t.status === 'open')
|
|
142
|
+
if (open.length === 0) {
|
|
143
|
+
console.error('无待回收的后台规划工单(open=0)')
|
|
144
|
+
process.exit(0)
|
|
145
|
+
}
|
|
146
|
+
let failed = 0
|
|
147
|
+
for (const view of open) {
|
|
148
|
+
const ticket = loadTicket(stateRoot, view.id)
|
|
149
|
+
if (!ticket || ticket.status !== 'open') continue
|
|
150
|
+
const result = await runPlanner(ticket)
|
|
151
|
+
const deliverable = result.ok
|
|
152
|
+
? `# 后台深度规划交付:${ticket.objective.slice(0, 60)}\n(工单 ${ticket.id},原请求 ${ticket.requestedAt})\n\n${result.output}`
|
|
153
|
+
: `# 后台深度规划失败:${ticket.objective.slice(0, 60)}\n(工单 ${ticket.id},原请求 ${ticket.requestedAt})\n\n未产出交付物:${result.error ?? '未知原因'}。这趟规划没有完成——请重新发起,失败原因如上,无部分结果可交付。`
|
|
154
|
+
const { deliverablePath } = await settleTurnHandoffTicket(
|
|
155
|
+
stateRoot, ticket, result.ok ? 'settled' : 'failed', deliverable, result.ok ? undefined : result.error,
|
|
156
|
+
)
|
|
157
|
+
console.error(`工单 ${ticket.id} ${result.ok ? '已交付' : '失败'}:${deliverablePath}`)
|
|
158
|
+
if (!result.ok) failed += 1
|
|
159
|
+
}
|
|
160
|
+
console.log(JSON.stringify({
|
|
161
|
+
schema: TURN_HANDOFF_TERMINAL_SCHEMA,
|
|
162
|
+
ticket_id: `--all(${open.length})`,
|
|
163
|
+
status: failed === 0 ? 'succeeded' : 'failed',
|
|
164
|
+
error: failed === 0 ? undefined : `${failed}/${open.length} 张工单回收失败`,
|
|
165
|
+
}))
|
|
166
|
+
process.exit(failed === 0 ? 0 : 2)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const [target, stateRootArg] = process.argv.slice(2)
|
|
170
|
+
const stateRoot = stateRootArg ?? '.'
|
|
171
|
+
if (target === '--all') {
|
|
172
|
+
await collectAll(stateRoot)
|
|
173
|
+
} else if (target) {
|
|
174
|
+
await collectOne(stateRoot, target)
|
|
175
|
+
} else {
|
|
176
|
+
console.error('用法:npx tsx scripts/turn-handoff-collect.ts <ticketId|--all> [stateRoot]')
|
|
177
|
+
process.exit(1)
|
|
178
|
+
}
|