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.
- package/app/plugins/aw-plugins.client.ts +79 -0
- package/cli/commands/plugin.mjs +169 -0
- package/cli/commands/update.mjs +12 -4
- package/docs/plugins.md +127 -0
- package/package.json +19 -2
- package/scripts/dev-guard.mjs +13 -0
- package/scripts/home-bootstrap.mjs +25 -1
- package/sdk/api.mjs +101 -0
- package/sdk/client.mjs +120 -0
- package/sdk/context.mjs +219 -0
- package/sdk/examples/sample-insight/client.mjs +23 -0
- package/sdk/examples/sample-insight/index.mjs +39 -0
- package/sdk/hooks.mjs +103 -0
- package/sdk/index.d.mts +135 -0
- package/sdk/index.mjs +41 -0
- package/sdk/lifecycle.mjs +25 -0
- package/server/api/plugins/[name]/[...path].ts +22 -0
- package/server/api/plugins/client/[name].get.ts +18 -0
- package/server/api/plugins/manifest.get.ts +10 -0
- package/server/api/workshop/dcw/lines/[id]/start.post.ts +2 -0
- package/server/api/workshop/dcw/lines/[id]/stop.post.ts +2 -0
- package/server/data/daqs.json +347 -61
- package/server/data/dcw-lines.json +91 -0
- package/server/data/dcw-products.json +77 -0
- package/server/data/dcw-recipes.json +220 -0
- package/server/data/dcw-rollback.json +1 -1
- package/server/data/dcw-runs.json +275 -198
- package/server/data/dcws.json +231 -0
- package/server/data/line-runs.json +74 -14
- package/server/plugins/aw-plugins.ts +24 -0
- package/server/services/workshop/daq/daq-controller.ts +6 -2
- package/server/services/workshop/dcw/dcw-controller.ts +3 -0
- package/server/services/workshop/plugins/host.mjs +289 -0
- 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,
|