@v587d/capital-generation 2.12.0 → 2.13.1

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.
@@ -0,0 +1,732 @@
1
+ /**
2
+ * Fixed `capital_automation` storage domain. Every record carries the server-derived
3
+ * `profileKey`; list/read/write/cancel/notification paths filter on it before doing anything
4
+ * else. The client never supplies a profile, owner, session, preset or cwd.
5
+ */
6
+ import { createHash, randomUUID } from 'node:crypto'
7
+ import {
8
+ AttemptRecordSchema,
9
+ AutomationTaskSchema,
10
+ NotificationSchema,
11
+ OwnerRecordSchema,
12
+ RunRecordSchema,
13
+ TaskConfigSchema,
14
+ TaskOverridesSchema,
15
+ TaskRecordSchema,
16
+ isActiveRun,
17
+ parseStrict,
18
+ resolveTaskRecord,
19
+ } from './schema.js'
20
+ import { BUILTIN_BY_ID, BUILTIN_DEFINITIONS } from './builtins.js'
21
+ import {
22
+ DEFAULT_IDLE_TIMEOUT_MINUTES,
23
+ DEFAULT_MAX_STEPS,
24
+ DEFAULT_MAX_TOOL_CALLS,
25
+ DEFAULT_RUN_TIMEOUT_MINUTES,
26
+ MAX_ERRORS_PER_RUN,
27
+ MAX_NOTIFICATIONS_PER_PROFILE,
28
+ MAX_RUNS_PER_PROFILE,
29
+ MAX_RUNS_PER_TASK,
30
+ SETTLED_RUN_STATUSES,
31
+ } from './defaults.js'
32
+ import { canonicalTrigger, formatInZone, nextOccurrence } from './time.js'
33
+
34
+ export const DOMAIN_NAME = 'capital_automation'
35
+ export const ROUTE_PATH = '/capital-automation'
36
+
37
+ /** Declared as a plain object; `defineDomain` only re-validates the same fields. */
38
+ export const domainSpec = {
39
+ name: DOMAIN_NAME,
40
+ version: 1,
41
+ layout: 'single',
42
+ tables: {
43
+ tasks: { valueSchema: TaskRecordSchema },
44
+ runs: { valueSchema: RunRecordSchema },
45
+ attempts: { valueSchema: AttemptRecordSchema },
46
+ notifications: { valueSchema: NotificationSchema },
47
+ meta: { valueSchema: OwnerRecordSchema },
48
+ },
49
+ }
50
+
51
+ export function profileKeyOf(profileName) {
52
+ const digest = createHash('sha256').update(String(profileName)).digest('hex').slice(0, 24)
53
+ return `p_${digest}`
54
+ }
55
+
56
+ function builtinRecordId(profileKey, builtinId) {
57
+ return `bt_${createHash('sha256').update(`${profileKey}:${builtinId}`).digest('hex').slice(0, 24)}`
58
+ }
59
+
60
+ function iso(now) {
61
+ return new Date(now).toISOString()
62
+ }
63
+
64
+ export class StorageUnavailableError extends Error {
65
+ constructor(message) {
66
+ super(message)
67
+ this.name = 'StorageUnavailableError'
68
+ this.code = 'storage_unavailable'
69
+ }
70
+ }
71
+
72
+ export function createAutomationStore(options) {
73
+ const {
74
+ openDomain,
75
+ profile,
76
+ now = () => Date.now(),
77
+ random = () => randomUUID(),
78
+ builtins = BUILTIN_DEFINITIONS,
79
+ } = options
80
+ const rawProfileName = String(profile?.name ?? '')
81
+ const profileName = rawProfileName.slice(0, 200)
82
+ const profileKey = profileKeyOf(rawProfileName)
83
+ const profileById = new Map(builtins.map((definition) => [definition.id, definition]))
84
+ let opening
85
+ let claimChain = Promise.resolve()
86
+
87
+ function serializeClaim(operation) {
88
+ const next = claimChain.then(operation, operation)
89
+ claimChain = next.then(() => {}, () => {})
90
+ return next
91
+ }
92
+
93
+ async function open() {
94
+ if (opening === undefined) {
95
+ opening = (async () => {
96
+ const domain = await openDomain()
97
+ return {
98
+ domain,
99
+ tasks: domain.table('tasks'),
100
+ runs: domain.table('runs'),
101
+ attempts: domain.table('attempts'),
102
+ notifications: domain.table('notifications'),
103
+ meta: domain.table('meta'),
104
+ }
105
+ })()
106
+ }
107
+ try {
108
+ return await opening
109
+ } catch (error) {
110
+ // Never memoize a failed open: the facility may mount after this call.
111
+ opening = undefined
112
+ throw new StorageUnavailableError(error instanceof Error ? error.message : String(error))
113
+ }
114
+ }
115
+
116
+ function ownTask(record) {
117
+ return record !== undefined && record.profileKey === profileKey
118
+ }
119
+
120
+ function ownRun(record) {
121
+ return record !== undefined && record.profileKey === profileKey
122
+ }
123
+
124
+ async function seedBuiltins(tables) {
125
+ const at = iso(now())
126
+ for (const definition of builtins) {
127
+ const id = builtinRecordId(profileKey, definition.id)
128
+ const existing = tables.tasks.get(id)
129
+ if (!ownTask(existing)) {
130
+ const record = parseStrict(TaskRecordSchema, {
131
+ schemaVersion: 1,
132
+ id,
133
+ origin: 'builtin',
134
+ sourceBuiltinId: definition.id,
135
+ profileKey,
136
+ profileName,
137
+ state: 'dormant',
138
+ definitionVersion: definition.version,
139
+ scheduleAnchorAt: at,
140
+ createdAt: at,
141
+ updatedAt: at,
142
+ }, 'builtin task')
143
+ await tables.tasks.put(id, record)
144
+ continue
145
+ }
146
+ if (existing.definitionVersion !== definition.version || existing.profileName !== profileName) {
147
+ const next = parseStrict(TaskRecordSchema, {
148
+ ...existing,
149
+ profileName,
150
+ definitionVersion: definition.version,
151
+ updatedAt: at,
152
+ }, 'builtin task')
153
+ await tables.tasks.put(id, next)
154
+ }
155
+ }
156
+ }
157
+
158
+ async function ensure() {
159
+ const tables = await open()
160
+ if (!tables.seeded) {
161
+ await seedBuiltins(tables)
162
+ tables.seeded = true
163
+ }
164
+ return tables
165
+ }
166
+
167
+ async function listTaskRecords() {
168
+ const tables = await ensure()
169
+ const records = []
170
+ for (const [, record] of tables.tasks.entries()) {
171
+ if (ownTask(record)) records.push(record)
172
+ }
173
+ records.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id))
174
+ return records
175
+ }
176
+
177
+ async function taskRecordById(id) {
178
+ const tables = await ensure()
179
+ const record = tables.tasks.get(id)
180
+ return ownTask(record) ? record : undefined
181
+ }
182
+
183
+ function definitionOf(record) {
184
+ return record.origin === 'builtin' ? profileById.get(record.sourceBuiltinId) : undefined
185
+ }
186
+
187
+ function resolve(record) {
188
+ if (record.origin === 'builtin' && definitionOf(record) === undefined) return undefined
189
+ return resolveTaskRecord(record, definitionOf(record))
190
+ }
191
+
192
+ async function listTasks() {
193
+ const records = await listTaskRecords()
194
+ return records.map(resolve).filter((task) => task !== undefined)
195
+ }
196
+
197
+ async function getTask(id) {
198
+ const record = await taskRecordById(id)
199
+ if (record === undefined) return undefined
200
+ return resolve(record)
201
+ }
202
+
203
+ async function putTaskRecord(record) {
204
+ const tables = await ensure()
205
+ const clean = parseStrict(TaskRecordSchema, record, 'task record')
206
+ await tables.tasks.put(clean.id, clean)
207
+ return clean
208
+ }
209
+
210
+ function defaultCustomConfig() {
211
+ return {
212
+ category: 'research',
213
+ title: '新建任务',
214
+ description: '',
215
+ instruction: '按任务说明完成一次研究并给出可核查的结论。',
216
+ trigger: {
217
+ kind: 'schedule',
218
+ cadence: 'daily',
219
+ time: '09:00',
220
+ timezone: 'Asia/Shanghai',
221
+ tradingDaysOnly: true,
222
+ },
223
+ mode: 'research',
224
+ cwdKind: 'profile',
225
+ limits: {
226
+ runTimeoutMinutes: DEFAULT_RUN_TIMEOUT_MINUTES,
227
+ idleTimeoutMinutes: DEFAULT_IDLE_TIMEOUT_MINUTES,
228
+ maxSteps: DEFAULT_MAX_STEPS,
229
+ maxToolCalls: DEFAULT_MAX_TOOL_CALLS,
230
+ },
231
+ notifications: { inApp: true, onFailure: true, onSuccess: false },
232
+ }
233
+ }
234
+
235
+ async function createTask(input) {
236
+ const at = iso(now())
237
+ const config = TaskConfigSchema.parse({
238
+ ...defaultCustomConfig(),
239
+ ...input,
240
+ cwdKind: 'profile',
241
+ })
242
+ const record = parseStrict(TaskRecordSchema, {
243
+ schemaVersion: 1,
244
+ id: `task_${random().replace(/-/g, '')}`,
245
+ origin: 'custom',
246
+ profileKey,
247
+ profileName,
248
+ state: 'active',
249
+ definitionVersion: 1,
250
+ custom: config,
251
+ scheduleAnchorAt: at,
252
+ createdAt: at,
253
+ updatedAt: at,
254
+ }, 'custom task')
255
+ return putTaskRecord(record)
256
+ }
257
+
258
+ async function patchTask(id, patch) {
259
+ const record = await taskRecordById(id)
260
+ if (record === undefined) return undefined
261
+ const at = iso(now())
262
+ const triggerChanged = patch.trigger !== undefined && canonicalTrigger(patch.trigger) !== canonicalTrigger(resolve(record).trigger)
263
+ const next = { ...record, updatedAt: at }
264
+ if (record.origin === 'builtin') {
265
+ const merged = TaskOverridesSchema.parse({ ...(record.overrides ?? {}), ...patch })
266
+ next.overrides = merged
267
+ } else {
268
+ const merged = TaskConfigSchema.parse({ ...record.custom, ...patch, cwdKind: 'profile' })
269
+ next.custom = merged
270
+ }
271
+ if (triggerChanged) {
272
+ next.scheduleAnchorAt = at
273
+ delete next.lastHandledOccurrenceKey
274
+ delete next.lastScheduledFor
275
+ }
276
+ return putTaskRecord(next)
277
+ }
278
+
279
+ async function setTaskState(id, state) {
280
+ const record = await taskRecordById(id)
281
+ if (record === undefined) return undefined
282
+ const at = iso(now())
283
+ const next = { ...record, state, scheduleAnchorAt: at, updatedAt: at }
284
+ delete next.lastHandledOccurrenceKey
285
+ delete next.lastScheduledFor
286
+ if (state === 'dormant' && record.origin === 'builtin') {
287
+ delete next.overrides
288
+ const definition = definitionOf(record)
289
+ next.definitionVersion = definition?.version ?? record.definitionVersion
290
+ }
291
+ return putTaskRecord(next)
292
+ }
293
+
294
+ async function deleteTask(id) {
295
+ const record = await taskRecordById(id)
296
+ if (record === undefined) return false
297
+ if (record.origin !== 'custom') return false
298
+ const tables = await ensure()
299
+ const runIds = new Set()
300
+ for (const [runId, run] of [...tables.runs.entries()]) {
301
+ if (ownRun(run) && run.taskId === id) {
302
+ runIds.add(runId)
303
+ await tables.runs.delete(runId)
304
+ }
305
+ }
306
+ for (const [attemptId, attempt] of [...tables.attempts.entries()]) {
307
+ if (attempt.profileKey === profileKey && (attempt.taskId === id || runIds.has(attempt.runId))) {
308
+ await tables.attempts.delete(attemptId)
309
+ }
310
+ }
311
+ for (const [notificationId, notification] of [...tables.notifications.entries()]) {
312
+ if (notification.profileKey === profileKey && notification.taskId === id) {
313
+ await tables.notifications.delete(notificationId)
314
+ }
315
+ }
316
+ return tables.tasks.delete(id)
317
+ }
318
+
319
+ async function copyBuiltin(id) {
320
+ const record = await taskRecordById(id)
321
+ if (record === undefined || record.origin !== 'builtin') return undefined
322
+ const task = resolve(record)
323
+ if (task === undefined) return undefined
324
+ const at = iso(now())
325
+ const config = TaskConfigSchema.parse({
326
+ category: task.category,
327
+ title: `${task.title}(副本)`.slice(0, 120),
328
+ description: task.description,
329
+ trigger: task.trigger,
330
+ instruction: task.instruction,
331
+ mode: task.mode,
332
+ cwdKind: 'profile',
333
+ ...(task.workspaceId === undefined ? {} : { workspaceId: task.workspaceId }),
334
+ limits: task.limits,
335
+ notifications: task.notifications,
336
+ })
337
+ const custom = parseStrict(TaskRecordSchema, {
338
+ schemaVersion: 1,
339
+ id: `task_${random().replace(/-/g, '')}`,
340
+ origin: 'custom',
341
+ profileKey,
342
+ profileName,
343
+ state: 'paused',
344
+ definitionVersion: 1,
345
+ custom: config,
346
+ scheduleAnchorAt: at,
347
+ createdAt: at,
348
+ updatedAt: at,
349
+ }, 'copied task')
350
+ return putTaskRecord(custom)
351
+ }
352
+
353
+ async function setLastHandled(id, occurrenceKey, scheduledFor) {
354
+ const record = await taskRecordById(id)
355
+ if (record === undefined) return
356
+ const next = {
357
+ ...record,
358
+ lastHandledOccurrenceKey: occurrenceKey,
359
+ updatedAt: iso(now()),
360
+ }
361
+ if (scheduledFor === undefined) delete next.lastScheduledFor
362
+ else next.lastScheduledFor = scheduledFor
363
+ await putTaskRecord(next)
364
+ }
365
+
366
+ async function createRun(input) {
367
+ const tables = await ensure()
368
+ const at = iso(now())
369
+ const run = parseStrict(RunRecordSchema, {
370
+ schemaVersion: 1,
371
+ id: input.id ?? `run_${random().replace(/-/g, '')}`,
372
+ profileKey,
373
+ taskId: input.taskId,
374
+ taskTitle: input.taskTitle,
375
+ trigger: input.trigger,
376
+ occurrenceKey: input.occurrenceKey,
377
+ ...(input.scheduleVersion === undefined ? {} : { scheduleVersion: input.scheduleVersion }),
378
+ ...(input.scheduledFor === undefined ? {} : { scheduledFor: input.scheduledFor }),
379
+ status: input.status ?? 'queued',
380
+ ...(input.sessionId === undefined ? {} : { sessionId: input.sessionId }),
381
+ ...(input.sessionTitle === undefined ? {} : { sessionTitle: input.sessionTitle }),
382
+ queuedAt: at,
383
+ steps: 0,
384
+ toolCalls: 0,
385
+ blockedRequiresUser: false,
386
+ errors: [],
387
+ }, 'run')
388
+ await tables.runs.put(run.id, run)
389
+ await retainRuns(tables)
390
+ return run
391
+ }
392
+
393
+ async function updateRun(id, patch) {
394
+ const tables = await ensure()
395
+ const current = tables.runs.get(id)
396
+ if (!ownRun(current)) return undefined
397
+ if (current === undefined) return undefined
398
+ const terminal = !isActiveRun(current.status)
399
+ const nextStatus = patch.status ?? current.status
400
+ const safe = terminal && nextStatus !== current.status ? { ...patch, status: current.status } : patch
401
+ const next = parseStrict(RunRecordSchema, { ...current, ...safe }, 'run')
402
+ await tables.runs.put(id, next)
403
+ await retainRuns(tables)
404
+ return next
405
+ }
406
+
407
+ async function getRun(id) {
408
+ const tables = await ensure()
409
+ const run = tables.runs.get(id)
410
+ return ownRun(run) ? run : undefined
411
+ }
412
+
413
+ async function listRunsPage({ limit = 50, before, taskId } = {}) {
414
+ const tables = await ensure()
415
+ const runs = []
416
+ for (const [, run] of tables.runs.entries()) {
417
+ if (!ownRun(run)) continue
418
+ if (taskId !== undefined && run.taskId !== taskId) continue
419
+ runs.push(run)
420
+ }
421
+ runs.sort((left, right) => right.queuedAt.localeCompare(left.queuedAt) || right.id.localeCompare(left.id))
422
+ let start = 0
423
+ if (before !== undefined) {
424
+ const index = runs.findIndex((run) => run.id === before)
425
+ if (index === -1) return { runs: [], nextBefore: undefined, invalidCursor: true }
426
+ start = index + 1
427
+ }
428
+ const page = runs.slice(start, start + Math.max(1, Math.min(2_000, limit)))
429
+ const last = page.at(-1)
430
+ return {
431
+ runs: page,
432
+ nextBefore: start + page.length < runs.length ? last?.id : undefined,
433
+ invalidCursor: false,
434
+ }
435
+ }
436
+
437
+ async function activeRunForTask(taskId) {
438
+ const tables = await ensure()
439
+ for (const [, run] of tables.runs.entries()) {
440
+ if (ownRun(run) && run.taskId === taskId && isActiveRun(run.status)) return run
441
+ }
442
+ return undefined
443
+ }
444
+
445
+ async function runCountForTask(taskId) {
446
+ const tables = await ensure()
447
+ let count = 0
448
+ for (const [, run] of tables.runs.entries()) if (ownRun(run) && run.taskId === taskId) count += 1
449
+ return count
450
+ }
451
+
452
+ async function retainRuns(tables) {
453
+ const byProfile = []
454
+ for (const [, run] of tables.runs.entries()) if (ownRun(run)) byProfile.push(run)
455
+ byProfile.sort((left, right) => right.queuedAt.localeCompare(left.queuedAt) || right.id.localeCompare(left.id))
456
+ const perTask = new Map()
457
+ const doomed = new Set()
458
+ byProfile.forEach((run, index) => {
459
+ const count = (perTask.get(run.taskId) ?? 0) + 1
460
+ perTask.set(run.taskId, count)
461
+ if (index >= MAX_RUNS_PER_PROFILE || count > MAX_RUNS_PER_TASK) doomed.add(run.id)
462
+ })
463
+ for (const id of doomed) await tables.runs.delete(id)
464
+ }
465
+
466
+ async function claimOccurrence(input) {
467
+ return serializeClaim(async () => {
468
+ const tables = await ensure()
469
+ const id = input.occurrenceKey
470
+ const existing = tables.attempts.get(id)
471
+ if (existing !== undefined) return false
472
+ const at = iso(now())
473
+ const record = parseStrict(AttemptRecordSchema, {
474
+ schemaVersion: 1,
475
+ id,
476
+ profileKey,
477
+ taskId: input.taskId,
478
+ runId: input.runId,
479
+ occurrenceKey: input.occurrenceKey,
480
+ state: 'claimed',
481
+ createdAt: at,
482
+ updatedAt: at,
483
+ }, 'attempt')
484
+ await tables.attempts.put(id, record)
485
+ return true
486
+ })
487
+ }
488
+
489
+ async function hasAttempt(occurrenceKey) {
490
+ const tables = await ensure()
491
+ const record = tables.attempts.get(occurrenceKey)
492
+ return record?.profileKey === profileKey
493
+ }
494
+
495
+ async function setAttemptState(occurrenceKey, state) {
496
+ const tables = await ensure()
497
+ const current = tables.attempts.get(occurrenceKey)
498
+ if (current === undefined || current.profileKey !== profileKey) return undefined
499
+ const next = parseStrict(AttemptRecordSchema, {
500
+ ...current,
501
+ state,
502
+ updatedAt: iso(now()),
503
+ }, 'attempt')
504
+ await tables.attempts.put(occurrenceKey, next)
505
+ return next
506
+ }
507
+
508
+ async function listAttemptsForRun(runId) {
509
+ const tables = await ensure()
510
+ const records = []
511
+ for (const [, record] of tables.attempts.entries()) {
512
+ if (record.profileKey === profileKey && record.runId === runId) records.push(record)
513
+ }
514
+ return records
515
+ }
516
+
517
+ async function addNotification(input) {
518
+ const tables = await ensure()
519
+ const record = parseStrict(NotificationSchema, {
520
+ schemaVersion: 1,
521
+ id: `ntf_${random().replace(/-/g, '')}`,
522
+ profileKey,
523
+ kind: input.kind,
524
+ title: input.title,
525
+ message: input.message,
526
+ ...(input.runId === undefined ? {} : { runId: input.runId }),
527
+ ...(input.taskId === undefined ? {} : { taskId: input.taskId }),
528
+ createdAt: iso(now()),
529
+ }, 'notification')
530
+ await tables.notifications.put(record.id, record)
531
+ await retainNotifications(tables)
532
+ return record
533
+ }
534
+
535
+ async function retainNotifications(tables) {
536
+ const records = []
537
+ for (const [, record] of tables.notifications.entries()) if (record.profileKey === profileKey) records.push(record)
538
+ records.sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id))
539
+ for (const record of records.slice(MAX_NOTIFICATIONS_PER_PROFILE)) await tables.notifications.delete(record.id)
540
+ }
541
+
542
+ async function listNotifications({ unreadOnly = false, limit = 50, taskId } = {}) {
543
+ const tables = await ensure()
544
+ const records = []
545
+ for (const [, record] of tables.notifications.entries()) {
546
+ if (record.profileKey !== profileKey) continue
547
+ if (taskId !== undefined && record.taskId !== taskId) continue
548
+ if (unreadOnly && record.readAt !== undefined) continue
549
+ records.push(record)
550
+ }
551
+ records.sort((left, right) => right.createdAt.localeCompare(left.createdAt) || right.id.localeCompare(left.id))
552
+ return records.slice(0, Math.max(1, Math.min(200, limit)))
553
+ }
554
+
555
+ async function unreadNotificationCount() {
556
+ const tables = await ensure()
557
+ let count = 0
558
+ for (const [, record] of tables.notifications.entries()) {
559
+ if (record.profileKey === profileKey && record.readAt === undefined) count += 1
560
+ }
561
+ return count
562
+ }
563
+
564
+ async function markNotificationsRead({ ids, all = false }) {
565
+ const tables = await ensure()
566
+ const wanted = new Set(ids ?? [])
567
+ let changed = 0
568
+ const at = iso(now())
569
+ for (const [id, record] of [...tables.notifications.entries()]) {
570
+ if (record.profileKey !== profileKey || record.readAt !== undefined) continue
571
+ if (!all && !wanted.has(id)) continue
572
+ const next = parseStrict(NotificationSchema, { ...record, readAt: at }, 'notification')
573
+ await tables.notifications.put(id, next)
574
+ changed += 1
575
+ }
576
+ return changed
577
+ }
578
+
579
+ async function setOwnerMeta(record) {
580
+ const tables = await ensure()
581
+ const clean = parseStrict(OwnerRecordSchema, record, 'owner meta')
582
+ const existing = tables.meta.get(profileKey)
583
+ if (existing !== undefined
584
+ && existing.fencingToken !== clean.fencingToken
585
+ && Date.parse(existing.leaseExpiresAt) > now()) return false
586
+ await tables.meta.put(profileKey, clean)
587
+ return true
588
+ }
589
+
590
+ async function getOwnerMeta() {
591
+ const tables = await ensure()
592
+ const record = tables.meta.get(profileKey)
593
+ return record?.profileKey === profileKey ? record : undefined
594
+ }
595
+
596
+ /**
597
+ * Startup recovery: a durable turn boundary proves how the run ended. Only runs with no
598
+ * finished turn are left unknown (and never retried silently).
599
+ */
600
+ async function recoverNonTerminalRuns() {
601
+ const tables = await ensure()
602
+ const at = iso(now())
603
+ const outcomes = new Map()
604
+ let recovered = 0
605
+ for (const [id, run] of [...tables.runs.entries()]) {
606
+ if (!ownRun(run) || !isActiveRun(run.status)) continue
607
+ const turn = run.turnState
608
+ const settled = turn?.phase === 'ended' && SETTLED_RUN_STATUSES.includes(turn.status) ? turn.status : undefined
609
+ const status = settled ?? 'unknown_after_restart'
610
+ const finishedAt = settled === undefined ? at : turn.at
611
+ const errors = settled === undefined
612
+ ? [...run.errors, {
613
+ at: finishedAt,
614
+ phase: 'session',
615
+ severity: 'warning',
616
+ code: 'unknown_after_restart',
617
+ message: '进程重启时无法证明本轮运行已经结束,结果未知;为避免重复执行,未自动重试。',
618
+ }].slice(-MAX_ERRORS_PER_RUN)
619
+ : status === 'succeeded'
620
+ ? run.errors
621
+ : [...run.errors, {
622
+ at: finishedAt,
623
+ phase: 'session',
624
+ severity: 'warning',
625
+ code: 'recovered_after_restart',
626
+ message: `进程重启前回合已结束(${status});按该终态收口。`,
627
+ }].slice(-MAX_ERRORS_PER_RUN)
628
+ const next = parseStrict(RunRecordSchema, {
629
+ ...run,
630
+ status,
631
+ finishedAt,
632
+ durationMs: Math.max(0, Date.parse(finishedAt) - Date.parse(run.startedAt ?? run.queuedAt)),
633
+ ...(status === 'blocked_requires_user' ? { blockedRequiresUser: true } : {}),
634
+ errors,
635
+ }, 'run')
636
+ await tables.runs.put(id, next)
637
+ if (settled !== undefined) outcomes.set(id, status)
638
+ recovered += 1
639
+ }
640
+ for (const [id, attempt] of [...tables.attempts.entries()]) {
641
+ if (attempt.profileKey !== profileKey) continue
642
+ if (!['claimed', 'session_created', 'prompt_submitted'].includes(attempt.state)) continue
643
+ const status = outcomes.get(attempt.runId)
644
+ const state = status === undefined ? 'unknown' : status === 'failed' ? 'failed' : 'accepted'
645
+ const next = parseStrict(AttemptRecordSchema, { ...attempt, state, updatedAt: at }, 'attempt')
646
+ await tables.attempts.put(id, next)
647
+ }
648
+ return recovered
649
+ }
650
+
651
+ async function close() {
652
+ if (opening === undefined) return
653
+ const tables = await opening.catch(() => undefined)
654
+ opening = undefined
655
+ await tables?.domain?.close?.().catch(() => {})
656
+ }
657
+
658
+ function decorateTask(task, runs) {
659
+ const latest = runs.find((run) => run.taskId === task.id)
660
+ const next = nextOccurrence(task.trigger, now(), { calendar: options.calendar })
661
+ return {
662
+ ...task,
663
+ nextRunAt: next.kind === 'occurrence' ? next.scheduledFor : null,
664
+ nextRunLocal: next.kind === 'occurrence' ? formatInZone(next.scheduledFor, task.trigger.timezone) : null,
665
+ nextRunUnavailable: next.kind === 'calendar_unavailable',
666
+ lastRun: latest === undefined ? null : publicRun(latest),
667
+ }
668
+ }
669
+
670
+ function publicRun(run) {
671
+ return {
672
+ id: run.id,
673
+ taskId: run.taskId,
674
+ taskTitle: run.taskTitle,
675
+ trigger: run.trigger,
676
+ status: run.status,
677
+ occurrenceKey: run.occurrenceKey,
678
+ scheduledFor: run.scheduledFor ?? null,
679
+ sessionId: run.sessionId ?? null,
680
+ sessionTitle: run.sessionTitle ?? null,
681
+ queuedAt: run.queuedAt,
682
+ startedAt: run.startedAt ?? null,
683
+ finishedAt: run.finishedAt ?? null,
684
+ durationMs: run.durationMs ?? null,
685
+ steps: run.steps,
686
+ toolCalls: run.toolCalls,
687
+ blockedRequiresUser: run.blockedRequiresUser,
688
+ errors: run.errors,
689
+ summary: run.summary ?? null,
690
+ }
691
+ }
692
+
693
+ return {
694
+ profileKey,
695
+ profileName,
696
+ domainSpec,
697
+ open: ensure,
698
+ close,
699
+ listTaskRecords,
700
+ listTasks,
701
+ getTask,
702
+ taskRecordById,
703
+ createTask,
704
+ patchTask,
705
+ setTaskState,
706
+ deleteTask,
707
+ copyBuiltin,
708
+ setLastHandled,
709
+ createRun,
710
+ updateRun,
711
+ getRun,
712
+ listRunsPage,
713
+ activeRunForTask,
714
+ runCountForTask,
715
+ claimOccurrence,
716
+ hasAttempt,
717
+ setAttemptState,
718
+ listAttemptsForRun,
719
+ addNotification,
720
+ listNotifications,
721
+ unreadNotificationCount,
722
+ markNotificationsRead,
723
+ setOwnerMeta,
724
+ getOwnerMeta,
725
+ recoverNonTerminalRuns,
726
+ decorateTask,
727
+ publicRun,
728
+ definitions: profileById,
729
+ AutomationTaskSchema,
730
+ isActiveRun,
731
+ }
732
+ }