@marsken/dsh-studio 0.0.0-stage → 0.1.0
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 +148 -0
- package/LICENSE +202 -0
- package/README.md +1356 -3
- package/client.js +952 -0
- package/client.src.js +2466 -0
- package/cordis.patch.yml +9 -0
- package/dist/client.js +952 -0
- package/docs/compatibility.md +99 -0
- package/docs/publish.md +86 -0
- package/docs/quickstart.md +53 -0
- package/icon.svg +6 -0
- package/index.js +2674 -0
- package/locale/en.json +11 -0
- package/locale/zh.json +11 -0
- package/package.json +63 -4
- package/scripts/smoke-store.mjs +76 -0
- package/src/host/cases.js +135 -0
- package/src/host/design.js +87 -0
- package/src/host/dispatcher.js +936 -0
- package/src/host/domain.js +1966 -0
- package/src/host/executor.js +1396 -0
- package/src/host/gitlab-facts.js +22 -0
- package/src/host/impact.js +32 -0
- package/src/host/integrations.js +587 -0
- package/src/host/quality.js +106 -0
- package/src/host/routes/admin.js +100 -0
- package/src/host/routes/agents.js +71 -0
- package/src/host/routes/auth.js +155 -0
- package/src/host/routes/coding.js +203 -0
- package/src/host/routes/connect.js +166 -0
- package/src/host/routes/exports.js +122 -0
- package/src/host/routes/insights.js +97 -0
- package/src/host/routes/releases.js +335 -0
- package/src/host/routes/requirements.js +430 -0
- package/src/host/routes/skills.js +75 -0
- package/src/host/routes/tasks.js +573 -0
- package/src/host/store.js +522 -0
- package/src/host/team-memory.js +46 -0
- package/src/host/tools.js +1211 -0
package/index.js
ADDED
|
@@ -0,0 +1,2674 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-studio — host half.
|
|
3
|
+
*
|
|
4
|
+
* Owns three things and nothing else:
|
|
5
|
+
* 1. the studio settings file (GitLab / Feishu / role→model mapping),
|
|
6
|
+
* 2. the work ledger (tasks + comments + status history),
|
|
7
|
+
* 3. the `/studio-api` HTTP surface used by the browser panel, plus the
|
|
8
|
+
* `studio_*` agent tools used by the role agents.
|
|
9
|
+
*
|
|
10
|
+
* No @deepseek-ai runtime imports: the published copy must never resolve a
|
|
11
|
+
* mirror SDK package from the profile's node_modules.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-studio
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from 'node:child_process'
|
|
16
|
+
import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
|
|
17
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile, chmod } from 'node:fs/promises'
|
|
18
|
+
import { homedir } from 'node:os'
|
|
19
|
+
import { dirname, join, resolve } from 'node:path'
|
|
20
|
+
|
|
21
|
+
/** Cordis plugin name. */
|
|
22
|
+
export const name = 'dsh-studio'
|
|
23
|
+
|
|
24
|
+
/** Required host services. */
|
|
25
|
+
export const inject = ['tools', 'systemPrompt']
|
|
26
|
+
|
|
27
|
+
// ------------------------------------------------------------------ domain
|
|
28
|
+
|
|
29
|
+
|
|
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
|
+
import { Dispatcher, RUN_LOG, attachRunLog, claimDispatchLock, clearAgentFailure, configureDispatcher, noteAgentFailure, parseMentions, runSummary } from './src/host/dispatcher.js'
|
|
32
|
+
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
|
+
import { configureAuthRoutes, handleAuthRoute } from './src/host/routes/auth.js'
|
|
34
|
+
import { configureExportRoutes, handleExportRoute } from './src/host/routes/exports.js'
|
|
35
|
+
import { configureTaskRoutes, handleTaskRoute } from './src/host/routes/tasks.js'
|
|
36
|
+
import { configureRequirementRoutes, handleRequirementRoute } from './src/host/routes/requirements.js'
|
|
37
|
+
import { configureReleasesRoutes, handleReleasesRoute } from './src/host/routes/releases.js'
|
|
38
|
+
import { configureAgentsRoutes, handleAgentsRoute } from './src/host/routes/agents.js'
|
|
39
|
+
import { configureCodingRoutes, handleCodingRoute } from './src/host/routes/coding.js'
|
|
40
|
+
import { configureToolHost, registerTools } from './src/host/tools.js'
|
|
41
|
+
import { configureConnectRoutes, handleConnectRoute } from './src/host/routes/connect.js'
|
|
42
|
+
import { configureInsightsRoutes, handleInsightsRoute } from './src/host/routes/insights.js'
|
|
43
|
+
import { configureAdminRoutes, handleAdminRoute } from './src/host/routes/admin.js'
|
|
44
|
+
import { configureSkillsRoutes, handleSkillsRoute } from './src/host/routes/skills.js'
|
|
45
|
+
import { INTEGRATION, markHealth, escalateHealth, healthReport, restoreHealth, projectReady, gitlabReady, gitlabMergeRequestDetail, gitlabRequest, gitlabSync, feishuPush, feishuBroadcast, broadcastChange, configureIntegrations } from './src/host/integrations.js'
|
|
46
|
+
import { SecretStore, StudioStore } from './src/host/store.js'
|
|
47
|
+
|
|
48
|
+
function upsertById(list, incoming) {
|
|
49
|
+
const arrivals = Array.isArray(list) ? [...list] : []
|
|
50
|
+
if (incoming === null || typeof incoming !== 'object') return arrivals
|
|
51
|
+
const id = String(incoming.id ?? '').trim()
|
|
52
|
+
if (id.length === 0) return [...arrivals, incoming]
|
|
53
|
+
const index = arrivals.findIndex(entry => entry.id === id)
|
|
54
|
+
if (index >= 0) arrivals[index] = { ...arrivals[index], ...incoming }
|
|
55
|
+
else arrivals.push(incoming)
|
|
56
|
+
return arrivals
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** DSH home, honouring DSH_HOME. */
|
|
60
|
+
function dshHomePath(...segments) {
|
|
61
|
+
const override = process.env.DSH_HOME
|
|
62
|
+
const home = resolve(override !== undefined && override.length > 0 ? override : join(homedir(), '.dsh'))
|
|
63
|
+
return join(home, ...segments)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ------------------------------------------------------------- skill library
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Where the agent runtime looks for skills: `<DSH_HOME>/skills/<name>/SKILL.md`.
|
|
70
|
+
* Writing here makes a skill real — every agent's `skill` tool can load it,
|
|
71
|
+
* which is what turns this panel from a text box into a working capability.
|
|
72
|
+
*/
|
|
73
|
+
function skillsRoot() {
|
|
74
|
+
return dshHomePath('skills')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The skill-name grammar the DSH registry validates against (kebab-case). */
|
|
78
|
+
function isSkillName(value) {
|
|
79
|
+
const name = String(value ?? '')
|
|
80
|
+
return name.length > 0 && name.length <= 64 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Coerce whatever the caller sent into a storable skill row. */
|
|
84
|
+
function sanitizeSkill(raw) {
|
|
85
|
+
if (raw === null || typeof raw !== 'object') return undefined
|
|
86
|
+
const name = String(raw.name ?? '').trim().toLowerCase()
|
|
87
|
+
if (!isSkillName(name)) return undefined
|
|
88
|
+
const content = String(raw.content ?? '')
|
|
89
|
+
const description = (String(raw.description ?? '').trim() || content.trim().split('\n')[0] || name).slice(0, 300)
|
|
90
|
+
const tags = (Array.isArray(raw.tags) ? raw.tags : String(raw.tags ?? '').split(/[,,\s]+/))
|
|
91
|
+
.map(tag => String(tag ?? '').trim().slice(0, 30))
|
|
92
|
+
.filter(tag => tag.length > 0)
|
|
93
|
+
.filter((tag, index, all) => all.indexOf(tag) === index)
|
|
94
|
+
.slice(0, 8)
|
|
95
|
+
return {
|
|
96
|
+
name,
|
|
97
|
+
description,
|
|
98
|
+
whenToUse: String(raw.whenToUse ?? '').trim().slice(0, 400),
|
|
99
|
+
content: content.slice(0, 60_000),
|
|
100
|
+
tags,
|
|
101
|
+
enabled: raw.enabled !== false,
|
|
102
|
+
updatedAt: typeof raw.updatedAt === 'string' && raw.updatedAt.length > 0 ? raw.updatedAt : new Date().toISOString(),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Coerce a saved library file into skill rows. */
|
|
107
|
+
function sanitizeSkills(input) {
|
|
108
|
+
if (!Array.isArray(input)) return []
|
|
109
|
+
const seen = new Set()
|
|
110
|
+
const out = []
|
|
111
|
+
for (const raw of input.slice(0, 200)) {
|
|
112
|
+
const skill = sanitizeSkill(raw)
|
|
113
|
+
if (skill === undefined || seen.has(skill.name)) continue
|
|
114
|
+
seen.add(skill.name)
|
|
115
|
+
out.push(skill)
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The SKILL.md text (YAML frontmatter + body) for one library row. */
|
|
121
|
+
function skillMarkdown(skill) {
|
|
122
|
+
const lines = ['---', `name: ${skill.name}`, `description: ${skill.description.replace(/\n/g, ' ')}`]
|
|
123
|
+
if (skill.whenToUse.length > 0) lines.push(`whenToUse: ${skill.whenToUse.replace(/\n/g, ' ')}`)
|
|
124
|
+
if (skill.tags.length > 0) lines.push(`tags: ${skill.tags.join(', ')}`)
|
|
125
|
+
lines.push('---', '', skill.content.trim(), '')
|
|
126
|
+
return lines.join('\n')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Split `---` frontmatter into `{fields, body}`; no frontmatter → all body. */
|
|
130
|
+
function splitFrontmatter(source) {
|
|
131
|
+
const text = String(source ?? '').replace(/\r\n/g, '\n')
|
|
132
|
+
const match = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(text)
|
|
133
|
+
if (match === null) return { fields: {}, body: text }
|
|
134
|
+
const fields = {}
|
|
135
|
+
for (const line of match[1].split('\n')) {
|
|
136
|
+
const colon = line.indexOf(':')
|
|
137
|
+
if (colon <= 0) continue
|
|
138
|
+
const key = line.slice(0, colon).trim()
|
|
139
|
+
let value = line.slice(colon + 1).trim()
|
|
140
|
+
if (value.length >= 2) {
|
|
141
|
+
const first = value[0]
|
|
142
|
+
const last = value[value.length - 1]
|
|
143
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) value = value.slice(1, -1)
|
|
144
|
+
}
|
|
145
|
+
if (key.length > 0) fields[key] = value
|
|
146
|
+
}
|
|
147
|
+
return { fields, body: match[2] }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** kebab-case slug from arbitrary text; non-ASCII text falls back to a hash. */
|
|
151
|
+
function slugifySkill(value) {
|
|
152
|
+
const ascii = String(value ?? '')
|
|
153
|
+
.toLowerCase()
|
|
154
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
155
|
+
.replace(/^-+|-+$/g, '')
|
|
156
|
+
.slice(0, 48)
|
|
157
|
+
if (ascii.length >= 2) return ascii
|
|
158
|
+
return `skill-${createHash('sha1').update(String(value ?? '')).digest('hex').slice(0, 6)}`
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Turn an uploaded `SKILL.md` into a library row.
|
|
163
|
+
*
|
|
164
|
+
* Precedence for the name: frontmatter `name` → filename → heading → hash, so
|
|
165
|
+
* a skill exported by another tool keeps its identity on the way in.
|
|
166
|
+
*/
|
|
167
|
+
function skillFromMarkdown(fileName, source) {
|
|
168
|
+
const { fields, body } = splitFrontmatter(source)
|
|
169
|
+
const base = String(fileName ?? '').split('/').pop().replace(/\.(md|markdown|txt)$/i, '')
|
|
170
|
+
const declared = String(fields.name ?? '').trim().toLowerCase()
|
|
171
|
+
const name = isSkillName(declared) ? declared : slugifySkill(declared || base)
|
|
172
|
+
const heading = (body.match(/^#\s+(.+)$/m) ?? [])[1] ?? ''
|
|
173
|
+
const description = String(fields.description ?? '').trim() || heading.trim() || name
|
|
174
|
+
return {
|
|
175
|
+
name,
|
|
176
|
+
description,
|
|
177
|
+
whenToUse: String(fields.whenToUse ?? fields.when_to_use ?? '').trim(),
|
|
178
|
+
content: body.trim(),
|
|
179
|
+
tags: String(fields.tags ?? '').replace(/[[\]]/g, '').split(/[,,\s]+/).filter(Boolean),
|
|
180
|
+
enabled: true,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Pull `SKILL.md` out of an uploaded `.zip`.
|
|
186
|
+
*
|
|
187
|
+
* Uses the system `unzip`; when it is missing we say so instead of failing
|
|
188
|
+
* with a stack trace, because a single Markdown file always works.
|
|
189
|
+
*/
|
|
190
|
+
async function skillFromZip(buffer) {
|
|
191
|
+
const tmp = join(dshHomePath('tmp'), `skill-upload-${randomBytes(4).toString('hex')}.zip`)
|
|
192
|
+
await mkdir(dirname(tmp), { recursive: true })
|
|
193
|
+
await writeFile(tmp, buffer)
|
|
194
|
+
try {
|
|
195
|
+
const listing = await runCommand('unzip', ['-Z1', tmp], 15000)
|
|
196
|
+
if (!listing.ok) {
|
|
197
|
+
const hint = String(listing.stderr || '').split('\n')[0].slice(0, 120)
|
|
198
|
+
return { ok: false, error: `服务器上解不了 .zip(${hint || 'unzip 不可用'})。可以直接上传 SKILL.md。` }
|
|
199
|
+
}
|
|
200
|
+
const entries = listing.stdout.split('\n').map(line => line.trim()).filter(Boolean)
|
|
201
|
+
const target = entries.find(entry => /(^|\/)SKILL\.md$/i.test(entry))
|
|
202
|
+
if (target === undefined) return { ok: false, error: '压缩包里没有 SKILL.md:技能包应该把 SKILL.md 放在根目录或同名子目录里。' }
|
|
203
|
+
const dump = await runCommand('unzip', ['-p', tmp, target], 15000)
|
|
204
|
+
if (!dump.ok) return { ok: false, error: `解压失败:${String(dump.stderr).split('\n')[0].slice(0, 160)}` }
|
|
205
|
+
return { ok: true, fileName: target, text: dump.stdout }
|
|
206
|
+
} finally {
|
|
207
|
+
await rm(tmp, { force: true }).catch(() => {})
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The company skill catalogue.
|
|
213
|
+
*
|
|
214
|
+
* Kept in its own file rather than inside the settings blob: skill bodies are
|
|
215
|
+
* long, and the panel polls `/studio-api/state` every few seconds.
|
|
216
|
+
* Every write is mirrored to `<DSH_HOME>/skills/<name>/SKILL.md` so the runtime
|
|
217
|
+
* skill registry picks it up for the next agent session.
|
|
218
|
+
*/
|
|
219
|
+
class SkillLibrary {
|
|
220
|
+
constructor(file) {
|
|
221
|
+
this.file = file
|
|
222
|
+
this.state = { version: 1, skills: [], synced: [] }
|
|
223
|
+
this.chain = Promise.resolve()
|
|
224
|
+
this.ready = this.load()
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async load() {
|
|
228
|
+
try {
|
|
229
|
+
const raw = JSON.parse(await readFile(this.file, 'utf8'))
|
|
230
|
+
this.state = {
|
|
231
|
+
version: 1,
|
|
232
|
+
skills: sanitizeSkills(raw?.skills),
|
|
233
|
+
synced: Array.isArray(raw?.synced) ? raw.synced.filter(name => typeof name === 'string') : [],
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (error?.code !== 'ENOENT') throw error
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
snapshot() {
|
|
241
|
+
return this.state
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
list() {
|
|
245
|
+
return this.state.skills.map(skill => ({ ...skill }))
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
get(name) {
|
|
249
|
+
return this.state.skills.find(skill => skill.name === name)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Serialize every mutation behind one chain so two saves cannot interleave. */
|
|
253
|
+
async mutate(change) {
|
|
254
|
+
const run = this.chain.then(async () => {
|
|
255
|
+
await this.ready
|
|
256
|
+
const draft = {
|
|
257
|
+
version: 1,
|
|
258
|
+
skills: this.state.skills.map(skill => ({ ...skill })),
|
|
259
|
+
synced: [...this.state.synced],
|
|
260
|
+
}
|
|
261
|
+
const result = await change(draft)
|
|
262
|
+
await this.persist(draft)
|
|
263
|
+
this.state = draft
|
|
264
|
+
return result
|
|
265
|
+
})
|
|
266
|
+
this.chain = run.catch(() => {})
|
|
267
|
+
return run
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async persist(next) {
|
|
271
|
+
await mkdir(dirname(this.file), { recursive: true })
|
|
272
|
+
const tmp = `${this.file}.${process.pid}.tmp`
|
|
273
|
+
await writeFile(tmp, JSON.stringify(next, null, 2), 'utf8')
|
|
274
|
+
await rename(tmp, this.file)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Write one skill to disk; returns the absolute SKILL.md path. */
|
|
278
|
+
async materialize(skill) {
|
|
279
|
+
if (skill.enabled === false) {
|
|
280
|
+
await this.forget(skill.name)
|
|
281
|
+
return undefined
|
|
282
|
+
}
|
|
283
|
+
const dir = join(skillsRoot(), skill.name)
|
|
284
|
+
await mkdir(dir, { recursive: true })
|
|
285
|
+
const file = join(dir, 'SKILL.md')
|
|
286
|
+
await writeFile(file, skillMarkdown(skill), 'utf8')
|
|
287
|
+
return file
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Remove a materialized skill directory we own. */
|
|
291
|
+
async forget(name) {
|
|
292
|
+
await rm(join(skillsRoot(), name), { recursive: true, force: true }).catch(() => {})
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Write every enabled skill to disk and drop directories this library
|
|
297
|
+
* created but no longer declares. Directories it never created are left
|
|
298
|
+
* alone, so hand-written skills in the same root survive.
|
|
299
|
+
*/
|
|
300
|
+
async syncAll() {
|
|
301
|
+
return this.mutate(async draft => {
|
|
302
|
+
const written = []
|
|
303
|
+
const keep = new Set()
|
|
304
|
+
for (const skill of draft.skills) {
|
|
305
|
+
if (skill.enabled === false) continue
|
|
306
|
+
keep.add(skill.name)
|
|
307
|
+
try {
|
|
308
|
+
await this.materialize(skill)
|
|
309
|
+
written.push(skill.name)
|
|
310
|
+
} catch {
|
|
311
|
+
// A single unwritable skill must not abort the rest of the library.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
for (const name of draft.synced) {
|
|
315
|
+
if (!keep.has(name)) await this.forget(name)
|
|
316
|
+
}
|
|
317
|
+
draft.synced = [...keep]
|
|
318
|
+
return { written }
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Create or replace one skill and push it to disk. */
|
|
323
|
+
async save(input) {
|
|
324
|
+
const skill = sanitizeSkill({ ...input, updatedAt: new Date().toISOString() })
|
|
325
|
+
if (skill === undefined) throw new Error('技能名必须是 kebab-case(小写字母、数字、连字符),例如 release-checklist')
|
|
326
|
+
return this.mutate(async draft => {
|
|
327
|
+
const index = draft.skills.findIndex(entry => entry.name === skill.name)
|
|
328
|
+
if (index === -1) draft.skills.push(skill)
|
|
329
|
+
else draft.skills[index] = skill
|
|
330
|
+
if (!draft.synced.includes(skill.name)) draft.synced.push(skill.name)
|
|
331
|
+
let file
|
|
332
|
+
let warning
|
|
333
|
+
try {
|
|
334
|
+
file = await this.materialize(skill)
|
|
335
|
+
} catch (error) {
|
|
336
|
+
// Keep the row: the body is still worth keeping, and the next boot
|
|
337
|
+
// sync will retry the write.
|
|
338
|
+
warning = `技能已存库,但写入 SKILL.md 失败:${error instanceof Error ? error.message : String(error)}`
|
|
339
|
+
}
|
|
340
|
+
return { skill, file, warning }
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Delete one skill from the library and from disk. */
|
|
345
|
+
async remove(name) {
|
|
346
|
+
return this.mutate(async draft => {
|
|
347
|
+
const index = draft.skills.findIndex(entry => entry.name === name)
|
|
348
|
+
if (index === -1) throw new Error(`no skill ${name}`)
|
|
349
|
+
draft.skills.splice(index, 1)
|
|
350
|
+
draft.synced = draft.synced.filter(entry => entry !== name)
|
|
351
|
+
await this.forget(name)
|
|
352
|
+
return { name }
|
|
353
|
+
})
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Names of skills the runtime can currently see, for a live cross-check. */
|
|
357
|
+
async visibleRoots() {
|
|
358
|
+
try {
|
|
359
|
+
const entries = await readdir(skillsRoot(), { withFileTypes: true })
|
|
360
|
+
return entries.filter(entry => entry.isDirectory()).map(entry => entry.name)
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (error?.code === 'ENOENT') return []
|
|
363
|
+
throw error
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The library the HTTP routes and tools share. Set in `apply()`. */
|
|
369
|
+
let activeSkills
|
|
370
|
+
|
|
371
|
+
/** A short, readable task id. */
|
|
372
|
+
function newTaskId() {
|
|
373
|
+
return `t-${randomBytes(4).toString('hex')}`
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Trimmed string or undefined. */
|
|
377
|
+
// -------------------------------------------------------------------- http
|
|
378
|
+
|
|
379
|
+
/** Write one CSV response (Excel 友好:带 BOM,逗号字段加引号转义)。 */
|
|
380
|
+
function sendCsv(res, rows, filename) {
|
|
381
|
+
const escape = value => {
|
|
382
|
+
const text = String(value ?? '')
|
|
383
|
+
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
|
|
384
|
+
}
|
|
385
|
+
const payload = Buffer.from(`\uFEFF${rows.map(row => row.map(escape).join(',')).join('\r\n')}\r\n`, 'utf8')
|
|
386
|
+
res.writeHead(200, {
|
|
387
|
+
'content-type': 'text/csv; charset=utf-8',
|
|
388
|
+
'content-disposition': `attachment; filename="${filename}"`,
|
|
389
|
+
'cache-control': 'no-store',
|
|
390
|
+
'content-length': payload.length,
|
|
391
|
+
})
|
|
392
|
+
res.end(payload)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Write one JSON response. */
|
|
396
|
+
function sendJson(res, status, body) {
|
|
397
|
+
const payload = JSON.stringify(body)
|
|
398
|
+
res.writeHead(status, {
|
|
399
|
+
'content-type': 'application/json; charset=utf-8',
|
|
400
|
+
'cache-control': 'no-store',
|
|
401
|
+
'content-length': Buffer.byteLength(payload),
|
|
402
|
+
})
|
|
403
|
+
res.end(payload)
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Read and parse a JSON request body. */
|
|
407
|
+
async function readJsonBody(req) {
|
|
408
|
+
if (req.__studioBody !== undefined) return req.__studioBody
|
|
409
|
+
const chunks = []
|
|
410
|
+
let size = 0
|
|
411
|
+
for await (const chunk of req) {
|
|
412
|
+
size += chunk.length
|
|
413
|
+
if (size > 2_000_000) throw new Error('request body too large')
|
|
414
|
+
chunks.push(chunk)
|
|
415
|
+
}
|
|
416
|
+
if (chunks.length === 0) return {}
|
|
417
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
418
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('body must be a JSON object')
|
|
419
|
+
return parsed
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Settings as the browser may see them: secrets replaced by presence flags. */
|
|
423
|
+
function publicConfig(config) {
|
|
424
|
+
return {
|
|
425
|
+
board: config.board,
|
|
426
|
+
experts: config.experts,
|
|
427
|
+
teams: config.teams,
|
|
428
|
+
columns: config.columns,
|
|
429
|
+
gitlab: {
|
|
430
|
+
url: config.gitlab.url,
|
|
431
|
+
group: config.gitlab.group,
|
|
432
|
+
project: config.gitlab.project,
|
|
433
|
+
tokenSet: config.gitlab.token.length > 0,
|
|
434
|
+
syncEnabled: config.gitlab.syncEnabled,
|
|
435
|
+
syncLabel: config.gitlab.syncLabel,
|
|
436
|
+
lastSyncAt: config.gitlab.lastSyncAt,
|
|
437
|
+
lastSyncError: config.gitlab.lastSyncError,
|
|
438
|
+
projects: config.gitlab.projects.map(project => ({
|
|
439
|
+
id: project.id,
|
|
440
|
+
name: project.name,
|
|
441
|
+
url: project.url,
|
|
442
|
+
project: project.project,
|
|
443
|
+
group: project.group,
|
|
444
|
+
tokenSet: project.token.length > 0,
|
|
445
|
+
syncEnabled: project.syncEnabled,
|
|
446
|
+
syncLabel: project.syncLabel,
|
|
447
|
+
lastSyncAt: project.lastSyncAt,
|
|
448
|
+
lastSyncError: project.lastSyncError,
|
|
449
|
+
})),
|
|
450
|
+
},
|
|
451
|
+
feishu: {
|
|
452
|
+
appId: config.feishu.appId,
|
|
453
|
+
chatId: config.feishu.chatId,
|
|
454
|
+
baseToken: config.feishu.baseToken,
|
|
455
|
+
tableId: config.feishu.tableId,
|
|
456
|
+
appSecretSet: config.feishu.appSecret.length > 0,
|
|
457
|
+
webhookSet: config.feishu.webhook.length > 0,
|
|
458
|
+
notifyOnChange: config.feishu.notifyOnChange,
|
|
459
|
+
driveFolder: config.feishu.driveFolder,
|
|
460
|
+
autoSyncArtifacts: config.feishu.autoSyncArtifacts,
|
|
461
|
+
escalateWebhookSet: String(config.feishu.escalateWebhook ?? '').length > 0,
|
|
462
|
+
escalateAfter: config.feishu.escalateAfter,
|
|
463
|
+
larkReady: larkProbeCache.ok,
|
|
464
|
+
bots: config.feishu.bots.map(bot => ({
|
|
465
|
+
id: bot.id,
|
|
466
|
+
name: bot.name,
|
|
467
|
+
chatId: bot.chatId,
|
|
468
|
+
webhookSet: bot.webhook.length > 0,
|
|
469
|
+
webhookHint: bot.webhook.length > 8 ? `…${bot.webhook.slice(-6)}` : '',
|
|
470
|
+
events: bot.events,
|
|
471
|
+
notifyOnChange: bot.notifyOnChange,
|
|
472
|
+
enabled: bot.enabled,
|
|
473
|
+
})),
|
|
474
|
+
},
|
|
475
|
+
roles: config.roles,
|
|
476
|
+
projects: config.projects,
|
|
477
|
+
templates: config.templates,
|
|
478
|
+
market: config.market,
|
|
479
|
+
agents: config.agents,
|
|
480
|
+
testing: config.testing,
|
|
481
|
+
access: config.access,
|
|
482
|
+
sla: config.sla,
|
|
483
|
+
design: config.design,
|
|
484
|
+
costs: config.costs,
|
|
485
|
+
/** 部署环境:只用于登记「版本上过哪个环境」,不触发部署。 */
|
|
486
|
+
environments: config.environments ?? defaultDeployEnvs(),
|
|
487
|
+
/** 发布准入规则:要求先上预发 / 要求需求签字。 */
|
|
488
|
+
releaseRules: config.releaseRules ?? { requireStaging: false, requireRequirementSignoff: false },
|
|
489
|
+
/** 统一登录:只回配置和「密码有没有存过」,密码本身永远不出服务端。 */
|
|
490
|
+
ldap: (() => {
|
|
491
|
+
const ldap = sanitizeLdap(config.ldap ?? {})
|
|
492
|
+
return {
|
|
493
|
+
enabled: ldap.enabled,
|
|
494
|
+
url: ldap.url,
|
|
495
|
+
baseDN: ldap.baseDN,
|
|
496
|
+
bindDN: ldap.bindDN,
|
|
497
|
+
userFilter: ldap.userFilter,
|
|
498
|
+
nameAttr: ldap.nameAttr,
|
|
499
|
+
mailAttr: ldap.mailAttr,
|
|
500
|
+
deptAttr: ldap.deptAttr,
|
|
501
|
+
defaultRole: ldap.defaultRole,
|
|
502
|
+
requireLogin: ldap.requireLogin,
|
|
503
|
+
timeoutMs: ldap.timeoutMs,
|
|
504
|
+
bindPasswordSet: String(ldap.bindPassword ?? '').length > 0,
|
|
505
|
+
}
|
|
506
|
+
})(),
|
|
507
|
+
/** 本地超管账号(口令永远不下发,只告诉有没有设过)。 */
|
|
508
|
+
localAuth: (() => {
|
|
509
|
+
const local = sanitizeLocalAuth(config.localAuth)
|
|
510
|
+
return {
|
|
511
|
+
enabled: local.enabled,
|
|
512
|
+
username: local.username,
|
|
513
|
+
hasPassword: local.password !== null,
|
|
514
|
+
mustChange: local.mustChange,
|
|
515
|
+
updatedAt: local.updatedAt,
|
|
516
|
+
}
|
|
517
|
+
})(),
|
|
518
|
+
/** 登录过的人(LDAP 同步 + 最近登录情况)。 */
|
|
519
|
+
users: config.users ?? [],
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** 一个项目的进度体检:每列多少卡、多少阻塞/逾期、缺陷、清单完成度、周期。 */
|
|
524
|
+
function projectMetrics(tasks, now) {
|
|
525
|
+
const byStatus = {}
|
|
526
|
+
for (const status of STATUS_IDS) byStatus[status] = 0
|
|
527
|
+
let blocked = 0
|
|
528
|
+
let overdue = 0
|
|
529
|
+
let bugs = 0
|
|
530
|
+
let done = 0
|
|
531
|
+
let checkTotal = 0
|
|
532
|
+
let checkDone = 0
|
|
533
|
+
let artifacts = 0
|
|
534
|
+
let cycleSum = 0
|
|
535
|
+
let cycleCount = 0
|
|
536
|
+
let throughput = 0
|
|
537
|
+
let codingTasks = 0
|
|
538
|
+
let filesChanged = 0
|
|
539
|
+
let testsTotal = 0
|
|
540
|
+
let testsPassed = 0
|
|
541
|
+
let reviewed = 0
|
|
542
|
+
let merged = 0
|
|
543
|
+
const week = 7 * 24 * 3600 * 1000
|
|
544
|
+
for (const task of tasks) {
|
|
545
|
+
if (byStatus[task.status] !== undefined) byStatus[task.status] += 1
|
|
546
|
+
if (task.blocked !== null && task.blocked !== undefined) blocked += 1
|
|
547
|
+
if (task.dueAt > 0 && task.dueAt < now && task.status !== 'done') overdue += 1
|
|
548
|
+
if (task.type === 'bug' && task.status !== 'done') bugs += 1
|
|
549
|
+
if (task.status === 'done') {
|
|
550
|
+
done += 1
|
|
551
|
+
if (task.updatedAt > now - week) throughput += 1
|
|
552
|
+
cycleSum += Math.max(0, task.updatedAt - task.createdAt)
|
|
553
|
+
cycleCount += 1
|
|
554
|
+
}
|
|
555
|
+
for (const item of task.checklist ?? []) {
|
|
556
|
+
checkTotal += 1
|
|
557
|
+
if (item.done) checkDone += 1
|
|
558
|
+
}
|
|
559
|
+
artifacts += (task.artifacts ?? []).length
|
|
560
|
+
if (task.coding !== null && task.coding !== undefined) {
|
|
561
|
+
codingTasks += 1
|
|
562
|
+
filesChanged += task.coding.files.length
|
|
563
|
+
if (task.coding.unitTests.total > 0) { testsTotal += task.coding.unitTests.total; testsPassed += task.coding.unitTests.passed }
|
|
564
|
+
if (task.coding.review.status === 'approved') reviewed += 1
|
|
565
|
+
if (task.coding.mergedAt > 0) merged += 1
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const inProgress = tasks.filter(task => task.status !== 'done').length
|
|
569
|
+
return {
|
|
570
|
+
total: tasks.length,
|
|
571
|
+
byStatus,
|
|
572
|
+
inProgress,
|
|
573
|
+
blocked,
|
|
574
|
+
overdue,
|
|
575
|
+
bugs,
|
|
576
|
+
done,
|
|
577
|
+
checklist: { total: checkTotal, done: checkDone },
|
|
578
|
+
checklistRate: checkTotal > 0 ? Math.round((checkDone / checkTotal) * 100) : 0,
|
|
579
|
+
artifacts,
|
|
580
|
+
avgCycleMs: cycleCount > 0 ? Math.round(cycleSum / cycleCount) : 0,
|
|
581
|
+
cycleDays: cycleCount > 0 ? Math.round((cycleSum / cycleCount / DAY) * 10) / 10 : 0,
|
|
582
|
+
throughput7d: throughput,
|
|
583
|
+
coding: { tasks: codingTasks, files: filesChanged, tests: testsTotal, testsPassed, reviewed, merged },
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** 按项目汇总,外加「没挂项目」的那一堆。 */
|
|
588
|
+
function buildMetrics(state) {
|
|
589
|
+
const now = Date.now()
|
|
590
|
+
const projects = state.config.projects.map(project => {
|
|
591
|
+
const tasks = state.tasks.filter(task => task.project === project.id)
|
|
592
|
+
return {
|
|
593
|
+
id: project.id,
|
|
594
|
+
name: project.name,
|
|
595
|
+
key: project.key,
|
|
596
|
+
status: project.status,
|
|
597
|
+
iterations: project.iterations,
|
|
598
|
+
metrics: projectMetrics(tasks, now),
|
|
599
|
+
}
|
|
600
|
+
})
|
|
601
|
+
const assigned = new Set(state.config.projects.map(project => project.id))
|
|
602
|
+
const loose = state.tasks.filter(task => task.project === '' || !assigned.has(task.project))
|
|
603
|
+
return { projects, unassigned: projectMetrics(loose, now), all: projectMetrics(state.tasks, now) }
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// --------------------------------------------------------------- analytics
|
|
607
|
+
// 效能看的是「过程和返工」,不是卡片数:一次通过率、返工率、周期分布、
|
|
608
|
+
// 每一列停留多久、AI 交付物被人采纳多少。
|
|
609
|
+
|
|
610
|
+
const ANALYTICS_DAYS = [7, 30, 90]
|
|
611
|
+
|
|
612
|
+
function percentile(values, ratio) {
|
|
613
|
+
if (values.length === 0) return 0
|
|
614
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
615
|
+
const index = Math.min(sorted.length - 1, Math.max(0, Math.round((sorted.length - 1) * ratio)))
|
|
616
|
+
return sorted[index]
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const DAY = 86_400_000
|
|
620
|
+
|
|
621
|
+
/** 把一张卡的历史折成「走过哪些列、倒退过几次、每列待了多久」。 */
|
|
622
|
+
function taskTimeline(task) {
|
|
623
|
+
const history = Array.isArray(task.history) ? task.history : []
|
|
624
|
+
const steps = []
|
|
625
|
+
for (const entry of history) {
|
|
626
|
+
const status = String(entry?.status ?? '')
|
|
627
|
+
if (status === '' || !STATUS_IDS.includes(status)) continue
|
|
628
|
+
if (steps.length > 0 && steps[steps.length - 1].status === status) {
|
|
629
|
+
// 同一列重复记录(比如阻塞/解除)合并,但保留最后一次时间
|
|
630
|
+
steps[steps.length - 1].at = Number(entry.at) || steps[steps.length - 1].at
|
|
631
|
+
continue
|
|
632
|
+
}
|
|
633
|
+
steps.push({ status, at: Number(entry.at) || 0, by: String(entry?.by ?? ''), note: String(entry?.note ?? '') })
|
|
634
|
+
}
|
|
635
|
+
if (steps.length === 0) {
|
|
636
|
+
steps.push({ status: task.status, at: Number(task.createdAt) || 0, by: '', note: '' })
|
|
637
|
+
}
|
|
638
|
+
const rank = status => STATUS_IDS.indexOf(status)
|
|
639
|
+
let backward = 0
|
|
640
|
+
let reviewBack = 0
|
|
641
|
+
let testBack = 0
|
|
642
|
+
const dwell = {}
|
|
643
|
+
for (let index = 0; index < steps.length; index += 1) {
|
|
644
|
+
const step = steps[index]
|
|
645
|
+
const next = steps[index + 1]
|
|
646
|
+
const until = next === undefined ? Date.now() : next.at
|
|
647
|
+
const span = Math.max(0, until - step.at)
|
|
648
|
+
dwell[step.status] = (dwell[step.status] ?? 0) + span
|
|
649
|
+
if (next === undefined) continue
|
|
650
|
+
if (rank(next.status) < rank(step.status)) {
|
|
651
|
+
backward += 1
|
|
652
|
+
if (step.status === 'review') reviewBack += 1
|
|
653
|
+
if (step.status === 'test' || step.status === 'accept') testBack += 1
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
const doneStep = steps.find(step => step.status === 'done')
|
|
657
|
+
return {
|
|
658
|
+
steps,
|
|
659
|
+
backward,
|
|
660
|
+
reviewBack,
|
|
661
|
+
testBack,
|
|
662
|
+
dwell,
|
|
663
|
+
doneAt: doneStep === undefined ? 0 : doneStep.at,
|
|
664
|
+
columns: steps.length,
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function buildAnalytics(state, options = {}) {
|
|
669
|
+
const now = Date.now()
|
|
670
|
+
const requested = Number(options.days)
|
|
671
|
+
const days = ANALYTICS_DAYS.includes(requested) ? requested : 30
|
|
672
|
+
const since = now - days * DAY
|
|
673
|
+
const project = String(options.project ?? '').trim()
|
|
674
|
+
const inScope = state.tasks.filter(task => {
|
|
675
|
+
if (project !== '' && task.project !== project) return false
|
|
676
|
+
const createdAt = Number(task.createdAt) || 0
|
|
677
|
+
return createdAt >= since || task.status !== 'done'
|
|
678
|
+
})
|
|
679
|
+
|
|
680
|
+
const expertNames = new Set((state.config.experts ?? []).map(expert => expert.name))
|
|
681
|
+
let moved = 0
|
|
682
|
+
let reworked = 0
|
|
683
|
+
let reviewRework = 0
|
|
684
|
+
let testRework = 0
|
|
685
|
+
let done = 0
|
|
686
|
+
let doneFirstPass = 0
|
|
687
|
+
const cycleValues = []
|
|
688
|
+
const buckets = { fast: 0, normal: 0, slow: 0, verySlow: 0 }
|
|
689
|
+
const dwellTotals = {}
|
|
690
|
+
const dwellCounts = {}
|
|
691
|
+
const worst = []
|
|
692
|
+
|
|
693
|
+
for (const task of inScope) {
|
|
694
|
+
const timeline = taskTimeline(task)
|
|
695
|
+
if (timeline.steps.length > 1) moved += 1
|
|
696
|
+
if (timeline.backward > 0) {
|
|
697
|
+
reworked += 1
|
|
698
|
+
worst.push({ id: task.id, code: task.code ?? '', title: task.title, backward: timeline.backward, status: task.status })
|
|
699
|
+
}
|
|
700
|
+
if (timeline.reviewBack > 0) reviewRework += 1
|
|
701
|
+
if (timeline.testBack > 0) testRework += 1
|
|
702
|
+
for (const [status, span] of Object.entries(timeline.dwell)) {
|
|
703
|
+
if (span <= 0) continue
|
|
704
|
+
dwellTotals[status] = (dwellTotals[status] ?? 0) + span
|
|
705
|
+
dwellCounts[status] = (dwellCounts[status] ?? 0) + 1
|
|
706
|
+
}
|
|
707
|
+
if (timeline.doneAt > 0 && timeline.doneAt >= since) {
|
|
708
|
+
done += 1
|
|
709
|
+
const cycle = Math.max(0, timeline.doneAt - (Number(task.createdAt) || timeline.doneAt))
|
|
710
|
+
cycleValues.push(cycle)
|
|
711
|
+
if (timeline.backward === 0) doneFirstPass += 1
|
|
712
|
+
const cycleDays = cycle / DAY
|
|
713
|
+
if (cycleDays <= 1) buckets.fast += 1
|
|
714
|
+
else if (cycleDays <= 3) buckets.normal += 1
|
|
715
|
+
else if (cycleDays <= 7) buckets.slow += 1
|
|
716
|
+
else buckets.verySlow += 1
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// AI 产出:交付物是谁登记的、有没有被人验收;Coding 记录评审通过率。
|
|
721
|
+
let artifactsTotal = 0
|
|
722
|
+
let artifactsAi = 0
|
|
723
|
+
let artifactsAiAccepted = 0
|
|
724
|
+
let artifactsAiEmpty = 0
|
|
725
|
+
let artifactsEmpty = 0
|
|
726
|
+
let codingTasks = 0
|
|
727
|
+
let codingApproved = 0
|
|
728
|
+
let codingChanges = 0
|
|
729
|
+
let testsTotal = 0
|
|
730
|
+
let testsPassed = 0
|
|
731
|
+
let merged = 0
|
|
732
|
+
for (const task of inScope) {
|
|
733
|
+
for (const artifact of task.artifacts ?? []) {
|
|
734
|
+
if (typeof artifact !== 'object' || artifact === null) continue
|
|
735
|
+
artifactsTotal += 1
|
|
736
|
+
const by = String(artifact.by ?? '')
|
|
737
|
+
const empty = String(artifact.content ?? '').trim() === ''
|
|
738
|
+
if (empty) artifactsEmpty += 1
|
|
739
|
+
const isAi = expertNames.has(by) || by.trim().toLowerCase() === 'agent'
|
|
740
|
+
if (!isAi) continue
|
|
741
|
+
artifactsAi += 1
|
|
742
|
+
if (artifact.status === 'accepted') artifactsAiAccepted += 1
|
|
743
|
+
if (empty) artifactsAiEmpty += 1
|
|
744
|
+
}
|
|
745
|
+
const coding = task.coding
|
|
746
|
+
if (coding !== null && typeof coding === 'object') {
|
|
747
|
+
codingTasks += 1
|
|
748
|
+
if (coding.review?.status === 'approved') codingApproved += 1
|
|
749
|
+
if (coding.review?.status === 'changes_requested') codingChanges += 1
|
|
750
|
+
testsTotal += Number(coding.unitTests?.total) || 0
|
|
751
|
+
testsPassed += Number(coding.unitTests?.passed) || 0
|
|
752
|
+
if (coding.mergedAt > 0) merged += 1
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const throughput = []
|
|
757
|
+
for (let offset = Math.min(days, 14) - 1; offset >= 0; offset -= 1) {
|
|
758
|
+
const start = new Date(now - offset * DAY)
|
|
759
|
+
start.setHours(0, 0, 0, 0)
|
|
760
|
+
const from = start.getTime()
|
|
761
|
+
const to = from + DAY
|
|
762
|
+
let count = 0
|
|
763
|
+
for (const task of inScope) {
|
|
764
|
+
const timeline = taskTimeline(task)
|
|
765
|
+
if (timeline.doneAt >= from && timeline.doneAt < to) count += 1
|
|
766
|
+
}
|
|
767
|
+
throughput.push({ day: `${start.getMonth() + 1}-${String(start.getDate()).padStart(2, '0')}`, from, count })
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const columnDwell = STATUS_IDS.map(status => ({
|
|
771
|
+
status,
|
|
772
|
+
label: STATUSES.find(entry => entry.id === status)?.label ?? status,
|
|
773
|
+
avgDays: dwellCounts[status] > 0 ? dwellTotals[status] / dwellCounts[status] / DAY : 0,
|
|
774
|
+
cards: dwellCounts[status] ?? 0,
|
|
775
|
+
}))
|
|
776
|
+
|
|
777
|
+
const rate = (value, total) => (total > 0 ? value / total : 0)
|
|
778
|
+
|
|
779
|
+
return {
|
|
780
|
+
days,
|
|
781
|
+
project,
|
|
782
|
+
generatedAt: now,
|
|
783
|
+
scope: { tasks: inScope.length, moved, done },
|
|
784
|
+
cycle: {
|
|
785
|
+
count: cycleValues.length,
|
|
786
|
+
avgDays: cycleValues.length > 0 ? cycleValues.reduce((sum, value) => sum + value, 0) / cycleValues.length / DAY : 0,
|
|
787
|
+
medianDays: percentile(cycleValues, 0.5) / DAY,
|
|
788
|
+
p90Days: percentile(cycleValues, 0.9) / DAY,
|
|
789
|
+
buckets: [
|
|
790
|
+
{ id: 'fast', label: '≤1 天', count: buckets.fast },
|
|
791
|
+
{ id: 'normal', label: '1–3 天', count: buckets.normal },
|
|
792
|
+
{ id: 'slow', label: '3–7 天', count: buckets.slow },
|
|
793
|
+
{ id: 'verySlow', label: '>7 天', count: buckets.verySlow },
|
|
794
|
+
],
|
|
795
|
+
},
|
|
796
|
+
rework: {
|
|
797
|
+
moved,
|
|
798
|
+
reworked,
|
|
799
|
+
rate: rate(reworked, moved),
|
|
800
|
+
reviewRework,
|
|
801
|
+
testRework,
|
|
802
|
+
firstPassRate: rate(doneFirstPass, done),
|
|
803
|
+
worst: worst.sort((a, b) => b.backward - a.backward).slice(0, 8),
|
|
804
|
+
},
|
|
805
|
+
columns: columnDwell,
|
|
806
|
+
throughput,
|
|
807
|
+
ai: {
|
|
808
|
+
artifactsTotal,
|
|
809
|
+
artifactsAi,
|
|
810
|
+
artifactsEmpty,
|
|
811
|
+
aiShare: rate(artifactsAi, artifactsTotal),
|
|
812
|
+
accepted: artifactsAiAccepted,
|
|
813
|
+
acceptedRate: rate(artifactsAiAccepted, artifactsAi),
|
|
814
|
+
emptyBody: artifactsEmpty,
|
|
815
|
+
aiEmptyBody: artifactsAiEmpty,
|
|
816
|
+
codingTasks,
|
|
817
|
+
codingApproved,
|
|
818
|
+
codingChanges,
|
|
819
|
+
codingApprovedRate: rate(codingApproved, codingTasks),
|
|
820
|
+
unitTests: testsTotal,
|
|
821
|
+
unitPassed: testsPassed,
|
|
822
|
+
unitPassRate: rate(testsPassed, testsTotal),
|
|
823
|
+
merged,
|
|
824
|
+
},
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// ------------------------------------------------------------ releases
|
|
829
|
+
// 版本不是「打个 tag」:它把一批卡、它们的验收结果、缺陷状态捆在一起,
|
|
830
|
+
// 只在门禁通过时才能发布,发布后还能回滚并留下原因。
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
/** 冒烟清单:发布前必须亲手过一遍的那几条。 */
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* 成员名单:谁是谁之后才能谈权限。enabled=false 时所有人按管理员算,
|
|
840
|
+
* 这样单机用法完全不受影响。
|
|
841
|
+
*/
|
|
842
|
+
|
|
843
|
+
/** 审计条目:谁、什么时候、动了哪个接口、成没成。只留摘要,不留正文与密钥。 */
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
/** 一个版本能不能发:卡全做完、没阻塞、缺陷关闭、用例没有失败。 */
|
|
847
|
+
function releaseGate(state, release) {
|
|
848
|
+
const tasks = state.tasks.filter(task => task.releaseId === release.id)
|
|
849
|
+
const open = tasks.filter(task => task.status !== 'done')
|
|
850
|
+
const blocked = tasks.filter(task => task.blocked !== null && task.blocked !== undefined)
|
|
851
|
+
const bugs = tasks.filter(task => task.type === 'bug' && task.status !== 'done')
|
|
852
|
+
const failing = []
|
|
853
|
+
const emptyArtifacts = []
|
|
854
|
+
for (const task of tasks) {
|
|
855
|
+
for (const item of task.cases ?? []) {
|
|
856
|
+
if (item.result === 'fail') failing.push({ taskId: task.id, code: task.code, title: item.title })
|
|
857
|
+
}
|
|
858
|
+
for (const item of task.artifacts ?? []) {
|
|
859
|
+
if (String(item.content ?? '').trim() === '') emptyArtifacts.push({ taskId: task.id, code: task.code, title: item.title })
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
const blockers = []
|
|
863
|
+
const smoke = release.smoke ?? []
|
|
864
|
+
const smokeOpen = smoke.filter(item => item.done !== true)
|
|
865
|
+
if (tasks.length === 0) blockers.push('这个版本还没有关联任何卡片')
|
|
866
|
+
if (open.length > 0) blockers.push(`${open.length} 张卡还没完成:${open.slice(0, 4).map(task => task.code || task.id).join('、')}`)
|
|
867
|
+
if (blocked.length > 0) blockers.push(`${blocked.length} 张卡处于阻塞`)
|
|
868
|
+
if (bugs.length > 0) blockers.push(`${bugs.length} 个缺陷未关闭`)
|
|
869
|
+
if (failing.length > 0) blockers.push(`${failing.length} 条用例仍是失败状态`)
|
|
870
|
+
if (String(release.tagName ?? '').trim() === '') blockers.push('还没有填发布标签(tag):发布要能对应到一段确定的代码')
|
|
871
|
+
if (smokeOpen.length > 0) blockers.push(`${smokeOpen.length} 项冒烟还没过:${smokeOpen.slice(0, 3).map(item => item.text).join('、')}`)
|
|
872
|
+
const warnings = []
|
|
873
|
+
if (emptyArtifacts.length > 0) warnings.push(`${emptyArtifacts.length} 件交付物缺正文`)
|
|
874
|
+
if (tasks.length > 0 && tasks.every(task => (task.artifacts ?? []).length === 0)) warnings.push('这批卡一件交付物都没登记')
|
|
875
|
+
if ((release.approvals ?? []).length === 0) warnings.push('还没有审批记录:发布前应该有人在版本上点「审批通过」并写结论')
|
|
876
|
+
const gray = release.grayBatches ?? []
|
|
877
|
+
const grayPending = gray.filter(item => item.status === 'pending' || item.status === 'running')
|
|
878
|
+
if (gray.length === 0) warnings.push('没有灰度批次:将一次性全量发布')
|
|
879
|
+
if (gray.length > 0 && grayPending.length > 0 && release.status === 'released') {
|
|
880
|
+
warnings.push(`还有 ${grayPending.length} 批灰度没走完`)
|
|
881
|
+
}
|
|
882
|
+
return {
|
|
883
|
+
ready: blockers.length === 0 && release.status !== 'released',
|
|
884
|
+
blockers,
|
|
885
|
+
warnings,
|
|
886
|
+
stats: {
|
|
887
|
+
tasks: tasks.length,
|
|
888
|
+
done: tasks.length - open.length,
|
|
889
|
+
open: open.length,
|
|
890
|
+
blocked: blocked.length,
|
|
891
|
+
bugs: bugs.length,
|
|
892
|
+
failing: failing.length,
|
|
893
|
+
artifacts: tasks.reduce((sum, task) => sum + (task.artifacts ?? []).length, 0),
|
|
894
|
+
emptyArtifacts: emptyArtifacts.length,
|
|
895
|
+
smoke: { total: smoke.length, done: smoke.length - smokeOpen.length },
|
|
896
|
+
approvals: (release.approvals ?? []).length,
|
|
897
|
+
gray: { total: gray.length, passed: gray.filter(item => item.status === 'passed').length },
|
|
898
|
+
},
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/** 发布说明:按类型分组,编号 + 标题,人可以直接贴到群里。 */
|
|
903
|
+
function releaseNotes(state, release) {
|
|
904
|
+
const tasks = state.tasks.filter(task => task.releaseId === release.id)
|
|
905
|
+
const groups = [
|
|
906
|
+
['requirement', '需求'],
|
|
907
|
+
['design', '设计'],
|
|
908
|
+
['dev', '研发'],
|
|
909
|
+
['bug', '缺陷修复'],
|
|
910
|
+
['test', '测试'],
|
|
911
|
+
['ops', '运维'],
|
|
912
|
+
]
|
|
913
|
+
const lines = [`# ${release.version || '未编号'} ${release.name}`.trim(), '']
|
|
914
|
+
const when = release.releasedAt > 0 ? release.releasedAt : release.plannedAt
|
|
915
|
+
if (when > 0) lines.push(`- 时间:${new Date(when).toISOString().slice(0, 10)}`)
|
|
916
|
+
if (release.owner !== '') lines.push(`- 负责人:${release.owner}`)
|
|
917
|
+
lines.push(`- 关联卡片:${tasks.length} 张`)
|
|
918
|
+
lines.push('')
|
|
919
|
+
// 版本 ↔ 需求:从卡片上的 requirementId 反查,发版说明里能直接看到这次交付了哪几条需求。
|
|
920
|
+
const reqIds = [...new Set(tasks.map(task => String(task.requirementId ?? '')).filter(id => id !== ''))]
|
|
921
|
+
const reqs = reqIds.map(id => state.requirements.find(item => item.id === id)).filter(item => item !== undefined)
|
|
922
|
+
if (reqs.length > 0) {
|
|
923
|
+
lines.push(`## 需求(${reqs.length} 条)`)
|
|
924
|
+
for (const req of reqs) lines.push(`- ${req.code === '' ? req.id : req.code} ${req.title}`)
|
|
925
|
+
lines.push('')
|
|
926
|
+
}
|
|
927
|
+
let any = false
|
|
928
|
+
for (const [type, label] of groups) {
|
|
929
|
+
const list = tasks.filter(task => (task.type ?? 'requirement') === type)
|
|
930
|
+
if (list.length === 0) continue
|
|
931
|
+
any = true
|
|
932
|
+
lines.push(`## ${label}`)
|
|
933
|
+
for (const task of list) {
|
|
934
|
+
const code = task.code === '' ? task.id : task.code
|
|
935
|
+
lines.push(`- ${code} ${task.title}`)
|
|
936
|
+
}
|
|
937
|
+
lines.push('')
|
|
938
|
+
}
|
|
939
|
+
if (!any) lines.push('(这个版本还没有关联卡片)')
|
|
940
|
+
return lines.join('\n').slice(0, 60000)
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/** 版本 ↔ 需求:按卡上的 releaseId 反查,这版到底交付了哪几条需求。 */
|
|
944
|
+
/**
|
|
945
|
+
* 发布准入(可配):是否要求先上过预发、需求是否签过字。
|
|
946
|
+
* 这两条默认关闭,打开后发布必须满足,否则要走「强制发布」并写原因。
|
|
947
|
+
*/
|
|
948
|
+
function releaseRuleViolations(state, release) {
|
|
949
|
+
const rules = state.config?.releaseRules ?? {}
|
|
950
|
+
const violations = []
|
|
951
|
+
if (rules.requireStaging === true) {
|
|
952
|
+
const staging = (state.config.environments ?? []).filter(env => env.kind === 'staging').map(env => env.id)
|
|
953
|
+
const deployed = (release.deployLog ?? []).filter(entry => entry.action === 'deploy' && staging.includes(String(entry.env ?? '')))
|
|
954
|
+
if (staging.length === 0) violations.push('还没配「预发」环境(设置 → 部署环境里把某个环境设成 staging)')
|
|
955
|
+
else if (deployed.length === 0) violations.push('这个版本还没登记过预发部署(版本详情 → 登记部署)')
|
|
956
|
+
}
|
|
957
|
+
if (rules.requireRequirementSignoff === true) {
|
|
958
|
+
const reqs = releaseRequirements(state, release)
|
|
959
|
+
const unsigned = reqs.filter(item => {
|
|
960
|
+
const found = state.requirements.find(req => req.id === item.id)
|
|
961
|
+
return found !== undefined && (found.signoff === null || found.signoff === undefined)
|
|
962
|
+
})
|
|
963
|
+
if (unsigned.length > 0) violations.push(`关联需求还没验收签字:${unsigned.map(item => item.code || item.title).join('、')}`)
|
|
964
|
+
}
|
|
965
|
+
return violations
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function releaseRequirements(state, release) {
|
|
969
|
+
const ids = [...new Set(state.tasks
|
|
970
|
+
.filter(task => task.releaseId === release.id)
|
|
971
|
+
.map(task => String(task.requirementId ?? ''))
|
|
972
|
+
.filter(id => id !== ''))]
|
|
973
|
+
return ids
|
|
974
|
+
.map(id => state.requirements.find(item => item.id === id))
|
|
975
|
+
.filter(item => item !== undefined)
|
|
976
|
+
.map(item => ({ id: item.id, code: item.code, title: item.title, derived: requirementProgress(state, item).derived }))
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* 某个版本在哪些环境里出现过:只读 deployLog,最后一条「部署/回滚」记到哪个环境就算到哪。
|
|
981
|
+
* 平台从不触发部署,这里纯粹是时间线的投影。
|
|
982
|
+
*/
|
|
983
|
+
function releaseDeploys(state, release) {
|
|
984
|
+
const envs = Array.isArray(state.config?.environments) ? state.config.environments : []
|
|
985
|
+
const latest = new Map()
|
|
986
|
+
for (const entry of release.deployLog ?? []) {
|
|
987
|
+
const id = String(entry.env ?? '')
|
|
988
|
+
if (id === '') continue
|
|
989
|
+
if (entry.action !== 'deploy' && entry.action !== 'rollback') continue
|
|
990
|
+
latest.set(id, entry)
|
|
991
|
+
}
|
|
992
|
+
return envs.map(env => {
|
|
993
|
+
const hit = latest.get(env.id)
|
|
994
|
+
return {
|
|
995
|
+
id: env.id,
|
|
996
|
+
name: env.name,
|
|
997
|
+
kind: env.kind,
|
|
998
|
+
at: hit === undefined ? 0 : Number(hit.at) || 0,
|
|
999
|
+
action: hit === undefined ? '' : String(hit.action),
|
|
1000
|
+
by: hit === undefined ? '' : String(hit.by ?? ''),
|
|
1001
|
+
note: hit === undefined ? '' : String(hit.note ?? ''),
|
|
1002
|
+
}
|
|
1003
|
+
})
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** 一条需求的上线足迹:把挂在这条需求上的卡所在的版本,按环境合成一条时间线。 */
|
|
1007
|
+
function requirementDeploys(state, requirement) {
|
|
1008
|
+
const releaseIds = [...new Set(state.tasks
|
|
1009
|
+
.filter(task => task.requirementId === requirement.id)
|
|
1010
|
+
.map(task => String(task.releaseId ?? ''))
|
|
1011
|
+
.filter(id => id !== ''))]
|
|
1012
|
+
const out = new Map()
|
|
1013
|
+
for (const id of releaseIds) {
|
|
1014
|
+
const release = state.releases.find(item => item.id === id)
|
|
1015
|
+
if (release === undefined) continue
|
|
1016
|
+
for (const item of releaseDeploys(state, release)) {
|
|
1017
|
+
if (item.at === 0) continue
|
|
1018
|
+
const prev = out.get(item.id)
|
|
1019
|
+
if (prev === undefined || item.at > prev.at) out.set(item.id, { ...item, releaseId: release.id, releaseName: release.name })
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return [...out.values()].sort((a, b) => a.at - b.at)
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** Everything the panel needs in one poll. */
|
|
1026
|
+
function publicState(store) {
|
|
1027
|
+
const state = store.snapshot()
|
|
1028
|
+
return {
|
|
1029
|
+
revision: state.revision,
|
|
1030
|
+
updatedAt: state.updatedAt,
|
|
1031
|
+
profile: hostProfile || 'web',
|
|
1032
|
+
statuses: STATUSES,
|
|
1033
|
+
roles: ROLE_IDS,
|
|
1034
|
+
priorities: PRIORITIES,
|
|
1035
|
+
config: publicConfig(state.config),
|
|
1036
|
+
tasks: state.tasks.map(task => ({
|
|
1037
|
+
...task,
|
|
1038
|
+
...publicComments(task),
|
|
1039
|
+
...publicCases(task),
|
|
1040
|
+
...publicChecklist(task),
|
|
1041
|
+
sla: bugSla(task, state.config),
|
|
1042
|
+
// 正文太占地方,轮询只下发长度;抽屉点「阅读 / 正文」时走 /task/artifact 的 get 单独取。
|
|
1043
|
+
artifacts: (task.artifacts ?? []).map(publicArtifact),
|
|
1044
|
+
})),
|
|
1045
|
+
fallbackFolder: state.fallbackFolder ?? null,
|
|
1046
|
+
releases: (state.releases ?? []).map(release => ({
|
|
1047
|
+
...release,
|
|
1048
|
+
// 版本 ↔ 需求:按卡上的 releaseId 反查,不用人再维护一遍关联表。
|
|
1049
|
+
requirements: releaseRequirements(state, release),
|
|
1050
|
+
// 这个版本上过哪些环境(只看部署记录,不触发任何部署)。
|
|
1051
|
+
deploys: releaseDeploys(state, release),
|
|
1052
|
+
})),
|
|
1053
|
+
releaseStatuses: RELEASE_STATUSES,
|
|
1054
|
+
audit: (state.audit ?? []).slice(0, 200),
|
|
1055
|
+
integrations: healthReport(),
|
|
1056
|
+
// 技能库在磁盘上是另一份数据,但命令面板要能搜到它,所以顺带下发精简版(含"几位专家在用")。
|
|
1057
|
+
skills: activeSkills === undefined
|
|
1058
|
+
? []
|
|
1059
|
+
: activeSkills.list().map(skill => ({
|
|
1060
|
+
name: skill.name,
|
|
1061
|
+
description: skill.description ?? '',
|
|
1062
|
+
tags: skill.tags ?? [],
|
|
1063
|
+
enabled: skill.enabled !== false,
|
|
1064
|
+
users: (state.config.experts ?? []).filter(expert => (expert.skills ?? []).includes(skill.name)).length,
|
|
1065
|
+
})),
|
|
1066
|
+
storage: typeof store.describe === 'function' ? store.describe() : null,
|
|
1067
|
+
requirements: state.requirements.map(item => ({
|
|
1068
|
+
...item,
|
|
1069
|
+
// 正文可能很长,轮询不做搬运;只给长度,点开编辑时再单独取全文。
|
|
1070
|
+
docs: (item.docs ?? []).map(doc => ({
|
|
1071
|
+
...doc,
|
|
1072
|
+
content: '',
|
|
1073
|
+
contentLength: String(doc.content ?? '').length,
|
|
1074
|
+
})),
|
|
1075
|
+
baseline: item.baseline === null || item.baseline === undefined
|
|
1076
|
+
? null
|
|
1077
|
+
: { ...item.baseline, content: '', contentLength: String(item.baseline.content ?? '').length },
|
|
1078
|
+
// 需求级门禁在每次状态轮询里一起下发:列表和详情都靠它显示「还差几项 / 能不能签字」。
|
|
1079
|
+
progress: requirementProgress(state, item),
|
|
1080
|
+
// 这条需求对应的版本上过哪些环境(上线足迹,只登记不触发)。
|
|
1081
|
+
deploys: requirementDeploys(state, item),
|
|
1082
|
+
})),
|
|
1083
|
+
reqStatuses: REQ_STATUSES,
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/** Apply a settings patch from the browser or a tool. */
|
|
1088
|
+
async function applyConfig(store, patch) {
|
|
1089
|
+
return store.mutate(state => {
|
|
1090
|
+
const config = state.config
|
|
1091
|
+
if (patch.board !== undefined && typeof patch.board === 'object') {
|
|
1092
|
+
if (typeof patch.board.title === 'string' && patch.board.title.trim().length > 0) {
|
|
1093
|
+
config.board.title = patch.board.title.trim().slice(0, 120)
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
if (patch.experts !== undefined) {
|
|
1097
|
+
const next = sanitizeExperts(patch.experts)
|
|
1098
|
+
if (next.length > 0) config.experts = next
|
|
1099
|
+
// A column whose expert disappeared falls back to the shipped default.
|
|
1100
|
+
config.columns = sanitizeColumns(config.columns, config.experts, config.teams)
|
|
1101
|
+
}
|
|
1102
|
+
if (patch.teams !== undefined) {
|
|
1103
|
+
config.teams = sanitizeTeams(patch.teams, config.experts)
|
|
1104
|
+
config.columns = sanitizeColumns(config.columns, config.experts, config.teams)
|
|
1105
|
+
}
|
|
1106
|
+
if (patch.columns !== undefined && typeof patch.columns === 'object') {
|
|
1107
|
+
config.columns = sanitizeColumns({ ...config.columns, ...patch.columns }, config.experts, config.teams)
|
|
1108
|
+
}
|
|
1109
|
+
if (patch.projects !== undefined) {
|
|
1110
|
+
config.projects = sanitizeWorkProjects(patch.projects)
|
|
1111
|
+
// 服务被删/改名后,卡上残留的服务 id 一律清掉(卡还在,只是不再指向任何路径)。
|
|
1112
|
+
const byId = new Map(config.projects.map(item => [item.id, item]))
|
|
1113
|
+
for (const task of state.tasks ?? []) {
|
|
1114
|
+
const service = String(task.service ?? '')
|
|
1115
|
+
if (service === '') continue
|
|
1116
|
+
const project = byId.get(String(task.project ?? ''))
|
|
1117
|
+
if (project === undefined || !(project.services ?? []).some(item => item.id === service)) task.service = ''
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
if (patch.templates !== undefined) {
|
|
1121
|
+
config.templates = sanitizeTemplates(patch.templates)
|
|
1122
|
+
}
|
|
1123
|
+
if (patch.access !== undefined && typeof patch.access === 'object') {
|
|
1124
|
+
const a = patch.access
|
|
1125
|
+
if (Array.isArray(a.members)) config.access.members = sanitizeAccess({ ...config.access, members: a.members }).members
|
|
1126
|
+
if (typeof a.enabled === 'boolean') config.access.enabled = a.enabled
|
|
1127
|
+
}
|
|
1128
|
+
if (patch.gitlab !== undefined && typeof patch.gitlab === 'object') {
|
|
1129
|
+
const g = patch.gitlab
|
|
1130
|
+
if (Array.isArray(g.projects)) config.gitlab.projects = sanitizeProjects(g.projects)
|
|
1131
|
+
if (typeof g.url === 'string') config.gitlab.url = g.url.trim().slice(0, 500)
|
|
1132
|
+
if (typeof g.group === 'string') config.gitlab.group = g.group.trim().slice(0, 300)
|
|
1133
|
+
if (typeof g.project === 'string') config.gitlab.project = g.project.trim().slice(0, 300)
|
|
1134
|
+
if (typeof g.token === 'string' && g.token.trim().length > 0) config.gitlab.token = g.token.trim()
|
|
1135
|
+
if (g.clearToken === true) config.gitlab.token = ''
|
|
1136
|
+
if (typeof g.syncEnabled === 'boolean') config.gitlab.syncEnabled = g.syncEnabled
|
|
1137
|
+
if (typeof g.syncLabel === 'string') config.gitlab.syncLabel = g.syncLabel.trim().slice(0, 100)
|
|
1138
|
+
}
|
|
1139
|
+
if (patch.feishu !== undefined && typeof patch.feishu === 'object') {
|
|
1140
|
+
const f = patch.feishu
|
|
1141
|
+
if (Array.isArray(f.bots)) config.feishu.bots = sanitizeBots(f.bots)
|
|
1142
|
+
for (const key of ['appId', 'chatId', 'baseToken', 'tableId', 'webhook']) {
|
|
1143
|
+
if (typeof f[key] === 'string') config.feishu[key] = f[key].trim().slice(0, 500)
|
|
1144
|
+
}
|
|
1145
|
+
if (typeof f.appSecret === 'string' && f.appSecret.trim().length > 0) config.feishu.appSecret = f.appSecret.trim()
|
|
1146
|
+
if (f.clearAppSecret === true) config.feishu.appSecret = ''
|
|
1147
|
+
if (typeof f.notifyOnChange === 'boolean') config.feishu.notifyOnChange = f.notifyOnChange
|
|
1148
|
+
if (typeof f.driveFolder === 'string') config.feishu.driveFolder = f.driveFolder.trim().slice(0, 200)
|
|
1149
|
+
if (typeof f.autoSyncArtifacts === 'boolean') config.feishu.autoSyncArtifacts = f.autoSyncArtifacts
|
|
1150
|
+
if (typeof f.escalateWebhook === 'string') config.feishu.escalateWebhook = f.escalateWebhook.trim().slice(0, 500)
|
|
1151
|
+
if (Number.isFinite(Number(f.escalateAfter))) config.feishu.escalateAfter = Math.max(1, Math.min(Math.round(Number(f.escalateAfter)), 20))
|
|
1152
|
+
}
|
|
1153
|
+
if (patch.design !== undefined) {
|
|
1154
|
+
const next = { ...config.design, ...(patch.design ?? {}) }
|
|
1155
|
+
config.design = {
|
|
1156
|
+
enabled: next.enabled === true,
|
|
1157
|
+
columns: (Array.isArray(next.columns) ? next.columns : ['dev']).map(String).slice(0, 8),
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
if (patch.costs !== undefined) config.costs = sanitizeCosts({ ...config.costs, ...patch.costs })
|
|
1161
|
+
if (patch.ldap !== undefined && typeof patch.ldap === 'object') {
|
|
1162
|
+
const ld = patch.ldap
|
|
1163
|
+
const password = typeof ld.bindPassword === 'string' ? ld.bindPassword.trim() : ''
|
|
1164
|
+
const merged = sanitizeLdap({
|
|
1165
|
+
...(config.ldap ?? {}),
|
|
1166
|
+
...ld,
|
|
1167
|
+
// 空串 / 不传 = 沿用已保存的密码;clearBindPassword 才真清掉。
|
|
1168
|
+
bindPassword: password !== '' ? password : (config.ldap?.bindPassword ?? ''),
|
|
1169
|
+
})
|
|
1170
|
+
if (ld.clearBindPassword === true) merged.bindPassword = ''
|
|
1171
|
+
config.ldap = merged
|
|
1172
|
+
}
|
|
1173
|
+
if (patch.users !== undefined) config.users = sanitizeUsers(patch.users)
|
|
1174
|
+
if (patch.localAuth !== undefined && typeof patch.localAuth === 'object') {
|
|
1175
|
+
const current = sanitizeLocalAuth(config.localAuth)
|
|
1176
|
+
const incoming = patch.localAuth
|
|
1177
|
+
const next = {
|
|
1178
|
+
...current,
|
|
1179
|
+
enabled: incoming.enabled === undefined ? current.enabled : incoming.enabled === true,
|
|
1180
|
+
username: incoming.username === undefined ? current.username : incoming.username,
|
|
1181
|
+
}
|
|
1182
|
+
config.localAuth = sanitizeLocalAuth(next)
|
|
1183
|
+
}
|
|
1184
|
+
if (patch.roles !== undefined && typeof patch.roles === 'object') {
|
|
1185
|
+
for (const role of ROLE_IDS) {
|
|
1186
|
+
const incoming = patch.roles[role]
|
|
1187
|
+
if (incoming === undefined || incoming === null || typeof incoming !== 'object') continue
|
|
1188
|
+
const target = config.roles[role]
|
|
1189
|
+
if (typeof incoming.label === 'string' && incoming.label.trim().length > 0) target.label = incoming.label.trim().slice(0, 40)
|
|
1190
|
+
if (typeof incoming.model === 'string') target.model = incoming.model.trim().slice(0, 120)
|
|
1191
|
+
if (typeof incoming.prompt === 'string') target.prompt = incoming.prompt.slice(0, 4000)
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
if (patch.market !== undefined && typeof patch.market === 'object') {
|
|
1195
|
+
const m = patch.market
|
|
1196
|
+
if (typeof m.title === 'string' && m.title.trim().length > 0) {
|
|
1197
|
+
config.market.title = m.title.trim().slice(0, 60)
|
|
1198
|
+
}
|
|
1199
|
+
if (Array.isArray(m.items)) {
|
|
1200
|
+
config.market.items = m.items
|
|
1201
|
+
.filter(item => item !== null && typeof item === 'object')
|
|
1202
|
+
.map(item => ({
|
|
1203
|
+
id: String(item.id ?? randomBytes(4).toString('hex')).slice(0, 60),
|
|
1204
|
+
name: String(item.name ?? '').trim().slice(0, 80),
|
|
1205
|
+
spec: String(item.spec ?? '').trim().slice(0, 300),
|
|
1206
|
+
description: String(item.description ?? '').slice(0, 400),
|
|
1207
|
+
}))
|
|
1208
|
+
.filter(item => item.name.length > 0 && item.spec.length > 0)
|
|
1209
|
+
.slice(0, 200)
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
if (patch.agents !== undefined && typeof patch.agents === 'object') {
|
|
1213
|
+
const a = patch.agents
|
|
1214
|
+
if (typeof a.enabled === 'boolean') config.agents.enabled = a.enabled
|
|
1215
|
+
if (typeof a.maxConcurrent === 'number' && Number.isFinite(a.maxConcurrent)) {
|
|
1216
|
+
config.agents.maxConcurrent = Math.max(1, Math.min(8, Math.trunc(a.maxConcurrent)))
|
|
1217
|
+
}
|
|
1218
|
+
if (typeof a.workspaceDir === 'string') config.agents.workspaceDir = a.workspaceDir.trim().slice(0, 400)
|
|
1219
|
+
if (typeof a.agentPreset === 'string') config.agents.agentPreset = a.agentPreset.trim().slice(0, 80) || 'standard'
|
|
1220
|
+
if (SANDBOX_MODES.includes(String(a.sandbox ?? ''))) config.agents.sandbox = String(a.sandbox)
|
|
1221
|
+
if (ISOLATION_MODES.includes(String(a.isolation ?? ''))) config.agents.isolation = String(a.isolation)
|
|
1222
|
+
if (typeof a.worktreeRoot === 'string') config.agents.worktreeRoot = a.worktreeRoot.trim().slice(0, 400)
|
|
1223
|
+
if (['on_done', 'manual'].includes(String(a.cleanupWorktree ?? ''))) config.agents.cleanupWorktree = String(a.cleanupWorktree)
|
|
1224
|
+
if (Array.isArray(a.autoStatuses)) {
|
|
1225
|
+
config.agents.autoStatuses = a.autoStatuses.filter(id => STATUS_SET.has(id)).slice(0, 8)
|
|
1226
|
+
}
|
|
1227
|
+
if (a.retry !== undefined && typeof a.retry === 'object') {
|
|
1228
|
+
const r = a.retry
|
|
1229
|
+
if (typeof r.max === 'number' && Number.isFinite(r.max)) config.agents.retry.max = Math.max(0, Math.min(6, Math.trunc(r.max)))
|
|
1230
|
+
if (typeof r.baseMs === 'number' && Number.isFinite(r.baseMs)) config.agents.retry.baseMs = Math.max(1000, Math.min(600000, Math.trunc(r.baseMs)))
|
|
1231
|
+
if (typeof r.factor === 'number' && Number.isFinite(r.factor)) config.agents.retry.factor = Math.max(1, Math.min(6, r.factor))
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
if (patch.releaseRules !== undefined && typeof patch.releaseRules === 'object') {
|
|
1235
|
+
const r = patch.releaseRules
|
|
1236
|
+
if (config.releaseRules === undefined || config.releaseRules === null) {
|
|
1237
|
+
config.releaseRules = { requireStaging: false, requireRequirementSignoff: false }
|
|
1238
|
+
}
|
|
1239
|
+
if (typeof r.requireStaging === 'boolean') config.releaseRules.requireStaging = r.requireStaging
|
|
1240
|
+
if (typeof r.requireRequirementSignoff === 'boolean') config.releaseRules.requireRequirementSignoff = r.requireRequirementSignoff
|
|
1241
|
+
}
|
|
1242
|
+
if (patch.testing !== undefined && typeof patch.testing === 'object') {
|
|
1243
|
+
config.testing = sanitizeTesting({ ...config.testing, ...patch.testing })
|
|
1244
|
+
}
|
|
1245
|
+
if (patch.sla !== undefined && typeof patch.sla === 'object') {
|
|
1246
|
+
config.sla = sanitizeSla({ ...config.sla, ...patch.sla })
|
|
1247
|
+
}
|
|
1248
|
+
if (patch.environments !== undefined) {
|
|
1249
|
+
const next = sanitizeDeployEnvs(patch.environments)
|
|
1250
|
+
// 空列表没有意义(部署记录要挂在环境上),至少留默认三档。
|
|
1251
|
+
config.environments = next.length > 0 ? next : defaultDeployEnvs()
|
|
1252
|
+
}
|
|
1253
|
+
})
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** Create a task from a normalized input object. */
|
|
1257
|
+
async function createTask(store, input) {
|
|
1258
|
+
const title = text(input.title, 200)
|
|
1259
|
+
if (title === undefined) throw new Error('title is required')
|
|
1260
|
+
const patch = normalizePatch(input)
|
|
1261
|
+
const now = Date.now()
|
|
1262
|
+
// 缺陷默认直接进「待开发」,需求默认从「待澄清」开始。
|
|
1263
|
+
const initialStatus = patch.status ?? (patch.type === 'bug' ? 'dev_ready' : 'clarify')
|
|
1264
|
+
const initialRole = patch.role ?? (STATUSES.find(item => item.id === initialStatus)?.role ?? 'product')
|
|
1265
|
+
const type = patch.type ?? 'requirement'
|
|
1266
|
+
// 建卡时按模板铺一张验收清单:这一行活到底要交什么,一开始就写清楚。
|
|
1267
|
+
const template = store.snapshot().config.templates[type] ?? []
|
|
1268
|
+
const seeded = patch.checklist ?? template.map((text, index) => ({
|
|
1269
|
+
id: `c${index + 1}`,
|
|
1270
|
+
text,
|
|
1271
|
+
done: false,
|
|
1272
|
+
by: '',
|
|
1273
|
+
at: 0,
|
|
1274
|
+
note: '',
|
|
1275
|
+
}))
|
|
1276
|
+
const requirementId = patch.requirementId ?? ''
|
|
1277
|
+
// 卡片的业务领域默认跟随所属需求:泳道按角色位派单时靠它挑对应领域专家。
|
|
1278
|
+
const inheritedDomain = (() => {
|
|
1279
|
+
const own = String(patch.domain ?? '').trim()
|
|
1280
|
+
if (own !== '') return own
|
|
1281
|
+
const requirement = store.snapshot().requirements.find(entry => entry.id === requirementId)
|
|
1282
|
+
return String(requirement?.domain ?? '').trim()
|
|
1283
|
+
})()
|
|
1284
|
+
// 记住这张卡是照着哪一版需求基线做的,需求改版后能看出谁需要返工。
|
|
1285
|
+
const baseline = requirementId === ''
|
|
1286
|
+
? null
|
|
1287
|
+
: (store.snapshot().requirements.find(item => item.id === requirementId)?.baseline ?? null)
|
|
1288
|
+
const task = {
|
|
1289
|
+
id: newTaskId(),
|
|
1290
|
+
title,
|
|
1291
|
+
type,
|
|
1292
|
+
code: requirementId === '' ? '' : nextTaskCode(store.snapshot().tasks, requirementId, CODE_PREFIX[type] ?? 'TASK'),
|
|
1293
|
+
requirementId,
|
|
1294
|
+
pointId: patch.pointId ?? '',
|
|
1295
|
+
releaseId: patch.releaseId ?? '',
|
|
1296
|
+
reqBaseline: baseline === null ? null : { version: baseline.version, at: baseline.at, title: baseline.title },
|
|
1297
|
+
worktree: null,
|
|
1298
|
+
signoff: null,
|
|
1299
|
+
status: initialStatus,
|
|
1300
|
+
role: initialRole,
|
|
1301
|
+
priority: patch.priority ?? (patch.type === 'bug' && (patch.severity ?? '') === 'p0' ? 'urgent' : 'normal'),
|
|
1302
|
+
severity: (patch.type ?? 'requirement') === 'bug' ? (patch.severity ?? 'p2') : '',
|
|
1303
|
+
requirement: patch.requirement ?? '',
|
|
1304
|
+
expert: patch.expert ?? '',
|
|
1305
|
+
domain: inheritedDomain,
|
|
1306
|
+
project: patch.project ?? '',
|
|
1307
|
+
iteration: patch.iteration ?? '',
|
|
1308
|
+
service: patch.service ?? '',
|
|
1309
|
+
parent: patch.parent ?? '',
|
|
1310
|
+
dueAt: patch.dueAt ?? 0,
|
|
1311
|
+
gitlabIssue: patch.gitlabIssue ?? '',
|
|
1312
|
+
gitlabMR: patch.gitlabMR ?? { iid: 0, url: '', title: '', state: '' },
|
|
1313
|
+
feishuThread: patch.feishuThread ?? '',
|
|
1314
|
+
owner: patch.owner ?? '',
|
|
1315
|
+
blockedReason: patch.blockedReason ?? '',
|
|
1316
|
+
/** AI 派单连续失败次数与下次允许重试的时间(退避窗口)。 */
|
|
1317
|
+
agentFailures: 0,
|
|
1318
|
+
retryAt: 0,
|
|
1319
|
+
artifacts: patch.artifacts ?? [],
|
|
1320
|
+
checklist: seeded,
|
|
1321
|
+
cases: patch.cases ?? [],
|
|
1322
|
+
testRuns: [],
|
|
1323
|
+
coding: sanitizeCoding(patch.coding ?? null),
|
|
1324
|
+
bugSource: patch.bugSource ?? null,
|
|
1325
|
+
/** 缺陷去重:这张卡看起来和哪张缺陷是同一件事(人再判断要不要合并)。 */
|
|
1326
|
+
duplicateOf: '',
|
|
1327
|
+
tags: patch.tags ?? [],
|
|
1328
|
+
createdAt: now,
|
|
1329
|
+
updatedAt: now,
|
|
1330
|
+
comments: [],
|
|
1331
|
+
history: [{ at: now, by: 'agent', status: task_status_of(patch) }],
|
|
1332
|
+
}
|
|
1333
|
+
// 建缺陷卡时顺手查一遍「是不是已经有人报过同一件事」:只做提示,不替人合并。
|
|
1334
|
+
if (type === 'bug') {
|
|
1335
|
+
const twin = findDuplicateBug(store.snapshot(), title, task.bugSource?.caseId ?? '')
|
|
1336
|
+
if (twin !== undefined) {
|
|
1337
|
+
task.duplicateOf = twin.id
|
|
1338
|
+
task.tags = [...new Set([...task.tags, '疑似重复'])]
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
// 服务必须属于所选项目:传了不对的服务就当没选(不要让卡挂着一个不存在的路径)。
|
|
1342
|
+
if (task.service !== '') {
|
|
1343
|
+
const owner = (store.snapshot().config.projects ?? []).find(entry => entry.id === task.project)
|
|
1344
|
+
if (owner === undefined || !(owner.services ?? []).some(entry => entry.id === task.service)) task.service = ''
|
|
1345
|
+
}
|
|
1346
|
+
await store.mutate(state => {
|
|
1347
|
+
// 编号必须在临界区里再校验一次:并发开卡(一次跑多条用例)时,快照里算出的编号会撞号。
|
|
1348
|
+
if (task.code !== '' && state.tasks.some(entry => entry.requirementId === task.requirementId && entry.code === task.code)) {
|
|
1349
|
+
task.code = nextTaskCode(state.tasks, task.requirementId, CODE_PREFIX[task.type] ?? 'TASK')
|
|
1350
|
+
}
|
|
1351
|
+
state.tasks.unshift(task)
|
|
1352
|
+
})
|
|
1353
|
+
if (task.duplicateOf !== '') {
|
|
1354
|
+
const twin = store.snapshot().tasks.find(entry => entry.id === task.duplicateOf)
|
|
1355
|
+
if (twin !== undefined) {
|
|
1356
|
+
await commentTask(store, twin.id, `又收到一张疑似重复的缺陷:${task.code === '' ? task.id : task.code}「${task.title}」。如果是同一件事,在缺陷上标一下重复并关掉一张。`, '系统', 'agent')
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return task
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** 标题归一后比较:把 emoji、标点、空格都去掉,再看有没有同名在办缺陷。 */
|
|
1363
|
+
function bugTitleKey(title) {
|
|
1364
|
+
return String(title ?? '')
|
|
1365
|
+
.replace(/^\[用例失败\]\s*/, '')
|
|
1366
|
+
.replace(/[\s,,。.、::;;!!??"'“”‘’()()[\]【】<>《》-]/g, '')
|
|
1367
|
+
.toLowerCase()
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
function findDuplicateBug(state, title, caseId = '') {
|
|
1371
|
+
const key = bugTitleKey(title)
|
|
1372
|
+
if (key === '') return undefined
|
|
1373
|
+
return state.tasks.find(entry => entry.type === 'bug'
|
|
1374
|
+
&& entry.status !== 'done'
|
|
1375
|
+
&& bugTitleKey(entry.title) === key
|
|
1376
|
+
&& (caseId === '' || entry.bugSource?.caseId !== caseId))
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/** Status chosen at creation time (kept as a helper for readable history). */
|
|
1380
|
+
function task_status_of(patch) {
|
|
1381
|
+
return patch.status ?? 'clarify'
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
/**
|
|
1385
|
+
* 环境/连接类失败不该污染缺陷池:跑不起来 ≠ 实现有错。
|
|
1386
|
+
* 只认「连不上/超时/DNS/网关」这类信号;404 不在此列——路径写错往往就是真缺陷。
|
|
1387
|
+
*/
|
|
1388
|
+
function isEnvFailure(text) {
|
|
1389
|
+
const value = String(text ?? '')
|
|
1390
|
+
if (value === '') return false
|
|
1391
|
+
return /ECONNREFUSED|ECONNRESET|ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|ETIMEDOUT|ESOCKETTIMEDOUT|socket hang up|getaddrinfo|connect ECONN|connection refused|Network Error|network error|timed out|timeout of \d+|502 Bad Gateway|504 Gateway|Bad Gateway|ENV_UNAVAILABLE|连不上|连接被拒绝|不可达|服务未就绪|环境不可用|环境没起来|baseUrl 没配/i.test(value)
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* 用例失败 → 自动开一张缺陷卡,挂同一个需求、同一个项目/迭代,父任务是这张测试卡。
|
|
1396
|
+
* 同一张卡的同一用例只会开一次;重新标失败只是更新证据。
|
|
1397
|
+
*/
|
|
1398
|
+
async function autoBugFromCase(store, task, item, by) {
|
|
1399
|
+
const snapshot = store.snapshot()
|
|
1400
|
+
const existing = snapshot.tasks.find(entry => entry.bugSource != null
|
|
1401
|
+
&& entry.bugSource.taskId === task.id
|
|
1402
|
+
&& entry.bugSource.caseId === item.id)
|
|
1403
|
+
const requirement = snapshot.requirements.find(entry => entry.id === task.requirementId)
|
|
1404
|
+
if (existing !== undefined) {
|
|
1405
|
+
await commentTask(store, existing.id, [
|
|
1406
|
+
`用例再次失败(${new Date(item.at || Date.now()).toLocaleString('zh-CN')})。`,
|
|
1407
|
+
item.note === '' ? '' : `实际:${item.note}`,
|
|
1408
|
+
].filter(Boolean).join('\n'), by, 'agent')
|
|
1409
|
+
return { bug: existing, created: false }
|
|
1410
|
+
}
|
|
1411
|
+
const evidence = [item.note, item.actual, item.error].map(value => String(value ?? '')).filter(value => value !== '').join(' · ')
|
|
1412
|
+
if (isEnvFailure(evidence)) {
|
|
1413
|
+
await commentTask(store, task.id, [
|
|
1414
|
+
`用例「${item.title}」判为失败,但失败信息像是环境/连接问题,按规则**不开缺陷**(先修环境再复跑)。`,
|
|
1415
|
+
evidence === '' ? '' : `证据:${evidence}`,
|
|
1416
|
+
].filter(Boolean).join('\n'), by, 'agent')
|
|
1417
|
+
return { bug: null, created: false, skipped: 'env' }
|
|
1418
|
+
}
|
|
1419
|
+
const bug = await createTask(store, {
|
|
1420
|
+
title: `[用例失败] ${item.title}`,
|
|
1421
|
+
type: 'bug',
|
|
1422
|
+
severity: 'p2',
|
|
1423
|
+
status: 'dev_ready',
|
|
1424
|
+
priority: 'normal',
|
|
1425
|
+
requirement: [
|
|
1426
|
+
`来源:${task.code === '' ? task.id : task.code}「${task.title}」的测试用例`,
|
|
1427
|
+
`用例:${item.title}`,
|
|
1428
|
+
item.expected === '' ? '' : `期望:${item.expected}`,
|
|
1429
|
+
item.note === '' ? '' : `实际:${item.note}`,
|
|
1430
|
+
requirement === undefined ? '' : `所属需求:${requirement.code} ${requirement.title}`,
|
|
1431
|
+
].filter(line => line !== '').join('\n'),
|
|
1432
|
+
requirementId: task.requirementId,
|
|
1433
|
+
project: task.project,
|
|
1434
|
+
iteration: task.iteration,
|
|
1435
|
+
parent: task.id,
|
|
1436
|
+
domain: task.domain,
|
|
1437
|
+
bugSource: {
|
|
1438
|
+
taskId: task.id,
|
|
1439
|
+
code: task.code,
|
|
1440
|
+
caseId: item.id,
|
|
1441
|
+
caseTitle: item.title,
|
|
1442
|
+
expected: item.expected,
|
|
1443
|
+
note: item.note,
|
|
1444
|
+
by,
|
|
1445
|
+
at: item.at || Date.now(),
|
|
1446
|
+
},
|
|
1447
|
+
})
|
|
1448
|
+
await commentTask(store, task.id, `用例「${item.title}」判为失败,已自动登记缺陷 ${bug.code === '' ? bug.id : bug.code}(父任务挂了本卡)。`, '系统', 'agent')
|
|
1449
|
+
await commentTask(store, bug.id, [
|
|
1450
|
+
'自动化用例失败后自动创建。',
|
|
1451
|
+
`复现:执行 ${task.code === '' ? task.id : task.code} 的用例「${item.title}」。`,
|
|
1452
|
+
item.expected === '' ? '' : `期望结果:${item.expected}`,
|
|
1453
|
+
item.note === '' ? '' : `实际结果:${item.note}`,
|
|
1454
|
+
].filter(line => line !== '').join('\n'), '系统', 'agent')
|
|
1455
|
+
// 自动开出来的缺陷要有人看见:群里推一条,别让它躺在待开发列里没人领。
|
|
1456
|
+
feishuBroadcast(
|
|
1457
|
+
store.snapshot().config,
|
|
1458
|
+
`【新缺陷】${bug.code || bug.id} ${bug.title}\n来源用例:${item.title}\n从「${task.code === '' ? task.id : task.code} ${task.title}」自动开出,去「待开发」看一眼。`,
|
|
1459
|
+
'bug',
|
|
1460
|
+
).catch(() => undefined)
|
|
1461
|
+
return { bug, created: true }
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/** 缺陷卡修好进入「待测试」时,把它来源的那条用例重置成待执行,等着回归。 */
|
|
1465
|
+
async function resetOriginCase(store, bug) {
|
|
1466
|
+
const source = bug.bugSource
|
|
1467
|
+
if (source == null) return null
|
|
1468
|
+
let changed = false
|
|
1469
|
+
await store.mutate(state => {
|
|
1470
|
+
const task = state.tasks.find(entry => entry.id === source.taskId)
|
|
1471
|
+
if (task === undefined) return
|
|
1472
|
+
task.cases = (Array.isArray(task.cases) ? task.cases : []).map(item => {
|
|
1473
|
+
if (item.id !== source.caseId) return item
|
|
1474
|
+
changed = true
|
|
1475
|
+
return { ...item, result: 'pending', note: `回归中(${bug.code === '' ? bug.id : bug.code} 已修复待验证)`, by: '系统', at: Date.now() }
|
|
1476
|
+
})
|
|
1477
|
+
if (changed) task.updatedAt = Date.now()
|
|
1478
|
+
})
|
|
1479
|
+
if (changed) {
|
|
1480
|
+
await commentTask(store, source.taskId, `${bug.code === '' ? bug.id : bug.code} 已进入待测试,原用例「${source.caseTitle}」已重置为待执行,请复跑确认。`, '系统', 'agent')
|
|
1481
|
+
await commentTask(store, bug.id, `已把来源用例「${source.caseTitle}」重置为待执行,测试复跑通过后本卡即可关闭。`, '系统', 'agent')
|
|
1482
|
+
}
|
|
1483
|
+
return changed ? source : null
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// ------------------------------------------------- 沙箱与 git worktree 隔离(P5)
|
|
1487
|
+
|
|
1488
|
+
/** worktree 根目录:没配就用 workspaceDir 的同级兄弟目录。 */
|
|
1489
|
+
|
|
1490
|
+
/** 卡对应的业务项目(worktree 的仓库与基线分支都来自它)。 */
|
|
1491
|
+
function workProjectOf(config, task) {
|
|
1492
|
+
const id = String(task?.project ?? '').trim()
|
|
1493
|
+
if (id.length === 0) return undefined
|
|
1494
|
+
return (config?.projects ?? []).find(project => project.id === id)
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/** 卡上的服务:一个业务项目里可以有多个服务,每个服务一个 git 路径。 */
|
|
1498
|
+
function serviceOf(config, task) {
|
|
1499
|
+
const id = String(task?.service ?? '').trim()
|
|
1500
|
+
if (id.length === 0) return undefined
|
|
1501
|
+
const project = workProjectOf(config, task)
|
|
1502
|
+
if (project === undefined) return undefined
|
|
1503
|
+
return (project.services ?? []).find(service => service.id === id)
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/**
|
|
1507
|
+
* 服务依赖:这个服务依赖哪些服务、它们各自还有多少张没完成的卡。
|
|
1508
|
+
* 只在本项目内生效;没配依赖、或卡没挂服务时返回 undefined。
|
|
1509
|
+
*/
|
|
1510
|
+
function serviceDependencies(state, task) {
|
|
1511
|
+
const config = state?.config ?? {}
|
|
1512
|
+
const service = serviceOf(config, task)
|
|
1513
|
+
const project = workProjectOf(config, task)
|
|
1514
|
+
if (service === undefined || project === undefined) return undefined
|
|
1515
|
+
const wanted = Array.isArray(service.dependsOn) ? service.dependsOn : []
|
|
1516
|
+
if (wanted.length === 0) return undefined
|
|
1517
|
+
const rows = wanted
|
|
1518
|
+
.map(id => (project.services ?? []).find(one => one.id === id))
|
|
1519
|
+
.filter(dep => dep !== undefined)
|
|
1520
|
+
.map(dep => {
|
|
1521
|
+
const open = (state.tasks ?? []).filter(one => one.service === dep.id && one.project === project.id && one.status !== 'done')
|
|
1522
|
+
return { id: dep.id, name: dep.name, open: open.length, titles: open.slice(0, 2).map(one => one.code || one.title) }
|
|
1523
|
+
})
|
|
1524
|
+
.filter(row => row !== undefined)
|
|
1525
|
+
if (rows.length === 0) return undefined
|
|
1526
|
+
return { project: project.id, service: service.id, rows, pending: rows.reduce((sum, row) => sum + row.open, 0) }
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
/**
|
|
1530
|
+
* 同服务并行:同一个服务上还有别的卡在改代码(开发中/待评审/待测试),
|
|
1531
|
+
* 合并时会互相踩。返回 undefined 表示没有并行的同类卡。
|
|
1532
|
+
*/
|
|
1533
|
+
function serviceConflicts(state, task) {
|
|
1534
|
+
const config = state?.config ?? {}
|
|
1535
|
+
const service = serviceOf(config, task)
|
|
1536
|
+
if (service === undefined) return undefined
|
|
1537
|
+
const others = (state.tasks ?? []).filter(other => other.id !== task.id
|
|
1538
|
+
&& other.service === service.id
|
|
1539
|
+
&& (other.project ?? '') === (task.project ?? '')
|
|
1540
|
+
&& ['dev', 'review', 'test'].includes(other.status))
|
|
1541
|
+
if (others.length === 0) return undefined
|
|
1542
|
+
return {
|
|
1543
|
+
service: service.id,
|
|
1544
|
+
name: service.name,
|
|
1545
|
+
count: others.length,
|
|
1546
|
+
tasks: others.slice(0, 6).map(other => ({ id: other.id, code: other.code || other.id, title: other.title, status: other.status })),
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
/** 一卡一分支名:优先用卡号(DEV-001),没编号就用 id。 */
|
|
1551
|
+
function worktreeBranch(task) {
|
|
1552
|
+
const label = String(task?.code ?? '').trim() || String(task?.id ?? '').trim()
|
|
1553
|
+
return `task/${label}`.slice(0, 120)
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/**
|
|
1557
|
+
* 给一张卡准备独立的 git worktree。
|
|
1558
|
+
* 返回 { path, branch, repo, base, at };没配仓库或建不出来时返回 undefined
|
|
1559
|
+
* (并给出原因,调用方负责记日志)。
|
|
1560
|
+
*/
|
|
1561
|
+
async function ensureWorktree(store, task, config) {
|
|
1562
|
+
const enabled = String(config?.agents?.isolation ?? 'none') === 'worktree'
|
|
1563
|
+
if (!enabled) return { skipped: 'isolation=none' }
|
|
1564
|
+
const project = workProjectOf(config, task)
|
|
1565
|
+
if (project === undefined) return { skipped: '卡没挂业务项目' }
|
|
1566
|
+
const service = serviceOf(config, task)
|
|
1567
|
+
const repo = String(service?.repoPath ?? '').trim() || String(project.repoPath ?? '').trim()
|
|
1568
|
+
if (repo.length === 0) {
|
|
1569
|
+
return { skipped: service === undefined ? `项目「${project.name}」没配本机代码目录` : `服务「${service.name}」没配本机代码目录` }
|
|
1570
|
+
}
|
|
1571
|
+
const probe = await runCommand('git', ['-C', repo, 'rev-parse', '--is-inside-work-tree'], 20000)
|
|
1572
|
+
if (!probe.ok) return { skipped: `${repo} 不是 git 仓库(${(probe.stderr || '').trim().split('\n')[0] ?? ''})`.slice(0, 300) }
|
|
1573
|
+
const existing = task?.worktree
|
|
1574
|
+
if (existing !== undefined && existing !== null && String(existing.path ?? '').length > 0) {
|
|
1575
|
+
const alive = await runCommand('git', ['-C', repo, 'worktree', 'list', '--porcelain'], 20000)
|
|
1576
|
+
if (alive.ok && String(alive.stdout).includes(`worktree ${existing.path}`)) return existing
|
|
1577
|
+
}
|
|
1578
|
+
const branch = worktreeBranch(task)
|
|
1579
|
+
const path = join(worktreeRoot(config), String(task?.code ?? '').trim() || String(task?.id ?? 'card'))
|
|
1580
|
+
await mkdir(dirname(path), { recursive: true })
|
|
1581
|
+
const base = String(service?.baseBranch ?? project.baseBranch ?? '').trim() || 'main'
|
|
1582
|
+
const branches = await runCommand('git', ['-C', repo, 'branch', '--list', branch], 20000)
|
|
1583
|
+
const exists = branches.ok && String(branches.stdout).trim().length > 0
|
|
1584
|
+
const added = exists
|
|
1585
|
+
? await runCommand('git', ['-C', repo, 'worktree', 'add', path, branch], 60000)
|
|
1586
|
+
: await runCommand('git', ['-C', repo, 'worktree', 'add', '-b', branch, path, base], 60000)
|
|
1587
|
+
if (!added.ok) {
|
|
1588
|
+
const message = (added.stderr || added.stdout || '').trim().split('\n')[0] ?? ''
|
|
1589
|
+
markHealth('worktree', 'fail', message || 'git worktree add 失败')
|
|
1590
|
+
return { skipped: `git worktree 失败:${message}`.slice(0, 300) }
|
|
1591
|
+
}
|
|
1592
|
+
markHealth('worktree', 'ok')
|
|
1593
|
+
const info = {
|
|
1594
|
+
path, branch, repo, base,
|
|
1595
|
+
service: String(service?.id ?? ''),
|
|
1596
|
+
serviceName: String(service?.name ?? project.name ?? ''),
|
|
1597
|
+
at: Date.now(),
|
|
1598
|
+
}
|
|
1599
|
+
try {
|
|
1600
|
+
await store.mutate(draft => {
|
|
1601
|
+
const target = draft.tasks.find(entry => entry.id === task.id)
|
|
1602
|
+
if (target !== undefined) target.worktree = info
|
|
1603
|
+
return draft
|
|
1604
|
+
})
|
|
1605
|
+
} catch (error) {
|
|
1606
|
+
// 落库失败不影响这一轮工作,worktree 已经建好了
|
|
1607
|
+
}
|
|
1608
|
+
return info
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/** 回收 worktree(卡已完成且策略是 on_done 时调用)。 */
|
|
1612
|
+
async function removeWorktree(store, task, config) {
|
|
1613
|
+
const info = task?.worktree
|
|
1614
|
+
if (info === undefined || info === null) return { ok: false, skipped: '没有 worktree' }
|
|
1615
|
+
const repo = String(info.repo ?? '').trim()
|
|
1616
|
+
if (repo.length === 0) return { ok: false, skipped: 'worktree 记录里没有仓库' }
|
|
1617
|
+
const removed = await runCommand('git', ['-C', repo, 'worktree', 'remove', '--force', String(info.path)], 60000)
|
|
1618
|
+
await runCommand('git', ['-C', repo, 'branch', '-D', String(info.branch)], 20000)
|
|
1619
|
+
try {
|
|
1620
|
+
await store.mutate(draft => {
|
|
1621
|
+
const target = draft.tasks.find(entry => entry.id === task.id)
|
|
1622
|
+
if (target !== undefined) target.worktree = null
|
|
1623
|
+
return draft
|
|
1624
|
+
})
|
|
1625
|
+
} catch (error) {
|
|
1626
|
+
// ignore
|
|
1627
|
+
}
|
|
1628
|
+
return { ok: removed.ok, error: removed.ok ? '' : (removed.stderr || '').trim().split('\n')[0] }
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// ------------------------------------------------------------------- tools
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
/** A one-line task summary for tool output. */
|
|
1635
|
+
function taskLine(task) {
|
|
1636
|
+
const status = STATUSES.find(s => s.id === task.status)
|
|
1637
|
+
const kind = task.type === 'bug' ? `[BUG ${String(task.severity || 'p2').toUpperCase()}] ` : ''
|
|
1638
|
+
const flags = []
|
|
1639
|
+
if (task.blocked !== null && task.blocked !== undefined) flags.push('⛔阻塞')
|
|
1640
|
+
if ((Number(task.reopenCount) || 0) > 0) flags.push(`重开${task.reopenCount}次`)
|
|
1641
|
+
const checkTotal = (task.checklist ?? []).length
|
|
1642
|
+
if (checkTotal > 0) flags.push(`清单 ${(task.checklist ?? []).filter(item => item.done).length}/${checkTotal}`)
|
|
1643
|
+
if ((task.artifacts ?? []).length > 0) flags.push(`产物 ${task.artifacts.length}`)
|
|
1644
|
+
const cases = task.cases ?? []
|
|
1645
|
+
if (cases.length > 0) {
|
|
1646
|
+
const bad = cases.filter(item => item.result === 'fail').length
|
|
1647
|
+
flags.push(`用例 ${cases.filter(item => item.result === 'pass').length}/${cases.length}${bad > 0 ? `(${bad} 不通过)` : ''}`)
|
|
1648
|
+
}
|
|
1649
|
+
if ((task.gitlabMR?.iid ?? 0) > 0) flags.push(`MR !${task.gitlabMR.iid}${task.gitlabMR.state === 'merged' ? '已合并' : ''}`)
|
|
1650
|
+
const coding = task.coding ?? null
|
|
1651
|
+
if (coding !== null && (coding.files.length > 0 || coding.commits.length > 0)) {
|
|
1652
|
+
const ut = coding.unitTests
|
|
1653
|
+
flags.push(`代码 ${coding.files.length} 文件/${coding.commits.length} 提交${ut.total > 0 ? `/单测 ${ut.passed}/${ut.total}` : ''}${coding.review.status === 'approved' ? '/评审通过' : coding.review.status === 'changes_requested' ? '/评审待改' : ''}`)
|
|
1654
|
+
}
|
|
1655
|
+
if (task.dueAt > 0) flags.push(`截止 ${new Date(task.dueAt).toISOString().slice(0, 10)}`)
|
|
1656
|
+
if (task.bugSource != null) flags.push(`来源用例 ${task.bugSource.code || task.bugSource.taskId}`)
|
|
1657
|
+
if (String(task.duplicateOf ?? '') !== '') flags.push(`重复于 ${task.duplicateOf}`)
|
|
1658
|
+
const runs = Array.isArray(task.testRuns) ? task.testRuns : []
|
|
1659
|
+
const lastRun = runs.length > 0 ? runs[runs.length - 1] : null
|
|
1660
|
+
if (lastRun !== null) flags.push(`最近执行 ${lastRun.passed}/${lastRun.total}${lastRun.failed > 0 ? `(${lastRun.failed} 失败)` : ''}`)
|
|
1661
|
+
const suffix = flags.length > 0 ? ` · ${flags.join(' · ')}` : ''
|
|
1662
|
+
const project = task.project !== undefined && task.project.length > 0 ? ` {${task.project}}` : ''
|
|
1663
|
+
const code = task.code !== undefined && task.code.length > 0 ? `${task.code} ` : ''
|
|
1664
|
+
return `- ${code}${task.id}${project} [${status?.label ?? task.status}] ${kind}${task.title}${suffix}`
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// --------------------------------------------------------------------- api
|
|
1668
|
+
|
|
1669
|
+
// ------------------------------------------------------------------ market
|
|
1670
|
+
|
|
1671
|
+
/** The profile this plugin runs in — the market installs into its own profile. */
|
|
1672
|
+
function profileFromArgv() {
|
|
1673
|
+
if (process.env.DSH_PROFILE) return process.env.DSH_PROFILE
|
|
1674
|
+
const argv = process.argv
|
|
1675
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
1676
|
+
const a = argv[i]
|
|
1677
|
+
if (a === '--profile' && argv[i + 1]) return argv[i + 1]
|
|
1678
|
+
if (a.startsWith('--profile=')) return a.slice('--profile='.length)
|
|
1679
|
+
}
|
|
1680
|
+
return undefined
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
/**
|
|
1684
|
+
* argv 里没有 --profile(宿主可能把它吃掉了)时,去 profiles 目录里认领:
|
|
1685
|
+
* 优先选装了本插件(dsh-studio)的那个 profile,再退到最近改过的那个。
|
|
1686
|
+
*/
|
|
1687
|
+
async function detectProfile() {
|
|
1688
|
+
const fromArgv = profileFromArgv()
|
|
1689
|
+
if (fromArgv !== undefined && fromArgv !== '') return fromArgv
|
|
1690
|
+
const root = dshHomePath('profiles')
|
|
1691
|
+
let names = []
|
|
1692
|
+
try {
|
|
1693
|
+
names = (await readdir(root, { withFileTypes: true })).filter(entry => entry.isDirectory()).map(entry => entry.name)
|
|
1694
|
+
} catch {
|
|
1695
|
+
return 'web'
|
|
1696
|
+
}
|
|
1697
|
+
for (const name of names) {
|
|
1698
|
+
try {
|
|
1699
|
+
const parsed = JSON.parse(await readFile(join(root, name, 'package.json'), 'utf8'))
|
|
1700
|
+
const bundles = parsed?.dsh?.profile?.bundles ?? []
|
|
1701
|
+
const deps = parsed.dependencies ?? {}
|
|
1702
|
+
if (deps['dsh-studio'] !== undefined || bundles.includes('dsh-studio')) return name
|
|
1703
|
+
} catch { /* 跳过坏 profile */ }
|
|
1704
|
+
}
|
|
1705
|
+
return names.includes('web') ? 'web' : (names[0] ?? 'web')
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
let profileCache
|
|
1709
|
+
let hostProfile = ''
|
|
1710
|
+
async function currentProfile() {
|
|
1711
|
+
if (profileCache === undefined) profileCache = await detectProfile()
|
|
1712
|
+
return profileCache
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
/** Package names currently installed in the profile. */
|
|
1716
|
+
async function installedPackages() {
|
|
1717
|
+
try {
|
|
1718
|
+
const raw = await readFile(dshHomePath('profiles', await currentProfile(), 'package.json'), 'utf8')
|
|
1719
|
+
const parsed = JSON.parse(raw)
|
|
1720
|
+
return Object.keys(parsed.dependencies ?? {})
|
|
1721
|
+
} catch {
|
|
1722
|
+
return []
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/**
|
|
1727
|
+
* Install one catalogue entry into the profile by delegating to the DSH CLI,
|
|
1728
|
+
* which is the supported path (it keeps pnpm's lockfile and the profile's
|
|
1729
|
+
* bundle list consistent). Resolves with the CLI output.
|
|
1730
|
+
*/
|
|
1731
|
+
async function runPluginCli(args) {
|
|
1732
|
+
const profile = await currentProfile()
|
|
1733
|
+
return new Promise(resolvePromise => {
|
|
1734
|
+
const child = spawn('dsh', ['plugin', '--profile', profile, ...args], {
|
|
1735
|
+
cwd: homedir(),
|
|
1736
|
+
env: { ...process.env, HOME: homedir() },
|
|
1737
|
+
timeout: 180_000,
|
|
1738
|
+
})
|
|
1739
|
+
let output = ''
|
|
1740
|
+
child.stdout.on('data', chunk => { output += chunk })
|
|
1741
|
+
child.stderr.on('data', chunk => { output += chunk })
|
|
1742
|
+
child.on('error', error => resolvePromise({ ok: false, error: error.message, output }))
|
|
1743
|
+
child.on('close', code => resolvePromise({ ok: code === 0, code, output: output.slice(-2000) }))
|
|
1744
|
+
})
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* Turn a catalogue spec into the package name `dsh plugin remove` expects.
|
|
1749
|
+
* `pnpm remove` takes a name, not a spec, so `link:/path` has to be read back
|
|
1750
|
+
* from the package it points at.
|
|
1751
|
+
*/
|
|
1752
|
+
async function resolvePackageName(spec) {
|
|
1753
|
+
const trimmed = String(spec ?? '').trim()
|
|
1754
|
+
if (trimmed.length === 0) return undefined
|
|
1755
|
+
if (/^(link|file):/.test(trimmed)) {
|
|
1756
|
+
const dir = trimmed.replace(/^(link|file):/, '')
|
|
1757
|
+
try {
|
|
1758
|
+
const raw = await readFile(join(dir, 'package.json'), 'utf8')
|
|
1759
|
+
const name = JSON.parse(raw).name
|
|
1760
|
+
if (typeof name === 'string' && name.trim().length > 0) return name.trim()
|
|
1761
|
+
} catch {}
|
|
1762
|
+
return undefined
|
|
1763
|
+
}
|
|
1764
|
+
const match = trimmed.match(/^((?:@[^/@]+\/)?[^@/]+)(?:@.*)?$/)
|
|
1765
|
+
return match?.[1]
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/** 这次请求算谁:有会话就用会话身份;没锁门(没开必须登录)时算本地超管;否则算匿名。 */
|
|
1769
|
+
function resolveActor(req, config) {
|
|
1770
|
+
const session = sessionOf(req, config)
|
|
1771
|
+
if (session !== null) return String(session.user.name ?? session.user.id ?? '').slice(0, 40)
|
|
1772
|
+
const ldap = config?.ldap ?? {}
|
|
1773
|
+
const locked = ldap.enabled === true && ldap.requireLogin === true
|
|
1774
|
+
if (locked) return ''
|
|
1775
|
+
const local = sanitizeLocalAuth(config?.localAuth)
|
|
1776
|
+
return local.username === 'admin' ? '超管 admin' : local.username
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
/** 谁能操作:先看这次请求算出来的身份(会话 / 超管),再看请求头 x-studio-user,最后当「人」。 */
|
|
1780
|
+
function operatorOf(req) {
|
|
1781
|
+
const actor = typeof req?.studioActor === 'string' ? req.studioActor.trim() : ''
|
|
1782
|
+
if (actor !== '') return actor.slice(0, 40)
|
|
1783
|
+
const raw = req.headers['x-studio-user']
|
|
1784
|
+
const value = Array.isArray(raw) ? raw[0] : raw
|
|
1785
|
+
if (typeof value !== 'string' || value.trim() === '') return '人'
|
|
1786
|
+
try {
|
|
1787
|
+
const text = decodeURIComponent(value).trim().slice(0, 40)
|
|
1788
|
+
return text === '' ? '人' : text
|
|
1789
|
+
} catch {
|
|
1790
|
+
return value.trim().slice(0, 40) || '人'
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
// ---------------------------------------------------------------- 统一登录
|
|
1795
|
+
|
|
1796
|
+
/** 登录会话放内存:12 小时有效;服务重启后重新登录(会话不落盘,避免 token 泄漏)。 */
|
|
1797
|
+
const SESSIONS = new Map()
|
|
1798
|
+
const SESSION_TTL_MS = 12 * 60 * 60 * 1000
|
|
1799
|
+
/* 会话要跨重启存活,所以除了内存 Map 还写 SQLite;store 就绪后把未过期的会话读回内存。 */
|
|
1800
|
+
let SESSION_STORE = null
|
|
1801
|
+
|
|
1802
|
+
function attachSessionStore(store) {
|
|
1803
|
+
/* registerRoutes 同步执行时 #boot 还没打开数据库,此时挂上会把 "已挂载" 变成
|
|
1804
|
+
永久状态却一条会话都读不到;db 为空就直接返回,让第一次请求再试一次。 */
|
|
1805
|
+
if (store.db === null) return
|
|
1806
|
+
SESSION_STORE = store
|
|
1807
|
+
try {
|
|
1808
|
+
store.pruneSessions()
|
|
1809
|
+
for (const row of store.listSessions()) SESSIONS.set(row.token, row)
|
|
1810
|
+
} catch (error) {
|
|
1811
|
+
console.warn('[dsh-studio] restore sessions failed:', error?.message ?? error)
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
/** LDAP 过滤器里的特殊字符要转义,否则用户名能改写查询。 */
|
|
1816
|
+
function ldapEscape(value) {
|
|
1817
|
+
return String(value).replace(/[\\*()\u0000]/g, ch => `\\${ch.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/** 解析 ldapsearch 的 LDIF 输出:返回 [{ dn, attrs: { 属性名: [值…] } }]。 */
|
|
1821
|
+
function parseLdif(text) {
|
|
1822
|
+
const entries = []
|
|
1823
|
+
let current = null
|
|
1824
|
+
let lastKey = ''
|
|
1825
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
1826
|
+
if (line.trim() === '') {
|
|
1827
|
+
if (current !== null) entries.push(current)
|
|
1828
|
+
current = null
|
|
1829
|
+
lastKey = ''
|
|
1830
|
+
continue
|
|
1831
|
+
}
|
|
1832
|
+
if (line.startsWith('#')) continue
|
|
1833
|
+
if (line.startsWith(' ')) {
|
|
1834
|
+
if (current !== null && lastKey !== '') {
|
|
1835
|
+
const list = current.attrs[lastKey]
|
|
1836
|
+
if (Array.isArray(list) && list.length > 0) list[list.length - 1] += line.slice(1)
|
|
1837
|
+
}
|
|
1838
|
+
continue
|
|
1839
|
+
}
|
|
1840
|
+
const index = line.indexOf(':')
|
|
1841
|
+
if (index <= 0) continue
|
|
1842
|
+
const key = line.slice(0, index).trim()
|
|
1843
|
+
// RFC 2849:`attr:: <base64>` 表示值用了 base64(AD 的中文 displayName 就是这样)
|
|
1844
|
+
const rest = line.slice(index + 1)
|
|
1845
|
+
const encoded = rest.startsWith(':')
|
|
1846
|
+
const raw = encoded ? rest.slice(1).trim() : rest.trim()
|
|
1847
|
+
let value = raw
|
|
1848
|
+
if (encoded) {
|
|
1849
|
+
try { value = Buffer.from(raw, 'base64').toString('utf8') } catch { value = raw }
|
|
1850
|
+
}
|
|
1851
|
+
if (key.toLowerCase() === 'dn') {
|
|
1852
|
+
current = { dn: value, attrs: {} }
|
|
1853
|
+
continue
|
|
1854
|
+
}
|
|
1855
|
+
if (current === null) continue
|
|
1856
|
+
const name = key.toLowerCase()
|
|
1857
|
+
if (!Array.isArray(current.attrs[name])) current.attrs[name] = []
|
|
1858
|
+
current.attrs[name].push(value)
|
|
1859
|
+
lastKey = name
|
|
1860
|
+
}
|
|
1861
|
+
if (current !== null) entries.push(current)
|
|
1862
|
+
return entries
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
/** 取第一个属性值。 */
|
|
1866
|
+
function ldapAttr(entry, name) {
|
|
1867
|
+
const list = entry?.attrs?.[String(name).toLowerCase()]
|
|
1868
|
+
return Array.isArray(list) && list.length > 0 ? String(list[0]) : ''
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/** 把 ldapsearch 的报错翻成一句人话。 */
|
|
1872
|
+
function ldapProblemMessage(result) {
|
|
1873
|
+
const stderr = String(result?.stderr ?? '')
|
|
1874
|
+
if (/Can't contact LDAP server/i.test(stderr)) return '连不上 LDAP 服务器:检查地址、端口和网络(这台机器能不能访问到);现在可以先用本地超管账号登录'
|
|
1875
|
+
if (/Invalid credentials/i.test(stderr) || /\(49\)/.test(stderr)) return '账号或密码不对'
|
|
1876
|
+
if (/No such object/i.test(stderr)) return 'BASE DN 下找不到这个条目:检查 BASE DN 和过滤器'
|
|
1877
|
+
if (/No such file|ENOENT/i.test(stderr)) return '这台机器上没有 ldapsearch:先装 ldap-utils'
|
|
1878
|
+
// 被 runCommand 超时杀掉时拿不到退出码,也没有 stderr —— 多半是网络不通(包被丢)
|
|
1879
|
+
if (result?.code === null || result?.code === undefined) return '连不上 LDAP 服务器:超时没响应,先确认这台机器能访问它的地址和端口;现在可以先用本地超管账号登录'
|
|
1880
|
+
const tail = stderr.split('\n').map(line => line.trim()).filter(Boolean).slice(-2).join(' ')
|
|
1881
|
+
return (tail === '' ? `ldapsearch 退出码 ${result?.code ?? '未知'}` : tail).slice(0, 200)
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
/**
|
|
1885
|
+
* 跑一次 ldapsearch。密码只写进 0600 的临时文件(-y),不会出现在进程参数里,
|
|
1886
|
+
* 用完就删;超时由 runCommand 负责。
|
|
1887
|
+
*/
|
|
1888
|
+
async function runLdapsearch(settings, options) {
|
|
1889
|
+
const dir = join(dshHomePath('tmp'), `ldap-${randomBytes(6).toString('hex')}`)
|
|
1890
|
+
await mkdir(dir, { recursive: true })
|
|
1891
|
+
const pwFile = join(dir, 'pw')
|
|
1892
|
+
try {
|
|
1893
|
+
await writeFile(pwFile, String(options.password ?? ''), { encoding: 'utf8', mode: 0o600 })
|
|
1894
|
+
const args = ['-x', '-LLL', '-H', settings.url, '-o', 'ldif-wrap=no', '-o', `nettimeout=${Math.max(2, Math.ceil(Number(settings.timeoutMs) / 1000))}`]
|
|
1895
|
+
if (String(options.bindDN ?? '') !== '') args.push('-D', options.bindDN)
|
|
1896
|
+
args.push('-y', pwFile, '-b', String(options.base ?? settings.baseDN), '-s', String(options.scope ?? 'sub'), '-z', String(options.sizeLimit ?? 2))
|
|
1897
|
+
// ldapsearch 的用法是 `[filter [attributes...]]`:过滤器必须排在属性前面,
|
|
1898
|
+
// 否则它会被当成属性名,搜索退化成 (objectClass=*)。
|
|
1899
|
+
args.push(String(options.filter ?? '(objectClass=*)'))
|
|
1900
|
+
if (Array.isArray(options.attributes) && options.attributes.length > 0) args.push(...options.attributes)
|
|
1901
|
+
const result = await runCommand('ldapsearch', args, Math.max(3000, Number(settings.timeoutMs) + 1500))
|
|
1902
|
+
return { ok: result.ok === true && result.code === 0, entries: parseLdif(result.stdout), stderr: String(result.stderr ?? ''), stdout: String(result.stdout ?? ''), code: result.code }
|
|
1903
|
+
} finally {
|
|
1904
|
+
await rm(dir, { recursive: true, force: true }).catch(() => {})
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
/** 用服务账号查人:返回 LDAP 条目(含 dn 与属性)。 */
|
|
1909
|
+
async function ldapLookup(settings, username) {
|
|
1910
|
+
const filter = String(settings.userFilter).split('{user}').join(ldapEscape(username))
|
|
1911
|
+
const attributes = ['cn', 'sAMAccountName', 'uid', 'mail', 'displayName', 'department', 'title',
|
|
1912
|
+
settings.nameAttr, settings.mailAttr, settings.deptAttr]
|
|
1913
|
+
const result = await runLdapsearch(settings, {
|
|
1914
|
+
bindDN: settings.bindDN,
|
|
1915
|
+
password: settings.bindPassword,
|
|
1916
|
+
filter,
|
|
1917
|
+
sizeLimit: 2,
|
|
1918
|
+
attributes: [...new Set(attributes.filter(name => name !== ''))],
|
|
1919
|
+
})
|
|
1920
|
+
if (!result.ok) return { ok: false, message: ldapProblemMessage(result), entries: [] }
|
|
1921
|
+
return { ok: true, entries: result.entries }
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
/** 用「用户自己的 DN + 密码」再绑一次:这才是验密码。 */
|
|
1925
|
+
async function ldapVerifyPassword(settings, dn, password) {
|
|
1926
|
+
const result = await runLdapsearch(settings, {
|
|
1927
|
+
bindDN: dn,
|
|
1928
|
+
password,
|
|
1929
|
+
base: dn,
|
|
1930
|
+
scope: 'base',
|
|
1931
|
+
sizeLimit: 1,
|
|
1932
|
+
attributes: ['dn'],
|
|
1933
|
+
})
|
|
1934
|
+
if (result.ok) return { ok: true }
|
|
1935
|
+
const stderr = String(result.stderr ?? '')
|
|
1936
|
+
if (/Invalid credentials/i.test(stderr) || /\(49\)/.test(stderr)) return { ok: false, message: '账号或密码不对' }
|
|
1937
|
+
return { ok: false, message: ldapProblemMessage(result) }
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
/** 完整登录:服务账号查人 → 用户 DN 验密码 → canonical 用户名。 */
|
|
1941
|
+
async function ldapAuthenticate(settings, username, password) {
|
|
1942
|
+
if (settings.enabled !== true) return { ok: false, code: 'disabled', message: '统一登录没有开启(设置 → 统一登录)' }
|
|
1943
|
+
if (String(settings.bindPassword ?? '').trim() === '') return { ok: false, code: 'nobind', message: '服务账号密码还没保存(设置 → 统一登录 → 保存)' }
|
|
1944
|
+
const name = String(username ?? '').trim()
|
|
1945
|
+
if (name === '' || String(password ?? '') === '') return { ok: false, code: 'empty', message: '用户名和密码都要填' }
|
|
1946
|
+
const lookup = await ldapLookup(settings, name)
|
|
1947
|
+
if (!lookup.ok) return { ok: false, code: 'directory', message: lookup.message }
|
|
1948
|
+
if (lookup.entries.length === 0) return { ok: false, code: 'notfound', message: `LDAP 里没有「${name}」这个账号` }
|
|
1949
|
+
const entry = lookup.entries[0]
|
|
1950
|
+
const verified = await ldapVerifyPassword(settings, entry.dn, password)
|
|
1951
|
+
if (!verified.ok) return { ok: false, code: 'badpass', message: verified.message }
|
|
1952
|
+
const id = (ldapAttr(entry, 'sAMAccountName') || ldapAttr(entry, 'uid') || name).trim().toLowerCase()
|
|
1953
|
+
return {
|
|
1954
|
+
ok: true,
|
|
1955
|
+
user: {
|
|
1956
|
+
id,
|
|
1957
|
+
name: ldapAttr(entry, settings.nameAttr) || ldapAttr(entry, 'cn') || id,
|
|
1958
|
+
mail: ldapAttr(entry, settings.mailAttr) || ldapAttr(entry, 'mail'),
|
|
1959
|
+
dept: ldapAttr(entry, settings.deptAttr) || ldapAttr(entry, 'department'),
|
|
1960
|
+
dn: entry.dn,
|
|
1961
|
+
},
|
|
1962
|
+
}
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
/** 清掉过期的会话。 */
|
|
1966
|
+
function pruneSessions() {
|
|
1967
|
+
const now = Date.now()
|
|
1968
|
+
for (const [token, item] of SESSIONS) if (Number(item.expiresAt) <= now) SESSIONS.delete(token)
|
|
1969
|
+
if (SESSION_STORE !== null) SESSION_STORE.pruneSessions(now)
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
function issueSession(user, ip) {
|
|
1973
|
+
pruneSessions()
|
|
1974
|
+
const token = randomBytes(24).toString('hex')
|
|
1975
|
+
const at = Date.now()
|
|
1976
|
+
const expiresAt = at + SESSION_TTL_MS
|
|
1977
|
+
const session = {
|
|
1978
|
+
userId: String(user.id),
|
|
1979
|
+
name: String(user.name),
|
|
1980
|
+
role: String(user.role ?? 'member').slice(0, 20),
|
|
1981
|
+
source: String(user.source ?? 'ldap').slice(0, 20),
|
|
1982
|
+
at,
|
|
1983
|
+
expiresAt,
|
|
1984
|
+
ip: String(ip ?? '').slice(0, 60),
|
|
1985
|
+
}
|
|
1986
|
+
SESSIONS.set(token, session)
|
|
1987
|
+
if (SESSION_STORE !== null) SESSION_STORE.saveSession({ token, ...session })
|
|
1988
|
+
return { token, expiresAt }
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
/** 从请求头 x-studio-session 认人;没有或过期返回 null。 */
|
|
1992
|
+
function sessionOf(req, config) {
|
|
1993
|
+
const raw = req.headers['x-studio-session']
|
|
1994
|
+
const token = String((Array.isArray(raw) ? raw[0] : raw) ?? '').trim()
|
|
1995
|
+
if (token === '') return null
|
|
1996
|
+
const session = SESSIONS.get(token)
|
|
1997
|
+
if (session === undefined) return null
|
|
1998
|
+
if (Number(session.expiresAt) <= Date.now()) {
|
|
1999
|
+
SESSIONS.delete(token)
|
|
2000
|
+
if (SESSION_STORE !== null) SESSION_STORE.dropSession(token)
|
|
2001
|
+
return null
|
|
2002
|
+
}
|
|
2003
|
+
/* 本地超管不在 config.users 里,会话本身就带名字与角色,不再依赖名单。 */
|
|
2004
|
+
const stored = (config?.users ?? []).find(item => item.id === session.userId)
|
|
2005
|
+
const user = stored ?? {
|
|
2006
|
+
id: session.userId,
|
|
2007
|
+
name: session.name,
|
|
2008
|
+
role: session.role,
|
|
2009
|
+
source: session.source,
|
|
2010
|
+
mail: '',
|
|
2011
|
+
dept: '',
|
|
2012
|
+
}
|
|
2013
|
+
return { token, user, session }
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
/** 登录成功:记到 config.users(首次登录建档,之后只更新最近登录情况)。 */
|
|
2017
|
+
async function recordLogin(store, user, ip) {
|
|
2018
|
+
const at = Date.now()
|
|
2019
|
+
await store.mutate(state => {
|
|
2020
|
+
if (!Array.isArray(state.config.users)) state.config.users = []
|
|
2021
|
+
const id = String(user.id).toLowerCase()
|
|
2022
|
+
let entry = state.config.users.find(item => item.id === id)
|
|
2023
|
+
if (entry === undefined) {
|
|
2024
|
+
entry = { id, name: user.name, mail: '', dept: '', dn: '', role: 'member', source: 'ldap', firstLoginAt: at, lastLoginAt: at, loginCount: 0, lastIp: '' }
|
|
2025
|
+
state.config.users.push(entry)
|
|
2026
|
+
}
|
|
2027
|
+
if (String(user.name ?? '') !== '') entry.name = user.name
|
|
2028
|
+
if (String(user.mail ?? '') !== '') entry.mail = user.mail
|
|
2029
|
+
if (String(user.dept ?? '') !== '') entry.dept = user.dept
|
|
2030
|
+
if (String(user.dn ?? '') !== '') entry.dn = user.dn
|
|
2031
|
+
entry.source = 'ldap'
|
|
2032
|
+
entry.firstLoginAt = Number(entry.firstLoginAt) || at
|
|
2033
|
+
entry.lastLoginAt = at
|
|
2034
|
+
entry.loginCount = (Number(entry.loginCount) || 0) + 1
|
|
2035
|
+
entry.lastIp = String(ip ?? '').slice(0, 60)
|
|
2036
|
+
state.config.users = sanitizeUsers(state.config.users)
|
|
2037
|
+
})
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/** 只是读、但走 POST 的入口(前端只有一处 api() 封装,固定发 POST)。 */
|
|
2041
|
+
const READ_ONLY_POST_ROUTES = new Set(['/task/comments', '/task/parts', '/cases', '/team/memory', '/suite/list'])
|
|
2042
|
+
|
|
2043
|
+
/** 这个请求需要什么权限:读、写、还是管理员。 */
|
|
2044
|
+
function accessLevelFor(route, method) {
|
|
2045
|
+
if (method !== 'POST') return 'read'
|
|
2046
|
+
return ADMIN_ROUTES.has(route) ? 'admin' : 'write'
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
/** 权限判定,返回 null 表示放行,否则返回给人看的原因。 */
|
|
2050
|
+
function checkAccess(config, operator, level) {
|
|
2051
|
+
const access = config?.access ?? { enabled: false, members: [] }
|
|
2052
|
+
if (access.enabled !== true) return null
|
|
2053
|
+
// 本地超管永远放行:开权限时最怕的是管理员把自己关在门外。
|
|
2054
|
+
const raw = String(operator).trim()
|
|
2055
|
+
if (raw.includes('超级管理员')) return null
|
|
2056
|
+
const name = raw.toLowerCase()
|
|
2057
|
+
const member = access.members.find(item => {
|
|
2058
|
+
const ids = [item.id, item.name].map(value => String(value ?? '').trim().toLowerCase()).filter(Boolean)
|
|
2059
|
+
return ids.includes(name) || ids.some(value => name.endsWith(value) || name.includes(` ${value}`))
|
|
2060
|
+
})
|
|
2061
|
+
if (member === undefined) {
|
|
2062
|
+
// 名单外的人还能看(否则名字对不上就直接进不去),但一个写操作都不许做。
|
|
2063
|
+
if (level === 'read') return null
|
|
2064
|
+
return `「${operator}」不在成员名单里:让管理员到「设置 → 权限与审计」把你加进去,或用页头的「我是…」换个名字`
|
|
2065
|
+
}
|
|
2066
|
+
const rank = ROLE_LEVEL[member.role] ?? 0
|
|
2067
|
+
if ((LEVEL_RANK[level] ?? 0) > rank) {
|
|
2068
|
+
const need = level === 'admin' ? '管理员' : '成员'
|
|
2069
|
+
return `「${operator}」是${ACCESS_ROLE_LABEL[member.role] ?? member.role},这一步需要${need}权限`
|
|
2070
|
+
}
|
|
2071
|
+
return null
|
|
2072
|
+
}
|
|
2073
|
+
|
|
2074
|
+
/**
|
|
2075
|
+
* 每个工具要求的权限档位。没列出来的一律按「写」处理——收紧是默认,
|
|
2076
|
+
* 放松要显式写在这里,避免新工具默默变成后门。
|
|
2077
|
+
*/
|
|
2078
|
+
const TOOL_ACCESS = {
|
|
2079
|
+
studio_config_get: 'read',
|
|
2080
|
+
studio_requirement_list: 'read',
|
|
2081
|
+
studio_requirement_get: 'read',
|
|
2082
|
+
studio_task_list: 'read',
|
|
2083
|
+
studio_task_get: 'read',
|
|
2084
|
+
studio_project_list: 'read',
|
|
2085
|
+
studio_release_list: 'read',
|
|
2086
|
+
studio_release_get: 'read',
|
|
2087
|
+
studio_analytics: 'read',
|
|
2088
|
+
studio_skill_list: 'read',
|
|
2089
|
+
studio_skill_get: 'read',
|
|
2090
|
+
studio_config_set: 'admin',
|
|
2091
|
+
studio_release_publish: 'admin',
|
|
2092
|
+
studio_release_save: 'admin',
|
|
2093
|
+
studio_skill_delete: 'admin',
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
function toolLevel(name) {
|
|
2097
|
+
return TOOL_ACCESS[name] ?? 'write'
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
/** 会话名形如 `studio-<taskId>-<expertId>`,从里面认出是哪个专家在调用。 */
|
|
2101
|
+
function operatorFromSessionId(sessionId, config) {
|
|
2102
|
+
const match = /^studio-(t-[0-9a-z]+)-(.+)$/.exec(String(sessionId))
|
|
2103
|
+
if (match === null) return null
|
|
2104
|
+
const expertId = match[2]
|
|
2105
|
+
const expert = (config?.experts ?? []).find(item => item.id === expertId)
|
|
2106
|
+
return { id: expertId, name: String(expert?.name ?? expertId) }
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
/** 工具调用的身份:优先从会话名解析,解析不出再问派单器。 */
|
|
2110
|
+
function toolOperator(store, exec) {
|
|
2111
|
+
const agent = exec?.agent
|
|
2112
|
+
const rawSession = agent?.session ?? exec?.sessionId
|
|
2113
|
+
const sessionId = String(
|
|
2114
|
+
rawSession !== null && typeof rawSession === 'object'
|
|
2115
|
+
? (rawSession.id ?? rawSession.sessionId ?? '')
|
|
2116
|
+
: (rawSession ?? ''),
|
|
2117
|
+
)
|
|
2118
|
+
if (sessionId === '') return null
|
|
2119
|
+
const config = store.snapshot().config
|
|
2120
|
+
const parsed = operatorFromSessionId(sessionId, config)
|
|
2121
|
+
if (parsed !== null) return { ...parsed, sessionId }
|
|
2122
|
+
const dispatcher = activeDispatcher
|
|
2123
|
+
if (dispatcher !== undefined && dispatcher?.running instanceof Map) {
|
|
2124
|
+
for (const info of dispatcher.running.values()) {
|
|
2125
|
+
if (info.sessionId !== sessionId) continue
|
|
2126
|
+
const expert = (config?.experts ?? []).find(item => item.id === info.expert)
|
|
2127
|
+
return { id: info.expert, name: String(expert?.name ?? info.expert), sessionId }
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
return { id: '', name: 'AI', sessionId }
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
/**
|
|
2134
|
+
* 工具层的权限判定:和 HTTP 面板用同一张成员名单、同一套档位。
|
|
2135
|
+
* `access.agents === 'bypass'` 时只记审计不拦(给「全交给 AI」的团队留一个开关)。
|
|
2136
|
+
*/
|
|
2137
|
+
function checkToolAccess(config, operator, toolName) {
|
|
2138
|
+
const level = toolLevel(toolName)
|
|
2139
|
+
// operator 既可能是名字字符串,也可能是 {id,name};统一取名字再判。
|
|
2140
|
+
const name = typeof operator === 'string' ? operator : String(operator?.name ?? operator?.id ?? '')
|
|
2141
|
+
if (config?.access?.enabled !== true) return { level, denial: null }
|
|
2142
|
+
if (config.access.agents === 'bypass') return { level, denial: null }
|
|
2143
|
+
return { level, denial: checkAccess(config, name, level) }
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
/** 记一条审计:只留摘要,不记正文、不记密钥。写失败不影响主流程。 */async function recordAudit(store, entry) {
|
|
2147
|
+
try {
|
|
2148
|
+
await store.mutate(state => {
|
|
2149
|
+
if (!Array.isArray(state.audit)) state.audit = []
|
|
2150
|
+
const at = Date.now()
|
|
2151
|
+
state.audit.unshift({
|
|
2152
|
+
id: `au-${at.toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
|
2153
|
+
at,
|
|
2154
|
+
by: entry.by,
|
|
2155
|
+
route: entry.route,
|
|
2156
|
+
level: entry.level,
|
|
2157
|
+
status: entry.status,
|
|
2158
|
+
ok: entry.ok === true,
|
|
2159
|
+
summary: String(entry.summary ?? '').slice(0, 200),
|
|
2160
|
+
})
|
|
2161
|
+
if (state.audit.length > 1000) state.audit.length = 1000
|
|
2162
|
+
})
|
|
2163
|
+
} catch {
|
|
2164
|
+
/* 审计写不进去不能挡住业务 */
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
/** 审计摘要:只留几个识别字段,不搬正文、不搬 token。 */
|
|
2169
|
+
function auditSummary(body) {
|
|
2170
|
+
if (body === null || typeof body !== 'object') return ''
|
|
2171
|
+
const parts = []
|
|
2172
|
+
for (const key of ['id', 'taskId', 'name', 'title', 'version', 'status', 'action', 'reason', 'project', 'requirementId', 'releaseId', 'expertId']) {
|
|
2173
|
+
const value = body[key]
|
|
2174
|
+
if (typeof value === 'string' && value.trim() !== '') parts.push(`${key}=${value.trim().slice(0, 40)}`)
|
|
2175
|
+
}
|
|
2176
|
+
for (const key of ['taskIds', 'caseIds', 'members']) {
|
|
2177
|
+
if (Array.isArray(body[key]) && body[key].length > 0) parts.push(`${key}=${body[key].length}`)
|
|
2178
|
+
}
|
|
2179
|
+
return parts.join(' ').slice(0, 200)
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
/** Register the /studio-api routes. Returns a disposer. */
|
|
2183
|
+
/* ── 交付闭环增强:用例生成 / 缺陷 SLA / 重复缺陷 / 负载 / 燃尽 ───────────── */
|
|
2184
|
+
|
|
2185
|
+
/**
|
|
2186
|
+
* 用例不再靠人一条条敲:按需求的「功能点 + 验收条件」推导一遍。
|
|
2187
|
+
* 一个功能点有几条验收条件就出几条用例,一条都没有时退化成「主流程」一条。
|
|
2188
|
+
*/
|
|
2189
|
+
function draftCases(state, task) {
|
|
2190
|
+
const requirement = state.requirements.find(item => item.id === task.requirementId)
|
|
2191
|
+
if (requirement === undefined) return { cases: [], reason: '这张卡没挂需求,没有验收标准可以推导用例' }
|
|
2192
|
+
const points = (requirement.points ?? []).filter(point => task.pointId === '' || point.id === task.pointId)
|
|
2193
|
+
if (points.length === 0) return { cases: [], reason: '这个需求还没拆功能点:先去需求页加功能点与验收条件' }
|
|
2194
|
+
const cases = []
|
|
2195
|
+
for (const point of points) {
|
|
2196
|
+
const lines = (point.acceptance ?? []).filter(item => String(item.text ?? '').trim() !== '')
|
|
2197
|
+
if (lines.length === 0) {
|
|
2198
|
+
cases.push({ title: `${point.title} 主流程`.slice(0, 200), expected: String(point.description || point.title).slice(0, 300), pointId: point.id })
|
|
2199
|
+
continue
|
|
2200
|
+
}
|
|
2201
|
+
for (const line of lines) {
|
|
2202
|
+
cases.push({ title: `${point.title}:${line.text}`.slice(0, 200), expected: String(line.text).slice(0, 300), pointId: point.id })
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
return { cases, reason: '' }
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
async function addDraftCases(store, taskId, by = '人') {
|
|
2209
|
+
const before = store.snapshot()
|
|
2210
|
+
const task = before.tasks.find(item => item.id === taskId)
|
|
2211
|
+
if (task === undefined) throw new Error(`no task ${taskId}`)
|
|
2212
|
+
const draft = draftCases(before, task)
|
|
2213
|
+
if (draft.cases.length === 0) return { added: 0, total: (task.cases ?? []).length, reason: draft.reason }
|
|
2214
|
+
const existing = new Set((task.cases ?? []).map(item => item.title))
|
|
2215
|
+
let added = 0
|
|
2216
|
+
await store.mutate(state => {
|
|
2217
|
+
const entry = state.tasks.find(item => item.id === taskId)
|
|
2218
|
+
for (const item of draft.cases) {
|
|
2219
|
+
if (existing.has(item.title)) continue
|
|
2220
|
+
existing.add(item.title)
|
|
2221
|
+
entry.cases.push({
|
|
2222
|
+
id: `c-${Math.random().toString(36).slice(2, 7)}`,
|
|
2223
|
+
title: item.title,
|
|
2224
|
+
expected: item.expected,
|
|
2225
|
+
result: 'pending',
|
|
2226
|
+
note: '',
|
|
2227
|
+
pointId: item.pointId,
|
|
2228
|
+
by,
|
|
2229
|
+
at: Date.now(),
|
|
2230
|
+
http: null,
|
|
2231
|
+
lastRun: null,
|
|
2232
|
+
})
|
|
2233
|
+
added += 1
|
|
2234
|
+
}
|
|
2235
|
+
entry.updatedAt = Date.now()
|
|
2236
|
+
})
|
|
2237
|
+
const after = store.snapshot().tasks.find(item => item.id === taskId)
|
|
2238
|
+
return { added, total: (after?.cases ?? []).length, reason: '' }
|
|
2239
|
+
}
|
|
2240
|
+
|
|
2241
|
+
/** 缺陷 SLA:按严重级别算应修时限,返回预期、剩余与状态。 */
|
|
2242
|
+
function bugSla(task, config) {
|
|
2243
|
+
if (task === null || typeof task !== 'object' || task.type !== 'bug') return null
|
|
2244
|
+
const table = sanitizeSla(config?.sla)
|
|
2245
|
+
const severity = SEVERITIES.includes(task.severity) ? task.severity : 'p2'
|
|
2246
|
+
const hours = table[severity]
|
|
2247
|
+
const dueAt = (Number(task.createdAt) || 0) + hours * 3600 * 1000
|
|
2248
|
+
const closedAt = task.status === 'done' ? (Number(task.updatedAt) || Date.now()) : 0
|
|
2249
|
+
const end = closedAt > 0 ? closedAt : Date.now()
|
|
2250
|
+
const remaining = dueAt - end
|
|
2251
|
+
let state = 'ok'
|
|
2252
|
+
if (closedAt > 0) state = closedAt <= dueAt ? 'met' : 'late'
|
|
2253
|
+
else if (remaining < 0) state = 'overdue'
|
|
2254
|
+
else if (remaining < hours * 3600 * 1000 * 0.25) state = 'due_soon'
|
|
2255
|
+
return { severity, hours, dueAt, closedAt, remaining, state }
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
/** 每个专家手上还有多少活:在办卡、正在跑、近 7 天完成、逾期缺陷。 */
|
|
2259
|
+
function workloadReport(state, runningList = []) {
|
|
2260
|
+
const now = Date.now()
|
|
2261
|
+
const week = 7 * 86_400_000
|
|
2262
|
+
const runningBy = new Map()
|
|
2263
|
+
for (const info of runningList) runningBy.set(info.expert, (runningBy.get(info.expert) ?? 0) + 1)
|
|
2264
|
+
const experts = (state.config.experts ?? []).map(expert => {
|
|
2265
|
+
let open = 0
|
|
2266
|
+
let done7d = 0
|
|
2267
|
+
let overdue = 0
|
|
2268
|
+
for (const task of state.tasks) {
|
|
2269
|
+
const owner = task.dispatch?.expert || task.expert || ''
|
|
2270
|
+
if (owner !== expert.id) continue
|
|
2271
|
+
if (task.status === 'done') {
|
|
2272
|
+
if (now - (Number(task.updatedAt) || 0) <= week) done7d += 1
|
|
2273
|
+
continue
|
|
2274
|
+
}
|
|
2275
|
+
open += 1
|
|
2276
|
+
const sla = bugSla(task, state.config)
|
|
2277
|
+
if (sla !== null && sla.state === 'overdue') overdue += 1
|
|
2278
|
+
}
|
|
2279
|
+
return {
|
|
2280
|
+
id: expert.id,
|
|
2281
|
+
name: expert.name,
|
|
2282
|
+
emoji: expert.emoji,
|
|
2283
|
+
domain: expert.domain,
|
|
2284
|
+
enabled: expert.enabled !== false,
|
|
2285
|
+
running: runningBy.get(expert.id) ?? 0,
|
|
2286
|
+
open,
|
|
2287
|
+
done7d,
|
|
2288
|
+
overdue,
|
|
2289
|
+
}
|
|
2290
|
+
})
|
|
2291
|
+
return { experts: experts.sort((a, b) => (b.running - a.running) || (b.open - a.open)), busy: experts.filter(item => item.running > 0).length }
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2294
|
+
/** 燃尽:近 N 天每天结束时的剩余卡数(按创建/完成时间回推)。 */
|
|
2295
|
+
function burndownReport(state, options = {}) {
|
|
2296
|
+
const days = [7, 14, 30].includes(Number(options.days)) ? Number(options.days) : 14
|
|
2297
|
+
const project = String(options.project ?? '').trim()
|
|
2298
|
+
const tasks = state.tasks.filter(task => project === '' || task.project === project)
|
|
2299
|
+
const end = Date.now()
|
|
2300
|
+
const start = end - (days - 1) * 86_400_000
|
|
2301
|
+
const doneAt = task => {
|
|
2302
|
+
const step = (task.history ?? []).find(item => item.status === 'done')
|
|
2303
|
+
return step === undefined ? 0 : Number(step.at) || 0
|
|
2304
|
+
}
|
|
2305
|
+
const series = []
|
|
2306
|
+
for (let index = 0; index < days; index += 1) {
|
|
2307
|
+
const dayStart = start + index * 86_400_000
|
|
2308
|
+
const dayEnd = dayStart + 86_400_000
|
|
2309
|
+
const created = tasks.filter(task => (Number(task.createdAt) || 0) < dayEnd).length
|
|
2310
|
+
const finished = tasks.filter(task => {
|
|
2311
|
+
const at = doneAt(task)
|
|
2312
|
+
return at > 0 && at < dayEnd
|
|
2313
|
+
}).length
|
|
2314
|
+
series.push({
|
|
2315
|
+
day: new Date(dayEnd - 1).toISOString().slice(5, 10),
|
|
2316
|
+
at: dayEnd,
|
|
2317
|
+
created,
|
|
2318
|
+
done: finished,
|
|
2319
|
+
remaining: Math.max(0, created - finished),
|
|
2320
|
+
})
|
|
2321
|
+
}
|
|
2322
|
+
return { days, project, series, now: { remaining: series[series.length - 1]?.remaining ?? 0 } }
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
/** 把一张缺陷标成另一张的重复(人决定的,不是脚本猜的)。 */
|
|
2326
|
+
async function markDuplicate(store, id, targetId, by = '人') {
|
|
2327
|
+
const state = store.snapshot()
|
|
2328
|
+
const task = state.tasks.find(item => item.id === id)
|
|
2329
|
+
const target = targetId === '' ? undefined : state.tasks.find(item => item.id === targetId)
|
|
2330
|
+
if (task === undefined) throw new Error(`no task ${id}`)
|
|
2331
|
+
await store.mutate(current => {
|
|
2332
|
+
const entry = current.tasks.find(item => item.id === id)
|
|
2333
|
+
entry.duplicateOf = target === undefined ? '' : target.id
|
|
2334
|
+
entry.tags = target === undefined ? entry.tags.filter(tag => tag !== '疑似重复') : [...new Set([...entry.tags, '重复'])].filter(tag => tag !== '疑似重复')
|
|
2335
|
+
entry.updatedAt = Date.now()
|
|
2336
|
+
})
|
|
2337
|
+
if (target !== undefined) {
|
|
2338
|
+
await commentTask(store, id, `标记为重复:${target.code === '' ? target.id : target.code}「${target.title}」。`, by, 'human')
|
|
2339
|
+
await commentTask(store, target.id, `${task.code === '' ? task.id : task.code}「${task.title}」被标成了这张的重复。`, by, 'human')
|
|
2340
|
+
}
|
|
2341
|
+
return store.snapshot().tasks.find(item => item.id === id)
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
function registerRoutes(webCtx, store, ctx, getDispatcher = () => undefined) {
|
|
2345
|
+
attachSessionStore(store)
|
|
2346
|
+
const core = async (req, res) => {
|
|
2347
|
+
try {
|
|
2348
|
+
const url = new URL(req.url ?? '/', 'http://studio.local')
|
|
2349
|
+
const route = url.pathname.replace(/^\/studio-api/, '') || '/'
|
|
2350
|
+
const method = (req.method ?? 'GET').toUpperCase()
|
|
2351
|
+
await store.ready
|
|
2352
|
+
/* registerRoutes 是同步调用的,那时 #boot 还没跑完(db 还是 null),
|
|
2353
|
+
所以第一次请求到达、store 真正就绪之后再恢复会话。 */
|
|
2354
|
+
if (SESSION_STORE === null) attachSessionStore(store)
|
|
2355
|
+
|
|
2356
|
+
// 统一登录:开了 requireLogin 之后,除了登录相关的几个接口,其余都要带会话。
|
|
2357
|
+
// 没开的时候(默认)平台直接以本地超管身份运行,不会要求登录。
|
|
2358
|
+
{
|
|
2359
|
+
const configNow = store.snapshot().config
|
|
2360
|
+
const ldapNow = configNow.ldap ?? {}
|
|
2361
|
+
const localNow = sanitizeLocalAuth(configNow.localAuth)
|
|
2362
|
+
const locked = ldapNow.enabled === true && ldapNow.requireLogin === true
|
|
2363
|
+
const openRoutes = new Set(['/intake', '/intake/requirement', '/intake/status', '/intake/query', '/intake/mine', '/intake/mine/query', '/auth/login', '/auth/me', '/auth/logout', '/auth/password', '/auth/ldap/test', '/audit/export.csv', '/export/dispatch.csv', '/state', '/'])
|
|
2364
|
+
const session = sessionOf(req, configNow)
|
|
2365
|
+
if (session !== null) {
|
|
2366
|
+
req.studioActor = String(session.user.name ?? session.user.id ?? '').slice(0, 40)
|
|
2367
|
+
} else if (!locked) {
|
|
2368
|
+
req.studioActor = localNow.username === 'admin' ? '超管 admin' : localNow.username
|
|
2369
|
+
}
|
|
2370
|
+
if (locked && !openRoutes.has(route) && session === null) {
|
|
2371
|
+
sendJson(res, 401, { ok: false, code: 'login_required', error: '需要登录(统一登录已开启)' })
|
|
2372
|
+
return
|
|
2373
|
+
}
|
|
2374
|
+
if (locked && (route === '/state' || route === '/') && session === null) {
|
|
2375
|
+
sendJson(res, 200, { ok: true, locked: true, state: { revision: Number(store.snapshot().revision) || 1, config: publicConfig(configNow), tasks: [], requirements: [], releases: [] } })
|
|
2376
|
+
return
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
|
|
2380
|
+
if (method === 'GET' && (route === '/' || route === '/state')) {
|
|
2381
|
+
const dispatcher = getDispatcher()
|
|
2382
|
+
const state = publicState(store)
|
|
2383
|
+
sendJson(res, 200, { ok: true, state: dispatcher === undefined ? state : { ...state, agents: dispatcher.status() } })
|
|
2384
|
+
return
|
|
2385
|
+
}
|
|
2386
|
+
if (await handleAuthRoute({ method, route, req, res, store, url })) return
|
|
2387
|
+
if (method === 'GET' && route === '/agents/probe') {
|
|
2388
|
+
const dispatcher = getDispatcher()
|
|
2389
|
+
sendJson(res, 200, { ok: true, probe: dispatcher === undefined ? {} : await dispatcher.probePrompt() })
|
|
2390
|
+
return
|
|
2391
|
+
}
|
|
2392
|
+
if (method === 'GET' && route === '/models') {
|
|
2393
|
+
sendJson(res, 200, { ok: true, models: await listModels(ctx) })
|
|
2394
|
+
return
|
|
2395
|
+
}
|
|
2396
|
+
if (await handleSkillsRoute({ method, route, req, res, store, url })) return
|
|
2397
|
+
if (await handleAdminRoute({ method, route, req, res, store, url })) return
|
|
2398
|
+
if (await handleTaskRoute({ method, route, req, res, store, url })) return
|
|
2399
|
+
if (handleExportRoute({ method, route, url, res, store, sendJson })) return
|
|
2400
|
+
if (await handleCodingRoute({ method, route, req, res, store, url })) return
|
|
2401
|
+
if (await handleRequirementRoute({ method, route, req, res, store, url })) return
|
|
2402
|
+
if (await handleReleasesRoute({ method, route, req, res, store, url })) return
|
|
2403
|
+
if (await handleInsightsRoute({ method, route, req, res, store, url })) return
|
|
2404
|
+
if (method === 'POST' && route === '/task/cases/generate') {
|
|
2405
|
+
const body = await readJsonBody(req)
|
|
2406
|
+
const task = store.snapshot().tasks.find(entry => entry.id === body.id)
|
|
2407
|
+
if (task === undefined) {
|
|
2408
|
+
sendJson(res, 404, { ok: false, error: 'no such task' })
|
|
2409
|
+
return
|
|
2410
|
+
}
|
|
2411
|
+
const result = await addDraftCases(store, task.id, operatorOf(req))
|
|
2412
|
+
sendJson(res, 200, { ok: true, ...result, task: store.snapshot().tasks.find(entry => entry.id === task.id), state: publicState(store) })
|
|
2413
|
+
return
|
|
2414
|
+
}
|
|
2415
|
+
if (method === 'POST' && route === '/task/duplicate') {
|
|
2416
|
+
const body = await readJsonBody(req)
|
|
2417
|
+
const task = await markDuplicate(store, body.id, String(body.targetId ?? ''), operatorOf(req))
|
|
2418
|
+
sendJson(res, 200, { ok: true, task, state: publicState(store) })
|
|
2419
|
+
return
|
|
2420
|
+
}
|
|
2421
|
+
if (await handleAgentsRoute({ method, route, req, res, store, url })) return
|
|
2422
|
+
if (await handleConnectRoute({ method, route, req, res, store, url })) return
|
|
2423
|
+
sendJson(res, 404, { ok: false, error: `no route ${method} ${route}` })
|
|
2424
|
+
} catch (error) {
|
|
2425
|
+
sendJson(res, 400, {
|
|
2426
|
+
ok: false,
|
|
2427
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2428
|
+
stack: error instanceof Error ? String(error.stack ?? '').split('\n').slice(0, 6).join(' | ') : '',
|
|
2429
|
+
})
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
const handler = async (req, res) => {
|
|
2433
|
+
await store.ready
|
|
2434
|
+
const url = new URL(req.url ?? '/', 'http://studio.local')
|
|
2435
|
+
const route = url.pathname.replace(/^\/studio-api/, '') || '/'
|
|
2436
|
+
const method = (req.method ?? 'GET').toUpperCase()
|
|
2437
|
+
const resolved = resolveActor(req, store.snapshot().config)
|
|
2438
|
+
if (resolved !== '') req.studioActor = resolved
|
|
2439
|
+
const operator = operatorOf(req)
|
|
2440
|
+
// 登录/登出/改密绝不能受权限名单影响,否则一旦开启权限就再也登不进来。
|
|
2441
|
+
// 有些 POST 只是读(取交付物正文、取评论):别被写档位挡住只读成员。
|
|
2442
|
+
const readOnlyPost = READ_ONLY_POST_ROUTES.has(route)
|
|
2443
|
+
|| (route === '/task/artifact' && String(req.__studioBody?.action ?? '') === 'get')
|
|
2444
|
+
const level = route.startsWith('/auth/') ? 'read' : readOnlyPost ? 'read' : accessLevelFor(route, method)
|
|
2445
|
+
let summary = ''
|
|
2446
|
+
if (method === 'POST') {
|
|
2447
|
+
try {
|
|
2448
|
+
req.__studioBody = await readJsonBody(req)
|
|
2449
|
+
summary = auditSummary(req.__studioBody)
|
|
2450
|
+
} catch {
|
|
2451
|
+
/* 坏 JSON 交给下面的 core 去报 400 */
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
const denied = checkAccess(store.snapshot().config, operator, level)
|
|
2455
|
+
if (denied !== null) {
|
|
2456
|
+
await recordAudit(store, { by: operator, route, level, status: 403, ok: false, summary: `${denied}${summary === '' ? '' : ` | ${summary}`}` })
|
|
2457
|
+
sendJson(res, 403, { ok: false, error: denied })
|
|
2458
|
+
return
|
|
2459
|
+
}
|
|
2460
|
+
try {
|
|
2461
|
+
await core(req, res)
|
|
2462
|
+
} catch (error) {
|
|
2463
|
+
sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) })
|
|
2464
|
+
}
|
|
2465
|
+
if (method === 'POST') {
|
|
2466
|
+
await recordAudit(store, {
|
|
2467
|
+
by: operator,
|
|
2468
|
+
route,
|
|
2469
|
+
level,
|
|
2470
|
+
status: res.statusCode ?? 200,
|
|
2471
|
+
ok: (res.statusCode ?? 200) < 400,
|
|
2472
|
+
summary,
|
|
2473
|
+
})
|
|
2474
|
+
}
|
|
2475
|
+
}
|
|
2476
|
+
return webCtx.webServer.register({ kind: 'prefix', path: '/studio-api', handler })
|
|
2477
|
+
}
|
|
2478
|
+
|
|
2479
|
+
/** Best-effort model catalogue from the llm service. */
|
|
2480
|
+
async function listModels(ctx) {
|
|
2481
|
+
try {
|
|
2482
|
+
const llm = ctx.get?.('llm')
|
|
2483
|
+
if (llm === undefined || typeof llm.listProviders !== 'function') return []
|
|
2484
|
+
const providers = llm.listProviders()
|
|
2485
|
+
const out = []
|
|
2486
|
+
for (const provider of providers) {
|
|
2487
|
+
try {
|
|
2488
|
+
// llm.listModels(provider) 是异步的;旧写法 llm.models?.() 不存在,永远拿不到模型。
|
|
2489
|
+
const models = typeof llm.listModels === 'function' ? await llm.listModels(provider.id) : []
|
|
2490
|
+
for (const model of models ?? []) out.push({ provider: provider.id, model: model.id ?? model, name: model.name ?? '' })
|
|
2491
|
+
} catch { /* skip provider */ }
|
|
2492
|
+
}
|
|
2493
|
+
return out
|
|
2494
|
+
} catch {
|
|
2495
|
+
return []
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
/** Agent-facing tools. */
|
|
2500
|
+
|
|
2501
|
+
/** The agent-facing protocol section. */
|
|
2502
|
+
const PROTOCOL = `# Studio 工作看板协议
|
|
2503
|
+
|
|
2504
|
+
本环境有一个跨角色的工作看板(工具前缀 studio_)。专家(产品、架构、研发、测试,以及公司自己配置的其他专家)和专家团通过它交接工作,人通过 Web 端「需求&任务工作空间」面板查看同一份数据。
|
|
2505
|
+
|
|
2506
|
+
- 接活前先 studio_task_list 找到指派给自己或待处理的卡,再 studio_task_get 读全量与评论。多项目并行时先看卡上的 project 字段,别把两个项目的事混在一起。
|
|
2507
|
+
- **需求是链路的锚点**:每张卡都应该挂在一个需求(REQ-YYYY-NNN)下,卡上会带一个编号(DEV-001 / TEST-001 / BUG-001)。开新卡时用 requirementId 挂到对应需求;用 studio_requirement_get 能看到这条需求下面的全部卡片、产物、用例和 MR。找不到对应需求就先 studio_requirement_save 建一条,别让卡悬空。
|
|
2508
|
+
- **需求要收到完工才算交付**:一条需求的所有卡都完成、功能点都有通过用例覆盖、没有阻塞和未结缺陷后,用 studio_requirement_signoff 写上验收结论签字;签字前 requirement_get 返回的 progress.gate.blockers 会列出还差什么。
|
|
2509
|
+
- 状态推进:待澄清 → 待方案 → 待开发 → 开发中 → 待评审 → 待测试 → 待验收 → 已完成;卡住时用 studio_task_block 打阻塞标记(卡留在原列,不自动往下派,等人来定怎么继续),能继续了再由人解除。
|
|
2510
|
+
- 研发提了 MR 就会被同步到卡上(gitlabMR),并自动进入「待评审」;架构师评审通过才放行到「待测试」,不通过就 @ 开发退回「开发中」。
|
|
2511
|
+
- **研发必须留执行记录**:改完代码调 studio_task_coding 登记 agent/model、分支、commits、改动文件、单测结果;评审的人在同一张卡上 action=review 写结论。谁照着哪版需求改了什么,靠这份记录回答。
|
|
2512
|
+
- 测试环节用 studio_task_case 把用例登记到卡上(做什么、期望什么)。**能写成接口调用的用例,就把 method/url/expectStatus/expectContains 一起写上**,然后用 studio_test_run 真的跑一遍——平台发请求、比对结果、写回状态、留下执行记录;跑不了的(需要人工操作、看 UI、连设备)才自己执行并回填 result。**用例判 fail 平台会自动开缺陷卡(父任务指向测试卡),不要手工重复建 Bug**;缺陷卡修好进「待测试」时,平台会自动把来源用例重置为待执行,测试复跑通过即可关闭缺陷。
|
|
2513
|
+
- 每完成一步用 studio_task_comment 留下证据(方案要点、改动文件、测试命令与结果、MR 链接),不要只改状态。
|
|
2514
|
+
- **交付物要落到卡上,并归档到云盘**:每做完一件能给人看的东西(需求说明、方案文档、MR、用例集、测试报告、发布说明),就用 studio_task_artifact 登记一条(kind + title + ref + **content 正文**)。登记时默认会把它写成飞书云盘里「该需求文件夹」下的一份 Markdown 文档(同名会覆盖更新)。只在评论里说"已经完成"不算交付。需求文件夹用 studio_requirement_folder 查看或补建。
|
|
2515
|
+
- **验收清单就是"做完"的定义**:用 studio_task_check 勾掉你实际做完的条目,附证据(命令输出、文件路径、测试结果)。没做完的不要勾。
|
|
2516
|
+
- 评论里用 @专家名 或 @专家团名 可以点名让某个专家接着做——被 @ 的专家会收到这条评论并开始工作。需要别的角色配合时,在评论里 @ 对方,而不是自己代做。
|
|
2517
|
+
- 专家团协作时由团长拆活、给成员派活、汇总结论;成员之间用 @ 互相接棒。
|
|
2518
|
+
- 评审不通过时把卡退回上一状态并说明原因,不要新建重复卡。发现缺陷时新开一张 type=bug 的卡,并用 parent 指回它来自的那张需求卡。
|
|
2519
|
+
- 一次只推进一张卡;不要把无关工作塞进同一张卡。
|
|
2520
|
+
- 技能库:用 studio_skill_list 看公司积累的技能,studio_skill_get 读全文,skill 工具加载后照着做。这次摸索出可复用的做法(命令、坑、检查清单)就用 studio_skill_save 写成一条技能,下次别的专家直接能用——不要让它停留在这次对话里。`
|
|
2521
|
+
|
|
2522
|
+
let syncTimer
|
|
2523
|
+
let activeDispatcher
|
|
2524
|
+
let activeStore = null
|
|
2525
|
+
|
|
2526
|
+
// ------------------------------------------------------------------- mount
|
|
2527
|
+
|
|
2528
|
+
/**
|
|
2529
|
+
* Mount the host half.
|
|
2530
|
+
* @param ctx - plugin context carrying tools + systemPrompt.
|
|
2531
|
+
*/
|
|
2532
|
+
export function apply(ctx) {
|
|
2533
|
+
const store = new StudioStore(dshHomePath('dsh-studio.json'), {
|
|
2534
|
+
dbFile: dshHomePath('dsh-studio.db'),
|
|
2535
|
+
secrets: new SecretStore(dshHomePath('dsh-studio.secrets.json')),
|
|
2536
|
+
snapshotLimit: 10,
|
|
2537
|
+
})
|
|
2538
|
+
const skills = new SkillLibrary(dshHomePath('dsh-studio-skills.json'))
|
|
2539
|
+
activeStore = store
|
|
2540
|
+
// 派单日志落盘:重启后「按专家看质量」的派单量与耗时还在。
|
|
2541
|
+
attachRunLog(store)
|
|
2542
|
+
activeSkills = skills
|
|
2543
|
+
|
|
2544
|
+
// 记住自己跑在哪个 profile:市场装卸与「profile xxx」展示都靠它。
|
|
2545
|
+
ctx.effect(() => {
|
|
2546
|
+
let alive = true
|
|
2547
|
+
void currentProfile().then(name => { if (alive) hostProfile = name })
|
|
2548
|
+
return () => { alive = false }
|
|
2549
|
+
}, 'dsh-studio: host profile')
|
|
2550
|
+
|
|
2551
|
+
// Materialize the library once at boot so a wiped ~/.dsh/skills heals itself.
|
|
2552
|
+
ctx.effect(() => {
|
|
2553
|
+
skills.syncAll().catch(() => {})
|
|
2554
|
+
return () => { activeSkills = undefined }
|
|
2555
|
+
}, 'dsh-studio: skill library boot sync')
|
|
2556
|
+
|
|
2557
|
+
// lark-cli 探活:启动探一次,之后每 5 分钟刷新,面板上的「飞书授权」状态靠它。
|
|
2558
|
+
ctx.effect(() => {
|
|
2559
|
+
let alive = true
|
|
2560
|
+
void larkCliReady()
|
|
2561
|
+
const timer = setInterval(() => {
|
|
2562
|
+
if (!alive) return
|
|
2563
|
+
invalidateLarkProbe()
|
|
2564
|
+
void larkCliReady()
|
|
2565
|
+
}, 300000)
|
|
2566
|
+
timer.unref?.()
|
|
2567
|
+
return () => { alive = false; clearInterval(timer) }
|
|
2568
|
+
}, 'dsh-studio: lark probe')
|
|
2569
|
+
|
|
2570
|
+
const disposeSection = ctx.systemPrompt.section({ name: 'studio-protocol', order: 70, text: PROTOCOL })
|
|
2571
|
+
ctx.effect(() => disposeSection, 'dsh-studio: protocol section')
|
|
2572
|
+
|
|
2573
|
+
configureToolHost({
|
|
2574
|
+
addDraftCases, applyConfig, auditSummary, buildAnalytics, buildMetrics, burndownReport, checkToolAccess, createTask,
|
|
2575
|
+
releaseGate, releaseNotes, skillsRoot, taskLine, toolLevel, toolOperator, upsertById, workloadReport,
|
|
2576
|
+
getActiveDispatcher: () => activeDispatcher, getActiveSkills: () => activeSkills,
|
|
2577
|
+
})
|
|
2578
|
+
const disposeTools = registerTools(ctx, store)
|
|
2579
|
+
ctx.effect(() => () => { for (const dispose of disposeTools.splice(0)) dispose() }, 'dsh-studio: tools')
|
|
2580
|
+
|
|
2581
|
+
ctx.effect(() => {
|
|
2582
|
+
syncTimer = setInterval(() => {
|
|
2583
|
+
const config = store.snapshot().config
|
|
2584
|
+
const due = (config.gitlab.projects ?? []).filter(project => project.syncEnabled === true && projectReady(config, project))
|
|
2585
|
+
if (due.length === 0) return
|
|
2586
|
+
for (const project of due) {
|
|
2587
|
+
gitlabSync(store, project.id).catch(error => {
|
|
2588
|
+
const message = String(error?.message ?? error).slice(0, 300)
|
|
2589
|
+
INTEGRATION.lastSyncError = message
|
|
2590
|
+
store.mutate(state => {
|
|
2591
|
+
const row = state.config.gitlab.projects.find(entry => entry.id === project.id)
|
|
2592
|
+
if (row !== undefined) row.lastSyncError = message
|
|
2593
|
+
}).catch(() => {})
|
|
2594
|
+
})
|
|
2595
|
+
}
|
|
2596
|
+
}, 60_000)
|
|
2597
|
+
syncTimer.unref?.()
|
|
2598
|
+
return () => {
|
|
2599
|
+
if (syncTimer !== undefined) clearInterval(syncTimer)
|
|
2600
|
+
syncTimer = undefined
|
|
2601
|
+
}
|
|
2602
|
+
}, 'dsh-studio: gitlab sync timer')
|
|
2603
|
+
|
|
2604
|
+
/** Set once the host services the dispatcher needs are available. */
|
|
2605
|
+
let dispatcher
|
|
2606
|
+
|
|
2607
|
+
configureExecutor({
|
|
2608
|
+
autoBugFromCase,
|
|
2609
|
+
probeEnv,
|
|
2610
|
+
publicState,
|
|
2611
|
+
serviceDependencies,
|
|
2612
|
+
serviceConflicts,
|
|
2613
|
+
bugSla,
|
|
2614
|
+
releaseGate,
|
|
2615
|
+
get activeDispatcher() { return activeDispatcher },
|
|
2616
|
+
healthReport,
|
|
2617
|
+
markHealth,
|
|
2618
|
+
dshHomePath,
|
|
2619
|
+
broadcastChange,
|
|
2620
|
+
INTEGRATION,
|
|
2621
|
+
resetOriginCase,
|
|
2622
|
+
})
|
|
2623
|
+
|
|
2624
|
+
ctx.inject(['webServer'], webCtx => {
|
|
2625
|
+
const disposeRoutes = registerRoutes(webCtx, store, ctx, () => dispatcher)
|
|
2626
|
+
return () => disposeRoutes()
|
|
2627
|
+
})
|
|
2628
|
+
|
|
2629
|
+
ctx.inject(['agents', 'sessionController', 'workspaceRegistry', 'permissionPresets', 'agentDefaultModel'], dispatcherCtx => {
|
|
2630
|
+
configureDispatcher({ feishuBroadcast, INTEGRATION, bugSla, removeWorktree, ensureWorktree, serviceOf, workProjectOf, serviceConflicts })
|
|
2631
|
+
configureIntegrations({ newTaskId, serviceOf, getStore: () => activeStore })
|
|
2632
|
+
// 集成健康是旁路数据,启动时读回来,重启后不再显示成「还没用过」。
|
|
2633
|
+
restoreHealth()
|
|
2634
|
+
configureExportRoutes({ sendCsv, requirementProgress, sessions: SESSIONS })
|
|
2635
|
+
configureCodingRoutes({
|
|
2636
|
+
gitlabRequest,
|
|
2637
|
+
gitlabMergeRequestDetail, applyConfig, ensureWorktree, publicState, readJsonBody, removeWorktree, sendJson, serviceOf, upsertById, workProjectOf })
|
|
2638
|
+
configureAgentsRoutes({ publicState, readJsonBody, sendJson, getDispatcher: () => activeDispatcher })
|
|
2639
|
+
configureConnectRoutes({ applyConfig, installedPackages, publicState, readJsonBody, resolvePackageName, runPluginCli, sendJson, upsertById, getSyncTimer: () => syncTimer })
|
|
2640
|
+
configureAdminRoutes({ applyConfig, checkToolAccess, operatorFromSessionId, operatorOf, publicState, readJsonBody, sendJson })
|
|
2641
|
+
configureSkillsRoutes({ isSkillName, readJsonBody, sendJson, skillFromMarkdown, skillFromZip, skillsRoot, getSkills: () => activeSkills })
|
|
2642
|
+
configureInsightsRoutes({ getDispatcher: () => activeDispatcher, sendJson, buildMetrics, buildAnalytics, workloadReport, burndownReport })
|
|
2643
|
+
configureReleasesRoutes({ sendJson, readJsonBody, publicState, releaseGate, releaseNotes, releaseRequirements, releaseRuleViolations, releaseDeploys })
|
|
2644
|
+
configureRequirementRoutes({ sendJson, readJsonBody, createTask, publicState })
|
|
2645
|
+
configureTaskRoutes({
|
|
2646
|
+
sendJson,
|
|
2647
|
+
readJsonBody,
|
|
2648
|
+
createTask,
|
|
2649
|
+
updateTask,
|
|
2650
|
+
commentTask,
|
|
2651
|
+
runTaskCases,
|
|
2652
|
+
probeEnv,
|
|
2653
|
+
syncArtifact,
|
|
2654
|
+
applyConfig,
|
|
2655
|
+
getDispatcher: () => activeDispatcher,
|
|
2656
|
+
autoBugFromCase,
|
|
2657
|
+
publicState,
|
|
2658
|
+
upsertById,
|
|
2659
|
+
})
|
|
2660
|
+
configureAuthRoutes({ sendJson, readJsonBody, recordAudit, recordLogin, issueSession, sessionOf, operatorOf, ldapAuthenticate, ldapLookup, ldapAttr, sessions: SESSIONS, dropSession: token => SESSION_STORE?.dropSession?.(token), hasSessionStore: () => SESSION_STORE !== null, publicState })
|
|
2661
|
+
dispatcher = new Dispatcher(dispatcherCtx, store)
|
|
2662
|
+
activeDispatcher = dispatcher
|
|
2663
|
+
dispatcher.start()
|
|
2664
|
+
const offStatus = dispatcherCtx.on('agent/status', ({ agent, status }) => {
|
|
2665
|
+
if (status === 'idle') dispatcher?.settle(agent.id)
|
|
2666
|
+
})
|
|
2667
|
+
return () => {
|
|
2668
|
+
dispatcher?.stop()
|
|
2669
|
+
dispatcher = undefined
|
|
2670
|
+
activeDispatcher = undefined
|
|
2671
|
+
offStatus?.()
|
|
2672
|
+
}
|
|
2673
|
+
})
|
|
2674
|
+
}
|