@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
@@ -0,0 +1,106 @@
1
+ // 相 ai 的质量度量:一次通过率、返工率、耗时。纯函数,口径写在 README「AI 质量的口径」一节。
2
+ //
3
+ // 三个信号都来自台账本身,不猜:
4
+ // 派单次数 / 平均耗时 —— dispatcher 的 RUN_LOG(内存里最近 300 条,重启就清零,见 README 已知限制)
5
+ // 卡数 / 完成数 —— task.expert(换列时会被列负责人覆盖,所以只有「当前负责」的卡能算)
6
+ // 返工 —— 列往回退过、AI 失败过、缺陷重开过,任一命中就算这张卡返工了
7
+
8
+ import { STATUS_IDS } from './domain.js'
9
+
10
+ const order = new Map(STATUS_IDS.map((id, index) => [id, index]))
11
+
12
+ /** 这张卡有没有返工,以及为什么。 */
13
+ export function taskRework(task) {
14
+ const reasons = []
15
+ const history = Array.isArray(task?.history) ? task.history : []
16
+ let backwards = 0
17
+ for (let index = 1; index < history.length; index += 1) {
18
+ const from = order.get(String(history[index - 1]?.status ?? ''))
19
+ const to = order.get(String(history[index]?.status ?? ''))
20
+ if (from === undefined || to === undefined) continue
21
+ if (to < from) backwards += 1
22
+ }
23
+ // 返工只认「列往回退」——和效能页那个「一次通过率」KPI 用同一条定义(index.js 的 taskTimeline.backward),
24
+ // 两处数字必须能对上。AI 失败与缺陷重开单独给出来,但不进返工率。
25
+ if (backwards > 0) reasons.push(`被打回 ${backwards} 次`)
26
+ return {
27
+ rework: backwards > 0,
28
+ backwards,
29
+ failures: Number(task?.agentFailures) || 0,
30
+ reopens: Number(task?.reopenCount) || 0,
31
+ reasons,
32
+ }
33
+ }
34
+
35
+ const rate = (part, total) => (total > 0 ? Math.round((part / total) * 100) : null)
36
+
37
+ /**
38
+ * 按专家汇总质量。runs 传 runSummary() 的结果(含 byExpert)。
39
+ * 只统计「有专家」的卡;没派过的卡不进分母。
40
+ */
41
+ export function buildQuality(state, runs) {
42
+ const tasks = Array.isArray(state?.tasks) ? state.tasks : []
43
+ const perExpert = new Map()
44
+ const touch = key => {
45
+ if (!perExpert.has(key)) perExpert.set(key, { key, cards: 0, done: 0, rework: 0, firstPass: 0 })
46
+ return perExpert.get(key)
47
+ }
48
+ for (const task of tasks) {
49
+ const expertId = String(task.expert ?? task.dispatch?.expert ?? '')
50
+ if (expertId === '') continue
51
+ const row = touch(expertId)
52
+ row.cards += 1
53
+ const done = String(task.status) === 'done'
54
+ const { rework } = taskRework(task)
55
+ if (done) row.done += 1
56
+ if (rework) row.rework += 1
57
+ if (done && !rework) row.firstPass += 1
58
+ }
59
+ const runRows = (runs?.byExpert ?? []).map(item => ({ name: String(item.key ?? ''), runs: Number(item.runs) || 0, ms: Number(item.ms) || 0 }))
60
+ const runByName = new Map(runRows.map(item => [item.name.toLowerCase(), item]))
61
+ const experts = [...perExpert.values()].map(row => {
62
+ const info = (state?.config?.experts ?? []).find(item => item.id === row.key)
63
+ const label = info === undefined ? row.key : `${info.emoji ?? ''} ${info.name}`.trim()
64
+ const run = runByName.get(String(info?.name ?? row.key).toLowerCase())
65
+ return {
66
+ expert: row.key,
67
+ label,
68
+ dispatched: run?.runs ?? 0,
69
+ avgMs: run !== undefined && run.runs > 0 ? Math.round(run.ms / run.runs) : 0,
70
+ cards: row.cards,
71
+ done: row.done,
72
+ rework: row.rework,
73
+ firstPass: row.firstPass,
74
+ firstPassRate: rate(row.firstPass, row.done),
75
+ reworkRate: rate(row.rework, row.cards),
76
+ }
77
+ }).sort((a, b) => (b.dispatched - a.dispatched) || (b.cards - a.cards))
78
+
79
+ const sum = key => experts.reduce((total, item) => total + (item[key] ?? 0), 0)
80
+ const done = sum('done')
81
+ const firstPass = sum('firstPass')
82
+ const cards = sum('cards')
83
+ return {
84
+ experts,
85
+ totals: {
86
+ dispatched: sum('dispatched'),
87
+ cards,
88
+ done,
89
+ rework: sum('rework'),
90
+ firstPass,
91
+ firstPassRate: rate(firstPass, done),
92
+ reworkRate: rate(sum('rework'), cards),
93
+ failures: tasks.reduce((total, task) => total + (Number(task.agentFailures) || 0), 0),
94
+ reopens: tasks.reduce((total, task) => total + (Number(task.reopenCount) || 0), 0),
95
+ avgMs: (() => {
96
+ const totalMs = runRows.reduce((total, item) => total + item.ms, 0)
97
+ const totalRuns = runRows.reduce((total, item) => total + item.runs, 0)
98
+ return totalRuns > 0 ? Math.round(totalMs / totalRuns) : 0
99
+ })(),
100
+ },
101
+ cost: {
102
+ wired: false,
103
+ note: '成本还没接真实 token usage:平台目前只记录派单次数与耗时,没有按卡/按项目记账。接上 usage 之后这里才会有数字。',
104
+ },
105
+ }
106
+ }
@@ -0,0 +1,100 @@
1
+ import { sanitizeAccess } from '../domain.js'
2
+ const INJECTED = ["applyConfig","checkToolAccess","operatorFromSessionId","operatorOf","publicState","readJsonBody","sendJson"]
3
+ let host = {
4
+ applyConfig: () => {},
5
+ checkToolAccess: () => {},
6
+ operatorFromSessionId: () => {},
7
+ operatorOf: () => {},
8
+ publicState: () => {},
9
+ readJsonBody: () => {},
10
+ sendJson: () => {},
11
+ }
12
+ /** 配置、权限成员、审计与快照。宿主能力由 configureAdminRoutes() 注入。 */
13
+ export function configureAdminRoutes(services) {
14
+ host = { ...host, ...(services ?? {}) }
15
+ }
16
+
17
+ export async function handleAdminRoute({ method, route, req, res, store, url }) {
18
+ if (method === 'POST' && route === '/config') {
19
+ await host.applyConfig(store, await host.readJsonBody(req))
20
+ host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
21
+ return true
22
+ }
23
+ if (method === 'POST' && route === '/access/save') {
24
+ const body = await host.readJsonBody(req)
25
+ const member = body.member !== null && typeof body.member === 'object' ? body.member : null
26
+ await store.mutate(state => {
27
+ const access = state.config.access ?? { enabled: false, agents: 'inherit', members: [] }
28
+ const wasEnabled = access.enabled === true
29
+ if (typeof body.enabled === 'boolean') access.enabled = body.enabled
30
+ if (body.agents === 'inherit' || body.agents === 'bypass') access.agents = body.agents
31
+ if (member !== null) {
32
+ const name = String(member.name ?? '').trim()
33
+ if (name !== '') {
34
+ const key = name.toLowerCase()
35
+ const rest = access.members.filter(item => item.name.toLowerCase() !== key)
36
+ access.members = sanitizeAccess({ members: [...rest, member] }).members
37
+ }
38
+ }
39
+ // 刚打开权限时,把现有专家按「成员」自动登记:否则所有 AI 会在下一张卡上被自己人拦死。
40
+ if (wasEnabled !== true && access.enabled === true) {
41
+ const known = new Set(access.members.map(item => item.name.toLowerCase()))
42
+ for (const expert of state.config.experts ?? []) {
43
+ if (expert.enabled === false) continue
44
+ const name = String(expert.name ?? '').trim()
45
+ if (name === '' || known.has(name.toLowerCase())) continue
46
+ known.add(name.toLowerCase())
47
+ access.members.push({ name, role: 'member' })
48
+ }
49
+ }
50
+ state.config.access = access
51
+ })
52
+ host.sendJson(res, 200, { ok: true, access: store.snapshot().config.access, state: host.publicState(store) })
53
+ return true
54
+ }
55
+ if (method === 'POST' && route === '/access/delete') {
56
+ const body = await host.readJsonBody(req)
57
+ const name = String(body.name ?? '').trim().toLowerCase()
58
+ await store.mutate(state => {
59
+ state.config.access.members = state.config.access.members.filter(item => item.name.toLowerCase() !== name)
60
+ })
61
+ host.sendJson(res, 200, { ok: true, access: store.snapshot().config.access, state: host.publicState(store) })
62
+ return true
63
+ }
64
+ if (method === 'POST' && route === '/audit/clear') {
65
+ await store.mutate(state => { state.audit = [] })
66
+ host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
67
+ return true
68
+ }
69
+ if (method === 'POST' && route === '/access/explain') {
70
+ const body = await host.readJsonBody(req)
71
+ const config = store.snapshot().config
72
+ const sessionId = String(body.sessionId ?? '')
73
+ const tool = String(body.tool ?? '')
74
+ const resolved = host.operatorFromSessionId(sessionId, config)
75
+ const who = host.operatorOf(req)
76
+ const operator = resolved ?? { id: who, name: who }
77
+ const verdict = host.checkToolAccess(config, operator, tool)
78
+ host.sendJson(res, 200, {
79
+ ok: true,
80
+ operator,
81
+ tool,
82
+ level: verdict.level,
83
+ allowed: verdict.denial === null,
84
+ reason: verdict.denial ?? '',
85
+ mode: config?.access?.enabled === true ? (config.access.agents ?? 'inherit') : 'off',
86
+ })
87
+ return true
88
+ }
89
+ if (method === 'GET' && route === '/snapshots') {
90
+ host.sendJson(res, 200, { ok: true, storage: store.describe(), snapshots: store.snapshots() })
91
+ return true
92
+ }
93
+ if (method === 'POST' && route === '/snapshot/restore') {
94
+ const body = await host.readJsonBody(req)
95
+ await store.restoreSnapshot(String(body.id ?? ''))
96
+ host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
97
+ return true
98
+ }
99
+ return false
100
+ }
@@ -0,0 +1,71 @@
1
+ import { ISOLATION_MODES, SANDBOX_MODES } from '../domain.js'
2
+ const INJECTED = ["getDispatcher","publicState","readJsonBody","sendJson"]
3
+ let host = {
4
+ getDispatcher: () => undefined,
5
+ publicState: () => {},
6
+ readJsonBody: () => {},
7
+ sendJson: () => {},
8
+ }
9
+ /** 派单运行态、策略与手动起停。宿主能力由 configureAgentsRoutes() 注入。 */
10
+ export function configureAgentsRoutes(services) {
11
+ host = { ...host, ...(services ?? {}) }
12
+ }
13
+
14
+ export async function handleAgentsRoute({ method, route, req, res, store, url }) {
15
+ if (method === 'GET' && route === '/agents') {
16
+ const dispatcher = host.getDispatcher()
17
+ host.sendJson(res, 200, dispatcher === undefined
18
+ ? { ok: false, error: 'dispatcher not available yet' }
19
+ : { ok: true, ...dispatcher.status() })
20
+ return true
21
+ }
22
+ if (method === 'GET' && route === '/agents/policy') {
23
+ const dispatcher = host.getDispatcher()
24
+ if (dispatcher === undefined) {
25
+ host.sendJson(res, 200, { ok: false, error: 'dispatcher not available yet' })
26
+ return true
27
+ }
28
+ const cfg = store.snapshot().config.agents
29
+ const live = []
30
+ for (const info of dispatcher.running.values()) {
31
+ let current = ''
32
+ try {
33
+ const agent = dispatcher.ctx.agents.get(info.sessionId)
34
+ current = String(dispatcher.ctx.permissionPresets.current(agent?.session))
35
+ } catch (error) {
36
+ current = `unknown: ${error?.message ?? error}`
37
+ }
38
+ live.push({ sessionId: info.sessionId, expert: info.expert, taskId: info.taskId, preset: current })
39
+ }
40
+ host.sendJson(res, 200, {
41
+ ok: true,
42
+ configured: {
43
+ sandbox: SANDBOX_MODES.includes(String(cfg.sandbox ?? '')) ? cfg.sandbox : 'workspace-write',
44
+ isolation: ISOLATION_MODES.includes(String(cfg.isolation ?? '')) ? cfg.isolation : 'none',
45
+ worktreeRoot: String(cfg.worktreeRoot ?? ''),
46
+ cleanupWorktree: String(cfg.cleanupWorktree ?? 'manual'),
47
+ },
48
+ isolation: cfg.isolation ?? 'none',
49
+ live,
50
+ })
51
+ return true
52
+ }
53
+ if (method === 'POST' && route === '/agents/run') {
54
+ const dispatcher = host.getDispatcher()
55
+ if (dispatcher === undefined) throw new Error('dispatcher not available yet')
56
+ const body = await host.readJsonBody(req)
57
+ await dispatcher.launchNow(String(body.id ?? ''), String(body.expert ?? ''))
58
+ host.sendJson(res, 200, { ok: true, state: host.publicState(store), agents: dispatcher.status() })
59
+ return true
60
+ }
61
+ if (method === 'POST' && route === '/agents/stop') {
62
+ const dispatcher = host.getDispatcher()
63
+ if (dispatcher === undefined) throw new Error('dispatcher not available yet')
64
+ const body = await host.readJsonBody(req)
65
+ const id = String(body.id ?? '')
66
+ dispatcher.abortTask(id, body.expert === undefined ? undefined : String(body.expert))
67
+ host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
68
+ return true
69
+ }
70
+ return false
71
+ }
@@ -0,0 +1,155 @@
1
+ import { hashLocalPassword, sanitizeLdap, sanitizeLocalAuth, verifyLocalPassword } from '../domain.js'
2
+
3
+ let host = {
4
+ sendJson: () => {},
5
+ readJsonBody: async () => ({}),
6
+ recordAudit: async () => {},
7
+ recordLogin: async () => {},
8
+ issueSession: () => ({ token: '', expiresAt: 0 }),
9
+ sessionOf: () => null,
10
+ operatorOf: () => '',
11
+ ldapAuthenticate: async () => ({ ok: false }),
12
+ ldapLookup: async () => ({ ok: false, entries: [] }),
13
+ ldapAttr: () => '',
14
+ sessions: new Map(),
15
+ dropSession: () => {},
16
+ hasSessionStore: () => false,
17
+ publicState: () => ({}),
18
+ }
19
+
20
+ /** 统一登录相关路由。宿主能力由 configureAuthRoutes() 注入。 */
21
+ export function configureAuthRoutes(services) {
22
+ host = { ...host, ...(services ?? {}) }
23
+ }
24
+
25
+ export async function handleAuthRoute({ method, route, req, res, store, url }) {
26
+ if (method === 'POST' && route === '/auth/login') {
27
+ const body = await host.readJsonBody(req)
28
+ const ip = String(req.socket?.remoteAddress ?? '')
29
+ const config = store.snapshot().config
30
+ const settings = sanitizeLdap(config.ldap ?? {})
31
+ /* 中文输入法会把 admin 打成全角 admin,NFKC 归一后再比对,免得
32
+ 用户明明敲对了却被告知口令不对(典型:口令框里混进全角字母/空格)。 */
33
+ const username = String(body.username ?? body.user ?? '').trim().normalize('NFKC')
34
+ const password = String(body.password ?? '').normalize('NFKC')
35
+ const wideChar = /[\uFF01-\uFF5E\u3000]/.test(String(body.password ?? ''))
36
+ // mode: local = 只试本地账号;ldap = 只试目录;'' = 先本地后目录(老调用方兼容)
37
+ const mode = String(body.mode ?? '').trim().toLowerCase()
38
+
39
+ /* 先试本地超管:没配 LDAP 时它是唯一账号,配了也能在目录故障时兜底。 */
40
+ const local = mode === 'ldap' ? { ok: false, reason: 'skipped' } : verifyLocalPassword(config.localAuth, username, password)
41
+ if (local.ok === true) {
42
+ const localUser = sanitizeLocalAuth(config.localAuth)
43
+ const user = { id: localUser.username, name: '超级管理员', role: 'admin', source: 'local', mail: '', dept: '' }
44
+ const session = host.issueSession(user, ip)
45
+ await host.recordAudit(store, { by: user.name, route, level: 'read', status: 200, ok: true, summary: `本地超管登录成功:${user.id}` })
46
+ host.sendJson(res, 200, { ok: true, token: session.token, expiresAt: session.expiresAt, user, state: host.publicState(store) })
47
+ return true
48
+ }
49
+
50
+ if (mode === 'local') {
51
+ const localUser = sanitizeLocalAuth(config.localAuth)
52
+ const hint =
53
+ local.reason === 'nouser'
54
+ ? `没有这个本地账号(本地超管是 ${localUser.username})`
55
+ : local.reason === 'form'
56
+ ? '账号和密码都要填'
57
+ : wideChar
58
+ ? `口令里有全角字符(中文输入法打出来的),切到英文输入法再输一次;账号 ${localUser.username}${localUser.password === null ? ',默认口令是 admin' : ''}`
59
+ : `本地超管的账号或口令不对(账号 ${localUser.username}${localUser.password === null ? ',还没改过口令就是默认的 admin' : ''})`
60
+ await host.recordAudit(store, { by: username === '' ? '匿名' : username.slice(0, 40), route, level: 'read', status: 401, ok: false, summary: `本地登录失败:${hint}` })
61
+ host.sendJson(res, 401, { ok: false, code: 'local', error: hint })
62
+ return true
63
+ }
64
+
65
+ const result = await host.ldapAuthenticate(settings, username, password)
66
+ if (result.ok !== true) {
67
+ const hint =
68
+ result.code === 'disabled' && local.reason === 'badpass'
69
+ ? `用户名或密码不对(本地超管账号是 ${sanitizeLocalAuth(config.localAuth).username};LDAP 登录还没启用)`
70
+ : result.message
71
+ await host.recordAudit(store, { by: username === '' ? '匿名' : username.slice(0, 40), route, level: 'read', status: 401, ok: false, summary: `登录失败:${hint}` })
72
+ host.sendJson(res, 401, { ok: false, code: result.code, error: hint })
73
+ return true
74
+ }
75
+ await host.recordLogin(store, result.user, ip)
76
+ const session = host.issueSession(result.user, ip)
77
+ await host.recordAudit(store, { by: result.user.name, route, level: 'read', status: 200, ok: true, summary: `LDAP 登录成功:${result.user.id}` })
78
+ const saved = (store.snapshot().config.users ?? []).find(item => item.id === result.user.id) ?? {}
79
+ host.sendJson(res, 200, { ok: true, token: session.token, expiresAt: session.expiresAt, user: { ...result.user, ...saved }, state: host.publicState(store) })
80
+ return true
81
+ }
82
+ if (method === 'POST' && route === '/auth/password') {
83
+ const body = await host.readJsonBody(req)
84
+ const config = store.snapshot().config
85
+ const local = sanitizeLocalAuth(config.localAuth)
86
+ const username = String(body.username ?? local.username).trim().toLowerCase().normalize('NFKC')
87
+ const currentPassword = String(body.currentPassword ?? '').normalize('NFKC')
88
+ const check = verifyLocalPassword(local, username, currentPassword)
89
+ if (check.ok !== true) {
90
+ host.sendJson(res, 401, { ok: false, code: check.reason, error: check.reason === 'nouser' ? '没有这个本地账号' : '当前密码不对' })
91
+ return true
92
+ }
93
+ const next = String(body.newPassword ?? '').normalize('NFKC')
94
+ if (body.reset !== true && next.length < 8) {
95
+ host.sendJson(res, 400, { ok: false, error: '新密码至少 8 位' })
96
+ return true
97
+ }
98
+ if (body.reset !== true && next === currentPassword) {
99
+ host.sendJson(res, 400, { ok: false, error: '新密码和当前密码一样' })
100
+ return true
101
+ }
102
+ await store.mutate(state => {
103
+ const current = sanitizeLocalAuth(state.config.localAuth)
104
+ state.config.localAuth =
105
+ body.reset === true
106
+ ? { ...current, password: null, mustChange: true, updatedAt: Date.now() }
107
+ : { ...current, password: hashLocalPassword(next), mustChange: false, updatedAt: Date.now() }
108
+ })
109
+ await host.recordAudit(store, { by: host.operatorOf(req), route, level: 'admin', status: 200, ok: true, summary: body.reset === true ? '重置本地超管密码为默认' : '修改本地超管密码' })
110
+ host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
111
+ return true
112
+ }
113
+ if (method === 'GET' && route === '/auth/me') {
114
+ const found = host.sessionOf(req, store.snapshot().config)
115
+ if (found === null) {
116
+ host.sendJson(res, 401, { ok: false, error: '没有登录或会话已过期' })
117
+ return true
118
+ }
119
+ host.sendJson(res, 200, { ok: true, user: found.user, expiresAt: found.session.expiresAt })
120
+ return true
121
+ }
122
+ if (method === 'POST' && route === '/auth/logout') {
123
+ const rawToken = req.headers['x-studio-session']
124
+ const token = String((Array.isArray(rawToken) ? rawToken[0] : rawToken) ?? '').trim()
125
+ if (token !== '') {
126
+ host.sessions.delete(token)
127
+ if (host.hasSessionStore()) host.dropSession(token)
128
+ }
129
+ host.sendJson(res, 200, { ok: true })
130
+ return true
131
+ }
132
+ if (method === 'POST' && route === '/auth/ldap/test') {
133
+ const body = await host.readJsonBody(req)
134
+ const config = store.snapshot().config
135
+ const settings = sanitizeLdap({ ...(config.ldap ?? {}), ...(body.ldap !== null && typeof body.ldap === 'object' ? body.ldap : {}) })
136
+ if (String(body.bindPassword ?? '') !== '') settings.bindPassword = String(body.bindPassword)
137
+ const steps = []
138
+ const started = Date.now()
139
+ const lookup = await host.ldapLookup(settings, String(body.username ?? '').trim() || '*')
140
+ steps.push({ name: '服务账号绑定并查询', ok: lookup.ok === true, message: lookup.ok === true ? `查到 ${lookup.entries.length} 条` : lookup.message, ms: Date.now() - started })
141
+ if (lookup.ok === true && lookup.entries.length > 0) {
142
+ const entry = lookup.entries[0]
143
+ steps.push({
144
+ name: '第一个命中的账号',
145
+ ok: true,
146
+ message: `${host.ldapAttr(entry, 'sAMAccountName') || host.ldapAttr(entry, 'uid') || '(无 sAMAccountName)'} · ${host.ldapAttr(entry, settings.nameAttr) || host.ldapAttr(entry, 'cn') || '(无显示名)'} · ${entry.dn}`,
147
+ ms: 0,
148
+ })
149
+ }
150
+ const ok = steps.every(step => step.ok === true)
151
+ host.sendJson(res, ok ? 200 : 400, { ok, steps, message: ok ? '连接正常,可以保存' : steps.find(step => step.ok !== true)?.message ?? '测试没通过' })
152
+ return true
153
+ }
154
+ return false
155
+ }
@@ -0,0 +1,203 @@
1
+ import { CODING_REVIEW_STATUS, cleanId, mergeCoding, sanitizeCoding } from '../domain.js'
2
+ import { clearAgentFailure, noteAgentFailure } from '../dispatcher.js'
3
+ import { codingVerifyLine, runCommand, taskExitGate, verifyCodingRecord } from '../executor.js'
4
+ import { compareCodingFacts } from '../gitlab-facts.js'
5
+ const INJECTED = ["applyConfig","ensureWorktree","publicState","readJsonBody","removeWorktree","sendJson","serviceOf","upsertById","workProjectOf"]
6
+ let host = {
7
+ applyConfig: () => {},
8
+ ensureWorktree: () => {},
9
+ publicState: () => {},
10
+ readJsonBody: () => {},
11
+ removeWorktree: () => {},
12
+ sendJson: () => {},
13
+ serviceOf: () => {},
14
+ upsertById: () => {},
15
+ workProjectOf: () => {},
16
+ }
17
+ /** 执行记录核对、Agent 失败重试、worktree 与项目配置。宿主能力由 configureCodingRoutes() 注入。 */
18
+ export function configureCodingRoutes(services) {
19
+ host = { ...host, ...(services ?? {}) }
20
+ }
21
+
22
+ export async function handleCodingRoute({ method, route, req, res, store, url }) {
23
+ if (method === 'POST' && route === '/coding/gitlab') {
24
+ // 从 GitLab 核对:拉 MR + changes,和卡上自报的文件数/提交数比一遍,结果记进 coding.gitlab。
25
+ const body = await host.readJsonBody(req)
26
+ const id = String(body.id ?? '')
27
+ const snapshot = store.snapshot()
28
+ const task = snapshot.tasks.find(t => t.id === id)
29
+ if (task === undefined) throw new Error(`no task ${id}`)
30
+ const iid = Number(task.gitlabMR?.iid ?? 0)
31
+ if (iid <= 0) throw new Error('这张卡还没关联 MR:先提 MR,把 !iid 填进卡片的 GitLab 区块')
32
+ const config = snapshot.config
33
+ const projects = config.gitlab?.projects ?? []
34
+ const project = projects.find(item => item.id === task.gitlabProject) ?? projects[0]
35
+ if (project === undefined) throw new Error('还没配 GitLab 项目(设置 → 代码仓库)')
36
+ const mr = await host.gitlabRequest(config, project, `/merge_requests/${iid}`)
37
+ const detail = await host.gitlabMergeRequestDetail(config, project, iid)
38
+ const facts = compareCodingFacts(task.coding, mr, detail)
39
+ await store.mutate(state => {
40
+ const target = state.tasks.find(t => t.id === id)
41
+ if (target === undefined) return
42
+ target.coding = { ...(target.coding ?? {}), gitlab: facts }
43
+ target.updatedAt = Date.now()
44
+ })
45
+ host.sendJson(res, 200, { ok: true, facts, state: host.publicState(store) })
46
+ return true
47
+ }
48
+ if (method === 'POST' && route === '/task/coding') {
49
+ const body = await host.readJsonBody(req)
50
+ const id = String(body.id ?? '')
51
+ const action = String(body.action ?? 'update')
52
+ const by = String(body.by ?? '人').slice(0, 40)
53
+ const snapshot = await store.mutate(state => {
54
+ const task = state.tasks.find(t => t.id === id)
55
+ if (task === undefined) throw new Error(`no task ${id}`)
56
+ if (action === 'clear') {
57
+ task.coding = null
58
+ } else if (action === 'review') {
59
+ const base = sanitizeCoding(task.coding ?? {})
60
+ const status = CODING_REVIEW_STATUS.includes(String(body.status)) ? String(body.status) : 'approved'
61
+ task.coding = sanitizeCoding({
62
+ ...base,
63
+ review: { status, by, at: Date.now(), note: String(body.note ?? '').slice(0, 600) },
64
+ })
65
+ } else if (action === 'merge') {
66
+ const base = sanitizeCoding(task.coding ?? {})
67
+ task.coding = sanitizeCoding({ ...base, mergedBy: by, mergedAt: Date.now() })
68
+ } else {
69
+ const patch = body.coding ?? body.patch ?? body
70
+ task.coding = mergeCoding(task.coding ?? null, patch)
71
+ }
72
+ task.updatedAt = Date.now()
73
+ return task
74
+ })
75
+ const task = snapshot.tasks.find(entry => entry.id === id)
76
+ host.sendJson(res, 200, { ok: true, task, state: host.publicState(store) })
77
+ return true
78
+ }
79
+ if (method === 'POST' && route === '/task/coding/verify') {
80
+ // 拿 worktree 里的 git 事实核对执行记录:agent 说改了哪些提交/文件,真的在分支上吗。
81
+ const body = await host.readJsonBody(req)
82
+ const id = String(body.id ?? '')
83
+ const current = store.snapshot().tasks.find(entry => entry.id === id)
84
+ if (current === undefined) throw new Error(`no task ${id}`)
85
+ const report = await verifyCodingRecord(store.snapshot(), current)
86
+ if (report.skipped === undefined) {
87
+ await store.mutate(state => {
88
+ const task = state.tasks.find(entry => entry.id === id)
89
+ if (task !== undefined && task.coding !== null && task.coding !== undefined) {
90
+ task.coding = sanitizeCoding({ ...task.coding, verified: report })
91
+ }
92
+ return task
93
+ })
94
+ }
95
+ const state = store.snapshot()
96
+ const task = state.tasks.find(entry => entry.id === id)
97
+ host.sendJson(res, 200, {
98
+ ok: true,
99
+ report,
100
+ line: codingVerifyLine(report),
101
+ gate: taskExitGate(state, task),
102
+ task,
103
+ state: host.publicState(store),
104
+ })
105
+ return true
106
+ }
107
+ if (method === 'POST' && route === '/task/agent/fail') {
108
+ // 记一次 AI 失败(真失败时由派单器调用;也可由 agent 自己上报)。
109
+ const body = await host.readJsonBody(req)
110
+ const id = String(body.id ?? '')
111
+ const plan = await noteAgentFailure(store, id, String(body.error ?? 'AI 没有完成这一轮'), String(body.by ?? '系统').slice(0, 60))
112
+ if (plan === undefined) throw new Error(`no task ${id}`)
113
+ const state = store.snapshot()
114
+ const task = state.tasks.find(entry => entry.id === id)
115
+ host.sendJson(res, 200, { ok: true, plan, task, gate: taskExitGate(state, task), state: host.publicState(store) })
116
+ return true
117
+ }
118
+ if (method === 'POST' && route === '/task/agent/retry') {
119
+ const body = await host.readJsonBody(req)
120
+ const id = String(body.id ?? '')
121
+ const changed = await clearAgentFailure(store, id, String(body.by ?? '人').slice(0, 60))
122
+ if (!changed) throw new Error(`no task ${id}`)
123
+ const state = store.snapshot()
124
+ const task = state.tasks.find(entry => entry.id === id)
125
+ host.sendJson(res, 200, { ok: true, task, state: host.publicState(store) })
126
+ return true
127
+ }
128
+ if (method === 'POST' && route === '/task/worktree') {
129
+ const body = await host.readJsonBody(req)
130
+ const id = String(body.id ?? '')
131
+ const action = String(body.action ?? 'create')
132
+ const snapshotNow = store.snapshot()
133
+ const task = snapshotNow.tasks.find(entry => entry.id === id)
134
+ if (task === undefined) throw new Error(`no task ${id}`)
135
+ if (action === 'remove') {
136
+ const result = await host.removeWorktree(store, task, snapshotNow.config)
137
+ host.sendJson(res, 200, { ok: result.ok, result, state: host.publicState(store) })
138
+ return true
139
+ }
140
+ if (action === 'list') {
141
+ const service = host.serviceOf(snapshotNow.config, task)
142
+ const project = host.workProjectOf(snapshotNow.config, task)
143
+ const repo = String(service?.repoPath ?? project?.repoPath ?? '').trim()
144
+ const listed = repo.length === 0
145
+ ? { ok: false, stdout: '', stderr: '项目没配本机代码目录' }
146
+ : await runCommand('git', ['-C', repo, 'worktree', 'list', '--porcelain'], 20000)
147
+ host.sendJson(res, 200, { ok: listed.ok, output: String(listed.stdout ?? ''), error: String(listed.stderr ?? ''), state: host.publicState(store) })
148
+ return true
149
+ }
150
+ const info = await host.ensureWorktree(store, task, snapshotNow.config)
151
+ host.sendJson(res, 200, {
152
+ ok: info?.path !== undefined,
153
+ worktree: info ?? null,
154
+ skipped: info?.skipped ?? '',
155
+ state: host.publicState(store),
156
+ })
157
+ return true
158
+ }
159
+ if (method === 'POST' && route === '/projects/save') {
160
+ const body = await host.readJsonBody(req)
161
+ const incoming = body.project ?? body
162
+ const existing = store.snapshot().config.projects
163
+ const wantedId = cleanId(incoming.id ?? incoming.name ?? incoming.key, 'proj')
164
+ const saved = existing.find(item => item.id === wantedId)
165
+ const merged = { ...incoming, id: wantedId, iterations: incoming.iterations ?? saved?.iterations ?? [] }
166
+ await host.applyConfig(store, { projects: host.upsertById(existing, merged) })
167
+ host.sendJson(res, 200, { ok: true, projects: store.snapshot().config.projects, state: host.publicState(store) })
168
+ return true
169
+ }
170
+ if (method === 'POST' && route === '/projects/delete') {
171
+ const body = await host.readJsonBody(req)
172
+ const id = cleanId(body.id, 'proj')
173
+ await host.applyConfig(store, { projects: store.snapshot().config.projects.filter(entry => entry.id !== id) })
174
+ host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
175
+ return true
176
+ }
177
+ if (method === 'POST' && route === '/projects/iteration') {
178
+ const body = await host.readJsonBody(req)
179
+ const action = String(body.action ?? 'save')
180
+ const projectId = cleanId(body.project, 'proj')
181
+ const projects = store.snapshot().config.projects.map(project => {
182
+ if (project.id !== projectId) return project
183
+ if (action === 'remove') {
184
+ return { ...project, iterations: project.iterations.filter(item => item.id !== cleanId(body.iteration, 'iter')) }
185
+ }
186
+ const wantedId = cleanId(body.iteration ?? body.name, 'iter')
187
+ const saved = project.iterations.find(item => item.id === wantedId)
188
+ const merged = {
189
+ ...saved,
190
+ id: wantedId,
191
+ name: String(body.name ?? saved?.name ?? wantedId).slice(0, 60),
192
+ start: Number(body.start ?? saved?.start) || 0,
193
+ due: Number(body.due ?? saved?.due) || 0,
194
+ status: ['planned', 'active', 'closed'].includes(String(body.status)) ? String(body.status) : (saved?.status ?? 'planned'),
195
+ }
196
+ return { ...project, iterations: host.upsertById(project.iterations, merged) }
197
+ })
198
+ await host.applyConfig(store, { projects })
199
+ host.sendJson(res, 200, { ok: true, state: host.publicState(store) })
200
+ return true
201
+ }
202
+ return false
203
+ }