@visorcraft/idlehands 1.1.10 → 1.1.12

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.
@@ -2,6 +2,7 @@ import path from 'node:path';
2
2
  import readline from 'node:readline/promises';
3
3
  import { stdin as input, stdout as output } from 'node:process';
4
4
  import { spawnSync } from 'node:child_process';
5
+ import { probeModelsEndpoint, waitForModelsReady } from '../runtime/health.js';
5
6
  import { loadRuntimes, saveRuntimes, validateRuntimes, redactConfig, bootstrapRuntimes, interpolateTemplate, } from '../runtime/store.js';
6
7
  import { configDir, shellEscape } from '../utils.js';
7
8
  import { firstToken } from './command-utils.js';
@@ -37,18 +38,17 @@ function runHostCommand(host, command, timeoutSec = 5) {
37
38
  if (host.transport === 'local')
38
39
  return runLocalCommand(command, timeoutSec);
39
40
  const target = `${host.connection.user ? `${host.connection.user}@` : ''}${host.connection.host ?? ''}`;
40
- const sshArgs = [
41
+ const sshParts = [
41
42
  'ssh',
42
43
  '-o', 'BatchMode=yes',
43
44
  '-o', `ConnectTimeout=${timeoutSec}`,
44
45
  ];
45
46
  if (host.connection.port)
46
- sshArgs.push('-p', String(host.connection.port));
47
+ sshParts.push('-p', String(host.connection.port));
47
48
  if (host.connection.key_path)
48
- sshArgs.push('-i', shellEscape(host.connection.key_path));
49
- sshArgs.push(shellEscape(target));
50
- sshArgs.push(shellEscape(command));
51
- return runLocalCommand(sshArgs.join(' '), timeoutSec + 1);
49
+ sshParts.push('-i', shellEscape(host.connection.key_path));
50
+ sshParts.push(shellEscape(target), '--', 'bash', '-lc', shellEscape(command));
51
+ return runLocalCommand(sshParts.join(' '), timeoutSec + 1);
52
52
  }
53
53
  function usage(kind) {
54
54
  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`);
@@ -744,8 +744,11 @@ export async function runSelectSubcommand(args, _config) {
744
744
  const force = !!args.force;
745
745
  const restart = !!args.restart;
746
746
  const forceRestart = force || restart;
747
+ const waitReady = !!(args['wait-ready'] ?? args.wait_ready);
748
+ const waitTimeoutSecRaw = Number(args['wait-timeout'] ?? args.wait_timeout ?? args.timeout ?? 0);
749
+ const waitTimeoutSec = Number.isFinite(waitTimeoutSecRaw) && waitTimeoutSecRaw > 0 ? waitTimeoutSecRaw : undefined;
747
750
  if (!modelId) {
748
- console.log('Usage: idlehands select --model <id> [--backend <id>] [--host <id>] [--dry-run] [--json] [--force] [--restart]');
751
+ console.log('Usage: idlehands select --model <id> [--backend <id>] [--host <id>] [--dry-run] [--json] [--force] [--restart] [--wait-ready] [--wait-timeout <sec>]');
749
752
  console.log(' idlehands select status');
750
753
  return;
751
754
  }
@@ -807,26 +810,65 @@ export async function runSelectSubcommand(args, _config) {
807
810
  },
808
811
  force,
809
812
  });
813
+ let executedPlan = result;
810
814
  let execResult = await executeWithRenderer(result);
811
815
  // Reuse-probe fallback: if reuse validation fails, force restart automatically.
812
816
  if (!execResult.ok && result.reuse && !forceRestart) {
813
817
  console.error('Reuse health check failed. Retrying with forced restart...');
814
818
  const restartPlan = plan({ modelId, backendOverride, hostOverride, mode: 'live', forceRestart: true }, rtConfig, active);
815
819
  if (restartPlan.ok) {
820
+ executedPlan = restartPlan;
816
821
  await applyDynamicProbeDefaults(restartPlan, rtConfig, runOnHost);
817
822
  execResult = await executeWithRenderer(restartPlan);
818
823
  }
819
824
  }
825
+ const readyChecks = [];
826
+ let readyOk = true;
827
+ if (execResult.ok && waitReady) {
828
+ const timeoutSec = waitTimeoutSec ?? (executedPlan.model.launch.probe_timeout_sec ?? 60);
829
+ for (const resolvedHost of executedPlan.hosts) {
830
+ const hostCfg = rtConfig.hosts.find((h) => h.id === resolvedHost.id);
831
+ if (!hostCfg)
832
+ continue;
833
+ const port = executedPlan.model.runtime_defaults?.port ?? 8080;
834
+ process.stdout.write(` Waiting for /v1/models on ${resolvedHost.id}:${port}...`);
835
+ const ready = await waitForModelsReady(runOnHost, hostCfg, port, {
836
+ timeoutMs: timeoutSec * 1000,
837
+ intervalMs: executedPlan.model.launch.probe_interval_ms ?? 1500,
838
+ });
839
+ readyChecks.push({
840
+ hostId: resolvedHost.id,
841
+ ok: ready.ok,
842
+ attempts: ready.attempts,
843
+ reason: ready.reason,
844
+ status: ready.last.status,
845
+ httpCode: ready.last.httpCode,
846
+ modelIds: ready.last.modelIds,
847
+ });
848
+ if (ready.ok) {
849
+ process.stdout.write(' ✓\n');
850
+ }
851
+ else {
852
+ process.stdout.write(' ✗\n');
853
+ if (ready.reason)
854
+ process.stdout.write(` ${ready.reason}\n`);
855
+ readyOk = false;
856
+ }
857
+ }
858
+ }
820
859
  iface.close();
821
860
  if (jsonOut) {
822
- console.log(JSON.stringify(execResult, null, 2));
861
+ console.log(JSON.stringify({
862
+ execute: execResult,
863
+ waitReady: waitReady ? { ok: readyOk, checks: readyChecks } : undefined,
864
+ }, null, 2));
823
865
  }
824
866
  else if (execResult.ok) {
825
867
  if (execResult.reused) {
826
868
  console.log('Runtime already active and healthy. No changes needed.');
827
869
  }
828
870
  else {
829
- console.log(`Runtime switched to "${result.model.display_name}" successfully.`);
871
+ console.log(`Runtime switched to "${executedPlan.model.display_name}" successfully.`);
830
872
  }
831
873
  // Show the derived endpoint so the user knows where requests will go
832
874
  const { loadActiveRuntime: loadAR } = await import('../runtime/executor.js');
@@ -834,6 +876,10 @@ export async function runSelectSubcommand(args, _config) {
834
876
  if (activeNow?.endpoint) {
835
877
  console.log(`Endpoint: ${activeNow.endpoint}`);
836
878
  }
879
+ if (waitReady && !readyOk) {
880
+ console.error('Wait-ready failed: server did not become ready in time.');
881
+ process.exitCode = 1;
882
+ }
837
883
  }
838
884
  else {
839
885
  console.error(`Execution failed: ${execResult.error || 'unknown error'}`);
@@ -847,27 +893,9 @@ const YELLOW = '\x1b[33m';
847
893
  const DIM = '\x1b[2m';
848
894
  const BOLD = '\x1b[1m';
849
895
  const RESET = '\x1b[0m';
850
- function parseCurlTagged(stdout) {
851
- const m = stdout.match(/\n__HTTP__:(\d{3})\s*$/);
852
- if (!m)
853
- return { code: null, body: stdout.trim() };
854
- const code = Number(m[1]);
855
- const body = stdout.slice(0, m.index).trim();
856
- return { code: Number.isFinite(code) ? code : null, body };
857
- }
858
- function classifyProbe(exitCode, httpCode) {
859
- if (httpCode === 200)
860
- return 'ready';
861
- if (httpCode === 503)
862
- return 'loading';
863
- if (exitCode === 7 || exitCode === 28)
864
- return 'down';
865
- if (exitCode !== 0)
866
- return 'down';
867
- return 'unknown';
868
- }
869
896
  export async function runHealthSubcommand(args, _config) {
870
897
  const { runOnHost } = await import('../runtime/executor.js');
898
+ const jsonOut = !!args.json;
871
899
  let runtimes;
872
900
  try {
873
901
  runtimes = await loadRuntimes();
@@ -887,7 +915,6 @@ export async function runHealthSubcommand(args, _config) {
887
915
  const trimmed = input.trim();
888
916
  if (!trimmed)
889
917
  return null;
890
- // Try range format: "8000-8100"
891
918
  if (/^\d+-\d+$/.test(trimmed)) {
892
919
  const [start, end] = trimmed.split('-').map(Number);
893
920
  if (start > end || start < 1 || end > 65535)
@@ -897,13 +924,11 @@ export async function runHealthSubcommand(args, _config) {
897
924
  ports.push(p);
898
925
  return ports;
899
926
  }
900
- // Try comma-separated: "8080,8081,8082"
901
927
  if (trimmed.includes(',')) {
902
928
  const parts = trimmed.split(',').map((s) => s.trim()).filter(Boolean);
903
929
  const ports = parts.map(Number).filter((p) => Number.isFinite(p) && p >= 1 && p <= 65535);
904
930
  return ports.length > 0 ? ports : null;
905
931
  }
906
- // Single port
907
932
  const port = Number(trimmed);
908
933
  if (Number.isFinite(port) && port >= 1 && port <= 65535)
909
934
  return [port];
@@ -915,86 +940,114 @@ export async function runHealthSubcommand(args, _config) {
915
940
  return;
916
941
  }
917
942
  let anyFailed = false;
918
- // ── Host health checks ──────────────────────────────────────────
919
- console.log(`\n${BOLD}Hosts${RESET}`);
943
+ const report = {
944
+ ok: true,
945
+ generatedAt: new Date().toISOString(),
946
+ hosts: [],
947
+ configuredModels: [],
948
+ discovery: {
949
+ ports: [],
950
+ hosts: [],
951
+ },
952
+ };
953
+ if (!jsonOut)
954
+ console.log(`\n${BOLD}Hosts${RESET}`);
920
955
  for (const host of enabledHosts) {
921
956
  const label = host.transport === 'ssh'
922
957
  ? `${host.id} (${host.connection.user ? host.connection.user + '@' : ''}${host.connection.host ?? '?'})`
923
958
  : `${host.id} (local)`;
924
959
  const cmd = host.health.check_cmd;
925
960
  const timeoutMs = (host.health.timeout_sec ?? 5) * 1000;
926
- process.stdout.write(` ${label}... `);
961
+ if (!jsonOut)
962
+ process.stdout.write(` ${label}... `);
927
963
  const result = await runOnHost(cmd, host, timeoutMs);
964
+ report.hosts.push({
965
+ id: host.id,
966
+ ok: result.exitCode === 0,
967
+ exitCode: result.exitCode,
968
+ stdout: result.stdout ?? '',
969
+ stderr: result.stderr ?? '',
970
+ });
928
971
  if (result.exitCode === 0) {
929
- console.log(`${GREEN}✓${RESET}`);
972
+ if (!jsonOut)
973
+ console.log(`${GREEN}✓${RESET}`);
930
974
  }
931
975
  else {
932
- console.log(`${RED}✗${RESET}`);
933
- if (result.stderr.trim()) {
934
- for (const line of result.stderr.trim().split('\n').slice(0, 4)) {
935
- console.log(` ${DIM}${line}${RESET}`);
976
+ if (!jsonOut) {
977
+ console.log(`${RED}✗${RESET}`);
978
+ if (result.stderr.trim()) {
979
+ for (const line of result.stderr.trim().split('\n').slice(0, 4)) {
980
+ console.log(` ${DIM}${line}${RESET}`);
981
+ }
936
982
  }
937
983
  }
938
984
  anyFailed = true;
939
985
  }
940
986
  }
941
- // ── Model probe checks ─────────────────────────────────────────
942
- if (enabledModels.length > 0) {
943
- console.log(`\n${BOLD}Models${RESET}`);
944
- for (const model of enabledModels) {
945
- // Figure out which hosts this model can run on
946
- const targetHosts = model.host_policy === 'any'
947
- ? enabledHosts
948
- : enabledHosts.filter((h) => model.host_policy.includes(h.id));
949
- // Find applicable backend for template vars
950
- const backend = model.backend_policy === 'any'
951
- ? enabledBackends[0] ?? null
952
- : enabledBackends.find((b) => model.backend_policy.includes(b.id)) ?? null;
953
- for (const host of targetHosts) {
954
- const port = String(model.runtime_defaults?.port ?? 8080);
955
- const backendArgs = backend?.args?.map((a) => shellEscape(a)).join(' ') ?? '';
956
- const backendEnv = backend?.env
957
- ? Object.entries(backend.env).map(([k, v]) => `${k}=${shellEscape(String(v))}`).join(' ')
958
- : '';
959
- const vars = {
960
- source: model.source,
961
- port,
962
- host: host.connection.host ?? host.id,
963
- backend_args: backendArgs,
964
- backend_env: backendEnv,
965
- model_id: model.id,
966
- host_id: host.id,
967
- backend_id: backend?.id ?? '',
968
- };
969
- let probeCmd;
970
- try {
971
- probeCmd = interpolateTemplate(model.launch.probe_cmd, vars);
972
- }
973
- catch {
974
- probeCmd = model.launch.probe_cmd;
975
- }
976
- const timeoutMs = (model.launch.probe_timeout_sec ?? 60) * 1000;
987
+ if (enabledModels.length > 0 && !jsonOut) {
988
+ console.log(`\n${BOLD}Configured Models${RESET}`);
989
+ }
990
+ for (const model of enabledModels) {
991
+ const targetHosts = model.host_policy === 'any'
992
+ ? enabledHosts
993
+ : enabledHosts.filter((h) => model.host_policy.includes(h.id));
994
+ const backend = model.backend_policy === 'any'
995
+ ? enabledBackends[0] ?? null
996
+ : enabledBackends.find((b) => model.backend_policy.includes(b.id)) ?? null;
997
+ for (const host of targetHosts) {
998
+ const port = String(model.runtime_defaults?.port ?? 8080);
999
+ const backendArgs = backend?.args?.map((a) => shellEscape(a)).join(' ') ?? '';
1000
+ const backendEnv = backend?.env
1001
+ ? Object.entries(backend.env).map(([k, v]) => `${k}=${shellEscape(String(v))}`).join(' ')
1002
+ : '';
1003
+ const vars = {
1004
+ source: model.source,
1005
+ port,
1006
+ host: host.connection.host ?? host.id,
1007
+ backend_args: backendArgs,
1008
+ backend_env: backendEnv,
1009
+ model_id: model.id,
1010
+ host_id: host.id,
1011
+ backend_id: backend?.id ?? '',
1012
+ };
1013
+ let probeCmd;
1014
+ try {
1015
+ probeCmd = interpolateTemplate(model.launch.probe_cmd, vars);
1016
+ }
1017
+ catch {
1018
+ probeCmd = model.launch.probe_cmd;
1019
+ }
1020
+ const timeoutMs = (model.launch.probe_timeout_sec ?? 60) * 1000;
1021
+ if (!jsonOut)
977
1022
  process.stdout.write(` ${model.display_name} on ${host.id}... `);
978
- const result = await runOnHost(probeCmd, host, timeoutMs);
979
- if (result.exitCode === 0) {
1023
+ const result = await runOnHost(probeCmd, host, timeoutMs);
1024
+ const detail = (result.stderr || result.stdout || '').trim();
1025
+ report.configuredModels.push({
1026
+ modelId: model.id,
1027
+ hostId: host.id,
1028
+ ok: result.exitCode === 0,
1029
+ exitCode: result.exitCode,
1030
+ detail,
1031
+ });
1032
+ if (result.exitCode === 0) {
1033
+ if (!jsonOut) {
980
1034
  const body = result.stdout.trim();
981
1035
  console.log(`${GREEN}✓${RESET}${body ? ` ${DIM}${body.split('\n')[0].slice(0, 80)}${RESET}` : ''}`);
982
1036
  }
983
- else {
1037
+ }
1038
+ else {
1039
+ if (!jsonOut) {
984
1040
  console.log(`${RED}✗${RESET}`);
985
- const detail = (result.stderr || result.stdout).trim();
986
1041
  if (detail) {
987
1042
  for (const line of detail.split('\n').slice(0, 4)) {
988
1043
  console.log(` ${DIM}${line}${RESET}`);
989
1044
  }
990
1045
  }
991
- anyFailed = true;
992
1046
  }
1047
+ anyFailed = true;
993
1048
  }
994
1049
  }
995
1050
  }
996
- // ── Discovery: what is actually loaded (configured + common ports) ──
997
- console.log(`\n${BOLD}Loaded (discovered)${RESET}`);
998
1051
  let candidatePorts;
999
1052
  if (scanPortsOverride) {
1000
1053
  candidatePorts = scanPortsOverride;
@@ -1005,57 +1058,45 @@ export async function runHealthSubcommand(args, _config) {
1005
1058
  configuredPorts.add(p);
1006
1059
  candidatePorts = Array.from(configuredPorts).sort((a, b) => a - b);
1007
1060
  }
1061
+ report.discovery.ports = candidatePorts;
1008
1062
  const configuredModelIds = new Set(enabledModels.map((m) => m.id));
1063
+ if (!jsonOut)
1064
+ console.log(`\n${BOLD}Discovered Servers (/v1/models + /health)${RESET}`);
1009
1065
  for (const host of enabledHosts) {
1010
- console.log(` ${host.id}:`);
1066
+ const hostEntry = { hostId: host.id, services: [] };
1067
+ if (!jsonOut)
1068
+ console.log(` ${host.id}:`);
1011
1069
  for (const port of candidatePorts) {
1012
- const modelsCmd = `curl -sS -m 3 -o - -w "\\n__HTTP__:%{http_code}" http://127.0.0.1:${port}/v1/models`;
1013
- const modelsRes = await runOnHost(modelsCmd, host, 5000);
1014
- const parsedModels = parseCurlTagged(modelsRes.stdout ?? '');
1015
- let status = classifyProbe(modelsRes.exitCode, parsedModels.code);
1016
- let modelIds = [];
1017
- let httpCode = parsedModels.code;
1018
- if (modelsRes.exitCode === 0 && parsedModels.code === 200) {
1019
- try {
1020
- const json = JSON.parse(parsedModels.body);
1021
- modelIds = Array.isArray(json?.data)
1022
- ? json.data.map((x) => String(x?.id ?? '')).filter(Boolean)
1023
- : [];
1024
- }
1025
- catch {
1026
- // leave modelIds empty
1027
- }
1028
- }
1029
- // Fallback to /health when /v1/models doesn't resolve as ready.
1030
- if (status !== 'ready') {
1031
- const healthCmd = `curl -sS -m 3 -o - -w "\\n__HTTP__:%{http_code}" http://127.0.0.1:${port}/health`;
1032
- const healthRes = await runOnHost(healthCmd, host, 5000);
1033
- const parsedHealth = parseCurlTagged(healthRes.stdout ?? '');
1034
- const healthStatus = classifyProbe(healthRes.exitCode, parsedHealth.code);
1035
- // Prefer the more informative status.
1036
- if (healthStatus === 'ready' || healthStatus === 'loading') {
1037
- status = healthStatus;
1038
- httpCode = parsedHealth.code;
1039
- }
1040
- else if (status === 'unknown') {
1041
- status = healthStatus;
1042
- httpCode = parsedHealth.code;
1043
- }
1070
+ const probe = await probeModelsEndpoint(runOnHost, host, port, 5000);
1071
+ if (probe.status === 'down')
1072
+ continue; // concise display + compact JSON
1073
+ hostEntry.services.push({
1074
+ port,
1075
+ status: probe.status,
1076
+ httpCode: probe.httpCode,
1077
+ modelIds: probe.modelIds,
1078
+ stderr: probe.stderr,
1079
+ exitCode: probe.exitCode,
1080
+ });
1081
+ if (!jsonOut) {
1082
+ const icon = probe.status === 'ready' ? `${GREEN}✓${RESET}` : probe.status === 'loading' ? `${YELLOW}~${RESET}` : `${DIM}?${RESET}`;
1083
+ const extras = probe.modelIds.filter((id) => !configuredModelIds.has(id));
1084
+ const idsText = probe.modelIds.length ? ` models=${probe.modelIds.join(', ')}` : '';
1085
+ const extrasText = extras.length ? ` ${YELLOW}(extra/unconfigured: ${extras.join(', ')})${RESET}` : '';
1086
+ const httpText = probe.httpCode != null ? ` http=${probe.httpCode}` : '';
1087
+ console.log(` ${icon} :${port} ${probe.status}${httpText}${idsText}${extrasText}`);
1044
1088
  }
1045
- if (status === 'down')
1046
- continue; // keep output concise
1047
- const icon = status === 'ready' ? `${GREEN}✓${RESET}` : status === 'loading' ? `${YELLOW}~${RESET}` : `${DIM}?${RESET}`;
1048
- const statusWord = status === 'ready' ? 'ready' : status === 'loading' ? 'loading' : 'unknown';
1049
- const extras = modelIds.filter((id) => !configuredModelIds.has(id));
1050
- const idsText = modelIds.length ? ` models=${modelIds.join(', ')}` : '';
1051
- const extrasText = extras.length ? ` ${YELLOW}(extra/unconfigured: ${extras.join(', ')})${RESET}` : '';
1052
- const httpText = httpCode != null ? ` http=${httpCode}` : '';
1053
- console.log(` ${icon} :${port} ${statusWord}${httpText}${idsText}${extrasText}`);
1054
1089
  }
1090
+ report.discovery.hosts.push(hostEntry);
1055
1091
  }
1056
- console.log();
1057
- if (anyFailed) {
1058
- process.exitCode = 1;
1092
+ report.ok = !anyFailed;
1093
+ if (jsonOut) {
1094
+ console.log(JSON.stringify(report, null, 2));
1059
1095
  }
1096
+ else {
1097
+ console.log();
1098
+ }
1099
+ if (anyFailed)
1100
+ process.exitCode = 1;
1060
1101
  }
1061
1102
  //# sourceMappingURL=runtime-cmds.js.map