@mzzsfy/dsh-rs-workflow 1.0.0 → 1.0.2

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.
@@ -0,0 +1,23 @@
1
+ // 单批次执行器脚本:宿主 workflowEngine 裸 vm body,globals 仅 agent/parallel/pipeline/phase/log/args,无模块语法
2
+ const BATCH_PHASE = '若水批次'
3
+ const NULL_OUTPUT_ERROR = '子代理未提交结构化产出'
4
+
5
+ const errorText = (error) => String((error && error.message) || error)
6
+ const settle = (callId, outputs) => outputs === null
7
+ ? { callId, ok: false, error: NULL_OUTPUT_ERROR }
8
+ : { callId, ok: true, outputs }
9
+
10
+ const { calls } = args
11
+ // v5 引擎 parallel 契约:零参 thunk 数组(每个 thunk 失败→null),不再是 promise 数组;
12
+ // agent() options 键存在则值必须是 JSON(undefined 不行),缺省键须整键省略
13
+ const dispatches = calls.map((call) => () => agent(call.prompt, {
14
+ label: call.label,
15
+ phase: BATCH_PHASE,
16
+ ...call.schema !== undefined ? { schema: call.schema } : {},
17
+ ...call.provider !== undefined ? { provider: call.provider } : {},
18
+ ...call.model !== undefined ? { model: call.model } : {},
19
+ }).then((outputs) => settle(call.callId, outputs))
20
+ .catch((error) => ({ callId: call.callId, ok: false, error: errorText(error) })))
21
+
22
+ const results = await parallel(dispatches)
23
+ return { results }
package/lib/board.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  // board — /api/rsww/* 路由薄分发:数据权威态在 store 单例/自有文件存储(v5/{templates,config}.json)/driver 控制队列单例,路由无业务状态
2
2
  // 运行时路由 v5 恢复:runs/run/control(approve|reject 增 by/reason)/resume-from(种子续跑,不拉段)/run-remove/release/unrelease/released
3
3
  // 规划受理不经 HTTP:rs_workflow_start 是 orchestrator 工具行(见 feat/orchestrator.md)
4
+ // 安全边界(全仓库约定,AGENTS.md):不做 host 认证,无 Host 白名单/rebinding 防线/token 校验;
5
+ // 认证由宿主原生 cookie/startup-auth 承担,写路由跨源防护以 Origin 同源比对为上限
4
6
  import { reportStore, ACTIVE_STATES } from './store.mjs'
5
7
  import { registry, post, initiatorOf, resumerOf } from './driver/control.mjs'
6
8
  import { validateTemplate, validateTemplateSet } from './template.mjs'
@@ -67,21 +69,26 @@ guardedRoute.post = (handler) => async (req, res) => {
67
69
  return guardedRoute(handler)(req, res)
68
70
  }
69
71
 
70
- function readJsonBody(req) {
72
+ function readJsonBody(req, res) {
71
73
  return new Promise((resolve, reject) => {
72
74
  let size = 0
73
75
  const chunks = []
76
+ let over = false
74
77
  req.on('data', (chunk) => {
75
78
  size += chunk.length
76
79
  if (size > BODY_MAX_BYTES) {
80
+ // 只暂停流入并标记超限,不销毁 socket:连接毁了 guardedRoute 的 400 就送不出去
81
+ over = true
82
+ req.pause()
77
83
  reject(new Error('请求体超过上限'))
78
- req.destroy()
79
84
  return
80
85
  }
81
- chunks.push(chunk)
86
+ if (!over) chunks.push(chunk)
82
87
  })
83
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
84
- req.on('error', reject)
88
+ req.on('end', () => { if (!over) resolve(Buffer.concat(chunks).toString('utf8')) })
89
+ req.on('error', () => { if (!over) reject(new Error('请求体读取失败')) })
90
+ // 超限后 end 不再流到(已暂停):挂 res 收尾销毁,防 socket 悬挂
91
+ res.on('finish', () => req.destroy())
85
92
  })
86
93
  }
87
94
 
@@ -238,11 +245,11 @@ export function registerBoardRoutes(ctx) {
238
245
  sendJson(res, 200, run)
239
246
  }), 'rsww run detail route')
