agentworkshop 0.3.0 → 0.4.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.
@@ -1,5 +1,5 @@
1
1
  // ============================================================
2
- // AgentWorkShop 插件宿主 —— 发现 / 装载 / 生命周期 / 路由表
2
+ // AgentWorkShop 插件宿主 —— 发现 / 装载 / 启停状态 / 热重载 / 路由表
3
3
  // ------------------------------------------------------------
4
4
  // 目录(与 aw commands 同哲学):
5
5
  // project: <repo>/.AgentWorkShop/plugins/<name>/index.mjs
@@ -7,10 +7,15 @@
7
7
  // 契约:入口导出普通对象 { name, version?, description?, setup(ctx)?,
8
8
  // client?: './client.mjs', routes?: [{method,path,handler}] }
9
9
  // —— ctx 由宿主注入,插件运行时零导入依赖(sdk/ 供类型与显式糖)。
10
+ //
11
+ // 启停状态机:配置根 plugins-state.json { version, updatedAt, disabled: string[] }
12
+ // · 装载时跳过 disabled 插件(manifest 仍可见,enabled:false)
13
+ // · 状态文件变化(fs.watch)→ 热重载:全部 dispose/解绑 → 重新装载
14
+ // · CLI(aw plugin enable/disable) 与 Web 设置页均只写状态文件,服务自感知
10
15
  // 错误隔离:单插件装载/执行失败记入 failures,绝不拖垮主服务。
11
16
  // ============================================================
12
- import { existsSync, readdirSync, readFileSync, watch } from 'node:fs'
13
- import { join, resolve } from 'node:path'
17
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, watch, writeFileSync } from 'node:fs'
18
+ import { dirname, join, resolve } from 'node:path'
14
19
  import { pathToFileURL } from 'node:url'
15
20
  import { HookBus, createPluginContext, createRouteTable, validatePluginModule } from '@/sdk/index.mjs'
16
21
 
@@ -18,6 +23,7 @@ const g = globalThis
18
23
 
