mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-04 02:04:03 +00:00
Minting is issuing authority. IAM mints — it signs the token, and that signature is where the authority is created. tasksd verifies one and restates it as headers a transport that cannot carry a token can read. Nothing here creates authority, so naming both operations "mint" put the issuer's job and this service's under one word, and a word that covers two operations is a word that stops distinguishing them. Every place that named the header write "mint" now names it "write", matching the leaf it already cites: hanzoai/authz names its header list "once by the party that WRITES their values". The four ctx accessors said "minted" for a value that is neither issued nor a header — they return what the validated claims RESOLVED to, which is the word the org scoping already used for Claims.EffectiveOrg. "mint" stays where it is the right word: the test helper signing a JWT, the `owner` claim IAM stamps at sign time, the HMAC task tokens dispatch issues, and money mint/burn in CONTRACT.md. LLM.md also cited edge.Inject, which no longer exists. The call is edge.Apply — the name it took when the four readers collapsed onto one leaf, precisely because writing headers is not injecting authority. Co-authored-by: Hanzo Dev <[email protected]>
274 lines
10 KiB
Markdown
274 lines
10 KiB
Markdown
# Hanzo Tasks — Canonical Contract
|
|
|
|
The single durable async substrate for every Hanzo Go service.
|
|
|
|
This document is law. If your service needs retries, durability, scheduling,
|
|
cron, or any async work that must survive a process restart, you use
|
|
`github.com/hanzoai/tasks`. There is no second async system.
|
|
|
|
## 1. When to use tasks vs sync
|
|
|
|
Sync HTTP handler (no tasks):
|
|
|
|
- Total work fits in <100ms.
|
|
- Caller blocks for the response and the result is the whole point of the call.
|
|
- Single round-trip with no external I/O that can flake (OTP code generation,
|
|
JWT issue, field validation, in-memory CLOB match, JWKS lookup).
|
|
- Failure is the response — the caller decides what to do.
|
|
|
|
Tasks (durable workflow):
|
|
|
|
- Any side-effect that must survive a pod restart or panic.
|
|
- Anything with retry policy (webhook delivery, mint/burn, settlement,
|
|
notification send, third-party API fan-out).
|
|
- Anything scheduled (cron, delay, `send_at`, daily/quarterly sweeps).
|
|
- Anything that fans out across multiple steps where a partial completion
|
|
is worse than no completion (saga-style flows).
|
|
- Long jobs (>5s) where the HTTP caller should not block.
|
|
|
|
Rule of thumb: if you find yourself writing `go func() { ... }()` to fire
|
|
work in the background, you are writing a bug. Use tasks.
|
|
|
|
## 2. Task definition shape
|
|
|
|
Every workflow is named, namespaced, and has a typed input + output.
|
|
|
|
```go
|
|
// pkg/tasks/<feature>.go in your service.
|
|
package <feature>tasks
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/hanzoai/tasks/pkg/sdk/workflow"
|
|
)
|
|
|
|
// Input is the workflow + activity payload. JSON-serializable, no
|
|
// pointers, no interfaces.
|
|
type Input struct {
|
|
ID string `json:"id"`
|
|
TenantSlug string `json:"tenant_slug"`
|
|
// … domain fields …
|
|
}
|
|
|
|
// Output is the workflow return shape.
|
|
type Output struct {
|
|
ID string `json:"id"`
|
|
Status string `json:"status"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// FeatureWorkflow is the durable entry point. Must be deterministic on
|
|
// its inputs — all I/O happens inside activities.
|
|
func FeatureWorkflow(ctx workflow.Context, in Input) (Output, error) {
|
|
actCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
|
|
StartToCloseTimeout: 60 * time.Second,
|
|
RetryPolicy: &workflow.RetryPolicy{
|
|
InitialInterval: 5 * time.Second,
|
|
BackoffCoefficient: 2.0,
|
|
MaximumInterval: 2 * time.Minute,
|
|
MaximumAttempts: 5,
|
|
},
|
|
})
|
|
var out Output
|
|
if err := workflow.ExecuteActivity(actCtx, "Deliver", in).Get(actCtx, &out); err != nil {
|
|
return Output{ID: in.ID, Status: "failed", Error: err.Error()}, err
|
|
}
|
|
return out, nil
|
|
}
|
|
```
|
|
|
|
Defaults — apply unless your workflow explicitly contradicts them:
|
|
|
|
| Field | Default | When to change |
|
|
|---|---|---|
|
|
| `StartToCloseTimeout` | 60s | I/O heavy >60s; never above 10m |
|
|
| `InitialInterval` | 5s | Fast-retry only if upstream <1s |
|
|
| `BackoffCoefficient` | 2.0 | Never lower (thundering herd) |
|
|
| `MaximumInterval` | 2m | Long-tail jobs may bump to 30m |
|
|
| `MaximumAttempts` | 5 | Idempotent ops can go to 10 |
|
|
|
|
Idempotency is the activity's responsibility. The tasks server replays on
|
|
retry; your activity must detect "already done" and return the prior result.
|
|
|
|
## 3. Worker registration pattern
|
|
|
|
One worker per service, started during boot, drained on shutdown.
|
|
|
|
```go
|
|
// in your service's main.go boot path
|
|
import (
|
|
tasksclient "github.com/hanzoai/tasks/pkg/sdk/client"
|
|
tasksworker "github.com/hanzoai/tasks/pkg/sdk/worker"
|
|
)
|
|
|
|
cli, err := tasksclient.Dial(tasksclient.Options{
|
|
Address: os.Getenv("TASKS_ADDR"), // "tasks.hanzo.svc:9999" or "/run/tasks.sock"
|
|
Namespace: tenantNamespace(orgID), // see §6
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("tasks dial: %w", err)
|
|
}
|
|
|
|
wk := tasksworker.New(cli, "<service>-default", tasksworker.Options{
|
|
Logger: logger,
|
|
})
|
|
wk.RegisterWorkflow(FeatureWorkflow)
|
|
wk.RegisterActivity(activities.Deliver)
|
|
if err := wk.Start(); err != nil {
|
|
return fmt.Errorf("tasks worker start: %w", err)
|
|
}
|
|
defer wk.Stop()
|
|
```
|
|
|
|
Task queue name: `<service>-<purpose>` (e.g. `notify-send`, `bd-webhooks`,
|
|
`ta-chain`, `ats-settlement`). One queue per workflow family. Do not
|
|
multiplex unrelated workflows on the same queue.
|
|
|
|
## 4. Client API
|
|
|
|
The sync caller's interaction with tasks is exactly two calls.
|
|
|
|
```go
|
|
// Enqueue — returns immediately with task id.
|
|
run, err := cli.ExecuteWorkflow(ctx, tasksclient.StartWorkflowOptions{
|
|
ID: "<feature>-" + in.ID, // deterministic ID = dedup key
|
|
TaskQueue: "<service>-<purpose>",
|
|
}, "FeatureWorkflow", in)
|
|
if err != nil {
|
|
return "", fmt.Errorf("tasks enqueue: %w", err)
|
|
}
|
|
taskID := run.GetID()
|
|
|
|
// Optional wait for completion (sync-over-async).
|
|
var out Output
|
|
if err := run.Get(ctx, &out); err != nil {
|
|
return out, err
|
|
}
|
|
```
|
|
|
|
`ExecuteWorkflow` is idempotent on the workflow ID. Re-submitting the same
|
|
ID with the workflow still running returns the existing handle. Use the
|
|
business key (`message_id`, `withdrawal_id`, `intent_id`) as the workflow
|
|
ID — that is your idempotency token.
|
|
|
|
## 5. ZAP transport
|
|
|
|
Production: ZAP only. `tasksd` listens on TCP/9999 by default -- or on a
|
|
unix socket when `--zap` is given a path, which is the right shape when
|
|
caller and engine share a host; the SDK
|
|
dials directly. No HTTP, no JSON envelope on the hot path, no gRPC.
|
|
|
|
Local dev: `TASKS_ADDR=""` switches the SDK to in-process mode (no
|
|
durability, used for tests and dev loops). Production must set
|
|
`TASKS_ADDR=tasks.hanzo.svc:9999` (or the per-cluster service name).
|
|
|
|
Auth flows via the same JWT the caller already holds. The SDK attaches
|
|
`Authorization: Bearer <jwt>` from `tasksclient.Options.AuthToken` or
|
|
from the context's outbound header bag. `tasksd` validates against IAM
|
|
JWKS (see hanzoai/tasks LLM.md §"Auth: IAM only").
|
|
|
|
## 6. Per-tenant namespace isolation
|
|
|
|
Namespaces map 1:1 to IAM orgs. This is non-negotiable.
|
|
|
|
| Layer | Value |
|
|
|---|---|
|
|
| IAM org | `mlc` |
|
|
| Tasks namespace | `mlc` |
|
|
| SQLite store | `data/mlc.db` |
|
|
|
|
Workers dial with `Namespace: <orgSlug>`. All schedules, workflows, and
|
|
visibility queries are namespace-scoped at the server. An `org-a`
|
|
worker cannot see `org-b` workflows even if both share a binary. The
|
|
strip + write identity middleware in `tasksd` enforces this on every RPC.
|
|
|
|
Multi-tenant services hold one client + worker per active org. Onboarding
|
|
a new org allocates the namespace lazily on first `ExecuteWorkflow`.
|
|
|
|
Single-tenant services (notify when serving Hanzo only) may run a single
|
|
`default` namespace; the moment they serve a second tenant, switch to
|
|
per-org namespacing — no retrofitting allowed in production.
|
|
|
|
## 7. Failure modes
|
|
|
|
- **Activity error returned**: tasks server retries per the workflow's
|
|
RetryPolicy. After `MaximumAttempts`, the workflow run is marked
|
|
`failed` and the result is queryable via `cli.DescribeWorkflowExecution`.
|
|
- **Activity panic**: caught by the SDK; counted as an attempt; retried.
|
|
- **Worker process killed mid-activity**: the heartbeat times out; the
|
|
task is rescheduled on another worker. Activities MUST be idempotent.
|
|
- **Workflow exceeds `WorkflowExecutionTimeout`**: terminated; downstream
|
|
saga must reconcile or fire a compensating workflow.
|
|
- **Server unreachable on enqueue**: `ExecuteWorkflow` returns an error.
|
|
The caller decides — usually: persist the intent to local storage and
|
|
resubmit on a recovery cron, never silently drop.
|
|
|
|
Dead-letter behavior: there is no separate DLQ collection. Failed runs
|
|
stay in the visibility store with `status=failed` and are queryable. The
|
|
operator surface (CRD `LiquidTasks` / `HanzoTasks` `.status`) surfaces
|
|
counts of `failed_24h` per namespace per queue.
|
|
|
|
Alerting: `tasks_workflow_runs_failed_total{namespace,queue}` Prometheus
|
|
metric → alerts at >0.5% failure ratio over 5m or any 10 consecutive
|
|
failures of the same workflow ID. The alert lands in the owning team's
|
|
PagerDuty service via Hanzo Insights.
|
|
|
|
## 8. Observability
|
|
|
|
UI: `tasks.hanzo.ai` (hanzoai/tasks Web UI). Filter by namespace, task
|
|
queue, status, time range. Inspect input/output/history for any run.
|
|
|
|
CLI:
|
|
|
|
```bash
|
|
# describe a stuck workflow
|
|
tasksctl workflow describe --namespace <org> <workflow-id>
|
|
|
|
# replay events for debugging
|
|
tasksctl workflow history --namespace <org> <workflow-id> --json
|
|
|
|
# queue depth (per-queue backlog)
|
|
tasksctl queue stats --namespace <org> <queue-name>
|
|
|
|
# force-fail a runaway workflow
|
|
tasksctl workflow terminate --namespace <org> <workflow-id> --reason <text>
|
|
```
|
|
|
|
Metrics (Prometheus, scraped from `tasksd` `/metrics`):
|
|
|
|
| Metric | Labels |
|
|
|---|---|
|
|
| `tasks_workflow_runs_started_total` | namespace, workflow_type |
|
|
| `tasks_workflow_runs_completed_total` | namespace, workflow_type, status |
|
|
| `tasks_workflow_runs_failed_total` | namespace, workflow_type |
|
|
| `tasks_activity_attempts_total` | namespace, activity_type, outcome |
|
|
| `tasks_queue_backlog` | namespace, queue |
|
|
| `tasks_workflow_run_duration_seconds` | namespace, workflow_type (histogram) |
|
|
|
|
Traces: every workflow + activity emits an OTEL span. Span links connect
|
|
the originating HTTP handler → workflow start → each activity attempt.
|
|
|
|
Logs: workflow + activity logs are structured slog records with
|
|
`workflow_id`, `run_id`, `namespace`, `task_queue`, `attempt`. They
|
|
land in the cluster's logging backend (Loki, in our case).
|
|
|
|
## 9. What this replaces
|
|
|
|
This contract supersedes:
|
|
|
|
- Per-service `pkg/taskqueue/` and `pkg/tasks/` wrappers that wrap a
|
|
fork of the SDK with HTTP fallbacks. The wrappers will be removed
|
|
once direct callers migrate to `github.com/hanzoai/tasks/pkg/sdk`.
|
|
- `go.temporal.io/sdk/*` imports anywhere outside this repo. Any
|
|
service that still imports the legacy SDK gets migrated in the
|
|
dispatched follow-up work; new code must not import it.
|
|
- Ad-hoc `time.NewTicker` + `go func()` for retries, cron, or scheduled
|
|
send. If you wrote one of those, you owe a tasks workflow.
|
|
|
|
## 10. Versioning
|
|
|
|
The Hanzo Tasks module pins to `v1.x.x` forever per the global Go
|
|
package policy (no `v2`+ semver-import-versioning). New features land
|
|
as minor bumps; fixes as patches. See LLM.md §"Versioning policy".
|