@marsken/dsh-studio 0.1.10 → 0.1.12

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.
@@ -6,7 +6,7 @@
6
6
 
7
7
  | 项 | 值 |
8
8
  | --- | --- |
9
- | dsh-studio | `package.json` `version: 0.1.10` |
9
+ | dsh-studio | `package.json` `version: 0.1.12` |
10
10
  | DeepSeek Harness | `0.2.2-rc.1`(`dsh --version`) |
11
11
  | Node | `v24.22.2`(Linux 目标机);本机 Windows `v24.19.0` |
12
12
  | 目标系统 | Ubuntu 24.04,`dsh web` 监听 `18080`,nginx `:80` 反代并注入 `dsh-auth-*` cookie |
package/docs/publish.md CHANGED
@@ -127,6 +127,8 @@ token 只写进临时 npmrc(放在仓库外或 `tmp/` 且不提交),发完
127
127
  | --- | --- | --- |
128
128
  | `0.1.0` | 2026-11-13 | 首个公开版:看板 / 需求 / 用例回归 / 效能 / 设计评审四期 |
129
129
  | `0.1.1` | 2026-11-13 | 补三张 README 截图(npm 页可看图);默认值与文档去掉公司内网信息 |
130
+ | `0.1.12` | 2026-11-15 | 真实 token 成本(读会话日志 + 价目表计价) |
131
+ | `0.1.11` | 2026-11-15 | 需求变更影响面(接口 + 面板)、可跑用例口径修正、成本口径文档 |
130
132
  | `0.1.10` | 2026-11-15 | shell/db 用例可跑、GitLab 核对结果常驻显示、事件带 truncated、角色归并 |
131
133
  | `0.1.9` | 2026-11-14 | 准出门禁提示「代码事实核对不通过」(只提醒,不拦) |
132
134
  | `0.1.8` | 2026-11-14 | 卡进「待评审」时自动核对代码事实,对不上就写进 history 并记 gate/blocked |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marsken/dsh-studio",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "type": "module",
5
5
  "description": "DSH Studio: role-based multi-agent workspace (product / architect / developer / QA) with a live work board, GitLab + Feishu connection settings, all configurable from the web GUI.",
6
6
  "main": "index.js",
@@ -11,7 +11,9 @@
11
11
  "scripts": {
12
12
  "test": "node --test tests/repo.test.mjs tests/store-boot.test.mjs tests/client-units.test.mjs tests/notify.test.mjs",
13
13
  "check": "node --check index.js && node --check client.src.js && node --test tests/repo.test.mjs tests/store-boot.test.mjs tests/client-units.test.mjs tests/notify.test.mjs",
14
- "smoke:store": "node scripts/smoke-store.mjs"
14
+ "smoke:store": "node scripts/smoke-store.mjs",
15
+ "mock:gitlab": "node tools/mock-gitlab.mjs",
16
+ "verify:gitlab": "node tools/verify-gitlab-facts.mjs"
15
17
  },
16
18
  "exports": {
17
19
  ".": "./index.js",
@@ -1,6 +1,7 @@
1
1
  /** dsh-studio 的领域层:板列、卡片/需求/发布的数据形状与清洗、默认配置、编号生成。纯函数,可单测。 */
2
2
  import { createHash, randomBytes } from 'node:crypto'
3
3
  import { sanitizeDbCase } from './db-runner.js'
4
+ import { sanitizePrices } from './usage.js'
4
5
  import { sanitizeWebhooks } from './webhooks.js'
5
6
  import { sanitizeCaseShell } from './shell-case.js'
6
7
  import { LIMIT_DEFAULTS, sanitizeLimits } from './limits.js'
@@ -465,17 +466,16 @@ export function sanitizeSla(raw) {
465
466
  }
466
467
  return out
467
468
  }