19
24
  function log() {
20
25
  return {
26
+ debug: (...a) => console.log('[aw-plugins][debug]', ...a),
21
27
  info: (...a) => console.log('[aw-plugins]', ...a),
22
28
  warn: (...a) => console.warn('[aw-plugins]', ...a),
23
29
  error: (...a) => console.error('[aw-plugins]', ...a),
@@ -35,6 +41,7 @@ function modePaths(cwd) {
35
41
  return {
36
42
  projectDir: isRepo ? join(cwd, '.AgentWorkShop', 'plugins') : null,
37
43
  userDir: join(process.env.AW_HOME && String(process.env.AW_HOME).trim() ? String(process.env.AW_HOME).trim() : defaultHome(), 'plugins'),
44
+ homeDir: process.env.AW_HOME && String(process.env.AW_HOME).trim() ? String(process.env.AW_HOME).trim() : defaultHome(),
38
45
  }
39
46
  }
40
47
 
@@ -42,6 +49,29 @@ function pathToUrl(p) {
42
49
  return pathToFileURL(resolve(p)).href
43
50
  }
44
51
 
52
+ // ---- 启停状态(单一事实源:<配置根>/plugins-state.json;CLI/Web/宿主三方读写) ----
53
+ export function statePathFor(homeDir) {
54
+ return join(homeDir, 'plugins-state.json')
55
+ }
56
+
57
+ export function readDisabledSet(homeDir) {
58
+ try {
59
+ const j = JSON.parse(readFileSync(statePathFor(homeDir), 'utf8'))
60
+ return new Set(Array.isArray(j.disabled) ? j.disabled : [])
61
+ }
62
+ catch {
63
+ return new Set()
64
+ }
65
+ }
66
+
67
+ export function writeDisabledSet(homeDir, disabled) {
68
+ const p = statePathFor(homeDir)
69
+ mkdirSync(dirname(p), { recursive: true })
70
+ const tmp = `${p}.${process.pid}.tmp`
71
+ writeFileSync(tmp, `${JSON.stringify({ version: 1, updatedAt: new Date().toISOString(), disabled: [...disabled] }, null, 2)}\n`, 'utf8')
72
+ renameSync(tmp, p)
73
+ }
74
+
45
75
  /** 发现两个作用域下的插件入口(project 同名覆盖 user) */
46
76
  export function discoverPluginDirs(cwd = process.cwd()) {
47
77
  const { projectDir, userDir } = modePaths(cwd)
@@ -62,12 +92,11 @@ export function discoverPluginDirs(cwd = process.cwd()) {
62
92
 
63
93
  /**
64
94
  * 装载插件宿主(idempotent;nitro 启动期调用一次)。
65
- * 装载 = 动态 import 入口 → 形态校验 → createPluginContext → setup(ctx)
66
- * → 收集 routes/client → emit plugin:host:init
67
95
  */
68
96
  export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {}) {
69
97
  if (g.__awPluginHost) return g.__awPluginHost
70
98
  const logger = log()
99
+ const homeDir = modePaths(cwd).homeDir
71
100
  const host = {
72
101
  bus: new HookBus({
73
102
  name: 'aw-plugins',
@@ -75,17 +104,20 @@ export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {})
75
104
  }),
76
105
  routes: createRouteTable(),
77
106
  plugins: new Map(),
78
- disposables: new Map(), // name → fn[](setup 内 ctx.onDispose 登记;server:close 时回收)
107
+ disposables: new Map(), // name → fn[](ctx.onDispose 登记)
108
+ hookOffs: new Map(), // name → off[](ctx.hooks.on 登记;热重载时解绑)
79
109
  failures: [],
80
110
  initedAt: null,
111
+ cwd,
112
+ packageRoot,
81
113
  logger,
82
114
  }
83
115
  g.__awPluginHost = host
84
116
 
85
- // 有效配置(只读面;引擎/模式解析从运行根动态加载,js-yaml 由对应 node_modules 解析)
117
+ // 有效配置(只读面;引擎/模式解析从运行根动态加载)
86
118
  let config = null
87
119
  let settingsPath = null
88
- let paths = { home: defaultHome(), configRoot: join(cwd, '.AgentWorkShop'), dataDir: join(cwd, '.AgentWorkShop', 'data') }
120
+ let paths = { home: homeDir, configRoot: join(cwd, '.AgentWorkShop'), dataDir: join(cwd, '.AgentWorkShop', 'data') }
89
121
  try {
90
122
  const homeMod = await import(pathToUrl(join(cwd, 'shared', 'config', 'home.mjs')))
91
123
  const rm = homeMod.resolveRunMode({ cwd, packageRoot, env: process.env })
@@ -102,8 +134,9 @@ export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {})
102
134
  host.logger.warn('配置引擎加载降级(插件 ctx.config 将为空):', err?.message)
103
135
  }
104
136
  host.config = config
105
- // 自环 origin:PORT env(prod) > argv --port(dev:dev-guard 转发链天然携带) > 配置 dev 端口;
106
- // nitro listen 钩子(setSelfOrigin)仍保留为最终兜底
137
+
138
+ // 自环 origin:PORT env(prod:start.mjs 注入 / dev:dev-guard 注入 CLI 显式值)权威;
139
+ // nitro listen 钩子兜底回填
107
140
  const argPort = (() => {
108
141
  const argv = process.argv
109
142
  const i = argv.indexOf('--port')
@@ -117,38 +150,21 @@ export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {})
117
150
  host.logger.info(`自环 origin 就绪: ${selfOrigin}`)
118
151
  }
119
152
  host.selfOrigin = () => selfOrigin
120
- host.logger.info(`自环 origin: ${selfOrigin} (PORT=${process.env.PORT ?? '∅'} NITRO_PORT=${process.env.NITRO_PORT ?? '∅'} argPort=${argPort ?? '∅'})`)
153
+ host.logger.info(`自环 origin: ${selfOrigin}`)
121
154
 
122
- // 配置变更监听:runtime-settings.json 变化 原地刷新 effective + config:changed 钩子
123
- if (settingsPath && existsSync(settingsPath)) {
124
- try {
125
- let debounce = null
126
- watch(settingsPath, () => {
127
- clearTimeout(debounce)
128
- debounce = setTimeout(async () => {
129
- try {
130
- if (host.config && existsSync(settingsPath)) {
131
- const homeMod = await import(pathToUrl(join(cwd, 'shared', 'config', 'home.mjs')))
132
- const rm = homeMod.resolveRunMode({ cwd, packageRoot, env: process.env })
133
- const engine = await import(pathToUrl(join((rm.mode === 'repo' ? rm.root : packageRoot ?? rm.root) ?? cwd, 'shared', 'config', 'engine.mjs')))
134
- const fresh = engine.loadEffective({ configPath: rm.configPath, settingsPath: rm.settingsPath, env: process.env })
135
- Object.assign(host.config.effective, fresh.effective)
136
- host.config.sources = fresh.sources
137
- }
138
- await host.bus.emit('config:changed', { at: new Date().toISOString() })
139
- host.logger.info('配置变更已广播(config:changed)')
140
- }
141
- catch (err) {
142
- host.logger.warn('config:changed 处理失败:', err?.message)
143
- }
144
- }, 300)
145
- })
146
- }
147
- catch { /* fs.watch 不可用时插件可经轮询自行感知 */ }
148
- }
155
+ await loadAllPlugins(host, { config, settingsPath, paths })
156
+ ensureStateWatcher(host)
157
+ return host
158
+ }
149
159
 
150
- const entries = discoverPluginDirs(cwd)
151
- if (entries.length) host.logger.info(`发现 ${entries.length} 个插件,开始装载 ...`)
160
+ /** 装载/重载全部插件(跳过 disabled;manifest 仍可见) */
161
+ async function loadAllPlugins(host, { config, paths }) {
162
+ const homeDir = modePaths(host.cwd).homeDir
163
+ const disabled = readDisabledSet(homeDir)
164
+ host.disabledSet = disabled
165
+
166
+ const entries = discoverPluginDirs(host.cwd)
167
+ if (entries.length) host.logger.info(`发现 ${entries.length} 个插件(停用 ${disabled.size}),开始装载 ...`)
152
168
 
153
169
  for (const { dir, scope } of entries) {
154
170
  const entry = join(dir, 'index.mjs')
@@ -159,6 +175,22 @@ export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {})
159
175
  const def = check.def
160
176
 
161
177
  if (host.plugins.has(def.name)) throw new Error(`插件重名(后装载者跳过): ${def.name}`)
178
+ if (disabled.has(def.name)) {
179
+ host.plugins.set(def.name, {
180
+ name: def.name,
181
+ version: String(def.version ?? '0.0.0'),
182
+ description: String(def.description ?? ''),
183
+ scope,
184
+ dir,
185
+ entry,
186
+ clientPath: def.client ? resolve(dir, def.client) : null,
187
+ routes: [],
188
+ enabled: false,
189
+ error: null,
190
+ })
191
+ host.logger.info(`⊘ 跳过(已停用) [${scope}] ${def.name}`)
192
+ continue
193
+ }
162
194
 
163
195
  const rec = {
164
196
  name: def.name,
@@ -169,6 +201,8 @@ export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {})
169
201
  entry,
170
202
  clientPath: def.client ? resolve(dir, def.client) : null,
171
203
  routes: [],
204
+ enabled: true,
205
+ error: null,
172
206
  }
173
207
 
174
208
  const emitter = {
@@ -178,17 +212,33 @@ export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {})
178
212
  },
