# DagNats Documentation (Full) Generated: 2026-07-10T15:02:55Z --- # Source: docs/site/content/_index.md --- # Source: docs/site/content/docs/_index.md DagNats is a DAG-based workflow engine built on NATS JetStream for orchestrating durable workflows and autonomous LLM coding pipelines. ## The Problem Multi-step workflows -- deploy pipelines, code review bots, data processing chains -- need retries, timeouts, dependency ordering, and observability. Most workflow engines require external databases (Postgres, Redis, MySQL) alongside a separate message broker. This creates deployment complexity, operational burden, and failure modes that have nothing to do with your actual workflows. ## What DagNats Does DagNats combines the **workflow engine** and the **message broker** into a single system by building directly on **NATS JetStream**. You define workflows as directed acyclic graphs (DAGs) in JSON, register them with the server, and write workers that handle individual steps. The engine resolves dependencies, dispatches tasks, handles retries, and tracks state -- all through NATS primitives. The core execution model is **event sourcing**. Every state change is an immutable event on a JetStream stream. The engine is stateless: it replays the event log to reconstruct workflow state. There is no mutable database row to corrupt or lose. ## Key Differentiators - **Single binary** -- `dagnats serve` starts an embedded NATS server, the workflow engine, the API, and an HTTP gateway. No external dependencies. - **No external database** -- JetStream provides streams, key-value stores, and object storage. All state lives in NATS. - **Event-sourced** -- Immutable event log is the source of truth. KV snapshots are a convenience, not authoritative. - **NATS-native** -- Retries use `NakWithDelay`, timeouts use `AckWait`, signals use KV watches, dedup uses `Nats-Msg-Id`. - **Agent loops as a primitive** -- Built-in `agent_loop` step type for iterative LLM agent execution with bounded iterations and duration. ## Architecture ```mermaid graph LR CLI[CLI / HTTP] --> API[API Service] API --> NATS[NATS JetStream] NATS --> Engine[Engine] Engine --> NATS NATS --> Workers[Workers] Workers --> NATS ``` All communication flows through NATS -- no direct connections between components. The engine watches the event stream, resolves the DAG, and dispatches tasks. Workers pull tasks, execute them, and publish results back. ## Next Steps - [Quickstart](/docs/get-started/quickstart) -- zero to running in five minutes - [Core Concepts](/docs/concepts) -- workflows, steps, events, and the DAG model - [Architecture](/docs/architecture) -- deep dive into the engine and NATS primitives --- # Source: docs/site/content/docs/ai-patterns/_index.md {{< cards cols="3" >}} {{< card link="agent-loop-pattern" title="Agent Loop Pattern" >}} {{< card link="context-management" title="Context Management" >}} {{< card link="cost-and-safety-controls" title="Cost and Safety Controls" >}} {{< card link="human-in-the-loop" title="Human in the Loop" >}} {{< card link="multi-agent-orchestration" title="Multi-Agent Orchestration" >}} {{< card link="overview" title="Overview" >}} {{< card link="planner-pattern" title="Planner Pattern" >}} {{< card link="prompt-and-response-schemas" title="Prompt and Response Schemas" >}} {{< card link="runtime-generated-workflows" title="Runtime-Generated Workflows" >}} {{< card link="tool-use-as-steps" title="Tool Use as Steps" >}} {{< /cards >}} --- # Source: docs/site/content/docs/ai-patterns/agent-loop-pattern.md The agent loop pattern maps the LLM reasoning cycle -- prompt, tool call, observe, decide -- onto a single DagNats step that iterates with checkpointed state. ## The Reasoning Cycle Every LLM agent follows the same core loop: ```mermaid stateDiagram-v2 [*] --> Prompt: input / resume from checkpoint Prompt --> ToolCall: model requests tool ToolCall --> Observe: execute tool, get result Observe --> Prompt: append result, re-prompt Prompt --> Done: model signals completion Done --> [*] ``` The number of cycles is unknown at definition time. A simple question might resolve in 1 cycle. A complex coding task might take 15. The [agent loop step type](/docs/step-types/agent-loops) handles this natively: the handler calls `Continue()` to request another iteration or `Complete()` to terminate. ## Mapping to DagNats | LLM Concept | DagNats Primitive | |------------|-------------------| | One reasoning cycle | One agent loop iteration | | Conversation history | [Checkpoint](/docs/coordination/checkpoints) in KV | | "Keep going" decision | `Continue(output)` | | "I'm done" decision | `Complete(output)` | | Iteration cap | `MaxIterations` | | Time cap | `MaxDuration` | | Per-iteration timeout | `StepDef.Timeout` | | Rate limit spacing | `LoopDelay` | ## Workflow Definition ```go wf := dag.NewWorkflow("code-review-agent") agent := wf.AgentLoop("review", "llm-review"). WithMaxIterations(20). WithMaxDuration(10 * time.Minute). WithLoopDelay(1 * time.Second). WithTimeout(60 * time.Second) def, err := wf.Build() ``` `MaxIterations` is your hard bound on LLM calls. `MaxDuration` caps total wall-clock time. `Timeout` applies per-iteration -- if a single LLM call hangs, that iteration times out and retries. `LoopDelay` adds spacing between iterations for rate-limited APIs. ## Handler Implementation The handler follows a consistent structure: load state, execute one cycle, save state, decide. ```go w.Handle("llm-review", func(ctx worker.TaskContext) error { // 1. Load or initialize conversation var messages []Message if saved, _ := ctx.LoadCheckpoint(); saved != nil { json.Unmarshal(saved, &messages) } else { messages = []Message{ {Role: "system", Content: systemPrompt}, {Role: "user", Content: string(ctx.Input())}, } } // 2. Call the LLM response, err := callLLM(messages) if err != nil { return ctx.Fail(err) } messages = append(messages, response.Message) // 3. Execute tool calls if requested if response.HasToolCalls() { for _, call := range response.ToolCalls { result := executeTool(call) messages = append(messages, toolResultMessage(call, result)) } } // 4. Save state data, _ := json.Marshal(messages) ctx.Checkpoint(data) // 5. Decide: continue or complete if response.Done || !response.HasToolCalls() { return ctx.Complete(extractFinalAnswer(messages)) } return ctx.Continue(nil) }) ``` ## Exit Conditions An agent loop terminates when any of these conditions is met: | Condition | Who Enforces | What Happens | |-----------|-------------|--------------| | Handler calls `Complete()` | Worker | Step completes successfully | | Handler calls `Fail()` | Worker | Step fails (retry policy applies) | | `MaxIterations` reached | Engine | Step fails with iteration limit error | | `MaxDuration` exceeded | Engine | Step fails with duration limit error | | Per-iteration `Timeout` exceeded | Engine | Iteration retried (retry policy applies) | Design your LLM prompt to include an explicit "I'm done" signal (e.g., a `done` field in structured output). Relying solely on `MaxIterations` to stop the loop means the agent ran out of budget rather than completing its task. ## Streaming During Iterations Combine the agent loop with [streaming](/docs/coordination/streaming) to publish tokens in real time: ```go // Inside the agent loop handler stream, _ := openLLMStream(messages) var fullResponse strings.Builder for token := range stream.Tokens() { ctx.PutStream([]byte(token)) fullResponse.WriteString(token) } ctx.Heartbeat() ``` Clients subscribe to `stream.{runID}.{stepID}` to see tokens as they arrive across all iterations. ## Related - [Agent Loops](/docs/step-types/agent-loops) -- step type mechanics (Continue, iteration tracking) - [Context Management](/docs/ai-patterns/context-management) -- conversation state strategies - [Cost and Safety Controls](/docs/ai-patterns/cost-and-safety-controls) -- bounding agent execution - [Human in the Loop](/docs/ai-patterns/human-in-the-loop) -- injecting human feedback mid-loop --- # Source: docs/site/content/docs/ai-patterns/context-management.md Context management is the strategy for maintaining, transmitting, and bounding conversation state across agent loop iterations, retries, and step boundaries. ## The Problem LLM agents accumulate context over time: conversation messages, tool results, retrieved documents, intermediate reasoning. This context must survive across: - **Iterations** in an agent loop (each `Continue()` re-enqueues the task) - **Retries** after transient failures (network errors, rate limits) - **Worker restarts** (the handler might execute on a different machine) Without a strategy, context is lost on any of these events, forcing expensive replay of previous LLM calls. ## Checkpoints for Conversation State [Checkpoints](/docs/coordination/checkpoints) are the primary mechanism for persisting context. Save the full conversation after each LLM call: ```go w.Handle("agent", func(ctx worker.TaskContext) error { var conv Conversation if saved, _ := ctx.LoadCheckpoint(); saved != nil { json.Unmarshal(saved, &conv) } else { conv = newConversation(ctx.Input()) } response, err := callLLM(conv.Messages) if err != nil { // Checkpoint is already saved from previous iteration. // Retry will resume from the last good state. return ctx.Fail(err) } conv.Messages = append(conv.Messages, response.Message) data, _ := json.Marshal(conv) ctx.Checkpoint(data) if response.Done { return ctx.Complete(extractResult(conv)) } return ctx.Continue(nil) }) ``` The checkpoint saves **before** the decision to continue or complete. If the worker crashes after `Checkpoint()` but before `Continue()`, the retry loads the saved state and re-executes only the current decision, not the LLM call. ## Streaming for Real-Time Tokens [Streaming](/docs/coordination/streaming) provides a parallel channel for live output. It does not replace checkpoints -- it complements them: ```go // Stream tokens as they arrive (ephemeral, for live UX) for token := range llmStream.Tokens() { ctx.PutStream([]byte(token)) fullResponse.WriteString(token) } // Checkpoint the assembled response (durable, for recovery) conv.Messages = append(conv.Messages, assistantMessage(fullResponse.String())) ctx.Checkpoint(marshalConv(conv)) ``` Streaming is fire-and-forget over core NATS. If no one is subscribed, tokens are lost. The checkpoint ensures the assembled response survives regardless. ## Signals for Injecting Context Mid-Execution [Signals](/docs/coordination/signals) let external systems inject new context into a running agent: ```go // Agent checks for injected context before each LLM call signal, _ := ctx.WaitForSignal("context-update", 1*time.Second) if signal != nil { var update ContextUpdate json.Unmarshal(signal, &update) conv.Messages = append(conv.Messages, userMessage(update.Content)) } ``` Short timeouts (1-5 seconds) let the agent check for updates without blocking. If no signal arrives, processing continues normally. This is useful for: - Injecting new requirements discovered after the agent started - Providing human feedback on intermediate results - Updating configuration (model, temperature) mid-run ## Context Window Limits Across Retries LLM context windows are finite. An agent loop that checkpoints the full conversation will eventually exceed the model's limit. Strategies: **Truncation.** Drop old messages, keeping system prompt and recent history: ```go func truncateConversation(msgs []Message, maxTokens int) []Message { if estimateTokens(msgs) <= maxTokens { return msgs } // Keep system prompt (first) and recent messages (last N) system := msgs[0] recent := msgs[len(msgs)-10:] return append([]Message{system}, recent...) } ``` **Summarization.** Periodically summarize older context into a single message: ```go if len(conv.Messages) > 20 && conv.Iteration%5 == 0 { summary, _ := callLLM(summarizePrompt(conv.Messages[:len(conv.Messages)-5])) conv.Messages = append( []Message{conv.Messages[0], assistantMessage(summary)}, conv.Messages[len(conv.Messages)-5:]..., ) } ``` **External memory.** Store detailed context in NATS KV or an object store, and include only references in the conversation: ```go // Store large tool results externally key := fmt.Sprintf("%s.memory.%s", ctx.RunID(), toolCallID) kv.Put(ctx, key, largeToolResult) // Include a reference in the conversation conv.Messages = append(conv.Messages, toolResultMessage( fmt.Sprintf("[Result stored at %s, %d bytes]", key, len(largeToolResult)), )) ``` ## State Flow Summary ```mermaid graph TD CP[Checkpoint KV] -->|LoadCheckpoint| H[Handler] H -->|Checkpoint| CP H -->|PutStream| S[NATS Core Pub/Sub] S -->|subscribe| C[Client] SIG[Signal KV] -->|WaitForSignal| H EXT[External System] -->|SendSignal| SIG H -->|Continue/Complete| E[Engine] ``` ## Related - [Checkpoints](/docs/coordination/checkpoints) -- KV storage API - [Streaming](/docs/coordination/streaming) -- real-time pub/sub - [Signals](/docs/coordination/signals) -- cross-step and external communication - [Agent Loop Pattern](/docs/ai-patterns/agent-loop-pattern) -- the iteration cycle that needs context --- # Source: docs/site/content/docs/ai-patterns/cost-and-safety-controls.md Cost and safety controls bound LLM agent execution through iteration caps, timeouts, rate limits, and concurrency limits -- preventing runaway API spend and unbounded computation. ## The Risk LLM API calls cost money. An agent loop with no bounds can iterate indefinitely, accumulating token costs. Parallel agents multiply the problem. A single misconfigured workflow can generate thousands of API calls in minutes. DagNats does not track token counts or dollar amounts directly. Instead, it provides **structural bounds** that limit the number of LLM calls an agent can make and the time it can spend making them. These are blunt but effective proxies for cost. ## Max Iterations as Token Budget Proxy `MaxIterations` on an [agent loop](/docs/step-types/agent-loops) caps the number of reasoning cycles. Each iteration typically involves one LLM call, so `MaxIterations` is a direct proxy for maximum API calls per agent step. ```go agent := wf.AgentLoop("agent", "llm-task"). WithMaxIterations(20). // at most 20 LLM calls WithMaxDuration(10 * time.Minute) ``` | MaxIterations | Typical Use Case | |---------------|-----------------| | 3-5 | Simple Q&A with tool use | | 10-20 | Code review, document analysis | | 20-50 | Complex coding tasks | | 50+ | Rarely justified; consider sub-workflows | If an agent hits `MaxIterations`, the step fails. Design the agent prompt to complete well before the cap. The cap is a safety net, not a target. ## Timeouts as Spend Caps `MaxDuration` bounds total wall-clock time. `Timeout` bounds per-iteration time. Together they prevent both slow accumulation and individual hung calls. ```go agent := wf.AgentLoop("agent", "llm-task"). WithMaxIterations(30). WithMaxDuration(15 * time.Minute). // total time cap WithTimeout(60 * time.Second). // per-call cap WithLoopDelay(500 * time.Millisecond) ``` `MaxDuration` is the hard ceiling. If the agent has done 5 iterations in 14 minutes, it will not start iteration 6 if `MaxDuration` is 15 minutes. `Timeout` catches individual LLM calls that hang (model provider issues, network problems). For normal (non-loop) steps that call LLMs, the step-level [timeout](/docs/reliability/timeouts) serves the same purpose. ## Rate Limits per Model/Provider [Rate limiting](/docs/flow-control/rate-limiting) controls how many tasks of a given type execute per time window. Use this to stay within API provider rate limits: ```go wf := dag.NewWorkflow("rate-limited-pipeline") // Each step using the LLM gets rate-limited agent := wf.AgentLoop("agent", "llm-gpt4"). WithMaxIterations(20). WithRateLimit(10, time.Minute) // 10 calls per minute ``` Rate limits are enforced at the engine level. If the limit is reached, tasks queue until the window resets. This prevents bursting through provider rate limits and incurring 429 errors. For different models with different rate limits, register separate task types: ```go w.Handle("llm-gpt4", gpt4Handler) // rate limited to 10/min w.Handle("llm-claude", claudeHandler) // rate limited to 20/min ``` ## Concurrency Limits for Parallel API Calls [Concurrency limits](/docs/flow-control/concurrency-limits) bound how many LLM tasks execute simultaneously. This is important for map steps that fan out tool calls: ```go tools := wf.Map("tools", "llm-tool-call"). After(plan). WithMaxItems(20). WithConcurrency(5) // at most 5 simultaneous API calls ``` Without concurrency limits, a map step with 20 items dispatches all 20 tasks simultaneously. If each calls an LLM API, that is 20 concurrent requests. Concurrency limits cap the parallelism. ## Planner Step Bounds [Planner steps](/docs/step-types/planner-steps) that let LLMs generate DAG fragments have their own bounds: ```go plan := wf.Planner("plan", "generate-plan", dag.PlannerConfig{ MaxSteps: 10, // cap generated steps MaxDepth: 3, // cap dependency chain depth AllowedTasks: []string{ // restrict available task types "code-edit", "test-run", }, }) ``` | Bound | Purpose | |-------|---------| | `MaxSteps` (per planner) | Limits work generated by one planning call | | 500 dynamic steps (per run) | Limits total generated work across all planners | | `AllowedTasks` | Prevents generating steps for unauthorized task types | | `MaxDepth` | Prevents deeply chained sequential execution | ## Configuration Summary ```go wf := dag.NewWorkflow("bounded-agent") agent := wf.AgentLoop("agent", "llm-task"). WithMaxIterations(20). // iteration cap WithMaxDuration(10 * time.Minute). // time cap WithTimeout(60 * time.Second). // per-iteration timeout WithLoopDelay(1 * time.Second). // spacing between iterations WithRateLimit(10, time.Minute). // provider rate limit WithRetries(2) // retry transient failures plan := wf.Planner("plan", "generate-plan", dag.PlannerConfig{ MaxSteps: 10, MaxDepth: 3, AllowedTasks: []string{"code-edit", "test-run"}, }).After(agent) def, _ := wf.Build() ``` Every bound is explicit in the workflow definition. There are no implicit defaults that silently allow unbounded execution. ## Related - [Agent Loops](/docs/step-types/agent-loops) -- MaxIterations and MaxDuration configuration - [Rate Limiting](/docs/flow-control/rate-limiting) -- per-task-type rate controls - [Concurrency Limits](/docs/flow-control/concurrency-limits) -- parallel execution bounds - [Timeouts](/docs/reliability/timeouts) -- per-step and per-iteration timeouts - [Planner Pattern](/docs/ai-patterns/planner-pattern) -- safety constraints on generated plans --- # Source: docs/site/content/docs/ai-patterns/human-in-the-loop.md Human-in-the-loop patterns let you inject human judgment into automated LLM workflows for high-risk actions, quality review, or mid-execution guidance. ## Two Mechanisms DagNats provides two complementary mechanisms for human interaction: | Mechanism | When to Use | How It Works | |-----------|------------|--------------| | [Approval Gates](/docs/step-types/approval-gates) | Before a step executes | Engine-level pause; step does not start until approved | | [Signals](/docs/coordination/signals) | During a step's execution | Handler-level pause; step blocks on `WaitForSignal` | **Approval gates** are best for "should this proceed?" decisions -- deploy approvals, spend authorizations, destructive operations. The workflow pauses **between** steps. **Signals** are best for "what should I do next?" decisions -- human feedback to a running agent, corrections to generated content, parameter adjustments. The workflow pauses **within** a step. ## Approval Gates for High-Risk Actions When an LLM agent proposes a destructive action (deleting files, deploying code, sending emails), gate it behind an approval step: ```go wf := dag.NewWorkflow("agent-with-approval") agent := wf.AgentLoop("plan", "llm-agent"). WithMaxIterations(10) approve := wf.Approval("review-plan", dag.ApprovalConfig{ Timeout: 4 * time.Hour, Subject: "approval.agent.action", Description: "Review agent's proposed changes", }).After(agent) execute := wf.Task("execute", "apply-changes"). After(approve) def, _ := wf.Build() ``` The agent loop reasons and produces a plan. The approval gate pauses until a human reviews it. Only after approval does execution proceed. If the human rejects, the workflow fails (or routes to an `OnFailure` handler for re-planning). The approval notification publishes to the NATS subject `approval.agent.action`. External integrations (Slack, email, dashboard) subscribe and present the decision to the reviewer. ## Signal-Based Review Mid-Execution For interactive feedback during an agent loop, use signals. The agent pauses mid-iteration and waits for input: ```go w.Handle("llm-agent", func(ctx worker.TaskContext) error { var state AgentState if saved, _ := ctx.LoadCheckpoint(); saved != nil { json.Unmarshal(saved, &state) } result, _ := callLLM(state.Messages) if result.Confidence < 0.7 { // Low confidence -- ask for human guidance ctx.PutStream([]byte("Low confidence. Requesting review...")) review, err := ctx.WaitForSignal("human-review", 1*time.Hour) if err != nil { return ctx.Fail(err) } state.Messages = append(state.Messages, Message{Role: "user", Content: string(review)}, ) data, _ := json.Marshal(state) ctx.Checkpoint(data) return ctx.Continue(nil) } if result.Done { return ctx.Complete([]byte(result.Answer)) } return ctx.Continue(nil) }) ``` An external system sends the human's feedback: ```bash # Via CLI dagnats signal send human-review '{"guidance": "Focus on auth module"}' ``` ## Combining Approval with Agent Loops For maximum safety, use both: signals for in-loop guidance and approval gates before irreversible actions. ```go wf := dag.NewWorkflow("safe-agent") // Agent reasons and produces a plan (signals for mid-loop feedback) agent := wf.AgentLoop("reason", "llm-reason"). WithMaxIterations(15). WithMaxDuration(20 * time.Minute) // Human reviews the plan before execution gate := wf.Approval("approve", dag.ApprovalConfig{ Timeout: 24 * time.Hour, Subject: "approval.safe-agent", }).After(agent) // Execute the approved plan execute := wf.Planner("execute", "run-plan", dag.PlannerConfig{ MaxSteps: 10, AllowedTasks: []string{"code-edit", "test-run"}, }).After(gate) def, _ := wf.Build() ``` This three-phase pattern (reason, approve, execute) is the safest way to run LLM agents that take real-world actions. The agent can iterate freely during the reasoning phase. The approval gate is the checkpoint before anything irreversible happens. ## Timeout Design | Component | Recommended Timeout | Rationale | |-----------|-------------------|-----------| | `WaitForSignal` in agent loop | 30-60 minutes | Human may be away; heartbeat to prevent redelivery | | Approval gate | 4-24 hours | Async review; auto-reject on expiry | | Per-iteration timeout | 60-120 seconds | Prevent hung LLM calls | For `WaitForSignal`, remember to call `Heartbeat()` periodically if the wait exceeds the NATS AckWait period (typically 30 seconds). Or use `Pause()` to NAK with delay and resume when the human responds. ## Related - [Approval Gates](/docs/step-types/approval-gates) -- step type mechanics and token validation - [Signals](/docs/coordination/signals) -- WaitForSignal and SendSignal API - [Agent Loop Pattern](/docs/ai-patterns/agent-loop-pattern) -- the reasoning cycle that gates protect - [Cost and Safety Controls](/docs/ai-patterns/cost-and-safety-controls) -- complementary safety mechanisms --- # Source: docs/site/content/docs/ai-patterns/multi-agent-orchestration.md Multi-agent orchestration composes multiple LLM agents into a coordinated pipeline using sub-workflows for delegation, map steps for parallel fan-out, and signals for inter-agent communication. ## Topology ```mermaid graph TD O[Orchestrator Agent] -->|sub-workflow| A1[Research Agent] O -->|sub-workflow| A2[Code Agent] O -->|sub-workflow| A3[Review Agent] A1 -->|signal| A2 A2 --> A3 A3 -->|signal| O ``` Each agent is a separate workflow with its own agent loop, checkpoints, and bounds. The orchestrator composes them using DagNats primitives rather than custom inter-process communication. ## Sub-Workflows as Agent Delegation Each agent runs as an independent [sub-workflow](/docs/step-types/sub-workflows). The orchestrator's DAG references child workflows by name: ```go wf := dag.NewWorkflow("multi-agent-pipeline") research := wf.SubWorkflow("research", "research-agent"). WithTimeout(10 * time.Minute) code := wf.SubWorkflow("code", "code-agent"). After(research). WithTimeout(15 * time.Minute) review := wf.SubWorkflow("review", "review-agent"). After(code). WithTimeout(5 * time.Minute) def, _ := wf.Build() ``` Each child workflow is a full workflow definition with its own agent loop: ```go researchWf := dag.NewWorkflow("research-agent") researchWf.AgentLoop("loop", "research-llm"). WithMaxIterations(10). WithMaxDuration(8 * time.Minute) researchDef, _ := researchWf.Build() ``` The parent step blocks until the child completes. The child's output becomes the parent step's output, flowing to downstream dependencies. If a child agent fails, the parent step fails and the orchestrator's error handling applies. ## Map Steps for Parallel Agent Fan-Out When the same agent task needs to run over multiple inputs, use [map steps](/docs/step-types/map-steps) to fan out: ```go wf := dag.NewWorkflow("parallel-review") // Split codebase into files split := wf.Task("split", "split-files") // Run review agent on each file in parallel review := wf.Map("review-all", "review-single-file"). After(split). WithMaxItems(50). WithTimeout(5 * time.Minute) // Aggregate all reviews merge := wf.Task("merge", "merge-reviews"). After(review) def, _ := wf.Build() ``` Each map instance can itself be a sub-workflow if the per-item processing is complex enough to warrant its own agent loop. The map step collects all outputs in array order for the downstream merge step. ## Signals for Inter-Agent Communication Agents running concurrently can communicate via [signals](/docs/coordination/signals). This is useful when one agent discovers information that another needs: ```go // Research agent sends findings as a signal w.Handle("research-llm", func(ctx worker.TaskContext) error { // ... research logic ... if discovery.Relevant { ctx.SendSignal(parentRunID, "research-update", discoveryData) } return ctx.Continue(nil) }) // Code agent checks for research updates w.Handle("code-llm", func(ctx worker.TaskContext) error { update, _ := ctx.WaitForSignal("research-update", 5*time.Second) if update != nil { // Incorporate new information state.AddContext(update) } // ... coding logic ... }) ``` For cross-run signals, the sender needs the target run's ID. Pass it via step input or a shared KV key. ## Orchestrator as Planner For dynamic multi-agent orchestration, combine the orchestrator with a [planner step](/docs/step-types/planner-steps). The orchestrator LLM decides which agents to invoke based on the task: ```go wf := dag.NewWorkflow("dynamic-orchestrator") analyze := wf.Task("analyze", "analyze-task") plan := wf.Planner("plan", "orchestrate-agents", dag.PlannerConfig{ MaxSteps: 10, AllowedTasks: []string{"research-agent", "code-agent", "review-agent"}, }).After(analyze) report := wf.Task("report", "final-report"). After(plan) def, _ := wf.Build() ``` The planner handler prompts the LLM to produce a DAG fragment that wires together the appropriate agents with dependencies. The engine validates and executes the generated plan. ## Nesting Limits Sub-workflow nesting is capped at **3 levels deep**. This means: - Level 0: Orchestrator workflow - Level 1: Agent sub-workflows - Level 2: Agent-spawned sub-workflows - Level 3: Maximum depth (rejected if exceeded) Design your agent hierarchy to fit within this bound. If you need deeper composition, flatten by having agents communicate results via step output rather than spawning nested sub-workflows. ## Related - [Sub-Workflows](/docs/step-types/sub-workflows) -- delegation mechanics - [Map Steps](/docs/step-types/map-steps) -- parallel fan-out - [Signals](/docs/coordination/signals) -- inter-agent communication - [Planner Pattern](/docs/ai-patterns/planner-pattern) -- dynamic orchestration - [Agent Loop Pattern](/docs/ai-patterns/agent-loop-pattern) -- the loop each agent runs --- # Source: docs/site/content/docs/ai-patterns/overview.md DagNats provides the infrastructure primitives that LLM agent pipelines need but ad-hoc scripts lack: durability, bounded execution, checkpointing, and observability. ## The Problem with Raw API Calls Most LLM integrations start as a script: call the API, parse the response, maybe loop a few times. This works until it does not: - **No retry on failure.** A transient 429 or 500 kills the entire run. You write retry logic. Then backoff. Then jitter. - **No state persistence.** If the process dies mid-conversation, you lose everything and start over. For a 20-iteration agent loop, that is 20 wasted API calls. - **No execution bounds.** An agent loop with no iteration cap burns tokens indefinitely. You add a counter. Then a timeout. Then a cost tracker. - **No observability.** When something goes wrong at 2 AM, you grep logs. No traces, no metrics, no structured events. Each of these is solvable individually. Solving all of them together, reliably, across multiple agents running concurrently, is a workflow engine. ## What DagNats Provides | Concern | Raw Script | DagNats | |---------|-----------|---------| | Retry on failure | Manual retry loops | [Retry policies](/docs/reliability/retry-policies) with configurable backoff | | State persistence | None (or ad-hoc files) | [Checkpoints](/docs/coordination/checkpoints) in NATS KV | | Execution bounds | Manual counters | [MaxIterations, MaxDuration](/docs/step-types/agent-loops) on agent loops | | Human intervention | Not possible mid-run | [Signals](/docs/coordination/signals) and [approval gates](/docs/step-types/approval-gates) | | Parallel execution | threading/async | [Map steps](/docs/step-types/map-steps) with bounded concurrency | | Dynamic planning | Hardcoded pipelines | [Planner steps](/docs/step-types/planner-steps) generate DAG fragments | | Observability | print statements | Distributed traces, structured events, metrics | | Cost control | Hope | Iteration caps, timeouts, [rate limits](/docs/flow-control/rate-limiting) | ## Core Primitives for LLM Pipelines DagNats was not designed exclusively for AI. But the primitives it provides map directly to LLM agent requirements: **Agent Loops** solve the variable-iteration problem. An LLM agent reasons in cycles (prompt, tool call, observe, decide). The number of cycles is unknown at design time. `StepTypeAgentLoop` with `Continue()` handles this natively with `MaxIterations` and `MaxDuration` as hard bounds. **Checkpoints** solve the state persistence problem. Conversation history, tool results, and intermediate reasoning are saved to KV after each iteration. A crash replays only the current iteration, not the entire conversation. **Signals** solve the human-in-the-loop problem. A running agent can pause and wait for human input via `WaitForSignal()`. An external system (CLI, API, Slack bot) sends the signal when the human responds. **Planner Steps** solve the dynamic workflow problem. An LLM can analyze a task, generate a plan as a JSON DAG fragment, and the engine executes it. No predefined step graph required. **Streaming** solves the real-time feedback problem. `PutStream()` publishes tokens as they arrive from the model API. Clients subscribe for live output without waiting for the full response. ## Section Guide The pages in this section show how to compose these primitives into practical LLM agent patterns: | Page | Pattern | |------|---------| | [Agent Loop Pattern](/docs/ai-patterns/agent-loop-pattern) | Core reasoning cycle with checkpointed state | | [Tool Use as Steps](/docs/ai-patterns/tool-use-as-steps) | Each LLM tool call as a typed DAG step | | [Planner Pattern](/docs/ai-patterns/planner-pattern) | LLM generates DAG dynamically | | [Human in the Loop](/docs/ai-patterns/human-in-the-loop) | Approval gates and signal-based review | | [Context Management](/docs/ai-patterns/context-management) | Conversation state across iterations and retries | | [Multi-Agent Orchestration](/docs/ai-patterns/multi-agent-orchestration) | Delegation, fan-out, inter-agent communication | | [Cost and Safety Controls](/docs/ai-patterns/cost-and-safety-controls) | Bounded execution, rate limits, spend caps | | [Prompt and Response Schemas](/docs/ai-patterns/prompt-and-response-schemas) | Typed I/O validation for LLM handlers | Each page includes working Go code patterns that reference actual DagNats APIs. --- # Source: docs/site/content/docs/ai-patterns/planner-pattern.md The planner pattern lets an LLM generate a DAG fragment at runtime, turning an AI's plan into executable workflow steps with safety constraints. ## How It Works A [planner step](/docs/step-types/planner-steps) is a worker task whose output is not data -- it is a JSON DAG fragment. The engine validates the fragment against configurable bounds, namespaces the generated step IDs, and materializes them into the running workflow. From that point, the generated steps execute like any other. ```mermaid graph TD A[Analyze Task] --> P[Planner Step: LLM generates plan] P --> G1[Generated: code-edit] P --> G2[Generated: test-run] G1 --> G2 G2 --> R[Report Results] ``` This is the core mechanism for AI-planned workflows. The LLM analyzes a problem, decides what subtasks are needed, and emits structured JSON. The engine handles execution, retries, and dependency resolution. ## LLM Prompt Design The planner worker prompts the LLM to produce a structured plan. The key is constraining the output format to match the engine's expected fragment schema: ```go w.Handle("generate-plan", func(ctx worker.TaskContext) error { prompt := fmt.Sprintf(`Analyze this task and create an execution plan. Available tools: code-edit, test-run, lint, search Output a JSON plan with steps and dependencies. Maximum %d steps. Each step needs an id, task, and optional depends_on. Task: %s`, maxSteps, string(ctx.Input())) response, err := callLLMWithSchema(prompt, planSchema) if err != nil { return ctx.Fail(err) } return ctx.Complete([]byte(response)) }) ``` The LLM should produce output like: ```json { "steps": [ {"id": "search", "task": "search", "input": {"query": "auth handler"}}, {"id": "edit", "task": "code-edit", "depends_on": ["search"]}, {"id": "test", "task": "test-run", "depends_on": ["edit"]}, {"id": "lint", "task": "lint", "depends_on": ["edit"]} ] } ``` ## Schema Validation The engine validates every fragment before materialization. This is your safety net against LLM hallucination: | Check | What It Prevents | |-------|-----------------| | Step count bounds (1 to `MaxSteps`) | Unbounded step generation | | Depth limit (`MaxDepth`) | Deeply chained dependencies | | Cycle detection | Circular dependencies | | ID collision check | Conflicts with static steps | | `AllowedTasks` allowlist | Unauthorized task types | If validation fails, the planner step fails and the engine's retry policy applies. The LLM gets another chance to produce a valid plan (if retries are configured). ## Workflow Definition ```go wf := dag.NewWorkflow("ai-coding-agent") analyze := wf.Task("analyze", "analyze-codebase"). WithTimeout(30 * time.Second) plan := wf.Planner("plan", "generate-plan", dag.PlannerConfig{ MaxSteps: 20, MaxDepth: 5, AllowedTasks: []string{"code-edit", "test-run", "lint", "search"}, }).After(analyze) report := wf.Task("report", "summarize-results"). After(plan) def, err := wf.Build() ``` The `AllowedTasks` list is critical for safety. Without it, an LLM could generate steps referencing any registered task type. With it, the engine rejects fragments containing unauthorized tasks. ## Safety Constraints Plan generation has hard limits to prevent runaway execution: | Constraint | Default | Maximum | |-----------|---------|---------| | Steps per planner | configured | 100 | | Total dynamic steps per run | -- | 500 | | Fragment depth | configured | 10 | These limits mean that even if the LLM hallucinates an enormous plan, the engine rejects it before any generated step executes. The per-run limit of 500 dynamic steps prevents multiple planners from collectively overwhelming the system. ## Combining with Agent Loops A powerful pattern: an agent loop decides **when** to plan, and a planner step decides **what** to execute. ```go wf := dag.NewWorkflow("iterative-planner") agent := wf.AgentLoop("agent", "reasoning-loop"). WithMaxIterations(5). WithMaxDuration(30 * time.Minute) // The agent loop handler can trigger planning by completing // with a plan request, which feeds into a planner step plan := wf.Planner("execute", "plan-and-run", dag.PlannerConfig{ MaxSteps: 10, AllowedTasks: []string{"code-edit", "test-run"}, }).After(agent) ``` The agent loop handles the high-level reasoning. When it decides action is needed, it completes with context for the planner. The planner generates and executes the concrete steps. ## Related - [Planner Steps](/docs/step-types/planner-steps) -- step type mechanics and configuration - [Tool Use as Steps](/docs/ai-patterns/tool-use-as-steps) -- static tool-per-step alternative - [Cost and Safety Controls](/docs/ai-patterns/cost-and-safety-controls) -- bounding generated work - [Multi-Agent Orchestration](/docs/ai-patterns/multi-agent-orchestration) -- planners that delegate to sub-agents --- # Source: docs/site/content/docs/ai-patterns/prompt-and-response-schemas.md Typed schemas enforce structured input and output at workflow and step boundaries, catching malformed LLM responses before they propagate downstream. ## The Problem LLM outputs are inherently unstructured. Even with structured output modes, models can produce unexpected formats, missing fields, or wrong types. Without validation, a malformed response from step 3 silently corrupts the input to step 7, and you debug for hours. DagNats validates data at two levels: **workflow-level schemas** validate the initial input and final output of a run, and **handler-level typing** validates data at each step boundary within Go code. ## Workflow Input/Output Schemas `WorkflowDef` supports `InputSchema` and `OutputSchema` fields for validating run-level data. The engine checks the workflow input against `InputSchema` before starting the run, and checks the final output against `OutputSchema` before marking the run as completed. ```go wf := dag.NewWorkflow("code-review") wf.SetInputSchema(map[string]any{ "type": "object", "required": []string{"repo", "branch"}, "properties": map[string]any{ "repo": map[string]any{"type": "string"}, "branch": map[string]any{"type": "string"}, "files": map[string]any{ "type": "array", "items": map[string]any{"type": "string"}, }, }, }) wf.SetOutputSchema(map[string]any{ "type": "object", "required": []string{"summary", "issues"}, "properties": map[string]any{ "summary": map[string]any{"type": "string"}, "issues": map[string]any{ "type": "array", "items": map[string]any{"type": "object"}, }, }, }) def, err := wf.Build() ``` If the input fails validation, the run is rejected immediately. If the final output fails, the run fails with a schema validation error. This catches structural issues at the boundary, not deep in the pipeline. ## Typed Handlers for Structured LLM I/O Within handlers, use Go structs to enforce types on step input and output: ```go type ReviewInput struct { Repo string `json:"repo"` Branch string `json:"branch"` Files []string `json:"files"` } type ReviewOutput struct { Summary string `json:"summary"` Issues []Issue `json:"issues"` Score float64 `json:"score"` } w.Handle("llm-review", func(ctx worker.TaskContext) error { var input ReviewInput if err := json.Unmarshal(ctx.Input(), &input); err != nil { return ctx.FailPermanent( fmt.Errorf("invalid input schema: %w", err), ) } response, err := callLLMStructured(input) if err != nil { return ctx.Fail(err) } // Validate the LLM's response parses correctly var output ReviewOutput if err := json.Unmarshal(response, &output); err != nil { return ctx.Fail( fmt.Errorf("LLM response schema mismatch: %w", err), ) } data, _ := json.Marshal(output) return ctx.Complete(data) }) ``` Two different failure modes: invalid **input** is `FailPermanent` (the upstream step produced bad data; retrying will not help). Invalid **LLM response** is `Fail` (the model might produce valid output on retry). ## Schema Generation from Go Types For handlers that need to communicate their schema to the LLM (e.g., for structured output mode), derive the schema from the Go struct: ```go type ToolCallSchema struct { Name string `json:"name"` Arguments map[string]any `json:"arguments"` } func schemaFromType(v any) map[string]any { // Reflect on struct fields to produce JSON Schema // This is a simplified example; real implementations // handle nested types, slices, and optionality. t := reflect.TypeOf(v) props := make(map[string]any) required := []string{} for i := 0; i < t.NumField(); i++ { field := t.Field(i) tag := field.Tag.Get("json") props[tag] = map[string]any{"type": goTypeToJSON(field.Type)} required = append(required, tag) } return map[string]any{ "type": "object", "properties": props, "required": required, } } ``` Pass the generated schema to the LLM's structured output parameter so the model knows exactly what format to produce. When the response comes back, `json.Unmarshal` into the same Go type validates it. ## Validation at Step Boundaries Each step boundary is a validation point: ```mermaid graph LR I[Workflow Input] -->|InputSchema| S1[Step 1] S1 -->|json.Unmarshal| S2[Step 2: LLM] S2 -->|json.Unmarshal| S3[Step 3] S3 -->|OutputSchema| O[Workflow Output] ``` Data flows through the DAG as `[]byte`. Each handler deserializes its input, validates the structure, does its work, and serializes its output. The next handler deserializes and validates again. Malformed data fails at the first handler that touches it, with a clear error message identifying the schema mismatch. ## Related - [Tool Use as Steps](/docs/ai-patterns/tool-use-as-steps) -- typed tool handlers - [Planner Pattern](/docs/ai-patterns/planner-pattern) -- validating LLM-generated plans - [Error Handling](/docs/reliability/error-handling) -- FailPermanent vs Fail for schema errors --- # Source: docs/site/content/docs/ai-patterns/runtime-generated-workflows.md # Runtime-Generated Workflows (Agent Runtimes) Most workflows are **pre-registered**: you author a DAG, register it, and trigger runs of it. Agent runtimes lift that restriction for **gated** task handlers — a running step can **author a brand-new workflow at runtime and launch it**, so an LLM planner can compose known tools into a *novel* DAG and execute it durably, crash-recoverable like any other run. This is the capability ADR-021 Phase A delivers. It is opt-in, deny-by-default, and bounded on every axis — a runaway agent cannot fork-bomb the engine. > **Mental model.** A gated handler receives a `ControlPlane` handle on its > `TaskContext`. Through two methods — `RegisterWorkflow` and `StartRun` — it > authors an **ephemeral** workflow definition and spawns a **child run** of it. > The child is linked to the spawning run's lineage, so every run an agent > generates belongs to one **generation tree** rooted at the top-level run. ## The control-plane handle ```go type ControlPlane interface { // Author an ephemeral workflow def; returns the server-scoped name. RegisterWorkflow(ctx context.Context, def dag.WorkflowDef, opts RegisterOpts) (scopedName string, err error) // Launch a child run of a (scoped) workflow; returns the child run ID. StartRun(ctx context.Context, name string, input []byte) (runID string, err error) // Current-vs-max quota usage, so a handler can self-throttle. Budget(ctx context.Context) (RuntimeBudget, error) } ``` `ctx.ControlPlane()` returns the handle **or `nil`** — it is `nil` unless the step both *declares* the capability **and** is *granted* it (see below). Handlers must nil-check. Every failure is a typed `*ControlPlaneError` (never a panic), so the [durable agent loop]({{< ref "agent-loop-pattern" >}}) can inspect `.Kind` and regenerate-and-retry instead of crashing the engine. ## Using it (SDK) **1. Declare the capability on the step** that should be able to generate workflows: ```json { "name": "planner", "version": "1.0", "steps": [ { "id": "plan", "task": "plan-task", "type": "normal", "required_capabilities": ["control-plane"] } ] } ``` **2. Build the worker with a control plane** and nil-check the handle: ```go w := worker.NewWorker(nc, worker.WithControlPlane(worker.NewControlPlane(nc))) w.Handle("plan-task", func(ctx worker.TaskContext) error { cp := ctx.ControlPlane() if cp == nil { return ctx.Fail(fmt.Errorf("control plane not granted")) } // Author a child workflow AT RUNTIME — it is not pre-registered. child := dag.WorkflowDef{ Name: "do-step", Version: "1", Steps: []dag.StepDef{{ID: "work", Task: "child-work", Type: dag.StepTypeNormal}}, } scoped, err := cp.RegisterWorkflow(ctx.Context(), child, worker.RegisterOpts{}) if err != nil { return ctx.Fail(fmt.Errorf("register child: %w", err)) // typed error → loop self-corrects } runID, err := cp.StartRun(ctx.Context(), scoped, nil) if err != nil { return ctx.Fail(fmt.Errorf("start child: %w", err)) } return ctx.Complete([]byte(`{"planned":true}`)) }) // The runtime-authored workflow's task must also be handled. w.Handle("child-work", func(ctx worker.TaskContext) error { return ctx.Complete([]byte(`{"done":true}`)) }) ``` A complete, runnable version lives in [`examples/planner/`](https://github.com/danmestas/dagnats/tree/main/examples/planner). `RegisterWorkflow` returns a **server-computed scoped name** (`agent..`) — you pass *that exact string* to `StartRun`. The worker never constructs the namespace key; the server owns it. ## Enabling it (operator): deny-by-default grant policy Declaring `control-plane` is necessary but **not sufficient**. The workflow must also be **granted** the capability in `dagnats.yaml`: ```yaml policy: control_plane: grant: # workflows allowed a ControlPlane handle - planner - supervisor promote: # subset additionally allowed to promote (must ⊆ grant) - supervisor ``` - A workflow **absent from `grant`** gets `ctx.ControlPlane() == nil` even if its step declares the capability — deny-by-default is structural (the engine strips the capability from the dispatched task; the worker never receives a handle). - The policy is **hot-reloadable** ([ADR-018]) — editing `dagnats.yaml` flips a grant live, no restart. ## The safety model Agent runtimes open a generative capability, so every axis is bounded. None of these ever crash the orchestrator — they return a typed `ControlPlaneError`. | Concern | Defense | Kind | |---|---|---| | Privilege escalation | Deny-by-default: capability **declared + granted** | `denied` | | Acting in a foreign tree | **Server-derived** namespace (`agent..*`) + a **per-dispatch nonce** that binds the request to the run the worker is actually executing | `namespace` | | Runaway recursion | **Generation-depth cap** (`max_generation_depth`, ≤ engine ceiling) | `depth_exceeded` | | Resource exhaustion | Per-tree **quotas**: max active runs, max registered defs | `quota_exceeded` | | Registration storms | Per-tree **rate limit** | `rate_limited` | | Unauthorized promotion | Promotion governed by the `promote` policy list | `denied` | | Leaked ephemeral defs | A bounded, idempotent **reaper** sweeps `agent..*` defs after the root run is terminal + a grace window | | **Namespace binding (the key invariant).** A worker can only `RegisterWorkflow` / `StartRun` for the run it is *currently executing*. The orchestrator stamps a fresh random nonce into each task dispatch; the handle carries it; the server rejects any request whose nonce doesn't match the run's current step. A worker holding a *different* live run's ID cannot forge another tree's nonce. ## Self-throttling with `Budget()` A granted handler can read its remaining budget and back off *before* hitting a `quota_exceeded` reply: ```go b, _ := cp.Budget(ctx.Context()) if b.ActiveRuns >= b.MaxActiveRuns { // pause / re-plan instead of spawning } // RuntimeBudget{ ActiveRuns, MaxActiveRuns, RegisteredDefs, MaxRegisteredDefs } ``` ## Promotion (durable defs) By default a registered def is **ephemeral** (namespaced to the tree, reaped when the root completes). Setting `RegisterOpts{Promote: true}` registers it under the stable, reaper-immune `promoted.*` namespace — but only if the workflow is in the policy's `promote` list; otherwise the call returns `denied`. (Promoted defs are *not* subject to the per-tree quotas — they have no owning tree.) ## Observability - **Console → Agent runtimes** (Activity section): one row per generation tree, showing the spawned-run lineage, per-runtime budget consumption, and a "runtime" tag on runs that were spawned by an agent. Live over SSE. - **`nats micro ls` / `nats micro stats`**: the internal control-plane services (`dagnats-api`, `dagnats-trigger`) are discoverable — see [Service discovery]({{< ref "/docs/operations/service-discovery" >}}). - **Audit log** (Console → Audit): every grant decision and control-plane mutation (`runtime.register` / `runtime.spawn` / `runtime.promote`, plus `denied` outcomes) is recorded. ## Configuration reference All values are read from `dagnats.yaml` (or the `DAGNATS_*` env override) and are **per generation tree** (keyed by the root run). Full details in [Configuration]({{< ref "/docs/operations/configuration" >}}). | Key | Env | Default | Meaning | |---|---|---|---| | `max_active_runs_per_root` | `DAGNATS_MAX_ACTIVE_RUNS_PER_ROOT` | `100` | Max non-terminal runs per tree | | `max_defs_per_root` | `DAGNATS_MAX_DEFS_PER_ROOT` | `500` | Max ephemeral defs per tree | | `max_generation_depth` | `DAGNATS_MAX_GENERATION_DEPTH` | `3` | Max spawn nesting (≤ engine ceiling) | | `max_registers_per_minute_per_root` | `DAGNATS_MAX_REGISTERS_PER_MINUTE_PER_ROOT` | `60` | Register rate limit per tree | | `policy.control_plane.grant` / `.promote` | — | (empty = deny all) | Capability grant lists | ## Not in Phase A Deliberately deferred (the control-plane interface is stable; these extend behind it): per-**step** (vs per-workflow) grant granularity; a Tier-2 supervisor and `ProvisionFunction` (spawning new *workers*, not just runs); token/compute metering in `Budget`. Operator worker controls (drain/decommission) stay out — the Agent-runtimes view is observe-only. [ADR-018]: https://github.com/danmestas/dagnats/blob/main/docs/architecture/adr-018-dagnats-yaml-hot-reload.md --- # Source: docs/site/content/docs/ai-patterns/tool-use-as-steps.md Modeling each LLM tool call as a separate DAG step gives you typed I/O, independent retries, parallel execution, and full observability per tool invocation. ## The Pattern Instead of executing tools inline within an agent loop handler, define each tool as its own step type. An orchestrating step (or planner) wires them together as DAG dependencies. The LLM decides which tools to call; the DAG executes them with all the reliability guarantees of normal steps. ```mermaid graph LR LLM[LLM Step] -->|tool: read_file| RF[read-file step] LLM -->|tool: search| S[search step] RF --> Agg[Aggregate Results] S --> Agg Agg --> LLM2[LLM Continue] ``` ## Why Separate Steps **Independent retries.** A file-read tool that hits a transient error retries on its own without re-running the LLM call that requested it. Each tool has its own [retry policy](/docs/reliability/retry-policies). **Parallel execution.** When the LLM requests multiple tools simultaneously, [map steps](/docs/step-types/map-steps) fan them out across workers. No sequential bottleneck. **Typed validation.** Each tool handler deserializes typed input and serializes typed output. Schema mismatches fail fast at the handler level, not buried in a string concatenation. **Observability.** Each tool invocation is a separate trace span with its own duration, status, and error. You can see exactly which tool is slow or failing. ## Typed Tool Handlers Define handlers that expect structured input and produce structured output: ```go type ReadFileInput struct { Path string `json:"path"` } type ReadFileOutput struct { Content string `json:"content"` Size int `json:"size"` } w.Handle("read-file", func(ctx worker.TaskContext) error { var input ReadFileInput if err := json.Unmarshal(ctx.Input(), &input); err != nil { return ctx.FailPermanent(fmt.Errorf("invalid input: %w", err)) } content, err := os.ReadFile(input.Path) if err != nil { return ctx.Fail(err) } output, _ := json.Marshal(ReadFileOutput{ Content: string(content), Size: len(content), }) return ctx.Complete(output) }) ``` Invalid input is a `FailPermanent` -- no point retrying a malformed tool call. Transient errors (file system, network) use `Fail` for retry. ## Parallel Tool Calls via Map Steps When an LLM returns multiple tool calls in a single response, model them as a map step. The orchestrator serializes the tool calls as a JSON array, and a map step fans out: ```go wf := dag.NewWorkflow("multi-tool") plan := wf.Task("plan", "llm-plan-tools") execute := wf.Map("execute-tools", "dispatch-tool"). After(plan). WithMaxItems(10). WithTimeout(30 * time.Second) synthesize := wf.Task("synthesize", "llm-synthesize"). After(execute) def, _ := wf.Build() ``` The `dispatch-tool` handler inspects each item's `tool` field and delegates: ```go w.Handle("dispatch-tool", func(ctx worker.TaskContext) error { var call ToolCall json.Unmarshal(ctx.Input(), &call) switch call.Name { case "read_file": return executeReadFile(ctx, call.Arguments) case "search": return executeSearch(ctx, call.Arguments) default: return ctx.FailPermanent( fmt.Errorf("unknown tool: %s", call.Name), ) } }) ``` Each array element is processed independently and concurrently. The map step collects all outputs in order for the synthesis step. ## Tool Result Aggregation The downstream step receives map outputs as a JSON array. The synthesizer formats them back into the LLM's expected tool-result format: ```go w.Handle("llm-synthesize", func(ctx worker.TaskContext) error { var results []ToolResult json.Unmarshal(ctx.Input(), &results) messages := buildMessagesWithToolResults(results) response, err := callLLM(messages) if err != nil { return ctx.Fail(err) } return ctx.Complete([]byte(response.Content)) }) ``` ## When to Use This Pattern | Scenario | Recommended Approach | |----------|---------------------| | Tools are fast and simple | Inline in agent loop handler | | Tools are slow or unreliable | Separate steps with retries | | LLM requests multiple tools at once | Map step for parallel execution | | Tools need different timeout/retry configs | Separate steps | | You need per-tool observability | Separate steps | For simple agents with fast tools, inlining tool execution in the [agent loop handler](/docs/ai-patterns/agent-loop-pattern) is simpler. Break tools into steps when reliability or parallelism matters. ## Related - [Map Steps](/docs/step-types/map-steps) -- parallel fan-out over arrays - [Agent Loop Pattern](/docs/ai-patterns/agent-loop-pattern) -- inline tool execution alternative - [Prompt and Response Schemas](/docs/ai-patterns/prompt-and-response-schemas) -- typed I/O validation - [Planner Pattern](/docs/ai-patterns/planner-pattern) -- LLM decides which tools to wire together --- # Source: docs/site/content/docs/architecture/_index.md {{< cards cols="3" >}} {{< card link="actor-runtime" title="Actor Runtime" >}} {{< card link="dag-resolution" title="DAG Resolution" >}} {{< card link="design-philosophy" title="Design Philosophy" >}} {{< card link="event-sourcing-model" title="Event Sourcing Model" >}} {{< card link="nats-primitives-mapping" title="NATS Primitives Mapping" >}} {{< /cards >}} --- # Source: docs/site/content/docs/architecture/actor-runtime.md DagNats uses a lightweight actor runtime to manage per-workflow execution state with supervised concurrency. ## Why Actors A workflow engine processes events for thousands of concurrent runs. Each run has its own state (step statuses, outputs, retry counts) that must be updated atomically. Two approaches handle this: locks or message passing. DagNats chose message passing. Each workflow run gets its own **actor** -- a goroutine with a channel mailbox. Events for a run are delivered to its actor sequentially. No locks needed inside the actor. No data races between runs. The actor model also provides **supervision** -- when an actor fails, its parent decides what to do (restart, stop, escalate). This eliminates manual error handling for transient failures in event processing. ## Core Types The `actor/` package is pure Go with zero NATS dependencies. NATS integration lives in `engine/`. ### Address Every actor has a unique address composed of a type and an ID: ```go type Address struct { Type string // e.g. "workflow" ID string // e.g. run ID } ``` Addresses format as `{type}.{id}` for logging and map keys. ### Message Messages are envelopes with a sender address and a payload: ```go type Message struct { From Address Payload any } ``` The payload is untyped -- actors type-assert to the expected type in their `Receive` method. ### Actor Interface Every actor implements a single method: ```go type Actor interface { Receive(ctx *Context, msg Message) error } ``` The runtime guarantees **sequential delivery**: one message at a time, in order. If `Receive` returns an error, the supervision strategy decides what happens next. ### Lifecycle Hooks Actors that need startup or cleanup logic implement the optional `Lifecycle` interface: ```go type Lifecycle interface { PreStart(ctx *Context) error PostStop(ctx *Context) } ``` `PreStart` runs before the first message. Errors in `PreStart` trigger supervision (the actor never starts). `PostStop` runs after the actor stops -- errors are logged but not supervised. ### Context The `Context` provides actor operations: - **`Self()`** -- returns this actor's address - **`Send(to, payload)`** -- delivers a message to another actor - **`Spawn(addr, actor, opts...)`** -- creates a supervised child actor ## Actor Topology ```mermaid graph TD RT[actor.Runtime] --> AO[ActorOrchestrator] AO --> WA1[WorkflowActor run-abc] AO --> WA2[WorkflowActor run-def] AO --> WA3[WorkflowActor run-ghi] style RT fill:#f0f0f0,stroke:#333 style AO fill:#e8f4e8,stroke:#333 style WA1 fill:#e8e8f4,stroke:#333 style WA2 fill:#e8e8f4,stroke:#333 style WA3 fill:#e8e8f4,stroke:#333 ``` The **Runtime** is the top-level container. The **ActorOrchestrator** subscribes to the `WORKFLOW_HISTORY` stream and routes events to per-run **WorkflowActors**. Each WorkflowActor holds its run state in memory and processes events sequentially. ## Runtime Mechanics ### Spawning `Runtime.Spawn()` creates a root actor. `Context.Spawn()` creates a supervised child. Both allocate a buffered channel (default capacity: 64) and start a goroutine running the receive loop. ```go rt := actor.NewRuntime() rt.Spawn( actor.Address{Type: "workflow", ID: runID}, workflowActor, actor.WithMailboxSize(128), actor.WithSupervision(&actor.OneForOne{}), ) ``` ### Message Delivery `Send()` writes to the target actor's channel. If the channel is full, it returns `ErrMailboxFull` immediately (non-blocking). If the target does not exist, it returns `ErrActorNotFound`. ### Sequential Processing Each actor goroutine runs a select loop reading from its mailbox channel. One message is processed at a time. This eliminates concurrency bugs inside actors -- no mutexes, no race conditions on run state. ## Supervision When an actor's `Receive` returns an error, the runtime consults the parent's **supervision strategy** to decide the response. ### Directives | Directive | Behavior | |-----------|----------| | **Restart** | Stop the failed actor, re-enter its receive loop (same instance) | | **Stop** | Terminate the actor permanently | | **Escalate** | Stop the actor, then apply supervision to the parent | | **Resume** | Ignore the error, re-enter the receive loop | ### Strategies **OneForOne** (default): only the failed child restarts. Other siblings continue unaffected. This is the strategy used by the `ActorOrchestrator` -- a failure in one workflow run does not impact others. ```go actor.WithSupervision(&actor.OneForOne{ Decider: func(err error) actor.Directive { if isTransient(err) { return actor.Restart } return actor.Stop }, }) ``` **AllForOne**: all siblings restart when any child fails. Useful when children have interdependent state (not currently used in DagNats). ### Restart Tracking Each actor has a **RestartTracker** that allows at most 5 restarts within a 1-minute window. If an actor exceeds this budget, it is stopped permanently instead of restarted. This prevents infinite restart loops from consuming resources. The tracker uses iterative pruning of expired timestamps -- no recursion, bounded memory. ## WorkflowActor The `WorkflowActor` (`engine/workflow_actor.go`) implements `actor.Actor` for a single workflow run: ```go type WorkflowActor struct { runID string def *dag.WorkflowDef run *dag.WorkflowRun store *SnapshotStore js jetstream.JetStream } ``` It holds the workflow definition and run state **in memory**. No per-event KV loads. The actor processes events by type: | Event | Actor Behavior | |-------|---------------| | `workflow.started` | Parse definition, create run, enqueue ready steps | | `step.completed` | Update step state, resolve next steps, check completion | | `step.failed` | Increment attempts, apply retry policy, fail run if exhausted | | `step.continue` | Increment iteration, check loop bounds, publish next task | After each event, the actor snapshots to the `workflow_runs` KV bucket for durability. But the in-memory state is authoritative during the actor's lifetime. ## ActorOrchestrator The `ActorOrchestrator` (`engine/actor_orch.go`) bridges NATS and the actor runtime: 1. Subscribes to `history.>` on the `WORKFLOW_HISTORY` stream with `DeliverAll` policy 2. Unmarshals each event and extracts the `runID` 3. Calls `ensureActor(runID)` -- spawns a `WorkflowActor` if one does not exist 4. Routes the event to the actor via `runtime.Send()` 5. Acknowledges the NATS message If sending to the actor fails (mailbox full, actor not found), the NATS message is NAK'd with a 5-second delay for retry. ```go func (ao *ActorOrchestrator) ensureActor(runID string) { if _, loaded := ao.actors.Load(runID); loaded { return } wa := NewWorkflowActor(runID, ao.store, ao.js) addr := actor.Address{Type: "workflow", ID: runID} ao.rt.Spawn(addr, wa, actor.WithSupervision(&actor.OneForOne{})) ao.actors.Store(runID, wa) } ``` Actor spawning is idempotent. If two events for the same run arrive concurrently, the second `Spawn` call returns `ErrAlreadyExists` and is silently ignored. --- # Source: docs/site/content/docs/architecture/dag-resolution.md The DAG resolution algorithm is a pure function that determines which steps to execute next based on the current state of a workflow run. ## The Core Function `dag.ResolveReady()` takes a workflow definition, a set of completed step IDs, and a set of queued step IDs. It returns the list of steps whose dependencies are satisfied and that have not already been dispatched. ```go func ResolveReady( def WorkflowDef, completed map[string]bool, queued map[string]bool, ) []StepDef ``` This function is **pure** -- no I/O, no side effects, no NATS. Given the same inputs, it always returns the same outputs. This makes it trivially testable and safe to call from any context. ### Resolution Logic For each step in the definition: 1. **Skip if already done**: if the step is in `completed` or `queued`, skip it 2. **Skip auxiliary steps**: steps marked as auxiliary (on-failure handlers, compensation targets) are never resolved through normal dependency resolution -- the engine dispatches them directly when their trigger fires 3. **Check dependencies**: if every step in `DependsOn` appears in `completed`, the step is ready Steps with no dependencies (entry points) are always ready on the first call. ```mermaid flowchart TD A[For each step in def] --> B{Completed or queued?} B -->|Yes| A B -->|No| C{Auxiliary step?} C -->|Yes| A C -->|No| D{All deps completed?} D -->|No| A D -->|Yes| E[Add to ready list] E --> A ``` ### No Recursion The algorithm iterates over the flat step list once. Dependency checking is a linear scan of each step's `DependsOn` slice against the `completed` map. There is no recursive graph traversal. The step list in `WorkflowDef` is topologically sorted at build time, so iteration order naturally respects the DAG structure. ## Related Functions ### ResolveSkipped ```go func ResolveSkipped(def, completed, queued, steps) []StepDef ``` Returns steps whose dependencies are satisfied **and** whose `SkipIf` condition evaluates to true. The orchestrator marks these as `Skipped` instead of enqueuing them. Skipped steps count as completed for downstream resolution -- their dependents can proceed. ### ResolveInput ```go func ResolveInput(step StepDef, steps map[string]StepState) ([]byte, error) ``` Builds the input payload for a step from upstream outputs: | Upstream Count | Input | |---------------|-------| | 0 (entry step) | `nil` -- receives workflow-level input from the caller | | 1 | Pass-through -- the upstream step's output verbatim | | N (fan-in) | JSON map of `{depID: output}` so the handler can address each upstream | ### IsComplete ```go func IsComplete(def WorkflowDef, completed map[string]bool) bool ``` Returns true when every non-auxiliary step is completed or skipped. Auxiliary steps that were never triggered (the happy path) do not block completion. ### ResolveCompensateChain ```go func ResolveCompensateChain(def, completed, failedStepID) []StepDef ``` When a step fails and the workflow has compensation steps defined, this function builds the compensation chain. It collects completed steps that have a `Compensate` target, reverses them (last completed first), and wires `DependsOn` between them for sequential execution. ## How the Engine Calls Resolution The `WorkflowActor` calls resolution functions after each event. The pattern repeats for every event type: ```mermaid sequenceDiagram participant Stream as WORKFLOW_HISTORY participant Actor as WorkflowActor participant DAG as dag package participant Tasks as TASK_QUEUES Stream->>Actor: step.completed event Actor->>Actor: update step state in memory Actor->>DAG: ResolveReady(def, completed, queued) DAG-->>Actor: []StepDef (ready steps) Actor->>DAG: ResolveInput(step, states) DAG-->>Actor: input payload Actor->>Tasks: publish task for each ready step Actor->>Actor: snapshot to KV ``` 1. **Event arrives**: the actor updates the in-memory `WorkflowRun` struct 2. **Resolve ready steps**: call `ResolveReady()` with current completed and queued sets 3. **Resolve input**: for each ready step, call `ResolveInput()` to build its input from upstream outputs 4. **Publish tasks**: publish task messages to `TASK_QUEUES` at subject `task.{taskType}` 5. **Check completion**: call `IsComplete()` to see if the workflow is done 6. **Snapshot**: save the updated run state to the `workflow_runs` KV bucket This cycle drives the entire workflow forward. Each event potentially unlocks new steps, which produce new events when they complete, which unlock more steps. The DAG structure ensures forward progress without cycles. ## Action Types When the engine processes ready steps, it translates them into actions based on the step type: | Step Type | Action | |-----------|--------| | Normal | Publish task to `task.{taskType}` | | AgentLoop | Publish task with iteration metadata | | SubWorkflow | Publish `workflow.spawn` event | | Map | Publish one task per input item (atomic batch) | | Sleep | Publish to `SLEEP_TIMERS` (no task) | | WaitForEvent | Register waiter in `event_waiters` KV | | Approval | Generate token, store in `approval_tokens` KV | | Planner | Publish task, then materialize returned DAG fragment | Normal task dispatch is the common case. Special step types have dedicated handling in the engine but still use the same resolution algorithm to determine when they are ready. ## Determinism The resolution algorithm is deterministic: same definition + same completed set + same queued set = same ready steps. This property is critical for: - **Testing**: pure functions are trivially unit-testable without NATS - **Replay**: rebuilding state from the event log produces identical results - **Debugging**: given the event history, you can reproduce the exact sequence of resolution decisions The `dag/` package has zero I/O dependencies. All NATS interaction happens in `engine/`, which calls into `dag/` for the pure computation and handles the I/O side effects itself. --- # Source: docs/site/content/docs/architecture/design-philosophy.md DagNats is built on three complementary design philosophies that shape every decision in the codebase. ## Ousterhout: A Philosophy of Software Design John Ousterhout's book argues that complexity is the root cause of software difficulty. DagNats applies three of its core principles. ### Deep Modules, Small Interfaces A **deep module** hides significant implementation behind a simple interface. The caller gets rich behavior without understanding the internals. The `worker` package is the clearest example. A handler receives a `TaskContext` with methods like `Complete(output)`, `Fail(err)`, and `Checkpoint(state)`. Behind that interface, the worker SDK manages NATS consumer creation, message acknowledgment, heartbeats, retry NAKs, KV persistence, and pub/sub streaming. The handler author sees none of this. ```go w.Handle("summarize", func(ctx worker.TaskContext) error { result := summarize(ctx.Input()) return ctx.Complete(result) }) ``` The engine's `ActorOrchestrator` follows the same pattern. It exposes `Start()` and `Stop()`. Internally it manages a JetStream consumer, per-run actor spawning, supervision, event routing, and KV snapshots. ### Pull Complexity Downward When a feature is hard, push the difficulty into the library -- not into every caller. DagNats centralizes NATS setup in `natsutil.SetupAll()`, retry logic in the engine, and timer management in the `SLEEP_TIMERS` stream. Workers never create streams. Handlers never manage consumers. Users never configure dedup windows. ### Define Errors Out of Existence Where possible, DagNats eliminates error conditions rather than handling them. Running `natsutil.SetupAll()` uses `CreateOrUpdateStream`, which succeeds whether the stream already exists or not. The worker directory bucket is optional -- if it is missing, workers function normally. Unknown config file keys produce warnings, not errors. ## TigerStyle: Safety-First Engineering TigerBeetle's coding discipline prioritizes correctness through constraints and contracts. ### Safety > Performance > Developer Experience Every design tradeoff follows this ordering. The engine uses assertions (panics) for programmer errors -- a nil connection, an empty address, an impossible state. These are not recoverable errors; they indicate bugs that must be fixed immediately. The codebase contains at minimum two assertions per function. ```go func NewActorOrchestrator(nc *nats.Conn, tel *observe.Telemetry) *ActorOrchestrator { if nc == nil { panic("NewActorOrchestrator: nc must not be nil") } if tel == nil { panic("NewActorOrchestrator: tel must not be nil") } // ... } ``` This is intentional. A panic at startup is better than a nil pointer dereference at 3 AM under load. ### Bounded Everything All loops, queues, retries, and collections have explicit upper bounds. Configuration files are limited to 300 lines. Leaf remotes are capped at 10. Worker configs are capped at 50. Map steps allow at most 10,000 items. Dynamic planners generate at most 100 steps. Actor mailboxes are buffered channels with fixed capacity. Restart trackers allow at most 5 restarts per minute. Unbounded growth is a bug. Every bound is documented and enforced. ### Assertions as Contracts Functions declare their preconditions as panicking assertions. This serves as executable documentation: if you call `Send()` with an empty address, the panic message tells you exactly what went wrong. Test coverage verifies these contracts, and the contracts protect production. ## HIPP: Simple, Fast, Reliable The HIPP philosophy (named after SQLite's design principles) emphasizes self-containment and minimal dependencies. ### Zero External Dependencies Beyond NATS DagNats requires only a NATS server. No PostgreSQL, no Redis, no external queue, no coordination service. The `dagnats serve` command embeds the NATS server itself, achieving true single-binary deployment with zero infrastructure prerequisites. ### Single-Binary Deployment The `dagnats serve` binary contains everything: embedded NATS, JetStream storage, the workflow engine, API, triggers, and HTTP server. Download one binary, run one command. This is not a convenience wrapper -- it is the primary deployment model. ### Do Not Import What You Can Write The codebase avoids external dependencies aggressively. The actor runtime, supervision strategies, restart tracking, KV-backed rate limiting, event correlation, and config file parsing are all implemented in-house. Each is under 300 lines. If you can write the 50 lines yourself, do it -- you own the behavior, the tests, and the upgrade path. ## How These Apply Together The three philosophies reinforce each other: - **Deep modules** (Ousterhout) + **bounded everything** (TigerStyle) = interfaces that are both simple and safe - **Pull complexity downward** (Ousterhout) + **zero dependencies** (HIPP) = rich behavior with a small dependency tree - **Assertions as contracts** (TigerStyle) + **define errors out of existence** (Ousterhout) = clear separation between programmer errors (panic) and operational errors (return) - **Single-binary** (HIPP) + **safety-first** (TigerStyle) = a system that is easy to deploy and hard to misconfigure The result is a workflow engine where the happy path is obvious, the error path is explicit, and the operational surface area is minimal. --- # Source: docs/site/content/docs/architecture/event-sourcing-model.md DagNats uses event sourcing as its persistence model -- the WORKFLOW_HISTORY stream is the single source of truth for all workflow state. ## Why Event Sourcing Traditional workflow engines store mutable rows: update the step status, overwrite the output, increment the retry counter. This makes debugging hard (what happened between states?), recovery fragile (what if the update was partial?), and auditing impossible (who changed what when?). Event sourcing stores **what happened** as an immutable, append-only log. Current state is derived by replaying events from the beginning. Every state transition is recorded. Nothing is lost. Nothing is overwritten. For a workflow engine, this is a natural fit. A workflow run *is* a sequence of events: started, step completed, step failed, workflow completed. Storing those events directly means the log is both the operational data and the audit trail. ## The Event Log Every state change publishes an immutable event to the `WORKFLOW_HISTORY` JetStream stream at subject `history.{runID}`. ```mermaid sequenceDiagram participant API as API participant Stream as WORKFLOW_HISTORY participant Engine as ActorOrchestrator participant KV as workflow_runs KV API->>Stream: workflow.started (runID, def, input) Stream->>Engine: deliver event Engine->>Engine: spawn WorkflowActor Engine->>Engine: Advance() -> EnqueueTask actions Engine->>KV: snapshot run state Note over Stream: time passes, worker completes task API->>Stream: step.completed (runID, stepID, output) Stream->>Engine: deliver event Engine->>Engine: Advance() -> more actions Engine->>KV: snapshot updated state ``` ### Event Types The system defines these event types, each carrying a typed payload: | Event | Published When | |-------|---------------| | `workflow.started` | API starts a new run | | `workflow.completed` | All steps completed | | `workflow.failed` | Step failure exhausted retries | | `workflow.cancelled` | Run cancelled via API | | `workflow.spawn` | Sub-workflow launched | | `workflow.child.completed` | Child workflow finished | | `workflow.child.failed` | Child workflow failed | | `step.completed` | Worker reports success | | `step.failed` | Worker reports failure | | `step.cancelled` | Step cancelled | | `step.continue` | Agent loop iteration | | `step.sleep.started` | Sleep step begins | | `step.sleep.completed` | Sleep duration elapsed | | `step.wait.started` | Wait-for-event step begins | | `step.wait.matched` | External event matched | | `step.wait.timeout` | Wait timed out | | `step.map.started` | Map fan-out begins | | `step.map.completed` | All map instances done | | `step.map.instance.completed` | One map instance done | | `agent.loop.iteration` | Agent loop progress | | `compensate.started` | Compensation chain begins | | `compensate.step.completed` | Compensation step done | | `compensate.failed` | Compensation failed | | `compensate.completed` | All compensation done | ## Deduplication Every event published to `WORKFLOW_HISTORY` includes a `Nats-Msg-Id` header. NATS uses a 5-second dedup window to reject duplicate publishes. This means the engine can safely retry event publishing without risk of double-processing. The dedup ID is typically `{runID}.{eventType}.{stepID}` or `{runID}.{eventType}` for workflow-level events. This makes each event naturally idempotent. ## KV Snapshots The `workflow_runs` KV bucket stores **snapshots** of run state. These are a convenience for fast reads -- not the source of truth. The snapshot contains the current `WorkflowRun` struct: run status, step states, outputs, error messages, timing data. The engine updates the snapshot after processing each event. **Snapshots exist for two reasons:** 1. **Fast reads**: the API can serve `GET /runs/{id}` by reading the KV entry directly, without replaying the event stream 2. **Recovery hint**: on startup, the orchestrator can load the last snapshot as a starting point, then replay only events published after the snapshot's sequence number If a snapshot is lost or corrupt, the engine rebuilds it from the event log. The event log is always correct. ## Replay Semantics On startup, the `ActorOrchestrator` creates a JetStream consumer with `DeliverAll` policy on `history.>`. It processes every event from the beginning of the stream. ```mermaid flowchart LR A[Start] --> B[Subscribe to history.>] B --> C[Receive event] C --> D{Actor exists?} D -->|No| E[Spawn WorkflowActor] D -->|Yes| F[Route to actor] E --> F F --> G[Actor processes event] G --> H[Snapshot to KV] H --> C ``` For each event: 1. The orchestrator ensures a `WorkflowActor` exists for that `runID` 2. The event is routed to the actor's mailbox 3. The actor updates its in-memory state 4. The actor snapshots to KV This replay is idempotent. Processing the same event twice produces the same state because each event is a deterministic state transition. The `Nats-Msg-Id` dedup prevents duplicate events from entering the stream in the first place. ## Stateless Orchestrator The orchestrator holds no persistent state of its own. All state lives either in the event stream or in per-run actor memory (which is rebuilt from the stream on startup). This means: - **Restarts are safe**: kill the process, start it again, state recovers from the event log - **No migration**: there is no database schema to migrate - **No coordination**: multiple orchestrator instances reading the same stream would process the same events (though DagNats runs a single orchestrator by design) ## Trade-offs Event sourcing is not free: - **Storage grows over time**: every event is kept. Set `MaxAge` or `MaxBytes` on `WORKFLOW_HISTORY` when runs older than your recovery window are no longer needed. - **Replay time on startup**: proportional to total events. For very large deployments, snapshots reduce the effective replay window. - **Event schema evolution**: changing event payloads requires backward compatibility. DagNats handles this by keeping payloads as `json.RawMessage` and parsing defensively. For a workflow engine, these trade-offs are strongly favorable. The audit trail, debuggability, and recovery guarantees far outweigh the storage cost. --- # Source: docs/site/content/docs/architecture/nats-primitives-mapping.md DagNats maps every infrastructure need to a NATS primitive, eliminating external dependencies entirely. ## Why NATS-Native Most workflow engines layer custom infrastructure on top of general-purpose databases: task queues in PostgreSQL, state in Redis, events in Kafka. Each dependency adds operational complexity, failure modes, and configuration surface. DagNats inverts this. NATS JetStream provides durable streams, key-value storage, object storage, pub/sub, and request/reply in a single binary. By mapping every need to a NATS primitive, the entire system runs on one infrastructure dependency. ## The Mapping | Need | NATS Primitive | How DagNats Uses It | |------|---------------|---------------------| | **Event log** | JetStream stream (WORKFLOW_HISTORY) | Immutable, append-only event sourcing. Every workflow state change is a message on `history.{runID}`. Retention: limits policy, 5s dedup. | | **Task distribution** | JetStream stream (TASK_QUEUES) + pull consumers | WorkQueue retention ensures each task is delivered to exactly one worker. Workers create filtered pull consumers for their task types. `MaxAckPending` controls parallelism. | | **Atomic fan-out** | JetStream atomic batch publish | Map steps publish all instance tasks in a single atomic operation. Either all tasks publish or none do. Requires NATS >= 2.12. | | **Run state** | KV bucket (workflow_runs) | Snapshot of current run state for fast API reads. Optimistic locking via KV Revision prevents stale writes. | | **Workflow definitions** | KV bucket (workflow_defs) | Immutable after creation. KV revision history provides versioning. | | **Retries with backoff** | NakWithDelay | When a task fails with a retriable error, the engine NAKs the NATS message with a delay. NATS redelivers after the delay. No timer service, no retry queue, no cron job. | | **Durable sleep** | NakWithDelay via SLEEP_TIMERS | Sleep steps publish a message to the `SLEEP_TIMERS` stream, then NAK it with the sleep duration. On redeliver, the engine publishes a `step.sleep.completed` event. Sleeps up to 365 days. | | **Step timeouts** | AckWait + MaxDeliver | Each task has an `AckWait` deadline. If the worker does not acknowledge within the deadline, NATS redelivers. After `MaxDeliver` attempts, the task goes to the dead letter stream. | | **Exactly-once events** | Nats-Msg-Id header | Every event published to WORKFLOW_HISTORY includes a dedup ID. The 5-second dedup window rejects duplicate publishes during retries. | | **Cross-workflow signals** | KV watches | The `signals` KV bucket stores signal values at `{runID}.{name}`. A step calls `WaitForSignal()` which creates a KV watch. When another step calls `SendSignal()`, the KV update triggers the watcher immediately. | | **Event correlation** | KV watches (event_waiters) | Wait-for-event steps register a waiter in the `event_waiters` KV bucket. The correlator watches this bucket and matches incoming events from the `EVENTS` stream. O(1) lookup per event type. | | **Worker discovery** | KV bucket (workers) with TTL | Workers heartbeat every 30s by re-putting their entry. The 60s TTL auto-expires stale entries. Observability-only -- the engine never reads it. | | **Internal API** | NATS micro framework | The API service uses `micro` for service discovery and load balancing over NATS request/reply. CLI and engine communicate via NATS subjects, not HTTP. | | **Real-time streaming** | Core pub/sub | Workers call `PutStream(data)` which publishes to `stream.{runID}.{stepID}`. Subscribers receive updates in real time. No persistence needed -- this is ephemeral output streaming. | | **Rate limiting** | KV CAS (compare-and-swap) | Token bucket state stored in the `rate_limits` KV bucket. CAS operations ensure atomic token acquisition. When exhausted, tasks retry via `SLEEP_TIMERS` with the refill delay. | | **Concurrency control** | KV CAS counters | Per-task-type and per-run counters in `concurrency_tasks` KV bucket. CAS increment/decrement prevents races. Bounded retry: 10 CAS attempts. | | **Large payloads** | Object Store + event references | When payloads exceed message size limits, they are stored in NATS Object Store. Events reference them by key. | | **Worker affinity** | KV (sticky_bindings) + dedicated stream (STICKY_TASKS) | Bindings map runs to workers. Affinity tasks route to worker-specific subjects on the `STICKY_TASKS` memory stream. | | **Human approval** | KV (approval_tokens) with TTL | 256-bit tokens stored with 7-day TTL. Atomic consumption via CAS prevents double-approve. | | **Idempotency** | KV (idempotency_keys) with TTL | Dedup keys map to run IDs with 24-hour TTL. Prevents duplicate workflow starts from retry logic. | ## Key Patterns ### NakWithDelay as Universal Timer The most powerful pattern in the mapping. NATS `NakWithDelay` tells the server "redeliver this message after N duration." DagNats uses this for: - **Retry backoff**: failed tasks are NAK'd with exponentially increasing delays - **Durable sleep**: sleep steps NAK with the sleep duration (up to 365 days) - **Wait-for-event timeout**: NAK with the timeout duration, fires if no match arrives - **Rate limit retry**: NAK with the token refill delay - **Debounce**: NAK with the debounce window One primitive replaces what would otherwise require a timer service, a scheduler, a delay queue, and a cron system. ### KV as Coordination Primitive NATS KV provides atomic operations (Put, CAS, Delete) and real-time watches. DagNats uses these for: - **Optimistic locking**: `workflow_runs` snapshots use KV Revision to detect concurrent writes - **Signal delivery**: KV watches provide instant notification when a signal is written - **Token buckets**: CAS operations ensure atomic rate limit token acquisition - **Worker health**: TTL-based auto-expiry replaces explicit health check polling ### Pull Consumers for Work Distribution Unlike push-based messaging, pull consumers let workers control their own pace. A worker pulls tasks when it is ready, processes one, then pulls the next. `MaxAckPending` caps the number of in-flight tasks. This naturally handles back-pressure without complex flow control. ## What This Eliminates By mapping everything to NATS primitives, DagNats does not need: | Eliminated | NATS Replacement | |-----------|-----------------| | PostgreSQL / MySQL | KV buckets + JetStream streams | | Redis | KV buckets with TTL | | Kafka / RabbitMQ | JetStream streams + consumers | | Celery / Sidekiq | TASK_QUEUES stream + pull consumers | | Cron daemon | SLEEP_TIMERS + NakWithDelay | | Timer service | NakWithDelay | | Service mesh | NATS micro + request/reply | | etcd / Consul | KV watches + CAS | The result is a single infrastructure dependency. One binary to deploy, one system to monitor, one backup strategy to implement. --- # Source: docs/site/content/docs/concepts/_index.md {{< cards cols="3" >}} {{< card link="events-and-event-sourcing" title="Events and Event Sourcing" >}} {{< card link="runs" title="Runs" >}} {{< card link="steps" title="Steps" >}} {{< card link="workers" title="Workers" >}} {{< card link="workflows-and-dags" title="Workflows and DAGs" >}} {{< /cards >}} --- # Source: docs/site/content/docs/concepts/events-and-event-sourcing.md DagNats uses **event sourcing** as its persistence model -- the immutable event log is the source of truth, not the KV snapshots. ## The WORKFLOW_HISTORY Stream Every state change in a workflow run is recorded as an event on the `WORKFLOW_HISTORY` JetStream stream. Events are published to subjects matching `history.{run_id}` and are retained indefinitely with a 5-second deduplication window (via `Nats-Msg-Id` headers). This stream is **append-only**. Events are never modified or deleted. The full history of any run can be reconstructed by replaying its events from the stream. ## Event Types Events are categorized by lifecycle scope: ### Workflow lifecycle | Event | When published | |-------|---------------| | `workflow.started` | Run begins execution | | `workflow.completed` | All non-auxiliary steps succeed | | `workflow.failed` | A step fails permanently | | `workflow.cancelled` | Run is cancelled | | `workflow.spawn` | Sub-workflow child is created | | `workflow.child.completed` | Child sub-workflow finishes successfully | | `workflow.child.failed` | Child sub-workflow fails | ### Step lifecycle | Event | When published | |-------|---------------| | `step.completed` | Worker calls `Complete()` | | `step.failed` | Worker calls `Fail()` / `FailPermanent()` / `FailRetryAfter()` | | `step.cancelled` | Step cancelled during run cancellation | | `step.continue` | Worker calls `Continue()` (agent loop iteration) | ### Agent loop | Event | When published | |-------|---------------| | `agent.loop.iteration` | Engine processes a Continue event | ### Sleep and wait | Event | When published | |-------|---------------| | `step.sleep.started` | Sleep step timer begins | | `step.sleep.completed` | Sleep duration elapses | | `step.wait.started` | WaitForEvent step begins watching | | `step.wait.matched` | External event matches the condition | | `step.wait.timeout` | Wait timeout expires without a match | ### Map steps | Event | When published | |-------|---------------| | `step.map.started` | Map step fans out to individual tasks | | `step.map.completed` | All map instances finish (or one fails) | | `step.map.instance.completed` | One map item finishes | ### Compensation (saga) | Event | When published | |-------|---------------| | `compensate.started` | Saga rollback begins | | `compensate.step.completed` | One compensation step finishes | | `compensate.failed` | Compensation itself fails | | `compensate.completed` | All compensation steps succeed | ## How Events Drive DAG Resolution The engine's core function is `dag.Advance(def, run, event) []Action` -- a **pure function** that takes an immutable workflow definition, the current run state, and a new event, then returns a list of actions to execute (enqueue tasks, complete the workflow, fail the workflow, etc.). This function contains no I/O. It calculates which steps have all dependencies satisfied and produces the next set of actions. The engine loop is: 1. Consume event from `WORKFLOW_HISTORY` 2. Load run snapshot from KV 3. Call `Advance()` to get actions 4. Execute actions (publish tasks, update KV, publish new events) 5. Repeat Because `Advance()` is pure, the engine is **stateless**. On restart, it replays the event stream to reconstruct the current state of all active runs. ## Replay Semantics Any run's complete state can be rebuilt by replaying its events from the `WORKFLOW_HISTORY` stream. This provides: - **Crash recovery** -- the engine restarts and replays, no data lost - **Debugging** -- replay a run's history to understand exactly what happened - **Auditing** -- every state transition is permanently recorded with timestamps Deduplication via `Nats-Msg-Id` ensures that replayed events (from worker retries or engine restarts) do not create duplicate state transitions. ## Events vs KV Snapshots DagNats maintains both an event stream and KV snapshots. They serve different purposes: | Concern | Event stream | KV snapshot | |---------|-------------|-------------| | **Authority** | Source of truth | Recovery convenience | | **Mutability** | Append-only, immutable | Overwritten on each update | | **Use case** | Audit, replay, debugging | Fast current-state lookup | | **Optimistic locking** | N/A | KV Revision for CAS | The KV snapshot in `workflow_runs` stores the latest `WorkflowRun` state for fast reads. The engine updates it after processing each event. If the KV snapshot is lost or corrupted, it can be rebuilt entirely from the event stream. ## Related pages - [Runs](/docs/concepts/runs) -- the state that events modify - [Workers](/docs/concepts/workers) -- the source of step completion events - [Workflows and DAGs](/docs/concepts/workflows-and-dags) -- the definition that `Advance()` evaluates against --- # Source: docs/site/content/docs/concepts/runs.md A **run** is a single execution of a workflow definition -- it tracks the live state of every step from creation to terminal status. ## WorkflowRun When you start a workflow, the engine creates a `WorkflowRun` with all steps initialized to `pending`. The run holds mutable state that evolves as events arrive. Key fields: | Field | Type | Purpose | |-------|------|---------| | `RunID` | `string` | Unique execution identifier | | `WorkflowID` | `string` | Name of the workflow definition | | `Status` | `RunStatus` | Current lifecycle state | | `Steps` | `map[string]StepState` | Per-step mutable state | | `Input` | `json.RawMessage` | Original user-supplied payload | | `CreatedAt` | `time.Time` | When the run was created (UTC) | | `ParentRunID` | `string` | Set for child sub-workflow runs | | `Deadline` | `*time.Time` | Workflow-level timeout deadline | ## Run Status State Machine A run progresses through these states: ```mermaid stateDiagram-v2 [*] --> Pending Pending --> Running: engine claims run Running --> Completed: all steps succeed Running --> Failed: step fails permanently Running --> Cancelled: external cancellation Running --> Compensated: saga rollback succeeds Running --> CompensateFailed: saga rollback fails Completed --> [*] Failed --> [*] Cancelled --> [*] Compensated --> [*] CompensateFailed --> [*] ``` | Status | Meaning | |--------|---------| | `pending` | Created but not yet claimed by the engine | | `running` | Engine is actively scheduling steps | | `completed` | All non-auxiliary steps finished successfully | | `failed` | A step failed permanently (retries exhausted, no compensation) | | `cancelled` | Cancelled via API, CLI, or `CancelOn` event | | `compensated` | Failed but saga compensation succeeded | | `compensate_failed` | Compensation itself failed | All statuses except `pending` and `running` are **terminal** -- once reached, the run never changes state again. ## Step Status States Each step within a run has its own status: | Status | Meaning | |--------|---------| | `pending` | Not yet ready (dependencies unsatisfied) | | `queued` | Dispatched to NATS, waiting for a worker | | `running` | Worker is executing the task | | `completed` | Finished successfully with output | | `failed` | Failed permanently | | `skipped` | Bypassed via `SkipIf` condition | | `cancelled` | Run was cancelled while step was in progress | | `recovered` | Failed but recovered via OnFailure handler | ## Input and Output **Run input** is an arbitrary JSON payload supplied when starting the workflow. It is preserved on the `WorkflowRun` so retries can reuse it. Steps with no dependencies receive the run input as their task input. **Step output** is stored as raw bytes on `StepState.Output`. Downstream steps receive the output of their dependencies as input. For steps with multiple dependencies, the engine assembles a merged input payload. ## Starting a Run Via CLI: ```bash dagnats run start my-workflow '{"key": "value"}' ``` Via API: ```go runID, err := svc.StartRun(ctx, "my-workflow", inputJSON) ``` ## Inspecting a Run ```bash dagnats run get dagnats run list --workflow=my-workflow --status=running ``` The `get` command shows the run status, each step's status, and timing information. The `list` command filters runs by workflow name, status, and time range. ## Cancelling a Run ```bash dagnats run cancel ``` Cancellation transitions the run to `cancelled` and terminates any in-flight steps. Steps that have already completed retain their output. Steps that are `pending` or `queued` are moved to `cancelled` without execution. Workflows can also self-cancel via the `CancelOn` builder option, which watches for a matching external event. ## Related pages - [Workflows and DAGs](/docs/concepts/workflows-and-dags) -- defining what a run executes - [Events and Event Sourcing](/docs/concepts/events-and-event-sourcing) -- how run state changes are recorded - [Workers](/docs/concepts/workers) -- what executes the steps in a run --- # Source: docs/site/content/docs/concepts/steps.md A **step** is the smallest unit of work in a DagNats workflow -- one task dispatched to one worker (or handled by the engine itself for infrastructure steps). ## Step Types DagNats supports 9 step types. Each has distinct execution semantics: | Type | Purpose | When to use | |------|---------|-------------| | `normal` | Execute a task once, complete or fail | Standard compute, API calls, data transforms | | `agent_loop` | Iterative execution with `Continue()`, bounded by max iterations/duration | LLM agent loops, polling, convergence tasks | | `agent` | Routed to agent SDK via metadata | Claude Agent SDK integration | | `sub_workflow` | Spawn a child workflow, wait for completion | Reusable workflow composition, nested pipelines | | `map` | Fan-out over an array (one task per item), fan-in on completion | Parallel batch processing, list transforms | | `sleep` | Durable delay handled by the engine | Rate limiting, scheduled delays, cooldowns | | `wait_for_event` | Block until an external event matches a condition | Webhooks, human triggers, cross-system coordination | | `approval` | Human approval gate with cryptographic token | Deploy approvals, sensitive operations | | `planner` | Generate DAG fragments at runtime | Dynamic pipelines, AI-planned workflows | Steps that require a **worker** to execute: `normal`, `agent_loop`, `agent`, `map`, `planner`. Steps handled entirely by the **engine**: `sleep`, `wait_for_event`, `approval`, `sub_workflow`. ## StepDef Fields Every step is declared as a `StepDef` in the workflow definition: | Field | Type | Purpose | |-------|------|---------| | `ID` | `string` | Unique identifier within the workflow | | `Task` | `string` | Task type that workers register for | | `DependsOn` | `[]string` | Step IDs that must complete first | | `Type` | `StepType` | One of the 9 types above | | `Timeout` | `time.Duration` | Per-attempt timeout | | `Retries` | `int` | Max retry attempts (0 = no retries) | | `Retry` | `*RetryPolicy` | Fine-grained retry config (backoff, etc.) | | `Config` | `json.RawMessage` | Type-specific configuration (AgentLoopConfig, MapConfig, etc.) | | `SkipIf` | `*ParentCond` | Conditional skip based on parent output | | `Metadata` | `map[string]string` | Opaque key-value pairs (used by agent steps) | | `WorkerGroup` | `string` | Route to specific worker group | | `OnFailure` | `string` | Step ID to run on permanent failure | | `Compensate` | `string` | Step ID for saga compensation | | `MaxTaskConcurrency` | `int` | Global per-task-type concurrency limit | ## Dependencies Dependencies are declared via `DependsOn` -- a list of step IDs that must reach a terminal state before this step is queued. The engine resolves dependencies iteratively: when a step completes, it checks which downstream steps have all their dependencies satisfied and enqueues them. **Skipped steps count as satisfied.** If a step is skipped via `SkipIf`, downstream steps that depend on it proceed normally. This lets you build conditional branches without blocking the rest of the graph. The builder API provides two ways to wire dependencies: ```go // String-based (backward compat) wf.Task("process", "process") wf.DependsOn("fetch") // StepRef-based (compile-time safe, preferred) fetch := wf.Task("fetch", "fetch") process := wf.Task("process", "process").After(fetch) ``` `After()` panics immediately if you pass a `StepRef` from a different builder, catching miswiring at construction time rather than at validation time. ## Conditional Execution Steps can be conditionally skipped based on a parent step's output using `SkipIf`. The condition references a parent step (which must be in `DependsOn`) and evaluates an operator against a field in that step's output. ```go fetch := wf.Task("fetch", "fetch") process := wf.Task("process", "process"). After(fetch). SkipIf(dag.ParentOutput("fetch", "skip", dag.OpEquals, true)) ``` ## Related pages - [Workflows and DAGs](/docs/concepts/workflows-and-dags) -- how steps compose into workflows - [Workers](/docs/concepts/workers) -- how steps are executed - [Step Types](/docs/step-types/) -- detailed reference for each step type --- # Source: docs/site/content/docs/concepts/workers.md A **worker** is a process that subscribes to NATS task subjects, executes handler functions, and publishes completion events back to the engine. ## Lifecycle Workers follow a strict lifecycle: create, register handlers, start, consume tasks, stop. ```mermaid stateDiagram-v2 [*] --> Created: NewWorker() Created --> Registered: Handle() / HandleTyped() Registered --> Running: Start() Running --> Running: consume tasks Running --> Stopped: Stop() Stopped --> [*] ``` **Create** allocates the worker and binds it to a NATS connection. **Register** maps task type strings to handler functions. **Start** creates JetStream pull consumers for each registered task type and begins consuming. **Stop** unsubscribes all consumers and deregisters from the worker directory. ## Creating a Worker `worker.NewWorker()` takes a NATS connection, an optional telemetry bundle, and variadic options: ```go w := worker.NewWorker(nc, nil) // nil tel = noop telemetry ``` Options include: | Option | Purpose | |--------|---------| | `WithGroups(groups...)` | Subscribe only to specific worker groups | | `WithPartitions(n)` | Enable elastic consumer groups with n partitions | ## Registering Handlers Two registration patterns: ### Handle (raw bytes) `Handle()` registers a `HandlerFunc` that receives a `TaskContext` with raw `[]byte` input. The handler calls exactly one terminal method per execution. ```go w.Handle("fetch", func(ctx worker.TaskContext) error { data := fetchFromAPI(ctx.Input()) return ctx.Complete(data) }) ``` ### HandleTyped (generics) `HandleTyped[I, O]()` wraps a typed handler function. JSON marshal/unmarshal is handled automatically. Serialization failures are non-retriable. ```go worker.HandleTyped(w, "transform", func( ctx worker.TaskContext, input TransformInput, ) (TransformOutput, error) { result := transform(input) return result, nil }) ``` The typed handler calls `Complete()` automatically on success -- you return the output value and error, not call `Complete()` yourself. ### HandleSingleton `HandleSingleton()` registers a handler that runs as a single-partition elastic consumer group. Only one consumer processes messages at a time across all worker instances. Useful for ordered processing or exclusive resource access. ## TaskContext Interface Every handler receives a `TaskContext` -- the deep interface that hides all NATS mechanics behind clean method calls. ### Identity and input | Method | Returns | Purpose | |--------|---------|---------| | `Input()` | `[]byte` | Raw task input payload | | `RunID()` | `string` | Workflow run identifier | | `StepID()` | `string` | Step identifier within the run | | `RetryCount()` | `int` | Current attempt number (0-based) | ### Step completion Call exactly one of these per execution: | Method | Purpose | |--------|---------| | `Complete(output)` | Mark step as succeeded with output | | `Fail(err)` | Mark as retriable failure (retries apply) | | `FailPermanent(err)` | Mark as non-retriable failure (skip all retries) | | `FailRetryAfter(err, d)` | Fail with explicit retry delay, bypassing backoff | | `Continue(output)` | Agent loop: signal another iteration | ### Streaming and heartbeat | Method | Purpose | |--------|---------| | `PutStream(data)` | Publish data to `stream.{runID}.{stepID}` via core NATS (ephemeral, fire-and-forget) | | `Heartbeat()` | Extend AckWait timer to prevent redelivery during long work | ### Checkpointing | Method | Purpose | |--------|---------| | `Checkpoint(state)` | Save arbitrary state to KV at `{runID}.{stepID}` | | `LoadCheckpoint()` | Retrieve saved state (returns `nil, nil` if none exists) | | `Pause(name, d)` | Checkpoint + NakWithDelay for mid-task durable delay | ### Signals | Method | Purpose | |--------|---------| | `WaitForSignal(name, timeout)` | Block until KV key `{runID}.{name}` is written (max 1 hour) | | `SendSignal(runID, name, data)` | Write to KV to wake a waiting step | ## Graceful Shutdown `Stop()` unsubscribes all JetStream consumers, stops the heartbeat goroutine, and deregisters from the worker directory KV bucket. In-flight tasks that have not yet called a terminal method will be redelivered by JetStream's `MaxDeliver` policy after `AckWait` expires. ```go w.Start() defer w.Stop() // Block until shutdown signal sig := make(chan os.Signal, 1) signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) <-sig ``` ## Related pages - [Steps](/docs/concepts/steps) -- what workers execute - [Runs](/docs/concepts/runs) -- the execution context workers operate within - [Events and Event Sourcing](/docs/concepts/events-and-event-sourcing) -- what happens after a worker completes --- # Source: docs/site/content/docs/concepts/workflows-and-dags.md A **workflow** is a directed acyclic graph (DAG) of steps that DagNats executes with dependency-aware scheduling. ## WorkflowDef Every workflow starts as a `WorkflowDef` -- an immutable schema stored in the `workflow_defs` KV bucket. A definition declares the graph topology (steps and their dependencies), version string, and optional constraints like concurrency limits and timeouts. Once stored, a definition can be executed many times as independent [runs](/docs/concepts/runs). Key fields on `WorkflowDef`: | Field | Type | Purpose | |-------|------|---------| | `Name` | `string` | Unique identifier for the workflow | | `Version` | `string` | Schema version (defaults to `"1"`) | | `Steps` | `[]StepDef` | Ordered list of step definitions | | `Concurrency` | `*ConcurrencyLimit` | Max parallel runs and steps | | `Timeout` | `time.Duration` | Workflow-level deadline | | `Sticky` | `StickyStrategy` | Worker affinity (`soft` or `hard`) | | `Singleton` | `*SingletonConfig` | One-at-a-time constraint | ## DAG Structure Steps form a DAG through their `DependsOn` fields. A step with no dependencies starts immediately when the run begins. A step with dependencies waits until all listed predecessors complete. The engine resolves the graph iteratively -- steps at the same depth execute in parallel. ```mermaid graph LR A[fetch-data] --> B[transform] A --> C[validate] B --> D[publish] C --> D ``` In this graph, `fetch-data` runs first. Once it completes, `transform` and `validate` run in parallel. `publish` waits for both to finish. ## Builder API `dag.NewWorkflow()` returns a fluent builder for constructing workflow definitions. The builder returns `StepRef` handles for compile-time-safe dependency wiring -- no string typos, no silent miswiring. ```go wf := dag.NewWorkflow("data-pipeline").Version("2") fetch := wf.Task("fetch-data", "fetch") transform := wf.Task("transform", "transform").After(fetch) validate := wf.Task("validate", "validate").After(fetch) publish := wf.Task("publish", "publish").After(transform, validate). WithTimeout(30 * time.Second). WithRetries(3) def, err := wf.Build() ``` `Build()` assembles the `WorkflowDef` and calls `Validate()` internally. Any structural error -- cycles, missing dependencies, invalid config -- surfaces as a clean error value rather than a runtime panic. ### Workflow-level options Chain these on the builder before calling `Build()`: | Method | Purpose | |--------|---------| | `Version(v)` | Override the default `"1"` version string | | `WithConcurrency(maxRuns, maxSteps)` | Bound parallel runs and in-flight steps | | `WithSticky(strategy)` | Worker affinity (`soft` prefers, `hard` requires) | | `WithIdempotencyKey(dotPath)` | Dedup runs by input field | | `WithSingleton(mode)` | One-at-a-time (`skip` or `cancel` duplicates) | | `WithPriority(cfg)` | Priority-based run ordering | | `CancelOn(event, match)` | Cancel on external event | ## Validation `dag.Validate()` checks a `WorkflowDef` for structural correctness before any run is created. Catching errors at definition time defines them out of existence at runtime -- the engine safely assumes every definition it receives has already passed validation. Validate checks for: - **Unique step IDs** -- no duplicates - **Valid dependencies** -- every `DependsOn` reference points to an existing step - **No cycles** -- uses iterative Kahn's algorithm (no recursion) - **Type-specific config** -- AgentLoop has `MaxIterations > 0`, Map has exactly one dependency, etc. - **Concurrency bounds** -- `MaxSteps` in `[0, 1000]` - **Aux target constraints** -- OnFailure/Compensate targets have no dependencies and don't self-reference ## Versioning The `Version` field on `WorkflowDef` enables schema evolution. Existing runs continue executing against the definition version they were started with. New runs pick up the latest version stored in KV. The `workflow_defs` KV bucket preserves revision history, so you can inspect previous versions. ## Related pages - [Steps](/docs/concepts/steps) -- what goes inside a workflow - [Runs](/docs/concepts/runs) -- executing a workflow definition - [Step Types](/docs/step-types/) -- detailed reference for each step type --- # Source: docs/site/content/docs/coordination/_index.md {{< cards cols="2" >}} {{< card link="checkpoints" title="Checkpoints" >}} {{< card link="signals" title="Signals" >}} {{< card link="streaming" title="Streaming" >}} {{< /cards >}} --- # Source: docs/site/content/docs/coordination/checkpoints.md Checkpoints persist arbitrary handler state to NATS KV, enabling recovery across retries, redeliveries, and agent loop iterations. ## Overview When a step handler crashes, times out, or calls `Continue()` in an agent loop, the engine re-enqueues the task. Without checkpoints, the handler restarts from scratch every time. The **checkpoints** KV bucket gives handlers a key-value store to save and restore state at `{runID}.{stepID}`. Checkpoints are especially important for LLM agent loops where conversation history accumulates over many iterations. Losing that history means replaying expensive API calls. With `Checkpoint()` and `LoadCheckpoint()`, the handler saves its conversation state after each LLM call and restores it on the next iteration or retry. ## API ### Checkpoint Saves state to KV. Overwrites any previous value for this step. ```go w.Handle("summarize", func(ctx worker.TaskContext) error { partial, _ := ctx.LoadCheckpoint() result, err := processChunk(ctx.Input(), partial) if err != nil { return ctx.Fail(err) } if err := ctx.Checkpoint(result.State); err != nil { return ctx.Fail(err) } return ctx.Complete(result.Output) }) ``` ### LoadCheckpoint Retrieves the last saved state. Returns `(nil, nil)` if no checkpoint exists or the KV bucket is not configured. ```go w.Handle("agent-step", func(ctx worker.TaskContext) error { state, err := ctx.LoadCheckpoint() if err != nil { return ctx.Fail(err) } if state == nil { state = ctx.Input() // first execution } // ... continue from saved state }) ``` ### Pause and Resume `Pause()` combines a checkpoint with a NAK-with-delay, creating a clean pause/resume pattern without involving the engine. The step stays in `Running` status while the NATS message is redelivered after the delay. ```go w.Handle("rate-limited", func(ctx worker.TaskContext) error { state, _ := ctx.LoadCheckpoint() if isPauseResume(state) { state = extractResumeState(state) } result, err := callAPI(state) if err != nil && isRateLimited(err) { return ctx.Pause("rate-limit", 30*time.Second) } return ctx.Complete(result) }) ``` Under the hood, `Pause()` writes a JSON checkpoint with a `{"pause_resume": "name"}` marker, then calls `msg.NakWithDelay(duration)`. On the next delivery, `LoadCheckpoint()` returns the marker so the handler knows it is resuming from a pause. ## Persistence Across Retries When a step fails and the engine retries it (via [retry policy](/docs/reliability/retry-policies)), the checkpoint from the previous attempt is still in KV. The new attempt can load it and skip already-completed work: ```mermaid sequenceDiagram participant W1 as Attempt 1 participant KV as checkpoints KV participant W2 as Attempt 2 W1->>KV: Checkpoint(partial_result) W1->>W1: crashes Note over W1,W2: engine retries after backoff W2->>KV: LoadCheckpoint() KV-->>W2: partial_result W2->>W2: resumes from partial_result ``` This is critical for idempotent processing. If a step processes 100 items and crashes at item 73, the retry can checkpoint the list of completed items and skip them. ## Storage Details | Property | Value | |----------|-------| | KV bucket | `checkpoints` | | Key format | `{runID}.{stepID}` | | Value | arbitrary bytes (handler-defined) | | TTL | configured on the bucket (recommended: match run retention) | | Max value size | NATS KV default (typically 1MB) | The `checkpoints` bucket is optional. If it was not created during `natsutil.SetupAll`, `Checkpoint()` returns an error and `LoadCheckpoint()` returns `(nil, nil)`. This means handlers that do not need checkpoints work without any KV setup. ## LLM Pattern: Checkpointing Conversation History An agent loop handler saves the full conversation after each LLM call: ```go w.Handle("llm-agent", func(ctx worker.TaskContext) error { var messages []Message saved, _ := ctx.LoadCheckpoint() if saved != nil { json.Unmarshal(saved, &messages) } else { messages = []Message{{Role: "user", Content: string(ctx.Input())}} } response, err := callLLM(messages) if err != nil { return ctx.Fail(err) } messages = append(messages, response.Message) data, _ := json.Marshal(messages) ctx.Checkpoint(data) if response.Done { return ctx.Complete([]byte(response.FinalAnswer)) } return ctx.Continue(nil) }) ``` Each iteration adds to the conversation. If the worker crashes mid-iteration, the retry loads the last checkpoint and re-does only the current LLM call, not the entire conversation. ## Related - [Signals](/docs/coordination/signals) -- cross-step communication via KV watches - [Streaming](/docs/coordination/streaming) -- real-time output during execution - [Agent Loops](/docs/step-types/agent-loops) -- iterative steps that rely on checkpoints - [Retry Policies](/docs/reliability/retry-policies) -- when checkpoints matter most --- # Source: docs/site/content/docs/coordination/signals.md Signals enable real-time communication between steps or across workflow runs using NATS KV watches. ## Overview Steps sometimes need to wait for input that arrives asynchronously -- a human approval, another step's side effect, or an event from an external system. The **signals** KV bucket provides a publish/subscribe mechanism scoped to workflow runs. A sender writes a value to a KV key, and a receiver watches that key. The moment the value appears, the watcher unblocks. Unlike [Wait for Event](/docs/step-types/wait-for-event) steps (which are a step type), signals operate **within** a running step's handler. A step calls `WaitForSignal()` and blocks in-process until the signal arrives or the timeout fires. Another step (in the same run or a different one) calls `SendSignal()` to write the value. The NATS KV watch delivers the update immediately. Signals are keyed as `{runID}.{name}` in the `signals` KV bucket. The run ID scoping means you can use the same signal name across different runs without collision. ## Signal Flow ```mermaid sequenceDiagram participant StepA as Step A (sender) participant KV as signals KV participant StepB as Step B (receiver) StepB->>KV: Watch({runID}.approval) Note over StepB: blocks until value appears StepA->>KV: Put({runID}.approval, data) KV-->>StepB: update notification StepB->>StepB: unblocks, returns data ``` ## API ### WaitForSignal Blocks the current step until a named signal is written or the timeout expires. ```go w.Handle("wait-for-review", func(ctx worker.TaskContext) error { data, err := ctx.WaitForSignal("review-done", 30*time.Minute) if err != nil { return ctx.Fail(err) // timeout or KV error } return ctx.Complete(data) }) ``` The timeout is capped at **1 hour** by the implementation. If the timeout expires before a signal arrives, `WaitForSignal` returns an error with the message `signal "name" timed out after duration`. **While waiting**, the step's NATS message remains in-flight. For long waits, call `Heartbeat()` periodically to prevent AckWait redelivery, or use `Pause()` to NAK with delay and resume on the next delivery. ### SendSignal Writes data to a signal key, waking any watcher on that key. ```go w.Handle("submit-review", func(ctx worker.TaskContext) error { feedback := []byte(`{"approved": true, "comment": "LGTM"}`) err := ctx.SendSignal(ctx.RunID(), "review-done", feedback) if err != nil { return ctx.Fail(err) } return ctx.Complete(feedback) }) ``` `SendSignal` accepts an explicit `runID` parameter, enabling **cross-run communication**. A step in run A can send a signal to run B if it knows run B's ID. ### Cross-Step Example A two-step workflow where one step blocks until the other signals: ```go wf := dag.NewWorkflow("feedback-loop") process := wf.Task("process", "process-data") review := wf.Task("review", "wait-for-review") def, _ := wf.Build() ``` The steps run concurrently (no dependency edge). The `wait-for-review` handler blocks on `WaitForSignal`, and the `process-data` handler calls `SendSignal` when it needs the reviewer to unblock. ## Timeout Behavior | Condition | Result | |-----------|--------| | Signal arrives before timeout | Returns `(data, nil)` | | Timeout expires first | Returns `(nil, error)` with timeout message | | Signal KV not configured | Returns `(nil, error)` immediately | | Context cancelled | Depends on KV watcher; may return update or block until timeout | The maximum allowed timeout is **1 hour**. Passing a value outside `(0, 1h]` causes a panic -- this is a programmer error, not a runtime condition. ## LLM Pattern: Injecting User Feedback into Running Agents An [agent loop](/docs/step-types/agent-loops) can pause mid-execution and wait for human input before continuing: ```go w.Handle("llm-agent", func(ctx worker.TaskContext) error { history, _ := ctx.LoadCheckpoint() result, _ := callLLM(history) if result.NeedsFeedback { ctx.Checkpoint(result.FullHistory) ctx.PutStream([]byte("Waiting for user feedback...")) feedback, err := ctx.WaitForSignal("user-feedback", 30*time.Minute) if err != nil { return ctx.Fail(err) } result.FullHistory = appendFeedback(result.FullHistory, feedback) ctx.Checkpoint(result.FullHistory) return ctx.Continue(feedback) } return ctx.Complete(result.Summary) }) ``` An external system (CLI, API, UI) sends the feedback via the API, which internally calls `SendSignal(runID, "user-feedback", data)`. ## Related - [Checkpoints](/docs/coordination/checkpoints) -- persisting state across retries and iterations - [Streaming](/docs/coordination/streaming) -- real-time output to clients - [Approval Gates](/docs/step-types/approval-gates) -- signal-based human approval as a step type - [Wait for Event](/docs/step-types/wait-for-event) -- engine-level event correlation --- # Source: docs/site/content/docs/coordination/streaming.md Streaming publishes real-time data from a running step to any subscribed client using core NATS pub/sub. ## Overview Some workflows produce output incrementally -- LLM token generation, log lines, progress updates. Waiting for `Complete()` to see any output is not acceptable for these use cases. **PutStream** lets a handler publish data mid-execution on a subject that clients can subscribe to for live delivery. Streaming uses core NATS publish (not JetStream). Messages are ephemeral, fire-and-forget. If no subscriber is listening, the data is lost. This is intentional: streaming is for real-time observation, not durable state. For durable output, use `Complete()` or [Checkpoints](/docs/coordination/checkpoints). The subject format is `stream.{runID}.{stepID}`. Any NATS client can subscribe to this subject to receive live data from a specific step, or use a wildcard like `stream.{runID}.>` to receive all streaming output from a run. ## API ### PutStream Publishes data to the step's streaming subject. ```go w.Handle("generate-text", func(ctx worker.TaskContext) error { for i, chunk := range generateChunks(ctx.Input()) { ctx.PutStream(chunk) if i%10 == 0 { ctx.Heartbeat() } } return ctx.Complete(assembleResult()) }) ``` `PutStream` publishes to `stream.{runID}.{stepID}` via `nc.Publish` -- a plain NATS core publish. There is no ack, no persistence, no backpressure. If the publish buffer is full, it returns an error. ### Subscribing to Streams Clients subscribe using a standard NATS subscription: ```go sub, _ := nc.Subscribe( fmt.Sprintf("stream.%s.%s", runID, stepID), func(msg *nats.Msg) { fmt.Print(string(msg.Data)) }, ) defer sub.Unsubscribe() ``` Wildcard subscription for all steps in a run: ```go sub, _ := nc.Subscribe( fmt.Sprintf("stream.%s.>", runID), func(msg *nats.Msg) { // msg.Subject contains the full stream.{runID}.{stepID} fmt.Printf("[%s] %s\n", msg.Subject, msg.Data) }, ) ``` ### CLI: Tailing Logs The `dagnats logs --tail` command subscribes to the streaming subject for a run and prints output as it arrives: ```bash dagnats logs --tail dagnats logs --tail --step ``` ## Streaming vs. Durable Output | Aspect | PutStream | Complete | |--------|-----------|----------| | Delivery | Fire-and-forget | Durable (JetStream) | | Persistence | None | Stored in run history | | Backpressure | None | Ack-based | | Use case | Real-time observation | Final result | | Subscriber required | Yes, or data is lost | No | Use both together: stream tokens as they arrive for live UX, then call `Complete()` with the assembled final output for durable storage. ## Heartbeat During Streaming Long-running steps that stream data should call `Heartbeat()` periodically to prevent NATS message redelivery. The AckWait timer on the task message resets with each heartbeat: ```go w.Handle("long-stream", func(ctx worker.TaskContext) error { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for chunk := range processLargeInput(ctx.Input()) { ctx.PutStream(chunk) select { case <-ticker.C: ctx.Heartbeat() default: } } return ctx.Complete([]byte("done")) }) ``` ## LLM Pattern: Streaming Token Output to Clients An LLM handler streams tokens as they arrive from the model API, giving the end user immediate feedback: ```go w.Handle("llm-generate", func(ctx worker.TaskContext) error { var fullResponse strings.Builder stream, err := openLLMStream(ctx.Input()) if err != nil { return ctx.Fail(err) } count := 0 for token := range stream.Tokens() { ctx.PutStream([]byte(token)) fullResponse.WriteString(token) count++ if count%50 == 0 { ctx.Heartbeat() } } return ctx.Complete([]byte(fullResponse.String())) }) ``` A frontend subscribes to `stream.{runID}.{stepID}` and renders tokens as they arrive. The final assembled response is stored durably via `Complete()`. ## Related - [Checkpoints](/docs/coordination/checkpoints) -- durable state persistence - [Signals](/docs/coordination/signals) -- cross-step coordination - [Agent Loops](/docs/step-types/agent-loops) -- iterative steps that benefit from streaming --- # Source: docs/site/content/docs/examples/_index.md {{< cards cols="3" >}} {{< card link="agent-loop" title="Agent Loop" >}} {{< card link="cron-trigger" title="Cron Trigger" >}} {{< card link="hello-world" title="Hello World" >}} {{< card link="map-step" title="Map Step" >}} {{< card link="retry-errors" title="Retry and Error Handling" >}} {{< card link="signals" title="Signals" >}} {{< card link="sub-workflow" title="Sub-Workflow" >}} {{< /cards >}} --- # Source: docs/site/content/docs/examples/agent-loop.md A counter that increments through checkpointed iterations, demonstrating the agent loop pattern with `Continue()` and `Complete()` semantics. ## Workflow Definition The workflow has a single step with `type: "agent_loop"`. The `loop` configuration sets the maximum number of iterations and the delay between each iteration (in nanoseconds -- 1 second here). ```json { "name": "agent-loop", "version": "1.0", "steps": [ { "id": "counter", "task": "counter", "type": "agent_loop", // enables iterative execution "depends_on": [], "loop": { "max_iterations": 10, // safety bound: never run more than 10 times "loop_delay": 1000000000 // 1 second between iterations (nanoseconds) } } ] } ``` ## Worker Implementation The handler loads its checkpoint (previous state) on each iteration, increments the counter, saves the checkpoint, and decides whether to continue or complete. This pattern is the foundation for LLM agent loops that iterate until a goal is met. ```go package main import ( "encoding/json" "fmt" "os" "os/signal" "github.com/danmestas/dagnats/worker" "github.com/nats-io/nats.go" ) func main() { url := os.Getenv("NATS_URL") if url == "" { url = nats.DefaultURL } nc, err := nats.Connect(url) if err != nil { fmt.Fprintf(os.Stderr, "connect: %v\n", err) os.Exit(1) } defer nc.Close() w := worker.NewWorker(nc, nil) w.Handle("counter", handleCounter) fmt.Println("Worker ready. Waiting for tasks...") w.Start() sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt) <-sig fmt.Println("\nShutting down...") w.Stop() } // counterState is the checkpoint payload for the counter loop. type counterState struct { Count int `json:"count"` } const counterTarget = 5 // handleCounter loads the checkpoint, increments the counter, // saves the checkpoint, and either continues or completes. func handleCounter(ctx worker.TaskContext) error { state := loadCounter(ctx) state.Count++ fmt.Printf( "[counter] iteration %d / %d\n", state.Count, counterTarget, ) data, err := json.Marshal(state) if err != nil { return fmt.Errorf("marshal checkpoint: %w", err) } // Persist state to NATS KV so it survives restarts. if err := ctx.Checkpoint(data); err != nil { return fmt.Errorf("save checkpoint: %w", err) } // Decision point: are we done? if state.Count >= counterTarget { fmt.Println("[counter] target reached, completing") return ctx.Complete(data) // finish the step } return ctx.Continue(data) // request another iteration } // loadCounter reads the checkpoint from KV. Returns a zero-value // counterState if no checkpoint exists yet. func loadCounter(ctx worker.TaskContext) counterState { raw, err := ctx.LoadCheckpoint() if err != nil || raw == nil { return counterState{} } var state counterState if err := json.Unmarshal(raw, &state); err != nil { return counterState{} } return state } ``` ## Running the Example 1. Start the DagNats server: ```bash dagnats serve ``` 2. In a second terminal, start the worker: ```bash go run ./examples/agent-loop/ ``` 3. In a third terminal, register and run: ```bash dagnats workflow register examples/agent-loop/workflow.json dagnats run start agent-loop '{}' ``` 4. Watch the worker iterate: ``` [counter] iteration 1 / 5 [counter] iteration 2 / 5 [counter] iteration 3 / 5 [counter] iteration 4 / 5 [counter] iteration 5 / 5 [counter] target reached, completing ``` ## What's Happening 1. The engine dispatches the `counter` task for the first time. No checkpoint exists, so the counter starts at 0. 2. The handler increments to 1, saves a checkpoint `{"count":1}` to NATS KV, and calls `ctx.Continue(data)`. 3. `Continue()` tells the engine to re-dispatch the same step after the configured `loop_delay` (1 second). 4. On each subsequent iteration, `LoadCheckpoint()` restores the previous state. The handler increments, saves, and continues. 5. When the counter reaches 5, the handler calls `ctx.Complete(data)` instead, which marks the step as finished. 6. The `max_iterations` bound (10) acts as a safety net -- if the handler never calls `Complete()`, the engine stops it after 10 iterations. Key concepts demonstrated: - **`Continue()` vs `Complete()`** -- the handler controls the loop by choosing which to call. - **Checkpoints** persist state across iterations in NATS KV. If the worker crashes mid-loop, it resumes from the last checkpoint. - **`max_iterations`** provides a hard upper bound, preventing runaway loops. - **`loop_delay`** adds backoff between iterations, useful for rate-limiting API calls in LLM agent patterns. ## Related - [Agent Loops](/docs/step-types/agent-loops) -- step type reference - [Agent Loop Pattern](/docs/ai-patterns/agent-loop-pattern) -- design pattern for LLM agents --- # Source: docs/site/content/docs/examples/cron-trigger.md A minimal single-step workflow designed to run on a cron schedule, demonstrating how to set up time-based workflow triggers. ## Workflow Definition The workflow itself is simple -- a single `tick` step that logs the current time. The cron schedule is configured separately when registering the workflow, not in the workflow JSON. ```json { "name": "cron-trigger", "version": "1.0", "steps": [ { "id": "tick", "task": "tick", "type": "normal", "depends_on": [] } ] } ``` ## Worker Implementation The handler prints the current UTC timestamp and completes. Each cron trigger creates a new workflow run, so the handler runs fresh each time. ```go package main import ( "fmt" "os" "os/signal" "time" "github.com/danmestas/dagnats/worker" "github.com/nats-io/nats.go" ) func main() { url := os.Getenv("NATS_URL") if url == "" { url = nats.DefaultURL } nc, err := nats.Connect(url) if err != nil { fmt.Fprintf(os.Stderr, "connect: %v\n", err) os.Exit(1) } defer nc.Close() w := worker.NewWorker(nc, nil) w.Handle("tick", handleTick) fmt.Println("Worker ready. Waiting for tasks...") w.Start() sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt) <-sig fmt.Println("\nShutting down...") w.Stop() } // handleTick prints the current time and completes. // Each cron invocation creates a fresh workflow run. func handleTick(ctx worker.TaskContext) error { now := time.Now().UTC().Format(time.RFC3339) fmt.Printf("[tick] %s\n", now) return ctx.Complete([]byte(now)) } ``` ## Running the Example 1. Start the DagNats server: ```bash dagnats serve ``` 2. In a second terminal, start the worker: ```bash go run ./examples/cron-trigger/ ``` 3. In a third terminal, register the workflow and add a cron schedule: ```bash dagnats workflow register examples/cron-trigger/workflow.json dagnats cron add cron-trigger "*/1 * * * *" '{}' ``` This schedules the workflow to run every minute with an empty JSON input. 4. Watch the worker print timestamps every minute: ``` [tick] 2025-01-15T10:00:00Z [tick] 2025-01-15T10:01:00Z [tick] 2025-01-15T10:02:00Z ``` 5. Remove the schedule when done: ```bash dagnats cron remove cron-trigger ``` ## What's Happening 1. The `dagnats cron add` command registers a cron expression with the DagNats server. 2. The server's cron scheduler evaluates the expression and starts a new workflow run at each matching time. 3. Each run is independent -- the engine creates a fresh run, dispatches the `tick` step, and the handler executes. 4. The workflow definition stays simple. The scheduling concern is separate from the workflow logic. This separation means any workflow can be cron-triggered without modification. You can add a cron schedule to the hello-world example, the retry-errors example, or any other workflow. Key concepts demonstrated: - **Cron schedules** are an operational concern, configured outside the workflow definition. - **Each trigger creates a new run** -- there is no shared state between cron invocations. - **Standard cron expressions** -- uses the familiar `* * * * *` format (minute, hour, day, month, weekday). ## Related - [Cron Schedules](/docs/triggers/cron-schedules) -- cron configuration and expression reference --- # Source: docs/site/content/docs/examples/hello-world.md A minimal two-step DAG that greets a user by name and uppercases the result, demonstrating typed handlers and JSON I/O. ## Workflow Definition The workflow declares two steps in sequence: `greet` produces a greeting string, and `uppercase` transforms it. The `depends_on` field creates the edge in the DAG. ```json { "name": "hello-world", "version": "1.0", "steps": [ { // First step: no dependencies, runs immediately. "id": "greet", "task": "greet", // matches the handler name registered in the worker "depends_on": [] }, { // Second step: waits for "greet" to complete. // Its input is the JSON output of the greet step. "id": "uppercase", "task": "uppercase", "depends_on": ["greet"] } ] } ``` ## Worker Implementation The worker connects to NATS and registers two typed handlers. `HandleTyped` automatically deserializes the task input and serializes the return value as JSON, so handlers work with native Go types instead of raw bytes. ```go package main import ( "fmt" "os" "os/signal" "strings" "github.com/danmestas/dagnats/worker" "github.com/nats-io/nats.go" ) func main() { // Connect to NATS. Falls back to localhost:4222. url := os.Getenv("NATS_URL") if url == "" { url = nats.DefaultURL } nc, err := nats.Connect(url) if err != nil { fmt.Fprintf(os.Stderr, "connect: %v\n", err) os.Exit(1) } defer nc.Close() w := worker.NewWorker(nc, nil) // Step 1: produce a greeting from the input name. // HandleTyped deserializes the JSON input into a string // and serializes the returned string back to JSON. worker.HandleTyped(w, "greet", func( ctx worker.TaskContext, name string, ) (string, error) { if name == "" { name = "World" } greeting := fmt.Sprintf("Hello, %s!", name) fmt.Printf("[greet] %s\n", greeting) return greeting, nil }, ) // Step 2: uppercase the greeting from step 1. // The input string is the JSON output of the greet handler. worker.HandleTyped(w, "uppercase", func( ctx worker.TaskContext, input string, ) (string, error) { result := strings.ToUpper(input) fmt.Printf("[uppercase] %s\n", result) return result, nil }, ) fmt.Println("Worker ready. Waiting for tasks...") w.Start() // Block until Ctrl-C, then gracefully shut down. sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt) <-sig fmt.Println("\nShutting down...") w.Stop() } ``` ## Running the Example 1. Start the DagNats server: ```bash dagnats serve ``` 2. In a second terminal, start the worker: ```bash go run ./examples/hello-world/ ``` 3. In a third terminal, register and run the workflow: ```bash dagnats workflow register examples/hello-world/workflow.json dagnats run start hello-world '"Alice"' ``` 4. The worker output shows the execution: ``` [greet] Hello, Alice! [uppercase] HELLO, ALICE! ``` ## What's Happening 1. The engine parses the DAG and finds `greet` has no dependencies, so it dispatches it immediately. 2. The `greet` handler receives `"Alice"` as its typed input, produces `"Hello, Alice!"`, and returns it as the step output. 3. The engine sees `uppercase` depends on `greet`. Now that `greet` is complete, it dispatches `uppercase` with the greet output as input. 4. The `uppercase` handler transforms the string and completes. With all steps done, the workflow run finishes. Key concepts demonstrated: - **`HandleTyped`** removes all JSON boilerplate. You define Go function signatures and DagNats handles serialization. - **`depends_on`** creates edges in the DAG. Step outputs flow automatically to downstream step inputs. - **`worker.NewWorker`** + `w.Start()` subscribes to NATS subjects for each registered task. The worker pulls tasks from a durable JetStream consumer. ## Related - [Workflow Definitions](/docs/getting-started/workflow-definitions) -- how to write workflow JSON - [Writing Workers](/docs/getting-started/writing-workers) -- worker patterns and `HandleTyped` --- # Source: docs/site/content/docs/examples/map-step.md A three-step image pipeline that fans out over an array of URLs in parallel, demonstrating the Map step for fan-out/fan-in execution. ## Workflow Definition The `resize` step has `type: "map"`, which tells the engine to split its input array into individual elements, process each in parallel, and collect the results back into an array for the next step. ```json { "name": "image-pipeline", "version": "1.0", "steps": [ { // Step 1: produce a JSON array of image URLs. "id": "fetch-urls", "task": "fetch-urls", "type": "normal" }, { // Step 2: Map step -- runs once per element in the array. // The engine fans out automatically. "id": "resize", "task": "resize-image", "type": "map", "depends_on": ["fetch-urls"] }, { // Step 3: receives all Map outputs collected into an array. "id": "build-gallery", "task": "build-gallery", "type": "normal", "depends_on": ["resize"] } ] } ``` ## Worker Implementation Each handler is straightforward. The Map step handler processes a single element -- the engine handles the fan-out and fan-in. The handler does not need to know it is running inside a Map step. ```go package main import ( "encoding/json" "fmt" "os" "os/signal" "strings" "github.com/danmestas/dagnats/worker" "github.com/nats-io/nats.go" ) func main() { url := os.Getenv("NATS_URL") if url == "" { url = nats.DefaultURL } nc, err := nats.Connect(url) if err != nil { fmt.Fprintf(os.Stderr, "connect: %v\n", err) os.Exit(1) } defer nc.Close() w := worker.NewWorker(nc, nil) // Step 1: Return a list of image URLs. // The output must be a JSON array for the Map step to split. worker.HandleTyped(w, "fetch-urls", func( ctx worker.TaskContext, input json.RawMessage, ) ([]string, error) { urls := []string{ "img/hero.png", "img/logo.png", "img/banner.png", } fmt.Printf("[fetch-urls] returning %d URLs\n", len(urls)) return urls, nil }, ) // Step 2: Process ONE image (called once per array element). // The engine fans out -- this handler receives a single string, // not the full array. Multiple instances run in parallel. worker.HandleTyped(w, "resize-image", func( ctx worker.TaskContext, imageURL string, ) (string, error) { resized := strings.Replace( imageURL, ".png", "_thumb.png", 1, ) fmt.Printf("[resize] %s -> %s\n", imageURL, resized) return resized, nil }, ) // Step 3: Collect all resized URLs into a gallery. // The engine fans in -- input is an array of all Map outputs. worker.HandleTyped(w, "build-gallery", func( ctx worker.TaskContext, thumbnails []string, ) (string, error) { fmt.Printf("[gallery] building from %d thumbnails\n", len(thumbnails)) return fmt.Sprintf( "gallery with %d images", len(thumbnails), ), nil }, ) fmt.Println("Map-step worker ready. Waiting for tasks...") w.Start() sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt) <-sig fmt.Println("\nShutting down...") w.Stop() } ``` ## Running the Example 1. Start the DagNats server: ```bash dagnats serve ``` 2. In a second terminal, start the worker: ```bash go run ./examples/map-step/ ``` 3. In a third terminal, register and run: ```bash dagnats workflow register examples/map-step/workflow.json dagnats run start image-pipeline '["img1.png","img2.png","img3.png"]' ``` 4. Watch the parallel fan-out: ``` [fetch-urls] returning 3 URLs [resize] img/hero.png -> img/hero_thumb.png [resize] img/logo.png -> img/logo_thumb.png [resize] img/banner.png -> img/banner_thumb.png [gallery] building from 3 thumbnails ``` ## What's Happening 1. The engine dispatches `fetch-urls`, which returns a JSON array of three URLs. 2. The engine sees `resize` is a `map` step. It splits the array into three individual tasks and dispatches them in parallel. 3. Each `resize-image` handler receives a single URL string, processes it, and returns the result. These run concurrently across available workers. 4. Once all three map tasks complete, the engine collects their outputs into a JSON array and passes it to `build-gallery`. 5. The `build-gallery` handler receives `["img/hero_thumb.png", "img/logo_thumb.png", "img/banner_thumb.png"]` and produces the final result. Key concepts demonstrated: - **Map step fan-out** -- the engine splits arrays automatically. Handlers process single elements. - **Fan-in collection** -- downstream steps receive the collected array of all Map outputs. - **Parallel execution** -- Map tasks run concurrently, scaling with the number of available workers. - **Handler simplicity** -- the `resize-image` handler has no idea it is part of a Map step. It just processes one item. ## Related - [Map Steps](/docs/step-types/map-steps) -- step type reference and configuration options --- # Source: docs/site/content/docs/examples/retry-errors.md A workflow with a flaky fetch step that demonstrates retry policies with exponential backoff and `on_failure` routing to an error reporter. ## Workflow Definition The `fetch` step configures an exponential retry policy and an `on_failure` fallback. If all retries exhaust, the engine routes to the `report-error` step instead of failing the entire workflow. ```json { "name": "retry-errors", "version": "1.0", "steps": [ { "id": "fetch", "task": "fetch", "type": "normal", "depends_on": [], "retry": { "max_attempts": 3, // try up to 3 times total "strategy": "exponential", // exponential backoff "initial_delay": 1000000000, // 1 second initial delay (nanoseconds) "max_delay": 10000000000, // 10 second cap on delay "multiplier": 2.0 // double the delay each retry }, "on_failure": "report-error" // route here if all retries fail }, { // Happy path: runs after a successful fetch. "id": "process", "task": "process", "type": "normal", "depends_on": ["fetch"] }, { // Error path: runs only if fetch exhausts all retries. // Receives the error details as input. "id": "report-error", "task": "report-error", "type": "normal", "depends_on": [] } ] } ``` ## Worker Implementation The `fetch` handler uses `ctx.RetryCount()` to know which attempt it is on. It deliberately fails the first two attempts to demonstrate the retry mechanism. ```go package main import ( "fmt" "os" "os/signal" "strings" "github.com/danmestas/dagnats/worker" "github.com/nats-io/nats.go" ) func main() { url := os.Getenv("NATS_URL") if url == "" { url = nats.DefaultURL } nc, err := nats.Connect(url) if err != nil { fmt.Fprintf(os.Stderr, "connect: %v\n", err) os.Exit(1) } defer nc.Close() w := worker.NewWorker(nc, nil) w.Handle("fetch", handleFetch) w.Handle("process", handleProcess) w.Handle("report-error", handleReportError) fmt.Println("Worker ready. Waiting for tasks...") w.Start() sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt) <-sig fmt.Println("\nShutting down...") w.Stop() } // handleFetch simulates a flaky HTTP fetch that fails twice // before succeeding. RetryCount is 0-based: 0 and 1 fail, // 2 succeeds. func handleFetch(ctx worker.TaskContext) error { attempt := ctx.RetryCount() fmt.Printf("[fetch] attempt %d\n", attempt) // Simulate transient failures on first two attempts. if attempt < 2 { return fmt.Errorf( "fetch failed (attempt %d)", attempt, ) } // Third attempt succeeds. data := []byte(`{"status":"ok","items":["a","b","c"]}`) fmt.Printf("[fetch] success: %s\n", data) return ctx.Complete(data) } // handleProcess uppercases the fetched data. func handleProcess(ctx worker.TaskContext) error { input := string(ctx.Input()) result := strings.ToUpper(input) fmt.Printf("[process] %s\n", result) return ctx.Complete([]byte(result)) } // handleReportError logs the error from a failed step. // Only called if fetch exhausts all retries. func handleReportError(ctx worker.TaskContext) error { input := string(ctx.Input()) fmt.Printf("[report-error] failure reported: %s\n", input) return ctx.Complete([]byte("error reported")) } ``` ## Running the Example 1. Start the DagNats server: ```bash dagnats serve ``` 2. In a second terminal, start the worker: ```bash go run ./examples/retry-errors/ ``` 3. In a third terminal, register and run: ```bash dagnats workflow register examples/retry-errors/workflow.json dagnats run start retry-errors '{}' ``` 4. Watch the retries and eventual success: ``` [fetch] attempt 0 [fetch] attempt 1 [fetch] attempt 2 [fetch] success: {"status":"ok","items":["a","b","c"]} [process] {"STATUS":"OK","ITEMS":["A","B","C"]} ``` ## What's Happening 1. The engine dispatches `fetch`. The handler returns an error on attempt 0. 2. The engine applies the retry policy: waits 1 second (initial delay), then redispatches. Attempt 1 also fails. 3. The engine waits 2 seconds (1s * 2.0 multiplier), then redispatches. Attempt 2 succeeds. 4. With `fetch` complete, the engine dispatches `process`, which transforms the data. If you change `counterTarget` so that all 3 attempts fail, the engine would route to `report-error` instead of `process`, because of the `on_failure` configuration. The retry mechanism uses NATS `NakWithDelay` under the hood -- there is no separate timer service. The message stays in the JetStream consumer and is redelivered after the computed delay. Key concepts demonstrated: - **`RetryCount()`** -- lets the handler know which attempt it is on (0-based). - **Exponential backoff** -- delays grow as `initial_delay * multiplier^attempt`, capped at `max_delay`. - **`on_failure` routing** -- graceful degradation instead of workflow failure. - **NATS-native retries** -- uses `NakWithDelay` for retry scheduling, no external timer needed. ## Related - [Retry Policies](/docs/reliability/retry-policies) -- full retry configuration reference - [Error Handling](/docs/reliability/error-handling) -- error routing and failure strategies --- # Source: docs/site/content/docs/examples/signals.md A three-step workflow where a preparation step runs in parallel with a signal-waiting step, demonstrating cross-step coordination with `WaitForSignal` and `SendSignal`. ## Workflow Definition The `prepare` and `wait-for-approval` steps both have no dependencies, so they run in parallel. The `finalize` step depends on both and only runs after both complete. ```json { "name": "signals", "version": "1.0", "steps": [ { // Runs immediately: prepares resources. "id": "prepare", "task": "prepare", "type": "normal", "depends_on": [] }, { // Also runs immediately: blocks waiting for an external signal. "id": "wait-for-approval", "task": "wait-for-approval", "type": "normal", "depends_on": [] }, { // Runs only after BOTH prepare and wait-for-approval complete. "id": "finalize", "task": "finalize", "type": "normal", "depends_on": ["prepare", "wait-for-approval"] } ] } ``` ## Worker Implementation The `wait-for-approval` handler calls `ctx.WaitForSignal` to block until an external process sends the named signal or the timeout expires. ```go package main import ( "fmt" "os" "os/signal" "time" "github.com/danmestas/dagnats/worker" "github.com/nats-io/nats.go" ) func main() { url := os.Getenv("NATS_URL") if url == "" { url = nats.DefaultURL } nc, err := nats.Connect(url) if err != nil { fmt.Fprintf(os.Stderr, "connect: %v\n", err) os.Exit(1) } defer nc.Close() w := worker.NewWorker(nc, nil) w.Handle("prepare", handlePrepare) w.Handle("wait-for-approval", handleWaitForApproval) w.Handle("finalize", handleFinalize) fmt.Println("Worker ready. Waiting for tasks...") w.Start() sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt) <-sig fmt.Println("\nShutting down...") w.Stop() } // handlePrepare simulates preparation work and completes. func handlePrepare(ctx worker.TaskContext) error { fmt.Println("[prepare] preparing resources...") result := []byte(`{"prepared":true}`) fmt.Printf("[prepare] done: %s\n", result) return ctx.Complete(result) } // handleWaitForApproval blocks until an external signal arrives // or times out after 5 minutes. func handleWaitForApproval(ctx worker.TaskContext) error { fmt.Println("[wait-for-approval] waiting for signal...") // The signal name "approval" is application-defined. data, err := ctx.WaitForSignal( "approval", 5*time.Minute, ) if err != nil { return fmt.Errorf("signal wait failed: %w", err) } fmt.Printf("[wait-for-approval] received: %s\n", data) return ctx.Complete(data) } // handleFinalize combines outputs from prepare and approval. func handleFinalize(ctx worker.TaskContext) error { input := string(ctx.Input()) result := fmt.Sprintf( "finalized with input: %s", input, ) fmt.Printf("[finalize] %s\n", result) return ctx.Complete([]byte(result)) } ``` ## Running the Example 1. Start the DagNats server: ```bash dagnats serve ``` 2. In a second terminal, start the worker: ```bash go run ./examples/signals/ ``` 3. In a third terminal, register and start the workflow: ```bash dagnats workflow register examples/signals/workflow.json dagnats run start signals '{}' ``` 4. The `prepare` step completes immediately. The `wait-for-approval` step blocks, waiting for a signal. Send it: ```bash dagnats signal send approval '{"approved":true}' ``` 5. Watch the workflow complete: ``` [prepare] preparing resources... [prepare] done: {"prepared":true} [wait-for-approval] waiting for signal... [wait-for-approval] received: {"approved":true} [finalize] finalized with input: ... ``` ## What's Happening 1. The engine dispatches `prepare` and `wait-for-approval` in parallel (neither has dependencies on the other). 2. `prepare` completes immediately with `{"prepared":true}`. 3. `wait-for-approval` calls `WaitForSignal("approval", 5*time.Minute)`, which watches a NATS KV key for the named signal. The handler blocks. 4. An external process (the CLI, another workflow, or an API call) sends the `approval` signal with a JSON payload. 5. `WaitForSignal` returns the signal data. The handler completes with that data as output. 6. Now both dependencies of `finalize` are satisfied. The engine dispatches it with the combined inputs. 7. `finalize` produces the final result and the workflow completes. Key concepts demonstrated: - **`WaitForSignal`** -- blocks a handler until a named signal arrives via NATS KV watch. Includes a timeout for safety. - **Parallel execution** -- steps without mutual dependencies run concurrently. - **External coordination** -- signals allow human approval, webhook callbacks, or cross-workflow communication. - **Bounded waits** -- the 5-minute timeout ensures the handler never blocks forever. ## Related - [Signals](/docs/coordination/signals) -- signal concepts and API reference --- # Source: docs/site/content/docs/examples/sub-workflow.md A parent order pipeline that delegates payment processing to a child workflow, demonstrating sub-workflow steps with input/output mapping. ## Workflow Definitions This example uses two workflow files. The parent workflow validates an order, spawns a child workflow for payment, and sends confirmation when the child completes. ### Parent: order-pipeline ```json { "name": "order-pipeline", "version": "1.0", "steps": [ { // Step 1: validate the incoming order. "id": "validate", "task": "validate-order", "type": "normal" }, { // Step 2: spawn the "payment-flow" child workflow. // type: "sub_workflow" tells the engine to start a new // workflow run and wait for it to complete. "id": "process-payment", "type": "sub_workflow", "config": { "workflow": "payment-flow" // name of the child workflow }, "depends_on": ["validate"] }, { // Step 3: runs after the child workflow completes. // Input is the child workflow's final output. "id": "confirm", "task": "send-confirmation", "type": "normal", "depends_on": ["process-payment"] } ] } ``` ### Child: payment-flow ```json { "name": "payment-flow", "version": "1.0", "steps": [ { "id": "charge", "task": "charge", "type": "normal" } ] } ``` The child workflow is a standalone workflow -- it can be run independently or as a sub-workflow. When used as a sub-workflow, it receives the parent step's input and its output flows back to the parent. ## Worker Implementation A single worker handles tasks for both the parent and child workflows. The `charge` handler runs inside the child workflow context but is registered the same way as any other handler. ```go package main import ( "encoding/json" "fmt" "os" "os/signal" "github.com/danmestas/dagnats/worker" "github.com/nats-io/nats.go" ) // order represents the input payload. type order struct { Item string `json:"item"` Amount int `json:"amount"` } // paymentResult is the child workflow's output. type paymentResult struct { TransactionID string `json:"transaction_id"` Status string `json:"status"` } func main() { url := os.Getenv("NATS_URL") if url == "" { url = nats.DefaultURL } nc, err := nats.Connect(url) if err != nil { fmt.Fprintf(os.Stderr, "connect: %v\n", err) os.Exit(1) } defer nc.Close() w := worker.NewWorker(nc, nil) // Parent workflow step 1: validate the order. worker.HandleTyped(w, "validate-order", func( ctx worker.TaskContext, o order, ) (order, error) { fmt.Printf("[validate] order: %s ($%d)\n", o.Item, o.Amount) if o.Amount <= 0 { return order{}, fmt.Errorf( "invalid amount: %d", o.Amount, ) } return o, nil }, ) // Child workflow step: charge payment. // This runs inside the "payment-flow" sub-workflow. // The handler has no knowledge of the parent workflow. worker.HandleTyped(w, "charge", func( ctx worker.TaskContext, o order, ) (paymentResult, error) { fmt.Printf("[charge] processing $%d for %s\n", o.Amount, o.Item) return paymentResult{ TransactionID: "txn-001", Status: "charged", }, nil }, ) // Parent workflow step 3: send confirmation. // Input is the child workflow's output (paymentResult JSON). worker.HandleTyped(w, "send-confirmation", func( ctx worker.TaskContext, result json.RawMessage, ) (string, error) { fmt.Printf("[confirm] payment complete: %s\n", string(result)) return "confirmation sent", nil }, ) fmt.Println("Sub-workflow worker ready. Waiting for tasks...") w.Start() sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt) <-sig fmt.Println("\nShutting down...") w.Stop() } ``` ## Running the Example 1. Start the DagNats server: ```bash dagnats serve ``` 2. In a second terminal, start the worker: ```bash go run ./examples/sub-workflow/ ``` 3. In a third terminal, register both workflows and start the parent: ```bash dagnats workflow register examples/sub-workflow/workflow.json dagnats workflow register examples/sub-workflow/payment-flow.json dagnats run start order-pipeline '{"item":"widget","amount":42}' ``` 4. Watch the full execution: ``` [validate] order: widget ($42) [charge] processing $42 for widget [confirm] payment complete: {"transaction_id":"txn-001","status":"charged"} ``` ## What's Happening 1. The engine starts the `order-pipeline` workflow and dispatches `validate`. The handler checks that the amount is positive and passes the order through. 2. The engine sees `process-payment` is a `sub_workflow` step. It starts a new `payment-flow` workflow run, passing the validate output as the child's input. 3. Inside the child workflow, the `charge` step runs. It produces a `paymentResult` and completes. 4. The child workflow finishes. The engine passes the child's output back to the parent as the output of the `process-payment` step. 5. The engine dispatches `confirm` with the payment result. The handler logs the confirmation and the parent workflow completes. Key concepts demonstrated: - **`sub_workflow` step type** -- spawns a child workflow and waits for it to complete. - **Input/output mapping** -- the parent's step output becomes the child's input; the child's final output becomes the parent step's output. - **Composability** -- the child workflow (`payment-flow`) is a standalone workflow that can also run independently. - **Single worker** -- one worker process can handle tasks from both parent and child workflows. ## Related - [Sub-Workflows](/docs/step-types/sub-workflows) -- step type reference and configuration --- # Source: docs/site/content/docs/flow-control/_index.md {{< cards cols="2" >}} {{< card link="concurrency-limits" title="Concurrency Limits" >}} {{< card link="conditional-skipping" title="Conditional Skipping" >}} {{< card link="priority" title="Priority" >}} {{< card link="rate-limiting" title="Rate Limiting" >}} {{< /cards >}} --- # Source: docs/site/content/docs/flow-control/concurrency-limits.md Concurrency limits control how many workflow runs and steps execute in parallel, preventing resource exhaustion and upstream overload. ## ConcurrencyLimit Configuration The `ConcurrencyLimit` struct on `WorkflowDef` provides two scopes: | Field | JSON Key | Type | Description | |-------|----------|------|-------------| | **MaxRuns** | `max_runs` | `int` | Max parallel runs of this workflow (0 = unlimited) | | **MaxSteps** | `max_steps` | `int` | Max parallel steps within a single run (0 = unlimited) | ```go wf := dag.NewWorkflow("code-review"). WithConcurrency(3, 2) def, _ := wf.Build() // def.Concurrency.MaxRuns == 3 // def.Concurrency.MaxSteps == 2 ``` ## Per-Workflow Run Limits (MaxRuns) `MaxRuns` caps how many runs of the same workflow execute simultaneously. When a new run starts and the limit is reached, the run remains in `pending` status until an active run completes. ### Implementation Run counts are tracked in the `concurrency_runs` KV bucket using **compare-and-swap (CAS)** operations for atomic updates. The engine increments the counter when a run starts and decrements it when a run reaches a terminal state. ```mermaid flowchart LR NEW[New run] --> CHECK{count < MaxRuns?} CHECK -->|yes| INC[CAS increment] --> RUN[Run starts] CHECK -->|no| WAIT[Stay pending] DONE[Run completes] --> DEC[CAS decrement] DEC --> WAKE[Evaluate pending runs] ``` CAS retries are bounded at **10 attempts** to prevent infinite loops under contention. If all attempts fail, the run stays pending and is re-evaluated on the next engine tick. ### Pending Run Priority When a slot opens, the engine selects the **oldest pending run** (by creation time) to maintain FIFO ordering. The `PriorityConfig` can adjust this ordering -- see [Priority](/docs/flow-control/priority). ## Per-Run Step Limits (MaxSteps) `MaxSteps` limits how many steps within a single run execute concurrently. When the DAG has more ready steps than the limit allows, the engine queues the excess steps and dispatches them as running steps complete. This is enforced in the `enqueueReady` path -- the engine counts in-flight steps and only dispatches up to the remaining budget. ## Per-Task-Type Limits Beyond workflow-level limits, individual steps can cap global concurrency for their task type: ```go callLLM := wf.Task("call-llm", "llm.chat"). WithTimeout(60 * time.Second). WithTaskConcurrency(5) ``` This means at most 5 `llm.chat` tasks run across **all workflows** simultaneously. The counter lives in the `concurrency_tasks` KV bucket. If the limit is reached, the task is retried via `SLEEP_TIMERS` with a 1-second delay. **Bounds:** 1-1000 per scope. ## JSON Schema Example ```json { "name": "deploy-pipeline", "version": "1", "concurrency": { "max_runs": 1, "max_steps": 3 }, "steps": [ { "id": "build", "task": "build", "timeout": "10m", "type": "normal", "max_task_concurrency": 2 } ] } ``` ## Related Pages - [Rate Limiting](/docs/flow-control/rate-limiting) -- request-rate throttling - [Priority](/docs/flow-control/priority) -- ordering pending runs - [Retry Policies](/docs/reliability/retry-policies) -- retry behavior when concurrency-delayed --- # Source: docs/site/content/docs/flow-control/conditional-skipping.md The `skip_if` field on a step definition evaluates a condition against a parent step's output, skipping the step entirely when the condition is true. ## ParentCond The `ParentCond` struct defines the skip condition: | Field | JSON Key | Type | Description | |-------|----------|------|-------------| | **StepID** | `step_id` | `string` | Parent step to evaluate (must be in `depends_on`) | | **Field** | `field` | `string` | Top-level key in the parent's JSON output | | **Op** | `op` | `string` | Comparison operator | | **Value** | `value` | `any` | Value to compare against | ### Supported Operators | Operator | Meaning | |----------|---------| | `==` | Equal | | `!=` | Not equal | | `<` | Less than | | `>` | Greater than | | `<=` | Less than or equal | | `>=` | Greater than or equal | ## Type Coercion The condition evaluator compares values using these rules: - **Numbers:** Both sides parsed as `float64` (JSON default). All six operators supported. - **Strings:** Lexicographic comparison. All six operators supported. - **Booleans:** Only `==` and `!=` are supported. Other operators return false. - **Mismatched types:** Condition evaluates to false (step runs normally). If the parent step has no output or the field is missing, the condition evaluates to **false** and the step executes. ## Builder API Use `SkipIf` with a `StepRef` for compile-time-safe references: ```go wf := dag.NewWorkflow("review-pipeline") lint := wf.Task("lint", "lint.run"). WithTimeout(5 * time.Minute) postResults := wf.Task("post-results", "github.comment"). After(lint). WithTimeout(30 * time.Second). SkipIf(lint, "issues_found", "==", 0) ``` If the `lint` step's output contains `{"issues_found": 0}`, `post-results` is skipped. The `SkipIfOutput` helper constructs the `ParentCond` internally. ## JSON Schema ```json { "id": "post-results", "task": "github.comment", "timeout": "30s", "type": "normal", "depends_on": ["lint"], "skip_if": { "step_id": "lint", "field": "issues_found", "op": "==", "value": 0 } } ``` ## Skip Semantics When a step is skipped: - Its status is set to `skipped` - **Downstream steps treat skipped as completed.** A step waiting on a skipped dependency proceeds normally. The skipped step produces no output, so any downstream `skip_if` referencing its fields evaluates to false. - Skipped steps do not count against [concurrency limits](/docs/flow-control/concurrency-limits). - Skipped steps do not appear in the [dead letter queue](/docs/reliability/dead-letter-queue). ## Validation The workflow validator enforces: 1. `skip_if.step_id` must reference a step in the `depends_on` list (not any arbitrary step) 2. `skip_if.op` must be one of the six valid operators 3. The referenced step must exist in the workflow Violations produce a clear error from `Build()` or `Validate()`. ## Related Pages - [Normal Steps](/docs/step-types/normal-steps) -- the default step type that supports skip_if - [Error Handling](/docs/reliability/error-handling) -- what happens when non-skipped steps fail - [Priority](/docs/flow-control/priority) -- ordering runs when steps are skipped --- # Source: docs/site/content/docs/flow-control/priority.md Priority configuration controls the ordering of workflow runs when [concurrency limits](/docs/flow-control/concurrency-limits) create a backlog, using input-driven rules to promote or demote runs in the queue. ## PriorityConfig The `PriorityConfig` on `WorkflowDef` maps input field values to priority offsets: | Field | JSON Key | Type | Description | |-------|----------|------|-------------| | **Key** | `key` | `string` | Dot-path into run input for value extraction | | **Rules** | `rules` | `map[string]int` | Value-to-offset mapping | | **DefaultOffset** | `default_offset` | `int` | Offset when key is missing or value has no rule | ```go wf := dag.NewWorkflow("deploy"). WithConcurrency(1, 0). WithPriority(dag.PriorityConfig{ Key: "environment", Rules: map[string]int{ "production": 300, "staging": 100, "dev": -100, }, DefaultOffset: 0, }) ``` ## How Priority Works Priority is implemented as a **time offset** on the run's creation timestamp. Higher offsets make a run appear "older" to the scheduler, causing it to be selected first when a concurrency slot opens. The engine computes the effective queue position with: ``` effective_time = created_at - (priority_offset * 1 second) ``` A run with offset `300` appears 5 minutes older than its actual creation time. When the engine selects the oldest pending run to fill an open slot, higher-priority runs win. ### Offset Bounds Priority offsets are **clamped to [-600, 600]** (plus or minus 10 minutes). This prevents extreme offsets from starving lower-priority runs indefinitely. ## Resolution `ResolvePriority` computes the offset from the run's input data: 1. If no `PriorityConfig` is set, offset is 0 2. If `Key` is empty, `DefaultOffset` is used 3. Otherwise, extract the value at `Key` using dot-path, look up in `Rules` 4. If the value has no matching rule, `DefaultOffset` is used The resolved offset is stored on `WorkflowRun.PriorityOffset` and used by `EffectiveTime()` for queue ordering. ## JSON Schema Example ```json { "name": "deploy-pipeline", "version": "1", "concurrency": {"max_runs": 1}, "priority": { "key": "environment", "rules": { "production": 300, "staging": 100 }, "default_offset": 0 }, "steps": [ { "id": "deploy", "task": "deploy", "timeout": "10m", "type": "normal" } ] } ``` With `max_runs: 1`, only one deploy runs at a time. If a production deploy and a staging deploy are both pending, the production deploy (offset 300) is selected first. ## NATS Consumer Priority At the NATS transport layer, task message ordering is determined by JetStream consumer configuration. DagNats uses pull consumers with `AckWait`-based timeout, and the engine controls dispatch ordering through the `EffectiveTime()` calculation rather than NATS-level priority. This keeps priority logic in the application layer where it can be customized per-workflow. ## Related Pages - [Concurrency Limits](/docs/flow-control/concurrency-limits) -- the limits that create backlogs - [Rate Limiting](/docs/flow-control/rate-limiting) -- throttling dispatch frequency - [CLI and API](/docs/triggers/cli-and-api) -- starting runs with input that drives priority --- # Source: docs/site/content/docs/flow-control/rate-limiting.md Rate limiting controls how frequently tasks of a given type are dispatched, using a KV-backed token bucket that works across all workflow runs. ## Two Scopes DagNats supports **global** and **keyed** rate limits. Global limits apply to all invocations of a task type. Keyed limits partition the budget by a value extracted from the task input. ### Global Rate Limit ```go callLLM := wf.Task("call-llm", "llm.chat"). WithTimeout(60 * time.Second). WithRateLimit(dag.RateLimit{ Limit: 10, Period: 1 * time.Minute, }) ``` This allows at most 10 `llm.chat` dispatches per minute across all runs. | Field | Type | Description | |-------|------|-------------| | **Limit** | `int` | Max tokens (dispatches) per period; must be positive | | **Period** | `duration` | Time window for token refill; must be positive | ### Keyed Rate Limit ```go callProvider := wf.Task("call-provider", "llm.chat"). WithTimeout(60 * time.Second). WithKeyedRateLimit(dag.KeyedRateLimit{ Key: "provider", Limit: 100, Period: 1 * time.Minute, Units: 1, }) ``` This maintains a **separate** token bucket for each distinct `provider` value in the task input. If one run's input has `{"provider": "openai"}` and another has `{"provider": "anthropic"}`, they consume from independent buckets. | Field | Type | Description | |-------|------|-------------| | **Key** | `string` | Dot-path into task input for bucket partitioning | | **Limit** | `int` | Max tokens per period per key; must be positive | | **Period** | `duration` | Refill window; must be positive | | **Units** | `int` | Tokens consumed per dispatch; must be positive | The `Key` field supports dot-path expressions (e.g., `config.provider.name`) using the same `ExtractDotPath` function as idempotency keys. ## Token Bucket Implementation Rate limits are stored in the `rate_limits` KV bucket. Each bucket entry tracks the current token count and last-refill timestamp. - **Global key format:** `{taskType}._global` - **Per-key format:** `{taskType}.{keyValue}` The check happens in the **task dispatch path**. When a task is about to be published to `TASK_QUEUES`: 1. Engine reads the token bucket from KV 2. If tokens are available, decrement and dispatch 3. If exhausted, NAK via `SLEEP_TIMERS` with a `rate_retry` action The `rate_retry` timer re-publishes the task after the refill delay, at which point the check runs again. ```mermaid flowchart TD DISPATCH[Dispatch task] --> CHECK{Tokens available?} CHECK -->|yes| DEC[Decrement bucket via CAS] --> PUBLISH[Publish to TASK_QUEUES] CHECK -->|no| NAK[NAK to SLEEP_TIMERS] --> WAIT[Wait for refill] WAIT --> DISPATCH ``` ### CAS Contention Token bucket updates use **compare-and-swap** bounded at 10 retries. KV entries auto-expire at **2x the rate period** to prevent stale state accumulation. ## Validation Rules - A step cannot have both `RateLimit` and `KeyedRateLimit` set simultaneously - `Limit` must be positive - `Period` must be positive - For keyed limits, `Key` must be non-empty and `Units` must be positive {{< callout type="tip" >}} **Respecting provider rate limits.** When calling LLM APIs, set the rate limit to match your provider's documented limits minus a safety margin. For example, if your OpenAI tier allows 60 RPM, set `Limit: 50, Period: 1 * time.Minute` to leave headroom for other consumers. {{< /callout >}} ## Related Pages - [Concurrency Limits](/docs/flow-control/concurrency-limits) -- parallel execution caps - [Retry Policies](/docs/reliability/retry-policies) -- retry behavior for rate-limited tasks - [Idempotency](/docs/reliability/idempotency) -- safe replay after rate-limit delays --- # Source: docs/site/content/docs/get-started/_index.md {{< cards cols="2" >}} {{< card link="quickstart" title="Quickstart" >}} {{< card link="your-first-workflow" title="Your First Workflow" >}} {{< /cards >}} --- # Source: docs/site/content/docs/get-started/quickstart.md Get DagNats running and execute your first workflow in under five minutes. ## Prerequisites - Go 1.22 or later - No other dependencies (NATS is embedded) ## 1. Install DagNats ```bash go install github.com/danmestas/dagnats/cmd/dagnats@latest ``` Verify the installation: ```bash dagnats version ``` ## 2. Start the Server ```bash dagnats serve ``` This starts an embedded NATS server, the workflow engine, the API service, and an HTTP gateway -- all in a single process. Data is stored in a platform-default directory (`~/.local/share/dagnats` on Linux, `~/Library/Application Support/dagnats` on macOS). Leave this terminal running. ## 3. Define a Workflow Create a file called `workflow.json`: ```json { "name": "hello-world", "version": "1.0", "steps": [ { "id": "greet", "task": "greet", "depends_on": [] }, { "id": "uppercase", "task": "uppercase", "depends_on": ["greet"] } ] } ``` This defines a two-step DAG: `greet` runs first, then `uppercase` runs after `greet` completes. The `task` field maps each step to a handler registered in your worker. ## 4. Register the Workflow In a new terminal: ```bash dagnats workflow register workflow.json ``` The server validates the JSON, checks for cycles in the dependency graph, and stores the definition in a NATS KV bucket. ## 5. Write a Worker Create `main.go`: ```go package main import ( "fmt" "os" "os/signal" "strings" "github.com/danmestas/dagnats/worker" "github.com/nats-io/nats.go" ) func main() { nc, err := nats.Connect(nats.DefaultURL) if err != nil { fmt.Fprintf(os.Stderr, "connect: %v\n", err) os.Exit(1) } defer nc.Close() w := worker.NewWorker(nc, nil) worker.HandleTyped(w, "greet", func(ctx worker.TaskContext, name string) (string, error) { if name == "" { name = "World" } return fmt.Sprintf("Hello, %s!", name), nil }, ) worker.HandleTyped(w, "uppercase", func(ctx worker.TaskContext, input string) (string, error) { return strings.ToUpper(input), nil }, ) w.Start() sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt) <-sig w.Stop() } ``` `worker.HandleTyped` registers a typed handler that automatically marshals and unmarshals JSON. The `greet` handler receives a string input and returns a greeting. The `uppercase` handler receives the output of `greet` and returns it uppercased. ## 6. Run the Worker In a new terminal: ```bash go run main.go ``` The worker connects to NATS, subscribes to the `greet` and `uppercase` task queues, and waits for work. ## 7. Start a Workflow Run In a fourth terminal: ```bash dagnats run start hello-world '"World"' --watch ``` The `--watch` flag streams events as they happen. You should see output like: ``` Run abc123 started step.greet: completed step.uppercase: completed Run abc123 completed ``` ## 8. Check the Result ```bash dagnats run status ``` This shows the final state of each step, including outputs. The `uppercase` step output should be `"HELLO, WORLD!"`. ## What Just Happened 1. The CLI sent a `StartRun` request to the API over NATS 2. The engine wrote a `workflow.started` event to the history stream 3. The engine resolved the DAG and dispatched `greet` to the task queue 4. Your worker pulled the task, executed the handler, and published the result 5. The engine received `step.completed`, resolved the next ready step, and dispatched `uppercase` 6. Your worker executed `uppercase` and published the result 7. The engine saw all steps complete and wrote `workflow.completed` All of this happened through NATS JetStream -- no HTTP calls, no database writes, no polling. ## Next Steps - [Your First Workflow](/docs/get-started/your-first-workflow) -- deeper tutorial with typed handlers, retries, and timeouts - [Core Concepts](/docs/concepts) -- understand the event sourcing model and DAG resolution - [Worker SDK](/docs/workers) -- full worker API reference {{< callout type="info" >}} Building LLM agent pipelines? Check out [AI & LLM Patterns](/docs/ai-patterns/) for agent loops, checkpoints, and multi-agent orchestration. {{< /callout >}} --- # Source: docs/site/content/docs/get-started/your-first-workflow.md Build a three-step workflow with typed handlers, retries, and timeouts, then inspect the run using CLI tools. This tutorial assumes you completed the [Quickstart](/docs/get-started/quickstart) and have `dagnats serve` running. ## Understanding the Workflow Definition A **workflow definition** is a JSON document with a name, version, and a list of steps. Each step declares a unique `id`, a `task` name that maps to a worker handler, and an optional `depends_on` array listing step IDs that must complete before this step runs. ```json { "name": "hello-world", "version": "1.0", "steps": [ { "id": "greet", "task": "greet", "depends_on": [] }, { "id": "uppercase", "task": "uppercase", "depends_on": ["greet"] } ] } ``` The engine uses **Kahn's algorithm** to validate that the dependency graph is acyclic and to determine execution order. Steps with no dependencies (or whose dependencies have all completed) are dispatched in parallel. ## Adding a Third Step Extend the workflow with a `reverse` step that depends on `uppercase`: ```json { "name": "hello-pipeline", "version": "1.0", "steps": [ { "id": "greet", "task": "greet", "depends_on": [] }, { "id": "uppercase", "task": "uppercase", "depends_on": ["greet"] }, { "id": "reverse", "task": "reverse", "depends_on": ["uppercase"] } ] } ``` This creates a linear chain: `greet` -> `uppercase` -> `reverse`. Save this as `pipeline.json` and register it: ```bash dagnats workflow register pipeline.json ``` ## Writing Typed Handlers `worker.HandleTyped[I, O]` is a generic function that wraps your handler with automatic JSON marshaling. The type parameters `I` and `O` define the input and output types. The worker framework deserializes the task input into `I` and serializes the return value as `O`. ```go worker.HandleTyped(w, "greet", func(ctx worker.TaskContext, name string) (string, error) { if name == "" { name = "World" } return fmt.Sprintf("Hello, %s!", name), nil }, ) ``` For structured data, use Go structs: ```go type ReviewInput struct { Repo string `json:"repo"` Branch string `json:"branch"` } type ReviewOutput struct { Issues int `json:"issues"` Report string `json:"report"` } worker.HandleTyped(w, "review", func(ctx worker.TaskContext, in ReviewInput) (ReviewOutput, error) { // ... perform review ... return ReviewOutput{Issues: 3, Report: "Found 3 issues"}, nil }, ) ``` If JSON deserialization fails (e.g., the input does not match the expected type), the handler returns a **non-retryable error** automatically. Retrying with the same malformed input would not help. Add the `reverse` handler to your worker: ```go worker.HandleTyped(w, "reverse", func(ctx worker.TaskContext, input string) (string, error) { runes := []rune(input) for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 { runes[i], runes[j] = runes[j], runes[i] } return string(runes), nil }, ) ``` ## Running With --watch Start the worker, then kick off a run: ```bash dagnats run start hello-pipeline '"World"' --watch ``` The `--watch` flag subscribes to the run's event stream and prints each event as it arrives: ``` Run def456 started step.greet: completed step.uppercase: completed step.reverse: completed Run def456 completed ``` Each line corresponds to an **event** written to the `WORKFLOW_HISTORY` JetStream stream. The engine processes these events to advance the DAG. ## Inspecting Runs After a run completes, use the CLI to inspect its state: ```bash dagnats run status ``` This shows the current state of the run and each step, including outputs: ``` Run: def456 Status: completed Steps: greet: completed "Hello, World!" uppercase: completed "HELLO, WORLD!" reverse: completed "!DLROW ,OLLEH" ``` To see the full event history: ```bash dagnats run events ``` This prints every event in chronological order -- useful for debugging why a step failed or understanding execution timing. ## Adding Timeout and Retry Policy Steps can declare a **timeout** (maximum execution duration) and a **retry policy** (what to do when a step fails). ```json { "id": "greet", "task": "greet", "depends_on": [], "timeout": "30s", "type": "normal", "retry": { "max_attempts": 3, "strategy": "exponential", "initial_delay": "1s", "max_delay": "30s", "multiplier": 2.0 } } ``` - **timeout** sets the per-step deadline. If the worker does not complete within this duration, the task is redelivered (up to `MaxDeliver`). Under the hood, this maps to NATS `AckWait`. - **retry.max_attempts** is the total number of retry attempts after the first failure. - **retry.strategy** controls backoff: `fixed` (constant delay), `linear` (delay * attempt), or `exponential` (delay * multiplier^attempt). - **retry.initial_delay** is the base delay between attempts. - **retry.max_delay** caps the delay to prevent unbounded waits. You can also set a **default retry policy** at the workflow level that applies to all steps unless overridden: ```json { "name": "hello-pipeline", "version": "1.1", "default_retry": { "max_attempts": 2, "strategy": "fixed", "initial_delay": "5s", "max_delay": "5s" }, "steps": [...] } ``` ## What Happens Under the Hood When you call `dagnats run start`, here is what the system does: 1. **API** receives the request and publishes a `workflow.started` event to the `WORKFLOW_HISTORY` JetStream stream at subject `history.`. 2. **Engine** consumes the event. It calls `dag.Advance(def, run, event)` -- a pure function that takes the workflow definition, current run state, and new event, then returns a list of **actions** (e.g., `EnqueueTask`, `CompleteWorkflow`). 3. For each `EnqueueTask` action, the engine publishes a task message to the `TASK_QUEUES` stream at subject `task.`. 4. **Worker** pulls the task via a JetStream pull consumer. It executes the registered handler and publishes a `step.completed` or `step.failed` event back to `WORKFLOW_HISTORY`. 5. The engine consumes the completion event, calls `dag.Advance` again, and dispatches the next ready steps. This loop continues until all steps complete or a terminal failure occurs. 6. When the final step completes, `dag.Advance` returns a `CompleteWorkflow` action. The engine writes `workflow.completed` to the history stream. The engine is **stateless** -- it reconstructs run state by replaying the event stream. KV snapshots in the `workflow_runs` bucket are an optimization for fast reads, not the source of truth. If the engine crashes and restarts, it replays from the stream and resumes exactly where it left off. ## Next Steps - [Core Concepts](/docs/concepts) -- deep dive into workflows, events, and the DAG model - [Step Types](/docs/step-types) -- agent loops, sub-workflows, map steps, and more - [Reliability](/docs/reliability) -- retry policies, dead letter queues, compensation - [Worker SDK](/docs/workers) -- full TaskContext API, checkpointing, signals --- # Source: docs/site/content/docs/observability/_index.md {{< cards cols="3" >}} {{< card link="distributed-tracing" title="Distributed Tracing" >}} {{< card link="error-reporting" title="Error Reporting" >}} {{< card link="metrics" title="Metrics" >}} {{< card link="structured-logging" title="Structured Logging" >}} {{< card link="telemetry-stream" title="Telemetry Stream" >}} {{< /cards >}} --- # Source: docs/site/content/docs/observability/distributed-tracing.md The `observe.Tracer` interface creates spans that track units of work across process and message boundaries using W3C trace context propagation. ## Interface ```go type Tracer interface { Start(ctx context.Context, name string, opts ...SpanOption) (context.Context, Span) } ``` `Start` creates a new span, stores it in the returned context, and returns both. If a parent span exists in `ctx`, the child inherits its trace ID and links to the parent. The returned context carries the new span so downstream calls can continue the trace. ## Span ```go type Span interface { End() SetStatus(code StatusCode, description string) SetAttributes(attrs ...Attribute) RecordError(err error) AddEvent(name string, attrs ...Attribute) } ``` **End** must be called exactly once per span. **SetStatus** marks the span as OK or Error. **RecordError** captures an error message and sets error status. **AddEvent** records a timestamped event within the span's lifetime. ## Span Options Options control span creation: ```go ctx, span := tel.Tracer.Start(ctx, "worker.executeTask", observe.WithSpanKind(observe.SpanKindServer), observe.WithAttributes( observe.StringAttr("run_id", runID), observe.StringAttr("step_id", stepID), ), ) defer span.End() ``` | Option | Description | |--------|-------------| | `WithSpanKind(kind)` | Sets the span kind: `SpanKindInternal`, `SpanKindServer`, `SpanKindClient` | | `WithAttributes(attrs...)` | Attaches key-value pairs at creation time | ## Attributes Attribute constructors mirror the Field constructors used by Logger: | Constructor | Value Type | |-------------|-----------| | `StringAttr(key, val)` | `string` | | `Int64Attr(key, val)` | `int64` | | `Float64Attr(key, val)` | `float64` | | `BoolAttr(key, val)` | `bool` | ## W3C Trace Context Propagation DagNats propagates trace context across NATS messages using the W3C `traceparent` header format: `00-{traceID}-{spanID}-{flags}`. On the **producer side**, the engine writes `traceparent` to the NATS message header and the event's `TraceParent` field (dual-write for compatibility with different consumers). On the **consumer side**, the worker extracts `traceparent` from the message header, parses the trace and span IDs, and stores them in the context via `observe.ContextWithParentInfo`. The tracer implementation then links child spans to the remote parent. ```go // Extraction (worker internal) info, ok := observe.ParentInfoFromContext(ctx) // info.TraceID, info.SpanID available for linking ``` ## Adapter Pattern DagNats does not ship a production tracing backend. The internal `simple.TraceCollector` publishes spans to the NATS TELEMETRY stream for development and debugging. For production, implement the `Tracer` interface with your preferred backend (OpenTelemetry, Datadog, etc.): ```go type OTelTracerAdapter struct { tracer trace.Tracer // from go.opentelemetry.io/otel } func (a *OTelTracerAdapter) Start( ctx context.Context, name string, opts ...observe.SpanOption, ) (context.Context, observe.Span) { // Convert observe.SpanOption to OTel options // Return wrapped span that implements observe.Span } ``` Pass the adapter into the `observe.Telemetry` bundle. All DagNats components (engine, worker, bridge) use the same `Tracer` interface, so swapping backends requires no code changes outside the adapter. ## Built-in OTLP Bridge DagNats includes a built-in OTLP/HTTP exporter that bridges the NATS `TELEMETRY` stream to any OTLP-compatible backend (SigNoz, Jaeger, Grafana Tempo, etc.). The bridge consumes spans, logs, and metrics from the telemetry stream and POSTs them to the standard OTLP endpoints (`/v1/traces`, `/v1/logs`, `/v1/metrics`). Run it as a standalone process: ```bash dagnats otlp-bridge --endpoint=http://localhost:4318 ``` Or set the endpoint via environment variable: ```bash export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 dagnats otlp-bridge ``` The bridge creates a durable consumer (`otlp-bridge`) on the `TELEMETRY` stream with explicit ack policy. Messages are fetched in batches (default: 100) and flushed on a configurable interval (default: 5s). Failed exports are retried via `NakWithDelay` up to 4 delivery attempts. | Setting | Default | Description | |---------|---------|-------------| | Batch size | 100 | Messages per fetch | | Flush interval | 5s | Max wait between flushes | | Max deliveries | 4 | Retry limit before dead-letter | | Service name | `dagnats` | `service.name` resource attribute | The bridge routes messages by subject prefix: `telemetry.spans.*` to traces, `telemetry.logs.*` to logs, and `telemetry.metrics.*` to metrics. Custom headers can be configured programmatically via `BridgeConfig.Headers` for backends that require authentication tokens. ## SpanContext Span implementations may optionally implement the `SpanContext` interface to expose trace and span IDs for cross-process propagation: ```go type SpanContext interface { TraceID() string SpanID() string } ``` Callers use type assertion: `if sc, ok := span.(observe.SpanContext); ok { ... }` ## Related - [Structured Logging](/docs/observability/structured-logging) -- leveled structured logs - [Metrics](/docs/observability/metrics) -- counters, histograms, gauges - [Telemetry Stream](/docs/observability/telemetry-stream) -- NATS-native telemetry transport --- # Source: docs/site/content/docs/observability/error-reporting.md The `observe.ErrorReporter` interface captures exceptions and messages to an external error-tracking backend without coupling application code to any vendor. ## Interface ```go type ErrorReporter interface { CaptureError(ctx context.Context, err error, tags map[string]string) CaptureMessage(ctx context.Context, msg string, level Level) } ``` **CaptureError** records an error with optional key-value tags for grouping and filtering. The `ctx` parameter carries trace context so the error can be correlated with the active span. **CaptureMessage** records a text message at a given severity level. Use for non-error conditions that still warrant attention (e.g., deprecation warnings, unusual but valid states). ## Usage ```go tel.Errors.CaptureError(ctx, err, map[string]string{ "run_id": runID, "task_type": "send-email", }) tel.Errors.CaptureMessage(ctx, "rate limit approaching threshold", observe.LevelWarn, ) ``` ## Built-in Behavior The internal `simple.ErrorReporter` implementation bridges errors to the active trace span: 1. When a span exists in `ctx`, `CaptureError` calls `span.RecordError(err)` and sets error status on the span 2. When no span exists, it falls back to `logger.Error()` with tags as structured fields 3. `CaptureMessage` adds a span event when a span is active, or logs at the appropriate level otherwise This means errors are automatically visible in distributed traces without any extra wiring. ## Adapter Pattern The noop implementation discards all captures: ```go reporter := observe.NewNoopErrorReporter() ``` For production error tracking (Sentry, Bugsnag, Rollbar), implement the interface in a separate adapter package. The adapter is the only code that imports the vendor SDK: ```go type SentryReporter struct { hub *sentry.Hub } func (s *SentryReporter) CaptureError( ctx context.Context, err error, tags map[string]string, ) { s.hub.WithScope(func(scope *sentry.Scope) { for k, v := range tags { scope.SetTag(k, v) } s.hub.CaptureException(err) }) } func (s *SentryReporter) CaptureMessage( ctx context.Context, msg string, level observe.Level, ) { s.hub.CaptureMessage(msg) } ``` Pass the adapter into the `observe.Telemetry` bundle alongside your other observability implementations: ```go tel := &observe.Telemetry{ Errors: &SentryReporter{hub: sentry.CurrentHub()}, Logger: myLogger, Tracer: myTracer, Metrics: myMetrics, } ``` ## Telemetry Bundle All four observability interfaces are bundled into a single `observe.Telemetry` struct that is passed to component constructors: ```go type Telemetry struct { Tracer Tracer Logger Logger Metrics Metrics Errors ErrorReporter } ``` All fields must be non-nil. Use `observe.NewNoopTelemetry()` for a safe default with all noop implementations. ## Related - [Structured Logging](/docs/observability/structured-logging) -- leveled structured logs - [Distributed Tracing](/docs/observability/distributed-tracing) -- span-based tracing - [Metrics](/docs/observability/metrics) -- counters, histograms, gauges --- # Source: docs/site/content/docs/observability/metrics.md The `observe.Metrics` interface is a factory for named metric instruments that decouple measurement from the collection backend. ## Interface ```go type Metrics interface { Counter(name string, tags map[string]string) Counter Histogram(name string, tags map[string]string) Histogram Gauge(name string, tags map[string]string) Gauge } ``` Each factory method returns a typed instrument. Tags are label key-value pairs that the backend attaches to every observation. ## Instruments ### Counter A monotonically increasing value. Use for request counts, error counts, retry counts. ```go type Counter interface { Inc() Add(delta float64) } ``` ### Histogram Records observations in configurable buckets. Use for latencies, payload sizes, queue depths. ```go type Histogram interface { Observe(value float64) } ``` ### Gauge A value that can go up or down. Use for active tasks, queue length, memory usage. ```go type Gauge interface { Set(value float64) Inc() Dec() } ``` ## Standard Metrics DagNats components emit these metrics automatically: | Metric | Type | Component | Description | |--------|------|-----------|-------------| | `step.duration_ms` | Histogram | Worker | Time to execute a task handler | | `step.retries` | Counter | Worker | Number of handler errors that triggered retry | | `worker.tasks.active` | Gauge | Worker | Number of tasks currently being processed | | `bridge.requests` | Counter | Bridge | Total HTTP bridge requests | | `bridge.request.duration_ms` | Histogram | Bridge | HTTP bridge request latency | | `bridge.ackmap.size` | Gauge | Bridge | Number of unresolved tasks in the ack map | | `telemetry.spans.dropped` | Counter | TraceCollector | Spans dropped due to full buffer | ## Usage Instruments are created once at construction time and reused: ```go duration := tel.Metrics.Histogram("step.duration_ms", nil) retries := tel.Metrics.Counter("step.retries", nil) active := tel.Metrics.Gauge("worker.tasks.active", nil) // In the hot path active.Inc() start := time.Now() err := handler(ctx) duration.Observe(float64(time.Since(start).Milliseconds())) active.Dec() if err != nil { retries.Inc() } ``` Tags allow partitioning metrics by dimension: ```go counter := tel.Metrics.Counter("requests", map[string]string{ "task_type": "send-email", "status": "success", }) counter.Inc() ``` ## Adapter Pattern The noop implementation discards all observations. Use it for tests and environments where metrics are not needed: ```go metrics := observe.NewNoopMetrics() ``` The internal `simple.MetricsCollector` publishes `MetricPoint` records as JSON to the NATS TELEMETRY stream at `telemetry.metrics.{service}.{name}`. For production, implement the `Metrics` interface with your preferred backend (Prometheus, OTel, StatsD): ```go type PrometheusMetrics struct { /* ... */ } func (p *PrometheusMetrics) Counter( name string, tags map[string]string, ) observe.Counter { // Return a Prometheus counter vec with the given labels } ``` ## Related - [Structured Logging](/docs/observability/structured-logging) -- leveled structured logs - [Distributed Tracing](/docs/observability/distributed-tracing) -- span-based tracing - [Telemetry Stream](/docs/observability/telemetry-stream) -- NATS-native telemetry transport --- # Source: docs/site/content/docs/observability/structured-logging.md The `observe.Logger` interface provides structured, leveled logging that is decoupled from any specific logging backend. ## Interface ```go type Logger interface { Info(msg string, fields ...Field) Error(msg string, err error, fields ...Field) With(fields ...Field) Logger } ``` **Info** emits an informational log entry with optional structured fields. **Error** emits an error-level entry with the causing error and optional fields. **With** returns a new Logger that prepends the given fields to every subsequent call -- useful for adding context like `run_id` or `step_id` once and carrying it through a call chain. ## Fields `Field` is a typed key-value pair for structured context. Constructor helpers keep call sites concise: ```go observe.String("run_id", runID) observe.Int("attempt", 3) observe.Err(err) ``` | Constructor | Value Type | |-------------|-----------| | `String(key, val)` | `string` | | `Int(key, val)` | `int` | | `Err(err)` | `error` (key is always `"error"`) | ## Levels The `Level` enum orders severity from least to most severe: | Level | Value | Use | |-------|-------|-----| | `LevelDebug` | 0 | Verbose diagnostic output | | `LevelInfo` | 1 | Normal operational events | | `LevelWarn` | 2 | Degraded but recoverable situations | | `LevelError` | 3 | Failures requiring attention | Levels are used by `ErrorReporter.CaptureMessage` to route messages to the appropriate severity. The Logger interface itself uses method names (`Info`, `Error`) rather than a level parameter. ## Contextual Chaining `With` creates a child logger that inherits all parent fields. The parent's field slice is never mutated -- each `With` call copies the parent fields into a new slice. ```go logger := tel.Logger.With( observe.String("run_id", runID), observe.String("workflow", "deploy"), ) logger.Info("step started", observe.String("step_id", "build")) // Output includes: run_id, workflow, step_id logger.Info("step finished", observe.String("step_id", "test")) // Output includes: run_id, workflow, step_id ``` ## Adapter Pattern DagNats ships with two Logger implementations: **NoopLogger** -- discards all output. Used as the default when no telemetry is configured. `With` returns the same instance (no allocation). **LogCollector** (internal) -- publishes log records as JSON to the NATS TELEMETRY stream on subject `telemetry.logs.{service}.{level}`. Each log call is independent and safe for concurrent use. To integrate with an external backend (e.g., structured JSON to stdout, or a third-party service), implement the `Logger` interface and pass it into the `observe.Telemetry` bundle: ```go tel := &observe.Telemetry{ Logger: myCustomLogger, Tracer: observe.NewNoopTracer(), Metrics: observe.NewNoopMetrics(), Errors: observe.NewNoopErrorReporter(), } w := worker.NewWorker(nc, tel) ``` ## Related - [Distributed Tracing](/docs/observability/distributed-tracing) -- span-based tracing - [Metrics](/docs/observability/metrics) -- counters, histograms, gauges - [Error Reporting](/docs/observability/error-reporting) -- exception capture - [Telemetry Stream](/docs/observability/telemetry-stream) -- NATS-native telemetry transport --- # Source: docs/site/content/docs/observability/telemetry-stream.md The TELEMETRY JetStream stream is the NATS-native transport for all observability signals -- spans, metrics, and logs flow through a single durable stream. ## Stream Configuration The stream is provisioned by `natsutil.SetupTelemetryStream()` (called automatically by `natsutil.SetupAll()`): | Parameter | Value | |-----------|-------| | **Name** | `TELEMETRY` | | **Subjects** | `telemetry.>` | | **Retention** | Limits policy | | **Storage** | File | | **MaxAge** | 7 days | | **MaxBytes** | 1 GB | | **Dedup Window** | 5 seconds | The 7-day retention and 1 GB cap ensure telemetry does not grow unbounded. Whichever limit is hit first triggers pruning. ## Subject Hierarchy All telemetry is published under `telemetry.>` with a structured subject hierarchy: | Subject Pattern | Signal Type | Example | |----------------|-------------|---------| | `telemetry.spans.{service}.{run_id}` | Trace spans | `telemetry.spans.worker.abc123` | | `telemetry.metrics.{service}.{name}` | Metric points | `telemetry.metrics.worker.step.duration_ms` | | `telemetry.logs.{service}.{level}` | Log records | `telemetry.logs.engine.info` | This hierarchy enables targeted subscriptions. Subscribe to `telemetry.logs.engine.error` to see only engine errors, or `telemetry.spans.worker.>` to see all worker traces. ## Wire Formats ### Span Records ```json { "trace_id": "a1b2c3d4e5f6...", "span_id": "f6e5d4c3b2a1...", "parent_id": "1a2b3c4d5e6f...", "name": "worker.executeTask", "service": "worker", "kind": "server", "start_time": "2024-01-15T10:30:00Z", "end_time": "2024-01-15T10:30:01.234Z", "duration_ms": 1234, "status": "ok", "attributes": {"run_id": "abc123", "step_id": "build"}, "events": [], "error": "" } ``` Spans use `Nats-Msg-Id` (`{traceID}.{spanID}`) for deduplication. Duplicate spans from retried publishes are silently dropped. ### Metric Points ```json { "name": "step.duration_ms", "type": "histogram", "value": 1234.0, "tags": {"task_type": "build"}, "service": "worker", "timestamp": "2024-01-15T10:30:01Z" } ``` ### Log Records ```json { "level": "error", "message": "task handler returned error, will retry", "service": "engine", "timestamp": "2024-01-15T10:30:01Z", "fields": {"run_id": "abc123", "task_type": "build"}, "error": "connection refused" } ``` ## Tailing with the CLI The `dagnats logs` command subscribes to the TELEMETRY stream for real-time observation: ```bash # Tail all telemetry dagnats logs --tail # Filter by signal type dagnats logs --tail --subject "telemetry.logs.engine.error" # Filter by run dagnats logs --tail --subject "telemetry.spans.worker.abc123" ``` ## External Collection The TELEMETRY stream is designed for consumption by external collectors. An OTel Collector, custom aggregator, or any NATS client can create a durable consumer on the stream and forward signals to a backend like SigNoz, Grafana, or Datadog. ```go cons, _ := js.CreateOrUpdateConsumer(ctx, "TELEMETRY", jetstream.ConsumerConfig{ FilterSubject: "telemetry.spans.>", AckPolicy: jetstream.AckExplicitPolicy, DeliverPolicy: jetstream.DeliverAllPolicy, }, ) ``` The 5-second dedup window prevents duplicate signals when publishers retry, so consumers do not need their own deduplication logic. ## Related - [Structured Logging](/docs/observability/structured-logging) -- the Logger interface - [Distributed Tracing](/docs/observability/distributed-tracing) -- the Tracer interface - [Metrics](/docs/observability/metrics) -- the Metrics interface --- # Source: docs/site/content/docs/operations/_index.md {{< cards cols="3" >}} {{< card link="configuration" title="Configuration" >}} {{< card link="deployment-models" title="Deployment Models" >}} {{< card link="nats-infrastructure" title="NATS Infrastructure" >}} {{< card link="production-checklist" title="Production Checklist" >}} {{< card link="service-discovery" title="Service Discovery" >}} {{< card link="troubleshooting" title="Troubleshooting" >}} {{< /cards >}} --- # Source: docs/site/content/docs/operations/configuration.md DagNats uses a three-tier configuration system where each tier overrides the previous. ## Resolution Order 1. **Built-in defaults** -- hardcoded, platform-appropriate values 2. **Config file** (`dagnats.yaml`) -- optional file in the working directory 3. **Environment variables** (`DAGNATS_*`) -- highest priority Zero-config starts everything on defaults. Environment variables always win. ## Config Keys | Key | Type | Default (macOS) | Default (Linux) | |-----|------|-----------------|-----------------| | `data_dir` | string | `~/Library/Application Support/dagnats` | `~/.local/share/dagnats` | | `http_addr` | string | `:8080` | `:8080` | | `nats_port` | int | `4222` | `4222` | | `leaf_remotes` | []string | (none) | (none) | | `leaf_credentials` | string | (none) | (none) | | `monitor_port` | int | (none) | (none) | | `max_store_bytes` | int64 | `10737418240` (10 GiB) | `10737418240` (10 GiB) | On Linux, `data_dir` respects `XDG_DATA_HOME` if set. ## Environment Variables ### Core | Variable | Overrides | Notes | |----------|-----------|-------| | `DAGNATS_DATA_DIR` | `data_dir` | | | `DAGNATS_HTTP_ADDR` | `http_addr` | | | `DAGNATS_NATS_PORT` | `nats_port` | Must be a valid integer | | `DAGNATS_LEAF_REMOTES` | `leaf_remotes` | Comma-separated, max 10 entries | | `DAGNATS_LEAF_CREDENTIALS` | `leaf_credentials` | Path to NATS credentials file | | `DAGNATS_MONITOR_PORT` | `monitor_port` | NATS monitoring HTTP port | | `DAGNATS_MAX_STORE_BYTES` | `max_store_bytes` | Must be a positive integer | ### Triggers | Variable | Default | Notes | |----------|---------|-------| | `DAGNATS_WEBHOOK_SECRET` | (none) | Default HMAC secret for webhook triggers | When `DAGNATS_WEBHOOK_SECRET` is set and no `--secret` flag is provided to `dagnats trigger create`, the env var value is used. The `--secret` flag always takes precedence. This keeps secrets out of shell history. ### Observability | Variable | Default | Notes | |----------|---------|-------| | `OTEL_EXPORTER_OTLP_ENDPOINT` | (none) | OTLP/HTTP base URL for telemetry | When `OTEL_EXPORTER_OTLP_ENDPOINT` is set, DagNats subscribes to the internal `TELEMETRY` span stream and batches spans to `{endpoint}/v1/traces` via OTLP/HTTP JSON. Works with any OTLP/HTTP-compatible backend (SigNoz, Grafana Tempo, Jaeger). When unset, spans are still written to the NATS `TELEMETRY` stream but not exported externally. Export failures never affect workflow execution. ```bash OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 dagnats serve ``` ### Workers (Config-Driven) Worker handlers can be defined in the config file and overridden per-task via environment variables: | Variable Pattern | Notes | |-----------------|-------| | `DAGNATS_WORKER_{TASK}_EXEC` | Shell command to execute | | `DAGNATS_WORKER_{TASK}_HTTP` | HTTP endpoint URL | | `DAGNATS_WORKER_{TASK}_HTTP_METHOD` | HTTP method (default: POST) | Task names are uppercased with hyphens replaced by underscores. For example, a task named `call-claude` uses `DAGNATS_WORKER_CALL_CLAUDE_EXEC`. ### Deprecated Variables The following environment variables still work but produce a warning on stderr. Migrate to the new names. | Old Name | New Name | |----------|----------| | `NATS_URL` | `DAGNATS_NATS_URL` | | `LISTEN_ADDR` | `DAGNATS_LISTEN_ADDR` | ## Config File Place a `dagnats.yaml` file in the working directory. Format is simple `key: value` pairs, one per line. Lines starting with `#` are comments. Maximum 300 lines. ```yaml # dagnats.yaml data_dir: /var/lib/dagnats http_addr: :9090 nats_port: 4333 leaf_remotes: nats://hub1:7422, nats://hub2:7422 max_store_bytes: 5368709120 # Config-driven workers worker.summarize.exec: python3 /opt/workers/summarize.py worker.call-api.http: http://localhost:3000/tasks worker.call-api.http_method: POST ``` Unknown keys produce a warning but do not cause an error. ### Worker Config in YAML Each worker needs a task name and either an `exec` command or an `http` endpoint (not both): ```yaml worker.my-task.exec: /usr/local/bin/my-handler worker.my-task.http: http://localhost:3000/handle worker.my-task.http_method: PUT ``` Maximum of 50 worker configs. Duplicate task names are rejected. Having both `exec` and `http` for the same task is rejected. ### Inline Leaf Credentials `DAGNATS_LEAF_CREDENTIALS` accepts either a file path or inline PEM content. If the value starts with `-----BEGIN`, DagNats writes it to a secure temp file automatically. This is useful in CI/CD where mounting a credentials file is inconvenient: ```bash DAGNATS_LEAF_CREDENTIALS="-----BEGIN NATS USER JWT----- eyJ0eXAiOiJK... ------END NATS USER JWT------ -----BEGIN USER NKEY SEED----- SUAM... ------END USER NKEY SEED------" dagnats serve ``` ## Control plane policy By default, task handlers cannot access the control plane. To allow a task to register and spawn workflows at runtime, declare `capabilities: ["control-plane"]` in the workflow definition and grant the workflow via deployment policy. The `policy.control_plane.grant` and `policy.control_plane.promote` settings are hot-reloadable and default to deny-by-default (empty or absent = no workflow has control-plane access). ```yaml policy: control_plane: grant: [planner, supervisor] promote: [supervisor] ``` - `grant` — workflows allowed to access the control plane (list of workflow names) - `promote` — subset of `grant` that can spawn higher-privilege workflows; must be a subset See [Runtime-Generated Workflows]({{< ref "/docs/ai-patterns/runtime-generated-workflows" >}}) for agent-runtime patterns and [Service discovery]({{< ref "/docs/operations/service-discovery" >}}) for how the control plane is exposed. ## Agent-runtime limits & retention Agent runtimes (runtime-generated workflows) are bounded per generation tree (root run). The following limits are enforced at capability boundaries and return errors the agent loop can handle: | Config Key | Environment Variable | Default | Meaning | |------------|----------------------|---------|---------| | `max_active_runs_per_root` | `DAGNATS_MAX_ACTIVE_RUNS_PER_ROOT` | `100` | Maximum non-terminal runs per root | | `max_defs_per_root` | `DAGNATS_MAX_DEFS_PER_ROOT` | `500` | Maximum ephemeral workflow definitions per root | | `max_generation_depth` | `DAGNATS_MAX_GENERATION_DEPTH` | `3` | Maximum spawn nesting depth (clamped to engine ceiling) | | `max_registers_per_minute_per_root` | `DAGNATS_MAX_REGISTERS_PER_MINUTE_PER_ROOT` | `60` | Registration rate-limit per root tree | | `runs_max_age` | `DAGNATS_RUNS_MAX_AGE` | unset (disabled) | Optional run-retention window; Go duration format (e.g. `"720h"`); when set, terminal runs older than the window are pruned | `runs_max_age` is opt-in — when unset, run retention is unlimited. Negative values are rejected at load time. The `max_generation_depth` limit is clamped to the engine ceiling and any value exceeding it is rejected at config load. ## Viewing Effective Config ```bash dagnats config show dagnats config show --json ``` The `config show` command loads the resolved configuration (all three tiers merged) and prints it. Use `--json` for machine-readable output. This is the fastest way to verify which values are active. Example output: ``` data_dir: /Users/you/Library/Application Support/dagnats http_addr: :8080 nats_port: 4222 leaf_remotes: (none) leaf_credentials:(none) monitor_port: (none) max_store_bytes: 10737418240 otlp_endpoint: (none) ``` --- # Source: docs/site/content/docs/operations/deployment-models.md DagNats supports two deployment models: a single-binary server for simplicity and a distributed deployment for scale. ## Single Binary (`dagnats serve`) The `dagnats serve` command starts everything in one process: an embedded NATS server, the workflow engine, the API, triggers, and an HTTP server. Zero configuration required. ```bash dagnats serve ``` This is the recommended model for development, staging, and small-to-medium production workloads. The embedded NATS server binds to `127.0.0.1` by default, so only local connections are accepted. **What runs inside `dagnats serve`:** | Component | Role | |-----------|------| | Embedded NATS | JetStream streams, KV buckets, pub/sub | | ActorOrchestrator | Per-workflow actors, event processing | | API Service | REST + NATS micro control plane | | TriggerService | Cron, subject, and webhook triggers | | HTTP Server | REST endpoints, health checks, webhooks | | Telemetry | Tracing, metrics, structured logging | Workers are always separate processes. They connect to the embedded NATS server over the network and are never embedded inside `dagnats serve`. ### Startup Order 1. Resolve data directory (platform default if unset, created if missing) 2. Start embedded NATS server 3. Connect internal client to `nats://localhost:{port}` 4. Create all JetStream streams and KV buckets (`natsutil.SetupAll`) 5. Initialize telemetry pipeline 6. Start API service, actor orchestrator, trigger service 7. Start HTTP server 8. Block on SIGINT/SIGTERM ### Shutdown Order Shutdown reverses startup with a hard 15-second deadline: 1. HTTP server graceful shutdown (5s timeout) 2. Stop triggers (unsubscribe, stop scheduler) 3. Stop orchestrator (unsubscribe from history stream, stop all actors) 4. Flush telemetry (cancel exporter, flush pending spans) 5. Drain NATS connection (flush pending messages) 6. Stop embedded NATS server If any step hangs past the deadline, the process force-exits. No goroutine leaks. ## Distributed Deployment For larger workloads or multi-machine setups, run components as separate binaries connecting to an external NATS cluster: ```bash # Machine 1: NATS cluster (or use NATS Cloud) nats-server -js -c nats.conf # Machine 2: Engine + API dagnats-engine --nats-url nats://nats-host:4222 dagnats-api --nats-url nats://nats-host:4222 # Machine 3+: Workers my-worker --nats-url nats://nats-host:4222 ``` In this model, you manage the NATS infrastructure yourself. Each component connects to the external NATS server. The engine, API, and workers can scale independently. ## Leaf Node Topology The embedded NATS server in `dagnats serve` can connect to a hub cluster as a **leaf node**. This gives you single-binary simplicity with multi-cluster reach. ```yaml # dagnats.yaml leaf_remotes: nats://hub1:7422, nats://hub2:7422 ``` When leaf remotes are configured: - The embedded NATS server binds to `0.0.0.0` (instead of `127.0.0.1`) for hub communication - NATS handles message routing transparently between the leaf and hub - All internal components still connect to `localhost:{port}` - Workers can connect to either the leaf or the hub -- same code path Leaf node mode enables geographic distribution, multi-team isolation, or connecting edge deployments to a central cluster. A maximum of 10 leaf remotes can be configured. ## When to Use Each Model | Scenario | Model | |----------|-------| | Development and testing | `dagnats serve` (standalone) | | Small production (< 50 workers) | `dagnats serve` (standalone) | | Multi-region or multi-team | `dagnats serve` (leaf node) | | High availability with NATS clustering | Distributed | | Independent scaling of engine vs workers | Distributed | ## Health Checks Both models expose health endpoints on the HTTP server: | Endpoint | Behavior | |----------|----------| | `GET /health` | 200 if NATS connected and JetStream available, 503 otherwise | | `GET /ready` | 200 only after all components have started | Use `/health` for liveness probes and `/ready` for readiness probes in container orchestrators. ## Scaling Workers Workers scale horizontally by running more instances. Each worker connects to NATS and pulls tasks from JetStream consumers. NATS handles load distribution automatically via **pull consumers** with `MaxAckPending`. ```bash # Run 5 instances of the same worker for i in $(seq 1 5); do my-worker --nats-url nats://localhost:4222 & done ``` The engine does not need to know how many workers exist. Worker discovery is observability-only via the `workers` KV bucket (60s TTL heartbeat). The engine never reads it. --- # Source: docs/site/content/docs/operations/nats-infrastructure.md DagNats provisions all its NATS resources automatically on startup via `natsutil.SetupAll`. ## JetStream Streams Seven streams handle all durable messaging. `dagnats serve` creates these on first boot; distributed deployments must ensure they exist before components start. | Stream | Subjects | Retention | Storage | Limits | Purpose | |--------|----------|-----------|---------|--------|---------| | `WORKFLOW_HISTORY` | `history.>` | Limits | File | 5s dedup window | Immutable event log (source of truth) | | `TASK_QUEUES` | `task.>` | WorkQueue | File | Atomic publish enabled | Work distribution to workers | | `EVENTS` | `event.>` | Limits | File | | External event ingestion | | `DEAD_LETTERS` | `dead.>` | Limits | File | 30-day retention | Permanent failures for inspection | | `TELEMETRY` | `telemetry.>` | Limits | File | 7-day, 1 GiB max, 5s dedup | Spans, metrics, logs | | `SLEEP_TIMERS` | `sleep.>`, `scheduled.>` | Limits | File | | Durable timers (sleep, timeout, rate-retry, scheduled runs) | | `STICKY_TASKS` | `sticky.>` | Limits | Memory | 30-minute max age | Worker-affinity task routing | ### WORKFLOW_HISTORY The event log. Every state change for every workflow run is an immutable event published to `history.{runID}`. The 5-second dedup window (via `Nats-Msg-Id`) prevents duplicate events during retries. The orchestrator replays this stream on startup to rebuild in-memory actor state. ### TASK_QUEUES Work distribution. Uses **WorkQueuePolicy** so each message is delivered to exactly one consumer. Tasks are published to `task.{taskType}` and workers create pull consumers filtered to the task types they handle. Atomic publish is enabled for fan-out operations (map steps). ### SLEEP_TIMERS A shared timer stream using an action discriminator in the message payload: | Action | Fires After | Result | |--------|-------------|--------| | `sleep_complete` | Sleep duration | Publishes `step.sleep.completed` event | | `wait_timeout` | Wait-for-event timeout | Publishes `step.wait.timeout` event | | `rate_retry` | Rate limit refill delay | Re-publishes task to `task.>` | | `debounce_fire` | Debounce window | Fires debounced trigger | | `batch_fire` | Batch timeout | Fires accumulated batch | | `retry_after` | Requested delay | Re-publishes task for retry | All timers use NATS `NakWithDelay` -- the message is negatively acknowledged with a delay, and NATS redelivers it after the specified duration. No external timer service needed. ## KV Buckets Fifteen KV buckets store workflow state, coordination data, and operational metadata. | Bucket | TTL | History | Purpose | |--------|-----|---------|---------| | `workflow_defs` | -- | default | Immutable workflow definitions | | `workflow_runs` | -- | default | Mutable run state snapshots | | `scheduled_runs` | -- | default | One-shot scheduled workflow runs | | `workers` | 60s | default | Worker directory (heartbeat) | | `event_waiters` | -- | default | Wait-for-event correlation entries | | `rate_limits` | -- | default | Token bucket state per task type | | `concurrency_tasks` | -- | 1 | Per-task-type concurrency counters | | `approval_tokens` | 7 days | 1 | Human approval gate tokens | | `debounce_state` | 14 days | default | Subject trigger debounce windows | | `idempotency_keys` | 24 hours | default | Workflow dedup key-to-runID mapping | | `sticky_bindings` | ~25 hours | default | Run-to-worker affinity binding | | `singleton_locks` | -- | default | Singleton execution locks | | `checkpoints` | -- | default | Worker step state persistence | | `signals` | -- | default | Cross-workflow KV-based signaling | | `triggers` | -- | default | Trigger definitions | | `trigger_state` | -- | default | Cron last-run timestamps | ### Workers Bucket The `workers` bucket has a 60-second TTL. Workers re-PUT their entry every 30 seconds, so a single missed heartbeat is tolerated. The engine never reads this bucket -- it exists purely for observability (`dagnats workers list`). If the bucket is missing, workers function normally. ### Concurrency Buckets `concurrency_tasks` uses `History: 1` to minimize storage for CAS-based counters. The engine checks these counters before dispatching tasks. If a limit is exhausted, the task is retried via `SLEEP_TIMERS` with a 1-second delay. ## Subject Hierarchy All NATS subjects follow a dot-separated hierarchy. The `>` wildcard matches one or more tokens. ``` history.{runID} # Workflow events task.{taskType} # Task distribution event.{eventType} # External events dead.{runID}.{stepID} # Dead letter entries telemetry.spans # Trace spans telemetry.metrics # Metrics telemetry.logs # Log records sleep.{runID}.{stepID} # Timer messages scheduled.{workflowName} # Scheduled run triggers sticky.{workerID}.{taskType} # Worker-affinity tasks stream.{runID}.{stepID} # Real-time step output streaming approval.{runID}.{stepID} # Approval notifications ``` The subject design ensures that consumers can filter to exactly the messages they need. A worker subscribing to `task.summarize` only receives summarize tasks. The orchestrator subscribing to `history.>` receives all workflow events. ## Resource Setup On startup, `natsutil.SetupAll(nc)` calls: 1. `SetupStreams` -- creates the 5 core streams 2. `SetupKVBuckets` -- creates all KV buckets 3. `SetupTelemetryStream` -- creates the TELEMETRY stream 4. `SetupStickyStream` -- creates the STICKY_TASKS stream 5. `enableAtomicPublish` -- enables atomic batch publish on TASK_QUEUES Each call uses `CreateOrUpdateStream`/`CreateOrUpdateKeyValue`, making them idempotent. Running `dagnats serve` multiple times against the same NATS data directory is safe. All setup operations have a 30-second timeout. If NATS is unavailable, startup fails fast. --- # Source: docs/site/content/docs/operations/production-checklist.md A pre-launch checklist covering NATS tuning, monitoring, backup, scaling, and health checks. ## NATS Configuration ### JetStream Storage Set `max_store_bytes` based on your expected event volume. The default is 10 GiB. For high-throughput deployments, increase it: ```yaml # dagnats.yaml max_store_bytes: 53687091200 # 50 GiB ``` JetStream data lives in `{data_dir}/jetstream/`. Use fast storage (SSD or NVMe) for this directory. Spinning disks create latency spikes during fsync. ### Stream Retention - **WORKFLOW_HISTORY**: grows indefinitely by default. Set a `MaxAge` or `MaxBytes` on the stream if you have retention requirements. Events older than your recovery window are safe to discard. - **DEAD_LETTERS**: 30-day retention. Increase if you need longer post-mortem windows. - **TELEMETRY**: 7-day retention, 1 GiB cap. Increase `MaxBytes` if you export slowly or want deeper history. ### Dedup Window `WORKFLOW_HISTORY` and `TELEMETRY` use a 5-second dedup window via `Nats-Msg-Id`. This prevents duplicate events during retries. Do not reduce this below 5 seconds unless you understand the implications for at-least-once delivery. ## Monitoring ### Health Endpoints | Endpoint | Use | |----------|-----| | `GET /health` | Liveness probe -- 200 if NATS connected and JetStream available | | `GET /ready` | Readiness probe -- 200 only after all components started | Wire `/health` to your container orchestrator's liveness check and `/ready` to the readiness check. ### NATS Monitoring Enable the NATS monitoring port for direct server metrics: ```yaml monitor_port: 8222 ``` This exposes the standard NATS monitoring endpoints (`/varz`, `/jsz`, `/connz`, `/subsz`) on the specified port. Use these for: - **Stream health** (`/jsz`): message counts, consumer lag, storage usage - **Connection health** (`/connz`): connected workers, subscription counts - **Server health** (`/varz`): memory, CPU, goroutines ### Key Metrics to Watch | Metric | Warning Threshold | Action | |--------|-------------------|--------| | Consumer pending count | > 1000 | Add workers or check for stuck tasks | | Dead letter stream size | Growing steadily | Investigate failing tasks | | JetStream storage usage | > 80% of `max_store_bytes` | Increase limit or add retention policies | | Worker heartbeat gaps | > 60s | Worker likely crashed | | `WORKFLOW_HISTORY` consumer lag | > 500 | Engine falling behind event processing | ### Status Command ```bash dagnats status ``` Shows connection state, stream info, active workers, and pending tasks at a glance. ## Backup ### JetStream Snapshots NATS JetStream supports stream snapshots for backup: ```bash nats stream backup WORKFLOW_HISTORY /backups/history-$(date +%Y%m%d).tar nats stream backup TASK_QUEUES /backups/tasks-$(date +%Y%m%d).tar ``` Back up at minimum: - `WORKFLOW_HISTORY` -- your source of truth for all workflow state - KV buckets (`workflow_defs`, `workflow_runs`) -- quick recovery without full replay KV buckets are stored as JetStream streams internally (prefixed `KV_`), so you can back them up the same way: ```bash nats stream backup KV_workflow_defs /backups/defs-$(date +%Y%m%d).tar nats stream backup KV_workflow_runs /backups/runs-$(date +%Y%m%d).tar ``` ### Recovery Restore from snapshots: ```bash nats stream restore WORKFLOW_HISTORY /backups/history-20260401.tar ``` After restoring `WORKFLOW_HISTORY`, the orchestrator replays the stream on next startup and rebuilds in-memory state. KV snapshots are a convenience -- the event log is the authoritative record. ## Scaling Workers Workers scale horizontally. Each worker instance creates a pull consumer on the `TASK_QUEUES` stream filtered to its task types. NATS distributes work automatically. **Guidelines:** - Start with 1 worker per task type, add more when consumer pending count rises - Each worker process handles one task at a time per task type by default - For CPU-bound tasks, match worker count to available cores - For I/O-bound tasks (API calls, LLM inference), run more workers than cores - Maximum `MaxAckPending` on the consumer limits in-flight parallelism ### Worker Affinity For stateful workers (large model caches, local file context), use **sticky bindings**. The `sticky_bindings` KV bucket maps runs to specific workers. Tasks for that run route to the same worker via the `STICKY_TASKS` stream. ## Memory and Disk Sizing ### Memory - **Engine**: ~2 KiB per active workflow actor. 10,000 concurrent runs needs ~20 MiB for actor state alone, plus overhead. - **Workers**: depends on task payload size. Budget for the largest payload you expect times `MaxAckPending`. - **NATS server**: JetStream uses memory-mapped files. Budget at least 256 MiB for the NATS process itself. ### Disk - **WORKFLOW_HISTORY**: ~500 bytes per event. A workflow with 10 steps generates ~12 events. 1 million completed workflows = ~6 GiB. - **TASK_QUEUES**: WorkQueue retention means completed tasks are deleted. Disk usage is proportional to in-flight tasks, not total tasks. - **TELEMETRY**: capped at 1 GiB by default with 7-day retention. - **KV buckets**: typically small. `workflow_runs` is the largest, proportional to active + recently completed runs. ### Recommended Minimums | Deployment Size | CPU | Memory | Disk | |----------------|-----|--------|------| | Development | 1 core | 512 MiB | 1 GiB | | Small (< 100 concurrent runs) | 2 cores | 2 GiB | 20 GiB SSD | | Medium (< 10,000 concurrent runs) | 4 cores | 8 GiB | 100 GiB SSD | | Large (> 10,000 concurrent runs) | 8+ cores | 16+ GiB | 500+ GiB NVMe | ## Security ### Network Isolation In standalone mode, the embedded NATS server binds to `127.0.0.1` -- only local connections accepted. In leaf node mode, it binds to `0.0.0.0` for hub communication. For production: - Use NATS credentials (`leaf_credentials` config key) for leaf-to-hub authentication - Place the HTTP API behind a reverse proxy with TLS - Use `DAGNATS_WEBHOOK_SECRET` for webhook trigger authentication - Use `DAGNATS_BRIDGE_TOKEN` for HTTP bridge authentication ### Data Directory Permissions The `data_dir` contains JetStream storage including workflow definitions, run state, and task payloads. Restrict access: ```bash chmod 700 /var/lib/dagnats chown dagnats:dagnats /var/lib/dagnats ``` --- # Source: docs/site/content/docs/operations/service-discovery.md # Service Discovery (nats-micro) DagNats's internal control-plane endpoints run as **[nats-micro](https://github.com/nats-io/nats.go/tree/main/micro) services**. That means dagnats is **discoverable and observable with standard NATS tooling** out of the box — no extra wiring — and the console Services page shows **live** instance/version/health data sourced from the `$SRV.*` discovery protocol rather than a static registry. This is purely additive: the request/reply **subjects are unchanged**, so every existing caller keeps working. Wrapping them in `micro.Service` only adds the discovery + per-endpoint statistics surface. ## The services | Service | Endpoints (subjects) | |---|---| | `dagnats-api` | `api.workflows.register`, `api.runs.start`, `api.runs.get`, `api.runtimes.register`, `api.runs.spawn`, `api.runtimes.budget` | | `dagnats-trigger` | the trigger-type registration ack (`_REGISTRY.trigger_types.ack`) — present only when external-trigger support is enabled | Each service reports the build version stamped into the binary (an un-stamped dev build reports `0.0.0-dev`). ## Inspecting with the `nats` CLI ```bash # List discoverable dagnats services and their instances nats micro ls # Full info for one service (endpoints, subjects, metadata) nats micro info dagnats-api # Per-endpoint request/error counts + processing time nats micro stats dagnats-api ``` Under the hood these use the reserved `$SRV.PING`, `$SRV.INFO`, and `$SRV.STATS` subjects that `micro.Service` answers automatically. Discovery is **request-many-reply**: every running instance responds, so `nats micro ls` shows the instance count for a horizontally-scaled deployment. ## In the console **Console → Services** renders a live roster: it issues a bounded `$SRV` discovery request per page load (a short, bounded timeout so a slow or absent responder can never stall the page) and shows, per service: - **Instances** — count of `$SRV.PING` responders. - **Version** — from the ping response. - **Status** — `online` (responding, no errors), `degraded` (errors reported), `unknown` (reachable but stats unavailable), or `stale` (registered but not responding). Anything unbacked is dashed, never fabricated. The page is a **union** of the `services` KV roster and live-discovered services, so `dagnats-api` and `dagnats-trigger` appear even though they don't self-register in the KV bucket. ## Notes - **Fan-out preserved.** The endpoints run **without** a micro queue group, matching the pre-micro plain-subscribe behavior — every instance receives each message (no load-balancing was silently introduced). - **Error envelope unchanged.** Handlers reply with the same JSON error shape as before (they do not use `micro`'s `Nats-Service-Error` headers), so existing request/reply clients parse responses identically. --- # Source: docs/site/content/docs/operations/troubleshooting.md Common issues and how to diagnose them using built-in tools. ## Runs Stuck in Pending A workflow run stays in `pending` status when the orchestrator has not yet processed its `workflow.started` event. **Diagnosis:** ```bash dagnats run inspect ``` Check the run status and step states. If the run shows `pending` with no steps queued: 1. **Engine not running**: verify `dagnats serve` is up and the orchestrator started. Check logs for `ActorOrchestrator.Start` errors. 2. **Consumer lag**: the orchestrator's consumer on `WORKFLOW_HISTORY` may be behind. Check with `nats consumer info WORKFLOW_HISTORY` and look at `Num Pending`. 3. **NATS connectivity**: the engine may have lost its connection. Check `/health` -- a 503 response means NATS is disconnected. **Resolution:** Restart `dagnats serve`. The orchestrator replays the `WORKFLOW_HISTORY` stream on startup, recovering all in-flight runs. ## Tasks Not Being Picked Up Steps are stuck in `queued` status -- the engine published tasks but no worker is processing them. **Diagnosis:** ```bash dagnats workers list ``` If no workers appear, none are connected. If workers appear but tasks are stuck: 1. **Task type mismatch**: the worker must handle the exact task type defined in the step. Check `worker.Handle("task-type", ...)` matches the step's `TaskType` field. 2. **Consumer not created**: the worker creates a pull consumer on `TASK_QUEUES` filtered to `task.{taskType}`. Check `nats consumer ls TASK_QUEUES` for the expected consumer. 3. **Rate limit exhausted**: if the step has a rate limit configured, tasks queue until tokens refill. Check the `rate_limits` KV bucket. 4. **Concurrency limit reached**: per-task-type or per-run concurrency limits can hold tasks. Check `concurrency_tasks` KV bucket. **Resolution:** Start a worker that handles the correct task type. If rate or concurrency limits are the cause, wait for them to clear or adjust the limits. ## Task Timeouts A task is redelivered after `AckWait` expires, incrementing the retry counter. **Common causes:** - **Handler too slow**: the worker did not call `Complete()`, `Fail()`, or `Heartbeat()` before `AckWait` expired. Use `ctx.Heartbeat()` for long-running tasks -- it calls NATS `InProgress()` to extend the ack deadline. - **Worker crashed mid-task**: NATS redelivers after `AckWait`. The task retries on another worker (or the same one after restart). If the handler uses [checkpoints](/docs/coordination/checkpoints), it resumes from the last checkpoint. - **Network partition**: the worker lost its NATS connection. NATS redelivers to another consumer. **Diagnosis:** ```bash dagnats run inspect ``` Check the step's `Attempts` count. If it is climbing toward `MaxAttempts`, the handler is consistently failing or timing out. ## Dead Letter Queue Growing The `DEAD_LETTERS` stream receives tasks that exhausted all retry attempts. **Diagnosis:** ```bash dagnats dlq list dagnats dlq inspect ``` Each dead letter entry includes the original task payload, the error message, and the run/step IDs. Look at the error message for the root cause. **Common causes:** - **Permanent failure**: the handler called `FailPermanent(err)` to skip retries. This is intentional -- fix the underlying issue and use `dagnats run retry` to re-run. - **Retry exhaustion**: `MaxAttempts` reached. Either increase retries in the retry policy or fix the handler. - **Invalid input**: the task received data the handler cannot process. Check the workflow definition's input wiring. **Resolution:** ```bash # Retry a single failed run (fresh start with current workflow def) dagnats run retry --mode=rerun # Retry all failed runs in a time window dagnats run retry-all --after=2026-04-01 --before=2026-04-02 --mode=rerun # Replay from DLQ (resume at failed step, requires < 30 days) dagnats run retry --mode=replay ``` ## Worker Disconnects Workers disappear from `dagnats workers list` and in-flight tasks eventually timeout. **Diagnosis:** Check worker logs for NATS connection errors. Common causes: 1. **NATS server unreachable**: network issue or NATS server restart. Workers should auto-reconnect if using the default NATS client options. 2. **Slow consumer**: the worker's subscription fell behind and NATS disconnected it. This happens with high message volume and slow processing. Increase the worker's pending message limit. 3. **Resource exhaustion**: the worker process ran out of memory or file descriptors. The `workers` KV bucket has a 60-second TTL. If a worker misses two consecutive heartbeats (30s interval), its entry expires and it disappears from the list. The worker itself continues functioning -- the directory is observability-only. **Resolution:** Fix the underlying connectivity or resource issue. In-flight tasks will timeout and be redelivered by NATS to healthy workers. ## Run Inspection The `dagnats run inspect` command is the primary diagnostic tool: ```bash dagnats run inspect ``` It shows: - Run status (pending, running, completed, failed, cancelled) - Each step's status, attempts, output, and error - Workflow definition name and version - Timing information For deeper investigation, query the event history directly: ```bash nats stream get WORKFLOW_HISTORY --subject "history." --last ``` This shows the raw events on the `WORKFLOW_HISTORY` stream for that run, in order. Since DagNats is event-sourced, this stream contains the complete, authoritative history of every state change. ## Telemetry Export Failures If spans are not appearing in your observability backend: 1. **Check `OTEL_EXPORTER_OTLP_ENDPOINT`**: must be a valid OTLP/HTTP base URL (e.g., `http://localhost:4318`) 2. **Check connectivity**: the telemetry exporter must reach `{endpoint}/v1/traces` 3. **Check TELEMETRY stream**: spans are always written to the NATS `TELEMETRY` stream regardless of export. If the stream has messages but exports fail, the issue is between DagNats and your backend. Export failures never affect workflow execution. Telemetry is best-effort. --- # Source: docs/site/content/docs/reference/_index.md {{< cards cols="3" >}} {{< card link="cli-reference" title="CLI Reference" >}} {{< card link="rest-api" title="REST API Reference" >}} {{< card link="wire-protocol" title="Wire Protocol" >}} {{< card link="workflow-schema" title="Workflow Schema" >}} {{< card link="sdk" title="Go SDK" >}} {{< /cards >}} --- # Source: docs/site/content/docs/reference/cli-reference.md Complete reference for the `dagnats` command-line interface. ## Global Flags | Flag | Description | |------|-------------| | `--json` | Output in JSON format (available on all commands) | | `--help`, `-h` | Show usage information | | `--version`, `-v` | Print version | ## Usage ``` dagnats [args] ``` ## run Manage workflow runs. ### run start Start a new workflow run. Optionally provide JSON input, schedule for later, watch until completion, or print output. ``` dagnats run start [input] [flags] [--json] ``` | Argument | Required | Description | |----------|----------|-------------| | `workflow` | Yes | Workflow name to run | | `input` | No | JSON input payload | | Flag | Description | |------|-------------| | `--watch` | Watch run events until completion | | `--output` | Print terminal step output on completion (implies `--watch`) | | `--at=TIME` | Schedule run at RFC3339 time instead of running immediately | **Example:** ```bash dagnats run start code-review '{"pr": 42}' # Started: a1b2c3d4e5f6... dagnats run start deploy --at=2025-01-15T09:00:00Z # Scheduled a1b2c3d4 (run at 2025-01-15T09:00:00Z) dagnats run start code-review '{"pr": 42}' --watch --output # Started: a1b2c3d4... # [events stream...] # Output: # {"issues_found": 3, "summary": "..."} ``` ### run status Show the current status of a workflow run, including per-step breakdown. ``` dagnats run status [--last] [--json] ``` | Argument | Required | Description | |----------|----------|-------------| | `run-id` | Yes* | Run ID (accepts 8+ character prefixes) | | Flag | Description | |------|-------------| | `--last` | Use the most recent run instead of specifying an ID | **Example:** ```bash dagnats run status a1b2c3d4 # Run: a1b2c3d4e5f6... # Workflow: code-review # Status: running # Created: 2025-01-15 09:00:00 UTC # # Steps: # fetch-diff completed (attempts: 1) # lint running (attempts: 1) dagnats run status --last --json ``` ### run inspect Unified debug view combining run status, failure events, and dead-letter entries for a single run. Replaces the three-command workflow of `run status` + `run events` + `dlq list`. ``` dagnats run inspect [--last] [--json] ``` | Flag | Description | |------|-------------| | `--last` | Use the most recent run | **Example:** ```bash dagnats run inspect a1b2c3d4 # Run: a1b2c3d4... # Workflow: code-review # Status: failed # ... # Failures: # 09:15:30 step.failed lint # trace: abc123... # {"error": "lint timeout"} # Dead Letters: # SEQ TASK STEP ERROR # 42 lint.run lint timeout exceeded ``` ### run cancel Cancel a running or scheduled workflow. ``` dagnats run cancel [--last] [--json] ``` **Example:** ```bash dagnats run cancel a1b2c3d4 # Cancelled: a1b2c3d4... dagnats run cancel --last --json # {"run_id":"a1b2c3d4...","cancelled":true} ``` ### run cancel-all Bulk cancel runs matching a workflow and optional filters. ``` dagnats run cancel-all [flags] ``` | Flag | Required | Description | |------|----------|-------------| | `--workflow=ID` | Yes | Workflow ID to filter | | `--status=STATUS` | No | `running`, `pending`, or `all` (default: `all`) | | `--after=TIME` | No | Only runs created after this RFC3339 time | | `--before=TIME` | No | Only runs created before this RFC3339 time | | `--dry-run` | No | Preview matching runs without cancelling | | `--json` | No | JSON output | **Example:** ```bash dagnats run cancel-all --workflow=deploy --status=pending --dry-run # [dry-run] Would cancel 3 runs # a1b2c3d4... # e5f6a7b8... # c9d0e1f2... dagnats run cancel-all --workflow=deploy # Cancelled 3 runs ``` ### run bulk Start multiple runs of a workflow with different inputs. ``` dagnats run bulk [flags] [inputs...] ``` | Flag | Required | Description | |------|----------|-------------| | `--workflow=ID` | Yes | Workflow ID | | `--from-file=PATH` | No | JSONL file with one input per line | | `--json` | No | JSON output | Inputs can be provided as positional arguments, from a JSONL file, or both. **Example:** ```bash dagnats run bulk --workflow=deploy '{"env":"staging"}' '{"env":"prod"}' # Started 2 runs: # a1b2c3d4... # e5f6a7b8... dagnats run bulk --workflow=deploy --from-file=inputs.jsonl --json # {"run_ids":["a1b2...","e5f6..."],"total":2} ``` ### run retry-all Bulk retry failed runs of a workflow. ``` dagnats run retry-all [flags] ``` | Flag | Required | Description | |------|----------|-------------| | `--workflow=ID` | Yes | Workflow ID to filter | | `--mode=MODE` | Yes | `rerun` (fresh start with original input) or `replay` (re-publish DLQ messages) | | `--after=TIME` | No | Only runs created after this RFC3339 time | | `--before=TIME` | No | Only runs created before this RFC3339 time | | `--dry-run` | No | Preview matching runs without retrying | | `--json` | No | JSON output | **Example:** ```bash dagnats run retry-all --workflow=deploy --mode=rerun --dry-run # [dry-run] Would retry 2 runs # a1b2c3d4... # e5f6a7b8... dagnats run retry-all --workflow=deploy --mode=replay # Retried 2 runs # a1b2c3d4 (replayed) # e5f6a7b8 (replayed) ``` ### run signal Send a named signal with a JSON payload to a running workflow. ``` dagnats run signal [--last] [--json] ``` | Argument | Required | Description | |----------|----------|-------------| | `run-id` | Yes* | Run ID (or use `--last`) | | `name` | Yes | Signal name | | `payload` | Yes | JSON payload | **Example:** ```bash dagnats run signal a1b2c3d4 approval '{"approved": true}' # Signal sent: approval dagnats run signal --last done '{}' --json # {"run_id":"a1b2...","signal":"done","sent":true} ``` ### run list List workflow runs with optional filtering. ``` dagnats run list [flags] [--json] ``` | Flag | Description | |------|-------------| | `--workflow=ID` | Filter by workflow ID | | `--status=STATUS` | Filter by status (client-side) | | `--scheduled` | List scheduled (pending) runs instead | **Output columns:** RUN_ID, WORKFLOW, STATUS, CREATED, STEPS **Example:** ```bash dagnats run list --workflow=deploy --status=failed # RUN_ID WORKFLOW STATUS CREATED STEPS # a1b2c3d4 deploy failed 2025-01-15 09:00:00 3 dagnats run list --scheduled # RUN ID WORKFLOW RUN AT STATUS # a1b2c3d4 deploy 2025-01-16T09:00:00Z scheduled ``` ### run events Show the event history for a run with optional filtering. ``` dagnats run events [flags] [--json] ``` | Flag | Description | |------|-------------| | `--last` | Use the most recent run | | `--full` | Show full event data (default truncates to 200 chars) | | `--type=TYPE` | Filter by event type (e.g., `step.completed`) | | `--step=STEP` | Filter by step ID | **Output columns:** TIMESTAMP, TYPE, STEP, TRACE, DATA **Example:** ```bash dagnats run events a1b2c3d4 --type=step.failed --full # TIMESTAMP TYPE STEP TRACE DATA # 2025-01-15 09:05:00 step.failed lint abc123... {"error":"..."} ``` ### run watch Attach to an existing run and tail its events in real time until completion. ``` dagnats run watch [--last] ``` **Example:** ```bash dagnats run watch a1b2c3d4 # [events stream until run completes or fails] ``` ### run output Print the final output of a completed run's terminal steps (steps with no dependents). ``` dagnats run output [--last] [--json] ``` **Example:** ```bash dagnats run output a1b2c3d4 # {"issues_found": 3, "summary": "All checks passed"} dagnats run output --last --json # {"run_id":"a1b2...","status":"completed","outputs":{"post-results":"{...}"}} ``` ### run retry Re-run a workflow using an existing run's workflow ID and input. ``` dagnats run retry [input] [--last] [--json] ``` | Argument | Required | Description | |----------|----------|-------------| | `run-id` | Yes* | Original run ID (or use `--last`) | | `input` | No | Override input (uses original run's input if omitted) | **Example:** ```bash dagnats run retry a1b2c3d4 # Retrying workflow deploy: e5f6a7b8... dagnats run retry --last --json # {"original_run_id":"a1b2...","workflow":"deploy","new_run_id":"e5f6..."} ``` ## dlq Manage the dead-letter queue. ### dlq list List dead-letter messages with optional filtering. ``` dagnats dlq list [flags] [--json] ``` | Flag | Description | |------|-------------| | `--run=RUN_ID` | Filter by run ID | | `--limit=N` | Max messages to fetch (default: 50) | **Output columns:** SEQ, SUBJECT, RUN_ID, STEP_ID, TASK, ERROR, TIMESTAMP **Example:** ```bash dagnats dlq list --limit=10 # SEQ SUBJECT RUN_ID STEP_ID TASK ERROR TIMESTAMP # 42 dead.lint.run a1b2c3d4 lint lint.run timeout exceeded 2025-01-15 09:05:00 ``` ### dlq replay Replay a dead-letter message by sequence number or replay all messages for a run. ``` dagnats dlq replay [--json] dagnats dlq replay --run= [--json] ``` **Example:** ```bash dagnats dlq replay 42 # Replayed dead letter 42 dagnats dlq replay --run=a1b2c3d4 # Replayed dead letter 42 (lint.run) # Replayed 1 dead letters for run a1b2c3d4 ``` ### dlq watch Continuously poll the DLQ and auto-replay messages on an interval. Runs until interrupted (Ctrl+C). ``` dagnats dlq watch [flags] ``` | Flag | Description | |------|-------------| | `--interval=DURATION` | Poll interval (default: `30s`) | | `--max-replays=N` | Max replay attempts per message (default: 3) | | `--run=RUN_ID` | Filter by run ID | | `--json` | JSON output (one line per action) | **Example:** ```bash dagnats dlq watch --interval=10s --max-replays=5 # Watching DLQ every 10s (max 5 replays per message) # [replay 1/5] seq=42 task=lint.run run=a1b2c3d4 err=timeout # [skip exhausted 5/5] seq=42 task=lint.run run=a1b2c3d4 # ^C # Watch summary: 3 replayed, 1 exhausted ``` ## serve Start the embedded NATS server with DagNats engine and API. ``` dagnats serve ``` Reads configuration from `dagnats.yaml` (optional, in current directory) and environment variables. | Environment Variable | Description | |---------------------|-------------| | `DAGNATS_DATA_DIR` | JetStream data directory | | `DAGNATS_HTTP_ADDR` | HTTP API listen address | | `DAGNATS_NATS_PORT` | NATS client port | Run `dagnats config show` to see the effective configuration. **Example:** ```bash DAGNATS_HTTP_ADDR=:8080 dagnats serve ``` ## config View effective server configuration. ### config show Print the resolved configuration from `dagnats.yaml` and environment variables. ``` dagnats config show [--json] ``` **Example:** ```bash dagnats config show # data_dir: /tmp/dagnats # http_addr: :8080 # nats_port: 4222 # monitor_port: (disabled) # leaf_remotes: (none) # leaf_credentials: (none) # max_store_bytes: 1073741824 dagnats config show --json # {"data_dir":"/tmp/dagnats","http_addr":":8080",...} ``` Running `dagnats config` without a subcommand defaults to `show`. ## logs Tail the NATS telemetry log stream in real time. Subscribes to the `TELEMETRY` JetStream stream and prints formatted log records. Blocks until interrupted (Ctrl+C). ``` dagnats logs [flags] ``` | Flag | Description | |------|-------------| | `--level=LEVEL` | Filter by log level (`ERROR`, `WARN`, `INFO`, `DEBUG`) | | `--service=NAME` | Filter by service name | | `--tail=N` | Show last N historical messages and exit (max: 10000) | **Example:** ```bash dagnats logs --level=ERROR # Tailing logs on telemetry.logs.*.ERROR ... # 09:15:30 ERROR engine step failed [run_id=a1b2 step=lint] dagnats logs --tail=20 --service=api # 09:14:00 INFO api started run [run_id=a1b2 workflow=deploy] # 09:14:01 INFO api started run [run_id=c3d4 workflow=review] ``` ## clean Purge data from streams and KV buckets. Cleans runs and dead letters by default. Supports filtering by category and age. ``` dagnats clean [flags] ``` | Flag | Description | |------|-------------| | `--type=CATEGORIES` | Comma-separated categories to clean: `runs`, `dlq`, `otel`, `defs` | | `--older-than=DURATION` | Only clean data older than duration (`7d`, `24h`, `30m`) | | `--dry-run` | Show what would be cleaned without doing it | | `--all` | Clean all categories (runs, dlq, otel, defs) | | `--force` | Skip confirmation prompt | | `--json` | Output result as JSON | **Categories:** | Category | Streams | KV Buckets | |----------|---------|------------| | `runs` | WORKFLOW_HISTORY, TASK_QUEUES, EVENTS, SLEEP_TIMERS | workflow_runs, scheduled_runs, event_waiters, rate_limits, concurrency_tasks, approval_tokens, debounce_state, idempotency_keys, sticky_bindings, singleton_locks | | `dlq` | DEAD_LETTERS | — | | `otel` | TELEMETRY | — | | `defs` | — | workflow_defs | Default (no `--type`): `runs` + `dlq` **Example:** ```bash dagnats clean --dry-run # Would clean: # WORKFLOW_HISTORY stream 1042 msgs 12.3 MiB # TASK_QUEUES stream 0 msgs 0 B # DEAD_LETTERS stream 3 msgs 1.2 KiB # workflow_runs kv 28 keys # Total: 1073 messages, 12.3 MiB dagnats clean --older-than=7d --force # Purged 4 streams, cleared 10 KV buckets dagnats clean --type=otel --force # Purged 1 streams, cleared 0 KV buckets dagnats clean --type=otel,dlq --older-than=30d --dry-run # Would clean: # TELEMETRY stream 500 msgs 5.1 MiB # DEAD_LETTERS stream 1 msgs 512 B # Total: 501 messages, 5.1 MiB dagnats clean --all --force # Purged 6 streams, cleared 11 KV buckets dagnats clean --force --json # {"streams_purged":5,"buckets_cleared":10,"errors":0} ``` --- # Source: docs/site/content/docs/reference/rest-api.md Complete reference for the DagNats control-plane HTTP API. All endpoints return `application/json` responses. Error responses use plain text with the appropriate HTTP status code. ## Base URL The API is served by `dagnats serve` at the configured HTTP address (default `:8080`). ## Runs ### List Runs Retrieve all workflow runs, optionally filtered by workflow. Returns runs sorted by creation time (newest first). ``` GET /runs[?workflow=NAME] ``` | Query Parameter | Description | |----------------|-------------| | `workflow` | Filter by workflow name | **Response:** `200 OK` ```json [ { "run_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "workflow_id": "code-review", "status": "running", "created_at": "2025-01-15T09:00:00Z", "steps": { "fetch-diff": {"status": "completed", "attempts": 1}, "lint": {"status": "running", "attempts": 1} } } ] ``` **curl:** ```bash curl http://localhost:8080/runs?workflow=code-review ``` ### Start Run Start a new workflow run, optionally with input data. If `run_at` is provided and is more than 1 second in the future, the run is scheduled for later execution. ``` POST /runs ``` **Request body:** ```json { "workflow": "code-review", "input": {"pr": 42}, "run_at": "2025-01-16T09:00:00Z" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `workflow` | string | Yes | Workflow name | | `input` | JSON | No | Arbitrary input data | | `run_at` | string | No | RFC3339 time for scheduled execution | **Response (immediate):** `201 Created` ```json { "run_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" } ``` **Response (scheduled):** `201 Created` ```json { "run_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "status": "scheduled" } ``` | Status | Condition | |--------|-----------| | `201` | Run started or scheduled | | `400` | Invalid JSON, workflow not found, or input validation failure | **curl:** ```bash curl -X POST http://localhost:8080/runs \ -H "Content-Type: application/json" \ -d '{"workflow":"code-review","input":{"pr":42}}' ``` ### Get Run Retrieve the current snapshot of a workflow run. ``` GET /runs/{id} ``` **Response:** `200 OK` ```json { "run_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "workflow_id": "code-review", "status": "completed", "created_at": "2025-01-15T09:00:00Z", "steps": { "fetch-diff": { "status": "completed", "attempts": 1, "output": {"files": 3} } } } ``` | Status | Condition | |--------|-----------| | `200` | Run found | | `400` | Missing run ID | | `404` | Run not found | **curl:** ```bash curl http://localhost:8080/runs/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6 ``` ### Cancel Run Cancel a running workflow by publishing a `workflow.cancelled` event. ``` POST /runs/{id}/cancel ``` **Response:** `200 OK` ```json { "status": "cancelled", "run_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" } ``` | Status | Condition | |--------|-----------| | `200` | Cancel event published | | `400` | Missing run ID | | `500` | Publish failure | **curl:** ```bash curl -X POST http://localhost:8080/runs/a1b2c3d4.../cancel ``` ### Send Signal Write a named signal with arbitrary data to a running workflow via the `signals` KV bucket. ``` POST /runs/{id}/signal/{name} ``` The request body is the signal payload (raw bytes, max 1 MiB). **Response:** `200 OK` ```json { "status": "sent", "run_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "signal": "approval" } ``` | Status | Condition | |--------|-----------| | `200` | Signal written to KV | | `400` | Missing run ID or signal name | | `500` | KV write failure | **curl:** ```bash curl -X POST http://localhost:8080/runs/a1b2c3d4.../signal/approval \ -d '{"approved": true}' ``` ### Handle Approval Process an approval or rejection for a human-in-the-loop step. Uses atomic CAS to guarantee exactly-once token consumption. ``` POST /runs/{id}/approval/{step_id}?action=approve&token=TOKEN POST /runs/{id}/approval/{step_id}?action=reject&token=TOKEN ``` | Query Parameter | Required | Description | |----------------|----------|-------------| | `action` | Yes | `approve` or `reject` | | `token` | Yes | One-time approval token | **Request body (optional):** ```json { "comment": "LGTM", "approved_by": "alice" } ``` **Response:** `200 OK` ```json { "status": "approved", "run_id": "a1b2c3d4...", "step": "review" } ``` | Status | Condition | |--------|-----------| | `200` | Approval processed | | `400` | Missing step ID, invalid action | | `401` | Invalid token or token not found/expired | | `409` | Token already consumed | **curl:** ```bash curl -X POST \ "http://localhost:8080/runs/a1b2.../approval/review?action=approve&token=abc123" ``` ## Bulk Operations ### Bulk Run Start multiple workflow runs in a single request. The workflow definition is loaded once and all inputs are validated atomically before any runs start. ``` POST /runs/bulk ``` **Request body:** ```json { "workflow_id": "deploy", "inputs": [ {"env": "staging"}, {"env": "prod"} ] } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `workflow_id` | string | Yes | Workflow name | | `inputs` | []JSON | Yes | Array of input payloads (max 1000) | **Response:** `201 Created` ```json { "run_ids": [ "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", "e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4" ], "total": 2 } ``` | Status | Condition | |--------|-----------| | `201` | All runs started | | `400` | Invalid request, workflow not found, or input validation failure | **curl:** ```bash curl -X POST http://localhost:8080/runs/bulk \ -H "Content-Type: application/json" \ -d '{"workflow_id":"deploy","inputs":[{"env":"staging"},{"env":"prod"}]}' ``` ### Bulk Cancel Cancel multiple runs matching filter criteria. ``` POST /runs/cancel ``` **Request body:** ```json { "workflow_id": "deploy", "status": "running", "after": "2025-01-15T00:00:00Z", "before": "2025-01-16T00:00:00Z", "dry_run": false } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `workflow_id` | string | Yes | Workflow name | | `status` | string | No | `running`, `pending`, or `all` (default: `all`) | | `after` | string | No | RFC3339 lower bound on creation time | | `before` | string | No | RFC3339 upper bound on creation time | | `dry_run` | bool | No | Preview without cancelling | **Response:** `200 OK` ```json { "cancelled": ["a1b2c3d4...", "e5f6a7b8..."], "skipped": [], "total": 2, "dry_run": false } ``` | Status | Condition | |--------|-----------| | `200` | Cancel operation completed | | `400` | Invalid request or too many matching runs (max 1000) | **curl:** ```bash curl -X POST http://localhost:8080/runs/cancel \ -H "Content-Type: application/json" \ -d '{"workflow_id":"deploy","status":"pending","dry_run":true}' ``` ### Bulk Retry Retry failed runs of a workflow. Supports two modes: - **rerun**: Start fresh runs with the original input - **replay**: Re-publish DLQ task messages to resume at the failed step ``` POST /runs/retry ``` **Request body:** ```json { "workflow_id": "deploy", "mode": "rerun", "after": "2025-01-15T00:00:00Z", "dry_run": false } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `workflow_id` | string | Yes | Workflow name | | `mode` | string | Yes | `rerun` or `replay` | | `after` | string | No | RFC3339 lower bound | | `before` | string | No | RFC3339 upper bound | | `dry_run` | bool | No | Preview without retrying | **Response:** `200 OK` ```json { "retried": [ {"original_run_id": "a1b2...", "new_run_id": "c3d4..."} ], "skipped": [], "total": 1, "dry_run": false } ``` For `replay` mode, `new_run_id` is omitted since the original run resumes. | Status | Condition | |--------|-----------| | `200` | Retry operation completed | | `400` | Invalid request, mode, or too many matching runs (max 1000) | **curl:** ```bash curl -X POST http://localhost:8080/runs/retry \ -H "Content-Type: application/json" \ -d '{"workflow_id":"deploy","mode":"rerun"}' ``` ## Error Responses Errors are returned as plain text with the appropriate HTTP status code: ``` HTTP/1.1 400 Bad Request invalid workflow: step "x" depends on non-existent step "y" ``` | Status | Meaning | |--------|---------| | `400` | Bad request (invalid JSON, validation error, missing fields) | | `401` | Unauthorized (invalid approval token) | | `404` | Resource not found | | `405` | Method not allowed | | `409` | Conflict (approval token already consumed) | | `500` | Internal server error | --- # Source: docs/site/content/docs/reference/sdk/_index.md API reference for the public Go packages in the DagNats module (`github.com/danmestas/dagnats`). Each package section includes a hand-written overview and auto-generated API documentation from source. | Package | Description | |---------|-------------| | [dag](dag/) | Pure DAG logic: workflow definitions, validation, state machine | | [worker](worker/) | Task execution framework: worker lifecycle, handlers, task context | | [protocol](protocol/) | Wire types: events, task payloads, resolutions | | [observe](observe/) | Telemetry interfaces: logging, tracing, metrics, error reporting | | [actor](actor/) | Actor runtime with supervision trees | | [server](server/) | Embedded NATS server with programmatic startup | | [bridge](bridge/) | HTTP-to-NATS gateway for non-Go workers | | [httpclient](httpclient/) | Go HTTP reference client for the bridge | | [dagnatstest](dagnatstest/) | Test helpers for one-call test setup | --- # Source: docs/site/content/docs/reference/sdk/actor/_index.md ``` import "github.com/danmestas/dagnats/actor" ``` Lightweight actor runtime for DagNats. Actors are goroutines with channel mailboxes, supervised by parent actors. Pure Go with no NATS imports -- NATS integration lives in the engine package. ## Key Types | Type | Description | |------|-------------| | `Runtime` | Top-level actor system: spawns, stops, and monitors actors | | `Actor` | Interface that user-defined actors implement: `Receive(msg)` | | `Address` | Unique actor identifier with `Type` and `ID` fields | | `Message` | Envelope carrying data to an actor's mailbox | ## Supervision | Type | Description | |------|-------------| | `OneForOne` | Supervision strategy: restart only the failed child, leave siblings running | When an actor panics, the supervisor catches the panic and applies the configured restart strategy. This prevents cascading failures across the actor hierarchy. ## Address Format Addresses are formatted as `type.id` for logging and map keys: ```go addr := actor.Address{Type: "workflow", ID: "run-abc"} fmt.Println(addr.String()) // "workflow.run-abc" ``` ## Usage ```go rt := actor.NewRuntime() // Spawn a supervised actor rt.Spawn(actor.Address{Type: "worker", ID: "w1"}, myActor) // Send a message rt.Send(actor.Address{Type: "worker", ID: "w1"}, payload) // Stop all actors rt.Stop() ``` The engine uses the actor runtime internally to manage per-run orchestration goroutines with supervision. --- # Source: docs/site/content/docs/reference/sdk/actor/api.md # actor ```go import "github.com/danmestas/dagnats/actor" ``` actor/actor.go The actor package provides a lightweight actor runtime for DagNats. Actors are goroutines with channel mailboxes, supervised by parent actors. Pure Go — no NATS imports. NATS integration lives in engine/. ## Index - [Variables](<#variables>) - [type Actor](<#Actor>) - [type Address](<#Address>) - [func \(a Address\) String\(\) string](<#Address.String>) - [type AllForOne](<#AllForOne>) - [func \(s \*AllForOne\) Decide\(err error\) Directive](<#AllForOne.Decide>) - [func \(s \*AllForOne\) RestartScope\(\) RestartScope](<#AllForOne.RestartScope>) - [type Context](<#Context>) - [func \(c \*Context\) Self\(\) Address](<#Context.Self>) - [func \(c \*Context\) Send\(to Address, payload any\) error](<#Context.Send>) - [func \(c \*Context\) Spawn\(addr Address, actor Actor, opts ...SpawnOption\) error](<#Context.Spawn>) - [type Directive](<#Directive>) - [func \(d Directive\) String\(\) string](<#Directive.String>) - [type Lifecycle](<#Lifecycle>) - [type Message](<#Message>) - [type OneForOne](<#OneForOne>) - [func \(s \*OneForOne\) Decide\(err error\) Directive](<#OneForOne.Decide>) - [func \(s \*OneForOne\) RestartScope\(\) RestartScope](<#OneForOne.RestartScope>) - [type RestartScope](<#RestartScope>) - [type RestartTracker](<#RestartTracker>) - [func NewRestartTracker\(limit int, window time.Duration\) \*RestartTracker](<#NewRestartTracker>) - [func \(tr \*RestartTracker\) Allow\(\) bool](<#RestartTracker.Allow>) - [type Runtime](<#Runtime>) - [func NewRuntime\(\) \*Runtime](<#NewRuntime>) - [func \(r \*Runtime\) Send\(to Address, msg Message\) error](<#Runtime.Send>) - [func \(r \*Runtime\) Spawn\(addr Address, actor Actor, opts ...SpawnOption\) error](<#Runtime.Spawn>) - [func \(r \*Runtime\) Stop\(addr Address\) error](<#Runtime.Stop>) - [func \(r \*Runtime\) StopAll\(\)](<#Runtime.StopAll>) - [type SpawnOption](<#SpawnOption>) - [func WithMailboxSize\(size int\) SpawnOption](<#WithMailboxSize>) - [func WithSupervision\(s SupervisionStrategy\) SpawnOption](<#WithSupervision>) - [type SupervisionStrategy](<#SupervisionStrategy>) ## Variables ErrActorNotFound is returned when sending to an unknown address. ```go var ErrActorNotFound = fmt.Errorf("actor: not found") ``` ErrAlreadyExists is returned when spawning at an occupied address. ```go var ErrAlreadyExists = fmt.Errorf("actor: already exists") ``` ErrMailboxFull is returned when an actor's mailbox is at capacity. ```go var ErrMailboxFull = fmt.Errorf("actor: mailbox full") ``` ## type [Actor]() Actor is the interface all actors implement. Receive processes one message at a time — the runtime guarantees sequential delivery. ```go type Actor interface { // Receive processes a single message. Returning an error // triggers the supervision strategy. Receive(ctx *Context, msg Message) error } ``` ## type [Address]() Address uniquely identifies an actor within the runtime. ```go type Address struct { Type string // e.g. "workflow", "worker", "tool" ID string // e.g. run ID, worker ID } ``` ### func \(Address\) [String]() ```go func (a Address) String() string ``` String formats the address as "type.id" for logging and map keys. ## type [AllForOne]() AllForOne restarts all siblings when any child fails. Useful when children have interdependent state. ```go type AllForOne struct { // Decider maps errors to directives. If nil, defaults to Restart. Decider func(error) Directive } ``` ### func \(\*AllForOne\) [Decide]() ```go func (s *AllForOne) Decide(err error) Directive ``` Decide returns the directive for the given error. ### func \(\*AllForOne\) [RestartScope]() ```go func (s *AllForOne) RestartScope() RestartScope ``` RestartScope returns RestartAll — all siblings restart. ## type [Context]() Context provides services to a running actor. ```go type Context struct { // contains filtered or unexported fields } ``` ### func \(\*Context\) [Self]() ```go func (c *Context) Self() Address ``` Self returns this actor's address. ### func \(\*Context\) [Send]() ```go func (c *Context) Send(to Address, payload any) error ``` Send delivers a message to another actor's mailbox. Returns an error if the target actor is not found or its mailbox is full. ### func \(\*Context\) [Spawn]() ```go func (c *Context) Spawn(addr Address, actor Actor, opts ...SpawnOption) error ``` Spawn creates a child actor supervised by this actor. ## type [Directive]() Directive tells a supervisor how to handle a failed child. ```go type Directive int ``` ```go const ( Restart Directive = iota // Restart the failed actor Stop // Stop the actor permanently Escalate // Escalate failure to parent Resume // Ignore the error, continue ) ``` ### func \(Directive\) [String]() ```go func (d Directive) String() string ``` String returns the lowercase name of the directive. ## type [Lifecycle]() Lifecycle extends Actor with optional startup/shutdown hooks. Actors that don't need hooks can implement Actor alone. ```go type Lifecycle interface { // PreStart runs before the actor begins receiving messages. // Errors here trigger supervision (the actor never starts). PreStart(ctx *Context) error // PostStop runs after the actor stops receiving messages. // Used for cleanup. Errors are logged but not supervised. PostStop(ctx *Context) } ``` ## type [Message]() Message is the envelope delivered to an actor's mailbox. ```go type Message struct { From Address Payload any } ``` ## type [OneForOne]() OneForOne restarts only the failed child. The default strategy. ```go type OneForOne struct { // Decider maps errors to directives. If nil, defaults to Restart. Decider func(error) Directive } ``` ### func \(\*OneForOne\) [Decide]() ```go func (s *OneForOne) Decide(err error) Directive ``` Decide returns the directive for the given error. ### func \(\*OneForOne\) [RestartScope]() ```go func (s *OneForOne) RestartScope() RestartScope ``` RestartScope returns RestartOne — only the failed child restarts. ## type [RestartScope]() RestartScope controls which actors restart on a failure. ```go type RestartScope int ``` ```go const ( RestartOne RestartScope = iota // Only the failed actor RestartAll // All children of the supervisor ) ``` ## type [RestartTracker]() RestartTracker enforces a bounded number of restarts within a sliding time window. If the limit is exceeded, the tracker signals that the actor should be stopped or escalated. ```go type RestartTracker struct { // contains filtered or unexported fields } ``` ### func [NewRestartTracker]() ```go func NewRestartTracker(limit int, window time.Duration) *RestartTracker ``` NewRestartTracker creates a tracker allowing limit restarts within the given window. Panics if limit \< 1. ### func \(\*RestartTracker\) [Allow]() ```go func (tr *RestartTracker) Allow() bool ``` Allow returns true if a restart is permitted. It prunes expired entries, then checks the count against the limit. If allowed, it records the restart time. ## type [Runtime]() Runtime manages actor lifecycle, message delivery, and supervision. All methods are safe for concurrent use. ```go type Runtime struct { // contains filtered or unexported fields } ``` ### func [NewRuntime]() ```go func NewRuntime() *Runtime ``` NewRuntime creates an empty actor runtime. ### func \(\*Runtime\) [Send]() ```go func (r *Runtime) Send(to Address, msg Message) error ``` Send delivers a message to an actor's mailbox. Returns ErrActorNotFound if the address is unknown, ErrMailboxFull if the channel is at capacity. ### func \(\*Runtime\) [Spawn]() ```go func (r *Runtime) Spawn(addr Address, actor Actor, opts ...SpawnOption) error ``` Spawn starts a new root\-level actor. Use Context.Spawn for supervised child actors. Returns ErrAlreadyExists if the address is taken. ### func \(\*Runtime\) [Stop]() ```go func (r *Runtime) Stop(addr Address) error ``` Stop gracefully terminates an actor and its children. ### func \(\*Runtime\) [StopAll]() ```go func (r *Runtime) StopAll() ``` StopAll terminates all actors. Used in defer for cleanup. ## type [SpawnOption]() SpawnOption configures actor spawning. ```go type SpawnOption func(*spawnOptions) ``` ### func [WithMailboxSize]() ```go func WithMailboxSize(size int) SpawnOption ``` WithMailboxSize sets the buffered channel capacity. ### func [WithSupervision]() ```go func WithSupervision(s SupervisionStrategy) SpawnOption ``` WithSupervision sets the strategy for this actor's children. ## type [SupervisionStrategy]() SupervisionStrategy decides how to handle a failed child actor. The runtime calls Decide when an actor's Receive returns an error. ```go type SupervisionStrategy interface { // Decide returns the directive for handling the failure. Decide(err error) Directive // RestartScope controls whether a failure restarts one child // or all siblings under the same supervisor. RestartScope() RestartScope } ``` Generated by [gomarkdoc]() --- # Source: docs/site/content/docs/reference/sdk/bridge/_index.md ``` import "github.com/danmestas/dagnats/bridge" ``` HTTP-to-NATS gateway that lets non-Go workers interact with DagNats over HTTP. The bridge exposes three endpoints implementing the full worker lifecycle: connect, poll, and resolve. ## Key Types | Type | Description | |------|-------------| | `Bridge` | The HTTP handler implementing the bridge endpoints | ## Endpoints | Method | Path | Description | |--------|------|-------------| | `POST` | `/v1/workers/connect` | Register worker and maintain SSE heartbeat | | `POST` | `/v1/tasks/poll` | Long-poll for available tasks | | `POST` | `/v1/tasks/{id}/resolve` | Complete, fail, pause, or checkpoint a task | See [Wire Protocol](../../wire-protocol) for full request/response schemas. ## Authentication When the `DAGNATS_BRIDGE_TOKEN` environment variable is set, all requests must include an `Authorization: Bearer {token}` header. When unset, all requests are allowed (development mode). ## Architecture The bridge translates HTTP requests into NATS operations: - **Connect**: Registers the worker in the `workers` KV bucket and sends SSE heartbeats every 25 seconds to maintain the HTTP connection and refresh the KV TTL - **Poll**: Creates ephemeral pull subscriptions on the TASK_QUEUES stream for each requested task type, fetches messages, and stores them in an in-memory ack map keyed by task ID - **Resolve**: Looks up the original NATS message in the ack map, publishes the appropriate event to the history stream, and ACKs or NAKs the message ## Usage The bridge is typically started as part of `server.Run()` and does not need to be used directly. For custom setups: ```go nc, _ := nats.Connect("nats://localhost:4222") js, _ := jetstream.New(nc) tel := observe.NewNoopTelemetry() b := bridge.New(nc, js, tel) http.ListenAndServe(":9090", b) ``` --- # Source: docs/site/content/docs/reference/sdk/bridge/api.md # bridge ```go import "github.com/danmestas/dagnats/bridge" ``` ## Index - [type AckMap](<#AckMap>) - [func NewAckMap\(\) \*AckMap](<#NewAckMap>) - [func \(am \*AckMap\) Count\(\) int64](<#AckMap.Count>) - [func \(am \*AckMap\) Delete\(taskID string\)](<#AckMap.Delete>) - [func \(am \*AckMap\) Load\(taskID string\) \(jetstream.Msg, bool\)](<#AckMap.Load>) - [func \(am \*AckMap\) Store\(taskID string, msg jetstream.Msg\)](<#AckMap.Store>) - [type Bridge](<#Bridge>) - [func NewBridge\(pub \*natsutil.TracingPublisher\) \*Bridge](<#NewBridge>) - [func \(b \*Bridge\) Handler\(\) http.Handler](<#Bridge.Handler>) ## type [AckMap]() AckMap tracks in\-flight tasks for HTTP workers. Maps task\_id \(\{runID\}.\{stepID\}\) to the NATS message so the bridge can ack/nak on behalf of the HTTP client when it resolves the task. Thread\-safe: multiple poll/resolve handlers run concurrently. Bounded by the number of in\-flight tasks across all HTTP workers. ```go type AckMap struct { // contains filtered or unexported fields } ``` ### func [NewAckMap]() ```go func NewAckMap() *AckMap ``` NewAckMap creates an empty AckMap ready for use. ### func \(\*AckMap\) [Count]() ```go func (am *AckMap) Count() int64 ``` Count returns the number of in\-flight tasks. ### func \(\*AckMap\) [Delete]() ```go func (am *AckMap) Delete(taskID string) ``` Delete removes a task from the map after resolution. ### func \(\*AckMap\) [Load]() ```go func (am *AckMap) Load(taskID string) (jetstream.Msg, bool) ``` Load retrieves the NATS message for the given task ID. Returns \(nil, false\) if not found. ### func \(\*AckMap\) [Store]() ```go func (am *AckMap) Store(taskID string, msg jetstream.Msg) ``` Store saves a NATS message keyed by task ID. Panics on empty taskID or nil msg — both are programmer errors. ## type [Bridge]() Bridge is an HTTP\-to\-NATS gateway that lets non\-Go workers interact with DagNats over HTTP. Three deep endpoints expose the full worker lifecycle: connect, poll, and resolve. Authentication: when DAGNATS\_BRIDGE\_TOKEN env var is set, all requests must include Authorization: Bearer \. When unset, all requests are allowed \(development mode\). Every outbound NATS publish goes through \*natsutil.TracingPublisher so W3C trace context \(traceparent / tracestate\) is auto\-injected onto the outgoing message. This continues distributed traces from the inbound HTTP request into the NATS plane — without it, the trace ID would terminate at the HTTP boundary for non\-Go workers. ```go type Bridge struct { // contains filtered or unexported fields } ``` ### func [NewBridge]() ```go func NewBridge(pub *natsutil.TracingPublisher) *Bridge ``` NewBridge creates a Bridge. Panics on nil pub — a programmer error at startup. The TracingPublisher wraps both \*nats.Conn and jetstream.JetStream and is the only legal publish surface inside this package \(CI lint enforces this\). Binds optional KV buckets for checkpoints and signals \(nil if not present\). ### func \(\*Bridge\) [Handler]() ```go func (b *Bridge) Handler() http.Handler ``` Handler returns an http.Handler with the three bridge routes. The mux routes are: - POST /v1/workers/connect - POST /v1/tasks/poll - POST /v1/tasks/ \(resolve, path includes task ID\) Generated by [gomarkdoc]() --- # Source: docs/site/content/docs/reference/sdk/dag/_index.md ``` import "github.com/danmestas/dagnats/dag" ``` Pure DAG logic with zero NATS dependencies. This package defines the workflow data model, validation rules, and state machine for advancing runs through their steps. ## Key Types | Type | Description | |------|-------------| | `WorkflowDef` | Complete workflow definition: name, version, steps, retry policy, concurrency limits, timeout, input/output schemas | | `StepDef` | Individual step within a workflow: task type, dependencies, retry, timeout, conditional skip | | `WorkflowRun` | Runtime snapshot of an executing workflow: status, per-step state, timestamps | | `StepState` | Per-step execution state: status, attempts, iterations, output, error | | `RetryPolicy` | Structured retry configuration: strategy, delays, max attempts | | `AgentLoopConfig` | Configuration for agent-loop steps: max iterations, duration, delay | | `ConcurrencyLimit` | Workflow-level parallelism: max parallel runs and steps | | `ParentCond` | Conditional skip predicate evaluated against parent step output | ## Key Functions | Function | Description | |----------|-------------| | `Validate(def)` | Validates a workflow definition: unique IDs, valid references, acyclic graph (Kahn's algorithm) | | `Advance(run, event)` | State machine: applies an event to a run snapshot and returns the next set of ready steps | | `ResolveRetryPolicy(def, step)` | Resolves the effective retry policy for a step (step > workflow default > legacy) | | `ValidateSchema(schema, data)` | Validates JSON data against a JSON Schema | | `ExtractDotPath(path, data)` | Extracts a value from JSON data using dot-notation path | ## Step Types The `StepType` enum controls execution semantics: | Type | Constant | Description | |------|----------|-------------| | `normal` | `StepTypeNormal` | Runs once, completes or fails | | `agent_loop` | `StepTypeAgentLoop` | Iterates until termination signal | | `sub_workflow` | `StepTypeSubWorkflow` | Delegates to a nested workflow | | `agent` | `StepTypeAgent` | Single autonomous agent execution | | `map` | `StepTypeMap` | Fan-out over input collection | | `sleep` | `StepTypeSleep` | Pause for a duration | | `wait_for_event` | `StepTypeWaitForEvent` | Wait for an external signal | | `approval` | `StepTypeApproval` | Human-in-the-loop gate | | `planner` | `StepTypePlanner` | Dynamic step generation | ## Run Status | Status | Terminal | Description | |--------|----------|-------------| | `pending` | No | Created, not yet started | | `running` | No | At least one step is executing | | `completed` | Yes | All steps completed successfully | | `failed` | Yes | One or more steps failed | | `cancelled` | Yes | Cancelled by user or system | | `compensate_failed` | Yes | Saga compensation failed | ## Usage ```go def := dag.WorkflowDef{ Name: "pipeline", Version: "1.0", Steps: []dag.StepDef{ {ID: "fetch", Task: "fetch", Timeout: 2 * time.Minute, Type: dag.StepTypeNormal}, {ID: "process", Task: "process", Timeout: 5 * time.Minute, Type: dag.StepTypeNormal, DependsOn: []string{"fetch"}}, }, } if err := dag.Validate(def); err != nil { log.Fatal(err) } ``` --- # Source: docs/site/content/docs/reference/sdk/dag/api.md # dag ```go import "github.com/danmestas/dagnats/dag" ``` WorkflowBuilder provides a fluent DSL for constructing WorkflowDefs. Centralizing construction here lets callers express graph topology naturally without touching StepDef internals — the builder enforces invariants and delegates final structural validation to Validate. dag/priority.go Priority resolution for workflow run ordering. ## Index - [Constants](<#constants>) - [func CalculateDelay\(policy RetryPolicy, attempt int\) time.Duration](<#CalculateDelay>) - [func ExtractDotPath\(path string, data \[\]byte\) \(any, error\)](<#ExtractDotPath>) - [func IsComplete\(def WorkflowDef, completed map\[string\]bool\) bool](<#IsComplete>) - [func MarshalConfig\(cfg interface\{\}\) json.RawMessage](<#MarshalConfig>) - [func ResolveInput\(step StepDef, steps map\[string\]StepState, runInput ...json.RawMessage\) \(\[\]byte, error\)](<#ResolveInput>) - [func ResolvePriority\(cfg \*PriorityConfig, input json.RawMessage\) int](<#ResolvePriority>) - [func RunStatusNames\(\) \[\]string](<#RunStatusNames>) - [func Validate\(def WorkflowDef\) error](<#Validate>) - [func ValidateFragment\(fragment \[\]StepDef, cfg PlannerConfig, existingIDs map\[string\]bool\) error](<#ValidateFragment>) - [func ValidateSchema\(schema json.RawMessage, data json.RawMessage\) error](<#ValidateSchema>) - [type AgentLoopConfig](<#AgentLoopConfig>) - [func ParseAgentLoopConfig\(step StepDef\) \(AgentLoopConfig, error\)](<#ParseAgentLoopConfig>) - [type ApprovalConfig](<#ApprovalConfig>) - [func ParseApprovalConfig\(step StepDef\) \(ApprovalConfig, error\)](<#ParseApprovalConfig>) - [type CancelOn](<#CancelOn>) - [type ConcurrencyLimit](<#ConcurrencyLimit>) - [type KeyedRateLimit](<#KeyedRateLimit>) - [type MapConfig](<#MapConfig>) - [func ParseMapConfig\(step StepDef\) \(MapConfig, error\)](<#ParseMapConfig>) - [type MapInstanceState](<#MapInstanceState>) - [type Match](<#Match>) - [func \(m Match\) Resolve\(stepOutputs map\[string\]\[\]byte, workflowInput \[\]byte\) \(ResolvedMatch, error\)](<#Match.Resolve>) - [type MatchOp](<#MatchOp>) - [type ParentCond](<#ParentCond>) - [func SkipIfOutput\(parent StepRef, field, op string, value any\) \*ParentCond](<#SkipIfOutput>) - [func \(c \*ParentCond\) Evaluate\(steps map\[string\]StepState\) bool](<#ParentCond.Evaluate>) - [type PlannerConfig](<#PlannerConfig>) - [func ParsePlannerConfig\(step StepDef\) \(PlannerConfig, error\)](<#ParsePlannerConfig>) - [type PriorityConfig](<#PriorityConfig>) - [type RateLimit](<#RateLimit>) - [type ResolvedMatch](<#ResolvedMatch>) - [func \(m ResolvedMatch\) Evaluate\(eventData \[\]byte\) \(bool, error\)](<#ResolvedMatch.Evaluate>) - [type RespondConfig](<#RespondConfig>) - [func ParseRespondConfig\(step StepDef\) \(RespondConfig, error\)](<#ParseRespondConfig>) - [func \(c RespondConfig\) Defaulted\(\) RespondConfig](<#RespondConfig.Defaulted>) - [type RetryPolicy](<#RetryPolicy>) - [func ResolveRetryPolicy\(wfDef WorkflowDef, stepDef StepDef\) \*RetryPolicy](<#ResolveRetryPolicy>) - [type RetryStrategy](<#RetryStrategy>) - [func \(s RetryStrategy\) MarshalJSON\(\) \(\[\]byte, error\)](<#RetryStrategy.MarshalJSON>) - [func \(s RetryStrategy\) String\(\) string](<#RetryStrategy.String>) - [func \(s \*RetryStrategy\) UnmarshalJSON\(data \[\]byte\) error](<#RetryStrategy.UnmarshalJSON>) - [type RunStatus](<#RunStatus>) - [func ParseRunStatus\(s string\) \(RunStatus, error\)](<#ParseRunStatus>) - [func \(r RunStatus\) IsTerminal\(\) bool](<#RunStatus.IsTerminal>) - [func \(r RunStatus\) MarshalJSON\(\) \(\[\]byte, error\)](<#RunStatus.MarshalJSON>) - [func \(r RunStatus\) String\(\) string](<#RunStatus.String>) - [func \(r \*RunStatus\) UnmarshalJSON\(data \[\]byte\) error](<#RunStatus.UnmarshalJSON>) - [type SingletonConfig](<#SingletonConfig>) - [type SingletonMode](<#SingletonMode>) - [type SleepConfig](<#SleepConfig>) - [func ParseSleepConfig\(step StepDef\) \(SleepConfig, error\)](<#ParseSleepConfig>) - [type StepDef](<#StepDef>) - [func EffectiveSteps\(def WorkflowDef, run WorkflowRun\) \[\]StepDef](<#EffectiveSteps>) - [func NamespaceFragment\(plannerID string, fragment \[\]StepDef\) \[\]StepDef](<#NamespaceFragment>) - [func ResolveCompensateChain\(def WorkflowDef, completed map\[string\]bool, failedStepID string\) \[\]StepDef](<#ResolveCompensateChain>) - [func ResolveReady\(def WorkflowDef, completed map\[string\]bool, queued map\[string\]bool\) \[\]StepDef](<#ResolveReady>) - [func ResolveSkipped\(def WorkflowDef, completed map\[string\]bool, queued map\[string\]bool, steps map\[string\]StepState\) \[\]StepDef](<#ResolveSkipped>) - [type StepRef](<#StepRef>) - [func \(r StepRef\) After\(refs ...StepRef\) StepRef](<#StepRef.After>) - [func \(r StepRef\) Compensate\(target StepRef\) StepRef](<#StepRef.Compensate>) - [func \(r StepRef\) ID\(\) string](<#StepRef.ID>) - [func \(r StepRef\) OnFailure\(target StepRef\) StepRef](<#StepRef.OnFailure>) - [func \(r StepRef\) SkipIf\(cond \*ParentCond\) StepRef](<#StepRef.SkipIf>) - [func \(r StepRef\) WithDetach\(\) StepRef](<#StepRef.WithDetach>) - [func \(r StepRef\) WithKeyedRateLimit\(krl KeyedRateLimit\) StepRef](<#StepRef.WithKeyedRateLimit>) - [func \(r StepRef\) WithLoopDelay\(d time.Duration\) StepRef](<#StepRef.WithLoopDelay>) - [func \(r StepRef\) WithMaxDuration\(d time.Duration\) StepRef](<#StepRef.WithMaxDuration>) - [func \(r StepRef\) WithMaxItems\(n int\) StepRef](<#StepRef.WithMaxItems>) - [func \(r StepRef\) WithMaxIterations\(n int\) StepRef](<#StepRef.WithMaxIterations>) - [func \(r StepRef\) WithRateLimit\(rl RateLimit\) StepRef](<#StepRef.WithRateLimit>) - [func \(r StepRef\) WithRetries\(n int\) StepRef](<#StepRef.WithRetries>) - [func \(r StepRef\) WithTaskConcurrency\(max int\) StepRef](<#StepRef.WithTaskConcurrency>) - [func \(r StepRef\) WithTimeout\(d time.Duration\) StepRef](<#StepRef.WithTimeout>) - [type StepState](<#StepState>) - [type StepStatus](<#StepStatus>) - [func \(s StepStatus\) MarshalJSON\(\) \(\[\]byte, error\)](<#StepStatus.MarshalJSON>) - [func \(s StepStatus\) String\(\) string](<#StepStatus.String>) - [func \(s \*StepStatus\) UnmarshalJSON\(data \[\]byte\) error](<#StepStatus.UnmarshalJSON>) - [type StepType](<#StepType>) - [func \(s StepType\) MarshalJSON\(\) \(\[\]byte, error\)](<#StepType.MarshalJSON>) - [func \(s StepType\) String\(\) string](<#StepType.String>) - [func \(s \*StepType\) UnmarshalJSON\(data \[\]byte\) error](<#StepType.UnmarshalJSON>) - [type StickyStrategy](<#StickyStrategy>) - [type SubWorkflowConfig](<#SubWorkflowConfig>) - [func ParseSubWorkflowConfig\(step StepDef\) \(SubWorkflowConfig, error\)](<#ParseSubWorkflowConfig>) - [type WaitForEventOpts](<#WaitForEventOpts>) - [func ParseWaitForEventConfig\(step StepDef\) \(WaitForEventOpts, error\)](<#ParseWaitForEventConfig>) - [type Warning](<#Warning>) - [func ValidateRespondReachability\(def WorkflowDef, hasHTTPTrigger bool\) \[\]Warning](<#ValidateRespondReachability>) - [type WorkflowBuilder](<#WorkflowBuilder>) - [func NewWorkflow\(name string\) \*WorkflowBuilder](<#NewWorkflow>) - [func \(b \*WorkflowBuilder\) Agent\(id, task string, metadata map\[string\]string\) StepRef](<#WorkflowBuilder.Agent>) - [func \(b \*WorkflowBuilder\) AgentLoop\(id, task string\) StepRef](<#WorkflowBuilder.AgentLoop>) - [func \(b \*WorkflowBuilder\) Approval\(id string, cfg ApprovalConfig\) StepRef](<#WorkflowBuilder.Approval>) - [func \(b \*WorkflowBuilder\) Build\(\) \(WorkflowDef, error\)](<#WorkflowBuilder.Build>) - [func \(b \*WorkflowBuilder\) CancelOn\(event string, match Match\) \*WorkflowBuilder](<#WorkflowBuilder.CancelOn>) - [func \(b \*WorkflowBuilder\) CancelOnWithTimeout\(event string, match Match, timeout time.Duration\) \*WorkflowBuilder](<#WorkflowBuilder.CancelOnWithTimeout>) - [func \(b \*WorkflowBuilder\) DependsOn\(ids ...string\) \*WorkflowBuilder](<#WorkflowBuilder.DependsOn>) - [func \(b \*WorkflowBuilder\) Map\(id, taskType string\) StepRef](<#WorkflowBuilder.Map>) - [func \(b \*WorkflowBuilder\) Name\(\) string](<#WorkflowBuilder.Name>) - [func \(b \*WorkflowBuilder\) Planner\(id, task string, cfg PlannerConfig\) StepRef](<#WorkflowBuilder.Planner>) - [func \(b \*WorkflowBuilder\) Sleep\(id string, duration time.Duration\) StepRef](<#WorkflowBuilder.Sleep>) - [func \(b \*WorkflowBuilder\) SubWorkflow\(id, workflow string\) StepRef](<#WorkflowBuilder.SubWorkflow>) - [func \(b \*WorkflowBuilder\) Task\(id, task string\) StepRef](<#WorkflowBuilder.Task>) - [func \(b \*WorkflowBuilder\) Version\(v string\) \*WorkflowBuilder](<#WorkflowBuilder.Version>) - [func \(b \*WorkflowBuilder\) WaitForEvent\(id string, opts WaitForEventOpts\) StepRef](<#WorkflowBuilder.WaitForEvent>) - [func \(b \*WorkflowBuilder\) WithConcurrency\(maxRuns, maxSteps int\) \*WorkflowBuilder](<#WorkflowBuilder.WithConcurrency>) - [func \(b \*WorkflowBuilder\) WithIdempotencyKey\(dotPath string\) \*WorkflowBuilder](<#WorkflowBuilder.WithIdempotencyKey>) - [func \(b \*WorkflowBuilder\) WithMaxDuration\(d time.Duration\) \*WorkflowBuilder](<#WorkflowBuilder.WithMaxDuration>) - [func \(b \*WorkflowBuilder\) WithMaxIterations\(n int\) \*WorkflowBuilder](<#WorkflowBuilder.WithMaxIterations>) - [func \(b \*WorkflowBuilder\) WithPriority\(cfg PriorityConfig\) \*WorkflowBuilder](<#WorkflowBuilder.WithPriority>) - [func \(b \*WorkflowBuilder\) WithSingleton\(mode SingletonMode\) \*WorkflowBuilder](<#WorkflowBuilder.WithSingleton>) - [func \(b \*WorkflowBuilder\) WithSingletonKey\(mode SingletonMode, key string\) \*WorkflowBuilder](<#WorkflowBuilder.WithSingletonKey>) - [func \(b \*WorkflowBuilder\) WithSticky\(s StickyStrategy\) \*WorkflowBuilder](<#WorkflowBuilder.WithSticky>) - [func \(b \*WorkflowBuilder\) WithTimeout\(d time.Duration\) \*WorkflowBuilder](<#WorkflowBuilder.WithTimeout>) - [type WorkflowDef](<#WorkflowDef>) - [func EffectiveDef\(def WorkflowDef, run WorkflowRun\) WorkflowDef](<#EffectiveDef>) - [func WithSchemas\[I, O any\]\(def WorkflowDef\) WorkflowDef](<#WithSchemas>) - [type WorkflowRun](<#WorkflowRun>) - [func NewWorkflowRun\(def WorkflowDef, runID string\) WorkflowRun](<#NewWorkflowRun>) - [func \(r WorkflowRun\) EffectiveTime\(\) time.Time](<#WorkflowRun.EffectiveTime>) ## Constants Warning kinds. Stable strings — consumers \(registration response, CLI surfaces\) may switch on these. ```go const ( WarnMissingRespond = "missing_respond" WarnDuplicateRespond = "duplicate_respond" WarnMissingSchemas = "missing_schemas" ) ``` RetryAttemptCountMax bounds MaxAttempts on any retry policy. Validate rejects larger values at definition time so the engine's retry scheduler can assert the bound as a true unreachable invariant rather than a config\-reachable one \(see internal/engine scheduleRetryBackoff\). ```go const RetryAttemptCountMax = 100_000 ``` ## func [CalculateDelay]() ```go func CalculateDelay(policy RetryPolicy, attempt int) time.Duration ``` CalculateDelay returns the delay before the next retry attempt. Attempt is 1\-based \(first retry = attempt 1\). ## func [ExtractDotPath]() ```go func ExtractDotPath(path string, data []byte) (any, error) ``` ExtractDotPath extracts a value from JSON data using a dot\-separated path. The path walks nested objects: "data.user.id" accesses data\["user"\]\["id"\]. Returns the raw value \(string, number, bool, map, array, or nil\). Panics if path is empty \(programmer error\); returns error for missing keys. ## func [IsComplete]() ```go func IsComplete(def WorkflowDef, completed map[string]bool) bool ``` IsComplete returns true when every step in the definition has been completed or skipped. Auxiliary steps \(OnFailure/Compensate targets\) that were never triggered don't block completion — they are expected to remain Pending in the happy path. ## func [MarshalConfig]() ```go func MarshalConfig(cfg interface{}) json.RawMessage ``` MarshalConfig serializes a config struct into raw JSON for StepDef.Config. Panics on nil or marshal failure — both are programmer errors that should be caught at build time. ## func [ResolveInput]() ```go func ResolveInput(step StepDef, steps map[string]StepState, runInput ...json.RawMessage) ([]byte, error) ``` ResolveInput builds the input payload for a step from its upstream outputs. No deps → forward the run\-level input so root steps receive caller data. Single dep → pass that step's output through unchanged. Fan\-in → map of dep ID → raw output, so the task can address each upstream. ## func [ResolvePriority]() ```go func ResolvePriority(cfg *PriorityConfig, input json.RawMessage) int ``` ResolvePriority computes the priority offset from input data. ## func [RunStatusNames]() ```go func RunStatusNames() []string ``` RunStatusNames returns the canonical lowercase string names for every RunStatus value, in numeric order. Callers \(CLI help text, API docs, error messages\) reuse this rather than maintaining their own copy of the slice. ## func [Validate]() ```go func Validate(def WorkflowDef) error ``` Validate checks a WorkflowDef for structural correctness before any run is created. Catching these errors at definition time defines them out of existence at runtime — the engine can safely assume every WorkflowDef it receives has already passed Validate. ## func [ValidateFragment]() ```go func ValidateFragment(fragment []StepDef, cfg PlannerConfig, existingIDs map[string]bool) error ``` ValidateFragment checks a planner\-generated DAG fragment against bounds. All IDs must be unique and not collide with existing steps. Tasks must be non\-empty and in AllowedTasks if configured. Dependencies must reference only within\-fragment steps. ## func [ValidateSchema]() ```go func ValidateSchema(schema json.RawMessage, data json.RawMessage) error ``` ValidateSchema validates data against a JSON Schema subset. Supports: type \(string, number, boolean, object, array\), required, properties \(nested\). Returns nil if schema is nil. ## type [AgentLoopConfig]() AgentLoopConfig bounds the iterative behavior of an agent\-loop step. Both limits are enforced: whichever fires first terminates the loop. BudgetWarnAt and BudgetForceAt are advisory — the engine does not enforce them. Workers read these to inject budget warnings into LLM conversations \(e.g., "you have N iterations left"\). ```go type AgentLoopConfig struct { MaxIterations int `json:"max_iterations"` MaxDuration time.Duration `json:"max_duration,omitempty"` LoopDelay time.Duration `json:"loop_delay,omitempty"` BudgetWarnAt int `json:"budget_warn_at,omitempty"` BudgetForceAt int `json:"budget_force_at,omitempty"` } ``` ### func [ParseAgentLoopConfig]() ```go func ParseAgentLoopConfig(step StepDef) (AgentLoopConfig, error) ``` ParseAgentLoopConfig extracts AgentLoopConfig from a StepDef's Config field. Returns an error if the step type is wrong, Config is nil, or the JSON is malformed. ## type [ApprovalConfig]() ApprovalConfig holds configuration for approval gate steps. Subject is the NATS subject where a notification is published when the approval is requested. External systems subscribe to this subject and present approve/reject actions to humans. ```go type ApprovalConfig struct { Timeout time.Duration `json:"timeout"` Subject string `json:"subject"` Description string `json:"description,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` } ``` ### func [ParseApprovalConfig]() ```go func ParseApprovalConfig(step StepDef) (ApprovalConfig, error) ``` ParseApprovalConfig extracts ApprovalConfig from a StepDef's Config field. Returns an error if the step type is wrong, Config is nil, or the JSON is malformed. ## type [CancelOn]() CancelOn specifies an event that cancels a running workflow. ```go type CancelOn struct { Event string `json:"event"` Match Match `json:"match"` Timeout time.Duration `json:"timeout,omitempty"` } ``` ## type [ConcurrencyLimit]() ConcurrencyLimit controls parallel execution at workflow and step level. ```go type ConcurrencyLimit struct { MaxRuns int `json:"max_runs,omitempty"` MaxSteps int `json:"max_steps,omitempty"` } ``` ## type [KeyedRateLimit]() KeyedRateLimit configures per\-key rate limiting using a dot\-path expression. ```go type KeyedRateLimit struct { Key string `json:"key"` Limit int `json:"limit"` Period time.Duration `json:"period"` Units int `json:"units"` } ``` ## type [MapConfig]() MapConfig controls parallel execution for map steps that fan out. MaxItems caps the array size to prevent unbounded parallelism. ```go type MapConfig struct { MaxItems int `json:"max_items"` } ``` ### func [ParseMapConfig]() ```go func ParseMapConfig(step StepDef) (MapConfig, error) ``` ParseMapConfig extracts MapConfig from a StepDef's Config field. ## type [MapInstanceState]() MapInstanceState tracks runtime state for one map item execution. Each instance represents one parallel task invocation for a map step. ```go type MapInstanceState struct { Status StepStatus `json:"status"` Output json.RawMessage `json:"output,omitempty"` Error string `json:"error,omitempty"` } ``` ## type [Match]() Match is the builder\-time type. Both sides are dot\-path strings. ```go type Match struct { Left string `json:"left"` Op MatchOp `json:"op"` Right string `json:"right"` } ``` ### func \(Match\) [Resolve]() ```go func (m Match) Resolve(stepOutputs map[string][]byte, workflowInput []byte) (ResolvedMatch, error) ``` Resolve converts a builder\-time Match to a runtime ResolvedMatch. ## type [MatchOp]() MatchOp defines comparison operators for event matching. ```go type MatchOp string ``` ```go const MatchOpEq MatchOp = "eq" ``` ## type [ParentCond]() ParentCond evaluates a simple comparison on a parent step's JSON output. Field is a top\-level key in the output JSON object. Op is one of the six comparison operators: ==, \!=, \<, \>, \<=, \>=. Value is the comparison target. Evaluated purely — no I/O, no side effects. ```go type ParentCond struct { StepID string `json:"step_id"` Field string `json:"field"` Op string `json:"op"` Value any `json:"value"` } ``` ### func [SkipIfOutput]() ```go func SkipIfOutput(parent StepRef, field, op string, value any) *ParentCond ``` SkipIfOutput creates a ParentCond for use with StepRef.SkipIf. The parent must be in the step's DependsOn list \(enforced by Validate\). ### func \(\*ParentCond\) [Evaluate]() ```go func (c *ParentCond) Evaluate(steps map[string]StepState) bool ``` Evaluate returns true when the condition is satisfied against the given step states. Returns false if the parent step has no output, the field is missing, or the types are not comparable. ## type [PlannerConfig]() PlannerConfig holds configuration for planner steps that generate DAG fragments at runtime. MaxSteps bounds the number of steps the planner can emit; MaxDepth bounds the longest dependency chain. AllowedTasks restricts which task types the fragment may reference. ```go type PlannerConfig struct { MaxSteps int `json:"max_steps"` MaxDepth int `json:"max_depth,omitempty"` AllowedTasks []string `json:"allowed_tasks,omitempty"` } ``` ### func [ParsePlannerConfig]() ```go func ParsePlannerConfig(step StepDef) (PlannerConfig, error) ``` ParsePlannerConfig extracts PlannerConfig from a StepDef's Config field. Returns an error if the step type is wrong, Config is nil, or the JSON is malformed. ## type [PriorityConfig]() PriorityConfig controls run ordering when concurrency limits create backlogs. Key is a dot\-path into input data. ```go type PriorityConfig struct { Key string `json:"key"` Rules map[string]int `json:"rules"` DefaultOffset int `json:"default_offset"` } ``` ## type [RateLimit]() RateLimit configures global per\-task\-type rate limiting. ```go type RateLimit struct { Limit int `json:"limit"` Period time.Duration `json:"period"` } ``` ## type [ResolvedMatch]() ResolvedMatch is the runtime type stored in KV waiter entries. Right is resolved to a concrete value when the waiter is created. ```go type ResolvedMatch struct { Left string `json:"left"` Op MatchOp `json:"op"` Right any `json:"right"` } ``` ### func \(ResolvedMatch\) [Evaluate]() ```go func (m ResolvedMatch) Evaluate(eventData []byte) (bool, error) ``` Evaluate checks if the match condition holds against event JSON data. ## type [RespondConfig]() RespondConfig is the configuration for a StepTypeRespond step per ADR\-013. A respond step publishes the workflow's outward\-facing HTTP response on dagnats.http.response.\. The step is a side effect, not a return: subsequent steps in the DAG continue to run after the response has been dispatched. Status defaults to 200 and ContentType defaults to "application/json" via Defaulted\(\) — the engine applies defaults at execution time so authors can omit the boilerplate. BodyFrom is a dotpath into prior step output; empty means "use the immediate upstream step's output". ```go type RespondConfig struct { Status int `json:"status,omitempty"` Headers map[string]string `json:"headers,omitempty"` BodyFrom string `json:"body_from,omitempty"` ContentType string `json:"content_type,omitempty"` } ``` ### func [ParseRespondConfig]() ```go func ParseRespondConfig(step StepDef) (RespondConfig, error) ``` ParseRespondConfig extracts RespondConfig from a StepDef's Config field. Mirrors the other Parse\*Config helpers in this package. Programmer\-error invariants \(empty step ID, mismatched type\) trigger a panic — the workflow validator catches these one frame up; reaching this function with a malformed step means a registration path skipped Validate\(\), which is itself a bug to fix. ### func \(RespondConfig\) [Defaulted]() ```go func (c RespondConfig) Defaulted() RespondConfig ``` Defaulted returns a copy of c with zero\-valued fields filled from the documented defaults. Callers should use this exactly once at execute time; storing the defaulted copy back into the workflow definition would obscure the author's intent and lose the "default" signal on subsequent re\-reads. ## type [RetryPolicy]() RetryPolicy configures retry behavior for a step or as a workflow default. MaxAttempts=0 means no retries. ```go type RetryPolicy struct { MaxAttempts int `json:"max_attempts"` Strategy RetryStrategy `json:"strategy"` InitialDelay time.Duration `json:"initial_delay"` MaxDelay time.Duration `json:"max_delay"` Multiplier float64 `json:"multiplier,omitempty"` } ``` ### func [ResolveRetryPolicy]() ```go func ResolveRetryPolicy(wfDef WorkflowDef, stepDef StepDef) *RetryPolicy ``` ResolveRetryPolicy returns the effective retry policy for a step. Resolution order: step Retry → workflow DefaultRetry → legacy Retries field → nil \(no retries\). ## type [RetryStrategy]() RetryStrategy selects the backoff algorithm for step retries. ```go type RetryStrategy int ``` ```go const ( RetryFixed RetryStrategy = iota // Same delay every attempt RetryLinear // delay * attempt RetryExponential // delay * multiplier^(attempt-1) ) ``` ### func \(RetryStrategy\) [MarshalJSON]() ```go func (s RetryStrategy) MarshalJSON() ([]byte, error) ``` ### func \(RetryStrategy\) [String]() ```go func (s RetryStrategy) String() string ``` ### func \(\*RetryStrategy\) [UnmarshalJSON]() ```go func (s *RetryStrategy) UnmarshalJSON(data []byte) error ``` ## type [RunStatus]() RunStatus tracks the lifecycle of a workflow run. The zero value \(pending\) is a safe default — a newly created run has not yet been claimed by the engine. ```go type RunStatus int ``` ```go const ( RunStatusPending RunStatus = iota RunStatusRunning RunStatusCompleted RunStatusFailed RunStatusCancelled RunStatusCompensated RunStatusCompensateFailed ) ``` ### func [ParseRunStatus]() ```go func ParseRunStatus(s string) (RunStatus, error) ``` ParseRunStatus parses an operator\-supplied string into a RunStatus. Case\-insensitive, trims surrounding whitespace. On no match it returns a descriptive error listing the valid set, so callers never need to maintain their own copy of the status names. Single deep entry point: every CLI flag, API parameter, or other surface that accepts a run\-status string MUST funnel through here. ### func \(RunStatus\) [IsTerminal]() ```go func (r RunStatus) IsTerminal() bool ``` IsTerminal returns true for statuses that represent a finished run. ### func \(RunStatus\) [MarshalJSON]() ```go func (r RunStatus) MarshalJSON() ([]byte, error) ``` ### func \(RunStatus\) [String]() ```go func (r RunStatus) String() string ``` ### func \(\*RunStatus\) [UnmarshalJSON]() ```go func (r *RunStatus) UnmarshalJSON(data []byte) error ``` ## type [SingletonConfig]() SingletonConfig constrains runs to one\-at\-a\-time per key. ```go type SingletonConfig struct { Mode SingletonMode `json:"mode"` Key string `json:"key,omitempty"` } ``` ## type [SingletonMode]() SingletonMode determines behavior on duplicate detection. String type for safe JSON serialization in KV storage. ```go type SingletonMode string ``` ```go const ( SingletonModeSkip SingletonMode = "skip" SingletonModeCancel SingletonMode = "cancel" ) ``` ## type [SleepConfig]() SleepConfig holds configuration for sleep steps. Duration is the durable delay the engine waits before completing. ```go type SleepConfig struct { Duration time.Duration `json:"duration"` } ``` ### func [ParseSleepConfig]() ```go func ParseSleepConfig(step StepDef) (SleepConfig, error) ``` ParseSleepConfig extracts SleepConfig from a StepDef's Config field. ## type [StepDef]() StepDef is the immutable declaration of a single step within a WorkflowDef. DependsOn lists step IDs that must complete before this step is queued. Config holds type\-specific configuration as raw JSON — use ParseXxxConfig helpers to extract typed structs. ```go type StepDef struct { ID string `json:"id"` Task string `json:"task"` DependsOn []string `json:"depends_on,omitempty"` Retries int `json:"retries,omitempty"` Timeout time.Duration `json:"timeout"` Type StepType `json:"type"` Config json.RawMessage `json:"config,omitempty"` SkipIf *ParentCond `json:"skip_if,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` Retry *RetryPolicy `json:"retry,omitempty"` WorkerGroup string `json:"worker_group,omitempty"` OnFailure string `json:"on_failure,omitempty"` Compensate string `json:"compensate,omitempty"` RateLimit *RateLimit `json:"rate_limit,omitempty"` KeyedRateLimit *KeyedRateLimit `json:"keyed_rate_limit,omitempty"` MaxTaskConcurrency int `json:"max_task_concurrency,omitempty"` Singleton bool `json:"singleton,omitempty"` // RequiredCapabilities names runtime capabilities the step's handler // requires (e.g. "control-plane"). The orchestrator copies these into // the TaskPayload at enqueue time; the worker uses them to decide // whether to grant a gated handle. Zero value (nil) = no capabilities // declared, which is today's behavior. Additive — omitempty keeps old // defs byte-identical on the wire. RequiredCapabilities []string `json:"required_capabilities,omitempty"` } ``` ### func [EffectiveSteps]() ```go func EffectiveSteps(def WorkflowDef, run WorkflowRun) []StepDef ``` EffectiveSteps returns the combined static \+ dynamic steps for a running workflow. When no dynamic steps exist, the original slice is returned unchanged to avoid allocation. ### func [NamespaceFragment]() ```go func NamespaceFragment(plannerID string, fragment []StepDef) []StepDef ``` NamespaceFragment prefixes all step IDs and DependsOn references with the planner step ID to prevent collisions. Also forces all steps to StepTypeNormal — planners cannot spawn nested planners. ### func [ResolveCompensateChain]() ```go func ResolveCompensateChain(def WorkflowDef, completed map[string]bool, failedStepID string) []StepDef ``` ResolveCompensateChain returns compensate steps for completed steps in reverse topological order. Each step \(except the first\) gets a DependsOn pointing to the previous — this lets the engine enforce sequential execution using existing resolution logic. ### func [ResolveReady]() ```go func ResolveReady(def WorkflowDef, completed map[string]bool, queued map[string]bool) []StepDef ``` ResolveReady returns steps whose dependencies are fully satisfied \(completed or skipped\) and that have not yet been queued or completed. Both completed and queued are checked to avoid double\-dispatching a step already in flight. ### func [ResolveSkipped]() ```go func ResolveSkipped(def WorkflowDef, completed map[string]bool, queued map[string]bool, steps map[string]StepState) []StepDef ``` ResolveSkipped returns steps whose dependencies are satisfied AND whose SkipIf condition evaluates to true. These should be marked Skipped by the orchestrator instead of being enqueued. Steps without SkipIf are never returned here. ## type [StepRef]() StepRef is a compile\-time\-safe handle to a step within a WorkflowBuilder. Returned by Task\(\) and AgentLoop\(\), it replaces string\-based DependsOn with typed references that cannot silently miswire dependencies. The zero value is unusable — only the builder constructs valid StepRefs. ```go type StepRef struct { // contains filtered or unexported fields } ``` ### func \(StepRef\) [After]() ```go func (r StepRef) After(refs ...StepRef) StepRef ``` After declares that this step depends on the given steps. Compile\-time safe: passing a StepRef from a different builder panics immediately rather than producing a corrupt WorkflowDef discovered at validation time. ### func \(StepRef\) [Compensate]() ```go func (r StepRef) Compensate(target StepRef) StepRef ``` Compensate designates a step to reverse this step's side effects during saga compensation. Runs in reverse topo order when a downstream step fails permanently. Panics on zero\-value StepRef, cross\-builder refs, or self\-reference. ### func \(StepRef\) [ID]() ```go func (r StepRef) ID() string ``` ID returns the step's string identifier. Useful for bridge code that still needs the raw ID \(e.g. serialization boundaries\). ### func \(StepRef\) [OnFailure]() ```go func (r StepRef) OnFailure(target StepRef) StepRef ``` OnFailure designates a step to run when this step fails permanently \(retries exhausted\). The target step receives error context as input. Panics on zero\-value StepRef, cross\-builder refs, or self\-reference. ### func \(StepRef\) [SkipIf]() ```go func (r StepRef) SkipIf(cond *ParentCond) StepRef ``` SkipIf sets a condition that, when true, causes this step to be skipped instead of executed. The condition's StepID must be in DependsOn \(enforced by Validate\). Skipped steps are treated as "satisfied" for downstream deps. ### func \(StepRef\) [WithDetach]() ```go func (r StepRef) WithDetach() StepRef ``` WithDetach marks a SubWorkflow step as detached — the parent step completes immediately after spawning the child, without waiting for the child to finish. Panics if called on a non\-SubWorkflow step. ### func \(StepRef\) [WithKeyedRateLimit]() ```go func (r StepRef) WithKeyedRateLimit(krl KeyedRateLimit) StepRef ``` WithKeyedRateLimit sets per\-key rate limiting on this step. ### func \(StepRef\) [WithLoopDelay]() ```go func (r StepRef) WithLoopDelay(d time.Duration) StepRef ``` WithLoopDelay configures the delay between agent loop iterations. The orchestrator waits this duration before re\-enqueuing the step. Useful for rate\-limited APIs where you need spacing between calls. ### func \(StepRef\) [WithMaxDuration]() ```go func (r StepRef) WithMaxDuration(d time.Duration) StepRef ``` WithMaxDuration configures the wall\-clock bound on an AgentLoop step. ### func \(StepRef\) [WithMaxItems]() ```go func (r StepRef) WithMaxItems(n int) StepRef ``` WithMaxItems configures the maximum number of items to process for a Map step. Calling this on a non\-Map step or with n \<= 0 panics — these are programmer errors that should be caught immediately. ### func \(StepRef\) [WithMaxIterations]() ```go func (r StepRef) WithMaxIterations(n int) StepRef ``` WithMaxIterations configures the iteration bound on an AgentLoop step. Panics if the step is not an AgentLoop — calling this on a Task step indicates a logic error in the caller. ### func \(StepRef\) [WithRateLimit]() ```go func (r StepRef) WithRateLimit(rl RateLimit) StepRef ``` WithRateLimit sets global per\-task\-type rate limiting on this step. ### func \(StepRef\) [WithRetries]() ```go func (r StepRef) WithRetries(n int) StepRef ``` WithRetries sets the maximum number of retry attempts for this step. Zero means no retries — the step fails permanently on first error. ### func \(StepRef\) [WithTaskConcurrency]() ```go func (r StepRef) WithTaskConcurrency(max int) StepRef ``` WithTaskConcurrency sets the global per\-task\-type concurrency limit on this step. At most max tasks of this type will execute concurrently across all workflow runs. ### func \(StepRef\) [WithTimeout]() ```go func (r StepRef) WithTimeout(d time.Duration) StepRef ``` WithTimeout sets the per\-attempt timeout on this step. ## type [StepState]() StepState captures mutable runtime state for one step in a run. Output is kept as raw bytes to remain payload\-agnostic. Iterations tracks how many agent\-loop Continue cycles have completed; used to generate unique dedup IDs for each re\-enqueue. LoopStartedAt records when the first iteration began, for MaxDuration enforcement. MapInstances tracks state for each parallel map item when Type == StepTypeMap. WakeAt records when a sleep step should complete, for engine scheduling. ChildRunID links to the spawned child run for SubWorkflow steps. ```go type StepState struct { Status StepStatus `json:"status"` Attempts int `json:"attempts"` Iterations int `json:"iterations,omitempty"` LoopStartedAt time.Time `json:"loop_started_at,omitempty"` Output []byte `json:"output,omitempty"` Error string `json:"error,omitempty"` MapInstances []MapInstanceState `json:"map_instances,omitempty"` WakeAt *time.Time `json:"wake_at,omitempty"` ChildRunID string `json:"child_run_id,omitempty"` // DispatchNonce is a fresh random token stamped each time this step is // dispatched to a worker (#380, ADR-021 Phase A). It rides this snapshot // write — no extra KV write. The same value is placed on the // TaskPayload; the worker echoes it back on control-plane requests. The // server verifies the echoed nonce equals this value, proving the caller // is the worker that received this exact dispatch — a sibling-run worker // cannot forge another run's nonce. A retry re-stamps a fresh nonce. // Additive: legacy snapshots deserialize to "". DispatchNonce string `json:"dispatch_nonce,omitempty"` } ``` ## type [StepStatus]() StepStatus tracks the lifecycle of a single step within a run. Queued means the step has been dispatched to NATS but not yet claimed by a worker. ```go type StepStatus int ``` ```go const ( StepStatusPending StepStatus = iota StepStatusQueued StepStatusRunning StepStatusCompleted StepStatusFailed StepStatusSkipped StepStatusCancelled StepStatusRecovered ) ``` ### func \(StepStatus\) [MarshalJSON]() ```go func (s StepStatus) MarshalJSON() ([]byte, error) ``` ### func \(StepStatus\) [String]() ```go func (s StepStatus) String() string ``` ### func \(\*StepStatus\) [UnmarshalJSON]() ```go func (s *StepStatus) UnmarshalJSON(data []byte) error ``` ## type [StepType]() StepType distinguishes execution semantics — normal tasks run once, agent loops iterate until a termination signal, and sub\-workflows delegate to a nested DAG. ```go type StepType int ``` ```go const ( StepTypeNormal StepType = iota StepTypeAgentLoop StepTypeSubWorkflow StepTypeAgent StepTypeMap StepTypeSleep StepTypeWaitForEvent StepTypeApproval StepTypePlanner StepTypeRespond ) ``` ### func \(StepType\) [MarshalJSON]() ```go func (s StepType) MarshalJSON() ([]byte, error) ``` ### func \(StepType\) [String]() ```go func (s StepType) String() string ``` ### func \(\*StepType\) [UnmarshalJSON]() ```go func (s *StepType) UnmarshalJSON(data []byte) error ``` ## type [StickyStrategy]() StickyStrategy controls worker affinity for workflow runs. ```go type StickyStrategy string ``` ```go const ( StickyNone StickyStrategy = "" StickySoft StickyStrategy = "soft" StickyHard StickyStrategy = "hard" ) ``` ## type [SubWorkflowConfig]() SubWorkflowConfig holds configuration for sub\-workflow steps. Workflow names the child workflow definition to spawn. Detach controls whether the parent waits for the child to complete: when true, the parent step completes immediately after spawn. ```go type SubWorkflowConfig struct { Workflow string `json:"workflow"` Detach bool `json:"detach,omitempty"` } ``` ### func [ParseSubWorkflowConfig]() ```go func ParseSubWorkflowConfig(step StepDef) (SubWorkflowConfig, error) ``` ParseSubWorkflowConfig extracts SubWorkflowConfig from a StepDef's Config field. ## type [WaitForEventOpts]() WaitForEventOpts configures a wait\-for\-event step. ```go type WaitForEventOpts struct { Event string `json:"event"` Match Match `json:"match"` Timeout time.Duration `json:"timeout"` } ``` ### func [ParseWaitForEventConfig]() ```go func ParseWaitForEventConfig(step StepDef) (WaitForEventOpts, error) ``` ParseWaitForEventConfig extracts WaitForEventOpts from a StepDef's Config field. ## type [Warning]() Warning is the structured result of a graph\-level validation check per ADR\-013 Layer 1. Warnings are surfaced through the workflow registration response — they do NOT fail the registration, because legitimate branch\-per\-outcome patterns can produce false positives and a hard rejection would block real use cases. Fatal field rules live in the per\-trigger Validate\(\) methods. ```go type Warning struct { Kind string `json:"kind"` Message string `json:"message"` } ``` ### func [ValidateRespondReachability]() ```go func ValidateRespondReachability(def WorkflowDef, hasHTTPTrigger bool) []Warning ``` ValidateRespondReachability returns warnings \(not errors\) for the two ADR\-013 graph problems unique to HTTP\-triggered workflows: - missing\_respond — workflow declares an HTTP trigger but no reachable terminal path contains a respond step. At runtime the caller hangs until the 504 timeout fires. - duplicate\_respond — two respond steps are simultaneously reachable on the same execution. The second publish has no subscriber \(subject already unsubscribed\) and silently drops. hasHTTPTrigger gates the missing\_respond check — a workflow without an HTTP trigger has no caller to leave hanging. duplicate\_respond is always emitted, since two responds on the same run is wrong regardless of trigger kind \(the second publish always drops\). missing\_schemas is emitted only for HTTP\-triggered workflows that lack input\_schema and/or output\_schema, since the OpenAPI generator falls back to free\-form objects in that case and clients lose type safety. Returns a nil slice when there are no problems. Callers should distinguish nil from non\-empty rather than relying on len\(\). ## type [WorkflowBuilder]() WorkflowBuilder accumulates step definitions and wires them into a WorkflowDef on Build\(\). current tracks the most recently added step so that chained modifier calls \(DependsOn, WithTimeout, etc.\) always target the right step. ```go type WorkflowBuilder struct { // contains filtered or unexported fields } ``` ### func [NewWorkflow]() ```go func NewWorkflow(name string) *WorkflowBuilder ``` NewWorkflow starts a new builder for a workflow with the given name. Version defaults to "1" — override via Version\(\) if needed. ### func \(\*WorkflowBuilder\) [Agent]() ```go func (b *WorkflowBuilder) Agent(id, task string, metadata map[string]string) StepRef ``` Agent appends a Claude Agent SDK step. Metadata carries role and other agent\-specific config — the core DAG package is ignorant of what it means. ### func \(\*WorkflowBuilder\) [AgentLoop]() ```go func (b *WorkflowBuilder) AgentLoop(id, task string) StepRef ``` AgentLoop appends an agent\-loop step with an initialised \(but unconfigured\) AgentLoopConfig. Callers must configure bounds via WithMaxIterations / WithMaxDuration before Build\(\) — Validate enforces MaxIterations \> 0. ### func \(\*WorkflowBuilder\) [Approval]() ```go func (b *WorkflowBuilder) Approval(id string, cfg ApprovalConfig) StepRef ``` Approval adds a human approval gate step to the workflow. No worker is involved — the engine manages the token and timeout. ### func \(\*WorkflowBuilder\) [Build]() ```go func (b *WorkflowBuilder) Build() (WorkflowDef, error) ``` ### func \(\*WorkflowBuilder\) [CancelOn]() ```go func (b *WorkflowBuilder) CancelOn(event string, match Match) *WorkflowBuilder ``` CancelOn registers an event that cancels the workflow. ### func \(\*WorkflowBuilder\) [CancelOnWithTimeout]() ```go func (b *WorkflowBuilder) CancelOnWithTimeout(event string, match Match, timeout time.Duration) *WorkflowBuilder ``` CancelOnWithTimeout registers a cancellation event with timeout. ### func \(\*WorkflowBuilder\) [DependsOn]() ```go func (b *WorkflowBuilder) DependsOn(ids ...string) *WorkflowBuilder ``` DependsOn declares that the active step must not start until all listed step IDs have completed. Kept for backward compatibility — prefer After\(StepRef\) for new code which provides compile\-time safety. ### func \(\*WorkflowBuilder\) [Map]() ```go func (b *WorkflowBuilder) Map(id, taskType string) StepRef ``` Map appends a map step that fans out over an array from its dependency. The step will execute taskType once per item in the input array, up to MapConfig.MaxItems. Returns a StepRef for chaining dependency wiring and calling WithMaxItems to override the default bound of 1000. ### func \(\*WorkflowBuilder\) [Name]() ```go func (b *WorkflowBuilder) Name() string ``` Name returns the workflow name. Used by higher\-level packages that need to derive task names from the workflow identity. ### func \(\*WorkflowBuilder\) [Planner]() ```go func (b *WorkflowBuilder) Planner(id, task string, cfg PlannerConfig) StepRef ``` Planner appends a planner step that generates a DAG fragment at runtime. The worker outputs JSON steps; the engine validates, namespaces, and materializes them into the running workflow. ### func \(\*WorkflowBuilder\) [Sleep]() ```go func (b *WorkflowBuilder) Sleep(id string, duration time.Duration) StepRef ``` Sleep adds a durable delay step to the workflow. No worker is involved — the engine handles the timer. ### func \(\*WorkflowBuilder\) [SubWorkflow]() ```go func (b *WorkflowBuilder) SubWorkflow(id, workflow string) StepRef ``` SubWorkflow appends a sub\-workflow step that spawns a child workflow execution. The child workflow must be registered in the workflow\_defs KV bucket. By default the parent step blocks until the child completes; use WithDetach\(\) on the returned StepRef to fire\-and\-forget. ### func \(\*WorkflowBuilder\) [Task]() ```go func (b *WorkflowBuilder) Task(id, task string) StepRef ``` Task appends a normal \(non\-looping\) step and returns a StepRef for compile\-time\-safe dependency wiring and modifier chaining. ### func \(\*WorkflowBuilder\) [Version]() ```go func (b *WorkflowBuilder) Version(v string) *WorkflowBuilder ``` Version overrides the default workflow version string. ### func \(\*WorkflowBuilder\) [WaitForEvent]() ```go func (b *WorkflowBuilder) WaitForEvent(id string, opts WaitForEventOpts) StepRef ``` WaitForEvent adds a step that waits for an external event to match a condition. No worker is involved — the engine handles event matching. ### func \(\*WorkflowBuilder\) [WithConcurrency]() ```go func (b *WorkflowBuilder) WithConcurrency(maxRuns, maxSteps int) *WorkflowBuilder ``` WithConcurrency sets workflow\-level concurrency limits. MaxRuns bounds how many runs of this workflow execute in parallel; MaxSteps bounds how many steps execute concurrently within a single run. ### func \(\*WorkflowBuilder\) [WithIdempotencyKey]() ```go func (b *WorkflowBuilder) WithIdempotencyKey(dotPath string) *WorkflowBuilder ``` WithIdempotencyKey configures a dot\-path expression evaluated against workflow input to produce a dedup key. Duplicate runs with the same key value return the existing run ID instead of creating a new one. ### func \(\*WorkflowBuilder\) [WithMaxDuration]() ```go func (b *WorkflowBuilder) WithMaxDuration(d time.Duration) *WorkflowBuilder ``` WithMaxDuration configures the wall\-clock bound on the active AgentLoop step. Kept for backward compatibility — prefer StepRef.WithMaxDuration. ### func \(\*WorkflowBuilder\) [WithMaxIterations]() ```go func (b *WorkflowBuilder) WithMaxIterations(n int) *WorkflowBuilder ``` WithMaxIterations configures the iteration bound on the active AgentLoop step. Kept for backward compatibility — prefer StepRef.WithMaxIterations. ### func \(\*WorkflowBuilder\) [WithPriority]() ```go func (b *WorkflowBuilder) WithPriority(cfg PriorityConfig) *WorkflowBuilder ``` WithPriority configures run priority ordering. ### func \(\*WorkflowBuilder\) [WithSingleton]() ```go func (b *WorkflowBuilder) WithSingleton(mode SingletonMode) *WorkflowBuilder ``` WithSingleton configures global singleton constraint. ### func \(\*WorkflowBuilder\) [WithSingletonKey]() ```go func (b *WorkflowBuilder) WithSingletonKey(mode SingletonMode, key string) *WorkflowBuilder ``` WithSingletonKey configures per\-entity singleton. ### func \(\*WorkflowBuilder\) [WithSticky]() ```go func (b *WorkflowBuilder) WithSticky(s StickyStrategy) *WorkflowBuilder ``` Build assembles the WorkflowDef and delegates to Validate. Any structural error \(cycle, missing dep, etc.\) is surfaced here so callers get a clean error value rather than a panic at execution time. WithSticky configures worker affinity for workflow runs. Soft prefers the same worker; Hard requires it. ### func \(\*WorkflowBuilder\) [WithTimeout]() ```go func (b *WorkflowBuilder) WithTimeout(d time.Duration) *WorkflowBuilder ``` WithTimeout sets the per\-attempt timeout on the active step. Kept for backward compatibility — prefer StepRef.WithTimeout for new code. ## type [WorkflowDef]() WorkflowDef is the immutable schema for a workflow. Stored once, referenced by many runs. Version allows schema evolution without breaking existing runs. ```go type WorkflowDef struct { Name string `json:"name"` Version string `json:"version"` Steps []StepDef `json:"steps"` DefaultRetry *RetryPolicy `json:"default_retry,omitempty"` Concurrency *ConcurrencyLimit `json:"concurrency,omitempty"` Timeout time.Duration `json:"timeout,omitempty"` InputSchema json.RawMessage `json:"input_schema,omitempty"` OutputSchema json.RawMessage `json:"output_schema,omitempty"` AuxSteps map[string]bool `json:"aux_steps,omitempty"` IdempotencyKey string `json:"idempotency_key,omitempty"` Sticky StickyStrategy `json:"sticky,omitempty"` Priority *PriorityConfig `json:"priority,omitempty"` CancelOn []CancelOn `json:"cancel_on,omitempty"` Singleton *SingletonConfig `json:"singleton,omitempty"` } ``` ### func [EffectiveDef]() ```go func EffectiveDef(def WorkflowDef, run WorkflowRun) WorkflowDef ``` EffectiveDef returns a WorkflowDef augmented with dynamic steps from the run. The original def is not mutated — a shallow copy is returned with the combined step list and rebuilt AuxSteps. ### func [WithSchemas]() ```go func WithSchemas[I, O any](def WorkflowDef) WorkflowDef ``` WithSchemas generates JSON schemas from Go types I \(input\) and O \(output\) and attaches them to the WorkflowDef. Applied after Build\(\). Supports flat structs with primitive fields, slices, and maps. ## type [WorkflowRun]() WorkflowRun holds live state for a single execution of a WorkflowDef. Steps maps step ID to its current StepState; initialized to pending for all steps. Input preserves the original user\-supplied payload so retries can reuse it. ```go type WorkflowRun struct { RunID string `json:"run_id"` WorkflowID string `json:"workflow_id"` Status RunStatus `json:"status"` Steps map[string]StepState `json:"steps"` Input json.RawMessage `json:"input,omitempty"` CreatedAt time.Time `json:"created_at"` DynamicSteps []StepDef `json:"dynamic_steps,omitempty"` ParentRunID string `json:"parent_run_id,omitempty"` // RootRunID is the run ID of the tree-root for this run's lineage — // the same value for every run in a spawn tree (#377). Server-derived, // never worker-supplied. Additive: legacy snapshots deserialize to "" // and a run with RootRunID=="" self-roots (see rootRunIDOf). RootRunID string `json:"root_run_id,omitempty"` ParentStepID string `json:"parent_step_id,omitempty"` Deadline *time.Time `json:"deadline,omitempty"` PriorityOffset int `json:"priority_offset,omitempty"` SingletonKey string `json:"singleton_key,omitempty"` TraceParent string `json:"trace_parent,omitempty"` CompletedAt *time.Time `json:"completed_at,omitempty"` } ``` ### func [NewWorkflowRun]() ```go func NewWorkflowRun(def WorkflowDef, runID string) WorkflowRun ``` NewWorkflowRun constructs a WorkflowRun with all steps initialized to pending. runID must be non\-empty — callers are responsible for providing a unique ID \(e.g. nuid.Next\(\)\) before calling this constructor. ### func \(WorkflowRun\) [EffectiveTime]() ```go func (r WorkflowRun) EffectiveTime() time.Time ``` EffectiveTime returns the priority\-adjusted queue position. Generated by [gomarkdoc]() --- # Source: docs/site/content/docs/reference/sdk/dagnatstest/_index.md ``` import "github.com/danmestas/dagnats/dagnatstest" ``` Test helpers for DagNats workflows. Starts an embedded NATS server with all required streams and KV buckets in a single call, ready for workflow testing. ## Key Functions | Function | Description | |----------|-------------| | `Server(t)` | Starts an embedded NATS server with JetStream and all DagNats infrastructure, returns a ready `*nats.Conn` | | `RunAndWait(t, svc, workflow, input, timeout)` | Starts a workflow run and blocks until it reaches a terminal status, returns the final `WorkflowRun` snapshot | | `WaitForStatus(t, svc, runID, timeout, statuses...)` | Polls a run until it reaches one of the target statuses, returns the matching snapshot | ## What Server(t) Provides A single call to `Server(t)` handles all of the following: - Starts an embedded NATS server with JetStream enabled - Creates all required streams (`HISTORY`, `TASK_QUEUES`, `DEAD_LETTERS`, `TELEMETRY`) - Creates all required KV buckets (`workflow_defs`, `run_snapshots`, `signals`, `triggers`, `workers`, `checkpoints`, `approval_tokens`, `scheduled_runs`, `idempotency_keys`) - Returns a connected `*nats.Conn` ready for use - Registers cleanup via `t.Cleanup()` to shut down the server when the test finishes ## RunAndWait Starts a workflow run and blocks until it reaches any terminal status (`Completed`, `Failed`, `Cancelled`, `Compensated`, `CompensateFailed`). Returns the final `dag.WorkflowRun` snapshot. Fatals the test if the run does not finish within the given timeout. ```go run := dagnatstest.RunAndWait(t, svc, "my-workflow", input, 10*time.Second) assert.Equal(t, dag.RunStatusCompleted, run.Status) ``` Under the hood, `RunAndWait` calls `svc.StartRun` and then delegates to `WaitForStatus` with all terminal statuses. ## WaitForStatus Polls `svc.GetRun` every 25ms until the run reaches one of the given target statuses. Returns the matching `dag.WorkflowRun` snapshot. Fatals the test with a descriptive message (including the last observed status) on timeout. ```go run := dagnatstest.WaitForStatus(t, svc, runID, 5*time.Second, dag.RunStatusCompleted, dag.RunStatusFailed, ) ``` This is useful when you need to wait for a specific subset of statuses rather than all terminal states, or when you already have a run ID from a previous operation. ## Usage ```go func TestMyWorkflow(t *testing.T) { nc := dagnatstest.Server(t) // Register a workflow tel := observe.NewNoopTelemetry() svc := api.NewService(nc, tel) svc.RegisterWorkflow(ctx, myWorkflowDef) // Start a worker w := worker.NewWorker(nc, tel) w.Handle("process", myHandler) go w.Start() // Start a run and wait for completion run := dagnatstest.RunAndWait(t, svc, "my-workflow", nil, 10*time.Second) assert.Equal(t, dag.RunStatusCompleted, run.Status) } ``` ## Design Notes Each test gets its own embedded NATS server. Servers are not shared between tests, preventing cross-test interference. The server uses a temporary data directory that is cleaned up automatically. --- # Source: docs/site/content/docs/reference/sdk/dagnatstest/api.md # dagnatstest ```go import "github.com/danmestas/dagnats/dagnatstest" ``` dagnatstest/cli\_fixture.go CLIFixture drives the dagnats CLI in\-process against the fixture's embedded NATS server. Stdout/stderr are captured by redirecting the process\-global file descriptors during the call. The CLI calls os.Exit on errors; the fixture intercepts that via the ExitInterceptor wired by the caller \(typically the cli package itself, which owns the os.Exit indirection\). The fixture takes the CLI entry point as a function value rather than importing the cli package directly. This avoids a package import cycle \(cli tests already import dagnatstest, so dagnatstest must not import cli\). The cli package wires the bridge by passing cli.Run and cli.SwapExitFunc into NewCLIFixture. dagnatstest/cluster.go In\-process N\-node NATS cluster helper for tests. Spins up real nats\-server instances configured as a cluster, waits for routes to mesh and a JetStream meta\-leader to be elected, then confirms the cluster is API\-healthy via the production WaitForClusterQuorum helper. Cleanup registered with t.Cleanup so tests stay leak\-free. Package dagnatstest provides test helpers for DagNats workflows. It starts an embedded NATS server with all required streams and KV buckets in a single call, ready for workflow testing. Usage: ``` func TestMyWorkflow(t *testing.T) { nc := dagnatstest.Server(t) // nc is ready — register workflows, start workers, etc. } ``` dagnatstest/dlq\_fixture.go DLQFixture clusters DLQ\-related test helpers off a shared Harness. Construct via NewDLQFixture\(h\). Helpers stay under 70 lines \(TigerStyle\). Concern boundary: the Harness owns embedded NATS lifecycle; the fixture owns DLQ\-stream observation and DLQ\-publish drivers. The fixture never mutates the Harness beyond reading its NATS connection. dagnatstest/fixtures.go Pre\-built workflow definitions for common DAG topologies. Eliminates boilerplate in tests that rebuild linear, fan\-out, fan\-in, and diamond patterns from scratch every time. dagnatstest/harness.go Test harness that bundles NATS server, orchestrator, API service, and worker into a single struct. Eliminates \~15 lines of boilerplate that every integration test otherwise duplicates. dagnatstest/helpers.go Convenience helpers for workflow integration tests. RunAndWait and WaitForStatus eliminate the poll\-loop boilerplate that every test otherwise duplicates. dagnatstest/log\_capture.go LogCapture installs a process\-wide slog handler that records every log record emitted while the test runs. The capture is restored on t.Cleanup, so tests cannot leak captured state into siblings. Motivation: engine/worker code paths emit warnings via slog package functions \(slog.WarnContext, slog.InfoContext\) which always route through slog.Default\(\). To assert "log fires exactly once" we swap the default handler with a capturing one for the duration of the test. See PR 5 of the AFK plan \(issue \#195\) for the consuming test. dagnatstest/run\_fixture.go RunFixture clusters run\-population helpers around a shared Harness. Keeping these off Harness lets the harness stay a thin lifecycle owner; per\-concern helpers cluster by concern, not by accretion \(the inventory pattern documented in the AFK plan\). dagnatstest/setup.go Higher\-level setup helpers that eliminate boilerplate when tests need a registered workflow or a running worker. These build on Server\(\), RunAndWait\(\), and WaitForStatus\(\) from helpers.go. ## Index - [func DiamondDef\(t \*testing.T\) dag.WorkflowDef](<#DiamondDef>) - [func FailHandler\(msg string\) worker.HandlerFunc](<#FailHandler>) - [func FanInDef\(t \*testing.T, n int\) dag.WorkflowDef](<#FanInDef>) - [func FanOutDef\(t \*testing.T, n int\) dag.WorkflowDef](<#FanOutDef>) - [func HandleTypedOn\[I, O any\]\(h \*Harness, t \*testing.T, taskType string, fn worker.TypedHandlerFunc\[I, O\]\)](<#HandleTypedOn>) - [func LinearDef\(t \*testing.T, n int\) dag.WorkflowDef](<#LinearDef>) - [func PassHandler\(\) worker.HandlerFunc](<#PassHandler>) - [func RunAndWait\(t \*testing.T, svc \*api.Service, workflow string, input \[\]byte, timeout time.Duration\) dag.WorkflowRun](<#RunAndWait>) - [func Server\(t \*testing.T\) \*nats.Conn](<#Server>) - [func StartTestCluster\(t \*testing.T, n int\) \*nats.Conn](<#StartTestCluster>) - [func WaitForStatus\(t \*testing.T, svc \*api.Service, runID string, timeout time.Duration, statuses ...dag.RunStatus\) dag.WorkflowRun](<#WaitForStatus>) - [func Worker\[I, O any\]\(t \*testing.T, nc \*nats.Conn, taskType string, fn worker.TypedHandlerFunc\[I, O\]\) \*worker.Worker](<#Worker>) - [func Workflow\(t \*testing.T, svc \*api.Service, builder \*dag.WorkflowBuilder\) dag.WorkflowDef](<#Workflow>) - [type CLIFixture](<#CLIFixture>) - [func NewCLIFixture\(h \*Harness, runCLI CLIRunner, swapExit ExitSwapper\) \*CLIFixture](<#NewCLIFixture>) - [func \(f \*CLIFixture\) Run\(t \*testing.T, args ...string\) string](<#CLIFixture.Run>) - [func \(f \*CLIFixture\) RunErr\(t \*testing.T, args ...string\) \(string, error\)](<#CLIFixture.RunErr>) - [func \(f \*CLIFixture\) RunSplit\(t \*testing.T, args ...string\) \(string, string\)](<#CLIFixture.RunSplit>) - [type CLIRunner](<#CLIRunner>) - [type DLQFixture](<#DLQFixture>) - [func NewDLQFixture\(h \*Harness\) \*DLQFixture](<#NewDLQFixture>) - [func \(f \*DLQFixture\) AwaitReplay\(t \*testing.T, d time.Duration\) \<\-chan \[\]byte](<#DLQFixture.AwaitReplay>) - [func \(f \*DLQFixture\) Count\(ctx context.Context\) \(int, error\)](<#DLQFixture.Count>) - [func \(f \*DLQFixture\) IsBodyMissingError\(err error\) bool](<#DLQFixture.IsBodyMissingError>) - [func \(f \*DLQFixture\) PublishAndExhaustToDLQ\(t \*testing.T, body \[\]byte\) uint64](<#DLQFixture.PublishAndExhaustToDLQ>) - [func \(f \*DLQFixture\) PublishDLQNoMsgID\(ctx context.Context, subject string, body \[\]byte\) error](<#DLQFixture.PublishDLQNoMsgID>) - [func \(f \*DLQFixture\) PublishDLQWithMsgID\(ctx context.Context, subject string, msgID string, body \[\]byte\) error](<#DLQFixture.PublishDLQWithMsgID>) - [func \(f \*DLQFixture\) PublishLegacyEntry\(t \*testing.T\) uint64](<#DLQFixture.PublishLegacyEntry>) - [func \(f \*DLQFixture\) Replay\(ctx context.Context, seq uint64\) error](<#DLQFixture.Replay>) - [func \(f \*DLQFixture\) Seed\(t \*testing.T, n int\)](<#DLQFixture.Seed>) - [func \(f \*DLQFixture\) WaitForCount\(t \*testing.T, target int, timeout time.Duration\) int](<#DLQFixture.WaitForCount>) - [type ExitSwapper](<#ExitSwapper>) - [type FailRetryAfterCall](<#FailRetryAfterCall>) - [type Harness](<#Harness>) - [func NewHarness\(t \*testing.T\) \*Harness](<#NewHarness>) - [func \(h \*Harness\) Handle\(t \*testing.T, taskType string, fn worker.HandlerFunc\)](<#Harness.Handle>) - [func \(h \*Harness\) RegisterAndRun\(t \*testing.T, def dag.WorkflowDef, input \[\]byte, timeout time.Duration\) dag.WorkflowRun](<#Harness.RegisterAndRun>) - [func \(h \*Harness\) Start\(t \*testing.T\)](<#Harness.Start>) - [type LogCapture](<#LogCapture>) - [func NewLogCapture\(t \*testing.T\) \*LogCapture](<#NewLogCapture>) - [func \(c \*LogCapture\) Enabled\(context.Context, slog.Level\) bool](<#LogCapture.Enabled>) - [func \(c \*LogCapture\) Handle\(\_ context.Context, r slog.Record\) error](<#LogCapture.Handle>) - [func \(c \*LogCapture\) Hits\(substr string\) int](<#LogCapture.Hits>) - [func \(c \*LogCapture\) WithAttrs\(\[\]slog.Attr\) slog.Handler](<#LogCapture.WithAttrs>) - [func \(c \*LogCapture\) WithGroup\(string\) slog.Handler](<#LogCapture.WithGroup>) - [type MockTaskContext](<#MockTaskContext>) - [func \(m \*MockTaskContext\) Checkpoint\(state \[\]byte\) error](<#MockTaskContext.Checkpoint>) - [func \(m \*MockTaskContext\) Complete\(output \[\]byte\) error](<#MockTaskContext.Complete>) - [func \(m \*MockTaskContext\) Context\(\) context.Context](<#MockTaskContext.Context>) - [func \(m \*MockTaskContext\) Continue\(output \[\]byte\) error](<#MockTaskContext.Continue>) - [func \(m \*MockTaskContext\) ControlPlane\(\) worker.ControlPlane](<#MockTaskContext.ControlPlane>) - [func \(m \*MockTaskContext\) Fail\(err error\) error](<#MockTaskContext.Fail>) - [func \(m \*MockTaskContext\) FailPermanent\(err error\) error](<#MockTaskContext.FailPermanent>) - [func \(m \*MockTaskContext\) FailRetryAfter\(err error, after time.Duration\) error](<#MockTaskContext.FailRetryAfter>) - [func \(m \*MockTaskContext\) Heartbeat\(\) error](<#MockTaskContext.Heartbeat>) - [func \(m \*MockTaskContext\) Input\(\) \[\]byte](<#MockTaskContext.Input>) - [func \(m \*MockTaskContext\) LoadCheckpoint\(\) \(\[\]byte, error\)](<#MockTaskContext.LoadCheckpoint>) - [func \(m \*MockTaskContext\) Metadata\(\) map\[string\]string](<#MockTaskContext.Metadata>) - [func \(m \*MockTaskContext\) Pause\(name string, duration time.Duration\) error](<#MockTaskContext.Pause>) - [func \(m \*MockTaskContext\) PutStream\(data \[\]byte\) error](<#MockTaskContext.PutStream>) - [func \(m \*MockTaskContext\) RetryCount\(\) int](<#MockTaskContext.RetryCount>) - [func \(m \*MockTaskContext\) RunID\(\) string](<#MockTaskContext.RunID>) - [func \(m \*MockTaskContext\) SendSignal\(runID, name string, data \[\]byte\) error](<#MockTaskContext.SendSignal>) - [func \(m \*MockTaskContext\) StepID\(\) string](<#MockTaskContext.StepID>) - [func \(m \*MockTaskContext\) WaitForSignal\(name string, timeout time.Duration\) \(\[\]byte, error\)](<#MockTaskContext.WaitForSignal>) - [type RunFixture](<#RunFixture>) - [func NewRunFixture\(h \*Harness\) \*RunFixture](<#NewRunFixture>) - [func \(f \*RunFixture\) PublishStaleStepStarted\(ctx context.Context, t \*testing.T, runID, stepID string, attempt int\)](<#RunFixture.PublishStaleStepStarted>) - [func \(f \*RunFixture\) RunSingleStepToCompletion\(t \*testing.T\) \(string, string\)](<#RunFixture.RunSingleStepToCompletion>) - [func \(f \*RunFixture\) Snapshot\(t \*testing.T, runID string\) dag.WorkflowRun](<#RunFixture.Snapshot>) - [func \(f \*RunFixture\) SubmitAndAdvanceTo\(t \*testing.T, state string, n int\)](<#RunFixture.SubmitAndAdvanceTo>) - [type SentSignal](<#SentSignal>) ## func [DiamondDef]() ```go func DiamondDef(t *testing.T) dag.WorkflowDef ``` DiamondDef builds the classic diamond: A \-\> \{B, C\} \-\> D. ## func [FailHandler]() ```go func FailHandler(msg string) worker.HandlerFunc ``` FailHandler returns a HandlerFunc that always fails permanently with the given message. Panics if msg is empty. ## func [FanInDef]() ```go func FanInDef(t *testing.T, n int) dag.WorkflowDef ``` FanInDef builds a workflow with root \-\> n branches \-\> join: root \-\> \{branch\-0, ..., branch\-\(n\-1\)\} \-\> join. ## func [FanOutDef]() ```go func FanOutDef(t *testing.T, n int) dag.WorkflowDef ``` FanOutDef builds a workflow with 1 root and n parallel branches: root \-\> \{branch\-0, branch\-1, ..., branch\-\(n\-1\)\}. ## func [HandleTypedOn]() ```go func HandleTypedOn[I, O any](h *Harness, t *testing.T, taskType string, fn worker.TypedHandlerFunc[I, O]) ``` HandleTypedOn registers a typed handler on the harness worker. Must be a package\-level function because Go generics cannot appear on methods. ## func [LinearDef]() ```go func LinearDef(t *testing.T, n int) dag.WorkflowDef ``` LinearDef builds a workflow with n steps in sequence: task\-0 \-\> task\-1 \-\> ... \-\> task\-\(n\-1\). Each step uses task type "task\-\{i\}". ## func [PassHandler]() ```go func PassHandler() worker.HandlerFunc ``` PassHandler returns a HandlerFunc that completes immediately, passing the input through as output. ## func [RunAndWait]() ```go func RunAndWait(t *testing.T, svc *api.Service, workflow string, input []byte, timeout time.Duration) dag.WorkflowRun ``` RunAndWait starts a workflow run and blocks until it reaches any terminal status \(Completed, Failed, Cancelled, Compensated, CompensateFailed\). Returns the final WorkflowRun snapshot. Fatals the test if the run does not finish within timeout. ## func [Server]() ```go func Server(t *testing.T) *nats.Conn ``` Server starts an embedded NATS server with JetStream and all required streams/KV buckets provisioned. Returns the connected client. Server and connection are cleaned up automatically when the test ends. ## func [StartTestCluster]() ```go func StartTestCluster(t *testing.T, n int) *nats.Conn ``` StartTestCluster starts n in\-process NATS servers configured as a cluster, waits for routes to mesh and quorum to form, and returns a connection to peer 0. All cleanup is registered with t.Cleanup. Panics if n \< 3 or n \> 5. ## func [WaitForStatus]() ```go func WaitForStatus(t *testing.T, svc *api.Service, runID string, timeout time.Duration, statuses ...dag.RunStatus) dag.WorkflowRun ``` WaitForStatus polls svc.GetRun every 25ms until the run reaches one of the given target statuses. Returns the matching snapshot. Fatals the test with a descriptive message on timeout. ## func [Worker]() ```go func Worker[I, O any](t *testing.T, nc *nats.Conn, taskType string, fn worker.TypedHandlerFunc[I, O]) *worker.Worker ``` Worker starts a worker with a single typed handler and stops it automatically when the test ends. Uses generics so callers get compile\-time type safety on handler input and output. Must be a package\-level function \(not a method\) because Go does not allow generic methods. ## func [Workflow]() ```go func Workflow(t *testing.T, svc *api.Service, builder *dag.WorkflowBuilder) dag.WorkflowDef ``` Workflow builds and registers a workflow definition, failing the test immediately on any error. Returns the built definition so callers can inspect the name or steps if needed. ## type [CLIFixture]() CLIFixture drives the dagnats CLI in\-process. Constructed off a Harness; the harness owns NATS lifecycle, the fixture owns the shape of "execute the CLI and observe its output". ```go type CLIFixture struct { // contains filtered or unexported fields } ``` ### func [NewCLIFixture]() ```go func NewCLIFixture(h *Harness, runCLI CLIRunner, swapExit ExitSwapper) *CLIFixture ``` NewCLIFixture constructs a CLIFixture. runCLI is typically cli.Run and swapExit is typically cli.SwapExitFunc — the caller injects them so this package does not import cli \(which already imports this one via tests\). ### func \(\*CLIFixture\) [Run]() ```go func (f *CLIFixture) Run(t *testing.T, args ...string) string ``` Run executes the dagnats CLI with the given args. On non\-zero exit it fatals the test. Returns stdout \(stderr discarded — use RunSplit when stderr matters\). ### func \(\*CLIFixture\) [RunErr]() ```go func (f *CLIFixture) RunErr(t *testing.T, args ...string) (string, error) ``` RunErr executes the CLI and returns stdout plus any non\-nil error produced by a non\-zero exit. Stdout may be partially populated even when err is non\-nil. ### func \(\*CLIFixture\) [RunSplit]() ```go func (f *CLIFixture) RunSplit(t *testing.T, args ...string) (string, string) ``` RunSplit executes the CLI and returns stdout \+ stderr separately. Fatals on non\-zero exit; for negative\-path tests use RunErr. ## type [CLIRunner]() CLIRunner is the in\-process entry point for the dagnats CLI. The concrete implementation is cli.Run, but the fixture takes it as a value to break the cli ↔ dagnatstest import cycle. ```go type CLIRunner func(args []string) ``` ## type [DLQFixture]() DLQFixture holds DLQ\-focused test helpers bound to a Harness. Use NewDLQFixture\(h\) to construct. ```go type DLQFixture struct { // contains filtered or unexported fields } ``` ### func [NewDLQFixture]() ```go func NewDLQFixture(h *Harness) *DLQFixture ``` NewDLQFixture binds a DLQFixture to an existing Harness. Panics if h is nil. ### func \(\*DLQFixture\) [AwaitReplay]() ```go func (f *DLQFixture) AwaitReplay(t *testing.T, d time.Duration) <-chan []byte ``` AwaitReplay subscribes to the task subject space, blocks up to d for the next delivery, and returns the message body via a 1\-buffer channel. nil means timeout. Install \*before\* Replay so the subscription catches the republished message. ### func \(\*DLQFixture\) [Count]() ```go func (f *DLQFixture) Count(ctx context.Context) (int, error) ``` Count returns the current number of entries on the DEAD\_LETTERS stream. Reads stream info directly — no consumer needed. ### func \(\*DLQFixture\) [IsBodyMissingError]() ```go func (f *DLQFixture) IsBodyMissingError(err error) bool ``` IsBodyMissingError reports whether err is the typed body\-missing error returned by Replay against a legacy DLQ entry. ### func \(\*DLQFixture\) [PublishAndExhaustToDLQ]() ```go func (f *DLQFixture) PublishAndExhaustToDLQ(t *testing.T, body []byte) uint64 ``` PublishAndExhaustToDLQ drives a workflow whose step is failed non\-retriably so the engine publishes a DLQ entry, and returns the DLQ stream sequence of that entry. The given body is used as the workflow input bytes; after the fix it must also appear on the DLQ entry's Body field \(the TaskPayload bytes containing this input\). Drives via direct step.failed publish on history — mirrors orchestrator\_test.go's shape for deterministic exhaustion. Side effect: drains the original task message from TASK\_QUEUES so a subsequent AwaitReplay sees only the republished delivery. ### func \(\*DLQFixture\) [PublishDLQNoMsgID]() ```go func (f *DLQFixture) PublishDLQNoMsgID(ctx context.Context, subject string, body []byte) error ``` PublishDLQNoMsgID publishes a synthetic DLQ entry on \`subject\` with no Nats\-Msg\-Id header — mirrors the pre\-fix production code path in engine.RecoveryManager.PublishDeadLetter. Used to demonstrate that without dedup, repeated publishes for the same logical event all land. ### func \(\*DLQFixture\) [PublishDLQWithMsgID]() ```go func (f *DLQFixture) PublishDLQWithMsgID(ctx context.Context, subject string, msgID string, body []byte) error ``` PublishDLQWithMsgID publishes a synthetic DLQ entry on subject \`dead.\\` with the given Nats\-Msg\-Id header for dedup testing. Returns the publish error so callers can assert success/failure. Used by tests that exercise the dedup contract directly without driving the full workflow\-failure path. ### func \(\*DLQFixture\) [PublishLegacyEntry]() ```go func (f *DLQFixture) PublishLegacyEntry(t *testing.T) uint64 ``` PublishLegacyEntry writes a synthetic DLQ entry in the pre\-fix shape: payload is just \{run\_id, step\_id, task\} with no Body field. Returns the DLQ stream sequence so the caller can replay it. ### func \(\*DLQFixture\) [Replay]() ```go func (f *DLQFixture) Replay(ctx context.Context, seq uint64) error ``` Replay invokes the API\-layer replay for the given DLQ sequence. Returns the error verbatim so callers can match against ErrDLQBodyMissing or assert nil. ### func \(\*DLQFixture\) [Seed]() ```go func (f *DLQFixture) Seed(t *testing.T, n int) ``` Seed publishes n synthetic DLQ entries with deterministic body bytes \("seed\-\"\) and a populated metadata header set so \#203's CLI tests can assert on truncation, \-\-all behavior, and the visibility of the delivery\_count \+ consumer fields in \-\-json output. Bounded: n must be in \(0, seedMax\]. Helpers stay under 70 lines so the publish loop delegates to seedOne. ### func \(\*DLQFixture\) [WaitForCount]() ```go func (f *DLQFixture) WaitForCount(t *testing.T, target int, timeout time.Duration) int ``` WaitForCount polls Count\(\) until it returns target or timeout. Bounded wait per CLAUDE.md testing rules — fails the test on timeout. Returns the final observed count. ## type [ExitSwapper]() ExitSwapper installs an exit hook and returns the previous one. It mirrors cli.SwapExitFunc and is passed in for the same cycle reason. ```go type ExitSwapper func(next func(int)) func(int) ``` ## type [FailRetryAfterCall]() FailRetryAfterCall records a FailRetryAfter call. ```go type FailRetryAfterCall struct { Err error After time.Duration } ``` ## type [Harness]() Harness holds all components needed for a workflow integration test. Fields are public so tests can reach into them for assertions \(e.g., h.Svc.GetRun\(\)\). ```go type Harness struct { NC *nats.Conn Engine *engine.Orchestrator Svc *api.Service Worker *worker.Worker } ``` ### func [NewHarness]() ```go func NewHarness(t *testing.T) *Harness ``` NewHarness starts an embedded NATS server, orchestrator, and API service. The worker is created but NOT started — register handlers first, then call h.Start\(t\). ### func \(\*Harness\) [Handle]() ```go func (h *Harness) Handle(t *testing.T, taskType string, fn worker.HandlerFunc) ``` Handle registers a raw handler on the harness worker. Call before h.Start\(t\). ### func \(\*Harness\) [RegisterAndRun]() ```go func (h *Harness) RegisterAndRun(t *testing.T, def dag.WorkflowDef, input []byte, timeout time.Duration) dag.WorkflowRun ``` RegisterAndRun registers a workflow definition, starts a run, and blocks until it reaches a terminal status. Returns the final WorkflowRun snapshot. ### func \(\*Harness\) [Start]() ```go func (h *Harness) Start(t *testing.T) ``` Start starts the worker and registers cleanup. Call after all handlers are registered. ## type [LogCapture]() LogCapture is a thread\-safe, in\-memory slog handler that records every record emitted via slog package functions while it is the default. Hits\(substr\) counts records whose message contains substr. ```go type LogCapture struct { // contains filtered or unexported fields } ``` ### func [NewLogCapture]() ```go func NewLogCapture(t *testing.T) *LogCapture ``` NewLogCapture installs a fresh LogCapture as the slog default and arranges for the prior default to be restored on t.Cleanup. Panics on nil t — programmer error. ### func \(\*LogCapture\) [Enabled]() ```go func (c *LogCapture) Enabled(context.Context, slog.Level) bool ``` Enabled accepts every record so callers' WarnContext / DebugContext hits land in the capture regardless of the prior log level. ### func \(\*LogCapture\) [Handle]() ```go func (c *LogCapture) Handle(_ context.Context, r slog.Record) error ``` Handle records the slog.Record under the mutex. The Record's Clone\(\) is unnecessary here: slog.Record is a struct value, and we only read its Message field. ### func \(\*LogCapture\) [Hits]() ```go func (c *LogCapture) Hits(substr string) int ``` Hits returns the number of captured records whose message contains substr. Empty substr panics — empty match would count every record and almost certainly indicates a caller bug. ### func \(\*LogCapture\) [WithAttrs]() ```go func (c *LogCapture) WithAttrs([]slog.Attr) slog.Handler ``` WithAttrs returns the receiver unchanged. Captured records keep only their Message, so per\-handler attrs are discarded by design. ### func \(\*LogCapture\) [WithGroup]() ```go func (c *LogCapture) WithGroup(string) slog.Handler ``` WithGroup returns the receiver unchanged for the same reason as WithAttrs. ## type [MockTaskContext]() MockTaskContext is a test double for worker.TaskContext that records all method calls. Safe for concurrent use. Satisfies TaskContext and all role interfaces \(SimpleTask, CheckpointTask, LoopTask, StreamTask, SignalTask\). ```go type MockTaskContext struct { // Configuration -- set before use InputData []byte RunIDValue string StepIDValue string RetryCountVal int CtxValue context.Context CheckpointData []byte // returned by LoadCheckpoint SignalData []byte // returned by WaitForSignal FailErr error // if set, Complete/Fail/Continue return this // Recorded calls Completed bool CompletedOutput []byte Failed bool FailedErr error FailedPermanent bool Continued bool ContinuedOutput []byte Checkpoints [][]byte Streams [][]byte HeartbeatCount int SignalsSent []SentSignal FailRetryAfterCalls []FailRetryAfterCall // contains filtered or unexported fields } ``` ### func \(\*MockTaskContext\) [Checkpoint]() ```go func (m *MockTaskContext) Checkpoint(state []byte) error ``` ### func \(\*MockTaskContext\) [Complete]() ```go func (m *MockTaskContext) Complete(output []byte) error ``` ### func \(\*MockTaskContext\) [Context]() ```go func (m *MockTaskContext) Context() context.Context ``` ### func \(\*MockTaskContext\) [Continue]() ```go func (m *MockTaskContext) Continue(output []byte) error ``` ### func \(\*MockTaskContext\) [ControlPlane]() ```go func (m *MockTaskContext) ControlPlane() worker.ControlPlane ``` ControlPlane returns nil: the mock is ungated by default, matching the deny\-by\-default contract. Tests that need a granted handle set it explicitly via their own double. ### func \(\*MockTaskContext\) [Fail]() ```go func (m *MockTaskContext) Fail(err error) error ``` ### func \(\*MockTaskContext\) [FailPermanent]() ```go func (m *MockTaskContext) FailPermanent(err error) error ``` ### func \(\*MockTaskContext\) [FailRetryAfter]() ```go func (m *MockTaskContext) FailRetryAfter(err error, after time.Duration) error ``` ### func \(\*MockTaskContext\) [Heartbeat]() ```go func (m *MockTaskContext) Heartbeat() error ``` ### func \(\*MockTaskContext\) [Input]() ```go func (m *MockTaskContext) Input() []byte ``` ### func \(\*MockTaskContext\) [LoadCheckpoint]() ```go func (m *MockTaskContext) LoadCheckpoint() ([]byte, error) ``` ### func \(\*MockTaskContext\) [Metadata]() ```go func (m *MockTaskContext) Metadata() map[string]string ``` ### func \(\*MockTaskContext\) [Pause]() ```go func (m *MockTaskContext) Pause(name string, duration time.Duration) error ``` ### func \(\*MockTaskContext\) [PutStream]() ```go func (m *MockTaskContext) PutStream(data []byte) error ``` ### func \(\*MockTaskContext\) [RetryCount]() ```go func (m *MockTaskContext) RetryCount() int ``` ### func \(\*MockTaskContext\) [RunID]() ```go func (m *MockTaskContext) RunID() string ``` ### func \(\*MockTaskContext\) [SendSignal]() ```go func (m *MockTaskContext) SendSignal(runID, name string, data []byte) error ``` ### func \(\*MockTaskContext\) [StepID]() ```go func (m *MockTaskContext) StepID() string ``` ### func \(\*MockTaskContext\) [WaitForSignal]() ```go func (m *MockTaskContext) WaitForSignal(name string, timeout time.Duration) ([]byte, error) ``` ## type [RunFixture]() RunFixture wraps a Harness with helpers that submit and advance workflow runs into terminal states. The fixture is concern\-scoped \(run shape\) rather than infrastructure\-scoped \(NATS lifecycle\). ```go type RunFixture struct { // contains filtered or unexported fields } ``` ### func [NewRunFixture]() ```go func NewRunFixture(h *Harness) *RunFixture ``` NewRunFixture constructs a RunFixture bound to h. Panics on nil harness — programmer error. ### func \(\*RunFixture\) [PublishStaleStepStarted]() ```go func (f *RunFixture) PublishStaleStepStarted(ctx context.Context, t *testing.T, runID, stepID string, attempt int) ``` PublishStaleStepStarted synthesizes a step.started event for runID/stepID at the given attempt number and publishes it to the WORKFLOW\_HISTORY stream so the engine's history consumer receives it. The event carries the same shape a real worker would publish. We deliberately do NOT set Nats\-Msg\-Id to the deterministic NATSMsgID\(\). The production failure mode this regression test guards against is delivery of an event the engine has already terminalised — that delivery can arrive via either consumer\-side redelivery \(same stream message, replayed past ack\) or a fresh publish from a recovered worker that the stream's dedup window has already aged out. Both produce identical engine input. To exercise the engine path without coupling the test to either JetStream's consumer redelivery internals or the 5s dedup window on WORKFLOW\_HISTORY, we publish with a unique tag in the MsgId so the stream accepts the message and the engine sees the duplicate payload. The engine's terminal\-state guard in handleStepStarted is the contract under test, not the dedup window. attempt must be \> 0; attempt == 0 is reserved for "not yet scheduled" elsewhere in the engine. ### func \(\*RunFixture\) [RunSingleStepToCompletion]() ```go func (f *RunFixture) RunSingleStepToCompletion(t *testing.T) (string, string) ``` RunSingleStepToCompletion drives one fresh single\-step workflow through the worker harness and waits for it to reach Completed. Returns the run ID and step ID of the now\-terminal step. Callers rely on this to seed the "step is terminal" precondition before synthesizing a stale step.started in idempotency tests. ### func \(\*RunFixture\) [Snapshot]() ```go func (f *RunFixture) Snapshot(t *testing.T, runID string) dag.WorkflowRun ``` Snapshot returns the current KV snapshot for runID. Fatals the test on load failure — callers use this to assert state didn't change across an event publish, so a load error is unrecoverable. ### func \(\*RunFixture\) [SubmitAndAdvanceTo]() ```go func (f *RunFixture) SubmitAndAdvanceTo(t *testing.T, state string, n int) ``` SubmitAndAdvanceTo registers and runs n single\-step workflows whose handlers drive each run to the requested terminal state \("completed" or "failed"\). Returns once every run has reached state or the per\-run timeout fires. ## type [SentSignal]() SentSignal records a SendSignal call. ```go type SentSignal struct { RunID string Name string Data []byte } ``` Generated by [gomarkdoc]() --- # Source: docs/site/content/docs/reference/sdk/httpclient/_index.md ``` import "github.com/danmestas/dagnats/sdk/httpclient" ``` Go HTTP reference client for the DagNats bridge. Implements the worker protocol over HTTP and serves as a template for building SDKs in other languages. ## Key Types | Type | Description | |------|-------------| | `Client` | HTTP client that implements the bridge wire protocol | ## Key Functions | Function | Description | |----------|-------------| | `Connect(baseURL, token, workerID, taskTypes, maxTasks)` | Creates a client and registers the worker via SSE connect | ## Client Methods | Method | Description | |--------|-------------| | `Poll(ctx, taskTypes, maxTasks, timeoutMs)` | Long-polls for available tasks | | `Resolve(ctx, taskID, resolution)` | Resolves a task with complete/fail/pause/checkpoint | | `Close()` | Disconnects the worker | ## Usage ```go client, err := httpclient.Connect( "http://localhost:8080", "my-bridge-token", "worker-1", []string{"llm", "http"}, 2, ) if err != nil { log.Fatal(err) } defer client.Close() // Poll for tasks tasks, err := client.Poll(ctx, []string{"llm"}, 1, 30000) if err != nil { log.Fatal(err) } // Resolve a task err = client.Resolve(ctx, tasks[0].TaskID, protocol.TaskResolution{ Action: "complete", Output: json.RawMessage(`{"result": "ok"}`), }) ``` ## SDK Template This package demonstrates the three HTTP calls needed for any language SDK: 1. `POST /v1/workers/connect` -- register and maintain heartbeat 2. `POST /v1/tasks/poll` -- long-poll for tasks 3. `POST /v1/tasks/{id}/resolve` -- resolve tasks See [Wire Protocol](../../wire-protocol) for the complete JSON schemas. --- # Source: docs/site/content/docs/reference/sdk/httpclient/api.md # httpclient ```go import "github.com/danmestas/dagnats/sdk/httpclient" ``` Package httpclient implements the DagNats worker protocol over HTTP. This is the reference client for validating the bridge wire protocol and serves as a template for other language SDKs. ## Index - [type Client](<#Client>) - [func New\(baseURL string, opts ...Option\) \*Client](<#New>) - [func \(c \*Client\) Checkpoint\(ctx context.Context, taskID string, data json.RawMessage\) error](<#Client.Checkpoint>) - [func \(c \*Client\) Complete\(ctx context.Context, taskID string, output json.RawMessage\) error](<#Client.Complete>) - [func \(c \*Client\) Connect\(ctx context.Context, workerID string, taskTypes \[\]string, maxTasks int\) error](<#Client.Connect>) - [func \(c \*Client\) Disconnect\(\)](<#Client.Disconnect>) - [func \(c \*Client\) Fail\(ctx context.Context, taskID string, errMsg string\) error](<#Client.Fail>) - [func \(c \*Client\) Pause\(ctx context.Context, taskID string, name string, duration time.Duration, checkpoint json.RawMessage\) error](<#Client.Pause>) - [func \(c \*Client\) Poll\(ctx context.Context, taskTypes \[\]string, maxTasks int, timeout time.Duration\) \(\[\]protocol.TaskPayload, error\)](<#Client.Poll>) - [type Option](<#Option>) - [func WithToken\(token string\) Option](<#WithToken>) ## type [Client]() Client implements the DagNats worker protocol over HTTP. This is the reference implementation for other language SDKs. ```go type Client struct { // contains filtered or unexported fields } ``` ### func [New]() ```go func New(baseURL string, opts ...Option) *Client ``` New creates an HTTP client targeting the given base URL. Panics if baseURL is empty. ### func \(\*Client\) [Checkpoint]() ```go func (c *Client) Checkpoint(ctx context.Context, taskID string, data json.RawMessage) error ``` Checkpoint saves intermediate state for a task. ### func \(\*Client\) [Complete]() ```go func (c *Client) Complete(ctx context.Context, taskID string, output json.RawMessage) error ``` Complete resolves a task as successfully completed. ### func \(\*Client\) [Connect]() ```go func (c *Client) Connect(ctx context.Context, workerID string, taskTypes []string, maxTasks int) error ``` Connect registers a worker with the bridge and starts a background SSE heartbeat reader. The SSE connection stays open until Disconnect is called or ctx is cancelled. ### func \(\*Client\) [Disconnect]() ```go func (c *Client) Disconnect() ``` Disconnect cancels the SSE connection. ### func \(\*Client\) [Fail]() ```go func (c *Client) Fail(ctx context.Context, taskID string, errMsg string) error ``` Fail resolves a task as failed with an error message. ### func \(\*Client\) [Pause]() ```go func (c *Client) Pause(ctx context.Context, taskID string, name string, duration time.Duration, checkpoint json.RawMessage) error ``` Pause pauses a task with optional checkpoint, to be resumed after the given duration. ### func \(\*Client\) [Poll]() ```go func (c *Client) Poll(ctx context.Context, taskTypes []string, maxTasks int, timeout time.Duration) ([]protocol.TaskPayload, error) ``` Poll long\-polls for available tasks. Returns an empty slice on timeout. ## type [Option]() Option configures a Client. ```go type Option func(*Client) ``` ### func [WithToken]() ```go func WithToken(token string) Option ``` WithToken sets the bearer token for bridge authentication. Generated by [gomarkdoc]() --- # Source: docs/site/content/docs/reference/sdk/observe/_index.md ``` import "github.com/danmestas/dagnats/observe" ``` Provider-agnostic telemetry interfaces for logging, tracing, metrics, and error reporting. DagNats defines interfaces in this package and ships adapter implementations separately, following the adapter pattern. ## Key Interfaces | Interface | Description | |-----------|-------------| | `Logger` | Structured logging with level-based methods (`Info`, `Warn`, `Error`, `Debug`) | | `Tracer` | Distributed tracing: `Start(ctx, name)` returns a `(context.Context, Span)` | | `Span` | Individual trace span with `End()`, `RecordError()`, `SetStatus()`, `SetAttributes()` | | `SpanContext` | Optional interface on Span for extracting `TraceID()` and `SpanID()` | | `Metrics` | Instrument factory: `Counter(name)`, `Histogram(name)`, `Gauge(name)` | | `Counter` | Monotonic counter: `Inc()`, `Add(delta)` | | `Histogram` | Distribution recorder: `Observe(value)` | | `Gauge` | Point-in-time value: `Set(value)` | | `ErrorReporter` | Error reporting interface: `CaptureError(err)`, `CaptureMessage(msg)` | ## Telemetry Bundle The `Telemetry` struct bundles all four concerns into a single value passed through the system: ```go type Telemetry struct { Logger Logger Tracer Tracer Metrics Metrics ErrorReporter ErrorReporter } ``` ## No-op Implementations For testing and development, the package provides no-op implementations that satisfy all interfaces without producing output: | Function | Description | |----------|-------------| | `NewNoopTelemetry()` | Returns a `*Telemetry` with all no-op implementations | | `NewNoopLogger()` | Returns a `Logger` that discards all output | | `NewNoopTracer()` | Returns a `Tracer` with no-op spans | | `NewNoopMetrics()` | Returns a `Metrics` that discards all recordings | ## Attribute Helpers Helper functions for creating span and log attributes: | Function | Returns | |----------|---------| | `String(key, value)` | Key-value string attribute | | `Int(key, value)` | Key-value int attribute | | `Bool(key, value)` | Key-value bool attribute | | `StringAttr(key, value)` | Span attribute (string) | | `Int64Attr(key, value)` | Span attribute (int64) | | `BoolAttr(key, value)` | Span attribute (bool) | ## Adapter Pattern To integrate with a real telemetry backend (e.g., OpenTelemetry, Sentry): 1. Implement the interfaces in this package 2. Wire them into a `Telemetry` struct 3. Pass to `server.New()`, `worker.NewWorker()`, or `api.NewService()` The `internal/observe/simple` package provides a NATS JetStream-backed implementation used by the embedded server. ## Usage ```go // Production: wire real implementations tel := &observe.Telemetry{ Logger: myOtelLogger, Tracer: myOtelTracer, Metrics: myOtelMetrics, } // Testing: use no-ops tel := observe.NewNoopTelemetry() ``` --- # Source: docs/site/content/docs/reference/sdk/observe/api.md # observe ```go import "github.com/danmestas/dagnats/observe" ``` config.go defines the telemetry configuration struct. Kept separate from setup.go so callers can construct Config without importing OTel SDK types. propagation.go provides trace context propagation helpers for NATS message boundaries. InjectTraceContext writes W3C trace context to both NATS headers and the Event payload for persistence. ExtractTraceContext reads from headers first, falling back to Event.TraceParent for replay scenarios. setup.go is the single entry point for OTel provider setup. One call to InitTelemetry wires tracing, metrics, and logging with NATS\-backed exporters \(always\) and OTLP/HTTP exporters \(when configured\). This is a deep module: rich behavior behind a minimal interface. ## Index - [func ExtractTraceContext\(msg jetstream.Msg, evt \*protocol.Event\) context.Context](<#ExtractTraceContext>) - [func ExtractTraceContextRaw\(msg \*nats.Msg, evt \*protocol.Event\) context.Context](<#ExtractTraceContextRaw>) - [func InitTelemetry\(ctx context.Context, cfg Config\) \(func\(context.Context\), error\)](<#InitTelemetry>) - [func InjectTraceContext\(ctx context.Context, msg \*nats.Msg, evt \*protocol.Event\)](<#InjectTraceContext>) - [type Config](<#Config>) - [type NATSHeaderCarrier](<#NATSHeaderCarrier>) - [func \(c NATSHeaderCarrier\) Get\(key string\) string](<#NATSHeaderCarrier.Get>) - [func \(c NATSHeaderCarrier\) Keys\(\) \[\]string](<#NATSHeaderCarrier.Keys>) - [func \(c NATSHeaderCarrier\) Set\(key, val string\)](<#NATSHeaderCarrier.Set>) ## func [ExtractTraceContext]() ```go func ExtractTraceContext(msg jetstream.Msg, evt *protocol.Event) context.Context ``` ExtractTraceContext reads W3C trace context from NATS headers, falling back to Event.TraceParent for replay. Accepts jetstream.Msg for consumer message handling. evt may be nil when no event fallback is needed. ## func [ExtractTraceContextRaw]() ```go func ExtractTraceContextRaw(msg *nats.Msg, evt *protocol.Event) context.Context ``` ExtractTraceContextRaw reads W3C trace context from a raw \*nats.Msg header, falling back to Event.TraceParent for replay. Used in publish paths that work with \*nats.Msg. ## func [InitTelemetry]() ```go func InitTelemetry(ctx context.Context, cfg Config) (func(context.Context), error) ``` InitTelemetry creates and registers OTel TracerProvider, MeterProvider, and LoggerProvider. Returns a shutdown function that flushes and closes all three providers. Panics on programmer errors \(nil conn, empty service name\). ## func [InjectTraceContext]() ```go func InjectTraceContext(ctx context.Context, msg *nats.Msg, evt *protocol.Event) ``` InjectTraceContext writes W3C trace context to both NATS headers and the Event's TraceParent/TraceState fields for persistence. Panics on nil msg \(programmer error\). evt may be nil when no event dual\-write is needed. ## type [Config]() Config controls how InitTelemetry wires OTel providers. ServiceName and NATSConn are required — InitTelemetry panics if either is zero\-valued. OTLPEndpoint, when non\-empty, gates whether OTLP/HTTP export is enabled at all; the actual endpoint, headers, TLS, protocol, and per\-signal overrides come from the standard OTel SDK env vars \(see \#184\). ```go type Config struct { // ServiceName identifies this process in telemetry data. ServiceName string // NATSConn is used to create a JetStream context for the // NATS-backed exporters. Caller owns the connection. NATSConn *nats.Conn // OTLPEndpoint is a sentinel that gates OTLP exporter // construction: when non-empty, dagnats constructs OTLP // exporters and the OTel SDK reads its standard env vars // (OTEL_EXPORTER_OTLP_ENDPOINT, _HEADERS, _PROTOCOL, // _INSECURE, per-signal variants, etc.) for the actual // endpoint and transport configuration. The string value // itself is no longer passed to the SDK. OTLPEndpoint string // Resource holds additional OTel resource attributes // merged into the auto-detected set (host, OS, process). Resource map[string]string } ``` ## type [NATSHeaderCarrier]() NATSHeaderCarrier adapts nats.Header to OTel's propagation.TextMapCarrier interface, enabling W3C trace\-context injection and extraction over NATS messages. ```go type NATSHeaderCarrier struct { Header nats.Header } ``` ### func \(NATSHeaderCarrier\) [Get]() ```go func (c NATSHeaderCarrier) Get(key string) string ``` Get returns the first value for the given key, or "" if the header is nil. ### func \(NATSHeaderCarrier\) [Keys]() ```go func (c NATSHeaderCarrier) Keys() []string ``` Keys returns all header keys in sorted order, or nil if Header is nil. ### func \(NATSHeaderCarrier\) [Set]() ```go func (c NATSHeaderCarrier) Set(key, val string) ``` Set stores a key\-value pair. Panics if Header is nil \(programmer error\). Generated by [gomarkdoc]() --- # Source: docs/site/content/docs/reference/sdk/protocol/_index.md ``` import "github.com/danmestas/dagnats/protocol" ``` Wire types shared between the engine, workers, and API. This package defines the event schema, task payload format, and resolution types that flow through NATS subjects. ## Key Types | Type | Description | |------|-------------| | `Event` | Envelope for all workflow and step lifecycle events | | `EventType` | String enum for event types (`workflow.started`, `step.completed`, etc.) | | `TaskPayload` | Message body published to task subjects when the engine dispatches a step | | `TaskResolution` | Resolution sent by HTTP workers via the bridge resolve endpoint | | `FailureType` | Categorizes failures: `permanent`, `transient`, `rate_limited` | ## Event Types | Constant | Value | Scope | |----------|-------|-------| | `EventWorkflowStarted` | `workflow.started` | Workflow | | `EventWorkflowCompleted` | `workflow.completed` | Workflow | | `EventWorkflowFailed` | `workflow.failed` | Workflow | | `EventWorkflowCancelled` | `workflow.cancelled` | Workflow | | `EventStepStarted` | `step.started` | Step | | `EventStepCompleted` | `step.completed` | Step | | `EventStepFailed` | `step.failed` | Step | | `EventStepContinue` | `step.continue` | Step (agent loop) | | `EventApprovalGranted` | `approval.granted` | Step (approval) | | `EventApprovalRejected` | `approval.rejected` | Step (approval) | ## Event Structure ```go type Event struct { Type EventType `json:"type"` RunID string `json:"run_id"` StepID string `json:"step_id,omitempty"` Timestamp time.Time `json:"timestamp"` Payload json.RawMessage `json:"payload,omitempty"` TraceParent string `json:"trace_parent,omitempty"` } ``` ## Constructor Functions | Function | Description | |----------|-------------| | `NewWorkflowEvent(typ, runID, payload)` | Creates a workflow-scoped event | | `NewStepEvent(typ, runID, stepID, payload)` | Creates a step-scoped event | Both constructors set the timestamp and provide methods for NATS subject routing (`NATSSubject()`) and deduplication IDs (`NATSMsgID()`). ## TaskPayload Structure ```go type TaskPayload struct { TaskID string `json:"task_id"` RunID string `json:"run_id"` StepID string `json:"step_id"` Iteration int `json:"iteration,omitempty"` Attempt int `json:"attempt,omitempty"` Input json.RawMessage `json:"input,omitempty"` } ``` ## TaskResolution Structure Used by HTTP workers to resolve tasks via the bridge: ```go type TaskResolution struct { Action string `json:"action"` Output json.RawMessage `json:"output,omitempty"` Error string `json:"error,omitempty"` DurationMs int `json:"duration_ms,omitempty"` Checkpoint json.RawMessage `json:"checkpoint,omitempty"` Data json.RawMessage `json:"data,omitempty"` } ``` Actions: `complete`, `fail`, `pause`, `checkpoint`. --- # Source: docs/site/content/docs/reference/sdk/protocol/api.md # protocol ```go import "github.com/danmestas/dagnats/protocol" ``` ## Index - [type Event](<#Event>) - [func NewStepEvent\(eventType EventType, runID string, stepID string, payload \[\]byte\) Event](<#NewStepEvent>) - [func NewWorkflowEvent\(eventType EventType, runID string, payload \[\]byte\) Event](<#NewWorkflowEvent>) - [func UnmarshalEvent\(data \[\]byte\) \(Event, error\)](<#UnmarshalEvent>) - [func \(e Event\) Marshal\(\) \(\[\]byte, error\)](<#Event.Marshal>) - [func \(e Event\) NATSMsgID\(\) string](<#Event.NATSMsgID>) - [func \(e Event\) NATSSubject\(\) string](<#Event.NATSSubject>) - [type EventType](<#EventType>) - [type FailureType](<#FailureType>) - [type StepFailedPayload](<#StepFailedPayload>) - [type TaskPayload](<#TaskPayload>) - [type TaskResolution](<#TaskResolution>) - [type WorkerStatusSnapshot](<#WorkerStatusSnapshot>) ## type [Event]() Event is the core communication primitive published to the history stream. Payload carries event\-specific data as raw JSON to keep the type schema\-agnostic. ```go type Event struct { Type EventType `json:"type"` RunID string `json:"run_id"` StepID string `json:"step_id,omitempty"` Timestamp time.Time `json:"timestamp"` Payload json.RawMessage `json:"payload,omitempty"` TraceParent string `json:"trace_parent,omitempty"` TraceState string `json:"trace_state,omitempty"` WorkerID string `json:"worker_id,omitempty"` AttemptNumber int `json:"attempt_number,omitempty"` } ``` ### func [NewStepEvent]() ```go func NewStepEvent(eventType EventType, runID string, stepID string, payload []byte) Event ``` NewStepEvent constructs an Event for a step lifecycle transition. Panics on empty runID or stepID — these are programmer errors, not runtime errors. ### func [NewWorkflowEvent]() ```go func NewWorkflowEvent(eventType EventType, runID string, payload []byte) Event ``` NewWorkflowEvent constructs an Event for a workflow lifecycle transition. Panics on empty runID — programmer error. ### func [UnmarshalEvent]() ```go func UnmarshalEvent(data []byte) (Event, error) ``` UnmarshalEvent deserializes a NATS message body into an Event. ### func \(Event\) [Marshal]() ```go func (e Event) Marshal() ([]byte, error) ``` Marshal serializes the event to JSON for publishing to NATS. ### func \(Event\) [NATSMsgID]() ```go func (e Event) NATSMsgID() string ``` NATSMsgID returns the deduplication ID for JetStream idempotent publishing. Composed of run, step, and event type so replays are safe. For workflow events \(empty StepID\), omits the step segment to avoid double dots. ### func \(Event\) [NATSSubject]() ```go func (e Event) NATSSubject() string ``` NATSSubject returns the JetStream subject for publishing this event. All events for a run share one subject so consumers get ordered history. Panics on empty RunID — subjects with empty segments are invalid in NATS. ## type [EventType]() EventType identifies the kind of workflow lifecycle event. Using string constants makes events self\-describing over the wire. ```go type EventType string ``` ```go const ( EventWorkflowStarted EventType = "workflow.started" EventStepQueued EventType = "step.queued" EventStepStarted EventType = "step.started" EventStepCompleted EventType = "step.completed" EventStepFailed EventType = "step.failed" EventStepContinue EventType = "step.continue" EventAgentLoopIteration EventType = "agent.loop.iteration" EventStepMapStarted EventType = "step.map.started" EventStepMapCompleted EventType = "step.map.completed" EventStepMapInstanceCompleted EventType = "step.map.instance.completed" EventStepSleepStarted EventType = "step.sleep.started" EventStepSleepCompleted EventType = "step.sleep.completed" EventStepWaitStarted EventType = "step.wait.started" EventStepWaitMatched EventType = "step.wait.matched" EventStepWaitTimeout EventType = "step.wait.timeout" EventWorkflowCompleted EventType = "workflow.completed" EventWorkflowFailed EventType = "workflow.failed" EventWorkflowSpawn EventType = "workflow.spawn" EventWorkflowChildCompleted EventType = "workflow.child.completed" EventWorkflowChildFailed EventType = "workflow.child.failed" EventWorkflowCancelled EventType = "workflow.cancelled" EventStepCancelled EventType = "step.cancelled" EventCompensateStarted EventType = "compensate.started" EventCompensateStepCompleted EventType = "compensate.step.completed" EventCompensateFailed EventType = "compensate.failed" EventCompensateCompleted EventType = "compensate.completed" EventApprovalRequested EventType = "approval.requested" EventApprovalGranted EventType = "approval.granted" EventApprovalRejected EventType = "approval.rejected" EventApprovalExpired EventType = "approval.expired" EventPlannerMaterialized EventType = "planner.materialized" ) ``` ## type [FailureType]() FailureType distinguishes how the engine handles a step failure. ```go type FailureType string ``` ```go const ( FailureTypeRetriable FailureType = "retriable" FailureTypeNonRetriable FailureType = "non_retriable" FailureTypeRetryAfter FailureType = "retry_after" ) ``` ## type [StepFailedPayload]() StepFailedPayload is the structured payload for EventStepFailed. FailureType defaults to retriable when empty \(backward compat\). RetryAfterMs is only used with FailureTypeRetryAfter. ```go type StepFailedPayload struct { Error string `json:"error"` FailureType FailureType `json:"failure_type,omitempty"` RetryAfterMs int64 `json:"retry_after_ms,omitempty"` } ``` ## type [TaskPayload]() TaskPayload is the message body published to a task subject when the engine dispatches a step for execution. Workers unmarshal this to build a TaskContext. Iteration is the agent\-loop iteration index \(0 for the first execution\); workers include it in Continue event MsgIds to prevent JetStream deduplication across cycles. ```go type TaskPayload struct { TaskID string `json:"task_id"` // {runID}.{stepID} RunID string `json:"run_id"` StepID string `json:"step_id"` Iteration int `json:"iteration,omitempty"` Attempt int `json:"attempt,omitempty"` Input json.RawMessage `json:"input,omitempty"` // Metadata is static per-step config copied from StepDef.Metadata, // delivered to workers via TaskContext.Metadata(). Nil when the step // declared no metadata. Metadata map[string]string `json:"metadata,omitempty"` // RequiredCapabilities is copied verbatim from StepDef.RequiredCapabilities // at enqueue time. The worker reads it to gate capability handles (e.g. // the control plane). Nil = no capabilities declared (today's behavior). RequiredCapabilities []string `json:"required_capabilities,omitempty"` // DispatchNonce is the per-dispatch run-binding token (#380, ADR-021 // Phase A). The engine stamps the same value on StepState.DispatchNonce // and here. The worker carries it on control-plane requests so the // server can verify the caller received this exact dispatch. Additive, // omitempty: legacy payloads deserialize to "" and the server rejects an // empty/mismatched nonce on the control-plane path. DispatchNonce string `json:"dispatch_nonce,omitempty"` } ``` ## type [TaskResolution]() TaskResolution is the wire format for HTTP bridge resolve actions. The action field discriminates between complete/fail/pause/checkpoint. Workers POST this to /v1/tasks/\{id\}/resolve to report task outcomes. ```go type TaskResolution struct { Action string `json:"action"` Output json.RawMessage `json:"output,omitempty"` Error string `json:"error,omitempty"` Name string `json:"name,omitempty"` DurationMs int64 `json:"duration_ms,omitempty"` Checkpoint json.RawMessage `json:"checkpoint,omitempty"` Data json.RawMessage `json:"data,omitempty"` } ``` ## type [WorkerStatusSnapshot]() WorkerStatusSnapshot is the per\-worker counter snapshot stored in the worker\_status KV bucket. Workers update their entry as counters change; the CLI reads and aggregates entries to surface drain progress \(issue \#182\). Last\-write\-wins on the WorkerID key. ```go type WorkerStatusSnapshot struct { WorkerID string `json:"worker_id"` CancelledTasksSkipped uint64 `json:"cancelled_tasks_skipped"` } ``` Generated by [gomarkdoc]() --- # Source: docs/site/content/docs/reference/sdk/server/_index.md ``` import "github.com/danmestas/dagnats/server" ``` Embedded NATS server with the full DagNats stack: engine, API, bridge, and trigger runtime. Provides programmatic startup for single-binary deployment. ## Key Types | Type | Description | |------|-------------| | `Config` | Server configuration: data directory, ports, leaf node remotes, store limits | | `Server` | The assembled server: embedded NATS, engine, HTTP API, bridge | ## Key Functions | Function | Description | |----------|-------------| | `New(cfg)` | Creates a new server from configuration | | `ConfigFromEnv()` | Loads configuration from `dagnats.yaml` and environment variables | | `EmbeddedWorker(srv)` | Returns a `*worker.Worker` connected to the embedded server's NATS | ## Configuration Sources Configuration is resolved in order (later sources override): 1. Built-in defaults 2. `dagnats.yaml` in the current directory 3. Environment variables | Environment Variable | Config Field | Default | |---------------------|-------------|---------| | `DAGNATS_DATA_DIR` | `DataDir` | `/tmp/dagnats` | | `DAGNATS_HTTP_ADDR` | `HTTPAddr` | `:8080` | | `DAGNATS_NATS_PORT` | `NATSPort` | `4222` | | `DAGNATS_LEAF_REMOTES` | `LeafRemotes` | (none) | | `DAGNATS_LEAF_CREDENTIALS` | `LeafCredentials` | (none) | | `DAGNATS_MAX_STORE_BYTES` | `MaxStoreBytes` | `1073741824` (1 GiB) | | `DAGNATS_MONITOR_PORT` | `MonitorPort` | (disabled) | ## Server Lifecycle 1. `New(cfg)` creates the server and starts the embedded NATS instance 2. Optionally attach embedded workers with `EmbeddedWorker(srv)` 3. `Run()` starts the engine, API, bridge, and blocks until SIGINT/SIGTERM 4. Graceful shutdown stops all components in reverse order ## Usage ```go cfg := server.ConfigFromEnv() srv := server.New(cfg) // Optional: register embedded workers w := server.EmbeddedWorker(srv) w.Handle("process", myHandler) // Blocks until signal if err := srv.Run(); err != nil { log.Fatal(err) } ``` --- # Source: docs/site/content/docs/reference/sdk/server/api.md # server ```go import "github.com/danmestas/dagnats/server" ``` server/dry\_run.go Dry\-run validation: loads config, reports sources, checks prerequisites. Validates environment without starting any components. server/metrics\_auth.go METRICS\_AUTH gate around the /metrics exporter. Independent from the console gate: an operator may want the console locked behind basic auth while letting their Prometheus scraper hit /metrics with a service\-account token \(forward auth\). Documented modes: ``` loopback (default): only 127.0.0.1 / ::1 reaches /metrics. basic: HTTP Basic Auth from METRICS_BASIC_USER + METRICS_BASIC_PASS. forward: trust X-Forwarded-User from an upstream proxy. none: open. Logs a Warn at startup in a non-dev context. ``` The gate wraps prom.Handler; the rendered output is unchanged. ## Index - [func LogMetricsAuthStartup\(logger \*slog.Logger, cfg MetricsAuthConfig, httpAddr string\)](<#LogMetricsAuthStartup>) - [func PrintDryRun\(w io.Writer, rc ResolvedConfig\) bool](<#PrintDryRun>) - [type Config](<#Config>) - [func ConfigFromEnv\(\) Config](<#ConfigFromEnv>) - [func ConfigWithPath\(configPath string\) \(Config, string, error\)](<#ConfigWithPath>) - [func DefaultConfig\(\) Config](<#DefaultConfig>) - [type ConfigEntry](<#ConfigEntry>) - [type MetricsAuthConfig](<#MetricsAuthConfig>) - [func LoadMetricsAuthConfigFromEnv\(logger \*slog.Logger\) MetricsAuthConfig](<#LoadMetricsAuthConfigFromEnv>) - [type MetricsAuthMode](<#MetricsAuthMode>) - [type ResolvedConfig](<#ResolvedConfig>) - [func ResolveConfig\(\) ResolvedConfig](<#ResolveConfig>) - [type Server](<#Server>) - [func New\(cfg Config\) \*Server](<#New>) - [func \(s \*Server\) Run\(\) error](<#Server.Run>) - [func \(s \*Server\) Stop\(\)](<#Server.Stop>) - [type ValidationResult](<#ValidationResult>) - [func DryRunValidate\(cfg Config\) \(\[\]ValidationResult, bool\)](<#DryRunValidate>) - [type WorkerConfig](<#WorkerConfig>) - [type WorkerShim](<#WorkerShim>) - [func EmbeddedWorker\(srv \*Server\) \*WorkerShim](<#EmbeddedWorker>) - [func \(s \*WorkerShim\) Handle\(taskType string, handler worker.HandlerFunc\)](<#WorkerShim.Handle>) - [func \(s \*WorkerShim\) HandleLoop\(taskType string, fn func\(worker.LoopTask\) error\)](<#WorkerShim.HandleLoop>) - [func \(s \*WorkerShim\) HandleSignal\(taskType string, fn func\(worker.SignalTask\) error\)](<#WorkerShim.HandleSignal>) - [func \(s \*WorkerShim\) HandleSingleton\(taskType string, handler worker.HandlerFunc\)](<#WorkerShim.HandleSingleton>) - [func \(s \*WorkerShim\) HandleStream\(taskType string, fn func\(worker.StreamTask\) error\)](<#WorkerShim.HandleStream>) - [func \(s \*WorkerShim\) WithGroups\(groups ...string\)](<#WorkerShim.WithGroups>) ## func [LogMetricsAuthStartup]() ```go func LogMetricsAuthStartup(logger *slog.Logger, cfg MetricsAuthConfig, httpAddr string) ``` LogMetricsAuthStartup emits a single INFO line announcing the resolved auth mode for /metrics, and escalates to WARN when the listener is non\-loopback AND the operator picked the open mode. The WARN message is operator\-actionable: it tells the reader that the endpoint is open to anyone on the network, not just localhost. Called from the server boot path immediately after LoadMetricsAuthConfigFromEnv resolves. ## func [PrintDryRun]() ```go func PrintDryRun(w io.Writer, rc ResolvedConfig) bool ``` PrintDryRun writes the dry\-run report to w. Returns true if all validations passed. ## type [Config]() Config holds all server configuration. ```go type Config struct { DataDir string `json:"data_dir"` HTTPAddr string `json:"http_addr"` NATSPort int `json:"nats_port"` LeafRemotes []string `json:"leaf_remotes"` LeafCredentials string `json:"leaf_credentials"` NATSClusterName string `json:"nats_cluster_name"` NATSClusterRoutes []string `json:"nats_cluster_routes"` NATSClusterAuthToken string `json:"nats_cluster_auth_token"` NATSJetStreamReplicas int `json:"nats_jetstream_replicas"` MonitorPort int `json:"monitor_port"` MaxStoreBytes int64 `json:"max_store_bytes"` // MaxMemoryBytes caps the JetStream in-memory store // (JetStreamMaxMemory) and is applied as the soft Go memory limit at // startup (#441). Defaults to defaultMaxMemoryBytes; <= 0 disables the // JetStream cap and the Go limit. MaxMemoryBytes int64 `json:"max_memory_bytes"` Workers []WorkerConfig `json:"workers"` OTLPEndpoint string `json:"otlp_endpoint"` // RunsMaxAge is the opt-in run-retention window for the // workflow_runs KV (#453). Zero (the default) DISABLES pruning // entirely — upgrading never silently deletes anyone's runs. // When > 0, terminal runs older than this are dropped // (delete-only) by the orchestrator's background sweeper. Set // via DAGNATS_RUNS_MAX_AGE, which accepts a Go duration ("720h") // or a d/w suffix ("30d", "2w") for operator ergonomics. RunsMaxAge time.Duration `json:"runs_max_age"` // Per-runtime safety bounds (ADR-021 Phase A, #378). These cap a // single spawn-tree's resource use so a runaway agent loop cannot // fork-bomb the orchestrator. Zero means DEFAULT, not "unlimited" — it // resolves to the default consts in internal/api at service // construction (additive: an old config file silently inherits the // defaults; a safety layer has no "off" switch). #380 owns grant // policy; #378 only reads these numeric values. // // MaxActiveRunsPerRoot caps non-terminal runs sharing a tree-root. // MaxDefsPerRoot caps ephemeral defs registered under a tree-root. // MaxGenerationDepth caps spawn-chain nesting depth (reuses the // existing depth check; default equals engine.MaxNestingDepth). // MaxRegistersPerMinutePerRoot rate-limits runtime def registration. MaxActiveRunsPerRoot int `json:"max_active_runs_per_root"` MaxDefsPerRoot int `json:"max_defs_per_root"` MaxGenerationDepth int `json:"max_generation_depth"` MaxRegistersPerMinutePerRoot int `json:"max_registers_per_minute_per_root"` // NATSWebsocketPort enables an embedded NATS WebSocket // listener for browser clients when > 0. 0 (default) // disables it — the safe production posture. See ADR-020. NATSWebsocketPort int `json:"nats_ws_port"` // NATSWebsocketNoTLS turns off TLS for the WebSocket // listener. Until top-level NATS TLS is wired this is // required when NATSWebsocketPort > 0; the explicit // opt-in keeps operators from shipping cleartext to // production by accident. NATSWebsocketNoTLS bool `json:"nats_ws_no_tls"` // FailOnPortConflict makes startup return an error (non-zero exit) // instead of auto-falling-back to an ephemeral port when the default // NATS port or default HTTP address is already in use. Default false // keeps auto-fallback as the documented behavior (#370). Opt-in for // operators who want a hard failure when a stale server holds the // port. FailOnPortConflict bool `json:"fail_on_port_conflict"` // Build is the binary's version/revision string, threaded from // cli.Version by the serve command (ldflags-stamped). Empty for // un-stamped local builds — the console footer degrades empty to // the honest "dev" marker (consoleBuildLabel). Not persisted to // dagnats.yaml; it is link-time identity, not user config. Build string `json:"-"` // ConfigFilePath is the absolute path of the dagnats.yaml that // was loaded (empty when no file was found). Phase 4 / ADR-018: // the server uses it to drive the configfile.Watcher for live // reload of workflows and triggers declared in the same file. // Not stored in the on-disk file itself — populated by the CLI // from the resolved path after the file is loaded. ConfigFilePath string `json:"-"` // DieWithParent makes a spawned `dagnats serve` self-terminate via // the normal graceful shutdown when its parent process dies (#476). // Default OFF. Opt-in for sidecar spawners (notify's e2e tests, the // eventbus sidecar) whose own cleanup can't run on SIGKILL / // `go test -timeout`, so they'd otherwise orphan the server. The // CLI's --die-with-parent flag sets it; not persisted to // dagnats.yaml — it's an invocation mode, not stored config. DieWithParent bool `json:"-"` } ``` ### func [ConfigFromEnv]() ```go func ConfigFromEnv() Config ``` ConfigFromEnv loads config from defaults, config file, then env vars. Config file is dagnats.yaml in CWD. Missing file is not an error. Panics if DataDir is empty or MaxStoreBytes \<= 0 after resolution. ### func [ConfigWithPath]() ```go func ConfigWithPath(configPath string) (Config, string, error) ``` ConfigWithPath loads config using an explicit path or standard search. Returns the resolved config and the path of the file that was loaded \(empty string if no file was found\). When configPath is non\-empty, the file must exist or an error is returned. Panics if DataDir is empty or MaxStoreBytes \<= 0 after resolution. ### func [DefaultConfig]() ```go func DefaultConfig() Config ``` DefaultConfig returns platform\-appropriate defaults. Panics if dataDir resolves empty. ## type [ConfigEntry]() ConfigEntry holds a resolved config value and its source. ```go type ConfigEntry struct { Key string Value string Source string } ``` ## type [MetricsAuthConfig]() MetricsAuthConfig captures the gate's inputs. HTTPAddr is required because the loopback mode rejects non\-loopback requests by remote address rather than listener bind. ```go type MetricsAuthConfig struct { Mode MetricsAuthMode BasicUser string BasicPass string } ``` ### func [LoadMetricsAuthConfigFromEnv]() ```go func LoadMetricsAuthConfigFromEnv(logger *slog.Logger) MetricsAuthConfig ``` LoadMetricsAuthConfigFromEnv reads METRICS\_AUTH \+ METRICS\_BASIC\_USER \+ METRICS\_BASIC\_PASS from the process environment. Defaults to loopback when METRICS\_AUTH is unset. Unknown modes are normalised to loopback with a slog.Warn so a typo fails closed. ## type [MetricsAuthMode]() MetricsAuthMode is the bounded enum the gate accepts. The env var METRICS\_AUTH carries the string form; resolveMetricsAuthMode normalises it. ```go type MetricsAuthMode string ``` ```go const ( MetricsAuthLoopback MetricsAuthMode = "loopback" MetricsAuthBasic MetricsAuthMode = "basic" MetricsAuthForward MetricsAuthMode = "forward" MetricsAuthNone MetricsAuthMode = "none" ) ``` ## type [ResolvedConfig]() ResolvedConfig holds config entries with provenance tracking. ```go type ResolvedConfig struct { Config Config Entries []ConfigEntry } ``` ### func [ResolveConfig]() ```go func ResolveConfig() ResolvedConfig ``` ResolveConfig loads config and tracks the source of each value. Returns resolved config with provenance for every key. ## type [Server]() Server is the all\-in\-one DagNats server lifecycle manager. ```go type Server struct { // contains filtered or unexported fields } ``` ### func [New]() ```go func New(cfg Config) *Server ``` New creates a Server with the given config. Panics if DataDir is empty. ### func \(\*Server\) [Run]() ```go func (s *Server) Run() error ``` Run starts all server components, serves HTTP, and blocks until shutdown. Returns nil on clean shutdown, error otherwise. ### func \(\*Server\) [Stop]() ```go func (s *Server) Stop() ``` Stop closes the stopCh to trigger shutdown. Safe to call multiple times. ## type [ValidationResult]() ValidationResult holds one check outcome. ```go type ValidationResult struct { Name string Passed bool Detail string } ``` ### func [DryRunValidate]() ```go func DryRunValidate(cfg Config) ([]ValidationResult, bool) ``` DryRunValidate checks prerequisites without starting components. Returns validation results and true if all passed. ## type [WorkerConfig]() WorkerConfig defines a config\-driven embedded worker handler. ```go type WorkerConfig struct { Task string Exec string HTTP string HTTPMethod string // default: POST } ``` ## type [WorkerShim]() WorkerShim collects handler registrations before the server starts. Returned by EmbeddedWorker\(\). The shim is materialized to a real \*worker.Worker during startComponents\(\). ```go type WorkerShim struct { // contains filtered or unexported fields } ``` ### func [EmbeddedWorker]() ```go func EmbeddedWorker(srv *Server) *WorkerShim ``` EmbeddedWorker creates a WorkerShim bound to srv's lifecycle. Must be called before Run\(\). Panics if called after Run\(\), if srv is nil, or if the max embedded worker limit is exceeded. ### func \(\*WorkerShim\) [Handle]() ```go func (s *WorkerShim) Handle(taskType string, handler worker.HandlerFunc) ``` Handle registers a handler for a task type. Panics if called after Run\(\), if taskType is empty, or if handler is nil. ### func \(\*WorkerShim\) [HandleLoop]() ```go func (s *WorkerShim) HandleLoop(taskType string, fn func(worker.LoopTask) error) ``` HandleLoop registers an agent\-loop handler. During materialization, the handler is wrapped as HandlerFunc and dispatched via w.Handle. ### func \(\*WorkerShim\) [HandleSignal]() ```go func (s *WorkerShim) HandleSignal(taskType string, fn func(worker.SignalTask) error) ``` HandleSignal registers an inter\-step signal handler. During materialization, the handler is wrapped as HandlerFunc and dispatched via w.Handle. ### func \(\*WorkerShim\) [HandleSingleton]() ```go func (s *WorkerShim) HandleSingleton(taskType string, handler worker.HandlerFunc) ``` HandleSingleton registers a handler that runs as a single\- partition consumer. During materialization, translated to worker.HandleSingleton. ### func \(\*WorkerShim\) [HandleStream]() ```go func (s *WorkerShim) HandleStream(taskType string, fn func(worker.StreamTask) error) ``` HandleStream registers a streaming\-output handler. During materialization, the handler is wrapped as HandlerFunc and dispatched via w.Handle. ### func \(\*WorkerShim\) [WithGroups]() ```go func (s *WorkerShim) WithGroups(groups ...string) ``` WithGroups configures this embedded worker for specific worker groups. During materialization, translated to worker.WithGroups\(groups...\). Panics after Run\(\). Generated by [gomarkdoc]() --- # Source: docs/site/content/docs/reference/sdk/worker/_index.md ``` import "github.com/danmestas/dagnats/worker" ``` Task execution framework for building DagNats workers. Handles NATS subscription management, message acknowledgment, retry/NAK logic, heartbeat registration, and the task context API that handlers use. ## Key Types | Type | Description | |------|-------------| | `Worker` | Core worker runtime: manages NATS subscriptions, dispatches tasks to handlers, handles lifecycle | | `TaskContext` | Interface passed to handlers: provides input, completion, failure, checkpointing, and signal methods | | `WorkerRegistration` | Metadata registered in the `workers` KV bucket for discovery | ## Worker Lifecycle 1. Create a worker with `NewWorker(nc, tel)` 2. Register handlers with `Handle(taskType, fn)` or `HandleTyped(taskType, fn)` 3. Call `Start()` to begin consuming tasks (blocks until shutdown) ## Handle vs HandleTyped | Method | Signature | Input Access | |--------|-----------|-------------| | `Handle` | `func(ctx TaskContext) error` | `ctx.Input()` returns raw `[]byte` | | `HandleTyped[I, O]` | `func(ctx TaskContext, input I) (O, error)` | Automatic JSON unmarshal into `I`, marshal `O` on return | `HandleTyped` is a generic convenience wrapper. It unmarshals the input, calls your function, and auto-completes with the marshaled output on nil error. ## TaskContext Methods | Method | Description | |--------|-------------| | `Input() []byte` | Raw input JSON | | `RunID() string` | Workflow run ID | | `StepID() string` | Step ID | | `Attempt() int` | Current retry attempt (1-based) | | `Complete(output []byte) error` | Mark task as successfully completed | | `Fail(err error) error` | Mark task as failed | | `Continue(output []byte) error` | Request next agent-loop iteration | | `Checkpoint(data []byte) error` | Save incremental state without pausing | | `LoadCheckpoint() ([]byte, error)` | Load previously saved checkpoint | | `Pause(duration, checkpoint) error` | Suspend and redeliver after duration | | `WaitForSignal(name) ([]byte, error)` | Block until a named signal arrives | ## Error Helpers | Function | Description | |----------|-------------| | `Retryable(err)` | Wraps an error to indicate it should be retried (NAK with delay) | | `IsRetryable(err) bool` | Checks if an error was marked retryable | | `Fatal(err)` | Wraps an error to indicate no retry (ACK + fail immediately) | | `IsFatal(err) bool` | Checks if an error was marked fatal | ## Usage ```go nc, _ := nats.Connect("nats://localhost:4222") tel := observe.NewNoopTelemetry() w := worker.NewWorker(nc, tel) w.Handle("process", func(ctx worker.TaskContext) error { input := ctx.Input() result := doWork(input) return ctx.Complete(result) }) w.Start() // blocks ``` --- # Source: docs/site/content/docs/reference/sdk/worker/api.md # worker ```go import "github.com/danmestas/dagnats/worker" ``` worker/consumer\_collision.go Registration\-time precheck: refuse to start if any \(taskType, group\) pair in the worker's configured handlers collides on the durable consumer name after sanitization. Catches cases like "render.gpu" \+ "render\-gpu" before they corrupt NATS state via CreateOrUpdateConsumer. worker/consumer\_collision\_xprocess.go Cross\-process precheck \(ADR\-010\): catches the case where two workers in different processes registered different task types whose sanitized durable names collide. Worker A registered first; this helper runs in Worker B's subscribePullConsumer before CreateOrUpdateConsumer, sees the existing durable with our name but a different FilterSubject, and panics with both filters named so the operator knows which task types to rename. Companion to assertNoConsumerNameCollisions \(consumer\_collision.go\), which catches the same collision class in the same Worker. This file is the NATS\-aware twin that catches the cross\-process variant. worker/consumer\_naming.go Naming convention for dagnats\-managed JetStream consumers on TASK\_QUEUES. All durable names live under the "workers\-" prefix; sanitization maps task\-type/group strings to NATS\-legal name fragments. worker/controlplane.go ControlPlane is the worker\-side handle a GATED task handler uses to author and launch workflows at runtime. It is a DEEP module: the two methods below hide def validation, server\-side namespacing, run lineage, the maxNestingDepth cap, and every NATS round\-trip. Handlers never see subjects, KV keys, or wire framing — they hold a small interface and get back a scoped name or a run ID, or a structured typed error they can branch on. Why worker\-side handle over the api micro.Service \(not engine logic\): the validated control\-plane boundary already lives on the dagnats\-api micro.Service \(\#456\). Duplicating register/spawn logic in the worker would fork the namespace/lineage/depth rules. Instead the handle speaks NATS request/reply to two additive subjects and lets the server stay the single source of truth. Every boundary failure returns \*ControlPlaneError so the durable agent loop \(ADR\-002\) can self\-correct instead of crashing. Panics are reserved for programmer errors \(nil ctx, nil receiver, empty ownerRunID\) — never for agent\-supplied data. Public re\-exports of the internal envelope types. Go's internal/ rule blocks downstream workers from importing the canonical definitions directly, so without these aliases every consumer redeclares the struct shape and drifts over time. Aliases \(not wrapper types\) keep the engine and the worker on the same underlying type with zero conversion at the call site. See \#235. worker/identity.go Process identity helpers used to populate WorkerRegistration's Pid / Hostname / Version fields \(\#289\). Resolved once per process and cached, since none of these can change mid\-run. worker/services.go ServiceDef \+ Worker.RegisterService SDK method \(ADR\-017 / \#321\). Services are a metadata namespace for grouping task types under a logical name. They are deliberately separated from the worker directory \(worker/directory.go, \#289\): workers have a 60s TTL and a heartbeat loop; services have neither. A service entry persists across worker restarts and never expires automatically — it is a stable description, not a liveness signal. This file does not import or extend Directory. Sharing machinery would conflate two different lifecycles. See ADR\-017 §Alternatives for the rejected re\-use option. worker/trigger\_types.go Worker\-side complement to the ExternalRegistrar ack micro endpoint \(\#327\). RegisterTriggerType is the SDK call workers make once on boot to \(a\) publish their TriggerTypeDef into the \`trigger\_types\` KV bucket and \(b\) ask the engine to allocate an externalRegistrar so subsequent \`\_TRIGGER.\.\{activate,deactivate\}\` requests get bridged to this worker. KV\-then\-ack ordering is load\-bearing — the engine's handleAck reads the schema bytes straight from KV \(audit\-adjusted contract: "KV is the source of truth"\). A worker that calls ack before its Put has landed will see a "trigger type %q not registered in KV" error and must retry. Idempotency: re\-registering with the same Name \+ OwnerWorkerID \+ ConfigSchema bytes returns nil — workers may call this on every boot without coordinating. Schema or owner drift surfaces as an error so silent fleet skew is impossible. worker/watch\_triggers.go Worker SDK for receiving External trigger activate/deactivate events \(parent \#273 Phase 2.4, \#333\). Bridges the engine's \`\_TRIGGER.\.\{activate,deactivate\}\` request subjects \(driven by the externalRegistrar in internal/trigger/registrar\_external.go\) into user\-supplied callbacks. Subscription lifecycle is internal — the audit \(\#333 comment\) chose internal tracking over returning a Subscription handle because callers already own a Worker.Stop\(\) lifecycle. A separate Unsubscribe\(\) would add a third lifecycle they have to remember. Stop\(\) drains every triggerSubs entry; see worker.go. Catch\-up contract: on subscribe, the worker scans the \`triggers\` KV bucket and fires onActivate for every Enabled entry whose External.Kind matches. Bounded at maxCatchupKeys = 10000 — over\-cap the loop stops and logs a warning rather than silently truncating half\-way through \(TigerStyle "all loops must have fixed upper bounds"; CLAUDE.md\). ## Index - [Variables](<#variables>) - [func HandleCheckpoint\(w \*Worker, taskType string, fn func\(CheckpointTask\) error\)](<#HandleCheckpoint>) - [func HandleLoop\(w \*Worker, taskType string, fn func\(LoopTask\) error\)](<#HandleLoop>) - [func HandleSignal\(w \*Worker, taskType string, fn func\(SignalTask\) error\)](<#HandleSignal>) - [func HandleSimple\(w \*Worker, taskType string, fn func\(SimpleTask\) error\)](<#HandleSimple>) - [func HandleStream\(w \*Worker, taskType string, fn func\(StreamTask\) error\)](<#HandleStream>) - [func HandleTyped\[I, O any\]\(w \*Worker, taskType string, fn TypedHandlerFunc\[I, O\], opts ...TypedOption\)](<#HandleTyped>) - [type CheckpointTask](<#CheckpointTask>) - [type ControlPlane](<#ControlPlane>) - [func NewControlPlane\(nc \*nats.Conn\) ControlPlane](<#NewControlPlane>) - [type ControlPlaneError](<#ControlPlaneError>) - [func \(e \*ControlPlaneError\) Error\(\) string](<#ControlPlaneError.Error>) - [func \(e \*ControlPlaneError\) Is\(target error\) bool](<#ControlPlaneError.Is>) - [func \(e \*ControlPlaneError\) Unwrap\(\) error](<#ControlPlaneError.Unwrap>) - [type ControlPlaneErrorKind](<#ControlPlaneErrorKind>) - [type Directory](<#Directory>) - [func NewDirectory\(js jetstream.JetStream\) \*Directory](<#NewDirectory>) - [func \(d \*Directory\) Deregister\(workerID string\) error](<#Directory.Deregister>) - [func \(d \*Directory\) List\(\) \(\[\]WorkerRegistration, error\)](<#Directory.List>) - [func \(d \*Directory\) Register\(reg WorkerRegistration\) error](<#Directory.Register>) - [type HTTPEnvelope](<#HTTPEnvelope>) - [type HandlerFunc](<#HandlerFunc>) - [func Typed\[I, O any\]\(fn TypedHandlerFunc\[I, O\], opts ...TypedOption\) HandlerFunc](<#Typed>) - [type HandlerOption](<#HandlerOption>) - [func WithAckWait\(d time.Duration\) HandlerOption](<#WithAckWait>) - [type LoopTask](<#LoopTask>) - [type NonRetryableError](<#NonRetryableError>) - [func NewNonRetryableError\(err error\) \*NonRetryableError](<#NewNonRetryableError>) - [func \(e \*NonRetryableError\) Error\(\) string](<#NonRetryableError.Error>) - [func \(e \*NonRetryableError\) Unwrap\(\) error](<#NonRetryableError.Unwrap>) - [type RateLimitError](<#RateLimitError>) - [func NewRateLimitError\(err error, retryAfter time.Duration\) \*RateLimitError](<#NewRateLimitError>) - [func \(e \*RateLimitError\) Error\(\) string](<#RateLimitError.Error>) - [func \(e \*RateLimitError\) Unwrap\(\) error](<#RateLimitError.Unwrap>) - [type RegisterOpts](<#RegisterOpts>) - [type RuntimeBudget](<#RuntimeBudget>) - [type ServiceDef](<#ServiceDef>) - [func ListServices\(js jetstream.JetStream\) \(\[\]ServiceDef, error\)](<#ListServices>) - [type SignalTask](<#SignalTask>) - [type SimpleTask](<#SimpleTask>) - [type StreamTask](<#StreamTask>) - [type TaskContext](<#TaskContext>) - [type TriggerEnvelope](<#TriggerEnvelope>) - [type TypedHandlerFunc](<#TypedHandlerFunc>) - [type TypedOption](<#TypedOption>) - [func UnwrapTrigger\(\) TypedOption](<#UnwrapTrigger>) - [type Worker](<#Worker>) - [func NewWorker\(nc \*nats.Conn, opts ...WorkerOption\) \*Worker](<#NewWorker>) - [func \(w \*Worker\) Handle\(taskType string, handler HandlerFunc, opts ...HandlerOption\)](<#Worker.Handle>) - [func \(w \*Worker\) HandleSingleton\(taskType string, handler HandlerFunc\)](<#Worker.HandleSingleton>) - [func \(w \*Worker\) RegisterService\(def ServiceDef\) error](<#Worker.RegisterService>) - [func \(w \*Worker\) RegisterTriggerType\(ctx context.Context, def dagnatsext.TriggerTypeDef\) error](<#Worker.RegisterTriggerType>) - [func \(w \*Worker\) Start\(\)](<#Worker.Start>) - [func \(w \*Worker\) Stop\(\)](<#Worker.Stop>) - [func \(w \*Worker\) WatchTriggers\(ctx context.Context, kind string, onActivate triggerHandler, onDeactivate triggerHandler\) error](<#Worker.WatchTriggers>) - [type WorkerOption](<#WorkerOption>) - [func WithControlPlane\(cp ControlPlane\) WorkerOption](<#WithControlPlane>) - [func WithGroups\(groups ...string\) WorkerOption](<#WithGroups>) - [func WithPartitions\(n int\) WorkerOption](<#WithPartitions>) - [type WorkerRegistration](<#WorkerRegistration>) ## Variables Sentinels for errors.Is. Each wraps its Kind so a freshly constructed \*ControlPlaneError of the same Kind matches via the Is method below. ```go var ( ErrPromotionUnsupported = &ControlPlaneError{ Kind: KindPromotionUnsupported, Op: "RegisterWorkflow", Message: "promotion is not supported in Tier 1 (deferred to #378)", } ErrInvalidDef = &ControlPlaneError{Kind: KindInvalidDef} ErrNamespace = &ControlPlaneError{Kind: KindNamespace} ErrUnresolvableName = &ControlPlaneError{Kind: KindUnresolvableName} ErrTransport = &ControlPlaneError{Kind: KindTransport} ErrDenied = &ControlPlaneError{Kind: KindDenied} ErrDepthExceeded = &ControlPlaneError{Kind: KindDepthExceeded} ErrQuotaExceeded = &ControlPlaneError{Kind: KindQuotaExceeded} ErrRateLimited = &ControlPlaneError{Kind: KindRateLimited} ) ``` MaxWorkerStaleness is the read\-time cutoff used by List\(\): entries whose last Put is older than this are treated as dead and filtered out. The workers KV bucket has a 60s TTL, but NATS may delay purging past the nominal TTL — this filter makes staleness deterministic for callers \(e.g. \`dagnats workers list\`\) so a SIGKILL'd worker stops appearing within MaxWorkerStaleness rather than waiting for the next NATS cleanup pass. Matches the bucket TTL so dead entries vanish promptly after the heartbeat would have refreshed them. Variable rather than const so tests can shrink the window. ```go var MaxWorkerStaleness = 60 * time.Second ``` ## func [HandleCheckpoint]() ```go func HandleCheckpoint(w *Worker, taskType string, fn func(CheckpointTask) error) ``` HandleCheckpoint registers a handler that receives CheckpointTask for save/restore across retries. ## func [HandleLoop]() ```go func HandleLoop(w *Worker, taskType string, fn func(LoopTask) error) ``` HandleLoop registers a handler that receives LoopTask for agent\-loop iteration with Continue and FailRetryAfter. ## func [HandleSignal]() ```go func HandleSignal(w *Worker, taskType string, fn func(SignalTask) error) ``` HandleSignal registers a handler that receives SignalTask for inter\-step coordination via WaitForSignal/SendSignal. ## func [HandleSimple]() ```go func HandleSimple(w *Worker, taskType string, fn func(SimpleTask) error) ``` HandleSimple registers a handler that receives only SimpleTask. The handler sees a narrow interface — it cannot call Continue, Checkpoint, or other advanced methods. ## func [HandleStream]() ```go func HandleStream(w *Worker, taskType string, fn func(StreamTask) error) ``` HandleStream registers a handler that receives StreamTask for streaming output and heartbeat keep\-alive. ## func [HandleTyped]() ```go func HandleTyped[I, O any](w *Worker, taskType string, fn TypedHandlerFunc[I, O], opts ...TypedOption) ``` HandleTyped registers a typed task handler that automatically marshals/unmarshals JSON. Combines Typed\(\) and Handle\(\) into a single call so workers don't need to know about the wrapping. Optional TypedOption values \(e.g. UnwrapTrigger\) tune the wrapper. ## type [CheckpointTask]() CheckpointTask adds checkpoint/resume capability for handlers that need to persist state across retries. ```go type CheckpointTask interface { SimpleTask Checkpoint(state []byte) error LoadCheckpoint() ([]byte, error) RetryCount() int } ``` ## type [ControlPlane]() ControlPlane lets a gated handler author an ephemeral workflow def at runtime and launch a child run of it. nil unless the step declared the "control\-plane" capability AND the deployment granted it — always nil\-check before use. Adding a method here EXTENDS the public interface: any external implementation \(mock or alternate handle\) must add it too. Budget\(\) was added in \#378; there are no external implementations in\-repo, so this is source\-compatible here, but downstream embedders must update their mocks. ```go type ControlPlane interface { // RegisterWorkflow validates def and persists it under a // server-computed scoped name, returning that name. opts.Promote is // WIRED (#377): true registers under the reaper-immune "promoted.*" // namespace. Promotion is GOVERNED (#380): the server authorizes it // against the grant policy's promote list (keyed on the author name) and // returns KindDenied when the workflow is not authorized. Promoted defs // still bypass the #378 root-scoped quota/rate limits (they have no // owning tree). The returned scopedName is what StartRun expects. RegisterWorkflow( ctx context.Context, def dag.WorkflowDef, opts RegisterOpts, ) (scopedName string, err error) // StartRun launches a child run of the named (scoped) workflow with // the given input, returning the child run ID. Lineage and the // nesting-depth cap are enforced server-side. StartRun( ctx context.Context, name string, input []byte, ) (runID string, err error) // Budget reports the owning tree's current-vs-max for the two quota // dimensions (active runs, registered defs), computed by the same // server-side scan that enforces the quotas. A gated handler reads it // to self-throttle before hitting a KindQuotaExceeded reply (#378). Budget(ctx context.Context) (RuntimeBudget, error) } ``` ### func [NewControlPlane]() ```go func NewControlPlane(nc *nats.Conn) ControlPlane ``` NewControlPlane constructs a ControlPlane bound to nc. The owning run and step are bound later, at grant time, via newControlPlaneFor — this public constructor is what a deployment wires through WithControlPlane. Panics if nc is nil \(programmer error at startup\). ## type [ControlPlaneError]() ControlPlaneError is the single structured error type every boundary failure returns. Kind is the branch key; Op names the failing operation; Message is human\-readable; wrapped carries any underlying cause for errors.Is/As against the sentinels below. ```go type ControlPlaneError struct { Kind ControlPlaneErrorKind Op string Message string // contains filtered or unexported fields } ``` ### func \(\*ControlPlaneError\) [Error]() ```go func (e *ControlPlaneError) Error() string ``` ### func \(\*ControlPlaneError\) [Is]() ```go func (e *ControlPlaneError) Is(target error) bool ``` Is lets errors.Is match by Kind, so callers can write errors.Is\(err, ErrPromotionUnsupported\) regardless of the concrete instance returned. ### func \(\*ControlPlaneError\) [Unwrap]() ```go func (e *ControlPlaneError) Unwrap() error ``` ## type [ControlPlaneErrorKind]() ControlPlaneErrorKind is the small, closed set of failure categories a gated handler may branch on. Stable strings so they can also travel on the wire between the server endpoints and the worker handle. ```go type ControlPlaneErrorKind string ``` ```go const ( KindInvalidDef ControlPlaneErrorKind = "invalid_def" KindNamespace ControlPlaneErrorKind = "namespace" KindUnresolvableName ControlPlaneErrorKind = "unresolvable_name" KindPromotionUnsupported ControlPlaneErrorKind = "promotion_unsupported" KindTransport ControlPlaneErrorKind = "transport" KindDenied ControlPlaneErrorKind = "denied" KindDepthExceeded ControlPlaneErrorKind = "depth_exceeded" // Additive safety-limit kinds (#378). KindQuotaExceeded covers both the // active-run and the def quota; KindRateLimited covers the register // rate limit. A gated handler branches on these to back off. KindQuotaExceeded ControlPlaneErrorKind = "quota_exceeded" KindRateLimited ControlPlaneErrorKind = "rate_limited" ) ``` ## type [Directory]() Directory provides worker visibility via NATS KV. Each worker writes its registration to the "workers" bucket; the bucket's TTL ensures stale entries are purged automatically. ```go type Directory struct { // contains filtered or unexported fields } ``` ### func [NewDirectory]() ```go func NewDirectory(js jetstream.JetStream) *Directory ``` NewDirectory creates a Directory backed by the "workers" KV bucket. Panics if js is nil or the bucket does not exist — both are programmer errors indicating missing setup. ### func \(\*Directory\) [Deregister]() ```go func (d *Directory) Deregister(workerID string) error ``` Deregister removes the worker's entry from the directory. Panics if workerID is empty. Returns nil if the key does not exist. ### func \(\*Directory\) [List]() ```go func (d *Directory) List() ([]WorkerRegistration, error) ``` List returns all currently registered workers. Returns an empty slice when no workers are registered. Skips entries that fail to unmarshal \(TTL expiry race\). ### func \(\*Directory\) [Register]() ```go func (d *Directory) Register(reg WorkerRegistration) error ``` Register writes the worker's registration to the KV bucket. The worker must call Register periodically \(before the 60s TTL\) to maintain its presence. Panics on empty WorkerID or TaskTypes. ## type [HTTPEnvelope]() HTTPEnvelope is the request shape lifted from inbound HTTP and webhook triggers. Bind it via worker.HandleTyped\[HTTPEnvelope\] when worker.UnwrapTrigger\(\) is set. ```go type HTTPEnvelope = httpenvelope.Envelope ``` ## type [HandlerFunc]() HandlerFunc is the function signature for task handlers registered with a Worker. ```go type HandlerFunc func(ctx TaskContext) error ``` ### func [Typed]() ```go func Typed[I, O any](fn TypedHandlerFunc[I, O], opts ...TypedOption) HandlerFunc ``` Typed wraps a TypedHandlerFunc into a HandlerFunc by handling JSON serialization. Marshal/unmarshal failures are wrapped in NonRetryableError because bad serialization will not fix itself on retry. Optional TypedOption values tune the wrapper. ## type [HandlerOption]() HandlerOption configures per\-handler behavior at registration time. Distinct from WorkerOption \(which configures the Worker itself\): HandlerOptions bind a knob to a specific taskType. Variadic on Handle keeps existing callers source\-compatible. ```go type HandlerOption func(w *Worker, taskType string) ``` ### func [WithAckWait]() ```go func WithAckWait(d time.Duration) HandlerOption ``` WithAckWait overrides the JetStream AckWait for the consumer that will be created for taskType. Sub\-second tasks should use a short override so worker\-crash redelivery latency is bounded; long\-running agent loops can opt into a longer wait. Panics if d \<= 0 — non\-positive durations are programmer errors per TigerStyle. ## type [LoopTask]() LoopTask adds agent\-loop iteration capability for handlers that call Continue to request another execution cycle. Includes streaming and heartbeat for long\-running iterations. ```go type LoopTask interface { CheckpointTask Continue(output []byte) error FailRetryAfter(err error, after time.Duration) error PutStream(data []byte) error Heartbeat() error } ``` ## type [NonRetryableError]() NonRetryableError wraps an error to signal that retrying will not help. The worker framework detects this via errors.As and calls ctx.Fail\(\) instead of NakWithDelay, causing immediate permanent failure. ```go type NonRetryableError struct { Err error } ``` ### func [NewNonRetryableError]() ```go func NewNonRetryableError(err error) *NonRetryableError ``` NewNonRetryableError wraps err so the worker framework skips retries. Panics if err is nil — a nil non\-retryable error is a programmer mistake. ### func \(\*NonRetryableError\) [Error]() ```go func (e *NonRetryableError) Error() string ``` ### func \(\*NonRetryableError\) [Unwrap]() ```go func (e *NonRetryableError) Unwrap() error ``` ## type [RateLimitError]() RateLimitError wraps an error to signal a rate limit was hit. The worker framework detects this via errors.As and calls ctx.FailRetryAfter with the specified delay instead of using the default NAK backoff. ```go type RateLimitError struct { Err error RetryAfter time.Duration } ``` ### func [NewRateLimitError]() ```go func NewRateLimitError(err error, retryAfter time.Duration) *RateLimitError ``` NewRateLimitError wraps err with a suggested retry delay. Panics if err is nil or retryAfter is not positive. ### func \(\*RateLimitError\) [Error]() ```go func (e *RateLimitError) Error() string ``` ### func \(\*RateLimitError\) [Unwrap]() ```go func (e *RateLimitError) Unwrap() error ``` ## type [RegisterOpts]() RegisterOpts carries optional knobs for RegisterWorkflow. Promote requests the def be registered under the reaper\-immune "promoted.\*" namespace instead of the ephemeral "agent.\.\*" namespace \(\#377\). The worker forwards the flag; the server owns the namespace shape. Authorization for promotion is GOVERNED by the grant policy's promote list \(\#380\): an unauthorized caller gets KindDenied. Promoted defs remain outside the \#378 root\-scoped quota / rate limits \(those bound only root\-scoped ephemeral defs\). ```go type RegisterOpts struct { Promote bool } ``` ## type [RuntimeBudget]() RuntimeBudget is the server\-computed snapshot of a spawn tree's quota usage \(\#378\). It carries real, scan\-backed numbers — not a stub. Token / compute metering is deferred \(\#378 P3\), so no such field exists here; a zero\-valued field would lie about being tracked. ```go type RuntimeBudget struct { ActiveRuns int `json:"active_runs"` MaxActiveRuns int `json:"max_active_runs"` RegisteredDefs int `json:"registered_defs"` MaxRegisteredDefs int `json:"max_registered_defs"` } ``` ## type [ServiceDef]() ServiceDef is the metadata entry for a logical service in the \`services\` KV bucket. Pure descriptive surface — does NOT gate task invocation. The \`service::task\` convention in task\-type names is just a naming hint; the engine never reads this bucket during dispatch. Fields are intentionally minimal. A \`ParentService\` grouping field was considered and deferred: no consumer exists yet \(\#274 R11 may add one\). Adding it later is additive and last\-write\-wins handles the migration. ```go type ServiceDef struct { Name string `json:"name"` Description string `json:"description"` RegisteredAt time.Time `json:"registered_at"` } ``` ### func [ListServices]() ```go func ListServices(js jetstream.JetStream) ([]ServiceDef, error) ``` ListServices reads every entry from the \`services\` KV bucket. Returns an empty slice when no services are registered. Skips entries that fail to unmarshal so a single bad payload does not block the whole listing \(defensive — the bucket is metadata only, never authoritative\). This is package\-level rather than a method on Worker because the CLI reads the bucket without owning a Worker. It takes a jetstream.JetStream handle so callers can share their existing connection. ## type [SignalTask]() SignalTask adds inter\-step coordination for handlers that wait on or send signals to other steps in the workflow. ```go type SignalTask interface { SimpleTask WaitForSignal( name string, timeout time.Duration, ) ([]byte, error) SendSignal(runID, name string, data []byte) error } ``` ## type [SimpleTask]() SimpleTask is the minimal interface for basic task handlers. Most handlers only need Input, Complete, and Fail. ```go type SimpleTask interface { Input() []byte RunID() string StepID() string Context() context.Context Complete(output []byte) error Fail(err error) error FailPermanent(err error) error } ``` ## type [StreamTask]() StreamTask adds streaming output and heartbeat for handlers that produce incremental results or need keep\-alive signals. ```go type StreamTask interface { SimpleTask PutStream(data []byte) error Heartbeat() error } ``` ## type [TaskContext]() TaskContext is the interface workers use to interact with the DagNats engine. Includes step completion, checkpointing, signals, and streaming. Workers call exactly one of Complete, Fail, or Continue per execution. Checkpoint and signal methods depend on optional KV buckets \("checkpoints" and "signals"\). They return an error if the bucket was not provisioned at startup — check your natsutil.SetupAll call. ```go type TaskContext interface { // Step identity and input Input() []byte RunID() string StepID() string RetryCount() int // Metadata returns static per-step metadata from the workflow step // definition; nil if the step declared none. Metadata() map[string]string Context() context.Context // Step completion — call exactly one per execution Complete(output []byte) error Fail(err error) error FailPermanent(err error) error FailRetryAfter(err error, after time.Duration) error Continue(output []byte) error // Streaming and heartbeat PutStream(data []byte) error Heartbeat() error // Checkpointing — save/restore handler state across retries Checkpoint(state []byte) error LoadCheckpoint() ([]byte, error) Pause(name string, duration time.Duration) error // Signals — coordinate between steps WaitForSignal( name string, timeout time.Duration, ) ([]byte, error) SendSignal(runID, name string, data []byte) error // ControlPlane returns the runtime control-plane handle for this // step, or nil. It is nil unless the step declared the // "control-plane" capability AND the deployment granted one via // WithControlPlane — deny-by-default. Always nil-check before use. ControlPlane() ControlPlane } ``` ## type [TriggerEnvelope]() TriggerEnvelope is the standard outer envelope every trigger publishes. Bind it when the worker needs the trigger metadata \(kind, source, workflow\_id, timestamp\) alongside the inner data. ```go type TriggerEnvelope = trigger.TriggerEnvelope ``` ## type [TypedHandlerFunc]() TypedHandlerFunc is a task handler with typed input and output. The worker.Typed wrapper handles JSON marshal/unmarshal so handlers work with concrete Go types instead of raw \[\]byte. ```go type TypedHandlerFunc[I, O any] func(ctx TaskContext, input I) (O, error) ``` ## type [TypedOption]() TypedOption configures the Typed/HandleTyped wrapper. Variadic on Typed and HandleTyped keeps existing call sites source\-compatible. Distinct from HandlerOption \(which mutates the Worker at registration time\): TypedOption mutates only the in\-memory wrapper config. ```go type TypedOption func(*typedConfig) ``` ### func [UnwrapTrigger]() ```go func UnwrapTrigger() TypedOption ``` UnwrapTrigger asks the Typed wrapper to auto\-detect trigger envelopes in the task input and unmarshal the typed parameter from the envelope's \`data\` field instead of the raw input. Auto\-detect is structural: if the input is a JSON object with both a top\-level \`trigger\` string AND a top\-level \`data\` field, the input is treated as an envelope and \`data\` is extracted as the unmarshal source. Otherwise the input passes through unchanged — plain non\-envelope inputs still work, e.g. during local unit tests or when the workflow is invoked directly without a trigger. Metadata access \(trigger kind, source, timestamp\) is out of scope for v1. Workers that need those fields should drop to ctx.Input\(\) and unmarshal the full envelope manually. See issue \#229 for the path to first\-class metadata access. ## type [Worker]() Worker subscribes to task subjects and dispatches messages to registered handlers. Each task type gets its own JetStream subscription; messages are ack'd after the handler returns so failures are retried by JetStream's MaxDeliver policy. ```go type Worker struct { // contains filtered or unexported fields } ``` ### func [NewWorker]() ```go func NewWorker(nc *nats.Conn, opts ...WorkerOption) *Worker ``` NewWorker creates a Worker using the given connection. Panics if nc is nil or if JetStream cannot be initialised — both are programmer errors at startup. Tracing and metrics use the global OTel providers \(noop by default\). ### func \(\*Worker\) [Handle]() ```go func (w *Worker) Handle(taskType string, handler HandlerFunc, opts ...HandlerOption) ``` Handle registers a HandlerFunc for the given task type. Optional HandlerOptions \(e.g. WithAckWait\) tune per\-task knobs. Panics on empty taskType or nil handler — both are programmer errors. ### func \(\*Worker\) [HandleSingleton]() ```go func (w *Worker) HandleSingleton(taskType string, handler HandlerFunc) ``` HandleSingleton registers a handler that runs as a single\- partition elastic consumer group. Only one consumer processes messages at a time across all worker instances. Implicitly enables partitioned mode if not already configured. ### func \(\*Worker\) [RegisterService]() ```go func (w *Worker) RegisterService(def ServiceDef) error ``` RegisterService publishes service metadata to the \`services\` KV bucket. Last\-write\-wins: re\-calling with different Description \(or any other field\) silently replaces the prior entry without error. This is intentional — the bucket is a descriptive surface, not an authoritative registry, and worker restarts must be safe to repeat without conflict\-handling boilerplate at every call site. Idempotency contract: - Two calls with identical def → identical KV state. - Two calls with the same Name but different Description → second call's Description wins, no error returned. - Concurrent calls race to the latest Put; no locking, no compare\- and\-swap. Callers needing strict serialization should layer it above. Panics on empty Name \(programmer error\). Stamps RegisteredAt on every call so callers don't have to. ### func \(\*Worker\) [RegisterTriggerType]() ```go func (w *Worker) RegisterTriggerType(ctx context.Context, def dagnatsext.TriggerTypeDef) error ``` RegisterTriggerType publishes def into the trigger\_types KV bucket, then requests engine acknowledgement on \`\_REGISTRY.trigger\_types.ack\` \(\#330\). Returns the engine's error verbatim on failure. Side effects: - Sets def.OwnerWorkerID to w.WorkerID\(\) when empty so callers don't have to look it up. - Sets def.RegisteredAt to time.Now\(\).UTC\(\) when zero. Idempotent at the engine side per the ack contract \(\#327\): same Name \+ same OwnerWorkerID \+ same ConfigSchema → nil. Schema drift → error. Owner drift → error. ### func \(\*Worker\) [Start]() ```go func (w *Worker) Start() ``` Start creates JetStream subscriptions for all registered task types. Panics if any subscription fails — stream misconfiguration is a startup error. Binds optional KV buckets for checkpoints and signals \(nil if not present\). When groups are configured, subscribes to group\-specific subjects. ### func \(\*Worker\) [Stop]() ```go func (w *Worker) Stop() ``` ### func \(\*Worker\) [WatchTriggers]() ```go func (w *Worker) WatchTriggers(ctx context.Context, kind string, onActivate triggerHandler, onDeactivate triggerHandler) error ``` WatchTriggers subscribes to \`\_TRIGGER.\.activate\` and \`\_TRIGGER.\.deactivate\`, decodes each request into a trigger.TriggerDef, and invokes the corresponding callback. The callback's returned error is reported back to the engine via a \`\{"error":"..."\}\` reply; a nil error replies with an empty body. Catch\-up: before returning, the worker scans the \`triggers\` KV bucket and fires onActivate for every Enabled entry whose External.Kind == kind. Bounded at maxCatchupKeys \(10000\). Over\-cap → warning logged and scan stopped \(no fires beyond the cap\). Lifecycle: both NATS subscriptions are appended to w.triggerSubs and drained by Worker.Stop\(\). Callers do not unsubscribe directly. ## type [WorkerOption]() WorkerOption configures optional Worker behavior. ```go type WorkerOption func(*Worker) ``` ### func [WithControlPlane]() ```go func WithControlPlane(cp ControlPlane) WorkerOption ``` WithControlPlane grants this worker the runtime control plane: gated steps \(those declaring the "control\-plane" capability\) will receive a per\-step handle via TaskContext.ControlPlane\(\). Without this option the field stays nil and every step's ControlPlane\(\) returns nil — deny\-by\-default is structural, not a runtime check. Panics if cp is nil \(a deployment that wires the grant must supply a real handle\). ### func [WithGroups]() ```go func WithGroups(groups ...string) WorkerOption ``` WithGroups configures the worker to subscribe only to specific worker groups. When provided, the worker subscribes to task.\{taskType\}.\{group\}.\> instead of task.\{taskType\}.\>. ### func [WithPartitions]() ```go func WithPartitions(n int) WorkerOption ``` WithPartitions configures pcgroups elastic consumer groups with the given partition count. 0 = legacy consumer \(default\). ## type [WorkerRegistration]() WorkerRegistration is the directory entry for a running worker. The directory is observability\-only — the engine never reads it. Workers register on startup and maintain their entry via periodic heartbeat writes \(the KV bucket has a 60s TTL\). Identity & heartbeat fields \(LastSeen, Pid, Hostname, Version\) make the existing workers bucket double as a heartbeat surface — avoiding a parallel worker\_heartbeats bucket \(\#289\). LastSeen is stamped by Register on every write, so each periodic heartbeat tick advances it automatically. All four fields use omitempty so older payloads written before this struct grew \(zero\-valued\) deserialise cleanly. ```go type WorkerRegistration struct { WorkerID string `json:"worker_id"` TaskTypes []string `json:"task_types"` Language string `json:"language"` Transport string `json:"transport"` MaxTasks int `json:"max_tasks"` Metadata map[string]string `json:"metadata,omitempty"` // Identity — populated once at worker boot, stable for the life // of the process. Pid int `json:"pid,omitempty"` Hostname string `json:"hostname,omitempty"` Version string `json:"version,omitempty"` // LastSeen is the wall-clock timestamp of the most recent write // to the KV bucket. Register stamps this on every call, so the // periodic heartbeat naturally refreshes it. Readers compare it // to time.Now() to gauge worker liveness without depending on // NATS KV's TTL-eviction latency. LastSeen time.Time `json:"last_seen,omitempty"` } ``` Generated by [gomarkdoc]() --- # Source: docs/site/content/docs/reference/wire-protocol.md DagNats supports two transport modes for workers: **NATS** (native) and **HTTP** (via bridge). Both use the same JSON schemas for task payloads and resolutions, ensuring consistent semantics across languages and runtimes. ## NATS Transport Workers connect directly to NATS JetStream and subscribe to task subjects. ### Task Subjects Task subjects follow the pattern `task.{type}.{runID}`: | Subject | Matches | |---------|---------| | `task.llm.*` | All LLM tasks | | `task.http.*` | All HTTP tasks | | `task.llm.run-abc` | LLM tasks for run-abc only | Workers create durable pull consumers or ephemeral subscriptions with manual ACK. ### TaskPayload Schema Published to task subjects when the engine dispatches a step: ```json { "task_id": "run-1.step-a", "run_id": "run-1", "step_id": "step-a", "iteration": 0, "attempt": 1, "input": {"key": "value"} } ``` Canonical Go type: `protocol.TaskPayload` in `protocol/protocol.go`. | Field | Type | Required | Description | |-------|------|----------|-------------| | `task_id` | string | Yes | Unique task identifier: `{run_id}.{step_id}` | | `run_id` | string | Yes | Workflow run identifier | | `step_id` | string | Yes | Step identifier from workflow DAG | | `iteration` | int | No | Agent-loop iteration (0 for first execution) | | `attempt` | int | No | Retry attempt number (1-based) | | `input` | JSON | No | Step input data as raw JSON | ### Task Resolution Workers publish lifecycle events back to the history stream: | Action | Event Type | Subject | Description | |--------|-----------|---------|-------------| | Complete | `step.completed` | `history.{runID}` | Task finished successfully with output payload | | Fail | `step.failed` | `history.{runID}` | Task failed with error payload | | Continue | `step.continue` | `history.{runID}` | Agent loop requesting next iteration | Use `protocol.NewStepEvent()` to construct events with correct subject and dedup ID. ### Heartbeat Workers register in the `workers` KV bucket on startup via `worker.Directory`. The bucket has a **60s TTL**, so workers must re-PUT their registration every **30s** to remain visible. Deregistration happens automatically on TTL expiry or explicit DELETE. ## WorkerRegistration Schema Workers register their presence in the `workers` KV bucket: ```json { "worker_id": "worker-123", "task_types": ["llm", "http"], "language": "python", "transport": "bridge", "max_tasks": 2, "metadata": {"version": "1.0.0"} } ``` Canonical Go type: `worker.WorkerRegistration` in `worker/directory.go`. ## Pause and Checkpoint Semantics **Pause** suspends task execution for a fixed duration. The worker writes checkpoint state to KV and NAKs the message with delay. After the delay expires, the task is redelivered to the same or another worker with the checkpoint data available in KV. **Checkpoint** saves incremental state without suspending execution. The worker writes data to KV and calls `InProgress()` to extend the ack deadline. The task remains in-flight and the worker continues execution. Both mechanisms use the `checkpoints` KV bucket with keys formatted as `{run_id}.{step_id}`. ## Authentication | Transport | Method | |-----------|--------| | HTTP bridge | Bearer token via `Authorization: Bearer {token}` header. Token set via `DAGNATS_BRIDGE_TOKEN`. Missing or invalid tokens return `401 Unauthorized`. | | NATS native | NATS native authentication (user/password, tokens, NKey, JWT). | ## Reference Implementations - **Go (NATS):** `worker/` package - **HTTP SDKs:** Implement against the three HTTP endpoints and JSON schemas above - All Go types referenced in this document are canonical -- implement JSON serialization matching these types --- # Source: docs/site/content/docs/reference/workflow-schema.md Reference for the JSON format used by `dagnats workflow register` and `dagnats workflow validate`. {{< callout type="info" >}} **IDE support:** Point your editor at [`workflow-schema.json`](https://github.com/danmestas/dagnats/blob/main/docs/workflow-schema.json) for autocomplete and validation. Add `"$schema": "./path/to/workflow-schema.json"` to your workflow files. {{< /callout >}} Durations are Go `time.Duration` strings: `"30s"`, `"5m"`, `"1h30m"`. ## StepDef Individual step within a workflow. | Field | JSON Key | Type | Required | Default | Description | |-------|----------|------|----------|---------|-------------| | ID | `id` | string | Yes | -- | Unique within the workflow | | Task | `task` | string | Yes | -- | Task name workers subscribe to | | DependsOn | `depends_on` | []string | No | [] | Step IDs that must complete first | | Retries | `retries` | int | No | 0 | Legacy retry count (prefer `retry`) | | Timeout | `timeout` | duration | Yes | -- | Per-step execution deadline | | Type | `type` | StepType | Yes | -- | Execution semantics (see below) | | Loop | `loop` | AgentLoopConfig | No | nil | Required when type is `agent_loop` | | SkipIf | `skip_if` | ParentCond | No | nil | Conditional skip based on parent output | | Metadata | `metadata` | map | No | nil | Arbitrary key-value pairs | | Retry | `retry` | RetryPolicy | No | nil | Structured retry policy (overrides `retries`) | | WorkerGroup | `worker_group` | string | No | "" | Routes to a specific worker pool | | OnFailure | `on_failure` | string | No | "" | Step ID to run on failure | | Compensate | `compensate` | string | No | "" | Step ID for saga compensation | **Example with dependencies and retry:** ```json { "id": "lint", "task": "lint.run", "timeout": "5m", "type": "normal", "depends_on": ["fetch-diff"], "worker_group": "lint-workers", "retry": { "max_attempts": 3, "strategy": "exponential", "initial_delay": "2s", "max_delay": "30s", "multiplier": 2.0 } } ``` ## RetryPolicy Resolution order: step `retry` > workflow `default_retry` > legacy `retries` > none. | Field | JSON Key | Type | Required | Default | Description | |-------|----------|------|----------|---------|-------------| | MaxAttempts | `max_attempts` | int | Yes | -- | Total retry attempts; 0 means no retries | | Strategy | `strategy` | RetryStrategy | Yes | -- | Backoff algorithm | | InitialDelay | `initial_delay` | duration | Yes | -- | Base delay between attempts | | MaxDelay | `max_delay` | duration | Yes | -- | Delay cap (0 = uncapped) | | Multiplier | `multiplier` | float64 | No | 0 | Used by `exponential` strategy only | ### RetryStrategy Values | Value | Delay Formula | Example (initial=2s) | |-------|---------------|---------------------| | `fixed` | `initial_delay` every attempt | 2s, 2s, 2s | | `linear` | `initial_delay * attempt` | 2s, 4s, 6s | | `exponential` | `initial_delay * multiplier^(attempt-1)` | 2s, 4s, 8s (multiplier=2) | **Example:** ```json { "max_attempts": 5, "strategy": "exponential", "initial_delay": "1s", "max_delay": "30s", "multiplier": 2.0 } ``` ## ConcurrencyLimit Controls parallelism at the workflow level. | Field | JSON Key | Type | Required | Default | Description | |-------|----------|------|----------|---------|-------------| | MaxRuns | `max_runs` | int | No | 0 | Max parallel runs of this workflow | | MaxSteps | `max_steps` | int | No | 0 | Max parallel steps within a run | **Example:** ```json { "concurrency": { "max_runs": 3, "max_steps": 2 } } ``` ## Validation Rules The following rules are enforced by `dag.Validate()`: | # | Rule | |---|------| | 1 | Workflow must have a non-empty `name` | | 2 | Workflow must contain at least one step | | 3 | Every step must have a unique `id` | | 4 | Every step must have a non-empty `task` | | 5 | `depends_on` entries must reference existing step IDs | | 6 | `on_failure` must reference an existing step ID (if set) | | 7 | `compensate` must reference an existing step ID (if set) | | 8 | `retries` must not be negative | | 9 | `agent_loop` steps must have a `loop` config with `max_iterations > 0` | | 10 | Non-`agent_loop` steps must not have a `loop` config | | 11 | `skip_if.step_id` must be in the step's `depends_on` list | | 12 | `skip_if.op` must be a valid operator | | 13 | The step dependency graph must be acyclic (Kahn's algorithm) | --- # Source: docs/site/content/docs/reliability/_index.md {{< cards cols="3" >}} {{< card link="cancellation" title="Cancellation" >}} {{< card link="dead-letter-queue" title="Dead Letter Queue" >}} {{< card link="error-handling" title="Error Handling" >}} {{< card link="idempotency" title="Idempotency" >}} {{< card link="retry-policies" title="Retry Policies" >}} {{< card link="timeouts" title="Timeouts" >}} {{< /cards >}} --- # Source: docs/site/content/docs/reliability/cancellation.md Running workflows can be cancelled through the CLI or API, with the engine propagating cancellation to all in-flight steps. ## Cancelling a Run Cancel a running workflow by run ID: ```bash dagnats run cancel ``` Or via the REST API: ```bash curl -X POST http://localhost:8080/v1/runs//cancel ``` The engine publishes a `workflow.cancelled` event to the history stream. The run status transitions to `cancelled`. ## Propagation When a run is cancelled, the engine: 1. Marks the run status as `cancelled` 2. Cancels all **pending** steps (not yet dispatched) by setting them to `cancelled` 3. Cancels all **queued** steps by letting their NATS messages expire (AckWait timeout) 4. In-flight steps that are already running on a worker continue until the worker checks for cancellation or the step's AckWait expires Workers do not receive an explicit cancellation signal mid-execution. Instead, the next time the worker attempts to publish a result event, the engine ignores events for cancelled runs. This design avoids the complexity of distributed cancellation protocols. ## Event-Driven Cancellation The `CancelOn` field on `WorkflowDef` specifies events that automatically cancel a running workflow: ```go wf := dag.NewWorkflow("deploy"). WithCancelOn(dag.CancelOn{ Event: "event.deploy.rollback", Match: dag.Match{ Left: "env", Right: "env", }, Timeout: 1 * time.Hour, }) ``` When a matching event arrives on the `EVENTS` stream, the engine cancels the run without manual intervention. The `Match` field correlates the event payload against the run input -- in this example, only a rollback event for the same `env` value cancels the run. ## Graceful Shutdown When the `dagnats serve` process receives a shutdown signal (SIGTERM/SIGINT), it performs a graceful shutdown: 1. Stops accepting new workflow runs 2. Drains in-flight NATS subscriptions 3. Waits for active workers to finish current tasks (bounded by AckWait) 4. Persists final state to KV Runs that were in progress during shutdown resume automatically when the server restarts -- the orchestrator replays from the `WORKFLOW_HISTORY` stream and picks up where it left off. ## Child Workflow Cancellation Cancelling a parent workflow propagates to child workflows spawned by [sub-workflow steps](/docs/step-types/sub-workflows). The engine cancels child runs recursively (bounded by max nesting depth of 3). ## Related Pages - [Timeouts](/docs/reliability/timeouts) -- automatic cancellation on deadline - [Error Handling](/docs/reliability/error-handling) -- failure handling after cancellation - [Concurrency Limits](/docs/flow-control/concurrency-limits) -- preventing overload --- # Source: docs/site/content/docs/reliability/dead-letter-queue.md The **dead letter queue** (DLQ) captures task messages that have exhausted all retries, providing a 30-day window for inspection and replay. ## DEAD_LETTERS Stream When a step fails permanently -- either by exceeding its retry limit or by calling `FailPermanent()` -- the engine publishes the failed task message to the `DEAD_LETTERS` stream on `dead.>` subjects. Messages are retained for **30 days**. The DLQ preserves the full task payload including run ID, step ID, attempt count, and original input. This gives operators everything needed to diagnose the failure and decide whether to replay. ## Inspecting the DLQ List dead-lettered tasks with the CLI: ```bash # List recent DLQ entries dagnats dlq list # Filter by workflow dagnats dlq list --workflow code-review-pipeline # Filter by time range dagnats dlq list --after 2025-01-01 --before 2025-01-02 # JSON output for scripting dagnats dlq list --json ``` Each entry shows the task ID, workflow name, step ID, failure reason, and timestamp. ## Replaying Failed Tasks Replay republishes dead-lettered task messages back to the `TASK_QUEUES` stream, allowing them to be picked up by workers for another attempt: ```bash # Replay a specific task dagnats dlq replay # Bulk replay all failed tasks for a workflow dagnats run retry-all code-review-pipeline --mode replay ``` ### Replay vs. Rerun The `retry-all` command supports two modes: | Mode | Behavior | |------|----------| | `replay` | Re-publish original DLQ messages to resume at the failed step. Uses the existing run ID and state. Limited by 30-day DLQ retention. | | `rerun` | Start a fresh run with the original input. New run ID, clean state, uses the current workflow definition. | Both modes support `--dry-run` to preview what would be replayed without taking action. ## When Tasks Are Dead-Lettered A task enters the DLQ when: 1. **Retries exhausted** -- the step has a retry policy and all attempts have failed with retriable errors 2. **Non-retryable failure** -- the worker called `FailPermanent()`, bypassing retries entirely 3. **MaxDeliver exceeded** -- NATS has redelivered the message the maximum number of times (timeout-driven failures) Tasks that succeed, are cancelled, or are skipped never enter the DLQ. ## Related Pages - [Retry Policies](/docs/reliability/retry-policies) -- controlling retry behavior before DLQ - [Error Handling](/docs/reliability/error-handling) -- failure types that lead to DLQ - [Idempotency](/docs/reliability/idempotency) -- safe replay of dead-lettered tasks --- # Source: docs/site/content/docs/reliability/error-handling.md DagNats provides structured error handling with three failure types, on-failure handlers, and saga compensation -- giving workers precise control over how the engine responds to failures. ## FailureType Every step failure carries a `FailureType` that tells the engine what to do next: | Type | Wire Value | Engine Behavior | |------|-----------|-----------------| | **Retriable** | `retriable` | Apply retry policy (default) | | **Non-retriable** | `non_retriable` | Skip retries, go to on-failure/compensation/fail | | **Retry-after** | `retry_after` | Schedule exact delay via `SLEEP_TIMERS`, bypass backoff | The default is `retriable` -- if a worker calls `Fail()` without specifying a type, the engine applies the step's retry policy normally. Old-format error payloads (plain strings) are also treated as retriable for backward compatibility. ## Worker Failure Methods Workers signal failures through three methods on `TaskContext`: ```go w.Handle("process", func(ctx worker.TaskContext) { result, err := doWork(ctx.Input()) if err != nil { switch { case isInvalidInput(err): // No point retrying -- input won't change ctx.FailPermanent(err) case isRateLimited(err): // Provider said wait 30s ctx.FailRetryAfter(err, 30*time.Second) default: // Transient error -- use normal retry policy ctx.Fail(err) } return } ctx.Complete(result) }) ``` | Method | FailureType | Use Case | |--------|-------------|----------| | `Fail(err)` | `retriable` | Transient errors (network, timeouts, 5xx) | | `FailPermanent(err)` | `non_retriable` | Permanent errors (bad input, 4xx, business logic) | | `FailRetryAfter(err, d)` | `retry_after` | Caller-specified delay (rate limits, circuit breakers) | `FailRetryAfter` delay is clamped to [100ms, 1 hour]. The engine schedules the retry via `SLEEP_TIMERS` with a `TimerActionRetryAfter`, bypassing the normal backoff calculation entirely. ## Failure Flow ```mermaid flowchart TD F[Step Fails] --> T{FailureType?} T -->|retriable| R{Retries left?} R -->|yes| RETRY[NakWithDelay] R -->|no| PERM[Permanent failure] T -->|non_retriable| PERM T -->|retry_after| TIMER[SLEEP_TIMERS delay] TIMER --> RETRY2[Redeliver task] PERM --> OF{on_failure set?} OF -->|yes| HANDLER[Run on_failure step] OF -->|no| COMP{compensate set?} COMP -->|yes| SAGA[Run compensation] COMP -->|no| FAIL[Workflow fails] ``` ## On-Failure Handlers The `on_failure` field on a step definition names another step to run when the primary step fails permanently. The failure handler receives the original step's error in its input. ```go wf := dag.NewWorkflow("payment") charge := wf.Task("charge", "stripe.charge"). WithTimeout(30 * time.Second). WithOnFailure("notify-failure") notify := wf.Task("notify-failure", "slack.post"). WithTimeout(10 * time.Second) ``` On-failure steps are declared as **auxiliary steps** -- they do not participate in the normal DAG flow and are only executed when their associated step fails. ## Saga Compensation The `compensate` field names a step that undoes the work of a completed step when a downstream failure requires rollback. Compensation runs in reverse topological order. ```go wf := dag.NewWorkflow("order") reserve := wf.Task("reserve", "inventory.reserve"). WithTimeout(30 * time.Second). WithCompensation("release") charge := wf.Task("charge", "payment.charge"). After(reserve). WithTimeout(30 * time.Second) release := wf.Task("release", "inventory.release"). WithTimeout(30 * time.Second) ``` If `charge` fails permanently, the engine runs `release` to undo the inventory reservation. The run status transitions to `compensated` on success or `compensate_failed` if compensation itself fails. ## Related Pages - [Retry Policies](/docs/reliability/retry-policies) -- backoff strategies and configuration - [Dead Letter Queue](/docs/reliability/dead-letter-queue) -- where permanently failed tasks go - [Cancellation](/docs/reliability/cancellation) -- cancelling in-flight work --- # Source: docs/site/content/docs/reliability/idempotency.md DagNats provides two layers of deduplication -- application-level idempotency keys for workflow runs and transport-level `Nats-Msg-Id` for message dedup -- ensuring safe retries and replay without duplicate side effects. ## Workflow Idempotency Keys An **idempotency key** prevents duplicate workflow runs from the same logical request. When set on a `WorkflowDef`, the engine extracts a key value from the run input using a dot-path expression and checks it against the `idempotency_keys` KV bucket before creating the run. ```go wf := dag.NewWorkflow("payment"). WithIdempotencyKey("payment_id") ``` When a run starts with input `{"payment_id": "pay_123", "amount": 50}`, the engine: 1. Extracts `"pay_123"` from the input via the `payment_id` dot-path 2. Checks the `idempotency_keys` KV bucket for key `payment.pay_123` 3. If found, returns the existing run ID without creating a new run 4. If not found, creates the run and stores the mapping ### Dot-Path Extraction The key field supports nested dot-path expressions for deeply nested input structures: ```go // Extracts from input.metadata.request_id wf := dag.NewWorkflow("webhook-handler"). WithIdempotencyKey("metadata.request_id") ``` The `dag.ExtractDotPath()` function walks nested JSON objects using dot-separated segments. Missing keys produce an error, and the run proceeds without dedup protection. ### TTL Idempotency key entries have a **24-hour TTL** by default. After expiry, the same key can create a new run. This balances dedup protection against storage growth -- most duplicate requests arrive within seconds or minutes, not days. ## NATS Message Deduplication At the transport layer, NATS JetStream provides automatic message deduplication via the `Nats-Msg-Id` header. DagNats sets this header on all published messages to prevent duplicate events from being stored in streams. ### Message ID Format | Message Type | ID Format | Example | |-------------|-----------|---------| | Step events | `{run_id}.{step_id}.{event_type}` | `run-1.fetch.step.completed` | | Rate retries | `{run_id}.{step_id}.rate_retry` | `run-1.call-llm.rate_retry` | ### Dedup Window The JetStream dedup window is **2 minutes** (stream-level configuration on `WORKFLOW_HISTORY`). Within this window, publishing a message with an already-seen `Nats-Msg-Id` is silently dropped. This handles scenarios like: - Engine crashes mid-publish and replays on restart - Network partitions causing duplicate deliveries - Worker publishing a completion event twice After the 2-minute window, the same message ID can be published again. This is safe because events are idempotent by design -- replaying a `step.completed` event for an already-completed step is a no-op in the orchestrator. ## Designing Idempotent Workers While DagNats handles dedup at the platform level, workers should be idempotent at the application level when possible: ```go w.Handle("charge", func(ctx worker.TaskContext) { var in ChargeInput json.Unmarshal(ctx.Input(), &in) // Use the payment ID as an idempotency key with Stripe result, err := stripe.Charge(in.Amount, stripe.WithIdempotencyKey( fmt.Sprintf("%s.%s", ctx.RunID(), ctx.StepID()), )) if err != nil { ctx.Fail(err) return } ctx.Complete(result) }) ``` Using `{runID}.{stepID}` as an external idempotency key ensures that retries of the same step hit the same external dedup window. ## Related Pages - [Retry Policies](/docs/reliability/retry-policies) -- retries that benefit from idempotency - [Dead Letter Queue](/docs/reliability/dead-letter-queue) -- safe replay of failed tasks - [Error Handling](/docs/reliability/error-handling) -- failure types and retry behavior --- # Source: docs/site/content/docs/reliability/retry-policies.md A **retry policy** defines how DagNats handles transient step failures -- how many times to retry, how long to wait between attempts, and which backoff strategy to use. ## RetryPolicy Configuration | Field | JSON Key | Type | Description | |-------|----------|------|-------------| | **MaxAttempts** | `max_attempts` | `int` | Total retry attempts; 0 means no retries | | **Strategy** | `strategy` | `RetryStrategy` | Backoff algorithm: `fixed`, `linear`, or `exponential` | | **InitialDelay** | `initial_delay` | `duration` | Base delay between attempts | | **MaxDelay** | `max_delay` | `duration` | Delay cap (0 = uncapped) | | **Multiplier** | `multiplier` | `float64` | Multiplier for `exponential` strategy only | ## Backoff Strategies DagNats supports three backoff strategies, each computing the delay before the next retry attempt (1-based): | Strategy | Formula | Example (initial: 2s) | |----------|---------|----------------------| | `fixed` | `initial_delay` | 2s, 2s, 2s, 2s | | `linear` | `initial_delay * attempt` | 2s, 4s, 6s, 8s | | `exponential` | `initial_delay * multiplier^(attempt-1)` | 2s, 4s, 8s, 16s | All strategies respect **MaxDelay** -- if the computed delay exceeds the cap, MaxDelay is used instead. ## Setting Retry Policies ### Per-Step (Builder API) ```go wf := dag.NewWorkflow("llm-pipeline") callLLM := wf.Task("call-llm", "llm.chat"). WithTimeout(60 * time.Second). WithRetryPolicy(dag.RetryPolicy{ MaxAttempts: 5, Strategy: dag.RetryExponential, InitialDelay: 2 * time.Second, MaxDelay: 30 * time.Second, Multiplier: 2.0, }) ``` ### Workflow Default Set a default retry policy that applies to all steps unless overridden: ```go wf := dag.NewWorkflow("data-pipeline"). WithDefaultRetry(dag.RetryPolicy{ MaxAttempts: 3, Strategy: dag.RetryFixed, InitialDelay: 5 * time.Second, MaxDelay: 5 * time.Second, }) ``` ### Resolution Order The engine resolves the effective retry policy for each step using this precedence: 1. Step-level `Retry` field (highest priority) 2. Workflow-level `DefaultRetry` 3. Legacy `Retries` field (converted to fixed-delay policy with 5s delay) 4. No retries (if none of the above are set) ## NATS Implementation Retries use NATS **NakWithDelay** -- the engine NAKs the failed task message with the computed delay. NATS redelivers the message after the delay expires. This eliminates the need for a separate timer service or retry queue. ```mermaid sequenceDiagram participant Worker participant NATS as TASK_QUEUES participant Engine Worker->>Engine: step.failed (retriable) Engine->>Engine: calculate delay (attempt 2) Engine->>NATS: NakWithDelay(4s) Note over NATS: waits 4 seconds NATS->>Worker: redeliver task ``` ## Non-Retryable Errors Workers can signal that a failure should **not** be retried by calling `FailPermanent()`: ```go w.Handle("validate", func(ctx worker.TaskContext) { input := ctx.Input() if !isValid(input) { ctx.FailPermanent(fmt.Errorf("invalid input: %s", input)) return } ctx.Complete(map[string]any{"valid": true}) }) ``` `FailPermanent()` sets the failure type to `non_retriable`, causing the engine to skip all remaining retries and immediately proceed to on-failure handling or workflow failure. See [Error Handling](/docs/reliability/error-handling) for the full `FailureType` taxonomy. {{< callout type="tip" >}} **Retrying transient LLM API failures.** LLM providers frequently return 429 (rate limit) and 503 (overloaded) errors. Use `exponential` backoff with a generous `max_delay` (30-60s) and 5+ max attempts to ride out transient API unavailability without manual intervention. {{< /callout >}} ## Related Pages - [Error Handling](/docs/reliability/error-handling) -- failure types and on-failure handlers - [Timeouts](/docs/reliability/timeouts) -- per-step and per-workflow deadlines - [Rate Limiting](/docs/flow-control/rate-limiting) -- preventing upstream overload --- # Source: docs/site/content/docs/reliability/timeouts.md Every step in DagNats **requires** a timeout -- there are no unbounded executions. ## Per-Step Timeout The `Timeout` field on `StepDef` is mandatory. It sets the maximum wall-clock time a worker has to complete the step before the engine considers it failed. ```go wf := dag.NewWorkflow("data-pipeline") fetch := wf.Task("fetch", "http-fetch"). WithTimeout(30 * time.Second) transform := wf.Task("transform", "process"). After(fetch). WithTimeout(5 * time.Minute) ``` If a worker does not resolve a task within the timeout, NATS **AckWait** expires and the message is redelivered (up to **MaxDeliver** times). This is the primary timeout enforcement mechanism -- no application-level timer is needed. ### How AckWait Enforces Timeouts ```mermaid sequenceDiagram participant Engine participant NATS as TASK_QUEUES participant Worker Engine->>NATS: publish task (AckWait = step timeout) NATS->>Worker: deliver message Note over Worker: processing... alt completes in time Worker->>NATS: Ack else timeout expires NATS->>NATS: AckWait expires NATS->>Worker: redeliver (attempt 2) end ``` Workers can extend the deadline mid-execution by calling `Heartbeat()`, which sends an `InProgress()` signal to NATS to reset the AckWait timer: ```go w.Handle("long-task", func(ctx worker.TaskContext) { for i := 0; i < 100; i++ { processChunk(i) ctx.Heartbeat() // reset AckWait } ctx.Complete(map[string]any{"chunks": 100}) }) ``` ## Per-Workflow Timeout The optional `Timeout` field on `WorkflowDef` sets an overall deadline for the entire run. When set, the engine computes a `Deadline` on the `WorkflowRun` at creation time. ```go wf := dag.NewWorkflow("bounded-pipeline"). WithTimeout(30 * time.Minute) ``` If the run has not reached a terminal state by the deadline, the engine cancels all in-flight steps and marks the run as `failed`. The workflow timeout acts as a safety net -- individual step timeouts handle the common case, but the workflow timeout catches pathological scenarios like cascading retries. ## Timeout Guidance | Scenario | Recommended Timeout | |----------|-------------------| | HTTP API call | 10-60s | | LLM inference | 60-300s | | File processing | 1-10m | | Agent loop (total) | 10-30m | | End-to-end pipeline | 30-120m | Set step timeouts based on the **worst-case expected duration** plus headroom for retries. The workflow timeout should exceed the sum of the critical path's step timeouts. ## Validation The workflow validator enforces that every step has a non-zero `Timeout`. Calling `Build()` on a workflow with a missing timeout returns an error. This is a deliberate design choice -- unbounded execution is a reliability hazard in production systems. ## Related Pages - [Retry Policies](/docs/reliability/retry-policies) -- what happens after a timeout - [Error Handling](/docs/reliability/error-handling) -- failure semantics - [Cancellation](/docs/reliability/cancellation) -- manual termination --- # Source: docs/site/content/docs/step-types/_index.md {{< cards cols="3" >}} {{< card link="agent-loops" title="Agent Loops" >}} {{< card link="approval-gates" title="Approval Gates" >}} {{< card link="map-steps" title="Map Steps" >}} {{< card link="normal-steps" title="Normal Steps" >}} {{< card link="planner-steps" title="Planner Steps" >}} {{< card link="respond-steps" title="Respond Steps" >}} {{< card link="sleep-and-timers" title="Sleep and Timers" >}} {{< card link="sub-workflows" title="Sub-Workflows" >}} {{< card link="wait-for-event" title="Wait for Event" >}} {{< /cards >}} --- # Source: docs/site/content/docs/step-types/agent-loops.md An **agent loop** step iterates until the handler signals completion or a bound is reached, making it the native primitive for LLM agent reasoning cycles. ## Overview Agent loops solve a fundamental problem in AI workflows: the number of iterations is not known at definition time. An LLM agent might need 3 tool-call rounds or 30, depending on the task. Rather than modeling this as a fixed chain of steps, `StepTypeAgentLoop` lets a single step iterate in place, calling `Continue()` to request another iteration or `Complete()` to terminate. Each iteration is a full task dispatch cycle -- the engine re-enqueues the step with an incremented iteration counter, and the worker picks it up again. This means every iteration gets its own NATS message with dedup protection, its own timeout enforcement, and its own trace span. The conversation state persists across iterations via the `Checkpoint()` / `LoadCheckpoint()` API on the worker's `TaskContext`. Two hard bounds prevent runaway loops: **MaxIterations** caps the number of cycles, and **MaxDuration** caps wall-clock time from the first iteration. Whichever fires first terminates the loop. An optional **LoopDelay** adds spacing between iterations for rate-limited APIs. ## How It Works ```mermaid stateDiagram-v2 [*] --> Queued: dependencies satisfied Queued --> Running: worker picks up Running --> Continue: handler calls Continue() Continue --> Queued: engine re-enqueues (iteration++) Running --> Completed: handler calls Complete() Running --> Failed: handler calls Fail() Continue --> Failed: MaxIterations or MaxDuration exceeded Completed --> [*] Failed --> [*] ``` When the engine enqueues an agent loop step, it includes the current `Iteration` count in the `TaskPayload`. The worker handler loads any saved checkpoint (conversation history, tool results), performs one reasoning cycle, and decides: - **`Continue(output)`** -- publish a `step.continue` event. The engine increments the iteration counter, checks bounds, and re-enqueues the task. The output from `Continue()` becomes the input for the next iteration. - **`Complete(output)`** -- publish a `step.completed` event. The loop terminates successfully, and downstream steps receive the final output. - **`Fail(err)`** -- publish a `step.failed` event. Retry policy applies as normal. The `Continue()` message ID includes a nonce (`UnixNano` timestamp) to handle the case where a worker crashes after calling `Continue()` but before acking the NATS message. On redelivery, the nonce ensures the new continue event is not swallowed by JetStream dedup. ## Usage ```go wf := dag.NewWorkflow("code-review") review := wf.AgentLoop("review", "llm-review"). WithMaxIterations(10). WithMaxDuration(5 * time.Minute). WithLoopDelay(500 * time.Millisecond). WithTimeout(30 * time.Second) def, err := wf.Build() ``` The handler uses checkpoint to persist conversation state: ```go w.Handle("llm-review", func(ctx worker.TaskContext) error { history, _ := ctx.LoadCheckpoint() if history == nil { history = ctx.Input() } result, err := callLLM(history) if err != nil { return ctx.Fail(err) } ctx.Checkpoint(result.FullHistory) if result.Done { return ctx.Complete(result.Summary) } return ctx.Continue(result.ToolOutput) }) ``` ## Configuration Agent loop configuration is stored in `StepDef.Config` as `AgentLoopConfig`: | Field | Type | Default | Purpose | |-------|------|---------|---------| | `max_iterations` | `int` | (required) | Maximum number of loop cycles. Must be > 0. | | `max_duration` | `time.Duration` | 0 (unlimited) | Wall-clock bound from first iteration start | | `loop_delay` | `time.Duration` | 0 (none) | Delay between re-enqueue cycles | The `StepDef.Timeout` field controls the per-iteration timeout, not the total loop duration. Set `max_duration` to bound total wall-clock time. ## Related - [Normal Steps](/docs/step-types/normal-steps) -- single-execution counterpart - [Steps](/docs/concepts/steps) -- step lifecycle and StepDef fields --- # Source: docs/site/content/docs/step-types/approval-gates.md An **approval gate** pauses workflow execution until a human explicitly approves or rejects it, enabling human-in-the-loop workflows for sensitive operations. ## Overview Approval gates address the requirement that some workflow steps should not proceed without human review. A deploy to production, a financial transaction above a threshold, or an AI agent's proposed action -- these are cases where automation must yield to human judgment. When the engine reaches a `StepTypeApproval` step, it generates a **256-bit cryptographically random token**, stores it in the `approval_tokens` KV bucket with a 7-day TTL, and publishes a notification to a configurable NATS subject. External integrations (Slack bots, email systems, dashboards) subscribe to this subject and present the approve/reject action to the appropriate human. No worker is involved in the step itself. The token ensures that only someone who received the notification can act on the approval. Atomic consumption via CAS (compare-and-swap) on the KV entry prevents double-approve race conditions. Once consumed, the token cannot be reused. ## How It Works ```mermaid sequenceDiagram participant Engine participant KV as approval_tokens KV participant NATS as approval.{runID}.{stepID} participant Human participant API as HTTP API / CLI Engine->>KV: store token (7-day TTL) Engine->>NATS: publish notification NATS->>Human: Slack/email/dashboard Human->>API: approve (with token) API->>KV: CAS delete token API->>Engine: step.completed (approved) ``` When a human approves or rejects: - **Approve**: the engine publishes a `step.completed` event with `{"approved": true}` as output. Downstream steps proceed normally. - **Reject**: the engine publishes a `step.failed` event. The workflow can handle this via `OnFailure` handlers or saga compensation. - **Timeout**: if no action is taken within the configured timeout (max 7 days), the step auto-rejects. The API exposes two endpoints for approval actions: ``` POST /runs/{id}/approval/{step_id}?action=approve&token={token} POST /runs/{id}/approval/{step_id}?action=reject&token={token} ``` The CLI provides equivalent commands: ```bash dagnats run approve --token= dagnats run reject --token= ``` ## Usage ```go wf := dag.NewWorkflow("production-deploy") plan := wf.Task("plan", "terraform-plan"). WithTimeout(5 * time.Minute) approve := wf.Approval("approve-deploy", dag.ApprovalConfig{ Timeout: 24 * time.Hour, Subject: "approval.deploy.production", Description: "Review terraform plan before apply", Metadata: map[string]string{ "team": "platform", "environment": "production", }, }).After(plan) apply := wf.Task("apply", "terraform-apply"). After(approve). WithTimeout(10 * time.Minute) def, err := wf.Build() ``` ## Configuration Approval configuration is stored in `StepDef.Config` as `ApprovalConfig`: | Field | Type | Required | Purpose | |-------|------|----------|---------| | `timeout` | `time.Duration` | Yes | How long to wait for a decision. Max 168h (7 days). | | `subject` | `string` | Yes | NATS subject for notification publication | | `description` | `string` | No | Human-readable description of what is being approved | | `metadata` | `map[string]string` | No | Arbitrary key-value pairs passed in the notification | **Constraints:** - Timeout must be positive and at most 168 hours (7 days) - Subject must be non-empty - Token is 256-bit, cryptographically random - Token stored in `approval_tokens` KV bucket with 7-day TTL - CAS prevents double-approve ## Related - [Wait for Event](/docs/step-types/wait-for-event) -- programmatic event correlation - [Normal Steps](/docs/step-types/normal-steps) -- standard automated execution --- # Source: docs/site/content/docs/step-types/map-steps.md A **map step** fans out over an input array, executing one task per item in parallel, then collects all outputs into a single result. ## Overview Map steps solve the parallel batch processing problem. When a step produces an array as its output, a downstream map step can process each element independently and concurrently. The engine creates one task instance per array item, dispatches them all to the `TASK_QUEUES` stream, and waits for every instance to complete before marking the map step as done. This pattern is common in AI workflows -- an LLM might identify 5 files to modify, and a map step processes each file in parallel. Or an API returns a list of items that each need enrichment. The fan-out/fan-in is handled entirely by the engine; the worker handler sees each item individually, as if it were a normal step. Map steps enforce a **fail-fast** policy: if any single instance fails (after retries), the entire map step fails immediately. Partial results are not propagated. The `MapConfig.MaxItems` field bounds the array size to prevent unbounded parallelism, defaulting to 1,000 with an absolute maximum of 10,000. ## How It Works ```mermaid graph LR A[Upstream Step] -->|array output| M[Map Step] M --> I1[Instance 0] M --> I2[Instance 1] M --> I3[Instance 2] M --> IN[Instance N] I1 --> C[Collect Outputs] I2 --> C I3 --> C IN --> C C --> D[Downstream Step] ``` When the engine resolves a map step as ready, it reads the input (the upstream step's output), parses it as a JSON array, and publishes one task message per element. Each task message includes the array index so the engine can reassemble results in order. Instance state is tracked in `StepState.MapInstances` -- a slice of `MapInstanceState` structs, each with its own status, output, and error. The engine updates the relevant instance on each `step.map.instance.completed` event. When all instances reach a terminal state, the engine publishes `step.map.completed` with the collected outputs as a JSON array. Workers do not need any special handling for map steps. The handler receives one item as input and calls `Complete()` or `Fail()` exactly like a normal step. The fan-out/fan-in is invisible to the worker. ## Usage ```go wf := dag.NewWorkflow("batch-enrich") fetch := wf.Task("fetch", "fetch-items"). WithTimeout(30 * time.Second) enrich := wf.Map("enrich", "enrich-item"). After(fetch). WithMaxItems(500). WithTimeout(10 * time.Second). WithRetries(2) summarize := wf.Task("summarize", "aggregate"). After(enrich) def, err := wf.Build() ``` The worker handler processes one item at a time: ```go w.Handle("enrich-item", func(ctx worker.TaskContext) error { var item Item if err := json.Unmarshal(ctx.Input(), &item); err != nil { return ctx.Fail(err) } enriched, err := callAPI(item) if err != nil { return ctx.Fail(err) } output, _ := json.Marshal(enriched) return ctx.Complete(output) }) ``` ## Configuration Map configuration is stored in `StepDef.Config` as `MapConfig`: | Field | Type | Default | Purpose | |-------|------|---------|---------| | `max_items` | `int` | 1000 | Maximum array size. Capped at 10,000. | **Behavior:** - Input must be a JSON array. Non-array input causes the step to fail. - **Fail-fast**: any instance failure fails the entire map step. - Outputs are collected in original array order. - Each instance gets its own retry budget (from the step's `Retries` field). ## Related - [Normal Steps](/docs/step-types/normal-steps) -- single-item execution - [Sub-Workflows](/docs/step-types/sub-workflows) -- composing entire workflows - [Agent Loops](/docs/step-types/agent-loops) -- iterative single-step execution --- # Source: docs/site/content/docs/step-types/normal-steps.md A **normal step** is the default step type -- it executes a task once on a worker, then completes or fails. ## Overview Normal steps are the building blocks of most workflows. Each normal step is dispatched to a worker that has registered a handler for the step's task type. The worker receives the step's input (assembled from the outputs of upstream dependencies), performs its work, and calls `Complete()` with a result or `Fail()` with an error. The engine routes normal steps through NATS JetStream pull consumers. When all of a step's dependencies reach a terminal state, the engine enqueues a task message on the `TASK_QUEUES` stream at subject `task.{taskType}`. A worker picks it up, executes the handler, and publishes a `step.completed` or `step.failed` event back to the history stream. Normal steps support retries, timeouts, conditional skipping, worker group routing, on-failure handlers, and saga compensation -- all configured declaratively on the `StepDef`. ## How It Works ```mermaid sequenceDiagram participant Engine participant NATS as TASK_QUEUES participant Worker Engine->>NATS: publish task.{taskType} NATS->>Worker: deliver message Worker->>Worker: execute handler alt success Worker->>Engine: step.completed event else failure Worker->>Engine: step.failed event Engine->>Engine: retry or fail permanently end ``` When a normal step's dependencies are all satisfied, the engine publishes a `TaskPayload` containing the run ID, step ID, attempt number, and merged input from upstream outputs. The worker's handler receives this as a `TaskContext` with read-only accessors (`Input()`, `RunID()`, `StepID()`, `RetryCount()`) and terminal actions (`Complete()`, `Fail()`, `FailPermanent()`, `FailRetryAfter()`). ## Usage Define a workflow with normal steps using the builder API: ```go wf := dag.NewWorkflow("data-pipeline") fetch := wf.Task("fetch", "http-fetch"). WithTimeout(30 * time.Second). WithRetries(3) process := wf.Task("process", "transform"). After(fetch). WithTimeout(60 * time.Second) def, err := wf.Build() ``` Register a handler on a worker: ```go w := worker.New(nc, tel) w.Handle("http-fetch", func(ctx worker.TaskContext) error { url := gjson.GetBytes(ctx.Input(), "url").String() resp, err := http.Get(url) if err != nil { return ctx.Fail(err) } defer resp.Body.Close() body, _ := io.ReadAll(resp.Body) return ctx.Complete(body) }) ``` ## Configuration Normal steps use the base `StepDef` fields -- no type-specific `Config` is required. | Field | Type | Purpose | |-------|------|---------| | `ID` | `string` | Unique step identifier within the workflow | | `Task` | `string` | Task type that workers register handlers for | | `DependsOn` | `[]string` | Step IDs that must complete before this step runs | | `Timeout` | `time.Duration` | Per-attempt timeout (enforced via NATS AckWait) | | `Retries` | `int` | Maximum retry attempts (0 = no retries) | | `WorkerGroup` | `string` | Route to a specific worker group | | `OnFailure` | `string` | Step ID to run on permanent failure | | `Compensate` | `string` | Step ID for saga compensation | ## Related - [Steps](/docs/concepts/steps) -- step lifecycle and StepDef fields - [Workers](/docs/concepts/workers) -- how workers register and execute tasks - [Agent Loops](/docs/step-types/agent-loops) -- iterative execution variant --- # Source: docs/site/content/docs/step-types/planner-steps.md A **planner step** generates a DAG fragment at runtime, enabling workflows to create steps dynamically based on data or LLM output. ## Overview Static DAGs require you to know every step at definition time. Planner steps remove that constraint. A `StepTypePlanner` step is a normal worker task whose output is not data -- it is a JSON DAG fragment containing steps and edges. The engine validates the fragment, namespaces the generated step IDs, and materializes them into the running workflow as `DynamicSteps`. From that point, the generated steps execute exactly like static ones. This is the primitive that makes AI-planned workflows possible. An LLM can analyze a task, decide what subtasks are needed, emit a plan as structured JSON, and the engine executes it. The planner does not need to know the full workflow graph -- it only produces its fragment, and the engine merges it with the static definition. Planner steps are bounded to prevent runaway generation: a single planner can emit at most **100 steps**, a single run can accumulate at most **500 dynamic steps** total, and the dependency chain depth within a fragment is capped at **10**. Generated step IDs are namespaced as `{plannerStepID}.{generatedID}` to prevent collisions with static steps or other planner fragments. ## How It Works ```mermaid sequenceDiagram participant Worker as Planner Worker participant Engine participant Run as WorkflowRun Engine->>Worker: dispatch planner task Worker->>Engine: step.completed (JSON fragment) Engine->>Engine: validate fragment (bounds, cycles, IDs) Engine->>Run: append to DynamicSteps Note over Run: EffectiveSteps() merges static + dynamic Engine->>Engine: enqueue ready generated steps ``` The engine performs several validation checks on the fragment before materialization: 1. **Bounds check**: step count within [1, `MaxSteps`], depth within [0, `MaxDepth`] 2. **Cycle detection**: the generated fragment must be a DAG 3. **ID collision check**: namespaced IDs must not collide with existing steps 4. **Task allowlist**: if `AllowedTasks` is configured, generated steps may only reference listed task types After validation, the engine appends the fragment to `WorkflowRun.DynamicSteps` and publishes an `EventPlannerMaterialized` event for observability. The `EffectiveSteps()` method on the workflow run merges static steps from the definition with all dynamic steps, producing the complete step graph that the DAG resolver operates on. **Output aggregation**: if the planner fragment has a single terminal step (no downstream dependencies within the fragment), its output becomes the planner step's output. If there are multiple terminal steps, their outputs are collected into a map keyed by step ID. ## Usage ```go wf := dag.NewWorkflow("ai-pipeline") analyze := wf.Task("analyze", "analyze-task"). WithTimeout(30 * time.Second) plan := wf.Planner("plan", "generate-plan", dag.PlannerConfig{ MaxSteps: 20, MaxDepth: 5, AllowedTasks: []string{"code-edit", "test-run", "lint"}, }).After(analyze) report := wf.Task("report", "summarize"). After(plan) def, err := wf.Build() ``` The planner worker returns a JSON fragment: ```go w.Handle("generate-plan", func(ctx worker.TaskContext) error { plan := map[string]any{ "steps": []map[string]any{ {"id": "edit", "task": "code-edit"}, {"id": "test", "task": "test-run", "depends_on": []string{"edit"}}, {"id": "lint", "task": "lint", "depends_on": []string{"edit"}}, }, } output, _ := json.Marshal(plan) return ctx.Complete(output) }) ``` ## Configuration Planner configuration is stored in `StepDef.Config` as `PlannerConfig`: | Field | Type | Default | Purpose | |-------|------|---------|---------| | `max_steps` | `int` | (required) | Maximum steps in the fragment. Range: 1-100. | | `max_depth` | `int` | 0 | Maximum dependency chain depth. Range: 0-10. | | `allowed_tasks` | `[]string` | (all allowed) | Restrict which task types may appear in the fragment | **Global bounds:** | Limit | Value | |-------|-------| | Steps per planner | 100 | | Total dynamic steps per run | 500 | | Max fragment depth | 10 | | ID namespace | `{plannerStepID}.{generatedID}` | ## Related - [Sub-Workflows](/docs/step-types/sub-workflows) -- static workflow composition - [Map Steps](/docs/step-types/map-steps) -- parallel execution over arrays - [Normal Steps](/docs/step-types/normal-steps) -- the step type that generated steps become --- # Source: docs/site/content/docs/step-types/respond-steps.md A **respond step** publishes the synchronous HTTP response for an [HTTP trigger](/docs/triggers/http). It is **engine-resolved** — no worker dispatch — and the DAG continues to advance past it like any other step. ## Overview Respond steps exist to give a DAG-shaped workflow an explicit "return point" for an HTTP request. iii and Inngest bind the response to a function's return value; a DAG has no single return statement, so the response node is first-class instead. A respond step: - Reads its body from the upstream step's output, or from a dotpath ref via `body_from`. - Publishes a single message to the engine-private subject `dagnats.http.response.`. - Emits a `step.completed` event so DAG advance continues. - Lets subsequent steps run for cleanup, audit logging, or fanning out follow-ups — **after** the HTTP client has already received its response. Respond is the only step type the engine resolves directly without worker dispatch. The body is purely a function of run state, so routing through a worker would buy nothing. ## How It Works ```mermaid sequenceDiagram participant Client as HTTP Client participant API as dagnats-api participant Engine participant Worker Client->>API: POST /api/orders API->>API: subscribe to ResponseSubject(runID) API->>Engine: publish TriggerEnvelope Engine->>Worker: dispatch task A Worker-->>Engine: step.completed Engine->>Engine: execute respond step (no worker) Engine->>API: publish to ResponseSubject(runID) API->>Client: HTTP response Engine->>Worker: dispatch task C (cleanup) ``` The API handler subscribes to the response subject **before** publishing the trigger envelope — closing a race a fast workflow could otherwise exploit. The engine's respond step executor (`internal/engine/respond_step.go`) reads `RespondConfig`, builds the wire payload, and publishes once. ## Usage Declare a respond step in workflow JSON: ```json { "id": "respond", "type": "respond", "depends_on": ["compute"], "config": { "status": 200, "content_type": "application/json", "headers": { "X-Source": "dagnats" } } } ``` The body defaults to the immediate upstream step's output. To pull from a specific dotpath in run state, set `body_from`: ```json { "id": "respond", "type": "respond", "depends_on": ["compute"], "config": { "status": 201, "body_from": "data.result.summary" } } ``` ## Configuration | Field | Default | Purpose | |-------|---------|---------| | `status` | `200` | HTTP status code. | | `content_type` | `"application/json"` | Sets the `Content-Type` response header. | | `headers` | `{}` | Additional response headers. `X-Dagnats-Run-Id` is always set by the API handler regardless. | | `body_from` | `""` (upstream output) | Dotpath into run state to pull the response body from. | ## Mental Model: Side Effect, Not Return ``` http trigger → [step A] → [step B] → respond → [step C] → [step D] │ └─ HTTP response dispatched here (client connection released) ``` `[step C]` and `[step D]` run **after** the client has received the response. Their outputs are not visible to the caller. **Anti-pattern:** put an auth-revocation, billing-charge, or any "must-complete-before-the-user-sees-success" operation *after* `respond`. The user has already seen success; the late step can fail silently. Put such steps **before** `respond`, or split them into a separate workflow keyed off the response event. ## Multiple Respond Branches A DAG can have more than one respond step — for example, a happy path and an error path on mutually-exclusive branches. The graph validator at workflow registration warns if two respond steps are *simultaneously* reachable; branch-per-outcome patterns (gated by opposite `skip_if` on the same parent) are recognized as legitimate and pass without warning. If two respond steps do execute in the same run, the originating API replica unsubscribes after the first publish — the second publish has no subscriber and is silently dropped at NATS. The HTTP response is already on the wire by then. ## Related - [HTTP Trigger](/docs/triggers/http) — the trigger that pairs with respond steps. - [Webhooks](/docs/triggers/webhooks) — the fire-and-forget alternative. - [ADR-013](https://github.com/danmestas/dagnats/blob/main/docs/architecture/adr-013-http-trigger-respond-step.md) — design rationale. --- # Source: docs/site/content/docs/step-types/sleep-and-timers.md A **sleep step** introduces a durable delay into a workflow -- the engine handles the timer entirely, with no worker involved. ## Overview Sleep steps pause workflow execution for a specified duration. Unlike a `time.Sleep()` in application code, a DagNats sleep is durable: it survives engine restarts, NATS reconnections, and server reboots. The delay is stored as a `WakeAt` timestamp in the step state, and the engine uses the `SLEEP_TIMERS` JetStream stream with `NakWithDelay` to schedule the wake-up. No worker participates in a sleep step. The engine publishes a timer message to `SLEEP_TIMERS`, the NATS consumer NAKs it with the requested delay, and when NATS redelivers the message after the delay expires, the engine publishes a `step.sleep.completed` event to advance the workflow. This is distinct from the worker-level `Pause()` method, which checkpoints mid-task state and uses `NakWithDelay` to resume the same worker handler. Sleep steps are DAG-level delays between steps; `Pause()` is a within-step delay that keeps the step in `Running` status. ## How It Works ```mermaid sequenceDiagram participant Engine participant SLEEP as SLEEP_TIMERS participant History as WORKFLOW_HISTORY Engine->>Engine: step ready, type = sleep Engine->>SLEEP: publish timer (action: sleep_complete) Engine->>History: step.sleep.started Note over SLEEP: NakWithDelay(duration) SLEEP-->>Engine: redeliver after delay Engine->>History: step.sleep.completed Note over Engine: downstream steps now ready ``` The `SLEEP_TIMERS` stream is shared across multiple timer use cases via an **action discriminator** in the message payload. Sleep steps use the `sleep_complete` action. The same stream handles wait-for-event timeouts (`wait_timeout`), rate-limit retries (`rate_retry`), and other scheduled operations. The step's `WakeAt` field in `StepState` records the expected completion time. This is purely informational -- the actual wake-up is driven by NATS redelivery, not by polling the timestamp. ## Usage ```go wf := dag.NewWorkflow("rate-limited-pipeline") call := wf.Task("call-api", "api-call"). WithTimeout(10 * time.Second) cooldown := wf.Sleep("cooldown", 30*time.Second). After(call) next := wf.Task("next-call", "api-call"). After(cooldown) def, err := wf.Build() ``` ## Configuration Sleep configuration is stored in `StepDef.Config` as `SleepConfig`: | Field | Type | Purpose | |-------|------|---------| | `duration` | `time.Duration` | How long to sleep. Must be positive. | **Bounds:** - Maximum: **365 days** - A warning is logged for durations exceeding 30 days - The engine sets `StepState.WakeAt` to `now + duration` when the sleep starts **Sleep vs Pause:** | | Sleep Step | Worker Pause | |---|-----------|-------------| | Scope | DAG-level, between steps | Within a step handler | | Worker | None | Same worker resumes | | Step status | Transitions through started/completed | Stays `Running` | | Builder | `wf.Sleep(id, duration)` | `ctx.Pause(name, duration)` | | Mechanism | `SLEEP_TIMERS` stream | Checkpoint + `NakWithDelay` | ## Related - [Wait for Event](/docs/step-types/wait-for-event) -- pause until an external event arrives - [Normal Steps](/docs/step-types/normal-steps) -- standard task execution --- # Source: docs/site/content/docs/step-types/sub-workflows.md A **sub-workflow** step spawns a child workflow execution and optionally waits for it to complete, enabling composition of workflows into larger pipelines. ## Overview Sub-workflows let you break complex pipelines into reusable, independently testable units. A parent workflow declares a `StepTypeSubWorkflow` step that references a child workflow by name. When the engine reaches that step, it starts a new run of the child workflow, passing the parent step's input as the child's input. The child runs independently with its own run ID, history stream, and step states. By default, the parent step blocks until the child completes. The child's output becomes the parent step's output, available to downstream dependencies. If the child fails, the parent step fails too. This blocking behavior uses a KV watch pattern -- the engine watches for `workflow.child.completed` or `workflow.child.failed` events rather than polling. For fire-and-forget scenarios, the **detach** mode lets the parent step complete immediately after spawning the child. The child continues running independently, and the parent workflow proceeds without waiting for a result. ## How It Works ```mermaid sequenceDiagram participant Parent as Parent Workflow participant Engine participant Child as Child Workflow Parent->>Engine: sub-workflow step ready Engine->>Child: workflow.spawn (new run) Note over Child: child runs independently alt blocking (default) Child->>Engine: workflow.child.completed Engine->>Parent: step completed (child output) else detached Engine->>Parent: step completed immediately Note over Child: continues independently end ``` The engine links parent and child through `ParentRunID` and `ParentStepID` fields on the child's `WorkflowRun`. This linkage serves two purposes: it enables the KV watch for completion notification, and it enforces the **maximum nesting depth of 3**. When spawning a child, the engine walks the parent chain and rejects the spawn if the depth would exceed 3 levels. The child workflow must be registered in the `workflow_defs` KV bucket before the parent runs. The engine loads the child's `WorkflowDef` at spawn time and starts a fresh `WorkflowRun` with all steps initialized to pending. ## Usage ```go wf := dag.NewWorkflow("deploy-pipeline") build := wf.Task("build", "build-service"). WithTimeout(5 * time.Minute) // Blocking: parent waits for child to complete deploy := wf.SubWorkflow("deploy", "deploy-to-staging"). After(build) // Detached: parent continues immediately notify := wf.SubWorkflow("notify", "send-notifications"). After(deploy). WithDetach() def, err := wf.Build() ``` ## Configuration Sub-workflow configuration is stored in `StepDef.Config` as `SubWorkflowConfig`: | Field | Type | Default | Purpose | |-------|------|---------|---------| | `workflow` | `string` | (required) | Name of the child workflow definition to spawn | | `detach` | `bool` | `false` | If true, parent completes immediately after spawn | **Constraints:** - Maximum nesting depth: **3** (enforced at spawn time) - The child workflow must exist in the `workflow_defs` KV bucket - The `Task` field on the `StepDef` is set to the workflow name by the builder ## Related - [Normal Steps](/docs/step-types/normal-steps) -- single-execution steps - [Planner Steps](/docs/step-types/planner-steps) -- dynamic DAG generation at runtime - [Workflows and DAGs](/docs/concepts/workflows-and-dags) -- workflow definition and structure --- # Source: docs/site/content/docs/step-types/wait-for-event.md A **wait-for-event step** blocks workflow execution until a matching external event arrives or a timeout expires. ## Overview Wait-for-event steps implement **event correlation** -- the ability to pause a workflow and resume it when a specific external event matches a declared condition. This is the pull-based counterpart to signals (which are push-based and target a specific run ID). With wait-for-event, the workflow declares what it is waiting for, and the engine's correlator matches incoming events automatically. The step declares three things: an **event type** to listen for, a **match condition** that compares a field in the event payload against a resolved value (from workflow input or a prior step's output), and a **timeout** after which the step completes with a timeout marker rather than failing. No worker is involved. The engine handles the entire lifecycle: writing a waiter entry to the `event_waiters` KV bucket, subscribing to the `EVENTS` stream, and publishing `step.wait.matched` or `step.wait.timeout` when the condition resolves. ## How It Works ```mermaid sequenceDiagram participant Engine participant KV as event_waiters KV participant Events as EVENTS stream participant Timer as SLEEP_TIMERS Engine->>Engine: step ready, type = wait_for_event Engine->>KV: write waiter entry (resolved match) Engine->>Timer: schedule timeout Engine->>Events: correlator watches alt event arrives Events->>Engine: event matches condition Engine->>Engine: step.wait.matched else timeout Timer-->>Engine: redeliver after timeout Engine->>Engine: step.wait.timeout end Engine->>KV: delete waiter entry ``` The **correlator** runs inside the orchestrator process (not a separate component). It maintains an in-memory index of active waiters by event type, populated via a KV watch on `event_waiters.>`. When an event arrives on the `EVENTS` stream, the correlator performs an **O(1) lookup** by event type, then evaluates each waiter's `ResolvedMatch` against the event payload. Match conditions use two types that split builder-time and runtime concerns: - **`Match`** (builder-time): both `Left` and `Right` are dot-path strings. `Left` references a field in the incoming event. `Right` references a value from `step.{id}.output.{field}` or `input.{field}`. - **`ResolvedMatch`** (runtime): `Right` is resolved to a concrete value when the waiter is created. This avoids re-resolving on every event. Timeouts are not failures. When a wait-for-event step times out, it completes with `{"timeout": true}` as output. Downstream steps can inspect this output to branch accordingly using `SkipIf`. ## Usage ```go wf := dag.NewWorkflow("webhook-handler") create := wf.Task("create-order", "create-order"). WithTimeout(10 * time.Second) wait := wf.WaitForEvent("payment", dag.WaitForEventOpts{ Event: "payment.received", Match: dag.Match{ Left: "order_id", Op: dag.MatchOpEq, Right: "step.create-order.output.id", }, Timeout: 1 * time.Hour, }).After(create) fulfill := wf.Task("fulfill", "fulfill-order"). After(wait) def, err := wf.Build() ``` External systems publish events to the `EVENTS` stream: ```go eventData := `{"order_id": "ord_123", "amount": 99.99}` js.Publish("event.payment.received", []byte(eventData)) ``` ## Configuration Wait-for-event configuration is stored in `StepDef.Config` as `WaitForEventOpts`: | Field | Type | Purpose | |-------|------|---------| | `event` | `string` | Event type to listen for (e.g. `payment.received`) | | `match` | `Match` | Condition: `left` (event field) `op` `right` (resolved value) | | `timeout` | `time.Duration` | How long to wait before completing with timeout output | **Match operators:** | Operator | Meaning | |----------|---------| | `eq` | String equality after `fmt.Sprintf("%v", val)` | **Bounds:** - Maximum 10,000 active waiters per event type - Timeout is required and must be positive - On workflow cancellation, waiter KV entries are cleaned up ## Related - [Sleep and Timers](/docs/step-types/sleep-and-timers) -- durable delays without event matching - [Approval Gates](/docs/step-types/approval-gates) -- human-in-the-loop variant --- # Source: docs/site/content/docs/triggers/_index.md {{< cards cols="2" >}} {{< card link="cli-and-api" title="CLI and API" >}} {{< card link="cron-schedules" title="Cron Schedules" >}} {{< card link="event-triggers" title="Event Triggers" >}} {{< card link="webhooks" title="Webhooks" >}} {{< card link="http" title="HTTP Trigger + Respond Step" >}} {{< /cards >}} --- # Source: docs/site/content/docs/triggers/cli-and-api.md Every workflow run starts with an explicit trigger -- the CLI's `run start` command or the REST API's `POST /v1/runs` endpoint. ## Starting a Run via CLI The `dagnats run start` command creates a new run of a registered workflow and returns the run ID immediately. ```bash dagnats run start code-review-pipeline \ --input '{"repo": "acme/api", "pr": 42}' ``` The `--input` flag accepts a JSON string that becomes the run's `Input` payload. Steps without dependencies receive this payload directly. ### Watching a Run Add `--watch` to stream run events to the terminal until the run reaches a terminal state: ```bash dagnats run start code-review-pipeline \ --input '{"repo": "acme/api", "pr": 42}' \ --watch ``` The watch stream prints step status transitions as they occur. Press `Ctrl+C` to detach without cancelling the run. ### Bulk Runs Start up to 1000 runs of the same workflow in a single call using `dagnats run bulk`: ```bash dagnats run bulk code-review-pipeline \ --from-file inputs.jsonl ``` Each line of the JSONL file is one run's input. You can also pass inputs as positional arguments: ```bash dagnats run bulk code-review-pipeline \ '{"pr": 1}' '{"pr": 2}' '{"pr": 3}' ``` Validation is **atomic** -- the first invalid input fails the entire batch before any runs are created. ## Starting a Run via REST API The control plane exposes a REST endpoint for programmatic run creation. ```bash curl -X POST http://localhost:8080/v1/runs \ -H "Content-Type: application/json" \ -d '{ "workflow": "code-review-pipeline", "input": {"repo": "acme/api", "pr": 42} }' ``` The response includes the new run ID: ```json {"run_id": "abc123"} ``` ### Bulk API `POST /v1/runs/bulk` accepts an array of inputs for the same workflow: ```json { "workflow": "code-review-pipeline", "inputs": [ {"repo": "acme/api", "pr": 1}, {"repo": "acme/api", "pr": 2} ] } ``` Same atomic validation semantics as the CLI -- all or nothing. ## Run Lifecycle Once started, a run transitions through these states: | Status | Meaning | |--------|---------| | `pending` | Created, not yet claimed by the engine | | `running` | Engine is actively processing steps | | `completed` | All steps finished successfully | | `failed` | One or more steps failed permanently | | `cancelled` | Cancelled via CLI or API | Check run status at any time with: ```bash dagnats run get dagnats run get --json ``` ## Related Pages - [Cron Schedules](/docs/triggers/cron-schedules) -- automated recurring runs - [Event Triggers](/docs/triggers/event-triggers) -- start runs from NATS events - [Webhooks](/docs/triggers/webhooks) -- fire-and-forget HTTP triggers (202 immediately) - [HTTP Trigger + Respond Step](/docs/triggers/http) -- synchronous HTTP request/response endpoints - [Retry Policies](/docs/reliability/retry-policies) -- handling transient failures - [Cancellation](/docs/reliability/cancellation) -- stopping a running workflow --- # Source: docs/site/content/docs/triggers/cron-schedules.md Cron triggers start workflow runs on a recurring schedule, managed entirely through the CLI and persisted in the `triggers` KV bucket. ## Creating a Cron Trigger Use `dagnats trigger create` with the `--cron` flag to register a scheduled trigger: ```bash dagnats trigger create daily-report \ --workflow report-pipeline \ --cron "0 9 * * *" \ --input '{"format": "pdf"}' ``` This creates a trigger named `daily-report` that starts a `report-pipeline` run every day at 9:00 AM. ## Cron Syntax DagNats uses standard five-field cron syntax: ``` ┌───────────── minute (0-59) │ ┌───────────── hour (0-23) │ │ ┌───────────── day of month (1-31) │ │ │ ┌───────────── month (1-12) │ │ │ │ ┌───────────── day of week (0-6, Sunday=0) │ │ │ │ │ * * * * * ``` | Expression | Schedule | |------------|----------| | `*/15 * * * *` | Every 15 minutes | | `0 9 * * 1-5` | Weekdays at 9:00 AM | | `0 0 1 * *` | First of every month at midnight | | `30 */2 * * *` | Every 2 hours at :30 | ## Timezone By default, cron expressions are evaluated in UTC. Specify a timezone with `--timezone`: ```bash dagnats trigger create standup-reminder \ --workflow notify \ --cron "0 9 * * 1-5" \ --timezone "America/Denver" ``` ## Trigger Lifecycle Triggers are stored as JSON in the `triggers` KV bucket. The scheduler evaluates all active triggers on each tick, comparing the current time against each trigger's cron expression and its last-fire timestamp from the `trigger_state` KV bucket. ### Managing Triggers ```bash # List all triggers dagnats trigger list # Inspect a trigger dagnats trigger get daily-report # Delete a trigger dagnats trigger delete daily-report ``` ### Trigger State The `trigger_state` KV bucket stores the last-run timestamp for each cron trigger. This prevents duplicate fires after a scheduler restart -- the scheduler compares "now" against the stored timestamp to determine whether a tick should fire. If the scheduler was down during a scheduled time, it performs a **backfill** on startup, evaluating missed windows and firing any triggers that should have run. Backfill evaluates triggers concurrently using `errgroup` for performance. ## How It Works ```mermaid sequenceDiagram participant Scheduler participant KV as trigger_state KV participant Engine Scheduler->>KV: read last-fire timestamp Scheduler->>Scheduler: evaluate cron expression alt should fire Scheduler->>Engine: start workflow run Scheduler->>KV: update last-fire timestamp end ``` The scheduler runs inside the `dagnats serve` process. Each tick evaluates all triggers independently -- one failing trigger does not block the others. ## Related Pages - [CLI and API](/docs/triggers/cli-and-api) -- manual run triggers - [Event Triggers](/docs/triggers/event-triggers) -- event-driven triggers - [Concurrency Limits](/docs/flow-control/concurrency-limits) -- preventing trigger overload --- # Source: docs/site/content/docs/triggers/event-triggers.md Event triggers start workflow runs in response to messages published to the NATS `EVENTS` stream, enabling reactive workflows driven by external systems. ## Overview Any system that can publish to NATS can trigger a DagNats workflow. The `EVENTS` stream accepts messages on `event.>` subjects. A trigger definition binds a subject pattern to a workflow, optionally filtering on message content. ## Creating an Event Trigger ```bash dagnats trigger create on-deploy \ --workflow deploy-pipeline \ --subject "event.deploy.production" ``` This fires a `deploy-pipeline` run whenever a message arrives on `event.deploy.production`. The event payload becomes the run's input. ### Subject Wildcards NATS subject wildcards work in trigger definitions: | Pattern | Matches | |---------|---------| | `event.deploy.*` | Any deploy event (`event.deploy.staging`, `event.deploy.production`) | | `event.git.>` | All git events at any depth (`event.git.push`, `event.git.pr.opened`) | ## Publishing Events External systems publish events to the `EVENTS` stream using any NATS client: ```go nc, _ := nats.Connect("nats://localhost:4222") js, _ := nc.JetStream() payload := []byte(`{"repo": "acme/api", "branch": "main"}`) js.Publish("event.deploy.production", payload) ``` ## Filtering Triggers can filter events by content to avoid unnecessary runs. The filter evaluates fields in the event payload before starting a workflow. ```bash dagnats trigger create on-main-push \ --workflow ci-pipeline \ --subject "event.git.push" \ --filter-field "branch" \ --filter-value "main" ``` Only events where `branch == "main"` will trigger a run. ## Debounce High-frequency events can be debounced to collapse rapid-fire events into a single run. Debounce state is tracked in the `debounce_state` KV bucket. ```bash dagnats trigger create on-file-change \ --workflow rebuild \ --subject "event.fs.changed" \ --debounce "5s" ``` When the first event arrives, a timer starts. Subsequent events within the 5-second window reset the timer. The run fires once the window expires with the **last** event's payload. The timer is implemented via `SLEEP_TIMERS` with a `debounce_fire` action. ## Batching Batch mode accumulates events over a time window or until a count threshold is reached, then fires a single run with all accumulated payloads as an array input. State is tracked in the `batch_state` KV bucket (TTL: 2x max timeout). The timer uses `SLEEP_TIMERS` with a `batch_fire` action. ## Event Correlation vs. Event Triggers **Event triggers** start new workflow runs. They live in the `triggers` KV bucket and are evaluated by the scheduler. **Wait-for-event steps** pause an existing run until a matching event arrives. They use the `event_waiters` KV bucket and are evaluated by the correlator inside the orchestrator. These are distinct mechanisms. See [Wait for Event](/docs/step-types/wait-for-event) for the step-level pattern. ## Related Pages - [Cron Schedules](/docs/triggers/cron-schedules) -- time-based triggers - [Webhooks](/docs/triggers/webhooks) -- HTTP-based event ingestion - [Wait for Event](/docs/step-types/wait-for-event) -- mid-workflow event correlation --- # Source: docs/site/content/docs/triggers/http.md The HTTP trigger turns a DagNats workflow into a synchronous HTTP endpoint. The caller waits for the workflow's response. The **respond step** is the explicit node in the DAG that publishes that response — analogous to a `return` statement, but a node because DAGs have no single return point. This pair (ADR-013) is distinct from [webhooks](/docs/triggers/webhooks), which are fire-and-forget; webhook callers get a 202 immediately and never see the workflow's output. ## Mental model: `respond` is a side effect, not a return ``` http trigger → [step A] → [step B] → respond → [step C] → [step D] │ └─ HTTP response dispatched here (client connection released) ``` `[step C]` and `[step D]` run **after** the HTTP client has already received its response. Their outputs are not visible to the caller. This is desirable for cleanup, audit logging, or fanning out follow-up workflows. **Anti-pattern:** placing an auth-revocation, billing-charge, or any "must-complete-before-the-user-sees-success" operation *after* `respond`. The user has already seen success; the late step can fail silently. Put such steps **before** `respond`, or split into a separate workflow keyed off the response event. ## Defining an HTTP trigger Triggers ship inline with the workflow JSON; the dagnats CLI registers both in one call: ```json { "name": "http-echo", "version": "1.0", "steps": [ { "id": "echo", "task": "echo", "depends_on": [] }, { "id": "respond", "type": "respond", "depends_on": ["echo"], "config": { "status": 200, "content_type": "application/json" } } ], "triggers": [ { "id": "http-echo-trigger", "workflow_id": "http-echo", "enabled": true, "http": { "path": "/api/echo", "method": "POST", "timeout_ms": 5000, "max_body_bytes": 1048576 } } ] } ``` Configuration fields: | Field | Required | Default | Notes | | -------------------- | -------- | ------- | --------------------------------------------------------------------- | | `path` | yes | — | Exact match, must start with `/`. No wildcards in v1. | | `method` | yes | — | One of `GET`, `POST`, `PUT`, `PATCH`, `DELETE`. | | `timeout_ms` | yes | — | Hard cap on the request; 504 if elapsed. | | `max_body_bytes` | yes | — | 413 if exceeded. | | `secret` | no | — | HMAC-SHA256 shared secret; signature read from `X-Signature-256`. | | `idempotency_header` | no | — | If set, header value → run replay (see below). | Routes mount under `/api/` on the same HTTP listener as the control plane. Two HTTP triggers may not share the same `(method, path)` — registration of a colliding trigger returns a `route_conflict` error with the holder trigger's id. ## Reading the request inside a worker Every trigger kind (cron, webhook, subject, http) hands the worker a **wrapped envelope**. The worker's task input is *not* the HTTP request directly — it's a `TriggerEnvelope` whose `data` field carries the request envelope: ```json { "trigger": "http", "source": "http-echo-trigger", "workflow_id": "http-echo", "timestamp": "2026-05-13T18:37:29Z", "data": { "method": "POST", "path": "/api/echo", "headers": { "Content-Type": "application/json" }, "body": "" } } ``` `data.body` is base64-encoded over JSON because the engine treats it as opaque bytes — `[]byte` in Go, which `encoding/json` renders as base64. Unmarshalling back into `[]byte` decodes it. The `worker.UnwrapTrigger()` option on `HandleTyped` auto-detects the envelope and hands the typed handler the unwrapped `data` directly, so workers that don't need the outer metadata can skip the wrapper struct: ```go type httpRequestData struct { Method string `json:"method"` Path string `json:"path"` Headers map[string]string `json:"headers,omitempty"` Body []byte `json:"body,omitempty"` // base64 over JSON } worker.HandleTyped(w, "echo", func(ctx worker.TaskContext, in httpRequestData) (echoOutput, error) { // in.Method == "POST" // in.Path == "/api/echo" // in.Body == raw request bytes (already base64-decoded) var inner struct{ Name string `json:"name"` } _ = json.Unmarshal(in.Body, &inner) ... }, worker.UnwrapTrigger(), ) ``` Auto-detect is structural: the option only unwraps inputs whose JSON has both a top-level `trigger` string AND a top-level `data` field. Plain inputs (e.g. during local unit tests, or when the workflow is invoked directly via the CLI) still pass through unchanged. Authors who need the trigger metadata fields (`trigger`, `source`, `timestamp`) can drop the option and unmarshal the envelope manually via `ctx.Input()` — see [#229](https://github.com/danmestas/dagnats/issues/229) for when these will become first-class on `TaskContext`. This wrap is shared with `cron`, `webhook`, and `subject` triggers — the metadata is uniform, only `data` varies by trigger kind. Working example: [`examples/http-respond/main.go`](https://github.com/danmestas/dagnats/blob/main/examples/http-respond/main.go). ## Defining the respond step ```json { "id": "respond", "type": "respond", "depends_on": ["upstream-step"], "config": { "status": 200, "content_type": "application/json", "headers": { "X-Custom-Header": "value" }, "body_from": "result.value" } } ``` Configuration fields: | Field | Default | Meaning | | -------------- | -------------------- | ------------------------------------------------------------------------- | | `status` | `200` | HTTP status code. | | `content_type` | `application/json` | `Content-Type` header. | | `headers` | `null` | Extra response headers. | | `body_from` | `""` (upstream) | Empty: use the upstream step's output. Dotpath like `result.value`: pluck. | ## Response always carries `X-Dagnats-Run-Id` Every HTTP response includes `X-Dagnats-Run-Id` with the run id. Use it with `dagnats run inspect ` to walk the DAG that produced the response — including any steps that ran *after* `respond` (which the client never sees). ## Failure modes | Condition | HTTP outcome | | ---------------------------------------- | ----------------------------------------------------------- | | Worker returns error → engine fails run | `500` with `{"error":"workflow_failed","run_id":"..."}` | | Run cancelled via `dagnats run cancel` | `503` with `{"error":"workflow_cancelled","run_id":"..."}` | | Client disconnects before response | `499` with `{"error":"client_closed","run_id":"..."}` | | Per-request timeout elapses | `504` with `{"error":"workflow_timeout","run_id":"..."}` | | Workflow ends without hitting `respond` | `504` (same as timeout — there's no other signal) | The last case is the foot-gun the workflow validator warns about at registration time. If you register a workflow with an HTTP trigger but no reachable `respond` step, `POST /workflows` returns 201 with a `warnings` array: ```json { "status": "registered", "name": "http-echo", "warnings": [ { "kind": "missing_respond", "message": "..." } ] } ``` The other warning is `duplicate_respond` — two respond steps simultaneously reachable on the same run. Mutually-exclusive branches (happy-path + error-path each with their own `respond`) are not warned about. Warnings are surfaced; they do not block registration. ## Idempotency replay Setting `idempotency_header` (e.g. `Idempotency-Key`) opts the trigger into replay semantics: when two requests carry the same header value, the second request is bound to the original run's response. The mapping `(trigger_id, header_value) → run_id` is held in a JetStream KV with a 1-hour TTL. This is true replay — not just NATS dedup. The second request receives the **same response body** as the first, even after the first run has fully completed and the response subject has gone idle. ## Compared to webhooks | Capability | HTTP trigger | [Webhook](/docs/triggers/webhooks) | | ------------------------ | ---------------- | ---------------------------------- | | Caller waits for output | yes | no — 202 immediately | | Response from workflow | `respond` step | none | | Path | configurable | `/hooks/{name}` | | HMAC validation | optional | optional | | Idempotency replay | yes (`Idempotency-Key`) | no | Use webhooks for fire-and-forget ingestion (GitHub events, Stripe events, batch kicks). Use HTTP triggers when you need the workflow's result on the wire. ## Example See [examples/http-respond/](https://github.com/danmestas/dagnats/tree/main/examples/http-respond) for a runnable workflow + worker pair. ## Related Pages - [Webhooks](/docs/triggers/webhooks) -- fire-and-forget HTTP ingestion - [CLI and API](/docs/triggers/cli-and-api) -- manual run creation --- # Source: docs/site/content/docs/triggers/webhooks.md Webhook triggers expose an HTTP endpoint that translates incoming HTTP requests into NATS events, bridging external services like GitHub, Stripe, or custom systems into DagNats workflows. ## How It Works The webhook endpoint runs on the `dagnats serve` HTTP mux. When a request arrives, the server validates the request, extracts the payload, and publishes it to the `EVENTS` stream where event triggers pick it up. ```mermaid sequenceDiagram participant External as External Service participant Webhook as Webhook Endpoint participant EVENTS as EVENTS Stream participant Trigger as Event Trigger External->>Webhook: POST /v1/webhooks/{name} Webhook->>Webhook: validate HMAC signature Webhook->>EVENTS: publish event.webhook.{name} EVENTS->>Trigger: trigger evaluates event Trigger->>Trigger: start workflow run ``` ## Request Validation Webhook endpoints validate incoming requests using HMAC-SHA256 signatures. The shared secret is configured per-trigger or via the `DAGNATS_WEBHOOK_SECRET` environment variable. ### Setting the Secret **Environment variable** (applies as default for all webhooks): ```bash export DAGNATS_WEBHOOK_SECRET="whsec_your_secret_here" dagnats serve ``` **Per-trigger** (overrides the environment variable): ```bash dagnats trigger create github-push \ --workflow ci-pipeline \ --webhook \ --secret "whsec_github_specific_secret" ``` The `--secret` flag always takes precedence over `DAGNATS_WEBHOOK_SECRET`. Using the environment variable keeps secrets out of shell history. ### Signature Verification The webhook endpoint expects a signature header in the request. It computes `HMAC-SHA256(secret, request_body)` and compares against the provided signature. Requests with invalid or missing signatures receive a `401 Unauthorized` response. ## Creating a Webhook Trigger Combine `--webhook` with a workflow binding: ```bash dagnats trigger create stripe-payment \ --workflow payment-processor \ --webhook \ --secret "whsec_stripe_secret" ``` This creates an endpoint at `/v1/webhooks/stripe-payment`. Configure the external service to send `POST` requests to this URL. The request body becomes the workflow run's input. Content-Type must be `application/json`. ## Connecting to Event Triggers Webhooks publish to `event.webhook.{trigger-name}` on the `EVENTS` stream. You can also create a separate event trigger that listens to webhook subjects for additional filtering or debouncing: ```bash # Webhook ingests the HTTP request dagnats trigger create github-events \ --workflow github-handler \ --webhook # Separate trigger with filtering dagnats trigger create on-pr-opened \ --workflow pr-review \ --subject "event.webhook.github-events" \ --filter-field "action" \ --filter-value "opened" ``` ## Related Pages - [Event Triggers](/docs/triggers/event-triggers) -- NATS-native event triggers - [CLI and API](/docs/triggers/cli-and-api) -- manual run creation --- # Source: docs/site/content/docs/workers/_index.md {{< cards cols="3" >}} {{< card link="embedded-workers" title="Embedded Workers" >}} {{< card link="heartbeats" title="Heartbeats" >}} {{< card link="http-bridge" title="HTTP Bridge" >}} {{< card link="sticky-assignment" title="Sticky Assignment" >}} {{< card link="worker-configuration" title="Worker Configuration" >}} {{< /cards >}} --- # Source: docs/site/content/docs/workers/embedded-workers.md Embedded workers run in the same process as the DagNats engine, eliminating network hops between the orchestrator and task handlers. ## When to Use **Development** -- Run the entire system in a single binary for local iteration. No separate worker processes to manage, no port conflicts, instant feedback. **Simple deployments** -- When the workflow engine and workers share the same machine and scaling them independently is not needed. A single `dagnats serve` process handles orchestration and task execution. **Testing** -- Integration and end-to-end tests embed workers alongside the engine in the test process. Each test gets its own NATS server, engine, and workers with zero external dependencies. ## How It Works An embedded worker uses the same `worker.NewWorker()` constructor and `Handle()` registration as a standalone worker. The only difference is lifecycle management -- the worker starts and stops alongside the engine in the same process. ```go nc, _ := nats.Connect(nats.DefaultURL) natsutil.SetupAll(nc) // Create engine eng := engine.New(nc, tel) // Create embedded worker w := worker.NewWorker(nc, tel) w.Handle("send-email", emailHandler) w.Handle("resize-image", imageHandler) w.Start() defer w.Stop() // Engine and worker share the same NATS connection eng.Start() defer eng.Stop() ``` Because both the engine and worker connect to the same NATS server, task dispatch and completion events flow through JetStream exactly as they would with separate processes. There is no special "embedded mode" -- the worker subscribes to the same TASK_QUEUES stream and publishes the same events. ## When Not to Use **Independent scaling** -- If workers need to scale horizontally (more CPU for image processing) while the engine stays at one instance, run workers as separate processes. **Language diversity** -- If some task handlers are written in Python or TypeScript, those must use the [HTTP Bridge](/docs/workers/http-bridge) regardless. **Fault isolation** -- A panic in an embedded worker takes down the engine. Separate processes isolate failures. ## Test Pattern The `dagnatstest` package provides a one-call setup that creates an embedded NATS server, engine, and worker for testing: ```go func TestWorkflow(t *testing.T) { env := dagnatstest.Setup(t) env.Worker.Handle("my-task", func(ctx worker.TaskContext) error { return ctx.Complete([]byte(`{"ok": true}`)) }) env.Start() // Submit workflow and assert results... } ``` Each test gets an isolated NATS server, so tests run in parallel without interference. ## Related - [Worker Configuration](/docs/workers/worker-configuration) -- full configuration reference - [HTTP Bridge](/docs/workers/http-bridge) -- non-Go worker support --- # Source: docs/site/content/docs/workers/heartbeats.md `Heartbeat()` extends the NATS AckWait timer on a task message to prevent redelivery while long-running work is in progress. ## The Problem JetStream delivers each task message with an **AckWait** deadline. If the worker does not acknowledge the message before the deadline expires, NATS assumes the worker failed and redelivers to another consumer. For tasks that take longer than AckWait (default 30 seconds), this causes duplicate execution. ## The Solution Calling `Heartbeat()` on the `TaskContext` signals to NATS that the worker is still alive and processing. Internally, it calls `msg.InProgress()` on the underlying JetStream message, which resets the AckWait timer. ```go w.Handle("long-task", func(ctx worker.TaskContext) error { ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() for i := 0; i < steps; i++ { doWork(i) select { case <-ticker.C: ctx.Heartbeat() default: } } return ctx.Complete(result) }) ``` ## Recommended Interval Send heartbeats at **one-third of the AckWait duration**. With the default 30-second AckWait, heartbeat every 10 seconds. This provides two missed heartbeats as buffer before redelivery. | AckWait | Heartbeat Interval | Missed Heartbeats Before Redeliver | |---------|-------------------|------------------------------------| | 30s | 10s | 2 | | 60s | 20s | 2 | | 120s | 40s | 2 | ## What Happens When Heartbeats Stop If a worker stops sending heartbeats (crash, network partition, infinite loop without heartbeat calls), the sequence is: 1. **AckWait expires** -- NATS marks the message as unacknowledged 2. **Redelivery** -- NATS delivers the message to another available consumer 3. **RetryCount increments** -- the new worker sees `ctx.RetryCount()` incremented by 1 4. **MaxDeliver limit** -- after the configured maximum deliveries, the message is discarded or sent to the dead letter stream The worker that crashed does not need to do anything. JetStream's redelivery mechanism handles the failover automatically. ## Streaming with Heartbeats When using [PutStream](/docs/coordination/streaming) for real-time output, interleave heartbeats with stream publishes: ```go w.Handle("generate", func(ctx worker.TaskContext) error { for i, chunk := range generate(ctx.Input()) { ctx.PutStream(chunk) if i%50 == 0 { ctx.Heartbeat() } } return ctx.Complete(assembleResult()) }) ``` ## Panics `Heartbeat()` panics if the underlying message is nil (already consumed or not initialized). This is a programmer error -- it means the handler called a completion method before calling Heartbeat, or the TaskContext was used outside its handler scope. ## Related - [Worker Configuration](/docs/workers/worker-configuration) -- setting up workers - [Streaming](/docs/coordination/streaming) -- real-time output with heartbeats - [Checkpoints](/docs/coordination/checkpoints) -- saving state across retries - [Retry Policies](/docs/reliability/retry-policies) -- controlling retry behavior --- # Source: docs/site/content/docs/workers/http-bridge.md The HTTP bridge is an HTTP-to-NATS gateway that lets non-Go workers (Python, TypeScript, any language with an HTTP client) interact with DagNats over three REST endpoints. ## Architecture The bridge runs as an HTTP server that translates REST calls into NATS JetStream operations. It maintains an in-memory **ack map** that tracks polled NATS messages so they can be acknowledged or NAK'd when the HTTP worker resolves a task. ``` HTTP Worker --> Bridge (HTTP) --> NATS JetStream poll <-- task payload <-- TASK_QUEUES resolve --> event publish --> WORKFLOW_HISTORY ``` The bridge provides **full capability parity** with Go native workers: completion, failure, retry, checkpointing, signals, and pause are all supported through the resolve endpoint. ## Endpoints ### POST /v1/workers/connect Registers an HTTP worker and opens an SSE heartbeat stream. The connection stays open; the bridge sends `event: heartbeat` every 25 seconds to keep proxies and load balancers alive. ```json { "worker_id": "python-worker-1", "task_types": ["extract-text", "classify"], "max_tasks": 3 } ``` The worker appears in the **workers** KV directory alongside Go native workers. On disconnect, the bridge deregisters the worker automatically. ### POST /v1/tasks/poll Long-polls for tasks from the TASK_QUEUES stream. Returns a JSON array of task payloads, or an empty array on timeout. ```json { "task_types": ["extract-text"], "max_tasks": 1, "timeout_ms": 30000 } ``` Response: ```json [ { "task_id": "abc123.step-1", "run_id": "abc123", "step_id": "step-1", "iteration": 0, "attempt": 0, "input": {"url": "https://example.com/doc.pdf"} } ] ``` The `timeout_ms` field controls how long the bridge waits for a task before returning empty. Maximum is 60 seconds. ### POST /v1/tasks/{id}/resolve Resolves a polled task. The `action` field determines behavior: | Action | Description | |--------|-------------| | `complete` | Publishes step.completed, acks the NATS message | | `fail` | Publishes step.failed with configurable failure type | | `pause` | Writes checkpoint, NAKs with delay for later retry | | `checkpoint` | Saves state to KV, extends ack deadline | | `send_signal` | Writes signal to KV for cross-step coordination | | `wait_signal` | Blocks until signal arrives or timeout expires | Complete example: ```json { "action": "complete", "output": {"extracted_text": "Hello world"} } ``` Fail with retry-after: ```json { "action": "fail", "error": "rate limited by upstream API", "failure_type": "retry_after", "retry_after_ms": 5000 } ``` ## Authentication Set the `DAGNATS_BRIDGE_TOKEN` environment variable to enable bearer token authentication. When set, all requests must include: ``` Authorization: Bearer ``` When unset, all requests are allowed (development mode). ## Setup ```go b := bridge.NewBridge(nc, tel) http.ListenAndServe(":8080", b.Handler()) ``` The bridge binds optional KV buckets for **checkpoints** and **signals** at construction time. If these buckets are missing, the corresponding resolve actions return an error. ## Examples Working examples of non-Go workers using the HTTP bridge: - **[Python worker](https://github.com/Craft-Design-Group/dagnats/tree/main/examples/http-worker-python)** -- complete Python worker with connect, poll, resolve, and reconnection logic - **[curl walkthrough](https://github.com/Craft-Design-Group/dagnats/tree/main/examples/http-worker-curl)** -- step-by-step protocol walkthrough using only curl commands ## Related - [Worker Configuration](/docs/workers/worker-configuration) -- Go native worker setup - [Checkpoints](/docs/coordination/checkpoints) -- durable state persistence - [Signals](/docs/coordination/signals) -- cross-step coordination --- # Source: docs/site/content/docs/workers/sticky-assignment.md Sticky assignment routes all steps of a workflow run to the same worker, enabling cache affinity, GPU pinning, and session-local state. ## StickyStrategy The `StickyStrategy` type is a string enum on `WorkflowDef` that controls worker affinity behavior: | Strategy | Value | Behavior | |----------|-------|----------| | **None** | `""` | Default. Tasks are distributed across all workers via the TASK_QUEUES stream. | | **Soft** | `"soft"` | The engine attempts to route to the same worker. Falls back to any worker if the bound worker is unavailable. | | **Hard** | `"hard"` | The engine always routes to the bound worker. If that worker is down, the task waits until it returns. | ```go def := dag.WorkflowDef{ Name: "gpu-pipeline", Sticky: dag.StickySoft, Steps: []dag.StepDef{...}, } ``` ## How It Works When a sticky workflow starts, the engine records the first worker that completes a step in the **sticky_bindings** KV bucket (key: `{runID}`, value: `{workerID}`). Subsequent steps are published to the `STICKY_TASKS` stream on subject `sticky.{taskType}.{workerID}.{runID}`, where only that specific worker is listening. Workers automatically subscribe to their sticky subject on startup: ``` sticky.{taskType}.{workerID}.> ``` The `STICKY_TASKS` stream uses **memory storage** with a 30-minute MaxAge. The `sticky_bindings` KV bucket has a 25-hour TTL, ensuring bindings expire after the workflow completes. ## Use Cases **Cache affinity** -- An LLM embedding pipeline loads a large model into memory. Sticky soft routing keeps all steps on the same worker to reuse the loaded model, but allows failover if the worker crashes. **GPU pinning** -- A video processing pipeline needs a specific GPU. Sticky hard routing guarantees all steps run on the same machine, even if it means waiting for the worker to recover. **Session state** -- A multi-step conversation agent accumulates context in memory. Sticky routing avoids serializing and deserializing the full context on every step. ## Soft vs. Hard Choose **soft** for performance optimization where correctness does not depend on worker identity. The engine tries the sticky worker first but falls back to the normal TASK_QUEUES stream if delivery fails. Choose **hard** only when correctness requires the same worker -- for example, when local hardware resources are involved. Hard sticky tasks wait indefinitely for the bound worker to return, so pair with a workflow-level timeout to bound the wait. ## Infrastructure Requirements Sticky routing requires two NATS resources beyond the defaults: 1. **STICKY_TASKS stream** -- provisioned by `natsutil.SetupStickyStream()` 2. **sticky_bindings KV bucket** -- provisioned by `natsutil.SetupKVBuckets()` Both are created automatically by `natsutil.SetupAll()`. If the STICKY_TASKS stream does not exist, the worker silently skips sticky subscription -- sticky features degrade gracefully. ## Related - [Worker Configuration](/docs/workers/worker-configuration) -- setting up workers - [Flow Control](/docs/flow-control/rate-limiting) -- controlling task throughput --- # Source: docs/site/content/docs/workers/worker-configuration.md `worker.NewWorker()` is the single entry point for creating a task processor that subscribes to NATS JetStream and dispatches messages to registered handlers. ## Constructor ```go w := worker.NewWorker(nc, tel, worker.WithGroups("gpu", "cpu"), worker.WithPartitions(8), ) w.Handle("resize-image", resizeHandler) w.HandleSingleton("billing-sync", billingHandler) w.Start() defer w.Stop() ``` The constructor panics if `nc` is nil or JetStream cannot be initialized -- both are startup-time programmer errors. When `tel` is nil, a **noop telemetry** bundle is used so callers are not forced to import the `observe` package for simple use cases. ## Options | Option | Description | Default | |--------|-------------|---------| | `WithGroups(groups...)` | Subscribe only to specific **worker groups**. The worker listens on `task.{type}.{group}.>` instead of `task.{type}.>`. | All groups | | `WithPartitions(n)` | Enable **elastic consumer groups** with `n` partitions (1--256). Each partition gets its own JetStream consumer for parallel processing. | 0 (legacy single consumer) | ### WithGroups Groups route tasks to a subset of workers. When a step definition sets `WorkerGroup: "gpu"`, only workers created with `WithGroups("gpu")` receive those tasks. ```go w := worker.NewWorker(nc, tel, worker.WithGroups("gpu")) w.Handle("inference", inferenceHandler) w.Start() ``` Panics if called with zero groups or any empty group name -- both are programmer errors that should fail at startup. ### WithPartitions Partitions enable the [pcgroups](https://github.com/synadia-io/orbit.go) elastic consumer group pattern. Workers automatically join and leave the group; partitions are rebalanced across all active members. ```go w := worker.NewWorker(nc, tel, worker.WithPartitions(16)) w.Handle("process", handler) w.Start() ``` The partition count is bounded to 256 maximum. Higher values panic at construction time. ## Singleton Handlers `HandleSingleton` registers a handler that runs as a **single-partition** elastic consumer group. Only one worker instance processes messages for that task type at any given time, across the entire cluster. ```go w.HandleSingleton("cron-cleanup", cleanupHandler) ``` Internally, `HandleSingleton` sets `partitions = 1` for the task type and implicitly enables partitioned mode if `WithPartitions` was not called. ## Handler Registration `Handle` maps a task type string to a `HandlerFunc`. The handler receives a [TaskContext](/docs/workers/heartbeats) with methods for input access, completion, streaming, checkpointing, and signals. ```go w.Handle("send-email", func(ctx worker.TaskContext) error { input := ctx.Input() // ... process return ctx.Complete([]byte(`{"sent": true}`)) }) ``` Call exactly one of `Complete`, `Fail`, `FailPermanent`, `FailRetryAfter`, or `Continue` per execution. Returning a non-nil error from the handler triggers a retry via `NakWithDelay(5s)`. ## Lifecycle 1. **NewWorker** -- allocates the worker, applies options, creates metric instruments 2. **Handle / HandleSingleton** -- registers task handlers (must be called before Start) 3. **Start** -- creates JetStream consumers, binds optional KV buckets, registers in the worker directory 4. **Stop** -- unsubscribes all consumers, deregisters from the directory Start panics if no handlers are registered. Stop is safe to call after Start and cleans up all resources including the directory heartbeat goroutine. ## Optional KV Buckets Start binds two optional KV buckets if they exist: - **checkpoints** -- enables `Checkpoint()` and `LoadCheckpoint()` on TaskContext - **signals** -- enables `WaitForSignal()` and `SendSignal()` on TaskContext If either bucket is missing, the corresponding methods return an error. Provision them via `natsutil.SetupAll()` or `natsutil.SetupKVBuckets()`. ## Related - [Heartbeats](/docs/workers/heartbeats) -- extending AckWait for long-running tasks - [Sticky Assignment](/docs/workers/sticky-assignment) -- worker affinity routing - [Embedded Workers](/docs/workers/embedded-workers) -- running workers in-process - [Rate Limiting](/docs/flow-control/rate-limiting) -- per-key and global rate limits