agentworkshop 0.2.2 → 0.4.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 (40) hide show
  1. package/LICENSE +91 -0
  2. package/README-zh.md +431 -424
  3. package/README.md +10 -3
  4. package/app/components/AppSidebar.vue +1 -0
  5. package/app/pages/plugins/index.vue +258 -0
  6. package/app/plugins/aw-plugins.client.ts +81 -48
  7. package/cli/commands/plugin.mjs +57 -7
  8. package/docs/plugins.md +133 -63
  9. package/docs/sdk.md +265 -0
  10. package/i18n/locales/en.ts +17 -0
  11. package/i18n/locales/zh-CN.ts +17 -0
  12. package/package.json +19 -2
  13. package/scripts/_dbg-docs-site-shot.mjs +33 -0
  14. package/scripts/dev-guard.mjs +13 -0
  15. package/scripts/home-bootstrap.mjs +26 -1
  16. package/sdk/api.mjs +101 -0
  17. package/sdk/client.mjs +56 -8
  18. package/sdk/context.mjs +56 -15
  19. package/sdk/examples/line-sentinel/client.mjs +31 -0
  20. package/sdk/examples/line-sentinel/index.mjs +102 -0
  21. package/sdk/examples/ops-notifier/client.mjs +23 -0
  22. package/sdk/examples/ops-notifier/index.mjs +34 -0
  23. package/sdk/index.d.mts +135 -0
  24. package/sdk/index.mjs +5 -18
  25. package/sdk/lifecycle.mjs +25 -0
  26. package/server/api/plugins/[name]/[...path].ts +5 -2
  27. package/server/api/workshop/plugins/[name]/disable.post.ts +19 -0
  28. package/server/api/workshop/plugins/[name]/enable.post.ts +19 -0
  29. package/server/api/workshop/plugins/[name]/index.get.ts +14 -0
  30. package/server/api/workshop/plugins/index.get.ts +17 -0
  31. package/server/data/daqs.json +260 -62
  32. package/server/data/dcw-lines.json +63 -0
  33. package/server/data/dcw-products.json +49 -0
  34. package/server/data/dcw-recipes.json +140 -0
  35. package/server/data/dcw-rollback.json +1 -1
  36. package/server/data/dcw-runs.json +175 -126
  37. package/server/data/dcws.json +147 -0
  38. package/server/data/line-runs.json +85 -15
  39. package/server/plugins/aw-plugins.ts +7 -1
  40. package/server/services/workshop/plugins/host.mjs +212 -18
@@ -7,7 +7,7 @@
7
7
  // 原则:幂等(已存在绝不覆盖)、零依赖、任何失败只告警不阻断安装。
8
8
  // ============================================================
9
9
  import { randomBytes } from 'node:crypto'
