@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,650 @@
1
+ /**
2
+ * The tenant router of the AaaS state layer (docs/rfc/aaas-rfc.md §4.2 items 5 and 6, §4.4).
3
+ *
4
+ * One replica asks the router for a tenant by `(organization, ledger tenant)`. The router acquires the
5
+ * lease, restores the ledger from the newest verified durable copy, and returns a handle. The handle is
6
+ * the only way to the tenant's ledger, and it is tenant-bound: no method takes a tenant id.
7
+ *
8
+ * A write is acknowledged only if the lease is provably held before the work and after it. `run` and
9
+ * `runAsync` bracket the work, and the ledger the handle exposes is a guarded view, so a long-lived
10
+ * consumer that keeps the ledger (a booking runtime, say) is stopped the same way: every call, including
11
+ * every prepared statement, first proves the lease. A write that was applied locally but not acknowledged
12
+ * is harmless, because a later owner restores from the durable copy and local files never survive.
13
+ *
14
+ * Reads go to the owner. A request for a tenant another replica owns fails with `LeaseHeldError`, whose
15
+ * record names the owner; forwarding to it belongs to the deployment layer (A3).
16
+ */
17
+ import { discardLocalLedger, forgetTenant, publishSnapshot, restoreTenantLedger, sweepMaskedCopies, } from './epoch-copies.js';
18
+ import { DrainTimeoutError, FencedError, LeaseLostError, ReplicaAtCapacityError, RouterClosedError, TenantDrainingError } from './errors.js';
19
+ import { leaseKeyFor, tenantRootFor } from './ids.js';
20
+ import { systemMonotonicClock, TenantLease } from './tenant-lease.js';
21
+ /** An iterator that proves the lease on every step. Closing it is cleanup and always allowed. */
22
+ function guardedIterator(iterator, assertHeld) {
23
+ const guarded = {
24
+ next: () => {
25
+ assertHeld();
26
+ return iterator.next();
27
+ },
28
+ return: (value) => iterator.return?.(value) ?? { done: true, value },
29
+ [Symbol.iterator]: () => guarded,
30
+ };
31
+ return guarded;
32
+ }
33
+ /**
34
+ * A prepared statement behind a gate. This is a facade, not a proxy: better-sqlite3 statements carry read-only
35
+ * properties, among them a raw `database` handle, that a proxy would have to return unchanged and so would leak.
36
+ */
37
+ function guardedStatement(statement, assertHeld) {
38
+ const target = statement;
39
+ const facade = {};
40
+ for (const name of ['run', 'get', 'all', 'columns']) {
41
+ facade[name] = (...args) => {
42
+ assertHeld();
43
+ return Reflect.apply(target[name], statement, args);
44
+ };
45
+ }
46
+ facade.iterate = (...args) => {
47
+ assertHeld();
48
+ return guardedIterator(Reflect.apply(target.iterate, statement, args), assertHeld);
49
+ };
50
+ for (const name of ['bind', 'pluck', 'expand', 'raw', 'safeIntegers']) {
51
+ facade[name] = (...args) => {
52
+ assertHeld();
53
+ Reflect.apply(target[name], statement, args);
54
+ return facade;
55
+ };
56
+ }
57
+ for (const name of ['reader', 'readonly', 'source', 'busy']) {
58
+ Object.defineProperty(facade, name, { get: () => statement[name], enumerable: true });
59
+ }
60
+ Object.defineProperty(facade, 'database', { get: () => guardedDatabase(statement.database, assertHeld), enumerable: true });
61
+ return facade;
62
+ }
63
+ /** A transaction function behind a gate, with its `.deferred`, `.immediate` and `.exclusive` variants. Its raw `database` handle is not passed on. */
64
+ function guardedTransaction(transaction, assertHeld) {
65
+ const gate = (fn) => function (...args) {
66
+ assertHeld();
67
+ return Reflect.apply(fn, this, args);
68
+ };
69
+ const guarded = gate(transaction);
70
+ for (const mode of ['default', 'deferred', 'immediate', 'exclusive']) {
71
+ const variant = transaction[mode];
72
+ if (typeof variant === 'function')
73
+ Object.defineProperty(guarded, mode, { value: gate(variant), enumerable: true });
74
+ }
75
+ return guarded;
76
+ }
77
+ /** The database of a ledger: every method proves the lease first, and statements and transactions come back guarded. */
78
+ function guardedDatabase(db, assertHeld) {
79
+ const guarded = new Proxy(db, {
80
+ get(target, property) {
81
+ const value = Reflect.get(target, property, target);
82
+ if (typeof value !== 'function')
83
+ return value;
84
+ // The router owns the connection its own writes, its snapshots and its handoff depend on; a consumer cannot close it.
85
+ if (property === 'close')
86
+ return () => guarded;
87
+ return (...args) => {
88
+ assertHeld();
89
+ const result = Reflect.apply(value, target, args);
90
+ // `exec`, `defaultSafeIntegers`, `function`, `loadExtension` and the like return the database itself; what a
91
+ // caller chains onto it has to stay behind the gate.
92
+ if (result === target)
93
+ return guarded;
94
+ if (property === 'prepare')
95
+ return guardedStatement(result, assertHeld);
96
+ if (property === 'transaction')
97
+ return guardedTransaction(result, assertHeld);
98
+ return result;
99
+ };
100
+ },
101
+ });
102
+ return guarded;
103
+ }
104
+ /**
105
+ * The ledger as a handle exposes it. The router owns its lifecycle, so `close` does nothing here, and the view is
106
+ * read-only: nothing a consumer does to it changes the ledger the router holds.
107
+ */
108
+ function guardedLedger(ledger, assertHeld) {
109
+ return new Proxy(ledger, {
110
+ get(target, property) {
111
+ const value = Reflect.get(target, property, target);
112
+ if (property === 'db')
113
+ return guardedDatabase(value, assertHeld);
114
+ if (typeof value !== 'function')
115
+ return value;
116
+ if (property === 'close')
117
+ return () => undefined;
118
+ return (...args) => {
119
+ assertHeld();
120
+ return Reflect.apply(value, target, args);
121
+ };
122
+ },
123
+ // `Object.getOwnPropertyDescriptor(ledger, 'db').value` is a second way to the database that `get` does not cover.
124
+ getOwnPropertyDescriptor(target, property) {
125
+ const descriptor = Reflect.getOwnPropertyDescriptor(target, property);
126
+ if (property === 'db' && descriptor && 'value' in descriptor)
127
+ return { ...descriptor, value: guardedDatabase(descriptor.value, assertHeld) };
128
+ return descriptor;
129
+ },
130
+ // An assignment to a property that exists ends in defineProperty, so refusing that and delete is what makes the view read-only;
131
+ // the prototype and the extensibility are the rest of what a consumer could change on the ledger the router holds.
132
+ defineProperty: () => false,
133
+ deleteProperty: () => false,
134
+ setPrototypeOf: () => false,
135
+ preventExtensions: () => false,
136
+ });
137
+ }
138
+ /** A refused renewal, or a lease that was released or lost, ends the tenure for good. A merely expired local deadline does not: a renewal can resume it. */
139
+ function endsTenure(error) {
140
+ return error instanceof LeaseLostError || (error instanceof FencedError && error.reason !== 'lease_expired_locally');
141
+ }
142
+ /** A promise in disguise: anything with a `then` function. */
143
+ function isThenable(value) {
144
+ return (typeof value === 'object' || typeof value === 'function') && value !== null && typeof value.then === 'function';
145
+ }
146
+ class OpenTenant {
147
+ organization;
148
+ ledgerTenant;
149
+ epoch;
150
+ restoredFrom;
151
+ ledger;
152
+ raw;
153
+ deps;
154
+ // `draining`: new work is refused and what already runs may finish. `flushing` begins when the drain is over and the
155
+ // final image is about to be taken: from then on a write through the ledger could not be in the image, so the view is
156
+ // closed to every call until the tenant is closed or serving again. `forgetting`: the tenant is being deleted; nothing is
157
+ // served, but the lease is renewed until the deletion has handed it back.
158
+ state = 'serving';
159
+ inFlight = 0;
160
+ idleWaiters = new Set();
161
+ renewTimer = null;
162
+ renewTicking = false;
163
+ snapshotTimer = null;
164
+ snapshotTail = Promise.resolve();
165
+ snapshotQueued = 0;
166
+ constructor(deps) {
167
+ this.deps = deps;
168
+ this.organization = deps.organization;
169
+ this.ledgerTenant = deps.ledgerTenant;
170
+ this.epoch = deps.lease.epoch;
171
+ this.restoredFrom = deps.restored.restoredFrom;
172
+ this.raw = deps.restored.ledger;
173
+ // Work that began before a handoff may finish and new work is refused at `run`; the view itself stays open while
174
+ // the drain waits, so writes made in that time are in the final image, and closes when the image is taken.
175
+ this.ledger = guardedLedger(this.raw, () => this.assertViewUsable());
176
+ }
177
+ isOpen() {
178
+ return this.state === 'serving';
179
+ }
180
+ isDraining() {
181
+ return this.state === 'draining' || this.state === 'flushing';
182
+ }
183
+ /** The state as it is now, for a check after an await: another call may have changed it, which a narrowed `this.state` or getter does not show. */
184
+ phaseNow() {
185
+ return this.state;
186
+ }
187
+ /** Called once by the router, after the handle is registered. */
188
+ begin() {
189
+ this.scheduleRenewal();
190
+ this.scheduleSnapshot();
191
+ }
192
+ run(work) {
193
+ this.assertServing();
194
+ const result = work(this.ledger);
195
+ if (isThenable(result)) {
196
+ // The callback is already running, and what is left of it is outside the lease bracket and the drain. Nobody awaits
197
+ // the promise it returned, so its failure is taken here instead of becoming an unhandled rejection.
198
+ void Promise.resolve(result).catch(() => undefined);
199
+ throw new TypeError('TenantHandle.run takes a synchronous callback: use runAsync for work that awaits');
200
+ }
201
+ this.deps.lease.assertHeld();
202
+ return result;
203
+ }
204
+ async runAsync(work) {
205
+ this.assertServing();
206
+ this.inFlight += 1;
207
+ try {
208
+ const result = await work(this.ledger);
209
+ this.deps.lease.assertHeld();
210
+ return result;
211
+ }
212
+ finally {
213
+ this.inFlight -= 1;
214
+ if (this.inFlight === 0)
215
+ this.wakeIdleWaiters();
216
+ }
217
+ }
218
+ async renew() {
219
+ try {
220
+ await this.deps.lease.renew();
221
+ this.emit({ type: 'renewed', key: this.deps.key, epoch: this.epoch });
222
+ }
223
+ catch (error) {
224
+ if (endsTenure(error))
225
+ this.markLost();
226
+ throw error;
227
+ }
228
+ }
229
+ snapshot() {
230
+ this.snapshotQueued += 1;
231
+ const run = this.snapshotTail.then(() => this.publishNow(), () => this.publishNow());
232
+ this.snapshotTail = run.catch(() => undefined).finally(() => { this.snapshotQueued -= 1; });
233
+ return run;
234
+ }
235
+ async handoff() {
236
+ if (this.state === 'closed')
237
+ return;
238
+ if (this.state !== 'serving')
239
+ throw new TenantDrainingError(this.deps.key);
240
+ this.setState('draining');
241
+ // Only the periodic snapshots stop. The lease keeps being renewed through the drain and the final upload: both can
242
+ // take longer than a lease, and a handoff that let it lapse would flush into a prefix no successor reads.
243
+ this.stopSnapshotTimer();
244
+ try {
245
+ const drained = await this.drain();
246
+ if (drained === 'timeout') {
247
+ // The lease is renewed through the drain, so a call that never returns would hold the tenant here for ever.
248
+ this.emit({ type: 'drain_timeout', key: this.deps.key, epoch: this.epoch, inFlight: this.inFlight });
249
+ throw new DrainTimeoutError(this.deps.key, this.inFlight, this.deps.drainTimeoutMs);
250
+ }
251
+ // Closed meanwhile (abandoned, forgotten, lease lost): there is nothing left to flush, and a closed handle stays closed.
252
+ if (this.phaseNow() !== 'draining')
253
+ throw new TenantDrainingError(this.deps.key);
254
+ this.setState('flushing');
255
+ await this.snapshot();
256
+ // The same if it happened while the image was uploading. A forget deletes what this flush uploaded and needs the lease
257
+ // to finish; an abandon or a lost lease has ended the tenure. Releasing now, and reporting a flush, would be false.
258
+ if (this.phaseNow() !== 'flushing')
259
+ throw new TenantDrainingError(this.deps.key);
260
+ }
261
+ catch (error) {
262
+ // A flush that failed for want of a store leaves the tenant serving; one that failed because the lease is gone ends it.
263
+ if (endsTenure(error))
264
+ this.markLost();
265
+ else if (this.isDraining()) {
266
+ this.setState('serving');
267
+ this.begin();
268
+ }
269
+ throw error;
270
+ }
271
+ await this.finish(true);
272
+ }
273
+ async abandon() {
274
+ // A forget that is under way owns the end of the tenure: it hands the lease back itself.
275
+ if (this.state === 'closed' || this.state === 'forgetting')
276
+ return;
277
+ this.stopTimers();
278
+ await this.finish(false);
279
+ }
280
+ async forget() {
281
+ this.deps.lease.assertHeld();
282
+ // Closed with the lease still held is an abandon on its way out: it ends the tenure, and a forget on top of it would run
283
+ // the deletion over a tenure that is already over and report a second release.
284
+ if (this.state === 'forgetting' || this.state === 'closed')
285
+ throw new TenantDrainingError(this.deps.key);
286
+ // Nothing is served from here on, but the lease keeps being renewed: waiting for an upload under way can take longer than
287
+ // the lease, and a forget that let it lapse could no longer delete.
288
+ this.stopSnapshotTimer();
289
+ this.setState('forgetting');
290
+ this.closeLedger();
291
+ this.deps.detach(this);
292
+ const work = this.completeForget();
293
+ // The handle has left the router's table, but the deletion is not over: closing the router waits for it.
294
+ this.deps.retain(work.then(() => undefined, () => undefined));
295
+ return work;
296
+ }
297
+ async completeForget() {
298
+ try {
299
+ // An upload already under way would land after the deletion and leave the tenant's image in the store, where nothing
300
+ // would look for it again. Snapshots queued behind it refuse to start now that the tenant is being forgotten.
301
+ await this.snapshotTail.catch(() => undefined);
302
+ const outcome = await forgetTenant(this.deps.context);
303
+ this.emit({ type: 'released', key: this.deps.key, epoch: this.epoch, flushed: false });
304
+ return outcome;
305
+ }
306
+ finally {
307
+ this.setState('closed');
308
+ this.stopTimers();
309
+ }
310
+ }
311
+ assertServing() {
312
+ if (this.state === 'serving') {
313
+ this.deps.lease.assertHeld();
314
+ return;
315
+ }
316
+ // A lease that is gone says so first. With the lease still held, a handoff, a forget or an abandon is under way.
317
+ if (this.state === 'closed')
318
+ this.deps.lease.assertHeld();
319
+ throw new TenantDrainingError(this.deps.key);
320
+ }
321
+ /** The gate of the ledger a consumer holds: the lease, and a view that is open only while the tenant is served or draining. */
322
+ assertViewUsable() {
323
+ this.deps.lease.assertHeld();
324
+ if (this.state !== 'serving' && this.state !== 'draining')
325
+ throw new TenantDrainingError(this.deps.key);
326
+ }
327
+ setState(next) {
328
+ this.state = next;
329
+ // Whoever waits for the in-flight work to end is also waiting for the tenant to stay a tenant: closing it ends the wait.
330
+ if (next === 'closed' || next === 'forgetting')
331
+ this.wakeIdleWaiters();
332
+ }
333
+ emit(event) {
334
+ try {
335
+ this.deps.emit(event);
336
+ }
337
+ catch {
338
+ // A broken observer must never take a tenant down.
339
+ }
340
+ }
341
+ async publishNow() {
342
+ // A snapshot queued before a forget, an abandon or a lost lease must not start after it.
343
+ if (this.state === 'closed' || this.state === 'forgetting')
344
+ throw new TenantDrainingError(this.deps.key);
345
+ try {
346
+ const manifest = await publishSnapshot(this.deps.context);
347
+ this.emit({ type: 'snapshot', key: this.deps.key, epoch: this.epoch, seq: manifest.seq });
348
+ return manifest;
349
+ }
350
+ catch (error) {
351
+ this.emit({ type: 'snapshot_failed', key: this.deps.key, epoch: this.epoch, code: error.code ?? 'unknown' });
352
+ throw error;
353
+ }
354
+ }
355
+ /**
356
+ * Waits until the calls that were running are done, the tenant is closed, or `drainTimeoutMs` has passed. The timer
357
+ * keeps the process alive on purpose: a graceful stop that gave up the moment nothing else was pending would exit
358
+ * without flushing.
359
+ */
360
+ drain() {
361
+ if (this.inFlight === 0)
362
+ return Promise.resolve('idle');
363
+ return new Promise((resolve) => {
364
+ const end = (outcome) => {
365
+ clearTimeout(timer);
366
+ this.idleWaiters.delete(wake);
367
+ resolve(outcome);
368
+ };
369
+ const wake = () => end(this.state === 'draining' ? 'idle' : 'closed');
370
+ const timer = setTimeout(() => end('timeout'), this.deps.drainTimeoutMs);
371
+ this.idleWaiters.add(wake);
372
+ });
373
+ }
374
+ wakeIdleWaiters() {
375
+ for (const wake of [...this.idleWaiters])
376
+ wake();
377
+ }
378
+ closeLedger() {
379
+ try {
380
+ this.raw.close();
381
+ }
382
+ catch {
383
+ // Already closed.
384
+ }
385
+ }
386
+ async finish(flushed) {
387
+ this.setState('closed');
388
+ this.stopTimers();
389
+ this.closeLedger();
390
+ // With everything flushed the durable copy is the authority, and this replica keeps no copy of the tenant's data. An
391
+ // abandoned tenant keeps its file: it may hold writes that exist nowhere else, until the next open of that tenant on this
392
+ // replica, whose restore replaces it.
393
+ if (flushed) {
394
+ try {
395
+ discardLocalLedger(this.deps.context);
396
+ }
397
+ catch { /* a leftover file is discarded at the next restore */ }
398
+ }
399
+ this.deps.detach(this);
400
+ try {
401
+ await this.deps.lease.release();
402
+ }
403
+ finally {
404
+ this.emit({ type: 'released', key: this.deps.key, epoch: this.epoch, flushed });
405
+ }
406
+ }
407
+ /** The lease is gone for good: stop serving, close the ledger, leave the stale local file to be replaced by the next restore. */
408
+ markLost() {
409
+ if (this.state === 'closed')
410
+ return;
411
+ this.setState('closed');
412
+ this.stopTimers();
413
+ this.closeLedger();
414
+ this.deps.detach(this);
415
+ this.emit({ type: 'lost', key: this.deps.key, epoch: this.epoch });
416
+ }
417
+ stopTimers() {
418
+ if (this.renewTimer)
419
+ clearTimeout(this.renewTimer);
420
+ this.renewTimer = null;
421
+ this.stopSnapshotTimer();
422
+ }
423
+ stopSnapshotTimer() {
424
+ if (this.snapshotTimer)
425
+ clearTimeout(this.snapshotTimer);
426
+ this.snapshotTimer = null;
427
+ }
428
+ scheduleRenewal() {
429
+ // Idempotent, and never beside a renewal that is under way: a handoff that fails resumes service while its renewals never
430
+ // stopped, and a second chain would double them for the rest of the tenure.
431
+ if (!this.deps.autoRenew || this.state === 'closed' || this.renewTimer || this.renewTicking)
432
+ return;
433
+ this.renewTimer = setTimeout(() => { void this.renewTick(); }, this.deps.renewEveryMs);
434
+ this.renewTimer.unref();
435
+ }
436
+ async renewTick() {
437
+ this.renewTimer = null;
438
+ this.renewTicking = true;
439
+ try {
440
+ await this.renew();
441
+ }
442
+ catch (error) {
443
+ if (this.state !== 'closed')
444
+ this.emit({ type: 'renew_failed', key: this.deps.key, epoch: this.epoch, code: error.code ?? 'unknown' });
445
+ }
446
+ finally {
447
+ this.renewTicking = false;
448
+ }
449
+ this.scheduleRenewal();
450
+ }
451
+ scheduleSnapshot() {
452
+ if (this.deps.snapshotEveryMs <= 0 || this.state === 'closed' || this.snapshotTimer)
453
+ return;
454
+ this.snapshotTimer = setTimeout(() => { void this.snapshotTick(); }, this.deps.snapshotEveryMs);
455
+ this.snapshotTimer.unref();
456
+ }
457
+ async snapshotTick() {
458
+ this.snapshotTimer = null;
459
+ if (this.snapshotQueued === 0)
460
+ await this.snapshot().catch(() => undefined);
461
+ this.scheduleSnapshot();
462
+ }
463
+ }
464
+ // ---- the router -------------------------------------------------------------------------
465
+ const DEFAULT_TTL_MS = 10_000;
466
+ const DEFAULT_MARGIN_MS = 2_000;
467
+ const DEFAULT_DRAIN_TIMEOUT_MS = 120_000;
468
+ const DEFAULT_CLOSE_CONCURRENCY = 8;
469
+ const MAX_TIMER_MS = 2_147_483_647;
470
+ export class TenantRouter {
471
+ options;
472
+ clock;
473
+ handles = new Map();
474
+ opening = new Map();
475
+ forgets = new Set();
476
+ closed = false;
477
+ constructor(options) {
478
+ if (typeof options.replicaId !== 'string' || options.replicaId.length === 0)
479
+ throw new RangeError('replicaId must be a non-empty string');
480
+ if (options.maxOpenTenants !== undefined && (!Number.isSafeInteger(options.maxOpenTenants) || options.maxOpenTenants < 1))
481
+ throw new RangeError('maxOpenTenants must be a positive integer');
482
+ if (options.drainTimeoutMs !== undefined && (!Number.isSafeInteger(options.drainTimeoutMs) || options.drainTimeoutMs < 1 || options.drainTimeoutMs > MAX_TIMER_MS))
483
+ throw new RangeError(`drainTimeoutMs must be an integer within [1, ${MAX_TIMER_MS}]`);
484
+ if (options.closeConcurrency !== undefined && (!Number.isSafeInteger(options.closeConcurrency) || options.closeConcurrency < 1))
485
+ throw new RangeError('closeConcurrency must be a positive integer');
486
+ this.options = options;
487
+ this.clock = options.clock ?? systemMonotonicClock;
488
+ }
489
+ /**
490
+ * The handle of a tenant this replica owns, acquiring the lease and restoring the ledger first if needed.
491
+ * Concurrent calls for one tenant share one acquisition.
492
+ *
493
+ * @throws InvalidTenantIdError for an id that is not in the fixed format, before any store or file is touched
494
+ * @throws LeaseHeldError when another replica owns the tenant; the record names it
495
+ */
496
+ openTenant(organization, ledgerTenant) {
497
+ if (this.closed)
498
+ return Promise.reject(new RouterClosedError());
499
+ let key;
500
+ try {
501
+ key = leaseKeyFor(organization, ledgerTenant);
502
+ }
503
+ catch (error) {
504
+ return Promise.reject(error);
505
+ }
506
+ const existing = this.handles.get(key);
507
+ if (existing?.isOpen())
508
+ return Promise.resolve(existing);
509
+ // Our own lease is still held while a handoff drains, so an acquisition would only be told it is held by us.
510
+ if (existing?.isDraining())
511
+ return Promise.reject(new TenantDrainingError(key));
512
+ const pending = this.opening.get(key);
513
+ if (pending)
514
+ return pending;
515
+ const cap = this.options.maxOpenTenants;
516
+ // A tenant being opened already holds a slot, and so does one being handed off: both still hold their descriptors.
517
+ if (cap !== undefined && new Set([...this.handles.keys(), ...this.opening.keys()]).size >= cap)
518
+ return Promise.reject(new ReplicaAtCapacityError(cap));
519
+ const attempt = this.open(organization, ledgerTenant, key).finally(() => { this.opening.delete(key); });
520
+ this.opening.set(key, attempt);
521
+ return attempt;
522
+ }
523
+ /** The open handle of a tenant, if this replica currently serves it. */
524
+ handleOf(organization, ledgerTenant) {
525
+ const existing = this.handles.get(leaseKeyFor(organization, ledgerTenant));
526
+ return existing?.isOpen() ? existing : null;
527
+ }
528
+ openCount() {
529
+ return [...this.handles.values()].filter(handle => handle.isOpen()).length;
530
+ }
531
+ /**
532
+ * The directory of one tenant on this replica: its ledger and everything that must travel with it (planner working
533
+ * files, tickets). A consumer that keeps per-tenant files puts them here, never beside another tenant's.
534
+ */
535
+ tenantRootOf(organization, ledgerTenant) {
536
+ return tenantRootFor(this.options.stateRoot, organization, ledgerTenant);
537
+ }
538
+ /**
539
+ * Hands every open tenant off gracefully and refuses new ones. Resolves with the tenants whose flush failed, among them
540
+ * those whose drain ran past `drainTimeoutMs`. Tenants are handed off several at a time, so one whose drain cannot finish
541
+ * holds back only the slot it sits in, and for no longer than that deadline. A forget that is under way is waited for, for
542
+ * as long as a drain may take. A durable store call that never returns is the binding's to time out (`DurableStore`).
543
+ */
544
+ async close() {
545
+ this.closed = true;
546
+ const queue = [...this.handles];
547
+ const flushed = new Array(queue.length).fill(false);
548
+ let next = 0;
549
+ const lane = async () => {
550
+ for (;;) {
551
+ const index = next;
552
+ next += 1;
553
+ if (index >= queue.length)
554
+ return;
555
+ try {
556
+ await queue[index][1].handoff();
557
+ flushed[index] = true;
558
+ }
559
+ catch {
560
+ // Reported below, in the order the tenants were opened.
561
+ }
562
+ }
563
+ };
564
+ await Promise.all(Array.from({ length: Math.min(this.options.closeConcurrency ?? DEFAULT_CLOSE_CONCURRENCY, queue.length) }, lane));
565
+ await this.forgetsUnderWay();
566
+ return queue.filter((_, index) => !flushed[index]).map(([key]) => key);
567
+ }
568
+ /** Waits for the forgets that have left the table but are not over, but not longer than a drain may take. */
569
+ async forgetsUnderWay() {
570
+ if (this.forgets.size === 0)
571
+ return;
572
+ let timer;
573
+ const deadline = new Promise((resolve) => { timer = setTimeout(resolve, this.options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS); });
574
+ try {
575
+ await Promise.race([Promise.allSettled([...this.forgets]), deadline]);
576
+ }
577
+ finally {
578
+ clearTimeout(timer);
579
+ }
580
+ }
581
+ async open(organization, ledgerTenant, key) {
582
+ const { options } = this;
583
+ // A broken observer must never leave a lease held and unrenewed: it is told what happened, and nothing it throws matters.
584
+ const emit = (event) => {
585
+ try {
586
+ options.onEvent?.(event);
587
+ }
588
+ catch {
589
+ // Ignored on purpose.
590
+ }
591
+ };
592
+ const lease = await TenantLease.acquire({
593
+ store: options.leases,
594
+ key,
595
+ replicaId: options.replicaId,
596
+ ttlMs: options.ttlMs ?? DEFAULT_TTL_MS,
597
+ marginMs: options.marginMs ?? DEFAULT_MARGIN_MS,
598
+ clock: this.clock,
599
+ });
600
+ emit({ type: 'acquired', key, epoch: lease.epoch });
601
+ const context = {
602
+ durable: options.durable,
603
+ lease,
604
+ organization,
605
+ ledgerTenant,
606
+ tenantRoot: tenantRootFor(options.stateRoot, organization, ledgerTenant),
607
+ };
608
+ let restored;
609
+ try {
610
+ restored = await restoreTenantLedger(context);
611
+ }
612
+ catch (error) {
613
+ await lease.release().catch(() => undefined);
614
+ throw error;
615
+ }
616
+ emit({ type: 'restored', key, epoch: lease.epoch, from: restored.restoredFrom, skipped: restored.skipped.length });
617
+ if (this.closed) {
618
+ restored.ledger.close();
619
+ await lease.release().catch(() => undefined);
620
+ throw new RouterClosedError();
621
+ }
622
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
623
+ const handle = new OpenTenant({
624
+ key,
625
+ organization,
626
+ ledgerTenant,
627
+ lease,
628
+ context,
629
+ restored,
630
+ autoRenew: options.autoRenew ?? true,
631
+ renewEveryMs: options.renewEveryMs ?? Math.max(50, Math.floor(ttlMs / 3)),
632
+ snapshotEveryMs: options.snapshotEveryMs ?? 0,
633
+ drainTimeoutMs: options.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS,
634
+ emit,
635
+ detach: (gone) => { if (this.handles.get(key) === gone)
636
+ this.handles.delete(key); },
637
+ retain: (work) => {
638
+ this.forgets.add(work);
639
+ void work.then(() => { this.forgets.delete(work); });
640
+ },
641
+ });
642
+ this.handles.set(key, handle);
643
+ handle.begin();
644
+ // What a superseded owner uploaded after this tenant was forgotten is dead whoever wrote it. It is deleted here, off the
645
+ // open path, so a slow store cannot use up the lease before the first renewal; a failure is tried again at the next open.
646
+ if (lease.forgottenAtEpoch > 0)
647
+ void sweepMaskedCopies(options.durable, key, lease.forgottenAtEpoch).catch(() => undefined);
648
+ return handle;
649
+ }
650
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Stai Travel Bridge",
4
- "version": "0.2.0.32",
4
+ "version": "0.2.0.33",
5
5
  "description": "Stai 连接获准的旅行检索与供应商门户:检索结果经本机或授权后端桥接;门户可触发一次性登录代填,扩展不持久保存凭据。",
6
6
  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnuzBX8rT8+RZKEUip5VRA0cY056pExy9PNdN9YCiuY3dgglRbcIBhjuFJoR6EX3/MaUv82vJVyItRVrowXLTwDk4WZof0vwFgKL0zBK304PC46pUaAKTSu1PgXeL1+j5KTAGz/9a9+RnPdbj7jND5rM/MOmBHNPVPpp1NlwxRGIgF9OxHSOfXORcH4Iyte1aJSFUJbFtNS/DaE5D+KjyupPLyIqVN/dCiKnNsTCEHQB9ngeli665PFp76hkccHQQZ4sVuMLS4zBPcazUsp1kcnW+xsQoUATkODTCY+Pzk3zUVF1QOESTjRCVnbNi8tkO43RMRs2q63gX/3inFivcEwIDAQAB",
7
7
  "permissions": [
@@ -56,5 +56,5 @@
56
56
  "run_at": "document_start"
57
57
  }
58
58
  ],
59
- "version_name": "0.2.0-rc.32"
59
+ "version_name": "0.2.0-rc.33"
60
60
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danceiny/gotry",
3
- "version": "0.2.0-rc.32",
3
+ "version": "0.2.0-rc.33",
4
4
  "description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm/源码入口共用锁定的 dsh runtime + README 安装路径。",
5
5
  "type": "module",
6
6
  "main": "ts/src/index.ts",
@@ -101,6 +101,9 @@
101
101
  "schemas/booking.surface.schema.json",
102
102
  "schemas/booking.intent.schema.json",
103
103
  "ts/src/booking-surface/",
104
+ "ts/src/tenant-seam/",
105
+ "ts/src/tenant-state/",
106
+ "ts/src/llm-price-table.ts",
104
107
  "ts/scripts/skeleton-check.ts",
105
108
  "ts/scripts/skeleton-integration-test.ts",
106
109
  "ts/scripts/async-collect.ts",