@sema-agent/server 7.53.0 → 7.55.0

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 (72) hide show
  1. package/USAGE.md +5 -0
  2. package/dist/approval-ask-audit-store.d.ts +129 -0
  3. package/dist/approval-ask-audit-store.js +284 -0
  4. package/dist/approval-card.d.ts +18 -6
  5. package/dist/approval-card.js +2 -2
  6. package/dist/approval-reconciler.d.ts +2 -1
  7. package/dist/approval-reconciler.js +1 -1
  8. package/dist/boot/config-center.js +2 -2
  9. package/dist/boot/coordinators.d.ts +2 -0
  10. package/dist/boot/coordinators.js +18 -1
  11. package/dist/boot/leader.js +2 -0
  12. package/dist/boot/reapers.d.ts +2 -1
  13. package/dist/boot/resolve-spec.js +1 -3
  14. package/dist/boot/runner-deps.d.ts +20 -0
  15. package/dist/boot/runner-deps.js +14 -8
  16. package/dist/boot/shutdown.js +1 -1
  17. package/dist/boot/stores.js +8 -1
  18. package/dist/capabilities/team.d.ts +10 -9
  19. package/dist/config-center/apply-effective.d.ts +25 -26
  20. package/dist/config-center/apply-effective.js +28 -66
  21. package/dist/config-center/effective-keys.d.ts +30 -0
  22. package/dist/config-center/effective-keys.js +49 -0
  23. package/dist/config-center/facade.d.ts +3 -3
  24. package/dist/config-center/facade.js +1 -1
  25. package/dist/config-center/restart-signal.js +3 -9
  26. package/dist/config-center/types.d.ts +19 -40
  27. package/dist/config-lkg.js +7 -1
  28. package/dist/config-provider.js +1 -1
  29. package/dist/device-enrollment.d.ts +5 -3
  30. package/dist/device-store.d.ts +81 -3
  31. package/dist/device-store.js +37 -0
  32. package/dist/device-ws-hub.d.ts +15 -2
  33. package/dist/device-ws-hub.js +31 -5
  34. package/dist/http/routes/approvals-assistant.js +1 -0
  35. package/dist/http/routes/devices.d.ts +64 -0
  36. package/dist/http/routes/devices.js +173 -0
  37. package/dist/http/server.d.ts +14 -0
  38. package/dist/http/server.js +32 -2
  39. package/dist/http/wire-types.d.ts +19 -0
  40. package/dist/leader/wire.d.ts +12 -0
  41. package/dist/leader/wire.js +6 -5
  42. package/dist/main.js +3 -1
  43. package/dist/observability/fail-open.d.ts +20 -0
  44. package/dist/observability/fail-open.js +20 -0
  45. package/dist/plugins/approval-ask-store-memory.d.ts +11 -1
  46. package/dist/plugins/approval-ask-store-memory.js +20 -3
  47. package/dist/plugins/approval-ask-store-sql.d.ts +82 -0
  48. package/dist/plugins/approval-ask-store-sql.js +41 -10
  49. package/dist/plugins/device-store-sql.d.ts +38 -1
  50. package/dist/plugins/device-store-sql.js +82 -2
  51. package/dist/plugins/pg-pool.d.ts +29 -2
  52. package/dist/plugins/pg-pool.js +45 -2
  53. package/dist/plugins/remote-env-device.d.ts +4 -2
  54. package/dist/plugins/remote-env-device.js +12 -2
  55. package/dist/plugins/roster-store-sql.d.ts +1 -3
  56. package/dist/plugins/sql-errors.d.ts +12 -0
  57. package/dist/plugins/sql-errors.js +10 -0
  58. package/dist/plugins/store-backend.js +1 -1
  59. package/dist/plugins/tidb-pool.d.ts +13 -0
  60. package/dist/runs.js +1 -0
  61. package/dist/runtime-governance.d.ts +4 -0
  62. package/dist/runtime-governance.js +23 -1
  63. package/dist/tool-approval.d.ts +63 -39
  64. package/dist/tool-approval.js +322 -109
  65. package/dist/trace/engine-notice-wire.d.ts +1 -1
  66. package/dist/trace/engine-notice-wire.js +2 -0
  67. package/dist/trace/injection-tier.d.ts +11 -21
  68. package/dist/trace/injection-tier.js +3 -4
  69. package/dist/trace/ledger-events.d.ts +9 -0
  70. package/dist/trace/project.d.ts +1 -0
  71. package/dist/trace/project.js +1 -0
  72. package/package.json +3 -3
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * config-center wire types — the config-center `/api/config/effective` payload shapes (`CenterModel`,
3
- * `CenterTeam`, `CenterSkillManifest`, `CenterMcpServer`, `EffectiveConfig`) plus the per-principal
3
+ * `CenterSkillManifest`, `CenterMcpServer`, `CenterA2aPeer`, `EffectiveConfig`) plus the per-principal
4
4
  * execution/session-mirror ruling shapes riding the caps view (`ExecutionRuling`/`SessionMirrorRuling`).
5
5
  * Pure type/interface declarations — no runtime logic. Split out of `facade.ts` ([ref] A13,
6
6
  * internal-lossless: the facade re-exports every symbol below unchanged).
@@ -71,24 +71,6 @@ export type CenterRoleTarget = {
71
71
  } | {
72
72
  select: unknown;
73
73
  };
74
- export interface CenterTeam {
75
- name: string;
76
- members: {
77
- role: string;
78
- modelRole?: string;
79
- model?: string;
80
- systemPrompt?: string;
81
- }[];
82
- rounds: number;
83
- synthesizer?: {
84
- role: string;
85
- modelRole?: string;
86
- systemPrompt?: string;
87
- };
88
- scenario?: string;
89
- maxTranscriptTokens?: number;
90
- enabled?: boolean;
91
- }
92
74
  /** Skill MANIFEST entry (content is NOT inline — fetched by hash; see `fetchSkillContent`). */
