@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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -2
  3. package/dist/bin.js +54 -0
  4. package/dist/cli.js +241 -0
  5. package/dist/commands/auth.js +573 -0
  6. package/dist/commands/common.js +48 -0
  7. package/dist/commands/dashboard.js +298 -0
  8. package/dist/commands/delete.js +167 -0
  9. package/dist/commands/doctor.js +326 -0
  10. package/dist/commands/guide.js +84 -0
  11. package/dist/commands/index.js +28 -0
  12. package/dist/commands/init.js +248 -0
  13. package/dist/commands/logs.js +154 -0
  14. package/dist/commands/new.js +109 -0
  15. package/dist/commands/paths.js +64 -0
  16. package/dist/commands/reload.js +32 -0
  17. package/dist/commands/results.js +445 -0
  18. package/dist/commands/start.js +443 -0
  19. package/dist/commands/stop.js +68 -0
  20. package/dist/commands/tasks.js +93 -0
  21. package/dist/commands/worker.js +178 -0
  22. package/dist/core/api.js +132 -0
  23. package/dist/core/config.js +188 -0
  24. package/dist/core/control-db.js +462 -0
  25. package/dist/core/credentials.js +75 -0
  26. package/dist/core/domain/constants.js +121 -0
  27. package/dist/core/domain/contract.js +102 -0
  28. package/dist/core/domain/errors.js +57 -0
  29. package/dist/core/domain/index.js +3 -0
  30. package/dist/core/domain/types.js +1 -0
  31. package/dist/core/error-report.js +103 -0
  32. package/dist/core/host/detach.js +49 -0
  33. package/dist/core/host/live-connection.js +270 -0
  34. package/dist/core/host/live-receiver.js +264 -0
  35. package/dist/core/host/module.js +1 -0
  36. package/dist/core/host/poller.js +113 -0
  37. package/dist/core/host/pull-step.js +54 -0
  38. package/dist/core/host/receiver.js +1 -0
  39. package/dist/core/host/reload.js +34 -0
  40. package/dist/core/host/runner-lock.js +245 -0
  41. package/dist/core/host/session.js +84 -0
  42. package/dist/core/host/usage.js +20 -0
  43. package/dist/core/log-read.js +59 -0
  44. package/dist/core/logger.js +206 -0
  45. package/dist/core/names.js +27 -0
  46. package/dist/core/network.js +10 -0
  47. package/dist/core/open-browser.js +26 -0
  48. package/dist/core/output.js +68 -0
  49. package/dist/core/paths.js +26 -0
  50. package/dist/core/project.js +85 -0
  51. package/dist/core/prompt.js +140 -0
  52. package/dist/core/redact.js +45 -0
  53. package/dist/core/secure-fs.js +50 -0
  54. package/dist/core/text-file.js +12 -0
  55. package/dist/core/text.js +27 -0
  56. package/dist/core/time.js +84 -0
  57. package/dist/core/update-notice.js +142 -0
  58. package/dist/core/version.js +16 -0
  59. package/dist/core/warnings.js +10 -0
  60. package/dist/help.js +144 -0
  61. package/dist/modules/dashboard/assets.js +411 -0
  62. package/dist/modules/dashboard/data.js +565 -0
  63. package/dist/modules/dashboard/guard.js +75 -0
  64. package/dist/modules/dashboard/lock.js +93 -0
  65. package/dist/modules/dashboard/server.js +295 -0
  66. package/dist/modules/dashboard/views/html.js +114 -0
  67. package/dist/modules/dashboard/views/layout.js +59 -0
  68. package/dist/modules/dashboard/views/logs.js +122 -0
  69. package/dist/modules/dashboard/views/overview.js +316 -0
  70. package/dist/modules/dashboard/views/runs.js +486 -0
  71. package/dist/modules/dashboard/views/workers.js +157 -0
  72. package/dist/modules/workers/builtin/host-identity.js +59 -0
  73. package/dist/modules/workers/builtin/results-worker.js +37 -0
  74. package/dist/modules/workers/local-server.js +244 -0
  75. package/dist/modules/workers/module.js +98 -0
  76. package/dist/modules/workers/reload-result.js +16 -0
  77. package/dist/modules/workers/repeat-scheduler.js +165 -0
  78. package/dist/modules/workers/results/missing-paths.js +66 -0
  79. package/dist/modules/workers/results/query.js +175 -0
  80. package/dist/modules/workers/results/reader-input.js +134 -0
  81. package/dist/modules/workers/results/reader.js +430 -0
  82. package/dist/modules/workers/results/results-child.js +64 -0
  83. package/dist/modules/workers/results/shape.js +212 -0
  84. package/dist/modules/workers/results/store.js +336 -0
  85. package/dist/modules/workers/results/substitute.js +30 -0
  86. package/dist/modules/workers/runtime/env.js +43 -0
  87. package/dist/modules/workers/runtime/error-chain.js +109 -0
  88. package/dist/modules/workers/runtime/executor.js +289 -0
  89. package/dist/modules/workers/runtime/inspect.js +53 -0
  90. package/dist/modules/workers/runtime/protocol.js +1 -0
  91. package/dist/modules/workers/runtime/resolve.js +30 -0
  92. package/dist/modules/workers/runtime/schema.js +85 -0
  93. package/dist/modules/workers/runtime/thread.js +117 -0
  94. package/dist/modules/workers/start.js +265 -0
  95. package/dist/modules/workers/sync.js +241 -0
  96. package/dist/modules/workers/task-runner.js +892 -0
  97. package/dist/modules/workers/template.js +100 -0
  98. package/dist/modules/workers/worker-set.js +122 -0
  99. package/dist/modules/workers/workers.js +66 -0
  100. package/package.json +48 -5
