@danceiny/gotry 0.2.0-rc.32 → 0.2.0-rc.33

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 (89) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/dist/capabilities/session/bridge-ledger.js +168 -67
  4. package/dist/capabilities/session/extension-bridge.js +140 -46
  5. package/dist/capabilities/session/gui-flight.js +281 -69
  6. package/dist/capabilities/session/gui-hotel.js +74 -17
  7. package/dist/capabilities/session-search.js +6 -5
  8. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  9. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  10. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  11. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  12. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  13. package/dist/scripts/bridge-ledger-tests.js +20 -18
  14. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  15. package/dist/scripts/mutation-check.js +153 -0
  16. package/dist/scripts/nightly-evidence.js +4 -111
  17. package/dist/scripts/session-tests.js +42 -8
  18. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  19. package/dist/scripts/tenant-capacity-probe.js +263 -0
  20. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  21. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  22. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  23. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  24. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  25. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  26. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  27. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  28. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  29. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  30. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  31. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  32. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  33. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  34. package/dist/scripts/tenant-state-router-tests.js +464 -0
  35. package/dist/scripts/tenant-state-test-kit.js +92 -0
  36. package/dist/scripts/tenant-state-tests.js +564 -0
  37. package/dist/src/backend/modules/booking-executor.js +61 -6
  38. package/dist/src/backend/modules/session-search.js +136 -39
  39. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  40. package/dist/src/booking-surface/dsh-planner.js +150 -19
  41. package/dist/src/booking-surface/index.js +1 -0
  42. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  43. package/dist/src/booking-surface/server.js +193 -80
  44. package/dist/src/booking-surface/startup.js +153 -0
  45. package/dist/src/booking-surface/tenancy.js +188 -0
  46. package/dist/src/index.js +31 -1
  47. package/dist/src/llm-price-table.js +148 -0
  48. package/dist/src/tenant-seam/index.js +8 -0
  49. package/dist/src/tenant-seam/llm-broker.js +508 -0
  50. package/dist/src/tenant-seam/process-governor.js +281 -0
  51. package/dist/src/tenant-seam/quotas.js +360 -0
  52. package/dist/src/tenant-seam/seam.js +92 -0
  53. package/dist/src/tenant-state/durable-store.js +158 -0
  54. package/dist/src/tenant-state/epoch-copies.js +422 -0
  55. package/dist/src/tenant-state/errors.js +108 -0
  56. package/dist/src/tenant-state/ids.js +101 -0
  57. package/dist/src/tenant-state/index.js +11 -0
  58. package/dist/src/tenant-state/lease-store.js +203 -0
  59. package/dist/src/tenant-state/tenant-lease.js +150 -0
  60. package/dist/src/tenant-state/tenant-router.js +650 -0
  61. package/extension/manifest.json +2 -2
  62. package/package.json +4 -1
  63. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  64. package/ts/capabilities/session/extension-bridge.ts +176 -65
  65. package/ts/capabilities/session/gui-flight.ts +318 -88
  66. package/ts/capabilities/session/gui-hotel.ts +72 -18
  67. package/ts/capabilities/session-search.ts +17 -5
  68. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  69. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  70. package/ts/src/booking-surface/index.ts +1 -0
  71. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  72. package/ts/src/booking-surface/server.ts +215 -84
  73. package/ts/src/booking-surface/startup.ts +155 -0
  74. package/ts/src/booking-surface/tenancy.ts +242 -0
  75. package/ts/src/index.ts +28 -2
  76. package/ts/src/llm-price-table.ts +180 -0
  77. package/ts/src/tenant-seam/index.ts +9 -0
  78. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  79. package/ts/src/tenant-seam/process-governor.ts +343 -0
  80. package/ts/src/tenant-seam/quotas.ts +433 -0
  81. package/ts/src/tenant-seam/seam.ts +142 -0
  82. package/ts/src/tenant-state/durable-store.ts +171 -0
  83. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  84. package/ts/src/tenant-state/errors.ts +128 -0
  85. package/ts/src/tenant-state/ids.ts +108 -0
  86. package/ts/src/tenant-state/index.ts +12 -0
  87. package/ts/src/tenant-state/lease-store.ts +266 -0
  88. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  89. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Proof tests for the gateway chat-completions LLM plugin
