@marsken/dsh-studio 0.1.3 → 0.1.5

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.
@@ -6,7 +6,7 @@
6
6
 
7
7
  | 项 | 值 |
8
8
  | --- | --- |
9
- | dsh-studio | `package.json` `version: 0.1.3` |
9
+ | dsh-studio | `package.json` `version: 0.1.5` |
10
10
  | DeepSeek Harness | `0.2.2-rc.1`(`dsh --version`) |
11
11
  | Node | `v24.22.2`(Linux 目标机);本机 Windows `v24.19.0` |
12
12
  | 目标系统 | Ubuntu 24.04,`dsh web` 监听 `18080`,nginx `:80` 反代并注入 `dsh-auth-*` cookie |
package/docs/publish.md CHANGED
@@ -127,10 +127,13 @@ token 只写进临时 npmrc(放在仓库外或 `tmp/` 且不提交),发完
127
127
  | --- | --- | --- |
128
128
  | `0.1.0` | 2026-11-13 | 首个公开版:看板 / 需求 / 用例回归 / 效能 / 设计评审四期 |
129
129
  | `0.1.1` | 2026-11-13 | 补三张 README 截图(npm 页可看图);默认值与文档去掉公司内网信息 |
130
+ | `0.1.5` | 2026-11-14 | agent/failed 事件、控制面板(执行开关 / 上限 / webhook 按事件订阅)、抽屉里加数据库用例入口 |
131
+ | `0.1.4` | 2026-11-14 | SQLite 用例、出站 webhook、可配置上限、执行器注册表 |
130
132
  | `0.1.3` | 2026-11-13 | shell 用例(命令 + 退出码/输出断言,默认关闭) |
131
133
  | `0.1.2` | 2026-11-13 | 修 5 个缺陷:卡片挂不上 MR、换列清空负责人、三处静默截断、还原度无历史、规范体检不定位元素 |
132
134
 
133
135
  两条硬规矩,每次发版都过一遍:
134
136
 
135
137
  1. `npm run check` + `pnpm build` + `cmp -s dist/client.js client.js`(产物必须逐字节一致);
136
- 2. 发完**在临时 profile 里装一遍**并跑 `dsh --profile <tmp> --dump-config | grep dsh-studio`,确认宿主真认到了,然后把 profile 删掉。
138
+ 2. 发完**在临时 profile 里装一遍**并跑 `dsh --profile <tmp> --dump-config | grep dsh-studio`,确认宿主真认到了,然后把 profile 删掉;
139
+ 3. 部署脚本退出码 0 **不等于服务活着**——它打印的 `state=` 要自己看一眼,再单独 `curl -o /dev/null -w '%{http_code}' <host>/studio-api/state` 确认 200(2026-11-13 有过一次 `state=404` 被当成成功,线上挂了十分钟)。
package/index.js CHANGED
@@ -29,6 +29,8 @@ export const inject = ['tools', 'systemPrompt']
29
29
 
