@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.
- package/CHANGELOG.md +29 -0
- package/README.md +70 -70
- package/automation/builtins.js +147 -0
- package/automation/calendar-source.js +361 -0
- package/automation/calendar.js +66 -0
- package/automation/client.js +351 -0
- package/automation/client.src.cjs +2734 -0
- package/automation/defaults.js +75 -0
- package/automation/index.js +188 -0
- package/automation/owner.js +222 -0
- package/automation/package.json +51 -0
- package/automation/policy.js +105 -0
- package/automation/redact.js +25 -0
- package/automation/route.js +241 -0
- package/automation/runner.js +912 -0
- package/automation/scheduler.js +513 -0
- package/automation/schema.js +375 -0
- package/automation/service.js +338 -0
- package/automation/settings.js +48 -0
- package/automation/store.js +732 -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/sources/wind-mcp.js +3 -3
- 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 +5 -2
- package/watchlist/package.json +1 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/capital-automation/*` route. Authentication is the first statement: a rejected request
|
|
3
|
+
* never reads the body, a task id, or storage. Bodies are strict JSON and capped before parsing.
|
|
4
|
+
*/
|
|
5
|
+
import { MAX_REQUEST_BODY_BYTES } from './defaults.js'
|
|
6
|
+
import { AutomationError } from './service.js'
|
|
7
|
+
import { SchemaError } from './schema.js'
|
|
8
|
+
import { sanitizeMessage } from './redact.js'
|
|
9
|
+
|
|
10
|
+
export const ROUTE_PREFIX = '/capital-automation'
|
|
11
|
+
const ID_PATTERN = /^[A-Za-z0-9_-]{1,120}$/
|
|
12
|
+
|
|
13
|
+
function send(res, status, payload, extraHeaders = {}) {
|
|
14
|
+
const body = JSON.stringify(payload)
|
|
15
|
+
res.statusCode = status
|
|
16
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
17
|
+
res.setHeader('cache-control', 'no-store')
|
|
18
|
+
res.setHeader('x-content-type-options', 'nosniff')
|
|
19
|
+
for (const [name, value] of Object.entries(extraHeaders)) res.setHeader(name, value)
|
|
20
|
+
res.end(body)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function plain(res, status, text) {
|
|
24
|
+
res.statusCode = status
|
|
25
|
+
res.setHeader('content-type', 'text/plain; charset=utf-8')
|
|
26
|
+
res.setHeader('cache-control', 'no-store')
|
|
27
|
+
res.end(text)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function requestIdOf() {
|
|
31
|
+
return `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MAX_QUERY_KEYS = 4
|
|
35
|
+
const MAX_QUERY_VALUE_CHARS = 512
|
|
36
|
+
const MAX_REQUEST_URL_CHARS = 16 * 1024
|
|
37
|
+
|
|
38
|
+
function invalidJson() {
|
|
39
|
+
return new AutomationError('invalid_json', '请求体不是合法 JSON。', 400)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function bodyTextOf(value) {
|
|
43
|
+
if (typeof value === 'string') return value
|
|
44
|
+
if (Buffer.isBuffer(value)) return value.toString('utf8')
|
|
45
|
+
if (value instanceof Uint8Array) return Buffer.from(value).toString('utf8')
|
|
46
|
+
let text
|
|
47
|
+
try {
|
|
48
|
+
text = JSON.stringify(value)
|
|
49
|
+
} catch {
|
|
50
|
+
throw invalidJson()
|
|
51
|
+
}
|
|
52
|
+
if (typeof text !== 'string') throw invalidJson()
|
|
53
|
+
return text
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function contentLengthOf(req) {
|
|
57
|
+
const headers = req?.headers
|
|
58
|
+
const raw = headers?.['content-length'] ?? headers?.['Content-Length']
|
|
59
|
+
if (raw === undefined) return undefined
|
|
60
|
+
const value = Number(raw)
|
|
61
|
+
if (!Number.isSafeInteger(value) || value < 0) throw invalidJson()
|
|
62
|
+
return value
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readBody(req) {
|
|
66
|
+
const declaredLength = contentLengthOf(req)
|
|
67
|
+
if (declaredLength !== undefined && declaredLength > MAX_REQUEST_BODY_BYTES) {
|
|
68
|
+
throw new AutomationError('body_too_large', '请求体过大。', 413)
|
|
69
|
+
}
|
|
70
|
+
if (req.body !== undefined) {
|
|
71
|
+
const text = bodyTextOf(req.body)
|
|
72
|
+
if (Buffer.byteLength(text, 'utf8') > MAX_REQUEST_BODY_BYTES) {
|
|
73
|
+
throw new AutomationError('body_too_large', '请求体过大。', 413)
|
|
74
|
+
}
|
|
75
|
+
if (text.trim().length === 0) return undefined
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(text)
|
|
78
|
+
} catch {
|
|
79
|
+
throw invalidJson()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (typeof req[Symbol.asyncIterator] !== 'function') return undefined
|
|
83
|
+
const chunks = []
|
|
84
|
+
let bytes = 0
|
|
85
|
+
try {
|
|
86
|
+
for await (const chunk of req) {
|
|
87
|
+
const buffer = Buffer.isBuffer(chunk)
|
|
88
|
+
? chunk
|
|
89
|
+
: typeof chunk === 'string' || chunk instanceof Uint8Array
|
|
90
|
+
? Buffer.from(chunk)
|
|
91
|
+
: undefined
|
|
92
|
+
if (buffer === undefined) throw invalidJson()
|
|
93
|
+
bytes += buffer.byteLength
|
|
94
|
+
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
95
|
+
throw new AutomationError('body_too_large', '请求体过大。', 413)
|
|
96
|
+
}
|
|
97
|
+
chunks.push(buffer)
|
|
98
|
+
}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error instanceof AutomationError) throw error
|
|
101
|
+
throw invalidJson()
|
|
102
|
+
}
|
|
103
|
+
const text = Buffer.concat(chunks).toString('utf8').trim()
|
|
104
|
+
if (text.length === 0) return undefined
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(text)
|
|
107
|
+
} catch {
|
|
108
|
+
throw invalidJson()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function decodeId(raw) {
|
|
113
|
+
let id
|
|
114
|
+
try {
|
|
115
|
+
id = decodeURIComponent(raw)
|
|
116
|
+
} catch {
|
|
117
|
+
throw new AutomationError('invalid_id', '任务或运行 id 无效。', 400)
|
|
118
|
+
}
|
|
119
|
+
if (!ID_PATTERN.test(id)) throw new AutomationError('invalid_id', '任务或运行 id 无效。', 400)
|
|
120
|
+
return id
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function queryOf(url, allowedKeys) {
|
|
124
|
+
const result = {}
|
|
125
|
+
let count = 0
|
|
126
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
127
|
+
count += 1
|
|
128
|
+
if (count > MAX_QUERY_KEYS || !allowedKeys.has(key) || Object.prototype.hasOwnProperty.call(result, key)) {
|
|
129
|
+
throw new AutomationError('invalid_query', '查询参数无效。', 400)
|
|
130
|
+
}
|
|
131
|
+
if (value.length > MAX_QUERY_VALUE_CHARS) {
|
|
132
|
+
throw new AutomationError('invalid_query', '查询参数过长。', 400)
|
|
133
|
+
}
|
|
134
|
+
result[key] = value
|
|
135
|
+
}
|
|
136
|
+
return result
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function createAutomationRouteHandler(service, options = {}) {
|
|
140
|
+
const authorize = options.authorize
|
|
141
|
+
const logger = options.logger
|
|
142
|
+
return async function handler(req, res) {
|
|
143
|
+
const requestId = requestIdOf()
|
|
144
|
+
res.setHeader('x-request-id', requestId)
|
|
145
|
+
if (typeof authorize !== 'function') {
|
|
146
|
+
plain(res, 401, 'unauthorized')
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
let rejection
|
|
150
|
+
try {
|
|
151
|
+
rejection = authorize(req)
|
|
152
|
+
} catch {
|
|
153
|
+
rejection = 401
|
|
154
|
+
}
|
|
155
|
+
if (rejection !== undefined && rejection !== null) {
|
|
156
|
+
plain(res, rejection, rejection === 401 ? 'unauthorized' : 'forbidden')
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
let url
|
|
160
|
+
const rawUrl = req.url ?? '/'
|
|
161
|
+
if (typeof rawUrl !== 'string') {
|
|
162
|
+
send(res, 400, { ok: false, code: 'bad_request', message: '请求地址无效。', requestId })
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
if (rawUrl.length > MAX_REQUEST_URL_CHARS) {
|
|
166
|
+
send(res, 414, { ok: false, code: 'url_too_long', message: '请求地址过长。', requestId })
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
url = new URL(rawUrl, 'http://localhost')
|
|
171
|
+
} catch {
|
|
172
|
+
send(res, 400, { ok: false, code: 'bad_request', message: '请求地址无效。', requestId })
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
const pathname = url.pathname
|
|
176
|
+
if (pathname !== ROUTE_PREFIX && !pathname.startsWith(`${ROUTE_PREFIX}/`)) {
|
|
177
|
+
send(res, 404, { ok: false, code: 'not_found', message: '路径不存在。', requestId })
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
const suffix = pathname.slice(ROUTE_PREFIX.length) || '/'
|
|
181
|
+
const method = String(req.method ?? 'GET').toUpperCase()
|
|
182
|
+
try {
|
|
183
|
+
const payload = await dispatch({ req, method, suffix, url, service, requestId })
|
|
184
|
+
send(res, payload.status ?? 200, { ok: true, ...payload.body }, payload.headers)
|
|
185
|
+
} catch (error) {
|
|
186
|
+
const known = error instanceof AutomationError
|
|
187
|
+
const schema = error instanceof SchemaError
|
|
188
|
+
const storage = error?.code === 'storage_unavailable'
|
|
189
|
+
const status = known ? error.status : schema ? 400 : storage ? 503 : 500
|
|
190
|
+
const code = known ? error.code : schema ? 'invalid_request' : storage ? 'storage_unavailable' : 'internal_error'
|
|
191
|
+
const message = known || schema ? error.message : storage ? '投研自动化存储暂时不可用。' : '服务暂时不可用。'
|
|
192
|
+
if (!known && !schema && !storage) {
|
|
193
|
+
logger?.warn?.(`capital-automation: ${requestId} ${method} ${pathname} 失败:${String(error)}`)
|
|
194
|
+
}
|
|
195
|
+
send(res, status, {
|
|
196
|
+
ok: false,
|
|
197
|
+
code,
|
|
198
|
+
message: sanitizeMessage(message),
|
|
199
|
+
requestId,
|
|
200
|
+
...(known ? error.extra : {}),
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function dispatch({ req, method, suffix, url, service, requestId }) {
|
|
207
|
+
if (suffix === '/meta') {
|
|
208
|
+
if (method !== 'GET') throw new AutomationError('method_not_allowed', '只支持 GET。', 405)
|
|
209
|
+
return { body: await service.meta() }
|
|
210
|
+
}
|
|
211
|
+
if (suffix === '/tasks' && method === 'GET') return { body: { tasks: await service.listTasks() } }
|
|
212
|
+
if (suffix === '/tasks' && method === 'POST') return { body: { task: await service.createTask(await readBody(req)) }, status: 201 }
|
|
213
|
+
const taskAction = /^\/tasks\/([^/]+)\/(activate|pause|hibernate|copy|run)$/.exec(suffix)
|
|
214
|
+
if (taskAction !== null && method === 'POST') {
|
|
215
|
+
const id = decodeId(taskAction[1])
|
|
216
|
+
const action = taskAction[2]
|
|
217
|
+
if (action === 'copy') return { body: { task: await service.copyTask(id) }, status: 201 }
|
|
218
|
+
if (action === 'run') return { body: { run: await service.runTask(id) }, status: 202 }
|
|
219
|
+
return { body: { task: await service.changeState(id, action) } }
|
|
220
|
+
}
|
|
221
|
+
const taskDetail = /^\/tasks\/([^/]+)$/.exec(suffix)
|
|
222
|
+
if (taskDetail !== null) {
|
|
223
|
+
const id = decodeId(taskDetail[1])
|
|
224
|
+
if (method === 'GET') return { body: await service.taskDetail(id) }
|
|
225
|
+
if (method === 'PATCH') return { body: { task: await service.patchTask(id, await readBody(req)) } }
|
|
226
|
+
if (method === 'DELETE') return { body: await service.deleteTask(id) }
|
|
227
|
+
throw new AutomationError('method_not_allowed', '只支持 GET / PATCH / DELETE。', 405)
|
|
228
|
+
}
|
|
229
|
+
if (suffix === '/runs' && method === 'GET') {
|
|
230
|
+
return { body: await service.listRuns(queryOf(url, new Set(['limit', 'before']))) }
|
|
231
|
+
}
|
|
232
|
+
if (suffix === '/notifications' && method === 'GET') {
|
|
233
|
+
return { body: await service.listNotifications(queryOf(url, new Set(['unread']))) }
|
|
234
|
+
}
|
|
235
|
+
if (suffix === '/notifications/read' && method === 'POST') return { body: await service.markNotificationsRead(await readBody(req)) }
|
|
236
|
+
const runAction = /^\/runs\/([^/]+)\/cancel$/.exec(suffix)
|
|
237
|
+
if (runAction !== null && method === 'POST') return { body: await service.cancelRun(decodeId(runAction[1])), status: 202 }
|
|
238
|
+
const runDetail = /^\/runs\/([^/]+)$/.exec(suffix)
|
|
239
|
+
if (runDetail !== null && method === 'GET') return { body: { run: await service.getRun(decodeId(runDetail[1])) } }
|
|
240
|
+
throw new AutomationError('not_found', '路径不存在。', 404, { requestId })
|
|
241
|
+
}
|