@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 +14 -49
- package/cordis.patch.yml +8 -17
- package/lib/board.mjs +355 -0
- package/lib/driver/approve.mjs +126 -0
- package/lib/driver/control.mjs +46 -0
- package/lib/driver/index.mjs +454 -0
- package/lib/driver/prompts.mjs +102 -0
- package/lib/driver/runner.mjs +173 -0
- package/lib/driver/scheduler.mjs +159 -0
- package/lib/index.js +30 -213
- package/lib/orchestrator.mjs +428 -0
- package/lib/planner-gate.mjs +195 -0
- package/lib/release.mjs +183 -0
- package/lib/settings-schema.mjs +73 -0
- package/lib/spec.mjs +161 -0
- package/lib/storage.mjs +35 -0
- package/lib/store.mjs +255 -0
- package/lib/template-tool.mjs +161 -0
- package/lib/template.mjs +341 -0
- package/package.json +8 -6
- package/src/client.js +1269 -0
- package/lib/preset-sync.mjs +0 -227
- package/preset/rs-workflow/agent.cordis.yml +0 -196
- package/preset/rs-workflow/preset.yml +0 -5
- package/preset/rs-workflow/skills/rs-workflow/SKILL.md +0 -110
- package/preset/rs-workflow/skills/rs-workflow/references/engine.js +0 -1446
- package/preset/rs-workflow/skills/rs-workflow/references/templates.md +0 -104
- package/preset/rs-workflow/skills/rs-workflow/slots.json5 +0 -53
package/README.md
CHANGED
|
@@ -1,63 +1,28 @@
|
|
|
1
1
|
# @mzzsfy/dsh-rs-workflow
|
|
2
2
|
|
|
3
|
-
若水工作流 (
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
## 卸载
|
|
9
|
+
## 行角色
|
|
28
10
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
```
|
|
11
|
+
| 行角色 | 作用 |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `board` | `/api/rsww/*` 设置子域路由(模板 CRUD/校验/规范/配置读写),数据落 `~/.dsh/dsh-rs-workflow/v5/{templates,config}.json` |
|
|
33
14
|
|
|
34
|
-
|
|
15
|
+
## 设置页路由
|
|
35
16
|
|
|
36
|
-
|
|
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/
|
|
22
|
+
node scripts/dev-link.mjs all # 挂 junction 开发态
|
|
23
|
+
node --test "test/*.test.mjs" # 包内测试(在 packages/dsh-rs-workflow 下)
|
|
47
24
|
```
|
|
48
25
|
|
|
49
|
-
|
|
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
|
-
|
|
28
|
+
三版本全部通过:preset 释放、Agent 预设页若水工作流卡、rs_workflow_ 工具可列出、激活 live。
|
package/cordis.patch.yml
CHANGED
|
@@ -1,21 +1,12 @@
|
|
|
1
|
-
#
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
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:
|
|
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
|
+
}
|