@mzzsfy/dsh-rs-workflow 0.2.4 → 1.0.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/README.md CHANGED
@@ -1,63 +1,28 @@
1
1
  # @mzzsfy/dsh-rs-workflow
2
2
 
3
- 若水工作流 (rs-workflow) 一体化插件:GUI 配置、模型工具与 agent preset(工作流协议技能、编排引擎、模式组合)全部在一个包里。
3
+ 若水工作流 dsh 插件——设置面:流程模板管理与工作位/预算配置。模板编辑(host dryRun 权威校验)、只读详情、模板规范、六工作位与三预算配置读写。
4
4
 
5
- ## 行角色
6
-
7
- | 行角色 | 所在平面 | 位置 | 作用 |
8
- |---|---|---|---|
9
- | `settings` | host | 包内 bundle patch(cordis.patch.yml) | 注册 settings 命名空间 `rs-workflow` → GUI 设置页出现表单(3 基础+13 细分工作位、默认模板、任务上限、预算(对齐 rs-tui 原始配置语义)),持久化于 `~/.dsh/settings.yaml` |
10
- | `preset-sync` | host | 同上 | 每次 dsh 启动把包内 `preset/rs-workflow` 幂等同步到用户预设根 → 模式选择器出现"若水工作流",升级包后重启即更新 |
11
- | `tool` | agent | 释放出的 preset 组合(agent.cordis.yml) | 注册模型工具 `rs_workflow_config` → 主代理启动工作流编排前读取当前配置;skills/rs-workflow 协议技能(SKILL.md + engine.js 编排引擎 + slots.json5 后备配置)随 preset 一起分发 |
12
-
13
- ## 安装
14
-
15
- ```sh
16
- dsh plugin --profile web add @mzzsfy/dsh-rs-workflow
17
- ```
18
-
19
- 重启 dsh 后三件事自动发生:设置页出现 rs-workflow 表单;preset 释放到 `<dsh-home>/.agent-presets/rs-workflow`(含来源标记 `.dsh-rs-workflow-source.json`);模式选择器出现"若水工作流"。无需手动编辑 cordis.patch.yml,无需手动拷贝 preset。
20
-
21
- preset 内 tool 行以裸包名引用本包(从 profile 目录上溯解析到 profile node_modules),包与 preset 始终由本包同时交付,不存在顺序问题。
5
+ **编排运行时(driver/store/flow-exec/释放链)按 v5 设计(`docs/rsww-v5/`)实现。**
22
6
 
23
- ## 升级
7
+ 模板文本为**严格 JSON**(有 GUI 编辑器,无 JSON5 需求);配置与模板存自有文件 `~/.dsh/dsh-rs-workflow/v5/`,**不经宿主 settings 服务,不写 settings.yaml**。
24
8
 
25
- 同一命令重复执行即升级到最新发布版本,重启 dsh 后 preset-sync 自动把新版 preset 同步到预设根。
26
-
27
- ## 卸载
9
+ ## 行角色
28
10
 
29
- ```sh
30
- dsh plugin --profile web remove @mzzsfy/dsh-rs-workflow # 或 pnpm remove(profile 内)
31
- rm -r ~/.dsh/.agent-presets/rs-workflow
32
- ```
11
+ | 行角色 | 作用 |
12
+ |---|---|
13
+ | `board` | `/api/rsww/*` 设置子域路由(模板 CRUD/校验/规范/配置读写),数据落 `~/.dsh/dsh-rs-workflow/v5/{templates,config}.json` |
33
14
 
34
- pnpm 不执行依赖的卸载脚本(preuninstall,pnpm 11 实测含 onlyBuiltDependencies 白名单均不放行),插件无法在自身被移除后自动清理,preset 残留会在模式选择器显示为 broken——按上面第二条命令手动删除即可。包内亦提供编程接口(从包根导入,exports 只开放 `.` 单一入口):
15
+ ## 设置页路由
35
16
 
36
- ```js
37
- import { presetDest, removePreset, syncPreset } from '@mzzsfy/dsh-rs-workflow'
38
- // presetDest(): 释放目标绝对路径(诊断 home 错位)
39
- // syncPreset(): 立即同步一次,返回 'created' | 'updated' | 'unchanged' | 'skipped-foreign'
40
- // removePreset(): 删除本包释放的 preset,返回 'removed' | 'missing' | 'foreign'
41
- ```
17
+ `/api/rsww/templates` `template` `template-save`(含 dryRun) `template-remove` `spec` `config` `config-save`
42
18
 
