ainize 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +225 -0
- package/dist/bin.d.ts +10 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +711 -0
- package/dist/bin.js.map +1 -0
- package/dist/client.d.ts +38 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +164 -0
- package/dist/client.js.map +1 -0
- package/dist/commands/auth.d.ts +30 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +216 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/branch.d.ts +326 -0
- package/dist/commands/branch.d.ts.map +1 -0
- package/dist/commands/branch.js +467 -0
- package/dist/commands/branch.js.map +1 -0
- package/dist/commands/chain.d.ts +43 -0
- package/dist/commands/chain.d.ts.map +1 -0
- package/dist/commands/chain.js +224 -0
- package/dist/commands/chain.js.map +1 -0
- package/dist/commands/chat.d.ts +157 -0
- package/dist/commands/chat.d.ts.map +1 -0
- package/dist/commands/chat.js +395 -0
- package/dist/commands/chat.js.map +1 -0
- package/dist/commands/dataset.d.ts +51 -0
- package/dist/commands/dataset.d.ts.map +1 -0
- package/dist/commands/dataset.js +157 -0
- package/dist/commands/dataset.js.map +1 -0
- package/dist/commands/drive.d.ts +32 -0
- package/dist/commands/drive.d.ts.map +1 -0
- package/dist/commands/drive.js +68 -0
- package/dist/commands/drive.js.map +1 -0
- package/dist/commands/huggingface-dataset.d.ts +63 -0
- package/dist/commands/huggingface-dataset.d.ts.map +1 -0
- package/dist/commands/huggingface-dataset.js +254 -0
- package/dist/commands/huggingface-dataset.js.map +1 -0
- package/dist/commands/init.d.ts +107 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +538 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/ledger.d.ts +43 -0
- package/dist/commands/ledger.d.ts.map +1 -0
- package/dist/commands/ledger.js +125 -0
- package/dist/commands/ledger.js.map +1 -0
- package/dist/commands/node.d.ts +250 -0
- package/dist/commands/node.d.ts.map +1 -0
- package/dist/commands/node.js +651 -0
- package/dist/commands/node.js.map +1 -0
- package/dist/commands/patch.d.ts +852 -0
- package/dist/commands/patch.d.ts.map +1 -0
- package/dist/commands/patch.js +1527 -0
- package/dist/commands/patch.js.map +1 -0
- package/dist/commands/peers.d.ts +86 -0
- package/dist/commands/peers.d.ts.map +1 -0
- package/dist/commands/peers.js +143 -0
- package/dist/commands/peers.js.map +1 -0
- package/dist/commands/teach-dataset.d.ts +381 -0
- package/dist/commands/teach-dataset.d.ts.map +1 -0
- package/dist/commands/teach-dataset.js +780 -0
- package/dist/commands/teach-dataset.js.map +1 -0
- package/dist/commands/teach.d.ts +351 -0
- package/dist/commands/teach.d.ts.map +1 -0
- package/dist/commands/teach.js +435 -0
- package/dist/commands/teach.js.map +1 -0
- package/dist/context.d.ts +55 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +102 -0
- package/dist/context.js.map +1 -0
- package/dist/ens.d.ts +50 -0
- package/dist/ens.d.ts.map +1 -0
- package/dist/ens.js +155 -0
- package/dist/ens.js.map +1 -0
- package/dist/help.d.ts +57 -0
- package/dist/help.d.ts.map +1 -0
- package/dist/help.js +227 -0
- package/dist/help.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/output.d.ts +104 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +383 -0
- package/dist/output.js.map +1 -0
- package/dist/pid.d.ts +5 -0
- package/dist/pid.d.ts.map +1 -0
- package/dist/pid.js +25 -0
- package/dist/pid.js.map +1 -0
- package/dist/quiet.d.ts +2 -0
- package/dist/quiet.d.ts.map +1 -0
- package/dist/quiet.js +12 -0
- package/dist/quiet.js.map +1 -0
- package/package.json +55 -0
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ainize start|stop|status|logs|seed` — node lifecycle.
|
|
3
|
+
*/
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { applyEnv, humanBytes } from '@ainize/core';
|
|
10
|
+
/**
|
|
11
|
+
* `@ainize/node` is the server, and it is an OPTIONAL peer of this package (see package.json).
|
|
12
|
+
*
|
|
13
|
+
* Every other command here talks to a node over HTTP and works against somebody else's machine — listing
|
|
14
|
+
* knowledge, buying, teaching, chatting. Only `start` and `seed` run a node in this process, and they are the
|
|
15
|
+
* only reason a terminal would ever need express, sqlite, better-sqlite3 and the trainer on disk. Loading it
|
|
16
|
+
* here, at the moment those two are called, keeps `npm i -g ainize` small and keeps this package
|
|
17
|
+
* installable where the server cannot even build.
|
|
18
|
+
*/
|
|
19
|
+
async function server() {
|
|
20
|
+
try {
|
|
21
|
+
return await import('@ainize/node');
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new CliError(`running a node in this process needs the server package: \`npm i @ainize/node\`\n(every other ${PROG} command works against a remote node and does not.)`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
import { NodeClient, query } from '../client.js';
|
|
28
|
+
import { CliError, PROG } from '../context.js';
|
|
29
|
+
import { logFile, pidFile, runningPid } from '../pid.js';
|
|
30
|
+
import { c, emit, fmtTime, info, kv, ok, shortAddr, table, warn } from '../output.js';
|
|
31
|
+
import { blockedLines, peerColumns, ledgerMismatchLines } from './peers.js';
|
|
32
|
+
import { assertUsableConfig, requireConfig } from './init.js';
|
|
33
|
+
import { promptLine } from './auth.js';
|
|
34
|
+
export { runningPid };
|
|
35
|
+
function binPath() {
|
|
36
|
+
// dist/commands/node.js → dist/bin.js (or src/commands/node.ts → src/bin.ts under tsx)
|
|
37
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
const js = resolve(here, '..', 'bin.js');
|
|
39
|
+
return existsSync(js) ? js : resolve(here, '..', 'bin.ts');
|
|
40
|
+
}
|
|
41
|
+
function applyArgs(cfg, a) {
|
|
42
|
+
if (a.port)
|
|
43
|
+
cfg.port = a.port;
|
|
44
|
+
if (a.peer?.length)
|
|
45
|
+
cfg.peers = [...new Set([...cfg.peers, ...a.peer])];
|
|
46
|
+
if (a.roles)
|
|
47
|
+
cfg.roles = a.roles.split(',').map((s) => s.trim()).filter(Boolean);
|
|
48
|
+
if (a.publicUrl)
|
|
49
|
+
cfg.publicUrl = a.publicUrl;
|
|
50
|
+
return cfg;
|
|
51
|
+
}
|
|
52
|
+
/** How long `start -d` waits for the child to answer /api/info before it reports the failure in node.log. */
|
|
53
|
+
const startTimeoutMs = () => Number(process.env.AINIZE_START_TIMEOUT_MS ?? 20_000);
|
|
54
|
+
/** How long `stop` waits for a SIGTERMed node to exit before escalating to SIGKILL. */
|
|
55
|
+
const stopGraceMs = () => Number(process.env.AINIZE_STOP_GRACE_MS ?? 10_000);
|
|
56
|
+
/** Above this, `start -d` rolls node.log aside before appending (item 128: nothing ever rotated it). */
|
|
57
|
+
export const LOG_MAX_BYTES = Number(process.env.AINIZE_LOG_MAX_BYTES ?? 32 * 1000 ** 2);
|
|
58
|
+
/** How many rolled generations are kept (node.log.1 … node.log.N). */
|
|
59
|
+
export const LOG_GENERATIONS = 2;
|
|
60
|
+
/**
|
|
61
|
+
* Roll node.log when it has grown past `LOG_MAX_BYTES`, keeping `LOG_GENERATIONS` older copies (item 128).
|
|
62
|
+
* `start -d` opened the file with 'a' and nothing ever truncated it, so a long-lived node's only crash log was
|
|
63
|
+
* also an unbounded consumer of the same volume as the blob store.
|
|
64
|
+
*/
|
|
65
|
+
export function rotateLog(home) {
|
|
66
|
+
const file = logFile(home);
|
|
67
|
+
let size = 0;
|
|
68
|
+
try {
|
|
69
|
+
size = statSync(file).size;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (size < LOG_MAX_BYTES)
|
|
75
|
+
return false;
|
|
76
|
+
try {
|
|
77
|
+
unlinkSync(`${file}.${LOG_GENERATIONS}`);
|
|
78
|
+
}
|
|
79
|
+
catch { /* there may be none */ }
|
|
80
|
+
for (let i = LOG_GENERATIONS - 1; i >= 1; i--) {
|
|
81
|
+
try {
|
|
82
|
+
renameSync(`${file}.${i}`, `${file}.${i + 1}`);
|
|
83
|
+
}
|
|
84
|
+
catch { /* there may be none */ }
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
renameSync(file, `${file}.1`);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** Last `n` lines of the node log — the only place a failed start ever wrote its reason. */
|
|
95
|
+
export function logTail(home, n = 20) {
|
|
96
|
+
try {
|
|
97
|
+
const lines = readFileSync(logFile(home), 'utf8').replace(/\n$/, '').split('\n');
|
|
98
|
+
return lines.slice(-n).join('\n');
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return '';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** `start -d` could not confirm the node: drop the pid file nothing owns and show what node.log says. */
|
|
105
|
+
function startFailed(ctx, why) {
|
|
106
|
+
try {
|
|
107
|
+
unlinkSync(pidFile(ctx.home));
|
|
108
|
+
}
|
|
109
|
+
catch { /* ignore */ }
|
|
110
|
+
const tail = logTail(ctx.home, 20);
|
|
111
|
+
throw new CliError(`${why} — it is not running.\n${c.dim(`${logFile(ctx.home)} (last ${tail ? tail.split('\n').length : 0} lines):`)}\n${tail || c.dim('(the log is empty)')}`);
|
|
112
|
+
}
|
|
113
|
+
/** Start the node in-process (returns when it is listening; caller keeps the event loop alive) or detached. */
|
|
114
|
+
export async function start(ctx, a = {}) {
|
|
115
|
+
const cfg = assertUsableConfig(applyArgs(applyEnv(requireConfig(ctx)), a), ctx.home);
|
|
116
|
+
const existing = runningPid(ctx.home);
|
|
117
|
+
// (the child `start -d` spawns finds its own pid in node.pid — the parent wrote it before the child ran)
|
|
118
|
+
if (existing && existing !== process.pid)
|
|
119
|
+
throw new CliError(`node already running in the background (pid ${existing}) — \`${PROG} stop\` first`);
|
|
120
|
+
// Whoever already answers the port is not the child about to be spawned. When it is this home's own node —
|
|
121
|
+
// started in the foreground or by a supervisor, so there is no pid file — the start-up probe below would have
|
|
122
|
+
// matched the identity and printed the tick for a child that died on EADDRINUSE a moment later (item 118).
|
|
123
|
+
const probe = new NodeClient({ ...ctx, nodeUrl: `http://localhost:${cfg.port}`, token: null });
|
|
124
|
+
const holder = await probe.get('/api/info', { auth: false, timeoutMs: 2000 }).catch(() => null);
|
|
125
|
+
if (holder) {
|
|
126
|
+
throw new CliError(holder.node.address.toLowerCase() === cfg.identity.address.toLowerCase()
|
|
127
|
+
? `this node is already serving on http://localhost:${cfg.port} — it was not started by \`${PROG} start -d\` (foreground, or a supervisor), so stop it where it was started before starting it here`
|
|
128
|
+
: `port ${cfg.port} is already answered by "${holder.node.name}" (${shortAddr(holder.node.address, 8)}), not the node in ${ctx.home} — stop that node, or move this one: \`${PROG} config set port <1-65535>\``);
|
|
129
|
+
}
|
|
130
|
+
if (a.detach) {
|
|
131
|
+
mkdirSync(ctx.home, { recursive: true });
|
|
132
|
+
rotateLog(ctx.home);
|
|
133
|
+
const out = openSync(logFile(ctx.home), 'a');
|
|
134
|
+
const args = [...process.execArgv, binPath(), 'start', '--home', ctx.home];
|
|
135
|
+
if (a.port)
|
|
136
|
+
args.push('--port', String(a.port));
|
|
137
|
+
for (const p of a.peer ?? [])
|
|
138
|
+
args.push('--peer', p);
|
|
139
|
+
if (a.roles)
|
|
140
|
+
args.push('--roles', a.roles);
|
|
141
|
+
if (a.publicUrl)
|
|
142
|
+
args.push('--public-url', a.publicUrl);
|
|
143
|
+
const child = spawn(process.execPath, args, { detached: true, stdio: ['ignore', out, out], env: { ...process.env, AINIZE_HOME: ctx.home } });
|
|
144
|
+
const exited = [];
|
|
145
|
+
child.once('exit', (code, signal) => { exited.push({ code, signal }); });
|
|
146
|
+
writeFileSync(pidFile(ctx.home), String(child.pid));
|
|
147
|
+
// Wait for the child to actually answer before claiming it started: a port already in use, a config the node
|
|
148
|
+
// refuses, an unreachable chain — all of those used to print a green tick and a pid that was dead a
|
|
149
|
+
// millisecond later, with the reason in node.log and nothing on screen (item 118).
|
|
150
|
+
const timeoutMs = startTimeoutMs();
|
|
151
|
+
const deadline = Date.now() + timeoutMs;
|
|
152
|
+
for (;;) {
|
|
153
|
+
const exit = exited[0];
|
|
154
|
+
if (exit) {
|
|
155
|
+
child.unref();
|
|
156
|
+
startFailed(ctx, `node exited while starting (${exit.signal ? `signal ${exit.signal}` : `exit code ${exit.code}`})`);
|
|
157
|
+
}
|
|
158
|
+
const info = await probe.get('/api/info', { auth: false, timeoutMs: 2000 }).catch(() => null);
|
|
159
|
+
if (info && info.node.address.toLowerCase() === cfg.identity.address.toLowerCase())
|
|
160
|
+
break;
|
|
161
|
+
if (Date.now() > deadline) {
|
|
162
|
+
child.unref();
|
|
163
|
+
startFailed(ctx, info
|
|
164
|
+
? `port ${cfg.port} is answered by "${info.node.name}" (${shortAddr(info.node.address, 8)}), not by the node in ${ctx.home}`
|
|
165
|
+
: `node did not answer on http://localhost:${cfg.port} within ${timeoutMs / 1000} s`);
|
|
166
|
+
}
|
|
167
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
168
|
+
}
|
|
169
|
+
child.unref();
|
|
170
|
+
ok(ctx, `node started in the background (pid ${child.pid}) — port ${cfg.port}\n ${c.dim(`logs: ${logFile(ctx.home)} stop: ${PROG} stop`)}`);
|
|
171
|
+
return { detached: true, pid: child.pid, log: logFile(ctx.home) };
|
|
172
|
+
}
|
|
173
|
+
const { startNode } = await server();
|
|
174
|
+
const node = await startNode(cfg, { home: ctx.home, quiet: ctx.quiet });
|
|
175
|
+
writeFileSync(pidFile(ctx.home), String(process.pid));
|
|
176
|
+
const cleanup = async () => { try {
|
|
177
|
+
unlinkSync(pidFile(ctx.home));
|
|
178
|
+
}
|
|
179
|
+
catch { /* ignore */ } await node.stop(); process.exit(0); };
|
|
180
|
+
process.once('SIGINT', cleanup);
|
|
181
|
+
process.once('SIGTERM', cleanup);
|
|
182
|
+
return node;
|
|
183
|
+
}
|
|
184
|
+
/** Poll until the process is gone; false when it is still alive after `ms`. */
|
|
185
|
+
async function waitGone(pid, ms) {
|
|
186
|
+
const until = Date.now() + ms;
|
|
187
|
+
for (;;) {
|
|
188
|
+
try {
|
|
189
|
+
process.kill(pid, 0);
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
if (Date.now() > until)
|
|
195
|
+
return false;
|
|
196
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
export async function stop(ctx) {
|
|
200
|
+
const pid = runningPid(ctx.home);
|
|
201
|
+
if (!pid) {
|
|
202
|
+
try {
|
|
203
|
+
unlinkSync(pidFile(ctx.home));
|
|
204
|
+
}
|
|
205
|
+
catch { /* ignore */ }
|
|
206
|
+
info(ctx, c.dim('no background node running for this AINIZE_HOME'));
|
|
207
|
+
// …but something may still be serving this home's node (started in the foreground, or by a supervisor):
|
|
208
|
+
// saying nothing at all is how an operator ends up with a node no command of theirs can manage (item 119).
|
|
209
|
+
const cfg = ctx.cfg;
|
|
210
|
+
if (cfg) {
|
|
211
|
+
const probe = new NodeClient({ ...ctx, nodeUrl: `http://localhost:${cfg.port}`, token: null });
|
|
212
|
+
const d = await probe.get('/api/info', { auth: false, timeoutMs: 1500 }).catch(() => null);
|
|
213
|
+
if (d && d.node.address.toLowerCase() === cfg.identity.address.toLowerCase()) {
|
|
214
|
+
warn(ctx, `this node is still serving on http://localhost:${cfg.port} — it was not started by \`${PROG} start -d\` (foreground, or a supervisor), so stop it where it was started`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { stopped: false, pid: null };
|
|
218
|
+
}
|
|
219
|
+
process.kill(pid, 'SIGTERM');
|
|
220
|
+
// The wait loop's outcome was never checked: `stop` printed the tick and deleted the pid file even when the
|
|
221
|
+
// process was still there, leaving a node nothing could manage afterwards (item 119).
|
|
222
|
+
let killed = false;
|
|
223
|
+
const grace = stopGraceMs();
|
|
224
|
+
if (!(await waitGone(pid, grace))) {
|
|
225
|
+
warn(ctx, `node ${pid} is still running ${grace / 1000} s after SIGTERM — sending SIGKILL`);
|
|
226
|
+
try {
|
|
227
|
+
process.kill(pid, 'SIGKILL');
|
|
228
|
+
}
|
|
229
|
+
catch { /* it exited in between */ }
|
|
230
|
+
killed = true;
|
|
231
|
+
if (!(await waitGone(pid, 5000))) {
|
|
232
|
+
throw new CliError(`node ${pid} did not stop (still running after SIGTERM and SIGKILL) — kill it manually (\`kill -9 ${pid}\`); ${pidFile(ctx.home)} kept so \`${PROG} stop\` can try again`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
unlinkSync(pidFile(ctx.home));
|
|
237
|
+
}
|
|
238
|
+
catch { /* ignore */ }
|
|
239
|
+
ok(ctx, `stopped node (pid ${pid})${killed ? c.dim(' — it ignored SIGTERM, so it was killed') : ''}`);
|
|
240
|
+
return { stopped: true, pid, killed };
|
|
241
|
+
}
|
|
242
|
+
/** `1.1 GB (bodies 932 MB · sets 0 B · uploads 115 MB · db 9 MB) · 12 GB free` — item 128. */
|
|
243
|
+
export function diskLine(d, showReclaimable = true) {
|
|
244
|
+
if (!d)
|
|
245
|
+
return c.dim('not reported by this node (older build)');
|
|
246
|
+
const parts = `bodies ${humanBytes(d.blobs)} · sets ${humanBytes(d.datasets)} · uploads ${humanBytes(d.uploads)} · db ${humanBytes(d.db)}${d.log ? ` · log ${humanBytes(d.log)}` : ''}`;
|
|
247
|
+
const tight = d.free !== null && d.size !== null && (d.free < 2 * 1000 ** 3 || d.free / d.size < 0.05);
|
|
248
|
+
const free = d.free === null ? '' : `${c.dim(' · ')}${tight ? c.warn(`${humanBytes(d.free)} free`) : `${humanBytes(d.free)} free`}`;
|
|
249
|
+
const reclaim = showReclaimable && d.reclaimable_bytes > 0
|
|
250
|
+
? c.dim(`\n${humanBytes(d.reclaimable_bytes)} in ${d.reclaimable_files} verification cop${d.reclaimable_files === 1 ? 'y' : 'ies'} — \`${PROG} gc\` frees it`) : '';
|
|
251
|
+
return `${humanBytes(d.total)} ${c.dim(`(${parts})`)}${free}${reclaim}`;
|
|
252
|
+
}
|
|
253
|
+
/** `2 published here, 1 bought, 1 fetched too recently` — why the bodies that stayed, stayed. */
|
|
254
|
+
const keptLine = (kept) => {
|
|
255
|
+
const label = {
|
|
256
|
+
authored: 'published by this node', purchased: 'bought', applied: 'loaded in the model', draft: 'unpublished drafts',
|
|
257
|
+
unlisted: 'not on the record', too_new: 'fetched too recently', sole_copy: 'the only copy left',
|
|
258
|
+
};
|
|
259
|
+
const parts = Object.entries(kept).filter(([, n]) => n > 0).map(([k, n]) => `${n} ${label[k] ?? k.replace(/_/g, ' ')}`);
|
|
260
|
+
return parts.length ? c.dim(`kept: ${parts.join(', ')}`) : '';
|
|
261
|
+
};
|
|
262
|
+
/** What build is actually running — with the config's own version only when it differs (item 141). */
|
|
263
|
+
export function nodeVersion(n) {
|
|
264
|
+
const built = n.build ? ` · built ${fmtTime(Date.parse(n.build))}` : '';
|
|
265
|
+
const written = n.config_version && n.config_version !== n.version ? c.dim(` (config.json written by ${n.config_version})`) : '';
|
|
266
|
+
return `${n.version}${built}${written}`;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* `status --check`: the node's own readiness, for a monitor or a deploy script. Exit 1 when a check fails —
|
|
270
|
+
* `/api/info` answers 200 whether the model server is up or down, so it could never be a health check (item 134).
|
|
271
|
+
*/
|
|
272
|
+
export async function statusCheck(ctx) {
|
|
273
|
+
const res = await new NodeClient(ctx).get('/readyz', { auth: false, raw: true });
|
|
274
|
+
if (res.status === 404)
|
|
275
|
+
throw new CliError(`${ctx.nodeUrl} has no /readyz — it is running a build older than this CLI`, 2);
|
|
276
|
+
const d = (await res.json());
|
|
277
|
+
emit(ctx, d, (x) => [
|
|
278
|
+
(x.ok ? c.ok('✓ ') : c.err('✗ ')) + c.bold(x.node) + c.dim(` ${ctx.nodeUrl}`) + (x.ok ? '' : c.err(' NOT READY')),
|
|
279
|
+
kv([
|
|
280
|
+
['ledger', x.checks.ledger.ok ? c.ok(`ok · ${x.checks.ledger.kind}${x.checks.ledger.height === null ? '' : ` · height ${x.checks.ledger.height}`}`) : c.err(x.checks.ledger.error ?? 'unreachable')],
|
|
281
|
+
['runtime', !x.checks.runtime.required ? c.dim('not required by this node\'s roles')
|
|
282
|
+
: x.checks.runtime.ok ? c.ok(`ok · ${x.checks.runtime.model}`) : c.err(x.checks.runtime.error ?? 'unavailable')],
|
|
283
|
+
['peers', `${x.checks.peers.configured} configured${x.checks.peers.unreachable ? c.warn(` · ${x.checks.peers.unreachable} unreachable`) : ''}`],
|
|
284
|
+
]),
|
|
285
|
+
].join('\n'));
|
|
286
|
+
if (!d.ok)
|
|
287
|
+
process.exitCode = 1;
|
|
288
|
+
return d;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* `available · Qwen3.8-Flash-Next · hook ok · held by pid:1859000 (runtime) for 4m · 2 waiting` (item 135).
|
|
292
|
+
*
|
|
293
|
+
* Several nodes on one host serialise on a cross-process lock in the shared patch directory, with a 20-minute wait
|
|
294
|
+
* before a caller gives up. The holder was reported only to visitors through the chat endpoints, so the commonest
|
|
295
|
+
* cause of "why is nothing happening" on a multi-node host was invisible on every operator surface.
|
|
296
|
+
*/
|
|
297
|
+
export function runtimeLine(r) {
|
|
298
|
+
const head = r.available ? c.ok(`available · ${r.model} · hook ok`) : c.warn(`unavailable${r.error ? ` (${r.error})` : ''}`);
|
|
299
|
+
const q = r.queue;
|
|
300
|
+
if (!q)
|
|
301
|
+
return head;
|
|
302
|
+
const parts = [];
|
|
303
|
+
if (q.lock && !q.lock.mine)
|
|
304
|
+
parts.push(c.warn(`held by ${q.lock.owner} (${q.lock.label}) for ${relAge(Date.now() - q.lock.since)}${q.lock.stale ? ', stale' : !q.lock.alive ? ', holder is gone' : ''}`));
|
|
305
|
+
else if (q.lock?.mine && q.running)
|
|
306
|
+
parts.push(`this node is running ${q.running.label} (${relAge(Date.now() - q.running.since)})`);
|
|
307
|
+
if (q.waiting)
|
|
308
|
+
parts.push(`${q.waiting} waiting`);
|
|
309
|
+
return head + (parts.length ? c.dim(' · ') + parts.join(c.dim(' · ')) : '');
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* `09-01 → 09-02 · on the table (checked 12 s ago)` (item 215). `applied` is a row in this node's database; the only
|
|
313
|
+
* thing that knows whether the rows are physically there is the watchdog's `patch.py status`, every 20 s, on the top
|
|
314
|
+
* of the stack. Through a rollback and through every verification restore the flag said yes and the model answered no.
|
|
315
|
+
*/
|
|
316
|
+
export function appliedLine(r) {
|
|
317
|
+
const ids = r.applied ?? [];
|
|
318
|
+
const chk = r.checked;
|
|
319
|
+
const top = ids[ids.length - 1];
|
|
320
|
+
if (!chk || chk.patch_id !== top)
|
|
321
|
+
return ids.join(' → ') + c.dim(' (not measured on the live table yet)');
|
|
322
|
+
const age = relAge(Date.now() - chk.at);
|
|
323
|
+
return ids.join(' → ') + (chk.present
|
|
324
|
+
? c.dim(' · ') + c.ok(`on the table (${top} checked ${age} ago)`)
|
|
325
|
+
: c.dim(' · ') + c.err(`${top} is NOT on the table — its rows were overwritten (checked ${age} ago); the watchdog re-applies the stack`));
|
|
326
|
+
}
|
|
327
|
+
/** `3 known · 3 answered · 2 verifiers` — the peer count alone said nothing about whether anyone was there (item 170). */
|
|
328
|
+
export function peersLine(st, fallback) {
|
|
329
|
+
if (!st)
|
|
330
|
+
return String(fallback);
|
|
331
|
+
const parts = [`${st.known} known`, st.reachable === st.known ? c.ok(`${st.reachable} answered`) : c.warn(`${st.reachable} answered`)];
|
|
332
|
+
parts.push(st.verifiers ? `${st.verifiers} verifier${st.verifiers === 1 ? '' : 's'}` : c.warn('0 verifiers'));
|
|
333
|
+
if (st.ledger_mismatch)
|
|
334
|
+
parts.push(c.warn(`${st.ledger_mismatch} on another ledger`));
|
|
335
|
+
return parts.join(c.dim(' · '));
|
|
336
|
+
}
|
|
337
|
+
export async function status(ctx) {
|
|
338
|
+
const client = new NodeClient(ctx);
|
|
339
|
+
// The token is sent when there is one (item 142: `balance` is operator-only); `status` still works without it.
|
|
340
|
+
const d = await client.get('/api/info', { auth: !!ctx.token });
|
|
341
|
+
const pid = runningPid(ctx.home);
|
|
342
|
+
/*
|
|
343
|
+
* Item 321 — money this node owes creators and could not send had no line on the one screen an operator looks at.
|
|
344
|
+
* The rows are operator-only, so this is asked for only when there is a token, and a node with no wallet or no
|
|
345
|
+
* debt adds nothing to the output.
|
|
346
|
+
*/
|
|
347
|
+
const payouts = ctx.token
|
|
348
|
+
? await client.get('/api/me/payouts?limit=1').then((r) => r.summary).catch(() => null)
|
|
349
|
+
: null;
|
|
350
|
+
// Whatever answers the configured port is not necessarily this home's node: after a failed start it is usually
|
|
351
|
+
// someone else's, and the whole block below — address, roles, ledger height, catalogue — would be theirs (item 118).
|
|
352
|
+
const mine = ctx.cfg?.identity.address;
|
|
353
|
+
const stranger = !!mine && d.node.address.toLowerCase() !== mine.toLowerCase();
|
|
354
|
+
if (stranger) {
|
|
355
|
+
warn(ctx, `${ctx.nodeUrl} is answered by "${d.node.name}" (${shortAddr(d.node.address, 8)}), not the node in ${ctx.home} (${shortAddr(mine, 8)}) — that node is not running.\n ${c.dim('everything below belongs to that other node.')}`);
|
|
356
|
+
}
|
|
357
|
+
emit(ctx, { ...d, pid, is_this_home: !stranger }, (x) => [
|
|
358
|
+
c.bold(`${x.node.name}`) + c.dim(` ${x.node.endpoint}${pid ? ` (pid ${pid})` : ''}`),
|
|
359
|
+
kv([
|
|
360
|
+
['address', x.node.address], ['roles', x.node.roles.join(', ')], ['version', nodeVersion(x.node)],
|
|
361
|
+
['ledger', `${x.ledger.kind} · ${x.ledger.network}${x.ledger.provider ? ` · ${x.ledger.provider}` : ''} · ${x.ledger.records} records${x.ledger.height !== undefined ? ` · height ${x.ledger.height}` : ''}`],
|
|
362
|
+
['runtime', runtimeLine(x.runtime)],
|
|
363
|
+
// item 144: which mailbox this node writes .npz files into — `runtime.api` (which model to talk to) and
|
|
364
|
+
// `runtime.patchDir` (which table to mutate) are independent, and nothing named the second anywhere.
|
|
365
|
+
...(x.runtime.patch_dir ? [['mailbox', x.runtime.patch_dir + (x.runtime.patch_dir_source === 'repo' ? c.dim(' (from runtime.repo — set runtime.patchDir to be sure it is this instance\'s)') : '')]] : []),
|
|
366
|
+
...(x.runtime.applied?.length ? [['in model', appliedLine(x.runtime)]] : []),
|
|
367
|
+
['peers', peersLine(x.peer_status, x.peers)], ['patches', `${x.counts.patches} (${x.counts.listed} listed)`], ['quorum', x.quorum], ['currency', x.currency],
|
|
368
|
+
// item 142: this node signs and PAYS for every announce, attest and settle; nothing but `ainize wallet` said so.
|
|
369
|
+
...(typeof x.balance === 'number' ? [['balance', `${x.balance} ${x.currency}${x.balance <= 0 ? c.warn(' — this node cannot announce, attest or settle until it is funded') : ''}`]] : []),
|
|
370
|
+
['branches', x.node.branches.join(', ') || '-'], ['blobs held', `${x.node.blobs.length}${x.disk ? c.dim(` of ${x.disk.blob_files} files on disk`) : ''}`],
|
|
371
|
+
['disk', diskLine(x.disk)],
|
|
372
|
+
// Item 321: "payouts needing attention" — a creator on another node stays unpaid until somebody sees this.
|
|
373
|
+
...(payouts && payouts.pending + payouts.failed > 0
|
|
374
|
+
? [['payouts owed', `${(payouts.failed ? c.err : c.warn)(String(payouts.pending + payouts.failed))} royalty transfer(s) not sent${payouts.failed ? ` (${payouts.failed} failed)` : ''}${c.dim(` — ${PROG} payouts ls`)}`]]
|
|
375
|
+
: []),
|
|
376
|
+
]),
|
|
377
|
+
...ledgerMismatchLines(x.peer_status),
|
|
378
|
+
].filter(Boolean).join('\n'));
|
|
379
|
+
// exit 2 = "the node you asked about is not there" — the same code the client uses for an unreachable node,
|
|
380
|
+
// so a health check built on `ainize status` fails when this home's node is down and a stranger holds its port.
|
|
381
|
+
if (stranger)
|
|
382
|
+
process.exitCode = 2;
|
|
383
|
+
return d;
|
|
384
|
+
}
|
|
385
|
+
/** `18s` / `12m` / `4h 03m` / `9d` — an age a column can hold, for the SEEN column and the peer state. */
|
|
386
|
+
export function relAge(ms) {
|
|
387
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
388
|
+
if (s < 60)
|
|
389
|
+
return `${s}s`;
|
|
390
|
+
const m = Math.round(s / 60);
|
|
391
|
+
if (m < 60)
|
|
392
|
+
return `${m}m`;
|
|
393
|
+
const h = Math.floor(m / 60);
|
|
394
|
+
return h < 48 ? `${h}h ${String(m % 60).padStart(2, '0')}m` : `${Math.round(h / 24)}d`;
|
|
395
|
+
}
|
|
396
|
+
/** warn is "warnings and worse", the question an operator asks — the same floor `/api/events` applies. */
|
|
397
|
+
const LEVELS = ['debug', 'info', 'warn', 'error'];
|
|
398
|
+
/**
|
|
399
|
+
* The event table read straight from `<dataDir>/node.sqlite` (item 131).
|
|
400
|
+
*
|
|
401
|
+
* `ainize logs` fetched `/api/events` over HTTP, so it worked only while the node was up — and a crash is precisely
|
|
402
|
+
* when the log is needed. The rows are in the node's own database either way; this reads them with no node. Opened
|
|
403
|
+
* read-only when SQLite allows it (a WAL left behind by a kill needs a writable open to be replayed).
|
|
404
|
+
*/
|
|
405
|
+
export function offlineEvents(dataDir, a = {}) {
|
|
406
|
+
const file = join(dataDir, 'node.sqlite');
|
|
407
|
+
if (!existsSync(file))
|
|
408
|
+
throw new CliError(`no node database at ${file} — no node has ever run in this home`, 2);
|
|
409
|
+
let db;
|
|
410
|
+
try {
|
|
411
|
+
db = new DatabaseSync(file, { readOnly: true });
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
db = new DatabaseSync(file);
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
const where = [];
|
|
418
|
+
const args = [];
|
|
419
|
+
if (a.patch) {
|
|
420
|
+
where.push('patch_id = ?');
|
|
421
|
+
args.push(a.patch);
|
|
422
|
+
}
|
|
423
|
+
if (a.kind) {
|
|
424
|
+
where.push('kind = ?');
|
|
425
|
+
args.push(a.kind);
|
|
426
|
+
}
|
|
427
|
+
if (a.level && LEVELS.includes(a.level)) {
|
|
428
|
+
const wanted = LEVELS.slice(LEVELS.indexOf(a.level));
|
|
429
|
+
where.push(`level IN (${wanted.map(() => '?').join(', ')})`);
|
|
430
|
+
args.push(...wanted);
|
|
431
|
+
}
|
|
432
|
+
const sql = `SELECT seq, ts, level, kind, patch_id, message, data FROM events ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY seq DESC LIMIT ${Number(a.limit ?? 100)}`;
|
|
433
|
+
const rows = db.prepare(sql).all(...args);
|
|
434
|
+
return rows.map((r) => ({
|
|
435
|
+
seq: r.seq, ts: r.ts, level: r.level, kind: r.kind,
|
|
436
|
+
patch_id: r.patch_id ?? null, message: r.message,
|
|
437
|
+
data: r.data ? (() => { try {
|
|
438
|
+
return JSON.parse(r.data);
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
return null;
|
|
442
|
+
} })() : null,
|
|
443
|
+
})).sort((x, y) => x.seq - y.seq);
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
db.close();
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
export async function logs(ctx, a = {}) {
|
|
450
|
+
const client = new NodeClient(ctx);
|
|
451
|
+
const fetchEvents = async (since) => {
|
|
452
|
+
const path = a.patch
|
|
453
|
+
? `/api/patches/${encodeURIComponent(a.patch)}/events${query({ limit: a.limit ?? 100 })}`
|
|
454
|
+
: `/api/events${query({ limit: a.limit ?? 100, kind: a.kind, level: a.level, since })}`;
|
|
455
|
+
// send the operator token: without it the operator's own terminal was served the redacted visitor stream,
|
|
456
|
+
// where every `draft …` line is dropped — so `logs --kind patch` printed a blank screen on a busy node (item 132)
|
|
457
|
+
const r = await client.get(path);
|
|
458
|
+
return [...r.events].sort((x, y) => x.seq - y.seq);
|
|
459
|
+
};
|
|
460
|
+
const render = (rows) => rows.map((e) => {
|
|
461
|
+
const lvl = e.level === 'error' ? c.err(e.level.padEnd(5)) : e.level === 'warn' ? c.warn(e.level.padEnd(5)) : c.dim(e.level.padEnd(5));
|
|
462
|
+
return `${c.dim(fmtTime(e.ts))} ${lvl} ${c.id(e.kind.padEnd(9))} ${e.patch_id ? c.dim(`[${e.patch_id}] `) : ''}${e.message}`;
|
|
463
|
+
}).join('\n');
|
|
464
|
+
const filters = [a.patch && `patch ${a.patch}`, a.kind && `kind '${a.kind}'`, a.level && `level ${a.level} or worse`].filter(Boolean).join(', ');
|
|
465
|
+
const empty = () => c.dim(filters ? `(no events match ${filters}${ctx.token ? '' : ` — and you are not logged in, so teach and draft lines are hidden; run \`${PROG} login\``})` : '(no events yet)');
|
|
466
|
+
let rows;
|
|
467
|
+
try {
|
|
468
|
+
rows = await fetchEvents();
|
|
469
|
+
}
|
|
470
|
+
catch (e) {
|
|
471
|
+
// exit 2 from the client is "nothing answered at that URL" — the post-mortem case. The events are still in the
|
|
472
|
+
// node's own database, so read them from there rather than leaving the operator with a fetch error (item 131).
|
|
473
|
+
const dataDir = ctx.cfg?.dataDir;
|
|
474
|
+
if (!(e instanceof CliError) || e.exitCode !== 2 || !dataDir)
|
|
475
|
+
throw e;
|
|
476
|
+
const offline = offlineEvents(dataDir, { limit: a.limit ?? 100, kind: a.kind, level: a.level, patch: a.patch });
|
|
477
|
+
info(ctx, c.dim(`node is not running — reading ${join(dataDir, 'node.sqlite')}`));
|
|
478
|
+
emit(ctx, offline, (r) => (r.length ? render(r) : empty()));
|
|
479
|
+
if (a.follow)
|
|
480
|
+
warn(ctx, `--follow needs a running node; showing what is on disk (\`${PROG} start -d\` to bring it back)`);
|
|
481
|
+
return offline;
|
|
482
|
+
}
|
|
483
|
+
if (!a.follow) {
|
|
484
|
+
emit(ctx, rows, (r) => (r.length ? render(r) : empty()));
|
|
485
|
+
return rows;
|
|
486
|
+
}
|
|
487
|
+
emit(ctx, rows, render);
|
|
488
|
+
let since = rows.length ? rows[rows.length - 1].ts : Date.now();
|
|
489
|
+
for (;;) {
|
|
490
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
491
|
+
const more = (await fetchEvents(since)).filter((e) => e.ts > since);
|
|
492
|
+
if (more.length) {
|
|
493
|
+
emit(ctx, more, render);
|
|
494
|
+
since = more[more.length - 1].ts;
|
|
495
|
+
rows = rows.concat(more);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
export async function seed(ctx, opts = {}) {
|
|
500
|
+
const cfg = assertUsableConfig(applyEnv(requireConfig(ctx)), ctx.home);
|
|
501
|
+
// Item 143: this used to offer two escapes that do not exist — the wrong binary name (`ngram stop`), and "seed from
|
|
502
|
+
// the web console", where there is no seed control at all. The order is the whole fix: seed the data directory,
|
|
503
|
+
// THEN start the node that opens it, which is what scripts/cluster.mjs has always done and what deploy/README.md
|
|
504
|
+
// now documents. The check also asks about THIS home's node rather than whatever `--node` points at: seeding writes
|
|
505
|
+
// `cfg.dataDir` whatever that flag says, so `--node <somewhere else>` used to walk straight past the guard.
|
|
506
|
+
const pid = runningPid(ctx.home);
|
|
507
|
+
const own = new NodeClient({ ...ctx, nodeUrl: `http://localhost:${cfg.port}`, token: null });
|
|
508
|
+
const holder = await own.get('/api/info', { auth: false, timeoutMs: 1500 }).catch(() => null);
|
|
509
|
+
const serving = !!pid || (!!holder && holder.node.address.toLowerCase() === cfg.identity.address.toLowerCase());
|
|
510
|
+
if (serving) {
|
|
511
|
+
throw new CliError([
|
|
512
|
+
`this home's node is running${pid ? ` (pid ${pid})` : ` on http://localhost:${cfg.port}`} and seeding writes directly into ${cfg.dataDir}, which that node has open.`,
|
|
513
|
+
` stop it, seed, start again: ${PROG} stop && ${PROG} seed && ${PROG} start -d`,
|
|
514
|
+
].join('\n'));
|
|
515
|
+
}
|
|
516
|
+
const { startNode, seedDemo } = await server();
|
|
517
|
+
const node = await startNode(cfg, { home: ctx.home, listen: false, quiet: true, serveWeb: false });
|
|
518
|
+
try {
|
|
519
|
+
const rep = await seedDemo(node.market, opts);
|
|
520
|
+
emit(ctx, rep, (r) => [
|
|
521
|
+
c.ok('✓ ') + `seeded: ${r.created.length} patch(es), ${r.branches.length} branch(es), ${r.imported_prototype} prototype record(s) imported`,
|
|
522
|
+
r.created.length ? ' created: ' + r.created.join(', ') : '',
|
|
523
|
+
r.branches.length ? ' branches: ' + r.branches.join(', ') : '',
|
|
524
|
+
r.skipped.length ? c.dim(' skipped (already present): ' + r.skipped.join(', ')) : '',
|
|
525
|
+
].filter(Boolean).join('\n'));
|
|
526
|
+
return rep;
|
|
527
|
+
}
|
|
528
|
+
finally {
|
|
529
|
+
await node.stop();
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/** Why this node is holding a body — the column that decides whether `gc` may take it. */
|
|
533
|
+
const heldFor = (b) => b.mine ? c.ok('published here') : b.applied ? c.ok('loaded') : b.purchased ? c.ok('bought') : b.reclaimable ? c.warn('verification') : c.dim('verification (sole copy)');
|
|
534
|
+
export async function blobsLs(ctx) {
|
|
535
|
+
const d = await new NodeClient(ctx).get('/api/me/blobs');
|
|
536
|
+
emit(ctx, d, (x) => [
|
|
537
|
+
table(x.items, [
|
|
538
|
+
{ key: 'id', title: 'KNOWLEDGE', get: (b) => b.name ?? c.dim(b.sha256.slice(0, 12)) },
|
|
539
|
+
{ key: 'sha', title: 'SHA256', get: (b) => c.dim(b.sha256.slice(0, 12)) },
|
|
540
|
+
{ key: 'size', title: 'SIZE', get: (b) => humanBytes(b.size_bytes), align: 'right' },
|
|
541
|
+
{ key: 'rows', title: 'ROWS', get: (b) => String(b.rows), align: 'right' },
|
|
542
|
+
{ key: 'held', title: 'HELD FOR', get: heldFor },
|
|
543
|
+
{ key: 'holders', title: 'PEERS', get: (b) => String(b.holders), align: 'right' },
|
|
544
|
+
{ key: 'when', title: 'FETCHED', get: (b) => fmtTime(b.imported_at) },
|
|
545
|
+
], 'no knowledge files on this node yet'),
|
|
546
|
+
'',
|
|
547
|
+
kv([['disk', diskLine(x.disk, false)]]),
|
|
548
|
+
x.reclaimable_bytes > 0
|
|
549
|
+
? c.dim(`\`${PROG} gc --dry-run\` lists what would go; every one of them is re-fetchable from a peer that holds it.`)
|
|
550
|
+
: x.items.length ? c.dim('nothing here is reclaimable: every body is published here, bought, loaded, or the only copy left.') : '',
|
|
551
|
+
keptLine(x.kept),
|
|
552
|
+
].filter(Boolean).join('\n'));
|
|
553
|
+
return d;
|
|
554
|
+
}
|
|
555
|
+
/** `--older-than 30d` / `12h` / `90` (days). */
|
|
556
|
+
export function parseAge(v) {
|
|
557
|
+
if (!v)
|
|
558
|
+
return undefined;
|
|
559
|
+
const m = /^(\d+(?:\.\d+)?)\s*([smhdw]?)$/i.exec(v.trim());
|
|
560
|
+
if (!m)
|
|
561
|
+
throw new CliError(`--older-than must be a duration like 30d, 12h or 90m — got ${JSON.stringify(v)}`);
|
|
562
|
+
const mult = { s: 1000, m: 60_000, h: 3600_000, d: 86_400_000, w: 7 * 86_400_000, '': 86_400_000 };
|
|
563
|
+
return Math.round(Number(m[1]) * mult[m[2].toLowerCase()]);
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* `ainize gc` — give back the disk that verification duty cost (item 128). A dry run first, always: the operator
|
|
567
|
+
* sees every body and its size before anything is deleted, and `--yes` is what turns the plan into a deletion.
|
|
568
|
+
*/
|
|
569
|
+
export async function gc(ctx, a = {}) {
|
|
570
|
+
const client = new NodeClient(ctx);
|
|
571
|
+
const body = {
|
|
572
|
+
keep_purchased: a.keepPurchased !== false,
|
|
573
|
+
older_than_ms: parseAge(a.olderThan) ?? null,
|
|
574
|
+
allow_sole_copy: !!a.allowSoleCopy,
|
|
575
|
+
};
|
|
576
|
+
const plan = await client.post('/api/me/blobs/gc', { ...body, dry_run: true });
|
|
577
|
+
const listPlan = (x) => [
|
|
578
|
+
table(x.candidates, [
|
|
579
|
+
{ key: 'id', title: 'KNOWLEDGE', get: (b) => b.name },
|
|
580
|
+
{ key: 'pid', title: 'ID', get: (b) => c.id(b.patch_id) },
|
|
581
|
+
{ key: 'size', title: 'SIZE', get: (b) => humanBytes(b.bytes), align: 'right' },
|
|
582
|
+
{ key: 'peers', title: 'PEERS HOLDING', get: (b) => String(b.holders), align: 'right' },
|
|
583
|
+
{ key: 'when', title: 'FETCHED', get: (b) => fmtTime(b.imported_at) },
|
|
584
|
+
], 'nothing to reclaim with these filters'),
|
|
585
|
+
keptLine(x.kept),
|
|
586
|
+
].filter(Boolean).join('\n');
|
|
587
|
+
if (!plan.candidates.length || a.dryRun) {
|
|
588
|
+
emit(ctx, plan, (x) => [listPlan(x), '', kv([['would free', humanBytes(x.bytes)], ['disk', diskLine(x.disk, false)]])].join('\n'));
|
|
589
|
+
return plan;
|
|
590
|
+
}
|
|
591
|
+
if (!a.yes) {
|
|
592
|
+
info(ctx, listPlan(plan));
|
|
593
|
+
info(ctx, `\nthis removes ${plan.candidates.length} knowledge file(s) and frees ${humanBytes(plan.bytes)}.`);
|
|
594
|
+
const typed = await promptLine('Type "yes" to delete them (anything else cancels): ');
|
|
595
|
+
if (typed.toLowerCase() !== 'yes')
|
|
596
|
+
throw new CliError('cancelled — nothing was deleted');
|
|
597
|
+
}
|
|
598
|
+
const done = await client.post('/api/me/blobs/gc', { ...body, dry_run: false });
|
|
599
|
+
emit(ctx, done, (x) => [
|
|
600
|
+
c.ok('✓ ') + `removed ${x.removed.length} knowledge file(s), freed ${humanBytes(x.freed)}`,
|
|
601
|
+
...(x.removed.length < x.candidates.length ? [c.warn(`${x.candidates.length - x.removed.length} could not be deleted — they are still on disk`)] : []),
|
|
602
|
+
kv([['disk', diskLine(x.disk, false)]]),
|
|
603
|
+
c.dim('every one of them is re-fetchable from a peer that holds it; verification will fetch it again if it is needed.'),
|
|
604
|
+
].join('\n'));
|
|
605
|
+
return done;
|
|
606
|
+
}
|
|
607
|
+
/** How recently a node must have been heard from to make the default `ainize nodes` list (item 140). */
|
|
608
|
+
export const NODES_RECENT_MS = 3600_000;
|
|
609
|
+
export async function nodesTable(ctx, a = {}) {
|
|
610
|
+
const client = new NodeClient(ctx);
|
|
611
|
+
// Ask for everything and filter here, so `--all` needs no second round trip and the cut-off is the CLI's own.
|
|
612
|
+
const d = await client.get('/api/nodes?all=1', { auth: false });
|
|
613
|
+
// The peers table carries the state that actually diagnoses a gossip problem, and it used to be printed BELOW a
|
|
614
|
+
// wall of 122 dead node records (item 140). It goes first.
|
|
615
|
+
const recent = d.nodes.filter((n) => n.address === d.self || Date.now() - (n.last_seen ?? 0) < NODES_RECENT_MS);
|
|
616
|
+
const shown = (a.all ? d.nodes : recent).slice(0, a.limit && a.limit > 0 ? a.limit : undefined);
|
|
617
|
+
const hidden = d.nodes.length - shown.length;
|
|
618
|
+
// One line per colliding ADDRESS, not per row: both rows carry the same endpoint pair (item 139).
|
|
619
|
+
const dupes = new Map();
|
|
620
|
+
for (const n of d.nodes)
|
|
621
|
+
if (n.duplicate_endpoints?.length && !dupes.has(n.address.toLowerCase()))
|
|
622
|
+
dupes.set(n.address.toLowerCase(), n);
|
|
623
|
+
emit(ctx, { ...d, nodes: shown, nodes_total: d.nodes.length, nodes_hidden: hidden }, (x) => [
|
|
624
|
+
c.head('peers'),
|
|
625
|
+
table(x.peers, peerColumns),
|
|
626
|
+
...blockedLines(x.blocked),
|
|
627
|
+
'', c.head(`known nodes${a.all ? '' : ' (seen in the last hour)'}`),
|
|
628
|
+
table(shown, [
|
|
629
|
+
{ key: 'name', title: 'NAME', get: (n) => (n.address === x.self ? c.bold(n.name + ' (self)') : n.name) + (n.duplicate_endpoints?.length ? c.err(' DUPLICATE') : '') },
|
|
630
|
+
{ key: 'addr', title: 'ADDRESS', get: (n) => shortAddr(n.address, 8) },
|
|
631
|
+
{ key: 'ep', title: 'ENDPOINT', get: (n) => n.endpoint },
|
|
632
|
+
{ key: 'roles', title: 'ROLES', get: (n) => n.roles.join(',') },
|
|
633
|
+
{ key: 'ledger', title: 'LEDGER', get: (n) => (n.ledger_mismatch ? c.warn(`${n.ledger} ≠ ours`) : n.ledger) },
|
|
634
|
+
{ key: 'branches', title: 'BRANCHES', get: (n) => n.branches.join(',') || '-' },
|
|
635
|
+
// what the node ITSELF says it holds: `blobs` is filtered through this node's catalogue, so a peer on another
|
|
636
|
+
// ledger showed 0 while holding four (item 170)
|
|
637
|
+
{ key: 'blobs', title: 'BLOBS', get: (n) => String(n.blobs_advertised ?? n.blobs.length), align: 'right' },
|
|
638
|
+
{ key: 'seen', title: 'SEEN', get: (n) => (n.address === x.self ? c.dim('now') : n.last_seen ? `${relAge(Date.now() - n.last_seen)} ago` : c.dim('never')) },
|
|
639
|
+
], 'no node records yet'),
|
|
640
|
+
...(hidden > 0 ? [c.dim(`… and ${hidden} node record(s) not seen for over an hour — \`${PROG} nodes --all\` lists them`)] : []),
|
|
641
|
+
// item 139: two endpoints answering for one identity. Whichever spoke last owns the address in every registry.
|
|
642
|
+
...[...dupes.values()].map((n) => c.err('! ') + `${shortAddr(n.address, 8)} is answering at ${n.duplicate_endpoints.join(' and ')} — two nodes are running on one identity, so buyers and verifiers reach one of them at random. Stop one, or give it its own key (\`${PROG} keys rotate\`).`),
|
|
643
|
+
// "configured peers" was a lie on this table: gossip adds every endpoint any peer advertises, so an operator was
|
|
644
|
+
// shown other people's nodes under a heading that said they had configured them (item 136). SOURCE says which is
|
|
645
|
+
// which, and STATE carries the reason a peer is not answering instead of a raw counter (item 138).
|
|
646
|
+
...ledgerMismatchLines(x.peer_status),
|
|
647
|
+
].join('\n'));
|
|
648
|
+
return d;
|
|
649
|
+
}
|
|
650
|
+
export { warn };
|
|
651
|
+
//# sourceMappingURL=node.js.map
|