@visorcraft/idlehands 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +30 -0
- package/dist/agent.js +2604 -0
- package/dist/agent.js.map +1 -0
- package/dist/anton/controller.js +341 -0
- package/dist/anton/controller.js.map +1 -0
- package/dist/anton/lock.js +110 -0
- package/dist/anton/lock.js.map +1 -0
- package/dist/anton/parser.js +303 -0
- package/dist/anton/parser.js.map +1 -0
- package/dist/anton/prompt.js +203 -0
- package/dist/anton/prompt.js.map +1 -0
- package/dist/anton/reporter.js +119 -0
- package/dist/anton/reporter.js.map +1 -0
- package/dist/anton/session.js +51 -0
- package/dist/anton/session.js.map +1 -0
- package/dist/anton/types.js +7 -0
- package/dist/anton/types.js.map +1 -0
- package/dist/anton/verifier.js +263 -0
- package/dist/anton/verifier.js.map +1 -0
- package/dist/bench/compare.js +239 -0
- package/dist/bench/compare.js.map +1 -0
- package/dist/bench/debug_hooks.js +17 -0
- package/dist/bench/debug_hooks.js.map +1 -0
- package/dist/bench/json_extract.js +22 -0
- package/dist/bench/json_extract.js.map +1 -0
- package/dist/bench/openclaw.js +86 -0
- package/dist/bench/openclaw.js.map +1 -0
- package/dist/bench/report.js +116 -0
- package/dist/bench/report.js.map +1 -0
- package/dist/bench/runner.js +312 -0
- package/dist/bench/runner.js.map +1 -0
- package/dist/bench/types.js +2 -0
- package/dist/bench/types.js.map +1 -0
- package/dist/bot/commands.js +444 -0
- package/dist/bot/commands.js.map +1 -0
- package/dist/bot/confirm-discord.js +133 -0
- package/dist/bot/confirm-discord.js.map +1 -0
- package/dist/bot/confirm-telegram.js +290 -0
- package/dist/bot/confirm-telegram.js.map +1 -0
- package/dist/bot/discord.js +826 -0
- package/dist/bot/discord.js.map +1 -0
- package/dist/bot/format.js +210 -0
- package/dist/bot/format.js.map +1 -0
- package/dist/bot/session-manager.js +270 -0
- package/dist/bot/session-manager.js.map +1 -0
- package/dist/bot/telegram.js +678 -0
- package/dist/bot/telegram.js.map +1 -0
- package/dist/cli/agent-turn.js +45 -0
- package/dist/cli/agent-turn.js.map +1 -0
- package/dist/cli/args.js +236 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/bot.js +252 -0
- package/dist/cli/bot.js.map +1 -0
- package/dist/cli/build-repl-context.js +365 -0
- package/dist/cli/build-repl-context.js.map +1 -0
- package/dist/cli/command-registry.js +20 -0
- package/dist/cli/command-registry.js.map +1 -0
- package/dist/cli/commands/anton.js +271 -0
- package/dist/cli/commands/anton.js.map +1 -0
- package/dist/cli/commands/editing.js +328 -0
- package/dist/cli/commands/editing.js.map +1 -0
- package/dist/cli/commands/model.js +274 -0
- package/dist/cli/commands/model.js.map +1 -0
- package/dist/cli/commands/project.js +255 -0
- package/dist/cli/commands/project.js.map +1 -0
- package/dist/cli/commands/runtime.js +63 -0
- package/dist/cli/commands/runtime.js.map +1 -0
- package/dist/cli/commands/session.js +281 -0
- package/dist/cli/commands/session.js.map +1 -0
- package/dist/cli/commands/tools.js +126 -0
- package/dist/cli/commands/tools.js.map +1 -0
- package/dist/cli/commands/trifecta.js +221 -0
- package/dist/cli/commands/trifecta.js.map +1 -0
- package/dist/cli/commands/tui.js +17 -0
- package/dist/cli/commands/tui.js.map +1 -0
- package/dist/cli/init.js +222 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/cli/input.js +360 -0
- package/dist/cli/input.js.map +1 -0
- package/dist/cli/oneshot.js +254 -0
- package/dist/cli/oneshot.js.map +1 -0
- package/dist/cli/repl-context.js +2 -0
- package/dist/cli/repl-context.js.map +1 -0
- package/dist/cli/runtime-cmds.js +811 -0
- package/dist/cli/runtime-cmds.js.map +1 -0
- package/dist/cli/service.js +145 -0
- package/dist/cli/service.js.map +1 -0
- package/dist/cli/session-state.js +130 -0
- package/dist/cli/session-state.js.map +1 -0
- package/dist/cli/setup.js +815 -0
- package/dist/cli/setup.js.map +1 -0
- package/dist/cli/shell.js +79 -0
- package/dist/cli/shell.js.map +1 -0
- package/dist/cli/status.js +392 -0
- package/dist/cli/status.js.map +1 -0
- package/dist/cli/watch.js +33 -0
- package/dist/cli/watch.js.map +1 -0
- package/dist/client.js +676 -0
- package/dist/client.js.map +1 -0
- package/dist/commands.js +194 -0
- package/dist/commands.js.map +1 -0
- package/dist/config.js +507 -0
- package/dist/config.js.map +1 -0
- package/dist/confirm/auto.js +13 -0
- package/dist/confirm/auto.js.map +1 -0
- package/dist/confirm/headless.js +41 -0
- package/dist/confirm/headless.js.map +1 -0
- package/dist/confirm/terminal.js +90 -0
- package/dist/confirm/terminal.js.map +1 -0
- package/dist/context.js +49 -0
- package/dist/context.js.map +1 -0
- package/dist/git.js +136 -0
- package/dist/git.js.map +1 -0
- package/dist/harnesses.js +171 -0
- package/dist/harnesses.js.map +1 -0
- package/dist/history.js +139 -0
- package/dist/history.js.map +1 -0
- package/dist/index.js +700 -0
- package/dist/index.js.map +1 -0
- package/dist/indexer.js +374 -0
- package/dist/indexer.js.map +1 -0
- package/dist/jsonrpc.js +76 -0
- package/dist/jsonrpc.js.map +1 -0
- package/dist/lens.js +525 -0
- package/dist/lens.js.map +1 -0
- package/dist/lsp.js +605 -0
- package/dist/lsp.js.map +1 -0
- package/dist/markdown.js +275 -0
- package/dist/markdown.js.map +1 -0
- package/dist/mcp.js +554 -0
- package/dist/mcp.js.map +1 -0
- package/dist/recovery.js +178 -0
- package/dist/recovery.js.map +1 -0
- package/dist/replay.js +132 -0
- package/dist/replay.js.map +1 -0
- package/dist/replay_cli.js +24 -0
- package/dist/replay_cli.js.map +1 -0
- package/dist/runtime/executor.js +418 -0
- package/dist/runtime/executor.js.map +1 -0
- package/dist/runtime/planner.js +197 -0
- package/dist/runtime/planner.js.map +1 -0
- package/dist/runtime/store.js +289 -0
- package/dist/runtime/store.js.map +1 -0
- package/dist/runtime/types.js +2 -0
- package/dist/runtime/types.js.map +1 -0
- package/dist/safety.js +446 -0
- package/dist/safety.js.map +1 -0
- package/dist/spinner.js +224 -0
- package/dist/spinner.js.map +1 -0
- package/dist/sys/context.js +124 -0
- package/dist/sys/context.js.map +1 -0
- package/dist/sys/snapshot.sh +97 -0
- package/dist/term.js +61 -0
- package/dist/term.js.map +1 -0
- package/dist/themes.js +135 -0
- package/dist/themes.js.map +1 -0
- package/dist/tools.js +1114 -0
- package/dist/tools.js.map +1 -0
- package/dist/tui/branch-picker.js +65 -0
- package/dist/tui/branch-picker.js.map +1 -0
- package/dist/tui/command-handler.js +108 -0
- package/dist/tui/command-handler.js.map +1 -0
- package/dist/tui/confirm.js +90 -0
- package/dist/tui/confirm.js.map +1 -0
- package/dist/tui/controller.js +463 -0
- package/dist/tui/controller.js.map +1 -0
- package/dist/tui/event-bridge.js +44 -0
- package/dist/tui/event-bridge.js.map +1 -0
- package/dist/tui/events.js +2 -0
- package/dist/tui/events.js.map +1 -0
- package/dist/tui/keymap.js +144 -0
- package/dist/tui/keymap.js.map +1 -0
- package/dist/tui/layout.js +11 -0
- package/dist/tui/layout.js.map +1 -0
- package/dist/tui/render.js +186 -0
- package/dist/tui/render.js.map +1 -0
- package/dist/tui/screen.js +48 -0
- package/dist/tui/screen.js.map +1 -0
- package/dist/tui/state.js +167 -0
- package/dist/tui/state.js.map +1 -0
- package/dist/tui/theme.js +70 -0
- package/dist/tui/theme.js.map +1 -0
- package/dist/tui/types.js +2 -0
- package/dist/tui/types.js.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/upgrade.js +412 -0
- package/dist/upgrade.js.map +1 -0
- package/dist/utils.js +87 -0
- package/dist/utils.js.map +1 -0
- package/dist/vault.js +520 -0
- package/dist/vault.js.map +1 -0
- package/dist/vim.js +160 -0
- package/dist/vim.js.map +1 -0
- package/package.json +67 -0
- package/src/sys/snapshot.sh +97 -0
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import readline from 'node:readline/promises';
|
|
3
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { loadRuntimes, saveRuntimes, validateRuntimes, redactConfig, bootstrapRuntimes, interpolateTemplate, } from '../runtime/store.js';
|
|
6
|
+
import { configDir, shellEscape } from '../utils.js';
|
|
7
|
+
function runtimesFilePath() {
|
|
8
|
+
return path.join(configDir(), 'runtimes.json');
|
|
9
|
+
}
|
|
10
|
+
function isTTY() {
|
|
11
|
+
return !!(process.stdin.isTTY && process.stdout.isTTY);
|
|
12
|
+
}
|
|
13
|
+
function printList(items) {
|
|
14
|
+
if (process.stdout.isTTY) {
|
|
15
|
+
console.table(items);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
process.stdout.write(JSON.stringify(items, null, 2) + '\n');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function ask(rl, prompt, fallback = '') {
|
|
22
|
+
const q = fallback ? `${prompt} [${fallback}]: ` : `${prompt}: `;
|
|
23
|
+
const ans = (await rl.question(q)).trim();
|
|
24
|
+
return ans || fallback;
|
|
25
|
+
}
|
|
26
|
+
function runLocalCommand(command, timeoutSec = 5) {
|
|
27
|
+
const p = spawnSync('bash', ['-lc', command], { encoding: 'utf8', timeout: timeoutSec * 1000 });
|
|
28
|
+
return {
|
|
29
|
+
ok: p.status === 0,
|
|
30
|
+
code: p.status,
|
|
31
|
+
stdout: p.stdout ?? '',
|
|
32
|
+
stderr: p.stderr ?? '',
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function runHostCommand(host, command, timeoutSec = 5) {
|
|
36
|
+
if (host.transport === 'local')
|
|
37
|
+
return runLocalCommand(command, timeoutSec);
|
|
38
|
+
const target = `${host.connection.user ? `${host.connection.user}@` : ''}${host.connection.host ?? ''}`;
|
|
39
|
+
const sshArgs = [
|
|
40
|
+
'ssh',
|
|
41
|
+
'-o', 'BatchMode=yes',
|
|
42
|
+
'-o', `ConnectTimeout=${timeoutSec}`,
|
|
43
|
+
];
|
|
44
|
+
if (host.connection.port)
|
|
45
|
+
sshArgs.push('-p', String(host.connection.port));
|
|
46
|
+
if (host.connection.key_path)
|
|
47
|
+
sshArgs.push('-i', shellEscape(host.connection.key_path));
|
|
48
|
+
sshArgs.push(shellEscape(target));
|
|
49
|
+
sshArgs.push(shellEscape(command));
|
|
50
|
+
return runLocalCommand(sshArgs.join(' '), timeoutSec + 1);
|
|
51
|
+
}
|
|
52
|
+
function usage(kind) {
|
|
53
|
+
console.log(`Usage:\n idlehands ${kind}\n idlehands ${kind} show <id>\n idlehands ${kind} add\n idlehands ${kind} edit <id>\n idlehands ${kind} remove <id>\n idlehands ${kind} validate\n idlehands ${kind} test <id>\n idlehands ${kind} doctor`);
|
|
54
|
+
}
|
|
55
|
+
export async function runHostsSubcommand(args, _config) {
|
|
56
|
+
await bootstrapRuntimes();
|
|
57
|
+
const cmd = String(args._[1] ?? '').toLowerCase();
|
|
58
|
+
const id = String(args._[2] ?? '');
|
|
59
|
+
const runtimes = await loadRuntimes();
|
|
60
|
+
if (!cmd) {
|
|
61
|
+
const rows = runtimes.hosts.map((h) => ({
|
|
62
|
+
id: h.id,
|
|
63
|
+
name: h.display_name,
|
|
64
|
+
enabled: h.enabled,
|
|
65
|
+
transport: h.transport,
|
|
66
|
+
host: h.connection.host ?? 'local',
|
|
67
|
+
backends: h.capabilities.backends.join(','),
|
|
68
|
+
}));
|
|
69
|
+
printList(rows);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (cmd === 'show') {
|
|
73
|
+
if (!id)
|
|
74
|
+
throw new Error('hosts show requires <id>');
|
|
75
|
+
const redacted = redactConfig(runtimes);
|
|
76
|
+
const host = redacted.hosts.find((h) => h.id === id);
|
|
77
|
+
if (!host)
|
|
78
|
+
throw new Error(`host not found: ${id}`);
|
|
79
|
+
process.stdout.write(JSON.stringify(host, null, 2) + '\n');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (cmd === 'add') {
|
|
83
|
+
if (!isTTY())
|
|
84
|
+
throw new Error('hosts add requires a TTY');
|
|
85
|
+
const rl = readline.createInterface({ input, output });
|
|
86
|
+
try {
|
|
87
|
+
const host = {
|
|
88
|
+
id: await ask(rl, 'Host id (e.g. local-main)'),
|
|
89
|
+
display_name: await ask(rl, 'Display name'),
|
|
90
|
+
enabled: (await ask(rl, 'Enabled (y/n)', 'y')).toLowerCase().startsWith('y'),
|
|
91
|
+
transport: ((await ask(rl, 'Transport (local/ssh)', 'local')).toLowerCase() === 'ssh' ? 'ssh' : 'local'),
|
|
92
|
+
connection: {
|
|
93
|
+
host: undefined,
|
|
94
|
+
port: undefined,
|
|
95
|
+
user: undefined,
|
|
96
|
+
key_path: undefined,
|
|
97
|
+
password: undefined,
|
|
98
|
+
},
|
|
99
|
+
capabilities: {
|
|
100
|
+
gpu: (await ask(rl, 'GPU tags (comma-separated)', '')).split(',').map((s) => s.trim()).filter(Boolean),
|
|
101
|
+
vram_gb: (() => {
|
|
102
|
+
const raw = args['vram-gb'] ?? undefined;
|
|
103
|
+
return raw == null ? undefined : Number(raw);
|
|
104
|
+
})(),
|
|
105
|
+
backends: (await ask(rl, 'Supported backends (comma-separated)', '')).split(',').map((s) => s.trim()).filter(Boolean),
|
|
106
|
+
},
|
|
107
|
+
health: {
|
|
108
|
+
check_cmd: await ask(rl, 'Health check command', 'true'),
|
|
109
|
+
timeout_sec: Number(await ask(rl, 'Health timeout sec', '5')),
|
|
110
|
+
},
|
|
111
|
+
model_control: {
|
|
112
|
+
stop_cmd: await ask(rl, 'Stop model command', 'pkill -f llama-server || true'),
|
|
113
|
+
cleanup_cmd: await ask(rl, 'Cleanup command (optional)', ''),
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
if (host.transport === 'ssh') {
|
|
117
|
+
host.connection.host = await ask(rl, 'SSH host');
|
|
118
|
+
host.connection.user = await ask(rl, 'SSH user', '');
|
|
119
|
+
const p = await ask(rl, 'SSH port', '22');
|
|
120
|
+
host.connection.port = Number(p);
|
|
121
|
+
host.connection.key_path = await ask(rl, 'SSH key path (optional)', '');
|
|
122
|
+
}
|
|
123
|
+
if (!host.model_control.cleanup_cmd)
|
|
124
|
+
host.model_control.cleanup_cmd = null;
|
|
125
|
+
if (!host.connection.user)
|
|
126
|
+
host.connection.user = undefined;
|
|
127
|
+
if (!host.connection.key_path)
|
|
128
|
+
host.connection.key_path = undefined;
|
|
129
|
+
const next = { ...runtimes, hosts: [...runtimes.hosts, host] };
|
|
130
|
+
await saveRuntimes(next);
|
|
131
|
+
console.log(`Added host: ${host.id}`);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
rl.close();
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (cmd === 'edit') {
|
|
139
|
+
if (!id)
|
|
140
|
+
throw new Error('hosts edit requires <id>');
|
|
141
|
+
if (!runtimes.hosts.find((h) => h.id === id))
|
|
142
|
+
throw new Error(`host not found: ${id}`);
|
|
143
|
+
const editor = process.env.EDITOR || 'vi';
|
|
144
|
+
const file = runtimesFilePath();
|
|
145
|
+
await bootstrapRuntimes(file);
|
|
146
|
+
const p = spawnSync(editor, [file], { stdio: 'inherit' });
|
|
147
|
+
if (p.status !== 0)
|
|
148
|
+
throw new Error(`editor exited with code ${p.status}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (cmd === 'remove') {
|
|
152
|
+
if (!id)
|
|
153
|
+
throw new Error('hosts remove requires <id>');
|
|
154
|
+
const hit = runtimes.hosts.find((h) => h.id === id);
|
|
155
|
+
if (!hit)
|
|
156
|
+
throw new Error(`host not found: ${id}`);
|
|
157
|
+
if (!isTTY())
|
|
158
|
+
throw new Error('hosts remove requires a TTY confirmation');
|
|
159
|
+
const rl = readline.createInterface({ input, output });
|
|
160
|
+
try {
|
|
161
|
+
const ans = (await rl.question(`Remove host '${id}'? [y/N] `)).trim().toLowerCase();
|
|
162
|
+
if (ans !== 'y' && ans !== 'yes') {
|
|
163
|
+
console.log('Cancelled.');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
finally {
|
|
168
|
+
rl.close();
|
|
169
|
+
}
|
|
170
|
+
await saveRuntimes({ ...runtimes, hosts: runtimes.hosts.filter((h) => h.id !== id) });
|
|
171
|
+
console.log(`Removed host: ${id}`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (cmd === 'validate') {
|
|
175
|
+
try {
|
|
176
|
+
validateRuntimes(runtimes);
|
|
177
|
+
console.log('runtimes.json is valid.');
|
|
178
|
+
process.exitCode = 0;
|
|
179
|
+
}
|
|
180
|
+
catch (e) {
|
|
181
|
+
console.error(`invalid runtimes config: ${e?.message ?? String(e)}`);
|
|
182
|
+
process.exitCode = 1;
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (cmd === 'test') {
|
|
187
|
+
if (!id)
|
|
188
|
+
throw new Error('hosts test requires <id>');
|
|
189
|
+
const host = runtimes.hosts.find((h) => h.id === id);
|
|
190
|
+
if (!host)
|
|
191
|
+
throw new Error(`host not found: ${id}`);
|
|
192
|
+
const command = interpolateTemplate(host.health.check_cmd, {
|
|
193
|
+
host: host.connection.host,
|
|
194
|
+
host_id: host.id,
|
|
195
|
+
backend_args: '',
|
|
196
|
+
backend_env: '',
|
|
197
|
+
backend_id: '',
|
|
198
|
+
model_id: '',
|
|
199
|
+
source: '',
|
|
200
|
+
port: '',
|
|
201
|
+
});
|
|
202
|
+
const res = runHostCommand(host, command, host.health.timeout_sec ?? 5);
|
|
203
|
+
console.log(`[${host.id}] ${res.ok ? 'OK' : 'FAIL'} (exit=${res.code ?? -1})`);
|
|
204
|
+
if (res.stdout.trim())
|
|
205
|
+
console.log(res.stdout.trim());
|
|
206
|
+
if (res.stderr.trim())
|
|
207
|
+
console.error(res.stderr.trim());
|
|
208
|
+
if (!res.ok)
|
|
209
|
+
process.exitCode = 1;
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (cmd === 'doctor') {
|
|
213
|
+
const problems = [];
|
|
214
|
+
for (const host of runtimes.hosts) {
|
|
215
|
+
if (!host.enabled)
|
|
216
|
+
continue;
|
|
217
|
+
if (host.transport === 'ssh') {
|
|
218
|
+
if (!host.connection.host) {
|
|
219
|
+
problems.push(`[${host.id}] ssh host is missing`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const ping = runHostCommand(host, 'echo ok', 5);
|
|
223
|
+
if (!ping.ok)
|
|
224
|
+
problems.push(`[${host.id}] ssh unreachable (${ping.code ?? -1})`);
|
|
225
|
+
}
|
|
226
|
+
const checkCmd = interpolateTemplate(host.health.check_cmd, {
|
|
227
|
+
host: host.connection.host,
|
|
228
|
+
host_id: host.id,
|
|
229
|
+
backend_args: '',
|
|
230
|
+
backend_env: '',
|
|
231
|
+
backend_id: '',
|
|
232
|
+
model_id: '',
|
|
233
|
+
source: '',
|
|
234
|
+
port: '',
|
|
235
|
+
});
|
|
236
|
+
const firstToken = checkCmd.trim().split(/\s+/)[0] || '';
|
|
237
|
+
if (firstToken) {
|
|
238
|
+
const checkBin = runLocalCommand(`command -v ${shellEscape(firstToken)} >/dev/null 2>&1`, 3);
|
|
239
|
+
if (!checkBin.ok)
|
|
240
|
+
problems.push(`[${host.id}] missing local binary: ${firstToken}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (!problems.length) {
|
|
244
|
+
console.log('Doctor: no obvious runtime host issues found.');
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
for (const p of problems)
|
|
248
|
+
console.log(`- ${p}`);
|
|
249
|
+
process.exitCode = 1;
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
usage('hosts');
|
|
253
|
+
}
|
|
254
|
+
export async function runBackendsSubcommand(args, _config) {
|
|
255
|
+
await bootstrapRuntimes();
|
|
256
|
+
const cmd = String(args._[1] ?? '').toLowerCase();
|
|
257
|
+
const id = String(args._[2] ?? '');
|
|
258
|
+
const runtimes = await loadRuntimes();
|
|
259
|
+
if (!cmd) {
|
|
260
|
+
printList(runtimes.backends.map((b) => ({
|
|
261
|
+
id: b.id,
|
|
262
|
+
name: b.display_name,
|
|
263
|
+
enabled: b.enabled,
|
|
264
|
+
type: b.type,
|
|
265
|
+
host_filters: b.host_filters === 'any' ? 'any' : b.host_filters.join(','),
|
|
266
|
+
})));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (cmd === 'show') {
|
|
270
|
+
if (!id)
|
|
271
|
+
throw new Error('backends show requires <id>');
|
|
272
|
+
const backend = runtimes.backends.find((b) => b.id === id);
|
|
273
|
+
if (!backend)
|
|
274
|
+
throw new Error(`backend not found: ${id}`);
|
|
275
|
+
process.stdout.write(JSON.stringify(backend, null, 2) + '\n');
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (cmd === 'add') {
|
|
279
|
+
if (!isTTY())
|
|
280
|
+
throw new Error('backends add requires a TTY');
|
|
281
|
+
const rl = readline.createInterface({ input, output });
|
|
282
|
+
try {
|
|
283
|
+
const backend = {
|
|
284
|
+
id: await ask(rl, 'Backend id'),
|
|
285
|
+
display_name: await ask(rl, 'Display name'),
|
|
286
|
+
enabled: (await ask(rl, 'Enabled (y/n)', 'y')).toLowerCase().startsWith('y'),
|
|
287
|
+
type: (await ask(rl, 'Type (vulkan|rocm|cuda|metal|cpu|custom)', 'custom')),
|
|
288
|
+
host_filters: (() => {
|
|
289
|
+
const raw = (args.hosts ?? '').toString();
|
|
290
|
+
return raw ? raw.split(',').map((s) => s.trim()).filter(Boolean) : 'any';
|
|
291
|
+
})(),
|
|
292
|
+
apply_cmd: await ask(rl, 'Apply command (optional)', ''),
|
|
293
|
+
verify_cmd: await ask(rl, 'Verify command (optional)', ''),
|
|
294
|
+
rollback_cmd: await ask(rl, 'Rollback command (optional)', ''),
|
|
295
|
+
env: undefined,
|
|
296
|
+
args: undefined,
|
|
297
|
+
};
|
|
298
|
+
const filters = await ask(rl, 'Host filters (any or comma-separated ids)', 'any');
|
|
299
|
+
backend.host_filters = filters === 'any' ? 'any' : filters.split(',').map((s) => s.trim()).filter(Boolean);
|
|
300
|
+
if (!backend.apply_cmd)
|
|
301
|
+
backend.apply_cmd = null;
|
|
302
|
+
if (!backend.verify_cmd)
|
|
303
|
+
backend.verify_cmd = null;
|
|
304
|
+
if (!backend.rollback_cmd)
|
|
305
|
+
backend.rollback_cmd = null;
|
|
306
|
+
await saveRuntimes({ ...runtimes, backends: [...runtimes.backends, backend] });
|
|
307
|
+
console.log(`Added backend: ${backend.id}`);
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
rl.close();
|
|
311
|
+
}
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (cmd === 'edit') {
|
|
315
|
+
if (!id)
|
|
316
|
+
throw new Error('backends edit requires <id>');
|
|
317
|
+
if (!runtimes.backends.find((b) => b.id === id))
|
|
318
|
+
throw new Error(`backend not found: ${id}`);
|
|
319
|
+
const editor = process.env.EDITOR || 'vi';
|
|
320
|
+
const p = spawnSync(editor, [runtimesFilePath()], { stdio: 'inherit' });
|
|
321
|
+
if (p.status !== 0)
|
|
322
|
+
throw new Error(`editor exited with code ${p.status}`);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (cmd === 'remove') {
|
|
326
|
+
if (!id)
|
|
327
|
+
throw new Error('backends remove requires <id>');
|
|
328
|
+
if (!runtimes.backends.find((b) => b.id === id))
|
|
329
|
+
throw new Error(`backend not found: ${id}`);
|
|
330
|
+
if (!isTTY())
|
|
331
|
+
throw new Error('backends remove requires a TTY confirmation');
|
|
332
|
+
const rl = readline.createInterface({ input, output });
|
|
333
|
+
try {
|
|
334
|
+
const ans = (await rl.question(`Remove backend '${id}'? [y/N] `)).trim().toLowerCase();
|
|
335
|
+
if (ans !== 'y' && ans !== 'yes') {
|
|
336
|
+
console.log('Cancelled.');
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
rl.close();
|
|
342
|
+
}
|
|
343
|
+
await saveRuntimes({ ...runtimes, backends: runtimes.backends.filter((b) => b.id !== id) });
|
|
344
|
+
console.log(`Removed backend: ${id}`);
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (cmd === 'validate') {
|
|
348
|
+
try {
|
|
349
|
+
validateRuntimes(runtimes);
|
|
350
|
+
console.log('runtimes.json is valid.');
|
|
351
|
+
process.exitCode = 0;
|
|
352
|
+
}
|
|
353
|
+
catch (e) {
|
|
354
|
+
console.error(`invalid runtimes config: ${e?.message ?? String(e)}`);
|
|
355
|
+
process.exitCode = 1;
|
|
356
|
+
}
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (cmd === 'test') {
|
|
360
|
+
if (!id)
|
|
361
|
+
throw new Error('backends test requires <id>');
|
|
362
|
+
const backend = runtimes.backends.find((b) => b.id === id);
|
|
363
|
+
if (!backend)
|
|
364
|
+
throw new Error(`backend not found: ${id}`);
|
|
365
|
+
if (!backend.verify_cmd) {
|
|
366
|
+
console.log(`[${backend.id}] no verify_cmd configured.`);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const cmdText = interpolateTemplate(backend.verify_cmd, {
|
|
370
|
+
backend_id: backend.id,
|
|
371
|
+
backend_args: (backend.args ?? []).join(' '),
|
|
372
|
+
backend_env: Object.entries(backend.env ?? {}).map(([k, v]) => `${k}=${v}`).join(' '),
|
|
373
|
+
host: '', host_id: '', model_id: '', source: '', port: '',
|
|
374
|
+
});
|
|
375
|
+
const res = runLocalCommand(cmdText, 8);
|
|
376
|
+
console.log(`[${backend.id}] ${res.ok ? 'OK' : 'FAIL'} (exit=${res.code ?? -1})`);
|
|
377
|
+
if (res.stdout.trim())
|
|
378
|
+
console.log(res.stdout.trim());
|
|
379
|
+
if (res.stderr.trim())
|
|
380
|
+
console.error(res.stderr.trim());
|
|
381
|
+
if (!res.ok)
|
|
382
|
+
process.exitCode = 1;
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (cmd === 'doctor') {
|
|
386
|
+
const problems = [];
|
|
387
|
+
for (const b of runtimes.backends) {
|
|
388
|
+
if (!b.enabled)
|
|
389
|
+
continue;
|
|
390
|
+
for (const c of [b.apply_cmd, b.verify_cmd, b.rollback_cmd]) {
|
|
391
|
+
if (!c)
|
|
392
|
+
continue;
|
|
393
|
+
const token = c.trim().split(/\s+/)[0] || '';
|
|
394
|
+
if (!token)
|
|
395
|
+
continue;
|
|
396
|
+
const bin = runLocalCommand(`command -v ${shellEscape(token)} >/dev/null 2>&1`, 2);
|
|
397
|
+
if (!bin.ok)
|
|
398
|
+
problems.push(`[${b.id}] missing local binary: ${token}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (!problems.length) {
|
|
402
|
+
console.log('Doctor: no obvious backend issues found.');
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
for (const p of problems)
|
|
406
|
+
console.log(`- ${p}`);
|
|
407
|
+
process.exitCode = 1;
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
usage('backends');
|
|
411
|
+
}
|
|
412
|
+
export async function runModelsSubcommand(args, _config) {
|
|
413
|
+
await bootstrapRuntimes();
|
|
414
|
+
const cmd = String(args._[1] ?? '').toLowerCase();
|
|
415
|
+
const id = String(args._[2] ?? '');
|
|
416
|
+
const runtimes = await loadRuntimes();
|
|
417
|
+
if (!cmd) {
|
|
418
|
+
printList(runtimes.models.map((m) => ({
|
|
419
|
+
id: m.id,
|
|
420
|
+
name: m.display_name,
|
|
421
|
+
enabled: m.enabled,
|
|
422
|
+
source: m.source,
|
|
423
|
+
hosts: m.host_policy === 'any' ? 'any' : m.host_policy.join(','),
|
|
424
|
+
backends: m.backend_policy === 'any' ? 'any' : m.backend_policy.join(','),
|
|
425
|
+
})));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (cmd === 'show') {
|
|
429
|
+
if (!id)
|
|
430
|
+
throw new Error('models show requires <id>');
|
|
431
|
+
const model = runtimes.models.find((m) => m.id === id);
|
|
432
|
+
if (!model)
|
|
433
|
+
throw new Error(`model not found: ${id}`);
|
|
434
|
+
process.stdout.write(JSON.stringify(model, null, 2) + '\n');
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (cmd === 'add') {
|
|
438
|
+
if (!isTTY())
|
|
439
|
+
throw new Error('models add requires a TTY');
|
|
440
|
+
const rl = readline.createInterface({ input, output });
|
|
441
|
+
try {
|
|
442
|
+
const model = {
|
|
443
|
+
id: await ask(rl, 'Model id'),
|
|
444
|
+
display_name: await ask(rl, 'Display name'),
|
|
445
|
+
enabled: (await ask(rl, 'Enabled (y/n)', 'y')).toLowerCase().startsWith('y'),
|
|
446
|
+
source: await ask(rl, 'Model source (path or URL)'),
|
|
447
|
+
host_policy: 'any',
|
|
448
|
+
backend_policy: 'any',
|
|
449
|
+
launch: {
|
|
450
|
+
start_cmd: await ask(rl, 'Start command'),
|
|
451
|
+
probe_cmd: await ask(rl, 'Probe command', 'curl -fsS http://127.0.0.1:{port}/health'),
|
|
452
|
+
probe_timeout_sec: Number(await ask(rl, 'Probe timeout sec', '60')),
|
|
453
|
+
probe_interval_ms: Number(await ask(rl, 'Probe interval ms', '1000')),
|
|
454
|
+
},
|
|
455
|
+
runtime_defaults: {
|
|
456
|
+
port: Number(await ask(rl, 'Default port', '8080')),
|
|
457
|
+
},
|
|
458
|
+
split_policy: null,
|
|
459
|
+
};
|
|
460
|
+
const hp = await ask(rl, 'Host policy (any or comma-separated ids)', 'any');
|
|
461
|
+
const bp = await ask(rl, 'Backend policy (any or comma-separated ids)', 'any');
|
|
462
|
+
model.host_policy = hp === 'any' ? 'any' : hp.split(',').map((s) => s.trim()).filter(Boolean);
|
|
463
|
+
model.backend_policy = bp === 'any' ? 'any' : bp.split(',').map((s) => s.trim()).filter(Boolean);
|
|
464
|
+
await saveRuntimes({ ...runtimes, models: [...runtimes.models, model] });
|
|
465
|
+
console.log(`Added model: ${model.id}`);
|
|
466
|
+
}
|
|
467
|
+
finally {
|
|
468
|
+
rl.close();
|
|
469
|
+
}
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (cmd === 'edit') {
|
|
473
|
+
if (!id)
|
|
474
|
+
throw new Error('models edit requires <id>');
|
|
475
|
+
if (!runtimes.models.find((m) => m.id === id))
|
|
476
|
+
throw new Error(`model not found: ${id}`);
|
|
477
|
+
const editor = process.env.EDITOR || 'vi';
|
|
478
|
+
const p = spawnSync(editor, [runtimesFilePath()], { stdio: 'inherit' });
|
|
479
|
+
if (p.status !== 0)
|
|
480
|
+
throw new Error(`editor exited with code ${p.status}`);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
if (cmd === 'remove') {
|
|
484
|
+
if (!id)
|
|
485
|
+
throw new Error('models remove requires <id>');
|
|
486
|
+
if (!runtimes.models.find((m) => m.id === id))
|
|
487
|
+
throw new Error(`model not found: ${id}`);
|
|
488
|
+
if (!isTTY())
|
|
489
|
+
throw new Error('models remove requires a TTY confirmation');
|
|
490
|
+
const rl = readline.createInterface({ input, output });
|
|
491
|
+
try {
|
|
492
|
+
const ans = (await rl.question(`Remove model '${id}'? [y/N] `)).trim().toLowerCase();
|
|
493
|
+
if (ans !== 'y' && ans !== 'yes') {
|
|
494
|
+
console.log('Cancelled.');
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
finally {
|
|
499
|
+
rl.close();
|
|
500
|
+
}
|
|
501
|
+
await saveRuntimes({ ...runtimes, models: runtimes.models.filter((m) => m.id !== id) });
|
|
502
|
+
console.log(`Removed model: ${id}`);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (cmd === 'validate') {
|
|
506
|
+
try {
|
|
507
|
+
validateRuntimes(runtimes);
|
|
508
|
+
console.log('runtimes.json is valid.');
|
|
509
|
+
process.exitCode = 0;
|
|
510
|
+
}
|
|
511
|
+
catch (e) {
|
|
512
|
+
console.error(`invalid runtimes config: ${e?.message ?? String(e)}`);
|
|
513
|
+
process.exitCode = 1;
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (cmd === 'test') {
|
|
518
|
+
if (!id)
|
|
519
|
+
throw new Error('models test requires <id>');
|
|
520
|
+
const model = runtimes.models.find((m) => m.id === id);
|
|
521
|
+
if (!model)
|
|
522
|
+
throw new Error(`model not found: ${id}`);
|
|
523
|
+
const host = model.host_policy === 'any'
|
|
524
|
+
? runtimes.hosts.find((h) => h.enabled)
|
|
525
|
+
: model.host_policy.map((hid) => runtimes.hosts.find((h) => h.id === hid && h.enabled)).find(Boolean);
|
|
526
|
+
if (!host)
|
|
527
|
+
throw new Error(`no eligible host for model: ${model.id}`);
|
|
528
|
+
const backend = model.backend_policy === 'any'
|
|
529
|
+
? runtimes.backends.find((b) => b.enabled)
|
|
530
|
+
: model.backend_policy.map((bid) => runtimes.backends.find((b) => b.id === bid && b.enabled)).find(Boolean);
|
|
531
|
+
const port = model.runtime_defaults?.port ?? 8080;
|
|
532
|
+
const backendArgs = backend?.args?.map((a) => shellEscape(a)).join(' ') ?? '';
|
|
533
|
+
const backendEnv = backend?.env
|
|
534
|
+
? Object.entries(backend.env).map(([k, v]) => `${k}=${shellEscape(String(v))}`).join(' ')
|
|
535
|
+
: '';
|
|
536
|
+
const cmdText = interpolateTemplate(model.launch.probe_cmd, {
|
|
537
|
+
model_id: model.id,
|
|
538
|
+
source: model.source,
|
|
539
|
+
port,
|
|
540
|
+
backend_args: backendArgs,
|
|
541
|
+
backend_env: backendEnv,
|
|
542
|
+
backend_id: backend?.id ?? '',
|
|
543
|
+
host: host.connection.host ?? host.id,
|
|
544
|
+
host_id: host.id,
|
|
545
|
+
});
|
|
546
|
+
const res = runHostCommand(host, cmdText, model.launch.probe_timeout_sec ?? 60);
|
|
547
|
+
console.log(`[${model.id}] host=${host.id} ${res.ok ? 'OK' : 'FAIL'} (exit=${res.code ?? -1})`);
|
|
548
|
+
if (res.stdout.trim())
|
|
549
|
+
console.log(res.stdout.trim());
|
|
550
|
+
if (res.stderr.trim())
|
|
551
|
+
console.error(res.stderr.trim());
|
|
552
|
+
if (!res.ok)
|
|
553
|
+
process.exitCode = 1;
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
if (cmd === 'doctor') {
|
|
557
|
+
const problems = [];
|
|
558
|
+
const backendIds = new Set(runtimes.backends.map((b) => b.id));
|
|
559
|
+
const hostIds = new Set(runtimes.hosts.map((h) => h.id));
|
|
560
|
+
for (const m of runtimes.models) {
|
|
561
|
+
if (!m.enabled)
|
|
562
|
+
continue;
|
|
563
|
+
if (m.host_policy !== 'any') {
|
|
564
|
+
for (const h of m.host_policy)
|
|
565
|
+
if (!hostIds.has(h))
|
|
566
|
+
problems.push(`[${m.id}] unknown host in host_policy: ${h}`);
|
|
567
|
+
}
|
|
568
|
+
if (m.backend_policy !== 'any') {
|
|
569
|
+
for (const b of m.backend_policy)
|
|
570
|
+
if (!backendIds.has(b))
|
|
571
|
+
problems.push(`[${m.id}] unknown backend in backend_policy: ${b}`);
|
|
572
|
+
}
|
|
573
|
+
for (const c of [m.launch.start_cmd, m.launch.probe_cmd]) {
|
|
574
|
+
const token = c.trim().split(/\s+/)[0] || '';
|
|
575
|
+
const bin = runLocalCommand(`command -v ${shellEscape(token)} >/dev/null 2>&1`, 2);
|
|
576
|
+
if (!bin.ok)
|
|
577
|
+
problems.push(`[${m.id}] missing local binary: ${token}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (!problems.length) {
|
|
581
|
+
console.log('Doctor: no obvious model issues found.');
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
for (const p of problems)
|
|
585
|
+
console.log(`- ${p}`);
|
|
586
|
+
process.exitCode = 1;
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
usage('models');
|
|
590
|
+
}
|
|
591
|
+
export async function runSelectSubcommand(args, _config) {
|
|
592
|
+
const modelId = typeof args.model === 'string' ? args.model : undefined;
|
|
593
|
+
const backendOverride = typeof args.backend === 'string' ? args.backend : undefined;
|
|
594
|
+
const hostOverride = typeof args.host === 'string' ? args.host : undefined;
|
|
595
|
+
const dryRun = !!(args['dry-run'] ?? args.dry_run);
|
|
596
|
+
const jsonOut = !!args.json;
|
|
597
|
+
// Status subcommand
|
|
598
|
+
if (args._?.[1] === 'status') {
|
|
599
|
+
const { loadActiveRuntime } = await import('../runtime/executor.js');
|
|
600
|
+
const active = await loadActiveRuntime();
|
|
601
|
+
if (!active) {
|
|
602
|
+
console.log('No active runtime.');
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
if (jsonOut) {
|
|
606
|
+
console.log(JSON.stringify(active, null, 2));
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
console.log(`Active runtime:`);
|
|
610
|
+
console.log(` Model: ${active.modelId}`);
|
|
611
|
+
if (active.backendId)
|
|
612
|
+
console.log(` Backend: ${active.backendId}`);
|
|
613
|
+
console.log(` Hosts: ${active.hostIds.join(', ')}`);
|
|
614
|
+
console.log(` Healthy: ${active.healthy ? 'yes' : 'no'}`);
|
|
615
|
+
if (active.endpoint)
|
|
616
|
+
console.log(` Endpoint: ${active.endpoint}`);
|
|
617
|
+
console.log(` Started: ${active.startedAt}`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const force = !!args.force;
|
|
623
|
+
if (!modelId) {
|
|
624
|
+
console.log('Usage: idlehands select --model <id> [--backend <id>] [--host <id>] [--dry-run] [--json] [--force]');
|
|
625
|
+
console.log(' idlehands select status');
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
const { plan } = await import('../runtime/planner.js');
|
|
629
|
+
const { execute, loadActiveRuntime } = await import('../runtime/executor.js');
|
|
630
|
+
const rtConfig = await loadRuntimes();
|
|
631
|
+
const active = await loadActiveRuntime();
|
|
632
|
+
const mode = dryRun ? 'dry-run' : 'live';
|
|
633
|
+
const result = plan({ modelId, backendOverride, hostOverride, mode }, rtConfig, active);
|
|
634
|
+
if (!result.ok) {
|
|
635
|
+
if (jsonOut) {
|
|
636
|
+
console.log(JSON.stringify(result, null, 2));
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
console.error(`Plan failed: ${result.reason} (${result.code})`);
|
|
640
|
+
}
|
|
641
|
+
process.exitCode = 1;
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (dryRun) {
|
|
645
|
+
if (jsonOut) {
|
|
646
|
+
console.log(JSON.stringify(result, null, 2));
|
|
647
|
+
}
|
|
648
|
+
else {
|
|
649
|
+
console.log(`Plan for model "${result.model.display_name}":`);
|
|
650
|
+
if (result.reuse) {
|
|
651
|
+
console.log(' → Current runtime matches. No changes needed.');
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
for (const step of result.steps) {
|
|
655
|
+
console.log(` [${step.kind}] ${step.description} (timeout: ${step.timeout_sec}s)`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
// Live execution
|
|
662
|
+
const rl = await import('node:readline/promises');
|
|
663
|
+
const iface = rl.createInterface({ input: process.stdin, output: process.stdout });
|
|
664
|
+
const execResult = await execute(result, {
|
|
665
|
+
onStep: (step, status) => {
|
|
666
|
+
if (status === 'start')
|
|
667
|
+
process.stdout.write(` ${step.description}...`);
|
|
668
|
+
else if (status === 'done')
|
|
669
|
+
process.stdout.write(' ✓\n');
|
|
670
|
+
else if (status === 'error')
|
|
671
|
+
process.stdout.write(' ✗\n');
|
|
672
|
+
},
|
|
673
|
+
confirm: async (prompt) => {
|
|
674
|
+
const ans = (await iface.question(`${prompt} [y/N] `)).trim().toLowerCase();
|
|
675
|
+
return ans === 'y' || ans === 'yes';
|
|
676
|
+
},
|
|
677
|
+
force,
|
|
678
|
+
});
|
|
679
|
+
iface.close();
|
|
680
|
+
if (jsonOut) {
|
|
681
|
+
console.log(JSON.stringify(execResult, null, 2));
|
|
682
|
+
}
|
|
683
|
+
else if (execResult.ok) {
|
|
684
|
+
if (execResult.reused) {
|
|
685
|
+
console.log('Runtime already active and healthy. No changes needed.');
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
console.log(`Runtime switched to "${result.model.display_name}" successfully.`);
|
|
689
|
+
}
|
|
690
|
+
// Show the derived endpoint so the user knows where requests will go
|
|
691
|
+
const { loadActiveRuntime: loadAR } = await import('../runtime/executor.js');
|
|
692
|
+
const activeNow = await loadAR();
|
|
693
|
+
if (activeNow?.endpoint) {
|
|
694
|
+
console.log(`Endpoint: ${activeNow.endpoint}`);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
console.error(`Execution failed: ${execResult.error || 'unknown error'}`);
|
|
699
|
+
process.exitCode = 1;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
// ── Health check ──────────────────────────────────────────────────────
|
|
703
|
+
const GREEN = '\x1b[32m';
|
|
704
|
+
const RED = '\x1b[31m';
|
|
705
|
+
const DIM = '\x1b[2m';
|
|
706
|
+
const BOLD = '\x1b[1m';
|
|
707
|
+
const RESET = '\x1b[0m';
|
|
708
|
+
export async function runHealthSubcommand(_args, _config) {
|
|
709
|
+
const { runOnHost } = await import('../runtime/executor.js');
|
|
710
|
+
let runtimes;
|
|
711
|
+
try {
|
|
712
|
+
runtimes = await loadRuntimes();
|
|
713
|
+
}
|
|
714
|
+
catch (e) {
|
|
715
|
+
console.error(`Failed to load runtimes: ${e?.message ?? e}`);
|
|
716
|
+
process.exitCode = 1;
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const enabledHosts = runtimes.hosts.filter((h) => h.enabled);
|
|
720
|
+
const enabledModels = runtimes.models.filter((m) => m.enabled);
|
|
721
|
+
const enabledBackends = runtimes.backends.filter((b) => b.enabled);
|
|
722
|
+
if (enabledHosts.length === 0) {
|
|
723
|
+
console.log('No enabled hosts configured. Run `idlehands setup` first.');
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
let anyFailed = false;
|
|
727
|
+
// ── Host health checks ──────────────────────────────────────────
|
|
728
|
+
console.log(`\n${BOLD}Hosts${RESET}`);
|
|
729
|
+
for (const host of enabledHosts) {
|
|
730
|
+
const label = host.transport === 'ssh'
|
|
731
|
+
? `${host.id} (${host.connection.user ? host.connection.user + '@' : ''}${host.connection.host ?? '?'})`
|
|
732
|
+
: `${host.id} (local)`;
|
|
733
|
+
const cmd = host.health.check_cmd;
|
|
734
|
+
const timeoutMs = (host.health.timeout_sec ?? 5) * 1000;
|
|
735
|
+
process.stdout.write(` ${label}... `);
|
|
736
|
+
const result = await runOnHost(cmd, host, timeoutMs);
|
|
737
|
+
if (result.exitCode === 0) {
|
|
738
|
+
console.log(`${GREEN}✓${RESET}`);
|
|
739
|
+
}
|
|
740
|
+
else {
|
|
741
|
+
console.log(`${RED}✗${RESET}`);
|
|
742
|
+
if (result.stderr.trim()) {
|
|
743
|
+
for (const line of result.stderr.trim().split('\n').slice(0, 4)) {
|
|
744
|
+
console.log(` ${DIM}${line}${RESET}`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
anyFailed = true;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
// ── Model probe checks ─────────────────────────────────────────
|
|
751
|
+
if (enabledModels.length > 0) {
|
|
752
|
+
console.log(`\n${BOLD}Models${RESET}`);
|
|
753
|
+
for (const model of enabledModels) {
|
|
754
|
+
// Figure out which hosts this model can run on
|
|
755
|
+
const targetHosts = model.host_policy === 'any'
|
|
756
|
+
? enabledHosts
|
|
757
|
+
: enabledHosts.filter((h) => model.host_policy.includes(h.id));
|
|
758
|
+
// Find applicable backend for template vars
|
|
759
|
+
const backend = model.backend_policy === 'any'
|
|
760
|
+
? enabledBackends[0] ?? null
|
|
761
|
+
: enabledBackends.find((b) => model.backend_policy.includes(b.id)) ?? null;
|
|
762
|
+
for (const host of targetHosts) {
|
|
763
|
+
const port = String(model.runtime_defaults?.port ?? 8080);
|
|
764
|
+
const backendArgs = backend?.args?.map((a) => shellEscape(a)).join(' ') ?? '';
|
|
765
|
+
const backendEnv = backend?.env
|
|
766
|
+
? Object.entries(backend.env).map(([k, v]) => `${k}=${shellEscape(String(v))}`).join(' ')
|
|
767
|
+
: '';
|
|
768
|
+
const vars = {
|
|
769
|
+
source: model.source,
|
|
770
|
+
port,
|
|
771
|
+
host: host.connection.host ?? host.id,
|
|
772
|
+
backend_args: backendArgs,
|
|
773
|
+
backend_env: backendEnv,
|
|
774
|
+
model_id: model.id,
|
|
775
|
+
host_id: host.id,
|
|
776
|
+
backend_id: backend?.id ?? '',
|
|
777
|
+
};
|
|
778
|
+
let probeCmd;
|
|
779
|
+
try {
|
|
780
|
+
probeCmd = interpolateTemplate(model.launch.probe_cmd, vars);
|
|
781
|
+
}
|
|
782
|
+
catch {
|
|
783
|
+
probeCmd = model.launch.probe_cmd;
|
|
784
|
+
}
|
|
785
|
+
const label = `${model.id} on ${host.id} (${probeCmd})`;
|
|
786
|
+
const timeoutMs = (model.launch.probe_timeout_sec ?? 60) * 1000;
|
|
787
|
+
process.stdout.write(` ${model.display_name} on ${host.id}... `);
|
|
788
|
+
const result = await runOnHost(probeCmd, host, timeoutMs);
|
|
789
|
+
if (result.exitCode === 0) {
|
|
790
|
+
const body = result.stdout.trim();
|
|
791
|
+
console.log(`${GREEN}✓${RESET}${body ? ` ${DIM}${body.split('\n')[0].slice(0, 80)}${RESET}` : ''}`);
|
|
792
|
+
}
|
|
793
|
+
else {
|
|
794
|
+
console.log(`${RED}✗${RESET}`);
|
|
795
|
+
const detail = (result.stderr || result.stdout).trim();
|
|
796
|
+
if (detail) {
|
|
797
|
+
for (const line of detail.split('\n').slice(0, 4)) {
|
|
798
|
+
console.log(` ${DIM}${line}${RESET}`);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
anyFailed = true;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
console.log();
|
|
807
|
+
if (anyFailed) {
|
|
808
|
+
process.exitCode = 1;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
//# sourceMappingURL=runtime-cmds.js.map
|