@marsken/dsh-studio 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +148 -0
- package/LICENSE +202 -0
- package/README.md +1356 -3
- package/client.js +952 -0
- package/client.src.js +2466 -0
- package/cordis.patch.yml +9 -0
- package/dist/client.js +952 -0
- package/docs/compatibility.md +99 -0
- package/docs/publish.md +86 -0
- package/docs/quickstart.md +53 -0
- package/icon.svg +6 -0
- package/index.js +2674 -0
- package/locale/en.json +11 -0
- package/locale/zh.json +11 -0
- package/package.json +63 -4
- package/scripts/smoke-store.mjs +76 -0
- package/src/host/cases.js +135 -0
- package/src/host/design.js +87 -0
- package/src/host/dispatcher.js +936 -0
- package/src/host/domain.js +1966 -0
- package/src/host/executor.js +1396 -0
- package/src/host/gitlab-facts.js +22 -0
- package/src/host/impact.js +32 -0
- package/src/host/integrations.js +587 -0
- package/src/host/quality.js +106 -0
- package/src/host/routes/admin.js +100 -0
- package/src/host/routes/agents.js +71 -0
- package/src/host/routes/auth.js +155 -0
- package/src/host/routes/coding.js +203 -0
- package/src/host/routes/connect.js +166 -0
- package/src/host/routes/exports.js +122 -0
- package/src/host/routes/insights.js +97 -0
- package/src/host/routes/releases.js +335 -0
- package/src/host/routes/requirements.js +430 -0
- package/src/host/routes/skills.js +75 -0
- package/src/host/routes/tasks.js +573 -0
- package/src/host/store.js +522 -0
- package/src/host/team-memory.js +46 -0
- package/src/host/tools.js +1211 -0
|
@@ -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
|
+
}
|