@marsken/dsh-studio 0.0.0-stage → 0.1.1

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.
Files changed (42) hide show
  1. package/AGENTS.md +148 -0
  2. package/LICENSE +202 -0
  3. package/README.md +1367 -3
  4. package/client.js +952 -0
  5. package/client.src.js +2466 -0
  6. package/cordis.patch.yml +9 -0
  7. package/dist/client.js +952 -0
  8. package/docs/compatibility.md +101 -0
  9. package/docs/images/board.png +0 -0
  10. package/docs/images/cases.png +0 -0
  11. package/docs/images/quality.png +0 -0
  12. package/docs/publish.md +123 -0
  13. package/docs/quickstart.md +53 -0
  14. package/icon.svg +6 -0
  15. package/index.js +2674 -0
  16. package/locale/en.json +11 -0
  17. package/locale/zh.json +11 -0
  18. package/package.json +64 -4
  19. package/scripts/smoke-store.mjs +76 -0
  20. package/src/host/cases.js +135 -0
  21. package/src/host/design.js +87 -0
  22. package/src/host/dispatcher.js +936 -0
  23. package/src/host/domain.js +1966 -0
  24. package/src/host/executor.js +1396 -0
  25. package/src/host/gitlab-facts.js +22 -0
  26. package/src/host/impact.js +32 -0
  27. package/src/host/integrations.js +587 -0
  28. package/src/host/quality.js +106 -0
  29. package/src/host/routes/admin.js +100 -0
  30. package/src/host/routes/agents.js +71 -0
  31. package/src/host/routes/auth.js +155 -0
  32. package/src/host/routes/coding.js +203 -0
  33. package/src/host/routes/connect.js +166 -0
  34. package/src/host/routes/exports.js +122 -0
  35. package/src/host/routes/insights.js +97 -0
  36. package/src/host/routes/releases.js +335 -0
  37. package/src/host/routes/requirements.js +430 -0
  38. package/src/host/routes/skills.js +75 -0
  39. package/src/host/routes/tasks.js +573 -0
  40. package/src/host/store.js +522 -0
  41. package/src/host/team-memory.js +46 -0
  42. 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
+ }