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.
- package/CHANGELOG.md +41 -0
- package/README.md +52 -25
- package/package.json +3 -2
- package/src/console.html +135 -43
- package/src/criteria.js +4 -5
- package/src/gates.js +15 -4
- package/src/human-channel.js +116 -10
- package/src/model-call.js +20 -3
- package/src/rework-store.js +69 -0
- package/src/split.js +205 -53
package/src/human-channel.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
602
|
-
|
|
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
|
+
}
|