30
30
  import { ACCESS_ROLE_LABEL, ACCESS_ROLES, ADMIN_ROUTES, ARTIFACT_KINDS, ARTIFACT_STATUS, CASE_HTTP_METHODS, CASE_PRIORITIES, CASE_RESULTS, CASE_TYPE_LABEL, CASE_TYPES, cleanId, CODE_PREFIX, CODING_FILE_CHANGES, CODING_REVIEW_STATUS, codingSummary, COLUMN_STRATEGIES, DEFAULT_ROLES, defaultColumns, defaultConfig, defaultDeployEnvs, defaultTemplates, DEPLOY_ENV_KINDS, FALLBACK_AGENT_MODEL, hashLocalPassword, INTAKE_HTML, ISOLATION_MODES, LEGACY_BLOCKED_STATUS, LEVEL_RANK, LOCAL_DEFAULT_PASSWORD, mergeCoding, mergeConfig, mergeSection, MINE_HTML, nextRequirementCode, nextTaskCode, normalizePatch, normalizeStrategy, publicArtifact, publicCases, publicChecklist, publicComments, PRIORITIES, ROLE_LABELS, canonicalAuthor, RELEASE_STATUSES, RELEASE_STATUS_LABEL, REQ_DOC_KIND_LABEL, REQ_DOC_KINDS, REQ_DOC_STATUS, REQ_REVIEW_LABEL, REQ_REVIEW_STATUS, REQ_STATUS_LABEL, REQ_STATUSES, ROLE_IDS, ROLE_LEVEL, SANDBOX_MODES, sanitizeAcceptanceItem, sanitizeAccess, sanitizeAllowHosts, sanitizeArtifact, sanitizeArtifacts, sanitizeBaseline, sanitizeBots, sanitizeBugSource, sanitizeCase, sanitizeCaseHttp, sanitizeCaseRun, sanitizeCases, sanitizeChangedFile, sanitizeChangedFiles, sanitizeChecklist, sanitizeChecklistItem, sanitizeCoding, sanitizeColumns, sanitizeCommit, sanitizeCommits, sanitizeCosts, sanitizeDeployEnvs, sanitizeEnvs, sanitizeExperts, sanitizeIterations, sanitizeLdap, sanitizeLocalAuth, sanitizeProjects, sanitizeReqDoc, sanitizeReqDocs, sanitizeRelease, sanitizeRequirement, sanitizeRequirementChange, sanitizeRequirementHistory, sanitizeRequirementPoint, sanitizeRequirementPoints, sanitizeRequirementPool, sanitizeRequirementQuestions, sanitizeRequirementReview, sanitizeRequirements, sanitizeServices, sanitizeSla, sanitizeTeams, sanitizeTemplates, sanitizeTesting, sanitizeTestRun, sanitizeTestRuns, sanitizeUsers, sanitizeWorkProjects, seedExperts, SEVERITIES, STATUS_HTML, STATUS_IDS, STATUS_SET, STATUSES, TASK_TYPES, text, verifyLocalPassword, worktreeRoot } from './src/host/domain.js'
31
31
  import { Dispatcher, RUN_LOG, attachRunLog, claimDispatchLock, clearAgentFailure, configureDispatcher, noteAgentFailure, parseMentions, runSummary } from './src/host/dispatcher.js'
32
+ import { sanitizeLimits } from './src/host/limits.js'
33
+ import { attachWebhooks, sanitizeWebhooks } from './src/host/webhooks.js'
32
34
  import { buildDashboard, codingVerifyLine, commentTask, configureExecutor, ensureReqFolder, larkCliReady, invalidateLarkProbe, larkProbeCache, mutateReqDoc, probeEnv, pullFeishuDoc, requirementGate, requirementMatrix, requirementProgress, requirementRollup, runCommand, runSuite, runTaskCases, saveRequirement, syncArtifact, syncRequirementArtifacts, taskExitGate, updateTask, verifyCodingRecord } from './src/host/executor.js'
33
35
  import { configureAuthRoutes, handleAuthRoute } from './src/host/routes/auth.js'
34
36
  import { configureExportRoutes, handleExportRoute } from './src/host/routes/exports.js'
