@mebular/fleet 0.0.0-stage → 0.1.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 (178) hide show
  1. package/dist/agentdir.d.ts +43 -0
  2. package/dist/agentdir.d.ts.map +1 -0
  3. package/dist/agentdir.js +107 -0
  4. package/dist/agentdir.js.map +1 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +1135 -0
  8. package/dist/cli.js.map +1 -0
  9. package/dist/collab/chatter.d.ts +54 -0
  10. package/dist/collab/chatter.d.ts.map +1 -0
  11. package/dist/collab/chatter.js +94 -0
  12. package/dist/collab/chatter.js.map +1 -0
  13. package/dist/collab/dag.d.ts +27 -0
  14. package/dist/collab/dag.d.ts.map +1 -0
  15. package/dist/collab/dag.js +107 -0
  16. package/dist/collab/dag.js.map +1 -0
  17. package/dist/collab/negotiation.d.ts +60 -0
  18. package/dist/collab/negotiation.d.ts.map +1 -0
  19. package/dist/collab/negotiation.js +90 -0
  20. package/dist/collab/negotiation.js.map +1 -0
  21. package/dist/collab/tree.d.ts +39 -0
  22. package/dist/collab/tree.d.ts.map +1 -0
  23. package/dist/collab/tree.js +177 -0
  24. package/dist/collab/tree.js.map +1 -0
  25. package/dist/config.d.ts +123 -0
  26. package/dist/config.d.ts.map +1 -0
  27. package/dist/config.js +206 -0
  28. package/dist/config.js.map +1 -0
  29. package/dist/index.d.ts +33 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +40 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/join.d.ts +50 -0
  34. package/dist/join.d.ts.map +1 -0
  35. package/dist/join.js +167 -0
  36. package/dist/join.js.map +1 -0
  37. package/dist/jointoken.d.ts +144 -0
  38. package/dist/jointoken.d.ts.map +1 -0
  39. package/dist/jointoken.js +472 -0
  40. package/dist/jointoken.js.map +1 -0
  41. package/dist/model.d.ts +64 -0
  42. package/dist/model.d.ts.map +1 -0
  43. package/dist/model.js +158 -0
  44. package/dist/model.js.map +1 -0
  45. package/dist/onboard.d.ts +90 -0
  46. package/dist/onboard.d.ts.map +1 -0
  47. package/dist/onboard.js +510 -0
  48. package/dist/onboard.js.map +1 -0
  49. package/dist/protocol/envelope.d.ts +94 -0
  50. package/dist/protocol/envelope.d.ts.map +1 -0
  51. package/dist/protocol/envelope.js +169 -0
  52. package/dist/protocol/envelope.js.map +1 -0
  53. package/dist/protocol/events.d.ts +51 -0
  54. package/dist/protocol/events.d.ts.map +1 -0
  55. package/dist/protocol/events.js +87 -0
  56. package/dist/protocol/events.js.map +1 -0
  57. package/dist/qr.d.ts +15 -0
  58. package/dist/qr.d.ts.map +1 -0
  59. package/dist/qr.js +52 -0
  60. package/dist/qr.js.map +1 -0
  61. package/dist/quickstart.d.ts +167 -0
  62. package/dist/quickstart.d.ts.map +1 -0
  63. package/dist/quickstart.js +482 -0
  64. package/dist/quickstart.js.map +1 -0
  65. package/dist/quota.d.ts +41 -0
  66. package/dist/quota.d.ts.map +1 -0
  67. package/dist/quota.js +84 -0
  68. package/dist/quota.js.map +1 -0
  69. package/dist/runtime/agent.d.ts +100 -0
  70. package/dist/runtime/agent.d.ts.map +1 -0
  71. package/dist/runtime/agent.js +260 -0
  72. package/dist/runtime/agent.js.map +1 -0
  73. package/dist/runtime/executor.d.ts +42 -0
  74. package/dist/runtime/executor.d.ts.map +1 -0
  75. package/dist/runtime/executor.js +87 -0
  76. package/dist/runtime/executor.js.map +1 -0
  77. package/dist/runtime/fairness.d.ts +25 -0
  78. package/dist/runtime/fairness.d.ts.map +1 -0
  79. package/dist/runtime/fairness.js +79 -0
  80. package/dist/runtime/fairness.js.map +1 -0
  81. package/dist/runtime/node.d.ts +91 -0
  82. package/dist/runtime/node.d.ts.map +1 -0
  83. package/dist/runtime/node.js +203 -0
  84. package/dist/runtime/node.js.map +1 -0
  85. package/dist/runtime/openchamber-http.d.ts +27 -0
  86. package/dist/runtime/openchamber-http.d.ts.map +1 -0
  87. package/dist/runtime/openchamber-http.js +118 -0
  88. package/dist/runtime/openchamber-http.js.map +1 -0
  89. package/dist/runtime/openchamber.d.ts +38 -0
  90. package/dist/runtime/openchamber.d.ts.map +1 -0
  91. package/dist/runtime/openchamber.js +43 -0
  92. package/dist/runtime/openchamber.js.map +1 -0
  93. package/dist/runtime/planner.d.ts +23 -0
  94. package/dist/runtime/planner.d.ts.map +1 -0
  95. package/dist/runtime/planner.js +17 -0
  96. package/dist/runtime/planner.js.map +1 -0
  97. package/dist/runtime/worker.d.ts +96 -0
  98. package/dist/runtime/worker.d.ts.map +1 -0
  99. package/dist/runtime/worker.js +296 -0
  100. package/dist/runtime/worker.js.map +1 -0
  101. package/dist/security.d.ts +462 -0
  102. package/dist/security.d.ts.map +1 -0
  103. package/dist/security.js +1197 -0
  104. package/dist/security.js.map +1 -0
  105. package/dist/store/daemon-store.d.ts +33 -0
  106. package/dist/store/daemon-store.d.ts.map +1 -0
  107. package/dist/store/daemon-store.js +115 -0
  108. package/dist/store/daemon-store.js.map +1 -0
  109. package/dist/store/file-store.d.ts +22 -0
  110. package/dist/store/file-store.d.ts.map +1 -0
  111. package/dist/store/file-store.js +63 -0
  112. package/dist/store/file-store.js.map +1 -0
  113. package/dist/store/mebular-store.d.ts +22 -0
  114. package/dist/store/mebular-store.d.ts.map +1 -0
  115. package/dist/store/mebular-store.js +53 -0
  116. package/dist/store/mebular-store.js.map +1 -0
  117. package/dist/store/message-store.d.ts +26 -0
  118. package/dist/store/message-store.d.ts.map +1 -0
  119. package/dist/store/message-store.js +51 -0
  120. package/dist/store/message-store.js.map +1 -0
  121. package/dist/surface.d.ts +43 -0
  122. package/dist/surface.d.ts.map +1 -0
  123. package/dist/surface.js +515 -0
  124. package/dist/surface.js.map +1 -0
  125. package/dist/transport/null.d.ts +7 -0
  126. package/dist/transport/null.d.ts.map +1 -0
  127. package/dist/transport/null.js +16 -0
  128. package/dist/transport/null.js.map +1 -0
  129. package/dist/transport/spool.d.ts +12 -0
  130. package/dist/transport/spool.d.ts.map +1 -0
  131. package/dist/transport/spool.js +54 -0
  132. package/dist/transport/spool.js.map +1 -0
  133. package/dist/transport/types.d.ts +21 -0
  134. package/dist/transport/types.d.ts.map +1 -0
  135. package/dist/transport/types.js +6 -0
  136. package/dist/transport/types.js.map +1 -0
  137. package/dist/version.json +6 -0
  138. package/package.json +37 -4
  139. package/protocol/collab.example.json +23 -0
  140. package/protocol/daemon-home.example.json +66 -0
  141. package/protocol/envelope.example.json +12 -0
  142. package/protocol/state-machine.json +14 -0
  143. package/protocol/task-tree.example.json +72 -0
  144. package/src/agentdir.ts +138 -0
  145. package/src/cli.ts +1192 -0
  146. package/src/collab/chatter.ts +116 -0
  147. package/src/collab/dag.ts +124 -0
  148. package/src/collab/negotiation.ts +130 -0
  149. package/src/collab/tree.ts +184 -0
  150. package/src/config.ts +286 -0
  151. package/src/index.ts +39 -0
  152. package/src/join.ts +227 -0
  153. package/src/jointoken.ts +609 -0
  154. package/src/model.ts +203 -0
  155. package/src/onboard.ts +601 -0
  156. package/src/protocol/envelope.ts +228 -0
  157. package/src/protocol/events.ts +135 -0
  158. package/src/qr.ts +68 -0
  159. package/src/quickstart.ts +632 -0
  160. package/src/quota.ts +112 -0
  161. package/src/runtime/agent.ts +313 -0
  162. package/src/runtime/executor.ts +114 -0
  163. package/src/runtime/fairness.ts +90 -0
  164. package/src/runtime/node.ts +250 -0
  165. package/src/runtime/openchamber-http.ts +137 -0
  166. package/src/runtime/openchamber.ts +66 -0
  167. package/src/runtime/planner.ts +36 -0
  168. package/src/runtime/worker.ts +354 -0
  169. package/src/security.ts +1660 -0
  170. package/src/store/daemon-store.ts +144 -0
  171. package/src/store/file-store.ts +75 -0
  172. package/src/store/mebular-store.ts +66 -0
  173. package/src/store/message-store.ts +62 -0
  174. package/src/surface.ts +535 -0
  175. package/src/transport/null.ts +20 -0
  176. package/src/transport/spool.ts +62 -0
  177. package/src/transport/types.ts +27 -0
  178. package/README.md +0 -3
