Files
antje 25d296a6f5
Hanzo CI/CD / cicd (push) Successful in 20s
CI/CD / gate (push) Successful in 20s
CI/CD / containment (push) Successful in 1m40s
CI/CD / image (push) Skipped
CI/CD / rollout (push) Skipped
CI/CD / reach (push) Skipped
CI/CD / fanout (push) Skipped
CI/CD / receipt (push) Skipped
fix: a refused credential is not a blip — stop retrying it
The register retry added earlier exists so a rolling control plane cannot kill a
worker mid-render: api.hanzo.ai answers 503 for a few seconds while its pod is
replaced, and riding that out saves whatever was sampling.

401/403 is the opposite kind of failure. It says this node's token is not accepted,
and waiting never changes that. Retried, it cost 30s per boot inside a systemd restart
loop — found at restart counter 10 on a node whose credential had expired — and buried
the one line naming the cause under five that said "retrying".

It now fails on the first refusal and says what to do: run `hanzo login` on that node.
Same lesson as the held spool, one layer over: a permanent condition wearing a
retryable shape is worse than an error, because it looks like progress.
2026-08-01 18:26:35 -07:00

2430 lines
89 KiB
Go

package cli
// gpu.go — the compute-worker machinery behind `hanzo link` (command wiring lives
// in link.go). It authenticates against IAM (reusing the `hanzo login` credential
// store), registers the machine + its CPU/memory/GPU inventory as a heartbeating
// presence record in the org's `fleet` tasks namespace, and runs an OUTBOUND-only
// worker loop that CLAIMS jobs from the org's `gpu-jobs` namespace — the NAT-safe
// primitive (the worker dials out; nothing dials in). The registered node then shows
// up on console.hanzo.ai's Machines + GPUs pages (provider="byo") via cloud's
// /v1/fleet union.
//
// One identity, one way: the same IAM token `hanzo login` mints (org in its `owner`
// claim) authorizes every cloud call; the server derives the tenant from the token,
// so the CLI never sends an org header. Job types are pluggable (jobHandlers); v1
// ships `echo` (trivial, no GPU — smoke/E2E) and `studio.render` (POST to the local
// ComfyUI at 127.0.0.1:8188 and poll history).
//
// --serve-engine adds the `engine.serve` capability: the worker probes a local
// hanzo-engine (the OpenAI + Anthropic model server on :1234), advertises its model
// endpoint in the presence record, and prints (or with --register-provider, POSTs)
// the POST /v1/ai/providers call that routes api.hanzo.ai model traffic to this GPU. One
// fleet, two job types: engine.serve (model serving) alongside studio.render.
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"runtime"
"slices"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"unicode"
"github.com/spf13/cobra"
)
const (
fleetNS = "fleet"
defaultJobsNS = "gpu-jobs"
// gpuQueuePrefix names a per-GPU lane WITHIN the gpu-jobs namespace: a job whose
// taskQueue is "gpu:<identity>" is claimed ONLY by that node; the shared
// "gpu-jobs" value stays the any-GPU broadcast. ONE convention (shared with the
// studio dispatcher + clients/visor), so targeting is the taskQueue VALUE — not a
// second store, a new namespace, or a new endpoint.
gpuQueuePrefix = "gpu:"
// heartbeatEvery keeps the presence record fresh; well under cloud's 90s
// byoLiveWindow so one missed beat does not flap the machine offline.
heartbeatEvery = 30 * time.Second
claimPoll = 2 * time.Second
claimLeaseSecs = 120
// maxFetchedInputBytes guards fetchInput's read. Generous next to any real
// source (a 4K PNG is ~20MB) and finite, so a wrong URL cannot exhaust memory.
maxFetchedInputBytes = 512 << 20
// registerAttempts/registerBackoff let the presence write ride out a control
// plane that is rolling. ~30s of cover, which is longer than a pod replacement
// and far shorter than a real outage.
registerAttempts = 6
registerBackoff = 5 * time.Second
// renderWindow matches the dispatch cap (studio gpu_dispatch sets
// startToCloseTimeout 14400s). The old 10m local poll undercut it and
// marked live renders failed while they kept sampling (observed 8-70m).
renderWindow = 4 * time.Hour
// localComfyUI is the studio render backend the studio.render handler drives.
localComfyUI = "http://127.0.0.1:8188"
// localWorkerExecute is the GATED submit seam on the local studio (worker-mode):
// the render graph is POSTed here with X-Worker-Token so ONLY the fleet worker —
// not anything else that can reach loopback — can start a render. It replaces the
// open /prompt POST (the hidden-run hole the studio's --worker-mode closes).
localWorkerExecute = localComfyUI + "/v1/worker/execute"
// defaultStudioUploadURL is where finished render outputs are POSTed so they
// land in the org's gallery (orgs/{org}/output → S3 mirror). The render runs on
// the LOCAL studio; the deliverable is uploaded to the org's cloud studio,
// authorized by the user's IAM token — no S3/rclone credentials on the box.
// Overridable per-job (input.uploadUrl) or per-box (HANZO_STUDIO_UPLOAD_URL).
defaultStudioUploadURL = "https://studio.hanzo.ai"
// defaultEngineURL is where --serve-engine probes hanzo-engine on THIS node.
// hanzo-engine binds its OpenAI + Anthropic HTTP API on 0.0.0.0:1234 by default.
defaultEngineURL = "http://localhost:1234"
)
// Fleet capability names advertised in the presence record. studioCap is always
// present (the worker claims gpu-jobs); engineCap is added by --serve-engine so the
// gateway can route model calls to a hanzo-engine running on this node.
const (
studioCap = "studio.render"
engineCap = "engine.serve"
fnCap = "fn.run" // ephemeral script execution (uv-run) on this node
)
// The node-level command surface — `hanzo link | unlink` — is wired in link.go.
// It composes the worker machinery below (runConnect / runDisconnect) with the
// fabric verbs (`hanzo node up|stop`). Reading the fleet back is `hanzo status`,
// which this binary does not serve; the router hands it to the fabric CLI.
// ---------------------------------------------------------------------------
// worker — the connect loop's state.
// ---------------------------------------------------------------------------
type worker struct {
env *Env
http *http.Client
baseURL string
identity string // sanitized hostname == fleet activityId == runId
hostname string
jobsNS string
gpus []gpuInfo
arch string // CPU arch (`uname -m`), detected once at newWorker
cpuModel string // CPU model name (/proc/cpuinfo | sysctl), detected once at newWorker
memory int64 // total system RAM in bytes, detected once at newWorker
handlers map[string]jobHandler
// studioUploadURL is the org studio base that receives finished render outputs
// (POST /upload/output). Falls back to defaultStudioUploadURL; a job may override
// it via input.uploadUrl.
studioUploadURL string
// policy is this machine's sharing policy (nil = permissive). Advertised in the
// fleet record and enforced at claim.
policy *SharePolicy
// studio.render preflight — a node must be able to SERVE renders before it
// advertises studioCap or claims render lanes, else it claims jobs the gated
// /v1/worker/execute will only refuse (poison loop). studioReady = a worker token
// is present AND a studio is reachable (or we launch one via --studio-dir).
// Re-evaluated on each heartbeat so a studio dying/recovering flips claiming.
launchesStudio bool // --studio-dir set: we own the studio lifecycle
studioReady bool // current preflight verdict
studioWarned bool // loud "won't render" error emitted once per not-ready spell
// engine.serve — advertise a hanzo-engine model server running on this node.
serveEngine bool
engineURL string // local URL probed for /v1/models
engineAdvURL string // endpoint advertised for gateway routing
engine *engineAdvertisement // latest probe result (nil until probed)
}
// engineAdvertisement describes a hanzo-engine model server on this node.
// hanzo-engine serves the OpenAI AND Anthropic HTTP APIs from ONE axum port
// (0.0.0.0:1234), so the gateway can route model calls here on the standard chat-completions
// (Type=Local) provider. This rides in the fleet presence record's Input.
type engineAdvertisement struct {
URL string `json:"url"` // base the gateway calls (…:1234)
APIs []string `json:"apis,omitempty"` // wire formats served: ["openai","anthropic"]
Models []string `json:"models,omitempty"` // model ids from GET /v1/models
Status string `json:"status"` // "ready" | "unreachable"
}
type gpuInfo struct {
Name string `json:"name"`
MemoryTotal string `json:"memoryTotal,omitempty"`
Arch string `json:"arch,omitempty"` // native target, e.g. "gfx1151" (AMD)
Unified bool `json:"unified,omitempty"` // memory is a unified CPU/GPU pool (APU / SoC)
}
// registration is the fleet presence activity's Input — the shape cloud's
// clients/visor/fleet.go fleetRegistration decodes. Capabilities + Engine are
// additive (omitempty): an older cloud that does not read them still renders the
// GPU; a newer one advertises the engine endpoint on GET /v1/fleet/workers.
type registration struct {
Hostname string `json:"hostname"`
Os string `json:"os"`
// Arch/CPUs/Memory are THIS host's static CPU spec, in the SAME convention the
// fleet already uses for code-linked run-targets: Arch is `uname -m`
// (aarch64 | x86_64 | arm64), Memory is total system RAM in BYTES. Matching the
// existing convention matters — evo-2 and spark appear on the board as BOTH a
// run-target and a linked worker, so both rows must show the SAME arch.
Arch string `json:"arch,omitempty"`
CPUs int `json:"cpus,omitempty"`
CPUModel string `json:"cpuModel,omitempty"`
Memory int64 `json:"memory,omitempty"`
Version string `json:"version"`
JobQueue string `json:"jobQueue"`
Rocm string `json:"rocm,omitempty"` // host ROCm version (AMD only)
Hip string `json:"hip,omitempty"` // host HIP version (AMD only)
Cuda string `json:"cuda,omitempty"` // host CUDA toolkit version (NVIDIA only)
Driver string `json:"driver,omitempty"` // host NVIDIA driver version
GPUs []gpuInfo `json:"gpus"`
Capabilities []string `json:"capabilities,omitempty"`
Engine *engineAdvertisement `json:"engine,omitempty"`
Policy *SharePolicy `json:"policy,omitempty"`
}
// SharePolicy is the per-machine sharing policy: which jobs this GPU accepts and for
// whom. ONE object on the machine record (advertised in the fleet registration) and
// enforced ONCE, at claim. A nil/zero policy is fully permissive — an unconfigured
// box behaves exactly as before. A linked GPU can thus be shared to specific orgs /
// projects / job types / models with per-scope limits, edited on the machine record
// (desktop UI / visor) and loaded here via HANZO_GPU_POLICY (inline JSON) or
// HANZO_GPU_POLICY_FILE (path).
type SharePolicy struct {
AllowedOrgs []string `json:"allowedOrgs,omitempty"` // org owners allowed to run here (empty = any)
AllowedProjects []string `json:"allowedProjects,omitempty"` // project ids allowed (empty = any)
AllowedJobTypes []string `json:"allowedJobTypes,omitempty"` // e.g. ["studio.render"] (empty = any this worker handles)
AllowedModels []string `json:"allowedModels,omitempty"` // model ids allowed (empty = any)
MaxConcurrent int `json:"maxConcurrent,omitempty"` // 0 = unbounded (the worker is serial today)
}
// loadSharePolicy reads the machine's sharing policy from HANZO_GPU_POLICY (inline
// JSON) or HANZO_GPU_POLICY_FILE (a path to it). Returns (nil, nil) when unset.
func loadSharePolicy() (*SharePolicy, error) {
raw := os.Getenv("HANZO_GPU_POLICY")
if raw == "" {
if f := os.Getenv("HANZO_GPU_POLICY_FILE"); f != "" {
b, err := os.ReadFile(f)
if err != nil {
return nil, fmt.Errorf("read HANZO_GPU_POLICY_FILE: %w", err)
}
raw = string(b)
}
}
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
var p SharePolicy
if err := json.Unmarshal([]byte(raw), &p); err != nil {
return nil, fmt.Errorf("parse share policy: %w", err)
}
return &p, nil
}
// reject returns a non-empty reason when this machine's policy forbids running the
// claimed job, or "" to allow it. It reads org/project/model from the job input
// (best-effort: an absent field skips its gate). Enforcement point is the claim loop,
// so a declined job is failed back for an eligible worker to take.
func (p *SharePolicy) reject(jobType string, input json.RawMessage) string {
if p == nil {
return ""
}
if len(p.AllowedJobTypes) > 0 && !slices.Contains(p.AllowedJobTypes, jobType) {
return fmt.Sprintf("job type %q not allowed", jobType)
}
var meta struct {
Org string `json:"org"`
Project string `json:"project"`
Model string `json:"model"`
}
_ = json.Unmarshal(input, &meta)
if len(p.AllowedOrgs) > 0 && meta.Org != "" && !slices.Contains(p.AllowedOrgs, meta.Org) {
return fmt.Sprintf("org %q not allowed", meta.Org)
}
if len(p.AllowedProjects) > 0 && meta.Project != "" && !slices.Contains(p.AllowedProjects, meta.Project) {
return fmt.Sprintf("project %q not allowed", meta.Project)
}
if len(p.AllowedModels) > 0 && meta.Model != "" && !slices.Contains(p.AllowedModels, meta.Model) {
return fmt.Sprintf("model %q not allowed", meta.Model)
}
return ""
}
type jobHandler func(ctx context.Context, input json.RawMessage) (any, error)
func newWorker(env *Env, jobsNS string) (*worker, error) {
host, _ := os.Hostname()
if host == "" {
host = "worker"
}
id := sanitizeID(host)
w := &worker{
env: env,
http: &http.Client{Timeout: 60 * time.Second},
baseURL: env.CloudURL,
identity: id,
hostname: host,
jobsNS: firstNonEmpty(jobsNS, defaultJobsNS),
gpus: detectGPUs(),
arch: detectArch(),
cpuModel: detectCPUModel(),
memory: detectMemTotal(),
studioUploadURL: firstNonEmpty(os.Getenv("HANZO_STUDIO_UPLOAD_URL"), defaultStudioUploadURL),
}
policy, err := loadSharePolicy()
if err != nil {
return nil, err
}
w.policy = policy
w.handlers = map[string]jobHandler{
"echo": echoHandler,
"studio.render": w.studioRenderHandler,
}
if _, err := exec.LookPath("uv"); err == nil {
w.handlers[fnCap] = fnRunHandler // functions-runner: needs uv, nothing else
}
return w, nil
}
var idRE = regexp.MustCompile(`[^a-z0-9-]+`)
// sanitizeID lower-cases and reduces s to the [a-z0-9-] alphabet valid as a
// tasks path segment / activity id, so a hostname is a stable machine key.
func sanitizeID(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = idRE.ReplaceAllString(s, "-")
s = strings.Trim(s, "-")
if s == "" {
return "worker"
}
return s
}
// detectGPUs reports the machine's accelerators as first-class resources — NVIDIA
// (nvidia-smi), AMD (rocm-smi / kfd topology / vulkaninfo), or the Apple Silicon
// integrated GPU with its unified memory. Each vendor path is tried in turn; the
// first that reports a card wins. Degrades gracefully to an empty list (CPU-only)
// when none is present.
func detectGPUs() []gpuInfo {
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
return detectAppleGPU()
}
if g := detectNvidiaGPUs(); len(g) > 0 {
return g
}
if g := detectAmdGPUs(); len(g) > 0 {
return g
}
return nil
}
// detectNvidiaGPUs reports NVIDIA accelerators via nvidia-smi: name, memory,
// and the sm arch (compute capability). A unified SoC (GB10 Grace-Blackwell)
// reports memory.total as "[N/A]" — there is no dedicated VRAM counter — so it
// reports the MACHINE's RAM snapped to hardware capacity, unified=true, the
// same convention the AMD APU and Apple paths use.
func detectNvidiaGPUs() []gpuInfo {
out, err := exec.Command("nvidia-smi", "--query-gpu=name,memory.total,compute_cap", "--format=csv,noheader").Output()
if err != nil {
return nil
}
return parseNvidiaSmiCSV(out, detectMemTotal()/(1<<20))
}
// parseNvidiaSmiCSV parses `nvidia-smi --query-gpu=name,memory.total,compute_cap`
// output ("NVIDIA GB10, [N/A], 12.1"). Pure — hostMiB is injected for the
// unified-SoC memory figure.
func parseNvidiaSmiCSV(out []byte, hostMiB int64) []gpuInfo {
var gpus []gpuInfo
sc := bufio.NewScanner(bytes.NewReader(out))
for sc.Scan() {
fields := strings.Split(sc.Text(), ",")
if len(fields) < 2 || strings.TrimSpace(fields[0]) == "" {
continue
}
g := gpuInfo{Name: strings.TrimSpace(fields[0]), MemoryTotal: strings.TrimSpace(fields[1])}
var memMiB int64
if _, err := fmt.Sscanf(g.MemoryTotal, "%d MiB", &memMiB); err != nil || memMiB <= 0 {
// No dedicated VRAM counter — a unified SoC. The machine's RAM is
// the GPU's RAM.
if hostMiB > 0 {
g.MemoryTotal = fmt.Sprintf("%d MiB", snapUnified(hostMiB))
g.Unified = true
} else {
g.MemoryTotal = ""
}
}
if len(fields) >= 3 {
if cap := strings.TrimSpace(fields[2]); cap != "" && cap != "[N/A]" {
g.Arch = "sm_" + strings.ReplaceAll(cap, ".", "")
}
}
gpus = append(gpus, g)
}
return gpus
}
// nvidiaSoft is the host CUDA/driver inventory, detected once — the NVIDIA
// mirror of amdSoftware. Empty on non-NVIDIA hosts.
type nvidiaSoft struct{ cuda, driver string }
var nvidiaSoftware = sync.OnceValue(func() nvidiaSoft {
var v nvidiaSoft
if out, err := exec.Command("nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader").Output(); err == nil {
v.driver = strings.TrimSpace(strings.SplitN(string(out), "\n", 2)[0])
}
if b, err := os.ReadFile("/usr/local/cuda/version.json"); err == nil {
var m struct {
Cuda struct {
Version string `json:"version"`
} `json:"cuda"`
}
if json.Unmarshal(b, &m) == nil {
v.cuda = m.Cuda.Version
}
}
return v
})
// detectAmdGPUs reports AMD accelerators — discrete Radeon cards and gfx APUs alike
// (e.g. evo's gfx1151 Radeon 8060S on the RYZEN AI MAX+ 395). Resolution order:
// rocm-smi (marketing name + gfx target), then the kfd topology under /sys (gfx
// target from gfx_target_version, GPU nodes only), then a vulkaninfo summary. VRAM
// is filled best-effort from the amdgpu sysfs mem_info_vram_total, positionally.
func detectAmdGPUs() []gpuInfo {
if out, err := exec.Command("rocm-smi", "--showproductname", "--csv").Output(); err == nil {
if gpus := parseRocmSmiCSV(out); len(gpus) > 0 {
return apuProcessorNames(fillAmdVRAM(gpus, amdVRAMTotals(sysfsDRM)), detectCPUModel())
}
}
if gpus := parseKfdTopology(sysfsKfdNodes); len(gpus) > 0 {
return apuProcessorNames(fillAmdVRAM(gpus, amdVRAMTotals(sysfsDRM)), detectCPUModel())
}
if out, err := exec.Command("vulkaninfo", "--summary").Output(); err == nil {
if gpus := parseVulkaninfoSummary(out); len(gpus) > 0 {
return gpus
}
}
return nil
}
// sysfs roots, indirected so tests can point them at fixtures.
var (
sysfsKfdNodes = "/sys/class/kfd/kfd/topology/nodes"
sysfsDRM = "/sys/class/drm"
)
// parseRocmSmiCSV parses `rocm-smi --showproductname --csv` into one gpuInfo per
// card, naming it "<Card Series> (<GFX Version>)" (e.g. "Radeon 8060S Graphics
// (gfx1151)"). Column order is read from the header so it survives field additions.
func parseRocmSmiCSV(out []byte) []gpuInfo {
sc := bufio.NewScanner(bytes.NewReader(out))
var series, gfx = -1, -1
var gpus []gpuInfo
for sc.Scan() {
fields := strings.Split(strings.TrimSpace(sc.Text()), ",")
if len(fields) < 2 {
continue
}
if series < 0 { // header row
for i, f := range fields {
switch strings.TrimSpace(f) {
case "Card Series":
series = i
case "GFX Version":
gfx = i
}
}
continue
}
if series >= len(fields) {
continue
}
name := strings.TrimSpace(fields[series])
if name == "" {
continue
}
arch := ""
if gfx >= 0 && gfx < len(fields) {
if v := strings.TrimSpace(fields[gfx]); v != "" {
arch = v
name += " (" + v + ")"
}
}
if fam := amdFamily(arch); fam != "" {
name = fam + " \u00b7 " + name
}
gpus = append(gpus, gpuInfo{Name: name, Arch: arch})
}
return gpus
}
// parseKfdTopology reads the amdgpu kfd topology under nodesDir and reports one
// gpuInfo per GPU node — a node with simd_count > 0 (node 0 is the CPU) — naming it
// from the decoded gfx_target_version (110501 → "gfx1151"). Nodes are visited in
// numeric order so the list matches the DRM card order VRAM is read in.
func parseKfdTopology(nodesDir string) []gpuInfo {
entries, err := os.ReadDir(nodesDir)
if err != nil {
return nil
}
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name())
}
sort.Slice(names, func(i, j int) bool { return atoiSafe(names[i]) < atoiSafe(names[j]) })
var gpus []gpuInfo
for _, n := range names {
b, err := os.ReadFile(filepath.Join(nodesDir, n, "properties"))
if err != nil {
continue
}
simd, gfxVer := kfdNodeProps(b)
if simd <= 0 || gfxVer <= 0 {
continue // CPU node or non-GPU
}
arch := gfxName(gfxVer)
name := "AMD GPU (" + arch + ")"
if fam := amdFamily(arch); fam != "" {
name = fam + " (" + arch + ")"
}
gpus = append(gpus, gpuInfo{Name: name, Arch: arch})
}
return gpus
}
// kfdNodeProps extracts simd_count and gfx_target_version from a kfd node's
// properties file (space-separated "key value" lines).
func kfdNodeProps(properties []byte) (simd, gfxVer int) {
sc := bufio.NewScanner(bytes.NewReader(properties))
for sc.Scan() {
k, v, ok := strings.Cut(strings.TrimSpace(sc.Text()), " ")
if !ok {
continue
}
switch k {
case "simd_count":
simd = atoiSafe(strings.TrimSpace(v))
case "gfx_target_version":
gfxVer = atoiSafe(strings.TrimSpace(v))
}
}
return simd, gfxVer
}
// gfxName decodes a kfd gfx_target_version into the LLVM gfx target string:
// 110501 → "gfx1151" (major=v/10000, minor=(v/100)%100, step=v%100).
func gfxName(v int) string {
return fmt.Sprintf("gfx%d%d%d", v/10000, (v/100)%100, v%100)
}
// amdFamily maps a gfx target to the APU/SoC family marketing name, so the board
// says what the silicon IS ("AMD Strix Halo"), not just the iGPU series. Discrete
// cards return "" and keep their own name.
func amdFamily(arch string) string {
switch arch {
case "gfx1151":
return "AMD Strix Halo"
case "gfx1150":
return "AMD Strix Point"
case "gfx1103":
return "AMD Phoenix"
}
return ""
}
// apuProcessorNames renders a unified-memory APU as the PROCESSOR it is — the
// cpuinfo marketing name ("AMD Ryzen AI Max+ 395 w/ Radeon 8060S") beats the
// iGPU series, because that is the product the owner bought. Discrete cards and
// non-Ryzen hosts keep their existing names.
func apuProcessorNames(gpus []gpuInfo, cpuModel string) []gpuInfo {
name := amdAPUName(cpuModel)
if name == "" {
return gpus
}
for i, g := range gpus {
if !g.Unified {
continue
}
if g.Arch != "" {
gpus[i].Name = name + " (" + g.Arch + ")"
} else {
gpus[i].Name = name
}
}
return gpus
}
// amdAPUName normalizes an AMD APU cpuinfo model into its marketing name:
// "AMD RYZEN AI MAX+ 395 w/ Radeon 8060S" -> "AMD Ryzen AI Max+ 395 w/ Radeon
// 8060S". BIOS strings shout; the board should not. Non-Ryzen models return "".
func amdAPUName(cpuModel string) string {
m := strings.Join(strings.Fields(cpuModel), " ")
if !strings.Contains(strings.ToLower(m), "ryzen") {
return ""
}
words := strings.Split(m, " ")
for i, w := range words {
if w == "AMD" || w == "AI" || w != strings.ToUpper(w) {
continue // brand/initialism stays; mixed-case is already right
}
if strings.ContainsFunc(w, unicode.IsDigit) {
continue // model numbers ("8060S", "395") keep their casing
}
if r := []rune(w); len(r) > 1 && strings.ContainsFunc(w, unicode.IsLetter) {
words[i] = string(r[0]) + strings.ToLower(string(r[1:]))
}
}
return strings.Join(words, " ")
}
// amdSoft is the host ROCm/HIP toolchain inventory, detected once: ROCm from
// /opt/rocm/.info/version, HIP from `hipconfig --version` (which exists even on
// TheRock-style installs that lack the .info file). Empty on non-AMD hosts.
type amdSoft struct{ rocm, hip string }
var amdSoftware = sync.OnceValue(func() amdSoft {
var v amdSoft
if b, err := os.ReadFile("/opt/rocm/.info/version"); err == nil {
v.rocm = strings.TrimSpace(string(b))
}
if out, err := exec.Command("hipconfig", "--version").Output(); err == nil {
v.hip = strings.TrimSpace(string(out))
}
return v
})
// pickAmdMem chooses the honest memory figure for a card: dedicated VRAM for a
// discrete GPU; for an APU whose "VRAM" is a token carve-out (Strix Halo: 1 GiB
// VRAM beside a ~118 GiB GTT pool) the figure is the MACHINE's unified RAM —
// the same convention Apple silicon reports (an M4 Max says 128 GiB, not the
// wired-down remainder) — snapped to hardware capacity. Unified when GTT wins.
func pickAmdMem(vramMiB, gttMiB, hostMiB int64) (miB int64, unified bool) {
if gttMiB > vramMiB*4 {
m := gttMiB
if hostMiB > m {
m = hostMiB
}
return snapUnified(m), true
}
return vramMiB, false
}
// snapUnified rounds a kernel-visible unified-memory figure up to the hardware
// DIMM capacity (the next 16 GiB multiple) when the gap is a plausible firmware
// reservation (≤ 6 GiB): a 128 GiB Strix Halo shows 124.4 GiB to Linux because
// BIOS + the VRAM carve-out are invisible to the OS. A larger gap (say a 16 GiB
// carve-out) is real capacity the pool lost — reported as-is, never invented.
func snapUnified(miB int64) int64 {
const step = 16 << 10 // 16 GiB in MiB
next := ((miB + step - 1) / step) * step
if next-miB <= 8<<10 {
return next
}
return miB
}
// amdMem is one card's memory inventory in MiB: dedicated VRAM plus the GTT
// (system-memory) pool an APU actually computes in.
type amdMem struct{ vramMiB, gttMiB int64 }
// amdVRAMTotals returns each amdgpu card's memory totals in MiB, in DRM card order,
// read from /sys/class/drm/card*/device/mem_info_{vram,gtt}_total (vendor 0x1002).
func amdVRAMTotals(drmDir string) []amdMem {
entries, err := os.ReadDir(drmDir)
if err != nil {
return nil
}
cards := make([]string, 0, len(entries))
for _, e := range entries {
n := e.Name()
if strings.HasPrefix(n, "card") && !strings.Contains(n, "-") {
cards = append(cards, n)
}
}
sort.Slice(cards, func(i, j int) bool {
return atoiSafe(strings.TrimPrefix(cards[i], "card")) < atoiSafe(strings.TrimPrefix(cards[j], "card"))
})
readMiB := func(path string) int64 {
b, err := os.ReadFile(path)
if err != nil {
return 0
}
var n int64
if _, err := fmt.Sscan(strings.TrimSpace(string(b)), &n); err != nil || n <= 0 {
return 0
}
return n / (1024 * 1024)
}
var mems []amdMem
for _, c := range cards {
dev := filepath.Join(drmDir, c, "device")
if vendor, _ := os.ReadFile(filepath.Join(dev, "vendor")); strings.TrimSpace(string(vendor)) != "0x1002" {
continue
}
vram := readMiB(filepath.Join(dev, "mem_info_vram_total"))
if vram == 0 {
continue
}
mems = append(mems, amdMem{vramMiB: vram, gttMiB: readMiB(filepath.Join(dev, "mem_info_gtt_total"))})
}
return mems
}
// fillAmdVRAM attaches memory totals to the GPU list positionally when the counts
// match (the common single-GPU case always does); otherwise the names stand alone.
// An APU reports its unified GTT pool, not the token VRAM carve-out.
func fillAmdVRAM(gpus []gpuInfo, mems []amdMem) []gpuInfo {
if len(mems) != len(gpus) {
return gpus
}
hostMiB := detectMemTotal() / (1 << 20)
for i := range gpus {
miB, unified := pickAmdMem(mems[i].vramMiB, mems[i].gttMiB, hostMiB)
gpus[i].MemoryTotal = fmt.Sprintf("%d MiB", miB)
gpus[i].Unified = unified
}
return gpus
}
// parseVulkaninfoSummary is the last-resort AMD path: it scrapes GPU device names
// from `vulkaninfo --summary` (the "deviceName = ..." lines), keeping AMD/Radeon
// devices only so it never double-counts an NVIDIA card already handled upstream.
func parseVulkaninfoSummary(out []byte) []gpuInfo {
sc := bufio.NewScanner(bytes.NewReader(out))
var gpus []gpuInfo
for sc.Scan() {
_, v, ok := strings.Cut(sc.Text(), "deviceName")
if !ok {
continue
}
name := strings.TrimSpace(strings.TrimLeft(v, " =\t"))
if name == "" {
continue
}
if l := strings.ToLower(name); strings.Contains(l, "amd") || strings.Contains(l, "radeon") || strings.Contains(l, "gfx") {
gpus = append(gpus, gpuInfo{Name: name})
}
}
return gpus
}
// atoiSafe parses an int, returning 0 on any error (0 never denotes a GPU node).
func atoiSafe(s string) int {
n, _ := strconv.Atoi(strings.TrimSpace(s))
return n
}
// detectAppleGPU reports the Apple Silicon chip as one GPU with the machine's
// unified memory (Metal/MPS shares it all), MiB-formatted like nvidia-smi.
func detectAppleGPU() []gpuInfo {
brand, err := exec.Command("sysctl", "-n", "machdep.cpu.brand_string").Output()
if err != nil {
return nil
}
name := strings.TrimSpace(string(brand))
if !strings.HasPrefix(name, "Apple") {
return nil
}
info := gpuInfo{Name: name + " (Metal)"}
if mem, err := exec.Command("sysctl", "-n", "hw.memsize").Output(); err == nil {
var b int64
if _, err := fmt.Sscan(strings.TrimSpace(string(mem)), &b); err == nil && b > 0 {
info.MemoryTotal = fmt.Sprintf("%d MiB", b/(1024*1024))
}
}
return []gpuInfo{info}
}
// detectArch reports this machine's CPU architecture in the SAME convention the
// fleet already uses for code-linked nodes — `uname -m` (aarch64 | x86_64 on Linux,
// arm64 | x86_64 on Darwin) — so a machine that shows up as both a run-target and a
// linked worker carries ONE arch string on the board. Falls back to the
// compiled runtime.GOARCH only if uname is unavailable; "" is never forced.
func detectArch() string {
if out, err := exec.Command("uname", "-m").Output(); err == nil {
if a := strings.TrimSpace(string(out)); a != "" {
return a
}
}
return runtime.GOARCH
}
// detectCPUModel reports this machine's CPU model name so the node advertises its
// processor, not just its core count — Linux reads /proc/cpuinfo's "model name"
// (x86) or "Model" (arm64 boards like spark's GB10), Darwin reads sysctl
// machdep.cpu.brand_string. Empty when unreadable (reported via omitempty, never
// faked); a bare `uname -m` arch still travels on the record.
func detectCPUModel() string {
if runtime.GOOS == "darwin" {
if out, err := exec.Command("sysctl", "-n", "machdep.cpu.brand_string").Output(); err == nil {
return strings.TrimSpace(string(out))
}
return ""
}
b, err := os.ReadFile("/proc/cpuinfo")
if err != nil {
return ""
}
sc := bufio.NewScanner(bytes.NewReader(b))
for sc.Scan() {
line := sc.Text()
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "model name", "Model", "cpu model":
if v := strings.TrimSpace(val); v != "" {
return v
}
}
}
return ""
}
// detectMemTotal returns this machine's total physical RAM in bytes, or 0 when it
// cannot be read (reported as "unknown" via omitempty — never faked). Linux reads
// /proc/meminfo's MemTotal (covers evo-2's Strix Halo and spark's GB10, both Linux);
// Darwin reads sysctl hw.memsize. This is the SAME total a code-linked box reports
// as Spec.Memory, so the fleet board describes both kinds of node identically.
func detectMemTotal() int64 {
if runtime.GOOS == "darwin" {
out, err := exec.Command("sysctl", "-n", "hw.memsize").Output()
if err != nil {
return 0
}
var b int64
if _, err := fmt.Sscan(strings.TrimSpace(string(out)), &b); err == nil && b > 0 {
return b
}
return 0
}
b, err := os.ReadFile("/proc/meminfo")
if err != nil {
return 0
}
return parseMemTotalKB(b)
}
// parseMemTotalKB extracts MemTotal from /proc/meminfo content (reported in kB) and
// returns it in bytes, or 0 when the line is absent or malformed.
func parseMemTotalKB(meminfo []byte) int64 {
sc := bufio.NewScanner(bytes.NewReader(meminfo))
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "MemTotal:") {
continue
}
f := strings.Fields(line) // "MemTotal:" <kb> "kB"
if len(f) < 2 {
return 0
}
kb, err := strconv.ParseInt(f[1], 10, 64)
if err != nil || kb <= 0 {
return 0
}
return kb * 1024
}
return 0
}
// sampleProbeTimeout hard-caps the nvidia-smi probe; sampleReportTimeout bounds the
// whole detached report (probe + POST). A hung nvidia-smi (GPU/driver pressure) thus
// self-cancels instead of wedging the worker's select loop.
const (
sampleProbeTimeout = 5 * time.Second
sampleReportTimeout = 20 * time.Second
)
// nvidiaSmi runs the utilization query bounded by ctx (CommandContext kills it when
// ctx fires). A package var so a test can substitute a hung/slow probe and prove it
// can never block the worker loop.
var nvidiaSmi = func(ctx context.Context) ([]byte, error) {
return exec.CommandContext(ctx, "nvidia-smi",
"--query-gpu=utilization.gpu,memory.used,memory.free",
"--format=csv,noheader,nounits").Output()
}
// sampleReport is this machine's live GPU utilization for the fleet series.
type sampleReport struct {
GPUUtil float64 // mean across cards, 0..1
GPUs int
GPUModel string
MemUsed int64 // bytes, summed across cards
MemFree int64 // bytes, summed across cards
}
// sampleGPUs reads live utilization from nvidia-smi (utilization.gpu %, memory
// used/free MiB), averaging util to 0..1 and summing memory across cards. A box
// without nvidia-smi (Apple / CPU-only) returns a zero-util report that still
// carries its GPU count + model — a valid liveness point, never faked numbers.
func (w *worker) sampleGPUs(ctx context.Context) sampleReport {
rep := sampleReport{GPUs: len(w.gpus)}
if len(w.gpus) > 0 {
rep.GPUModel = w.gpus[0].Name
}
pctx, cancel := context.WithTimeout(ctx, sampleProbeTimeout)
defer cancel()
out, err := nvidiaSmi(pctx)
if err != nil {
return rep // hung/absent nvidia-smi self-cancels → inventory-only report, never a stall
}
var utilSum float64
var n int
sc := bufio.NewScanner(bytes.NewReader(out))
for sc.Scan() {
f := strings.Split(sc.Text(), ",")
if len(f) < 3 {
continue
}
if u, e := strconv.ParseFloat(strings.TrimSpace(f[0]), 64); e == nil {
utilSum += u
n++
}
if used, e := strconv.ParseInt(strings.TrimSpace(f[1]), 10, 64); e == nil {
rep.MemUsed += used << 20 // MiB → bytes
}
if free, e := strconv.ParseInt(strings.TrimSpace(f[2]), 10, 64); e == nil {
rep.MemFree += free << 20
}
}
if n > 0 {
rep.GPUUtil = utilSum / float64(n) / 100.0 // percent → 0..1
}
return rep
}
// reportSample posts this machine's live utilization to the org's fleet series
// (POST /v1/fleet/samples) so the console board shows THIS GPU's load beside its
// inventory. DETACHED + bounded: the probe and POST run on their OWN goroutine under
// a hard timeout, so a hung nvidia-smi (GPU/driver pressure) or a slow POST can NEVER
// block the worker's select loop — heartbeats and claims keep flowing. At most one
// report is in flight per ticker site (interval ≫ budget), and it self-cancels on
// worker shutdown (parent ctx). A dropped sample is the server's to log, never the
// worker's to stall on.
func (w *worker) reportSample(parent context.Context) {
go func() {
ctx, cancel := context.WithTimeout(parent, sampleReportTimeout)
defer cancel()
r := w.sampleGPUs(ctx)
_, _ = w.call(ctx, http.MethodPost, "/v1/fleet/samples", map[string]any{
"unit": w.identity,
"host": w.hostname,
"gpuUtil": r.GPUUtil,
"gpus": r.GPUs,
"gpuModel": r.GPUModel,
"memUsed": r.MemUsed,
"memFree": r.MemFree,
}, nil)
}()
}
// ---------------------------------------------------------------------------
// connect.
// ---------------------------------------------------------------------------
// connectOpts is the resolved `hanzo link` compute-worker configuration.
type connectOpts struct {
jobsNS string
serveEngine bool
engineURL string // local URL to probe hanzo-engine
engineEndpoint string // public URL to advertise (defaults to engineURL)
registerProvider bool // auto POST /v1/ai/providers for the engine
studioDir string // local Studio checkout to launch + supervise on :8188
studioURL string // studio base the render mirror uploads finished images to
mirror bool // sweep local renders into the org studio library (default on)
}
func runConnect(cmd *cobra.Command, env *Env, opts connectOpts) error {
ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM)
defer stop()
if _, err := env.ensureToken(ctx); err != nil {
return err
}
w, err := newWorker(env, opts.jobsNS)
if err != nil {
return err
}
out := cmd.OutOrStdout()
// engine.serve: probe the local hanzo-engine once so the first registration
// carries its live model list + reachability.
if opts.serveEngine {
w.serveEngine = true
w.engineURL = firstNonEmpty(opts.engineURL, defaultEngineURL)
w.engineAdvURL = firstNonEmpty(opts.engineEndpoint, w.engineURL)
w.refreshEngine(ctx)
}
// studio.render preflight: decide whether this node can SERVE renders BEFORE it
// advertises studioCap or claims a render lane. Every node probes, including one
// that launches its own studio below — at this point that studio has not been
// started yet, so a node claiming work here would be claiming it cold.
// The worker token is required either way — the gated execute seam refuses without it.
w.launchesStudio = opts.studioDir != ""
w.refreshStudioReady(ctx)
if err := w.register(ctx); err != nil {
return fmt.Errorf("register: %w", err)
}
fmt.Fprintf(out, "linked %q into %s as a node (org %s)\n", w.hostname, w.baseURL, orgOf(env))
fmt.Fprintf(out, " cpu: %s\n", describeCPU(w))
fmt.Fprintf(out, " gpu: %s\n", describeGPUs(w.gpus))
w.warnIfCannotRender(cmd.ErrOrStderr())
// studio.render backend: the claim loop drives the LOCAL studio server, so
// when a checkout is named we own its lifecycle too — no separate watchdog.
if opts.studioDir != "" {
go superviseStudio(ctx, opts.studioDir, out)
}
fmt.Fprintf(out, "claiming %s jobs; heartbeating every %s. Ctrl-C to stop (the node goes offline after ~90s; `hanzo unlink` removes it).\n", w.jobsNS, heartbeatEvery)
if w.serveEngine {
w.printEngineHint(out)
if opts.registerProvider {
if err := w.registerProvider(ctx, w.engine); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "register-provider: %v\n", err)
} else {
fmt.Fprintf(out, " → registered org model provider %q on %s\n", "gpu-"+w.identity, w.baseURL)
}
}
}
hb := time.NewTicker(heartbeatEvery)
defer hb.Stop()
poll := time.NewTicker(claimPoll)
defer poll.Stop()
// Render mirror — independent of claims by design. It scans the local studio
// output tree every heartbeatEvery and uploads every image to the org's library
// (POST /v1/library/upload), so EVERY render lands in studio.hanzo.ai even when
// it was produced outside the job path — a graph hand-run on this node, or a
// render that finished after its activity was reaped (the stranded-late-render
// class). Active only when a studio checkout is named (there is local output to
// mirror); a nil channel case never fires when it is not.
w.studioUploadURL = firstNonEmpty(opts.studioURL, w.studioUploadURL)
mirrorBase := w.studioUploadURL
mirrorDir := ""
seen := map[string]int64{}
var mirC <-chan time.Time
if opts.studioDir != "" && opts.mirror {
mirrorDir = filepath.Join(opts.studioDir, "output")
mir := time.NewTicker(heartbeatEvery)
defer mir.Stop()
mirC = mir.C
}
// Heartbeat once immediately so the machine reports online without waiting a
// full interval, and report an initial utilization sample.
_ = w.heartbeat(ctx)
w.reportSample(ctx)
for {
select {
case <-ctx.Done():
fmt.Fprintln(out, "\nstopping (node will go offline; run `hanzo unlink` to remove it)")
return nil
case <-hb.C:
// Re-probe the engine; if its reachability or model set changed, rewrite
// the presence record so the fleet advertisement stays honest (e.g. the
// engine came up after connect, or loaded a new model).
if w.serveEngine && w.refreshEngine(ctx) {
if err := w.register(ctx); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "re-advertise engine: %v\n", err)
} else {
fmt.Fprintf(out, "engine %s — %s\n", w.engine.URL, describeEngine(w.engine))
}
}
// Re-evaluate render readiness; if it flipped, re-advertise (studioCap on/off)
// so the fleet never shows a node accepting renders it can't serve.
if w.refreshStudioReady(ctx) {
if err := w.register(ctx); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "re-advertise capabilities: %v\n", err)
}
if w.studioReady {
w.studioWarned = false
fmt.Fprintf(out, "studio ready — now accepting render jobs\n")
} else {
w.warnIfCannotRender(cmd.ErrOrStderr())
}
}
if err := w.heartbeat(ctx); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "heartbeat: %v\n", err)
}
w.reportSample(ctx)
case <-poll.C:
if err := w.claimAndRun(ctx, out); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "claim: %v\n", err)
}
case <-mirC:
w.mirrorRenders(ctx, out, mirrorDir, mirrorBase, seen)
}
}
}
// register ensures the fleet + jobs namespaces exist, then writes this machine's
// presence record (activityId==runId==identity, no requestId so a reconnect
// overwrites any prior/terminal record with a fresh online row).
func (w *worker) register(ctx context.Context) error {
// Ensuring the namespaces is BEST-EFFORT: they already exist in any live org, so a
// transient API blip here must not kill the worker. It used to be fatal, and with
// systemd Restart=always/RestartSec=5 one 503 turned into an infinite crash-loop
// (observed at 141 restarts, machine offline the whole time). If a namespace truly
// is missing, the presence write below fails and surfaces the real error.
for _, ns := range []string{fleetNS, w.jobsNS} {
if _, err := w.call(ctx, http.MethodPost, "/v1/tasks/namespaces", map[string]any{
"namespaceInfo": map[string]any{"name": ns},
}, nil); err != nil {
fmt.Fprintf(os.Stderr, "ensure namespace %q (continuing): %v\n", ns, err)
}
}
// The presence write RIDES OUT a rolling control plane. It is the call whose
// error ends the process, and systemd restarts us — which kills the studio this
// worker supervises, and with it whatever render was sampling at the time. So a
// cloud deploy destroyed renders on every BYO box: api.hanzo.ai answered
// "503 no available server" for the few seconds its pod was replaced, three
// restarts landed in that window, and a compose that had been running for
// minutes was gone.
//
// A blip is not an outage. Retry with backoff and only then report; a genuinely
// missing namespace or a real rejection still surfaces, just later.
//
// But only a blip. A REFUSAL is not one: 401/403 says this node's credential is
// not accepted, and no amount of waiting changes that. Retrying it burned 30s per
// boot inside a systemd restart loop — seen at restart counter 10 on a node whose
// token had expired — and buried the one line that said what was wrong under five
// that said "retrying". Fail fast, and say what to do about it.
var err error
for attempt := 0; attempt < registerAttempts; attempt++ {
var code int
if code, err = w.call(ctx, http.MethodPost, "/v1/tasks/namespaces/"+fleetNS+"/activities", map[string]any{
"activityId": w.identity,
"runId": w.identity,
"activityType": map[string]any{"name": "fleet.worker"},
"taskQueue": fleetNS,
"heartbeatTimeout": "120s",
"input": w.buildRegistration(),
}, nil); err == nil {
return nil
}
if ctx.Err() != nil {
return err
}
if code == http.StatusUnauthorized || code == http.StatusForbidden {
return fmt.Errorf("%w — this node's credential was refused; run `hanzo login` on it", err)
}
if attempt < registerAttempts-1 {
fmt.Fprintf(os.Stderr, "register (attempt %d/%d, retrying in %s): %v\n",
attempt+1, registerAttempts, registerBackoff, err)
select {
case <-ctx.Done():
return err
case <-time.After(registerBackoff):
}
}
}
return err
}
// buildRegistration is the pure presence-record payload: this machine's inventory,
// its advertised capabilities, and (when serving) its hanzo-engine endpoint.
func (w *worker) buildRegistration() registration {
return registration{
Hostname: w.hostname,
Os: runtime.GOOS,
Arch: w.arch,
CPUs: runtime.NumCPU(),
CPUModel: w.cpuModel,
Memory: w.memory,
Version: Version,
JobQueue: w.jobsNS,
Rocm: amdSoftware().rocm,
Hip: amdSoftware().hip,
Cuda: nvidiaSoftware().cuda,
Driver: nvidiaSoftware().driver,
GPUs: w.gpus,
Capabilities: w.capabilities(),
Engine: w.engine,
Policy: w.policy,
}
}
// capabilities lists what this worker does for the org. studio.render is always
// present (it claims gpu-jobs); engine.serve is added when --serve-engine advertises
// a local hanzo-engine model server.
func (w *worker) capabilities() []string {
caps := []string{}
if w.studioReady {
caps = append(caps, studioCap) // only advertised when this node can actually render
}
if w.serveEngine {
caps = append(caps, engineCap)
}
if _, ok := w.handlers[fnCap]; ok {
caps = append(caps, fnCap)
}
return caps
}
// hasNonRenderLane reports whether this worker serves any lane beyond the render
// path (echo is a smoke type, not a lane).
func (w *worker) hasNonRenderLane() bool {
for name := range w.handlers {
if name != "echo" && name != studioCap {
return true
}
}
return false
}
// studioReachable probes the local studio's /queue (bounded), authenticated. A node
// that can answer it is up enough to accept a render.
func (w *worker) studioReachable(ctx context.Context) bool {
rctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(rctx, http.MethodGet, localComfyUI+"/queue", nil)
if err != nil {
return false
}
authLocal(req)
resp, err := w.http.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<10))
return resp.StatusCode/100 == 2
}
// refreshStudioReady recomputes whether this node can serve renders and returns
// whether the verdict changed. Ready ⇔ a worker token is present AND a studio
// ANSWERS right now. The token is the decisive gate: without it the gated execute
// seam 403s every job.
//
// Reachability is MEASURED even when we launch the studio ourselves. Launching one
// does not make it serve — it makes it serve eventually, after it binds the port and
// loads its models, which on a cold box is minutes. Treating "--studio-dir was
// passed" as readiness advertised studio.render and claimed render jobs from the
// first instant of the process, before superviseStudio had even been started (it is
// launched after this runs), so a cold node won every job it could reach and failed
// each one on arrival. Nothing deadlocks by probing instead: the studio launch does
// not depend on this verdict, and the heartbeat loop re-evaluates it every 30s and
// flips claiming on the way up — the same path that already recovers a studio that
// died under a node that had been ready.
func (w *worker) refreshStudioReady(ctx context.Context) bool {
ready := workerToken() != "" && w.studioReachable(ctx)
changed := ready != w.studioReady
w.studioReady = ready
return changed
}
// warnIfCannotRender emits the loud "this node won't render" message once per
// not-ready spell (latched), and clears the latch when the node recovers so a later
// regression warns again.
func (w *worker) warnIfCannotRender(errw io.Writer) {
if w.studioReady {
w.studioWarned = false
return
}
if w.studioWarned {
return
}
w.studioWarned = true
fmt.Fprintf(errw, "WARNING: this node will NOT accept renders — %s. It heartbeats as present but claims no render jobs.\n", w.studioBlockReason())
}
// studioBlockReason explains why a node won't serve renders, for the loud operator
// warning. Empty when the node IS ready.
func (w *worker) studioBlockReason() string {
if w.studioReady {
return ""
}
if workerToken() == "" {
return "STUDIO_WORKER_TOKEN not set (the gated studio seam would refuse every render) — set it from KMS"
}
if w.launchesStudio {
return fmt.Sprintf("the studio we launched is not serving on %s yet (still starting, or it failed) — this clears by itself once it answers", localComfyUI)
}
return fmt.Sprintf("no studio reachable on %s (start one, or pass --studio-dir so the worker launches it)", localComfyUI)
}
func (w *worker) heartbeat(ctx context.Context) error {
path := fmt.Sprintf("/v1/tasks/namespaces/%s/activities/%s/%s/heartbeat", fleetNS, w.identity, w.identity)
_, err := w.call(ctx, http.MethodPost, path, map[string]any{
"details": map[string]any{"gpus": len(w.gpus), "ts": time.Now().UTC().Format(time.RFC3339)},
}, nil)
return err
}
// gpuQueue is this worker's OWN task-queue lane — the value a job names to target
// THIS machine ("gpu:spark"). Targeted jobs land here; the shared w.jobsNS
// ("gpu-jobs") lane stays the any-GPU broadcast. ONE namespace (w.jobsNS), two
// queue VALUES — targeting is the string, not a second store or endpoint.
func (w *worker) gpuQueue() string { return gpuQueuePrefix + w.identity }
// claimFrom claims the next job on ONE task-queue value within w.jobsNS. Returns
// (act, true, nil) when a job was claimed, (_, false, nil) on an empty queue (204).
func (w *worker) claimFrom(ctx context.Context, taskQueue string) (claimedActivity, bool, error) {
var act claimedActivity
code, err := w.call(ctx, http.MethodPost, "/v1/tasks/namespaces/"+w.jobsNS+"/activities/claim", map[string]any{
"taskQueue": taskQueue,
"identity": w.identity,
"leaseSeconds": claimLeaseSecs,
}, &act)
if err != nil {
return claimedActivity{}, false, err
}
return act, code != http.StatusNoContent, nil
}
// claimAndRun claims the next job — this machine's OWN lane first (gpu:<identity>),
// then the shared any-GPU lane — runs its handler, and reports the terminal result.
// Both lanes empty is a no-op. Targeted-lane-first guarantees a job pinned to THIS
// GPU is never starved by shared work; a job is only ever claimed under an explicit
// queue name, so one worker never steals another GPU's targeted job (an empty
// taskQueue on claim would match any lane and do exactly that).
func (w *worker) claimAndRun(ctx context.Context, out io.Writer) error {
// Poison-loop guard, per-LANE: a node that can't serve renders must not sit on
// render jobs — but a node with other lanes (fn.run) still claims. When renders
// are this worker's only real lane and the studio isn't ready, stay idle; a
// claimed render on a non-ready node is declined below so an eligible worker
// takes it.
if !w.studioReady && !w.hasNonRenderLane() {
return nil
}
act, claimed, err := w.claimFrom(ctx, w.gpuQueue())
if err != nil {
return err
}
if !claimed {
if act, claimed, err = w.claimFrom(ctx, w.jobsNS); err != nil {
return err
}
if !claimed {
return nil // both lanes empty — nothing to do
}
}
wf, run := act.Execution.WorkflowId, act.Execution.RunId
fmt.Fprintf(out, "claimed job %s (type %s)\n", wf, act.Type.Name)
// Sharing policy: decline (fail back) a job this machine isn't allowed to run,
// so an eligible worker can take it. Enforced once, here, at claim.
if reason := w.policy.reject(act.Type.Name, act.Input); reason != "" {
_, _ = w.call(ctx, http.MethodPost, w.actPath(wf, run, "fail"), map[string]any{"cause": "share policy: " + reason, "identity": w.identity}, nil)
fmt.Fprintf(out, " → declined (share policy: %s)\n", reason)
return nil
}
if act.Type.Name == studioCap && !w.studioReady {
// DECLINE BY LEASE LAPSE — deliberately no terminal report. `fail` is
// terminal: it does not return the job to anyone, so declining with it
// KILLED the render it claimed to be handing on. Two workers that had both
// just restarted each claimed one render and each failed it, and the person
// who asked for it got nothing back.
//
// The engine's verbs are claim/complete/fail/heartbeat/cancel — there is no
// release. What there IS is the claim's LEASE (claimLeaseSecs): a claim that
// is neither completed nor failed nor heartbeated expires and the job goes
// back to pending for whoever can run it. So the way to hand a job back is
// to say NOTHING and let the lease lapse, which is what this branch always
// meant by "declined".
fmt.Fprintf(out, " → declined (studio not ready on this node) — returning it to the queue by letting the %ds claim lease lapse\n", claimLeaseSecs)
return nil
}
h, ok := w.handlers[act.Type.Name]
if !ok {
cause := fmt.Sprintf("no handler for job type %q", act.Type.Name)
_, _ = w.call(ctx, http.MethodPost, w.actPath(wf, run, "fail"), map[string]any{"cause": cause, "identity": w.identity}, nil)
fmt.Fprintf(out, " → failed: %s\n", cause)
return nil
}
// Keep BOTH the claimed activity and this machine's fleet presence alive while
// the handler runs. A render blocks this call for minutes (a cold GB10 reloads
// ~40GB before sampling); without heartbeats the studio.render activity hits its
// heartbeatTimeout AND the fleet presence (120s) goes stale, so the machine
// drops offline mid-render and the next dispatch sees no online GPU. A ticker in
// a child context heartbeats both every heartbeatEvery until the handler returns.
hbCtx, stopHB := context.WithCancel(ctx)
go func() {
t := time.NewTicker(heartbeatEvery)
defer t.Stop()
for {
select {
case <-hbCtx.Done():
return
case <-t.C:
_, _ = w.call(ctx, http.MethodPost, w.actPath(wf, run, "heartbeat"),
map[string]any{"identity": w.identity}, nil)
_ = w.heartbeat(ctx) // fleet presence — stays online through the render
w.reportSample(ctx) // live util during the render (the board's hottest point)
}
}
}()
result, herr := h(ctx, act.Input)
stopHB()
if herr != nil {
_, _ = w.call(ctx, http.MethodPost, w.actPath(wf, run, "fail"), map[string]any{"cause": herr.Error(), "identity": w.identity}, nil)
fmt.Fprintf(out, " → failed: %v\n", herr)
return nil
}
if _, err := w.call(ctx, http.MethodPost, w.actPath(wf, run, "complete"), map[string]any{"result": result, "identity": w.identity}, nil); err != nil {
return fmt.Errorf("complete %s: %w", wf, err)
}
fmt.Fprintf(out, " → completed\n")
return nil
}
func (w *worker) actPath(wf, run, verb string) string {
return fmt.Sprintf("/v1/tasks/namespaces/%s/activities/%s/%s/%s", w.jobsNS, wf, run, verb)
}
type claimedActivity struct {
Execution struct {
WorkflowId string `json:"workflowId"`
RunId string `json:"runId"`
} `json:"execution"`
Type struct {
Name string `json:"name"`
} `json:"type"`
Input json.RawMessage `json:"input"`
TaskQueue string `json:"taskQueue"`
Status string `json:"status"`
}
// ---------------------------------------------------------------------------
// disconnect.
// ---------------------------------------------------------------------------
// fleetWorker is one row of GET /v1/fleet/workers — what the org's fleet reports
// back about a machine that linked in. Rendering it is the fabric CLI's `status`;
// here it is the shape this binary's registration must round-trip to.
type fleetWorker struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
Provider string `json:"provider"`
Location string `json:"location"`
Status string `json:"status"`
Arch string `json:"arch,omitempty"`
CPUs int `json:"cpus,omitempty"`
CPUModel string `json:"cpuModel,omitempty"`
Memory int64 `json:"memory,omitempty"`
GPUs []gpuInfo `json:"gpus"`
LastHeartbeat string `json:"lastHeartbeat"`
Capabilities []string `json:"capabilities,omitempty"`
Engine *engineAdvertisement `json:"engine,omitempty"`
}
// cpuSummary renders a node's CPU as "<cores> cores · <arch> · <model>", omitting
// any field the node did not report. "unknown" only when it reported nothing.
func cpuSummary(arch string, cpus int, model string) string {
var parts []string
if cpus > 0 {
parts = append(parts, fmt.Sprintf("%d cores", cpus))
}
if arch != "" {
parts = append(parts, arch)
}
if model != "" {
parts = append(parts, model)
}
if len(parts) == 0 {
return "unknown"
}
return strings.Join(parts, " · ")
}
// describeCPU renders THIS node's CPU line for the link confirmation.
func describeCPU(w *worker) string {
return cpuSummary(w.arch, runtime.NumCPU(), w.cpuModel) + " · " + humanBytes(w.memory)
}
// humanBytes renders a byte count as GiB/MiB, "unknown" for 0 (never faked).
func humanBytes(n int64) string {
switch {
case n <= 0:
return "unknown"
case n >= 1<<30:
return fmt.Sprintf("%.0f GiB", float64(n)/float64(1<<30))
default:
return fmt.Sprintf("%.0f MiB", float64(n)/float64(1<<20))
}
}
func runDisconnect(cmd *cobra.Command, env *Env) error {
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
defer cancel()
if _, err := env.ensureToken(ctx); err != nil {
return err
}
w, _ := newWorker(env, "")
out := cmd.OutOrStdout()
path := fmt.Sprintf("/v1/tasks/namespaces/%s/activities/%s/%s/complete", fleetNS, w.identity, w.identity)
code, err := w.call(ctx, http.MethodPost, path, map[string]any{
"result": map[string]any{"disconnected": true},
"identity": w.identity,
}, nil)
if err != nil {
// Idempotent: an already-terminal (409) or absent (404) fleet row is the
// desired end state, so a repeat `hanzo unlink` no-ops instead of erroring.
if code == http.StatusConflict || code == http.StatusNotFound {
fmt.Fprintf(out, "%q already unlinked (no active fleet row)\n", w.hostname)
return nil
}
return fmt.Errorf("deregister: %w", err)
}
fmt.Fprintf(out, "unlinked %q from the fleet\n", w.hostname)
return nil
}
// ---------------------------------------------------------------------------
// HTTP + token.
// ---------------------------------------------------------------------------
// call issues one authed JSON request to the cloud API, decoding a 2xx body into
// out (when non-nil) and returning the status code. A 204 returns (204,nil) with
// out untouched — the empty-queue signal the claim loop reads.
func (w *worker) call(ctx context.Context, method, path string, body, out any) (int, error) {
tok, err := w.env.ensureToken(ctx)
if err != nil {
return 0, err
}
var rdr io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return 0, err
}
rdr = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, w.baseURL+path, rdr)
if err != nil {
return 0, err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "hanzo-cli/"+Version)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := w.http.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode/100 != 2 {
return resp.StatusCode, fmt.Errorf("%s %s: HTTP %d: %s", method, path, resp.StatusCode, serverMessage(raw))
}
if out != nil && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return resp.StatusCode, fmt.Errorf("%s %s: decode response: %w", method, path, err)
}
}
return resp.StatusCode, nil
}
// ensureToken returns a valid IAM bearer token, refreshing it when it is missing
// or within 90s of expiry and a refresh token is available. Refreshed credentials
// are persisted so a long-running connect loop survives token rotation. Returns a
// clear error when no usable credential exists.
func (e *Env) ensureToken(ctx context.Context) (string, error) {
if t := os.Getenv("HANZO_TOKEN"); t != "" {
return t, nil
}
if e.creds == nil || e.creds.AccessToken == "" {
return "", fmt.Errorf("not logged in: run `hanzo login`")
}
needsRefresh := e.creds.Expiry > 0 && time.Now().Add(90*time.Second).Unix() >= e.creds.Expiry
if needsRefresh && e.creds.RefreshToken != "" {
iam := newIAMClient(e.IAMIssuer, e.ClientID)
tr, err := iam.refreshGrant(ctx, e.creds.RefreshToken)
if err == nil {
nc := credsFromToken(tr)
// Preserve any machine tokens + a refresh token IAM did not re-issue.
nc.PlatformToken = e.creds.PlatformToken
nc.BuildToken = e.creds.BuildToken
if nc.RefreshToken == "" {
nc.RefreshToken = e.creds.RefreshToken
}
*e.creds = *nc
_ = SaveActive(e.creds) // refresh the active identity in the store + mirror
}
// On refresh failure fall through: the current token may still be valid
// (clock skew) and the server is the authority.
}
return e.creds.AccessToken, nil
}
func orgOf(e *Env) string {
if e.creds != nil && e.creds.Owner != "" {
return e.creds.Owner
}
return "?"
}
func describeGPUs(gpus []gpuInfo) string {
if len(gpus) == 0 {
return "CPU-only"
}
parts := make([]string, len(gpus))
for i, g := range gpus {
if g.MemoryTotal != "" {
parts[i] = g.Name + " (" + g.MemoryTotal + ")"
} else {
parts[i] = g.Name
}
}
return strings.Join(parts, ", ")
}
// ---------------------------------------------------------------------------
// Job handlers (pluggable).
// ---------------------------------------------------------------------------
// echoHandler returns its input verbatim — a trivial, GPU-free job for smoke
// tests and E2E, and the reference shape for a job handler.
func echoHandler(_ context.Context, input json.RawMessage) (any, error) {
var v any
if len(input) > 0 {
_ = json.Unmarshal(input, &v)
}
return map[string]any{"echo": v}, nil
}
// fnRunInput is the fn.run job payload: an inline script executed on this node in
// an ephemeral `uv run` environment. The queue is org-scoped, so the submitter is
// running code on their OWN fleet — same trust domain as a CI runner.
type fnRunInput struct {
Name string `json:"name,omitempty"` // label for logs
Script string `json:"script"` // Python source (required)
Requirements []string `json:"requirements,omitempty"` // uv --with deps, e.g. ["numpy", "torch==2.5.*"]
Env map[string]string `json:"env,omitempty"` // extra environment
TimeoutSeconds int `json:"timeoutSeconds,omitempty"` // default 3600, cap 21600
}
const (
fnDefaultTimeout = time.Hour
fnMaxTimeout = 6 * time.Hour
fnOutputTail = 128 << 10 // keep the LAST 128 KiB of combined output
)
// fnValidate parses and bounds an fn.run payload. Requirements must look like
// package specs — a leading dash would inject uv flags.
func fnValidate(input json.RawMessage) (fnRunInput, error) {
var in fnRunInput
if err := json.Unmarshal(input, &in); err != nil {
return in, fmt.Errorf("fn.run: bad input: %w", err)
}
if strings.TrimSpace(in.Script) == "" {
return in, fmt.Errorf("fn.run: input needs a `script`")
}
for _, r := range in.Requirements {
if r == "" || strings.HasPrefix(r, "-") {
return in, fmt.Errorf("fn.run: bad requirement %q", r)
}
}
if in.TimeoutSeconds <= 0 {
in.TimeoutSeconds = int(fnDefaultTimeout / time.Second)
}
if in.TimeoutSeconds > int(fnMaxTimeout/time.Second) {
in.TimeoutSeconds = int(fnMaxTimeout / time.Second)
}
return in, nil
}
// tailBuffer keeps the last cap bytes written — a training loop can log gigabytes;
// the activity result carries the end, where the outcome lives.
type tailBuffer struct {
cap int
buf []byte
truncated bool
}
func (t *tailBuffer) Write(p []byte) (int, error) {
t.buf = append(t.buf, p...)
if len(t.buf) > t.cap {
t.buf = t.buf[len(t.buf)-t.cap:]
t.truncated = true
}
return len(p), nil
}
// fnRunHandler executes an fn.run job: write the script to an ephemeral dir, run
// it under `uv run` (which resolves requirements into a throwaway env — ROCm/CUDA
// wheels included), and return the output tail + exit code. Nonzero exit fails
// the activity with the tail as the cause, so the submitter sees the traceback.
func fnRunHandler(ctx context.Context, input json.RawMessage) (any, error) {
in, err := fnValidate(input)
if err != nil {
return nil, err
}
dir, err := os.MkdirTemp("", "fnrun-*")
if err != nil {
return nil, fmt.Errorf("fn.run: %w", err)
}
defer os.RemoveAll(dir)
script := filepath.Join(dir, "main.py")
if err := os.WriteFile(script, []byte(in.Script), 0o600); err != nil {
return nil, fmt.Errorf("fn.run: %w", err)
}
runCtx, cancel := context.WithTimeout(ctx, time.Duration(in.TimeoutSeconds)*time.Second)
defer cancel()
args := []string{"run", "--no-project", "--quiet"}
for _, r := range in.Requirements {
args = append(args, "--with", r)
}
args = append(args, script)
cmd := exec.CommandContext(runCtx, "uv", args...)
cmd.Dir = dir
cmd.Env = os.Environ()
for k, v := range in.Env {
cmd.Env = append(cmd.Env, k+"="+v)
}
tail := &tailBuffer{cap: fnOutputTail}
cmd.Stdout = tail
cmd.Stderr = tail
start := time.Now()
runErr := cmd.Run()
dur := time.Since(start)
out := string(tail.buf)
if runCtx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("fn.run: timed out after %ds\n%s", in.TimeoutSeconds, out)
}
if runErr != nil {
return nil, fmt.Errorf("fn.run: %v\n%s", runErr, out)
}
return map[string]any{
"name": in.Name,
"exitCode": 0,
"durationMs": dur.Milliseconds(),
"output": out,
"truncated": tail.truncated,
}, nil
}
// workerToken is the KMS-sourced STUDIO_WORKER_TOKEN this box holds — the credential
// the local studio's --worker-mode gate checks. Empty on an unconfigured box (the
// render preflight refuses such a node before it ever claims a render lane).
func workerToken() string { return os.Getenv("STUDIO_WORKER_TOKEN") }
// authLocal stamps the worker token on a loopback studio request. The studio's
// --worker-mode gate guards the submit seam today, but sending it on ALL loopback
// calls (execute/history/view/upload/queue) makes the CLI robust if that scope widens
// — one way to talk to the local studio, always authenticated.
func authLocal(req *http.Request) {
if t := workerToken(); t != "" {
req.Header.Set("X-Worker-Token", t)
}
}
// studioRenderHandler POSTs the job payload to the local ComfyUI /prompt endpoint
// and polls /history/{id} until the prompt completes. The GPU work happens entirely
// on the LOCAL studio server (127.0.0.1:8188); this handler drives it, then uploads
// the finished outputs to the org's cloud studio gallery (POST /upload/output),
// authorized by the user's IAM token — so the render lands in orgs/{org}/output
// (S3-mirrored to the gallery) with no S3/rclone credentials ever on this box.
// The payload is the ComfyUI prompt graph (input.prompt) — the same body
// studio.hanzo.ai submits; input.uploadUrl (optional) overrides the gallery target.
func (w *worker) studioRenderHandler(ctx context.Context, input json.RawMessage) (any, error) {
var req struct {
Prompt json.RawMessage `json:"prompt"`
UploadURL string `json:"uploadUrl"`
Org string `json:"org"`
Inputs []inputImage `json:"inputs"`
}
if err := json.Unmarshal(input, &req); err != nil || len(req.Prompt) == 0 {
return nil, fmt.Errorf("studio.render: input needs a `prompt` graph")
}
cl := &http.Client{Timeout: 60 * time.Second}
// The claim-to-submit window: hold off the supervisor's recycle, and wait out
// one if it is already mid-flight — a claimed job must never die on staging
// because the engine happened to be restarting.
staging.Add(1)
defer staging.Add(-1)
if err := waitEngine(ctx, cl); err != nil {
return nil, fmt.Errorf("studio.render: %w", err)
}
// Materialize any uploaded inputs (they live in orgs/{org}/input on the cloud
// pod, which this worker cannot read) into the LOCAL studio input dir via its
// own /upload/image, so LoadImage resolves them before we render.
if err := w.materializeInputs(ctx, cl, req.Inputs); err != nil {
return nil, fmt.Errorf("studio.render: staging inputs: %w", err)
}
body, _ := json.Marshal(map[string]any{"prompt": req.Prompt})
post, err := http.NewRequestWithContext(ctx, http.MethodPost, localWorkerExecute, bytes.NewReader(body))
if err != nil {
return nil, err
}
post.Header.Set("Content-Type", "application/json")
authLocal(post) // gated worker-mode submit seam
resp, err := cl.Do(post)
if err != nil {
return nil, fmt.Errorf("studio.render: POST /v1/worker/execute: %w (is the local studio server on %s?)", err, localComfyUI)
}
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("studio.render: /v1/worker/execute HTTP %d: %s", resp.StatusCode, serverMessage(raw))
}
var pr struct {
PromptID string `json:"prompt_id"`
}
if err := json.Unmarshal(raw, &pr); err != nil || pr.PromptID == "" {
return nil, fmt.Errorf("studio.render: no prompt_id in /v1/worker/execute response")
}
// Poll history until the prompt shows up (completed).
deadline := time.Now().Add(renderWindow)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(2 * time.Second):
}
hreq, _ := http.NewRequestWithContext(ctx, http.MethodGet, localComfyUI+"/history/"+pr.PromptID, nil)
authLocal(hreq)
hresp, err := cl.Do(hreq)
if err != nil {
continue
}
hraw, _ := io.ReadAll(io.LimitReader(hresp.Body, 8<<20))
hresp.Body.Close()
var hist map[string]json.RawMessage
if err := json.Unmarshal(hraw, &hist); err != nil {
continue
}
if entry, ok := hist[pr.PromptID]; ok {
outputs := collectOutputs(entry)
gallery, uerr := w.uploadOutputs(ctx, outputs, req.UploadURL, req.Org)
if uerr != nil {
return nil, fmt.Errorf("studio.render: prompt %s rendered but gallery upload failed: %w", pr.PromptID, uerr)
}
// The engine leaks ~58GB per render; recycling after each completed
// render caps it at one render's worth. Boot (~20s) is noise next to
// 8-70m renders. Never recycle on the timeout path — the engine may
// still be sampling and the mirror rescues late finishes.
requestStudioRecycle()
return map[string]any{"promptId": pr.PromptID, "outputs": outputs, "gallery": gallery}, nil
}
}
return nil, fmt.Errorf("studio.render: prompt %s did not complete within the deadline", pr.PromptID)
}
// uploadOutputs fetches each finished render from the LOCAL studio (GET /view) and
// POSTs it to the org studio's /upload/output with the user's IAM bearer. The remote
// studio writes to orgs/{org}/output for the ACTIVE org — persisted to the gallery.
// org is the job's active org (from the browser's org switcher); it is forwarded as
// the studio_active_org cookie so a worker whose token home-org differs from the
// active org still lands the output in the right gallery. Returns the stored gallery
// paths. base is resolved from (in order) the per-job uploadUrl, then w.studioUploadURL.
func (w *worker) uploadOutputs(ctx context.Context, outputs []string, uploadURL, org string) ([]string, error) {
if len(outputs) == 0 {
return nil, nil
}
tok, err := w.env.ensureToken(ctx)
if err != nil {
return nil, err
}
base := strings.TrimRight(firstNonEmpty(uploadURL, w.studioUploadURL), "/")
if base == "" {
return nil, fmt.Errorf("no studio upload URL configured")
}
stored := make([]string, 0, len(outputs))
for _, o := range outputs {
sub, name := filepath.Split(o)
sub = strings.Trim(sub, "/")
data, err := w.fetchLocalOutput(ctx, name, sub)
if err != nil {
return stored, fmt.Errorf("fetch %s: %w", o, err)
}
path, err := w.postGalleryOutput(ctx, base, tok, org, name, sub, data)
if err != nil {
return stored, fmt.Errorf("upload %s: %w", o, err)
}
stored = append(stored, path)
}
return stored, nil
}
// fetchLocalOutput reads one finished output image from the local studio's /view.
func (w *worker) fetchLocalOutput(ctx context.Context, name, subfolder string) ([]byte, error) {
q := url.Values{"filename": {name}, "type": {"output"}}
if subfolder != "" {
q.Set("subfolder", subfolder)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, localComfyUI+"/view?"+q.Encode(), nil)
if err != nil {
return nil, err
}
authLocal(req)
resp, err := w.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256<<20))
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("GET /view HTTP %d: %s", resp.StatusCode, serverMessage(body))
}
return body, nil
}
// postGalleryOutput multipart-POSTs one output to <base>/upload/output with the
// user's IAM bearer and returns the stored "subfolder/name" gallery path. When org
// is set it rides as the studio_active_org cookie so the studio writes into that
// org's gallery even if the worker's token home-org differs.
func (w *worker) postGalleryOutput(ctx context.Context, base, tok, org, name, subfolder string, data []byte) (string, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("image", name)
if err != nil {
return "", err
}
if _, err := part.Write(data); err != nil {
return "", err
}
_ = mw.WriteField("type", "output")
_ = mw.WriteField("subfolder", subfolder)
_ = mw.WriteField("overwrite", "true")
if err := mw.Close(); err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/upload/output", &buf)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "application/json")
if org != "" {
req.Header.Set("Cookie", "studio_active_org="+org)
}
resp, err := w.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("POST /upload/output HTTP %d: %s", resp.StatusCode, serverMessage(raw))
}
var out struct {
Name string `json:"name"`
Subfolder string `json:"subfolder"`
}
if err := json.Unmarshal(raw, &out); err != nil || out.Name == "" {
// Upload succeeded (2xx) but the body was unexpected; fall back to the sent name.
return filepath.Join(subfolder, name), nil
}
return filepath.Join(out.Subfolder, out.Name), nil
}
// isImageFile reports whether name carries a render image extension the library accepts.
func isImageFile(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".png", ".jpg", ".jpeg", ".webp":
return true
}
return false
}
// mirrorRenders scans dir (the local studio output tree) for image files new or
// changed since the last scan and POSTs each to base/v1/library/upload with the
// worker's bearer, tagged with this node's identity, so EVERY render lands in the
// org's studio library — including ones produced OUTSIDE the job path. seen (rel
// path -> size) skips unchanged files; the endpoint dedupes, so a re-scan after a
// restart is cheap and harmless. One log line per newly stored file; upload
// failures are summarized once per scan and retried next tick (no 5xx log spam).
func (w *worker) mirrorRenders(ctx context.Context, out io.Writer, dir, base string, seen map[string]int64) {
tok, err := w.env.ensureToken(ctx)
if err != nil {
return
}
base = strings.TrimRight(base, "/")
failed := 0
var firstErr error
_ = filepath.Walk(dir, func(p string, info os.FileInfo, werr error) error {
if werr != nil || info == nil || info.IsDir() || !isImageFile(p) {
return nil
}
// Hidden files and AppleDouble forks (`._*`, `.DS_Store`) ride along with
// mac scp and are not renders — `._foo.png` passes the extension check
// but is a 4KB resource fork that poisons the library.
if strings.HasPrefix(filepath.Base(p), ".") {
return nil
}
rel, rerr := filepath.Rel(dir, p)
if rerr != nil {
return nil
}
rel = filepath.ToSlash(rel)
if seen[rel] == info.Size() {
return nil
}
data, derr := os.ReadFile(p)
if derr != nil || len(data) == 0 {
return nil
}
sub, name := "", rel
if i := strings.LastIndex(rel, "/"); i >= 0 {
sub, name = rel[:i], rel[i+1:]
}
existed, perr := w.postLibraryUpload(ctx, base, tok, sub, name, data)
if perr != nil {
failed++
if firstErr == nil {
firstErr = perr
}
return nil
}
seen[rel] = info.Size()
if !existed {
fmt.Fprintf(out, "mirrored %s (%d bytes) -> %s\n", rel, len(data), base)
}
return nil
})
if failed > 0 {
fmt.Fprintf(out, "mirror: %d file(s) failed to upload, will retry: %v\n", failed, firstErr)
}
}
// postLibraryUpload multipart-POSTs one image to base/v1/library/upload with the
// worker's IAM bearer, landing it in the org's library (orgs/{org}/output). The
// file's subfolder rides as ?subpath and this node's identity as ?node so the
// render is filterable by its source in Queue & History. Returns whether the
// endpoint already had a byte-identical copy (dedup).
func (w *worker) postLibraryUpload(ctx context.Context, base, tok, sub, name string, data []byte) (bool, error) {
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("image", name)
if err != nil {
return false, err
}
if _, err := part.Write(data); err != nil {
return false, err
}
if err := mw.Close(); err != nil {
return false, err
}
q := url.Values{"node": {w.identity}}
if sub != "" {
q.Set("subpath", sub)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/v1/library/upload?"+q.Encode(), &buf)
if err != nil {
return false, err
}
req.Header.Set("Authorization", "Bearer "+tok)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "application/json")
resp, err := w.http.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode/100 != 2 {
return false, fmt.Errorf("POST /v1/library/upload HTTP %d: %s", resp.StatusCode, serverMessage(raw))
}
var out struct {
Existed bool `json:"existed"`
}
_ = json.Unmarshal(raw, &out)
return out.Existed, nil
}
// inputImage is one uploaded input shipped with the job: a base64 blob plus the
// input-dir-relative location it must occupy on this worker so LoadImage finds it.
type inputImage struct {
Name string `json:"name"`
Subfolder string `json:"subfolder"`
Data string `json:"data"` // base64 — small inputs ride inside the job
// URL is the other way an input arrives: the coordinator serves it and this
// worker FETCHES it. Inlining is bounded by the tasks API's 4MB body, so a 4K
// source (19MB, 25MB encoded) could never be enqueued at all — the dispatch was
// refused, reported as "GPU worker unavailable", and the render waited forever
// for a fleet that was healthy. Pulling the bytes moves them over a channel with
// no such ceiling. Empty for an inlined input; when set, Data is empty.
URL string `json:"url"`
}
// fetchInput pulls one input's bytes from the coordinator that dispatched this job.
//
// It exists because inlining has a ceiling and photographs do not. The tasks API
// takes a 4MB body, so a 4K source could never ride inside the job — its dispatch was
// refused, the refusal was reported as "GPU render worker is momentarily unavailable",
// and the render waited on a fleet that was never the problem. Fetching has no such
// ceiling, and the coordinator already serves the file it staged.
//
// Authorized with this worker's own credentials, exactly as uploadOutputs is: the
// worker is a member of the org whose work it claims, and the coordinator resolves
// the org from the token rather than from anything the job says.
func (w *worker) fetchInput(ctx context.Context, cl *http.Client, url string) ([]byte, error) {
tok, err := w.env.ensureToken(ctx)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := cl.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, serverMessage(raw))
}
// A source is megabytes, not gigabytes; the cap is a guard, not a budget.
data, err := io.ReadAll(io.LimitReader(resp.Body, maxFetchedInputBytes))
if err != nil {
return nil, err
}
if len(data) == 0 {
return nil, fmt.Errorf("empty body")
}
return data, nil
}
// materializeInputs writes each shipped input into the LOCAL studio's input dir by
// POSTing it to the studio's own /upload/image (loopback, auth-bypassed). This is
// how an uploaded photo — which lives in orgs/{org}/input on the dispatching pod,
// unreadable by this worker — becomes resolvable by LoadImage before the render.
func (w *worker) materializeInputs(ctx context.Context, cl *http.Client, inputs []inputImage) error {
for _, in := range inputs {
if in.Name == "" || (in.Data == "" && in.URL == "") {
continue
}
var data []byte
var err error
if in.Data != "" {
if data, err = base64.StdEncoding.DecodeString(in.Data); err != nil {
return fmt.Errorf("decode input %q: %w", in.Name, err)
}
} else if data, err = w.fetchInput(ctx, cl, in.URL); err != nil {
return fmt.Errorf("fetch input %q: %w", in.Name, err)
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, err := mw.CreateFormFile("image", in.Name)
if err != nil {
return err
}
if _, err := part.Write(data); err != nil {
return err
}
_ = mw.WriteField("type", "input")
_ = mw.WriteField("subfolder", in.Subfolder)
_ = mw.WriteField("overwrite", "true")
if err := mw.Close(); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, localComfyUI+"/upload/image", &buf)
if err != nil {
return err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
authLocal(req)
resp, err := cl.Do(req)
if err != nil {
return fmt.Errorf("stage input %q: %w", in.Name, err)
}
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("stage input %q: HTTP %d: %s", in.Name, resp.StatusCode, serverMessage(raw))
}
}
return nil
}
// waitEngine blocks until the local engine answers its /queue — up to 90s, which
// outlasts any supervisor recycle (engine restart is seconds, model reload longer).
func waitEngine(ctx context.Context, cl *http.Client) error {
deadline := time.Now().Add(90 * time.Second)
for {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, localComfyUI+"/queue", nil)
if err != nil {
return err
}
authLocal(req)
resp, err := cl.Do(req)
if err == nil {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<10))
resp.Body.Close()
if resp.StatusCode/100 == 2 {
return nil
}
}
if time.Now().After(deadline) {
return fmt.Errorf("engine not up: %v", err)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(3 * time.Second):
}
}
}
// collectOutputs pulls the output file names out of a ComfyUI history entry. Savers
// publish under different keys — SaveImage/SaveVideo under "images", SaveGLB under
// "3d" — so every saver's outputs are gathered, not just images (a 3D mesh would
// otherwise never travel back to the library).
func collectOutputs(entry json.RawMessage) []string {
type namedFile struct {
Filename string `json:"filename"`
Subfolder string `json:"subfolder"`
}
var e struct {
Outputs map[string]struct {
Images []namedFile `json:"images"`
ThreeD []namedFile `json:"3d"`
} `json:"outputs"`
}
if err := json.Unmarshal(entry, &e); err != nil {
return nil
}
var files []string
for _, node := range e.Outputs {
for _, f := range append(append([]namedFile{}, node.Images...), node.ThreeD...) {
files = append(files, filepath.Join(f.Subfolder, f.Filename))
}
}
return files
}
// ---------------------------------------------------------------------------
// engine.serve — advertise a local hanzo-engine model server.
// ---------------------------------------------------------------------------
// probeEngine GETs {base}/v1/models and returns the served model ids. hanzo-engine
// answers an OpenAI-shaped {"object":"list","data":[{"id":...}]} once it is serving;
// a transport error or non-2xx means it is not up yet.
func probeEngine(ctx context.Context, base string) ([]string, error) {
url := strings.TrimRight(base, "/") + "/v1/models"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
cl := &http.Client{Timeout: 5 * time.Second}
resp, err := cl.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode/100 != 2 {
return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
}
var ml struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &ml); err != nil {
return nil, fmt.Errorf("decode model list: %w", err)
}
ids := make([]string, 0, len(ml.Data))
for _, m := range ml.Data {
if m.ID != "" {
ids = append(ids, m.ID)
}
}
return ids, nil
}
// refreshEngine re-probes the local engine and updates w.engine, returning true when
// the advertisement materially changed (reachability or model set) so the caller can
// rewrite the presence record.
func (w *worker) refreshEngine(ctx context.Context) bool {
prev := w.engine
adv := &engineAdvertisement{URL: w.engineAdvURL, APIs: []string{"openai", "anthropic"}}
if models, err := probeEngine(ctx, w.engineURL); err != nil {
adv.Status = "unreachable"
} else {
adv.Status = "ready"
adv.Models = models
}
w.engine = adv
return !sameEngine(prev, adv)
}
func sameEngine(a, b *engineAdvertisement) bool {
if a == nil || b == nil {
return a == b
}
if a.URL != b.URL || a.Status != b.Status || len(a.Models) != len(b.Models) {
return false
}
for i := range a.Models {
if a.Models[i] != b.Models[i] {
return false
}
}
return true
}
func describeEngine(adv *engineAdvertisement) string {
if adv == nil {
return "no engine"
}
if adv.Status != "ready" {
return adv.Status
}
n := len(adv.Models)
if n == 1 {
return "ready · 1 model"
}
return fmt.Sprintf("ready · %d models", n)
}
// providerBody is the POST /v1/ai/providers payload registering this node's engine
// as an org model provider. hanzo-engine speaks the standard chat-completions wire, so Type=Local: the
// gateway speaks the OpenAI wire format to it and auto-appends /v1 to providerUrl.
func (w *worker) providerBody() map[string]any {
model := "default"
if w.engine != nil && len(w.engine.Models) > 0 {
model = w.engine.Models[0]
}
url := ""
if w.engine != nil {
url = w.engine.URL
}
return map[string]any{
"name": "gpu-" + w.identity,
"category": "Model",
"type": "Local",
"providerUrl": url,
"subType": model,
"compatibleProvider": model,
}
}
// printEngineHint prints the ready-to-run registration so an operator can route
// api.hanzo.ai model calls to this GPU (or pass --register-provider to do it here).
func (w *worker) printEngineHint(out io.Writer) {
adv := w.engine
if adv == nil {
return
}
fmt.Fprintf(out, "serving hanzo-engine (OpenAI + Anthropic) at %s — %s\n", adv.URL, describeEngine(adv))
body, _ := json.Marshal(w.providerBody())
fmt.Fprintln(out, " route api.hanzo.ai model calls to this GPU by registering it as an org provider:")
fmt.Fprintf(out, " curl -sS %s/v1/ai/providers -H \"Authorization: Bearer $HANZO_TOKEN\" \\\n", w.baseURL)
fmt.Fprintf(out, " -H 'Content-Type: application/json' -d '%s'\n", body)
fmt.Fprintln(out, " (or pass --register-provider. The endpoint must be reachable from api.hanzo.ai —")
fmt.Fprintln(out, " a cloud GPU is in-cluster; a BYO node needs a public URL/tunnel. add-provider needs a platform-admin token today.)")
}
// registerProvider POSTs /v1/ai/providers so the gateway routes model calls to this
// node's engine. Requires the engine to be reachable and (today) a platform-admin
// token; both failures are reported clearly rather than swallowed.
func (w *worker) registerProvider(ctx context.Context, adv *engineAdvertisement) error {
if adv == nil || adv.Status != "ready" {
return fmt.Errorf("engine not ready at %s — start hanzo-engine, then retry", w.engineURL)
}
code, err := w.call(ctx, http.MethodPost, "/v1/ai/providers", w.providerBody(), nil)
if err != nil {
if code == http.StatusForbidden {
return fmt.Errorf("add-provider is gated to a platform-admin token today; register from the console or with an admin token: %w", err)
}
return err
}
return nil
}
// ---------------------------------------------------------------------------
// Daemon install (systemd --user).
// ---------------------------------------------------------------------------
func installDaemon(cmd *cobra.Command, opts connectOpts) error {
exe, err := os.Executable()
if err != nil {
return err
}
home, err := os.UserHomeDir()
if err != nil {
return err
}
unitDir := filepath.Join(home, ".config", "systemd", "user")
if err := os.MkdirAll(unitDir, 0o755); err != nil {
return err
}
// The unit is persistent COMPUTE membership; hanzod has its own supervision, so
// the worker unit does not also babysit the fabric (--no-fabric).
args := "link --no-fabric"
if opts.jobsNS != "" && opts.jobsNS != defaultJobsNS {
args += " --jobs-namespace " + opts.jobsNS
}
if opts.serveEngine {
args += " --serve-engine"
if opts.engineURL != "" && opts.engineURL != defaultEngineURL {
args += " --engine-url " + opts.engineURL
}
if opts.engineEndpoint != "" {
args += " --engine-endpoint " + opts.engineEndpoint
}
if opts.registerProvider {
args += " --register-provider"
}
}
if opts.studioDir != "" {
args += " --studio-dir " + opts.studioDir
}
if !opts.mirror {
args += " --mirror=false"
}
unit := fmt.Sprintf(`[Unit]
Description=Hanzo node (compute worker)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=%s %s
Restart=always
RestartSec=5
[Install]
WantedBy=default.target
`, exe, args)
unitPath := filepath.Join(unitDir, "hanzo-node.service")
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
return err
}
out := cmd.OutOrStdout()
fmt.Fprintf(out, "wrote %s\n", unitPath)
if _, err := exec.LookPath("systemctl"); err != nil {
fmt.Fprintln(out, "systemctl not found; enable the unit manually once available.")
return nil
}
_ = exec.Command("systemctl", "--user", "daemon-reload").Run()
if err := exec.Command("systemctl", "--user", "enable", "--now", "hanzo-node.service").Run(); err != nil {
fmt.Fprintf(out, "unit written; enable it with: systemctl --user enable --now hanzo-node.service\n")
return nil
}
fmt.Fprintln(out, "enabled and started hanzo-node.service (Restart=always). Check: systemctl --user status hanzo-node")
fmt.Fprintln(out, "note: the daemon reuses ~/.hanzo/credentials.json — keep `hanzo login` current, or set HANZO_TOKEN in the unit.")
return nil
}