240
247
  route('/api/rsww/control', guardedRoute.post(async (req, res) => {
241
- const body = JSON.parse(await readJsonBody(req))
248
+ const body = JSON.parse(await readJsonBody(req, res))
242
249
  sendJson(res, 200, handleControl(body ?? {}))
243
250
  }), 'rsww control route')
244
251
  route('/api/rsww/resume-from', guardedRoute.post(async (req, res) => {
245
- const body = JSON.parse(await readJsonBody(req))
252
+ const body = JSON.parse(await readJsonBody(req, res))
246
253
  const runId = typeof body.runId === 'string' ? body.runId : ''
247
254
  const record = store.get(runId)
248
255
  if (!record) throw new Error('运行记录不存在:' + runId)
@@ -255,14 +262,21 @@ export function registerBoardRoutes(ctx) {
255
262
  if (fromStepId !== undefined && record.plan?.steps?.some((p) => p.ref === fromStepId) !== true) {
256
263
  throw new Error('fromStepId 不在剧本中:' + fromStepId)
257
264
  }
258
- const inputs = body.inputs && typeof body.inputs === 'object' ? body.inputs : undefined
265
+ // inputs 须为字符串值对象:数组/嵌套值在种子展开中失真
266
+ let inputs
267
+ if (body.inputs !== undefined && body.inputs !== null) {
268
+ if (typeof body.inputs !== 'object' || Array.isArray(body.inputs) || Object.values(body.inputs).some((v) => typeof v !== 'string')) {
269
+ throw new Error('inputs 必须为字符串值对象(键→字符串)')
270
+ }
271
+ inputs = body.inputs
272
+ }
259
273
  const outcome = start(record, fromStepId, inputs)
260
274
  if (!outcome.ok) throw new Error(outcome.error)
261
275
  // 纠偏消息不跨 run:旧 run 受理未消费的纠偏不带入种子(controls 属旧 run 审计)
262
276
  sendJson(res, 200, { ok: true, runId: outcome.runId, hint: '旧 run 未消费的纠偏消息不带入新 run' })
263
277
  }), 'rsww resume-from route')
264
278
  route('/api/rsww/run-remove', guardedRoute.post(async (req, res) => {
265
- const body = JSON.parse(await readJsonBody(req))
279
+ const body = JSON.parse(await readJsonBody(req, res))
266
280
  const runId = typeof body.runId === 'string' ? body.runId : ''
267
281
  const record = store.has(runId) ? store.get(runId) : undefined
268
282
  if (record && isRunActive(runId, record)) throw new Error('运行进行中,不可删除')
@@ -292,7 +306,7 @@ export function registerBoardRoutes(ctx) {
292
306
  sendJson(res, 200, { spec: SPEC_TEXT })
293
307
  }), 'rsww spec route')
294
308
  route('/api/rsww/template-save', guardedRoute.post(async (req, res) => {
295
- const body = JSON.parse(await readJsonBody(req))
309
+ const body = JSON.parse(await readJsonBody(req, res))
296
310
  const id = typeof body.id === 'string' ? body.id.trim() : ''
297
311
  const { parsed, json } = parseTemplateEntry(id, body)
298
312
  if (body.dryRun === true) {
@@ -311,14 +325,14 @@ export function registerBoardRoutes(ctx) {
311
325
  sendJson(res, 200, { ok: true })
312
326
  }), 'rsww template-save route')
313
327
  route('/api/rsww/template-remove', guardedRoute.post(async (req, res) => {
314
- const body = JSON.parse(await readJsonBody(req))
328
+ const body = JSON.parse(await readJsonBody(req, res))
315
329
  const id = typeof body.id === 'string' ? body.id.trim() : ''
316
330
  const outcome = removeTemplateById(id)
317
331
  if (!outcome.ok) throw new Error(outcome.error)
318
332
  sendJson(res, 200, { ok: true })
319
333
  }), 'rsww template-remove route')
320
334
  route('/api/rsww/release', guardedRoute.post(async (req, res) => {
321
- const body = JSON.parse(await readJsonBody(req))
335
+ const body = JSON.parse(await readJsonBody(req, res))
322
336
  const id = typeof body.id === 'string' ? body.id.trim() : ''
323
337
  const entries = readTemplates()
324
338
  const entry = entries.find((t) => t.id === id)
@@ -334,7 +348,7 @@ export function registerBoardRoutes(ctx) {
334
348
  sendJson(res, 200, { ok: true, outcome })
335
349
  }), 'rsww release route')
336
350
  route('/api/rsww/unrelease', guardedRoute.post(async (req, res) => {
337
- const body = JSON.parse(await readJsonBody(req))
351
+ const body = JSON.parse(await readJsonBody(req, res))
338
352
  const id = typeof body.id === 'string' ? body.id.trim() : ''
339
353
  const outcome = unreleaseFlowTemplate(id)
340
354
  sendJson(res, 200, { ok: true, outcome })
@@ -343,7 +357,7 @@ export function registerBoardRoutes(ctx) {
343
357
  sendJson(res, 200, { config: normalizeConfig(loadJson('config.json', undefined)) })
344
358
  }), 'rsww config route')
345
359
  route('/api/rsww/config-save', guardedRoute.post(async (req, res) => {
346
- const body = JSON.parse(await readJsonBody(req))
360
+ const body = JSON.parse(await readJsonBody(req, res))
347
361
  const patch = configSavePatch(body ?? {})
348
362
  const current = normalizeConfig(loadJson('config.json', undefined))
349
363
  // config.json 权威节集 = slots/budgets(data-design);templates 属 templates.json 独立存储
@@ -106,7 +106,7 @@ export function applyExternalVerdict(state, script, approveStep, { verdict, comm
106
106
  }
107
107
 
108
108
  // 段 settle 负载(waiting):待裁决摘要 + 口径上下文(feat/approve-bridge.md 字段集)
109
- export function waitingPayload({ runId, state, script, planStepOf, approveStep }) {
109
+ export function waitingPayload({ runId, state, script, planStepOf, approveStep, autoApprove = false }) {
110
110
  const target = state.steps[approveStep.target]
111
111
  const planStep = planStepOf.get(approveStep.id)
112
112
  const brief = planStep?.done || planStep?.note || approveStep.prompt
@@ -121,6 +121,8 @@ export function waitingPayload({ runId, state, script, planStepOf, approveStep }
121
121
  brief,
122
122
  comments: ledger?.lastComments ?? '',
123
123
  rounds: ledger?.rounds ?? 0,
124
+ // 模板代审口径:主循环 persona 据此决定代审(by 缺省)或转呈真人(by=user)
125
+ autoApprove,
124
126
  },
125
127
  }
126
128
  }
@@ -1,5 +1,7 @@
1
1
  // RunDriver 聚合(v5):剧本驱动;runSegment 分段推进(审批到达/暂停/终态即返回 settle 负载);
2
2
  // 外部裁决回写(waiting_approval 即时应用,paused 入队 resume 生效);取消优先;推进责任在主循环 resume
3
+ import { readFileSync } from 'node:fs'
4
+ import { isAbsolute, join, resolve, sep } from 'node:path'
3
5
  import { reportStore } from '../store.mjs'
4
6
  import { templateDeps } from '../planner-gate.mjs'
5
7
  import { nextBatch, scriptViewOf } from './scheduler.mjs'
@@ -88,11 +90,13 @@ export function buildSeed(record, plan, fromStepId, inputs) {
88
90
  }
89
91
  }
90
92
  state.pendingApprovals = []
91
- // 终态污染标志清零:续跑后旧账不得再次收口(blocked)或无耗尽即派发升级步
93
+ // 终态污染标志清零:续跑后旧账不得再次收口(blocked)或无耗尽即派发升级步;
94
+ // escalateReady 是步级标记(scheduler 消费),须逐步清零
92
95
  state.terminalBlocked = false
93
- state.escalateReady = []
94
96
  state.escalateLimitReached = false
95
- state.controlSeq = record.controls?.length ?? 0
97
+ for (const s of Object.values(state.steps)) s.escalateReady = false
98
+ // 控制序号对齐新记录:续跑生成全新 store 记录(controls 从空起),沿用旧计数会吞掉新记录前缀消息
99
+ state.controlSeq = 0
96
100
  return state
97
101
  }
98
102
 
@@ -123,27 +127,46 @@ export class RunDriver {
123
127
  this.subordinate = subordinate
124
128
  // 编排子代理的挂载父 agent(与 workflow 工具链对齐;undefined 时引擎派发行为未定义)
125
129
  this.parent = parent
130
+ // spec §8 doc:<相对路径>:读会话工作区文件,缺失不阻断(空串);resolve 限定在 workspace 内防路径逃逸
131
+ const base = resolve(workspace || process.cwd())
132
+ this.readDoc = (rel) => {
133
+ const target = resolve(base, rel)
134
+ if (!target.startsWith(base + sep) && target !== base) return ''
135
+ try { return readFileSync(target, 'utf8') } catch { return '' }
136
+ }
126
137
  }
127
138
 
128
139
  // 段推进唯一入口:跑到下一个段边界(审批到达/暂停/终态)返回 settle 负载;orchestrator 包装为 continuable job
129
140
  async runSegment() {
141
+ // 重入防护:在飞段由 settlePromise 承载(取消方 await 它收敛,而非重入第二个 loop)
142
+ if (this.active) return this.settlePromise ?? this.terminalPayload()
130
143
  if (this.finished || TERMINAL_STATES.has(this.state.status)) {
131
144
  return this.terminalPayload()
132
145
  }
133
146
  this.active = true
134
- try {
135
- this.drainPendingApprovals()
136
- return await this.loop()
137
- } catch (e) {
138
- if (this.signal.aborted) {
139
- this.finish('cancelled', CANCELLED_SUMMARY)
147
+ this.settlePromise = (async () => {
148
+ try {
149
+ this.drainPendingApprovals()
150
+ return await this.loop()
151
+ } catch (e) {
152
+ if (this.signal.aborted) {
153
+ this.finish('cancelled', CANCELLED_SUMMARY)
154
+ return this.terminalPayload()
155
+ }
156
+ this.finish('failed', `驱动器异常:${e?.message ?? e}`)
140
157
  return this.terminalPayload()
158
+ } finally {
159
+ this.active = false
160
+ this.settlePromise = undefined
141
161
  }
142
- this.finish('failed', `驱动器异常:${e?.message ?? e}`)
143
- return this.terminalPayload()
144
- } finally {
145
- this.active = false
146
- }
162
+ })()
163
+ return this.settlePromise
164
+ }
165
+
166
+ /** 取消并等待在飞段收敛(无在飞段时即时收敛) */
167
+ async cancelAndSettle() {
168
+ this.cancel()
169
+ if (this.settlePromise !== undefined) await this.settlePromise.catch(() => {})
147
170
  }
148
171
 
149
172
  startPersist() {
@@ -159,6 +182,8 @@ export class RunDriver {
159
182
  if (this.finished || TERMINAL_STATES.has(this.state.status)) return
160
183
  // 统一转 paused(与 waiting_approval 互斥,paused 优先);活跃段在飞批次收敛后于段顶 settle
161
184
  this.state.status = 'paused'
185
+ // 暂停即撤销先前的恢复意图,否则陈旧标记会让守门持续催 resume
186
+ this.awaitingResume = false
162
187
  this.persistState()
163
188
  }
164
189
 
@@ -243,6 +268,8 @@ export class RunDriver {
243
268
  this.state.redoInfo = { [route.redoTarget]: { comments: route.comments, prevOutputs: route.prevOutputs } }
244
269
  }
245
270
  }
271
+ // 与 waiting 直裁路径同款收口:残留 waitingApproval 会让后续 pause 期裁决对非等待步幽灵生效
272
+ this.state.waitingApproval = undefined
246
273
  this.store.update({ runId: this.runId, waiting: null })
247
274
  }
248
275
 
@@ -266,6 +293,9 @@ export class RunDriver {
266
293
  }
267
294
 
268
295
  persistState() {
296
+ // 嵌套子流程与父共享同 runId 记录:子状态落盘会覆盖父的全局 state,崩溃后续跑种子即失真;
297
+ // 子流程中间态不落盘,flow 步结束后由父 persistState 兜底
298
+ if (this.subordinate) return
269
299
  this.store.update({ runId: this.runId, state: this.state, status: this.state.status })
270
300
  }
271
301
 
@@ -343,8 +373,10 @@ export class RunDriver {
343
373
  const step = batch.step
344
374
  this.state.status = 'waiting_approval'
345
375
  this.state.waitingApproval = step.id
376
+ // 模板代审口径落 state:进程重启后 status 工具仍可透出
377
+ this.state.waitingAutoApprove = this.template?.autoApprove === true
346
378
  this.persistState()
347
- const payload = waitingPayload({ runId: this.runId, state: this.state, script: this.script, planStepOf: this.planStepOf, approveStep: step })
379
+ const payload = waitingPayload({ runId: this.runId, state: this.state, script: this.script, planStepOf: this.planStepOf, approveStep: step, autoApprove: this.template?.autoApprove === true })
348
380
  // 待裁决摘要落盘:页签 /run 路由据此渲染审批上下文
349
381
  this.store.update({ runId: this.runId, waiting: payload.waiting })
350
382
  return payload
@@ -48,7 +48,7 @@ function rotateCursor(state, slotKey) {
48
48
  state.slotCursor[slotKey] = (state.slotCursor[slotKey] ?? 0) + 1
49
49
  }
50
50
 
51
- // 单批次:组 Batch → engine.start → 收敛逐 call 记账。返回 {results, cancelled}
51
+ // 单批次:组 Batch → engine.start → 收敛逐 call 记账。返回 {settled, cancelled}
52
52
  export async function runBatch({ state, script, template, batch, ctx }) {
53
53
  const { store, runId, engine, parent, signal } = ctx
54
54
  const calls = []
@@ -87,8 +87,9 @@ export async function runBatch({ state, script, template, batch, ctx }) {
87
87
  }
88
88
 
89
89
  let outcome
90
+ let run
90
91
  try {
91
- const run = engine.start({
92
+ run = engine.start({
92
93
  script: FLOW_EXEC_SOURCE,
93
94
  args: { calls },
94
95
  // 宿主 meta 契约:description 必填非空;phases 必须为 {title} 对象数组(dsh-workflow-worker-thread meta 校验)
@@ -104,15 +105,20 @@ export async function runBatch({ state, script, template, batch, ctx }) {
104
105
  } catch (e) {
105
106
  if (signal?.aborted) return { cancelled: true }
106
107
  outcome = { results: calls.map((c) => ({ callId: c.callId, ok: false, error: String(e?.message ?? e) })) }
108
+ } finally {
109
+ // 宿主契约:caller must dispose every run(否则 worker 线程泄漏);engine.start 同步抛时 run 为空
110
+ await run?.dispose?.().catch?.(() => {})
107
111
  }
108
112
  if (signal?.aborted) return { cancelled: true }
109
113
  // 引擎 run.result 契约:{ value(脚本返回值), stopReason, error?, agentsStarted }
110
- // stopReason 非 completed(如 cancelled/error)时 value 不可信,全部调用按失败记账
114
+ // stopReason 非 completed(如 error)时 value 不可信,全部调用统一按失败记账
115
+ let results
111
116
  if (outcome?.stopReason !== undefined && outcome?.stopReason !== 'completed') {
112
117
  const reason = String(outcome?.error ?? outcome?.stopReason)
113
- return { results: calls.map((c) => ({ callId: c.callId, ok: false, error: reason })) }
118
+ results = calls.map((c) => ({ callId: c.callId, ok: false, error: reason }))
119
+ } else {
120
+ results = outcome?.value?.results ?? outcome?.results ?? []
114
121
  }
115
- const results = outcome?.value?.results ?? outcome?.results ?? []
116
122
  const settled = []
117
123
  for (const result of results) {
118
124
  const meta = callMeta.get(result.callId)
@@ -45,7 +45,8 @@ function expandForEach(state, step) {
45
45
  const list = sourceList(state, step)
46
46
  if (list === null) return
47
47
  s.instances = list.map((item, index) => ({
48
- key: `#${index + 1}`, index, item, status: 'pending', outputs: null, failCount: 0, carry: null,
48
+ // index 1 基(与 key #N 及 spec {item.index} 序号口径一致)
49
+ key: `#${index + 1}`, index: index + 1, item, status: 'pending', outputs: null, failCount: 0, carry: null,
49
50
  }))
50
51
  }
51
52
 
package/lib/guard.mjs ADDED
@@ -0,0 +1,67 @@
1
+ // 会话守门:轮次将停时判定本会话编排是否欠动作(persona 判据 3 的机器化执行)
2
+ // 判据来源:running 且无活跃段 job → 欠 resume;paused 且 awaitingResume → 欠 resume;
3
+ // waiting_approval → 欠裁决。终态与无现役 run 不拦。
4
+ // 拦截手段由调用方承担(agent.steer 投喂提醒消息,机器重读 inbox 后继续跑)。
5
+
6
+ // 单个欠动作周期的提醒上限:弱模型可能忽略首次提醒,但无限提醒会烧 token 死循环。
7
+ // 计数在轮次不欠动作时清零,故多步编排的每个欠动作周期各自享有上限,而非全 run 共享。
8
+ export const MAX_NUDGE = 2
9
+
10
+ const ACTION_OF_STATUS = {
11
+ waiting_approval: { need: '裁决', text: '等待裁决' },
12
+ paused: { need: 'resume', text: '已暂停' },
13
+ }
14
+
15
+ /** 判据:record 状态 + driver 活跃度 → 是否欠动作;无现役 run / 终态 / 段在飞均返回 undefined */
16
+ export function pendingOf(record, driver) {
17
+ if (record === undefined) return undefined
18
+ if (record.status === 'running') {
19
+ // 段在飞即正常等待:段 job settle 会经完成通知唤醒主循环,此时轮次停是合理行为
20
+ return driver?.active === true ? undefined : { need: 'resume', text: '运行中待拉起' }
21
+ }
22
+ const known = ACTION_OF_STATUS[record.status]
23
+ if (known === undefined) return undefined
24
+ if (record.status === 'paused' && driver?.awaitingResume !== true) {
25
+ // paused 且页签未发恢复:裁决已入队的 resume 拉段仍欠——段首生效前欠动作不变
26
+ if ((record.state?.pendingApprovals?.length ?? 0) > 0) return { need: 'resume', text: '已暂停(待生效裁决)' }
27
+ return undefined
28
+ }
29
+ return known
30
+ }
31
+
32
+ /** 提醒计数闸:按欠动作周期独立计数,超上限返回 false(调用方改记告警,不再 steer) */
33
+ export function createNudgeGate(maxNudge = MAX_NUDGE) {
34
+ const counts = new Map()
35
+ const warned = new Set()
36
+ return {
37
+ take(runId) {
38
+ const used = counts.get(runId) ?? 0
39
+ if (used >= maxNudge) return false
40
+ counts.set(runId, used + 1)
41
+ return true
42
+ },
43
+ clear(runId) {
44
+ counts.delete(runId)
45
+ warned.delete(runId)
46
+ },
47
+ used(runId) {
48
+ return counts.get(runId) ?? 0
49
+ },
50
+ /** 首次触顶返回 true,此后false(告警只一次;clear 重置) */
51
+ exhausted(runId) {
52
+ if ((counts.get(runId) ?? 0) < maxNudge) return false
53
+ if (warned.has(runId)) return false
54
+ warned.add(runId)
55
+ return true
56
+ },
57
+ }
58
+ }
59
+
60
+ /** 提醒文案:给出 runId / 状态 / 欠动作,并指向应调用的工具 */
61
+ export function nudgeText(runId, pending) {
62
+ // waiting_approval 有合法终止路径(转呈真人后等输入),文案带豁免以免误伤已处置的轮次
63
+ const dispense = pending.need === '裁决'
64
+ ? '裁决按模板 autoApprove 决定代审或转呈真人;若已转呈真人并等待其输入,则本轮无需再动作,可直接结束轮次。'
65
+ : '欠 resume 调 rs_workflow_resume 补拉。'
66
+ return `[若水守门] 本会话编排 ${runId} 未终态(状态=${pending.text},欠动作=${pending.need})。请立即调用 rs_workflow_status 查看并按流程纪律处置:${dispense}处置完成后才可结束轮次。`
67
+ }