@danceiny/gotry 0.2.0-rc.31 → 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 (128) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +63 -53
  4. package/bin/gotry-hbcli-release.d.ts +3 -0
  5. package/bin/gotry-hbcli-release.js +4 -0
  6. package/client/client.js +66 -21
  7. package/dist/capabilities/doctor.js +7 -8
  8. package/dist/capabilities/effect.js +45 -12
  9. package/dist/capabilities/flyai-config.js +12 -10
  10. package/dist/capabilities/flyai.js +87 -56
  11. package/dist/capabilities/hbcli-config.js +156 -0
  12. package/dist/capabilities/hbcli.js +29 -2
  13. package/dist/capabilities/resilience.js +6 -6
  14. package/dist/capabilities/session/adapters/ctrip-flight.js +42 -7
  15. package/dist/capabilities/session/adapters/ctrip-hotel.js +52 -3
  16. package/dist/capabilities/session/bridge-ledger.js +168 -67
  17. package/dist/capabilities/session/ctrip-city.js +186 -0
  18. package/dist/capabilities/session/extension-bridge.js +148 -47
  19. package/dist/capabilities/session/gui-flight.js +949 -0
  20. package/dist/capabilities/session/gui-hotel.js +470 -0
  21. package/dist/capabilities/session-search.js +102 -14
  22. package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
  23. package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
  24. package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
  25. package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
  26. package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
  27. package/dist/scripts/bootstrap-tests.js +23 -6
  28. package/dist/scripts/bridge-ledger-tests.js +20 -18
  29. package/dist/scripts/ctrip-city-tests.js +80 -0
  30. package/dist/scripts/effect-tests.js +602 -504
  31. package/dist/scripts/extension-tests.js +14 -8
  32. package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
  33. package/dist/scripts/flight-malformed-tests.js +1 -1
  34. package/dist/scripts/flyai-setup-tests.js +19 -4
  35. package/dist/scripts/flyai-tests.js +768 -653
  36. package/dist/scripts/gotry-web-api-tests.js +5 -0
  37. package/dist/scripts/gotry-web-client-tests.js +20 -1
  38. package/dist/scripts/gui-flight-live.js +27 -0
  39. package/dist/scripts/hbcli-tests.js +1 -1
  40. package/dist/scripts/hbcli-upgrade-tests.js +107 -0
  41. package/dist/scripts/hbcli-web-setup-tests.js +114 -0
  42. package/dist/scripts/hotel-date-gate-tests.js +1 -1
  43. package/dist/scripts/hotel-result-tests.js +169 -0
  44. package/dist/scripts/issue-271-liveness-tests.js +1 -1
  45. package/dist/scripts/mutation-check.js +153 -0
  46. package/dist/scripts/nightly-evidence.js +4 -111
  47. package/dist/scripts/session-tests.js +211 -6
  48. package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
  49. package/dist/scripts/tenant-capacity-probe.js +263 -0
  50. package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
  51. package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
  52. package/dist/scripts/tenant-executor-binding-tests.js +203 -0
  53. package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
  54. package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
  55. package/dist/scripts/tenant-seam-quota-tests.js +383 -0
  56. package/dist/scripts/tenant-seam-test-kit.js +292 -0
  57. package/dist/scripts/tenant-state-copies-tests.js +607 -0
  58. package/dist/scripts/tenant-state-drill-tests.js +298 -0
  59. package/dist/scripts/tenant-state-drill-worker.js +164 -0
  60. package/dist/scripts/tenant-state-durable-conformance.js +101 -0
  61. package/dist/scripts/tenant-state-handoff-tests.js +854 -0
  62. package/dist/scripts/tenant-state-lease-conformance.js +142 -0
  63. package/dist/scripts/tenant-state-lease-worker.js +28 -0
  64. package/dist/scripts/tenant-state-router-tests.js +464 -0
  65. package/dist/scripts/tenant-state-test-kit.js +92 -0
  66. package/dist/scripts/tenant-state-tests.js +564 -0
  67. package/dist/src/backend/modules/booking-executor.js +61 -6
  68. package/dist/src/backend/modules/session-search.js +136 -39
  69. package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  70. package/dist/src/booking-surface/dsh-planner.js +150 -19
  71. package/dist/src/booking-surface/index.js +1 -0
  72. package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
  73. package/dist/src/booking-surface/server.js +193 -80
  74. package/dist/src/booking-surface/startup.js +153 -0
  75. package/dist/src/booking-surface/tenancy.js +188 -0
  76. package/dist/src/gotry-web-api.js +64 -2
  77. package/dist/src/index.js +70 -18
  78. package/dist/src/llm-price-table.js +148 -0
  79. package/dist/src/tenant-seam/index.js +8 -0
  80. package/dist/src/tenant-seam/llm-broker.js +508 -0
  81. package/dist/src/tenant-seam/process-governor.js +281 -0
  82. package/dist/src/tenant-seam/quotas.js +360 -0
  83. package/dist/src/tenant-seam/seam.js +92 -0
  84. package/dist/src/tenant-state/durable-store.js +158 -0
  85. package/dist/src/tenant-state/epoch-copies.js +422 -0
  86. package/dist/src/tenant-state/errors.js +108 -0
  87. package/dist/src/tenant-state/ids.js +101 -0
  88. package/dist/src/tenant-state/index.js +11 -0
  89. package/dist/src/tenant-state/lease-store.js +203 -0
  90. package/dist/src/tenant-state/tenant-lease.js +150 -0
  91. package/dist/src/tenant-state/tenant-router.js +650 -0
  92. package/extension/manifest.json +2 -2
  93. package/package.json +5 -1
  94. package/ts/capabilities/flyai-config.ts +12 -10
  95. package/ts/capabilities/flyai.ts +91 -50
  96. package/ts/capabilities/hbcli-config.ts +128 -0
  97. package/ts/capabilities/hbcli.ts +30 -3
  98. package/ts/capabilities/session/adapters/ctrip-flight.ts +43 -7
  99. package/ts/capabilities/session/adapters/ctrip-hotel.ts +51 -4
  100. package/ts/capabilities/session/bridge-ledger.ts +192 -78
  101. package/ts/capabilities/session/ctrip-city.ts +158 -0
  102. package/ts/capabilities/session/extension-bridge.ts +185 -67
  103. package/ts/capabilities/session/gui-flight.ts +1022 -0
  104. package/ts/capabilities/session/gui-hotel.ts +505 -0
  105. package/ts/capabilities/session-search.ts +130 -18
  106. package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
  107. package/ts/src/booking-surface/dsh-planner.ts +169 -19
  108. package/ts/src/booking-surface/index.ts +1 -0
  109. package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
  110. package/ts/src/booking-surface/server.ts +215 -84
  111. package/ts/src/booking-surface/startup.ts +155 -0
  112. package/ts/src/booking-surface/tenancy.ts +242 -0
  113. package/ts/src/gotry-web-api.ts +51 -2
  114. package/ts/src/index.ts +67 -19
  115. package/ts/src/llm-price-table.ts +180 -0
  116. package/ts/src/tenant-seam/index.ts +9 -0
  117. package/ts/src/tenant-seam/llm-broker.ts +520 -0
  118. package/ts/src/tenant-seam/process-governor.ts +343 -0
  119. package/ts/src/tenant-seam/quotas.ts +433 -0
  120. package/ts/src/tenant-seam/seam.ts +142 -0
  121. package/ts/src/tenant-state/durable-store.ts +171 -0
  122. package/ts/src/tenant-state/epoch-copies.ts +491 -0
  123. package/ts/src/tenant-state/errors.ts +128 -0
  124. package/ts/src/tenant-state/ids.ts +108 -0
  125. package/ts/src/tenant-state/index.ts +12 -0
  126. package/ts/src/tenant-state/lease-store.ts +266 -0
  127. package/ts/src/tenant-state/tenant-lease.ts +175 -0
  128. package/ts/src/tenant-state/tenant-router.ts +743 -0
