@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,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
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Settings resolution at call time, in the documented order:
3
+ * code defaults -> deployment row values -> current profile user settings -> task override.
4
+ * The task override is applied by the task record, not here.
5
+ */
6
+ import { readCapitalEntry } from '../lib/capital-settings.js'
7
+ import {
8
+ DEFAULT_AUTOMATION_SETTINGS,
9
+ MAX_IDLE_TIMEOUT_MINUTES,
10
+ MAX_MAX_STEPS,
11
+ MAX_MAX_TOOL_CALLS,
12
+ MAX_RUN_TIMEOUT_MINUTES,
13
+ MAX_TICK_MS,
14
+ MIN_IDLE_TIMEOUT_MINUTES,
15
+ MIN_MAX_STEPS,
16
+ MIN_MAX_TOOL_CALLS,
17
+ MIN_RUN_TIMEOUT_MINUTES,
18
+ MIN_TICK_MS,
19
+ } from './defaults.js'
20
+
21
+ function intIn(value, minimum, maximum, fallback) {
22
+ const number = typeof value === 'number' ? value : Number(value)
23
+ if (!Number.isFinite(number)) return fallback
24
+ return Math.min(maximum, Math.max(minimum, Math.round(number)))
25
+ }
26
+
27
+ export function normalizeAutomationSettings(value = {}, fallback = DEFAULT_AUTOMATION_SETTINGS) {
28
+ const source = value ?? {}
29
+ return {
30
+ tickMs: intIn(source.tickMs, MIN_TICK_MS, MAX_TICK_MS, fallback.tickMs),
31
+ runTimeoutMinutes: intIn(source.runTimeoutMinutes, MIN_RUN_TIMEOUT_MINUTES, MAX_RUN_TIMEOUT_MINUTES, fallback.runTimeoutMinutes),
32
+ idleTimeoutMinutes: intIn(source.idleTimeoutMinutes, MIN_IDLE_TIMEOUT_MINUTES, MAX_IDLE_TIMEOUT_MINUTES, fallback.idleTimeoutMinutes),
33
+ maxSteps: intIn(source.maxSteps, MIN_MAX_STEPS, MAX_MAX_STEPS, fallback.maxSteps),
34
+ maxToolCalls: intIn(source.maxToolCalls, MIN_MAX_TOOL_CALLS, MAX_MAX_TOOL_CALLS, fallback.maxToolCalls),
35
+ inAppNotifications: source.inAppNotifications === undefined ? fallback.inAppNotifications : source.inAppNotifications !== false,
36
+ }
37
+ }
38
+
39
+ export function resolveAutomationSettings(ctx, deployment = {}) {
40
+ let user
41
+ try {
42
+ user = readCapitalEntry(ctx).value?.automation
43
+ } catch {
44
+ user = undefined
45
+ }
46
+ const deployed = normalizeAutomationSettings(deployment)
47
+ return normalizeAutomationSettings(user, deployed)
48
+ }