@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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +82 -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,892 @@
1
+ import { RepeatScheduler, parseSettings } from "./repeat-scheduler.js";
2
+ import { ApiError } from "../../core/api.js";
3
+ import { ERROR_MESSAGE_MAX_CHARS, EVENT_MESSAGE_MAX_CHARS, REPORT_ENVELOPE_RESERVE, REPEAT_FAIL_STREAK_MAX, REPORT_MAX_BYTES, RESULT_RESERVED_KEY, STATUS_MESSAGE_MAX_CHARS, StopError, TASK_CODES, TASK_ERROR_CODE } from "../../core/domain/index.js";
4
+ import { computeShape } from "./results/shape.js";
5
+ import { substituteResult, substituteStatusMessage } from "./results/substitute.js";
6
+ import { formatCount, oneLine, stripControl, truncate } from "../../core/text.js";
7
+ export const CANCEL = {
8
+ server: 'cancelled',
9
+ superseded: 'superseded',
10
+ shutdown: 'host_stopped'
11
+ };
12
+ const STOPPED_PREFIX = 'stopped: ';
13
+ const PENDING_MAX_AGE_MS = 24 * 60 * 60 * 1000;
14
+ export class TaskRunner {
15
+ opts;
16
+ inflight = new Map();
17
+ pending = [];
18
+ repeatAcks = 0;
19
+ flushing = null;
20
+ flushAgain = null;
21
+ scheduler;
22
+ constructor(opts) {
23
+ this.opts = opts;
24
+ this.scheduler = new RepeatScheduler({
25
+ db: opts.db,
26
+ logger: opts.logger,
27
+ now: opts.now,
28
+ run: (taskId, runNo) => this.runRepeat(taskId, runNo),
29
+ every: opts.repeatEvery
30
+ });
31
+ }
32
+ get free() {
33
+ return Math.max(0, this.opts.executor.concurrency - this.inflight.size - this.repeatAcks);
34
+ }
35
+ get busy() {
36
+ return this.inflight.size;
37
+ }
38
+ get repeating() {
39
+ return this.scheduler.size;
40
+ }
41
+ startRepeats() {
42
+ this.scheduler.load();
43
+ }
44
+ dropRepeats(workers) {
45
+ const gone = new Set(workers);
46
+ const ids = new Set(this.scheduler.dropWorkers(workers));
47
+ for (const row of this.opts.db.repeatTasks())
48
+ if (gone.has(row.worker))
49
+ ids.add(row.task_id);
50
+ for (const id of ids) {
51
+ this.scheduler.remove(id);
52
+ this.opts.logger.info('repeating task stopped; its worker was deleted on the server', {
53
+ task_id: id
54
+ });
55
+ this.opts.db.markFinished(id, 'cancelled', this.opts.now(), 'worker deleted on the server');
56
+ this.opts.executor.cancel(id, CANCEL.server);
57
+ }
58
+ }
59
+ handle(task, reporter, { ack }) {
60
+ return this.process(task, reporter, ack).catch((err) => {
61
+ this.opts.logger.error('task handling failed', {
62
+ task_id: task.task_id,
63
+ worker: task.worker,
64
+ message: err?.stack ?? String(err)
65
+ });
66
+ });
67
+ }
68
+ cancel(taskId) {
69
+ const repeat = this.scheduler.has(taskId) || this.isWaitingRepeat(taskId);
70
+ if (repeat) {
71
+ this.scheduler.remove(taskId);
72
+ this.opts.db.markFinished(taskId, 'cancelled', this.opts.now());
73
+ this.event(`${this.workerOf(taskId)} ${shortId(taskId)} repeat cancelled`);
74
+ }
75
+ if (this.opts.executor.cancel(taskId, CANCEL.server) || repeat) {
76
+ this.opts.logger.info('task cancelled by the server', { task_id: taskId });
77
+ }
78
+ }
79
+ isWaitingRepeat(taskId) {
80
+ const row = this.opts.db.getTask(taskId);
81
+ return Boolean(row?.repeat) && (row.state === 'received' || row.state === 'running');
82
+ }
83
+ async shutdown() {
84
+ this.scheduler.stop();
85
+ await this.opts.executor.cancelAll(CANCEL.shutdown);
86
+ await this.settled();
87
+ }
88
+ async halt(reason) {
89
+ this.scheduler.stop();
90
+ await (this.flushAgain ?? this.flushing);
91
+ this.pending = [];
92
+ await this.opts.executor.haltAll(STOPPED_PREFIX + reason);
93
+ await this.settled();
94
+ }
95
+ flushPending() {
96
+ if (this.flushing) {
97
+ this.flushAgain ??= this.flushing.then(() => {
98
+ this.flushAgain = null;
99
+ return this.flushPending();
100
+ });
101
+ return this.flushAgain;
102
+ }
103
+ this.flushing = this.flushQueue().finally(() => (this.flushing = null));
104
+ return this.flushing;
105
+ }
106
+ async flushQueue() {
107
+ const queue = this.pending;
108
+ this.pending = [];
109
+ for (const [i, p] of queue.entries()) {
110
+ const { r, at } = await this.deliver(p.task, p.reporter, p.replies);
111
+ if (r === 'stop') {
112
+ this.pending.push({ ...p, replies: p.replies.slice(at) }, ...queue.slice(i + 1));
113
+ return;
114
+ }
115
+ if (r === 'retry') {
116
+ if (this.opts.now() - p.since < PENDING_MAX_AGE_MS) {
117
+ this.pending.push({ ...p, replies: p.replies.slice(at) });
118
+ }
119
+ else {
120
+ this.setReply(p.task, 'rejected');
121
+ this.finish(p.task, p.replies[at].state, 'reply was not delivered within 24 hours');
122
+ }
123
+ continue;
124
+ }
125
+ this.settle(p.task, r, p.replies[at]);
126
+ }
127
+ }
128
+ async recover(reporter) {
129
+ const rows = this.opts.db.unfinishedTasks();
130
+ for (const row of rows)
131
+ await this.recoverOne(row, reporter);
132
+ for (const row of this.opts.db.repeatTasks())
133
+ await this.recoverRepeat(row);
134
+ return rows.length;
135
+ }
136
+ async recoverRepeat(row) {
137
+ const task = this.repeatEnvelope(row);
138
+ if (!task)
139
+ return;
140
+ const runNo = row.run_no ?? 0;
141
+ if (runNo <= (row.runs ?? 0)) {
142
+ await this.closeIfDone(task, tallyOf(row), row.last_run_at ?? this.opts.now(), null);
143
+ return;
144
+ }
145
+ this.opts.logger.warn('a run of a repeating task was cut off by the previous run', {
146
+ task_id: row.task_id,
147
+ worker: row.worker,
148
+ run_no: runNo
149
+ });
150
+ const message = "the 3minapi CLI on the user's PC stopped during this run — it is unknown whether it finished";
151
+ await this.recordRun(task, runNo, tallyOf(row), {
152
+ ok: false,
153
+ reason: 'host_restarted',
154
+ message,
155
+ payload: JSON.stringify(failedBody('host_restarted', message).error),
156
+ report: false,
157
+ close: true
158
+ });
159
+ }
160
+ async recoverOne(row, reporter) {
161
+ const { logger } = this.opts;
162
+ const pull = Boolean(row.lease_id);
163
+ if (row.repeat) {
164
+ logger.info('dropping an unacknowledged repeating task; the server will deliver it again', {
165
+ task_id: row.task_id,
166
+ worker: row.worker
167
+ });
168
+ return this.opts.db.markFinished(row.task_id, 'superseded', this.opts.now(), 'not acknowledged before the restart; the server will deliver it again');
169
+ }
170
+ const task = {
171
+ task_id: row.task_id,
172
+ worker: row.worker,
173
+ input: null,
174
+ callback_url: row.callback_url ?? null,
175
+ lease_id: pull ? row.lease_id : null,
176
+ delivery_attempt: row.delivery_attempt,
177
+ source: 'pull'
178
+ };
179
+ const id = `${row.worker} ${shortId(row.task_id)}`;
180
+ const attrs = { task_id: row.task_id, worker: row.worker, state: row.state };
181
+ const message = "the 3minapi CLI on the user's PC stopped before this task finished — it is unknown whether it ran";
182
+ if (!pull) {
183
+ logger.warn('closing a pushed task left unfinished by the previous run', attrs);
184
+ return this.finish(task, 'failed', message);
185
+ }
186
+ const stored = this.storedResult(row.task_id);
187
+ if (stored && stored.reply !== 'pending') {
188
+ return this.finish(task, stored.status);
189
+ }
190
+ if (stored) {
191
+ logger.info('resending the reply of a task the previous run finished', attrs);
192
+ if (stored.status === 'completed')
193
+ return this.completed(task, reporter, stored.payload, 0, stored);
194
+ const failed = parseStoredError(stored.payload);
195
+ if (failed) {
196
+ this.event(`${id} failed earlier (${failed.reason}); resent its reply`);
197
+ return this.terminal(task, reporter, this.failedReplies(failed.reason, failed.message));
198
+ }
199
+ }
200
+ if (row.state === 'running' || row.acked_at != null) {
201
+ logger.warn('closing a task left unfinished by the previous run', attrs);
202
+ this.event(`${id} failed (host_restarted): the previous run stopped before it finished`);
203
+ if (task.callback_url === null)
204
+ return this.finish(task, 'failed', message);
205
+ this.store(task, 'failed', JSON.stringify(failedBody('host_restarted', message).error));
206
+ return this.terminal(task, reporter, this.failedReplies('host_restarted', message));
207
+ }
208
+ logger.info('dropping an unacknowledged task; the server will deliver it again', attrs);
209
+ this.finish(task, 'superseded', 'not acknowledged before the restart; the server will deliver it again');
210
+ }
211
+ storedResult(taskId) {
212
+ if (!this.opts.results)
213
+ return null;
214
+ try {
215
+ return this.opts.results.get(taskId);
216
+ }
217
+ catch (err) {
218
+ this.opts.logger.warn('could not read the saved result', {
219
+ task_id: taskId,
220
+ message: err.message
221
+ });
222
+ return null;
223
+ }
224
+ }
225
+ async settled() {
226
+ while (this.inflight.size > 0) {
227
+ await Promise.all([...this.inflight.values()].map((f) => f.done));
228
+ }
229
+ }
230
+ async process(task, reporter, ack) {
231
+ const { executor, db, logger } = this.opts;
232
+ if (task.repeat && task.lease_id !== null)
233
+ return this.receiveRepeat(task, reporter, ack);
234
+ const prev = this.inflight.get(task.task_id);
235
+ if (prev) {
236
+ if (prev.leaseId === task.lease_id)
237
+ return;
238
+ executor.cancel(task.task_id, CANCEL.superseded);
239
+ await prev.done;
240
+ }
241
+ if (db.receive(this.received(task), this.opts.now()) === 'duplicate-done') {
242
+ logger.info('task already finished here; not running it again', { task_id: task.task_id });
243
+ return;
244
+ }
245
+ let release;
246
+ const entry = { leaseId: task.lease_id, done: new Promise((r) => (release = r)) };
247
+ this.inflight.set(task.task_id, entry);
248
+ try {
249
+ await this.execute(task, reporter, ack);
250
+ }
251
+ finally {
252
+ this.releaseSlot(() => {
253
+ if (this.inflight.get(task.task_id) === entry)
254
+ this.inflight.delete(task.task_id);
255
+ });
256
+ release();
257
+ }
258
+ }
259
+ async execute(task, reporter, ack) {
260
+ const { executor, db, logger, now } = this.opts;
261
+ const attrs = { task_id: task.task_id, worker: task.worker };
262
+ if (ack) {
263
+ const r = await this.send(task, reporter, { status: 'working' });
264
+ if (r === 'ok')
265
+ db.markAcked(task.task_id, now());
266
+ if (r === 'stop')
267
+ return this.finish(task, 'cancelled', 'stopped before it started');
268
+ if (r === 'drop')
269
+ return this.finish(task, 'superseded', 'the server no longer accepts this task');
270
+ if (r === 'retry' || r === 'too_large') {
271
+ logger.warn('could not confirm the acknowledgement; running the task anyway', attrs);
272
+ }
273
+ }
274
+ let startedAt = null;
275
+ const outcome = await executor.run({ id: task.task_id, worker: task.worker, input: task.input }, {
276
+ onStart: () => {
277
+ startedAt = now();
278
+ db.markRunning(task.task_id, startedAt);
279
+ logger.info('task started', attrs);
280
+ this.event(`${task.worker} ${shortId(task.task_id)} started`);
281
+ },
282
+ onReport: async (message) => {
283
+ const r = await this.send(task, reporter, {
284
+ status: 'working',
285
+ status_message: clip(message, STATUS_MESSAGE_MAX_CHARS)
286
+ });
287
+ if (r === 'drop')
288
+ executor.cancel(task.task_id, CANCEL.superseded);
289
+ }
290
+ });
291
+ const ms = startedAt === null ? 0 : now() - startedAt;
292
+ await this.conclude(task, reporter, outcome, ms);
293
+ }
294
+ async conclude(task, reporter, outcome, ms) {
295
+ const { logger } = this.opts;
296
+ const attrs = { task_id: task.task_id, worker: task.worker, duration_ms: ms };
297
+ if (outcome.status === 'cancelled') {
298
+ if (outcome.reason.startsWith(STOPPED_PREFIX)) {
299
+ logger.warn('task aborted by the stop; not reported', attrs);
300
+ this.event(`${task.worker} ${shortId(task.task_id)} aborted (not reported — calls are stopped)`);
301
+ return this.finish(task, 'cancelled', outcome.reason);
302
+ }
303
+ if (outcome.reason === CANCEL.shutdown) {
304
+ logger.info('task aborted because the CLI is stopping', attrs);
305
+ this.event(`${task.worker} ${shortId(task.task_id)} aborted (CLI stopping)`);
306
+ const message = "the 3minapi CLI on the user's PC stopped before the task finished";
307
+ this.store(task, 'failed', JSON.stringify(failedBody('host_stopped', message).error));
308
+ return this.terminal(task, reporter, [
309
+ { body: failedBody('host_stopped', message), state: 'failed', reply: 'failed' }
310
+ ]);
311
+ }
312
+ const state = outcome.reason === CANCEL.superseded ? 'superseded' : 'cancelled';
313
+ logger.info(`task ${state}`, attrs);
314
+ this.event(`${task.worker} ${shortId(task.task_id)} ${state}`);
315
+ return this.finish(task, state);
316
+ }
317
+ if (outcome.status === 'completed')
318
+ return this.completed(task, reporter, outcome.resultJson, ms);
319
+ const builtin = this.opts.isBuiltin?.(task.worker) ?? false;
320
+ logger.warn('task failed', {
321
+ ...attrs,
322
+ reason: outcome.reason,
323
+ ...(builtin ? {} : { message: outcome.message })
324
+ });
325
+ this.event(`${task.worker} ${shortId(task.task_id)} failed (${outcome.reason}): ${clip(outcome.message, EVENT_MESSAGE_MAX_CHARS)}`);
326
+ this.store(task, 'failed', JSON.stringify(failedBody(outcome.reason, outcome.detail ?? outcome.message).error));
327
+ return this.terminal(task, reporter, [
328
+ {
329
+ body: failedBody(outcome.reason, outcome.message),
330
+ state: 'failed',
331
+ reply: 'failed',
332
+ error: builtin ? outcome.reason : outcome.message
333
+ }
334
+ ]);
335
+ }
336
+ completed(task, reporter, resultJson, ms, resend = null) {
337
+ const { logger } = this.opts;
338
+ const attrs = { task_id: task.task_id, worker: task.worker, duration_ms: ms };
339
+ const id = `${task.worker} ${shortId(task.task_id)}`;
340
+ const done = resend ? 'completed earlier; resent its reply' : `completed in ${ms} ms`;
341
+ const value = JSON.parse(resultJson);
342
+ const bytes = Buffer.byteLength(resultJson);
343
+ let shape = null;
344
+ let saved;
345
+ if (resend) {
346
+ shape =
347
+ resend.shape && resend.shape_hash ? { shape: resend.shape, hash: resend.shape_hash } : null;
348
+ saved = true;
349
+ }
350
+ else {
351
+ if (this.opts.results && !this.opts.isBuiltin?.(task.worker))
352
+ shape = computeShape(value);
353
+ saved = this.store(task, 'completed', resultJson, shape);
354
+ }
355
+ const where = saved
356
+ ? "it was saved on the user's PC (3minapi worker results show " + task.task_id + ')'
357
+ : "it could not be saved on the user's PC";
358
+ if (isReserved(value)) {
359
+ const message = `the result has a top-level "${RESULT_RESERVED_KEY}" key, which 3minapi reserves — rename it; ${where}`;
360
+ logger.warn('task failed', { ...attrs, reason: 'reserved_result_key' });
361
+ this.event(`${id} failed (reserved_result_key): rename the top-level "${RESULT_RESERVED_KEY}" key`);
362
+ return this.terminal(task, reporter, this.failedReplies('reserved_result_key', message));
363
+ }
364
+ const tooLarge = this.failedReplies('result_too_large', `the result is ${formatCount(bytes)} bytes, over the 100 KiB reply limit; ${where}`);
365
+ const substitute = task.source === 'pull' && saved && shape
366
+ ? [
367
+ {
368
+ body: {
369
+ status: 'completed',
370
+ status_message: substituteStatusMessage(bytes),
371
+ result: substituteResult({
372
+ taskId: task.task_id,
373
+ worker: task.worker,
374
+ bytes,
375
+ shape: shape.shape,
376
+ shapeHash: shape.hash,
377
+ reader: this.opts.reader?.() ?? null
378
+ })
379
+ },
380
+ state: 'completed',
381
+ reply: 'substitute'
382
+ }
383
+ ]
384
+ : [];
385
+ const max = this.opts.maxResultBytes ?? REPORT_MAX_BYTES - REPORT_ENVELOPE_RESERVE;
386
+ if (bytes <= max) {
387
+ logger.info('task completed', attrs);
388
+ this.event(`${id} ${done}`);
389
+ return this.terminal(task, reporter, [
390
+ { body: { status: 'completed', result: value }, state: 'completed', reply: 'result' },
391
+ ...substitute,
392
+ ...tooLarge
393
+ ]);
394
+ }
395
+ if (substitute.length > 0) {
396
+ logger.info('task completed; the result is stored locally and a summary was sent', {
397
+ ...attrs,
398
+ bytes
399
+ });
400
+ this.event(`${id} ${done} — ${formatCount(bytes)} bytes saved in data.db, sent a summary`);
401
+ return this.terminal(task, reporter, [...substitute, ...tooLarge]);
402
+ }
403
+ logger.warn('task failed', { ...attrs, reason: 'result_too_large', bytes, saved });
404
+ this.event(`${id} failed (result_too_large): ${formatCount(bytes)} bytes${saved ? ' — saved in data.db' : ''}`);
405
+ return this.terminal(task, reporter, tooLarge);
406
+ }
407
+ failedReplies(reason, message) {
408
+ return [
409
+ { body: failedBody(reason, message), state: 'failed', reply: 'failed', error: message }
410
+ ];
411
+ }
412
+ store(task, status, payload, shape = null, runNo) {
413
+ const { results } = this.opts;
414
+ if (!results || this.opts.isBuiltin?.(task.worker))
415
+ return false;
416
+ try {
417
+ results.save({
418
+ taskId: task.task_id,
419
+ worker: task.worker,
420
+ createdAt: this.opts.now(),
421
+ source: task.source,
422
+ status,
423
+ reply: task.source === 'serve' || runNo !== undefined ? 'none' : 'pending',
424
+ payload,
425
+ shape: shape?.shape ?? null,
426
+ shapeHash: shape?.hash ?? null,
427
+ ...(runNo !== undefined ? { runNo } : {})
428
+ });
429
+ return true;
430
+ }
431
+ catch (err) {
432
+ const message = err.message;
433
+ this.opts.logger.warn('could not save the result to data.db', {
434
+ task_id: task.task_id,
435
+ message
436
+ });
437
+ this.event(`${task.worker} ${shortId(task.task_id)} result not saved to data.db: ${message}`);
438
+ return false;
439
+ }
440
+ }
441
+ setReply(task, reply) {
442
+ if (task.repeat)
443
+ return;
444
+ if (!this.opts.results || task.source === 'serve' || this.opts.isBuiltin?.(task.worker))
445
+ return;
446
+ try {
447
+ this.opts.results.setReply(task.task_id, reply);
448
+ }
449
+ catch (err) {
450
+ this.opts.logger.warn('could not update the saved result', {
451
+ task_id: task.task_id,
452
+ message: err.message
453
+ });
454
+ }
455
+ }
456
+ async terminal(task, reporter, replies) {
457
+ const { r, at } = await this.deliver(task, reporter, replies);
458
+ if (r === 'retry') {
459
+ this.opts.logger.warn('reply not delivered; will retry later', {
460
+ task_id: task.task_id
461
+ });
462
+ this.pending.push({
463
+ task,
464
+ reporter: reporter,
465
+ replies: replies.slice(at),
466
+ since: this.opts.now()
467
+ });
468
+ return;
469
+ }
470
+ this.settle(task, r, replies[at]);
471
+ }
472
+ async deliver(task, reporter, replies) {
473
+ let at = 0;
474
+ let r = await this.send(task, reporter, replies[0].body);
475
+ while (r === 'too_large' && at < replies.length - 1) {
476
+ at++;
477
+ r = await this.send(task, reporter, replies[at].body);
478
+ }
479
+ return { r, at };
480
+ }
481
+ settle(task, r, reply) {
482
+ switch (r) {
483
+ case 'ok':
484
+ this.setReply(task, reply.reply);
485
+ return this.finish(task, reply.state, reply.error);
486
+ case 'skip':
487
+ return this.finish(task, reply.state, reply.error);
488
+ case 'drop':
489
+ this.setReply(task, 'rejected');
490
+ return this.finish(task, reply.state, 'the server no longer accepts this task');
491
+ case 'stop':
492
+ this.setReply(task, 'rejected');
493
+ return this.finish(task, reply.state, 'not reported — calls are stopped');
494
+ case 'too_large':
495
+ this.setReply(task, 'rejected');
496
+ return this.finish(task, 'failed', 'the server rejected every reply as too large');
497
+ }
498
+ }
499
+ async send(task, reporter, body, invalidAsDrop = false, runNo) {
500
+ if (!reporter || task.callback_url === null)
501
+ return 'skip';
502
+ const withLease = task.lease_id === null ? body : { ...body, lease_id: task.lease_id };
503
+ try {
504
+ await reporter.report(task.callback_url, withLease);
505
+ return 'ok';
506
+ }
507
+ catch (err) {
508
+ if (err instanceof StopError) {
509
+ this.opts.onStop(err);
510
+ return 'stop';
511
+ }
512
+ if (err instanceof ApiError) {
513
+ const attrs = {
514
+ task_id: task.task_id,
515
+ ...(runNo !== undefined ? { run_no: runNo } : {}),
516
+ status: err.status,
517
+ code: err.code,
518
+ message: err.message
519
+ };
520
+ if (err.code !== null && TASK_CODES.includes(err.code)) {
521
+ this.opts.logger.info('the server dropped the task', attrs);
522
+ return 'drop';
523
+ }
524
+ if (invalidAsDrop && err.code === 'invalid_request') {
525
+ this.opts.logger.error('the server rejected a repeat report (a CLI bug)', attrs);
526
+ return 'drop';
527
+ }
528
+ if (err.code === 'payload_too_large' || err.code === 'unsupported_media_type') {
529
+ this.opts.logger.warn('the server rejected the reply', attrs);
530
+ return 'too_large';
531
+ }
532
+ this.opts.logger.warn('reply failed', attrs);
533
+ return 'retry';
534
+ }
535
+ throw err;
536
+ }
537
+ }
538
+ async receiveRepeat(task, reporter, ack) {
539
+ const { db, logger, now } = this.opts;
540
+ const attrs = { task_id: task.task_id, worker: task.worker };
541
+ if (db.receive(this.received(task), now()) === 'duplicate-done') {
542
+ logger.info('task already finished here; not running it again', attrs);
543
+ return;
544
+ }
545
+ if (this.opts.isBuiltin?.(task.worker) || !this.opts.repeatReporter) {
546
+ const message = this.opts.isBuiltin?.(task.worker)
547
+ ? 'the built-in results reader does not repeat — create a single task for each read'
548
+ : 'this 3minapi CLI cannot run repeating tasks here';
549
+ logger.warn('repeating task rejected', attrs);
550
+ return this.terminal(task, reporter, this.failedReplies('invalid_input', message));
551
+ }
552
+ this.repeatAcks++;
553
+ let r = 'skip';
554
+ try {
555
+ if (ack)
556
+ r = await this.send(task, reporter, { status: 'working' });
557
+ }
558
+ finally {
559
+ this.releaseSlot(() => this.repeatAcks--);
560
+ }
561
+ if (r === 'ok')
562
+ db.markAcked(task.task_id, now());
563
+ if (r === 'stop')
564
+ return this.finish(task, 'cancelled', 'stopped before it started');
565
+ if (r === 'drop')
566
+ return this.finish(task, 'superseded', 'the server no longer accepts this task');
567
+ if (r === 'retry' || r === 'too_large') {
568
+ logger.warn('could not confirm the acknowledgement; running the repeating task anyway', attrs);
569
+ }
570
+ if (this.scheduler.has(task.task_id)) {
571
+ logger.info('repeating task delivered again; continuing with the new lease', attrs);
572
+ return;
573
+ }
574
+ const every = task.repeat.max_runs === undefined ? '' : `, ${task.repeat.max_runs} runs`;
575
+ logger.info('repeating task accepted', {
576
+ ...attrs,
577
+ interval_seconds: task.repeat.interval_seconds,
578
+ max_runs: task.repeat.max_runs ?? null
579
+ });
580
+ this.event(`${task.worker} ${shortId(task.task_id)} repeating every ${task.repeat.interval_seconds}s${every}`);
581
+ const row = db.getTask(task.task_id);
582
+ this.scheduler.add(task.task_id, task.worker, task.repeat, Math.max(row?.run_no ?? 0, row?.runs ?? 0));
583
+ }
584
+ async runRepeat(taskId, runNo) {
585
+ const { executor, db, logger, now } = this.opts;
586
+ const row = db.getTask(taskId);
587
+ const task = row && (row.state === 'received' || row.state === 'running')
588
+ ? this.repeatEnvelope(row)
589
+ : null;
590
+ if (!row || !task) {
591
+ this.scheduler.remove(taskId);
592
+ return;
593
+ }
594
+ let release;
595
+ const entry = { leaseId: task.lease_id, done: new Promise((r) => (release = r)) };
596
+ this.inflight.set(taskId, entry);
597
+ try {
598
+ let startedAt = null;
599
+ const outcome = await executor.run({ id: taskId, worker: task.worker, input: task.input, run: runNo }, {
600
+ onStart: () => {
601
+ startedAt = now();
602
+ db.startRun(taskId, runNo, startedAt);
603
+ logger.info('repeat run started', {
604
+ task_id: taskId,
605
+ worker: task.worker,
606
+ run_no: runNo
607
+ });
608
+ },
609
+ onReport: async (message) => {
610
+ const r = await this.send(this.withCurrentLease(task), this.opts.repeatReporter, { status: 'working', status_message: clip(message, STATUS_MESSAGE_MAX_CHARS) }, false, runNo);
611
+ if (r === 'drop')
612
+ executor.cancel(taskId, CANCEL.superseded);
613
+ }
614
+ });
615
+ const ms = startedAt === null ? 0 : now() - startedAt;
616
+ const current = db.getTask(taskId) ?? row;
617
+ await this.concludeRun(this.withCurrentLease(task, current), runNo, tallyOf(current), outcome, ms, startedAt !== null);
618
+ }
619
+ catch (err) {
620
+ logger.error('repeat run failed', {
621
+ task_id: taskId,
622
+ run_no: runNo,
623
+ message: err?.stack ?? String(err)
624
+ });
625
+ }
626
+ finally {
627
+ this.releaseSlot(() => {
628
+ if (this.inflight.get(taskId) === entry)
629
+ this.inflight.delete(taskId);
630
+ });
631
+ release();
632
+ }
633
+ }
634
+ releaseSlot(release) {
635
+ const wasFull = this.free === 0;
636
+ release();
637
+ if (wasFull && this.free > 0)
638
+ this.opts.onSlotFree?.();
639
+ }
640
+ async concludeRun(task, runNo, tally, outcome, ms, started) {
641
+ const { logger } = this.opts;
642
+ const attrs = { task_id: task.task_id, worker: task.worker, run_no: runNo, duration_ms: ms };
643
+ if (!this.scheduler.has(task.task_id))
644
+ return;
645
+ if (outcome.status === 'cancelled') {
646
+ if (outcome.reason === CANCEL.server || outcome.reason === CANCEL.superseded) {
647
+ this.scheduler.remove(task.task_id);
648
+ logger.info('repeating task closed', { ...attrs, reason: outcome.reason });
649
+ return this.finish(task, 'cancelled', outcome.reason === CANCEL.superseded
650
+ ? 'the server no longer accepts this task'
651
+ : undefined);
652
+ }
653
+ if (!started) {
654
+ logger.info('queued repeat run dropped; the repeating task resumes on the next start', attrs);
655
+ return;
656
+ }
657
+ const message = outcome.reason.startsWith(STOPPED_PREFIX)
658
+ ? `the run was aborted because calls are ${outcome.reason}`
659
+ : "the 3minapi CLI on the user's PC stopped during this run";
660
+ logger.info('repeat run aborted; the repeating task resumes on the next start', attrs);
661
+ return this.recordRun(task, runNo, tally, {
662
+ ok: false,
663
+ reason: 'host_stopped',
664
+ message,
665
+ payload: JSON.stringify(failedBody('host_stopped', message).error),
666
+ report: false,
667
+ close: false
668
+ });
669
+ }
670
+ if (outcome.status === 'completed') {
671
+ const shape = this.opts.results ? computeShape(JSON.parse(outcome.resultJson)) : null;
672
+ logger.info('repeat run completed', attrs);
673
+ return this.recordRun(task, runNo, tally, {
674
+ ok: true,
675
+ payload: outcome.resultJson,
676
+ shape,
677
+ report: true,
678
+ close: true
679
+ });
680
+ }
681
+ logger.warn('repeat run failed', {
682
+ ...attrs,
683
+ reason: outcome.reason,
684
+ message: outcome.message
685
+ });
686
+ return this.recordRun(task, runNo, tally, {
687
+ ok: false,
688
+ reason: outcome.reason,
689
+ message: outcome.message,
690
+ payload: JSON.stringify(failedBody(outcome.reason, outcome.detail ?? outcome.message).error),
691
+ report: true,
692
+ close: true
693
+ });
694
+ }
695
+ async recordRun(task, runNo, tally, run) {
696
+ const { db, logger, now } = this.opts;
697
+ this.store(task, run.ok ? 'completed' : 'failed', run.payload, run.ok ? run.shape : null, runNo);
698
+ const next = {
699
+ runs: tally.runs + 1,
700
+ failed: tally.failed + (run.ok ? 0 : 1),
701
+ streak: run.ok ? 0 : tally.streak + 1
702
+ };
703
+ const lastRunAt = now();
704
+ db.recordRun(task.task_id, {
705
+ runs: next.runs,
706
+ failed: next.failed,
707
+ failStreak: next.streak,
708
+ lastRunAt
709
+ });
710
+ const settings = task.repeat;
711
+ const of = settings.max_runs === undefined ? '' : ` of ${settings.max_runs}`;
712
+ this.event(run.ok
713
+ ? `${task.worker} ${shortId(task.task_id)} run ${runNo}${of} completed`
714
+ : `${task.worker} ${shortId(task.task_id)} run ${runNo}${of} failed (${run.reason}): ${clip(run.message, EVENT_MESSAGE_MAX_CHARS)}`);
715
+ if (!run.close)
716
+ return;
717
+ if (await this.closeIfDone(task, next, lastRunAt, run.ok ? null : run.message))
718
+ return;
719
+ if (!run.report)
720
+ return;
721
+ const progress = {
722
+ runs: next.runs,
723
+ failed: next.failed,
724
+ last_run_at: new Date(lastRunAt).toISOString()
725
+ };
726
+ const r = await this.send(task, this.opts.repeatReporter ?? null, {
727
+ status: 'working',
728
+ status_message: clip(run.ok ? `Run ${runNo}${of} completed.` : `Run ${runNo}${of} failed: ${run.message}`, STATUS_MESSAGE_MAX_CHARS),
729
+ repeat: progress
730
+ }, true, runNo);
731
+ if (r === 'ok') {
732
+ if (this.opts.db.getTask(task.task_id)?.acked_at == null) {
733
+ this.opts.db.markAcked(task.task_id, this.opts.now());
734
+ }
735
+ }
736
+ else if (r === 'drop') {
737
+ this.scheduler.remove(task.task_id);
738
+ this.event(`${task.worker} ${shortId(task.task_id)} repeat ended on the server`);
739
+ this.finish(task, 'cancelled', 'the server no longer accepts this task');
740
+ }
741
+ else if (r === 'retry') {
742
+ logger.warn('repeat report not delivered; the next run reports the totals', {
743
+ task_id: task.task_id,
744
+ run_no: runNo
745
+ });
746
+ }
747
+ }
748
+ async closeIfDone(task, tally, lastRunAt, lastMessage) {
749
+ const { db, logger } = this.opts;
750
+ const settings = task.repeat;
751
+ const progress = {
752
+ runs: tally.runs,
753
+ failed: tally.failed,
754
+ last_run_at: new Date(lastRunAt).toISOString()
755
+ };
756
+ const reporter = this.opts.repeatReporter ?? null;
757
+ if (tally.streak >= REPEAT_FAIL_STREAK_MAX) {
758
+ this.scheduler.remove(task.task_id);
759
+ logger.warn('repeating task failed; too many runs failed in a row', {
760
+ task_id: task.task_id,
761
+ runs: tally.runs,
762
+ failed: tally.failed
763
+ });
764
+ this.event(`${task.worker} ${shortId(task.task_id)} repeat failed: ${REPEAT_FAIL_STREAK_MAX} runs in a row failed`);
765
+ const message = `${REPEAT_FAIL_STREAK_MAX} runs in a row failed` +
766
+ (lastMessage ? `; the last one: ${lastMessage}` : '');
767
+ const body = failedBody('repeat_failed', message);
768
+ body.error.data = { reason: 'repeat_failed', runs: tally.runs, failed: tally.failed };
769
+ await this.terminal(task, reporter, [
770
+ { body: { ...body, repeat: progress }, state: 'failed', reply: 'failed', error: message }
771
+ ]);
772
+ return true;
773
+ }
774
+ if (settings.max_runs !== undefined && tally.runs >= settings.max_runs) {
775
+ this.scheduler.remove(task.task_id);
776
+ logger.info('repeating task completed', {
777
+ task_id: task.task_id,
778
+ runs: tally.runs,
779
+ failed: tally.failed
780
+ });
781
+ this.event(`${task.worker} ${shortId(task.task_id)} repeat completed (${tally.runs} runs, ${tally.failed} failed)`);
782
+ const firstRunAt = db.getTask(task.task_id)?.started_at ?? lastRunAt;
783
+ await this.terminal(task, reporter, [
784
+ {
785
+ body: {
786
+ status: 'completed',
787
+ status_message: `All ${tally.runs} runs finished; ${tally.failed} failed.`,
788
+ result: {
789
+ runs: tally.runs,
790
+ failed: tally.failed,
791
+ first_run_at: new Date(firstRunAt).toISOString(),
792
+ last_run_at: progress.last_run_at
793
+ },
794
+ repeat: progress
795
+ },
796
+ state: 'completed',
797
+ reply: 'result'
798
+ }
799
+ ]);
800
+ return true;
801
+ }
802
+ return false;
803
+ }
804
+ repeatEnvelope(row) {
805
+ const repeat = parseSettings(row.repeat);
806
+ let input = null;
807
+ try {
808
+ input = JSON.parse(row.input_json ?? 'null');
809
+ }
810
+ catch {
811
+ }
812
+ if (!repeat || row.input_json == null || !row.lease_id) {
813
+ this.opts.logger.warn('closing a repeating task that cannot be resumed', {
814
+ task_id: row.task_id
815
+ });
816
+ this.scheduler.remove(row.task_id);
817
+ this.opts.db.markFinished(row.task_id, 'failed', this.opts.now(), 'its saved settings could not be read');
818
+ return null;
819
+ }
820
+ return {
821
+ task_id: row.task_id,
822
+ worker: row.worker,
823
+ input,
824
+ callback_url: row.callback_url ?? null,
825
+ lease_id: row.lease_id,
826
+ delivery_attempt: row.delivery_attempt,
827
+ source: 'pull',
828
+ repeat
829
+ };
830
+ }
831
+ withCurrentLease(task, row = this.opts.db.getTask(task.task_id)) {
832
+ if (!row?.lease_id || row.lease_id === task.lease_id)
833
+ return task;
834
+ return { ...task, lease_id: row.lease_id, callback_url: row.callback_url ?? task.callback_url };
835
+ }
836
+ workerOf(taskId) {
837
+ return this.opts.db.getTask(taskId)?.worker ?? '';
838
+ }
839
+ finish(task, state, error) {
840
+ this.opts.db.markFinished(task.task_id, state, this.opts.now(), error);
841
+ }
842
+ received(task) {
843
+ return {
844
+ task_id: task.task_id,
845
+ worker: task.worker,
846
+ lease_id: task.lease_id ?? '',
847
+ delivery_attempt: task.delivery_attempt,
848
+ callback_url: task.callback_url,
849
+ ...(task.repeat ? { repeat: task.repeat, input: task.input } : {})
850
+ };
851
+ }
852
+ event(line) {
853
+ this.opts.onEvent?.(stripControl(line));
854
+ }
855
+ }
856
+ export function failedBody(reason, message) {
857
+ return {
858
+ status: 'failed',
859
+ status_message: clip(message, STATUS_MESSAGE_MAX_CHARS),
860
+ error: { code: TASK_ERROR_CODE, message, data: { reason } }
861
+ };
862
+ }
863
+ function parseStoredError(payload) {
864
+ try {
865
+ const error = JSON.parse(payload);
866
+ const reason = error?.data?.reason;
867
+ if (typeof reason !== 'string' || typeof error.message !== 'string')
868
+ return null;
869
+ return {
870
+ reason: reason,
871
+ message: truncate(error.message, ERROR_MESSAGE_MAX_CHARS)
872
+ };
873
+ }
874
+ catch {
875
+ return null;
876
+ }
877
+ }
878
+ function isReserved(value) {
879
+ return (value !== null &&
880
+ typeof value === 'object' &&
881
+ !Array.isArray(value) &&
882
+ Object.hasOwn(value, RESULT_RESERVED_KEY));
883
+ }
884
+ function clip(message, max) {
885
+ return truncate(oneLine(message), max);
886
+ }
887
+ function shortId(id) {
888
+ return id.length > 13 ? id.slice(-12) : id;
889
+ }
890
+ function tallyOf(row) {
891
+ return { runs: row.runs ?? 0, failed: row.failed ?? 0, streak: row.fail_streak ?? 0 };
892
+ }