@@ -14,10 +14,12 @@
14
14
  * 页面照常生效(下单/支付面 URL 仍被物理拦截)。
15
15
  */
16
16
 
17
- /** 城市码表(**实测校准 2026-09-03**:每条 id 均经 hotels.ctrip.com list 页
17
+ import { resolveCtripHotelCity, hotelResolutionHint, type HotelCityResolution } from '../ctrip-city.ts'
18
+
19
+ /** 离线兼容缓存(**实测校准 2026-09-03**:每条 id 均经 hotels.ctrip.com list 页
18
20
  * <title> 验证——探测法曾纠出 104=平遥县(传猜成都)等 5 处错猜,表内只收
19
21
  * 验证过的 id;词表外 unresolved 逐字保留,调用方可显式传 cityId 覆盖) */
20
- const HOTEL_CITY_CODES: Record<string, string> = {
22
+ export const HOTEL_CITY_CODES: Record<string, string> = {
21
23
  上海: '2', 北京: '1', 广州: '32', 深圳: '30', 成都: '28',
22
24
  杭州: '17', 南京: '12', 苏州: '14', 无锡: '13', 扬州: '15', 镇江: '16',
23
25
  青岛: '7', 厦门: '25', 珠海: '31', 三亚: '43', 海口: '42',
@@ -25,6 +27,9 @@ const HOTEL_CITY_CODES: Record<string, string> = {
25
27
  贵阳: '38', 拉萨: '41', 乌鲁木齐: '39', 太原: '105', 长春: '158', 南通: '82',
26
28
  敦煌: '11', 舟山: '19', 张家界: '27', 黄山市: '23', 九江: '24', 武夷山: '26',
27
29
  迪拜: '220',
30
+ // Verified against the official list page and its displayed destination/date
31
+ // controls on 2026-10-07; https://hotels.ctrip.com/hotels/list?city=5
32
+ 哈尔滨: '5',
28
33
  }
29
34
 
30
35
  export interface AdapterEntry {
@@ -32,6 +37,7 @@ export interface AdapterEntry {
32
37
  url?: string
33
38
  /** 词表外城市逐字保留(调用方提示:web 搜携程酒店 list 页取 city id 后带 cityId 重试) */
34
39
  unresolved?: string[]
40
+ error?: string
35
41
  }
36
42
 
37
43
  export interface HotelEntryQuery {
@@ -39,6 +45,7 @@ export interface HotelEntryQuery {
39
45
  to: string
40
46
  /** 显式城市 id(携程/trip.com 酒店 list 页 URL 的 city= 数字;覆盖码表) */
41
47
  cityId?: number | string
48
+ country?: string
42
49
  /** YYYY-MM-DD */
43
50
  checkIn?: string
44
51
  /** YYYY-MM-DD(与 checkIn 成对) */
@@ -46,14 +53,23 @@ export interface HotelEntryQuery {
46
53
  adults?: number
47
54
  }
48
55
 
56
+ export async function resolveHotelEntryUrl(q: HotelEntryQuery,
57
+ resolve: (query: string, country?: string) => Promise<HotelCityResolution> = resolveCtripHotelCity): Promise<AdapterEntry> {
58
+ if (q.cityId !== undefined) return buildHotelEntryUrl(q)
59
+ const result = await resolve(q.to, q.country)
60
+ if (!result.ok) return { ok: false, unresolved: [q.to], error: hotelResolutionHint(q.to, result) }
61
+ return buildHotelEntryUrl({ ...q, cityId: result.city.id })
62
+ }
63
+
49
64
  export function buildHotelEntryUrl(q: HotelEntryQuery): AdapterEntry {
50
65
  const unresolved: string[] = []
51
- const cityId = String(q.cityId ?? '').trim() || HOTEL_CITY_CODES[q.to.trim()]
66
+ const candidate = q.cityId === undefined ? HOTEL_CITY_CODES[q.to.trim()] : String(q.cityId).trim()
67
+ const cityId = candidate && /^[1-9]\d{0,9}$/.test(candidate) ? candidate : undefined
52
68
  if (!cityId) unresolved.push(q.to)
53
69
  if (unresolved.length > 0) {
54
70
  return { ok: false, unresolved }
55
71
  }
56
- const params = new URLSearchParams({ city: cityId })
72
+ const params = new URLSearchParams({ city: cityId! })
57
73
  if (q.checkIn) params.set('checkin', q.checkIn)
58
74
  if (q.checkOut) params.set('checkout', q.checkOut)
59
75
  if (q.adults && q.adults > 0) params.set('adult', String(q.adults))
@@ -236,3 +252,34 @@ export function parseCtripHotelList(body: string, opts: { maxItems?: number } =
236
252
  walk(raw, 0, new Set())
237
253
  return out
238
254
  }
255
+
256
+ /** Only an explicit empty hotel array proves a miss; parser failure proves nothing. */
257
+ export function classifyCtripHotelList(body: string): {
258
+ shape: 'parsed' | 'recognized-empty' | 'malformed' | 'unrecognized'
259
+ hotels: SessionHotelOption[]
260
+ } {
261
+ let raw: unknown
262
+ try { raw = JSON.parse(body) } catch { return { shape: 'malformed', hotels: [] } }
263
+ const envelope = raw as { code?: unknown; success?: unknown; ok?: unknown; ResponseStatus?: { Ack?: unknown } } | null
264
+ if (envelope?.success === false || envelope?.ok === false
265
+ || (envelope?.code !== undefined && envelope.code !== 0 && envelope.code !== '0')
266
+ || /^(failure|error)$/i.test(String(envelope?.ResponseStatus?.Ack ?? ''))) {
267
+ return { shape: 'unrecognized', hotels: [] }
268
+ }
269
+ const hotels = parseCtripHotelList(body)
270
+ if (hotels.length > 0) return { shape: 'parsed', hotels }
271
+ let emptyList = false
272
+ let unparsedItems = false
273
+ const inspectHotelLists = (node: unknown, depth: number): void => {
274
+ if (depth > 12 || node === null || typeof node !== 'object') return
275
+ for (const [key, value] of Object.entries(node)) {
276
+ if (['hotelList', 'hotelMatchInfos'].includes(key) && Array.isArray(value)) {
277
+ if (value.length === 0) emptyList = true
278
+ else unparsedItems = true
279
+ }
280
+ inspectHotelLists(value, depth + 1)
281
+ }
282
+ }
283
+ inspectHotelLists(raw, 0)
284
+ return { shape: emptyList && !unparsedItems ? 'recognized-empty' : 'unrecognized', hotels: [] }
285
+ }
@@ -16,6 +16,14 @@
16
16
  * (活动上下文,非权威事实)经 POST /v1/session/bridge/events 上行落账;
17
17
  * idem_key 部分唯一索引(WHERE NOT NULL)让重试幂等:同 key 重发不重复落行。
18
18
  *
19
+ * AaaS A2(#674,威胁 S-5):组织维度。作业/客户端/节律/事件各行都属于恰好一个组织
20
+ * (`organization` 列,缺省 `local` = 今天的单车道);客户端主键 (organization, client_id)、
21
+ * 节律主键 (organization, site)、事件幂等唯一索引 (organization, idem_key);
22
+ * 按作业 id 的更新与查询一律再带组织条件。组织永远是调用方(队列按自己的条目、
23
+ * 接缝解出的调用者)传入的参数,**绝不来自回包体或请求头**;每个方法先用
24
+ * assertOrganizationId 校验,非法组织在碰库之前就被拒。旧库(无组织列)在打开时原地迁移,
25
+ * 既有行归 `local`,迁移是单向的(迁移后旧版本二进制的节律/客户端写会失败)。
26
+ *
19
27
  * 红线继承:本账本只存**控制状态**(job 元数据/回包/节律/客户端能力清单/事件流水),
20
28
  * 协议面不存在 cookie 值字段(extension-tests §38 红线断言),账本亦零凭据落库。
21
29
  *
@@ -26,7 +34,8 @@
26
34
  import Database from 'better-sqlite3'
27
35
  import { mkdirSync } from 'node:fs'
28
36
  import { dirname, isAbsolute, join } from 'node:path'
29
- import type { BridgeJobStore, ExtensionJob, ExtensionJobKind } from './extension-bridge.ts'
37
+ import { assertOrganizationId } from '../../src/tenant-state/ids.ts'
38
+ import type { BridgeJobStore, ExtensionJob, ExtensionJobResult } from './extension-bridge.ts'
30
39
 
31
40
  /** 站点节律冷却(毫秒):与 capabilities/session-search.ts 的 MIN_INTERVAL_MS 同口径 30s;导出仅测试/文档锚点 */
32
41
  export const BRIDGE_SITE_COOLDOWN_MS = 30_000
@@ -36,6 +45,7 @@ export type BridgeJobStatus = 'queued' | 'claimed' | 'resolved' | 'unresolved' |
36
45
  /** bridge_jobs 行(列名与建表一一对应;测试/对账读口径) */
37
46
  export interface BridgeJobRow {
38
47
  id: string
48
+ organization: string
39
49
  kind: string
40
50
  site: string
41
51
  payload_json: string
@@ -50,9 +60,10 @@ export interface BridgeJobRow {
50
60
  updated_at: number
51
61
  }
52
62
 
53
- /** recoverOnBoot 输入:未终态(queued/claimed)作业的最小投影 */
63
+ /** recoverOnBoot 输入:未终态(queued/claimed)作业的最小投影(含所属组织,恢复时按行还原) */
54
64
  export interface BridgePendingJob {
55
65
  id: string
66
+ organization: string
56
67
  kind: string
57
68
  site: string
58
69
  payload_json: string
@@ -64,6 +75,7 @@ export interface BridgePendingJob {
64
75
  }
65
76
 
66
77
  export interface BridgeClientRow {
78
+ organization: string
67
79
  client_id: string
68
80
  origin: string
69
81
  capabilities_json: string
@@ -72,6 +84,7 @@ export interface BridgeClientRow {
72
84
  }
73
85
 
74
86
  export interface BridgePacingRow {
87
+ organization: string
75
88
  site: string
76
89
  last_hit_at: number
77
90
  cooldown_until: number
@@ -80,6 +93,7 @@ export interface BridgePacingRow {
80
93
  /** bridge_events 行(批次 B;列名与建表一一对应,测试/对账读口径) */
81
94
  export interface BridgeEventRow {
82
95
  seq: number
96
+ organization: string
83
97
  ts: number
84
98
  client_id: string
85
99
  kind: string
@@ -109,9 +123,29 @@ export function bridgeLedgerDbPath(stateRoot: string): string {
109
123
  return join(stateDirOf(stateRoot), 'bridge.db')
110
124
  }
111
125
 
112
- const SCHEMA = `
126
+ // 主键含组织的两张表:建表与「旧库重建」共用同一份列定义,两处不会漂移
127
+ const CLIENTS_COLUMNS = `(
128
+ organization TEXT NOT NULL DEFAULT 'local',
129
+ client_id TEXT NOT NULL,
130
+ origin TEXT NOT NULL,
131
+ capabilities_json TEXT NOT NULL DEFAULT '[]',
132
+ last_seen_at INTEGER NOT NULL,
133
+ login_sites_json TEXT NOT NULL DEFAULT '[]',
134
+ PRIMARY KEY (organization, client_id)
135
+ )`
136
+
137
+ const PACING_COLUMNS = `(
138
+ organization TEXT NOT NULL DEFAULT 'local',
139
+ site TEXT NOT NULL,
140
+ last_hit_at INTEGER NOT NULL,
141
+ cooldown_until INTEGER NOT NULL DEFAULT 0,
142
+ PRIMARY KEY (organization, site)
143
+ )`
144
+
145
+ const TABLES = `
113
146
  CREATE TABLE IF NOT EXISTS bridge_jobs (
114
147
  id TEXT PRIMARY KEY,
148
+ organization TEXT NOT NULL DEFAULT 'local',
115
149
  kind TEXT NOT NULL,
116
150
  site TEXT NOT NULL,
117
151
  payload_json TEXT NOT NULL,
@@ -125,24 +159,14 @@ CREATE TABLE IF NOT EXISTS bridge_jobs (
125
159
  created_at INTEGER NOT NULL,
126
160
  updated_at INTEGER NOT NULL
127
161
  );
128
- CREATE INDEX IF NOT EXISTS bridge_jobs_status ON bridge_jobs(status);
129
162
 
130
- CREATE TABLE IF NOT EXISTS bridge_clients (
131
- client_id TEXT PRIMARY KEY,
132
- origin TEXT NOT NULL,
133
- capabilities_json TEXT NOT NULL DEFAULT '[]',
134
- last_seen_at INTEGER NOT NULL,
135
- login_sites_json TEXT NOT NULL DEFAULT '[]'
136
- );
163
+ CREATE TABLE IF NOT EXISTS bridge_clients ${CLIENTS_COLUMNS};
137
164
 
138
- CREATE TABLE IF NOT EXISTS bridge_pacing (
139
- site TEXT PRIMARY KEY,
140
- last_hit_at INTEGER NOT NULL,
141
- cooldown_until INTEGER NOT NULL DEFAULT 0
142
- );
165
+ CREATE TABLE IF NOT EXISTS bridge_pacing ${PACING_COLUMNS};
143
166
 
144
167
  CREATE TABLE IF NOT EXISTS bridge_events (
145
168
  seq INTEGER PRIMARY KEY AUTOINCREMENT,
169
+ organization TEXT NOT NULL DEFAULT 'local',
146
170
  ts INTEGER NOT NULL,
147
171
  client_id TEXT NOT NULL,
148
172
  kind TEXT NOT NULL,
@@ -151,14 +175,66 @@ CREATE TABLE IF NOT EXISTS bridge_events (
151
175
  payload_json TEXT NOT NULL,
152
176
  idem_key TEXT
153
177
  );
154
- CREATE UNIQUE INDEX IF NOT EXISTS bridge_events_idem_key
155
- ON bridge_events(idem_key) WHERE idem_key IS NOT NULL;
156
178
  `
157
179
 
180
+ // 索引在迁移之后建:旧库的 bridge_events 在迁移前还没有 organization 列
181
+ const INDEXES = `
182
+ CREATE INDEX IF NOT EXISTS bridge_jobs_status ON bridge_jobs(status);
183
+ CREATE UNIQUE INDEX IF NOT EXISTS bridge_events_org_idem_key
184
+ ON bridge_events(organization, idem_key) WHERE idem_key IS NOT NULL;
185
+ `
186
+
187
+ const ORGANIZATION_TABLES = ['bridge_jobs', 'bridge_clients', 'bridge_pacing', 'bridge_events'] as const
188
+
189
+ function hasOrganizationColumn(db: Database.Database, table: string): boolean {
190
+ return (db.pragma(`table_info(${table})`) as Array<{ name: string }>).some((column) => column.name === 'organization')
191
+ }
192
+
193
+ /**
194
+ * 旧库(组织维度之前)原地迁移:jobs/events 加列,clients/pacing 因主键含组织而重建;
195
+ * 既有行一律归 `local`。整段在一个 IMMEDIATE 事务里,拿到写锁后重新判定哪些表还需要迁移
196
+ * (并发打开同一个库的另一连接可能已迁完),所以幂等且无半迁移状态。
197
+ */
198
+ function migrateOrganizationDimension(db: Database.Database): void {
199
+ const pending = (): string[] => ORGANIZATION_TABLES.filter((table) => !hasOrganizationColumn(db, table))
200
+ if (pending().length > 0) migrateTables(db, pending)
201
+ // The single-column unique index on idem_key of the legacy schema would make two organizations' identical keys refuse each
202
+ // other (the per-organization one replaces it). An older binary recreates it every time it opens the file, so a file that
203
+ // went new, old, new has nothing left to migrate and still holds it. Drop it on every open (a no-op when absent).
204
+ if (hasIndex(db, 'bridge_events_idem_key')) db.transaction(() => { db.exec('DROP INDEX IF EXISTS bridge_events_idem_key') }).immediate()
205
+ }
206
+
207
+ function hasIndex(db: Database.Database, name: string): boolean {
208
+ return db.prepare("SELECT 1 FROM sqlite_master WHERE type='index' AND name=?").get(name) !== undefined
209
+ }
210
+
211
+ function migrateTables(db: Database.Database, pending: () => string[]): void {
212
+ db.transaction(() => {
213
+ for (const table of pending()) {
214
+ if (table === 'bridge_jobs' || table === 'bridge_events') {
215
+ db.exec(`ALTER TABLE ${table} ADD COLUMN organization TEXT NOT NULL DEFAULT 'local'`)
216
+ continue
217
+ }
218
+ db.exec(`ALTER TABLE ${table} RENAME TO ${table}__legacy`)
219
+ if (table === 'bridge_clients') {
220
+ db.exec(`CREATE TABLE bridge_clients ${CLIENTS_COLUMNS}`)
221
+ db.exec(`INSERT INTO bridge_clients (organization, client_id, origin, capabilities_json, last_seen_at, login_sites_json)
222
+ SELECT 'local', client_id, origin, capabilities_json, last_seen_at, login_sites_json FROM bridge_clients__legacy`)
223
+ } else {
224
+ db.exec(`CREATE TABLE bridge_pacing ${PACING_COLUMNS}`)
225
+ db.exec(`INSERT INTO bridge_pacing (organization, site, last_hit_at, cooldown_until)
226
+ SELECT 'local', site, last_hit_at, cooldown_until FROM bridge_pacing__legacy`)
227
+ }
228
+ db.exec(`DROP TABLE ${table}__legacy`)
229
+ }
230
+ }).immediate()
231
+ }
232
+
158
233
  /**
159
234
  * SQLite 落地的桥作业账本(实现 extension-bridge.BridgeJobStore 契约)。
160
235
  * 所有写方法单语句/单事务同步提交;调用方(extension-bridge)负责失败兜底
161
236
  * (write-before-submit 的 INSERT 失败 = 提交失败,fail-closed 不入队)。
237
+ * 组织是每个方法的第一个参数且必填:漏传是编译错误,不是静默落进 `local`。
162
238
  */
163
239
  export class BridgeLedgerStore implements BridgeJobStore {
164
240
  readonly dbPath: string
@@ -171,155 +247,193 @@ export class BridgeLedgerStore implements BridgeJobStore {
171
247
  mkdirSync(dirname(dbPath), { recursive: true })
172
248
  this.db = new Database(dbPath)
173
249
  this.db.pragma('journal_mode = WAL')
250
+ // Opening a legacy file needs the write lock once (migration, index changes). A rolling deploy can have the old process
251
+ // writing through it, so the open waits longer than a normal write does and then drops back.
252
+ this.db.pragma('busy_timeout = 30000')
253
+ this.db.exec(TABLES)
254
+ migrateOrganizationDimension(this.db)
255
+ this.db.exec(INDEXES)
174
256
  this.db.pragma('busy_timeout = 5000')
175
- this.db.exec(SCHEMA)
176
257
  }
177
258
 
178
259
  /** write-before-submit:入队前先落 queued(提交失败 = 调用方不得派发) */
179
- insertQueuedJob(job: ExtensionJob, timeoutAt: number): void {
260
+ insertQueuedJob(organization: string, job: ExtensionJob, timeoutAt: number): void {
261
+ const org = assertOrganizationId(organization)
180
262
  const ts = this.now()
181
263
  this.db.prepare(
182
- `INSERT INTO bridge_jobs (id, kind, site, payload_json, status, timeout_at, attempts, created_at, updated_at)
183
- VALUES (@id, @kind, @site, @payload, 'queued', @timeoutAt, 0, @ts, @ts)`,
184
- ).run({ id: job.jobId, kind: job.kind, site: job.site, payload: JSON.stringify(job), timeoutAt, ts })
264
+ `INSERT INTO bridge_jobs (id, organization, kind, site, payload_json, status, timeout_at, attempts, created_at, updated_at)
265
+ VALUES (@id, @organization, @kind, @site, @payload, 'queued', @timeoutAt, 0, @ts, @ts)`,
266
+ ).run({ id: job.jobId, organization: org, kind: job.kind, site: job.site, payload: JSON.stringify(job), timeoutAt, ts })
185
267
  }
186
268
 
187
269
  /** 领取(/jobs 派发与 dispatchToParked 两处):claimed + 领取者 + attempts 递增 */
188
- markClaimed(jobId: string, claim: { origin: string; clientId?: string }): void {
270
+ markClaimed(organization: string, jobId: string, claim: { origin: string; clientId?: string }): void {
271
+ const org = assertOrganizationId(organization)
189
272
  const ts = this.now()
190
273
  this.db.prepare(
191
274
  `UPDATE bridge_jobs
192
275
  SET status='claimed', claimed_by_client=@client, claimed_origin=@origin, claimed_at=@ts, attempts=attempts+1, updated_at=@ts
193
- WHERE id=@id`,
194
- ).run({ id: jobId, client: claim.clientId ?? null, origin: claim.origin, ts })
276
+ WHERE id=@id AND organization=@organization`,
277
+ ).run({ id: jobId, organization: org, client: claim.clientId ?? null, origin: claim.origin, ts })
195
278
  }
196
279
 
197
- markResolved(jobId: string, result: unknown): void {
280
+ markResolved(organization: string, jobId: string, result: unknown): void {
281
+ const org = assertOrganizationId(organization)
198
282
  this.db.prepare(
199
- `UPDATE bridge_jobs SET status='resolved', result_json=@result, updated_at=@ts WHERE id=@id`,
200
- ).run({ id: jobId, result: JSON.stringify(result), ts: this.now() })
283
+ `UPDATE bridge_jobs SET status='resolved', result_json=@result, updated_at=@ts WHERE id=@id AND organization=@organization`,
284
+ ).run({ id: jobId, organization: org, result: JSON.stringify(result), ts: this.now() })
201
285
  }
202
286
 
203
- markUnresolved(jobId: string, note?: { reason: string }): void {
287
+ markUnresolved(organization: string, jobId: string, note?: { reason: string }): void {
288
+ const org = assertOrganizationId(organization)
204
289
  const result = note ? JSON.stringify({ ok: false, reason: note.reason }) : null
205
290
  this.db.prepare(
206
- `UPDATE bridge_jobs SET status='unresolved', result_json=@result, updated_at=@ts WHERE id=@id`,
207
- ).run({ id: jobId, result, ts: this.now() })
291
+ `UPDATE bridge_jobs SET status='unresolved', result_json=@result, updated_at=@ts WHERE id=@id AND organization=@organization`,
292
+ ).run({ id: jobId, organization: org, result, ts: this.now() })
208
293
  }
209
294
 
210
- markVoid(jobId: string): void {
211
- this.db.prepare(`UPDATE bridge_jobs SET status='void', updated_at=@ts WHERE id=@id`).run({ id: jobId, ts: this.now() })
295
+ markVoid(organization: string, jobId: string): void {
296
+ const org = assertOrganizationId(organization)
297
+ this.db.prepare(
298
+ `UPDATE bridge_jobs SET status='void', updated_at=@ts WHERE id=@id AND organization=@organization`,
299
+ ).run({ id: jobId, organization: org, ts: this.now() })
212
300
  }
213
301
 
214
302
  /**
215
303
  * 孤儿回包落库(对账面):未知 jobId 的回包内容以 status='unresolved' 保留。
216
- * - 无行(纯未知/探测回包):插 kind 取回包自报(缺省 'orphan'),site='unknown';
304
+ * - 无行(纯未知/探测回包):插 kind 取回包自报(缺省 'orphan'),site='unknown',归调用方组织;
217
305
  * - 已 resolved:迟到回包不覆盖已决结果(幂等,首次裁决为准);
218
- * - unresolved/void:补 result_json——作废/超时后扩展其实跑完了,留证供对账。
306
+ * - unresolved/void:补 result_json——作废/超时后扩展其实跑完了,留证供对账;
307
+ * - 该 jobId 已属于**另一个组织**:拒绝(抛错,零写入)。队列在调用前就按组织拦下并答
308
+ * 与未知作业完全一样的 404,这里是第二道防线。
219
309
  */
220
- recordOrphanResult(jobId: string, result: { kind?: ExtensionJobKind }): void {
221
- const existing = this.getJob(jobId)
310
+ recordOrphanResult(organization: string, jobId: string, result: ExtensionJobResult): void {
311
+ const org = assertOrganizationId(organization)
312
+ const existing = this.db.prepare(`SELECT organization, status FROM bridge_jobs WHERE id=@id`).get({ id: jobId }) as { organization: string; status: BridgeJobStatus } | undefined
222
313
  const ts = this.now()
223
314
  const resultJson = JSON.stringify(result)
224
315
  if (!existing) {
225
316
  this.db.prepare(
226
- `INSERT INTO bridge_jobs (id, kind, site, payload_json, status, result_json, attempts, created_at, updated_at)
227
- VALUES (@id, @kind, 'unknown', '{}', 'unresolved', @result, 0, @ts, @ts)`,
228
- ).run({ id: jobId, kind: result.kind ?? 'orphan', result: resultJson, ts })
317
+ `INSERT INTO bridge_jobs (id, organization, kind, site, payload_json, status, result_json, attempts, created_at, updated_at)
318
+ VALUES (@id, @organization, @kind, 'unknown', '{}', 'unresolved', @result, 0, @ts, @ts)`,
319
+ ).run({ id: jobId, organization: org, kind: result.kind ?? 'orphan', result: resultJson, ts })
229
320
  return
230
321
  }
322
+ if (existing.organization !== org) throw new Error('bridge-job-organization-mismatch')
231
323
  if (existing.status === 'resolved') return
232
324
  this.db.prepare(
233
- `UPDATE bridge_jobs SET status='unresolved', result_json=@result, updated_at=@ts WHERE id=@id`,
234
- ).run({ id: jobId, result: resultJson, ts })
325
+ `UPDATE bridge_jobs SET status='unresolved', result_json=@result, updated_at=@ts WHERE id=@id AND organization=@organization`,
326
+ ).run({ id: jobId, organization: org, result: resultJson, ts })
235
327
  }
236
328
 
237
329
  /** bridge_clients upsert:/jobs 轮询体(clientId + capabilities)到达即刷新,login_sites 保序合并 */
238
- upsertClient(clientId: string, origin: string, capabilities: readonly string[]): void {
330
+ upsertClient(organization: string, clientId: string, origin: string, capabilities: readonly string[]): void {
331
+ const org = assertOrganizationId(organization)
239
332
  const ts = this.now()
240
333
  this.db.prepare(
241
- `INSERT INTO bridge_clients (client_id, origin, capabilities_json, last_seen_at, login_sites_json)
242
- VALUES (@clientId, @origin, @caps, @ts, '[]')
243
- ON CONFLICT(client_id) DO UPDATE SET
334
+ `INSERT INTO bridge_clients (organization, client_id, origin, capabilities_json, last_seen_at, login_sites_json)
335
+ VALUES (@organization, @clientId, @origin, @caps, @ts, '[]')
336
+ ON CONFLICT(organization, client_id) DO UPDATE SET
244
337
  origin=excluded.origin, capabilities_json=excluded.capabilities_json, last_seen_at=excluded.last_seen_at`,
245
- ).run({ clientId, origin, caps: JSON.stringify([...capabilities]), ts })
338
+ ).run({ organization: org, clientId, origin, caps: JSON.stringify([...capabilities]), ts })
246
339
  }
247
340
 
248
341
  /** cookie-names 命中票据名(名字级)→ 该客户端在该站点持有登录态 */
249
- recordClientLoginSite(clientId: string, site: string): void {
250
- const row = this.db.prepare(`SELECT login_sites_json FROM bridge_clients WHERE client_id=?`).get(clientId) as { login_sites_json: string } | undefined
342
+ recordClientLoginSite(organization: string, clientId: string, site: string): void {
343
+ const org = assertOrganizationId(organization)
344
+ const row = this.db.prepare(`SELECT login_sites_json FROM bridge_clients WHERE organization=@organization AND client_id=@clientId`).get({ organization: org, clientId }) as { login_sites_json: string } | undefined
251
345
  if (!row) return
252
346
  let sites: unknown
253
347
  try { sites = JSON.parse(row.login_sites_json) } catch { sites = [] }
254
348
  const merged = new Set(Array.isArray(sites) ? sites.filter((s): s is string => typeof s === 'string') : [])
255
349
  merged.add(site)
256
- this.db.prepare(`UPDATE bridge_clients SET login_sites_json=@sites WHERE client_id=@clientId`).run({ clientId, sites: JSON.stringify([...merged]) })
350
+ this.db.prepare(`UPDATE bridge_clients SET login_sites_json=@sites WHERE organization=@organization AND client_id=@clientId`).run({ organization: org, clientId, sites: JSON.stringify([...merged]) })
257
351
  }
258
352
 
259
- /** 站点导航类作业提交/领取:记命中时刻并把冷却推到 now+30s(不回退既有冷却) */
260
- noteSiteHit(site: string): void {
353
+ /** 站点导航类作业提交/领取:记命中时刻并把冷却推到 now+30s(不回退既有冷却);节律按 组织+站点 */
354
+ noteSiteHit(organization: string, site: string): void {
355
+ const org = assertOrganizationId(organization)
261
356
  const ts = this.now()
262
357
  this.db.prepare(
263
- `INSERT INTO bridge_pacing (site, last_hit_at, cooldown_until) VALUES (@site, @ts, @until)
264
- ON CONFLICT(site) DO UPDATE SET last_hit_at=excluded.last_hit_at, cooldown_until=MAX(cooldown_until, excluded.cooldown_until)`,
265
- ).run({ site, ts, until: ts + BRIDGE_SITE_COOLDOWN_MS })
358
+ `INSERT INTO bridge_pacing (organization, site, last_hit_at, cooldown_until) VALUES (@organization, @site, @ts, @until)
359
+ ON CONFLICT(organization, site) DO UPDATE SET last_hit_at=excluded.last_hit_at, cooldown_until=MAX(cooldown_until, excluded.cooldown_until)`,
360
+ ).run({ organization: org, site, ts, until: ts + BRIDGE_SITE_COOLDOWN_MS })
266
361
  }
267
362
 
268
363
  /** 超时结算冷却:claimed 超时的导航作业——站点可能已被打,只推冷却,不虚构 last_hit_at */
269
- applyCooldown(site: string): void {
364
+ applyCooldown(organization: string, site: string): void {
365
+ const org = assertOrganizationId(organization)
270
366
  const ts = this.now()
271
367
  this.db.prepare(
272
- `INSERT INTO bridge_pacing (site, last_hit_at, cooldown_until) VALUES (@site, @ts, @until)
273
- ON CONFLICT(site) DO UPDATE SET cooldown_until=MAX(cooldown_until, excluded.cooldown_until)`,
274
- ).run({ site, ts, until: ts + BRIDGE_SITE_COOLDOWN_MS })
368
+ `INSERT INTO bridge_pacing (organization, site, last_hit_at, cooldown_until) VALUES (@organization, @site, @ts, @until)
369
+ ON CONFLICT(organization, site) DO UPDATE SET cooldown_until=MAX(cooldown_until, excluded.cooldown_until)`,
370
+ ).run({ organization: org, site, ts, until: ts + BRIDGE_SITE_COOLDOWN_MS })
275
371
  }
276
372
 
277
- pacingOf(site: string): { lastHitAt: number; cooldownUntil: number } | null {
278
- const row = this.db.prepare(`SELECT last_hit_at, cooldown_until FROM bridge_pacing WHERE site=?`).get(site) as Pick<BridgePacingRow, 'last_hit_at' | 'cooldown_until'> | undefined
373
+ pacingOf(organization: string, site: string): { lastHitAt: number; cooldownUntil: number } | null {
374
+ const org = assertOrganizationId(organization)
375
+ const row = this.db.prepare(`SELECT last_hit_at, cooldown_until FROM bridge_pacing WHERE organization=@organization AND site=@site`).get({ organization: org, site }) as Pick<BridgePacingRow, 'last_hit_at' | 'cooldown_until'> | undefined
279
376
  return row ? { lastHitAt: row.last_hit_at, cooldownUntil: row.cooldown_until } : null
280
377
  }
281
378
 
282
379
  /**
283
380
  * 事件上行落账(批次 B):seq 由 AUTOINCREMENT 分配,ts 取服务端落账时刻
284
381
  * (clientTs 是扩展侧时钟,仅参与形状校验不入列——服务端时钟才是账本唯一时间轴)。
285
- * 幂等:idem_key 非空且已存在 → 不重复落行,返回 false(由调用方计入 rejected);
286
- * idem_key 为 NULL 的行不参与去重,多行共存(部分唯一索引)。
287
- * 同进程同步 API,check-then-insert 对本连接原子。
382
+ * 幂等:idem_key 非空且**同组织**已存在 → 不重复落行,返回 false(由调用方计入 rejected);
383
+ * 不同组织可以复用同一个 idem_key,互不去重。idem_key 为 NULL 的行不参与去重,多行共存
384
+ * (部分唯一索引)。同进程同步 API,check-then-insert 对本连接原子。
288
385
  */
289
- insertEvent(ev: BridgeEventInput): boolean {
386
+ insertEvent(organization: string, ev: BridgeEventInput): boolean {
387
+ const org = assertOrganizationId(organization)
290
388
  if (ev.idem_key !== null) {
291
- const dup = this.db.prepare(`SELECT 1 FROM bridge_events WHERE idem_key=?`).get(ev.idem_key)
389
+ const dup = this.db.prepare(`SELECT 1 FROM bridge_events WHERE organization=@organization AND idem_key=@idem`).get({ organization: org, idem: ev.idem_key })
292
390
  if (dup) return false
293
391
  }
294
392
  this.db.prepare(
295
- `INSERT INTO bridge_events (ts, client_id, kind, site, subject, payload_json, idem_key)
296
- VALUES (@ts, @clientId, @kind, @site, @subject, @payload, @idem)`,
297
- ).run({ ts: this.now(), clientId: ev.client_id, kind: ev.kind, site: ev.site, subject: ev.subject, payload: ev.payload_json, idem: ev.idem_key })
393
+ `INSERT INTO bridge_events (organization, ts, client_id, kind, site, subject, payload_json, idem_key)
394
+ VALUES (@organization, @ts, @clientId, @kind, @site, @subject, @payload, @idem)`,
395
+ ).run({ organization: org, ts: this.now(), clientId: ev.client_id, kind: ev.kind, site: ev.site, subject: ev.subject, payload: ev.payload_json, idem: ev.idem_key })
298
396
  return true
299
397
  }
300
398
 
301
- /** 事件流水读面(测试/对账):按 seq 升序 */
302
- listEvents(): BridgeEventRow[] {
303
- return this.db.prepare(`SELECT seq, ts, client_id, kind, site, subject, payload_json, idem_key FROM bridge_events ORDER BY seq`).all() as BridgeEventRow[]
399
+ /** 事件流水读面(测试/对账):某组织的行,按 seq 升序 */
400
+ listEvents(organization: string): BridgeEventRow[] {
401
+ const org = assertOrganizationId(organization)
402
+ return this.db.prepare(`SELECT seq, organization, ts, client_id, kind, site, subject, payload_json, idem_key FROM bridge_events WHERE organization=@organization ORDER BY seq`).all({ organization: org }) as BridgeEventRow[]
304
403
  }
305
404
 
405
+ /** 全部组织的未终态作业(开机恢复按行还原所属组织) */
306
406
  listPendingJobs(): BridgePendingJob[] {
307
407
  return this.db.prepare(
308
- `SELECT id, kind, site, payload_json, status, claimed_by_client, claimed_origin, timeout_at, attempts
408
+ `SELECT id, organization, kind, site, payload_json, status, claimed_by_client, claimed_origin, timeout_at, attempts
309
409
  FROM bridge_jobs WHERE status IN ('queued','claimed')`,
310
410
  ).all() as BridgePendingJob[]
311
411
  }
312
412
 
413
+ /** 按 jobId 读行(任何组织;测试/对账口径,行里带 organization)。调用方有组织时用 getJobForOrganization */
313
414
  getJob(jobId: string): BridgeJobRow | null {
314
415
  return (this.db.prepare(`SELECT * FROM bridge_jobs WHERE id=?`).get(jobId) as BridgeJobRow | undefined) ?? null
315
416
  }
316
417
 
317
- getClient(clientId: string): BridgeClientRow | null {
318
- return (this.db.prepare(`SELECT * FROM bridge_clients WHERE client_id=?`).get(clientId) as BridgeClientRow | undefined) ?? null
418
+ getJobForOrganization(organization: string, jobId: string): BridgeJobRow | null {
419
+ const org = assertOrganizationId(organization)
420
+ return (this.db.prepare(`SELECT * FROM bridge_jobs WHERE id=@id AND organization=@organization`).get({ id: jobId, organization: org }) as BridgeJobRow | undefined) ?? null
421
+ }
422
+
423
+ /** 该 jobId 属于哪个组织(账本里没有这行 = null);队列用它认出「别的组织的作业」 */
424
+ organizationOfJob(jobId: string): string | null {
425
+ const row = this.db.prepare(`SELECT organization FROM bridge_jobs WHERE id=?`).get(jobId) as { organization: string } | undefined
426
+ return row ? row.organization : null
427
+ }
428
+
429
+ getClient(organization: string, clientId: string): BridgeClientRow | null {
430
+ const org = assertOrganizationId(organization)
431
+ return (this.db.prepare(`SELECT * FROM bridge_clients WHERE organization=@organization AND client_id=@clientId`).get({ organization: org, clientId }) as BridgeClientRow | undefined) ?? null
319
432
  }
320
433
 
321
- listPacing(): BridgePacingRow[] {
322
- return this.db.prepare(`SELECT site, last_hit_at, cooldown_until FROM bridge_pacing ORDER BY site`).all() as BridgePacingRow[]
434
+ listPacing(organization: string): BridgePacingRow[] {
435
+ const org = assertOrganizationId(organization)
436
+ return this.db.prepare(`SELECT organization, site, last_hit_at, cooldown_until FROM bridge_pacing WHERE organization=@organization ORDER BY site`).all({ organization: org }) as BridgePacingRow[]
323
437
  }
324
438
 
325
439
  close(): void {