dsh-card-dispatch 0.1.0 → 0.1.1

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.
@@ -24,6 +24,7 @@ import { describeConfig, mergeConfig, normalizeConfig } from './config.js'
24
24
  import { evaluateStart } from './gates.js'
25
25
  import { runSelfCheck } from './selfcheck.js'
26
26
  import { decideNext, parseVerdict, verdictPath } from './verdict.js'
27
+ import { createReworkStore } from './rework-store.js'
27
28
  import { parseRequirementDoc } from './import.js'
28
29
  import { suggestCriteria } from './criteria.js'
29
30
  import { draftWithModel } from './criteria-llm.js'
@@ -48,6 +49,27 @@ function isLoopbackAddress(address) {
48
49
  return value === '::1' || value === '127.0.0.1' || value.startsWith('127.') || value === '::ffff:127.0.0.1'
49
50
  }
50
51
 
52
+ /**
53
+ * 运行态快照:页面从看板 `/state` 的 executions 汇总后传进来。
54
+ *
55
+ * 缺一个数就返回 null(而不是补 0):补 0 等于「今天一次没跑、没有会话在跑」,
56
+ * 两个闸会永远放行——这正是要修掉的那个洞。
57
+ *
58
+ * @param {unknown} raw
59
+ * @returns {{runsToday:number, runningSessions:number}|null}
60
+ */
61
+ function gateStateOf(raw) {
62
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null
63
+ const count = (value) => {
64
+ const number = Number(value)
65
+ return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null
66
+ }
67
+ const runsToday = count(raw.runsToday)
68
+ const runningSessions = count(raw.runningSessions)
69
+ if (runsToday === null || runningSessions === null) return null
70
+ return { runsToday, runningSessions }
71
+ }
72
+
51
73
  /**
52
74
  * 模型写的「提示词开头」,只认 root / part-N 两种 key。
53
75
  *
@@ -187,9 +209,21 @@ export function createHumanChannel(deps = {}) {
187
209
  return { status: 200, body: { ok: true, rejected, config: describeConfig(merged) } }
188
210
  }
189
211
 
190
- const statusRoute = () => {
212
+ const statusRoute = ({ searchParams } = {}) => {
191
213
  const workspaces = safeWorkspaces()
192
- const check = runSelfCheck({ config: current, facts: { ...facts(), workspaceCount: workspaces.length } })
214
+ // 页面能看见看板(同源 fetch),插件进程看不见(看板自己有信任栅栏)。
215
+ // 所以让页面把探测结果带回来,体检才不是一堆「未知」。
216
+ const query = searchParams ?? new URLSearchParams()
217
+ const tri = (key) => {
218
+ const raw = query.get(key)
219
+ return raw === 'true' ? true : (raw === 'false' ? false : null)
220
+ }
221
+ const mergedFacts = { ...facts(), workspaceCount: workspaces.length }
222
+ const reachable = tri('boardReachable')
223
+ const teamRun = tri('boardTeamRun')
224
+ if (reachable !== null) mergedFacts.boardReachable = reachable
225
+ if (teamRun !== null) mergedFacts.boardTeamRun = teamRun
226
+ const check = runSelfCheck({ config: current, facts: mergedFacts })
193
227
  return {
194
228
  status: 200,
195
229
  body: {
@@ -248,14 +282,45 @@ export function createHumanChannel(deps = {}) {
248
282
  ? { ok: false, verdict: null, items: [], problems: [], summary: '', error: readError }
249
283
  : parseVerdict(text)
250
284
 
251
- const decision = decideNext({ parsed, config: current, rejectedCount: body?.rejectedCount ?? 0 })
285
+ // 打回次数由**服务端落盘**决定,不信任 body 里的 rejectedCount:
286
+ // 以前计数只活在页面内存,刷新一次就归零,R3 的「第三次必须升级」形同虚设。
287
+ const reworkStore = createReworkStore({ dir: current.dataDir })
288
+ let previous = 0
289
+ try {
290
+ previous = reworkStore.get(rootId)
291
+ } catch {
292
+ // 读不出来按 0 处理;真正写的时候失败会明确报错(见下)
293
+ previous = 0
294
+ }
295
+ const decision = decideNext({ parsed, config: current, rejectedCount: previous })
252
296
  // 一次可以重跑多张卡:验收结论的 items 里可能有好几条判据,分属不同子卡。
253
297
  // `taskId` 是单卡的老写法,保留;`taskIds` 是页面上用的写法。
254
298
  const requested = Array.isArray(body?.taskIds)
255
299
  ? body.taskIds
256
300
  : (typeof body?.taskId === 'string' ? [body.taskId] : [])
257
301
  const taskIds = requested.map((value) => String(value ?? '').trim()).filter((value) => value !== '')
258
- const runs = decision.action === 'rework'
302
+ // 只有「真的给出了要重跑的卡」才算一次打回:页面会先读一次结论、再带着卡 id 调一次,
303
+ // 第一次不该把计数加掉,否则上限会少算一轮。
304
+ const willRerun = decision.action === 'rework' && taskIds.length > 0
305
+ let counted = false
306
+ if (willRerun) {
307
+ try {
308
+ reworkStore.set(rootId, decision.rejectedCount)
309
+ counted = true
310
+ } catch (error) {
311
+ return {
312
+ status: 500,
313
+ body: { ok: false, error: { code: 'E_STORE_WRITE', message: `打回次数写盘失败(上限不生效,已拒绝重跑):${error?.message ?? String(error)}` } },
314
+ }
315
+ }
316
+ } else if (decision.action === 'pass') {
317
+ try {
318
+ reworkStore.set(rootId, 0)
319
+ } catch {
320
+ // 验收通过时清不掉旧计数不致命:下次 rework 会按旧值继续,至少不会放宽
321
+ }
322
+ }
323
+ const runs = willRerun
259
324
  ? taskIds.map((id) => ({ requestId: `card-dispatch-${randomUUID()}`, action: { kind: 'run', taskId: id } }))
260
325
  : []
261
326
  return {
@@ -265,10 +330,13 @@ export function createHumanChannel(deps = {}) {
265
330
  path,
266
331
  verdict: parsed,
267
332
  decision,
333
+ // 这次到底有没有把计数加掉:页面据此说「第 N 次」才不会虚报
334
+ counted,
335
+ attempt: decision.rejectedCount,
268
336
  runs,
269
337
  run: runs.length > 0 ? runs[0] : null,
270
338
  // 判定做完了但不知道重跑哪张卡时,明确说清楚
271
- hint: decision.action === 'rework' && runs.length === 0 ? '要重跑就带上 taskIds(被拒的那几张开发卡)' : '',
339
+ hint: decision.action === 'rework' && runs.length === 0 ? '要重跑就带上 taskIds(被拒的那几张开发卡);带上才会记一次打回' : '',
272
340
  },
273
341
  }
274
342
  }
@@ -598,22 +666,29 @@ export function createHumanChannel(deps = {}) {
598
666
  criteria: body?.criteria ?? [],
599
667
  parallel: body?.parallel !== false,
600
668
  workspaceId: body?.workspaceId ?? null,
601
- agentPreset: body?.agentPreset ?? null,
602
- permission: body?.permission ?? current.permission ?? 'workspace-write',
669
+ // 权限与预设只认配置:body 能覆盖的话,curl 一句就能把卡提到 danger-full-access,
670
+ // 「配置里写 workspace-write」就成了摆设。页面本来也不传这两个字段。
671
+ agentPreset: null,
672
+ permission: current.permission,
603
673
  // 交付门槛开着才生成验收卡与证据要求;关掉就是省一张卡(它的会话费在团队模式下本来也不额外计)。
604
674
  requireEvidence: current.gates.requireEvidence === true,
605
675
  // 智能体团队由开关决定;父卡带 teamRun,子卡不带(子卡是 Lead 派出去的 teammate)。
606
676
  teamRun: current.execution?.teamRun === true,
607
677
  intros: sanitizeIntros(body?.intros),
678
+ maxReworkRounds: current.gates.maxReworkRounds,
608
679
  })
609
680
  // 拆分本身不花钱(只算文本),所以闸门卡的是**运行信封**:闸门不允许时
610
681
  // `run` 直接为 null,页面拿不到可执行的指令,就不会误开一轮。
611
682
  // 计划闸的「批准」在这一层只能来自人点「写入并运行」这一下——插件无法证明人
612
683
  // 真的读过拆分,这点在文档里写明了,不假装能证明。
684
+ // 运行态(今天跑了几次、几个在跑)由页面从看板 /state 汇总后传来;
685
+ // 拿不到就不放行(E_STATE_UNKNOWN),不能拿 0 冒充。
686
+ const gateState = gateStateOf(body?.state)
613
687
  const gate = evaluateStart({
614
688
  config: current,
615
- state: {},
689
+ state: gateState ?? {},
616
690
  request: { planApproved: body?.planApproved === true },
691
+ stateKnown: gateState !== null,
617
692
  })
618
693
  return { status: 200, body: { ok: true, ...plan, run: gate.allow ? plan.run : null, gate } }
619
694
  } catch (error) {
@@ -655,7 +730,7 @@ export function createHumanChannel(deps = {}) {
655
730
  if (method === 'POST' && pathname === '/criteria') return criteriaRoute(request)
656
731
  if (method === 'POST' && pathname === '/parse') return parseRoute(request)
657
732
  if (method === 'POST' && pathname === '/plan') return planRoute(request)
658
- if (method === 'GET' && pathname === '/status') return statusRoute()
733
+ if (method === 'GET' && pathname === '/status') return statusRoute({ searchParams: request.searchParams })
659
734
  if (method === 'GET' && pathname === '/config') {
660
735
  return { status: 200, body: { ok: true, configFile: store?.file ?? null, config: describeConfig(current) } }
661
736
  }
@@ -675,11 +750,13 @@ export function createHumanChannel(deps = {}) {
675
750
  }
676
751
  }
677
752
 
678
- function createHttpHandler(channel, options = {}) {
753
+ export function createHttpHandler(channel, options = {}) {
679
754
  const send = (res, status, body, contentType = 'application/json; charset=utf-8') => {
680
755
  res.statusCode = status
681
756
  res.setHeader('content-type', contentType)
682
757
  res.setHeader('cache-control', 'no-store')
758
+ // 页面里的内容只该按声明的类型解释;顺手挡掉跨站嗅探。
759
+ res.setHeader('x-content-type-options', 'nosniff')
683
760
  res.end(typeof body === 'string' ? body : JSON.stringify(body ?? null))
684
761
  }
685
762
 
@@ -689,6 +766,29 @@ function createHttpHandler(channel, options = {}) {
689
766
  send(res, 403, { ok: false, error: { code: 'E_NOT_LOOPBACK', message: '控制台只接受本机回环请求' } })
690
767
  return
691
768
  }
769
+
770
+ // 同源栅栏:人标记不是鉴权,跨站页面可以带 `human: true` 发简单请求。
771
+ // 宿主有 connection 服务时下面还会再叠一层;没有时这里就是唯一一道。
772
+ const fetchSite = String(req.headers?.['sec-fetch-site'] ?? '').toLowerCase()
773
+ if (fetchSite === 'cross-site' || fetchSite === 'same-site') {
774
+ send(res, 403, { ok: false, error: { code: 'E_CROSS_SITE', message: '只接受同源请求' } })
775
+ return
776
+ }
777
+ const origin = req.headers?.origin
778
+ if (origin !== undefined) {
779
+ let originHost = ''
780
+ try {
781
+ originHost = new URL(String(origin)).host.toLowerCase()
782
+ } catch {
783
+ originHost = ''
784
+ }
785
+ const host = String(req.headers?.host ?? '').toLowerCase()
786
+ if (originHost === '' || originHost !== host) {
787
+ send(res, 403, { ok: false, error: { code: 'E_CROSS_ORIGIN', message: 'Origin 与 Host 不一致:只接受同源请求' } })
788
+ return
789
+ }
790
+ }
791
+
692
792
  const rejection = options.connection?.requestRejection?.(req)
693
793
  if (rejection !== undefined) {
694
794
  send(res, rejection, { ok: false, error: { code: 'E_UNTRUSTED_CONNECTION', message: `宿主连接栅栏拒绝:${rejection}` } })
@@ -702,6 +802,12 @@ function createHttpHandler(channel, options = {}) {
702
802
 
703
803
  let body
704
804
  if (String(req.method).toUpperCase() === 'POST') {
805
+ // 只收 JSON:`text/plain` 的简单请求不触发预检,正是跨站伪造最常用的形状。
806
+ const contentType = String(req.headers?.['content-type'] ?? '').toLowerCase()
807
+ if (!contentType.startsWith('application/json')) {
808
+ send(res, 415, { ok: false, error: { code: 'E_JSON_REQUIRED', message: 'POST 必须带 content-type: application/json' } })
809
+ return
810
+ }
705
811
  try {
706
812
  const text = await readBoundedBody(req)
707
813
  body = text.trim() === '' ? {} : JSON.parse(text)
package/src/model-call.js CHANGED
@@ -30,19 +30,35 @@ async function buildMessage(text) {
30
30
  /**
31
31
  * 让模型回一段文本。
32
32
  *
33
+ * 失败重试一次:只在**非超时**的失败上重试(网络抖动、流断掉)。
34
+ * 超时不重试——`/plan` 本来就要顺序调两次,再翻倍等待只会让人以为页面卡死;
35
+ * 超时直接退回规则解析,让人看到「模型没答上来」。
36
+ *
33
37
  * @param {object} input
34
38
  * @param {any} input.llm 宿主的 llm 服务
35
39
  * @param {{provider:string, model:string}} input.route
36
40
  * @param {string} input.system 系统提示
37
41
  * @param {string} input.text 用户内容
38
42
  * @param {number} [input.timeoutMs]
43
+ * @param {number} [input.retries] 额外重试次数(默认 1)
39
44
  * @returns {Promise<{ok:boolean, reply:string, error:string|null}>}
40
45
  */
