@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.29
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 +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/gotry-bootstrap.js +56 -12
- package/data/yunnan-pack.json +30 -27
- package/dist/capabilities/anything.js +26 -0
- package/dist/capabilities/channel-registry.js +4 -4
- package/dist/capabilities/doctor.js +14 -7
- package/dist/capabilities/effect.js +1 -1
- package/dist/capabilities/ground-transfer.js +168 -1
- package/dist/capabilities/provider-detail-sanitize.js +64 -0
- package/dist/capabilities/route-provider-conformance.js +341 -0
- package/dist/capabilities/session/extension-bridge.js +3 -3
- package/dist/capabilities/session-search.js +1 -1
- package/dist/scripts/anything-path-measure-tests.js +133 -0
- package/dist/scripts/anything-path-measure.js +150 -0
- package/dist/scripts/anything-tests.js +136 -2
- package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
- package/dist/scripts/bootstrap-tests.js +77 -7
- package/dist/scripts/city-scenario-tier-tests.js +234 -0
- package/dist/scripts/doctor-tests.js +39 -3
- package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
- package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
- package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
- package/dist/scripts/dsh-llm-tests.js +437 -0
- package/dist/scripts/g5-guard-tests.js +20 -1
- package/dist/scripts/g5-guard.js +5 -1
- package/dist/scripts/gotry-backend-kernel-tests.js +1 -1
- package/dist/scripts/m3-cohort-tests.js +714 -0
- package/dist/scripts/m3-cohort.js +310 -0
- package/dist/scripts/nightly-evidence-tests.js +44 -2
- package/dist/scripts/nightly-evidence.js +21 -4
- package/dist/scripts/outcome-projection-tests.js +427 -0
- package/dist/scripts/persona-sim-tests.js +621 -0
- package/dist/scripts/persona-sim.js +1081 -0
- package/dist/scripts/replay-async.js +2 -0
- package/dist/scripts/replay.js +2 -0
- package/dist/scripts/route-provider-conformance-tests.js +391 -0
- package/dist/scripts/session-dida-live-e2e.js +1 -1
- package/dist/scripts/session-zone-crash.js +49 -0
- package/dist/scripts/session-zone-ledger-tests.js +432 -0
- package/dist/scripts/session-zone-observation-tests.js +492 -0
- package/dist/scripts/session-zone-observe.js +135 -0
- package/dist/scripts/session-zone-wiring-tests.js +566 -0
- package/dist/scripts/session-zones-tests.js +17 -1
- package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
- package/dist/scripts/state-cli.js +16 -0
- package/dist/scripts/time-eval-tests.js +2 -0
- package/dist/scripts/unified-tests.js +79 -2
- package/dist/scripts/z3-race-tests.js +6 -1
- package/dist/src/backend/modules/session-search.js +1 -1
- package/dist/src/city-scenario-tier.js +378 -0
- package/dist/src/dsh-llm.js +84 -14
- package/dist/src/index.js +166 -7
- package/dist/src/loop.js +16 -5
- package/dist/src/m3-cohort.js +1577 -0
- package/dist/src/outcome-projection.js +746 -0
- package/dist/src/session-zone-ledger.js +312 -0
- package/dist/src/session-zone-observation.js +354 -0
- package/dist/src/session-zone-wiring.js +658 -0
- package/dist/src/session-zones.js +7 -4
- package/dist/src/state-ledger.js +293 -40
- package/dist/src/unified.js +69 -5
- package/extension/README.md +1 -1
- package/extension/README.zh-CN.md +1 -1
- package/extension/background.js +1 -1
- package/extension/content-bridge.js +1 -1
- package/extension/content-main.js +1 -1
- package/extension/manifest.json +2 -2
- package/package.json +1 -1
- package/ts/capabilities/anything.ts +27 -0
- package/ts/capabilities/session/extension-bridge.ts +3 -3
- package/ts/capabilities/session-search.ts +1 -1
- package/ts/scripts/state-cli.ts +18 -0
- package/ts/src/contracts.ts +4 -0
- package/ts/src/dsh-llm.ts +111 -9
- package/ts/src/index.ts +181 -8
- package/ts/src/loop.ts +15 -5
- package/ts/src/state-ledger.ts +294 -40
- package/ts/src/unified.ts +91 -18
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Read-only measurement of the external Anything path
|
|
4
|
+
* (hbcli -> hotel-be /api/search/anything), the evidence probe for two trigger trackers:
|
|
5
|
+
* #276 does the external path show a material latency/reliability bottleneck at scale?
|
|
6
|
+
* #345 what fields does Anything actually return for place-like queries (rating/photos/hours)?
|
|
7
|
+
*
|
|
8
|
+
* Discipline:
|
|
9
|
+
* - Opt-in only: without GOTRY_UAT_READONLY=1 it exits 0 with `waiting_external_evidence` and spawns nothing
|
|
10
|
+
* (CI-safe; same no-spend discipline as nightly-evidence.ts).
|
|
11
|
+
* - Read-only `search anything` calls against the environment hbcli is already authenticated for
|
|
12
|
+
* (flags fixed to `--json --env=uat`). It never reads, prints or persists credentials.
|
|
13
|
+
* - Prints aggregates only (latency percentiles, error counts, candidate-type counts, field-presence
|
|
14
|
+
* ratios). Raw responses are not persisted; failure details are scrubbed and truncated.
|
|
15
|
+
* - This MEASURES the real path. It does not satisfy either tracker's trigger: both need real M4
|
|
16
|
+
* usage evidence (and, for #345, founder approval of per-call billing).
|
|
17
|
+
*/
|
|
18
|
+
import { execFile } from 'node:child_process';
|
|
19
|
+
import { pathToFileURL } from 'node:url';
|
|
20
|
+
export const DEFAULT_KEYWORDS = [
|
|
21
|
+
'Dali', '大理', '洱海', 'Erhai', 'Lijiang', '丽江', 'Phuket', '普吉', 'Rawai', 'Krabi', '甲米',
|
|
22
|
+
'Shenzhen', '深圳', 'Hong Kong', '香港', 'Dubai', '迪拜', 'Kunming', '昆明', 'Bangkok', '曼谷',
|
|
23
|
+
'Tokyo', 'Paris', 'Hilton', 'Marriott', 'Old Town', 'Airport', 'Beach',
|
|
24
|
+
];
|
|
25
|
+
export const OPT_IN_ENV = 'GOTRY_UAT_READONLY';
|
|
26
|
+
export const CALL_TIMEOUT_MS = 60_000;
|
|
27
|
+
export const CONCURRENCY = 5;
|
|
28
|
+
/** Redact credential-looking material and bound the length of anything we keep from a failed call. */
|
|
29
|
+
export function scrub(text) {
|
|
30
|
+
return text
|
|
31
|
+
.replace(/(bearer|ticket|token|authorization|secret|api[-_ ]?key|password)\s*[:=]?\s*[^\s"',}]+/gi, '$1=<redacted>')
|
|
32
|
+
.replace(/\s+/g, ' ')
|
|
33
|
+
.trim()
|
|
34
|
+
.slice(0, 160);
|
|
35
|
+
}
|
|
36
|
+
/** Nearest-rank percentile; null for an empty sample. */
|
|
37
|
+
export function percentile(values, p) {
|
|
38
|
+
if (values.length === 0)
|
|
39
|
+
return null;
|
|
40
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
41
|
+
return sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)] ?? null;
|
|
42
|
+
}
|
|
43
|
+
export function latencyStats(results) {
|
|
44
|
+
const reasons = {};
|
|
45
|
+
for (const r of results)
|
|
46
|
+
if (!r.ok)
|
|
47
|
+
reasons[r.reason] = (reasons[r.reason] ?? 0) + 1;
|
|
48
|
+
const times = results.map((r) => r.ms);
|
|
49
|
+
return {
|
|
50
|
+
calls: results.length,
|
|
51
|
+
errors: results.filter((r) => !r.ok).length,
|
|
52
|
+
error_reasons: reasons,
|
|
53
|
+
p50_ms: percentile(times, 50),
|
|
54
|
+
p95_ms: percentile(times, 95),
|
|
55
|
+
max_ms: times.length ? Math.max(...times) : null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function isPresent(value) {
|
|
59
|
+
if (value === null || value === undefined || value === '')
|
|
60
|
+
return false;
|
|
61
|
+
if (Array.isArray(value))
|
|
62
|
+
return value.length > 0;
|
|
63
|
+
if (typeof value === 'object')
|
|
64
|
+
return Object.keys(value).length > 0;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
/** For each key seen on any object: how many of the n objects carry a non-empty value (`"52/55"`). */
|
|
68
|
+
export function fieldPresence(objects) {
|
|
69
|
+
const present = {};
|
|
70
|
+
const real = objects.filter((o) => o !== undefined && o !== null);
|
|
71
|
+
for (const o of real)
|
|
72
|
+
for (const [k, v] of Object.entries(o))
|
|
73
|
+
present[k] = (present[k] ?? 0) + (isPresent(v) ? 1 : 0);
|
|
74
|
+
const fields = {};
|
|
75
|
+
for (const k of Object.keys(present).sort())
|
|
76
|
+
fields[k] = `${present[k]}/${real.length}`;
|
|
77
|
+
return { n: real.length, fields };
|
|
78
|
+
}
|
|
79
|
+
/** Real runner: one hbcli call, classified, bounded, scrubbed. Never throws. */
|
|
80
|
+
export const hbcliRunner = (args) => new Promise((resolve) => {
|
|
81
|
+
const started = performance.now();
|
|
82
|
+
execFile('hbcli', ['--json', '--env=uat', 'search', 'anything', ...args], { timeout: CALL_TIMEOUT_MS, maxBuffer: 8 << 20 }, (error, stdout, stderr) => {
|
|
83
|
+
const ms = Math.round(performance.now() - started);
|
|
84
|
+
if (error) {
|
|
85
|
+
const killed = error.killed === true;
|
|
86
|
+
const code = error.code;
|
|
87
|
+
const reason = killed ? 'timeout' : typeof code === 'number' ? `exit_${code}` : 'spawn_error';
|
|
88
|
+
resolve({ ok: false, ms, reason, detail: scrub(String(stderr || stdout || error.message)) });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(String(stdout));
|
|
93
|
+
resolve({ ok: true, ms, candidates: Array.isArray(parsed.candidates) ? parsed.candidates : [] });
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
resolve({ ok: false, ms, reason: 'non_json', detail: scrub(String(stdout)) });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
async function sequential(run, keywords, extra) {
|
|
101
|
+
const out = [];
|
|
102
|
+
for (const kw of keywords)
|
|
103
|
+
out.push(await run([kw, ...extra]));
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
async function concurrent(run, keywords, width) {
|
|
107
|
+
const out = [];
|
|
108
|
+
for (let i = 0; i < keywords.length; i += width) {
|
|
109
|
+
out.push(...await Promise.all(keywords.slice(i, i + width).map((kw) => run([kw]))));
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
/** Pure given a runner and a clock: the whole measurement, no environment access. */
|
|
114
|
+
export async function measure(run, keywords = DEFAULT_KEYWORDS, now = () => new Date()) {
|
|
115
|
+
const mixed = await sequential(run, keywords, []);
|
|
116
|
+
const placeOnly = await sequential(run, keywords, ['--content-type', 'place']);
|
|
117
|
+
const burst = await concurrent(run, keywords, CONCURRENCY);
|
|
118
|
+
const mixedCandidates = mixed.flatMap((r) => (r.ok ? r.candidates : []));
|
|
119
|
+
const placeCandidates = placeOnly.flatMap((r) => (r.ok ? r.candidates : []));
|
|
120
|
+
const byType = {};
|
|
121
|
+
for (const c of mixedCandidates)
|
|
122
|
+
byType[c.type ?? 'unknown'] = (byType[c.type ?? 'unknown'] ?? 0) + 1;
|
|
123
|
+
const zeroPlace = placeOnly.filter((r) => r.ok && r.candidates.length === 0).length;
|
|
124
|
+
return {
|
|
125
|
+
state: 'measured',
|
|
126
|
+
measured_at: now().toISOString(),
|
|
127
|
+
hbcli_json_flags: '--json --env=uat',
|
|
128
|
+
sequential_mixed: latencyStats(mixed),
|
|
129
|
+
sequential_place_only: latencyStats(placeOnly),
|
|
130
|
+
concurrent_mixed: { ...latencyStats(burst), concurrency: CONCURRENCY },
|
|
131
|
+
mixed_candidates_by_type: byType,
|
|
132
|
+
place_only_total_candidates: placeCandidates.length,
|
|
133
|
+
keywords_with_zero_place_results: `${zeroPlace}/${keywords.length}`,
|
|
134
|
+
hotel_object_field_presence: fieldPresence(mixedCandidates.filter((c) => c.type === 'hotel').map((c) => c.hotel)),
|
|
135
|
+
city_region_field_presence: fieldPresence(mixedCandidates.filter((c) => c.type === 'city').map((c) => c.region)),
|
|
136
|
+
place_object_field_presence: fieldPresence(placeCandidates.map((c) => c.place)),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** The opt-in gate: nothing is spawned unless the environment explicitly asks for it. */
|
|
140
|
+
export async function measureIfOptedIn(env, run) {
|
|
141
|
+
if (env[OPT_IN_ENV] !== '1') {
|
|
142
|
+
return { state: 'waiting_external_evidence', reason: `${OPT_IN_ENV}=1 not set; no hbcli process was spawned, nothing was measured` };
|
|
143
|
+
}
|
|
144
|
+
return measure(run);
|
|
145
|
+
}
|
|
146
|
+
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
147
|
+
measureIfOptedIn(process.env, hbcliRunner).then((result) => {
|
|
148
|
+
console.log(JSON.stringify(result, null, 2));
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -10,15 +10,25 @@
|
|
|
10
10
|
* 6. 超时:fake hbcli hang — AbortError → verdict error + 不抛错
|
|
11
11
|
* 7. 关键词为空:不调 hbcli — verdict error
|
|
12
12
|
* 8. 缺 hbcli(spawn 失败):用户面是人话(不留 `(exit null)`/ENOENT 进程噪音)
|
|
13
|
+
* 9. 离线开关 GOTRY_HBCLI_LIVE=0|false|off(issue #617):计数 fake hbcli **零次**被调起,
|
|
14
|
+
* 返回既有降级面(与缺 hbcli 同形:ok:false / hbcli-anything-error / verdict:error + 人话原因);
|
|
15
|
+
* 含 PATH 解析的真实调用面(runTurn 的 PoI 探针不传 hbcliBin)
|
|
16
|
+
* 10. 开关未设或任何其他取值:行为与此前完全一致——fake 被调起,hit/miss/error 三值语义不变
|
|
17
|
+
* 11. 既有入参的既有结果在开关关闭时逐字节不变:空 keyword / pre-aborted 先于离线判定
|
|
18
|
+
*
|
|
19
|
+
* 本套件独占 GOTRY_HBCLI_LIVE:开头清掉外部值(`GOTRY_HBCLI_LIVE=0 ./scripts/run-all-tests.sh` 也不得改变 fake 断言),
|
|
20
|
+
* 结束还原。
|
|
13
21
|
*
|
|
14
22
|
* 运行: cd ts && npx tsx scripts/anything-tests.ts
|
|
15
23
|
*/
|
|
16
24
|
import assert from 'node:assert/strict';
|
|
17
|
-
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
25
|
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
18
26
|
import { tmpdir } from 'node:os';
|
|
19
27
|
import { join } from 'node:path';
|
|
20
28
|
import { anythingSearch } from '../capabilities/anything.js';
|
|
21
29
|
const tmp = await mkdtemp(join(tmpdir(), 'anything-test-'));
|
|
30
|
+
const savedLive = process.env.GOTRY_HBCLI_LIVE;
|
|
31
|
+
delete process.env.GOTRY_HBCLI_LIVE;
|
|
22
32
|
try {
|
|
23
33
|
async function fakeBin(name, behaviour) {
|
|
24
34
|
const p = join(tmp, name);
|
|
@@ -144,8 +154,132 @@ while :; do sleep 5; done
|
|
|
144
154
|
assert.ok(/hbcli/.test(r8.error), `原因须指明是 hbcli,实得:${r8.error}`);
|
|
145
155
|
assert.ok(r8.evidence.includes('[实时API:hbcli-anything@error@'), '证据链 tag 形态不变');
|
|
146
156
|
console.log(`8. 缺 hbcli → 人话降级 OK(${r8.error})`);
|
|
147
|
-
|
|
157
|
+
// ---- 9-11. 离线开关 GOTRY_HBCLI_LIVE(issue #617) -------------------------------------------
|
|
158
|
+
// 计数 fake:每被调起一次就往 calls.log 追加一行(只用 shell 内建,PATH 为空也能跑),再按 kind 回包。
|
|
159
|
+
const callsLog = join(tmp, 'calls.log');
|
|
160
|
+
const invocations = async () => {
|
|
161
|
+
try {
|
|
162
|
+
return (await readFile(callsLog, 'utf8')).split('\n').filter(Boolean).length;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
async function countedBin(name, kind, dir = tmp) {
|
|
169
|
+
const p = join(dir, name);
|
|
170
|
+
const reply = kind === 'hit'
|
|
171
|
+
? `printf '%s' '{"candidates":[{"type":"city","matchScore":9,"region":{"id":"d1","name":{"zh":"大理市"}}}]}'`
|
|
172
|
+
: kind === 'miss'
|
|
173
|
+
? `printf '%s' '{"candidates":[]}'`
|
|
174
|
+
: `printf 'hotelbe down\\n' >&2\nexit 1`;
|
|
175
|
+
await writeFile(p, `#!/bin/sh\nprintf 'call\\n' >> '${callsLog}'\n${reply}\n`, { mode: 0o755 });
|
|
176
|
+
return p;
|
|
177
|
+
}
|
|
178
|
+
async function withLive(value, body) {
|
|
179
|
+
const prev = process.env.GOTRY_HBCLI_LIVE;
|
|
180
|
+
if (value === undefined)
|
|
181
|
+
delete process.env.GOTRY_HBCLI_LIVE;
|
|
182
|
+
else
|
|
183
|
+
process.env.GOTRY_HBCLI_LIVE = value;
|
|
184
|
+
try {
|
|
185
|
+
return await body();
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
if (prev === undefined)
|
|
189
|
+
delete process.env.GOTRY_HBCLI_LIVE;
|
|
190
|
+
else
|
|
191
|
+
process.env.GOTRY_HBCLI_LIVE = prev;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const countedHit = await countedBin('hbcli-counted-hit', 'hit');
|
|
195
|
+
const countedMiss = await countedBin('hbcli-counted-miss', 'miss');
|
|
196
|
+
const countedFail = await countedBin('hbcli-counted-fail', 'fail');
|
|
197
|
+
// 9. 关闭:0/false/off(不分大小写、忽略首尾空白)→ 零 spawn + 既有降级面
|
|
198
|
+
for (const off of ['0', 'false', 'off', 'OFF', 'False', ' 0 ', ' off\n']) {
|
|
199
|
+
const before = await invocations();
|
|
200
|
+
const r9 = await withLive(off, () => anythingSearch({ keyword: '大理', hbcliBin: countedHit }));
|
|
201
|
+
assert.equal(await invocations(), before, `GOTRY_HBCLI_LIVE=${JSON.stringify(off)}:fake hbcli 必须被调起 0 次`);
|
|
202
|
+
assert.equal(r9.ok, false);
|
|
203
|
+
assert.equal(r9.via, 'hbcli-anything-error');
|
|
204
|
+
assert.equal(r9.verdict, 'error', '「没查」不得说成「查无」:走 error 降级面而非 miss');
|
|
205
|
+
assert.equal(r9.hits, undefined);
|
|
206
|
+
assert.deepEqual(Object.keys(r9).sort(), Object.keys(r8).sort(), '与缺 hbcli 降级同形(同一组字段)');
|
|
207
|
+
assert.match(r9.evidence, /^\[实时API:hbcli-anything@offline@[^\]]+\]/, `证据链应标 offline,实得:${r9.evidence}`);
|
|
208
|
+
assert.ok(r9.error && /hbcli/.test(r9.error) && /GOTRY_HBCLI_LIVE/.test(r9.error), `原因须说明 hbcli 通道被开关关闭,实得:${r9.error}`);
|
|
209
|
+
assert.ok(!/(exit null|ENOENT|spawn |timeout)/i.test(r9.error), `用户面不得出现进程噪音(也不得含 timeout:效应层只重试 timeout 类),实得:${r9.error}`);
|
|
210
|
+
}
|
|
211
|
+
// 9b. 真实调用面:不传 hbcliBin('hbcli' 由 PATH 解析,runTurn 的 PoI 探针即如此)。
|
|
212
|
+
// 未设开关的 positive control 证明 fake 确实可达——否则「零次」可能是空断言。
|
|
213
|
+
const pathBin = join(tmp, 'pathbin');
|
|
214
|
+
await mkdir(pathBin);
|
|
215
|
+
await countedBin('hbcli', 'hit', pathBin);
|
|
216
|
+
const savedPath = process.env.PATH;
|
|
217
|
+
const savedHome = process.env.HOME;
|
|
218
|
+
process.env.PATH = `${pathBin}:${savedPath ?? ''}`;
|
|
219
|
+
process.env.HOME = join(tmp, 'empty-home'); // 已知安装位回退落在空目录,碰不到开发机真实 hbcli
|
|
220
|
+
try {
|
|
221
|
+
const base = await invocations();
|
|
222
|
+
const control = await withLive(undefined, () => anythingSearch({ keyword: '大理' }));
|
|
223
|
+
assert.equal(control.verdict, 'hit', `positive control:未设开关时 PATH 上的 fake 应被调起,实得:${control.evidence}`);
|
|
224
|
+
assert.equal(await invocations(), base + 1, 'positive control 恰好调起一次');
|
|
225
|
+
const off = await withLive('0', () => anythingSearch({ keyword: '大理' }));
|
|
226
|
+
assert.equal(off.verdict, 'error');
|
|
227
|
+
assert.match(off.evidence, /@offline@/);
|
|
228
|
+
assert.equal(await invocations(), base + 1, 'GOTRY_HBCLI_LIVE=0:PATH 上的 hbcli 零次调起');
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
if (savedPath === undefined)
|
|
232
|
+
delete process.env.PATH;
|
|
233
|
+
else
|
|
234
|
+
process.env.PATH = savedPath;
|
|
235
|
+
if (savedHome === undefined)
|
|
236
|
+
delete process.env.HOME;
|
|
237
|
+
else
|
|
238
|
+
process.env.HOME = savedHome;
|
|
239
|
+
}
|
|
240
|
+
console.log('9. GOTRY_HBCLI_LIVE=0|false|off → 零 spawn + 既有降级面(显式 hbcliBin 与 PATH 解析两种调用面)OK');
|
|
241
|
+
// 10. 未设或任何其他取值 = 与此前完全一致:fake 被调起,hit/miss/error 三值语义不变
|
|
242
|
+
for (const on of [undefined, '1']) {
|
|
243
|
+
const before = await invocations();
|
|
244
|
+
const h = await withLive(on, () => anythingSearch({ keyword: '大理', hbcliBin: countedHit }));
|
|
245
|
+
const m = await withLive(on, () => anythingSearch({ keyword: 'nothing', hbcliBin: countedMiss }));
|
|
246
|
+
const f = await withLive(on, () => anythingSearch({ keyword: 'x', hbcliBin: countedFail }));
|
|
247
|
+
assert.equal(await invocations(), before + 3, `GOTRY_HBCLI_LIVE=${String(on)}:三只 fake 各被调起一次`);
|
|
248
|
+
assert.equal(h.verdict, 'hit');
|
|
249
|
+
assert.equal(h.hits[0].name, '大理市');
|
|
250
|
+
assert.match(h.evidence, /^\[实时API:hbcli-anything@\d{4}-/, 'hit 证据链形态不变');
|
|
251
|
+
assert.equal(m.verdict, 'miss');
|
|
252
|
+
assert.equal(m.hits.length, 0);
|
|
253
|
+
assert.equal(f.verdict, 'error');
|
|
254
|
+
assert.match(f.evidence, /^\[实时API:hbcli-anything@error@/, 'error 证据链形态不变(不是 offline)');
|
|
255
|
+
assert.equal(f.error, 'hbcli 报错:hotelbe down', '既有原因串逐字节不变');
|
|
256
|
+
}
|
|
257
|
+
// 非 0/false/off 的取值一律视为开启(与此前一致):空串/1/true/on/yes/no/disabled/00/任意字符串
|
|
258
|
+
for (const other of ['', 'true', 'on', 'yes', 'no', 'disabled', '00', 'maybe']) {
|
|
259
|
+
const before = await invocations();
|
|
260
|
+
const r = await withLive(other, () => anythingSearch({ keyword: '大理', hbcliBin: countedHit }));
|
|
261
|
+
assert.equal(r.verdict, 'hit', `GOTRY_HBCLI_LIVE=${JSON.stringify(other)} 不是关闭值,应保持 live,实得:${r.evidence}`);
|
|
262
|
+
assert.equal(await invocations(), before + 1);
|
|
263
|
+
}
|
|
264
|
+
console.log('10. 开关未设/其他取值 → 与此前一致(hit/miss/error 三值 + 既有原因串)OK');
|
|
265
|
+
// 11. 关闭时,既有入参的既有结果逐字节不变:空 keyword / pre-aborted 先于离线判定(两者本就零 spawn)
|
|
266
|
+
const before11 = await invocations();
|
|
267
|
+
const rEmpty = await withLive('0', () => anythingSearch({ keyword: ' ', hbcliBin: countedHit }));
|
|
268
|
+
assert.equal(rEmpty.error, 'keyword is required');
|
|
269
|
+
assert.match(rEmpty.evidence, /^\[实时API:hbcli-anything@error@[^\]]+\] keyword empty$/);
|
|
270
|
+
const preAborted = new AbortController();
|
|
271
|
+
preAborted.abort();
|
|
272
|
+
const rAbort = await withLive('0', () => anythingSearch({ keyword: '大理', hbcliBin: countedHit, signal: preAborted.signal }));
|
|
273
|
+
assert.equal(rAbort.error, 'aborted by host signal');
|
|
274
|
+
assert.match(rAbort.evidence, /^\[实时API:hbcli-anything@abort@[^\]]+\] pre-aborted, zero spawn$/);
|
|
275
|
+
assert.equal(await invocations(), before11, '两条既有降级路径均零 spawn');
|
|
276
|
+
console.log('11. 关闭态下空 keyword / pre-aborted 的既有结果不变 OK');
|
|
277
|
+
console.log('\nANYTHING TESTS: 11/11 OK(hbcli fake + wire 对齐 + 降级诚实 + #617 离线开关零 spawn)');
|
|
148
278
|
}
|
|
149
279
|
finally {
|
|
280
|
+
if (savedLive === undefined)
|
|
281
|
+
delete process.env.GOTRY_HBCLI_LIVE;
|
|
282
|
+
else
|
|
283
|
+
process.env.GOTRY_HBCLI_LIVE = savedLive;
|
|
150
284
|
await rm(tmp, { recursive: true, force: true });
|
|
151
285
|
}
|
|
@@ -15,9 +15,12 @@ mkdirSync(markerDir, { recursive: true });
|
|
|
15
15
|
const actualBin = pathToFileURL(join(dirname(fileURLToPath(import.meta.resolve('@deepseek-ai/dsh/package.json'))), 'lib/bin.js')).href;
|
|
16
16
|
const gateBin = join(root, 'blocked-dsh.mjs');
|
|
17
17
|
writeFileSync(gateBin, `
|
|
18
|
-
import { existsSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { existsSync, renameSync, writeFileSync } from 'node:fs'
|
|
19
19
|
const marker = ${JSON.stringify(markerDir)} + '/gate-' + process.pid + '.json'
|
|
20
|
-
|
|
20
|
+
// Atomic publish: the proof parses each marker the moment the directory lists it, so a marker must never be visible half-written (writeFileSync creates the empty file first).
|
|
21
|
+
const staging = ${JSON.stringify(root)} + '/gate-' + process.pid + '.staging'
|
|
22
|
+
writeFileSync(staging, JSON.stringify({ pid: process.pid, workerPid: process.ppid }))
|
|
23
|
+
renameSync(staging, marker)
|
|
21
24
|
while (!existsSync(${JSON.stringify(releaseFile)})) await new Promise((resolve) => setTimeout(resolve, 10))
|
|
22
25
|
const { runCli } = await import(${JSON.stringify(actualBin)})
|
|
23
26
|
await runCli()
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* 4. GOTRY_SETUP_EXTENSION=0 单项跳过
|
|
11
11
|
* 5. wizard --dry-run 与真实路径(probe 失败 exit 1)
|
|
12
12
|
* 8. doctor 子命令:体检清单/LLM key 让渡/报告落盘(状态面回归)
|
|
13
|
+
* 24. hbcli 凭证判定(#623):CLI doctor 按 whoami 三档 JSON 判而非退出码,并与工具层 doctor 对拍同结论
|
|
13
14
|
*
|
|
14
15
|
* 不测真实安装(浏览器商店一键装已上架,扩展就位检测走 runHealthWatch 的回环端口)。
|
|
15
16
|
* 运行: cd ts && npx tsx scripts/bootstrap-tests.ts
|
|
@@ -20,6 +21,7 @@ import { EventEmitter } from 'node:events';
|
|
|
20
21
|
import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
21
22
|
import { tmpdir } from 'node:os';
|
|
22
23
|
import { join, dirname, delimiter } from 'node:path';
|
|
24
|
+
import { runDoctorChecks } from '../capabilities/doctor.js';
|
|
23
25
|
const repoRoot = join(import.meta.dirname, '..', '..');
|
|
24
26
|
const bootstrap = join(repoRoot, 'bin', 'gotry-bootstrap.js');
|
|
25
27
|
function runBootstrap(extraArgs, extraEnv, bootstrapPath = bootstrap) {
|
|
@@ -39,7 +41,7 @@ function runBootstrap(extraArgs, extraEnv, bootstrapPath = bootstrap) {
|
|
|
39
41
|
// 1. --check-only:扩展就位探测,exit 0(已不替用户管 hbcli/agent-reach/sidebar)
|
|
40
42
|
const c1 = runBootstrap(['--check-only'], {});
|
|
41
43
|
assert.equal(c1.code, 0, `--check-only 应 exit 0,实际 ${c1.code}\n${c1.out}`);
|
|
42
|
-
assert.ok(c1.out.includes('
|
|
44
|
+
assert.ok(c1.out.includes('Stai Travel Bridge'), '报告应含扩展就位节(issue #21 传输层方案 C)');
|
|
43
45
|
assert.ok(!c1.out.includes('hbcli'), 'setup 不再替用户管 hbcli(已让出)');
|
|
44
46
|
assert.ok(!c1.out.includes('agent-reach'), 'setup 不再替用户管 agent-reach(已让出)');
|
|
45
47
|
assert.ok(!c1.out.includes('dsh-better-sidebar'), 'setup 不再替用户管 dsh-better-sidebar(已让出)');
|
|
@@ -191,7 +193,7 @@ console.log('11. setupSidebar 落盘状态复核(pnpm 忽略构建脚本 exit 1
|
|
|
191
193
|
// 只取 missing 一态;degraded 分类在 12 里用独立 item 单测,避免 label-keyed Map 碰撞。
|
|
192
194
|
const onboardingItems = [
|
|
193
195
|
{ label: 'Node 运行时', level: 'ok', detail: 'Node 22.x', fix: undefined },
|
|
194
|
-
{ label: '
|
|
196
|
+
{ label: 'Stai Travel Bridge 扩展', level: 'missing', detail: '未安装', fix: 'https://chromewebstore...' },
|
|
195
197
|
{ label: 'Agent Reach(网页/社媒读取)', level: 'missing', detail: '未装配', fix: 'npx @danceiny/gotry doctor --fix' },
|
|
196
198
|
{ label: 'hbcli(酒店实时源)', level: 'missing', detail: '未安装', fix: 'npx @danceiny/gotry doctor --fix' },
|
|
197
199
|
{ label: 'FlyAI(飞猪官方检索)', level: 'degraded', detail: '未配 FLYAI_API_KEY', fix: '到 flyai 控制台申请 key' },
|
|
@@ -276,7 +278,7 @@ console.log('13. onboardingSkipReason(非 TTY/CI/benchmark/--no-onboarding/SKIP
|
|
|
276
278
|
assert.equal(byLabel.get('Agent Reach(网页/社媒读取)').status, 'installed');
|
|
277
279
|
assert.equal(byLabel.get('hbcli(酒店实时源)').status, 'installed');
|
|
278
280
|
assert.equal(byLabel.get('dsh-better-sidebar(侧栏工作台)').status, 'installed');
|
|
279
|
-
assert.equal(byLabel.get('
|
|
281
|
+
assert.equal(byLabel.get('Stai Travel Bridge 扩展').status, 'needs-user-action');
|
|
280
282
|
assert.equal(byLabel.get('FlyAI(飞猪官方检索)').status, 'needs-user-action');
|
|
281
283
|
assert.equal(byLabel.get('dsh-calendar(日历工作窗口)').status, 'needs-user-action');
|
|
282
284
|
assert.equal(byLabel.get('dsh-map-tools(地图/路线/POI)').status, 'unavailable');
|
|
@@ -410,7 +412,7 @@ console.log('18. onboarding CLI 跳过(非 TTY / CI / GOTRY_SETUP_SKIP / GOTRY_O
|
|
|
410
412
|
assert.ok(autoLabels.includes('hbcli(酒店实时源)'), 'hbcli missing(空 PATH)→ auto');
|
|
411
413
|
assert.ok(autoLabels.includes('Agent Reach(网页/社媒读取)'), 'reach missing(fixture 无 .venv)→ auto');
|
|
412
414
|
const userActionLabels = plan.userAction.map((g) => g.label);
|
|
413
|
-
assert.ok(userActionLabels.includes('
|
|
415
|
+
assert.ok(userActionLabels.includes('Stai Travel Bridge 扩展'), '扩展 missing(隔离 HOME)→ user-action(浏览器商店)');
|
|
414
416
|
assert.ok(userActionLabels.some(label => label.startsWith('FlyAI(飞猪官方检索')), 'flyai 无 key → user-action');
|
|
415
417
|
assert.ok(!plan.unavailable.some((g) => g.label.startsWith('dsh-map-tools')), 'map-tools 随包就位 → 非 unavailable');
|
|
416
418
|
assert.ok(!plan.unavailable.some((g) => g.label.startsWith('dsh-tool-ask-user')), 'ask-user 随包就位 → 非 unavailable');
|
|
@@ -592,7 +594,7 @@ console.log('19. onboarding --scan(临时包 fixture + 隔离 HOME + 受控 PATH
|
|
|
592
594
|
assert.ok(visible.includes('dsh-better-sidebar(侧栏工作台)'), '渲染 sidebar 不可用行');
|
|
593
595
|
assert.match(visible, /Windows[^\n]*dsh-better-sidebar/, 'sidebar 行带 Windows 原因');
|
|
594
596
|
// user-action 项平台无关,仍渲染(扩展商店/flyai key/calendar)
|
|
595
|
-
assert.ok(visible.includes('
|
|
597
|
+
assert.ok(visible.includes('Stai Travel Bridge 扩展'), '渲染扩展 user-action 行');
|
|
596
598
|
assert.ok(visible.includes('FlyAI(飞猪官方检索)'), '渲染 flyai user-action 行');
|
|
597
599
|
assert.ok(visible.includes('dsh-calendar(日历工作窗口)'), '渲染 calendar user-action 行');
|
|
598
600
|
}
|
|
@@ -1415,7 +1417,7 @@ console.log('21. spawned installed-package inner(21a TTY-eligible 真实 prompt
|
|
|
1415
1417
|
assert.match(unavail.get('dsh-better-sidebar(侧栏工作台)').detail, /Windows/, 'sidebar unavailable 给 win 平台具体原因');
|
|
1416
1418
|
// user-action 项不受平台影响(扩展商店/flyai key/calendar profile)
|
|
1417
1419
|
const ua = new Set(plan.userAction.map((g) => g.label));
|
|
1418
|
-
assert.ok(ua.has('
|
|
1420
|
+
assert.ok(ua.has('Stai Travel Bridge 扩展'), 'win32 扩展仍 user-action(平台无关)');
|
|
1419
1421
|
assert.ok(ua.has('FlyAI(飞猪官方检索)'), 'win32 flyai 仍 user-action(平台无关)');
|
|
1420
1422
|
assert.ok(ua.has('dsh-calendar(日历工作窗口)'), 'win32 calendar 仍 user-action(平台无关)');
|
|
1421
1423
|
// darwin 计划对照:auto=3,promptable=true(不回归)
|
|
@@ -1476,4 +1478,72 @@ console.log('22. win32 platform boundary(hbcli/reach/sidebar → unavailable +
|
|
|
1476
1478
|
}
|
|
1477
1479
|
}
|
|
1478
1480
|
console.log('23. result 通道排他写入(预存文件不被覆盖 + symlink 不跟随 victim,mode 0600 + flag wx)OK');
|
|
1479
|
-
|
|
1481
|
+
// 24. hbcli 凭证判定(#623):`gotry doctor` 的 CLI 面按 `hbcli --json auth whoami` 的三档 JSON 判,不看退出码——
|
|
1482
|
+
// whoami 没有凭证时也退 0,旧判法对「没登录」恒报「凭证有效」(rc.27 已发布版实测 14/14)。
|
|
1483
|
+
// CLI(bin/gotry-bootstrap.js)与工具层(ts/capabilities/doctor.ts)是同一检查的两份实现,
|
|
1484
|
+
// 故同一份假 hbcli 夹具同时喂两边对拍:结论必须一致(漂移即红)。受控 PATH 只含 node 软链 + 假 hbcli,
|
|
1485
|
+
// 隔离 HOME,不碰真机 hbcli(开发机常把真 hbcli 装在 node 同目录)。
|
|
1486
|
+
{
|
|
1487
|
+
const { classifyHbcliWhoami } = await import(bootstrap);
|
|
1488
|
+
const allFalse = '{"env":"uat","api_key":{"configured":false},"portal":{"configured":false},"customer":{"configured":false}}';
|
|
1489
|
+
const unit = (out) => classifyHbcliWhoami(out);
|
|
1490
|
+
assert.deepEqual(unit(allFalse), { ok: false, customer: false }, '三档全 false → 未配置');
|
|
1491
|
+
assert.deepEqual(unit(allFalse.replace('"customer":{"configured":false}', '"customer":{"configured":true}')), { ok: true, customer: true }, 'customer 档 → 有效且标注 customer');
|
|
1492
|
+
assert.deepEqual(unit(allFalse.replace('"api_key":{"configured":false}', '"api_key":{"configured":true}')), { ok: true, customer: false }, 'api_key 档 → 有效');
|
|
1493
|
+
assert.deepEqual(unit('not json'), { ok: true, customer: false }, '非 JSON(超旧版/异常)保守视为有效,与工具层同语义');
|
|
1494
|
+
assert.deepEqual(unit(''), { ok: true, customer: false }, '空输出同属不可解析,保守视为有效');
|
|
1495
|
+
assert.deepEqual(unit(null), { ok: false, customer: false }, '取不到输出(非零退出/超时/spawn 失败)→ 未确认');
|
|
1496
|
+
const fakeHbcli = (whoamiArm) => `#!/bin/sh\ncase "$*" in\n *--version*) echo '0.0.4';;\n *version*) echo '0.0.4';;\n *whoami*) ${whoamiArm};;\nesac\n`;
|
|
1497
|
+
const cases = [
|
|
1498
|
+
{ name: 'allfalse', arm: `echo '${allFalse}'`, expect: 'degraded' },
|
|
1499
|
+
{ name: 'customer', arm: `echo '${allFalse.replace('"customer":{"configured":false}', '"customer":{"configured":true}')}'`, expect: 'ok', customer: true },
|
|
1500
|
+
{ name: 'apikey', arm: `echo '${allFalse.replace('"api_key":{"configured":false}', '"api_key":{"configured":true}')}'`, expect: 'ok' },
|
|
1501
|
+
{ name: 'plaintext', arm: `echo 'not json'`, expect: 'ok' },
|
|
1502
|
+
{ name: 'failing', arm: `echo boom >&2; exit 3`, expect: 'degraded' },
|
|
1503
|
+
];
|
|
1504
|
+
const emptyRepo = mkdtempSync(join(tmpdir(), 'gotry-hbcli-parity-repo-'));
|
|
1505
|
+
const prevPath = process.env.PATH;
|
|
1506
|
+
try {
|
|
1507
|
+
for (const c of cases) {
|
|
1508
|
+
const root = mkdtempSync(join(tmpdir(), `gotry-hbcli-${c.name}-`));
|
|
1509
|
+
try {
|
|
1510
|
+
const binDir = join(root, 'bin');
|
|
1511
|
+
const home = join(root, 'home');
|
|
1512
|
+
mkdirSync(binDir);
|
|
1513
|
+
mkdirSync(home);
|
|
1514
|
+
symlinkSync(process.execPath, join(binDir, 'node'));
|
|
1515
|
+
writeFileSync(join(binDir, 'hbcli'), fakeHbcli(c.arm), { mode: 0o755 });
|
|
1516
|
+
// CLI 面
|
|
1517
|
+
const cli = runBootstrap(['doctor'], { HOME: home, PATH: binDir, FLYAI_API_KEY: '' });
|
|
1518
|
+
const line = cli.out.split('\n').find((l) => l.includes('hbcli(酒店实时源)'));
|
|
1519
|
+
assert.ok(line, `${c.name}: CLI doctor 应输出 hbcli 行\n${cli.out}`);
|
|
1520
|
+
const cliLevel = line.includes('已安装且凭证有效') ? 'ok' : line.includes('二进制在,但凭证未配置/失效') ? 'degraded' : 'other';
|
|
1521
|
+
assert.equal(cliLevel, c.expect, `${c.name}: CLI doctor 的 hbcli 结论应为 ${c.expect}\n${line}`);
|
|
1522
|
+
if (c.customer)
|
|
1523
|
+
assert.ok(line.includes('customer 档在用'), `${c.name}: CLI 应标注 customer 档在用\n${line}`);
|
|
1524
|
+
// 工具层(同一夹具、同一受控 PATH)
|
|
1525
|
+
process.env.PATH = binDir;
|
|
1526
|
+
const tool = (await runDoctorChecks({ repoRoot: emptyRepo, homeDir: home, env: {} })).items.find((i) => i.id === 'hbcli');
|
|
1527
|
+
process.env.PATH = prevPath;
|
|
1528
|
+
assert.equal(tool.status, c.expect, `${c.name}: 工具层 doctor 的 hbcli 结论应为 ${c.expect},实际 ${tool.status}`);
|
|
1529
|
+
assert.equal(cliLevel, tool.status, `${c.name}: CLI 与工具层必须同结论(两份实现不得漂移)`);
|
|
1530
|
+
}
|
|
1531
|
+
finally {
|
|
1532
|
+
process.env.PATH = prevPath;
|
|
1533
|
+
try {
|
|
1534
|
+
rmSync(root, { recursive: true, force: true });
|
|
1535
|
+
}
|
|
1536
|
+
catch { /* ignore */ }
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
finally {
|
|
1541
|
+
process.env.PATH = prevPath;
|
|
1542
|
+
try {
|
|
1543
|
+
rmSync(emptyRepo, { recursive: true, force: true });
|
|
1544
|
+
}
|
|
1545
|
+
catch { /* ignore */ }
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
console.log('24. hbcli 凭证判定(CLI doctor 按 whoami 三档 JSON 判,不看退出码;与工具层 doctor 对拍 5 夹具同结论:全 false=degraded / customer·api_key=ok / 非 JSON=保守 ok / 非零退出=degraded)OK');
|
|
1549
|
+
console.log('BOOTSTRAP TESTS: 26/26 OK(扩展就位 + 跳过开关 / wizard --dry-run / wizard 真实 / 扩展分发通道 / doctor 体检面 / calendar setup 状态面 / 显式跳过 + auto 跳过 + 单项跳过 / 启动摘要 / sidebar 落盘状态复核 / onboarding 分类+计划+env opt-out+跳过原因+yes+partial-failure+幂等+prompt+CLI 跳过+--scan / 编排缝 orchestrateWebLaunch × 6 / prompt waiter failure deterministic process-group reap / waiter 原始错误 + cleanup timeout/failure handled / 临时安装包 spawned inner 21a TTY-eligible 真实 prompt→n→web + 摘要抑制 + 无残留 / 21b non-TTY 零 prompt / 21c POSIX 信号清理 SIGTERM→清 result+patch 目录 + 无残留子进程 / 21f yes-path installer 子树信号清理 / 21g yes-path timeout installer 子树清理 / 21d timeout 诊断 + 无残留 / 21e dsh-lifecycle 信号转发 / win32 平台边界 / result 通道排他写入 / hbcli 凭证判定 CLI↔工具层对拍)');
|