Files
jbergner 8c67c7a7fa
All checks were successful
release-tag / release-image (push) Successful in 10m52s
Update 1.5.0
2026-08-27 07:51:39 +02:00

139 lines
3.8 KiB
Go

package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"sort"
"strings"
"time"
"neuroforge/internal/core"
"neuroforge/internal/vector"
)
type relinkPayload struct {
TargetID string `json:"target_id"`
Target []float32 `json:"target"`
Candidates []struct {
ID string `json:"id"`
Vector []float32 `json:"vector"`
} `json:"candidates"`
K int `json:"k"`
MinSimilarity float64 `json:"min_similarity"`
}
type neighbor struct {
ID string `json:"id"`
Similarity float64 `json:"similarity"`
}
type relinkResult struct {
TargetID string `json:"target_id"`
Neighbors []neighbor `json:"neighbors"`
}
func main() {
server := flag.String("server", "http://localhost:8080", "NeuroForge server")
id := flag.String("id", hostname(), "worker id")
interval := flag.Duration("interval", 2*time.Second, "poll interval")
flag.Parse()
token := strings.TrimSpace(os.Getenv("NEUROFORGE_WORKER_TOKEN"))
if token == "" {
log.Fatal("NEUROFORGE_WORKER_TOKEN is required")
}
client := &http.Client{Timeout: 180 * time.Second}
log.Printf("worker %s polling %s", *id, *server)
for {
job, err := claim(client, *server, token, *id)
if err != nil {
log.Printf("claim: %v", err)
time.Sleep(*interval)
continue
}
if job == nil {
time.Sleep(*interval)
continue
}
res, jobErr := run(job)
if err := complete(client, *server, token, *id, job.ID, res, jobErr); err != nil {
log.Printf("complete %s: %v", job.ID, err)
} else {
log.Printf("job %s %s done", job.ID, job.Type)
}
}
}
func hostname() string {
h, _ := os.Hostname()
if h == "" {
h = "worker"
}
return h
}
func claim(c *http.Client, server, token, id string) (*core.Job, error) {
body, _ := json.Marshal(map[string]string{"worker_id": id})
req, _ := http.NewRequest("POST", strings.TrimRight(server, "/")+"/api/v1/worker/claim", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 204 {
return nil, nil
}
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if resp.StatusCode != 200 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, raw)
}
var j core.Job
if err := json.Unmarshal(raw, &j); err != nil {
return nil, err
}
return &j, nil
}
func run(j *core.Job) (json.RawMessage, string) {
switch j.Type {
case "vector.relink":
var p relinkPayload
if err := json.Unmarshal(j.Payload, &p); err != nil {
return nil, err.Error()
}
out := relinkResult{TargetID: p.TargetID}
for _, c := range p.Candidates {
sim := vector.Cosine(p.Target, c.Vector)
if sim >= p.MinSimilarity {
out.Neighbors = append(out.Neighbors, neighbor{ID: c.ID, Similarity: sim})
}
}
sort.Slice(out.Neighbors, func(i, k int) bool { return out.Neighbors[i].Similarity > out.Neighbors[k].Similarity })
if p.K > 0 && len(out.Neighbors) > p.K {
out.Neighbors = out.Neighbors[:p.K]
}
b, _ := json.Marshal(out)
return b, ""
default:
return nil, "unsupported job type: " + j.Type
}
}
func complete(c *http.Client, server, token, id, jobID string, result json.RawMessage, jobErr string) error {
body, _ := json.Marshal(map[string]any{"worker_id": id, "job_id": jobID, "result": result, "error": jobErr})
req, _ := http.NewRequest("POST", strings.TrimRight(server, "/")+"/api/v1/worker/complete", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, raw)
}
return nil
}