41
- export async function callModel({ llm, route, system, text, timeoutMs = MODEL_TIMEOUT_MS } = {}) {
46
+ export async function callModel({ llm, route, system, text, timeoutMs = MODEL_TIMEOUT_MS, retries = 1 } = {}) {
42
47
  if (llm === null || llm === undefined) return { ok: false, reply: '', error: '宿主没有提供 llm 服务' }
43
48
  if (route === null || route === undefined) return { ok: false, reply: '', error: '没有可用的模型路由' }
44
49
  if (typeof llm.stream !== 'function') return { ok: false, reply: '', error: 'llm 服务没有 stream 方法' }
45
50
 
51
+ const extra = Math.max(0, Math.trunc(Number(retries) || 0))
52
+ let last = { ok: false, reply: '', error: '模型没有回内容' }
53
+ for (let attempt = 0; attempt <= extra; attempt += 1) {
54
+ last = await callOnce({ llm, route, system, text, timeoutMs })
55
+ if (last.ok || last.aborted === true) return { ok: last.ok, reply: last.reply, error: last.error }
56
+ if (attempt < extra) await new Promise((resolve) => setTimeout(resolve, 300))
57
+ }
58
+ return { ok: last.ok, reply: last.reply, error: last.error }
59
+ }
60
+
61
+ async function callOnce({ llm, route, system, text, timeoutMs }) {
46
62
  const controller = new AbortController()
47
63
  const timer = setTimeout(() => controller.abort(), timeoutMs)
48
64
  try {
@@ -61,14 +77,15 @@ export async function callModel({ llm, route, system, text, timeoutMs = MODEL_TI
61
77
  for await (const chunk of stream) {
62
78
  if (chunk !== null && chunk !== undefined && chunk.type === 'text-delta') reply += chunk.text
63
79
  }
64
- if (reply.trim() === '') return { ok: false, reply: '', error: '模型没有回内容' }
65
- return { ok: true, reply, error: null }
80
+ if (reply.trim() === '') return { ok: false, reply: '', error: '模型没有回内容', aborted: false }
81
+ return { ok: true, reply, error: null, aborted: false }
66
82
  } catch (error) {
67
83
  const aborted = controller.signal.aborted
68
84
  return {
69
85
  ok: false,
70
86
  reply: '',
71
87
  error: aborted ? `模型 ${timeoutMs / 1000}s 内没答上来` : String(error?.message ?? error),
88
+ aborted,
72
89
  }
73
90
  } finally {
74
91
  clearTimeout(timer)
@@ -0,0 +1,69 @@
1
+ /**
2
+ * rework-store.js:把「这一批已经打回了几次」**落到磁盘**。
3
+ *
4
+ * 为什么不能只放页面内存:R3 要求「第三次打回必须升级给人」。以前计数只活在一个
5
+ * JS 变量里,刷新页面/重开面板就归零,等于这条上限可以被无限绕过。
6
+ *
7
+ * 存哪儿:`<dataDir>/rework.json`,按 `rootId` 记一个很小的对象。
8
+ * 和 config.json 一样:读失败当空、写失败要报错(写不进去就不能假装限流生效)。
9
+ *
10
+ * @module dsh-card-dispatch/rework-store
11
+ */
12
+
13
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
14
+ import { join, resolve } from 'node:path'
15
+
16
+ /**
17
+ * @param {object} options
18
+ * @param {string} options.dir dataDir(相对宿主 cwd,和 config.json 同一个目录)
19
+ */
20
+ export function createReworkStore({ dir }) {
21
+ const root = resolve(dir)
22
+ const file = join(root, 'rework.json')
23
+
24
+ /** @returns {Record<string, {count:number, updatedAt:string}>} */
25
+ function loadAll() {
26
+ try {
27
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
28
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
29
+ return parsed
30
+ } catch {
31
+ // 文件不存在 / 被手改成半截 JSON:都当「没有记录」,不能因此让面板挂掉
32
+ return {}
33
+ }
34
+ }
35
+
36
+ /**
37
+ * 这一批已经打回了几次。
38
+ *
39
+ * @param {string} rootId
40
+ * @returns {number}
41
+ */
42
+ function get(rootId) {
43
+ const entry = loadAll()[rootId]
44
+ const count = Number(entry?.count)
45
+ return Number.isFinite(count) && count > 0 ? Math.trunc(count) : 0
46
+ }
47
+
48
+ /**
49
+ * 写回计数;0 表示清掉记录(验收通过)。
50
+ *
51
+ * @param {string} rootId
52
+ * @param {number} count
53
+ * @returns {number}
54
+ */
55
+ function set(rootId, count) {
56
+ const value = Math.max(0, Math.trunc(Number(count) || 0))
57
+ const all = loadAll()
58
+ if (value === 0) {
59
+ delete all[rootId]
60
+ } else {
61
+ all[rootId] = { count: value, updatedAt: new Date().toISOString() }
62
+ }
63
+ mkdirSync(root, { recursive: true })
64
+ writeFileSync(file, `${JSON.stringify(all, null, 2)}\n`, 'utf8')
65
+ return value
66
+ }
67
+
68
+ return { file, get, set }
69
+ }