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.
- 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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-hams",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "HAMS:DeepSeek Harness 内的人机工作台插件(项目 / 任务 / 人机成员 / 知识 / 记忆)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -32,12 +32,13 @@
|
|
|
32
32
|
"schema:split": "node scripts/split-schema.mjs",
|
|
33
33
|
"schema:check": "node scripts/split-schema.mjs --check",
|
|
34
34
|
"store:init": "node scripts/init-store.mjs",
|
|
35
|
-
"store:backup": "node scripts/backup-store.mjs"
|
|
35
|
+
"store:backup": "node scripts/backup-store.mjs",
|
|
36
|
+
"build": "esbuild src/index.js --bundle --platform=node --format=esm --target=node24 --outfile=dist/index.js --external:@deepseek-ai/*"
|
|
36
37
|
},
|
|
37
|
-
"main": "./
|
|
38
|
+
"main": "./dist/index.js",
|
|
38
39
|
"exports": {
|
|
39
40
|
".": {
|
|
40
|
-
"default": "./
|
|
41
|
+
"default": "./dist/index.js"
|
|
41
42
|
},
|
|
42
43
|
"./client": {
|
|
43
44
|
"default": "./client/index.js"
|
|
@@ -46,7 +47,7 @@
|
|
|
46
47
|
"./package.json": "./package.json"
|
|
47
48
|
},
|
|
48
49
|
"files": [
|
|
49
|
-
"
|
|
50
|
+
"dist",
|
|
50
51
|
"client/index.js",
|
|
51
52
|
"cordis.patch.yml",
|
|
52
53
|
"README.md",
|
|
@@ -72,5 +73,8 @@
|
|
|
72
73
|
"@deepseek-ai/cordis": "~4.1.0",
|
|
73
74
|
"@deepseek-ai/dsh-tools": "*",
|
|
74
75
|
"@deepseek-ai/schemastery": "*"
|
|
76
|
+
},
|
|
77
|
+
"devDependencies": {
|
|
78
|
+
"esbuild": "^0.28.2"
|
|
75
79
|
}
|
|
76
80
|
}
|
package/src/domain/check.ts
DELETED
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 复查时点与模板策略解析。
|
|
3
|
-
*
|
|
4
|
-
* 三种复查节奏(backend-design §4.3 / 画板 ③):
|
|
5
|
-
* - `next_day_at`:次日当地 `HH:mm`(默认 09:00);
|
|
6
|
-
* - `after_hours`:N 小时后;
|
|
7
|
-
* - `next_working_day`:下一工作日当地 `HH:mm`。
|
|
8
|
-
*
|
|
9
|
-
* 时区换算不引依赖:用 `Intl.DateTimeFormat` 取某时区的挂钟分量,再用
|
|
10
|
-
* 「把挂钟当 UTC 求偏移、迭代两次」的方式把挂钟时间转回 UTC 瞬时。跨 DST 的
|
|
11
|
-
* 边界会落在 ±1 小时以内,对「次日 09:00 复查」这种粒度足够;真需要分钟级
|
|
12
|
-
* 精确的场合应改用带时区库,这一点写在这里而不是偷偷假设。
|
|
13
|
-
*/
|
|
14
|
-
import { stateInvalid } from './errors.ts'
|
|
15
|
-
|
|
16
|
-
export type CheckMode = 'next_day_at' | 'after_hours' | 'next_working_day'
|
|
17
|
-
|
|
18
|
-
export interface CheckPolicy {
|
|
19
|
-
mode: CheckMode
|
|
20
|
-
time: string
|
|
21
|
-
tz: string
|
|
22
|
-
hours: number
|
|
23
|
-
maxRechecks: number | null
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface ReviewPolicy {
|
|
27
|
-
/** `owner` 或 `position:<id>`。 */
|
|
28
|
-
approver: string
|
|
29
|
-
expiresHours: number
|
|
30
|
-
autoClose: boolean
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const DEFAULT_TZ = 'Asia/Shanghai'
|
|
34
|
-
|
|
35
|
-
function two(n: number): string {
|
|
36
|
-
return String(n).padStart(2, '0')
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function parseClock(time: string): { hour: number; minute: number } {
|
|
40
|
-
const match = /^([01]\d|2[0-3]):([0-5]\d)$/.exec(time.trim())
|
|
41
|
-
if (!match) throw stateInvalid(`时间格式必须是 HH:mm,收到 ${time}`)
|
|
42
|
-
return { hour: Number(match[1]), minute: Number(match[2]) }
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function zoneParts(ms: number, timeZone: string): {
|
|
46
|
-
year: number
|
|
47
|
-
month: number
|
|
48
|
-
day: number
|
|
49
|
-
hour: number
|
|
50
|
-
minute: number
|
|
51
|
-
second: number
|
|
52
|
-
} {
|
|
53
|
-
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
54
|
-
timeZone,
|
|
55
|
-
year: 'numeric',
|
|
56
|
-
month: '2-digit',
|
|
57
|
-
day: '2-digit',
|
|
58
|
-
hour: '2-digit',
|
|
59
|
-
minute: '2-digit',
|
|
60
|
-
second: '2-digit',
|
|
61
|
-
hour12: false,
|
|
62
|
-
})
|
|
63
|
-
const parts = formatter.formatToParts(new Date(ms))
|
|
64
|
-
const pick = (type: string): number => Number(parts.find((part) => part.type === type)?.value ?? '0')
|
|
65
|
-
return {
|
|
66
|
-
year: pick('year'),
|
|
67
|
-
month: pick('month'),
|
|
68
|
-
day: pick('day'),
|
|
69
|
-
hour: pick('hour') % 24,
|
|
70
|
-
minute: pick('minute'),
|
|
71
|
-
second: pick('second'),
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function offsetAt(ms: number, timeZone: string): number {
|
|
76
|
-
const parts = zoneParts(ms, timeZone)
|
|
77
|
-
const wallAsUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second)
|
|
78
|
-
return wallAsUtc - Math.floor(ms / 1000) * 1000
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** 把某时区的挂钟时间转成 UTC 毫秒。 */
|
|
82
|
-
export function wallToUtc(
|
|
83
|
-
timeZone: string,
|
|
84
|
-
year: number,
|
|
85
|
-
month: number,
|
|
86
|
-
day: number,
|
|
87
|
-
hour: number,
|
|
88
|
-
minute: number,
|
|
89
|
-
): number {
|
|
90
|
-
const guess = Date.UTC(year, month - 1, day, hour, minute)
|
|
91
|
-
let candidate = guess - offsetAt(guess, timeZone)
|
|
92
|
-
// DST 切换附近,候选时刻的偏移可能和 guess 处不同;只有挂钟分量对不上时才再校准一次。
|
|
93
|
-
const local = zoneParts(candidate, timeZone)
|
|
94
|
-
if (local.hour !== hour || local.minute !== minute) {
|
|
95
|
-
candidate = guess - offsetAt(candidate, timeZone)
|
|
96
|
-
}
|
|
97
|
-
return candidate
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function normalizeDate(year: number, month: number, day: number): { year: number; month: number; day: number } {
|
|
101
|
-
const date = new Date(Date.UTC(year, month - 1, day))
|
|
102
|
-
return { year: date.getUTCFullYear(), month: date.getUTCMonth() + 1, day: date.getUTCDate() }
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function weekdayOf(year: number, month: number, day: number): number {
|
|
106
|
-
return new Date(Date.UTC(year, month - 1, day)).getUTCDay()
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function parseCheckPolicy(json: string | null | undefined): CheckPolicy {
|
|
110
|
-
const fallback: CheckPolicy = {
|
|
111
|
-
mode: 'after_hours',
|
|
112
|
-
time: '09:00',
|
|
113
|
-
tz: DEFAULT_TZ,
|
|
114
|
-
hours: 24,
|
|
115
|
-
maxRechecks: null,
|
|
116
|
-
}
|
|
117
|
-
if (!json || !json.trim()) return fallback
|
|
118
|
-
let raw: unknown
|
|
119
|
-
try {
|
|
120
|
-
raw = JSON.parse(json)
|
|
121
|
-
} catch {
|
|
122
|
-
throw stateInvalid(`check_policy_json 不是合法 JSON:${json}`)
|
|
123
|
-
}
|
|
124
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return fallback
|
|
125
|
-
const item = raw as Record<string, unknown>
|
|
126
|
-
const mode = typeof item.mode === 'string' ? (item.mode as CheckMode) : fallback.mode
|
|
127
|
-
if (!['next_day_at', 'after_hours', 'next_working_day'].includes(mode)) {
|
|
128
|
-
throw stateInvalid(`未知的复查节奏:${String(item.mode)}`)
|
|
129
|
-
}
|
|
130
|
-
const tz = typeof item.tz === 'string' && item.tz.trim() ? item.tz.trim() : DEFAULT_TZ
|
|
131
|
-
const time = typeof item.time === 'string' && item.time.trim() ? item.time.trim() : fallback.time
|
|
132
|
-
parseClock(time)
|
|
133
|
-
try {
|
|
134
|
-
new Intl.DateTimeFormat('en-US', { timeZone: tz })
|
|
135
|
-
} catch {
|
|
136
|
-
throw stateInvalid(`未知时区:${tz}`)
|
|
137
|
-
}
|
|
138
|
-
const hours = item.hours === undefined ? fallback.hours : Number(item.hours)
|
|
139
|
-
if (!Number.isFinite(hours) || hours <= 0) throw stateInvalid(`复查间隔小时数必须是正数:${String(item.hours)}`)
|
|
140
|
-
const maxRaw = item.max_rechecks ?? item.maxRechecks
|
|
141
|
-
const maxRechecks =
|
|
142
|
-
maxRaw === undefined || maxRaw === null ? null : Number.isInteger(Number(maxRaw)) && Number(maxRaw) >= 0 ? Number(maxRaw) : null
|
|
143
|
-
return { mode, time, tz, hours, maxRechecks }
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
export function nextCheckAt(policy: CheckPolicy, fromMs: number): number {
|
|
147
|
-
if (policy.mode === 'after_hours') return fromMs + policy.hours * 3_600_000
|
|
148
|
-
|
|
149
|
-
const { hour, minute } = parseClock(policy.time)
|
|
150
|
-
const local = zoneParts(fromMs, policy.tz)
|
|
151
|
-
|
|
152
|
-
if (policy.mode === 'next_day_at') {
|
|
153
|
-
const nextDay = normalizeDate(local.year, local.month, local.day + 1)
|
|
154
|
-
return wallToUtc(policy.tz, nextDay.year, nextDay.month, nextDay.day, hour, minute)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
for (let offset = 1; offset <= 8; offset += 1) {
|
|
158
|
-
const candidate = normalizeDate(local.year, local.month, local.day + offset)
|
|
159
|
-
const weekday = weekdayOf(candidate.year, candidate.month, candidate.day)
|
|
160
|
-
if (weekday === 0 || weekday === 6) continue
|
|
161
|
-
return wallToUtc(policy.tz, candidate.year, candidate.month, candidate.day, hour, minute)
|
|
162
|
-
}
|
|
163
|
-
throw stateInvalid('无法计算下一工作日:日期滚动超过 8 天')
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export function parseReviewPolicy(json: string | null | undefined): ReviewPolicy {
|
|
167
|
-
const fallback: ReviewPolicy = { approver: 'owner', expiresHours: 24, autoClose: false }
|
|
168
|
-
if (!json || !json.trim()) return fallback
|
|
169
|
-
let raw: unknown
|
|
170
|
-
try {
|
|
171
|
-
raw = JSON.parse(json)
|
|
172
|
-
} catch {
|
|
173
|
-
throw stateInvalid(`review_policy_json 不是合法 JSON:${json}`)
|
|
174
|
-
}
|
|
175
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return fallback
|
|
176
|
-
const item = raw as Record<string, unknown>
|
|
177
|
-
const approver = typeof item.approver === 'string' && item.approver.trim() ? item.approver.trim() : fallback.approver
|
|
178
|
-
if (approver !== 'owner' && !/^position:.+$/.test(approver)) {
|
|
179
|
-
throw stateInvalid(`review_policy.approver 只支持 owner 或 position:<id>,收到 ${approver}`)
|
|
180
|
-
}
|
|
181
|
-
const expiresRaw = item.expires_hours ?? item.expiresHours ?? fallback.expiresHours
|
|
182
|
-
const expiresHours = Number(expiresRaw)
|
|
183
|
-
if (!Number.isFinite(expiresHours) || expiresHours <= 0) {
|
|
184
|
-
throw stateInvalid(`review_policy.expires_hours 必须是正数:${String(expiresRaw)}`)
|
|
185
|
-
}
|
|
186
|
-
const autoClose = item.auto_close === true || item.autoClose === true
|
|
187
|
-
return { approver, expiresHours, autoClose }
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export function formatClock(time: string): string {
|
|
191
|
-
const { hour, minute } = parseClock(time)
|
|
192
|
-
return `${two(hour)}:${two(minute)}`
|
|
193
|
-
}
|
package/src/domain/errors.ts
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 错误码:与 backend-design §9 的 15 类错误一一对应。
|
|
3
|
-
*
|
|
4
|
-
* 这 15 个码是界面文案的分支依据,**不要随手新增**——新增一个码就意味着界面要
|
|
5
|
-
* 多一个分支说明。内部配置/实现错误尽量复用到语义最近的码,并在 message 里写清,
|
|
6
|
-
* 例如「模板的复查策略无法解析」用 STATE_INVALID 而不是造一个 CONFIG_INVALID。
|
|
7
|
-
*/
|
|
8
|
-
export const HAMS_ERROR_CODES = [
|
|
9
|
-
'VERSION_CONFLICT',
|
|
10
|
-
'EVIDENCE_CHANGED',
|
|
11
|
-
'APPROVAL_EXPIRED',
|
|
12
|
-
'NOT_APPROVER',
|
|
13
|
-
'POLICY_DENIED',
|
|
14
|
-
'STATE_INVALID',
|
|
15
|
-
'EPOCH_STALE',
|
|
16
|
-
'APPROVAL_REQUIRED',
|
|
17
|
-
'EVIDENCE_INCOMPLETE',
|
|
18
|
-
'LEASE_LOST',
|
|
19
|
-
'IDEMPOTENCY_MISMATCH',
|
|
20
|
-
'CURSOR_EXPIRED',
|
|
21
|
-
'DATA_STALE',
|
|
22
|
-
'EXTERNAL_RESULT_UNKNOWN',
|
|
23
|
-
'HOST_CAPABILITY_MISSING',
|
|
24
|
-
] as const
|
|
25
|
-
|
|
26
|
-
export type HamsErrorCode = (typeof HAMS_ERROR_CODES)[number]
|
|
27
|
-
|
|
28
|
-
export class HamsError extends Error {
|
|
29
|
-
code: HamsErrorCode
|
|
30
|
-
nextStep: string | undefined
|
|
31
|
-
detail: unknown
|
|
32
|
-
|
|
33
|
-
constructor(code: HamsErrorCode, message: string, options: { nextStep?: string; detail?: unknown } = {}) {
|
|
34
|
-
super(message)
|
|
35
|
-
this.name = 'HamsError'
|
|
36
|
-
this.code = code
|
|
37
|
-
this.nextStep = options.nextStep
|
|
38
|
-
this.detail = options.detail
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function isHamsError(error: unknown): error is HamsError {
|
|
43
|
-
return error instanceof HamsError
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function denied(message: string, nextStep?: string): HamsError {
|
|
47
|
-
return new HamsError('POLICY_DENIED', message, { nextStep })
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function stateInvalid(message: string, nextStep?: string): HamsError {
|
|
51
|
-
return new HamsError('STATE_INVALID', message, { nextStep })
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export function evidenceIncomplete(message: string, detail?: unknown): HamsError {
|
|
55
|
-
return new HamsError('EVIDENCE_INCOMPLETE', message, {
|
|
56
|
-
detail,
|
|
57
|
-
nextStep: '补齐缺的证据后重新申请',
|
|
58
|
-
})
|
|
59
|
-
}
|
package/src/domain/evidence.ts
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 证据集与关单判据。
|
|
3
|
-
*
|
|
4
|
-
* 两条规则来自 backend-design §4.6 / §6:
|
|
5
|
-
* 1. `evidence_set_hash` 只覆盖 `status='active'` 的证据,按 id 固定排序拼接后取 sha256;
|
|
6
|
-
* id 是 ULID,所以「固定顺序」= 追加顺序,任何人重算都能得到同一个值。
|
|
7
|
-
* 2. 关单需要的证据种类与最低条数写在 `work_template_version.required_evidence_json`,
|
|
8
|
-
* 形如 `[{kind:'reading',min_count:2,max_age_hours:24}]`。缺一项就是 EVIDENCE_INCOMPLETE,
|
|
9
|
-
* 不靠人肉记忆。
|
|
10
|
-
*
|
|
11
|
-
* 已知缺口(记在 progress §9):证据行没有数值字段,所以「连续两次读数 ≥ 80%」这类
|
|
12
|
-
* 数值判据目前只能靠具名验证器从 `ref` 指向的外部内容里读,或改用 metric_observation。
|
|
13
|
-
* 本文件只做「种类 / 条数 / 时效」这层可判定的部分,并在 requestClosure 里对未注册的
|
|
14
|
-
* 验证器**失败关闭**(HOST_CAPABILITY_MISSING),不假装已经校验过。
|
|
15
|
-
*/
|
|
16
|
-
import { createHash } from 'node:crypto'
|
|
17
|
-
|
|
18
|
-
export interface EvidenceSnapshotRow {
|
|
19
|
-
id: string
|
|
20
|
-
kind: string
|
|
21
|
-
contentHash: string
|
|
22
|
-
observedAt: number
|
|
23
|
-
status: string
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export interface EvidenceRequirement {
|
|
27
|
-
kind: string
|
|
28
|
-
minCount: number
|
|
29
|
-
maxAgeHours: number | null
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface RequirementMissing {
|
|
33
|
-
kind: string
|
|
34
|
-
need: number
|
|
35
|
-
have: number
|
|
36
|
-
reason: 'count' | 'stale'
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface RequirementCheck {
|
|
40
|
-
satisfied: boolean
|
|
41
|
-
missing: RequirementMissing[]
|
|
42
|
-
used: number
|
|
43
|
-
totalActive: number
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function evidenceSetHash(rows: EvidenceSnapshotRow[]): string {
|
|
47
|
-
const active = rows
|
|
48
|
-
.filter((row) => row.status === 'active')
|
|
49
|
-
.map((row) => `${row.id}:${row.kind}:${row.contentHash}:${row.observedAt}`)
|
|
50
|
-
.sort()
|
|
51
|
-
return createHash('sha256').update(active.join('\n')).digest('hex')
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
function toRequirement(raw: unknown): EvidenceRequirement | null {
|
|
55
|
-
if (!raw || typeof raw !== 'object') return null
|
|
56
|
-
const item = raw as Record<string, unknown>
|
|
57
|
-
const kind = typeof item.kind === 'string' ? item.kind.trim() : ''
|
|
58
|
-
if (!kind) return null
|
|
59
|
-
const minCountRaw = item.min_count ?? item.minCount ?? 1
|
|
60
|
-
const maxAgeRaw = item.max_age_hours ?? item.maxAgeHours ?? null
|
|
61
|
-
const minCount = Number(minCountRaw)
|
|
62
|
-
const maxAgeHours = maxAgeRaw === null || maxAgeRaw === undefined ? null : Number(maxAgeRaw)
|
|
63
|
-
if (!Number.isInteger(minCount) || minCount < 1) {
|
|
64
|
-
throw new Error(`required_evidence 的 min_count 必须是 ≥1 的整数:${JSON.stringify(raw)}`)
|
|
65
|
-
}
|
|
66
|
-
if (maxAgeHours !== null && (!Number.isFinite(maxAgeHours) || maxAgeHours <= 0)) {
|
|
67
|
-
throw new Error(`required_evidence 的 max_age_hours 必须是正数:${JSON.stringify(raw)}`)
|
|
68
|
-
}
|
|
69
|
-
return { kind, minCount, maxAgeHours }
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function parseEvidenceRequirements(json: string | null | undefined): EvidenceRequirement[] {
|
|
73
|
-
if (!json || !json.trim()) return []
|
|
74
|
-
let parsed: unknown
|
|
75
|
-
try {
|
|
76
|
-
parsed = JSON.parse(json)
|
|
77
|
-
} catch {
|
|
78
|
-
throw new Error(`required_evidence_json 不是合法 JSON:${json}`)
|
|
79
|
-
}
|
|
80
|
-
if (!Array.isArray(parsed)) throw new Error('required_evidence_json 必须是数组')
|
|
81
|
-
return parsed.map(toRequirement).filter((item): item is EvidenceRequirement => item !== null)
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
export function evaluateRequirements(
|
|
85
|
-
rows: EvidenceSnapshotRow[],
|
|
86
|
-
requirements: EvidenceRequirement[],
|
|
87
|
-
nowMs: number,
|
|
88
|
-
): RequirementCheck {
|
|
89
|
-
const active = rows.filter((row) => row.status === 'active')
|
|
90
|
-
const missing: RequirementMissing[] = []
|
|
91
|
-
let used = 0
|
|
92
|
-
|
|
93
|
-
for (const requirement of requirements) {
|
|
94
|
-
const ofKind = active.filter((row) => row.kind === requirement.kind)
|
|
95
|
-
const fresh =
|
|
96
|
-
requirement.maxAgeHours === null
|
|
97
|
-
? ofKind
|
|
98
|
-
: ofKind.filter((row) => nowMs - row.observedAt <= requirement.maxAgeHours! * 3_600_000)
|
|
99
|
-
if (fresh.length >= requirement.minCount) {
|
|
100
|
-
used += requirement.minCount
|
|
101
|
-
continue
|
|
102
|
-
}
|
|
103
|
-
missing.push({
|
|
104
|
-
kind: requirement.kind,
|
|
105
|
-
need: requirement.minCount,
|
|
106
|
-
have: fresh.length,
|
|
107
|
-
reason: fresh.length < ofKind.length ? 'stale' : 'count',
|
|
108
|
-
})
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return { satisfied: missing.length === 0, missing, used, totalActive: active.length }
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** 判据快照:写进 approval.criteria_json,让「批的是哪一版规则」可回看。 */
|
|
115
|
-
export interface CriteriaSnapshot {
|
|
116
|
-
templateVersionId: string | null
|
|
117
|
-
requirements: EvidenceRequirement[]
|
|
118
|
-
result: RequirementCheck
|
|
119
|
-
evidenceSetHash: string
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function buildCriteriaSnapshot(
|
|
123
|
-
templateVersionId: string | null,
|
|
124
|
-
requirements: EvidenceRequirement[],
|
|
125
|
-
rows: EvidenceSnapshotRow[],
|
|
126
|
-
nowMs: number,
|
|
127
|
-
): CriteriaSnapshot {
|
|
128
|
-
return {
|
|
129
|
-
templateVersionId,
|
|
130
|
-
requirements,
|
|
131
|
-
result: evaluateRequirements(rows, requirements, nowMs),
|
|
132
|
-
evidenceSetHash: evidenceSetHash(rows),
|
|
133
|
-
}
|
|
134
|
-
}
|
package/src/domain/ids.ts
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 主键:ULID 文本(26 位 Crockford Base32,前 10 位毫秒时间戳)。
|
|
3
|
-
*
|
|
4
|
-
* 没有引入依赖:ULID 的规范只有两段——时间前缀 + 80 位随机数。这里不做同毫秒单调
|
|
5
|
-
* 递增的严格实现(同一毫秒内靠随机位去重),因为所有插入都发生在单写者事务里,
|
|
6
|
-
* 冲突由主键约束兜底。
|
|
7
|
-
*/
|
|
8
|
-
const ENCODING = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'
|
|
9
|
-
|
|
10
|
-
export type IdFactory = () => string
|
|
11
|
-
|
|
12
|
-
export function ulidAt(nowMs: number, random: () => number = Math.random): string {
|
|
13
|
-
if (!Number.isFinite(nowMs) || nowMs < 0) throw new Error(`非法时间戳:${nowMs}`)
|
|
14
|
-
let timePart = ''
|
|
15
|
-
let rest = Math.floor(nowMs)
|
|
16
|
-
for (let i = 0; i < 10; i += 1) {
|
|
17
|
-
timePart = ENCODING[rest % 32] + timePart
|
|
18
|
-
rest = Math.floor(rest / 32)
|
|
19
|
-
}
|
|
20
|
-
let randomPart = ''
|
|
21
|
-
for (let i = 0; i < 16; i += 1) {
|
|
22
|
-
randomPart += ENCODING[Math.floor(random() * 32)]
|
|
23
|
-
}
|
|
24
|
-
return timePart + randomPart
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function createIdFactory(now: () => number, random: () => number = Math.random): IdFactory {
|
|
28
|
-
return () => ulidAt(now(), random)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export const ULID_PATTERN = /^[0-9A-HJKMNP-TV-Z]{26}$/
|