diff --git a/.gitignore b/.gitignore index f1ba1517..a1215fdb 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ coverage.html # Config files with secrets config.yaml proxy-config.yaml +proxy-config.local.yaml .env .envrc diff --git a/AGENTS.md b/AGENTS.md index b4aa3570..1498f1af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,6 +171,8 @@ CLI commands and groups include: - `upgrade` - `upload` - `version` +- `workflow` — client for the external workflow engine, relayed server → proxy; the + proxy holds the credential (not a module, no MCP tool or resources) The proxy is a separate binary, built with `make build-proxy`. @@ -250,6 +252,7 @@ pkg/ embedding/ # Remote embedding client for semantic search config/ # Configuration loading and validation observability/ # Prometheus metrics + workflowrelay/ # Workflow-passthrough contract shared by the server and proxy hops types/ # Shared data types datasets/ # Dataset knowledge packs (content-only module) modules/ diff --git a/config.example.yaml b/config.example.yaml index df4af89d..e48910cb 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -45,6 +45,17 @@ proxies: # mode: "oidc" # "oidc", "oauth", or "client_credentials" # issuer_url: "https://panda-proxy.ethpandaops.io" # client_id: "panda" + # # Extra scopes requested at login. To use `panda workflow ...` against a + # # proxy running the workflow engine in passthrough mode, request the + # # "workflows" scope: Authentik cross-grants the workflow-engine audience + # # into this token so the same credential is accepted upstream. When set, + # # the list is sent verbatim — keep the base scopes and add "workflows". + # # `panda init` provisions this automatically: an oidc proxy running the + # # workflow engine in passthrough mode returns the scope set via + # # /auth/metadata, so a freshly generated config already includes it. Set + # # it by hand only when writing config manually or overriding what the + # # proxy advertises. + # scopes: ["openid", "email", "groups", "offline_access", "workflows"] # # Non-interactive service accounts (e.g. bots) use the client_credentials # grant with an Authentik service account + app password. Access tokens are @@ -83,6 +94,13 @@ proxies: # repository: "ethereum/consensus-specs" # GitHub owner/repo (for forks) # ref: "" # Branch, tag, or SHA (empty = latest release) +# Workflow-engine access has NO server-side config. The server relays +# `panda workflow ...` calls to whichever proxy advertises the workflow engine; +# the credential is held only by the proxy. Configure the engine in the +# `workflow:` block of proxy-config.yaml, not here. When that proxy runs the +# engine in passthrough mode, request the "workflows" scope in the proxy +# auth.scopes above so your login token is accepted upstream. + # Observability configuration observability: metrics_enabled: true diff --git a/docs/architecture.md b/docs/architecture.md index a305690c..0a55054d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,7 +41,7 @@ If a change affects product semantics, defaults, validation, output shape, or th - datasource discovery via `GET /datasources` (authenticated, returns metadata without credentials) - hosted auth control plane for remote users - proxy-scoped bearer token validation -- raw upstream relay to ClickHouse, Prometheus, Loki, and Ethereum nodes +- raw upstream relay to ClickHouse, Prometheus, Loki, Ethereum nodes, and the workflow engine - rate limiting and audit logging `proxy` must not own user-facing operation semantics. diff --git a/go.mod b/go.mod index e1199958..49509c97 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/moby/moby/client v0.4.1 github.com/oapi-codegen/runtime v1.3.1 github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 github.com/redis/go-redis/v9 v9.20.0 github.com/rivo/tview v0.42.0 github.com/sirupsen/logrus v1.9.4 @@ -83,6 +84,7 @@ require ( github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/klauspost/crc32 v1.3.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect github.com/magiconair/properties v1.8.10 // indirect @@ -110,7 +112,6 @@ require ( github.com/philhofer/fwd v1.2.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect - github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/pkg/app/app.go b/pkg/app/app.go index ad8dcdb1..4d175896 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -320,6 +320,7 @@ func (a *App) proxyClientConfig(proxyCfg config.ProxyConfig, onDiscover func()) cfg.IssuerURL = proxyCfg.ResolvedAuthIssuerURL() cfg.ClientID = proxyCfg.Auth.ClientID cfg.Resource = proxyCfg.ResolvedAuthResource() + cfg.Scopes = proxyCfg.Auth.Scopes cfg.RefreshTokenTTL = proxyCfg.Auth.RefreshTokenTTL cfg.AuthMode = strings.TrimSpace(proxyCfg.Auth.Mode) cfg.Username = strings.TrimSpace(proxyCfg.Auth.Username) diff --git a/pkg/auth/client/client.go b/pkg/auth/client/client.go index 58abdbbe..03e67cc9 100644 --- a/pkg/auth/client/client.go +++ b/pkg/auth/client/client.go @@ -165,10 +165,17 @@ type DeviceAuth struct { Interval int } +// DefaultScopes are the OAuth scopes the client requests when none are +// configured: OIDC identity, the groups claim the proxy authorizes on, and +// offline_access for refresh tokens. It is the single source of truth so that +// callers advertising or provisioning scopes (e.g. proxy /auth/metadata, +// `panda init`) stay in sync with what the client actually requests. +var DefaultScopes = []string{"openid", "email", "groups", "offline_access"} + // New creates a new OAuth client. func New(log logrus.FieldLogger, cfg Config) Client { if len(cfg.Scopes) == 0 { - cfg.Scopes = []string{"openid", "email", "groups", "offline_access"} + cfg.Scopes = append([]string(nil), DefaultScopes...) } return &client{ diff --git a/pkg/auth/token/token.go b/pkg/auth/token/token.go index 46e95719..612afb6e 100644 --- a/pkg/auth/token/token.go +++ b/pkg/auth/token/token.go @@ -41,6 +41,8 @@ type Config struct { ClientID string // Resource is the optional RFC 8707 resource indicator. Resource string + // Scopes are the OAuth scopes to request; empty means the client's defaults. + Scopes []string // Username and Password are the service-account credentials for // ModeClientCredentials. Username string @@ -103,6 +105,7 @@ func NewSource(log logrus.FieldLogger, cfg Config) Source { IssuerURL: issuer, ClientID: clientID, Resource: resource, + Scopes: cfg.Scopes, Username: cfg.Username, Password: cfg.Password, }) diff --git a/pkg/cli/init.go b/pkg/cli/init.go index 46059dd6..55a250ae 100644 --- a/pkg/cli/init.go +++ b/pkg/cli/init.go @@ -241,6 +241,7 @@ type initAuthConfig struct { Mode string IssuerURL string ClientID string + Scopes []string } // serverImageForVersion returns the published server image reference pinned to @@ -282,6 +283,19 @@ func buildConfigTemplate(proxyURL, sandboxImage string, auth initAuthConfig) str modeField = fmt.Sprintf("\n mode: %q", auth.Mode) } + // When the proxy advertises scopes (e.g. "workflows" for a configured + // workflow engine), pin them verbatim — an explicit scopes list replaces the + // client defaults, so the proxy advertises the complete set to request. + scopesField := "" + if len(auth.Scopes) > 0 { + quoted := make([]string, len(auth.Scopes)) + for i, s := range auth.Scopes { + quoted[i] = fmt.Sprintf("%q", s) + } + + scopesField = fmt.Sprintf("\n scopes: [%s]", strings.Join(quoted, ", ")) + } + return fmt.Sprintf(`# panda configuration # Generated by 'panda init'. See https://github.com/ethpandaops/panda for all options. @@ -304,8 +318,8 @@ proxy: url: %q auth:%s issuer_url: %q - client_id: %q -`, sandboxImage, proxyURL, modeField, auth.IssuerURL, auth.ClientID) + client_id: %q%s +`, sandboxImage, proxyURL, modeField, auth.IssuerURL, auth.ClientID, scopesField) } // discoverProxyAuth fetches auth metadata from the proxy's /auth/metadata @@ -348,6 +362,7 @@ func discoverProxyAuth(ctx context.Context, proxyURL string) initAuthConfig { Mode: meta.Mode, IssuerURL: meta.IssuerURL, ClientID: meta.ClientID, + Scopes: meta.Scopes, } } diff --git a/pkg/cli/init_test.go b/pkg/cli/init_test.go index 30955467..802932c2 100644 --- a/pkg/cli/init_test.go +++ b/pkg/cli/init_test.go @@ -80,6 +80,51 @@ func TestBuildConfigTemplate(t *testing.T) { } } +func TestBuildConfigTemplateScopes(t *testing.T) { + t.Parallel() + + t.Run("advertised scopes are pinned verbatim", func(t *testing.T) { + t.Parallel() + + authCfg := initAuthConfig{ + IssuerURL: "https://authentik.example.com/application/o/panda-proxy/", + ClientID: "panda-proxy", + Scopes: []string{"openid", "email", "groups", "offline_access", "workflows"}, + } + result := buildConfigTemplate(defaultProxyURL, defaultSandboxImage, authCfg) + + var parsed map[string]any + require.NoError(t, yaml.Unmarshal([]byte(result), &parsed), "generated config must be valid YAML") + + auth := parsed["proxy"].(map[string]any)["auth"].(map[string]any) + scopes, ok := auth["scopes"].([]any) + require.True(t, ok, "auth block must carry the advertised scopes") + + got := make([]string, len(scopes)) + for i, s := range scopes { + got[i] = s.(string) + } + assert.Equal(t, authCfg.Scopes, got) + }) + + t.Run("no scopes key when the proxy advertises none", func(t *testing.T) { + t.Parallel() + + authCfg := initAuthConfig{ + IssuerURL: "https://dex.example.com", + ClientID: "panda-proxy", + } + result := buildConfigTemplate(defaultProxyURL, defaultSandboxImage, authCfg) + + var parsed map[string]any + require.NoError(t, yaml.Unmarshal([]byte(result), &parsed), "generated config must be valid YAML") + + auth := parsed["proxy"].(map[string]any)["auth"].(map[string]any) + _, hasScopes := auth["scopes"] + assert.False(t, hasScopes, "omitting scopes lets the client defaults apply") + }) +} + func TestBuildComposeTemplate(t *testing.T) { t.Parallel() diff --git a/pkg/cli/root.go b/pkg/cli/root.go index 0303a3cc..d4f0cd8c 100644 --- a/pkg/cli/root.go +++ b/pkg/cli/root.go @@ -46,7 +46,10 @@ tables, columns, or query syntax: Most topic words are search terms, not subcommands. Full guide: - panda getting-started` + panda getting-started + +Not a data question? Drive the workflow engine with ` + "`panda workflow`" + ` (authoring +and running multi-step agent workflows), not Ethereum data queries.` // updateResult carries the latest version from the background check. // A nil value means the check failed or was skipped. diff --git a/pkg/cli/serverclient.go b/pkg/cli/serverclient.go index 469eaf2e..73e58b7f 100644 --- a/pkg/cli/serverclient.go +++ b/pkg/cli/serverclient.go @@ -28,6 +28,16 @@ type rawServerResponse struct { ContentType string } +// applyEnvAttribution stamps the caller-attribution (on-behalf-of) header from +// the environment onto an outbound server request, when set. Every server +// request — buffered (serverDo) and streaming (workflowStream) alike — must go +// through this so the proxy's audit log attributes all of them. +func applyEnvAttribution(h http.Header) { + if v := strings.TrimSpace(os.Getenv(attribution.EnvVar)); v != "" { + h.Set(attribution.Header, v) + } +} + func serverBaseURL() (string, error) { cfg, err := config.LoadClient(cfgFile) if err != nil { @@ -63,9 +73,7 @@ func serverDo( req.Header.Set(key, value) } - if v := strings.TrimSpace(os.Getenv(attribution.EnvVar)); v != "" { - req.Header.Set(attribution.Header, v) - } + applyEnvAttribution(req.Header) resp, err := serverHTTP.Do(req) if err != nil { @@ -216,20 +224,26 @@ func (e *apiError) Error() string { } func decodeAPIError(status int, data []byte) error { - var message string + return &apiError{Status: status, Message: apiErrorMessage(data)} +} +// apiErrorMessage extracts a human-readable message from an error response body. +// It tries the common JSON error fields in order — panda's `error`, then RFC +// 7807 problem+json `detail` and `title` — before falling back to the raw body. +// The raw-body fallback covers non-JSON responses (e.g. an unknown route's +// `text/plain` "404 page not found"), so decoding never panics or loses the +// message. +func apiErrorMessage(data []byte) string { var payload map[string]any if err := json.Unmarshal(data, &payload); err == nil { - if msg, ok := payload["error"].(string); ok && msg != "" { - message = msg + for _, key := range []string{"error", "detail", "title"} { + if msg, ok := payload[key].(string); ok && msg != "" { + return msg + } } } - if message == "" { - message = strings.TrimSpace(string(data)) - } - - return &apiError{Status: status, Message: message} + return strings.TrimSpace(string(data)) } func serverErrorHint(status int, message string) string { diff --git a/pkg/cli/workflow.go b/pkg/cli/workflow.go new file mode 100644 index 00000000..7f10715d --- /dev/null +++ b/pkg/cli/workflow.go @@ -0,0 +1,927 @@ +package cli + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "net/http" + "net/url" + "os" + "sort" + "strings" + "time" + + "github.com/spf13/cobra" +) + +const workflowLong = `Author, run, and monitor workflows on the workflow engine. + +The workflow engine is an external service that designs, publishes, and runs +multi-step agent workflows. panda talks to it through the local server, which +relays to the panda proxy; the proxy holds the credential and the CLI never +sees it. Access is configured on the proxy, not on the server. + +Resource model (you drive the sequence, the CLI does not orchestrate): + + whiteboard ── the planning space (holds sessions + drafts) wb_… + └─ session ── your chat with the worker that writes drafts ses_… + └─ draft ── a candidate workflow spec (iterate → revise) + └─ publish ─▶ workflow ── the executable object wf_… + └─ run ── one execution run_… + +The lifecycle is whiteboard → session → draft → workflow → run. + +This command group exposes raw CRUD + streaming primitives, one workflow-engine +REST call per leaf command. It is not the whiteboard skill: the design → publish +→ run → follow sequence is yours to drive, and the workflow engine owns drafting +— describe what you want in plain language and let it write the spec. + +Agents: publishing/running is a side-effect boundary, and FRESH IS THE DEFAULT +— a new request gets a new whiteboard → draft → review → run. Never hunt +through 'workflow list' / 'whiteboard list' for an existing item to run or +continue; reuse only what the user explicitly named. Render the draft for your +user with 'draft show' and get their explicit publish/run approval first — the +original task request alone is not approval. 'draft publish', 'draft run', and +'run create' enforce a tripwire (--approved, re-typing the reviewed id); the +flag is proof of review, never a substitute for the user's approval. The full +operator loop, checkpoint choices, and approval rules are in 'panda workflow +docs'. + +The run-stream status→exit contract applies only to run streams: 'run watch' and +'run logs -f' set the exit code from the terminal run.status (0 on completed, +non-zero on failed/cancelled). Session and whiteboard streams exit non-zero only +on a stream/transport error. + +Read 'panda workflow docs' for the lifecycle and examples. + +Examples: + panda workflow whiteboard create --requirements "count blocks per day" --json + panda workflow session create --content "count blocks per day" --json + panda workflow draft show + panda workflow draft run --approved --json + panda workflow run watch --json + panda workflow docs` + +var workflowCmd = &cobra.Command{ + GroupID: groupWorkflow, + Use: "workflow", + Aliases: []string{"wf"}, + Short: "Author, run, and monitor workflows on the workflow engine", + Long: workflowLong, +} + +func init() { + rootCmd.AddCommand(workflowCmd) + + // Hidden until a help-time probe confirms the proxy advertises the workflow + // engine to this caller; see registerWorkflowVisibility. Access is gated on + // the proxy by allowed_orgs (e.g. ethpandaops:Core), which filters the advert + // out of discovery for callers who lack it, so the command stays hidden for + // them. + workflowCmd.Hidden = true + + workflowCmd.AddCommand( + // Base workflow-noun verbs live directly on `panda workflow`. + workflowListCmd, + workflowGetCmd, + workflowReleaseCmd, + workflowWhiteboardCmd, + workflowSessionCmd, + workflowDraftCmd, + workflowRunCmd, + workflowSteerCmd, + workflowArtifactCmd, + workflowDispatchCmd, + workflowURLCmd, + workflowDocsCmd, + workflowAPICmd, + ) + + registerWorkflowVisibility() +} + +// workflowProbeTimeout bounds the help-time workflow-info probe that decides +// whether the workflow command is advertised. +const workflowProbeTimeout = 2 * time.Second + +// registerWorkflowVisibility wraps the root help function so the workflow command +// is revealed only when the proxy advertises the workflow engine to this caller +// (the proxy strips the advert from per-caller discovery when allowed_orgs +// excludes them). Execution is never blocked — a hidden command still runs and +// fails with the proxy's 403/503 — so this controls advertisement only. +func registerWorkflowVisibility() { + defaultHelp := rootCmd.HelpFunc() + + rootCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + if cmd == rootCmd || commandInWorkflowTree(cmd) { + workflowCmd.Hidden = !workflowAvailable(cmd.Context()) + } + + defaultHelp(cmd, args) + }) +} + +// commandInWorkflowTree reports whether cmd is the workflow command or one of its +// descendants. +func commandInWorkflowTree(cmd *cobra.Command) bool { + for c := cmd; c != nil; c = c.Parent() { + if c == workflowCmd { + return true + } + } + + return false +} + +// workflowAvailable reports whether the server advertises the workflow engine to +// this caller, read from /api/v1/workflow-info (which the proxy answers from +// per-caller filtered discovery, gated by allowed_orgs). It fails safe (returns +// false) on any error so the command stays hidden when the server is unreachable +// or the caller lacks access. +func workflowAvailable(ctx context.Context) bool { + if ctx == nil { + ctx = context.Background() + } + + probeCtx, cancel := context.WithTimeout(ctx, workflowProbeTimeout) + defer cancel() + + data, status, _, err := serverDo(probeCtx, http.MethodGet, "/api/v1/workflow-info", nil, nil, nil) + if err != nil || status < 200 || status >= 300 { + return false + } + + var payload struct { + Enabled bool `json:"enabled"` + } + + if err := json.Unmarshal(data, &payload); err != nil { + return false + } + + return payload.Enabled +} + +// workflowPath builds a `/api/v1/workflow/` passthrough path, percent- +// encoding each segment so IDs carrying reserved characters (e.g. a loop steer +// key like `tasks.fetch.one[iter=0002]`) survive intact and are never +// concatenated raw. url.PathEscape encodes `[` and `]` but leaves `=` literal, +// so we additionally encode `=`→`%3D` for spec fidelity (`[ ] =`). +func workflowPath(segments ...string) string { + var b strings.Builder + + b.WriteString("/api/v1/workflow") + + for _, seg := range segments { + b.WriteByte('/') + b.WriteString(strings.ReplaceAll(url.PathEscape(seg), "=", "%3D")) + } + + return b.String() +} + +// workflowAPIError is a non-2xx workflow passthrough response. Its hint is keyed on +// the HTTP status and overrides the generic serverErrorHint (whose 503 text is +// sandbox-specific and wrong for the workflow engine). +type workflowAPIError struct { + Status int + Message string +} + +func (e *workflowAPIError) Error() string { + if hint := workflowErrorHint(e.Status, e.Message); hint != "" { + return fmt.Sprintf("HTTP %d: %s\n\n hint: %s", e.Status, e.Message, hint) + } + + return fmt.Sprintf("HTTP %d: %s", e.Status, e.Message) +} + +// workflowErrorHint returns a workflow-engine-specific hint for the statuses +// where the generic hint would be misleading or absent. It reuses +// apiErrorMessage's decoded body via the caller, so problem+json and text/plain +// bodies both render. +// +// The hints are keyed on the HTTP status plus the canonical problem+json detail +// substrings emitted along the panda → server → proxy → workflow-engine path, so +// a proxy-not-advertising 503, a proxy-unreachable 502, and an engine-unreachable +// 502 are each named precisely rather than collapsed into one message. A body +// without a recognized phrase (a verbatim upstream 5xx page, an engine 401) falls +// through to the upstream-origin wording. +func workflowErrorHint(status int, message string) string { + lower := strings.ToLower(message) + + switch status { + case http.StatusServiceUnavailable: + // Only panda-server's "no proxy advertises the engine" short-circuit is a + // local-config problem; any other 503 is a forwarded upstream outage. + if strings.Contains(lower, "no configured proxy advertises") { + return "the workflow engine is not enabled on any configured proxy — " + + "add the workflow block to your proxy's proxy-config.yaml (or ask the proxy operator)" + } + + return "the workflow engine returned a server error upstream — retry; " + + "if it persists contact the operator" + case http.StatusBadGateway: + // The server emits "proxy is unreachable"; the proxy emits "configured but + // unreachable" for a dead engine. Distinguish the two hops; a 502 body with + // neither phrase was relayed verbatim from beyond the proxy, so blame the + // engine side rather than either hop. + if strings.Contains(lower, "proxy is unreachable") { + return "panda could not reach the proxy — check the server's proxies[] " + + "config and that the proxy is up" + } + + if strings.Contains(lower, "configured but unreachable") { + return "the proxy could not reach the workflow engine — the upstream may be " + + "down; try again or contact the operator" + } + + return "the workflow engine returned a server error upstream — retry; " + + "if it persists contact the operator" + case http.StatusUnauthorized: + // The proxy's own bearer rejections carry the exact plain-text phrases + // from its writeBearerError (pkg/proxy/oidc_auth.go); an engine 401 body + // is relayed verbatim and uses its own wording. + for _, phrase := range []string{ + "missing or invalid authorization header", + "missing bearer token", + "invalid token", + "token subject is missing", + } { + if strings.Contains(lower, phrase) { + return "the proxy rejected your credential — run 'panda auth login' and retry" + } + } + + return "the workflow engine rejected the credential — if your proxy uses " + + "passthrough auth, re-run 'panda auth login' (your token may predate the " + + "engine scope); otherwise ask the proxy operator to check the configured api_token" + case http.StatusForbidden: + return "your account is not allowed to use the workflow engine on this proxy — " + + "ask an operator for access" + case http.StatusNotFound: + return "check the whiteboard / run / draft id (or the path for " + + "`panda workflow api`)" + default: + // Any other upstream 5xx (500, 504, …): an engine-side error, not a local + // misconfig — say so rather than leaving it hintless. + if status >= 500 { + return "the workflow engine returned a server error upstream — retry; " + + "if it persists contact the operator" + } + + return "" + } +} + +// workflowError wraps a non-2xx passthrough response, decoding its body via the +// shared apiErrorMessage fallback chain (error → detail → title → raw body). +func workflowError(status int, data []byte) error { + return &workflowAPIError{Status: status, Message: apiErrorMessage(data)} +} + +// workflowGet performs a GET against the workflow passthrough and returns the raw +// response body, routing non-2xx through the workflow-hinted error. +func workflowGet(ctx context.Context, query url.Values, segments ...string) ([]byte, error) { + data, status, _, err := serverDo(ctx, http.MethodGet, workflowPath(segments...), nil, query, nil) + if err != nil { + return nil, err + } + + if status < 200 || status >= 300 { + return nil, workflowError(status, data) + } + + return data, nil +} + +// workflowSend performs a write (POST/PATCH/DELETE) against the workflow passthrough +// and returns the raw response body. A nil body sends no payload; a non-nil +// body sets Content-Type: application/json (the engine rejects a bare form body). +// It tolerates an empty 2xx body (e.g. run cancel's 204) — it never unmarshals. +func workflowSend( + ctx context.Context, + method string, + body []byte, + headers map[string]string, + query url.Values, + segments ...string, +) ([]byte, error) { + var reader io.Reader + + sendHeaders := make(map[string]string, len(headers)+1) + maps.Copy(sendHeaders, headers) + + if body != nil { + reader = bytes.NewReader(body) + sendHeaders["Content-Type"] = "application/json" + } + + data, status, _, err := serverDo(ctx, method, workflowPath(segments...), reader, query, sendHeaders) + if err != nil { + return nil, err + } + + if status < 200 || status >= 300 { + return nil, workflowError(status, data) + } + + return data, nil +} + +// workflowStream opens a streaming request against the workflow passthrough and +// returns the OPEN response (the caller MUST close its Body) for SSE and +// artifact downloads. It checks the HTTP status BEFORE returning: on a non-2xx +// it reads the small error body, closes it, and returns a workflow-hinted error, +// so an error body is never fed to the SSE parser. A non-nil body sets +// Content-Type: application/json. +func workflowStream( + ctx context.Context, + method string, + body []byte, + headers map[string]string, + query url.Values, + rawPath string, +) (*http.Response, error) { + baseURL, err := serverBaseURL() + if err != nil { + return nil, err + } + + reqURL := strings.TrimRight(baseURL, "/") + rawPath + if len(query) > 0 { + reqURL += "?" + query.Encode() + } + + var reader io.Reader + if body != nil { + reader = bytes.NewReader(body) + } + + req, err := http.NewRequestWithContext(ctx, method, reqURL, reader) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + for k, v := range headers { + req.Header.Set(k, v) + } + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + // Streaming requests carry the same caller attribution as buffered serverDo + // requests, so the proxy's audit log attributes watch/follow/log streams too. + applyEnvAttribution(req.Header) + + resp, err := serverHTTP.Do(req) + if err != nil { + if isConnectionRefused(err) { + return nil, fmt.Errorf( + "server is not running at %s — run 'panda init' or 'panda server start' first", + baseURL, + ) + } + + return nil, fmt.Errorf("request failed: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + _ = resp.Body.Close() + + return nil, workflowError(resp.StatusCode, data) + } + + return resp, nil +} + +// maxSSELineBytes bounds a single SSE line (a `data:`/`event:`/`id:` line, or a +// blank-line frame delimiter). It guards against a newline-sparse body — e.g. a +// large base64 artifact mis-routed through `api -f` — being buffered unbounded +// into memory. It is far above any legitimate SSE line (worker-log pages and +// state snapshots are well under a MiB) yet a hard ceiling against OOM/DoS. +const maxSSELineBytes = 8 << 20 // 8 MiB + +// sseEvent is one parsed server-sent event frame. +type sseEvent struct { + Event string + ID string + Data string +} + +// parseSSE reads server-sent event frames from r and invokes fn once per +// complete (blank-line-delimited) frame. It: +// - skips `:`-comment lines (the engine's `: ping` heartbeat every 30s), +// - does NOT surface an `event: sync` frame (replay-caught-up marker) as a +// data event or turn-done — such frames are dropped entirely, +// - joins multiple `data:` lines in a frame with "\n", +// - uses bufio.Reader.ReadString rather than bufio.Scanner to avoid the 64K +// token cap on large payloads. +// +// It returns nil at end of stream (io.EOF), fn's error if fn returns one, or a +// wrapped read/context error. bufio transparently reassembles reads that split +// mid-frame. +func parseSSE(ctx context.Context, r io.Reader, fn func(sseEvent) error) error { + reader := bufio.NewReader(r) + + var ( + event string + id string + dataLines []string + haveData bool + ) + + flush := func() error { + defer func() { event, id, dataLines, haveData = "", "", nil, false }() + + if event == "sync" { + return nil + } + + if !haveData && event == "" && id == "" { + return nil + } + + return fn(sseEvent{Event: event, ID: id, Data: strings.Join(dataLines, "\n")}) + } + + for { + if err := ctx.Err(); err != nil { + return err + } + + line, readErr := readBoundedSSELine(reader, maxSSELineBytes) + + if len(line) > 0 { + trimmed := strings.TrimRight(line, "\n") + trimmed = strings.TrimRight(trimmed, "\r") + + switch { + case trimmed == "": + if err := flush(); err != nil { + return err + } + case strings.HasPrefix(trimmed, ":"): + // Comment / heartbeat line — skip. + default: + field, value, _ := strings.Cut(trimmed, ":") + value = strings.TrimPrefix(value, " ") + + switch field { + case "event": + event = value + case "id": + id = value + case "data": + dataLines = append(dataLines, value) + haveData = true + } + } + } + + if readErr != nil { + if errors.Is(readErr, io.EOF) { + return flush() + } + + return fmt.Errorf("reading stream: %w", readErr) + } + } +} + +// readBoundedSSELine reads up to and including the next '\n' from r, returning +// an error once the accumulated line exceeds maxBytes rather than buffering an +// unbounded newline-sparse payload. It uses ReadSlice so the ceiling is enforced +// incrementally (a few KiB at a time) instead of after the whole line is in +// memory. The returned line retains its trailing '\n' (as ReadString did). +func readBoundedSSELine(r *bufio.Reader, maxBytes int) (string, error) { + var b strings.Builder + + for { + chunk, err := r.ReadSlice('\n') + + if b.Len()+len(chunk) > maxBytes { + return "", fmt.Errorf("stream line exceeds %d bytes", maxBytes) + } + + b.Write(chunk) + + switch { + case err == nil: + return b.String(), nil + case errors.Is(err, bufio.ErrBufferFull): + // Line longer than the bufio buffer: keep accumulating, bounded by + // the maxBytes check above. + continue + default: + return b.String(), err + } + } +} + +// printNDJSONBytes compacts a JSON payload to a single line and prints it, for +// NDJSON stream output. A non-JSON payload is printed verbatim (trimmed). +func printNDJSONBytes(data []byte) error { + var buf bytes.Buffer + if err := json.Compact(&buf, data); err != nil { + fmt.Println(strings.TrimSpace(string(data))) + + return nil + } + + buf.WriteByte('\n') + _, err := buf.WriteTo(os.Stdout) + + return err +} + +// readInlineOrFile resolves a flag value that is either inline JSON/text or a +// `@path` file reference, returning the bytes verbatim (no validation). An +// empty value returns (nil, nil). +func readInlineOrFile(value string) ([]byte, error) { + if value == "" { + return nil, nil + } + + if strings.HasPrefix(value, "@") { + path := value[1:] + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + return data, nil + } + + return []byte(value), nil +} + +// readJSONFlag resolves an inline-or-@file flag whose payload gets embedded +// verbatim (json.RawMessage) into a request body, validating it up front so a +// typo surfaces as "--inputs is not valid JSON" instead of a cryptic +// json.RawMessage marshal error. An empty value returns (nil, nil). +func readJSONFlag(flag, value string) ([]byte, error) { + data, err := readInlineOrFile(value) + if err != nil || len(data) == 0 { + return data, err + } + + if !json.Valid(data) { + return nil, fmt.Errorf("%s is not valid JSON", flag) + } + + return data, nil +} + +// renderWorkflow prints a raw workflow response body: pretty raw JSON under --json +// (preserving number precision), otherwise the caller's text summary. +func renderWorkflow(body []byte, summarize func([]byte)) error { + if isJSON() { + return printJSONBytes(body) + } + + summarize(body) + + return nil +} + +// summarizeWorkflowItems prints a concise, non-contractual listing of `body[key]`, +// one line per item with any of id/name/status/revision present. It falls back +// to compact JSON when the shape is unexpected. --json is the stable contract. +func summarizeWorkflowItems(body []byte, key, empty string) { + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil { + fmt.Println(strings.TrimSpace(string(body))) + + return + } + + raw, ok := payload[key] + if !ok { + summarizeWorkflowObject(body) + + return + } + + var items []map[string]any + if err := json.Unmarshal(raw, &items); err != nil { + _ = printNDJSONBytes(raw) + + return + } + + if len(items) == 0 { + fmt.Println(empty) + + return + } + + rows := make([][]string, 0, len(items)) + identified := false + + for _, item := range items { + id := stringField(item, "id") + name := stringField(item, "name") + status := stringField(item, "status") + + if id != "-" || name != "-" || status != "-" { + identified = true + } + + rows = append(rows, []string{id, name, status}) + } + + // Lists whose items carry none of id/name/status (session/steer turns, + // dispatch inventory entries) would render as a block of pure dashes. Fall + // back to a compact scalar summary per item instead of the id/name/status + // table. --json remains the stable contract. + if !identified { + for _, item := range items { + fmt.Println(scalarFieldLine(item)) + } + + return + } + + printTable([]string{"ID", "NAME", "STATUS"}, rows) +} + +// scalarFieldLine renders an item's scalar fields as a single sorted +// `key=value key=value` line, skipping nested arrays/objects (e.g. a turn's +// large embedded events[]) and nil values. It is the compact per-item fallback +// for id-less lists in text mode. +func scalarFieldLine(item map[string]any) string { + keys := make([]string, 0, len(item)) + + for k, v := range item { + switch v.(type) { + case nil, map[string]any, []any: + // Skip nils and nested structures — --json shows them. + default: + keys = append(keys, k) + } + } + + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%v", k, item[k])) + } + + return strings.Join(parts, " ") +} + +// summarizeWorkflowObject prints the top-level scalar fields of a JSON object as +// aligned key/value pairs — a non-contractual convenience for text mode. +func summarizeWorkflowObject(body []byte) { + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + fmt.Println(strings.TrimSpace(string(body))) + + return + } + + keys := make([]string, 0, len(payload)) + for k := range payload { + keys = append(keys, k) + } + + sort.Strings(keys) + + pairs := make([][2]string, 0, len(keys)) + + for _, k := range keys { + switch v := payload[k].(type) { + case map[string]any, []any: + // Skip nested structures in the summary; --json shows them. + default: + pairs = append(pairs, [2]string{k, fmt.Sprint(v)}) + } + } + + if len(pairs) == 0 { + fmt.Println("(no scalar fields; use --json for the full object)") + + return + } + + printKeyValue(pairs) +} + +// stringField returns m[key] as a string, or "-" when absent/non-string. +func stringField(m map[string]any, key string) string { + if v, ok := m[key].(string); ok && v != "" { + return v + } + + return "-" +} + +// errStreamComplete is a sentinel returned from an SSE callback to stop the +// stream cleanly once a client-side terminal condition is reached. +var errStreamComplete = errors.New("stream complete") + +// sseHeaders returns the headers for opening an SSE stream. The passthrough +// allow-lists Accept and Last-Event-ID; the workflow-engine credential is +// injected by the proxy, never by the CLI. +func sseHeaders() map[string]string { + return map[string]string{"Accept": "text/event-stream"} +} + +// emitStreamData emits one SSE data payload: a compact NDJSON line under --json, +// else a concise, non-contractual text line. Empty data is dropped. +func emitStreamData(ev sseEvent) error { + if ev.Data == "" { + return nil + } + + if isJSON() { + return printNDJSONBytes([]byte(ev.Data)) + } + + if ev.Event != "" { + fmt.Printf("%s\t%s\n", ev.Event, ev.Data) + } else { + fmt.Println(ev.Data) + } + + return nil +} + +// workerLogPage is the data payload of a worker-log SSE `page` frame: a batch +// of log items plus a resume cursor. The worker-log stream frames EVERY event +// as `event: page`; the real per-event type lives inside each item's `.type`. +type workerLogPage struct { + Items []json.RawMessage `json:"items"` + LiveCursor string `json:"liveCursor"` +} + +// workerLogItem is one entry inside a worker-log page's items[] batch. Only the +// fields the CLI renders in text mode are declared; --json emits the raw item. +type workerLogItem struct { + Type string `json:"type"` + EventName string `json:"eventName"` + Message string `json:"message"` +} + +// emitStreamItems emits one SSE data frame, flattening a worker-log page into +// one line per items[] element (each raw item as its own NDJSON line under +// --json, else a concise text line). A payload that is not a page with an +// items[] array falls back to emitting the whole data payload (state streams, +// which carry a single snapshot object per frame, pass through unchanged). +func emitStreamItems(ev sseEvent) error { + if ev.Data == "" { + return nil + } + + var page workerLogPage + if err := json.Unmarshal([]byte(ev.Data), &page); err != nil || page.Items == nil { + return emitStreamData(ev) + } + + for _, raw := range page.Items { + if err := emitWorkerLogItem(raw); err != nil { + return err + } + } + + return nil +} + +// emitWorkerLogItem emits a single worker-log item: a compact NDJSON line under +// --json, else a concise `type\tmessage` text line. A shape the CLI does not +// recognize falls back to a compact JSON line. +func emitWorkerLogItem(raw json.RawMessage) error { + if isJSON() { + return printNDJSONBytes(raw) + } + + var item workerLogItem + if err := json.Unmarshal(raw, &item); err != nil { + fmt.Println(strings.TrimSpace(string(raw))) + + return nil + } + + return printWorkerLogText(item, raw) +} + +// printWorkerLogText renders one worker-log item as a concise text line: +// `type\tmessage`, or whichever of the two is present, falling back to a +// compact JSON line when neither is. Shared by the streamed (`-f`) and the +// non-streamed history renderings so both present identically. +func printWorkerLogText(item workerLogItem, raw json.RawMessage) error { + switch { + case item.Type != "" && item.Message != "": + fmt.Printf("%s\t%s\n", item.Type, item.Message) + case item.Type != "": + fmt.Println(item.Type) + case item.Message != "": + fmt.Println(item.Message) + default: + return printNDJSONBytes(raw) + } + + return nil +} + +// summarizeWorkerLog renders a worker-log history page (`{items:[…]}`) as one +// concise `type\tmessage` line per event — matching the `-f` stream rendering, +// so the follow and non-follow log paths present identical text (the generic +// id/name/status table would render worker-log events, which carry neither, as +// a useless block of dashes). It falls back to the raw body on an unexpected +// shape and to the empty message on an empty page. +func summarizeWorkerLog(body []byte, empty string) { + var payload struct { + Items []json.RawMessage `json:"items"` + } + + if err := json.Unmarshal(body, &payload); err != nil { + fmt.Println(strings.TrimSpace(string(body))) + + return + } + + if len(payload.Items) == 0 { + fmt.Println(empty) + + return + } + + for _, raw := range payload.Items { + var item workerLogItem + if err := json.Unmarshal(raw, &item); err != nil { + fmt.Println(strings.TrimSpace(string(raw))) + + continue + } + + _ = printWorkerLogText(item, raw) + } +} + +// emitSnapshot emits a refetched /state snapshot: a compact NDJSON line under +// --json (NDJSON of snapshots), else a key/value summary. +func emitSnapshot(body []byte) error { + if isJSON() { + return printNDJSONBytes(body) + } + + summarizeWorkflowObject(body) + + return nil +} + +// resolveStreamResult maps a completed stream into a command result. A +// client-side terminal (errStreamComplete) returns termErr (an exit-code error +// or nil). A user interrupt (ctx cancelled) exits cleanly. Otherwise a genuine +// stream/transport error propagates; a plain EOF (streamErr nil) exits cleanly. +func resolveStreamResult(ctx context.Context, streamErr, termErr error) error { + if errors.Is(streamErr, errStreamComplete) { + return termErr + } + + if ctx.Err() != nil { + return nil + } + + if streamErr != nil && !errors.Is(streamErr, context.Canceled) { + return streamErr + } + + return nil +} + +// terminalRunStatuses is the set of terminal workflow run.status values. +var terminalRunStatuses = map[string]struct{}{ + "completed": {}, + "failed": {}, + "cancelled": {}, +} + +// isTerminalRunStatus reports whether a run.status is terminal. +func isTerminalRunStatus(status string) bool { + _, ok := terminalRunStatuses[status] + + return ok +} + +// runStatusExitError maps a terminal run.status to a command result: nil (exit +// 0) on completed, a code-1 exitCodeError on failed/cancelled, so a harness can +// branch on $?. +func runStatusExitError(status string) error { + if status == "completed" { + return nil + } + + return &exitCodeError{code: 1} +} diff --git a/pkg/cli/workflow_api.go b/pkg/cli/workflow_api.go new file mode 100644 index 00000000..16e73524 --- /dev/null +++ b/pkg/cli/workflow_api.go @@ -0,0 +1,135 @@ +package cli + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/spf13/cobra" +) + +var ( + workflowAPIData string + workflowAPIFollow bool +) + +var workflowAPICmd = &cobra.Command{ + Use: "api ", + Short: "Call an uncurated workflow-engine endpoint directly", + Long: `Call any workflow-engine endpoint through the passthrough. is relative to +/api/v1 (e.g. 'whiteboards' or 'whiteboards/{wb}/state'); a leading '/' or +'/api/v1' is stripped. --data is the request body (inline JSON or @file.json), +passed verbatim. -f streams the response as NDJSON under --json. + +Examples: + panda workflow api GET whiteboards + panda workflow api GET workflows/{wf}/runs/{run} + panda workflow api POST dispatch/simulate --data @sim.json + panda workflow api GET workflows/{wf}/runs/{run}/state/stream -f --json`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + method := strings.ToUpper(args[0]) + + segments, query, err := normalizeWorkflowAPIPath(args[1]) + if err != nil { + return err + } + + data, err := readInlineOrFile(workflowAPIData) + if err != nil { + return err + } + + if workflowAPIFollow { + return followWorkflowAPI(commandContext(cmd), method, data, query, segments) + } + + body, err := workflowSend(cmd.Context(), method, data, nil, query, segments...) + if err != nil { + return err + } + + if isJSON() { + return printJSONBytes(body) + } + + summarizeWorkflowObject(body) + + return nil + }, +} + +func init() { + workflowAPICmd.Flags().StringVar(&workflowAPIData, "data", "", + "Request body as inline JSON or @file.json") + workflowAPICmd.Flags().BoolVarP(&workflowAPIFollow, "follow", "f", false, + "Stream the response (SSE) as NDJSON under --json") + + workflowAPICmd.ValidArgsFunction = noCompletions +} + +// normalizeWorkflowAPIPath splits a raw `api` path into percent-encodable segments +// and an optional query. A leading '/' or a leading 'api/v1' SEGMENT is stripped +// so there is no double '/api/v1' (the engine's base path); 'api/v1foo' is left +// intact. Each segment is passed to workflowPath, which percent-encodes it. A +// malformed query string is surfaced as an error rather than silently dropped. +func normalizeWorkflowAPIPath(raw string) ([]string, url.Values, error) { + pathPart := raw + queryPart := "" + + if idx := strings.IndexByte(raw, '?'); idx >= 0 { + pathPart = raw[:idx] + queryPart = raw[idx+1:] + } + + pathPart = strings.TrimPrefix(pathPart, "/") + + // Strip a leading api/v1 only on a full-segment boundary so 'api/v1foo' is + // not mangled into 'foo'. + switch { + case pathPart == "api/v1": + pathPart = "" + case strings.HasPrefix(pathPart, "api/v1/"): + pathPart = strings.TrimPrefix(pathPart, "api/v1/") + } + + var segments []string + + for _, seg := range strings.Split(pathPart, "/") { + if seg != "" { + segments = append(segments, seg) + } + } + + var query url.Values + + if queryPart != "" { + parsed, err := url.ParseQuery(queryPart) + if err != nil { + return nil, nil, fmt.Errorf("parsing query %q: %w", queryPart, err) + } + + query = parsed + } + + return segments, query, nil +} + +// followWorkflowAPI streams a raw workflow endpoint, emitting each SSE data payload as +// NDJSON (under --json) or a text line. Worker-log endpoints frame events as a +// `page` batch, so a data payload with an items[] array is flattened to one +// line per item; other payloads (e.g. state-stream snapshots) pass through +// whole. It runs until EOF or Ctrl-C; there is no terminal-status contract for +// the raw hatch. +func followWorkflowAPI(ctx context.Context, method string, body []byte, query url.Values, segments []string) error { + resp, err := workflowStream(ctx, method, body, sseHeaders(), query, workflowPath(segments...)) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + streamErr := parseSSE(ctx, resp.Body, emitStreamItems) + + return resolveStreamResult(ctx, streamErr, nil) +} diff --git a/pkg/cli/workflow_artifact.go b/pkg/cli/workflow_artifact.go new file mode 100644 index 00000000..6db15233 --- /dev/null +++ b/pkg/cli/workflow_artifact.go @@ -0,0 +1,94 @@ +package cli + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" +) + +var workflowArtifactOut string + +var workflowArtifactCmd = &cobra.Command{ + Use: "artifact", + Short: "List and download a run's output artifacts", + Long: `Artifacts are a run's file outputs, referenced from run outputs as +$resource envelopes. List them to find the concrete row by slotName/mediaType, +then get its bytes. + +Examples: + panda workflow artifact list + panda workflow artifact get --out report.md`, +} + +var workflowArtifactListCmd = &cobra.Command{ + Use: "list ", + Short: "List a run's artifacts", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := workflowGet(cmd.Context(), nil, "workflows", args[0], "runs", args[1], "resources") + if err != nil { + return err + } + + return renderWorkflow(body, func(b []byte) { + summarizeWorkflowItems(b, "items", "No artifacts.") + }) + }, +} + +var workflowArtifactGetCmd = &cobra.Command{ + Use: "get ", + Short: "Download an artifact's raw bytes", + Long: `Download an artifact's content verbatim (may be binary). Use --out to +write a file; otherwise the bytes go to stdout (fine for a pipe, but binary +content can garble a TTY).`, + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + resp, err := workflowStream(cmd.Context(), "GET", nil, nil, nil, + workflowPath("workflows", args[0], "runs", args[1], "artifacts", args[2], "content")) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + if workflowArtifactOut == "" || workflowArtifactOut == "-" { + _, err := io.Copy(os.Stdout, resp.Body) + + return err + } + + file, err := os.Create(workflowArtifactOut) + if err != nil { + return fmt.Errorf("creating %s: %w", workflowArtifactOut, err) + } + + written, err := io.Copy(file, resp.Body) + if err != nil { + _ = file.Close() + + return fmt.Errorf("writing %s: %w", workflowArtifactOut, err) + } + + // Surface a Close error (e.g. a flush/fsync failure on the final block) + // instead of reporting success on a possibly-truncated file. + if err := file.Close(); err != nil { + return fmt.Errorf("closing %s: %w", workflowArtifactOut, err) + } + + fmt.Fprintf(os.Stderr, "wrote %d bytes to %s\n", written, workflowArtifactOut) + + return nil + }, +} + +func init() { + workflowArtifactCmd.AddCommand(workflowArtifactListCmd, workflowArtifactGetCmd) + + workflowArtifactGetCmd.Flags().StringVar(&workflowArtifactOut, "out", "", + "Output file (default: stdout)") + + workflowArtifactListCmd.ValidArgsFunction = noCompletions + workflowArtifactGetCmd.ValidArgsFunction = noCompletions +} diff --git a/pkg/cli/workflow_commands_test.go b/pkg/cli/workflow_commands_test.go new file mode 100644 index 00000000..b4af99ab --- /dev/null +++ b/pkg/cli/workflow_commands_test.go @@ -0,0 +1,262 @@ +package cli + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildSessionSendBodyMode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + interrupt bool + wantMode string + }{ + {name: "default queue", interrupt: false, wantMode: "queue"}, + {name: "interrupt stop_and_send", interrupt: true, wantMode: "stop_and_send"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + body, err := buildSessionSendBody("hello", tt.interrupt) + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + + assert.Equal(t, "message", payload["type"]) + assert.Equal(t, tt.wantMode, payload["mode"]) + assert.Equal(t, "hello", payload["content"]) + }) + } +} + +func TestBuildSessionCreateBodyOmitsInitialItemWithoutContent(t *testing.T) { + t.Parallel() + + body, err := buildSessionCreateBody("", "") + require.NoError(t, err) + + var payload map[string]any + require.NoError(t, json.Unmarshal(body, &payload)) + + _, hasInitialItem := payload["initialItem"] + assert.False(t, hasInitialItem, "no --content must send NO initialItem") + assert.JSONEq(t, `{}`, string(body)) +} + +func TestBuildSessionCreateBodyWithContent(t *testing.T) { + t.Parallel() + + body, err := buildSessionCreateBody("my title", "do the thing") + require.NoError(t, err) + + var payload struct { + Title string `json:"title"` + InitialItem struct { + Type string `json:"type"` + Mode string `json:"mode"` + Content string `json:"content"` + } `json:"initialItem"` + } + + require.NoError(t, json.Unmarshal(body, &payload)) + + assert.Equal(t, "my title", payload.Title) + assert.Equal(t, "message", payload.InitialItem.Type) + assert.Equal(t, "queue", payload.InitialItem.Mode) + assert.Equal(t, "do the thing", payload.InitialItem.Content) +} + +func TestBuildRunBodyEmbedsInputsAndDispatch(t *testing.T) { + t.Parallel() + + body, err := buildRunBody(`{"values":{"a":1}}`, `{"defaults":{"agent":"x"}}`) + require.NoError(t, err) + + assert.JSONEq(t, + `{"inputs":{"values":{"a":1}},"dispatchPolicy":{"defaults":{"agent":"x"}}}`, + string(body)) +} + +func TestBuildRunBodyEmptyIsEmptyObject(t *testing.T) { + t.Parallel() + + body, err := buildRunBody("", "") + require.NoError(t, err) + + assert.JSONEq(t, `{}`, string(body)) +} + +func TestDispatchEffectiveQueryOmitsScopeUnlessSet(t *testing.T) { + t.Parallel() + + assert.Nil(t, dispatchEffectiveQuery(""), "no --scope must send no scope param") + + query := dispatchEffectiveQuery("org") + require.NotNil(t, query) + assert.Equal(t, "org", query.Get("scope")) +} + +func TestNormalizeWorkflowAPIPathStripsPrefix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + wantSegments []string + wantQuery map[string]string + wantErr string + }{ + { + name: "bare relative path", + raw: "whiteboards", + wantSegments: []string{"whiteboards"}, + }, + { + name: "leading slash stripped", + raw: "/whiteboards", + wantSegments: []string{"whiteboards"}, + }, + { + name: "leading /api/v1 stripped", + raw: "/api/v1/whiteboards/wb_1/state", + wantSegments: []string{"whiteboards", "wb_1", "state"}, + }, + { + name: "api/v1 without leading slash stripped", + raw: "api/v1/workflows", + wantSegments: []string{"workflows"}, + }, + { + // Finding F: api/v1 strip must be segment-anchored — 'api/v1foo' is + // NOT the prefix, so it is left intact (splits on '/'). + name: "api/v1foo not mis-stripped", + raw: "api/v1foo/bar", + wantSegments: []string{"api", "v1foo", "bar"}, + }, + { + name: "query preserved", + raw: "runs?limit=5", + wantSegments: []string{"runs"}, + wantQuery: map[string]string{"limit": "5"}, + }, + { + // Finding F: a malformed query is surfaced, not silently dropped. + name: "malformed query surfaced as error", + raw: "runs?%zz", + wantErr: "parsing query", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + segments, query, err := normalizeWorkflowAPIPath(tt.raw) + + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + + return + } + + require.NoError(t, err) + assert.Equal(t, tt.wantSegments, segments) + + for k, v := range tt.wantQuery { + assert.Equal(t, v, query.Get(k)) + } + }) + } +} + +func TestQueueItemActionMapping(t *testing.T) { + t.Parallel() + + method, action, err := queueItemAction(true, false, false) + require.NoError(t, err) + assert.Equal(t, "POST", method) + assert.Equal(t, "retry", action) + + method, action, err = queueItemAction(false, true, false) + require.NoError(t, err) + assert.Equal(t, "POST", method) + assert.Equal(t, "skip", action) + + method, action, err = queueItemAction(false, false, true) + require.NoError(t, err) + assert.Equal(t, "DELETE", method) + assert.Equal(t, "", action) + + _, _, err = queueItemAction(false, false, false) + require.Error(t, err) + + _, _, err = queueItemAction(true, true, false) + require.Error(t, err) +} + +func TestSteerQueueItemAction(t *testing.T) { + t.Parallel() + + action, err := steerQueueItemAction(true, false) + require.NoError(t, err) + assert.Equal(t, "dismiss", action) + + action, err = steerQueueItemAction(false, true) + require.NoError(t, err) + assert.Equal(t, "retry", action) + + _, err = steerQueueItemAction(false, false) + require.Error(t, err) + + _, err = steerQueueItemAction(true, true) + require.Error(t, err) +} + +func TestWorkerLogQueryFilters(t *testing.T) { + t.Parallel() + + assert.Nil(t, workerLogQuery("", nil, nil)) + + query := workerLogQuery("cur1", []string{"tasks.x"}, []string{"te_1"}) + require.NotNil(t, query) + assert.Equal(t, "cur1", query.Get("cursor")) + assert.Equal(t, []string{"tasks.x"}, query["specNodeIds[]"]) + assert.Equal(t, []string{"te_1"}, query["taskExecutionIds[]"]) +} + +func TestBuildWhiteboardCreateBody(t *testing.T) { + t.Parallel() + + body, err := buildWhiteboardCreateBody("wb", "count blocks", []byte(`{"x":1}`)) + require.NoError(t, err) + + assert.JSONEq(t, `{"name":"wb","requirements":"count blocks","inputs":{"x":1}}`, string(body)) + + empty, err := buildWhiteboardCreateBody("", "", nil) + require.NoError(t, err) + assert.JSONEq(t, `{}`, string(empty)) +} + +func TestRunStatusExitError(t *testing.T) { + t.Parallel() + + assert.NoError(t, runStatusExitError("completed")) + + for _, status := range []string{"failed", "cancelled"} { + err := runStatusExitError(status) + require.Error(t, err) + + var exitErr *exitCodeError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, 1, exitErr.code) + } +} diff --git a/pkg/cli/workflow_coverage_test.go b/pkg/cli/workflow_coverage_test.go new file mode 100644 index 00000000..32606e8f --- /dev/null +++ b/pkg/cli/workflow_coverage_test.go @@ -0,0 +1,576 @@ +package cli + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// sseFlush writes an SSE frame to w and flushes it so the client parser sees it +// before the handler blocks or returns. +func sseFlush(t *testing.T, w http.ResponseWriter, frame string) { + t.Helper() + + _, err := io.WriteString(w, frame) + require.NoError(t, err) + + flusher, ok := w.(http.Flusher) + require.True(t, ok, "ResponseWriter must support Flush for SSE") + flusher.Flush() +} + +// G1: `run cancel` must accept a 204 with an empty body (no JSON parse) and print +// the `run cancelled` confirmation in text mode. +func TestRunCancelHandles204EmptyBody(t *testing.T) { + var gotMethod, gotPath string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + w.WriteHeader(http.StatusNoContent) // 204, empty body — must not be parsed. + })) + defer server.Close() + + setClientConfig(t, server.URL) + setOutputFormat(t, "text") + + cmd := testCommand() + + var out bytes.Buffer + + cmd.SetOut(&out) + + require.NotPanics(t, func() { + require.NoError(t, workflowRunCancelCmd.RunE(cmd, []string{"wf_1", "run_1"})) + }) + + assert.Equal(t, "run run_1 cancelled\n", out.String()) + assert.Equal(t, http.MethodPost, gotMethod) + assert.Equal(t, "/api/v1/workflow/workflows/wf_1/runs/run_1/cancel", gotPath) +} + +// G2: isTerminalRunStatus classifies the terminal run.status set. +func TestIsTerminalRunStatus(t *testing.T) { + t.Parallel() + + tests := []struct { + status string + want bool + }{ + {"completed", true}, + {"failed", true}, + {"cancelled", true}, + {"running", false}, + {"pending", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.status, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, isTerminalRunStatus(tt.status)) + }) + } +} + +// G2: runSnapshotTerminal derives (done, exitErr) from a run /state snapshot — +// the run-watch terminal derivation. Non-terminal/absent status is never done; +// completed is done with exit 0; failed/cancelled are done with exit code 1. +func TestRunSnapshotTerminal(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + snapshot string + wantDone bool + wantCode int // -1 = expect nil error + }{ + {"completed exits 0", `{"run":{"status":"completed"}}`, true, -1}, + {"failed exits 1", `{"run":{"status":"failed"}}`, true, 1}, + {"cancelled exits 1", `{"run":{"status":"cancelled"}}`, true, 1}, + {"running not terminal", `{"run":{"status":"running"}}`, false, -1}, + {"absent status not terminal", `{"run":{}}`, false, -1}, + {"malformed not terminal", "not json", false, -1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + done, err := runSnapshotTerminal([]byte(tt.snapshot)) + assert.Equal(t, tt.wantDone, done) + + if tt.wantCode < 0 { + assert.NoError(t, err) + + return + } + + var exitErr *exitCodeError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, tt.wantCode, exitErr.code) + }) + } +} + +// G2: followRunLogs streams the worker log while polling /state out-of-band; when +// the poll observes a terminal status it cancels the stream and exits with the +// status-derived code (0 on completed, non-zero on failed/cancelled). +func TestFollowRunLogsExitsOnPolledTerminalStatus(t *testing.T) { + tests := []struct { + name string + status string + wantCode int // -1 = nil (exit 0) + }{ + {"completed exits 0", "completed", -1}, + {"failed exits non-zero", "failed", 1}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + restore := workflowRunLogsPollGap + workflowRunLogsPollGap = 5 * time.Millisecond + defer func() { workflowRunLogsPollGap = restore }() + + release := make(chan struct{}) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/worker-log/stream") { + w.Header().Set("Content-Type", "text/event-stream") + sseFlush(t, w, "event: page\ndata: {\"items\":[{\"type\":\"worker.system\"}],"+ + "\"liveCursor\":\"c1\"}\n\n") + <-release // Keep the stream open until the poll cancels it. + + return + } + + // /state poll returns a terminal status immediately. + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"run":{"status":"`+tt.status+`"}}`) + })) + defer server.Close() + defer close(release) // LIFO: released before server.Close so the handler unblocks. + + setClientConfig(t, server.URL) + setOutputFormat(t, "json") + + var err error + + _ = captureStdout(t, func() { + err = followRunLogs(context.Background(), "wf_1", "run_1", "", nil, nil) + }) + + if tt.wantCode < 0 { + assert.NoError(t, err) + + return + } + + var exitErr *exitCodeError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, tt.wantCode, exitErr.code) + }) + } +} + +// L1: when the worker-log stream ends before the out-of-band /state poll +// observed a terminal status, followRunLogs must do one final synchronous +// /state read so a failed run still exits non-zero. Here the poll only ever +// sees "running"; the stream then EOFs and the final fetch reports "failed". +func TestFollowRunLogsFinalStateFetchSetsExitCode(t *testing.T) { + restore := workflowRunLogsPollGap + workflowRunLogsPollGap = time.Hour // only the immediate first poll fires + defer func() { workflowRunLogsPollGap = restore }() + + release := make(chan struct{}) + firstPoll := make(chan struct{}) + + var stateCalls int32 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/worker-log/stream") { + w.Header().Set("Content-Type", "text/event-stream") + sseFlush(t, w, "event: page\ndata: {\"items\":[{\"type\":\"worker.system\"}],"+ + "\"liveCursor\":\"c1\"}\n\n") + <-release // hold open until the poll has observed a non-terminal status + + return + } + + // /state: the first (poll) read is non-terminal; the later final read is + // terminal, so only the final fetch can drive the exit code. + w.Header().Set("Content-Type", "application/json") + + if atomic.AddInt32(&stateCalls, 1) == 1 { + _, _ = io.WriteString(w, `{"run":{"status":"running"}}`) + close(firstPoll) + + return + } + + _, _ = io.WriteString(w, `{"run":{"status":"failed"}}`) + })) + defer server.Close() + + setClientConfig(t, server.URL) + setOutputFormat(t, "json") + + // Release the held-open stream only after the poll has read "running", so the + // poll never sees a terminal status and the final /state fetch is the only + // thing that can set a non-zero exit code. + go func() { + select { + case <-firstPoll: + case <-time.After(5 * time.Second): + } + + close(release) + }() + + var err error + + _ = captureStdout(t, func() { + err = followRunLogs(context.Background(), "wf_1", "run_1", "", nil, nil) + }) + + var exitErr *exitCodeError + require.ErrorAs(t, err, &exitErr) + assert.Equal(t, 1, exitErr.code) +} + +// G3: `artifact get --out FILE` writes the raw bytes to the file; `--out ""` +// streams them to stdout. +func TestArtifactGetOutFileAndStdout(t *testing.T) { + payload := "artifact bytes\x00\x01binary" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/v1/workflow/workflows/wf_1/runs/run_1/artifacts/art_1/content", r.URL.Path) + _, _ = io.WriteString(w, payload) + })) + defer server.Close() + + setClientConfig(t, server.URL) + setOutputFormat(t, "text") + + restore := workflowArtifactOut + defer func() { workflowArtifactOut = restore }() + + t.Run("out file", func(t *testing.T) { + outPath := filepath.Join(t.TempDir(), "report.md") + workflowArtifactOut = outPath + + require.NoError(t, workflowArtifactGetCmd.RunE(testCommand(), []string{"wf_1", "run_1", "art_1"})) + + data, err := os.ReadFile(outPath) + require.NoError(t, err) + assert.Equal(t, payload, string(data)) + }) + + t.Run("stdout", func(t *testing.T) { + workflowArtifactOut = "" + + out := captureStdout(t, func() { + require.NoError(t, workflowArtifactGetCmd.RunE(testCommand(), []string{"wf_1", "run_1", "art_1"})) + }) + + assert.Equal(t, payload, out) + }) +} + +// G4: `session logs -f` exits 0 on the FIRST terminal item, without waiting for +// end-of-stream. The server holds the connection open after the terminal frame; +// followSessionLogs must still return promptly (via the stream-complete sentinel). +func TestFollowSessionLogsExitsOnFirstTerminalItem(t *testing.T) { + release := make(chan struct{}) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + sseFlush(t, w, "event: page\ndata: {\"items\":[{\"type\":\"turn.completed\"}],"+ + "\"liveCursor\":\"c1\"}\n\n") + <-release // Stays open; the command must not wait for this to exit. + })) + defer server.Close() + defer close(release) + + setClientConfig(t, server.URL) + setOutputFormat(t, "json") + + var err error + + out := captureStdout(t, func() { + err = followSessionLogs(context.Background(), "wb_1", "sid_1", "") + }) + + require.NoError(t, err, "a terminal item must exit 0") + assert.Contains(t, out, "turn.completed") +} + +// G4: a non-terminal page must NOT end the follow — the stream keeps consuming +// items until a terminal item (or EOF) is reached. +func TestFollowSessionLogsContinuesPastNonTerminalItem(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + // A non-terminal page first, then a terminal page. If the non-terminal + // page ended the follow, only the first item would be emitted. + sseFlush(t, w, "event: page\ndata: {\"items\":[{\"type\":\"worker.system\"}],"+ + "\"liveCursor\":\"c1\"}\n\n") + sseFlush(t, w, "event: page\ndata: {\"items\":[{\"type\":\"turn.completed\"}],"+ + "\"liveCursor\":\"c2\"}\n\n") + })) + defer server.Close() + + setClientConfig(t, server.URL) + setOutputFormat(t, "json") + + var err error + + out := captureStdout(t, func() { + err = followSessionLogs(context.Background(), "wb_1", "sid_1", "") + }) + + require.NoError(t, err) + + lines := strings.Split(strings.TrimSpace(out), "\n") + require.Len(t, lines, 2, "both the non-terminal and terminal items must be emitted") + assert.Contains(t, lines[0], "worker.system") + assert.Contains(t, lines[1], "turn.completed") +} + +// G5: `watch` follows the snapshot policy — it emits the initial /state snapshot, +// then refetches and emits the /state snapshot per stream event, as NDJSON of +// snapshots. The raw stream delta is never emitted. +func TestWatchWorkflowStateEmitsSnapshotsNotDelta(t *testing.T) { + const snapshot = `{"whiteboard":{"id":"wb_1"},"cursor":7}` + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/state/stream"): + w.Header().Set("Content-Type", "text/event-stream") + // The stream delta carries a marker the snapshot policy must ignore. + sseFlush(t, w, "event: state.updated\ndata: {\"delta\":\"IGNORE_ME\"}\n\n") + case strings.HasSuffix(r.URL.Path, "/state"): + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, snapshot) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + setClientConfig(t, server.URL) + setOutputFormat(t, "json") + + var err error + + out := captureStdout(t, func() { + err = watchWorkflowState( + context.Background(), + 0, + []string{"whiteboards", "wb_1", "state"}, + []string{"whiteboards", "wb_1", "state", "stream"}, + nil, + ) + }) + + require.NoError(t, err) + assert.NotContains(t, out, "IGNORE_ME", "the raw stream delta must never be emitted") + + lines := strings.Split(strings.TrimSpace(out), "\n") + require.Len(t, lines, 2, "initial snapshot + one refetched snapshot") + + for _, line := range lines { + assert.JSONEq(t, snapshot, line, "every emitted line is a full /state snapshot") + } +} + +// G6: read/write commands guard their required flags with a clear error before +// any request is made. +func TestWorkflowRequiredFlagGuards(t *testing.T) { + tests := []struct { + name string + reset func() + run func() error + wantMsg string + }{ + { + name: "steer send needs --message", + reset: func() { workflowSteerMessage = "" }, + run: func() error { return workflowSteerSendCmd.RunE(testCommand(), []string{"wf", "run", "task"}) }, + wantMsg: "--message is required", + }, + { + name: "session send needs --content", + reset: func() { workflowSessionContent = "" }, + run: func() error { return workflowSessionSendCmd.RunE(testCommand(), []string{"wb", "sid"}) }, + wantMsg: "--content is required", + }, + { + name: "dispatch simulate needs --data", + reset: func() { workflowDispatchData = "" }, + run: func() error { return workflowDispatchSimulateCmd.RunE(testCommand(), nil) }, + wantMsg: "--data is required", + }, + { + name: "draft revise needs --spec", + reset: func() { workflowDraftSpec = "" }, + run: func() error { return workflowDraftReviseCmd.RunE(testCommand(), []string{"wb", "draft"}) }, + wantMsg: "--spec is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.reset() + + err := tt.run() + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantMsg) + }) + } +} + +// Part 2: `run get` text mode surfaces the run id, status, and the scalar +// run.outputs map — the load-bearing fields that live under `.run` and were +// hidden by the previous top-level-scalar-only summary. +func TestSummarizeRunStateSurfacesStatusAndOutputs(t *testing.T) { + setOutputFormat(t, "text") + + body := `{"cursor":243,"run":{"id":"run_1","status":"completed",` + + `"outputs":{"sum":300,"verdict":"correct"},` + + `"inputs":{"a":1},"dispatchPolicy":{"defaults":{"agent":"x"}}}}` + + out := captureStdout(t, func() { + summarizeRunState([]byte(body)) + }) + + assert.Contains(t, out, "run_1") + assert.Contains(t, out, "completed") + assert.Contains(t, out, "outputs:") + assert.Contains(t, out, "sum") + assert.Contains(t, out, "300") + assert.Contains(t, out, "verdict") + assert.Contains(t, out, "correct") + assert.NotContains(t, out, "dispatchPolicy", "nested run fields must not dump") + assert.NotContains(t, out, "243", "the top-level cursor is no longer the only field shown") +} + +// Part 2: a body without a `.run` object falls back to the generic scalar summary +// rather than printing nothing. +func TestSummarizeRunStateFallsBackWithoutRun(t *testing.T) { + setOutputFormat(t, "text") + + out := captureStdout(t, func() { + summarizeRunState([]byte(`{"cursor":9}`)) + }) + + assert.Contains(t, out, "cursor") + assert.Contains(t, out, "9") +} + +// Part 2: `whiteboard get` text mode surfaces the whiteboard id/name/status, the +// latestDraftId/latestSessionId pointers, and the draft/session counts. +func TestSummarizeWhiteboardStateSurfacesKeyFields(t *testing.T) { + setOutputFormat(t, "text") + + body := `{"cursor":152,"whiteboard":{"id":"wb_1","name":"acc calc","status":"archived",` + + `"latestDraftId":"dr_1","latestSessionId":"ses_1",` + + `"requirements":"Calculate 7 + 8 and verify the result."},` + + `"drafts":[{"id":"dr_1"},{"id":"dr_2"},{"id":"dr_3"}],"sessions":[{"id":"ses_1"}]}` + + out := captureStdout(t, func() { + summarizeWhiteboardState([]byte(body)) + }) + + assert.Contains(t, out, "wb_1") + assert.Contains(t, out, "acc calc") + assert.Contains(t, out, "archived") + assert.Contains(t, out, "latestDraftId") + assert.Contains(t, out, "dr_1") + assert.Contains(t, out, "latestSessionId") + assert.Contains(t, out, "ses_1") + assert.Contains(t, out, "drafts") + assert.Contains(t, out, "3") + assert.Contains(t, out, "sessions") + assert.Contains(t, out, "1") +} + +// Part 2: a body without a `.whiteboard` object falls back to the generic scalar +// summary. +func TestSummarizeWhiteboardStateFallsBackWithoutWhiteboard(t *testing.T) { + setOutputFormat(t, "text") + + out := captureStdout(t, func() { + summarizeWhiteboardState([]byte(`{"cursor":5}`)) + }) + + assert.Contains(t, out, "cursor") + assert.Contains(t, out, "5") +} + +// The folded `dispatch agents|workers|operations` commands must hit the same +// engine resources the old `agent list` / `worker list` / `worker operations` +// groups did, now under the `/api/v1/workflow` passthrough prefix. +func TestDispatchFoldedCommandsHitEngineEndpoints(t *testing.T) { + tests := []struct { + name string + cmd *cobra.Command + wantPath string + }{ + {"agents", workflowDispatchAgentsCmd, "/api/v1/workflow/agents"}, + {"workers", workflowDispatchWorkersCmd, "/api/v1/workflow/worker-identities"}, + {"operations", workflowDispatchOperationsCmd, "/api/v1/workflow/workers/operations"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotPath, gotMethod string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotMethod = r.Method + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"items":[]}`) + })) + defer server.Close() + + setClientConfig(t, server.URL) + setOutputFormat(t, "json") + + _ = captureStdout(t, func() { + require.NoError(t, tt.cmd.RunE(testCommand(), nil)) + }) + + assert.Equal(t, http.MethodGet, gotMethod) + assert.Equal(t, tt.wantPath, gotPath) + }) + } +} + +// Bare `panda workflow run ` must print the two-line pointer to `run create` +// and `draft run`, not cobra's unknown-command error. +func TestBareWorkflowRunPrintsPointer(t *testing.T) { + cmd := testCommand() + + var out bytes.Buffer + + cmd.SetOut(&out) + + require.NoError(t, workflowRunCmd.RunE(cmd, []string{"wf_1"})) + + got := out.String() + assert.Contains(t, got, "panda workflow run create wf_1") + assert.Contains(t, got, "panda workflow draft run") +} diff --git a/pkg/cli/workflow_dispatch.go b/pkg/cli/workflow_dispatch.go new file mode 100644 index 00000000..b93feb1b --- /dev/null +++ b/pkg/cli/workflow_dispatch.go @@ -0,0 +1,178 @@ +package cli + +import ( + "fmt" + "net/url" + + "github.com/spf13/cobra" +) + +var ( + workflowDispatchScope string + workflowDispatchData string +) + +var workflowDispatchCmd = &cobra.Command{ + Use: "dispatch", + Short: "Inspect dispatch placement, agents, and workers", + Long: `Inspect the dispatch inventory, effective policy, health, agents, and +worker identities/operations, and simulate placement decisions. + +Examples: + panda workflow dispatch inventory + panda workflow dispatch effective + panda workflow dispatch effective --scope org + panda workflow dispatch simulate --data @sim.json + panda workflow dispatch agents + panda workflow dispatch workers + panda workflow dispatch operations`, +} + +var workflowDispatchInventoryCmd = &cobra.Command{ + Use: "inventory", + Short: "List (agent, model) pairs with healthy workers", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + body, err := workflowGet(cmd.Context(), nil, "dispatch", "inventory") + if err != nil { + return err + } + + return renderWorkflow(body, func(b []byte) { + summarizeWorkflowItems(b, "entries", "No inventory entries.") + }) + }, +} + +var workflowDispatchEffectiveCmd = &cobra.Command{ + Use: "effective", + Short: "Show the effective dispatch policy", + Long: `Show the effective dispatch policy. --scope me|org narrows it; when +--scope is unset the CLI sends no scope param and the engine applies its own default.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + body, err := workflowGet(cmd.Context(), dispatchEffectiveQuery(workflowDispatchScope), + "dispatch", "effective") + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +var workflowDispatchHealthCmd = &cobra.Command{ + Use: "health", + Short: "Show dispatch cooldowns/health", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + body, err := workflowGet(cmd.Context(), nil, "dispatch", "health") + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +var workflowDispatchSimulateCmd = &cobra.Command{ + Use: "simulate", + Short: "Simulate a dispatch placement decision", + Long: `Simulate a placement decision. --data is the request body (inline JSON +or @file.json), passed verbatim.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + data, err := readInlineOrFile(workflowDispatchData) + if err != nil { + return err + } + + if len(data) == 0 { + return fmt.Errorf("--data is required for 'dispatch simulate'") + } + + body, err := workflowSend(cmd.Context(), "POST", data, nil, nil, "dispatch", "simulate") + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +var workflowDispatchAgentsCmd = &cobra.Command{ + Use: "agents", + Short: "List agents and their workers", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + body, err := workflowGet(cmd.Context(), nil, "agents") + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +var workflowDispatchWorkersCmd = &cobra.Command{ + Use: "workers", + Short: "List worker identities", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + body, err := workflowGet(cmd.Context(), nil, "worker-identities") + if err != nil { + return err + } + + return renderWorkflow(body, func(b []byte) { + summarizeWorkflowItems(b, "workerIdentities", "No worker identities.") + }) + }, +} + +var workflowDispatchOperationsCmd = &cobra.Command{ + Use: "operations", + Short: "List queued/running worker operations", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + body, err := workflowGet(cmd.Context(), nil, "workers", "operations") + if err != nil { + return err + } + + return renderWorkflow(body, func(b []byte) { + summarizeWorkflowItems(b, "items", "No worker operations.") + }) + }, +} + +func init() { + workflowDispatchCmd.AddCommand( + workflowDispatchInventoryCmd, + workflowDispatchEffectiveCmd, + workflowDispatchHealthCmd, + workflowDispatchSimulateCmd, + workflowDispatchAgentsCmd, + workflowDispatchWorkersCmd, + workflowDispatchOperationsCmd, + ) + + workflowDispatchEffectiveCmd.Flags().StringVar(&workflowDispatchScope, "scope", "", + "Narrow the effective policy (me or org)") + workflowDispatchSimulateCmd.Flags().StringVar(&workflowDispatchData, "data", "", + "Simulation request body as inline JSON or @file.json") + + _ = workflowDispatchEffectiveCmd.RegisterFlagCompletionFunc("scope", + cobra.FixedCompletions([]string{"me", "org"}, cobra.ShellCompDirectiveNoFileComp)) +} + +// dispatchEffectiveQuery builds the query for GET /dispatch/effective. It adds +// a scope param only when scope is non-empty; an unset scope sends no param so +// the engine applies its server default. +func dispatchEffectiveQuery(scope string) url.Values { + if scope == "" { + return nil + } + + return url.Values{"scope": []string{scope}} +} diff --git a/pkg/cli/workflow_docs.go b/pkg/cli/workflow_docs.go new file mode 100644 index 00000000..1596dc25 --- /dev/null +++ b/pkg/cli/workflow_docs.go @@ -0,0 +1,78 @@ +package cli + +import ( + "embed" + "fmt" + + "github.com/spf13/cobra" +) + +// The workflow docs are embedded in the CLI and never registered as server +// resources: the workflow engine has no MCP surface, so MCP clients neither +// list nor read these docs. +// +//go:embed workflowdocs/*.md +var workflowDocFiles embed.FS + +// workflowDocsTopics maps a `panda workflow docs [topic]` topic to its +// embedded file. +var workflowDocsTopics = map[string]string{ + "": "workflowdocs/guide.md", + "guide": "workflowdocs/guide.md", + "api": "workflowdocs/api.md", +} + +var workflowDocsCmd = &cobra.Command{ + Use: "docs [topic]", + Short: "Show the workflow-engine lifecycle guide and API cheat-sheet", + Long: `Show the embedded workflow-engine documentation. No topic (or 'guide') +shows the lifecycle guide; 'api' shows the endpoint cheat-sheet. + +Examples: + panda workflow docs + panda workflow docs guide + panda workflow docs api`, + Args: cobra.MaximumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + topic := "" + if len(args) == 1 { + topic = args[0] + } + + path, ok := workflowDocsTopics[topic] + if !ok { + return fmt.Errorf("unknown docs topic %q; valid topics: guide, api", topic) + } + + data, err := workflowDocFiles.ReadFile(path) + if err != nil { + return fmt.Errorf("reading embedded doc %s: %w", path, err) + } + + if isJSON() { + return printJSON(map[string]string{ + "topic": topicOrGuide(topic), + "content": string(data), + }) + } + + fmt.Print(string(data)) + + return nil + }, +} + +// topicOrGuide normalizes the empty default topic to its real name. +func topicOrGuide(topic string) string { + if topic == "" { + return "guide" + } + + return topic +} + +func init() { + workflowDocsCmd.ValidArgsFunction = func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return []string{"guide", "api"}, cobra.ShellCompDirectiveNoFileComp + } +} diff --git a/pkg/cli/workflow_draft.go b/pkg/cli/workflow_draft.go new file mode 100644 index 00000000..339b459b --- /dev/null +++ b/pkg/cli/workflow_draft.go @@ -0,0 +1,273 @@ +package cli + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" +) + +var ( + workflowDraftSpec string + workflowDraftInputs string + workflowDraftDispatch string + workflowDraftApproved string +) + +// requireDraftApproval is the tripwire in front of the publish/run side-effect +// boundary: the caller must re-type the exact draft id as --approved, proving +// the review checkpoint happened against THIS draft — hard to pass by +// accident, still scriptable. A stale approval (older draft id after a new +// revision) fails the match by construction. The flag is proof of review, not +// the approval itself — that must come from the user. +func requireDraftApproval(approved, draftID string) error { + switch { + case approved == "": + return fmt.Errorf(`this command crosses the publish/run side-effect boundary and requires --approved . + +The flag is proof the review checkpoint happened — not a formality to skip: + 1. render the draft for the user: panda workflow draft show %[1]s + 2. get their explicit publish/run approval for THIS draft + 3. re-run this command with: --approved %[1]s + +If the user has not explicitly approved, stop and ask them — do not pass +--approved on their behalf. Approval rules: 'panda workflow docs'`, draftID) + case approved != draftID: + return fmt.Errorf( + "--approved %s does not match the draft argument %s — approval binds to one exact draft; "+ + "if a newer revision appeared, re-review it (draft show) and get approval for its id", + approved, draftID) + } + + return nil +} + +var workflowDraftCmd = &cobra.Command{ + Use: "draft", + Short: "List, inspect, revise, publish, and run drafts", + Long: `Drafts are candidate workflow specs on a whiteboard. The engine owns +drafting — iterate via 'session send' rather than hand-authoring specs. + +Examples: + panda workflow draft list + panda workflow draft show + panda workflow draft get --json + panda workflow draft run --approved --json`, +} + +var workflowDraftListCmd = &cobra.Command{ + Use: "list ", + Short: "List a whiteboard's drafts", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := workflowGet(cmd.Context(), nil, "whiteboards", args[0], "drafts") + if err != nil { + return err + } + + return renderWorkflow(body, func(b []byte) { + summarizeWorkflowItems(b, "items", "No drafts.") + }) + }, +} + +var workflowDraftGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a draft (graph, inputs, spec)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := workflowGet(cmd.Context(), nil, "whiteboards", args[0], "drafts", args[1]) + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +var workflowDraftReviseCmd = &cobra.Command{ + Use: "revise ", + Short: "Post a hand-authored spec revision (expert escape hatch)", + Long: `Post a hand-authored authoredSpecYaml to the engine's manual-revision +endpoint. This is an expert/manual escape hatch — the normal path is to iterate +via 'session send' and let the engine draft. --spec is required (inline YAML or +@file); --inputs is an optional provided-inputs override.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + spec, err := readInlineOrFile(workflowDraftSpec) + if err != nil { + return err + } + + if len(spec) == 0 { + return fmt.Errorf("--spec is required (inline YAML or @file.yaml)") + } + + inputs, err := readJSONFlag("--inputs", workflowDraftInputs) + if err != nil { + return err + } + + payload, err := buildDraftRevisionBody(spec, inputs) + if err != nil { + return err + } + + body, err := workflowSend(cmd.Context(), "POST", payload, nil, nil, + "whiteboards", args[0], "drafts", args[1], "revisions") + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +var workflowDraftPublishCmd = &cobra.Command{ + Use: "publish ", + Short: "Publish a draft into a workflow (side-effect boundary)", + Long: `Publish a draft into an executable workflow. This crosses the +publish/run side-effect boundary — get the user's explicit approval for this +exact draft first; the original task request alone is not approval (see +'panda workflow docs'). + +Requires --approved (re-type the draft id being published) as proof +the user reviewed and approved this exact draft.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireDraftApproval(workflowDraftApproved, args[1]); err != nil { + return err + } + + body, err := workflowSend(cmd.Context(), "POST", nil, nil, nil, + "whiteboards", args[0], "drafts", args[1], "publish") + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +var workflowDraftRunCmd = &cobra.Command{ + Use: "run ", + Short: "Publish and run a draft in one step (side-effect boundary)", + Long: `Publish a draft and start a run in one call. This crosses the +publish/run side-effect boundary in a single step — do not invoke it until the +user has seen this exact draft (use 'draft show') and explicitly approved +publishing/running it; the original task request alone is not approval (see +'panda workflow docs'). + +Requires --approved (re-type the draft id being run) as proof the +user reviewed and approved this exact draft. A stale id from a superseded +revision is rejected. + +Inputs are usually optional (the engine defaults them); override with --inputs, and +pin placement with --dispatch. Both accept inline JSON or @file.json and are +placed at body.inputs and body.dispatchPolicy.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireDraftApproval(workflowDraftApproved, args[1]); err != nil { + return err + } + + payload, err := buildRunBody(workflowDraftInputs, workflowDraftDispatch) + if err != nil { + return err + } + + body, err := workflowSend(cmd.Context(), "POST", payload, nil, nil, + "whiteboards", args[0], "drafts", args[1], "run") + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +func init() { + workflowDraftCmd.AddCommand( + workflowDraftListCmd, + workflowDraftGetCmd, + workflowDraftShowCmd, + workflowDraftReviseCmd, + workflowDraftPublishCmd, + workflowDraftRunCmd, + ) + + workflowDraftReviseCmd.Flags().StringVar(&workflowDraftSpec, "spec", "", + "authoredSpecYaml as inline YAML or @file.yaml (required)") + workflowDraftReviseCmd.Flags().StringVar(&workflowDraftInputs, "inputs", "", + "Provided-inputs override as inline JSON or @file.json") + workflowDraftRunCmd.Flags().StringVar(&workflowDraftInputs, "inputs", "", + "Run inputs {values,artifacts,secrets} as inline JSON or @file.json") + workflowDraftRunCmd.Flags().StringVar(&workflowDraftDispatch, "dispatch", "", + "Dispatch policy overrides as inline JSON or @file.json") + + for _, c := range []*cobra.Command{workflowDraftPublishCmd, workflowDraftRunCmd} { + c.Flags().StringVar(&workflowDraftApproved, "approved", "", + "Draft id the user explicitly approved (must match the draft argument)") + } + + for _, c := range []*cobra.Command{ + workflowDraftListCmd, + workflowDraftGetCmd, + workflowDraftShowCmd, + workflowDraftReviseCmd, + workflowDraftPublishCmd, + workflowDraftRunCmd, + } { + c.ValidArgsFunction = completeWorkflowWhiteboardIDs + } +} + +// buildDraftRevisionBody assembles the manual-revision body. spec is the +// authoredSpecYaml (a YAML string carried as a JSON string); inputs, when +// present, is embedded verbatim at `.inputs`. +func buildDraftRevisionBody(spec, inputs []byte) ([]byte, error) { + body := struct { + AuthoredSpecYaml string `json:"authoredSpecYaml"` + Inputs json.RawMessage `json:"inputs,omitempty"` + }{ + AuthoredSpecYaml: string(spec), + Inputs: json.RawMessage(inputs), + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("building revision body: %w", err) + } + + return data, nil +} + +// buildRunBody assembles a run body {inputs, dispatchPolicy} from the inline- +// or-@file flag values, embedding each verbatim. An empty result marshals to +// `{}`, which workflow treats as default inputs/placement. +func buildRunBody(inputsFlag, dispatchFlag string) ([]byte, error) { + inputs, err := readJSONFlag("--inputs", inputsFlag) + if err != nil { + return nil, err + } + + dispatch, err := readJSONFlag("--dispatch", dispatchFlag) + if err != nil { + return nil, err + } + + body := struct { + Inputs json.RawMessage `json:"inputs,omitempty"` + DispatchPolicy json.RawMessage `json:"dispatchPolicy,omitempty"` + }{ + Inputs: json.RawMessage(inputs), + DispatchPolicy: json.RawMessage(dispatch), + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("building run body: %w", err) + } + + return data, nil +} diff --git a/pkg/cli/workflow_follow.go b/pkg/cli/workflow_follow.go new file mode 100644 index 00000000..c51493a5 --- /dev/null +++ b/pkg/cli/workflow_follow.go @@ -0,0 +1,444 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "strings" + "time" + + "github.com/spf13/cobra" +) + +// workflowRunFollowCmd is the background-friendly sibling of `run watch`. watch +// streams a full state snapshot per event — right for a foreground tail, far +// too verbose for a background task whose accumulated output gets read later; +// follow inverts the contract: change-only progress lines on stderr, exactly +// one final JSON summary on stdout, and the run-status exit code. +var workflowRunFollowCmd = &cobra.Command{ + Use: "follow ", + Short: "Follow a run to terminal: delta progress on stderr, one summary JSON on stdout", + Long: `Follow a run until terminal status, built for running as a background +task. Progress goes to stderr as one short line per change (task/run status +transitions, failures); stdout carries exactly one JSON summary object at +terminal — status, duration, task counts, failed-task errors, scalar outputs, +and artifact resources — so reading the output afterwards is cheap. A dropped +stream reconnects automatically (the run may finish during the gap; that is +caught on reconnect). + +The run-stream exit contract applies: 0 on completed, non-zero on +failed/cancelled. Ctrl-C detaches cleanly without a summary; the run keeps +going. --json is a no-op here — stdout is always the JSON summary. + +Foreground alternatives: 'run watch' (full snapshot stream), 'run logs -f' +(worker-log tail).`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return followWorkflowRun(commandContext(cmd), args[0], args[1]) + }, +} + +// followReconnects bounds CONSECUTIVE fruitless stream reconnect attempts: a +// stream that delivered at least one event resets the budget, so a long run +// behind an idle-timeouting load balancer is not bounded to 10 drops over its +// lifetime. Each reconnect refetches the state snapshot first, so a run that +// finished while the stream was down terminates immediately instead of burning +// an attempt waiting. +const followReconnects = 10 + +// followReconnectGap is the pause between stream reconnect attempts. +const followReconnectGap = 3 * time.Second + +// followTaskError caps a failed task's error text in progress lines and the +// summary, so one giant upstream error cannot bloat either. +const followTaskErrorCap = 300 + +// followView is the diffable digest of one run /state snapshot. +type followView struct { + initialized bool + runStatus string + order []string // task ids in upstream order + status map[string]string // task id → status + errs map[string]string // task id → compact error (failed tasks) +} + +// followArtifact is one artifact resource row in the final summary. +type followArtifact struct { + ID string `json:"id"` + SlotName string `json:"slotName,omitempty"` + MediaType string `json:"mediaType,omitempty"` + SizeBytes int64 `json:"sizeBytes,omitempty"` + TaskKey string `json:"taskKey,omitempty"` +} + +// followFailure names a failed task and its (capped) error in the summary. +type followFailure struct { + ID string `json:"id"` + Error string `json:"error,omitempty"` +} + +// runFollowSummary is the single stdout object `run follow` emits at terminal. +type runFollowSummary struct { + WorkflowID string `json:"workflowId"` + RunID string `json:"runId"` + Status string `json:"status"` + DurationSeconds float64 `json:"durationSeconds,omitempty"` + TasksTotal int `json:"tasksTotal"` + TasksByStatus map[string]int `json:"tasksByStatus,omitempty"` + FailedTasks []followFailure `json:"failedTasks,omitempty"` + Outputs map[string]any `json:"outputs,omitempty"` + Artifacts []followArtifact `json:"artifacts,omitempty"` + // Links carries frontend URLs (run, workflow) when the server exposes + // workflow's web origin; users log in there themselves. + Links map[string]string `json:"links,omitempty"` +} + +// followStatePayload is the decoded slice of a run /state snapshot that follow +// renders. Task identity prefers specNodeKey (the steer/log key) over id. +type followStatePayload struct { + Run struct { + ID string `json:"id"` + Status string `json:"status"` + Outputs map[string]any `json:"outputs"` + StartedAt string `json:"startedAt"` + FinishedAt string `json:"finishedAt"` + } `json:"run"` + Tasks []struct { + ID string `json:"id"` + SpecNodeKey string `json:"specNodeKey"` + TaskKey string `json:"taskKey"` + Status string `json:"status"` + Error any `json:"error"` + } `json:"tasks"` + Resources []struct { + ID string `json:"id"` + SlotName string `json:"slotName"` + MediaType string `json:"mediaType"` + SizeBytes int64 `json:"sizeBytes"` + TaskKey string `json:"taskKey"` + } `json:"resources"` +} + +// followWorkflowRun drives the follow loop: snapshot → delta → stream → refetch +// per event → delta, reconnecting on stream drops, until terminal run.status. +func followWorkflowRun(ctx context.Context, wf, run string) error { + statePath := []string{"workflows", wf, "runs", run, "state"} + streamPath := workflowPath("workflows", wf, "runs", run, "state", "stream") + + // Resolve the frontend origin once, best-effort: the summary carries + // clickable links when available and stays link-less otherwise. + webBase := workflowWebBaseBestEffort(ctx) + + prev := followView{} + fruitless := 0 + + for { + snap, err := workflowGet(ctx, nil, statePath...) + if err != nil { + return err + } + + view := parseFollowView(snap) + printFollowDelta(os.Stderr, run, prev, view) + prev = view + + if isTerminalRunStatus(view.runStatus) { + return emitFollowSummary(os.Stdout, snap, wf, run, webBase) + } + + termErr, streamErr, delivered := followStreamOnce(ctx, statePath, streamPath, &prev, wf, run, webBase) + if streamErr == nil || errors.Is(streamErr, errStreamComplete) { + return resolveStreamResult(ctx, streamErr, termErr) + } + + if ctx.Err() != nil { + fmt.Fprintf(os.Stderr, "detached from run %s (still active)\n", run) + + return nil + } + + // The budget bounds consecutive fruitless attempts, not lifetime drops: + // a stream that made progress before dropping resets it. + if delivered { + fruitless = 0 + } else { + fruitless++ + } + + if fruitless >= followReconnects { + return fmt.Errorf("stream failed after %d consecutive reconnects: %w", followReconnects, streamErr) + } + + fmt.Fprintf(os.Stderr, "stream dropped (%v) — reconnecting\n", streamErr) + + select { + case <-ctx.Done(): + return nil + case <-time.After(followReconnectGap): + } + } +} + +// followStreamOnce opens the state stream and refetches + diffs the snapshot +// per event until the run is terminal (summary emitted, errStreamComplete) or +// the stream drops (streamErr for the caller's reconnect loop). delivered +// reports whether the stream yielded at least one event before dropping, so +// the caller can reset its consecutive-reconnect budget. +func followStreamOnce( + ctx context.Context, + statePath []string, + streamPath string, + prev *followView, + wf, run, webBase string, +) (termErr, streamErr error, delivered bool) { + resp, err := workflowStream(ctx, "GET", nil, sseHeaders(), url.Values{}, streamPath) + if err != nil { + return nil, err, false + } + defer func() { _ = resp.Body.Close() }() + + streamErr = parseSSE(ctx, resp.Body, func(_ sseEvent) error { + delivered = true + + snap, refetchErr := workflowGet(ctx, nil, statePath...) + if refetchErr != nil { + return refetchErr + } + + view := parseFollowView(snap) + printFollowDelta(os.Stderr, run, *prev, view) + *prev = view + + if isTerminalRunStatus(view.runStatus) { + termErr = emitFollowSummary(os.Stdout, snap, wf, run, webBase) + + return errStreamComplete + } + + return nil + }) + + // A clean EOF before terminal is a drop for follow purposes: the caller + // must reconnect rather than exit without a summary. + if streamErr == nil { + streamErr = errors.New("stream closed before terminal status") + } + + return termErr, streamErr, delivered +} + +// parseFollowView digests a /state snapshot into the diffable view. +func parseFollowView(snapshot []byte) followView { + var payload followStatePayload + + view := followView{ + initialized: true, + status: map[string]string{}, + errs: map[string]string{}, + } + + if err := json.Unmarshal(snapshot, &payload); err != nil { + return view + } + + view.runStatus = payload.Run.Status + + for _, task := range payload.Tasks { + id := task.SpecNodeKey + if id == "" { + id = task.ID + } + + if id == "" { + id = task.TaskKey + } + + if id == "" { + continue + } + + view.order = append(view.order, id) + view.status[id] = task.Status + + if msg := compactFollowError(task.Error); msg != "" { + view.errs[id] = msg + } + } + + return view +} + +// compactFollowError renders a task error (string or structured) as a single +// capped line, or "" when absent. +func compactFollowError(v any) string { + var msg string + + switch t := v.(type) { + case nil: + return "" + case string: + msg = t + default: + data, err := json.Marshal(t) + if err != nil { + return "" + } + + msg = string(data) + } + + msg = strings.Join(strings.Fields(msg), " ") + if len(msg) > followTaskErrorCap { + msg = msg[:followTaskErrorCap] + "…" + } + + return msg +} + +// printFollowDelta writes change-only progress lines: an attach line on the +// first snapshot, then one line per run-status change, per new task, and per +// task transition (with the error for failures). An unchanged snapshot prints +// nothing. +func printFollowDelta(w io.Writer, run string, prev, view followView) { + if !prev.initialized { + _, _ = fmt.Fprintf(w, "following run %s status: %s tasks: %d\n", + run, orDash(view.runStatus), len(view.order)) + + for _, id := range view.order { + _, _ = fmt.Fprintf(w, " %s %s\n", id, orDash(view.status[id])) + } + + return + } + + if view.runStatus != prev.runStatus { + _, _ = fmt.Fprintf(w, "run %s → %s\n", orDash(prev.runStatus), orDash(view.runStatus)) + } + + for _, id := range view.order { + current := view.status[id] + + before, known := prev.status[id] + if known && before == current { + continue + } + + switch { + case !known: + _, _ = fmt.Fprintf(w, "task %s → %s\n", id, orDash(current)) + default: + _, _ = fmt.Fprintf(w, "task %s %s → %s\n", id, orDash(before), orDash(current)) + } + + if msg, failed := view.errs[id]; failed && msg != prev.errs[id] { + _, _ = fmt.Fprintf(w, " error: %s\n", msg) + } + } +} + +// orDash substitutes "-" for an empty status so delta lines stay readable. +func orDash(s string) string { + if s == "" { + return "-" + } + + return s +} + +// emitFollowSummary builds and prints the single stdout summary from the +// terminal snapshot, then returns the run-status exit error (nil on completed). +func emitFollowSummary(w io.Writer, snapshot []byte, wf, run, webBase string) error { + summary := buildFollowSummary(snapshot, wf, run, webBase) + + data, err := json.MarshalIndent(summary, "", " ") + if err != nil { + return fmt.Errorf("building summary: %w", err) + } + + _, _ = fmt.Fprintln(w, string(data)) + + return runStatusExitError(summary.Status) +} + +// buildFollowSummary digests the terminal /state snapshot into the summary +// object. It never fails: an undecodable snapshot yields a summary with only +// the ids, and the caller's exit code falls back to non-zero via the empty +// status. +func buildFollowSummary(snapshot []byte, wf, run, webBase string) *runFollowSummary { + summary := &runFollowSummary{WorkflowID: wf, RunID: run} + + if webBase != "" { + summary.Links = map[string]string{ + "run": workflowRunURL(webBase, wf, run), + "workflow": workflowWorkflowURL(webBase, wf), + } + } + + var payload followStatePayload + if err := json.Unmarshal(snapshot, &payload); err != nil { + return summary + } + + summary.Status = payload.Run.Status + summary.Outputs = payload.Run.Outputs + summary.TasksTotal = len(payload.Tasks) + summary.DurationSeconds = followDurationSeconds(payload.Run.StartedAt, payload.Run.FinishedAt) + + if len(payload.Tasks) > 0 { + summary.TasksByStatus = map[string]int{} + } + + for _, task := range payload.Tasks { + summary.TasksByStatus[orDash(task.Status)]++ + + if task.Status != "failed" { + continue + } + + id := task.SpecNodeKey + if id == "" { + id = task.ID + } + + summary.FailedTasks = append(summary.FailedTasks, followFailure{ + ID: id, + Error: compactFollowError(task.Error), + }) + } + + for _, res := range payload.Resources { + summary.Artifacts = append(summary.Artifacts, followArtifact{ + ID: res.ID, + SlotName: res.SlotName, + MediaType: res.MediaType, + SizeBytes: res.SizeBytes, + TaskKey: res.TaskKey, + }) + } + + return summary +} + +// followDurationSeconds derives the run duration from its RFC3339 timestamps, +// or 0 when either is absent/invalid. +func followDurationSeconds(startedAt, finishedAt string) float64 { + start, err := time.Parse(time.RFC3339, startedAt) + if err != nil { + return 0 + } + + end, err := time.Parse(time.RFC3339, finishedAt) + if err != nil { + return 0 + } + + if seconds := end.Sub(start).Seconds(); seconds > 0 { + return seconds + } + + return 0 +} diff --git a/pkg/cli/workflow_follow_test.go b/pkg/cli/workflow_follow_test.go new file mode 100644 index 00000000..86406d2d --- /dev/null +++ b/pkg/cli/workflow_follow_test.go @@ -0,0 +1,140 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const followSnapshotRunning = `{ + "run": {"id": "run_x", "status": "running", "outputs": {}}, + "tasks": [ + {"specNodeKey": "tasks.collect", "status": "completed"}, + {"specNodeKey": "tasks.analyze", "status": "running"} + ] +}` + +const followSnapshotTerminal = `{ + "run": { + "id": "run_x", "status": "failed", + "outputs": {"summary": "partial"}, + "startedAt": "2026-07-07T01:00:00Z", "finishedAt": "2026-07-07T01:02:30Z" + }, + "tasks": [ + {"specNodeKey": "tasks.collect", "status": "completed"}, + {"specNodeKey": "tasks.analyze", "status": "failed", "error": {"message": "boom", "code": 7}} + ], + "resources": [ + {"id": "wart_1", "slotName": "report", "mediaType": "text/markdown", "sizeBytes": 512, "taskKey": "tasks.analyze"} + ] +}` + +func TestParseFollowViewDigestsTasksAndErrors(t *testing.T) { + t.Parallel() + + view := parseFollowView([]byte(followSnapshotTerminal)) + + assert.Equal(t, "failed", view.runStatus) + assert.Equal(t, []string{"tasks.collect", "tasks.analyze"}, view.order) + assert.Equal(t, "failed", view.status["tasks.analyze"]) + assert.Contains(t, view.errs["tasks.analyze"], "boom") +} + +func TestPrintFollowDeltaAttachThenTransitions(t *testing.T) { + t.Parallel() + + first := parseFollowView([]byte(followSnapshotRunning)) + + var attach strings.Builder + + printFollowDelta(&attach, "run_x", followView{}, first) + assert.Contains(t, attach.String(), "following run run_x") + assert.Contains(t, attach.String(), "tasks.analyze running") + + second := parseFollowView([]byte(followSnapshotTerminal)) + + var delta strings.Builder + + printFollowDelta(&delta, "run_x", first, second) + + out := delta.String() + assert.Contains(t, out, "run running → failed") + assert.Contains(t, out, "task tasks.analyze running → failed") + assert.Contains(t, out, "error:") + assert.NotContains(t, out, "tasks.collect", + "an unchanged task must not produce a delta line") +} + +func TestPrintFollowDeltaUnchangedSnapshotIsSilent(t *testing.T) { + t.Parallel() + + view := parseFollowView([]byte(followSnapshotRunning)) + + var out strings.Builder + + printFollowDelta(&out, "run_x", view, view) + assert.Empty(t, out.String(), "no change must print nothing") +} + +func TestBuildFollowSummaryFromTerminalSnapshot(t *testing.T) { + t.Parallel() + + summary := buildFollowSummary([]byte(followSnapshotTerminal), "wf_x", "run_x", "https://workflow.example") + + assert.Equal(t, "wf_x", summary.WorkflowID) + assert.Equal(t, "run_x", summary.RunID) + assert.Equal(t, "failed", summary.Status) + assert.InDelta(t, 150.0, summary.DurationSeconds, 0.001) + assert.Equal(t, 2, summary.TasksTotal) + assert.Equal(t, map[string]int{"completed": 1, "failed": 1}, summary.TasksByStatus) + + require.Len(t, summary.FailedTasks, 1) + assert.Equal(t, "tasks.analyze", summary.FailedTasks[0].ID) + assert.Contains(t, summary.FailedTasks[0].Error, "boom") + + require.Len(t, summary.Artifacts, 1) + assert.Equal(t, "wart_1", summary.Artifacts[0].ID) + assert.Equal(t, "report", summary.Artifacts[0].SlotName) + + assert.Equal(t, "partial", summary.Outputs["summary"]) + assert.Equal(t, "https://workflow.example/workflows/wf_x/runs/run_x", summary.Links["run"]) + assert.Equal(t, "https://workflow.example/workflows/wf_x", summary.Links["workflow"]) +} + +func TestBuildFollowSummaryWithoutWebBaseHasNoLinks(t *testing.T) { + t.Parallel() + + summary := buildFollowSummary([]byte(followSnapshotTerminal), "wf_x", "run_x", "") + assert.Nil(t, summary.Links) +} + +func TestBuildFollowSummaryUndecodableSnapshotKeepsIDs(t *testing.T) { + t.Parallel() + + summary := buildFollowSummary([]byte("not json"), "wf_x", "run_x", "") + + assert.Equal(t, "wf_x", summary.WorkflowID) + assert.Empty(t, summary.Status) +} + +func TestCompactFollowErrorCapsAndFlattens(t *testing.T) { + t.Parallel() + + assert.Empty(t, compactFollowError(nil)) + assert.Equal(t, "plain text", compactFollowError("plain\n text")) + + long := strings.Repeat("x", followTaskErrorCap+50) + capped := compactFollowError(long) + assert.Len(t, capped, followTaskErrorCap+len("…")) +} + +func TestFollowDurationSeconds(t *testing.T) { + t.Parallel() + + assert.Zero(t, followDurationSeconds("", "2026-07-07T01:00:00Z")) + assert.Zero(t, followDurationSeconds("bad", "worse")) + assert.InDelta(t, 90.0, + followDurationSeconds("2026-07-07T01:00:00Z", "2026-07-07T01:01:30Z"), 0.001) +} diff --git a/pkg/cli/workflow_review.go b/pkg/cli/workflow_review.go new file mode 100644 index 00000000..34e397ed --- /dev/null +++ b/pkg/cli/workflow_review.go @@ -0,0 +1,440 @@ +package cli + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" +) + +// workflowDraftShowCmd renders the human-facing review of a draft — the plan +// presented at the review checkpoint before publish/run approval: `draft show` +// renders what would run, `draft run --approved` applies it. +var workflowDraftShowCmd = &cobra.Command{ + Use: "show ", + Short: "Render a draft review for the user (DAG, inputs, outputs)", + Long: `Render the human-facing review of a draft: id/revision/status, declared +inputs (with defaults), scalar and artifact outputs, and the task DAG in +dependency order. This is the review to present at the publish/run checkpoint +(see 'panda workflow docs') — show it to the user, then ask for approval. + +Under --json it emits the structured review (parsed from the draft's graph and +compiledSpecJson) rather than the raw draft object; use 'draft get' for that.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := workflowGet(cmd.Context(), nil, "whiteboards", args[0], "drafts", args[1]) + if err != nil { + return err + } + + review, err := buildDraftReview(body) + if err != nil { + return err + } + + // Best-effort frontend link so the review hands the user something + // clickable; a missing web origin degrades to a link-less review. + if base := workflowWebBaseBestEffort(cmd.Context()); base != "" { + review.WhiteboardURL = workflowWhiteboardURL(base, args[0]) + } + + if isJSON() { + return printJSON(review) + } + + renderDraftReviewText(review, args[0]) + + return nil + }, +} + +// draftReviewPort is one declared input or output slot in a draft review. +type draftReviewPort struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + Default any `json:"default,omitempty"` + HasDefault bool `json:"hasDefault,omitempty"` + Required bool `json:"required,omitempty"` + Description string `json:"description,omitempty"` + ContentType string `json:"contentType,omitempty"` +} + +// draftReviewPorts groups a draft's declared slots by section. +type draftReviewPorts struct { + Values []draftReviewPort `json:"values,omitempty"` + Artifacts []draftReviewPort `json:"artifacts,omitempty"` + Secrets []draftReviewPort `json:"secrets,omitempty"` +} + +// draftReviewNode is one DAG node in a draft review, in dependency order. +type draftReviewNode struct { + ID string `json:"id"` + Kind string `json:"kind,omitempty"` + Needs []string `json:"needs,omitempty"` + QualityGate bool `json:"qualityGate,omitempty"` + HasRetry bool `json:"hasRetry,omitempty"` +} + +// draftReview is the structured review of a draft: the reviewable surface of +// the workflow (identity, declared IO, DAG) without the embedded spec blobs. +type draftReview struct { + ID string `json:"id"` + Revision any `json:"revision,omitempty"` + Status string `json:"status,omitempty"` + Inputs draftReviewPorts `json:"inputs"` + Outputs draftReviewPorts `json:"outputs"` + DAG []draftReviewNode `json:"dag"` + // SpecNote flags a missing/unparseable compiledSpecJson so the reviewer + // knows the inputs/outputs sections are absent rather than empty. + SpecNote string `json:"specNote,omitempty"` + // WhiteboardURL is the frontend link for the draft's whiteboard, when the + // server exposes workflow's web origin. Users log in there themselves. + WhiteboardURL string `json:"whiteboardUrl,omitempty"` +} + +// compiledSpecPort is one slot spec inside a compiled spec's inputs/outputs +// sections. Only the review-relevant fields are declared. +type compiledSpecPort struct { + Description string `json:"description"` + Required bool `json:"required"` + ContentType string `json:"contentType"` + Schema struct { + Type string `json:"type"` + Default json.RawMessage `json:"default"` + } `json:"schema"` +} + +// buildDraftReview parses a raw draft object into a structured review. The DAG +// comes from `.graph.nodes[]`; declared inputs/outputs come from +// `.compiledSpecJson` (a JSON string, parsed here so callers never touch it). A +// missing or unparseable compiled spec degrades to a SpecNote instead of +// failing — the DAG alone is still reviewable. +func buildDraftReview(body []byte) (*draftReview, error) { + var raw struct { + ID string `json:"id"` + Revision any `json:"revision"` + Status string `json:"status"` + Graph struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Needs []string `json:"needs"` + QualityGate any `json:"qualityGate"` + HasRetry bool `json:"hasRetry"` + } `json:"nodes"` + } `json:"graph"` + CompiledSpecJSON string `json:"compiledSpecJson"` + } + + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("parsing draft: %w", err) + } + + if raw.ID == "" { + return nil, fmt.Errorf("response carries no draft id — is this a draft object? (use 'draft list' to find one)") + } + + review := &draftReview{ID: raw.ID, Revision: raw.Revision, Status: raw.Status} + + nodes := make([]draftReviewNode, 0, len(raw.Graph.Nodes)) + for _, n := range raw.Graph.Nodes { + nodes = append(nodes, draftReviewNode{ + ID: n.ID, + Kind: n.Kind, + Needs: n.Needs, + QualityGate: truthyFlag(n.QualityGate), + HasRetry: n.HasRetry, + }) + } + + review.DAG = topoSortReviewNodes(nodes) + + if err := parseCompiledSpecPorts(raw.CompiledSpecJSON, review); err != nil { + review.SpecNote = fmt.Sprintf( + "compiledSpecJson missing or unparseable (%v); declared inputs/outputs not shown", err) + } + + return review, nil +} + +// parseCompiledSpecPorts fills review.Inputs/Outputs from a compiled spec JSON +// string (`{inputs:{values,artifacts,secrets}, outputs:{…}}`). +func parseCompiledSpecPorts(compiled string, review *draftReview) error { + if strings.TrimSpace(compiled) == "" { + return fmt.Errorf("empty") + } + + var spec struct { + Inputs map[string]map[string]compiledSpecPort `json:"inputs"` + Outputs map[string]map[string]compiledSpecPort `json:"outputs"` + } + + if err := json.Unmarshal([]byte(compiled), &spec); err != nil { + return err + } + + review.Inputs = draftReviewPorts{ + Values: reviewPortList(spec.Inputs["values"]), + Artifacts: reviewPortList(spec.Inputs["artifacts"]), + Secrets: reviewPortList(spec.Inputs["secrets"]), + } + review.Outputs = draftReviewPorts{ + Values: reviewPortList(spec.Outputs["values"]), + Artifacts: reviewPortList(spec.Outputs["artifacts"]), + } + + return nil +} + +// reviewPortList converts one compiled-spec section map into a name-sorted +// slice of review ports. +func reviewPortList(section map[string]compiledSpecPort) []draftReviewPort { + if len(section) == 0 { + return nil + } + + ports := make([]draftReviewPort, 0, len(section)) + + for name, p := range section { + port := draftReviewPort{ + Name: name, + Type: p.Schema.Type, + Required: p.Required, + Description: p.Description, + ContentType: p.ContentType, + } + + if len(p.Schema.Default) > 0 && string(p.Schema.Default) != "null" { + var def any + if err := json.Unmarshal(p.Schema.Default, &def); err == nil { + port.Default = def + port.HasDefault = true + } + } + + ports = append(ports, port) + } + + sort.Slice(ports, func(i, j int) bool { return ports[i].Name < ports[j].Name }) + + return ports +} + +// truthyFlag interprets a graph-node flag that workflow may encode as a bool or a +// non-empty expression string. Anything else reads as unset. +func truthyFlag(v any) bool { + switch t := v.(type) { + case bool: + return t + case string: + return t != "" + default: + return false + } +} + +// topoSortReviewNodes orders nodes so every node appears after the nodes it +// needs (Kahn's algorithm in waves, preserving input order within a wave). +// `needs` entries are resolved against node ids first, then short names / +// dotted suffixes (authored specs reference siblings by short name). On a cycle +// or unresolvable tail, the remaining nodes are appended in input order rather +// than dropped. +func topoSortReviewNodes(nodes []draftReviewNode) []draftReviewNode { + if len(nodes) == 0 { + return nodes + } + + ids := make(map[string]bool, len(nodes)) + byShortName := make(map[string]string, len(nodes)) + + for _, n := range nodes { + ids[n.ID] = true + + if idx := strings.LastIndexByte(n.ID, '.'); idx >= 0 { + byShortName[n.ID[idx+1:]] = n.ID + } + } + + resolve := func(need string) string { + if ids[need] { + return need + } + + if full, ok := byShortName[need]; ok { + return full + } + + return need + } + + placed := make(map[string]bool, len(nodes)) + out := make([]draftReviewNode, 0, len(nodes)) + remaining := nodes + + for len(remaining) > 0 { + var next []draftReviewNode + + progressed := false + + for _, n := range remaining { + ready := true + + for _, need := range n.Needs { + if dep := resolve(need); ids[dep] && !placed[dep] { + ready = false + + break + } + } + + if ready { + out = append(out, n) + placed[n.ID] = true + progressed = true + } else { + next = append(next, n) + } + } + + if !progressed { + return append(out, next...) + } + + remaining = next + } + + return out +} + +// renderDraftReviewText prints the review for a human: header, declared IO, +// DAG in dependency order, and the checkpoint instructions (approve → run with +// --approved; changes → session send). Non-contractual; --json is the stable +// shape. +func renderDraftReviewText(review *draftReview, whiteboardID string) { + header := "Draft " + review.ID + + if review.Revision != nil { + header += fmt.Sprintf(" — revision %v", review.Revision) + } + + if review.Status != "" { + header += ", " + review.Status + } + + fmt.Println(header) + + if review.SpecNote != "" { + fmt.Printf("\nnote: %s\n", review.SpecNote) + } + + printReviewPorts("Inputs", review.Inputs, true) + printReviewPorts("Outputs", review.Outputs, false) + + fmt.Println("\nDAG (dependency order):") + + if len(review.DAG) == 0 { + fmt.Println(" (no graph nodes)") + } + + for _, n := range review.DAG { + line := " " + n.ID + + if len(n.Needs) > 0 { + line += " needs: " + strings.Join(n.Needs, ", ") + } + + if n.Kind != "" && n.Kind != "task" { + line += " (" + n.Kind + ")" + } + + if n.QualityGate { + line += " [quality-gate]" + } + + if n.HasRetry { + line += " [retry]" + } + + fmt.Println(line) + } + + if review.WhiteboardURL != "" { + fmt.Printf("\nWhiteboard: %s\n", review.WhiteboardURL) + } + + fmt.Printf(` +Present this to the user (with the whiteboard link when shown above) and ask: +publish and run, select workers/agents, iterate, or stop. On explicit approval +of THIS draft: + panda workflow draft run %s %s --approved %s +To iterate, send the user's change verbatim via 'session send'. +`, whiteboardID, review.ID, review.ID) +} + +// printReviewPorts prints one Inputs/Outputs block. Sections with no declared +// slots are omitted; a fully empty block prints "(none declared)". Secrets are +// only ever printed by name (withSecrets guards the section that has them). +func printReviewPorts(title string, ports draftReviewPorts, withSecrets bool) { + fmt.Printf("\n%s:\n", title) + + if len(ports.Values) == 0 && len(ports.Artifacts) == 0 && len(ports.Secrets) == 0 { + fmt.Println(" (none declared)") + + return + } + + for _, p := range ports.Values { + fmt.Printf(" values.%s\n", formatReviewPort(p)) + } + + for _, p := range ports.Artifacts { + line := " artifacts." + p.Name + if p.ContentType != "" { + line += " (" + p.ContentType + ")" + } + + if p.Description != "" { + line += " — " + p.Description + } + + fmt.Println(line) + } + + if withSecrets { + for _, p := range ports.Secrets { + fmt.Printf(" secrets.%s\n", p.Name) + } + } +} + +// formatReviewPort renders one values slot as `name (type, default: X, +// required) — description`, omitting absent parts. +func formatReviewPort(p draftReviewPort) string { + var attrs []string + + if p.Type != "" { + attrs = append(attrs, p.Type) + } + + if p.HasDefault { + attrs = append(attrs, fmt.Sprintf("default: %v", p.Default)) + } + + if p.Required { + attrs = append(attrs, "required") + } + + line := p.Name + if len(attrs) > 0 { + line += " (" + strings.Join(attrs, ", ") + ")" + } + + if p.Description != "" { + line += " — " + p.Description + } + + return line +} diff --git a/pkg/cli/workflow_review_test.go b/pkg/cli/workflow_review_test.go new file mode 100644 index 00000000..77b4ca25 --- /dev/null +++ b/pkg/cli/workflow_review_test.go @@ -0,0 +1,220 @@ +package cli + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// sampleDraftBody builds a realistic draft object: graph nodes out of +// dependency order, a compiledSpecJson embedded as a JSON string, and the +// provided-inputs override at top-level .inputs (which must be ignored). +func sampleDraftBody(t *testing.T) []byte { + t.Helper() + + compiled := map[string]any{ + "inputs": map[string]any{ + "values": map[string]any{ + "network": map[string]any{ + "description": "network to query", + "schema": map[string]any{"type": "string", "default": "mainnet"}, + }, + "hours": map[string]any{ + "required": true, + "schema": map[string]any{"type": "number", "default": 24}, + }, + }, + "secrets": map[string]any{ + "api_token": map[string]any{}, + }, + }, + "outputs": map[string]any{ + "values": map[string]any{ + "summary": map[string]any{"schema": map[string]any{"type": "string"}}, + }, + "artifacts": map[string]any{ + "report": map[string]any{"contentType": "text/markdown"}, + }, + }, + } + + compiledJSON, err := json.Marshal(compiled) + require.NoError(t, err) + + draft := map[string]any{ + "id": "dr_test", + "revision": 2, + "status": "candidate", + "inputs": map[string]any{}, + "graph": map[string]any{ + "nodes": []map[string]any{ + {"id": "tasks.report", "name": "report", "kind": "task", "needs": []string{"analyze"}}, + {"id": "tasks.collect", "name": "collect", "kind": "task"}, + { + "id": "tasks.analyze", "name": "analyze", "kind": "task", + "needs": []string{"tasks.collect"}, + "qualityGate": true, "hasRetry": true, + }, + }, + }, + "compiledSpecJson": string(compiledJSON), + } + + body, err := json.Marshal(draft) + require.NoError(t, err) + + return body +} + +func TestBuildDraftReviewParsesIdentityPortsAndDAG(t *testing.T) { + t.Parallel() + + review, err := buildDraftReview(sampleDraftBody(t)) + require.NoError(t, err) + + assert.Equal(t, "dr_test", review.ID) + assert.Equal(t, "candidate", review.Status) + assert.Empty(t, review.SpecNote) + + // Inputs sorted by name; defaults surfaced from schema.default. + require.Len(t, review.Inputs.Values, 2) + assert.Equal(t, "hours", review.Inputs.Values[0].Name) + assert.True(t, review.Inputs.Values[0].Required) + assert.True(t, review.Inputs.Values[0].HasDefault) + assert.Equal(t, "network", review.Inputs.Values[1].Name) + assert.Equal(t, "mainnet", review.Inputs.Values[1].Default) + assert.Equal(t, "network to query", review.Inputs.Values[1].Description) + + require.Len(t, review.Inputs.Secrets, 1) + assert.Equal(t, "api_token", review.Inputs.Secrets[0].Name) + + require.Len(t, review.Outputs.Artifacts, 1) + assert.Equal(t, "report", review.Outputs.Artifacts[0].Name) + assert.Equal(t, "text/markdown", review.Outputs.Artifacts[0].ContentType) + + // DAG in dependency order, resolving needs by full id AND short name. + require.Len(t, review.DAG, 3) + assert.Equal(t, "tasks.collect", review.DAG[0].ID) + assert.Equal(t, "tasks.analyze", review.DAG[1].ID) + assert.True(t, review.DAG[1].QualityGate) + assert.True(t, review.DAG[1].HasRetry) + assert.Equal(t, "tasks.report", review.DAG[2].ID) +} + +func TestBuildDraftReviewDegradesWithoutCompiledSpec(t *testing.T) { + t.Parallel() + + review, err := buildDraftReview([]byte(`{"id":"dr_x","graph":{"nodes":[{"id":"tasks.a"}]}}`)) + require.NoError(t, err) + + assert.NotEmpty(t, review.SpecNote, "missing compiledSpecJson must be flagged, not silent") + assert.Empty(t, review.Inputs.Values) + require.Len(t, review.DAG, 1) +} + +func TestBuildDraftReviewRejectsNonDraft(t *testing.T) { + t.Parallel() + + _, err := buildDraftReview([]byte(`{"items":[]}`)) + require.Error(t, err) + assert.Contains(t, err.Error(), "no draft id") +} + +func TestBuildDraftReviewStringQualityGate(t *testing.T) { + t.Parallel() + + body := []byte(`{"id":"dr_x","graph":{"nodes":[ + {"id":"tasks.a","qualityGate":"outputs.values.ok == true"}, + {"id":"tasks.b","qualityGate":""} + ]}}`) + + review, err := buildDraftReview(body) + require.NoError(t, err) + + assert.True(t, review.DAG[0].QualityGate, "CEL-string gate must read as set") + assert.False(t, review.DAG[1].QualityGate, "empty string must read as unset") +} + +func TestTopoSortReviewNodesCycleFallsBackToInputOrder(t *testing.T) { + t.Parallel() + + nodes := []draftReviewNode{ + {ID: "tasks.a", Needs: []string{"tasks.b"}}, + {ID: "tasks.b", Needs: []string{"tasks.a"}}, + {ID: "tasks.c"}, + } + + out := topoSortReviewNodes(nodes) + require.Len(t, out, 3, "a cycle must not drop nodes") + assert.Equal(t, "tasks.c", out[0].ID, "the acyclic node still sorts first") +} + +func TestTopoSortReviewNodesDanglingNeedIsIgnored(t *testing.T) { + t.Parallel() + + nodes := []draftReviewNode{ + {ID: "tasks.a", Needs: []string{"tasks.gone"}}, + } + + out := topoSortReviewNodes(nodes) + require.Len(t, out, 1, "a need pointing outside the graph must not wedge the sort") +} + +func TestRequireDraftApproval(t *testing.T) { + t.Parallel() + + t.Run("missing flag refuses with review instructions", func(t *testing.T) { + t.Parallel() + + err := requireDraftApproval("", "dr_x") + require.Error(t, err) + assert.Contains(t, err.Error(), "side-effect boundary") + assert.Contains(t, err.Error(), "draft show") + assert.Contains(t, err.Error(), "--approved dr_x") + }) + + t.Run("stale draft id refuses", func(t *testing.T) { + t.Parallel() + + err := requireDraftApproval("dr_old", "dr_new") + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match") + }) + + t.Run("matching draft id passes", func(t *testing.T) { + t.Parallel() + + require.NoError(t, requireDraftApproval("dr_x", "dr_x")) + }) +} + +func TestRequireRunApproval(t *testing.T) { + t.Parallel() + + t.Run("missing flag refuses and steers to the fresh path", func(t *testing.T) { + t.Parallel() + + err := requireRunApproval("", "wf_x") + require.Error(t, err) + assert.Contains(t, err.Error(), "side-effect boundary") + assert.Contains(t, err.Error(), "Reuse is not the default") + assert.Contains(t, err.Error(), "workflow list") + assert.Contains(t, err.Error(), "--approved wf_x") + }) + + t.Run("mismatched workflow id refuses", func(t *testing.T) { + t.Parallel() + + err := requireRunApproval("wf_other", "wf_x") + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match") + }) + + t.Run("matching workflow id passes", func(t *testing.T) { + t.Parallel() + + require.NoError(t, requireRunApproval("wf_x", "wf_x")) + }) +} diff --git a/pkg/cli/workflow_run.go b/pkg/cli/workflow_run.go new file mode 100644 index 00000000..c0d7da42 --- /dev/null +++ b/pkg/cli/workflow_run.go @@ -0,0 +1,524 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "sync" + "time" + + "github.com/spf13/cobra" +) + +var ( + workflowRunInputs string + workflowRunDispatch string + workflowRunApproved string + workflowRunTaskExec string + workflowRunSpecNode string + workflowRunCursor string + workflowRunAfterSeq int64 + workflowRunFollowLogs bool + workflowRunLogsPollGap = 3 * time.Second +) + +// workflowListCmd / workflowGetCmd / workflowReleaseCmd are the base workflow-noun +// verbs. They attach directly to `panda workflow` (there is no `workflow workflow` +// sub-noun), matching the `(base)` row in the CLI↔API table. +var workflowListCmd = &cobra.Command{ + Use: "list", + Short: "List workflows", + Long: `List the workflows published from drafts (the executable objects). + +Agents: 'workflow list' is for inspecting state or resolving a workflow the +user explicitly named — never for finding an existing workflow to run. A new +request starts with a fresh whiteboard → draft → review, not with reuse (see +'panda workflow docs'). + +Examples: + panda workflow list + panda workflow get + panda workflow release `, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + body, err := workflowGet(cmd.Context(), nil, "workflows") + if err != nil { + return err + } + + return renderWorkflow(body, func(b []byte) { + summarizeWorkflowItems(b, "items", "No workflows.") + }) + }, +} + +var workflowGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a workflow", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := workflowGet(cmd.Context(), nil, "workflows", args[0]) + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +var workflowReleaseCmd = &cobra.Command{ + Use: "release ", + Short: "Get a workflow release", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := workflowGet(cmd.Context(), nil, "workflows", args[0], "releases", args[1]) + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +var workflowRunCmd = &cobra.Command{ + Use: "run", + Short: "Create, list, inspect, follow, and cancel runs", + Long: `Runs are single executions of a workflow. The status→exit contract +applies here: 'run watch' and 'run logs -f' set the exit code from the terminal +run.status (0 completed, non-zero failed/cancelled). + +Examples: + panda workflow run create --approved --json + panda workflow run list + panda workflow run watch --json + panda workflow run follow # background-friendly: deltas on stderr, summary on stdout + panda workflow run logs -f --json + panda workflow run cancel `, + // Bare `panda workflow run ` is a likely first guess: point at the two + // real entry points instead of cobra's unknown-command error. + RunE: func(cmd *cobra.Command, args []string) error { + wf := "" + if len(args) > 0 { + wf = args[0] + } + + cmd.Printf("to run an existing workflow: panda workflow run create %s --approved %s\n", wf, wf) + cmd.Println("to publish and run a draft: panda workflow draft run --approved ") + + return nil + }, +} + +// requireRunApproval is the run-create tripwire: running an EXISTING workflow +// is both a side effect and a reuse decision, and neither is the agent's to +// make. The default path is a fresh whiteboard → draft → review → 'draft run'; +// 'run create' is only for a workflow the user explicitly named, and still +// needs their explicit run approval, proven by re-typing the workflow id. +func requireRunApproval(approved, workflowID string) error { + switch { + case approved == "": + return fmt.Errorf(`running an existing workflow crosses the side-effect boundary and requires --approved . + +Reuse is not the default: unless the user explicitly named workflow %[1]s +(or said to re-run an existing workflow), start fresh instead — whiteboard → +draft → review ('draft show') → approval → 'draft run'. Do not go looking in +'workflow list' for something to run. + +If the user DID name this workflow and explicitly approved running it: + re-run this command with: --approved %[1]s + +Do not pass --approved on the user's behalf. See 'panda workflow docs'`, workflowID) + case approved != workflowID: + return fmt.Errorf( + "--approved %s does not match the workflow argument %s — approval binds to one exact workflow", + approved, workflowID) + } + + return nil +} + +var workflowRunCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Start a run of an existing workflow (side-effect boundary)", + Long: `Start a run of an already-published workflow. This crosses the run +side-effect boundary AND reuses an existing workflow — only do it when the +user explicitly named this workflow (or asked to re-run one) and explicitly +approved running it; the default path for a new request is a fresh +whiteboard → draft → review → 'draft run' (see 'panda workflow docs'). + +Requires --approved (re-type the workflow id being run) as proof +of that approval.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := requireRunApproval(workflowRunApproved, args[0]); err != nil { + return err + } + + payload, err := buildRunBody(workflowRunInputs, workflowRunDispatch) + if err != nil { + return err + } + + body, err := workflowSend(cmd.Context(), "POST", payload, nil, nil, + "workflows", args[0], "runs") + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +var workflowRunListCmd = &cobra.Command{ + Use: "list ", + Short: "List a workflow's runs", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := workflowGet(cmd.Context(), nil, "workflows", args[0], "runs") + if err != nil { + return err + } + + return renderWorkflow(body, func(b []byte) { + summarizeWorkflowItems(b, "items", "No runs.") + }) + }, +} + +var workflowRunGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a run's state (status, outputs, tasks)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := workflowGet(cmd.Context(), nil, "workflows", args[0], "runs", args[1], "state") + if err != nil { + return err + } + + return renderWorkflow(body, summarizeRunState) + }, +} + +var workflowRunWatchCmd = &cobra.Command{ + Use: "watch ", + Short: "Watch a run's state stream to terminal", + Long: `Watch a run by streaming its state and refetching the /state snapshot +after each event (snapshot policy). Under --json each line is a full snapshot. +Exits at terminal run.status: 0 on completed, non-zero on failed/cancelled. +Ctrl-C exits cleanly.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return watchWorkflowState( + commandContext(cmd), + workflowRunAfterSeq, + []string{"workflows", args[0], "runs", args[1], "state"}, + []string{"workflows", args[0], "runs", args[1], "state", "stream"}, + runSnapshotTerminal, + ) + }, +} + +var workflowRunTasksCmd = &cobra.Command{ + Use: "tasks ", + Short: "List a run's tasks (spec-node keys for steering)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := workflowGet(cmd.Context(), nil, "workflows", args[0], "runs", args[1], "tasks") + if err != nil { + return err + } + + return renderWorkflow(body, func(b []byte) { + summarizeWorkflowItems(b, "items", "No tasks.") + }) + }, +} + +var workflowRunLogsCmd = &cobra.Command{ + Use: "logs ", + Short: "Read or follow a run's worker log", + Long: `Read a run's worker log. Without -f, a single GET of the log history. +With -f, follow the worker-log stream; because it carries no run.status, the +command polls .../runs/{run}/state out-of-band and exits at terminal run.status +(0 completed, non-zero failed/cancelled). --task-execution and --spec-node +filter; --cursor resumes.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + specNodes := nonEmptySlice(workflowRunSpecNode) + taskExecs := nonEmptySlice(workflowRunTaskExec) + + if !workflowRunFollowLogs { + body, err := workflowGet(cmd.Context(), + workerLogQuery(workflowRunCursor, specNodes, taskExecs), + "workflows", args[0], "runs", args[1], "worker-log") + if err != nil { + return err + } + + return renderWorkflow(body, func(b []byte) { + summarizeWorkerLog(b, "No log entries.") + }) + } + + return followRunLogs(commandContext(cmd), args[0], args[1], workflowRunCursor, specNodes, taskExecs) + }, +} + +var workflowRunCancelCmd = &cobra.Command{ + Use: "cancel ", + Short: "Cancel a run", + Long: `Cancel a run. The engine returns 204 with no body, so nothing is parsed; +the run transitions to cancelled.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if _, err := workflowSend(cmd.Context(), "POST", nil, nil, nil, + "workflows", args[0], "runs", args[1], "cancel"); err != nil { + return err + } + + if !isJSON() { + cmd.Printf("run %s cancelled\n", args[1]) + } + + return nil + }, +} + +func init() { + // The base workflow-noun verbs (workflowListCmd/GetCmd/ReleaseCmd) are wired + // onto workflowCmd in workflow.go's init. + workflowRunCmd.AddCommand( + workflowRunCreateCmd, + workflowRunListCmd, + workflowRunGetCmd, + workflowRunWatchCmd, + workflowRunFollowCmd, + workflowRunTasksCmd, + workflowRunLogsCmd, + workflowRunCancelCmd, + ) + + workflowRunCreateCmd.Flags().StringVar(&workflowRunInputs, "inputs", "", + "Run inputs {values,artifacts,secrets} as inline JSON or @file.json") + workflowRunCreateCmd.Flags().StringVar(&workflowRunDispatch, "dispatch", "", + "Dispatch policy overrides as inline JSON or @file.json") + workflowRunCreateCmd.Flags().StringVar(&workflowRunApproved, "approved", "", + "Workflow id the user explicitly named and approved running (must match the workflow argument)") + workflowRunWatchCmd.Flags().Int64Var(&workflowRunAfterSeq, "after-seq", 0, + "Resume the state stream after this numeric sequence") + workflowRunLogsCmd.Flags().BoolVarP(&workflowRunFollowLogs, "follow", "f", false, + "Follow the worker-log stream") + workflowRunLogsCmd.Flags().StringVar(&workflowRunTaskExec, "task-execution", "", + "Filter the worker log by task-execution id") + workflowRunLogsCmd.Flags().StringVar(&workflowRunSpecNode, "spec-node", "", + "Filter the worker log by spec-node key") + workflowRunLogsCmd.Flags().StringVar(&workflowRunCursor, "cursor", "", + "Resume the worker-log stream from this opaque cursor") + + for _, c := range []*cobra.Command{ + workflowRunCreateCmd, + workflowRunListCmd, + workflowRunGetCmd, + workflowRunWatchCmd, + workflowRunFollowCmd, + workflowRunTasksCmd, + workflowRunLogsCmd, + workflowRunCancelCmd, + } { + c.ValidArgsFunction = noCompletions + } +} + +// summarizeRunState renders a run /state snapshot for text mode: the run id, its +// status, and the scalar run.outputs map (one key: value line each). The nested +// run fields (inputs, dispatchPolicy) and the surrounding envelope (tasks, +// operations, resources) are omitted, and non-scalar output values are skipped +// so a large nested payload never dumps. --json remains the full-body contract. +// A body without a `.run` object falls back to the generic object summary. +func summarizeRunState(body []byte) { + var payload struct { + Run *struct { + ID string `json:"id"` + Status string `json:"status"` + Outputs map[string]any `json:"outputs"` + } `json:"run"` + } + + if err := json.Unmarshal(body, &payload); err != nil || payload.Run == nil { + summarizeWorkflowObject(body) + + return + } + + pairs := make([][2]string, 0, 2) + + if payload.Run.ID != "" { + pairs = append(pairs, [2]string{"run", payload.Run.ID}) + } + + if payload.Run.Status != "" { + pairs = append(pairs, [2]string{"status", payload.Run.Status}) + } + + if len(pairs) > 0 { + printKeyValue(pairs) + } + + printScalarMap("outputs", payload.Run.Outputs) +} + +// printScalarMap prints a titled block of a map's scalar entries as indented +// `key: value` lines, skipping nested objects/arrays and nil values so a large +// nested payload never dumps. Nothing is printed when no scalar entries remain. +func printScalarMap(title string, m map[string]any) { + if len(m) == 0 { + return + } + + keys := make([]string, 0, len(m)) + + for k, v := range m { + switch v.(type) { + case nil, map[string]any, []any: + // Skip nils and nested structures; --json shows them. + default: + keys = append(keys, k) + } + } + + if len(keys) == 0 { + return + } + + sort.Strings(keys) + + fmt.Printf("%s:\n", title) + + pairs := make([][2]string, 0, len(keys)) + for _, k := range keys { + pairs = append(pairs, [2]string{" " + k, fmt.Sprint(m[k])}) + } + + printKeyValue(pairs) +} + +// runSnapshotTerminal derives terminal exit from a run /state snapshot. +func runSnapshotTerminal(snapshot []byte) (bool, error) { + status := runStatusFromState(snapshot) + if status == "" || !isTerminalRunStatus(status) { + return false, nil + } + + return true, runStatusExitError(status) +} + +// runStatusFromState reads `.run.status` from a run /state snapshot. +func runStatusFromState(snapshot []byte) string { + var payload struct { + Run struct { + Status string `json:"status"` + } `json:"run"` + } + + if err := json.Unmarshal(snapshot, &payload); err != nil { + return "" + } + + return payload.Run.Status +} + +// followRunLogs streams a run's worker log while polling .../state out-of-band +// for the terminal run.status the worker-log stream does not carry. The stream +// is opened on a cancellable context so that cancelling it (once the poll sees +// a terminal status) unblocks the SSE read promptly instead of waiting for the +// next heartbeat. It polls immediately, then every workflowRunLogsPollGap, so an +// already-completed run exits at once. It exits with the status-derived code; +// Ctrl-C exits cleanly. +func followRunLogs(ctx context.Context, wf, run, cursor string, specNodes, taskExecs []string) error { + streamCtx, cancel := context.WithCancel(ctx) + defer cancel() + + resp, err := workflowStream(streamCtx, "GET", nil, sseHeaders(), + workerLogQuery(cursor, specNodes, taskExecs), + workflowPath("workflows", wf, "runs", run, "worker-log", "stream")) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + var ( + mu sync.Mutex + terminalStatus string + wg sync.WaitGroup + ) + + wg.Go(func() { + ticker := time.NewTicker(workflowRunLogsPollGap) + defer ticker.Stop() + + for { + status, pollErr := fetchRunStatus(streamCtx, wf, run) + if pollErr == nil && isTerminalRunStatus(status) { + mu.Lock() + terminalStatus = status + mu.Unlock() + cancel() + + return + } + + select { + case <-streamCtx.Done(): + return + case <-ticker.C: + } + } + }) + + streamErr := parseSSE(streamCtx, resp.Body, emitStreamItems) + + cancel() + wg.Wait() + + mu.Lock() + status := terminalStatus + mu.Unlock() + + if status != "" { + return runStatusExitError(status) + } + + // The worker-log stream carries no run.status and is not guaranteed to close + // at terminal, so it can EOF at completion before the poll (every + // workflowRunLogsPollGap) observed a terminal status — or the poll may have been + // erroring. Do one final synchronous /state read on the parent context (not + // the now-cancelled streamCtx) so a failed/cancelled run still exits non-zero. + // Skipped on user cancellation (Ctrl-C), where ctx.Err() is set. + if ctx.Err() == nil { + if finalStatus, ferr := fetchRunStatus(ctx, wf, run); ferr == nil && isTerminalRunStatus(finalStatus) { + return runStatusExitError(finalStatus) + } + } + + return resolveStreamResult(ctx, streamErr, nil) +} + +// fetchRunStatus reads the current run.status from the run /state endpoint. +func fetchRunStatus(ctx context.Context, wf, run string) (string, error) { + body, err := workflowGet(ctx, nil, "workflows", wf, "runs", run, "state") + if err != nil { + return "", err + } + + return runStatusFromState(body), nil +} + +// nonEmptySlice wraps a non-empty value in a single-element slice, else nil. +func nonEmptySlice(value string) []string { + if value == "" { + return nil + } + + return []string{value} +} diff --git a/pkg/cli/workflow_session.go b/pkg/cli/workflow_session.go new file mode 100644 index 00000000..1545df53 --- /dev/null +++ b/pkg/cli/workflow_session.go @@ -0,0 +1,437 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + + "github.com/google/uuid" + "github.com/spf13/cobra" +) + +var ( + workflowSessionTitle string + workflowSessionContent string + workflowSessionInterrupt bool + workflowSessionIdemKey string + workflowSessionFollow bool + workflowSessionCursor string + workflowQueueItemRetry bool + workflowQueueItemSkip bool + workflowQueueItemDelete bool +) + +var workflowSessionCmd = &cobra.Command{ + Use: "session", + Aliases: []string{"ses"}, + Short: "Drive whiteboard chat sessions with the drafting worker", + Long: `Sessions are your chat with the workflow-engine worker that writes drafts. Send +plain-language requests and let the engine draft; do not hand-author specs. + +Examples: + panda workflow session create --content "count blocks per day" + panda workflow session send --content "make it a loop" + panda workflow session logs -f --json + panda workflow session turns `, +} + +var workflowSessionCreateCmd = &cobra.Command{ + Use: "create ", + Short: "Create a session (optionally with a first message)", + Long: `Create a session on a whiteboard. --content is optional: with it the +CLI sends an initialItem (mode:queue); without it no initialItem is sent and the +first message goes via 'session send'.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + payload, err := buildSessionCreateBody(workflowSessionTitle, workflowSessionContent) + if err != nil { + return err + } + + body, err := workflowSend(cmd.Context(), "POST", payload, nil, nil, + "whiteboards", args[0], "sessions") + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +var workflowSessionSendCmd = &cobra.Command{ + Use: "send ", + Short: "Send a message to a session", + Long: `Send a message to a session. Defaults to mode:queue (starts a turn when +idle, enqueues behind a live turn). --interrupt selects mode:stop_and_send. A +random UUIDv4 Idempotency-Key is minted per invocation; override it with +--idempotency-key for cross-retry dedup.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if workflowSessionContent == "" { + return fmt.Errorf("--content is required for 'session send'") + } + + payload, err := buildSessionSendBody(workflowSessionContent, workflowSessionInterrupt) + if err != nil { + return err + } + + key := workflowSessionIdemKey + if key == "" { + key = uuid.NewString() + } + + headers := map[string]string{"Idempotency-Key": key} + + body, err := workflowSend(cmd.Context(), "POST", payload, headers, nil, + "whiteboards", args[0], "sessions", args[1], "items") + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +var workflowSessionLogsCmd = &cobra.Command{ + Use: "logs ", + Short: "Read or follow a session's worker log", + Long: `Read a session's worker log. Without -f, a single GET of the log +history. With -f, follow the worker-log stream; it exits 0 on the FIRST +turn/operation-terminal event (a queued item starts a NEW turn that is not +followed — re-invoke to follow it), and non-zero only on a stream/transport +error. --cursor resumes an opaque worker-log cursor.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if !workflowSessionFollow { + body, err := workflowGet(cmd.Context(), workerLogQuery(workflowSessionCursor, nil, nil), + "whiteboards", args[0], "sessions", args[1], "worker-log") + if err != nil { + return err + } + + return renderWorkflow(body, func(b []byte) { + summarizeWorkerLog(b, "No log entries.") + }) + } + + return followSessionLogs(commandContext(cmd), args[0], args[1], workflowSessionCursor) + }, +} + +var workflowSessionTurnsCmd = &cobra.Command{ + Use: "turns ", + Short: "List a session's turns", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := workflowGet(cmd.Context(), nil, "whiteboards", args[0], "sessions", args[1], "turns") + if err != nil { + return err + } + + return renderWorkflow(body, func(b []byte) { + summarizeWorkflowItems(b, "items", "No turns.") + }) + }, +} + +var workflowSessionQueueCmd = &cobra.Command{ + Use: "queue ", + Short: "Show a session's queue (parked + pending)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := workflowGet(cmd.Context(), nil, "whiteboards", args[0], "sessions", args[1], "queue") + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +var workflowSessionResumeCmd = &cobra.Command{ + Use: "resume ", + Short: "Resume a session", + Args: cobra.ExactArgs(2), + RunE: runSessionAction("resume"), +} + +var workflowSessionSkipCmd = &cobra.Command{ + Use: "skip ", + Short: "Skip the session's pending elicitation", + Args: cobra.ExactArgs(2), + RunE: runSessionAction("skip"), +} + +var workflowSessionInterruptCmd = &cobra.Command{ + Use: "interrupt ", + Short: "Interrupt the session's active worker operation", + Args: cobra.ExactArgs(2), + RunE: runSessionAction("interrupt"), +} + +var workflowSessionQueueItemCmd = &cobra.Command{ + Use: "queue-item ", + Short: "Retry, skip, or delete a session queue item", + Long: `Act on a session queue item by id. Exactly one of --retry (only valid +on a parked item), --skip, or --delete must be given.`, + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + method, action, err := queueItemAction( + workflowQueueItemRetry, workflowQueueItemSkip, workflowQueueItemDelete) + if err != nil { + return err + } + + segments := []string{"whiteboards", args[0], "sessions", args[1], "queue", args[2]} + if action != "" { + segments = append(segments, action) + } + + body, err := workflowSend(cmd.Context(), method, nil, nil, nil, segments...) + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +func init() { + workflowSessionCmd.AddCommand( + workflowSessionCreateCmd, + workflowSessionSendCmd, + workflowSessionLogsCmd, + workflowSessionTurnsCmd, + workflowSessionQueueCmd, + workflowSessionResumeCmd, + workflowSessionSkipCmd, + workflowSessionInterruptCmd, + workflowSessionQueueItemCmd, + ) + + workflowSessionCreateCmd.Flags().StringVar(&workflowSessionTitle, "title", "", "Session title") + workflowSessionCreateCmd.Flags().StringVar(&workflowSessionContent, "content", "", + "First message content (optional)") + workflowSessionSendCmd.Flags().StringVar(&workflowSessionContent, "content", "", "Message content") + workflowSessionSendCmd.Flags().BoolVar(&workflowSessionInterrupt, "interrupt", false, + "Interrupt the live turn and send now (mode:stop_and_send)") + workflowSessionSendCmd.Flags().StringVar(&workflowSessionIdemKey, "idempotency-key", "", + "Idempotency-Key override (default: a fresh UUIDv4 per invocation)") + workflowSessionLogsCmd.Flags().BoolVarP(&workflowSessionFollow, "follow", "f", false, + "Follow the worker-log stream") + workflowSessionLogsCmd.Flags().StringVar(&workflowSessionCursor, "cursor", "", + "Resume the worker-log stream from this opaque cursor") + + workflowSessionQueueItemCmd.Flags().BoolVar(&workflowQueueItemRetry, "retry", false, + "Retry a parked queue item") + workflowSessionQueueItemCmd.Flags().BoolVar(&workflowQueueItemSkip, "skip", false, + "Skip the queue item") + workflowSessionQueueItemCmd.Flags().BoolVar(&workflowQueueItemDelete, "delete", false, + "Delete (retract) the queue item") + + for _, c := range []*cobra.Command{ + workflowSessionCreateCmd, + workflowSessionSendCmd, + workflowSessionLogsCmd, + workflowSessionTurnsCmd, + workflowSessionQueueCmd, + workflowSessionResumeCmd, + workflowSessionSkipCmd, + workflowSessionInterruptCmd, + workflowSessionQueueItemCmd, + } { + c.ValidArgsFunction = completeWorkflowWhiteboardIDs + } +} + +// runSessionAction returns a RunE that POSTs a session control action. +func runSessionAction(action string) func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + body, err := workflowSend(cmd.Context(), "POST", nil, nil, nil, + "whiteboards", args[0], "sessions", args[1], action) + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + } +} + +// buildSessionCreateBody assembles the POST .../sessions body. When content is +// empty, NO initialItem is sent (a partial initialItem is rejected 500); when +// present it is minted with mode:queue. +func buildSessionCreateBody(title, content string) ([]byte, error) { + body := map[string]any{} + + if title != "" { + body["title"] = title + } + + if content != "" { + body["initialItem"] = map[string]any{ + "type": "message", + "mode": "queue", + "content": content, + } + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("building session body: %w", err) + } + + return data, nil +} + +// buildSessionSendBody assembles the POST .../items body. mode defaults to +// "queue"; --interrupt selects "stop_and_send". +func buildSessionSendBody(content string, interrupt bool) ([]byte, error) { + mode := "queue" + if interrupt { + mode = "stop_and_send" + } + + data, err := json.Marshal(map[string]any{ + "type": "message", + "mode": mode, + "content": content, + }) + if err != nil { + return nil, fmt.Errorf("building message body: %w", err) + } + + return data, nil +} + +// queueItemAction maps the retry/skip/delete flags to an HTTP method and path +// suffix. Exactly one flag must be set. --retry → POST .../retry; --skip → +// POST .../skip; --delete → DELETE (no suffix). +func queueItemAction(retry, skip, del bool) (method, action string, err error) { + count := 0 + for _, b := range []bool{retry, skip, del} { + if b { + count++ + } + } + + if count != 1 { + return "", "", fmt.Errorf("exactly one of --retry, --skip, or --delete is required") + } + + switch { + case retry: + return "POST", "retry", nil + case skip: + return "POST", "skip", nil + default: + return "DELETE", "", nil + } +} + +// workerLogQuery builds the query for a worker-log read/stream: cursor plus +// optional spec-node and task-execution filters (repeatable array params). +func workerLogQuery(cursor string, specNodes, taskExecutions []string) url.Values { + query := url.Values{} + + if cursor != "" { + query.Set("cursor", cursor) + } + + for _, node := range specNodes { + query.Add("specNodeIds[]", node) + } + + for _, task := range taskExecutions { + query.Add("taskExecutionIds[]", task) + } + + if len(query) == 0 { + return nil + } + + return query +} + +// followSessionLogs streams a session's worker log, flattening each `page` +// frame into per-item output and exiting 0 on the first item whose `.type` is a +// turn/operation terminal. The worker-log stream frames every event as +// `event: page`, so terminal detection reads the item types inside the data +// payload, NOT the SSE `event:` frame name. +func followSessionLogs(ctx context.Context, wb, sid, cursor string) error { + resp, err := workflowStream(ctx, "GET", nil, sseHeaders(), + workerLogQuery(cursor, nil, nil), + workflowPath("whiteboards", wb, "sessions", sid, "worker-log", "stream")) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + streamErr := parseSSE(ctx, resp.Body, func(ev sseEvent) error { + if emitErr := emitStreamItems(ev); emitErr != nil { + return emitErr + } + + if workerLogTerminalType([]byte(ev.Data)) { + return errStreamComplete + } + + return nil + }) + + return resolveStreamResult(ctx, streamErr, nil) +} + +// sessionTerminalEvents is the set of worker-log event names that end a +// session's turn/operation. +var sessionTerminalEvents = map[string]struct{}{ + "worker.operation.completed": {}, + "worker.operation.failed": {}, + "worker.operation.cancelled": {}, + "worker.operation.interrupted": {}, + "turn.completed": {}, + "turn.failed": {}, + "turn.interrupted": {}, + "whiteboard.turn.completed": {}, + "whiteboard.session.failed": {}, +} + +// isSessionTerminalEvent reports whether a session worker-log item `.type` ends +// the current turn or operation. +func isSessionTerminalEvent(event string) bool { + _, ok := sessionTerminalEvents[event] + + return ok +} + +// workerLogTerminalType reports whether a worker-log `page` frame's data +// payload contains any item whose `.type` ends the session's current turn or +// operation. A payload that is not a page (empty, `sync`, or a non-batch shape) +// reports false. +func workerLogTerminalType(data []byte) bool { + if len(data) == 0 { + return false + } + + var page workerLogPage + if err := json.Unmarshal(data, &page); err != nil { + return false + } + + for _, raw := range page.Items { + var item struct { + Type string `json:"type"` + } + + if err := json.Unmarshal(raw, &item); err != nil { + continue + } + + if isSessionTerminalEvent(item.Type) { + return true + } + } + + return false +} diff --git a/pkg/cli/workflow_steer.go b/pkg/cli/workflow_steer.go new file mode 100644 index 00000000..4f62540b --- /dev/null +++ b/pkg/cli/workflow_steer.go @@ -0,0 +1,158 @@ +package cli + +import ( + "encoding/json" + "fmt" + + "github.com/spf13/cobra" +) + +var ( + workflowSteerMessage string + workflowSteerDismiss bool + workflowSteerRetry bool +) + +var workflowSteerCmd = &cobra.Command{ + Use: "steer", + Short: "Redirect a running task mid-run", + Long: `Steer a running task without cancelling it: the task interrupts, +applies your direction, and still finishes with validated output. is the +task's specNodeKey from 'run tasks' (e.g. tasks.analyze); for a loop, steer the +inner iteration node (tasks..[iter=NNNN]), not the parent. + +Examples: + panda workflow steer send --message "only report Japan" + panda workflow steer queue + panda workflow steer turns `, +} + +var workflowSteerSendCmd = &cobra.Command{ + Use: "send ", + Short: "Send a steer message to a running task", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + if workflowSteerMessage == "" { + return fmt.Errorf("--message is required for 'steer send'") + } + + payload, err := buildSteerBody(workflowSteerMessage) + if err != nil { + return err + } + + body, err := workflowSend(cmd.Context(), "POST", payload, nil, nil, + "workflows", args[0], "runs", args[1], "task-executions", args[2], "steer") + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +var workflowSteerQueueCmd = &cobra.Command{ + Use: "queue ", + Short: "Show a task's steer queue", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := workflowGet(cmd.Context(), nil, + "workflows", args[0], "runs", args[1], "task-executions", args[2], "queue") + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +var workflowSteerTurnsCmd = &cobra.Command{ + Use: "turns ", + Short: "List a task's steer turns", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := workflowGet(cmd.Context(), nil, + "workflows", args[0], "runs", args[1], "task-executions", args[2], "turns") + if err != nil { + return err + } + + return renderWorkflow(body, func(b []byte) { + summarizeWorkflowItems(b, "items", "No turns.") + }) + }, +} + +var workflowSteerQueueItemCmd = &cobra.Command{ + Use: "queue-item ", + Short: "Dismiss or retry a steer queue item", + Long: `Act on a steer queue item by id. Exactly one of --dismiss (retract the +item) or --retry (only valid on a parked item) must be given. Both and + are percent-encoded so reserved characters (e.g. [ ] =) survive.`, + Args: cobra.ExactArgs(4), + RunE: func(cmd *cobra.Command, args []string) error { + action, err := steerQueueItemAction(workflowSteerDismiss, workflowSteerRetry) + if err != nil { + return err + } + + body, err := workflowSend(cmd.Context(), "POST", nil, nil, nil, + "workflows", args[0], "runs", args[1], "task-executions", args[2], + "queue", args[3], action) + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +func init() { + workflowSteerCmd.AddCommand( + workflowSteerSendCmd, + workflowSteerQueueCmd, + workflowSteerTurnsCmd, + workflowSteerQueueItemCmd, + ) + + workflowSteerSendCmd.Flags().StringVar(&workflowSteerMessage, "message", "", + "Steer direction to apply to the running task (required)") + workflowSteerQueueItemCmd.Flags().BoolVar(&workflowSteerDismiss, "dismiss", false, + "Dismiss (retract) the queue item") + workflowSteerQueueItemCmd.Flags().BoolVar(&workflowSteerRetry, "retry", false, + "Retry a parked queue item") + + for _, c := range []*cobra.Command{ + workflowSteerSendCmd, + workflowSteerQueueCmd, + workflowSteerTurnsCmd, + workflowSteerQueueItemCmd, + } { + c.ValidArgsFunction = noCompletions + } +} + +// buildSteerBody assembles the steer body {message}. +func buildSteerBody(message string) ([]byte, error) { + data, err := json.Marshal(map[string]any{"message": message}) + if err != nil { + return nil, fmt.Errorf("building steer body: %w", err) + } + + return data, nil +} + +// steerQueueItemAction maps the dismiss/retry flags to a path suffix. Exactly +// one flag must be set. +func steerQueueItemAction(dismiss, retry bool) (string, error) { + switch { + case dismiss && retry: + return "", fmt.Errorf("only one of --dismiss or --retry may be given") + case dismiss: + return "dismiss", nil + case retry: + return "retry", nil + default: + return "", fmt.Errorf("exactly one of --dismiss or --retry is required") + } +} diff --git a/pkg/cli/workflow_test.go b/pkg/cli/workflow_test.go new file mode 100644 index 00000000..fcaf68e0 --- /dev/null +++ b/pkg/cli/workflow_test.go @@ -0,0 +1,607 @@ +package cli + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/panda/pkg/attribution" +) + +func TestWorkflowPathPercentEncodesSegments(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + segments []string + want string + }{ + { + name: "plain segments", + segments: []string{"whiteboards", "wb_123", "state"}, + want: "/api/v1/workflow/whiteboards/wb_123/state", + }, + { + name: "steer loop iteration key with reserved chars", + segments: []string{ + "workflows", "wf_1", "runs", "run_1", "task-executions", + "tasks.fetch_weather.fetch_one[iter=0002]", "queue", + "tlitem1.abc", "dismiss", + }, + want: "/api/v1/workflow/workflows/wf_1/runs/run_1/task-executions/" + + "tasks.fetch_weather.fetch_one%5Biter%3D0002%5D/queue/tlitem1.abc/dismiss", + }, + { + name: "slash inside an id is escaped", + segments: []string{"whiteboards", "a/b"}, + want: "/api/v1/workflow/whiteboards/a%2Fb", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, workflowPath(tt.segments...)) + }) + } +} + +func TestApiErrorMessageFallbackChain(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + want string + }{ + { + name: "panda error field", + body: `{"error":"boom"}`, + want: "boom", + }, + { + name: "rfc7807 detail", + body: `{"type":"about:blank","title":"Not Found","status":404,"detail":"run not found"}`, + want: "run not found", + }, + { + name: "rfc7807 title only", + body: `{"type":"about:blank","title":"Unauthorized","status":401}`, + want: "Unauthorized", + }, + { + name: "text/plain unknown route 404 falls back to raw body", + body: "404 page not found\n", + want: "404 page not found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, apiErrorMessage([]byte(tt.body))) + }) + } +} + +func TestDecodeAPIErrorTextPlain404NoPanic(t *testing.T) { + t.Parallel() + + // An unknown-route text/plain 404 must not panic a JSON decode and must + // still surface its message. + err := decodeAPIError(404, []byte("404 page not found\n")) + require.Error(t, err) + assert.Contains(t, err.Error(), "404 page not found") +} + +func TestWorkflowErrorHintByStatus(t *testing.T) { + t.Parallel() + + // 503 with the server's canonical "no proxy advertises" detail → the engine + // is not enabled on any proxy; point at the proxy-config.yaml workflow block. + err503 := workflowError(503, []byte( + `{"detail":"workflow engine is not available: no configured proxy advertises it"}`)) + assert.Contains(t, err503.Error(), "not enabled on any configured proxy") + assert.Contains(t, err503.Error(), "proxy-config.yaml") + + // A forwarded upstream 503 (no panda short-circuit phrase) is an availability + // problem, not a local misconfig — it must not claim the engine is unenabled. + err503upstream := workflowError(503, []byte("503 Service Temporarily Unavailable")) + assert.Contains(t, err503upstream.Error(), "server error upstream") + assert.NotContains(t, err503upstream.Error(), "not enabled on any configured proxy") + + // 502 splits by hop: the server's "proxy is unreachable" vs the proxy's + // "configured but unreachable" (engine down). + err502proxy := workflowError(502, []byte(`{"detail":"proxy is unreachable"}`)) + assert.Contains(t, err502proxy.Error(), "could not reach the proxy") + assert.Contains(t, err502proxy.Error(), "proxies[]") + + err502engine := workflowError(502, []byte(`{"detail":"workflow engine is configured but unreachable"}`)) + assert.Contains(t, err502engine.Error(), "could not reach the workflow engine") + + // A verbatim 502 page from beyond the proxy carries neither canonical phrase; + // it must not blame the proxy→engine hop. + err502upstream := workflowError(502, []byte("502 Bad Gateway")) + assert.Contains(t, err502upstream.Error(), "server error upstream") + assert.NotContains(t, err502upstream.Error(), "could not reach the workflow engine") + + // Other upstream 5xx get a generic upstream-error hint rather than none. + err500 := workflowError(500, []byte(`{"detail":"boom"}`)) + assert.Contains(t, err500.Error(), "server error upstream") + err504 := workflowError(504, []byte("504 Gateway Time-out")) + assert.Contains(t, err504.Error(), "server error upstream") + + // 401 picks proxy vs upstream wording by body origin: the proxy's bearer + // rejections use the exact writeBearerError phrases; an engine body relayed + // verbatim uses its own wording. + for _, body := range []string{ + "missing or invalid Authorization header", + "missing bearer token", + "invalid token", + "invalid token claims", + "token subject is missing", + } { + err401proxy := workflowError(401, []byte(body)) + assert.Contains(t, err401proxy.Error(), "proxy rejected your credential", body) + assert.Contains(t, err401proxy.Error(), "panda auth login", body) + } + + err401upstream := workflowError(401, []byte(`{"error":"invalid api key"}`)) + assert.Contains(t, err401upstream.Error(), "workflow engine rejected the credential") + assert.Contains(t, err401upstream.Error(), "passthrough auth") + + // 403 → the caller's org is not allowed on this proxy. + err403 := workflowError(403, []byte(`{"detail":"organization not allowed"}`)) + assert.Contains(t, err403.Error(), "not allowed to use the workflow engine") + assert.Contains(t, err403.Error(), "ask an operator") + + err404 := workflowError(404, []byte("404 page not found")) + assert.Contains(t, err404.Error(), "check the whiteboard") + assert.Contains(t, err404.Error(), "panda workflow api") + + // A status without a workflow hint still renders the decoded message. + err409 := workflowError(409, []byte(`{"detail":"conflict"}`)) + assert.Contains(t, err409.Error(), "conflict") +} + +func TestReadInlineOrFile(t *testing.T) { + t.Parallel() + + inline, err := readInlineOrFile(`{"a":1}`) + require.NoError(t, err) + assert.JSONEq(t, `{"a":1}`, string(inline)) + + empty, err := readInlineOrFile("") + require.NoError(t, err) + assert.Nil(t, empty) + + path := t.TempDir() + "/body.json" + require.NoError(t, os.WriteFile(path, []byte(`{"b":2}`), 0o600)) + + fromFile, err := readInlineOrFile("@" + path) + require.NoError(t, err) + assert.JSONEq(t, `{"b":2}`, string(fromFile)) + + _, err = readInlineOrFile("@/nonexistent/path.json") + require.Error(t, err) +} + +func TestReadJSONFlagValidatesPayload(t *testing.T) { + t.Parallel() + + inline, err := readJSONFlag("--inputs", `{"a":1}`) + require.NoError(t, err) + assert.JSONEq(t, `{"a":1}`, string(inline)) + + empty, err := readJSONFlag("--inputs", "") + require.NoError(t, err) + assert.Nil(t, empty) + + // Invalid JSON must fail with the flag name, not a RawMessage marshal error. + _, err = readJSONFlag("--inputs", `{oops`) + require.ErrorContains(t, err, "--inputs is not valid JSON") + + path := t.TempDir() + "/bad.json" + require.NoError(t, os.WriteFile(path, []byte(`not json`), 0o600)) + + _, err = readJSONFlag("--dispatch", "@"+path) + require.ErrorContains(t, err, "--dispatch is not valid JSON") +} + +func TestBuildRunBodyRejectsInvalidJSON(t *testing.T) { + t.Parallel() + + _, err := buildRunBody(`{oops`, "") + require.ErrorContains(t, err, "--inputs is not valid JSON") + + _, err = buildRunBody("", `[unclosed`) + require.ErrorContains(t, err, "--dispatch is not valid JSON") +} + +func TestWorkflowStreamCarriesEnvAttribution(t *testing.T) { + // No t.Parallel(): t.Setenv and the shared cfgFile global forbid it. + var gotAttribution, gotAccept string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAttribution = r.Header.Get(attribution.Header) + gotAccept = r.Header.Get("Accept") + w.Header().Set("Content-Type", "text/event-stream") + _, _ = w.Write([]byte("data: {}\n\n")) + })) + defer server.Close() + + setClientConfig(t, server.URL) + t.Setenv(attribution.EnvVar, "agent:claude") + + resp, err := workflowStream(context.Background(), http.MethodGet, nil, sseHeaders(), nil, + workflowPath("whiteboards", "wb_1", "state", "stream")) + require.NoError(t, err) + + defer func() { _ = resp.Body.Close() }() + _, _ = io.Copy(io.Discard, resp.Body) + + assert.Equal(t, "agent:claude", gotAttribution, + "streaming requests must carry the on-behalf-of header like serverDo does") + assert.Equal(t, "text/event-stream", gotAccept) +} + +func TestParseSSESkipsCommentAndSync(t *testing.T) { + t.Parallel() + + stream := strings.Join([]string{ + ": ping", + "", + "event: sync", + "id: 5", + "data: {\"replay\":true}", + "", + "event: run.updated", + "id: 6", + "data: {\"status\":\"running\"}", + "", + ": ping", + "", + "event: turn.completed", + "data: {\"done\":true}", + "", + }, "\n") + + var got []sseEvent + + err := parseSSE(context.Background(), strings.NewReader(stream), func(ev sseEvent) error { + got = append(got, ev) + + return nil + }) + require.NoError(t, err) + + require.Len(t, got, 2, "sync and comment frames must be dropped") + assert.Equal(t, "run.updated", got[0].Event) + assert.Equal(t, "6", got[0].ID) + assert.JSONEq(t, `{"status":"running"}`, got[0].Data) + assert.Equal(t, "turn.completed", got[1].Event) +} + +func TestParseSSEMultiLineData(t *testing.T) { + t.Parallel() + + stream := "event: msg\ndata: line1\ndata: line2\n\n" + + var got []sseEvent + + err := parseSSE(context.Background(), strings.NewReader(stream), func(ev sseEvent) error { + got = append(got, ev) + + return nil + }) + require.NoError(t, err) + + require.Len(t, got, 1) + assert.Equal(t, "line1\nline2", got[0].Data) +} + +// fragmentReader returns its payload one byte at a time to exercise reads that +// split mid-frame. +type fragmentReader struct { + data []byte + pos int +} + +func (f *fragmentReader) Read(p []byte) (int, error) { + if f.pos >= len(f.data) { + return 0, io.EOF + } + + p[0] = f.data[f.pos] + f.pos++ + + return 1, nil +} + +func TestParseSSEFragmentedReads(t *testing.T) { + t.Parallel() + + stream := "event: run.updated\ndata: {\"a\":1}\n\nevent: turn.completed\ndata: {\"b\":2}\n\n" + + var got []sseEvent + + reader := &fragmentReader{data: []byte(stream)} + + err := parseSSE(context.Background(), reader, func(ev sseEvent) error { + got = append(got, ev) + + return nil + }) + require.NoError(t, err) + + require.Len(t, got, 2) + assert.JSONEq(t, `{"a":1}`, got[0].Data) + assert.JSONEq(t, `{"b":2}`, got[1].Data) +} + +func TestParseSSELargeEventOver64K(t *testing.T) { + t.Parallel() + + // bufio.Scanner's default token cap is 64K; the parser must handle a larger + // single data payload. + big := strings.Repeat("x", 200*1024) + stream := "event: msg\ndata: " + big + "\n\n" + + var got []sseEvent + + err := parseSSE(context.Background(), strings.NewReader(stream), func(ev sseEvent) error { + got = append(got, ev) + + return nil + }) + require.NoError(t, err) + + require.Len(t, got, 1) + assert.Len(t, got[0].Data, len(big)) +} + +func TestParseSSERejectsOversizeLine(t *testing.T) { + t.Parallel() + + // A single newline-sparse line past the 8 MiB ceiling (e.g. a large base64 + // artifact mis-routed through `api -f`) must return a bounded error rather + // than buffer unbounded. + huge := strings.Repeat("x", maxSSELineBytes+1024) + stream := "data: " + huge + "\n\n" + + var got []sseEvent + + err := parseSSE(context.Background(), strings.NewReader(stream), func(ev sseEvent) error { + got = append(got, ev) + + return nil + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "exceeds") + assert.Empty(t, got, "no frame should be emitted for an oversize line") +} + +func TestParseSSEContextCancel(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := parseSSE(ctx, strings.NewReader("data: x\n\n"), func(sseEvent) error { + return nil + }) + require.ErrorIs(t, err, context.Canceled) +} + +func TestWorkerLogTerminalType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + want bool + }{ + { + name: "page with a terminal turn.completed item", + data: `{"items":[{"type":"worker.system","eventName":"boot"},` + + `{"type":"turn.completed","eventName":"done"}],"liveCursor":"c2"}`, + want: true, + }, + { + name: "page with a terminal worker.operation.completed item", + data: `{"items":[{"type":"worker.operation.completed"}],"liveCursor":"c3"}`, + want: true, + }, + { + name: "page with only non-terminal items", + data: `{"items":[{"type":"worker.system"},` + + `{"type":"worker.operation.started"}],"liveCursor":"c1"}`, + want: false, + }, + { + name: "empty page", + data: `{"items":[],"liveCursor":"c0"}`, + want: false, + }, + { + name: "empty payload", + data: "", + want: false, + }, + { + name: "non-page object (state snapshot) is never terminal", + data: `{"run":{"status":"completed"}}`, + want: false, + }, + { + name: "malformed json", + data: "not json", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, workerLogTerminalType([]byte(tt.data))) + }) + } +} + +func TestEmitStreamItemsFlattensPageJSON(t *testing.T) { + restore := outputFormat + outputFormat = "json" + defer func() { outputFormat = restore }() + + page := `{"items":[{"type":"worker.system","message":"a"},` + + `{"type":"turn.completed","message":"b"}],"liveCursor":"c1"}` + + out := captureStdout(t, func() { + require.NoError(t, emitStreamItems(sseEvent{Event: "page", Data: page})) + }) + + lines := strings.Split(strings.TrimSpace(out), "\n") + require.Len(t, lines, 2, "each items[] element must be its own NDJSON line") + + var first map[string]any + require.NoError(t, json.Unmarshal([]byte(lines[0]), &first)) + assert.Equal(t, "worker.system", first["type"]) + + var second map[string]any + require.NoError(t, json.Unmarshal([]byte(lines[1]), &second)) + assert.Equal(t, "turn.completed", second["type"]) +} + +func TestEmitStreamItemsPassesThroughNonPage(t *testing.T) { + restore := outputFormat + outputFormat = "json" + defer func() { outputFormat = restore }() + + snapshot := `{"run":{"status":"completed"}}` + + out := captureStdout(t, func() { + require.NoError(t, emitStreamItems(sseEvent{Event: "state.updated", Data: snapshot})) + }) + + lines := strings.Split(strings.TrimSpace(out), "\n") + require.Len(t, lines, 1, "a non-page snapshot passes through as one line") + assert.JSONEq(t, snapshot, lines[0]) +} + +func TestSummarizeWorkerLogRendersTypeMessage(t *testing.T) { + restore := outputFormat + outputFormat = "" + defer func() { outputFormat = restore }() + + body := `{"items":[` + + `{"type":"worker.system","message":"boot"},` + + `{"type":"turn.completed","message":"done"}],"liveCursor":"c1"}` + + out := captureStdout(t, func() { + summarizeWorkerLog([]byte(body), "No log entries.") + }) + + lines := strings.Split(strings.TrimSpace(out), "\n") + require.Len(t, lines, 2, "one line per worker-log event, not a dash table") + assert.Equal(t, "worker.system\tboot", lines[0]) + assert.Equal(t, "turn.completed\tdone", lines[1]) + assert.NotContains(t, out, "-\t-\t-", "must not render the id/name/status dash table") +} + +func TestSummarizeWorkerLogEmpty(t *testing.T) { + restore := outputFormat + outputFormat = "" + defer func() { outputFormat = restore }() + + out := captureStdout(t, func() { + summarizeWorkerLog([]byte(`{"items":[],"liveCursor":"c0"}`), "No log entries.") + }) + + assert.Equal(t, "No log entries.", strings.TrimSpace(out)) +} + +func TestSummarizeWorkflowItemsFallsBackForIDlessLists(t *testing.T) { + restore := outputFormat + outputFormat = "" + defer func() { outputFormat = restore }() + + // A steer/session turn carries turnId/taskKey but no id/name/status, plus a + // large nested events[] the summary must NOT dump. A dash table would be + // useless, so it falls back to a compact scalar line (events[] excluded). + body := `{"items":[{"turnId":"worker-turn:wop_1","turnSeq":1,` + + `"taskKey":"tasks.fetch[iter=0000]","events":[{"big":"payload"}]}]}` + + out := captureStdout(t, func() { + summarizeWorkflowItems([]byte(body), "items", "No turns.") + }) + + trimmed := strings.TrimSpace(out) + assert.Contains(t, trimmed, "turnId=worker-turn:wop_1") + assert.Contains(t, trimmed, "taskKey=tasks.fetch[iter=0000]") + assert.Contains(t, trimmed, "turnSeq=1") + assert.NotContains(t, out, "ID NAME STATUS", "no dash table for id-less items") + assert.NotContains(t, out, "events=", "nested events[] must be excluded from the scalar line") + assert.NotContains(t, out, "big", "nested payload must not leak into the summary") +} + +func TestSummarizeWorkflowItemsKeepsTableWhenIdentified(t *testing.T) { + restore := outputFormat + outputFormat = "" + defer func() { outputFormat = restore }() + + // A list whose items carry id/name/status still renders the table. + body := `{"items":[{"id":"wf_1","name":"calc","status":"active"}]}` + + out := captureStdout(t, func() { + summarizeWorkflowItems([]byte(body), "items", "No workflows.") + }) + + assert.Contains(t, out, "ID") + assert.Contains(t, out, "wf_1") + assert.Contains(t, out, "calc") + assert.Contains(t, out, "active") +} + +func TestScalarFieldLineSkipsNestedAndNil(t *testing.T) { + t.Parallel() + + line := scalarFieldLine(map[string]any{ + "agent": "claude", + "model": "sonnet", + "workerCount": float64(2), + "availableSlots": nil, + "nested": map[string]any{"x": 1}, + "list": []any{1, 2}, + }) + + assert.Contains(t, line, "agent=claude") + assert.Contains(t, line, "model=sonnet") + assert.Contains(t, line, "workerCount=2") + assert.NotContains(t, line, "availableSlots", "nil values are skipped") + assert.NotContains(t, line, "nested", "nested objects are skipped") + assert.NotContains(t, line, "list", "nested arrays are skipped") +} + +func TestRunStatusFromState(t *testing.T) { + t.Parallel() + + snapshot, err := json.Marshal(map[string]any{ + "run": map[string]any{"status": "completed"}, + }) + require.NoError(t, err) + + assert.Equal(t, "completed", runStatusFromState(snapshot)) + assert.Equal(t, "", runStatusFromState([]byte(`{"run":{}}`))) + assert.Equal(t, "", runStatusFromState([]byte("not json"))) +} diff --git a/pkg/cli/workflow_url.go b/pkg/cli/workflow_url.go new file mode 100644 index 00000000..acf73fbc --- /dev/null +++ b/pkg/cli/workflow_url.go @@ -0,0 +1,136 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/spf13/cobra" +) + +// workflowURLCmd mints human-facing workflow-engine frontend links. The web +// origin comes from the server's /api/v1/workflow-info (answered from proxy +// discovery of the engine's web_base_url); no token is involved — users log into +// the workflow-engine UI themselves. +var workflowURLCmd = &cobra.Command{ + Use: "url [whiteboard | workflow | run ]", + Short: "Print a workflow-engine frontend link (users log in there themselves)", + Long: `Print a human-facing workflow-engine web UI link. With no arguments, prints +the web origin. Include these links when reporting whiteboards and runs so the +user can open them in a browser — access is their own workflow-engine login, +never the server's or proxy's token. + +Examples: + panda workflow url + panda workflow url whiteboard + panda workflow url workflow + panda workflow url run `, + Args: cobra.MaximumNArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + base, err := fetchWorkflowWebBase(cmd.Context()) + if err != nil { + return err + } + + if base == "" { + return fmt.Errorf( + "the server does not expose a workflow-engine web origin — the workflow " + + "engine is not advertised by any configured proxy (or the server predates " + + "workflow-info; upgrade panda-server)") + } + + link, err := buildWorkflowWebURL(base, args) + if err != nil { + return err + } + + if isJSON() { + return printJSON(map[string]string{"url": link}) + } + + fmt.Println(link) + + return nil + }, +} + +// buildWorkflowWebURL maps a `url` argument form onto a frontend path. +func buildWorkflowWebURL(base string, args []string) (string, error) { + switch { + case len(args) == 0: + return base, nil + case args[0] == "whiteboard" && len(args) == 2: + return workflowWhiteboardURL(base, args[1]), nil + case args[0] == "workflow" && len(args) == 2: + return workflowWorkflowURL(base, args[1]), nil + case args[0] == "run" && len(args) == 3: + return workflowRunURL(base, args[1], args[2]), nil + default: + return "", fmt.Errorf( + "usage: url [whiteboard | workflow | run ], got %q", + strings.Join(args, " ")) + } +} + +// workflowWhiteboardURL builds the frontend link for a whiteboard. +func workflowWhiteboardURL(base, wb string) string { + return base + "/whiteboards/" + wb +} + +// workflowWorkflowURL builds the frontend link for a workflow. +func workflowWorkflowURL(base, wf string) string { + return base + "/workflows/" + wf +} + +// workflowRunURL builds the frontend link for a workflow run. +func workflowRunURL(base, wf, run string) string { + return base + "/workflows/" + wf + "/runs/" + run +} + +// fetchWorkflowWebBase reads the workflow-engine web origin from the server's +// /api/v1/workflow-info. It returns "" (no error) when the engine is not +// advertised or the server predates the endpoint (404), so callers can degrade +// to link-less output; transport errors still propagate. +func fetchWorkflowWebBase(ctx context.Context) (string, error) { + data, status, _, err := serverDo(ctx, http.MethodGet, "/api/v1/workflow-info", nil, nil, nil) + if err != nil { + return "", err + } + + if status == http.StatusNotFound { + return "", nil + } + + if status < 200 || status >= 300 { + return "", workflowError(status, data) + } + + var payload struct { + Enabled bool `json:"enabled"` + WebBaseURL string `json:"web_base_url"` + } + + if err := json.Unmarshal(data, &payload); err != nil { + return "", fmt.Errorf("parsing workflow-info: %w", err) + } + + if !payload.Enabled { + return "", nil + } + + return strings.TrimRight(payload.WebBaseURL, "/"), nil +} + +// workflowWebBaseBestEffort resolves the web origin for optional link decoration, +// swallowing every failure into "" — callers render link-less output rather +// than fail their primary job over a missing frontend origin. +func workflowWebBaseBestEffort(ctx context.Context) string { + base, err := fetchWorkflowWebBase(ctx) + if err != nil { + return "" + } + + return base +} diff --git a/pkg/cli/workflow_url_test.go b/pkg/cli/workflow_url_test.go new file mode 100644 index 00000000..2ac51626 --- /dev/null +++ b/pkg/cli/workflow_url_test.go @@ -0,0 +1,165 @@ +package cli + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildWorkflowWebURLForms(t *testing.T) { + t.Parallel() + + base := "https://workflow.example" + + tests := []struct { + name string + args []string + want string + }{ + {name: "bare origin", args: nil, want: base}, + {name: "whiteboard", args: []string{"whiteboard", "wb_1"}, want: base + "/whiteboards/wb_1"}, + {name: "workflow", args: []string{"workflow", "wf_1"}, want: base + "/workflows/wf_1"}, + {name: "run", args: []string{"run", "wf_1", "run_1"}, want: base + "/workflows/wf_1/runs/run_1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := buildWorkflowWebURL(base, tt.args) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestBuildWorkflowWebURLRejectsUnknownForms(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{ + {"whiteboard"}, + {"run", "wf_1"}, + {"bogus", "x"}, + } { + _, err := buildWorkflowWebURL("https://workflow.example", args) + require.Error(t, err, "args %v must be rejected", args) + assert.Contains(t, err.Error(), "usage:") + } +} + +// workflowInfoServer serves a fixed status+body on /api/v1/workflow-info and +// records the request path. Not parallel-safe with other client-config tests: +// setClientConfig mutates package globals. +func workflowInfoServer(t *testing.T, status int, body string) (baseURL string, gotPath *string) { + t.Helper() + + var path string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + })) + t.Cleanup(server.Close) + + return server.URL, &path +} + +func TestFetchWorkflowWebBaseFromWorkflowInfo(t *testing.T) { + serverURL, gotPath := workflowInfoServer(t, http.StatusOK, + `{"enabled":true,"web_base_url":"https://workflow.example/"}`) + setClientConfig(t, serverURL) + + base, err := fetchWorkflowWebBase(context.Background()) + require.NoError(t, err) + assert.Equal(t, "/api/v1/workflow-info", *gotPath) + // The trailing slash is trimmed so path joins don't double it. + assert.Equal(t, "https://workflow.example", base) +} + +func TestFetchWorkflowWebBaseDegradesToEmpty(t *testing.T) { + tests := []struct { + name string + status int + body string + }{ + {name: "engine not advertised", status: http.StatusOK, body: `{}`}, + {name: "disabled with stale url", status: http.StatusOK, body: `{"enabled":false,"web_base_url":"https://stale.example"}`}, + {name: "server predates workflow-info", status: http.StatusNotFound, body: "404 page not found"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + serverURL, _ := workflowInfoServer(t, tt.status, tt.body) + setClientConfig(t, serverURL) + + base, err := fetchWorkflowWebBase(context.Background()) + require.NoError(t, err) + assert.Empty(t, base) + }) + } +} + +func TestFetchWorkflowWebBaseSurfacesErrors(t *testing.T) { + tests := []struct { + name string + status int + body string + want string + }{ + { + name: "engine not advertised on any proxy", + status: http.StatusServiceUnavailable, + body: `{"detail":"workflow engine is not available: no configured proxy advertises it"}`, + want: "not enabled on any configured proxy", + }, + { + name: "malformed payload", + status: http.StatusOK, + body: `{"enabled":`, + want: "parsing workflow-info", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + serverURL, _ := workflowInfoServer(t, tt.status, tt.body) + setClientConfig(t, serverURL) + + _, err := fetchWorkflowWebBase(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.want) + + // The best-effort variant swallows the same failure into link-less output. + assert.Empty(t, workflowWebBaseBestEffort(context.Background())) + }) + } +} + +func TestWorkflowURLCommandBuildsLinkFromInfo(t *testing.T) { + serverURL, _ := workflowInfoServer(t, http.StatusOK, + `{"enabled":true,"web_base_url":"https://workflow.example"}`) + setClientConfig(t, serverURL) + setOutputFormat(t, "text") + + output := captureStdout(t, func() { + require.NoError(t, workflowURLCmd.RunE(testCommand(), []string{"run", "wf_1", "run_1"})) + }) + + assert.Equal(t, "https://workflow.example/workflows/wf_1/runs/run_1\n", output) +} + +func TestWorkflowURLCommandErrorsWhenNotAdvertised(t *testing.T) { + serverURL, _ := workflowInfoServer(t, http.StatusOK, `{}`) + setClientConfig(t, serverURL) + + err := workflowURLCmd.RunE(testCommand(), nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "not advertised by any configured proxy") +} diff --git a/pkg/cli/workflow_whiteboard.go b/pkg/cli/workflow_whiteboard.go new file mode 100644 index 00000000..4bb24042 --- /dev/null +++ b/pkg/cli/workflow_whiteboard.go @@ -0,0 +1,295 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strconv" + + "github.com/spf13/cobra" +) + +var ( + workflowWhiteboardName string + workflowWhiteboardRequirements string + workflowWhiteboardInputs string + workflowWatchAfterSeq int64 +) + +var workflowWhiteboardCmd = &cobra.Command{ + Use: "whiteboard", + Aliases: []string{"wb"}, + Short: "List, create, inspect, and watch whiteboards", + Long: `Whiteboards are the workflow-engine planning space that holds sessions and drafts. + +Agents: a new request gets a NEW whiteboard ('whiteboard create'). +'whiteboard list' is for inspecting state or resolving a whiteboard the user +explicitly named — never for finding an existing one to continue from (see +'panda workflow docs'). + +Examples: + panda workflow whiteboard list + panda workflow whiteboard create --requirements "count blocks per day" + panda workflow whiteboard get + panda workflow whiteboard watch --json`, +} + +var workflowWhiteboardListCmd = &cobra.Command{ + Use: "list", + Short: "List whiteboards", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + body, err := workflowGet(cmd.Context(), nil, "whiteboards") + if err != nil { + return err + } + + return renderWorkflow(body, func(b []byte) { + summarizeWorkflowItems(b, "items", "No whiteboards found.") + }) + }, +} + +var workflowWhiteboardCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a whiteboard", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + inputs, err := readJSONFlag("--inputs", workflowWhiteboardInputs) + if err != nil { + return err + } + + payload, err := buildWhiteboardCreateBody(workflowWhiteboardName, workflowWhiteboardRequirements, inputs) + if err != nil { + return err + } + + body, err := workflowSend(cmd.Context(), "POST", payload, nil, nil, "whiteboards") + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWorkflowObject) + }, +} + +var workflowWhiteboardGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a whiteboard state snapshot", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + body, err := workflowGet(cmd.Context(), nil, "whiteboards", args[0], "state") + if err != nil { + return err + } + + return renderWorkflow(body, summarizeWhiteboardState) + }, +} + +var workflowWhiteboardWatchCmd = &cobra.Command{ + Use: "watch ", + Short: "Watch a whiteboard's state stream (snapshot per event)", + Long: `Watch a whiteboard by streaming its state and refetching the /state +snapshot after each event. Under --json each line is a full snapshot (NDJSON of +snapshots), never the raw delta. Exits non-zero only on a stream/transport +error; Ctrl-C exits cleanly.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return watchWorkflowState( + commandContext(cmd), + workflowWatchAfterSeq, + []string{"whiteboards", args[0], "state"}, + []string{"whiteboards", args[0], "state", "stream"}, + nil, + ) + }, +} + +func init() { + workflowWhiteboardCmd.AddCommand( + workflowWhiteboardListCmd, + workflowWhiteboardCreateCmd, + workflowWhiteboardGetCmd, + workflowWhiteboardWatchCmd, + ) + + workflowWhiteboardCreateCmd.Flags().StringVar(&workflowWhiteboardName, "name", "", + "Whiteboard name") + workflowWhiteboardCreateCmd.Flags().StringVar(&workflowWhiteboardRequirements, "requirements", "", + "Plain-language requirements for the whiteboard") + workflowWhiteboardCreateCmd.Flags().StringVar(&workflowWhiteboardInputs, "inputs", "", + "Inputs object as inline JSON or @file.json") + workflowWhiteboardWatchCmd.Flags().Int64Var(&workflowWatchAfterSeq, "after-seq", 0, + "Resume the state stream after this numeric sequence") + + workflowWhiteboardGetCmd.ValidArgsFunction = completeWorkflowWhiteboardIDs + workflowWhiteboardWatchCmd.ValidArgsFunction = completeWorkflowWhiteboardIDs +} + +// buildWhiteboardCreateBody assembles the POST /whiteboards body. inputs, when +// present, is embedded verbatim at `.inputs`. +func buildWhiteboardCreateBody(name, requirements string, inputs []byte) ([]byte, error) { + body := struct { + Name string `json:"name,omitempty"` + Requirements string `json:"requirements,omitempty"` + Inputs json.RawMessage `json:"inputs,omitempty"` + }{ + Name: name, + Requirements: requirements, + Inputs: json.RawMessage(inputs), + } + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("building whiteboard body: %w", err) + } + + return data, nil +} + +// summarizeWhiteboardState renders a whiteboard /state snapshot for text mode: +// the whiteboard id/name/status, its latestDraftId/latestSessionId pointers, and +// a count of drafts/sessions. The nested whiteboard object's remaining fields and +// the full drafts/sessions arrays are omitted; --json remains the stable, full- +// body contract. A body without a `.whiteboard` object falls back to the generic +// object summary. +func summarizeWhiteboardState(body []byte) { + var payload struct { + Whiteboard *struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + LatestDraftID string `json:"latestDraftId"` + LatestSessionID string `json:"latestSessionId"` + } `json:"whiteboard"` + Drafts []json.RawMessage `json:"drafts"` + Sessions []json.RawMessage `json:"sessions"` + } + + if err := json.Unmarshal(body, &payload); err != nil || payload.Whiteboard == nil { + summarizeWorkflowObject(body) + + return + } + + wb := payload.Whiteboard + pairs := make([][2]string, 0, 7) + + for _, kv := range [][2]string{ + {"whiteboard", wb.ID}, + {"name", wb.Name}, + {"status", wb.Status}, + {"latestDraftId", wb.LatestDraftID}, + {"latestSessionId", wb.LatestSessionID}, + } { + if kv[1] != "" { + pairs = append(pairs, kv) + } + } + + pairs = append(pairs, + [2]string{"drafts", strconv.Itoa(len(payload.Drafts))}, + [2]string{"sessions", strconv.Itoa(len(payload.Sessions))}, + ) + + printKeyValue(pairs) +} + +// watchWorkflowState implements the snapshot policy: fetch the initial /state +// snapshot, emit it, then open the state stream and refetch + emit the snapshot +// after each event. terminalFn (nil for whiteboards) derives an exit code from +// a snapshot; a nil terminalFn never terminates on status. Exits non-zero only +// on a stream/transport error; Ctrl-C exits cleanly. +func watchWorkflowState( + ctx context.Context, + afterSeq int64, + statePath, streamPath []string, + terminalFn func([]byte) (bool, error), +) error { + snapshot, err := workflowGet(ctx, nil, statePath...) + if err != nil { + return err + } + + if err := emitSnapshot(snapshot); err != nil { + return err + } + + if terminalFn != nil { + if done, exitErr := terminalFn(snapshot); done { + return exitErr + } + } + + query := url.Values{} + if afterSeq > 0 { + query.Set("afterSeq", strconv.FormatInt(afterSeq, 10)) + } + + resp, err := workflowStream(ctx, "GET", nil, sseHeaders(), query, workflowPath(streamPath...)) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + + var termErr error + + streamErr := parseSSE(ctx, resp.Body, func(_ sseEvent) error { + snap, refetchErr := workflowGet(ctx, nil, statePath...) + if refetchErr != nil { + return refetchErr + } + + if emitErr := emitSnapshot(snap); emitErr != nil { + return emitErr + } + + if terminalFn != nil { + if done, exitErr := terminalFn(snap); done { + termErr = exitErr + + return errStreamComplete + } + } + + return nil + }) + + return resolveStreamResult(ctx, streamErr, termErr) +} + +// completeWorkflowWhiteboardIDs completes the first positional arg with whiteboard +// ids from 'whiteboard list'. Subsequent args are free text (nested ids the CLI +// cannot cheaply enumerate). +func completeWorkflowWhiteboardIDs(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) > 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + body, err := workflowGet(commandContext(cmd), nil, "whiteboards") + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + var payload struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + + if err := json.Unmarshal(body, &payload); err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + + ids := make([]string, 0, len(payload.Items)) + for _, item := range payload.Items { + if item.ID != "" { + ids = append(ids, item.ID) + } + } + + return ids, cobra.ShellCompDirectiveNoFileComp +} diff --git a/pkg/cli/workflowdocs/api.md b/pkg/cli/workflowdocs/api.md new file mode 100644 index 00000000..14b1f62d --- /dev/null +++ b/pkg/cli/workflowdocs/api.md @@ -0,0 +1,214 @@ +# Workflow API cheat-sheet + +The workflow engine's REST endpoints `panda workflow` wraps. Every command is +exactly one call. + +**Transport.** Requests travel `panda → server (/api/v1/workflow/*) → proxy +(/workflow/*) → engine (/api/v1/*)`. The proxy holds the credential and injects +it (its configured `api_token`, or the caller's own bearer under passthrough +auth) — the CLI and server never handle a token. The engine's paths are under +`/api/v1`; `panda workflow api ` reaches any of them directly +(path relative to the engine's `/api/v1`, e.g. `panda workflow api GET whiteboards`, +which the server roots at `/api/v1/workflow/whiteboards`). + +## CLI ↔ API resources + +Each CLI noun maps to one engine resource (this is why the server paths double +the word — `/api/v1/workflow/` + the engine's `/workflows` resource): + +``` +| CLI noun | API resource | +|------------|---------------------------------------------| +| whiteboard | /whiteboards/{wb} | +| session | /whiteboards/{wb}/sessions/{ses} | +| draft | /whiteboards/{wb}/drafts/{draft} | +| (base) | /workflows/{wf}, /workflows/{wf}/releases | +| run | /workflows/{wf}/runs/{run} (+ /state SSE) | +| dispatch | /agents, /workers, /dispatch/* | +``` + +## Basics + +- **Base path:** `/api/v1` (on the engine). Runs are always workflow-scoped + (`/workflows/{wf}/runs/…`); there is no top-level `/runs`. +- **Auth:** the proxy injects the credential; you never handle a token. A + misconfigured or unavailable path returns `401`/`403`/`502`/`503`. +- **Frontend links:** panda-server (not the engine) serves `GET /api/v1/workflow-info` + → `{enabled, web_base_url}` — the web origin for human-facing links + (`/whiteboards/{wb}`, `/workflows/{wf}/runs/{run}`); `panda workflow url` wraps + it. Users log in there themselves; no token is exposed. +- **Errors:** RFC 7807 `application/problem+json` — `{type, title, status, + detail}`. `type` is always `about:blank` (don't branch on it). An **unknown + `/api/v1` route** returns `text/plain` "404 page not found" instead of JSON, so + read `detail`/`title` when present and fall back to the raw body. +- **Spec fields:** `authoredSpecYaml` / `compiledSpecJson` are large embedded + strings on draft/release objects. For the **DAG** read `graph`; for **declared + inputs/defaults** parse `compiledSpecJson` (a JSON string) and read + `.inputs.values`: `jq -r '.compiledSpecJson | fromjson | .inputs.values'`. + Defaults live at `.schema.default`. Never parse `authoredSpecYaml`. +- **Idempotency:** `POST …/sessions/{sid}/items` requires an `Idempotency-Key`; + `panda workflow session send` mints one per invocation (`--idempotency-key` to + reuse for cross-retry dedup). Same key + same body → replay; same key + + different body → `409`. +- **ID prefixes:** `wb_` whiteboard · `dr_` draft · `ses_` session · `wf_` + workflow · `wfr_` release · `run_` run · `wart_` artifact · `wop_` worker + operation · `dep_` deployment · `trg_` trigger · `tlitem1.` queue/steer + items. + +## SSE streams + +`…/stream` endpoints return `Content-Type: text/event-stream` with +`X-Accel-Buffering: no`. Framing is `id:` / `event:` / `data:` per event, +blank-line separated. The engine also sends `:`-comment heartbeats (`: ping`) and an +initial `event: sync` frame after backfill — treat neither as data. Resume via +`Last-Event-ID` (allow-listed) or a query cursor. + +- **State streams** (`…/state/stream`) resume with a numeric `?afterSeq=`; refetch + the state snapshot when an event arrives (events are notifications, not state). +- **Worker-log streams** (`…/worker-log/stream`) resume with an opaque base64 + `?cursor=`. +- Streams stay open and are **not** guaranteed to close at a terminal state — + detect terminal `run.status` / turn-end events client-side and disconnect. + +## Whiteboards + +| Method | Path | Notes | +|--------|------|-------| +| POST | `/whiteboards` | body `{name, requirements, inputs?}` → `{id: wb_…, name, status, …}` | +| GET | `/whiteboards` | → `{items:[{id,name,requirements,status,…}]}` | +| GET | `/whiteboards/{wb}/state` | snapshot: `whiteboard.latestDraftId`, `whiteboard.latestSessionId`, `drafts[]` (each carries `graph`, DAG at `graph.nodes`), `sessions[]`, `operations[]`, `cursor` | +| GET | `/whiteboards/{wb}/state/stream?afterSeq=` | SSE: `draft.updated`, `session.updated`, `operation.updated`, `whiteboard.updated`, `state.updated` → refetch state | + +## Sessions + +| Method | Path | Notes | +|--------|------|-------| +| POST | `/whiteboards/{wb}/sessions` | body `{title?, initialItem?:{type:"message",mode:"queue",content}}` → `201` `{sessionId: ses_…}`. `initialItem` is optional; omit it entirely when there is no initial content (a partial `initialItem` is rejected). | +| POST | `/whiteboards/{wb}/sessions/{sid}/items` | body `{type:"message",mode:"queue"\|"stop_and_send",content}`; `mode` is REQUIRED. `queue` starts a turn when idle and enqueues behind a live turn; `stop_and_send` interrupts a live turn. `Idempotency-Key` required. → the queued item (`id: tlitem1.`) | +| GET | `/whiteboards/{wb}/sessions/{sid}/worker-log?limit=` | history; `{items:[…], liveCursor}` | +| GET | `/whiteboards/{wb}/sessions/{sid}/worker-log/stream?cursor=` | SSE worker typing/turn events | +| GET | `…/turns` | cursor-paginated `{items, liveCursor, beforeCursor, hasMoreBefore, lastEventSeq}` | +| GET | `…/queue` | `{parked, pending}` | +| POST | `…/resume` · `…/skip` · `…/interrupt` | session control: `interrupt` → `404` if no active worker op; `skip` → `409` if no pending elicitation | +| POST/DELETE | `…/queue/{itemId}/skip` · `…/queue/{itemId}/retry` · `DELETE …/queue/{itemId}` | queue-item control: `skip`/delete → `200`; `retry` valid only on a parked item | + +**Session worker-log events:** typing `worker.message.delta`; final text +`worker.message.final` / `output.text`; structured +`worker.structured_output.updated` / `output.structured`; end-of-turn +`worker.operation.{completed,failed,cancelled,interrupted}`, +`turn.{completed,failed,interrupted}`, `whiteboard.turn.completed`, +`whiteboard.session.failed`. An `event: sync` frame means replay caught up (not +turn done). + +## Drafts + +| Method | Path | Notes | +|--------|------|-------| +| GET | `/whiteboards/{wb}/drafts` | → `{items:[{id:dr_…,revision,status,graph{nodes[],edges[]},…}]}` (full draft objects; DAG at `graph.nodes`) | +| GET | `/whiteboards/{wb}/drafts/{dr}` | full draft | +| POST | `/whiteboards/{wb}/drafts` · `…/{dr}/revisions` | manual draft/revision `{authoredSpecYaml, inputs?}` → `201` with a new draft (`revision`+1, `status:candidate`) | +| POST | `/whiteboards/{wb}/drafts/{dr}/publish` | → `201` `{workflow, release, deployment, trigger}` | +| POST | `/whiteboards/{wb}/drafts/{dr}/run` | publish-and-run → `202` `{workflow, release, deployment, trigger, run}`; optional body `{inputs, dispatchPolicy}` | + +**Draft top-level keys:** `id`, `whiteboardId`, `revision`, `status` +(`candidate`|`published`), `authoredSpecYaml`, `compiledSpecJson`, `inputs`, +`graph`, timestamps. `inputs` is the provided-inputs *override* (usually `{}`), +NOT the declared schema — read the schema from `compiledSpecJson`. `graph` +carries `nodes` and `edges`; each node is +`{id, name, path, phase, kind, needs?, qualityGate, hasRetry}`. + +## Workflows & runs + +| Method | Path | Notes | +|--------|------|-------| +| GET | `/workflows` | → `{items:[{id: wf_…, name, status, …}]}` | +| GET | `/workflows/{wf}` | `{id,name,status,currentReleaseId,…}` | +| GET | `/workflows/{wf}/releases/{rel}` | `{id,releaseNumber,authoredSpecYaml,compiledSpecJson,graph,dispatchPolicy,…}` | +| POST | `/workflows/{wf}/runs` | body `{inputs:{values,artifacts,secrets}?, dispatchPolicy?}` → `202` `{id: run_…, status}` | +| GET | `/workflows/{wf}/runs` | → `{items:[…]}` — each item is a full run object | +| GET | `/workflows/{wf}/runs/{run}` | full run: `id, status, inputs, outputs, dispatchPolicy, error, startedAt, finishedAt, …` | +| GET | `/workflows/{wf}/runs/{run}/state` | `run.status`, `run.outputs`, `tasks[].outputs`; top-level `operations[]`, `resources[]` | +| GET | `/workflows/{wf}/runs/{run}/state/stream?afterSeq=` | SSE: `run.updated`, `task.updated`, `operation.updated`, `resource.updated`, `state.updated` | +| GET | `/workflows/{wf}/runs/{run}/tasks` | `{items:[{id(=specNodeKey), specNodeKey, taskKey, workerOperationId, status, steerable}]}` (`workflowTaskExecutionId` may be absent/null on live runs) | +| GET | `/workflows/{wf}/runs/{run}/worker-log[?limit=,specNodeIds[]=,taskExecutionIds[]=]` + `/worker-log/stream?cursor=` | run logs; filter by spec node or task execution | +| POST | `/workflows/{wf}/runs/{run}/cancel` | cancel → `204` no body; status → `cancelled` | + +**Terminal `run.status`:** `completed`, `failed`, `cancelled`. The worker-log +stream carries no `run.status`, so `run logs -f` reads terminal status from a +separate `/state` poll; `run watch` reads it from the `/state` snapshot it +refetches per event. + +## Steering (mid-run task control) + +Redirect a running task without cancelling; it resumes with the new direction and +still finishes with validated output. + +| Method | Path | Notes | +|--------|------|-------| +| POST | `…/runs/{run}/task-executions/{task}/steer` | body `{message}` → `202` `{steerId, disposition}` | +| GET | `…/task-executions/{task}/queue` | `{consumed, dismissed, parked, pending, retracted}` | +| GET | `…/task-executions/{task}/turns` | cursor-paginated turns | +| POST | `…/queue/{itemId}/dismiss` · `/retry` | `dismiss` → `200` (status → `retracted`); `retry` valid only on a parked item | + +`{task}` is the task's `specNodeKey` (its `id` in `run tasks`, e.g. +`tasks.fetch_weather`). For a loop task the steerable unit is the inner iteration +(`tasks..[iter=NNNN]`) — the loop parent is not steerable. URL-encode +the `[ ] =` in the key (the CLI does this for you). `disposition` ∈ +`interrupt_requested`, `queued_behind_turn`, `queued_no_live_operation`. A steer +after the task has settled returns `409` "workflow task execution is terminal". + +## Outputs & artifacts + +- Scalar outputs: `state.run.outputs` and `state.tasks[].outputs`. +- Artifact/secret outputs arrive as `$resource` envelopes: + ```json + { "report": { "$resource": { "kind": "artifact", "ref": "tasks.write.outputs.artifacts.report" } } } + ``` + +| Method | Path | Notes | +|--------|------|-------| +| GET | `…/runs/{run}/resources` | → rows `{id: wart_…, slotName, mediaType, sizeBytes, ref, taskKey}` | +| GET | `…/runs/{run}/artifacts/{wart_…}/content` | raw bytes (honor `Content-Type`; may be binary) | + +Choose the concrete row by `slotName` / `mediaType` (matrix/loop outputs return +one row per producing task instance). + +## Dispatch / agents / workers (placement) + +| Method | Path | Notes | +|--------|------|-------| +| GET | `/agents` | → `{agents:{:{name,type,models,workerIds}}}` | +| GET | `/dispatch/inventory` | → `{entries:[{agent, model?, workerCount, availableSlots}]}` (`model` optional) | +| GET | `/dispatch/effective[?scope=me\|org]` | `scope` is optional; omit for the server default. → `{mode, principal, scope, …}` | +| GET | `/dispatch/health` | `{cooldowns}` | +| POST | `/dispatch/simulate` | body `{kind:"workflow_task", runtime:{agent,model}, …}` → `{candidates, empty, wouldSelect}` | +| GET | `/worker-identities` | `{workerIdentities:[{id,name,…}]}` | +| GET | `/workers/operations` | `{items}` — queued/running worker operations | + +`dispatchPolicy` on a run body is `WorkflowDispatchOverrides` (`defaults` + +per-task overrides keyed by spec node key like `tasks.review`). Supply only to +pin an agent/model/worker. + +Read the **default** from `/dispatch/effective` `.workflowTask.policy.agent` / +`.model`; a `policy.stages[]` array is an escalation/fallback ladder, **not** +the default. For `simulate`, always pass the exact `runtime` you intend — a +bare `{"kind":"workflow_task"}` can report a stage candidate. After starting a +pinned run, verify the operation's `runtime.resolved.agent` (via +`/workers/operations`) matches the request; cancel on a mismatch. + +## Uncurated endpoints (raw `api` hatch) + +Reach these via `panda workflow api `: + +| Method | Path | Notes | +|--------|------|-------| +| GET | `/auth/me` (+ `/principals/me`) | the token's principal identity | +| GET | `/health` | liveness | +| GET · PATCH | `/whiteboards/{wb}` | fetch / update a whiteboard | +| POST | `/whiteboards/{wb}/archive` | archive (there is no hard `DELETE`) | +| GET | `/workflows/{wf}/releases` | release list | + +The raw hatch reaches the full `/api/v1` with the injected token's authority — +including `secrets/*`, principal admin, `auth/tokens`, `templates/*`, +`deployments/*`, `workspaces/*` — so the proxy should inject a least-privilege +engine principal. diff --git a/pkg/cli/workflowdocs/guide.md b/pkg/cli/workflowdocs/guide.md new file mode 100644 index 00000000..a30743ad --- /dev/null +++ b/pkg/cli/workflowdocs/guide.md @@ -0,0 +1,339 @@ +# Driving workflows with `panda workflow` + +`panda workflow` is a thin client for the **workflow engine**. The engine designs, +publishes, and runs multi-step agent workflows. You use it to turn a plain- +language request into a completed workflow run. + +**Three roles, strictly separated:** + +- **The workflow engine owns drafting.** It has templates and knows about devnets/networks and + inputs. You describe what is wanted in plain language; the engine writes the + workflow spec. You never hand-author specs and never rewrite the user's words. +- **The user owns the publish/run decision.** A request like "use the workflow engine to get + the status of X" is a *drafting brief* — it authorizes creating a whiteboard + and drafting, **not** publishing or running. Publishing/running is a side + effect the user must approve after seeing the draft. See "The publish/run + gate" below; it defines exactly what counts as approval. +- **You (the operating agent) own the sequence.** `panda workflow` gives you raw + primitives, one workflow-engine REST call per command. The checkpoints below are yours + to enforce (the CLI's `--approved` tripwires backstop them, nothing more). + Use `--json` and parse the output. + +**Fresh by default.** Every new request gets a **new** whiteboard → draft → +review → run. Reuse an existing whiteboard/workflow/run **only** when the user +explicitly named its id (`wb_…`/`wf_…`/`run_…`) or said in so many words to +re-run an existing one. A leftover workflow from an earlier session that looks +like it matches the request is **not** an invitation to run it — +`workflow list` / `whiteboard list` exist to inspect state and resolve +something the user named, never to find something to run. + +## The resource model + +``` +whiteboard ── the planning space (holds sessions + drafts) wb_… + └─ session ── your chat with the worker that writes drafts ses_… + └─ draft ── a candidate workflow spec (iterate → revise) + └─ publish ─▶ workflow ── the executable object wf_… + └─ run ── one execution run_… +``` + +## The operator loop + +### 1. Create a whiteboard +``` +panda workflow whiteboard create --requirements "" --json +# → .id = wb_… +``` + +### 2. Ask the engine to draft — the user's words, verbatim +``` +panda workflow session create --content "" --json +# → .sessionId = ses_… +``` +Pass the request through cleanly. Do **not** prepend your analysis, guess at a +spec, or expand the request — the engine drafts it. + +### 3. Wait for the draft (watch two signals) +The draft can appear before the chat turn fully ends. Watch both: +``` +panda workflow session logs -f --json # exits on the FIRST turn/operation terminal +panda workflow whiteboard get --json # → .whiteboard.latestDraftId once present +``` +`session logs -f` exits on the **first** turn-terminal event; a queued item starts a +**new** turn that is *not* followed — re-invoke `session logs -f` to follow it. + +### 4. Show the draft to the user +Render the draft **to the user**. The review is for the human, not for you — +fetching the draft and reading it silently does not satisfy this step. +``` +panda workflow draft show # the review, pre-rendered +panda workflow draft show --json # same review, structured +``` +`draft show` is the plan output: id/revision/status, declared inputs (with +defaults), scalar + artifact outputs, and the task DAG in dependency order +(with `[quality-gate]` / loop / matrix annotations). Present it verbatim or +reformatted — but present it. Add brief notes on anything notable (loops, +`if` guards, `uses:` templates) and possible tweaks — only ones genuinely +worth asking about (missing inputs/outputs, weak verification, risky +assumptions). Do not pad the list. + +For the raw draft object use `draft get --json`: the DAG is +`.graph.nodes[]`; declared inputs live in `.compiledSpecJson`, a JSON +**string** (`jq -r '.compiledSpecJson | fromjson | .inputs.values'`, defaults +at `.schema.default`). The top-level `.inputs` is the *provided-inputs +override* (usually `{}`), not the schema. **Never parse `authoredSpecYaml`.** + +### 5. The review checkpoint — hard stop +After presenting the draft, **stop and ask**. Use a blocking question tool when +your harness has one (`AskUserQuestion` in Claude Code; an `elicitation/create` +request in MCP clients); otherwise ask in plain text and treat silence or an +ambiguous reply as *not* approved. Never auto-resolve this checkpoint. Offer +exactly these choices and follow the branch the user picks: + +- **Publish and run** — default dispatch, no placement question (skip step 7); + ask the follow preference (step 8), then run. +- **Publish and run with selected workers and/or agents** — do the dispatch + placement elicitation (step 7), then the follow preference (step 8), then run. +- **Iterate** — ask what to change (offer the tweaks you spotted), send it per + step 6, and re-review the **new** draft at this same checkpoint. +- **Stop** — leave the draft unpublished, report the captured IDs, and hand + control back. + +### 6. Iterate — send the change verbatim +``` +panda workflow session send --content "Make it a loop." --json +``` +Send only the user's change, in direct imperative form; no commentary, review +notes, or inferred rationale. If the user picked one of your suggested tweaks, +send only that tweak's text. If the feedback is ambiguous enough that rewriting +it would change its meaning, ask the user first. This starts a new turn — +re-invoke `session logs -f`, wait for the new draft (new revision / new +`latestDraftId`), and go back to step 4. + +### 7. Dispatch placement (only on the "selected workers/agents" branch) +Skip this entirely for a plain "Publish and run" — default dispatch needs no +placement question. Otherwise, query the live options first — never guess +agent/model names: +``` +panda workflow dispatch agents --json # agent types, models, workers +panda workflow dispatch inventory --json # (agent, model) pairs with healthy workers NOW +panda workflow dispatch workers --json # worker ids/names for workerId pins +panda workflow dispatch effective --scope me --json # what "default" resolves to +``` +Read "default" from `.workflowTask.policy.agent` / `.model` — the top-level +resolved policy. If `policy.stages[]` is present it is an escalation/fallback +ladder, **not** the default. Offer only agents that appear in the inventory. +Ask one concise placement question (default vs a specific agent, or per-task +assignments). For per-task pins, build overrides keyed by spec node key: +```json +{"defaults": {"agent": "codex"}, "tasks": {"tasks.review": {"agent": "claude"}}} +``` +Sanity-check the pin before running — always passing the exact runtime you +intend (a bare `{"kind":"workflow_task"}` can report a stage candidate instead): +``` +panda workflow dispatch simulate --data '{"kind":"workflow_task","runtime":{"agent":"codex"}}' +``` +If it comes back empty for the requested agent, tell the user the pin will not +place instead of running blind. + +### 8. Ask the follow preference, then publish + run +Before running, ask one more question — how should the run be followed? + +- **Follow live** — stream progress in the foreground (`run watch` / + `run logs -f`); the conversation waits on the run. +- **Follow in the background** (best default when your harness runs commands + in the background) — launch `run follow` as a background task and keep + working with the user; relay notable transitions when you check in, and + report the summary when the task's completion notification arrives. +- **Wait quietly** — no live progress; check at the end and report once. + +Whichever they pick, tell the user they can steer any running task mid-flight — +redirect it without cancelling (step 10); steering works from the foreground +even while a background follow is watching. Then run: +``` +panda workflow draft run --approved --json # publish-and-run +# → .workflow.id = wf_… , .run.id = run_… +``` +`--approved` must re-type the exact draft id the user approved — the CLI +refuses without it, and refuses a stale id after a newer revision. It is proof +the checkpoint happened, **not** the approval itself: passing it without the +user's explicit approval violates the gate. Inputs are usually optional — +the engine defaults them. Override with +`--inputs '{"values":{…}}'` only when needed (read what it accepts from the +draft's `compiledSpecJson`, step 4). Pass the assembled placement, if any, as +`--dispatch '{…}'`. If an agent was pinned, verify placement after the run +starts — check the run's operations resolve to the requested agent +(`panda workflow dispatch operations --json`, `runtime.resolved.agent`); on a +mismatch, cancel and report rather than letting it run on the wrong agent. + +### 9. Follow the run +``` +panda workflow run tasks --json # task keys (for steering / narrow logs) +panda workflow run follow # background-friendly: deltas → stderr, one summary JSON → stdout +panda workflow run watch --json # foreground: full snapshot stream +panda workflow run logs -f --json # worker logs (optional) +``` +All three exit at terminal `run.status` (`completed|failed|cancelled`) with the +status exit code (0 completed, 1 failed/cancelled). `run watch` emits a full +`/state` snapshot per event — right for a foreground tail. `run follow` is the +one to background: stderr carries one short line per *change* (task/run +transitions, failures), stdout carries exactly **one** JSON summary at terminal +(status, duration, task counts, failed-task errors, outputs, artifacts), and a +dropped stream reconnects automatically — so reading its output later costs a +few lines, not an archive of snapshots. `run logs -f` streams pure worker-log +text and polls `/state` out-of-band for the terminal status. + +Honor the user's follow preference. **Follow live:** report task transitions, +quality-gate outcomes, and errors as they happen; on a long quiet stretch, say +the run is still going and name the active task(s). **Background:** launch +`run follow` as a background task, keep working with the user, and when the +task exits read its stdout summary (the exit code already says +completed-vs-not) and report; don't rely solely on the completion notification +— check the task on your next natural turn if none arrived. When you hand the +run to the background, tell the user they can steer any running task mid-flight +(step 10) — redirect it without cancelling and without disturbing the follow. **Wait quietly:** +check at terminal and report once — but still look at failures rather than +assuming success. Do not disappear mid-run in any mode. + +### 10. Steer a running task (optional) +Redirect a task mid-flight without cancelling it: +``` +panda workflow steer send --message "" --json +``` +When the user asks you to steer, **read the current run state first** — don't +steer blind. Pull `run tasks --json` and confirm the task you mean to +target is actually in flight and `steerable`; a steer to a task that has already +settled returns `409`, and the loop parent silently accepts nothing (see below). +If more than one task is running, don't guess which one they mean: name the +in-flight tasks back to the user and confirm the target — and the exact +direction — before sending. + +`` is the task's `specNodeKey` from `run tasks` (e.g. `tasks.analyze`) — use +the real key you just read, never a guessed name. The task interrupts, applies your +direction, and still finishes with valid output. Steer to redirect; cancel +(`run cancel`) only to stop a task entirely. + +Pass the user's steer direction through as close to verbatim as you can — it goes +straight to the running task. Don't paraphrase, expand, or "improve" it; only touch +the wording when it genuinely needs it (resolve a "that"/"it" that the task can't +see, or drop conversational filler), and keep their intent and specifics intact. If +their direction is ambiguous, ask them rather than guessing a rewrite. + +For a **loop** task, steer the **inner iteration** node that `run tasks` shows +(`tasks..[iter=NNNN]`), not the loop parent — the parent isn't +steerable, so steering it does nothing. + +### 11. Read outputs + artifacts +``` +panda workflow run get --json # → .run.outputs (scalars) +``` +Artifact outputs appear as `$resource` envelopes. Resolve them: +``` +panda workflow artifact list --json # find by slotName / mediaType +panda workflow artifact get --out out.md # fetch bytes +``` + +### 12. Report back +Answer the original request from the scalar outputs and artifact content, and +include: the IDs (`wb_…`, approved `dr_…`, `wf_…`, `run_…`) so the side effect +is auditable; **frontend links** so the user can open things in a browser — +`panda workflow url whiteboard ` / `url run ` (also on +`draft show`'s `whiteboardUrl` and `run follow`'s summary `links`; access is +the user's own workflow-engine login, never a panda or proxy token — omit links silently if the +server exposes no web origin); the final status; a task summary +(completed/failed counts, any failed-task errors); and the artifact list +(`slotName`, `mediaType`, size). If +artifacts exist and the user hasn't said what to do with them, ask once: print +inline, save to a directory, or leave it on the engine. For a failed or cancelled run, +still list produced artifacts and include the most relevant worker-log/error +excerpt. Never print credentials or secret values. + +## The publish/run gate + +Publishing/running is the side-effect boundary, and `draft run` / +`draft publish` / `run create` cross it. The CLI backstops all three — +`draft publish` and `draft run` refuse without `--approved `, and +`run create` refuses without `--approved ` — but the flag is a +tripwire, not the gate itself. It proves *you* completed the checkpoint; the +approval must come from the user. Never pass `--approved` on the user's +behalf. + +`run create` carries a second condition on top of approval: it reuses an +existing workflow, which per "Fresh by default" is only legitimate when the +user explicitly named that workflow or asked to re-run one. "The user's goal +matches what some existing workflow does" satisfies neither condition. + +**Approval that counts** (explicit, about publish/run, for the reviewed draft): + +- "publish and run" / "approved to publish and run" / "run this exact draft"; +- an initial request that *explicitly* says to draft, publish, and run without + stopping for review (e.g. "draft and run it, don't ask"). + +**Not approval — do not cross the gate on these:** + +- the original task request itself ("use the workflow engine to get the status of X" ≠ "run + whatever you drafted") — never self-authorize by reading intent into the goal; +- an existing whiteboard/workflow you **found** via `workflow list` / + `whiteboard list` that looks like it fits the request — discovery is not + designation; only the user can name a thing to reuse; +- silence, or "ok" / "continue" / "looks good" when your question did not + clearly ask for publish/run approval; +- approval given for an **older** draft after a newer revision appeared — + approval binds to a specific `draftId`; re-review the new one. + +If approval is missing, stop at the step-5 checkpoint and ask. When in doubt, +it is not approval — asking again is cheap; an unwanted run is not. + +## Definition of done + +Do not claim completion until: + +- the run came from a **fresh** whiteboard → draft → review loop, or from a + workflow/whiteboard the **user explicitly named** — never one you discovered; +- `whiteboardId`, `sessionId`, and the final `draftId` were captured; +- the draft was **shown to the user** (`draft show`: DAG + + inputs/outputs/artifacts), not just fetched; +- the user explicitly approved publish/run at the checkpoint (or had + pre-authorized it in so many words), any requested iterations produced a + newer reviewed draft, and `--approved` carried the id of that reviewed draft; +- the placement branch matched the user's choice — default dispatch ran with no + placement question; a pinned run was verified to resolve to the requested + agent; +- the user was offered follow live / follow in the background / wait quietly, + told about steering, and progress was followed per their choice — not + silently abandoned mid-run; +- the run reached a terminal status, outputs were read from run state, and + artifacts (if any) were resolved via `artifact list` / `artifact get`; +- the report includes IDs, status, outputs, artifacts, and frontend links + (whiteboard + run, via `panda workflow url`) when the server exposes the engine's + web origin; any failure names the failing command, the response, and the + next diagnostic step. + +## Gotchas + +- **Draft-ready lag.** `latestDraftId` can trail the actual draft; if + `whiteboard get` lags, check `draft list ` / `session logs`. Watch both + signals. +- **Streams may not close at terminal.** Don't treat stream EOF as the signal. + `run watch` and `run follow` read terminal `run.status` + (`completed|failed|cancelled`) from their `/state` snapshots (`follow` also + reconnects on a dropped stream); `run logs -f` polls `/state` on the side for + the same; session streams end on turn/operation-terminal events. All built in. +- **Inputs are optional.** the engine defaults declared inputs — only pass `--inputs` + to override. +- **`session send` always makes progress.** It defaults to `mode:"queue"`, which + starts a new turn when the session is idle and enqueues behind a live turn — so a + plain `session send` after a completed turn kicks off the next revision. Use + `--interrupt` only to cut off a turn that is still running. +- **`draft run` is publish-and-run in one shot** — the highest-blast-radius + command here. It belongs *after* the step-5 checkpoint, never before, and + requires `--approved ` (so does `draft publish`). + +## When to use `panda workflow` (and when not) + +- **Use it** to create/inspect/run/steer a workflow or read its outputs. +- **Do not use it** to query Ethereum data — that's the panda data workflow + (`panda datasets` → `panda search examples` → `panda execute`). + +See `panda workflow docs api` for the full endpoint cheat-sheet, or +`panda workflow --help`. diff --git a/pkg/config/config.go b/pkg/config/config.go index 76ff751b..b7ed5ca8 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -194,6 +194,15 @@ type ProxyAuthConfig struct { // Leave empty for standard OIDC providers that do not use RFC 8707 resource parameters. Resource string `yaml:"resource,omitempty"` + // Scopes are the OAuth scopes requested at login. When empty, the auth client + // requests its defaults (openid, email, groups, offline_access). When set, + // this list is sent verbatim — so include those base scopes plus any extras. + // For example, requesting the workflow-engine audience makes Authentik + // cross-grant that audience to the panda-proxy token so the same credential + // works against the workflow engine in passthrough mode. Omitting + // offline_access means no refresh token is issued. + Scopes []string `yaml:"scopes,omitempty"` + // RefreshTokenTTL is the expected lifetime of the refresh token issued by the // OIDC provider. When set, the client will proactively refresh at 50% of this // duration to keep the refresh token alive via provider rotation. diff --git a/pkg/observability/metrics.go b/pkg/observability/metrics.go index 225f3ee6..a9e6b0e4 100644 --- a/pkg/observability/metrics.go +++ b/pkg/observability/metrics.go @@ -43,11 +43,43 @@ var ( ) ) +// Workflow passthrough metrics. The /api/v1/workflow/* passthrough has no +// metrics middleware, so its handler records these itself after each request +// (once on stream completion for long-lived SSE). +var ( + // WorkflowPassthroughTotal counts workflow passthrough requests by method and + // response status class (2xx, 4xx, 5xx, ...). + WorkflowPassthroughTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Namespace: metricsNamespace, + Name: "workflow_passthrough_total", + Help: "Total number of workflow passthrough requests", + }, + []string{"method", "status_class"}, + ) + + // WorkflowPassthroughDuration measures workflow passthrough request duration + // in seconds. It is recorded only for non-streaming responses: SSE streams + // (text/event-stream) are excluded because the handler blocks for the whole + // stream lifetime, so the elapsed time is the stream duration, not latency. + WorkflowPassthroughDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Namespace: metricsNamespace, + Name: "workflow_passthrough_duration_seconds", + Help: "Duration of workflow passthrough requests in seconds", + Buckets: prometheus.ExponentialBuckets(0.05, 2, 12), + }, + []string{"method"}, + ) +) + func init() { // Register all metrics with the default registry. prometheus.MustRegister( ToolCallsTotal, ToolCallDuration, ActiveConnections, + WorkflowPassthroughTotal, + WorkflowPassthroughDuration, ) } diff --git a/pkg/proxy/authorizer.go b/pkg/proxy/authorizer.go index 54c8714d..b32168d0 100644 --- a/pkg/proxy/authorizer.go +++ b/pkg/proxy/authorizer.go @@ -75,6 +75,15 @@ func NewAuthorizer(log logrus.FieldLogger, cfg ServerConfig) *Authorizer { }} } + // Workflow is gated at the type level (no per-name granularity). A rule is + // only installed when allowed_orgs is set; otherwise the engine is open to + // all authenticated users. + if cfg.Workflow != nil && len(cfg.Workflow.AllowedOrgs) > 0 { + a.rules[ruleKey("workflow", "")] = []datasourceVariantRule{{ + allowedOrgs: append([]string(nil), cfg.Workflow.AllowedOrgs...), + }} + } + return a } @@ -121,6 +130,12 @@ func (a *Authorizer) FilterDatasources(ctx context.Context, resp DatasourcesResp filtered.BenchmarkoorInfo = a.filterDatasourceList(userOrgs, hasUser, "benchmarkoor", resp.BenchmarkoorInfo) filtered.ComputeInfo = a.filterDatasourceList(userOrgs, hasUser, "compute", resp.ComputeInfo) + // The workflow advert is type-level: hide it entirely from callers who + // lack the required org membership. + if resp.Workflow != nil && a.orgsMatch(userOrgs, hasUser, ruleKey("workflow", "")) { + filtered.Workflow = resp.Workflow + } + return filtered } @@ -162,6 +177,15 @@ func (a *Authorizer) routeName(ctx context.Context, dsType, dsName string) (stri return "", false } + // Workflow is gated at the type level too (no X-Datasource name). + if dsType == "workflow" { + if a.orgsMatch(userOrgs, hasUser, ruleKey("workflow", "")) { + return "", true + } + + return "", false + } + // For datasources endpoint, skip middleware check (filtered in handler). if dsType == "datasources" || dsType == "unknown" { return "", true diff --git a/pkg/proxy/client.go b/pkg/proxy/client.go index 5d6afc27..9f2e317e 100644 --- a/pkg/proxy/client.go +++ b/pkg/proxy/client.go @@ -65,6 +65,9 @@ type ClientConfig struct { // Leave empty for standard OIDC providers that do not use RFC 8707 resource parameters. Resource string + // Scopes are the OAuth scopes to request; empty means the auth client's defaults. + Scopes []string + // RefreshTokenTTL is the expected lifetime of the refresh token. // When set, the credential store will refresh at 50% of this duration // to keep the refresh token alive via provider rotation. @@ -136,8 +139,9 @@ var ErrAuthenticationRequired = errors.New("proxy authentication required") // Compile-time interface checks. var ( - _ Client = (*proxyClient)(nil) - _ Service = (*proxyClient)(nil) + _ Client = (*proxyClient)(nil) + _ Service = (*proxyClient)(nil) + _ WorkflowInfoProvider = (*proxyClient)(nil) ) // NewClient creates a new proxy client. @@ -170,6 +174,7 @@ func NewClient(log logrus.FieldLogger, cfg ClientConfig) Client { IssuerURL: issuerURL, ClientID: cfg.ClientID, Resource: cfg.Resource, + Scopes: cfg.Scopes, Username: cfg.Username, Password: cfg.Password, Mode: cfg.AuthMode, @@ -501,6 +506,19 @@ func (c *proxyClient) EmbeddingModel() string { return c.datasources.EmbeddingModel } +// WorkflowInfo reports whether the proxy advertises a workflow engine and its +// web URL, read from the cached datasource discovery. +func (c *proxyClient) WorkflowInfo() (enabled bool, webURL string) { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.datasources.Workflow == nil { + return false, "" + } + + return c.datasources.Workflow.Enabled, c.datasources.Workflow.WebURL +} + // Discover fetches datasource information from the proxy's /datasources endpoint. // A 401/403 invalidates the cached token and the request is retried once with a // fresh one, covering proxy-side revocation before the local expiry buffer diff --git a/pkg/proxy/handlers/workflow.go b/pkg/proxy/handlers/workflow.go new file mode 100644 index 00000000..38bd7815 --- /dev/null +++ b/pkg/proxy/handlers/workflow.go @@ -0,0 +1,251 @@ +package handlers + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httputil" + "net/url" + "path" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/sirupsen/logrus" + + "github.com/ethpandaops/panda/pkg/attribution" + "github.com/ethpandaops/panda/pkg/workflowrelay" +) + +// Workflow auth modes select how the proxy credentials the upstream engine. +const ( + // WorkflowAuthModeToken injects the proxy-held api_token as the bearer; the + // inbound proxy bearer never reaches upstream. + WorkflowAuthModeToken = "token" + // WorkflowAuthModePassthrough re-attaches the caller's own bearer unchanged + // so the engine validates the user's token directly. + WorkflowAuthModePassthrough = "passthrough" +) + +// WorkflowConfig holds the workflow-engine passthrough handler configuration. +type WorkflowConfig struct { + // URL is the workflow engine API origin (bare scheme+host[:port], no path). + URL string + // AuthMode is "token" or "passthrough". + AuthMode string + // APIToken is the bearer injected in "token" mode. + APIToken string +} + +// workflowReqData carries the per-request path values the reverse-proxy Rewrite +// needs. They are resolved (and validated) in ServeHTTP before proxying so +// failures short-circuit cleanly; Rewrite cannot return an error. +type workflowReqData struct { + outPath string + rawPath string +} + +type workflowDataKeyType struct{} + +var workflowDataKey workflowDataKeyType + +// WorkflowHandler is a thin credentialed reverse proxy to the workflow engine +// API. It clamps and cleans the request path, forwards a strict header +// allow-list, sets Authorization per auth mode, and streams responses +// (SSE-safe). All HTTP methods are forwarded: the engine manages mutable +// resources and authorizes each request itself. +type WorkflowHandler struct { + log logrus.FieldLogger + baseURL *url.URL + authMode string + apiToken string + proxy *httputil.ReverseProxy +} + +// NewWorkflowHandler builds a workflow passthrough for the given config. It +// returns an error when the configured URL cannot be parsed. +func NewWorkflowHandler(log logrus.FieldLogger, cfg WorkflowConfig) (*WorkflowHandler, error) { + base, err := url.Parse(strings.TrimSpace(cfg.URL)) + if err != nil { + return nil, fmt.Errorf("parsing workflow url: %w", err) + } + + // Reuse the shared handler transport but disable transparent gzip: Go's + // Transport auto-adds Accept-Encoding: gzip and decompresses, which buffers + // a block before emitting and defeats FlushInterval: -1 for SSE. + transport := newProxyTransport(false) + transport.DisableCompression = true + + mode := strings.TrimSpace(cfg.AuthMode) + if mode == "" { + mode = WorkflowAuthModeToken + } + + h := &WorkflowHandler{ + log: log.WithField("handler", "workflow"), + baseURL: base, + authMode: mode, + apiToken: cfg.APIToken, + } + + h.proxy = &httputil.ReverseProxy{ + Transport: transport, + // Flush every write so SSE frames reach the client immediately. + FlushInterval: -1, + Rewrite: h.rewrite, + ErrorHandler: h.errorHandler, + } + + return h, nil +} + +// rewrite rebuilds the outbound request: upstream target, explicit path, header +// allow-list, and Authorization set per auth mode. +func (h *WorkflowHandler) rewrite(pr *httputil.ProxyRequest) { + pr.SetURL(h.baseURL) + + // SetURL joins the target path with the inbound path, which would leak the + // /workflow mount prefix upstream. Overwrite Path and RawPath explicitly + // with the computed /api/v1/ (this is why url must be path-less). + data, _ := pr.In.Context().Value(workflowDataKey).(*workflowReqData) + if data != nil { + pr.Out.URL.Path = data.outPath + pr.Out.URL.RawPath = data.rawPath + } + + pr.Out.Host = pr.Out.URL.Host + + // Replace the (hop-by-hop-stripped) inbound headers with the shared strict + // allow-list. This drops Authorization, Cookie, Host, and the attribution + // header by construction before we set our own Authorization. + allowed := workflowrelay.FilterHeaders(pr.In.Header) + + switch h.authMode { + case WorkflowAuthModePassthrough: + // Re-attach the caller's own verified bearer unchanged; the engine + // validates it directly. + if inbound := pr.In.Header.Get("Authorization"); inbound != "" { + allowed.Set("Authorization", inbound) + } + default: + // token mode: inject the proxy-held api_token; the inbound proxy bearer + // never reaches upstream. + if h.apiToken != "" { + allowed.Set("Authorization", "Bearer "+h.apiToken) + } + } + + pr.Out.Header = allowed + + pr.SetXForwarded() + + // The allow-list already drops attribution; delete it explicitly so a + // future addition to ForwardedHeaders cannot leak it upstream. + pr.Out.Header.Del(attribution.Header) +} + +// errorHandler returns a clean 502 problem+json when the upstream is +// unreachable. +func (h *WorkflowHandler) errorHandler(w http.ResponseWriter, _ *http.Request, err error) { + h.log.WithError(err).Warn("workflow upstream error") + workflowrelay.WriteProblem(w, http.StatusBadGateway, "Bad Gateway", + "workflow engine is configured but unreachable") +} + +// ServeHTTP resolves and clamps the request path, then proxies the request. It +// clears the response write deadline so SSE streams outlive server.write_timeout. +func (h *WorkflowHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Clear the write deadline (best-effort) so a long-lived SSE stream is not + // cut off by server.write_timeout. Wrapping writers expose Unwrap() so the + // controller reaches the real writer. + _ = http.NewResponseController(w).SetWriteDeadline(time.Time{}) + + outPath, rawPath, err := workflowCleanPath(chi.URLParam(r, "*")) + if err != nil { + workflowrelay.WriteProblem(w, http.StatusBadRequest, "Bad Request", err.Error()) + + return + } + + ctx := context.WithValue(r.Context(), workflowDataKey, &workflowReqData{ + outPath: outPath, + rawPath: rawPath, + }) + + h.proxy.ServeHTTP(w, r.WithContext(ctx)) +} + +// workflowCleanPath clamps and cleans the wildcard remainder into the upstream +// path pair (decoded Path, encoded RawPath) rooted at /api/v1. It rejects path +// traversal and strips a redundant leading /api/v1 segment from the raw form so +// the prefix is never doubled. +// +// chi hands us the raw (still percent-encoded) wildcard for most inputs, but a +// double-encoded input (e.g. %252e%252e) arrives already decoded once. We decode +// exactly once here to obtain a single canonical representation for ALL traversal +// checks, so both single- (%2e%2e) and double-encoded (%252e%252e) traversal — +// plus matrix-param (`..;`) and backslash (`..\..`) variants — surface as literal +// "..". The raw form is what we path.Clean and forward, so percent-encoded +// reserved characters in a segment (steer keys like tasks.loop.child%5Biter%3D0002%5D, +// or an encoded slash %2F) survive intact instead of being decoded and re-split. +func workflowCleanPath(rest string) (outPath, rawPath string, err error) { + decoded, decErr := url.PathUnescape(rest) + if decErr != nil { + return "", "", errors.New("invalid path encoding") + } + + if traversalErr := workflowrelay.RejectTraversal(decoded); traversalErr != nil { + return "", "", traversalErr + } + + // Clean the raw (still percent-encoded) remainder to collapse `.`/`..` + // segments and duplicate slashes. + cleanedRaw := path.Clean("/" + strings.TrimPrefix(rest, "/")) + + // Re-verify AFTER cleaning, on the decoded form, so path.Clean can never + // hand a surviving ".." segment upstream (defence in depth over the pre-clean + // check above). + cleanedDecoded, decErr := url.PathUnescape(cleanedRaw) + if decErr != nil { + return "", "", errors.New("invalid path encoding") + } + + if traversalErr := workflowrelay.RejectTraversal(cleanedDecoded); traversalErr != nil { + return "", "", traversalErr + } + + cleanedRaw = workflowTrimAPIV1Prefix(cleanedRaw) + if cleanedRaw == "" || cleanedRaw == "/" { + rawPath = "/api/v1" + } else { + if !strings.HasPrefix(cleanedRaw, "/") { + cleanedRaw = "/" + cleanedRaw + } + + rawPath = "/api/v1" + cleanedRaw + } + + outPath, decErr = url.PathUnescape(rawPath) + if decErr != nil { + return "", "", errors.New("invalid path encoding") + } + + return outPath, rawPath, nil +} + +// workflowTrimAPIV1Prefix strips a leading "/api/v1" ONLY on a full-segment +// boundary (exact match or followed by "/"), so "/api/v1foo" is never mangled +// into "/foo". It returns "" for an exact "/api/v1" so the caller re-roots it. +func workflowTrimAPIV1Prefix(p string) string { + const prefix = "/api/v1" + + switch { + case p == prefix: + return "" + case strings.HasPrefix(p, prefix+"/"): + return strings.TrimPrefix(p, prefix) + default: + return p + } +} diff --git a/pkg/proxy/handlers/workflow_test.go b/pkg/proxy/handlers/workflow_test.go new file mode 100644 index 00000000..3d0ca8a9 --- /dev/null +++ b/pkg/proxy/handlers/workflow_test.go @@ -0,0 +1,459 @@ +package handlers + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/panda/pkg/attribution" +) + +// capturedWorkflowRequest records what the mock upstream received. +type capturedWorkflowRequest struct { + method string + path string + rawURI string + header http.Header +} + +// newWorkflowTestHandler wires a chi router with the workflow passthrough +// pointed at upstream, mounted at /workflow/* like the proxy does. authMode and +// token select the credential behavior. +func newWorkflowTestHandler(t *testing.T, upstreamURL, authMode, token string) http.Handler { + t.Helper() + + h, err := NewWorkflowHandler(logrus.New(), WorkflowConfig{ + URL: upstreamURL, + AuthMode: authMode, + APIToken: token, + }) + require.NoError(t, err) + + r := chi.NewRouter() + r.Handle("/workflow", h) + r.Handle("/workflow/*", h) + + return r +} + +func TestWorkflowHandlerHeaderAllowListAndPrefixStrip(t *testing.T) { + t.Parallel() + + var captured capturedWorkflowRequest + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = capturedWorkflowRequest{ + method: r.Method, + path: r.URL.Path, + rawURI: r.URL.RequestURI(), + header: r.Header.Clone(), + } + + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true}`) + })) + defer upstream.Close() + + handler := newWorkflowTestHandler(t, upstream.URL, WorkflowAuthModeToken, "proxy-tok") + + req := httptest.NewRequest(http.MethodGet, "/workflow/whiteboards?limit=5", nil) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Idempotency-Key", "idem-123") + req.Header.Set("Last-Event-ID", "42") + // These must be stripped; the proxy injects its own bearer in token mode. + req.Header.Set("Authorization", "Bearer client-should-not-leak") + req.Header.Set("Cookie", "session=nope") + req.Header.Set(attribution.Header, "someone") + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + // Prefix strip: upstream sees /api/v1/whiteboards, NOT /workflow/... + assert.Equal(t, "/api/v1/whiteboards", captured.path) + assert.NotContains(t, captured.path, "workflow") + assert.Equal(t, "/api/v1/whiteboards?limit=5", captured.rawURI) + + // token mode injects the proxy-held api_token; the caller's bearer is dropped. + assert.Equal(t, "Bearer proxy-tok", captured.header.Get("Authorization")) + + // Allow-listed headers forwarded. + assert.Equal(t, "application/json", captured.header.Get("Accept")) + assert.Equal(t, "application/json", captured.header.Get("Content-Type")) + assert.Equal(t, "idem-123", captured.header.Get("Idempotency-Key")) + assert.Equal(t, "42", captured.header.Get("Last-Event-ID")) + + // Stripped headers are gone. + assert.Empty(t, captured.header.Get("Cookie")) + assert.Empty(t, captured.header.Get(attribution.Header)) +} + +func TestWorkflowHandlerPassthroughForwardsCallerBearer(t *testing.T) { + t.Parallel() + + var captured capturedWorkflowRequest + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = capturedWorkflowRequest{header: r.Header.Clone()} + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + // passthrough mode: no api_token; the caller's own bearer is forwarded. + handler := newWorkflowTestHandler(t, upstream.URL, WorkflowAuthModePassthrough, "") + + req := httptest.NewRequest(http.MethodGet, "/workflow/whiteboards", nil) + req.Header.Set("Authorization", "Bearer caller-jwt") + req.Header.Set("Cookie", "session=nope") + req.Header.Set(attribution.Header, "someone") + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + // The caller's bearer is forwarded unchanged. + assert.Equal(t, "Bearer caller-jwt", captured.header.Get("Authorization")) + // Cookie and attribution are still dropped. + assert.Empty(t, captured.header.Get("Cookie")) + assert.Empty(t, captured.header.Get(attribution.Header)) +} + +func TestWorkflowHandlerRawAPIPrefixStrip(t *testing.T) { + t.Parallel() + + var gotPath string + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + handler := newWorkflowTestHandler(t, upstream.URL, WorkflowAuthModeToken, "tok") + + // The raw `api` form may include a leading /api/v1 — it must not double. + req := httptest.NewRequest(http.MethodGet, "/workflow/api/v1/health", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, "/api/v1/health", gotPath) +} + +func TestWorkflowHandlerEncodedSegmentPreserved(t *testing.T) { + t.Parallel() + + var rawURI string + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawURI = r.URL.RequestURI() + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + handler := newWorkflowTestHandler(t, upstream.URL, WorkflowAuthModeToken, "tok") + + // Steer key with reserved chars, percent-encoded by the caller. + req := httptest.NewRequest(http.MethodGet, + "/workflow/workflows/wf_1/runs/run_1/task-executions/tasks.loop.child%5Biter%3D0002%5D/queue", + nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rawURI, "%5Biter%3D0002%5D") +} + +func TestWorkflowHandlerEncodedSlashPreserved(t *testing.T) { + t.Parallel() + + var rawURI string + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawURI = r.URL.RequestURI() + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + handler := newWorkflowTestHandler(t, upstream.URL, WorkflowAuthModeToken, "tok") + + // An encoded slash inside a single id segment must NOT be decoded and + // re-split into extra upstream segments. + req := httptest.NewRequest(http.MethodGet, "/workflow/whiteboards/a%2Fb", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rawURI, "a%2Fb") +} + +func TestWorkflowHandlerRejectsTraversal(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("upstream should not be reached for a traversal path") + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + handler := newWorkflowTestHandler(t, upstream.URL, WorkflowAuthModeToken, "tok") + + cases := []struct { + name string + path string + }{ + {"literal dotdot", "/workflow/../../secrets"}, + {"single-encoded dotdot", "/workflow/%2e%2e/%2e%2e/secrets"}, + {"double-encoded dotdot", "/workflow/%252e%252e/secrets"}, + {"encoded-slash dotdot", "/workflow/..%2f..%2fopenapi.yaml"}, + {"matrix-param dotdot", "/workflow/..;/secrets"}, + {"triple dot", "/workflow/.../secrets"}, + {"backslash traversal", "/workflow/..%5c..%5csecrets"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code, "path %s", tc.path) + assert.Equal(t, "application/problem+json", rec.Header().Get("Content-Type")) + }) + } +} + +func TestWorkflowHandlerAPIV1PrefixNotMisStripped(t *testing.T) { + t.Parallel() + + var gotPath string + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.WriteHeader(http.StatusOK) + })) + defer upstream.Close() + + handler := newWorkflowTestHandler(t, upstream.URL, WorkflowAuthModeToken, "tok") + + // 'api/v1foo' is NOT the /api/v1 prefix on a segment boundary, so it must not + // be stripped to 'foo'; it maps under the workflow root verbatim. + req := httptest.NewRequest(http.MethodGet, "/workflow/api/v1foo", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "/api/v1/api/v1foo", gotPath) +} + +func TestWorkflowHandlerRelaysStatusAndBody(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(w, `{"type":"about:blank","title":"Conflict","status":409,"detail":"nope"}`) + })) + defer upstream.Close() + + handler := newWorkflowTestHandler(t, upstream.URL, WorkflowAuthModeToken, "tok") + + req := httptest.NewRequest(http.MethodPost, "/workflow/whiteboards/wb_1/sessions/ses_1/items", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusConflict, rec.Code) + assert.Equal(t, "application/problem+json", rec.Header().Get("Content-Type")) + assert.Contains(t, rec.Body.String(), `"detail":"nope"`) +} + +func TestWorkflowHandlerRelaysPlainText404(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, "404 page not found") + })) + defer upstream.Close() + + handler := newWorkflowTestHandler(t, upstream.URL, WorkflowAuthModeToken, "tok") + + req := httptest.NewRequest(http.MethodGet, "/workflow/does-not-exist", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.Contains(t, rec.Header().Get("Content-Type"), "text/plain") + assert.Equal(t, "404 page not found", rec.Body.String()) +} + +func TestWorkflowHandler204NoBody(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer upstream.Close() + + handler := newWorkflowTestHandler(t, upstream.URL, WorkflowAuthModeToken, "tok") + + req := httptest.NewRequest(http.MethodPost, "/workflow/workflows/wf_1/runs/run_1/cancel", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNoContent, rec.Code) + assert.Empty(t, rec.Body.String()) +} + +// failingTransport always errors, standing in for an unreachable upstream. A +// closed httptest server's port can be re-bound by a concurrent test's server, +// turning the expected dial error into a live 404 — so the failure is injected +// at the transport instead of relying on a freed port staying dead. +type failingTransport struct{} + +func (failingTransport) RoundTrip(*http.Request) (*http.Response, error) { + return nil, errors.New("dial tcp: connection refused") +} + +func TestWorkflowHandlerUpstreamDownReturns502(t *testing.T) { + t.Parallel() + + h, err := NewWorkflowHandler(logrus.New(), WorkflowConfig{ + URL: "http://workflow-engine.invalid", + AuthMode: WorkflowAuthModeToken, + APIToken: "tok", + }) + require.NoError(t, err) + + h.proxy.Transport = failingTransport{} + + r := chi.NewRouter() + r.Handle("/workflow/*", h) + + req := httptest.NewRequest(http.MethodGet, "/workflow/whiteboards", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadGateway, rec.Code) + assert.Equal(t, "application/problem+json", rec.Header().Get("Content-Type")) + assert.Contains(t, rec.Body.String(), "workflow engine is configured but unreachable") +} + +// flushWriter is an http.ResponseWriter that records flushes so we can assert +// SSE frames are streamed, not buffered to the end. +type flushWriter struct { + mu sync.Mutex + header http.Header + buf strings.Builder + flushes int + status int + flushed chan struct{} + once sync.Once +} + +func newFlushWriter() *flushWriter { + return &flushWriter{header: make(http.Header), flushed: make(chan struct{})} +} + +func (f *flushWriter) Header() http.Header { return f.header } + +func (f *flushWriter) WriteHeader(code int) { + f.mu.Lock() + defer f.mu.Unlock() + f.status = code +} + +func (f *flushWriter) Write(b []byte) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + + return f.buf.Write(b) +} + +func (f *flushWriter) Flush() { + f.mu.Lock() + n := f.buf.Len() + f.flushes++ + f.mu.Unlock() + + if n > 0 { + f.once.Do(func() { close(f.flushed) }) + } +} + +func (f *flushWriter) body() string { + f.mu.Lock() + defer f.mu.Unlock() + + return f.buf.String() +} + +func TestWorkflowHandlerSSEStreamsWithGzipRequest(t *testing.T) { + t.Parallel() + + release := make(chan struct{}) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // The client requested gzip; DisableCompression must prevent transparent + // gzip that would buffer the stream. + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + flusher, ok := w.(http.Flusher) + require.True(t, ok) + + _, _ = io.WriteString(w, "data: first\n\n") + flusher.Flush() + + <-release // hold the stream open until the test has seen the first frame + + _, _ = io.WriteString(w, "data: second\n\n") + flusher.Flush() + })) + defer upstream.Close() + + handler := newWorkflowTestHandler(t, upstream.URL, WorkflowAuthModeToken, "tok") + + req := httptest.NewRequest(http.MethodGet, "/workflow/workflows/wf_1/runs/run_1/state/stream", nil) + req.Header.Set("Accept-Encoding", "gzip") + + fw := newFlushWriter() + + done := make(chan struct{}) + go func() { + defer close(done) + handler.ServeHTTP(fw, req) + }() + + // The first frame must reach us BEFORE the upstream stream is released — + // proving it is flushed through, not buffered to EOF. + select { + case <-fw.flushed: + case <-time.After(3 * time.Second): + close(release) + t.Fatal("SSE first frame was not flushed before stream completion") + } + + assert.Contains(t, fw.body(), "data: first") + + close(release) + <-done + + assert.Equal(t, "text/event-stream", fw.Header().Get("Content-Type")) + assert.Equal(t, "no", fw.Header().Get("X-Accel-Buffering")) + assert.Contains(t, fw.body(), "data: second") +} diff --git a/pkg/proxy/metrics.go b/pkg/proxy/metrics.go index 92a56bdc..dcc883a9 100644 --- a/pkg/proxy/metrics.go +++ b/pkg/proxy/metrics.go @@ -9,6 +9,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/ethpandaops/panda/pkg/proxy/handlers" + "github.com/ethpandaops/panda/pkg/workflowrelay" ) const ( @@ -176,9 +177,17 @@ func (s *server) metricsMiddleware(next http.Handler) http.Handler { ProxyRequestsTotal.WithLabelValues( dsType, ds, method, statusCode, ).Inc() - ProxyRequestDurationSeconds.WithLabelValues( - dsType, ds, method, - ).Observe(duration) + + // Skip the duration histogram for workflow SSE responses: the middleware + // blocks for the whole stream lifetime, so duration is the stream + // lifetime (minutes+), not request latency, and would swamp the +Inf + // bucket. The counter above still covers every response. + if dsType != "workflow" || !workflowrelay.IsEventStream(mrw.Header().Get("Content-Type")) { + ProxyRequestDurationSeconds.WithLabelValues( + dsType, ds, method, + ).Observe(duration) + } + ProxyResponseSizeBytes.WithLabelValues( dsType, ).Observe(float64(mrw.bytesWritten)) @@ -251,6 +260,13 @@ func (w *responseCapture) Flush() { } } +// Unwrap exposes the underlying writer to http.ResponseController so a handler +// (e.g. the workflow passthrough) can clear the write deadline for long-lived +// SSE streams through this wrapper. +func (w *responseCapture) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + // extractDatasourceType extracts the datasource type from the request path. func extractDatasourceType(path string) string { trimmed := strings.TrimPrefix(path, "/") @@ -272,6 +288,8 @@ func extractDatasourceType(path string) string { return "benchmarkoor" case "compute": return "compute" + case "workflow": + return "workflow" case "github": return "github" case "uploads": diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 7a4338f3..63ab0221 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -78,6 +78,16 @@ type Service interface { EmbeddingModel() string } +// WorkflowInfoProvider reports whether the proxy advertises a workflow engine +// and the web URL for building human links to it. It is a capability interface, +// deliberately separate from Service: the server resolves it with a type +// assertion, and implementations that lack it simply have no workflow engine. +type WorkflowInfoProvider interface { + // WorkflowInfo returns whether a workflow engine is advertised and, when it + // is, the web URL for human links (never a credential). + WorkflowInfo() (enabled bool, webURL string) +} + // ethNodeDatasourceInfo returns the ethnode datasource identity when available, // or nil. Ethnode is a single type-level datasource, not a discoverable list. func ethNodeDatasourceInfo(available bool) []types.DatasourceInfo { diff --git a/pkg/proxy/router.go b/pkg/proxy/router.go index 5361b564..c5314791 100644 --- a/pkg/proxy/router.go +++ b/pkg/proxy/router.go @@ -27,6 +27,13 @@ type Router interface { // ClientForDatasource returns the proxy client that owns a datasource by type/name. ClientForDatasource(datasourceType, datasourceName string) (Client, bool) + + // WorkflowRoute returns the proxy client for the first route, in priority + // order, that advertises the workflow engine. The server relays /workflow + // traffic to it. It shares selection logic with WorkflowInfo so human links + // and forwarded traffic never disagree. It is NOT necessarily Primary(): a + // secondary route may carry the engine when the primary does not. + WorkflowRoute() (Client, bool) } // DatasourceOwner identifies the proxy that owns a datasource. @@ -327,6 +334,46 @@ func (r *routerClient) EmbeddingModel() string { return primary.EmbeddingModel() } +// WorkflowInfo reports whether any wrapped route advertises a workflow engine +// and, when one does, its web URL. It selects the first route in priority order +// that advertises the engine (not necessarily Primary()), sharing selection +// logic with WorkflowRoute so links and traffic never disagree. +func (r *routerClient) WorkflowInfo() (enabled bool, webURL string) { + if _, webURL, ok := r.selectWorkflowRoute(); ok { + return true, webURL + } + + return false, "" +} + +// WorkflowRoute returns the proxy client for the first route, in priority order, +// that advertises the workflow engine. +func (r *routerClient) WorkflowRoute() (Client, bool) { + if route, _, ok := r.selectWorkflowRoute(); ok { + return route, true + } + + return nil, false +} + +// selectWorkflowRoute returns the first wrapped client (in configured order) +// that both implements WorkflowInfoProvider and advertises an enabled workflow +// engine, along with the resolved web URL. +func (r *routerClient) selectWorkflowRoute() (Client, string, bool) { + for i := range r.routes { + provider, ok := r.routes[i].client.(WorkflowInfoProvider) + if !ok { + continue + } + + if enabled, webURL := provider.WorkflowInfo(); enabled { + return r.routes[i].client, webURL, true + } + } + + return nil, "", false +} + // OwnerForDatasource returns the proxy that owns a datasource by type/name. func (r *routerClient) OwnerForDatasource(datasourceType, datasourceName string) (DatasourceOwner, bool) { _, owners := r.mergeDatasourceInfo(datasourceType) @@ -436,7 +483,8 @@ func (r *routerClient) warnCollision(datasourceType, datasourceName, winnerProxy } var ( - _ Client = (*routerClient)(nil) - _ Router = (*routerClient)(nil) - _ Service = (*routerClient)(nil) + _ Client = (*routerClient)(nil) + _ Router = (*routerClient)(nil) + _ Service = (*routerClient)(nil) + _ WorkflowInfoProvider = (*routerClient)(nil) ) diff --git a/pkg/proxy/router_test.go b/pkg/proxy/router_test.go index 29858eb1..4645666a 100644 --- a/pkg/proxy/router_test.go +++ b/pkg/proxy/router_test.go @@ -319,6 +319,9 @@ type fakeRouterClient struct { model string ready bool + workflowEnabled bool + workflowWebURL string + starts int stops int discovers int @@ -392,6 +395,10 @@ func (f *fakeRouterClient) EthNodeDatasourceInfo() []types.DatasourceInfo { func (f *fakeRouterClient) EmbeddingAvailable() bool { return f.embedding } func (f *fakeRouterClient) EmbeddingModel() string { return "" } +func (f *fakeRouterClient) WorkflowInfo() (bool, string) { + return f.workflowEnabled, f.workflowWebURL +} + func (f *fakeRouterClient) Discover(_ context.Context) error { f.discovers++ @@ -400,7 +407,10 @@ func (f *fakeRouterClient) Discover(_ context.Context) error { func (f *fakeRouterClient) EnsureAuthenticated(_ context.Context) error { return nil } -var _ Client = (*fakeRouterClient)(nil) +var ( + _ Client = (*fakeRouterClient)(nil) + _ WorkflowInfoProvider = (*fakeRouterClient)(nil) +) type failingRoundTripper struct{} diff --git a/pkg/proxy/server.go b/pkg/proxy/server.go index 203155ff..3f21a2e1 100644 --- a/pkg/proxy/server.go +++ b/pkg/proxy/server.go @@ -16,6 +16,7 @@ import ( "github.com/sirupsen/logrus" "github.com/ethpandaops/panda/pkg/auth" + authclient "github.com/ethpandaops/panda/pkg/auth/client" "github.com/ethpandaops/panda/pkg/proxy/handlers" "github.com/ethpandaops/panda/pkg/types" ) @@ -78,6 +79,7 @@ type server struct { ethNodeHandler *handlers.EthNodeHandler benchmarkoorHandler *handlers.BenchmarkoorHandler computeHandler *handlers.ComputeHandler + workflowHandler *handlers.WorkflowHandler uploadsHandler *handlers.UploadsHandler uploadsLimiter *RateLimiter embeddingService *EmbeddingService @@ -99,8 +101,9 @@ type server struct { // Compile-time interface check. var ( - _ Server = (*server)(nil) - _ Service = (*server)(nil) + _ Server = (*server)(nil) + _ Service = (*server)(nil) + _ WorkflowInfoProvider = (*server)(nil) ) // NewServer creates a new proxy server. @@ -208,6 +211,19 @@ func newServer(log logrus.FieldLogger, cfg ServerConfig, hostURL, port string) ( s.computeHandler = handlers.NewComputeHandler(log, computeConfigs) } + if cfg.Workflow != nil { + workflowHandler, err := handlers.NewWorkflowHandler(log, handlers.WorkflowConfig{ + URL: cfg.Workflow.URL, + AuthMode: cfg.Workflow.ResolvedAuthMode(), + APIToken: cfg.Workflow.APIToken, + }) + if err != nil { + return nil, fmt.Errorf("creating workflow handler: %w", err) + } + + s.workflowHandler = workflowHandler + } + if uploadsCfg := cfg.ToUploadsHandlerConfig(); uploadsCfg != nil { uploadsHandler, err := handlers.NewUploadsHandler(log, *uploadsCfg) if err != nil { @@ -362,6 +378,10 @@ func (s *server) registerRoutes() { s.handleSubtreeRoute("/compute", s.metricsMiddleware(chain(s.computeHandler))) } + if s.workflowHandler != nil { + s.handleSubtreeRoute("/workflow", s.metricsMiddleware(chain(s.workflowHandler))) + } + if s.githubHandler != nil { s.handleSubtreeRoute("/github", s.metricsMiddleware(chain(s.githubHandler))) } @@ -431,6 +451,17 @@ type DatasourcesResponse struct { EthNodeAvailable bool `json:"ethnode_available,omitempty"` EmbeddingAvailable bool `json:"embedding_available,omitempty"` EmbeddingModel string `json:"embedding_model,omitempty"` + // Workflow advertises the workflow engine as a capability. Nil when the + // proxy does not expose one (or the caller is not authorized for it). + Workflow *WorkflowAdvert `json:"workflow,omitempty"` +} + +// WorkflowAdvert advertises the workflow engine capability in discovery. It +// carries only whether the engine is enabled and the web URL for building human +// links — never the api_token. +type WorkflowAdvert struct { + Enabled bool `json:"enabled"` + WebURL string `json:"web_url,omitempty"` } // datasourcesResponseWire is the on-the-wire shape of DatasourcesResponse, @@ -447,6 +478,7 @@ type datasourcesResponseWire struct { EthNodeAvailable bool `json:"ethnode_available,omitempty"` EmbeddingAvailable bool `json:"embedding_available,omitempty"` EmbeddingModel string `json:"embedding_model,omitempty"` + Workflow *WorkflowAdvert `json:"workflow,omitempty"` } // MarshalJSON emits both the detailed *Info lists and the derived name-only @@ -464,6 +496,7 @@ func (d DatasourcesResponse) MarshalJSON() ([]byte, error) { EthNodeAvailable: d.EthNodeAvailable, EmbeddingAvailable: d.EmbeddingAvailable, EmbeddingModel: d.EmbeddingModel, + Workflow: d.Workflow, }) } @@ -483,6 +516,7 @@ func (d *DatasourcesResponse) UnmarshalJSON(data []byte) error { d.EthNodeAvailable = wire.EthNodeAvailable d.EmbeddingAvailable = wire.EmbeddingAvailable d.EmbeddingModel = wire.EmbeddingModel + d.Workflow = wire.Workflow return nil } @@ -525,6 +559,7 @@ func (s *server) handleDatasources(w http.ResponseWriter, r *http.Request) { EthNodeAvailable: s.EthNodeAvailable(), EmbeddingAvailable: s.EmbeddingAvailable(), EmbeddingModel: s.EmbeddingModel(), + Workflow: s.workflowAdvert(), } if s.authorizer != nil { @@ -778,6 +813,11 @@ type AuthMetadataResponse struct { Mode string `json:"mode"` IssuerURL string `json:"issuer_url,omitempty"` ClientID string `json:"client_id,omitempty"` + // Scopes is the complete scope set clients should request at login to use + // this proxy fully. Empty means "use the client defaults". It carries the + // extra scopes required by advertised features (e.g. "workflows" when a + // workflow engine is configured) so `panda init` can provision them. + Scopes []string `json:"scopes,omitempty"` } // handleAuthMetadata returns the proxy's auth config so clients can discover @@ -798,6 +838,19 @@ func (s *server) handleAuthMetadata(w http.ResponseWriter, _ *http.Request) { resp.IssuerURL = s.cfg.Auth.Issuers[0].IssuerURL resp.ClientID = s.cfg.Auth.Issuers[0].ClientID } + + // Advertise the extra scopes advertised features require so `panda init` + // provisions them. The "workflows" scope matters only when the caller's + // own token reaches the engine — oidc mode with workflow passthrough + // auth, where Authentik cross-grants the engine audience to eligible + // users (safe to request for everyone). In token mode the proxy injects + // its api_token, and in oauth (GitHub) mode these OIDC scope names would + // be meaningless, so both keep the client defaults. + if s.cfg.Auth.Mode == AuthModeOIDC && + s.cfg.Workflow != nil && strings.TrimSpace(s.cfg.Workflow.URL) != "" && + s.cfg.Workflow.ResolvedAuthMode() == WorkflowAuthModePassthrough { + resp.Scopes = append(append([]string(nil), authclient.DefaultScopes...), "workflows") + } } w.Header().Set("Content-Type", "application/json") @@ -1182,6 +1235,30 @@ func (s *server) EthNodeAvailable() bool { return s.ethNodeHandler != nil } +// workflowAdvert returns the workflow discovery advert, or nil when the proxy +// is not configured with a workflow engine. The web URL is derived from config +// (web_url else url); the api_token is never advertised. +func (s *server) workflowAdvert() *WorkflowAdvert { + if s.cfg.Workflow == nil { + return nil + } + + return &WorkflowAdvert{ + Enabled: true, + WebURL: s.cfg.Workflow.ResolvedWebURL(), + } +} + +// WorkflowInfo reports whether this proxy advertises a workflow engine and the +// web URL for building human links to it, resolved from config. +func (s *server) WorkflowInfo() (enabled bool, webURL string) { + if s.cfg.Workflow == nil { + return false, "" + } + + return true, s.cfg.Workflow.ResolvedWebURL() +} + // EthNodeDatasourceInfo returns the ethnode datasource info when configured. func (s *server) EthNodeDatasourceInfo() []types.DatasourceInfo { return ethNodeDatasourceInfo(s.EthNodeAvailable()) diff --git a/pkg/proxy/server_config.go b/pkg/proxy/server_config.go index 13a6bfc7..6e082eed 100644 --- a/pkg/proxy/server_config.go +++ b/pkg/proxy/server_config.go @@ -1,9 +1,12 @@ package proxy import ( + "errors" "fmt" + "net/url" "os" "regexp" + "strconv" "strings" "time" @@ -61,6 +64,10 @@ type ServerConfig struct { // GitHub holds optional GitHub API configuration for triggering workflows. GitHub *GitHubAPIConfig `yaml:"github,omitempty"` + // Workflow holds optional workflow-engine passthrough configuration. Nil + // disables the /workflow route entirely. + Workflow *WorkflowConfig `yaml:"workflow,omitempty"` + // Uploads holds optional R2 (S3-compatible) object-store config backing the // /uploads route (`panda upload`). Uploads *UploadsConfig `yaml:"uploads,omitempty"` @@ -124,6 +131,58 @@ type GitHubAPIConfig struct { Token string `yaml:"token"` } +// Workflow auth modes select how the proxy credentials the upstream workflow +// engine on each request. +const ( + // WorkflowAuthModeToken injects the proxy's configured api_token as the + // bearer — one generic all-users key. This is the default and the + // local-testing shape. + WorkflowAuthModeToken = "token" + // WorkflowAuthModePassthrough re-attaches the caller's own verified bearer + // so the engine validates the user's OIDC token directly. It requires the + // proxy to run in an authenticated mode (oauth/oidc). + WorkflowAuthModePassthrough = "passthrough" +) + +// WorkflowConfig holds the workflow-engine passthrough configuration. The API +// token lives HERE, at the proxy layer, never on the panda server. +type WorkflowConfig struct { + // URL is the workflow engine API origin. Bare scheme+host[:port]; no + // path/query/fragment/userinfo. + URL string `yaml:"url"` + // AuthMode is "token" (default; inject APIToken) or "passthrough" (forward + // the caller's own bearer for the engine to validate directly). + AuthMode string `yaml:"auth_mode,omitempty"` + // APIToken is required when AuthMode is "token". ${ENV}-substitutable. + APIToken string `yaml:"api_token,omitempty"` + // WebURL is the frontend-link origin (may carry a path). Defaults to URL. + WebURL string `yaml:"web_url,omitempty"` + // AllowedOrgs restricts /workflow access to members of these orgs/groups: + // OIDC groups (e.g. "ethpandaops:Core") in oidc mode, or GitHub orgs in oauth + // mode. Empty leaves the engine open to all authenticated callers. + AllowedOrgs []string `yaml:"allowed_orgs,omitempty"` +} + +// ResolvedAuthMode returns the effective auth mode, defaulting to "token". +func (w *WorkflowConfig) ResolvedAuthMode() string { + if strings.TrimSpace(w.AuthMode) == "" { + return WorkflowAuthModeToken + } + + return strings.TrimSpace(w.AuthMode) +} + +// ResolvedWebURL returns the frontend-link origin: web_url when set, else url, +// with a single trailing slash trimmed. It never returns the api_token. +func (w *WorkflowConfig) ResolvedWebURL() string { + base := strings.TrimSpace(w.WebURL) + if base == "" { + base = strings.TrimSpace(w.URL) + } + + return strings.TrimRight(base, "/") +} + // HTTPServerConfig holds HTTP server configuration. type HTTPServerConfig struct { // ListenAddr is the address to listen on (default: ":18081"). @@ -594,9 +653,10 @@ func (c *ServerConfig) Validate() error { } } - // Validate at least one datasource is configured. - if len(c.ClickHouse) == 0 && len(c.Prometheus) == 0 && len(c.Loki) == 0 && c.EthNode == nil && len(c.Benchmarkoor) == 0 && len(c.Compute) == 0 { - return fmt.Errorf("at least one datasource (clickhouse, prometheus, loki, ethnode, benchmarkoor, or compute) must be configured") + // Validate the proxy serves something: at least one datasource, or the + // workflow engine (a proxy dedicated to engine passthrough is valid). + if len(c.ClickHouse) == 0 && len(c.Prometheus) == 0 && len(c.Loki) == 0 && c.EthNode == nil && len(c.Benchmarkoor) == 0 && len(c.Compute) == 0 && c.Workflow == nil { + return fmt.Errorf("at least one datasource (clickhouse, prometheus, loki, ethnode, benchmarkoor, or compute) or the workflow engine must be configured") } // Validate ClickHouse configs. @@ -727,6 +787,118 @@ func (c *ServerConfig) Validate() error { } } + if err := c.validateWorkflow(); err != nil { + return err + } + + return nil +} + +// validateWorkflow validates the optional workflow block. It asserts only when +// workflow is enabled (non-nil) so existing no-workflow configs keep validating. +func (c *ServerConfig) validateWorkflow() error { + if c.Workflow == nil { + return nil + } + + parsed, err := url.Parse(strings.TrimSpace(c.Workflow.URL)) + if err != nil { + return fmt.Errorf("workflow.url is invalid: %w", err) + } + + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("workflow.url must use an http or https scheme, got %q", parsed.Scheme) + } + + if parsed.Host == "" { + return errors.New("workflow.url must include a host") + } + + // The passthrough injects its own bearer, so embedded credentials would be a + // silent trap — reject them rather than ignore them. + if parsed.User != nil { + return errors.New("workflow.url must not include userinfo") + } + + // url.Port() returns only an all-digit port (or ""), so validate the range. + if portStr := parsed.Port(); portStr != "" { + port, portErr := strconv.Atoi(portStr) + if portErr != nil || port < 1 || port > 65535 { + return fmt.Errorf("workflow.url has an invalid port %q, must be 1..65535", portStr) + } + } + + // A bare host or a single trailing slash is path-less; anything else would + // corrupt the /api/v1/ join the passthrough builds. + if parsed.Path != "" && parsed.Path != "/" { + return fmt.Errorf("workflow.url must not include a path, got %q", parsed.Path) + } + + if parsed.RawQuery != "" { + return fmt.Errorf("workflow.url must not include a query, got %q", parsed.RawQuery) + } + + if parsed.Fragment != "" { + return fmt.Errorf("workflow.url must not include a fragment, got %q", parsed.Fragment) + } + + if err := validateWorkflowWebURL(c.Workflow.WebURL); err != nil { + return err + } + + switch c.Workflow.ResolvedAuthMode() { + case WorkflowAuthModeToken: + if strings.TrimSpace(c.Workflow.APIToken) == "" { + return errors.New("workflow.api_token is required when workflow.auth_mode is token") + } + case WorkflowAuthModePassthrough: + if strings.TrimSpace(c.Workflow.APIToken) != "" { + return errors.New("workflow.api_token must be empty when workflow.auth_mode is passthrough") + } + + if c.Auth.Mode != AuthModeOAuth && c.Auth.Mode != AuthModeOIDC { + return fmt.Errorf( + "workflow.auth_mode passthrough requires proxy auth.mode oauth or oidc, got %q", + c.Auth.Mode, + ) + } + default: + return fmt.Errorf( + "workflow.auth_mode must be one of {%s, %s}, got %q", + WorkflowAuthModeToken, WorkflowAuthModePassthrough, c.Workflow.AuthMode, + ) + } + + return nil +} + +// validateWorkflowWebURL validates the optional frontend-link origin. Unlike +// url it may carry a path (a UI mounted under a subpath), but it is a link +// prefix for humans, so scheme+host are still required and query/fragment still +// make no sense. +func validateWorkflowWebURL(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + parsed, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("workflow.web_url is invalid: %w", err) + } + + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("workflow.web_url must use an http or https scheme, got %q", parsed.Scheme) + } + + if parsed.Host == "" { + return errors.New("workflow.web_url must include a host") + } + + if parsed.RawQuery != "" || parsed.Fragment != "" { + return errors.New("workflow.web_url must not include a query or fragment") + } + return nil } diff --git a/pkg/proxy/server_config_workflow_test.go b/pkg/proxy/server_config_workflow_test.go new file mode 100644 index 00000000..0fa9f0cc --- /dev/null +++ b/pkg/proxy/server_config_workflow_test.go @@ -0,0 +1,220 @@ +package proxy + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/panda/pkg/auth" +) + +// clickHouseOnly returns a minimal valid datasource so workflow validation is +// exercised in isolation from the "at least one datasource" requirement. +func clickHouseOnly() []ClickHouseClusterConfig { + return []ClickHouseClusterConfig{ + {BaseDatasourceConfig: BaseDatasourceConfig{Name: "ch"}, Host: "example.com", Port: 8123, Username: "u", Password: "p"}, + } +} + +func TestWorkflowConfigValidation(t *testing.T) { + t.Parallel() + + oidcAuth := AuthConfig{ + Mode: AuthModeOIDC, + Issuers: []OIDCIssuerConfig{{IssuerURL: "https://idp.example.com", ClientID: "panda"}}, + } + oauthAuth := AuthConfig{ + Mode: AuthModeOAuth, + IssuerURL: "https://proxy.example.com", + GitHub: &auth.GitHubConfig{ClientID: "cid", ClientSecret: "sec"}, + Tokens: auth.TokensConfig{SecretKey: "key"}, + } + + tests := []struct { + name string + auth AuthConfig + workflow *WorkflowConfig + wantErr string + }{ + { + name: "nil workflow is valid", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: nil, + }, + { + name: "token mode https host valid", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "https://workflow.example.io", APIToken: "tok"}, + }, + { + name: "token mode trailing slash valid", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "https://workflow.example.io/", APIToken: "tok"}, + }, + { + name: "token mode http host with port valid", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "http://localhost:8080", APIToken: "tok"}, + }, + { + name: "ipv6 host with port valid", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "http://[::1]:8080", APIToken: "tok"}, + }, + { + name: "auth_mode defaults to token and requires api_token", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "https://workflow.example.io"}, + wantErr: "workflow.api_token is required when workflow.auth_mode is token", + }, + { + name: "token without api_token rejected", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "https://workflow.example.io", AuthMode: "token"}, + wantErr: "workflow.api_token is required when workflow.auth_mode is token", + }, + { + name: "passthrough with oidc valid", + auth: oidcAuth, + workflow: &WorkflowConfig{URL: "https://workflow.example.io", AuthMode: "passthrough"}, + }, + { + name: "passthrough with oauth valid", + auth: oauthAuth, + workflow: &WorkflowConfig{URL: "https://workflow.example.io", AuthMode: "passthrough"}, + }, + { + name: "passthrough with api_token rejected", + auth: oidcAuth, + workflow: &WorkflowConfig{URL: "https://workflow.example.io", AuthMode: "passthrough", APIToken: "tok"}, + wantErr: "workflow.api_token must be empty when workflow.auth_mode is passthrough", + }, + { + name: "passthrough with auth none rejected", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "https://workflow.example.io", AuthMode: "passthrough"}, + wantErr: "workflow.auth_mode passthrough requires proxy auth.mode oauth or oidc", + }, + { + name: "unknown auth_mode rejected", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "https://workflow.example.io", AuthMode: "bogus", APIToken: "tok"}, + wantErr: "workflow.auth_mode must be one of", + }, + { + name: "path rejected", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "https://workflow.example.io/workflow", APIToken: "tok"}, + wantErr: "workflow.url must not include a path", + }, + { + name: "query rejected", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "https://workflow.example.io?x=1", APIToken: "tok"}, + wantErr: "workflow.url must not include a query", + }, + { + name: "fragment rejected", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "https://workflow.example.io#frag", APIToken: "tok"}, + wantErr: "workflow.url must not include a fragment", + }, + { + name: "non-http scheme rejected", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "ftp://workflow.example.io", APIToken: "tok"}, + wantErr: "workflow.url must use an http or https scheme", + }, + { + name: "userinfo rejected", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "http://user:pass@workflow.example.io", APIToken: "tok"}, + wantErr: "workflow.url must not include userinfo", + }, + { + name: "out-of-range port rejected", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "http://workflow.example.io:70000", APIToken: "tok"}, + wantErr: "workflow.url has an invalid port", + }, + { + name: "missing host rejected", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "https://", APIToken: "tok"}, + wantErr: "workflow.url must include a host", + }, + { + name: "web_url with path valid", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "https://workflow.example.io", APIToken: "tok", WebURL: "https://ui.example.io/app"}, + }, + { + name: "web_url with query rejected", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "https://workflow.example.io", APIToken: "tok", WebURL: "https://ui.example.io/app?x=1"}, + wantErr: "workflow.web_url must not include a query or fragment", + }, + { + name: "web_url non-http scheme rejected", + auth: AuthConfig{Mode: AuthModeNone}, + workflow: &WorkflowConfig{URL: "https://workflow.example.io", APIToken: "tok", WebURL: "ftp://ui.example.io"}, + wantErr: "workflow.web_url must use an http or https scheme", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := ServerConfig{ + Auth: tt.auth, + ClickHouse: clickHouseOnly(), + Workflow: tt.workflow, + } + cfg.ApplyDefaults() + + err := cfg.Validate() + if tt.wantErr == "" { + require.NoError(t, err) + + return + } + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +// A proxy dedicated to workflow-engine passthrough (the local test shape) is +// valid without any datasource; with neither it still serves nothing. +func TestWorkflowOnlyProxySatisfiesDatasourceRequirement(t *testing.T) { + t.Parallel() + + cfg := ServerConfig{ + Auth: AuthConfig{Mode: AuthModeNone}, + Workflow: &WorkflowConfig{URL: "https://workflow.example.io", APIToken: "tok"}, + } + cfg.ApplyDefaults() + require.NoError(t, cfg.Validate()) + + empty := ServerConfig{Auth: AuthConfig{Mode: AuthModeNone}} + empty.ApplyDefaults() + + err := empty.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "or the workflow engine must be configured") +} + +func TestWorkflowConfigResolvedHelpers(t *testing.T) { + t.Parallel() + + assert.Equal(t, WorkflowAuthModeToken, (&WorkflowConfig{}).ResolvedAuthMode()) + assert.Equal(t, WorkflowAuthModePassthrough, (&WorkflowConfig{AuthMode: "passthrough"}).ResolvedAuthMode()) + + // web_url wins over url; trailing slash trimmed. + assert.Equal(t, "https://ui.example.io", (&WorkflowConfig{URL: "https://api.example.io", WebURL: "https://ui.example.io/"}).ResolvedWebURL()) + // falls back to url when web_url is empty. + assert.Equal(t, "https://api.example.io", (&WorkflowConfig{URL: "https://api.example.io/"}).ResolvedWebURL()) +} diff --git a/pkg/proxy/server_test.go b/pkg/proxy/server_test.go index d5071355..f04a92ea 100644 --- a/pkg/proxy/server_test.go +++ b/pkg/proxy/server_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "reflect" "strconv" "strings" "sync" @@ -18,6 +19,7 @@ import ( "github.com/sirupsen/logrus" "github.com/ethpandaops/panda/pkg/auth" + authclient "github.com/ethpandaops/panda/pkg/auth/client" "github.com/ethpandaops/panda/pkg/proxy/handlers" ) @@ -879,6 +881,93 @@ func TestAuthMetadataEndpointReturnsConfig(t *testing.T) { if got.ClientID != "panda-proxy" { t.Fatalf("expected client_id=panda-proxy, got %q", got.ClientID) } + + if len(got.Scopes) != 0 { + t.Fatalf("expected no advertised scopes without a workflow engine, got %v", got.Scopes) + } +} + +func TestAuthMetadataEndpointAdvertisesWorkflowScope(t *testing.T) { + t.Parallel() + + cfg := ServerConfig{ + Auth: AuthConfig{ + Mode: AuthModeOIDC, + Issuers: []OIDCIssuerConfig{ + {IssuerURL: "https://authentik.example.com/application/o/panda-proxy/", ClientID: "panda-proxy"}, + }, + }, + Workflow: &WorkflowConfig{ + URL: "https://workflows.example.com", + AuthMode: WorkflowAuthModePassthrough, + }, + ClickHouse: []ClickHouseClusterConfig{ + {BaseDatasourceConfig: BaseDatasourceConfig{Name: "clickhouse-raw"}, Host: "example.com", Port: 8123, Username: "user", Password: "pass"}, + }, + } + cfg.ApplyDefaults() + + srv, err := newServer(logrus.New(), cfg, "http://proxy.test", "18081") + if err != nil { + t.Fatalf("newServer failed: %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/auth/metadata", nil) + srv.mux.ServeHTTP(rec, req) + + var got AuthMetadataResponse + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decoding response: %v", err) + } + + // The complete request set: client defaults plus the workflow feature scope. + want := append(append([]string(nil), authclient.DefaultScopes...), "workflows") + if !reflect.DeepEqual(got.Scopes, want) { + t.Fatalf("expected advertised scopes %v, got %v", want, got.Scopes) + } +} + +func TestAuthMetadataEndpointNoWorkflowScopeInTokenMode(t *testing.T) { + t.Parallel() + + cfg := ServerConfig{ + Auth: AuthConfig{ + Mode: AuthModeOIDC, + Issuers: []OIDCIssuerConfig{ + {IssuerURL: "https://authentik.example.com/application/o/panda-proxy/", ClientID: "panda-proxy"}, + }, + }, + // In token mode the proxy injects its api_token; the caller's token never + // reaches the engine, so no extra scope is advertised. + Workflow: &WorkflowConfig{ + URL: "https://workflows.example.com", + AuthMode: WorkflowAuthModeToken, + APIToken: "secret", + }, + ClickHouse: []ClickHouseClusterConfig{ + {BaseDatasourceConfig: BaseDatasourceConfig{Name: "clickhouse-raw"}, Host: "example.com", Port: 8123, Username: "user", Password: "pass"}, + }, + } + cfg.ApplyDefaults() + + srv, err := newServer(logrus.New(), cfg, "http://proxy.test", "18081") + if err != nil { + t.Fatalf("newServer failed: %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/auth/metadata", nil) + srv.mux.ServeHTTP(rec, req) + + var got AuthMetadataResponse + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decoding response: %v", err) + } + + if len(got.Scopes) != 0 { + t.Fatalf("expected no advertised scopes for token-mode workflow, got %v", got.Scopes) + } } func TestAuthMetadataEndpointNoAuth(t *testing.T) { diff --git a/pkg/proxy/workflow_test.go b/pkg/proxy/workflow_test.go new file mode 100644 index 00000000..ecd2055e --- /dev/null +++ b/pkg/proxy/workflow_test.go @@ -0,0 +1,289 @@ +package proxy + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + dto "github.com/prometheus/client_model/go" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/panda/pkg/types" +) + +func TestWorkflowAdvertWireRoundTrip(t *testing.T) { + t.Parallel() + + orig := DatasourcesResponse{ + ClickHouseInfo: []types.DatasourceInfo{{Type: "clickhouse", Name: "ch"}}, + Workflow: &WorkflowAdvert{Enabled: true, WebURL: "https://ui.example.io"}, + } + + data, err := json.Marshal(orig) + require.NoError(t, err) + + // The token is never in the wire (there is none here, but the advert shape + // itself must never carry a credential field). + assert.NotContains(t, string(data), "api_token") + assert.Contains(t, string(data), `"workflow"`) + assert.Contains(t, string(data), `"web_url":"https://ui.example.io"`) + + var got DatasourcesResponse + require.NoError(t, json.Unmarshal(data, &got)) + + require.NotNil(t, got.Workflow) + assert.True(t, got.Workflow.Enabled) + assert.Equal(t, "https://ui.example.io", got.Workflow.WebURL) + // The legacy name-only clickhouse list must still round-trip alongside it. + require.Len(t, got.ClickHouseInfo, 1) + assert.Equal(t, "ch", got.ClickHouseInfo[0].Name) +} + +func TestWorkflowAdvertAbsentWhenNil(t *testing.T) { + t.Parallel() + + data, err := json.Marshal(DatasourcesResponse{}) + require.NoError(t, err) + assert.NotContains(t, string(data), "workflow") + + // A legacy name-only list (older peer) unmarshals with no workflow advert. + var got DatasourcesResponse + require.NoError(t, json.Unmarshal([]byte(`{"clickhouse":["ch"]}`), &got)) + assert.Nil(t, got.Workflow) + require.Len(t, got.ClickHouseInfo, 1) + assert.Equal(t, "ch", got.ClickHouseInfo[0].Name) +} + +func TestWorkflowAdvertOrgFilteringHidesAdvert(t *testing.T) { + t.Parallel() + + cfg := ServerConfig{ + Auth: AuthConfig{Mode: AuthModeOIDC}, + ClickHouse: clickHouseOnly(), + Workflow: &WorkflowConfig{URL: "https://workflow.example.io", AllowedOrgs: []string{"ethpandaops"}}, + } + cfg.ApplyDefaults() + + authz := NewAuthorizer(logrus.New(), cfg) + + resp := DatasourcesResponse{Workflow: &WorkflowAdvert{Enabled: true, WebURL: "https://ui.example.io"}} + + // Member of the allowed org keeps the advert. + memberCtx := withAuthUser(context.Background(), &AuthUser{Groups: []string{"ethpandaops"}}) + member := authz.FilterDatasources(memberCtx, resp) + require.NotNil(t, member.Workflow) + assert.Equal(t, "https://ui.example.io", member.Workflow.WebURL) + + // Non-member loses the advert entirely. + otherCtx := withAuthUser(context.Background(), &AuthUser{Groups: []string{"sigp"}}) + other := authz.FilterDatasources(otherCtx, resp) + assert.Nil(t, other.Workflow) +} + +func TestWorkflowInfoServerFromConfig(t *testing.T) { + t.Parallel() + + cfg := ServerConfig{ + ClickHouse: clickHouseOnly(), + Workflow: &WorkflowConfig{URL: "https://api.example.io/", APIToken: "tok"}, + } + + s, err := newServer(logrus.New(), cfg, "http://localhost:0", "0") + require.NoError(t, err) + + enabled, webURL := s.WorkflowInfo() + assert.True(t, enabled) + assert.Equal(t, "https://api.example.io", webURL) + + // No workflow configured → disabled. + cfg2 := ServerConfig{ClickHouse: clickHouseOnly()} + s2, err := newServer(logrus.New(), cfg2, "http://localhost:0", "0") + require.NoError(t, err) + enabled2, webURL2 := s2.WorkflowInfo() + assert.False(t, enabled2) + assert.Empty(t, webURL2) +} + +func TestWorkflowInfoProxyClientFromCache(t *testing.T) { + t.Parallel() + + c := &proxyClient{ + datasources: &DatasourcesResponse{ + Workflow: &WorkflowAdvert{Enabled: true, WebURL: "https://ui.example.io"}, + }, + } + + enabled, webURL := c.WorkflowInfo() + assert.True(t, enabled) + assert.Equal(t, "https://ui.example.io", webURL) + + empty := &proxyClient{datasources: &DatasourcesResponse{}} + enabled2, webURL2 := empty.WorkflowInfo() + assert.False(t, enabled2) + assert.Empty(t, webURL2) +} + +func TestWorkflowInfoRouterPrimaryLacksSecondaryHas(t *testing.T) { + t.Parallel() + + // Primary does not advertise; a secondary route does. Selection must pick + // the secondary, NOT Primary(). + primary := &fakeRouterClient{url: "https://primary.example"} + secondary := &fakeRouterClient{url: "https://secondary.example", workflowEnabled: true, workflowWebURL: "https://ui.example.io"} + + router := NewRouter(logrus.New(), []ClientRoute{ + {Name: "primary", Client: primary}, + {Name: "secondary", Client: secondary}, + }) + + enabled, webURL := router.(WorkflowInfoProvider).WorkflowInfo() + assert.True(t, enabled) + assert.Equal(t, "https://ui.example.io", webURL) + + route, ok := router.WorkflowRoute() + require.True(t, ok) + assert.Equal(t, "https://secondary.example", route.URL()) + + // The primary is still the first external proxy, distinct from the workflow route. + assert.Equal(t, "https://primary.example", router.Primary().URL()) +} + +func TestWorkflowInfoRouterFirstAdvertisingWins(t *testing.T) { + t.Parallel() + + first := &fakeRouterClient{url: "https://a.example", workflowEnabled: true, workflowWebURL: "https://a-ui.example"} + second := &fakeRouterClient{url: "https://b.example", workflowEnabled: true, workflowWebURL: "https://b-ui.example"} + + router := NewRouter(logrus.New(), []ClientRoute{ + {Name: "a", Client: first}, + {Name: "b", Client: second}, + }) + + enabled, webURL := router.(WorkflowInfoProvider).WorkflowInfo() + assert.True(t, enabled) + assert.Equal(t, "https://a-ui.example", webURL) + + route, ok := router.WorkflowRoute() + require.True(t, ok) + assert.Equal(t, "https://a.example", route.URL()) +} + +func TestWorkflowInfoRouterNoneAdvertise(t *testing.T) { + t.Parallel() + + router := NewRouter(logrus.New(), []ClientRoute{ + {Name: "a", Client: &fakeRouterClient{url: "https://a.example"}}, + }) + + enabled, _ := router.(WorkflowInfoProvider).WorkflowInfo() + assert.False(t, enabled) + + _, ok := router.WorkflowRoute() + assert.False(t, ok) +} + +// newWorkflowMetricsServer builds an auth-none proxy server whose /workflow +// route targets the given upstream in token mode. +func newWorkflowMetricsServer(t *testing.T, upstreamURL string) *server { + t.Helper() + + cfg := ServerConfig{ + Auth: AuthConfig{Mode: AuthModeNone}, + ClickHouse: clickHouseOnly(), + Workflow: &WorkflowConfig{URL: upstreamURL, APIToken: "tok"}, + } + + s, err := newServer(logrus.New(), cfg, "http://localhost:0", "0") + require.NoError(t, err) + + return s +} + +func durationSampleCount(t *testing.T, method string) uint64 { + t.Helper() + + obs, err := ProxyRequestDurationSeconds.GetMetricWithLabelValues("workflow", "default", method) + require.NoError(t, err) + + m := &dto.Metric{} + require.NoError(t, obs.(interface{ Write(*dto.Metric) error }).Write(m)) + + return m.GetHistogram().GetSampleCount() +} + +func counterValue(t *testing.T, method, status string) float64 { + t.Helper() + + c, err := ProxyRequestsTotal.GetMetricWithLabelValues("workflow", "default", method, status) + require.NoError(t, err) + + m := &dto.Metric{} + require.NoError(t, c.Write(m)) + + return m.GetCounter().GetValue() +} + +func TestWorkflowRouteMetricsUseWorkflowType(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"ok":true}`) + })) + defer upstream.Close() + + s := newWorkflowMetricsServer(t, upstream.URL) + + // PUT is a real method no other workflow test uses, isolating the series. + const method = http.MethodPut + + beforeCounter := counterValue(t, method, "200") + beforeDuration := durationSampleCount(t, method) + + req := httptest.NewRequest(method, "/workflow/whiteboards", nil) + rec := httptest.NewRecorder() + s.mux.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + assert.InDelta(t, beforeCounter+1, counterValue(t, method, "200"), 0.0001, + "workflow request counter should increment") + assert.Equal(t, beforeDuration+1, durationSampleCount(t, method), + "non-SSE workflow request duration should be observed") +} + +func TestWorkflowSSEExcludedFromDuration(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + + if f, ok := w.(http.Flusher); ok { + _, _ = io.WriteString(w, "data: hi\n\n") + f.Flush() + } + })) + defer upstream.Close() + + s := newWorkflowMetricsServer(t, upstream.URL) + + // PATCH isolates the (method, status) series from the non-SSE test above. + const method = http.MethodPatch + + beforeCounter := counterValue(t, method, "200") + beforeDuration := durationSampleCount(t, method) + + req := httptest.NewRequest(method, "/workflow/workflows/wf_1/runs/run_1/state/stream", nil) + rec := httptest.NewRecorder() + s.mux.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + assert.InDelta(t, beforeCounter+1, counterValue(t, method, "200"), 0.0001, + "workflow SSE request counter should still increment") + assert.Equal(t, beforeDuration, durationSampleCount(t, method), + "workflow SSE duration must be excluded from the histogram") +} diff --git a/pkg/server/api.go b/pkg/server/api.go index 361369ce..7e9b138b 100644 --- a/pkg/server/api.go +++ b/pkg/server/api.go @@ -55,6 +55,19 @@ func (s *service) mountAPIRoutes(r chi.Router) { r.Delete("/uploads/published", s.handleUploadDeletePublished) } + // Workflow-engine passthrough: a streaming relay to the proxy route that + // advertises the engine (the proxy holds the credential). The bare route + // (no wildcard match in chi) relays to the engine API root, so a probe + // like `panda workflow api GET /` is not a misleading 404. + r.HandleFunc("/workflow", s.handleAPIWorkflowProxy) + r.HandleFunc("/workflow/*", s.handleAPIWorkflowProxy) + + // Workflow integration metadata (served by panda-server, NOT proxied): + // the web origin for human-facing frontend links, answered from proxy + // discovery. Deliberately outside the /workflow/* wildcard so it can + // never shadow a real workflow route. + r.Get("/workflow-info", s.handleAPIWorkflowInfo) + // Public file serving (no auth — same as MinIO anonymous download). r.Get("/storage/files/*", s.handleStorageServeFile) @@ -116,6 +129,19 @@ func (s *service) runtimeAuthMiddleware(next http.Handler) http.Handler { }) } +// handleAPIWorkflowInfo reports whether a proxy advertises the workflow engine +// and the web origin for frontend links, answered from proxy discovery. It +// never exposes tokens; users log into the workflow UI themselves. When no +// proxy advertises the engine it returns an empty `{}` body (Enabled false). +func (s *service) handleAPIWorkflowInfo(w http.ResponseWriter, _ *http.Request) { + enabled, webURL := s.workflowInfo() + + writeJSON(w, http.StatusOK, serverapi.WorkflowInfoResponse{ + Enabled: enabled, + WebBaseURL: webURL, + }) +} + func (s *service) handleAPIProxyAuthMetadata(w http.ResponseWriter, _ *http.Request) { if s.proxyAuthMetadata == nil { writeJSON(w, http.StatusOK, serverapi.ProxyAuthMetadataResponse{}) diff --git a/pkg/server/auth.go b/pkg/server/auth.go index 78f422e2..d4394ad3 100644 --- a/pkg/server/auth.go +++ b/pkg/server/auth.go @@ -25,6 +25,7 @@ type credentialTarget struct { issuerURL string clientID string resource string + scopes []string enabled bool } @@ -66,6 +67,7 @@ func newCredentialController(log logrus.FieldLogger, meta *serverapi.ProxyAuthMe issuerURL: meta.IssuerURL, clientID: meta.ClientID, resource: meta.Resource, + scopes: meta.Scopes, enabled: meta.Enabled, } @@ -77,6 +79,7 @@ func newCredentialController(log logrus.FieldLogger, meta *serverapi.ProxyAuthMe IssuerURL: t.issuerURL, ClientID: t.clientID, Resource: t.resource, + Scopes: t.scopes, }) }, newStore: func(t credentialTarget, c authclient.Client) authstore.Store { diff --git a/pkg/server/builder.go b/pkg/server/builder.go index 05ac226e..5472a79d 100644 --- a/pkg/server/builder.go +++ b/pkg/server/builder.go @@ -253,6 +253,7 @@ func buildProxyAuthMetadata(cfg *config.Config) *serverapi.ProxyAuthMetadataResp IssuerURL: issuerURL, ClientID: cfg.Proxy.Auth.ClientID, Resource: cfg.Proxy.ResolvedAuthResource(), + Scopes: cfg.Proxy.Auth.Scopes, } } diff --git a/pkg/server/server.go b/pkg/server/server.go index 3765dfa3..a0dee4f4 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -45,6 +45,7 @@ type Service interface { type service struct { log logrus.FieldLogger cfg config.ServerConfig + workflow *workflowPassthrough toolRegistry tool.Registry resourceRegistry resource.Registry searchService *searchsvc.Service @@ -92,7 +93,7 @@ func NewService( runtimeTokens *tokenstore.Store, cleanup func(context.Context) error, ) Service { - return &service{ + s := &service{ log: log.WithField("component", "server"), cfg: cfg, toolRegistry: toolRegistry, @@ -113,6 +114,13 @@ func NewService( httpClient: &http.Client{Transport: &version.Transport{}, Timeout: 0}, done: make(chan struct{}), } + + // The workflow passthrough holds no credential or config: it relays to the + // proxy route that advertises the workflow engine, resolved per request. It + // is always built; availability is decided at request time from discovery. + s.workflow = newWorkflowPassthrough(s.log, nil) + + return s } // Start initializes and starts the MCP server. diff --git a/pkg/server/workflow.go b/pkg/server/workflow.go new file mode 100644 index 00000000..c5aeb325 --- /dev/null +++ b/pkg/server/workflow.go @@ -0,0 +1,448 @@ +package server + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "io" + "maps" + "net" + "net/http" + "net/http/httputil" + "net/url" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/sirupsen/logrus" + + "github.com/ethpandaops/panda/pkg/attribution" + "github.com/ethpandaops/panda/pkg/observability" + "github.com/ethpandaops/panda/pkg/proxy" + "github.com/ethpandaops/panda/pkg/workflowrelay" +) + +// workflowMaxReplayBody caps how much of a request body is buffered so an auth +// retry can replay it. Beyond the cap the body streams once and the retry is +// skipped (the relay still forwards the request, just without replay). +const workflowMaxReplayBody = 4 << 20 // 4 MiB + +// workflowReqData carries the per-request values the reverse-proxy Rewrite +// needs. They are resolved in serve before proxying so failures short-circuit +// cleanly; Rewrite cannot return an error. token is empty when no Authorization +// header should be sent (the NoAuthToken sentinel maps to ""). +type workflowReqData struct { + baseURL *url.URL + token string + outPath string + rawPath string +} + +type workflowDataKeyType struct{} + +var workflowDataKey workflowDataKeyType + +// workflowPassthrough is a thin streaming relay from /api/v1/workflow/* to the +// proxy route that advertises the workflow engine. It resolves the route per +// request, forwards a strict header allow-list plus the proxy bearer, and +// streams responses (SSE-safe). The proxy owns the credential, canonical path +// clamp, and /api/v1 rooting; the relay keeps only traversal rejection. +type workflowPassthrough struct { + log logrus.FieldLogger + proxy *httputil.ReverseProxy +} + +// newWorkflowPassthrough builds the relay. transport is overridable for tests; +// nil uses a default compression-disabled transport (Go's automatic +// Accept-Encoding: gzip would otherwise defeat FlushInterval streaming). +func newWorkflowPassthrough(log logrus.FieldLogger, transport http.RoundTripper) *workflowPassthrough { + if transport == nil { + transport = &http.Transport{ + TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, + DialContext: (&net.Dialer{Timeout: 30 * time.Second}).DialContext, + TLSHandshakeTimeout: 10 * time.Second, + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + // Disable transparent gzip: Go's Transport auto-adds + // Accept-Encoding: gzip and decompresses, which buffers a block + // before emitting and defeats FlushInterval: -1 for SSE. + DisableCompression: true, + } + } + + wp := &workflowPassthrough{ + log: log.WithField("component", "workflow_passthrough"), + } + + wp.proxy = &httputil.ReverseProxy{ + Transport: transport, + // Flush every write so SSE frames reach the client immediately. + FlushInterval: -1, + Rewrite: wp.rewrite, + ErrorHandler: wp.errorHandler, + } + + return wp +} + +// rewrite rebuilds the outbound request: the proxy route target, the explicit +// /workflow/ path, the header allow-list, the injected proxy bearer, and +// the attribution header for proxy audit logging. +func (wp *workflowPassthrough) rewrite(pr *httputil.ProxyRequest) { + data, _ := pr.In.Context().Value(workflowDataKey).(*workflowReqData) + if data != nil { + pr.SetURL(data.baseURL) + + // SetURL joins the inbound path onto the target, which would leak the + // /api/v1/workflow prefix. Overwrite Path and RawPath explicitly with the + // computed /workflow/ (the proxy strips /workflow and roots at + // /api/v1). RawPath preserves the caller's percent-encoding on the wire. + pr.Out.URL.Path = data.outPath + pr.Out.URL.RawPath = data.rawPath + pr.Out.Host = pr.Out.URL.Host + } + + // Replace the (hop-by-hop-stripped) inbound headers with the shared strict + // allow-list. This drops Authorization, Cookie, Host, and attribution by + // construction before we inject our own. + allowed := workflowrelay.FilterHeaders(pr.In.Header) + + if data != nil && data.token != "" { + allowed.Set("Authorization", "Bearer "+data.token) + } + + if v := attribution.FromContext(pr.In.Context()); v != "" { + allowed.Set(attribution.Header, v) + } + + pr.Out.Header = allowed + + pr.SetXForwarded() +} + +// errorHandler returns a clean 502 problem+json when the proxy is unreachable, +// distinct from the 503 no-route-advertises short-circuit in serve. +func (wp *workflowPassthrough) errorHandler(w http.ResponseWriter, _ *http.Request, err error) { + wp.log.WithError(err).Warn("workflow proxy relay error") + workflowrelay.WriteProblem(w, http.StatusBadGateway, "Bad Gateway", "proxy is unreachable") +} + +// serve relays the request to the resolved proxy route, recording a single +// passthrough metric once the (possibly long-lived) response completes. On a +// proxy auth rejection it invalidates the token and replays once. +func (wp *workflowPassthrough) serve(w http.ResponseWriter, r *http.Request, route proxy.Service) { + baseURL, err := url.Parse(strings.TrimRight(route.URL(), "/")) + if err != nil || baseURL.Scheme == "" || baseURL.Host == "" { + observability.WorkflowPassthroughTotal.WithLabelValues(r.Method, "5xx").Inc() + workflowrelay.WriteProblem(w, http.StatusBadGateway, "Bad Gateway", "proxy is unreachable") + + return + } + + outPath, rawPath, err := workflowOutboundPath(chi.URLParam(r, "*")) + if err != nil { + observability.WorkflowPassthroughTotal.WithLabelValues(r.Method, "4xx").Inc() + workflowrelay.WriteProblem(w, http.StatusBadRequest, "Bad Request", err.Error()) + + return + } + + // Preserve a base path on the proxy URL (a proxy mounted under a subpath). + // The rewrite overwrites Path/RawPath wholesale, so the prefix must be + // baked in here. + if basePath := baseURL.Path; basePath != "" && basePath != "/" { + outPath = basePath + outPath + rawPath = baseURL.EscapedPath() + rawPath + } + + // Buffer the request body so an auth retry can replay it. Beyond the cap the + // body streams once and the retry is skipped. + buffered, replayable, firstBody, err := bufferWorkflowBody(r.Body) + if err != nil { + observability.WorkflowPassthroughTotal.WithLabelValues(r.Method, "5xx").Inc() + workflowrelay.WriteProblem(w, http.StatusBadGateway, "Bad Gateway", "proxy is unreachable") + + return + } + + data := &workflowReqData{ + baseURL: baseURL, + token: routeBearer(route), + outPath: outPath, + rawPath: rawPath, + } + ctx := context.WithValue(r.Context(), workflowDataKey, data) + + // Long-lived SSE streams must not hit a write deadline. Clear it defensively + // (the recorder's Unwrap lets the controller reach the real writer) so a + // future server WriteTimeout cannot truncate a stream mid-flight. + rc := http.NewResponseController(w) + _ = rc.SetWriteDeadline(time.Time{}) + + // Snapshot the pre-relay response headers (set by middleware, if any) so a + // retry resets to this baseline instead of an empty map: stale upstream + // headers from a trapped attempt are dropped without stripping headers + // middleware set before the relay ran. + pristine := w.Header().Clone() + + runOnce := func(trap bool, body io.ReadCloser) *workflowStatusRecorder { + // Reset response headers to the pre-relay baseline, dropping anything a + // prior (trapped) attempt copied in so a retried response never carries + // stale upstream headers. + h := w.Header() + for k := range h { + delete(h, k) + } + + maps.Copy(h, pristine.Clone()) + + rec := &workflowStatusRecorder{ResponseWriter: w, status: http.StatusOK, trap: trap} + + req := r.Clone(ctx) + req.Body = body + + wp.proxy.ServeHTTP(rec, req) + + return rec + } + + start := time.Now() + + rec := runOnce(replayable, firstBody) + + if rec.trapped { + // A 401/403 reached the relay: invalidate, refresh the bearer, and replay + // once; a second rejection is relayed to the caller. The rejection may + // also be an engine 401 the proxy relayed verbatim — indistinguishable at + // this hop without coupling to the proxy's error shape (which older + // deployed proxies would not honor). That is deliberate: in passthrough + // mode a refreshed user token is exactly the right recovery, and in token + // mode the cost is one bounded token refresh before the 401 is relayed. + route.Invalidate() + data.token = routeBearer(route) + + rec = runOnce(false, replayWorkflowBody(buffered)) + } + + observability.WorkflowPassthroughTotal. + WithLabelValues(r.Method, workflowStatusClass(rec.status)).Inc() + + // Skip the duration histogram for SSE responses: ServeHTTP blocks for the + // whole stream lifetime, so time.Since(start) is the stream duration, not + // request latency, and would swamp the +Inf bucket. The counter above still + // covers every response. + if !workflowrelay.IsEventStream(rec.Header().Get("Content-Type")) { + observability.WorkflowPassthroughDuration. + WithLabelValues(r.Method).Observe(time.Since(start).Seconds()) + } +} + +// bufferWorkflowBody reads the request body up to the replay cap. When the body +// fits, it is buffered and replayable; beyond the cap the first attempt streams +// the whole body once (buffered prefix + remaining reader) and replay is off. +func bufferWorkflowBody(body io.ReadCloser) (buffered []byte, replayable bool, firstBody io.ReadCloser, err error) { + if body == nil { + return nil, true, http.NoBody, nil + } + + buf, err := io.ReadAll(io.LimitReader(body, workflowMaxReplayBody+1)) + if err != nil { + _ = body.Close() + + return nil, false, nil, err + } + + if len(buf) > workflowMaxReplayBody { + // Too large to buffer for replay: stream the whole body once, no retry. + combined := struct { + io.Reader + io.Closer + }{io.MultiReader(bytes.NewReader(buf), body), body} + + return nil, false, combined, nil + } + + _ = body.Close() + + return buf, true, io.NopCloser(bytes.NewReader(buf)), nil +} + +// replayWorkflowBody returns a fresh reader over the buffered body for a retry. +func replayWorkflowBody(buffered []byte) io.ReadCloser { + if buffered == nil { + return http.NoBody + } + + return io.NopCloser(bytes.NewReader(buffered)) +} + +// routeBearer returns the Authorization bearer for the proxy route, or "" when +// no Authorization header should be sent (no auth or an unavailable token). The +// NoAuthToken sentinel is never forwarded as a credential. +func routeBearer(route proxy.Service) string { + tok := route.RegisterToken() + if tok == "" || tok == proxy.NoAuthToken { + return "" + } + + return tok +} + +// workflowRoute resolves the proxy route that advertises the workflow engine. +// With a router it uses the priority-ordered WorkflowRoute selection (shared +// with WorkflowInfo so links and traffic never disagree); with a single client +// it returns that client when it advertises the engine directly. +func (s *service) workflowRoute() (proxy.Service, bool) { + if s.proxyService == nil { + return nil, false + } + + if router, ok := s.proxyService.(proxy.Router); ok { + client, found := router.WorkflowRoute() + if !found { + return nil, false + } + + return client, true + } + + if provider, ok := s.proxyService.(proxy.WorkflowInfoProvider); ok { + if enabled, _ := provider.WorkflowInfo(); enabled { + return s.proxyService, true + } + } + + return nil, false +} + +// workflowInfo reports whether a proxy advertises the workflow engine and its +// web URL, read from proxy discovery. +func (s *service) workflowInfo() (enabled bool, webURL string) { + if s.proxyService == nil { + return false, "" + } + + if provider, ok := s.proxyService.(proxy.WorkflowInfoProvider); ok { + return provider.WorkflowInfo() + } + + return false, "" +} + +// handleAPIWorkflowProxy handles /api/v1/workflow/* — the streaming relay to +// the proxy route that advertises the workflow engine. When no proxy advertises +// it, it short-circuits with a 503 before any relaying. +func (s *service) handleAPIWorkflowProxy(w http.ResponseWriter, r *http.Request) { + route, ok := s.workflowRoute() + if !ok { + // No route short-circuit: count it (5xx) so every inbound request is + // reflected in the passthrough total. + observability.WorkflowPassthroughTotal.WithLabelValues(r.Method, "5xx").Inc() + workflowrelay.WriteProblem(w, http.StatusServiceUnavailable, "Service Unavailable", + "workflow engine is not available: no configured proxy advertises it") + + return + } + + s.workflow.serve(w, r, route) +} + +// workflowOutboundPath builds the outbound relay path pair (decoded Path, +// encoded RawPath) rooted at /workflow, forwarding verbatim. The +// canonical clamp and /api/v1 rooting live proxy-side; the server keeps only +// traversal rejection on the decoded form. +func workflowOutboundPath(rest string) (outPath, rawPath string, err error) { + decoded, decErr := url.PathUnescape(rest) + if decErr != nil { + return "", "", errors.New("invalid path encoding") + } + + if traversalErr := workflowrelay.RejectTraversal(decoded); traversalErr != nil { + return "", "", traversalErr + } + + rest = strings.TrimPrefix(rest, "/") + decoded = strings.TrimPrefix(decoded, "/") + + return "/workflow/" + decoded, "/workflow/" + rest, nil +} + +// workflowStatusClass maps an HTTP status code to a coarse class label. +func workflowStatusClass(status int) string { + switch { + case status >= 500: + return "5xx" + case status >= 400: + return "4xx" + case status >= 300: + return "3xx" + case status >= 200: + return "2xx" + default: + return "1xx" + } +} + +// workflowStatusRecorder captures the response status for metrics while +// preserving streaming semantics (Flush) and ResponseController compatibility +// (Unwrap). In trap mode it swallows a proxy auth rejection (401/403) so the +// relay can invalidate the token and replay before any bytes reach the client. +type workflowStatusRecorder struct { + http.ResponseWriter + status int + wrote bool + trap bool + trapped bool +} + +func (s *workflowStatusRecorder) WriteHeader(code int) { + if s.trap && !s.trapped && (code == http.StatusUnauthorized || code == http.StatusForbidden) { + s.trapped = true + s.status = code + + return + } + + if s.trapped { + return + } + + if !s.wrote { + s.status = code + s.wrote = true + } + + s.ResponseWriter.WriteHeader(code) +} + +func (s *workflowStatusRecorder) Write(b []byte) (int, error) { + if s.trapped { + // Discard the trapped rejection body; the retry writes the real response. + return len(b), nil + } + + if !s.wrote { + s.status = http.StatusOK + s.wrote = true + } + + return s.ResponseWriter.Write(b) +} + +func (s *workflowStatusRecorder) Flush() { + if s.trapped { + return + } + + if f, ok := s.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Unwrap exposes the underlying writer to http.ResponseController so +// ReverseProxy's flushing (via the controller) reaches the real writer. +func (s *workflowStatusRecorder) Unwrap() http.ResponseWriter { + return s.ResponseWriter +} diff --git a/pkg/server/workflow_test.go b/pkg/server/workflow_test.go new file mode 100644 index 00000000..ef87aeeb --- /dev/null +++ b/pkg/server/workflow_test.go @@ -0,0 +1,845 @@ +package server + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + dto "github.com/prometheus/client_model/go" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ethpandaops/panda/pkg/observability" + "github.com/ethpandaops/panda/pkg/proxy" + "github.com/ethpandaops/panda/pkg/types" +) + +// fakeProxy is a minimal proxy.Service + proxy.WorkflowInfoProvider used to +// stand in for the resolved workflow route. RegisterToken walks the tokens +// slice as Invalidate is called, so an auth retry sees a fresh bearer. +type fakeProxy struct { + url string + enabled bool + webURL string + tokens []string + + mu sync.Mutex + invalidated int +} + +func (f *fakeProxy) URL() string { return f.url } + +func (f *fakeProxy) RegisterToken() string { + f.mu.Lock() + defer f.mu.Unlock() + + if len(f.tokens) == 0 { + return proxy.NoAuthToken + } + + i := f.invalidated + if i >= len(f.tokens) { + i = len(f.tokens) - 1 + } + + return f.tokens[i] +} + +func (f *fakeProxy) Invalidate() { + f.mu.Lock() + defer f.mu.Unlock() + f.invalidated++ +} + +func (f *fakeProxy) invalidateCount() int { + f.mu.Lock() + defer f.mu.Unlock() + + return f.invalidated +} + +func (f *fakeProxy) WorkflowInfo() (bool, string) { return f.enabled, f.webURL } + +// The rest of proxy.Service is unused by the relay. +func (f *fakeProxy) Start(context.Context) error { return nil } +func (f *fakeProxy) Stop(context.Context) error { return nil } +func (f *fakeProxy) Ready() bool { return true } +func (f *fakeProxy) RevokeToken() {} +func (f *fakeProxy) ClickHouseDatasources() []string { return nil } +func (f *fakeProxy) ClickHouseDatasourceInfo() []types.DatasourceInfo { return nil } +func (f *fakeProxy) ClickHouseQuery(context.Context, string, string, url.Values) ([]byte, error) { + return nil, nil +} +func (f *fakeProxy) PrometheusDatasourceInfo() []types.DatasourceInfo { return nil } +func (f *fakeProxy) LokiDatasourceInfo() []types.DatasourceInfo { return nil } +func (f *fakeProxy) BenchmarkoorDatasourceInfo() []types.DatasourceInfo { return nil } +func (f *fakeProxy) ComputeDatasourceInfo() []types.DatasourceInfo { return nil } +func (f *fakeProxy) EthNodeAvailable() bool { return false } +func (f *fakeProxy) EthNodeDatasourceInfo() []types.DatasourceInfo { return nil } +func (f *fakeProxy) EmbeddingAvailable() bool { return false } +func (f *fakeProxy) EmbeddingModel() string { return "" } + +var ( + _ proxy.Service = (*fakeProxy)(nil) + _ proxy.WorkflowInfoProvider = (*fakeProxy)(nil) +) + +// capturedRequest records what the mock proxy received from the relay. +type capturedRequest struct { + method string + path string + rawURI string + header http.Header +} + +// newWorkflowHandler mounts the relay on a chi router pointed at proxyURL with +// the given proxy-bearer tokens, matching how the server wires the route. +func newWorkflowHandler(proxyURL string, tokens []string) http.Handler { + s := &service{ + proxyService: &fakeProxy{url: proxyURL, enabled: true, tokens: tokens}, + workflow: newWorkflowPassthrough(logrus.New(), nil), + } + + r := chi.NewRouter() + r.Use(attributionMiddleware) + r.HandleFunc("/api/v1/workflow", s.handleAPIWorkflowProxy) + r.HandleFunc("/api/v1/workflow/*", s.handleAPIWorkflowProxy) + + return r +} + +func TestWorkflowRelayTargetsRouteAndInjectsBearer(t *testing.T) { + t.Parallel() + + var captured capturedRequest + + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = capturedRequest{ + method: r.Method, + path: r.URL.Path, + rawURI: r.URL.RequestURI(), + header: r.Header.Clone(), + } + + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true}`) + })) + defer proxySrv.Close() + + handler := newWorkflowHandler(proxySrv.URL, []string{"server-tok"}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/workflow/whiteboards?limit=5", nil) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Idempotency-Key", "idem-123") + req.Header.Set("Last-Event-ID", "42") + // These must be stripped before the relay injects its own bearer. + req.Header.Set("Authorization", "Bearer client-should-not-leak") + req.Header.Set("Cookie", "session=nope") + // Caller attribution is lifted into context by attributionMiddleware and + // re-added for proxy audit. + req.Header.Set("X-Panda-On-Behalf-Of", "someone") + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + + // Targets /workflow/, verbatim; the /api/v1/workflow + // prefix does not leak. + assert.Equal(t, "/workflow/whiteboards", captured.path) + assert.Equal(t, "/workflow/whiteboards?limit=5", captured.rawURI) + + // The relay injects its own proxy bearer; the client's is dropped. + assert.Equal(t, "Bearer server-tok", captured.header.Get("Authorization")) + + // Allow-listed headers forwarded. + assert.Equal(t, "application/json", captured.header.Get("Accept")) + assert.Equal(t, "application/json", captured.header.Get("Content-Type")) + assert.Equal(t, "idem-123", captured.header.Get("Idempotency-Key")) + assert.Equal(t, "42", captured.header.Get("Last-Event-ID")) + + // Cookie stripped; attribution forwarded for proxy audit. + assert.Empty(t, captured.header.Get("Cookie")) + assert.Equal(t, "someone", captured.header.Get("X-Panda-On-Behalf-Of")) +} + +func TestWorkflowRelayPreservesProxyBasePath(t *testing.T) { + t.Parallel() + + var captured capturedRequest + + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = capturedRequest{path: r.URL.Path, rawURI: r.URL.RequestURI()} + + _, _ = io.WriteString(w, `{}`) + })) + defer proxySrv.Close() + + // A proxy mounted under a subpath: the relay must join /workflow onto the + // base path (like every other proxy call), not overwrite it away. + handler := newWorkflowHandler(proxySrv.URL+"/sub/mount/", []string{"tok"}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/workflow/whiteboards?limit=5", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "/sub/mount/workflow/whiteboards", captured.path) + assert.Equal(t, "/sub/mount/workflow/whiteboards?limit=5", captured.rawURI) +} + +func TestWorkflowRelayBareRouteRelaysEngineRoot(t *testing.T) { + t.Parallel() + + var captured capturedRequest + + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + captured = capturedRequest{path: r.URL.Path} + + _, _ = io.WriteString(w, `{}`) + })) + defer proxySrv.Close() + + handler := newWorkflowHandler(proxySrv.URL, []string{"tok"}) + + // `panda workflow api GET /` produces the bare /api/v1/workflow path; it + // must relay to the engine root, not 404. + req := httptest.NewRequest(http.MethodGet, "/api/v1/workflow", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "/workflow/", captured.path) +} + +func TestWorkflowRelayNoAuthTokenSendsNoAuthorization(t *testing.T) { + t.Parallel() + + var ( + gotAuth string + seen bool + ) + + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + seen = true + + w.WriteHeader(http.StatusOK) + })) + defer proxySrv.Close() + + // The NoAuthToken sentinel maps to no Authorization header. + handler := newWorkflowHandler(proxySrv.URL, []string{proxy.NoAuthToken}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/workflow/whiteboards", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.True(t, seen) + assert.Empty(t, gotAuth, "NoAuthToken must not be forwarded as a credential") +} + +func TestWorkflowRelayEncodedSegmentPreserved(t *testing.T) { + t.Parallel() + + var rawURI string + + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawURI = r.URL.RequestURI() + w.WriteHeader(http.StatusOK) + })) + defer proxySrv.Close() + + handler := newWorkflowHandler(proxySrv.URL, []string{"tok"}) + + // Steer key with reserved chars, percent-encoded by the caller — forwarded + // verbatim (the proxy owns the canonical clamp). + req := httptest.NewRequest(http.MethodGet, + "/api/v1/workflow/workflows/wf_1/runs/run_1/task-executions/tasks.loop.child%5Biter%3D0002%5D/queue", + nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rawURI, "%5Biter%3D0002%5D") +} + +func TestWorkflowRelayEncodedSlashPreserved(t *testing.T) { + t.Parallel() + + var rawURI string + + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawURI = r.URL.RequestURI() + w.WriteHeader(http.StatusOK) + })) + defer proxySrv.Close() + + handler := newWorkflowHandler(proxySrv.URL, []string{"tok"}) + + // An encoded slash inside a single id segment must survive on the wire, not + // be decoded and re-split into extra segments. + req := httptest.NewRequest(http.MethodGet, "/api/v1/workflow/whiteboards/a%2Fb", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rawURI, "a%2Fb") +} + +func TestWorkflowRelayRejectsTraversal(t *testing.T) { + t.Parallel() + + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("proxy should not be reached for a traversal path") + w.WriteHeader(http.StatusOK) + })) + defer proxySrv.Close() + + handler := newWorkflowHandler(proxySrv.URL, []string{"tok"}) + + cases := []struct { + name string + path string + }{ + {"literal dotdot", "/api/v1/workflow/../../secrets"}, + {"single-encoded dotdot", "/api/v1/workflow/%2e%2e/%2e%2e/secrets"}, + {"double-encoded dotdot", "/api/v1/workflow/%252e%252e/secrets"}, + {"encoded-slash dotdot", "/api/v1/workflow/..%2f..%2fopenapi.yaml"}, + {"matrix-param dotdot", "/api/v1/workflow/..;/secrets"}, + {"triple dot", "/api/v1/workflow/.../secrets"}, + {"backslash traversal", "/api/v1/workflow/..%5c..%5csecrets"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code, "path %s", tc.path) + assert.Contains(t, rec.Body.String(), "path traversal is not allowed") + }) + } +} + +func TestWorkflowRelayRelaysStatusAndBody(t *testing.T) { + t.Parallel() + + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusConflict) + _, _ = io.WriteString(w, `{"type":"about:blank","title":"Conflict","status":409,"detail":"nope"}`) + })) + defer proxySrv.Close() + + handler := newWorkflowHandler(proxySrv.URL, []string{"tok"}) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/workflow/whiteboards/wb_1/sessions/ses_1/items", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusConflict, rec.Code) + assert.Equal(t, "application/problem+json", rec.Header().Get("Content-Type")) + assert.Contains(t, rec.Body.String(), `"detail":"nope"`) +} + +func TestWorkflowRelayRelaysPlainText404(t *testing.T) { + t.Parallel() + + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(w, "404 page not found") + })) + defer proxySrv.Close() + + handler := newWorkflowHandler(proxySrv.URL, []string{"tok"}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/workflow/does-not-exist", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.Contains(t, rec.Header().Get("Content-Type"), "text/plain") + assert.Equal(t, "404 page not found", rec.Body.String()) +} + +func TestWorkflowRelay204NoBody(t *testing.T) { + t.Parallel() + + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer proxySrv.Close() + + handler := newWorkflowHandler(proxySrv.URL, []string{"tok"}) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/workflow/workflows/wf_1/runs/run_1/cancel", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNoContent, rec.Code) + assert.Empty(t, rec.Body.String()) +} + +func TestWorkflowRelayNoRouteReturns503(t *testing.T) { + t.Parallel() + + // No proxy advertises the engine. + s := &service{ + proxyService: &fakeProxy{url: "http://127.0.0.1:0", enabled: false}, + workflow: newWorkflowPassthrough(logrus.New(), nil), + } + + r := chi.NewRouter() + r.HandleFunc("/api/v1/workflow/*", s.handleAPIWorkflowProxy) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/workflow/whiteboards", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + assert.Equal(t, "application/problem+json", rec.Header().Get("Content-Type")) + assert.Contains(t, rec.Body.String(), + "workflow engine is not available: no configured proxy advertises it") +} + +func TestWorkflowRelayProxyUnreachableReturns502(t *testing.T) { + t.Parallel() + + // Point at a closed server to force a dial error. + closed := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + closedURL := closed.URL + closed.Close() + + handler := newWorkflowHandler(closedURL, []string{"tok"}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/workflow/whiteboards", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadGateway, rec.Code) + assert.Equal(t, "application/problem+json", rec.Header().Get("Content-Type")) + assert.Contains(t, rec.Body.String(), "proxy is unreachable") +} + +func TestWorkflowRelayAuthRetryReplaysBody(t *testing.T) { + t.Parallel() + + var ( + mu sync.Mutex + bodies []string + ) + + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + + mu.Lock() + bodies = append(bodies, string(body)) + mu.Unlock() + + // The stale bearer is rejected; a fresh one (after invalidate) succeeds. + if r.Header.Get("Authorization") != "Bearer good-tok" { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"detail":"stale"}`) + + return + } + + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"ok":true}`) + })) + defer proxySrv.Close() + + fp := &fakeProxy{url: proxySrv.URL, enabled: true, tokens: []string{"bad-tok", "good-tok"}} + s := &service{proxyService: fp, workflow: newWorkflowPassthrough(logrus.New(), nil)} + + r := chi.NewRouter() + r.HandleFunc("/api/v1/workflow/*", s.handleAPIWorkflowProxy) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/workflow/whiteboards/wb_1/sessions", + strings.NewReader("payload")) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"ok":true`) + assert.Equal(t, 1, fp.invalidateCount(), "token invalidated exactly once") + + mu.Lock() + defer mu.Unlock() + require.Len(t, bodies, 2, "request attempted twice (original + retry)") + assert.Equal(t, "payload", bodies[0]) + assert.Equal(t, "payload", bodies[1], "body replayed on retry") +} + +func TestWorkflowRelayAuthRetryRelaysFinal401(t *testing.T) { + t.Parallel() + + var ( + mu sync.Mutex + calls int + ) + + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + calls++ + mu.Unlock() + + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"detail":"proxy credential rejected"}`) + })) + defer proxySrv.Close() + + fp := &fakeProxy{url: proxySrv.URL, enabled: true, tokens: []string{"bad-1", "bad-2"}} + s := &service{proxyService: fp, workflow: newWorkflowPassthrough(logrus.New(), nil)} + + r := chi.NewRouter() + r.HandleFunc("/api/v1/workflow/*", s.handleAPIWorkflowProxy) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/workflow/whiteboards", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + // After a failed retry, the proxy's 401 is relayed verbatim. + assert.Equal(t, http.StatusUnauthorized, rec.Code) + assert.Contains(t, rec.Body.String(), "proxy credential rejected") + assert.Equal(t, 1, fp.invalidateCount()) + + mu.Lock() + defer mu.Unlock() + assert.Equal(t, 2, calls, "attempted twice then relayed") +} + +func TestWorkflowRelayAuthRetryPreservesMiddlewareHeaders(t *testing.T) { + t.Parallel() + + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer good-tok" { + // Set a header only the rejected attempt carries: the retry must + // reset it rather than leak it into the final response. + w.Header().Set("X-Stale-Upstream", "yes") + w.WriteHeader(http.StatusUnauthorized) + + return + } + + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, `{"ok":true}`) + })) + defer proxySrv.Close() + + fp := &fakeProxy{url: proxySrv.URL, enabled: true, tokens: []string{"bad-tok", "good-tok"}} + s := &service{proxyService: fp, workflow: newWorkflowPassthrough(logrus.New(), nil)} + + r := chi.NewRouter() + // A middleware-set response header is the pre-relay baseline: the retry's + // header reset must restore it, not strip it along with the stale ones. + r.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("X-Middleware", "kept") + next.ServeHTTP(w, req) + }) + }) + r.HandleFunc("/api/v1/workflow/*", s.handleAPIWorkflowProxy) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/workflow/whiteboards", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "kept", rec.Header().Get("X-Middleware"), + "middleware header survives the auth-retry header reset") + assert.Empty(t, rec.Header().Get("X-Stale-Upstream"), + "trapped attempt's upstream header does not leak into the retried response") +} + +// flushWriter is an http.ResponseWriter that records flushes so we can assert +// SSE frames are streamed, not buffered to the end. +type flushWriter struct { + mu sync.Mutex + header http.Header + buf strings.Builder + flushes int + status int + flushed chan struct{} + once sync.Once +} + +func newFlushWriter() *flushWriter { + return &flushWriter{header: make(http.Header), flushed: make(chan struct{})} +} + +func (f *flushWriter) Header() http.Header { return f.header } + +func (f *flushWriter) WriteHeader(code int) { + f.mu.Lock() + defer f.mu.Unlock() + f.status = code +} + +func (f *flushWriter) Write(b []byte) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + + return f.buf.Write(b) +} + +func (f *flushWriter) Flush() { + f.mu.Lock() + n := f.buf.Len() + f.flushes++ + f.mu.Unlock() + + if n > 0 { + f.once.Do(func() { close(f.flushed) }) + } +} + +func (f *flushWriter) body() string { + f.mu.Lock() + defer f.mu.Unlock() + + return f.buf.String() +} + +func TestWorkflowRelaySSEStreamsWithGzipRequest(t *testing.T) { + t.Parallel() + + release := make(chan struct{}) + + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + // DisableCompression must prevent transparent gzip that would buffer. + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + flusher, ok := w.(http.Flusher) + require.True(t, ok) + + _, _ = io.WriteString(w, "data: first\n\n") + flusher.Flush() + + <-release // hold the stream open until the test has seen the first frame + + _, _ = io.WriteString(w, "data: second\n\n") + flusher.Flush() + })) + defer proxySrv.Close() + + handler := newWorkflowHandler(proxySrv.URL, []string{"tok"}) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/workflow/workflows/wf_1/runs/run_1/state/stream", nil) + req.Header.Set("Accept-Encoding", "gzip") + + fw := newFlushWriter() + + done := make(chan struct{}) + go func() { + defer close(done) + handler.ServeHTTP(fw, req) + }() + + // The first frame must reach us BEFORE the proxy stream is released, proving + // it is flushed through, not buffered to EOF. + select { + case <-fw.flushed: + case <-time.After(3 * time.Second): + close(release) + t.Fatal("SSE first frame was not flushed before stream completion") + } + + assert.Contains(t, fw.body(), "data: first") + + close(release) + <-done + + assert.Equal(t, "text/event-stream", fw.Header().Get("Content-Type")) + assert.Equal(t, "no", fw.Header().Get("X-Accel-Buffering")) + assert.Contains(t, fw.body(), "data: second") +} + +// TestWorkflowRelayMetricEmittedOnceOnStreamCompletion asserts the relay emits +// its request counter exactly once, and only after a long-lived SSE stream +// completes — not once per flushed frame — and that the SSE duration is excluded +// from the histogram. It uses PATCH (a real method no other workflow test +// exercises) so the (method, status_class) series is not shared. +func TestWorkflowRelayMetricEmittedOnceOnStreamCompletion(t *testing.T) { + release := make(chan struct{}) + + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + + flusher, ok := w.(http.Flusher) + require.True(t, ok) + + _, _ = io.WriteString(w, "data: first\n\n") + flusher.Flush() + <-release + _, _ = io.WriteString(w, "data: second\n\n") + flusher.Flush() + })) + defer proxySrv.Close() + + handler := newWorkflowHandler(proxySrv.URL, []string{"tok"}) + + const method = http.MethodPatch + + before := metricCount(t, method, "2xx") + beforeDuration := durationSampleCount(t, method) + + req := httptest.NewRequest(method, "/api/v1/workflow/workflows/wf_1/runs/run_1/state/stream", nil) + fw := newFlushWriter() + + done := make(chan struct{}) + go func() { + defer close(done) + handler.ServeHTTP(fw, req) + }() + + // While the stream is still open the counter must not have incremented — it + // fires on completion, not per frame. + select { + case <-fw.flushed: + case <-time.After(3 * time.Second): + close(release) + t.Fatal("SSE first frame was not flushed") + } + + assert.InDelta(t, before, metricCount(t, method, "2xx"), 0.0001, + "metric must not increment mid-stream") + + close(release) + <-done + + assert.InDelta(t, before+1, metricCount(t, method, "2xx"), 0.0001, + "metric should increment exactly once on stream completion") + + // SSE is excluded from the duration histogram. + assert.Equal(t, beforeDuration, durationSampleCount(t, method), + "SSE responses must not be recorded in the duration histogram") +} + +func TestWorkflowRelayMetricEmittedOnce(t *testing.T) { + t.Parallel() + + proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "ok") + })) + defer proxySrv.Close() + + handler := newWorkflowHandler(proxySrv.URL, []string{"tok"}) + + // DELETE: a real method no other workflow test exercises, so this (method, + // status_class) series is not shared with parallel tests (the registry is + // process-global). + const method = http.MethodDelete + + before := metricCount(t, method, "2xx") + beforeDuration := durationSampleCount(t, method) + + req := httptest.NewRequest(method, "/api/v1/workflow/whiteboards/wb_1/sessions/ses_1/queue/item1", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.InDelta(t, before+1, metricCount(t, method, "2xx"), 0.0001, + "metric should increment exactly once") + // A non-SSE response IS recorded in the duration histogram. + assert.Equal(t, beforeDuration+1, durationSampleCount(t, method), + "non-SSE response recorded in duration histogram") +} + +func TestWorkflowRelayShortCircuitsAreCounted(t *testing.T) { + // 503 (no route) and 400 (traversal) short-circuits are still counted. Uses + // custom methods so the series are not shared with other tests. + noRoute := &service{ + proxyService: &fakeProxy{enabled: false}, + workflow: newWorkflowPassthrough(logrus.New(), nil), + } + + r := chi.NewRouter() + r.HandleFunc("/api/v1/workflow/*", noRoute.handleAPIWorkflowProxy) + + // OPTIONS and PUT are standard methods no other workflow test exercises, so + // these (method, status_class) series are not shared with parallel tests. + before503 := metricCount(t, http.MethodOptions, "5xx") + r.ServeHTTP(httptest.NewRecorder(), + httptest.NewRequest(http.MethodOptions, "/api/v1/workflow/whiteboards", nil)) + assert.InDelta(t, before503+1, metricCount(t, http.MethodOptions, "5xx"), 0.0001, + "503 short-circuit counted") + + handler := newWorkflowHandler("http://127.0.0.1:0", []string{"tok"}) + before400 := metricCount(t, http.MethodPut, "4xx") + handler.ServeHTTP(httptest.NewRecorder(), + httptest.NewRequest(http.MethodPut, "/api/v1/workflow/../secrets", nil)) + assert.InDelta(t, before400+1, metricCount(t, http.MethodPut, "4xx"), 0.0001, + "400 short-circuit counted") +} + +func TestWorkflowInfoReflectsDiscovery(t *testing.T) { + t.Parallel() + + s := &service{proxyService: &fakeProxy{enabled: true, webURL: "https://workflow.example.io"}} + + rec := httptest.NewRecorder() + s.handleAPIWorkflowInfo(rec, httptest.NewRequest(http.MethodGet, "/api/v1/workflow-info", nil)) + + require.Equal(t, http.StatusOK, rec.Code) + assert.JSONEq(t, `{"enabled":true,"web_base_url":"https://workflow.example.io"}`, rec.Body.String()) +} + +func TestWorkflowInfoEmptyWhenAbsent(t *testing.T) { + t.Parallel() + + s := &service{proxyService: &fakeProxy{enabled: false}} + + rec := httptest.NewRecorder() + s.handleAPIWorkflowInfo(rec, httptest.NewRequest(http.MethodGet, "/api/v1/workflow-info", nil)) + + require.Equal(t, http.StatusOK, rec.Code) + assert.JSONEq(t, `{}`, rec.Body.String()) +} + +// metricCount reads the current value of the workflow passthrough counter for +// the given method and status class. +func metricCount(t *testing.T, method, statusClass string) float64 { + t.Helper() + + c, err := observability.WorkflowPassthroughTotal.GetMetricWithLabelValues(method, statusClass) + require.NoError(t, err) + + return testutil.ToFloat64(c) +} + +// durationSampleCount reports the number of observations recorded in the +// workflow passthrough duration histogram for the given method. +func durationSampleCount(t *testing.T, method string) uint64 { + t.Helper() + + obs, err := observability.WorkflowPassthroughDuration.GetMetricWithLabelValues(method) + require.NoError(t, err) + + metric, ok := obs.(prometheus.Metric) + require.True(t, ok) + + var m dto.Metric + require.NoError(t, metric.Write(&m)) + + return m.GetHistogram().GetSampleCount() +} diff --git a/pkg/serverapi/types.go b/pkg/serverapi/types.go index dae527de..74f91b77 100644 --- a/pkg/serverapi/types.go +++ b/pkg/serverapi/types.go @@ -19,11 +19,21 @@ type DatasourcesResponse struct { } type ProxyAuthMetadataResponse struct { - Enabled bool `json:"enabled"` - Mode string `json:"mode,omitempty"` - IssuerURL string `json:"issuer_url,omitempty"` - ClientID string `json:"client_id,omitempty"` - Resource string `json:"resource,omitempty"` + Enabled bool `json:"enabled"` + Mode string `json:"mode,omitempty"` + IssuerURL string `json:"issuer_url,omitempty"` + ClientID string `json:"client_id,omitempty"` + Resource string `json:"resource,omitempty"` + Scopes []string `json:"scopes,omitempty"` +} + +// WorkflowInfoResponse describes the workflow engine to CLI clients: whether a +// proxy advertises it, and the web origin for human-facing frontend links +// (users authenticate there themselves; no credential is exposed). It is +// answered from proxy discovery, so an absent engine encodes as `{}`. +type WorkflowInfoResponse struct { + Enabled bool `json:"enabled,omitempty"` + WebBaseURL string `json:"web_base_url,omitempty"` } // AuthStatusResponse reports the server's credential state. It deliberately diff --git a/pkg/surface/cli.go b/pkg/surface/cli.go index 31da888b..528159c7 100644 --- a/pkg/surface/cli.go +++ b/pkg/surface/cli.go @@ -136,5 +136,28 @@ Before writing a query from scratch, search for prior art: - ` + "`panda search \"\"`" + ` — search everything at once Runbooks codify how to debug specific scenarios end-to-end (which datasources to query, which fields to filter on, common pitfalls). For anything non-trivial, start with a runbook search instead of probing raw tables. -` +` + cliWorkflowSection } + +// cliWorkflowSection is the CLI-dialect getting-started section for the workflow +// engine. It is a different axis from the Ethereum-data funnel, so it renders as +// its own section and stays out of the MCP dialect entirely. +const cliWorkflowSection = ` +## Workflows + +` + "`panda workflow`" + ` is a client for the workflow engine — design (whiteboard → +draft), publish, run, monitor, and steer multi-step agent workflows. + +- **Use it when** the task is to create, inspect, run, steer, or read the outputs + of a workflow. +- **Do not use it** to query Ethereum data — that is the datasets → search → + execute workflow above. +- It exposes raw CRUD + streaming primitives, not orchestration: the design → + publish → run sequence is yours to drive. +- **The workflow engine owns drafting.** It has templates and knows + devnets/networks and inputs. Describe what you want to the session in plain + language and let the engine draft it — do not hand-author specs, and pass + iteration feedback verbatim. + +Read ` + "`panda workflow docs`" + ` for the lifecycle and examples, then ` + "`panda workflow --help`" + `. +` diff --git a/pkg/surface/surface_test.go b/pkg/surface/surface_test.go index 22d99641..9eceadbd 100644 --- a/pkg/surface/surface_test.go +++ b/pkg/surface/surface_test.go @@ -103,3 +103,14 @@ func TestDiscoveryGuide(t *testing.T) { assert.Contains(t, cliGuide, "panda search consensus-specs") assert.Contains(t, cliGuide, "panda search runbooks") } + +func TestCLIDiscoveryGuideHasWorkflowSection(t *testing.T) { + cliGuide := CLI.DiscoveryGuide(Discovery{}) + assert.Contains(t, cliGuide, "## Workflows") + assert.Contains(t, cliGuide, "panda workflow docs") + + // The workflow section is CLI-only; the MCP dialect must not carry it. + mcpGuide := MCP.DiscoveryGuide(Discovery{}) + assert.NotContains(t, mcpGuide, "## Workflows") + assert.NotContains(t, mcpGuide, "panda workflow") +} diff --git a/pkg/workflowrelay/workflowrelay.go b/pkg/workflowrelay/workflowrelay.go new file mode 100644 index 00000000..4a02966e --- /dev/null +++ b/pkg/workflowrelay/workflowrelay.go @@ -0,0 +1,77 @@ +// Package workflowrelay is the shared vocabulary of the two workflow-engine +// relay hops: the panda-server passthrough (pkg/server) and the proxy handler +// (pkg/proxy/handlers). Both forward the same header allow-list, reject the +// same traversal vectors, and speak the same problem+json — keeping those in +// one place means a fix on one hop cannot silently miss the other. Path +// building stays hop-local: the server roots at /workflow, the proxy at +// /api/v1, and their clamp rules genuinely differ. +package workflowrelay + +import ( + "errors" + "fmt" + "net/http" + "net/textproto" + "strings" +) + +// ForwardedHeaders is the explicit allow-list of inbound headers a relay hop +// forwards. Everything else (Authorization, Cookie, Host, attribution, +// hop-by-hop) is dropped by construction; each hop injects its own +// Authorization and attribution after filtering. +var ForwardedHeaders = []string{ + "Accept", + "Content-Type", + "Idempotency-Key", + "Last-Event-ID", +} + +// FilterHeaders returns a fresh header map holding only the allow-listed +// relay headers from in, with room for the couple of headers the caller +// injects afterwards (Authorization, attribution). +func FilterHeaders(in http.Header) http.Header { + allowed := make(http.Header, len(ForwardedHeaders)+2) + + for _, name := range ForwardedHeaders { + if values := in.Values(name); len(values) > 0 { + key := textproto.CanonicalMIMEHeaderKey(name) + allowed[key] = append([]string(nil), values...) + } + } + + return allowed +} + +// RejectTraversal rejects a decoded path that carries any traversal or +// normalization vector: a backslash separator, or a segment that is or contains +// ".." (covers "..", "..;", "...", "..\..") or carries a ";" matrix parameter. +// Workflow path segments never contain "..", ";", or "\", so rejecting outright +// is safe and closes the whole class defensively. +func RejectTraversal(decoded string) error { + if strings.ContainsRune(decoded, '\\') { + return errors.New("path traversal is not allowed") + } + + for seg := range strings.SplitSeq(decoded, "/") { + if strings.Contains(seg, "..") || strings.Contains(seg, ";") { + return errors.New("path traversal is not allowed") + } + } + + return nil +} + +// WriteProblem writes an RFC 7807-style problem+json error body. +func WriteProblem(w http.ResponseWriter, status int, title, detail string) { + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(status) + _, _ = fmt.Fprintf(w, + `{"type":"about:blank","title":%q,"status":%d,"detail":%q}`, + title, status, detail) +} + +// IsEventStream reports whether a response Content-Type is a server-sent event +// stream (whose duration must not be recorded as request latency). +func IsEventStream(contentType string) bool { + return strings.HasPrefix(contentType, "text/event-stream") +} diff --git a/pkg/workflowrelay/workflowrelay_test.go b/pkg/workflowrelay/workflowrelay_test.go new file mode 100644 index 00000000..ca419ce5 --- /dev/null +++ b/pkg/workflowrelay/workflowrelay_test.go @@ -0,0 +1,81 @@ +package workflowrelay + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFilterHeadersKeepsOnlyAllowList(t *testing.T) { + t.Parallel() + + in := http.Header{} + in.Set("Accept", "text/event-stream") + in.Set("Content-Type", "application/json") + in.Add("Last-Event-ID", "42") + in.Set("Authorization", "Bearer caller-token") + in.Set("Cookie", "session=abc") + in.Set("X-Panda-On-Behalf-Of", "someone") + + out := FilterHeaders(in) + + assert.Equal(t, "text/event-stream", out.Get("Accept")) + assert.Equal(t, "application/json", out.Get("Content-Type")) + assert.Equal(t, "42", out.Get("Last-Event-ID")) + assert.Empty(t, out.Get("Authorization")) + assert.Empty(t, out.Get("Cookie")) + assert.Empty(t, out.Get("X-Panda-On-Behalf-Of")) + + // The returned map must be a copy: mutating it must not touch the input. + out.Set("Accept", "mutated") + assert.Equal(t, "text/event-stream", in.Get("Accept")) +} + +func TestRejectTraversal(t *testing.T) { + t.Parallel() + + for _, ok := range []string{ + "whiteboards/wb_1/state", + "workflows/wf_1/runs/run_2/task-executions/tasks.loop.child[iter=0002]/steer", + "", + } { + assert.NoError(t, RejectTraversal(ok), ok) + } + + for _, bad := range []string{ + "..", + "../secrets", + "whiteboards/../admin", + "whiteboards/..;/admin", + `whiteboards\..\admin`, + "a/b;jsessionid=1/c", + "a/.../b", + } { + assert.Error(t, RejectTraversal(bad), bad) + } +} + +func TestWriteProblem(t *testing.T) { + t.Parallel() + + rec := httptest.NewRecorder() + WriteProblem(rec, http.StatusBadGateway, "Bad Gateway", "proxy is unreachable") + + require.Equal(t, http.StatusBadGateway, rec.Code) + assert.Equal(t, "application/problem+json", rec.Header().Get("Content-Type")) + assert.JSONEq(t, + `{"type":"about:blank","title":"Bad Gateway","status":502,"detail":"proxy is unreachable"}`, + rec.Body.String()) +} + +func TestIsEventStream(t *testing.T) { + t.Parallel() + + assert.True(t, IsEventStream("text/event-stream")) + assert.True(t, IsEventStream("text/event-stream; charset=utf-8")) + assert.False(t, IsEventStream("application/json")) + assert.False(t, IsEventStream("")) +} diff --git a/proxy-config.example.yaml b/proxy-config.example.yaml index 081cdb1f..bc16c4e8 100644 --- a/proxy-config.example.yaml +++ b/proxy-config.example.yaml @@ -196,6 +196,30 @@ loki: # allowed_orgs: # - ethpandaops:Core +# Workflow engine passthrough (optional). Relays /workflow/* to the workflow +# engine API; SSE streams end-to-end. Two auth modes: +# +# token - the proxy injects the api_token below; the caller's bearer +# never reaches upstream. api_token is required. +# passthrough - the proxy re-attaches the caller's own verified bearer +# unchanged; the engine validates it directly. api_token must +# be empty, and this proxy's auth.mode must be oauth or oidc. +# Callers must request the "workflows" scope at login so +# Authentik cross-grants the engine audience into their token. +# +# The api_token lives HERE, never on the panda server. +# workflow: +# url: "${WORKFLOW_ENGINE_URL}" # bare scheme+host[:port], no path +# auth_mode: "passthrough" # token | passthrough +# # api_token: "${WORKFLOW_ENGINE_API_TOKEN}" # required for auth_mode: token; omit for passthrough +# web_url: "" # optional, defaults to url +# # allowed_orgs gates the engine to members of these orgs/groups: OIDC groups +# # (e.g. "ethpandaops:Core") in oidc mode, or GitHub orgs in oauth mode. The +# # proxy filters the advert out of discovery for callers who lack it, so the +# # panda CLI hides `panda workflow` from them too. Empty leaves it open to all. +# allowed_orgs: +# - ethpandaops:Core + # Ethereum node API access (beacon and execution nodes) # Single credential pair for all bn-*.srv.*.ethpandaops.io and rpc-*.srv.*.ethpandaops.io endpoints # ethnode: diff --git a/runbooks/devnet_bug_report.md b/runbooks/devnet_bug_report.md index 3f139d24..5b1450d4 100644 --- a/runbooks/devnet_bug_report.md +++ b/runbooks/devnet_bug_report.md @@ -66,7 +66,7 @@ hits as query-shape guidance and translate them to the network's raw tables at t actual placement. One translation trap: **participation** — a raw attestation-event aggregate measures observation coverage, not attestation correctness, so it is NOT the 66.7% finality-participation figure the severity rubric compares against. Read epoch -participation from Dora (`panda dora epoch ` → `globalParticipationRate`) +participation from Dora (`panda dora epoch ` → `data.globalparticipationrate`) when the refined/CBT participation tables are absent, rather than passing off a raw observed-share proxy as participation. diff --git a/runbooks/devnet_issue_root_cause.md b/runbooks/devnet_issue_root_cause.md index 6367d45f..a55b708f 100644 --- a/runbooks/devnet_issue_root_cause.md +++ b/runbooks/devnet_issue_root_cause.md @@ -8,6 +8,7 @@ triggers: - investigate this collated issue record end to end - why did validators fail to produce blocks on my kurtosis devnet - investigate missed blocks on a local enclave + - which snapshot should I restore to investigate an issue --- Orchestrates root-cause investigation for ONE issue in the shape of @@ -39,10 +40,17 @@ in the summary and bind `confidence` to the deeper claim. 2. **Fingerprint before expensive work.** A `duplicate` with a matched prior issue and no new variant dimension is a valid early exit: return the fingerprint block, occurrence evidence, and a `publish`/`manual-review` feedback task. -3. **Reproduce or restore before blaming.** Prefer the watch snapshot; otherwise pick - the cheapest faithful target kind (`runbooks://debug_ethereum_network`) and label the - mode explicitly: `reproduced | partial | not-reproduced | local-live | public-live | - historical-only` (naming the historical evidence). +3. **Reproduce or restore before blaming.** Prefer a snapshot restore + (`runbooks://panda_compute_kurtosis_lifecycle`); otherwise pick the cheapest faithful + target kind (`runbooks://debug_ethereum_network`) and label the mode explicitly: + `reproduced | partial | not-reproduced | local-live | public-live | + historical-only` (naming the historical evidence). Given several snapshots of the + same network, choose the restore point by what the investigation needs: the + broken-state (latest) snapshot to inspect the failure as it stands, or the latest + snapshot strictly BEFORE the issue's `first_bad` to re-run the window and watch the + failure develop under targeted observation. A pre-`first_bad` restore re-executes + rather than replays — peer, proposer, and builder timing re-randomize — so a failure + that does not recur is a determinism finding to record, not a dead end. 4. **Hypotheses are bounded.** 3–5 concrete hypotheses, each with an angle, a test, a supporting observable, AND a rejecting observable. 5. **Judge twice.** Adversarial plan review before compute-heavy work; adversarial @@ -62,8 +70,8 @@ in the summary and bind `confidence` to the deeper claim. 1. **Canonicalize** the input into the issue record shape and fill the fingerprint block. Preserve upstream snapshot handles and evidence verbatim; add fields without rewriting facts. -2. **Choose the reproduction path** (first viable): restore the broken-state snapshot - (`runbooks://panda_compute_kurtosis_lifecycle`); reuse an existing enclave +2. **Choose the reproduction path** (first viable): restore a snapshot, picking the + restore point per rule 3 (`runbooks://panda_compute_kurtosis_lifecycle`); reuse an existing enclave (`runbooks://kurtosis_devnet`); relaunch the provided config and drive it to the window; synthesize a faithful config (`runbooks://public_devnet_context` + `runbooks://kurtosis_devnet_config`); or investigate the public network live diff --git a/runbooks/kurtosis_devnet_config.md b/runbooks/kurtosis_devnet_config.md index ad20c18c..849031e4 100644 --- a/runbooks/kurtosis_devnet_config.md +++ b/runbooks/kurtosis_devnet_config.md @@ -65,6 +65,38 @@ unless deliberately stressing a stacked boundary — fork/BPO boundary semantics give at least one participant full custody (`supernode: true`) — PeerDAS validation fails in tiny topologies without one; include Buildoor when observing the builder path. +## Minimal args-file + +The load-bearing shape is small — one or two participant pairs, network params, and the +tooling you actually need. Confirm exact keys against the package's `network_params.yaml` +and a shipped example; do NOT reconstruct the schema by reading the package's Starlark +source. + +```yaml # shape to ADAPT — swap images, epochs, and counts for the deployed devnet +participants: + - el_type: geth + el_image: ethpandaops/geth: + cl_type: lighthouse + cl_image: ethpandaops/lighthouse: + use_separate_vc: true + vc_type: lighthouse + validator_count: 64 + supernode: true # ≥1 full-custody node keeps a tiny PeerDAS topology viable + - el_type: nethermind + el_image: ethpandaops/nethermind: + cl_type: teku + cl_image: ethpandaops/teku: + validator_count: 64 +network_params: + preset: mainnet # `minimal` only as a recorded acceleration deviation + seconds_per_slot: 12 + genesis_delay: 60 # short, so the network reaches genesis promptly + # fork activation epochs + blob (BPO) schedule copied from the deployed config, e.g.: + # fulu_fork_epoch: 0 +additional_services: + - dora # add per-participant buildoor when ePBS is in scope +``` + ## Output shape ```yaml diff --git a/runbooks/panda_compute_kurtosis_lifecycle.md b/runbooks/panda_compute_kurtosis_lifecycle.md index 09d44f72..b3b5eebd 100644 --- a/runbooks/panda_compute_kurtosis_lifecycle.md +++ b/runbooks/panda_compute_kurtosis_lifecycle.md @@ -40,22 +40,64 @@ Most mutations return an operation id. Poll `panda compute operations get -- date -u -s ""`) before `kurtosis run`. Genesis time and +every epoch target time are in the SANDBOX clock frame, not the orchestrator's — carry +them forward as such. + +Restore boots a sandbox from a snapshot with `sandboxes create --snapshot`. Keep the +default `--boot-flavor warm`: it resumes the snapshot's memory so the network continues +in its exact in-flight state — same genesis time, same peers. `--boot-flavor cold` does +a fresh boot on the snapshot disk instead (OS and clients restart and resync) — a +reboot, not a continuation; use it only when a cold start is what you want. + +## Teardown + +- **Stop/start** (`sandboxes stop ` … `sandboxes start `) keeps the same + sandbox and its id for a later restart; **pause/resume** only parks the vCPUs. +- **Delete** (`sandboxes delete `) releases the sandbox entirely. Its snapshots + survive independently and stay restorable into new sandboxes, so when a run captures a + snapshot and then hands off through that snapshot, delete the source sandbox — the + snapshot is the durable artifact, not the sandbox. Deleting still requires an explicit + caller request (see Safety). + ## Lifecycle - **Provision:** `panda compute sandboxes create --template --ttl ` → poll → `sandboxes get ` for connection details → confirm Docker + Kurtosis present. When the caller separated provision from deployment, stop after provisioning. -- **Launch:** copy the args file in, `kurtosis run --args-file `; - record package ref, args path, enclave, genesis time, and blocks-produced. Discover - services via `runbooks://kurtosis_devnet`. +- **Launch (sandbox-side):** drive everything inside the sandbox with + `sandboxes exec -- …`. Copy the args file in, then `kurtosis run --args-file + `. Image pulls can take several minutes and outlast a short exec + timeout, so launch detached (`nohup kurtosis run … &`) and poll the log to completion; + on a retry, first clear a stale enclave and its leftover docker network / logs-collector + container (`kurtosis enclave rm -f `, then `docker network rm kt-`) or the + rerun collides. Record package ref, args path, enclave, genesis time, and + blocks-produced. Discover services via `runbooks://kurtosis_devnet`. - **Epoch-aligned snapshots:** snapshot for epoch N at the START of epoch N. Compute `target_time = genesis_time + N * slots_per_epoch * seconds_per_slot` before launch, and use the SANDBOX clock (restored VMs may drift from wall-clock). Snapshot from the orchestrator side only: `panda compute sandboxes snapshot --note "…"` → poll → resolve id. Preserve target-epoch order. -- **Restore:** `panda compute snapshots restore --ttl ` → poll → a - NEW sandbox id (unrelated to the original). Then establish enclave, current - epoch/slot, slot timing, service inventory, and setup summary. +- **Restore:** `panda compute sandboxes create --snapshot --ttl ` + (keep the default `--boot-flavor warm`) → poll → a NEW sandbox id (unrelated to the + original). Genesis time is preserved, so epoch target times still compute from it on + the sandbox clock. Then establish enclave, current epoch/slot, slot timing, service + inventory, and setup summary. - **Final snapshot (watch runs):** exactly one after the end epoch is reached; poll and resolve before any downstream judgment.