@lqc123qwe/car-runtime 1.0.0-rc.1

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 (54) hide show
  1. package/.github/workflows/ci.yml +12 -0
  2. package/.github/workflows/release.yml +21 -0
  3. package/README.md +122 -0
  4. package/adr/ADR-001.md +4 -0
  5. package/adr/ADR-002.md +5 -0
  6. package/adr/ADR-003.md +35 -0
  7. package/adr/host-onboarding.md +68 -0
  8. package/adr/mlps-audit-checklist.md +33 -0
  9. package/adr/win32-spike.md +45 -0
  10. package/package.json +25 -0
  11. package/scripts/ptc-baseline/run-baseline.ts +118 -0
  12. package/scripts/release-pipeline.ts +189 -0
  13. package/scripts/win32-koffi-spike.ts +34 -0
  14. package/src/authz/authz.ts +96 -0
  15. package/src/cli.ts +122 -0
  16. package/src/governance/dump.ts +41 -0
  17. package/src/host/facade.ts +99 -0
  18. package/src/host/hostGateway.ts +98 -0
  19. package/src/host/mappings.ts +74 -0
  20. package/src/host/stdio.ts +74 -0
  21. package/src/kernel/context.ts +208 -0
  22. package/src/kernel/events.ts +99 -0
  23. package/src/load/loader.ts +165 -0
  24. package/src/load/registry.ts +88 -0
  25. package/src/load/verifier.ts +67 -0
  26. package/src/loop/goal.ts +53 -0
  27. package/src/loop/recover.ts +47 -0
  28. package/src/loop/stop.ts +200 -0
  29. package/src/mcp/gateway.ts +118 -0
  30. package/src/ptc/budget.ts +37 -0
  31. package/src/ptc/erasable.ts +28 -0
  32. package/src/ptc/runCode.ts +127 -0
  33. package/src/ptc/sdk.ts +28 -0
  34. package/src/ptc/worker-entry.ts +67 -0
  35. package/src/sandbox/sandbox.ts +199 -0
  36. package/src/security/secrets.ts +111 -0
  37. package/src/session/export.ts +110 -0
  38. package/src/session/log.ts +120 -0
  39. package/src/telemetry/metrics.ts +38 -0
  40. package/test/kernel.spec.ts +136 -0
  41. package/test/s11.spec.ts +129 -0
  42. package/test/s12.spec.ts +135 -0
  43. package/test/s13.spec.ts +117 -0
  44. package/test/s14.spec.ts +117 -0
  45. package/test/s15.spec.ts +80 -0
  46. package/test/s17.spec.ts +102 -0
  47. package/test/s18.spec.ts +72 -0
  48. package/test/s2.spec.ts +245 -0
  49. package/test/s3.spec.ts +179 -0
  50. package/test/s4.spec.ts +169 -0
  51. package/test/s5.spec.ts +95 -0
  52. package/test/s6.spec.ts +169 -0
  53. package/test/s7.spec.ts +135 -0
  54. package/test/s9.spec.ts +89 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * T-1 · HostGateway(M3-S11 骨架):CAR-as-MCP-Server 接入面
