diff --git a/README.md b/README.md index 1c6dd27..a4d6af7 100644 --- a/README.md +++ b/README.md @@ -404,6 +404,7 @@ dde signal --input examples/signal-update.json dde backtest --input internal/backtest/testdata/scenario.json dde migrate dde serve +dde mcp dde version ``` @@ -449,6 +450,47 @@ GET /v1/plans/{id}/versions --- +## MCP server + +The engine is also an **MCP server**: every use-case above is exposed as a +Model Context Protocol tool, so MCP-capable agents (Claude Code, Claude +Desktop, custom runtimes) can create goals, generate ranked plans, submit +signals, record outcomes and audit the version history with zero integration +code. + +```bash +dde mcp # stdio — point Claude Code/Desktop at {"command": "dde", "args": ["mcp"]} +dde serve # also mounts streamable HTTP MCP at http://localhost:8080/mcp +``` + +Both transports share the engine's semantics with REST; the `/mcp` endpoint +shares the live service with the API. + +> 📖 **Tool reference, transports and agent-loop guidance:** [`docs/mcp.md`](docs/mcp.md) + +--- + +## Webhooks + +The engine can push domain events (`goal.created`, `plan.created`, +`signal.received`, `replan.completed`, `outcome.recorded`, +`goal.status_changed`) to an HTTP endpoint as they happen — Slack bridges, +n8n/Zapier flows and agent triggers react to decisions without polling. +Off by default; enable with: + +```bash +DDE_WEBHOOK_URL=https://example.com/hook \ +DDE_WEBHOOK_SECRET=s3cret \ +dde serve +``` + +Deliveries are best-effort with retries and an HMAC-SHA256 signature header +(`X-DDE-Signature`) for receiver-side verification. + +> 📖 **Event types, envelope format, signature verification and delivery semantics:** [`docs/api.md`](docs/api.md#webhooks) + +--- + ## Architecture ```text @@ -493,6 +535,8 @@ The initial core is implemented and tested: * Postgres persistence with ordered migrations * Prometheus metrics + Grafana dashboards * a minimal Next.js admin UI +* webhook event notifications +* an MCP server (stdio + streamable HTTP) More LLM providers, local / self-hosted inference, authentication, richer scoring, OpenTelemetry, and deeper admin workflows are planned next. @@ -524,6 +568,8 @@ More LLM providers, local / self-hosted inference, authentication, richer scorin * [x] Async, FIFO-per-plan replanning + durable signal status + plan cache (TTL for finance) * [x] Per-domain metrics * [x] Pluggable external data sources (HTTP/MCP/AI-agent/read-model; deterministic pre-fetch, provenance-tracked) +* [x] Webhook event notifications (best-effort, HMAC-signed, retried) +* [x] MCP server (stdio via `dde mcp` + streamable HTTP at `/mcp`) * [ ] Local / self-hosted LLM inference (OpenAI-compatible endpoint, provider TBD) * [ ] Real market-data vendor (HTTP provider stub in place) * [ ] OpenTelemetry tracing diff --git a/cmd/dde/commands.go b/cmd/dde/commands.go index d44bffc..b375897 100644 --- a/cmd/dde/commands.go +++ b/cmd/dde/commands.go @@ -4,11 +4,14 @@ import ( "context" "encoding/json" "fmt" + "log/slog" + "net/http" "os" "os/signal" "syscall" "time" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/cobra" "github.com/vingrad/dynamic-decision-engine/internal/api" @@ -20,9 +23,11 @@ import ( "github.com/vingrad/dynamic-decision-engine/internal/llm" "github.com/vingrad/dynamic-decision-engine/internal/logging" "github.com/vingrad/dynamic-decision-engine/internal/marketdata" + mcpserver "github.com/vingrad/dynamic-decision-engine/internal/mcp" "github.com/vingrad/dynamic-decision-engine/internal/pack" "github.com/vingrad/dynamic-decision-engine/internal/policy" "github.com/vingrad/dynamic-decision-engine/internal/storage" + "github.com/vingrad/dynamic-decision-engine/internal/webhook" "github.com/vingrad/dynamic-decision-engine/internal/wire" ) @@ -204,6 +209,87 @@ func newEngine(cfg config.Config, reg *pack.Registry, pol policy.Policy, cacheOb return engine.New(router, opts...), nil } +// buildService opens storage and assembles the full production service: domain +// registry, policy, engine, metrics, optional webhook notifier and optional +// async replan queue (including crash recovery). It is shared by the long-running +// transports (serve, mcp). The returned cleanup drains the service (in-flight +// replans first, then queued webhook deliveries — replans emit events) and +// closes the repository; call it on shutdown. +func buildService(ctx context.Context, cfg config.Config, log *slog.Logger, metrics *api.Metrics) (*app.Service, func(), error) { + repo, err := storage.Open(ctx, storage.Options{ + DatabaseURL: cfg.DatabaseURL, + MaxConns: cfg.DBMaxConns, + }, log) + if err != nil { + return nil, nil, err + } + + reg, err := buildRegistry(cfg) + if err != nil { + repo.Close() + return nil, nil, err + } + pol, err := policy.Load(cfg.PolicyFile) + if err != nil { + repo.Close() + return nil, nil, err + } + eng, err := newEngine(cfg, reg, pol, metrics) + if err != nil { + repo.Close() + return nil, nil, err + } + + opts := []app.Option{app.WithMetrics(metrics), app.WithLogger(log), app.WithRegistry(reg)} + var hooks *webhook.Dispatcher + if cfg.WebhookURL != "" { + hooks = webhook.New(webhook.Config{ + URL: cfg.WebhookURL, + Secret: cfg.WebhookSecret, + Timeout: cfg.WebhookTimeout, + Retries: cfg.WebhookRetries, + Events: cfg.WebhookEvents, + }, log, metrics) + opts = append(opts, app.WithNotifier(hooks)) + } + if cfg.ReplanAsync { + opts = append(opts, + app.WithReplanQueue(app.NewMemoryQueue(cfg.ReplanWorkers, 1024, log, app.WithQueueTimeout(cfg.ReplanTimeout))), + app.WithReplanRetries(cfg.ReplanMaxRetries), + ) + } + svc := app.New(repo, eng, opts...) + // Re-enqueue replans left pending by a previous crash (async only — the + // in-memory queue loses scheduled work on restart; inline never does). + if cfg.ReplanAsync { + go func() { + n, err := svc.RecoverPending(ctx) + if err != nil { + log.Error("replan recovery failed", "err", err) + return + } + if n > 0 { + log.Info("recovered pending replans", "count", n) + } + }() + } + + cleanup := func() { + // Drain in-flight async replanning first: completing replans emit events, + // which the webhook drain below then delivers. + sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + _ = svc.Shutdown(sctx) + cancel() + if hooks != nil { + wctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + _ = hooks.Shutdown(wctx) + cancel() + } + repo.Close() + } + return svc, cleanup, nil +} + // newServeCommand runs the REST API server. func newServeCommand() *cobra.Command { return &cobra.Command{ @@ -220,59 +306,18 @@ func newServeCommand() *cobra.Command { ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) defer stop() - repo, err := storage.Open(ctx, storage.Options{ - DatabaseURL: cfg.DatabaseURL, - MaxConns: cfg.DBMaxConns, - }, log) - if err != nil { - return err - } - defer repo.Close() - - reg, err := buildRegistry(cfg) - if err != nil { - return err - } - pol, err := policy.Load(cfg.PolicyFile) - if err != nil { - return err - } metrics := api.NewMetrics() - eng, err := newEngine(cfg, reg, pol, metrics) + svc, cleanup, err := buildService(ctx, cfg, log, metrics) if err != nil { return err } + defer cleanup() - opts := []app.Option{app.WithMetrics(metrics), app.WithLogger(log), app.WithRegistry(reg)} - if cfg.ReplanAsync { - opts = append(opts, - app.WithReplanQueue(app.NewMemoryQueue(cfg.ReplanWorkers, 1024, log, app.WithQueueTimeout(cfg.ReplanTimeout))), - app.WithReplanRetries(cfg.ReplanMaxRetries), - ) - } - svc := app.New(repo, eng, opts...) - // Re-enqueue replans left pending by a previous crash (async only — the - // in-memory queue loses scheduled work on restart; inline never does). - if cfg.ReplanAsync { - go func() { - n, err := svc.RecoverPending(ctx) - if err != nil { - log.Error("replan recovery failed", "err", err) - return - } - if n > 0 { - log.Info("recovered pending replans", "count", n) - } - }() - } - // Drain in-flight async replanning on shutdown. - defer func() { - sctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - _ = svc.Shutdown(sctx) - }() - - srv := api.New(cfg, log, svc, metrics) + // The MCP endpoint shares the live service: REST and MCP clients see + // the same goals, plans and versions. + mcpSrv := mcpserver.New(svc, version) + srv := api.New(cfg, log, svc, metrics, + api.WithMCP(mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return mcpSrv }, nil))) return srv.Run(ctx) }, } diff --git a/cmd/dde/main.go b/cmd/dde/main.go index 7c143b2..a443eb8 100644 --- a/cmd/dde/main.go +++ b/cmd/dde/main.go @@ -26,6 +26,7 @@ func main() { root.AddCommand( newServeCommand(), + newMCPCommand(), newMigrateCommand(), newEvaluateCommand(), newSignalCommand(), diff --git a/cmd/dde/mcp.go b/cmd/dde/mcp.go new file mode 100644 index 0000000..6ead865 --- /dev/null +++ b/cmd/dde/mcp.go @@ -0,0 +1,53 @@ +package main + +import ( + "os/signal" + "syscall" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/spf13/cobra" + + "github.com/vingrad/dynamic-decision-engine/internal/api" + "github.com/vingrad/dynamic-decision-engine/internal/config" + "github.com/vingrad/dynamic-decision-engine/internal/logging" + mcpserver "github.com/vingrad/dynamic-decision-engine/internal/mcp" +) + +// newMCPCommand serves the engine as an MCP server over stdio, so MCP clients +// (Claude Code/Desktop, agent runtimes) can drive the decision loop as tools. +// It assembles the same production service as serve — same DDE_* config, store +// (in-memory by default, Postgres via DATABASE_URL), planner and webhooks. +// +// Stdout carries JSON-RPC only: logging.New writes to stderr, and nothing in +// this path may print to stdout. +func newMCPCommand() *cobra.Command { + return &cobra.Command{ + Use: "mcp", + Short: "Serve the engine as an MCP server over stdio", + Long: "mcp exposes the engine's use-cases (evaluate, create_goal, generate_plan, " + + "submit_signal, record_outcome, ...) as Model Context Protocol tools over stdio. " + + "Configuration matches serve: DDE_* environment variables select the store, " + + "planner and webhooks.", + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + log := logging.New(cfg.LogLevel, cfg.LogFormat) + + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + // Metrics are recorded but not exported in stdio mode (no HTTP listener). + metrics := api.NewMetrics() + svc, cleanup, err := buildService(ctx, cfg, log, metrics) + if err != nil { + return err + } + defer cleanup() + + srv := mcpserver.New(svc, version) + return srv.Run(ctx, &mcp.StdioTransport{}) + }, + } +} diff --git a/docs/api.md b/docs/api.md index 0669a7d..e088cf9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -16,6 +16,7 @@ List endpoints accept `?limit=` and `?offset=` (defaults: limit 50, max 200). | GET | `/health`, `/health/live` | Liveness. | | GET | `/health/ready` | Readiness (pings storage). | | GET | `/metrics` | Prometheus exposition. | +| * | `/mcp` | MCP server (streamable HTTP) sharing the live service — see [`mcp.md`](mcp.md). | ## Goals @@ -167,3 +168,80 @@ the same primitive the CLI `evaluate` command uses. ``` Returns `200` with a plan version. + +## Webhooks + +The engine can POST domain events to a single HTTP endpoint as they happen, so +external systems (Slack bridges, n8n/Zapier flows, agent triggers) react to +decisions without polling. Webhooks are off by default and enabled by setting a +URL. + +| Variable | Default | Description | +| --- | --- | --- | +| `DDE_WEBHOOK_URL` | _(empty = disabled)_ | Endpoint events are POSTed to. | +| `DDE_WEBHOOK_SECRET` | _(empty)_ | When set, each body is signed with HMAC-SHA256 in `X-DDE-Signature`. | +| `DDE_WEBHOOK_TIMEOUT` | `5s` | Per-attempt delivery timeout. | +| `DDE_WEBHOOK_RETRIES` | `3` | Extra attempts after a failed delivery. | +| `DDE_WEBHOOK_EVENTS` | _(empty = all)_ | Comma-separated event-type filter. | + +### Event types + +| Type | Fired when | +| --- | --- | +| `goal.created` | A goal is persisted. | +| `plan.created` | The initial plan (version 1) is generated for a goal. | +| `signal.received` | A signal is stored; `status` is `applied`/`unchanged` (inline replanning) or `pending` (async). | +| `replan.completed` | Replanning finished: `applied` (new version), `unchanged` (immaterial) or `failed`. | +| `outcome.recorded` | An outcome is recorded for a move. | +| `goal.status_changed` | A goal's lifecycle status transitioned; payload carries `previous_status`. | + +With the default inline (synchronous) replan queue, one signal produces both a +`signal.received` and a `replan.completed` event; filter with +`DDE_WEBHOOK_EVENTS` if you only want one. + +### Delivery format + +Each delivery is a JSON envelope: + +```json +{ + "id": "evt_u7ctmqbm3f6fhdux", + "type": "replan.completed", + "created_at": "2026-06-10T19:38:30Z", + "payload": { + "goal_id": "goal_ikjtghtpm265ij6n", + "plan_id": "plan_x2x3hwc2ujadkkqi", + "signal_id": "sig_4nrqkm66g3qhd3xr", + "status": "applied", + "reason": "confidence shifted materially", + "version": { "plan_id": "plan_x2x3hwc2ujadkkqi", "version": 2, "...": "..." } + } +} +``` + +Headers: `Content-Type: application/json`, `X-DDE-Event` (the type), +`X-DDE-Delivery` (the event id), and — when a secret is configured — +`X-DDE-Signature: sha256=`, the HMAC-SHA256 of the raw request body keyed +with the secret. Verify it by recomputing: + +```python +import hashlib, hmac +expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() +ok = hmac.compare_digest(expected, request.headers["X-DDE-Signature"]) +``` + +### Delivery semantics + +Delivery is **best-effort, at-most-once**: events are queued in memory and +POSTed by background workers. Transport errors, `429` and `5xx` responses are +retried with exponential backoff and jitter; other `4xx` responses are treated +as receiver misconfiguration and not retried. If the queue overflows (receiver +down for long) or the process crashes, events are dropped — the store remains +the source of truth, so receivers needing a complete picture should reconcile +via the REST API. On graceful shutdown queued events are drained before exit. +Deliveries are observable via the `dde_webhook_deliveries_total{event,result}` +metric (`success|failure|dropped`) and warn-level logs. + +The webhook notifier runs in the long-lived transports (`dde serve`, +`dde mcp`); one-shot CLI commands (`dde evaluate`, `dde signal`) do not emit +events. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..990bff3 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,86 @@ +# MCP server + +The engine exposes its use-cases as [Model Context Protocol](https://modelcontextprotocol.io) +tools, so MCP-capable agents (Claude Code, Claude Desktop, custom agent +runtimes) can drive the full decision loop — create goals, generate ranked +plans, report signals, record outcomes and inspect the immutable version +history — with zero integration code. The MCP layer is a thin transport adapter +over the same application service the REST API uses: identical semantics, +validation and error mapping. + +## Transports + +### stdio: `dde mcp` + +The zero-config path for local agents. The command assembles the same +production service as `dde serve` — same `DDE_*` environment configuration, +in-memory store by default, Postgres via `DATABASE_URL`, deterministic mock +planner by default (BYOK for a real one). Logs go to stderr; stdout carries +JSON-RPC only. + +Example client configuration (Claude Code/Desktop `mcpServers` entry): + +```json +{ + "mcpServers": { + "dde": { + "command": "dde", + "args": ["mcp"], + "env": { "DDE_PLANNER": "mock" } + } + } +} +``` + +With no `DATABASE_URL` each session gets a fresh in-memory store; point it at +Postgres to share durable state with a running `dde serve`. + +### Streamable HTTP: `/mcp` on the API server + +`dde serve` mounts an MCP streamable-HTTP endpoint at `http://localhost:8080/mcp`, +sharing the live service — goals created over REST are visible to MCP tools and +vice versa. Like the rest of the API, `/mcp` is currently **unauthenticated**; +do not expose it beyond a trusted network until authentication lands. + +Try either transport with the MCP inspector: + +```bash +npx @modelcontextprotocol/inspector ./dde mcp # stdio +npx @modelcontextprotocol/inspector http://localhost:8080/mcp # HTTP (serve running) +``` + +## Tools + +| Tool | Arguments | Returns | REST equivalent | +| --- | --- | --- | --- | +| `evaluate` | `objective` (req), `domain`, `metric`, `target`, `context`, `signal_note` | plan version (not persisted) | `POST /v1/evaluate` | +| `create_goal` | `objective` (req), `player_id`, `domain`, `metric`, `target`, `context` | goal | `POST /v1/goals` | +| `get_goal` | `goal_id` (req) | goal | `GET /v1/goals/{id}` | +| `list_goals` | `status`, `limit`, `offset` | `{goals: [...]}` | `GET /v1/goals` | +| `update_goal_status` | `goal_id`, `status` (req), `resolution_result`, `resolution_notes` | goal | `PATCH /v1/goals/{id}/status` | +| `generate_plan` | `goal_id` (req) | plan version 1 | `POST /v1/goals/{id}/plans` | +| `get_plan` | exactly one of `plan_id` \| `goal_id` | `{plan, current_version}` | `GET /v1/plans/{id}` | +| `list_plan_versions` | `plan_id` (req), `limit`, `offset` | `{versions: [...]}` | `GET /v1/plans/{id}/versions` | +| `submit_signal` | `goal_id`, `kind` (req), `description`, `payload` | `{signal, status, material, reason, plan_version}` | `POST /v1/signals` | +| `record_outcome` | `goal_id`, `plan_version`, `move_rank`, `result` (req), `observed_signals`, `notes` | outcome | `POST /v1/outcomes` | + +The typical agent loop: `create_goal` → `generate_plan` → act → `submit_signal` +when something changes (a material signal appends a new immutable version) → +`record_outcome` for executed moves → `update_goal_status` when the goal +concludes. + +## Errors + +Service errors surface as MCP tool errors (`isError: true`) with messages +mirroring the REST status mapping: `invalid input: …` (400), `not found` (404) +and `conflict: …` (409, e.g. a second `generate_plan` for the same goal, or a +signal for a goal with no plan). Internal failures return `internal error` +without detail. + +## Notes + +- Webhooks (see [`api.md`](api.md#webhooks)) work in both transports: `dde mcp` + is a long-running process and emits events like `serve`. +- In stdio mode metrics are recorded but not exported (no HTTP listener). +- Provenance survives the transport: plans generated through MCP carry the same + planner/model/snapshot provenance as REST-generated ones. diff --git a/go.mod b/go.mod index 0d3e7d9..7dbe578 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/anthropics/anthropic-sdk-go v1.48.0 github.com/go-chi/chi/v5 v5.3.0 github.com/jackc/pgx/v5 v5.10.0 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/openai/openai-go/v3 v3.39.0 github.com/prometheus/client_golang v1.23.2 github.com/spf13/cobra v1.10.2 @@ -17,6 +18,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/invopop/jsonschema v0.14.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -28,16 +30,20 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/prometheus/procfs v0.16.1 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/standard-webhooks/standard-webhooks/libraries v0.0.1 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.29.0 // indirect google.golang.org/protobuf v1.36.8 // indirect ) diff --git a/go.sum b/go.sum index 180834b..7aa7fac 100644 --- a/go.sum +++ b/go.sum @@ -17,8 +17,12 @@ github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= @@ -39,6 +43,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/openai/openai-go/v3 v3.39.0 h1:WgLGgMOOdQDkZyo8YIhzUNXRXlEc+OJfU4EKP5Qp6AA= @@ -58,6 +64,10 @@ github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlT github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -79,6 +89,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= @@ -86,12 +98,16 @@ go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 7a57dcd..f540a0b 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/vingrad/dynamic-decision-engine/internal/app" "github.com/vingrad/dynamic-decision-engine/internal/config" @@ -402,3 +403,58 @@ func TestAsyncSignalReturns202AndStatus(t *testing.T) { t.Errorf("expected applied v2, got status=%q v=%d", sig.Status, sig.ResultVersion) } } + +// TestMCPMountAndTimeoutScope verifies that a handler passed via WithMCP is +// served at /mcp outside the per-request timeout middleware, while /v1 routes +// remain bounded by it. +func TestMCPMountAndTimeoutScope(t *testing.T) { + cfg := config.Default() + cfg.RequestTimeout = 50 * time.Millisecond + log := logging.New("error", "text") + metrics := NewMetrics() + svc := app.New(storage.NewMemory(), engine.New(llm.NewMockPlanner()), app.WithMetrics(metrics)) + + slow := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + return // timed out: middleware already wrote 504 + case <-time.After(150 * time.Millisecond): + w.WriteHeader(http.StatusOK) + } + }) + h := New(cfg, log, svc, metrics, WithMCP(slow)).Handler() + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil)) + if rec.Code != http.StatusOK { + t.Errorf("expected /mcp to outlive the request timeout, got %d", rec.Code) + } + + // A /v1 route slower than the timeout is cut off by the middleware. The + // health endpoint is fast, so instead assert the middleware is present by + // checking a context deadline exists inside the group. + var hasDeadline bool + probe := New(cfg, log, svc, metrics, WithMCP(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, hasDeadline = r.Context().Deadline() + }))).Handler() + rec = httptest.NewRecorder() + probe.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil)) + if hasDeadline { + t.Error("/mcp request should not carry the per-request timeout deadline") + } + + rec = doJSON(t, h, http.MethodGet, "/v1/goals", nil) + if dl := rec.Result().Header; rec.Code != http.StatusOK { + t.Errorf("expected /v1/goals to succeed, got %d (%v)", rec.Code, dl) + } +} + +// TestNoMCPMountByDefault verifies /mcp is absent without WithMCP. +func TestNoMCPMountByDefault(t *testing.T) { + h := newTestServer() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/mcp", nil)) + if rec.Code != http.StatusNotFound { + t.Errorf("expected 404 for unmounted /mcp, got %d", rec.Code) + } +} diff --git a/internal/api/metrics.go b/internal/api/metrics.go index ac76c73..63eba7d 100644 --- a/internal/api/metrics.go +++ b/internal/api/metrics.go @@ -23,6 +23,7 @@ type Metrics struct { replans *prometheus.CounterVec replanQueue *prometheus.CounterVec planCache *prometheus.CounterVec + webhooks *prometheus.CounterVec } // domainLabel normalises an empty domain to "generic" so the metric label is @@ -64,9 +65,13 @@ func NewMetrics() *Metrics { Name: "dde_plan_cache_total", Help: "Plan-cache lookups by domain and result (hit|miss).", }, []string{"domain", "result"}), + webhooks: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "dde_webhook_deliveries_total", + Help: "Webhook deliveries by event type and result (success|failure|dropped).", + }, []string{"event", "result"}), } reg.MustRegister( - m.requests, m.duration, m.planVersions, m.replans, m.replanQueue, m.planCache, + m.requests, m.duration, m.planVersions, m.replans, m.replanQueue, m.planCache, m.webhooks, collectors.NewGoCollector(), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), ) @@ -126,3 +131,8 @@ func (m *Metrics) PlanCacheHit(domain string) { func (m *Metrics) PlanCacheMiss(domain string) { m.planCache.WithLabelValues(domainLabel(domain), "miss").Inc() } + +// WebhookDelivery records a webhook delivery result for an event type. +func (m *Metrics) WebhookDelivery(event, result string) { + m.webhooks.WithLabelValues(event, result).Inc() +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 4f22bd4..91feeab 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -22,35 +22,44 @@ func (s *Server) Handler() http.Handler { r.Use(middleware.RequestID) r.Use(s.requestLogger) r.Use(middleware.Recoverer) - r.Use(middleware.Timeout(s.cfg.RequestTimeout)) r.Use(s.corsMiddleware) r.Use(s.metrics.middleware) - // Operational endpoints. - r.Get("/health", s.handleLive) - r.Get("/health/live", s.handleLive) - r.Get("/health/ready", s.handleReady) - r.Handle("/metrics", s.metrics.Handler()) - - // Versioned API. - r.Route("/v1", func(r chi.Router) { - r.Route("/goals", func(r chi.Router) { - r.Post("/", s.handleCreateGoal) - r.Get("/", s.handleListGoals) - r.Get("/{id}", s.handleGetGoal) - r.Patch("/{id}/status", s.handleUpdateGoalStatus) - r.Post("/{id}/plans", s.handleCreatePlan) - r.Get("/{id}/plans", s.handleGetGoalPlan) - }) - r.Route("/plans", func(r chi.Router) { - r.Get("/{id}", s.handleGetPlan) - r.Get("/{id}/versions", s.handleListPlanVersions) + // The per-request timeout wraps the operational and REST routes only: MCP + // sessions at /mcp are long-lived streams and must not be cut short. + r.Group(func(r chi.Router) { + r.Use(middleware.Timeout(s.cfg.RequestTimeout)) + + // Operational endpoints. + r.Get("/health", s.handleLive) + r.Get("/health/live", s.handleLive) + r.Get("/health/ready", s.handleReady) + r.Handle("/metrics", s.metrics.Handler()) + + // Versioned API. + r.Route("/v1", func(r chi.Router) { + r.Route("/goals", func(r chi.Router) { + r.Post("/", s.handleCreateGoal) + r.Get("/", s.handleListGoals) + r.Get("/{id}", s.handleGetGoal) + r.Patch("/{id}/status", s.handleUpdateGoalStatus) + r.Post("/{id}/plans", s.handleCreatePlan) + r.Get("/{id}/plans", s.handleGetGoalPlan) + }) + r.Route("/plans", func(r chi.Router) { + r.Get("/{id}", s.handleGetPlan) + r.Get("/{id}/versions", s.handleListPlanVersions) + }) + r.Post("/signals", s.handleCreateSignal) + r.Get("/signals/{id}", s.handleGetSignal) + r.Post("/outcomes", s.handleCreateOutcome) + r.Post("/evaluate", s.handleEvaluate) }) - r.Post("/signals", s.handleCreateSignal) - r.Get("/signals/{id}", s.handleGetSignal) - r.Post("/outcomes", s.handleCreateOutcome) - r.Post("/evaluate", s.handleEvaluate) }) + if s.mcp != nil { + r.Mount("/mcp", s.mcp) + } + return r } diff --git a/internal/api/server.go b/internal/api/server.go index c0822f9..3cef76f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -22,18 +22,33 @@ type Server struct { log *slog.Logger svc *app.Service metrics *Metrics + mcp http.Handler +} + +// ServerOption customises a Server. +type ServerOption func(*Server) + +// WithMCP mounts h at /mcp. The handler is opaque to this package (an MCP +// streamable-HTTP handler in practice), keeping the api package free of any +// MCP SDK dependency. +func WithMCP(h http.Handler) ServerOption { + return func(s *Server) { s.mcp = h } } // New constructs a Server around the application service. The same *Metrics // passed here should also be wired into the service so counters and the /metrics // endpoint share one registry. -func New(cfg config.Config, log *slog.Logger, svc *app.Service, metrics *Metrics) *Server { - return &Server{ +func New(cfg config.Config, log *slog.Logger, svc *app.Service, metrics *Metrics, opts ...ServerOption) *Server { + s := &Server{ cfg: cfg, log: log, svc: svc, metrics: metrics, } + for _, opt := range opts { + opt(s) + } + return s } // Run starts the HTTP server and blocks until ctx is cancelled, then performs a @@ -44,8 +59,11 @@ func (s *Server) Run(ctx context.Context) error { Handler: s.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: s.cfg.RequestTimeout + 5*time.Second, - WriteTimeout: s.cfg.RequestTimeout + 5*time.Second, - IdleTimeout: 60 * time.Second, + // No connection-level write deadline: /mcp streams long-lived SSE + // responses. The REST routes are still bounded per request by the + // timeout middleware (see Handler). + WriteTimeout: 0, + IdleTimeout: 60 * time.Second, } errCh := make(chan error, 1) diff --git a/internal/app/notify.go b/internal/app/notify.go new file mode 100644 index 0000000..278bb8f --- /dev/null +++ b/internal/app/notify.go @@ -0,0 +1,103 @@ +package app + +import ( + "context" + "time" + + "github.com/vingrad/dynamic-decision-engine/internal/domain" +) + +// Event type names. Keep in sync with webhookEventNames in internal/config. +const ( + EventGoalCreated = "goal.created" + EventPlanCreated = "plan.created" + EventSignalReceived = "signal.received" + EventReplanCompleted = "replan.completed" + EventOutcomeRecorded = "outcome.recorded" + EventGoalStatusChanged = "goal.status_changed" +) + +// Event is the envelope handed to a Notifier after a use-case commits. Its JSON +// encoding is the wire format webhook receivers see. +type Event struct { + ID string `json:"id"` + Type string `json:"type"` + CreatedAt time.Time `json:"created_at"` + Payload any `json:"payload"` +} + +// Notifier receives domain events after they are durably committed. Emit must +// not block and must never fail the calling use-case — event fan-out is +// best-effort, the store is the source of truth. Like Metrics, the interface +// lives here so the service stays free of transport dependencies. +type Notifier interface { + Emit(ctx context.Context, evt Event) +} + +// nopNotifier is the default no-op Notifier used when none is supplied. +type nopNotifier struct{} + +func (nopNotifier) Emit(context.Context, Event) {} + +// WithNotifier sets the event sink. +func WithNotifier(n Notifier) Option { + return func(s *Service) { + if n != nil { + s.notifier = n + } + } +} + +// emit wraps a payload in an Event envelope and forwards it to the notifier. +func (s *Service) emit(ctx context.Context, typ string, payload any) { + s.notifier.Emit(ctx, Event{ + ID: domain.NewID("evt"), + Type: typ, + CreatedAt: s.clock(), + Payload: payload, + }) +} + +// GoalCreatedPayload is the payload of a goal.created event. +type GoalCreatedPayload struct { + Goal domain.Goal `json:"goal"` +} + +// PlanCreatedPayload is the payload of a plan.created event (the initial plan). +type PlanCreatedPayload struct { + GoalID string `json:"goal_id"` + Version domain.PlanVersion `json:"version"` +} + +// SignalReceivedPayload is the payload of a signal.received event. Status is +// pending when replanning was scheduled asynchronously; otherwise it carries +// the inline replanning result (applied or unchanged). +type SignalReceivedPayload struct { + Signal domain.Signal `json:"signal"` + Status SignalStatus `json:"status"` +} + +// ReplanCompletedPayload is the payload of a replan.completed event: the +// terminal result of re-evaluating a plan against a signal. +type ReplanCompletedPayload struct { + GoalID string `json:"goal_id"` + PlanID string `json:"plan_id"` + SignalID string `json:"signal_id"` + Status SignalStatus `json:"status"` // applied | unchanged | failed + Reason string `json:"reason,omitempty"` + Error string `json:"error,omitempty"` + // Version is the resulting plan version: the new version when applied, the + // surviving current version when unchanged, nil when failed. + Version *domain.PlanVersion `json:"version,omitempty"` +} + +// OutcomeRecordedPayload is the payload of an outcome.recorded event. +type OutcomeRecordedPayload struct { + Outcome domain.Outcome `json:"outcome"` +} + +// GoalStatusChangedPayload is the payload of a goal.status_changed event. +type GoalStatusChangedPayload struct { + Goal domain.Goal `json:"goal"` + PreviousStatus domain.GoalStatus `json:"previous_status"` +} diff --git a/internal/app/notify_test.go b/internal/app/notify_test.go new file mode 100644 index 0000000..1f223c5 --- /dev/null +++ b/internal/app/notify_test.go @@ -0,0 +1,272 @@ +package app + +import ( + "context" + "sync" + "testing" + + "github.com/vingrad/dynamic-decision-engine/internal/domain" + "github.com/vingrad/dynamic-decision-engine/internal/engine" + "github.com/vingrad/dynamic-decision-engine/internal/llm" + "github.com/vingrad/dynamic-decision-engine/internal/storage" +) + +// fakeNotifier records emitted events for assertions. +type fakeNotifier struct { + mu sync.Mutex + events []Event +} + +func (f *fakeNotifier) Emit(_ context.Context, evt Event) { + f.mu.Lock() + defer f.mu.Unlock() + f.events = append(f.events, evt) +} + +func (f *fakeNotifier) byType(typ string) []Event { + f.mu.Lock() + defer f.mu.Unlock() + var out []Event + for _, e := range f.events { + if e.Type == typ { + out = append(out, e) + } + } + return out +} + +func newNotifyingService(n Notifier, opts ...Option) *Service { + opts = append([]Option{WithNotifier(n)}, opts...) + return New(storage.NewMemory(), engine.New(llm.NewMockPlanner()), opts...) +} + +func TestNotifyGoalCreated(t *testing.T) { + n := &fakeNotifier{} + s := newNotifyingService(n) + g := makeGoal(t, s) + + evts := n.byType(EventGoalCreated) + if len(evts) != 1 { + t.Fatalf("expected 1 goal.created event, got %d", len(evts)) + } + evt := evts[0] + if evt.ID == "" || evt.CreatedAt.IsZero() { + t.Errorf("envelope incomplete: id=%q created_at=%v", evt.ID, evt.CreatedAt) + } + p, ok := evt.Payload.(GoalCreatedPayload) + if !ok { + t.Fatalf("unexpected payload type %T", evt.Payload) + } + if p.Goal.ID != g.ID { + t.Errorf("payload goal %q != created goal %q", p.Goal.ID, g.ID) + } +} + +func TestNotifyNoEventOnValidationFailure(t *testing.T) { + n := &fakeNotifier{} + s := newNotifyingService(n) + if _, err := s.CreateGoal(context.Background(), CreateGoalInput{}); err == nil { + t.Fatal("expected validation error") + } + if len(n.events) != 0 { + t.Fatalf("expected no events on validation failure, got %d", len(n.events)) + } +} + +func TestNotifyPlanCreated(t *testing.T) { + n := &fakeNotifier{} + s := newNotifyingService(n) + ctx := context.Background() + g := makeGoal(t, s) + v1, err := s.GeneratePlan(ctx, g.ID) + if err != nil { + t.Fatal(err) + } + + evts := n.byType(EventPlanCreated) + if len(evts) != 1 { + t.Fatalf("expected 1 plan.created event, got %d", len(evts)) + } + p := evts[0].Payload.(PlanCreatedPayload) + if p.GoalID != g.ID || p.Version.Version != v1.Version { + t.Errorf("unexpected payload: goal=%q version=%d", p.GoalID, p.Version.Version) + } + + // A conflicting second generation emits nothing further. + if _, err := s.GeneratePlan(ctx, g.ID); err == nil { + t.Fatal("expected ErrPlanExists") + } + if got := len(n.byType(EventPlanCreated)); got != 1 { + t.Errorf("conflict should not emit plan.created, got %d events", got) + } +} + +func TestNotifySignalInline(t *testing.T) { + n := &fakeNotifier{} + s := newNotifyingService(n) + ctx := context.Background() + g := makeGoal(t, s) + if _, err := s.GeneratePlan(ctx, g.ID); err != nil { + t.Fatal(err) + } + + // Material signal: applied. + r, err := s.ApplySignal(ctx, SignalInput{GoalID: g.ID, Kind: "competitive_shift", Description: "free tier launched"}) + if err != nil { + t.Fatal(err) + } + recv := n.byType(EventSignalReceived) + if len(recv) != 1 { + t.Fatalf("expected 1 signal.received event, got %d", len(recv)) + } + sp := recv[0].Payload.(SignalReceivedPayload) + if sp.Signal.ID != r.Signal.ID || sp.Status != StatusApplied { + t.Errorf("unexpected signal.received payload: id=%q status=%q", sp.Signal.ID, sp.Status) + } + done := n.byType(EventReplanCompleted) + if len(done) != 1 { + t.Fatalf("expected 1 replan.completed event, got %d", len(done)) + } + rp := done[0].Payload.(ReplanCompletedPayload) + if rp.SignalID != r.Signal.ID || rp.Status != StatusApplied || rp.GoalID != g.ID { + t.Errorf("unexpected replan.completed payload: %+v", rp) + } + if rp.Version == nil || rp.Version.Version != r.PlanVersion.Version { + t.Errorf("replan.completed should carry the new version") + } + + // Identical signal again: immaterial -> unchanged on both events. + if _, err := s.ApplySignal(ctx, SignalInput{GoalID: g.ID, Kind: "competitive_shift", Description: "free tier launched"}); err != nil { + t.Fatal(err) + } + recv = n.byType(EventSignalReceived) + if got := recv[len(recv)-1].Payload.(SignalReceivedPayload).Status; got != StatusUnchanged { + t.Errorf("expected unchanged signal.received, got %q", got) + } + done = n.byType(EventReplanCompleted) + if got := done[len(done)-1].Payload.(ReplanCompletedPayload); got.Status != StatusUnchanged || got.Version == nil { + t.Errorf("expected unchanged replan.completed with surviving version, got %+v", got) + } +} + +func TestNotifySignalAsync(t *testing.T) { + n := &fakeNotifier{} + q := NewMemoryQueue(1, 16, discardLogger()) + s := newNotifyingService(n, WithReplanQueue(q)) + ctx := context.Background() + g := makeGoal(t, s) + if _, err := s.GeneratePlan(ctx, g.ID); err != nil { + t.Fatal(err) + } + + r, err := s.ApplySignal(ctx, SignalInput{GoalID: g.ID, Kind: "competitive_shift", Description: "free tier launched"}) + if err != nil { + t.Fatal(err) + } + if r.Status != StatusPending { + t.Fatalf("expected pending result, got %q", r.Status) + } + recv := n.byType(EventSignalReceived) + if len(recv) != 1 || recv[0].Payload.(SignalReceivedPayload).Status != StatusPending { + t.Fatalf("expected pending signal.received, got %v", recv) + } + + if err := s.Shutdown(ctx); err != nil { + t.Fatal(err) + } + done := n.byType(EventReplanCompleted) + if len(done) != 1 { + t.Fatalf("expected 1 replan.completed after drain, got %d", len(done)) + } + rp := done[0].Payload.(ReplanCompletedPayload) + if rp.Status != StatusApplied || rp.SignalID != r.Signal.ID { + t.Errorf("unexpected async replan.completed: %+v", rp) + } +} + +func TestNotifyReplanFailed(t *testing.T) { + n := &fakeNotifier{} + repo := storage.NewMemory() + ctx := context.Background() + + // Goal and initial plan via a working service; then a failing planner handles + // the replan so the failed path emits. + good := New(repo, engine.New(llm.NewMockPlanner())) + g, err := good.CreateGoal(ctx, CreateGoalInput{Objective: "x"}) + if err != nil { + t.Fatal(err) + } + if _, err := good.GeneratePlan(ctx, g.ID); err != nil { + t.Fatal(err) + } + + q := NewMemoryQueue(1, 8, discardLogger()) + bad := New(repo, engine.New(failingPlanner{}), WithReplanQueue(q), WithNotifier(n)) + if _, err := bad.ApplySignal(ctx, SignalInput{GoalID: g.ID, Kind: "x"}); err != nil { + t.Fatal(err) + } + if err := bad.Shutdown(ctx); err != nil { + t.Fatal(err) + } + + done := n.byType(EventReplanCompleted) + if len(done) != 1 { + t.Fatalf("expected 1 replan.completed, got %d", len(done)) + } + rp := done[0].Payload.(ReplanCompletedPayload) + if rp.Status != StatusFailed || rp.Error == "" || rp.Version != nil { + t.Errorf("expected failed payload with error and no version, got %+v", rp) + } +} + +func TestNotifyGoalStatusChanged(t *testing.T) { + n := &fakeNotifier{} + s := newNotifyingService(n) + ctx := context.Background() + g := makeGoal(t, s) + + if _, err := s.UpdateGoalStatus(ctx, UpdateGoalStatusInput{ + GoalID: g.ID, + Status: domain.GoalResolved, + ResolutionResult: domain.OutcomeSuccess, + }); err != nil { + t.Fatal(err) + } + + evts := n.byType(EventGoalStatusChanged) + if len(evts) != 1 { + t.Fatalf("expected 1 goal.status_changed event, got %d", len(evts)) + } + p := evts[0].Payload.(GoalStatusChangedPayload) + if p.Goal.Status != domain.GoalResolved || p.PreviousStatus != domain.GoalActive { + t.Errorf("unexpected payload: status=%q previous=%q", p.Goal.Status, p.PreviousStatus) + } +} + +func TestNotifyOutcomeRecorded(t *testing.T) { + n := &fakeNotifier{} + s := newNotifyingService(n) + ctx := context.Background() + g := makeGoal(t, s) + v1, err := s.GeneratePlan(ctx, g.ID) + if err != nil { + t.Fatal(err) + } + + out, err := s.RecordOutcome(ctx, OutcomeInput{ + GoalID: g.ID, + PlanVersion: v1.Version, + MoveRank: 1, + Result: domain.OutcomeSuccess, + }) + if err != nil { + t.Fatal(err) + } + evts := n.byType(EventOutcomeRecorded) + if len(evts) != 1 { + t.Fatalf("expected 1 outcome.recorded event, got %d", len(evts)) + } + if p := evts[0].Payload.(OutcomeRecordedPayload); p.Outcome.ID != out.ID { + t.Errorf("payload outcome %q != recorded %q", p.Outcome.ID, out.ID) + } +} diff --git a/internal/app/service.go b/internal/app/service.go index d36fa44..58bf020 100644 --- a/internal/app/service.go +++ b/internal/app/service.go @@ -26,6 +26,7 @@ type Service struct { repo storage.Repository eng *engine.Engine metrics Metrics + notifier Notifier clock func() time.Time log *slog.Logger queue ReplanQueue @@ -70,11 +71,12 @@ func WithReplanRetries(n int) Option { // service's own processReplan, defaulting to a synchronous inline queue. func New(repo storage.Repository, eng *engine.Engine, opts ...Option) *Service { s := &Service{ - repo: repo, - eng: eng, - metrics: nopMetrics{}, - clock: func() time.Time { return time.Now().UTC() }, - log: slog.Default(), + repo: repo, + eng: eng, + metrics: nopMetrics{}, + notifier: nopNotifier{}, + clock: func() time.Time { return time.Now().UTC() }, + log: slog.Default(), } for _, opt := range opts { opt(s) @@ -169,6 +171,7 @@ func (s *Service) CreateGoal(ctx context.Context, in CreateGoalInput) (domain.Go if err := s.repo.CreateGoal(ctx, &goal); err != nil { return domain.Goal{}, err } + s.emit(ctx, EventGoalCreated, GoalCreatedPayload{Goal: goal}) return goal, nil } @@ -219,6 +222,7 @@ func (s *Service) UpdateGoalStatus(ctx context.Context, in UpdateGoalStatusInput goal.Status = in.Status goal.Resolution = resolution goal.UpdatedAt = now + s.emit(ctx, EventGoalStatusChanged, GoalStatusChangedPayload{Goal: goal, PreviousStatus: current}) return goal, nil } @@ -312,6 +316,7 @@ func (s *Service) GeneratePlan(ctx context.Context, goalID string) (domain.PlanV return domain.PlanVersion{}, err } s.metrics.PlanVersionCreated(goal.Domain) + s.emit(ctx, EventPlanCreated, PlanCreatedPayload{GoalID: goal.ID, Version: version}) return version, nil } @@ -408,7 +413,9 @@ func (s *Service) ApplySignal(ctx context.Context, in SignalInput) (SignalResult } if !enq.Synchronous { - // Asynchronous: accepted; the new version (if any) appears once a worker runs. + // Asynchronous: accepted; the new version (if any) appears once a worker + // runs (which emits the terminal replan.completed event). + s.emit(ctx, EventSignalReceived, SignalReceivedPayload{Signal: signal, Status: StatusPending}) return SignalResult{Signal: signal, Status: StatusPending, Material: false, PlanVersion: current}, nil } @@ -417,6 +424,7 @@ func (s *Service) ApplySignal(ctx context.Context, in SignalInput) (SignalResult if out.Material { status = StatusApplied } + s.emit(ctx, EventSignalReceived, SignalReceivedPayload{Signal: signal, Status: status}) return SignalResult{Signal: signal, Status: status, Material: out.Material, Reason: out.Reason, PlanVersion: out.Version}, nil } @@ -476,6 +484,7 @@ func (s *Service) RecordOutcome(ctx context.Context, in OutcomeInput) (domain.Ou if err := s.repo.CreateOutcome(ctx, &outcome); err != nil { return domain.Outcome{}, err } + s.emit(ctx, EventOutcomeRecorded, OutcomeRecordedPayload{Outcome: outcome}) return outcome, nil } diff --git a/internal/app/worker.go b/internal/app/worker.go index 3832a40..8793164 100644 --- a/internal/app/worker.go +++ b/internal/app/worker.go @@ -39,6 +39,7 @@ func (s *Service) processReplan(ctx context.Context, job ReplanJob) (ReplanOutco if !result.Material { s.markSignal(ctx, job.SignalID, StatusUnchanged, current.Version, result.Reason, "") + s.emitReplanCompleted(ctx, job, StatusUnchanged, result.Reason, "", ¤t) return ReplanOutcome{Processed: true, Material: false, Reason: result.Reason, Version: current}, nil } @@ -47,6 +48,7 @@ func (s *Service) processReplan(ctx context.Context, job ReplanJob) (ReplanOutco case err == nil: s.metrics.PlanVersionCreated(goal.Domain) s.markSignal(ctx, job.SignalID, StatusApplied, result.Candidate.Version, result.Reason, "") + s.emitReplanCompleted(ctx, job, StatusApplied, result.Reason, "", &result.Candidate) return ReplanOutcome{Processed: true, Material: true, Reason: result.Reason, Version: result.Candidate}, nil case errors.Is(err, storage.ErrConflict): s.log.Debug("replan version conflict, retrying", "plan_id", job.PlanID, "attempt", attempt+1) @@ -111,9 +113,25 @@ func retryBackoff(attempt int) time.Duration { func (s *Service) failReplan(ctx context.Context, job ReplanJob, err error) (ReplanOutcome, error) { s.metrics.ReplanFailed(job.Domain) s.markSignal(ctx, job.SignalID, StatusFailed, 0, "replan failed", err.Error()) + s.emitReplanCompleted(ctx, job, StatusFailed, "replan failed", err.Error(), nil) return ReplanOutcome{}, err } +// emitReplanCompleted emits the terminal replan.completed event for a job. The +// context is detached from cancellation so a job-deadline expiry (which is itself +// a reportable outcome) does not suppress the notification. +func (s *Service) emitReplanCompleted(ctx context.Context, job ReplanJob, status SignalStatus, reason, errMsg string, version *domain.PlanVersion) { + s.emit(context.WithoutCancel(ctx), EventReplanCompleted, ReplanCompletedPayload{ + GoalID: job.GoalID, + PlanID: job.PlanID, + SignalID: job.SignalID, + Status: status, + Reason: reason, + Error: errMsg, + Version: version, + }) +} + // markSignal persists the signal's terminal replanning status. A failure to record // it is logged but does not change the replan result. func (s *Service) markSignal(ctx context.Context, signalID string, status SignalStatus, version int, reason, errMsg string) { diff --git a/internal/config/config.go b/internal/config/config.go index 5f69934..eefd179 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "os" + "slices" "strconv" "strings" "time" @@ -88,6 +89,30 @@ type Config struct { // SourcesConfigPath optionally points at a JSON/YAML file declaring source // adapters (HTTP endpoints, etc.) keyed by name (DDE_SOURCES). Empty means none. SourcesConfigPath string `json:"sources_file" yaml:"sources_file"` + // WebhookURL enables best-effort webhook notifications when non-empty: domain + // events (goal.created, plan.created, ...) are POSTed there as JSON. + WebhookURL string `json:"webhook_url" yaml:"webhook_url"` + // WebhookSecret, when set, signs each delivery body with HMAC-SHA256 in the + // X-DDE-Signature header so receivers can authenticate the sender. + WebhookSecret string `json:"webhook_secret" yaml:"webhook_secret"` + // WebhookTimeout bounds a single delivery attempt. + WebhookTimeout time.Duration `json:"webhook_timeout" yaml:"webhook_timeout"` + // WebhookRetries is how many extra attempts a failed delivery gets (with backoff). + WebhookRetries int `json:"webhook_retries" yaml:"webhook_retries"` + // WebhookEvents optionally filters which event types are delivered. + // Empty means all. Valid names are listed in webhookEventNames. + WebhookEvents []string `json:"webhook_events" yaml:"webhook_events"` +} + +// webhookEventNames lists the event types a webhook can subscribe to. Keep in +// sync with the Event* constants in internal/app/notify.go. +var webhookEventNames = []string{ + "goal.created", + "plan.created", + "signal.received", + "replan.completed", + "outcome.recorded", + "goal.status_changed", } // Default returns the baseline configuration used when nothing else is set. @@ -123,6 +148,11 @@ func Default() Config { SourcesEnabled: false, SourceTimeout: 5 * time.Second, SourcesConfigPath: "", + WebhookURL: "", + WebhookSecret: "", + WebhookTimeout: 5 * time.Second, + WebhookRetries: 3, + WebhookEvents: nil, } } @@ -277,6 +307,25 @@ func applyEnv(cfg *Config) { if v := os.Getenv("DDE_SOURCES"); v != "" { cfg.SourcesConfigPath = v } + if v := os.Getenv("DDE_WEBHOOK_URL"); v != "" { + cfg.WebhookURL = v + } + if v := os.Getenv("DDE_WEBHOOK_SECRET"); v != "" { + cfg.WebhookSecret = v + } + if v := os.Getenv("DDE_WEBHOOK_TIMEOUT"); v != "" { + if d, err := time.ParseDuration(v); err == nil { + cfg.WebhookTimeout = d + } + } + if v := os.Getenv("DDE_WEBHOOK_RETRIES"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + cfg.WebhookRetries = n + } + } + if v := os.Getenv("DDE_WEBHOOK_EVENTS"); v != "" { + cfg.WebhookEvents = splitAndTrim(v) + } } // Validate checks the configuration for obviously invalid values. @@ -325,6 +374,19 @@ func (c Config) Validate() error { if c.SourceTimeout < 0 { return fmt.Errorf("config: source_timeout must not be negative") } + if c.WebhookURL != "" { + if c.WebhookTimeout <= 0 { + return fmt.Errorf("config: webhook_timeout must be positive") + } + if c.WebhookRetries < 0 { + return fmt.Errorf("config: webhook_retries must not be negative") + } + } + for _, e := range c.WebhookEvents { + if !slices.Contains(webhookEventNames, e) { + return fmt.Errorf("config: unknown webhook event %q (want one of %s)", e, strings.Join(webhookEventNames, ", ")) + } + } return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 686eddd..6c735cd 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -31,6 +31,70 @@ func TestReplanDefaults(t *testing.T) { } } +func TestWebhookEnvOverrides(t *testing.T) { + t.Setenv("DDE_WEBHOOK_URL", "https://hooks.example.com/dde") + t.Setenv("DDE_WEBHOOK_SECRET", "s3cret") + t.Setenv("DDE_WEBHOOK_TIMEOUT", "10s") + t.Setenv("DDE_WEBHOOK_RETRIES", "1") + t.Setenv("DDE_WEBHOOK_EVENTS", "goal.created, plan.created") + + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.WebhookURL != "https://hooks.example.com/dde" { + t.Errorf("expected webhook_url override, got %q", cfg.WebhookURL) + } + if cfg.WebhookSecret != "s3cret" { + t.Errorf("expected webhook_secret override, got %q", cfg.WebhookSecret) + } + if cfg.WebhookTimeout != 10*time.Second { + t.Errorf("expected webhook_timeout 10s, got %v", cfg.WebhookTimeout) + } + if cfg.WebhookRetries != 1 { + t.Errorf("expected webhook_retries 1, got %d", cfg.WebhookRetries) + } + want := []string{"goal.created", "plan.created"} + if len(cfg.WebhookEvents) != len(want) || cfg.WebhookEvents[0] != want[0] || cfg.WebhookEvents[1] != want[1] { + t.Errorf("expected webhook_events %v, got %v", want, cfg.WebhookEvents) + } +} + +func TestWebhookDefaults(t *testing.T) { + c := Default() + if c.WebhookURL != "" { + t.Errorf("webhooks should be disabled by default, got url %q", c.WebhookURL) + } + if c.WebhookTimeout != 5*time.Second { + t.Errorf("default webhook_timeout should be 5s, got %v", c.WebhookTimeout) + } + if c.WebhookRetries != 3 { + t.Errorf("default webhook_retries should be 3, got %d", c.WebhookRetries) + } +} + +func TestValidateRejectsBadWebhookKnobs(t *testing.T) { + c := Default() + c.WebhookURL = "https://hooks.example.com/dde" + c.WebhookTimeout = 0 + if err := c.Validate(); err == nil { + t.Error("expected error for non-positive webhook_timeout") + } + + c = Default() + c.WebhookURL = "https://hooks.example.com/dde" + c.WebhookRetries = -1 + if err := c.Validate(); err == nil { + t.Error("expected error for negative webhook_retries") + } + + c = Default() + c.WebhookEvents = []string{"goal.created", "nope"} + if err := c.Validate(); err == nil { + t.Error("expected error for unknown webhook event") + } +} + func TestValidateRejectsBadReplanKnobs(t *testing.T) { c := Default() c.ReplanTimeout = 0 diff --git a/internal/mcp/errors.go b/internal/mcp/errors.go new file mode 100644 index 0000000..341ffbc --- /dev/null +++ b/internal/mcp/errors.go @@ -0,0 +1,36 @@ +package mcpserver + +import ( + "errors" + "fmt" + + "github.com/vingrad/dynamic-decision-engine/internal/app" + "github.com/vingrad/dynamic-decision-engine/internal/storage" +) + +// mapErr shapes service-layer errors into tool-error messages, mirroring the +// REST API's status mapping (internal/api/errors.go). The SDK turns a returned +// error into a CallToolResult with IsError set, so mapping here is about +// message clarity and not leaking internals. +func mapErr(err error) error { + if err == nil { + return nil + } + var validation *app.ValidationError + switch { + case errors.As(err, &validation): + return fmt.Errorf("invalid input: %s", validation.Msg) + case errors.Is(err, storage.ErrNotFound): + return errors.New("not found") + case errors.Is(err, app.ErrPlanExists): + return errors.New("conflict: a plan already exists for this goal") + case errors.Is(err, app.ErrNoPlanForGoal): + return errors.New("conflict: no plan exists for this goal; call generate_plan first") + case errors.Is(err, app.ErrGoalNotActive): + return errors.New("conflict: goal is not active; resume it before sending signals or generating plans") + case errors.Is(err, storage.ErrConflict): + return errors.New("conflict: resource conflict, retry") + default: + return errors.New("internal error") + } +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 0000000..117c6a3 --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,26 @@ +// Package mcpserver exposes the decision engine's use-cases as Model Context +// Protocol tools, so MCP-capable agents (Claude Code/Desktop, custom agent +// runtimes) can drive the full decision loop: create goals, generate ranked +// plans, submit signals, record outcomes and inspect the immutable version +// history. Like the REST API it is a thin transport adapter over app.Service — +// tool semantics, validation and error mapping mirror the HTTP endpoints. +package mcpserver + +import ( + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/vingrad/dynamic-decision-engine/internal/app" +) + +// New builds an MCP server with the engine's tool set registered. The same +// server value can serve a single stdio session (Run) or many concurrent HTTP +// sessions (mcp.NewStreamableHTTPHandler). +func New(svc *app.Service, version string) *mcp.Server { + s := mcp.NewServer(&mcp.Implementation{ + Name: "dde", + Title: "Dynamic Decision Engine", + Version: version, + }, nil) + addTools(s, svc) + return s +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go new file mode 100644 index 0000000..4301e71 --- /dev/null +++ b/internal/mcp/server_test.go @@ -0,0 +1,241 @@ +package mcpserver + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/vingrad/dynamic-decision-engine/internal/app" + "github.com/vingrad/dynamic-decision-engine/internal/engine" + "github.com/vingrad/dynamic-decision-engine/internal/llm" + "github.com/vingrad/dynamic-decision-engine/internal/storage" +) + +// connect builds an offline service (memory store + deterministic planner), +// serves it over an in-memory MCP transport and returns a connected client +// session. +func connect(t *testing.T) *mcp.ClientSession { + t.Helper() + svc := app.New(storage.NewMemory(), engine.New(llm.NewMockPlanner())) + srv := New(svc, "test") + + clientTr, serverTr := mcp.NewInMemoryTransports() + ctx := context.Background() + go func() { _ = srv.Run(ctx, serverTr) }() + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0"}, nil) + session, err := client.Connect(ctx, clientTr, nil) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = session.Close() }) + return session +} + +// call invokes a tool and fails the test on a protocol-level error. +func call(t *testing.T, s *mcp.ClientSession, name string, args map[string]any) *mcp.CallToolResult { + t.Helper() + res, err := s.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil { + t.Fatalf("call %s: %v", name, err) + } + return res +} + +// callOK invokes a tool, requires success, and unmarshals the structured +// content into a generic map. +func callOK(t *testing.T, s *mcp.ClientSession, name string, args map[string]any) map[string]any { + t.Helper() + res := call(t, s, name, args) + if res.IsError { + t.Fatalf("call %s errored: %s", name, errText(res)) + } + b, err := json.Marshal(res.StructuredContent) + if err != nil { + t.Fatalf("marshal structured content: %v", err) + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("unmarshal structured content: %v", err) + } + return out +} + +// callErr invokes a tool and requires a tool error containing want. +func callErr(t *testing.T, s *mcp.ClientSession, name string, args map[string]any, want string) { + t.Helper() + res := call(t, s, name, args) + if !res.IsError { + t.Fatalf("call %s: expected tool error containing %q, got success", name, want) + } + if text := errText(res); !strings.Contains(text, want) { + t.Errorf("call %s: error %q does not contain %q", name, text, want) + } +} + +func errText(res *mcp.CallToolResult) string { + var parts []string + for _, c := range res.Content { + if tc, ok := c.(*mcp.TextContent); ok { + parts = append(parts, tc.Text) + } + } + return strings.Join(parts, "\n") +} + +func TestListTools(t *testing.T) { + s := connect(t) + res, err := s.ListTools(context.Background(), nil) + if err != nil { + t.Fatal(err) + } + want := map[string]bool{ + "evaluate": false, "create_goal": false, "get_goal": false, + "list_goals": false, "update_goal_status": false, "generate_plan": false, + "get_plan": false, "list_plan_versions": false, "submit_signal": false, + "record_outcome": false, + } + for _, tool := range res.Tools { + if _, ok := want[tool.Name]; !ok { + t.Errorf("unexpected tool %q", tool.Name) + continue + } + want[tool.Name] = true + if tool.InputSchema == nil { + t.Errorf("tool %q has no input schema", tool.Name) + } + } + for name, seen := range want { + if !seen { + t.Errorf("tool %q not registered", name) + } + } +} + +func TestEvaluateStateless(t *testing.T) { + s := connect(t) + out := callOK(t, s, "evaluate", map[string]any{ + "objective": "Grow to 1000 customers", + "context": map[string]any{ + "assets": []map[string]any{{"name": "founder network"}}, + }, + }) + if out["version"] != float64(1) { + t.Errorf("expected version 1, got %v", out["version"]) + } + moves, ok := out["ranked_moves"].([]any) + if !ok || len(moves) == 0 { + t.Errorf("expected ranked moves, got %v", out["ranked_moves"]) + } +} + +func TestFullDecisionLoop(t *testing.T) { + s := connect(t) + + goal := callOK(t, s, "create_goal", map[string]any{ + "objective": "Grow to 1000 customers", + "metric": "customers", + }) + goalID, _ := goal["id"].(string) + if goalID == "" { + t.Fatalf("no goal id in %v", goal) + } + if goal["status"] != "active" { + t.Errorf("expected active goal, got %v", goal["status"]) + } + + plan := callOK(t, s, "generate_plan", map[string]any{"goal_id": goalID}) + planID, _ := plan["plan_id"].(string) + if planID == "" || plan["version"] != float64(1) { + t.Fatalf("unexpected plan: %v", plan) + } + + sig := callOK(t, s, "submit_signal", map[string]any{ + "goal_id": goalID, + "kind": "competitive_shift", + "description": "free tier launched", + }) + if sig["status"] != "applied" || sig["material"] != true { + t.Errorf("expected applied material signal, got status=%v material=%v", sig["status"], sig["material"]) + } + + versions := callOK(t, s, "list_plan_versions", map[string]any{"plan_id": planID}) + if vs, _ := versions["versions"].([]any); len(vs) != 2 { + t.Errorf("expected 2 versions after material signal, got %d", len(vs)) + } + + view := callOK(t, s, "get_plan", map[string]any{"goal_id": goalID}) + cur, _ := view["current_version"].(map[string]any) + if cur["version"] != float64(2) { + t.Errorf("expected current version 2, got %v", cur["version"]) + } + + outcome := callOK(t, s, "record_outcome", map[string]any{ + "goal_id": goalID, + "plan_version": 2, + "move_rank": 1, + "result": "success", + }) + if outcome["move_title"] == "" { + t.Errorf("expected server-snapshotted move title, got %v", outcome) + } + + resolved := callOK(t, s, "update_goal_status", map[string]any{ + "goal_id": goalID, + "status": "resolved", + "resolution_result": "success", + }) + if resolved["status"] != "resolved" { + t.Errorf("expected resolved goal, got %v", resolved["status"]) + } + + goals := callOK(t, s, "list_goals", map[string]any{"status": "resolved"}) + if gs, _ := goals["goals"].([]any); len(gs) != 1 { + t.Errorf("expected 1 resolved goal, got %v", goals["goals"]) + } +} + +func TestToolErrors(t *testing.T) { + s := connect(t) + + callErr(t, s, "get_goal", map[string]any{"goal_id": "goal_missing"}, "not found") + callErr(t, s, "evaluate", map[string]any{"objective": ""}, "invalid input") + callErr(t, s, "list_goals", map[string]any{"status": "bogus"}, "invalid input") + callErr(t, s, "get_plan", map[string]any{}, "exactly one of plan_id or goal_id") + callErr(t, s, "get_plan", map[string]any{"plan_id": "p", "goal_id": "g"}, "exactly one of plan_id or goal_id") + + goal := callOK(t, s, "create_goal", map[string]any{"objective": "x"}) + goalID := goal["id"].(string) + + callErr(t, s, "submit_signal", map[string]any{"goal_id": goalID, "kind": "x"}, "no plan exists") + + if _ = callOK(t, s, "generate_plan", map[string]any{"goal_id": goalID}); true { + callErr(t, s, "generate_plan", map[string]any{"goal_id": goalID}, "conflict") + } + + callErr(t, s, "record_outcome", map[string]any{ + "goal_id": goalID, "plan_version": 99, "move_rank": 1, "result": "success", + }, "invalid input") + + callErr(t, s, "update_goal_status", map[string]any{ + "goal_id": goalID, "status": "resolved", + }, "invalid input") +} + +func TestListGoalsPagination(t *testing.T) { + s := connect(t) + for i := 0; i < 3; i++ { + callOK(t, s, "create_goal", map[string]any{"objective": "x"}) + } + page := callOK(t, s, "list_goals", map[string]any{"limit": 2}) + if gs, _ := page["goals"].([]any); len(gs) != 2 { + t.Errorf("expected page of 2 goals, got %v", len(gs)) + } + rest := callOK(t, s, "list_goals", map[string]any{"limit": 2, "offset": 2}) + if gs, _ := rest["goals"].([]any); len(gs) != 1 { + t.Errorf("expected 1 goal on second page, got %v", len(gs)) + } +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go new file mode 100644 index 0000000..73039f3 --- /dev/null +++ b/internal/mcp/tools.go @@ -0,0 +1,258 @@ +package mcpserver + +import ( + "context" + "errors" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/vingrad/dynamic-decision-engine/internal/app" + "github.com/vingrad/dynamic-decision-engine/internal/domain" + "github.com/vingrad/dynamic-decision-engine/internal/storage" +) + +// Tool input types. These mirror the REST DTOs (internal/api/dto.go) but are +// defined here with jsonschema descriptions so the SDK can infer self-documenting +// input schemas for agents. +// +// Output note: tools whose result contains a domain.PlanVersion use an untyped +// (any) output. PlanVersion's provenance carries raw source payloads as +// json.RawMessage, which schema inference types as a byte array — a typed output +// schema would then reject real plans during output validation. Goal and outcome +// results carry no raw JSON and stay fully typed. + +type evaluateInput struct { + Domain string `json:"domain,omitempty" jsonschema:"decision domain: generic, investing, growth or career; empty means generic"` + Objective string `json:"objective" jsonschema:"the goal to plan for (required)"` + Metric string `json:"metric,omitempty" jsonschema:"how progress is measured"` + Target string `json:"target,omitempty" jsonschema:"the value or threshold that defines success"` + Context domain.Context `json:"context,omitempty" jsonschema:"the known situation: facts, assets and constraints"` + SignalNote string `json:"signal_note,omitempty" jsonschema:"optional note about a recent change to fold into the reasoning"` +} + +type createGoalInput struct { + PlayerID string `json:"player_id,omitempty" jsonschema:"optional id of the person/team/system pursuing the goal"` + Domain string `json:"domain,omitempty" jsonschema:"decision domain: generic, investing, growth or career; empty means generic"` + Objective string `json:"objective" jsonschema:"the goal to plan for (required)"` + Metric string `json:"metric,omitempty" jsonschema:"how progress is measured"` + Target string `json:"target,omitempty" jsonschema:"the value or threshold that defines success"` + Context domain.Context `json:"context,omitempty" jsonschema:"the known situation: facts, assets and constraints"` +} + +type goalIDInput struct { + GoalID string `json:"goal_id" jsonschema:"the goal id (required)"` +} + +type listGoalsInput struct { + Status string `json:"status,omitempty" jsonschema:"filter by lifecycle status: active, on_hold, resolved or abandoned; empty means all"` + Limit int `json:"limit,omitempty" jsonschema:"page size (default 50, max 200)"` + Offset int `json:"offset,omitempty" jsonschema:"page offset"` +} + +type updateGoalStatusInput struct { + GoalID string `json:"goal_id" jsonschema:"the goal id (required)"` + Status string `json:"status" jsonschema:"target lifecycle status: active, on_hold, resolved or abandoned (required)"` + ResolutionResult string `json:"resolution_result,omitempty" jsonschema:"required when resolving or abandoning: success, failure, partial or inconclusive"` + ResolutionNotes string `json:"resolution_notes,omitempty" jsonschema:"optional notes on how the goal concluded"` +} + +type getPlanInput struct { + PlanID string `json:"plan_id,omitempty" jsonschema:"the plan id; provide exactly one of plan_id or goal_id"` + GoalID string `json:"goal_id,omitempty" jsonschema:"the goal id; provide exactly one of plan_id or goal_id"` +} + +type listPlanVersionsInput struct { + PlanID string `json:"plan_id" jsonschema:"the plan id (required)"` + Limit int `json:"limit,omitempty" jsonschema:"page size (default 50, max 200)"` + Offset int `json:"offset,omitempty" jsonschema:"page offset"` +} + +type submitSignalInput struct { + GoalID string `json:"goal_id" jsonschema:"the goal the signal applies to (required)"` + Kind string `json:"kind" jsonschema:"signal kind, e.g. competitive_shift, experiment_result, constraint_change (required)"` + Description string `json:"description,omitempty" jsonschema:"what happened"` + Payload map[string]any `json:"payload,omitempty" jsonschema:"optional structured signal data"` +} + +type recordOutcomeInput struct { + GoalID string `json:"goal_id" jsonschema:"the goal the outcome belongs to (required)"` + PlanVersion int `json:"plan_version" jsonschema:"the immutable plan version the executed move came from (required)"` + MoveRank int `json:"move_rank" jsonschema:"the rank of the executed move within that version (required)"` + Result string `json:"result" jsonschema:"the observed result: success, failure, partial or inconclusive (required)"` + ObservedSignals []string `json:"observed_signals,omitempty" jsonschema:"signals observed while executing the move"` + Notes string `json:"notes,omitempty" jsonschema:"free-form notes"` +} + +// listGoalsOutput mirrors the REST list envelope ({"goals": [...]}). +type listGoalsOutput struct { + Goals []domain.Goal `json:"goals"` +} + +// addTools registers the engine's tool set on s, all calling svc directly. +func addTools(s *mcp.Server, svc *app.Service) { + mcp.AddTool(s, &mcp.Tool{ + Name: "evaluate", + Description: "Generate a ranked action plan for a self-contained goal without persisting anything. " + + "Returns ranked moves with confidence, impact/effort/risk, rationale, experiments and fallbacks. " + + "Use create_goal + generate_plan instead when the decision should be tracked over time.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in evaluateInput) (*mcp.CallToolResult, any, error) { + version, err := svc.Evaluate(ctx, app.EvaluateInput{ + Domain: in.Domain, + Objective: in.Objective, + Metric: in.Metric, + Target: in.Target, + Context: in.Context, + SignalNote: in.SignalNote, + }) + if err != nil { + return nil, nil, mapErr(err) + } + return nil, version, nil + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "create_goal", + Description: "Create a persistent goal (objective + context) to plan around. " + + "Call generate_plan next to produce its initial ranked plan.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in createGoalInput) (*mcp.CallToolResult, domain.Goal, error) { + goal, err := svc.CreateGoal(ctx, app.CreateGoalInput{ + PlayerID: in.PlayerID, + Domain: in.Domain, + Objective: in.Objective, + Metric: in.Metric, + Target: in.Target, + Context: in.Context, + }) + return nil, goal, mapErr(err) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "get_goal", + Description: "Fetch a goal by id, including its lifecycle status and resolution.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in goalIDInput) (*mcp.CallToolResult, domain.Goal, error) { + goal, err := svc.GetGoal(ctx, in.GoalID) + return nil, goal, mapErr(err) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "list_goals", + Description: "List goals, optionally filtered by lifecycle status, paginated.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in listGoalsInput) (*mcp.CallToolResult, listGoalsOutput, error) { + var filter storage.GoalFilter + if in.Status != "" { + status := domain.GoalStatus(in.Status) + if !status.Valid() { + return nil, listGoalsOutput{}, errors.New("invalid input: status must be one of: active, on_hold, resolved, abandoned") + } + filter.Status = status + } + goals, err := svc.ListGoals(ctx, filter, storage.Page{Limit: in.Limit, Offset: in.Offset}) + if err != nil { + return nil, listGoalsOutput{}, mapErr(err) + } + return nil, listGoalsOutput{Goals: goals}, nil + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "update_goal_status", + Description: "Transition a goal's lifecycle status (active, on_hold, resolved, abandoned). " + + "Resolving or abandoning is terminal and requires a resolution_result.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in updateGoalStatusInput) (*mcp.CallToolResult, domain.Goal, error) { + goal, err := svc.UpdateGoalStatus(ctx, app.UpdateGoalStatusInput{ + GoalID: in.GoalID, + Status: domain.GoalStatus(in.Status), + ResolutionResult: domain.OutcomeResult(in.ResolutionResult), + ResolutionNotes: in.ResolutionNotes, + }) + return nil, goal, mapErr(err) + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "generate_plan", + Description: "Generate and persist the initial ranked plan (version 1) for a goal. " + + "A goal has at most one plan; later changes arrive as new immutable versions via submit_signal.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in goalIDInput) (*mcp.CallToolResult, any, error) { + version, err := svc.GeneratePlan(ctx, in.GoalID) + if err != nil { + return nil, nil, mapErr(err) + } + return nil, version, nil + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "get_plan", + Description: "Fetch a plan head and its current version, by plan id or by goal id (exactly one).", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in getPlanInput) (*mcp.CallToolResult, any, error) { + if (in.PlanID == "") == (in.GoalID == "") { + return nil, nil, errors.New("invalid input: provide exactly one of plan_id or goal_id") + } + var ( + view app.PlanView + err error + ) + if in.PlanID != "" { + view, err = svc.GetPlan(ctx, in.PlanID) + } else { + view, err = svc.GetGoalPlan(ctx, in.GoalID) + } + if err != nil { + return nil, nil, mapErr(err) + } + // Same envelope as GET /v1/plans/{id}. + return nil, map[string]any{"plan": view.Plan, "current_version": view.CurrentVersion}, nil + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "list_plan_versions", + Description: "List a plan's immutable version history (the decision audit trail), paginated.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in listPlanVersionsInput) (*mcp.CallToolResult, any, error) { + versions, err := svc.ListPlanVersions(ctx, in.PlanID, storage.Page{Limit: in.Limit, Offset: in.Offset}) + if err != nil { + return nil, nil, mapErr(err) + } + // Same envelope as GET /v1/plans/{id}/versions. + return nil, map[string]any{"versions": versions}, nil + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "submit_signal", + Description: "Report that something changed (a result, a market shift, a constraint change). " + + "The engine re-evaluates the goal's plan and, if the change is material, appends a new immutable version. " + + "Status pending means replanning runs asynchronously — poll get_plan or list_plan_versions for the result.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in submitSignalInput) (*mcp.CallToolResult, any, error) { + result, err := svc.ApplySignal(ctx, app.SignalInput{ + GoalID: in.GoalID, + Kind: in.Kind, + Description: in.Description, + Payload: in.Payload, + }) + if err != nil { + return nil, nil, mapErr(err) + } + // Same envelope as POST /v1/signals (api.SignalResponse). + return nil, map[string]any{ + "signal": result.Signal, + "status": string(result.Status), + "material": result.Material, + "reason": result.Reason, + "plan_version": result.PlanVersion, + }, nil + }) + + mcp.AddTool(s, &mcp.Tool{ + Name: "record_outcome", + Description: "Record the real-world result of an executed move, addressed by (plan_version, move_rank) " + + "in the goal's immutable plan. Outcomes build the audit trail; they do not trigger replanning — " + + "submit a signal if the outcome should change the plan.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, in recordOutcomeInput) (*mcp.CallToolResult, domain.Outcome, error) { + outcome, err := svc.RecordOutcome(ctx, app.OutcomeInput{ + GoalID: in.GoalID, + PlanVersion: in.PlanVersion, + MoveRank: in.MoveRank, + Result: domain.OutcomeResult(in.Result), + ObservedSignals: in.ObservedSignals, + Notes: in.Notes, + }) + return nil, outcome, mapErr(err) + }) +} diff --git a/internal/webhook/dispatcher.go b/internal/webhook/dispatcher.go new file mode 100644 index 0000000..56bc162 --- /dev/null +++ b/internal/webhook/dispatcher.go @@ -0,0 +1,236 @@ +// Package webhook delivers app events to an external HTTP endpoint. Delivery is +// best-effort and fully decoupled from the use-case path: events are queued in +// memory, POSTed by background workers with bounded retries, and dropped (with a +// log line and a metric) when the receiver is down for long or the queue is +// full. The store remains the source of truth — receivers that need a complete +// picture reconcile via the REST API. +package webhook + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log/slog" + "math/rand/v2" + "net/http" + "sync" + "time" + + "github.com/vingrad/dynamic-decision-engine/internal/app" +) + +// Metrics is the delivery observability hook (the API's *Metrics satisfies it). +type Metrics interface { + // WebhookDelivery records one delivery result: success, failure or dropped. + WebhookDelivery(event, result string) +} + +type nopMetrics struct{} + +func (nopMetrics) WebhookDelivery(_, _ string) {} + +// Config configures a Dispatcher. +type Config struct { + // URL is the endpoint events are POSTed to. + URL string + // Secret, when non-empty, signs each delivery body with HMAC-SHA256 in the + // X-DDE-Signature header ("sha256="). + Secret string + // Timeout bounds a single delivery attempt. Defaults to 5s. + Timeout time.Duration + // Retries is how many extra attempts a failed delivery gets. Defaults to 0. + Retries int + // Events optionally filters delivered event types. Empty means all. + Events []string + // QueueSize bounds the in-memory event buffer. Defaults to 1024. + QueueSize int + // Workers is the delivery worker count. Defaults to 2. + Workers int +} + +// Dispatcher implements app.Notifier by POSTing events to a webhook endpoint. +type Dispatcher struct { + cfg Config + allowed map[string]bool // nil means all events + client *http.Client + ch chan app.Event + wg sync.WaitGroup + mu sync.Mutex + closed bool + log *slog.Logger + metrics Metrics +} + +// New starts a Dispatcher with cfg.Workers delivery workers. +func New(cfg Config, log *slog.Logger, m Metrics) *Dispatcher { + if cfg.Timeout <= 0 { + cfg.Timeout = 5 * time.Second + } + if cfg.QueueSize <= 0 { + cfg.QueueSize = 1024 + } + if cfg.Workers <= 0 { + cfg.Workers = 2 + } + if log == nil { + log = slog.Default() + } + if m == nil { + m = nopMetrics{} + } + var allowed map[string]bool + if len(cfg.Events) > 0 { + allowed = make(map[string]bool, len(cfg.Events)) + for _, e := range cfg.Events { + allowed[e] = true + } + } + d := &Dispatcher{ + cfg: cfg, + allowed: allowed, + client: &http.Client{Timeout: cfg.Timeout}, + ch: make(chan app.Event, cfg.QueueSize), + log: log, + metrics: m, + } + d.wg.Add(cfg.Workers) + for i := 0; i < cfg.Workers; i++ { + go d.worker() + } + return d +} + +// Emit queues an event for delivery. It never blocks: when the buffer is full +// (receiver down or too slow) or the dispatcher is shut down, the event is +// dropped and recorded as such. +func (d *Dispatcher) Emit(_ context.Context, evt app.Event) { + if d.allowed != nil && !d.allowed[evt.Type] { + return + } + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + d.drop(evt, "dispatcher shut down") + return + } + select { + case d.ch <- evt: + default: + d.drop(evt, "queue full") + } +} + +func (d *Dispatcher) drop(evt app.Event, reason string) { + d.metrics.WebhookDelivery(evt.Type, "dropped") + d.log.Warn("webhook event dropped", "event_id", evt.ID, "event", evt.Type, "reason", reason) +} + +// Shutdown stops accepting events and waits until queued events are delivered +// or ctx expires. +func (d *Dispatcher) Shutdown(ctx context.Context) error { + d.mu.Lock() + if !d.closed { + d.closed = true + close(d.ch) + } + d.mu.Unlock() + + done := make(chan struct{}) + go func() { + d.wg.Wait() + close(done) + }() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (d *Dispatcher) worker() { + defer d.wg.Done() + for evt := range d.ch { + d.deliver(evt) + } +} + +// deliver POSTs one event, retrying transient failures (transport errors, 429 +// and 5xx) with exponential backoff and jitter. Other 4xx statuses indicate a +// misconfigured receiver and are terminal. +func (d *Dispatcher) deliver(evt app.Event) { + body, err := json.Marshal(evt) + if err != nil { + d.metrics.WebhookDelivery(evt.Type, "failure") + d.log.Warn("webhook event not serialisable", "event_id", evt.ID, "event", evt.Type, "err", err) + return + } + + var lastErr string + for attempt := 0; attempt <= d.cfg.Retries; attempt++ { + if attempt > 0 { + time.Sleep(deliveryBackoff(attempt)) + } + retryable, errMsg := d.attempt(evt, body) + if errMsg == "" { + d.metrics.WebhookDelivery(evt.Type, "success") + return + } + lastErr = errMsg + if !retryable { + break + } + } + d.metrics.WebhookDelivery(evt.Type, "failure") + d.log.Warn("webhook delivery failed", "event_id", evt.ID, "event", evt.Type, "url", d.cfg.URL, "err", lastErr) +} + +// attempt performs a single delivery. It returns whether a failure is worth +// retrying and an empty errMsg on success. +func (d *Dispatcher) attempt(evt app.Event, body []byte) (retryable bool, errMsg string) { + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, d.cfg.URL, bytes.NewReader(body)) + if err != nil { + return false, err.Error() + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "dde-webhook/1.0") + req.Header.Set("X-DDE-Event", evt.Type) + req.Header.Set("X-DDE-Delivery", evt.ID) + if d.cfg.Secret != "" { + mac := hmac.New(sha256.New, []byte(d.cfg.Secret)) + mac.Write(body) + req.Header.Set("X-DDE-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil))) + } + + resp, err := d.client.Do(req) + if err != nil { + return true, err.Error() + } + defer resp.Body.Close() + // Drain (bounded) so the connection can be reused. + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10)) + + switch { + case resp.StatusCode >= 200 && resp.StatusCode < 300: + return false, "" + case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500: + return true, fmt.Sprintf("status %d", resp.StatusCode) + default: + return false, fmt.Sprintf("status %d", resp.StatusCode) + } +} + +// deliveryBackoff is an exponential backoff with jitter (≈250ms, 500ms, 1s, … +// capped at 5s, each scaled by a random factor in [0.5, 1.5)). +func deliveryBackoff(attempt int) time.Duration { + d := 250 * time.Millisecond << (attempt - 1) + if d > 5*time.Second { + d = 5 * time.Second + } + return time.Duration(float64(d) * (0.5 + rand.Float64())) +} diff --git a/internal/webhook/dispatcher_test.go b/internal/webhook/dispatcher_test.go new file mode 100644 index 0000000..6969b5c --- /dev/null +++ b/internal/webhook/dispatcher_test.go @@ -0,0 +1,326 @@ +package webhook + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/vingrad/dynamic-decision-engine/internal/app" +) + +func discardLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// recordingMetrics counts delivery results per (event, result). +type recordingMetrics struct { + mu sync.Mutex + counts map[string]int +} + +func newRecordingMetrics() *recordingMetrics { + return &recordingMetrics{counts: make(map[string]int)} +} + +func (m *recordingMetrics) WebhookDelivery(event, result string) { + m.mu.Lock() + defer m.mu.Unlock() + m.counts[event+"/"+result]++ +} + +func (m *recordingMetrics) get(event, result string) int { + m.mu.Lock() + defer m.mu.Unlock() + return m.counts[event+"/"+result] +} + +// waitFor polls cond until it holds or the deadline passes. +func waitFor(t *testing.T, cond func() bool) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if cond() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("condition not met in time") +} + +func testEvent(typ string) app.Event { + return app.Event{ + ID: "evt_test1", + Type: typ, + CreatedAt: time.Date(2026, 6, 10, 12, 0, 0, 0, time.UTC), + Payload: map[string]any{"goal_id": "goal_x"}, + } +} + +func shutdown(t *testing.T, d *Dispatcher) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := d.Shutdown(ctx); err != nil { + t.Fatalf("shutdown: %v", err) + } +} + +func TestDeliveryEnvelopeAndHeaders(t *testing.T) { + type received struct { + body []byte + headers http.Header + } + got := make(chan received, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + got <- received{body: body, headers: r.Header.Clone()} + })) + defer srv.Close() + + m := newRecordingMetrics() + d := New(Config{URL: srv.URL, Secret: "s3cret"}, discardLogger(), m) + evt := testEvent("goal.created") + d.Emit(context.Background(), evt) + shutdown(t, d) + + r := <-got + var env struct { + ID string `json:"id"` + Type string `json:"type"` + CreatedAt time.Time `json:"created_at"` + Payload map[string]any `json:"payload"` + } + if err := json.Unmarshal(r.body, &env); err != nil { + t.Fatalf("unmarshal envelope: %v", err) + } + if env.ID != evt.ID || env.Type != evt.Type || env.Payload["goal_id"] != "goal_x" { + t.Errorf("unexpected envelope: %+v", env) + } + if ct := r.headers.Get("Content-Type"); ct != "application/json" { + t.Errorf("content type %q", ct) + } + if h := r.headers.Get("X-DDE-Event"); h != "goal.created" { + t.Errorf("X-DDE-Event %q", h) + } + if h := r.headers.Get("X-DDE-Delivery"); h != evt.ID { + t.Errorf("X-DDE-Delivery %q", h) + } + mac := hmac.New(sha256.New, []byte("s3cret")) + mac.Write(r.body) + want := "sha256=" + hex.EncodeToString(mac.Sum(nil)) + if sig := r.headers.Get("X-DDE-Signature"); sig != want { + t.Errorf("signature mismatch: got %q want %q", sig, want) + } + if m.get("goal.created", "success") != 1 { + t.Errorf("expected 1 success metric, got %d", m.get("goal.created", "success")) + } +} + +func TestNoSignatureWithoutSecret(t *testing.T) { + got := make(chan string, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got <- r.Header.Get("X-DDE-Signature") + })) + defer srv.Close() + + d := New(Config{URL: srv.URL}, discardLogger(), nil) + d.Emit(context.Background(), testEvent("goal.created")) + shutdown(t, d) + + if sig := <-got; sig != "" { + t.Errorf("expected no signature header, got %q", sig) + } +} + +func TestRetryOnServerError(t *testing.T) { + var mu sync.Mutex + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + calls++ + first := calls == 1 + mu.Unlock() + if first { + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer srv.Close() + + m := newRecordingMetrics() + d := New(Config{URL: srv.URL, Retries: 2}, discardLogger(), m) + d.Emit(context.Background(), testEvent("plan.created")) + shutdown(t, d) + + mu.Lock() + defer mu.Unlock() + if calls != 2 { + t.Errorf("expected 2 attempts (500 then 200), got %d", calls) + } + if m.get("plan.created", "success") != 1 { + t.Errorf("expected success after retry") + } +} + +func TestNoRetryOnClientError(t *testing.T) { + var mu sync.Mutex + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + calls++ + mu.Unlock() + w.WriteHeader(http.StatusBadRequest) + })) + defer srv.Close() + + m := newRecordingMetrics() + d := New(Config{URL: srv.URL, Retries: 3}, discardLogger(), m) + d.Emit(context.Background(), testEvent("plan.created")) + shutdown(t, d) + + mu.Lock() + defer mu.Unlock() + if calls != 1 { + t.Errorf("expected 1 attempt for 400, got %d", calls) + } + if m.get("plan.created", "failure") != 1 { + t.Errorf("expected failure metric") + } +} + +func TestRetriesExhausted(t *testing.T) { + var mu sync.Mutex + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + calls++ + mu.Unlock() + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer srv.Close() + + m := newRecordingMetrics() + d := New(Config{URL: srv.URL, Retries: 1}, discardLogger(), m) + d.Emit(context.Background(), testEvent("outcome.recorded")) + shutdown(t, d) + + mu.Lock() + defer mu.Unlock() + if calls != 2 { + t.Errorf("expected 2 attempts, got %d", calls) + } + if m.get("outcome.recorded", "failure") != 1 { + t.Errorf("expected failure metric after exhausted retries") + } +} + +func TestEventFilter(t *testing.T) { + var mu sync.Mutex + var events []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + events = append(events, r.Header.Get("X-DDE-Event")) + mu.Unlock() + })) + defer srv.Close() + + d := New(Config{URL: srv.URL, Events: []string{"plan.created"}}, discardLogger(), nil) + d.Emit(context.Background(), testEvent("goal.created")) + d.Emit(context.Background(), testEvent("plan.created")) + shutdown(t, d) + + mu.Lock() + defer mu.Unlock() + if len(events) != 1 || events[0] != "plan.created" { + t.Errorf("expected only plan.created delivered, got %v", events) + } +} + +func TestQueueOverflowDrops(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-release: + case <-r.Context().Done(): + } + })) + defer srv.Close() + + m := newRecordingMetrics() + d := New(Config{URL: srv.URL, QueueSize: 1, Workers: 1}, discardLogger(), m) + // First event occupies the worker, second fills the queue, third drops. + d.Emit(context.Background(), testEvent("goal.created")) + waitFor(t, func() bool { return len(d.ch) == 0 }) // worker picked it up + d.Emit(context.Background(), testEvent("goal.created")) + d.Emit(context.Background(), testEvent("goal.created")) + + if got := m.get("goal.created", "dropped"); got != 1 { + t.Errorf("expected 1 dropped event, got %d", got) + } + close(release) + shutdown(t, d) +} + +func TestShutdownDrainsQueue(t *testing.T) { + var mu sync.Mutex + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + calls++ + mu.Unlock() + })) + defer srv.Close() + + d := New(Config{URL: srv.URL, Workers: 1}, discardLogger(), nil) + for i := 0; i < 5; i++ { + d.Emit(context.Background(), testEvent("goal.created")) + } + shutdown(t, d) + + mu.Lock() + defer mu.Unlock() + if calls != 5 { + t.Errorf("expected all 5 queued events delivered on drain, got %d", calls) + } +} + +func TestEmitAfterShutdownDrops(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer srv.Close() + + m := newRecordingMetrics() + d := New(Config{URL: srv.URL}, discardLogger(), m) + shutdown(t, d) + + d.Emit(context.Background(), testEvent("goal.created")) // must not panic + if got := m.get("goal.created", "dropped"); got != 1 { + t.Errorf("expected dropped metric after shutdown, got %d", got) + } +} + +func TestAttemptTimeout(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Outlast the client's 50ms timeout, but return soon after so the test + // server can close. (Blocking on r.Context() would deadlock: the server + // never observes the disconnect while the request body is unread.) + time.Sleep(500 * time.Millisecond) + })) + defer srv.Close() + + m := newRecordingMetrics() + d := New(Config{URL: srv.URL, Timeout: 50 * time.Millisecond}, discardLogger(), m) + d.Emit(context.Background(), testEvent("goal.created")) + shutdown(t, d) + + if got := m.get("goal.created", "failure"); got != 1 { + t.Errorf("expected timeout to count as failure, got %d", got) + } +}