agentworkshop 0.2.1 → 0.3.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 (34) hide show
  1. package/app/plugins/aw-plugins.client.ts +79 -0
  2. package/cli/commands/plugin.mjs +169 -0
  3. package/cli/commands/update.mjs +12 -4
  4. package/docs/plugins.md +127 -0
  5. package/package.json +19 -2
  6. package/scripts/dev-guard.mjs +13 -0
  7. package/scripts/home-bootstrap.mjs +25 -1
  8. package/sdk/api.mjs +101 -0
  9. package/sdk/client.mjs +120 -0
  10. package/sdk/context.mjs +219 -0
  11. package/sdk/examples/sample-insight/client.mjs +23 -0
  12. package/sdk/examples/sample-insight/index.mjs +39 -0
  13. package/sdk/hooks.mjs +103 -0
  14. package/sdk/index.d.mts +135 -0
  15. package/sdk/index.mjs +41 -0
  16. package/sdk/lifecycle.mjs +25 -0
  17. package/server/api/plugins/[name]/[...path].ts +22 -0
  18. package/server/api/plugins/client/[name].get.ts +18 -0
  19. package/server/api/plugins/manifest.get.ts +10 -0
  20. package/server/api/workshop/dcw/lines/[id]/start.post.ts +2 -0
  21. package/server/api/workshop/dcw/lines/[id]/stop.post.ts +2 -0
  22. package/server/data/daqs.json +347 -61
  23. package/server/data/dcw-lines.json +91 -0
  24. package/server/data/dcw-products.json +77 -0
  25. package/server/data/dcw-recipes.json +220 -0
  26. package/server/data/dcw-rollback.json +1 -1
  27. package/server/data/dcw-runs.json +275 -198
  28. package/server/data/dcws.json +231 -0
  29. package/server/data/line-runs.json +74 -14
  30. package/server/plugins/aw-plugins.ts +24 -0
  31. package/server/services/workshop/daq/daq-controller.ts +6 -2
  32. package/server/services/workshop/dcw/dcw-controller.ts +3 -0
  33. package/server/services/workshop/plugins/host.mjs +289 -0
  34. package/server/services/workshop/scene-events.ts +3 -0
