@3minapi/cli 0.0.0-stage → 0.22.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/LICENSE +21 -0
- package/README.md +82 -2
- package/dist/bin.js +54 -0
- package/dist/cli.js +241 -0
- package/dist/commands/auth.js +573 -0
- package/dist/commands/common.js +48 -0
- package/dist/commands/dashboard.js +298 -0
- package/dist/commands/delete.js +167 -0
- package/dist/commands/doctor.js +326 -0
- package/dist/commands/guide.js +84 -0
- package/dist/commands/index.js +28 -0
- package/dist/commands/init.js +248 -0
- package/dist/commands/logs.js +154 -0
- package/dist/commands/new.js +109 -0
- package/dist/commands/paths.js +64 -0
- package/dist/commands/reload.js +32 -0
- package/dist/commands/results.js +445 -0
- package/dist/commands/start.js +443 -0
- package/dist/commands/stop.js +68 -0
- package/dist/commands/tasks.js +93 -0
- package/dist/commands/worker.js +178 -0
- package/dist/core/api.js +132 -0
- package/dist/core/config.js +188 -0
- package/dist/core/control-db.js +462 -0
- package/dist/core/credentials.js +75 -0
- package/dist/core/domain/constants.js +121 -0
- package/dist/core/domain/contract.js +102 -0
- package/dist/core/domain/errors.js +57 -0
- package/dist/core/domain/index.js +3 -0
- package/dist/core/domain/types.js +1 -0
- package/dist/core/error-report.js +103 -0
- package/dist/core/host/detach.js +49 -0
- package/dist/core/host/live-connection.js +270 -0
- package/dist/core/host/live-receiver.js +264 -0
- package/dist/core/host/module.js +1 -0
- package/dist/core/host/poller.js +113 -0
- package/dist/core/host/pull-step.js +54 -0
- package/dist/core/host/receiver.js +1 -0
- package/dist/core/host/reload.js +34 -0
- package/dist/core/host/runner-lock.js +245 -0
- package/dist/core/host/session.js +84 -0
- package/dist/core/host/usage.js +20 -0
- package/dist/core/log-read.js +59 -0
- package/dist/core/logger.js +206 -0
- package/dist/core/names.js +27 -0
- package/dist/core/network.js +10 -0
- package/dist/core/open-browser.js +26 -0
- package/dist/core/output.js +68 -0
- package/dist/core/paths.js +26 -0
- package/dist/core/project.js +85 -0
- package/dist/core/prompt.js +140 -0
- package/dist/core/redact.js +45 -0
- package/dist/core/secure-fs.js +50 -0
- package/dist/core/text-file.js +12 -0
- package/dist/core/text.js +27 -0
- package/dist/core/time.js +84 -0
- package/dist/core/update-notice.js +142 -0
- package/dist/core/version.js +16 -0
- package/dist/core/warnings.js +10 -0
- package/dist/help.js +144 -0
- package/dist/modules/dashboard/assets.js +411 -0
- package/dist/modules/dashboard/data.js +565 -0
- package/dist/modules/dashboard/guard.js +75 -0
- package/dist/modules/dashboard/lock.js +93 -0
- package/dist/modules/dashboard/server.js +295 -0
- package/dist/modules/dashboard/views/html.js +114 -0
- package/dist/modules/dashboard/views/layout.js +59 -0
- package/dist/modules/dashboard/views/logs.js +122 -0
- package/dist/modules/dashboard/views/overview.js +316 -0
- package/dist/modules/dashboard/views/runs.js +486 -0
- package/dist/modules/dashboard/views/workers.js +157 -0
- package/dist/modules/workers/builtin/host-identity.js +59 -0
- package/dist/modules/workers/builtin/results-worker.js +37 -0
- package/dist/modules/workers/local-server.js +244 -0
- package/dist/modules/workers/module.js +98 -0
- package/dist/modules/workers/reload-result.js +16 -0
- package/dist/modules/workers/repeat-scheduler.js +165 -0
- package/dist/modules/workers/results/missing-paths.js +66 -0
- package/dist/modules/workers/results/query.js +175 -0
- package/dist/modules/workers/results/reader-input.js +134 -0
- package/dist/modules/workers/results/reader.js +430 -0
- package/dist/modules/workers/results/results-child.js +64 -0
- package/dist/modules/workers/results/shape.js +212 -0
- package/dist/modules/workers/results/store.js +336 -0
- package/dist/modules/workers/results/substitute.js +30 -0
- package/dist/modules/workers/runtime/env.js +43 -0
- package/dist/modules/workers/runtime/error-chain.js +109 -0
- package/dist/modules/workers/runtime/executor.js +289 -0
- package/dist/modules/workers/runtime/inspect.js +53 -0
- package/dist/modules/workers/runtime/protocol.js +1 -0
- package/dist/modules/workers/runtime/resolve.js +30 -0
- package/dist/modules/workers/runtime/schema.js +85 -0
- package/dist/modules/workers/runtime/thread.js +117 -0
- package/dist/modules/workers/start.js +265 -0
- package/dist/modules/workers/sync.js +241 -0
- package/dist/modules/workers/task-runner.js +892 -0
- package/dist/modules/workers/template.js +100 -0
- package/dist/modules/workers/worker-set.js +122 -0
- package/dist/modules/workers/workers.js +66 -0
- package/package.json +48 -5
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { StopError, builtinResultsName } from "../../../core/domain/index.js";
|
|
3
|
+
export const HOST_META = {
|
|
4
|
+
id: 'host_id',
|
|
5
|
+
name: 'host_name',
|
|
6
|
+
tokenFp: 'host_token_fp'
|
|
7
|
+
};
|
|
8
|
+
export function tokenFingerprint(token) {
|
|
9
|
+
return createHash('sha256').update(token).digest('hex').slice(0, 12);
|
|
10
|
+
}
|
|
11
|
+
export function cacheHostIdentity(db, token, host, now) {
|
|
12
|
+
try {
|
|
13
|
+
const previous = db.getMeta(HOST_META.id);
|
|
14
|
+
db.setMetas({
|
|
15
|
+
[HOST_META.id]: host.id,
|
|
16
|
+
[HOST_META.name]: host.name,
|
|
17
|
+
[HOST_META.tokenFp]: tokenFingerprint(token)
|
|
18
|
+
}, now);
|
|
19
|
+
return previous !== null && previous !== host.id ? previous : null;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function hostChangedWarning(previousId, host) {
|
|
26
|
+
return (`this token is for host "${host.name}" (${host.id}); this PC was host ${previousId}. ` +
|
|
27
|
+
"The previous host's task history and saved results stay on this PC; remote AIs on the new host " +
|
|
28
|
+
"cannot read those results. Remove the task history with '3minapi tasks prune' if you no longer need it.");
|
|
29
|
+
}
|
|
30
|
+
export function cachedHostIdentity(db, token) {
|
|
31
|
+
if (db.getMeta(HOST_META.tokenFp) !== tokenFingerprint(token))
|
|
32
|
+
return null;
|
|
33
|
+
const id = db.getMeta(HOST_META.id);
|
|
34
|
+
const name = db.getMeta(HOST_META.name);
|
|
35
|
+
return id && name ? { id, name } : null;
|
|
36
|
+
}
|
|
37
|
+
export async function resolveHostIdentity(db, api, token, now) {
|
|
38
|
+
const cached = cachedHostIdentity(db, token);
|
|
39
|
+
if (cached)
|
|
40
|
+
return { host: cached };
|
|
41
|
+
try {
|
|
42
|
+
const res = await api.getHost();
|
|
43
|
+
const host = { id: res.host.id, name: res.host.name };
|
|
44
|
+
const previous = cacheHostIdentity(db, token, host, now);
|
|
45
|
+
return previous === null ? { host } : { host, previousHostId: previous };
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
if (err instanceof StopError)
|
|
49
|
+
throw err;
|
|
50
|
+
return { host: null, error: err.message };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function builtinResultsSpec(host) {
|
|
54
|
+
return {
|
|
55
|
+
name: builtinResultsName(host.id),
|
|
56
|
+
description: `Built-in reader for the task results saved on host ${JSON.stringify(host.name)}. ` +
|
|
57
|
+
'input: {"op":"describe"|"query"|"read", …} — see help(tasks) for the fields and examples.'
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { RESULTS_QUERY_TIMEOUT_MS } from "../../../core/domain/index.js";
|
|
2
|
+
import { runReaderChild } from "../results/query.js";
|
|
3
|
+
import { parseReaderInput } from "../results/reader-input.js";
|
|
4
|
+
import { resultsDbPath } from "../results/store.js";
|
|
5
|
+
export function resultsWorker(root, hostId, timeoutMs = RESULTS_QUERY_TIMEOUT_MS) {
|
|
6
|
+
return async (input, signal) => {
|
|
7
|
+
const parsed = parseReaderInput(input);
|
|
8
|
+
if (!parsed.ok)
|
|
9
|
+
return { status: 'failed', reason: 'invalid_input', message: parsed.message };
|
|
10
|
+
let r;
|
|
11
|
+
try {
|
|
12
|
+
r = await runReaderChild(root, hostId, parsed.req, timeoutMs, signal);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return {
|
|
16
|
+
status: 'failed',
|
|
17
|
+
reason: 'query_error',
|
|
18
|
+
message: "could not read the saved results on the user's PC"
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
if (r === 'cancelled')
|
|
22
|
+
return { status: 'failed', reason: 'query_error', message: 'cancelled' };
|
|
23
|
+
if (r === 'timeout') {
|
|
24
|
+
return {
|
|
25
|
+
status: 'failed',
|
|
26
|
+
reason: 'timeout',
|
|
27
|
+
message: `did not finish within ${timeoutMs / 1000}s — narrow the scope or aggregate in SQL`
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (!r.ok)
|
|
31
|
+
return { status: 'failed', reason: r.reason, message: withoutPaths(root, r.message) };
|
|
32
|
+
return { status: 'completed', resultJson: JSON.stringify(r.result) };
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function withoutPaths(root, message) {
|
|
36
|
+
return message.split(resultsDbPath(root)).join('data.db').split(root).join('<project>');
|
|
37
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { PUSH_TIMESTAMP_TOLERANCE_S, REPORT_MAX_BYTES, WORKER_KEY_ENV, WORKER_SECRET_ENV, isPullWorkerName } from "../../core/domain/index.js";
|
|
4
|
+
import { resolveWorkerFile } from "./runtime/resolve.js";
|
|
5
|
+
const TASK_PATH = /^\/task\/([^/]+)$/;
|
|
6
|
+
const UNAUTHORIZED = { status: 401, body: { success: false, error: 'unauthorized' } };
|
|
7
|
+
export async function startLocalServer(opts) {
|
|
8
|
+
const tokenDigest = sha256(opts.token);
|
|
9
|
+
const server = createServer((req, res) => {
|
|
10
|
+
handle(opts, tokenDigest, req, actualPort)
|
|
11
|
+
.then((reply) => send(res, reply))
|
|
12
|
+
.catch((err) => {
|
|
13
|
+
opts.onError?.(err);
|
|
14
|
+
send(res, { status: 500, body: error('internal error') });
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
const host = opts.public ? '0.0.0.0' : '127.0.0.1';
|
|
18
|
+
await new Promise((resolve, reject) => {
|
|
19
|
+
server.once('error', reject);
|
|
20
|
+
server.listen(opts.port, host, () => {
|
|
21
|
+
server.off('error', reject);
|
|
22
|
+
resolve();
|
|
23
|
+
});
|
|
24
|
+
}).catch((err) => {
|
|
25
|
+
if (err.code === 'EADDRINUSE') {
|
|
26
|
+
throw Object.assign(new Error(`port ${opts.port} is already in use — pick another with --port`), {
|
|
27
|
+
code: err.code
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
throw err;
|
|
31
|
+
});
|
|
32
|
+
const actualPort = server.address().port;
|
|
33
|
+
return {
|
|
34
|
+
url: `http://${opts.public ? '0.0.0.0' : '127.0.0.1'}:${actualPort}`,
|
|
35
|
+
port: actualPort,
|
|
36
|
+
close: () => new Promise((resolve) => {
|
|
37
|
+
server.closeAllConnections();
|
|
38
|
+
server.close(() => resolve());
|
|
39
|
+
})
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async function handle(opts, tokenDigest, req, port) {
|
|
43
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
44
|
+
if (!opts.public) {
|
|
45
|
+
const denied = localGuard(req, port);
|
|
46
|
+
if (denied)
|
|
47
|
+
return denied;
|
|
48
|
+
}
|
|
49
|
+
if (url.pathname === '/health') {
|
|
50
|
+
if (req.method !== 'GET')
|
|
51
|
+
return { status: 405, body: error('use GET') };
|
|
52
|
+
return { status: 200, body: { ok: true, version: opts.version } };
|
|
53
|
+
}
|
|
54
|
+
const match = TASK_PATH.exec(url.pathname);
|
|
55
|
+
if (!match)
|
|
56
|
+
return { status: 404, body: error('not found. Use POST /task/<worker>') };
|
|
57
|
+
if (req.method !== 'POST')
|
|
58
|
+
return { status: 405, body: error('use POST') };
|
|
59
|
+
let name;
|
|
60
|
+
try {
|
|
61
|
+
name = decodeURIComponent(match[1]);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return { status: 404, body: error('not found. Use POST /task/<worker>') };
|
|
65
|
+
}
|
|
66
|
+
let secrets = null;
|
|
67
|
+
if (opts.public) {
|
|
68
|
+
if (!isPullWorkerName(name))
|
|
69
|
+
return { status: 404, body: error(`unknown worker "${name}"`) };
|
|
70
|
+
const now = opts.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
|
|
71
|
+
const headers = signatureHeaders(req, now);
|
|
72
|
+
if (headers === 'missing')
|
|
73
|
+
return UNAUTHORIZED;
|
|
74
|
+
if (headers !== null) {
|
|
75
|
+
opts.onRejected?.(name, headers);
|
|
76
|
+
return UNAUTHORIZED;
|
|
77
|
+
}
|
|
78
|
+
const env = typeof opts.env === 'function' ? opts.env() : opts.env;
|
|
79
|
+
const secret = env[WORKER_SECRET_ENV(name)];
|
|
80
|
+
const key = env[WORKER_KEY_ENV(name)];
|
|
81
|
+
if (!secret || !key) {
|
|
82
|
+
opts.onRejected?.(name, `not configured as a public worker (${WORKER_SECRET_ENV(name)} / ${WORKER_KEY_ENV(name)})`);
|
|
83
|
+
return UNAUTHORIZED;
|
|
84
|
+
}
|
|
85
|
+
secrets = { secret, key };
|
|
86
|
+
}
|
|
87
|
+
if (!opts.public && !bearerMatches(req.headers.authorization, tokenDigest)) {
|
|
88
|
+
return {
|
|
89
|
+
status: 401,
|
|
90
|
+
body: error('missing or wrong local server token (Authorization: Bearer <token>)')
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (!/^application\/json\b/i.test(req.headers['content-type'] ?? '')) {
|
|
94
|
+
return { status: 415, body: error('Content-Type must be application/json') };
|
|
95
|
+
}
|
|
96
|
+
const raw = await readBody(req, REPORT_MAX_BYTES);
|
|
97
|
+
if (raw === null)
|
|
98
|
+
return { status: 413, body: error(`body exceeds ${REPORT_MAX_BYTES} bytes`) };
|
|
99
|
+
let reporter = null;
|
|
100
|
+
if (secrets) {
|
|
101
|
+
const now = opts.nowSeconds?.() ?? Math.floor(Date.now() / 1000);
|
|
102
|
+
const invalid = verifySignature(secrets.secret, req, raw, now);
|
|
103
|
+
if (invalid) {
|
|
104
|
+
opts.onRejected?.(name, invalid);
|
|
105
|
+
return UNAUTHORIZED;
|
|
106
|
+
}
|
|
107
|
+
reporter = opts.reporterFor(secrets.key);
|
|
108
|
+
}
|
|
109
|
+
let body;
|
|
110
|
+
try {
|
|
111
|
+
body = JSON.parse(raw.toString('utf8'));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return { status: 400, body: error('body is not valid JSON') };
|
|
115
|
+
}
|
|
116
|
+
const task = toEnvelope(body, name, req, opts.public);
|
|
117
|
+
if (typeof task === 'string')
|
|
118
|
+
return { status: 400, body: error(task) };
|
|
119
|
+
const found = resolveWorkerFile(opts.workersDir, name);
|
|
120
|
+
if (!found.ok && found.reason === 'worker_not_found')
|
|
121
|
+
return { status: 404, body: error(found.message) };
|
|
122
|
+
const inactive = opts.inactive?.(name) ?? null;
|
|
123
|
+
if (inactive !== null)
|
|
124
|
+
return { status: 409, body: error(inactive) };
|
|
125
|
+
opts.onTask(task, reporter);
|
|
126
|
+
return { status: 202, body: { success: true, task_id: task.task_id } };
|
|
127
|
+
}
|
|
128
|
+
function localGuard(req, port) {
|
|
129
|
+
const allowed = [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`];
|
|
130
|
+
const host = (req.headers.host ?? '').toLowerCase();
|
|
131
|
+
if (!allowed.includes(host)) {
|
|
132
|
+
return { status: 403, body: error('Host header must be 127.0.0.1 or localhost') };
|
|
133
|
+
}
|
|
134
|
+
if (req.headers.origin !== undefined) {
|
|
135
|
+
return { status: 403, body: error('requests from web pages are not accepted') };
|
|
136
|
+
}
|
|
137
|
+
const site = req.headers['sec-fetch-site'];
|
|
138
|
+
if (site !== undefined && site !== 'none') {
|
|
139
|
+
return { status: 403, body: error('requests from web pages are not accepted') };
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
function toEnvelope(body, worker, req, isPublic) {
|
|
144
|
+
if (typeof body !== 'object' || body === null || Array.isArray(body))
|
|
145
|
+
return 'body must be a JSON object';
|
|
146
|
+
const b = body;
|
|
147
|
+
const attempt = Number(req.headers['x-3minapi-delivery-attempt'] ?? 1);
|
|
148
|
+
const delivery_attempt = Number.isInteger(attempt) && attempt > 0 ? attempt : 1;
|
|
149
|
+
if (b.type === 'task.created' && typeof b.data === 'object' && b.data !== null) {
|
|
150
|
+
const d = b.data;
|
|
151
|
+
if (isPublic) {
|
|
152
|
+
if (typeof d.task_id !== 'string' || d.task_id !== req.headers['webhook-id']) {
|
|
153
|
+
return 'data.task_id must match the webhook-id header';
|
|
154
|
+
}
|
|
155
|
+
if (typeof d.callback_url !== 'string')
|
|
156
|
+
return 'data.callback_url is required';
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
task_id: isPublic ? d.task_id : randomUUID(),
|
|
160
|
+
worker,
|
|
161
|
+
input: d.input ?? {},
|
|
162
|
+
callback_url: isPublic ? d.callback_url : null,
|
|
163
|
+
lease_id: null,
|
|
164
|
+
delivery_attempt,
|
|
165
|
+
source: isPublic ? 'public' : 'serve'
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
if (isPublic)
|
|
169
|
+
return 'expected a task.created envelope';
|
|
170
|
+
if (!('input' in b))
|
|
171
|
+
return 'expected {"input": ...} or a task.created envelope';
|
|
172
|
+
return {
|
|
173
|
+
task_id: randomUUID(),
|
|
174
|
+
worker,
|
|
175
|
+
input: b.input,
|
|
176
|
+
callback_url: null,
|
|
177
|
+
lease_id: null,
|
|
178
|
+
delivery_attempt,
|
|
179
|
+
source: 'serve'
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function signatureHeaders(req, nowSeconds) {
|
|
183
|
+
const { 'webhook-id': id, 'webhook-timestamp': ts, 'webhook-signature': sig } = req.headers;
|
|
184
|
+
if (typeof id !== 'string' || typeof ts !== 'string' || typeof sig !== 'string')
|
|
185
|
+
return 'missing';
|
|
186
|
+
const t = Number(ts);
|
|
187
|
+
if (!Number.isInteger(t) || Math.abs(nowSeconds - t) > PUSH_TIMESTAMP_TOLERANCE_S) {
|
|
188
|
+
return 'webhook-timestamp is outside the allowed window';
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
export function verifySignature(secret, req, raw, nowSeconds) {
|
|
193
|
+
const id = req.headers['webhook-id'];
|
|
194
|
+
const ts = req.headers['webhook-timestamp'];
|
|
195
|
+
const sig = req.headers['webhook-signature'];
|
|
196
|
+
if (typeof id !== 'string' || typeof ts !== 'string' || typeof sig !== 'string') {
|
|
197
|
+
return 'missing webhook-id / webhook-timestamp / webhook-signature headers';
|
|
198
|
+
}
|
|
199
|
+
const t = Number(ts);
|
|
200
|
+
if (!Number.isInteger(t) || Math.abs(nowSeconds - t) > PUSH_TIMESTAMP_TOLERANCE_S) {
|
|
201
|
+
return 'webhook-timestamp is outside the allowed window';
|
|
202
|
+
}
|
|
203
|
+
const key = Buffer.from(secret.startsWith('whsec_') ? secret.slice(6) : secret, 'base64');
|
|
204
|
+
const expected = createHmac('sha256', key).update(`${id}.${ts}.`).update(raw).digest();
|
|
205
|
+
for (const part of sig.split(' ')) {
|
|
206
|
+
const [version, value] = part.split(',', 2);
|
|
207
|
+
if (version !== 'v1' || !value)
|
|
208
|
+
continue;
|
|
209
|
+
const given = Buffer.from(value, 'base64');
|
|
210
|
+
if (given.length === expected.length && timingSafeEqual(given, expected))
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
return 'webhook-signature does not match';
|
|
214
|
+
}
|
|
215
|
+
function bearerMatches(header, digest) {
|
|
216
|
+
const m = header?.match(/^Bearer\s+(\S+)$/i);
|
|
217
|
+
return m !== null && m !== undefined && timingSafeEqual(sha256(m[1]), digest);
|
|
218
|
+
}
|
|
219
|
+
function sha256(value) {
|
|
220
|
+
return createHash('sha256').update(value).digest();
|
|
221
|
+
}
|
|
222
|
+
async function readBody(req, max) {
|
|
223
|
+
const chunks = [];
|
|
224
|
+
let size = 0;
|
|
225
|
+
for await (const chunk of req) {
|
|
226
|
+
size += chunk.length;
|
|
227
|
+
if (size > max * 4) {
|
|
228
|
+
req.destroy();
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
if (size <= max)
|
|
232
|
+
chunks.push(chunk);
|
|
233
|
+
}
|
|
234
|
+
return size > max ? null : Buffer.concat(chunks);
|
|
235
|
+
}
|
|
236
|
+
function send(res, reply) {
|
|
237
|
+
if (res.headersSent || res.destroyed)
|
|
238
|
+
return;
|
|
239
|
+
res.writeHead(reply.status, { 'content-type': 'application/json; charset=utf-8' });
|
|
240
|
+
res.end(JSON.stringify(reply.body));
|
|
241
|
+
}
|
|
242
|
+
function error(message) {
|
|
243
|
+
return { success: false, error: message };
|
|
244
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { PULL_MAX_LIMIT, isPullWorkerName } from "../../core/domain/index.js";
|
|
2
|
+
import { checkRepeat } from "./repeat-scheduler.js";
|
|
3
|
+
export class WorkersModule {
|
|
4
|
+
topics = ['workers'];
|
|
5
|
+
opts;
|
|
6
|
+
repeatsStarted = false;
|
|
7
|
+
slotFree = null;
|
|
8
|
+
constructor(opts) {
|
|
9
|
+
this.opts = opts;
|
|
10
|
+
}
|
|
11
|
+
get activeCount() {
|
|
12
|
+
return this.opts.workers.activeCount;
|
|
13
|
+
}
|
|
14
|
+
beforePull() {
|
|
15
|
+
return this.opts.runner.flushPending();
|
|
16
|
+
}
|
|
17
|
+
flushPending() {
|
|
18
|
+
return this.opts.runner.flushPending();
|
|
19
|
+
}
|
|
20
|
+
onSlotFree(cb) {
|
|
21
|
+
this.slotFree = cb;
|
|
22
|
+
}
|
|
23
|
+
notifySlotFree() {
|
|
24
|
+
this.slotFree?.();
|
|
25
|
+
}
|
|
26
|
+
pullMax() {
|
|
27
|
+
return Math.min(PULL_MAX_LIMIT, this.opts.runner.free);
|
|
28
|
+
}
|
|
29
|
+
onPull(res) {
|
|
30
|
+
const { api, runner, logger, workers } = this.opts;
|
|
31
|
+
const { deleted, unregistered } = workers.applyServerList(res.workers.map((w) => w.name));
|
|
32
|
+
if (unregistered.length > 0) {
|
|
33
|
+
logger.warn('workers that could not load are not registered on the server', {
|
|
34
|
+
workers: unregistered
|
|
35
|
+
});
|
|
36
|
+
this.opts.onWorkersUnregistered?.(unregistered);
|
|
37
|
+
}
|
|
38
|
+
if (deleted.length > 0) {
|
|
39
|
+
logger.warn('workers deleted on the server; reload or restart start to register them again', {
|
|
40
|
+
workers: deleted
|
|
41
|
+
});
|
|
42
|
+
this.opts.onWorkersDeleted?.(deleted);
|
|
43
|
+
runner.dropRepeats(deleted);
|
|
44
|
+
}
|
|
45
|
+
for (const id of res.cancelled)
|
|
46
|
+
runner.cancel(id);
|
|
47
|
+
for (const t of res.tasks) {
|
|
48
|
+
if (!isPullWorkerName(t.worker)) {
|
|
49
|
+
logger.warn('ignored a task with an invalid worker name', {
|
|
50
|
+
task_id: String(t.data?.task_id)
|
|
51
|
+
});
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const repeat = t.data.repeat == null ? null : checkRepeat(t.data.repeat);
|
|
55
|
+
if (t.data.repeat != null && repeat === null) {
|
|
56
|
+
logger.warn('ignored a repeating task with invalid settings', {
|
|
57
|
+
task_id: String(t.data?.task_id)
|
|
58
|
+
});
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
void runner.handle({
|
|
62
|
+
task_id: t.data.task_id,
|
|
63
|
+
worker: t.worker,
|
|
64
|
+
input: t.data.input,
|
|
65
|
+
callback_url: t.data.callback_url,
|
|
66
|
+
lease_id: t.lease_id,
|
|
67
|
+
delivery_attempt: t.delivery_attempt,
|
|
68
|
+
source: 'pull',
|
|
69
|
+
...(repeat ? { repeat } : {})
|
|
70
|
+
}, api, { ack: true });
|
|
71
|
+
}
|
|
72
|
+
if (!this.repeatsStarted) {
|
|
73
|
+
this.repeatsStarted = true;
|
|
74
|
+
runner.startRepeats();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
dropLocal(names) {
|
|
78
|
+
const dropped = this.opts.workers.dropLocal(names);
|
|
79
|
+
if (dropped.length === 0)
|
|
80
|
+
return;
|
|
81
|
+
this.opts.logger.info('workers deleted with worker delete; turned off', { workers: dropped });
|
|
82
|
+
this.opts.onWorkersDropped?.(dropped);
|
|
83
|
+
this.opts.runner.dropRepeats(dropped);
|
|
84
|
+
}
|
|
85
|
+
async resync() {
|
|
86
|
+
await this.opts.resync?.();
|
|
87
|
+
}
|
|
88
|
+
halt(reason) {
|
|
89
|
+
return this.opts.runner.halt(reason);
|
|
90
|
+
}
|
|
91
|
+
async shutdown() {
|
|
92
|
+
await this.opts.runner.shutdown();
|
|
93
|
+
await this.opts.runner.flushPending();
|
|
94
|
+
}
|
|
95
|
+
ended() {
|
|
96
|
+
return { ended: 'no_active_workers', deletedWorkers: this.opts.workers.deletedNames() };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { formatCount } from "../../core/text.js";
|
|
2
|
+
export function reloadLines(result) {
|
|
3
|
+
const lines = [];
|
|
4
|
+
const registered = [
|
|
5
|
+
...result.created.map((n) => `${n} (new)`),
|
|
6
|
+
...result.updated.map((n) => `${n} (updated)`)
|
|
7
|
+
];
|
|
8
|
+
if (registered.length > 0)
|
|
9
|
+
lines.push(`registered ${registered.join(', ')}`);
|
|
10
|
+
for (const r of result.rejected)
|
|
11
|
+
lines.push(`rejected ${r.name} — ${r.error}`);
|
|
12
|
+
for (const w of result.notLoaded)
|
|
13
|
+
lines.push(`not loaded ${w.name} — ${w.message}`);
|
|
14
|
+
lines.push(`workers ${result.active.length} active (${result.active.join(', ') || 'none'})`, `quota ${formatCount(result.quota.remaining)} of ${formatCount(result.quota.limit)} calls left`);
|
|
15
|
+
return lines;
|
|
16
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { REPEAT_INTERVAL_MAX_S, REPEAT_INTERVAL_MIN_S, REPEAT_TICK_MS } from "../../core/domain/index.js";
|
|
2
|
+
export class RepeatScheduler {
|
|
3
|
+
opts;
|
|
4
|
+
entries = new Map();
|
|
5
|
+
timer = null;
|
|
6
|
+
stopped = false;
|
|
7
|
+
loaded = false;
|
|
8
|
+
constructor(opts) {
|
|
9
|
+
this.opts = opts;
|
|
10
|
+
}
|
|
11
|
+
has(taskId) {
|
|
12
|
+
return this.entries.has(taskId);
|
|
13
|
+
}
|
|
14
|
+
get size() {
|
|
15
|
+
return this.entries.size;
|
|
16
|
+
}
|
|
17
|
+
add(taskId, worker, settings, doneRuns = 0) {
|
|
18
|
+
if (this.stopped || this.entries.has(taskId))
|
|
19
|
+
return;
|
|
20
|
+
const entry = {
|
|
21
|
+
taskId,
|
|
22
|
+
worker,
|
|
23
|
+
intervalMs: settings.interval_seconds * 1000,
|
|
24
|
+
runNo: doneRuns,
|
|
25
|
+
nextAt: this.opts.now(),
|
|
26
|
+
busy: false
|
|
27
|
+
};
|
|
28
|
+
this.entries.set(taskId, entry);
|
|
29
|
+
this.ensureTimer();
|
|
30
|
+
this.due(entry, this.opts.now());
|
|
31
|
+
}
|
|
32
|
+
load() {
|
|
33
|
+
if (this.stopped || this.loaded)
|
|
34
|
+
return;
|
|
35
|
+
this.loaded = true;
|
|
36
|
+
const now = this.opts.now();
|
|
37
|
+
for (const row of this.opts.db.repeatTasks()) {
|
|
38
|
+
if (this.entries.has(row.task_id))
|
|
39
|
+
continue;
|
|
40
|
+
const settings = parseSettings(row.repeat);
|
|
41
|
+
if (!settings) {
|
|
42
|
+
this.opts.logger.warn('ignored a repeating task with unreadable settings', {
|
|
43
|
+
task_id: row.task_id
|
|
44
|
+
});
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
this.entries.set(row.task_id, {
|
|
48
|
+
taskId: row.task_id,
|
|
49
|
+
worker: row.worker,
|
|
50
|
+
intervalMs: settings.interval_seconds * 1000,
|
|
51
|
+
runNo: Math.max(row.run_no ?? 0, row.runs ?? 0),
|
|
52
|
+
nextAt: row.next_at == null || row.next_at < now ? now : row.next_at,
|
|
53
|
+
busy: false
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (this.entries.size > 0) {
|
|
57
|
+
this.ensureTimer();
|
|
58
|
+
this.tick();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
remove(taskId) {
|
|
62
|
+
this.entries.delete(taskId);
|
|
63
|
+
}
|
|
64
|
+
dropWorkers(names) {
|
|
65
|
+
const gone = new Set(names);
|
|
66
|
+
const ids = [];
|
|
67
|
+
for (const e of this.entries.values()) {
|
|
68
|
+
if (gone.has(e.worker))
|
|
69
|
+
ids.push(e.taskId);
|
|
70
|
+
}
|
|
71
|
+
for (const id of ids)
|
|
72
|
+
this.entries.delete(id);
|
|
73
|
+
return ids;
|
|
74
|
+
}
|
|
75
|
+
stop() {
|
|
76
|
+
this.stopped = true;
|
|
77
|
+
this.timer?.clear();
|
|
78
|
+
this.timer = null;
|
|
79
|
+
}
|
|
80
|
+
snapshot() {
|
|
81
|
+
return [...this.entries.values()].map((e) => ({ ...e }));
|
|
82
|
+
}
|
|
83
|
+
tick() {
|
|
84
|
+
if (this.stopped)
|
|
85
|
+
return;
|
|
86
|
+
const now = this.opts.now();
|
|
87
|
+
for (const entry of [...this.entries.values()]) {
|
|
88
|
+
if (entry.nextAt <= now)
|
|
89
|
+
this.due(entry, now);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
due(entry, now) {
|
|
93
|
+
let next = entry.nextAt + entry.intervalMs;
|
|
94
|
+
if (next <= now)
|
|
95
|
+
next = now + entry.intervalMs;
|
|
96
|
+
entry.nextAt = next;
|
|
97
|
+
this.persistNextAt(entry);
|
|
98
|
+
if (entry.busy) {
|
|
99
|
+
this.opts.logger.info('repeat run skipped; the previous run has not finished', {
|
|
100
|
+
task_id: entry.taskId,
|
|
101
|
+
worker: entry.worker,
|
|
102
|
+
run_no: entry.runNo
|
|
103
|
+
});
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
entry.busy = true;
|
|
107
|
+
entry.runNo += 1;
|
|
108
|
+
void this.opts
|
|
109
|
+
.run(entry.taskId, entry.runNo)
|
|
110
|
+
.catch((err) => {
|
|
111
|
+
this.opts.logger.error('repeat run failed to start', {
|
|
112
|
+
task_id: entry.taskId,
|
|
113
|
+
message: err?.stack ?? String(err)
|
|
114
|
+
});
|
|
115
|
+
})
|
|
116
|
+
.finally(() => {
|
|
117
|
+
entry.busy = false;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
persistNextAt(entry) {
|
|
121
|
+
try {
|
|
122
|
+
this.opts.db.setNextAt(entry.taskId, entry.nextAt);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
this.opts.logger.warn('could not record the next run time', {
|
|
126
|
+
task_id: entry.taskId,
|
|
127
|
+
message: err.message
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
ensureTimer() {
|
|
132
|
+
if (this.timer || this.stopped)
|
|
133
|
+
return;
|
|
134
|
+
const every = this.opts.every ??
|
|
135
|
+
((fn, ms) => {
|
|
136
|
+
const t = setInterval(fn, ms);
|
|
137
|
+
t.unref();
|
|
138
|
+
return { clear: () => clearInterval(t) };
|
|
139
|
+
});
|
|
140
|
+
this.timer = every(() => this.tick(), REPEAT_TICK_MS);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
export function parseSettings(json) {
|
|
144
|
+
if (!json)
|
|
145
|
+
return null;
|
|
146
|
+
try {
|
|
147
|
+
return checkRepeat(JSON.parse(json));
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
export function checkRepeat(value) {
|
|
154
|
+
if (typeof value !== 'object' || value === null)
|
|
155
|
+
return null;
|
|
156
|
+
const { interval_seconds: every, max_runs: max } = value;
|
|
157
|
+
if (!Number.isInteger(every))
|
|
158
|
+
return null;
|
|
159
|
+
const seconds = every;
|
|
160
|
+
if (seconds < REPEAT_INTERVAL_MIN_S || seconds > REPEAT_INTERVAL_MAX_S)
|
|
161
|
+
return null;
|
|
162
|
+
if (max !== undefined && !(Number.isSafeInteger(max) && max > 0))
|
|
163
|
+
return null;
|
|
164
|
+
return { interval_seconds: seconds, ...(max === undefined ? {} : { max_runs: max }) };
|
|
165
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export const MISSING_PATHS_MAX = 10;
|
|
2
|
+
export function sqlJsonPaths(sql) {
|
|
3
|
+
const out = [];
|
|
4
|
+
for (const m of sql.matchAll(/'((?:[^']|'')*)'/g)) {
|
|
5
|
+
const literal = m[1].replace(/''/g, "'");
|
|
6
|
+
if (literal.length <= 1 || !literal.startsWith('$') || out.includes(literal))
|
|
7
|
+
continue;
|
|
8
|
+
if (isPathPosition(sql, m.index ?? 0))
|
|
9
|
+
out.push(literal);
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
function isPathPosition(sql, at) {
|
|
14
|
+
const before = sql.slice(0, at).trimEnd();
|
|
15
|
+
if (before.endsWith('->') || before.endsWith('->>'))
|
|
16
|
+
return true;
|
|
17
|
+
if (!before.endsWith(','))
|
|
18
|
+
return false;
|
|
19
|
+
let depth = 0;
|
|
20
|
+
let inString = false;
|
|
21
|
+
for (let i = before.length - 2; i >= 0; i--) {
|
|
22
|
+
const ch = before[i];
|
|
23
|
+
if (ch === "'")
|
|
24
|
+
inString = !inString;
|
|
25
|
+
if (inString)
|
|
26
|
+
continue;
|
|
27
|
+
if (ch === ')')
|
|
28
|
+
depth++;
|
|
29
|
+
else if (ch === '(') {
|
|
30
|
+
if (depth === 0)
|
|
31
|
+
return /json_\w+\s*$/i.test(before.slice(0, i));
|
|
32
|
+
depth--;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
function normalize(path) {
|
|
38
|
+
return path.replace(/\[(?:\d+|#[^\]]*)\]/g, '[*]');
|
|
39
|
+
}
|
|
40
|
+
export function missingPaths(sql, known) {
|
|
41
|
+
const missing = [];
|
|
42
|
+
for (const raw of sqlJsonPaths(sql)) {
|
|
43
|
+
const path = normalize(raw);
|
|
44
|
+
if (known.has(path))
|
|
45
|
+
continue;
|
|
46
|
+
const tail = path.slice(1);
|
|
47
|
+
if (tail === '' || !(tail.startsWith('.') || tail.startsWith('[')))
|
|
48
|
+
continue;
|
|
49
|
+
let found = false;
|
|
50
|
+
for (const k of known) {
|
|
51
|
+
if (k.endsWith(tail)) {
|
|
52
|
+
found = true;
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!found)
|
|
57
|
+
missing.push(raw);
|
|
58
|
+
}
|
|
59
|
+
return missing;
|
|
60
|
+
}
|
|
61
|
+
export function missingPathsWarning(missing) {
|
|
62
|
+
const shown = missing.slice(0, MISSING_PATHS_MAX);
|
|
63
|
+
const more = missing.length - shown.length;
|
|
64
|
+
return (`No result in the scope has ${shown.join(', ')}${more > 0 ? ` and ${more} more` : ''}, so ` +
|
|
65
|
+
`${missing.length === 1 ? 'it reads' : 'they read'} as NULL — op "describe" lists the paths the results have.`);
|
|
66
|
+
}
|