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
package/src/service/hams.ts
DELETED
|
@@ -1,3103 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HAMS 领域命令层(M3 最小闭环)。
|
|
3
|
-
*
|
|
4
|
-
* 只做「一条闭环」需要的命令,但每条命令都落在 backend-design §7 的事务边界里:
|
|
5
|
-
*
|
|
6
|
-
* T1 建任务(告警入站) ingestAlert
|
|
7
|
-
* T4 提议采纳并落单 propose → decideProposal
|
|
8
|
-
* T2 认领 claim(bot)/ accept(人)
|
|
9
|
-
* T3 运行开始 / 结束 startRun / submitResult
|
|
10
|
-
* T8 追加证据 appendEvidence
|
|
11
|
-
* T6 审批请求 requestClosure
|
|
12
|
-
* T7 / T5 审批决定与关单 decideApproval
|
|
13
|
-
* T9 暂停 / 恢复 / 取消等 pause / resume / cancel / reopen / retry
|
|
14
|
-
* 复查判定 evaluateCheck
|
|
15
|
-
*
|
|
16
|
-
* 纪律:
|
|
17
|
-
* - 一个命令 = 一个 `BEGIN IMMEDIATE` 事务;内部 `*Tx` 方法假定事务已开,不再开事务;
|
|
18
|
-
* - 主体只从 `ctx.principalId` 解析,参数里没有 memberId 可以伪造;
|
|
19
|
-
* - 业务失败抛 `HamsError`(带 15 类错误码之一),调用方(HTTP / 工具)负责转成回执;
|
|
20
|
-
* - 所有写命令都写事件、变更游标与审计;审计与业务行同事务,失败一起回滚。
|
|
21
|
-
*
|
|
22
|
-
* 还没做的:tick 调度、租约心跳回收、outbox 派发、recover() 对账(M4),
|
|
23
|
-
* 以及 HTTP 进站校验与工具注册(M5)。
|
|
24
|
-
*/
|
|
25
|
-
import type { DatabaseSync } from 'node:sqlite'
|
|
26
|
-
import * as lifecycle from './project-lifecycle.ts'
|
|
27
|
-
import { publishConfig, memberActivity, type PublishConfigInput } from './member-config.ts'
|
|
28
|
-
import { createHash } from 'node:crypto'
|
|
29
|
-
import { HamsError, denied, evidenceIncomplete, stateInvalid } from '../domain/errors.ts'
|
|
30
|
-
import { createIdFactory, type IdFactory } from '../domain/ids.ts'
|
|
31
|
-
import {
|
|
32
|
-
buildCriteriaSnapshot,
|
|
33
|
-
evidenceSetHash,
|
|
34
|
-
parseEvidenceRequirements,
|
|
35
|
-
type CriteriaSnapshot,
|
|
36
|
-
type EvidenceSnapshotRow,
|
|
37
|
-
} from '../domain/evidence.ts'
|
|
38
|
-
import {
|
|
39
|
-
nextCheckAt,
|
|
40
|
-
parseCheckPolicy,
|
|
41
|
-
parseReviewPolicy,
|
|
42
|
-
type CheckPolicy,
|
|
43
|
-
type ReviewPolicy,
|
|
44
|
-
} from '../domain/check.ts'
|
|
45
|
-
import { CAPABILITY, requireBot, requireCapability, requireHuman, resolvePrincipal } from './permissions.ts'
|
|
46
|
-
import {
|
|
47
|
-
clearProjectDraft,
|
|
48
|
-
getProject as queryProject,
|
|
49
|
-
insertProject as insertProjectRow,
|
|
50
|
-
listProjects as queryProjects,
|
|
51
|
-
recordObservation as insertObservation,
|
|
52
|
-
saveProjectDraft as persistProjectDraft,
|
|
53
|
-
setProjectStatus as applyProjectStatus,
|
|
54
|
-
softDeleteProject,
|
|
55
|
-
type CreateProjectInput,
|
|
56
|
-
type ObservationInput,
|
|
57
|
-
type ProjectDetail,
|
|
58
|
-
type ProjectSummary,
|
|
59
|
-
type SaveDraftInput,
|
|
60
|
-
} from './projects.ts'
|
|
61
|
-
import { ensureDefaultAnalystInProject } from './defaults.ts'
|
|
62
|
-
import {
|
|
63
|
-
createMemory,
|
|
64
|
-
listMemberMemories as queryMemberMemories,
|
|
65
|
-
listProjectMemories as queryProjectMemories,
|
|
66
|
-
updateMemory,
|
|
67
|
-
type CreateMemoryInput,
|
|
68
|
-
} from './memory.ts'
|
|
69
|
-
import {
|
|
70
|
-
getMember as queryMember,
|
|
71
|
-
insertMember as insertMemberRow,
|
|
72
|
-
listMembers as queryMembers,
|
|
73
|
-
listPositions as queryPositions,
|
|
74
|
-
setMemberStatus as applyMemberStatus,
|
|
75
|
-
type CreateMemberInput,
|
|
76
|
-
type MemberDetail,
|
|
77
|
-
type MemberSummary,
|
|
78
|
-
type PositionSummary,
|
|
79
|
-
} from './members.ts'
|
|
80
|
-
import {
|
|
81
|
-
KnowledgeService,
|
|
82
|
-
type DocDetail,
|
|
83
|
-
type ImportReport,
|
|
84
|
-
type KnowledgeItem,
|
|
85
|
-
type KnowledgeListResult,
|
|
86
|
-
type SkillDetail,
|
|
87
|
-
type SyncReport,
|
|
88
|
-
type TableDetail,
|
|
89
|
-
} from './knowledge.ts'
|
|
90
|
-
import { insertWorkItem, type WorkItemFactory } from './work-item-create.ts'
|
|
91
|
-
import { writeAudit, writeChange, writeEvent } from './audit.ts'
|
|
92
|
-
import { hashPayload, loadReceipt, receiptScopeKey, saveReceipt } from './receipts.ts'
|
|
93
|
-
import type {
|
|
94
|
-
ApprovalRow,
|
|
95
|
-
CheckRow,
|
|
96
|
-
CommandContext,
|
|
97
|
-
CommandReceipt,
|
|
98
|
-
EvidenceRow,
|
|
99
|
-
Principal,
|
|
100
|
-
ProposalRow,
|
|
101
|
-
RunRow,
|
|
102
|
-
TemplateRow,
|
|
103
|
-
TemplateVersionRow,
|
|
104
|
-
ThreadRow,
|
|
105
|
-
WorkItemRow,
|
|
106
|
-
} from './types.ts'
|
|
107
|
-
|
|
108
|
-
const TERMINAL_ITEM_STATUSES = ['completed', 'failed', 'cancelled'] as const
|
|
109
|
-
// 与库里的部分唯一索引 uq_wi_dedupe 保持同一判据:只排除 completed / cancelled。
|
|
110
|
-
// 漏掉 failed 会让「按 dedupe 查不到、插入又被索引拒绝」变成 SQLITE_ERROR。
|
|
111
|
-
const OPEN_ITEM_STATUSES = [
|
|
112
|
-
'ready',
|
|
113
|
-
'active',
|
|
114
|
-
'waiting_check',
|
|
115
|
-
'waiting_review',
|
|
116
|
-
'blocked',
|
|
117
|
-
'paused',
|
|
118
|
-
'failed',
|
|
119
|
-
] as const
|
|
120
|
-
|
|
121
|
-
export interface HamsServiceOptions {
|
|
122
|
-
db: DatabaseSync
|
|
123
|
-
now?: () => number
|
|
124
|
-
runtimeEpoch?: number
|
|
125
|
-
idFactory?: IdFactory
|
|
126
|
-
/** 预算按哪个时区切日/周(项目本身还没有时区列,先用部署时区)。 */
|
|
127
|
-
timeZone?: string
|
|
128
|
-
/** 具名判据验证器的注册表;找不到就失败关闭。 */
|
|
129
|
-
validators?: Record<string, Validator>
|
|
130
|
-
/**
|
|
131
|
-
* 知识屏的真实数据源。`dataDir` 是 HAMS 数据目录(面板撰写的文档落在其下);
|
|
132
|
-
* `skillDirs` 默认由宿主适配器填成 `$DSH_HOME/skills`,测试里可指向临时目录。
|
|
133
|
-
*/
|
|
134
|
-
knowledge?: { dataDir: string; skillDirs?: string[] }
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export type Validator = (input: {
|
|
138
|
-
workItem: WorkItemRow
|
|
139
|
-
evidence: EvidenceRow[]
|
|
140
|
-
params: Record<string, unknown>
|
|
141
|
-
now: number
|
|
142
|
-
}) => { passed: boolean; detail?: unknown }
|
|
143
|
-
|
|
144
|
-
export interface IngestAlertInput {
|
|
145
|
-
connector: string
|
|
146
|
-
externalEventId: string
|
|
147
|
-
eventType: string
|
|
148
|
-
severity?: number
|
|
149
|
-
occurredAt: number
|
|
150
|
-
payloadRef: string
|
|
151
|
-
payloadHash?: string
|
|
152
|
-
asset: { externalSystem: string; externalId: string; displayName: string; kind?: string }
|
|
153
|
-
workItem: {
|
|
154
|
-
domain: string
|
|
155
|
-
category: string
|
|
156
|
-
title: string
|
|
157
|
-
projectId?: string | null
|
|
158
|
-
ownerHumanId: string
|
|
159
|
-
templateVersionId?: string | null
|
|
160
|
-
dedupeKey: string
|
|
161
|
-
priority?: number
|
|
162
|
-
dueAt?: number | null
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export interface CreateWorkItemTxInput {
|
|
167
|
-
domain: string
|
|
168
|
-
category: string
|
|
169
|
-
title: string
|
|
170
|
-
ownerHumanId: string
|
|
171
|
-
projectId?: string | null
|
|
172
|
-
templateVersionId?: string | null
|
|
173
|
-
assetId?: string | null
|
|
174
|
-
dedupeKey?: string | null
|
|
175
|
-
priority?: number
|
|
176
|
-
dueAt?: number | null
|
|
177
|
-
createdBy: string
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export interface CreateWorkItemCommandInput {
|
|
181
|
-
domain: string
|
|
182
|
-
category: string
|
|
183
|
-
title: string
|
|
184
|
-
ownerHumanId?: string
|
|
185
|
-
projectId?: string | null
|
|
186
|
-
templateVersionId?: string | null
|
|
187
|
-
assetId?: string | null
|
|
188
|
-
dedupeKey?: string | null
|
|
189
|
-
priority?: number
|
|
190
|
-
dueAt?: number | null
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
export interface ProposeInput {
|
|
194
|
-
kind: 'create_task' | 'upgrade_task' | 'permission_request' | 'scope_change' | 'knowledge_change'
|
|
195
|
-
subjectKey: string
|
|
196
|
-
dedupeKey: string
|
|
197
|
-
deciderId: string
|
|
198
|
-
title: string
|
|
199
|
-
body: string
|
|
200
|
-
rationale: { evidence: unknown[]; expectedEffect?: string; cost?: unknown }
|
|
201
|
-
impact?: Record<string, unknown>
|
|
202
|
-
projectId?: string | null
|
|
203
|
-
workItemId?: string | null
|
|
204
|
-
expiresAt?: number | null
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
export interface DecideProposalInput {
|
|
208
|
-
proposalId: string
|
|
209
|
-
decision: 'accept' | 'reject'
|
|
210
|
-
note: string
|
|
211
|
-
workItem?: {
|
|
212
|
-
domain: string
|
|
213
|
-
category: string
|
|
214
|
-
title: string
|
|
215
|
-
ownerHumanId: string
|
|
216
|
-
projectId?: string | null
|
|
217
|
-
templateVersionId?: string | null
|
|
218
|
-
assetId?: string | null
|
|
219
|
-
dueAt?: number | null
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export interface ClaimInput {
|
|
224
|
-
workItemId: string
|
|
225
|
-
/** 宿主会话 id;新建线程时必填,复用现有 active 线程时忽略。 */
|
|
226
|
-
sessionId?: string
|
|
227
|
-
leaseMinutes?: number
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
export interface AppendEvidenceInput {
|
|
231
|
-
workItemId: string
|
|
232
|
-
kind: string
|
|
233
|
-
ref: string
|
|
234
|
-
observedAt: number
|
|
235
|
-
contentHash?: string
|
|
236
|
-
source?: string
|
|
237
|
-
runId?: string | null
|
|
238
|
-
onBehalfOf?: string | null
|
|
239
|
-
supersedesId?: string | null
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
export interface RequestClosureInput {
|
|
243
|
-
workItemId: string
|
|
244
|
-
reason: string
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
export interface DecideApprovalInput {
|
|
248
|
-
approvalId: string
|
|
249
|
-
decision: 'approve' | 'reject' | 'return'
|
|
250
|
-
note?: string
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
export interface EvaluateCheckInput {
|
|
254
|
-
checkId: string
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
export interface SubmitResultInput {
|
|
258
|
-
runId: string
|
|
259
|
-
summary: string
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
export class HamsService {
|
|
263
|
-
private db: DatabaseSync
|
|
264
|
-
private now: () => number
|
|
265
|
-
private runtimeEpoch: number
|
|
266
|
-
private id: IdFactory
|
|
267
|
-
private timeZone: string
|
|
268
|
-
private validators: Record<string, Validator>
|
|
269
|
-
private knowledgeService: KnowledgeService | null
|
|
270
|
-
|
|
271
|
-
constructor(options: HamsServiceOptions) {
|
|
272
|
-
this.db = options.db
|
|
273
|
-
this.now = options.now ?? (() => Date.now())
|
|
274
|
-
this.id = options.idFactory ?? createIdFactory(this.now)
|
|
275
|
-
this.timeZone = options.timeZone ?? 'Asia/Shanghai'
|
|
276
|
-
this.validators = options.validators ?? {}
|
|
277
|
-
this.runtimeEpoch = options.runtimeEpoch ?? this.readRuntimeEpoch()
|
|
278
|
-
this.knowledgeService = options.knowledge?.dataDir
|
|
279
|
-
? new KnowledgeService({
|
|
280
|
-
db: this.db,
|
|
281
|
-
now: this.now,
|
|
282
|
-
idFactory: this.id,
|
|
283
|
-
dataDir: options.knowledge.dataDir,
|
|
284
|
-
skillDirs: options.knowledge.skillDirs,
|
|
285
|
-
})
|
|
286
|
-
: null
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// ───────────────────────── 读 ─────────────────────────
|
|
290
|
-
|
|
291
|
-
whoami(ctx: CommandContext): { principal: Omit<Principal, 'capabilities'>; capabilities: string[] } {
|
|
292
|
-
const principal = resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
293
|
-
const { capabilities, ...rest } = principal
|
|
294
|
-
return { principal: rest, capabilities: [...capabilities].sort() }
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
getWorkItem(ctx: CommandContext, workItemId: string) {
|
|
298
|
-
const principal = resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
299
|
-
const item = this.workItemOrThrow(workItemId)
|
|
300
|
-
const all = (table: string) => this.db.prepare(`SELECT * FROM ${table} WHERE work_item_id=? ORDER BY rowid`).all(item.id)
|
|
301
|
-
const threads = all('work_item_thread') as ThreadRow[]
|
|
302
|
-
const thread = threads.find(t => t.status === 'active' || t.status === 'pending_rotate') ?? null
|
|
303
|
-
const templateVersion = this.templateVersionTx(item.template_version_id)
|
|
304
|
-
return { ...item, allowedActions: this.allowedActions(principal, item),
|
|
305
|
-
events: all('work_item_event'), evidence: all('evidence'), runs: all('task_run'),
|
|
306
|
-
approvals: this.db.prepare("SELECT * FROM approval WHERE subject_id=? AND subject_kind='work_item_close' ORDER BY created_at DESC").all(item.id),
|
|
307
|
-
checks: all('work_item_check'), threads, thread, current_thread: thread, templateVersion,
|
|
308
|
-
criteriaSnapshot: templateVersion ? { templateVersionId: templateVersion.id, requiredEvidence: JSON.parse(templateVersion.required_evidence_json), validatorKey: templateVersion.validator_key, validatorParams: JSON.parse(templateVersion.validator_params_json), checkPolicy: JSON.parse(templateVersion.check_policy_json), reviewPolicy: JSON.parse(templateVersion.review_policy_json) } : null,
|
|
309
|
-
canEvaluateCheck: principal.capabilities.has(CAPABILITY.checkEvaluate) && item.status !== 'paused',
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
listWorkItems(
|
|
314
|
-
ctx: CommandContext,
|
|
315
|
-
filter: { status?: string; assigneeId?: string; ownerId?: string; projectId?: string; domain?: string; limit?: number } = {},
|
|
316
|
-
): WorkItemRow[] {
|
|
317
|
-
resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
318
|
-
const clauses: string[] = []
|
|
319
|
-
const params: unknown[] = []
|
|
320
|
-
if (filter.status) {
|
|
321
|
-
clauses.push('status = ?')
|
|
322
|
-
params.push(filter.status)
|
|
323
|
-
}
|
|
324
|
-
if (filter.assigneeId) {
|
|
325
|
-
clauses.push('assignee_id = ?')
|
|
326
|
-
params.push(filter.assigneeId)
|
|
327
|
-
}
|
|
328
|
-
if (filter.ownerId) {
|
|
329
|
-
clauses.push('owner_human_id = ?')
|
|
330
|
-
params.push(filter.ownerId)
|
|
331
|
-
}
|
|
332
|
-
for (const [field,column] of [['projectId','project_id'],['domain','domain']] as const) {
|
|
333
|
-
if (filter[field]) { clauses.push(`${column} = ?`); params.push(filter[field]) }
|
|
334
|
-
}
|
|
335
|
-
const limit = Math.min(Math.max(filter.limit ?? 50, 1), 200)
|
|
336
|
-
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''
|
|
337
|
-
return this.db
|
|
338
|
-
.prepare(`SELECT * FROM work_item ${where} ORDER BY updated_at DESC LIMIT ${limit}`)
|
|
339
|
-
.all(...(params as never[])) as WorkItemRow[]
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
listApprovals(ctx: CommandContext, filter: { approverId?: string; status?: string } = {}): ApprovalRow[] {
|
|
343
|
-
resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
344
|
-
const clauses: string[] = []
|
|
345
|
-
const params: unknown[] = []
|
|
346
|
-
if (filter.approverId) {
|
|
347
|
-
clauses.push('approver_id = ?')
|
|
348
|
-
params.push(filter.approverId)
|
|
349
|
-
}
|
|
350
|
-
if (filter.status) {
|
|
351
|
-
clauses.push('status = ?')
|
|
352
|
-
params.push(filter.status)
|
|
353
|
-
}
|
|
354
|
-
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''
|
|
355
|
-
return this.db
|
|
356
|
-
.prepare(`SELECT * FROM approval ${where} ORDER BY created_at DESC LIMIT 200`)
|
|
357
|
-
.all(...(params as never[])) as ApprovalRow[]
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/** 诊断页 / ops/health 用:只读计数,不触发任何写。 */
|
|
361
|
-
health(ctx: CommandContext): {
|
|
362
|
-
schemaVersion: number
|
|
363
|
-
runtimeEpoch: number
|
|
364
|
-
now: number
|
|
365
|
-
counts: {
|
|
366
|
-
workItems: number
|
|
367
|
-
openWorkItems: number
|
|
368
|
-
pendingApprovals: number
|
|
369
|
-
activeRuns: number
|
|
370
|
-
dueChecks: number
|
|
371
|
-
leases: number
|
|
372
|
-
}
|
|
373
|
-
} {
|
|
374
|
-
resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
375
|
-
const count = (sql: string, ...params: (string | number)[]): number =>
|
|
376
|
-
Number((this.db.prepare(sql).get(...params) as { c: number }).c)
|
|
377
|
-
return {
|
|
378
|
-
schemaVersion: Number((this.db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version),
|
|
379
|
-
runtimeEpoch: this.runtimeEpoch,
|
|
380
|
-
now: this.now(),
|
|
381
|
-
counts: {
|
|
382
|
-
workItems: count('SELECT COUNT(*) AS c FROM work_item'),
|
|
383
|
-
openWorkItems: count("SELECT COUNT(*) AS c FROM work_item WHERE status NOT IN ('completed','cancelled')"),
|
|
384
|
-
pendingApprovals: count("SELECT COUNT(*) AS c FROM approval WHERE status = 'pending'"),
|
|
385
|
-
activeRuns: count("SELECT COUNT(*) AS c FROM task_run WHERE status IN ('queued','running')"),
|
|
386
|
-
dueChecks: count("SELECT COUNT(*) AS c FROM work_item_check WHERE status = 'scheduled' AND planned_due_at <= ?", this.now()),
|
|
387
|
-
leases: count('SELECT COUNT(*) AS c FROM lease'),
|
|
388
|
-
},
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
listChecks(ctx: CommandContext, workItemId: string): CheckRow[] {
|
|
393
|
-
resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
394
|
-
return this.db
|
|
395
|
-
.prepare('SELECT * FROM work_item_check WHERE work_item_id = ? ORDER BY generation DESC')
|
|
396
|
-
.all(workItemId) as CheckRow[]
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* 模板目录(只读):建单要绑一个**已发布**的判据版本,否则申请关单会被拒。
|
|
401
|
-
*
|
|
402
|
-
* 只返回已发布版本;草稿/退役版本对界面没有意义(建单也选不了)。
|
|
403
|
-
* 没有模板时返回空数组,界面要显式提示「先配模板」,而不是让人建出关不掉单。
|
|
404
|
-
*/
|
|
405
|
-
listTemplates(ctx: CommandContext, filter: { domain?: string } = {}): (TemplateRow & {
|
|
406
|
-
version: number | null
|
|
407
|
-
version_status: string | null
|
|
408
|
-
required_evidence_json: string | null
|
|
409
|
-
check_policy_json: string | null
|
|
410
|
-
review_policy_json: string | null
|
|
411
|
-
})[] {
|
|
412
|
-
resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
413
|
-
const where = filter.domain ? 'WHERE t.domain = ?' : ''
|
|
414
|
-
const params: unknown[] = filter.domain ? [filter.domain] : []
|
|
415
|
-
return this.db
|
|
416
|
-
.prepare(
|
|
417
|
-
`SELECT t.id, t.key, t.name, t.domain, t.category, t.published_version_id,
|
|
418
|
-
v.version, v.status AS version_status, v.required_evidence_json,
|
|
419
|
-
v.check_policy_json, v.review_policy_json
|
|
420
|
-
FROM work_template t
|
|
421
|
-
LEFT JOIN work_template_version v ON v.id = t.published_version_id
|
|
422
|
-
${where}
|
|
423
|
-
ORDER BY t.name`,
|
|
424
|
-
)
|
|
425
|
-
.all(...(params as never[])) as never
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
listProposals(
|
|
429
|
-
ctx: CommandContext,
|
|
430
|
-
filter: { deciderId?: string; status?: string; limit?: number } = {},
|
|
431
|
-
): ProposalRow[] {
|
|
432
|
-
resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
433
|
-
const clauses: string[] = []
|
|
434
|
-
const params: unknown[] = []
|
|
435
|
-
if (filter.deciderId) {
|
|
436
|
-
clauses.push('decider_id = ?')
|
|
437
|
-
params.push(filter.deciderId)
|
|
438
|
-
}
|
|
439
|
-
if (filter.status) {
|
|
440
|
-
clauses.push('status = ?')
|
|
441
|
-
params.push(filter.status)
|
|
442
|
-
}
|
|
443
|
-
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''
|
|
444
|
-
for (const [field,column] of [['projectId','project_id'],['domain','domain']] as const) {
|
|
445
|
-
if (filter[field]) { clauses.push(`${column} = ?`); params.push(filter[field]) }
|
|
446
|
-
}
|
|
447
|
-
const limit = Math.min(Math.max(filter.limit ?? 50, 1), 200)
|
|
448
|
-
return this.db
|
|
449
|
-
.prepare(`SELECT * FROM proposal ${where} ORDER BY created_at DESC LIMIT ${limit}`)
|
|
450
|
-
.all(...(params as never[])) as ProposalRow[]
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* 总览读模型(画板 ①):待我把关、到期复查、执行与异常、状态计数。
|
|
455
|
-
* 只读、不写审计;界面用「快照 + changes/since 游标」增量刷新。
|
|
456
|
-
*/
|
|
457
|
-
overview(ctx: CommandContext): {
|
|
458
|
-
principalId: string
|
|
459
|
-
generatedAt: number
|
|
460
|
-
counts: { byStatus: Record<string, number>; myOpen: number; attention: number; activeRuns: number }
|
|
461
|
-
approvals: ApprovalRow[]
|
|
462
|
-
proposals: ProposalRow[]
|
|
463
|
-
reviews: Record<string, any>[]
|
|
464
|
-
dueChecks: (CheckRow & { title: string })[]
|
|
465
|
-
attention: { id: string; workItemId: string | null; reason: string; detail: string; createdAt: number }[]
|
|
466
|
-
executions: { runId: string; workItemId: string; title: string; status: string; memberId: string; startedAt: number | null }[]
|
|
467
|
-
latestChangeSeq: number
|
|
468
|
-
} {
|
|
469
|
-
const principal = resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
470
|
-
const at = this.now()
|
|
471
|
-
const byStatusRows = this.db
|
|
472
|
-
.prepare('SELECT status, COUNT(*) AS c FROM work_item GROUP BY status')
|
|
473
|
-
.all() as { status: string; c: number }[]
|
|
474
|
-
const byStatus: Record<string, number> = {}
|
|
475
|
-
for (const row of byStatusRows) byStatus[row.status] = Number(row.c)
|
|
476
|
-
|
|
477
|
-
const myOpen = Number(
|
|
478
|
-
(
|
|
479
|
-
this.db
|
|
480
|
-
.prepare(
|
|
481
|
-
`SELECT COUNT(*) AS c FROM work_item
|
|
482
|
-
WHERE (owner_human_id = ? OR assignee_id = ?) AND status NOT IN ('completed','cancelled')`,
|
|
483
|
-
)
|
|
484
|
-
.get(principal.memberId, principal.memberId) as { c: number }
|
|
485
|
-
).c,
|
|
486
|
-
)
|
|
487
|
-
|
|
488
|
-
const approvals = this.db
|
|
489
|
-
.prepare(
|
|
490
|
-
`SELECT * FROM approval WHERE approver_id = ? AND status = 'pending'
|
|
491
|
-
ORDER BY expires_at LIMIT 50`,
|
|
492
|
-
)
|
|
493
|
-
.all(principal.memberId) as ApprovalRow[]
|
|
494
|
-
|
|
495
|
-
const proposals = this.db
|
|
496
|
-
.prepare(
|
|
497
|
-
`SELECT * FROM proposal WHERE decider_id = ? AND status = 'open'
|
|
498
|
-
ORDER BY created_at LIMIT 50`,
|
|
499
|
-
)
|
|
500
|
-
.all(principal.memberId) as ProposalRow[]
|
|
501
|
-
|
|
502
|
-
const dueChecks = this.db
|
|
503
|
-
.prepare(
|
|
504
|
-
`SELECT c.*, w.title AS title FROM work_item_check c
|
|
505
|
-
JOIN work_item w ON w.id = c.work_item_id
|
|
506
|
-
WHERE c.status = 'scheduled' AND c.planned_due_at <= ?
|
|
507
|
-
AND (w.owner_human_id = ? OR w.assignee_id = ?)
|
|
508
|
-
ORDER BY c.planned_due_at LIMIT 50`,
|
|
509
|
-
)
|
|
510
|
-
.all(at, principal.memberId, principal.memberId) as (CheckRow & { title: string })[]
|
|
511
|
-
|
|
512
|
-
const attention = this.db
|
|
513
|
-
.prepare(
|
|
514
|
-
`SELECT id, work_item_id AS workItemId, reason, detail, created_at AS createdAt
|
|
515
|
-
FROM attention_item WHERE to_member_id = ? AND resolved_at IS NULL
|
|
516
|
-
ORDER BY created_at DESC LIMIT 50`,
|
|
517
|
-
)
|
|
518
|
-
.all(principal.memberId) as { id: string; workItemId: string | null; reason: string; detail: string; createdAt: number }[]
|
|
519
|
-
|
|
520
|
-
const executions = this.db
|
|
521
|
-
.prepare(
|
|
522
|
-
`SELECT r.id AS runId, r.work_item_id AS workItemId, w.title AS title, r.status AS status,
|
|
523
|
-
r.member_id AS memberId, r.started_at AS startedAt
|
|
524
|
-
FROM task_run r JOIN work_item w ON w.id = r.work_item_id
|
|
525
|
-
WHERE r.status IN ('queued','running')
|
|
526
|
-
AND (w.owner_human_id = ? OR w.assignee_id = ?)
|
|
527
|
-
ORDER BY r.created_at DESC LIMIT 50`,
|
|
528
|
-
)
|
|
529
|
-
.all(principal.memberId, principal.memberId) as {
|
|
530
|
-
runId: string
|
|
531
|
-
workItemId: string
|
|
532
|
-
title: string
|
|
533
|
-
status: string
|
|
534
|
-
memberId: string
|
|
535
|
-
startedAt: number | null
|
|
536
|
-
}[]
|
|
537
|
-
|
|
538
|
-
const latestChangeSeq = Number(
|
|
539
|
-
(this.db.prepare('SELECT COALESCE(MAX(seq), 0) AS seq FROM change_log').get() as { seq: number }).seq,
|
|
540
|
-
)
|
|
541
|
-
|
|
542
|
-
return {
|
|
543
|
-
principalId: principal.memberId,
|
|
544
|
-
generatedAt: at,
|
|
545
|
-
counts: {
|
|
546
|
-
byStatus,
|
|
547
|
-
myOpen,
|
|
548
|
-
attention: attention.length,
|
|
549
|
-
activeRuns: Number(
|
|
550
|
-
(this.db.prepare("SELECT COUNT(*) AS c FROM task_run WHERE status IN ('queued','running')").get() as { c: number }).c,
|
|
551
|
-
),
|
|
552
|
-
},
|
|
553
|
-
approvals,
|
|
554
|
-
proposals,
|
|
555
|
-
reviews: this.db.prepare("SELECT r.*, r.project_id AS projectId, p.name AS projectName FROM project_review r JOIN project p ON p.id=r.project_id WHERE r.status='awaiting_confirm' AND r.confirmer_id=? ORDER BY r.period_end DESC").all(principal.memberId),
|
|
556
|
-
dueChecks,
|
|
557
|
-
attention,
|
|
558
|
-
executions,
|
|
559
|
-
latestChangeSeq,
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/**
|
|
564
|
-
* 变更游标(画板 ㉑ 第 6 格):只读 change_log;游标超出保留期返回 CURSOR_EXPIRED。
|
|
565
|
-
* 保留期暂按「最早一条的 seq - 1」判断:有人归档/清理过旧行就算过期。
|
|
566
|
-
*/
|
|
567
|
-
changesSince(
|
|
568
|
-
ctx: CommandContext,
|
|
569
|
-
input: { cursor?: number; limit?: number } = {},
|
|
570
|
-
): {
|
|
571
|
-
cursor: number
|
|
572
|
-
nextCursor: number
|
|
573
|
-
latestCursor: number
|
|
574
|
-
hasMore: boolean
|
|
575
|
-
items: { seq: number; objectKind: string; objectId: string; revision: number; changeKind: string; createdAt: number }[]
|
|
576
|
-
} {
|
|
577
|
-
resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
578
|
-
const cursor = Math.max(0, Math.floor(input.cursor ?? 0))
|
|
579
|
-
const limit = Math.min(Math.max(input.limit ?? 200, 1), 500)
|
|
580
|
-
const bounds = this.db
|
|
581
|
-
.prepare('SELECT MIN(seq) AS minSeq, MAX(seq) AS maxSeq FROM change_log')
|
|
582
|
-
.get() as { minSeq: number | null; maxSeq: number | null }
|
|
583
|
-
const minSeq = bounds.minSeq === null ? 0 : Number(bounds.minSeq)
|
|
584
|
-
const latestCursor = bounds.maxSeq === null ? 0 : Number(bounds.maxSeq)
|
|
585
|
-
if (minSeq > 0 && cursor < minSeq - 1) {
|
|
586
|
-
throw new HamsError('CURSOR_EXPIRED', `游标 ${cursor} 早于保留期(最早 ${minSeq}),请重新拉取快照`, {
|
|
587
|
-
nextStep: '重新获取 overview/get 快照后再用新游标增量',
|
|
588
|
-
})
|
|
589
|
-
}
|
|
590
|
-
const rows = this.db
|
|
591
|
-
.prepare(
|
|
592
|
-
`SELECT seq, object_kind AS objectKind, object_id AS objectId, revision,
|
|
593
|
-
change_kind AS changeKind, created_at AS createdAt
|
|
594
|
-
FROM change_log WHERE seq > ? ORDER BY seq LIMIT ?`,
|
|
595
|
-
)
|
|
596
|
-
.all(cursor, limit) as {
|
|
597
|
-
seq: number
|
|
598
|
-
objectKind: string
|
|
599
|
-
objectId: string
|
|
600
|
-
revision: number
|
|
601
|
-
changeKind: string
|
|
602
|
-
createdAt: number
|
|
603
|
-
}[]
|
|
604
|
-
const nextCursor = rows.length ? Number(rows[rows.length - 1].seq) : cursor
|
|
605
|
-
return {
|
|
606
|
-
cursor,
|
|
607
|
-
nextCursor,
|
|
608
|
-
latestCursor,
|
|
609
|
-
hasMore: nextCursor < latestCursor,
|
|
610
|
-
items: rows.map((row) => ({ ...row, seq: Number(row.seq), revision: Number(row.revision) })),
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
// ───────────────────────── 人工建单 ─────────────────────────
|
|
615
|
-
|
|
616
|
-
createWorkItem(ctx: CommandContext, input: CreateWorkItemCommandInput): CommandReceipt {
|
|
617
|
-
return this.command(ctx, 'workItem/create', input, () => {
|
|
618
|
-
const principal = this.principal(ctx)
|
|
619
|
-
requireCapability(principal, CAPABILITY.workItemCreate)
|
|
620
|
-
if (principal.kind === 'bot') throw denied('bot 建单必须走提议,不能直接建单')
|
|
621
|
-
const ownerHumanId = input.ownerHumanId ?? principal.memberId
|
|
622
|
-
if (input.dedupeKey) {
|
|
623
|
-
const open = this.findOpenByDedupeKey(input.dedupeKey)
|
|
624
|
-
if (open) {
|
|
625
|
-
const auditId = this.audit(ctx, principal, {
|
|
626
|
-
action: 'workItem/create',
|
|
627
|
-
subjectKind: 'work_item',
|
|
628
|
-
subjectId: open.id,
|
|
629
|
-
after: { duplicate: true, dedupeKey: input.dedupeKey },
|
|
630
|
-
})
|
|
631
|
-
return this.receipt(ctx, {
|
|
632
|
-
status: 'accepted',
|
|
633
|
-
objectId: open.id,
|
|
634
|
-
objectRevision: open.revision,
|
|
635
|
-
executionEpoch: open.execution_epoch,
|
|
636
|
-
auditId,
|
|
637
|
-
detail: { duplicate: true, dedupeKey: input.dedupeKey },
|
|
638
|
-
})
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
const item = this.createWorkItemTx(ctx, principal, {
|
|
642
|
-
domain: input.domain,
|
|
643
|
-
category: input.category,
|
|
644
|
-
title: input.title,
|
|
645
|
-
ownerHumanId,
|
|
646
|
-
projectId: input.projectId ?? null,
|
|
647
|
-
templateVersionId: input.templateVersionId ?? null,
|
|
648
|
-
assetId: input.assetId ?? null,
|
|
649
|
-
dedupeKey: input.dedupeKey ?? null,
|
|
650
|
-
priority: input.priority,
|
|
651
|
-
dueAt: input.dueAt ?? null,
|
|
652
|
-
createdBy: principal.memberId,
|
|
653
|
-
})
|
|
654
|
-
const auditId = this.audit(ctx, principal, {
|
|
655
|
-
action: 'workItem/create',
|
|
656
|
-
subjectKind: 'work_item',
|
|
657
|
-
subjectId: item.id,
|
|
658
|
-
after: { title: item.title, dedupeKey: item.dedupe_key },
|
|
659
|
-
})
|
|
660
|
-
return this.receipt(ctx, {
|
|
661
|
-
status: 'applied',
|
|
662
|
-
objectId: item.id,
|
|
663
|
-
objectRevision: item.revision,
|
|
664
|
-
executionEpoch: item.execution_epoch,
|
|
665
|
-
auditId,
|
|
666
|
-
detail: { dedupeKey: item.dedupe_key },
|
|
667
|
-
})
|
|
668
|
-
})
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
// ───────────────────────── T1 建任务 ─────────────────────────
|
|
672
|
-
|
|
673
|
-
ingestAlert(ctx: CommandContext, input: IngestAlertInput): CommandReceipt {
|
|
674
|
-
return this.command(ctx, 'work_item/ingestAlert', input, () => {
|
|
675
|
-
const principal = this.principal(ctx)
|
|
676
|
-
if (principal.kind !== 'system') requireCapability(principal, CAPABILITY.connectorIngest)
|
|
677
|
-
const at = this.now()
|
|
678
|
-
|
|
679
|
-
const existingEvent = this.db
|
|
680
|
-
.prepare('SELECT * FROM event_inbox WHERE connector = ? AND external_event_id = ?')
|
|
681
|
-
.get(input.connector, input.externalEventId) as
|
|
682
|
-
| { id: string; work_item_id: string | null; disposition: string }
|
|
683
|
-
| undefined
|
|
684
|
-
if (existingEvent) {
|
|
685
|
-
const existingItem = existingEvent.work_item_id ? this.workItemOrThrow(existingEvent.work_item_id) : null
|
|
686
|
-
const auditId = this.audit(ctx, principal, {
|
|
687
|
-
action: 'work_item/ingestAlert',
|
|
688
|
-
subjectKind: 'event_inbox',
|
|
689
|
-
subjectId: existingEvent.id,
|
|
690
|
-
after: { disposition: existingEvent.disposition, duplicate: true },
|
|
691
|
-
})
|
|
692
|
-
return this.receipt(ctx, {
|
|
693
|
-
status: 'accepted',
|
|
694
|
-
objectId: existingItem?.id,
|
|
695
|
-
objectRevision: existingItem?.revision,
|
|
696
|
-
executionEpoch: existingItem?.execution_epoch,
|
|
697
|
-
auditId,
|
|
698
|
-
detail: { duplicate: true, disposition: existingEvent.disposition, eventId: existingEvent.id },
|
|
699
|
-
})
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
const eventId = this.id()
|
|
703
|
-
const assetId = this.upsertAsset(input.asset)
|
|
704
|
-
this.db
|
|
705
|
-
.prepare(
|
|
706
|
-
`INSERT INTO event_inbox
|
|
707
|
-
(id, connector, external_event_id, asset_id, event_type, severity, payload_ref, payload_hash,
|
|
708
|
-
occurred_at, received_at, disposition)
|
|
709
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'received')`,
|
|
710
|
-
)
|
|
711
|
-
.run(
|
|
712
|
-
eventId,
|
|
713
|
-
input.connector,
|
|
714
|
-
input.externalEventId,
|
|
715
|
-
assetId,
|
|
716
|
-
input.eventType,
|
|
717
|
-
input.severity ?? 0,
|
|
718
|
-
input.payloadRef,
|
|
719
|
-
input.payloadHash ?? createHash('sha256').update(`${input.connector}:${input.externalEventId}`).digest('hex'),
|
|
720
|
-
input.occurredAt,
|
|
721
|
-
at,
|
|
722
|
-
)
|
|
723
|
-
|
|
724
|
-
const open = this.findOpenByDedupeKey(input.workItem.dedupeKey)
|
|
725
|
-
if (open) {
|
|
726
|
-
this.db
|
|
727
|
-
.prepare("UPDATE event_inbox SET work_item_id = ?, disposition = 'attached' WHERE id = ?")
|
|
728
|
-
.run(open.id, eventId)
|
|
729
|
-
const auditId = this.audit(ctx, principal, {
|
|
730
|
-
action: 'work_item/ingestAlert',
|
|
731
|
-
subjectKind: 'work_item',
|
|
732
|
-
subjectId: open.id,
|
|
733
|
-
after: { disposition: 'attached', eventId },
|
|
734
|
-
})
|
|
735
|
-
return this.receipt(ctx, {
|
|
736
|
-
status: 'accepted',
|
|
737
|
-
objectId: open.id,
|
|
738
|
-
objectRevision: open.revision,
|
|
739
|
-
executionEpoch: open.execution_epoch,
|
|
740
|
-
auditId,
|
|
741
|
-
detail: { duplicate: true, disposition: 'attached', eventId },
|
|
742
|
-
})
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
const item = this.createWorkItemTx(ctx, principal, {
|
|
746
|
-
...input.workItem,
|
|
747
|
-
assetId,
|
|
748
|
-
createdBy: principal.memberId,
|
|
749
|
-
})
|
|
750
|
-
this.db.prepare("UPDATE event_inbox SET work_item_id = ?, disposition = 'created' WHERE id = ?").run(item.id, eventId)
|
|
751
|
-
const auditId = this.audit(ctx, principal, {
|
|
752
|
-
action: 'work_item/ingestAlert',
|
|
753
|
-
subjectKind: 'work_item',
|
|
754
|
-
subjectId: item.id,
|
|
755
|
-
after: { eventId, dedupeKey: input.workItem.dedupeKey },
|
|
756
|
-
})
|
|
757
|
-
return this.receipt(ctx, {
|
|
758
|
-
status: 'applied',
|
|
759
|
-
objectId: item.id,
|
|
760
|
-
objectRevision: item.revision,
|
|
761
|
-
executionEpoch: item.executionEpoch,
|
|
762
|
-
auditId,
|
|
763
|
-
detail: { eventId, disposition: 'created' },
|
|
764
|
-
})
|
|
765
|
-
})
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
// ───────────────────────── T4 提议 → 采纳落单 ─────────────────────────
|
|
769
|
-
|
|
770
|
-
propose(ctx: CommandContext, input: ProposeInput): CommandReceipt {
|
|
771
|
-
return this.command(ctx, 'proposal/propose', input, () => {
|
|
772
|
-
const principal = this.principal(ctx)
|
|
773
|
-
requireBot(principal)
|
|
774
|
-
requireCapability(principal, CAPABILITY.proposalCreate)
|
|
775
|
-
if (!Array.isArray(input.rationale?.evidence) || input.rationale.evidence.length === 0) {
|
|
776
|
-
throw denied('提议必须给出理由与证据;rationale.evidence 不能为空')
|
|
777
|
-
}
|
|
778
|
-
const decider = this.memberOrThrow(input.deciderId)
|
|
779
|
-
if (decider.kind !== 'human') throw denied('提议只能由人决定;deciderId 必须是人')
|
|
780
|
-
const at = this.now()
|
|
781
|
-
|
|
782
|
-
const open = this.db
|
|
783
|
-
.prepare(
|
|
784
|
-
`SELECT * FROM proposal WHERE subject_key = ? AND kind = ? AND dedupe_key = ? AND status = 'open'`,
|
|
785
|
-
)
|
|
786
|
-
.get(input.subjectKey, input.kind, input.dedupeKey) as ProposalRow | undefined
|
|
787
|
-
if (open) {
|
|
788
|
-
this.db
|
|
789
|
-
.prepare('UPDATE proposal SET duplicate_count = duplicate_count + 1, revision = revision + 1 WHERE id = ?')
|
|
790
|
-
.run(open.id)
|
|
791
|
-
writeChange(this.db, {
|
|
792
|
-
objectKind: 'proposal',
|
|
793
|
-
objectId: open.id,
|
|
794
|
-
revision: open.revision + 1,
|
|
795
|
-
changeKind: 'duplicate_proposal',
|
|
796
|
-
at,
|
|
797
|
-
})
|
|
798
|
-
const auditId = this.audit(ctx, principal, {
|
|
799
|
-
action: 'proposal/propose',
|
|
800
|
-
subjectKind: 'proposal',
|
|
801
|
-
subjectId: open.id,
|
|
802
|
-
after: { duplicate: true, duplicateCount: open.duplicate_count + 1 },
|
|
803
|
-
})
|
|
804
|
-
return this.receipt(ctx, {
|
|
805
|
-
status: 'accepted',
|
|
806
|
-
objectId: open.id,
|
|
807
|
-
auditId,
|
|
808
|
-
detail: { duplicate: true, duplicateCount: open.duplicate_count + 1 },
|
|
809
|
-
})
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
const proposalId = this.id()
|
|
813
|
-
this.db
|
|
814
|
-
.prepare(
|
|
815
|
-
`INSERT INTO proposal
|
|
816
|
-
(id, subject_key, project_id, work_item_id, kind, dedupe_key, proposed_by, decider_id, title, body,
|
|
817
|
-
rationale_json, impact_json, status, duplicate_count, snoozed_until, expires_at, revision, created_at)
|
|
818
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', 0, NULL, ?, 1, ?)`,
|
|
819
|
-
)
|
|
820
|
-
.run(
|
|
821
|
-
proposalId,
|
|
822
|
-
input.subjectKey,
|
|
823
|
-
input.projectId ?? null,
|
|
824
|
-
input.workItemId ?? null,
|
|
825
|
-
input.kind,
|
|
826
|
-
input.dedupeKey,
|
|
827
|
-
principal.memberId,
|
|
828
|
-
input.deciderId,
|
|
829
|
-
input.title,
|
|
830
|
-
input.body,
|
|
831
|
-
JSON.stringify(input.rationale),
|
|
832
|
-
JSON.stringify(input.impact ?? {}),
|
|
833
|
-
input.expiresAt ?? null,
|
|
834
|
-
at,
|
|
835
|
-
)
|
|
836
|
-
writeChange(this.db, { objectKind: 'proposal', objectId: proposalId, revision: 1, changeKind: 'proposal_created', at })
|
|
837
|
-
const auditId = this.audit(ctx, principal, {
|
|
838
|
-
action: 'proposal/propose',
|
|
839
|
-
subjectKind: 'proposal',
|
|
840
|
-
subjectId: proposalId,
|
|
841
|
-
after: { kind: input.kind, title: input.title },
|
|
842
|
-
})
|
|
843
|
-
return this.receipt(ctx, { status: 'applied', objectId: proposalId, auditId })
|
|
844
|
-
})
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
decideProposal(ctx: CommandContext, input: DecideProposalInput): CommandReceipt {
|
|
848
|
-
return this.command(ctx, 'proposal/decide', input, () => {
|
|
849
|
-
const principal = this.principal(ctx)
|
|
850
|
-
requireHuman(principal)
|
|
851
|
-
requireCapability(principal, CAPABILITY.proposalDecide)
|
|
852
|
-
if (ctx.channel !== 'ui') throw denied('提议只能由人通过UI决定')
|
|
853
|
-
const at = this.now()
|
|
854
|
-
const proposal = this.db.prepare('SELECT * FROM proposal WHERE id = ?').get(input.proposalId) as ProposalRow | undefined
|
|
855
|
-
if (!proposal) throw stateInvalid(`提议不存在:${input.proposalId}`)
|
|
856
|
-
if (proposal.status !== 'open') throw stateInvalid(`提议已是终态:${proposal.status}`)
|
|
857
|
-
if (proposal.decider_id !== principal.memberId) {
|
|
858
|
-
throw denied('只有提议上写明的决定人可以决定')
|
|
859
|
-
}
|
|
860
|
-
if (input.decision === 'reject' && input.note.trim().length === 0) {
|
|
861
|
-
throw stateInvalid('驳回必须写明原因')
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
this.db
|
|
865
|
-
.prepare(
|
|
866
|
-
`INSERT INTO decision (id, proposal_id, decided_by, decision, channel, note, created_at)
|
|
867
|
-
VALUES (?, ?, ?, ?, 'ui', ?, ?)`,
|
|
868
|
-
)
|
|
869
|
-
.run(this.id(), proposal.id, principal.memberId, input.decision, input.note, at)
|
|
870
|
-
|
|
871
|
-
let workItem: WorkItemRow | null = null
|
|
872
|
-
if (input.decision === 'accept' && proposal.kind === 'create_task') {
|
|
873
|
-
if (!input.workItem) throw stateInvalid('采纳提议必须给出要建的工作项内容')
|
|
874
|
-
const existing = this.findOpenByDedupeKey(proposal.dedupe_key)
|
|
875
|
-
workItem = existing ?? this.createWorkItemTx(ctx, principal, {
|
|
876
|
-
...input.workItem,
|
|
877
|
-
dedupeKey: proposal.dedupe_key,
|
|
878
|
-
createdBy: principal.memberId,
|
|
879
|
-
})
|
|
880
|
-
this.db
|
|
881
|
-
.prepare(
|
|
882
|
-
"UPDATE proposal SET status = 'accepted', result_work_item_id = ?, revision = revision + 1 WHERE id = ?",
|
|
883
|
-
)
|
|
884
|
-
.run(workItem.id, proposal.id)
|
|
885
|
-
} else {
|
|
886
|
-
if (input.workItem) throw stateInvalid('非创建任务提议不能附带建单内容;变更须通过对应受权命令执行')
|
|
887
|
-
this.db.prepare('UPDATE proposal SET status = ?, revision = revision + 1 WHERE id = ?').run(input.decision === 'accept' ? 'accepted' : 'rejected', proposal.id)
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
writeChange(this.db, {
|
|
891
|
-
objectKind: 'proposal',
|
|
892
|
-
objectId: proposal.id,
|
|
893
|
-
revision: proposal.revision + 1,
|
|
894
|
-
changeKind: `proposal_${input.decision === 'accept' ? 'accepted' : 'rejected'}`,
|
|
895
|
-
at,
|
|
896
|
-
})
|
|
897
|
-
const auditId = this.audit(ctx, principal, {
|
|
898
|
-
action: 'proposal/decide',
|
|
899
|
-
subjectKind: 'proposal',
|
|
900
|
-
subjectId: proposal.id,
|
|
901
|
-
after: { decision: input.decision, note: input.note, workItemId: workItem?.id ?? null },
|
|
902
|
-
})
|
|
903
|
-
return this.receipt(ctx, {
|
|
904
|
-
status: 'applied',
|
|
905
|
-
objectId: workItem?.id ?? proposal.id,
|
|
906
|
-
objectRevision: workItem?.revision,
|
|
907
|
-
executionEpoch: workItem?.execution_epoch,
|
|
908
|
-
auditId,
|
|
909
|
-
detail: { proposalId: proposal.id, decision: input.decision, workItemId: workItem?.id ?? null },
|
|
910
|
-
})
|
|
911
|
-
})
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
// ───────────────────────── T2 认领 ─────────────────────────
|
|
915
|
-
|
|
916
|
-
claim(ctx: CommandContext, input: ClaimInput): CommandReceipt {
|
|
917
|
-
return this.command(ctx, 'work_item/claim', input, () => {
|
|
918
|
-
const principal = this.principal(ctx)
|
|
919
|
-
requireBot(principal)
|
|
920
|
-
// 系统派发替被指派的 bot 认领时,work_item.execute 就够:指派本身已经是人的动作,
|
|
921
|
-
// 而种子里的执行 bot 不一定同时拿到 claim(例如数据巡检只有 execute)。
|
|
922
|
-
if (ctx.channel === 'system') {
|
|
923
|
-
const canClaim = principal.capabilities instanceof Set ? principal.capabilities.has(CAPABILITY.workItemClaim) : false
|
|
924
|
-
if (!canClaim) requireCapability(principal, CAPABILITY.workItemExecute)
|
|
925
|
-
} else {
|
|
926
|
-
requireCapability(principal, CAPABILITY.workItemClaim)
|
|
927
|
-
}
|
|
928
|
-
const at = this.now()
|
|
929
|
-
const before = this.workItemOrThrow(input.workItemId)
|
|
930
|
-
if (before.status !== 'ready') {
|
|
931
|
-
throw stateInvalid(`只有 ready 的工作项可以认领,当前 ${before.status}`, '刷新后重试')
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
const leaseMinutes = Math.min(Math.max(input.leaseMinutes ?? 10, 1), 120)
|
|
935
|
-
const updated = this.db
|
|
936
|
-
.prepare(
|
|
937
|
-
`UPDATE work_item
|
|
938
|
-
SET status = 'active', assignee_id = ?, revision = revision + 1,
|
|
939
|
-
execution_epoch = execution_epoch + 1, fence_seq = fence_seq + 1, updated_at = ?
|
|
940
|
-
WHERE id = ? AND status = 'ready'`,
|
|
941
|
-
)
|
|
942
|
-
.run(principal.memberId, at, input.workItemId)
|
|
943
|
-
if (updated.changes !== 1) throw stateInvalid('认领竞争失败:工作项状态已变化')
|
|
944
|
-
|
|
945
|
-
const current = this.workItemOrThrow(input.workItemId)
|
|
946
|
-
const thread = this.ensureThreadTx(principal, current, input.sessionId)
|
|
947
|
-
const runId = this.id()
|
|
948
|
-
this.db
|
|
949
|
-
.prepare(
|
|
950
|
-
`INSERT INTO task_run
|
|
951
|
-
(id, work_item_id, thread_id, check_id, member_id, member_version, skill_versions_json,
|
|
952
|
-
execution_epoch, runtime_epoch, fence, trigger, attempt, status, created_at)
|
|
953
|
-
VALUES (?, ?, ?, NULL, ?, ?, '{}', ?, ?, ?, 'claim', 1, 'queued', ?)`,
|
|
954
|
-
)
|
|
955
|
-
.run(
|
|
956
|
-
runId,
|
|
957
|
-
current.id,
|
|
958
|
-
thread.id,
|
|
959
|
-
principal.memberId,
|
|
960
|
-
thread.member_version,
|
|
961
|
-
current.execution_epoch,
|
|
962
|
-
this.runtimeEpoch,
|
|
963
|
-
current.fence_seq,
|
|
964
|
-
at,
|
|
965
|
-
)
|
|
966
|
-
const leaseUntil = at + leaseMinutes * 60_000
|
|
967
|
-
this.db
|
|
968
|
-
.prepare(
|
|
969
|
-
`INSERT INTO lease (work_item_id, holder_member_id, run_id, fence, runtime_epoch, lease_until, renewed_at)
|
|
970
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
971
|
-
)
|
|
972
|
-
.run(current.id, principal.memberId, runId, current.fence_seq, this.runtimeEpoch, leaseUntil, at)
|
|
973
|
-
|
|
974
|
-
writeEvent(this.db, this.id(), {
|
|
975
|
-
workItemId: current.id,
|
|
976
|
-
kind: 'work_item_claimed',
|
|
977
|
-
actorId: principal.memberId,
|
|
978
|
-
actorKind: principal.kind,
|
|
979
|
-
payload: { runId, threadId: thread.id, fence: current.fence_seq, executionEpoch: current.execution_epoch },
|
|
980
|
-
revision: current.revision,
|
|
981
|
-
executionEpoch: current.execution_epoch,
|
|
982
|
-
at,
|
|
983
|
-
})
|
|
984
|
-
writeChange(this.db, { objectKind: 'work_item', objectId: current.id, revision: current.revision, changeKind: 'claimed', at })
|
|
985
|
-
const auditId = this.audit(ctx, principal, {
|
|
986
|
-
action: 'work_item/claim',
|
|
987
|
-
subjectKind: 'work_item',
|
|
988
|
-
subjectId: current.id,
|
|
989
|
-
before: { status: before.status, assignee: before.assignee_id },
|
|
990
|
-
after: { status: 'active', assignee: principal.memberId, runId, executionEpoch: current.execution_epoch },
|
|
991
|
-
})
|
|
992
|
-
return this.receipt(ctx, {
|
|
993
|
-
status: 'applied',
|
|
994
|
-
objectId: current.id,
|
|
995
|
-
objectRevision: current.revision,
|
|
996
|
-
executionEpoch: current.execution_epoch,
|
|
997
|
-
auditId,
|
|
998
|
-
detail: { runId, threadId: thread.id, leaseUntil, fence: current.fence_seq },
|
|
999
|
-
})
|
|
1000
|
-
})
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
/**
|
|
1004
|
-
* 指派:把一个 ready 的工作项交给某个在岗 bot(或人)。
|
|
1005
|
-
* 人要有 work_item.manage;bot 只能是该工作项所属项目的推进 bot(coordinator)——
|
|
1006
|
-
* 这就是「协调 bot 派活」的入口。真正开跑由会话适配器的派发器完成:它替被指派 bot 认领并投递一轮。
|
|
1007
|
-
*/
|
|
1008
|
-
assignWorkItem(ctx: CommandContext, input: { workItemId: string; memberId: string }): CommandReceipt {
|
|
1009
|
-
return this.command(ctx, 'work_item/assign', input, () => {
|
|
1010
|
-
const principal = this.principal(ctx)
|
|
1011
|
-
const at = this.now()
|
|
1012
|
-
const before = this.workItemOrThrow(input.workItemId)
|
|
1013
|
-
const target = this.db
|
|
1014
|
-
.prepare('SELECT id, kind, name, status FROM member WHERE id = ?')
|
|
1015
|
-
.get(input.memberId) as { id: string; kind: string; name: string; status: string } | undefined
|
|
1016
|
-
if (!target) throw stateInvalid(`成员不存在:${input.memberId}`)
|
|
1017
|
-
if (target.status !== 'active') throw stateInvalid(`${target.name} 不在岗,不能接任务`)
|
|
1018
|
-
let allowed = false
|
|
1019
|
-
if (principal.kind === 'human') {
|
|
1020
|
-
requireCapability(principal, CAPABILITY.workItemManage)
|
|
1021
|
-
allowed = true
|
|
1022
|
-
} else {
|
|
1023
|
-
const coordinator = before.project_id
|
|
1024
|
-
? (this.db.prepare('SELECT coordinator_id FROM project WHERE id = ?').get(before.project_id) as { coordinator_id: string | null } | undefined)?.coordinator_id
|
|
1025
|
-
: null
|
|
1026
|
-
allowed = Boolean(coordinator && coordinator === principal.memberId)
|
|
1027
|
-
}
|
|
1028
|
-
if (!allowed) throw denied('只有项目的推进 bot 或具备工单管理权限的人可以指派任务')
|
|
1029
|
-
if (before.status !== 'ready') throw stateInvalid(`只有待处理的工作项可以指派,当前 ${before.status}`)
|
|
1030
|
-
if (before.assignee_id === input.memberId) {
|
|
1031
|
-
return this.receipt(ctx, { status: 'noop', objectId: before.id, objectRevision: before.revision, executionEpoch: before.execution_epoch, detail: { memberId: target.id, alreadyAssigned: true } })
|
|
1032
|
-
}
|
|
1033
|
-
this.db
|
|
1034
|
-
.prepare('UPDATE work_item SET assignee_id = ?, revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
1035
|
-
.run(target.id, at, before.id)
|
|
1036
|
-
const after = this.workItemOrThrow(before.id)
|
|
1037
|
-
writeEvent(this.db, this.id(), {
|
|
1038
|
-
workItemId: after.id,
|
|
1039
|
-
kind: 'work_item_assigned',
|
|
1040
|
-
actorId: principal.memberId,
|
|
1041
|
-
actorKind: principal.kind,
|
|
1042
|
-
payload: { memberId: target.id, memberName: target.name, previous: before.assignee_id },
|
|
1043
|
-
revision: after.revision,
|
|
1044
|
-
executionEpoch: after.execution_epoch,
|
|
1045
|
-
at,
|
|
1046
|
-
})
|
|
1047
|
-
writeChange(this.db, { objectKind: 'work_item', objectId: after.id, revision: after.revision, changeKind: 'assigned', at })
|
|
1048
|
-
const auditId = this.audit(ctx, principal, {
|
|
1049
|
-
action: 'work_item/assign',
|
|
1050
|
-
subjectKind: 'work_item',
|
|
1051
|
-
subjectId: after.id,
|
|
1052
|
-
before: { assignee: before.assignee_id },
|
|
1053
|
-
after: { assignee: target.id, revision: after.revision },
|
|
1054
|
-
})
|
|
1055
|
-
return this.receipt(ctx, {
|
|
1056
|
-
status: 'applied',
|
|
1057
|
-
objectId: after.id,
|
|
1058
|
-
objectRevision: after.revision,
|
|
1059
|
-
executionEpoch: after.execution_epoch,
|
|
1060
|
-
auditId,
|
|
1061
|
-
detail: { memberId: target.id, memberName: target.name },
|
|
1062
|
-
})
|
|
1063
|
-
})
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
accept(ctx: CommandContext, input: { workItemId: string; note?: string }): CommandReceipt {
|
|
1067
|
-
return this.command(ctx, 'work_item/accept', input, () => {
|
|
1068
|
-
const principal = this.principal(ctx)
|
|
1069
|
-
requireHuman(principal)
|
|
1070
|
-
requireCapability(principal, CAPABILITY.workItemAccept)
|
|
1071
|
-
const at = this.now()
|
|
1072
|
-
const before = this.workItemOrThrow(input.workItemId)
|
|
1073
|
-
if (before.status !== 'ready') throw stateInvalid(`只有 ready 的工作项可以接单,当前 ${before.status}`)
|
|
1074
|
-
this.db
|
|
1075
|
-
.prepare(
|
|
1076
|
-
`UPDATE work_item
|
|
1077
|
-
SET status = 'active', assignee_id = ?, revision = revision + 1,
|
|
1078
|
-
execution_epoch = execution_epoch + 1, updated_at = ?
|
|
1079
|
-
WHERE id = ? AND status = 'ready'`,
|
|
1080
|
-
)
|
|
1081
|
-
.run(principal.memberId, at, input.workItemId)
|
|
1082
|
-
const current = this.workItemOrThrow(input.workItemId)
|
|
1083
|
-
writeEvent(this.db, this.id(), {
|
|
1084
|
-
workItemId: current.id,
|
|
1085
|
-
kind: 'work_item_accepted',
|
|
1086
|
-
actorId: principal.memberId,
|
|
1087
|
-
actorKind: 'human',
|
|
1088
|
-
payload: { note: input.note ?? '' },
|
|
1089
|
-
revision: current.revision,
|
|
1090
|
-
executionEpoch: current.execution_epoch,
|
|
1091
|
-
at,
|
|
1092
|
-
})
|
|
1093
|
-
writeChange(this.db, { objectKind: 'work_item', objectId: current.id, revision: current.revision, changeKind: 'accepted', at })
|
|
1094
|
-
const auditId = this.audit(ctx, principal, {
|
|
1095
|
-
action: 'work_item/accept',
|
|
1096
|
-
subjectKind: 'work_item',
|
|
1097
|
-
subjectId: current.id,
|
|
1098
|
-
before: { status: before.status },
|
|
1099
|
-
after: { status: 'active', assignee: principal.memberId, executionEpoch: current.execution_epoch },
|
|
1100
|
-
})
|
|
1101
|
-
return this.receipt(ctx, {
|
|
1102
|
-
status: 'applied',
|
|
1103
|
-
objectId: current.id,
|
|
1104
|
-
objectRevision: current.revision,
|
|
1105
|
-
executionEpoch: current.execution_epoch,
|
|
1106
|
-
auditId,
|
|
1107
|
-
})
|
|
1108
|
-
})
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
// ───────────────────────── T3 运行开始 / 结束 ─────────────────────────
|
|
1112
|
-
|
|
1113
|
-
rotateThread(ctx: CommandContext,input:{workItemId:string;sessionId:string;reason?:string}):CommandReceipt {
|
|
1114
|
-
return this.command(ctx,'work_item/rotateThread',input,()=>{
|
|
1115
|
-
const principal=this.principal(ctx);requireHuman(principal);requireCapability(principal,CAPABILITY.workItemManage)
|
|
1116
|
-
if(ctx.channel!=='ui')throw denied('手动换新会话只能由人通过UI执行')
|
|
1117
|
-
const item=this.workItemOrThrow(input.workItemId),at=this.now()
|
|
1118
|
-
if((TERMINAL_ITEM_STATUSES as readonly string[]).includes(item.status))throw stateInvalid('终态工作项不能换新会话')
|
|
1119
|
-
if(this.db.prepare("SELECT id FROM task_run WHERE work_item_id=? AND status='running'").get(item.id))throw stateInvalid('运行中不能换新会话,请先等本轮结束')
|
|
1120
|
-
if(!item.assignee_id)throw stateInvalid('工作项尚未分配bot')
|
|
1121
|
-
const bot=resolvePrincipal(this.db,item.assignee_id,at);requireBot(bot)
|
|
1122
|
-
if(typeof input.sessionId!=='string'||!input.sessionId.trim())throw stateInvalid('新宿主会话id不能为空')
|
|
1123
|
-
if(this.db.prepare('SELECT id FROM work_item_thread WHERE session_id=?').get(input.sessionId))throw stateInvalid('新会话id已绑定,不能复用旧会话')
|
|
1124
|
-
this.closeThreadTx(item.id,input.reason??'manual_rotation',at)
|
|
1125
|
-
const thread=this.ensureThreadTx(bot,item,input.sessionId)
|
|
1126
|
-
const rewired=this.db.prepare("UPDATE task_run SET thread_id=?,member_version=? WHERE work_item_id=? AND status='queued'").run(thread.id,thread.member_version,item.id)
|
|
1127
|
-
this.db.prepare('UPDATE work_item SET revision=revision+1,updated_at=? WHERE id=?').run(at,item.id)
|
|
1128
|
-
const after=this.workItemOrThrow(item.id),detail={threadId:thread.id,generation:thread.generation,memberVersion:thread.member_version,sessionId:thread.session_id,rewiredRuns:Number(rewired.changes)}
|
|
1129
|
-
writeEvent(this.db,this.id(),{workItemId:item.id,kind:'thread_rotated',actorId:principal.memberId,actorKind:'human',payload:{...detail,reason:input.reason??'manual_rotation'},revision:after.revision,executionEpoch:after.execution_epoch,at})
|
|
1130
|
-
writeChange(this.db,{objectKind:'work_item',objectId:item.id,revision:after.revision,changeKind:'thread_rotated',at})
|
|
1131
|
-
const auditId=this.audit(ctx,principal,{action:'work_item/rotateThread',subjectKind:'work_item',subjectId:item.id,after:detail})
|
|
1132
|
-
return this.receipt(ctx,{status:'applied',objectId:item.id,objectRevision:after.revision,executionEpoch:after.execution_epoch,auditId,detail})
|
|
1133
|
-
})
|
|
1134
|
-
}
|
|
1135
|
-
|
|
1136
|
-
startRun(ctx: CommandContext, input: { runId: string; sessionId?: string }): CommandReceipt {
|
|
1137
|
-
return this.command(ctx, 'run/start', input, () => {
|
|
1138
|
-
const principal = this.principal(ctx)
|
|
1139
|
-
requireBot(principal)
|
|
1140
|
-
requireCapability(principal, CAPABILITY.workItemExecute)
|
|
1141
|
-
const at = this.now()
|
|
1142
|
-
const run = this.runOrThrow(input.runId)
|
|
1143
|
-
if (run.member_id !== principal.memberId) throw denied('只能启动自己的运行')
|
|
1144
|
-
if (run.status !== 'queued') throw stateInvalid(`运行状态不是 queued:${run.status}`)
|
|
1145
|
-
const major = this.db.prepare("SELECT version FROM member_version WHERE member_id=? AND version>? AND change_class='major' ORDER BY version DESC LIMIT 1").get(principal.memberId,run.member_version)
|
|
1146
|
-
const boundThread = this.db.prepare('SELECT status FROM work_item_thread WHERE id=?').get(run.thread_id) as {status:string} | undefined
|
|
1147
|
-
if (major || boundThread?.status === 'pending_rotate') {
|
|
1148
|
-
const item = this.workItemOrThrow(run.work_item_id)
|
|
1149
|
-
const thread = this.ensureThreadTx(principal,item,input.sessionId)
|
|
1150
|
-
this.db.prepare('UPDATE task_run SET thread_id=?,member_version=? WHERE id=?').run(thread.id,thread.member_version,run.id)
|
|
1151
|
-
run.thread_id=thread.id;run.member_version=thread.member_version
|
|
1152
|
-
}
|
|
1153
|
-
const item = this.workItemOrThrow(run.work_item_id)
|
|
1154
|
-
if (item.execution_epoch !== run.execution_epoch) {
|
|
1155
|
-
throw new HamsError('EPOCH_STALE', '工作项执行代次已变,这次运行作废', { nextStep: '重新认领' })
|
|
1156
|
-
}
|
|
1157
|
-
const busy = this.db
|
|
1158
|
-
.prepare("SELECT COUNT(*) AS c FROM task_run WHERE member_id = ? AND status = 'running'")
|
|
1159
|
-
.get(principal.memberId) as { c: number }
|
|
1160
|
-
if (Number(busy.c) > 0) throw stateInvalid('该 bot 已有运行中的任务;同一 agent 同时只跑 1 个任务')
|
|
1161
|
-
|
|
1162
|
-
this.db
|
|
1163
|
-
.prepare("UPDATE task_run SET status = 'running', started_at = ? WHERE id = ? AND status = 'queued'")
|
|
1164
|
-
.run(at, run.id)
|
|
1165
|
-
this.db
|
|
1166
|
-
.prepare('UPDATE work_item_thread SET turn_count = turn_count + 1 WHERE id = ?')
|
|
1167
|
-
.run(run.thread_id)
|
|
1168
|
-
writeEvent(this.db, this.id(), {
|
|
1169
|
-
workItemId: item.id,
|
|
1170
|
-
kind: 'run_started',
|
|
1171
|
-
actorId: principal.memberId,
|
|
1172
|
-
actorKind: 'bot',
|
|
1173
|
-
payload: { runId: run.id, threadId: run.thread_id },
|
|
1174
|
-
revision: item.revision,
|
|
1175
|
-
executionEpoch: item.execution_epoch,
|
|
1176
|
-
at,
|
|
1177
|
-
})
|
|
1178
|
-
const auditId = this.audit(ctx, principal, {
|
|
1179
|
-
action: 'run/start',
|
|
1180
|
-
subjectKind: 'task_run',
|
|
1181
|
-
subjectId: run.id,
|
|
1182
|
-
before: { status: 'queued' },
|
|
1183
|
-
after: { status: 'running' },
|
|
1184
|
-
})
|
|
1185
|
-
return this.receipt(ctx, {
|
|
1186
|
-
status: 'applied',
|
|
1187
|
-
objectId: item.id,
|
|
1188
|
-
objectRevision: item.revision,
|
|
1189
|
-
executionEpoch: item.execution_epoch,
|
|
1190
|
-
auditId,
|
|
1191
|
-
detail: { runId: run.id },
|
|
1192
|
-
})
|
|
1193
|
-
})
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
submitResult(ctx: CommandContext, input: SubmitResultInput): CommandReceipt {
|
|
1197
|
-
return this.command(ctx, 'run/submitResult', input, () => {
|
|
1198
|
-
const principal = this.principal(ctx)
|
|
1199
|
-
requireBot(principal)
|
|
1200
|
-
requireCapability(principal, CAPABILITY.workItemExecute)
|
|
1201
|
-
const at = this.now()
|
|
1202
|
-
const run = this.runOrThrow(input.runId)
|
|
1203
|
-
if (run.member_id !== principal.memberId) throw denied('只能提交自己的运行结果')
|
|
1204
|
-
if (run.status !== 'running') throw stateInvalid(`运行状态不是 running:${run.status}`)
|
|
1205
|
-
const item = this.workItemOrThrow(run.work_item_id)
|
|
1206
|
-
if (item.execution_epoch !== run.execution_epoch) {
|
|
1207
|
-
throw new HamsError('EPOCH_STALE', '运行所属代次已过期', { nextStep: '丢弃这次结果,等新运行' })
|
|
1208
|
-
}
|
|
1209
|
-
|
|
1210
|
-
// 到期复查起的运行(check_id 非空)只结束这一轮,不改工作项状态:
|
|
1211
|
-
// 判定由 evaluateCheck 做,工作项此时停在 waiting_check 是正常的。
|
|
1212
|
-
if (item.status !== 'active') {
|
|
1213
|
-
if (item.status === 'waiting_check' && run.check_id) {
|
|
1214
|
-
this.db
|
|
1215
|
-
.prepare("UPDATE task_run SET status = 'succeeded', finished_at = ?, result_summary = ? WHERE id = ? AND status = 'running'")
|
|
1216
|
-
.run(at, input.summary, run.id)
|
|
1217
|
-
writeEvent(this.db, this.id(), {
|
|
1218
|
-
workItemId: item.id,
|
|
1219
|
-
kind: 'check_run_finished',
|
|
1220
|
-
actorId: principal.memberId,
|
|
1221
|
-
actorKind: 'bot',
|
|
1222
|
-
payload: { runId: run.id, checkId: run.check_id, summary: input.summary },
|
|
1223
|
-
revision: item.revision,
|
|
1224
|
-
executionEpoch: item.execution_epoch,
|
|
1225
|
-
at,
|
|
1226
|
-
})
|
|
1227
|
-
const auditId = this.audit(ctx, principal, {
|
|
1228
|
-
action: 'run/submitResult',
|
|
1229
|
-
subjectKind: 'task_run',
|
|
1230
|
-
subjectId: run.id,
|
|
1231
|
-
before: { status: 'running' },
|
|
1232
|
-
after: { status: 'succeeded', checkId: run.check_id },
|
|
1233
|
-
})
|
|
1234
|
-
return this.receipt(ctx, {
|
|
1235
|
-
status: 'applied',
|
|
1236
|
-
objectId: item.id,
|
|
1237
|
-
objectRevision: item.revision,
|
|
1238
|
-
executionEpoch: item.execution_epoch,
|
|
1239
|
-
auditId,
|
|
1240
|
-
detail: { runId: run.id, checkId: run.check_id, checkOnly: true },
|
|
1241
|
-
})
|
|
1242
|
-
}
|
|
1243
|
-
throw stateInvalid(`只有 active 的工作项可以提交结果,当前 ${item.status}`)
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
this.db
|
|
1247
|
-
.prepare("UPDATE task_run SET status = 'succeeded', finished_at = ?, result_summary = ? WHERE id = ? AND status = 'running'")
|
|
1248
|
-
.run(at, input.summary, run.id)
|
|
1249
|
-
|
|
1250
|
-
const template = this.templateVersionTx(item.template_version_id)
|
|
1251
|
-
const checkPolicy = parseCheckPolicy(template?.check_policy_json)
|
|
1252
|
-
const plannedDueAt = nextCheckAt(checkPolicy, at)
|
|
1253
|
-
const generation = item.check_generation + 1
|
|
1254
|
-
const checkId = this.id()
|
|
1255
|
-
this.db
|
|
1256
|
-
.prepare(
|
|
1257
|
-
`INSERT INTO work_item_check
|
|
1258
|
-
(id, work_item_id, generation, execution_epoch, template_version_id, planned_due_at, status,
|
|
1259
|
-
evidence_set_hash, result_json, created_at)
|
|
1260
|
-
VALUES (?, ?, ?, ?, ?, ?, 'scheduled', ?, '{}', ?)`,
|
|
1261
|
-
)
|
|
1262
|
-
.run(checkId, item.id, generation, item.execution_epoch, item.template_version_id, plannedDueAt, item.evidence_set_hash, at)
|
|
1263
|
-
this.db
|
|
1264
|
-
.prepare(
|
|
1265
|
-
`UPDATE work_item
|
|
1266
|
-
SET status = 'waiting_check', revision = revision + 1, check_generation = ?, next_action_at = ?, updated_at = ?
|
|
1267
|
-
WHERE id = ?`,
|
|
1268
|
-
)
|
|
1269
|
-
.run(generation, plannedDueAt, at, item.id)
|
|
1270
|
-
|
|
1271
|
-
const current = this.workItemOrThrow(item.id)
|
|
1272
|
-
writeEvent(this.db, this.id(), {
|
|
1273
|
-
workItemId: item.id,
|
|
1274
|
-
kind: 'result_submitted',
|
|
1275
|
-
actorId: principal.memberId,
|
|
1276
|
-
actorKind: 'bot',
|
|
1277
|
-
payload: { runId: run.id, summary: input.summary, checkId, plannedDueAt },
|
|
1278
|
-
revision: current.revision,
|
|
1279
|
-
executionEpoch: current.execution_epoch,
|
|
1280
|
-
at,
|
|
1281
|
-
})
|
|
1282
|
-
writeChange(this.db, { objectKind: 'work_item', objectId: item.id, revision: current.revision, changeKind: 'result_submitted', at })
|
|
1283
|
-
writeChange(this.db, { objectKind: 'work_item_check', objectId: checkId, revision: 1, changeKind: 'check_scheduled', at })
|
|
1284
|
-
const auditId = this.audit(ctx, principal, {
|
|
1285
|
-
action: 'run/submitResult',
|
|
1286
|
-
subjectKind: 'task_run',
|
|
1287
|
-
subjectId: run.id,
|
|
1288
|
-
before: { status: 'running' },
|
|
1289
|
-
after: { status: 'succeeded', checkId, plannedDueAt },
|
|
1290
|
-
})
|
|
1291
|
-
return this.receipt(ctx, {
|
|
1292
|
-
status: 'applied',
|
|
1293
|
-
objectId: item.id,
|
|
1294
|
-
objectRevision: current.revision,
|
|
1295
|
-
executionEpoch: current.execution_epoch,
|
|
1296
|
-
auditId,
|
|
1297
|
-
detail: { runId: run.id, checkId, plannedDueAt },
|
|
1298
|
-
})
|
|
1299
|
-
})
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
// ───────────────────────── T8 追加证据 ─────────────────────────
|
|
1303
|
-
|
|
1304
|
-
appendEvidence(ctx: CommandContext, input: AppendEvidenceInput): CommandReceipt {
|
|
1305
|
-
return this.command(ctx, 'work_item/addEvidence', input, () => {
|
|
1306
|
-
const principal = this.principal(ctx)
|
|
1307
|
-
requireCapability(principal, CAPABILITY.evidenceAppend)
|
|
1308
|
-
const at = this.now()
|
|
1309
|
-
const item = this.workItemOrThrow(input.workItemId)
|
|
1310
|
-
if (item.status === 'cancelled') throw stateInvalid('已取消的工作项不能再追加证据')
|
|
1311
|
-
|
|
1312
|
-
if (input.supersedesId) {
|
|
1313
|
-
const old = this.db.prepare('SELECT * FROM evidence WHERE id = ?').get(input.supersedesId) as EvidenceRow | undefined
|
|
1314
|
-
if (!old || old.work_item_id !== item.id) throw stateInvalid('被替代的证据不存在或不属于该工作项')
|
|
1315
|
-
if (old.status !== 'active') throw stateInvalid(`被替代的证据状态不是 active:${old.status}`)
|
|
1316
|
-
this.db.prepare("UPDATE evidence SET status = 'superseded' WHERE id = ?").run(old.id)
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
const evidenceId = this.id()
|
|
1320
|
-
const contentHash =
|
|
1321
|
-
input.contentHash ?? createHash('sha256').update(`${input.kind}\u0000${input.ref}`).digest('hex')
|
|
1322
|
-
this.db
|
|
1323
|
-
.prepare(
|
|
1324
|
-
`INSERT INTO evidence
|
|
1325
|
-
(id, work_item_id, run_id, kind, ref, content_hash, source, observed_at, captured_at, captured_by,
|
|
1326
|
-
on_behalf_of, supersedes_id, status)
|
|
1327
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active')`,
|
|
1328
|
-
)
|
|
1329
|
-
.run(
|
|
1330
|
-
evidenceId,
|
|
1331
|
-
item.id,
|
|
1332
|
-
input.runId ?? null,
|
|
1333
|
-
input.kind,
|
|
1334
|
-
input.ref,
|
|
1335
|
-
contentHash,
|
|
1336
|
-
input.source ?? '',
|
|
1337
|
-
input.observedAt,
|
|
1338
|
-
at,
|
|
1339
|
-
principal.memberId,
|
|
1340
|
-
input.onBehalfOf ?? null,
|
|
1341
|
-
input.supersedesId ?? null,
|
|
1342
|
-
)
|
|
1343
|
-
|
|
1344
|
-
const newHash = evidenceSetHash(this.evidenceSnapshots(item.id))
|
|
1345
|
-
this.db
|
|
1346
|
-
.prepare('UPDATE work_item SET evidence_set_hash = ?, revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
1347
|
-
.run(newHash, at, item.id)
|
|
1348
|
-
const current = this.workItemOrThrow(item.id)
|
|
1349
|
-
|
|
1350
|
-
writeEvent(this.db, this.id(), {
|
|
1351
|
-
workItemId: item.id,
|
|
1352
|
-
kind: 'evidence_appended',
|
|
1353
|
-
actorId: principal.memberId,
|
|
1354
|
-
actorKind: principal.kind,
|
|
1355
|
-
payload: { evidenceId, kind: input.kind, evidenceSetHash: newHash, supersedesId: input.supersedesId ?? null },
|
|
1356
|
-
revision: current.revision,
|
|
1357
|
-
executionEpoch: current.execution_epoch,
|
|
1358
|
-
at,
|
|
1359
|
-
})
|
|
1360
|
-
writeChange(this.db, { objectKind: 'work_item', objectId: item.id, revision: current.revision, changeKind: 'evidence_appended', at })
|
|
1361
|
-
const auditId = this.audit(ctx, principal, {
|
|
1362
|
-
action: 'work_item/addEvidence',
|
|
1363
|
-
subjectKind: 'work_item',
|
|
1364
|
-
subjectId: item.id,
|
|
1365
|
-
after: { evidenceId, evidenceSetHash: newHash },
|
|
1366
|
-
})
|
|
1367
|
-
return this.receipt(ctx, {
|
|
1368
|
-
status: 'applied',
|
|
1369
|
-
objectId: item.id,
|
|
1370
|
-
objectRevision: current.revision,
|
|
1371
|
-
executionEpoch: current.execution_epoch,
|
|
1372
|
-
auditId,
|
|
1373
|
-
detail: { evidenceId, evidenceSetHash: newHash },
|
|
1374
|
-
})
|
|
1375
|
-
})
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
// ───────────────────────── T6 审批请求 ─────────────────────────
|
|
1379
|
-
|
|
1380
|
-
requestClosure(ctx: CommandContext, input: RequestClosureInput): CommandReceipt {
|
|
1381
|
-
return this.command(ctx, 'work_item/requestClosure', input, () => {
|
|
1382
|
-
const principal = this.principal(ctx)
|
|
1383
|
-
requireCapability(principal, CAPABILITY.workItemRequestClosure)
|
|
1384
|
-
const at = this.now()
|
|
1385
|
-
const item = this.workItemOrThrow(input.workItemId)
|
|
1386
|
-
// waiting_review 也允许申请:审批过期 / 被撤回后「重新申请」不会改变工作项状态,
|
|
1387
|
-
// 只把修订号推进一格并换一张新的审批单(白名单里本来就没有 waiting_review→waiting_review 这条边)。
|
|
1388
|
-
if (!['active', 'waiting_check', 'blocked', 'waiting_review'].includes(item.status)) {
|
|
1389
|
-
throw stateInvalid(`当前状态不能申请关单:${item.status}`)
|
|
1390
|
-
}
|
|
1391
|
-
const template = this.templateVersionTx(item.template_version_id)
|
|
1392
|
-
if (!template) {
|
|
1393
|
-
throw evidenceIncomplete('工作项没有绑定模板版本,无法校验关单判据')
|
|
1394
|
-
}
|
|
1395
|
-
if (template.status !== 'published') throw stateInvalid(`判据版本未发布:${template.status}`)
|
|
1396
|
-
const requirements = parseEvidenceRequirements(template.required_evidence_json)
|
|
1397
|
-
const snapshot = buildCriteriaSnapshot(template.id, requirements, this.evidenceSnapshots(item.id), at)
|
|
1398
|
-
if (!snapshot.result.satisfied) {
|
|
1399
|
-
throw evidenceIncomplete('证据清单未满足,不能申请关单', snapshot.result.missing)
|
|
1400
|
-
}
|
|
1401
|
-
if (template.validator_key) {
|
|
1402
|
-
const validator = this.validators[template.validator_key]
|
|
1403
|
-
if (!validator) {
|
|
1404
|
-
throw new HamsError(
|
|
1405
|
-
'HOST_CAPABILITY_MISSING',
|
|
1406
|
-
`判据验证器未注册:${template.validator_key}`,
|
|
1407
|
-
{ nextStep: '注册该验证器后再申请关单,或降级为人工判定' },
|
|
1408
|
-
)
|
|
1409
|
-
}
|
|
1410
|
-
const result = validator({
|
|
1411
|
-
workItem: item,
|
|
1412
|
-
evidence: this.evidenceRows(item.id),
|
|
1413
|
-
params: this.parseJsonObject(template.validator_params_json),
|
|
1414
|
-
now: at,
|
|
1415
|
-
})
|
|
1416
|
-
if (!result.passed) {
|
|
1417
|
-
throw evidenceIncomplete(`判据验证器 ${template.validator_key} 未通过`, result.detail ?? null)
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
const pending = this.db
|
|
1422
|
-
.prepare(
|
|
1423
|
-
`SELECT id FROM approval WHERE subject_kind = 'work_item_close' AND subject_id = ? AND status = 'pending'`,
|
|
1424
|
-
)
|
|
1425
|
-
.get(item.id) as { id: string } | undefined
|
|
1426
|
-
if (pending) throw stateInvalid('该工作项已有一条待决审批', '等现有审批结束或失效后再申请')
|
|
1427
|
-
|
|
1428
|
-
if (item.status === 'waiting_review') {
|
|
1429
|
-
this.db
|
|
1430
|
-
.prepare('UPDATE work_item SET revision = revision + 1, updated_at = ? WHERE id = ?')
|
|
1431
|
-
.run(at, item.id)
|
|
1432
|
-
} else {
|
|
1433
|
-
this.db
|
|
1434
|
-
.prepare(
|
|
1435
|
-
`UPDATE work_item
|
|
1436
|
-
SET status = 'waiting_review', resume_state = NULL, blocked_reason = NULL,
|
|
1437
|
-
revision = revision + 1, updated_at = ?
|
|
1438
|
-
WHERE id = ?`,
|
|
1439
|
-
)
|
|
1440
|
-
.run(at, item.id)
|
|
1441
|
-
}
|
|
1442
|
-
const current = this.workItemOrThrow(item.id)
|
|
1443
|
-
|
|
1444
|
-
const reviewPolicy = parseReviewPolicy(template.review_policy_json)
|
|
1445
|
-
const approverId = this.resolveApproverTx(reviewPolicy, current, at)
|
|
1446
|
-
const approvalId = this.id()
|
|
1447
|
-
const expiresAt = at + reviewPolicy.expiresHours * 3_600_000
|
|
1448
|
-
this.db
|
|
1449
|
-
.prepare(
|
|
1450
|
-
`INSERT INTO approval
|
|
1451
|
-
(id, subject_kind, subject_id, subject_revision, execution_epoch, evidence_set_hash, criteria_ref,
|
|
1452
|
-
criteria_json, reason_text, requested_by, requested_channel, approver_id, status,
|
|
1453
|
-
decision_note, expires_at, created_at)
|
|
1454
|
-
VALUES (?, 'work_item_close', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', '', ?, ?)`,
|
|
1455
|
-
)
|
|
1456
|
-
.run(
|
|
1457
|
-
approvalId,
|
|
1458
|
-
item.id,
|
|
1459
|
-
current.revision,
|
|
1460
|
-
current.execution_epoch,
|
|
1461
|
-
current.evidence_set_hash,
|
|
1462
|
-
template.id,
|
|
1463
|
-
JSON.stringify(snapshot),
|
|
1464
|
-
input.reason,
|
|
1465
|
-
principal.memberId,
|
|
1466
|
-
this.requestChannel(ctx),
|
|
1467
|
-
approverId,
|
|
1468
|
-
expiresAt,
|
|
1469
|
-
at,
|
|
1470
|
-
)
|
|
1471
|
-
|
|
1472
|
-
writeEvent(this.db, this.id(), {
|
|
1473
|
-
workItemId: item.id,
|
|
1474
|
-
kind: 'closure_requested',
|
|
1475
|
-
actorId: principal.memberId,
|
|
1476
|
-
actorKind: principal.kind,
|
|
1477
|
-
payload: { approvalId, approverId, expiresAt, evidenceSetHash: current.evidence_set_hash },
|
|
1478
|
-
revision: current.revision,
|
|
1479
|
-
executionEpoch: current.execution_epoch,
|
|
1480
|
-
at,
|
|
1481
|
-
})
|
|
1482
|
-
writeChange(this.db, { objectKind: 'approval', objectId: approvalId, revision: 1, changeKind: 'approval_requested', at })
|
|
1483
|
-
const auditId = this.audit(ctx, principal, {
|
|
1484
|
-
action: 'work_item/requestClosure',
|
|
1485
|
-
subjectKind: 'work_item',
|
|
1486
|
-
subjectId: item.id,
|
|
1487
|
-
after: { approvalId, approverId, expiresAt },
|
|
1488
|
-
})
|
|
1489
|
-
return this.receipt(ctx, {
|
|
1490
|
-
status: 'applied',
|
|
1491
|
-
objectId: item.id,
|
|
1492
|
-
objectRevision: current.revision,
|
|
1493
|
-
executionEpoch: current.execution_epoch,
|
|
1494
|
-
approvalId,
|
|
1495
|
-
auditId,
|
|
1496
|
-
detail: { approvalId, approverId, expiresAt, criteria: snapshot },
|
|
1497
|
-
})
|
|
1498
|
-
})
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
// ───────────────────────── T7 / T5 审批决定与关单 ─────────────────────────
|
|
1502
|
-
|
|
1503
|
-
decideApproval(ctx: CommandContext, input: DecideApprovalInput): CommandReceipt {
|
|
1504
|
-
return this.command(ctx, 'approval/decide', input, () => {
|
|
1505
|
-
const principal = this.principal(ctx)
|
|
1506
|
-
requireHuman(principal)
|
|
1507
|
-
requireCapability(principal, CAPABILITY.approvalDecide)
|
|
1508
|
-
const at = this.now()
|
|
1509
|
-
const approval = this.approvalOrThrow(input.approvalId)
|
|
1510
|
-
if (approval.status !== 'pending') throw stateInvalid(`审批已是终态:${approval.status}`)
|
|
1511
|
-
if (approval.approver_id !== principal.memberId) {
|
|
1512
|
-
throw new HamsError('NOT_APPROVER', '只有申请时指定的审批人可以决定', { nextStep: '让指定审批人处理' })
|
|
1513
|
-
}
|
|
1514
|
-
if (at >= approval.expires_at) {
|
|
1515
|
-
throw new HamsError('APPROVAL_EXPIRED', '审批已过有效期', { nextStep: '重新申请' })
|
|
1516
|
-
}
|
|
1517
|
-
if (input.decision !== 'approve' && input.decision !== 'reject' && input.decision !== 'return') {
|
|
1518
|
-
throw stateInvalid(`未知审批决定:${String(input.decision)}`)
|
|
1519
|
-
}
|
|
1520
|
-
const note = input.note ?? ''
|
|
1521
|
-
if ((input.decision === 'reject' || input.decision === 'return') && note.trim().length === 0) {
|
|
1522
|
-
throw stateInvalid('驳回或退回必须写明原因')
|
|
1523
|
-
}
|
|
1524
|
-
|
|
1525
|
-
const isCloseSubject = approval.subject_kind === 'work_item_close'
|
|
1526
|
-
const isTargetCheck = approval.subject_kind === 'target_change'
|
|
1527
|
-
let targetCheck: { id: string; target_id: string; status: string; submitted_by: string; approver_id: string | null } | null = null
|
|
1528
|
-
if (isTargetCheck) {
|
|
1529
|
-
targetCheck = this.db.prepare('SELECT id,target_id,status,submitted_by,approver_id FROM project_phase_target_check WHERE id = ?').get(approval.subject_id) as
|
|
1530
|
-
| { id: string; target_id: string; status: string; submitted_by: string; approver_id: string | null }
|
|
1531
|
-
| undefined ?? null
|
|
1532
|
-
if (!targetCheck) throw stateInvalid('检验记录不存在或已删除')
|
|
1533
|
-
if (targetCheck.status !== 'pending') throw stateInvalid(`这条检验已处理:${targetCheck.status}`)
|
|
1534
|
-
}
|
|
1535
|
-
let item: WorkItemRow | null = null
|
|
1536
|
-
if (isCloseSubject) {
|
|
1537
|
-
item = this.workItemOrThrow(approval.subject_id)
|
|
1538
|
-
if (item.status !== 'waiting_review') throw stateInvalid(`工作项不在待验收状态:${item.status}`)
|
|
1539
|
-
if (item.evidence_set_hash !== approval.evidence_set_hash) {
|
|
1540
|
-
throw new HamsError('EVIDENCE_CHANGED', '证据材料已变,这次审批的对象不再是最新版本', {
|
|
1541
|
-
nextStep: '按新材料重新申请',
|
|
1542
|
-
})
|
|
1543
|
-
}
|
|
1544
|
-
if (item.revision !== approval.subject_revision) {
|
|
1545
|
-
throw new HamsError('VERSION_CONFLICT', '对象修订号已变,审批作废', { nextStep: '刷新后重新申请' })
|
|
1546
|
-
}
|
|
1547
|
-
if (item.execution_epoch !== approval.execution_epoch) {
|
|
1548
|
-
throw new HamsError('EPOCH_STALE', '执行代次已变,这次审批不适用', { nextStep: '在新代次上重新处置' })
|
|
1549
|
-
}
|
|
1550
|
-
}
|
|
1551
|
-
|
|
1552
|
-
const status = input.decision === 'approve' ? 'approved' : input.decision === 'reject' ? 'rejected' : 'returned'
|
|
1553
|
-
this.db
|
|
1554
|
-
.prepare(
|
|
1555
|
-
`UPDATE approval
|
|
1556
|
-
SET status = ?, decided_by = ?, decided_channel = 'ui', decision_note = ?, decided_at = ?
|
|
1557
|
-
WHERE id = ? AND status = 'pending'`,
|
|
1558
|
-
)
|
|
1559
|
-
.run(status, principal.memberId, note, at, approval.id)
|
|
1560
|
-
|
|
1561
|
-
if (isTargetCheck && targetCheck) {
|
|
1562
|
-
const nextStatus = input.decision === 'approve' ? 'approved' : input.decision === 'reject' ? 'rejected' : 'pending'
|
|
1563
|
-
this.db
|
|
1564
|
-
.prepare('UPDATE project_phase_target_check SET status = ?, result = ?, decided_by = ?, decided_at = ?, decision_note = ? WHERE id = ?')
|
|
1565
|
-
.run(nextStatus, input.decision === 'approve' ? 'met' : input.decision === 'reject' ? 'missed' : 'unknown', principal.memberId, at, note, targetCheck.id)
|
|
1566
|
-
}
|
|
1567
|
-
let itemAfter: WorkItemRow | null = null
|
|
1568
|
-
if (isCloseSubject && item) {
|
|
1569
|
-
if (input.decision === 'approve') {
|
|
1570
|
-
this.db
|
|
1571
|
-
.prepare(
|
|
1572
|
-
`UPDATE work_item
|
|
1573
|
-
SET status = 'completed', revision = revision + 1, updated_at = ?, completed_at = ?
|
|
1574
|
-
WHERE id = ? AND status = 'waiting_review'`,
|
|
1575
|
-
)
|
|
1576
|
-
.run(at, at, item.id)
|
|
1577
|
-
this.closeThreadTx(item.id, 'completed', at)
|
|
1578
|
-
this.db.prepare('DELETE FROM lease WHERE work_item_id = ?').run(item.id)
|
|
1579
|
-
itemAfter = this.workItemOrThrow(item.id)
|
|
1580
|
-
writeEvent(this.db, this.id(), {
|
|
1581
|
-
workItemId: item.id,
|
|
1582
|
-
kind: 'work_item_closed',
|
|
1583
|
-
actorId: principal.memberId,
|
|
1584
|
-
actorKind: 'human',
|
|
1585
|
-
payload: { approvalId: approval.id, evidenceSetHash: item.evidence_set_hash },
|
|
1586
|
-
revision: itemAfter.revision,
|
|
1587
|
-
executionEpoch: itemAfter.execution_epoch,
|
|
1588
|
-
at,
|
|
1589
|
-
})
|
|
1590
|
-
writeChange(this.db, { objectKind: 'work_item', objectId: item.id, revision: itemAfter.revision, changeKind: 'closed', at })
|
|
1591
|
-
} else if (input.decision === 'reject') {
|
|
1592
|
-
this.db
|
|
1593
|
-
.prepare("UPDATE work_item SET status = 'active', revision = revision + 1, updated_at = ? WHERE id = ?")
|
|
1594
|
-
.run(at, item.id)
|
|
1595
|
-
itemAfter = this.workItemOrThrow(item.id)
|
|
1596
|
-
writeEvent(this.db, this.id(), {
|
|
1597
|
-
workItemId: item.id,
|
|
1598
|
-
kind: 'closure_rejected',
|
|
1599
|
-
actorId: principal.memberId,
|
|
1600
|
-
actorKind: 'human',
|
|
1601
|
-
payload: { approvalId: approval.id, note },
|
|
1602
|
-
revision: itemAfter.revision,
|
|
1603
|
-
executionEpoch: itemAfter.execution_epoch,
|
|
1604
|
-
at,
|
|
1605
|
-
})
|
|
1606
|
-
writeChange(this.db, { objectKind: 'work_item', objectId: item.id, revision: itemAfter.revision, changeKind: 'closure_rejected', at })
|
|
1607
|
-
} else {
|
|
1608
|
-
this.db
|
|
1609
|
-
.prepare(
|
|
1610
|
-
`UPDATE work_item
|
|
1611
|
-
SET status = 'blocked', blocked_reason = 'needs_evidence', revision = revision + 1, updated_at = ?
|
|
1612
|
-
WHERE id = ?`,
|
|
1613
|
-
)
|
|
1614
|
-
.run(at, item.id)
|
|
1615
|
-
itemAfter = this.workItemOrThrow(item.id)
|
|
1616
|
-
writeEvent(this.db, this.id(), {
|
|
1617
|
-
workItemId: item.id,
|
|
1618
|
-
kind: 'closure_returned',
|
|
1619
|
-
actorId: principal.memberId,
|
|
1620
|
-
actorKind: 'human',
|
|
1621
|
-
payload: { approvalId: approval.id, note },
|
|
1622
|
-
revision: itemAfter.revision,
|
|
1623
|
-
executionEpoch: itemAfter.execution_epoch,
|
|
1624
|
-
at,
|
|
1625
|
-
})
|
|
1626
|
-
writeChange(this.db, { objectKind: 'work_item', objectId: item.id, revision: itemAfter.revision, changeKind: 'closure_returned', at })
|
|
1627
|
-
}
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
writeChange(this.db, { objectKind: 'approval', objectId: approval.id, revision: 1, changeKind: `approval_${status}`, at })
|
|
1631
|
-
const auditId = this.audit(ctx, principal, {
|
|
1632
|
-
action: 'approval/decide',
|
|
1633
|
-
subjectKind: 'approval',
|
|
1634
|
-
subjectId: approval.id,
|
|
1635
|
-
before: { status: 'pending' },
|
|
1636
|
-
after: { status, note, workItemStatus: itemAfter?.status ?? null },
|
|
1637
|
-
})
|
|
1638
|
-
return this.receipt(ctx, {
|
|
1639
|
-
status: 'applied',
|
|
1640
|
-
objectId: itemAfter?.id ?? approval.subject_id,
|
|
1641
|
-
objectRevision: itemAfter?.revision,
|
|
1642
|
-
executionEpoch: itemAfter?.execution_epoch,
|
|
1643
|
-
approvalId: approval.id,
|
|
1644
|
-
auditId,
|
|
1645
|
-
detail: { approvalStatus: status, workItemStatus: itemAfter?.status ?? null },
|
|
1646
|
-
})
|
|
1647
|
-
})
|
|
1648
|
-
}
|
|
1649
|
-
|
|
1650
|
-
// ───────────────────────── 复查判定 ─────────────────────────
|
|
1651
|
-
|
|
1652
|
-
evaluateCheck(ctx: CommandContext, input: EvaluateCheckInput): CommandReceipt {
|
|
1653
|
-
return this.command(ctx, 'check/evaluate', input, () => {
|
|
1654
|
-
const principal = this.principal(ctx)
|
|
1655
|
-
requireCapability(principal, CAPABILITY.checkEvaluate)
|
|
1656
|
-
const at = this.now()
|
|
1657
|
-
const check = this.checkOrThrow(input.checkId)
|
|
1658
|
-
if (!['scheduled', 'running'].includes(check.status)) {
|
|
1659
|
-
throw stateInvalid(`复查已是终态:${check.status}`)
|
|
1660
|
-
}
|
|
1661
|
-
const item = this.workItemOrThrow(check.work_item_id)
|
|
1662
|
-
if (item.execution_epoch !== check.execution_epoch) {
|
|
1663
|
-
throw new HamsError('EPOCH_STALE', '复查所属代次已过期', { nextStep: '按当前代次重新排复查' })
|
|
1664
|
-
}
|
|
1665
|
-
const template = this.templateVersionTx(check.template_version_id ?? item.template_version_id)
|
|
1666
|
-
const requirements = parseEvidenceRequirements(template?.required_evidence_json)
|
|
1667
|
-
const rows = this.evidenceRows(item.id)
|
|
1668
|
-
const snapshot = buildCriteriaSnapshot(
|
|
1669
|
-
template?.id ?? item.template_version_id,
|
|
1670
|
-
requirements,
|
|
1671
|
-
this.evidenceSnapshots(item.id),
|
|
1672
|
-
at,
|
|
1673
|
-
)
|
|
1674
|
-
let validatorResult: unknown = null
|
|
1675
|
-
let validatorPassed = true
|
|
1676
|
-
if (template?.validator_key) {
|
|
1677
|
-
const validator = this.validators[template.validator_key]
|
|
1678
|
-
if (!validator) {
|
|
1679
|
-
throw new HamsError('HOST_CAPABILITY_MISSING', `判据验证器未注册:${template.validator_key}`)
|
|
1680
|
-
}
|
|
1681
|
-
const result = validator({
|
|
1682
|
-
workItem: item,
|
|
1683
|
-
evidence: rows,
|
|
1684
|
-
params: this.parseJsonObject(template.validator_params_json),
|
|
1685
|
-
now: at,
|
|
1686
|
-
})
|
|
1687
|
-
validatorPassed = result.passed
|
|
1688
|
-
validatorResult = result.detail ?? null
|
|
1689
|
-
}
|
|
1690
|
-
|
|
1691
|
-
const freshHash = evidenceSetHash(this.evidenceSnapshots(item.id))
|
|
1692
|
-
let verdict: 'passed' | 'failed' | 'unknown'
|
|
1693
|
-
if (!snapshot.result.satisfied) {
|
|
1694
|
-
const allKindsEmpty = snapshot.result.missing.every((missing) => missing.have === 0)
|
|
1695
|
-
verdict = allKindsEmpty ? 'unknown' : 'failed'
|
|
1696
|
-
} else {
|
|
1697
|
-
verdict = validatorPassed ? 'passed' : 'failed'
|
|
1698
|
-
}
|
|
1699
|
-
|
|
1700
|
-
this.db
|
|
1701
|
-
.prepare(
|
|
1702
|
-
`UPDATE work_item_check
|
|
1703
|
-
SET status = ?, evidence_set_hash = ?, result_json = ?, evaluated_at = ?
|
|
1704
|
-
WHERE id = ?`,
|
|
1705
|
-
)
|
|
1706
|
-
.run(
|
|
1707
|
-
verdict,
|
|
1708
|
-
freshHash,
|
|
1709
|
-
JSON.stringify({ criteria: snapshot, validator: validatorResult, verdict }),
|
|
1710
|
-
at,
|
|
1711
|
-
check.id,
|
|
1712
|
-
)
|
|
1713
|
-
|
|
1714
|
-
let itemAfter = item
|
|
1715
|
-
let autoClosed = false
|
|
1716
|
-
if (verdict === 'passed' && item.status === 'waiting_check' && item.close_requires_approval === 0) {
|
|
1717
|
-
// 状态白名单里没有 waiting_check → completed 这条边(自动关单只从 waiting_review 出发),
|
|
1718
|
-
// 所以在同一事务里先过 waiting_review 再关单;两步都写在白名单内,外部看不到中间态。
|
|
1719
|
-
this.db
|
|
1720
|
-
.prepare(
|
|
1721
|
-
"UPDATE work_item SET status = 'waiting_review', revision = revision + 1, updated_at = ? WHERE id = ? AND status = 'waiting_check'",
|
|
1722
|
-
)
|
|
1723
|
-
.run(at, item.id)
|
|
1724
|
-
this.db
|
|
1725
|
-
.prepare(
|
|
1726
|
-
`UPDATE work_item
|
|
1727
|
-
SET status = 'completed', revision = revision + 1, updated_at = ?, completed_at = ?
|
|
1728
|
-
WHERE id = ?`,
|
|
1729
|
-
)
|
|
1730
|
-
.run(at, at, item.id)
|
|
1731
|
-
this.closeThreadTx(item.id, 'completed', at)
|
|
1732
|
-
this.db.prepare('DELETE FROM lease WHERE work_item_id = ?').run(item.id)
|
|
1733
|
-
itemAfter = this.workItemOrThrow(item.id)
|
|
1734
|
-
autoClosed = true
|
|
1735
|
-
writeEvent(this.db, this.id(), {
|
|
1736
|
-
workItemId: item.id,
|
|
1737
|
-
kind: 'work_item_auto_closed',
|
|
1738
|
-
actorId: 'system',
|
|
1739
|
-
actorKind: 'system',
|
|
1740
|
-
payload: { checkId: check.id, evidenceSetHash: freshHash },
|
|
1741
|
-
revision: itemAfter.revision,
|
|
1742
|
-
executionEpoch: itemAfter.execution_epoch,
|
|
1743
|
-
at,
|
|
1744
|
-
})
|
|
1745
|
-
writeChange(this.db, { objectKind: 'work_item', objectId: item.id, revision: itemAfter.revision, changeKind: 'auto_closed', at })
|
|
1746
|
-
} else if (verdict === 'failed' && item.status === 'waiting_check') {
|
|
1747
|
-
this.db
|
|
1748
|
-
.prepare("UPDATE work_item SET status = 'active', revision = revision + 1, next_action_at = NULL, updated_at = ? WHERE id = ?")
|
|
1749
|
-
.run(at, item.id)
|
|
1750
|
-
itemAfter = this.workItemOrThrow(item.id)
|
|
1751
|
-
writeEvent(this.db, this.id(), {
|
|
1752
|
-
workItemId: item.id,
|
|
1753
|
-
kind: 'check_failed',
|
|
1754
|
-
actorId: principal.memberId,
|
|
1755
|
-
actorKind: principal.kind,
|
|
1756
|
-
payload: { checkId: check.id },
|
|
1757
|
-
revision: itemAfter.revision,
|
|
1758
|
-
executionEpoch: itemAfter.execution_epoch,
|
|
1759
|
-
at,
|
|
1760
|
-
})
|
|
1761
|
-
writeChange(this.db, { objectKind: 'work_item', objectId: item.id, revision: itemAfter.revision, changeKind: 'check_failed', at })
|
|
1762
|
-
} else if (verdict === 'unknown' && item.status === 'waiting_check') {
|
|
1763
|
-
this.db
|
|
1764
|
-
.prepare(
|
|
1765
|
-
`UPDATE work_item
|
|
1766
|
-
SET status = 'blocked', blocked_reason = 'data_source', revision = revision + 1, updated_at = ?
|
|
1767
|
-
WHERE id = ?`,
|
|
1768
|
-
)
|
|
1769
|
-
.run(at, item.id)
|
|
1770
|
-
itemAfter = this.workItemOrThrow(item.id)
|
|
1771
|
-
writeEvent(this.db, this.id(), {
|
|
1772
|
-
workItemId: item.id,
|
|
1773
|
-
kind: 'check_unknown',
|
|
1774
|
-
actorId: principal.memberId,
|
|
1775
|
-
actorKind: principal.kind,
|
|
1776
|
-
payload: { checkId: check.id },
|
|
1777
|
-
revision: itemAfter.revision,
|
|
1778
|
-
executionEpoch: itemAfter.execution_epoch,
|
|
1779
|
-
at,
|
|
1780
|
-
})
|
|
1781
|
-
writeChange(this.db, { objectKind: 'work_item', objectId: item.id, revision: itemAfter.revision, changeKind: 'check_unknown', at })
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
writeChange(this.db, { objectKind: 'work_item_check', objectId: check.id, revision: 1, changeKind: `check_${verdict}`, at })
|
|
1785
|
-
const auditId = this.audit(ctx, principal, {
|
|
1786
|
-
action: 'check/evaluate',
|
|
1787
|
-
subjectKind: 'work_item_check',
|
|
1788
|
-
subjectId: check.id,
|
|
1789
|
-
before: { status: check.status },
|
|
1790
|
-
after: { status: verdict, evidenceSetHash: freshHash, autoClosed },
|
|
1791
|
-
})
|
|
1792
|
-
return this.receipt(ctx, {
|
|
1793
|
-
status: 'applied',
|
|
1794
|
-
objectId: itemAfter.id,
|
|
1795
|
-
objectRevision: itemAfter.revision,
|
|
1796
|
-
executionEpoch: itemAfter.execution_epoch,
|
|
1797
|
-
auditId,
|
|
1798
|
-
detail: { checkId: check.id, verdict, autoClosed, missing: snapshot.result.missing },
|
|
1799
|
-
})
|
|
1800
|
-
})
|
|
1801
|
-
}
|
|
1802
|
-
|
|
1803
|
-
// ───────────────────────── T9 生命周期命令 ─────────────────────────
|
|
1804
|
-
|
|
1805
|
-
pause(ctx: CommandContext, input: { workItemId: string; reason?: string }): CommandReceipt {
|
|
1806
|
-
return this.transitionCommand(ctx, 'work_item/pause', input, 'pause')
|
|
1807
|
-
}
|
|
1808
|
-
|
|
1809
|
-
resume(ctx: CommandContext, input: { workItemId: string; reason?: string }): CommandReceipt {
|
|
1810
|
-
return this.transitionCommand(ctx, 'work_item/resume', input, 'resume')
|
|
1811
|
-
}
|
|
1812
|
-
|
|
1813
|
-
cancel(ctx: CommandContext, input: { workItemId: string; reason?: string }): CommandReceipt {
|
|
1814
|
-
return this.transitionCommand(ctx, 'work_item/cancel', input, 'cancel')
|
|
1815
|
-
}
|
|
1816
|
-
|
|
1817
|
-
reopen(ctx: CommandContext, input: { workItemId: string; reason?: string }): CommandReceipt {
|
|
1818
|
-
return this.transitionCommand(ctx, 'work_item/reopen', input, 'reopen')
|
|
1819
|
-
}
|
|
1820
|
-
|
|
1821
|
-
retry(ctx: CommandContext, input: { workItemId: string; reason?: string }): CommandReceipt {
|
|
1822
|
-
return this.transitionCommand(ctx, 'work_item/retry', input, 'retry')
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
reportBlocker(
|
|
1826
|
-
ctx: CommandContext,
|
|
1827
|
-
input: { workItemId: string; category: string; note: string },
|
|
1828
|
-
): CommandReceipt {
|
|
1829
|
-
return this.command(ctx, 'work_item/reportBlocker', input, () => {
|
|
1830
|
-
const principal = this.principal(ctx)
|
|
1831
|
-
if (principal.kind === 'bot') requireCapability(principal, CAPABILITY.workItemExecute)
|
|
1832
|
-
else requireCapability(principal, CAPABILITY.workItemManage)
|
|
1833
|
-
const at = this.now()
|
|
1834
|
-
const item = this.workItemOrThrow(input.workItemId)
|
|
1835
|
-
if (!['ready', 'active'].includes(item.status)) throw stateInvalid(`当前状态不能报阻塞:${item.status}`)
|
|
1836
|
-
const allowed = ['needs_evidence', 'external', 'dependency', 'permission', 'human_input', 'data_source']
|
|
1837
|
-
if (!allowed.includes(input.category)) throw stateInvalid(`未知阻塞原因:${input.category}`)
|
|
1838
|
-
this.db
|
|
1839
|
-
.prepare(
|
|
1840
|
-
`UPDATE work_item
|
|
1841
|
-
SET status = 'blocked', blocked_reason = ?, revision = revision + 1, updated_at = ?
|
|
1842
|
-
WHERE id = ?`,
|
|
1843
|
-
)
|
|
1844
|
-
.run(input.category, at, item.id)
|
|
1845
|
-
// 报阻塞就结束本轮:否则 run 永远停在 running,界面一直显示「运行中」,也占着 uq_wi_active_run。
|
|
1846
|
-
const running = this.db
|
|
1847
|
-
.prepare("SELECT id FROM task_run WHERE work_item_id = ? AND status = 'running' ORDER BY created_at DESC LIMIT 1")
|
|
1848
|
-
.get(item.id) as { id: string } | undefined
|
|
1849
|
-
if (running) {
|
|
1850
|
-
this.db
|
|
1851
|
-
.prepare("UPDATE task_run SET status = 'failed', finished_at = ?, error_kind = ? WHERE id = ? AND status = 'running'")
|
|
1852
|
-
.run(at, `blocked:${input.category}`, running.id)
|
|
1853
|
-
this.db.prepare('DELETE FROM lease WHERE run_id = ?').run(running.id)
|
|
1854
|
-
}
|
|
1855
|
-
const after = this.workItemOrThrow(item.id)
|
|
1856
|
-
writeEvent(this.db, this.id(), {
|
|
1857
|
-
workItemId: item.id,
|
|
1858
|
-
kind: 'blocker_reported',
|
|
1859
|
-
actorId: principal.memberId,
|
|
1860
|
-
actorKind: principal.kind,
|
|
1861
|
-
payload: { category: input.category, note: input.note },
|
|
1862
|
-
revision: after.revision,
|
|
1863
|
-
executionEpoch: after.execution_epoch,
|
|
1864
|
-
at,
|
|
1865
|
-
})
|
|
1866
|
-
writeChange(this.db, { objectKind: 'work_item', objectId: item.id, revision: after.revision, changeKind: 'blocked', at })
|
|
1867
|
-
const auditId = this.audit(ctx, principal, {
|
|
1868
|
-
action: 'work_item/reportBlocker',
|
|
1869
|
-
subjectKind: 'work_item',
|
|
1870
|
-
subjectId: item.id,
|
|
1871
|
-
after: { blockedReason: input.category, note: input.note },
|
|
1872
|
-
})
|
|
1873
|
-
return this.receipt(ctx, {
|
|
1874
|
-
status: 'applied',
|
|
1875
|
-
objectId: item.id,
|
|
1876
|
-
objectRevision: after.revision,
|
|
1877
|
-
executionEpoch: after.execution_epoch,
|
|
1878
|
-
auditId,
|
|
1879
|
-
})
|
|
1880
|
-
})
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
unblock(ctx: CommandContext, input: { workItemId: string; to?: 'ready' | 'active' }): CommandReceipt {
|
|
1884
|
-
return this.command(ctx, 'work_item/unblock', input, () => {
|
|
1885
|
-
const principal = this.principal(ctx)
|
|
1886
|
-
requireHuman(principal)
|
|
1887
|
-
requireCapability(principal, CAPABILITY.workItemManage)
|
|
1888
|
-
const at = this.now()
|
|
1889
|
-
const item = this.workItemOrThrow(input.workItemId)
|
|
1890
|
-
if (item.status !== 'blocked') throw stateInvalid(`只有 blocked 的工作项可以解除阻塞,当前 ${item.status}`)
|
|
1891
|
-
const to = input.to ?? 'ready'
|
|
1892
|
-
this.assertTransition(item.status, to)
|
|
1893
|
-
this.db
|
|
1894
|
-
.prepare(
|
|
1895
|
-
`UPDATE work_item
|
|
1896
|
-
SET status = ?, blocked_reason = NULL, revision = revision + 1, updated_at = ?
|
|
1897
|
-
WHERE id = ?`,
|
|
1898
|
-
)
|
|
1899
|
-
.run(to, at, item.id)
|
|
1900
|
-
const after = this.workItemOrThrow(item.id)
|
|
1901
|
-
writeEvent(this.db, this.id(), {
|
|
1902
|
-
workItemId: item.id,
|
|
1903
|
-
kind: 'unblocked',
|
|
1904
|
-
actorId: principal.memberId,
|
|
1905
|
-
actorKind: 'human',
|
|
1906
|
-
payload: { to },
|
|
1907
|
-
revision: after.revision,
|
|
1908
|
-
executionEpoch: after.execution_epoch,
|
|
1909
|
-
at,
|
|
1910
|
-
})
|
|
1911
|
-
writeChange(this.db, { objectKind: 'work_item', objectId: item.id, revision: after.revision, changeKind: 'unblocked', at })
|
|
1912
|
-
const auditId = this.audit(ctx, principal, {
|
|
1913
|
-
action: 'work_item/unblock',
|
|
1914
|
-
subjectKind: 'work_item',
|
|
1915
|
-
subjectId: item.id,
|
|
1916
|
-
after: { status: to },
|
|
1917
|
-
})
|
|
1918
|
-
return this.receipt(ctx, {
|
|
1919
|
-
status: 'applied',
|
|
1920
|
-
objectId: item.id,
|
|
1921
|
-
objectRevision: after.revision,
|
|
1922
|
-
executionEpoch: after.execution_epoch,
|
|
1923
|
-
auditId,
|
|
1924
|
-
})
|
|
1925
|
-
})
|
|
1926
|
-
}
|
|
1927
|
-
|
|
1928
|
-
// ───────────────────────── 项目(M6) ─────────────────────────
|
|
1929
|
-
|
|
1930
|
-
/**
|
|
1931
|
-
* 项目列表。数据来自 `project` 与它关联的工作项/指标,不是样例;
|
|
1932
|
-
* 空库就返回空数组,界面负责显示「建第一个项目」的引导。
|
|
1933
|
-
*/
|
|
1934
|
-
listProjects(
|
|
1935
|
-
ctx: CommandContext,
|
|
1936
|
-
filter: { status?: string; type?: string; limit?: number } = {},
|
|
1937
|
-
) {
|
|
1938
|
-
const principal=resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
1939
|
-
return queryProjects(this.db, filter).map(project=>{
|
|
1940
|
-
const count=(sql:string)=>Number((this.db.prepare(sql).get(project.id,principal.memberId) as {n:number}).n)
|
|
1941
|
-
const pending_my_count=count("SELECT COUNT(*) AS n FROM proposal WHERE project_id=? AND decider_id=? AND status='open'")
|
|
1942
|
-
+count("SELECT COUNT(*) AS n FROM project_review WHERE project_id=? AND confirmer_id=? AND status='awaiting_confirm'")
|
|
1943
|
-
+count("SELECT COUNT(*) AS n FROM project_phase WHERE project_id=? AND confirmer_id=? AND status='awaiting_confirm'")
|
|
1944
|
-
+count("SELECT COUNT(*) AS n FROM approval a JOIN work_item w ON w.id=a.subject_id WHERE w.project_id=? AND a.approver_id=? AND a.status='pending' AND a.subject_kind='work_item_close'")
|
|
1945
|
-
return {...project,pending_my_count,needs_my_action:pending_my_count>0}
|
|
1946
|
-
})
|
|
1947
|
-
}
|
|
1948
|
-
|
|
1949
|
-
getProjectDetail(ctx: CommandContext, projectId: string) {
|
|
1950
|
-
const principal = resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
1951
|
-
const detail = queryProject(this.db, projectId)
|
|
1952
|
-
const extra = lifecycle.projectLifecycleDetail(this.db, projectId)
|
|
1953
|
-
const actions = this.projectActions(principal, detail)
|
|
1954
|
-
if (principal.kind === 'human' && detail.status !== 'archived') {
|
|
1955
|
-
if (principal.capabilities.has('target.publish')) actions.push('publishTarget')
|
|
1956
|
-
if (principal.capabilities.has(CAPABILITY.projectManage)) {
|
|
1957
|
-
actions.push('createPhase','updatePhase','createReview')
|
|
1958
|
-
if (extra.phases.some(p => p.status === 'awaiting_confirm' && p.confirmer_id === principal.memberId)) actions.push('confirmPhase')
|
|
1959
|
-
}
|
|
1960
|
-
if (principal.capabilities.has('review.confirm') && extra.reviews.some(r => r.status === 'awaiting_confirm' && r.confirmer_id === principal.memberId)) actions.push('confirmReview')
|
|
1961
|
-
}
|
|
1962
|
-
const phases = extra.phases.map(p => ({...p,allowedActions: [
|
|
1963
|
-
...(actions.includes('updatePhase') && p.status !== 'done' ? ['updatePhase'] : []),
|
|
1964
|
-
...(actions.includes('confirmPhase') && p.status === 'awaiting_confirm' && p.confirmer_id === principal.memberId ? ['confirmPhase'] : []),
|
|
1965
|
-
]}))
|
|
1966
|
-
const reviews = extra.reviews.map(r => ({...r,allowedActions: actions.includes('confirmReview') && r.status === 'awaiting_confirm' && r.confirmer_id === principal.memberId ? ['confirmReview'] : []}))
|
|
1967
|
-
return { ...detail, ...extra, phases, reviews, workItems: this.db.prepare('SELECT * FROM work_item WHERE project_id=? ORDER BY updated_at DESC,id').all(projectId), target_versions: detail.target_versions.map(v => ({...v, metrics: this.db.prepare('SELECT * FROM project_metric WHERE version_id=? ORDER BY name').all(v.id)})), allowedActions: actions }
|
|
1968
|
-
}
|
|
1969
|
-
|
|
1970
|
-
createProject(ctx: CommandContext, input: CreateProjectInput): CommandReceipt {
|
|
1971
|
-
return this.command(ctx, 'project/create', input, () => {
|
|
1972
|
-
const principal = this.principal(ctx)
|
|
1973
|
-
requireHuman(principal)
|
|
1974
|
-
requireCapability(principal, CAPABILITY.projectCreate)
|
|
1975
|
-
const at = this.now()
|
|
1976
|
-
const created = insertProjectRow(this.db, this.id, at, principal.memberId, input)
|
|
1977
|
-
const detail = queryProject(this.db, created.id)
|
|
1978
|
-
writeChange(this.db, { objectKind: 'project', objectId: created.id, revision: 1, changeKind: 'created', at })
|
|
1979
|
-
const auditId = this.audit(ctx, principal, {
|
|
1980
|
-
action: 'project/create',
|
|
1981
|
-
subjectKind: 'project',
|
|
1982
|
-
subjectId: created.id,
|
|
1983
|
-
after: { name: detail.name, type: detail.type, status: detail.status, goal: detail.goal_text },
|
|
1984
|
-
})
|
|
1985
|
-
// 默认阶段与目标:发布的项目一建立就有可推进的骨架;草稿等发布时再补。
|
|
1986
|
-
if (detail.status !== 'draft') {
|
|
1987
|
-
lifecycle.ensureDefaultPlan(this.db, this.id, at, created.id)
|
|
1988
|
-
ensureDefaultAnalystInProject(this.db, created.id, detail.coordinator_id, principal.memberId, at)
|
|
1989
|
-
}
|
|
1990
|
-
return this.receipt(ctx, {
|
|
1991
|
-
status: 'applied',
|
|
1992
|
-
objectId: created.id,
|
|
1993
|
-
objectRevision: created.revision,
|
|
1994
|
-
auditId,
|
|
1995
|
-
detail: {
|
|
1996
|
-
targetVersionId: created.targetVersionId,
|
|
1997
|
-
metrics: detail.metrics.length,
|
|
1998
|
-
coordinatorId: detail.coordinator_id,
|
|
1999
|
-
status: detail.status,
|
|
2000
|
-
},
|
|
2001
|
-
})
|
|
2002
|
-
})
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
|
-
/**
|
|
2006
|
-
* 新建向导保存草稿:可只填名称,允许目标/指标为空。
|
|
2007
|
-
* 新建走 project.create 权限;续存自己的草稿也允许,改别人的草稿要 project.manage。
|
|
2008
|
-
*/
|
|
2009
|
-
saveProjectDraft(ctx: CommandContext, input: SaveDraftInput): CommandReceipt {
|
|
2010
|
-
return this.command(ctx, 'project/saveDraft', input, () => {
|
|
2011
|
-
const principal = this.principal(ctx)
|
|
2012
|
-
requireHuman(principal)
|
|
2013
|
-
const at = this.now()
|
|
2014
|
-
if (input.projectId) {
|
|
2015
|
-
const existing = this.db
|
|
2016
|
-
.prepare('SELECT owner_human_id, status FROM project WHERE id = ? AND deleted_at IS NULL')
|
|
2017
|
-
.get(input.projectId) as { owner_human_id: string; status: string } | undefined
|
|
2018
|
-
if (!existing) throw stateInvalid(`项目不存在:${input.projectId}`)
|
|
2019
|
-
if (existing.status !== 'draft') throw stateInvalid('只有草稿项目可以在新建向导里继续修改')
|
|
2020
|
-
if (existing.owner_human_id !== principal.memberId) requireCapability(principal, CAPABILITY.projectManage)
|
|
2021
|
-
} else {
|
|
2022
|
-
requireCapability(principal, CAPABILITY.projectCreate)
|
|
2023
|
-
}
|
|
2024
|
-
const saved = persistProjectDraft(this.db, this.id, at, principal.memberId, input)
|
|
2025
|
-
if (saved.created) {
|
|
2026
|
-
writeChange(this.db, { objectKind: 'project', objectId: saved.projectId, revision: saved.revision, changeKind: 'draft_created', at })
|
|
2027
|
-
}
|
|
2028
|
-
const auditId = this.audit(ctx, principal, {
|
|
2029
|
-
action: 'project/saveDraft',
|
|
2030
|
-
subjectKind: 'project',
|
|
2031
|
-
subjectId: saved.projectId,
|
|
2032
|
-
after: { created: saved.created, revision: saved.revision, step: saved.step },
|
|
2033
|
-
})
|
|
2034
|
-
return this.receipt(ctx, {
|
|
2035
|
-
status: 'applied',
|
|
2036
|
-
objectId: saved.projectId,
|
|
2037
|
-
objectRevision: saved.revision,
|
|
2038
|
-
auditId,
|
|
2039
|
-
detail: { created: saved.created, step: saved.step, savedAt: saved.savedAt },
|
|
2040
|
-
})
|
|
2041
|
-
})
|
|
2042
|
-
}
|
|
2043
|
-
|
|
2044
|
-
/** 删除归档项目:软删,工作项解绑保留;只有具备 project.archive 的人能做。 */
|
|
2045
|
-
deleteProject(ctx: CommandContext, input: { projectId: string; reason?: string }): CommandReceipt {
|
|
2046
|
-
return this.command(ctx, 'project/delete', input, () => {
|
|
2047
|
-
const principal = this.principal(ctx)
|
|
2048
|
-
requireHuman(principal)
|
|
2049
|
-
requireCapability(principal, 'project.archive')
|
|
2050
|
-
const at = this.now()
|
|
2051
|
-
const deleted = softDeleteProject(this.db, at, principal.memberId, input.projectId)
|
|
2052
|
-
writeChange(this.db, {
|
|
2053
|
-
objectKind: 'project',
|
|
2054
|
-
objectId: deleted.id,
|
|
2055
|
-
revision: deleted.revision,
|
|
2056
|
-
changeKind: 'deleted',
|
|
2057
|
-
at,
|
|
2058
|
-
})
|
|
2059
|
-
const auditId = this.audit(ctx, principal, {
|
|
2060
|
-
action: 'project/delete',
|
|
2061
|
-
subjectKind: 'project',
|
|
2062
|
-
subjectId: deleted.id,
|
|
2063
|
-
after: { detachedWorkItems: deleted.detachedWorkItems, reason: input.reason ?? null },
|
|
2064
|
-
})
|
|
2065
|
-
return this.receipt(ctx, {
|
|
2066
|
-
status: 'applied',
|
|
2067
|
-
objectId: deleted.id,
|
|
2068
|
-
objectRevision: deleted.revision,
|
|
2069
|
-
auditId,
|
|
2070
|
-
detail: { detachedWorkItems: deleted.detachedWorkItems },
|
|
2071
|
-
})
|
|
2072
|
-
})
|
|
2073
|
-
}
|
|
2074
|
-
|
|
2075
|
-
// ───────────────────────── 项目记忆 ─────────────────────────
|
|
2076
|
-
|
|
2077
|
-
listProjectMemories(
|
|
2078
|
-
ctx: CommandContext,
|
|
2079
|
-
filter: { projectId: string; status?: string; kind?: string; limit?: number },
|
|
2080
|
-
) {
|
|
2081
|
-
resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
2082
|
-
return queryProjectMemories(this.db, filter)
|
|
2083
|
-
}
|
|
2084
|
-
|
|
2085
|
-
/** 人在项目「记忆」页签手写一条;能力类可以指定同步给哪个 bot。 */
|
|
2086
|
-
createProjectMemory(
|
|
2087
|
-
ctx: CommandContext,
|
|
2088
|
-
input: { projectId: string; kind: string; title: string; body: string; memberId?: string | null },
|
|
2089
|
-
): CommandReceipt {
|
|
2090
|
-
return this.command(ctx, 'project/memory/create', input, () => {
|
|
2091
|
-
const principal = this.principal(ctx)
|
|
2092
|
-
requireHuman(principal)
|
|
2093
|
-
requireCapability(principal, CAPABILITY.projectManage)
|
|
2094
|
-
const at = this.now()
|
|
2095
|
-
const created = createMemory(this.db, this.id, at, principal.memberId, {
|
|
2096
|
-
projectId: input.projectId,
|
|
2097
|
-
kind: input.kind,
|
|
2098
|
-
title: input.title,
|
|
2099
|
-
body: input.body,
|
|
2100
|
-
origin: 'manual',
|
|
2101
|
-
memberId: input.memberId ?? null,
|
|
2102
|
-
sourceRef: 'manual',
|
|
2103
|
-
} satisfies CreateMemoryInput)
|
|
2104
|
-
writeChange(this.db, {
|
|
2105
|
-
objectKind: 'project',
|
|
2106
|
-
objectId: input.projectId,
|
|
2107
|
-
revision: 0,
|
|
2108
|
-
changeKind: 'memory.created',
|
|
2109
|
-
at,
|
|
2110
|
-
})
|
|
2111
|
-
const auditId = this.audit(ctx, principal, {
|
|
2112
|
-
action: 'project/memory/create',
|
|
2113
|
-
subjectKind: 'project_memory',
|
|
2114
|
-
subjectId: created.memory.id,
|
|
2115
|
-
after: { kind: created.memory.kind, title: created.memory.title, syncedTo: created.syncedTo },
|
|
2116
|
-
})
|
|
2117
|
-
return this.receipt(ctx, {
|
|
2118
|
-
status: 'applied',
|
|
2119
|
-
objectId: created.memory.id,
|
|
2120
|
-
objectRevision: created.memory.revision,
|
|
2121
|
-
auditId,
|
|
2122
|
-
detail: { projectId: input.projectId, created: created.created, syncedTo: created.syncedTo },
|
|
2123
|
-
})
|
|
2124
|
-
})
|
|
2125
|
-
}
|
|
2126
|
-
|
|
2127
|
-
/** 改标题/正文,或归档/恢复;能力类记忆同步更新 bot 的副本。 */
|
|
2128
|
-
updateProjectMemory(
|
|
2129
|
-
ctx: CommandContext,
|
|
2130
|
-
input: { memoryId: string; title?: string; body?: string; status?: string },
|
|
2131
|
-
): CommandReceipt {
|
|
2132
|
-
return this.command(ctx, 'project/memory/update', input, () => {
|
|
2133
|
-
const principal = this.principal(ctx)
|
|
2134
|
-
requireHuman(principal)
|
|
2135
|
-
const before = this.db
|
|
2136
|
-
.prepare('SELECT id, project_id, status, title FROM project_memory WHERE id = ?')
|
|
2137
|
-
.get(input.memoryId) as { id: string; project_id: string; status: string; title: string } | undefined
|
|
2138
|
-
if (!before) throw stateInvalid(`记忆不存在:${input.memoryId}`)
|
|
2139
|
-
requireCapability(principal, CAPABILITY.projectManage)
|
|
2140
|
-
const at = this.now()
|
|
2141
|
-
const updated = updateMemory(this.db, at, input)
|
|
2142
|
-
writeChange(this.db, {
|
|
2143
|
-
objectKind: 'project',
|
|
2144
|
-
objectId: before.project_id,
|
|
2145
|
-
revision: 0,
|
|
2146
|
-
changeKind: `memory.${updated.status}`,
|
|
2147
|
-
at,
|
|
2148
|
-
})
|
|
2149
|
-
const auditId = this.audit(ctx, principal, {
|
|
2150
|
-
action: 'project/memory/update',
|
|
2151
|
-
subjectKind: 'project_memory',
|
|
2152
|
-
subjectId: updated.id,
|
|
2153
|
-
before: { status: before.status, title: before.title },
|
|
2154
|
-
after: { status: updated.status, title: updated.title },
|
|
2155
|
-
})
|
|
2156
|
-
return this.receipt(ctx, {
|
|
2157
|
-
status: 'applied',
|
|
2158
|
-
objectId: updated.id,
|
|
2159
|
-
objectRevision: updated.revision,
|
|
2160
|
-
auditId,
|
|
2161
|
-
detail: { projectId: before.project_id, status: updated.status },
|
|
2162
|
-
})
|
|
2163
|
-
})
|
|
2164
|
-
}
|
|
2165
|
-
|
|
2166
|
-
/** 项目状态机:发布目标(draft→active)、暂停/继续、归档。归档能力是人专属。 */
|
|
2167
|
-
updateProjectStatus(
|
|
2168
|
-
ctx: CommandContext,
|
|
2169
|
-
input: { projectId: string; status: string; reason?: string },
|
|
2170
|
-
): CommandReceipt {
|
|
2171
|
-
return this.command(ctx, 'project/status', input, () => {
|
|
2172
|
-
const principal = this.principal(ctx)
|
|
2173
|
-
requireHuman(principal)
|
|
2174
|
-
if (input.status === 'archived') requireCapability(principal, 'project.archive')
|
|
2175
|
-
else requireCapability(principal, CAPABILITY.projectManage)
|
|
2176
|
-
const at = this.now()
|
|
2177
|
-
const change = applyProjectStatus(this.db, this.id, at, principal.memberId, input.projectId, input.status)
|
|
2178
|
-
if (change.before === 'draft' && change.after === 'active') {
|
|
2179
|
-
// 从详情页「发布目标」发布草稿时,与向导发布走同一套收尾。
|
|
2180
|
-
clearProjectDraft(this.db, input.projectId)
|
|
2181
|
-
lifecycle.ensureDefaultPlan(this.db, this.id, at, input.projectId)
|
|
2182
|
-
const coordinator = (this.db.prepare('SELECT coordinator_id FROM project WHERE id = ?').get(input.projectId) as { coordinator_id: string | null }).coordinator_id
|
|
2183
|
-
ensureDefaultAnalystInProject(this.db, input.projectId, coordinator, principal.memberId, at)
|
|
2184
|
-
}
|
|
2185
|
-
writeChange(this.db, {
|
|
2186
|
-
objectKind: 'project',
|
|
2187
|
-
objectId: input.projectId,
|
|
2188
|
-
revision: change.revision,
|
|
2189
|
-
changeKind: `status:${change.after}`,
|
|
2190
|
-
at,
|
|
2191
|
-
})
|
|
2192
|
-
const auditId = this.audit(ctx, principal, {
|
|
2193
|
-
action: 'project/status',
|
|
2194
|
-
subjectKind: 'project',
|
|
2195
|
-
subjectId: input.projectId,
|
|
2196
|
-
before: { status: change.before },
|
|
2197
|
-
after: { status: change.after, reason: input.reason ?? null, targetVersionId: change.targetVersionId },
|
|
2198
|
-
})
|
|
2199
|
-
return this.receipt(ctx, {
|
|
2200
|
-
status: 'applied',
|
|
2201
|
-
objectId: input.projectId,
|
|
2202
|
-
objectRevision: change.revision,
|
|
2203
|
-
auditId,
|
|
2204
|
-
detail: { before: change.before, after: change.after, targetVersionId: change.targetVersionId },
|
|
2205
|
-
})
|
|
2206
|
-
})
|
|
2207
|
-
}
|
|
2208
|
-
|
|
2209
|
-
tryProjectMetric(ctx: CommandContext, input: {projectId?: string;metric: import('./projects.ts').MetricInput}) {
|
|
2210
|
-
resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
2211
|
-
if (input.projectId) lifecycle.projectRow(this.db,input.projectId)
|
|
2212
|
-
return lifecycle.tryMetric(this.db,this.now(),input.projectId,input.metric)
|
|
2213
|
-
}
|
|
2214
|
-
|
|
2215
|
-
publishProjectTarget(ctx:CommandContext,input:lifecycle.PublishTargetInput) {
|
|
2216
|
-
return this.projectCommand(ctx,'project/publishTarget',input,'target.publish',p => {
|
|
2217
|
-
const before=this.db.prepare('SELECT status,coordinator_id FROM project WHERE id=?').get(input.projectId) as {status:string;coordinator_id:string|null}
|
|
2218
|
-
const result = lifecycle.publishTarget(this.db,this.id,this.now(),p.memberId,input)
|
|
2219
|
-
if(before.status==='draft'){
|
|
2220
|
-
// 草稿第一次发布:向导草稿清掉,补齐默认阶段,并把数据分析 bot 拉进群。
|
|
2221
|
-
const at=this.now()
|
|
2222
|
-
clearProjectDraft(this.db,input.projectId)
|
|
2223
|
-
lifecycle.ensureDefaultPlan(this.db,this.id,at,input.projectId)
|
|
2224
|
-
ensureDefaultAnalystInProject(this.db,input.projectId,before.coordinator_id,p.memberId,at)
|
|
2225
|
-
}
|
|
2226
|
-
return result
|
|
2227
|
-
})
|
|
2228
|
-
}
|
|
2229
|
-
createProjectPhase(ctx:CommandContext,input:lifecycle.CreatePhaseInput) {
|
|
2230
|
-
return this.projectCommand(ctx,'project/createPhase',input,CAPABILITY.projectManage,p => lifecycle.createPhase(this.db,this.id,p.memberId,input))
|
|
2231
|
-
}
|
|
2232
|
-
updateProjectPhase(ctx:CommandContext,input:lifecycle.UpdatePhaseInput) {
|
|
2233
|
-
return this.projectCommand(ctx,'project/updatePhase',input,CAPABILITY.projectManage,p => lifecycle.updatePhase(this.db,this.now(),p.memberId,input))
|
|
2234
|
-
}
|
|
2235
|
-
updateProjectTarget(ctx:CommandContext,input:lifecycle.UpdateTargetInput) {
|
|
2236
|
-
return this.projectCommand(ctx,'project/updateTarget',input,CAPABILITY.projectManage,p => lifecycle.updatePhaseTarget(this.db,input))
|
|
2237
|
-
}
|
|
2238
|
-
addProjectTarget(ctx:CommandContext,input:{projectId:string;phaseId:string;description:string;detail?:string;targetValue?:number|null;metricId?:string;verifierHumanId?:string}) {
|
|
2239
|
-
return this.projectCommand(ctx,'project/addTarget',input,CAPABILITY.projectManage,() => lifecycle.addPhaseTarget(this.db,this.id,input))
|
|
2240
|
-
}
|
|
2241
|
-
submitProjectTargetCheck(ctx:CommandContext,input:lifecycle.SubmitTargetCheckInput) {
|
|
2242
|
-
return this.projectCommand(ctx,'project/submitTargetCheck',input,CAPABILITY.projectManage,p => {
|
|
2243
|
-
// 指定审批人必须是能批准的人类(避免指派一个没有审批能力的人让流程卡死)。
|
|
2244
|
-
const approver=resolvePrincipal(this.db,input.approverId,this.now())
|
|
2245
|
-
requireHuman(approver)
|
|
2246
|
-
requireCapability(approver,CAPABILITY.approvalDecide)
|
|
2247
|
-
return lifecycle.submitTargetCheck(this.db,this.id,this.now(),p.memberId,input)
|
|
2248
|
-
})
|
|
2249
|
-
}
|
|
2250
|
-
confirmProjectPhase(ctx:CommandContext,input:{projectId:string;phaseId:string;note?:string}) {
|
|
2251
|
-
return this.projectCommand(ctx,'project/confirmPhase',input,CAPABILITY.projectManage,p => lifecycle.confirmPhase(this.db,this.now(),p.memberId,input))
|
|
2252
|
-
}
|
|
2253
|
-
createProjectReview(ctx:CommandContext,input:lifecycle.CreateReviewInput) {
|
|
2254
|
-
return this.projectCommand(ctx,'project/createReview',input,CAPABILITY.projectManage,p => lifecycle.createReview(this.db,this.id,this.now(),p.memberId,input))
|
|
2255
|
-
}
|
|
2256
|
-
confirmProjectReview(ctx:CommandContext,input:{projectId:string;reviewId:string;note?:string}) {
|
|
2257
|
-
return this.projectCommand(ctx,'project/confirmReview',input,'review.confirm',p => lifecycle.confirmReview(this.db,this.now(),p.memberId,input))
|
|
2258
|
-
}
|
|
2259
|
-
private projectCommand(ctx:CommandContext,action:string,input:{projectId:string},capability:string,apply:(p:Principal)=>Record<string,unknown>) {
|
|
2260
|
-
return this.command(ctx,action,input,()=>{
|
|
2261
|
-
const p=this.principal(ctx)
|
|
2262
|
-
requireHuman(p); requireCapability(p,capability)
|
|
2263
|
-
if(ctx.channel!=='ui') throw denied('项目发布与人工确认只能通过UI通道')
|
|
2264
|
-
const project=lifecycle.projectRow(this.db,input.projectId)
|
|
2265
|
-
if(project.status==='archived') throw stateInvalid('归档项目不能修改')
|
|
2266
|
-
const result=apply(p),at=this.now(),revision=project.revision+1
|
|
2267
|
-
this.db.prepare('UPDATE project SET revision=?,updated_at=? WHERE id=?').run(revision,at,project.id)
|
|
2268
|
-
writeChange(this.db,{objectKind:'project',objectId:project.id,revision,changeKind:action.split('/')[1],at})
|
|
2269
|
-
const auditId=this.audit(ctx,p,{action,subjectKind:'project',subjectId:project.id,before:{revision:project.revision},after:{...input,...result}})
|
|
2270
|
-
return this.receipt(ctx,{status:'applied',objectId:project.id,objectRevision:revision,auditId,detail:result})
|
|
2271
|
-
})
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2274
|
-
/** 记一条指标读数;`value=null` 表示缺数(界面必须显示「未知」而不是达标)。 */
|
|
2275
|
-
recordMetric(ctx: CommandContext, input: ObservationInput): CommandReceipt {
|
|
2276
|
-
return this.command(ctx, 'project/recordMetric', input, () => {
|
|
2277
|
-
const principal = this.principal(ctx)
|
|
2278
|
-
requireCapability(principal, CAPABILITY.metricRecord)
|
|
2279
|
-
const at = this.now()
|
|
2280
|
-
const observation = insertObservation(this.db, this.id, at, input)
|
|
2281
|
-
writeChange(this.db, {
|
|
2282
|
-
objectKind: 'project',
|
|
2283
|
-
objectId: input.projectId,
|
|
2284
|
-
revision: 0,
|
|
2285
|
-
changeKind: 'metric.observed',
|
|
2286
|
-
at,
|
|
2287
|
-
})
|
|
2288
|
-
const auditId = this.audit(ctx, principal, {
|
|
2289
|
-
action: 'project/recordMetric',
|
|
2290
|
-
subjectKind: 'metric_observation',
|
|
2291
|
-
subjectId: observation.id,
|
|
2292
|
-
after: {
|
|
2293
|
-
projectId: input.projectId,
|
|
2294
|
-
metricId: observation.metricId,
|
|
2295
|
-
value: observation.value,
|
|
2296
|
-
windowStart: observation.windowStart,
|
|
2297
|
-
windowEnd: observation.windowEnd,
|
|
2298
|
-
},
|
|
2299
|
-
})
|
|
2300
|
-
return this.receipt(ctx, {
|
|
2301
|
-
status: 'applied',
|
|
2302
|
-
objectId: input.projectId,
|
|
2303
|
-
auditId,
|
|
2304
|
-
detail: { observationId: observation.id, metricId: observation.metricId },
|
|
2305
|
-
})
|
|
2306
|
-
})
|
|
2307
|
-
}
|
|
2308
|
-
|
|
2309
|
-
private projectActions(principal: Principal, detail: ProjectDetail): string[] {
|
|
2310
|
-
const has = (capability: string): boolean =>
|
|
2311
|
-
principal.kind === 'system' || principal.capabilities.has(capability)
|
|
2312
|
-
const actions: string[] = []
|
|
2313
|
-
if (detail.status !== 'archived' && has(CAPABILITY.projectCreate)) actions.push('createWorkItem')
|
|
2314
|
-
if (principal.kind === 'human' && has(CAPABILITY.projectManage)) {
|
|
2315
|
-
if (detail.status === 'draft') actions.push('publish')
|
|
2316
|
-
if (detail.status === 'active') actions.push('pause')
|
|
2317
|
-
if (detail.status === 'paused') actions.push('resume')
|
|
2318
|
-
}
|
|
2319
|
-
if (principal.kind === 'human' && has('project.archive') && detail.status !== 'archived') actions.push('archive')
|
|
2320
|
-
// 草稿可以回到新建向导继续填;归档后可以删除(软删)。
|
|
2321
|
-
if (principal.kind === 'human' && has(CAPABILITY.projectCreate) && detail.status === 'draft') actions.push('continueDraft')
|
|
2322
|
-
if (principal.kind === 'human' && has('project.archive') && detail.status === 'archived') actions.push('delete')
|
|
2323
|
-
if (principal.kind === 'human' && has(CAPABILITY.projectManage) && detail.status !== 'archived') actions.push('manageMemory')
|
|
2324
|
-
return actions
|
|
2325
|
-
}
|
|
2326
|
-
|
|
2327
|
-
// ───────────────────────── 人机成员(M6) ─────────────────────────
|
|
2328
|
-
|
|
2329
|
-
listMembers(
|
|
2330
|
-
ctx: CommandContext,
|
|
2331
|
-
filter: { kind?: string; status?: string; limit?: number } = {},
|
|
2332
|
-
): MemberSummary[] {
|
|
2333
|
-
resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
2334
|
-
return queryMembers(this.db, this.now(), filter)
|
|
2335
|
-
}
|
|
2336
|
-
|
|
2337
|
-
getMemberDetail(ctx: CommandContext, memberId: string) {
|
|
2338
|
-
const principal = resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
2339
|
-
const detail = queryMember(this.db, this.now(), memberId)
|
|
2340
|
-
const actions: string[] = []
|
|
2341
|
-
const canManage = principal.kind === 'human' && principal.capabilities.has('member.manage')
|
|
2342
|
-
if (canManage && memberId !== principal.memberId && detail.kind !== 'system') {
|
|
2343
|
-
if (detail.status === 'active') actions.push('pause', 'retire')
|
|
2344
|
-
if (detail.status === 'paused') actions.push('resume', 'retire')
|
|
2345
|
-
if (detail.status === 'retired') actions.push('resume')
|
|
2346
|
-
}
|
|
2347
|
-
if (canManage && detail.kind === 'bot' && detail.status !== 'retired') actions.push('publishConfig')
|
|
2348
|
-
const bot_versions = this.db.prepare('SELECT * FROM member_version WHERE member_id=? ORDER BY version DESC').all(memberId).map((v:any) => ({...v,memory_scope:JSON.parse(v.memory_scope_json),skills:this.db.prepare('SELECT * FROM member_version_skill WHERE member_version_id=?').all(v.id),responsibility:(this.db.prepare("SELECT after_json FROM audit_log WHERE subject_kind='member' AND subject_id=? AND action IN ('member/publishConfig','member/create') ORDER BY created_at DESC,rowid DESC").all(memberId) as any[]).map(a=>JSON.parse(a.after_json)).find(a=>a.versionId===v.id || a.version===v.version)?.responsibility ?? null}))
|
|
2349
|
-
return { ...detail, bot_versions, memories: queryMemberMemories(this.db, memberId), activity: memberActivity(this.db,this.now(),memberId,this.timeZone), allowedActions: actions }
|
|
2350
|
-
}
|
|
2351
|
-
|
|
2352
|
-
listPositions(ctx: CommandContext): PositionSummary[] {
|
|
2353
|
-
resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
2354
|
-
return queryPositions(this.db)
|
|
2355
|
-
}
|
|
2356
|
-
|
|
2357
|
-
createMember(ctx: CommandContext, input: CreateMemberInput): CommandReceipt {
|
|
2358
|
-
return this.command(ctx, 'member/create', input, () => {
|
|
2359
|
-
const principal = this.principal(ctx)
|
|
2360
|
-
requireHuman(principal)
|
|
2361
|
-
requireCapability(principal, 'member.manage')
|
|
2362
|
-
const at = this.now()
|
|
2363
|
-
const created = insertMemberRow(this.db, this.id, at, principal.memberId, input)
|
|
2364
|
-
writeChange(this.db, {
|
|
2365
|
-
objectKind: 'member',
|
|
2366
|
-
objectId: created.id,
|
|
2367
|
-
revision: created.version,
|
|
2368
|
-
changeKind: 'created',
|
|
2369
|
-
at,
|
|
2370
|
-
})
|
|
2371
|
-
const auditId = this.audit(ctx, principal, {
|
|
2372
|
-
action: 'member/create',
|
|
2373
|
-
subjectKind: 'member',
|
|
2374
|
-
subjectId: created.id,
|
|
2375
|
-
after: { kind: created.kind, version: created.version, capabilities: created.capabilities, responsibility: input.responsibility ?? '', model: input.model ?? null, agentPreset: input.agentPreset ?? null, memoryScope: {} },
|
|
2376
|
-
})
|
|
2377
|
-
return this.receipt(ctx, {
|
|
2378
|
-
status: 'applied',
|
|
2379
|
-
objectId: created.id,
|
|
2380
|
-
objectRevision: created.version,
|
|
2381
|
-
auditId,
|
|
2382
|
-
detail: { kind: created.kind, capabilities: created.capabilities },
|
|
2383
|
-
})
|
|
2384
|
-
})
|
|
2385
|
-
}
|
|
2386
|
-
|
|
2387
|
-
publishMemberConfig(ctx:CommandContext,input:PublishConfigInput) {
|
|
2388
|
-
return this.command(ctx,'member/publishConfig',input,()=>{
|
|
2389
|
-
const p=this.principal(ctx);requireHuman(p);requireCapability(p,'member.manage')
|
|
2390
|
-
if(ctx.channel!=='ui')throw denied('配置发布只能通过UI通道')
|
|
2391
|
-
const at=this.now(),result=publishConfig(this.db,this.id,at,p.memberId,input)
|
|
2392
|
-
writeChange(this.db,{objectKind:'member',objectId:input.memberId,revision:result.version,changeKind:'config_published',at})
|
|
2393
|
-
const auditId=this.audit(ctx,p,{action:'member/publishConfig',subjectKind:'member',subjectId:input.memberId,after:result})
|
|
2394
|
-
return this.receipt(ctx,{status:'applied',objectId:input.memberId,objectRevision:result.version,auditId,detail:result})
|
|
2395
|
-
})
|
|
2396
|
-
}
|
|
2397
|
-
|
|
2398
|
-
updateMemberStatus(
|
|
2399
|
-
ctx: CommandContext,
|
|
2400
|
-
input: { memberId: string; status: string },
|
|
2401
|
-
): CommandReceipt {
|
|
2402
|
-
return this.command(ctx, 'member/status', input, () => {
|
|
2403
|
-
const principal = this.principal(ctx)
|
|
2404
|
-
requireHuman(principal)
|
|
2405
|
-
requireCapability(principal, 'member.manage')
|
|
2406
|
-
const at = this.now()
|
|
2407
|
-
const change = applyMemberStatus(this.db, at, principal.memberId, input.memberId, input.status)
|
|
2408
|
-
writeChange(this.db, {
|
|
2409
|
-
objectKind: 'member',
|
|
2410
|
-
objectId: input.memberId,
|
|
2411
|
-
revision: 0,
|
|
2412
|
-
changeKind: `status:${change.after}`,
|
|
2413
|
-
at,
|
|
2414
|
-
})
|
|
2415
|
-
const auditId = this.audit(ctx, principal, {
|
|
2416
|
-
action: 'member/status',
|
|
2417
|
-
subjectKind: 'member',
|
|
2418
|
-
subjectId: input.memberId,
|
|
2419
|
-
before: { status: change.before },
|
|
2420
|
-
after: { status: change.after, closedPositions: change.closedPositions },
|
|
2421
|
-
})
|
|
2422
|
-
return this.receipt(ctx, {
|
|
2423
|
-
status: 'applied',
|
|
2424
|
-
objectId: input.memberId,
|
|
2425
|
-
auditId,
|
|
2426
|
-
detail: change,
|
|
2427
|
-
})
|
|
2428
|
-
})
|
|
2429
|
-
}
|
|
2430
|
-
|
|
2431
|
-
// ───────────────────────── 知识(M6,真实来源) ─────────────────────────
|
|
2432
|
-
|
|
2433
|
-
private knowledgeOrThrow(): KnowledgeService {
|
|
2434
|
-
if (!this.knowledgeService) {
|
|
2435
|
-
throw new HamsError('HOST_CAPABILITY_MISSING', '知识数据源未配置(缺少 dataDir)', {
|
|
2436
|
-
nextStep: '检查插件配置的 dataDir 是否为空',
|
|
2437
|
-
})
|
|
2438
|
-
}
|
|
2439
|
-
return this.knowledgeService
|
|
2440
|
-
}
|
|
2441
|
-
|
|
2442
|
-
listKnowledge(
|
|
2443
|
-
ctx: CommandContext,
|
|
2444
|
-
filter: { kind?: string; q?: string; limit?: number } = {},
|
|
2445
|
-
): KnowledgeListResult {
|
|
2446
|
-
resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
2447
|
-
return this.knowledgeOrThrow().list(filter)
|
|
2448
|
-
}
|
|
2449
|
-
|
|
2450
|
-
getKnowledge(
|
|
2451
|
-
ctx: CommandContext,
|
|
2452
|
-
input: { kind: string; id: string },
|
|
2453
|
-
): (KnowledgeItem | DocDetail | SkillDetail | TableDetail) & { allowedActions: string[] } {
|
|
2454
|
-
const principal = resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
2455
|
-
const detail = this.knowledgeOrThrow().get(input.kind, input.id)
|
|
2456
|
-
const actions: string[] = []
|
|
2457
|
-
const canWrite = principal.kind === 'system' || principal.capabilities.has(CAPABILITY.knowledgeWrite)
|
|
2458
|
-
if (detail.kind === 'doc' && canWrite) {
|
|
2459
|
-
if ('source_kind' in detail && detail.source_kind === 'hams') actions.push('edit')
|
|
2460
|
-
if ('status' in detail && detail.status === 'draft') actions.push('publish')
|
|
2461
|
-
}
|
|
2462
|
-
if (detail.kind === 'skill' && principal.kind === 'human' && principal.capabilities.has(CAPABILITY.knowledgeSync)) {
|
|
2463
|
-
actions.push('sync')
|
|
2464
|
-
}
|
|
2465
|
-
return { ...detail, allowedActions: actions }
|
|
2466
|
-
}
|
|
2467
|
-
|
|
2468
|
-
/** 技能镜像 + 业务表结构登记,来源都是本机真实数据;重复执行幂等。 */
|
|
2469
|
-
syncKnowledge(ctx: CommandContext): CommandReceipt {
|
|
2470
|
-
return this.command(ctx, 'knowledge/sync', {}, () => {
|
|
2471
|
-
const principal = this.principal(ctx)
|
|
2472
|
-
requireHuman(principal)
|
|
2473
|
-
requireCapability(principal, CAPABILITY.knowledgeSync)
|
|
2474
|
-
const at = this.now()
|
|
2475
|
-
const report: SyncReport = this.knowledgeOrThrow().sync(principal.memberId)
|
|
2476
|
-
writeChange(this.db, {
|
|
2477
|
-
objectKind: 'knowledge',
|
|
2478
|
-
objectId: 'sync',
|
|
2479
|
-
revision: 0,
|
|
2480
|
-
changeKind: 'synced',
|
|
2481
|
-
at,
|
|
2482
|
-
})
|
|
2483
|
-
const auditId = this.audit(ctx, principal, {
|
|
2484
|
-
action: 'knowledge/sync',
|
|
2485
|
-
subjectKind: 'knowledge',
|
|
2486
|
-
subjectId: 'sync',
|
|
2487
|
-
after: {
|
|
2488
|
-
skills: { created: report.skills.created, updated: report.skills.updated, unchanged: report.skills.unchanged },
|
|
2489
|
-
tables: { created: report.tables.created, updated: report.tables.updated },
|
|
2490
|
-
},
|
|
2491
|
-
})
|
|
2492
|
-
return this.receipt(ctx, { status: 'applied', auditId, detail: { report } })
|
|
2493
|
-
})
|
|
2494
|
-
}
|
|
2495
|
-
|
|
2496
|
-
/** 新建一律是草稿:发布必须走 `publishKnowledgeDoc`(人),避免 bot 或界面绕过发布关卡。 */
|
|
2497
|
-
createKnowledgeDoc(
|
|
2498
|
-
ctx: CommandContext,
|
|
2499
|
-
input: { title: string; body: string },
|
|
2500
|
-
): CommandReceipt {
|
|
2501
|
-
return this.command(ctx, 'knowledge/createDoc', input, () => {
|
|
2502
|
-
const principal = this.principal(ctx)
|
|
2503
|
-
requireCapability(principal, CAPABILITY.knowledgeWrite)
|
|
2504
|
-
const at = this.now()
|
|
2505
|
-
const created = this.knowledgeOrThrow().createDoc(principal.memberId, {
|
|
2506
|
-
title: input.title,
|
|
2507
|
-
body: input.body,
|
|
2508
|
-
status: 'draft',
|
|
2509
|
-
})
|
|
2510
|
-
writeChange(this.db, {
|
|
2511
|
-
objectKind: 'kb_doc',
|
|
2512
|
-
objectId: created.id,
|
|
2513
|
-
revision: created.revision,
|
|
2514
|
-
changeKind: 'created',
|
|
2515
|
-
at,
|
|
2516
|
-
})
|
|
2517
|
-
const auditId = this.audit(ctx, principal, {
|
|
2518
|
-
action: 'knowledge/createDoc',
|
|
2519
|
-
subjectKind: 'kb_doc',
|
|
2520
|
-
subjectId: created.id,
|
|
2521
|
-
after: { title: input.title, revision: created.revision, path: created.path },
|
|
2522
|
-
})
|
|
2523
|
-
return this.receipt(ctx, {
|
|
2524
|
-
status: 'applied',
|
|
2525
|
-
objectId: created.id,
|
|
2526
|
-
objectRevision: created.revision,
|
|
2527
|
-
auditId,
|
|
2528
|
-
detail: { path: created.path },
|
|
2529
|
-
})
|
|
2530
|
-
})
|
|
2531
|
-
}
|
|
2532
|
-
|
|
2533
|
-
updateKnowledgeDoc(
|
|
2534
|
-
ctx: CommandContext,
|
|
2535
|
-
input: { docId: string; title?: string; body: string; expectedRevision?: number },
|
|
2536
|
-
): CommandReceipt {
|
|
2537
|
-
return this.command(ctx, 'knowledge/updateDoc', input, () => {
|
|
2538
|
-
const principal = this.principal(ctx)
|
|
2539
|
-
requireCapability(principal, CAPABILITY.knowledgeWrite)
|
|
2540
|
-
const allowForeign = principal.kind === 'system' || principal.capabilities.has('member.manage')
|
|
2541
|
-
const at = this.now()
|
|
2542
|
-
const updated = this.knowledgeOrThrow().updateDoc(
|
|
2543
|
-
principal.memberId,
|
|
2544
|
-
input.docId,
|
|
2545
|
-
{ title: input.title, body: input.body, expectedRevision: input.expectedRevision },
|
|
2546
|
-
allowForeign,
|
|
2547
|
-
)
|
|
2548
|
-
writeChange(this.db, {
|
|
2549
|
-
objectKind: 'kb_doc',
|
|
2550
|
-
objectId: input.docId,
|
|
2551
|
-
revision: updated.revision,
|
|
2552
|
-
changeKind: 'updated',
|
|
2553
|
-
at,
|
|
2554
|
-
})
|
|
2555
|
-
const auditId = this.audit(ctx, principal, {
|
|
2556
|
-
action: 'knowledge/updateDoc',
|
|
2557
|
-
subjectKind: 'kb_doc',
|
|
2558
|
-
subjectId: input.docId,
|
|
2559
|
-
after: { revision: updated.revision, title: input.title ?? null },
|
|
2560
|
-
})
|
|
2561
|
-
return this.receipt(ctx, {
|
|
2562
|
-
status: 'applied',
|
|
2563
|
-
objectId: input.docId,
|
|
2564
|
-
objectRevision: updated.revision,
|
|
2565
|
-
auditId,
|
|
2566
|
-
detail: { path: updated.path },
|
|
2567
|
-
})
|
|
2568
|
-
})
|
|
2569
|
-
}
|
|
2570
|
-
|
|
2571
|
-
publishKnowledgeDoc(ctx: CommandContext, input: { docId: string }): CommandReceipt {
|
|
2572
|
-
return this.command(ctx, 'knowledge/publishDoc', input, () => {
|
|
2573
|
-
const principal = this.principal(ctx)
|
|
2574
|
-
requireHuman(principal)
|
|
2575
|
-
requireCapability(principal, CAPABILITY.knowledgeWrite)
|
|
2576
|
-
const allowForeign = principal.capabilities.has('member.manage')
|
|
2577
|
-
const at = this.now()
|
|
2578
|
-
const published = this.knowledgeOrThrow().publishDoc(principal.memberId, input.docId, allowForeign)
|
|
2579
|
-
writeChange(this.db, {
|
|
2580
|
-
objectKind: 'kb_doc',
|
|
2581
|
-
objectId: input.docId,
|
|
2582
|
-
revision: 0,
|
|
2583
|
-
changeKind: 'published',
|
|
2584
|
-
at,
|
|
2585
|
-
})
|
|
2586
|
-
const auditId = this.audit(ctx, principal, {
|
|
2587
|
-
action: 'knowledge/publishDoc',
|
|
2588
|
-
subjectKind: 'kb_doc',
|
|
2589
|
-
subjectId: input.docId,
|
|
2590
|
-
after: { status: published.status },
|
|
2591
|
-
})
|
|
2592
|
-
return this.receipt(ctx, { status: 'applied', objectId: input.docId, auditId, detail: published })
|
|
2593
|
-
})
|
|
2594
|
-
}
|
|
2595
|
-
|
|
2596
|
-
/** 导入本机目录里的 Markdown(只登记路径与哈希,不复制文件)。 */
|
|
2597
|
-
importKnowledgeDocs(
|
|
2598
|
-
ctx: CommandContext,
|
|
2599
|
-
input: { dir: string; recursive?: boolean; maxFiles?: number },
|
|
2600
|
-
): CommandReceipt {
|
|
2601
|
-
return this.command(ctx, 'knowledge/importDocs', input, () => {
|
|
2602
|
-
const principal = this.principal(ctx)
|
|
2603
|
-
requireHuman(principal)
|
|
2604
|
-
requireCapability(principal, CAPABILITY.knowledgeWrite)
|
|
2605
|
-
const at = this.now()
|
|
2606
|
-
const report: ImportReport = this.knowledgeOrThrow().importDocs(principal.memberId, input)
|
|
2607
|
-
writeChange(this.db, {
|
|
2608
|
-
objectKind: 'knowledge',
|
|
2609
|
-
objectId: report.dir,
|
|
2610
|
-
revision: 0,
|
|
2611
|
-
changeKind: 'imported',
|
|
2612
|
-
at,
|
|
2613
|
-
})
|
|
2614
|
-
const auditId = this.audit(ctx, principal, {
|
|
2615
|
-
action: 'knowledge/importDocs',
|
|
2616
|
-
subjectKind: 'knowledge',
|
|
2617
|
-
subjectId: report.dir,
|
|
2618
|
-
after: { scanned: report.scanned, imported: report.imported, updated: report.updated, skipped: report.skipped },
|
|
2619
|
-
})
|
|
2620
|
-
return this.receipt(ctx, { status: 'applied', auditId, detail: { report } })
|
|
2621
|
-
})
|
|
2622
|
-
}
|
|
2623
|
-
|
|
2624
|
-
// ───────────────────────── 内部:事务边界与幂等 ─────────────────────────
|
|
2625
|
-
|
|
2626
|
-
private command<T extends object>(
|
|
2627
|
-
ctx: CommandContext,
|
|
2628
|
-
action: string,
|
|
2629
|
-
payload: T,
|
|
2630
|
-
run: () => CommandReceipt,
|
|
2631
|
-
): CommandReceipt {
|
|
2632
|
-
const payloadHash = hashPayload(payload)
|
|
2633
|
-
const scopeKey = ctx.idempotencyKey
|
|
2634
|
-
? receiptScopeKey(ctx.principalId, action, ctx.idempotencyKey)
|
|
2635
|
-
: null
|
|
2636
|
-
|
|
2637
|
-
if (this.db.isTransaction) throw new HamsError('STATE_INVALID', '命令不能在已有事务内执行')
|
|
2638
|
-
|
|
2639
|
-
this.db.exec('BEGIN IMMEDIATE')
|
|
2640
|
-
try {
|
|
2641
|
-
if (scopeKey) {
|
|
2642
|
-
const stored = loadReceipt(this.db, scopeKey)
|
|
2643
|
-
if (stored) {
|
|
2644
|
-
if (stored.payloadHash !== payloadHash) {
|
|
2645
|
-
throw new HamsError('IDEMPOTENCY_MISMATCH', '同一幂等键收到了不同载荷', {
|
|
2646
|
-
nextStep: '换一个幂等键重试',
|
|
2647
|
-
})
|
|
2648
|
-
}
|
|
2649
|
-
const receipt = JSON.parse(stored.receiptJson) as CommandReceipt
|
|
2650
|
-
this.db.exec('COMMIT')
|
|
2651
|
-
return receipt
|
|
2652
|
-
}
|
|
2653
|
-
}
|
|
2654
|
-
// Replay precedes optimistic locking: retrying a committed command must return its original receipt.
|
|
2655
|
-
const target = payload as Record<string, unknown>
|
|
2656
|
-
if (ctx.expectedRevision !== undefined) {
|
|
2657
|
-
let current: number | undefined
|
|
2658
|
-
if (typeof target.workItemId === 'string') current = this.workItemOrThrow(target.workItemId).revision
|
|
2659
|
-
else if (typeof target.projectId === 'string') current = lifecycle.projectRow(this.db,target.projectId).revision
|
|
2660
|
-
else if (typeof target.memberId === 'string') current = (this.db.prepare('SELECT current_version FROM member WHERE id=?').get(target.memberId) as {current_version:number}|undefined)?.current_version
|
|
2661
|
-
if (current !== undefined && current !== ctx.expectedRevision) throw new HamsError('VERSION_CONFLICT',`对象修订号已变:期望 ${ctx.expectedRevision},当前 ${current}`)
|
|
2662
|
-
}
|
|
2663
|
-
const receipt = run()
|
|
2664
|
-
if (scopeKey) {
|
|
2665
|
-
saveReceipt(this.db, {
|
|
2666
|
-
scopeKey,
|
|
2667
|
-
commandId: ctx.correlationId,
|
|
2668
|
-
principalId: ctx.principalId,
|
|
2669
|
-
action,
|
|
2670
|
-
payloadHash,
|
|
2671
|
-
receiptJson: JSON.stringify(receipt),
|
|
2672
|
-
at: this.now(),
|
|
2673
|
-
})
|
|
2674
|
-
}
|
|
2675
|
-
this.db.exec('COMMIT')
|
|
2676
|
-
return receipt
|
|
2677
|
-
} catch (error) {
|
|
2678
|
-
if (this.db.isTransaction) this.db.exec('ROLLBACK')
|
|
2679
|
-
throw error
|
|
2680
|
-
}
|
|
2681
|
-
}
|
|
2682
|
-
|
|
2683
|
-
private principal(ctx: CommandContext): Principal {
|
|
2684
|
-
return resolvePrincipal(this.db, ctx.principalId, this.now())
|
|
2685
|
-
}
|
|
2686
|
-
|
|
2687
|
-
private receipt(
|
|
2688
|
-
ctx: CommandContext,
|
|
2689
|
-
input: {
|
|
2690
|
-
status: CommandReceipt['status']
|
|
2691
|
-
objectId?: string
|
|
2692
|
-
objectRevision?: number
|
|
2693
|
-
executionEpoch?: number
|
|
2694
|
-
approvalId?: string
|
|
2695
|
-
auditId: string
|
|
2696
|
-
detail?: Record<string, unknown>
|
|
2697
|
-
},
|
|
2698
|
-
): CommandReceipt {
|
|
2699
|
-
return {
|
|
2700
|
-
ok: input.status !== 'rejected',
|
|
2701
|
-
status: input.status,
|
|
2702
|
-
objectId: input.objectId,
|
|
2703
|
-
objectRevision: input.objectRevision,
|
|
2704
|
-
executionEpoch: input.executionEpoch,
|
|
2705
|
-
approvalId: input.approvalId,
|
|
2706
|
-
auditId: input.auditId,
|
|
2707
|
-
correlationId: ctx.correlationId,
|
|
2708
|
-
detail: input.detail,
|
|
2709
|
-
}
|
|
2710
|
-
}
|
|
2711
|
-
|
|
2712
|
-
private audit(
|
|
2713
|
-
ctx: CommandContext,
|
|
2714
|
-
principal: Principal,
|
|
2715
|
-
entry: {
|
|
2716
|
-
action: string
|
|
2717
|
-
subjectKind: string
|
|
2718
|
-
subjectId: string
|
|
2719
|
-
before?: unknown
|
|
2720
|
-
after?: unknown
|
|
2721
|
-
},
|
|
2722
|
-
): string {
|
|
2723
|
-
const auditId = this.id()
|
|
2724
|
-
writeAudit(this.db, auditId, {
|
|
2725
|
-
actorId: principal.memberId,
|
|
2726
|
-
actorKind: principal.kind,
|
|
2727
|
-
onBehalfOf: ctx.onBehalfOf ?? null,
|
|
2728
|
-
channel: ctx.channel,
|
|
2729
|
-
authSubject: ctx.authSubject ?? null,
|
|
2730
|
-
hostPreset: ctx.hostPreset ?? null,
|
|
2731
|
-
action: entry.action,
|
|
2732
|
-
subjectKind: entry.subjectKind,
|
|
2733
|
-
subjectId: entry.subjectId,
|
|
2734
|
-
before: entry.before,
|
|
2735
|
-
after: entry.after,
|
|
2736
|
-
correlationId: ctx.correlationId,
|
|
2737
|
-
at: this.now(),
|
|
2738
|
-
})
|
|
2739
|
-
return auditId
|
|
2740
|
-
}
|
|
2741
|
-
|
|
2742
|
-
// ───────────────────────── 内部:工作项与关联行 ─────────────────────────
|
|
2743
|
-
|
|
2744
|
-
private factory(): WorkItemFactory {
|
|
2745
|
-
return { db: this.db, ids: this.id, now: this.now, timeZone: this.timeZone }
|
|
2746
|
-
}
|
|
2747
|
-
|
|
2748
|
-
private createWorkItemTx(
|
|
2749
|
-
_ctx: CommandContext,
|
|
2750
|
-
principal: Principal,
|
|
2751
|
-
input: CreateWorkItemTxInput,
|
|
2752
|
-
): WorkItemRow {
|
|
2753
|
-
return insertWorkItem(this.factory(), {
|
|
2754
|
-
domain: input.domain,
|
|
2755
|
-
category: input.category,
|
|
2756
|
-
title: input.title,
|
|
2757
|
-
ownerHumanId: input.ownerHumanId,
|
|
2758
|
-
projectId: input.projectId ?? null,
|
|
2759
|
-
templateVersionId: input.templateVersionId ?? null,
|
|
2760
|
-
assetId: input.assetId ?? null,
|
|
2761
|
-
dedupeKey: input.dedupeKey ?? null,
|
|
2762
|
-
priority: input.priority,
|
|
2763
|
-
dueAt: input.dueAt ?? null,
|
|
2764
|
-
createdBy: input.createdBy,
|
|
2765
|
-
actorKind: principal.kind,
|
|
2766
|
-
})
|
|
2767
|
-
}
|
|
2768
|
-
|
|
2769
|
-
private upsertAsset(input: { externalSystem: string; externalId: string; displayName: string; kind?: string }): string {
|
|
2770
|
-
const existing = this.db
|
|
2771
|
-
.prepare('SELECT id FROM asset WHERE external_system = ? AND external_id = ?')
|
|
2772
|
-
.get(input.externalSystem, input.externalId) as { id: string } | undefined
|
|
2773
|
-
if (existing) return existing.id
|
|
2774
|
-
const assetId = this.id()
|
|
2775
|
-
this.db
|
|
2776
|
-
.prepare(
|
|
2777
|
-
`INSERT INTO asset (id, external_system, external_id, kind, display_name) VALUES (?, ?, ?, ?, ?)`,
|
|
2778
|
-
)
|
|
2779
|
-
.run(assetId, input.externalSystem, input.externalId, input.kind ?? 'battery', input.displayName)
|
|
2780
|
-
return assetId
|
|
2781
|
-
}
|
|
2782
|
-
|
|
2783
|
-
private ensureThreadTx(principal: Principal, item: WorkItemRow, sessionId?: string): ThreadRow {
|
|
2784
|
-
const active = this.db
|
|
2785
|
-
.prepare(
|
|
2786
|
-
`SELECT * FROM work_item_thread
|
|
2787
|
-
WHERE work_item_id = ? AND member_id = ? AND status IN ('active','pending_rotate')
|
|
2788
|
-
ORDER BY generation DESC LIMIT 1`,
|
|
2789
|
-
)
|
|
2790
|
-
.get(item.id, principal.memberId) as ThreadRow | undefined
|
|
2791
|
-
if (active) {
|
|
2792
|
-
const major = this.db.prepare("SELECT version FROM member_version WHERE member_id=? AND version>? AND change_class='major' ORDER BY version DESC LIMIT 1").get(principal.memberId,active.member_version)
|
|
2793
|
-
if (!major && active.status === 'active') return active
|
|
2794
|
-
if (!sessionId || sessionId === active.session_id) throw new HamsError('HOST_CAPABILITY_MISSING','配置大改或会话达到换代条件,需要宿主创建新会话',{nextStep:'创建新会话后带新sessionId认领'})
|
|
2795
|
-
this.closeThreadTx(item.id,major?'config_major':'rotation',this.now())
|
|
2796
|
-
}
|
|
2797
|
-
if (!sessionId || !sessionId.trim()) {
|
|
2798
|
-
throw new HamsError('HOST_CAPABILITY_MISSING', '新建线程需要宿主会话 id', {
|
|
2799
|
-
nextStep: '先由宿主创建会话,再带着 sessionId 认领',
|
|
2800
|
-
})
|
|
2801
|
-
}
|
|
2802
|
-
const taken = this.db.prepare('SELECT work_item_id FROM work_item_thread WHERE session_id = ?').get(sessionId) as
|
|
2803
|
-
| { work_item_id: string }
|
|
2804
|
-
| undefined
|
|
2805
|
-
if (taken) throw stateInvalid(`会话已绑定到其它工作项:${sessionId}`)
|
|
2806
|
-
const maxGen = this.db
|
|
2807
|
-
.prepare('SELECT COALESCE(MAX(generation), 0) AS g FROM work_item_thread WHERE work_item_id = ?')
|
|
2808
|
-
.get(item.id) as { g: number }
|
|
2809
|
-
const threadId = this.id()
|
|
2810
|
-
this.db
|
|
2811
|
-
.prepare(
|
|
2812
|
-
`INSERT INTO work_item_thread
|
|
2813
|
-
(id, work_item_id, generation, session_id, member_id, member_version, status, turn_count,
|
|
2814
|
-
rotate_after_turns, rotate_after_days, opened_at)
|
|
2815
|
-
VALUES (?, ?, ?, ?, ?, ?, 'active', 0, 200, 30, ?)`,
|
|
2816
|
-
)
|
|
2817
|
-
.run(threadId, item.id, Number(maxGen.g) + 1, sessionId, principal.memberId, principal.currentVersion, this.now())
|
|
2818
|
-
return this.db.prepare('SELECT * FROM work_item_thread WHERE id = ?').get(threadId) as ThreadRow
|
|
2819
|
-
}
|
|
2820
|
-
|
|
2821
|
-
private closeThreadTx(workItemId: string, reason: string, at: number): void {
|
|
2822
|
-
this.db
|
|
2823
|
-
.prepare(
|
|
2824
|
-
`UPDATE work_item_thread
|
|
2825
|
-
SET status = 'closed', closed_at = ?, close_reason = ?
|
|
2826
|
-
WHERE work_item_id = ? AND status IN ('active','pending_rotate')`,
|
|
2827
|
-
)
|
|
2828
|
-
.run(at, reason, workItemId)
|
|
2829
|
-
}
|
|
2830
|
-
|
|
2831
|
-
private resolveApproverTx(policy: ReviewPolicy, item: WorkItemRow, at: number): string {
|
|
2832
|
-
if (policy.approver === 'owner') {
|
|
2833
|
-
const owner = this.memberOrThrow(item.owner_human_id)
|
|
2834
|
-
if (owner.kind !== 'human') throw denied('负责人不是人,无法作为审批人')
|
|
2835
|
-
return item.owner_human_id
|
|
2836
|
-
}
|
|
2837
|
-
const positionId = policy.approver.slice('position:'.length)
|
|
2838
|
-
const row = this.db
|
|
2839
|
-
.prepare(
|
|
2840
|
-
`SELECT mp.member_id AS member_id
|
|
2841
|
-
FROM member_position mp
|
|
2842
|
-
JOIN member m ON m.id = mp.member_id AND m.kind = 'human' AND m.status = 'active'
|
|
2843
|
-
WHERE mp.position_id = ? AND mp.starts_at <= ? AND (mp.ends_at IS NULL OR mp.ends_at > ?)
|
|
2844
|
-
ORDER BY mp.starts_at DESC LIMIT 1`,
|
|
2845
|
-
)
|
|
2846
|
-
.get(positionId, at, at) as { member_id: string } | undefined
|
|
2847
|
-
if (!row) throw denied(`岗位 ${positionId} 当前没有在岗的人,无法解析审批人`, '安排在岗人员或改用 owner 审批')
|
|
2848
|
-
return row.member_id
|
|
2849
|
-
}
|
|
2850
|
-
|
|
2851
|
-
private requestChannel(ctx: CommandContext): 'tool' | 'ui' | 'system' {
|
|
2852
|
-
if (ctx.channel === 'tool') return 'tool'
|
|
2853
|
-
if (ctx.channel === 'ui') return 'ui'
|
|
2854
|
-
return 'system'
|
|
2855
|
-
}
|
|
2856
|
-
|
|
2857
|
-
// ───────────────────────── 内部:生命周期通用 ─────────────────────────
|
|
2858
|
-
|
|
2859
|
-
private transitionCommand(
|
|
2860
|
-
ctx: CommandContext,
|
|
2861
|
-
action: string,
|
|
2862
|
-
input: { workItemId: string; reason?: string },
|
|
2863
|
-
kind: 'pause' | 'resume' | 'cancel' | 'reopen' | 'retry',
|
|
2864
|
-
): CommandReceipt {
|
|
2865
|
-
return this.command(ctx, action, input, () => {
|
|
2866
|
-
const principal = this.principal(ctx)
|
|
2867
|
-
requireHuman(principal)
|
|
2868
|
-
requireCapability(principal, CAPABILITY.workItemManage)
|
|
2869
|
-
const at = this.now()
|
|
2870
|
-
const item = this.workItemOrThrow(input.workItemId)
|
|
2871
|
-
|
|
2872
|
-
let target: string
|
|
2873
|
-
let resumeState: string | null = null
|
|
2874
|
-
if (kind === 'pause') {
|
|
2875
|
-
if (item.status === 'paused') throw stateInvalid('工作项已经暂停')
|
|
2876
|
-
target = 'paused'
|
|
2877
|
-
resumeState = item.status
|
|
2878
|
-
} else if (kind === 'resume') {
|
|
2879
|
-
if (item.status !== 'paused' || !item.resume_state) throw stateInvalid('工作项不在暂停状态')
|
|
2880
|
-
target = item.resume_state
|
|
2881
|
-
} else if (kind === 'cancel') {
|
|
2882
|
-
target = 'cancelled'
|
|
2883
|
-
} else if (kind === 'reopen') {
|
|
2884
|
-
target = 'ready'
|
|
2885
|
-
} else {
|
|
2886
|
-
target = 'ready'
|
|
2887
|
-
}
|
|
2888
|
-
this.assertTransition(item.status, target)
|
|
2889
|
-
|
|
2890
|
-
// 联动必须在升修订号之前做:否则 work_item 的修订号一变,pending 审批会先被
|
|
2891
|
-
// trg_wi_change_invalidates_approval 标成 invalidated,T9 要求的 withdrawn 就落不了地。
|
|
2892
|
-
const linkage = this.bumpEpochLinkageTx(item.id, at)
|
|
2893
|
-
|
|
2894
|
-
this.db
|
|
2895
|
-
.prepare(
|
|
2896
|
-
`UPDATE work_item
|
|
2897
|
-
SET status = ?, resume_state = ?, revision = revision + 1, execution_epoch = execution_epoch + 1,
|
|
2898
|
-
updated_at = ?${kind === 'reopen' ? ', completed_at = NULL' : ''}
|
|
2899
|
-
WHERE id = ?`,
|
|
2900
|
-
)
|
|
2901
|
-
.run(target, resumeState, at, item.id)
|
|
2902
|
-
let after = this.workItemOrThrow(item.id)
|
|
2903
|
-
if (kind === 'resume' && target === 'waiting_check') {
|
|
2904
|
-
const previous = this.db.prepare('SELECT * FROM work_item_check WHERE work_item_id=? ORDER BY generation DESC LIMIT 1').get(item.id) as CheckRow | undefined
|
|
2905
|
-
if (previous) {
|
|
2906
|
-
const generation = after.check_generation + 1, checkId = this.id()
|
|
2907
|
-
this.db.prepare("INSERT INTO work_item_check (id,work_item_id,generation,execution_epoch,template_version_id,planned_due_at,status,created_at) VALUES (?,?,?,?,?,?,'scheduled',?)").run(checkId,item.id,generation,after.execution_epoch,previous.template_version_id,previous.planned_due_at,at)
|
|
2908
|
-
this.db.prepare('UPDATE work_item SET check_generation=?,next_action_at=?,revision=revision+1 WHERE id=?').run(generation,Math.max(at,previous.planned_due_at),item.id)
|
|
2909
|
-
if (previous.planned_due_at <= at && after.assignee_id) {
|
|
2910
|
-
const thread = this.db.prepare("SELECT * FROM work_item_thread WHERE work_item_id=? AND member_id=? AND status='active' ORDER BY generation DESC LIMIT 1").get(item.id,after.assignee_id) as ThreadRow | undefined
|
|
2911
|
-
if (thread) {
|
|
2912
|
-
const fence=after.fence_seq+1, runId=this.id()
|
|
2913
|
-
this.db.prepare('UPDATE work_item SET fence_seq=?,revision=revision+1 WHERE id=?').run(fence,item.id)
|
|
2914
|
-
this.db.prepare("INSERT INTO task_run (id,work_item_id,thread_id,check_id,member_id,member_version,execution_epoch,runtime_epoch,fence,trigger,status,created_at) VALUES (?,?,?,?,?,?,?,?,?,'check','queued',?)").run(runId,item.id,thread.id,checkId,thread.member_id,thread.member_version,after.execution_epoch,this.runtimeEpoch,fence,at)
|
|
2915
|
-
this.db.prepare("UPDATE work_item_check SET status='running' WHERE id=?").run(checkId)
|
|
2916
|
-
writeChange(this.db,{objectKind:'task_run',objectId:runId,revision:1,changeKind:'check_run_queued',at})
|
|
2917
|
-
}
|
|
2918
|
-
}
|
|
2919
|
-
}
|
|
2920
|
-
}
|
|
2921
|
-
after = this.workItemOrThrow(item.id)
|
|
2922
|
-
writeEvent(this.db, this.id(), {
|
|
2923
|
-
workItemId: item.id,
|
|
2924
|
-
kind: `work_item_${kind}`,
|
|
2925
|
-
actorId: principal.memberId,
|
|
2926
|
-
actorKind: 'human',
|
|
2927
|
-
payload: { from: item.status, to: target, reason: input.reason ?? '', linkage },
|
|
2928
|
-
revision: after.revision,
|
|
2929
|
-
executionEpoch: after.execution_epoch,
|
|
2930
|
-
at,
|
|
2931
|
-
})
|
|
2932
|
-
writeChange(this.db, { objectKind: 'work_item', objectId: item.id, revision: after.revision, changeKind: kind, at })
|
|
2933
|
-
const auditId = this.audit(ctx, principal, {
|
|
2934
|
-
action,
|
|
2935
|
-
subjectKind: 'work_item',
|
|
2936
|
-
subjectId: item.id,
|
|
2937
|
-
before: { status: item.status, executionEpoch: item.execution_epoch },
|
|
2938
|
-
after: { status: target, executionEpoch: after.execution_epoch, linkage },
|
|
2939
|
-
})
|
|
2940
|
-
return this.receipt(ctx, {
|
|
2941
|
-
status: 'applied',
|
|
2942
|
-
objectId: item.id,
|
|
2943
|
-
objectRevision: after.revision,
|
|
2944
|
-
executionEpoch: after.execution_epoch,
|
|
2945
|
-
auditId,
|
|
2946
|
-
detail: { status: target, linkage },
|
|
2947
|
-
})
|
|
2948
|
-
})
|
|
2949
|
-
}
|
|
2950
|
-
|
|
2951
|
-
/** T9 的「六件事」里与其它表联动的部分;epoch/revision 已在同一条 UPDATE 里升过。 */
|
|
2952
|
-
private bumpEpochLinkageTx(workItemId: string, at: number): Record<string, number> {
|
|
2953
|
-
const runs = this.db
|
|
2954
|
-
.prepare(
|
|
2955
|
-
`UPDATE task_run SET status = 'cancelled', finished_at = ?
|
|
2956
|
-
WHERE work_item_id = ? AND status IN ('queued','running')`,
|
|
2957
|
-
)
|
|
2958
|
-
.run(at, workItemId)
|
|
2959
|
-
const checks = this.db
|
|
2960
|
-
.prepare(
|
|
2961
|
-
`UPDATE work_item_check SET status = 'cancelled'
|
|
2962
|
-
WHERE work_item_id = ? AND status = 'scheduled'`,
|
|
2963
|
-
)
|
|
2964
|
-
.run(workItemId)
|
|
2965
|
-
const approvals = this.db
|
|
2966
|
-
.prepare(
|
|
2967
|
-
`UPDATE approval SET status = 'withdrawn'
|
|
2968
|
-
WHERE subject_id = ? AND subject_kind IN ('work_item_close','evidence_override','asset_disposal')
|
|
2969
|
-
AND status = 'pending'`,
|
|
2970
|
-
)
|
|
2971
|
-
.run(workItemId)
|
|
2972
|
-
const outbox = this.db
|
|
2973
|
-
.prepare(
|
|
2974
|
-
`UPDATE outbox SET status = 'cancelled'
|
|
2975
|
-
WHERE work_item_id = ? AND status = 'pending'`,
|
|
2976
|
-
)
|
|
2977
|
-
.run(workItemId)
|
|
2978
|
-
const leases = this.db.prepare('DELETE FROM lease WHERE work_item_id = ?').run(workItemId)
|
|
2979
|
-
return {
|
|
2980
|
-
cancelledRuns: Number(runs.changes),
|
|
2981
|
-
cancelledChecks: Number(checks.changes),
|
|
2982
|
-
withdrawnApprovals: Number(approvals.changes),
|
|
2983
|
-
cancelledOutbox: Number(outbox.changes),
|
|
2984
|
-
releasedLeases: Number(leases.changes),
|
|
2985
|
-
}
|
|
2986
|
-
}
|
|
2987
|
-
|
|
2988
|
-
private assertTransition(from: string, to: string): void {
|
|
2989
|
-
const row = this.db
|
|
2990
|
-
.prepare('SELECT command FROM work_item_transition WHERE from_status = ? AND to_status = ?')
|
|
2991
|
-
.get(from, to) as { command: string } | undefined
|
|
2992
|
-
if (!row) throw stateInvalid(`状态转换不在白名单:${from} → ${to}`)
|
|
2993
|
-
}
|
|
2994
|
-
|
|
2995
|
-
private allowedActions(principal: Principal, item: WorkItemRow): string[] {
|
|
2996
|
-
const actions: string[] = []
|
|
2997
|
-
const has = (capability: string): boolean => principal.kind === 'system' || principal.capabilities.has(capability)
|
|
2998
|
-
if (item.status === 'ready' && has(CAPABILITY.workItemAccept) && principal.kind === 'human') actions.push('accept')
|
|
2999
|
-
if (item.status === 'ready' && has(CAPABILITY.workItemClaim) && principal.kind === 'bot') actions.push('claim')
|
|
3000
|
-
if (item.status === 'waiting_review' && has(CAPABILITY.approvalDecide)) actions.push('decideApproval')
|
|
3001
|
-
if (['active', 'waiting_check', 'waiting_review', 'blocked'].includes(item.status) && has(CAPABILITY.workItemRequestClosure) && !this.db.prepare("SELECT id FROM approval WHERE subject_id=? AND subject_kind='work_item_close' AND status='pending' AND expires_at>? LIMIT 1").get(item.id,this.now())) {
|
|
3002
|
-
actions.push('requestClosure')
|
|
3003
|
-
}
|
|
3004
|
-
if (has(CAPABILITY.evidenceAppend)) actions.push('addEvidence')
|
|
3005
|
-
if (['ready','active'].includes(item.status) && has(principal.kind === 'bot' ? CAPABILITY.workItemExecute : CAPABILITY.workItemManage)) actions.push('reportBlocker')
|
|
3006
|
-
if (principal.kind === 'human' && has(CAPABILITY.workItemManage)) {
|
|
3007
|
-
if (!(TERMINAL_ITEM_STATUSES as readonly string[]).includes(item.status)) { actions.push('cancel'); if(item.status !== 'paused') actions.push('pause') }
|
|
3008
|
-
if (item.status === 'paused') actions.push('resume')
|
|
3009
|
-
if (item.status === 'completed') actions.push('reopen')
|
|
3010
|
-
if (item.status === 'failed') actions.push('retry')
|
|
3011
|
-
if (item.status === 'blocked') actions.push('unblock')
|
|
3012
|
-
if (!(TERMINAL_ITEM_STATUSES as readonly string[]).includes(item.status) && item.assignee_id && this.db.prepare("SELECT id FROM member WHERE id=? AND kind='bot' AND status='active'").get(item.assignee_id) && !this.db.prepare("SELECT id FROM task_run WHERE work_item_id=? AND status='running'").get(item.id)) actions.push('rotateThread')
|
|
3013
|
-
}
|
|
3014
|
-
return actions
|
|
3015
|
-
}
|
|
3016
|
-
|
|
3017
|
-
// ───────────────────────── 内部:取行与工具 ─────────────────────────
|
|
3018
|
-
|
|
3019
|
-
private workItemOrThrow(id: string): WorkItemRow {
|
|
3020
|
-
const row = this.db.prepare('SELECT * FROM work_item WHERE id = ?').get(id) as WorkItemRow | undefined
|
|
3021
|
-
if (!row) throw stateInvalid(`工作项不存在:${id}`)
|
|
3022
|
-
return row
|
|
3023
|
-
}
|
|
3024
|
-
|
|
3025
|
-
private runOrThrow(id: string): RunRow {
|
|
3026
|
-
const row = this.db.prepare('SELECT * FROM task_run WHERE id = ?').get(id) as RunRow | undefined
|
|
3027
|
-
if (!row) throw stateInvalid(`运行不存在:${id}`)
|
|
3028
|
-
return row
|
|
3029
|
-
}
|
|
3030
|
-
|
|
3031
|
-
private checkOrThrow(id: string): CheckRow {
|
|
3032
|
-
const row = this.db.prepare('SELECT * FROM work_item_check WHERE id = ?').get(id) as CheckRow | undefined
|
|
3033
|
-
if (!row) throw stateInvalid(`复查不存在:${id}`)
|
|
3034
|
-
return row
|
|
3035
|
-
}
|
|
3036
|
-
|
|
3037
|
-
private approvalOrThrow(id: string): ApprovalRow {
|
|
3038
|
-
const row = this.db.prepare('SELECT * FROM approval WHERE id = ?').get(id) as ApprovalRow | undefined
|
|
3039
|
-
if (!row) throw stateInvalid(`审批不存在:${id}`)
|
|
3040
|
-
return row
|
|
3041
|
-
}
|
|
3042
|
-
|
|
3043
|
-
private memberOrThrow(id: string): { kind: string; id: string } {
|
|
3044
|
-
const row = this.db.prepare('SELECT id, kind FROM member WHERE id = ?').get(id) as
|
|
3045
|
-
| { id: string; kind: string }
|
|
3046
|
-
| undefined
|
|
3047
|
-
if (!row) throw stateInvalid(`成员不存在:${id}`)
|
|
3048
|
-
return row
|
|
3049
|
-
}
|
|
3050
|
-
|
|
3051
|
-
private templateVersionTx(id: string | null | undefined): TemplateVersionRow | null {
|
|
3052
|
-
if (!id) return null
|
|
3053
|
-
const row = this.db.prepare('SELECT * FROM work_template_version WHERE id = ?').get(id) as
|
|
3054
|
-
| TemplateVersionRow
|
|
3055
|
-
| undefined
|
|
3056
|
-
return row ?? null
|
|
3057
|
-
}
|
|
3058
|
-
|
|
3059
|
-
private evidenceRows(workItemId: string): EvidenceRow[] {
|
|
3060
|
-
return this.db
|
|
3061
|
-
.prepare('SELECT * FROM evidence WHERE work_item_id = ? ORDER BY id')
|
|
3062
|
-
.all(workItemId) as EvidenceRow[]
|
|
3063
|
-
}
|
|
3064
|
-
|
|
3065
|
-
/** 领域函数只认 camelCase 快照,避免把 snake_case 行直接喂给哈希(那样会全员 undefined)。 */
|
|
3066
|
-
private evidenceSnapshots(workItemId: string): EvidenceSnapshotRow[] {
|
|
3067
|
-
return this.evidenceRows(workItemId).map((row) => ({
|
|
3068
|
-
id: row.id,
|
|
3069
|
-
kind: row.kind,
|
|
3070
|
-
contentHash: row.content_hash,
|
|
3071
|
-
observedAt: row.observed_at,
|
|
3072
|
-
status: row.status,
|
|
3073
|
-
}))
|
|
3074
|
-
}
|
|
3075
|
-
|
|
3076
|
-
private findOpenByDedupeKey(dedupeKey: string | null | undefined): WorkItemRow | null {
|
|
3077
|
-
if (!dedupeKey) return null
|
|
3078
|
-
const placeholders = OPEN_ITEM_STATUSES.map(() => '?').join(',')
|
|
3079
|
-
const row = this.db
|
|
3080
|
-
.prepare(`SELECT * FROM work_item WHERE dedupe_key = ? AND status IN (${placeholders}) LIMIT 1`)
|
|
3081
|
-
.get(dedupeKey, ...OPEN_ITEM_STATUSES) as WorkItemRow | undefined
|
|
3082
|
-
return row ?? null
|
|
3083
|
-
}
|
|
3084
|
-
|
|
3085
|
-
private readRuntimeEpoch(): number {
|
|
3086
|
-
const row = this.db.prepare("SELECT value FROM hams_meta WHERE key = 'runtime_epoch'").get() as
|
|
3087
|
-
| { value: string }
|
|
3088
|
-
| undefined
|
|
3089
|
-
return Number(row?.value ?? 0)
|
|
3090
|
-
}
|
|
3091
|
-
|
|
3092
|
-
private parseJsonObject(json: string | null | undefined): Record<string, unknown> {
|
|
3093
|
-
if (!json || !json.trim()) return {}
|
|
3094
|
-
try {
|
|
3095
|
-
const parsed = JSON.parse(json) as unknown
|
|
3096
|
-
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}
|
|
3097
|
-
} catch {
|
|
3098
|
-
return {}
|
|
3099
|
-
}
|
|
3100
|
-
}
|
|
3101
|
-
}
|
|
3102
|
-
|
|
3103
|
-
export type { CriteriaSnapshot }
|