3
+ * (dsh-llm-gateway-chat.js): wire dialect against a mock OpenAI-style
4
+ * chat/completions server, patch wiring, and provider selection.
5
+ *
6
+ * Run: npx tsx scripts/booking-copilot-gateway-chat-adapter-tests.ts
7
+ */
8
+ import assert from 'node:assert/strict';
9
+ import { createServer } from 'node:http';
10
+ // A string-typed specifier keeps TypeScript from resolving the undeclared JS
11
+ // module; GatewayChatModule above is the surface this proof relies on.
12
+ const GATEWAY_CHAT_MODULE = '../src/booking-surface/dsh-llm-gateway-chat.js';
13
+ const { GatewayChatAdapter } = await import(GATEWAY_CHAT_MODULE);
14
+ const { buildDshEmbeddedBookingPatch, resolveRealRunPortConfig } = await import('../src/booking-surface/dsh-planner.js');
15
+ function chunksOf(chunks, type) {
16
+ return chunks.filter((chunk) => chunk.type === type);
17
+ }
18
+ function firstChunk(chunks, type) {
19
+ const [chunk] = chunksOf(chunks, type);
20
+ assert.ok(chunk, `expected a ${type} chunk`);
21
+ return chunk;
22
+ }
23
+ function errorCode(error) {
24
+ return typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined;
25
+ }
26
+ function sse(res, frames) {
27
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
28
+ for (const frame of frames)
29
+ res.write(frame);
30
+ res.end('data: [DONE]\n\n');
31
+ }
32
+ async function withMockServer(handler, run) {
33
+ const server = createServer(handler);
34
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
35
+ try {
36
+ const address = server.address();
37
+ assert.ok(address !== null && typeof address === 'object', 'mock server listens on a TCP port');
38
+ return await run(address.port);
39
+ }
40
+ finally {
41
+ server.close();
42
+ }
43
+ }
44
+ function adapterFor(port, captured) {
45
+ return new GatewayChatAdapter({
46
+ resolveConnection: () => ({ baseURL: `http://127.0.0.1:${port}/v1`, models: [] }),
47
+ resolveApiKey: async () => 'test-only-key',
48
+ providerName: 'Gateway Chat',
49
+ ...(captured !== undefined ? { __captured: captured } : {}),
50
+ });
51
+ }
52
+ // 1. A tool-calling stream translates to harness chunks: text block, tool-call
53
+ // accumulation across deltas, deferred block-ends, usage, and tool-calls finish.
54
+ {
55
+ const requests = [];
56
+ const chunks = await withMockServer((req, res) => {
57
+ let body = '';
58
+ req.on('data', (c) => { body += c; });
59
+ req.on('end', () => {
60
+ requests.push(JSON.parse(body));
61
+ sse(res, [
62
+ 'data: {"choices":[{"delta":{"role":"assistant","content":"Let me search"}}]}\n\n',
63
+ 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"booking_search_hotels","arguments":"{\\"input\\":"}}]}}]}\n\n',
64
+ 'data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"destination\\":\\"Osaka\\"}"}}]}}]}\n\n',
65
+ 'data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":100,"completion_tokens":20,"total_tokens":120,"prompt_tokens_details":{"cached_tokens":40},"completion_tokens_details":{"reasoning_tokens":5}}}\n\n',
66
+ ]);
67
+ });
68
+ }, async (port) => {
69
+ const out = [];
70
+ for await (const chunk of adapterFor(port).stream({
71
+ provider: 'gateway-chat',
72
+ model: 'deepseek-flash',
73
+ messages: [
74
+ { role: 'system', content: [{ type: 'text', text: 'planner system prompt' }] },
75
+ { role: 'user', content: [{ type: 'text', text: 'hotels in Osaka' }] },
76
+ ],
77
+ tools: [{ name: 'booking_search_hotels', description: 'search hotels', parameters: { type: 'object', properties: {} } }],
78
+ maxTokens: 16_384,
79
+ }))
80
+ out.push(chunk);
81
+ return out;
82
+ });
83
+ assert.equal(requests.length, 1);
84
+ assert.equal(requests[0].model, 'deepseek-flash');
85
+ assert.deepEqual(requests[0].messages.map((m) => m.role), ['system', 'user']);
86
+ assert.equal(requests[0].tools?.[0].type, 'function');
87
+ assert.equal(requests[0].tools?.[0].function.name, 'booking_search_hotels');
88
+ assert.equal(requests[0].stream, true);
89
+ assert.deepEqual(requests[0].stream_options, { include_usage: true });
90
+ // The harness profile runs the planner with thinking enabled at high effort
91
+ // (rc.1 wire parity); dropping these fields measurably degrades the model's
92
+ // adherence to the planner's intent rules.
93
+ assert.deepEqual(requests[0].thinking, { type: 'enabled' });
94
+ assert.equal(requests[0].reasoning_effort, 'high');
95
+ const kinds = chunks.map((c) => c.type);
96
+ assert.deepEqual(kinds, ['block-start', 'text-delta', 'block-start', 'tool-call-delta', 'tool-call-delta', 'block-end', 'block-end', 'usage', 'finish']);
97
+ const text = firstChunk(chunks, 'text-delta');
98
+ assert.equal(text.text, 'Let me search');
99
+ const toolDelta = chunksOf(chunks, 'tool-call-delta');
100
+ assert.equal(toolDelta[0].id, 'call_1');
101
+ assert.equal(toolDelta[0].name, 'booking_search_hotels');
102
+ assert.equal(toolDelta.map((c) => c.argumentsDelta).join(''), '{"input":{"destination":"Osaka"}');
103
+ const ends = chunksOf(chunks, 'block-end');
104
+ assert.equal(ends[0].block.type, 'text');
105
+ const toolBlock = ends[1].block;
106
+ assert.ok(toolBlock.type === 'tool-call', 'the second block closes the tool call');
107
+ assert.equal(toolBlock.arguments, '{"input":{"destination":"Osaka"}');
108
+ const usage = firstChunk(chunks, 'usage');
109
+ assert.equal(usage.usage.inputTokens, 60, 'cache reads subtract from prompt tokens');
110
+ assert.equal(usage.usage.outputTokens, 20);
111
+ assert.equal(usage.usage.totalTokens, 120);
112
+ assert.equal(usage.usage.cacheReadTokens, 40);
113
+ assert.equal(usage.usage.reasoningTokens, 5);
114
+ const finish = firstChunk(chunks, 'finish');
115
+ assert.deepEqual(finish.reason, { kind: 'tool-calls' });
116
+ }
117
+ // 2. Tool-result history round-trips as `role:"tool"` wire messages.
118
+ {
119
+ const requests = [];
120
+ await withMockServer((req, res) => {
121
+ let body = '';
122
+ req.on('data', (c) => { body += c; });
123
+ req.on('end', () => {
124
+ requests.push(JSON.parse(body));
125
+ sse(res, [
126
+ 'data: {"choices":[{"delta":{"content":"done"}}]}\n\n',
127
+ 'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
128
+ ]);
129
+ });
130
+ }, async (port) => {
131
+ const out = [];
132
+ for await (const chunk of adapterFor(port).stream({
133
+ provider: 'gateway-chat',
134
+ model: 'deepseek-flash',
135
+ reasoningEffort: 'off',
136
+ messages: [
137
+ { role: 'user', content: [{ type: 'text', text: 'hotels in Osaka' }] },
138
+ { role: 'assistant', content: [{ type: 'tool-call', id: 'call_9', name: 'booking_search_hotels', arguments: '{"input":{}}' }] },
139
+ { role: 'user', content: [{ type: 'tool-result', toolCallId: 'call_9', content: [{ type: 'text', text: '598 hotels' }] }] },
140
+ ],
141
+ }))
142
+ out.push(chunk);
143
+ return out;
144
+ });
145
+ assert.deepEqual(requests[0].thinking, { type: 'disabled' }, 'reasoningEffort=off maps to thinking disabled');
146
+ assert.equal(requests[0].reasoning_effort, undefined, 'off never crosses the wire as an effort');
147
+ const roles = requests[0].messages.map((m) => m.role);
148
+ assert.deepEqual(roles, ['user', 'assistant', 'tool']);
149
+ assert.equal(requests[0].messages[1].tool_calls?.[0].id, 'call_9');
150
+ assert.equal(requests[0].messages[2].tool_call_id, 'call_9');
151
+ assert.equal(requests[0].messages[2].content, '598 hotels');
152
+ }
153
+ // 3. HTTP failure statuses map to typed LlmError codes.
154
+ {
155
+ await withMockServer((req, res) => {
156
+ res.writeHead(401, { 'content-type': 'application/json' });
157
+ res.end('{"error":"bad key"}');
158
+ }, async (port) => {
159
+ await assert.rejects(() => adapterFor(port).stream({ provider: 'gateway-chat', model: 'm', messages: [{ role: 'user', content: [{ type: 'text', text: 'x' }] }] }).next(), (error) => errorCode(error) === 'AUTH');
160
+ });
161
+ }
162
+ // 4. A stream that dies before [DONE] is STREAM_CLOSED, never a silent success.
163
+ {
164
+ await withMockServer((req, res) => {
165
+ res.writeHead(200, { 'content-type': 'text/event-stream' });
166
+ res.write('data: {"choices":[{"delta":{"content":"partial"}}]}\n\n');
167
+ res.end();
168
+ }, async (port) => {
169
+ await assert.rejects(() => adapterFor(port).stream({ provider: 'gateway-chat', model: 'm', messages: [{ role: 'user', content: [{ type: 'text', text: 'x' }] }] }).next(), () => true).catch((error) => {
170
+ // first next() may surface the text-delta fine; the failure lands on the
171
+ // following tick — drain to the throw.
172
+ });
173
+ let saw = false;
174
+ try {
175
+ for await (const _chunk of adapterFor(port).stream({ provider: 'gateway-chat', model: 'm', messages: [{ role: 'user', content: [{ type: 'text', text: 'x' }] }] })) {
176
+ void _chunk;
177
+ }
178
+ }
179
+ catch (error) {
180
+ saw = errorCode(error) === 'STREAM_CLOSED';
181
+ }
182
+ assert.ok(saw, 'a truncated stream must surface STREAM_CLOSED');
183
+ });
184
+ }
185
+ // 4b. Exact model metadata satisfies the harness INVALID_MODEL_INFO gate
186
+ // (provider/id/name required) and prepareCall returns the { model } shape.
187
+ {
188
+ const adapter = adapterFor(1);
189
+ const info = await adapter.resolveModel('gateway-chat', 'deepseek-flash');
190
+ assert.equal(info.provider, 'gateway-chat');
191
+ assert.equal(info.id, 'deepseek-flash');
192
+ assert.equal(info.name, 'deepseek-flash');
193
+ assert.ok(info.context.contextWindow > 0);
194
+ assert.ok(info.reasoning.efforts.some((e) => e.id === 'off'));
195
+ const prepared = await adapter.prepareCall('gateway-chat', 'deepseek-flash');
196
+ assert.equal(prepared.model.id, 'deepseek-flash');
197
+ assert.equal(typeof prepared.stream, 'function');
198
+ }
199
+ // 5. The patch renders both plugin inserts: the typed tools plugin and the
200
+ // gateway chat-completions LLM plugin.
201
+ {
202
+ const patch = buildDshEmbeddedBookingPatch('/tools/plugin.js', '/llm/gateway.js');
203
+ assert.match(patch, /- id: gotry-embedded-booking\n/);
204
+ assert.match(patch, /name: '\/tools\/plugin\.js'/);
205
+ assert.match(patch, /- id: llm-gateway-chat\n/);
206
+ assert.match(patch, /name: '\/llm\/gateway\.js'/);
207
+ const minimal = buildDshEmbeddedBookingPatch('/tools/plugin.js');
208
+ assert.match(minimal, /gotry-embedded-booking/);
209
+ assert.doesNotMatch(minimal, /llm-gateway-chat/);
210
+ }
211
+ // 6. Provider selection: DEEPSEEK_PROVIDER rides the child env and selects the
212
+ // gateway route without an explicit option.
213
+ {
214
+ const resolved = resolveRealRunPortConfig({
215
+ env: {
216
+ DEEPSEEK_API_KEY: 'k',
217
+ DEEPSEEK_BASE_URL: 'http://gw/v1',
218
+ DEEPSEEK_MODEL: 'deepseek-flash',
219
+ DEEPSEEK_PROVIDER: 'gateway-chat',
220
+ },
221
+ });
222
+ assert.equal(resolved.provider, 'gateway-chat');
223
+ assert.equal(resolved.childEnv.DEEPSEEK_PROVIDER, 'gateway-chat');
224
+ const fallback = resolveRealRunPortConfig({
225
+ env: {
226
+ DEEPSEEK_API_KEY: 'k',
227
+ DEEPSEEK_MODEL: 'deepseek-flash',
228
+ },
229
+ });
230
+ assert.equal(fallback.provider, 'deepseek-official');
231
+ assert.equal(fallback.childEnv.DEEPSEEK_PROVIDER, undefined);
232
+ }
233
+ console.log('BOOKING COPILOT GATEWAY CHAT ADAPTER PROOF: wire dialect/tool round-trip/status mapping/patch wiring/provider selection OK');
@@ -67,6 +67,7 @@ try {
67
67
  DEEPSEEK_API_KEY: apiKey,
68
68
  DEEPSEEK_BASE_URL: baseUrl,
69
69
  DEEPSEEK_MODEL: model,
70
+ ...(process.env.DEEPSEEK_PROVIDER ? { DEEPSEEK_PROVIDER: process.env.DEEPSEEK_PROVIDER } : {}),
70
71
  },
