@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,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict storage and wire schemas for the automation domain. Every object is a
|
|
3
|
+
* `strictObject`: an unknown field is a write error, not something JSON will silently drop.
|
|
4
|
+
* Run/notification records are disposable; task records are authoritative.
|
|
5
|
+
*/
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import {
|
|
8
|
+
ACTIVE_RUN_STATUSES,
|
|
9
|
+
MAX_DESCRIPTION_CHARS,
|
|
10
|
+
MAX_ERROR_MESSAGE_CHARS,
|
|
11
|
+
MAX_ERRORS_PER_RUN,
|
|
12
|
+
MAX_INSTRUCTION_CHARS,
|
|
13
|
+
MAX_MAX_STEPS,
|
|
14
|
+
MAX_MAX_TOOL_CALLS,
|
|
15
|
+
MAX_RUN_TIMEOUT_MINUTES,
|
|
16
|
+
MAX_TITLE_CHARS,
|
|
17
|
+
MIN_MAX_STEPS,
|
|
18
|
+
MIN_MAX_TOOL_CALLS,
|
|
19
|
+
MIN_RUN_TIMEOUT_MINUTES,
|
|
20
|
+
RUN_STATUSES,
|
|
21
|
+
} from './defaults.js'
|
|
22
|
+
|
|
23
|
+
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
|
24
|
+
|
|
25
|
+
/** Canonical UTC ISO instant; `Date.parse` alone accepts offsets and seconds variants. */
|
|
26
|
+
export const isoInstant = z.string().refine((value) => ISO_UTC.test(value) && new Date(value).toISOString() === value, {
|
|
27
|
+
message: 'must be a canonical UTC ISO instant',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const TIME_OF_DAY = /^(?:[01]\d|2[0-3]):[0-5]\d$/
|
|
31
|
+
|
|
32
|
+
export function isValidTimeZone(value) {
|
|
33
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 64) return false
|
|
34
|
+
try {
|
|
35
|
+
new Intl.DateTimeFormat('en-US', { timeZone: value }).format(0)
|
|
36
|
+
return true
|
|
37
|
+
} catch {
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const timeZone = z.string().min(1).max(64).refine(isValidTimeZone, { message: 'must be a valid IANA time zone' })
|
|
43
|
+
|
|
44
|
+
/** Registered Workspace id; empty/absent means the profile's dedicated automation workspace. */
|
|
45
|
+
export const workspaceId = z.string().max(120)
|
|
46
|
+
|
|
47
|
+
export const TriggerSchema = z.strictObject({
|
|
48
|
+
kind: z.literal('schedule'),
|
|
49
|
+
cadence: z.enum(['daily', 'weekly', 'monthly']),
|
|
50
|
+
time: z.string().regex(TIME_OF_DAY, { message: 'must be HH:mm' }),
|
|
51
|
+
timezone: timeZone,
|
|
52
|
+
weekdays: z.array(z.number().int().min(1).max(7)).min(1).max(7).optional(),
|
|
53
|
+
dayOfMonth: z.number().int().min(1).max(28).optional(),
|
|
54
|
+
tradingDaysOnly: z.boolean(),
|
|
55
|
+
}).superRefine((trigger, ctx) => {
|
|
56
|
+
if (trigger.cadence === 'weekly') {
|
|
57
|
+
if (trigger.weekdays === undefined) ctx.addIssue({ code: 'custom', path: ['weekdays'], message: 'weekly requires weekdays' })
|
|
58
|
+
else if (new Set(trigger.weekdays).size !== trigger.weekdays.length) ctx.addIssue({ code: 'custom', path: ['weekdays'], message: 'weekdays must be unique' })
|
|
59
|
+
if (trigger.dayOfMonth !== undefined) ctx.addIssue({ code: 'custom', path: ['dayOfMonth'], message: 'weekly must not carry dayOfMonth' })
|
|
60
|
+
}
|
|
61
|
+
if (trigger.cadence === 'monthly') {
|
|
62
|
+
if (trigger.dayOfMonth === undefined) ctx.addIssue({ code: 'custom', path: ['dayOfMonth'], message: 'monthly requires dayOfMonth' })
|
|
63
|
+
if (trigger.weekdays !== undefined) ctx.addIssue({ code: 'custom', path: ['weekdays'], message: 'monthly must not carry weekdays' })
|
|
64
|
+
}
|
|
65
|
+
if (trigger.cadence === 'daily') {
|
|
66
|
+
if (trigger.weekdays !== undefined) ctx.addIssue({ code: 'custom', path: ['weekdays'], message: 'daily must not carry weekdays' })
|
|
67
|
+
if (trigger.dayOfMonth !== undefined) ctx.addIssue({ code: 'custom', path: ['dayOfMonth'], message: 'daily must not carry dayOfMonth' })
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
export const LimitsSchema = z.strictObject({
|
|
72
|
+
runTimeoutMinutes: z.number().int().min(MIN_RUN_TIMEOUT_MINUTES).max(MAX_RUN_TIMEOUT_MINUTES),
|
|
73
|
+
idleTimeoutMinutes: z.number().int().min(1).max(30),
|
|
74
|
+
maxSteps: z.number().int().min(MIN_MAX_STEPS).max(MAX_MAX_STEPS),
|
|
75
|
+
maxToolCalls: z.number().int().min(MIN_MAX_TOOL_CALLS).max(MAX_MAX_TOOL_CALLS),
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
export const NotificationsSchema = z.strictObject({
|
|
79
|
+
inApp: z.boolean(),
|
|
80
|
+
onFailure: z.boolean(),
|
|
81
|
+
onSuccess: z.boolean(),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
export const ExecutionModeSchema = z.enum(['news', 'market', 'review', 'research'])
|
|
85
|
+
|
|
86
|
+
/** User-writable configuration. Server-owned fields are deliberately absent. */
|
|
87
|
+
export const TaskConfigSchema = z.strictObject({
|
|
88
|
+
category: z.enum(['news', 'market', 'review', 'research']),
|
|
89
|
+
title: z.string().min(1).max(MAX_TITLE_CHARS),
|
|
90
|
+
description: z.string().max(MAX_DESCRIPTION_CHARS),
|
|
91
|
+
trigger: TriggerSchema,
|
|
92
|
+
instruction: z.string().min(1).max(MAX_INSTRUCTION_CHARS),
|
|
93
|
+
mode: ExecutionModeSchema,
|
|
94
|
+
cwdKind: z.literal('profile'),
|
|
95
|
+
workspaceId: workspaceId.optional(),
|
|
96
|
+
limits: LimitsSchema,
|
|
97
|
+
notifications: NotificationsSchema,
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
export const TaskOverridesSchema = z.strictObject({
|
|
101
|
+
category: z.enum(['news', 'market', 'review', 'research']).optional(),
|
|
102
|
+
title: z.string().min(1).max(MAX_TITLE_CHARS).optional(),
|
|
103
|
+
description: z.string().max(MAX_DESCRIPTION_CHARS).optional(),
|
|
104
|
+
trigger: TriggerSchema.optional(),
|
|
105
|
+
instruction: z.string().min(1).max(MAX_INSTRUCTION_CHARS).optional(),
|
|
106
|
+
mode: ExecutionModeSchema.optional(),
|
|
107
|
+
workspaceId: workspaceId.optional(),
|
|
108
|
+
limits: LimitsSchema.optional(),
|
|
109
|
+
notifications: NotificationsSchema.optional(),
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
export const TaskStateSchema = z.enum(['dormant', 'active', 'paused'])
|
|
113
|
+
|
|
114
|
+
export const AutomationTaskSchema = z.strictObject({
|
|
115
|
+
id: z.string().min(1).max(80),
|
|
116
|
+
schemaVersion: z.literal(1),
|
|
117
|
+
definitionVersion: z.number().int().min(0),
|
|
118
|
+
origin: z.enum(['builtin', 'custom']),
|
|
119
|
+
sourceBuiltinId: z.string().min(1).max(80).optional(),
|
|
120
|
+
profileKey: z.string().min(1).max(80),
|
|
121
|
+
profileName: z.string().min(1).max(200),
|
|
122
|
+
state: TaskStateSchema,
|
|
123
|
+
category: z.enum(['news', 'market', 'review', 'research']),
|
|
124
|
+
title: z.string().min(1).max(MAX_TITLE_CHARS),
|
|
125
|
+
description: z.string().max(MAX_DESCRIPTION_CHARS),
|
|
126
|
+
trigger: TriggerSchema,
|
|
127
|
+
instruction: z.string().min(1).max(MAX_INSTRUCTION_CHARS),
|
|
128
|
+
mode: ExecutionModeSchema,
|
|
129
|
+
cwdKind: z.literal('profile'),
|
|
130
|
+
workspaceId: workspaceId.optional(),
|
|
131
|
+
limits: LimitsSchema,
|
|
132
|
+
notifications: NotificationsSchema,
|
|
133
|
+
overrides: TaskOverridesSchema.optional(),
|
|
134
|
+
scheduleAnchorAt: isoInstant,
|
|
135
|
+
lastHandledOccurrenceKey: z.string().max(300).optional(),
|
|
136
|
+
lastScheduledFor: isoInstant.optional(),
|
|
137
|
+
createdAt: isoInstant,
|
|
138
|
+
updatedAt: isoInstant,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
/** Durable builtin override row. Runtime fields (state, anchors) always sit on the record. */
|
|
142
|
+
export const TaskRecordSchema = z.strictObject({
|
|
143
|
+
schemaVersion: z.literal(1),
|
|
144
|
+
id: z.string().min(1).max(80),
|
|
145
|
+
origin: z.enum(['builtin', 'custom']),
|
|
146
|
+
sourceBuiltinId: z.string().min(1).max(80).optional(),
|
|
147
|
+
profileKey: z.string().min(1).max(80),
|
|
148
|
+
profileName: z.string().min(1).max(200),
|
|
149
|
+
state: TaskStateSchema,
|
|
150
|
+
definitionVersion: z.number().int().min(0),
|
|
151
|
+
custom: TaskConfigSchema.optional(),
|
|
152
|
+
overrides: TaskOverridesSchema.optional(),
|
|
153
|
+
scheduleAnchorAt: isoInstant,
|
|
154
|
+
lastHandledOccurrenceKey: z.string().max(300).optional(),
|
|
155
|
+
lastScheduledFor: isoInstant.optional(),
|
|
156
|
+
createdAt: isoInstant,
|
|
157
|
+
updatedAt: isoInstant,
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
export const RunErrorSchema = z.strictObject({
|
|
161
|
+
at: isoInstant,
|
|
162
|
+
phase: z.enum(['schedule', 'session', 'turn', 'tool', 'llm', 'timeout', 'storage']),
|
|
163
|
+
severity: z.enum(['error', 'warning']),
|
|
164
|
+
code: z.string().min(1).max(80),
|
|
165
|
+
message: z.string().min(1).max(MAX_ERROR_MESSAGE_CHARS),
|
|
166
|
+
tool: z.string().max(80).optional(),
|
|
167
|
+
callId: z.string().max(120).optional(),
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
export const RunRecordSchema = z.strictObject({
|
|
171
|
+
schemaVersion: z.literal(1),
|
|
172
|
+
id: z.string().min(1).max(80),
|
|
173
|
+
profileKey: z.string().min(1).max(80),
|
|
174
|
+
taskId: z.string().min(1).max(80),
|
|
175
|
+
taskTitle: z.string().min(1).max(MAX_TITLE_CHARS),
|
|
176
|
+
trigger: z.enum(['scheduled', 'manual']),
|
|
177
|
+
occurrenceKey: z.string().min(1).max(300),
|
|
178
|
+
scheduleVersion: z.string().min(1).max(80).optional(),
|
|
179
|
+
scheduledFor: isoInstant.optional(),
|
|
180
|
+
status: z.enum(RUN_STATUSES),
|
|
181
|
+
sessionId: z.string().min(1).max(120).optional(),
|
|
182
|
+
sessionTitle: z.string().min(1).max(200).optional(),
|
|
183
|
+
queuedAt: isoInstant,
|
|
184
|
+
startedAt: isoInstant.optional(),
|
|
185
|
+
finishedAt: isoInstant.optional(),
|
|
186
|
+
durationMs: z.number().int().min(0).optional(),
|
|
187
|
+
steps: z.number().int().min(0),
|
|
188
|
+
toolCalls: z.number().int().min(0),
|
|
189
|
+
blockedRequiresUser: z.boolean(),
|
|
190
|
+
errors: z.array(RunErrorSchema).max(MAX_ERRORS_PER_RUN),
|
|
191
|
+
summary: z.string().max(2_000).optional(),
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
export const AttemptRecordSchema = z.strictObject({
|
|
195
|
+
schemaVersion: z.literal(1),
|
|
196
|
+
id: z.string().min(1).max(300),
|
|
197
|
+
profileKey: z.string().min(1).max(80),
|
|
198
|
+
taskId: z.string().min(1).max(80),
|
|
199
|
+
runId: z.string().min(1).max(80),
|
|
200
|
+
occurrenceKey: z.string().min(1).max(300),
|
|
201
|
+
state: z.enum(['claimed', 'session_created', 'prompt_submitted', 'accepted', 'failed', 'unknown']),
|
|
202
|
+
createdAt: isoInstant,
|
|
203
|
+
updatedAt: isoInstant,
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
export const NotificationSchema = z.strictObject({
|
|
207
|
+
schemaVersion: z.literal(1),
|
|
208
|
+
id: z.string().min(1).max(80),
|
|
209
|
+
profileKey: z.string().min(1).max(80),
|
|
210
|
+
kind: z.enum(['run_failed', 'run_timeout', 'run_succeeded', 'scheduler_standby', 'calendar_unavailable', 'skipped']),
|
|
211
|
+
title: z.string().min(1).max(120),
|
|
212
|
+
message: z.string().max(MAX_ERROR_MESSAGE_CHARS),
|
|
213
|
+
runId: z.string().min(1).max(80).optional(),
|
|
214
|
+
taskId: z.string().min(1).max(80).optional(),
|
|
215
|
+
createdAt: isoInstant,
|
|
216
|
+
readAt: isoInstant.optional(),
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
export const OwnerRecordSchema = z.strictObject({
|
|
220
|
+
schemaVersion: z.literal(1),
|
|
221
|
+
profileKey: z.string().min(1).max(80),
|
|
222
|
+
ownerId: z.string().min(1).max(120),
|
|
223
|
+
fencingToken: z.string().min(1).max(120),
|
|
224
|
+
leaseExpiresAt: isoInstant,
|
|
225
|
+
heartbeatAt: isoInstant,
|
|
226
|
+
schedulerState: z.enum(['active', 'standby']),
|
|
227
|
+
updatedAt: isoInstant,
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
/** Create/patch wire schemas: no profile, owner, session, preset or cwd field exists at all. */
|
|
231
|
+
export const TaskCreateSchema = z.strictObject({
|
|
232
|
+
title: z.string().min(1).max(MAX_TITLE_CHARS),
|
|
233
|
+
description: z.string().max(MAX_DESCRIPTION_CHARS).optional(),
|
|
234
|
+
instruction: z.string().min(1).max(MAX_INSTRUCTION_CHARS),
|
|
235
|
+
category: z.enum(['news', 'market', 'review', 'research']),
|
|
236
|
+
trigger: TriggerSchema,
|
|
237
|
+
mode: ExecutionModeSchema,
|
|
238
|
+
workspaceId: workspaceId.optional(),
|
|
239
|
+
limits: LimitsSchema.optional(),
|
|
240
|
+
notifications: NotificationsSchema.optional(),
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
export const TaskPatchSchema = z.strictObject({
|
|
244
|
+
title: z.string().min(1).max(MAX_TITLE_CHARS).optional(),
|
|
245
|
+
description: z.string().max(MAX_DESCRIPTION_CHARS).optional(),
|
|
246
|
+
instruction: z.string().min(1).max(MAX_INSTRUCTION_CHARS).optional(),
|
|
247
|
+
category: z.enum(['news', 'market', 'review', 'research']).optional(),
|
|
248
|
+
trigger: TriggerSchema.optional(),
|
|
249
|
+
mode: ExecutionModeSchema.optional(),
|
|
250
|
+
workspaceId: workspaceId.optional(),
|
|
251
|
+
limits: LimitsSchema.optional(),
|
|
252
|
+
notifications: NotificationsSchema.optional(),
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
export const NotificationsReadSchema = z.strictObject({
|
|
256
|
+
ids: z.array(z.string().min(1).max(80)).max(200).optional(),
|
|
257
|
+
all: z.boolean().optional(),
|
|
258
|
+
}).refine((value) => value.all === true || (value.ids !== undefined && value.ids.length > 0), {
|
|
259
|
+
message: 'either all=true or a non-empty ids array is required',
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
export const BuiltinDefaultsSchema = z.strictObject({
|
|
263
|
+
state: z.literal('dormant'),
|
|
264
|
+
category: z.enum(['news', 'market', 'review', 'research']),
|
|
265
|
+
title: z.string().min(1).max(MAX_TITLE_CHARS),
|
|
266
|
+
description: z.string().max(MAX_DESCRIPTION_CHARS),
|
|
267
|
+
trigger: TriggerSchema,
|
|
268
|
+
instruction: z.string().min(1).max(MAX_INSTRUCTION_CHARS),
|
|
269
|
+
mode: ExecutionModeSchema,
|
|
270
|
+
cwdKind: z.literal('profile'),
|
|
271
|
+
limits: LimitsSchema,
|
|
272
|
+
notifications: NotificationsSchema,
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
export const BuiltinDefinitionSchema = z.strictObject({
|
|
276
|
+
id: z.string().min(1).max(80),
|
|
277
|
+
version: z.number().int().min(1),
|
|
278
|
+
category: z.enum(['news', 'market', 'review', 'research']),
|
|
279
|
+
defaults: BuiltinDefaultsSchema,
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
/** Reject values JSON would silently change: undefined, NaN/Infinity, holes, non-object members. */
|
|
283
|
+
export function assertCleanJson(value, path = '$') {
|
|
284
|
+
if (value === undefined) throw new TypeError(`undefined is not allowed at ${path}`)
|
|
285
|
+
if (typeof value === 'number' && !Number.isFinite(value)) throw new TypeError(`non-finite number at ${path}`)
|
|
286
|
+
if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'bigint') {
|
|
287
|
+
throw new TypeError(`unsupported value at ${path}`)
|
|
288
|
+
}
|
|
289
|
+
if (Array.isArray(value)) {
|
|
290
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
291
|
+
if (!(index in value)) throw new TypeError(`sparse array hole at ${path}[${index}]`)
|
|
292
|
+
assertCleanJson(value[index], `${path}[${index}]`)
|
|
293
|
+
}
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
if (value !== null && typeof value === 'object') {
|
|
297
|
+
const proto = Object.getPrototypeOf(value)
|
|
298
|
+
if (proto !== Object.prototype && proto !== null) throw new TypeError(`non-plain object at ${path}`)
|
|
299
|
+
for (const [key, item] of Object.entries(value)) assertCleanJson(item, `${path}.${key}`)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function parseStrict(schema, value, label) {
|
|
304
|
+
try {
|
|
305
|
+
assertCleanJson(value)
|
|
306
|
+
} catch (error) {
|
|
307
|
+
throw new SchemaError('invalid_record', `${label}: ${error instanceof Error ? error.message : String(error)}`)
|
|
308
|
+
}
|
|
309
|
+
const result = schema.safeParse(value)
|
|
310
|
+
if (!result.success) {
|
|
311
|
+
const issue = result.error.issues[0]
|
|
312
|
+
const where = issue?.path?.length ? ` at ${issue.path.join('.')}` : ''
|
|
313
|
+
throw new SchemaError('invalid_record', `${label}${where}: ${issue?.message ?? 'invalid value'}`)
|
|
314
|
+
}
|
|
315
|
+
return result.data
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export class SchemaError extends Error {
|
|
319
|
+
constructor(code, message) {
|
|
320
|
+
super(message)
|
|
321
|
+
this.name = 'SchemaError'
|
|
322
|
+
this.code = code
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function isActiveRun(status) {
|
|
327
|
+
return ACTIVE_RUN_STATUSES.includes(status)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Merge order: code definition -> legal user override -> server runtime fields. */
|
|
331
|
+
export function resolveTaskRecord(record, definition) {
|
|
332
|
+
const base = record.origin === 'builtin'
|
|
333
|
+
? definition?.defaults
|
|
334
|
+
: record.custom
|
|
335
|
+
if (base === undefined) throw new SchemaError('invalid_record', `task ${record.id} has no resolvable base configuration`)
|
|
336
|
+
const overrides = record.overrides ?? {}
|
|
337
|
+
const merged = {
|
|
338
|
+
...base,
|
|
339
|
+
...overrides,
|
|
340
|
+
state: record.state,
|
|
341
|
+
}
|
|
342
|
+
const task = {
|
|
343
|
+
id: record.id,
|
|
344
|
+
schemaVersion: 1,
|
|
345
|
+
definitionVersion: record.definitionVersion,
|
|
346
|
+
origin: record.origin,
|
|
347
|
+
...(record.sourceBuiltinId === undefined ? {} : { sourceBuiltinId: record.sourceBuiltinId }),
|
|
348
|
+
profileKey: record.profileKey,
|
|
349
|
+
profileName: record.profileName,
|
|
350
|
+
...merged,
|
|
351
|
+
...(record.overrides === undefined ? {} : { overrides: record.overrides }),
|
|
352
|
+
scheduleAnchorAt: record.scheduleAnchorAt,
|
|
353
|
+
...(record.lastHandledOccurrenceKey === undefined ? {} : { lastHandledOccurrenceKey: record.lastHandledOccurrenceKey }),
|
|
354
|
+
...(record.lastScheduledFor === undefined ? {} : { lastScheduledFor: record.lastScheduledFor }),
|
|
355
|
+
createdAt: record.createdAt,
|
|
356
|
+
updatedAt: record.updatedAt,
|
|
357
|
+
}
|
|
358
|
+
return parseStrict(AutomationTaskSchema, task, 'automation task')
|
|
359
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API service. All mutations serialize on one promise chain so a manual run, a tick claim, and
|
|
3
|
+
* a page cancel cannot interleave profile-visible state. Every lookup goes through the store's
|
|
4
|
+
* profile filter; the wire body never selects a profile.
|
|
5
|
+
*/
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_AUTOMATION_SETTINGS,
|
|
8
|
+
MAX_IDLE_TIMEOUT_MINUTES,
|
|
9
|
+
MAX_MAX_STEPS,
|
|
10
|
+
MAX_MAX_TOOL_CALLS,
|
|
11
|
+
MAX_RUN_TIMEOUT_MINUTES,
|
|
12
|
+
MIN_IDLE_TIMEOUT_MINUTES,
|
|
13
|
+
MIN_MAX_STEPS,
|
|
14
|
+
MIN_MAX_TOOL_CALLS,
|
|
15
|
+
MIN_RUN_TIMEOUT_MINUTES,
|
|
16
|
+
} from './defaults.js'
|
|
17
|
+
import {
|
|
18
|
+
NotificationsReadSchema,
|
|
19
|
+
TaskCreateSchema,
|
|
20
|
+
TaskPatchSchema,
|
|
21
|
+
parseStrict,
|
|
22
|
+
} from './schema.js'
|
|
23
|
+
import { sanitizeMessage } from './redact.js'
|
|
24
|
+
|
|
25
|
+
const MAX_RUN_PAGE_LIMIT = 2_000
|
|
26
|
+
const RUN_CURSOR_PATTERN = /^[A-Za-z0-9_-]{1,120}$/
|
|
27
|
+
|
|
28
|
+
function invalidQuery(message) {
|
|
29
|
+
return new AutomationError('invalid_query', message, 400)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function runLimitOf(value) {
|
|
33
|
+
if (value === undefined) return 50
|
|
34
|
+
if (typeof value !== 'string' && typeof value !== 'number') throw invalidQuery('运行分页 limit 无效。')
|
|
35
|
+
const text = String(value)
|
|
36
|
+
if (!/^[1-9]\d{0,3}$/.test(text)) throw invalidQuery('运行分页 limit 必须是 1–2000 的整数。')
|
|
37
|
+
const limit = Number(text)
|
|
38
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > MAX_RUN_PAGE_LIMIT) {
|
|
39
|
+
throw invalidQuery('运行分页 limit 必须是 1–2000 的整数。')
|
|
40
|
+
}
|
|
41
|
+
return limit
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runCursorOf(value) {
|
|
45
|
+
if (value === undefined || value === '') return undefined
|
|
46
|
+
if (typeof value !== 'string' || !RUN_CURSOR_PATTERN.test(value)) {
|
|
47
|
+
throw new AutomationError('invalid_cursor', '运行分页游标无效。', 400)
|
|
48
|
+
}
|
|
49
|
+
return value
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class AutomationError extends Error {
|
|
53
|
+
constructor(code, message, status = 400, extra = {}) {
|
|
54
|
+
super(message)
|
|
55
|
+
this.name = 'AutomationError'
|
|
56
|
+
this.code = code
|
|
57
|
+
this.status = status
|
|
58
|
+
this.extra = extra
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createAutomationService(options) {
|
|
63
|
+
const { store, scheduler, runner, calendar, profile, workspaces, version, settings, now = () => Date.now() } = options
|
|
64
|
+
let chain = Promise.resolve()
|
|
65
|
+
|
|
66
|
+
function serialize(operation) {
|
|
67
|
+
const next = chain.then(operation, operation)
|
|
68
|
+
chain = next.then(() => {}, () => {})
|
|
69
|
+
return next
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function withTask(id) {
|
|
73
|
+
const task = await store.getTask(id)
|
|
74
|
+
if (task === undefined) throw new AutomationError('task_not_found', '任务不存在。', 404)
|
|
75
|
+
return task
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function runningTask(task) {
|
|
79
|
+
const active = await store.activeRunForTask(task.id)
|
|
80
|
+
if (active !== undefined) {
|
|
81
|
+
throw new AutomationError('run_in_progress', '任务仍在运行,请先取消或等待终态。', 409, { runId: active.id })
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function assertWorkspace(workspaceId) {
|
|
86
|
+
if (workspaceId === undefined || workspaceId === '') return
|
|
87
|
+
if (workspaces === undefined) {
|
|
88
|
+
throw new AutomationError('workspace_unavailable', '工作区服务暂时不可用,不能指定工作区。', 503)
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
await workspaces.validate(workspaceId)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (error?.code === 'workspace_not_found') {
|
|
94
|
+
throw new AutomationError('workspace_not_found', error.message, 400)
|
|
95
|
+
}
|
|
96
|
+
throw new AutomationError('workspace_unavailable', error?.message ?? '工作区服务暂时不可用。', 503)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function listTasks() {
|
|
101
|
+
const tasks = await store.listTasks()
|
|
102
|
+
const { runs } = await store.listRunsPage({ limit: 2_000 })
|
|
103
|
+
const runsByTask = new Map()
|
|
104
|
+
for (const run of runs) {
|
|
105
|
+
const taskRuns = runsByTask.get(run.taskId)
|
|
106
|
+
if (taskRuns === undefined) runsByTask.set(run.taskId, [run])
|
|
107
|
+
else taskRuns.push(run)
|
|
108
|
+
}
|
|
109
|
+
const notifications = await store.listNotifications({ unreadOnly: true, limit: 200 })
|
|
110
|
+
const unread = new Map()
|
|
111
|
+
for (const item of notifications) {
|
|
112
|
+
if (item.taskId === undefined) continue
|
|
113
|
+
unread.set(item.taskId, (unread.get(item.taskId) ?? 0) + 1)
|
|
114
|
+
}
|
|
115
|
+
return tasks.map((task) => ({
|
|
116
|
+
...store.decorateTask(task, runsByTask.get(task.id) ?? []),
|
|
117
|
+
unread: unread.get(task.id) ?? 0,
|
|
118
|
+
}))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function meta() {
|
|
122
|
+
let storage = { state: 'ready', error: null }
|
|
123
|
+
try {
|
|
124
|
+
await store.open()
|
|
125
|
+
} catch (error) {
|
|
126
|
+
storage = { state: 'unavailable', error: sanitizeMessage(String(error)) }
|
|
127
|
+
}
|
|
128
|
+
let workspaceList = []
|
|
129
|
+
let workspaceError
|
|
130
|
+
try {
|
|
131
|
+
workspaceList = await workspaces?.list?.() ?? []
|
|
132
|
+
} catch (error) {
|
|
133
|
+
workspaceError = sanitizeMessage(String(error?.message ?? error))
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
profile: { key: store.profileKey, name: profile.displayName, state: 'current' },
|
|
137
|
+
version,
|
|
138
|
+
storage,
|
|
139
|
+
workspaces: workspaceList,
|
|
140
|
+
defaultWorkspaceId: workspaceList.find((item) => item.default === true)?.id ?? null,
|
|
141
|
+
...(workspaceError === undefined ? {} : { workspaceError }),
|
|
142
|
+
scheduler: scheduler === undefined ? { state: 'unavailable' } : scheduler.status(),
|
|
143
|
+
calendar: calendar?.status?.(new Date(now())) ?? { available: false, source: 'none' },
|
|
144
|
+
execution: {
|
|
145
|
+
preset: 'capital-generation',
|
|
146
|
+
cwdKind: 'profile',
|
|
147
|
+
policy: 'automation-v1',
|
|
148
|
+
modes: ['news', 'market', 'review', 'research'],
|
|
149
|
+
cadences: ['daily', 'weekly', 'monthly'],
|
|
150
|
+
limits: {
|
|
151
|
+
runTimeoutMinutes: [MIN_RUN_TIMEOUT_MINUTES, MAX_RUN_TIMEOUT_MINUTES],
|
|
152
|
+
idleTimeoutMinutes: [MIN_IDLE_TIMEOUT_MINUTES, MAX_IDLE_TIMEOUT_MINUTES],
|
|
153
|
+
maxSteps: [MIN_MAX_STEPS, MAX_MAX_STEPS],
|
|
154
|
+
maxToolCalls: [MIN_MAX_TOOL_CALLS, MAX_MAX_TOOL_CALLS],
|
|
155
|
+
},
|
|
156
|
+
settings: settings(),
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function createTask(input) {
|
|
162
|
+
const parsed = parseStrict(TaskCreateSchema, input, 'task')
|
|
163
|
+
const defaults = { ...DEFAULT_AUTOMATION_SETTINGS, ...settings() }
|
|
164
|
+
const resolved = {
|
|
165
|
+
...parsed,
|
|
166
|
+
limits: parsed.limits ?? {
|
|
167
|
+
runTimeoutMinutes: defaults.runTimeoutMinutes,
|
|
168
|
+
idleTimeoutMinutes: defaults.idleTimeoutMinutes,
|
|
169
|
+
maxSteps: defaults.maxSteps,
|
|
170
|
+
maxToolCalls: defaults.maxToolCalls,
|
|
171
|
+
},
|
|
172
|
+
notifications: parsed.notifications ?? {
|
|
173
|
+
inApp: defaults.inAppNotifications !== false,
|
|
174
|
+
onFailure: true,
|
|
175
|
+
onSuccess: false,
|
|
176
|
+
},
|
|
177
|
+
}
|
|
178
|
+
return serialize(async () => {
|
|
179
|
+
await assertWorkspace(parsed.workspaceId)
|
|
180
|
+
const record = await store.createTask(resolved)
|
|
181
|
+
return store.getTask(record.id)
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function patchTask(id, input) {
|
|
186
|
+
const parsed = parseStrict(TaskPatchSchema, input, 'task')
|
|
187
|
+
if (Object.keys(parsed).length === 0) throw new AutomationError('empty_patch', '没有需要修改的字段。', 400)
|
|
188
|
+
return serialize(async () => {
|
|
189
|
+
await withTask(id)
|
|
190
|
+
await assertWorkspace(parsed.workspaceId)
|
|
191
|
+
const record = await store.patchTask(id, parsed)
|
|
192
|
+
if (record === undefined) throw new AutomationError('task_not_found', '任务不存在。', 404)
|
|
193
|
+
return store.getTask(id)
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function deleteTask(id) {
|
|
198
|
+
return serialize(async () => {
|
|
199
|
+
const task = await withTask(id)
|
|
200
|
+
if (task.origin !== 'custom') throw new AutomationError('builtin_not_deletable', '内置任务只能暂停或休眠,不能删除。', 409)
|
|
201
|
+
await runningTask(task)
|
|
202
|
+
const deleted = await store.deleteTask(id)
|
|
203
|
+
if (!deleted) throw new AutomationError('task_not_found', '任务不存在。', 404)
|
|
204
|
+
return { deleted: true, id }
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function changeState(id, action) {
|
|
209
|
+
return serialize(async () => {
|
|
210
|
+
const task = await withTask(id)
|
|
211
|
+
if (action === 'activate') {
|
|
212
|
+
if (task.state === 'active') return task
|
|
213
|
+
return store.getTask((await store.setTaskState(id, 'active')).id)
|
|
214
|
+
}
|
|
215
|
+
if (action === 'pause') {
|
|
216
|
+
if (task.state === 'paused') return task
|
|
217
|
+
if (task.state !== 'active') throw new AutomationError('invalid_state', '只有运行中的任务可以暂停。', 409)
|
|
218
|
+
return store.getTask((await store.setTaskState(id, 'paused')).id)
|
|
219
|
+
}
|
|
220
|
+
if (action === 'hibernate') {
|
|
221
|
+
if (task.origin !== 'builtin') throw new AutomationError('invalid_origin', '自定义任务请直接删除;休眠只用于恢复内置默认。', 409)
|
|
222
|
+
await runningTask(task)
|
|
223
|
+
return store.getTask((await store.setTaskState(id, 'dormant')).id)
|
|
224
|
+
}
|
|
225
|
+
throw new AutomationError('invalid_action', '未知的任务动作。', 400)
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function copyTask(id) {
|
|
230
|
+
return serialize(async () => {
|
|
231
|
+
const task = await withTask(id)
|
|
232
|
+
if (task.origin !== 'builtin') throw new AutomationError('invalid_origin', '只有内置任务可以复制为自定义任务。', 409)
|
|
233
|
+
const record = await store.copyBuiltin(id)
|
|
234
|
+
if (record === undefined) throw new AutomationError('task_not_found', '任务不存在。', 404)
|
|
235
|
+
return store.getTask(record.id)
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function runTask(id) {
|
|
240
|
+
return serialize(async () => {
|
|
241
|
+
const task = await withTask(id)
|
|
242
|
+
if (scheduler === undefined) throw new AutomationError('scheduler_unavailable', '调度器不可用。', 503)
|
|
243
|
+
const result = await scheduler.runNow(task)
|
|
244
|
+
if (!result.ok) throw new AutomationError(result.code, result.message, result.code === 'scheduler_unavailable' ? 503 : 409, result.runId === undefined ? {} : { runId: result.runId })
|
|
245
|
+
return result.run
|
|
246
|
+
})
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function cancelRun(id) {
|
|
250
|
+
return serialize(async () => {
|
|
251
|
+
const run = await store.getRun(id)
|
|
252
|
+
if (run === undefined) throw new AutomationError('run_not_found', '运行记录不存在。', 404)
|
|
253
|
+
if (store.isActiveRun(run.status) !== true) throw new AutomationError('run_not_active', '该运行已经结束。', 409)
|
|
254
|
+
const live = runner?.cancelRun?.(id) === true
|
|
255
|
+
if (!live) {
|
|
256
|
+
await store.updateRun(id, {
|
|
257
|
+
status: 'canceled',
|
|
258
|
+
finishedAt: new Date(now()).toISOString(),
|
|
259
|
+
durationMs: Math.max(0, now() - Date.parse(run.queuedAt)),
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
return { canceling: true, id }
|
|
263
|
+
})
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function getRun(id) {
|
|
267
|
+
const run = await store.getRun(id)
|
|
268
|
+
if (run === undefined) throw new AutomationError('run_not_found', '运行记录不存在。', 404)
|
|
269
|
+
return {
|
|
270
|
+
...store.publicRun(run),
|
|
271
|
+
attempts: await store.listAttemptsForRun(id),
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function listRuns(query = {}) {
|
|
276
|
+
const limit = runLimitOf(query.limit)
|
|
277
|
+
const before = runCursorOf(query.before)
|
|
278
|
+
const page = await store.listRunsPage({ limit, before })
|
|
279
|
+
if (page.invalidCursor) throw new AutomationError('invalid_cursor', '运行分页游标无效。', 400)
|
|
280
|
+
return {
|
|
281
|
+
runs: page.runs.map((run) => store.publicRun(run)),
|
|
282
|
+
nextBefore: page.nextBefore ?? null,
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function listNotifications(query = {}) {
|
|
287
|
+
let unreadOnly = false
|
|
288
|
+
if (query.unread !== undefined) {
|
|
289
|
+
if (query.unread === true || query.unread === '1' || query.unread === 'true') unreadOnly = true
|
|
290
|
+
else if (query.unread !== false && query.unread !== '0' && query.unread !== 'false') {
|
|
291
|
+
throw invalidQuery('通知 unread 参数无效。')
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
notifications: await store.listNotifications({ unreadOnly, limit: 200 }),
|
|
296
|
+
unread: await store.unreadNotificationCount(),
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function markNotificationsRead(input) {
|
|
301
|
+
const parsed = parseStrict(NotificationsReadSchema, input, 'notifications/read')
|
|
302
|
+
return { changed: await store.markNotificationsRead(parsed) }
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function taskDetail(id) {
|
|
306
|
+
const task = await withTask(id)
|
|
307
|
+
const page = await store.listRunsPage({ limit: 20, taskId: id })
|
|
308
|
+
const runs = page.runs.map((run) => store.publicRun(run))
|
|
309
|
+
const notifications = await store.listNotifications({ taskId: id, limit: 200 })
|
|
310
|
+
return { task: store.decorateTask(task, page.runs), runs, notifications }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
serialize,
|
|
315
|
+
meta,
|
|
316
|
+
listTasks,
|
|
317
|
+
taskDetail,
|
|
318
|
+
createTask,
|
|
319
|
+
patchTask,
|
|
320
|
+
deleteTask,
|
|
321
|
+
changeState,
|
|
322
|
+
copyTask,
|
|
323
|
+
runTask,
|
|
324
|
+
cancelRun,
|
|
325
|
+
getRun,
|
|
326
|
+
listRuns,
|
|
327
|
+
listNotifications,
|
|
328
|
+
markNotificationsRead,
|
|
329
|
+
storageState: async () => {
|
|
330
|
+
try {
|
|
331
|
+
await store.open()
|
|
332
|
+
return { state: 'ready' }
|
|
333
|
+
} catch (error) {
|
|
334
|
+
return { state: 'unavailable', error: sanitizeMessage(String(error)) }
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
}
|
|
338
|
+
}
|