@marsken/dsh-studio 0.1.3 → 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.
package/AGENTS.md CHANGED
@@ -7,7 +7,7 @@
7
7
  | 路径 | 是什么 | 谁能改 |
8
8
  | --- | --- | --- |
9
9
  | `index.js` | 宿主半边:`apply(ctx)`、39 个 `studio_*` 工具、`/studio-api/*` 路由、SQLite 存储、派单引擎、门禁 | ✅ 直接改 |
10
- | `src/host/*.js` | 宿主层模块:`cases.js`(跨卡用例聚合与回归汇总:`collectCases` / `groupCaseKeys` / `summarizeRun`,纯函数)、`team-memory.js`(专家团的团级记忆,存 side 键)、`quality.js`(AI 质量:返工识别与按专家汇总,纯函数)、`gitlab-facts.js`(执行记录 vs MR 事实的对账,纯函数)、`impact.js`(需求改版的影响面:受影响的卡与要重跑的用例,纯函数)、`design.js`(设计评审记录与进开发前的闸门,纯函数)、`shell-runner.js`(shell 用例执行器,默认关闭)、`shell-case.js`(shell 用例的数据形状,纯函数)、`domain.js`(板列/数据形状/`sanitize*`/默认配置,纯函数)、`dispatcher.js`(派单引擎)、`executor.js`(用例执行、代码事实核对、健康检查)、`store.js`(`StudioStore`/`SecretStore`:SQLite 台账 + 快照 + 登录会话)、`integrations.js`(GitLab / 飞书 / 平台健康)、`routes/*.js`(按前缀拆出的 HTTP 处理:`exports.js` 导出 CSV、`auth.js` 统一登录、`tasks.js` 卡片/专家/列、`requirements.js` 需求/文档/公开提单、`releases.js` 版本/灰度/部署、`insights.js` 指标与工作台、`skills.js` 技能库读写上传、`admin.js` 配置/权限成员/审计/快照、`connect.js` 市场/集成健康/GitLab/仓库/飞书、`agents.js` 派单运行态与起停、`coding.js` 执行记录/失败重试/worktree/项目配置、`tools.js` 39 个 `studio_*` 工具) | ✅ 直接改,新逻辑优先放这里 |
10
+ | `src/host/*.js` | 宿主层模块:`cases.js`(跨卡用例聚合与回归汇总:`collectCases` / `groupCaseKeys` / `summarizeRun`,纯函数)、`team-memory.js`(专家团的团级记忆,存 side 键)、`quality.js`(AI 质量:返工识别与按专家汇总,纯函数)、`gitlab-facts.js`(执行记录 vs MR 事实的对账,纯函数)、`impact.js`(需求改版的影响面:受影响的卡与要重跑的用例,纯函数)、`design.js`(设计评审记录与进开发前的闸门,纯函数)、`shell-runner.js`(shell 用例执行器,默认关闭)、`shell-case.js`(shell 用例的数据形状,纯函数)、`runners.js`(用例执行器注册表:match + run,加新执行方式不用动 runOneCase)、`limits.js`(数量上限的默认值与清洗,可配置)、`db-runner.js`(SQLite 用例执行器与数据形状,默认关闭)、`events.js`(进程内事件总线,同步扇出)、`webhooks.js`(事件 → 出站 webhook,失败不重试)、`domain.js`(板列/数据形状/`sanitize*`/默认配置,纯函数)、`dispatcher.js`(派单引擎)、`executor.js`(用例执行、代码事实核对、健康检查)、`store.js`(`StudioStore`/`SecretStore`:SQLite 台账 + 快照 + 登录会话)、`integrations.js`(GitLab / 飞书 / 平台健康)、`routes/*.js`(按前缀拆出的 HTTP 处理:`exports.js` 导出 CSV、`auth.js` 统一登录、`tasks.js` 卡片/专家/列、`requirements.js` 需求/文档/公开提单、`releases.js` 版本/灰度/部署、`insights.js` 指标与工作台、`skills.js` 技能库读写上传、`admin.js` 配置/权限成员/审计/快照、`connect.js` 市场/集成健康/GitLab/仓库/飞书、`agents.js` 派单运行态与起停、`coding.js` 执行记录/失败重试/worktree/项目配置、`tools.js` 39 个 `studio_*` 工具) | ✅ 直接改,新逻辑优先放这里 |
11
11
  | `client.src.js` | 浏览器半边的手写源码(通过 `window.__ModuleLoader__` 注册面板) | ✅ 直接改(前端逻辑) |
