@danceiny/gotry 0.0.1-rc.15 → 0.0.1-rc.17
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 +51 -13
- package/README.zh-CN.md +26 -12
- package/bin/gotry-booking-copilot.js +53 -0
- package/bin/gotry-bootstrap.js +357 -8
- package/bin/gotry-inner.js +442 -60
- package/bin/gotry-runtime-resolution.d.ts +27 -0
- package/bin/gotry-runtime-resolution.js +50 -0
- package/bin/gotry.js +1 -1
- package/cordis.gotry-patch.yml +15 -1
- package/data/airline-airports.json +39 -0
- package/dist/capabilities/agent-reach-deep.js +1 -1
- package/dist/capabilities/agent-reach.js +1 -1
- package/dist/capabilities/anything.js +1 -1
- package/dist/capabilities/artifacts.js +1 -1
- package/dist/capabilities/effect.js +293 -0
- package/dist/capabilities/fact-log.js +41 -0
- package/dist/capabilities/flyai.js +20 -6
- package/dist/capabilities/hbcli.js +43 -1
- package/dist/capabilities/incident-log.js +1 -1
- package/dist/capabilities/model-override.js +18 -0
- package/dist/capabilities/opensky.js +1 -1
- package/dist/capabilities/resilience.js +90 -0
- package/dist/capabilities/session/action-cache.js +1 -1
- package/dist/capabilities/session/adapters/ctrip-flight.js +1 -1
- package/dist/capabilities/session/adapters/meituan-local.js +1 -1
- package/dist/capabilities/session/benchmark.js +2 -1
- package/dist/capabilities/session/extension-bridge.js +358 -0
- package/dist/capabilities/session/extension-channel.js +93 -0
- package/dist/capabilities/session/extension-distribution.js +234 -0
- package/dist/capabilities/session/extract.js +1 -1
- package/dist/capabilities/session/golden-score.js +92 -0
- package/dist/capabilities/session/health-watch.js +216 -0
- package/dist/capabilities/session/read-guard.js +1 -1
- package/dist/capabilities/session/static-flight-golden.js +137 -0
- package/dist/capabilities/session/transport.js +1 -1
- package/dist/capabilities/session/wizard.js +147 -0
- package/dist/capabilities/session-consent.js +1 -1
- package/dist/capabilities/session-login.js +83 -1
- package/dist/capabilities/session-search.js +111 -2
- package/dist/capabilities/weather.js +168 -46
- package/dist/data/session-golden-20.json +25 -0
- package/dist/data/sf-golden-manifest.json +102 -0
- package/dist/data/sf-static-routes.json +91 -0
- package/dist/scripts/action-cache-tests.js +1 -1
- package/dist/scripts/agent-planning-budget-e2e.js +227 -0
- package/dist/scripts/agent-planning-budget-tests.js +173 -0
- package/dist/scripts/agent-reach-deep-tests.js +1 -1
- package/dist/scripts/agent-reach-tests.js +1 -1
- package/dist/scripts/agent-reach-wrapper-tests.js +1 -1
- package/dist/scripts/anything-tests.js +1 -1
- package/dist/scripts/async-collect.js +1 -1
- package/dist/scripts/benchmark-environment-bridge-e2e.js +981 -0
- package/dist/scripts/benchmark-environment-bridge-tests.js +2544 -0
- package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +335 -0
- package/dist/scripts/booking-copilot-availability-policy-v2-tests.js +1355 -0
- package/dist/scripts/booking-copilot-bin-proof-tests.js +89 -0
- package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +113 -0
- package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +356 -0
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +557 -0
- package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +140 -0
- package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +202 -0
- package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +102 -0
- package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +374 -0
- package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +447 -0
- package/dist/scripts/booking-copilot-runtime-proof-tests.js +205 -0
- package/dist/scripts/booking-copilot-server-proof-tests.js +317 -0
- package/dist/scripts/booking-copilot-startup-proof-tests.js +283 -0
- package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +3935 -0
- package/dist/scripts/booking-saga-tests.js +1 -1
- package/dist/scripts/booking-surface-contract-proof-tests.js +393 -0
- package/dist/scripts/booking-surface-v2-contract-proof-tests.js +1174 -0
- package/dist/scripts/bootstrap-tests.js +59 -3
- package/dist/scripts/build-changelog.js +279 -0
- package/dist/scripts/changelog-tests.js +165 -0
- package/dist/scripts/companion-tests.js +1 -1
- package/dist/scripts/diff-test.js +1 -1
- package/dist/scripts/dsh-runtime-closure-tests.js +225 -0
- package/dist/scripts/dsh-runtime-closure.js +150 -0
- package/dist/scripts/effect-tests.js +474 -0
- package/dist/scripts/engine-run.js +1 -1
- package/dist/scripts/engine-tests.js +1 -1
- package/dist/scripts/evaluation-cadence-tests.js +347 -0
- package/dist/scripts/evaluation-contract-tests.js +574 -0
- package/dist/scripts/extension-distribution-cli.js +35 -0
- package/dist/scripts/extension-distribution-tests.js +341 -0
- package/dist/scripts/extension-tests.js +573 -0
- package/dist/scripts/fact-gate-tests.js +318 -0
- package/dist/scripts/flyai-tests.js +1 -1
- package/dist/scripts/hbcli-e2e-tests.js +180 -0
- package/dist/scripts/hbcli-tests.js +1 -1
- package/dist/scripts/health-watch-cli.js +51 -0
- package/dist/scripts/i18n-tests.js +1 -1
- package/dist/scripts/incident-tests.js +1 -1
- package/dist/scripts/journey-tests.js +1 -1
- package/dist/scripts/ledger-tests.js +1 -1
- package/dist/scripts/ledger-workflow-crash.js +1 -1
- package/dist/scripts/memory-capture-tests.js +1 -1
- package/dist/scripts/memory-decay-tests.js +1 -1
- package/dist/scripts/memory-metrics.js +1 -1
- package/dist/scripts/memory-value-report.js +1 -1
- package/dist/scripts/model-override-e2e.js +176 -0
- package/dist/scripts/nightly-evidence-tests.js +67 -4
- package/dist/scripts/nightly-evidence.js +59 -3
- package/dist/scripts/nudge-digest.js +1 -1
- package/dist/scripts/onboarding-tests.js +186 -0
- package/dist/scripts/opensky-check.js +1 -1
- package/dist/scripts/opensky-tests.js +1 -1
- package/dist/scripts/pnpm-dsh-closure-proof.js +20 -0
- package/dist/scripts/price-drift-tests.js +441 -0
- package/dist/scripts/price-drift-watch.js +491 -0
- package/dist/scripts/probe-poi-tests.js +1 -1
- package/dist/scripts/product-metrics.js +1 -1
- package/dist/scripts/publish-preverify.js +40 -4
- package/dist/scripts/realtime-pricing-tests.js +1 -1
- package/dist/scripts/replay-async.js +1 -1
- package/dist/scripts/replay-real.js +1 -1
- package/dist/scripts/replay.js +1 -1
- package/dist/scripts/session-attach-diagnose.js +1 -1
- package/dist/scripts/session-attach-poc.js +1 -1
- package/dist/scripts/session-benchmark.js +1 -1
- package/dist/scripts/session-extract-tests.js +1 -1
- package/dist/scripts/session-login.js +1 -1
- package/dist/scripts/session-tests.js +70 -20
- package/dist/scripts/sf-live-benchmark.js +338 -0
- package/dist/scripts/sf-live-cli-tests.js +21 -0
- package/dist/scripts/sf-soft-score-tests.js +108 -0
- package/dist/scripts/sf-summary.js +93 -0
- package/dist/scripts/skeleton-check.js +1 -1
- package/dist/scripts/skeleton-integration-test.js +1 -1
- package/dist/scripts/skills-contract-tests.js +1 -1
- package/dist/scripts/smoke-session-gate-tests.js +29 -0
- package/dist/scripts/smoke.js +154 -33
- package/dist/scripts/state-cli-tests.js +1 -1
- package/dist/scripts/state-cli.js +1 -1
- package/dist/scripts/static-golden-tests.js +299 -0
- package/dist/scripts/time-eval-tests.js +1 -1
- package/dist/scripts/travel-timeline-tests.js +1 -1
- package/dist/scripts/unified-tests.js +1 -1
- package/dist/scripts/weather-tests.js +694 -44
- package/dist/scripts/z3-race-tests.js +1 -1
- package/dist/src/artifact-gate.js +310 -0
- package/dist/src/benchmark-agent-conformance.js +370 -0
- package/dist/src/benchmark-environment-bridge.js +384 -0
- package/dist/src/benchmark-headless-child-diagnostics.js +173 -0
- package/dist/src/benchmark-tool-isolation.js +124 -0
- package/dist/src/bookable-facts.js +349 -0
- package/dist/src/booking-saga.js +1 -1
- package/dist/src/booking-surface/availability-policy-v2.js +830 -0
- package/dist/src/booking-surface/canonical-schema.js +113 -0
- package/dist/src/booking-surface/contracts-v2.js +89 -0
- package/dist/src/booking-surface/contracts.js +46 -0
- package/dist/src/booking-surface/dsh-planner.js +453 -0
- package/dist/src/booking-surface/dsh-plugin.js +93 -0
- package/dist/src/booking-surface/error-codes.js +94 -0
- package/dist/src/booking-surface/index.js +15 -0
- package/dist/src/booking-surface/profile.js +68 -0
- package/dist/src/booking-surface/runtime-v2.js +1771 -0
- package/dist/src/booking-surface/runtime.js +351 -0
- package/dist/src/booking-surface/server-v2.js +334 -0
- package/dist/src/booking-surface/server.js +302 -0
- package/dist/src/booking-surface/startup.js +159 -0
- package/dist/src/booking-surface/validation-v2.js +319 -0
- package/dist/src/booking-surface/validation.js +809 -0
- package/dist/src/bridge.js +1 -1
- package/dist/src/companions.js +1 -1
- package/dist/src/contracts.js +1 -1
- package/dist/src/dsh-llm.js +1 -1
- package/dist/src/engine.js +1 -1
- package/dist/src/evaluation-cadence.js +234 -0
- package/dist/src/evaluation-contracts.js +906 -0
- package/dist/src/i18n.js +1 -1
- package/dist/src/index.js +234 -60
- package/dist/src/journey.js +1 -1
- package/dist/src/loop.js +1 -1
- package/dist/src/memory-capture.js +1 -1
- package/dist/src/memory-decay.js +1 -1
- package/dist/src/memory-utility.js +1 -1
- package/dist/src/mock-llm.js +1 -1
- package/dist/src/model.js +1 -1
- package/dist/src/realtime-pricing.js +14 -7
- package/dist/src/slot-spec.js +1 -1
- package/dist/src/state-ledger.js +2 -1
- package/dist/src/time-anchor.js +1 -1
- package/dist/src/tool-budget.js +136 -0
- package/dist/src/tool-packet.js +1 -1
- package/dist/src/travel-slots.js +1 -1
- package/dist/src/travel-timeline.js +1 -1
- package/dist/src/unified.js +1 -1
- package/dist/src/wish-pool.js +1 -1
- package/dist/src/z3-shared.js +1 -1
- package/extension/README.md +50 -0
- package/extension/background.js +160 -0
- package/extension/content-bridge.js +41 -0
- package/extension/content-main.js +62 -0
- package/extension/manifest.json +32 -0
- package/package.json +291 -12
- package/schemas/booking.surface.v1.schema.json +927 -0
- package/schemas/booking.surface.v2.schema.json +61 -0
- package/ts/capabilities/fact-log.ts +54 -0
- package/ts/capabilities/flyai.ts +16 -3
- package/ts/capabilities/hbcli.ts +46 -0
- package/ts/capabilities/session/benchmark.ts +4 -1
- package/ts/capabilities/session/extension-bridge.ts +377 -0
- package/ts/capabilities/session/extension-channel.ts +95 -0
- package/ts/capabilities/session/extension-distribution.ts +264 -0
- package/ts/capabilities/session/golden-score.ts +139 -0
- package/ts/capabilities/session/health-watch.ts +257 -0
- package/ts/capabilities/session/static-flight-golden.ts +209 -0
- package/ts/capabilities/session/wizard.ts +140 -0
- package/ts/capabilities/session-login.ts +63 -2
- package/ts/capabilities/session-search.ts +105 -3
- package/ts/capabilities/weather.ts +141 -52
- package/ts/package.json +3 -3
- package/ts/src/artifact-gate.ts +365 -0
- package/ts/src/benchmark-agent-conformance.ts +448 -0
- package/ts/src/benchmark-environment-bridge.ts +348 -0
- package/ts/src/benchmark-headless-child-diagnostics.ts +184 -0
- package/ts/src/benchmark-tool-isolation.ts +166 -0
- package/ts/src/bookable-facts.ts +557 -0
- package/ts/src/booking-surface/availability-policy-v2.ts +523 -0
- package/ts/src/booking-surface/canonical-schema.js +113 -0
- package/ts/src/booking-surface/contracts-v2.ts +118 -0
- package/ts/src/booking-surface/contracts.ts +380 -0
- package/ts/src/booking-surface/dsh-planner.ts +452 -0
- package/ts/src/booking-surface/dsh-plugin.js +93 -0
- package/ts/src/booking-surface/error-codes.ts +101 -0
- package/ts/src/booking-surface/index.ts +12 -0
- package/ts/src/booking-surface/profile.ts +42 -0
- package/ts/src/booking-surface/runtime-v2.ts +1466 -0
- package/ts/src/booking-surface/runtime.ts +483 -0
- package/ts/src/booking-surface/server-v2.ts +247 -0
- package/ts/src/booking-surface/server.ts +324 -0
- package/ts/src/booking-surface/startup.ts +196 -0
- package/ts/src/booking-surface/validation-v2.ts +205 -0
- package/ts/src/booking-surface/validation.ts +453 -0
- package/ts/src/index.ts +161 -14
- package/ts/src/state-ledger.ts +1 -0
- package/ts/src/tool-budget.ts +165 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扩展产物分发(issue #21 分发通道,ADR-21;founder 2026-08-30「产物下载和安装也得做成更好的
|
|
3
|
+
* 用户体验,可以用 github 作为分发渠道」)。
|
|
4
|
+
*
|
|
5
|
+
* 平台硬约束(诚实前置):Chrome 禁止普通用户从任意 URL 安装打包 CRX——GitHub 只能改善
|
|
6
|
+
* 「下载」(版本化/回滚/与 npm 发版节奏解耦),改善不了「安装」;「开发者模式加载已解压」
|
|
7
|
+
* 那 3 次点击只有上架 Chrome Web Store 才能消掉(商店版自动更新,见 extension-webstore-submission.md)。
|
|
8
|
+
* manifest 固定 key ⇒ 双通道是同一个扩展 ID(olpgkofjhhiiiahdkkbcninhjmegghfe),端口池/
|
|
9
|
+
* host_permissions 不随通道漂移——本模块用 key 钉住这一不变量。
|
|
10
|
+
*
|
|
11
|
+
* 单一职责:① 产物源解析(bundled 默认离线确定性 | github 显式 opt-in);
|
|
12
|
+
* ② GitHub Releases 下载链:dist-manifest → tar.gz → SHA256 校验 → tar 解压 → key 钉扎校验
|
|
13
|
+
* → 版本比较 → 原子交换 ~/.gotry/extension;
|
|
14
|
+
* ③ 失败语义:github 源任何失败(无网/404/校验不过/key 漂移)→ 结构化 fallback bundled,
|
|
15
|
+
* 绝不挡 setup(安装外部产物永远不挡 gotry 本体——bootstrap 契约)。
|
|
16
|
+
*
|
|
17
|
+
* 信任边界(诚实声明):TLS 传输 + 同源 dist-manifest 哈希完整性——防截断/损坏/误传资产,
|
|
18
|
+
* 不防仓库本身被攻陷(无独立 pinning;独立 pin 需把哈希烘进 npm 包,与「扩展更新与 npm
|
|
19
|
+
* 发版解耦」目标冲突,明确不做)。
|
|
20
|
+
*
|
|
21
|
+
* 零新 npm 依赖:fetch 走 Node ≥22 全局;解压走平台原生 `tar -xzf`(macOS/Linux/Windows10+
|
|
22
|
+
* 自带);SHA256 走 node:crypto。spawn/fetch 可注入(离线测试)。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { spawn } from 'node:child_process'
|
|
26
|
+
import { createHash } from 'node:crypto'
|
|
27
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
28
|
+
import { join } from 'node:path'
|
|
29
|
+
|
|
30
|
+
/** Releases 稳定资产名(每版覆盖同名上传;latest/download 永久链只认稳定名)。
|
|
31
|
+
* 与 scripts/package-extension.mjs 的资产名必须逐字一致——extension-distribution-tests 防漂移。 */
|
|
32
|
+
export const DIST_ASSET_TARBALL = 'gotry-session-bridge.tar.gz'
|
|
33
|
+
export const DIST_ASSET_STORE_ZIP = 'gotry-session-bridge-store.zip'
|
|
34
|
+
export const DIST_ASSET_MANIFEST = 'extension-dist-manifest.json'
|
|
35
|
+
|
|
36
|
+
export const DEFAULT_RELEASE_BASE = 'https://github.com/Danceiny/gotry/releases'
|
|
37
|
+
|
|
38
|
+
export interface ExtensionDistManifest {
|
|
39
|
+
version: string
|
|
40
|
+
tarball: string
|
|
41
|
+
tarballSha256: string
|
|
42
|
+
zip: string
|
|
43
|
+
zipSha256: string
|
|
44
|
+
builtFromCommit?: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface GithubSourceOptions {
|
|
48
|
+
/** Releases 基址(测试/镜像可指回环);默认 GitHub 官方 */
|
|
49
|
+
releaseBase?: string
|
|
50
|
+
/** 注入 fetch(离线测试);默认全局 fetch */
|
|
51
|
+
fetch?: typeof fetch
|
|
52
|
+
/** 注入 spawn(离线测试拦截 tar);默认 child_process.spawn */
|
|
53
|
+
spawnFn?: typeof spawn
|
|
54
|
+
/** 下载/解压超时(默认 60s,有界) */
|
|
55
|
+
timeoutMs?: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type ExtensionInstallAction = 'installed' | 'up-to-date' | 'fallback-bundled'
|
|
59
|
+
|
|
60
|
+
export interface ExtensionInstallResult {
|
|
61
|
+
ok: boolean
|
|
62
|
+
action: ExtensionInstallAction
|
|
63
|
+
/** 远端 dist-manifest 声明的版本(action=up-to-date 时也带回,供比较报告) */
|
|
64
|
+
version?: string
|
|
65
|
+
/** 交换前 dest 里已就位的版本(无则 undefined) */
|
|
66
|
+
previousVersion?: string
|
|
67
|
+
error?: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 纯函数:三段数字版本比较(a<b 负 / 相等 0 / a>b 正;非数字段按 0 处理,长度差补 0) */
|
|
71
|
+
export function compareVersions(a: string, b: string): number {
|
|
72
|
+
const pa = String(a).split('.').map((s) => Number.parseInt(s, 10) || 0)
|
|
73
|
+
const pb = String(b).split('.').map((s) => Number.parseInt(s, 10) || 0)
|
|
74
|
+
const n = Math.max(pa.length, pb.length)
|
|
75
|
+
for (let i = 0; i < n; i += 1) {
|
|
76
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0)
|
|
77
|
+
if (d !== 0) return d
|
|
78
|
+
}
|
|
79
|
+
return 0
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 纯函数:Releases latest/download 永久链(稳定资产名) */
|
|
83
|
+
export function distAssetUrls(releaseBase: string = DEFAULT_RELEASE_BASE): { tarball: string; storeZip: string; manifest: string } {
|
|
84
|
+
const base = releaseBase.replace(/\/+$/, '')
|
|
85
|
+
return {
|
|
86
|
+
tarball: `${base}/latest/download/${DIST_ASSET_TARBALL}`,
|
|
87
|
+
storeZip: `${base}/latest/download/${DIST_ASSET_STORE_ZIP}`,
|
|
88
|
+
manifest: `${base}/latest/download/${DIST_ASSET_MANIFEST}`,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const SHA256_RE = /^[0-9a-f]{64}$/
|
|
93
|
+
|
|
94
|
+
/** 纯函数:dist-manifest 解析,fail-closed(坏 JSON/缺字段/资产名漂移/哈希非 64-hex 一律拒绝) */
|
|
95
|
+
export function parseDistManifest(raw: string): ExtensionDistManifest {
|
|
96
|
+
let obj: Record<string, unknown>
|
|
97
|
+
try {
|
|
98
|
+
obj = JSON.parse(raw) as Record<string, unknown>
|
|
99
|
+
} catch {
|
|
100
|
+
throw new Error('dist-manifest 不是合法 JSON')
|
|
101
|
+
}
|
|
102
|
+
const version = obj.version
|
|
103
|
+
const tarball = obj.tarball
|
|
104
|
+
const tarballSha256 = obj.tarballSha256
|
|
105
|
+
const zip = obj.zip
|
|
106
|
+
const zipSha256 = obj.zipSha256
|
|
107
|
+
if (typeof version !== 'string' || !/^\d+\.\d+\.\d+$/.test(version)) throw new Error('dist-manifest.version 缺失或非 x.y.z')
|
|
108
|
+
if (tarball !== DIST_ASSET_TARBALL) throw new Error(`dist-manifest.tarball 资产名漂移(期望 ${DIST_ASSET_TARBALL})`)
|
|
109
|
+
if (zip !== DIST_ASSET_STORE_ZIP) throw new Error(`dist-manifest.zip 资产名漂移(期望 ${DIST_ASSET_STORE_ZIP})`)
|
|
110
|
+
if (typeof tarballSha256 !== 'string' || !SHA256_RE.test(tarballSha256)) throw new Error('dist-manifest.tarballSha256 非 64-hex')
|
|
111
|
+
if (typeof zipSha256 !== 'string' || !SHA256_RE.test(zipSha256)) throw new Error('dist-manifest.zipSha256 非 64-hex')
|
|
112
|
+
return {
|
|
113
|
+
version,
|
|
114
|
+
tarball,
|
|
115
|
+
tarballSha256,
|
|
116
|
+
zip,
|
|
117
|
+
zipSha256,
|
|
118
|
+
builtFromCommit: typeof obj.builtFromCommit === 'string' ? obj.builtFromCommit : undefined,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function sha256Hex(bytes: Uint8Array | string): string {
|
|
123
|
+
return createHash('sha256').update(bytes).digest('hex')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 读取扩展 manifest 的 key(固定 key ⇒ 固定扩展 ID;分发通道 ID 不变量的锚点) */
|
|
127
|
+
export function readExtensionKey(extensionDir: string): string | undefined {
|
|
128
|
+
const p = join(extensionDir, 'manifest.json')
|
|
129
|
+
if (!existsSync(p)) return undefined
|
|
130
|
+
try {
|
|
131
|
+
const m = JSON.parse(readFileSync(p, 'utf8')) as { key?: unknown }
|
|
132
|
+
return typeof m.key === 'string' ? m.key : undefined
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function readExtensionVersion(extensionDir: string): string | undefined {
|
|
139
|
+
const p = join(extensionDir, 'manifest.json')
|
|
140
|
+
if (!existsSync(p)) return undefined
|
|
141
|
+
try {
|
|
142
|
+
const m = JSON.parse(readFileSync(p, 'utf8')) as { version?: unknown }
|
|
143
|
+
return typeof m.version === 'string' ? m.version : undefined
|
|
144
|
+
} catch {
|
|
145
|
+
return undefined
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function runTar(args: string[], spawnFn: typeof spawn, timeoutMs: number): Promise<{ ok: boolean; error?: string }> {
|
|
150
|
+
return new Promise((resolve) => {
|
|
151
|
+
const child = spawnFn('tar', args, { stdio: 'ignore' })
|
|
152
|
+
let done = false
|
|
153
|
+
const timer = setTimeout(() => {
|
|
154
|
+
if (!done) {
|
|
155
|
+
done = true
|
|
156
|
+
try {
|
|
157
|
+
child.kill('SIGKILL')
|
|
158
|
+
} catch {
|
|
159
|
+
/* ignore */
|
|
160
|
+
}
|
|
161
|
+
resolve({ ok: false, error: `tar 超时(>${timeoutMs}ms)` })
|
|
162
|
+
}
|
|
163
|
+
}, timeoutMs)
|
|
164
|
+
child.on('error', (e) => {
|
|
165
|
+
if (!done) {
|
|
166
|
+
done = true
|
|
167
|
+
clearTimeout(timer)
|
|
168
|
+
resolve({ ok: false, error: `tar 不可用:${e.message}` })
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
child.on('close', (code) => {
|
|
172
|
+
if (!done) {
|
|
173
|
+
done = true
|
|
174
|
+
clearTimeout(timer)
|
|
175
|
+
resolve(code === 0 ? { ok: true } : { ok: false, error: `tar exit ${code}` })
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function fetchBytes(url: string, fetchFn: typeof fetch, timeoutMs: number): Promise<{ ok: true; bytes: Uint8Array } | { ok: false; error: string }> {
|
|
182
|
+
try {
|
|
183
|
+
const res = await fetchFn(url, { signal: AbortSignal.timeout(timeoutMs) })
|
|
184
|
+
if (!res.ok) return { ok: false, error: `HTTP ${res.status} ${url}` }
|
|
185
|
+
const buf = new Uint8Array(await res.arrayBuffer())
|
|
186
|
+
return { ok: true, bytes: buf }
|
|
187
|
+
} catch (e) {
|
|
188
|
+
return { ok: false, error: `下载失败 ${(e as Error).message}` }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface InstallFromGithubOptions extends GithubSourceOptions {
|
|
193
|
+
/** 落位目录(默认 ~/.gotry/extension 由调用方决定,本层不猜) */
|
|
194
|
+
destDir: string
|
|
195
|
+
/** 钉扎参照:bundled extension/ 目录(其 manifest key 是 ID 不变量的基准;缺省只做版本校验) */
|
|
196
|
+
pinnedSourceDir?: string
|
|
197
|
+
/** 只报告不落盘(up-to-date/将安装哪个版本;不下载 tarball 之外的任何东西) */
|
|
198
|
+
checkOnly?: boolean
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* GitHub Releases → ~/.gotry/extension 安装链。
|
|
203
|
+
* 任何失败都返回 { ok:false, action:'fallback-bundled' }——调用方显式降级 bundled,
|
|
204
|
+
* 本函数不抛出(bootstrap 契约:安装外部产物永远不挡 gotry 本体)。
|
|
205
|
+
*/
|
|
206
|
+
export async function installExtensionFromGithub(opts: InstallFromGithubOptions): Promise<ExtensionInstallResult> {
|
|
207
|
+
const timeoutMs = opts.timeoutMs ?? 60_000
|
|
208
|
+
const fetchFn = opts.fetch ?? fetch
|
|
209
|
+
const spawnFn = opts.spawnFn ?? spawn
|
|
210
|
+
const urls = distAssetUrls(opts.releaseBase)
|
|
211
|
+
const fail = (error: string): ExtensionInstallResult => ({ ok: false, action: 'fallback-bundled', error })
|
|
212
|
+
|
|
213
|
+
const mRes = await fetchBytes(urls.manifest, fetchFn, timeoutMs)
|
|
214
|
+
if (!mRes.ok) return fail(mRes.error)
|
|
215
|
+
let manifest: ExtensionDistManifest
|
|
216
|
+
try {
|
|
217
|
+
manifest = parseDistManifest(new TextDecoder().decode(mRes.bytes))
|
|
218
|
+
} catch (e) {
|
|
219
|
+
return fail(`dist-manifest 校验失败:${(e as Error).message}`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const previousVersion = readExtensionVersion(opts.destDir)
|
|
223
|
+
if (previousVersion != null && compareVersions(manifest.version, previousVersion) <= 0) {
|
|
224
|
+
return { ok: true, action: 'up-to-date', version: manifest.version, previousVersion }
|
|
225
|
+
}
|
|
226
|
+
if (opts.checkOnly) {
|
|
227
|
+
return { ok: true, action: 'up-to-date', version: manifest.version, previousVersion } // check-only 语义:报告远端版本,不落盘
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const tRes = await fetchBytes(urls.tarball, fetchFn, timeoutMs)
|
|
231
|
+
if (!tRes.ok) return fail(tRes.error)
|
|
232
|
+
const actual = sha256Hex(tRes.bytes)
|
|
233
|
+
if (actual !== manifest.tarballSha256) return fail(`SHA256 不符(期望 ${manifest.tarballSha256.slice(0, 12)}… 实得 ${actual.slice(0, 12)}…)`)
|
|
234
|
+
|
|
235
|
+
// 落盘 → tar 解压 → key 钉扎 → 原子交换;incoming 目录同盘保证 rename 原子性
|
|
236
|
+
const incoming = `${opts.destDir}.incoming-${process.pid}-${Date.now()}`
|
|
237
|
+
const tarPath = `${incoming}.tar.gz`
|
|
238
|
+
try {
|
|
239
|
+
rmSync(incoming, { recursive: true, force: true })
|
|
240
|
+
mkdirSync(incoming, { recursive: true })
|
|
241
|
+
writeFileSync(tarPath, tRes.bytes)
|
|
242
|
+
// 包内含顶层目录 gotry-session-bridge/;--strip-components 落到 incoming 根
|
|
243
|
+
const ext = await runTar(['-xzf', tarPath, '-C', incoming, '--strip-components', '1'], spawnFn, timeoutMs)
|
|
244
|
+
if (!ext.ok) return fail(ext.error!)
|
|
245
|
+
|
|
246
|
+
const incomingKey = readExtensionKey(incoming)
|
|
247
|
+
if (incomingKey == null) return fail('解压产物缺 manifest.json/key(非法包)')
|
|
248
|
+
if (opts.pinnedSourceDir) {
|
|
249
|
+
const pinnedKey = readExtensionKey(opts.pinnedSourceDir)
|
|
250
|
+
if (pinnedKey != null && incomingKey !== pinnedKey) return fail('扩展 key 漂移(通道产物与 bundled 不同 ID——拒绝安装,防端口池/host 权限漂移)')
|
|
251
|
+
}
|
|
252
|
+
const incomingVersion = readExtensionVersion(incoming)
|
|
253
|
+
if (incomingVersion !== manifest.version) return fail(`解压产物版本(${incomingVersion})与 dist-manifest(${manifest.version})不符`)
|
|
254
|
+
|
|
255
|
+
rmSync(opts.destDir, { recursive: true, force: true })
|
|
256
|
+
cpSync(incoming, opts.destDir, { recursive: true })
|
|
257
|
+
return { ok: true, action: 'installed', version: manifest.version, previousVersion }
|
|
258
|
+
} catch (e) {
|
|
259
|
+
return fail(`落位失败 ${(e as Error).message}`)
|
|
260
|
+
} finally {
|
|
261
|
+
rmSync(incoming, { recursive: true, force: true })
|
|
262
|
+
rmSync(tarPath, { force: true })
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SESSION_FIELD_ACCURACY_THRESHOLD,
|
|
3
|
+
type SessionComparableRecord,
|
|
4
|
+
} from './benchmark.ts'
|
|
5
|
+
|
|
6
|
+
export interface GoldenBand {
|
|
7
|
+
query_id: string
|
|
8
|
+
from: string
|
|
9
|
+
to: string
|
|
10
|
+
date: string
|
|
11
|
+
window_dep_local: { earliest: string; latest: string }
|
|
12
|
+
window_arr_local: { earliest: string; latest: string }
|
|
13
|
+
duration_min: { min: number; max: number }
|
|
14
|
+
price_cny: { min: number; max: number }
|
|
15
|
+
transport_hint: string
|
|
16
|
+
known_flights: string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GoldenSoftScore {
|
|
20
|
+
pass: boolean
|
|
21
|
+
total: number
|
|
22
|
+
correct: number
|
|
23
|
+
accuracy: number
|
|
24
|
+
missing: string[]
|
|
25
|
+
incorrect: string[]
|
|
26
|
+
golden_source: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseHHmm(value: string): number {
|
|
30
|
+
const [hours, minutes] = value.split(':')
|
|
31
|
+
return parseInt(hours!, 10) * 60 + parseInt(minutes!, 10)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseLocalMinutes(value: string): number | null {
|
|
35
|
+
const match = value.match(/(\d{2}):(\d{2})/)
|
|
36
|
+
if (!match) return null
|
|
37
|
+
return parseInt(match[1]!, 10) * 60 + parseInt(match[2]!, 10)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseLocalDate(value: string): string | null {
|
|
41
|
+
return value.match(/^(\d{4}-\d{2}-\d{2})[T ]/)?.[1] ?? null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function scoreSessionAgainstGoldenBand(
|
|
45
|
+
session: SessionComparableRecord,
|
|
46
|
+
band: GoldenBand,
|
|
47
|
+
goldenSource: string,
|
|
48
|
+
): GoldenSoftScore {
|
|
49
|
+
const missing: string[] = []
|
|
50
|
+
const hardIncorrect: string[] = []
|
|
51
|
+
const segment = session.route_segments[0]
|
|
52
|
+
|
|
53
|
+
if (!session.query_id) missing.push('query_id')
|
|
54
|
+
else if (session.query_id !== band.query_id) hardIncorrect.push('query_id')
|
|
55
|
+
if (!session.currency) missing.push('currency')
|
|
56
|
+
else if (session.currency !== 'CNY') hardIncorrect.push('currency')
|
|
57
|
+
if (!session.source) missing.push('source')
|
|
58
|
+
else if (session.source !== 'ctrip-flight') hardIncorrect.push('source')
|
|
59
|
+
if (!session.verdict) missing.push('verdict')
|
|
60
|
+
else if (session.verdict !== 'hit') hardIncorrect.push('verdict')
|
|
61
|
+
|
|
62
|
+
if (!segment) {
|
|
63
|
+
missing.push(
|
|
64
|
+
'route_segments[0].from',
|
|
65
|
+
'route_segments[0].to',
|
|
66
|
+
'route_segments[0].departure_at',
|
|
67
|
+
'route_segments[0].arrival_at',
|
|
68
|
+
'route_segments[0].transport_number',
|
|
69
|
+
)
|
|
70
|
+
} else {
|
|
71
|
+
if (!segment.from) missing.push('route_segments[0].from')
|
|
72
|
+
else if (segment.from !== band.from) hardIncorrect.push('route_segments[0].from')
|
|
73
|
+
if (!segment.to) missing.push('route_segments[0].to')
|
|
74
|
+
else if (segment.to !== band.to) hardIncorrect.push('route_segments[0].to')
|
|
75
|
+
if (!segment.departure_at) missing.push('route_segments[0].departure_at')
|
|
76
|
+
else if (parseLocalDate(segment.departure_at) !== band.date) {
|
|
77
|
+
hardIncorrect.push('route_segments[0].departure_at.date')
|
|
78
|
+
}
|
|
79
|
+
if (!segment.arrival_at) missing.push('route_segments[0].arrival_at')
|
|
80
|
+
else if (parseLocalDate(segment.arrival_at) !== band.date) {
|
|
81
|
+
hardIncorrect.push('route_segments[0].arrival_at.date')
|
|
82
|
+
}
|
|
83
|
+
if (!segment.transport_number) missing.push('route_segments[0].transport_number')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const softChecks: Array<{ path: string; ok: boolean }> = []
|
|
87
|
+
if (segment?.departure_at) {
|
|
88
|
+
const minutes = parseLocalMinutes(segment.departure_at)
|
|
89
|
+
softChecks.push({
|
|
90
|
+
path: 'route_segments[0].departure_at',
|
|
91
|
+
ok: minutes !== null
|
|
92
|
+
&& minutes >= parseHHmm(band.window_dep_local.earliest)
|
|
93
|
+
&& minutes <= parseHHmm(band.window_dep_local.latest),
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
if (segment?.arrival_at) {
|
|
97
|
+
const minutes = parseLocalMinutes(segment.arrival_at)
|
|
98
|
+
softChecks.push({
|
|
99
|
+
path: 'route_segments[0].arrival_at',
|
|
100
|
+
ok: minutes !== null
|
|
101
|
+
&& minutes >= parseHHmm(band.window_arr_local.earliest)
|
|
102
|
+
&& minutes <= parseHHmm(band.window_arr_local.latest),
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
if (segment?.transport_number) {
|
|
106
|
+
const actual = segment.transport_number.toUpperCase()
|
|
107
|
+
softChecks.push({
|
|
108
|
+
path: 'route_segments[0].transport_number',
|
|
109
|
+
ok: band.known_flights.some((flight) => actual.startsWith(flight.toUpperCase()))
|
|
110
|
+
|| band.known_flights.some((flight) => actual.includes(flight.toUpperCase().slice(0, 2))),
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
if (Number.isFinite(session.price) && session.price > 0) {
|
|
114
|
+
const margin = band.price_cny.max * 0.15
|
|
115
|
+
softChecks.push({
|
|
116
|
+
path: 'price',
|
|
117
|
+
ok: session.price >= band.price_cny.min - margin
|
|
118
|
+
&& session.price <= band.price_cny.max + margin,
|
|
119
|
+
})
|
|
120
|
+
} else missing.push('price')
|
|
121
|
+
|
|
122
|
+
const softIncorrect = softChecks.filter((check) => !check.ok).map((check) => check.path)
|
|
123
|
+
const incorrect = [...hardIncorrect, ...softIncorrect]
|
|
124
|
+
// 合同字段固定为 13,缺字段不能靠缩小分母抬高准确率。
|
|
125
|
+
const total = 13
|
|
126
|
+
const correct = Math.max(0, total - new Set(missing).size - incorrect.length)
|
|
127
|
+
const accuracy = total > 0 ? correct / total : 0
|
|
128
|
+
return {
|
|
129
|
+
pass: missing.length === 0
|
|
130
|
+
&& hardIncorrect.length === 0
|
|
131
|
+
&& accuracy >= SESSION_FIELD_ACCURACY_THRESHOLD,
|
|
132
|
+
total,
|
|
133
|
+
correct,
|
|
134
|
+
accuracy,
|
|
135
|
+
missing: Array.from(new Set(missing)),
|
|
136
|
+
incorrect,
|
|
137
|
+
golden_source: goldenSource,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扩展健康检查 + 自动重放(issue #21 onboarding UX,P3.6 gotry-session-onboarding-goal,
|
|
3
|
+
* RFC user-session-data-rfc.md §3.3)。
|
|
4
|
+
*
|
|
5
|
+
* 动机:首次 `sessionFlightSearch` 遇 `needs-extension` 时,旧版要求用户装完扩展后**自己重跑命令**;
|
|
6
|
+
* 本模块把「用户手工重跑」改成「后台有界轮询 + 扩展一就位自动重放同一 query_id」——
|
|
7
|
+
* 用户装完扩展零后续动作,gotry 自己感知到 ready 后立刻跑同一条 query。
|
|
8
|
+
*
|
|
9
|
+
* 形态:
|
|
10
|
+
* - `startExtensionHealthWatch({ timeoutMs, intervalMs, probe })` 启后台轮询。
|
|
11
|
+
* probe 是异步检测函数(默认 GET http://127.0.0.1:{port}/status,扩展 ≤30s 心跳一次,
|
|
12
|
+
* 1.5 倍容差 EXTENSION_CONNECTED_WINDOW_MS=45s;§38 已经验证扩展心跳节奏)。
|
|
13
|
+
* - 命中 ready → 调用方自动重放(query_id / args 不变;同一证据文件覆盖)。
|
|
14
|
+
* - 超时(timeoutMs 默认 120_000)→ 返回 'timeout',调用方降级回 `needs-extension` 错误面。
|
|
15
|
+
*
|
|
16
|
+
* 离线合同(run-all §40 onboarding-tests 5/5):
|
|
17
|
+
* - 三时序分支:probe 0ms ready / 中途 ready / 120s+1ms 超时;
|
|
18
|
+
* - 同 query_id 重放语义(参数透传);
|
|
19
|
+
* - 取消语义(timeoutMs=0 立即 timeout;cancel() 立即返回 'cancelled');
|
|
20
|
+
* - 重放上限(防止 watch 反复 ready/抖动导致无限重放;maxRetries 默认 1)。
|
|
21
|
+
*
|
|
22
|
+
* 纪律:
|
|
23
|
+
* - 不引 GUI 依赖;只 spawn `node:http` 探活(已经在扩展桥验证过,§38 24 断言覆盖);
|
|
24
|
+
* - 不持锁、不轮询文件、不读 dsh-runtime 共享状态;
|
|
25
|
+
* - 不污染扩展桥 single-instance;probe 走独立短连接。
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { request } from 'node:http'
|
|
29
|
+
import {
|
|
30
|
+
BRIDGE_PORTS,
|
|
31
|
+
EXTENSION_CONNECTED_WINDOW_MS,
|
|
32
|
+
type SessionBridgeOptions,
|
|
33
|
+
} from './extension-bridge.ts'
|
|
34
|
+
|
|
35
|
+
export type HealthWatchOutcome =
|
|
36
|
+
| { ready: true; attempts: number; waitedMs: number }
|
|
37
|
+
| { ready: false; reason: 'timeout' | 'cancelled'; attempts: number; waitedMs: number }
|
|
38
|
+
|
|
39
|
+
export interface HealthWatchOptions extends SessionBridgeOptions {
|
|
40
|
+
/** 最长等多久(默认 120s);0 = 立即 timeout(便于测试) */
|
|
41
|
+
timeoutMs?: number
|
|
42
|
+
/** 探活间隔(默认 5s);必须 < EXTENSION_CONNECTED_WINDOW_MS,否则抖动期漏心跳 */
|
|
43
|
+
intervalMs?: number
|
|
44
|
+
/** 探活函数:返回 true 即视为扩展就绪(测试可注入 fake probe)。
|
|
45
|
+
* 默认走双因子:① 心跳 `/status` extensionConnected === true
|
|
46
|
+
* + ② 业务探针 cookie-names 真回包(timeout 5s);二者同时通过才算真 ready。
|
|
47
|
+
* 仅心跳通过但 cookie-names 失败 = 扩展 SW 跑着但 chrome.cookies 权限缺 → 不算 ready。 */
|
|
48
|
+
probe?: () => Promise<boolean>
|
|
49
|
+
/** 最大重放次数(默认 1);防止 ready 抖动反复触发 query 重放 */
|
|
50
|
+
maxRetries?: number
|
|
51
|
+
/** 取消信号(可选)——给调用方提供立刻终止 watch 的逃生口 */
|
|
52
|
+
signal?: AbortSignal
|
|
53
|
+
/** 时间源(测试可注入 fake clock) */
|
|
54
|
+
now?: () => number
|
|
55
|
+
/**
|
|
56
|
+
* 是否启用业务探针(cookie-names)。wizard 默认 true(用户刚装扩展,首次需要双因子验证);
|
|
57
|
+
* 上线 session 检索可传 false(快速心跳足矣,业务探针会另发一次请求)。
|
|
58
|
+
*/
|
|
59
|
+
businessProbe?: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface HealthWatchController {
|
|
63
|
+
/** Promise:resolve 时扩展已 ready,可执行重放 */
|
|
64
|
+
waitReady(): Promise<HealthWatchOutcome>
|
|
65
|
+
/** 立刻取消;waitReady 立即 resolve {ready:false, reason:'cancelled'} */
|
|
66
|
+
cancel(reason?: string): void
|
|
67
|
+
/** 当前 ready 次数(供诊断/审计) */
|
|
68
|
+
readonly readyCount: number
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface InternalState {
|
|
72
|
+
cancelled: boolean
|
|
73
|
+
readyCount: number
|
|
74
|
+
resolveOutcome: ((o: HealthWatchOutcome) => void) | null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 双因子 probe(默认):心跳 + 业务探针(cookie-names)。
|
|
79
|
+
* 心跳通过只能证 SW 跑着;业务探针通过证 chrome.cookies 权限齐——二者同过才算
|
|
80
|
+
* 扩展真正 ready。wizard 必须双因子(用户刚装的扩展可能权限不全),上线 session
|
|
81
|
+
* 检索可传 `businessProbe:false` 跳业务探针(避免每次检索额外打一次 cookie-names)。
|
|
82
|
+
*
|
|
83
|
+
* 不直接 import extension-channel.ts(避免环)—用 dynamic import 懒拿。
|
|
84
|
+
*/
|
|
85
|
+
export async function combinedProbe(opts: HealthWatchOptions, businessProbe: boolean): Promise<boolean> {
|
|
86
|
+
if (!(await defaultProbe(opts))) return false
|
|
87
|
+
if (!businessProbe) return true
|
|
88
|
+
try {
|
|
89
|
+
// 动态 import 避免 health-watch ↔ extension-channel 双向环
|
|
90
|
+
const channel = await import('./extension-channel.ts')
|
|
91
|
+
const bridge = await import('./extension-bridge.ts')
|
|
92
|
+
// 双因子探针需要常驻桥(wizard 期间):keepBridge=true 透传到 bridge 单例
|
|
93
|
+
bridge.getOrCreateSessionBridge({ keepBridge: opts.keepBridge === true })
|
|
94
|
+
// 心跳已 ready(刚 defaultProbe 通过)= 扩展一定在线;cookie-names 超时给 8s,
|
|
95
|
+
// 比心跳间隔 5s 长——保证 tick 间不会撞死 cookie job
|
|
96
|
+
const cookie = await channel.extensionCookieNames({
|
|
97
|
+
site: 'ctrip-flight',
|
|
98
|
+
domain: 'ctrip.com',
|
|
99
|
+
ticketNames: ['cticket', 'uid'],
|
|
100
|
+
timeoutMs: 8_000,
|
|
101
|
+
})
|
|
102
|
+
// cookie-names.ok = true 表示扩展 SW 真能调 chrome.cookies.getAll(权限齐全);
|
|
103
|
+
// cookie-names.ok = true + names = [] 也算 ok(用户没登携程但扩展权限齐)。
|
|
104
|
+
// cookie-names.ok = false 分两类:bridge-unavailable/extension-not-connected(心跳 false 时) / timeout(扩展没回包)——
|
|
105
|
+
// 后者更可能是 SW 卡了,不应当 ready。但 wizard 必须严格:让用户明确知道权限缺或登录缺。
|
|
106
|
+
return cookie.ok
|
|
107
|
+
} catch {
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 默认 probe:GET http://127.0.0.1:{port}/status,看 extensionConnected: true
|
|
114
|
+
* (扩展心跳 ≤30s 一刷,窗口 EXTENSION_CONNECTED_WINDOW_MS=45s 容差)。
|
|
115
|
+
* 不在端口池内挑一个试——端口是已启动桥的事实,直接拿 opts.ports[0] 或 8791。
|
|
116
|
+
* 测试可注入 fake probe 跳过网络。
|
|
117
|
+
*/
|
|
118
|
+
export async function defaultProbe(opts: HealthWatchOptions): Promise<boolean> {
|
|
119
|
+
const ports = opts.ports ?? [...BRIDGE_PORTS]
|
|
120
|
+
for (const port of ports) {
|
|
121
|
+
const ok = await new Promise<boolean>((resolve) => {
|
|
122
|
+
const req = request(
|
|
123
|
+
{ host: '127.0.0.1', port, path: '/status', method: 'GET', timeout: 2_000 },
|
|
124
|
+
(res) => {
|
|
125
|
+
if (!res.statusCode || res.statusCode >= 400) { resolve(false); return }
|
|
126
|
+
let buf = ''
|
|
127
|
+
res.setEncoding('utf8')
|
|
128
|
+
res.on('data', (c: string) => { buf += c })
|
|
129
|
+
res.on('end', () => {
|
|
130
|
+
try {
|
|
131
|
+
const payload = JSON.parse(buf) as { extensionConnected?: boolean }
|
|
132
|
+
resolve(payload.extensionConnected === true)
|
|
133
|
+
} catch { resolve(false) }
|
|
134
|
+
})
|
|
135
|
+
res.on('error', () => resolve(false))
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
req.on('error', () => resolve(false))
|
|
139
|
+
req.on('timeout', () => { req.destroy(); resolve(false) })
|
|
140
|
+
req.end()
|
|
141
|
+
})
|
|
142
|
+
if (ok) return true
|
|
143
|
+
}
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function startExtensionHealthWatch(opts: HealthWatchOptions = {}): HealthWatchController {
|
|
148
|
+
const timeoutMs = opts.timeoutMs ?? 120_000
|
|
149
|
+
const intervalMs = opts.intervalMs ?? 5_000
|
|
150
|
+
const maxRetries = opts.maxRetries ?? 1
|
|
151
|
+
const businessProbe = opts.businessProbe ?? true
|
|
152
|
+
// 关键:wizard 期间需要常驻桥(keepBridge=true 透传到 getOrCreateSessionBridge),
|
|
153
|
+
// 否则启桥后 wizard main 一退桥就退,扩展心跳发不进来。
|
|
154
|
+
// 用 dynamic import 避免与 extension-bridge 顶层环(channel 也引 bridge,但 health-watch 引 channel/bridge 双向会死循环)
|
|
155
|
+
const probe = opts.probe ?? (async () => {
|
|
156
|
+
// 先确保桥存在 + keepBridge
|
|
157
|
+
const bridge = await import('./extension-bridge.ts')
|
|
158
|
+
await bridge.getOrCreateSessionBridge({ keepBridge: opts.keepBridge === true })
|
|
159
|
+
return combinedProbe(opts, businessProbe)
|
|
160
|
+
})
|
|
161
|
+
const now = opts.now ?? Date.now
|
|
162
|
+
const state: InternalState = { cancelled: false, readyCount: 0, resolveOutcome: null }
|
|
163
|
+
|
|
164
|
+
const finish = (outcome: HealthWatchOutcome): void => {
|
|
165
|
+
if (state.resolveOutcome) {
|
|
166
|
+
const resolve = state.resolveOutcome
|
|
167
|
+
state.resolveOutcome = null
|
|
168
|
+
resolve(outcome)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 取消信号 -> 立刻 timeout-ish 终态(不进 ready 路径)
|
|
173
|
+
const onAbort = (): void => {
|
|
174
|
+
state.cancelled = true
|
|
175
|
+
finish({ ready: false, reason: 'cancelled', attempts: state.readyCount, waitedMs: 0 })
|
|
176
|
+
}
|
|
177
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true })
|
|
178
|
+
|
|
179
|
+
const waitReady = (): Promise<HealthWatchOutcome> => new Promise<HealthWatchOutcome>((resolve) => {
|
|
180
|
+
if (state.cancelled) { resolve({ ready: false, reason: 'cancelled', attempts: 0, waitedMs: 0 }); return }
|
|
181
|
+
if (timeoutMs === 0) { resolve({ ready: false, reason: 'timeout', attempts: 0, waitedMs: 0 }); return }
|
|
182
|
+
state.resolveOutcome = resolve
|
|
183
|
+
const deadline = now() + timeoutMs
|
|
184
|
+
let attempts = 0
|
|
185
|
+
let waitedMs = 0
|
|
186
|
+
|
|
187
|
+
const tick = async (): Promise<void> => {
|
|
188
|
+
if (state.cancelled || state.resolveOutcome === null) return
|
|
189
|
+
attempts += 1
|
|
190
|
+
try {
|
|
191
|
+
const ready = await probe()
|
|
192
|
+
if (ready && !state.cancelled) {
|
|
193
|
+
state.readyCount += 1
|
|
194
|
+
waitedMs = timeoutMs - Math.max(0, deadline - now())
|
|
195
|
+
// readyCount > maxRetries → 视为抖动,继续等(避免一次性重放洪流)
|
|
196
|
+
if (state.readyCount <= maxRetries) {
|
|
197
|
+
finish({ ready: true, attempts, waitedMs: waitedMs > 0 ? waitedMs : 0 })
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch { /* probe 失败继续轮询,不挡主流程 */ }
|
|
202
|
+
waitedMs = timeoutMs - Math.max(0, deadline - now())
|
|
203
|
+
if (now() >= deadline) {
|
|
204
|
+
finish({ ready: false, reason: 'timeout', attempts, waitedMs })
|
|
205
|
+
return
|
|
206
|
+
}
|
|
207
|
+
// 间隔 = intervalMs;若剩余时间 < intervalMs 则最后一次等剩余时间
|
|
208
|
+
const remaining = deadline - now()
|
|
209
|
+
const delay = Math.min(intervalMs, Math.max(0, remaining))
|
|
210
|
+
if (delay <= 0) {
|
|
211
|
+
finish({ ready: false, reason: 'timeout', attempts, waitedMs })
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
setTimeout(tick, delay)
|
|
215
|
+
}
|
|
216
|
+
void tick()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
waitReady,
|
|
221
|
+
cancel: () => onAbort(),
|
|
222
|
+
get readyCount() { return state.readyCount },
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 自动重放 wrapper:首次调用走 fetch(返回 needs-extension) → 启 watch →
|
|
228
|
+
* 扩展 ready → 同 query 重放。语义:用户侧**只看到一条 query 的最终结果**;
|
|
229
|
+
* 中间产物(query_id 标记 + 尝试次数)落 evidence 文件,便于离线追溯。
|
|
230
|
+
*
|
|
231
|
+
* 这是 §3.3「用户零手工重跑」的对外契约:调用方把 fetch 包成 withAutoRetryOnExtension,
|
|
232
|
+
* 用户装完扩展→自然出结果;用户不装→等 120s 后返回 needs-extension,而不是「再跑一次试试」。
|
|
233
|
+
*/
|
|
234
|
+
export async function withAutoRetryOnExtension<T>(args: {
|
|
235
|
+
fetch: () => Promise<T>
|
|
236
|
+
isExtensionNeeded: (result: T) => boolean
|
|
237
|
+
/** watch 配置(测试可注入 fake probe) */
|
|
238
|
+
watch?: HealthWatchOptions
|
|
239
|
+
/** 重放 fetcher(默认复用 fetch) */
|
|
240
|
+
retry?: () => Promise<T>
|
|
241
|
+
}): Promise<{ result: T; retried: boolean; attempts: number; outcome: HealthWatchOutcome }> {
|
|
242
|
+
const first = await args.fetch()
|
|
243
|
+
if (!args.isExtensionNeeded(first)) {
|
|
244
|
+
return { result: first, retried: false, attempts: 1, outcome: { ready: true, attempts: 0, waitedMs: 0 } }
|
|
245
|
+
}
|
|
246
|
+
const watch = startExtensionHealthWatch(args.watch ?? {})
|
|
247
|
+
const outcome = await watch.waitReady()
|
|
248
|
+
if (!outcome.ready) {
|
|
249
|
+
return { result: first, retried: false, attempts: 1, outcome }
|
|
250
|
+
}
|
|
251
|
+
const retriedResult = await (args.retry ?? args.fetch)()
|
|
252
|
+
return { result: retriedResult, retried: true, attempts: 2, outcome }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 留作运行时只读检查,防止扩展心跳节律假设漂移(§38 防漂移同款语义) */
|
|
256
|
+
export const HEALTH_WATCH_INTERVAL_FLOOR_MS = 1_000
|
|
257
|
+
export const HEALTH_WATCH_INTERVAL_CEIL_MS = EXTENSION_CONNECTED_WINDOW_MS - 1_000
|