@v587d/capital-generation 2.12.0 → 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.
- package/CHANGELOG.md +4 -0
- package/README.md +12 -12
- package/automation/builtins.js +147 -0
- package/automation/calendar.js +66 -0
- package/automation/client.js +68 -0
- package/automation/client.src.cjs +777 -0
- package/automation/defaults.js +71 -0
- package/automation/index.js +183 -0
- package/automation/owner.js +222 -0
- package/automation/package.json +50 -0
- package/automation/policy.js +105 -0
- package/automation/redact.js +25 -0
- package/automation/route.js +241 -0
- package/automation/runner.js +858 -0
- package/automation/scheduler.js +495 -0
- package/automation/schema.js +359 -0
- package/automation/service.js +338 -0
- package/automation/settings.js +48 -0
- package/automation/store.js +707 -0
- package/automation/time.js +220 -0
- package/automation/workspaces.js +154 -0
- package/chart-ui/package.json +1 -1
- package/config/client.js +4 -2
- package/config/client.src.cjs +90 -1
- package/config/index.js +15 -0
- package/config/package.json +1 -1
- package/cordis.patch.yml +8 -0
- package/lib/index.d.ts +38 -0
- package/lib/index.js +16 -0
- package/lib/web-retriever/local-fetch.d.ts +1 -1
- package/lib/web-retriever/local-fetch.js +1 -1
- package/lib/web-retriever/wind-client.d.ts +1 -1
- package/lib/web-retriever/wind-client.js +1 -1
- package/package.json +4 -2
- package/watchlist/package.json +1 -1
|
@@ -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
|
+
}
|