12
12
  | `web/src/**` | shadcn/Radix 组件、样式、`legacy.js` 桥;`web/src/studio/*.js` 是从 `client.src.js` 抬出来的纯模块(`markdown.js` 渲染器、`format.js` 日期格式、`labels.js` 标签表、`roster.js` 负责人与候选人、`fields.js` 表单适配层、`shell.js` 共享抽屉、`card.js` 看板卡片、`board.js` 看板本体、`mentions.js` @ 选择器、`cases.js` 多步用例解析、`drawer.js` 卡片详情抽屉、`editor.js` 通用编辑器、`palette.js` 命令面板(⌘K 全局搜索卡片/需求/版本/专家/专家团/项目/技能 + 动作项 + Ctrl/⌘+Alt+Shift+N/T/D 全局快捷键;带 Shift 是为了避开宿主的 Ctrl+Alt+N 新会话)、`charts.js` 进度条与燃尽图、`context.js` 共享上下文(`provideClient`/`client`)、`panels/*.js` 设置页 17 个面板、`pages/*.js` 六个页面组件(看板之外的工作台/需求/用例/版本/效能/自助提单);**`StudioPage` 是组合根,故意留在 `client.src.js`**,它负责顶栏、页签与「+ 新建」菜单并调用 `provideClient()`,别为了行数把它搬走),由 `make-legacy` 生成的 `legacy.js` 顶部统一 import | ✅ 直接改(样式与组件) |
13
13
  | `dist/client.js` | `pnpm build` 的产物(单文件 IIFE,注意在仓库根 `dist/`,不是 `web/dist/`) | ❌ 生成物 |
package/README.md CHANGED
@@ -327,6 +327,54 @@ sudo systemctl restart dsh-web
327
327
  | `expectContains` / `expectNotContains` | 输出里必须出现 / 不能出现的内容 |
328
328
 
329
329
  两个入口:卡片抽屉用例区的 **「+ shell」**(命令 + 期望输出两步录入),或调 `POST /studio-api/task/case` 直接写 `shell: { … }`。
330
+ ## 数据库用例(默认关闭)
331
+
332
+ 第三种执行方式:对着一个 SQLite 库跑一条语句,断言行数与首行某列的值。适合「库存不能被扣成负数」这类只有查库才能确认的验收。
333
+
334
+ **打开方式**:设置 → 测试执行 → 打开「允许数据库用例」(`config.testing.allowDb`)。默认**只读**打开,要写入必须显式 `readOnly: false`。
335
+
336
+ ```json
337
+ { "title": "库存不能超卖", "db": {
338
+ "file": "/tmp/test.db",
339
+ "sql": "select count(*) as n from orders where stock < 0",
340
+ "expectRows": 1, "expectColumn": "n", "expectValue": "0" } }
341
+ ```
342
+
343
+ | 字段 | 作用 |
344
+ | --- | --- |
345
+ | `file` / `sql` | 必填;库文件路径与语句 |
346
+ | `params` | 绑定参数(`?` 占位) |
347
+ | `expectRows` | 期望行数;不填不校验 |
348
+ | `expectColumn` + `expectValue` | 校验首行某一列的值 |
349
+ | `readOnly` | 默认 `true`;要改数据才设 `false` |
350
+
351
+ ## 出站 webhook(把变化告诉外部系统)
352
+
353
+ 卡片流转、用例失败、派单失败都会发事件,配了 webhook 就 POST 出去:
354
+
355
+ ```json
356
+ { "webhooks": [{ "id": "ci", "url": "https://ci.example/hook",
357
+ "events": ["task/moved", "case/failed"], "enabled": true }] }
358
+ ```
359
+
360
+ 事件长这样:
361
+
362
+ ```json
363
+ { "type": "task/moved", "at": 1794585755429,
364
+ "payload": { "taskId": "t-…", "from": "clarify", "to": "accept", "by": "agent" } }
365
+ ```
366
+
367
+ 约定三条:**调用方不重试**(外部抖动是常态,事件本身已进台账)、**5 秒超时**、**订阅者之间互不影响**(一个 hook 抛错不影响其它 hook,也不影响正在写库的请求)。
368
+
369
+ ## 数量上限(可配)
370
+
371
+ 以前写死在路由里:一张卡 60 条用例、一次批量跑 60 条、一张卡 40 个交付物——**超了静默丢**。现在读配置,超了会明确报错(套件回归仍然继续跑,但会标记 `truncated`):
372
+
373
+ ```json
374
+ { "limits": { "casesPerTask": 60, "runKeys": 60, "artifactsPerTask": 40 } }
375
+ ```
376
+
377
+ 范围 1–500,写非法值会退回默认。
330
378
  ## 用例自动生成(从功能点长出来,不是凭空写)
331
379
 
332
380
  测试用例不用一条条手敲:需求拆了功能点、每个功能点带验收条件,平台就能照着生成。
@@ -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.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,7 @@ 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、可配置上限、执行器注册表 |
130
131
  | `0.1.3` | 2026-11-13 | shell 用例(命令 + 退出码/输出断言,默认关闭) |
131
132
  | `0.1.2` | 2026-11-13 | 修 5 个缺陷:卡片挂不上 MR、换列清空负责人、三处静默截断、还原度无历史、规范体检不定位元素 |
132
133
 
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.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,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
  }
@@ -1323,7 +1343,10 @@ export async function updateTask(store, id, patch) {
1323
1343
  }
1324
1344
  }
1325
1345
  }
1326
- 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
+ }
1327
1350
  }
1328
1351
  result = task
1329
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,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 }