mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-04 02:04:03 +00:00
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.
283 lines
9.3 KiB
Go
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
|
|
}
|