@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,522 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
5
|
+
import {
|
|
6
|
+
CODE_PREFIX,
|
|
7
|
+
canonicalAuthor,
|
|
8
|
+
DEFAULT_ROLES,
|
|
9
|
+
LEGACY_BLOCKED_STATUS,
|
|
10
|
+
ROLE_IDS,
|
|
11
|
+
STATUS_IDS,
|
|
12
|
+
defaultConfig,
|
|
13
|
+
mergeConfig,
|
|
14
|
+
nextRequirementCode,
|
|
15
|
+
nextTaskCode,
|
|
16
|
+
sanitizeArtifacts,
|
|
17
|
+
sanitizeAudit,
|
|
18
|
+
sanitizeBugSource,
|
|
19
|
+
sanitizeCases,
|
|
20
|
+
sanitizeChecklist,
|
|
21
|
+
sanitizeCoding,
|
|
22
|
+
sanitizeLdap,
|
|
23
|
+
sanitizeReleases,
|
|
24
|
+
sanitizeRequirements,
|
|
25
|
+
sanitizeTestRuns,
|
|
26
|
+
text,
|
|
27
|
+
} from './domain.js'
|
|
28
|
+
|
|
29
|
+
/** 持久化层:SQLite 台账(单写者 + 快照)与 0600 密钥文件。 */
|
|
30
|
+
|
|
31
|
+
/** File-backed work ledger + settings, single-writer per host process. */
|
|
32
|
+
/** 密钥单独放在 600 权限的文件里,台账只留「配没配」。 */
|
|
33
|
+
export class SecretStore {
|
|
34
|
+
constructor(file) {
|
|
35
|
+
this.file = file
|
|
36
|
+
this.cache = null
|
|
37
|
+
this.chain = Promise.resolve()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async load() {
|
|
41
|
+
if (this.cache !== null) return this.cache
|
|
42
|
+
try {
|
|
43
|
+
const raw = await readFile(this.file, 'utf8')
|
|
44
|
+
const parsed = JSON.parse(raw)
|
|
45
|
+
this.cache = {
|
|
46
|
+
gitlab: typeof parsed.gitlab === 'object' && parsed.gitlab !== null ? parsed.gitlab : {},
|
|
47
|
+
feishu: typeof parsed.feishu === 'object' && parsed.feishu !== null ? parsed.feishu : {},
|
|
48
|
+
ldap: typeof parsed.ldap === 'object' && parsed.ldap !== null ? parsed.ldap : {},
|
|
49
|
+
legacy: typeof parsed.legacy === 'object' && parsed.legacy !== null ? parsed.legacy : {},
|
|
50
|
+
}
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error?.code !== 'ENOENT') console.warn('[dsh-studio] could not read secrets file:', error?.message ?? error)
|
|
53
|
+
this.cache = { gitlab: {}, feishu: {}, ldap: {}, legacy: {} }
|
|
54
|
+
}
|
|
55
|
+
return this.cache
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async replace(next) {
|
|
59
|
+
this.cache = {
|
|
60
|
+
gitlab: next?.gitlab ?? {},
|
|
61
|
+
feishu: next?.feishu ?? {},
|
|
62
|
+
ldap: next?.ldap ?? {},
|
|
63
|
+
legacy: next?.legacy ?? {},
|
|
64
|
+
}
|
|
65
|
+
const run = this.chain.then(async () => {
|
|
66
|
+
await mkdir(dirname(this.file), { recursive: true })
|
|
67
|
+
const tmp = `${this.file}.${process.pid}.tmp`
|
|
68
|
+
await writeFile(tmp, JSON.stringify({ version: 1, ...this.cache }, null, 2), { encoding: 'utf8', mode: 0o600 })
|
|
69
|
+
await rename(tmp, this.file)
|
|
70
|
+
try { await chmod(this.file, 0o600) } catch { /* 非 POSIX 平台忽略 */ }
|
|
71
|
+
})
|
|
72
|
+
this.chain = run.then(() => undefined, () => undefined)
|
|
73
|
+
return run
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class StudioStore {
|
|
78
|
+
constructor(file, options = {}) {
|
|
79
|
+
this.file = file
|
|
80
|
+
this.dbFile = String(options.dbFile ?? file.replace(/\.json$/, '.db'))
|
|
81
|
+
this.secrets = options.secrets ?? null
|
|
82
|
+
this.snapshotLimit = Math.max(3, Math.min(100, Number(options.snapshotLimit) || 10))
|
|
83
|
+
this.db = null
|
|
84
|
+
this.engine = 'file'
|
|
85
|
+
this.state = { revision: 1, updatedAt: Date.now(), config: defaultConfig(), tasks: [], requirements: [], releases: [], audit: [], fallbackFolder: null }
|
|
86
|
+
this.chain = Promise.resolve()
|
|
87
|
+
this.ready = this.#boot()
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 先开库(拿跨进程写锁),拿不到就退回单文件模式。 */
|
|
91
|
+
/* ---- 登录会话(落库,服务重启不掉线) ---- */
|
|
92
|
+
listSessions(now = Date.now()) {
|
|
93
|
+
if (this.db === null) return []
|
|
94
|
+
try {
|
|
95
|
+
const rows = this.db
|
|
96
|
+
.prepare('SELECT token, user_id, name, role, source, at, expires_at, ip FROM sessions WHERE expires_at > ?')
|
|
97
|
+
.all(now)
|
|
98
|
+
return rows.map(row => ({
|
|
99
|
+
token: row.token,
|
|
100
|
+
userId: row.user_id,
|
|
101
|
+
name: row.name,
|
|
102
|
+
role: row.role,
|
|
103
|
+
source: row.source,
|
|
104
|
+
at: row.at,
|
|
105
|
+
expiresAt: row.expires_at,
|
|
106
|
+
ip: row.ip,
|
|
107
|
+
}))
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.warn('[dsh-studio] listSessions failed:', error?.message ?? error)
|
|
110
|
+
return []
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
saveSession(session) {
|
|
115
|
+
if (this.db === null) return
|
|
116
|
+
try {
|
|
117
|
+
this.db
|
|
118
|
+
.prepare('INSERT OR REPLACE INTO sessions (token, user_id, name, role, source, at, expires_at, ip) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
|
119
|
+
.run(session.token, session.userId, session.name, session.role, session.source, session.at, session.expiresAt, session.ip)
|
|
120
|
+
} catch (error) {
|
|
121
|
+
console.warn('[dsh-studio] saveSession failed:', error?.message ?? error)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
dropSession(token) {
|
|
126
|
+
if (this.db === null) return
|
|
127
|
+
try {
|
|
128
|
+
this.db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.warn('[dsh-studio] dropSession failed:', error?.message ?? error)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
pruneSessions(now = Date.now()) {
|
|
135
|
+
if (this.db === null) return
|
|
136
|
+
try {
|
|
137
|
+
this.db.prepare('DELETE FROM sessions WHERE expires_at <= ?').run(now)
|
|
138
|
+
} catch (error) {
|
|
139
|
+
console.warn('[dsh-studio] pruneSessions failed:', error?.message ?? error)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async #boot() {
|
|
144
|
+
try {
|
|
145
|
+
const { DatabaseSync } = await import('node:sqlite')
|
|
146
|
+
await mkdir(dirname(this.dbFile), { recursive: true })
|
|
147
|
+
const db = new DatabaseSync(this.dbFile)
|
|
148
|
+
db.exec('PRAGMA journal_mode = WAL')
|
|
149
|
+
db.exec('PRAGMA busy_timeout = 5000')
|
|
150
|
+
db.exec('PRAGMA synchronous = FULL')
|
|
151
|
+
db.exec('CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)')
|
|
152
|
+
db.exec('CREATE TABLE IF NOT EXISTS snapshots (id INTEGER PRIMARY KEY AUTOINCREMENT, at INTEGER NOT NULL, revision INTEGER NOT NULL, bytes INTEGER NOT NULL, blob TEXT NOT NULL)')
|
|
153
|
+
db.exec('CREATE TABLE IF NOT EXISTS sessions (token TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, role TEXT NOT NULL, source TEXT NOT NULL, at INTEGER NOT NULL, expires_at INTEGER NOT NULL, ip TEXT NOT NULL)')
|
|
154
|
+
// 多实例派单互斥:同一时刻只有一个进程持有派单租约(见 claimDispatchLock)。
|
|
155
|
+
db.exec('CREATE TABLE IF NOT EXISTS locks (name TEXT PRIMARY KEY, owner TEXT NOT NULL, at INTEGER NOT NULL)')
|
|
156
|
+
this.db = db
|
|
157
|
+
this.engine = 'sqlite'
|
|
158
|
+
} catch (error) {
|
|
159
|
+
console.warn(`[dsh-studio] sqlite unavailable, falling back to ${this.file}:`, error?.message ?? error)
|
|
160
|
+
this.db = null
|
|
161
|
+
this.engine = 'file'
|
|
162
|
+
}
|
|
163
|
+
await this.#load()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async #load() {
|
|
167
|
+
let saved = null
|
|
168
|
+
let imported = false
|
|
169
|
+
if (this.db !== null) {
|
|
170
|
+
try {
|
|
171
|
+
const row = this.db.prepare('SELECT value FROM meta WHERE key = ?').get('state')
|
|
172
|
+
if (row !== undefined && typeof row.value === 'string') saved = JSON.parse(row.value)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
console.warn('[dsh-studio] could not read sqlite state:', error?.message ?? error)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (saved === null) {
|
|
178
|
+
try {
|
|
179
|
+
const raw = await readFile(this.file, 'utf8')
|
|
180
|
+
saved = JSON.parse(raw)
|
|
181
|
+
imported = this.db !== null
|
|
182
|
+
} catch (error) {
|
|
183
|
+
if (error?.code !== 'ENOENT') {
|
|
184
|
+
console.warn(`[dsh-studio] could not read ${this.file}; starting empty:`, error?.message ?? error)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (saved !== null) {
|
|
189
|
+
this.state = {
|
|
190
|
+
revision: Number.isInteger(saved.revision) ? saved.revision : 1,
|
|
191
|
+
updatedAt: Number.isInteger(saved.updatedAt) ? saved.updatedAt : Date.now(),
|
|
192
|
+
config: mergeConfig(saved.config),
|
|
193
|
+
tasks: Array.isArray(saved.tasks) ? saved.tasks.map(t => ({ ...t })) : [],
|
|
194
|
+
requirements: sanitizeRequirements(saved.requirements),
|
|
195
|
+
releases: sanitizeReleases(saved.releases),
|
|
196
|
+
audit: sanitizeAudit(saved.audit),
|
|
197
|
+
fallbackFolder: saved.fallbackFolder !== null && typeof saved.fallbackFolder === 'object'
|
|
198
|
+
? {
|
|
199
|
+
token: String(saved.fallbackFolder.token ?? '').slice(0, 80),
|
|
200
|
+
url: String(saved.fallbackFolder.url ?? '').slice(0, 500),
|
|
201
|
+
name: String(saved.fallbackFolder.name ?? '').slice(0, 200),
|
|
202
|
+
at: Number(saved.fallbackFolder.at) || 0,
|
|
203
|
+
error: '',
|
|
204
|
+
}
|
|
205
|
+
: null,
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
await this.#hydrateSecrets()
|
|
209
|
+
if (imported) {
|
|
210
|
+
await this.#persist()
|
|
211
|
+
console.log(`[dsh-studio] imported ${this.file} into ${this.dbFile}`)
|
|
212
|
+
}
|
|
213
|
+
// 历史数据里的「阻塞」曾经是一列;现在它是卡上的标记。
|
|
214
|
+
for (const task of this.state.tasks) {
|
|
215
|
+
if (task.blocked === undefined) task.blocked = null
|
|
216
|
+
if (task.status === LEGACY_BLOCKED_STATUS) {
|
|
217
|
+
task.blocked = {
|
|
218
|
+
at: Number(task.updatedAt) || Date.now(),
|
|
219
|
+
by: '迁移',
|
|
220
|
+
reason: String(task.blockedReason ?? '').slice(0, 500),
|
|
221
|
+
}
|
|
222
|
+
task.status = 'clarify'
|
|
223
|
+
}
|
|
224
|
+
// v1 的产物是纯字符串;v2 起每一件都是带类型/状态/来源阶段的记录。
|
|
225
|
+
task.artifacts = sanitizeArtifacts(task.artifacts)
|
|
226
|
+
task.checklist = sanitizeChecklist(task.checklist)
|
|
227
|
+
task.cases = sanitizeCases(task.cases)
|
|
228
|
+
task.testRuns = sanitizeTestRuns(task.testRuns)
|
|
229
|
+
task.coding = sanitizeCoding(task.coding)
|
|
230
|
+
if (task.bugSource === undefined) task.bugSource = null
|
|
231
|
+
else task.bugSource = sanitizeBugSource(task.bugSource)
|
|
232
|
+
if (task.reqBaseline === undefined) task.reqBaseline = null
|
|
233
|
+
if (task.worktree === undefined) task.worktree = null
|
|
234
|
+
if (typeof task.pointId !== 'string') task.pointId = ''
|
|
235
|
+
if (task.signoff === undefined) task.signoff = null
|
|
236
|
+
else if (task.worktree !== null && (typeof task.worktree !== 'object' || typeof task.worktree.path !== 'string')) task.worktree = null
|
|
237
|
+
if (typeof task.gitlabMR !== 'object' || task.gitlabMR === null) task.gitlabMR = { iid: 0, url: '', title: '', state: '' }
|
|
238
|
+
if (typeof task.project !== 'string') task.project = ''
|
|
239
|
+
if (typeof task.iteration !== 'string') task.iteration = ''
|
|
240
|
+
if (typeof task.service !== 'string') task.service = ''
|
|
241
|
+
if (!Number.isFinite(Number(task.agentFailures))) task.agentFailures = 0
|
|
242
|
+
if (!Number.isFinite(Number(task.retryAt))) task.retryAt = 0
|
|
243
|
+
if (typeof task.parent !== 'string') task.parent = ''
|
|
244
|
+
if (!Number.isFinite(task.dueAt)) task.dueAt = 0
|
|
245
|
+
if (typeof task.requirementId !== 'string') task.requirementId = ''
|
|
246
|
+
if (typeof task.releaseId !== 'string') task.releaseId = ''
|
|
247
|
+
if (typeof task.code !== 'string') task.code = ''
|
|
248
|
+
// 老评论的作者写成「架构」「🏛️ 支付架构师」这种,统一成专家本名,接口与界面都一致。
|
|
249
|
+
for (const item of task.comments ?? []) {
|
|
250
|
+
if (item === null || typeof item !== 'object') continue
|
|
251
|
+
const next = canonicalAuthor(item.author, this.state.config)
|
|
252
|
+
if (next !== item.author) item.author = next
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// 先给需求补编号,再给挂在需求下的老卡补编号:DEV-001 / TEST-001 各自递增。
|
|
256
|
+
for (const req of this.state.requirements) {
|
|
257
|
+
if (req.code.length === 0) req.code = nextRequirementCode(this.state.requirements)
|
|
258
|
+
}
|
|
259
|
+
for (const task of this.state.tasks) {
|
|
260
|
+
if (task.code.length > 0 || task.requirementId.length === 0) continue
|
|
261
|
+
task.code = nextTaskCode(this.state.tasks, task.requirementId, CODE_PREFIX[task.type] ?? 'TASK')
|
|
262
|
+
}
|
|
263
|
+
// v1 的「待澄清」硬塞给产品经理;待澄清交谁由人定,清空的配置一次就好。
|
|
264
|
+
if (Number(this.state.config.schema ?? 1) < 2) {
|
|
265
|
+
const clarify = this.state.config.columns?.clarify
|
|
266
|
+
if (clarify !== undefined && clarify.kind === 'expert' && clarify.id === 'product') {
|
|
267
|
+
this.state.config.columns.clarify = { kind: 'none', id: '' }
|
|
268
|
+
}
|
|
269
|
+
this.state.config.schema = 2
|
|
270
|
+
}
|
|
271
|
+
// v2 没有独立的评审列:研发一改完就直接到测试,等于跳过评审。补一列交给架构师。
|
|
272
|
+
if (Number(this.state.config.schema ?? 1) < 3) {
|
|
273
|
+
const columns = this.state.config.columns ?? {}
|
|
274
|
+
if (columns.review === undefined) columns.review = { kind: 'expert', id: 'architect' }
|
|
275
|
+
for (const task of this.state.tasks) {
|
|
276
|
+
task.cases = sanitizeCases(task.cases)
|
|
277
|
+
task.checklist = sanitizeChecklist(task.checklist)
|
|
278
|
+
}
|
|
279
|
+
this.state.config.schema = 3
|
|
280
|
+
}
|
|
281
|
+
// v3 的专家只有一句 description,角色提示词是空的——面板上看着像「没配」。
|
|
282
|
+
// 补齐默认提示词:空的用内置角色说明(已经写过的保持不动)。
|
|
283
|
+
if (Number(this.state.config.schema ?? 1) < 4) {
|
|
284
|
+
for (const expert of this.state.config.experts ?? []) {
|
|
285
|
+
if (String(expert.prompt ?? '').trim() !== '') continue
|
|
286
|
+
const template = DEFAULT_ROLES[expert.role]?.prompt
|
|
287
|
+
if (template === undefined) continue
|
|
288
|
+
expert.prompt = expert.domain ? `你是${expert.domain}业务线的${DEFAULT_ROLES[expert.role].name}。\n${template}` : template
|
|
289
|
+
}
|
|
290
|
+
for (const expert of this.state.config.experts ?? []) {
|
|
291
|
+
expert.promptVersion = createHash('sha1').update(String(expert.prompt ?? '')).digest('hex').slice(0, 8)
|
|
292
|
+
}
|
|
293
|
+
this.state.config.schema = 4
|
|
294
|
+
}
|
|
295
|
+
// v4 里「按业务领域批量生成」写的是 13 个字的一句话提示词,等于没配:换成完整版。
|
|
296
|
+
if (Number(this.state.config.schema ?? 1) < 5) {
|
|
297
|
+
for (const expert of this.state.config.experts ?? []) {
|
|
298
|
+
const template = DEFAULT_ROLES[expert.role]?.prompt
|
|
299
|
+
if (template === undefined) continue
|
|
300
|
+
if (String(expert.prompt ?? '').trim().length >= 20) continue
|
|
301
|
+
expert.prompt = `你是${expert.domain || ''}业务线的${DEFAULT_ROLES[expert.role].name}。\n${template}`
|
|
302
|
+
expert.promptVersion = createHash('sha1').update(expert.prompt).digest('hex').slice(0, 8)
|
|
303
|
+
}
|
|
304
|
+
this.state.config.schema = 5
|
|
305
|
+
}
|
|
306
|
+
if (Number(this.state.config.schema ?? 1) < 6) {
|
|
307
|
+
// 泳道负责人不该绑死在某位领域专家上:领域专家是为具体业务需求服务的,
|
|
308
|
+
// 所以把「指定了领域专家」的列改写成同名的角色位,领域在卡上再解析。
|
|
309
|
+
const columns = this.state.config.columns ?? {}
|
|
310
|
+
const expertById = id => (this.state.config.experts ?? []).find(entry => entry.id === id)
|
|
311
|
+
const toRole = expert => (expert !== undefined && (expert.domain ?? '') !== '' && ROLE_IDS.includes(expert.role)
|
|
312
|
+
? { kind: 'role', id: expert.role }
|
|
313
|
+
: undefined)
|
|
314
|
+
for (const status of STATUS_IDS) {
|
|
315
|
+
const owner = columns[status]
|
|
316
|
+
if (owner === null || typeof owner !== 'object') continue
|
|
317
|
+
if (Array.isArray(owner.members)) {
|
|
318
|
+
owner.members = owner.members.map(member => (member?.kind === 'expert' ? (toRole(expertById(member.id)) ?? member) : member))
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
if (owner.kind !== 'expert') continue
|
|
322
|
+
const role = toRole(expertById(owner.id))
|
|
323
|
+
if (role !== undefined) columns[status] = { ...owner, ...role }
|
|
324
|
+
}
|
|
325
|
+
this.state.config.schema = 6
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
snapshot() {
|
|
330
|
+
return this.state
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Serialized mutation; resolves with the new snapshot. */
|
|
334
|
+
async mutate(fn) {
|
|
335
|
+
const run = this.chain.then(async () => {
|
|
336
|
+
await this.ready
|
|
337
|
+
fn(this.state)
|
|
338
|
+
this.state.revision += 1
|
|
339
|
+
this.state.updatedAt = Date.now()
|
|
340
|
+
await this.#persist()
|
|
341
|
+
return this.state
|
|
342
|
+
})
|
|
343
|
+
this.chain = run.then(() => undefined, () => undefined)
|
|
344
|
+
return run
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* 旁路数据(集成健康这类):单独一个 meta 键,不进 state、不涨 revision、不占快照。
|
|
349
|
+
* 它们每几秒就会变一次,混进 state 只会让面板误以为看板数据变了。
|
|
350
|
+
*/
|
|
351
|
+
async readSide(key) {
|
|
352
|
+
await this.ready
|
|
353
|
+
if (this.db === null) return null
|
|
354
|
+
try {
|
|
355
|
+
const row = this.db.prepare('SELECT value FROM meta WHERE key = ?').get('side:' + key)
|
|
356
|
+
return row === undefined || row === null ? null : JSON.parse(row.value)
|
|
357
|
+
} catch {
|
|
358
|
+
return null
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async writeSide(key, value) {
|
|
363
|
+
await this.ready
|
|
364
|
+
if (this.db === null) return
|
|
365
|
+
try {
|
|
366
|
+
this.db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
|
367
|
+
.run('side:' + key, JSON.stringify(value))
|
|
368
|
+
} catch {
|
|
369
|
+
/* 旁路数据写不进去也不能影响业务 */
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** 落盘前把密钥摘掉:库里只留「有没有配」,密钥在 600 权限的单独文件里。 */
|
|
374
|
+
#serialize() {
|
|
375
|
+
const plain = {
|
|
376
|
+
...this.state,
|
|
377
|
+
config: structuredClone(this.state.config ?? {}),
|
|
378
|
+
}
|
|
379
|
+
const gitlab = plain.config.gitlab ?? {}
|
|
380
|
+
if (typeof gitlab.token === 'string') gitlab.token = ''
|
|
381
|
+
for (const project of Array.isArray(gitlab.projects) ? gitlab.projects : []) {
|
|
382
|
+
if (typeof project.token === 'string') project.token = ''
|
|
383
|
+
}
|
|
384
|
+
const feishu = plain.config.feishu ?? {}
|
|
385
|
+
if (typeof feishu.webhook === 'string') feishu.webhook = ''
|
|
386
|
+
for (const bot of Array.isArray(feishu.bots) ? feishu.bots : []) {
|
|
387
|
+
if (typeof bot.webhook === 'string') bot.webhook = ''
|
|
388
|
+
}
|
|
389
|
+
const ldap = plain.config.ldap ?? {}
|
|
390
|
+
if (typeof ldap.bindPassword === 'string') ldap.bindPassword = ''
|
|
391
|
+
return plain
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** 把内存里的密钥同步进密钥文件(有就写、没了就删)。 */
|
|
395
|
+
async #syncSecrets() {
|
|
396
|
+
if (this.secrets === null) return
|
|
397
|
+
const gitlab = {}
|
|
398
|
+
const gitlabConfig = this.state.config.gitlab ?? {}
|
|
399
|
+
for (const project of Array.isArray(gitlabConfig.projects) ? gitlabConfig.projects : []) {
|
|
400
|
+
const token = String(project.token ?? '').trim()
|
|
401
|
+
if (token !== '') gitlab[project.id] = token
|
|
402
|
+
}
|
|
403
|
+
const feishu = {}
|
|
404
|
+
const feishuConfig = this.state.config.feishu ?? {}
|
|
405
|
+
for (const bot of Array.isArray(feishuConfig.bots) ? feishuConfig.bots : []) {
|
|
406
|
+
const webhook = String(bot.webhook ?? '').trim()
|
|
407
|
+
if (webhook !== '') feishu[bot.id] = webhook
|
|
408
|
+
}
|
|
409
|
+
await this.secrets.replace({
|
|
410
|
+
gitlab,
|
|
411
|
+
feishu,
|
|
412
|
+
ldap: (() => {
|
|
413
|
+
const password = String(this.state.config.ldap?.bindPassword ?? '').trim()
|
|
414
|
+
return password === '' ? {} : { bindPassword: password }
|
|
415
|
+
})(),
|
|
416
|
+
legacy: {
|
|
417
|
+
gitlabToken: String(gitlabConfig.token ?? '').trim(),
|
|
418
|
+
feishuWebhook: String(feishuConfig.webhook ?? '').trim(),
|
|
419
|
+
},
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** 从密钥文件把密钥补回内存(库里存的是空串)。 */
|
|
424
|
+
async #hydrateSecrets() {
|
|
425
|
+
if (this.secrets === null) return
|
|
426
|
+
const vault = await this.secrets.load()
|
|
427
|
+
const gitlabConfig = this.state.config.gitlab ?? {}
|
|
428
|
+
for (const project of Array.isArray(gitlabConfig.projects) ? gitlabConfig.projects : []) {
|
|
429
|
+
const token = String(project.token ?? '').trim()
|
|
430
|
+
if (token === '' && typeof vault.gitlab?.[project.id] === 'string') project.token = vault.gitlab[project.id]
|
|
431
|
+
}
|
|
432
|
+
const feishuConfig = this.state.config.feishu ?? {}
|
|
433
|
+
for (const bot of Array.isArray(feishuConfig.bots) ? feishuConfig.bots : []) {
|
|
434
|
+
const webhook = String(bot.webhook ?? '').trim()
|
|
435
|
+
if (webhook === '' && typeof vault.feishu?.[bot.id] === 'string') bot.webhook = vault.feishu[bot.id]
|
|
436
|
+
}
|
|
437
|
+
const ldapConfig = this.state.config.ldap ?? (this.state.config.ldap = sanitizeLdap({}))
|
|
438
|
+
if (String(ldapConfig.bindPassword ?? '').trim() === '' && typeof vault.ldap?.bindPassword === 'string') {
|
|
439
|
+
ldapConfig.bindPassword = vault.ldap.bindPassword
|
|
440
|
+
}
|
|
441
|
+
if (String(gitlabConfig.token ?? '').trim() === '' && typeof vault.legacy?.gitlabToken === 'string') {
|
|
442
|
+
gitlabConfig.token = vault.legacy.gitlabToken
|
|
443
|
+
}
|
|
444
|
+
if (String(feishuConfig.webhook ?? '').trim() === '' && typeof vault.legacy?.feishuWebhook === 'string') {
|
|
445
|
+
feishuConfig.webhook = vault.legacy.feishuWebhook
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async #persist() {
|
|
450
|
+
await mkdir(dirname(this.file), { recursive: true })
|
|
451
|
+
const plain = this.#serialize()
|
|
452
|
+
if (this.db === null) {
|
|
453
|
+
const tmp = `${this.file}.${process.pid}.tmp`
|
|
454
|
+
await writeFile(tmp, JSON.stringify(plain, null, 2), 'utf8')
|
|
455
|
+
await rename(tmp, this.file)
|
|
456
|
+
await this.#syncSecrets()
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
const text = JSON.stringify(plain)
|
|
460
|
+
this.db.exec('BEGIN IMMEDIATE')
|
|
461
|
+
try {
|
|
462
|
+
this.db.prepare('INSERT INTO snapshots (at, revision, bytes, blob) VALUES (?, ?, ?, ?)')
|
|
463
|
+
.run(Date.now(), plain.revision, text.length, text)
|
|
464
|
+
this.db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
|
465
|
+
.run('state', text)
|
|
466
|
+
this.db.prepare('DELETE FROM snapshots WHERE id NOT IN (SELECT id FROM snapshots ORDER BY id DESC LIMIT ?)')
|
|
467
|
+
.run(this.snapshotLimit)
|
|
468
|
+
this.db.exec('COMMIT')
|
|
469
|
+
} catch (error) {
|
|
470
|
+
try { this.db.exec('ROLLBACK') } catch { /* ignore */ }
|
|
471
|
+
throw error
|
|
472
|
+
}
|
|
473
|
+
await this.#syncSecrets()
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** 最近若干次落盘的快照(不含正文,列表用)。 */
|
|
477
|
+
snapshots() {
|
|
478
|
+
if (this.db === null) return []
|
|
479
|
+
try {
|
|
480
|
+
return this.db.prepare('SELECT id, at, revision, bytes FROM snapshots ORDER BY id DESC LIMIT 50').all()
|
|
481
|
+
.map(row => ({ id: Number(row.id), at: Number(row.at), revision: Number(row.revision), bytes: Number(row.bytes) }))
|
|
482
|
+
} catch (error) {
|
|
483
|
+
console.warn('[dsh-studio] could not list snapshots:', error?.message ?? error)
|
|
484
|
+
return []
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** 回滚到某个快照(会先给当前状态再存一版,避免回滚本身丢数据)。 */
|
|
489
|
+
async restoreSnapshot(id) {
|
|
490
|
+
if (this.db === null) throw new Error('当前是单文件模式,没有快照可回滚')
|
|
491
|
+
const row = this.db.prepare('SELECT blob FROM snapshots WHERE id = ?').get(Number(id))
|
|
492
|
+
if (row === undefined) throw new Error(`没有快照 ${id}`)
|
|
493
|
+
const saved = JSON.parse(String(row.blob))
|
|
494
|
+
await this.#persist()
|
|
495
|
+
this.state = {
|
|
496
|
+
revision: this.state.revision + 1,
|
|
497
|
+
updatedAt: Date.now(),
|
|
498
|
+
config: mergeConfig(saved.config),
|
|
499
|
+
tasks: Array.isArray(saved.tasks) ? saved.tasks.map(task => ({ ...task })) : [],
|
|
500
|
+
requirements: sanitizeRequirements(saved.requirements),
|
|
501
|
+
releases: sanitizeReleases(saved.releases),
|
|
502
|
+
audit: sanitizeAudit(saved.audit),
|
|
503
|
+
fallbackFolder: saved.fallbackFolder ?? null,
|
|
504
|
+
}
|
|
505
|
+
await this.#hydrateSecrets()
|
|
506
|
+
await this.#persist()
|
|
507
|
+
return this.state
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** 给面板看的存储信息。 */
|
|
511
|
+
describe() {
|
|
512
|
+
return {
|
|
513
|
+
engine: this.engine,
|
|
514
|
+
db: this.engine === 'sqlite' ? this.dbFile : this.file,
|
|
515
|
+
legacyFile: this.file,
|
|
516
|
+
snapshotLimit: this.snapshotLimit,
|
|
517
|
+
snapshots: this.snapshots().length,
|
|
518
|
+
secretsFile: this.secrets === null ? '' : this.secrets.file,
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// 专家团的团队记忆:跨卡的经验沉淀。存在 SQLite 的 side 键里——
|
|
2
|
+
// 不占 state、不涨 revision、不抢快照;读不到就当没有。
|
|
3
|
+
|
|
4
|
+
const KEY = 'team-memory'
|
|
5
|
+
const PER_TEAM = 20
|
|
6
|
+
const IN_PROMPT = 5
|
|
7
|
+
|
|
8
|
+
/** 全部团的记忆:{ [teamId]: [{ at, taskId, title, outcome, note }] } */
|
|
9
|
+
export async function loadTeamMemory(store) {
|
|
10
|
+
const saved = await store.readSide(KEY).catch(() => null)
|
|
11
|
+
if (saved === null || typeof saved !== 'object' || Array.isArray(saved)) return {}
|
|
12
|
+
return saved
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 记一条;同一个团最多留 PER_TEAM 条。 */
|
|
16
|
+
export async function appendTeamMemory(store, teamId, entry) {
|
|
17
|
+
const id = String(teamId ?? '')
|
|
18
|
+
if (id === '') return null
|
|
19
|
+
const all = await loadTeamMemory(store)
|
|
20
|
+
const list = Array.isArray(all[id]) ? all[id] : []
|
|
21
|
+
const row = {
|
|
22
|
+
at: Number(entry?.at) || Date.now(),
|
|
23
|
+
taskId: String(entry?.taskId ?? '').slice(0, 60),
|
|
24
|
+
title: String(entry?.title ?? '').slice(0, 120),
|
|
25
|
+
outcome: String(entry?.outcome ?? '').slice(0, 40),
|
|
26
|
+
note: String(entry?.note ?? '').slice(0, 300),
|
|
27
|
+
}
|
|
28
|
+
all[id] = [row, ...list].slice(0, PER_TEAM)
|
|
29
|
+
await store.writeSide(KEY, all).catch(() => {})
|
|
30
|
+
return row
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 清空某个团的记忆(人从面板上点)。 */
|
|
34
|
+
export async function clearTeamMemory(store, teamId) {
|
|
35
|
+
const all = await loadTeamMemory(store)
|
|
36
|
+
delete all[String(teamId ?? '')]
|
|
37
|
+
await store.writeSide(KEY, all).catch(() => {})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 拼进专家提示词的那一段;纯函数,方便单测。 */
|
|
41
|
+
export function teamMemoryBlock(entries, limit = IN_PROMPT) {
|
|
42
|
+
const list = Array.isArray(entries) ? entries.slice(0, Math.max(0, limit)) : []
|
|
43
|
+
if (list.length === 0) return ''
|
|
44
|
+
const lines = list.map(item => `- ${item.outcome || '经手'}:${item.title}${item.note ? `(${item.note})` : ''}`)
|
|
45
|
+
return `\n这个团最近经手过的卡(先看有没有踩过的坑,别重复犯):\n${lines.join('\n')}`
|
|
46
|
+
}
|