package/src/onboard.ts ADDED
@@ -0,0 +1,601 @@
1
+ // Fleet 设备上车 + 自检(Step 1b)。
2
+ //
3
+ // `onboardDevice`:一次(幂等)完成 主密钥(生成/导入,0600)→ 配置 → 设备证书(core 落盘)。
4
+ // `doctor`:逐项 PASS/FAIL/SKIP(含脱敏与修复建议);`skipped` 明列原因。
5
+
6
+ import { access, chmod, mkdir } from 'node:fs/promises';
7
+ import { constants } from 'node:fs';
8
+ import net from 'node:net';
9
+ import {
10
+ Mebular,
11
+ type NamespaceHandoffPlan,
12
+ type NamespaceHandoffResult,
13
+ type NamespaceRejoinResult,
14
+ } from '@mebular/core';
15
+ import { isHeartbeatFresh, readHeartbeat, registeredServicesForDir } from '../../service/dist/index.js';
16
+
17
+ import {
18
+ fileMode,
19
+ permissionsApplicable,
20
+ fleetConfigPath,
21
+ fleetMasterKeyPath,
22
+ fleetStoragePath,
23
+ generateMasterKey,
24
+ loadFleetConfig,
25
+ loadToolConfig,
26
+ masterKeyFingerprint,
27
+ readMasterKeyFile,
28
+ saveFleetConfig,
29
+ validateFleetConfig,
30
+ writeMasterKeyFile,
31
+ type FleetAgentConfig,
32
+ type FleetConfig,
33
+ type FleetEncryption,
34
+ type OnboardInput,
35
+ type OnboardResult,
36
+ } from './config.js';
37
+ import { CommandAgent, ExecutorRegistry, HermesAgent } from './runtime/agent.js';
38
+ import { HttpOpenChamberSeam } from './runtime/openchamber-http.js';
39
+ import { OpenChamberAgent } from './runtime/openchamber.js';
40
+ import { EchoExecutor } from './runtime/executor.js';
41
+
42
+ async function exists(path: string): Promise<boolean> {
43
+ try {
44
+ await access(path, constants.F_OK);
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /** 由 agent 配置构建执行器注册表(未知 kind 已在校验期拒绝)。 */
52
+ export function buildRegistry(agents: readonly FleetAgentConfig[]): ExecutorRegistry {
53
+ const registry = new ExecutorRegistry();
54
+ for (const a of agents) {
55
+ if (a.kind === 'echo') registry.register(a.name, new EchoExecutor());
56
+ else if (a.kind === 'command') {
57
+ registry.register(
58
+ a.name,
59
+ new CommandAgent({
60
+ command: a.command ?? '',
61
+ ...(a.baseArgs !== undefined ? { baseArgs: a.baseArgs } : {}),
62
+ ...(a.timeoutMs !== undefined ? { timeoutMs: a.timeoutMs } : {}),
63
+ ...(a.concurrency !== undefined ? { concurrency: a.concurrency } : {}),
64
+ }),
65
+ );
66
+ } else if (a.kind === 'hermes') {
67
+ registry.register(a.name, new HermesAgent({ ...(a.timeoutMs !== undefined ? { timeoutMs: a.timeoutMs } : {}) }));
68
+ } else {
69
+ registry.register(
70
+ a.name,
71
+ new OpenChamberAgent(new HttpOpenChamberSeam({ ...(a.timeoutMs !== undefined ? { timeoutMs: a.timeoutMs } : {}) })),
72
+ );
73
+ }
74
+ }
75
+ return registry;
76
+ }
77
+
78
+ /** 由配置 + 加密材料构造 core `Mebular` 选项(订阅自主;对端自行声明)。 */
79
+ export function mebularOptions(config: FleetConfig, encryption: FleetEncryption): Record<string, unknown> {
80
+ return {
81
+ storagePath: config.storagePath,
82
+ deviceId: config.device,
83
+ encryption,
84
+ network: { enabled: true, libp2p: { listen: [config.listen] } },
85
+ sync: {
86
+ autoSync: true,
87
+ pushOnWrite: true,
88
+ pushOnWriteThrottleMs: 20,
89
+ namespaces: [config.namespace],
90
+ antiEntropy: { enabled: true, intervalMs: 600_000, jitterRatio: 0.2 },
91
+ },
92
+ };
93
+ }
94
+
95
+ /** 离线(不启网络)`Mebular` 选项:onboard/doctor/subscribe 用,只读写本地存储。 */
96
+ export function offlineMebularOptions(config: FleetConfig, encryption: FleetEncryption): Record<string, unknown> {
97
+ return {
98
+ storagePath: config.storagePath,
99
+ deviceId: config.device,
100
+ encryption,
101
+ network: { enabled: false },
102
+ };
103
+ }
104
+
105
+ /** 生成/导入主密钥并写配置;幂等(重复执行不损坏身份)。 */
106
+ export async function onboardDevice(input: OnboardInput): Promise<OnboardResult> {
107
+ if (typeof input.device !== 'string' || input.device.length === 0) throw new Error('onboard 需要 --device');
108
+ await mkdir(input.dir, { recursive: true });
109
+ if (permissionsApplicable()) await chmod(input.dir, 0o700);
110
+
111
+ const keyPath = fleetMasterKeyPath(input.dir);
112
+ let masterKeyCreated = false;
113
+ let encryption: FleetEncryption;
114
+ if (input.masterKeyFile !== undefined) {
115
+ encryption = await readMasterKeyFile(input.masterKeyFile);
116
+ await writeMasterKeyFile(keyPath, encryption); // 归一化到设备目录,0600
117
+ } else if (await exists(keyPath)) {
118
+ encryption = await readMasterKeyFile(keyPath);
119
+ } else {
120
+ encryption = await generateMasterKey();
121
+ await writeMasterKeyFile(keyPath, encryption);
122
+ masterKeyCreated = true;
123
+ }
124
+
125
+ const config: FleetConfig = {
126
+ v: 1,
127
+ device: input.device,
128
+ dir: input.dir,
129
+ storagePath: fleetStoragePath(input.dir),
130
+ masterKeyFile: keyPath,
131
+ namespace: input.namespace ?? 'tasks',
132
+ listen: input.listen ?? '/ip4/0.0.0.0/tcp/0',
133
+ peers: input.peerDevice !== undefined ? [{ device: input.peerDevice, ...(input.peerAddr !== undefined ? { addr: input.peerAddr } : {}) }] : [],
134
+ agents: input.agents ?? [{ name: 'echo', kind: 'echo' }],
135
+ ...(input.quotaLimitPerDevice !== undefined ? { quotaLimitPerDevice: input.quotaLimitPerDevice } : {}),
136
+ };
137
+ const errors = validateFleetConfig(config);
138
+ if (errors.length > 0) throw new Error(`onboard 配置非法:${errors.join('; ')}`);
139
+
140
+ const alreadyOnboarded = await exists(fleetConfigPath(input.dir));
141
+ await saveFleetConfig(fleetConfigPath(input.dir), config);
142
+
143
+ // 用 core 落盘设备身份/证书(幂等:已存在则复用)。
144
+ const mebular = new Mebular({
145
+ storagePath: config.storagePath,
146
+ deviceId: config.device,
147
+ encryption,
148
+ network: { enabled: false },
149
+ } as never);
150
+ await mebular.initialize();
151
+ try {
152
+ // 上车即声明本机对该域的订阅(订阅自主、零门槛;其他人仍需自行订阅)。
153
+ await mebular.subscribeNamespace(config.namespace);
154
+ } finally {
155
+ await mebular.shutdown();
156
+ }
157
+
158
+ return { config, alreadyOnboarded, masterKeyCreated, masterKeyFingerprint: masterKeyFingerprint(encryption.userMasterKey) };
159
+ }
160
+
161
+ /**
162
+ * 工具/CLI 共用的 app 生命周期:注入 live app(运行中 daemon)时**复用它**——不 initialize、
163
+ * 不 shutdown;否则按目录新建离线实例并在结束时关闭。这样同一份 handler 既能服务 daemon,也能离线跑。
164
+ */
165
+ async function withToolMebular<T>(
166
+ dir: string,
167
+ live: Mebular | undefined,
168
+ run: (mebular: Mebular, config: FleetConfig) => Promise<T>,
169
+ ): Promise<T> {
170
+ const config = await loadToolConfig(dir);
171
+ if (live !== undefined) return run(live, config);
172
+ const encryption = await readMasterKeyFile(config.masterKeyFile);
173
+ const mebular = new Mebular(offlineMebularOptions(config, encryption) as never);
174
+ await mebular.initialize();
175
+ try {
176
+ return await run(mebular, config);
177
+ } finally {
178
+ await mebular.shutdown();
179
+ }
180
+ }
181
+
182
+ /**
183
+ * 本机订阅/退订某记忆域(**只能改自己**;写 `namespace_subscription` 到 `__policy__`)。
184
+ * `active=false` 只退订,不清本地数据;退出并交接走 `leaveNamespace`。
185
+ *
186
+ * `mebular`:运行中 daemon 的 live app(可选);提供时写入同一 EventLog,不另开离线实例。
187
+ */
188
+ export async function subscribeNamespace(
189
+ dir: string,
190
+ input: { namespace?: string; active?: boolean; note?: string; mebular?: Mebular },
191
+ ): Promise<{ member: string; namespace: string; active: boolean; eventId: string }> {
192
+ const active = input.active ?? true;
193
+ return withToolMebular(dir, input.mebular, async (mebular, config) => {
194
+ const namespace = input.namespace ?? config.namespace;
195
+ const event = active
196
+ ? await mebular.subscribeNamespace(namespace)
197
+ : await mebular.unsubscribeNamespace(namespace);
198
+ return { member: config.device, namespace, active, eventId: event.id };
199
+ });
200
+ }
201
+
202
+ /** 某分区的 active 订阅者(只读)。 */
203
+ export async function namespaceSubscribers(dir: string, namespace?: string): Promise<string[]> {
204
+ const config = await loadToolConfig(dir);
205
+ const encryption = await readMasterKeyFile(config.masterKeyFile);
206
+ const mebular = new Mebular(offlineMebularOptions(config, encryption) as never);
207
+ await mebular.initialize();
208
+ try {
209
+ return await mebular.getNamespaceSubscribers(namespace ?? config.namespace);
210
+ } finally {
211
+ await mebular.shutdown();
212
+ }
213
+ }
214
+
215
+ /** 某分区订阅视图(只读)。 */
216
+ export async function namespaceSubscriptions(
217
+ dir: string,
218
+ namespace?: string,
219
+ ): Promise<{ active: boolean; subscribers: string[] }> {
220
+ const config = await loadToolConfig(dir);
221
+ const encryption = await readMasterKeyFile(config.masterKeyFile);
222
+ const mebular = new Mebular(offlineMebularOptions(config, encryption) as never);
223
+ await mebular.initialize();
224
+ try {
225
+ return await mebular.getNamespaceSubscriptions(namespace ?? config.namespace);
226
+ } finally {
227
+ await mebular.shutdown();
228
+ }
229
+ }
230
+
231
+ /** 建域(零门槛):写 `namespace_declare`;不自动订阅。`mebular` 为运行中 daemon 的 live app(可选)。 */
232
+ export async function declareNamespace(
233
+ dir: string,
234
+ input: { namespace: string; note?: string; mebular?: Mebular },
235
+ ): Promise<{ namespace: string; eventId: string }> {
236
+ if (typeof input.namespace !== 'string' || input.namespace.length === 0) throw new Error('namespace 不能为空');
237
+ return withToolMebular(dir, input.mebular, async (mebular) => {
238
+ const event = await mebular.declareNamespace({ namespace: input.namespace, ...(input.note !== undefined ? { note: input.note } : {}) });
239
+ return { namespace: input.namespace, eventId: event.id };
240
+ });
241
+ }
242
+
243
+ /** 恢复被移出的设备(写 `device_restore`;high 档证明由调用方给出)。 */
244
+ export async function restoreDevice(
245
+ dir: string,
246
+ input: {
247
+ subject: string;
248
+ note?: string;
249
+ nonce?: string;
250
+ cosign?: unknown;
251
+ breakGlass?: unknown;
252
+ },
253
+ ): Promise<{ subject: string; eventId: string }> {
254
+ if (typeof input.subject !== 'string' || input.subject.length === 0) throw new Error('restore 需要 --to <deviceId>');
255
+ const config = await loadToolConfig(dir);
256
+ const encryption = await readMasterKeyFile(config.masterKeyFile);
257
+ const mebular = new Mebular(offlineMebularOptions(config, encryption) as never);
258
+ await mebular.initialize();
259
+ try {
260
+ const event = await mebular.restoreDevice({
261
+ subject: input.subject,
262
+ ...(input.note !== undefined ? { note: input.note } : {}),
263
+ ...(input.nonce !== undefined ? { nonce: input.nonce } : {}),
264
+ ...(input.cosign !== undefined ? { cosign: input.cosign as never } : {}),
265
+ ...(input.breakGlass !== undefined ? { breakGlass: input.breakGlass as never } : {}),
266
+ });
267
+ return { subject: input.subject, eventId: event.id };
268
+ } finally {
269
+ await mebular.shutdown();
270
+ }
271
+ }
272
+
273
+ /** 重订阅恢复(准入:曾订阅 ∧ 未被移出;写图外 reset 标记)。 */
274
+ export async function rejoinNamespace(
275
+ dir: string,
276
+ input: { namespace?: string } = {},
277
+ ): Promise<NamespaceRejoinResult> {
278
+ const config = await loadToolConfig(dir);
279
+ const encryption = await readMasterKeyFile(config.masterKeyFile);
280
+ const mebular = new Mebular(offlineMebularOptions(config, encryption) as never);
281
+ await mebular.initialize();
282
+ try {
283
+ return await mebular.rejoinNamespace({ namespace: input.namespace ?? config.namespace });
284
+ } finally {
285
+ await mebular.shutdown();
286
+ }
287
+ }
288
+
289
+ /** 本机是否已声明某分区重置(读图外标记)。只读。 */
290
+ export async function rejoinReset(dir: string, namespace?: string): Promise<boolean> {
291
+ const config = await loadToolConfig(dir);
292
+ const encryption = await readMasterKeyFile(config.masterKeyFile);
293
+ const mebular = new Mebular(offlineMebularOptions(config, encryption) as never);
294
+ await mebular.initialize();
295
+ try {
296
+ return await mebular.hasRejoinReset(namespace ?? config.namespace);
297
+ } finally {
298
+ await mebular.shutdown();
299
+ }
300
+ }
301
+
302
+ /** 交接前置校验(只读)——继任者是否订阅且已 ack 本机在该分区的全部事件。 */
303
+ export async function planHandoff(
304
+ dir: string,
305
+ input: { namespace?: string; successor: string },
306
+ ): Promise<NamespaceHandoffPlan> {
307
+ const config = await loadToolConfig(dir);
308
+ const encryption = await readMasterKeyFile(config.masterKeyFile);
309
+ const mebular = new Mebular(offlineMebularOptions(config, encryption) as never);
310
+ await mebular.initialize();
311
+ try {
312
+ return await mebular.planNamespaceHandoff({ namespace: input.namespace ?? config.namespace, successor: input.successor });
313
+ } finally {
314
+ await mebular.shutdown();
315
+ }
316
+ }
317
+
318
+ /** 退出并交接(默认要求继任者全量 ack;`force` 仅本地 CLI,仍如实记录)。 */
319
+ export async function leaveNamespace(
320
+ dir: string,
321
+ input: { namespace?: string; successor: string; force?: boolean; note?: string },
322
+ ): Promise<NamespaceHandoffResult> {
323
+ const config = await loadToolConfig(dir);
324
+ const encryption = await readMasterKeyFile(config.masterKeyFile);
325
+ const mebular = new Mebular(offlineMebularOptions(config, encryption) as never);
326
+ await mebular.initialize();
327
+ try {
328
+ return await mebular.leaveNamespace({
329
+ namespace: input.namespace ?? config.namespace,
330
+ successor: input.successor,
331
+ ...(input.force !== undefined ? { force: input.force } : {}),
332
+ ...(input.note !== undefined ? { note: input.note } : {}),
333
+ });
334
+ } finally {
335
+ await mebular.shutdown();
336
+ }
337
+ }
338
+
339
+ /** `WARN` = 非致命告警(不使 `ok=false`),用于暴露加固/运维风险。 */
340
+ export type DoctorStatus = 'PASS' | 'FAIL' | 'SKIP' | 'WARN';
341
+ export interface DoctorCheck {
342
+ name: string;
343
+ status: DoctorStatus;
344
+ detail: string;
345
+ hint?: string;
346
+ }
347
+ export interface DoctorReport {
348
+ ok: boolean;
349
+ device: string;
350
+ checks: DoctorCheck[];
351
+ skipped: string[];
352
+ }
353
+
354
+ function parseTcp(multiaddr: string): { host: string; port: number } | null {
355
+ const m = /\/ip4\/([0-9.]+)\/tcp\/(\d+)/.exec(multiaddr);
356
+ return m ? { host: m[1]!, port: Number(m[2]) } : null;
357
+ }
358
+
359
+ function tcpReachable(host: string, port: number, timeoutMs = 1500): Promise<boolean> {
360
+ return new Promise((resolve) => {
361
+ const socket = net.connect({ host, port });
362
+ const done = (ok: boolean): void => {
363
+ socket.destroy();
364
+ resolve(ok);
365
+ };
366
+ socket.setTimeout(timeoutMs);
367
+ socket.once('connect', () => done(true));
368
+ socket.once('timeout', () => done(false));
369
+ socket.once('error', () => done(false));
370
+ });
371
+ }
372
+
373
+ /** 从 multiaddr 取监听主机(`/ip4|ip6/<host>/tcp/<port>`);无法解析 → null。 */
374
+ function listenHostOf(multiaddr: string): string | null {
375
+ const m = /^\/(?:ip4|ip6)\/([^/]+)\/tcp\/\d+/.exec(multiaddr);
376
+ return m ? m[1]! : null;
377
+ }
378
+
379
+ /** 回环/私有/链路本地/ULA 视为“非公网”;`0.0.0.0`/`::`/全局地址 → false。 */
380
+ function isPrivateListenHost(host: string): boolean {
381
+ if (host === '127.0.0.1' || host === '::1' || host === 'localhost') return true;
382
+ if (/^10\./.test(host) || /^192\.168\./.test(host)) return true;
383
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(host)) return true;
384
+ if (/^169\.254\./.test(host)) return true;
385
+ if (/^fe80:/i.test(host)) return true;
386
+ if (/^f[cd]/i.test(host)) return true; // IPv6 ULA
387
+ return false;
388
+ }
389
+
390
+ /** 逐项自检;默认脱敏(不含任何密钥材料)。 */
391
+ export async function doctor(dir: string): Promise<DoctorReport> {
392
+ const checks: DoctorCheck[] = [];
393
+ const cfgPath = fleetConfigPath(dir);
394
+ const add = (name: string, status: DoctorStatus, detail: string, hint?: string): void => {
395
+ checks.push({ name, status, detail, ...(hint !== undefined ? { hint } : {}) });
396
+ };
397
+
398
+ let config: FleetConfig;
399
+ try {
400
+ config = await loadFleetConfig(cfgPath);
401
+ } catch (error) {
402
+ add('config', 'FAIL', `无法加载配置:${(error as Error).message}`, `先运行:fleet onboard --dir ${dir} --device <id>`);
403
+ return { ok: false, device: '<unknown>', checks, skipped: [] };
404
+ }
405
+
406
+ // 1) 配置权限(POSIX mode;Windows 无该语义 → SKIP,依赖用户目录 ACL)
407
+ const posix = permissionsApplicable();
408
+ if (posix) {
409
+ const cfgMode = await fileMode(cfgPath).catch(() => -1);
410
+ add('config 权限', cfgMode === 0o600 ? 'PASS' : 'FAIL', `mode=${cfgMode.toString(8)}`, cfgMode === 0o600 ? undefined : `chmod 600 ${cfgPath}`);
411
+ } else {
412
+ add('config 权限', 'SKIP', 'Windows 无 POSIX mode;依赖用户目录 ACL(见 ONBOARDING Windows 章节)');
413
+ }
414
+
415
+ // 1.5) 监听地址:绑定全接口/公网 → **WARN**(不静默;给加固建议)。不使 ok=false。
416
+ const listenHost = listenHostOf(config.listen);
417
+ if (listenHost === null) {
418
+ add('监听地址', 'SKIP', `无法解析 listen:${config.listen}`);
419
+ } else if (isPrivateListenHost(listenHost)) {
420
+ add('监听地址', 'PASS', config.listen);
421
+ } else {
422
+ add(
423
+ '监听地址',
424
+ 'WARN',
425
+ `${config.listen} 绑定到全接口/公网地址(${listenHost})`,
426
+ '改绑回环或 LAN 地址(/ip4/127.0.0.1 或内网 IP)+ 经 relay 互联,或仅放行已授权对端(见 RELAY-OPS.md)',
427
+ );
428
+ }
429
+
430
+ // 2) 主密钥(存在 + 权限 + 可加载)
431
+ let encryption: FleetEncryption | null = null;
432
+ try {
433
+ encryption = await readMasterKeyFile(config.masterKeyFile);
434
+ } catch {
435
+ encryption = null;
436
+ }
437
+ if (!posix) {
438
+ add('主密钥权限', 'SKIP', 'Windows 无 POSIX mode;依赖用户目录 ACL(见 ONBOARDING Windows 章节)');
439
+ if (encryption === null) add('主密钥', 'FAIL', '无法加载主密钥文件', '重新生成或导入(--master-key)');
440
+ else add('主密钥', 'PASS', `fingerprint=${masterKeyFingerprint(encryption.userMasterKey)}`);
441
+ } else {
442
+ const keyMode = await fileMode(config.masterKeyFile).catch(() => -1);
443
+ if (keyMode !== 0o600) add('主密钥权限', 'FAIL', `mode=${keyMode.toString(8)}`, `chmod 600 ${config.masterKeyFile}`);
444
+ else if (encryption === null) add('主密钥', 'FAIL', '无法加载主密钥文件', '重新生成或导入(--master-key)');
445
+ else add('主密钥', 'PASS', `fingerprint=${masterKeyFingerprint(encryption.userMasterKey)}`);
446
+ }
447
+
448
+ // 3) 设备身份 + 主密钥链(core 验签设备证书)
449
+ const identityPath = `${config.storagePath}.identity.json`;
450
+ add('设备身份文件', (await exists(identityPath)) ? 'PASS' : 'FAIL', identityPath, `fleet onboard --dir ${dir} --device ${config.device}`);
451
+ if (encryption !== null) {
452
+ try {
453
+ const m = new Mebular({ storagePath: config.storagePath, deviceId: config.device, encryption, network: { enabled: false } } as never);
454
+ await m.initialize();
455
+ await m.shutdown();
456
+ add('主密钥链', 'PASS', '设备证书链到用户主密钥');
457
+ } catch (error) {
458
+ add('主密钥链', 'FAIL', `初始化失败:${(error as Error).message}`, '确认 --master-key 与既有身份一致');
459
+ }
460
+ }
461
+
462
+ // 4) 对端可达(有 addr 才测;否则等待对端拨入)
463
+ const withAddr = config.peers.filter((p) => typeof p.addr === 'string');
464
+ if (config.peers.length === 0) {
465
+ add('peer 可达', 'SKIP', '未配置任何对端(默认拒绝)', 'fleet onboard … --peer-device <id> [--peer-addr <multiaddr>]');
466
+ } else if (withAddr.length === 0) {
467
+ add('peer 可达', 'SKIP', '对端未提供 addr(等待对端拨入)', undefined);
468
+ } else {
469
+ for (const peer of withAddr) {
470
+ const tcp = parseTcp(peer.addr!);
471
+ const reachable = tcp !== null && (await tcpReachable(tcp.host, tcp.port));
472
+ add(`peer 可达(${peer.device})`, reachable ? 'PASS' : 'FAIL', peer.addr!, '确认对端已启动且地址/端口可达');
473
+ }
474
+ }
475
+
476
+ // 5) 订阅:本机自声明 + 对端自声明(没有「授权」项)。
477
+ const peerDevices = config.peers.map((p) => p.device);
478
+ if (encryption === null) {
479
+ add('namespace 订阅', 'SKIP', `主密钥不可用,无法读取图上订阅(namespace=${config.namespace})`, '导入主密钥后重试(--master-key)');
480
+ } else {
481
+ try {
482
+ const m = new Mebular(offlineMebularOptions(config, encryption) as never);
483
+ await m.initialize();
484
+ const view = await m.getNamespaceSubscriptions(config.namespace);
485
+ const blocked = await m.getBlockedDevices();
486
+ const peerParts: string[] = [];
487
+ let allPeersSubscribed = peerDevices.length > 0;
488
+ for (const device of peerDevices) {
489
+ const subscribed = (await m.getSubscriptions(device)).includes(config.namespace);
490
+ peerParts.push(`${device}:${subscribed ? 'subscribed' : 'not-yet'}`);
491
+ if (!subscribed) allPeersSubscribed = false;
492
+ }
493
+ await m.shutdown();
494
+ const selfSubscribed = view.subscribers.includes(config.device);
495
+ add(
496
+ 'namespace 订阅',
497
+ selfSubscribed ? 'PASS' : 'FAIL',
498
+ `namespace=${config.namespace} subscribers=[${view.subscribers.join(', ')}] self=${config.device}`,
499
+ selfSubscribed ? undefined : `fleet subscribe --namespace ${config.namespace}`,
500
+ );
501
+ add(
502
+ '对端订阅',
503
+ peerDevices.length === 0 ? 'SKIP' : allPeersSubscribed ? 'PASS' : 'SKIP',
504
+ peerDevices.length === 0 ? '未登记对端' : `peers=[${peerParts.join(', ')}]`,
505
+ allPeersSubscribed ? undefined : '对端自行声明订阅后即可互通(订阅零门槛)',
506
+ );
507
+ add(
508
+ '移出状态',
509
+ blocked.includes(config.device) ? 'FAIL' : blocked.length > 0 ? 'WARN' : 'PASS',
510
+ blocked.length === 0 ? '无设备被移出' : `blocked=[${blocked.join(', ')}]`,
511
+ blocked.includes(config.device) ? '本机已被移出:请由其他设备执行 device_restore 后重新订阅' : undefined,
512
+ );
513
+ } catch (error) {
514
+ add('namespace 订阅', 'FAIL', `读取本地订阅失败:${(error as Error).message}`, '确认存储/主密钥可用');
515
+ }
516
+ }
517
+
518
+ // 5.5) W2 守护(仅当 fleet 走 daemon 客户端时检查;embedded 不引入新 SKIP,保持既有验收不变)
519
+ if (config.daemon?.endpoint !== undefined) {
520
+ try {
521
+ const res = await fetch(`${config.daemon.endpoint.replace(/\/+$/, '')}/healthz`);
522
+ add('守护', res.ok ? 'PASS' : 'FAIL', `${config.daemon.endpoint}(HTTP ${res.status})`, res.ok ? undefined : '确认 mebular serve 已启动(mebular service install mebular-serve)');
523
+ } catch (error) {
524
+ add('守护', 'FAIL', `${config.daemon.endpoint} 不可达`, `启动守护:mebular serve(${(error as Error).message})`);
525
+ }
526
+ }
527
+
528
+ // 6) agent 注册表可解析
529
+ const agentErrors = validateFleetConfig({ ...config, agents: config.agents }).filter((e) => e.startsWith('agent'));
530
+ try {
531
+ buildRegistry(config.agents);
532
+ add('agent 注册表', agentErrors.length === 0 ? 'PASS' : 'FAIL', config.agents.map((a) => `${a.name}:${a.kind}`).join(','), '修正 agents 配置');
533
+ } catch (error) {
534
+ add('agent 注册表', 'FAIL', (error as Error).message, '修正 agents 配置');
535
+ }
536
+
537
+ // 7) 同步已收敛(本地是否已见对端署名的任务事件)
538
+ if (encryption === null) {
539
+ add('同步已收敛', 'SKIP', '主密钥不可用,跳过');
540
+ } else {
541
+ try {
542
+ const m = new Mebular({ storagePath: config.storagePath, deviceId: config.device, encryption, network: { enabled: false } } as never);
543
+ await m.initialize();
544
+ const nodes = (await m.graph.listNodes({ type: 'task_event' })) as Array<{
545
+ namespace?: string;
546
+ content?: { actor?: { device?: string }; author?: string };
547
+ }>;
548
+ await m.shutdown();
549
+ const taskNodes = nodes.filter((n) => n.namespace === config.namespace);
550
+ if (taskNodes.length === 0) {
551
+ add('同步已收敛', 'SKIP', '尚无任务事件,无法判断(可在派活后重跑 doctor)');
552
+ } else {
553
+ const peerDevices = new Set(config.peers.map((p) => p.device));
554
+ const authors = taskNodes
555
+ .map((n) => n.content?.actor?.device ?? n.content?.author)
556
+ .filter((a): a is string => typeof a === 'string');
557
+ const seen = new Set(authors.filter((a) => peerDevices.has(a)));
558
+ add('同步已收敛', seen.size > 0 ? 'PASS' : 'FAIL', `对端署名事件:${seen.size}/${peerDevices.size}`, '确认对端在线、已授权且已产生同步');
559
+ }
560
+ } catch (error) {
561
+ add('同步已收敛', 'FAIL', `读取本地状态失败:${(error as Error).message}`);
562
+ }
563
+ }
564
+
565
+ // 7.4) 2c 重入状态:本机是否已声明某分区重置(图外标记,不同步)。恒 PASS,供审计。
566
+ const rejoinMarker = `${config.storagePath}.rejoin.${config.namespace}.json`;
567
+ add(
568
+ '重入状态',
569
+ 'PASS',
570
+ (await exists(rejoinMarker)) ? `namespace=${config.namespace} reset=true(已声明重置;下次同步将从零拉取)` : 'reset=false',
571
+ );
572
+
573
+ // 7.5) 2b 交接状态:存在未完成意图(图外 sidecar)→ FAIL(重跑 `fleet leave` 幂等续跑)。
574
+ const intentPath = `${config.storagePath}.handoff.json`;
575
+ if (await exists(intentPath)) {
576
+ add('交接状态', 'FAIL', `存在未完成交接意图:${intentPath}`, '重跑 `fleet leave --successor <id>`(幂等续跑)');
577
+ } else {
578
+ add('交接状态', 'PASS', '无未完成交接');
579
+ }
580
+
581
+ // 8) 服务已注册(D4):是否有常驻服务**指向本目录**(读 manifest,dir-scoped,temp 目录可复现)。
582
+ const registered = registeredServicesForDir(dir);
583
+ if (registered.length === 0) {
584
+ add('服务已注册', 'SKIP', '未安装常驻服务', 'fleet service install fleet-node|fleet-worker(或 mebular service install)');
585
+ } else {
586
+ add('服务已注册', 'PASS', `services=[${registered.join(', ')}]`);
587
+ }
588
+
589
+ // 9) 心跳新鲜(D4):process 写 `<dir>/service.heartbeat`;无文件 → SKIP,陈旧 → FAIL。
590
+ const heartbeat = readHeartbeat(dir);
591
+ if (heartbeat === null) {
592
+ add('心跳新鲜', 'SKIP', '无 service.heartbeat(服务未运行/未安装)');
593
+ } else if (isHeartbeatFresh(dir)) {
594
+ add('心跳新鲜', 'PASS', `role=${heartbeat.role} pid=${heartbeat.pid} age=${Date.now() - heartbeat.ts}ms sha=${heartbeat.sha}`);
595
+ } else {
596
+ add('心跳新鲜', 'FAIL', `心跳陈旧 age=${Date.now() - heartbeat.ts}ms(role=${heartbeat.role} pid=${heartbeat.pid})`, '服务可能已崩溃:`fleet service status` / `fleet service logs`');
597
+ }
598
+
599
+ const skipped = checks.filter((c) => c.status === 'SKIP').map((c) => c.name);
600
+ return { ok: !checks.some((c) => c.status === 'FAIL'), device: config.device, checks, skipped };
601
+ }