Introduces internal/harness — a minimal Harness interface inspired by
GoogleCloudPlatform/scion — plus four backends (k8sjob, subprocess,
webhook, stub) and an OTel-traced Registry that spans every dispatch
and injects W3C trace context into child processes via env vars.
Phases landed together on this branch:
1. internal/harness scaffold: Harness/Capabilities/ExecRequest/
ExecResult/Budget/Usage types, Registry with Resolve/Execute,
in-memory stub backend.
2. internal/harness/k8sjob: wraps existing k8s.JobRunner behind the
Harness interface with a Waiter abstraction (real clientset +
test fake). BuildHandler exports the per-agent config logic.
3. internal/harness/subprocess: os/exec-based backend (Mac+Linux),
per-run workdir, result.json handoff, bounded log capture,
Budget-driven wall-clock timeout.
4. internal/harness/webhook: synchronous HTTP POST with HMAC
signing via internal/webhooks.ComputeHMACSignature, per-agent
URL/secret/timeout read from harness_config_json.
5. internal/observability: OTel tracer init via OTLP HTTP (opt-in
via SYNAPBUS_OTEL_ENABLED), W3C propagator always installed;
Registry.Execute starts a harness.execute span per dispatch and
calls InjectTraceContext into req.Env so children inherit it.
6. internal/harness/runs: SQLite-backed Observer that persists a
harness_runs row per dispatch with status, usage, cost, duration,
trace_id, session_id, and a bounded logs excerpt.
Schema: new migration 019_harness.sql adds agents.harness_name /
local_command / harness_config_json columns and the backend-agnostic
harness_runs table with indices on (agent, created_at), (status),
(trace_id), (run_id). internal/reactor/reactor_test.go inline schema
updated to match.
Deployment: deploy/kubic/otel-collector.yaml stands up an otel-collector
Deployment + ConfigMap + ClusterIP Service in the synapbus namespace on
kubic, receiving OTLP gRPC (4317) and HTTP (4318) and exporting debug
output until a Tempo/Jaeger backend lands.
Docs: docs/harness-otel-research.html compares scion and paperclip
side-by-side and maps the current synapbus executor surface; its
companion docs/harness-otel-design.md carries the phase plan, span
taxonomy, and migration schema verbatim.
The reactor currently still calls k8s.JobRunner directly — rewiring it
through the Registry is a follow-up, intentionally out of scope for
this branch to keep the refactor reversible. The new packages are
independently tested (~78 new tests across 7 packages) and the full
project test suite passes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
263 lines
7.6 KiB
Go
263 lines
7.6 KiB
Go
// 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
|
|
}
|