179
213
  }
180
214
  const perPluginDisposables = []
215
+ const hookOffs = []
181
216
  host.disposables.set(def.name, perPluginDisposables)
217
+ host.hookOffs.set(def.name, hookOffs)
218
+ const scopedHooks = {
219
+ on: (t, fn) => {
220
+ const off = host.bus.on(t, fn)
221
+ hookOffs.push(off)
222
+ return off
223
+ },
224
+ once: (t, fn) => {
225
+ const off = host.bus.once(t, fn)
226
+ hookOffs.push(off)
227
+ return off
228
+ },
229
+ off: (t, fn) => host.bus.off(t, fn),
230
+ emit: (t, p) => host.bus.emit(t, p),
231
+ }
182
232
  const ctx = createPluginContext({
183
233
  name: def.name,
184
234
  scope,
185
235
  dir,
186
- hooks: host.bus,
236
+ hooks: scopedHooks,
187
237
  logger: {
188
- debug: (...a) => logger.info(`[${def.name}][debug]`, ...a),
189
- info: (...a) => logger.info(`[${def.name}]`, ...a),
190
- warn: (...a) => logger.warn(`[${def.name}]`, ...a),
191
- error: (...a) => logger.error(`[${def.name}]`, ...a),
238
+ debug: (...a) => host.logger.info(`[${def.name}][debug]`, ...a),
239
+ info: (...a) => host.logger.info(`[${def.name}]`, ...a),
240
+ warn: (...a) => host.logger.warn(`[${def.name}]`, ...a),
241
+ error: (...a) => host.logger.error(`[${def.name}]`, ...a),
192
242
  },
193
243
  config,
194
244
  paths,
@@ -197,10 +247,9 @@ export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {})
197
247
  perPluginDisposables.push(fn)
198
248
  return fn
199
249
  },
200
- selfOrigin: host.selfOrigin,
250
+ selfOrigin: () => selfOriginRef(host),
201
251
  })
202
252
 
203
- // 声明式 routes + setup 内 ctx.route() 两种形态都支持
204
253
  for (const r of (Array.isArray(def.routes) ? def.routes : [])) {
205
254
  emitter.registerRoute(def.name, r.method ?? 'GET', r.path, r.handler)
206
255
  }
@@ -217,10 +266,87 @@ export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {})
217
266
 
218
267
  host.initedAt = new Date().toISOString()
219
268
  await host.bus.emit('plugin:host:init', { plugins: [...host.plugins.keys()], failures: host.failures.length })