93
75
  export interface CenterSkillManifest {
94
76
  name: string;
@@ -250,14 +232,8 @@ export interface EffectiveConfig {
250
232
  tierGroups?: unknown;
251
233
  activeTierGroup?: string;
252
234
  };
253
- /** OPTIONAL since registry-core 0.7 deleted the teams domain: a 0.7+ center /effective payload
254
- * has NO teams key (collab is its successor). Absent/empty registerTeams resets to BUILTIN_TEAMS (the
255
- * permanent builtin base). */
256
- teams?: {
257
- teams: CenterTeam[];
258
- };
259
- /** registry-core 0.7 collab domain (teams' successor) — consumed by the slice-① read-only
260
- * projection (projectCollabToTeams → registerTeams). Both lanes carry it: the remote worker /effective view
235
+ /** registry-core 0.7 collab domain (teams' successor) consumed by the slice-1.5 projection
236
+ * (projectCollabToWorkflows registerCollabWorkflows). Both lanes carry it: the remote worker /effective view
261
237
  * ships collab, and local FileConfigStore reads config.d/collab.json (PORTABLE_DOMAINS includes collab). */
262
238
  collab?: {
263
239
  templates: CollabTemplateWire[];
@@ -285,22 +261,23 @@ export interface EffectiveConfig {
285
261
  enabled?: boolean;
286
262
  }>;
287
263
  };
288
- /** Fleet governance/limit gates (phase-2, center SERVICE-INTEGRATION「Runtime 治理/限额闸」). All restart-to-apply,
289
- * non-secret, all center `.optional()` (center `f95438a`): absent = unmanaged → keep env; present (incl. an
290
- * explicit 0/[] = "publish OFF") = override. Consumption precedence is in `applyRuntimeGates`. */
264
+ /** `runtime` = **限额残余五键**,与 settings-schema `RuntimeConfig` 的键集逐字相等([ref] 件3 对账门
265
+ * test/settings-schema-consumption-gates.test.ts dist `RuntimeConfig.shape` 钉集合等式)。全部 center
266
+ * `.optional()`(center `f95438a`):缺席 = 不管 → 保 env;在场(含显式 0 = "publish OFF")= 覆盖。
267
+ * [ref] 批1 起五键全是**热**键(属主 stage-limits,boot+refresh 双腿);`limits` 域是它们的继任发布位
268
+ * (两处同现 ⇒ limits 赢,见 {@link EffectiveConfig.limits})。
269
+ *
270
+ * 🔴 治理三件(autonomy / commandPolicy / approvalRequire)**不在这里**([ref] 件3 退役键清除):它们自
271
+ * settings-schema 0.6 起住 `governance` 域,0.7.1 撤了 runtime 位的双写镜像;此后 `RuntimeConfig` 是 strip
272
+ * 的 zod object,两条腿(远端 `readEffectiveWire` / 本地 FileConfigStore→`buildEffective`)都把旧位 strip
273
+ * 掉、由 `legacyGovernanceFromRuntime` 抬进 governance ⇒ 本仓在 runtime 位上读它们的每一行都是死码。
274
+ * 旧类型面上留着这三键,让 `runtime-gates` 重启片一直读着一个生产上恒缺席的位(审批闸收紧静默不生效)。 */
291
275
  runtime?: {
292
276
  rateLimitPerMin?: number;
293
- approvalRequire?: string[];
294
277
  maxTaskCostUsd?: number;
295
278
  maxTaskTokens?: number;
296
279
  maxPrincipalCostUsd?: number;
297
280
  costQuotaWindowSec?: number;
298
- /** Runtime governance second baton (center §10): autonomy mode + per-command rules. UNLIKE the 6 gates above,
299
- * these are per-request HOT (read live in resolveSpec, not baked into boot middleware) → applied on boot AND
300
- * refresh by `applyRuntimeHot` (NOT in RUNTIME_GATE_KEYS / restart-to-apply). Same presence rule (undefined =
301
- * unmanaged → keep). */
302
- autonomy?: Autonomy;
303
- commandPolicy?: CommandRule[];
304
281
  };
305
282
  /**
306
283
  * [ref] 批1 —— **limitsHttp 组的热更新发布域**([ref] §6 批1)。
@@ -330,9 +307,11 @@ export interface EffectiveConfig {
330
307
  projects?: {
331
308
  projects?: Record<string, unknown>;
332
309
  };
333
- /** registry-core 0.6:治理三件(autonomy/commandPolicy/approvalRequire)拆新 governance 域=真值;
334
- * 过渡期 effective 在 runtime 位双写镜像。读取优先 governance 新位、runtime 旧位 fallback(顺序契约:
335
- * 消费端先切换、确认后 center 才撤双写,治理闸零 fail-open 窗口)。 */
310
+ /** settings-schema 0.6 起治理三件(autonomy/commandPolicy/approvalRequire)的**唯一**承运域(0.7.1
311
+ * runtime 位双写镜像;顺序契约当年已走完:消费端先切、center 再撤)。键集与 schema `GovernanceConfig.shape`
312
+ * 逐字相等([ref] 件3 对账门钉)。消费:autonomy/commandPolicy 逐请求热(`stageRuntimeHot`,boot+refresh);
313
+ * approvalRequire boot-baked(`stageApprovalGate`,refresh 不写)⇒ 变更由 `runtime-gates` 重启片通告。
314
+ * 同一 presence 规则(undefined = 不管 → 保 env / 回基线)。 */
336
315
  governance?: {
337
316
  autonomy?: Autonomy;
338
317
  commandPolicy?: CommandRule[];
@@ -47,7 +47,7 @@ export async function loadLkg(path, worker) {
47
47
  const eff = p.effective;
48
48
  if (typeof eff.version !== "number")
49
49
  return { reason: "bad effective.version" };
50
- for (const [domain, arrayKey] of [["models", "models"], ["teams", "teams"], ["skills", "skills"], ["mcp", "servers"], ["scenarios", "scenarios"], ["runtime", null]]) {
50
+ for (const [domain, arrayKey] of [["models", "models"], ["skills", "skills"], ["mcp", "servers"], ["scenarios", "scenarios"], ["runtime", null]]) {
51
51
  const v = eff[domain];
52
52
  if (v === undefined)
53
53
  continue;
@@ -59,6 +59,12 @@ export async function loadLkg(path, worker) {
59
59
  return { reason: `bad effective.${domain}.${arrayKey}` };
60
60
  }
61
61
  }
62
+ {
63
+ const rt = eff.runtime;
64
+ if (rt && (rt.autonomy !== undefined || rt.commandPolicy !== undefined || rt.approvalRequire !== undefined)) {
65
+ return { reason: "legacy runtime governance keys (pre-0.7.1 mirror shape; no contract producer since LKG shipped) — refusing rather than silently dropping governance" };
66
+ }
67
+ }
62
68
  const promptsRaw = p.effective.prompts;
63
69
  if (promptsRaw !== undefined && !validatePromptsDomain(promptsRaw).ok)
64
70
  return { reason: "prompts face fails re-validation" };
@@ -126,8 +126,8 @@ export function mapToServiceEffective(eff, version) {
126
126
  ...(eff.models.tierGroups !== undefined ? { tierGroups: eff.models.tierGroups } : {}),
127
127
  ...(eff.models.activeTierGroup !== undefined ? { activeTierGroup: eff.models.activeTierGroup } : {}),
128
128
  },
129
- teams: { teams: [] },
130
129
  ...(eff.collab?.templates?.length ? { collab: { templates: eff.collab.templates } } : {}),
130
+ ...(eff.plugins?.plugins?.length ? { plugins: { plugins: eff.plugins.plugins } } : {}),
131
131
  skills: {
132
132
  skills: (eff.skills.skills ?? []).map((s) => ({
133
133
  name: s.name,
@@ -23,13 +23,15 @@
23
23
  * 判定);②吊销同事务把连接租约作废并把 `conn_generation` 推进一格 ⇒ 在途连接的每一次带世代谓词的写
24
24
  * 都会影响 0 行,设备侧因此立刻失权(§4.6 步 2);③重新 enrollment = 新 deviceId,不复活旧行。
25
25
  */
26
- import { DEVICE_RATE_LIMIT_CODE, type DeviceErrorCode, type DeviceOwner, type DeviceRow, type DeviceSessionRow, type DeviceStore, type NewDeviceInput } from "./device-store.js";
26
+ import { type DeviceOwner, type DeviceRejectCode, type DeviceRow, type DeviceSessionRow, type DeviceStore, type NewDeviceInput } from "./device-store.js";
27
27
  /** typed 拒绝 —— 端点层只负责把它翻成 HTTP(status 已在体内,不许调用方另算)。 */
28
28
  export interface DeviceRejection {
29
- code: DeviceErrorCode | typeof DEVICE_RATE_LIMIT_CODE;
29
+ /** [ref]:含两个借用位(`limit.rate_exceeded` 429 / `draining` 503);enrollment 本文件只铸前者,
30
+ * `draining` 的唯一铸造点在 device-ws-hub 的排空/停机两臂。 */
31
+ code: DeviceRejectCode;
30
32
  status: number;
31
33
  message: string;
32
- /** 限流专用:`Retry-After` 的秒值(附录 A 的 429 族纪律)。 */
34
+ /** 限流/排空专用:`Retry-After` 的秒值(附录 A 的 429/503 族纪律)。 */
33
35
  retryAfterSec?: number;
34
36
  }
35
37
  export type DeviceOutcome<T> = {
@@ -28,6 +28,13 @@ export declare const DEVICE_ERROR_CODES: {
28
28
  readonly ENROLLMENT_INVALID: "device.enrollment_invalid";
29
29
  /** 反枚举 404:设备不存在 **与** 设备不是你的,逐字节同形。 */
30
30
  readonly NOT_FOUND: "not_found.device";
31
+ /** O4 换绑动词(2026-08-30 v2 稿 §2 步 3):前态 CAS 输 —— 体带当前 rev(`currentRev`,[ref]
32
+ * currentPending 哲学,壳一跳重定位)。**唯一铸造点 = `POST /v1/devices/sessions/:rootSessionId/rebind`**
33
+ * (管理面,`http/routes/devices.ts`);adapter/准入链结构上不产它。 */
34
+ readonly REBIND_REV_CONFLICT: "device.rebind_rev_conflict";
35
+ /** O4 换绑动词(v2 稿 §2 步 4):该根会话名下有非终态 run(durable 判据 = run store 的 session
36
+ * claim;hub 内存在途计数**不作门**——跨副本不可见)—— 体带 `activeTaskId`。铸造点同上一条。 */
37
+ readonly REBIND_ACTIVE_RUN: "device.rebind_active_run";
31
38
  };
32
39
  /** {@link DEVICE_ERROR_CODES} 的值联合 —— 闭集(未知码 = 编译错误,[ref] 词表纪律)。 */
33
40
  export type DeviceErrorCode = (typeof DEVICE_ERROR_CODES)[keyof typeof DEVICE_ERROR_CODES];
@@ -39,16 +46,34 @@ export type DeviceErrorCode = (typeof DEVICE_ERROR_CODES)[keyof typeof DEVICE_ER
39
46
  * 同义码 = 消费端要认两套。`device.busy`(429)留给**在途指令数**上限,不是这里。
40
47
  */
41
48
  export declare const DEVICE_RATE_LIMIT_CODE: "limit.rate_exceeded";
49
+ /**
50
+ * 排空/停机拒绝复用**既有**的通用码(`draining`,`docs/ASSISTANT-WIRE-CONTRACT.md` 附录 A 已收录,503)。
51
+ *
52
+ * 🔴 刻意不铸 `device.draining`([ref]/[ref]):§4.8 的 device 闭集**冻结**且无 draining 成员,而排空
53
+ * 语义面全部署早有属主词 —— HTTP 计费提交拒臂(`sendError(res, 503, "draining", …)`,SDK spec 逐字引用
54
+ * 的冻结字面)、drain 期新 upgrade 拒(503)、WS hello 拒帧 / bye 帧的 `draining` 分支键。为同一语义再铸
55
+ * `device.*` 同义码 = 消费端要认两套(上面 rate-limit 条的同一判据);且 `device.draining` 在 HTTP 层
56
+ * 零发送点,写进附录 A 即幽灵行(`error-code-catalog-live` 的「文档多码」臂当场红)。
57
+ * 与 `device.busy` 的分工自此可判:`device.busy`(429)= 在途满,稍后重试**同设备**;`draining`(503)=
58
+ * **本副本**在退,重试同副本不会好 —— 该去替换副本/等设备重连。
59
+ */
60
+ export declare const DEVICE_DRAINING_CODE: "draining";
61
+ /** device 拒绝面的码全集 = §4.8 闭集 + 两个借用位(限流 429 / 排空 503)。 */
62
+ export type DeviceRejectCode = DeviceErrorCode | typeof DEVICE_RATE_LIMIT_CODE | typeof DEVICE_DRAINING_CODE;
42
63
  /** 拒绝码 → HTTP 状态。**穷举 map**(缺一个成员 = 编译错误),不是运行时 switch 的默认臂。 */
43
- export declare const DEVICE_ERROR_STATUS: Readonly<Record<DeviceErrorCode | typeof DEVICE_RATE_LIMIT_CODE, number>>;
64
+ export declare const DEVICE_ERROR_STATUS: Readonly<Record<DeviceRejectCode, number>>;
44
65
  /**
45
66
  * `device_audit.event` 的闭集(v2 §6 DDL 的 CHECK 清单 + §4.8「标审计行者」,两处逐字对齐)。
46
67
  *
47
68
  * ⚠️ §4.8 的**遥测**清单比这张表长(`device_connected`/`device_disconnected`/`device_heartbeat_lost`/
48
69
  * `device_instruction_dispatched`/`device_outcome_unknown`)—— 那些是**计数器/日志**面,不落审计表。
49
- * 只有下面这 8 个是「安全轴事件,必须可追溯查询」。加成员 = 同时改这里 + 两侧 DDL 的 CHECK
70
+ * 只有下面这 9 个是「安全轴事件,必须可追溯查询」。加成员 = 同时改这里 + 两侧 DDL 的 CHECK
71
+ * (`AUDIT_EVENT_CHECK` 由本数组生成,DDL 自动跟);**且**存量表的旧 CHECK 不会被
72
+ * `CREATE TABLE IF NOT EXISTS` 更新 —— 升级探针见 `device-store-sql.ts` 的
73
+ * `assertDeviceAuditRebindEventSchema`(能力探测:PG 强制 CHECK、TiDB 默认不执行,探的是**行为**
74
+ * 不是元数据 —— memory `capability-detection-not-impl-names` 同判)。
50
75
  */
51
- export declare const DEVICE_AUDIT_EVENTS: readonly ["device_enrolled", "device_revoked", "device_token_rotated", "device_enroll_token_reuse", "device_frame_unexpected", "device_identity_mismatch", "device_superseded", "device_attached_elsewhere"];
76
+ export declare const DEVICE_AUDIT_EVENTS: readonly ["device_enrolled", "device_revoked", "device_token_rotated", "device_enroll_token_reuse", "device_frame_unexpected", "device_identity_mismatch", "device_superseded", "device_attached_elsewhere", "session_rebound"];
52
77
  export type DeviceAuditEvent = (typeof DEVICE_AUDIT_EVENTS)[number];
53
78
  /** v1 平台闭集(AR-R1-8)。win32 = follow-on(§10 车A-4 的 flavor 注)。 */
54
79
  export declare const DEVICE_PLATFORM_OS: readonly ["darwin", "linux"];
@@ -144,6 +169,10 @@ export declare function assertDevicePubkeyShape(pubkey: string): void;
144
169
  export declare function assertNewDeviceShape(input: NewDeviceInput): void;
145
170
  export declare function assertIssueTokenShape(input: IssueEnrollTokenInput): void;
146
171
  export declare function assertRevokeShape(input: RevokeDeviceInput): void;
172
+ /** O4 换绑入参的整形门(三形共用)。`expectedRev` 收窄为非负整数:它要进 SQL 等值谓词,小数/负数/NaN
173
+ * 在两方言的 BIGINT 比对下不是「不匹配」而是各自的隐式转换歧义面 —— 在门口拒,方言分歧面消失。
174
+ * ⚠️ HTTP 层对坏形先答 400(业务拒绝);这里的抛是**编程/装配错误**的兜底,不是 wire 分支。 */
175
+ export declare function assertRebindShape(input: RebindSessionInput): void;
147
176
  export declare function assertAcquireShape(input: AcquireConnectionInput): void;
148
177
  /** token 摘要必须是**规范形**(小写 sha256 hex):大小写混形在 VARBINARY/COLLATE "C" 下是两个键,
149
178
  * 于是同一个明文 token 能被消费两次 —— 这是 §7-T5 ③「一次性 CAS」的直接跳过面。 */
@@ -259,6 +288,47 @@ export type BindSessionResult = {
259
288
  } | {
260
289
  outcome: "device_revoked";
261
290
  };
291
+ /**
292
+ * O4 显式换绑(2026-08-30 v2 稿 §2)。`owner` = **绑定行的**复合身份(HTTP 层已裁决:属主同域或
293
+ * explicit operator;operator 代操作时传的也是行的 owner,不是操作员自己)——店内所有谓词恒带它,
294
+ * 判别位在店里就消失(反枚举,文件头注)。`expectedRev` = 壳从 S-4 读面拿到的前态(强制显式,
295
+ * 防两操作员并发换绑互踩)。
296
+ */
297
+ export interface RebindSessionInput {
298
+ rootSessionId: string;
299
+ toDeviceId: string;
300
+ expectedRev: number;
301
+ owner: DeviceOwner;
302
+ }
303
+ /**
304
+ * O4 换绑的判别式结果(门序 = v2 稿 §2:目标设备在场 → 幂等短路 → 前态 CAS → 审计;活跃 run 门在
305
+ * **HTTP 层**、CAS 之前判 —— run store 是另一只店,CAS 语句不兼职)。
306
+ */
307
+ export type RebindSessionResult =
308
+ /** CAS 赢家:`rev+1` + `bound_at` 刷新 + `session_rebound` 审计行**同一事务**提交。 */
309
+ {
310
+ outcome: "rebound";
311
+ row: DeviceSessionRow;
312
+ }
313
+ /** 真幂等:`toDeviceId` ≡ 当前绑定 ∧ `expectedRev` ≡ 当前 rev ⇒ 回显不加 rev、不写审计。 */
314
+ | {
315
+ outcome: "idempotent";
316
+ row: DeviceSessionRow;
317
+ }
318
+ /** 前态输(rev 不符,含「toDeviceId 同但 rev 错」——前态错就是错)⇒ 携当前行供壳一跳重定位。 */
319
+ | {
320
+ outcome: "rev_conflict";
321
+ row: DeviceSessionRow;
322
+ }
323
+ /** 绑定行不在**或**不是该 owner 的 —— 反枚举同形(HTTP 层折 404 同串)。 */
324
+ | {
325
+ outcome: "binding_not_found";
326
+ }
327
+ /** 目标设备不在 / 不是该 owner 的 / **已吊销** —— v2 稿 §2 步 2:三臂同折 404(设备 id 也是
328
+ * 不可枚举面);吊销不单列 = 判别位在店里就消失,消费层想泄也泄不出来。 */
329
+ | {
330
+ outcome: "device_not_found";
331
+ };
262
332
  export interface RevokeDeviceInput {
263
333
  deviceId: string;
264
334
  owner: DeviceOwner;
@@ -330,12 +400,18 @@ export interface DeviceStore {
330
400
  /** 管理面/测试用的无 owner 谓词读口 —— **不得**接到任何 principal-scoped 的 wire 面上。 */
331
401
  getDeviceUnscoped(deviceId: string): Promise<DeviceRow | null>;
332
402
  listDevicesByOwner(owner: DeviceOwner): Promise<DeviceRow[]>;
403
+ /** O4/S-4:全量列表(operator 面专用;`getDeviceUnscoped` 同一条纪律 —— 不得接到任何
404
+ * principal-scoped 的 wire 面上,消费点必须先过 explicit operator 门)。 */
405
+ listDevicesUnscoped(): Promise<DeviceRow[]>;
333
406
  revokeDevice(input: RevokeDeviceInput): Promise<RevokeDeviceResult>;
334
407
  acquireConnection(input: AcquireConnectionInput): Promise<AcquireConnectionResult>;
335
408
  /** 世代谓词命中 = true;0 行 = 已失权。 */
336
409
  heartbeatConnection(input: HeartbeatConnectionInput): Promise<boolean>;
337
410
  releaseConnection(deviceId: string, generation: number): Promise<boolean>;
338
411
  bindSession(input: BindSessionInput): Promise<BindSessionResult>;
412
+ /** O4 显式换绑(v2 稿 §2 步 2/3/5):目标设备门 + 前态 CAS + `session_rebound` 审计行同一事务。
413
+ * 活跃 run 门**不在这里**(run store 是另一只店)—— 由 HTTP 层在调用前判(v2 稿 §2 步 4)。 */
414
+ rebindSession(input: RebindSessionInput): Promise<RebindSessionResult>;
339
415
  getSessionBinding(rootSessionId: string): Promise<DeviceSessionRow | null>;
340
416
  /** purge coordinator 座(§6 注②:core 的会话删除是**逐 store 显式调用**,不是 DB 自动扫;
341
417
  * 只建表不接 purge = 删会话后绑定残留 ⇒ sessionId 重用致旧设备粘连/claim conflict/隐私残留)。 */
@@ -366,11 +442,13 @@ export declare class InMemoryDeviceStore implements DeviceStore {
366
442
  readDeviceForAdmission(deviceId: string, owner: DeviceOwner): Promise<DeviceAdmissionView | null>;
367
443
  getDeviceUnscoped(deviceId: string): Promise<DeviceRow | null>;
368
444
  listDevicesByOwner(owner: DeviceOwner): Promise<DeviceRow[]>;
445
+ listDevicesUnscoped(): Promise<DeviceRow[]>;
369
446
  revokeDevice(input: RevokeDeviceInput): Promise<RevokeDeviceResult>;
370
447
  acquireConnection(input: AcquireConnectionInput): Promise<AcquireConnectionResult>;
371
448
  heartbeatConnection(input: HeartbeatConnectionInput): Promise<boolean>;
372
449
  releaseConnection(deviceId: string, generation: number): Promise<boolean>;
373
450
  bindSession(input: BindSessionInput): Promise<BindSessionResult>;
451
+ rebindSession(input: RebindSessionInput): Promise<RebindSessionResult>;
374
452
  getSessionBinding(rootSessionId: string): Promise<DeviceSessionRow | null>;
375
453
  deleteByRootSession(rootSessionId: string): Promise<number>;
376
454
  appendAudit(input: AppendAuditInput): Promise<void>;
@@ -10,8 +10,11 @@ export const DEVICE_ERROR_CODES = {
10
10
  BUSY: "device.busy",
11
11
  ENROLLMENT_INVALID: "device.enrollment_invalid",
12
12
  NOT_FOUND: "not_found.device",
13
+ REBIND_REV_CONFLICT: "device.rebind_rev_conflict",
14
+ REBIND_ACTIVE_RUN: "device.rebind_active_run",
13
15
  };
14
16
  export const DEVICE_RATE_LIMIT_CODE = "limit.rate_exceeded";
17
+ export const DEVICE_DRAINING_CODE = "draining";
15
18
  export const DEVICE_ERROR_STATUS = {
16
19
  "device.not_bound": 409,
17
20
  "device.offline": 409,
@@ -23,7 +26,10 @@ export const DEVICE_ERROR_STATUS = {
23
26
  "device.busy": 429,
24
27
  "device.enrollment_invalid": 403,
25
28
  "not_found.device": 404,
29
+ "device.rebind_rev_conflict": 409,
30
+ "device.rebind_active_run": 409,
26
31
  "limit.rate_exceeded": 429,
32
+ "draining": 503,
27
33
  };
28
34
  export const DEVICE_AUDIT_EVENTS = [
29
35
  "device_enrolled",
@@ -34,6 +40,7 @@ export const DEVICE_AUDIT_EVENTS = [
34
40
  "device_identity_mismatch",
35
41
  "device_superseded",
36
42
  "device_attached_elsewhere",
43
+ "session_rebound",
37
44
  ];
38
45
  export const DEVICE_PLATFORM_OS = ["darwin", "linux"];
39
46
  export const DEVICE_STATUSES = ["active", "revoked"];
@@ -112,6 +119,14 @@ export function assertRevokeShape(input) {
112
119
  assertBoundedText("revokedBy", input.revokedBy, DEVICE_TEXT_LIMITS.actor);
113
120
  assertBoundedText("revokeReason", input.reason ?? "", DEVICE_TEXT_LIMITS.revokeReason);
114
121
  }
122
+ export function assertRebindShape(input) {
123
+ assertRootSessionIdShape(input.rootSessionId);
124
+ assertDeviceIdShape(input.toDeviceId);
125
+ assertDeviceOwnerShape(input.owner);
126
+ if (!Number.isInteger(input.expectedRev) || input.expectedRev < 0) {
127
+ throw new Error(`device-store: expectedRev must be a non-negative integer (got ${input.expectedRev}) — it enters a byte-exact BIGINT equality predicate`);
128
+ }
129
+ }
115
130
  export function assertAcquireShape(input) {
116
131
  assertDeviceIdShape(input.deviceId);
117
132
  assertDeviceOwnerShape(input.owner);
@@ -282,6 +297,9 @@ export class InMemoryDeviceStore {
282
297
  assertDeviceOwnerShape(owner);
283
298
  return [...this.devices.values()].filter((d) => sameOwner(d.owner, owner)).sort((a, b) => (a.deviceId < b.deviceId ? -1 : 1)).map((d) => this.snapshotDevice(d));
284
299
  }
300
+ async listDevicesUnscoped() {
301
+ return [...this.devices.values()].sort((a, b) => (a.deviceId < b.deviceId ? -1 : 1)).map((d) => this.snapshotDevice(d));
302
+ }
285
303
  async revokeDevice(input) {
286
304
  assertRevokeShape(input);
287
305
  const row = this.devices.get(input.deviceId);
@@ -363,6 +381,25 @@ export class InMemoryDeviceStore {
363
381
  this.sessions.set(row.rootSessionId, row);
364
382
  return { outcome: "bound", row: this.snapshotSession(row) };
365
383
  }
384
+ async rebindSession(input) {
385
+ assertRebindShape(input);
386
+ const device = this.devices.get(input.toDeviceId);
387
+ if (!device || !sameOwner(device.owner, input.owner) || device.status !== "active")
388
+ return { outcome: "device_not_found" };
389
+ const row = this.sessions.get(input.rootSessionId);
390
+ if (!row || !sameOwner(row.owner, input.owner))
391
+ return { outcome: "binding_not_found" };
392
+ if (row.deviceId === input.toDeviceId && row.rev === input.expectedRev)
393
+ return { outcome: "idempotent", row: this.snapshotSession(row) };
394
+ if (row.rev !== input.expectedRev)
395
+ return { outcome: "rev_conflict", row: this.snapshotSession(row) };
396
+ const from = row.deviceId;
397
+ row.deviceId = input.toDeviceId;
398
+ row.boundAtMs = this.now();
399
+ row.rev += 1;
400
+ this.pushAudit({ event: "session_rebound", deviceId: input.toDeviceId, owner: row.owner, detail: { from, to: input.toDeviceId, rev: row.rev } });
401
+ return { outcome: "rebound", row: this.snapshotSession(row) };
402
+ }
366
403
  async getSessionBinding(rootSessionId) {
367
404
  assertRootSessionIdShape(rootSessionId);
368
405
  const row = this.sessions.get(rootSessionId);
@@ -56,6 +56,14 @@ export type DeviceDispatchInput = {
56
56
  [K in DeviceInstructionKind]: {
57
57
  rootSessionId: string;
58
58
  requester: DeviceOwner;
59
+ /**
60
+ * O4 §3 投递门**身份重断言**(codex 轮2 R2-[high],红先 R4b 实证):调用方 env **铸造时**钉住的
61
+ * 设备。准入在 dispatch 内解出的是**当前**绑定 —— 显式换绑与在飞 turn 竞态时两者可以不同,而
62
+ * adapter 的 connect 身份检查罩不住已连接 env 的后续调用(它们不再过 connect)。差异 ⇒ 恒拒
63
+ * `device.identity_mismatch`(cwd/能力面是从铸造设备读的,静默改道 = 在错误机器的工作区上执行)。
64
+ * **必填**:可选会让漏传静默([ref] `originTaskId` 同判)。
65
+ */
66
+ expectedDeviceId: string;
59
67
  kind: K;
60
68
  args: DeviceInstructionArgsByKind[K];
61
69
  cwd?: string;
@@ -162,8 +170,13 @@ export interface DeviceWsHub {
162
170
  dispatch(input: DeviceDispatchInput): Promise<DeviceDispatchResult>;
163
171
  /** 幂等:重复 cancel 同一指令 = no-op(§5.3)。 */
164
172
  cancel(instructionId: string): Promise<void>;
165
- /** §8-R7:停发新指令 + 拒新 upgrade,但**保持既有 WS**,在途结果帧照收。 */
166
- beginDrain(): void;
173
+ /** §8-R7:停发新指令 + 拒新 upgrade,但**保持既有 WS**,在途结果帧照收。
174
+ * [ref]:装配层可带 `graceMs`(= shutdown 的 drainGraceMs 上界)——排空拒体借它给出「drain 窗最迟
175
+ * 何时收窗」的指路;getter 形每次活读(drainGraceMs 是热改键,一次性快照会在窗中热调后失真,
176
+ * codex-S09-F2);不带 = 拒体不造数(测试宿主/嵌入形没有这个事实)。 */
177
+ beginDrain(opts?: {
178
+ graceMs?: number | (() => number);
179
+ }): void;
167
180
  isDraining(): boolean;
168
181
  connectedDeviceIds(): string[];
169
182
  /**
@@ -1,10 +1,11 @@
1
1
  import http from "node:http";
2
2
  import { createHash } from "node:crypto";
3
3
  import { WebSocketServer } from "ws";
4
- import { DEVICE_ERROR_CODES, DEVICE_ERROR_STATUS, assertDeviceIdShape, } from "./device-store.js";
4
+ import { DEVICE_DRAINING_CODE, DEVICE_ERROR_CODES, DEVICE_ERROR_STATUS, assertDeviceIdShape, } from "./device-store.js";
5
5
  import { DEVICE_MIN_PROTOCOL_VERSION, DEVICE_WS_LIMITS, DEVICE_WS_PATH, DEVICE_PROTOCOL_VERSION, asInstructionKind, buildHelloSignaturePayload, formatGeneration, mintChallengeNonce, mintInstructionId, parseDeviceFrameText, verifyHelloSignature, } from "./device-ws-protocol.js";
6
6
  const OUTCOME_UNKNOWN_SENTENCE = "outcome unknown — the command may have already executed on the device; verify its effect before re-running";
7
7
  const SETTLED_MEMO_MAX = 512;
8
+ const DRAIN_RETRY_AFTER_SEC = 15;
8
9
  function rejection(code, message, retryAfterSec) {
9
10
  return { code, status: DEVICE_ERROR_STATUS[code], message, ...(retryAfterSec === undefined ? {} : { retryAfterSec }) };
10
11
  }
@@ -73,6 +74,8 @@ export function createDeviceWsHub(options) {
73
74
  const settledMemo = new Map();
74
75
  let draining = false;
75
76
  let closed = false;
77
+ let drainGraceMs;
78
+ let drainStartMono;
76
79
  function auditFrame(deviceId, owner, reason, detail) {
77
80
  metrics?.inc("device_frame_unexpected_total", { reason });
78
81
  void store
@@ -775,6 +778,12 @@ export function createDeviceWsHub(options) {
775
778
  }
776
779
  sendServerFrame(conn, { t: "payload", gen: conn.genStr, instructionId, part: "commit", totalLen: body.length, sha256 });
777
780
  }
781
+ function drainingRejectResult() {
782
+ const graceNow = typeof drainGraceMs === "function" ? drainGraceMs() : drainGraceMs;
783
+ const remainMs = graceNow === undefined || drainStartMono === undefined ? undefined : Math.max(0, graceNow - (performance.now() - drainStartMono));
784
+ const drainHint = remainMs === undefined ? "" : `; the drain window ends within ~${Math.ceil(remainMs / 1000)}s`;
785
+ return { kind: "rejected", reject: rejection(DEVICE_DRAINING_CODE, "this replica is draining for shutdown/upgrade and no longer issues new device instructions — the instruction was never dispatched (safe to retry once the device reconnects on a replacement replica)" + drainHint, DRAIN_RETRY_AFTER_SEC) };
786
+ }
778
787
  async function dispatch(input) {
779
788
  if (input.payloadPartBytes !== undefined) {
780
789
  const n = input.payloadPartBytes;
@@ -793,15 +802,21 @@ export function createDeviceWsHub(options) {
793
802
  if (abortedNow())
794
803
  return abortedBeforeCommit();
795
804
  if (closed)
796
- return { kind: "rejected", reject: rejection(DEVICE_ERROR_CODES.BUSY, "this device hub is shutting down", 1) };
805
+ return { kind: "rejected", reject: rejection(DEVICE_DRAINING_CODE, "this device hub is closed for shutdown — the instruction was never dispatched (safe to retry once the device reconnects on a replacement replica)", DRAIN_RETRY_AFTER_SEC) };
797
806
  if (draining)
798
- return { kind: "rejected", reject: rejection(DEVICE_ERROR_CODES.BUSY, "this replica is draining for shutdown/upgrade and no longer issues new device instructions", 1) };
807
+ return drainingRejectResult();
799
808
  const admitted = await enrollment.admitSession({ rootSessionId: input.rootSessionId, requester: input.requester });
800
809
  if (!admitted.ok)
801
810
  return { kind: "rejected", reject: admitted.reject };
802
811
  if (abortedNow())
803
812
  return abortedBeforeCommit();
804
813
  const { deviceId, connGeneration } = admitted.value;
814
+ if (deviceId !== input.expectedDeviceId) {
815
+ return {
816
+ kind: "rejected",
817
+ reject: rejection(DEVICE_ERROR_CODES.IDENTITY_MISMATCH, `this session is now bound to a different device (${deviceId}) than the one this instruction's environment was minted for — refusing to silently re-target; re-initiate the work on the currently bound device (O4 v2 §3)`),
818
+ };
819
+ }
805
820
  const conn = conns.get(deviceId);
806
821
  if (!conn || conn.phase !== "active" || conn.goodbye) {
807
822
  return { kind: "rejected", reject: rejection(DEVICE_ERROR_CODES.OFFLINE, "the bound device has no dispatchable connection on this replica") };
@@ -820,12 +835,18 @@ export function createDeviceWsHub(options) {
820
835
  }
821
836
  const granted = await acquireSlot(deviceId);
822
837
  if (!granted) {
838
+ if (closed || draining)
839
+ return drainingRejectResult();
823
840
  return { kind: "rejected", reject: rejection(DEVICE_ERROR_CODES.BUSY, `the device already has ${maxInflightPerDevice} instructions in flight`, 1) };
824
841
  }
825
842
  if (abortedNow()) {
826
843
  releaseSlot(deviceId);
827
844
  return abortedBeforeCommit();
828
845
  }
846
+ if (closed) {
847
+ releaseSlot(deviceId);
848
+ return drainingRejectResult();
849
+ }
829
850
  {
830
851
  const drift = driftNow();
831
852
  if (drift.length > 0) {
@@ -975,9 +996,14 @@ export function createDeviceWsHub(options) {
975
996
  deliverCancel(p);
976
997
  return Promise.resolve();
977
998
  }
978
- function beginDrain() {
999
+ function beginDrain(opts) {
1000
+ if (!draining) {
1001
+ drainStartMono = performance.now();
1002
+ drainGraceMs = opts?.graceMs;
1003
+ }
979
1004
  draining = true;
980
- logger?.info?.("device_hub_draining", { connections: conns.size, pending: pending.size });
1005
+ const graceNow = typeof drainGraceMs === "function" ? drainGraceMs() : drainGraceMs;
1006
+ logger?.info?.("device_hub_draining", { connections: conns.size, pending: pending.size, ...(graceNow === undefined ? {} : { graceMs: graceNow }) });
981
1007
  }
982
1008
  async function close() {
983
1009
  closed = true;
@@ -108,6 +108,7 @@ async function handleApprovalsAssistantBody(req, res, url, ctx, miss) {
108
108
  sendJson(res, 200, {
109
109
  pending: (await cs.listPending(scope)).map((r) => projectPendingForWire(r, deps.config)),
110
110
  ...(deps.toolApproval ? { livePending: deps.toolApproval.listLivePending(scope) } : {}),
111
+ ...(deps.approvalAskAudit ? { crashConverged: deps.approvalAskAudit.listCrashConverged(scope) } : {}),
111
112
  });
112
113
  return;
113
114
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * O4 设备换绑 wire + S-4 additive 投影(DEBTS [ref];设计真源 = sema-internal
3
+ * `server/designs/2026-08-30-o4-device-rebind-wire-v2.md`,冻结稿)—— `/v1/devices/*` 管理面的
4
+ * **首批三动词**(capabilities.ts 那条「车C 的 `/v1/devices/*`」预留注所指的族,本文件开族):
5
+ *
6
+ * · `GET /v1/devices` —— 设备列表(owner 域过滤;explicit operator 全量)。行 =
7
+ * `{ deviceId, name?, status: "online"|"offline", lastSeenAt? }`(v2 稿 §4 冻结形;`status` 由
8
+ * **hub 在场性**判 —— v1 部署形 = 单副本(基线稿 §4.6),汇聚端的活连接集就是本副本的 presence
9
+ * 真源;`name` = displayName,空串诚实缺席;`lastSeenAt` ISO-8601)。**吊销行不列**:revoked 是
10
+ * 终态(重注册 = 新 deviceId),列出来只会被壳当换绑目标然后撞 404。
11
+ * · `GET /v1/devices/sessions/:rootSessionId` —— S-4 绑定读面(壳换绑 UX 的全部材料):
12
+ * `{ rootSessionId, boundDevice: { deviceId, rev, boundAt } }`。稿 §4 允许「并入 session 读面」或
13
+ * 「独立读面」两形,选**独立读面**:sessions 的 GET 面承诺「无参请求字节不变」(routes/sessions.ts
14
+ * 的 additive contract 注),且 audit 投影由 boot/session-faces 组装、不持 device 店 —— 并入即跨
15
+ * 装配缝加一次店读;独立读面把 device wire 族收在一个文件里,户口与门序自洽。
16
+ * · `POST /v1/devices/sessions/:rootSessionId/rebind` —— 显式换绑动词(v2 稿 §2 门序五步,逐字):
17
+ * ① gatedPrincipal + owner 门(非属主 404 反枚举同串,[ref] 纪律)
18
+ * ② 目标设备在场门(不存在 / 不是该域的 / 已吊销 ⇒ 404 **同串** —— 设备 id 也是不可枚举面)
19
+ * ③ 无活跃 run 门(**CAS 前判、CAS 语句不兼职**;durable 判据 = run store 的 session claim
20
+ * `getActiveTaskId`,hub 内存在途计数不作门 —— 内存态跨副本不可见;拒 = 409
21
+ * `device.rebind_active_run` 体带 `activeTaskId`)
22
+ * ④ 前态 CAS(店内单事务:`rev` 恒变列 + `session_rebound` 审计行;输 = 409
23
+ * `device.rebind_rev_conflict` 体带 `currentRev` —— [ref] currentPending 哲学,壳一跳重定位)
24
+ * ⑤ 赢 = 200 `{ rootSessionId, deviceId, rev, workspaceCarryover: "none" }`。
25
+ * 幂等形:toDeviceId ≡ 当前 ∧ expectedRev ≡ 当前 rev ⇒ 200 回显不加 rev(真幂等,不写审计)。
26
+ * `workspaceCarryover: "none"` 是 **wire 冻结字面**(v2 稿 §5):换绑只改路由,不搬字节 —— 壳据
27
+ * 此渲「新设备上是全新工作区」的诚实披露;两个 200 形恒带。
28
+ *
29
+ * ── 调用方域(owner domain)的唯一铸造点 ─────────────────────────────────────────────────────────
30
+ * device lane 的三表自持 O11 复合身份 `{owner_tenant, owner_subject}`(device-store.ts 头注;全局
31
+ * principal 一个字节不动)。基线稿 §11-O11 的落法:「认证需在 SSO 校验点把 tenant+subject 传到 device
32
+ * placement 校验腿」——对位到本仓的真源即:subject = `gatedPrincipal`(verified 链),tenant =
33
+ * `ssoVerifiedScope`(registry-JWT 的 `scope` claim,security.ts:同为签名真源、非自报头)。两者齐备
34
+ * 才构成一个 device 域;缺 tenant claim 的凭据(纯 principal 头 / 静态 token 直连)**判不出域** ⇒
35
+ * 非 operator 一律响亮 403(fail-closed + loud,[ref]:静默空列表是假健康)——基线稿对 enrollment 的
36
+ * 同向裁定是「缺 tenant/global scope 明确拒注册」。将来的 enrollment/管理端点(车C 余件)**必须**从
37
+ * 这同一个函数取域,不得手抄第二份派生。
38
+ *
39
+ * ── 反枚举 404 的单一铸造点 ──────────────────────────────────────────────────────────────────────
40
+ * 「绑定不在 / 不是你的 / 目标设备不在 / 不是该域的 / 已吊销」五臂共用 {@link sendDeviceNotFound} ——
41
+ * 判别位在一个函数里就消失,memory `anti-enum-404-ambiguity` 的教训(判别位留在上层 = 迟早有一个
42
+ * 调用点忘记归一)在结构上关掉。码/文案与 device-enrollment.ts 的 `rejectNotFound` 同串
43
+ * (`not_found.device` + "device not found")。
44
+ *
45
+ * ── 门与失败方向 ────────────────────────────────────────────────────────────────────────────────
46
+ * 活跃 run 门的 store 读**不吞错**(server.ts 有两处 `getActiveTaskId(...).catch(() => undefined)`,
47
+ * 那是观测面;这里是门 —— store 打不通就 500,绝不当「没有活跃 run」放行,[ref])。run store 缺席 =
48
+ * 501 `capability.run_store_required`(门无判据即拒,不是跳过)。
49
+ *
50
+ * billable = false 全族(纯管理/读面,零模型工作);刻意不吃 drain 503(读面与管理动作在排空期
51
+ * 照常有效 —— 换绑本身正是「设备要换了」的运维动作)。错误码登记:附录 A + error-code-catalog-live
52
+ * (send 点全部写**字面量** + `satisfies DeviceErrorCode` 钉闭集 —— 目录门扫的是字面量,闭集纪律由
53
+ * 编译器执行,两道门同时成立;device-store.ts「禁手抄」条的本义是禁**脱离闭集**的字面量)。
54
+ */
55
+ import type { IncomingMessage, ServerResponse } from "node:http";
56
+ import type { RouteCtx } from "../route-ctx.js";
57
+ import { type DeviceOwner } from "../../device-store.js";
58
+ /**
59
+ * 调用方 device 域的**唯一铸造点**(文件头注)。undefined = 判不出域(无 principal,或无 tenant
60
+ * claim)——消费点必须走 explicit operator 或响亮 403,禁静默空视图。
61
+ */
62
+ export declare function deviceOwnerDomainOf(req: IncomingMessage, config: RouteCtx["deps"]["config"]): DeviceOwner | undefined;
63
+ export declare function handleDevices(req: IncomingMessage, res: ServerResponse, url: string, ctx: RouteCtx): Promise<boolean>;
64
+ //# sourceMappingURL=devices.d.ts.map