@dsh-plus/actual 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.
package/src/runtime.ts ADDED
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Actual 能力运行时:连接缓存、进程内 MCP 会话生命周期、双源发现(MCP
3
+ * tools/list 主源 × CLI help 树交叉校验与兜底)、统一 invoke 与变更订阅。
4
+ * cordis 无关的纯逻辑核心,I/O 全部经注入依赖,可整体替身测试。
5
+ * @module @dsh-plus/actual/runtime
6
+ */
7
+
8
+ import {
9
+ ActualCli,
10
+ type ActualCliBinding,
11
+ type ActualManifest,
12
+ type ActualStatus,
13
+ type CapabilityEntry,
14
+ type CliConfig,
15
+ type CliDeps,
16
+ computeDrift,
17
+ createEntryInvoker,
18
+ discover,
19
+ type EntryInvoker,
20
+ sanitize,
21
+ versionStatusOf,
22
+ } from '@dsh-plus/actual-mcp'
23
+
24
+ import type { McpSessionLike } from './mcp-session.ts'
25
+ import { ReconnectScheduler } from './reconnect.ts'
26
+
27
+ /** 日志窄面(cordis logger 天然满足)。 */
28
+ export interface RuntimeLogger {
29
+ info(message: string): void
30
+ warn(message: string): void
31
+ error(message: string): void
32
+ }
33
+
34
+ /** 运行时配置 = CLI 连接 + 暴露过滤 + 版本策略。 */
35
+ export interface RuntimeConfig {
36
+ cli: CliConfig
37
+ allow: string[]
38
+ deny: string[]
39
+ cliVersionPolicy: 'warn' | 'strict'
40
+ toolCallTimeoutMs: number
41
+ }
42
+
43
+ /** 注入依赖 = CLI I/O + 进程内 MCP 会话工厂。 */
44
+ export interface RuntimeDeps extends CliDeps {
45
+ createSession(entries: readonly CapabilityEntry[], invoke: EntryInvoker): McpSessionLike
46
+ }
47
+
48
+ /** 一次工具调用的执行选项。 */
49
+ export interface InvokeOptions {
50
+ signal?: AbortSignal
51
+ timeoutMs: number
52
+ }
53
+
54
+ /** MCP 连接建立的超时预算。 */
55
+ const MCP_CONNECT_TIMEOUT_MS = 5_000
56
+
57
+ /** 能力运行时:一个实例对应主插件一行的生命周期。 */
58
+ export class ActualRuntime {
59
+ private binding: ActualCliBinding | undefined
60
+ private session: McpSessionLike | undefined
61
+ private cliInvoker: EntryInvoker | undefined
62
+ private manifestCache: ActualManifest | undefined
63
+ private discovering: Promise<ActualManifest> | undefined
64
+ private readonly listeners = new Set<(manifest: ActualManifest) => void>()
65
+ private readonly reconnect: ReconnectScheduler
66
+ private disposed = false
67
+ private lastError: string | undefined
68
+ private cliVersion = ''
69
+ private serverVersion = ''
70
+ private version: 'ok' | 'mismatch' | 'unknown' = 'unknown'
71
+
72
+ private readonly config: RuntimeConfig
73
+ private readonly deps: RuntimeDeps
74
+ private readonly logger: RuntimeLogger
75
+
76
+ constructor(config: RuntimeConfig, deps: RuntimeDeps, logger: RuntimeLogger) {
77
+ this.config = config
78
+ this.deps = deps
79
+ this.logger = logger
80
+ this.reconnect = new ReconnectScheduler({
81
+ onAttempt: () => {
82
+ if (this.disposed) return
83
+ void this.discover().catch((error: unknown) =>
84
+ this.logger.warn(`Actual 重连发现失败:${messageOf(error)}`),
85
+ )
86
+ },
87
+ onGiveUp: (failures) =>
88
+ this.logger.error(
89
+ `Actual 重连连续失败 ${failures} 次,停止自动重试(恢复后 /reload 可触发)`,
90
+ ),
91
+ })
92
+ }
93
+
94
+ /** 连接与发现状态快照(不含密钥)。 */
95
+ status(): ActualStatus {
96
+ const status: ActualStatus = {
97
+ cli: this.binding?.origin ?? '未解析',
98
+ cliVersion: this.cliVersion,
99
+ cliAvailable: this.binding !== undefined,
100
+ serverUrl: this.config.cli.serverUrl,
101
+ serverVersion: this.serverVersion,
102
+ mcpConnected: this.session?.connected === true,
103
+ versionStatus: this.version,
104
+ }
105
+ if (this.lastError !== undefined) status.lastError = this.lastError
106
+ return status
107
+ }
108
+
109
+ /** 最近一次成功发现的清单。 */
110
+ manifest(): ActualManifest | undefined {
111
+ return this.manifestCache
112
+ }
113
+
114
+ /** 订阅清单变更(新清单产出时触发),返回退订。 */
115
+ onChange(cb: (manifest: ActualManifest) => void): () => void {
116
+ this.listeners.add(cb)
117
+ return () => this.listeners.delete(cb)
118
+ }
119
+
120
+ /** 单飞发现:并发调用共享同一次执行。 */
121
+ discover(): Promise<ActualManifest> {
122
+ if (this.discovering === undefined) {
123
+ const task = this.runDiscover()
124
+ .catch((error: unknown) => {
125
+ this.lastError = this.sanitize(error instanceof Error ? error.message : String(error))
126
+ this.reconnect.schedule()
127
+ throw error
128
+ })
129
+ .finally(() => {
130
+ this.discovering = undefined
131
+ })
132
+ this.discovering = task
133
+ }
134
+ return this.discovering
135
+ }
136
+
137
+ /**
138
+ * 执行一次能力调用:**MCP 优先,CLI 兜底**。
139
+ *
140
+ * 兜底只在 MCP 会话本身不可用时触发——工具失败已由服务端表示为错误结果并
141
+ * 转成异常,绝不重放(写操作重放会造成重复记账)。
142
+ */
143
+ async invoke(
144
+ entry: CapabilityEntry,
145
+ args: Record<string, unknown>,
146
+ options: InvokeOptions,
147
+ ): Promise<unknown> {
148
+ if (this.session?.connected === true) {
149
+ return await this.session.callTool(entry.name, args, options)
150
+ }
151
+ if (entry.cli !== undefined && this.cliInvoker !== undefined) {
152
+ this.logger.warn(`Actual MCP 会话不可用,降级为 CLI 直连执行:${entry.name}`)
153
+ return await this.cliInvoker(entry, args, options)
154
+ }
155
+ throw new Error(
156
+ `Actual MCP 不可达且该能力无 CLI 回退:${entry.name}(${this.lastError ?? '未连接'})`,
157
+ )
158
+ }
159
+
160
+ /** 释放会话、重连调度与订阅。 */
161
+ dispose(): void {
162
+ this.disposed = true
163
+ this.reconnect.stop()
164
+ this.listeners.clear()
165
+ const session = this.session
166
+ this.session = undefined
167
+ if (session !== undefined) void session.close().catch(() => undefined)
168
+ }
169
+
170
+ /** 一次完整发现:CLI help 树 → MCP 服务端 → tools/list → 交叉校验 → 过滤 → 发布。 */
171
+ private async runDiscover(): Promise<ActualManifest> {
172
+ const found = await discover(this.config.cli, this.deps, {})
173
+ this.binding = found.binding
174
+ this.cliVersion = found.binding.version
175
+ this.serverVersion = found.serverVersion
176
+ for (const warning of found.warnings) this.logger.warn(`Actual 能力派生告警:${warning}`)
177
+ this.assertVersionPolicy()
178
+
179
+ const cli = new ActualCli(found.binding, this.config.cli, this.deps)
180
+ const invoker = createEntryInvoker(cli, this.deps, found.tree)
181
+ this.cliInvoker = invoker
182
+ const { entries, drift, source } = await this.entriesFrom(found, invoker)
183
+ const manifest: ActualManifest = {
184
+ source,
185
+ cliVersion: this.cliVersion,
186
+ serverVersion: this.serverVersion,
187
+ entries: this.filter(entries),
188
+ drift,
189
+ }
190
+ this.publish(manifest)
191
+ return manifest
192
+ }
193
+
194
+ /**
195
+ * 能力目录:MCP tools/list 为主源,会话建不起来时降级为 CLI help 树目录。
196
+ *
197
+ * 降级只是**目录来源**的变化,执行面仍是同一条 CLI 链路,因此可用性不受影响;
198
+ * 目录里始终附带 `cli` 执行计划,兜底与策略层都靠它。
199
+ */
200
+ private async entriesFrom(
201
+ found: Awaited<ReturnType<typeof discover>>,
202
+ invoker: EntryInvoker,
203
+ ): Promise<{
204
+ entries: CapabilityEntry[]
205
+ drift: ActualManifest['drift']
206
+ source: ActualManifest['source']
207
+ }> {
208
+ try {
209
+ await this.replaceSession(found.entries, invoker)
210
+ const tools = await this.requireSession().listTools()
211
+ const { entries, unmapped } = mapTools(tools, found.entries)
212
+ const drift = computeDrift(entries, found.tree.families, unmapped)
213
+ if (drift.cliOnly.length > 0 || drift.unmapped.length > 0) {
214
+ this.logger.info(
215
+ `Actual 能力交叉校验:MCP-only=${drift.mcpOnly.join(',') || '-'} ` +
216
+ `CLI-only=${drift.cliOnly.join(',') || '-'} 无 CLI 回退=${drift.unmapped.join(',') || '-'}`,
217
+ )
218
+ }
219
+ return { entries, drift, source: 'mixed' }
220
+ } catch (error) {
221
+ this.logger.warn(`Actual MCP 会话不可用,降级为 CLI help 树目录:${messageOf(error)}`)
222
+ await this.closeSession()
223
+ return {
224
+ entries: found.entries,
225
+ drift: computeDrift([], found.tree.families, []),
226
+ source: 'cli',
227
+ }
228
+ }
229
+ }
230
+
231
+ /** 关闭当前会话(降级与换代都先关旧)。 */
232
+ private async closeSession(): Promise<void> {
233
+ const stale = this.session
234
+ this.session = undefined
235
+ if (stale !== undefined) await stale.close().catch(() => undefined)
236
+ }
237
+
238
+ /** 版本策略:mismatch 时告警或(strict)硬失败,绝不静默带过。 */
239
+ private assertVersionPolicy(): void {
240
+ this.version = versionStatusOf(this.cliVersion, this.serverVersion)
241
+ if (this.version !== 'mismatch') return
242
+ const detail = `Actual CLI ${this.cliVersion} 与服务端 ${this.serverVersion} 的 major.minor 不一致`
243
+ if (this.config.cliVersionPolicy === 'strict') {
244
+ throw new Error(
245
+ `${detail}(cliVersionPolicy=strict)。请对齐两侧版本,` +
246
+ '或用 cliCommand 指定匹配的 CLI,或把 cliVersionPolicy 改为 warn。',
247
+ )
248
+ }
249
+ this.logger.warn(
250
+ `${detail}。Actual 服务端只做 CRDT 同步中继、预算逻辑全在客户端,通常仍可工作;` +
251
+ '若需硬失败请设 cliVersionPolicy=strict。',
252
+ )
253
+ }
254
+
255
+ /** 换代 MCP 会话(旧会话先关,避免同代工具目录残留)。 */
256
+ private async replaceSession(
257
+ entries: readonly CapabilityEntry[],
258
+ invoke: EntryInvoker,
259
+ ): Promise<void> {
260
+ await this.closeSession()
261
+ const session = this.deps.createSession(entries, invoke)
262
+ await session.connect(MCP_CONNECT_TIMEOUT_MS)
263
+ this.session = session
264
+ }
265
+
266
+ /** 已连接的会话;未连接属于编程错误。 */
267
+ private requireSession(): McpSessionLike {
268
+ if (this.session === undefined) throw new Error('Actual MCP 会话未建立')
269
+ return this.session
270
+ }
271
+
272
+ /** 暴露过滤:allow 白名单(空=全量)后剔除 deny。 */
273
+ private filter(entries: CapabilityEntry[]): CapabilityEntry[] {
274
+ return entries.filter(
275
+ (entry) =>
276
+ (this.config.allow.length === 0 || this.config.allow.includes(entry.name)) &&
277
+ !this.config.deny.includes(entry.name),
278
+ )
279
+ }
280
+
281
+ /** 发布新清单(签名不变则跳过),成功发现即重置重连预算。 */
282
+ private publish(manifest: ActualManifest): void {
283
+ const signature = JSON.stringify(manifest)
284
+ if (this.manifestCache !== undefined && JSON.stringify(this.manifestCache) === signature) return
285
+ this.manifestCache = manifest
286
+ this.reconnect.reset()
287
+ this.lastError = undefined
288
+ for (const cb of this.listeners) {
289
+ try {
290
+ cb(manifest)
291
+ } catch (error) {
292
+ this.logger.warn(`Actual 清单订阅者异常:${messageOf(error)}`)
293
+ }
294
+ }
295
+ }
296
+
297
+ /** 脱敏:错误文本中出现的密钥一律替换为 ***。 */
298
+ private sanitize(message: string): string {
299
+ return sanitize(message, this.config.cli)
300
+ }
301
+ }
302
+
303
+ /**
304
+ * MCP `tools/list` → 能力条目:按裸族名回填 `cli` 执行计划与只读注解,
305
+ * 回填不到计划的记入 `unmapped`(交叉校验会报告)。
306
+ */
307
+ function mapTools(
308
+ tools: readonly Pick<CapabilityEntry, 'name' | 'description' | 'inputSchema'>[],
309
+ derivedEntries: readonly CapabilityEntry[],
310
+ ): { entries: CapabilityEntry[]; unmapped: string[] } {
311
+ const derived = new Map(derivedEntries.map((entry) => [entry.name, entry]))
312
+ const unmapped: string[] = []
313
+ const entries: CapabilityEntry[] = tools.map((tool) => {
314
+ const source = derived.get(tool.name)
315
+ const entry: CapabilityEntry = {
316
+ name: tool.name,
317
+ description: tool.description,
318
+ inputSchema: tool.inputSchema,
319
+ source: 'mcp',
320
+ }
321
+ if (source?.cli !== undefined) entry.cli = source.cli
322
+ else unmapped.push(tool.name)
323
+ if (source?.readOnly === true) entry.readOnly = true
324
+ return entry
325
+ })
326
+ return { entries, unmapped }
327
+ }
328
+
329
+ /** 提取错误文本(异常或任意值)。 */
330
+ function messageOf(error: unknown): string {
331
+ return error instanceof Error ? error.message : String(error)
332
+ }
package/src/service.ts ADDED
@@ -0,0 +1,166 @@
1
+ /**
2
+ * 主插件服务(宿主 realm,键 `actual`):把配置 + 真实 I/O 装配成
3
+ * {@link ActualRuntime},对子插件暴露发现/执行/状态/订阅的薄委托。
4
+ *
5
+ * 配置为 loader 解析的 volatile 活动字段:`current()` 现取即热(配置卡片写入
6
+ * 用户层后无需 /reload)。**连接相关字段变化时重建运行时**——CLI 绑定、服务端
7
+ * 地址、预算与超时都参与 CLI 进程的构造,只换值不重建会让新旧配置混用;重建
8
+ * 后立即按新配置重新发现。纯展示/策略字段(name/description/order/persona 等)
9
+ * 不触发重建,它们在下一次预设注册或工具注册时自然生效。
10
+ *
11
+ * 密钥只经子进程环境变量下发;日志与状态均经脱敏。数据目录默认落在插件数据
12
+ * 目录(与用户自有 CLI 的 `~/.actual-cli/data` 隔离,避免缓存与迁移互相踩)。
13
+ * @module @dsh-plus/actual/service
14
+ */
15
+ import type { Context } from '@deepseek-ai/cordis'
16
+ import { Service } from '@deepseek-ai/cordis'
17
+ import {
18
+ type ActualManifest,
19
+ type ActualStatus,
20
+ type CapabilityEntry,
21
+ type CliConfig,
22
+ createNodeDeps,
23
+ } from '@dsh-plus/actual-mcp'
24
+ import { pluginDataPath, unwrapVolatile } from '@dsh-plus/shared'
25
+
26
+ import type { ActualConfig, ActualConfigFields } from './config.ts'
27
+ import { createInProcessSession } from './mcp-session.ts'
28
+ import {
29
+ ActualRuntime,
30
+ type InvokeOptions,
31
+ type RuntimeConfig,
32
+ type RuntimeDeps,
33
+ } from './runtime.ts'
34
+
35
+ /** 参与运行时构造的字段子集:变化即重建(其余字段热取即可)。 */
36
+ function cliSignatureOf(config: ActualConfig): string {
37
+ return JSON.stringify([
38
+ config.cliCommand,
39
+ config.serverUrl,
40
+ config.password,
41
+ config.sessionToken,
42
+ config.encryptionPassword,
43
+ config.syncId,
44
+ config.dataDir,
45
+ config.cacheTtl,
46
+ config.lockTimeout,
47
+ config.cliVersionPolicy,
48
+ config.allow,
49
+ config.deny,
50
+ config.toolCallTimeoutMs,
51
+ ])
52
+ }
53
+
54
+ /**
55
+ * `ctx.actual` 服务:预设子插件(及测试)消费的唯一入口。
56
+ * 构造即建运行时;fiber 卸载经 dispose 释放会话与重连定时器。
57
+ */
58
+ export class ActualService extends Service {
59
+ private runtime: ActualRuntime
60
+ private detach: () => void
61
+ private signature: string
62
+ private readonly listeners = new Set<(manifest: ActualManifest) => void>()
63
+ private readonly current: () => ActualConfig
64
+ private readonly deps: RuntimeDeps
65
+ private readonly logger: ReturnType<Context['logger']>
66
+
67
+ constructor(ctx: Context, config: ActualConfig | ActualConfigFields) {
68
+ super(ctx, 'actual')
69
+ // 0.1.7 起配置为活动引用:现取即热,替代按需重建之外的任何缓存。
70
+ this.current = () => unwrapVolatile(config) as ActualConfig
71
+ this.logger = ctx.logger('actual')
72
+ this.deps = {
73
+ ...createNodeDeps(),
74
+ createSession: (entries, invoke) =>
75
+ createInProcessSession(entries, invoke, this.current().toolCallTimeoutMs),
76
+ }
77
+ this.signature = cliSignatureOf(this.current())
78
+ const built = this.build()
79
+ this.runtime = built.runtime
80
+ this.detach = built.detach
81
+ ctx.events.on('loader/volatile-update', () => this.rebuildIfStale())
82
+ }
83
+
84
+ /** 按当前配置装配一个运行时,并把清单变更转发给服务级订阅者。 */
85
+ private build(): { runtime: ActualRuntime; detach: () => void } {
86
+ const config = this.current()
87
+ const cli: CliConfig = {
88
+ cliCommand: [...config.cliCommand],
89
+ serverUrl: config.serverUrl,
90
+ password: config.password,
91
+ sessionToken: config.sessionToken,
92
+ syncId: config.syncId,
93
+ dataDir: config.dataDir !== '' ? config.dataDir : pluginDataPath('actual', 'cli-data'),
94
+ encryptionPassword: config.encryptionPassword,
95
+ cacheTtl: config.cacheTtl,
96
+ lockTimeout: config.lockTimeout,
97
+ }
98
+ const runtimeConfig: RuntimeConfig = {
99
+ cli,
100
+ allow: [...config.allow],
101
+ deny: [...config.deny],
102
+ cliVersionPolicy: config.cliVersionPolicy,
103
+ toolCallTimeoutMs: config.toolCallTimeoutMs,
104
+ }
105
+ const runtime = new ActualRuntime(runtimeConfig, this.deps, this.logger)
106
+ const detach = runtime.onChange((manifest) => {
107
+ for (const listener of [...this.listeners]) listener(manifest)
108
+ })
109
+ return { runtime, detach }
110
+ }
111
+
112
+ /** 连接相关字段变化 → 重建运行时并按新配置重新发现。 */
113
+ private rebuildIfStale(): void {
114
+ const next = cliSignatureOf(this.current())
115
+ if (next === this.signature) return
116
+ this.signature = next
117
+ const stale = this.runtime
118
+ const staleDetach = this.detach
119
+ const built = this.build()
120
+ this.runtime = built.runtime
121
+ this.detach = built.detach
122
+ stale.dispose()
123
+ staleDetach()
124
+ this.logger.info('Actual 连接配置已更新,运行时已重建')
125
+ void this.runtime.discover().catch((error: unknown) => {
126
+ const detail = error instanceof Error ? error.message : String(error)
127
+ this.logger.warn(`按新配置重新发现失败:${detail}`)
128
+ })
129
+ }
130
+
131
+ /** 触发一次能力发现(单飞;失败抛错,由调用方告警处理)。 */
132
+ discover(): Promise<ActualManifest> {
133
+ return this.runtime.discover()
134
+ }
135
+
136
+ /** 最近一次成功发现的清单。 */
137
+ manifest(): ActualManifest | undefined {
138
+ return this.runtime.manifest()
139
+ }
140
+
141
+ /** 执行一次能力调用(MCP 优先,CLI 兜底)。 */
142
+ invoke(
143
+ entry: CapabilityEntry,
144
+ args: Record<string, unknown>,
145
+ options: InvokeOptions,
146
+ ): Promise<unknown> {
147
+ return this.runtime.invoke(entry, args, options)
148
+ }
149
+
150
+ /** 连接与发现状态(已脱敏)。 */
151
+ status(): ActualStatus {
152
+ return this.runtime.status()
153
+ }
154
+
155
+ /** 订阅清单变更(跨运行时重建仍有效),返回退订。 */
156
+ onChange(cb: (manifest: ActualManifest) => void): () => void {
157
+ this.listeners.add(cb)
158
+ return () => this.listeners.delete(cb)
159
+ }
160
+
161
+ dispose(): void {
162
+ this.listeners.clear()
163
+ this.detach()
164
+ this.runtime.dispose()
165
+ }
166
+ }