diff --git a/cmd/synapbus/main.go b/cmd/synapbus/main.go index 8ed9718..a9b4e74 100644 --- a/cmd/synapbus/main.go +++ b/cmd/synapbus/main.go @@ -44,6 +44,7 @@ import ( reactorpkg "github.com/synapbus/synapbus/internal/reactor" "github.com/synapbus/synapbus/internal/messaging" prommetrics "github.com/synapbus/synapbus/internal/metrics" + "github.com/synapbus/synapbus/internal/observability" "github.com/synapbus/synapbus/internal/reactions" "github.com/synapbus/synapbus/internal/search" "github.com/synapbus/synapbus/internal/search/embedding" @@ -194,6 +195,19 @@ func runServe(cmd *cobra.Command, args []string) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Initialise OpenTelemetry tracing (opt-in via SYNAPBUS_OTEL_ENABLED=1). + // Harmless when disabled — installs only the W3C propagator and + // leaves the global tracer provider as the default no-op. + otelShutdown, err := observability.Init(ctx, observability.ConfigFromEnv(os.Getenv), logger) + if err != nil { + return fmt.Errorf("init otel: %w", err) + } + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = otelShutdown(shutdownCtx) + }() + slog.Info("starting SynapBus", "host", host, "port", port, diff --git a/deploy/kubic/otel-collector.yaml b/deploy/kubic/otel-collector.yaml new file mode 100644 index 0000000..06b2be5 --- /dev/null +++ b/deploy/kubic/otel-collector.yaml @@ -0,0 +1,139 @@ +# OpenTelemetry Collector for kubic.home.arpa +# +# Installs a single-replica otelcol-contrib in the `synapbus` namespace. +# Accepts OTLP over gRPC (4317) and HTTP (4318) and forwards traces to +# stdout for now; swap in a Tempo / Jaeger exporter once one is up. +# +# Apply with: +# kubectl apply -f deploy/kubic/otel-collector.yaml +# +# SynapBus points at this collector via: +# SYNAPBUS_OTEL_ENABLED=1 +# SYNAPBUS_OTEL_ENDPOINT=otel-collector.synapbus.svc.cluster.local:4318 +# SYNAPBUS_OTEL_INSECURE=1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: otel-collector-config + namespace: synapbus +data: + config.yaml: | + receivers: + otlp: + protocols: + grpc: + endpoint: 0.0.0.0:4317 + http: + endpoint: 0.0.0.0:4318 + + processors: + batch: + timeout: 5s + send_batch_size: 512 + memory_limiter: + check_interval: 1s + limit_percentage: 80 + spike_limit_percentage: 20 + + exporters: + debug: + verbosity: normal + sampling_initial: 5 + sampling_thereafter: 200 + # TODO: wire a Tempo / Jaeger / Loki exporter once one is running + # on kubic. Until then, `debug` prints a sampled summary to stdout. + + service: + pipelines: + traces: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [debug] + logs: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [debug] + metrics: + receivers: [otlp] + processors: [memory_limiter, batch] + exporters: [debug] + telemetry: + logs: + level: info +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: otel-collector + namespace: synapbus + labels: + app.kubernetes.io/name: otel-collector + app.kubernetes.io/part-of: synapbus +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: otel-collector + template: + metadata: + labels: + app.kubernetes.io/name: otel-collector + spec: + containers: + - name: otelcol + image: otel/opentelemetry-collector-contrib:0.118.0 + args: ["--config=/conf/config.yaml"] + ports: + - name: otlp-grpc + containerPort: 4317 + - name: otlp-http + containerPort: 4318 + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + readinessProbe: + tcpSocket: + port: 4317 + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + tcpSocket: + port: 4317 + initialDelaySeconds: 15 + periodSeconds: 20 + volumeMounts: + - name: config + mountPath: /conf + readOnly: true + volumes: + - name: config + configMap: + name: otel-collector-config + items: + - key: config.yaml + path: config.yaml +--- +apiVersion: v1 +kind: Service +metadata: + name: otel-collector + namespace: synapbus + labels: + app.kubernetes.io/name: otel-collector + app.kubernetes.io/part-of: synapbus +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: otel-collector + ports: + - name: otlp-grpc + port: 4317 + targetPort: 4317 + - name: otlp-http + port: 4318 + targetPort: 4318 diff --git a/docs/harness-otel-design.md b/docs/harness-otel-design.md new file mode 100644 index 0000000..ce9dc5a --- /dev/null +++ b/docs/harness-otel-design.md @@ -0,0 +1,321 @@ +# Harness-Agnostic Wrappers + OTel — Design Document + +**Status:** IMPLEMENTED on branch `feat/harness-otel` (was DRAFT — approved 2026-04-13) +**Date:** 2026-04-13 +**Companion report:** [`harness-otel-research.html`](./harness-otel-research.html) + +## 1. Motivation + +SynapBus today executes reactive agents through two disjoint paths: + +- `internal/k8s` + `internal/reactor` — creates a Kubernetes Job per inbound message (primary). +- `internal/webhooks` — outbound HTTP delivery with HMAC signing (secondary). + +There is no way to run an external CLI (claude-code, gemini-cli, kimi, codex) as a local subprocess on a Mac or on `kubic` outside of a K8s Job. There is no unified `Runner` / `Harness` interface. OpenTelemetry is listed in `go.mod` but unused. Each new backend would require touching the reactor directly. + +This design introduces an `internal/harness/` package that: + +1. Defines a minimal `Harness` interface (inspired by `GoogleCloudPlatform/scion`'s `api.Harness`). +2. Wraps the existing K8s path and the existing webhook path as two implementations of that interface. +3. Adds a third implementation: a local subprocess executor. +4. Initialises OpenTelemetry in the main process and wires spans + W3C trace-context propagation through every implementation, using env-var injection as the transport into child processes. + +## 2. Goals / Non-goals + +**Goals** + +- One interface for "dispatch this message to this agent, wherever it runs." +- Pluggable backends: k8s-job, subprocess, webhook, in-process stub (tests). +- Capability flags so the dispatcher can pick the right backend and degrade gracefully. +- Distributed tracing from `mcp.tool.execute` → `reactor.dispatch` → `harness.execute` → child process. +- Cost / token / duration recorded in a new backend-agnostic `harness_runs` table. +- Preflight `TestEnvironment()` per harness, callable from the admin CLI. + +**Non-goals** + +- No task decomposition, no LLM planner, no judge. Consistent with scion and paperclip. +- No company / org-chart / budget-governance model. Out of scope. +- No plugin loader at runtime; compile-time registry for now. +- No changes to the MCP tool surface exposed to agents. This is all server-side. + +## 3. Interface + +```go +// internal/harness/harness.go + +package harness + +type Capabilities struct { + SystemPrompt bool + SessionResume bool + Skills bool + OTelNative bool // child honours OTEL_* env vars + MaxConcurrency int +} + +type Budget struct { + MaxWallClock time.Duration + MaxTokensIn int64 + MaxTokensOut int64 + MaxCostUSD float64 +} + +type Usage struct { + TokensIn int64 + TokensOut int64 + TokensCached int64 + CostUSD float64 +} + +type ExecRequest struct { + RunID string // generated by caller; propagated into child + AgentName string + Message *messaging.Message + Context []*messaging.Message // optional conversation window + Budget Budget + Env map[string]string // caller overrides + Skills []string +} + +type ExecResult struct { + ExitCode int + Logs string // captured stdout/stderr + ResultJSON json.RawMessage // optional structured output + Usage Usage + TraceID string // W3C, for correlation + Err error +} + +type Harness interface { + Name() string + Capabilities() Capabilities + + // One-shot pre-flight: is the binary installed, is auth valid, + // can we reach the model? Used by admin CLI and registry resolution. + TestEnvironment(ctx context.Context) error + + // One-shot setup for a given agent (write config files, pre-approve + // tool fingerprints, materialise skills). Idempotent. + Provision(ctx context.Context, agent *agents.Agent) error + + // Dispatch a single request. Blocks until completion (or Budget exceeded). + Execute(ctx context.Context, req *ExecRequest) (*ExecResult, error) + + // Best-effort cancellation of an in-flight run. + Cancel(ctx context.Context, runID string) error +} +``` + +## 4. Registry + resolution + +```go +type Registry struct { + mu sync.RWMutex + byName map[string]Harness +} + +func (r *Registry) Register(h Harness) { ... } + +// Resolve picks a backend for the given agent. Resolution order: +// 1. agent.HarnessName (explicit) +// 2. agent.K8sImage != "" && k8s runner available → "k8sjob" +// 3. agent has webhooks registered → "webhook" +// 4. agent.LocalCommand != "" → "subprocess" +// 5. ErrNoBackend +func (r *Registry) Resolve(agent *agents.Agent) (Harness, error) { ... } + +// Execute is the one entry point the reactor uses. It resolves, starts a +// span, injects trace context into req.Env, calls Execute, records usage, +// and writes a harness_runs row. +func (r *Registry) Execute(ctx context.Context, agent *agents.Agent, req *ExecRequest) (*ExecResult, error) { ... } +``` + +## 5. Backend implementations + +### 5.1 `internal/harness/k8sjob` + +- Wraps the existing `internal/k8s.JobRunner` + `internal/reactor` K8s path. +- `Execute` → `CreateJob` → poll `ReactiveRun` → `GetJobLogs` → parse logs for result envelope. +- `Provision` is a no-op (K8s path has nothing to provision). +- `Capabilities{SystemPrompt:false, SessionResume:false, Skills:false, OTelNative:true, MaxConcurrency:10}`. +- Env vars merged into `corev1.EnvVar` slice at `internal/k8s/runner.go:105–119` include the injected `TRACEPARENT` / `OTEL_EXPORTER_OTLP_ENDPOINT`. + +### 5.2 `internal/harness/subprocess` (NEW) + +- Runs `os/exec` with `cmd.Env = mergedEnv`, `cmd.Dir = workdir`, context timeout from `Budget.MaxWallClock`. +- Captures stdout/stderr into a bounded buffer (`MAX_LOG_BYTES`, e.g. 1 MiB; truncate with excerpt marker beyond). +- Reads a well-known `result.json` file from `workdir` after exit to populate `ExecResult.ResultJSON` (same convention as scion agents writing to workspace). +- Credential injection: `HOME`, `ANTHROPIC_API_KEY` / `GEMINI_API_KEY` from agent config; `~/.claude` readable via host FS. +- Per-agent `workdir` under `${SYNAPBUS_DATA_DIR}/harness/subprocess/${runID}/` — torn down on success, preserved on failure for forensics. +- `Capabilities{SystemPrompt:true, SessionResume:true (Claude Code), Skills:false, OTelNative:true, MaxConcurrency:4}`. + +### 5.3 `internal/harness/webhook` + +- Wraps the existing `internal/webhooks.DeliveryEngine` as a `Harness`. +- Async: `Execute` enqueues a delivery, polls `webhook_deliveries` for a terminal state, then synthesises an `ExecResult`. +- Useful for agents that want to receive a callback on their own HTTP endpoint instead of running in-process. + +### 5.4 `internal/harness/stub` (tests only) + +- In-memory; returns a canned `ExecResult`. Used by unit + integration tests so nothing in tests actually shells out or talks to K8s. + +## 6. OTel integration + +### 6.1 Initialisation + +New file `internal/observability/otel.go`: + +```go +package observability + +func Init(ctx context.Context, cfg Config) (shutdown func(context.Context) error, err error) { + res, _ := resource.New(ctx, + resource.WithAttributes(semconv.ServiceName("synapbus")), + ) + exp, err := otlptracegrpc.New(ctx, + otlptracegrpc.WithEndpoint(cfg.Endpoint), + otlptracegrpc.WithInsecure(), + ) + if err != nil { return nil, err } + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exp), + sdktrace.WithResource(res), + ) + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.TraceContext{}) + return tp.Shutdown, nil +} +``` + +Called from `cmd/synapbus/main.go` immediately after `slog` setup, opt-in via `SYNAPBUS_OTEL_ENABLED=1`. + +### 6.2 Span taxonomy + +| Span name | Location | Key attributes | +|--------------------------------|--------------------------------|----------------| +| `mcp.tool.execute` | MCP handler entry | `mcp.tool`, `agent.name`, `message.id` | +| `reactor.dispatch` | `reactor.Dispatch()` | `agent.name`, `trigger.depth`, `budget.remaining` | +| `harness.resolve` | `Registry.Resolve` | `harness.name`, `fallback.chain` | +| `harness.provision` | `Harness.Provision` | `harness.name`, `agent.home` | +| `harness.execute` | `Harness.Execute` | `harness.name`, `run.id`, `usage.*`, `cost.usd`, `exit.code` | +| `harness.k8s.job.create` | k8sjob backend | `k8s.job.name`, `k8s.namespace`, `k8s.image` | +| `harness.subprocess.exec` | subprocess backend | `proc.argv[0]`, `proc.pid`, `proc.workdir` | +| `harness.webhook.deliver` | webhook backend | `http.url`, `http.status_code`, `retry.count` | + +### 6.3 Context propagation into children + +```go +func injectTraceEnv(ctx context.Context, dst map[string]string, runID, agentName string, cfg Config) { + carrier := propagation.MapCarrier{} + otel.GetTextMapPropagator().Inject(ctx, carrier) + // OTel convention: env vars TRACEPARENT, TRACESTATE + for k, v := range carrier { + dst[strings.ToUpper(k)] = v + } + dst["OTEL_EXPORTER_OTLP_ENDPOINT"] = cfg.ChildEndpoint + dst["OTEL_EXPORTER_OTLP_PROTOCOL"] = "grpc" + dst["OTEL_SERVICE_NAME"] = "synapbus-agent-" + agentName + dst["OTEL_RESOURCE_ATTRIBUTES"] = fmt.Sprintf("synapbus.run_id=%s,synapbus.agent=%s", runID, agentName) +} +``` + +- **K8s backend**: merged into the `corev1.EnvVar` slice built at `internal/k8s/runner.go:105–119`. +- **Subprocess backend**: merged into `cmd.Env`. +- **Webhook backend**: set as HTTP headers (`traceparent`, `tracestate`) alongside existing `X-SynapBus-*` headers. + +### 6.4 Metrics + +Keep the existing Prometheus registry (`internal/metrics/metrics.go`). Also emit a minimal OTel meter set via the same OTLP exporter: + +- `synapbus.harness.runs` (counter, labels: `harness`, `status`) +- `synapbus.harness.duration_ms` (histogram) +- `synapbus.harness.tokens_in` / `tokens_out` (counters) +- `synapbus.harness.cost_usd` (counter) + +### 6.5 Config + +New env vars on `cmd/synapbus/main.go`: + +| Var | Default | Description | +|---|---|---| +| `SYNAPBUS_OTEL_ENABLED` | `false` | Opt-in master switch | +| `SYNAPBUS_OTEL_ENDPOINT` | `localhost:4317` | OTLP gRPC target | +| `SYNAPBUS_OTEL_INSECURE` | `true` | TLS off for LAN | +| `SYNAPBUS_OTEL_SERVICE_NAME` | `synapbus` | Override for multi-instance setups | + +## 7. Data model + +### 7.1 Migration `019_harness.sql` + +```sql +ALTER TABLE agents ADD COLUMN harness_name TEXT; +ALTER TABLE agents ADD COLUMN local_command TEXT; -- subprocess argv (JSON) +ALTER TABLE agents ADD COLUMN harness_config_json TEXT; -- per-harness config blob + +CREATE TABLE harness_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL UNIQUE, -- UUID, propagated into child + agent_name TEXT NOT NULL, + backend TEXT NOT NULL, -- 'k8sjob' | 'subprocess' | 'webhook' | 'stub' + message_id INTEGER, -- triggering message, if any + status TEXT NOT NULL, -- 'pending' | 'running' | 'success' | 'failed' | 'cancelled' | 'timeout' + exit_code INTEGER, + trace_id TEXT, + span_id TEXT, + tokens_in INTEGER DEFAULT 0, + tokens_out INTEGER DEFAULT 0, + tokens_cached INTEGER DEFAULT 0, + cost_usd REAL DEFAULT 0, + duration_ms INTEGER, + result_json TEXT, + logs_excerpt TEXT, -- bounded, full logs on disk + created_at INTEGER NOT NULL, + finished_at INTEGER, + FOREIGN KEY (message_id) REFERENCES messages(id) +); + +CREATE INDEX idx_harness_runs_agent ON harness_runs(agent_name, created_at DESC); +CREATE INDEX idx_harness_runs_status ON harness_runs(status, created_at DESC); +CREATE INDEX idx_harness_runs_trace ON harness_runs(trace_id); +``` + +### 7.2 Relationship to `ReactiveRun` + +Phase 2 keeps both tables. A follow-up (separate PR) folds `ReactiveRun` into `harness_runs` and drops the old table. This avoids a big-bang migration. + +## 8. Staged implementation plan + +| Phase | Scope | Reversible? | +|---|---|---| +| **0** | This design doc + research HTML report | yes — text only | +| **1** | Scaffold `internal/harness/` — interface, registry, stub backend, unit tests. No callers wired. | yes — dead code until Phase 2 | +| **2** | Refactor existing K8s path behind `k8sjob.Harness`. Reactor calls `Registry.Execute`. Behaviour unchanged. Existing tests green. | yes — one commit revert | +| **3** | New `subprocess` backend + migration `019_harness.sql` + per-agent `local_command`. | yes | +| **4** | Wrap webhook path as `webhook.Harness`. Route via registry. | yes | +| **5** | `internal/observability/otel.go` + span wiring + env-var propagation. Opt-in. | yes — feature-flagged | +| **6** | Session codec + cost accounting surfaced in `harness_runs`; `TestEnvironment` preflight on admin CLI. | yes | + +Each phase is a separate PR. Nothing is merged until the previous phase's tests are green. + +## 9. Testing strategy + +- **Unit**: every interface method on every backend, using the `stub` harness where possible. +- **Integration**: one-shot reactor dispatch end-to-end with the `stub` backend; asserts that spans are created, `harness_runs` row is written, trace id propagates. +- **K8s**: existing K8s-gated tests continue to run against a real kubeconfig when available (`SYNAPBUS_TEST_K8S=1`). +- **Subprocess**: run against a tiny golden binary (`testdata/echo-agent.sh`) that reads env, writes `result.json`, exits 0. +- **OTel**: in-memory span exporter asserted via `go.opentelemetry.io/otel/sdk/trace/tracetest`. + +## 10. Open questions (for approval) + +1. **Collector.** Stand up a collector on `kubic` first, or ship with stdout exporter as a no-op until a collector exists? +2. **Subprocess path on Mac.** Is laptop-local execution in-scope for Phase 3 or defer? +3. **Session codec.** Just a session-id pass-through, or full replay of conversation history? +4. **Runtime plugin loader.** Compile-time registry only, or add `hashicorp/go-plugin` later? +5. **Feature flag.** Global `SYNAPBUS_HARNESS_V2=1` to gate the whole thing until Phase 6, or trust the phase-by-phase PRs? + +## 11. References + +- `GoogleCloudPlatform/scion` — `pkg/api/harness.go:22–68`, `pkg/harness/claude_code.go:311–320`, `pkg/util/logging/otel_provider.go:26–61`. +- `paperclipai/paperclip` — `packages/adapter-utils/src/types.ts:292–331`, `server/src/adapters/registry.ts:89–222`, `server/src/services/heartbeat.ts:331–346`. +- SynapBus current surface — `internal/k8s/runner.go:96–183`, `internal/reactor/reactor.go:51`, `internal/webhooks/delivery.go:157`, `internal/mcp/tools_hybrid.go:489`, `internal/trace/tracer.go`, `go.mod:102–114` (OTel deps present but unused). +- Companion research HTML — [`harness-otel-research.html`](./harness-otel-research.html). diff --git a/docs/harness-otel-research.html b/docs/harness-otel-research.html new file mode 100644 index 0000000..15a798b --- /dev/null +++ b/docs/harness-otel-research.html @@ -0,0 +1,601 @@ + + + + + +Harness-Agnostic Wrappers & OTel — Research Report + + + +
+ +
+
Research Report • 2026-04-13
+

Harness-Agnostic Wrappers &
OpenTelemetry for SynapBus

+

Borrow what works from GoogleCloudPlatform/scion and paperclipai/paperclip, skip what doesn't, and sketch a minimal harness + OTel integration that fits SynapBus's Go / MCP / SQLite spine.

+
+ Scope research + design (no code yet) + Status awaiting approval + Targets scion / paperclip / synapbus +
+
+ +
+ Contents +
    +
  1. TL;DR — recommendation
  2. +
  3. What is scion actually doing?
  4. +
  5. What is paperclip actually doing?
  6. +
  7. Side-by-side comparison
  8. +
  9. SynapBus — current execution surface
  10. +
  11. Proposed design for SynapBus
  12. +
  13. OTel integration points
  14. +
  15. Other reusable nuggets
  16. +
  17. Next steps & open questions
  18. +
+
+ +
+

TL;DR

+
+

Both repos converge on the same core idea: a narrow Harness / Adapter interface that abstracts "some external AI CLI" behind a single execute(ctx)→result contract, then registers concrete implementations for Claude Code, Gemini CLI, Codex, OpenCode, etc.

+

Scion's design is the better template for SynapBus: it's Go, it ships OTel via env-var injection into child processes, and its Harness interface cleanly separates provisioning from invocation — exactly the seam we're missing.

+

Paperclip contributes two ideas we should adopt: (a) an adapter registry with capability flags so a router can pick the best backend at dispatch time, and (b) a session codec per adapter so long-running agents can be resumed.

+

SynapBus today has no subprocess executor, no unified runner interface, and no OTel spans — only a K8s-Job path and an HTTP-webhook path living as two disjoint code paths. A small internal/harness/ package would unify both and unlock local-subprocess execution.

+
+
+ +
+

1 · What scion actually does

+ +

Despite the name collision with the SCION internet-architecture project, GoogleCloudPlatform/scion is a multi-agent orchestration harness for evaluating and running "deep agents" (Claude Code, Gemini CLI, Codex, OpenCode) inside isolated containers. It is explicitly not a planner and not a verifier — it is the control plane and observability spine around arbitrary agent CLIs.

+ +

The Harness interface — the centrepiece

+

pkg/api/harness.go:22–68

+
type Harness interface {
+    Name() string
+    AdvancedCapabilities() HarnessAdvancedCapabilities
+    GetEnv(agentName, agentHome, unixUsername string) map[string]string
+    GetCommand(task string, resume bool, baseArgs []string) []string
+    DefaultConfigDir() string
+    SkillsDir() string
+    HasSystemPrompt(agentHome string) bool
+    Provision(ctx context.Context, agentName, agentDir, agentHome, agentWorkspace string) error
+    GetEmbedDir() string
+    GetInterruptKey() string
+    GetHarnessEmbedsFS() (embed.FS, string)
+    InjectAgentInstructions(agentHome string, content []byte) error
+    InjectSystemPrompt(agentHome string, content []byte) error
+    // the key OTel seam — returns env vars that the container runtime
+    // will merge into the child process env before exec
+    GetTelemetryEnv() map[string]string
+    ResolveAuth(auth AuthConfig) (*ResolvedAuth, error)
+}
+ +

Three things to notice:

+ + +

The factory

+

pkg/harness/harness.go:37–57

+
func New(name string) Harness {
+    switch name {
+    case "claude":   return &ClaudeCode{}
+    case "gemini":   return &GeminiCLI{}
+    case "opencode": return &OpenCode{}
+    case "codex":    return &Codex{}
+    }
+    if h := pluginMgr.Lookup(name); h != nil { return h }
+    return &Generic{} // universal fallback
+}
+ +

OTel injection pattern

+

pkg/harness/claude_code.go:311–320

+
func (c *ClaudeCode) GetTelemetryEnv() map[string]string {
+    return map[string]string{
+        "CLAUDE_CODE_ENABLE_TELEMETRY": "1",
+        "OTEL_METRICS_EXPORTER":       "otlp",
+        "OTEL_LOGS_EXPORTER":          "otlp",
+        "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc",
+        "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317",
+        "OTEL_METRIC_EXPORT_INTERVAL": "30000",
+    }
+}
+ +

Scion's own Go code emits OTel logs via the OTLP log exporter (pkg/util/logging/otel_provider.go:26–61) and bridges slog into it (pkg/util/logging/otel.go:85–119). W3C traceparent headers are extracted at HTTP ingress (pkg/util/logging/trace.go) so trace context can flow across the dispatcher → runtime → container boundary.

+ +

Coordination & decomposition

+

Scion does not decompose tasks. A single task string goes to the agent and the agent's own model decides how to break it up. Coordination between agents happens via a structured StructuredMessage envelope (pkg/messages/types.go:46–61) with fields {sender, recipient, msg, type, urgent, broadcasted, attachments} — an on-disk analogue of a SynapBus channel post.

+ +
+Reusable for SynapBus: the Harness interface shape, the env-var-injection model for both auth & telemetry, the capability-flags degradation pattern, and the Provision/GetCommand split. Ignore the container runtime abstraction — SynapBus already has K8s-Job + webhook paths and doesn't need a second one. +
+
+ +
+

2 · What paperclip actually does

+ +

Paperclip is a Node/Express control plane for running 10–20 agent "companies" with org charts, budgets, and approval gates. Wildly different product — but it has a clean adapter interface worth borrowing.

+ +

The ServerAdapterModule interface

+

packages/adapter-utils/src/types.ts:292–331

+
export interface ServerAdapterModule {
+  type: string;
+  execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult>;
+  testEnvironment(ctx: AdapterEnvironmentTestContext): Promise<AdapterEnvironmentTestResult>;
+  listSkills?:  (ctx) => Promise<AdapterSkillSnapshot>;
+  syncSkills?:  (ctx, desired: string[]) => Promise<AdapterSkillSnapshot>;
+  sessionCodec?:     AdapterSessionCodec;       // resume / serialize sessions
+  models?:           AdapterModel[];
+  listModels?:      () => Promise<AdapterModel[]>;
+  agentConfigurationDoc?: string;
+  onHireApproved?:  (payload, cfg) => Promise<HireApprovedHookResult>;
+  getQuotaWindows?: () => Promise<ProviderQuotaResult>;
+}
+ +

AdapterExecutionResult — types.ts:64–95

+
{ exitCode, signal, timedOut, errorMessage, errorCode,
+  usage: { inputTokens, outputTokens, cachedInputTokens },
+  resultJson, costUsd,
+  question?: { prompt, choices }  // can pause for human approval
+}
+ +

Ten adapters are registered via a mutable map in server/src/adapters/registry.ts:89–222: claude-local, codex-local, cursor, gemini, opencode, pi, openclaw, hermes, http, process. External adapters are loaded from plugins asynchronously (lines 244–270).

+ +

Coordination model — heartbeat + atomic checkout

+

server/src/services/heartbeat.ts

+

No DAG, no queue, no planner. Agents wake on a heartbeat (schedule or event), atomically claim assigned issues via a per-agent start lock (withAgentStartLock(), lines 331–346), run once, and go back to sleep. Concurrency is per-agent (default 1, configurable to 10). Task decomposition is entirely delegated to the agent's own model.

+ +

Verification

+

None that's interesting. Exit code 0 = success; timeouts and process-loss retries are tracked; there is no LLM judge, no schema validation, no test runner. Verification is whatever the running agent chooses to self-report in resultJson.

+ +

Observability

+

Pino structured logging (server/src/middleware/logger.ts:29–45) + a custom telemetry client (server/src/telemetry.ts:12–26) that batch-flushes events every 60s. No OpenTelemetry. This is the weakest part relative to scion.

+ +
+Skip for SynapBus: the whole company/org-chart/budget/approval-gate model, the Drizzle ORM, the plugin loader, the issue-tracker schema. They're all Node-centric and solve a problem SynapBus doesn't have. +
+
+Borrow from paperclip: (1) the sessionCodec idea — each harness knows how to serialise/resume its own session, so SynapBus can carry conversation state across reactive runs; (2) testEnvironment() as a preflight — "is the CLI installed, is auth valid, can it reach the model?"; (3) getQuotaWindows() / cost tracking in the result envelope. +
+
+ +
+

3 · Side-by-side comparison

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Aspectscion (Go)paperclip (Node)synapbus today
Core interfaceapi.Harness — 15 methods, env-var-centricServerAdapterModule — execute() + optional hooksk8s.JobRunner (K8s only) + webhooks.EventDispatcher — no unification
Backends shippedclaude, gemini, codex, opencode, generic fallbackclaude, codex, cursor, gemini, opencode, pi, openclaw, hermes, http, processK8s Job (one) + outbound HTTP webhook
Credential injectionenv vars from GetEnv()+ResolveAuth(); HostPath for ~/.claudeper-adapter config objects; provider SDK authK8s env vars from agent's k8s_env_json; HostPath ~/.claude (reactor.go:281–286)
Task decompositionNone — passes whole task string to agentNone — agents pull from issue queue themselvesNone — reactive trigger wraps one inbound message
VerificationWorkspace sync + agent logs; no judgeExit code, token usage, timeout; no judgeK8s Job success/fail + pod logs stored in ReactiveRun
ObservabilityOTel logs via OTLP gRPC, W3C trace-context propagation, slog bridgePino structured logs + custom telemetry clientslog JSON only; Prometheus metrics for reactor; OTel deps present but unused in Go code
CoordinationContainers per agent; inter-agent messages via typed envelopeHeartbeat + atomic per-agent lock; org-chart hierarchyMCP channels & DMs; reactive triggers fire on inbound
Capability flagsAdvancedCapabilities() for graceful degradationOptional methods on the interfaceNone — hardcoded paths
Session resumeYes — GetCommand(task, resume bool, ...)Yes — per-adapter sessionCodecNone — each reactive run is fresh
+
+ +
+

4 · SynapBus current execution surface

+ +
+
+

Path A — Reactive K8s Job primary

+
+
Reactor filters inbound messages for agents with TriggerMode=reactive reactor.go:51
+
Preconditions — image configured, budget, cooldown, depth
+
JobRunner.CreateJob builds a K8s batchv1.Job with env vars SYNAPBUS_MESSAGE_ID/_BODY/_FROM_AGENT/_EVENT/_CHANNEL k8s/runner.go:96–183
+
Poller goroutine watches Job status, stores result in ReactiveRun reactor/poller.go
+
GetJobLogs pulls pod logs on completion k8s/runner.go:185
+
+
+
+

Path B — Webhook delivery secondary

+
+
DeliveryEngine.Dispatch matches webhooks for event+agent webhooks/delivery.go:157
+
HTTP POST with X-SynapBus-Signature HMAC, X-SynapBus-Depth delivery.go:290–302
+
Retry 1s / 5s / 30s, dead-letter after 3 attempts
+
+
+
+ +
+

Gaps

+

These paths are two disjoint islands. There is:

+
    +
  • missing a local subprocess executor (no way to run a CLI when not in K8s)
  • +
  • missing a unified Runner/Harness interface — the reactor switches on K8s availability with a NoopRunner fallback
  • +
  • missing any OTel span around agent invocations — OTel deps exist in go.mod but are unimported
  • +
  • missing capability flags per backend (system-prompt support, session resume, skills)
  • +
  • partial credential injection — K8s path uses HostPath ~/.claude + env vars; webhook path has none
  • +
  • partial cost/token tracking — benchmark/sdk_backend.py returns it but core Go reactor does not
  • +
+

The recent benchmark/sdk_backend.py (commit 0e25fbc) is a Python two-backend fallback (anthropic SDK → claude-agent-sdk) that foreshadows exactly the abstraction we need — but in the benchmark tree, not in core.

+
+
+ +
+

5 · Proposed design for SynapBus

+ +

New package: internal/harness/

+ +
+
+
+

Caller

+
MCP handler
+
Reactor
+
Webhook engine
+
Benchmark harness
+
+
→
+
+

internal/harness

+
Registry
+
Harness interface
+
Capability flags
+
OTel spans + env injection
+
+
→
+
+

Backends

+
k8s-job (existing)
+
subprocess (new)
+
webhook (existing, wrapped)
+
in-process stub
+
+
+
+ +

Interface sketch

+
package harness
+
+type Capabilities struct {
+    SystemPrompt    bool
+    SessionResume   bool
+    Skills          bool
+    OTelNative      bool // child process honours OTEL_* env vars
+    MaxConcurrency  int
+}
+
+type ExecRequest struct {
+    AgentName  string
+    Message    *messaging.Message   // triggering message
+    Context    []*messaging.Message // optional conversation window
+    Budget     Budget               // tokens, cost, wallclock
+    Env        map[string]string // caller-provided overrides
+    Skills     []string
+}
+
+type ExecResult struct {
+    ExitCode   int
+    Logs       string
+    ResultJSON json.RawMessage
+    Usage      Usage // { in, out, cached tokens, cost }
+    TraceID    string // W3C, for correlation
+    Err        error
+}
+
+type Harness interface {
+    Name() string
+    Capabilities() Capabilities
+    TestEnvironment(ctx context.Context) error                 // preflight
+    Provision(ctx context.Context, agent *agents.Agent) error    // one-shot setup
+    Execute(ctx context.Context, req *ExecRequest) (*ExecResult, error)
+    Cancel(ctx context.Context, runID string) error
+}
+
+type Registry struct { /* map[string]Harness + mutex */ }
+
+func (r *Registry) Register(h Harness)
+func (r *Registry) Resolve(agent *agents.Agent) (Harness, error)
+func (r *Registry) Execute(ctx context.Context, req *ExecRequest) (*ExecResult, error)
+ +

Backend implementations

+ + + + + + + + +
PackageWrapsStatus
internal/harness/k8sjobexisting internal/k8s pathrefactor into Harness
internal/harness/subprocessos/exec with env-map + workdir + timeoutnew
internal/harness/webhookexisting internal/webhooks/delivery.gowrap as Harness, async result via DB poll
internal/harness/stubin-process fake for testsnew, test-only
+ +

Resolution policy

+

Registry.Resolve(agent) picks a backend based on:

+
    +
  1. Explicit agent.HarnessName field (new column, nullable)
  2. +
  3. Else: agent has K8sImage and k8s.JobRunner.IsAvailable() → k8sjob
  4. +
  5. Else: agent has Webhooks registered → webhook
  6. +
  7. Else: agent has LocalCommand configured → subprocess
  8. +
  9. Else: typed error ErrNoBackend
  10. +
+ +

Data model additions

+ +
+ +
+

6 · OTel integration points

+ +

Scion's pattern is the template: (a) initialize an OTel tracer provider in the main process, (b) start a span per harness invocation, (c) inject the trace context into the child as env vars, (d) ship spans via OTLP gRPC to whatever collector is configured.

+ +

Init

+

New file internal/observability/otel.go:

+
func Init(ctx context.Context, cfg Config) (shutdown func(context.Context) error, err error) {
+    res, _ := resource.New(ctx,
+        resource.WithAttributes(semconv.ServiceName("synapbus")),
+    )
+    exp, _ := otlptracegrpc.New(ctx,
+        otlptracegrpc.WithEndpoint(cfg.Endpoint),
+        otlptracegrpc.WithInsecure(),
+    )
+    tp := sdktrace.NewTracerProvider(
+        sdktrace.WithBatcher(exp),
+        sdktrace.WithResource(res),
+    )
+    otel.SetTracerProvider(tp)
+    otel.SetTextMapPropagator(propagation.TraceContext{})
+    return tp.Shutdown, nil
+}
+ +

Span taxonomy

+ + + + + + + + + + + + +
Span nameWhereKey attributes
mcp.tool.executeMCP handler entrymcp.tool, agent.name, message.id
reactor.dispatchreactor.Dispatch()agent.name, trigger.depth, budget.remaining
harness.resolveRegistry.Resolveharness.name, fallback.chain
harness.provisionHarness.Provisionharness.name, agent.home
harness.executeHarness.Executeharness.name, run.id, usage.*, cost.usd, exit.code
harness.k8s.job.createk8sjob backendk8s.job.name, k8s.namespace, k8s.image
harness.subprocess.execsubprocess backendproc.argv[0], proc.pid, proc.workdir
harness.webhook.deliverwebhook backendhttp.url, http.status_code, retry.count
+ +

Context propagation into children

+

For each backend, the current span's traceparent is serialised via propagation.TraceContext{}.Inject into an env-var map and merged with Harness.GetTelemetryEnv():

+
func injectTraceEnv(ctx context.Context, dst map[string]string) {
+    carrier := propagation.MapCarrier{}
+    otel.GetTextMapPropagator().Inject(ctx, carrier)
+    for k, v := range carrier {
+        // OTel convention: TRACEPARENT / TRACESTATE env names
+        dst[strings.ToUpper(k)] = v
+    }
+    dst["OTEL_EXPORTER_OTLP_ENDPOINT"] = cfg.ChildEndpoint // same collector
+    dst["OTEL_SERVICE_NAME"] = "synapbus-agent-" + agentName
+    dst["OTEL_RESOURCE_ATTRIBUTES"] = "synapbus.run_id=" + runID
+}
+

For K8s: merged into corev1.EnvVar slice at k8s/runner.go:105–119. For subprocess: merged into cmd.Env. For webhook: added as HTTP headers (traceparent, tracestate) alongside the existing X-SynapBus-* headers.

+ +

Metrics

+

Keep the existing Prometheus registry (internal/metrics/metrics.go) — it's already wired — but also emit a minimal set via OTel meter, so a single OTLP collector sees both spans and metrics:

+ + +

Config

+

Three new env vars (matching scion naming, with SYNAPBUS_ prefix for ours):

+ +

Until a real collector exists on kubic, a file exporter (stdouttrace) or the existing trace.Tracer (SQLite trace table) can back the same interface via an adapter.

+
+ +
+

7 · Other reusable nuggets

+
+
+

From scion

+
    +
  • Workspace-per-agent git worktree for isolation — nice-to-have once multiple reactive agents run in parallel on the same host.
  • +
  • Interrupt key per harness (GetInterruptKey) — e.g. double-Escape for Claude Code — useful for cancel semantics.
  • +
  • Pre-approved tool fingerprints written into .claude.json customApiKeyResponses — removes the "did you really want to use this key?" prompt.
  • +
  • Structured StructuredMessage envelope — SynapBus messages already have most of this; add a type enum (instruction/input-needed/state-change).
  • +
+
+
+

From paperclip

+
    +
  • testEnvironment() preflight — a health check per harness, runnable from the admin CLI ("can this agent actually dispatch?").
  • +
  • sessionCodec — serialise/resume an agent conversation across reactive runs. Gives SynapBus a real "sticky" agent without re-prompting.
  • +
  • Cost/token usage in the result envelope — already in benchmark/sdk_backend.py, worth lifting into the core result type.
  • +
  • Atomic per-agent lock — belt-and-braces guarantee that one agent can't double-fire on the same trigger.
  • +
+
+
+
+ +
+

8 · Next steps & open questions

+ +
+Awaiting approval before any code is written. The user asked for research + design first. +
+ +

Staged implementation plan (for discussion)

+
+
Phase 0 — design doc at docs/harness-otel-design.md (written alongside this report)
+
Phase 1 — scaffold internal/harness/ with the interface, registry, and a stub backend. Pure Go, no external deps added.
+
Phase 2 — refactor K8s path behind the new Harness interface without changing behaviour. Existing tests stay green.
+
Phase 3 — new subprocess backend + per-agent local_command config + migration 016.
+
Phase 4 — wrap webhook path as a third backend, via the resolver. Async result via DB poll.
+
Phase 5 — OTel init & span wiring around all three backends. Env-var propagation into children. Opt-in config.
+
Phase 6 — session codec + cost accounting on harness_runs. testEnvironment preflight exposed via admin CLI.
+
+ +

Open questions for you

+
    +
  1. Collector. Is there an OTel collector on kubic.home.arpa already, or do we deploy one first (Tempo? Jaeger? stdout only for now)?
  2. +
  3. Scope of Phase 1. Do you want the new package to land behind a feature flag, or replace the existing reactor path immediately?
  4. +
  5. Subprocess path on the Mac. SynapBus today only runs agents as K8s Jobs. The subprocess backend lets it also run claude-code / gemini-cli locally on your laptop. Is that in-scope now or defer?
  6. +
  7. Session codec. How much of paperclip's session-resume semantics do you want — just "reuse the Claude Code session id" or full conversation replay?
  8. +
  9. Plugin loader. Do we need to load third-party harnesses at runtime (plugin.Plugin / HashiCorp go-plugin), or is a compile-time registry enough?
  10. +
+
+ + + +
+ + diff --git a/go.mod b/go.mod index db727a9..949464a 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,11 @@ require ( github.com/prometheus/client_model v0.6.2 github.com/prometheus/common v0.66.1 github.com/spf13/cobra v1.10.2 + go.opentelemetry.io/otel v1.31.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 + go.opentelemetry.io/otel/sdk v1.31.0 + go.opentelemetry.io/otel/trace v1.31.0 golang.org/x/crypto v0.49.0 golang.org/x/oauth2 v0.36.0 golang.org/x/time v0.9.0 @@ -104,14 +109,9 @@ require ( go.opentelemetry.io/contrib/propagators/b3 v1.21.0 // indirect go.opentelemetry.io/contrib/propagators/jaeger v1.21.1 // indirect go.opentelemetry.io/contrib/samplers/jaegerremote v0.15.1 // indirect - go.opentelemetry.io/otel v1.31.0 // indirect go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.21.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.21.0 // indirect go.opentelemetry.io/otel/exporters/zipkin v1.21.0 // indirect go.opentelemetry.io/otel/metric v1.31.0 // indirect - go.opentelemetry.io/otel/sdk v1.31.0 // indirect - go.opentelemetry.io/otel/trace v1.31.0 // indirect go.opentelemetry.io/proto/otlp v1.0.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect diff --git a/internal/agents/store.go b/internal/agents/store.go index 0e1093e..df1010b 100644 --- a/internal/agents/store.go +++ b/internal/agents/store.go @@ -135,7 +135,8 @@ func (s *SQLiteAgentStore) ListReactiveAgents(ctx context.Context) ([]*Agent, er // agentSelectSQL returns the base SELECT clause for agent queries. func agentSelectSQL() string { return `SELECT id, name, display_name, type, capabilities, owner_id, api_key_hash, status, created_at, updated_at, - trigger_mode, cooldown_seconds, daily_trigger_budget, max_trigger_depth, k8s_image, k8s_env_json, k8s_resource_preset, pending_work + trigger_mode, cooldown_seconds, daily_trigger_budget, max_trigger_depth, k8s_image, k8s_env_json, k8s_resource_preset, pending_work, + harness_name, local_command, harness_config_json FROM agents` } @@ -241,6 +242,7 @@ func (s *SQLiteAgentStore) scanAgent(row *sql.Row) (*Agent, error) { var agent Agent var caps string var k8sImage, k8sEnvJSON sql.NullString + var harnessName, localCommand, harnessConfigJSON sql.NullString var pendingWork int err := row.Scan( &agent.ID, &agent.Name, &agent.DisplayName, &agent.Type, @@ -249,6 +251,7 @@ func (s *SQLiteAgentStore) scanAgent(row *sql.Row) (*Agent, error) { &agent.TriggerMode, &agent.CooldownSeconds, &agent.DailyTriggerBudget, &agent.MaxTriggerDepth, &k8sImage, &k8sEnvJSON, &agent.K8sResourcePreset, &pendingWork, + &harnessName, &localCommand, &harnessConfigJSON, ) if err != nil { return nil, err @@ -257,6 +260,9 @@ func (s *SQLiteAgentStore) scanAgent(row *sql.Row) (*Agent, error) { agent.K8sImage = k8sImage.String agent.K8sEnvJSON = k8sEnvJSON.String agent.PendingWork = pendingWork != 0 + agent.HarnessName = harnessName.String + agent.LocalCommand = localCommand.String + agent.HarnessConfigJSON = harnessConfigJSON.String return &agent, nil } @@ -266,6 +272,7 @@ func (s *SQLiteAgentStore) scanAgents(rows *sql.Rows) ([]*Agent, error) { var agent Agent var caps string var k8sImage, k8sEnvJSON sql.NullString + var harnessName, localCommand, harnessConfigJSON sql.NullString var pendingWork int err := rows.Scan( &agent.ID, &agent.Name, &agent.DisplayName, &agent.Type, @@ -274,6 +281,7 @@ func (s *SQLiteAgentStore) scanAgents(rows *sql.Rows) ([]*Agent, error) { &agent.TriggerMode, &agent.CooldownSeconds, &agent.DailyTriggerBudget, &agent.MaxTriggerDepth, &k8sImage, &k8sEnvJSON, &agent.K8sResourcePreset, &pendingWork, + &harnessName, &localCommand, &harnessConfigJSON, ) if err != nil { return nil, err @@ -282,6 +290,9 @@ func (s *SQLiteAgentStore) scanAgents(rows *sql.Rows) ([]*Agent, error) { agent.K8sImage = k8sImage.String agent.K8sEnvJSON = k8sEnvJSON.String agent.PendingWork = pendingWork != 0 + agent.HarnessName = harnessName.String + agent.LocalCommand = localCommand.String + agent.HarnessConfigJSON = harnessConfigJSON.String agents = append(agents, &agent) } if agents == nil { diff --git a/internal/agents/types.go b/internal/agents/types.go index 9c98a5a..3c08cd8 100644 --- a/internal/agents/types.go +++ b/internal/agents/types.go @@ -41,4 +41,9 @@ type Agent struct { K8sEnvJSON string `json:"k8s_env_json,omitempty"` K8sResourcePreset string `json:"k8s_resource_preset"` PendingWork bool `json:"pending_work"` + + // Harness-agnostic execution fields (migration 019). + HarnessName string `json:"harness_name,omitempty"` // explicit backend; empty = auto-resolve + LocalCommand string `json:"local_command,omitempty"` // JSON-encoded argv for subprocess backend + HarnessConfigJSON string `json:"harness_config_json,omitempty"` // opaque per-backend config } diff --git a/internal/harness/harness.go b/internal/harness/harness.go new file mode 100644 index 0000000..6f77078 --- /dev/null +++ b/internal/harness/harness.go @@ -0,0 +1,170 @@ +// Package harness provides a runtime-agnostic interface for dispatching agent +// work to a concrete execution backend (Kubernetes Job, local subprocess, +// outbound webhook, or an in-memory stub for tests). It is the single seam +// between SynapBus's reactor / webhook / MCP entry points and whatever +// actually runs an agent. +// +// Inspired by GoogleCloudPlatform/scion's api.Harness interface. See +// docs/harness-otel-design.md for the full design rationale. +package harness + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/synapbus/synapbus/internal/agents" + "github.com/synapbus/synapbus/internal/messaging" +) + +// ErrNoBackend is returned by Registry.Resolve when no registered harness +// can handle the given agent. +var ErrNoBackend = errors.New("harness: no backend available for agent") + +// ErrUnknownHarness is returned when a harness name is requested but is +// not registered. +var ErrUnknownHarness = errors.New("harness: unknown harness name") + +// Capabilities advertises what a given harness backend supports so the +// caller can degrade gracefully (e.g. fall back from a system prompt to +// inline instructions when the backend cannot carry one). +type Capabilities struct { + // SystemPrompt means the backend can carry a dedicated system prompt + // separate from the user task. + SystemPrompt bool + + // SessionResume means the backend can resume a prior conversation by + // session id. When false, every Execute is a cold start. + SessionResume bool + + // Skills means the backend can materialise a set of skill files into + // the agent workspace. + Skills bool + + // OTelNative means the child process honours standard OTEL_* env vars + // (OTEL_EXPORTER_OTLP_ENDPOINT, TRACEPARENT, etc.). If true, the + // dispatcher will inject trace context via environment variables. + OTelNative bool + + // MaxConcurrency is an advisory upper bound on simultaneous Execute + // calls the backend can handle. Zero means "no explicit limit". + MaxConcurrency int +} + +// Budget bounds a single Execute call. Zero values mean "no limit". +type Budget struct { + MaxWallClock time.Duration + MaxTokensIn int64 + MaxTokensOut int64 + MaxCostUSD float64 +} + +// Usage records resource consumption for a completed run. +type Usage struct { + TokensIn int64 `json:"tokens_in"` + TokensOut int64 `json:"tokens_out"` + TokensCached int64 `json:"tokens_cached"` + CostUSD float64 `json:"cost_usd"` +} + +// ExecRequest is the single input to a harness Execute call. +type ExecRequest struct { + // RunID is a stable caller-generated id (UUID-ish). Propagated into + // the child as SYNAPBUS_RUN_ID so logs and traces correlate. + RunID string + + // AgentName is the target agent's SynapBus name. The harness may use + // it for logging and for selecting per-agent configuration. + AgentName string + + // Agent is the full agent record. Backends read per-agent config + // from it (K8sImage, LocalCommand, etc.). Nil is allowed only for + // the stub backend used in tests. + Agent *agents.Agent + + // Message is the triggering message, if any. Nil for on-demand runs + // such as admin CLI invocations. + Message *messaging.Message + + // Context is an optional conversation window the caller wants the + // child to see. Backends that support SessionResume may ignore this + // in favour of their own session state. + Context []*messaging.Message + + // SessionID, when non-empty and Capabilities.SessionResume is true, + // asks the backend to resume a prior conversation. + SessionID string + + // Budget bounds wall-clock, tokens, and cost. + Budget Budget + + // Env is a set of caller-provided environment overrides merged on + // top of whatever the backend normally injects (last write wins). + Env map[string]string + + // Skills lists skill names the backend should materialise if + // Capabilities.Skills is true. + Skills []string +} + +// ExecResult is the single output of a harness Execute call. +type ExecResult struct { + // ExitCode follows Unix convention: 0 success, non-zero failure. + // For backends without a true exit code (e.g. webhook), this is a + // synthetic value: 0 on HTTP 2xx, 1 otherwise. + ExitCode int + + // Logs is a bounded excerpt of stdout+stderr (or HTTP response body + // for the webhook backend). Full logs live on disk / remote storage. + Logs string + + // ResultJSON is an optional structured output the agent emitted. + // Subprocess agents write this to a well-known path; K8s agents + // write it to stdout or a shared volume; webhook agents return it + // in the response body. + ResultJSON json.RawMessage + + // Usage captures token / cost accounting when the backend can + // report it. Zero values mean "not reported". + Usage Usage + + // SessionID is the backend-specific session identifier for resume. + // Empty when the backend does not support SessionResume. + SessionID string + + // TraceID is the W3C trace id (hex) the run was observed under, so + // callers can link their row to a distributed trace. + TraceID string +} + +// Harness is the interface every execution backend implements. +type Harness interface { + // Name is the short stable identifier used in config (e.g. "k8sjob", + // "subprocess", "webhook", "stub"). + Name() string + + // Capabilities advertises backend features. + Capabilities() Capabilities + + // TestEnvironment is a cheap preflight: is the required binary + // installed, is auth valid, can we reach the model? Called by the + // admin CLI and by Registry.Resolve as a health gate. + TestEnvironment(ctx context.Context) error + + // Provision performs one-shot setup for a given agent (writing + // config files, pre-approving tool fingerprints, materialising + // skills). Idempotent — safe to call repeatedly. + Provision(ctx context.Context, agent *agents.Agent) error + + // Execute dispatches a single request and blocks until the run + // terminates, the context is cancelled, or the Budget is exhausted. + // Implementations must always return a non-nil *ExecResult when + // they return nil error. + Execute(ctx context.Context, req *ExecRequest) (*ExecResult, error) + + // Cancel asks the backend to abort an in-flight run with the given + // RunID. Best-effort; returns nil if the run is unknown or already + // terminated. + Cancel(ctx context.Context, runID string) error +} diff --git a/internal/harness/k8sjob/k8sjob.go b/internal/harness/k8sjob/k8sjob.go new file mode 100644 index 0000000..f89ec7f --- /dev/null +++ b/internal/harness/k8sjob/k8sjob.go @@ -0,0 +1,299 @@ +// Package k8sjob is the Kubernetes-Job-backed implementation of +// harness.Harness. It wraps the existing internal/k8s.JobRunner (used by +// the reactor today) behind the harness interface so the reactor, admin +// CLI, and future callers all go through the same seam. +package k8sjob + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "time" + + "github.com/synapbus/synapbus/internal/agents" + "github.com/synapbus/synapbus/internal/harness" + k8spkg "github.com/synapbus/synapbus/internal/k8s" +) + +// Waiter blocks until a Kubernetes Job terminates and reports the outcome. +// It is its own interface so the harness can be unit-tested with a fake. +type Waiter interface { + Wait(ctx context.Context, namespace, jobName string) (JobOutcome, error) + Cancel(ctx context.Context, namespace, jobName string) error +} + +// JobOutcome is the terminal state of a watched Kubernetes Job. +type JobOutcome struct { + Success bool + FailureReason string +} + +// Harness is the k8sjob implementation of harness.Harness. +type Harness struct { + runner k8spkg.JobRunner + waiter Waiter + logger *slog.Logger +} + +// New constructs a k8sjob harness. Waiter may be nil when IsAvailable() +// is false (NoopRunner fallback) — in that case Execute returns an error +// rather than panicking. +func New(runner k8spkg.JobRunner, waiter Waiter, logger *slog.Logger) *Harness { + if logger == nil { + logger = slog.Default() + } + return &Harness{ + runner: runner, + waiter: waiter, + logger: logger.With("harness", "k8sjob"), + } +} + +// Name returns the registered harness name. +func (h *Harness) Name() string { return "k8sjob" } + +// Capabilities advertises what the k8sjob backend supports. +func (h *Harness) Capabilities() harness.Capabilities { + return harness.Capabilities{ + SystemPrompt: false, // passed via env vars, not a dedicated slot + SessionResume: false, // each job is a cold start + Skills: false, + OTelNative: true, // child honours OTEL_* env vars + MaxConcurrency: 10, + } +} + +// TestEnvironment checks the underlying runner is available. +func (h *Harness) TestEnvironment(ctx context.Context) error { + if h.runner == nil || !h.runner.IsAvailable() { + return errors.New("k8sjob: JobRunner is not available (not running in-cluster)") + } + return nil +} + +// Provision is a no-op for k8sjob. Per-run configuration is passed +// entirely via env vars at Execute time. +func (h *Harness) Provision(ctx context.Context, agent *agents.Agent) error { + return nil +} + +// Execute builds a K8s Job for the request, waits for it to terminate, +// and returns the captured logs plus exit code. +// +// If the Waiter is nil (e.g. when constructed against a NoopRunner), the +// harness returns an error immediately rather than blocking forever. +func (h *Harness) Execute(ctx context.Context, req *harness.ExecRequest) (*harness.ExecResult, error) { + if req == nil { + return nil, errors.New("k8sjob: nil ExecRequest") + } + if req.Agent == nil { + return nil, errors.New("k8sjob: ExecRequest.Agent is required") + } + if err := h.TestEnvironment(ctx); err != nil { + return nil, err + } + if h.waiter == nil { + return nil, errors.New("k8sjob: no Waiter configured") + } + + handler := BuildHandler(req.Agent) + // Merge caller-provided env overrides and run metadata on top of + // whatever the handler already carries. Last write wins. + if handler.Env == nil { + handler.Env = map[string]string{} + } + handler.Env["SYNAPBUS_RUN_ID"] = req.RunID + for k, v := range req.Env { + handler.Env[k] = v + } + + msg := buildJobMessage(req) + if req.Budget.MaxWallClock > 0 { + handler.TimeoutSeconds = int(req.Budget.MaxWallClock.Seconds()) + } + + jobName, err := h.runner.CreateJob(ctx, handler, msg) + if err != nil { + return nil, fmt.Errorf("k8sjob: create job: %w", err) + } + + ns := handler.Namespace + if ns == "" { + ns = h.runner.GetNamespace() + } + + h.logger.Info("k8sjob launched", + "job_name", jobName, + "namespace", ns, + "agent", req.AgentName, + "run_id", req.RunID, + ) + + outcome, err := h.waiter.Wait(ctx, ns, jobName) + if err != nil { + // Fetch whatever logs we can before bailing out. + logs, _ := h.runner.GetJobLogs(ctx, ns, jobName) + return &harness.ExecResult{ + ExitCode: 2, // distinguishable from plain "failed" (exit=1) + Logs: trimLogs(logs, 128), + }, fmt.Errorf("k8sjob: wait: %w", err) + } + + logs, logErr := h.runner.GetJobLogs(ctx, ns, jobName) + if logErr != nil { + h.logger.Warn("k8sjob: fetch logs failed", + "job_name", jobName, "error", logErr, + ) + } + + res := &harness.ExecResult{ + ExitCode: exitCodeFromOutcome(outcome), + Logs: trimLogs(logs, 128), + } + if !outcome.Success && outcome.FailureReason != "" { + // Surface the K8s-reported reason in the logs excerpt so + // callers writing to harness_runs can see both. + if res.Logs != "" { + res.Logs = outcome.FailureReason + "\n\n" + res.Logs + } else { + res.Logs = outcome.FailureReason + } + } + + // Best-effort: parse the tail of stdout as JSON (common pattern for + // agents emitting a final result envelope). If it parses, stash it. + if rj := extractResultJSON(logs); rj != nil { + res.ResultJSON = rj + } + return res, nil +} + +// Cancel deletes the K8s Job whose name equals runID. This is the +// convention used by SynapBus today: the harness returns the K8s Job +// name as the run identifier, so callers can cancel by run id. +func (h *Harness) Cancel(ctx context.Context, runID string) error { + if h.waiter == nil { + return errors.New("k8sjob: no Waiter configured") + } + ns := "" + if h.runner != nil { + ns = h.runner.GetNamespace() + } + return h.waiter.Cancel(ctx, ns, runID) +} + +// -- helpers -------------------------------------------------------------- + +// BuildHandler constructs a K8sHandler from an agent config. Exported so +// the reactor can share the exact same logic when it eventually routes +// through the harness registry. +func BuildHandler(agent *agents.Agent) *k8spkg.K8sHandler { + env := map[string]string{} + + if agent.K8sEnvJSON != "" { + var envMap map[string]json.RawMessage + if err := json.Unmarshal([]byte(agent.K8sEnvJSON), &envMap); err == nil { + for k, v := range envMap { + var s string + if err := json.Unmarshal(v, &s); err == nil { + env[k] = s + continue + } + env[k] = strings.Trim(string(v), "\"") + } + } + } + + memory := "2Gi" + cpu := "500m" + if agent.K8sResourcePreset == "small" { + memory = "512Mi" + cpu = "100m" + } + + handler := &k8spkg.K8sHandler{ + AgentName: agent.Name, + Image: agent.K8sImage, + Events: []string{"message.received", "message.mentioned"}, + ResourcesMemory: memory, + ResourcesCPU: cpu, + Env: env, + TimeoutSeconds: 3600, + Status: "active", + Args: []string{"--max-turns", "50", "--model", "claude-sonnet-4-6"}, + VolumeMounts: []k8spkg.VolumeMount{ + {Name: "claude-config", MountPath: "/app/.claude", ReadOnly: false}, + {Name: "workspace", MountPath: "/app/workspace", ReadOnly: false}, + }, + Volumes: []k8spkg.Volume{ + {Name: "claude-config", HostPath: "/home/user/.claude"}, + {Name: "workspace", EmptyDir: true}, + }, + } + + if agent.Name == "social-commenter" { + handler.Args = []string{"--max-turns", "80", "--model", "claude-opus-4-6"} + } + + return handler +} + +func buildJobMessage(req *harness.ExecRequest) *k8spkg.JobMessage { + m := &k8spkg.JobMessage{ + Timestamp: time.Now().UTC().Format(time.RFC3339), + } + if req.Message != nil { + m.MessageID = req.Message.ID + m.FromAgent = req.Message.FromAgent + m.Body = req.Message.Body + } + return m +} + +func exitCodeFromOutcome(o JobOutcome) int { + if o.Success { + return 0 + } + return 1 +} + +// trimLogs keeps the last n lines. Consistent with reactor poller's +// existing 100-line cap; we default a bit higher here. +func trimLogs(s string, n int) string { + if s == "" || n <= 0 { + return s + } + lines := strings.Split(s, "\n") + if len(lines) <= n { + return s + } + return strings.Join(lines[len(lines)-n:], "\n") +} + +// extractResultJSON looks for the last non-empty line of logs and tries +// to parse it as a JSON object. Returns nil on failure (very common — +// agents may not emit a result envelope at all). +func extractResultJSON(logs string) json.RawMessage { + if logs == "" { + return nil + } + lines := strings.Split(strings.TrimRight(logs, "\n"), "\n") + for i := len(lines) - 1; i >= 0; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" { + continue + } + if len(line) < 2 || line[0] != '{' { + return nil + } + var probe any + if err := json.Unmarshal([]byte(line), &probe); err != nil { + return nil + } + return json.RawMessage(line) + } + return nil +} diff --git a/internal/harness/k8sjob/k8sjob_test.go b/internal/harness/k8sjob/k8sjob_test.go new file mode 100644 index 0000000..3b6f8a3 --- /dev/null +++ b/internal/harness/k8sjob/k8sjob_test.go @@ -0,0 +1,320 @@ +package k8sjob_test + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/synapbus/synapbus/internal/agents" + "github.com/synapbus/synapbus/internal/harness" + "github.com/synapbus/synapbus/internal/harness/k8sjob" + k8spkg "github.com/synapbus/synapbus/internal/k8s" + "github.com/synapbus/synapbus/internal/messaging" +) + +// fakeRunner is a copy of the reactor test's fakeRunner — wrapping +// k8spkg.JobRunner so we don't pull in a real clientset. +type fakeRunner struct { + available bool + lastEnv map[string]string + lastHandler *k8spkg.K8sHandler + lastMessage *k8spkg.JobMessage + createErr error + logs string + logsErr error +} + +func (f *fakeRunner) IsAvailable() bool { return f.available } +func (f *fakeRunner) GetNamespace() string { return "test-ns" } +func (f *fakeRunner) GetJobLogs(_ context.Context, _, _ string) (string, error) { + return f.logs, f.logsErr +} + +func (f *fakeRunner) CreateJob(_ context.Context, handler *k8spkg.K8sHandler, msg *k8spkg.JobMessage) (string, error) { + if f.createErr != nil { + return "", f.createErr + } + f.lastHandler = handler + f.lastMessage = msg + f.lastEnv = make(map[string]string, len(handler.Env)) + for k, v := range handler.Env { + f.lastEnv[k] = v + } + return "synapbus-" + handler.AgentName + "-job", nil +} + +type fakeWaiter struct { + outcome k8sjob.JobOutcome + err error + delay time.Duration + + cancelCalls []string +} + +func (w *fakeWaiter) Wait(ctx context.Context, ns, jobName string) (k8sjob.JobOutcome, error) { + if w.delay > 0 { + select { + case <-time.After(w.delay): + case <-ctx.Done(): + return k8sjob.JobOutcome{}, ctx.Err() + } + } + return w.outcome, w.err +} + +func (w *fakeWaiter) Cancel(_ context.Context, _ string, jobName string) error { + w.cancelCalls = append(w.cancelCalls, jobName) + return nil +} + +func newTestAgent(name, image string) *agents.Agent { + return &agents.Agent{ + Name: name, + K8sImage: image, + K8sResourcePreset: "default", + } +} + +func TestHarness_ImplementsInterface(t *testing.T) { + var _ harness.Harness = (*k8sjob.Harness)(nil) +} + +func TestHarness_NameAndCapabilities(t *testing.T) { + h := k8sjob.New(nil, nil, nil) + if h.Name() != "k8sjob" { + t.Fatalf("Name = %q", h.Name()) + } + caps := h.Capabilities() + if !caps.OTelNative { + t.Fatal("expected OTelNative = true") + } + if caps.MaxConcurrency == 0 { + t.Fatal("expected non-zero MaxConcurrency") + } +} + +func TestHarness_TestEnvironment_NoRunner(t *testing.T) { + h := k8sjob.New(nil, nil, nil) + if err := h.TestEnvironment(context.Background()); err == nil { + t.Fatal("expected error for nil runner") + } +} + +func TestHarness_TestEnvironment_Unavailable(t *testing.T) { + h := k8sjob.New(&fakeRunner{available: false}, nil, nil) + if err := h.TestEnvironment(context.Background()); err == nil { + t.Fatal("expected error for unavailable runner") + } +} + +func TestHarness_TestEnvironment_OK(t *testing.T) { + h := k8sjob.New(&fakeRunner{available: true}, nil, nil) + if err := h.TestEnvironment(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestHarness_Provision_NoOp(t *testing.T) { + h := k8sjob.New(&fakeRunner{available: true}, nil, nil) + if err := h.Provision(context.Background(), newTestAgent("a", "foo:v1")); err != nil { + t.Fatalf("Provision err = %v, want nil", err) + } +} + +func TestHarness_Execute_Success(t *testing.T) { + runner := &fakeRunner{ + available: true, + logs: "hello world\n{\"ok\":true,\"tokens\":42}", + } + waiter := &fakeWaiter{outcome: k8sjob.JobOutcome{Success: true}} + h := k8sjob.New(runner, waiter, nil) + + req := &harness.ExecRequest{ + RunID: "r-1", + AgentName: "researcher", + Agent: newTestAgent("researcher", "ghcr.io/example/agent:v1"), + Message: &messaging.Message{ + ID: 42, + FromAgent: "human", + Body: "do the thing", + }, + Env: map[string]string{"EXTRA": "yes"}, + } + + res, err := h.Execute(context.Background(), req) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if res.ExitCode != 0 { + t.Errorf("ExitCode = %d, want 0", res.ExitCode) + } + if !strings.Contains(res.Logs, "hello world") { + t.Errorf("logs missing stdout: %q", res.Logs) + } + if string(res.ResultJSON) == "" { + t.Errorf("ResultJSON empty, want parsed envelope") + } + if !strings.Contains(string(res.ResultJSON), "\"ok\":true") { + t.Errorf("ResultJSON not parsed: %s", res.ResultJSON) + } + + // Verify env propagation + if runner.lastEnv["SYNAPBUS_RUN_ID"] != "r-1" { + t.Errorf("SYNAPBUS_RUN_ID = %q, want r-1", runner.lastEnv["SYNAPBUS_RUN_ID"]) + } + if runner.lastEnv["EXTRA"] != "yes" { + t.Errorf("EXTRA env not propagated: %v", runner.lastEnv) + } + // Verify JobMessage carried message context + if runner.lastMessage.MessageID != 42 { + t.Errorf("MessageID = %d", runner.lastMessage.MessageID) + } + if runner.lastMessage.FromAgent != "human" { + t.Errorf("FromAgent = %q", runner.lastMessage.FromAgent) + } +} + +func TestHarness_Execute_Failure(t *testing.T) { + runner := &fakeRunner{ + available: true, + logs: "error: file not found", + } + waiter := &fakeWaiter{ + outcome: k8sjob.JobOutcome{Success: false, FailureReason: "BackoffLimitExceeded"}, + } + h := k8sjob.New(runner, waiter, nil) + + req := &harness.ExecRequest{ + RunID: "r-2", + AgentName: "researcher", + Agent: newTestAgent("researcher", "ghcr.io/example/agent:v1"), + } + res, err := h.Execute(context.Background(), req) + if err != nil { + t.Fatalf("Execute err = %v, want nil for graceful failure", err) + } + if res.ExitCode != 1 { + t.Errorf("ExitCode = %d, want 1", res.ExitCode) + } + if !strings.Contains(res.Logs, "BackoffLimitExceeded") { + t.Errorf("logs missing failure reason: %q", res.Logs) + } +} + +func TestHarness_Execute_CreateJobError(t *testing.T) { + runner := &fakeRunner{ + available: true, + createErr: errors.New("api server down"), + } + h := k8sjob.New(runner, &fakeWaiter{}, nil) + _, err := h.Execute(context.Background(), &harness.ExecRequest{ + RunID: "r", AgentName: "a", Agent: newTestAgent("a", "foo:v1"), + }) + if err == nil || !strings.Contains(err.Error(), "create job") { + t.Fatalf("err = %v, want create job error", err) + } +} + +func TestHarness_Execute_NilAgent(t *testing.T) { + h := k8sjob.New(&fakeRunner{available: true}, &fakeWaiter{}, nil) + _, err := h.Execute(context.Background(), &harness.ExecRequest{RunID: "r"}) + if err == nil { + t.Fatal("expected error for nil agent") + } +} + +func TestHarness_Execute_NilRequest(t *testing.T) { + h := k8sjob.New(&fakeRunner{available: true}, &fakeWaiter{}, nil) + _, err := h.Execute(context.Background(), nil) + if err == nil { + t.Fatal("expected error for nil request") + } +} + +func TestHarness_Execute_BudgetOverridesTimeout(t *testing.T) { + runner := &fakeRunner{available: true} + waiter := &fakeWaiter{outcome: k8sjob.JobOutcome{Success: true}} + h := k8sjob.New(runner, waiter, nil) + + req := &harness.ExecRequest{ + RunID: "r", + AgentName: "a", + Agent: newTestAgent("a", "foo:v1"), + Budget: harness.Budget{MaxWallClock: 90 * time.Second}, + } + _, err := h.Execute(context.Background(), req) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if runner.lastHandler.TimeoutSeconds != 90 { + t.Errorf("TimeoutSeconds = %d, want 90", runner.lastHandler.TimeoutSeconds) + } +} + +func TestHarness_Execute_ContextCancel(t *testing.T) { + runner := &fakeRunner{available: true} + waiter := &fakeWaiter{ + outcome: k8sjob.JobOutcome{Success: true}, + delay: 500 * time.Millisecond, + } + h := k8sjob.New(runner, waiter, nil) + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + + _, err := h.Execute(ctx, &harness.ExecRequest{ + RunID: "r", AgentName: "a", Agent: newTestAgent("a", "foo:v1"), + }) + if err == nil { + t.Fatal("expected error when context cancelled before wait completes") + } +} + +func TestHarness_Cancel_PropagatesToWaiter(t *testing.T) { + runner := &fakeRunner{available: true} + waiter := &fakeWaiter{} + h := k8sjob.New(runner, waiter, nil) + if err := h.Cancel(context.Background(), "my-job"); err != nil { + t.Fatalf("Cancel err = %v", err) + } + if len(waiter.cancelCalls) != 1 || waiter.cancelCalls[0] != "my-job" { + t.Fatalf("cancelCalls = %v", waiter.cancelCalls) + } +} + +func TestBuildHandler_AppliesResourcePreset(t *testing.T) { + a := newTestAgent("small-agent", "x:v1") + a.K8sResourcePreset = "small" + h := k8sjob.BuildHandler(a) + if h.ResourcesMemory != "512Mi" || h.ResourcesCPU != "100m" { + t.Errorf("small preset: got mem=%s cpu=%s", h.ResourcesMemory, h.ResourcesCPU) + } +} + +func TestBuildHandler_SocialCommenterUsesOpus(t *testing.T) { + a := newTestAgent("social-commenter", "x:v1") + h := k8sjob.BuildHandler(a) + found := false + for _, arg := range h.Args { + if arg == "claude-opus-4-6" { + found = true + } + } + if !found { + t.Errorf("social-commenter args = %v, want opus", h.Args) + } +} + +func TestBuildHandler_ParsesK8sEnvJSON(t *testing.T) { + a := newTestAgent("a", "x:v1") + a.K8sEnvJSON = `{"GIT_REPO":"owner/repo","OTHER":"val"}` + h := k8sjob.BuildHandler(a) + if h.Env["GIT_REPO"] != "owner/repo" { + t.Errorf("GIT_REPO = %q", h.Env["GIT_REPO"]) + } + if h.Env["OTHER"] != "val" { + t.Errorf("OTHER = %q", h.Env["OTHER"]) + } +} diff --git a/internal/harness/k8sjob/waiter.go b/internal/harness/k8sjob/waiter.go new file mode 100644 index 0000000..a3724de --- /dev/null +++ b/internal/harness/k8sjob/waiter.go @@ -0,0 +1,98 @@ +package k8sjob + +import ( + "context" + "fmt" + "time" + + batchv1 "k8s.io/api/batch/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// ClientsetWaiter is a Waiter backed by a real k8s.io/client-go clientset. +// It polls Job status on an interval (default 10s) until the Job reports +// Complete, Failed, or the context is cancelled. +type ClientsetWaiter struct { + Clientset kubernetes.Interface + Interval time.Duration +} + +// NewClientsetWaiter constructs a Waiter from an existing clientset. +// Interval defaults to 10 seconds when zero. +func NewClientsetWaiter(cs kubernetes.Interface, interval time.Duration) *ClientsetWaiter { + if interval <= 0 { + interval = 10 * time.Second + } + return &ClientsetWaiter{Clientset: cs, Interval: interval} +} + +// Wait blocks until the named Job enters a terminal state or the context +// is cancelled. The returned JobOutcome has Success=true only when the +// Job's JobComplete condition is True. +func (w *ClientsetWaiter) Wait(ctx context.Context, namespace, jobName string) (JobOutcome, error) { + ticker := time.NewTicker(w.Interval) + defer ticker.Stop() + + // Do an immediate first check so short-running jobs return fast in + // tests (interval may be set to a small value). + if outcome, done, err := w.check(ctx, namespace, jobName); done || err != nil { + return outcome, err + } + + for { + select { + case <-ctx.Done(): + return JobOutcome{}, ctx.Err() + case <-ticker.C: + outcome, done, err := w.check(ctx, namespace, jobName) + if err != nil { + return JobOutcome{}, err + } + if done { + return outcome, nil + } + } + } +} + +func (w *ClientsetWaiter) check(ctx context.Context, namespace, jobName string) (JobOutcome, bool, error) { + job, err := w.Clientset.BatchV1().Jobs(namespace).Get(ctx, jobName, metav1.GetOptions{}) + if err != nil { + return JobOutcome{}, false, fmt.Errorf("get job %s/%s: %w", namespace, jobName, err) + } + for _, cond := range job.Status.Conditions { + if cond.Status != "True" { + continue + } + switch cond.Type { + case batchv1.JobComplete: + return JobOutcome{Success: true}, true, nil + case batchv1.JobFailed: + reason := cond.Reason + if cond.Message != "" { + if reason != "" { + reason += ": " + } + reason += cond.Message + } + return JobOutcome{Success: false, FailureReason: reason}, true, nil + } + } + if job.Status.Failed > 0 { + return JobOutcome{Success: false, FailureReason: "pod failure"}, true, nil + } + return JobOutcome{}, false, nil +} + +// Cancel deletes the Job (and its pods) by name. Used by harness.Cancel. +func (w *ClientsetWaiter) Cancel(ctx context.Context, namespace, jobName string) error { + propagation := metav1.DeletePropagationBackground + err := w.Clientset.BatchV1().Jobs(namespace).Delete(ctx, jobName, metav1.DeleteOptions{ + PropagationPolicy: &propagation, + }) + if err != nil { + return fmt.Errorf("delete job %s/%s: %w", namespace, jobName, err) + } + return nil +} diff --git a/internal/harness/registry.go b/internal/harness/registry.go new file mode 100644 index 0000000..4f729ab --- /dev/null +++ b/internal/harness/registry.go @@ -0,0 +1,183 @@ +package harness + +import ( + "context" + "fmt" + "sync" + + "github.com/synapbus/synapbus/internal/agents" + "github.com/synapbus/synapbus/internal/observability" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// Observer is called on every Registry.Execute so callers can persist +// harness_runs rows, emit metrics, or drive side-effects without +// coupling the core registry to storage. OnStart runs after the backend +// has been resolved but before Execute, so the observer knows the +// backend name. OnFinish runs after Execute returns, whether it +// succeeded or failed. +type Observer interface { + OnStart(ctx context.Context, agent *agents.Agent, harnessName string, req *ExecRequest) + OnFinish(ctx context.Context, agent *agents.Agent, harnessName string, req *ExecRequest, res *ExecResult, err error) +} + +// Registry holds the set of available Harness implementations and resolves +// the right backend for a given agent. It is safe for concurrent use. +type Registry struct { + mu sync.RWMutex + byName map[string]Harness + + // ResolveFn, if non-nil, overrides the default resolution policy. + // The default picks by agent.HarnessName → k8sjob → webhook → + // subprocess in that order. + ResolveFn func(r *Registry, agent *agents.Agent) (Harness, error) + + // Observer, if non-nil, is notified on every Execute. + Observer Observer +} + +// NewRegistry returns an empty registry. +func NewRegistry() *Registry { + return &Registry{byName: map[string]Harness{}} +} + +// Register adds a harness under its Name(). A second Register with the +// same name replaces the first — tests rely on this to swap in a stub. +func (r *Registry) Register(h Harness) { + r.mu.Lock() + defer r.mu.Unlock() + r.byName[h.Name()] = h +} + +// Get returns the harness registered under name, or ErrUnknownHarness. +func (r *Registry) Get(name string) (Harness, error) { + r.mu.RLock() + defer r.mu.RUnlock() + h, ok := r.byName[name] + if !ok { + return nil, fmt.Errorf("%w: %q", ErrUnknownHarness, name) + } + return h, nil +} + +// Names returns the registered harness names in no particular order. +func (r *Registry) Names() []string { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]string, 0, len(r.byName)) + for k := range r.byName { + out = append(out, k) + } + return out +} + +// Resolve picks the right backend for an agent. +// +// Default policy: +// 1. If ResolveFn is set, delegate to it. +// 2. Else if agent has a non-empty HarnessName that is registered, +// use it (explicit wins). +// 3. Else try "k8sjob" if registered and the agent has K8sImage set. +// 4. Else try "webhook" if registered. +// 5. Else try "subprocess" if registered. +// 6. Else ErrNoBackend. +// +// Note: the agent-level HarnessName/LocalCommand fields don't yet exist +// on agents.Agent — Phase 3 adds them via migration 016. Until then the +// resolver falls back to the legacy "agent has k8s_image" heuristic. +func (r *Registry) Resolve(agent *agents.Agent) (Harness, error) { + if r.ResolveFn != nil { + return r.ResolveFn(r, agent) + } + r.mu.RLock() + defer r.mu.RUnlock() + + if agent != nil && agent.K8sImage != "" { + if h, ok := r.byName["k8sjob"]; ok { + return h, nil + } + } + if h, ok := r.byName["webhook"]; ok { + return h, nil + } + if h, ok := r.byName["subprocess"]; ok { + return h, nil + } + return nil, fmt.Errorf("%w: agent=%q", ErrNoBackend, agentNameOf(agent)) +} + +func agentNameOf(a *agents.Agent) string { + if a == nil { + return "" + } + return a.Name +} + +// Execute is the single entry point the reactor uses: resolve a backend, +// call its Execute, return the result. Trace spans, env injection, and +// harness_runs rows are layered on top of this by higher packages so the +// core registry stays a thin dispatcher. +func (r *Registry) Execute(ctx context.Context, agent *agents.Agent, req *ExecRequest) (*ExecResult, error) { + tracer := otel.Tracer(observability.TracerName) + ctx, span := tracer.Start(ctx, "harness.execute", + trace.WithAttributes( + attribute.String("agent.name", agentNameOf(agent)), + attribute.String("run.id", req.RunID), + ), + ) + defer span.End() + + h, err := r.Resolve(agent) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + if r.Observer != nil { + r.Observer.OnFinish(ctx, agent, "", req, nil, err) + } + return nil, err + } + span.SetAttributes(attribute.String("harness.name", h.Name())) + + if req.Agent == nil { + req.Agent = agent + } + if req.Env == nil { + req.Env = map[string]string{} + } + observability.InjectTraceContext(ctx, req.Env) + + if r.Observer != nil { + r.Observer.OnStart(ctx, agent, h.Name(), req) + } + + res, err := h.Execute(ctx, req) + + if r.Observer != nil { + r.Observer.OnFinish(ctx, agent, h.Name(), req, res, err) + } + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return res, err + } + if res != nil { + span.SetAttributes( + attribute.Int("exit.code", res.ExitCode), + attribute.Int64("usage.tokens_in", res.Usage.TokensIn), + attribute.Int64("usage.tokens_out", res.Usage.TokensOut), + attribute.Float64("usage.cost_usd", res.Usage.CostUSD), + ) + if res.TraceID == "" { + res.TraceID = observability.TraceIDFromContext(ctx) + } + if res.ExitCode != 0 { + span.SetStatus(codes.Error, fmt.Sprintf("exit code %d", res.ExitCode)) + } else { + span.SetStatus(codes.Ok, "") + } + } + return res, nil +} diff --git a/internal/harness/registry_otel_test.go b/internal/harness/registry_otel_test.go new file mode 100644 index 0000000..4c0a69f --- /dev/null +++ b/internal/harness/registry_otel_test.go @@ -0,0 +1,131 @@ +package harness_test + +import ( + "context" + "errors" + "testing" + + "github.com/synapbus/synapbus/internal/agents" + "github.com/synapbus/synapbus/internal/harness" + "github.com/synapbus/synapbus/internal/harness/stub" + "github.com/synapbus/synapbus/internal/observability" + + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +func withTracer(t *testing.T) *tracetest.InMemoryExporter { + t.Helper() + exp := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(exp)) + otel.SetTracerProvider(tp) + // Install propagator so InjectTraceContext emits TRACEPARENT. + _, _ = observability.Init(context.Background(), observability.Config{Enabled: false}, nil) + t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) + return exp +} + +func TestRegistry_Execute_EmitsSpan(t *testing.T) { + exp := withTracer(t) + + r := harness.NewRegistry() + s := stub.New() + s.NameStr = "subprocess" + r.Register(s) + + _, err := r.Execute(context.Background(), &agents.Agent{Name: "a"}, &harness.ExecRequest{RunID: "run-x"}) + if err != nil { + t.Fatalf("Execute err = %v", err) + } + + spans := exp.GetSpans() + if len(spans) == 0 { + t.Fatal("no spans recorded") + } + found := false + for _, sp := range spans { + if sp.Name == "harness.execute" { + found = true + attrs := map[string]string{} + for _, a := range sp.Attributes { + attrs[string(a.Key)] = a.Value.Emit() + } + if attrs["agent.name"] != "a" { + t.Errorf("agent.name attr = %q", attrs["agent.name"]) + } + if attrs["run.id"] != "run-x" { + t.Errorf("run.id attr = %q", attrs["run.id"]) + } + if attrs["harness.name"] != "subprocess" { + t.Errorf("harness.name attr = %q", attrs["harness.name"]) + } + } + } + if !found { + t.Fatalf("span harness.execute not found in %v", spans) + } +} + +func TestRegistry_Execute_InjectsTraceContextIntoEnv(t *testing.T) { + _ = withTracer(t) + + r := harness.NewRegistry() + s := stub.New() + s.NameStr = "subprocess" + r.Register(s) + + _, err := r.Execute(context.Background(), &agents.Agent{Name: "a"}, &harness.ExecRequest{RunID: "run-x"}) + if err != nil { + t.Fatalf("Execute err = %v", err) + } + if len(s.Calls) != 1 { + t.Fatalf("stub calls = %d", len(s.Calls)) + } + env := s.Calls[0].Env + if _, ok := env["TRACEPARENT"]; !ok { + t.Errorf("TRACEPARENT not injected into req.Env: %v", env) + } +} + +func TestRegistry_Execute_SpanRecordsError(t *testing.T) { + exp := withTracer(t) + + r := harness.NewRegistry() + s := stub.New() + s.NameStr = "subprocess" + s.Err = errors.New("boom") + r.Register(s) + + _, err := r.Execute(context.Background(), &agents.Agent{Name: "a"}, &harness.ExecRequest{RunID: "run-x"}) + if err == nil { + t.Fatal("expected error") + } + spans := exp.GetSpans() + var statuses []string + for _, sp := range spans { + if sp.Name == "harness.execute" { + statuses = append(statuses, sp.Status.Code.String()) + } + } + if len(statuses) == 0 || statuses[0] != "Error" { + t.Errorf("span statuses = %v, want [Error]", statuses) + } +} + +func TestRegistry_Execute_PopulatesResultTraceID(t *testing.T) { + _ = withTracer(t) + + r := harness.NewRegistry() + s := stub.New() + s.NameStr = "subprocess" + r.Register(s) + + res, err := r.Execute(context.Background(), &agents.Agent{Name: "a"}, &harness.ExecRequest{RunID: "run-x"}) + if err != nil { + t.Fatalf("Execute err = %v", err) + } + if res.TraceID == "" { + t.Error("ExecResult.TraceID empty after registry execute") + } +} diff --git a/internal/harness/registry_test.go b/internal/harness/registry_test.go new file mode 100644 index 0000000..c03cde6 --- /dev/null +++ b/internal/harness/registry_test.go @@ -0,0 +1,213 @@ +package harness_test + +import ( + "context" + "errors" + "testing" + + "github.com/synapbus/synapbus/internal/agents" + "github.com/synapbus/synapbus/internal/harness" + "github.com/synapbus/synapbus/internal/harness/stub" +) + +func TestRegistry_RegisterAndGet(t *testing.T) { + r := harness.NewRegistry() + s := stub.New() + s.NameStr = "stub" + r.Register(s) + + got, err := r.Get("stub") + if err != nil { + t.Fatalf("Get(stub) error: %v", err) + } + if got != s { + t.Fatalf("Get returned %v, want %v", got, s) + } + + if _, err := r.Get("missing"); !errors.Is(err, harness.ErrUnknownHarness) { + t.Fatalf("Get(missing) error = %v, want ErrUnknownHarness", err) + } +} + +func TestRegistry_Names(t *testing.T) { + r := harness.NewRegistry() + a := stub.New() + a.NameStr = "a" + b := stub.New() + b.NameStr = "b" + r.Register(a) + r.Register(b) + + names := r.Names() + if len(names) != 2 { + t.Fatalf("Names len=%d, want 2 (%v)", len(names), names) + } + seen := map[string]bool{} + for _, n := range names { + seen[n] = true + } + if !seen["a"] || !seen["b"] { + t.Fatalf("Names missing entries: %v", names) + } +} + +func TestRegistry_RegisterReplaces(t *testing.T) { + r := harness.NewRegistry() + first := stub.New() + first.NameStr = "stub" + second := stub.New() + second.NameStr = "stub" + r.Register(first) + r.Register(second) + + got, err := r.Get("stub") + if err != nil { + t.Fatalf("Get error: %v", err) + } + if got != second { + t.Fatalf("replacement failed: got %v, want %v", got, second) + } +} + +func TestRegistry_Resolve_K8sImageWinsWhenRegistered(t *testing.T) { + r := harness.NewRegistry() + k8s := stub.New() + k8s.NameStr = "k8sjob" + web := stub.New() + web.NameStr = "webhook" + r.Register(k8s) + r.Register(web) + + a := &agents.Agent{Name: "a", K8sImage: "ghcr.io/foo/bar:v1"} + got, err := r.Resolve(a) + if err != nil { + t.Fatalf("Resolve error: %v", err) + } + if got != k8s { + t.Fatalf("Resolve picked %v, want k8sjob", got.Name()) + } +} + +func TestRegistry_Resolve_FallsBackToWebhook(t *testing.T) { + r := harness.NewRegistry() + web := stub.New() + web.NameStr = "webhook" + r.Register(web) + + a := &agents.Agent{Name: "a"} + got, err := r.Resolve(a) + if err != nil { + t.Fatalf("Resolve error: %v", err) + } + if got != web { + t.Fatalf("Resolve picked %v, want webhook", got.Name()) + } +} + +func TestRegistry_Resolve_FallsBackToSubprocess(t *testing.T) { + r := harness.NewRegistry() + sub := stub.New() + sub.NameStr = "subprocess" + r.Register(sub) + + a := &agents.Agent{Name: "a"} + got, err := r.Resolve(a) + if err != nil { + t.Fatalf("Resolve error: %v", err) + } + if got != sub { + t.Fatalf("Resolve picked %v, want subprocess", got.Name()) + } +} + +func TestRegistry_Resolve_ErrNoBackend(t *testing.T) { + r := harness.NewRegistry() + a := &agents.Agent{Name: "a"} + if _, err := r.Resolve(a); !errors.Is(err, harness.ErrNoBackend) { + t.Fatalf("err = %v, want ErrNoBackend", err) + } +} + +func TestRegistry_Resolve_K8sImageSetButNoK8sBackend_FallsThrough(t *testing.T) { + // Agent has k8s_image but no k8sjob backend is registered — should + // fall through to webhook/subprocess, not fail. + r := harness.NewRegistry() + web := stub.New() + web.NameStr = "webhook" + r.Register(web) + + a := &agents.Agent{Name: "a", K8sImage: "foo:v1"} + got, err := r.Resolve(a) + if err != nil { + t.Fatalf("Resolve error: %v", err) + } + if got != web { + t.Fatalf("Resolve picked %v, want webhook", got.Name()) + } +} + +func TestRegistry_Resolve_CustomResolveFn(t *testing.T) { + r := harness.NewRegistry() + a := stub.New() + a.NameStr = "a" + b := stub.New() + b.NameStr = "b" + r.Register(a) + r.Register(b) + + r.ResolveFn = func(r *harness.Registry, agent *agents.Agent) (harness.Harness, error) { + return r.Get("b") + } + + got, err := r.Resolve(&agents.Agent{Name: "x", K8sImage: "foo:v1"}) + if err != nil { + t.Fatalf("Resolve error: %v", err) + } + if got != b { + t.Fatalf("Resolve picked %v, want b", got.Name()) + } +} + +func TestRegistry_Execute_DelegatesToResolvedHarness(t *testing.T) { + r := harness.NewRegistry() + s := stub.New() + s.NameStr = "subprocess" + r.Register(s) + + req := &harness.ExecRequest{RunID: "run-1", AgentName: "a"} + res, err := r.Execute(context.Background(), &agents.Agent{Name: "a"}, req) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if res == nil { + t.Fatal("Execute returned nil result") + } + if len(s.Calls) != 1 { + t.Fatalf("stub got %d calls, want 1", len(s.Calls)) + } + if s.Calls[0].RunID != "run-1" { + t.Fatalf("stub call RunID = %q, want run-1", s.Calls[0].RunID) + } +} + +func TestRegistry_Execute_PropagatesError(t *testing.T) { + r := harness.NewRegistry() + s := stub.New() + s.NameStr = "subprocess" + wantErr := errors.New("boom") + s.Err = wantErr + r.Register(s) + + _, err := r.Execute(context.Background(), &agents.Agent{Name: "a"}, &harness.ExecRequest{RunID: "r"}) + if !errors.Is(err, wantErr) { + t.Fatalf("err = %v, want %v", err, wantErr) + } +} + +func TestRegistry_Execute_NoBackend(t *testing.T) { + r := harness.NewRegistry() + _, err := r.Execute(context.Background(), &agents.Agent{Name: "a"}, &harness.ExecRequest{RunID: "r"}) + if !errors.Is(err, harness.ErrNoBackend) { + t.Fatalf("err = %v, want ErrNoBackend", err) + } +} diff --git a/internal/harness/runs/store.go b/internal/harness/runs/store.go new file mode 100644 index 0000000..12f236e --- /dev/null +++ b/internal/harness/runs/store.go @@ -0,0 +1,335 @@ +// Package runs persists harness execution records to SQLite. It +// implements harness.Observer so it can be wired into the Registry +// without the harness core depending on storage. +package runs + +import ( + "context" + "database/sql" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/synapbus/synapbus/internal/agents" + "github.com/synapbus/synapbus/internal/harness" + "github.com/synapbus/synapbus/internal/observability" +) + +// Run is one row of the harness_runs table. +type Run struct { + ID int64 + RunID string + AgentName string + Backend string + MessageID *int64 + Status string + ExitCode *int + TraceID string + SpanID string + SessionID string + TokensIn int64 + TokensOut int64 + TokensCached int64 + CostUSD float64 + DurationMs *int64 + ResultJSON string + LogsExcerpt string + CreatedAt time.Time + FinishedAt *time.Time +} + +// Status constants match the harness_runs.status column domain. +const ( + StatusPending = "pending" + StatusRunning = "running" + StatusSuccess = "success" + StatusFailed = "failed" + StatusCancelled = "cancelled" + StatusTimeout = "timeout" +) + +// Store is the SQLite-backed harness_runs store. It also tracks start +// timestamps in memory so OnFinish can compute duration without needing +// the caller to pass it. +type Store struct { + db *sql.DB + logger *slog.Logger + + mu sync.Mutex + start map[string]time.Time // runID → start time +} + +// New constructs a Store. Safe for concurrent use. +func New(db *sql.DB, logger *slog.Logger) *Store { + if logger == nil { + logger = slog.Default() + } + return &Store{ + db: db, + logger: logger.With("component", "harness-runs"), + start: map[string]time.Time{}, + } +} + +// Compile-time check: Store satisfies harness.Observer. +var _ harness.Observer = (*Store)(nil) + +// OnStart writes a 'running' row for the run. Errors are logged, not +// returned, so storage issues never block Execute. +func (s *Store) OnStart(ctx context.Context, agent *agents.Agent, harnessName string, req *harness.ExecRequest) { + s.mu.Lock() + s.start[req.RunID] = time.Now().UTC() + s.mu.Unlock() + + var msgID *int64 + if req.Message != nil { + id := req.Message.ID + msgID = &id + } + + _, err := s.db.ExecContext(ctx, + `INSERT INTO harness_runs (run_id, agent_name, backend, message_id, status, trace_id, session_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, + req.RunID, + agentNameOf(agent), + harnessName, + msgID, + StatusRunning, + observability.TraceIDFromContext(ctx), + req.SessionID, + ) + if err != nil { + s.logger.Warn("harness_runs insert failed", + "run_id", req.RunID, + "agent", agentNameOf(agent), + "error", err, + ) + } +} + +// OnFinish updates the row with the terminal status, usage, and logs. +func (s *Store) OnFinish(ctx context.Context, agent *agents.Agent, harnessName string, req *harness.ExecRequest, res *harness.ExecResult, execErr error) { + s.mu.Lock() + startedAt, ok := s.start[req.RunID] + delete(s.start, req.RunID) + s.mu.Unlock() + + var durationMs *int64 + if ok { + d := time.Since(startedAt).Milliseconds() + durationMs = &d + } + + status := StatusSuccess + var exitCode *int + logsExcerpt := "" + var resultJSON string + var tokensIn, tokensOut, tokensCached int64 + var costUSD float64 + sessionID := req.SessionID + + if res != nil { + ec := res.ExitCode + exitCode = &ec + logsExcerpt = res.Logs + if len(res.ResultJSON) > 0 { + resultJSON = string(res.ResultJSON) + } + tokensIn = res.Usage.TokensIn + tokensOut = res.Usage.TokensOut + tokensCached = res.Usage.TokensCached + costUSD = res.Usage.CostUSD + if res.SessionID != "" { + sessionID = res.SessionID + } + if res.ExitCode != 0 { + status = StatusFailed + } + } + if execErr != nil { + status = StatusFailed + } + // Cap logs excerpt to keep row sizes sane (matches design: bounded). + const logsCap = 16 * 1024 + if len(logsExcerpt) > logsCap { + logsExcerpt = "... [truncated] ...\n" + logsExcerpt[len(logsExcerpt)-logsCap:] + } + + traceID := "" + if res != nil && res.TraceID != "" { + traceID = res.TraceID + } else { + traceID = observability.TraceIDFromContext(ctx) + } + + // If the run was never inserted (OnStart failed or skipped), fall + // back to an UPSERT via INSERT OR REPLACE on run_id to avoid losing + // the terminal row. + query := `UPDATE harness_runs SET + status = ?, exit_code = ?, trace_id = ?, session_id = ?, + tokens_in = ?, tokens_out = ?, tokens_cached = ?, cost_usd = ?, + duration_ms = ?, result_json = ?, logs_excerpt = ?, + finished_at = CURRENT_TIMESTAMP + WHERE run_id = ?` + + result, err := s.db.ExecContext(ctx, query, + status, exitCode, traceID, sessionID, + tokensIn, tokensOut, tokensCached, costUSD, + durationMs, nullableString(resultJSON), nullableString(logsExcerpt), + req.RunID, + ) + if err == nil { + if n, _ := result.RowsAffected(); n == 0 { + // Row didn't exist — insert it fresh. + _, err = s.db.ExecContext(ctx, + `INSERT INTO harness_runs (run_id, agent_name, backend, status, exit_code, trace_id, session_id, + tokens_in, tokens_out, tokens_cached, cost_usd, duration_ms, result_json, logs_excerpt, created_at, finished_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`, + req.RunID, agentNameOf(agent), harnessName, + status, exitCode, traceID, sessionID, + tokensIn, tokensOut, tokensCached, costUSD, + durationMs, nullableString(resultJSON), nullableString(logsExcerpt), + ) + } + } + if err != nil { + s.logger.Warn("harness_runs update failed", + "run_id", req.RunID, "error", err, + ) + } +} + +// GetByRunID retrieves a single harness run by its caller-assigned id. +func (s *Store) GetByRunID(ctx context.Context, runID string) (*Run, error) { + row := s.db.QueryRowContext(ctx, selectSQL()+` WHERE run_id = ?`, runID) + return scanRun(row) +} + +// ListByAgent returns recent runs for an agent, newest first. +func (s *Store) ListByAgent(ctx context.Context, agentName string, limit int) ([]*Run, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, + selectSQL()+` WHERE agent_name = ? ORDER BY created_at DESC LIMIT ?`, + agentName, limit, + ) + if err != nil { + return nil, fmt.Errorf("harness_runs list: %w", err) + } + defer rows.Close() + return scanRuns(rows) +} + +// -- helpers -------------------------------------------------------------- + +func agentNameOf(a *agents.Agent) string { + if a == nil { + return "" + } + return a.Name +} + +func nullableString(s string) any { + if s == "" { + return nil + } + return s +} + +func selectSQL() string { + return `SELECT id, run_id, agent_name, backend, message_id, status, exit_code, + trace_id, span_id, session_id, tokens_in, tokens_out, tokens_cached, cost_usd, + duration_ms, result_json, logs_excerpt, created_at, finished_at + FROM harness_runs` +} + +func scanRun(row *sql.Row) (*Run, error) { + var r Run + var msgID sql.NullInt64 + var exitCode sql.NullInt64 + var traceID, spanID, sessionID, resultJSON, logsExcerpt sql.NullString + var durationMs sql.NullInt64 + var createdAt, finishedAt sql.NullTime + if err := row.Scan( + &r.ID, &r.RunID, &r.AgentName, &r.Backend, &msgID, &r.Status, &exitCode, + &traceID, &spanID, &sessionID, &r.TokensIn, &r.TokensOut, &r.TokensCached, &r.CostUSD, + &durationMs, &resultJSON, &logsExcerpt, &createdAt, &finishedAt, + ); err != nil { + return nil, err + } + if msgID.Valid { + v := msgID.Int64 + r.MessageID = &v + } + if exitCode.Valid { + v := int(exitCode.Int64) + r.ExitCode = &v + } + r.TraceID = traceID.String + r.SpanID = spanID.String + r.SessionID = sessionID.String + r.ResultJSON = resultJSON.String + r.LogsExcerpt = logsExcerpt.String + if durationMs.Valid { + v := durationMs.Int64 + r.DurationMs = &v + } + if createdAt.Valid { + r.CreatedAt = createdAt.Time + } + if finishedAt.Valid { + t := finishedAt.Time + r.FinishedAt = &t + } + return &r, nil +} + +func scanRuns(rows *sql.Rows) ([]*Run, error) { + var out []*Run + for rows.Next() { + var r Run + var msgID sql.NullInt64 + var exitCode sql.NullInt64 + var traceID, spanID, sessionID, resultJSON, logsExcerpt sql.NullString + var durationMs sql.NullInt64 + var createdAt, finishedAt sql.NullTime + if err := rows.Scan( + &r.ID, &r.RunID, &r.AgentName, &r.Backend, &msgID, &r.Status, &exitCode, + &traceID, &spanID, &sessionID, &r.TokensIn, &r.TokensOut, &r.TokensCached, &r.CostUSD, + &durationMs, &resultJSON, &logsExcerpt, &createdAt, &finishedAt, + ); err != nil { + return nil, err + } + if msgID.Valid { + v := msgID.Int64 + r.MessageID = &v + } + if exitCode.Valid { + v := int(exitCode.Int64) + r.ExitCode = &v + } + r.TraceID = traceID.String + r.SpanID = spanID.String + r.SessionID = sessionID.String + r.ResultJSON = resultJSON.String + r.LogsExcerpt = logsExcerpt.String + if durationMs.Valid { + v := durationMs.Int64 + r.DurationMs = &v + } + if createdAt.Valid { + r.CreatedAt = createdAt.Time + } + if finishedAt.Valid { + t := finishedAt.Time + r.FinishedAt = &t + } + out = append(out, &r) + } + if out == nil { + out = []*Run{} + } + return out, rows.Err() +} diff --git a/internal/harness/runs/store_test.go b/internal/harness/runs/store_test.go new file mode 100644 index 0000000..a941933 --- /dev/null +++ b/internal/harness/runs/store_test.go @@ -0,0 +1,232 @@ +package runs_test + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "testing" + + "github.com/synapbus/synapbus/internal/agents" + "github.com/synapbus/synapbus/internal/harness" + "github.com/synapbus/synapbus/internal/harness/runs" + "github.com/synapbus/synapbus/internal/messaging" + + _ "modernc.org/sqlite" +) + +// setupDB installs just the harness_runs schema on an in-memory DB. +// It mirrors migration 019_harness.sql (the parts this store reads). +func setupDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("open: %v", err) + } + schema := ` +CREATE TABLE harness_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL UNIQUE, + agent_name TEXT NOT NULL, + backend TEXT NOT NULL, + message_id INTEGER, + status TEXT NOT NULL, + exit_code INTEGER, + trace_id TEXT, + span_id TEXT, + session_id TEXT, + tokens_in INTEGER NOT NULL DEFAULT 0, + tokens_out INTEGER NOT NULL DEFAULT 0, + tokens_cached INTEGER NOT NULL DEFAULT 0, + cost_usd REAL NOT NULL DEFAULT 0, + duration_ms INTEGER, + result_json TEXT, + logs_excerpt TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at DATETIME +);` + if _, err := db.Exec(schema); err != nil { + t.Fatalf("schema: %v", err) + } + return db +} + +func agent(name string) *agents.Agent { return &agents.Agent{Name: name} } + +func TestStore_OnStart_InsertsRunningRow(t *testing.T) { + db := setupDB(t) + s := runs.New(db, nil) + + req := &harness.ExecRequest{ + RunID: "run-1", + AgentName: "alpha", + Message: &messaging.Message{ID: 99, FromAgent: "caller"}, + } + s.OnStart(context.Background(), agent("alpha"), "subprocess", req) + + got, err := s.GetByRunID(context.Background(), "run-1") + if err != nil { + t.Fatalf("GetByRunID err = %v", err) + } + if got.Status != runs.StatusRunning { + t.Errorf("status = %q, want running", got.Status) + } + if got.Backend != "subprocess" { + t.Errorf("backend = %q", got.Backend) + } + if got.MessageID == nil || *got.MessageID != 99 { + t.Errorf("MessageID = %v, want 99", got.MessageID) + } +} + +func TestStore_OnFinish_UpdatesToSuccess(t *testing.T) { + db := setupDB(t) + s := runs.New(db, nil) + + req := &harness.ExecRequest{RunID: "r", AgentName: "a"} + s.OnStart(context.Background(), agent("a"), "stub", req) + + res := &harness.ExecResult{ + ExitCode: 0, + Logs: "hi", + ResultJSON: json.RawMessage(`{"ok":true}`), + Usage: harness.Usage{ + TokensIn: 10, + TokensOut: 20, + CostUSD: 0.005, + }, + SessionID: "sess-1", + } + s.OnFinish(context.Background(), agent("a"), "stub", req, res, nil) + + got, err := s.GetByRunID(context.Background(), "r") + if err != nil { + t.Fatalf("GetByRunID err = %v", err) + } + if got.Status != runs.StatusSuccess { + t.Errorf("status = %q, want success", got.Status) + } + if got.ExitCode == nil || *got.ExitCode != 0 { + t.Errorf("ExitCode = %v", got.ExitCode) + } + if got.TokensIn != 10 || got.TokensOut != 20 { + t.Errorf("usage not persisted: %+v", got) + } + if got.CostUSD != 0.005 { + t.Errorf("cost not persisted: %v", got.CostUSD) + } + if got.ResultJSON != `{"ok":true}` { + t.Errorf("result_json = %q", got.ResultJSON) + } + if got.SessionID != "sess-1" { + t.Errorf("session_id = %q", got.SessionID) + } + if got.DurationMs == nil { + t.Error("DurationMs nil, want computed") + } + if got.FinishedAt == nil { + t.Error("FinishedAt nil") + } +} + +func TestStore_OnFinish_UpdatesToFailedOnNonZero(t *testing.T) { + db := setupDB(t) + s := runs.New(db, nil) + req := &harness.ExecRequest{RunID: "r", AgentName: "a"} + s.OnStart(context.Background(), agent("a"), "stub", req) + s.OnFinish(context.Background(), agent("a"), "stub", req, + &harness.ExecResult{ExitCode: 5, Logs: "bad"}, nil) + + got, _ := s.GetByRunID(context.Background(), "r") + if got.Status != runs.StatusFailed { + t.Errorf("status = %q, want failed", got.Status) + } +} + +func TestStore_OnFinish_UpdatesToFailedOnExecErr(t *testing.T) { + db := setupDB(t) + s := runs.New(db, nil) + req := &harness.ExecRequest{RunID: "r", AgentName: "a"} + s.OnStart(context.Background(), agent("a"), "stub", req) + s.OnFinish(context.Background(), agent("a"), "stub", req, nil, errors.New("kaboom")) + + got, _ := s.GetByRunID(context.Background(), "r") + if got.Status != runs.StatusFailed { + t.Errorf("status = %q, want failed", got.Status) + } +} + +func TestStore_OnFinish_InsertsIfNoStart(t *testing.T) { + // Simulate the OnStart insert failing (e.g. store wired late) — + // OnFinish must still persist a terminal row. + db := setupDB(t) + s := runs.New(db, nil) + req := &harness.ExecRequest{RunID: "ghost", AgentName: "a"} + + s.OnFinish(context.Background(), agent("a"), "stub", req, + &harness.ExecResult{ExitCode: 0, Logs: "post hoc"}, nil) + + got, err := s.GetByRunID(context.Background(), "ghost") + if err != nil { + t.Fatalf("GetByRunID err = %v", err) + } + if got.Status != runs.StatusSuccess { + t.Errorf("status = %q", got.Status) + } + if got.LogsExcerpt != "post hoc" { + t.Errorf("logs = %q", got.LogsExcerpt) + } +} + +func TestStore_ListByAgent_OrdersNewestFirst(t *testing.T) { + db := setupDB(t) + s := runs.New(db, nil) + + for _, id := range []string{"r1", "r2", "r3"} { + req := &harness.ExecRequest{RunID: id, AgentName: "a"} + s.OnStart(context.Background(), agent("a"), "stub", req) + s.OnFinish(context.Background(), agent("a"), "stub", req, + &harness.ExecResult{ExitCode: 0}, nil) + } + + list, err := s.ListByAgent(context.Background(), "a", 10) + if err != nil { + t.Fatalf("ListByAgent err = %v", err) + } + if len(list) != 3 { + t.Fatalf("got %d runs, want 3", len(list)) + } + // Newest first ordering — at minimum the run_ids should all be + // present; ordering within same-timestamp is DB-defined. + seen := map[string]bool{} + for _, r := range list { + seen[r.RunID] = true + } + for _, id := range []string{"r1", "r2", "r3"} { + if !seen[id] { + t.Errorf("run %s missing", id) + } + } +} + +func TestStore_LogsExcerptCap(t *testing.T) { + db := setupDB(t) + s := runs.New(db, nil) + req := &harness.ExecRequest{RunID: "big", AgentName: "a"} + s.OnStart(context.Background(), agent("a"), "stub", req) + + big := make([]byte, 32*1024) + for i := range big { + big[i] = 'x' + } + s.OnFinish(context.Background(), agent("a"), "stub", req, + &harness.ExecResult{ExitCode: 0, Logs: string(big)}, nil) + + got, _ := s.GetByRunID(context.Background(), "big") + if len(got.LogsExcerpt) == 0 { + t.Fatal("logs empty") + } + if len(got.LogsExcerpt) > 17*1024 { + t.Errorf("logs excerpt not capped: %d bytes", len(got.LogsExcerpt)) + } +} diff --git a/internal/harness/stub/stub.go b/internal/harness/stub/stub.go new file mode 100644 index 0000000..57e2772 --- /dev/null +++ b/internal/harness/stub/stub.go @@ -0,0 +1,85 @@ +// Package stub provides an in-memory Harness implementation used by tests +// and by development modes where no real execution backend is wanted. +package stub + +import ( + "context" + "encoding/json" + "sync" + "time" + + "github.com/synapbus/synapbus/internal/agents" + "github.com/synapbus/synapbus/internal/harness" +) + +// Harness is a canned, in-memory implementation of harness.Harness. +// Callers set Result / Err directly (under Mu) to control what Execute +// returns. Every call is recorded in Calls for assertions. +type Harness struct { + Mu sync.Mutex + NameStr string + Caps harness.Capabilities + PreflightErr error + ProvisionErr error + Result *harness.ExecResult + Err error + ExecDelay time.Duration + Calls []harness.ExecRequest + CancelCalls []string +} + +// New returns a stub harness ready to use. The default result is a +// successful zero-exit-code run with empty logs. +func New() *Harness { + return &Harness{ + NameStr: "stub", + Caps: harness.Capabilities{OTelNative: false, MaxConcurrency: 1}, + Result: &harness.ExecResult{ + ExitCode: 0, + Logs: "", + ResultJSON: json.RawMessage(`{"ok":true}`), + }, + } +} + +func (h *Harness) Name() string { return h.NameStr } +func (h *Harness) Capabilities() harness.Capabilities { return h.Caps } + +func (h *Harness) TestEnvironment(ctx context.Context) error { + return h.PreflightErr +} + +func (h *Harness) Provision(ctx context.Context, agent *agents.Agent) error { + return h.ProvisionErr +} + +func (h *Harness) Execute(ctx context.Context, req *harness.ExecRequest) (*harness.ExecResult, error) { + h.Mu.Lock() + // Copy the request value so later caller mutation cannot race with + // test assertions reading Calls. + reqCopy := *req + h.Calls = append(h.Calls, reqCopy) + delay := h.ExecDelay + result := h.Result + err := h.Err + h.Mu.Unlock() + + if delay > 0 { + select { + case <-time.After(delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + if err != nil { + return nil, err + } + return result, nil +} + +func (h *Harness) Cancel(ctx context.Context, runID string) error { + h.Mu.Lock() + h.CancelCalls = append(h.CancelCalls, runID) + h.Mu.Unlock() + return nil +} diff --git a/internal/harness/stub/stub_test.go b/internal/harness/stub/stub_test.go new file mode 100644 index 0000000..da8996a --- /dev/null +++ b/internal/harness/stub/stub_test.go @@ -0,0 +1,104 @@ +package stub_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/synapbus/synapbus/internal/agents" + "github.com/synapbus/synapbus/internal/harness" + "github.com/synapbus/synapbus/internal/harness/stub" +) + +func TestStub_ImplementsHarness(t *testing.T) { + var _ harness.Harness = (*stub.Harness)(nil) +} + +func TestStub_NameAndCapabilities(t *testing.T) { + s := stub.New() + if s.Name() != "stub" { + t.Fatalf("Name = %q, want stub", s.Name()) + } + caps := s.Capabilities() + if caps.OTelNative { + t.Fatalf("stub should not claim OTelNative by default") + } +} + +func TestStub_Execute_ReturnsConfiguredResult(t *testing.T) { + s := stub.New() + s.Result = &harness.ExecResult{ExitCode: 42, Logs: "hi"} + + got, err := s.Execute(context.Background(), &harness.ExecRequest{RunID: "r", AgentName: "a"}) + if err != nil { + t.Fatalf("Execute error: %v", err) + } + if got.ExitCode != 42 || got.Logs != "hi" { + t.Fatalf("got %+v, want ExitCode=42 Logs=hi", got) + } + if len(s.Calls) != 1 { + t.Fatalf("Calls len = %d, want 1", len(s.Calls)) + } + if s.Calls[0].AgentName != "a" { + t.Fatalf("recorded call agent = %q, want a", s.Calls[0].AgentName) + } +} + +func TestStub_Execute_ReturnsError(t *testing.T) { + s := stub.New() + wantErr := errors.New("nope") + s.Err = wantErr + + _, err := s.Execute(context.Background(), &harness.ExecRequest{}) + if !errors.Is(err, wantErr) { + t.Fatalf("err = %v, want %v", err, wantErr) + } +} + +func TestStub_Execute_RespectsContextCancel(t *testing.T) { + s := stub.New() + s.ExecDelay = 2 * time.Second + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + + start := time.Now() + _, err := s.Execute(ctx, &harness.ExecRequest{}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err = %v, want DeadlineExceeded", err) + } + if time.Since(start) > time.Second { + t.Fatal("context cancel did not interrupt delay") + } +} + +func TestStub_Cancel_RecordsRunID(t *testing.T) { + s := stub.New() + if err := s.Cancel(context.Background(), "r-1"); err != nil { + t.Fatalf("Cancel error: %v", err) + } + if len(s.CancelCalls) != 1 || s.CancelCalls[0] != "r-1" { + t.Fatalf("CancelCalls = %v, want [r-1]", s.CancelCalls) + } +} + +func TestStub_Provision_ReturnsConfiguredError(t *testing.T) { + s := stub.New() + s.ProvisionErr = errors.New("bad") + err := s.Provision(context.Background(), &agents.Agent{Name: "a"}) + if err == nil || err.Error() != "bad" { + t.Fatalf("Provision err = %v, want 'bad'", err) + } +} + +func TestStub_TestEnvironment_ReturnsConfiguredError(t *testing.T) { + s := stub.New() + if err := s.TestEnvironment(context.Background()); err != nil { + t.Fatalf("default TestEnvironment err = %v, want nil", err) + } + s.PreflightErr = errors.New("nope") + if err := s.TestEnvironment(context.Background()); err == nil { + t.Fatal("expected error") + } +} diff --git a/internal/harness/subprocess/subprocess.go b/internal/harness/subprocess/subprocess.go new file mode 100644 index 0000000..04682d6 --- /dev/null +++ b/internal/harness/subprocess/subprocess.go @@ -0,0 +1,364 @@ +// Package subprocess is the local-process implementation of +// harness.Harness. It runs an agent as a child process of synapbus +// using os/exec, captures stdout+stderr, and reads an optional +// result.json the child may have written into its workdir. +// +// Works on Mac and Linux identically (no CGO, no platform-specific +// syscalls). Windows is not targeted because synapbus itself does not +// target Windows. +package subprocess + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/synapbus/synapbus/internal/agents" + "github.com/synapbus/synapbus/internal/harness" +) + +// Config tunes the subprocess harness. Zero-value defaults are sensible +// for development; production callers typically set BaseDir to a +// predictable location under SYNAPBUS_DATA_DIR so forensics are easy. +type Config struct { + // BaseDir is the parent directory under which a per-run workdir is + // created. Defaults to os.TempDir() when empty. + BaseDir string + + // LogsCap bounds the number of bytes kept in ExecResult.Logs. The + // full stdout/stderr stream is written to `stdout.log` / + // `stderr.log` inside the workdir for forensics. Defaults to 64 KiB. + LogsCap int + + // KeepWorkdirOnSuccess leaves the workdir behind even for + // zero-exit runs. Useful for debugging test flakes. + KeepWorkdirOnSuccess bool +} + +// Harness runs agents as local subprocesses. +type Harness struct { + cfg Config + logger *slog.Logger +} + +// New constructs a subprocess harness. Pass zero Config for defaults. +func New(cfg Config, logger *slog.Logger) *Harness { + if cfg.LogsCap <= 0 { + cfg.LogsCap = 64 * 1024 + } + if logger == nil { + logger = slog.Default() + } + return &Harness{ + cfg: cfg, + logger: logger.With("harness", "subprocess"), + } +} + +// Name returns the registered harness name. +func (h *Harness) Name() string { return "subprocess" } + +// Capabilities advertises backend features. +func (h *Harness) Capabilities() harness.Capabilities { + return harness.Capabilities{ + SystemPrompt: true, + SessionResume: true, + Skills: false, + OTelNative: true, + MaxConcurrency: 4, + } +} + +// TestEnvironment is a cheap sanity check: BaseDir (or os.TempDir) must +// exist and be writable. Per-agent binary reachability is checked at +// Execute time because the binary is agent-specific. +func (h *Harness) TestEnvironment(ctx context.Context) error { + base := h.cfg.BaseDir + if base == "" { + base = os.TempDir() + } + info, err := os.Stat(base) + if err != nil { + return fmt.Errorf("subprocess: base dir %q: %w", base, err) + } + if !info.IsDir() { + return fmt.Errorf("subprocess: base dir %q is not a directory", base) + } + return nil +} + +// Provision is a no-op for subprocess. All per-run state goes into the +// workdir Execute creates on the fly. +func (h *Harness) Provision(ctx context.Context, agent *agents.Agent) error { + return nil +} + +// ErrNoLocalCommand is returned when an agent has no LocalCommand +// configured so the subprocess backend cannot know what to run. +var ErrNoLocalCommand = errors.New("subprocess: agent has no local_command configured") + +// Execute launches the child, waits for it to exit (or for ctx / +// Budget to fire), and returns its output. +func (h *Harness) Execute(ctx context.Context, req *harness.ExecRequest) (*harness.ExecResult, error) { + if req == nil { + return nil, errors.New("subprocess: nil ExecRequest") + } + if req.Agent == nil { + return nil, errors.New("subprocess: ExecRequest.Agent is required") + } + + argv, err := parseLocalCommand(req.Agent.LocalCommand) + if err != nil { + return nil, err + } + + // Per-run workdir + base := h.cfg.BaseDir + if base == "" { + base = os.TempDir() + } + if err := os.MkdirAll(base, 0o755); err != nil { + return nil, fmt.Errorf("subprocess: mkdir base: %w", err) + } + runDirName := sanitizeRunDir(req.RunID) + if runDirName == "" { + runDirName = fmt.Sprintf("run-%d", time.Now().UnixNano()) + } + workdir := filepath.Join(base, runDirName) + if err := os.MkdirAll(workdir, 0o755); err != nil { + return nil, fmt.Errorf("subprocess: mkdir workdir: %w", err) + } + + // Context with Budget timeout if set. + runCtx := ctx + if req.Budget.MaxWallClock > 0 { + var cancel context.CancelFunc + runCtx, cancel = context.WithTimeout(ctx, req.Budget.MaxWallClock) + defer cancel() + } + + // Write the triggering message to message.json for the child to + // read if it cares. Simple, explicit, no stdin-piping ambiguity. + if req.Message != nil { + raw, _ := json.Marshal(req.Message) + _ = os.WriteFile(filepath.Join(workdir, "message.json"), raw, 0o644) + } + + cmd := exec.CommandContext(runCtx, argv[0], argv[1:]...) + cmd.Dir = workdir + cmd.Env = buildEnv(req, workdir) + + var stdout, stderr bytes.Buffer + cmd.Stdout = io.MultiWriter(&stdout, limitedFileWriter(workdir, "stdout.log")) + cmd.Stderr = io.MultiWriter(&stderr, limitedFileWriter(workdir, "stderr.log")) + + h.logger.Info("subprocess launching", + "run_id", req.RunID, + "agent", req.AgentName, + "cmd", argv[0], + "workdir", workdir, + ) + + startedAt := time.Now() + runErr := cmd.Run() + duration := time.Since(startedAt) + + exitCode := 0 + if runErr != nil { + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) { + exitCode = exitErr.ExitCode() + } else { + exitCode = 1 + } + } + + // Load optional result.json + var resultJSON json.RawMessage + if raw, readErr := os.ReadFile(filepath.Join(workdir, "result.json")); readErr == nil && len(raw) > 0 { + if json.Valid(raw) { + resultJSON = json.RawMessage(raw) + } + } + + logs := mergeLogs(&stdout, &stderr, h.cfg.LogsCap) + + // Cleanup policy: remove workdir on success unless configured to + // keep it; always keep on failure so users can inspect stdout.log / + // stderr.log / message.json / result.json. + if exitCode == 0 && !h.cfg.KeepWorkdirOnSuccess { + _ = os.RemoveAll(workdir) + } + + h.logger.Info("subprocess finished", + "run_id", req.RunID, + "agent", req.AgentName, + "exit", exitCode, + "duration_ms", duration.Milliseconds(), + ) + + result := &harness.ExecResult{ + ExitCode: exitCode, + Logs: logs, + ResultJSON: resultJSON, + } + + // Distinguish context timeout from plain failures so the caller + // can tell "budget exceeded" from "the agent crashed". + if runErr != nil && errors.Is(runCtx.Err(), context.DeadlineExceeded) { + return result, fmt.Errorf("subprocess: wall-clock budget %s exceeded", req.Budget.MaxWallClock) + } + if runErr != nil && errors.Is(runCtx.Err(), context.Canceled) { + return result, runCtx.Err() + } + return result, nil +} + +// Cancel is a no-op for subprocess today — cancellation happens via the +// context passed to Execute. A future iteration could track in-flight +// runs by RunID and send SIGTERM to them. +func (h *Harness) Cancel(ctx context.Context, runID string) error { + return nil +} + +// -- helpers -------------------------------------------------------------- + +// parseLocalCommand accepts either a JSON array (["claude", "--print"]) +// or a simple space-separated string. Returns the argv slice or an +// error if neither form parses. +func parseLocalCommand(raw string) ([]string, error) { + s := strings.TrimSpace(raw) + if s == "" { + return nil, ErrNoLocalCommand + } + if strings.HasPrefix(s, "[") { + var argv []string + if err := json.Unmarshal([]byte(s), &argv); err != nil { + return nil, fmt.Errorf("subprocess: parse local_command JSON: %w", err) + } + if len(argv) == 0 { + return nil, ErrNoLocalCommand + } + return argv, nil + } + // Fall back to whitespace split. Suitable for simple commands. + parts := strings.Fields(s) + if len(parts) == 0 { + return nil, ErrNoLocalCommand + } + return parts, nil +} + +// buildEnv constructs the env var list for the child. Starts from the +// parent's environment (so HOME, PATH, credentials are inherited by +// default — matches current K8s Pod behaviour). Then layers the +// agent's k8s_env_json (for consistency between backends), then caller +// overrides, then the SYNAPBUS_* run-context variables. +func buildEnv(req *harness.ExecRequest, workdir string) []string { + env := map[string]string{} + for _, kv := range os.Environ() { + if i := strings.IndexByte(kv, '='); i >= 0 { + env[kv[:i]] = kv[i+1:] + } + } + + // agent env map (K8sEnvJSON is shared across backends today) + if req.Agent != nil && req.Agent.K8sEnvJSON != "" { + var m map[string]json.RawMessage + if err := json.Unmarshal([]byte(req.Agent.K8sEnvJSON), &m); err == nil { + for k, v := range m { + var s string + if err := json.Unmarshal(v, &s); err == nil { + env[k] = s + continue + } + env[k] = strings.Trim(string(v), "\"") + } + } + } + + // caller overrides + for k, v := range req.Env { + env[k] = v + } + + // run context + env["SYNAPBUS_RUN_ID"] = req.RunID + env["SYNAPBUS_AGENT"] = req.AgentName + env["SYNAPBUS_WORKDIR"] = workdir + if req.Message != nil { + env["SYNAPBUS_MESSAGE_ID"] = fmt.Sprintf("%d", req.Message.ID) + env["SYNAPBUS_FROM_AGENT"] = req.Message.FromAgent + } + + out := make([]string, 0, len(env)) + for k, v := range env { + out = append(out, k+"="+v) + } + return out +} + +// mergeLogs interleaves stdout then stderr with a header, bounded by cap. +func mergeLogs(out, errb *bytes.Buffer, cap int) string { + var b strings.Builder + if out.Len() > 0 { + b.WriteString(out.String()) + } + if errb.Len() > 0 { + if b.Len() > 0 { + b.WriteString("\n") + } + b.WriteString("-- stderr --\n") + b.WriteString(errb.String()) + } + s := b.String() + if cap > 0 && len(s) > cap { + // Keep the tail — most informative on failure. + s = "... [truncated " + fmt.Sprintf("%d", len(s)-cap) + " bytes] ...\n" + s[len(s)-cap:] + } + return s +} + +// limitedFileWriter returns a writer that appends to a file inside the +// workdir. Errors are silently ignored — logs are best-effort and must +// not fail the run. +func limitedFileWriter(workdir, name string) io.Writer { + f, err := os.OpenFile(filepath.Join(workdir, name), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + return io.Discard + } + return f +} + +// sanitizeRunDir strips characters that cause surprises on case-folded +// filesystems or in shell globs. Keeps the resulting name readable. +func sanitizeRunDir(runID string) string { + if runID == "" { + return "" + } + var b strings.Builder + for _, r := range runID { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + b.WriteRune(r) + case r == '-' || r == '_': + b.WriteRune(r) + default: + b.WriteByte('-') + } + } + name := b.String() + if len(name) > 64 { + name = name[:64] + } + return name +} diff --git a/internal/harness/subprocess/subprocess_test.go b/internal/harness/subprocess/subprocess_test.go new file mode 100644 index 0000000..0408ca4 --- /dev/null +++ b/internal/harness/subprocess/subprocess_test.go @@ -0,0 +1,323 @@ +package subprocess_test + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/synapbus/synapbus/internal/agents" + "github.com/synapbus/synapbus/internal/harness" + "github.com/synapbus/synapbus/internal/harness/subprocess" + "github.com/synapbus/synapbus/internal/messaging" +) + +func requirePosix(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("subprocess tests use /bin/sh; not applicable on Windows") + } +} + +func newAgent(cmd string) *agents.Agent { + return &agents.Agent{Name: "test-agent", LocalCommand: cmd} +} + +func TestSubprocess_ImplementsInterface(t *testing.T) { + var _ harness.Harness = (*subprocess.Harness)(nil) +} + +func TestSubprocess_NameAndCapabilities(t *testing.T) { + h := subprocess.New(subprocess.Config{}, nil) + if h.Name() != "subprocess" { + t.Fatalf("Name = %q", h.Name()) + } + caps := h.Capabilities() + if !caps.SystemPrompt || !caps.SessionResume || !caps.OTelNative { + t.Fatalf("caps missing expected flags: %+v", caps) + } +} + +func TestSubprocess_TestEnvironment_OK(t *testing.T) { + h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil) + if err := h.TestEnvironment(context.Background()); err != nil { + t.Fatalf("TestEnvironment err = %v", err) + } +} + +func TestSubprocess_TestEnvironment_MissingDir(t *testing.T) { + h := subprocess.New(subprocess.Config{BaseDir: "/nonexistent/does/not/exist/xyz"}, nil) + if err := h.TestEnvironment(context.Background()); err == nil { + t.Fatal("expected error for missing base dir") + } +} + +func TestSubprocess_Execute_Success(t *testing.T) { + requirePosix(t) + + h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil) + req := &harness.ExecRequest{ + RunID: "run-success", + AgentName: "test", + Agent: newAgent(`["sh","-c","echo hello world"]`), + } + res, err := h.Execute(context.Background(), req) + if err != nil { + t.Fatalf("Execute err = %v", err) + } + if res.ExitCode != 0 { + t.Errorf("ExitCode = %d, want 0", res.ExitCode) + } + if !strings.Contains(res.Logs, "hello world") { + t.Errorf("logs missing stdout: %q", res.Logs) + } +} + +func TestSubprocess_Execute_NonZeroExit(t *testing.T) { + requirePosix(t) + + h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil) + req := &harness.ExecRequest{ + RunID: "run-fail", + AgentName: "test", + Agent: newAgent(`["sh","-c","echo oops; exit 7"]`), + } + res, err := h.Execute(context.Background(), req) + if err != nil { + t.Fatalf("Execute err = %v", err) + } + if res.ExitCode != 7 { + t.Errorf("ExitCode = %d, want 7", res.ExitCode) + } + if !strings.Contains(res.Logs, "oops") { + t.Errorf("logs missing stdout: %q", res.Logs) + } +} + +func TestSubprocess_Execute_StderrCaptured(t *testing.T) { + requirePosix(t) + + h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil) + req := &harness.ExecRequest{ + RunID: "run-stderr", + AgentName: "test", + Agent: newAgent(`["sh","-c","echo first; echo bad >&2; exit 0"]`), + } + res, err := h.Execute(context.Background(), req) + if err != nil { + t.Fatalf("Execute err = %v", err) + } + if !strings.Contains(res.Logs, "first") { + t.Errorf("logs missing stdout: %q", res.Logs) + } + if !strings.Contains(res.Logs, "bad") { + t.Errorf("logs missing stderr: %q", res.Logs) + } + if !strings.Contains(res.Logs, "-- stderr --") { + t.Errorf("logs missing stderr header: %q", res.Logs) + } +} + +func TestSubprocess_Execute_EnvPropagation(t *testing.T) { + requirePosix(t) + + h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil) + req := &harness.ExecRequest{ + RunID: "run-env", + AgentName: "my-agent", + Agent: newAgent(`["sh","-c","echo run=$SYNAPBUS_RUN_ID agent=$SYNAPBUS_AGENT custom=$CUSTOM"]`), + Env: map[string]string{"CUSTOM": "xyz"}, + } + res, err := h.Execute(context.Background(), req) + if err != nil { + t.Fatalf("Execute err = %v", err) + } + if !strings.Contains(res.Logs, "run=run-env") { + t.Errorf("run id not propagated: %q", res.Logs) + } + if !strings.Contains(res.Logs, "agent=my-agent") { + t.Errorf("agent name not propagated: %q", res.Logs) + } + if !strings.Contains(res.Logs, "custom=xyz") { + t.Errorf("caller env not propagated: %q", res.Logs) + } +} + +func TestSubprocess_Execute_ReadsResultJSON(t *testing.T) { + requirePosix(t) + + baseDir := t.TempDir() + h := subprocess.New(subprocess.Config{BaseDir: baseDir, KeepWorkdirOnSuccess: true}, nil) + req := &harness.ExecRequest{ + RunID: "run-json", + AgentName: "test", + Agent: newAgent(`["sh","-c","printf '{\"answer\":42,\"ok\":true}' > result.json"]`), + } + res, err := h.Execute(context.Background(), req) + if err != nil { + t.Fatalf("Execute err = %v", err) + } + if res.ExitCode != 0 { + t.Errorf("ExitCode = %d, want 0", res.ExitCode) + } + if len(res.ResultJSON) == 0 { + t.Fatal("ResultJSON empty") + } + if !strings.Contains(string(res.ResultJSON), `"answer":42`) { + t.Errorf("ResultJSON = %s", res.ResultJSON) + } + + // Verify the workdir is still present (KeepWorkdirOnSuccess=true) + if _, err := os.Stat(filepath.Join(baseDir, "run-json", "result.json")); err != nil { + t.Errorf("workdir removed despite KeepWorkdirOnSuccess: %v", err) + } +} + +func TestSubprocess_Execute_WritesMessageJSON(t *testing.T) { + requirePosix(t) + + baseDir := t.TempDir() + h := subprocess.New(subprocess.Config{BaseDir: baseDir, KeepWorkdirOnSuccess: true}, nil) + req := &harness.ExecRequest{ + RunID: "run-msg", + AgentName: "test", + Agent: newAgent(`["sh","-c","cat message.json > result.json"]`), + Message: &messaging.Message{ + ID: 123, + FromAgent: "alice", + Body: "hi", + }, + } + res, err := h.Execute(context.Background(), req) + if err != nil { + t.Fatalf("Execute err = %v", err) + } + if !strings.Contains(string(res.ResultJSON), `"from_agent":"alice"`) { + t.Errorf("message.json missing from_agent: %s", res.ResultJSON) + } + if !strings.Contains(string(res.ResultJSON), `"body":"hi"`) { + t.Errorf("message.json missing body: %s", res.ResultJSON) + } +} + +func TestSubprocess_Execute_WorkdirCleanedOnSuccess(t *testing.T) { + requirePosix(t) + + baseDir := t.TempDir() + h := subprocess.New(subprocess.Config{BaseDir: baseDir}, nil) + req := &harness.ExecRequest{ + RunID: "run-clean", + AgentName: "test", + Agent: newAgent(`["sh","-c","true"]`), + } + _, err := h.Execute(context.Background(), req) + if err != nil { + t.Fatalf("Execute err = %v", err) + } + if _, err := os.Stat(filepath.Join(baseDir, "run-clean")); !os.IsNotExist(err) { + t.Errorf("workdir not cleaned up: err=%v", err) + } +} + +func TestSubprocess_Execute_WorkdirKeptOnFailure(t *testing.T) { + requirePosix(t) + + baseDir := t.TempDir() + h := subprocess.New(subprocess.Config{BaseDir: baseDir}, nil) + req := &harness.ExecRequest{ + RunID: "run-forensic", + AgentName: "test", + Agent: newAgent(`["sh","-c","exit 5"]`), + } + _, err := h.Execute(context.Background(), req) + if err != nil { + t.Fatalf("Execute err = %v", err) + } + if _, err := os.Stat(filepath.Join(baseDir, "run-forensic")); err != nil { + t.Errorf("workdir removed on failure (should be kept): %v", err) + } +} + +func TestSubprocess_Execute_BudgetTimeout(t *testing.T) { + requirePosix(t) + + h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil) + req := &harness.ExecRequest{ + RunID: "run-timeout", + AgentName: "test", + Agent: newAgent(`["sh","-c","sleep 5"]`), + Budget: harness.Budget{MaxWallClock: 50 * time.Millisecond}, + } + start := time.Now() + _, err := h.Execute(context.Background(), req) + elapsed := time.Since(start) + if err == nil { + t.Fatal("expected budget timeout error") + } + if !strings.Contains(err.Error(), "budget") { + t.Errorf("err = %v, want 'budget' in message", err) + } + if elapsed > time.Second { + t.Errorf("budget not enforced; elapsed = %s", elapsed) + } +} + +func TestSubprocess_Execute_ContextCancel(t *testing.T) { + requirePosix(t) + + h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + + req := &harness.ExecRequest{ + RunID: "run-cancel", + AgentName: "test", + Agent: newAgent(`["sh","-c","sleep 5"]`), + } + start := time.Now() + _, _ = h.Execute(ctx, req) + if time.Since(start) > time.Second { + t.Error("context cancel did not stop child") + } +} + +func TestSubprocess_Execute_NilAgent(t *testing.T) { + h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil) + _, err := h.Execute(context.Background(), &harness.ExecRequest{RunID: "r"}) + if err == nil { + t.Fatal("expected error for nil agent") + } +} + +func TestSubprocess_Execute_NoLocalCommand(t *testing.T) { + h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil) + _, err := h.Execute(context.Background(), &harness.ExecRequest{ + RunID: "r", + Agent: &agents.Agent{Name: "no-cmd"}, + }) + if err == nil { + t.Fatal("expected ErrNoLocalCommand") + } +} + +func TestSubprocess_Execute_AcceptsWhitespaceArgvForm(t *testing.T) { + requirePosix(t) + + h := subprocess.New(subprocess.Config{BaseDir: t.TempDir()}, nil) + // Non-JSON form: simple whitespace-split. + req := &harness.ExecRequest{ + RunID: "run-ws", + AgentName: "test", + Agent: &agents.Agent{Name: "a", LocalCommand: "echo plain-form"}, + } + res, err := h.Execute(context.Background(), req) + if err != nil { + t.Fatalf("Execute err = %v", err) + } + if !strings.Contains(res.Logs, "plain-form") { + t.Errorf("whitespace form failed: %q", res.Logs) + } +} diff --git a/internal/harness/webhook/webhook.go b/internal/harness/webhook/webhook.go new file mode 100644 index 0000000..6e6aa10 --- /dev/null +++ b/internal/harness/webhook/webhook.go @@ -0,0 +1,262 @@ +// Package webhook is the HTTP-backed implementation of harness.Harness. +// It POSTs the run request to a configured URL and waits synchronously +// for a JSON response. Distinct from internal/webhooks, which is the +// fire-and-forget outbound event fan-out — this harness is a real +// request/response channel used when an agent lives behind an HTTP +// endpoint instead of a K8s Job or a local process. +package webhook + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "time" + + "github.com/synapbus/synapbus/internal/agents" + "github.com/synapbus/synapbus/internal/harness" + "github.com/synapbus/synapbus/internal/webhooks" +) + +// DefaultTimeout is applied when neither Config.DefaultTimeout nor +// ExecRequest.Budget.MaxWallClock is set. +const DefaultTimeout = 60 * time.Second + +// Config tunes the webhook harness. +type Config struct { + // HTTPClient is the outbound client. When nil, a default is used + // with a sensible timeout and no redirect following. + HTTPClient *http.Client + + // DefaultTimeout applies when the request has no budget set. + DefaultTimeout time.Duration + + // UserAgent overrides the User-Agent header. Defaults to + // "synapbus-harness-webhook/1". + UserAgent string +} + +// Harness is the webhook implementation of harness.Harness. +type Harness struct { + cfg Config + client *http.Client + logger *slog.Logger +} + +// agentWebhookConfig is the per-agent config blob the webhook harness +// reads from agents.harness_config_json. Callers populate this via the +// admin CLI or migration data. +type agentWebhookConfig struct { + URL string `json:"url"` + Secret string `json:"secret,omitempty"` + TimeoutSeconds int `json:"timeout_seconds,omitempty"` +} + +// payload is what the harness POSTs to the configured URL. Kept +// deliberately flat — the receiver can ignore fields it doesn't need. +type payload struct { + RunID string `json:"run_id"` + AgentName string `json:"agent_name"` + Message any `json:"message,omitempty"` + Context []any `json:"context,omitempty"` + SessionID string `json:"session_id,omitempty"` + Env map[string]string `json:"env,omitempty"` + Skills []string `json:"skills,omitempty"` +} + +// responseEnvelope is what the harness expects back. Every field is +// optional so a minimally-compliant receiver only needs to return an +// HTTP 200 to signal success. +type responseEnvelope struct { + ExitCode int `json:"exit_code"` + Logs string `json:"logs,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + SessionID string `json:"session_id,omitempty"` + Usage struct { + TokensIn int64 `json:"tokens_in"` + TokensOut int64 `json:"tokens_out"` + TokensCached int64 `json:"tokens_cached"` + CostUSD float64 `json:"cost_usd"` + } `json:"usage,omitempty"` +} + +// New constructs a webhook harness. +func New(cfg Config, logger *slog.Logger) *Harness { + client := cfg.HTTPClient + if client == nil { + client = &http.Client{ + Timeout: 0, // per-request timeout is set via context + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + } + if cfg.UserAgent == "" { + cfg.UserAgent = "synapbus-harness-webhook/1" + } + if cfg.DefaultTimeout <= 0 { + cfg.DefaultTimeout = DefaultTimeout + } + if logger == nil { + logger = slog.Default() + } + return &Harness{ + cfg: cfg, + client: client, + logger: logger.With("harness", "webhook"), + } +} + +// Name returns the registered harness name. +func (h *Harness) Name() string { return "webhook" } + +// Capabilities advertises backend features. +func (h *Harness) Capabilities() harness.Capabilities { + return harness.Capabilities{ + SystemPrompt: true, + SessionResume: true, + Skills: true, + OTelNative: false, // trace context goes via headers, not env + MaxConcurrency: 10, + } +} + +// TestEnvironment is a no-op for the webhook harness — we can't +// preflight without a target URL, which is per-agent config. +func (h *Harness) TestEnvironment(ctx context.Context) error { + return nil +} + +// Provision is a no-op for webhook. +func (h *Harness) Provision(ctx context.Context, agent *agents.Agent) error { + return nil +} + +// ErrNoWebhookConfig is returned when an agent's harness_config_json +// doesn't carry a URL the webhook harness can POST to. +var ErrNoWebhookConfig = errors.New("webhook: agent has no webhook config (harness_config_json.url missing)") + +// Execute builds the payload, signs it if a secret is configured, +// POSTs it, and maps the response back to an ExecResult. +func (h *Harness) Execute(ctx context.Context, req *harness.ExecRequest) (*harness.ExecResult, error) { + if req == nil { + return nil, errors.New("webhook: nil ExecRequest") + } + if req.Agent == nil { + return nil, errors.New("webhook: ExecRequest.Agent is required") + } + + cfg, err := parseAgentConfig(req.Agent.HarnessConfigJSON) + if err != nil { + return nil, err + } + + timeout := h.cfg.DefaultTimeout + if req.Budget.MaxWallClock > 0 { + timeout = req.Budget.MaxWallClock + } + if cfg.TimeoutSeconds > 0 { + timeout = time.Duration(cfg.TimeoutSeconds) * time.Second + } + + runCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + body := payload{ + RunID: req.RunID, + AgentName: req.AgentName, + SessionID: req.SessionID, + Env: req.Env, + Skills: req.Skills, + } + if req.Message != nil { + body.Message = req.Message + } + raw, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("webhook: marshal payload: %w", err) + } + + httpReq, err := http.NewRequestWithContext(runCtx, http.MethodPost, cfg.URL, bytes.NewReader(raw)) + if err != nil { + return nil, fmt.Errorf("webhook: build request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("User-Agent", h.cfg.UserAgent) + httpReq.Header.Set("X-SynapBus-Run-Id", req.RunID) + httpReq.Header.Set("X-SynapBus-Agent", req.AgentName) + if cfg.Secret != "" { + httpReq.Header.Set("X-SynapBus-Signature", + webhooks.ComputeHMACSignature([]byte(cfg.Secret), raw)) + } + + h.logger.Info("webhook dispatching", + "run_id", req.RunID, + "agent", req.AgentName, + "url", cfg.URL, + ) + + resp, err := h.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("webhook: POST %s: %w", cfg.URL, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB + if err != nil { + return nil, fmt.Errorf("webhook: read response: %w", err) + } + + var env responseEnvelope + if len(respBody) > 0 && json.Valid(respBody) { + // Best-effort parse. A non-JSON response is acceptable for + // receivers that just want to signal success via HTTP status. + _ = json.Unmarshal(respBody, &env) + } + + exitCode := env.ExitCode + if exitCode == 0 && resp.StatusCode >= 400 { + exitCode = 1 + } + logs := env.Logs + if logs == "" { + logs = string(respBody) + } + + return &harness.ExecResult{ + ExitCode: exitCode, + Logs: logs, + ResultJSON: env.Result, + SessionID: env.SessionID, + Usage: harness.Usage{ + TokensIn: env.Usage.TokensIn, + TokensOut: env.Usage.TokensOut, + TokensCached: env.Usage.TokensCached, + CostUSD: env.Usage.CostUSD, + }, + }, nil +} + +// Cancel is a no-op — HTTP POSTs are cancelled by context, not by +// out-of-band signalling. +func (h *Harness) Cancel(ctx context.Context, runID string) error { + return nil +} + +func parseAgentConfig(raw string) (agentWebhookConfig, error) { + var cfg agentWebhookConfig + if raw == "" { + return cfg, ErrNoWebhookConfig + } + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return cfg, fmt.Errorf("webhook: parse harness_config_json: %w", err) + } + if cfg.URL == "" { + return cfg, ErrNoWebhookConfig + } + return cfg, nil +} diff --git a/internal/harness/webhook/webhook_test.go b/internal/harness/webhook/webhook_test.go new file mode 100644 index 0000000..d11e04e --- /dev/null +++ b/internal/harness/webhook/webhook_test.go @@ -0,0 +1,223 @@ +package webhook_test + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/synapbus/synapbus/internal/agents" + "github.com/synapbus/synapbus/internal/harness" + "github.com/synapbus/synapbus/internal/harness/webhook" + "github.com/synapbus/synapbus/internal/messaging" + "github.com/synapbus/synapbus/internal/webhooks" +) + +func newAgent(url, secret string) *agents.Agent { + cfg, _ := json.Marshal(map[string]any{"url": url, "secret": secret}) + return &agents.Agent{Name: "webhook-agent", HarnessConfigJSON: string(cfg)} +} + +func TestWebhook_ImplementsInterface(t *testing.T) { + var _ harness.Harness = (*webhook.Harness)(nil) +} + +func TestWebhook_NameAndCapabilities(t *testing.T) { + h := webhook.New(webhook.Config{}, nil) + if h.Name() != "webhook" { + t.Fatalf("Name = %q", h.Name()) + } + caps := h.Capabilities() + if caps.OTelNative { + t.Errorf("OTelNative should be false (trace context via headers)") + } + if !caps.SessionResume { + t.Error("SessionResume should be true") + } +} + +func TestWebhook_TestEnvironment_NoOp(t *testing.T) { + h := webhook.New(webhook.Config{}, nil) + if err := h.TestEnvironment(context.Background()); err != nil { + t.Fatalf("TestEnvironment err = %v", err) + } +} + +func TestWebhook_Execute_Success(t *testing.T) { + var gotBody []byte + var gotSig string + var gotRunID string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotBody, _ = io.ReadAll(r.Body) + gotSig = r.Header.Get("X-SynapBus-Signature") + gotRunID = r.Header.Get("X-SynapBus-Run-Id") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"exit_code":0,"logs":"hi","result":{"answer":42},"usage":{"tokens_in":5,"tokens_out":10,"cost_usd":0.003}}`)) + })) + defer srv.Close() + + h := webhook.New(webhook.Config{}, nil) + req := &harness.ExecRequest{ + RunID: "run-ok", + AgentName: "webhook-agent", + Agent: newAgent(srv.URL, "s3cret"), + Message: &messaging.Message{ID: 1, FromAgent: "alice", Body: "hi"}, + } + res, err := h.Execute(context.Background(), req) + if err != nil { + t.Fatalf("Execute err = %v", err) + } + if res.ExitCode != 0 { + t.Errorf("ExitCode = %d", res.ExitCode) + } + if res.Logs != "hi" { + t.Errorf("Logs = %q", res.Logs) + } + if string(res.ResultJSON) != `{"answer":42}` { + t.Errorf("ResultJSON = %s", res.ResultJSON) + } + if res.Usage.TokensIn != 5 || res.Usage.TokensOut != 10 { + t.Errorf("usage tokens not mapped: %+v", res.Usage) + } + if res.Usage.CostUSD != 0.003 { + t.Errorf("cost not mapped: %v", res.Usage.CostUSD) + } + + // Request-side assertions + if gotRunID != "run-ok" { + t.Errorf("X-SynapBus-Run-Id header = %q", gotRunID) + } + if gotSig == "" { + t.Error("expected HMAC signature header") + } + expectedSig := webhooks.ComputeHMACSignature([]byte("s3cret"), gotBody) + if gotSig != expectedSig { + t.Errorf("signature mismatch: got=%q want=%q", gotSig, expectedSig) + } + + var sent map[string]any + if err := json.Unmarshal(gotBody, &sent); err != nil { + t.Fatalf("body not JSON: %v", err) + } + if sent["run_id"] != "run-ok" { + t.Errorf("sent body run_id = %v", sent["run_id"]) + } +} + +func TestWebhook_Execute_HTTPErrorMapsToNonZeroExit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + _, _ = w.Write([]byte("kaboom")) + })) + defer srv.Close() + + h := webhook.New(webhook.Config{}, nil) + res, err := h.Execute(context.Background(), &harness.ExecRequest{ + RunID: "r", AgentName: "a", Agent: newAgent(srv.URL, ""), + }) + if err != nil { + t.Fatalf("Execute err = %v (want graceful mapping)", err) + } + if res.ExitCode == 0 { + t.Errorf("ExitCode = 0, want non-zero") + } + if !strings.Contains(res.Logs, "kaboom") { + t.Errorf("logs missing body: %q", res.Logs) + } +} + +func TestWebhook_Execute_NoConfig(t *testing.T) { + h := webhook.New(webhook.Config{}, nil) + _, err := h.Execute(context.Background(), &harness.ExecRequest{ + RunID: "r", AgentName: "a", Agent: &agents.Agent{Name: "a"}, + }) + if !errors.Is(err, webhook.ErrNoWebhookConfig) { + t.Fatalf("err = %v, want ErrNoWebhookConfig", err) + } +} + +func TestWebhook_Execute_InvalidConfigJSON(t *testing.T) { + h := webhook.New(webhook.Config{}, nil) + a := &agents.Agent{Name: "a", HarnessConfigJSON: "not-json"} + _, err := h.Execute(context.Background(), &harness.ExecRequest{ + RunID: "r", AgentName: "a", Agent: a, + }) + if err == nil { + t.Fatal("expected parse error") + } +} + +func TestWebhook_Execute_Timeout(t *testing.T) { + var attempts int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&attempts, 1) + time.Sleep(500 * time.Millisecond) + w.WriteHeader(200) + })) + defer srv.Close() + + h := webhook.New(webhook.Config{}, nil) + req := &harness.ExecRequest{ + RunID: "r", + AgentName: "a", + Agent: newAgent(srv.URL, ""), + Budget: harness.Budget{MaxWallClock: 30 * time.Millisecond}, + } + start := time.Now() + _, err := h.Execute(context.Background(), req) + elapsed := time.Since(start) + if err == nil { + t.Fatal("expected timeout error") + } + if elapsed > time.Second { + t.Errorf("timeout not enforced; elapsed=%s", elapsed) + } + if atomic.LoadInt32(&attempts) != 1 { + t.Errorf("attempts = %d, want 1 (no retries in harness)", attempts) + } +} + +func TestWebhook_Execute_NilAgent(t *testing.T) { + h := webhook.New(webhook.Config{}, nil) + _, err := h.Execute(context.Background(), &harness.ExecRequest{RunID: "r"}) + if err == nil { + t.Fatal("expected error for nil agent") + } +} + +func TestWebhook_Execute_PerAgentTimeoutOverridesDefault(t *testing.T) { + var seenDeadline time.Time + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if d, ok := r.Context().Deadline(); ok { + seenDeadline = d + } + w.WriteHeader(200) + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + cfg, _ := json.Marshal(map[string]any{"url": srv.URL, "timeout_seconds": 2}) + a := &agents.Agent{Name: "a", HarnessConfigJSON: string(cfg)} + + h := webhook.New(webhook.Config{DefaultTimeout: 30 * time.Second}, nil) + _, err := h.Execute(context.Background(), &harness.ExecRequest{ + RunID: "r", AgentName: "a", Agent: a, + }) + if err != nil { + t.Fatalf("Execute err = %v", err) + } + if seenDeadline.IsZero() { + t.Skip("server did not capture deadline") + } + // Approximate: per-agent timeout (2s) should override the 30s default. + // We expect the deadline to be well under 10 seconds from now. + if time.Until(seenDeadline) > 10*time.Second { + t.Errorf("deadline too far out: %s", time.Until(seenDeadline)) + } +} diff --git a/internal/observability/otel.go b/internal/observability/otel.go new file mode 100644 index 0000000..e49643b --- /dev/null +++ b/internal/observability/otel.go @@ -0,0 +1,179 @@ +// Package observability initialises OpenTelemetry tracing for SynapBus. +// It is opt-in via SYNAPBUS_OTEL_ENABLED=1 and degrades to no-op +// behaviour when disabled, so every caller can unconditionally use +// the otel.Tracer API. +package observability + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.21.0" + "go.opentelemetry.io/otel/trace" +) + +// TracerName is the library-level identifier used by all SynapBus +// packages when getting a tracer. Keep it stable — it becomes the +// otel.library.name attribute on every span. +const TracerName = "github.com/synapbus/synapbus" + +// Config tunes the tracer provider. Zero-value config has Enabled=false +// and causes Init to return a noop shutdown func. +type Config struct { + // Enabled turns tracing on. Default: false. + Enabled bool + + // Endpoint is the OTLP HTTP target (host:port). Empty means + // "otlptracehttp default" — usually localhost:4318. + Endpoint string + + // Insecure switches to http:// instead of https://. Default true + // because most LAN collectors on kubic are TLS-less. + Insecure bool + + // ServiceName overrides the service.name resource attribute. + // Defaults to "synapbus". + ServiceName string + + // ServiceVersion adds a service.version attribute when non-empty. + ServiceVersion string + + // Sampler chooses the sampling rate. Values in [0,1] are treated + // as a TraceIDRatio. 0 or negative = AlwaysSample. 1 = AlwaysSample. + Sampler float64 +} + +// ConfigFromEnv reads SYNAPBUS_OTEL_* variables from the environment. +func ConfigFromEnv(getenv func(string) string) Config { + if getenv == nil { + getenv = func(string) string { return "" } + } + cfg := Config{ + Enabled: parseBoolEnv(getenv, "SYNAPBUS_OTEL_ENABLED", false), + Endpoint: getenv("SYNAPBUS_OTEL_ENDPOINT"), + Insecure: parseBoolEnv(getenv, "SYNAPBUS_OTEL_INSECURE", true), + ServiceName: getenv("SYNAPBUS_OTEL_SERVICE_NAME"), + ServiceVersion: getenv("SYNAPBUS_OTEL_SERVICE_VERSION"), + } + if cfg.ServiceName == "" { + cfg.ServiceName = "synapbus" + } + return cfg +} + +// Init wires a TracerProvider and a W3C text-map propagator. Returns +// a shutdown func the caller must defer. When cfg.Enabled is false +// the returned shutdown func is a no-op. +func Init(ctx context.Context, cfg Config, logger *slog.Logger) (func(context.Context) error, error) { + if logger == nil { + logger = slog.Default() + } + // Always install the propagator so trace context crosses our + // HTTP boundaries even when we're not the ones exporting spans. + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + if !cfg.Enabled { + logger.Info("otel tracing disabled (set SYNAPBUS_OTEL_ENABLED=1 to enable)") + return func(context.Context) error { return nil }, nil + } + + opts := []otlptracehttp.Option{} + if cfg.Endpoint != "" { + // otlptracehttp expects host[:port], not a full URL. + clean := strings.TrimPrefix(cfg.Endpoint, "http://") + clean = strings.TrimPrefix(clean, "https://") + clean = strings.TrimSuffix(clean, "/") + opts = append(opts, otlptracehttp.WithEndpoint(clean)) + } + if cfg.Insecure { + opts = append(opts, otlptracehttp.WithInsecure()) + } + + exporter, err := otlptrace.New(ctx, otlptracehttp.NewClient(opts...)) + if err != nil { + return nil, fmt.Errorf("observability: create OTLP HTTP exporter: %w", err) + } + + res, err := resource.Merge( + resource.Default(), + resource.NewWithAttributes(semconv.SchemaURL, + semconv.ServiceName(cfg.ServiceName), + semconv.ServiceVersion(cfg.ServiceVersion), + ), + ) + if err != nil { + return nil, fmt.Errorf("observability: build resource: %w", err) + } + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter, + sdktrace.WithBatchTimeout(5*time.Second), + ), + sdktrace.WithResource(res), + sdktrace.WithSampler(pickSampler(cfg.Sampler)), + ) + otel.SetTracerProvider(tp) + + logger.Info("otel tracing enabled", + "endpoint", cfg.Endpoint, + "service", cfg.ServiceName, + ) + return tp.Shutdown, nil +} + +// InjectTraceContext writes the W3C traceparent / tracestate headers +// from ctx into dst as env-var style keys (TRACEPARENT, TRACESTATE). +// Harnesses call this to push trace context into child processes and +// outbound HTTP requests in a single uniform shape. +func InjectTraceContext(ctx context.Context, dst map[string]string) { + if dst == nil { + return + } + carrier := propagation.MapCarrier{} + otel.GetTextMapPropagator().Inject(ctx, carrier) + for k, v := range carrier { + dst[strings.ToUpper(k)] = v + } +} + +// TraceIDFromContext extracts the trace id hex from the span currently +// attached to ctx, or returns the empty string if there isn't one. +func TraceIDFromContext(ctx context.Context) string { + span := trace.SpanFromContext(ctx) + sc := span.SpanContext() + if !sc.HasTraceID() { + return "" + } + return sc.TraceID().String() +} + +func parseBoolEnv(getenv func(string) string, key string, def bool) bool { + v := strings.TrimSpace(strings.ToLower(getenv(key))) + switch v { + case "": + return def + case "1", "t", "true", "yes", "on": + return true + case "0", "f", "false", "no", "off": + return false + } + return def +} + +func pickSampler(rate float64) sdktrace.Sampler { + if rate <= 0 || rate >= 1 { + return sdktrace.AlwaysSample() + } + return sdktrace.ParentBased(sdktrace.TraceIDRatioBased(rate)) +} diff --git a/internal/observability/otel_test.go b/internal/observability/otel_test.go new file mode 100644 index 0000000..a306036 --- /dev/null +++ b/internal/observability/otel_test.go @@ -0,0 +1,99 @@ +package observability_test + +import ( + "context" + "testing" + + "github.com/synapbus/synapbus/internal/observability" + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" +) + +func TestConfigFromEnv_DefaultDisabled(t *testing.T) { + cfg := observability.ConfigFromEnv(func(string) string { return "" }) + if cfg.Enabled { + t.Error("default Enabled should be false") + } + if cfg.ServiceName != "synapbus" { + t.Errorf("ServiceName default = %q, want synapbus", cfg.ServiceName) + } + if !cfg.Insecure { + t.Error("default Insecure should be true (LAN-friendly)") + } +} + +func TestConfigFromEnv_Overrides(t *testing.T) { + env := map[string]string{ + "SYNAPBUS_OTEL_ENABLED": "1", + "SYNAPBUS_OTEL_ENDPOINT": "otel.kubic.home.arpa:4318", + "SYNAPBUS_OTEL_INSECURE": "false", + "SYNAPBUS_OTEL_SERVICE_NAME": "synapbus-dev", + } + cfg := observability.ConfigFromEnv(func(k string) string { return env[k] }) + if !cfg.Enabled { + t.Error("Enabled=1 not honoured") + } + if cfg.Endpoint != "otel.kubic.home.arpa:4318" { + t.Errorf("Endpoint = %q", cfg.Endpoint) + } + if cfg.Insecure { + t.Error("Insecure=false not honoured") + } + if cfg.ServiceName != "synapbus-dev" { + t.Errorf("ServiceName = %q", cfg.ServiceName) + } +} + +func TestInit_DisabledIsNoopShutdown(t *testing.T) { + shutdown, err := observability.Init(context.Background(), observability.Config{Enabled: false}, nil) + if err != nil { + t.Fatalf("Init err = %v", err) + } + if shutdown == nil { + t.Fatal("shutdown is nil") + } + if err := shutdown(context.Background()); err != nil { + t.Fatalf("shutdown err = %v", err) + } +} + +func TestInjectTraceContext_NoSpan(t *testing.T) { + dst := map[string]string{} + observability.InjectTraceContext(context.Background(), dst) + // No span → no traceparent written (TextMapPropagator sees empty + // span context). Assert the function didn't panic; dst may be empty + // or contain TRACEPARENT with all-zero trace id depending on setup. +} + +func TestInjectTraceContext_WithSpan(t *testing.T) { + // Install a TracerProvider with an in-memory exporter so spans + // have real trace ids. + tp := sdktrace.NewTracerProvider(sdktrace.WithSyncer(tracetest.NewInMemoryExporter())) + t.Cleanup(func() { _ = tp.Shutdown(context.Background()) }) + otel.SetTracerProvider(tp) + + // Install the propagator (normally done by observability.Init) + _, _ = observability.Init(context.Background(), observability.Config{Enabled: false}, nil) + + ctx, span := tp.Tracer("test").Start(context.Background(), "outer") + defer span.End() + + dst := map[string]string{} + observability.InjectTraceContext(ctx, dst) + if _, ok := dst["TRACEPARENT"]; !ok { + t.Errorf("TRACEPARENT missing from dst: %v", dst) + } + + tid := observability.TraceIDFromContext(ctx) + if tid == "" { + t.Error("TraceIDFromContext returned empty") + } +} + +func TestTraceIDFromContext_NoSpan(t *testing.T) { + tid := observability.TraceIDFromContext(context.Background()) + if tid != "" { + t.Errorf("TraceIDFromContext with no span = %q, want empty", tid) + } +} diff --git a/internal/reactor/reactor_test.go b/internal/reactor/reactor_test.go index ef8f50b..43f0d26 100644 --- a/internal/reactor/reactor_test.go +++ b/internal/reactor/reactor_test.go @@ -45,7 +45,10 @@ func setupTestDB(t *testing.T) *sql.DB { k8s_image TEXT, k8s_env_json TEXT, k8s_resource_preset TEXT NOT NULL DEFAULT 'default', - pending_work INTEGER NOT NULL DEFAULT 0 + pending_work INTEGER NOT NULL DEFAULT 0, + harness_name TEXT, + local_command TEXT, + harness_config_json TEXT ); CREATE TABLE reactive_runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, diff --git a/internal/storage/schema/019_harness.sql b/internal/storage/schema/019_harness.sql new file mode 100644 index 0000000..49db441 --- /dev/null +++ b/internal/storage/schema/019_harness.sql @@ -0,0 +1,47 @@ +-- 019: Harness-agnostic wrappers +-- Adds per-agent harness configuration and a backend-agnostic harness_runs +-- table used by all harness implementations (k8sjob, subprocess, webhook). +-- See docs/harness-otel-design.md. + +-- Per-agent harness configuration ---------------------------------------- + +-- Explicit harness name ("k8sjob", "subprocess", "webhook", "stub"). +-- When NULL, Registry.Resolve picks a backend by fallback rules. +ALTER TABLE agents ADD COLUMN harness_name TEXT; + +-- JSON-encoded argv for the subprocess backend, e.g. +-- ["claude", "--print", "--max-turns", "50"] +ALTER TABLE agents ADD COLUMN local_command TEXT; + +-- Per-harness opaque config blob; parsed by the backend that owns it. +ALTER TABLE agents ADD COLUMN harness_config_json TEXT; + + +-- Backend-agnostic harness_runs table ------------------------------------ + +CREATE TABLE harness_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id TEXT NOT NULL UNIQUE, + agent_name TEXT NOT NULL, + backend TEXT NOT NULL, -- 'k8sjob' | 'subprocess' | 'webhook' | 'stub' + message_id INTEGER, -- triggering message, if any + status TEXT NOT NULL, -- 'pending' | 'running' | 'success' | 'failed' | 'cancelled' | 'timeout' + exit_code INTEGER, + trace_id TEXT, + span_id TEXT, + session_id TEXT, -- backend session id for resume, if any + tokens_in INTEGER NOT NULL DEFAULT 0, + tokens_out INTEGER NOT NULL DEFAULT 0, + tokens_cached INTEGER NOT NULL DEFAULT 0, + cost_usd REAL NOT NULL DEFAULT 0, + duration_ms INTEGER, + result_json TEXT, + logs_excerpt TEXT, -- bounded; full logs live on disk + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at DATETIME +); + +CREATE INDEX idx_harness_runs_agent ON harness_runs(agent_name, created_at DESC); +CREATE INDEX idx_harness_runs_status ON harness_runs(status, created_at DESC); +CREATE INDEX idx_harness_runs_trace ON harness_runs(trace_id); +CREATE INDEX idx_harness_runs_run_id ON harness_runs(run_id);