sublera-cli 0.0.0-stage → 1.3.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/CHANGELOG.md +172 -0
- package/LICENSE +21 -0
- package/README.md +585 -2
- package/SECURITY.md +78 -0
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/dist/agent.d.ts +270 -0
- package/dist/agent.js +1867 -0
- package/dist/commands.d.ts +76 -0
- package/dist/commands.js +841 -0
- package/dist/config.d.ts +27 -0
- package/dist/config.js +95 -0
- package/dist/engine/binaryManager.d.ts +67 -0
- package/dist/engine/binaryManager.js +590 -0
- package/dist/engine/gguf.d.ts +88 -0
- package/dist/engine/gguf.js +458 -0
- package/dist/engine/llamaRunner.d.ts +78 -0
- package/dist/engine/llamaRunner.js +282 -0
- package/dist/engine/modelManager.d.ts +63 -0
- package/dist/engine/modelManager.js +510 -0
- package/dist/engine/selfCheck.d.ts +34 -0
- package/dist/engine/selfCheck.js +108 -0
- package/dist/exitCodes.d.ts +20 -0
- package/dist/exitCodes.js +20 -0
- package/dist/hardware/gpuMonitor.d.ts +32 -0
- package/dist/hardware/gpuMonitor.js +378 -0
- package/dist/hardware/sleepGuard.d.ts +42 -0
- package/dist/hardware/sleepGuard.js +149 -0
- package/dist/hardware/types.d.ts +40 -0
- package/dist/hardware/types.js +2 -0
- package/dist/health.d.ts +17 -0
- package/dist/health.js +38 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +268 -0
- package/dist/logger.d.ts +53 -0
- package/dist/logger.js +201 -0
- package/dist/network/proxy.d.ts +58 -0
- package/dist/network/proxy.js +328 -0
- package/dist/network/streamProxy.d.ts +51 -0
- package/dist/network/streamProxy.js +286 -0
- package/dist/network/tunnelClient.d.ts +54 -0
- package/dist/network/tunnelClient.js +257 -0
- package/dist/settings.d.ts +53 -0
- package/dist/settings.js +171 -0
- package/dist/state.d.ts +52 -0
- package/dist/state.js +71 -0
- package/dist/storageCommand.d.ts +22 -0
- package/dist/storageCommand.js +81 -0
- package/dist/types.d.ts +222 -0
- package/dist/types.js +43 -0
- package/dist/ui/progress.d.ts +29 -0
- package/dist/ui/progress.js +82 -0
- package/dist/ui/terminalDashboard.d.ts +88 -0
- package/dist/ui/terminalDashboard.js +295 -0
- package/dist/util/env.d.ts +3 -0
- package/dist/util/env.js +6 -0
- package/dist/util/exec.d.ts +23 -0
- package/dist/util/exec.js +116 -0
- package/dist/util/format.d.ts +47 -0
- package/dist/util/format.js +125 -0
- package/dist/util/fs.d.ts +18 -0
- package/dist/util/fs.js +140 -0
- package/dist/util/http.d.ts +28 -0
- package/dist/util/http.js +128 -0
- package/dist/util/instanceLock.d.ts +3 -0
- package/dist/util/instanceLock.js +108 -0
- package/dist/util/open.d.ts +16 -0
- package/dist/util/open.js +65 -0
- package/dist/util/storage.d.ts +10 -0
- package/dist/util/storage.js +69 -0
- package/dist/version.d.ts +15 -0
- package/dist/version.js +29 -0
- package/package.json +68 -4
package/dist/commands.js
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
3
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
4
|
+
import { hostname } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { Agent } from './agent.js';
|
|
7
|
+
import { apiUrl, configPath, subletHome, maskToken, modelsDir, normalizeServer, wsUrl } from './config.js';
|
|
8
|
+
import { AGENT_NAME, BRAND, DEFAULT_SERVER, DEFAULT_WS_PATH, MIN_NODE, VERSION, compareVersions } from './version.js';
|
|
9
|
+
import { GpuMonitor, applyGpuOverride, checkFit } from './hardware/gpuMonitor.js';
|
|
10
|
+
import { sleepGuardAvailable } from './hardware/sleepGuard.js';
|
|
11
|
+
import { ensureLlamaServer, findExistingLlama, variantFor } from './engine/binaryManager.js';
|
|
12
|
+
import { inspectHuggingFace, parseHfUrl } from './engine/gguf.js';
|
|
13
|
+
import { readConfigFile, writeConfigFile } from './util/fs.js';
|
|
14
|
+
import { fetchJson, HttpError, latestPublishedVersion, measureDownloadSpeed } from './util/http.js';
|
|
15
|
+
import { sleep, toError } from './util/exec.js';
|
|
16
|
+
import { canOpenBrowser, openUrl, printable } from './util/open.js';
|
|
17
|
+
import { formatBytes, formatBytesPerSec, formatDuration, formatPercent, formatSpeed, formatUsdNanos, padRight, truncate } from './util/format.js';
|
|
18
|
+
import { activeRequestsOf, isRequestId } from './types.js';
|
|
19
|
+
import { EXIT, UsageError } from './exitCodes.js';
|
|
20
|
+
import { SETTINGS_SPEC } from './settings.js';
|
|
21
|
+
import { proxyFor, proxySettings, redactProxy } from './network/proxy.js';
|
|
22
|
+
import { logsDir } from './config.js';
|
|
23
|
+
import { acquireInstanceLock } from './util/instanceLock.js';
|
|
24
|
+
import { brandEnv } from './util/env.js';
|
|
25
|
+
function serverFor(flags, fallback) {
|
|
26
|
+
return normalizeServer(flags.server || brandEnv(process.env, 'SUBLET_SERVER') || fallback || DEFAULT_SERVER);
|
|
27
|
+
}
|
|
28
|
+
function gpuOverrideFrom(flags) {
|
|
29
|
+
return flags.gpu || flags.vram ? { name: flags.gpu ?? null, vramMb: flags.vram ?? null } : null;
|
|
30
|
+
}
|
|
31
|
+
async function resolvedConfig(flags, requireToken) {
|
|
32
|
+
const existing = await readConfigFile();
|
|
33
|
+
const server = serverFor(flags, existing?.server);
|
|
34
|
+
if (!flags.token && existing?.token && server !== normalizeServer(existing.server)) {
|
|
35
|
+
throw new UsageError('The saved host key belongs to a different server. Log in to the selected server or explicitly supply its key with SUBLET_KEY.');
|
|
36
|
+
}
|
|
37
|
+
const token = flags.token || existing?.token || '';
|
|
38
|
+
if (!token && requireToken)
|
|
39
|
+
throw new UsageError('No host key. Run: sublera login, or set SUBLET_KEY');
|
|
40
|
+
const wsPath = flags.wsPath || brandEnv(process.env, 'SUBLET_WS_PATH') || existing?.wsPath || DEFAULT_WS_PATH;
|
|
41
|
+
try {
|
|
42
|
+
wsUrl(server, '', wsPath, 'header');
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
throw new UsageError(error instanceof Error ? error.message : 'Invalid tunnel URL');
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
server,
|
|
49
|
+
token,
|
|
50
|
+
wsPath,
|
|
51
|
+
updatedAt: Date.now(),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
async function promptToken() {
|
|
55
|
+
if (!process.stdin.isTTY)
|
|
56
|
+
throw new UsageError('No TTY to prompt. Set SUBLET_KEY or pass --key <host key>.');
|
|
57
|
+
const rl = createInterface({ input, output });
|
|
58
|
+
try {
|
|
59
|
+
const answer = await rl.question('Paste the host key from the Sublera dashboard: ');
|
|
60
|
+
return answer.trim();
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
rl.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export async function verifyHostKey(server, token) {
|
|
67
|
+
try {
|
|
68
|
+
return await fetchJson(apiUrl(server, '/api/agent/me'), { token, timeoutMs: 15_000 });
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error instanceof HttpError) {
|
|
72
|
+
if (error.status === 401 || error.status === 403)
|
|
73
|
+
throw new Error('That host key was rejected. Issue a new one from the host console.');
|
|
74
|
+
if (error.status === 404 || error.status === 405)
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Links this computer to a web account: the server gives a short code, the person approves it on the
|
|
82
|
+
* website, and the CLI receives its own host key. Returns null when the server has no device login.
|
|
83
|
+
*/
|
|
84
|
+
export async function deviceLogin(server, options) {
|
|
85
|
+
let code;
|
|
86
|
+
try {
|
|
87
|
+
code = await fetchJson(apiUrl(server, '/api/cli/login'), { body: JSON.stringify({ name: options.name }), timeoutMs: 15_000 });
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error instanceof HttpError && (error.status === 404 || error.status === 405))
|
|
91
|
+
return null;
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
options.onCode(code);
|
|
95
|
+
const interval = Math.min(Math.max(code.intervalMs ?? 2_000, 10), 10_000);
|
|
96
|
+
while (Date.now() < code.expiresAt) {
|
|
97
|
+
await sleep(interval);
|
|
98
|
+
const poll = await fetchJson(apiUrl(server, '/api/cli/login/poll'), {
|
|
99
|
+
body: JSON.stringify({ deviceCode: code.deviceCode }),
|
|
100
|
+
timeoutMs: 15_000,
|
|
101
|
+
});
|
|
102
|
+
if (poll.status === 'approved' && poll.token)
|
|
103
|
+
return { token: poll.token, username: poll.username || '' };
|
|
104
|
+
}
|
|
105
|
+
throw new Error('The login code expired. Run sublera login again.');
|
|
106
|
+
}
|
|
107
|
+
/** Shows the device code. The link comes from the server: it is printed without control characters and opened only on the server's own origin. */
|
|
108
|
+
function announceLoginCode(code, flags, server) {
|
|
109
|
+
const link = printable(String(code.verificationUrlComplete || code.verificationUrl || ''));
|
|
110
|
+
const userCode = printable(String(code.userCode || ''));
|
|
111
|
+
if (flags.json) {
|
|
112
|
+
output.write(`${JSON.stringify({ event: 'code', userCode, verificationUrl: link, expiresAt: code.expiresAt })}\n`);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
output.write(`\nLink this computer to your ${BRAND} account:\n`);
|
|
116
|
+
output.write(` 1. Open ${link}\n`);
|
|
117
|
+
output.write(` 2. Check that the page shows the code ${userCode}, then click Connect.\n\n`);
|
|
118
|
+
output.write(`Waiting for you to connect... (Ctrl+C to stop)\n`);
|
|
119
|
+
if (!flags.noBrowser && process.stdout.isTTY && canOpenBrowser())
|
|
120
|
+
openUrl(link, server);
|
|
121
|
+
}
|
|
122
|
+
async function saveHostKey(server, token, flags) {
|
|
123
|
+
const me = await verifyHostKey(server, token);
|
|
124
|
+
const cfg = {
|
|
125
|
+
server,
|
|
126
|
+
token,
|
|
127
|
+
wsPath: flags.wsPath || DEFAULT_WS_PATH,
|
|
128
|
+
updatedAt: Date.now(),
|
|
129
|
+
tokenLabel: me?.user.username,
|
|
130
|
+
};
|
|
131
|
+
await writeConfigFile(cfg);
|
|
132
|
+
return { cfg, me };
|
|
133
|
+
}
|
|
134
|
+
/** start, host, and jobs connect this computer first when it has no host key, so a new host needs one command. */
|
|
135
|
+
export async function readyConfig(flags, logger) {
|
|
136
|
+
const cfg = await resolvedConfig(flags, false);
|
|
137
|
+
if (cfg.token)
|
|
138
|
+
return cfg;
|
|
139
|
+
if (flags.nonInteractive)
|
|
140
|
+
throw new UsageError('No host key in non-interactive mode. Run sublera login once, or set SUBLET_KEY.');
|
|
141
|
+
logger.info(`This computer is not connected to ${BRAND} yet. Connect it first.`);
|
|
142
|
+
const linked = await deviceLogin(cfg.server, { name: hostname(), onCode: (code) => announceLoginCode(code, flags, cfg.server) });
|
|
143
|
+
if (!linked)
|
|
144
|
+
throw new Error('No host key. Run: sublera login, or pass --key <key>');
|
|
145
|
+
const { cfg: saved, me } = await saveHostKey(cfg.server, linked.token, flags);
|
|
146
|
+
logger.success(`Connected as @${me?.user.username || linked.username}`, { config: configPath() });
|
|
147
|
+
return { ...saved, wsPath: cfg.wsPath };
|
|
148
|
+
}
|
|
149
|
+
export async function cmdLogin(flags, logger) {
|
|
150
|
+
const existing = await readConfigFile();
|
|
151
|
+
const server = serverFor(flags, existing?.server);
|
|
152
|
+
let token = (flags.token || flags.positionals[0] || '').trim();
|
|
153
|
+
if (!token && flags.nonInteractive)
|
|
154
|
+
throw new UsageError('Non-interactive login requires SUBLET_KEY or --key. No browser login was started.');
|
|
155
|
+
if (!token) {
|
|
156
|
+
const linked = await deviceLogin(server, { name: hostname(), onCode: (code) => announceLoginCode(code, flags, server) });
|
|
157
|
+
token = linked?.token ?? (await promptToken()).trim();
|
|
158
|
+
}
|
|
159
|
+
if (!token)
|
|
160
|
+
throw new Error('Host key is empty');
|
|
161
|
+
const { me } = await saveHostKey(server, token, flags);
|
|
162
|
+
if (flags.json) {
|
|
163
|
+
output.write(`${JSON.stringify({ ok: true, server, username: me?.user.username || null, config: configPath() })}\n`);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
logger.success(`Saved host key for ${me?.user.username ? `@${me.user.username}` : 'this machine'}`, { config: configPath() });
|
|
167
|
+
if (!me)
|
|
168
|
+
logger.warn('The server does not expose /api/agent/me yet. The key is saved, but verify it with: sublera doctor');
|
|
169
|
+
output.write(`\n${BRAND} agent is configured. Next: sublera start\n`);
|
|
170
|
+
}
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
// A public single-file GGUF on HuggingFace, the same CDN that serves booking models.
|
|
174
|
+
const SPEED_TEST_URL = 'https://huggingface.co/TheBloke/TinyLlama-1.1B-Chat-v1.0-GGUF/resolve/main/tinyllama-1.1b-chat-v1.0.Q4_K_M.gguf';
|
|
175
|
+
// Below 2 MB/s an 8 GB model takes more than an hour to download.
|
|
176
|
+
const SLOW_DOWNLOAD_BYTES_PER_SEC = 2 * 1024 * 1024;
|
|
177
|
+
function check(name, status, detail) {
|
|
178
|
+
return { name, status, detail };
|
|
179
|
+
}
|
|
180
|
+
export async function cmdDoctor(flags, logger) {
|
|
181
|
+
const cfg = await resolvedConfig(flags, false);
|
|
182
|
+
const server = cfg.server;
|
|
183
|
+
const checks = [];
|
|
184
|
+
const nodeOk = compareVersions(process.versions.node, MIN_NODE) >= 0;
|
|
185
|
+
checks.push(check('Node.js', nodeOk ? 'ok' : 'fail', `v${process.versions.node} (need >= ${MIN_NODE})`));
|
|
186
|
+
checks.push(check('Platform', 'ok', `${process.platform}/${process.arch}`));
|
|
187
|
+
try {
|
|
188
|
+
const proxy = proxyFor(server);
|
|
189
|
+
const ca = process.env.NODE_EXTRA_CA_CERTS;
|
|
190
|
+
checks.push(check('Network', 'ok', `${proxy ? `via proxy ${redactProxy(proxy)}` : 'direct (no proxy)'}${ca ? ` · extra CA ${ca}` : ''}`));
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
checks.push(check('Network', 'fail', toError(error).message));
|
|
194
|
+
}
|
|
195
|
+
const latest = await latestPublishedVersion();
|
|
196
|
+
const outdated = latest != null && compareVersions(latest, VERSION) > 0;
|
|
197
|
+
checks.push(check('Agent version', outdated ? 'warn' : 'ok', outdated
|
|
198
|
+
? `v${VERSION} installed, v${latest} available. Update with: npx ${AGENT_NAME}@latest`
|
|
199
|
+
: `v${VERSION}${latest ? ' (latest)' : ''}`));
|
|
200
|
+
let me = null;
|
|
201
|
+
if (cfg.token) {
|
|
202
|
+
try {
|
|
203
|
+
me = await verifyHostKey(server, cfg.token);
|
|
204
|
+
checks.push(check('Host key', me ? 'ok' : 'warn', me ? `@${me.user.username} · ${server}` : `Saved, but ${server} does not expose /api/agent/me yet`));
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
checks.push(check('Host key', 'fail', error instanceof Error ? error.message : String(error)));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
checks.push(check('Host key', 'warn', 'Not saved. Run: sublera login'));
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const started = Date.now();
|
|
215
|
+
const health = await fetchJson(apiUrl(server, '/api/health'), { timeoutMs: 8_000 });
|
|
216
|
+
checks.push(check('Server', 'ok', `${health.brand || BRAND} at ${server} · ${Date.now() - started}ms`));
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
checks.push(check('Server', 'fail', error instanceof Error ? error.message : String(error)));
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const speed = await measureDownloadSpeed(SPEED_TEST_URL);
|
|
223
|
+
const eightGbSeconds = (8 * 1024 ** 3) / Math.max(1, speed.bytesPerSec);
|
|
224
|
+
checks.push(check('Download speed', speed.bytesPerSec >= SLOW_DOWNLOAD_BYTES_PER_SEC ? 'ok' : 'warn', `${formatBytesPerSec(speed.bytesPerSec)} from HuggingFace · an 8 GB model takes about ${formatDuration(eightGbSeconds)}`));
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
checks.push(check('Download speed', 'warn', toError(error).message));
|
|
228
|
+
}
|
|
229
|
+
const monitor = new GpuMonitor();
|
|
230
|
+
const gpu = applyGpuOverride(await monitor.scan(true), gpuOverrideFrom(flags));
|
|
231
|
+
if (gpu.primary) {
|
|
232
|
+
const p = gpu.primary;
|
|
233
|
+
checks.push(check('GPU', 'ok', `${p.name} · ${(p.totalVramMb / 1024).toFixed(1)} GB total · ${(p.freeVramMb / 1024).toFixed(1)} GB free${p.temperatureC != null ? ` · ${p.temperatureC.toFixed(0)}C` : ''}`));
|
|
234
|
+
checks.push(check('VRAM headroom', p.freeVramMb > 1024 ? 'ok' : 'warn', `${formatBytes(p.freeVramMb * 1024 * 1024)} free`));
|
|
235
|
+
if (p.processes.length)
|
|
236
|
+
checks.push(check('GPU processes', 'warn', p.processes.map((proc) => `${proc.name} (${proc.usedMb}MB)`).join(', ')));
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
checks.push(check('GPU', 'warn', gpu.warnings.join(' ') || 'No GPU detected. Use --simulation or --gpu/--vram.'));
|
|
240
|
+
}
|
|
241
|
+
const sleepOk = await sleepGuardAvailable(process.platform);
|
|
242
|
+
checks.push(check('Sleep guard', sleepOk ? 'ok' : 'warn', sleepOk ? 'Wake-lock is available for overnight bookings' : 'No supported wake-lock on this platform'));
|
|
243
|
+
let install = await findExistingLlama({ env: process.env });
|
|
244
|
+
if (!install && flags.fix) {
|
|
245
|
+
try {
|
|
246
|
+
const release = await acquireInstanceLock();
|
|
247
|
+
try {
|
|
248
|
+
const result = await ensureLlamaServer({ fix: true, gpu, logger, showProgress: true, requireChecksum: flags.requireChecksum, simulation: false });
|
|
249
|
+
if (result.found)
|
|
250
|
+
install = result.install;
|
|
251
|
+
else
|
|
252
|
+
checks.push(check('llama-server', 'fail', result.reason));
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
await release();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
checks.push(check('llama-server', 'fail', toError(error).message));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (install) {
|
|
263
|
+
const own = install.source === 'env' || install.source === 'path';
|
|
264
|
+
const trust = own
|
|
265
|
+
? `your own build (${install.source === 'env' ? 'LLAMA_SERVER' : 'PATH'}), not checked; a different llama.cpp build can fail model checks`
|
|
266
|
+
: install.verified ? 'checksum verified' : 'local hash recorded, no published checksum';
|
|
267
|
+
checks.push(check('llama-server', install.verified ? 'ok' : flags.requireChecksum ? 'fail' : 'warn', `${install.path} · ${own ? trust : `${install.variant} · ${trust}`}`));
|
|
268
|
+
}
|
|
269
|
+
else if (!flags.fix) {
|
|
270
|
+
checks.push(check('llama-server', 'warn', `Not installed. Run: sublera doctor --fix (target: ${variantFor(process.platform, process.arch, gpu.primary?.vendor)})`));
|
|
271
|
+
}
|
|
272
|
+
const models = await listModels();
|
|
273
|
+
checks.push(check('Model cache', 'ok', `${models.count} file(s), ${formatBytes(models.bytes)} in ${modelsDir()}`));
|
|
274
|
+
const failures = checks.filter((c) => c.status === 'fail');
|
|
275
|
+
if (flags.json) {
|
|
276
|
+
output.write(`${JSON.stringify({ schemaVersion: 1, ok: failures.length === 0, agentVersion: VERSION, checks, server, config: configPath(), gpu, llama: install, me }, null, 2)}\n`);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
output.write(`\n${BRAND} agent doctor\n`);
|
|
280
|
+
output.write(`home: ${subletHome()}\nserver: ${server}\nkey: ${maskToken(cfg.token)}\n\n`);
|
|
281
|
+
for (const item of checks) {
|
|
282
|
+
const mark = item.status === 'ok' ? '[ ok ]' : item.status === 'warn' ? '[warn]' : '[fail]';
|
|
283
|
+
output.write(`${mark} ${padRight(item.name, 15)} ${item.detail}\n`);
|
|
284
|
+
}
|
|
285
|
+
output.write(`\n${failures.length ? `${failures.length} check(s) failed.` : 'All blocking checks passed.'}\n`);
|
|
286
|
+
if (!flags.fix && !install)
|
|
287
|
+
output.write(`Tip: sublera doctor --fix downloads the matching llama-server build.\n`);
|
|
288
|
+
}
|
|
289
|
+
return failures.length ? 1 : 0;
|
|
290
|
+
}
|
|
291
|
+
async function listModels() {
|
|
292
|
+
let count = 0;
|
|
293
|
+
let bytes = 0;
|
|
294
|
+
try {
|
|
295
|
+
for (const entry of await readdir(modelsDir(), { withFileTypes: true })) {
|
|
296
|
+
if (!entry.isFile() || !/\.gguf$/i.test(entry.name))
|
|
297
|
+
continue;
|
|
298
|
+
const info = await stat(join(modelsDir(), entry.name));
|
|
299
|
+
count++;
|
|
300
|
+
bytes += info.size;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
/* no cache */
|
|
305
|
+
}
|
|
306
|
+
return { count, bytes };
|
|
307
|
+
}
|
|
308
|
+
export async function cmdStart(flags, logger) {
|
|
309
|
+
const requestId = flags.positionals[0] ?? null;
|
|
310
|
+
if (requestId && !isRequestId(requestId))
|
|
311
|
+
throw new UsageError(`"${requestId}" is not a request code. Copy the start command from the website.`);
|
|
312
|
+
const cfg = await readyConfig(flags, logger);
|
|
313
|
+
if (requestId)
|
|
314
|
+
logger.info(`Serving request ${requestId}. The agent stops when the booking ends and no other rental uses this GPU.`);
|
|
315
|
+
const agent = new Agent({
|
|
316
|
+
requestId,
|
|
317
|
+
config: cfg,
|
|
318
|
+
logger,
|
|
319
|
+
simulation: flags.simulation,
|
|
320
|
+
model: flags.model || null,
|
|
321
|
+
publicMode: false,
|
|
322
|
+
price: flags.price ?? null,
|
|
323
|
+
priceInUsd: flags.priceIn ?? null,
|
|
324
|
+
priceOutUsd: flags.priceOut ?? null,
|
|
325
|
+
context: flags.context,
|
|
326
|
+
slots: flags.slots,
|
|
327
|
+
gpuOverride: gpuOverrideFrom(flags),
|
|
328
|
+
tokPerSec: flags.tokPerSec ?? null,
|
|
329
|
+
autoInstall: !flags.noInstall,
|
|
330
|
+
json: flags.json,
|
|
331
|
+
once: flags.once,
|
|
332
|
+
requireChecksum: flags.requireChecksum,
|
|
333
|
+
drainTimeoutMs: flags.drainTimeoutMs,
|
|
334
|
+
});
|
|
335
|
+
return await runAgent(agent, logger, requestId);
|
|
336
|
+
}
|
|
337
|
+
export async function cmdHost(flags, logger) {
|
|
338
|
+
const model = flags.model || flags.positionals[0];
|
|
339
|
+
if (!model)
|
|
340
|
+
throw new UsageError('Usage: sublera host <huggingface-gguf-url|path.gguf> --price 0.25');
|
|
341
|
+
// Fail before model inspection or download when unattended credentials are missing.
|
|
342
|
+
const cfg = await readyConfig(flags, logger);
|
|
343
|
+
if (!flags.simulation && !/^https?:\/\//i.test(model)) {
|
|
344
|
+
try {
|
|
345
|
+
await stat(model);
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
throw new Error(`Model file not found: ${model}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
let modelInfo = null;
|
|
352
|
+
if (!flags.simulation && /^https?:\/\//i.test(model)) {
|
|
353
|
+
const parsed = parseHfUrl(model);
|
|
354
|
+
if (parsed) {
|
|
355
|
+
modelInfo = await inspectHuggingFace(model, 15_000);
|
|
356
|
+
const info = modelInfo;
|
|
357
|
+
logger.info('Model inspected', {
|
|
358
|
+
file: info.file,
|
|
359
|
+
layers: info.layers ?? '?',
|
|
360
|
+
paramB: info.paramB ?? '?',
|
|
361
|
+
vram: info.needsMb ? `${(info.needsMb / 1024).toFixed(1)}GB` : '?',
|
|
362
|
+
sha: info.sha256 ? `${info.sha256.slice(0, 12)}…` : 'not published',
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const agent = new Agent({
|
|
367
|
+
config: cfg,
|
|
368
|
+
logger,
|
|
369
|
+
simulation: flags.simulation,
|
|
370
|
+
model,
|
|
371
|
+
modelInfo,
|
|
372
|
+
publicMode: true,
|
|
373
|
+
price: flags.price ?? 0.25,
|
|
374
|
+
priceInUsd: flags.priceIn ?? null,
|
|
375
|
+
priceOutUsd: flags.priceOut ?? flags.price ?? 0.25,
|
|
376
|
+
priceHourUsd: flags.priceHour ?? 0.5,
|
|
377
|
+
context: flags.context,
|
|
378
|
+
slots: flags.slots,
|
|
379
|
+
gpuOverride: gpuOverrideFrom(flags),
|
|
380
|
+
tokPerSec: flags.tokPerSec ?? null,
|
|
381
|
+
autoInstall: !flags.noInstall,
|
|
382
|
+
json: flags.json,
|
|
383
|
+
once: flags.once,
|
|
384
|
+
requireChecksum: flags.requireChecksum,
|
|
385
|
+
drainTimeoutMs: flags.drainTimeoutMs,
|
|
386
|
+
});
|
|
387
|
+
return await runAgent(agent, logger);
|
|
388
|
+
}
|
|
389
|
+
/** With a target request (`sublera start <code>`), the agent exits when that request ends. */
|
|
390
|
+
async function runAgent(agent, logger, target = null) {
|
|
391
|
+
let stopping = false;
|
|
392
|
+
let exitCode = 0;
|
|
393
|
+
let served = false;
|
|
394
|
+
const stop = async (code, preserveState = false) => {
|
|
395
|
+
if (stopping)
|
|
396
|
+
return;
|
|
397
|
+
stopping = true;
|
|
398
|
+
exitCode = code;
|
|
399
|
+
if (preserveState && code === EXIT.TERMINATED)
|
|
400
|
+
await agent.drain();
|
|
401
|
+
else
|
|
402
|
+
await agent.stop(preserveState);
|
|
403
|
+
};
|
|
404
|
+
const onInterrupt = () => { void stop(130); };
|
|
405
|
+
// Shutdowns and service managers send SIGTERM; keep state.json so the next start can reattach to the booking.
|
|
406
|
+
const onTerminate = () => { void stop(143, true); };
|
|
407
|
+
process.once('SIGINT', onInterrupt);
|
|
408
|
+
process.once('SIGTERM', onTerminate);
|
|
409
|
+
agent.once('fatal', () => { void stop(1, true); });
|
|
410
|
+
let watcher = null;
|
|
411
|
+
if (target) {
|
|
412
|
+
// The target ending is not enough to exit: other bookings on the same GPU keep the agent running.
|
|
413
|
+
let targetDone = false;
|
|
414
|
+
let rejectedTarget = false;
|
|
415
|
+
let waitingNoted = false;
|
|
416
|
+
const exitWhenIdle = () => {
|
|
417
|
+
if (!targetDone || stopping)
|
|
418
|
+
return;
|
|
419
|
+
if (agent.servingOthers(target)) {
|
|
420
|
+
if (!waitingNoted)
|
|
421
|
+
logger.info(`Request ${target} is over; other rentals are still active on this GPU, so the agent stays online until they end.`);
|
|
422
|
+
waitingNoted = true;
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (waitingNoted)
|
|
426
|
+
logger.info('No other rentals are left; stopping.');
|
|
427
|
+
else if (served && !rejectedTarget)
|
|
428
|
+
logger.success(`Booking ${target} finished. Run sublera start to stay online for public calls.`);
|
|
429
|
+
void stop(served ? 0 : 1);
|
|
430
|
+
};
|
|
431
|
+
agent.on('rejected', (event) => {
|
|
432
|
+
if (event.requestId !== target)
|
|
433
|
+
return;
|
|
434
|
+
served ||= event.wasActive;
|
|
435
|
+
if (served)
|
|
436
|
+
logger.info(`Request ${target} ended: ${event.reason}`);
|
|
437
|
+
else
|
|
438
|
+
logger.error(`Cannot serve ${target}: ${event.reason}`);
|
|
439
|
+
targetDone = true;
|
|
440
|
+
rejectedTarget = true;
|
|
441
|
+
exitWhenIdle();
|
|
442
|
+
});
|
|
443
|
+
watcher = setInterval(() => {
|
|
444
|
+
const booking = agent.booking(target);
|
|
445
|
+
if (booking?.timerEndsAt)
|
|
446
|
+
served = true;
|
|
447
|
+
if (served && (!booking || Date.now() > (booking.timerEndsAt ?? 0) + 3_000))
|
|
448
|
+
targetDone = true;
|
|
449
|
+
exitWhenIdle();
|
|
450
|
+
}, 1_000);
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
await agent.run();
|
|
454
|
+
}
|
|
455
|
+
finally {
|
|
456
|
+
if (watcher)
|
|
457
|
+
clearInterval(watcher);
|
|
458
|
+
process.removeListener('SIGINT', onInterrupt);
|
|
459
|
+
process.removeListener('SIGTERM', onTerminate);
|
|
460
|
+
await agent.stop();
|
|
461
|
+
}
|
|
462
|
+
return exitCode;
|
|
463
|
+
}
|
|
464
|
+
export async function cmdJobs(flags, logger) {
|
|
465
|
+
const cfg = await readyConfig(flags, logger);
|
|
466
|
+
const monitor = new GpuMonitor();
|
|
467
|
+
const gpu = applyGpuOverride(await monitor.scan(true), gpuOverrideFrom(flags));
|
|
468
|
+
const requests = await loadRequests(cfg, logger);
|
|
469
|
+
if (flags.json && !flags.claim) {
|
|
470
|
+
output.write(`${JSON.stringify(requests, null, 2)}\n`);
|
|
471
|
+
return 0;
|
|
472
|
+
}
|
|
473
|
+
if (!flags.json)
|
|
474
|
+
printRequests(requests, gpu.primary);
|
|
475
|
+
if (flags.claim) {
|
|
476
|
+
const target = requests.find((row) => row.id === flags.claim);
|
|
477
|
+
if (target?.needsMb != null && gpu.primary) {
|
|
478
|
+
const fit = checkFit(target.needsMb, gpu.primary);
|
|
479
|
+
if (!fit.ok)
|
|
480
|
+
throw new Error(fit.reason || 'This model does not fit in free VRAM');
|
|
481
|
+
}
|
|
482
|
+
if (flags.json) {
|
|
483
|
+
const result = await claimRequest(cfg, flags.claim, true);
|
|
484
|
+
output.write(`${JSON.stringify({ ok: true, requestId: flags.claim, ...result })}\n`);
|
|
485
|
+
return 0;
|
|
486
|
+
}
|
|
487
|
+
return await serveClaim(cfg, flags.claim, flags, logger);
|
|
488
|
+
}
|
|
489
|
+
if (flags.nonInteractive || !process.stdin.isTTY) {
|
|
490
|
+
output.write('\nNon-interactive terminal. Use --claim <request-id> to take a job.\n');
|
|
491
|
+
return 0;
|
|
492
|
+
}
|
|
493
|
+
if (!requests.length)
|
|
494
|
+
return 0;
|
|
495
|
+
const rl = createInterface({ input, output });
|
|
496
|
+
try {
|
|
497
|
+
const answer = (await rl.question('\nRun which request? (number, or Enter to leave) ')).trim();
|
|
498
|
+
if (!answer)
|
|
499
|
+
return 0;
|
|
500
|
+
const index = Number(answer) - 1;
|
|
501
|
+
const picked = requests[index];
|
|
502
|
+
if (!picked) {
|
|
503
|
+
logger.error('That number is not on the list.');
|
|
504
|
+
return 1;
|
|
505
|
+
}
|
|
506
|
+
if (picked.needsMb != null && gpu.primary) {
|
|
507
|
+
const fit = checkFit(picked.needsMb, gpu.primary);
|
|
508
|
+
if (!fit.ok)
|
|
509
|
+
throw new Error(fit.reason || 'This model does not fit in free VRAM');
|
|
510
|
+
}
|
|
511
|
+
return await serveClaim(cfg, picked.id, flags, logger);
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
rl.close();
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Claim a request and, when no other agent is online, run this terminal as the
|
|
519
|
+
* serving agent until the booking ends or the host presses Ctrl+C.
|
|
520
|
+
*/
|
|
521
|
+
async function serveClaim(cfg, requestId, flags, logger) {
|
|
522
|
+
const existing = await verifyHostKey(cfg.server, cfg.token).catch(() => null);
|
|
523
|
+
if (existing?.host?.kind === 'live' && existing.host.status === 'online') {
|
|
524
|
+
const result = await claimRequest(cfg, requestId, true);
|
|
525
|
+
logger.success(`Claimed ${requestId}; an already-running agent will serve it.`, { claim: result.claimId || '' });
|
|
526
|
+
return 0;
|
|
527
|
+
}
|
|
528
|
+
const agent = new Agent({
|
|
529
|
+
config: cfg,
|
|
530
|
+
logger,
|
|
531
|
+
simulation: flags.simulation,
|
|
532
|
+
model: null,
|
|
533
|
+
publicMode: false,
|
|
534
|
+
context: flags.context,
|
|
535
|
+
slots: 1,
|
|
536
|
+
gpuOverride: gpuOverrideFrom(flags),
|
|
537
|
+
tokPerSec: flags.tokPerSec ?? null,
|
|
538
|
+
autoInstall: !flags.noInstall,
|
|
539
|
+
json: flags.json,
|
|
540
|
+
once: false,
|
|
541
|
+
requireChecksum: flags.requireChecksum,
|
|
542
|
+
drainTimeoutMs: flags.drainTimeoutMs,
|
|
543
|
+
});
|
|
544
|
+
let finished = false;
|
|
545
|
+
let signalCode = null;
|
|
546
|
+
const stop = async (preserveState = false) => {
|
|
547
|
+
if (finished)
|
|
548
|
+
return;
|
|
549
|
+
finished = true;
|
|
550
|
+
if (preserveState)
|
|
551
|
+
await agent.drain();
|
|
552
|
+
else
|
|
553
|
+
await agent.stop();
|
|
554
|
+
};
|
|
555
|
+
// Same contract as start/host: Ctrl+C clears saved state (130); SIGTERM keeps it for the next start (143).
|
|
556
|
+
const onInterrupt = () => { signalCode = EXIT.INTERRUPTED; void stop(); };
|
|
557
|
+
const onTerminate = () => { signalCode = EXIT.TERMINATED; void stop(true); };
|
|
558
|
+
process.once('SIGINT', onInterrupt);
|
|
559
|
+
process.once('SIGTERM', onTerminate);
|
|
560
|
+
agent.once('stop', () => { finished = true; });
|
|
561
|
+
// A cancel, release, or failed check arrives as a reject. It can come before the polling below sees the booking.
|
|
562
|
+
let rejected = null;
|
|
563
|
+
agent.on('rejected', (event) => { if (event.requestId === requestId)
|
|
564
|
+
rejected = event; });
|
|
565
|
+
const runPromise = agent.run();
|
|
566
|
+
runPromise.catch(async (error) => {
|
|
567
|
+
logger.error('Serving agent stopped', { error: toError(error).message });
|
|
568
|
+
await stop();
|
|
569
|
+
});
|
|
570
|
+
try {
|
|
571
|
+
await Promise.race([
|
|
572
|
+
agent.waitForOnline(25_000),
|
|
573
|
+
runPromise.then(() => { throw new Error('Agent exited before it connected'); }),
|
|
574
|
+
]);
|
|
575
|
+
await claimRequest(cfg, requestId, true);
|
|
576
|
+
logger.success(`Claimed ${requestId}. This terminal is now the serving agent.`, { stop: 'Ctrl+C' });
|
|
577
|
+
let sawJob = false;
|
|
578
|
+
let targetDone = false;
|
|
579
|
+
let waitingNoted = false;
|
|
580
|
+
let lastNotice = Date.now();
|
|
581
|
+
while (!finished) {
|
|
582
|
+
const booking = agent.booking(requestId);
|
|
583
|
+
if (booking?.timerEndsAt) {
|
|
584
|
+
sawJob = true;
|
|
585
|
+
if (Date.now() > booking.timerEndsAt + 3_000)
|
|
586
|
+
targetDone = true;
|
|
587
|
+
}
|
|
588
|
+
else if (sawJob && !booking) {
|
|
589
|
+
targetDone = true;
|
|
590
|
+
}
|
|
591
|
+
else if (!sawJob && !rejected && Date.now() - lastNotice > 15 * 60_000) {
|
|
592
|
+
logger.info('Still waiting for the buyer to confirm or for the timer to start. Press Ctrl+C to stop.');
|
|
593
|
+
lastNotice = Date.now();
|
|
594
|
+
}
|
|
595
|
+
if (rejected)
|
|
596
|
+
targetDone = true;
|
|
597
|
+
// Exit only when this request is over and no other booking still uses this GPU.
|
|
598
|
+
if (targetDone) {
|
|
599
|
+
if (!agent.servingOthers(requestId))
|
|
600
|
+
break;
|
|
601
|
+
if (!waitingNoted)
|
|
602
|
+
logger.info(`Request ${requestId} is over; other rentals are still active, so this terminal keeps serving them.`);
|
|
603
|
+
waitingNoted = true;
|
|
604
|
+
}
|
|
605
|
+
await sleep(1_000);
|
|
606
|
+
}
|
|
607
|
+
if (signalCode != null)
|
|
608
|
+
return signalCode;
|
|
609
|
+
const ended = rejected;
|
|
610
|
+
if (!ended)
|
|
611
|
+
return 0;
|
|
612
|
+
if (sawJob || ended.wasActive) {
|
|
613
|
+
logger.info(`Request ${requestId} ended: ${ended.reason}`);
|
|
614
|
+
return 0;
|
|
615
|
+
}
|
|
616
|
+
logger.error(`Cannot serve ${requestId}: ${ended.reason}`);
|
|
617
|
+
return 1;
|
|
618
|
+
}
|
|
619
|
+
finally {
|
|
620
|
+
process.removeListener('SIGINT', onInterrupt);
|
|
621
|
+
process.removeListener('SIGTERM', onTerminate);
|
|
622
|
+
await stop(signalCode === EXIT.TERMINATED);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
async function loadRequests(cfg, logger) {
|
|
626
|
+
try {
|
|
627
|
+
const result = await fetchJson(apiUrl(cfg.server, '/api/agent/requests'), { token: cfg.token, timeoutMs: 15_000 });
|
|
628
|
+
return result.requests || [];
|
|
629
|
+
}
|
|
630
|
+
catch (error) {
|
|
631
|
+
if (error instanceof HttpError && (error.status === 404 || error.status === 405)) {
|
|
632
|
+
const fallback = await fetchJson(apiUrl(cfg.server, '/api/board'), { timeoutMs: 15_000 });
|
|
633
|
+
return fallback.requests || [];
|
|
634
|
+
}
|
|
635
|
+
logger.warn('Could not read the request board', { error: toError(error).message });
|
|
636
|
+
throw error;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
function printRequests(requests, gpu) {
|
|
640
|
+
output.write(`\nOpen buyer requests (${requests.length})\n`);
|
|
641
|
+
if (!requests.length) {
|
|
642
|
+
output.write(' None. Post one at /board/new.\n');
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
for (const [index, request] of requests.entries()) {
|
|
646
|
+
const needs = request.needsMb ?? null;
|
|
647
|
+
const fit = needs != null && gpu ? checkFit(needs, gpu).ok ? 'fits' : 'too big' : 'unknown size';
|
|
648
|
+
const payout = request.totalPayoutNanos ?? (request.hours && request.pricePerHourNanos ? request.hours * request.pricePerHourNanos : 0);
|
|
649
|
+
const model = request.modelLabel || request.file || request.repo || 'unknown model';
|
|
650
|
+
output.write(`${String(index + 1).padStart(2)}. ${truncate(model, 38)} ${formatUsdNanos(payout)} / ${request.hours || '?'}h ${needs ? `${(needs / 1024).toFixed(1)}GB` : 'VRAM ?'} ${fit} ${request.minTokS || 0} tok/s min\n`);
|
|
651
|
+
output.write(` id ${request.id} ${request.status}${request.fit ? ` ${request.fit}` : ''}${request.cached ? ' model on disk: ready now' : ''}\n`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
async function claimRequest(cfg, requestId, quiet = false) {
|
|
655
|
+
try {
|
|
656
|
+
const result = await fetchJson(apiUrl(cfg.server, `/api/agent/requests/${encodeURIComponent(requestId)}/claim`), {
|
|
657
|
+
token: cfg.token,
|
|
658
|
+
method: 'POST',
|
|
659
|
+
body: '{}',
|
|
660
|
+
timeoutMs: 20_000,
|
|
661
|
+
});
|
|
662
|
+
if (!quiet) {
|
|
663
|
+
output.write(`Claimed ${requestId}${result.status ? ` (${result.status})` : ''}.\n`);
|
|
664
|
+
output.write('The running `sublera start` agent will download and verify the model, then the server starts the clock after the model check.\n');
|
|
665
|
+
}
|
|
666
|
+
return result;
|
|
667
|
+
}
|
|
668
|
+
catch (error) {
|
|
669
|
+
if (error instanceof HttpError && (error.status === 404 || error.status === 405)) {
|
|
670
|
+
throw new Error('This server does not expose agent claims yet. Start the agent and use the website, or update the server.');
|
|
671
|
+
}
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
export async function cmdStatus(flags, logger) {
|
|
676
|
+
const cfg = await resolvedConfig(flags, true);
|
|
677
|
+
const me = await verifyHostKey(cfg.server, cfg.token);
|
|
678
|
+
if (flags.json) {
|
|
679
|
+
output.write(`${JSON.stringify(me, null, 2)}\n`);
|
|
680
|
+
return 0;
|
|
681
|
+
}
|
|
682
|
+
if (!me) {
|
|
683
|
+
logger.warn('Server does not expose /api/agent/me. Try: sublera doctor');
|
|
684
|
+
return 0;
|
|
685
|
+
}
|
|
686
|
+
const host = me.host;
|
|
687
|
+
// Text from the server is printed without control characters.
|
|
688
|
+
const text = (value) => printable(String(value ?? ''));
|
|
689
|
+
output.write(`\n${BRAND} host status\n`);
|
|
690
|
+
output.write(`user @${text(me.user.username)}\n`);
|
|
691
|
+
output.write(`server ${cfg.server}\n`);
|
|
692
|
+
output.write(`host ${host ? `${text(host.status)} · ${text(host.kind)} · ${text(host.gpuName)}` : 'not registered yet'}\n`);
|
|
693
|
+
// Only the server decides a hold (two strikes, or low reliability after repeated failures); never guess from the score.
|
|
694
|
+
output.write(`reliability ${host ? formatPercent(host.uptimePct) : '-'}${host?.status === 'held' ? ` HELD${host.flagReason ? ` (${text(host.flagReason)})` : ''}` : ''}\n`);
|
|
695
|
+
output.write(`model ${text(host?.modelLabel) || '-'} ${text(host?.quant)}\n`);
|
|
696
|
+
output.write(`speed ${formatSpeed(host?.tokPerSec)}\n`);
|
|
697
|
+
output.write(`wallet ${formatUsdNanos(me.wallet.availableNanos)} available · ${formatUsdNanos(me.wallet.pendingNanos)} pending\n`);
|
|
698
|
+
output.write(statusBookingLines(me));
|
|
699
|
+
return 0;
|
|
700
|
+
}
|
|
701
|
+
/** One `booking` line per live request: id, status, reserved speed, time left. */
|
|
702
|
+
export function statusBookingLines(me, now = Date.now()) {
|
|
703
|
+
const rows = activeRequestsOf(me);
|
|
704
|
+
let text = rows.length > 1 ? `bookings ${rows.length} active\n` : '';
|
|
705
|
+
// activeRequestsOf drops rows with a malformed id; the status text from the server is printed without control characters.
|
|
706
|
+
for (const active of rows) {
|
|
707
|
+
const remaining = active.timerEndsAt ? Math.max(0, Math.round((active.timerEndsAt - now) / 1000)) : null;
|
|
708
|
+
const speed = active.tokS && active.tokS > 0 ? `${active.tokS} tok/s seat` : active.shared ? 'shared seat' : 'full GPU';
|
|
709
|
+
const known = active.tokS !== undefined || active.shared != null;
|
|
710
|
+
text += `booking ${printable(active.id)} · ${printable(active.status)}${known ? ` · ${speed}` : ''}${remaining != null ? ` · ${formatDuration(remaining)} left` : ''}\n`;
|
|
711
|
+
}
|
|
712
|
+
return text;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* `sublera config` (or `config show`): the settings this agent will actually use and where each one came from.
|
|
716
|
+
* `config path` prints the agent.json path. Secrets are always masked.
|
|
717
|
+
*/
|
|
718
|
+
export async function cmdConfig(flags, _logger, resolved) {
|
|
719
|
+
const sub = (flags.positionals[0] || 'show').toLowerCase();
|
|
720
|
+
if (sub === 'path') {
|
|
721
|
+
output.write(flags.json ? `${JSON.stringify({ settings: resolved.file, config: configPath(), home: subletHome() })}\n` : `${resolved.file}\n`);
|
|
722
|
+
return EXIT.OK;
|
|
723
|
+
}
|
|
724
|
+
if (sub !== 'show')
|
|
725
|
+
throw new UsageError(`Unknown config action "${sub}". Use: sublera config [show|path]`);
|
|
726
|
+
const saved = await readConfigFile();
|
|
727
|
+
const rows = SETTINGS_SPEC.map((row) => {
|
|
728
|
+
let value = resolved.values[row.key];
|
|
729
|
+
let source = resolved.sources[row.key] ?? 'default';
|
|
730
|
+
if (row.key === 'server' && value === undefined) {
|
|
731
|
+
value = saved?.server || DEFAULT_SERVER;
|
|
732
|
+
source = saved?.server ? 'login' : 'default';
|
|
733
|
+
}
|
|
734
|
+
if (row.key === 'token' && value === undefined && saved?.token) {
|
|
735
|
+
value = saved.token;
|
|
736
|
+
source = 'login';
|
|
737
|
+
}
|
|
738
|
+
if (row.key === 'wsPath' && value === undefined)
|
|
739
|
+
value = saved?.wsPath || DEFAULT_WS_PATH;
|
|
740
|
+
if (row.key === 'logFile' && value === undefined)
|
|
741
|
+
value = join(logsDir(), 'agent.log');
|
|
742
|
+
const shown = row.secret ? maskToken(typeof value === 'string' ? value : null) : value ?? null;
|
|
743
|
+
return { key: row.key, env: row.env.replace(/^SUBLET_/, 'SUBLERA_'), value: shown, source };
|
|
744
|
+
});
|
|
745
|
+
const proxy = proxySettings();
|
|
746
|
+
const network = {
|
|
747
|
+
httpsProxy: proxy.httpsProxy ? redactProxy(proxy.httpsProxy) : null,
|
|
748
|
+
httpProxy: proxy.httpProxy ? redactProxy(proxy.httpProxy) : null,
|
|
749
|
+
noProxy: proxy.noProxy,
|
|
750
|
+
extraCaCerts: process.env.NODE_EXTRA_CA_CERTS || null,
|
|
751
|
+
};
|
|
752
|
+
if (flags.json) {
|
|
753
|
+
output.write(`${JSON.stringify({ schemaVersion: 1, home: subletHome(), settingsFile: resolved.file, config: configPath(), settings: rows, network }, null, 2)}\n`);
|
|
754
|
+
return EXIT.OK;
|
|
755
|
+
}
|
|
756
|
+
output.write(`\n${BRAND} agent settings (flag > env > ${resolved.file} > default)\n\n`);
|
|
757
|
+
for (const row of rows) {
|
|
758
|
+
output.write(`${padRight(row.key, 16)} ${padRight(row.value == null ? '-' : String(row.value), 44)} ${padRight(row.source, 8)} ${row.env}\n`);
|
|
759
|
+
}
|
|
760
|
+
output.write(`\nnetwork\n`);
|
|
761
|
+
output.write(`${padRight('HTTPS_PROXY', 16)} ${network.httpsProxy || '-'}\n`);
|
|
762
|
+
output.write(`${padRight('HTTP_PROXY', 16)} ${network.httpProxy || '-'}\n`);
|
|
763
|
+
output.write(`${padRight('NO_PROXY', 16)} ${network.noProxy.join(',') || '-'}\n`);
|
|
764
|
+
output.write(`${padRight('CA certificates', 16)} ${network.extraCaCerts || 'system default'}\n`);
|
|
765
|
+
return EXIT.OK;
|
|
766
|
+
}
|
|
767
|
+
export async function cmdLogout(flags, logger) {
|
|
768
|
+
const path = configPath();
|
|
769
|
+
const { rm } = await import('node:fs/promises');
|
|
770
|
+
await rm(path, { force: true });
|
|
771
|
+
logger.success('Host key removed', { config: path });
|
|
772
|
+
if (flags.json)
|
|
773
|
+
output.write(`${JSON.stringify({ ok: true, config: path })}\n`);
|
|
774
|
+
return 0;
|
|
775
|
+
}
|
|
776
|
+
export function globalHelp() {
|
|
777
|
+
return [
|
|
778
|
+
`${BRAND} GPU Terminal Agent v${VERSION}`,
|
|
779
|
+
'',
|
|
780
|
+
'Usage: sublera <command> [options]',
|
|
781
|
+
'',
|
|
782
|
+
'Commands:',
|
|
783
|
+
' login Link this computer to your Sublera account (opens the website)',
|
|
784
|
+
' doctor Scan GPU, server, sleep guard, and llama.cpp',
|
|
785
|
+
' start [code] Connect and serve (connects this computer on the first run); with a code, serve that request',
|
|
786
|
+
' host <gguf> Serve a public model: per 1M tokens, plus hourly rentals of the GPU',
|
|
787
|
+
' jobs Browse and claim buyer requests from the terminal',
|
|
788
|
+
' status Show this host, wallet, and every active booking',
|
|
789
|
+
' health [live|ready] Local fresh process/serving readiness (JSON; no server attestation)',
|
|
790
|
+
' storage [status|prune] Inspect storage; prune previews old partials (--force applies)',
|
|
791
|
+
' config [show|path] Show the settings in use and where each came from',
|
|
792
|
+
' logout Remove the saved host key',
|
|
793
|
+
'',
|
|
794
|
+
'Core options:',
|
|
795
|
+
' --key <key> Host key (sh_...) instead of the saved key',
|
|
796
|
+
` --server <url> Central server (default: ${DEFAULT_SERVER})`,
|
|
797
|
+
' --ws-path <path> Tunnel path (default: /tunnel)',
|
|
798
|
+
' --simulation Test the full pipe without a GPU or GGUF',
|
|
799
|
+
' --model <url|path> Load a model on start',
|
|
800
|
+
' --ctx <tokens> Locked context window (default: 8192)',
|
|
801
|
+
' --slots <n> Parallel slots (public mode default: 4)',
|
|
802
|
+
' --price <usd> Public price per 1M output tokens',
|
|
803
|
+
' --price-in <usd> Public price per 1M input tokens',
|
|
804
|
+
' --price-out <usd> Public price per 1M output tokens',
|
|
805
|
+
' --price-hour <usd> host: price per hour for the full GPU (default 0.50,',
|
|
806
|
+
' 0 = no hourly rentals, max 50); seats cost a share of it',
|
|
807
|
+
' --gpu <name> Correct a wrongly detected GPU name',
|
|
808
|
+
' --vram <mb> Correct detected VRAM in MB',
|
|
809
|
+
' --tok-s <n> Advertised tokens/sec',
|
|
810
|
+
' --fix Download llama-server when doctor needs it',
|
|
811
|
+
' --require-checksum Fail if llama-server has no published SHA-256',
|
|
812
|
+
' --no-browser login: print the link instead of opening a browser',
|
|
813
|
+
' --non-interactive Never prompt or start device login; requires a configured host key',
|
|
814
|
+
' --drain-timeout <ms> SIGTERM inference grace period (default 30000, max 300000)',
|
|
815
|
+
' --claim <request-id> Claim a board request non-interactively',
|
|
816
|
+
' --json Machine-readable output',
|
|
817
|
+
' --verbose Debug logs (same as --log-level debug)',
|
|
818
|
+
' --log-level <level> debug, info, warn, error, or silent',
|
|
819
|
+
' --log-file <path|off> Agent log file (start/host/jobs default: ~/.sublet/logs/agent.log)',
|
|
820
|
+
' --no-color Disable ANSI colors',
|
|
821
|
+
' --no-install Do not auto-download llama-server',
|
|
822
|
+
' --once Exit after the first public job (testing)',
|
|
823
|
+
' --help, --version',
|
|
824
|
+
'',
|
|
825
|
+
'Examples:',
|
|
826
|
+
' sublera login',
|
|
827
|
+
' sublera login --key sh_... (machines without a browser)',
|
|
828
|
+
' sublera doctor --fix',
|
|
829
|
+
' sublera start',
|
|
830
|
+
' sublera start r_abc123 (the command from Take on the website)',
|
|
831
|
+
' sublera start --simulation --gpu "RTX 4060" --vram 8188',
|
|
832
|
+
' sublera host https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF/resolve/main/Qwen2.5-7B-Instruct-Q4_K_M.gguf --price 0.25 --price-hour 0.5',
|
|
833
|
+
' sublera jobs',
|
|
834
|
+
'',
|
|
835
|
+
'Supported hosting settings can also come from SUBLERA_* variables or',
|
|
836
|
+
'legacy SUBLET_* aliases (SUBLERA_* wins when both are set).',
|
|
837
|
+
'~/.sublet/agent.json (see: sublera config). Proxies: HTTPS_PROXY, NO_PROXY,',
|
|
838
|
+
'NODE_EXTRA_CA_CERTS. Exit codes: 0 ok, 1 error, 2 usage, 130 Ctrl+C, 143 SIGTERM.',
|
|
839
|
+
].join('\n');
|
|
840
|
+
}
|
|
841
|
+
//# sourceMappingURL=commands.js.map
|