@danceiny/gotry 0.0.1-rc.17 → 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.
Files changed (137) hide show
  1. package/README.md +185 -148
  2. package/README.zh-CN.md +195 -154
  3. package/bin/gotry-bootstrap.js +246 -26
  4. package/bin/gotry-inner.js +68 -38
  5. package/bin/gotry-runtime-resolution.d.ts +1 -1
  6. package/bin/gotry-runtime-resolution.js +7 -8
  7. package/cordis.gotry-patch.yml +15 -5
  8. package/data/stations-12306-verify.json +524 -0
  9. package/dist/capabilities/agent-reach.js +4 -4
  10. package/dist/capabilities/channel-health.js +103 -0
  11. package/dist/capabilities/channel-registry.js +213 -0
  12. package/dist/capabilities/doctor.js +285 -0
  13. package/dist/capabilities/effect.js +178 -5
  14. package/dist/capabilities/flyai.js +13 -0
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +207 -0
  16. package/dist/capabilities/session/adapters/rail-12306.js +285 -0
  17. package/dist/capabilities/session/extension-bridge.js +14 -1
  18. package/dist/capabilities/session/extension-channel.js +4 -2
  19. package/dist/capabilities/session/extension-distribution.js +1 -1
  20. package/dist/capabilities/session-search.js +311 -0
  21. package/dist/capabilities/visa-policy.js +126 -0
  22. package/dist/scripts/agent-planning-turn-deadline-e2e.js +304 -0
  23. package/dist/scripts/agent-planning-turn-deadline-tests.js +281 -0
  24. package/dist/scripts/benchmark-environment-bridge-e2e.js +5 -10
  25. package/dist/scripts/benchmark-environment-bridge-tests.js +148 -142
  26. package/dist/scripts/booking-copilot-availability-ledger-binding-tests.js +26 -26
  27. package/dist/scripts/{booking-copilot-availability-policy-v2-tests.js → booking-copilot-availability-policy-tests.js} +115 -115
  28. package/dist/scripts/booking-copilot-crossrepo-fixture-server.js +41 -1
  29. package/dist/scripts/booking-copilot-dsh-core-proof-tests.js +75 -120
  30. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +370 -61
  31. package/dist/scripts/booking-copilot-dsh-plugin-proof-tests.js +2 -2
  32. package/dist/scripts/booking-copilot-event-sequence-concurrency-proof-tests.js +7 -3
  33. package/dist/scripts/booking-copilot-gap-code-contract-proof-tests.js +15 -9
  34. package/dist/scripts/booking-copilot-operation-ledger-concurrency-proof-tests.js +19 -11
  35. package/dist/scripts/booking-copilot-receipt-ledger-concurrency-proof-tests.js +16 -52
  36. package/dist/scripts/booking-copilot-runtime-proof-tests.js +3953 -155
  37. package/dist/scripts/booking-copilot-server-proof-tests.js +58 -23
  38. package/dist/scripts/booking-copilot-startup-proof-tests.js +10 -119
  39. package/dist/scripts/booking-surface-contract-proof-tests.js +1154 -326
  40. package/dist/scripts/bootstrap-tests.js +115 -44
  41. package/dist/scripts/build-metrics-report.js +339 -0
  42. package/dist/scripts/channel-probe-tests.js +148 -0
  43. package/dist/scripts/channel-probe.js +252 -0
  44. package/dist/scripts/channel-registry-tests.js +262 -0
  45. package/dist/scripts/doctor-tests.js +121 -0
  46. package/dist/scripts/effect-tests.js +274 -1
  47. package/dist/scripts/extension-tests.js +129 -11
  48. package/dist/scripts/fact-gate-tests.js +91 -2
  49. package/dist/scripts/flyai-tests.js +17 -1
  50. package/dist/scripts/hbcli-e2e-tests.js +1 -3
  51. package/dist/scripts/metrics-report-tests.js +288 -0
  52. package/dist/scripts/persona-surface-guard-tests.js +19 -0
  53. package/dist/scripts/session-tests.js +401 -1
  54. package/dist/scripts/smoke.js +100 -83
  55. package/dist/scripts/turn-handoff-collect-tests.js +216 -0
  56. package/dist/scripts/turn-handoff-collect.js +189 -0
  57. package/dist/scripts/turn-policy-tests.js +56 -0
  58. package/dist/scripts/typed-contract-canary.js +261 -0
  59. package/dist/scripts/visa-policy-tests.js +75 -0
  60. package/dist/scripts/wish-channel-gate-tests.js +111 -0
  61. package/dist/src/artifact-gate.js +91 -3
  62. package/dist/src/benchmark-agent-conformance.js +7 -4
  63. package/dist/src/benchmark-environment-bridge.js +68 -7
  64. package/dist/src/bookable-facts.js +62 -3
  65. package/dist/src/booking-surface/{availability-policy-v2.js → availability-policy.js} +89 -89
  66. package/dist/src/booking-surface/canonical-schema.js +8 -22
  67. package/dist/src/booking-surface/contracts.js +70 -19
  68. package/dist/src/booking-surface/dsh-planner.js +310 -177
  69. package/dist/src/booking-surface/dsh-plugin.js +3 -3
  70. package/dist/src/booking-surface/index.js +1 -5
  71. package/dist/src/booking-surface/profile.js +1 -1
  72. package/dist/src/booking-surface/runtime.js +1682 -247
  73. package/dist/src/booking-surface/server.js +371 -185
  74. package/dist/src/booking-surface/startup.js +17 -27
  75. package/dist/src/booking-surface/validation.js +270 -760
  76. package/dist/src/index.js +735 -149
  77. package/dist/src/turn-deadline.js +292 -0
  78. package/dist/src/turn-policy.js +146 -0
  79. package/dist/src/wish-pool.js +5 -0
  80. package/extension/background.js +23 -4
  81. package/extension/content-main.js +47 -9
  82. package/extension/manifest.json +27 -9
  83. package/package.json +6 -26
  84. package/schemas/booking.surface.schema.json +2593 -0
  85. package/ts/capabilities/agent-reach.ts +4 -4
  86. package/ts/capabilities/flyai.ts +21 -3
  87. package/ts/capabilities/session/action-cache.ts +1 -1
  88. package/ts/capabilities/session/adapters/ctrip-hotel.ts +238 -0
  89. package/ts/capabilities/session/adapters/rail-12306.ts +293 -0
  90. package/ts/capabilities/session/extension-bridge.ts +32 -2
  91. package/ts/capabilities/session/extension-channel.ts +9 -8
  92. package/ts/capabilities/session/extension-distribution.ts +3 -1
  93. package/ts/capabilities/session/health-watch.ts +1 -1
  94. package/ts/capabilities/session/read-guard.ts +1 -1
  95. package/ts/capabilities/session/wizard.ts +1 -1
  96. package/ts/capabilities/session-search.ts +323 -1
  97. package/ts/package.json +1 -0
  98. package/ts/scripts/turn-handoff-collect.ts +178 -0
  99. package/ts/src/artifact-gate.ts +82 -2
  100. package/ts/src/benchmark-agent-conformance.ts +14 -6
  101. package/ts/src/benchmark-environment-bridge.ts +29 -10
  102. package/ts/src/bookable-facts.ts +109 -5
  103. package/ts/src/booking-saga.ts +1 -1
  104. package/ts/src/booking-surface/{availability-policy-v2.ts → availability-policy.ts} +124 -125
  105. package/ts/src/booking-surface/canonical-schema.js +8 -22
  106. package/ts/src/booking-surface/contracts.ts +278 -239
  107. package/ts/src/booking-surface/dsh-planner.ts +333 -194
  108. package/ts/src/booking-surface/dsh-plugin.js +3 -3
  109. package/ts/src/booking-surface/index.ts +1 -5
  110. package/ts/src/booking-surface/profile.ts +11 -11
  111. package/ts/src/booking-surface/runtime.ts +1351 -351
  112. package/ts/src/booking-surface/server.ts +282 -175
  113. package/ts/src/booking-surface/startup.ts +36 -48
  114. package/ts/src/booking-surface/validation.ts +194 -442
  115. package/ts/src/contracts.ts +1 -1
  116. package/ts/src/index.ts +489 -171
  117. package/ts/src/loop.ts +1 -1
  118. package/ts/src/state-ledger.ts +1 -1
  119. package/ts/src/turn-deadline.ts +361 -0
  120. package/ts/src/turn-policy.ts +154 -0
  121. package/ts/src/wish-pool.ts +17 -4
  122. package/dist/scripts/agent-planning-budget-e2e.js +0 -227
  123. package/dist/scripts/agent-planning-budget-tests.js +0 -173
  124. package/dist/scripts/booking-copilot-v2-runtime-proof-tests.js +0 -3935
  125. package/dist/scripts/booking-surface-v2-contract-proof-tests.js +0 -1174
  126. package/dist/src/booking-surface/contracts-v2.js +0 -89
  127. package/dist/src/booking-surface/runtime-v2.js +0 -1771
  128. package/dist/src/booking-surface/server-v2.js +0 -334
  129. package/dist/src/booking-surface/validation-v2.js +0 -319
  130. package/dist/src/tool-budget.js +0 -136
  131. package/schemas/booking.surface.v1.schema.json +0 -927
  132. package/schemas/booking.surface.v2.schema.json +0 -61
  133. package/ts/src/booking-surface/contracts-v2.ts +0 -118
  134. package/ts/src/booking-surface/runtime-v2.ts +0 -1466
  135. package/ts/src/booking-surface/server-v2.ts +0 -247
  136. package/ts/src/booking-surface/validation-v2.ts +0 -205
  137. package/ts/src/tool-budget.ts +0 -165
