dsh-hams 0.1.2 → 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.
Files changed (49) hide show
  1. package/README.md +4 -0
  2. package/dist/index.js +11335 -0
  3. package/package.json +9 -5
  4. package/src/domain/check.ts +0 -193
  5. package/src/domain/errors.ts +0 -59
  6. package/src/domain/evidence.ts +0 -134
  7. package/src/domain/ids.ts +0 -31
  8. package/src/host/chat-runtime.ts +0 -489
  9. package/src/host/dsh-adapter.ts +0 -867
  10. package/src/host/session-adapter.ts +0 -276
  11. package/src/http/handler.ts +0 -1132
  12. package/src/index.js +0 -53
  13. package/src/service/audit.ts +0 -92
  14. package/src/service/chat.ts +0 -1100
  15. package/src/service/contracts.ts +0 -193
  16. package/src/service/defaults.ts +0 -182
  17. package/src/service/deliverables.ts +0 -240
  18. package/src/service/hams.ts +0 -3103
  19. package/src/service/knowledge.ts +0 -940
  20. package/src/service/member-config.ts +0 -39
  21. package/src/service/members.ts +0 -506
  22. package/src/service/memory.ts +0 -375
  23. package/src/service/permissions.ts +0 -118
  24. package/src/service/project-lifecycle.ts +0 -233
  25. package/src/service/projects.ts +0 -840
  26. package/src/service/receipts.ts +0 -63
  27. package/src/service/templates.ts +0 -140
  28. package/src/service/tick.ts +0 -751
  29. package/src/service/tools.ts +0 -711
  30. package/src/service/types.ts +0 -219
  31. package/src/service/work-item-create.ts +0 -159
  32. package/src/service/workspace-profile.ts +0 -443
  33. package/src/store/backup.ts +0 -239
  34. package/src/store/bootstrap.ts +0 -225
  35. package/src/store/db.ts +0 -179
  36. package/src/store/lock.ts +0 -194
  37. package/src/store/migrations.ts +0 -211
  38. package/src/store/schema/001_p1.sql +0 -846
  39. package/src/store/schema/001_p1.sql.ts +0 -865
  40. package/src/store/schema/002_knowledge.sql +0 -54
  41. package/src/store/schema/002_knowledge.sql.ts +0 -68
  42. package/src/store/schema/003_projects.sql.ts +0 -3
  43. package/src/store/schema/004_team.sql.ts +0 -50
  44. package/src/store/schema/005_target_check.sql.ts +0 -22
  45. package/src/store/schema/006_target_flow.sql.ts +0 -19
  46. package/src/store/schema/007_project_delete.sql.ts +0 -17
  47. package/src/store/schema/008_memory.sql.ts +0 -47
  48. package/src/store/schema/generate-projects.mjs +0 -16
  49. package/src/store/seed.ts +0 -114
