@v587d/capital-generation 2.11.1 → 2.13.0

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