@@ -0,0 +1,289 @@
1
+ // ============================================================
2
+ // AgentWorkShop 插件宿主 —— 发现 / 装载 / 生命周期 / 路由表
3
+ // ------------------------------------------------------------
4
+ // 目录(与 aw commands 同哲学):
5
+ // project: <repo>/.AgentWorkShop/plugins/<name>/index.mjs
6
+ // user: ~/.AgentWorkShop/plugins/<name>/index.mjs(同名 project 优先)
7
+ // 契约:入口导出普通对象 { name, version?, description?, setup(ctx)?,
8
+ // client?: './client.mjs', routes?: [{method,path,handler}] }
9
+ // —— ctx 由宿主注入,插件运行时零导入依赖(sdk/ 供类型与显式糖)。
10
+ // 错误隔离:单插件装载/执行失败记入 failures,绝不拖垮主服务。
11
+ // ============================================================
12
+ import { existsSync, readdirSync, readFileSync, watch } from 'node:fs'
13
+ import { join, resolve } from 'node:path'
14
+ import { pathToFileURL } from 'node:url'
15
+ import { HookBus, createPluginContext, createRouteTable, validatePluginModule } from '@/sdk/index.mjs'
16
+
17
+ const g = globalThis
18
+
19
+ function log() {
20
+ return {
21
+ info: (...a) => console.log('[aw-plugins]', ...a),
22
+ warn: (...a) => console.warn('[aw-plugins]', ...a),
23
+ error: (...a) => console.error('[aw-plugins]', ...a),
24
+ }
25
+ }
26
+
27
+ function defaultHome() {
28
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? ''
29
+ return join(home, '.AgentWorkShop')
30
+ }
31
+
32
+ /** 运行模式路径(cwd 为检出根时启用 project 作用域) */
33
+ function modePaths(cwd) {
34
+ const isRepo = existsSync(join(cwd, 'config.yml')) && existsSync(join(cwd, 'nuxt.config.ts'))
35
+ return {
36
+ projectDir: isRepo ? join(cwd, '.AgentWorkShop', 'plugins') : null,
37
+ userDir: join(process.env.AW_HOME && String(process.env.AW_HOME).trim() ? String(process.env.AW_HOME).trim() : defaultHome(), 'plugins'),
38
+ }
39
+ }
40
+
41
+ function pathToUrl(p) {
42
+ return pathToFileURL(resolve(p)).href
43
+ }
44
+
45
+ /** 发现两个作用域下的插件入口(project 同名覆盖 user) */
46
+ export function discoverPluginDirs(cwd = process.cwd()) {
47
+ const { projectDir, userDir } = modePaths(cwd)
48
+ const out = []
49
+ const seen = new Set()
50
+ for (const [dir, scope] of [[projectDir, 'project'], [userDir, 'user']]) {
51
+ if (!dir || !existsSync(dir)) continue
52
+ for (const name of readdirSync(dir)) {
53
+ const sub = join(dir, name)
54
+ if (!existsSync(join(sub, 'index.mjs'))) continue
55
+ if (seen.has(name)) continue
56
+ seen.add(name)
57
+ out.push({ dir: sub, scope })
58
+ }
59
+ }
60
+ return out.sort((a, b) => a.dir.localeCompare(b.dir))
61
+ }
62
+
63
+ /**
64
+ * 装载插件宿主(idempotent;nitro 启动期调用一次)。
65
+ * 装载 = 动态 import 入口 → 形态校验 → createPluginContext → setup(ctx)
66
+ * → 收集 routes/client → emit plugin:host:init
67
+ */
68
+ export async function initPluginHost({ cwd = process.cwd(), packageRoot } = {}) {
69
+ if (g.__awPluginHost) return g.__awPluginHost
70
+ const logger = log()
71
+ const host = {
72
+ bus: new HookBus({
73
+ name: 'aw-plugins',
74
+ onError: (err, meta) => logger.warn(`钩子错误(${meta?.type}):`, err?.message ?? err),
75
+ }),
76
+ routes: createRouteTable(),
77
+ plugins: new Map(),
78
+ disposables: new Map(), // name → fn[](setup 内 ctx.onDispose 登记;server:close 时回收)
79
+ failures: [],
80
+ initedAt: null,
81
+ logger,
82
+ }
83
+ g.__awPluginHost = host
84
+
85
+ // 有效配置(只读面;引擎/模式解析从运行根动态加载,js-yaml 由对应 node_modules 解析)
86
+ let config = null
87
+ let settingsPath = null
88
+ let paths = { home: defaultHome(), configRoot: join(cwd, '.AgentWorkShop'), dataDir: join(cwd, '.AgentWorkShop', 'data') }
89
+ try {
90
+ const homeMod = await import(pathToUrl(join(cwd, 'shared', 'config', 'home.mjs')))
91
+ const rm = homeMod.resolveRunMode({ cwd, packageRoot, env: process.env })
92
+ const engineRoot = rm.mode === 'repo' ? rm.root : (packageRoot ?? rm.root)
93
+ if (engineRoot && existsSync(join(engineRoot, 'shared', 'config', 'engine.mjs'))) {
94
+ const engine = await import(pathToUrl(join(engineRoot, 'shared', 'config', 'engine.mjs')))
95
+ config = engine.loadEffective({ configPath: rm.configPath, settingsPath: rm.settingsPath, env: process.env })
96
+ settingsPath = rm.settingsPath
97
+ }
98
+ paths = { home: rm.home, configRoot: rm.configRoot, dataDir: rm.dataDir }
99
+ host.logger.info(`配置根: ${rm.configRoot} (${rm.mode} 模式)`)
100
+ }
101
+ catch (err) {
102
+ host.logger.warn('配置引擎加载降级(插件 ctx.config 将为空):', err?.message)
103
+ }
104
+ host.config = config
105
+ // 自环 origin:PORT env(prod) > argv --port(dev:dev-guard 转发链天然携带) > 配置 dev 端口;
106
+ // nitro listen 钩子(setSelfOrigin)仍保留为最终兜底
107
+ const argPort = (() => {
108
+ const argv = process.argv
109
+ const i = argv.indexOf('--port')
110
+ if (i >= 0 && argv[i + 1]) return argv[i + 1]
111
+ const eq = argv.find(a => a.startsWith('--port='))
112
+ return eq ? eq.slice(7) : null
113
+ })()
114
+ let selfOrigin = `http://127.0.0.1:${process.env.PORT ?? process.env.NITRO_PORT ?? argPort ?? config?.effective?.['server.dev.port'] ?? 3000}`
115
+ host.setSelfOrigin = (port) => {
116
+ selfOrigin = `http://127.0.0.1:${port}`
117
+ host.logger.info(`自环 origin 就绪: ${selfOrigin}`)
118
+ }
119
+ host.selfOrigin = () => selfOrigin
120
+ host.logger.info(`自环 origin: ${selfOrigin} (PORT=${process.env.PORT ?? '∅'} NITRO_PORT=${process.env.NITRO_PORT ?? '∅'} argPort=${argPort ?? '∅'})`)
121
+
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
+ }
149
+
150
+ const entries = discoverPluginDirs(cwd)
151
+ if (entries.length) host.logger.info(`发现 ${entries.length} 个插件,开始装载 ...`)
152
+
153
+ for (const { dir, scope } of entries) {
154
+ const entry = join(dir, 'index.mjs')
155
+ try {
156
+ const mod = await import(pathToUrl(entry))
157
+ const check = validatePluginModule(mod, entry)
158
+ if (!check.ok) throw new Error(check.error)
159
+ const def = check.def
160
+
161
+ if (host.plugins.has(def.name)) throw new Error(`插件重名(后装载者跳过): ${def.name}`)
162
+
163
+ const rec = {
164
+ name: def.name,
165
+ version: String(def.version ?? '0.0.0'),
166
+ description: String(def.description ?? ''),
167
+ scope,
168
+ dir,
169
+ entry,
170
+ clientPath: def.client ? resolve(dir, def.client) : null,
171
+ routes: [],
172
+ }
173
+
174
+ const emitter = {
175
+ registerRoute: (n, m, p, h) => {
176
+ host.routes.register(n, m, p, h)
177
+ rec.routes.push({ method: String(m).toUpperCase(), path: p })
178
+ },
179
+ }
180
+ const perPluginDisposables = []
181
+ host.disposables.set(def.name, perPluginDisposables)
182
+ const ctx = createPluginContext({
183
+ name: def.name,
184
+ scope,
185
+ dir,
186
+ hooks: host.bus,
187
+ 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),
192
+ },
193
+ config,
194
+ paths,
195
+ emitter,
196
+ onDispose: (fn) => {
197
+ perPluginDisposables.push(fn)
198
+ return fn
199
+ },
200
+ selfOrigin: host.selfOrigin,
201
+ })
202
+
203
+ // 声明式 routes + setup 内 ctx.route() 两种形态都支持
204
+ for (const r of (Array.isArray(def.routes) ? def.routes : [])) {
205
+ emitter.registerRoute(def.name, r.method ?? 'GET', r.path, r.handler)
206
+ }
207
+ await def.setup?.(ctx)
208
+
209
+ host.plugins.set(def.name, rec)
210
+ host.logger.info(`✔ 已装载 [${scope}] ${def.name}@${rec.version}${rec.clientPath ? ' (+client)' : ''}`)
211
+ }
212
+ catch (err) {
213
+ host.failures.push({ source: entry, error: err?.message ?? String(err) })
214
+ host.logger.error(`装载失败 ${entry}:`, err?.message ?? err)
215
+ }
216
+ }
217
+
218
+ host.initedAt = new Date().toISOString()
219
+ 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} 条`)
221
+ return host
222
+ }
223
+
224
+ /** 单例访问(未初始化返回 null —— 桥接点据此快速 no-op) */
225
+ export function getPluginHost() {
226
+ return g.__awPluginHost ?? null
227
+ }
228
+
229
+ /** scene-events 桥:全部无频道实时事件 → 插件 event:<type> */
230
+ export function emitPluginEvent(type, payload) {
231
+ void g.__awPluginHost?.bus.emit(`event:${type}`, payload)
232
+ }
233
+
234
+ /** DAQ 下发级采样钩子(与 WS daq.reading 同点、同节拍语义) */
235
+ export function emitDaqSample(payload) {
236
+ void g.__awPluginHost?.bus.emit('daq:sample', payload)
237
+ }
238
+
239
+ /** 写控 ACK 后观察钩子 */
240
+ export function emitDcwWrite(payload) {
241
+ void g.__awPluginHost?.bus.emit('dcw:write', payload)
242
+ }
243
+
244
+ /** 产线启停钩子 */
245
+ export function emitLineLifecycle(kind, payload) {
246
+ void g.__awPluginHost?.bus.emit(kind, payload)
247
+ }
248
+
249
+ /** 客户端脚本读取(免鉴权只读端点用;越界路径拒绝) */
250
+ export function readClientScript(name) {
251
+ const host = getPluginHost()
252
+ const rec = host?.plugins.get(name)
253
+ if (!host || !rec) return { status: 404 }
254
+ if (!rec.clientPath || !existsSync(rec.clientPath)) return { status: 404 }
255
+ if (!resolve(rec.clientPath).startsWith(resolve(rec.dir))) return { status: 400 }
256
+ return { status: 200, code: readFileSync(rec.clientPath, 'utf8'), contentType: 'text/javascript; charset=utf-8' }
257
+ }
258
+
259
+ /** 清单(非敏感只读:名称/版本/作用域/描述/路由/是否有客户端) */
260
+ export function pluginManifest() {
261
+ const host = getPluginHost()
262
+ if (!host) return []
263
+ return [...host.plugins.values()].map(r => ({
264
+ name: r.name,
265
+ version: r.version,
266
+ description: r.description,
267
+ scope: r.scope,
268
+ hasClient: Boolean(r.clientPath),
269
+ routes: host.routes.byPlugin(r.name),
270
+ }))
271
+ }
272
+
273
+ /** 关机钩子(nitro close 时调用):先逐插件回收订阅/定时器,再广播 server:close */
274
+ export async function shutdownPluginHost() {
275
+ const host = getPluginHost()
276
+ if (!host) return
277
+ for (const [name, list] of host.disposables ?? []) {
278
+ for (const fn of list.splice(0)) {
279
+ try {
280
+ await fn()
281
+ }
282
+ catch (err) {
283
+ host.logger.warn(`[${name}] onDispose 失败:`, err?.message)
284
+ }
285
+ }
286
+ }
287
+ await host.bus.emit('server:close', { at: new Date().toISOString() })
288
+ host.logger.info('插件清理完成,已发出 server:close')
289
+ }
@@ -8,6 +8,7 @@
8
8
  * nitro 打包的循环初始化(TDZ)问题。
9
9
  */
10
10
  import type { AepEnvelope } from '../../../shared/workshop-protocol'
11
+ import { emitPluginEvent } from './plugins/host.mjs'
11
12
 
12
13
  const AEP_VERSION = 1
13
14
 
@@ -42,6 +43,8 @@ export function sceneEventPeerCount(): number {
42
43
  * 信封序列化一次、全 peer 复用:dag 遥测经此出口 N 节点×P 页面/秒高频扇出,
43
44
  * per-peer 重复 stringify 是纯浪费。 */
44
45
  export function broadcastSceneEvent(type: string, payload: unknown): void {
46
+ // 插件宿主事件桥(event:<type> 钩子;宿主未装载时 no-op)——先于 peers 短路,插件不依赖在线页面
47
+ emitPluginEvent(type, payload)
45
48
  if (peers().size === 0) return
46
49
  const e: AepEnvelope = {
47
50
  v: AEP_VERSION,