mirror of
https://github.com/hanzoai/notify.git
synced 2026-10-30 15:04:32 +00:00
POST /v1/notify/send now defaults to durable async dispatch via
hanzoai/tasks (NotifySendWorkflow, queue notify-send) and returns 202
Accepted with {message_id, task_id, status:"queued"}. ?sync=true keeps
the inline path and returns 200 with the terminal result.
The router fails closed if async is requested but the Dispatcher is
nil or not Started (503) — no silent sync fallback, matching
hanzoai/tasks CONTRACT.md §3 ("production MUST set TASKS_ADDR").
Worker lifecycle moves out of routes/ into cmd/notifyd so the routes
package depends only on the new tasks.Dispatcher interface; tests stub
it directly. Tasks namespace defaults to "default" per CONTRACT.md §6
(single shared worker for all tenants in this PR; per-org namespacing
is the next step once liquidity tenant traffic scales).
A handful of internal helpers narrow from *base.Base to the smaller
core.App interface (Activities, metering.Write/Aggregate, tenant.New,
event.Resolve, template.LoadPublished, mountSend) so tests can drive
them with tests.NewTestApp without booting a full daemon.
Tests:
- internal/tasks/activities_test.go — Deliver idempotency
short-circuits on status=sent and status=failed plus nil-receiver
guard.
- internal/routes/send_test.go — sync 200 / async 202 happy paths +
503-on-missing-dispatcher + 503-on-unstarted-dispatcher +
500-on-dispatch-error + 401-on-missing-X-Org-Id.
References hanzoai/tasks#3.
146 lines
4.5 KiB
Go
146 lines
4.5 KiB
Go
// Package tasks wires Hanzo Notify into hanzoai/tasks as a durable
|
|
// execution substrate.
|
|
//
|
|
// One workflow type, NotifySendWorkflow, takes a SendRequest and walks
|
|
// the same code path the sync HTTP handler does:
|
|
//
|
|
// resolve provider → render template → call library Send →
|
|
// write message row → write meter row → set status
|
|
//
|
|
// The worker registers itself on a single task queue ("notify-send").
|
|
// Async POST /v1/notify/send Dispatch hands off to ExecuteWorkflow; the
|
|
// sync path calls the same internal SendOnce function directly.
|
|
package tasks
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/hanzoai/tasks/pkg/sdk/client"
|
|
"github.com/hanzoai/tasks/pkg/sdk/worker"
|
|
luxlog "github.com/luxfi/log"
|
|
)
|
|
|
|
// Config is the worker's runtime configuration. Mirrors auto's engine.Config.
|
|
type Config struct {
|
|
// Address is the tasks server ZAP endpoint (host:port).
|
|
Address string
|
|
|
|
// Namespace is the tasks namespace this worker serves.
|
|
Namespace string
|
|
|
|
// TaskQueue is the queue both the workflow and activities sit on.
|
|
TaskQueue string
|
|
}
|
|
|
|
// Dispatcher is the narrow surface the HTTP routes need to enqueue an
|
|
// async send. *Worker satisfies it; tests pass an in-memory stub so the
|
|
// send handler can be exercised without dialing tasksd.
|
|
type Dispatcher interface {
|
|
// Dispatch enqueues a NotifySendWorkflow execution and returns the
|
|
// task id. Implementations must return a non-empty id on success.
|
|
Dispatch(ctx context.Context, req SendInput) (string, error)
|
|
|
|
// Started reports whether the dispatcher is ready to accept work.
|
|
// Routes use this to fail-closed (503) when async is requested
|
|
// against an unstarted worker.
|
|
Started() bool
|
|
}
|
|
|
|
// Worker owns the tasks client + worker lifecycle. Mirrors auto's
|
|
// engine.Worker (we deliberately copy the shape because they're siblings).
|
|
type Worker struct {
|
|
cfg Config
|
|
logger luxlog.Logger
|
|
cli client.Client
|
|
wk worker.Worker
|
|
started bool
|
|
// activities is the type-checked closure surface the workflow calls;
|
|
// kept as a pointer so registration sites and callers refer to the
|
|
// same instance.
|
|
activities *Activities
|
|
}
|
|
|
|
// New validates config and returns an un-started worker. The ZAP
|
|
// connection happens in Start so `--help`, `migrate`, and any non-serve
|
|
// command runs without a live tasks server.
|
|
func New(cfg Config, acts *Activities) (*Worker, error) {
|
|
if cfg.Address == "" {
|
|
return nil, errors.New("tasks: TASKS_ADDR is required")
|
|
}
|
|
if acts == nil {
|
|
return nil, errors.New("tasks: Activities is required")
|
|
}
|
|
if cfg.Namespace == "" {
|
|
// CONTRACT.md §6: single-tenant services run under "default" until
|
|
// they serve a second tenant. Notify is multi-tenant in principle
|
|
// but ships with one shared queue today; per-org namespaces land
|
|
// in the follow-up tracked at hanzoai/notify#TODO.
|
|
cfg.Namespace = "default"
|
|
}
|
|
if cfg.TaskQueue == "" {
|
|
cfg.TaskQueue = "notify-send"
|
|
}
|
|
return &Worker{
|
|
cfg: cfg,
|
|
logger: luxlog.New("module", "notify.tasks"),
|
|
activities: acts,
|
|
}, nil
|
|
}
|
|
|
|
// Start dials the tasks server, registers the workflow + activities,
|
|
// and starts the poll loop. Idempotent.
|
|
func (w *Worker) Start() error {
|
|
if w.started {
|
|
return nil
|
|
}
|
|
cli, err := client.Dial(client.Options{
|
|
HostPort: w.cfg.Address,
|
|
Namespace: w.cfg.Namespace,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("tasks: dial: %w", err)
|
|
}
|
|
wk := worker.New(cli, w.cfg.TaskQueue, worker.Options{Logger: w.logger})
|
|
wk.RegisterWorkflow(NotifySendWorkflow)
|
|
wk.RegisterActivity(w.activities.Deliver)
|
|
if err := wk.Start(); err != nil {
|
|
return err
|
|
}
|
|
w.cli = cli
|
|
w.wk = wk
|
|
w.started = true
|
|
return nil
|
|
}
|
|
|
|
// Stop unblocks poll loops and tears down subscriptions.
|
|
func (w *Worker) Stop() {
|
|
if !w.started {
|
|
return
|
|
}
|
|
w.wk.Stop()
|
|
w.started = false
|
|
}
|
|
|
|
// Started reports whether the worker is connected and active.
|
|
// Routes use this to decide async dispatch vs. 503.
|
|
func (w *Worker) Started() bool { return w.started }
|
|
|
|
// Dispatch enqueues a NotifySendWorkflow execution; returns the task id.
|
|
// The workflow runs asynchronously; clients poll via GET /v1/notify/
|
|
// messages/{id} (the workflow updates that record).
|
|
func (w *Worker) Dispatch(ctx context.Context, req SendInput) (string, error) {
|
|
if !w.started {
|
|
return "", errors.New("tasks: worker not started")
|
|
}
|
|
run, err := w.cli.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
|
|
ID: "notify-send-" + req.MessageID,
|
|
TaskQueue: w.cfg.TaskQueue,
|
|
}, "NotifySendWorkflow", req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("tasks: dispatch: %w", err)
|
|
}
|
|
return run.GetID(), nil
|
|
}
|