468
-
469
- /** 成本预算:跑多少轮、机上多少小时,超过 warnAt% 就在界面标黄。 */
469
+ /** 成本预算 + 价目表(元/百万 token;空表只报 token 不报钱)。 */
470
470
  export function sanitizeCosts(raw) {
471
471
  const input = raw !== null && typeof raw === 'object' ? raw : {}
472
472
  const budgetRuns = Number(input.budgetRuns)
473
- const budgetHours = Number(input.budgetHours)
474
- const warnAt = Number(input.warnAt)
473
+ const budgetHours = Number(input.budgetHours), warnAt = Number(input.warnAt)
475
474
  return {
476
475
  budgetRuns: Number.isFinite(budgetRuns) && budgetRuns > 0 ? Math.min(Math.round(budgetRuns), 100000) : 500,
477
476
  budgetHours: Number.isFinite(budgetHours) && budgetHours > 0 ? Math.min(Math.round(budgetHours), 100000) : 80,
478
477
  warnAt: Number.isFinite(warnAt) && warnAt > 0 ? Math.max(10, Math.min(Math.round(warnAt), 100)) : 80,
478
+ prices: sanitizePrices(input.prices), // 元/百万 token;空表只报 token 不报钱
479
479
  }
480
480
  }
481
481
  export function cleanId(value, prefix) {
@@ -13,7 +13,9 @@ export function casesOfTasks(tasks, limit = 40) {
13
13
  key: `${task.id}:${item.id}`,
14
14
  title: String(item.title ?? '').slice(0, 160),
15
15
  result: String(item.result ?? 'pending'),
16
- http: item.http === true,
16
+ // 能自动跑的判断要和执行器一致:接口 / shell / 数据库任一都算。
17
+ http: item.http !== null && item.http !== undefined,
18
+ runnable: item.http != null || item.shell != null || item.db != null,
17
19
  })
18
20
  }
19
21
  }
@@ -26,7 +28,7 @@ export function impactSummary(tasks) {
26
28
  return {
27
29
  cards: (Array.isArray(tasks) ? tasks : []).length,
28
30
  cases: cases.length,
29
- runnable: cases.filter(item => item.http).length,
31
+ runnable: cases.filter(item => item.runnable === true).length,
30
32
  failing: cases.filter(item => item.result === 'fail').length,
31
33
  }
32
34
  }
@@ -2,6 +2,8 @@ import {
2
2
  buildDashboard,
3
3
  } from '../executor.js'
4
4
  import { runSummary } from '../dispatcher.js'
5
+ import { collectSessionUsage, aggregateUsage } from '../session-usage.js'
6
+ import { costOf } from '../usage.js'
5
7
  const INJECTED = ['getDispatcher', "buildAnalytics","buildMetrics","burndownReport","sendJson","workloadReport"]