71
72
  // Match the UAT deployment deadline (planner-model-plane.yaml turnTimeoutMs).
72
73
  turnTimeoutMs: 45_000,
@@ -68,7 +68,7 @@ const started = await startBookingCopilotFromEnvironment(trustedEnv, {
68
68
  ingressBinding: trustedBinding,
69
69
  principal: { subject: 'startup-bff', scope: 'booking:read' },
70
70
  async startServer(options) {
71
- serverApiKey = options.apiKey;
71
+ serverApiKey = options.apiKey ?? '';
72
72
  serverArtifactId = options.artifactId ?? '';
73
73
  assert.equal(options.ingressMode, 'bff-ingress-binding');
74
74
  return {
@@ -31,6 +31,8 @@ import Database from 'better-sqlite3';
31
31
  import { EXTENSION_ORIGIN, createBridgeJobQueue, } from '../capabilities/session/extension-bridge.js';
32
32
  import { BRIDGE_SITE_COOLDOWN_MS, BridgeLedgerStore, bridgeLedgerDbPath, openBridgeLedgerStore, } from '../capabilities/session/bridge-ledger.js';
33
33
  import { startSessionSearchModule } from '../src/backend/modules/session-search.js';
34
+ // 账本方法的组织参数必填(漏传是编译错误):本套件验证的是缺省的单车道,统一传 local
35
+ const LOCAL = 'local';
34
36
  let passed = 0;
35
37
  async function check(label, assertion) {
36
38
  await assertion();
@@ -180,7 +182,7 @@ async function main() {
180
182
  assert.equal(row?.claimed_by_client, CLAIM_CLIENT);
181
183
  assert.equal(row?.attempts, 1);
182
184
  assert.ok(row?.claimed_at !== null, 'claimed_at 落账');
183
- const pacing = storeC.pacingOf('dida-portal');
185
+ const pacing = storeC.pacingOf(LOCAL, 'dida-portal');
184
186
  assert.ok(pacing && pacing.cooldownUntil > Date.now(), '导航类作业领取即记节律冷却');
185
187
  // 正常善果回包 → resolved + result 保留
186
188
  const r = await postResult(server.port, polled.job.jobId, { ok: true, kind: 'search', body: '{"rates":[]}', title: 'ok' }, { clientId: CLAIM_CLIENT });
@@ -213,7 +215,7 @@ async function main() {
213
215
  const row = storeC.getJob(polled.job.jobId);
214
216
  assert.equal(row?.status, 'unresolved', '曾被领取的超时终态 = unresolved');
215
217
  assert.ok(row?.result_json?.includes('timeout'), '超时结算留 timeout 备注');
216
- const pacing = storeC.pacingOf('train-12306');
218
+ const pacing = storeC.pacingOf(LOCAL, 'train-12306');
217
219
  assert.ok(pacing && pacing.cooldownUntil >= Date.now() + BRIDGE_SITE_COOLDOWN_MS - 2_000, '超时结算把站点冷却推满');
218
220
  }
219
221
  finally {
@@ -224,7 +226,7 @@ async function main() {
224
226
  const server = await serveQueue(queueC);
225
227
  try {
226
228
  const pending = queueC.submit({ kind: 'search', site: 'ctrip-hotel', url: 'https://hotels.ctrip.com/hotels/list' }, { timeoutMs: 300, extensionWaitMs: 30_000 });
227
- const pacingAtSubmit = storeC.pacingOf('ctrip-hotel');
229
+ const pacingAtSubmit = storeC.pacingOf(LOCAL, 'ctrip-hotel');
228
230
  assert.ok(pacingAtSubmit && pacingAtSubmit.cooldownUntil > Date.now(), '导航类作业提交即记节律(与 sessionDidaSearch 内存闸同语义:提交即承诺一次导航)');
229
231
  const outcome = await pending;
230
232
  assert.equal(outcome.ok, false);
@@ -238,7 +240,7 @@ async function main() {
238
240
  raw.close();
239
241
  }
240
242
  assert.equal(voided.length, 1, '未被领取的超时作业 = void');
241
- const pacingAfter = storeC.pacingOf('ctrip-hotel');
243
+ const pacingAfter = storeC.pacingOf(LOCAL, 'ctrip-hotel');
242
244
  assert.equal(pacingAfter?.cooldownUntil, pacingAtSubmit?.cooldownUntil, '从未被领取的超时结算不追加冷却(站点未被打)');
243
245
  }
244
246
  finally {
@@ -273,12 +275,12 @@ async function main() {
273
275
  let server;
274
276
  try {
275
277
  // 直接落账模拟上一进程崩溃后的持久状态(不经过队列 close——close 会把未领取作业落 void)
276
- storeE.insertQueuedJob({ jobId: 'rec-queued-fresh', kind: 'search', site: 'dida-portal', url: 'https://portal.dida.com/find' }, t0 + 60_000);
277
- storeE.insertQueuedJob({ jobId: 'rec-queued-stale', kind: 'search', site: 'dida-portal', url: 'https://portal.dida.com/find' }, t0 - 1_000);
278
- storeE.insertQueuedJob({ jobId: 'rec-claimed-live', kind: 'search', site: 'ctrip-flight', url: 'https://flights.ctrip.com/online/list' }, t0 + 60_000);
279
- storeE.markClaimed('rec-claimed-live', { origin: EXTENSION_ORIGIN, clientId: LIVE_CLIENT });
280
- storeE.insertQueuedJob({ jobId: 'rec-claimed-dead', kind: 'search', site: 'ctrip-hotel', url: 'https://hotels.ctrip.com/hotels/list' }, t0 - 1_000);
281
- storeE.markClaimed('rec-claimed-dead', { origin: EXTENSION_ORIGIN });
278
+ storeE.insertQueuedJob(LOCAL, { jobId: 'rec-queued-fresh', kind: 'search', site: 'dida-portal', url: 'https://portal.dida.com/find' }, t0 + 60_000);
279
+ storeE.insertQueuedJob(LOCAL, { jobId: 'rec-queued-stale', kind: 'search', site: 'dida-portal', url: 'https://portal.dida.com/find' }, t0 - 1_000);
280
+ storeE.insertQueuedJob(LOCAL, { jobId: 'rec-claimed-live', kind: 'search', site: 'ctrip-flight', url: 'https://flights.ctrip.com/online/list' }, t0 + 60_000);
281
+ storeE.markClaimed(LOCAL, 'rec-claimed-live', { origin: EXTENSION_ORIGIN, clientId: LIVE_CLIENT });
282
+ storeE.insertQueuedJob(LOCAL, { jobId: 'rec-claimed-dead', kind: 'search', site: 'ctrip-hotel', url: 'https://hotels.ctrip.com/hotels/list' }, t0 - 1_000);
283
+ storeE.markClaimed(LOCAL, 'rec-claimed-dead', { origin: EXTENSION_ORIGIN });
282
284
  q = createBridgeJobQueue({ store: storeE });
283
285
  server = await serveQueue(q);
284
286
  // 恢复先于任何轮询执行(等价挂载路径:模块启动即 recoverOnBoot)
@@ -289,7 +291,7 @@ async function main() {
289
291
  assert.equal(summary.settledUnresolved, 1);
290
292
  assert.equal(storeE.getJob('rec-queued-stale')?.status, 'void', '未被领取即过期 → void,不再派发(零花费)');
291
293
  assert.equal(storeE.getJob('rec-claimed-dead')?.status, 'unresolved', '领取后超时 → unresolved');
292
- assert.ok(storeE.pacingOf('ctrip-hotel') && storeE.pacingOf('ctrip-hotel').cooldownUntil > Date.now(), '超时结算记节律冷却');
294
+ assert.ok(storeE.pacingOf(LOCAL, 'ctrip-hotel') && storeE.pacingOf(LOCAL, 'ctrip-hotel').cooldownUntil > Date.now(), '超时结算记节律冷却');
293
295
  // 重排作业可被新进程的扩展领取并善果落账
294
296
  const polled = await pollJobs(server.port, { clientId: LIVE_CLIENT, capabilities: ['dida-portal'] });
295
297
  assert.equal(polled.job?.jobId, 'rec-queued-fresh', '恢复的 queued 作业重排进队列');
@@ -314,13 +316,13 @@ async function main() {
314
316
  const server = await serveQueue(queueC);
315
317
  try {
316
318
  const CLIENT = randomUUID();
317
- const pacingBefore = storeC.pacingOf('dida-portal');
319
+ const pacingBefore = storeC.pacingOf(LOCAL, 'dida-portal');
318
320
  const first = queueC.submit({ kind: 'cookie-names', site: 'dida-portal', timeoutMs: 3_000 }, { timeoutMs: 3_000 });
319
321
  const polled = await pollJobs(server.port, { clientId: CLIENT, capabilities: ['dida-portal'] });
320
322
  assert.ok(polled.job, 'cookie-names 作业应被领取');
321
323
  await postResult(server.port, polled.job.jobId, { ok: true, kind: 'cookie-names', names: ['dida_session_sig'] }, { clientId: CLIENT });
322
324
  assert.equal((await first).ok, true);
323
- const row1 = storeC.getClient(CLIENT);
325
+ const row1 = storeC.getClient(LOCAL, CLIENT);
324
326
  assert.ok(row1, '轮询即注册客户端');
325
327
  assert.equal(row1?.origin, EXTENSION_ORIGIN);
326
328
  assert.deepEqual(JSON.parse(row1?.capabilities_json ?? '[]'), ['dida-portal']);
@@ -332,11 +334,11 @@ async function main() {
332
334
  assert.ok(polled2.job, '第二次轮询仍可领取');
333
335
  await postResult(server.port, polled2.job.jobId, { ok: true, kind: 'cookie-names', names: [] }, { clientId: CLIENT });
334
336
  assert.equal((await second).ok, true);
335
- const row2 = storeC.getClient(CLIENT);
337
+ const row2 = storeC.getClient(LOCAL, CLIENT);
336
338
  assert.deepEqual(JSON.parse(row2?.capabilities_json ?? '[]'), ['dida-portal', 'train-12306'], 'upsert 覆盖能力清单');
337
339
  assert.ok((row2?.last_seen_at ?? 0) >= seen1, 'last_seen_at 单调刷新');
338
340
  assert.deepEqual(JSON.parse(row2?.login_sites_json ?? '[]'), ['dida-portal'], 'upsert 不回退 login_sites');
339
- const pacingAfter = storeC.pacingOf('dida-portal');
341
+ const pacingAfter = storeC.pacingOf(LOCAL, 'dida-portal');
340
342
  assert.deepEqual(pacingAfter, pacingBefore, 'cookie-names 非导航作业,不推动节律');
341
343
  }
342
344
  finally {
@@ -352,8 +354,8 @@ async function main() {
352
354
  try {
353
355
  // 预置一条上一进程遗留的已超时 claimed 作业:模块启动即应结算 unresolved
354
356
  const seed = openBridgeLedgerStore(rootG);
355
- seed.insertQueuedJob({ jobId: 'mount-recover-dead', kind: 'search', site: 'dida-portal', url: 'https://portal.dida.com/find' }, Date.now() - 1_000);
356
- seed.markClaimed('mount-recover-dead', { origin: EXTENSION_ORIGIN });
357
+ seed.insertQueuedJob(LOCAL, { jobId: 'mount-recover-dead', kind: 'search', site: 'dida-portal', url: 'https://portal.dida.com/find' }, Date.now() - 1_000);
358
+ seed.markClaimed(LOCAL, 'mount-recover-dead', { origin: EXTENSION_ORIGIN });
357
359
  seed.close();
358
360
  const mod = startSessionSearchModule({
359
361
  apiKey: () => 'ledger-test-key',
@@ -369,7 +371,7 @@ async function main() {
369
371
  // recoverOnBoot 已在模块启动路径跑过:遗留 claimed 超时作业被结算
370
372
  assert.equal(probe.getJob('mount-recover-dead')?.status, 'unresolved', '启动即恢复结算遗留超时作业');
371
373
  // 账本节律读判定:冷却内 429(带 retry-after)
372
- probe.applyCooldown('dida-portal');
374
+ probe.applyCooldown(LOCAL, 'dida-portal');
373
375
  const hot = await driveModuleSearch(mod, 'Bearer ledger-test-key');
374
376
  assert.equal(hot.statusCode, 429, '账本冷却内检索被节律闸拒绝');
375
377
  assert.ok(String(hot.headers['retry-after'] ?? '').length > 0, '429 携带 retry-after');
@@ -0,0 +1,73 @@
1
+ // A worker for the managed port's JSON-lines protocol that misbehaves on request (AaaS threat S-17 drill).
2
+ // Every runaway mode bounds itself, so a governor that fails costs a red test and not a dead machine.
3
+ import { spawn } from 'node:child_process'
4
+ import { writeFileSync } from 'node:fs'
5
+ import { createInterface } from 'node:readline'
6
+
7
+ const MIB = 1024 * 1024
8
+
9
+ if (process.env.TENANT_FIXTURE_PID_FILE) writeFileSync(process.env.TENANT_FIXTURE_PID_FILE, String(process.pid))
10
+ // TENANT_FIXTURE_NO_LIFECYCLE=1: a worker that never says who it is, so nothing can attach a governor to it.
11
+ if (process.env.TENANT_FIXTURE_NO_LIFECYCLE !== '1') {
12
+ // TENANT_FIXTURE_LIE_PID=1: it names a process that is not its own, so a sampler that trusts it would watch nothing.
13
+ const reported = process.env.TENANT_FIXTURE_LIE_PID === '1' ? 2_147_000_000 : process.pid
14
+ process.stdout.write(`${JSON.stringify({ lifecycle: 'worker_started', workerPid: reported, parentPid: process.ppid })}\n`)
15
+ }
16
+
17
+ const hold = []
18
+ const input = createInterface({ input: process.stdin, crlfDelay: Infinity })
19
+
20
+ function reply(value) {
21
+ process.stdout.write(`${JSON.stringify(value)}\n`)
22
+ }
23
+
24
+ input.on('line', (line) => {
25
+ const request = JSON.parse(line)
26
+ // TENANT_FIXTURE_MODE lets a planner, which sends its own prompt text, drive the same behavior.
27
+ const mode = process.env.TENANT_FIXTURE_MODE ?? request.prompt
28
+ if (mode === 'ok') {
29
+ reply({ id: request.id, ok: true, result: { finalResponse: 'ok', events: [{
30
+ nodeOptions: process.env.NODE_OPTIONS ?? null,
31
+ execArgv: process.execArgv,
32
+ ambient: { LLM_BASE_URL: process.env.LLM_BASE_URL ?? null, LLM_API_KEY: process.env.LLM_API_KEY ?? null, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? null, DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL ?? null },
33
+ }], notifications: [] } })
34
+ } else if (mode === 'memory') {
35
+ // Off-heap memory: the V8 heap cap cannot see a Buffer, so only the process-tree sampler can stop this one.
36
+ let total = 0
37
+ const timer = setInterval(() => {
38
+ hold.push(Buffer.alloc(16 * MIB, 1))
39
+ total += 16 * MIB
40
+ if (total >= 1536 * MIB) clearInterval(timer)
41
+ }, 10)
42
+ } else if (mode === 'memory-in-child') {
43
+ // The runaway is a descendant, not the worker: the tree is what is limited.
44
+ const child = spawn(process.execPath, ['-e', `
45
+ const hold = []; let total = 0
46
+ const timer = setInterval(() => { hold.push(Buffer.alloc(${16 * MIB}, 1)); total += ${16 * MIB}; if (total >= ${1536 * MIB}) clearInterval(timer) }, 10)
47
+ `], { stdio: 'ignore' })
48
+ reply({ id: request.id, ok: true, result: { finalResponse: 'spawned', events: [{ childPid: child.pid }], notifications: [] } })
49
+ } else if (mode === 'cpu') {
50
+ // Blocks this thread's event loop; the check comes from outside. Gives up by itself after 30 s.
51
+ const end = Date.now() + 30_000
52
+ while (Date.now() < end) { /* spin */ }
53
+ } else if (mode === 'heap') {
54
+ // JS heap growth: the V8 ceiling in NODE_OPTIONS must end this long before the machine notices.
55
+ const objects = []
56
+ while (process.memoryUsage().heapUsed < 900 * MIB) objects.push({ filler: 'x'.repeat(1000) })
57
+ reply({ id: request.id, ok: true, result: { finalResponse: 'heap-not-limited', events: [], notifications: [] } })
58
+ } else if (mode === 'flood') {
59
+ // One line that never ends, written as fast as the pipe takes it, up to 256 MiB.
60
+ const chunk = 'x'.repeat(64 * 1024)
61
+ let written = 0
62
+ const write = () => {
63
+ while (written < 256 * MIB) {
64
+ written += chunk.length
65
+ if (!process.stdout.write(chunk)) { process.stdout.once('drain', write); return }
66
+ }
67
+ }
68
+ write()
69
+ }
70
+ })
71
+
72
+ process.on('SIGTERM', () => process.exit(143))
73
+ setInterval(() => {}, 1_000)
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Mutation check runner (AaaS A2, docs/rfc/aaas-rfc.md §8.3: "falsification tests that must fail against a
3
+ * deliberately broken guard").
4
+ *
5
+ * A mutation list names a suite and a set of source edits. For each edit the runner clones the source trees into a
6
+ * scratch directory, breaks exactly one guard there, runs the suite, and requires it to fail. A suite that stays
7
+ * green under a broken guard proves nothing about that guard, so a surviving mutation fails the run. The list's
8
+ * unmutated baseline must pass first, otherwise "red" would only mean the harness is broken.
9
+ *
10
+ * The working tree is never edited: mutations happen in the clone, so a run can sit beside other work. A mutation file must
11
+ * name a path inside the clone (no absolute path, no `..`, nothing under node_modules, which is shared with the real tree).
12
+ *
13
+ * A mutation is "killed" only when the suite fails for a reason about behaviour. A suite that times out, and an edit that
14
+ * does not even load (syntax or module errors), prove nothing about the guard, so both fail the run as well.
15
+ *
16
+ * cd ts && npx tsx scripts/mutation-check.ts scripts/tenant-seam-mutations.json [--only <id>] [--keep] [--scratch <dir>]
17
+ *
18
+ * List format: { "suite": "scripts/x-tests.ts", "timeoutMs"?: number,
19
+ * "mutations": [{ "id", "file" (relative to ts/), "find" (must occur exactly once), "replace", "note"? }] }
20
+ */
21
+ import { spawnSync } from 'node:child_process';
22
+ import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
23
+ import { tmpdir } from 'node:os';
24
+ import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ const TS_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
27
+ const REPO_ROOT = resolve(TS_ROOT, '..');
28
+ const SKIP = new Set(['node_modules', '.git', 'dsh-runtime', 'gotry-state']);
29
+ function argValue(args, flag) {
30
+ const at = args.indexOf(flag);
31
+ return at >= 0 ? args[at + 1] : undefined;
32
+ }
33
+ function cloneTrees(scratch) {
34
+ const copy = (from, to) => {
35
+ cpSync(from, to, { recursive: true, filter: (source) => !SKIP.has(basename(source)) });
36
+ };
37
+ const tsClone = join(scratch, 'ts');
38
+ copy(TS_ROOT, tsClone);
39
+ for (const dir of ['schemas', 'data']) {
40
+ if (existsSync(join(REPO_ROOT, dir)))
41
+ copy(join(REPO_ROOT, dir), join(scratch, dir));
42
+ }
43
+ // Installed packages are shared, not copied: the suites only read them.
44
+ symlinkSync(join(TS_ROOT, 'node_modules'), join(tsClone, 'node_modules'));
45
+ if (existsSync(join(REPO_ROOT, 'node_modules')))
46
+ symlinkSync(join(REPO_ROOT, 'node_modules'), join(scratch, 'node_modules'));
47
+ return tsClone;
48
+ }
49
+ /** An edit that does not load says nothing about a guard: `tsx` reports these, not an assertion. */
50
+ const BROKEN_EDIT = /SyntaxError|TransformError|Transform failed|ERR_MODULE_NOT_FOUND|ERR_UNKNOWN_FILE_EXTENSION|Unexpected token/;
51
+ class ListError extends Error {
52
+ }
53
+ /** The file a mutation edits, which must be inside the scratch clone and never in the shared node_modules. */
54
+ function mutationTarget(tsClone, file) {
55
+ const parts = file.split(/[\\/]/);
56
+ if (isAbsolute(file) || parts.includes('..') || parts.includes('node_modules'))
57
+ throw new ListError(`mutation file "${file}" must be a relative path inside ts/ and not under node_modules`);
58
+ const path = resolve(tsClone, file);
59
+ if (!path.startsWith(tsClone + sep))
60
+ throw new ListError(`mutation file "${file}" leaves the clone`);
61
+ if (!existsSync(path))
62
+ throw new ListError(`mutation file "${file}" does not exist`);
63
+ return path;
64
+ }
65
+ function runSuite(tsClone, suite, timeoutMs) {
66
+ const result = spawnSync(process.execPath, [join(tsClone, 'node_modules', 'tsx', 'dist', 'cli.mjs'), suite], {
67
+ cwd: tsClone,
68
+ encoding: 'utf8',
69
+ timeout: timeoutMs,
70
+ env: { ...process.env, GOTRY_SESSION_LIVE: '0', GOTRY_HBCLI_LIVE: '0', GOTRY_HOTELBYTE_SKILLS_LIVE: '0' },
71
+ maxBuffer: 64 * 1024 * 1024,
72
+ });
73
+ const timedOut = result.error?.code === 'ETIMEDOUT';
74
+ return { status: result.status, output: `${result.stdout ?? ''}${result.stderr ?? ''}`, timedOut };
75
+ }
76
+ function lastLines(text, count) {
77
+ return text.trim().split('\n').slice(-count).join('\n');
78
+ }
79
+ const args = process.argv.slice(2);
80
+ const listPath = args.find((arg) => !arg.startsWith('--') && arg !== argValue(args, '--only') && arg !== argValue(args, '--scratch'));
81
+ if (!listPath) {
82
+ console.error('usage: mutation-check.ts <mutation-list.json> [--only <id>] [--keep] [--scratch <dir>]');
83
+ process.exit(2);
84
+ }
85
+ const list = JSON.parse(readFileSync(resolve(TS_ROOT, listPath), 'utf8'));
86
+ const only = argValue(args, '--only');
87
+ const timeoutMs = list.timeoutMs ?? 180_000;
88
+ function run() {
89
+ const scratch = mkdtempSync(join(argValue(args, '--scratch') ?? tmpdir(), 'gotry-mutation-'));
90
+ try {
91
+ const tsClone = cloneTrees(scratch);
92
+ // Every target is checked before anything runs, so a bad list fails at once and leaves no half-run behind.
93
+ const targets = list.mutations.map((mutation) => ({ mutation, path: mutationTarget(tsClone, mutation.file) }));
94
+ const baseline = runSuite(tsClone, list.suite, timeoutMs);
95
+ if (baseline.status !== 0) {
96
+ console.error(`baseline FAILED (exit ${baseline.status}${baseline.timedOut ? ', timed out' : ''}) — mutation results would mean nothing\n${lastLines(baseline.output, 20)}`);
97
+ return 2;
98
+ }
99
+ console.log(`baseline ok: ${list.suite}`);
100
+ let killed = 0;
101
+ let failed = 0;
102
+ for (const { mutation, path } of targets) {
103
+ if (only && mutation.id !== only)
104
+ continue;
105
+ const original = readFileSync(path, 'utf8');
106
+ const occurrences = original.split(mutation.find).length - 1;
107
+ if (occurrences !== 1)
108
+ throw new ListError(`${mutation.id}: "find" occurs ${occurrences} times in ${mutation.file} (need exactly 1)`);
109
+ writeFileSync(path, original.replace(mutation.find, () => mutation.replace));
110
+ let outcome;
111
+ try {
112
+ outcome = runSuite(tsClone, list.suite, timeoutMs);
113
+ }
114
+ finally {
115
+ writeFileSync(path, original);
116
+ }
117
+ const label = `${mutation.id} ${mutation.file}${mutation.note ? ` — ${mutation.note}` : ''}`;
118
+ if (outcome.status === 0) {
119
+ failed += 1;
120
+ console.log(`SURVIVED ${label}`);
121
+ }
122
+ else if (outcome.timedOut) {
123
+ failed += 1;
124
+ console.log(`TIMEOUT ${label}\n the suite hung under this edit (${timeoutMs} ms): that is not a kill`);
125
+ }
126
+ else if (BROKEN_EDIT.test(outcome.output)) {
127
+ failed += 1;
128
+ console.log(`BROKEN ${label}\n the edited file does not load: ${(outcome.output.split('\n').find((line) => BROKEN_EDIT.test(line)) ?? '').trim().slice(0, 140)}`);
129
+ }
130
+ else {
131
+ killed += 1;
132
+ const reason = outcome.output.split('\n').find((line) => /AssertionError|Error:|FAIL|not ok/.test(line)) ?? `exit ${outcome.status}`;
133
+ console.log(`killed ${label}\n ${reason.trim().slice(0, 160)}`);
134
+ }
135
+ }
136
+ console.log(`${killed} killed, ${failed} not killed (survived, hung or did not load)`);
137
+ return failed === 0 ? 0 : 1;
138
+ }
139
+ catch (error) {
140
+ if (error instanceof ListError) {
141
+ console.error(error.message);
142
+ return 2;
143
+ }
144
+ throw error;
145
+ }
146
+ finally {
147
+ if (args.includes('--keep'))
148
+ console.log(`scratch kept: ${scratch}`);
149
+ else
150
+ rmSync(scratch, { recursive: true, force: true });
151
+ }
152
+ }
153
+ process.exit(run());