@@ -1,840 +0,0 @@
1
- /**
2
- * 项目读模型与写命令的落库实现(M6)。
3
- *
4
- * 这一层只碰 `project / project_target_version / project_metric / metric_observation /
5
- * project_budget` 五张 P1 表,不做事务包裹与权限判断——那是 HamsService 的职责。
6
- * 拆出来的原因只有一个:项目详情要拼的查询比工作项多,塞进 hams.ts 会让闭环代码更难读。
7
- *
8
- * 口径纪律(backend-design §7):
9
- * - 目标一旦发布就是冻结版本,改目标要发新版本,这里不允许 UPDATE project_target_version;
10
- * - 指标只存「预置计算器 + 参数」,不存自由表达式;读数 value=NULL 表示缺数,界面必须显示未知;
11
- * - 推进计数(no_progress_streak)只有 tick 写,本模块不碰。
12
- */
13
- import type { DatabaseSync } from 'node:sqlite'
14
- import { stateInvalid } from '../domain/errors.ts'
15
- import type { IdFactory } from '../domain/ids.ts'
16
- import { defaultCoordinatorId } from './defaults.ts'
17
-
18
- export const PROJECT_TYPES = ['one_off', 'continuous'] as const
19
- export const PROJECT_STATUSES = ['draft', 'active', 'paused', 'archived'] as const
20
- export const METRIC_DIRECTIONS = ['higher', 'lower', 'range'] as const
21
- export const METRIC_WINDOWS = ['day', 'week', 'month', 'snapshot'] as const
22
-
23
- export interface ProjectRow {
24
- id: string
25
- name: string
26
- type: string
27
- status: string
28
- owner_human_id: string
29
- coordinator_id: string | null
30
- goal_text: string
31
- current_target_version: number
32
- no_progress_streak: number
33
- revision: number
34
- created_at: number
35
- updated_at: number
36
- /** 软删标记:归档项目「删除」后置时间;列表与详情都不再出现。 */
37
- deleted_at?: number | null
38
- deleted_by?: string | null
39
- }
40
-
41
- export interface MetricInput {
42
- name: string
43
- direction: string
44
- targetValue?: number | null
45
- targetMax?: number | null
46
- unit?: string | null
47
- windowKind: string
48
- definitionText?: string | null
49
- sourceKind?: string | null
50
- calculatorKey?: string | null
51
- calculatorParams?: Record<string, unknown>
52
- sourceRef?: string | null
53
- }
54
-
55
- export interface CreateProjectInput {
56
- name: string
57
- type: string
58
- goalText: string
59
- ownerHumanId?: string
60
- coordinatorId?: string | null
61
- metrics?: MetricInput[]
62
- /** 默认 true:创建即发布目标(status=active);false 则留草稿,发布动作走 project/status。 */
63
- publish?: boolean
64
- }
65
-
66
- export interface ProjectSummary {
67
- id: string
68
- name: string
69
- type: string
70
- status: string
71
- owner_human_id: string
72
- owner_name: string | null
73
- coordinator_id: string | null
74
- coordinator_name: string | null
75
- goal_text: string
76
- current_target_version: number
77
- revision: number
78
- created_at: number
79
- updated_at: number
80
- work_items: { total: number; open: number; byStatus: Record<string, number> }
81
- metric_count: number
82
- observation_count: number
83
- /** 项目「记忆」页签的有效条数(不含已归档)。 */
84
- memory_count: number
85
- }
86
-
87
- export interface MetricWithLatest {
88
- id: string
89
- name: string
90
- direction: string
91
- target_value: number | null
92
- target_max: number | null
93
- unit: string | null
94
- definition_text: string
95
- calculator_key: string | null
96
- window_kind: string
97
- source_kind: string | null
98
- source_ref: string | null
99
- calculator_params_json: string
100
- latest: {
101
- value: number | null
102
- observed_at: number
103
- window_start: number
104
- window_end: number
105
- source_ref: string | null
106
- } | null
107
- series: { value: number | null; window_end: number }[]
108
- }
109
-
110
- export interface ProjectDetail extends ProjectSummary {
111
- /** 草稿的项目:向导保存到一半的内容(步骤、指标、保存时间),发布后清空。 */
112
- draft: ProjectDraftState | null
113
- target_versions: {
114
- id: string
115
- version: number
116
- goal_text: string
117
- published_by: string
118
- published_by_name: string | null
119
- published_at: number
120
- }[]
121
- current_target: {
122
- id: string
123
- version: number
124
- goal_text: string
125
- published_at: number
126
- } | null
127
- metrics: MetricWithLatest[]
128
- budgets: { period_kind: string; period_start: string; metric: string; used: number; limit_value: number }[]
129
- recent_items: {
130
- id: string
131
- title: string
132
- domain: string
133
- status: string
134
- owner_human_id: string
135
- assignee_id: string | null
136
- due_at: number | null
137
- updated_at: number
138
- }[]
139
- }
140
-
141
- const MAX_NAME = 80
142
- const MAX_GOAL = 4000
143
- const MAX_METRICS = 12
144
- const MAX_METRIC_NAME = 40
145
-
146
- function text(value: unknown, field: string, max = MAX_NAME, allowEmpty = false): string {
147
- if (typeof value !== 'string') throw stateInvalid(`${field} 必须是字符串`)
148
- const trimmed = value.trim()
149
- if (!allowEmpty && !trimmed) throw stateInvalid(`${field} 不能为空`)
150
- if (trimmed.length > max) throw stateInvalid(`${field} 超过 ${max} 字`)
151
- return trimmed
152
- }
153
-
154
- function optionalText(value: unknown, field: string, max = MAX_NAME): string | null {
155
- if (value === undefined || value === null) return null
156
- if (typeof value !== 'string') throw stateInvalid(`${field} 必须是字符串`)
157
- const trimmed = value.trim()
158
- if (!trimmed) return null
159
- if (trimmed.length > max) throw stateInvalid(`${field} 超过 ${max} 字`)
160
- return trimmed
161
- }
162
-
163
- function finiteNumber(value: unknown, field: string): number | null {
164
- if (value === undefined || value === null) return null
165
- if (typeof value !== 'number' || !Number.isFinite(value)) throw stateInvalid(`${field} 必须是有限数字`)
166
- return value
167
- }
168
-
169
- function oneOf<T extends string>(value: unknown, field: string, allowed: readonly T[]): T {
170
- if (typeof value !== 'string' || !(allowed as readonly string[]).includes(value)) {
171
- throw stateInvalid(`${field} 只能是 ${allowed.join(' / ')}`)
172
- }
173
- return value as T
174
- }
175
-
176
- function memberName(db: DatabaseSync, id: string | null): string | null {
177
- if (!id) return null
178
- const row = db.prepare('SELECT name FROM member WHERE id = ?').get(id) as { name: string } | undefined
179
- return row?.name ?? null
180
- }
181
-
182
- function statusMap(db: DatabaseSync, projectIds: string[]): Map<string, Record<string, number>> {
183
- const result = new Map<string, Record<string, number>>()
184
- if (!projectIds.length) return result
185
- const placeholders = projectIds.map(() => '?').join(',')
186
- const rows = db
187
- .prepare(
188
- `SELECT project_id, status, COUNT(*) AS c FROM work_item
189
- WHERE project_id IN (${placeholders})
190
- GROUP BY project_id, status`,
191
- )
192
- .all(...(projectIds as never[])) as { project_id: string; status: string; c: number }[]
193
- for (const row of rows) {
194
- const bucket = result.get(row.project_id) ?? {}
195
- bucket[row.status] = Number(row.c)
196
- result.set(row.project_id, bucket)
197
- }
198
- return result
199
- }
200
-
201
- export function listProjects(
202
- db: DatabaseSync,
203
- filter: { status?: string; type?: string; limit?: number; projectId?: string } = {},
204
- ): ProjectSummary[] {
205
- const clauses: string[] = []
206
- const params: unknown[] = []
207
- if (filter.projectId) { clauses.push('p.id = ?'); params.push(filter.projectId) }
208
- if (filter.status) {
209
- clauses.push('p.status = ?')
210
- params.push(filter.status)
211
- }
212
- if (filter.type) {
213
- clauses.push('p.type = ?')
214
- params.push(filter.type)
215
- }
216
- const where = `WHERE p.deleted_at IS NULL${clauses.length ? ` AND ${clauses.join(' AND ')}` : ''}`
217
- const limit = Math.min(Math.max(filter.limit ?? 100, 1), 200)
218
- const rows = db
219
- .prepare(
220
- `SELECT p.*, o.name AS owner_name, c.name AS coordinator_name,
221
- (SELECT COUNT(*) FROM work_item w WHERE w.project_id = p.id) AS item_total,
222
- (SELECT COUNT(*) FROM work_item w WHERE w.project_id = p.id
223
- AND w.status NOT IN ('completed','cancelled','failed')) AS item_open,
224
- (SELECT COUNT(*) FROM project_metric m JOIN project_target_version t ON t.id=m.version_id WHERE m.project_id = p.id AND t.version=p.current_target_version) AS metric_count,
225
- (SELECT COUNT(*) FROM project_metric m
226
- JOIN metric_observation ob ON ob.metric_id = m.id
227
- WHERE m.project_id = p.id) AS observation_count,
228
- (SELECT COUNT(*) FROM project_memory pm
229
- WHERE pm.project_id = p.id AND pm.status = 'active') AS memory_count
230
- FROM project p
231
- LEFT JOIN member o ON o.id = p.owner_human_id
232
- LEFT JOIN member c ON c.id = p.coordinator_id
233
- ${where}
234
- ORDER BY p.updated_at DESC
235
- LIMIT ${limit}`,
236
- )
237
- .all(...(params as never[])) as (ProjectRow & {
238
- owner_name: string | null
239
- coordinator_name: string | null
240
- item_total: number
241
- item_open: number
242
- metric_count: number
243
- observation_count: number
244
- memory_count: number
245
- })[]
246
-
247
- const byProject = statusMap(db, rows.map((row) => row.id))
248
- return rows.map((row) => ({
249
- id: row.id,
250
- name: row.name,
251
- type: row.type,
252
- status: row.status,
253
- owner_human_id: row.owner_human_id,
254
- owner_name: row.owner_name,
255
- coordinator_id: row.coordinator_id,
256
- coordinator_name: row.coordinator_name,
257
- goal_text: row.goal_text,
258
- current_target_version: Number(row.current_target_version),
259
- revision: Number(row.revision),
260
- created_at: Number(row.created_at),
261
- updated_at: Number(row.updated_at),
262
- work_items: {
263
- total: Number(row.item_total),
264
- open: Number(row.item_open),
265
- byStatus: byProject.get(row.id) ?? {},
266
- },
267
- metric_count: Number(row.metric_count),
268
- observation_count: Number(row.observation_count),
269
- memory_count: Number(row.memory_count),
270
- }))
271
- }
272
-
273
- export function getProject(db: DatabaseSync, projectId: string): ProjectDetail {
274
- const row = db.prepare('SELECT * FROM project WHERE id = ?').get(projectId) as ProjectRow | undefined
275
- if (!row) throw stateInvalid(`项目不存在:${projectId}`)
276
- if (row.deleted_at) throw stateInvalid('项目已删除,不再出现在工作台里', '刷新项目列表')
277
-
278
- const summary = listProjects(db, { projectId, limit: 1 }).find((item) => item.id === projectId)
279
- if (!summary) throw stateInvalid(`项目读模型缺失:${projectId}`)
280
-
281
- const targetVersions = db
282
- .prepare(
283
- `SELECT t.id, t.version, t.goal_text, t.published_by, t.published_at, m.name AS published_by_name
284
- FROM project_target_version t
285
- LEFT JOIN member m ON m.id = t.published_by
286
- WHERE t.project_id = ?
287
- ORDER BY t.version DESC`,
288
- )
289
- .all(projectId) as {
290
- id: string
291
- version: number
292
- goal_text: string
293
- published_by: string
294
- published_by_name: string | null
295
- published_at: number
296
- }[]
297
- const currentTarget = targetVersions.find((item) => Number(item.version) === Number(row.current_target_version)) ?? null
298
-
299
- const metricRows = db
300
- .prepare(
301
- `SELECT m.* FROM project_metric m
302
- WHERE m.project_id = ? AND m.version_id = ?
303
- ORDER BY m.name`,
304
- )
305
- .all(projectId, currentTarget?.id ?? '') as {
306
- id: string
307
- name: string
308
- direction: string
309
- target_value: number | null
310
- target_max: number | null
311
- unit: string | null
312
- definition_text: string
313
- calculator_key: string | null
314
- window_kind: string
315
- source_kind: string | null
316
- source_ref: string | null
317
- calculator_params_json: string
318
- }[]
319
-
320
- const observations = metricRows.length
321
- ? (db
322
- .prepare(
323
- `SELECT metric_id, value, observed_at, window_start, window_end, source_ref
324
- FROM metric_observation
325
- WHERE metric_id IN (${metricRows.map(() => '?').join(',')})
326
- ORDER BY window_end DESC, observed_at DESC, rowid DESC`,
327
- )
328
- .all(...(metricRows.map((metric) => metric.id) as never[])) as {
329
- metric_id: string
330
- value: number | null
331
- observed_at: number
332
- window_start: number
333
- window_end: number
334
- source_ref: string | null
335
- }[])
336
- : []
337
- const byMetric = new Map<string, typeof observations>()
338
- for (const observation of observations) {
339
- const list = byMetric.get(observation.metric_id) ?? []
340
- list.push(observation)
341
- byMetric.set(observation.metric_id, list)
342
- }
343
-
344
- const metrics: MetricWithLatest[] = metricRows.map((metric) => {
345
- const list = byMetric.get(metric.id) ?? []
346
- const latest = list[0] ?? null
347
- return {
348
- id: metric.id,
349
- name: metric.name,
350
- direction: metric.direction,
351
- target_value: metric.target_value === null ? null : Number(metric.target_value),
352
- target_max: metric.target_max === null ? null : Number(metric.target_max),
353
- unit: metric.unit,
354
- definition_text: metric.definition_text,
355
- calculator_key: metric.calculator_key,
356
- window_kind: metric.window_kind,
357
- source_kind: metric.source_kind,
358
- source_ref: metric.source_ref,
359
- calculator_params_json: metric.calculator_params_json,
360
- latest: latest
361
- ? {
362
- value: latest.value === null ? null : Number(latest.value),
363
- observed_at: Number(latest.observed_at),
364
- window_start: Number(latest.window_start),
365
- window_end: Number(latest.window_end),
366
- source_ref: latest.source_ref,
367
- }
368
- : null,
369
- series: list
370
- .slice(0, 12)
371
- .reverse()
372
- .map((item) => ({ value: item.value === null ? null : Number(item.value), window_end: Number(item.window_end) })),
373
- }
374
- })
375
-
376
- const budgets = db
377
- .prepare(
378
- `SELECT period_kind, period_start, metric, used, limit_value
379
- FROM project_budget WHERE project_id = ?
380
- ORDER BY period_start DESC, metric LIMIT 40`,
381
- )
382
- .all(projectId) as ProjectDetail['budgets']
383
-
384
- const recentItems = db
385
- .prepare(
386
- `SELECT id, title, domain, status, owner_human_id, assignee_id, due_at, updated_at
387
- FROM work_item WHERE project_id = ?
388
- ORDER BY updated_at DESC LIMIT 8`,
389
- )
390
- .all(projectId) as ProjectDetail['recent_items']
391
-
392
- return {
393
- ...summary,
394
- draft: row.status === 'draft' ? readProjectDraft(db, projectId) : null,
395
- target_versions: targetVersions.map((item) => ({
396
- ...item,
397
- version: Number(item.version),
398
- published_at: Number(item.published_at),
399
- })),
400
- current_target: currentTarget
401
- ? {
402
- id: currentTarget.id,
403
- version: Number(currentTarget.version),
404
- goal_text: currentTarget.goal_text,
405
- published_at: Number(currentTarget.published_at),
406
- }
407
- : null,
408
- metrics,
409
- budgets: budgets.map((item) => ({
410
- ...item,
411
- used: Number(item.used),
412
- limit_value: Number(item.limit_value),
413
- })),
414
- recent_items: recentItems.map((item) => ({
415
- ...item,
416
- due_at: item.due_at === null ? null : Number(item.due_at),
417
- updated_at: Number(item.updated_at),
418
- })),
419
- }
420
- }
421
-
422
- export interface InsertedProject {
423
- id: string
424
- revision: number
425
- targetVersionId: string | null
426
- }
427
-
428
- export function insertProject(
429
- db: DatabaseSync,
430
- ids: IdFactory,
431
- now: number,
432
- actorId: string,
433
- input: CreateProjectInput,
434
- ): InsertedProject {
435
- const name = text(input.name, '项目名')
436
- const type = oneOf(input.type, '项目类型', PROJECT_TYPES)
437
- const publish = input.publish !== false
438
- // 草稿向导可以在只填了名称时保存,所以草稿允许空目标;发布目标时仍由 publishTarget 校验。
439
- const goalText = text(input.goalText ?? '', '目标', MAX_GOAL, !publish)
440
- const ownerHumanId = input.ownerHumanId ?? actorId
441
- // 没显式选推进 bot 时用默认的「项目管理」;显式 null 表示这个项目不要推进 bot。
442
- const coordinatorId =
443
- input.coordinatorId === undefined
444
- ? defaultCoordinatorId(db)
445
- : input.coordinatorId === null || input.coordinatorId === ''
446
- ? null
447
- : text(input.coordinatorId, '推进 bot', 64)
448
-
449
- const duplicate = db
450
- .prepare('SELECT id FROM project WHERE name = ? AND deleted_at IS NULL')
451
- .get(name) as { id: string } | undefined
452
- if (duplicate) {
453
- throw stateInvalid(`项目名已存在:${name}`, '换一个名字,或打开已有项目')
454
- }
455
-
456
- const owner = db.prepare('SELECT id, kind FROM member WHERE id = ?').get(ownerHumanId) as
457
- | { id: string; kind: string }
458
- | undefined
459
- if (!owner || owner.kind !== 'human') throw stateInvalid('项目负责人必须是存在的人')
460
- if (coordinatorId) {
461
- const coordinator = db.prepare('SELECT id, kind FROM member WHERE id = ?').get(coordinatorId) as
462
- | { id: string; kind: string }
463
- | undefined
464
- if (!coordinator) throw stateInvalid(`推进成员不存在:${coordinatorId}`)
465
- }
466
-
467
- const metrics = Array.isArray(input.metrics) ? input.metrics : []
468
- if (metrics.length > MAX_METRICS) throw stateInvalid(`指标最多 ${MAX_METRICS} 个`)
469
-
470
- const id = ids()
471
- const status = publish ? 'active' : 'draft'
472
-
473
- db.prepare(
474
- `INSERT INTO project
475
- (id, name, type, status, owner_human_id, coordinator_id, goal_text,
476
- current_target_version, no_progress_streak, revision, created_at, updated_at)
477
- VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, 1, ?, ?)`,
478
- ).run(id, name, type, status, ownerHumanId, coordinatorId, goalText, now, now)
479
-
480
- let targetVersionId: string | null = null
481
- if (publish) {
482
- targetVersionId = ids()
483
- db.prepare(
484
- `INSERT INTO project_target_version (id, project_id, version, goal_text, published_by, published_at)
485
- VALUES (?, ?, 1, ?, ?, ?)`,
486
- ).run(targetVersionId, id, goalText, actorId, now)
487
- db.prepare('UPDATE project SET current_target_version = 1 WHERE id = ?').run(id)
488
- for (const metric of metrics) insertMetric(db, ids, id, targetVersionId, metric)
489
- }
490
-
491
- return { id, revision: 1, targetVersionId }
492
- }
493
-
494
- export function insertMetric(
495
- db: DatabaseSync,
496
- ids: IdFactory,
497
- projectId: string,
498
- versionId: string,
499
- input: MetricInput,
500
- ): string {
501
- const name = text(input.name, '指标名', MAX_METRIC_NAME)
502
- const direction = oneOf(input.direction, '指标方向', METRIC_DIRECTIONS)
503
- const windowKind = oneOf(input.windowKind, '统计窗口', METRIC_WINDOWS)
504
- const targetValue = finiteNumber(input.targetValue, '目标值')
505
- const targetMax = finiteNumber(input.targetMax, '上界')
506
- const unit = optionalText(input.unit, '单位', 20)
507
- const definitionText = optionalText(input.definitionText, '口径说明', 500) ?? ''
508
- const sourceKind = input.sourceKind
509
- ? oneOf(input.sourceKind, '数据来源', ['ledger', 'work_item', 'webhook', 'manual'] as const)
510
- : 'manual'
511
- if (direction === 'range' && (targetValue === null || targetMax === null)) {
512
- throw stateInvalid('区间指标要同时给下界与上界')
513
- }
514
- if (direction !== 'range' && targetValue === null) {
515
- throw stateInvalid('非区间指标要给目标值')
516
- }
517
- if (direction === 'range' && targetMax! < targetValue!) throw stateInvalid('区间上界不能小于下界')
518
- const calculatorKey = optionalText(input.calculatorKey, '计算方式', 80)
519
- if (calculatorKey && !['manual_latest', 'work_item_count', 'work_item_completion_rate'].includes(calculatorKey)) throw stateInvalid('未知的预置计算方式')
520
- if (calculatorKey === 'manual_latest' && sourceKind !== 'manual') throw stateInvalid('manual_latest 需要 manual 来源')
521
- if (calculatorKey?.startsWith('work_item_') && sourceKind !== 'work_item') throw stateInvalid('工作项计算器需要 work_item 来源')
522
- const params = input.calculatorParams ?? {}
523
- if (!params || typeof params !== 'object' || Array.isArray(params) || Object.keys(params).some(key => !['domain', 'status'].includes(key)) || Object.values(params).some(value => typeof value !== 'string')) throw stateInvalid('计算器参数仅支持 domain / status 字符串筛选')
524
- const sourceRef = optionalText(input.sourceRef, '数据来源引用', 500)
525
- const id = ids()
526
- db.prepare(
527
- `INSERT INTO project_metric
528
- (id, project_id, version_id, name, direction, target_value, target_max, unit,
529
- definition_text, calculator_key, calculator_params_json, window_kind, source_kind, source_ref)
530
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
531
- ).run(id, projectId, versionId, name, direction, targetValue, targetMax, unit, definitionText, calculatorKey, JSON.stringify(params), windowKind, sourceKind, sourceRef)
532
- return id
533
- }
534
-
535
- export interface StatusChange {
536
- before: string
537
- after: string
538
- revision: number
539
- targetVersionId: string | null
540
- }
541
-
542
- /** 状态机:draft → active(发布目标)→ paused ⇄ active;任一状态可归档,归档不删数据。 */
543
- export function setProjectStatus(
544
- db: DatabaseSync,
545
- ids: IdFactory,
546
- now: number,
547
- actorId: string,
548
- projectId: string,
549
- to: string,
550
- ): StatusChange {
551
- const project = db.prepare('SELECT * FROM project WHERE id = ?').get(projectId) as ProjectRow | undefined
552
- if (!project) throw stateInvalid(`项目不存在:${projectId}`)
553
- if (project.deleted_at) throw stateInvalid('项目已删除,不能再改状态')
554
- const target = oneOf(to, '项目状态', PROJECT_STATUSES)
555
- const before = project.status
556
- if (before === target) return { before, after: target, revision: Number(project.revision), targetVersionId: null }
557
- const allowed: Record<string, string[]> = {
558
- draft: ['active', 'archived'],
559
- active: ['paused', 'archived'],
560
- paused: ['active', 'archived'],
561
- archived: [],
562
- }
563
- if (!allowed[before]?.includes(target)) {
564
- throw stateInvalid(`项目状态不能从 ${before} 改为 ${target}`, '归档后只能新建项目;草稿要先发布目标')
565
- }
566
-
567
- let targetVersionId: string | null = null
568
- if (target === 'active' && Number(project.current_target_version) === 0) {
569
- const goal = String(project.goal_text ?? '').trim()
570
- if (!goal) throw stateInvalid('没有目标口径的项目不能进入进行中', '先写目标,再发布')
571
- targetVersionId = ids()
572
- db.prepare(
573
- `INSERT INTO project_target_version (id, project_id, version, goal_text, published_by, published_at)
574
- VALUES (?, ?, 1, ?, ?, ?)`,
575
- ).run(targetVersionId, projectId, goal, actorId, now)
576
- db.prepare('UPDATE project SET current_target_version = 1 WHERE id = ?').run(projectId)
577
- }
578
-
579
- const revision = Number(project.revision) + 1
580
- db.prepare('UPDATE project SET status = ?, revision = ?, updated_at = ? WHERE id = ?').run(target, revision, now, projectId)
581
- return { before, after: target, revision, targetVersionId }
582
- }
583
-
584
- export interface ObservationInput {
585
- projectId: string
586
- metricId: string
587
- value?: number | null
588
- observedAt?: number
589
- windowStart?: number
590
- windowEnd?: number
591
- sourceRef?: string | null
592
- }
593
-
594
- export interface InsertedObservation {
595
- id: string
596
- metricId: string
597
- windowStart: number
598
- windowEnd: number
599
- value: number | null
600
- }
601
-
602
- const WINDOW_MS: Record<string, number> = {
603
- day: 86_400_000,
604
- week: 7 * 86_400_000,
605
- month: 30 * 86_400_000,
606
- snapshot: 0,
607
- }
608
-
609
- export function recordObservation(
610
- db: DatabaseSync,
611
- ids: IdFactory,
612
- now: number,
613
- input: ObservationInput,
614
- ): InsertedObservation {
615
- const metric = db
616
- .prepare(
617
- `SELECT m.id, m.window_kind, m.project_id FROM project_metric m WHERE m.id = ? AND m.project_id = ?`,
618
- )
619
- .get(input.metricId, input.projectId) as { id: string; window_kind: string; project_id: string } | undefined
620
- if (!metric) throw stateInvalid(`指标不属于该项目:${input.metricId}`)
621
-
622
- const observedAt = input.observedAt === undefined ? now : finiteNumber(input.observedAt, '观测时间')
623
- if (observedAt === null) throw stateInvalid('观测时间必须给值')
624
- const value = input.value === undefined ? null : finiteNumber(input.value, '读数')
625
- const span = WINDOW_MS[metric.window_kind] ?? 0
626
- const windowEnd = input.windowEnd === undefined ? observedAt : finiteNumber(input.windowEnd, '窗口结束')
627
- if (windowEnd === null) throw stateInvalid('窗口结束必须给值')
628
- const windowStart =
629
- input.windowStart === undefined
630
- ? metric.window_kind === 'snapshot'
631
- ? windowEnd
632
- : windowEnd - span
633
- : finiteNumber(input.windowStart, '窗口开始')
634
- if (windowStart === null) throw stateInvalid('窗口开始必须给值')
635
- if (windowStart > windowEnd) throw stateInvalid('窗口开始不能晚于结束')
636
-
637
- const sourceRef = optionalText(input.sourceRef, '数据引用', 500)
638
- const id = ids()
639
- db.prepare(
640
- `INSERT INTO metric_observation (id, metric_id, observed_at, window_start, window_end, value, source_ref, ingested_at)
641
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
642
- ).run(id, metric.id, observedAt, windowStart, windowEnd, value, sourceRef, now)
643
- db.prepare('UPDATE project SET revision = revision + 1, updated_at = ? WHERE id = ?').run(now, input.projectId)
644
- return { id, metricId: metric.id, windowStart, windowEnd, value }
645
- }
646
-
647
- // ───────────────────────── 新建向导草稿 ─────────────────────────
648
-
649
- /**
650
- * 向导草稿:项目已经是 draft,但向导还可能只填到第 2 步。
651
- * 步骤与指标(含未发布的试算结果)存在 `hams_meta` 的 `project:draft:<id>` 里,
652
- * 不改表结构,也不把未发布的指标塞进 `project_metric`(那张表只属于已发布版本)。
653
- */
654
- export interface ProjectDraftState {
655
- step: number
656
- metrics: MetricInput[]
657
- savedAt: number
658
- savedBy: string
659
- }
660
-
661
- export interface SaveDraftInput {
662
- projectId?: string | null
663
- name: string
664
- type: string
665
- goalText?: string
666
- coordinatorId?: string | null
667
- metrics?: MetricInput[]
668
- step?: number
669
- }
670
-
671
- const DRAFT_KEY_PREFIX = 'project:draft:'
672
- const MAX_DRAFT_STEP = 4
673
-
674
- function draftKey(projectId: string): string {
675
- return `${DRAFT_KEY_PREFIX}${projectId}`
676
- }
677
-
678
- function sanitizeDraftMetrics(input: unknown): MetricInput[] {
679
- if (!Array.isArray(input)) return []
680
- return input.slice(0, MAX_METRICS).map((raw) => {
681
- const metric = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>
682
- const direction = (METRIC_DIRECTIONS as readonly string[]).includes(String(metric.direction))
683
- ? (metric.direction as (typeof METRIC_DIRECTIONS)[number])
684
- : 'higher'
685
- const windowKind = (METRIC_WINDOWS as readonly string[]).includes(String(metric.windowKind))
686
- ? (metric.windowKind as (typeof METRIC_WINDOWS)[number])
687
- : 'week'
688
- const numberOrNull = (value: unknown): number | null =>
689
- typeof value === 'number' && Number.isFinite(value) ? value : null
690
- return {
691
- name: String(metric.name ?? '').slice(0, MAX_METRIC_NAME),
692
- direction,
693
- targetValue: numberOrNull(metric.targetValue),
694
- targetMax: numberOrNull(metric.targetMax),
695
- unit: typeof metric.unit === 'string' ? metric.unit.slice(0, 20) : null,
696
- windowKind,
697
- definitionText: typeof metric.definitionText === 'string' ? metric.definitionText.slice(0, 500) : null,
698
- sourceKind:
699
- typeof metric.sourceKind === 'string' && ['ledger', 'work_item', 'webhook', 'manual'].includes(metric.sourceKind)
700
- ? metric.sourceKind
701
- : 'manual',
702
- calculatorKey: typeof metric.calculatorKey === 'string' ? metric.calculatorKey.slice(0, 80) : null,
703
- calculatorParams:
704
- metric.calculatorParams && typeof metric.calculatorParams === 'object' && !Array.isArray(metric.calculatorParams)
705
- ? (metric.calculatorParams as Record<string, unknown>)
706
- : {},
707
- sourceRef: typeof metric.sourceRef === 'string' ? metric.sourceRef.slice(0, 500) : null,
708
- }
709
- })
710
- }
711
-
712
- export function readProjectDraft(db: DatabaseSync, projectId: string): ProjectDraftState | null {
713
- const row = db.prepare('SELECT value FROM hams_meta WHERE key = ?').get(draftKey(projectId)) as
714
- | { value: string }
715
- | undefined
716
- if (!row) return null
717
- try {
718
- const parsed = JSON.parse(row.value) as Partial<ProjectDraftState>
719
- if (!parsed || typeof parsed !== 'object') return null
720
- return {
721
- step: Math.min(Math.max(Number(parsed.step) || 0, 0), MAX_DRAFT_STEP),
722
- metrics: sanitizeDraftMetrics(parsed.metrics),
723
- savedAt: Number(parsed.savedAt) || 0,
724
- savedBy: String(parsed.savedBy || ''),
725
- }
726
- } catch {
727
- return null
728
- }
729
- }
730
-
731
- export function clearProjectDraft(db: DatabaseSync, projectId: string): void {
732
- db.prepare('DELETE FROM hams_meta WHERE key = ?').run(draftKey(projectId))
733
- }
734
-
735
- export interface SavedProjectDraft {
736
- projectId: string
737
- revision: number
738
- created: boolean
739
- savedAt: number
740
- step: number
741
- }
742
-
743
- /**
744
- * 保存/更新新建向导草稿。新建时项目就是 draft;更新只允许改 draft,
745
- * 已发布/暂停/归档的项目必须走正式的项目编辑路径。
746
- */
747
- export function saveProjectDraft(
748
- db: DatabaseSync,
749
- ids: IdFactory,
750
- now: number,
751
- actorId: string,
752
- input: SaveDraftInput,
753
- ): SavedProjectDraft {
754
- const step = Math.min(Math.max(Number(input.step) || 0, 0), MAX_DRAFT_STEP)
755
- const metrics = sanitizeDraftMetrics(input.metrics)
756
-
757
- let projectId = optionalText(input.projectId, '项目 id', 64)
758
- let created = false
759
- let revision: number
760
-
761
- if (!projectId) {
762
- const inserted = insertProject(db, ids, now, actorId, {
763
- name: input.name,
764
- type: input.type,
765
- goalText: input.goalText ?? '',
766
- coordinatorId: input.coordinatorId,
767
- metrics: [],
768
- publish: false,
769
- })
770
- projectId = inserted.id
771
- revision = inserted.revision
772
- created = true
773
- } else {
774
- const row = db.prepare('SELECT * FROM project WHERE id = ?').get(projectId) as ProjectRow | undefined
775
- if (!row) throw stateInvalid(`项目不存在:${projectId}`)
776
- if (row.deleted_at) throw stateInvalid('项目已删除,不能再保存草稿')
777
- if (row.status !== 'draft') throw stateInvalid('只有草稿项目能在新建向导里继续修改', '已发布项目请发布新目标版本')
778
-
779
- const name = text(input.name, '项目名')
780
- const type = oneOf(input.type, '项目类型', PROJECT_TYPES)
781
- const goalText = text(input.goalText ?? '', '目标', MAX_GOAL, true)
782
- const coordinatorId =
783
- input.coordinatorId === undefined
784
- ? row.coordinator_id
785
- : input.coordinatorId === null || input.coordinatorId === ''
786
- ? null
787
- : text(input.coordinatorId, '推进 bot', 64)
788
- if (coordinatorId) {
789
- const coordinator = db.prepare('SELECT id FROM member WHERE id = ?').get(coordinatorId) as { id: string } | undefined
790
- if (!coordinator) throw stateInvalid(`推进成员不存在:${coordinatorId}`)
791
- }
792
- const duplicate = db
793
- .prepare('SELECT id FROM project WHERE name = ? AND id <> ? AND deleted_at IS NULL')
794
- .get(name, projectId) as { id: string } | undefined
795
- if (duplicate) throw stateInvalid(`项目名已存在:${name}`, '换一个名字,或打开已有项目')
796
-
797
- revision = Number(row.revision) + 1
798
- db.prepare(
799
- 'UPDATE project SET name = ?, type = ?, goal_text = ?, coordinator_id = ?, revision = ?, updated_at = ? WHERE id = ?',
800
- ).run(name, type, goalText, coordinatorId, revision, now, projectId)
801
- }
802
-
803
- const state: ProjectDraftState = { step, metrics, savedAt: now, savedBy: actorId }
804
- db.prepare("INSERT OR REPLACE INTO hams_meta (key, value) VALUES (?, ?)").run(draftKey(projectId), JSON.stringify(state))
805
- return { projectId, revision, created, savedAt: now, step }
806
- }
807
-
808
- // ───────────────────────── 归档项目删除 ─────────────────────────
809
-
810
- export interface DeletedProject {
811
- id: string
812
- revision: number
813
- detachedWorkItems: number
814
- }
815
-
816
- /**
817
- * 「删除」归档项目:只打软删标记,列表/详情/群聊不再出现;
818
- * 工作项解绑为「无项目」继续留在任务里(证据与审计不删,符合库的追加式约束)。
819
- */
820
- export function softDeleteProject(
821
- db: DatabaseSync,
822
- now: number,
823
- actorId: string,
824
- projectId: string,
825
- ): DeletedProject {
826
- const row = db.prepare('SELECT * FROM project WHERE id = ?').get(projectId) as ProjectRow | undefined
827
- if (!row) throw stateInvalid(`项目不存在:${projectId}`)
828
- if (row.deleted_at) throw stateInvalid('项目已经删除')
829
- if (row.status !== 'archived') throw stateInvalid('只有已归档的项目可以删除', '先归档项目,再删除')
830
- // work_item 的 revision 只能向前:解绑也必须 +1,否则被 trg_wi_counters_monotonic 拒绝。
831
- const detached = db.prepare('UPDATE work_item SET project_id = NULL, revision = revision + 1 WHERE project_id = ?').run(projectId)
832
- const revision = Number(row.revision) + 1
833
- // project.name 有 UNIQUE 约束:软删也把名字换成墓碑,原名保留在 deleted_name,名字可再次使用。
834
- const tombstone = `${row.name}·已删除·${projectId.slice(-6)}`
835
- db.prepare(
836
- 'UPDATE project SET deleted_at = ?, deleted_by = ?, deleted_name = ?, name = ?, revision = ?, updated_at = ? WHERE id = ?',
837
- ).run(now, actorId, row.name, tombstone, revision, now, projectId)
838
- clearProjectDraft(db, projectId)
839
- return { id: projectId, revision, detachedWorkItems: Number(detached.changes ?? 0) }
840
- }