3
+ *
4
+ * 口径(M3系统设计增补 T-1 / 决议⑤ MCP 统一边界双宿主等价):
5
+ * - 9 tool 粗粒度会话级能力 + 工具面透传:session_start/turn/stop/status/replay/verify/export
6
+ * + tool_list/tool_call——step 循环不暴露(「统一边界而非统一内部」);
7
+ * - hostId 登记制(A050001 语义扩展):未登记宿主拒绝连接;
8
+ * - ServerTransport 对偶(gateway.ts):宿主作为 MCP client 经 tools/call 调用;
9
+ * - tool handlers 由注入的 RuntimeFacade 提供(S12 接线 Claude Code、S13 Codex + 契约测试);
10
+ * - 所有宿主调用 100% 落审计(audit 钩子,复用 SessionLog)。
11
+ */
12
+ import type { JsonRpcResponse, ServerTransport } from '../mcp/gateway.ts'
13
+ import { deriveSessionId, normalizeHostEvent, type HostProfile } from './mappings.ts'
14
+
15
+ export const HOST_TOOLS = [
16
+ 'session_start', 'session_turn', 'session_stop', 'session_status',
17
+ 'session_replay', 'session_verify', 'session_export', 'tool_list', 'tool_call',
18
+ ] as const
19
+ export type HostTool = (typeof HOST_TOOLS)[number]
20
+
21
+ export interface RuntimeFacade {
22
+ sessionStart(args: { hostSessionId: string }): Promise<{ sessionId: string }>
23
+ sessionTurn(args: { sessionId: string; input: unknown }): Promise<{ reason: string; steps: number }>
24
+ sessionStop(args: { sessionId: string }): Promise<{ reason: string }>
25
+ sessionStatus(args: { sessionId: string }): Promise<{ state: string; lastReason?: string }>
26
+ sessionReplay(args: { sessionId: string }): Promise<{ messages: unknown[] }>
27
+ sessionVerify(args: { sessionId: string }): Promise<{ ok: boolean; brokenAt: number | null }>
28
+ sessionExport(args: { sessionId: string }): Promise<{ bundle: unknown }>
29
+ toolList(args: Record<string, never>): Promise<{ tools: string[] }>
30
+ toolCall(args: { sessionId: string; tool: string; arguments?: Record<string, unknown> }): Promise<{ ok: boolean; result?: unknown; error?: string }>
31
+ }
32
+
33
+ export interface HostRegistration { hostId: string; profile: HostProfile; transport: ServerTransport }
34
+
35
+ export class HostGateway {
36
+ #hosts = new Map<string, HostRegistration>()
37
+ #audit: (event: { kind: string; detail: Record<string, unknown>; ts: number }) => void
38
+
39
+ constructor(opts: { facade: RuntimeFacade; audit: (event: { kind: string; detail: Record<string, unknown>; ts: number }) => void }) {
40
+ this.#facade = opts.facade
41
+ this.#audit = opts.audit
42
+ }
43
+ #facade: RuntimeFacade
44
+
45
+ /** 宿主登记(A050001 语义扩展:重复登记显式报错) */
46
+ registerHost(reg: HostRegistration): void {
47
+ if (this.#hosts.has(reg.hostId)) throw new Error(`CAR-E-HOST: hostId "${reg.hostId}" already registered(登记制)`)
48
+ this.#hosts.set(reg.hostId, reg)
49
+ }
50
+
51
+ listHosts(): string[] { return [...this.#hosts.keys()] }
52
+
53
+ /** 暴露给宿主的 tool 面(契约测试数据源:两宿主调用此方法结果必须等价) */
54
+ listTools(): HostTool[] { return [...HOST_TOOLS] }
55
+
56
+ /** 宿主到达调用分发:登记检查 → tool 分发 → 归一化 → 审计(无旁路) */
57
+ async handle(hostId: string, tool: string, args: Record<string, unknown>): Promise<{ ok: boolean; result?: unknown; error?: string }> {
58
+ const host = this.#hosts.get(hostId)
59
+ if (!host) {
60
+ this.#audit({ kind: 'host-rejected', detail: { hostId, tool, reason: 'A050001' }, ts: Date.now() })
61
+ return { ok: false, error: 'CAR-A050001: unregistered host connection rejected(登记制)' }
62
+ }
63
+ if (!(HOST_TOOLS as readonly string[]).includes(tool)) {
64
+ return { ok: false, error: `unknown host tool "${tool}"(9 tool 面,step 循环不暴露)` }
65
+ }
66
+ // tool 名(snake_case MCP 约定)→ facade 方法(camelCase TS 约定)
67
+ const camel = tool.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase())
68
+ const fn = (this.#facade as unknown as Record<string, (a: Record<string, unknown>) => Promise<unknown>>)[camel]
69
+ if (typeof fn !== 'function') return { ok: false, error: `host tool "${tool}" not wired in RuntimeFacade(S12 接线)` }
70
+ try {
71
+ const result = await fn.call(this.#facade, { ...args, __hostId: hostId })
72
+ this.#audit({ kind: 'host-call', detail: { hostId, tool, normalized: tool !== 'session_start' ? undefined : deriveSessionId(hostId, String((args as { hostSessionId: string }).hostSessionId)) }, ts: Date.now() })
73
+ return { ok: true, result }
74
+ } catch (e) {
75
+ this.#audit({ kind: 'host-call-error', detail: { hostId, tool, error: String(e) }, ts: Date.now() })
76
+ return { ok: false, error: String(e) }
77
+ }
78
+ }
79
+
80
+ /** ServerTransport 装配:把 JSON-RPC tools/call 桥到 handle(S12 接 Claude Code stdio) */
81
+ bindTransport(hostId: string): void {
82
+ const host = this.#hosts.get(hostId)
83
+ if (!host) throw new Error('CAR-A050001: cannot bind unregistered host')
84
+ void (host.transport as ServerTransport & { __bind?: unknown })
85
+ }
86
+ }
87
+
88
+ /** 归一化便捷入口:宿主事件 → CAR 事件(mappings.ts 的 gateway 侧封装,供 S12 数据流接线) */
89
+ export function ingestHostEvent(hostId: string, hostEvent: string, payload: Record<string, unknown>): ReturnType<typeof normalizeHostEvent> {
90
+ const profile = HOST_MAPPINGS_BY_ID.get(hostId)
91
+ if (!profile) throw new Error(`CAR-A050001: host "${hostId}" not registered(归一化前必须登记)`)
92
+ return normalizeHostEvent(profile, hostEvent, payload)
93
+ }
94
+
95
+ import { HOST_MAPPINGS } from './mappings.ts'
96
+ const HOST_MAPPINGS_BY_ID = new Map(HOST_MAPPINGS.map(h => [h.hostId, h]))
97
+
98
+ void (0 as unknown as JsonRpcResponse) // 类型锚:ServerTransport 响应契约
@@ -0,0 +1,74 @@
1
+ /**
2
+ * T-1 · 多宿主归一化 schema(host-mappings-v1,M3-S11)
3
+ *
4
+ * 口径(M3系统设计增补 T-1 / 决议⑤):
5
+ * - session id 归一化:确定性派生 SH- + sha256(hostId|hostSessionId)——幂等免映射表存储;
6
+ * 首次映射落哈希链登记事件(调用方职责);显式拒绝解析宿主 id 语义(对闭源漂移免疫);
7
+ * - 事件归一化:版本化映射表数据驱动(HOST_MAPPINGS),pattern 命中 → CAR EventKind + actor;
8
+ * **未命中一律降级 hostRaw 留痕(零静默,机器可断言)**;
9
+ * - 宿主标识字符串只允许出现在本数据文件(静态架构断言红线,测试强制)。
10
+ */
11
+ import { createHash } from 'node:crypto'
12
+ import type { EventKind } from '../session/log.ts'
13
+
14
+ /** 跨宿主会话 id:SH- 前缀 + 24 hex(sha256 前 12 字节) */
15
+ export function deriveSessionId(hostId: string, hostSessionId: string): string {
16
+ return 'SH-' + createHash('sha256').update(`${hostId}|${hostSessionId}`).digest('hex').slice(0, 24)
17
+ }
18
+
19
+ export interface HostEventMapping {
20
+ /** 宿主事件名(精确匹配 v1;glob/regex v2 评估) */
21
+ hostEvent: string
22
+ kind: EventKind
23
+ actor: 'user' | 'model' | 'plugin' | 'runtime'
24
+ /** 宿主 payload 字段 → CAR payload 字段(浅拷贝重命名) */
25
+ payloadMap?: Record<string, string>
26
+ }
27
+
28
+ export interface HostProfile {
29
+ hostId: string
30
+ /** 映射表版本(契约变更须升版本 + 双轨过渡一个迭代) */
31
+ schemaVersion: 'host-mappings-v1'
32
+ mappings: HostEventMapping[]
33
+ }
34
+
35
+ /** host-mappings-v1:Claude Code / Codex 等价映射(同一接入面,两份纯数据 profile) */
36
+ export const HOST_MAPPINGS: HostProfile[] = [
37
+ {
38
+ hostId: 'claude-code',
39
+ schemaVersion: 'host-mappings-v1',
40
+ mappings: [
41
+ { hostEvent: 'user_message', kind: 'user', actor: 'user', payloadMap: { content: 'text' } },
42
+ { hostEvent: 'assistant_message', kind: 'assistant', actor: 'model', payloadMap: { content: 'text' } },
43
+ { hostEvent: 'tool_use', kind: 'toolCall', actor: 'model', payloadMap: { tool_name: 'tool', tool_call_id: 'id' } },
44
+ { hostEvent: 'tool_result', kind: 'toolResult', actor: 'model', payloadMap: { tool_call_id: 'id' } },
45
+ { hostEvent: 'turn_complete', kind: 'turnEnd', actor: 'runtime' },
46
+ ],
47
+ },
48
+ {
49
+ hostId: 'codex',
50
+ schemaVersion: 'host-mappings-v1',
51
+ mappings: [
52
+ { hostEvent: 'input_item', kind: 'user', actor: 'user', payloadMap: { text: 'text' } },
53
+ { hostEvent: 'agent_message', kind: 'assistant', actor: 'model', payloadMap: { message: 'text' } },
54
+ { hostEvent: 'function_call', kind: 'toolCall', actor: 'model', payloadMap: { name: 'tool', call_id: 'id' } },
55
+ { hostEvent: 'function_call_output', kind: 'toolResult', actor: 'model', payloadMap: { call_id: 'id' } },
56
+ { hostEvent: 'task_complete', kind: 'turnEnd', actor: 'runtime' },
57
+ ],
58
+ },
59
+ ]
60
+
61
+ export interface NormalizedEvent { kind: EventKind; actor: string; payload: Record<string, unknown>; degraded: boolean }
62
+
63
+ /** 事件归一化:命中映射 → 转写;未命中 → hostRaw 降级(payload 原样保留,零静默) */
64
+ export function normalizeHostEvent(profile: HostProfile, hostEvent: string, payload: Record<string, unknown> = {}): NormalizedEvent {
65
+ const m = profile.mappings.find(x => x.hostEvent === hostEvent)
66
+ if (!m) {
67
+ return { kind: 'hostRaw', actor: 'runtime', payload: { hostId: profile.hostId, hostEvent, raw: payload }, degraded: true }
68
+ }
69
+ const out: Record<string, unknown> = {}
70
+ for (const [k, v] of Object.entries(payload)) {
71
+ out[m.payloadMap?.[k] ?? k] = v
72
+ }
73
+ return { kind: m.kind, actor: m.actor, payload: out, degraded: false }
74
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * T-1 · stdio ServerTransport 实装(M3-S12):宿主 → CAR 的 JSON-RPC over stdin/stdout
3
+ *
4
+ * 口径(M3系统设计增补 T-1 / M3部署设计增补 §4 多宿主拓扑:stdio 为 M3 主形态):
5
+ * - 换行分隔 JSON-RPC 2.0(与 M1 client 侧 gateway 同帧格式);
6
+ * - 方法面:initialize/notifications 握手(MCP 协议兼容)+ tools/list(9 tool 能力发现)+ tools/call(分发到 HostGateway.handle);
7
+ * - 所有到达请求与响应均经 HostGateway 审计(无旁路);未登记宿主在 handle 层拒绝;
8
+ * - stdin 结束(宿主退出)→ 通道关闭,进程内状态由审计日志承载(BD-02 等价:不静默丢数据)。
9
+ */
10
+ import { createInterface } from 'node:readline'
11
+ import { HOST_TOOLS, type HostTool } from './hostGateway.ts'
12
+
13
+ export interface StdioDispatcher {
14
+ (tool: string, args: Record<string, unknown>): Promise<{ ok: boolean; result?: unknown; error?: string }>
15
+ }
16
+
17
+ export interface StdioServer {
18
+ /** 消费 stdin 行直到结束;返回处理请求数(供测试断言) */
19
+ serve(input: NodeJS.ReadableStream, output: NodeJS.WritableStream): Promise<number>
20
+ }
21
+
22
+ export function createStdioServer(dispatch: StdioDispatcher): StdioServer {
23
+ let nextId = 0
24
+ return {
25
+ async serve(input, output) {
26
+ let count = 0
27
+ const rl = createInterface({ input })
28
+ const done = new Promise<void>(resolve => rl.on('close', resolve))
29
+ rl.on('line', line => {
30
+ const trimmed = line.trim()
31
+ if (!trimmed) return
32
+ count++
33
+ let req: { id?: number; method?: string; params?: { name?: string; arguments?: Record<string, unknown> } }
34
+ try { req = JSON.parse(trimmed) } catch {
35
+ output.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }) + '\n')
36
+ return
37
+ }
38
+ nextId++
39
+ const respond = (result: unknown, error?: { code: number; message: string }) => {
40
+ output.write(JSON.stringify({ jsonrpc: '2.0', id: req.id ?? nextId, ...(error ? { error } : { result }) }) + '\n')
41
+ }
42
+ // MCP 协议握手(E-6 实连前置):真实宿主客户端先发 initialize → 回 serverInfo;
43
+ // notifications/initialized 为无 id 通知,不响应(协议规定)
44
+ if (req.method === 'initialize') {
45
+ respond({
46
+ protocolVersion: (req.params as { protocolVersion?: string } | undefined)?.protocolVersion ?? '2024-11-05',
47
+ capabilities: { tools: { listChanged: false } },
48
+ serverInfo: { name: 'car-runtime', version: '0.3.0' },
49
+ })
50
+ return
51
+ }
52
+ if (req.method === 'notifications/initialized' || (req.method ?? '').startsWith('notifications/')) {
53
+ return // 通知无响应
54
+ }
55
+ if (req.method === 'tools/list') {
56
+ respond({ tools: HOST_TOOLS.map((t: HostTool) => ({ name: t })) })
57
+ return
58
+ }
59
+ if (req.method === 'tools/call') {
60
+ const name = req.params?.name ?? ''
61
+ const args = req.params?.arguments ?? {}
62
+ void dispatch(name, args).then(r => {
63
+ if (r.ok) respond({ content: [{ type: 'text', text: JSON.stringify(r.result ?? {}) }], carOk: true })
64
+ else respond(undefined, { code: -32000, message: r.error ?? 'host call failed' })
65
+ }).catch(e => respond(undefined, { code: -32000, message: String(e) }))
66
+ return
67
+ }
68
+ respond(undefined, { code: -32601, message: `unknown method "${req.method}"(支持 initialize / tools/list / tools/call)` })
69
+ })
70
+ await done
71
+ return count
72
+ },
73
+ }
74
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * CAR 微内核骨架(F1 可逆注册+依赖注入 / F9 加载-运行两阶段 / ADR-002 双模式卸载)
3
+ *
4
+ * 设计口径(《系统设计》v1.1):
5
+ * - 自研实现,范式借鉴 dsh/Cordis(inject 依赖推导、ctx.effect 可逆注册),代码不复用
6
+ * - 卸载双模式(ADR-002):strict-topo = 消费者先于提供者逐层排空(依赖计数屏障);
7
+ * concurrent = 跨 fiber Promise.all 并发。fiber 内一律注册逆序串行(确定性回卷)
8
+ * - 加载期错误必须显式(冲突链定位),禁止静默覆盖(US-7/N2)
9
+ */
10
+ export type Disposer = () => void | Promise<void>
11
+ export interface PluginDef {
12
+ name: string
13
+ /** 声明所需服务依赖;加载顺序由依赖推导(缺依赖 = PENDING 等待,不手工编排) */
14
+ inject?: string[]
15
+ apply(ctx: PluginContext): void | Promise<void>
16
+ }
17
+ export interface DisposeReport {
18
+ unloaded: string[]
19
+ /** 实际卸载顺序(strict-topo = 消费者→提供者;供治理视图审计,M2) */
20
+ order: string[]
21
+ errors: { plugin: string; label: string; error: unknown }[]
22
+ }
23
+ interface Fiber {
24
+ name: string
25
+ inject: string[]
26
+ disposables: { label: string; disposer: Disposer }[]
27
+ state: 'PENDING' | 'ACTIVE' | 'DISPOSING' | 'DISPOSED'
28
+ }
29
+
30
+ const DEP_CYCLE = 'CAR-E-DEPCYCLE'
31
+
32
+ export class Context {
33
+ private fibers = new Map<string, Fiber>()
34
+ private services = new Map<string, { impl: unknown; provider: string }>()
35
+ private pending: string[] = []
36
+
37
+ /** 提供服务(provider 侧);重复 provide 同名服务 = 加载期显式报错(禁静默覆盖) */
38
+ provide(name: string, impl: unknown, provider = '<root>'): void {
39
+ if (this.services.has(name)) {
40
+ throw new Error(`${DEP_CYCLE}: service "${name}" already provided by "${this.services.get(name)!.provider}" (conflict chain: ${provider})`)
41
+ }
42
+ this.services.set(name, { impl, provider })
43
+ this.tryMountPending()
44
+ }
45
+
46
+ /** 读取服务(consumer 侧,仅限已 inject 的插件 apply 内) */
47
+ get(name: string): unknown {
48
+ const s = this.services.get(name)
49
+ if (!s) throw new Error(`${DEP_CYCLE}: service "${name}" not provided (inject 声明缺失或 provider 未就绪)`)
50
+ return s.impl
51
+ }
52
+
53
+ /** 注册插件:依赖就绪即挂载,否则进入 PENDING 队列 */
54
+ plugin(def: PluginDef): void {
55
+ if (this.fibers.has(def.name)) {
56
+ throw new Error(`${DEP_CYCLE}: plugin "${def.name}" already registered (duplicate registration is blocked)`)
57
+ }
58
+ const fiber: Fiber = { name: def.name, inject: def.inject ?? [], disposables: [], state: 'PENDING' }
59
+ this.fibers.set(def.name, fiber)
60
+ this.defs.set(def.name, def)
61
+ if (fiber.inject.every(d => this.services.has(d))) this.mount(def, fiber)
62
+ else this.pending.push(def.name)
63
+ }
64
+
65
+ private mount(def: PluginDef, fiber: Fiber): void {
66
+ fiber.state = 'ACTIVE'
67
+ const pluginCtx: PluginContext = {
68
+ effect: (body: () => Disposer | Disposer[], label = 'anonymous') => {
69
+ const disposers = typeof body === 'function' ? [body()] : [...body()]
70
+ for (const d of disposers) fiber.disposables.push({ label, disposer: d as Disposer })
71
+ },
72
+ provide: (name: string, impl: unknown) => this.provide(name, impl, def.name),
73
+ get: (name: string) => this.get(name),
74
+ pluginName: def.name,
75
+ }
76
+ const ret = def.apply(pluginCtx)
77
+ if (ret && typeof (ret as any).then === 'function') throw new Error(`${DEP_CYCLE}: async apply 不被 S1 骨架支持(启动期必须同步完成注册,异步初始化请走 effect)`)
78
+ // 新提供的服务可能解锁 PENDING 插件
79
+ this.tryMountPending()
80
+ }
81
+
82
+ private tryMountPending(): void {
83
+ let progressed = true
84
+ while (progressed) {
85
+ progressed = false
86
+ for (let i = this.pending.length - 1; i >= 0; i--) {
87
+ const name = this.pending[i]
88
+ const fiber = this.fibers.get(name)!
89
+ const def = this.defs.get(name)!
90
+ if (fiber.inject.every(d => this.services.has(d))) {
91
+ this.pending.splice(i, 1)
92
+ this.mount(def, fiber)
93
+ progressed = true
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ private defs = new Map<string, PluginDef>()
100
+
101
+ /** 卸载单个插件(回卷其全部副作用;fiber 内注册逆序) */
102
+ async unload(name: string): Promise<void> {
103
+ const fiber = this.fibers.get(name)
104
+ if (!fiber || fiber.state === 'DISPOSED') return
105
+ fiber.state = 'DISPOSING'
106
+ for (const { label, disposer } of [...fiber.disposables].reverse()) {
107
+ await disposer()
108
+ }
109
+ fiber.disposables = []
110
+ fiber.state = 'DISPOSED'
111
+ }
112
+
113
+ /** 运行时整体卸载(ADR-002 双模式) */
114
+ async disposeRuntime(opts: { order?: 'strict-topo' | 'concurrent' } = {}): Promise<DisposeReport> {
115
+ const order = this.topoOrder()
116
+ const report: DisposeReport = { unloaded: [], order: [], errors: [] }
117
+ if ((opts.order ?? 'strict-topo') === 'strict-topo') {
118
+ // 逐层排空:Kahn 分层为 provider→consumer 方向,卸载需反转(消费者层先于提供者层)
119
+ for (const layer of [...order.layers].reverse()) {
120
+ await Promise.all(layer.map(async name => {
121
+ const fiber = this.fibers.get(name)!
122
+ if (fiber.state !== 'ACTIVE') return
123
+ fiber.state = 'DISPOSING'
124
+ for (const { label, disposer } of [...fiber.disposables].reverse()) {
125
+ try { await disposer() } catch (error) { report.errors.push({ plugin: name, label, error }) }
126
+ }
127
+ fiber.disposables = []
128
+ fiber.state = 'DISPOSED'
129
+ report.unloaded.push(name)
130
+ report.order.push(name)
131
+ }))
132
+ }
133
+ } else {
134
+ // concurrent:全部 fiber 并发(对应 dsh Fiber._unload 行为——作为降级/对照模式保留)
135
+ await Promise.all(order.all.map(async name => {
136
+ const fiber = this.fibers.get(name)!
137
+ if (fiber.state !== 'ACTIVE') return
138
+ fiber.state = 'DISPOSING'
139
+ await Promise.all([...fiber.disposables].reverse().map(async ({ label, disposer }) => {
140
+ try { await disposer() } catch (error) { report.errors.push({ plugin: name, label, error }) }
141
+ }))
142
+ fiber.disposables = []
143
+ fiber.state = 'DISPOSED'
144
+ report.unloaded.push(name)
145
+ report.order.push(name)
146
+ }))
147
+ }
148
+ return report
149
+ }
150
+
151
+ /** 治理视图(F14):装配配置树快照(插件/依赖/服务/副作用计数/状态)——只读,不改运行时 */
152
+ governanceSnapshot() {
153
+ return {
154
+ plugins: [...this.fibers.values()].map(f => ({
155
+ name: f.name,
156
+ inject: [...f.inject],
157
+ state: f.state,
158
+ effects: f.disposables.length,
159
+ effectLabels: f.disposables.map(d => d.label),
160
+ provides: [...this.services.entries()].filter(([, s]) => s.provider === f.name).map(([n]) => n),
161
+ })),
162
+ services: [...this.services.entries()].map(([name, s]) => ({ name, provider: s.provider })),
163
+ pending: [...this.pending],
164
+ }
165
+ }
166
+
167
+ /** 依赖图拓扑分层(Kahn 分层;环 = 加载期显式报错) */
168
+ private topoOrder(): { layers: string[][]; all: string[] } { const active = [...this.fibers.values()].filter(f => f.state === 'ACTIVE')
169
+ // provider 关系:插件 P provide 了服务 s,Q inject s => Q 依赖 P(Q 先卸载)
170
+ const depsOf = new Map<string, Set<string>>() // name -> 依赖的 provider 集合
171
+ for (const f of active) depsOf.set(f.name, new Set())
172
+ for (const [name, s] of this.services) {
173
+ if (depsOf.has(s.provider) && depsOf.has(name)) {
174
+ // name 是 inject s 的插件(近似:以插件名=服务名匹配 inject 声明)
175
+ }
176
+ }
177
+ for (const f of active) {
178
+ for (const d of f.inject) {
179
+ const s = this.services.get(d)
180
+ if (s && s.provider !== '<root>' && depsOf.has(s.provider) && s.provider !== f.name) {
181
+ depsOf.get(f.name)!.add(s.provider)
182
+ }
183
+ }
184
+ }
185
+ // Kahn 分层
186
+ const remaining = new Map(depsOf)
187
+ const layers: string[][] = []
188
+ while (remaining.size) {
189
+ const layer = [...remaining.entries()].filter(([, deps]) => deps.size === 0).map(([n]) => n)
190
+ if (!layer.length) throw new Error(`${DEP_CYCLE}: dependency cycle among [${[...remaining.keys()].join(', ')}]`)
191
+ layers.push(layer)
192
+ for (const n of layer) remaining.delete(n)
193
+ for (const deps of remaining.values()) {
194
+ for (const n of layer) deps.delete(n)
195
+ }
196
+ }
197
+ return { layers, all: layers.flat() }
198
+ }
199
+ }
200
+
201
+ /** 插件作用域上下文(effect 注册绑定到插件 fiber —— POC-2 实证的约束:不得透传根 ctx) */
202
+ export interface PluginContext {
203
+ pluginName: string
204
+ /** 可逆注册:body 返回 disposer 或 disposer 数组;卸载时按注册逆序回卷 */
205
+ effect(body: () => Disposer | Disposer[], label?: string): void
206
+ provide(name: string, impl: unknown): void
207
+ get(name: string): unknown
208
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * F7 · 事件分发契约(@mode 声明 + 启动期机器校验)
3
+ *
4
+ * 设计口径(《系统设计》F7:五种分发模式可裁剪起步):
5
+ * - S2 实装全部五模式:emit(广播)/ serial(有序检查点)/ bail(首断即止)/
6
+ * waterfall(环绕中间件 next())/ parallel(扇出)
7
+ * - 分发模式是事件的公开契约:未声明的事件禁止注册与分发(启动期静态报错 A010003,US-7 AC2)
8
+ * - 短路易错点(D3 §4.1 警示):waterfall 观察型监听器忘调 next() 会静默截断——
9
+ * 文档红线 + validateWaterfallUsage 辅助检测
10
+ */
11
+ export type DispatchMode = 'emit' | 'serial' | 'bail' | 'waterfall' | 'parallel'
12
+
13
+ /** 事件契约目录(对应 dsh typert 生成目录的 CAR 自研等价物) */
14
+ const catalog = new Map<string, DispatchMode>()
15
+
16
+ export function declareEvent(name: string, mode: DispatchMode): void {
17
+ if (catalog.has(name) && catalog.get(name) !== mode) {
18
+ throw new Error(`CAR-E-CONTRACT: event "${name}" re-declared with mode "${mode}" (was "${catalog.get(name)}")`)
19
+ }
20
+ catalog.set(name, mode)
21
+ }
22
+
23
+ export function declaredMode(name: string): DispatchMode | undefined {
24
+ return catalog.get(name)
25
+ }
26
+
27
+ /** 启动期机器校验:目录自洽性(声明与分发点一致性由 EventBus.dispatch 强制) */
28
+ export function verifyContracts(): { ok: true; declared: number } {
29
+ return { ok: true, declared: catalog.size }
30
+ }
31
+
32
+ type Handler<P, R> = (payload: P, next: () => R) => R
33
+
34
+ export interface HandlerOpts { label?: string; /** 优先级:升序小先(内核约定负值);同优先级保持注册序(稳定排序,ES2019) */ priority?: number }
35
+
36
+ export class EventBus {
37
+ #handlers = new Map<string, Array<{ fn: Handler<any, any>; label: string; priority: number; seq: number }>>()
38
+ #seq = 0
39
+
40
+ /** 注册处理器:事件未声明 = 启动期静态报错;label 与 HandlerOpts 双形态向后兼容(M1 兼容) */
41
+ on<P = unknown, R = unknown>(event: string, fn: Handler<P, R>, labelOrOpts: string | HandlerOpts = 'anonymous'): void {
42
+ if (!catalog.has(event)) {
43
+ throw new Error(`CAR-E-CONTRACT: cannot subscribe to undeclared event "${event}"(@mode 契约缺失,先 declareEvent)`)
44
+ }
45
+ const opts = typeof labelOrOpts === 'string' ? { label: labelOrOpts } : labelOrOpts
46
+ const list = this.#handlers.get(event) ?? []
47
+ list.push({ fn: fn as Handler<any, any>, label: opts.label ?? 'anonymous', priority: opts.priority ?? 0, seq: this.#seq++ })
48
+ // F11:优先级升序稳定排序(同 priority 保持注册序)
49
+ list.sort((a, b) => a.priority - b.priority || a.seq - b.seq)
50
+ this.#handlers.set(event, list)
51
+ }
52
+
53
+ /** 治理视图(M2 F14):返回某事件的处理器链(label+priority,按实际执行序) */
54
+ handlerChain(event: string): Array<{ label: string; priority: number }> {
55
+ return (this.#handlers.get(event) ?? []).map(h => ({ label: h.label, priority: h.priority }))
56
+ }
57
+
58
+ handlerCount(event: string): number {
59
+ return this.#handlers.get(event)?.length ?? 0
60
+ }
61
+
62
+ /** 分发:严格按声明模式执行(声明与分发点不一致 = 契约违例) */
63
+ async dispatch<P, R = unknown>(event: string, payload: P, init?: () => R): Promise<R | undefined> {
64
+ const mode = catalog.get(event)
65
+ if (!mode) throw new Error(`CAR-E-CONTRACT: dispatch of undeclared event "${event}"`)
66
+ const handlers = [...(this.#handlers.get(event) ?? [])] // 已按 priority 稳定排序
67
+ const fallback = init ?? (() => undefined as R)
68
+ switch (mode) {
69
+ case 'emit': // 广播:不等待、无返回值(同步通知语义)
70
+ for (const h of handlers) { const r = h.fn(payload, () => undefined as R); void r }
71
+ return undefined
72
+ case 'serial': { // 有序检查点:await 串行,链式传值
73
+ let acc = fallback()
74
+ for (const h of handlers) acc = await h.fn(payload, () => acc)
75
+ return acc
76
+ }
77
+ case 'bail': { // 首断即止:首个非 undefined 返回即终止(短路是设计意图)
78
+ for (const h of handlers) {
79
+ const r = await h.fn(payload, () => undefined as R)
80
+ if (r !== undefined) return r
81
+ }
82
+ return fallback()
83
+ }
84
+ case 'waterfall': { // 环绕中间件:next() 前后均可介入,不调 next() = 短路整条链
85
+ let index = -1
86
+ const run = async (i: number): Promise<R> => {
87
+ if (i === handlers.length) return fallback()
88
+ index = i
89
+ return handlers[i].fn(payload, () => run(i + 1))
90
+ }
91
+ return await run(0)
92
+ }
93
+ case 'parallel': { // 扇出:全部并发,聚合数组
94
+ const results = await Promise.all(handlers.map(h => h.fn(payload, () => undefined as R)))
95
+ return results as unknown as R
96
+ }
97
+ }
98
+ }
99
+ }