dsh-hams 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +4 -0
  2. package/dist/index.js +11335 -0
  3. package/package.json +9 -5
  4. package/src/domain/check.ts +0 -193
  5. package/src/domain/errors.ts +0 -59
  6. package/src/domain/evidence.ts +0 -134
  7. package/src/domain/ids.ts +0 -31
  8. package/src/host/chat-runtime.ts +0 -489
  9. package/src/host/dsh-adapter.ts +0 -867
  10. package/src/host/session-adapter.ts +0 -276
  11. package/src/http/handler.ts +0 -1132
  12. package/src/index.js +0 -53
  13. package/src/service/audit.ts +0 -92
  14. package/src/service/chat.ts +0 -1100
  15. package/src/service/contracts.ts +0 -193
  16. package/src/service/defaults.ts +0 -182
  17. package/src/service/deliverables.ts +0 -240
  18. package/src/service/hams.ts +0 -3103
  19. package/src/service/knowledge.ts +0 -940
  20. package/src/service/member-config.ts +0 -39
  21. package/src/service/members.ts +0 -506
  22. package/src/service/memory.ts +0 -375
  23. package/src/service/permissions.ts +0 -118
  24. package/src/service/project-lifecycle.ts +0 -233
  25. package/src/service/projects.ts +0 -840
  26. package/src/service/receipts.ts +0 -63
  27. package/src/service/templates.ts +0 -140
  28. package/src/service/tick.ts +0 -751
  29. package/src/service/tools.ts +0 -711
  30. package/src/service/types.ts +0 -219
  31. package/src/service/work-item-create.ts +0 -159
  32. package/src/service/workspace-profile.ts +0 -443
  33. package/src/store/backup.ts +0 -239
  34. package/src/store/bootstrap.ts +0 -225
  35. package/src/store/db.ts +0 -179
  36. package/src/store/lock.ts +0 -194
  37. package/src/store/migrations.ts +0 -211
  38. package/src/store/schema/001_p1.sql +0 -846
  39. package/src/store/schema/001_p1.sql.ts +0 -865
  40. package/src/store/schema/002_knowledge.sql +0 -54
  41. package/src/store/schema/002_knowledge.sql.ts +0 -68
  42. package/src/store/schema/003_projects.sql.ts +0 -3
  43. package/src/store/schema/004_team.sql.ts +0 -50
  44. package/src/store/schema/005_target_check.sql.ts +0 -22
  45. package/src/store/schema/006_target_flow.sql.ts +0 -19
  46. package/src/store/schema/007_project_delete.sql.ts +0 -17
  47. package/src/store/schema/008_memory.sql.ts +0 -47
  48. package/src/store/schema/generate-projects.mjs +0 -16
  49. package/src/store/seed.ts +0 -114
@@ -1,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
- }