@@ -0,0 +1,298 @@
1
+ import path from 'node:path';
2
+ import { APP_LOG_DIR, BIN, CliError, DASHBOARD_OUT_FILE, DETACH_READY_MS, ENV, ExitCode, STOP_CHECK_INTERVAL_MS, UsageError } from "../core/domain/index.js";
3
+ import { outputTail, spawnDetached } from "../core/host/detach.js";
4
+ import { Logger, pruneLogs } from "../core/logger.js";
5
+ import { openBrowser } from "../core/open-browser.js";
6
+ import { confirm } from "../core/prompt.js";
7
+ import { sleep } from "../core/time.js";
8
+ import { acquireDashboardLock, dashboardLockPath, readDashboard, readDashboardLinks, readDashboardSettled, requestDashboardStop } from "../modules/dashboard/lock.js";
9
+ import { startDashboard } from "../modules/dashboard/server.js";
10
+ import { workersPath } from "./common.js";
11
+ import { stopLocked } from "./stop.js";
12
+ import { initHint } from "../core/project.js";
13
+ const LAN_WARNING = [
14
+ '--lan opens the dashboard to other devices on this network over plain HTTP (not encrypted).',
15
+ ' Saved results (they may contain personal data), logs and the access link travel in clear text.',
16
+ ' Use it only on a network you trust, such as your home or office — not on public or shared Wi-Fi.',
17
+ ' Anyone with the link can see the dashboard: do not share it. If it may have leaked, run',
18
+ ` '${BIN} dashboard stop' and start again — every run makes a new link.`,
19
+ ' macOS and Windows may ask whether to allow incoming connections: allow them, or other devices',
20
+ ' cannot connect. On Linux, a firewall (ufw, firewalld) may need the port opened.'
21
+ ].join('\n');
22
+ export const dashboard = {
23
+ name: 'dashboard',
24
+ summary: 'Open a local, read-only web view of runs, results, workers and logs',
25
+ usage: '[stop | url] [--port <port>] [--no-open] [--lan [--yes]] [--detach]',
26
+ description: 'Serves a page on 127.0.0.1 and opens it in your browser with a link that works for this run only.\n' +
27
+ 'It reads the task history, the results saved in data.db and the logs on this PC — nothing is sent anywhere,\n' +
28
+ `and nothing is changed: use the CLI for that (the page shows the commands). Works while '${BIN} start' is running or not.\n` +
29
+ 'Press Ctrl+C to stop it. Only one can run per host.\n' +
30
+ '\n' +
31
+ '--lan also opens it to other devices on the same network (another PC or a phone on your Wi-Fi) over\n' +
32
+ 'plain HTTP, which is not encrypted. It shows a warning and asks first; pass --yes to skip the question\n' +
33
+ '(scripts and AI agents — without a terminal, --lan needs --yes). Use it only on a network you trust.\n' +
34
+ '\n' +
35
+ '--detach keeps it running in the background after the terminal is closed.\n' +
36
+ `'${BIN} dashboard url' prints the link again, '${BIN} dashboard stop' ends it, and '${BIN} status' shows it.`,
37
+ options: {
38
+ port: {
39
+ type: 'string',
40
+ valueName: 'port',
41
+ description: 'Port to listen on (default: any free port)'
42
+ },
43
+ open: {
44
+ type: 'boolean',
45
+ negated: true,
46
+ description: 'Print the link without opening a browser'
47
+ },
48
+ lan: {
49
+ type: 'boolean',
50
+ description: 'Also serve other devices on this network (plain HTTP — asks first)'
51
+ },
52
+ yes: {
53
+ type: 'boolean',
54
+ short: 'y',
55
+ description: 'Agree to the --lan warning without asking (scripts, AI agents)'
56
+ },
57
+ detach: {
58
+ type: 'boolean',
59
+ short: 'd',
60
+ description: `Run in the background (stop with '${BIN} dashboard stop')`
61
+ }
62
+ },
63
+ args: { min: 0, max: 1 },
64
+ async run(ctx) {
65
+ const action = ctx.positionals[0];
66
+ if (action !== undefined) {
67
+ if (action !== 'stop' && action !== 'url') {
68
+ throw new UsageError(`unknown dashboard action "${action}" — use '${BIN} dashboard stop' or '${BIN} dashboard url'`);
69
+ }
70
+ const { values } = ctx;
71
+ if (values.port !== undefined || values.lan || values.detach || values.yes) {
72
+ throw new UsageError(`'${BIN} dashboard ${action}' takes no options`);
73
+ }
74
+ return action === 'stop' ? stopDashboard(ctx) : printUrl(ctx);
75
+ }
76
+ const port = parsePort(ctx.values.port);
77
+ const lan = Boolean(ctx.values.lan);
78
+ const child = ctx.env[ENV.detached] === '1';
79
+ if (lan && !child)
80
+ await agreeToLan(ctx);
81
+ if (ctx.values.detach && !child)
82
+ return startDetached(ctx, port, lan);
83
+ return runDashboard(ctx, port, lan, child);
84
+ }
85
+ };
86
+ async function runDashboard(ctx, port, lan, child) {
87
+ const { out, values } = ctx;
88
+ const config = ctx.loadConfig();
89
+ const root = config.projectRoot;
90
+ const lock = acquireDashboardLock(ctx.dirs.data, { detached: child, lan, outFile: child ? outFilePath(ctx) : null }, ctx.now());
91
+ try {
92
+ ctx.claimInterrupt();
93
+ const logger = lan ? accessLogger(ctx, config.values) : null;
94
+ const dashboard = await startDashboard({
95
+ port,
96
+ version: ctx.version,
97
+ dataDir: ctx.dirs.data,
98
+ logsDir: ctx.dirs.logs,
99
+ projectRoot: root,
100
+ workersDir: root ? workersPath(root, config) : null,
101
+ now: ctx.now,
102
+ lan,
103
+ onError: (err, url) => out.warn(`${url}: ${err instanceof CliError ? err.message : (err?.stack ?? String(err))}`),
104
+ ...(logger ? { onAccess: (e) => logAccess(logger, e) } : {})
105
+ });
106
+ const shutdown = new AbortController();
107
+ const onAbort = () => shutdown.abort();
108
+ ctx.signal.addEventListener('abort', onAbort, { once: true });
109
+ const stopWatch = setInterval(() => {
110
+ lock.heartbeat();
111
+ if (lock.stopRequested())
112
+ shutdown.abort();
113
+ }, STOP_CHECK_INTERVAL_MS);
114
+ try {
115
+ lock.listening({
116
+ url: dashboard.url,
117
+ lanUrls: dashboard.lanUrls,
118
+ port: dashboard.port,
119
+ origins: [dashboard.origin, ...dashboard.lanOrigins]
120
+ });
121
+ logger?.info('dashboard started', { lan: true, port: dashboard.port, pid: process.pid });
122
+ if (child) {
123
+ out.info(`dashboard started · pid ${process.pid} · ${lock.info.origins.join(' · ')}` +
124
+ (lan ? ' · LAN (plain HTTP)' : ''));
125
+ }
126
+ else {
127
+ out.result(linksJson(lock.info, dashboard), linksText(ctx, dashboard, lan) +
128
+ '\n' +
129
+ out.out.dim('Read-only. The links work for this run only. Press Ctrl+C to stop.'));
130
+ }
131
+ if (!root) {
132
+ out.warn(`this host has no project — saved results and workers are not shown. ${initHint(ctx.home)}`);
133
+ }
134
+ if (!child && values.open !== false && !(await openBrowser(dashboard.url, ctx.platform))) {
135
+ out.info('Could not open a browser — open the link above yourself.');
136
+ }
137
+ if (!shutdown.signal.aborted) {
138
+ await new Promise((resolve) => shutdown.signal.addEventListener('abort', () => resolve(), { once: true }));
139
+ }
140
+ logger?.info('dashboard stopped', { pid: process.pid });
141
+ }
142
+ finally {
143
+ clearInterval(stopWatch);
144
+ ctx.signal.removeEventListener('abort', onAbort);
145
+ await dashboard.close();
146
+ }
147
+ }
148
+ finally {
149
+ lock.release();
150
+ }
151
+ return ExitCode.Ok;
152
+ }
153
+ async function agreeToLan(ctx) {
154
+ const { out } = ctx;
155
+ out.warn(LAN_WARNING);
156
+ if (ctx.values.yes)
157
+ return;
158
+ if (out.json || !ctx.stdin.isTTY) {
159
+ throw new UsageError(`--lan serves the dashboard to your network over plain HTTP and needs agreement. Nothing was opened. Once the user has agreed, run it again with --yes.`);
160
+ }
161
+ if (!(await confirm(ctx.stdin, out, 'Open the dashboard to this network over plain HTTP?'))) {
162
+ throw new CliError('cancelled — the dashboard was not opened.');
163
+ }
164
+ }
165
+ function accessLogger(ctx, values) {
166
+ return new Logger({
167
+ dir: ctx.dirs.logs,
168
+ now: ctx.now,
169
+ onMaintenance(now) {
170
+ pruneLogs({
171
+ dir: ctx.dirs.logs,
172
+ now,
173
+ retentionDays: values.logRetentionDays,
174
+ maxBytes: values.logMaxBytes
175
+ });
176
+ },
177
+ onError: (message) => ctx.out.warn(message)
178
+ });
179
+ }
180
+ function logAccess(logger, e) {
181
+ const attrs = { remote: e.remote ?? null, host: e.host ?? null, path: e.path, status: e.status };
182
+ if (e.kind === 'granted')
183
+ logger.info('dashboard access granted', attrs);
184
+ else
185
+ logger.warn('dashboard access denied', attrs);
186
+ }
187
+ async function startDetached(ctx, port, lan) {
188
+ const { out, values } = ctx;
189
+ const current = readDashboard(ctx.dirs.data);
190
+ if (current.state === 'running') {
191
+ throw new CliError(`the dashboard is already running (pid ${current.info.pid}). Only one can run per host.\n` +
192
+ `Open it with '${BIN} dashboard url', or stop it with '${BIN} dashboard stop' first.`);
193
+ }
194
+ const args = ['dashboard', '--no-open'];
195
+ if (port !== 0)
196
+ args.push('--port', String(port));
197
+ if (lan)
198
+ args.push('--lan', '--yes');
199
+ if (typeof values['api-url'] === 'string')
200
+ args.push('--api-url', values['api-url']);
201
+ if (typeof values.config === 'string')
202
+ args.push('--config', path.resolve(ctx.cwd, values.config));
203
+ const outFile = outFilePath(ctx);
204
+ const startedAt = Date.now();
205
+ const child = spawnDetached({ args, cwd: ctx.cwd, env: ctx.env, outFile });
206
+ let exited = null;
207
+ child.once('exit', (code) => (exited = code ?? 1));
208
+ let ready = null;
209
+ while (Date.now() - startedAt < DETACH_READY_MS && exited === null) {
210
+ const state = readDashboard(ctx.dirs.data);
211
+ if (state.state === 'running' && state.info.pid === child.pid && state.info.port !== null) {
212
+ const links = readDashboardLinks(ctx.dirs.data, state.info);
213
+ if (links) {
214
+ ready = { info: state.info, links };
215
+ break;
216
+ }
217
+ }
218
+ await sleep(100);
219
+ }
220
+ child.unref();
221
+ if (!ready) {
222
+ if (exited === null) {
223
+ try {
224
+ child.kill();
225
+ }
226
+ catch {
227
+ }
228
+ }
229
+ throw new CliError(`the background dashboard did not start${exited !== null ? ` (exit code ${exited})` : ''}.\n` +
230
+ outputTail(outFile));
231
+ }
232
+ const { info, links } = ready;
233
+ out.result({ ...linksJson(info, links), outFile }, [
234
+ `${out.out.green('dashboard started in the background')} pid ${info.pid}`,
235
+ linksText(ctx, links, info.lan),
236
+ `output: ${outFile}`,
237
+ `link again with '${BIN} dashboard url' · stop with '${BIN} dashboard stop'`
238
+ ].join('\n'));
239
+ if (values.open !== false && !(await openBrowser(links.url, ctx.platform))) {
240
+ out.info('Could not open a browser — open the link above yourself.');
241
+ }
242
+ return ExitCode.Ok;
243
+ }
244
+ function stopDashboard(ctx) {
245
+ return stopLocked(ctx, {
246
+ read: () => readDashboard(ctx.dirs.data),
247
+ readSettled: () => readDashboardSettled(ctx.dirs.data),
248
+ requestStop: (info) => requestDashboardStop(ctx.dirs.data, info),
249
+ lockFile: dashboardLockPath(ctx.dirs.data)
250
+ });
251
+ }
252
+ async function printUrl(ctx) {
253
+ const { out } = ctx;
254
+ const current = await readDashboardSettled(ctx.dirs.data);
255
+ const links = current.state === 'running' ? readDashboardLinks(ctx.dirs.data, current.info) : null;
256
+ if (current.state !== 'running' || !links) {
257
+ out.result({ running: false }, `Not running. Start it with '${BIN} dashboard'.`);
258
+ return ExitCode.Failure;
259
+ }
260
+ out.result(linksJson(current.info, links), linksText(ctx, links, current.info.lan));
261
+ return ExitCode.Ok;
262
+ }
263
+ function linksJson(info, links) {
264
+ return {
265
+ url: links.url,
266
+ port: links.port ?? info.port,
267
+ lan: info.lan,
268
+ lanUrls: links.lanUrls,
269
+ pid: info.pid,
270
+ detached: info.detached
271
+ };
272
+ }
273
+ function linksText(ctx, links, lan) {
274
+ const lines = [`Dashboard: ${links.url}`];
275
+ if (lan) {
276
+ if (links.lanUrls.length === 0) {
277
+ lines.push(ctx.out.out.yellow('No network address found on this PC — only this PC can open it.'));
278
+ }
279
+ else {
280
+ lines.push('From other devices on this network (plain HTTP):');
281
+ for (const url of links.lanUrls)
282
+ lines.push(` ${url}`);
283
+ }
284
+ }
285
+ return lines.join('\n');
286
+ }
287
+ function outFilePath(ctx) {
288
+ return path.join(ctx.dirs.logs, APP_LOG_DIR, DASHBOARD_OUT_FILE);
289
+ }
290
+ function parsePort(raw) {
291
+ if (raw === undefined)
292
+ return 0;
293
+ const n = Number(raw);
294
+ if (typeof raw !== 'string' || !Number.isInteger(n) || n < 1 || n > 65535) {
295
+ throw new UsageError('--port must be a number from 1 to 65535');
296
+ }
297
+ return n;
298
+ }
@@ -0,0 +1,167 @@
1
+ import { rmSync, statSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { ApiError } from "../core/api.js";
4
+ import { BIN, CliError, ExitCode, StopError, UsageError, WORKER_EXTENSIONS } from "../core/domain/index.js";
5
+ import { assertWorkerName } from "../core/names.js";
6
+ import { confirm } from "../core/prompt.js";
7
+ import { notifyDeleted, readRunner } from "../core/host/runner-lock.js";
8
+ import { clearStop, openSession, recordStop, rememberQuota, stopHint } from "../core/host/session.js";
9
+ import { formatCount } from "../core/text.js";
10
+ import { ResultStore } from "../modules/workers/results/store.js";
11
+ import { client, requireToken, workersPath } from "./common.js";
12
+ const DELETE_MAX_ROUNDS = 500;
13
+ export const workerDelete = {
14
+ name: 'worker delete',
15
+ summary: 'Delete a local worker and its task history from 3Min API, and remove its file',
16
+ usage: '<worker> [--yes]',
17
+ description: 'Deletes the local worker of this host from 3Min API together with its task history (this\n' +
18
+ 'cannot be undone), then removes its file from the workers folder. Removing the file alone\n' +
19
+ "leaves the worker on 3Min API, where AIs keep sending it tasks. A running 'start' on this PC\n" +
20
+ 'turns the worker off right away (no API call). Works when the quota is used up; one API call when\n' +
21
+ 'there is quota left. Asks for confirmation in a terminal; pass --yes otherwise.',
22
+ options: {
23
+ yes: { type: 'boolean', description: 'Delete without asking for confirmation' }
24
+ },
25
+ args: { min: 1, max: 1 },
26
+ async run(ctx) {
27
+ const { out } = ctx;
28
+ const name = ctx.positionals[0];
29
+ assertWorkerName(name);
30
+ const token = requireToken(ctx);
31
+ const { files, runner } = localFiles(ctx, name);
32
+ if (!ctx.values.yes) {
33
+ if (out.json || !ctx.stdin.isTTY) {
34
+ throw new UsageError(`deleting a worker removes its task history and cannot be undone. Nothing was deleted. Once the user has agreed, run: ${BIN} worker delete ${name} --yes`);
35
+ }
36
+ const also = files.length > 0 ? `, and remove ${files.map((f) => shown(ctx, f)).join(', ')}` : '';
37
+ const yes = await confirm(ctx.stdin, out, `Delete local worker "${name}" and its task history from 3Min API${also}? This cannot be undone.`);
38
+ if (!yes)
39
+ throw new CliError('cancelled — nothing was deleted.');
40
+ }
41
+ const result = await deleteOnServer(ctx, token.token, name);
42
+ if (result.server === 'not_found' && files.length === 0) {
43
+ throw new CliError(`no local worker "${name}" on this host, and no file for it here. '${BIN} status' lists the workers 3Min API has for this host.`);
44
+ }
45
+ const failed = [];
46
+ for (const file of files) {
47
+ try {
48
+ rmSync(file);
49
+ }
50
+ catch (err) {
51
+ failed.push(`${file}: ${err.message}`);
52
+ }
53
+ }
54
+ const removed = files.filter((f) => !failed.some((line) => line.startsWith(`${f}:`)));
55
+ const quota = result.server === 'deleted' ? result.quota : null;
56
+ const lines = [];
57
+ if (result.server === 'deleted') {
58
+ lines.push(`Deleted local worker "${name}" and its task history from 3Min API.`);
59
+ }
60
+ else {
61
+ lines.push(`"${name}" was not a local worker on this host — nothing to delete on 3Min API.`);
62
+ }
63
+ if (removed.length > 0)
64
+ lines.push(`Removed ${removed.map((f) => shown(ctx, f)).join(', ')}.`);
65
+ else if (files.length === 0)
66
+ lines.push('No local file for it was found here.');
67
+ if (result.server === 'deleted' && runner && files.length > 0) {
68
+ lines.push(tellRunner(ctx, runner, name)
69
+ ? `'${BIN} start' is running: it turns "${name}" off now, and stops if no other worker is left. No restart needed.`
70
+ : `'${BIN} start' is running: it turns "${name}" off on its next fetch, and stops if no other worker is left. No restart needed.`);
71
+ }
72
+ if (quota)
73
+ lines.push(`quota ${formatCount(quota.remaining)} of ${formatCount(quota.limit)} calls left`);
74
+ const kept = savedResultCount(ctx, name);
75
+ if (kept > 0) {
76
+ lines.push(`${formatCount(kept)} saved result(s) of "${name}" stay in data.db — ` +
77
+ `'${BIN} worker results prune --worker ${name} --before <date>' deletes them.`);
78
+ }
79
+ out.result({ name, server: result.server, files: removed, quota }, lines.join('\n'));
80
+ if (failed.length > 0) {
81
+ throw new CliError(`could not remove the worker file — delete it yourself, or the next '${BIN} start' registers it again:\n` +
82
+ failed.map((line) => ` ${line}`).join('\n'));
83
+ }
84
+ return ExitCode.Ok;
85
+ }
86
+ };
87
+ function savedResultCount(ctx, name) {
88
+ try {
89
+ const root = ctx.loadConfig().projectRoot;
90
+ const store = root ? ResultStore.openReadOnly(root) : null;
91
+ if (!store)
92
+ return 0;
93
+ try {
94
+ return store.usage({ worker: name }).count;
95
+ }
96
+ finally {
97
+ store.close();
98
+ }
99
+ }
100
+ catch {
101
+ return 0;
102
+ }
103
+ }
104
+ async function deleteOnServer(ctx, token, name) {
105
+ const session = openSession(ctx);
106
+ try {
107
+ const api = client(ctx, session, token);
108
+ for (let round = 0; round < DELETE_MAX_ROUNDS; round += 1) {
109
+ let res;
110
+ try {
111
+ res = await api.deleteWorker(name);
112
+ }
113
+ catch (err) {
114
+ if (err instanceof StopError) {
115
+ recordStop(session, err, ctx.now());
116
+ throw new StopError(err.reason, `${err.message}\n${stopHint(err.reason)}`, err.resetAt);
117
+ }
118
+ if (err instanceof ApiError && err.code === 'worker_not_found') {
119
+ clearStop(session);
120
+ return round === 0 ? { server: 'not_found' } : { server: 'deleted', quota: null };
121
+ }
122
+ throw err;
123
+ }
124
+ clearStop(session);
125
+ rememberQuota(session, res.quota, ctx.now());
126
+ if (res.complete)
127
+ return { server: 'deleted', quota: res.quota };
128
+ if (round === 0)
129
+ ctx.out.info(`deleting the task history of "${name}" — this can take a while…`);
130
+ }
131
+ }
132
+ finally {
133
+ session.close();
134
+ }
135
+ throw new CliError(`the task history of "${name}" is still being deleted. Run '${BIN} worker delete ${name} --yes' again to continue.`);
136
+ }
137
+ function localFiles(ctx, name) {
138
+ const state = readRunner(ctx.dirs.data);
139
+ const runner = state.state === 'running' && state.info.workersDir ? state.info : null;
140
+ let dir;
141
+ if (runner) {
142
+ dir = runner.workersDir;
143
+ }
144
+ else {
145
+ const config = ctx.loadConfig();
146
+ if (!config.projectRoot)
147
+ return { files: [], runner };
148
+ dir = workersPath(config.projectRoot, config);
149
+ }
150
+ const files = WORKER_EXTENSIONS.map((ext) => path.join(dir, name + ext)).filter((f) => statSync(f, { throwIfNoEntry: false })?.isFile());
151
+ return { files, runner };
152
+ }
153
+ function tellRunner(ctx, runner, name) {
154
+ if (!runner.localDelete)
155
+ return false;
156
+ try {
157
+ notifyDeleted(ctx.dirs.data, runner, [name]);
158
+ return true;
159
+ }
160
+ catch {
161
+ return false;
162
+ }
163
+ }
164
+ function shown(ctx, file) {
165
+ const rel = path.relative(ctx.cwd, file);
166
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel : file;
167
+ }