@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,265 @@
1
+ import path from 'node:path';
2
+ import { ApiClient } from "../../core/api.js";
3
+ import { getOrCreateLocalServerToken } from "../../core/credentials.js";
4
+ import { BIN, CliError, LIVE_RETRY_MS, LOCAL_SERVER_TOKEN_FILE, StopError, isReservedWorkerName } from "../../core/domain/index.js";
5
+ import { recordStop, stopHint } from "../../core/host/session.js";
6
+ import { formatLocal } from "../../core/time.js";
7
+ import { builtinResultsSpec, hostChangedWarning, resolveHostIdentity } from "./builtin/host-identity.js";
8
+ import { resultsWorker } from "./builtin/results-worker.js";
9
+ import { startLocalServer } from "./local-server.js";
10
+ import { WorkersModule } from "./module.js";
11
+ import { ResultStore } from "./results/store.js";
12
+ import { publicKeyEnv, workerEnv } from "./runtime/env.js";
13
+ import { Executor } from "./runtime/executor.js";
14
+ import { resyncWorkers, syncWorkers } from "./sync.js";
15
+ import { TaskRunner } from "./task-runner.js";
16
+ import { workerFiles } from "./workers.js";
17
+ export async function startWorkers(opts) {
18
+ const { ctx, session, api, lock, root, workersDir, serve, isPublic } = opts;
19
+ const { out } = ctx;
20
+ const cfg = session.config.values;
21
+ const results = openResults(ctx, session, root);
22
+ let host = null;
23
+ try {
24
+ if (results && hasSyncableFiles(workersDir)) {
25
+ host = await lookupHost(ctx, session, api, opts.token);
26
+ }
27
+ if (results && host)
28
+ results.setHost(host.id);
29
+ }
30
+ catch (err) {
31
+ results?.close();
32
+ throw err;
33
+ }
34
+ const builtin = host ? builtinResultsSpec(host) : null;
35
+ let workers;
36
+ const isBuiltin = (name) => builtin !== null && name === workers?.builtin;
37
+ const readBuiltin = host ? resultsWorker(root, host.id) : null;
38
+ const executor = new Executor({
39
+ workersDir,
40
+ concurrency: cfg.concurrency,
41
+ env: () => workerEnv(root, ctx.env),
42
+ logger: session.logger,
43
+ builtin: (name) => (isBuiltin(name) ? readBuiltin : null)
44
+ });
45
+ const syncOpts = {
46
+ root,
47
+ workersDir,
48
+ isPublic: serve && isPublic,
49
+ intervalMs: cfg.receiveMode === 'live' ? LIVE_RETRY_MS : cfg.pollIntervalMs,
50
+ signal: opts.signal,
51
+ builtin,
52
+ onLive: opts.onLive,
53
+ facts: {
54
+ receiveMode: cfg.receiveMode,
55
+ pollIntervalMs: cfg.pollIntervalMs,
56
+ concurrency: cfg.concurrency,
57
+ cliVersion: ctx.version,
58
+ platform: `${ctx.platform}-${process.arch}`
59
+ }
60
+ };
61
+ let synced;
62
+ try {
63
+ synced = await syncWorkers(ctx, session, api, syncOpts);
64
+ }
65
+ catch (err) {
66
+ results?.close();
67
+ throw err;
68
+ }
69
+ if (!synced) {
70
+ results?.close();
71
+ return null;
72
+ }
73
+ workers = synced.workers;
74
+ const runner = new TaskRunner({
75
+ executor,
76
+ db: session.db,
77
+ logger: session.logger,
78
+ now: ctx.now,
79
+ onStop: (err) => opts.onStop(err),
80
+ onEvent: (line) => out.info(`${out.err.dim(formatLocal(ctx.now()))} ${line}`),
81
+ results,
82
+ isBuiltin,
83
+ reader: () => (workers.builtinActive ? workers.builtin : null),
84
+ repeatReporter: api,
85
+ onSlotFree: () => module.notifySlotFree()
86
+ });
87
+ const module = new WorkersModule({
88
+ api,
89
+ runner,
90
+ logger: session.logger,
91
+ workers,
92
+ onWorkersUnregistered: (names) => {
93
+ for (const name of names) {
94
+ out.warn(`${out.err.dim(formatLocal(ctx.now()))} ${name} could not load and is not registered on the server — ` +
95
+ `fix the file and run '${BIN} reload'.`);
96
+ }
97
+ },
98
+ resync: () => resyncWorkers(ctx, session, api, syncOpts, workers, lock),
99
+ onWorkersDropped: (names) => {
100
+ for (const name of names) {
101
+ out.info(`${out.err.dim(formatLocal(ctx.now()))} ${name} was deleted with '${BIN} worker delete' — it is off for this run.`);
102
+ }
103
+ },
104
+ onWorkersDeleted: (names) => {
105
+ for (const name of names) {
106
+ if (name === workers.builtin) {
107
+ out.warn(`${out.err.dim(formatLocal(ctx.now()))} the built-in results reader ${name} was deleted on the server — ` +
108
+ `remote AIs cannot read saved results until '${BIN} reload'.`);
109
+ continue;
110
+ }
111
+ out.warn(`${out.err.dim(formatLocal(ctx.now()))} ${name} was deleted on the server — it is off for this run. ` +
112
+ `If its file is still there, run '${BIN} reload' to register it again.`);
113
+ }
114
+ }
115
+ });
116
+ let server = null;
117
+ return {
118
+ module,
119
+ quota: synced.quota,
120
+ live: synced.live,
121
+ recover: () => runner.recover(api),
122
+ async serve() {
123
+ if (!serve)
124
+ return;
125
+ server = await startLocalServer({
126
+ port: cfg.localServerPort,
127
+ public: isPublic,
128
+ token: getOrCreateLocalServerToken(ctx.dirs.config),
129
+ workersDir,
130
+ env: () => publicKeyEnv(root, ctx.env),
131
+ version: ctx.version,
132
+ inactive: (name) => inactiveMessage(workers, name),
133
+ onTask: (task, reporter) => void runner.handle(task, reporter, { ack: false }),
134
+ reporterFor: (key) => pushReporter(api, key, cfg.apiUrl, opts.userAgent),
135
+ onRejected: rejectionLogger(session.logger),
136
+ onError: (err) => session.logger.error('local server request failed', {
137
+ message: err?.stack ?? String(err)
138
+ })
139
+ }).catch((err) => {
140
+ throw new CliError(`cannot open the local server: ${err.message}`);
141
+ });
142
+ },
143
+ serverPort: () => server?.port ?? null,
144
+ bannerSubject: () => `workers in ${displayPath(ctx.cwd, workersDir)}`,
145
+ bannerLines: () => bannerLines(ctx, server, isPublic, workers, cfg.receiveMode),
146
+ endMessage: (ended) => {
147
+ const gone = ended.deletedWorkers;
148
+ return ((gone.length > 0
149
+ ? `no workers left to run — ${gone.join(', ')} ${gone.length === 1 ? 'was' : 'were'} deleted on the server. Stopped.\n`
150
+ : `no workers left to run — none of the workers in ${workersDir} is registered (see above). Stopped.\n`) +
151
+ `Run '${BIN} start' again to register the workers in ${workersDir}.`);
152
+ },
153
+ async close() {
154
+ await server?.close();
155
+ results?.close();
156
+ }
157
+ };
158
+ }
159
+ async function lookupHost(ctx, session, api, token) {
160
+ try {
161
+ const { host, error, previousHostId } = await resolveHostIdentity(session.db, api, token, ctx.now());
162
+ if (host && previousHostId) {
163
+ ctx.out.warn(hostChangedWarning(previousHostId, host));
164
+ session.logger.warn('host changed', { previous_host_id: previousHostId, host_id: host.id });
165
+ }
166
+ if (host)
167
+ return host;
168
+ ctx.out.warn(`could not look up this host (${error}) — the built-in results reader is off this run; remote AIs cannot read saved results.`);
169
+ session.logger.warn('host lookup failed; built-in reader off', { message: error });
170
+ return null;
171
+ }
172
+ catch (err) {
173
+ if (err instanceof StopError) {
174
+ recordStop(session, err, ctx.now());
175
+ throw new StopError(err.reason, `${err.message}\n${stopHint(err.reason)}`, err.resetAt);
176
+ }
177
+ throw err;
178
+ }
179
+ }
180
+ function hasSyncableFiles(workersDir) {
181
+ try {
182
+ return [...workerFiles(workersDir).keys()].some((name) => !isReservedWorkerName(name));
183
+ }
184
+ catch {
185
+ return true;
186
+ }
187
+ }
188
+ function openResults(ctx, session, root) {
189
+ try {
190
+ const { store, warnings } = ResultStore.open(root);
191
+ for (const w of warnings) {
192
+ ctx.out.warn(w);
193
+ session.logger.warn(w);
194
+ }
195
+ return store;
196
+ }
197
+ catch (err) {
198
+ const message = err.message;
199
+ ctx.out.warn(`cannot open data.db to save worker results: ${message}. Results are not saved this run, and results over 100 KiB fail.`);
200
+ session.logger.warn('cannot open data.db; results are not saved', { message });
201
+ return null;
202
+ }
203
+ }
204
+ export function rejectionLogger(logger) {
205
+ const last = new Map();
206
+ return (worker, reason) => {
207
+ const now = Date.now();
208
+ const key = `${worker}\n${reason}`;
209
+ if (now - (last.get(key) ?? 0) < 60_000)
210
+ return;
211
+ if (last.size >= 1000)
212
+ last.clear();
213
+ last.set(key, now);
214
+ logger.warn('public request rejected', { worker, reason });
215
+ };
216
+ }
217
+ function bannerLines(ctx, server, isPublic, workers, mode) {
218
+ const lines = [];
219
+ if (server) {
220
+ lines.push(isPublic
221
+ ? `public worker server on port ${server.port} (all interfaces, signed requests only)`
222
+ : `HTTP server on ${server.url} (token in ${path.join(ctx.dirs.config, LOCAL_SERVER_TOKEN_FILE)})`);
223
+ }
224
+ if (workers.pullActiveCount === 0) {
225
+ lines.push(ctx.out.err.yellow(mode === 'live'
226
+ ? 'no local workers are active — nothing will be fetched, but each live connect still uses one call'
227
+ : 'no local workers are active — polls will find nothing to run but still use one call each'));
228
+ }
229
+ return lines;
230
+ }
231
+ function inactiveMessage(workers, name) {
232
+ switch (workers.inactiveReason(name)) {
233
+ case null:
234
+ return null;
235
+ case 'rejected':
236
+ return `worker "${name}" was not registered with 3Min API (see the start output). Fix it and run '${BIN} reload'.`;
237
+ case 'deleted_on_server':
238
+ return `worker "${name}" was deleted on the server. If its file is still there, run '${BIN} reload' to register it again.`;
239
+ case 'not_on_server':
240
+ return `worker "${name}" is not registered for this run. Run '${BIN} reload' to register it.`;
241
+ }
242
+ }
243
+ function pushReporter(main, key, apiUrl, userAgent) {
244
+ const client = new ApiClient({ apiUrl, token: key, userAgent });
245
+ return {
246
+ async report(url, body) {
247
+ if (main.halted)
248
+ throw main.halted;
249
+ try {
250
+ return await client.report(url, body);
251
+ }
252
+ catch (err) {
253
+ if (err instanceof StopError)
254
+ main.halt(err);
255
+ throw err;
256
+ }
257
+ }
258
+ };
259
+ }
260
+ function displayPath(cwd, dir) {
261
+ const rel = path.relative(cwd, dir);
262
+ if (rel === '')
263
+ return '.';
264
+ return rel.startsWith('..') || path.isAbsolute(rel) ? dir : `.${path.sep}${rel}`;
265
+ }
@@ -0,0 +1,241 @@
1
+ import { ApiError } from "../../core/api.js";
2
+ import { BIN, CliError, StopError, SYNC_WORKERS_MAX, WORKER_KEY_ENV, WORKER_SECRET_ENV, isReservedWorkerName } from "../../core/domain/index.js";
3
+ import { rememberQuota, clearRunnerRecords, recordEnd, recordStop, stopHint } from "../../core/host/session.js";
4
+ import { formatCount } from "../../core/text.js";
5
+ import { describeAt, formatInterval, formatLocal, sleep } from "../../core/time.js";
6
+ import { publicKeyEnv, workerEnv } from "./runtime/env.js";
7
+ import { WorkerSet } from "./worker-set.js";
8
+ import { collectWorkers } from "./workers.js";
9
+ export async function syncWorkers(ctx, session, api, opts) {
10
+ const { out } = ctx;
11
+ const collected = await collectForSync(ctx, opts);
12
+ let res;
13
+ while (!opts.signal.aborted) {
14
+ try {
15
+ res = await api.syncWorkers(syncBody(collected, builtinFor(collected, opts), opts.facts));
16
+ break;
17
+ }
18
+ catch (err) {
19
+ if (err instanceof StopError) {
20
+ recordStop(session, err, ctx.now());
21
+ throw new StopError(err.reason, `${err.message}\n${stopHint(err.reason)}`, err.resetAt);
22
+ }
23
+ if (!(err instanceof ApiError))
24
+ throw err;
25
+ if (err.code === 'invalid_request') {
26
+ session.logger.error('the server rejected the worker sync request (a CLI bug)', {
27
+ message: err.message
28
+ });
29
+ throw new CliError(`the server rejected the list of workers: ${err.message}\n` +
30
+ "This is likely a bug in 3minapi. Please report it with the output of '3minapi worker list'.");
31
+ }
32
+ else {
33
+ session.logger.warn('worker sync failed; trying again at the next interval', {
34
+ status: err.status,
35
+ message: err.message
36
+ });
37
+ }
38
+ out.warn(`could not register the workers: ${err.message}\nTrying again in ${formatInterval(opts.intervalMs)}.`);
39
+ }
40
+ await sleep(opts.intervalMs, opts.signal);
41
+ }
42
+ if (!res)
43
+ return null;
44
+ clearRunnerRecords(session);
45
+ const workers = new WorkerSet(localNames(collected), builtinFor(collected, opts)?.name ?? null);
46
+ const rejected = workers.applySync(res, syncContext(ctx, opts), collected.unsent.map((w) => w.name));
47
+ announceSync(ctx, session, res, workers, rejected, collected, 'workers synced');
48
+ if (workers.activeCount === 0) {
49
+ recordEnd(session, 'no_active_workers', [], ctx.now());
50
+ throw new CliError(collected.local.length === 0
51
+ ? `no workers in ${opts.workersDir}. Create one with '${BIN} worker new <name>', then run start again.`
52
+ : `none of the workers in ${opts.workersDir} could be registered (see above). Fix them and run start again.`);
53
+ }
54
+ return { workers, quota: res.quota, live: res.live ?? null };
55
+ }
56
+ export async function resyncWorkers(ctx, session, api, opts, workers, lock) {
57
+ const { out } = ctx;
58
+ const startedAt = Date.now();
59
+ lock.writeReload({ state: 'running', startedAt });
60
+ out.info(`${out.err.dim(formatLocal(ctx.now()))} reloading worker files (3minapi reload)...`);
61
+ let result;
62
+ try {
63
+ const collected = await collectForSync(ctx, opts);
64
+ const res = await api.syncWorkers(syncBody(collected, builtinFor(collected, opts), opts.facts));
65
+ const rejected = workers.resync(localNames(collected), res, syncContext(ctx, opts), collected.unsent.map((w) => w.name), builtinFor(collected, opts)?.name ?? null);
66
+ announceSync(ctx, session, res, workers, rejected, collected, 'workers resynced');
67
+ if (res.live)
68
+ opts.onLive?.(res.live);
69
+ const names = (r) => res.workers.filter((w) => w.result === r).map((w) => w.name);
70
+ result = {
71
+ ok: true,
72
+ created: names('created'),
73
+ updated: names('updated'),
74
+ unchanged: names('unchanged'),
75
+ rejected: [
76
+ ...rejected.map((r) => ({ name: r.name, code: r.code, error: r.error })),
77
+ ...collected.caseClashes.map(({ worker, others }) => ({
78
+ name: worker.name,
79
+ code: 'name_case_conflict',
80
+ error: `differs from ${others.join(', ')} only by letter case`
81
+ })),
82
+ ...collected.reserved.map((w) => ({
83
+ name: w.name,
84
+ code: 'invalid_worker_name',
85
+ error: RESERVED_FILE_MESSAGE
86
+ }))
87
+ ],
88
+ notLoaded: collected.unsent.map((w) => ({
89
+ name: w.name,
90
+ message: w.message ?? 'could not load'
91
+ })),
92
+ active: workers.activeNames(),
93
+ quota: res.quota
94
+ };
95
+ }
96
+ catch (err) {
97
+ if (err instanceof StopError) {
98
+ lock.writeReload({
99
+ state: 'done',
100
+ startedAt,
101
+ finishedAt: Date.now(),
102
+ result: { ok: false, error: err.message, stopped: err.reason }
103
+ });
104
+ throw err;
105
+ }
106
+ const message = err instanceof Error ? err.message : String(err);
107
+ session.logger.warn('worker reload failed; keeping the current workers', { message });
108
+ out.warn(`reload failed: ${message} — keeping the current workers.`);
109
+ result = { ok: false, error: message };
110
+ }
111
+ lock.writeReload({ state: 'done', startedAt, finishedAt: Date.now(), result });
112
+ }
113
+ const RESERVED_FILE_MESSAGE = 'names starting with "_" are reserved for built-in workers';
114
+ async function collectForSync(ctx, opts) {
115
+ const local = await collectWorkers(opts.workersDir, workerEnv(opts.root, ctx.env));
116
+ if (local.length > SYNC_WORKERS_MAX) {
117
+ throw new CliError(`${opts.workersDir} has ${local.length} workers — at most ${SYNC_WORKERS_MAX} can be registered from one host.`);
118
+ }
119
+ const reserved = local.filter((w) => isReservedWorkerName(w.name));
120
+ const { sent, caseClashes } = withoutCaseClashes(local.filter((w) => (w.problem === null || w.problem === 'invalid_name') && !isReservedWorkerName(w.name)));
121
+ const unsent = local.filter((w) => w.problem === 'load_failed' || w.problem === 'duplicate');
122
+ if (opts.builtin && sent.length >= SYNC_WORKERS_MAX) {
123
+ ctx.out.warn(`${opts.workersDir} has ${sent.length} workers to register — the most one host can register — so the built-in results reader ` +
124
+ 'is left out this run; remote AIs cannot read saved results.');
125
+ }
126
+ return { local, sent, unsent, caseClashes, reserved };
127
+ }
128
+ function localNames(collected) {
129
+ return collected.local.filter((w) => !isReservedWorkerName(w.name)).map((w) => w.name);
130
+ }
131
+ function builtinFor(collected, opts) {
132
+ return collected.sent.length > 0 && collected.sent.length < SYNC_WORKERS_MAX
133
+ ? opts.builtin
134
+ : null;
135
+ }
136
+ function syncBody(collected, builtin, facts) {
137
+ const workers = collected.sent.map((w) => ({
138
+ name: w.name,
139
+ description: w.meta?.description ?? null
140
+ }));
141
+ if (builtin)
142
+ workers.push({ name: builtin.name, description: builtin.description });
143
+ return {
144
+ workers,
145
+ receive_mode: facts.receiveMode,
146
+ poll_interval_ms: facts.receiveMode === 'live' ? null : facts.pollIntervalMs,
147
+ concurrency: facts.concurrency,
148
+ cli_version: facts.cliVersion,
149
+ platform: facts.platform
150
+ };
151
+ }
152
+ function syncContext(ctx, opts) {
153
+ const publicKeys = opts.isPublic ? publicKeyEnv(opts.root, ctx.env) : {};
154
+ return {
155
+ public: opts.isPublic,
156
+ hasPushKeys: (name) => Boolean(publicKeys[WORKER_SECRET_ENV(name)] && publicKeys[WORKER_KEY_ENV(name)])
157
+ };
158
+ }
159
+ function announceSync(ctx, session, res, workers, rejected, collected, event) {
160
+ const { out } = ctx;
161
+ rememberQuota(session, res.quota, ctx.now());
162
+ session.logger.info(event, {
163
+ workers: res.workers,
164
+ rejected: rejected.map((r) => ({ name: r.name, code: r.code })),
165
+ push_served: workers.pushServedNames(),
166
+ not_sent: collected.local.filter((w) => !collected.sent.includes(w)).map((w) => w.name),
167
+ local: collected.local.map((w) => ({
168
+ name: w.name,
169
+ description: w.meta?.description ?? null,
170
+ timeout_ms: w.meta?.timeoutMs ?? null,
171
+ input_schema: w.meta?.hasInputSchema ?? false,
172
+ problem: w.problem,
173
+ message: w.message
174
+ }))
175
+ });
176
+ for (const r of rejected) {
177
+ if (r.name === workers.builtin) {
178
+ out.warn(`the built-in results reader ${r.name} was not registered: ${r.error} Remote AIs cannot read saved results this run.`);
179
+ continue;
180
+ }
181
+ out.warn(`${r.error} ${r.name} is off for this run — fix it and run '${BIN} reload'.`);
182
+ }
183
+ for (const w of collected.reserved) {
184
+ out.warn(`${w.name}: ${RESERVED_FILE_MESSAGE}, so it is not registered — rename the file and run '${BIN} reload'.`);
185
+ }
186
+ for (const { worker, others } of collected.caseClashes) {
187
+ out.warn(`${worker.name}: differs from ${others.join(', ')} only by letter case. Worker names ignore case, ` +
188
+ `so it is not registered — rename the file and run '${BIN} reload'.`);
189
+ }
190
+ for (const w of collected.unsent) {
191
+ out.warn(`${w.name}: ${w.message}. It was left out of the registration; if an earlier run registered it, ` +
192
+ 'its tasks fail until the file loads.');
193
+ }
194
+ const served = workers.pushServedNames();
195
+ if (served.length > 0) {
196
+ out.info(`public ${served.join(', ')} — served as public workers on this machine (--public)`);
197
+ }
198
+ out.info(workersLine(ctx, res, workers));
199
+ }
200
+ function withoutCaseClashes(workers) {
201
+ const byKey = new Map();
202
+ for (const w of workers) {
203
+ const key = w.name.toLowerCase();
204
+ byKey.set(key, [...(byKey.get(key) ?? []), w]);
205
+ }
206
+ const sent = [];
207
+ const caseClashes = [];
208
+ for (const group of byKey.values()) {
209
+ if (group.length === 1) {
210
+ sent.push(group[0]);
211
+ continue;
212
+ }
213
+ for (const w of group) {
214
+ if (w.problem === null)
215
+ sent.push(w);
216
+ else
217
+ caseClashes.push({
218
+ worker: w,
219
+ others: group.filter((o) => o !== w).map((o) => o.name)
220
+ });
221
+ }
222
+ }
223
+ return { sent, caseClashes };
224
+ }
225
+ function workersLine(ctx, res, workers) {
226
+ const active = workers.activeNames();
227
+ const count = (result) => res.workers.filter((w) => w.result === result).length;
228
+ const changes = [
229
+ count('created') > 0 ? `${count('created')} new` : null,
230
+ count('updated') > 0 ? `${count('updated')} updated` : null
231
+ ].filter(Boolean);
232
+ const { remaining, limit, reset_at } = res.quota;
233
+ return [
234
+ `workers ${active.length} active (${active.join(', ') || 'none'})` +
235
+ (changes.length > 0 ? ctx.out.err.dim(` · registered: ${changes.join(', ')}`) : ''),
236
+ ...(workers.builtinActive
237
+ ? [`reader ${workers.builtin} — remote AIs can query the saved results through it`]
238
+ : []),
239
+ `quota ${formatCount(remaining)} of ${formatCount(limit)} calls left · resets ${describeAt(reset_at, ctx.now())}`
240
+ ].join('\n');
241
+ }