43
- ## 冒烟测试
19
+ ## 开发
44
20
 
45
21
  ```sh
46
- node scripts/test-workflow-plugin.mjs
22
+ node scripts/dev-link.mjs all # 挂 junction 开发态
23
+ node --test "test/*.test.mjs" # 包内测试(在 packages/dsh-rs-workflow 下)
47
24
  ```
48
25
 
49
- 默认测已安装副本,传入包目录路径可测任意构建;仓库内副本解析不了 peer 依赖(@deepseek-ai/* 从安装位置向上才找得到),需自备 node_modules。覆盖三个行角色:preset-sync(临时 DSH_HOME 内真实写盘 + 幂等)、settings(分层解析)、tool(执行路径)。
50
-
51
- ## 行引用方式说明
52
-
53
- - 预设组合行的裸包名从组合 baseUrl(profile 目录)上溯 node_modules 解析
54
- (`PresetTree.import` → `internal.import(specifier, baseUrl)`),profile
55
- node_modules 在解析链上,所以预设行用裸 scoped 包名。
56
- - host 补丁行(bundle patch)的裸包名从安装锚点 / profile node_modules 解析,
57
- 两条链一致,补丁行同样用裸包名。
58
- - preset 技能发现走 skill-filesystem 的 `customSkillDirs`,以组合 `baseUrl`
59
- (= 释放出的 preset 目录)定位 `skills/`,随 preset 同步走。
60
-
61
- ## License
26
+ ## dsh 版本兼容
62
27
 
63
- MIT
28
+ 三版本全部通过:preset 释放、Agent 预设页若水工作流卡、rs_workflow_ 工具可列出、激活 live。
package/cordis.patch.yml CHANGED
@@ -1,21 +1,12 @@
1
- # The rs-workflow bundle patch: registers the host-plane rows of the rs-workflow
2
- # all-in-one plugin into the profile root. Applied by
3
- # `dsh plugin add @mzzsfy/dsh-rs-workflow`:
4
- # - rs-workflow / role settings: mounts the GUI settings form under the
5
- # `rs-workflow` namespace in the settings page.
6
- # - rs-workflow-preset-sync / role preset-sync: syncs the bundled preset
7
- # (preset.yml + agent.cordis.yml + skills) into the user preset root on
8
- # every boot, so the picker entry "若水工作流" ships with the plugin and
9
- # follows plugin upgrades.
10
- # The tool-plane row (rs_workflow_config model tool) lives in the released
11
- # preset composition, not here: model-facing tools belong to the preset plane.
1
+ # rs-workflow bundle patch:把本包 host 平面行注册进 profile 根
2
+ # (`dsh plugin add @mzzsfy/dsh-rs-workflow` 应用):
3
+ # - rs-workflow-board / role board:挂载 /api/rsww/* 路由(设置页模板管理 + 配置)。
4
+ # 模板与配置存自有文件 ~/.dsh/dsh-rs-workflow/v5/,不经宿主 settings 服务,不写 settings.yaml。
5
+ # 编排运行时只存在于模式组合(preset/rs-workflow 骨架经 release 生成 rs-<id> 组合),
6
+ # 宿主根不注册 orchestrator 行——编排属于会话,不属于全局。
12
7
 
13
8
  - insert:
14
- - id: rs-workflow
9
+ - id: rs-workflow-board
15
10
  name: '@mzzsfy/dsh-rs-workflow'
16
11
  config:
17
- role: settings
18
- - id: rs-workflow-preset-sync
19
- name: '@mzzsfy/dsh-rs-workflow'
20
- config:
21
- role: preset-sync
12
+ role: board
package/lib/board.mjs ADDED
@@ -0,0 +1,355 @@
1
+ // board — /api/rsww/* 路由薄分发:数据权威态在 store 单例/自有文件存储(v5/{templates,config}.json)/driver 控制队列单例,路由无业务状态
2
+ // 运行时路由 v5 恢复:runs/run/control(approve|reject 增 by/reason)/resume-from(种子续跑,不拉段)/run-remove/release/unrelease/released
3
+ // 规划受理不经 HTTP:rs_workflow_start 是 orchestrator 工具行(见 feat/orchestrator.md)
4
+ import { reportStore, ACTIVE_STATES } from './store.mjs'
5
+ import { registry, post, initiatorOf, resumerOf } from './driver/control.mjs'
6
+ import { validateTemplate, validateTemplateSet } from './template.mjs'
7
+ import { releaseFlowTemplate, unreleaseFlowTemplate, releasedTemplateIds } from './release.mjs'
8
+ import { SPEC_TEXT } from './spec.mjs'
9
+ import { normalizeConfig, BUDGET_KEYS, SLOT_KEYS } from './settings-schema.mjs'
10
+ import { loadJson, saveJson } from './storage.mjs'
11
+
12
+ const BODY_MAX_BYTES = 256 * 1024
13
+
14
+ function sendJson(res, status, payload) {
15
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
16
+ res.end(JSON.stringify(payload))
17
+ }
18
+
19
+ function rejectCrossOrigin(req, res) {
20
+ const origin = req.headers ? req.headers.origin : undefined
21
+ if (!origin) return false
22
+ let sameOrigin = false
23
+ try {
24
+ sameOrigin = new URL(origin).host === req.headers.host
25
+ } catch {
26
+ sameOrigin = false
27
+ }
28
+ if (sameOrigin) return false
29
+ sendJson(res, 403, { error: '跨源请求被拒绝' })
30
+ return true
31
+ }
32
+
33
+ function rejectNonJson(req, res) {
34
+ const contentType = req.headers ? String(req.headers['content-type'] || '') : ''
35
+ if (contentType.includes('application/json')) return false
36
+ sendJson(res, 400, { error: 'content-type 须为 application/json' })
37
+ return true
38
+ }
39
+
40
+ function guardedRoute(handler) {
41
+ return async (req, res) => {
42
+ try {
43
+ if (req.method !== 'GET' && req.method !== 'POST') {
44
+ sendJson(res, 405, { error: 'method not allowed' })
45
+ return
46
+ }
47
+ if (req.method === 'POST') {
48
+ if (rejectCrossOrigin(req, res)) return
49
+ if (rejectNonJson(req, res)) return
50
+ }
51
+ await handler(req, res)
52
+ } catch (error) {
53
+ // errors 数组(逐条 target:message)随响应透传,GUI 编辑器据此逐条定位
54
+ const body = { error: error && error.message ? error.message : String(error) }
55
+ if (Array.isArray(error?.errors)) body.errors = error.errors
56
+ sendJson(res, 400, body)
57
+ }
58
+ }
59
+ }
60
+
61
+ // POST-only 变体供无读面的写路由使用:GET 放行会让跨站 <img src> 无守卫驱动改写
62
+ guardedRoute.post = (handler) => async (req, res) => {
63
+ if (req.method !== 'POST') {
64
+ sendJson(res, 405, { error: 'method not allowed' })
65
+ return
66
+ }
67
+ return guardedRoute(handler)(req, res)
68
+ }
69
+
70
+ function readJsonBody(req) {
71
+ return new Promise((resolve, reject) => {
72
+ let size = 0
73
+ const chunks = []
74
+ req.on('data', (chunk) => {
75
+ size += chunk.length
76
+ if (size > BODY_MAX_BYTES) {
77
+ reject(new Error('请求体超过上限'))
78
+ req.destroy()
79
+ return
80
+ }
81
+ chunks.push(chunk)
82
+ })
83
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
84
+ req.on('error', reject)
85
+ })
86
+ }
87
+
88
+ // ── 模板读写(自有文件存储) ──────────────────────────────────────────────────
89
+ function rawTemplates() {
90
+ return loadJson('templates.json', [])
91
+ }
92
+
93
+ function readTemplates() {
94
+ return rawTemplates()
95
+ }
96
+
97
+ function writeTemplates(templates) {
98
+ saveJson('templates.json', templates)
99
+ }
100
+
101
+ function removeTemplateById(id) {
102
+ const raw = rawTemplates()
103
+ const next = raw.filter((t) => t.id !== id)
104
+ if (next.length === raw.length) return { ok: false, error: '模板不存在: ' + id }
105
+ writeTemplates(next)
106
+ // 撤下释放物:删除模板即移除对应释放目录(外来目录返回 foreign 不动)
107
+ const outcome = unreleaseFlowTemplate(id)
108
+ return { ok: true, outcome }
109
+ }
110
+
111
+ function parseTemplateEntry(id, body) { const json = typeof body.json === 'string' ? body.json : ''
112
+ let parsed
113
+ try {
114
+ parsed = JSON.parse(json)
115
+ } catch (error) {
116
+ const err = new Error('模板 JSON 解析失败:' + (error?.message ?? error))
117
+ err.errors = [{ target: 'json', message: String(error?.message ?? error) }]
118
+ throw err
119
+ }
120
+ const errors = validateTemplate(parsed)
121
+ if (errors.length > 0) {
122
+ const err = new Error('流程模板校验失败:' + errors.length + ' 条')
123
+ err.errors = errors
124
+ throw err
125
+ }
126
+ if (parsed && typeof parsed.id === 'string' && parsed.id !== id) throw new Error(`id 不一致: 模板 "${id}" vs 定义 "${parsed.id}"`)
127
+ return { parsed, json }
128
+ }
129
+
130
+ function configSavePatch(body) {
131
+ const patch = {}
132
+ const problems = []
133
+ if (body.slots !== undefined) {
134
+ if (typeof body.slots !== 'object' || body.slots === null || Array.isArray(body.slots)) {
135
+ problems.push('slots 须为对象')
136
+ } else {
137
+ const slots = {}
138
+ for (const [key, value] of Object.entries(body.slots)) {
139
+ if (!SLOT_KEYS.includes(key)) {
140
+ problems.push(`未知槽位键:${key}`)
141
+ continue
142
+ }
143
+ if (typeof value === 'string' || (Array.isArray(value) && value.every((v) => typeof v === 'string'))) {
144
+ slots[key] = value
145
+ } else {
146
+ problems.push(`槽位 ${key} 须为 string 或 string 数组`)
147
+ }
148
+ }
149
+ patch.slots = slots
150
+ }
151
+ }
152
+ if (body.budgets !== undefined) {
153
+ if (typeof body.budgets !== 'object' || body.budgets === null || Array.isArray(body.budgets)) {
154
+ problems.push('budgets 须为对象')
155
+ } else {
156
+ const budgets = {}
157
+ for (const [key, value] of Object.entries(body.budgets)) {
158
+ if (!BUDGET_KEYS.includes(key)) {
159
+ problems.push(`未知预算键:${key}`)
160
+ continue
161
+ }
162
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
163
+ problems.push(`预算 ${key} 须为整数`)
164
+ continue
165
+ }
166
+ budgets[key] = Math.max(1, Math.min(10, value))
167
+ }
168
+ patch.budgets = budgets
169
+ }
170
+ }
171
+ if (problems.length > 0) {
172
+ const err = new Error(problems.join(';'))
173
+ err.errors = problems
174
+ throw err
175
+ }
176
+ return patch
177
+ }
178
+
179
+ // ── 运行时控制(v5) ─────────────────────────────────────────────────────────
180
+ const CONTROL_KINDS = ['message', 'cancel', 'pause', 'resume', 'approve', 'reject']
181
+ const VERDICT_KINDS = new Set(['approve', 'reject'])
182
+ const CONTROL_BY = new Set(['user', 'main-agent'])
183
+
184
+ // 活跃判定:注册表在册或记录未终态(waiting_approval 属活跃)
185
+ const isRunActive = (runId, record) => registry.drivers.has(runId) || (record && ACTIVE_STATES.has(record.status))
186
+
187
+ export function handleControl(body) {
188
+ const runId = typeof body.runId === 'string' ? body.runId : ''
189
+ const kind = typeof body.kind === 'string' ? body.kind : ''
190
+ const store = reportStore()
191
+ if (!CONTROL_KINDS.includes(kind)) throw new Error('kind 须为 ' + CONTROL_KINDS.join('|'))
192
+ if (kind === 'message') {
193
+ if (typeof body.text !== 'string' || body.text.trim() === '') throw new Error('message 须携带非空 text')
194
+ const accepted = post(runId, { kind: 'message', text: body.text, inject: body.inject === true })
195
+ return { ok: accepted }
196
+ }
197
+ if (VERDICT_KINDS.has(kind)) {
198
+ // 外部裁决通道:by 必填(user|main-agent),代审(main-agent)须给出非空 reason
199
+ if (!CONTROL_BY.has(body.by)) throw new Error('裁决须携带 by(user|main-agent)')
200
+ if (body.by === 'main-agent' && (typeof body.reason !== 'string' || body.reason.trim() === '')) throw new Error('代审(main-agent)须携带非空 reason')
201
+ const accepted = post(runId, { kind, by: body.by, reason: typeof body.reason === 'string' ? body.reason : '' })
202
+ if (!accepted) return { ok: false }
203
+ // 裁决翻状态不推进:无活跃段时经会话挂靠拉下一段(页签先裁场景主循环无通知,须代为唤醒)
204
+ const record = store.get(runId)
205
+ const resumed = resumerOf(record?.sessionId)?.(runId) === true
206
+ return { ok: true, resumed }
207
+ }
208
+ const driver = registry.drivers.get(runId)
209
+ if (!driver) return { ok: false }
210
+ if (kind === 'cancel') {
211
+ store.step({ runId, event: 'control', body: { kind: 'cancel', by: 'user' } })
212
+ driver.cancel()
213
+ }
214
+ if (kind === 'pause') {
215
+ store.step({ runId, event: 'control', body: { kind: 'pause', by: 'user' } })
216
+ driver.pause()
217
+ }
218
+ if (kind === 'resume') {
219
+ // 仅 paused 受理;其余状态回状态不符(先到先得口径)
220
+ const accepted = driver.tabResume()
221
+ if (!accepted) return { ok: false, error: '状态不符:resume 仅受理 paused' }
222
+ }
223
+ return { ok: true }
224
+ }
225
+
226
+ export function registerBoardRoutes(ctx) {
227
+ const store = reportStore()
228
+ ctx.inject(['webServer'], (wctx) => {
229
+ const route = (path, handler, name) => wctx.effect(() => wctx.webServer.register({ kind: 'exact', path, handler }), name)
230
+ route('/api/rsww/runs', guardedRoute(async (req, res) => {
231
+ sendJson(res, 200, { runs: store.list() })
232
+ }), 'rsww runs route')
233
+ route('/api/rsww/run', guardedRoute(async (req, res) => {
234
+ const url = new URL(req.url, 'http://localhost')
235
+ const runId = url.searchParams.get('id') || ''
236
+ const run = store.get(runId)
237
+ if (!run) throw new Error('运行记录不存在:' + runId)
238
+ sendJson(res, 200, run)
239
+ }), 'rsww run detail route')
240
+ route('/api/rsww/control', guardedRoute.post(async (req, res) => {
241
+ const body = JSON.parse(await readJsonBody(req))
242
+ sendJson(res, 200, handleControl(body ?? {}))
243
+ }), 'rsww control route')
244
+ route('/api/rsww/resume-from', guardedRoute.post(async (req, res) => {
245
+ const body = JSON.parse(await readJsonBody(req))
246
+ const runId = typeof body.runId === 'string' ? body.runId : ''
247
+ const record = store.get(runId)
248
+ if (!record) throw new Error('运行记录不存在:' + runId)
249
+ if (isRunActive(runId, record)) throw new Error('运行进行中,不可续跑')
250
+ // 挂靠守卫:新 run 由原会话的推进器(orchestrator 行注册的 initiator)重建,engine/parent 闭包在其域内
251
+ const start = initiatorOf(record.sessionId)
252
+ if (!start) throw new Error('请先打开对应模式会话再重跑')
253
+ const fromStepId = typeof body.fromStepId === 'string' && body.fromStepId !== '' ? body.fromStepId : undefined
254
+ // 非法 fromStepId 显式报错(不静默退化为纯断点续跑)
255
+ if (fromStepId !== undefined && record.plan?.steps?.some((p) => p.ref === fromStepId) !== true) {
256
+ throw new Error('fromStepId 不在剧本中:' + fromStepId)
257
+ }
258
+ const inputs = body.inputs && typeof body.inputs === 'object' ? body.inputs : undefined
259
+ const outcome = start(record, fromStepId, inputs)
260
+ if (!outcome.ok) throw new Error(outcome.error)
261
+ // 纠偏消息不跨 run:旧 run 受理未消费的纠偏不带入种子(controls 属旧 run 审计)
262
+ sendJson(res, 200, { ok: true, runId: outcome.runId, hint: '旧 run 未消费的纠偏消息不带入新 run' })
263
+ }), 'rsww resume-from route')
264
+ route('/api/rsww/run-remove', guardedRoute.post(async (req, res) => {
265
+ const body = JSON.parse(await readJsonBody(req))
266
+ const runId = typeof body.runId === 'string' ? body.runId : ''
267
+ const record = store.has(runId) ? store.get(runId) : undefined
268
+ if (record && isRunActive(runId, record)) throw new Error('运行进行中,不可删除')
269
+ store.remove(runId)
270
+ sendJson(res, 200, { ok: true })
271
+ }), 'rsww run-remove route')
272
+ route('/api/rsww/templates', guardedRoute(async (req, res) => {
273
+ sendJson(res, 200, { templates: readTemplates() })
274
+ }), 'rsww templates route')
275
+ route('/api/rsww/template', guardedRoute(async (req, res) => {
276
+ const url = new URL(req.url, 'http://localhost')
277
+ const id = url.searchParams.get('id') || ''
278
+ const entry = readTemplates().find((t) => t.id === id)
279
+ if (!entry) throw new Error('模板不存在:' + id)
280
+ let parsed
281
+ try {
282
+ parsed = JSON.parse(entry.json)
283
+ } catch (e) {
284
+ throw new Error('模板文本解析失败:' + e.message)
285
+ }
286
+ sendJson(res, 200, { entry, parsed })
287
+ }), 'rsww template detail route')
288
+ route('/api/rsww/released', guardedRoute(async (req, res) => {
289
+ sendJson(res, 200, { ids: releasedTemplateIds() })
290
+ }), 'rsww released route')
291
+ route('/api/rsww/spec', guardedRoute(async (req, res) => {
292
+ sendJson(res, 200, { spec: SPEC_TEXT })
293
+ }), 'rsww spec route')
294
+ route('/api/rsww/template-save', guardedRoute.post(async (req, res) => {
295
+ const body = JSON.parse(await readJsonBody(req))
296
+ const id = typeof body.id === 'string' ? body.id.trim() : ''
297
+ const { parsed, json } = parseTemplateEntry(id, body)
298
+ if (body.dryRun === true) {
299
+ sendJson(res, 200, { ok: true, dryRun: true })
300
+ return
301
+ }
302
+ const templates = rawTemplates().filter((t) => t.id !== id)
303
+ templates.push({
304
+ id,
305
+ label: typeof body.label === 'string' && body.label.trim() !== '' ? body.label.trim() : parsed.label || id,
306
+ description: typeof body.description === 'string' && body.description.trim() !== '' ? body.description.trim() : parsed.description || '',
307
+ enabled: body.enabled !== false,
308
+ json,
309
+ })
310
+ writeTemplates(templates)
311
+ sendJson(res, 200, { ok: true })
312
+ }), 'rsww template-save route')
313
+ route('/api/rsww/template-remove', guardedRoute.post(async (req, res) => {
314
+ const body = JSON.parse(await readJsonBody(req))
315
+ const id = typeof body.id === 'string' ? body.id.trim() : ''
316
+ const outcome = removeTemplateById(id)
317
+ if (!outcome.ok) throw new Error(outcome.error)
318
+ sendJson(res, 200, { ok: true })
319
+ }), 'rsww template-remove route')
320
+ route('/api/rsww/release', guardedRoute.post(async (req, res) => {
321
+ const body = JSON.parse(await readJsonBody(req))
322
+ const id = typeof body.id === 'string' ? body.id.trim() : ''
323
+ const entries = readTemplates()
324
+ const entry = entries.find((t) => t.id === id)
325
+ if (!entry) throw new Error('模板不存在:' + id)
326
+ if (entry.enabled === false) throw new Error('模板已禁用,不可释放:' + id)
327
+ // 跨流程校验(spec §9):flow 引用存在性+环/深度在释放权威点执行
328
+ const parsedSet = entries.map((t) => {
329
+ try { return JSON.parse(t.json) } catch { return null }
330
+ }).filter((t) => t !== null)
331
+ const setErrors = validateTemplateSet(parsedSet).filter((e) => e.target === 'top:id' && e.message.startsWith(id + ' '))
332
+ if (setErrors.length > 0) throw new Error('模板集合校验失败:' + setErrors.map((e) => e.message).join(';'))
333
+ const outcome = releaseFlowTemplate(entry)
334
+ sendJson(res, 200, { ok: true, outcome })
335
+ }), 'rsww release route')
336
+ route('/api/rsww/unrelease', guardedRoute.post(async (req, res) => {
337
+ const body = JSON.parse(await readJsonBody(req))
338
+ const id = typeof body.id === 'string' ? body.id.trim() : ''
339
+ const outcome = unreleaseFlowTemplate(id)
340
+ sendJson(res, 200, { ok: true, outcome })
341
+ }), 'rsww unrelease route')
342
+ route('/api/rsww/config', guardedRoute(async (req, res) => {
343
+ sendJson(res, 200, { config: normalizeConfig(loadJson('config.json', undefined)) })
344
+ }), 'rsww config route')
345
+ route('/api/rsww/config-save', guardedRoute.post(async (req, res) => {
346
+ const body = JSON.parse(await readJsonBody(req))
347
+ const patch = configSavePatch(body ?? {})
348
+ const current = normalizeConfig(loadJson('config.json', undefined))
349
+ // config.json 权威节集 = slots/budgets(data-design);templates 属 templates.json 独立存储
350
+ const { templates: _drop, ...persistable } = current
351
+ saveJson('config.json', { ...persistable, ...patch })
352
+ sendJson(res, 200, { ok: true })
353
+ }), 'rsww config-save route')
354
+ })
355
+ }
@@ -0,0 +1,126 @@
1
+ // 审批裁决路由:重做循环/耗尽清算/升级账本 + 外部裁决(waiting_approval 置位/裁决回写/来源入账)
2
+ const resetStepForRedo = (s) => {
3
+ s.status = 'pending'
4
+ s.outputs = null
5
+ s.failCount = 0
6
+ s.instances = []
7
+ }
8
+
9
+ // target 及其未完成下游集合(剧本依赖图的可达闭包,不含 escalate-only)
10
+ function descendantsOf(script, targetId) {
11
+ const { depsById, escalateOnly } = script
12
+ const children = new Map()
13
+ for (const [id, ds] of depsById) {
14
+ for (const d of ds) {
15
+ if (!children.has(d)) children.set(d, [])
16
+ children.get(d).push(id)
17
+ }
18
+ }
19
+ const out = new Set()
20
+ const walk = (id) => {
21
+ for (const child of children.get(id) ?? []) {
22
+ if (out.has(child) || escalateOnly.has(child)) continue
23
+ out.add(child)
24
+ walk(child)
25
+ }
26
+ }
27
+ walk(targetId)
28
+ return out
29
+ }
30
+
31
+ // 审批结果路由;返回 {verdict, exhausted, routedTo}。result = { outputs: { verdict, comments } }(外部裁决包成同形)
32
+ export function applyApproveResult(state, script, approveStep, result, budgets) {
33
+ const s = state.steps[approveStep.id]
34
+ const verdict = result.outputs?.verdict
35
+ state.approvals[approveStep.id] ??= { rounds: 0 }
36
+ const ledger = state.approvals[approveStep.id]
37
+ ledger.lastComments = result.outputs?.comments ?? ''
38
+
39
+ if (verdict === 'APPROVED') {
40
+ s.status = 'done'
41
+ s.outputs = result.outputs
42
+ return { verdict: 'APPROVED', exhausted: false }
43
+ }
44
+
45
+ // REJECTED
46
+ ledger.rounds++
47
+ const limit = approveStep.rounds ?? budgets.approveRounds
48
+ const target = state.steps[approveStep.target]
49
+ if (ledger.rounds < limit) {
50
+ const prevOutputs = target?.outputs
51
+ s.status = 'pending'
52
+ s.outputs = null
53
+ if (target) {
54
+ target.redoBy = approveStep.id
55
+ resetStepForRedo(target)
56
+ // 全部后代重置(target 产出改变,done 后代亦陈旧)
57
+ for (const id of descendantsOf(script, approveStep.target)) {
58
+ resetStepForRedo(state.steps[id])
59
+ }
60
+ }
61
+ return { verdict: 'REJECTED', exhausted: false, redoTarget: approveStep.target, comments: result.outputs?.comments, prevOutputs: prevOutputs ? JSON.stringify(prevOutputs, null, 2) : null }
62
+ }
63
+
64
+ // 耗尽
65
+ s.status = 'done'
66
+ s.outputs = result.outputs
67
+ s.verdictExhausted = true
68
+ if (approveStep.onExhausted === 'blocked' || approveStep.onExhausted === undefined) {
69
+ // 缺省 blocked(spec §4 口径,与客户端提示一致):无出口即终止
70
+ state.terminalBlocked = `审批轮次耗尽:${approveStep.label ?? approveStep.id}(target=${approveStep.target},rounds=${ledger.rounds})`
71
+ return { verdict: 'REJECTED', exhausted: true, routedTo: 'blocked' }
72
+ }
73
+ const escalateId = approveStep.onExhausted
74
+ const escalateStep = state.steps[escalateId]
75
+ state.escalations++
76
+ if (target) {
77
+ target.status = 'skipped'
78
+ target.skipReason = '审批耗尽走升级出口'
79
+ target.outputs = null
80
+ for (const id of descendantsOf(script, approveStep.target)) {
81
+ const ds = state.steps[id]
82
+ if (!ds || ds.status === 'done') continue
83
+ ds.status = 'skipped'
84
+ ds.skipReason = '审批耗尽走升级出口'
85
+ ds.outputs = null
86
+ ds.instances = []
87
+ }
88
+ }
89
+ if (escalateStep) escalateStep.escalateReady = true
90
+ const hitLimit = state.escalations >= (budgets.escalateLimit ?? 2)
91
+ if (hitLimit) {
92
+ state.escalateLimitReached = true
93
+ state.terminalBlocked = `升级账达上限(${state.escalations}),整流程 blocked`
94
+ }
95
+ return { verdict: 'REJECTED', exhausted: true, routedTo: escalateId, escalateLimitReached: hitLimit }
96
+ }
97
+
98
+ // 外部裁决回写(waiting_approval 期间);paused 期间的裁决由 driver 入队,resume 段首生效
99
+ // 返回 { applied: true, route } | { applied: false }
100
+ export function applyExternalVerdict(state, script, approveStep, { verdict, comments }, budgets = {}) {
101
+ if (state.status !== 'waiting_approval') return { applied: false }
102
+ const route = applyApproveResult(state, script, approveStep, { outputs: { verdict, comments } }, budgets)
103
+ // blocked 终态由下一段 terminal 侦测收口;此处仅翻回 running(下一段推进/收口)
104
+ state.status = 'running'
105
+ return { applied: true, route }
106
+ }
107
+
108
+ // 段 settle 负载(waiting):待裁决摘要 + 口径上下文(feat/approve-bridge.md 字段集)
109
+ export function waitingPayload({ runId, state, script, planStepOf, approveStep }) {
110
+ const target = state.steps[approveStep.target]
111
+ const planStep = planStepOf.get(approveStep.id)
112
+ const brief = planStep?.done || planStep?.note || approveStep.prompt
113
+ const ledger = state.approvals[approveStep.id]
114
+ return {
115
+ kind: 'waiting',
116
+ runId,
117
+ status: state.status,
118
+ waiting: {
119
+ stepId: approveStep.id,
120
+ target: { id: approveStep.target, label: approveStep.target, outputs: target?.outputs ?? null },
121
+ brief,
122
+ comments: ledger?.lastComments ?? '',
123
+ rounds: ledger?.rounds ?? 0,
124
+ },
125
+ }
126
+ }
@@ -0,0 +1,46 @@
1
+ // 控制平面注册表:cancel/resume 即时通道直调 RunDriver;message 经 driver 落账(队列单例不持有 store)
2
+ export const registry = {
3
+ drivers: new Map(),
4
+ initiators: new Map(),
5
+ resumers: new Map(),
6
+ }
7
+
8
+ export function registerDriver(runId, driver) {
9
+ registry.drivers.set(runId, driver)
10
+ }
11
+
12
+ export function unregisterDriver(runId) {
13
+ registry.drivers.delete(runId)
14
+ }
15
+
16
+ export function registerInitiator(sessionId, start) {
17
+ registry.initiators.set(sessionId, start)
18
+ }
19
+
20
+ export function unregisterInitiator(sessionId) {
21
+ registry.initiators.delete(sessionId)
22
+ }
23
+
24
+ export function initiatorOf(sessionId) {
25
+ return registry.initiators.get(sessionId)
26
+ }
27
+
28
+ // 段拉起挂靠:页签裁决受理后经此回到 orchestrator 域拉下一段(裁决翻状态不推进,推进责任仍在段 job)
29
+ export function registerResumer(sessionId, resume) {
30
+ registry.resumers.set(sessionId, resume)
31
+ }
32
+
33
+ export function unregisterResumer(sessionId) {
34
+ registry.resumers.delete(sessionId)
35
+ }
36
+
37
+ export function resumerOf(sessionId) {
38
+ return registry.resumers.get(sessionId)
39
+ }
40
+
41
+ // post 受理语义:活跃(未终态且已注册 driver)才受理;落账经 driver 自持 store
42
+ export function post(runId, event) {
43
+ const driver = registry.drivers.get(runId)
44
+ if (!driver) return false
45
+ return driver.handlePost(event)
46
+ }