@@ -481,6 +483,8 @@ function publicConfig(config) {
481
483
  access: config.access,
482
484
  sla: config.sla,
483
485
  design: config.design,
486
+ limits: config.limits,
487
+ webhooks: config.webhooks,
484
488
  costs: config.costs,
485
489
  /** 部署环境:只用于登记「版本上过哪个环境」,不触发部署。 */
486
490
  environments: config.environments ?? defaultDeployEnvs(),
@@ -1150,6 +1154,8 @@ async function applyConfig(store, patch) {
1150
1154
  if (typeof f.escalateWebhook === 'string') config.feishu.escalateWebhook = f.escalateWebhook.trim().slice(0, 500)
1151
1155
  if (Number.isFinite(Number(f.escalateAfter))) config.feishu.escalateAfter = Math.max(1, Math.min(Math.round(Number(f.escalateAfter)), 20))
1152
1156
  }
1157
+ if (patch.limits !== undefined) config.limits = sanitizeLimits({ ...config.limits, ...(patch.limits ?? {}) })
1158
+ if (patch.webhooks !== undefined) config.webhooks = sanitizeWebhooks(patch.webhooks)
1153
1159
  if (patch.design !== undefined) {
1154
1160
  const next = { ...config.design, ...(patch.design ?? {}) }
1155
1161
  config.design = {
@@ -2539,6 +2545,8 @@ export function apply(ctx) {
2539
2545
  activeStore = store
2540
2546
  // 派单日志落盘:重启后「按专家看质量」的派单量与耗时还在。
2541
2547
  attachRunLog(store)
2548
+ // 事件 → 出站 webhook(卡片流转、用例失败、派单失败)
2549
+ attachWebhooks(store)
2542
2550
  activeSkills = skills
2543
2551
 
2544
2552
  // 记住自己跑在哪个 profile:市场装卸与「profile xxx」展示都靠它。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marsken/dsh-studio",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "DSH Studio: role-based multi-agent workspace (product / architect / developer / QA) with a live work board, GitLab + Feishu connection settings, all configurable from the web GUI.",
6
6
  "main": "index.js",
@@ -0,0 +1,56 @@
1
+ // SQLite 用例执行器:对着一个库跑一条语句,断言行数与首行某列的值。
2
+ // 和 shell 一样**默认关闭**(config.testing.allowDb)——打开它等于把「读宿主上的任意库」交给看板;
3
+ // 默认按只读打开,要写入必须显式 readOnly: false。
4
+ import { DatabaseSync } from 'node:sqlite'
5
+
6
+ /** 用例里的 db 字段:file + sql 必填,其余是断言。 */
7
+ export function sanitizeDbCase(raw) {
8
+ if (raw === null || typeof raw !== 'object') return null
9
+ const file = String(raw.file ?? '').trim().slice(0, 400)
10
+ const sql = String(raw.sql ?? '').trim().slice(0, 2000)
11
+ if (file === '' || sql === '') return null
12
+ return {
13
+ file,
14
+ sql,
15
+ params: (Array.isArray(raw.params) ? raw.params : []).slice(0, 20).map(value => (typeof value === 'number' ? value : String(value ?? '').slice(0, 200))),
16
+ expectRows: Number.isInteger(Number(raw.expectRows)) ? Math.max(0, Number(raw.expectRows)) : null,
17
+ expectColumn: String(raw.expectColumn ?? '').slice(0, 60),
18
+ expectValue: raw.expectValue === undefined || raw.expectValue === null ? null : String(raw.expectValue).slice(0, 200),
19
+ readOnly: raw.readOnly !== false,
20
+ }
21
+ }
22
+
23
+ export async function runDbCase(item, config) {
24
+ const started = Date.now()
25
+ const spec = item.db
26
+ if (config?.testing?.allowDb !== true) {
27
+ return { ok: false, status: 0, ms: 0, error: '这台机器没开数据库用例:设置 → 测试执行 里打开「允许数据库用例」', preview: '' }
28
+ }
29
+ let database
30
+ try {
31
+ database = new DatabaseSync(spec.file, { readOnly: spec.readOnly })
32
+ } catch (error) {
33
+ return { ok: false, status: 0, ms: Date.now() - started, error: '打不开这个库:' + String(error?.message ?? error).slice(0, 200), preview: '' }
34
+ }
35
+ try {
36
+ const rows = database.prepare(spec.sql).all(...(spec.params ?? []))
37
+ const problems = []
38
+ if (spec.expectRows !== null && rows.length !== spec.expectRows) problems.push('行数 ' + rows.length + '(期望 ' + spec.expectRows + ')')
39
+ if (spec.expectColumn !== '' && spec.expectValue !== null) {
40
+ const first = rows[0] ?? {}
41
+ const actual = first[spec.expectColumn]
42
+ if (String(actual) !== spec.expectValue) problems.push('首行 ' + spec.expectColumn + ' = ' + String(actual) + '(期望 ' + spec.expectValue + ')')
43
+ }
44
+ return {
45
+ ok: problems.length === 0,
46
+ status: 200,
47
+ ms: Date.now() - started,
48
+ error: problems.join(';').slice(0, 300),
49
+ preview: JSON.stringify(rows.slice(0, 5)).slice(0, 600),
50
+ }
51
+ } catch (error) {
52
+ return { ok: false, status: 0, ms: Date.now() - started, error: '语句执行失败:' + String(error?.message ?? error).slice(0, 200), preview: '' }
53
+ } finally {
54
+ try { database.close() } catch { /* 已经关了 */ }
55
+ }
56
+ }
@@ -1,5 +1,6 @@
1
1
  // ------------------------------------------------------------------ dispatcher
2
2
  import { DEFAULT_ROLES, FALLBACK_AGENT_MODEL, ROLE_IDS, SANDBOX_MODES, STATUSES, codingSummary, normalizeStrategy, worktreeRoot } from './domain.js'
3
+ import { emitEvent } from './events.js'
3
4
  import { loadTeamMemory, teamMemoryBlock } from './team-memory.js'
4
5
  import { mkdir } from 'node:fs/promises'
5
6
  import { randomBytes } from 'node:crypto'
@@ -345,6 +346,7 @@ export async function noteAgentFailure(store, taskId, error, by = '系统', opts
345
346
  host.INTEGRATION.lastPushError = String(cause?.message ?? cause).slice(0, 300)
346
347
  })
347
348
  }
349
+ emitEvent('agent/failed', { taskId, attempts: next, exhausted: plan.exhausted, error: message, by })
348
350
  return { failures: next, ...plan, message }
349
351
  }
350
352
 
@@ -1,14 +1,9 @@
1
- /**
2
- * dsh-studio — domain layer.
3
- *
4
- * Board columns, card / requirement / release shapes, their sanitizers, the
5
- * default settings, and the code-number generators. Pure data and validation:
6
- * no filesystem, no host services, no HTTP. Everything here is unit-testable.
7
- *
8
- * @module dsh-studio/src/host/domain
9
- */
1
+ /** dsh-studio 的领域层:板列、卡片/需求/发布的数据形状与清洗、默认配置、编号生成。纯函数,可单测。 */
10
2
  import { createHash, randomBytes } from 'node:crypto'
3
+ import { sanitizeDbCase } from './db-runner.js'
4
+ import { sanitizeWebhooks } from './webhooks.js'
11
5
  import { sanitizeCaseShell } from './shell-case.js'
6
+ import { LIMIT_DEFAULTS, sanitizeLimits } from './limits.js'
12
7
  import { homedir } from 'node:os'
13
8
  import { dirname, join } from 'node:path'
14
9
 
@@ -212,6 +207,10 @@ export function defaultConfig() {
212
207
  templates: defaultTemplates(),
213
208
  gitlab: { url: '', token: '', group: '', project: '', syncEnabled: false, syncLabel: '', lastSyncAt: 0, lastSyncError: '', projects: [] },
214
209
  feishu: { appId: '', appSecret: '', chatId: '', baseToken: '', tableId: '', webhook: '', notifyOnChange: true, bots: [], driveFolder: 'IuJPfTCtWlDrzwdCFmLc71aynbg', autoSyncArtifacts: true, escalateWebhook: '', escalateAfter: 3 },
210
+ /** 出站 webhook:卡片流转/用例失败/派单失败推给外部系统。 */
211
+ webhooks: [],
212
+ /** 数量上限:以前是硬编码在路由里的 60/60/40,超了静默丢。 */
213
+ limits: { ...LIMIT_DEFAULTS },
215
214
  /** 设计评审闸门:打开后,卡进 columns 里的列之前设计必须 approved。 */
216
215
  design: { enabled: false, columns: ['dev'] },
217
216
  /** 成本闸门:只统计「跑了多少次、花了多少机上时间」,宿主没把 token 用量回传给插件。 */
@@ -438,6 +437,8 @@ export function mergeConfig(saved) {
438
437
  access: sanitizeAccess(saved.access),
439
438
  sla: sanitizeSla(saved.sla),
440
439
  costs: sanitizeCosts(saved.costs),
440
+ limits: sanitizeLimits(saved.limits),
441
+ webhooks: sanitizeWebhooks(saved.webhooks),
441
442
  // 设计闸门:加了这个键就必须在这里也列一遍,否则重启一次配置就被 mergeConfig 抹掉。
442
443
  design: (() => {
443
444
  const merged = { ...base.design, ...(saved.design !== null && typeof saved.design === 'object' ? saved.design : {}) }
@@ -1278,6 +1279,7 @@ export function sanitizeCase(raw, index) {
1278
1279
  at: 0,
1279
1280
  http: null,
1280
1281
  shell: null,
1282
+ db: null,
1281
1283
  lastRun: null,
1282
1284
  }
1283
1285
  }
@@ -1291,6 +1293,7 @@ export function sanitizeCase(raw, index) {
1291
1293
  id: String(raw.id ?? '').trim() || `c${index + 1}-${randomBytes(2).toString('hex')}`,
1292
1294
  title,
1293
1295
  shell: sanitizeCaseShell(raw.shell),
1296
+ db: sanitizeDbCase(raw.db),
1294
1297
  expected: String(raw.expected ?? '').trim().slice(0, 300),
1295
1298
  pointId: String(raw.pointId ?? '').trim().slice(0, 24),
1296
1299
  result,
@@ -1448,10 +1451,11 @@ export function sanitizeAllowHosts(input) {
1448
1451
  }
1449
1452
 
1450
1453
  /** 测试执行配置:环境、默认环境、超时、允许的域名。 */
1454
+
1451
1455
  export function sanitizeTesting(raw) {
1452
1456
  // allowShell 默认关:开了等于把「在宿主机执行任意命令」的能力交给看板。
1453
1457
 
1454
- const base = { envs: [], defaultEnv: '', timeoutMs: 15000, allowHosts: ['127.0.0.1', 'localhost'], concurrency: 4, allowShell: false, shellCwd: '' }
1458
+ const base = { envs: [], defaultEnv: '', timeoutMs: 15000, allowHosts: ['127.0.0.1', 'localhost'], concurrency: 4, allowShell: false, shellCwd: '', allowDb: false }
1455
1459
  if (raw === null || typeof raw !== 'object') return base
1456
1460
  const envs = sanitizeEnvs(raw.envs)
1457
1461
  const defaultEnv = String(raw.defaultEnv ?? '').trim()
@@ -1461,6 +1465,7 @@ export function sanitizeTesting(raw) {
1461
1465
  timeoutMs: Math.max(1000, Math.min(120000, Number(raw.timeoutMs) || base.timeoutMs)),
1462
1466
  allowHosts: sanitizeAllowHosts(raw.allowHosts ?? base.allowHosts),
1463
1467
  allowShell: raw.allowShell === true,
1468
+ allowDb: raw.allowDb === true,
1464
1469
  shellCwd: String(raw.shellCwd ?? '').slice(0, 300),
1465
1470
  concurrency: Math.max(1, Math.min(8, Number(raw.concurrency) || base.concurrency)),
1466
1471
  }
@@ -0,0 +1,26 @@
1
+ // 事件总线:平台内部发生的事,外面也应该能知道(CI 触发、IM 通知、工单同步)。
2
+ // 订阅者可以注册任意多个;一个订阅者抛错不影响其它订阅者,也不影响主流程。
3
+ const subscribers = []
4
+
5
+ /** 订阅:返回取消订阅的函数。 */
6
+ export function subscribe(handler) {
7
+ if (typeof handler !== 'function') throw new Error('subscribe 需要函数')
8
+ subscribers.push(handler)
9
+ return () => {
10
+ const at = subscribers.indexOf(handler)
11
+ if (at >= 0) subscribers.splice(at, 1)
12
+ }
13
+ }
14
+
15
+ /** 发事件:同步扇出,异常吞掉(订阅者的失败不该影响正在写库的请求)。 */
16
+ export function emitEvent(type, payload = {}) {
17
+ const event = { type: String(type), at: Date.now(), payload }
18
+ for (const handler of subscribers) {
19
+ try { handler(event) } catch { /* 订阅者自己的事 */ }
20
+ }
21
+ return event
22
+ }
23
+
24
+ export function subscriberCount() {
25
+ return subscribers.length
26
+ }
@@ -8,6 +8,9 @@ import { casesOfTasks, impactSummary } from './impact.js'
8
8
  import { designGate } from './design.js'
9
9
  import { RUN_LOG, parseMentions } from './dispatcher.js'
10
10
  import { appendTeamMemory } from './team-memory.js'
11
+ import { pickRunner, registerRunner } from './runners.js'
12
+ import { runDbCase } from './db-runner.js'
13
+ import { emitEvent } from './events.js'
11
14
  import { runShellCase } from './shell-runner.js'
12
15
  import { homedir } from 'node:os'
13
16
  import { join, resolve } from 'node:path'
@@ -182,9 +185,26 @@ export async function probeEnv(env, config, timeoutMs = 5000) {
182
185
  }
183
186
  }
184
187
 
188
+ /**
189
+ * 跑一条用例:怎么跑由注册表决定(runners.js)。这里只负责挑 runner 和兜底报错,
190
+ * 以后加 DB / 浏览器执行器不用再改这个函数。
191
+ */
185
192
  export async function runOneCase(item, env, config, seedVars) {
193
+ const runner = pickRunner(item)
194
+ if (runner === null) {
195
+ return { ok: false, status: 0, ms: 0, error: '这条用例没有可执行的定义:http / shell 都是空的(先补一个再跑)', preview: '' }
196
+ }
197
+ return runner.run(item, env, config, seedVars)
198
+ }
199
+
200
+ // 内置两种执行器:http(打接口)与 shell(跑命令)。后面的类型用 registerRunner 往这里加。
201
+ registerRunner('http', { match: item => item.http != null, run: (item, env, config, seedVars) => runHttpCase(item, env, config, seedVars) })
202
+ // shell 后注册 = 匹配优先级更高:两条都填的时候按 shell 跑(它是显式选择)。
203
+ registerRunner('shell', { match: item => item.shell != null, run: (item, env, config) => runShellCase(item, config) })
204
+ registerRunner('db', { match: item => item.db != null, run: (item, env, config) => runDbCase(item, config) })
205
+
206
+ async function runHttpCase(item, env, config, seedVars) {
186
207
  const started = Date.now()
187
- if (item.shell !== null && item.shell !== undefined) return runShellCase(item, config)
188
208
  if (item.http == null) {
189
209
  return { ok: false, status: 0, ms: 0, error: '这条用例还没有可执行的请求定义(method/url)', preview: '' }
190
210
  }
@@ -313,6 +333,9 @@ export async function runTaskCases(store, taskId, options = {}) {
313
333
  for (const item of targets) {
314
334
  const outcome = await runOneCase(item, env, config)
315
335
  results.push({ id: item.id, title: item.title, ...outcome })
336
+ if (outcome.ok !== true && item.flaky !== true) {
337
+ emitEvent('case/failed', { taskId: task.id, code: task.code ?? '', caseId: item.id, title: item.title, error: String(outcome.error ?? '').slice(0, 200) })
338
+ }
316
339
  await store.mutate(draft => {
317
340
  const card = draft.tasks.find(entry => entry.id === task.id)
318
341
  if (card === undefined) return
@@ -1245,6 +1268,7 @@ export async function updateTask(store, id, patch) {
1245
1268
  const simulated = { ...current, ...normalized, blocked: current.blocked }
1246
1269
  const gate = taskExitGate(store.snapshot(), simulated)
1247
1270
  if (!gate.ready) {
1271
+ emitEvent('gate/blocked', { taskId: id, gate: 'exit', blockers: gate.blockers.slice(0, 3) })
1248
1272
  throw new Error(`准出没过:${gate.blockers.join(';')}。确实要收口就在推进时填 override 写明原因。`)
1249
1273
  }
1250
1274
  }
@@ -1254,7 +1278,10 @@ export async function updateTask(store, id, patch) {
1254
1278
  const current = store.snapshot().tasks.find(entry => entry.id === id)
1255
1279
  if (current !== undefined) {
1256
1280
  const gate = designGate(store.snapshot().config, current, normalized.status)
1257
- if (gate.needed === true && gate.ready !== true) throw new Error(`进开发被拦:${gate.blockers.join(';')}`)
1281
+ if (gate.needed === true && gate.ready !== true) {
1282
+ emitEvent('gate/blocked', { taskId: id, gate: 'design', blockers: gate.blockers.slice(0, 3) })
1283
+ throw new Error(`进开发被拦:${gate.blockers.join(';')}`)
1284
+ }
1258
1285
  }
1259
1286
  }
1260
1287
  let result
@@ -1323,7 +1350,10 @@ export async function updateTask(store, id, patch) {
1323
1350
  }
1324
1351
  }
1325
1352
  }
1326
- if (previous !== task.status) moved = { from: previous, to: task.status, task: structuredClone(task), by, expertBefore }
1353
+ if (previous !== task.status) {
1354
+ moved = { from: previous, to: task.status, task: structuredClone(task), by, expertBefore }
1355
+ emitEvent('task/moved', { taskId: task.id, code: task.code ?? '', title: task.title, from: previous, to: task.status, by, expert: task.expert ?? '' })
1356
+ }
1327
1357
  }
1328
1358
  result = task
1329
1359
  })
@@ -0,0 +1,19 @@
1
+ // 数量上限:以前散在路由里硬编码(60 / 60 / 40),超了静默丢数据。
2
+ // 拆出来一是可配置,二是不把 domain.js 撑过 2000 行闸门。
3
+
4
+ export const LIMIT_DEFAULTS = { casesPerTask: 60, runKeys: 60, artifactsPerTask: 40 }
5
+
6
+ export function sanitizeLimits(raw) {
7
+ const base = { casesPerTask: 60, runKeys: 60, artifactsPerTask: 40 }
8
+ if (raw === null || typeof raw !== 'object') return base
9
+ const pick = (value, fallback, max) => {
10
+ const number = Number(value)
11
+ if (!Number.isFinite(number) || number <= 0) return fallback
12
+ return Math.min(max, Math.max(1, Math.round(number)))
13
+ }
14
+ return {
15
+ casesPerTask: pick(raw.casesPerTask, base.casesPerTask, 500),
16
+ runKeys: pick(raw.runKeys, base.runKeys, 500),
17
+ artifactsPerTask: pick(raw.artifactsPerTask, base.artifactsPerTask, 500),
18
+ }
19
+ }
@@ -270,7 +270,8 @@ export async function handleTaskRoute({ method, route, req, res, store, url }) {
270
270
  const body = await host.readJsonBody(req)
271
271
  const allKeys = (Array.isArray(body.keys) ? body.keys : []).map(String).filter(key => key.length > 0)
272
272
  if (allKeys.length === 0) throw new Error('先勾几条用例再跑')
273
- if (allKeys.length > 60) throw new Error(`一次最多跑 60 条(这次勾了 ${allKeys.length} 条):分两次跑,或者把它存成套件`)
273
+ const maxRun = store.snapshot().config.limits?.runKeys ?? 60
274
+ if (allKeys.length > maxRun) throw new Error(`一次最多跑 ${maxRun} 条(这次勾了 ${allKeys.length} 条):分两次跑,或者把它存成套件`)
274
275
  const keys = allKeys
275
276
  const out = await runCaseKeys(store, keys, { env: body.env, by: String(body.by ?? '回归') })
276
277
  host.sendJson(res, 200, { ok: true, ...out, state: host.publicState(store) })
@@ -383,7 +384,8 @@ export async function handleTaskRoute({ method, route, req, res, store, url }) {
383
384
  if (created === undefined) throw new Error('交付物至少要有标题')
384
385
  created.by = String(body.by ?? created.by ?? '')
385
386
  created.at = Date.now()
386
- if (task.artifacts.length >= 40) throw new Error('一张卡最多 40 个交付物:先删掉过期的再加')
387
+ const maxArtifacts = store.snapshot().config.limits?.artifactsPerTask ?? 40
388
+ if (task.artifacts.length >= maxArtifacts) throw new Error(`一张卡最多 ${maxArtifacts} 个交付物:先删掉过期的再加`)
387
389
  task.artifacts = [...task.artifacts, created]
388
390
  } else if (action === 'remove') {
389
391
  task.artifacts = task.artifacts.filter(item => item.id !== String(body.artifactId ?? ''))
@@ -513,7 +515,8 @@ export async function handleTaskRoute({ method, route, req, res, store, url }) {
513
515
  task.updatedAt = Date.now()
514
516
  return task
515
517
  }
516
- if (list.length > 60) throw new Error(`一张卡最多 60 条用例(这次是 ${list.length} 条):先删掉不用的再加,别让它被静默截断`)
518
+ const maxCases = store.snapshot().config.limits?.casesPerTask ?? 60
519
+ if (list.length > maxCases) throw new Error(`一张卡最多 ${maxCases} 条用例(这次是 ${list.length} 条):先删掉不用的再加,别让它被静默截断`)
517
520
  task.cases = list
518
521
  task.updatedAt = Date.now()
519
522
  return task
@@ -0,0 +1,26 @@
1
+ // 用例执行器的注册表:谁认领这条用例、怎么跑,都挂在这里。
2
+ // 加一种新执行方式(DB 断言、浏览器、脚本)= 写一个模块 + registerRunner(),不用再动 runOneCase。
3
+ const registry = []
4
+
5
+ /** @param kind 名字(http / shell / 以后的自定义类型)
6
+ * @param match 判断这条用例归我跑
7
+ * @param run 真正执行,签名 (item, env, config, seedVars) => { ok, status, ms, error, preview }
8
+ */
9
+ export function registerRunner(kind, { match, run }) {
10
+ if (typeof match !== 'function' || typeof run !== 'function') throw new Error('registerRunner 需要 match 与 run')
11
+ const at = registry.findIndex(entry => entry.kind === kind)
12
+ const entry = { kind, match, run }
13
+ if (at >= 0) registry[at] = entry
14
+ else registry.unshift(entry)
15
+ return entry
16
+ }
17
+
18
+ export function pickRunner(item) {
19
+ return registry.find(entry => {
20
+ try { return entry.match(item) === true } catch { return false }
21
+ }) ?? null
22
+ }
23
+
24
+ export function runnerKinds() {
25
+ return registry.map(entry => entry.kind)
26
+ }
@@ -0,0 +1,45 @@
1
+ // webhook 订阅者:把事件 POST 给配置里的地址。失败只记健康,不重试、不阻塞。
2
+ import { emitEvent, subscribe } from './events.js'
3
+
4
+ export const WEBHOOK_EVENTS = ['task/moved', 'case/failed', 'gate/blocked', 'agent/failed']
5
+
6
+ export function sanitizeWebhooks(raw) {
7
+ if (!Array.isArray(raw)) return []
8
+ return raw.slice(0, 20).map((item, index) => {
9
+ const source = item !== null && typeof item === 'object' ? item : {}
10
+ const url = String(source.url ?? '').trim().slice(0, 500)
11
+ const events = (Array.isArray(source.events) ? source.events : []).map(String).filter(name => WEBHOOK_EVENTS.includes(name))
12
+ return {
13
+ id: String(source.id ?? '').trim().slice(0, 40) || 'hook-' + (index + 1),
14
+ url,
15
+ events: events.length > 0 ? events : [...WEBHOOK_EVENTS],
16
+ enabled: source.enabled !== false,
17
+ }
18
+ }).filter(hook => hook.url !== '')
19
+ }
20
+
21
+ /** 真正发出去:每个订阅地址一次 POST,5 秒超时。 */
22
+ export async function deliverWebhooks(config, event) {
23
+ const hooks = (config?.webhooks ?? []).filter(hook => hook.enabled !== false && (hook.events ?? []).includes(event.type))
24
+ for (const hook of hooks) {
25
+ try {
26
+ await fetch(hook.url, {
27
+ method: 'POST',
28
+ headers: { 'content-type': 'application/json' },
29
+ body: JSON.stringify(event),
30
+ signal: AbortSignal.timeout(5000),
31
+ })
32
+ } catch {
33
+ /* 外网/内网抖动是常态;事件本身已经进了台账,不在这里重试 */
34
+ }
35
+ }
36
+ }
37
+
38
+ /** 进程内接上:事件 → webhook。返回取消订阅函数。 */
39
+ export function attachWebhooks(store) {
40
+ return subscribe(event => {
41
+ void deliverWebhooks(store?.snapshot?.()?.config, event)
42
+ })
43
+ }
44
+
45
+ export { emitEvent }