dsh-hams 0.1.3 → 0.1.4
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/CHANGELOG.md +3 -3
- package/README.md +4 -0
- package/dist/index.js +11335 -0
- package/package.json +9 -5
- package/src/domain/check.ts +0 -193
- package/src/domain/errors.ts +0 -59
- package/src/domain/evidence.ts +0 -134
- package/src/domain/ids.ts +0 -31
- package/src/host/chat-runtime.ts +0 -489
- package/src/host/dsh-adapter.ts +0 -867
- package/src/host/session-adapter.ts +0 -276
- package/src/http/handler.ts +0 -1132
- package/src/index.js +0 -53
- package/src/service/audit.ts +0 -92
- package/src/service/chat.ts +0 -1100
- package/src/service/contracts.ts +0 -193
- package/src/service/defaults.ts +0 -182
- package/src/service/deliverables.ts +0 -240
- package/src/service/hams.ts +0 -3103
- package/src/service/knowledge.ts +0 -940
- package/src/service/member-config.ts +0 -39
- package/src/service/members.ts +0 -506
- package/src/service/memory.ts +0 -375
- package/src/service/permissions.ts +0 -118
- package/src/service/project-lifecycle.ts +0 -233
- package/src/service/projects.ts +0 -840
- package/src/service/receipts.ts +0 -63
- package/src/service/templates.ts +0 -140
- package/src/service/tick.ts +0 -751
- package/src/service/tools.ts +0 -711
- package/src/service/types.ts +0 -219
- package/src/service/work-item-create.ts +0 -159
- package/src/service/workspace-profile.ts +0 -443
- package/src/store/backup.ts +0 -239
- package/src/store/bootstrap.ts +0 -225
- package/src/store/db.ts +0 -179
- package/src/store/lock.ts +0 -194
- package/src/store/migrations.ts +0 -211
- package/src/store/schema/001_p1.sql +0 -846
- package/src/store/schema/001_p1.sql.ts +0 -865
- package/src/store/schema/002_knowledge.sql +0 -54
- package/src/store/schema/002_knowledge.sql.ts +0 -68
- package/src/store/schema/003_projects.sql.ts +0 -3
- package/src/store/schema/004_team.sql.ts +0 -50
- package/src/store/schema/005_target_check.sql.ts +0 -22
- package/src/store/schema/006_target_flow.sql.ts +0 -19
- package/src/store/schema/007_project_delete.sql.ts +0 -17
- package/src/store/schema/008_memory.sql.ts +0 -47
- package/src/store/schema/generate-projects.mjs +0 -16
- package/src/store/seed.ts +0 -114
|
@@ -1,443 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 工作区画像(workspace profile):把「我现在在做的事」一次性灌进 HAMS。
|
|
3
|
-
*
|
|
4
|
-
* 为什么不是把项目写死在代码里:这些内容属于用户,不属于插件。画像是一个
|
|
5
|
-
* 放在数据目录下的 JSON(默认 `$DSH_HOME/hams/workspace-profile.json`),
|
|
6
|
-
* 记录当前工作区里真实存在的项目、角色、下一步和文档路径;代码只负责读它、
|
|
7
|
-
* 校验它、幂等地落库,并在 `hams_meta` 里记内容哈希,避免每次启动重复写。
|
|
8
|
-
*
|
|
9
|
-
* 三条纪律:
|
|
10
|
-
* 1. **只补缺失,不覆盖**:成员按名字、项目按名字、工作项按 `profile:<key>`
|
|
11
|
-
* 去重、文档按绝对路径去重;已经有的不动,用户改过的内容不会被画像覆盖。
|
|
12
|
-
* 2. **来源可追溯**:工作项带 `source` 链接到工作区里的真实文件,知识文档
|
|
13
|
-
* 登记绝对路径与 sha256,不复制正文。
|
|
14
|
-
* 3. **失败不阻断启动**:某一篇文档路径不在、某个成员名冲突,只记进报告的
|
|
15
|
-
* `errors`,其余内容照常落库;适配器只打日志,不让插件起不来。
|
|
16
|
-
*
|
|
17
|
-
* 画像里可以出现 `cleanupTemplates`,用于删除设计期留下的示例模板(例如
|
|
18
|
-
* `tpl-battery-alert`);删除前仍由 `removeTemplateIfUnused` 检查有没有单据在用。
|
|
19
|
-
*/
|
|
20
|
-
import { createHash } from 'node:crypto'
|
|
21
|
-
import { readFileSync, existsSync } from 'node:fs'
|
|
22
|
-
import path from 'node:path'
|
|
23
|
-
import type { DatabaseSync } from 'node:sqlite'
|
|
24
|
-
import { transaction } from '../store/db.ts'
|
|
25
|
-
import type { IdFactory } from '../domain/ids.ts'
|
|
26
|
-
import { insertMember, type CreateMemberInput } from './members.ts'
|
|
27
|
-
import { insertProject, type MetricInput } from './projects.ts'
|
|
28
|
-
import { insertWorkItem } from './work-item-create.ts'
|
|
29
|
-
import { KnowledgeService } from './knowledge.ts'
|
|
30
|
-
import { removeTemplateIfUnused, upsertTemplate, STARTER_TEMPLATE, type TemplateDefinition } from './templates.ts'
|
|
31
|
-
import { writeAudit, writeEvent } from './audit.ts'
|
|
32
|
-
|
|
33
|
-
const DAY = 86_400_000
|
|
34
|
-
const DEFAULT_DOC_STATUS = 'published' as const
|
|
35
|
-
|
|
36
|
-
export interface ProfileMember {
|
|
37
|
-
key: string
|
|
38
|
-
kind: 'human' | 'bot'
|
|
39
|
-
name: string
|
|
40
|
-
role?: string
|
|
41
|
-
domain?: string
|
|
42
|
-
responsibility?: string
|
|
43
|
-
positionId?: string
|
|
44
|
-
capabilities?: string[]
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export interface ProfileMetric extends MetricInput {
|
|
48
|
-
/** 画像里用指标名引用;落库后换成 metric_id。 */
|
|
49
|
-
observations?: { value: number | null; sourceRef?: string }[]
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export interface ProfileProject {
|
|
53
|
-
key: string
|
|
54
|
-
name: string
|
|
55
|
-
type: 'one_off' | 'continuous'
|
|
56
|
-
status?: 'draft' | 'active'
|
|
57
|
-
goalText: string
|
|
58
|
-
coordinatorKey?: string
|
|
59
|
-
metrics?: ProfileMetric[]
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export interface ProfileWorkItem {
|
|
63
|
-
key: string
|
|
64
|
-
projectKey?: string
|
|
65
|
-
title: string
|
|
66
|
-
domain: string
|
|
67
|
-
category: string
|
|
68
|
-
priority?: number
|
|
69
|
-
dueInDays?: number
|
|
70
|
-
source?: string
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface ProfileDoc {
|
|
74
|
-
path: string
|
|
75
|
-
title?: string
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export interface WorkspaceProfile {
|
|
79
|
-
version: number
|
|
80
|
-
generatedAt?: string
|
|
81
|
-
workspaceRoot?: string
|
|
82
|
-
template?: TemplateDefinition
|
|
83
|
-
members?: ProfileMember[]
|
|
84
|
-
projects?: ProfileProject[]
|
|
85
|
-
workItems?: ProfileWorkItem[]
|
|
86
|
-
docs?: ProfileDoc[]
|
|
87
|
-
overview?: { title: string; body: string }
|
|
88
|
-
cleanupTemplates?: string[]
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export interface WorkspaceProfileReport {
|
|
92
|
-
status: 'missing' | 'unchanged' | 'applied' | 'failed'
|
|
93
|
-
file: string
|
|
94
|
-
hash: string | null
|
|
95
|
-
workspaceRoot: string | null
|
|
96
|
-
created: {
|
|
97
|
-
template: boolean
|
|
98
|
-
members: number
|
|
99
|
-
projects: number
|
|
100
|
-
workItems: number
|
|
101
|
-
docs: number
|
|
102
|
-
observations: number
|
|
103
|
-
skills: number
|
|
104
|
-
tables: number
|
|
105
|
-
removedTemplates: number
|
|
106
|
-
}
|
|
107
|
-
errors: string[]
|
|
108
|
-
/** 不阻止记录哈希的告警(例如还没有技能目录)。 */
|
|
109
|
-
warnings: string[]
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function blankReport(file: string, status: WorkspaceProfileReport['status']): WorkspaceProfileReport {
|
|
113
|
-
return {
|
|
114
|
-
status,
|
|
115
|
-
file,
|
|
116
|
-
hash: null,
|
|
117
|
-
workspaceRoot: null,
|
|
118
|
-
created: {
|
|
119
|
-
template: false,
|
|
120
|
-
members: 0,
|
|
121
|
-
projects: 0,
|
|
122
|
-
workItems: 0,
|
|
123
|
-
docs: 0,
|
|
124
|
-
observations: 0,
|
|
125
|
-
skills: 0,
|
|
126
|
-
tables: 0,
|
|
127
|
-
removedTemplates: 0,
|
|
128
|
-
},
|
|
129
|
-
errors: [],
|
|
130
|
-
warnings: [],
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export function defaultProfilePath(dataDir: string): string {
|
|
135
|
-
return path.join(path.resolve(dataDir), 'workspace-profile.json')
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function readProfile(file: string): { profile: WorkspaceProfile; hash: string; raw: string } {
|
|
139
|
-
const raw = readFileSync(file, 'utf8')
|
|
140
|
-
const hash = createHash('sha256').update(raw).digest('hex')
|
|
141
|
-
let parsed: unknown
|
|
142
|
-
try {
|
|
143
|
-
parsed = JSON.parse(raw)
|
|
144
|
-
} catch (error) {
|
|
145
|
-
throw new Error(`画像不是合法 JSON:${(error as Error).message}`)
|
|
146
|
-
}
|
|
147
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('画像必须是 JSON 对象')
|
|
148
|
-
const profile = parsed as WorkspaceProfile
|
|
149
|
-
if (profile.version !== 1) throw new Error(`画像版本不支持:${String(profile.version)},当前只支持 1`)
|
|
150
|
-
if (profile.template && !profile.template.key) throw new Error('template.key 不能为空')
|
|
151
|
-
return { profile, hash, raw }
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
export interface ApplyWorkspaceProfileOptions {
|
|
155
|
-
db: DatabaseSync
|
|
156
|
-
dataDir: string
|
|
157
|
-
operatorId: string
|
|
158
|
-
now?: number
|
|
159
|
-
idFactory: IdFactory
|
|
160
|
-
file?: string
|
|
161
|
-
/** 覆盖画像里的 workspaceRoot(测试或换机器时用)。 */
|
|
162
|
-
workspaceRoot?: string
|
|
163
|
-
/** 知识屏的真实技能目录;不传就只同步业务表。 */
|
|
164
|
-
skillDirs?: string[]
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* 读取并应用工作区画像。整个应用过程在一个 `BEGIN IMMEDIATE` 里;
|
|
169
|
-
* 任何一类内容出错只记 error,不中断其它类别;最后写审计与哈希。
|
|
170
|
-
*/
|
|
171
|
-
export function applyWorkspaceProfile(options: ApplyWorkspaceProfileOptions): WorkspaceProfileReport {
|
|
172
|
-
const now = options.now ?? Date.now()
|
|
173
|
-
const file = path.resolve(options.file ?? defaultProfilePath(options.dataDir))
|
|
174
|
-
if (!existsSync(file)) return blankReport(file, 'missing')
|
|
175
|
-
|
|
176
|
-
let loaded: { profile: WorkspaceProfile; hash: string }
|
|
177
|
-
try {
|
|
178
|
-
loaded = readProfile(file)
|
|
179
|
-
} catch (error) {
|
|
180
|
-
const report = blankReport(file, 'failed')
|
|
181
|
-
report.errors.push((error as Error).message)
|
|
182
|
-
return report
|
|
183
|
-
}
|
|
184
|
-
const { profile, hash } = loaded
|
|
185
|
-
const workspaceRoot = path.resolve(options.workspaceRoot ?? profile.workspaceRoot ?? process.cwd())
|
|
186
|
-
const prior = options.db
|
|
187
|
-
.prepare("SELECT value FROM hams_meta WHERE key = 'workspace_profile_sha256'")
|
|
188
|
-
.get() as { value: string } | undefined
|
|
189
|
-
const report = blankReport(file, 'applied')
|
|
190
|
-
report.hash = hash
|
|
191
|
-
report.workspaceRoot = workspaceRoot
|
|
192
|
-
if (prior?.value === hash) {
|
|
193
|
-
report.status = 'unchanged'
|
|
194
|
-
return report
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
const knowledge = new KnowledgeService({
|
|
198
|
-
db: options.db,
|
|
199
|
-
now: () => now,
|
|
200
|
-
idFactory: options.idFactory,
|
|
201
|
-
dataDir: options.dataDir,
|
|
202
|
-
skillDirs: options.skillDirs,
|
|
203
|
-
})
|
|
204
|
-
|
|
205
|
-
try {
|
|
206
|
-
transaction(options.db, () => {
|
|
207
|
-
const templateKey = (profile.template ?? STARTER_TEMPLATE).key
|
|
208
|
-
const templateBefore = options.db
|
|
209
|
-
.prepare('SELECT published_version_id FROM work_template WHERE key = ?')
|
|
210
|
-
.get(templateKey) as { published_version_id: string | null } | undefined
|
|
211
|
-
const templateVersionId = upsertTemplate(
|
|
212
|
-
options.db,
|
|
213
|
-
options.operatorId,
|
|
214
|
-
now,
|
|
215
|
-
profile.template ?? STARTER_TEMPLATE,
|
|
216
|
-
)
|
|
217
|
-
report.created.template = !templateBefore?.published_version_id
|
|
218
|
-
|
|
219
|
-
// 1) 成员:按名字去重;bot 责任人固定为本机操作者。
|
|
220
|
-
const memberIds = new Map<string, string>()
|
|
221
|
-
for (const member of profile.members ?? []) {
|
|
222
|
-
const existing = options.db.prepare('SELECT id FROM member WHERE name = ?').get(member.name) as
|
|
223
|
-
| { id: string }
|
|
224
|
-
| undefined
|
|
225
|
-
if (existing) {
|
|
226
|
-
memberIds.set(member.key, existing.id)
|
|
227
|
-
continue
|
|
228
|
-
}
|
|
229
|
-
try {
|
|
230
|
-
const input: CreateMemberInput = {
|
|
231
|
-
kind: member.kind,
|
|
232
|
-
name: member.name,
|
|
233
|
-
role: member.role,
|
|
234
|
-
domain: member.domain,
|
|
235
|
-
responsibility: member.responsibility,
|
|
236
|
-
positionId: member.positionId,
|
|
237
|
-
ownerHumanId: member.kind === 'bot' ? options.operatorId : undefined,
|
|
238
|
-
capabilities: member.capabilities,
|
|
239
|
-
}
|
|
240
|
-
const created = insertMember(options.db, options.idFactory, now, options.operatorId, input)
|
|
241
|
-
memberIds.set(member.key, created.id)
|
|
242
|
-
report.created.members += 1
|
|
243
|
-
} catch (error) {
|
|
244
|
-
report.errors.push(`成员「${member.name}」:${(error as Error).message}`)
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// 2) 项目:按名字去重;指标随目标版本一起建,读数按名称回填。
|
|
249
|
-
const projectIds = new Map<string, string>()
|
|
250
|
-
for (const project of profile.projects ?? []) {
|
|
251
|
-
const existing = options.db.prepare('SELECT id FROM project WHERE name = ? AND deleted_at IS NULL').get(project.name) as
|
|
252
|
-
| { id: string }
|
|
253
|
-
| undefined
|
|
254
|
-
if (existing) {
|
|
255
|
-
projectIds.set(project.key, existing.id)
|
|
256
|
-
continue
|
|
257
|
-
}
|
|
258
|
-
try {
|
|
259
|
-
const metrics = (project.metrics ?? []).map((metric) => ({
|
|
260
|
-
name: metric.name,
|
|
261
|
-
direction: metric.direction,
|
|
262
|
-
targetValue: metric.targetValue,
|
|
263
|
-
targetMax: metric.targetMax,
|
|
264
|
-
unit: metric.unit,
|
|
265
|
-
windowKind: metric.windowKind,
|
|
266
|
-
definitionText: metric.definitionText,
|
|
267
|
-
sourceKind: metric.sourceKind,
|
|
268
|
-
}))
|
|
269
|
-
const created = insertProject(options.db, options.idFactory, now, options.operatorId, {
|
|
270
|
-
name: project.name,
|
|
271
|
-
type: project.type,
|
|
272
|
-
goalText: project.goalText,
|
|
273
|
-
coordinatorId: project.coordinatorKey ? memberIds.get(project.coordinatorKey) ?? null : null,
|
|
274
|
-
metrics,
|
|
275
|
-
publish: project.status !== 'draft',
|
|
276
|
-
})
|
|
277
|
-
projectIds.set(project.key, created.id)
|
|
278
|
-
report.created.projects += 1
|
|
279
|
-
|
|
280
|
-
for (const metric of project.metrics ?? []) {
|
|
281
|
-
const metricRow = options.db
|
|
282
|
-
.prepare('SELECT id FROM project_metric WHERE project_id = ? AND name = ?')
|
|
283
|
-
.get(created.id, metric.name) as { id: string } | undefined
|
|
284
|
-
if (!metricRow) continue
|
|
285
|
-
for (const observation of metric.observations ?? []) {
|
|
286
|
-
const exists = options.db
|
|
287
|
-
.prepare('SELECT 1 AS ok FROM metric_observation WHERE metric_id = ? AND source_ref = ? LIMIT 1')
|
|
288
|
-
.get(metricRow.id, observation.sourceRef ?? '') as { ok: number } | undefined
|
|
289
|
-
if (exists) continue
|
|
290
|
-
options.db
|
|
291
|
-
.prepare(
|
|
292
|
-
`INSERT INTO metric_observation
|
|
293
|
-
(id, metric_id, observed_at, window_start, window_end, value, source_ref, ingested_at)
|
|
294
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
295
|
-
)
|
|
296
|
-
.run(
|
|
297
|
-
options.idFactory(),
|
|
298
|
-
metricRow.id,
|
|
299
|
-
now,
|
|
300
|
-
now,
|
|
301
|
-
now,
|
|
302
|
-
observation.value,
|
|
303
|
-
observation.sourceRef ?? null,
|
|
304
|
-
now,
|
|
305
|
-
)
|
|
306
|
-
report.created.observations += 1
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
} catch (error) {
|
|
310
|
-
report.errors.push(`项目「${project.name}」:${(error as Error).message}`)
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// 3) 工作项:按 profile:<key> 去重;负责人是本机操作者,绑通用模板。
|
|
315
|
-
for (const item of profile.workItems ?? []) {
|
|
316
|
-
const dedupeKey = `profile:${item.key}`
|
|
317
|
-
const existing = options.db.prepare('SELECT id FROM work_item WHERE dedupe_key = ?').get(dedupeKey) as
|
|
318
|
-
| { id: string }
|
|
319
|
-
| undefined
|
|
320
|
-
if (existing) continue
|
|
321
|
-
try {
|
|
322
|
-
const projectId = item.projectKey ? projectIds.get(item.projectKey) ?? null : null
|
|
323
|
-
const inserted = insertWorkItem(
|
|
324
|
-
{ db: options.db, ids: options.idFactory, now: () => now, timeZone: 'Asia/Shanghai' },
|
|
325
|
-
{
|
|
326
|
-
domain: item.domain,
|
|
327
|
-
category: item.category,
|
|
328
|
-
title: item.title,
|
|
329
|
-
ownerHumanId: options.operatorId,
|
|
330
|
-
projectId,
|
|
331
|
-
templateVersionId,
|
|
332
|
-
dedupeKey,
|
|
333
|
-
priority: item.priority,
|
|
334
|
-
dueAt: now + (item.dueInDays ?? 7) * DAY,
|
|
335
|
-
createdBy: options.operatorId,
|
|
336
|
-
actorKind: 'human',
|
|
337
|
-
},
|
|
338
|
-
)
|
|
339
|
-
if (item.source) {
|
|
340
|
-
writeEvent(options.db, options.idFactory(), {
|
|
341
|
-
workItemId: inserted.id,
|
|
342
|
-
kind: 'profile_source',
|
|
343
|
-
actorId: options.operatorId,
|
|
344
|
-
actorKind: 'human',
|
|
345
|
-
payload: { source: item.source },
|
|
346
|
-
revision: inserted.revision,
|
|
347
|
-
executionEpoch: inserted.execution_epoch,
|
|
348
|
-
at: now,
|
|
349
|
-
})
|
|
350
|
-
}
|
|
351
|
-
report.created.workItems += 1
|
|
352
|
-
} catch (error) {
|
|
353
|
-
report.errors.push(`工作项「${item.title}」:${(error as Error).message}`)
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// 4) 文档:登记绝对路径 + 内容哈希,不复制正文。
|
|
358
|
-
for (const doc of profile.docs ?? []) {
|
|
359
|
-
const absolute = path.isAbsolute(doc.path) ? doc.path : path.join(workspaceRoot, doc.path)
|
|
360
|
-
if (!existsSync(absolute)) {
|
|
361
|
-
report.errors.push(`文档不存在:${absolute}`)
|
|
362
|
-
continue
|
|
363
|
-
}
|
|
364
|
-
try {
|
|
365
|
-
const outcome = knowledge.importFile(options.operatorId, absolute, {
|
|
366
|
-
title: doc.title,
|
|
367
|
-
status: DEFAULT_DOC_STATUS,
|
|
368
|
-
})
|
|
369
|
-
if (outcome !== 'skipped') report.created.docs += 1
|
|
370
|
-
} catch (error) {
|
|
371
|
-
report.errors.push(`文档「${doc.title ?? doc.path}」:${(error as Error).message}`)
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// 5) 画像自带的总览:面板内撰写,不进源文件。
|
|
376
|
-
if (profile.overview?.title && profile.overview?.body) {
|
|
377
|
-
const exists = options.db
|
|
378
|
-
.prepare("SELECT 1 AS ok FROM kb_doc WHERE title = ? AND source_kind = 'hams' LIMIT 1")
|
|
379
|
-
.get(profile.overview.title) as { ok: number } | undefined
|
|
380
|
-
if (!exists) {
|
|
381
|
-
try {
|
|
382
|
-
knowledge.createDoc(options.operatorId, {
|
|
383
|
-
title: profile.overview.title,
|
|
384
|
-
body: profile.overview.body,
|
|
385
|
-
status: 'draft',
|
|
386
|
-
})
|
|
387
|
-
report.created.docs += 1
|
|
388
|
-
} catch (error) {
|
|
389
|
-
report.errors.push(`总览文档:${(error as Error).message}`)
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// 6) 真实技能与业务表结构:与知识屏「同步真实数据」同一条路径。
|
|
395
|
-
try {
|
|
396
|
-
const sync = knowledge.sync(options.operatorId)
|
|
397
|
-
report.created.skills = sync.skills.created + sync.skills.updated
|
|
398
|
-
report.created.tables = sync.tables.created + sync.tables.updated
|
|
399
|
-
report.warnings.push(...sync.skills.errors.slice(0, 5))
|
|
400
|
-
report.warnings.push(...sync.tables.errors.slice(0, 5))
|
|
401
|
-
} catch (error) {
|
|
402
|
-
report.warnings.push(`知识同步:${(error as Error).message}`)
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// 7) 清理设计期示例模板(有单据引用时保留)。
|
|
406
|
-
for (const legacy of profile.cleanupTemplates ?? []) {
|
|
407
|
-
if (removeTemplateIfUnused(options.db, legacy)) report.created.removedTemplates += 1
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
writeAudit(options.db, options.idFactory(), {
|
|
411
|
-
actorId: 'system',
|
|
412
|
-
actorKind: 'system',
|
|
413
|
-
channel: 'system',
|
|
414
|
-
action: 'workspace/profileApplied',
|
|
415
|
-
subjectKind: 'workspace_profile',
|
|
416
|
-
subjectId: hash,
|
|
417
|
-
after: { file, workspaceRoot, created: report.created, errors: report.errors.length },
|
|
418
|
-
correlationId: `profile-${hash.slice(0, 12)}`,
|
|
419
|
-
at: now,
|
|
420
|
-
})
|
|
421
|
-
|
|
422
|
-
// 有错时不记哈希:下次启动会再试一次;已落库的内容按去重键跳过,不会重复。
|
|
423
|
-
if (!report.errors.length) {
|
|
424
|
-
options.db
|
|
425
|
-
.prepare(
|
|
426
|
-
`INSERT INTO hams_meta (key, value) VALUES ('workspace_profile_sha256', ?)
|
|
427
|
-
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
428
|
-
)
|
|
429
|
-
.run(hash)
|
|
430
|
-
options.db
|
|
431
|
-
.prepare(
|
|
432
|
-
`INSERT INTO hams_meta (key, value) VALUES ('workspace_profile_applied_at', ?)
|
|
433
|
-
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
|
434
|
-
)
|
|
435
|
-
.run(String(now))
|
|
436
|
-
}
|
|
437
|
-
})
|
|
438
|
-
} catch (error) {
|
|
439
|
-
report.status = 'failed'
|
|
440
|
-
report.errors.push((error as Error).message)
|
|
441
|
-
}
|
|
442
|
-
return report
|
|
443
|
-
}
|
package/src/store/backup.ts
DELETED
|
@@ -1,239 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 备份与校验。
|
|
3
|
-
*
|
|
4
|
-
* 实测结论:本版 DatabaseSync 没有 `db.backup`,
|
|
5
|
-
* 因此备份 = `PRAGMA wal_checkpoint(TRUNCATE)` + `VACUUM INTO '<backups/...>'`,
|
|
6
|
-
* 然后**必须重新打开副本**校验:integrity_check、user_version、结构指纹、每张表行数,
|
|
7
|
-
* 三项全对才算成功。校验失败就删掉这一份候选文件(只删刚写出的那一个),不让坏备份混进保留集。
|
|
8
|
-
*
|
|
9
|
-
* 目录约定:$DSH_HOME/hams/backups/,默认保留最近 3 份(dsh-compat §7)。
|
|
10
|
-
* 恢复是人工流程,不提供自动脚本。
|
|
11
|
-
*/
|
|
12
|
-
import { DatabaseSync } from 'node:sqlite'
|
|
13
|
-
import { mkdirSync, readdirSync, statSync, unlinkSync, existsSync } from 'node:fs'
|
|
14
|
-
import path from 'node:path'
|
|
15
|
-
import {
|
|
16
|
-
closeDatabase,
|
|
17
|
-
isInTransaction,
|
|
18
|
-
schemaFingerprint,
|
|
19
|
-
tableRowCounts,
|
|
20
|
-
tableNames,
|
|
21
|
-
userVersion,
|
|
22
|
-
StoreError,
|
|
23
|
-
} from './db.ts'
|
|
24
|
-
|
|
25
|
-
export const BACKUP_DIRNAME = 'backups'
|
|
26
|
-
export const BACKUP_NAME_RE = /^hams-\d{8}-\d{6}-\d{2}\.db$/
|
|
27
|
-
export const DEFAULT_KEEP = 3
|
|
28
|
-
|
|
29
|
-
export class BackupError extends StoreError {
|
|
30
|
-
constructor(code: string, message: string, options?: { cause?: unknown }) {
|
|
31
|
-
super(code, message, options)
|
|
32
|
-
this.name = 'BackupError'
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface BackupFileInfo {
|
|
37
|
-
name: string
|
|
38
|
-
path: string
|
|
39
|
-
bytes: number
|
|
40
|
-
mtimeMs: number
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export interface CheckpointResult {
|
|
44
|
-
busy: number
|
|
45
|
-
log: number
|
|
46
|
-
checkpointed: number
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface BackupReport {
|
|
50
|
-
name: string
|
|
51
|
-
path: string
|
|
52
|
-
bytes: number
|
|
53
|
-
checkpoint: CheckpointResult
|
|
54
|
-
userVersion: number
|
|
55
|
-
tables: number
|
|
56
|
-
rows: Record<string, number>
|
|
57
|
-
integrity: string
|
|
58
|
-
schemaFingerprint: string
|
|
59
|
-
pruned: string[]
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export interface CreateBackupOptions {
|
|
63
|
-
/** 备份目录(通常是 <dataDir>/backups)。 */
|
|
64
|
-
dir: string
|
|
65
|
-
keep?: number
|
|
66
|
-
now?: Date
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function backupDir(dataDir: string): string {
|
|
70
|
-
return path.join(path.resolve(dataDir), BACKUP_DIRNAME)
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function stamp(now: Date): string {
|
|
74
|
-
const iso = now.toISOString() // 2026-11-14T09:05:00.000Z
|
|
75
|
-
const date = iso.slice(0, 10).replaceAll('-', '')
|
|
76
|
-
const time = iso.slice(11, 19).replaceAll(':', '')
|
|
77
|
-
return `${date}-${time}`
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function pickName(dir: string, now: Date): string {
|
|
81
|
-
// 序号永远带两位,既解决同秒碰撞,又保证文件名按字典序等于按生成顺序:
|
|
82
|
-
// hams-20261114-091048-01.db < ...-02.db < hams-20261114-091049-01.db
|
|
83
|
-
const base = `hams-${stamp(now)}`
|
|
84
|
-
let seq = 1
|
|
85
|
-
while (existsSync(path.join(dir, `${base}-${String(seq).padStart(2, '0')}.db`))) seq += 1
|
|
86
|
-
return `${base}-${String(seq).padStart(2, '0')}.db`
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function listBackups(dir: string): BackupFileInfo[] {
|
|
90
|
-
const resolved = path.resolve(dir)
|
|
91
|
-
if (!existsSync(resolved)) return []
|
|
92
|
-
const files: BackupFileInfo[] = []
|
|
93
|
-
for (const name of readdirSync(resolved)) {
|
|
94
|
-
if (!BACKUP_NAME_RE.test(name)) continue
|
|
95
|
-
const full = path.join(resolved, name)
|
|
96
|
-
const stat = statSync(full)
|
|
97
|
-
if (!stat.isFile()) continue
|
|
98
|
-
files.push({ name, path: full, bytes: stat.size, mtimeMs: stat.mtimeMs })
|
|
99
|
-
}
|
|
100
|
-
// 文件名里的时间戳可排序,等价于按生成时间排序。
|
|
101
|
-
files.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0))
|
|
102
|
-
return files
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function checkpoint(db: DatabaseSync, mode: 'PASSIVE' | 'TRUNCATE' = 'TRUNCATE'): CheckpointResult {
|
|
106
|
-
const row = db.prepare(`PRAGMA wal_checkpoint(${mode})`).get() as CheckpointResult
|
|
107
|
-
return { busy: Number(row.busy), log: Number(row.log), checkpointed: Number(row.checkpointed) }
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function rowCountsEqual(a: Record<string, number>, b: Record<string, number>): string[] {
|
|
111
|
-
const mismatches: string[] = []
|
|
112
|
-
for (const table of Object.keys(a)) {
|
|
113
|
-
if (b[table] !== a[table]) mismatches.push(`${table}: ${a[table]} -> ${b[table] ?? 'missing'}`)
|
|
114
|
-
}
|
|
115
|
-
for (const table of Object.keys(b)) {
|
|
116
|
-
if (!(table in a)) mismatches.push(`${table}: 副本多出该表`)
|
|
117
|
-
}
|
|
118
|
-
return mismatches
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export interface VerifyReport {
|
|
122
|
-
path: string
|
|
123
|
-
integrity: string
|
|
124
|
-
userVersion: number
|
|
125
|
-
sourceUserVersion: number
|
|
126
|
-
tables: number
|
|
127
|
-
rowsMatch: boolean
|
|
128
|
-
schemaMatch: boolean
|
|
129
|
-
mismatches: string[]
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/** 重新打开副本做四项核对;它不信任写入方,也不复用源连接。 */
|
|
133
|
-
export function verifyBackup(sourceDb: DatabaseSync, backupPath: string): VerifyReport {
|
|
134
|
-
const copy = new DatabaseSync(backupPath, { readOnly: true })
|
|
135
|
-
try {
|
|
136
|
-
const integrityRow = copy.prepare('PRAGMA integrity_check').get() as { integrity_check: string }
|
|
137
|
-
const integrity = String(integrityRow.integrity_check)
|
|
138
|
-
const sourceRows = tableRowCounts(sourceDb)
|
|
139
|
-
const copyRows = tableRowCounts(copy)
|
|
140
|
-
const mismatches = rowCountsEqual(sourceRows, copyRows)
|
|
141
|
-
const rowsMatch = mismatches.length === 0
|
|
142
|
-
const schemaMatch = schemaFingerprint(sourceDb) === schemaFingerprint(copy)
|
|
143
|
-
if (!schemaMatch) mismatches.push('结构指纹不一致')
|
|
144
|
-
return {
|
|
145
|
-
path: backupPath,
|
|
146
|
-
integrity,
|
|
147
|
-
userVersion: userVersion(copy),
|
|
148
|
-
sourceUserVersion: userVersion(sourceDb),
|
|
149
|
-
tables: tableNames(copy).length,
|
|
150
|
-
rowsMatch,
|
|
151
|
-
schemaMatch,
|
|
152
|
-
mismatches,
|
|
153
|
-
}
|
|
154
|
-
} finally {
|
|
155
|
-
closeDatabase(copy)
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* 生成一份可恢复的备份并校验。返回报告;校验失败时抛错且不留下坏文件。
|
|
161
|
-
*/
|
|
162
|
-
export function createBackup(db: DatabaseSync, options: CreateBackupOptions): BackupReport {
|
|
163
|
-
const dir = path.resolve(options.dir)
|
|
164
|
-
const keep = options.keep ?? DEFAULT_KEEP
|
|
165
|
-
const now = options.now ?? new Date()
|
|
166
|
-
|
|
167
|
-
if (isInTransaction(db)) {
|
|
168
|
-
throw new BackupError('BACKUP_IN_TRANSACTION', '备份不能在事务内做:先提交当前写事务')
|
|
169
|
-
}
|
|
170
|
-
if (!Number.isInteger(keep) || keep < 1) {
|
|
171
|
-
throw new BackupError('BAD_KEEP', `keep 必须是 ≥1 的整数,收到 ${keep}`)
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
mkdirSync(dir, { recursive: true })
|
|
175
|
-
const checkpointResult = checkpoint(db, 'TRUNCATE')
|
|
176
|
-
const name = pickName(dir, now)
|
|
177
|
-
const target = path.join(dir, name)
|
|
178
|
-
|
|
179
|
-
try {
|
|
180
|
-
db.prepare('VACUUM INTO ?').run(target)
|
|
181
|
-
} catch (error) {
|
|
182
|
-
if (existsSync(target)) unlinkSync(target)
|
|
183
|
-
throw new BackupError('BACKUP_FAILED', `VACUUM INTO 失败:${(error as Error).message}`, { cause: error })
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
let verification: VerifyReport
|
|
187
|
-
try {
|
|
188
|
-
verification = verifyBackup(db, target)
|
|
189
|
-
} catch (error) {
|
|
190
|
-
unlinkSync(target)
|
|
191
|
-
throw new BackupError('BACKUP_UNVERIFIED', `备份无法重新打开校验,已丢弃 ${name}`, { cause: error })
|
|
192
|
-
}
|
|
193
|
-
const ok =
|
|
194
|
-
verification.integrity === 'ok' &&
|
|
195
|
-
verification.rowsMatch &&
|
|
196
|
-
verification.schemaMatch &&
|
|
197
|
-
verification.userVersion === verification.sourceUserVersion
|
|
198
|
-
if (!ok) {
|
|
199
|
-
unlinkSync(target)
|
|
200
|
-
throw new BackupError(
|
|
201
|
-
'BACKUP_UNVERIFIED',
|
|
202
|
-
`备份校验失败,已丢弃 ${name}:${verification.mismatches.join('; ') || `integrity=${verification.integrity}`}`,
|
|
203
|
-
)
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const stat = statSync(target)
|
|
207
|
-
const pruned = pruneBackups(dir, keep)
|
|
208
|
-
if (!existsSync(target)) {
|
|
209
|
-
throw new BackupError('BACKUP_UNEXPECTEDLY_PRUNED', `保留策略误删了刚生成的备份:${target}`)
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return {
|
|
213
|
-
name,
|
|
214
|
-
path: target,
|
|
215
|
-
bytes: stat.size,
|
|
216
|
-
checkpoint: checkpointResult,
|
|
217
|
-
userVersion: verification.userVersion,
|
|
218
|
-
tables: verification.tables,
|
|
219
|
-
rows: tableRowCounts(db),
|
|
220
|
-
integrity: verification.integrity,
|
|
221
|
-
schemaFingerprint: schemaFingerprint(db),
|
|
222
|
-
pruned,
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** 按文件名(= 生成顺序)保留最新 keep 份;只删 backups/ 下符合命名规则的普通文件。 */
|
|
227
|
-
export function pruneBackups(dir: string, keep: number): string[] {
|
|
228
|
-
const resolved = path.resolve(dir)
|
|
229
|
-
const files = listBackups(resolved)
|
|
230
|
-
const pruned: string[] = []
|
|
231
|
-
files.forEach((file, index) => {
|
|
232
|
-
if (index < keep) return
|
|
233
|
-
if (path.dirname(file.path) !== resolved) return
|
|
234
|
-
if (!BACKUP_NAME_RE.test(file.name)) return
|
|
235
|
-
unlinkSync(file.path)
|
|
236
|
-
pruned.push(file.name)
|
|
237
|
-
})
|
|
238
|
-
return pruned
|
|
239
|
-
}
|