@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 +1 -1
- package/README.md +48 -0
- package/docs/compatibility.md +1 -1
- package/docs/publish.md +1 -0
- package/index.js +8 -0
- package/package.json +1 -1
- package/src/host/db-runner.js +56 -0
- package/src/host/domain.js +15 -10
- package/src/host/events.js +26 -0
- package/src/host/executor.js +25 -2
- package/src/host/limits.js +19 -0
- package/src/host/routes/tasks.js +6 -3
- package/src/host/runners.js +26 -0
- package/src/host/webhooks.js +45 -0
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
|
测试用例不用一条条手敲:需求拆了功能点、每个功能点带验收条件,平台就能照着生成。
|
package/docs/compatibility.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
| 项 | 值 |
|
|
8
8
|
| --- | --- |
|
|
9
|
-
| dsh-studio | `package.json` `version: 0.1.
|
|
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
|
+
"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
|
+
}
|
package/src/host/domain.js
CHANGED
|
@@ -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
|
+
}
|
package/src/host/executor.js
CHANGED
|
@@ -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)
|
|
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
|
+
}
|
package/src/host/routes/tasks.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|