@marsken/dsh-studio 0.1.2 → 0.1.4

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.2` |
9
+ | dsh-studio | `package.json` `version: 0.1.4` |
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,6 +127,8 @@ 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.4` | 2026-11-14 | SQLite 用例、出站 webhook、可配置上限、执行器注册表 |
131
+ | `0.1.3` | 2026-11-13 | shell 用例(命令 + 退出码/输出断言,默认关闭) |
130
132
  | `0.1.2` | 2026-11-13 | 修 5 个缺陷:卡片挂不上 MR、换列清空负责人、三处静默截断、还原度无历史、规范体检不定位元素 |
131
133
 
132
134
  两条硬规矩,每次发版都过一遍:
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.2",
3
+ "version": "0.1.4",
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,13 +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'
5
+ import { sanitizeCaseShell } from './shell-case.js'
6
+ import { LIMIT_DEFAULTS, sanitizeLimits } from './limits.js'
11
7
  import { homedir } from 'node:os'
12
8
  import { dirname, join } from 'node:path'
13
9
 
@@ -211,6 +207,10 @@ export function defaultConfig() {
211
207
  templates: defaultTemplates(),
212
208
  gitlab: { url: '', token: '', group: '', project: '', syncEnabled: false, syncLabel: '', lastSyncAt: 0, lastSyncError: '', projects: [] },
213
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 },
214
214
  /** 设计评审闸门:打开后,卡进 columns 里的列之前设计必须 approved。 */
215
215
  design: { enabled: false, columns: ['dev'] },
216
216
  /** 成本闸门:只统计「跑了多少次、花了多少机上时间」,宿主没把 token 用量回传给插件。 */