6
8
  let host = {
7
9
  getDispatcher: () => undefined,
@@ -74,9 +76,12 @@ export async function handleInsightsRoute({ method, route, req, res, store, url
74
76
  const level = Math.max(runRate, hourRate) >= 100
75
77
  ? 'over'
76
78
  : Math.max(runRate, hourRate) >= budget.warnAt ? 'warn' : 'ok'
79
+ // 真实 token 用量:读 studio-* 会话日志,按卡 / 按专家汇总;价目表为空时只给 token。
80
+ const usage = aggregateUsage(await collectSessionUsage(), store.snapshot().tasks, budget.prices ?? [], costOf)
77
81
  host.sendJson(res, 200, {
78
82
  ok: true,
79
83
  costs: summary,
84
+ usage,
80
85
  budget: {
81
86
  ...budget,
82
87
  runs,
@@ -7,6 +7,7 @@ import {
7
7
  sanitizeRequirementPool,
8
8
  text,
9
9
  } from '../domain.js'
10
+ import { casesOfTasks, impactSummary } from '../impact.js'
10
11
  import {
11
12
  ensureReqFolder,
12
13
  mutateReqDoc,
@@ -405,7 +406,25 @@ export async function handleRequirementRoute({ method, route, req, res, store, u
405
406
  host.sendJson(res, 200, { ok: true, requirement: result, state: host.publicState(store) })
406
407
  return true
407
408
  }
408
- if (method === 'POST' && route === '/requirement/pull') {
409
+ if (method === 'POST' && route === '/requirement/impact') {
410
+ // 需求一改,这条需求下面的卡和用例就要重看。这里只算、不改数据。
411
+ const body = await host.readJsonBody(req)
412
+ const id = String(body.id ?? '')
413
+ const snapshot = store.snapshot()
414
+ const requirement = (snapshot.requirements ?? []).find(item => item.id === id)
415
+ if (requirement === undefined) throw new Error(`no requirement ${id}`)
416
+ const tasks = (snapshot.tasks ?? []).filter(task => task.requirementId === id)
417
+ const summary = impactSummary(tasks)
418
+ const cases = casesOfTasks(tasks, 60)
419
+ host.sendJson(res, 200, {
420
+ ok: true,
421
+ summary,
422
+ cases,
423
+ cards: tasks.map(task => ({ id: task.id, code: task.code ?? '', title: task.title ?? '', status: task.status ?? '' })),
424
+ })
425
+ return true
426
+ }
427
+ if (method === 'POST' && route === '/requirement/pull') {
409
428
  const body = await host.readJsonBody(req)
410
429
  const result = await pullFeishuDoc(body.url ?? body.docUrl ?? '')
411
430
  host.sendJson(res, 200, { ok: true, ...result })
@@ -0,0 +1,100 @@
1
+ // 从 dsh 的会话日志里读用量:<home>/.dsh/sessions/<ws>/studio-<taskId>-<expert>/session.v4.jsonl.zstd
2
+ // 用 zstd 解压(宿主上本来就有),按 mtime 缓存,避免每刷新一次效能页都重解一遍。
3
+ import { execFile } from 'node:child_process'
4
+ import { promisify } from 'node:util'
5
+ import { readdir, stat } from 'node:fs/promises'
6
+ import { homedir } from 'node:os'
7
+ import { join } from 'node:path'
8
+ import { parseUsageLines, summarizeUsage } from './usage.js'
9
+
10
+ const run = promisify(execFile)
11
+ const CACHE = new Map()
12
+
13
+ export function sessionsRoot() {
14
+ return join(homedir(), '.dsh', 'sessions')
15
+ }
16
+
17
+ /** studio-<taskId>-<expertId>:taskId 形如 t-01a3c8d3,剩下都算专家。 */
18
+ export function parseSessionName(name) {
19
+ const match = /^studio-(t-[a-z0-9]+)-(.+)$/.exec(String(name ?? ''))
20
+ if (match === null) return null
21
+ return { taskId: match[1], expert: match[2] }
22
+ }
23
+
24
+ async function readFileUsage(file) {
25
+ let mtimeMs = 0
26
+ try { mtimeMs = (await stat(file)).mtimeMs } catch { return null }
27
+ const cached = CACHE.get(file)
28
+ if (cached !== undefined && cached.mtimeMs === mtimeMs) return cached.usage
29
+ let usage = []
30
+ try {
31
+ const { stdout } = await run('zstd', ['-dc', file], { maxBuffer: 256 * 1024 * 1024 })
32
+ usage = parseUsageLines(stdout.split('\n'))
33
+ } catch {
34
+ // 没装 zstd 或者文件坏了:当作读不到,不抛,页面上显示未知
35
+ usage = []
36
+ }
37
+ CACHE.set(file, { mtimeMs, usage })
38
+ return usage
39
+ }
40
+
41
+ /** 扫一遍所有 studio-* 会话,返回 [{ taskId, expert, at, usage }]。 */
42
+ export async function collectSessionUsage(root = sessionsRoot()) {
43
+ const out = []
44
+ let workspaces = []
45
+ try { workspaces = await readdir(root) } catch { return out }
46
+ for (const workspace of workspaces) {
47
+ const dir = join(root, workspace)
48
+ let entries = []
49
+ try { entries = await readdir(dir) } catch { continue }
50
+ for (const entry of entries) {
51
+ const parsed = parseSessionName(entry)
52
+ if (parsed === null) continue
53
+ const usage = await readFileUsage(join(dir, entry, 'session.v4.jsonl.zstd'))
54
+ if (usage === null || usage.length === 0) continue
55
+ out.push({ taskId: parsed.taskId, expert: parsed.expert, usage })
56
+ }
57
+ }
58
+ return out
59
+ }
60
+
61
+ /** 按卡 / 按专家汇总 token 与钱;prices 来自 config.costs.prices。 */
62
+ export function aggregateUsage(sessions, tasks, prices, costOf) {
63
+ const byCard = new Map()
64
+ const byExpert = new Map()
65
+ const totals = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
66
+ for (const session of sessions) {
67
+ const summary = summarizeUsage(session.usage)
68
+ const money = costOf(prices, session.usage)
69
+ for (const key of ['calls', 'input', 'output', 'cacheRead', 'cacheWrite', 'total']) totals[key] += summary[key]
70
+ const card = byCard.get(session.taskId) ?? { taskId: session.taskId, ...{ calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0, unknownCalls: 0 }
71
+ for (const key of ['calls', 'input', 'output', 'cacheRead', 'cacheWrite', 'total']) card[key] += summary[key]
72
+ if (money.cost !== null) card.cost += money.cost
73
+ card.unknownCalls += money.unknownCalls
74
+ byCard.set(session.taskId, card)
75
+ const expert = byExpert.get(session.expert) ?? { expert: session.expert, calls: 0, total: 0, cost: 0, unknownCalls: 0 }
76
+ expert.calls += summary.calls
77
+ expert.total += summary.total
78
+ if (money.cost !== null) expert.cost += money.cost
79
+ expert.unknownCalls += money.unknownCalls
80
+ byExpert.set(session.expert, expert)
81
+ }
82
+ const taskOf = new Map((Array.isArray(tasks) ? tasks : []).map((task) => [String(task.id ?? ''), task]))
83
+ const cards = [...byCard.values()].map((card) => {
84
+ const task = taskOf.get(card.taskId)
85
+ return { ...card, code: String(task?.code ?? ''), title: String(task?.title ?? ''), project: String(task?.project ?? '') }
86
+ }).sort((a, b) => b.total - a.total)
87
+ const round = (value) => Math.round(value * 10000) / 10000
88
+ return {
89
+ totals: {
90
+ ...totals,
91
+ // 有算不出价的调用且一分钱都没算出来时,给 null(未知),不要报成 0
92
+ cost: cards.reduce((sum, card) => sum + card.unknownCalls, 0) > 0 && cards.reduce((sum, card) => sum + card.cost, 0) === 0
93
+ ? null
94
+ : round(cards.reduce((sum, card) => sum + card.cost, 0)),
95
+ unknownCalls: cards.reduce((sum, card) => sum + card.unknownCalls, 0),
96
+ },
97
+ cards: cards.slice(0, 50),
98
+ experts: [...byExpert.values()].map((row) => ({ ...row, cost: round(row.cost) })).sort((a, b) => b.total - a.total),
99
+ }
100
+ }
@@ -0,0 +1,124 @@
1
+ // 会话用量解析:从 dsh 的 session.v4.jsonl 里把每次模型调用的 token 取出来。
2
+ // 纯函数,方便单测;读文件那半在 session-usage.js。
3
+ //
4
+ // 日志形状(实测):
5
+ // {"type":"assistant/message","seq":19,"time":...,"data":{"turn":1,"step":1,"message":{...},
6
+ // "usage":{"inputTokens":3033,"outputTokens":116,"cacheReadTokens":17024,"cacheWriteTokens":0,"totalTokens":20173},
7
+ // "stream":[{"type":"chunk","chunk":{"type":"usage",...}}]}}
8
+ // 同一条消息里 usage 会出现两次(消息体 + 流式 chunk),所以只认 assistant/message 上的那一份。
9
+
10
+ /** 从日志里挑出一条记录对应的用量;不是 assistant/message 就返回 null。 */
11
+ export function usageOfEntry(entry) {
12
+ if (entry === null || typeof entry !== 'object') return null
13
+ if (entry.type !== 'assistant/message') return null
14
+ const data = entry.data ?? {}
15
+ const usage = data.usage ?? data.message?.usage ?? null
16
+ if (usage === null || typeof usage !== 'object') return null
17
+ const number = (value) => {
18
+ const parsed = Number(value)
19
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0
20
+ }
21
+ const input = number(usage.inputTokens)
22
+ const output = number(usage.outputTokens)
23
+ const cacheRead = number(usage.cacheReadTokens)
24
+ const cacheWrite = number(usage.cacheWriteTokens)
25
+ return {
26
+ at: Number(entry.time) || 0,
27
+ turn: Number(data.turn) || 0,
28
+ model: String(data.model ?? entry.model ?? ''),
29
+ input,
30
+ output,
31
+ cacheRead,
32
+ cacheWrite,
33
+ total: number(usage.totalTokens) || input + output + cacheRead + cacheWrite,
34
+ }
35
+ }
36
+
37
+ /** 解析整个 jsonl(已解压的行数组),返回每条消息的用量。坏行跳过,不抛。 */
38
+ export function parseUsageLines(lines) {
39
+ const out = []
40
+ // 模型名不挂在每条消息上:它在会话头 / 请求上下文里(data.header.config.model 或 data.model),
41
+ // 所以一边扫一边记,后面没有 model 的消息沿用最近一次看到的。
42
+ let model = ''
43
+ for (const line of Array.isArray(lines) ? lines : []) {
44
+ const trimmed = String(line ?? '').trim()
45
+ if (trimmed === '' || trimmed[0] !== '{') continue
46
+ let entry = null
47
+ try { entry = JSON.parse(trimmed) } catch { continue }
48
+ const declared = entry?.data?.model ?? entry?.data?.header?.config?.model
49
+ if (typeof declared === 'string' && declared !== '') model = declared
50
+ const usage = usageOfEntry(entry)
51
+ if (usage !== null) out.push({ ...usage, model: usage.model === '' ? model : usage.model })
52
+ }
53
+ return out
54
+ }
55
+
56
+ /** 汇总:token 数按类型分开,别把缓存命中混进普通输入。 */
57
+ export function summarizeUsage(entries) {
58
+ const list = Array.isArray(entries) ? entries : []
59
+ const sum = (field) => list.reduce((total, item) => total + (Number(item?.[field]) || 0), 0)
60
+ return {
61
+ calls: list.length,
62
+ input: sum('input'),
63
+ output: sum('output'),
64
+ cacheRead: sum('cacheRead'),
65
+ cacheWrite: sum('cacheWrite'),
66
+ total: sum('total'),
67
+ }
68
+ }
69
+
70
+ /** 订阅套餐通道:*-coding / *-token-plan —— 走包月额度,token 照记但不算钱。 */
71
+ export function isSubscriptionModel(model) {
72
+ return /-(coding|token-plan)$/.test(String(model ?? ''))
73
+ }
74
+
75
+ /**
76
+ * 价目表按 model 查:prices 来自 config.costs.prices(单位:元 / 百万 token)。
77
+ * 查不到就返回 null —— 宁可显示"未知",也不能按 0 算成免费。
78
+ */
79
+ export function priceOf(prices, model) {
80
+ const name = String(model ?? '')
81
+ if (name === '') return null
82
+ const list = Array.isArray(prices) ? prices : []
83
+ const hit = list.find((item) => String(item?.model ?? '') === name)
84
+ if (hit === undefined) return null
85
+ return {
86
+ input: Number(hit.input) || 0,
87
+ output: Number(hit.output) || 0,
88
+ cacheRead: Number(hit.cacheRead) || 0,
89
+ cacheWrite: Number(hit.cacheWrite) || 0,
90
+ }
91
+ }
92
+
93
+ /**
94
+ * 算钱:命中价目表才给数字,订阅通道给 0(明确"不额外收费"),
95
+ * 其它情况给 null(未知)。
96
+ */
97
+ export function costOf(prices, entries) {
98
+ const list = Array.isArray(entries) ? entries : []
99
+ if (list.length === 0) return { cost: 0, priced: true, unknownCalls: 0 }
100
+ let cost = 0
101
+ let unknownCalls = 0
102
+ for (const item of list) {
103
+ if (isSubscriptionModel(item.model)) continue
104
+ const price = priceOf(prices, item.model)
105
+ if (price === null) { unknownCalls += 1; continue }
106
+ const million = 1000000
107
+ cost += (Number(item.input) || 0) / million * price.input
108
+ cost += (Number(item.output) || 0) / million * price.output
109
+ cost += (Number(item.cacheRead) || 0) / million * price.cacheRead
110
+ cost += (Number(item.cacheWrite) || 0) / million * price.cacheWrite
111
+ }
112
+ return { cost: unknownCalls > 0 && cost === 0 ? null : Math.round(cost * 10000) / 10000, priced: unknownCalls === 0, unknownCalls }
113
+ }
114
+
115
+ export function sanitizePrices(raw) {
116
+ if (!Array.isArray(raw)) return []
117
+ return raw.slice(0, 50).map((item) => ({
118
+ model: String(item?.model ?? '').trim().slice(0, 80),
119
+ input: Number(item?.input) || 0,
120
+ output: Number(item?.output) || 0,
121
+ cacheRead: Number(item?.cacheRead) || 0,
122
+ cacheWrite: Number(item?.cacheWrite) || 0,
123
+ })).filter((item) => item.model !== '')
124
+ }