@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.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/dist/capabilities/session/bridge-ledger.js +168 -67
- package/dist/capabilities/session/extension-bridge.js +140 -46
- package/dist/capabilities/session/gui-flight.js +281 -69
- package/dist/capabilities/session/gui-hotel.js +74 -17
- package/dist/capabilities/session-search.js +6 -5
- package/dist/scripts/booking-copilot-dsh-planner-proof-tests.js +129 -2
- package/dist/scripts/booking-copilot-dsh-readiness-proof.js +53 -5
- package/dist/scripts/booking-copilot-gateway-chat-adapter-tests.js +233 -0
- package/dist/scripts/booking-copilot-planner-live-probe.js +1 -0
- package/dist/scripts/booking-copilot-startup-proof-tests.js +1 -1
- package/dist/scripts/bridge-ledger-tests.js +20 -18
- package/dist/scripts/fixtures/tenant-runaway-worker-fixture.mjs +73 -0
- package/dist/scripts/mutation-check.js +153 -0
- package/dist/scripts/nightly-evidence.js +4 -111
- package/dist/scripts/session-tests.js +42 -8
- package/dist/scripts/tenant-bridge-org-tests.js +1264 -0
- package/dist/scripts/tenant-capacity-probe.js +263 -0
- package/dist/scripts/tenant-compute-isolation-tests.js +707 -0
- package/dist/scripts/tenant-egress-credentials-tests.js +822 -0
- package/dist/scripts/tenant-executor-binding-tests.js +203 -0
- package/dist/scripts/tenant-prompt-partition-tests.js +265 -0
- package/dist/scripts/tenant-seam-adversarial-tests.js +957 -0
- package/dist/scripts/tenant-seam-quota-tests.js +383 -0
- package/dist/scripts/tenant-seam-test-kit.js +292 -0
- package/dist/scripts/tenant-state-copies-tests.js +607 -0
- package/dist/scripts/tenant-state-drill-tests.js +298 -0
- package/dist/scripts/tenant-state-drill-worker.js +164 -0
- package/dist/scripts/tenant-state-durable-conformance.js +101 -0
- package/dist/scripts/tenant-state-handoff-tests.js +854 -0
- package/dist/scripts/tenant-state-lease-conformance.js +142 -0
- package/dist/scripts/tenant-state-lease-worker.js +28 -0
- package/dist/scripts/tenant-state-router-tests.js +464 -0
- package/dist/scripts/tenant-state-test-kit.js +92 -0
- package/dist/scripts/tenant-state-tests.js +564 -0
- package/dist/src/backend/modules/booking-executor.js +61 -6
- package/dist/src/backend/modules/session-search.js +136 -39
- package/dist/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/dist/src/booking-surface/dsh-planner.js +150 -19
- package/dist/src/booking-surface/index.js +1 -0
- package/dist/src/booking-surface/managed-dsh-run-port.js +121 -7
- package/dist/src/booking-surface/server.js +193 -80
- package/dist/src/booking-surface/startup.js +153 -0
- package/dist/src/booking-surface/tenancy.js +188 -0
- package/dist/src/index.js +31 -1
- package/dist/src/llm-price-table.js +148 -0
- package/dist/src/tenant-seam/index.js +8 -0
- package/dist/src/tenant-seam/llm-broker.js +508 -0
- package/dist/src/tenant-seam/process-governor.js +281 -0
- package/dist/src/tenant-seam/quotas.js +360 -0
- package/dist/src/tenant-seam/seam.js +92 -0
- package/dist/src/tenant-state/durable-store.js +158 -0
- package/dist/src/tenant-state/epoch-copies.js +422 -0
- package/dist/src/tenant-state/errors.js +108 -0
- package/dist/src/tenant-state/ids.js +101 -0
- package/dist/src/tenant-state/index.js +11 -0
- package/dist/src/tenant-state/lease-store.js +203 -0
- package/dist/src/tenant-state/tenant-lease.js +150 -0
- package/dist/src/tenant-state/tenant-router.js +650 -0
- package/extension/manifest.json +2 -2
- package/package.json +4 -1
- package/ts/capabilities/session/bridge-ledger.ts +192 -78
- package/ts/capabilities/session/extension-bridge.ts +176 -65
- package/ts/capabilities/session/gui-flight.ts +318 -88
- package/ts/capabilities/session/gui-hotel.ts +72 -18
- package/ts/capabilities/session-search.ts +17 -5
- package/ts/src/booking-surface/dsh-llm-gateway-chat.js +434 -0
- package/ts/src/booking-surface/dsh-planner.ts +169 -19
- package/ts/src/booking-surface/index.ts +1 -0
- package/ts/src/booking-surface/managed-dsh-run-port.ts +150 -7
- package/ts/src/booking-surface/server.ts +215 -84
- package/ts/src/booking-surface/startup.ts +155 -0
- package/ts/src/booking-surface/tenancy.ts +242 -0
- package/ts/src/index.ts +28 -2
- package/ts/src/llm-price-table.ts +180 -0
- package/ts/src/tenant-seam/index.ts +9 -0
- package/ts/src/tenant-seam/llm-broker.ts +520 -0
- package/ts/src/tenant-seam/process-governor.ts +343 -0
- package/ts/src/tenant-seam/quotas.ts +433 -0
- package/ts/src/tenant-seam/seam.ts +142 -0
- package/ts/src/tenant-state/durable-store.ts +171 -0
- package/ts/src/tenant-state/epoch-copies.ts +491 -0
- package/ts/src/tenant-state/errors.ts +128 -0
- package/ts/src/tenant-state/ids.ts +108 -0
- package/ts/src/tenant-state/index.ts +12 -0
- package/ts/src/tenant-state/lease-store.ts +266 -0
- package/ts/src/tenant-state/tenant-lease.ts +175 -0
- package/ts/src/tenant-state/tenant-router.ts +743 -0
|
@@ -19,6 +19,13 @@
|
|
|
19
19
|
* POST /results/:jobId → 善果/失败回传(≤8MB;batchSearch ~550KB)
|
|
20
20
|
* 语义:桥不存在即整个扩展车道不存在——fail-closed 不变量在调用方(session-search 的 needs-extension)。
|
|
21
21
|
* 审计:job 提交/回传写 GuardAuditEntry 同款 JSONL(kind:'extension-session-job',channel 层负责)。
|
|
22
|
+
*
|
|
23
|
+
* AaaS A2(#674,威胁 S-5)组织维度:作业、取活者(parked/长轮询)、在飞作业、心跳时刻、节律、
|
|
24
|
+
* 事件都属于恰好一个组织。作业匹配要求组织严格相等(先于 origin/clientId/站点 capability 判定);
|
|
25
|
+
* 另一个组织的回包与「未知作业」一模一样地 404 且**不落任何行**,在飞作业纹丝不动。
|
|
26
|
+
* 取活者的组织只由挂载宿主在鉴权之后经 `handleMountedRequest(req, res, { organization })`
|
|
27
|
+
* 传入,**从不读请求体或请求头**;不传 = 缺省 `local`(桌面 loopback 桥与既有调用方行为逐字不变)。
|
|
28
|
+
* 发给扩展的 ExtensionJob 线上形状不变:组织只活在队列内部条目与账本列里。
|
|
22
29
|
*/
|
|
23
30
|
|
|
24
31
|
import { randomUUID } from 'node:crypto'
|
|
@@ -26,6 +33,10 @@ import { existsSync } from 'node:fs'
|
|
|
26
33
|
import { homedir } from 'node:os'
|
|
27
34
|
import { join } from 'node:path'
|
|
28
35
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
|
36
|
+
import { assertOrganizationId } from '../../src/tenant-state/ids.ts'
|
|
37
|
+
|
|
38
|
+
/** 缺省组织 = 今天的单车道:不传组织的调用方(桌面 loopback 桥、既有测试)都落在这里 */
|
|
39
|
+
export const BRIDGE_DEFAULT_ORGANIZATION = 'local'
|
|
29
40
|
|
|
30
41
|
/** 桥端口池(extension/manifest.json host_permissions 必须同面;§38 断言) */
|
|
31
42
|
export const BRIDGE_PORTS = [8791, 8792, 8793, 8794, 8795] as const
|
|
@@ -136,7 +147,7 @@ export type SubmitOutcome =
|
|
|
136
147
|
| { ok: false; reason: 'bridge-unavailable' | 'extension-not-connected' | 'timeout'; summary: string; jobKind?: ExtensionJobKind; stage?: 'queued' | 'claimed' }
|
|
137
148
|
|
|
138
149
|
export interface SessionJobHandle {
|
|
139
|
-
submit(job: Omit<ExtensionJob, 'jobId'>, opts?: { timeoutMs?: number; extensionWaitMs?: number; preferredOrigin?: string; preferredClientId?: string }): Promise<SubmitOutcome>
|
|
150
|
+
submit(job: Omit<ExtensionJob, 'jobId'>, opts?: { timeoutMs?: number; extensionWaitMs?: number; preferredOrigin?: string; preferredClientId?: string; organization?: string }): Promise<SubmitOutcome>
|
|
140
151
|
extensionConnected(): boolean
|
|
141
152
|
port: number
|
|
142
153
|
close(): Promise<void>
|
|
@@ -160,6 +171,8 @@ export interface SessionBridgeOptions {
|
|
|
160
171
|
|
|
161
172
|
interface QueuedJob {
|
|
162
173
|
job: ExtensionJob
|
|
174
|
+
/** 作业所属组织(队列内部属性,不进发给扩展的 ExtensionJob) */
|
|
175
|
+
organization: string
|
|
163
176
|
resolve: (outcome: SubmitOutcome) => void
|
|
164
177
|
timer: ReturnType<typeof setTimeout> | null
|
|
165
178
|
preferredOrigin?: string
|
|
@@ -183,12 +196,16 @@ interface ParkedPoller {
|
|
|
183
196
|
capabilities: Set<string> | null
|
|
184
197
|
origin: string
|
|
185
198
|
clientId?: string
|
|
199
|
+
/** 取活者所属组织:由挂载宿主鉴权后传入,从不取自轮询体/请求头 */
|
|
200
|
+
organization: string
|
|
186
201
|
}
|
|
187
202
|
|
|
188
203
|
/** 扩展客户端标识形状(每 SW 生命周期一个 crypto.randomUUID);导出供事件上行端点(批次 B)同链校验 */
|
|
189
204
|
export const CLIENT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
190
205
|
|
|
191
|
-
function jobMatches(entry: QueuedJob, capabilities: Set<string> | null, origin: string, clientId
|
|
206
|
+
function jobMatches(entry: QueuedJob, capabilities: Set<string> | null, origin: string, clientId: string | undefined, organization: string): boolean {
|
|
207
|
+
// 组织严格相等先于一切:站点/origin/clientId 全部吻合的另一个组织的取活者,也永远领不到这个作业
|
|
208
|
+
if (entry.organization !== organization) return false
|
|
192
209
|
if (entry.preferredOrigin && entry.preferredOrigin !== origin) return false
|
|
193
210
|
if (entry.preferredClientId && entry.preferredClientId !== clientId) return false
|
|
194
211
|
return capabilities == null || capabilities.size === 0 || capabilities.has(entry.job.site)
|
|
@@ -231,11 +248,24 @@ export interface BridgeQueueStats {
|
|
|
231
248
|
* 浏览器客户端即执行环境,服务器只做编排/队列)
|
|
232
249
|
*/
|
|
233
250
|
export interface BridgeJobQueue extends SessionJobHandle {
|
|
234
|
-
|
|
235
|
-
|
|
251
|
+
/** 某组织的队列快照(缺省 `local`);别的组织的作业/取活者/心跳一概不计 */
|
|
252
|
+
stats(organization?: string): BridgeQueueStats
|
|
253
|
+
/** 某组织的扩展是否在线(缺省 `local`):心跳按组织分开记,一个组织的扩展在线不代表另一个组织在线 */
|
|
254
|
+
extensionConnected(organization?: string): boolean
|
|
255
|
+
/**
|
|
256
|
+
* 绑定到一个组织的提交句柄:`submit` 一律注入该组织,调用方传的 `organization` 会被覆盖,
|
|
257
|
+
* 所以下游代码(会话检索、登录入口)没有任何办法把作业提交到别的组织。组织非法 = 抛 InvalidTenantIdError。
|
|
258
|
+
* 句柄是视图:`close()` 不关闭共享队列。
|
|
259
|
+
*/
|
|
260
|
+
forOrganization(organization: string): SessionJobHandle
|
|
261
|
+
/** 完整桥协议(loopback 独立桥形态;/results/:jobId 路径式;组织恒为 `local`) */
|
|
236
262
|
handleRequest(req: IncomingMessage, res: ServerResponse): void
|
|
237
|
-
/**
|
|
238
|
-
|
|
263
|
+
/**
|
|
264
|
+
* 挂载形态(/v1/session/bridge 前缀;/results?jobId= 查询式;bearer 鉴权由宿主模块前置)。
|
|
265
|
+
* `caller.organization` 是宿主在鉴权之后解出的组织(缺省 `local`);非法组织 → 403 organization-invalid,
|
|
266
|
+
* 先于其它一切处理。组织**从不**读请求体或请求头。
|
|
267
|
+
*/
|
|
268
|
+
handleMountedRequest(req: IncomingMessage, res: ServerResponse, caller?: { organization: string }): void
|
|
239
269
|
/**
|
|
240
270
|
* 开机恢复(批次 A;仅 store 注入形态存在,纯内存无此方法):
|
|
241
271
|
* queued→重排 / claimed 已超时→unresolved+节律记账 / claimed 未超时→复活 inFlight。
|
|
@@ -265,28 +295,31 @@ export interface BridgeJobQueueOptions {
|
|
|
265
295
|
*/
|
|
266
296
|
export interface BridgeJobStore {
|
|
267
297
|
/** write-before-submit:入队前先落 queued;**抛错 = 提交失败,调用方不得入队派发** */
|
|
268
|
-
insertQueuedJob(job: ExtensionJob, timeoutAt: number): void
|
|
298
|
+
insertQueuedJob(organization: string, job: ExtensionJob, timeoutAt: number): void
|
|
269
299
|
/** 领取(/jobs 派发与 dispatchToParked 两处):claimed + 领取者,attempts 递增 */
|
|
270
|
-
markClaimed(jobId: string, claim: { origin: string; clientId?: string }): void
|
|
271
|
-
markResolved(jobId: string, result: ExtensionJobResult): void
|
|
300
|
+
markClaimed(organization: string, jobId: string, claim: { origin: string; clientId?: string }): void
|
|
301
|
+
markResolved(organization: string, jobId: string, result: ExtensionJobResult): void
|
|
272
302
|
/** 超时/对账结算:曾被领取却未拿到善果终态(带回包则由 recordOrphanResult 落 result) */
|
|
273
|
-
markUnresolved(jobId: string, note?: { reason: string }): void
|
|
303
|
+
markUnresolved(organization: string, jobId: string, note?: { reason: string }): void
|
|
274
304
|
/** 从未被领取的终态作废(零站点花费) */
|
|
275
|
-
markVoid(jobId: string): void
|
|
276
|
-
/** 孤儿回包落库(未知 jobId 的回包内容以 unresolved 保留,供对账) */
|
|
277
|
-
recordOrphanResult(jobId: string, result: ExtensionJobResult): void
|
|
305
|
+
markVoid(organization: string, jobId: string): void
|
|
306
|
+
/** 孤儿回包落库(未知 jobId 的回包内容以 unresolved 保留,供对账);jobId 已属别的组织 = 抛错零写入 */
|
|
307
|
+
recordOrphanResult(organization: string, jobId: string, result: ExtensionJobResult): void
|
|
278
308
|
/** bridge_clients upsert(/jobs 轮询体 clientId + capabilities 到达即刷新) */
|
|
279
|
-
upsertClient(clientId: string, origin: string, capabilities: readonly string[]): void
|
|
309
|
+
upsertClient(organization: string, clientId: string, origin: string, capabilities: readonly string[]): void
|
|
280
310
|
/** cookie-names 命中票据名(名字级)→ 记客户端在该站点持有登录态 */
|
|
281
|
-
recordClientLoginSite(clientId: string, site: string): void
|
|
282
|
-
/** 站点导航类作业提交/领取:记命中时刻并推冷却 */
|
|
283
|
-
noteSiteHit(site: string): void
|
|
311
|
+
recordClientLoginSite(organization: string, clientId: string, site: string): void
|
|
312
|
+
/** 站点导航类作业提交/领取:记命中时刻并推冷却(按 组织+站点) */
|
|
313
|
+
noteSiteHit(organization: string, site: string): void
|
|
284
314
|
/** 超时结算冷却(claimed 超时:站点可能已被打) */
|
|
285
|
-
applyCooldown(site: string): void
|
|
286
|
-
pacingOf(site: string): { lastHitAt: number; cooldownUntil: number } | null
|
|
287
|
-
/**
|
|
315
|
+
applyCooldown(organization: string, site: string): void
|
|
316
|
+
pacingOf(organization: string, site: string): { lastHitAt: number; cooldownUntil: number } | null
|
|
317
|
+
/** 该 jobId 在账本里属于哪个组织(无此行 = null):回包处理用它认出「别的组织的作业」 */
|
|
318
|
+
organizationOfJob(jobId: string): string | null
|
|
319
|
+
/** recoverOnBoot 输入:全部组织的未终态(queued/claimed)作业投影,每行带所属组织 */
|
|
288
320
|
listPendingJobs(): Array<{
|
|
289
321
|
id: string
|
|
322
|
+
organization: string
|
|
290
323
|
kind: string
|
|
291
324
|
site: string
|
|
292
325
|
payload_json: string
|
|
@@ -316,6 +349,16 @@ export function isNavigationJobKind(kind: ExtensionJobKind): boolean {
|
|
|
316
349
|
return NAVIGATION_JOB_KINDS.has(kind)
|
|
317
350
|
}
|
|
318
351
|
|
|
352
|
+
/** 组织 id 是否合法(assertOrganizationId 的布尔形式;非法组织一律在碰任何状态之前被拒) */
|
|
353
|
+
export function isBridgeOrganization(value: unknown): value is string {
|
|
354
|
+
try {
|
|
355
|
+
assertOrganizationId(value)
|
|
356
|
+
return true
|
|
357
|
+
} catch {
|
|
358
|
+
return false
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
319
362
|
export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJobQueue {
|
|
320
363
|
const extensionOrigins = new Set(opts.extensionOrigins ?? EXTENSION_ORIGINS)
|
|
321
364
|
const now = opts.now ?? Date.now
|
|
@@ -323,9 +366,13 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
|
|
|
323
366
|
const queue: QueuedJob[] = []
|
|
324
367
|
const inFlight = new Map<string, QueuedJob>()
|
|
325
368
|
const parked: ParkedPoller[] = []
|
|
326
|
-
|
|
369
|
+
// 心跳时刻按组织分开记:一个组织的扩展在线,不代表另一个组织的扩展在线
|
|
370
|
+
const lastSeenAt = new Map<string, number>()
|
|
327
371
|
let closed = false
|
|
328
|
-
const extensionConnected = (
|
|
372
|
+
const extensionConnected = (organization: string = BRIDGE_DEFAULT_ORGANIZATION): boolean => {
|
|
373
|
+
const seen = lastSeenAt.get(assertOrganizationId(organization)) ?? 0
|
|
374
|
+
return seen > 0 && now() - seen < EXTENSION_CONNECTED_WINDOW_MS
|
|
375
|
+
}
|
|
329
376
|
|
|
330
377
|
/**
|
|
331
378
|
* 账本写兜底:持久面故障不得打断桥协议主路径(write-before-submit 的 INSERT 除外,
|
|
@@ -341,65 +388,87 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
|
|
|
341
388
|
}
|
|
342
389
|
|
|
343
390
|
/** 领走首个 capability 匹配的 job(移入 inFlight——回包按 jobId 路由,两处都可找到) */
|
|
344
|
-
function takeMatching(capabilities: Set<string> | null, origin: string, clientId
|
|
345
|
-
const idx = queue.findIndex((q) => jobMatches(q, capabilities, origin, clientId))
|
|
391
|
+
function takeMatching(capabilities: Set<string> | null, origin: string, clientId: string | undefined, organization: string): QueuedJob | null {
|
|
392
|
+
const idx = queue.findIndex((q) => jobMatches(q, capabilities, origin, clientId, organization))
|
|
346
393
|
if (idx < 0) return null
|
|
347
394
|
const [next] = queue.splice(idx, 1)
|
|
348
395
|
next.claimedOrigin = origin
|
|
349
396
|
next.claimedClientId = clientId
|
|
350
397
|
inFlight.set(next.job.jobId, next)
|
|
351
|
-
record('markClaimed', () => store!.markClaimed(next.job.jobId, { origin, clientId }))
|
|
352
|
-
if (isNavigationJobKind(next.job.kind)) record('noteSiteHit', () => store!.noteSiteHit(next.job.site))
|
|
398
|
+
record('markClaimed', () => store!.markClaimed(next.organization, next.job.jobId, { origin, clientId }))
|
|
399
|
+
if (isNavigationJobKind(next.job.kind)) record('noteSiteHit', () => store!.noteSiteHit(next.organization, next.job.site))
|
|
353
400
|
return next
|
|
354
401
|
}
|
|
355
402
|
|
|
356
403
|
/** 扩展回包:queued 与 inFlight 两处都可寻址(job 执行失败 = result.ok:false,桥自身不吞) */
|
|
357
|
-
function resolveJob(jobId: string, parsed: ExtensionJobResult): void {
|
|
404
|
+
function resolveJob(organization: string, jobId: string, parsed: ExtensionJobResult): void {
|
|
358
405
|
const inFlightHit = inFlight.get(jobId)
|
|
359
406
|
if (inFlightHit) {
|
|
407
|
+
// 第二道防线:回包处理已把别的组织的 jobId 按「未知作业」挡在外面,这里再不认一次
|
|
408
|
+
if (inFlightHit.organization !== organization) return
|
|
360
409
|
inFlight.delete(jobId)
|
|
361
410
|
if (inFlightHit.timer) clearTimeout(inFlightHit.timer)
|
|
362
|
-
record('markResolved', () => store!.markResolved(jobId, parsed))
|
|
411
|
+
record('markResolved', () => store!.markResolved(inFlightHit.organization, jobId, parsed))
|
|
363
412
|
record('client-login-site', () => {
|
|
364
413
|
const names = Array.isArray(parsed.names) ? parsed.names : []
|
|
365
414
|
if (parsed.ok === true && inFlightHit.job.kind === 'cookie-names' && names.length > 0 && inFlightHit.claimedClientId) {
|
|
366
|
-
store!.recordClientLoginSite(inFlightHit.claimedClientId, inFlightHit.job.site)
|
|
415
|
+
store!.recordClientLoginSite(inFlightHit.organization, inFlightHit.claimedClientId, inFlightHit.job.site)
|
|
367
416
|
}
|
|
368
417
|
})
|
|
369
418
|
inFlightHit.resolve({ ok: true, result: parsed, origin: inFlightHit.claimedOrigin, clientId: inFlightHit.claimedClientId })
|
|
370
419
|
return
|
|
371
420
|
}
|
|
372
|
-
const idx = queue.findIndex((q) => q.job.jobId === jobId)
|
|
421
|
+
const idx = queue.findIndex((q) => q.job.jobId === jobId && q.organization === organization)
|
|
373
422
|
if (idx < 0) return
|
|
374
423
|
const [entry] = queue.splice(idx, 1)
|
|
375
424
|
if (entry.timer) clearTimeout(entry.timer)
|
|
376
|
-
record('markResolved', () => store!.markResolved(jobId, parsed))
|
|
425
|
+
record('markResolved', () => store!.markResolved(entry.organization, jobId, parsed))
|
|
377
426
|
entry.resolve({ ok: true, result: parsed })
|
|
378
427
|
}
|
|
379
428
|
|
|
429
|
+
/**
|
|
430
|
+
* 这个 jobId 属于另一个组织吗(内存里的排队/在飞条目,或账本里的行)?回包处理用它在**一切写入之前**
|
|
431
|
+
* 把别的组织的回包挡掉。读不了账本就证明不了「它不属于别的组织」,宁可当作属于(不落孤儿行)。
|
|
432
|
+
*/
|
|
433
|
+
function belongsToAnotherOrganization(jobId: string, organization: string): boolean {
|
|
434
|
+
const held = inFlight.get(jobId) ?? queue.find((q) => q.job.jobId === jobId)
|
|
435
|
+
if (held) return held.organization !== organization
|
|
436
|
+
if (!store) return false
|
|
437
|
+
try {
|
|
438
|
+
const owner = store.organizationOfJob(jobId)
|
|
439
|
+
return owner !== null && owner !== organization
|
|
440
|
+
} catch {
|
|
441
|
+
return true
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
380
445
|
/** 新 job 入队时即时派发给 capability 匹配的 parked 取活者(否则要白等一个 20s 长轮询周期) */
|
|
381
446
|
function dispatchToParked(entry: QueuedJob): boolean {
|
|
382
|
-
const i = parked.findIndex((p) => jobMatches(entry, p.capabilities, p.origin, p.clientId))
|
|
447
|
+
const i = parked.findIndex((p) => jobMatches(entry, p.capabilities, p.origin, p.clientId, p.organization))
|
|
383
448
|
if (i < 0) return false
|
|
384
449
|
const [p] = parked.splice(i, 1)
|
|
385
450
|
clearTimeout(p.timer)
|
|
386
451
|
entry.claimedOrigin = p.origin
|
|
387
452
|
entry.claimedClientId = p.clientId
|
|
388
453
|
inFlight.set(entry.job.jobId, entry)
|
|
389
|
-
record('markClaimed', () => store!.markClaimed(entry.job.jobId, { origin: p.origin, clientId: p.clientId }))
|
|
390
|
-
if (isNavigationJobKind(entry.job.kind)) record('noteSiteHit', () => store!.noteSiteHit(entry.job.site))
|
|
454
|
+
record('markClaimed', () => store!.markClaimed(entry.organization, entry.job.jobId, { origin: p.origin, clientId: p.clientId }))
|
|
455
|
+
if (isNavigationJobKind(entry.job.kind)) record('noteSiteHit', () => store!.noteSiteHit(entry.organization, entry.job.site))
|
|
391
456
|
p.res.statusCode = 200
|
|
392
457
|
p.res.setHeader('content-type', 'application/json')
|
|
393
458
|
p.res.end(JSON.stringify({ job: entry.job }))
|
|
394
459
|
return true
|
|
395
460
|
}
|
|
396
461
|
|
|
397
|
-
const stats = (): BridgeQueueStats =>
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
462
|
+
const stats = (organization: string = BRIDGE_DEFAULT_ORGANIZATION): BridgeQueueStats => {
|
|
463
|
+
const org = assertOrganizationId(organization)
|
|
464
|
+
const seen = lastSeenAt.get(org) ?? 0
|
|
465
|
+
return {
|
|
466
|
+
queued: queue.filter((q) => q.organization === org).length,
|
|
467
|
+
inFlight: [...inFlight.values()].filter((q) => q.organization === org).length,
|
|
468
|
+
parked: parked.filter((p) => p.organization === org).length,
|
|
469
|
+
lastSeenMsAgo: seen > 0 ? now() - seen : null,
|
|
470
|
+
}
|
|
471
|
+
}
|
|
403
472
|
|
|
404
473
|
/**
|
|
405
474
|
* 结算器(submit 与 recoverOnBoot 复活条目共用):内存三态清理 + 账本终态落笔。
|
|
@@ -422,15 +491,15 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
|
|
|
422
491
|
if (store && !outcome.ok) {
|
|
423
492
|
if (outcome.reason === 'timeout') {
|
|
424
493
|
if (wasClaimed) {
|
|
425
|
-
record('markUnresolved', () => store!.markUnresolved(entry.job.jobId, { reason: 'timeout' }))
|
|
426
|
-
if (isNavigationJobKind(entry.job.kind)) record('applyCooldown', () => store!.applyCooldown(entry.job.site))
|
|
494
|
+
record('markUnresolved', () => store!.markUnresolved(entry.organization, entry.job.jobId, { reason: 'timeout' }))
|
|
495
|
+
if (isNavigationJobKind(entry.job.kind)) record('applyCooldown', () => store!.applyCooldown(entry.organization, entry.job.site))
|
|
427
496
|
} else {
|
|
428
|
-
record('markVoid', () => store!.markVoid(entry.job.jobId))
|
|
497
|
+
record('markVoid', () => store!.markVoid(entry.organization, entry.job.jobId))
|
|
429
498
|
}
|
|
430
499
|
} else if (outcome.reason === 'extension-not-connected') {
|
|
431
|
-
record('markVoid', () => store!.markVoid(entry.job.jobId))
|
|
500
|
+
record('markVoid', () => store!.markVoid(entry.organization, entry.job.jobId))
|
|
432
501
|
} else if (!wasClaimed) {
|
|
433
|
-
record('markVoid', () => store!.markVoid(entry.job.jobId))
|
|
502
|
+
record('markVoid', () => store!.markVoid(entry.organization, entry.job.jobId))
|
|
434
503
|
}
|
|
435
504
|
}
|
|
436
505
|
deliver?.(outcome)
|
|
@@ -456,6 +525,11 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
|
|
|
456
525
|
return summary
|
|
457
526
|
}
|
|
458
527
|
for (const row of pending) {
|
|
528
|
+
// 行里的组织必须是合法 id:被改坏的行还原不出所属组织,留在原地不派发(fail-closed),绝不猜成 local
|
|
529
|
+
if (!isBridgeOrganization(row.organization)) {
|
|
530
|
+
process.stderr.write(`[bridge] 账本恢复跳过组织非法的作业 ${row.id}\n`)
|
|
531
|
+
continue
|
|
532
|
+
}
|
|
459
533
|
const expired = row.timeout_at !== null && row.timeout_at <= now()
|
|
460
534
|
let job: ExtensionJob | null = null
|
|
461
535
|
try {
|
|
@@ -466,6 +540,7 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
|
|
|
466
540
|
if (!expired && job) {
|
|
467
541
|
const entry: QueuedJob = {
|
|
468
542
|
job,
|
|
543
|
+
organization: row.organization,
|
|
469
544
|
resolve: null as unknown as (o: SubmitOutcome) => void,
|
|
470
545
|
timer: null,
|
|
471
546
|
claimedOrigin: row.claimed_origin ?? undefined,
|
|
@@ -478,14 +553,15 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
|
|
|
478
553
|
inFlight.set(row.id, entry)
|
|
479
554
|
summary.rehydratedInFlight++
|
|
480
555
|
} else {
|
|
481
|
-
record('markUnresolved', () => store!.markUnresolved(row.id, { reason: 'timeout' }))
|
|
482
|
-
if (isNavigationJobKind(row.kind as ExtensionJobKind)) record('applyCooldown', () => store!.applyCooldown(row.site))
|
|
556
|
+
record('markUnresolved', () => store!.markUnresolved(row.organization, row.id, { reason: 'timeout' }))
|
|
557
|
+
if (isNavigationJobKind(row.kind as ExtensionJobKind)) record('applyCooldown', () => store!.applyCooldown(row.organization, row.site))
|
|
483
558
|
summary.settledUnresolved++
|
|
484
559
|
}
|
|
485
560
|
} else {
|
|
486
561
|
if (!expired && job) {
|
|
487
562
|
const entry: QueuedJob = {
|
|
488
563
|
job,
|
|
564
|
+
organization: row.organization,
|
|
489
565
|
resolve: null as unknown as (o: SubmitOutcome) => void,
|
|
490
566
|
timer: null,
|
|
491
567
|
}
|
|
@@ -496,7 +572,7 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
|
|
|
496
572
|
if (!dispatchToParked(entry)) queue.push(entry)
|
|
497
573
|
summary.requeued++
|
|
498
574
|
} else {
|
|
499
|
-
record('markVoid', () => store!.markVoid(row.id))
|
|
575
|
+
record('markVoid', () => store!.markVoid(row.organization, row.id))
|
|
500
576
|
summary.voidedStale++
|
|
501
577
|
}
|
|
502
578
|
}
|
|
@@ -509,8 +585,19 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
|
|
|
509
585
|
* 为挂载形态(查询式 results——gotry-backend 内核只支持精确路由,jobId 走 query)。
|
|
510
586
|
* Origin 白名单校验内置于两种形态(远程形态同样强制:网页跨域请求必带邪恶 Origin)。
|
|
511
587
|
*/
|
|
512
|
-
function makeHandler(prefix: string, resultsViaQuery: boolean, statusExtra?: () => Record<string, unknown>) {
|
|
513
|
-
return (req: IncomingMessage, res: ServerResponse): void => {
|
|
588
|
+
function makeHandler(prefix: string, resultsViaQuery: boolean, mounted: boolean, statusExtra?: () => Record<string, unknown>) {
|
|
589
|
+
return (req: IncomingMessage, res: ServerResponse, caller?: { organization: string }): void => {
|
|
590
|
+
// 组织只来自挂载宿主在鉴权之后传入的 caller(loopback 桥恒为 local),**从不读请求体或请求头**;
|
|
591
|
+
// 非法组织先于一切 403,不碰任何状态
|
|
592
|
+
// 传了 caller 就必须带合法组织(缺字段/null 也算非法,绝不悄悄回落成 local);没传 caller = 单车道
|
|
593
|
+
const requested: unknown = mounted ? (caller === undefined ? BRIDGE_DEFAULT_ORGANIZATION : caller.organization) : BRIDGE_DEFAULT_ORGANIZATION
|
|
594
|
+
if (!isBridgeOrganization(requested)) {
|
|
595
|
+
res.statusCode = 403
|
|
596
|
+
res.setHeader('content-type', 'application/json')
|
|
597
|
+
res.end(JSON.stringify({ ok: false, error: 'organization-invalid' }))
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
const organization = requested
|
|
514
601
|
if (closed) { res.statusCode = 503; res.end(); return }
|
|
515
602
|
const urlPath = (req.url ?? '').split('?')[0]
|
|
516
603
|
const path = prefix && urlPath.startsWith(prefix) ? urlPath.slice(prefix.length) : urlPath
|
|
@@ -528,7 +615,7 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
|
|
|
528
615
|
// 一切携带白名单扩展 Origin 的请求都刷新 lastSeen,否则 45s 后误判扩展掉线;
|
|
529
616
|
// 无 Origin 的诊断 GET(/health /status)不记心跳,curl 探活不能伪造「扩展在线」
|
|
530
617
|
if (originTrusted) {
|
|
531
|
-
lastSeenAt
|
|
618
|
+
lastSeenAt.set(organization, now())
|
|
532
619
|
}
|
|
533
620
|
const finish = (code: number, body: unknown): void => {
|
|
534
621
|
res.statusCode = code
|
|
@@ -541,8 +628,8 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
|
|
|
541
628
|
}
|
|
542
629
|
if (req.method === 'GET' && path === '/status') {
|
|
543
630
|
finish(200, {
|
|
544
|
-
ok: true, protocol: BRIDGE_PROTOCOL, ...stats(),
|
|
545
|
-
extensionConnected: extensionConnected(),
|
|
631
|
+
ok: true, protocol: BRIDGE_PROTOCOL, ...stats(organization),
|
|
632
|
+
extensionConnected: extensionConnected(organization),
|
|
546
633
|
...(statusExtra ? statusExtra() : {}),
|
|
547
634
|
})
|
|
548
635
|
return
|
|
@@ -565,13 +652,13 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
|
|
|
565
652
|
})()
|
|
566
653
|
// 客户端注册落账(store 形态):轮询即刷新 origin/capabilities/last_seen(bridge_clients upsert)
|
|
567
654
|
if (pollerInfo.clientId) {
|
|
568
|
-
record('upsertClient', () => store!.upsertClient(pollerInfo.clientId!, origin as string, [...(pollerInfo.capabilities ?? [])]))
|
|
655
|
+
record('upsertClient', () => store!.upsertClient(organization, pollerInfo.clientId!, origin as string, [...(pollerInfo.capabilities ?? [])]))
|
|
569
656
|
}
|
|
570
|
-
const next = takeMatching(pollerInfo.capabilities, origin as string, pollerInfo.clientId)
|
|
657
|
+
const next = takeMatching(pollerInfo.capabilities, origin as string, pollerInfo.clientId, organization)
|
|
571
658
|
if (next) { finish(200, { job: next.job }); return }
|
|
572
659
|
// 长轮询:hold ≤ JOBS_LONG_POLL_MS(必须 < MV3 SW 30s 存活窗口,每次响应都续命);
|
|
573
660
|
// 新 job 提交时即时唤醒 parked 取活者(见 dispatchToParked)
|
|
574
|
-
const poller: ParkedPoller = { res, timer: null as unknown as ReturnType<typeof setTimeout>, capabilities: pollerInfo.capabilities, origin: origin as string, clientId: pollerInfo.clientId }
|
|
661
|
+
const poller: ParkedPoller = { res, timer: null as unknown as ReturnType<typeof setTimeout>, capabilities: pollerInfo.capabilities, origin: origin as string, clientId: pollerInfo.clientId, organization }
|
|
575
662
|
const parkTimer = setTimeout(() => {
|
|
576
663
|
const i = parked.findIndex((p) => p.res === res)
|
|
577
664
|
if (i >= 0) parked.splice(i, 1)
|
|
@@ -605,6 +692,13 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
|
|
|
605
692
|
finish(400, { ok: false, error: '回包不是 JSON' })
|
|
606
693
|
return
|
|
607
694
|
}
|
|
695
|
+
// 组织边界先于一切(含下面的 Origin/客户端校验):jobId 在内存或账本里属于另一个组织,
|
|
696
|
+
// 就答得与未知作业一模一样(404 unknown-job),但**什么都不写**——不落孤儿行、不动在飞作业
|
|
697
|
+
// (它自己的合法回包照常闭环),也不泄露「这个 id 存在」。
|
|
698
|
+
if (belongsToAnotherOrganization(jobId, organization)) {
|
|
699
|
+
finish(404, { ok: false, error: 'unknown-job' })
|
|
700
|
+
return
|
|
701
|
+
}
|
|
608
702
|
const assigned = inFlight.get(jobId)
|
|
609
703
|
if (assigned?.claimedOrigin && assigned.claimedOrigin !== origin) {
|
|
610
704
|
finish(403, { ok: false, error: '作业由另一扩展 Origin 领取' })
|
|
@@ -617,14 +711,14 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
|
|
|
617
711
|
// 未知 jobId(批次 A):不再 200 静默丢弃——404 明示,孤儿回包内容落账
|
|
618
712
|
// (status='unresolved',result_json 保留)供对账。扩展 postResult 本就有
|
|
619
713
|
// catch 且不检查状态码(extension/background.js),协议兼容零改动。
|
|
620
|
-
const known = assigned !== undefined || queue.some((q) => q.job.jobId === jobId)
|
|
714
|
+
const known = assigned !== undefined || queue.some((q) => q.job.jobId === jobId && q.organization === organization)
|
|
621
715
|
if (!known) {
|
|
622
|
-
record('orphan-result', () => store!.recordOrphanResult(jobId, parsed))
|
|
716
|
+
record('orphan-result', () => store!.recordOrphanResult(organization, jobId, parsed))
|
|
623
717
|
finish(404, { ok: false, error: 'unknown-job' })
|
|
624
718
|
return
|
|
625
719
|
}
|
|
626
720
|
finish(200, { ok: true })
|
|
627
|
-
resolveJob(jobId, parsed)
|
|
721
|
+
resolveJob(organization, jobId, parsed)
|
|
628
722
|
})
|
|
629
723
|
return
|
|
630
724
|
}
|
|
@@ -636,14 +730,31 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
|
|
|
636
730
|
port: 0,
|
|
637
731
|
extensionConnected,
|
|
638
732
|
stats,
|
|
639
|
-
|
|
640
|
-
|
|
733
|
+
forOrganization(organization) {
|
|
734
|
+
const bound = assertOrganizationId(organization)
|
|
735
|
+
return {
|
|
736
|
+
get port() { return queueHandle.port },
|
|
737
|
+
extensionConnected: () => extensionConnected(bound),
|
|
738
|
+
// 先展开调用方的选项、再盖上绑定的组织:下游传什么 organization 都改不了提交去向
|
|
739
|
+
submit: (job, submitOpts) => queueHandle.submit(job, { ...submitOpts, organization: bound }),
|
|
740
|
+
// 句柄只是视图,共享队列的生命周期归创建它的宿主
|
|
741
|
+
close: () => Promise.resolve(),
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
handleRequest: makeHandler('', false, false),
|
|
745
|
+
handleMountedRequest: makeHandler('/v1/session/bridge', true, true),
|
|
641
746
|
...(store ? { recoverOnBoot } : {}),
|
|
642
747
|
submit(job, submitOpts = {}) {
|
|
643
748
|
const extensionWaitMs = submitOpts.extensionWaitMs ?? DEFAULT_EXTENSION_WAIT_MS
|
|
644
749
|
const timeoutMs = submitOpts.timeoutMs ?? job.timeoutMs ?? 30_000
|
|
645
750
|
const full: ExtensionJob = { jobId: randomUUID(), ...job }
|
|
646
751
|
return new Promise<SubmitOutcome>((resolve) => {
|
|
752
|
+
// 组织只能是合法 id:非法的在碰账本/队列之前就失败(fail-closed)
|
|
753
|
+
const organization: unknown = submitOpts.organization === undefined ? BRIDGE_DEFAULT_ORGANIZATION : submitOpts.organization
|
|
754
|
+
if (!isBridgeOrganization(organization)) {
|
|
755
|
+
resolve({ ok: false, reason: 'bridge-unavailable', summary: '指定的组织标识无效' })
|
|
756
|
+
return
|
|
757
|
+
}
|
|
647
758
|
if (closed) { resolve({ ok: false, reason: 'bridge-unavailable', summary: '扩展桥已关闭' }); return }
|
|
648
759
|
if (submitOpts.preferredOrigin && !extensionOrigins.has(submitOpts.preferredOrigin)) {
|
|
649
760
|
resolve({ ok: false, reason: 'bridge-unavailable', summary: '指定扩展 Origin 不在桥白名单' })
|
|
@@ -657,14 +768,14 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
|
|
|
657
768
|
// 绝不派发无账作业。桌面纯内存形态此步整体跳过,行为零变化。
|
|
658
769
|
if (store) {
|
|
659
770
|
try {
|
|
660
|
-
store.insertQueuedJob(full, now() + timeoutMs)
|
|
771
|
+
store.insertQueuedJob(organization, full, now() + timeoutMs)
|
|
661
772
|
} catch (e) {
|
|
662
773
|
resolve({ ok: false, reason: 'bridge-unavailable', summary: `桥账本写入失败,作业不入队:${e instanceof Error ? e.message : String(e)}` })
|
|
663
774
|
return
|
|
664
775
|
}
|
|
665
|
-
if (isNavigationJobKind(full.kind)) record('noteSiteHit', () => store!.noteSiteHit(full.site))
|
|
776
|
+
if (isNavigationJobKind(full.kind)) record('noteSiteHit', () => store!.noteSiteHit(organization, full.site))
|
|
666
777
|
}
|
|
667
|
-
const entry: QueuedJob = { job: full, resolve: null as unknown as (o: SubmitOutcome) => void, timer: null, preferredOrigin: submitOpts.preferredOrigin, preferredClientId: submitOpts.preferredClientId }
|
|
778
|
+
const entry: QueuedJob = { job: full, organization, resolve: null as unknown as (o: SubmitOutcome) => void, timer: null, preferredOrigin: submitOpts.preferredOrigin, preferredClientId: submitOpts.preferredClientId }
|
|
668
779
|
const settle = armSettle(entry, resolve)
|
|
669
780
|
entry.resolve = settle
|
|
670
781
|
entry.timer = setTimeout(() => {
|
|
@@ -682,7 +793,7 @@ export function createBridgeJobQueue(opts: BridgeJobQueueOptions = {}): BridgeJo
|
|
|
682
793
|
let waited = 0
|
|
683
794
|
const waitTimer = setInterval(() => {
|
|
684
795
|
waited += 250
|
|
685
|
-
if (
|
|
796
|
+
if (extensionConnected(organization)) { clearInterval(waitTimer); return }
|
|
686
797
|
if (waited >= extensionWaitMs) {
|
|
687
798
|
clearInterval(waitTimer)
|
|
688
799
|
settle({
|