Files
tasks/cmd/tasksd/main.go
zeekay 56077de671 feat(tasks): v3.9.0 — distributed engine: per-NS SQLite shards + PQ consensus + sticky routing
Architecture
  One SQLite shard per (org, namespace) under <DataDir>/<org>/<ns>.db
  with WAL mode, single writer, 5s busy timeout. Replaces the prior
  shared memdb/zapdb store. Shard manager opens/caches/idle-evicts;
  prefix scans, namespace isolation, and cross-restart durability all
  proven by tests.

Replication (post-quantum, native)
  New pkg/tasks/replication/ exposes a Replicator interface with two
  drivers:
    local  — single-node passthrough (default for embedded/dev)
    quasar — wraps github.com/luxfi/consensus NewPQ (BLS + Ringtail
             dual-threshold). On every WAL-affecting op the shard
             builds a Frame, calls Propose, awaits Decision, applies
             on Accept. Followers receive accepted frames via the
             same Subscribe path so every node converges.

Sticky task-queue routing
  New pkg/tasks/routing/ — rendezvous-style smallest-hash leader
  election keyed by SHA-256(shard|taskQueue|seed). OnChange fan-out
  re-hashes on cluster membership change. Dispatcher consults Router
  before accepting subscriptions and forwards non-leader requests.

Namespace migration
  New pkg/tasks/migration/ — lock → checkpoint → copy → unlock with
  a job lifecycle (pending|locking|copying|replaying|released|failed).
  CLI: `tasksd migrate --org <id> --namespace <name> --to <node>`.
  HTTP: POST /v1/tasks/namespaces/{ns}/migrate.

New env / flags
  --data / TASKSD_DATA_DIR        shard root (./tasks-data)
  --replicator / TASKSD_REPLICATOR local | quasar (default local)
  --node-id / TASKSD_NODE_ID      stable id (required for quasar)
  --validators / TASKSD_VALIDATORS CSV host:port
  --validator-key / TASKSD_VALIDATOR_KEY (reserved for PQ key path)
  TASKSD_STORE                    sqlite | memory (default sqlite)

New routes
  GET  /v1/tasks/cluster
  GET  /v1/tasks/cluster/health
  POST /v1/tasks/namespaces/{ns}/migrate

UI bundle (synced)
  Cluster page with status / validators / shards / leader-map.
  NamespaceDetail "Migrate to node" button gated by
  canWriteNamespace + cluster.enabled. MigrationDialog enforces
  typed-name confirmation. Sidebar Cluster nav hidden when running
  single-node.

Tests
  +33 backend tests (store: 11, replication: 10, routing: 5,
  migration: 3, cluster routes: 2, store factory: 2). All packages
  PASS with -race; go vet clean.
  +13 UI tests (effectiveHealth: 6, canSubmitMigration: 4,
  useCluster: 3). 169 total UI tests, 1 pre-existing skip.

Migration v3.8.x → v3.9.0
  Fresh-data startup. zapdb backend dropped. Operators drain old
  cluster, snapshot for archival, deploy v3.9.0 with new --data
  directory. To enable HA, scale to 3+ replicas with --replicator
  quasar and matching --node-id / --validators.
2026-04-30 00:09:39 -07:00

283 lines
9.3 KiB
Go

