@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.
- package/AGENTS.md +1 -1
- package/README.md +65 -0
- package/client.js +17 -17
- package/dist/client.js +17 -17
- package/docs/compatibility.md +1 -1
- package/docs/publish.md +2 -0
- package/index.js +8 -0
- package/package.json +1 -1
- package/src/host/db-runner.js +56 -0
- package/src/host/domain.js +23 -10
- package/src/host/events.js +26 -0
- package/src/host/executor.js +28 -3
- 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/shell-case.js +16 -0
- package/src/host/shell-runner.js +42 -0
- package/src/host/webhooks.js +45 -0
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,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.
|
|
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,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
|
-
|
|
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
|
+
}
|
package/src/host/executor.js
CHANGED
|
@@ -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
|
|
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)
|
|
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
|
+
}
|
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,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 }
|