@@ -437,6 +437,8 @@ export function mergeConfig(saved) {
437
437
  access: sanitizeAccess(saved.access),
438
438
  sla: sanitizeSla(saved.sla),
439
439
  costs: sanitizeCosts(saved.costs),
440
+ limits: sanitizeLimits(saved.limits),
441
+ webhooks: sanitizeWebhooks(saved.webhooks),
440
442
  // 设计闸门:加了这个键就必须在这里也列一遍,否则重启一次配置就被 mergeConfig 抹掉。
441
443
  design: (() => {
442
444
  const merged = { ...base.design, ...(saved.design !== null && typeof saved.design === 'object' ? saved.design : {}) }
@@ -1257,6 +1259,7 @@ export function sanitizeCaseHttp(raw) {
1257
1259
  }
1258
1260
 
1259
1261
  /** 一条测试用例:做什么、期望什么、实际结果,以及最近一次执行的样子。 */
1262
+
1260
1263
  export function sanitizeCase(raw, index) {
1261
1264
  if (typeof raw === 'string') {
1262
1265
  const title = raw.trim().slice(0, 200)
@@ -1275,6 +1278,8 @@ export function sanitizeCase(raw, index) {
1275
1278
  by: '',
1276
1279
  at: 0,
1277
1280
  http: null,
1281
+ shell: null,
1282
+ db: null,
1278
1283
  lastRun: null,
1279
1284
  }
1280
1285
  }
@@ -1287,6 +1292,8 @@ export function sanitizeCase(raw, index) {
1287
1292
  return {
1288
1293
  id: String(raw.id ?? '').trim() || `c${index + 1}-${randomBytes(2).toString('hex')}`,
1289
1294
  title,
1295
+ shell: sanitizeCaseShell(raw.shell),
1296
+ db: sanitizeDbCase(raw.db),
1290
1297
  expected: String(raw.expected ?? '').trim().slice(0, 300),
1291
1298
  pointId: String(raw.pointId ?? '').trim().slice(0, 24),
1292
1299
  result,
@@ -1444,8 +1451,11 @@ export function sanitizeAllowHosts(input) {
1444
1451
  }
1445
1452
 
1446
1453
  /** 测试执行配置:环境、默认环境、超时、允许的域名。 */
1454
+
1447
1455
  export function sanitizeTesting(raw) {
1448
- const base = { envs: [], defaultEnv: '', timeoutMs: 15000, allowHosts: ['127.0.0.1', 'localhost'], concurrency: 4 }
1456
+ // allowShell 默认关:开了等于把「在宿主机执行任意命令」的能力交给看板。
1457
+
1458
+ const base = { envs: [], defaultEnv: '', timeoutMs: 15000, allowHosts: ['127.0.0.1', 'localhost'], concurrency: 4, allowShell: false, shellCwd: '', allowDb: false }
1449
1459
  if (raw === null || typeof raw !== 'object') return base
1450
1460
  const envs = sanitizeEnvs(raw.envs)
1451
1461
  const defaultEnv = String(raw.defaultEnv ?? '').trim()
@@ -1454,6 +1464,9 @@ export function sanitizeTesting(raw) {
1454
1464
  defaultEnv: envs.some(env => env.id === defaultEnv) ? defaultEnv : (envs[0]?.id ?? ''),
1455
1465
  timeoutMs: Math.max(1000, Math.min(120000, Number(raw.timeoutMs) || base.timeoutMs)),
1456
1466
  allowHosts: sanitizeAllowHosts(raw.allowHosts ?? base.allowHosts),
1467
+ allowShell: raw.allowShell === true,
1468
+ allowDb: raw.allowDb === true,
1469
+ shellCwd: String(raw.shellCwd ?? '').slice(0, 300),
1457
1470
  concurrency: Math.max(1, Math.min(8, Number(raw.concurrency) || base.concurrency)),
1458
1471
  }
1459
1472
  }
@@ -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,10 @@ 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'
14
+ import { runShellCase } from './shell-runner.js'
11
15
  import { homedir } from 'node:os'
12
16
  import { join, resolve } from 'node:path'
13
17
  import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
@@ -181,7 +185,25 @@ export async function probeEnv(env, config, timeoutMs = 5000) {
181
185
  }
182
186
  }
183
187
 
188
+ /**
189
+ * 跑一条用例:怎么跑由注册表决定(runners.js)。这里只负责挑 runner 和兜底报错,
190
+ * 以后加 DB / 浏览器执行器不用再改这个函数。
191
+ */
184
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) {
185
207
  const started = Date.now()
186
208
  if (item.http == null) {
187
209
  return { ok: false, status: 0, ms: 0, error: '这条用例还没有可执行的请求定义(method/url)', preview: '' }
@@ -297,7 +319,7 @@ export async function runTaskCases(store, taskId, options = {}) {
297
319
  ? new Set(options.caseIds.map(id => String(id)))
298
320
  : null
299
321
  const targets = (Array.isArray(task.cases) ? task.cases : [])
300
- .filter(item => (wanted === null ? item.http != null : wanted.has(item.id)))
322
+ .filter(item => (wanted === null ? item.http != null || item.shell != null : wanted.has(item.id)))
301
323
  if (targets.length === 0) throw new Error('没有可执行的用例:用例上要填 method + url(相对地址会拼环境 baseUrl)')
302
324
 
303
325
  const probe = await probeEnv(env, config)
@@ -940,7 +962,7 @@ export async function pullFeishuDoc(url) {
940
962
  }
941
963
  text = String(parsed.data?.document?.content ?? '')
942
964
  if (text.trim() === '') return { ok: false, tool: true, text: '', error: '拉到了空正文:这个文档可能是空的,或者应用没有阅读权限' }
943
- const title = /<title>([\s\S]*?)<\/title>/.exec(text)?.[1]?.trim() ?? ''
965
+ const title = text.match(/<title>([\s\S]*?)<\/title>/)?.[1]?.trim() ?? ''
944
966
  const body = text.replace(/<title>[\s\S]*?<\/title>\s*/, '')
945
967
  return { ok: true, tool: true, title, text: body.slice(0, 60000), error: '' }
946
968
  } catch {
@@ -1321,7 +1343,10 @@ export async function updateTask(store, id, patch) {
1321
1343
  }
1322
1344
  }
1323
1345
  }
1324
- if (previous !== task.status) moved = { from: previous, to: task.status, task: structuredClone(task), by, expertBefore }
1346
+ if (previous !== task.status) {
1347
+ moved = { from: previous, to: task.status, task: structuredClone(task), by, expertBefore }
1348
+ emitEvent('task/moved', { taskId: task.id, code: task.code ?? '', title: task.title, from: previous, to: task.status, by, expert: task.expert ?? '' })
1349
+ }
1325
1350
  }
1326
1351
  result = task
1327
1352
  })
@@ -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,16 @@
1
+ // shell 用例的数据形状。纯函数,放在这里是为了不把 domain.js 撑过 2000 行的闸门。
2
+
3
+ /** shell 用例:跑一条命令,看退出码与输出。默认**关**,要在设置里显式打开(见 config.testing.allowShell)。 */
4
+ export function sanitizeCaseShell(raw) {
5
+ if (raw === null || typeof raw !== 'object') return null
6
+ const command = String(raw.command ?? '').trim().slice(0, 2000)
7
+ if (command === '') return null
8
+ return {
9
+ command,
10
+ cwd: String(raw.cwd ?? '').trim().slice(0, 300),
11
+ timeoutMs: Math.max(1000, Math.min(120000, Number(raw.timeoutMs) || 30000)),
12
+ expectExit: Number.isInteger(Number(raw.expectExit)) ? Number(raw.expectExit) : 0,
13
+ expectContains: String(raw.expectContains ?? '').slice(0, 500),
14
+ expectNotContains: String(raw.expectNotContains ?? '').slice(0, 500),
15
+ }
16
+ }
@@ -0,0 +1,42 @@
1
+ // shell 用例执行器:跑一条命令,比对退出码与输出。
2
+ // 默认关闭(config.testing.allowShell)——打开它等于把「在宿主机执行任意命令」的能力交给看板。
3
+ import { exec } from 'node:child_process'
4
+
5
+ /** shell 用例:跑一条命令,比对退出码与输出。默认关闭,见 config.testing.allowShell。 */
6
+ export async function runShellCase(item, config) {
7
+ const started = Date.now()
8
+ const shell = item.shell
9
+ if (config?.testing?.allowShell !== true) {
10
+ return { ok: false, status: 0, ms: 0, error: '这台机器没开 shell 用例:设置 → 测试执行 里打开「允许 shell 用例」', preview: '' }
11
+ }
12
+ const cwd = shell.cwd !== '' ? shell.cwd : String(config?.testing?.shellCwd ?? '')
13
+ const timeout = shell.timeoutMs > 0 ? shell.timeoutMs : 30000
14
+ let stdout = ''
15
+ let stderr = ''
16
+ let code = 0
17
+ try {
18
+ const out = await new Promise(finish => {
19
+ exec(shell.command, { cwd: cwd === '' ? undefined : cwd, timeout, maxBuffer: 1024 * 1024 }, (error, outText, errText) => {
20
+ finish({ error, out: String(outText ?? ''), err: String(errText ?? '') })
21
+ })
22
+ })
23
+ stdout = out.out
24
+ stderr = out.err
25
+ code = out.error === null || out.error === undefined ? 0 : (Number(out.error.code) || 1)
26
+ } catch (error) {
27
+ stderr = String(error?.message ?? error)
28
+ code = 1
29
+ }
30
+ const combined = stdout + stderr
31
+ const problems = []
32
+ if (code !== shell.expectExit) problems.push('退出码 ' + code + '(期望 ' + shell.expectExit + ')')
33
+ if (shell.expectContains !== '' && !combined.includes(shell.expectContains)) problems.push('输出里没有「' + shell.expectContains + '」')
34
+ if (shell.expectNotContains !== '' && combined.includes(shell.expectNotContains)) problems.push('输出里有不该出现的「' + shell.expectNotContains + '」')
35
+ return {
36
+ ok: problems.length === 0,
37
+ status: code,
38
+ ms: Date.now() - started,
39
+ error: problems.join(';').slice(0, 300),
40
+ preview: combined.trim().slice(-600),
41
+ }
42
+ }
@@ -0,0 +1,45 @@
1
+ // webhook 订阅者:把事件 POST 给配置里的地址。失败只记健康,不重试、不阻塞。
2
+ import { emitEvent, subscribe } from './events.js'
3
+
4
+ export const WEBHOOK_EVENTS = ['task/moved', 'case/failed', '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 }