220
- host.logger.info(`装载完成: ${host.plugins.size} 成功 / ${host.failures.length} 失败 / 路由 ${host.routes.size} 条`)
269
+ host.logger.info(`装载完成: ${host.plugins.size} 个(含停用)/ ${host.failures.length} 失败 / 活跃路由 ${host.routes.size} 条`)
270
+ }
271
+
272
+ function selfOriginRef(host) {
273
+ return host.selfOrigin()
274
+ }
275
+
276
+ /** 热重载:全部 dispose/解绑 → 重新装载(跳过停用)→ 广播 plugins.reloaded(并发合并) */
277
+ export async function reloadPluginHost() {
278
+ const host = getPluginHost()
279
+ if (!host) return null
280
+ if (host.reloadInFlight) return host.reloadInFlight
281
+ host.reloadInFlight = doReload(host).finally(() => {
282
+ host.reloadInFlight = null
283
+ })
284
+ return host.reloadInFlight
285
+ }
286
+
287
+ async function doReload(host) {
288
+ host.logger.info('热重载插件宿主 ...')
289
+ for (const [name, list] of host.disposables ?? []) {
290
+ for (const fn of list.splice(0)) {
291
+ try {
292
+ await fn()
293
+ }
294
+ catch (err) {
295
+ host.logger.warn(`[${name}] onDispose 失败:`, err?.message)
296
+ }
297
+ }
298
+ }
299
+ for (const offs of (host.hookOffs ?? new Map()).values()) {
300
+ for (const off of offs.splice(0)) {
301
+ try {
302
+ off()
303
+ }
304
+ catch { /* 解绑失败忽略 */ }
305
+ }
306
+ }
307
+ host.plugins.clear()
308
+ host.routes = createRouteTable()
309
+ host.failures = []
310
+ await loadAllPlugins(host, { config: host.config, settingsPath: null, paths: { home: modePaths(host.cwd).homeDir, configRoot: join(host.cwd, '.AgentWorkShop'), dataDir: join(host.cwd, '.AgentWorkShop', 'data') } })
311
+ await host.bus.emit('plugins:reloaded', { plugins: pluginManifest() })
312
+ try {
313
+ const m = await import('@/server/services/workshop/scene-events')
314
+ m.broadcastSceneEvent('plugins.reloaded', { plugins: pluginManifest() })
315
+ }
316
+ catch { /* 广播失败不影响重载 */ }
221
317
  return host
222
318
  }
223
319
 
320
+ /** 启停单插件(写状态文件;调用方随后 reloadPluginHost 或由 state watcher 触发) */
321
+ export function setPluginEnabled(name, enabled) {
322
+ const host = getPluginHost()
323
+ const homeDir = modePaths(host?.cwd ?? process.cwd()).homeDir
324
+ const set = readDisabledSet(homeDir)
325
+ if (enabled) set.delete(name)
326
+ else set.add(name)
327
+ writeDisabledSet(homeDir, set)
328
+ return [...set]
329
+ }
330
+
331
+ /** 状态文件监视 → 热重载(CLI/Web 只写文件,服务自感知) */
332
+ function ensureStateWatcher(host) {
333
+ const statePath = statePathFor(modePaths(host.cwd).homeDir)
334
+ try {
335
+ mkdirSync(dirname(statePath), { recursive: true })
336
+ if (!existsSync(statePath)) writeDisabledSet(modePaths(host.cwd).homeDir, new Set())
337
+ let debounce = null
338
+ watch(statePath, () => {
339
+ clearTimeout(debounce)
340
+ debounce = setTimeout(() => {
341
+ void reloadPluginHost()
342
+ }, 400)
343
+ })
344
+ }
345
+ catch (err) {
346
+ host.logger.warn('状态文件监视不可用(启停需手动重启):', err?.message)
347
+ }
348
+ }
349
+
224
350
  /** 单例访问(未初始化返回 null —— 桥接点据此快速 no-op) */
225
351
  export function getPluginHost() {
226
352
  return g.__awPluginHost ?? null
@@ -256,7 +382,7 @@ export function readClientScript(name) {
256
382
  return { status: 200, code: readFileSync(rec.clientPath, 'utf8'), contentType: 'text/javascript; charset=utf-8' }
257
383
  }
258
384
 
259
- /** 清单(非敏感只读:名称/版本/作用域/描述/路由/是否有客户端) */
385
+ /** 清单(非敏感只读;含启停状态与路由) */
260
386
  export function pluginManifest() {
261
387
  const host = getPluginHost()
262
388
  if (!host) return []
@@ -265,8 +391,10 @@ export function pluginManifest() {
265
391
  version: r.version,
266
392
  description: r.description,
267
393
  scope: r.scope,
394
+ enabled: r.enabled !== false,
268
395
  hasClient: Boolean(r.clientPath),
269
396
  routes: host.routes.byPlugin(r.name),
397
+ error: r.error,
270
398
  }))
271
399
  }
272
400