10
- import { copyFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
10
+ import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
11
11
  import { homedir } from 'node:os'
12
12
  import { dirname, join, resolve } from 'node:path'
13
13
  import { fileURLToPath } from 'node:url'
@@ -121,6 +121,31 @@ export function runBootstrap({ quiet = false, env = process.env } = {}) {
121
121
  seeds.push('plugins/README.md')
122
122
  }
123
123
 
124
+ // 8. 官方示例插件随包分发(sdk/examples/*):只复制目标缺失的目录,绝不覆盖用户改动;
125
+ // 新种子的示例默认**停用**(写入 plugins-state.json),经 aw plugin enable 开启
126
+ const examplesSrc = join(packageRoot, 'sdk', 'examples')
127
+ if (existsSync(examplesSrc)) {
128
+ const pluginsDir2 = join(home, 'plugins')
129
+ const stateFile = join(home, 'plugins-state.json')
130
+ let state = { version: 1, updatedAt: new Date().toISOString(), disabled: [] }
131
+ try {
132
+ state = JSON.parse(readFileSync(stateFile, 'utf8'))
133
+ }
134
+ catch { /* 首次无状态文件 */ }
135
+ state.disabled ??= []
136
+ for (const example of readdirSync(examplesSrc)) {
137
+ const dest = join(pluginsDir2, example)
138
+ if (existsSync(join(dest, 'index.mjs'))) continue // 已存在(或用户改过)——不动
139
+ cpSync(join(examplesSrc, example), dest, { recursive: true })
140
+ if (!state.disabled.includes(example)) state.disabled.push(example) // 默认停用
141
+ seeds.push(`plugins/${example}(默认停用)`)
142
+ }
143
+ state.updatedAt = new Date().toISOString()
144
+ const stateTmp = `${stateFile}.${process.pid}.tmp`
145
+ writeFileSync(stateTmp, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
146
+ renameSync(stateTmp, stateFile)
147
+ }
148
+
124
149
  log(`[aw-home] ${home} ${created.length ? `(新建 ${created.length} 项)` : '(已就绪)'}`)
125
150
  if (seeds.length) log(`[aw-home] 种子文件: ${seeds.join(', ')}`)
126
151
  return { home, created, seeds }
package/sdk/api.mjs ADDED
@@ -0,0 +1,101 @@
1
+ // ============================================================
2
+ // AgentWorkShop SDK — 平台 REST 客户端
3
+ // ------------------------------------------------------------
4
+ // SDK 作为项目服务的 client:对平台 REST 面(/api/**)的类型化轻封装。
5
+ // 三个使用形态:
6
+ // 1) 插件内: ctx.api.lines.list() —— 宿主注入(自环 origin)
7
+ // 2) 外部脚本: createPlatformClient({ baseUrl, token }).daq.nodes()
8
+ // 3) 浏览器: createPlatformClient({ baseUrl: '' }) 同源相对路径
9
+ // 资源方法返回原始 JSON body(data 字段已解包);4xx/5xx 抛错(含 status)。
10
+ // ============================================================
11
+
12
+ export function createPlatformClient({ baseUrl = '', token, logger, timeoutMs = 10000 } = {}) {
13
+ // baseUrl 支持函数(延迟解析):插件宿主的监听端口在 nitro listen 钩子后才确定
14
+ const resolveBase = () => String(typeof baseUrl === 'function' ? baseUrl() : (baseUrl ?? '')).replace(/\/+$/, '')
15
+ let authToken = token ?? null
16
+
17
+ async function call(method, path, body, opt = {}) {
18
+ const base = resolveBase()
19
+ const headers = { accept: 'application/json', ...(opt.headers ?? {}) }
20
+ if (authToken) headers.authorization = `Bearer ${authToken}`
21
+ if (body !== undefined) headers['content-type'] = 'application/json'
22
+ const res = await fetch(`${base}${path}`, {
23
+ method,
24
+ headers,
25
+ body: body !== undefined ? JSON.stringify(body) : undefined,
26
+ signal: AbortSignal.timeout(opt.timeoutMs ?? timeoutMs),
27
+ })
28
+ const json = await res.json().catch(() => null)
29
+ if (!res.ok) {
30
+ const err = new Error(json?.message ?? `HTTP ${res.status} ${path}`)
31
+ err.status = res.status
32
+ err.body = json
33
+ if (logger) logger.warn(`API ${method} ${path} → ${res.status}`)
34
+ throw err
35
+ }
36
+ // 平台统一信封 { code, message, data } → 解包 data;非信封原样返回
37
+ return json && typeof json === 'object' && 'data' in json ? json.data : json
38
+ }
39
+
40
+ const resource = root => ({
41
+ list: (query = {}) => call('GET', `${root}${toQuery(query)}`),
42
+ get: id => call('GET', `${root}/${id}`),
43
+ create: body => call('POST', root, body),
44
+ update: (id, patch) => call('PATCH', `${root}/${id}`, patch),
45
+ remove: id => call('DELETE', `${root}/${id}`),
46
+ })
47
+
48
+ const client = {
49
+ /** 底层调用(任意平台路径;自动带 token 与信封解包) */
50
+ call,
51
+ get: (path, query = {}) => call('GET', `${path}${toQuery(query)}`),
52
+ post: (path, body) => call('POST', path, body),
53
+ patch: (path, body) => call('PATCH', path, body),
54
+ delete: path => call('DELETE', path),
55
+ setToken: (t) => {
56
+ authToken = t ?? null
57
+ return client
58
+ },
59
+ /** 平台健康(免鉴权) */
60
+ ping: () => call('GET', '/api/plugins/manifest'),
61
+
62
+ // ---- 业务资源面 ----
63
+ users: {
64
+ ...resource('/api/users'),
65
+ login: (email, password) => call('POST', '/api/users/login', { email, password }),
66
+ me: () => call('GET', '/api/users/me'),
67
+ },
68
+ lines: {
69
+ ...resource('/api/workshop/dcw/lines'),
70
+ start: (id, recipeId = '') => call('POST', `/api/workshop/dcw/lines/${id}/start`, { recipeId }),
71
+ stop: id => call('POST', `/api/workshop/dcw/lines/${id}/stop`),
72
+ },
73
+ products: resource('/api/workshop/dcw/products'),
74
+ recipes: resource('/api/workshop/dcw/recipes'),
75
+ dcwNodes: resource('/api/workshop/dcw'),
76
+ daqNodes: {
77
+ ...resource('/api/workshop/daq'),
78
+ alarms: () => call('GET', '/api/workshop/daq/alarms'),
79
+ },
80
+ templates: {
81
+ daq: () => call('GET', '/api/workshop/daq').then(d => d?.templates ?? []),
82
+ dcw: () => call('GET', '/api/workshop/dcw').then(d => d?.templates ?? []),
83
+ },
84
+ twins: resource('/api/workshop/device-twins'),
85
+ teams: resource('/api/workshop/teams'),
86
+ agents: resource('/api/workshop/agents'),
87
+ channels: resource('/api/workshop/channels'),
88
+ plugins: {
89
+ manifest: () => call('GET', '/api/plugins/manifest'),
90
+ },
91
+ }
92
+ return client
93
+ }
94
+
95
+ function toQuery(query) {
96
+ const entries = Object.entries(query ?? {}).filter(([, v]) => v !== undefined && v !== null && v !== '')
97
+ if (!entries.length) return ''
98
+ return `?${new URLSearchParams(Object.fromEntries(entries.map(([k, v]) => [k, String(v)]))).toString()}`
99
+ }
100
+
101
+ export default createPlatformClient
package/sdk/client.mjs CHANGED
@@ -5,16 +5,18 @@
5
5
  // export function setup(ctx) { ctx.on('daq.reading', d => { ... }) }
6
6
  // ctx 形态(浏览器侧,自包含、零框架依赖):
7
7
  // ctx.name / ctx.sdkVersion
8
- // ctx.hooks 客户端本地 HookBus(client:init / event:* / page:change)
9
- // ctx.on(type, fn) scene 实时事件订阅(与 WS 同源;'*' 通配)
8
+ // ctx.hooks 客户端本地 HookBus(client:init / event:* / page:change / client:destroy)
9
+ // ctx.on(type, fn) scene 实时事件订阅(与 WS 同源;'*' 通配)——自动登记 pagehide 回收
10
+ // ctx.fetch(path, …) 同源平台 API 助手(JSON;自动解信封 data)
10
11
  // ctx.el(tag, attrs, children) DOM 助手(挂到任意面板/宿主节点)
11
12
  // ctx.mount(selector|el, node) 挂载节点(缺失时挂 body 角落)
12
- // ctx.log 前缀 console
13
13
  // ctx.root 插件 UI 挂载点(懒创建,自动附加到 body,#aw-plugin-<name>)
14
+ // ctx.log 前缀 console
15
+ // ctx.dispose() 卸载:清空挂载点 + 回收订阅 + 广播 client:destroy(pagehide 自动触发)
14
16
  // ============================================================
15
17
  import { HookBus } from './hooks.mjs'
16
18
 
17
- export const CLIENT_SDK_VERSION = '0.2.2'
19
+ export const CLIENT_SDK_VERSION = '0.3.0'
18
20
 
19
21
  function el(tag, attrs = {}, children = []) {
20
22
  const node = document.createElement(tag)
@@ -30,16 +32,40 @@ function el(tag, attrs = {}, children = []) {
30
32
  return node
31
33
  }
32
34
 
33
- export function createClientContext({ name, eventBridge }) {
35
+ export function createClientContext({ name, eventBridge, baseUrl = '' }) {
34
36
  const hooks = new HookBus({ name: `client:${name}`, onError: err => console.warn(`[aw-plugin:${name}]`, err) })
37
+ const disposables = []
35
38
  let rootEl = null
39
+ let disposed = false
36
40
 
37
41
  const ctx = {
38
42
  name,
39
43
  sdkVersion: CLIENT_SDK_VERSION,
40
44
  hooks,
41
- on: (type, fn) => (type === 'event:*' ? hooks.on('*', fn) : hooks.on(`event:${type}`, fn)),
42
- off: (type, fn) => (type === 'event:*' ? hooks.off('*', fn) : hooks.off(`event:${type}`, fn)),
45
+ on: (type, fn) => {
46
+ const off = type === 'event:*' ? hooks.on('*', fn) : hooks.on(`event:${type}`, fn)
47
+ disposables.push(off)
48
+ return off
49
+ },
50
+ /** 同源平台 API(JSON;自动解 {data} 信封;非 2xx 抛错;自动携带 cookie token) */
51
+ fetch: async (path, opt = {}) => {
52
+ const headers = { accept: 'application/json', ...(opt.body !== undefined ? { 'content-type': 'application/json' } : {}), ...(opt.headers ?? {}) }
53
+ // 自动注入 cookie 里的平台 token(Authorization: Bearer)——与全站 $http 拦截器同源
54
+ const mCookie = document.cookie.match(/(?:^|;\s*)token=([^;]+)/)
55
+ if (mCookie) headers.authorization = `Bearer ${decodeURIComponent(mCookie[1])}`
56
+ const res = await fetch(`${baseUrl}${path}`, {
57
+ headers,
58
+ method: opt.method ?? (opt.body !== undefined ? 'POST' : 'GET'),
59
+ body: opt.body !== undefined ? JSON.stringify(opt.body) : undefined,
60
+ })
61
+ const json = await res.json().catch(() => null)
62
+ if (!res.ok) {
63
+ const err = new Error(json?.message ?? `HTTP ${res.status} ${path}`)
64
+ err.status = res.status
65
+ throw err
66
+ }
67
+ return json && typeof json === 'object' && 'data' in json ? json.data : json
68
+ },
43
69
  el,
44
70
  log: {
45
71
  info: (...a) => console.info(`[aw-plugin:${name}]`, ...a),
@@ -61,13 +87,35 @@ export function createClientContext({ name, eventBridge }) {
61
87
  ;(host ?? ctx.root()).append(node)
62
88
  return node
63
89
  },
90
+ /** 卸载:回收订阅 + 清空挂载点 + 广播 client:destroy(幂等) */
91
+ dispose: () => {
92
+ if (disposed) return
93
+ disposed = true
94
+ void hooks.emit('client:destroy', { name })
95
+ for (const d of disposables.splice(0)) {
96
+ try {
97
+ d()
98
+ }
99
+ catch { /* 单个回收失败不阻断 */ }
100
+ }
101
+ rootEl?.remove()
102
+ rootEl = null
103
+ },
64
104
  }
65
105
 
66
106
  // scene 事件桥 → ctx.hooks(event:<type> 与 '*' 均可订阅)
67
107
  if (eventBridge) {
68
- eventBridge((type, payload) => {
108
+ const offBridge = eventBridge((type, payload) => {
69
109
  void hooks.emit(`event:${type}`, payload)
70
110
  })
111
+ disposables.push(offBridge)
112
+ }
113
+
114
+ // 页面卸载自动回收(pagehide 覆盖 bfcache 场景)
115
+ if (typeof document !== 'undefined') {
116
+ document.addEventListener('visibilitychange', () => {
117
+ if (document.visibilityState === 'hidden') ctx.dispose()
118
+ }, { once: true })
71
119
  }
72
120
 
73
121
  return ctx
package/sdk/context.mjs CHANGED
@@ -1,22 +1,26 @@
1
1
  // ============================================================
2
2
  // AgentWorkShop SDK — 服务端插件上下文工厂(宿主调用;插件经 setup(ctx) 获得)
3
3
  // ------------------------------------------------------------
4
- // ctx 形态:
5
- // ctx.name / ctx.scope('home'|'project') / ctx.dir 身份与目录
6
- // ctx.hooks HookBus 生命周期钩子(on/once/off/emit)
7
- // ctx.logger 前缀日志(info/warn/error/debug) — 插件名自动前缀
8
- // ctx.config { get(key), all() } — 有效配置只读(四层引擎)
9
- // ctx.dataDir / ctx.kv { get,set,all,bump } 插件私有持久化(json 落配置根 data/plugins/<name>)
10
- // ctx.route(method, path, handler) — 注册插件 API → /api/plugins/<name><path>
11
- // ctx.http { get,post } 带超时 fetch — 对外请求( http/https)
12
- // ctx.events { on(type,fn) } — scene 实时事件订阅(event:* 桥的糖)
13
- // ctx.paths { home, configRoot, dataDir } — 配置根信息
4
+ // ctx 形态(运行时完整变量面):
5
+ // 身份 ctx.name / ctx.scope('home'|'project') / ctx.dir / ctx.sdkVersion
6
+ // 钩子 ctx.hooks HookBus(生命周期 + event:*) [SDK]
7
+ // 日志 ctx.logger { debug, info, warn, error } 插件名前缀 [SDK]
8
+ // 配置 ctx.config { get(key), all(), onChange(fn) } [SDK]
9
+ // 存储 ctx.kv { get,set,all,bump } 内存态+防抖落盘 [SDK]
10
+ // 定时 ctx.timer { setInterval, setTimeout } 服务关闭自动回收 [SDK]
11
+ // 清理 ctx.onDispose(fn) / ctx.subscriptions [SDK]
12
+ // 路由 ctx.route(method, path, handler) /api/plugins/<name>… [SDK]
13
+ // 平台 ctx.api 平台 REST 客户端(lines/daq/dcw/twins/teams…) [SDK]
14
+ // 网络 ctx.http { get, post } 带超时 fetch(仅 http/https) [SDK]
15
+ // 事件 ctx.events { on(type,fn), off } scene 实时事件 [SDK]
16
+ // 路径 ctx.paths { home, configRoot, dataDir } [SDK]
14
17
  // ============================================================
15
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'node:fs'
16
- import { dirname, join, resolve } from 'node:path'
19
+ import { join, resolve } from 'node:path'
17
20
  import { HookBus } from './hooks.mjs'
21
+ import { createPlatformClient } from './api.mjs'
18
22
 
19
- export const SDK_VERSION = '0.2.2'
23
+ export const SDK_VERSION = '0.3.0'
20
24
 
21
25
  /** 允许的对外请求协议守卫(拒绝 file:/data: 等;宿主/插件同守此规则) */
22
26
  function safeUrl(raw, timeoutMs = 8000) {
@@ -28,7 +32,7 @@ function safeUrl(raw, timeoutMs = 8000) {
28
32
 
29
33
  /**
30
34
  * 创建服务端插件上下文。
31
- * @param {{ name, scope, dir, hooks, logger, config, paths, emitter }} opts 宿主装配
35
+ * @param {{ name, scope, dir, hooks, logger, config, paths, emitter, onDispose, selfOrigin }} opts 宿主装配
32
36
  */
33
37
  export function createPluginContext(opts) {
34
38
  const { name, scope, dir, hooks, config, paths, emitter } = opts
@@ -41,6 +45,29 @@ export function createPluginContext(opts) {
41
45
  ...(opts.logger ?? {}),
42
46
  }
43
47
 
48
+ // ---- 订阅回收(VSCode subscriptions 范式):服务关闭时宿主逐个调用 ----
49
+ const disposables = []
50
+ const onDispose = (fn) => {
51
+ if (typeof fn === 'function') disposables.push(fn)
52
+ return fn
53
+ }
54
+
55
+ // ---- 定时器:自动登记回收,杜绝插件定时器泄漏 ----
56
+ const timer = {
57
+ setInterval: (fn, ms, ...rest) => {
58
+ const id = setInterval(fn, ms, ...rest)
59
+ if (typeof id === 'object' && id !== null && 'unref' in id) id.unref?.()
60
+ onDispose(() => clearInterval(id))
61
+ return id
62
+ },
63
+ setTimeout: (fn, ms, ...rest) => {
64
+ const id = setTimeout(fn, ms, ...rest)
65
+ if (typeof id === 'object' && id !== null && 'unref' in id) id.unref?.()
66
+ onDispose(() => clearTimeout(id))
67
+ return id
68
+ },
69
+ }
70
+
44
71
  // ---- 插件私有持久化:内存态为准 + 200ms 防抖落盘 —— 高频钩子(daq:sample)
45
72
  // 与低频钩子(line:stop)并发时无 read-modify-write 竞态(JS 同步内存操作原子) ----
46
73
  const kvDir = join(paths.dataDir, 'plugins', name)
@@ -77,6 +104,8 @@ export function createPluginContext(opts) {
77
104
  config: {
78
105
  get: key => config?.effective?.[key],
79
106
  all: () => ({ ...config?.effective }),
107
+ /** 运行时覆盖变更订阅(runtime-settings.json 变化;宿主 fs.watch 驱动) */
108
+ onChange: fn => hooks.on('config:changed', fn),
80
109
  },
81
110
  paths: { ...paths },
82
111
  dataDir: kvDir,
@@ -94,7 +123,21 @@ export function createPluginContext(opts) {
94
123
  return kvState[key]
95
124
  },
96
125
  },
126
+ timer,
127
+ onDispose,
128
+ /** 订阅式清理对象({ dispose(){} })集中登记 */
129
+ subscriptions: {
130
+ add: (d) => {
131
+ disposables.push(typeof d === 'function' ? d : (...a) => d.dispose?.(...a))
132
+ return d
133
+ },
134
+ },
97
135
  route: (method, path, handler) => emitter?.registerRoute(name, method, path, handler),
136
+ /** 平台 REST 客户端(自环 origin 延迟解析;鉴权端点请 ctx.api.setToken(token)) */
137
+ api: createPlatformClient({
138
+ baseUrl: typeof opts.selfOrigin === 'function' ? opts.selfOrigin : () => opts.selfOrigin,
139
+ logger,
140
+ }),
98
141
  http: {
99
142
  get: (url, opts2 = {}) => fetch(url, { ...safeUrl(url, opts2.timeoutMs), ...opts2 }),
100
143
  post: (url, body, opts2 = {}) => fetch(url, {
@@ -166,8 +209,6 @@ export function pluginKvExists(dataDir, name) {
166
209
  return existsSync(join(resolve(dataDir), 'plugins', name, 'kv.json'))
167
210
  }
168
211
 
169
- export { dirname }
170
-
171
212
  export default {
172
213
  SDK_VERSION,
173
214
  HookBus,
@@ -0,0 +1,31 @@
1
+ // line-sentinel — 客户端徽标(实时显示采样计数与告警态)
2
+ export function setup(ctx) {
3
+ const badge = ctx.el('div', {
4
+ id: 'line-sentinel-badge',
5
+ style: 'display:flex;align-items:center;gap:8px;padding:8px 12px;'
6
+ + 'border:1px solid rgba(53,224,160,.5);border-radius:10px;'
7
+ + 'background:rgba(6,18,14,.85);color:#35e0a0;'
8
+ + 'font:600 12px/1 ui-monospace,monospace;letter-spacing:.4px;'
9
+ + 'box-shadow:0 4px 18px rgba(0,0,0,.35);cursor:default',
10
+ }, ['🛡 line-sentinel · 待机'])
11
+
12
+ ctx.root().append(badge)
13
+
14
+ let n = 0
15
+ let alarms = 0
16
+
17
+ ctx.on('daq:sample', () => {
18
+ n++
19
+ badge.textContent = `🛡 line-sentinel · ${n} 样本 · ${alarms} 告警`
20
+ })
21
+
22
+ // 服务端告警状态变化经 event 桥可见(ops.log 或轮询 stats;此处演示事件订阅)
23
+ ctx.on('event:line.start', () => {
24
+ badge.style.borderColor = '#35e0a0'
25
+ })
26
+ ctx.on('event:line.stop', () => {
27
+ badge.style.borderColor = 'rgba(53,224,160,.35)'
28
+ })
29
+
30
+ ctx.log.info('哨兵徽标已挂载(右下角)')
31
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * line-sentinel — 产线哨兵(真实场景插件)
3
+ * ------------------------------------------------------------
4
+ * 职责:持续监视运行中产线的数采样本,越过告警阈值即记录并在 API 暴露;
5
+ * 心跳定时器自证存活;监听产线启停与配置变更;展示 SDK 全部能力面。
6
+ *
7
+ * 使用 SDK 面:ctx.hooks · ctx.api(平台客户端) · ctx.timer(自动回收)
8
+ * · ctx.onDispose · ctx.kv · ctx.route · ctx.config.onChange
9
+ * · ctx.events · ctx.logger
10
+ */
11
+ export default {
12
+ name: 'line-sentinel',
13
+ version: '1.0.0',
14
+ description: '产线哨兵:数采越限告警 + 心跳 + 产线生命周期跟踪',
15
+ client: './client.mjs',
16
+
17
+ async setup(ctx) {
18
+ ctx.logger.info(`装载完成(scope=${ctx.scope}, sdk=${ctx.sdkVersion})`)
19
+
20
+ // 平台服务消费:启动时经 SDK API 客户端读取产线清单(自环调用)
21
+ try {
22
+ const lines = await ctx.api.lines.list()
23
+ ctx.kv.set('linesTotal', Array.isArray(lines) ? lines.length : 0)
24
+ ctx.logger.info(`平台产线清单: ${ctx.kv.get('linesTotal')} 条`)
25
+ }
26
+ catch (err) {
27
+ ctx.logger.warn('产线清单读取失败(服务启动中,跳过):', err?.message)
28
+ }
29
+
30
+ // 配置变更订阅(aw config set / 设置页写入 → runtime-settings.json 变化)
31
+ ctx.config.onChange(() => {
32
+ const theme = ctx.config.get('theme.primaryColor')
33
+ ctx.logger.info(`配置已变更,当前主题色: ${theme}`)
34
+ ctx.kv.set('lastConfigAt', new Date().toISOString())
35
+ })
36
+
37
+ // 产线生命周期跟踪(事件消费增强:运行中产线计数)
38
+ ctx.hooks.on('line:start', (p) => {
39
+ ctx.kv.set('running', true)
40
+ ctx.kv.bump('runningCount')
41
+ ctx.kv.set('lastRun', { lineId: p?.lineId, at: new Date().toISOString() })
42
+ ctx.logger.info(`▶ 产线开跑 ${p?.lineId}`)
43
+ })
44
+ ctx.hooks.on('line:stop', () => {
45
+ ctx.kv.set('running', false)
46
+ ctx.logger.info('■ 产线停止')
47
+ })
48
+
49
+ // 核心:数采样本越限告警(阈值可经插件 KV 配置,默认 180)
50
+ ctx.hooks.on('daq:sample', (s) => {
51
+ ctx.kv.bump('samples')
52
+ const threshold = Number(ctx.kv.get('threshold')) || 180
53
+ if (typeof s?.value === 'number' && s.value > threshold) {
54
+ const key = `alarm:${s.nodeId}`
55
+ const prev = ctx.kv.get(key) ?? { count: 0 }
56
+ ctx.kv.set(key, { count: (prev.count ?? 0) + 1, value: s.value, at: s.at })
57
+ if ((prev.count ?? 0) === 0) ctx.logger.warn(`⚠ 越限告警 ${s.nodeId}: ${s.value} > ${threshold}`)
58
+ }
59
+ })
60
+
61
+ // scene 实时事件订阅(糖衣)
62
+ ctx.events.on('daq.node.changed', (p) => {
63
+ ctx.logger.debug?.('节点变更', p?.op ?? '')
64
+ })
65
+
66
+ // 心跳定时器(服务关闭自动回收):活性自证经免鉴权 manifest ping 验证 REST 自环通道
67
+ ctx.timer.setInterval(() => {
68
+ ctx.kv.set('heartbeat', new Date().toISOString())
69
+ ctx.api.plugins.manifest()
70
+ .then(() => ctx.kv.set('apiOk', true))
71
+ .catch(() => ctx.kv.set('apiOk', false))
72
+ }, 5000)
73
+
74
+ // 清理登记(关停时宿主逐个调用)
75
+ ctx.onDispose(() => ctx.logger.info('哨兵清理:告警状态已随 KV 落盘'))
76
+
77
+ // 插件 API:综合报告
78
+ ctx.route('GET', '/report', () => {
79
+ const alarms = Object.entries(ctx.kv.all())
80
+ .filter(([k]) => k.startsWith('alarm:'))
81
+ .map(([k, v]) => ({ nodeId: k.slice(6), ...v }))
82
+ return {
83
+ plugin: ctx.name,
84
+ version: ctx.version,
85
+ running: ctx.kv.get('running') ?? false,
86
+ runningCount: ctx.kv.get('runningCount') ?? 0,
87
+ heartbeat: ctx.kv.get('heartbeat'),
88
+ apiChannel: ctx.kv.get('apiOk') === true ? 'ok' : ctx.kv.get('apiOk') === false ? 'down' : 'pending',
89
+ samplesWatched: ctx.kv.get('samples') ?? 0,
90
+ threshold: Number(ctx.kv.get('threshold')) || 180,
91
+ alarms,
92
+ }
93
+ })
94
+
95
+ ctx.route('POST', '/threshold', (event) => {
96
+ // 宿主 catchall 已预读 body 挂在 event.awBody
97
+ const v = Number(event.awBody?.threshold) || 180
98
+ ctx.kv.set('threshold', v)
99
+ return { ok: true, threshold: v }
100
+ })
101
+ },
102
+ }
@@ -0,0 +1,23 @@
1
+ // ops-notifier — 客户端通知(最近一次写控 toast)
2
+ export function setup(ctx) {
3
+ const toast = ctx.el('div', {
4
+ style: 'display:none;padding:10px 14px;border:1px solid rgba(244,197,66,.6);'
5
+ + 'border-radius:10px;background:rgba(20,16,4,.88);color:#f4c542;'
6
+ + 'font:600 12px/1.5 ui-monospace,monospace;box-shadow:0 4px 18px rgba(0,0,0,.35)',
7
+ })
8
+ ctx.root().prepend(toast)
9
+
10
+ let timer = null
11
+ ctx.on('dcw:write', (w) => {
12
+ toast.textContent = w.ok
13
+ ? `✔ 写入 ${w.name} → ${w.eng}${w.source ? `(${w.source})` : ''}`
14
+ : `✖ 写入失败 ${w.name}`
15
+ toast.style.display = 'block'
16
+ clearTimeout(timer)
17
+ timer = setTimeout(() => {
18
+ toast.style.display = 'none'
19
+ }, 4000)
20
+ })
21
+
22
+ ctx.log.info('写控通知已就绪')
23
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * ops-notifier — 写控与产线事件回声(SDK 示例插件)
3
+ * 滚动记录最近写控/启停事件(内存 + KV 防抖落盘),提供 /recent 查询 API;
4
+ * 客户端以 toast 展示最近一次写控。展示 dcw:write / line:* 钩子与事件消费。
5
+ */
6
+ const ROLL = 30
7
+
8
+ export default {
9
+ name: 'ops-notifier',
10
+ version: '1.0.0',
11
+ description: '写控与产线事件回声:滚动记录最近事件 + 查询 API + 客户端通知',
12
+ client: './client.mjs',
13
+
14
+ async setup(ctx) {
15
+ ctx.logger.info('已装载 —— 监听 dcw:write 与产线启停')
16
+
17
+ const push = (entry) => {
18
+ const log = ctx.kv.get('recent') ?? []
19
+ log.unshift(entry)
20
+ ctx.kv.set('recent', log.slice(0, ROLL))
21
+ }
22
+
23
+ ctx.hooks.on('dcw:write', (w) => {
24
+ push({ kind: 'write', nodeId: w.nodeId, name: w.name, eng: w.eng, ok: w.ok, source: w.source, at: w.at })
25
+ })
26
+ ctx.hooks.on('line:start', p => push({ kind: 'line:start', lineId: p?.lineId, at: new Date().toISOString() }))
27
+ ctx.hooks.on('line:stop', p => push({ kind: 'line:stop', lineId: p?.lineId, at: new Date().toISOString() }))
28
+
29
+ ctx.route('GET', '/recent', () => ({
30
+ plugin: ctx.name,
31
+ recent: ctx.kv.get('recent') ?? [],
32
+ }))
33
+ },
34
+ }