@@ -0,0 +1,148 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, writeFile, mkdir, readFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { evaluateProbeResults, defaultProbes, parseArgs, main } from './channel-probe.js';
6
+ import { readLatestChannelEvents, recordChannelEvent } from '../capabilities/channel-health.js';
7
+ import { aggregateChannels } from './build-metrics-report.js';
8
+ const tmp = await mkdtemp(join(tmpdir(), 'gotry-probe-test-'));
9
+ const probes = defaultProbes();
10
+ const hb = probes.find((p)=>p.channel === 'hbcli-hotel');
11
+ const om = probes.find((p)=>p.channel === 'open-meteo');
12
+ const fly = probes.find((p)=>p.channel === 'flyai');
13
+ const now = new Date('2026-09-07T12:00:00Z');
14
+ const evs = evaluateProbeResults([
15
+ {
16
+ probe: hb,
17
+ result: {
18
+ ok: false,
19
+ reason: 'auth-denied',
20
+ ms: 5
21
+ }
22
+ },
23
+ {
24
+ probe: om,
25
+ result: {
26
+ ok: true,
27
+ ms: 100
28
+ }
29
+ },
30
+ {
31
+ probe: fly,
32
+ result: {
33
+ ok: true,
34
+ ms: 0
35
+ }
36
+ }
37
+ ], new Set([
38
+ 'open-meteo'
39
+ ]), now);
40
+ assert.equal(evs.length, 2, `fail→down + ok恢复→'ok' 共 2 条,实际 ${evs.length}`);
41
+ assert.deepEqual({
42
+ channel: evs[0].channel,
43
+ state: evs[0].state,
44
+ reason: evs[0].reason
45
+ }, {
46
+ channel: 'hbcli-hotel',
47
+ state: 'down',
48
+ reason: 'auth-denied'
49
+ }, 'fail 应产 down 事件带 reason');
50
+ assert.deepEqual({
51
+ channel: evs[1].channel,
52
+ state: evs[1].state
53
+ }, {
54
+ channel: 'open-meteo',
55
+ state: 'ok'
56
+ }, 'ok 且此前 down 应产 ok 恢复事件');
57
+ const noWrites = evaluateProbeResults([
58
+ {
59
+ probe: om,
60
+ result: {
61
+ ok: true,
62
+ ms: 90
63
+ }
64
+ }
65
+ ], new Set(), now);
66
+ assert.equal(noWrites.length, 0, 'ok 且此前健康 → 零写放大');
67
+ const stateRoot = join(tmp, 'state');
68
+ await mkdir(join(stateRoot, 'gotry-state'), {
69
+ recursive: true
70
+ });
71
+ const jsonl = join(stateRoot, 'gotry-state', 'channel-health.jsonl');
72
+ await writeFile(jsonl, [
73
+ JSON.stringify({
74
+ channel: 'opensky',
75
+ state: 'down',
76
+ reason: 'http-503',
77
+ at: '2026-09-07T10:00:00.000Z'
78
+ }),
79
+ JSON.stringify({
80
+ channel: 'opensky',
81
+ state: 'ok',
82
+ at: '2026-09-07T11:00:00.000Z'
83
+ }),
84
+ JSON.stringify({
85
+ channel: 'hbcli-hotel',
86
+ state: 'down',
87
+ reason: 'auth-denied',
88
+ at: '2026-09-07T10:30:00.000Z'
89
+ }),
90
+ 'not-json-garbage',
91
+ ''
92
+ ].join('\n'), 'utf-8');
93
+ const latest = await readLatestChannelEvents(stateRoot, {
94
+ now: Date.parse('2026-09-07T12:00:00Z')
95
+ });
96
+ assert.equal(latest.get('opensky')?.state, 'ok', "'ok' 应超越同通道更早的 down");
97
+ assert.equal(latest.get('hbcli-hotel')?.state, 'down', '未恢复通道保持 down');
98
+ assert.equal(latest.size, 2, '坏行跳过,latest 只含两通道');
99
+ const openskyEv = latest.get('opensky');
100
+ assert.ok(openskyEv && openskyEv.state !== 'down', 'doctor 的 down 判定应不再命中已恢复通道');
101
+ const rows = (await readFile(jsonl, 'utf-8')).split('\n').filter((l)=>l.trim()).flatMap((l)=>{
102
+ try {
103
+ return [
104
+ JSON.parse(l)
105
+ ];
106
+ } catch {
107
+ return [];
108
+ }
109
+ });
110
+ const stats = aggregateChannels(rows, {
111
+ now: new Date('2026-09-07T12:00:00Z'),
112
+ windowDays: 30
113
+ });
114
+ const openskyStat = stats.latest.find((c)=>c.channel === 'opensky');
115
+ assert.equal(openskyStat, undefined, 'ok 超越的通道不应出现在当前 down/cooldown newest 面');
116
+ const hbStat = stats.latest.find((c)=>c.channel === 'hbcli-hotel');
117
+ assert.ok(hbStat && hbStat.state === 'down', '未恢复通道仍在 newest 面');
118
+ const listed = defaultProbes();
119
+ assert.ok(listed.find((p)=>p.channel === 'flyai')?.probeable === false, 'flyai 默认不探(共享额度)');
120
+ assert.ok(listed.filter((p)=>p.channel.startsWith('session:')).every((p)=>!p.probeable), 'session:* 全部 skip');
121
+ assert.deepEqual(parseArgs([
122
+ '--state-root',
123
+ '/tmp/x',
124
+ '--only',
125
+ 'a, b',
126
+ '--dry-run'
127
+ ]), {
128
+ stateRoot: '/tmp/x',
129
+ only: [
130
+ 'a',
131
+ 'b'
132
+ ],
133
+ dryRun: true,
134
+ list: false
135
+ }, 'parseArgs 解析');
136
+ const rcList = await main([
137
+ '--list'
138
+ ]);
139
+ assert.equal(rcList, 0, '--list exit 0');
140
+ const rcBad = await main([
141
+ '--only',
142
+ 'no-such-channel'
143
+ ]);
144
+ assert.equal(rcBad, 2, '--only 未匹配 exit 2');
145
+ console.log('CHANNEL PROBE TESTS: 5/5 OK(evaluate 纯函数 / latest-wins 恢复 / doctor 口径 / metrics 超越 / CLI 面)');
146
+
147
+
148
+ //# sourceURL=ts/scripts/channel-probe-tests.ts
@@ -0,0 +1,252 @@
1
+ import { recordChannelEvent, readLatestChannelEvents } from '../capabilities/channel-health.js';
2
+ function timeout(ms) {
3
+ return AbortSignal.timeout(ms);
4
+ }
5
+ async function fetchOk(url, what, ms = 8000) {
6
+ const t0 = Date.now();
7
+ try {
8
+ const res = await fetch(url, {
9
+ signal: timeout(ms),
10
+ headers: {
11
+ Accept: 'application/json'
12
+ }
13
+ });
14
+ if (res.ok) return {
15
+ ok: true,
16
+ ms: Date.now() - t0
17
+ };
18
+ return {
19
+ ok: false,
20
+ reason: `${what}-http-${res.status}`,
21
+ ms: Date.now() - t0
22
+ };
23
+ } catch (err) {
24
+ const msg = err instanceof Error ? err.name : String(err);
25
+ return {
26
+ ok: false,
27
+ reason: msg === 'TimeoutError' ? 'timeout' : 'unreachable',
28
+ ms: Date.now() - t0
29
+ };
30
+ }
31
+ }
32
+ function spawnOk(cmd, args, what, ms = 10000) {
33
+ return async ()=>{
34
+ const t0 = Date.now();
35
+ try {
36
+ const { spawn } = await import('node:child_process');
37
+ const child = spawn(cmd, args, {
38
+ stdio: [
39
+ 'ignore',
40
+ 'ignore',
41
+ 'ignore'
42
+ ]
43
+ });
44
+ const code = await new Promise((resolve, reject)=>{
45
+ const timer = setTimeout(()=>{
46
+ child.kill('SIGKILL');
47
+ reject(new Error('timeout'));
48
+ }, ms);
49
+ child.on('exit', (c)=>{
50
+ clearTimeout(timer);
51
+ resolve(c ?? 1);
52
+ });
53
+ child.on('error', (e)=>{
54
+ clearTimeout(timer);
55
+ reject(e);
56
+ });
57
+ });
58
+ return code === 0 ? {
59
+ ok: true,
60
+ ms: Date.now() - t0
61
+ } : {
62
+ ok: false,
63
+ reason: `${what}-exit-${code}`,
64
+ ms: Date.now() - t0
65
+ };
66
+ } catch (err) {
67
+ const msg = err instanceof Error ? err.message : String(err);
68
+ return {
69
+ ok: false,
70
+ reason: msg === 'timeout' ? 'timeout' : 'spawn-failed',
71
+ ms: Date.now() - t0
72
+ };
73
+ }
74
+ };
75
+ }
76
+ export function defaultProbes() {
77
+ return [
78
+ {
79
+ channel: 'hbcli-hotel',
80
+ probeable: true,
81
+ run: spawnOk('hbcli', [
82
+ 'auth',
83
+ 'whoami'
84
+ ], 'auth-denied')
85
+ },
86
+ {
87
+ channel: 'open-meteo',
88
+ probeable: true,
89
+ run: ()=>fetchOk('https://api.open-meteo.com/v1/forecast?latitude=25.2048&longitude=55.2708&current=temperature_2m', 'weather')
90
+ },
91
+ {
92
+ channel: 'opensky',
93
+ probeable: true,
94
+ run: ()=>fetchOk('https://opensky-network.org/api/states/all?lamin=24.0&lomin=53.0&lamax=26.5&lomax=56.5', 'flights', 12000)
95
+ },
96
+ {
97
+ channel: 'flyai',
98
+ probeable: false,
99
+ skipReason: 'anonymous-shared-quota——探针烧额度弊大于利;--only flyai 显式选入',
100
+ run: async ()=>({
101
+ ok: true,
102
+ ms: 0
103
+ })
104
+ },
105
+ {
106
+ channel: 'session:ctrip-flight',
107
+ probeable: false,
108
+ skipReason: '需用户 Chrome 会话,无头不可探',
109
+ run: async ()=>({
110
+ ok: true,
111
+ ms: 0
112
+ })
113
+ },
114
+ {
115
+ channel: 'session:ctrip-hotel',
116
+ probeable: false,
117
+ skipReason: '需用户 Chrome 会话,无头不可探',
118
+ run: async ()=>({
119
+ ok: true,
120
+ ms: 0
121
+ })
122
+ },
123
+ {
124
+ channel: 'session:12306-train',
125
+ probeable: false,
126
+ skipReason: '需用户 Chrome 会话,无头不可探',
127
+ run: async ()=>({
128
+ ok: true,
129
+ ms: 0
130
+ })
131
+ },
132
+ {
133
+ channel: 'anything-geo',
134
+ probeable: false,
135
+ skipReason: '静态包通道,无远端可探',
136
+ run: async ()=>({
137
+ ok: true,
138
+ ms: 0
139
+ })
140
+ },
141
+ {
142
+ channel: 'static-hotel',
143
+ probeable: false,
144
+ skipReason: '静态包通道,无远端可探',
145
+ run: async ()=>({
146
+ ok: true,
147
+ ms: 0
148
+ })
149
+ },
150
+ {
151
+ channel: 'web-read',
152
+ probeable: false,
153
+ skipReason: '依赖 agent-reach 装配态,探测意义低;--only 显式选入可探',
154
+ run: async ()=>({
155
+ ok: true,
156
+ ms: 0
157
+ })
158
+ }
159
+ ];
160
+ }
161
+ export function evaluateProbeResults(results, priorDown, now = new Date()) {
162
+ const events = [];
163
+ for (const { probe, result } of results){
164
+ if (!probe.probeable) continue;
165
+ if (result.ok) {
166
+ if (priorDown.has(probe.channel)) {
167
+ events.push({
168
+ channel: probe.channel,
169
+ state: 'ok',
170
+ at: now.toISOString()
171
+ });
172
+ }
173
+ continue;
174
+ }
175
+ events.push({
176
+ channel: probe.channel,
177
+ state: 'down',
178
+ reason: result.reason,
179
+ at: now.toISOString()
180
+ });
181
+ }
182
+ return events;
183
+ }
184
+ export function parseArgs(argv) {
185
+ let stateRoot;
186
+ let only;
187
+ let dryRun = false;
188
+ let list = false;
189
+ for(let i = 0; i < argv.length; i += 1){
190
+ const a = argv[i];
191
+ if (a === '--state-root') stateRoot = argv[++i];
192
+ else if (a === '--only') only = (argv[++i] ?? '').split(',').map((s)=>s.trim()).filter(Boolean);
193
+ else if (a === '--dry-run') dryRun = true;
194
+ else if (a === '--list') list = true;
195
+ }
196
+ return {
197
+ stateRoot,
198
+ only,
199
+ dryRun,
200
+ list
201
+ };
202
+ }
203
+ export async function main(argv) {
204
+ const args = parseArgs(argv);
205
+ const all = defaultProbes();
206
+ if (args.list) {
207
+ for (const p of all){
208
+ console.log(`${p.probeable ? 'probe' : 'skip '} ${p.channel}${p.skipReason ? ` # ${p.skipReason}` : ''}`);
209
+ }
210
+ return 0;
211
+ }
212
+ const selected = args.only?.length ? all.filter((p)=>args.only.includes(p.channel)) : all.filter((p)=>p.probeable);
213
+ if (selected.length === 0) {
214
+ console.error('[channel-probe] --only 未匹配任何通道;--list 查看探测面');
215
+ return 2;
216
+ }
217
+ const results = [];
218
+ for (const probe of selected){
219
+ const result = await probe.run();
220
+ results.push({
221
+ probe,
222
+ result
223
+ });
224
+ console.log(`[channel-probe] ${result.ok ? 'ok ' : 'FAIL'} ${probe.channel} (${result.ms}ms)${result.ok ? '' : ` reason=${result.reason}`}`);
225
+ }
226
+ const failures = results.filter((r)=>!r.result.ok).length;
227
+ if (args.dryRun || !args.stateRoot) {
228
+ if (!args.stateRoot) console.log('[channel-probe] 未给 --state-root:只探测不落账(等价 --dry-run)');
229
+ return failures > 0 ? 1 : 0;
230
+ }
231
+ let priorDown = new Set();
232
+ try {
233
+ const latest = await readLatestChannelEvents(args.stateRoot);
234
+ priorDown = new Set([
235
+ ...latest.entries()
236
+ ].filter(([, ev])=>ev.state === 'down' || ev.state === 'cooldown').map(([ch])=>ch));
237
+ } catch {}
238
+ for (const ev of evaluateProbeResults(results, priorDown)){
239
+ await recordChannelEvent(args.stateRoot, ev);
240
+ console.log(`[channel-probe] 记录 ${ev.state} ${ev.channel}${ev.reason ? `(${ev.reason})` : ''}`);
241
+ }
242
+ return failures > 0 ? 1 : 0;
243
+ }
244
+ if (import.meta.url === `file://${process.argv[1]}`) {
245
+ main(process.argv.slice(2)).then((code)=>process.exit(code)).catch((err)=>{
246
+ console.error('[channel-probe] fatal:', err instanceof Error ? err.message : err);
247
+ process.exit(1);
248
+ });
249
+ }
250
+
251
+
252
+ //# sourceURL=ts/scripts/channel-probe.ts
@@ -0,0 +1,262 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdtemp, mkdir, writeFile, rm, readFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { CHANNELS, channelsForIntent, routingAdvice, renderRoutingCard, toolRoutingHeadline, INTENT_LABELS } from '../capabilities/channel-registry.js';
6
+ import { noteChannelVerdict, channelState, clearChannel, resetChannelHealth, recordChannelEvent, readLatestChannelEvents } from '../capabilities/channel-health.js';
7
+ import { runDoctorChecks } from '../capabilities/doctor.js';
8
+ import { apply } from '../src/index.js';
9
+ const NOW = 1_700_000_000_000;
10
+ const QUOTA_CLASSES = [
11
+ 'user-session',
12
+ 'user-key',
13
+ 'anonymous-trial',
14
+ 'free-public',
15
+ 'static'
16
+ ];
17
+ const TIERS = [
18
+ 'realtime-api',
19
+ 'session',
20
+ 'best-effort',
21
+ 'static'
22
+ ];
23
+ const INTENTS = Object.keys(INTENT_LABELS);
24
+ const ids = new Set();
25
+ for (const c of CHANNELS){
26
+ assert.ok(!ids.has(c.id), `通道 id 重复:${c.id}`);
27
+ ids.add(c.id);
28
+ assert.ok(QUOTA_CLASSES.includes(c.quotaClass), `${c.id} quotaClass 越界`);
29
+ assert.ok(TIERS.includes(c.tier), `${c.id} tier 越界`);
30
+ assert.ok(c.intents.length > 0 && c.intents.every((i)=>INTENTS.includes(i)), `${c.id} intents 越界`);
31
+ assert.ok(c.tool.startsWith('gotry_'), `${c.id} 工具名形态`);
32
+ if (c.quotaClass === 'static') assert.equal(c.routable, false, '静态包不进模型可选路由');
33
+ }
34
+ assert.ok(CHANNELS.length >= 9, '注册表覆盖面(≥9 通道)');
35
+ console.log('1. 注册表封闭性 OK');
36
+ resetChannelHealth();
37
+ assert.deepEqual(channelsForIntent('search-flight').map((c)=>c.id), [
38
+ 'flyai',
39
+ 'session:ctrip-flight',
40
+ 'web-read'
41
+ ], '机票顺位');
42
+ assert.deepEqual(channelsForIntent('search-train').map((c)=>c.id), [
43
+ 'flyai',
44
+ 'session:12306-train',
45
+ 'web-read'
46
+ ], '火车顺位');
47
+ assert.deepEqual(channelsForIntent('search-hotel').map((c)=>c.id), [
48
+ 'flyai',
49
+ 'hbcli-hotel',
50
+ 'session:ctrip-hotel',
51
+ 'web-read'
52
+ ], '酒店顺位');
53
+ assert.deepEqual(channelsForIntent('weather').map((c)=>c.id), [
54
+ 'open-meteo'
55
+ ], '单通道意图');
56
+ console.log('2. 意图顺位(证据级 > 效率)OK');
57
+ const base = routingAdvice('search-flight');
58
+ assert.equal(base.alternatives[0].channel, 'flyai', '健康时 flyai 首荐(零 setup 摩擦)');
59
+ assert.ok(base.alternatives[0].why.length > 4 && (base.alternatives[0].setup?.length ?? 0) > 0, 'why/setup 齐备');
60
+ noteChannelVerdict('flyai', 'needs-setup', {
61
+ now: NOW
62
+ });
63
+ const afterDown = routingAdvice('search-flight', {
64
+ now: NOW
65
+ });
66
+ assert.ok(!afterDown.alternatives.some((a)=>a.channel === 'flyai'), 'flyai 达限后从建议表排除(#108 病灶)');
67
+ assert.equal(afterDown.alternatives[0].channel, 'session:ctrip-flight', 'session 升首荐(优先级=健康面投影)');
68
+ const excl = routingAdvice('search-flight', {
69
+ excludeChannel: 'session:ctrip-flight',
70
+ now: NOW
71
+ });
72
+ assert.ok(!excl.alternatives.some((a)=>a.channel === 'session:ctrip-flight'), '发起通道被排除');
73
+ assert.equal(routingAdvice('search-flight', {
74
+ limit: 1,
75
+ now: NOW
76
+ }).alternatives.length, 1, 'limit 截断');
77
+ noteChannelVerdict('flyai', 'hit', {
78
+ now: NOW
79
+ });
80
+ assert.equal(routingAdvice('search-flight', {
81
+ now: NOW
82
+ }).alternatives[0].channel, 'flyai', 'hit 即恢复(#107:补 key 不被陈旧状态锁死)');
83
+ console.log('3. routingAdvice(down 排除/发起排除/limit/hit 恢复)OK');
84
+ resetChannelHealth();
85
+ assert.ok(noteChannelVerdict('x', 'needs-setup', {
86
+ now: NOW
87
+ }), 'needs-setup → down 事件');
88
+ assert.equal(channelState('x', NOW)?.state, 'down');
89
+ assert.equal(noteChannelVerdict('x', 'challenged', {
90
+ now: NOW
91
+ })?.reason, 'challenged', 'challenged → down');
92
+ assert.equal(noteChannelVerdict('x', 'needs-login', {
93
+ now: NOW
94
+ })?.reason, 'needs-login', 'needs-login → down');
95
+ assert.equal(noteChannelVerdict('x', 'needs-extension', {
96
+ now: NOW
97
+ })?.reason, 'needs-extension', 'needs-extension → down');
98
+ assert.equal(noteChannelVerdict('x', 'miss', {
99
+ now: NOW
100
+ }), undefined, '业务 miss ≠ 通道故障');
101
+ assert.equal(noteChannelVerdict('x', 'error', {
102
+ now: NOW
103
+ }), undefined, '业务 error ≠ 通道故障');
104
+ clearChannel('x');
105
+ assert.equal(channelState('x', NOW), undefined, '清除后无状态');
106
+ const cd = noteChannelVerdict('y', 'cooldown', {
107
+ now: NOW,
108
+ cooldownMs: 30_000
109
+ });
110
+ assert.equal(cd?.state, 'cooldown', 'cooldown → cooldown 事件');
111
+ assert.equal(channelState('y', NOW + 29_999)?.state, 'cooldown', '冷却未满仍在');
112
+ assert.equal(channelState('y', NOW + 30_001), undefined, '冷却满惰性过期');
113
+ console.log('4. noteChannelVerdict 映射闭集 OK');
114
+ const card1 = renderRoutingCard(new Date(NOW));
115
+ const card2 = renderRoutingCard(new Date(NOW));
116
+ assert.equal(card1, card2, '同刻渲染确定');
117
+ assert.match(card1, /检索通道顺位/, '卡头');
118
+ assert.match(card1, /verdict≠hit/, '改道规则在卡内');
119
+ assert.match(card1, /- 机票: .+gotry_flyai_search.+→.+gotry_session_search/, '机票顺位行');
120
+ assert.match(card1, /- 火车: .+12306 公开查询面.+kind=train/, '火车行带 kind 消歧');
121
+ assert.match(card1, /匿名试用=首次体验导流/, '额度口径脚注(D-7)');
122
+ assert.ok(!card1.includes('- 天气'), '单通道意图不上卡');
123
+ console.log('5. persona 路由卡 OK');
124
+ const stateRoot = await mkdtemp(join(tmpdir(), 'gotry-channel-test-'));
125
+ const realNow = Date.now();
126
+ await recordChannelEvent(stateRoot, {
127
+ channel: 'flyai',
128
+ state: 'down',
129
+ reason: 'needs-setup',
130
+ at: new Date(realNow - 86_400_000).toISOString()
131
+ });
132
+ await recordChannelEvent(stateRoot, {
133
+ channel: 'flyai',
134
+ state: 'down',
135
+ reason: 'needs-setup',
136
+ at: new Date(realNow).toISOString()
137
+ });
138
+ await recordChannelEvent(stateRoot, {
139
+ channel: 'session:ctrip-flight',
140
+ state: 'cooldown',
141
+ at: new Date(realNow).toISOString()
142
+ });
143
+ const latest = await readLatestChannelEvents(stateRoot);
144
+ assert.equal(latest.get('flyai')?.at, new Date(realNow).toISOString(), '每通道取最新');
145
+ assert.equal(latest.get('session:ctrip-flight')?.state, 'cooldown');
146
+ await recordChannelEvent(stateRoot, {
147
+ channel: 'legacy',
148
+ state: 'down',
149
+ at: new Date(realNow - 40 * 86_400_000).toISOString()
150
+ });
151
+ assert.ok(!(await readLatestChannelEvents(stateRoot)).has('legacy'), '40 天前事件被 limitDays 过滤');
152
+ const raw = `${stateRoot}/gotry-state/channel-health.jsonl`;
153
+ await writeFile(raw, `${await readFile(raw, 'utf-8')}not-json\n{"half":true}\n`, 'utf-8');
154
+ assert.equal((await readLatestChannelEvents(stateRoot)).size, 2, '坏行容忍');
155
+ console.log('6. JSONL 持久面 OK');
156
+ const emptyRepo = join(stateRoot, 'repo');
157
+ const emptyHome = join(stateRoot, 'home');
158
+ await mkdir(emptyRepo, {
159
+ recursive: true
160
+ });
161
+ await mkdir(join(emptyHome, '.gotry'), {
162
+ recursive: true
163
+ });
164
+ const calStatePath = join(emptyHome, '.gotry', 'calendar.json');
165
+ const d1 = await runDoctorChecks({
166
+ repoRoot: emptyRepo,
167
+ homeDir: emptyHome,
168
+ env: {},
169
+ stateRoot
170
+ });
171
+ assert.match(d1.items.find((i)=>i.id === 'flyai').detail, /最近一次试用达限/, '最近达限时间进 doctor(#107 可见性)');
172
+ assert.equal(d1.items.find((i)=>i.id === 'calendar').status, 'ok', 'calendar 默认不挂载=ok(D-9)');
173
+ assert.match(d1.items.find((i)=>i.id === 'calendar').detail, /默认未挂载/, '默认态人话');
174
+ await writeFile(calStatePath, JSON.stringify({
175
+ enabled: true
176
+ }), 'utf-8');
177
+ const d2 = await runDoctorChecks({
178
+ repoRoot: emptyRepo,
179
+ homeDir: emptyHome,
180
+ env: {}
181
+ });
182
+ assert.equal(d2.items.find((i)=>i.id === 'calendar').status, 'degraded', 'setup 开启但未配置 username=degraded');
183
+ assert.match(d2.items.find((i)=>i.id === 'calendar').fix ?? '', /npx gotry setup calendar/, 'fix 指向 setup 状态面');
184
+ const profileDir = join(emptyHome, '.dsh/profiles/web');
185
+ await mkdir(profileDir, {
186
+ recursive: true
187
+ });
188
+ await writeFile(join(profileDir, 'cordis.patch.yml'), '- id: dsh-calendar\n config:\n username: someone\n', 'utf-8');
189
+ const d3 = await runDoctorChecks({
190
+ repoRoot: emptyRepo,
191
+ homeDir: emptyHome,
192
+ env: {}
193
+ });
194
+ assert.equal(d3.items.find((i)=>i.id === 'calendar').status, 'ok', 'setup 开启且已配置=ok');
195
+ const d4 = await runDoctorChecks({
196
+ repoRoot: emptyRepo,
197
+ homeDir: emptyHome,
198
+ env: {}
199
+ });
200
+ assert.ok(!/最近一次试用达限/.test(d4.items.find((i)=>i.id === 'flyai').detail), '无 stateRoot 不读持久面(向后兼容)');
201
+ const mapItem = d4.items.find((i)=>i.id === 'map-tools');
202
+ const askItem = d4.items.find((i)=>i.id === 'ask-user');
203
+ assert.equal(mapItem.status, 'missing', '隔离环境 map-tools=missing(静默剔除面可见)');
204
+ assert.match(mapItem.detail, /静默剔除/, 'map-tools detail 说明静默剔除语义');
205
+ assert.equal(askItem.status, 'missing', '隔离环境 ask-user=missing');
206
+ assert.match(askItem.detail, /散文追问/, 'ask-user detail 说明退化面');
207
+ console.log('7. doctor 集成(flyai 配额可见 + calendar setup 状态面三态 + 宿主插件两态)OK');
208
+ resetChannelHealth();
209
+ const variables = {};
210
+ const registered = [];
211
+ const ctx = {
212
+ tools: {
213
+ register: (t)=>registered.push(t)
214
+ },
215
+ systemPrompt: {
216
+ variable: (name, provider)=>{
217
+ variables[name] = provider;
218
+ }
219
+ },
220
+ on: ()=>()=>{}
221
+ };
222
+ apply(ctx, {
223
+ stateRoot,
224
+ timeoutMs: 30_000,
225
+ hbcliBin: 'hbcli-not-on-path',
226
+ sessionAccess: 'ask'
227
+ });
228
+ assert.equal(typeof variables.channel_routing_card, 'function', 'channel_routing_card 变量已注册');
229
+ assert.match(variables.channel_routing_card(), /检索通道顺位/, '卡片可渲染');
230
+ const pastDate = await registered.find((t)=>t.name === 'gotry_flyai_search').execute({
231
+ kind: 'flight',
232
+ from: '深圳',
233
+ to: '普吉',
234
+ date: '2026-01-01'
235
+ }, null);
236
+ assert.ok(!('routing' in pastDate), '参数级预校验拒绝不带 routing(未发起检索,无改道语义)');
237
+ const legacyBlob = await registered.find((t)=>t.name === 'gotry_flyai_search').execute({
238
+ query: {
239
+ kind: 'flight',
240
+ from: '深圳',
241
+ to: '普吉',
242
+ date: '2026-01-01'
243
+ }
244
+ }, null);
245
+ assert.ok(legacyBlob.ok === false && !('routing' in legacyBlob) && !!legacyBlob.evidence, 'legacy blob → 结构化拒绝(ToolFailure,无 routing)');
246
+ const flyaiTool = registered.find((t)=>t.name === 'gotry_flyai_search');
247
+ const doctorToolDesc = registered.find((t)=>t.name === 'gotry_doctor');
248
+ assert.ok(flyaiTool, 'flyai 工具已注册');
249
+ assert.match(toolRoutingHeadline('gotry_flyai_search'), /机票: 飞猪官方 API\(gotry_flyai_search\) → 携程·用户登录态/, 'flyai 首行卡:机票顺位 flyai 在前(证据级)');
250
+ assert.match(toolRoutingHeadline('gotry_session_search'), /火车: .*12306 公开查询面\(gotry_session_search kind=train\)/, 'session 首行卡带 kind 消歧');
251
+ assert.equal(toolRoutingHeadline('gotry_doctor'), '', '非检索工具无首行卡(原样描述)');
252
+ assert.ok(flyaiTool.description?.startsWith('本工具服务的检索意图与通道顺位'), 'flyai 描述已前置首行卡(接线生效)');
253
+ assert.ok(!doctorToolDesc.description?.startsWith('本工具服务的检索意图'), 'doctor 描述无首行卡(接线不误伤)');
254
+ console.log('8. apply 接线 + 描述首行生成 OK');
255
+ await rm(stateRoot, {
256
+ recursive: true,
257
+ force: true
258
+ });
259
+ console.log('channel-registry-tests: 全部通过');
260
+
261
+
262
+ //# sourceURL=ts/scripts/channel-registry-tests.ts