dsh-hams 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/index.js +11335 -0
- package/package.json +9 -5
- package/src/domain/check.ts +0 -193
- package/src/domain/errors.ts +0 -59
- package/src/domain/evidence.ts +0 -134
- package/src/domain/ids.ts +0 -31
- package/src/host/chat-runtime.ts +0 -489
- package/src/host/dsh-adapter.ts +0 -867
- package/src/host/session-adapter.ts +0 -276
- package/src/http/handler.ts +0 -1132
- package/src/index.js +0 -53
- package/src/service/audit.ts +0 -92
- package/src/service/chat.ts +0 -1100
- package/src/service/contracts.ts +0 -193
- package/src/service/defaults.ts +0 -182
- package/src/service/deliverables.ts +0 -240
- package/src/service/hams.ts +0 -3103
- package/src/service/knowledge.ts +0 -940
- package/src/service/member-config.ts +0 -39
- package/src/service/members.ts +0 -506
- package/src/service/memory.ts +0 -375
- package/src/service/permissions.ts +0 -118
- package/src/service/project-lifecycle.ts +0 -233
- package/src/service/projects.ts +0 -840
- package/src/service/receipts.ts +0 -63
- package/src/service/templates.ts +0 -140
- package/src/service/tick.ts +0 -751
- package/src/service/tools.ts +0 -711
- package/src/service/types.ts +0 -219
- package/src/service/work-item-create.ts +0 -159
- package/src/service/workspace-profile.ts +0 -443
- package/src/store/backup.ts +0 -239
- package/src/store/bootstrap.ts +0 -225
- package/src/store/db.ts +0 -179
- package/src/store/lock.ts +0 -194
- package/src/store/migrations.ts +0 -211
- package/src/store/schema/001_p1.sql +0 -846
- package/src/store/schema/001_p1.sql.ts +0 -865
- package/src/store/schema/002_knowledge.sql +0 -54
- package/src/store/schema/002_knowledge.sql.ts +0 -68
- package/src/store/schema/003_projects.sql.ts +0 -3
- package/src/store/schema/004_team.sql.ts +0 -50
- package/src/store/schema/005_target_check.sql.ts +0 -22
- package/src/store/schema/006_target_flow.sql.ts +0 -19
- package/src/store/schema/007_project_delete.sql.ts +0 -17
- package/src/store/schema/008_memory.sql.ts +0 -47
- package/src/store/schema/generate-projects.mjs +0 -16
- package/src/store/seed.ts +0 -114
package/src/service/tick.ts
DELETED
|
@@ -1,751 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 调度与恢复(M4)。
|
|
3
|
-
*
|
|
4
|
-
* 两个入口:
|
|
5
|
-
* - `runTick(db, opts)`:5 秒一拍的唯一业务调度权威。每步一个 `BEGIN IMMEDIATE`,
|
|
6
|
-
* 单步失败不中断其余;一拍总量有上限(默认 50)。
|
|
7
|
-
* - `recover(db, opts)`:宿主启动时按 `runtime_epoch` 对账,只收拾「上个进程留下的」,
|
|
8
|
-
* 不碰业务代次。run 的 queued→cancelled、running→orphaned,outbox 的 sending→unknown。
|
|
9
|
-
*
|
|
10
|
-
* 与设计的偏差:
|
|
11
|
-
* - 审批过期只置 expired,**不生成 attention_item**:`attention_item.reason` 枚举里没有
|
|
12
|
-
* `approval_expired`,硬塞 `no_progress` 会污染「为什么需要人关注」的语义;
|
|
13
|
-
* - 到期复查起运行需要「同工作项同 bot 的 active 线程」;没有线程就记 missed + 关注项,
|
|
14
|
-
* 不凭空造会话(会话由宿主创建,属于 M5 的适配器);
|
|
15
|
-
* - 复查的漏跑策略按模板 `misfire_policy` 做了最小实现:`skip` 直接记账,`catch_up_latest`
|
|
16
|
-
* 同一工作项只补最新一条,其余取消;
|
|
17
|
-
* - outbox 发送需要连接器;没注入 `sendOutbox` 时只统计「待发送」,绝不假装发过。
|
|
18
|
-
*/
|
|
19
|
-
import type { DatabaseSync } from 'node:sqlite'
|
|
20
|
-
import { createIdFactory, type IdFactory } from '../domain/ids.ts'
|
|
21
|
-
import { wallToUtc } from '../domain/check.ts'
|
|
22
|
-
import { writeChange, writeEvent } from './audit.ts'
|
|
23
|
-
import { insertWorkItem } from './work-item-create.ts'
|
|
24
|
-
import type { CheckRow, WorkItemRow } from './types.ts'
|
|
25
|
-
import { existsSync, unlinkSync } from 'node:fs'
|
|
26
|
-
import path from 'node:path'
|
|
27
|
-
|
|
28
|
-
export interface TickOptions {
|
|
29
|
-
db: DatabaseSync
|
|
30
|
-
runtimeEpoch: number
|
|
31
|
-
now?: () => number
|
|
32
|
-
ids?: IdFactory
|
|
33
|
-
maxItems?: number
|
|
34
|
-
timeZone?: string
|
|
35
|
-
dataDir?: string
|
|
36
|
-
evidenceRetentionDays?: number
|
|
37
|
-
/** 注入后才真正发送出站动作;未注入时只统计待发送,不假装成功。 */
|
|
38
|
-
sendOutbox?: OutboxSender
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export type OutboxSender = (row: {
|
|
42
|
-
id: string
|
|
43
|
-
action: string
|
|
44
|
-
payloadJson: string
|
|
45
|
-
idempotencyKey: string
|
|
46
|
-
attempts: number
|
|
47
|
-
}) => { status: 'sent' | 'failed' | 'unknown'; externalRef?: string; error?: string }
|
|
48
|
-
|
|
49
|
-
export interface TickReport {
|
|
50
|
-
tickSeq: number
|
|
51
|
-
runtimeEpoch: number
|
|
52
|
-
startedAt: number
|
|
53
|
-
finishedAt: number
|
|
54
|
-
fired: number
|
|
55
|
-
steps: {
|
|
56
|
-
expiredApprovals: number
|
|
57
|
-
reclaimedLeases: number
|
|
58
|
-
queuedCheckRuns: number
|
|
59
|
-
missedChecks: number
|
|
60
|
-
cancelledChecks: number
|
|
61
|
-
firedSchedules: number
|
|
62
|
-
scheduledWorkItems: number
|
|
63
|
-
outboxSent: number
|
|
64
|
-
outboxFailed: number
|
|
65
|
-
outboxUnknown: number
|
|
66
|
-
outboxPending: number
|
|
67
|
-
rollupRows: number
|
|
68
|
-
purgedEvidence: number
|
|
69
|
-
}
|
|
70
|
-
errors: string[]
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export interface RecoverReport {
|
|
74
|
-
runtimeEpoch: number
|
|
75
|
-
startedAt: number
|
|
76
|
-
finishedAt: number
|
|
77
|
-
cancelledQueuedRuns: number
|
|
78
|
-
orphanedRunningRuns: number
|
|
79
|
-
outboxUnknown: number
|
|
80
|
-
expiredApprovals: number
|
|
81
|
-
missedChecks: number
|
|
82
|
-
tickSeq: number
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const DEFAULT_MAX_ITEMS = 50
|
|
86
|
-
const DEFAULT_TZ = 'Asia/Shanghai'
|
|
87
|
-
const DEFAULT_RETENTION_DAYS = 365
|
|
88
|
-
const OPEN_DEDUPE_STATUSES = ['ready', 'active', 'waiting_check', 'waiting_review', 'blocked', 'paused', 'failed']
|
|
89
|
-
|
|
90
|
-
interface StepBag {
|
|
91
|
-
errors: string[]
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function rollbackIfNeeded(db: DatabaseSync): void {
|
|
95
|
-
if (db.isTransaction) db.exec('ROLLBACK')
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function runStep<T>(db: DatabaseSync, bag: StepBag, name: string, fn: () => T): T | null {
|
|
99
|
-
rollbackIfNeeded(db)
|
|
100
|
-
db.exec('BEGIN IMMEDIATE')
|
|
101
|
-
try {
|
|
102
|
-
const result = fn()
|
|
103
|
-
db.exec('COMMIT')
|
|
104
|
-
return result
|
|
105
|
-
} catch (error) {
|
|
106
|
-
rollbackIfNeeded(db)
|
|
107
|
-
bag.errors.push(`${name}: ${error instanceof Error ? error.message : String(error)}`)
|
|
108
|
-
return null
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function attention(
|
|
113
|
-
db: DatabaseSync,
|
|
114
|
-
ids: IdFactory,
|
|
115
|
-
input: {
|
|
116
|
-
workItemId?: string | null
|
|
117
|
-
projectId?: string | null
|
|
118
|
-
reason: 'orphaned_run' | 'retries_exhausted' | 'no_progress' | 'severity_raised' | 'outbox_unknown' | 'missed_check'
|
|
119
|
-
detail: string
|
|
120
|
-
toMemberId: string
|
|
121
|
-
at: number
|
|
122
|
-
},
|
|
123
|
-
): void {
|
|
124
|
-
db.prepare(
|
|
125
|
-
`INSERT INTO attention_item (id, work_item_id, project_id, reason, detail, to_member_id, created_at)
|
|
126
|
-
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
127
|
-
).run(
|
|
128
|
-
ids(),
|
|
129
|
-
input.workItemId ?? null,
|
|
130
|
-
input.projectId ?? null,
|
|
131
|
-
input.reason,
|
|
132
|
-
input.detail,
|
|
133
|
-
input.toMemberId,
|
|
134
|
-
input.at,
|
|
135
|
-
)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function humanOwnerOf(db: DatabaseSync, workItemId: string | null | undefined): string | null {
|
|
139
|
-
if (!workItemId) return null
|
|
140
|
-
const row = db
|
|
141
|
-
.prepare(
|
|
142
|
-
`SELECT w.owner_human_id AS owner, m.kind AS kind
|
|
143
|
-
FROM work_item w JOIN member m ON m.id = w.owner_human_id
|
|
144
|
-
WHERE w.id = ?`,
|
|
145
|
-
)
|
|
146
|
-
.get(workItemId) as { owner: string; kind: string } | undefined
|
|
147
|
-
return row && row.kind === 'human' ? row.owner : null
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function hasTable(db: DatabaseSync, name: string): boolean {
|
|
151
|
-
return Boolean(
|
|
152
|
-
db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name),
|
|
153
|
-
)
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export function readRuntimeEpoch(db: DatabaseSync): number {
|
|
157
|
-
const row = db.prepare("SELECT value FROM hams_meta WHERE key = 'runtime_epoch'").get() as
|
|
158
|
-
| { value: string }
|
|
159
|
-
| undefined
|
|
160
|
-
return Number(row?.value ?? 0)
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function parseInterval(spec: string): number | null {
|
|
164
|
-
const match = /^(\d+)\s*([smhd])$/.exec(spec.trim())
|
|
165
|
-
if (!match) return null
|
|
166
|
-
const value = Number(match[1])
|
|
167
|
-
const unit = match[2]
|
|
168
|
-
const factor = unit === 's' ? 1_000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000
|
|
169
|
-
return value * factor
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export function recover(options: TickOptions): RecoverReport {
|
|
173
|
-
const db = options.db
|
|
174
|
-
const now = options.now ?? (() => Date.now())
|
|
175
|
-
const ids = options.ids ?? createIdFactory(now)
|
|
176
|
-
const runtimeEpoch = options.runtimeEpoch
|
|
177
|
-
const startedAt = now()
|
|
178
|
-
const max = options.maxItems ?? DEFAULT_MAX_ITEMS
|
|
179
|
-
const bag: StepBag = { errors: [] }
|
|
180
|
-
|
|
181
|
-
const runs = runStep(db, bag, 'recover-runs', () => {
|
|
182
|
-
const stale = db
|
|
183
|
-
.prepare(
|
|
184
|
-
`SELECT id, work_item_id, status, member_id FROM task_run
|
|
185
|
-
WHERE runtime_epoch < ? AND status IN ('queued','running')
|
|
186
|
-
LIMIT ?`,
|
|
187
|
-
)
|
|
188
|
-
.all(runtimeEpoch, max) as { id: string; work_item_id: string; status: string; member_id: string }[]
|
|
189
|
-
let cancelled = 0
|
|
190
|
-
let orphaned = 0
|
|
191
|
-
for (const run of stale) {
|
|
192
|
-
// 租约也要收:否则租约还占着 work_item 主键,新的认领会撞 lease 的唯一约束。
|
|
193
|
-
db.prepare('DELETE FROM lease WHERE run_id = ?').run(run.id)
|
|
194
|
-
if (run.status === 'queued') {
|
|
195
|
-
db.prepare("UPDATE task_run SET status = 'cancelled', finished_at = ?, error_kind = 'host_restart_before_start' WHERE id = ?").run(startedAt, run.id)
|
|
196
|
-
cancelled += 1
|
|
197
|
-
} else {
|
|
198
|
-
db.prepare("UPDATE task_run SET status = 'orphaned', finished_at = ?, error_kind = 'host_restart_during_run' WHERE id = ?").run(startedAt, run.id)
|
|
199
|
-
orphaned += 1
|
|
200
|
-
const owner = humanOwnerOf(db, run.work_item_id)
|
|
201
|
-
if (owner) {
|
|
202
|
-
attention(db, ids, {
|
|
203
|
-
workItemId: run.work_item_id,
|
|
204
|
-
reason: 'orphaned_run',
|
|
205
|
-
detail: `宿主重启时运行 ${run.id} 仍在执行,已标孤儿,需人工核对是否已产生外部副作用`,
|
|
206
|
-
toMemberId: owner,
|
|
207
|
-
at: startedAt,
|
|
208
|
-
})
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
return { cancelled, orphaned }
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
const outbox = runStep(db, bag, 'recover-outbox', () => {
|
|
216
|
-
const sending = db
|
|
217
|
-
.prepare("SELECT id, work_item_id, requested_by, action FROM outbox WHERE status = 'sending' LIMIT ?")
|
|
218
|
-
.all(max) as { id: string; work_item_id: string | null; requested_by: string; action: string }[]
|
|
219
|
-
let unknown = 0
|
|
220
|
-
for (const row of sending) {
|
|
221
|
-
db.prepare("UPDATE outbox SET status = 'unknown', last_error = 'host_restart_during_send' WHERE id = ?").run(row.id)
|
|
222
|
-
unknown += 1
|
|
223
|
-
const requesterIsHuman = (db.prepare('SELECT kind FROM member WHERE id = ?').get(row.requested_by) as { kind: string } | undefined)?.kind === 'human'
|
|
224
|
-
const to = humanOwnerOf(db, row.work_item_id) ?? (requesterIsHuman ? row.requested_by : null)
|
|
225
|
-
if (to) {
|
|
226
|
-
attention(db, ids, {
|
|
227
|
-
workItemId: row.work_item_id,
|
|
228
|
-
reason: 'outbox_unknown',
|
|
229
|
-
detail: `出站动作 ${row.action}(${row.id})发送结果未知,需对账,不要直接重发`,
|
|
230
|
-
toMemberId: to,
|
|
231
|
-
at: startedAt,
|
|
232
|
-
})
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
return unknown
|
|
236
|
-
})
|
|
237
|
-
|
|
238
|
-
const approvals = runStep(db, bag, 'recover-approvals', () => {
|
|
239
|
-
const expired = db
|
|
240
|
-
.prepare("UPDATE approval SET status = 'expired' WHERE status = 'pending' AND expires_at <= ?")
|
|
241
|
-
.run(startedAt)
|
|
242
|
-
return Number(expired.changes)
|
|
243
|
-
})
|
|
244
|
-
|
|
245
|
-
const missed = runStep(db, bag, 'recover-checks', () => {
|
|
246
|
-
const row = db
|
|
247
|
-
.prepare("SELECT COUNT(*) AS c FROM work_item_check WHERE status = 'scheduled' AND planned_due_at <= ?")
|
|
248
|
-
.get(startedAt) as { c: number }
|
|
249
|
-
return Number(row.c)
|
|
250
|
-
})
|
|
251
|
-
|
|
252
|
-
const finishedAt = now()
|
|
253
|
-
const report: RecoverReport = {
|
|
254
|
-
runtimeEpoch,
|
|
255
|
-
startedAt,
|
|
256
|
-
finishedAt,
|
|
257
|
-
cancelledQueuedRuns: runs?.cancelled ?? 0,
|
|
258
|
-
orphanedRunningRuns: runs?.orphaned ?? 0,
|
|
259
|
-
outboxUnknown: outbox ?? 0,
|
|
260
|
-
expiredApprovals: approvals ?? 0,
|
|
261
|
-
missedChecks: missed ?? 0,
|
|
262
|
-
tickSeq: 0,
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
const logged = runStep(db, bag, 'recover-log', () => {
|
|
266
|
-
db.prepare(
|
|
267
|
-
`INSERT INTO tick_log (runtime_epoch, started_at, finished_at, fired, recovered, error_text)
|
|
268
|
-
VALUES (?, ?, ?, 0, 1, ?)`,
|
|
269
|
-
).run(runtimeEpoch, startedAt, finishedAt, bag.errors.length ? bag.errors.join('; ') : null)
|
|
270
|
-
return Number((db.prepare('SELECT last_insert_rowid() AS id').get() as { id: number }).id)
|
|
271
|
-
})
|
|
272
|
-
report.tickSeq = logged ?? 0
|
|
273
|
-
return report
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
export function runTick(options: TickOptions): TickReport {
|
|
277
|
-
const db = options.db
|
|
278
|
-
const now = options.now ?? (() => Date.now())
|
|
279
|
-
const ids = options.ids ?? createIdFactory(now)
|
|
280
|
-
const runtimeEpoch = options.runtimeEpoch ?? readRuntimeEpoch(db)
|
|
281
|
-
const timeZone = options.timeZone ?? DEFAULT_TZ
|
|
282
|
-
const max = options.maxItems ?? DEFAULT_MAX_ITEMS
|
|
283
|
-
const startedAt = now()
|
|
284
|
-
const bag: StepBag = { errors: [] }
|
|
285
|
-
|
|
286
|
-
const steps: TickReport['steps'] = {
|
|
287
|
-
expiredApprovals: 0,
|
|
288
|
-
reclaimedLeases: 0,
|
|
289
|
-
queuedCheckRuns: 0,
|
|
290
|
-
missedChecks: 0,
|
|
291
|
-
cancelledChecks: 0,
|
|
292
|
-
firedSchedules: 0,
|
|
293
|
-
scheduledWorkItems: 0,
|
|
294
|
-
outboxSent: 0,
|
|
295
|
-
outboxFailed: 0,
|
|
296
|
-
outboxUnknown: 0,
|
|
297
|
-
outboxPending: 0,
|
|
298
|
-
rollupRows: 0,
|
|
299
|
-
purgedEvidence: 0,
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// 1. 审批过期只失效:没有自动通过、没有上报。
|
|
303
|
-
const expired = runStep(db, bag, 'expire-approvals', () => {
|
|
304
|
-
const result = db
|
|
305
|
-
.prepare("UPDATE approval SET status = 'expired' WHERE status = 'pending' AND expires_at <= ?")
|
|
306
|
-
.run(startedAt)
|
|
307
|
-
return Number(result.changes)
|
|
308
|
-
})
|
|
309
|
-
steps.expiredApprovals = expired ?? 0
|
|
310
|
-
|
|
311
|
-
// 2. 回收过期租约:运行结果未知,标 unknown,不自动重跑。
|
|
312
|
-
const leases = runStep(db, bag, 'reclaim-leases', () => {
|
|
313
|
-
const rows = db
|
|
314
|
-
.prepare('SELECT work_item_id, run_id FROM lease WHERE lease_until <= ? LIMIT ?')
|
|
315
|
-
.all(startedAt, max) as { work_item_id: string; run_id: string }[]
|
|
316
|
-
for (const row of rows) {
|
|
317
|
-
db.prepare('DELETE FROM lease WHERE work_item_id = ?').run(row.work_item_id)
|
|
318
|
-
db.prepare("UPDATE task_run SET status = 'unknown', finished_at = ?, error_kind = 'lease_expired' WHERE id = ? AND status = 'running'").run(startedAt, row.run_id)
|
|
319
|
-
}
|
|
320
|
-
return rows.length
|
|
321
|
-
})
|
|
322
|
-
steps.reclaimedLeases = leases ?? 0
|
|
323
|
-
|
|
324
|
-
// 3. 到期复查:按 misfire_policy 处理,没有 active 线程就记 missed。
|
|
325
|
-
const checks = runStep(db, bag, 'due-checks', () => {
|
|
326
|
-
const due = db
|
|
327
|
-
.prepare(
|
|
328
|
-
`SELECT c.*, v.misfire_policy AS misfire_policy
|
|
329
|
-
FROM work_item_check c
|
|
330
|
-
LEFT JOIN work_template_version v ON v.id = c.template_version_id
|
|
331
|
-
WHERE c.status = 'scheduled' AND c.planned_due_at <= ?
|
|
332
|
-
ORDER BY c.work_item_id, c.planned_due_at
|
|
333
|
-
LIMIT ?`,
|
|
334
|
-
)
|
|
335
|
-
.all(startedAt, max * 2) as (CheckRow & { misfire_policy: string | null })[]
|
|
336
|
-
|
|
337
|
-
// catch_up_latest:同一工作项只补最新一条,其余取消。
|
|
338
|
-
const latestByItem = new Map<string, string>()
|
|
339
|
-
for (const check of due) {
|
|
340
|
-
if ((check.misfire_policy ?? 'each') !== 'catch_up_latest') continue
|
|
341
|
-
const current = latestByItem.get(check.work_item_id)
|
|
342
|
-
if (!current || (db.prepare('SELECT planned_due_at FROM work_item_check WHERE id = ?').get(current) as { planned_due_at: number }).planned_due_at < check.planned_due_at) {
|
|
343
|
-
latestByItem.set(check.work_item_id, check.id)
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
let queuedRuns = 0
|
|
348
|
-
let missed = 0
|
|
349
|
-
let cancelled = 0
|
|
350
|
-
for (const check of due) {
|
|
351
|
-
const policy = check.misfire_policy ?? 'each'
|
|
352
|
-
const item = db.prepare('SELECT * FROM work_item WHERE id = ?').get(check.work_item_id) as WorkItemRow | undefined
|
|
353
|
-
if (!item || ['completed', 'cancelled', 'failed'].includes(item.status)) {
|
|
354
|
-
db.prepare("UPDATE work_item_check SET status = 'cancelled' WHERE id = ?").run(check.id)
|
|
355
|
-
cancelled += 1
|
|
356
|
-
continue
|
|
357
|
-
}
|
|
358
|
-
if (policy === 'catch_up_latest' && latestByItem.get(item.id) !== check.id) {
|
|
359
|
-
db.prepare("UPDATE work_item_check SET status = 'cancelled' WHERE id = ?").run(check.id)
|
|
360
|
-
cancelled += 1
|
|
361
|
-
continue
|
|
362
|
-
}
|
|
363
|
-
if (policy === 'skip') {
|
|
364
|
-
db.prepare("UPDATE work_item_check SET status = 'unknown', evaluated_at = ? WHERE id = ?").run(startedAt, check.id)
|
|
365
|
-
const owner = humanOwnerOf(db, item.id)
|
|
366
|
-
if (owner) {
|
|
367
|
-
attention(db, ids, {
|
|
368
|
-
workItemId: item.id,
|
|
369
|
-
reason: 'missed_check',
|
|
370
|
-
detail: `复查 ${check.id} 到期但模板策略为 skip,未执行`,
|
|
371
|
-
toMemberId: owner,
|
|
372
|
-
at: startedAt,
|
|
373
|
-
})
|
|
374
|
-
}
|
|
375
|
-
missed += 1
|
|
376
|
-
continue
|
|
377
|
-
}
|
|
378
|
-
if (item.status === 'waiting_review' || item.status === 'paused') continue
|
|
379
|
-
const thread = db
|
|
380
|
-
.prepare(
|
|
381
|
-
`SELECT * FROM work_item_thread
|
|
382
|
-
WHERE work_item_id = ? AND member_id = ? AND status = 'active'
|
|
383
|
-
ORDER BY generation DESC LIMIT 1`,
|
|
384
|
-
)
|
|
385
|
-
.get(item.id, item.assignee_id ?? '') as { id: string; member_id: string; member_version: number } | undefined
|
|
386
|
-
const activeRun = db
|
|
387
|
-
.prepare("SELECT 1 FROM task_run WHERE work_item_id = ? AND status IN ('queued','running')")
|
|
388
|
-
.get(item.id)
|
|
389
|
-
if (!thread || !item.assignee_id || activeRun) {
|
|
390
|
-
db.prepare("UPDATE work_item_check SET status = 'unknown', evaluated_at = ? WHERE id = ?").run(startedAt, check.id)
|
|
391
|
-
const owner = humanOwnerOf(db, item.id)
|
|
392
|
-
if (owner) {
|
|
393
|
-
attention(db, ids, {
|
|
394
|
-
workItemId: item.id,
|
|
395
|
-
reason: 'missed_check',
|
|
396
|
-
detail: thread
|
|
397
|
-
? `复查 ${check.id} 到期,但该工作项已有活跃运行,未重复起跑`
|
|
398
|
-
: `复查 ${check.id} 到期,但工作项没有在用的会话线程,无法起运行`,
|
|
399
|
-
toMemberId: owner,
|
|
400
|
-
at: startedAt,
|
|
401
|
-
})
|
|
402
|
-
}
|
|
403
|
-
missed += 1
|
|
404
|
-
continue
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
const fenceRow = db
|
|
408
|
-
.prepare('SELECT fence_seq FROM work_item WHERE id = ?')
|
|
409
|
-
.get(item.id) as { fence_seq: number }
|
|
410
|
-
const fence = Number(fenceRow.fence_seq) + 1
|
|
411
|
-
const runId = ids()
|
|
412
|
-
db.prepare(
|
|
413
|
-
`UPDATE work_item SET fence_seq = ?, revision = revision + 1, updated_at = ? WHERE id = ?`,
|
|
414
|
-
).run(fence, startedAt, item.id)
|
|
415
|
-
db.prepare(
|
|
416
|
-
`INSERT INTO task_run
|
|
417
|
-
(id, work_item_id, thread_id, check_id, member_id, member_version, skill_versions_json,
|
|
418
|
-
execution_epoch, runtime_epoch, fence, trigger, attempt, status, created_at)
|
|
419
|
-
VALUES (?, ?, ?, ?, ?, ?, '{}', ?, ?, ?, 'check', 1, 'queued', ?)`,
|
|
420
|
-
).run(
|
|
421
|
-
runId,
|
|
422
|
-
item.id,
|
|
423
|
-
thread.id,
|
|
424
|
-
check.id,
|
|
425
|
-
item.assignee_id,
|
|
426
|
-
thread.member_version,
|
|
427
|
-
item.execution_epoch,
|
|
428
|
-
runtimeEpoch,
|
|
429
|
-
fence,
|
|
430
|
-
startedAt,
|
|
431
|
-
)
|
|
432
|
-
db.prepare("UPDATE work_item_check SET status = 'running' WHERE id = ?").run(check.id)
|
|
433
|
-
const after = db.prepare('SELECT revision FROM work_item WHERE id = ?').get(item.id) as { revision: number }
|
|
434
|
-
writeEvent(db, ids(), {
|
|
435
|
-
workItemId: item.id,
|
|
436
|
-
kind: 'check_run_queued',
|
|
437
|
-
actorId: 'system',
|
|
438
|
-
actorKind: 'system',
|
|
439
|
-
payload: { checkId: check.id, runId, fence },
|
|
440
|
-
revision: after.revision,
|
|
441
|
-
executionEpoch: item.execution_epoch,
|
|
442
|
-
at: startedAt,
|
|
443
|
-
})
|
|
444
|
-
writeChange(db, { objectKind: 'task_run', objectId: runId, revision: 1, changeKind: 'check_run_queued', at: startedAt })
|
|
445
|
-
queuedRuns += 1
|
|
446
|
-
}
|
|
447
|
-
return { queuedRuns, missed, cancelled }
|
|
448
|
-
})
|
|
449
|
-
steps.queuedCheckRuns = checks?.queuedRuns ?? 0
|
|
450
|
-
steps.missedChecks = checks?.missed ?? 0
|
|
451
|
-
steps.cancelledChecks = checks?.cancelled ?? 0
|
|
452
|
-
|
|
453
|
-
// 4. 到期 schedule:先插 occurrence(主键防双触发),再物化工作项。
|
|
454
|
-
const schedules = runStep(db, bag, 'due-schedules', () => {
|
|
455
|
-
const due = db
|
|
456
|
-
.prepare(
|
|
457
|
-
`SELECT * FROM schedule
|
|
458
|
-
WHERE enabled = 1 AND next_fire_at <= ?
|
|
459
|
-
ORDER BY next_fire_at
|
|
460
|
-
LIMIT ?`,
|
|
461
|
-
)
|
|
462
|
-
.all(startedAt, max) as {
|
|
463
|
-
id: string
|
|
464
|
-
project_id: string | null
|
|
465
|
-
template_version_id: string | null
|
|
466
|
-
member_id: string | null
|
|
467
|
-
owner_human_id: string
|
|
468
|
-
kind: string
|
|
469
|
-
spec: string
|
|
470
|
-
timezone: string
|
|
471
|
-
max_fires: number | null
|
|
472
|
-
fired_count: number
|
|
473
|
-
next_fire_at: number
|
|
474
|
-
}[]
|
|
475
|
-
|
|
476
|
-
let fired = 0
|
|
477
|
-
let created = 0
|
|
478
|
-
for (const schedule of due) {
|
|
479
|
-
const plannedAt = schedule.next_fire_at
|
|
480
|
-
const existing = db
|
|
481
|
-
.prepare('SELECT work_item_id FROM schedule_occurrence WHERE schedule_id = ? AND planned_at = ?')
|
|
482
|
-
.get(schedule.id, plannedAt) as { work_item_id: string | null } | undefined
|
|
483
|
-
if (existing) {
|
|
484
|
-
// 已经被另一条触发路径处理过:只推进 next_fire_at,不再生单。
|
|
485
|
-
db.prepare('UPDATE schedule SET next_fire_at = ?, fired_count = fired_count + 1 WHERE id = ?').run(plannedAt + 1_000, schedule.id)
|
|
486
|
-
continue
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
let nextFire: number | null = null
|
|
490
|
-
if (schedule.kind === 'every') {
|
|
491
|
-
const interval = parseInterval(schedule.spec)
|
|
492
|
-
if (!interval) {
|
|
493
|
-
bag.errors.push(`schedule ${schedule.id}: 不支持的周期写法 ${schedule.spec}`)
|
|
494
|
-
db.prepare(
|
|
495
|
-
`INSERT INTO schedule_occurrence (schedule_id, planned_at, status, fired_at) VALUES (?, ?, 'skipped', ?)`,
|
|
496
|
-
).run(schedule.id, plannedAt, startedAt)
|
|
497
|
-
continue
|
|
498
|
-
}
|
|
499
|
-
nextFire = plannedAt + interval
|
|
500
|
-
while (nextFire <= startedAt) nextFire += interval
|
|
501
|
-
} else if (schedule.kind === 'at') {
|
|
502
|
-
nextFire = null
|
|
503
|
-
} else {
|
|
504
|
-
bag.errors.push(`schedule ${schedule.id}: tick 暂不支持 kind=${schedule.kind}`)
|
|
505
|
-
db.prepare(
|
|
506
|
-
`INSERT INTO schedule_occurrence (schedule_id, planned_at, status, fired_at) VALUES (?, ?, 'skipped', ?)`,
|
|
507
|
-
).run(schedule.id, plannedAt, startedAt)
|
|
508
|
-
continue
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
let workItemId: string | null = null
|
|
512
|
-
if (schedule.template_version_id) {
|
|
513
|
-
const dedupeKey = `schedule:${schedule.id}:${plannedAt}`
|
|
514
|
-
const open = db
|
|
515
|
-
.prepare(
|
|
516
|
-
`SELECT id FROM work_item WHERE dedupe_key = ? AND status IN (${OPEN_DEDUPE_STATUSES.map(() => '?').join(',')}) LIMIT 1`,
|
|
517
|
-
)
|
|
518
|
-
.get(dedupeKey, ...OPEN_DEDUPE_STATUSES) as { id: string } | undefined
|
|
519
|
-
if (open) {
|
|
520
|
-
workItemId = open.id
|
|
521
|
-
} else {
|
|
522
|
-
const template = db
|
|
523
|
-
.prepare(
|
|
524
|
-
`SELECT v.id, t.name FROM work_template_version v JOIN work_template t ON t.id = v.template_id WHERE v.id = ?`,
|
|
525
|
-
)
|
|
526
|
-
.get(schedule.template_version_id) as { id: string; name: string } | undefined
|
|
527
|
-
if (template) {
|
|
528
|
-
try {
|
|
529
|
-
const item = insertWorkItem(
|
|
530
|
-
{ db, ids, now: () => startedAt, timeZone },
|
|
531
|
-
{
|
|
532
|
-
domain: '运营',
|
|
533
|
-
category: '例行',
|
|
534
|
-
title: `${template.name}(${new Date(plannedAt).toISOString().slice(0, 10)})`,
|
|
535
|
-
ownerHumanId: schedule.owner_human_id,
|
|
536
|
-
projectId: schedule.project_id,
|
|
537
|
-
templateVersionId: template.id,
|
|
538
|
-
dedupeKey,
|
|
539
|
-
createdBy: 'system',
|
|
540
|
-
actorKind: 'system',
|
|
541
|
-
},
|
|
542
|
-
)
|
|
543
|
-
workItemId = item.id
|
|
544
|
-
created += 1
|
|
545
|
-
} catch (error) {
|
|
546
|
-
bag.errors.push(`schedule ${schedule.id} 生单失败:${error instanceof Error ? error.message : String(error)}`)
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
const reachedMax = schedule.max_fires !== null && schedule.fired_count + 1 >= Number(schedule.max_fires)
|
|
553
|
-
db.prepare(
|
|
554
|
-
`INSERT INTO schedule_occurrence (schedule_id, planned_at, status, work_item_id, fired_at)
|
|
555
|
-
VALUES (?, ?, 'fired', ?, ?)`,
|
|
556
|
-
).run(schedule.id, plannedAt, workItemId, startedAt)
|
|
557
|
-
db.prepare('UPDATE schedule SET next_fire_at = ?, fired_count = fired_count + 1, enabled = ?, revision = revision + 1 WHERE id = ?').run(
|
|
558
|
-
nextFire ?? plannedAt,
|
|
559
|
-
reachedMax || nextFire === null ? 0 : 1,
|
|
560
|
-
schedule.id,
|
|
561
|
-
)
|
|
562
|
-
fired += 1
|
|
563
|
-
}
|
|
564
|
-
return { fired, created }
|
|
565
|
-
})
|
|
566
|
-
steps.firedSchedules = schedules?.fired ?? 0
|
|
567
|
-
steps.scheduledWorkItems = schedules?.created ?? 0
|
|
568
|
-
|
|
569
|
-
// 5. 出站:注入发送器才真正发;三等(代次 / 对象 / 批准)由调用方在发送器里复核。
|
|
570
|
-
const outbox = runStep(db, bag, 'due-outbox', () => {
|
|
571
|
-
const due = db
|
|
572
|
-
.prepare(
|
|
573
|
-
`SELECT * FROM outbox
|
|
574
|
-
WHERE status = 'pending' AND available_at <= ?
|
|
575
|
-
AND (not_after IS NULL OR not_after > ?)
|
|
576
|
-
ORDER BY available_at LIMIT ?`,
|
|
577
|
-
)
|
|
578
|
-
.all(startedAt, startedAt, max) as {
|
|
579
|
-
id: string
|
|
580
|
-
action: string
|
|
581
|
-
payload_json: string
|
|
582
|
-
idempotency_key: string
|
|
583
|
-
attempts: number
|
|
584
|
-
}[]
|
|
585
|
-
if (!options.sendOutbox) return { sent: 0, failed: 0, unknown: 0, pending: due.length }
|
|
586
|
-
let sent = 0
|
|
587
|
-
let failed = 0
|
|
588
|
-
let unknown = 0
|
|
589
|
-
for (const row of due) {
|
|
590
|
-
db.prepare("UPDATE outbox SET status = 'sending', sending_at = ?, attempts = attempts + 1 WHERE id = ?").run(startedAt, row.id)
|
|
591
|
-
const result = options.sendOutbox({
|
|
592
|
-
id: row.id,
|
|
593
|
-
action: row.action,
|
|
594
|
-
payloadJson: row.payload_json,
|
|
595
|
-
idempotencyKey: row.idempotency_key,
|
|
596
|
-
attempts: row.attempts,
|
|
597
|
-
})
|
|
598
|
-
if (result.status === 'sent') {
|
|
599
|
-
db.prepare("UPDATE outbox SET status = 'sent', external_ref = ?, finished_at = ? WHERE id = ?").run(result.externalRef ?? null, startedAt, row.id)
|
|
600
|
-
sent += 1
|
|
601
|
-
} else if (result.status === 'failed') {
|
|
602
|
-
db.prepare("UPDATE outbox SET status = 'failed', last_error = ?, finished_at = ? WHERE id = ?").run(result.error ?? 'send_failed', startedAt, row.id)
|
|
603
|
-
failed += 1
|
|
604
|
-
} else {
|
|
605
|
-
db.prepare("UPDATE outbox SET status = 'unknown', last_error = ?, finished_at = ? WHERE id = ?").run(result.error ?? 'unknown_result', startedAt, row.id)
|
|
606
|
-
unknown += 1
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
return { sent, failed, unknown, pending: 0 }
|
|
610
|
-
})
|
|
611
|
-
steps.outboxSent = outbox?.sent ?? 0
|
|
612
|
-
steps.outboxFailed = outbox?.failed ?? 0
|
|
613
|
-
steps.outboxUnknown = outbox?.unknown ?? 0
|
|
614
|
-
steps.outboxPending = outbox?.pending ?? 0
|
|
615
|
-
|
|
616
|
-
// 6. 热力图日聚合:重算昨天,幂等 upsert。
|
|
617
|
-
// activity_daily 属于 P2(v1 只建 P1 41 表),没迁移到 P2 时这一步整段跳过,不算错误。
|
|
618
|
-
const rollup = hasTable(db, 'activity_daily')
|
|
619
|
-
? runStep(db, bag, 'rollup-activity', () => {
|
|
620
|
-
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
621
|
-
timeZone,
|
|
622
|
-
year: 'numeric',
|
|
623
|
-
month: '2-digit',
|
|
624
|
-
day: '2-digit',
|
|
625
|
-
}).formatToParts(new Date(startedAt))
|
|
626
|
-
const pick = (type: string): number => Number(parts.find((part) => part.type === type)?.value ?? '0')
|
|
627
|
-
const today = new Date(Date.UTC(pick('year'), pick('month') - 1, pick('day')))
|
|
628
|
-
const yesterday = new Date(today.getTime() - 86_400_000)
|
|
629
|
-
const date = yesterday.toISOString().slice(0, 10)
|
|
630
|
-
const start = wallToUtc(timeZone, yesterday.getUTCFullYear(), yesterday.getUTCMonth() + 1, yesterday.getUTCDate(), 0, 0)
|
|
631
|
-
const end = start + 86_400_000
|
|
632
|
-
const rows = db
|
|
633
|
-
.prepare(
|
|
634
|
-
`SELECT member_id,
|
|
635
|
-
COUNT(*) AS runs,
|
|
636
|
-
SUM(CASE WHEN status = 'succeeded' THEN 1 ELSE 0 END) AS succeeded,
|
|
637
|
-
SUM(CASE WHEN status IN ('failed','unknown','orphaned') THEN 1 ELSE 0 END) AS failed
|
|
638
|
-
FROM task_run
|
|
639
|
-
WHERE finished_at IS NOT NULL AND finished_at >= ? AND finished_at < ?
|
|
640
|
-
GROUP BY member_id`,
|
|
641
|
-
)
|
|
642
|
-
.all(start, end) as { member_id: string; runs: number; succeeded: number; failed: number }[]
|
|
643
|
-
const upsert = db.prepare(
|
|
644
|
-
`INSERT INTO activity_daily (member_id, date, runs, succeeded, failed) VALUES (?, ?, ?, ?, ?)
|
|
645
|
-
ON CONFLICT(member_id, date) DO UPDATE SET
|
|
646
|
-
runs = excluded.runs, succeeded = excluded.succeeded, failed = excluded.failed`,
|
|
647
|
-
)
|
|
648
|
-
for (const row of rows) upsert.run(row.member_id, date, Number(row.runs), Number(row.succeeded), Number(row.failed))
|
|
649
|
-
return rows.length
|
|
650
|
-
})
|
|
651
|
-
: 0
|
|
652
|
-
steps.rollupRows = rollup ?? 0
|
|
653
|
-
|
|
654
|
-
// 7. 证据保留期满:只删文件、置 purged_at,行与哈希永久保留。
|
|
655
|
-
const purged = runStep(db, bag, 'purge-evidence', () => {
|
|
656
|
-
const retentionDays = options.evidenceRetentionDays ?? DEFAULT_RETENTION_DAYS
|
|
657
|
-
const cutoff = startedAt - retentionDays * 86_400_000
|
|
658
|
-
const rows = db
|
|
659
|
-
.prepare(
|
|
660
|
-
`SELECT id, work_item_id, ref FROM evidence
|
|
661
|
-
WHERE purged_at IS NULL AND captured_at <= ? LIMIT ?`,
|
|
662
|
-
)
|
|
663
|
-
.all(cutoff, max) as { id: string; work_item_id: string; ref: string }[]
|
|
664
|
-
let removed = 0
|
|
665
|
-
for (const row of rows) {
|
|
666
|
-
if (options.dataDir) {
|
|
667
|
-
const base = path.resolve(options.dataDir, 'evidence')
|
|
668
|
-
const target = path.resolve(options.dataDir, row.ref)
|
|
669
|
-
const inside = target === base || target.startsWith(`${base}${path.sep}`)
|
|
670
|
-
if (!inside) {
|
|
671
|
-
bag.errors.push(`evidence ${row.id}: ref 越出证据目录,拒绝删除文件`)
|
|
672
|
-
continue
|
|
673
|
-
}
|
|
674
|
-
if (existsSync(target)) {
|
|
675
|
-
unlinkSync(target)
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
db.prepare('UPDATE evidence SET purged_at = ? WHERE id = ?').run(startedAt, row.id)
|
|
679
|
-
removed += 1
|
|
680
|
-
}
|
|
681
|
-
return removed
|
|
682
|
-
})
|
|
683
|
-
steps.purgedEvidence = purged ?? 0
|
|
684
|
-
|
|
685
|
-
// 8. 记账:一拍一行,失败原因写进 error_text,不吞错。
|
|
686
|
-
const finishedAt = now()
|
|
687
|
-
const firedTotal =
|
|
688
|
-
steps.expiredApprovals +
|
|
689
|
-
steps.reclaimedLeases +
|
|
690
|
-
steps.queuedCheckRuns +
|
|
691
|
-
steps.firedSchedules +
|
|
692
|
-
steps.scheduledWorkItems +
|
|
693
|
-
steps.outboxSent
|
|
694
|
-
const tickSeq = runStep(db, bag, 'tick-log', () => {
|
|
695
|
-
db.prepare(
|
|
696
|
-
`INSERT INTO tick_log (runtime_epoch, started_at, finished_at, fired, recovered, error_text)
|
|
697
|
-
VALUES (?, ?, ?, ?, 0, ?)`,
|
|
698
|
-
).run(runtimeEpoch, startedAt, finishedAt, firedTotal, bag.errors.length ? bag.errors.join('; ') : null)
|
|
699
|
-
return Number((db.prepare('SELECT last_insert_rowid() AS id').get() as { id: number }).id)
|
|
700
|
-
})
|
|
701
|
-
|
|
702
|
-
return {
|
|
703
|
-
tickSeq: tickSeq ?? 0,
|
|
704
|
-
runtimeEpoch,
|
|
705
|
-
startedAt,
|
|
706
|
-
finishedAt,
|
|
707
|
-
fired: firedTotal,
|
|
708
|
-
steps,
|
|
709
|
-
errors: bag.errors,
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
export interface TickerHandle {
|
|
714
|
-
runOnce(): TickReport
|
|
715
|
-
start(): void
|
|
716
|
-
stop(): void
|
|
717
|
-
running(): boolean
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
/** 给插件用的定时器;同一时刻只跑一拍,上一拍没结束就跳过。 */
|
|
721
|
-
export function createTicker(options: TickOptions & { intervalMs?: number }): TickerHandle {
|
|
722
|
-
const intervalMs = options.intervalMs ?? 5_000
|
|
723
|
-
let timer: ReturnType<typeof setInterval> | null = null
|
|
724
|
-
let busy = false
|
|
725
|
-
return {
|
|
726
|
-
runOnce(): TickReport {
|
|
727
|
-
return runTick(options)
|
|
728
|
-
},
|
|
729
|
-
running(): boolean {
|
|
730
|
-
return busy
|
|
731
|
-
},
|
|
732
|
-
start(): void {
|
|
733
|
-
if (timer) return
|
|
734
|
-
timer = setInterval(() => {
|
|
735
|
-
if (busy) return
|
|
736
|
-
busy = true
|
|
737
|
-
try {
|
|
738
|
-
runTick(options)
|
|
739
|
-
} finally {
|
|
740
|
-
busy = false
|
|
741
|
-
}
|
|
742
|
-
}, intervalMs)
|
|
743
|
-
if (typeof timer === 'object' && timer && 'unref' in timer) timer.unref()
|
|
744
|
-
},
|
|
745
|
-
stop(): void {
|
|
746
|
-
if (!timer) return
|
|
747
|
-
clearInterval(timer)
|
|
748
|
-
timer = null
|
|
749
|
-
},
|
|
750
|
-
}
|
|
751
|
-
}
|