// Copyright © 2026 Hanzo AI. MIT License.
//
// tasksd is the Hanzo Tasks daemon: one Go binary, native ZAP transport,
// IAM JWT auth, embedded React UI. Zero gRPC, zero go.temporal.io.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"strings"
"github.com/hanzoai/tasks/pkg/auth"
"github.com/hanzoai/tasks/pkg/tasks"
"github.com/hanzoai/tasks/pkg/tasks/migration"
"github.com/hanzoai/tasks/pkg/tasks/replication"
"github.com/hanzoai/tasks/pkg/tasks/routing"
"github.com/hanzoai/tasks/pkg/tasks/store"
tasksui "github.com/hanzoai/tasks/ui"
)
// storeNew is a thin alias used by runMigrate so the rest of main.go
// doesn't import the store package directly.
func storeNew(dir string) (*store.Manager, error) { return store.New(dir) }
// newCoordinator is a thin closure-bound migration runner used by runMigrate.
func newCoordinator(mgr *store.Manager, rep replication.Replicator) func(ctx context.Context, org, ns, from, to string) (*migration.Job, error) {
c := migration.NewCoordinator(mgr, rep)
return func(ctx context.Context, org, ns, from, to string) (*migration.Job, error) {
return c.Migrate(ctx, migration.Job{OrgID: org, Namespace: ns, From: from, To: to})
}
}
func main() {
if len(os.Args) > 1 && os.Args[1] == "migrate" {
runMigrate(os.Args[2:])
return
}
var (
zapPort = flag.Int("zap-port", envInt("TASKS_ZAP_PORT", 9999), "ZAP listener port")
httpAddr = flag.String("http", envStr("TASKS_HTTP_ADDR", ":7243"), "HTTP listen address (UI + healthz)")
dataDir = flag.String("data", envStr("TASKSD_DATA_DIR", envStr("TASKS_DATA_DIR", "./tasks-data")), "Tasks persistence directory")
ns = flag.String("namespace", envStr("TASKS_NAMESPACE", "default"), "Default namespace")
replKind = flag.String("replicator", envStr("TASKSD_REPLICATOR", "local"), "Replicator driver (local | quasar)")
nodeID = flag.String("node-id", envStr("TASKSD_NODE_ID", ""), "Stable node identifier (required for quasar)")
validators = flag.String("validators", envStr("TASKSD_VALIDATORS", ""), "Comma-separated validator host:port (required for quasar)")
_ = flag.String("validator-key", envStr("TASKSD_VALIDATOR_KEY", ""), "Path to PQ validator key (reserved)")
)
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
requireID := envBool("TASKSD_REQUIRE_IDENTITY", false)
validator := auth.NewValidator(auth.JWTConfig{
JWKSURL: envStr("TASKSD_JWKS_URL", ""),
Issuer: envStr("TASKSD_JWT_ISSUER", ""),
Audience: envStr("TASKSD_JWT_AUDIENCE", ""),
})
repl, router, err := buildReplicator(ctx, *replKind, *nodeID, *validators)
if err != nil {
logger.Error("replicator", "err", err)
os.Exit(1)
}
srv, err := tasks.Embed(ctx, tasks.EmbedConfig{
DataDir: *dataDir,
ZAPPort: *zapPort,
Namespace: *ns,
Logger: logger,
JWTValidator: validator,
RequireIdentity: requireID,
Replicator: repl,
Router: router,
NodeID: *nodeID,
})
if err != nil {
logger.Error("tasks.Embed", "err", err)
os.Exit(1)
}
defer srv.Stop(context.Background())
logger.Info("zap listener", "port", srv.ZAPPort(), "service", "_tasks._tcp")
httpSrv := &http.Server{
Addr: *httpAddr,
Handler: buildHTTP(*ns, srv, validator, requireID),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
logger.Info("http listener", "addr", *httpAddr)
if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("http", "err", err)
stop()
}
}()
<-ctx.Done()
logger.Info("shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = httpSrv.Shutdown(shutdownCtx)
}
// buildHTTP serves /healthz, /v1/tasks/* (browser JSON shim), and
// /_/tasks/* (embedded React UI). The /v1/tasks/* surface is the same
// data the ZAP node serves on :9999 — same model functions, no drift.
// Identity headers (X-User-Id, X-Org-Id, X-User-Email) are populated by
// hanzoai/gateway after IAM JWT validation; auth.RequireIdentity attaches
// them to the request context. TASKSD_REQUIRE_IDENTITY=true (production)
// rejects requests without identity headers; default false keeps the
// embedded/dev path working without a gateway.
//
// /healthz, /v1/tasks/health, and /_/tasks/* are intentionally
// unauthenticated: probes and the SPA shell run before any session
// exists.
func buildHTTP(ns string, srv *tasks.Embedded, validator *auth.Validator, requireID bool) http.Handler {
mux := http.NewServeMux()
probe := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"service": "tasks",
"status": "ok",
"namespace": ns,
})
}
mux.HandleFunc("/healthz", probe)
mux.HandleFunc("/v1/tasks/health", probe)
identity := auth.RequireIdentity(validator, requireID)
// /v1/tasks/settings is the SPA bootstrap — capability flags only,
// no per-org data, intentionally unauthenticated so the login page
// itself can render.
mux.Handle("/v1/tasks/settings", srv.HTTPHandler())
mux.Handle("/v1/tasks/cluster", srv.ClusterHandler())
mux.Handle("/v1/tasks/cluster/health", srv.ClusterHandler())
mux.Handle("/v1/tasks/", identity(srv.HTTPHandler()))
mux.Handle("/v1/tasks/mcp", identity(srv.MCPHandler()))
mux.Handle("/v1/tasks/events", identity(srv.EventsHandler()))
// React Router has basename="/_/tasks". The bundle is only valid at
// that prefix; serving it at "/" produces a blank page because
// the router refuses to match. Redirect "/" → "/_/tasks/".
mux.Handle("/_/tasks/", http.StripPrefix("/_/tasks", tasksui.Handler()))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
http.Redirect(w, r, "/_/tasks/", http.StatusFound)
})
return mux
}
func envBool(k string, def bool) bool {
if v := os.Getenv(k); v != "" {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return def
}
func envStr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
// runMigrate is the `tasksd migrate ...` subcommand. Quiesces the
// (org, namespace) shard via the consensus barrier, copies the
// SQLite file under <dataDir>/_migrations/<id>/, and releases the
// barrier. Idempotent.
func runMigrate(args []string) {
fs := flag.NewFlagSet("migrate", flag.ExitOnError)
dataDir := fs.String("data", envStr("TASKSD_DATA_DIR", "./tasks-data"), "Tasks persistence directory")
org := fs.String("org", "", "Org id (empty = sentinel)")
ns := fs.String("namespace", "", "Namespace to migrate")
to := fs.String("to", "", "Destination node id")
from := fs.String("from", "", "Source node id (annotation only)")
_ = fs.Parse(args)
if *ns == "" || *to == "" {
fmt.Fprintln(os.Stderr, "usage: tasksd migrate --org <id> --namespace <name> --to <node> [--from <node>]")
os.Exit(2)
}
mgr, err := storeNew(*dataDir)
if err != nil {
fmt.Fprintln(os.Stderr, "migrate:", err)
os.Exit(1)
}
defer mgr.Close()
rep := replication.NewLocal()
mgr.WithReplicator(rep)
c := newCoordinator(mgr, rep)
job, err := c(context.Background(), *org, *ns, *from, *to)
if err != nil {
fmt.Fprintln(os.Stderr, "migrate:", err)
os.Exit(1)
}
out, _ := json.MarshalIndent(job, "", " ")
fmt.Println(string(out))
}
// buildReplicator wires the requested driver. local → passthrough,
// quasar → consensus.NewPQ over the in-process MemoryHub (single-pod
// boot path); when validators are non-empty the hub is replaced with a
// real network transport at the next deploy phase.
func buildReplicator(ctx context.Context, kind, node, validatorsCSV string) (replication.Replicator, routing.Router, error) {
router := routing.NewHash([]byte("tasks"))
switch strings.ToLower(kind) {
case "", "local":
router.SetMembership(routing.NodeID(stringOr(node, "tasks-embed")), []routing.NodeID{routing.NodeID(stringOr(node, "tasks-embed"))})
return replication.NewLocal(), router, nil
case "quasar":
if node == "" {
return nil, nil, fmt.Errorf("quasar replicator requires --node-id")
}
hub := replication.NewMemoryHub()
t := hub.Connect(node)
var vs []routing.NodeID
for _, v := range strings.Split(validatorsCSV, ",") {
v = strings.TrimSpace(v)
if v != "" {
vs = append(vs, routing.NodeID(v))
}
}
if len(vs) == 0 {
vs = []routing.NodeID{routing.NodeID(node)}
}
router.SetMembership(routing.NodeID(node), vs)
validators := make([]string, len(vs))
for i, v := range vs {
validators[i] = string(v)
}
rep, err := replication.NewQuasar(ctx, replication.QuasarConfig{
NodeID: node,
Validators: validators,
Transport: t,
})
if err != nil {
return nil, nil, err
}
return rep, router, nil
default:
return nil, nil, fmt.Errorf("unknown replicator: %s (want local | quasar)", kind)
}
}
func stringOr(a, b string) string {
if a != "" {
return a
}
return b
}
func envInt(k string, def int) int {
if v := os.Getenv(k); v != "" {
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err == nil {
return n
}
}
return def
}