@3minapi/cli 0.0.0-stage → 0.22.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/LICENSE +21 -0
- package/README.md +88 -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,443 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ApiClient, userAgent } from "../core/api.js";
|
|
4
|
+
import { APP_LOG_DIR, BIN, CliError, PROJECT_CONFIG_FILE, DEFAULT_LOCAL_SERVER_PORT, DETACH_READY_MS, ENV, ExitCode, ConfigError, RECEIVE_MODES, isTrustedLiveUrl, LOCAL_SERVER_TOKEN_FILE, MONTH_MS, RUNNER_OUT_FILE, STOP_CHECK_INTERVAL_MS, StopError, UsageError } from "../core/domain/index.js";
|
|
5
|
+
import { outputTail, spawnDetached } from "../core/host/detach.js";
|
|
6
|
+
import { LIVE_META } from "../core/control-db.js";
|
|
7
|
+
import { LiveReceiver } from "../core/host/live-receiver.js";
|
|
8
|
+
import { Poller } from "../core/host/poller.js";
|
|
9
|
+
import { acquireRunnerLock, readRunner } from "../core/host/runner-lock.js";
|
|
10
|
+
import { openSession, recordEnd, recordStop, rememberQuota, stopHint } from "../core/host/session.js";
|
|
11
|
+
import { formatCount } from "../core/text.js";
|
|
12
|
+
import { formatInterval, formatLocal, formatThroughput, maxTasksPerMinute, sleep } from "../core/time.js";
|
|
13
|
+
import { startWorkers } from "../modules/workers/start.js";
|
|
14
|
+
import { noticeUpdate, requireProject, requireToken, requireWorkersDir } from "./common.js";
|
|
15
|
+
import { LatestWatch, readLatest, updateInfo } from "../core/update-notice.js";
|
|
16
|
+
export const start = {
|
|
17
|
+
name: 'start',
|
|
18
|
+
summary: 'Run this machine as a 3Min API host: fetch tasks and run the worker files until stopped',
|
|
19
|
+
usage: '[--detach] [--mode live|poll] [--serve [--port <port>] [--public]]',
|
|
20
|
+
description: 'Runs this machine as a 3Min API host. It registers the worker files in the workers\n' +
|
|
21
|
+
'folder with 3Min API as local workers (one API call), then fetches their tasks and\n' +
|
|
22
|
+
'runs them on this machine.\n' +
|
|
23
|
+
'\n' +
|
|
24
|
+
'When to fetch (--mode): live (default) keeps a connection open and fetches when 3Min API\n' +
|
|
25
|
+
'signals a new or cancelled task — idle costs no calls; each connect or reconnect is one\n' +
|
|
26
|
+
'call and each fetch is one. poll fetches every interval instead (pollIntervalMs), and every\n' +
|
|
27
|
+
'poll counts as one call even when nothing is waiting — use it where WebSockets are blocked\n' +
|
|
28
|
+
"(a company proxy or firewall; '3minapi doctor' checks). There is no automatic switch.\n" +
|
|
29
|
+
'\n' +
|
|
30
|
+
'Adding or renaming a worker file, or changing its description, takes effect on\n' +
|
|
31
|
+
"'3minapi reload' (no restart; running tasks keep going) — 'worker new' reloads by itself.\n" +
|
|
32
|
+
'Handler code and .env changes apply to the next task. A worker deleted on the server is\n' +
|
|
33
|
+
'turned off for the run, and start ends when no worker is left.\n' +
|
|
34
|
+
'\n' +
|
|
35
|
+
'The server holds the queue: each fetch takes only as many tasks as can start now (up to the\n' +
|
|
36
|
+
'concurrency setting, default 4). In poll mode at most concurrency x polls per minute run — about\n' +
|
|
37
|
+
'8 a minute at 4 every 30s. A fetched task is run to the end; if this machine stops before it finishes, the\n' +
|
|
38
|
+
'next start reports it failed (host_restarted) instead of running it again.\n' +
|
|
39
|
+
'Stops all calls immediately when the quota runs out or the token is revoked, and does not\n' +
|
|
40
|
+
'start again on its own — run start again after fixing the cause.\n' +
|
|
41
|
+
'\n' +
|
|
42
|
+
'Runs in the foreground by default (Ctrl+C to stop). With --detach it keeps running\n' +
|
|
43
|
+
"after the terminal is closed — stop it with '3minapi stop'. Only one can run per host.\n" +
|
|
44
|
+
'It does not start again after a reboot.\n' +
|
|
45
|
+
'\n' +
|
|
46
|
+
'--serve also opens an HTTP server: POST /task/<worker> on 127.0.0.1, with the token\n' +
|
|
47
|
+
`in ${LOCAL_SERVER_TOKEN_FILE} (Authorization: Bearer <token>). --public listens on all\n` +
|
|
48
|
+
'interfaces instead and serves worker files as public workers: 3Min API POSTs signed tasks\n' +
|
|
49
|
+
'to this machine. Use it for names registered as public workers on the dashboard, with\n' +
|
|
50
|
+
'THREEMINAPI_WORKER_<NAME>_SECRET and THREEMINAPI_WORKER_<NAME>_KEY set; those files are\n' +
|
|
51
|
+
'not registered as local workers. Long-running servers only — serverless platforms are\n' +
|
|
52
|
+
'not supported.',
|
|
53
|
+
options: {
|
|
54
|
+
detach: {
|
|
55
|
+
type: 'boolean',
|
|
56
|
+
short: 'd',
|
|
57
|
+
description: "Run in the background (stop with '3minapi stop')"
|
|
58
|
+
},
|
|
59
|
+
mode: {
|
|
60
|
+
type: 'string',
|
|
61
|
+
valueName: RECEIVE_MODES.join('|'),
|
|
62
|
+
description: 'When to fetch tasks: live (default — when 3Min API signals) or poll (every interval)'
|
|
63
|
+
},
|
|
64
|
+
serve: {
|
|
65
|
+
type: 'boolean',
|
|
66
|
+
description: 'Also open an HTTP server (127.0.0.1; all interfaces with --public)'
|
|
67
|
+
},
|
|
68
|
+
port: {
|
|
69
|
+
type: 'string',
|
|
70
|
+
valueName: 'port',
|
|
71
|
+
description: `HTTP server port (default ${DEFAULT_LOCAL_SERVER_PORT}, with --serve)`
|
|
72
|
+
},
|
|
73
|
+
public: {
|
|
74
|
+
type: 'boolean',
|
|
75
|
+
description: 'Serve worker files as public workers on all interfaces (with --serve)'
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
args: { min: 0, max: 0 },
|
|
79
|
+
async run(ctx) {
|
|
80
|
+
if ((ctx.values.port !== undefined || ctx.values.public) && !ctx.values.serve) {
|
|
81
|
+
throw new UsageError('--port and --public need --serve');
|
|
82
|
+
}
|
|
83
|
+
if (ctx.values.detach && ctx.env[ENV.detached] !== '1')
|
|
84
|
+
return startDetached(ctx);
|
|
85
|
+
return startForeground(ctx);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
async function startForeground(ctx) {
|
|
89
|
+
const { out } = ctx;
|
|
90
|
+
const token = requireToken(ctx);
|
|
91
|
+
const detachedChild = ctx.env[ENV.detached] === '1';
|
|
92
|
+
const session = openSession(ctx);
|
|
93
|
+
try {
|
|
94
|
+
const { values: cfg } = session.config;
|
|
95
|
+
const { root, dir: workersDir } = requireWorkersDir(session.config);
|
|
96
|
+
const serve = Boolean(ctx.values.serve);
|
|
97
|
+
const isPublic = Boolean(ctx.values.public);
|
|
98
|
+
const lock = acquireRunnerLock(ctx.dirs.data, {
|
|
99
|
+
detached: detachedChild,
|
|
100
|
+
port: serve ? cfg.localServerPort : null,
|
|
101
|
+
public: serve && isPublic,
|
|
102
|
+
intervalMs: cfg.receiveMode === 'live' ? null : cfg.pollIntervalMs,
|
|
103
|
+
mode: cfg.receiveMode,
|
|
104
|
+
concurrency: cfg.concurrency,
|
|
105
|
+
apiUrl: cfg.apiUrl,
|
|
106
|
+
outFile: detachedChild ? outFilePath(ctx) : null,
|
|
107
|
+
workersDir,
|
|
108
|
+
reload: true,
|
|
109
|
+
localDelete: true
|
|
110
|
+
}, ctx.now());
|
|
111
|
+
ctx.chdir(root);
|
|
112
|
+
ctx.claimInterrupt();
|
|
113
|
+
const shutdown = new AbortController();
|
|
114
|
+
const onAbort = () => shutdown.abort();
|
|
115
|
+
ctx.signal.addEventListener('abort', onAbort, { once: true });
|
|
116
|
+
let polling = null;
|
|
117
|
+
const projectFile = session.config.projectFile ?? path.join(root, PROJECT_CONFIG_FILE);
|
|
118
|
+
let projectGone = false;
|
|
119
|
+
let fileMisses = 0;
|
|
120
|
+
const stopWatch = setInterval(() => {
|
|
121
|
+
lock.heartbeat();
|
|
122
|
+
if (!projectGone) {
|
|
123
|
+
fileMisses = existsSync(projectFile) ? 0 : fileMisses + 1;
|
|
124
|
+
if (!existsSync(root) || fileMisses >= PROJECT_GONE_MISSES) {
|
|
125
|
+
projectGone = true;
|
|
126
|
+
session.logger.warn('project folder gone', { root });
|
|
127
|
+
shutdown.abort();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (lock.stopRequested())
|
|
131
|
+
shutdown.abort();
|
|
132
|
+
else if (polling?.resyncIdle) {
|
|
133
|
+
const deleted = lock.takeDeleted();
|
|
134
|
+
if (deleted.length > 0)
|
|
135
|
+
polling.requestDrop(deleted);
|
|
136
|
+
if (lock.takeReload())
|
|
137
|
+
polling.requestResync();
|
|
138
|
+
}
|
|
139
|
+
}, STOP_CHECK_INTERVAL_MS);
|
|
140
|
+
let workers = null;
|
|
141
|
+
try {
|
|
142
|
+
const debug = (m, a) => session.logger.debug(m, a);
|
|
143
|
+
const updates = new LatestWatch({
|
|
144
|
+
db: session.db,
|
|
145
|
+
now: ctx.now,
|
|
146
|
+
log: debug,
|
|
147
|
+
announce: () => {
|
|
148
|
+
if (!detachedChild) {
|
|
149
|
+
noticeUpdate(ctx, session, true);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const info = updateInfo(ctx.version, readLatest(session.db, debug), true);
|
|
153
|
+
if (info.available) {
|
|
154
|
+
session.logger.info('update available', { current: info.current, latest: info.latest });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
const agent = userAgent(ctx.version, ctx.platform);
|
|
159
|
+
const api = new ApiClient({
|
|
160
|
+
apiUrl: cfg.apiUrl,
|
|
161
|
+
token: token.token,
|
|
162
|
+
userAgent: agent,
|
|
163
|
+
onServerCli: (cli) => updates.observe(cli)
|
|
164
|
+
});
|
|
165
|
+
let receiver;
|
|
166
|
+
resetLiveState(session);
|
|
167
|
+
workers = await startWorkers({
|
|
168
|
+
ctx,
|
|
169
|
+
session,
|
|
170
|
+
api,
|
|
171
|
+
token: token.token,
|
|
172
|
+
userAgent: agent,
|
|
173
|
+
lock,
|
|
174
|
+
root,
|
|
175
|
+
workersDir,
|
|
176
|
+
serve,
|
|
177
|
+
isPublic,
|
|
178
|
+
signal: shutdown.signal,
|
|
179
|
+
onStop: (err) => receiver.stop(err),
|
|
180
|
+
onLive: (live) => {
|
|
181
|
+
if (receiver instanceof LiveReceiver)
|
|
182
|
+
receiver.setUrl(live.url);
|
|
183
|
+
else
|
|
184
|
+
rememberLiveUrl(session, live.url, cfg.apiUrl, ctx.now());
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
if (projectGone)
|
|
188
|
+
throw projectGoneError(root);
|
|
189
|
+
if (!workers) {
|
|
190
|
+
out.info('Stopped.');
|
|
191
|
+
return ctx.signal.aborted ? ExitCode.Interrupted : ExitCode.Ok;
|
|
192
|
+
}
|
|
193
|
+
const onPull = pullReporter(ctx, cfg.receiveMode, (quota) => rememberQuota(session, quota, ctx.now()));
|
|
194
|
+
const onError = (err) => out.warn(err.message);
|
|
195
|
+
if (workers.live)
|
|
196
|
+
rememberLiveUrl(session, workers.live.url, cfg.apiUrl, ctx.now());
|
|
197
|
+
if (cfg.receiveMode === 'live') {
|
|
198
|
+
const url = liveUrl(workers.live?.url ?? null, cfg.apiUrl);
|
|
199
|
+
receiver = new LiveReceiver({
|
|
200
|
+
api,
|
|
201
|
+
logger: session.logger,
|
|
202
|
+
module: workers.module,
|
|
203
|
+
quota: workers.quota,
|
|
204
|
+
signal: shutdown.signal,
|
|
205
|
+
url,
|
|
206
|
+
token: token.token,
|
|
207
|
+
apiUrl: cfg.apiUrl,
|
|
208
|
+
onPull,
|
|
209
|
+
onError,
|
|
210
|
+
onState: liveStateReporter(ctx, session),
|
|
211
|
+
onLiveUrl: (next) => saveMeta(session, LIVE_META.url, next, ctx.now())
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
receiver = new Poller({
|
|
216
|
+
api,
|
|
217
|
+
logger: session.logger,
|
|
218
|
+
intervalMs: cfg.pollIntervalMs,
|
|
219
|
+
module: workers.module,
|
|
220
|
+
quota: workers.quota,
|
|
221
|
+
signal: shutdown.signal,
|
|
222
|
+
onPull,
|
|
223
|
+
onError
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const closed = await workers.recover();
|
|
227
|
+
if (closed > 0) {
|
|
228
|
+
out.info(`closed ${closed} task(s) the previous run left unfinished — see '${BIN} tasks'`);
|
|
229
|
+
}
|
|
230
|
+
await workers.serve();
|
|
231
|
+
out.info(banner(ctx, session.config, workers));
|
|
232
|
+
updates.arm();
|
|
233
|
+
session.logger.info('runner started', {
|
|
234
|
+
pid: process.pid,
|
|
235
|
+
detached: detachedChild,
|
|
236
|
+
mode: cfg.receiveMode,
|
|
237
|
+
interval_ms: cfg.receiveMode === 'live' ? null : cfg.pollIntervalMs,
|
|
238
|
+
port: workers.serverPort(),
|
|
239
|
+
public: isPublic
|
|
240
|
+
});
|
|
241
|
+
polling = receiver;
|
|
242
|
+
const stopped = await receiver.run();
|
|
243
|
+
polling = null;
|
|
244
|
+
if (stopped && !(stopped instanceof StopError)) {
|
|
245
|
+
recordEnd(session, stopped.ended, stopped.deletedWorkers, ctx.now());
|
|
246
|
+
throw new CliError(workers.endMessage(stopped));
|
|
247
|
+
}
|
|
248
|
+
if (stopped) {
|
|
249
|
+
recordStop(session, stopped, ctx.now());
|
|
250
|
+
throw new StopError(stopped.reason, `${stopped.message}\n${stopHint(stopped.reason)}`, stopped.resetAt);
|
|
251
|
+
}
|
|
252
|
+
if (projectGone)
|
|
253
|
+
throw projectGoneError(root);
|
|
254
|
+
session.logger.info('runner stopped', { pid: process.pid });
|
|
255
|
+
out.info('Stopped.');
|
|
256
|
+
return ctx.signal.aborted ? ExitCode.Interrupted : ExitCode.Ok;
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
clearInterval(stopWatch);
|
|
260
|
+
ctx.signal.removeEventListener('abort', onAbort);
|
|
261
|
+
await workers?.close();
|
|
262
|
+
lock.release();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
session.close();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
function liveUrl(url, apiUrl) {
|
|
270
|
+
if (url === null) {
|
|
271
|
+
throw new ConfigError(`this server (${apiUrl}) does not support live mode. Run '${BIN} start --mode poll' to fetch tasks at an interval instead.`);
|
|
272
|
+
}
|
|
273
|
+
if (!isTrustedLiveUrl(url, apiUrl)) {
|
|
274
|
+
throw new ConfigError(`the server gave a live address that is not on ${apiUrl} — not connecting, so your host token is not sent elsewhere. ` +
|
|
275
|
+
`Run '${BIN} start --mode poll' instead, and check the API URL.`);
|
|
276
|
+
}
|
|
277
|
+
return url;
|
|
278
|
+
}
|
|
279
|
+
function resetLiveState(session) {
|
|
280
|
+
try {
|
|
281
|
+
session.db.deleteMeta(LIVE_META.state);
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
session.logger.warn('could not clear the live state', { message: e.message });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
function rememberLiveUrl(session, url, apiUrl, now) {
|
|
288
|
+
if (isTrustedLiveUrl(url, apiUrl))
|
|
289
|
+
saveMeta(session, LIVE_META.url, url, now);
|
|
290
|
+
}
|
|
291
|
+
function saveMeta(session, key, value, now) {
|
|
292
|
+
try {
|
|
293
|
+
session.db.setMeta(key, value, now);
|
|
294
|
+
}
|
|
295
|
+
catch (e) {
|
|
296
|
+
session.logger.warn('could not record live info', { key, message: e.message });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function liveStateReporter(ctx, session) {
|
|
300
|
+
let warned = false;
|
|
301
|
+
return (state) => {
|
|
302
|
+
try {
|
|
303
|
+
session.db.setLiveState(state, ctx.now());
|
|
304
|
+
}
|
|
305
|
+
catch (e) {
|
|
306
|
+
session.logger.warn('could not record the live state', { message: e.message });
|
|
307
|
+
}
|
|
308
|
+
if (state.connected && warned) {
|
|
309
|
+
warned = false;
|
|
310
|
+
ctx.out.info(`${ctx.out.err.dim(formatLocal(ctx.now()))} live connection open`);
|
|
311
|
+
}
|
|
312
|
+
else if (!state.connected && !state.everConnected && state.lastError && !warned) {
|
|
313
|
+
warned = true;
|
|
314
|
+
ctx.out.warn(`could not open the live connection (${state.lastError}) — retrying in the background; no tasks are fetched until it connects.
|
|
315
|
+
` +
|
|
316
|
+
`On a network that blocks WebSockets (a company proxy or firewall), run '${BIN} doctor' or start with '--mode poll'.`);
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
const PROJECT_GONE_MISSES = 2;
|
|
321
|
+
function projectGoneError(root) {
|
|
322
|
+
return new CliError(`the project folder ${root} is gone (moved or deleted). Stopped. ` +
|
|
323
|
+
`If you moved it, run '${BIN} start' inside the new folder.`);
|
|
324
|
+
}
|
|
325
|
+
function pullReporter(ctx, mode, remember) {
|
|
326
|
+
let warnedLow = false;
|
|
327
|
+
return (res) => {
|
|
328
|
+
remember(res.quota);
|
|
329
|
+
const { remaining, limit } = res.quota;
|
|
330
|
+
const left = `${formatCount(remaining)} of ${formatCount(limit)} calls left`;
|
|
331
|
+
if (res.tasks.length > 0) {
|
|
332
|
+
ctx.out.info(`${ctx.out.err.dim(formatLocal(ctx.now()))} received ${res.tasks.length} task(s) · ${left}`);
|
|
333
|
+
}
|
|
334
|
+
if (!warnedLow && limit > 0 && remaining < limit * 0.1) {
|
|
335
|
+
warnedLow = true;
|
|
336
|
+
ctx.out.warn(`only ${left}. ${mode === 'live'
|
|
337
|
+
? 'Each fetch and each reconnect uses one call'
|
|
338
|
+
: 'Every poll uses one call'}; all calls stop when the quota runs out.`);
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function banner(ctx, config, workers) {
|
|
343
|
+
const lines = [
|
|
344
|
+
`${ctx.out.err.green('started')} ${workers.bannerSubject()} · pid ${process.pid}`,
|
|
345
|
+
modeLine(config.values),
|
|
346
|
+
...workers.bannerLines()
|
|
347
|
+
];
|
|
348
|
+
lines.push(ctx.env[ENV.detached] === '1' ? `stop with '${BIN} stop'` : `Ctrl+C or '${BIN} stop' to stop`);
|
|
349
|
+
return lines.join('\n');
|
|
350
|
+
}
|
|
351
|
+
async function startDetached(ctx) {
|
|
352
|
+
const { out } = ctx;
|
|
353
|
+
requireToken(ctx);
|
|
354
|
+
const config = ctx.loadConfig();
|
|
355
|
+
const root = requireProject(config);
|
|
356
|
+
const current = readRunner(ctx.dirs.data);
|
|
357
|
+
if (current.state === 'running') {
|
|
358
|
+
throw new CliError(`3minapi is already running (pid ${current.info.pid}). Only one can run per host.\n` +
|
|
359
|
+
`Stop it with '${BIN} stop' first.`);
|
|
360
|
+
}
|
|
361
|
+
const outFile = outFilePath(ctx);
|
|
362
|
+
const startedAt = Date.now();
|
|
363
|
+
const child = spawnDetached({
|
|
364
|
+
args: ['start', ...forwardArgs(ctx)],
|
|
365
|
+
cwd: root,
|
|
366
|
+
env: ctx.env,
|
|
367
|
+
outFile
|
|
368
|
+
});
|
|
369
|
+
let exited = null;
|
|
370
|
+
child.once('exit', (code) => (exited = code ?? 1));
|
|
371
|
+
let info = null;
|
|
372
|
+
while (Date.now() - startedAt < DETACH_READY_MS && exited === null) {
|
|
373
|
+
const state = readRunner(ctx.dirs.data);
|
|
374
|
+
if (state.state === 'running' && state.info.pid === child.pid) {
|
|
375
|
+
info = state.info;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
await sleep(100);
|
|
379
|
+
}
|
|
380
|
+
child.unref();
|
|
381
|
+
if (!info) {
|
|
382
|
+
if (exited === null) {
|
|
383
|
+
try {
|
|
384
|
+
child.kill();
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
throw new CliError(`the background runner did not start${exited !== null ? ` (exit code ${exited})` : ''}.\n` +
|
|
390
|
+
outputTail(outFile));
|
|
391
|
+
}
|
|
392
|
+
const { pollIntervalMs, concurrency, receiveMode } = config.values;
|
|
393
|
+
const poll = receiveMode === 'poll';
|
|
394
|
+
out.result({
|
|
395
|
+
running: true,
|
|
396
|
+
pid: info.pid,
|
|
397
|
+
outFile,
|
|
398
|
+
mode: receiveMode,
|
|
399
|
+
intervalMs: poll ? pollIntervalMs : null,
|
|
400
|
+
monthlyCalls: poll ? Math.round(MONTH_MS / pollIntervalMs) : null,
|
|
401
|
+
concurrency,
|
|
402
|
+
maxTasksPerMinute: poll ? maxTasksPerMinute(concurrency, pollIntervalMs) : null
|
|
403
|
+
}, [
|
|
404
|
+
`${out.out.green('started in the background')} pid ${info.pid}`,
|
|
405
|
+
modeLine(config.values),
|
|
406
|
+
`output: ${outFile}`,
|
|
407
|
+
`stop with '${BIN} stop' · check with '${BIN} status'`
|
|
408
|
+
].join('\n'));
|
|
409
|
+
return ExitCode.Ok;
|
|
410
|
+
}
|
|
411
|
+
function forwardArgs(ctx) {
|
|
412
|
+
const { values } = ctx;
|
|
413
|
+
const args = [];
|
|
414
|
+
if (values.serve)
|
|
415
|
+
args.push('--serve');
|
|
416
|
+
if (values.public)
|
|
417
|
+
args.push('--public');
|
|
418
|
+
if (typeof values.port === 'string')
|
|
419
|
+
args.push('--port', values.port);
|
|
420
|
+
if (typeof values.mode === 'string')
|
|
421
|
+
args.push('--mode', values.mode);
|
|
422
|
+
if (typeof values['api-url'] === 'string')
|
|
423
|
+
args.push('--api-url', values['api-url']);
|
|
424
|
+
if (typeof values.config === 'string')
|
|
425
|
+
args.push('--config', path.resolve(ctx.cwd, values.config));
|
|
426
|
+
return args;
|
|
427
|
+
}
|
|
428
|
+
function outFilePath(ctx) {
|
|
429
|
+
return path.join(ctx.dirs.logs, APP_LOG_DIR, RUNNER_OUT_FILE);
|
|
430
|
+
}
|
|
431
|
+
function monthlyCalls(intervalMs) {
|
|
432
|
+
return formatCount(Math.round(MONTH_MS / intervalMs));
|
|
433
|
+
}
|
|
434
|
+
function modeLine(cfg) {
|
|
435
|
+
if (cfg.receiveMode === 'poll')
|
|
436
|
+
return pollingLine(cfg.pollIntervalMs, cfg.concurrency);
|
|
437
|
+
return (`live — fetches when 3Min API signals, up to ${cfg.concurrency} at a time. ` +
|
|
438
|
+
'Idle ≈ 0 calls · 1 call per connect or reconnect · about 3 calls per task');
|
|
439
|
+
}
|
|
440
|
+
function pollingLine(intervalMs, concurrency) {
|
|
441
|
+
return (`polling every ${formatInterval(intervalMs)}, ${formatThroughput(concurrency, intervalMs)} — ` +
|
|
442
|
+
`about ${monthlyCalls(intervalMs)} calls per month even when idle`);
|
|
443
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
2
|
+
import { CliError, ExitCode, STOP_WAIT_MS } from "../core/domain/index.js";
|
|
3
|
+
import { isAlive, lockPath, readRunner, readRunnerSettled, heartbeatStopped, requestStop } from "../core/host/runner-lock.js";
|
|
4
|
+
import { sleep } from "../core/time.js";
|
|
5
|
+
export const stop = {
|
|
6
|
+
name: 'stop',
|
|
7
|
+
summary: "Stop this host's running 'start' (background or another terminal)",
|
|
8
|
+
usage: '',
|
|
9
|
+
description: 'Asks the running start to finish — the whole host process, whatever it runs: running\n' +
|
|
10
|
+
'tasks are cancelled and reported as failed.\n' +
|
|
11
|
+
`If it has not exited after ${STOP_WAIT_MS / 1000}s it is killed.`,
|
|
12
|
+
options: {},
|
|
13
|
+
args: { min: 0, max: 0 },
|
|
14
|
+
run(ctx) {
|
|
15
|
+
return stopLocked(ctx, {
|
|
16
|
+
read: () => readRunner(ctx.dirs.data),
|
|
17
|
+
readSettled: () => readRunnerSettled(ctx.dirs.data),
|
|
18
|
+
requestStop: (info) => requestStop(ctx.dirs.data, info),
|
|
19
|
+
lockFile: lockPath(ctx.dirs.data)
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
export async function stopLocked(ctx, target) {
|
|
24
|
+
const { out } = ctx;
|
|
25
|
+
const current = await target.readSettled();
|
|
26
|
+
if (current.state !== 'running') {
|
|
27
|
+
out.result({ stopped: false, running: false }, 'Not running.');
|
|
28
|
+
return ExitCode.Ok;
|
|
29
|
+
}
|
|
30
|
+
const { info } = current;
|
|
31
|
+
target.requestStop(info);
|
|
32
|
+
out.info(`stopping pid ${info.pid}...`);
|
|
33
|
+
const deadline = Date.now() + STOP_WAIT_MS;
|
|
34
|
+
while (Date.now() < deadline && isAlive(info.pid) && existsSync(target.lockFile)) {
|
|
35
|
+
await sleep(200);
|
|
36
|
+
}
|
|
37
|
+
let forced = false;
|
|
38
|
+
if (isAlive(info.pid) &&
|
|
39
|
+
target.read().state === 'running' &&
|
|
40
|
+
heartbeatStopped(target.lockFile, info)) {
|
|
41
|
+
rmSync(target.lockFile, { force: true });
|
|
42
|
+
out.result({ stopped: true, pid: info.pid, forced: false, killed: false }, `pid ${info.pid} has not updated this lock for a while — it may be another program that reused the pid. ` +
|
|
43
|
+
'It was not killed; the stale lock was removed.');
|
|
44
|
+
return ExitCode.Ok;
|
|
45
|
+
}
|
|
46
|
+
if (isAlive(info.pid) && target.read().state === 'running') {
|
|
47
|
+
forced = true;
|
|
48
|
+
if (!(await forceKill(info.pid))) {
|
|
49
|
+
throw new CliError(`pid ${info.pid} did not stop and could not be killed. Stop it from the OS (Activity Monitor / Task Manager / kill -9).`);
|
|
50
|
+
}
|
|
51
|
+
rmSync(target.lockFile, { force: true });
|
|
52
|
+
}
|
|
53
|
+
out.result({ stopped: true, pid: info.pid, forced }, forced ? `Killed pid ${info.pid} (it did not stop in time).` : `Stopped pid ${info.pid}.`);
|
|
54
|
+
return ExitCode.Ok;
|
|
55
|
+
}
|
|
56
|
+
export async function forceKill(pid) {
|
|
57
|
+
try {
|
|
58
|
+
process.kill(pid, process.platform === 'win32' ? undefined : 'SIGKILL');
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (err.code === 'ESRCH')
|
|
62
|
+
return true;
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
for (let i = 0; i < 20 && isAlive(pid); i++)
|
|
66
|
+
await sleep(100);
|
|
67
|
+
return !isAlive(pid);
|
|
68
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ControlDb } from "../core/control-db.js";
|
|
4
|
+
import { BIN, CONTROL_DB_FILE, CliError, ExitCode, TASKS_PRUNE_MIN_AGE_MS, UsageError } from "../core/domain/index.js";
|
|
5
|
+
import { assertWorkerName } from "../core/names.js";
|
|
6
|
+
import { confirm } from "../core/prompt.js";
|
|
7
|
+
import { formatBytes, formatCount } from "../core/text.js";
|
|
8
|
+
import { formatLocal, parseDateArg } from "../core/time.js";
|
|
9
|
+
export const tasksPrune = {
|
|
10
|
+
name: 'tasks prune',
|
|
11
|
+
summary: 'Delete the run history of tasks that finished before a date',
|
|
12
|
+
usage: '--before <date> [--worker <name>] [--yes]',
|
|
13
|
+
description: "Deletes this host's task records — when each task ran, retries, cancellations and why it\n" +
|
|
14
|
+
'failed. Nothing is deleted automatically otherwise. Tasks still waiting or running, and tasks\n' +
|
|
15
|
+
'that finished in the last 24 hours, are kept: those records stop a task from running twice\n' +
|
|
16
|
+
'when it is delivered again. Saved results in data.db are not touched (worker results prune).\n' +
|
|
17
|
+
'Shows how many first and asks to confirm. The file does not shrink; new records reuse the space.',
|
|
18
|
+
options: {
|
|
19
|
+
before: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
valueName: 'date',
|
|
22
|
+
description: 'Delete tasks that finished before this time — YYYY-MM-DD (local midnight) or ISO 8601'
|
|
23
|
+
},
|
|
24
|
+
worker: { type: 'string', valueName: 'name', description: 'Only this worker' },
|
|
25
|
+
yes: { type: 'boolean', description: 'Delete without asking for confirmation' }
|
|
26
|
+
},
|
|
27
|
+
args: { min: 0, max: 0 },
|
|
28
|
+
async run(ctx) {
|
|
29
|
+
const { out, values } = ctx;
|
|
30
|
+
if (typeof values.before !== 'string') {
|
|
31
|
+
throw new UsageError(`--before is required.\nusage: ${BIN} tasks prune --before <date>`);
|
|
32
|
+
}
|
|
33
|
+
const before = parseDateArg(values.before);
|
|
34
|
+
if (before === null) {
|
|
35
|
+
throw new UsageError(`--before "${values.before}" is not a date. Use YYYY-MM-DD (local midnight) or ISO 8601 like 2026-10-01T09:00.`);
|
|
36
|
+
}
|
|
37
|
+
const filter = { before };
|
|
38
|
+
if (typeof values.worker === 'string') {
|
|
39
|
+
assertWorkerName(values.worker);
|
|
40
|
+
filter.worker = values.worker;
|
|
41
|
+
}
|
|
42
|
+
if (!existsSync(path.join(ctx.dirs.data, CONTROL_DB_FILE))) {
|
|
43
|
+
out.result({ deleted: 0, bytes: 0, kept: 0 }, 'Nothing to delete.');
|
|
44
|
+
return ExitCode.Ok;
|
|
45
|
+
}
|
|
46
|
+
const db = ControlDb.open(ctx.dirs.data);
|
|
47
|
+
try {
|
|
48
|
+
const now = ctx.now();
|
|
49
|
+
const target = db.pruneUsage(filter, now);
|
|
50
|
+
const keptNote = target.recent > 0
|
|
51
|
+
? `${formatCount(target.recent)} task(s) that finished in the last 24 hours are kept.`
|
|
52
|
+
: null;
|
|
53
|
+
if (target.count === 0) {
|
|
54
|
+
out.result({ deleted: 0, bytes: 0, kept: target.recent }, ['Nothing to delete.', ...(keptNote ? [keptNote] : [])].join('\n'));
|
|
55
|
+
return ExitCode.Ok;
|
|
56
|
+
}
|
|
57
|
+
const what = `the run history of ${formatCount(target.count)} finished task(s) (about ${formatBytes(target.bytes)})${filter.worker ? ` of ${filter.worker}` : ''} from before ${formatLocal(Math.min(before, now - TASKS_PRUNE_MIN_AGE_MS))}`;
|
|
58
|
+
if (!values.yes) {
|
|
59
|
+
if (out.json || !ctx.stdin.isTTY) {
|
|
60
|
+
throw new UsageError(`this deletes ${what} and cannot be undone.${keptNote ? ` ${keptNote}` : ''} ` +
|
|
61
|
+
'Nothing was deleted. Once the user has agreed, run it again with --yes.');
|
|
62
|
+
}
|
|
63
|
+
if (keptNote)
|
|
64
|
+
out.info(keptNote);
|
|
65
|
+
if (!(await confirm(ctx.stdin, out, `Delete ${what}? This cannot be undone.`))) {
|
|
66
|
+
throw new CliError('cancelled — nothing was deleted.');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const deleted = db.prune(filter, now);
|
|
70
|
+
out.result({ deleted, bytes: target.bytes, kept: target.recent }, [
|
|
71
|
+
`Deleted the run history of ${formatCount(deleted)} task(s). ${CONTROL_DB_FILE} keeps its size; new records reuse the space.`,
|
|
72
|
+
...(keptNote && values.yes ? [keptNote] : [])
|
|
73
|
+
].join('\n'));
|
|
74
|
+
return ExitCode.Ok;
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
db.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
export const tasks = {
|
|
82
|
+
name: 'tasks',
|
|
83
|
+
summary: "Manage this host's task run history",
|
|
84
|
+
usage: '<command>',
|
|
85
|
+
description: "This host keeps a record of every task it ran (control.db, '3minapi paths'): when it ran,\n" +
|
|
86
|
+
"retries, cancellations and why it failed. Nothing deletes it automatically — use 'tasks prune'.",
|
|
87
|
+
options: {},
|
|
88
|
+
args: { min: 0, max: 0 },
|
|
89
|
+
subcommands: [tasksPrune],
|
|
90
|
+
async run() {
|
|
91
|
+
return ExitCode.Usage;
|
|
92
|
+
}
|
|
93
|
+
};
|