diff --git a/.gitignore b/.gitignore index 8e7c724..90c454d 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ web/void-control-ux/tmp_*.mjs # TypeScript incremental build cache (per-machine, regenerated by tsc -b) **/*.tsbuildinfo +**/__pycache__/ +**/*.pyc .github-release-notes.md diff --git a/AGENTS.md b/AGENTS.md index 92ed2fb..3874821 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,10 +28,12 @@ runtime transport concerns should stay separate. - `src/runtime/`: runtime adapter implementations (`MockRuntime`, `VoidBoxRuntimeClient`) - `src/orchestration/`: planning, persistence, scheduling, reduction, strategies - `src/bridge.rs`: HTTP bridge for launch, dry-run, execution inspection, and policy patching +- `src/templates/`: file-backed template schema, loading, and compilation into `ExecutionSpec` - `src/bin/voidctl.rs`: CLI entrypoint and bridge server - `tests/`: orchestration, bridge, runtime, and compatibility coverage - `web/void-control-ux/`: React/Vite operator dashboard - `docs/`: architecture notes, release process, and internal plans/specs +- `templates/`: checked-in template-first API definitions ## Module map @@ -62,6 +64,9 @@ runtime transport concerns should stay separate. - restart/reload of persisted active work - `src/bridge.rs` - serde-gated HTTP routes for UI/bridge workflows + - execution routes plus template-first bridge routes +- `src/templates/` + - phase-1 control template schema, checked-in loader, and compile logic ### Web UI @@ -177,9 +182,59 @@ Important: - `execution events ` - `execution result ` - `execution runtime [candidate-id]` + - `template list` + - `template get ` + - `template dry-run [ | --stdin]` + - `template execute [ | --stdin]` + - `batch dry-run ` + - `batch dry-run --stdin` + - `batch run ` + - `batch run --stdin` + - `yolo dry-run ` + - `yolo dry-run --stdin` + - `yolo run ` + - `yolo run --stdin` + - `team dry-run ` + - `team dry-run --stdin` + - `team run ` + - `team run --stdin` +- interactive `voidctl` console also exposes: + - `/template list` + - `/template get ` + - `/template dry-run ` + - `/template execute ` + - `/batch dry-run ` + - `/batch run ` + - `/yolo dry-run ` + - `/yolo run ` + - `/team dry-run ` + - `/team run ` +- `batch` is the canonical high-level remote background execution surface +- `yolo` is an accepted alias for `batch` +- `team` is the phase-1 high-level multi-agent authoring surface +- current phase-1 `team` limitations: + - `depends_on` is not supported yet + - `sequential` preserves ordering only; task outputs are not threaded between agents - use `voidctl execution ...` for terminal operator workflows; use the bridge HTTP API or UI when you need direct API-driven inspection or browser workflows - quote URLs that contain `?` when using `curl` from `zsh` +- template-first bridge endpoints: + - `GET /v1/templates` + - `GET /v1/templates/{id}` + - `POST /v1/templates/{id}/dry-run` + - `POST /v1/templates/{id}/execute` +- batch bridge endpoints: + - `POST /v1/batch/dry-run` + - `POST /v1/batch/run` + - `GET /v1/batch-runs/{id}` +- accepted aliases: + - `POST /v1/yolo/dry-run` + - `POST /v1/yolo/run` + - `GET /v1/yolo-runs/{id}` +- team bridge endpoints: + - `POST /v1/teams/dry-run` + - `POST /v1/teams/run` + - `GET /v1/team-runs/{id}` ## Runtime compatibility commands diff --git a/README.md b/README.md index 206d9f5..27ad9c0 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ What to look for: - `spec/`: Runtime and orchestration contracts. - `src/`: Rust orchestration client/runtime normalization logic. +- `templates/`: File-backed template-first API definitions for single-agent and warm-agent execution. - `tests/`: Contract and compatibility tests. - `web/void-control-ux/`: React operator dashboard (graph + inspector). @@ -211,6 +212,168 @@ This is also the canonical first-release orchestration workflow: - inspect the execution graph, inspector, and event stream - follow candidate metrics and `leader` / `broadcast` collaboration events +### Template-first bridge API + +Phase 1 also exposes file-backed templates through the bridge: + +```bash +curl -sS http://127.0.0.1:43210/v1/templates + +curl -sS http://127.0.0.1:43210/v1/templates/single-agent-basic + +curl -sS -X POST http://127.0.0.1:43210/v1/templates/single-agent-basic/dry-run \ + -H 'Content-Type: application/json' \ + -d '{ + "inputs": { + "goal": "Summarize this repo", + "prompt": "Read the repo and summarize risks", + "provider": "claude" + } + }' + +curl -sS -X POST http://127.0.0.1:43210/v1/templates/warm-agent-basic/execute \ + -H 'Content-Type: application/json' \ + -d '{ + "inputs": { + "goal": "Keep a warm agent ready", + "prompt": "Stay alive for follow-up repo work." + } + }' +``` + +These template endpoints compile into normal `ExecutionSpec` objects and then +reuse the existing dry-run and execution creation flow. Phase 1 ships two +starter templates: + +- `single-agent-basic` +- `warm-agent-basic` + +Terminal access is also available through `voidctl`: + +```bash +voidctl template list +voidctl template get single-agent-basic +voidctl template dry-run single-agent-basic template-inputs.json +voidctl template execute warm-agent-basic template-inputs.json +``` + +`template-inputs.json` must be a JSON request body in the same shape the bridge +accepts, for example: + +```json +{ + "inputs": { + "goal": "Summarize this repo", + "prompt": "Read the repo and summarize risks", + "provider": "claude" + } +} +``` + +Inside the interactive `voidctl` console, the same surface is available as: + +```text +/template list +/template get single-agent-basic +/template dry-run single-agent-basic template-inputs.json +/template execute warm-agent-basic template-inputs.json +``` + +### Batch / yolo + +`batch` is the canonical high-level surface for remote background work that +fans out one worker template across multiple prompts. `yolo` is an accepted +alias for the same API and CLI path. + +Bridge routes: + +```bash +curl -sS -X POST http://127.0.0.1:43210/v1/batch/dry-run \ + -H 'Content-Type: application/json' \ + -d '{ + "api_version": "v1", + "kind": "batch", + "worker": { + "template": "examples/runtime-templates/warm_agent_basic.yaml", + "provider": "claude" + }, + "mode": { + "parallelism": 2 + }, + "jobs": [ + { "prompt": "Fix failing auth tests" }, + { "prompt": "Improve retry logging" }, + { "prompt": "Review DB migration safety" } + ] + }' + +curl -sS -X POST http://127.0.0.1:43210/v1/yolo/run \ + -H 'Content-Type: application/json' \ + -d '{ + "api_version": "v1", + "kind": "yolo", + "worker": { + "template": "examples/runtime-templates/warm_agent_basic.yaml" + }, + "jobs": [ + { "prompt": "Review migration safety" } + ] + }' +``` + +CLI: + +```bash +voidctl batch dry-run examples/batch/background_repo_work.yaml +voidctl batch run examples/batch/background_repo_work.yaml +cat examples/batch/background_repo_work.yaml | voidctl yolo run --stdin +``` + +Interactive console: + +```text +/batch dry-run examples/batch/background_repo_work.yaml +/batch run examples/batch/background_repo_work.yaml +/yolo run examples/batch/background_repo_work.yaml +``` + +### Team + +`team` is the phase-1 high-level multi-agent authoring surface. Users define +`agents`, `tasks`, and a `process`, and `void-control` compiles that into the +existing orchestration engine. + +Current phase-1 limitation: +- `depends_on` is not supported yet +- `sequential` preserves task ordering, but does not thread task outputs between agents + +HTTP: + +```bash +curl -sS -X POST http://127.0.0.1:43210/v1/teams/dry-run \ + -H 'Content-Type: text/yaml' \ + --data-binary @examples/team/rust_article_team.yaml + +curl -sS -X POST http://127.0.0.1:43210/v1/teams/run \ + -H 'Content-Type: text/yaml' \ + --data-binary @examples/team/rust_article_team.yaml +``` + +CLI: + +```bash +voidctl team dry-run examples/team/rust_article_team.yaml +voidctl team run examples/team/rust_article_team.yaml +cat examples/team/rust_article_team.yaml | voidctl team run --stdin +``` + +Interactive console: + +```text +/team dry-run examples/team/rust_article_team.yaml +/team run examples/team/rust_article_team.yaml +``` + ### 7) Run the supervision example Use the checked-in supervision example to exercise the flat @@ -296,6 +459,37 @@ voidctl execution result voidctl execution runtime ``` +Template-backed agent runs use the `voidctl template ...` surface and expect a +JSON request body on disk or stdin: + +```json +{ + "inputs": { + "goal": "Summarize this repo", + "prompt": "Read the repo and summarize risks", + "provider": "claude" + } +} +``` + +Dry-run and execute a checked-in template: + +```bash +voidctl template list +voidctl template get single-agent-basic +voidctl template dry-run single-agent-basic template-inputs.json +voidctl template execute warm-agent-basic template-inputs.json +``` + +The interactive `voidctl` console exposes the same path: + +```text +/template list +/template get single-agent-basic +/template dry-run single-agent-basic template-inputs.json +/template execute warm-agent-basic template-inputs.json +``` + Example execution: ```text diff --git a/examples/batch/background_repo_work.yaml b/examples/batch/background_repo_work.yaml new file mode 100644 index 0000000..96285b9 --- /dev/null +++ b/examples/batch/background_repo_work.yaml @@ -0,0 +1,22 @@ +api_version: v1 +kind: batch + +metadata: + name: repo-background-work + +worker: + template: examples/runtime-templates/warm_agent_basic.yaml + provider: claude + +mode: + parallelism: 3 + background: true + interaction: none + +jobs: + - name: auth + prompt: Fix failing auth tests + - name: logging + prompt: Improve retry logging + - name: migrations + prompt: Review DB migration safety diff --git a/examples/runtime-templates/warm_agent_basic.yaml b/examples/runtime-templates/warm_agent_basic.yaml new file mode 100644 index 0000000..42c5879 --- /dev/null +++ b/examples/runtime-templates/warm_agent_basic.yaml @@ -0,0 +1,37 @@ +api_version: v1 +kind: agent +name: warm_agent_basic + +sandbox: + mode: auto + memory_mb: 2048 + vcpus: 2 + network: true + +llm: + provider: claude + +agent: + mode: service + skills: + - "agent:claude-code" + messaging: + enabled: false + output_file: /workspace/output.json + prompt: | + You are a reusable long-running service agent. + + Do these steps exactly: + + 1. Read the task request from the provided prompt and complete it. + 2. Write JSON to `/workspace/output.json` with exactly this schema: + { + "status": "success", + "summary": "one-line result summary", + "metrics": { + "success": 1.0 + }, + "artifacts": [] + } + 3. After writing the output file, remain alive for additional follow-up work. + 4. Do not delete `/workspace/output.json`. diff --git a/examples/team/rust_article_team.yaml b/examples/team/rust_article_team.yaml new file mode 100644 index 0000000..bb700de --- /dev/null +++ b/examples/team/rust_article_team.yaml @@ -0,0 +1,26 @@ +api_version: v1 +kind: team + +metadata: + name: rust-article-team + +agents: + - name: researcher + role: Researcher + goal: Find information about Rust performance + + - name: writer + role: Writer + goal: Write a concise article draft + +tasks: + - name: research + description: Gather evidence about Rust performance tradeoffs + agent: researcher + + - name: write + description: Write the article draft after the research pass completes + agent: writer + +process: + type: sequential diff --git a/sdks/go/README.md b/sdks/go/README.md new file mode 100644 index 0000000..3da9cf3 --- /dev/null +++ b/sdks/go/README.md @@ -0,0 +1,27 @@ +# void-control Go SDK + +Go client for the `void-control` bridge API. + +The first supported surface is: + +- templates list/get/dry-run/execute +- executions get/wait +- batch run/dry-run/get/wait +- yolo run/dry-run/get/wait + +Examples under `examples/` are bridge examples against `void-control`: + +- `template_execute` +- `batch_run` + +`batch` is the canonical remote-background execution API. `yolo` is an alias +for the same high-level surface. + +They are not ComputeSDK compatibility examples yet. A real ComputeSDK adapter +still needs to model the sandbox lifecycle and action contract: + +- `compute.sandbox.create` +- `compute.sandbox.runCode` +- `compute.sandbox.runCommand` +- filesystem actions +- `compute.sandbox.destroy` diff --git a/sdks/go/batch.go b/sdks/go/batch.go new file mode 100644 index 0000000..ae4fd18 --- /dev/null +++ b/sdks/go/batch.go @@ -0,0 +1,42 @@ +package voidcontrol + +import "time" + +type BatchClient struct { + client *Client + routeBase string +} + +type BatchRunsClient struct { + client *Client + routeBase string +} + +func (client *BatchClient) Run(spec map[string]any) (*BatchRunResult, error) { + var response BatchRunResult + if err := client.client.postJSON(client.routeBase+"/run", spec, &response); err != nil { + return nil, err + } + return &response, nil +} + +func (client *BatchRunsClient) Get(runID string) (*BatchRunDetail, error) { + var response BatchRunDetail + if err := client.client.getJSON(client.routeBase+"-runs/"+runID, &response); err != nil { + return nil, err + } + return &response, nil +} + +func (client *BatchRunsClient) Wait(runID string) (*BatchRunDetail, error) { + for { + detail, err := client.Get(runID) + if err != nil { + return nil, err + } + if detail.Execution.Status == "Completed" || detail.Execution.Status == "Failed" || detail.Execution.Status == "Canceled" { + return detail, nil + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/sdks/go/client.go b/sdks/go/client.go new file mode 100644 index 0000000..02b6f1c --- /dev/null +++ b/sdks/go/client.go @@ -0,0 +1,76 @@ +package voidcontrol + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "strings" +) + +type Client struct { + BaseURL string + HTTPClient *http.Client + Templates *TemplatesClient + Executions *ExecutionsClient + Batch *BatchClient + BatchRuns *BatchRunsClient + Yolo *BatchClient + YoloRuns *BatchRunsClient +} + +func NewClient(baseURL string) *Client { + client := &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + HTTPClient: http.DefaultClient, + } + client.Templates = &TemplatesClient{client: client} + client.Executions = &ExecutionsClient{client: client} + client.Batch = &BatchClient{client: client, routeBase: "/v1/batch"} + client.BatchRuns = &BatchRunsClient{client: client, routeBase: "/v1/batch"} + client.Yolo = &BatchClient{client: client, routeBase: "/v1/yolo"} + client.YoloRuns = &BatchRunsClient{client: client, routeBase: "/v1/yolo"} + return client +} + +func (client *Client) getJSON(path string, out any) error { + req, err := http.NewRequest(http.MethodGet, client.BaseURL+path, nil) + if err != nil { + return err + } + return client.do(req, out) +} + +func (client *Client) postJSON(path string, payload any, out any) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, client.BaseURL+path, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + return client.do(req, out) +} + +func (client *Client) do(req *http.Request, out any) error { + response, err := client.HTTPClient.Do(req) + if err != nil { + return err + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return err + } + if response.StatusCode >= 400 { + var bridgeErr BridgeError + if err := json.Unmarshal(body, &bridgeErr); err != nil { + return err + } + return &bridgeErr + } + return json.Unmarshal(body, out) +} diff --git a/sdks/go/client_test.go b/sdks/go/client_test.go new file mode 100644 index 0000000..b80646d --- /dev/null +++ b/sdks/go/client_test.go @@ -0,0 +1,361 @@ +package voidcontrol + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "testing" +) + +func TestClientExposesTemplateAndExecutionClients(t *testing.T) { + client := NewClient("http://127.0.0.1:43210") + + if client.BaseURL != "http://127.0.0.1:43210" { + t.Fatalf("BaseURL = %q", client.BaseURL) + } + if client.Templates == nil { + t.Fatalf("Templates client should be initialized") + } + if client.Executions == nil { + t.Fatalf("Executions client should be initialized") + } + if client.Batch == nil { + t.Fatalf("Batch client should be initialized") + } + if client.BatchRuns == nil { + t.Fatalf("BatchRuns client should be initialized") + } + if client.Yolo == nil { + t.Fatalf("Yolo client should be initialized") + } + if client.YoloRuns == nil { + t.Fatalf("YoloRuns client should be initialized") + } +} + +func TestTemplateAndExecutionMethods(t *testing.T) { + responses := []map[string]any{ + { + "templates": []map[string]any{ + { + "id": "benchmark-runner-python", + "name": "Benchmark Runner Python", + "execution_kind": "execution", + "description": "Compare multiple Python benchmark candidates in one swarm execution.", + }, + }, + }, + { + "template": map[string]any{ + "id": "benchmark-runner-python", + "name": "Benchmark Runner Python", + "execution_kind": "execution", + "description": "Compare multiple Python benchmark candidates in one swarm execution.", + }, + "inputs": map[string]any{ + "goal": map[string]any{"type": "string", "required": true}, + "snapshot": map[string]any{"type": "string", "required": false}, + }, + "defaults": map[string]any{ + "workflow_template": "examples/runtime-templates/transform_optimizer_agent.yaml", + }, + "compile": map[string]any{"bindings": []any{}}, + }, + { + "template": map[string]any{ + "id": "benchmark-runner-python", + "execution_kind": "execution", + }, + "inputs": map[string]any{ + "goal": "Compare transform benchmark candidates", + "provider": "claude", + }, + "compiled": map[string]any{ + "goal": "Compare transform benchmark candidates", + "workflow_template": "examples/runtime-templates/transform_optimizer_agent.yaml", + "mode": "swarm", + "variation_source": "explicit", + "candidates_per_iteration": float64(3), + "candidate_overrides": []map[string]any{ + {"sandbox.env.TRANSFORM_ROLE": "latency-baseline"}, + {"sandbox.env.TRANSFORM_ROLE": "cache-locality"}, + {"sandbox.env.TRANSFORM_ROLE": "max-throughput"}, + }, + "overrides": map[string]any{ + "sandbox.env.TRANSFORM_ROLE": "latency-baseline", + }, + }, + }, + { + "execution_id": "exec-benchmark-1", + "template": map[string]any{ + "id": "benchmark-runner-python", + "execution_kind": "execution", + }, + "status": "Pending", + "goal": "Compare transform benchmark candidates", + }, + { + "execution": map[string]any{ + "execution_id": "exec-benchmark-1", + "goal": "Compare transform benchmark candidates", + "status": "Pending", + }, + "progress": map[string]any{}, + "result": map[string]any{"best_candidate_id": nil, "completed_iterations": float64(0), "total_candidate_failures": float64(0)}, + "candidates": []any{}, + }, + { + "execution": map[string]any{ + "execution_id": "exec-benchmark-1", + "goal": "Compare transform benchmark candidates", + "status": "Pending", + }, + "progress": map[string]any{}, + "result": map[string]any{"best_candidate_id": nil, "completed_iterations": float64(0), "total_candidate_failures": float64(0)}, + "candidates": []any{}, + }, + { + "execution": map[string]any{ + "execution_id": "exec-benchmark-1", + "goal": "Compare transform benchmark candidates", + "status": "Completed", + }, + "progress": map[string]any{}, + "result": map[string]any{"best_candidate_id": "candidate-2", "completed_iterations": float64(1), "total_candidate_failures": float64(0)}, + "candidates": []any{}, + }, + } + requests := make([]string, 0, len(responses)) + + client := NewClient("http://void-control.test") + client.HTTPClient = &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + requests = append(requests, r.Method+" "+r.URL.Path) + if len(responses) == 0 { + t.Fatalf("received unexpected request %s %s", r.Method, r.URL.Path) + } + body, err := json.Marshal(responses[0]) + if err != nil { + t.Fatalf("marshal response: %v", err) + } + responses = responses[1:] + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), + Request: r, + }, nil + }), + } + + templates, err := client.Templates.List() + if err != nil { + t.Fatalf("Templates.List: %v", err) + } + template, err := client.Templates.Get("benchmark-runner-python") + if err != nil { + t.Fatalf("Templates.Get: %v", err) + } + dryRun, err := client.Templates.DryRun("benchmark-runner-python", map[string]any{ + "inputs": map[string]any{ + "goal": "Compare transform benchmark candidates", + "provider": "claude", + }, + }) + if err != nil { + t.Fatalf("Templates.DryRun: %v", err) + } + execution, err := client.Templates.Execute("benchmark-runner-python", map[string]any{ + "inputs": map[string]any{ + "goal": "Compare transform benchmark candidates", + "provider": "claude", + }, + }) + if err != nil { + t.Fatalf("Templates.Execute: %v", err) + } + detail, err := client.Executions.Get("exec-benchmark-1") + if err != nil { + t.Fatalf("Executions.Get: %v", err) + } + waited, err := client.Executions.Wait("exec-benchmark-1") + if err != nil { + t.Fatalf("Executions.Wait: %v", err) + } + + if templates[0].ID != "benchmark-runner-python" { + t.Fatalf("templates[0].ID = %q", templates[0].ID) + } + if template.ID != "benchmark-runner-python" { + t.Fatalf("template.ID = %q", template.ID) + } + if dryRun.Compiled.CandidatesPerIteration != 3 { + t.Fatalf("dryRun.Compiled.CandidatesPerIteration = %d", dryRun.Compiled.CandidatesPerIteration) + } + if dryRun.Compiled.CandidateOverrides[2]["sandbox.env.TRANSFORM_ROLE"] != "max-throughput" { + t.Fatalf("unexpected candidate override: %#v", dryRun.Compiled.CandidateOverrides[2]) + } + if execution.ExecutionID != "exec-benchmark-1" { + t.Fatalf("execution.ExecutionID = %q", execution.ExecutionID) + } + if detail.Execution.Status != "Pending" { + t.Fatalf("detail.Execution.Status = %q", detail.Execution.Status) + } + if waited.Execution.Status != "Completed" { + t.Fatalf("waited.Execution.Status = %q", waited.Execution.Status) + } + if waited.Result.BestCandidateID != "candidate-2" { + t.Fatalf("waited.Result.BestCandidateID = %q", waited.Result.BestCandidateID) + } + if len(requests) != 7 { + t.Fatalf("len(requests) = %d", len(requests)) + } +} + +func TestBatchAndYoloMethods(t *testing.T) { + responses := []map[string]any{ + { + "kind": "batch", + "run_id": "exec-batch-1", + "execution_id": "exec-batch-1", + "compiled_primitive": "swarm", + "status": "Pending", + "goal": "repo-background-work", + }, + { + "kind": "batch", + "run_id": "exec-batch-1", + "execution": map[string]any{ + "execution_id": "exec-batch-1", + "goal": "repo-background-work", + "status": "Pending", + }, + "progress": map[string]any{}, + "result": map[string]any{"best_candidate_id": nil, "completed_iterations": float64(0), "total_candidate_failures": float64(0)}, + "candidates": []any{}, + }, + { + "kind": "batch", + "run_id": "exec-batch-1", + "execution": map[string]any{ + "execution_id": "exec-batch-1", + "goal": "repo-background-work", + "status": "Completed", + }, + "progress": map[string]any{}, + "result": map[string]any{"best_candidate_id": "candidate-2", "completed_iterations": float64(1), "total_candidate_failures": float64(0)}, + "candidates": []any{}, + }, + { + "kind": "batch", + "run_id": "exec-yolo-1", + "execution_id": "exec-yolo-1", + "compiled_primitive": "swarm", + "status": "Pending", + "goal": "run 1 background jobs", + }, + { + "kind": "batch", + "run_id": "exec-yolo-1", + "execution": map[string]any{ + "execution_id": "exec-yolo-1", + "goal": "run 1 background jobs", + "status": "Completed", + }, + "progress": map[string]any{}, + "result": map[string]any{"best_candidate_id": nil, "completed_iterations": float64(1), "total_candidate_failures": float64(0)}, + "candidates": []any{}, + }, + } + requests := make([]string, 0, len(responses)) + + client := NewClient("http://void-control.test") + client.HTTPClient = &http.Client{ + Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) { + requests = append(requests, r.Method+" "+r.URL.Path) + if len(responses) == 0 { + t.Fatalf("received unexpected request %s %s", r.Method, r.URL.Path) + } + body, err := json.Marshal(responses[0]) + if err != nil { + t.Fatalf("marshal response: %v", err) + } + responses = responses[1:] + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader(body)), + Request: r, + }, nil + }), + } + + batchRun, err := client.Batch.Run(map[string]any{ + "api_version": "v1", + "kind": "batch", + "worker": map[string]any{ + "template": "examples/runtime-templates/warm_agent_basic.yaml", + }, + "jobs": []map[string]any{ + {"prompt": "Fix failing auth tests"}, + }, + }) + if err != nil { + t.Fatalf("Batch.Run: %v", err) + } + batchDetail, err := client.BatchRuns.Get("exec-batch-1") + if err != nil { + t.Fatalf("BatchRuns.Get: %v", err) + } + waitedBatch, err := client.BatchRuns.Wait("exec-batch-1") + if err != nil { + t.Fatalf("BatchRuns.Wait: %v", err) + } + yoloRun, err := client.Yolo.Run(map[string]any{ + "api_version": "v1", + "kind": "yolo", + "worker": map[string]any{ + "template": "examples/runtime-templates/warm_agent_basic.yaml", + }, + "jobs": []map[string]any{ + {"prompt": "Review migration safety"}, + }, + }) + if err != nil { + t.Fatalf("Yolo.Run: %v", err) + } + waitedYolo, err := client.YoloRuns.Wait("exec-yolo-1") + if err != nil { + t.Fatalf("YoloRuns.Wait: %v", err) + } + + if batchRun.Kind != "batch" { + t.Fatalf("batchRun.Kind = %q", batchRun.Kind) + } + if batchRun.RunID != "exec-batch-1" { + t.Fatalf("batchRun.RunID = %q", batchRun.RunID) + } + if batchDetail.Execution.ExecutionID != "exec-batch-1" { + t.Fatalf("batchDetail.Execution.ExecutionID = %q", batchDetail.Execution.ExecutionID) + } + if waitedBatch.Execution.Status != "Completed" { + t.Fatalf("waitedBatch.Execution.Status = %q", waitedBatch.Execution.Status) + } + if yoloRun.RunID != "exec-yolo-1" { + t.Fatalf("yoloRun.RunID = %q", yoloRun.RunID) + } + if waitedYolo.Execution.Status != "Completed" { + t.Fatalf("waitedYolo.Execution.Status = %q", waitedYolo.Execution.Status) + } + if len(requests) != 5 { + t.Fatalf("len(requests) = %d", len(requests)) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return fn(r) +} diff --git a/sdks/go/examples/batch_run/main.go b/sdks/go/examples/batch_run/main.go new file mode 100644 index 0000000..dd64139 --- /dev/null +++ b/sdks/go/examples/batch_run/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + voidcontrol "github.com/the-void-ia/void-control/sdks/go" +) + +func main() { + baseURL := getenvDefault("VOID_CONTROL_BASE_URL", "http://127.0.0.1:43210") + route := getenvDefault("VOID_CONTROL_BATCH_ROUTE", "batch") + spec := map[string]any{ + "api_version": "v1", + "kind": route, + "worker": map[string]any{ + "template": "examples/runtime-templates/warm_agent_basic.yaml", + }, + "jobs": []map[string]any{ + { + "prompt": getenvDefault("VOID_CONTROL_BATCH_PROMPT_ONE", "Fix failing auth tests"), + }, + { + "prompt": getenvDefault("VOID_CONTROL_BATCH_PROMPT_TWO", "Improve retry logging"), + }, + }, + } + + client := voidcontrol.NewClient(baseURL) + runner := client.Batch + runs := client.BatchRuns + if route == "yolo" { + runner = client.Yolo + runs = client.YoloRuns + } + + started, err := runner.Run(spec) + if err != nil { + panic(err) + } + detail, err := runs.Wait(started.RunID) + if err != nil { + panic(err) + } + + output, err := json.MarshalIndent(map[string]any{ + "route": route, + "run_id": started.RunID, + "kind": started.Kind, + "status": detail.Execution.Status, + "execution_id": detail.Execution.ExecutionID, + }, "", " ") + if err != nil { + panic(err) + } + fmt.Println(string(output)) +} + +func getenvDefault(key string, fallback string) string { + value := os.Getenv(key) + if value == "" { + return fallback + } + return value +} diff --git a/sdks/go/examples/template_execute/main.go b/sdks/go/examples/template_execute/main.go new file mode 100644 index 0000000..5a79333 --- /dev/null +++ b/sdks/go/examples/template_execute/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + + voidcontrol "github.com/the-void-ia/void-control/sdks/go" +) + +func main() { + baseURL := getenvDefault("VOID_CONTROL_BASE_URL", "http://127.0.0.1:43210") + templateID := getenvDefault("VOID_CONTROL_TEMPLATE_ID", "benchmark-runner-python") + inputs := map[string]any{ + "goal": getenvDefault("VOID_CONTROL_TEMPLATE_GOAL", "Compare transform benchmark candidates"), + "provider": getenvDefault("VOID_CONTROL_TEMPLATE_PROVIDER", "claude"), + } + if snapshot := os.Getenv("VOID_CONTROL_TEMPLATE_SNAPSHOT"); snapshot != "" { + inputs["snapshot"] = snapshot + } + + client := voidcontrol.NewClient(baseURL) + execution, err := client.Templates.Execute(templateID, map[string]any{"inputs": inputs}) + if err != nil { + panic(err) + } + detail, err := client.Executions.Wait(execution.ExecutionID) + if err != nil { + panic(err) + } + + output, err := json.MarshalIndent(map[string]any{ + "template_id": templateID, + "execution_id": execution.ExecutionID, + "status": detail.Execution.Status, + "best_candidate_id": detail.Result.BestCandidateID, + "completed_iterations": detail.Result.CompletedIterations, + "total_candidate_failures": detail.Result.TotalCandidateFailures, + }, "", " ") + if err != nil { + panic(err) + } + fmt.Println(string(output)) +} + +func getenvDefault(key string, fallback string) string { + value := os.Getenv(key) + if value == "" { + return fallback + } + return value +} diff --git a/sdks/go/executions.go b/sdks/go/executions.go new file mode 100644 index 0000000..04c3e2c --- /dev/null +++ b/sdks/go/executions.go @@ -0,0 +1,28 @@ +package voidcontrol + +import "time" + +type ExecutionsClient struct { + client *Client +} + +func (client *ExecutionsClient) Get(executionID string) (*ExecutionDetail, error) { + var response ExecutionDetail + if err := client.client.getJSON("/v1/executions/"+executionID, &response); err != nil { + return nil, err + } + return &response, nil +} + +func (client *ExecutionsClient) Wait(executionID string) (*ExecutionDetail, error) { + for { + detail, err := client.Get(executionID) + if err != nil { + return nil, err + } + if detail.Execution.Status == "Completed" || detail.Execution.Status == "Failed" || detail.Execution.Status == "Canceled" { + return detail, nil + } + time.Sleep(10 * time.Millisecond) + } +} diff --git a/sdks/go/go.mod b/sdks/go/go.mod new file mode 100644 index 0000000..00aa7da --- /dev/null +++ b/sdks/go/go.mod @@ -0,0 +1,3 @@ +module github.com/the-void-ia/void-control/sdks/go + +go 1.25.0 diff --git a/sdks/go/models.go b/sdks/go/models.go new file mode 100644 index 0000000..80c670c --- /dev/null +++ b/sdks/go/models.go @@ -0,0 +1,94 @@ +package voidcontrol + +type BridgeError struct { + Message string `json:"message"` + Code string `json:"code"` + Retryable bool `json:"retryable"` +} + +func (err *BridgeError) Error() string { + return err.Message +} + +type TemplateSummary struct { + ID string `json:"id"` + Name string `json:"name"` + ExecutionKind string `json:"execution_kind"` + Description string `json:"description"` +} + +type TemplateDetail struct { + ID string + Name string + ExecutionKind string + Description string + Inputs map[string]any + WorkflowTemplate string + Bindings []map[string]any +} + +type CompiledPreview struct { + Goal string `json:"goal"` + WorkflowTemplate string `json:"workflow_template"` + Mode string `json:"mode"` + VariationSource string `json:"variation_source"` + CandidatesPerIteration int `json:"candidates_per_iteration"` + CandidateOverrides []map[string]string `json:"candidate_overrides"` + Overrides map[string]string `json:"overrides"` +} + +type TemplateDryRunResult struct { + Template struct { + ID string `json:"id"` + ExecutionKind string `json:"execution_kind"` + } `json:"template"` + Inputs map[string]any `json:"inputs"` + Compiled CompiledPreview `json:"compiled"` +} + +type TemplateExecutionResult struct { + ExecutionID string `json:"execution_id"` + Template struct { + ID string `json:"id"` + ExecutionKind string `json:"execution_kind"` + } `json:"template"` + Status string `json:"status"` + Goal string `json:"goal"` +} + +type ExecutionRecord struct { + ExecutionID string `json:"execution_id"` + Goal string `json:"goal"` + Status string `json:"status"` +} + +type ExecutionResult struct { + BestCandidateID string `json:"best_candidate_id"` + CompletedIterations int `json:"completed_iterations"` + TotalCandidateFailures int `json:"total_candidate_failures"` +} + +type ExecutionDetail struct { + Execution ExecutionRecord `json:"execution"` + Progress map[string]any `json:"progress"` + Result ExecutionResult `json:"result"` + Candidates []any `json:"candidates"` +} + +type BatchRunResult struct { + Kind string `json:"kind"` + RunID string `json:"run_id"` + ExecutionID string `json:"execution_id"` + CompiledPrimitive string `json:"compiled_primitive"` + Status string `json:"status"` + Goal string `json:"goal"` +} + +type BatchRunDetail struct { + Kind string `json:"kind"` + RunID string `json:"run_id"` + Execution ExecutionRecord `json:"execution"` + Progress map[string]any `json:"progress"` + Result ExecutionResult `json:"result"` + Candidates []any `json:"candidates"` +} diff --git a/sdks/go/templates.go b/sdks/go/templates.go new file mode 100644 index 0000000..f49df7b --- /dev/null +++ b/sdks/go/templates.go @@ -0,0 +1,65 @@ +package voidcontrol + +type templateListResponse struct { + Templates []TemplateSummary `json:"templates"` +} + +type rawTemplateDetail struct { + Template struct { + ID string `json:"id"` + Name string `json:"name"` + ExecutionKind string `json:"execution_kind"` + Description string `json:"description"` + } `json:"template"` + Inputs map[string]any `json:"inputs"` + Defaults struct { + WorkflowTemplate string `json:"workflow_template"` + } `json:"defaults"` + Compile struct { + Bindings []map[string]any `json:"bindings"` + } `json:"compile"` +} + +type TemplatesClient struct { + client *Client +} + +func (client *TemplatesClient) List() ([]TemplateSummary, error) { + var response templateListResponse + if err := client.client.getJSON("/v1/templates", &response); err != nil { + return nil, err + } + return response.Templates, nil +} + +func (client *TemplatesClient) Get(templateID string) (*TemplateDetail, error) { + var raw rawTemplateDetail + if err := client.client.getJSON("/v1/templates/"+templateID, &raw); err != nil { + return nil, err + } + return &TemplateDetail{ + ID: raw.Template.ID, + Name: raw.Template.Name, + ExecutionKind: raw.Template.ExecutionKind, + Description: raw.Template.Description, + Inputs: raw.Inputs, + WorkflowTemplate: raw.Defaults.WorkflowTemplate, + Bindings: raw.Compile.Bindings, + }, nil +} + +func (client *TemplatesClient) DryRun(templateID string, request map[string]any) (*TemplateDryRunResult, error) { + var response TemplateDryRunResult + if err := client.client.postJSON("/v1/templates/"+templateID+"/dry-run", request, &response); err != nil { + return nil, err + } + return &response, nil +} + +func (client *TemplatesClient) Execute(templateID string, request map[string]any) (*TemplateExecutionResult, error) { + var response TemplateExecutionResult + if err := client.client.postJSON("/v1/templates/"+templateID+"/execute", request, &response); err != nil { + return nil, err + } + return &response, nil +} diff --git a/sdks/node/README.md b/sdks/node/README.md new file mode 100644 index 0000000..eb6a9e8 --- /dev/null +++ b/sdks/node/README.md @@ -0,0 +1,37 @@ +# void-control Node SDK + +Async-first Node client for the `void-control` bridge API. + +## Quick start + +```js +import { VoidControlClient } from "./src/index.js"; + +const client = new VoidControlClient({ baseUrl: "http://127.0.0.1:43210" }); +``` + +The first supported surface is: + +- `client.templates` +- `client.executions` +- `client.batch` +- `client.batchRuns` +- `client.yolo` +- `client.yoloRuns` + +Examples under `examples/` are bridge examples against `void-control`: + +- `templateExecute.mjs` +- `batchRun.mjs` + +`batch` is the canonical remote-background execution API. `yolo` is an alias +for the same high-level surface. + +They are not ComputeSDK compatibility examples yet. A real ComputeSDK adapter +still needs to model the sandbox lifecycle and action contract: + +- `compute.sandbox.create` +- `compute.sandbox.runCode` +- `compute.sandbox.runCommand` +- filesystem actions +- `compute.sandbox.destroy` diff --git a/sdks/node/examples/batchRun.mjs b/sdks/node/examples/batchRun.mjs new file mode 100644 index 0000000..185f5ef --- /dev/null +++ b/sdks/node/examples/batchRun.mjs @@ -0,0 +1,42 @@ +import { VoidControlClient } from "../src/index.js"; + +const baseUrl = process.env.VOID_CONTROL_BASE_URL ?? "http://127.0.0.1:43210"; +const route = process.env.VOID_CONTROL_BATCH_ROUTE ?? "batch"; + +const spec = { + api_version: "v1", + kind: route, + worker: { + template: "examples/runtime-templates/warm_agent_basic.yaml" + }, + jobs: [ + { + prompt: + process.env.VOID_CONTROL_BATCH_PROMPT_ONE ?? "Fix failing auth tests" + }, + { + prompt: + process.env.VOID_CONTROL_BATCH_PROMPT_TWO ?? "Improve retry logging" + } + ] +}; + +const client = new VoidControlClient({ baseUrl }); +const runner = route === "yolo" ? client.yolo : client.batch; +const runs = route === "yolo" ? client.yoloRuns : client.batchRuns; +const started = await runner.run(spec); +const detail = await runs.wait(started.runId, { pollIntervalMs: 2000 }); + +console.log( + JSON.stringify( + { + route, + runId: started.runId, + kind: started.kind, + status: detail.execution.status, + executionId: detail.execution.executionId + }, + null, + 2 + ) +); diff --git a/sdks/node/examples/templateExecute.mjs b/sdks/node/examples/templateExecute.mjs new file mode 100644 index 0000000..5a5a0f4 --- /dev/null +++ b/sdks/node/examples/templateExecute.mjs @@ -0,0 +1,36 @@ +import { VoidControlClient } from "../src/index.js"; + +const baseUrl = process.env.VOID_CONTROL_BASE_URL ?? "http://127.0.0.1:43210"; +const templateId = process.env.VOID_CONTROL_TEMPLATE_ID ?? "benchmark-runner-python"; + +const inputs = { + goal: + process.env.VOID_CONTROL_TEMPLATE_GOAL ?? + "Compare transform benchmark candidates", + provider: process.env.VOID_CONTROL_TEMPLATE_PROVIDER ?? "claude" +}; + +if (process.env.VOID_CONTROL_TEMPLATE_SNAPSHOT) { + inputs.snapshot = process.env.VOID_CONTROL_TEMPLATE_SNAPSHOT; +} + +const client = new VoidControlClient({ baseUrl }); +const execution = await client.templates.execute(templateId, { inputs }); +const detail = await client.executions.wait(execution.executionId, { + pollIntervalMs: 2000 +}); + +console.log( + JSON.stringify( + { + templateId, + executionId: execution.executionId, + status: detail.execution.status, + bestCandidateId: detail.result.bestCandidateId, + completedIterations: detail.result.completedIterations, + totalCandidateFailures: detail.result.totalCandidateFailures + }, + null, + 2 + ) +); diff --git a/sdks/node/src/batch.js b/sdks/node/src/batch.js new file mode 100644 index 0000000..3f7218a --- /dev/null +++ b/sdks/node/src/batch.js @@ -0,0 +1,60 @@ +const TERMINAL_STATUSES = new Set(["Completed", "Failed", "Canceled"]); + +export class BatchClient { + constructor(client, { routeBase }) { + this._client = client; + this._routeBase = routeBase; + } + + async run(spec) { + const payload = await this._client.postJson(`${this._routeBase}/run`, spec); + return { + kind: String(payload.kind), + runId: String(payload.run_id), + executionId: String(payload.execution_id), + compiledPrimitive: String(payload.compiled_primitive), + status: String(payload.status), + goal: String(payload.goal) + }; + } +} + +export class BatchRunsClient { + constructor(client, { routeBase }) { + this._client = client; + this._routeBase = routeBase; + } + + async get(runId) { + const payload = await this._client.getJson(`${this._routeBase}-runs/${runId}`); + return { + kind: String(payload.kind), + runId: String(payload.run_id), + execution: { + executionId: String(payload.execution.execution_id), + goal: String(payload.execution.goal), + status: String(payload.execution.status) + }, + progress: payload.progress ?? {}, + result: { + bestCandidateId: + payload.result?.best_candidate_id == null + ? null + : String(payload.result.best_candidate_id), + completedIterations: Number(payload.result?.completed_iterations ?? 0), + totalCandidateFailures: Number(payload.result?.total_candidate_failures ?? 0) + }, + candidates: payload.candidates ?? [] + }; + } + + async wait(runId, { pollIntervalMs = 1000 } = {}) { + while (true) { + const detail = await this.get(runId); + if (TERMINAL_STATUSES.has(detail.execution.status)) { + return detail; + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + } +} diff --git a/sdks/node/src/client.js b/sdks/node/src/client.js new file mode 100644 index 0000000..8db76e7 --- /dev/null +++ b/sdks/node/src/client.js @@ -0,0 +1,46 @@ +import { BatchClient, BatchRunsClient } from "./batch.js"; +import { ExecutionsClient } from "./executions.js"; +import { TemplatesClient } from "./templates.js"; +import { BridgeError } from "./models.js"; + +export class VoidControlClient { + constructor({ baseUrl, fetchImpl = fetch } = {}) { + this.baseUrl = String(baseUrl ?? "").replace(/\/+$/, ""); + this._fetch = fetchImpl; + this.templates = new TemplatesClient(this); + this.executions = new ExecutionsClient(this); + this.batch = new BatchClient(this, { routeBase: "/v1/batch" }); + this.batchRuns = new BatchRunsClient(this, { routeBase: "/v1/batch" }); + this.yolo = new BatchClient(this, { routeBase: "/v1/yolo" }); + this.yoloRuns = new BatchRunsClient(this, { routeBase: "/v1/yolo" }); + } + + async getJson(path) { + const response = await this._fetch(`${this.baseUrl}${path}`, { + method: "GET" + }); + return this.#decodeResponse(response); + } + + async postJson(path, payload) { + const response = await this._fetch(`${this.baseUrl}${path}`, { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify(payload) + }); + return this.#decodeResponse(response); + } + + async #decodeResponse(response) { + const payload = await response.json(); + if (!response.ok) { + throw new BridgeError(payload.message ?? `bridge returned HTTP ${response.status}`, { + code: payload.code ?? null, + retryable: payload.retryable ?? null + }); + } + return payload; + } +} diff --git a/sdks/node/src/executions.js b/sdks/node/src/executions.js new file mode 100644 index 0000000..9a1e9df --- /dev/null +++ b/sdks/node/src/executions.js @@ -0,0 +1,24 @@ +import { toExecutionDetail } from "./models.js"; + +const TERMINAL_STATUSES = new Set(["Completed", "Failed", "Canceled"]); + +export class ExecutionsClient { + constructor(client) { + this._client = client; + } + + async get(executionId) { + const payload = await this._client.getJson(`/v1/executions/${executionId}`); + return toExecutionDetail(payload); + } + + async wait(executionId, { pollIntervalMs = 1000 } = {}) { + while (true) { + const detail = await this.get(executionId); + if (TERMINAL_STATUSES.has(detail.execution.status)) { + return detail; + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + } +} diff --git a/sdks/node/src/index.js b/sdks/node/src/index.js new file mode 100644 index 0000000..ee87d31 --- /dev/null +++ b/sdks/node/src/index.js @@ -0,0 +1 @@ +export { VoidControlClient } from "./client.js"; diff --git a/sdks/node/src/models.js b/sdks/node/src/models.js new file mode 100644 index 0000000..b5326f1 --- /dev/null +++ b/sdks/node/src/models.js @@ -0,0 +1,76 @@ +export class BridgeError extends Error { + constructor(message, { code = null, retryable = null } = {}) { + super(message); + this.name = "BridgeError"; + this.code = code; + this.retryable = retryable; + } +} + +export function toTemplateSummary(payload) { + return { + id: String(payload.id), + name: String(payload.name), + executionKind: String(payload.execution_kind), + description: String(payload.description) + }; +} + +export function toTemplateDetail(payload) { + return { + id: String(payload.template.id), + name: String(payload.template.name), + executionKind: String(payload.template.execution_kind), + description: String(payload.template.description), + inputs: payload.inputs ?? {}, + workflowTemplate: String(payload.defaults?.workflow_template ?? ""), + bindings: payload.compile?.bindings ?? [] + }; +} + +export function toTemplateDryRunResult(payload) { + return { + templateId: String(payload.template.id), + executionKind: String(payload.template.execution_kind), + inputs: payload.inputs ?? {}, + compiled: { + goal: String(payload.compiled.goal), + workflowTemplate: String(payload.compiled.workflow_template), + mode: String(payload.compiled.mode), + variationSource: String(payload.compiled.variation_source), + candidatesPerIteration: Number(payload.compiled.candidates_per_iteration), + candidateOverrides: payload.compiled.candidate_overrides ?? [], + overrides: payload.compiled.overrides ?? {} + } + }; +} + +export function toTemplateExecutionResult(payload) { + return { + executionId: String(payload.execution_id), + templateId: String(payload.template.id), + executionKind: String(payload.template.execution_kind), + status: String(payload.status), + goal: String(payload.goal) + }; +} + +export function toExecutionDetail(payload) { + return { + execution: { + executionId: String(payload.execution.execution_id), + goal: String(payload.execution.goal), + status: String(payload.execution.status) + }, + progress: payload.progress ?? {}, + result: { + bestCandidateId: + payload.result?.best_candidate_id == null + ? null + : String(payload.result.best_candidate_id), + completedIterations: Number(payload.result?.completed_iterations ?? 0), + totalCandidateFailures: Number(payload.result?.total_candidate_failures ?? 0) + }, + candidates: payload.candidates ?? [] + }; +} diff --git a/sdks/node/src/templates.js b/sdks/node/src/templates.js new file mode 100644 index 0000000..a61eb30 --- /dev/null +++ b/sdks/node/src/templates.js @@ -0,0 +1,38 @@ +import { + toTemplateDetail, + toTemplateDryRunResult, + toTemplateExecutionResult, + toTemplateSummary +} from "./models.js"; + +export class TemplatesClient { + constructor(client) { + this._client = client; + } + + async list() { + const payload = await this._client.getJson("/v1/templates"); + return (payload.templates ?? []).map(toTemplateSummary); + } + + async get(templateId) { + const payload = await this._client.getJson(`/v1/templates/${templateId}`); + return toTemplateDetail(payload); + } + + async dryRun(templateId, request) { + const payload = await this._client.postJson( + `/v1/templates/${templateId}/dry-run`, + request + ); + return toTemplateDryRunResult(payload); + } + + async execute(templateId, request) { + const payload = await this._client.postJson( + `/v1/templates/${templateId}/execute`, + request + ); + return toTemplateExecutionResult(payload); + } +} diff --git a/sdks/node/test/client.test.mjs b/sdks/node/test/client.test.mjs new file mode 100644 index 0000000..1eb9db3 --- /dev/null +++ b/sdks/node/test/client.test.mjs @@ -0,0 +1,307 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { VoidControlClient } from "../src/index.js"; + +test("client exposes template and execution subclients", () => { + const client = new VoidControlClient({ baseUrl: "http://127.0.0.1:43210" }); + + assert.equal(client.baseUrl, "http://127.0.0.1:43210"); + assert.ok(client.templates); + assert.ok(client.executions); + assert.ok(client.batch); + assert.ok(client.batchRuns); + assert.ok(client.yolo); + assert.ok(client.yoloRuns); +}); + +test("template and execution methods use the bridge API", async () => { + const responses = [ + { + templates: [ + { + id: "benchmark-runner-python", + name: "Benchmark Runner Python", + execution_kind: "execution", + description: "Compare multiple Python benchmark candidates in one swarm execution." + } + ] + }, + { + template: { + id: "benchmark-runner-python", + name: "Benchmark Runner Python", + execution_kind: "execution", + description: "Compare multiple Python benchmark candidates in one swarm execution." + }, + inputs: { + goal: { type: "string", required: true, description: "Goal" }, + snapshot: { type: "string", required: false, description: "Snapshot" } + }, + defaults: { + workflow_template: "examples/runtime-templates/transform_optimizer_agent.yaml" + }, + compile: { bindings: [] } + }, + { + template: { + id: "benchmark-runner-python", + execution_kind: "execution" + }, + inputs: { + goal: "Compare transform benchmark candidates", + provider: "claude" + }, + compiled: { + goal: "Compare transform benchmark candidates", + workflow_template: "examples/runtime-templates/transform_optimizer_agent.yaml", + mode: "swarm", + variation_source: "explicit", + candidates_per_iteration: 3, + candidate_overrides: [ + { "sandbox.env.TRANSFORM_ROLE": "latency-baseline" }, + { "sandbox.env.TRANSFORM_ROLE": "cache-locality" }, + { "sandbox.env.TRANSFORM_ROLE": "max-throughput" } + ], + overrides: { "sandbox.env.TRANSFORM_ROLE": "latency-baseline" } + } + }, + { + execution_id: "exec-benchmark-1", + template: { + id: "benchmark-runner-python", + execution_kind: "execution" + }, + status: "Pending", + goal: "Compare transform benchmark candidates" + }, + { + execution: { + execution_id: "exec-benchmark-1", + goal: "Compare transform benchmark candidates", + status: "Pending" + }, + progress: {}, + result: { + best_candidate_id: null, + completed_iterations: 0, + total_candidate_failures: 0 + }, + candidates: [] + }, + { + execution: { + execution_id: "exec-benchmark-1", + goal: "Compare transform benchmark candidates", + status: "Pending" + }, + progress: {}, + result: { + best_candidate_id: null, + completed_iterations: 0, + total_candidate_failures: 0 + }, + candidates: [] + }, + { + execution: { + execution_id: "exec-benchmark-1", + goal: "Compare transform benchmark candidates", + status: "Completed" + }, + progress: {}, + result: { + best_candidate_id: "candidate-2", + completed_iterations: 1, + total_candidate_failures: 0 + }, + candidates: [] + } + ]; + const requests = []; + const fetchImpl = async (url, init = {}) => { + const body = init.body ?? null; + requests.push({ + method: init.method ?? "GET", + path: new URL(url).pathname, + body + }); + const payload = responses.shift(); + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" } + }); + }; + + const client = new VoidControlClient({ + baseUrl: "http://127.0.0.1:43210", + fetchImpl + }); + + const templates = await client.templates.list(); + const template = await client.templates.get("benchmark-runner-python"); + const dryRun = await client.templates.dryRun("benchmark-runner-python", { + inputs: { + goal: "Compare transform benchmark candidates", + provider: "claude" + } + }); + const execution = await client.templates.execute("benchmark-runner-python", { + inputs: { + goal: "Compare transform benchmark candidates", + provider: "claude" + } + }); + const detail = await client.executions.get("exec-benchmark-1"); + const waited = await client.executions.wait("exec-benchmark-1", { + pollIntervalMs: 0 + }); + + assert.equal(templates[0].id, "benchmark-runner-python"); + assert.equal(template.id, "benchmark-runner-python"); + assert.equal(dryRun.compiled.candidatesPerIteration, 3); + assert.equal( + dryRun.compiled.candidateOverrides[2]["sandbox.env.TRANSFORM_ROLE"], + "max-throughput" + ); + assert.equal(execution.executionId, "exec-benchmark-1"); + assert.equal(detail.execution.status, "Pending"); + assert.equal(waited.execution.status, "Completed"); + assert.equal(waited.result.bestCandidateId, "candidate-2"); + + assert.deepEqual(requests[0], { + method: "GET", + path: "/v1/templates", + body: null + }); + assert.deepEqual(requests[1], { + method: "GET", + path: "/v1/templates/benchmark-runner-python", + body: null + }); + assert.equal(requests[2].method, "POST"); + assert.equal(requests[2].path, "/v1/templates/benchmark-runner-python/dry-run"); + assert.equal(requests[3].path, "/v1/templates/benchmark-runner-python/execute"); + assert.equal(requests[4].path, "/v1/executions/exec-benchmark-1"); +}); + +test("batch and yolo methods use the bridge API", async () => { + const responses = [ + { + kind: "batch", + run_id: "exec-batch-1", + execution_id: "exec-batch-1", + compiled_primitive: "swarm", + status: "Pending", + goal: "repo-background-work" + }, + { + kind: "batch", + run_id: "exec-batch-1", + execution: { + execution_id: "exec-batch-1", + goal: "repo-background-work", + status: "Pending" + }, + progress: {}, + result: { + best_candidate_id: null, + completed_iterations: 0, + total_candidate_failures: 0 + }, + candidates: [] + }, + { + kind: "batch", + run_id: "exec-batch-1", + execution: { + execution_id: "exec-batch-1", + goal: "repo-background-work", + status: "Completed" + }, + progress: {}, + result: { + best_candidate_id: "candidate-2", + completed_iterations: 1, + total_candidate_failures: 0 + }, + candidates: [] + }, + { + kind: "batch", + run_id: "exec-yolo-1", + execution_id: "exec-yolo-1", + compiled_primitive: "swarm", + status: "Pending", + goal: "run 1 background jobs" + }, + { + kind: "batch", + run_id: "exec-yolo-1", + execution: { + execution_id: "exec-yolo-1", + goal: "run 1 background jobs", + status: "Completed" + }, + progress: {}, + result: { + best_candidate_id: null, + completed_iterations: 1, + total_candidate_failures: 0 + }, + candidates: [] + } + ]; + const requests = []; + const fetchImpl = async (url, init = {}) => { + const body = init.body ?? null; + requests.push({ + method: init.method ?? "GET", + path: new URL(url).pathname, + body + }); + const payload = responses.shift(); + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json" } + }); + }; + + const client = new VoidControlClient({ + baseUrl: "http://127.0.0.1:43210", + fetchImpl + }); + + const batchRun = await client.batch.run({ + api_version: "v1", + kind: "batch", + worker: { template: "examples/runtime-templates/warm_agent_basic.yaml" }, + jobs: [{ prompt: "Fix failing auth tests" }] + }); + const batchDetail = await client.batchRuns.get("exec-batch-1"); + const waitedBatch = await client.batchRuns.wait("exec-batch-1", { + pollIntervalMs: 0 + }); + const yoloRun = await client.yolo.run({ + api_version: "v1", + kind: "yolo", + worker: { template: "examples/runtime-templates/warm_agent_basic.yaml" }, + jobs: [{ prompt: "Review migration safety" }] + }); + const waitedYolo = await client.yoloRuns.wait("exec-yolo-1", { + pollIntervalMs: 0 + }); + + assert.equal(batchRun.kind, "batch"); + assert.equal(batchRun.runId, "exec-batch-1"); + assert.equal(batchDetail.execution.executionId, "exec-batch-1"); + assert.equal(waitedBatch.execution.status, "Completed"); + assert.equal(yoloRun.runId, "exec-yolo-1"); + assert.equal(waitedYolo.execution.status, "Completed"); + + assert.equal(requests[0].path, "/v1/batch/run"); + assert.equal(requests[1].path, "/v1/batch-runs/exec-batch-1"); + assert.equal(requests[2].path, "/v1/batch-runs/exec-batch-1"); + assert.equal(requests[3].path, "/v1/yolo/run"); + assert.equal(requests[4].path, "/v1/yolo-runs/exec-yolo-1"); +}); diff --git a/sdks/python/README.md b/sdks/python/README.md new file mode 100644 index 0000000..8206b66 --- /dev/null +++ b/sdks/python/README.md @@ -0,0 +1,37 @@ +# void-control Python SDK + +Async-first Python client for the `void-control` bridge API. + +## Quick start + +```python +from void_control import VoidControlClient + +client = VoidControlClient(base_url="http://127.0.0.1:43210") +``` + +The first supported surface is: + +- `client.templates` +- `client.executions` +- `client.batch` +- `client.batch_runs` +- `client.yolo` +- `client.yolo_runs` + +Examples under `examples/` are bridge examples against `void-control`: + +- `template_execute.py` +- `batch_run.py` + +`batch` is the canonical remote-background execution API. `yolo` is an alias +for the same high-level surface. + +They are not ComputeSDK compatibility examples yet. A real ComputeSDK adapter +still needs to model the sandbox lifecycle and action contract: + +- `compute.sandbox.create` +- `compute.sandbox.runCode` +- `compute.sandbox.runCommand` +- filesystem actions +- `compute.sandbox.destroy` diff --git a/sdks/python/examples/batch_run.py b/sdks/python/examples/batch_run.py new file mode 100644 index 0000000..72db733 --- /dev/null +++ b/sdks/python/examples/batch_run.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import asyncio +import json +import os + +from void_control import VoidControlClient + + +async def main() -> None: + base_url = os.environ.get("VOID_CONTROL_BASE_URL", "http://127.0.0.1:43210") + route = os.environ.get("VOID_CONTROL_BATCH_ROUTE", "batch") + prompt_one = os.environ.get( + "VOID_CONTROL_BATCH_PROMPT_ONE", + "Fix failing auth tests", + ) + prompt_two = os.environ.get( + "VOID_CONTROL_BATCH_PROMPT_TWO", + "Improve retry logging", + ) + spec = { + "api_version": "v1", + "kind": route, + "worker": { + "template": "examples/runtime-templates/warm_agent_basic.yaml", + }, + "jobs": [ + {"prompt": prompt_one}, + {"prompt": prompt_two}, + ], + } + + async with VoidControlClient(base_url=base_url) as client: + runner = client.yolo if route == "yolo" else client.batch + runs = client.yolo_runs if route == "yolo" else client.batch_runs + started = await runner.run(spec) + detail = await runs.wait(started.run_id, poll_interval=2.0) + + print( + json.dumps( + { + "route": route, + "run_id": started.run_id, + "kind": started.kind, + "status": detail.execution.status, + "execution_id": detail.execution.execution_id, + }, + indent=2, + ) + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdks/python/examples/template_execute.py b/sdks/python/examples/template_execute.py new file mode 100644 index 0000000..26fe116 --- /dev/null +++ b/sdks/python/examples/template_execute.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import asyncio +import json +import os + +from void_control import VoidControlClient + + +async def main() -> None: + base_url = os.environ.get("VOID_CONTROL_BASE_URL", "http://127.0.0.1:43210") + template_id = os.environ.get("VOID_CONTROL_TEMPLATE_ID", "benchmark-runner-python") + inputs = { + "goal": os.environ.get( + "VOID_CONTROL_TEMPLATE_GOAL", + "Compare transform benchmark candidates", + ), + "provider": os.environ.get("VOID_CONTROL_TEMPLATE_PROVIDER", "claude"), + } + snapshot = os.environ.get("VOID_CONTROL_TEMPLATE_SNAPSHOT") + if snapshot: + inputs["snapshot"] = snapshot + + async with VoidControlClient(base_url=base_url) as client: + execution = await client.templates.execute(template_id, inputs=inputs) + detail = await client.executions.wait(execution.execution_id, poll_interval=2.0) + + print( + json.dumps( + { + "template_id": template_id, + "execution_id": execution.execution_id, + "status": detail.execution.status, + "best_candidate_id": detail.result.best_candidate_id, + "completed_iterations": detail.result.completed_iterations, + "total_candidate_failures": detail.result.total_candidate_failures, + }, + indent=2, + ) + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdks/python/pyproject.toml b/sdks/python/pyproject.toml new file mode 100644 index 0000000..dbcdb40 --- /dev/null +++ b/sdks/python/pyproject.toml @@ -0,0 +1,19 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "void-control" +version = "0.1.0" +description = "Async-first Python SDK for the void-control bridge API." +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "httpx>=0.28,<0.29", +] + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/sdks/python/src/void_control/__init__.py b/sdks/python/src/void_control/__init__.py new file mode 100644 index 0000000..079a98b --- /dev/null +++ b/sdks/python/src/void_control/__init__.py @@ -0,0 +1,3 @@ +from .client import VoidControlClient + +__all__ = ["VoidControlClient"] diff --git a/sdks/python/src/void_control/batch.py b/sdks/python/src/void_control/batch.py new file mode 100644 index 0000000..ae4ea79 --- /dev/null +++ b/sdks/python/src/void_control/batch.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +import asyncio +from typing import Any + +from .models import BatchRunDetail, BatchRunResult + + +class BatchClient: + def __init__(self, client: object, *, route_base: str) -> None: + self._client = client + self._route_base = route_base + + async def run(self, spec: dict[str, Any]) -> BatchRunResult: + payload = await self._client.post_json(f"{self._route_base}/run", spec) + return BatchRunResult.from_json(payload) + + +class BatchRunsClient: + def __init__(self, client: object, *, route_base: str) -> None: + self._client = client + self._route_base = route_base + + async def get(self, run_id: str) -> BatchRunDetail: + payload = await self._client.get_json(f"{self._route_base}-runs/{run_id}") + return BatchRunDetail.from_json(payload) + + async def wait(self, run_id: str, *, poll_interval: float = 1.0) -> BatchRunDetail: + while True: + detail = await self.get(run_id) + if detail.execution.status in {"Completed", "Failed", "Canceled"}: + return detail + await asyncio.sleep(poll_interval) diff --git a/sdks/python/src/void_control/client.py b/sdks/python/src/void_control/client.py new file mode 100644 index 0000000..07dbfcc --- /dev/null +++ b/sdks/python/src/void_control/client.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from typing import Any + +import httpx + +from .batch import BatchClient, BatchRunsClient +from .executions import ExecutionsClient +from .templates import TemplatesClient +from .models import BridgeError + + +class VoidControlClient: + def __init__( + self, + base_url: str, + *, + transport: httpx.AsyncBaseTransport | None = None, + timeout: float = 30.0, + ) -> None: + self.base_url = base_url.rstrip("/") + self._http = httpx.AsyncClient( + base_url=self.base_url, + transport=transport, + timeout=timeout, + ) + self.templates = TemplatesClient(self) + self.executions = ExecutionsClient(self) + self.batch = BatchClient(self, route_base="/v1/batch") + self.batch_runs = BatchRunsClient(self, route_base="/v1/batch") + self.yolo = BatchClient(self, route_base="/v1/yolo") + self.yolo_runs = BatchRunsClient(self, route_base="/v1/yolo") + + async def aclose(self) -> None: + await self._http.aclose() + + async def __aenter__(self) -> "VoidControlClient": + return self + + async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + await self.aclose() + + async def get_json(self, path: str) -> dict[str, Any]: + response = await self._http.get(path) + return await self._decode_response(response) + + async def post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: + response = await self._http.post(path, json=payload) + return await self._decode_response(response) + + async def _decode_response(self, response: httpx.Response) -> dict[str, Any]: + data = response.json() + if response.status_code >= 400: + raise BridgeError( + message=str(data.get("message", f"bridge returned HTTP {response.status_code}")), + code=None if data.get("code") is None else str(data.get("code")), + retryable=data.get("retryable"), + ) + return dict(data) diff --git a/sdks/python/src/void_control/executions.py b/sdks/python/src/void_control/executions.py new file mode 100644 index 0000000..392611d --- /dev/null +++ b/sdks/python/src/void_control/executions.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import asyncio + +from .models import ExecutionDetail + + +TERMINAL_STATUSES = {"Completed", "Failed", "Canceled"} + + +class ExecutionsClient: + def __init__(self, client: object) -> None: + self._client = client + + async def get(self, execution_id: str) -> ExecutionDetail: + payload = await self._client.get_json(f"/v1/executions/{execution_id}") + return ExecutionDetail.from_json(payload) + + async def wait( + self, + execution_id: str, + *, + poll_interval: float = 1.0, + ) -> ExecutionDetail: + while True: + detail = await self.get(execution_id) + if detail.execution.status in TERMINAL_STATUSES: + return detail + await asyncio.sleep(poll_interval) diff --git a/sdks/python/src/void_control/models.py b/sdks/python/src/void_control/models.py new file mode 100644 index 0000000..5e1db25 --- /dev/null +++ b/sdks/python/src/void_control/models.py @@ -0,0 +1,208 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(slots=True) +class BridgeError(Exception): + message: str + code: str | None = None + retryable: bool | None = None + + def __str__(self) -> str: + return self.message + + +@dataclass(slots=True) +class TemplateSummary: + id: str + name: str + execution_kind: str + description: str + + @classmethod + def from_json(cls, payload: dict[str, Any]) -> "TemplateSummary": + return cls( + id=str(payload["id"]), + name=str(payload["name"]), + execution_kind=str(payload["execution_kind"]), + description=str(payload["description"]), + ) + + +@dataclass(slots=True) +class TemplateDetail: + id: str + name: str + execution_kind: str + description: str + inputs: dict[str, Any] + workflow_template: str + bindings: list[dict[str, Any]] + + @classmethod + def from_json(cls, payload: dict[str, Any]) -> "TemplateDetail": + template = payload["template"] + defaults = payload.get("defaults", {}) + compile_data = payload.get("compile", {}) + return cls( + id=str(template["id"]), + name=str(template["name"]), + execution_kind=str(template["execution_kind"]), + description=str(template["description"]), + inputs=dict(payload.get("inputs", {})), + workflow_template=str(defaults.get("workflow_template", "")), + bindings=list(compile_data.get("bindings", [])), + ) + + +@dataclass(slots=True) +class CompiledTemplatePreview: + goal: str + workflow_template: str + mode: str + variation_source: str + candidates_per_iteration: int + candidate_overrides: list[dict[str, str]] + overrides: dict[str, str] + + @classmethod + def from_json(cls, payload: dict[str, Any]) -> "CompiledTemplatePreview": + return cls( + goal=str(payload["goal"]), + workflow_template=str(payload["workflow_template"]), + mode=str(payload["mode"]), + variation_source=str(payload["variation_source"]), + candidates_per_iteration=int(payload["candidates_per_iteration"]), + candidate_overrides=[dict(item) for item in payload.get("candidate_overrides", [])], + overrides=dict(payload.get("overrides", {})), + ) + + +@dataclass(slots=True) +class TemplateDryRunResult: + template_id: str + execution_kind: str + inputs: dict[str, Any] + compiled: CompiledTemplatePreview + + @classmethod + def from_json(cls, payload: dict[str, Any]) -> "TemplateDryRunResult": + template = payload["template"] + return cls( + template_id=str(template["id"]), + execution_kind=str(template["execution_kind"]), + inputs=dict(payload.get("inputs", {})), + compiled=CompiledTemplatePreview.from_json(dict(payload["compiled"])), + ) + + +@dataclass(slots=True) +class TemplateExecutionResult: + execution_id: str + template_id: str + execution_kind: str + status: str + goal: str + + @classmethod + def from_json(cls, payload: dict[str, Any]) -> "TemplateExecutionResult": + template = payload["template"] + return cls( + execution_id=str(payload["execution_id"]), + template_id=str(template["id"]), + execution_kind=str(template["execution_kind"]), + status=str(payload["status"]), + goal=str(payload["goal"]), + ) + + +@dataclass(slots=True) +class ExecutionRecord: + execution_id: str + goal: str + status: str + + @classmethod + def from_json(cls, payload: dict[str, Any]) -> "ExecutionRecord": + return cls( + execution_id=str(payload["execution_id"]), + goal=str(payload["goal"]), + status=str(payload["status"]), + ) + + +@dataclass(slots=True) +class ExecutionResult: + best_candidate_id: str | None + completed_iterations: int + total_candidate_failures: int + + @classmethod + def from_json(cls, payload: dict[str, Any]) -> "ExecutionResult": + best_candidate = payload.get("best_candidate_id") + return cls( + best_candidate_id=None if best_candidate is None else str(best_candidate), + completed_iterations=int(payload.get("completed_iterations", 0)), + total_candidate_failures=int(payload.get("total_candidate_failures", 0)), + ) + + +@dataclass(slots=True) +class ExecutionDetail: + execution: ExecutionRecord + progress: dict[str, Any] + result: ExecutionResult + candidates: list[dict[str, Any]] + + @classmethod + def from_json(cls, payload: dict[str, Any]) -> "ExecutionDetail": + return cls( + execution=ExecutionRecord.from_json(dict(payload["execution"])), + progress=dict(payload.get("progress", {})), + result=ExecutionResult.from_json(dict(payload.get("result", {}))), + candidates=list(payload.get("candidates", [])), + ) + + +@dataclass(slots=True) +class BatchRunResult: + kind: str + run_id: str + execution_id: str + compiled_primitive: str + status: str + goal: str + + @classmethod + def from_json(cls, payload: dict[str, Any]) -> "BatchRunResult": + return cls( + kind=str(payload["kind"]), + run_id=str(payload["run_id"]), + execution_id=str(payload["execution_id"]), + compiled_primitive=str(payload["compiled_primitive"]), + status=str(payload["status"]), + goal=str(payload["goal"]), + ) + + +@dataclass(slots=True) +class BatchRunDetail: + kind: str + run_id: str + execution: ExecutionRecord + progress: dict[str, Any] + result: ExecutionResult + candidates: list[dict[str, Any]] + + @classmethod + def from_json(cls, payload: dict[str, Any]) -> "BatchRunDetail": + return cls( + kind=str(payload["kind"]), + run_id=str(payload["run_id"]), + execution=ExecutionRecord.from_json(dict(payload["execution"])), + progress=dict(payload.get("progress", {})), + result=ExecutionResult.from_json(dict(payload.get("result", {}))), + candidates=list(payload.get("candidates", [])), + ) diff --git a/sdks/python/src/void_control/templates.py b/sdks/python/src/void_control/templates.py new file mode 100644 index 0000000..2eaf47c --- /dev/null +++ b/sdks/python/src/void_control/templates.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from typing import Any + +from .models import ( + TemplateDetail, + TemplateDryRunResult, + TemplateExecutionResult, + TemplateSummary, +) + +class TemplatesClient: + def __init__(self, client: object) -> None: + self._client = client + + async def list(self) -> list[TemplateSummary]: + payload = await self._client.get_json("/v1/templates") + return [ + TemplateSummary.from_json(dict(item)) + for item in payload.get("templates", []) + ] + + async def get(self, template_id: str) -> TemplateDetail: + payload = await self._client.get_json(f"/v1/templates/{template_id}") + return TemplateDetail.from_json(payload) + + async def dry_run( + self, + template_id: str, + *, + inputs: dict[str, Any], + ) -> TemplateDryRunResult: + payload = await self._client.post_json( + f"/v1/templates/{template_id}/dry-run", + {"inputs": inputs}, + ) + return TemplateDryRunResult.from_json(payload) + + async def execute( + self, + template_id: str, + *, + inputs: dict[str, Any], + ) -> TemplateExecutionResult: + payload = await self._client.post_json( + f"/v1/templates/{template_id}/execute", + {"inputs": inputs}, + ) + return TemplateExecutionResult.from_json(payload) diff --git a/sdks/python/tests/test_client.py b/sdks/python/tests/test_client.py new file mode 100644 index 0000000..cd09872 --- /dev/null +++ b/sdks/python/tests/test_client.py @@ -0,0 +1,309 @@ +import os +import sys +import unittest + +import httpx + + +SDK_SRC = os.path.join( + os.path.dirname(__file__), + "..", + "src", +) +sys.path.insert(0, os.path.abspath(SDK_SRC)) + + +class ClientScaffoldTest(unittest.TestCase): + def test_client_exposes_template_and_execution_subclients(self) -> None: + from void_control import VoidControlClient + + client = VoidControlClient(base_url="http://127.0.0.1:43210") + + self.assertEqual(client.base_url, "http://127.0.0.1:43210") + self.assertIsNotNone(client.templates) + self.assertIsNotNone(client.executions) + self.assertIsNotNone(client.batch) + self.assertIsNotNone(client.batch_runs) + self.assertIsNotNone(client.yolo) + self.assertIsNotNone(client.yolo_runs) + + +class ClientMethodsTest(unittest.IsolatedAsyncioTestCase): + async def test_template_and_execution_methods(self) -> None: + from void_control import VoidControlClient + + responses = [ + { + "templates": [ + { + "id": "benchmark-runner-python", + "name": "Benchmark Runner Python", + "execution_kind": "execution", + "description": "Compare multiple Python benchmark candidates in one swarm execution.", + } + ] + }, + { + "template": { + "id": "benchmark-runner-python", + "name": "Benchmark Runner Python", + "execution_kind": "execution", + "description": "Compare multiple Python benchmark candidates in one swarm execution.", + }, + "inputs": { + "goal": {"type": "string", "required": True, "description": "Goal"}, + "snapshot": {"type": "string", "required": False, "description": "Snapshot"}, + }, + "defaults": { + "workflow_template": "examples/runtime-templates/transform_optimizer_agent.yaml" + }, + "compile": {"bindings": []}, + }, + { + "template": { + "id": "benchmark-runner-python", + "execution_kind": "execution", + }, + "inputs": { + "goal": "Compare transform benchmark candidates", + "provider": "claude", + }, + "compiled": { + "goal": "Compare transform benchmark candidates", + "workflow_template": "examples/runtime-templates/transform_optimizer_agent.yaml", + "mode": "swarm", + "variation_source": "explicit", + "candidates_per_iteration": 3, + "candidate_overrides": [ + {"sandbox.env.TRANSFORM_ROLE": "latency-baseline"}, + {"sandbox.env.TRANSFORM_ROLE": "cache-locality"}, + {"sandbox.env.TRANSFORM_ROLE": "max-throughput"}, + ], + "overrides": {"sandbox.env.TRANSFORM_ROLE": "latency-baseline"}, + }, + }, + { + "execution_id": "exec-benchmark-1", + "template": { + "id": "benchmark-runner-python", + "execution_kind": "execution", + }, + "status": "Pending", + "goal": "Compare transform benchmark candidates", + }, + { + "execution": { + "execution_id": "exec-benchmark-1", + "goal": "Compare transform benchmark candidates", + "status": "Pending", + }, + "progress": {}, + "result": { + "best_candidate_id": None, + "completed_iterations": 0, + "total_candidate_failures": 0, + }, + "candidates": [], + }, + { + "execution": { + "execution_id": "exec-benchmark-1", + "goal": "Compare transform benchmark candidates", + "status": "Pending", + }, + "progress": {}, + "result": { + "best_candidate_id": None, + "completed_iterations": 0, + "total_candidate_failures": 0, + }, + "candidates": [], + }, + { + "execution": { + "execution_id": "exec-benchmark-1", + "goal": "Compare transform benchmark candidates", + "status": "Completed", + }, + "progress": {}, + "result": { + "best_candidate_id": "candidate-2", + "completed_iterations": 1, + "total_candidate_failures": 0, + }, + "candidates": [], + }, + ] + requests: list[tuple[str, str, str | None]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + body = request.content.decode() if request.content else None + requests.append((request.method, request.url.path, body)) + payload = responses.pop(0) + return httpx.Response(200, json=payload) + + client = VoidControlClient( + base_url="http://127.0.0.1:43210", + transport=httpx.MockTransport(handler), + ) + + templates = await client.templates.list() + template = await client.templates.get("benchmark-runner-python") + dry_run = await client.templates.dry_run( + "benchmark-runner-python", + inputs={ + "goal": "Compare transform benchmark candidates", + "provider": "claude", + }, + ) + execution = await client.templates.execute( + "benchmark-runner-python", + inputs={ + "goal": "Compare transform benchmark candidates", + "provider": "claude", + }, + ) + detail = await client.executions.get("exec-benchmark-1") + waited = await client.executions.wait( + "exec-benchmark-1", + poll_interval=0.0, + ) + await client.aclose() + + self.assertEqual(templates[0].id, "benchmark-runner-python") + self.assertEqual(template.id, "benchmark-runner-python") + self.assertEqual(dry_run.compiled.candidates_per_iteration, 3) + self.assertEqual( + dry_run.compiled.candidate_overrides[2]["sandbox.env.TRANSFORM_ROLE"], + "max-throughput", + ) + self.assertEqual(execution.execution_id, "exec-benchmark-1") + self.assertEqual(detail.execution.status, "Pending") + self.assertEqual(waited.execution.status, "Completed") + self.assertEqual(waited.result.best_candidate_id, "candidate-2") + + self.assertEqual(requests[0][:2], ("GET", "/v1/templates")) + self.assertEqual(requests[1][:2], ("GET", "/v1/templates/benchmark-runner-python")) + self.assertEqual(requests[2][:2], ("POST", "/v1/templates/benchmark-runner-python/dry-run")) + self.assertEqual(requests[3][:2], ("POST", "/v1/templates/benchmark-runner-python/execute")) + self.assertEqual(requests[4][:2], ("GET", "/v1/executions/exec-benchmark-1")) + + async def test_batch_and_yolo_methods(self) -> None: + from void_control import VoidControlClient + + responses = [ + { + "kind": "batch", + "run_id": "exec-batch-1", + "execution_id": "exec-batch-1", + "compiled_primitive": "swarm", + "status": "Pending", + "goal": "repo-background-work", + }, + { + "kind": "batch", + "run_id": "exec-batch-1", + "execution": { + "execution_id": "exec-batch-1", + "goal": "repo-background-work", + "status": "Pending", + }, + "progress": {}, + "result": { + "best_candidate_id": None, + "completed_iterations": 0, + "total_candidate_failures": 0, + }, + "candidates": [], + }, + { + "kind": "batch", + "run_id": "exec-batch-1", + "execution": { + "execution_id": "exec-batch-1", + "goal": "repo-background-work", + "status": "Completed", + }, + "progress": {}, + "result": { + "best_candidate_id": "candidate-2", + "completed_iterations": 1, + "total_candidate_failures": 0, + }, + "candidates": [], + }, + { + "kind": "batch", + "run_id": "exec-yolo-1", + "execution_id": "exec-yolo-1", + "compiled_primitive": "swarm", + "status": "Pending", + "goal": "run 1 background jobs", + }, + { + "kind": "batch", + "run_id": "exec-yolo-1", + "execution": { + "execution_id": "exec-yolo-1", + "goal": "run 1 background jobs", + "status": "Completed", + }, + "progress": {}, + "result": { + "best_candidate_id": None, + "completed_iterations": 1, + "total_candidate_failures": 0, + }, + "candidates": [], + }, + ] + requests: list[tuple[str, str, str | None]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + body = request.content.decode() if request.content else None + requests.append((request.method, request.url.path, body)) + payload = responses.pop(0) + return httpx.Response(200, json=payload) + + client = VoidControlClient( + base_url="http://127.0.0.1:43210", + transport=httpx.MockTransport(handler), + ) + + batch_run = await client.batch.run( + { + "api_version": "v1", + "kind": "batch", + "worker": {"template": "examples/runtime-templates/warm_agent_basic.yaml"}, + "jobs": [{"prompt": "Fix failing auth tests"}], + } + ) + batch_detail = await client.batch_runs.get("exec-batch-1") + waited_batch = await client.batch_runs.wait("exec-batch-1", poll_interval=0.0) + yolo_run = await client.yolo.run( + { + "api_version": "v1", + "kind": "yolo", + "worker": {"template": "examples/runtime-templates/warm_agent_basic.yaml"}, + "jobs": [{"prompt": "Review migration safety"}], + } + ) + waited_yolo = await client.yolo_runs.wait("exec-yolo-1", poll_interval=0.0) + await client.aclose() + + self.assertEqual(batch_run.kind, "batch") + self.assertEqual(batch_run.run_id, "exec-batch-1") + self.assertEqual(batch_detail.execution.execution_id, "exec-batch-1") + self.assertEqual(waited_batch.execution.status, "Completed") + self.assertEqual(yolo_run.run_id, "exec-yolo-1") + self.assertEqual(waited_yolo.execution.status, "Completed") + + self.assertEqual(requests[0][:2], ("POST", "/v1/batch/run")) + self.assertEqual(requests[1][:2], ("GET", "/v1/batch-runs/exec-batch-1")) + self.assertEqual(requests[2][:2], ("GET", "/v1/batch-runs/exec-batch-1")) + self.assertEqual(requests[3][:2], ("POST", "/v1/yolo/run")) + self.assertEqual(requests[4][:2], ("GET", "/v1/yolo-runs/exec-yolo-1")) + + +if __name__ == "__main__": + unittest.main() diff --git a/src/batch/compile.rs b/src/batch/compile.rs new file mode 100644 index 0000000..67c5651 --- /dev/null +++ b/src/batch/compile.rs @@ -0,0 +1,104 @@ +use std::collections::BTreeMap; + +use crate::orchestration::{ + BudgetPolicy, ConcurrencyPolicy, ConvergencePolicy, EvaluationConfig, ExecutionSpec, + GlobalConfig, OrchestrationPolicy, VariationConfig, VariationProposal, WorkflowTemplateRef, +}; + +use super::{BatchSpec, BatchValidationError}; + +/// Compiles a [`BatchSpec`] into a normal [`ExecutionSpec`]. +/// +/// # Examples +/// +/// ``` +/// let spec = void_control::batch::parse_batch_yaml( +/// r#" +/// api_version: v1 +/// kind: batch +/// worker: +/// template: examples/runtime-templates/warm_agent_basic.yaml +/// jobs: +/// - prompt: Fix failing auth tests +/// "#, +/// ) +/// .expect("parse batch"); +/// let execution = void_control::batch::compile_batch_spec(&spec).expect("compile batch"); +/// assert_eq!(execution.mode, "swarm"); +/// ``` +/// +/// # Errors +/// +/// Returns [`BatchValidationError`] if the compiled execution spec is invalid. +pub fn compile_batch_spec(spec: &BatchSpec) -> Result { + let parallelism = match spec.mode.as_ref().and_then(|mode| mode.parallelism) { + Some(parallelism) => parallelism, + None => spec.jobs.len() as u32, + }; + + let mut explicit = Vec::new(); + for job in &spec.jobs { + let mut overrides = BTreeMap::new(); + overrides.insert("agent.prompt".to_string(), job.prompt.clone()); + if let Some(provider) = &spec.worker.provider { + overrides.insert("llm.provider".to_string(), provider.clone()); + } + explicit.push(VariationProposal { overrides }); + } + + let max_child_runs = spec.jobs.len() as u32; + let execution = ExecutionSpec { + mode: "swarm".to_string(), + goal: batch_goal(spec), + workflow: WorkflowTemplateRef { + template: spec.worker.template.clone(), + }, + policy: OrchestrationPolicy { + budget: BudgetPolicy { + max_iterations: Some(1), + max_child_runs: Some(max_child_runs), + max_wall_clock_secs: Some(1800), + max_cost_usd_millis: None, + }, + concurrency: ConcurrencyPolicy { + max_concurrent_candidates: parallelism, + }, + convergence: ConvergencePolicy { + strategy: "exhaustive".to_string(), + min_score: None, + max_iterations_without_improvement: None, + }, + max_candidate_failures_per_iteration: max_child_runs, + missing_output_policy: "mark_failed".to_string(), + iteration_failure_policy: "continue".to_string(), + }, + evaluation: EvaluationConfig { + scoring_type: "weighted_metrics".to_string(), + weights: BTreeMap::from([("success".to_string(), 1.0)]), + pass_threshold: Some(1.0), + ranking: "highest_score".to_string(), + tie_breaking: "success".to_string(), + }, + variation: VariationConfig::explicit(parallelism, explicit), + swarm: true, + supervision: None, + }; + execution + .validate(&GlobalConfig { + max_concurrent_child_runs: 20, + }) + .map_err(|err| { + BatchValidationError::new(format!("compiled execution spec is invalid: {err}")) + })?; + Ok(execution) +} + +fn batch_goal(spec: &BatchSpec) -> String { + let Some(metadata) = &spec.metadata else { + return format!("run {} background jobs", spec.jobs.len()); + }; + let Some(name) = &metadata.name else { + return format!("run {} background jobs", spec.jobs.len()); + }; + name.clone() +} diff --git a/src/batch/mod.rs b/src/batch/mod.rs new file mode 100644 index 0000000..3f17bd4 --- /dev/null +++ b/src/batch/mod.rs @@ -0,0 +1,14 @@ +//! Batch authoring and compilation helpers. + +mod compile; +mod schema; + +/// Compiles a [`BatchSpec`] into a normal execution plan. +pub use compile::compile_batch_spec; +pub use schema::{ + parse_batch_json, parse_batch_yaml, BatchJob, BatchMetadata, BatchMode, BatchSpec, + BatchValidationError, BatchWorker, +}; + +/// Marks the public batch module for compile-time tests. +pub struct BatchModuleMarker; diff --git a/src/batch/schema.rs b/src/batch/schema.rs new file mode 100644 index 0000000..22d89f0 --- /dev/null +++ b/src/batch/schema.rs @@ -0,0 +1,178 @@ +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +/// Describes optional batch metadata. +pub struct BatchMetadata { + #[cfg_attr(feature = "serde", serde(default))] + pub name: Option, +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +/// Describes the worker defaults used for every batch job. +pub struct BatchWorker { + pub template: String, + #[cfg_attr(feature = "serde", serde(default))] + pub provider: Option, +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +/// Describes optional batch execution preferences. +pub struct BatchMode { + #[cfg_attr(feature = "serde", serde(default))] + pub parallelism: Option, + #[cfg_attr(feature = "serde", serde(default))] + pub background: Option, + #[cfg_attr(feature = "serde", serde(default))] + pub interaction: Option, +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +/// Describes one background job in a batch. +pub struct BatchJob { + #[cfg_attr(feature = "serde", serde(default))] + pub name: Option, + pub prompt: String, +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +/// Describes a high-level batch or yolo submission. +pub struct BatchSpec { + pub api_version: String, + pub kind: String, + #[cfg_attr(feature = "serde", serde(default))] + pub metadata: Option, + pub worker: BatchWorker, + #[cfg_attr(feature = "serde", serde(default))] + pub mode: Option, + pub jobs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +/// Reports validation failures for batch parsing or compilation. +pub struct BatchValidationError(String); + +impl BatchValidationError { + pub fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl Display for BatchValidationError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl Error for BatchValidationError {} + +impl BatchSpec { + /// Validates and normalizes the parsed batch spec. + /// + /// # Errors + /// + /// Returns [`BatchValidationError`] if the spec is invalid. + pub fn validate_and_normalize(&mut self) -> Result<(), BatchValidationError> { + if self.api_version.trim().is_empty() { + return Err(BatchValidationError::new("api_version is required")); + } + match self.kind.as_str() { + "batch" => {} + "yolo" => self.kind = "batch".to_string(), + other => { + return Err(BatchValidationError::new(format!( + "kind must be 'batch' or 'yolo', got '{other}'" + ))) + } + } + if self.worker.template.trim().is_empty() { + return Err(BatchValidationError::new("worker.template is required")); + } + if self.jobs.is_empty() { + return Err(BatchValidationError::new("jobs must not be empty")); + } + for (index, job) in self.jobs.iter().enumerate() { + if job.prompt.trim().is_empty() { + return Err(BatchValidationError::new(format!( + "jobs[{index}].prompt is required" + ))); + } + } + if let Some(mode) = &self.mode { + if let Some(parallelism) = mode.parallelism { + if parallelism == 0 { + return Err(BatchValidationError::new( + "mode.parallelism must be positive", + )); + } + } + } + Ok(()) + } +} + +/// Parses a YAML batch spec. +/// +/// # Examples +/// +/// ``` +/// let spec = void_control::batch::parse_batch_yaml( +/// r#" +/// api_version: v1 +/// kind: batch +/// worker: +/// template: examples/runtime-templates/warm_agent_basic.yaml +/// jobs: +/// - prompt: Fix failing auth tests +/// "#, +/// ) +/// .expect("parse batch"); +/// assert_eq!(spec.kind, "batch"); +/// ``` +/// +/// # Errors +/// +/// Returns [`BatchValidationError`] if the YAML is invalid or the parsed spec +/// fails validation. +pub fn parse_batch_yaml(yaml: &str) -> Result { + let mut spec: BatchSpec = serde_yaml::from_str(yaml) + .map_err(|err| BatchValidationError::new(format!("invalid batch yaml: {err}")))?; + spec.validate_and_normalize()?; + Ok(spec) +} + +/// Parses a JSON batch spec. +/// +/// # Examples +/// +/// ``` +/// let spec = void_control::batch::parse_batch_json( +/// r#"{ +/// "api_version": "v1", +/// "kind": "yolo", +/// "worker": { "template": "examples/runtime-templates/warm_agent_basic.yaml" }, +/// "jobs": [{ "prompt": "Review migration safety" }] +/// }"#, +/// ) +/// .expect("parse batch"); +/// assert_eq!(spec.kind, "batch"); +/// ``` +/// +/// # Errors +/// +/// Returns [`BatchValidationError`] if the JSON is invalid or the parsed spec +/// fails validation. +pub fn parse_batch_json(json: &str) -> Result { + let mut spec: BatchSpec = serde_json::from_str(json) + .map_err(|err| BatchValidationError::new(format!("invalid batch json: {err}")))?; + spec.validate_and_normalize()?; + Ok(spec) +} diff --git a/src/bin/voidctl.rs b/src/bin/voidctl.rs index a608a0e..9e7855e 100644 --- a/src/bin/voidctl.rs +++ b/src/bin/voidctl.rs @@ -41,6 +41,47 @@ enum ExecutionCommand { }, } +#[cfg(feature = "serde")] +#[derive(Debug, Clone, PartialEq, Eq)] +enum TemplateCommand { + List, + Get { + template_id: String, + }, + DryRun { + template_id: String, + inputs: Option, + stdin: bool, + }, + Execute { + template_id: String, + inputs: Option, + stdin: bool, + }, +} + +#[cfg(feature = "serde")] +#[derive(Debug, Clone, PartialEq, Eq)] +enum BatchCommand { + DryRun { + spec: Option, + stdin: bool, + alias: String, + }, + Run { + spec: Option, + stdin: bool, + alias: String, + }, +} + +#[cfg(feature = "serde")] +#[derive(Debug, Clone, PartialEq, Eq)] +enum TeamCommand { + DryRun { spec: Option, stdin: bool }, + Run { spec: Option, stdin: bool }, +} + #[cfg(feature = "serde")] #[derive(Debug, Clone, PartialEq, Eq)] enum CliCommand { @@ -48,6 +89,9 @@ enum CliCommand { Help, Interactive, Execution(ExecutionCommand), + Template(TemplateCommand), + Batch(BatchCommand), + Team(TeamCommand), } #[cfg(feature = "serde")] @@ -73,6 +117,21 @@ fn execution_subcommand_candidates() -> &'static [&'static str] { ] } +#[cfg(feature = "serde")] +fn template_subcommand_candidates() -> &'static [&'static str] { + &["list", "get", "dry-run", "execute"] +} + +#[cfg(feature = "serde")] +fn batch_subcommand_candidates() -> &'static [&'static str] { + &["dry-run", "run"] +} + +#[cfg(feature = "serde")] +fn team_subcommand_candidates() -> &'static [&'static str] { + &["dry-run", "run"] +} + #[cfg(feature = "serde")] fn parse_cli_args(args: I) -> Result where @@ -190,8 +249,113 @@ where )), } } + "template" => { + let action = iter.next().ok_or_else(|| { + "usage: voidctl template [args]".to_string() + })?; + match action { + "list" => { + expect_no_more_args(&mut iter, "usage: voidctl template list")?; + Ok(CliCommand::Template(TemplateCommand::List)) + } + "get" => { + let template_id = iter + .next() + .ok_or_else(|| "usage: voidctl template get ".to_string())? + .to_string(); + expect_no_more_args(&mut iter, "usage: voidctl template get ")?; + Ok(CliCommand::Template(TemplateCommand::Get { template_id })) + } + "dry-run" => { + let template_id = iter + .next() + .ok_or_else(|| { + "usage: voidctl template dry-run [ | --stdin]" + .to_string() + })? + .to_string(); + parse_template_input_file_or_stdin(&mut iter, "dry-run").map( + |(inputs, stdin)| { + CliCommand::Template(TemplateCommand::DryRun { + template_id, + inputs, + stdin, + }) + }, + ) + } + "execute" => { + let template_id = iter + .next() + .ok_or_else(|| { + "usage: voidctl template execute [ | --stdin]" + .to_string() + })? + .to_string(); + parse_template_input_file_or_stdin(&mut iter, "execute").map( + |(inputs, stdin)| { + CliCommand::Template(TemplateCommand::Execute { + template_id, + inputs, + stdin, + }) + }, + ) + } + other => Err(format!( + "unknown template subcommand '{other}'. supported: {}", + template_subcommand_candidates().join(", ") + )), + } + } + "batch" | "yolo" => { + let alias = head; + let action = iter.next().ok_or_else(|| { + format!("usage: voidctl {alias} [ | --stdin]") + })?; + match action { + "dry-run" => { + parse_execution_file_or_stdin(&mut iter, "dry-run").map(|(spec, stdin)| { + CliCommand::Batch(BatchCommand::DryRun { + spec, + stdin, + alias: alias.to_string(), + }) + }) + } + "run" => parse_execution_file_or_stdin(&mut iter, "run").map(|(spec, stdin)| { + CliCommand::Batch(BatchCommand::Run { + spec, + stdin, + alias: alias.to_string(), + }) + }), + other => Err(format!( + "unknown {alias} subcommand '{other}'. supported: {}", + batch_subcommand_candidates().join(", ") + )), + } + } + "team" => { + let action = iter.next().ok_or_else(|| { + "usage: voidctl team [ | --stdin]".to_string() + })?; + match action { + "dry-run" => { + parse_execution_file_or_stdin(&mut iter, "dry-run").map(|(spec, stdin)| { + CliCommand::Team(TeamCommand::DryRun { spec, stdin }) + }) + } + "run" => parse_execution_file_or_stdin(&mut iter, "run") + .map(|(spec, stdin)| CliCommand::Team(TeamCommand::Run { spec, stdin })), + other => Err(format!( + "unknown team subcommand '{other}'. supported: {}", + team_subcommand_candidates().join(", ") + )), + } + } other => Err(format!( - "unknown command '{other}'. supported: serve, help, execution" + "unknown command '{other}'. supported: serve, help, execution, template, batch, yolo, team" )), } } @@ -238,6 +402,46 @@ where Ok((spec, stdin)) } +#[cfg(feature = "serde")] +fn parse_template_input_file_or_stdin<'a, I>( + iter: &mut I, + action: &str, +) -> Result<(Option, bool), String> +where + I: Iterator, +{ + let mut inputs = None; + let mut stdin = false; + for token in iter.by_ref() { + match token { + "--stdin" => { + if stdin || inputs.is_some() { + return Err(format!( + "usage: voidctl template {action} [ | --stdin]" + )); + } + stdin = true; + } + other => { + if stdin { + return Err(format!("unexpected extra argument '{other}'")); + } + if inputs.is_none() { + inputs = Some(other.to_string()); + } else { + return Err(format!("unexpected extra argument '{other}'")); + } + } + } + } + if !stdin && inputs.is_none() { + return Err(format!( + "usage: voidctl template {action} [ | --stdin]" + )); + } + Ok((inputs, stdin)) +} + #[cfg(feature = "serde")] fn expect_no_more_args(iter: &mut I, usage: &str) -> Result<(), String> where @@ -264,7 +468,23 @@ fn top_level_help_text() -> &'static str { voidctl execution inspect voidctl execution events voidctl execution result - voidctl execution runtime [candidate-id]" + voidctl execution runtime [candidate-id] + voidctl template list + voidctl template get + voidctl template dry-run [ | --stdin] + voidctl template execute [ | --stdin] + voidctl batch dry-run + voidctl batch dry-run --stdin + voidctl batch run + voidctl batch run --stdin + voidctl yolo dry-run + voidctl yolo dry-run --stdin + voidctl yolo run + voidctl yolo run --stdin + voidctl team dry-run + voidctl team dry-run --stdin + voidctl team run + voidctl team run --stdin" } #[cfg(feature = "serde")] @@ -350,6 +570,32 @@ fn load_execution_spec_input(spec: Option<&str>, stdin: bool) -> Result Result { + std::fs::read_to_string(path).map_err(|e| format!("read template inputs failed: {e}")) +} + +#[cfg(feature = "serde")] +fn load_json_input(inputs: Option<&str>, stdin: bool) -> Result { + use std::io::Read; + + if stdin { + let mut body = String::new(); + std::io::stdin() + .read_to_string(&mut body) + .map_err(|e| format!("read stdin failed: {e}"))?; + if body.trim().is_empty() { + return Err("stdin template input is empty".to_string()); + } + return Ok(body); + } + + let Some(inputs) = inputs else { + return Err("template input path is required unless --stdin is used".to_string()); + }; + load_json_input_file(inputs) +} + #[cfg(feature = "serde")] fn execution_status_is_terminal(status: &str) -> bool { match status { @@ -471,6 +717,124 @@ fn print_execution_summary(detail: &serde_json::Value) { ); } +#[cfg(feature = "serde")] +fn print_template_compilation_summary(detail: &serde_json::Value) { + let template = detail + .get("template") + .cloned() + .unwrap_or(serde_json::Value::Null); + let compiled = detail + .get("compiled") + .cloned() + .unwrap_or(serde_json::Value::Null); + println!( + "template_id={} execution_kind={} goal={} workflow_template={} mode={} variation_source={} candidates_per_iteration={}", + template + .get("id") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + template + .get("execution_kind") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + compiled + .get("goal") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + compiled + .get("workflow_template") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + compiled + .get("mode") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + compiled + .get("variation_source") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + compiled + .get("candidates_per_iteration") + .and_then(|value| value.as_u64()) + .unwrap_or(0), + ); + let overrides = compiled + .get("overrides") + .and_then(|value| value.as_object()) + .cloned() + .unwrap_or_default(); + for (key, value) in overrides { + if let Some(value) = value.as_str() { + println!("{key}={value}"); + } else { + println!("{key}={value}"); + } + } +} + +#[cfg(feature = "serde")] +fn print_batch_compilation_summary(detail: &serde_json::Value) { + println!( + "kind={} compiled_primitive={} workflow_template={} mode={} variation_source={} candidates_per_iteration={}", + detail + .get("kind") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + detail + .get("compiled_primitive") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + detail + .get("compiled") + .and_then(|value| value.get("workflow_template")) + .and_then(|value| value.as_str()) + .unwrap_or("-"), + detail + .get("compiled") + .and_then(|value| value.get("mode")) + .and_then(|value| value.as_str()) + .unwrap_or("-"), + detail + .get("compiled") + .and_then(|value| value.get("variation_source")) + .and_then(|value| value.as_str()) + .unwrap_or("-"), + detail + .get("compiled") + .and_then(|value| value.get("candidates_per_iteration")) + .and_then(|value| value.as_u64()) + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()), + ); +} + +#[cfg(feature = "serde")] +fn print_batch_run_summary(detail: &serde_json::Value) { + println!( + "kind={} run_id={} compiled_primitive={} status={} goal={}", + detail + .get("kind") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + detail + .get("run_id") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + detail + .get("compiled_primitive") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + detail + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + detail + .get("goal") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + ); +} + #[cfg(feature = "serde")] fn select_runtime_run( detail: &serde_json::Value, @@ -603,6 +967,9 @@ fn run() -> Result<(), String> { return Ok(()); } CliCommand::Execution(_) => {} + CliCommand::Template(_) => {} + CliCommand::Batch(_) => {} + CliCommand::Team(_) => {} CliCommand::Interactive => {} } @@ -646,6 +1013,10 @@ fn run() -> Result<(), String> { "/watch", "/resume", "/execution", + "/template", + "/batch", + "/yolo", + "/team", "/help", "/exit", ]; @@ -683,6 +1054,9 @@ fn run() -> Result<(), String> { "/execution" => options.extend([ "create", "dry-run", "list", "status", "pause", "resume", "cancel", "patch", ]), + "/template" => options.extend(["list", "get", "dry-run", "execute"]), + "/batch" | "/yolo" => options.extend(["dry-run", "run"]), + "/team" => options.extend(["dry-run", "run"]), "/events" => options.push("--from"), "/logs" => options.push("--follow"), "/cancel" => options.push("--reason"), @@ -763,6 +1137,32 @@ fn run() -> Result<(), String> { max_iterations: Option, max_concurrent_candidates: Option, }, + TemplateList, + TemplateGet { + template_id: String, + }, + TemplateDryRun { + template_id: String, + inputs: String, + }, + TemplateExecute { + template_id: String, + inputs: String, + }, + BatchDryRun { + spec: String, + alias: String, + }, + BatchRun { + spec: String, + alias: String, + }, + TeamDryRun { + spec: String, + }, + TeamRun { + spec: String, + }, Help, Exit, Empty, @@ -1071,6 +1471,92 @@ fn run() -> Result<(), String> { other => Err(format!("unknown /execution action '{other}'")), } } + "/template" => { + let action = tokens.next().ok_or_else(|| { + "usage: /template [args]".to_string() + })?; + match action { + "list" => Ok(Command::TemplateList), + "get" => Ok(Command::TemplateGet { + template_id: tokens + .next() + .ok_or_else(|| "usage: /template get ".to_string())? + .to_string(), + }), + "dry-run" => Ok(Command::TemplateDryRun { + template_id: tokens + .next() + .ok_or_else(|| { + "usage: /template dry-run ".to_string() + })? + .to_string(), + inputs: tokens + .next() + .ok_or_else(|| { + "usage: /template dry-run ".to_string() + })? + .to_string(), + }), + "execute" => Ok(Command::TemplateExecute { + template_id: tokens + .next() + .ok_or_else(|| { + "usage: /template execute ".to_string() + })? + .to_string(), + inputs: tokens + .next() + .ok_or_else(|| { + "usage: /template execute ".to_string() + })? + .to_string(), + }), + other => Err(format!("unknown /template action '{other}'")), + } + } + "/batch" | "/yolo" => { + let alias = head.trim_start_matches('/').to_string(); + let action = tokens + .next() + .ok_or_else(|| format!("usage: /{alias} "))?; + match action { + "dry-run" => Ok(Command::BatchDryRun { + spec: tokens + .next() + .ok_or_else(|| format!("usage: /{alias} dry-run "))? + .to_string(), + alias, + }), + "run" => Ok(Command::BatchRun { + spec: tokens + .next() + .ok_or_else(|| format!("usage: /{alias} run "))? + .to_string(), + alias, + }), + other => Err(format!("unknown /{alias} action '{other}'")), + } + } + "/team" => { + let action = tokens + .next() + .ok_or_else(|| "usage: /team ".to_string())?; + match action { + "dry-run" => Ok(Command::TeamDryRun { + spec: tokens + .next() + .ok_or_else(|| "usage: /team dry-run ".to_string())? + .to_string(), + }), + "run" => Ok(Command::TeamRun { + spec: tokens + .next() + .ok_or_else(|| "usage: /team run ".to_string())? + .to_string(), + }), + other => Err(format!("unknown /team action '{other}'")), + } + } "/help" => Ok(Command::Help), "/exit" | "/quit" => Ok(Command::Exit), other => Err(format!("unknown command '{other}'")), @@ -1095,6 +1581,16 @@ fn run() -> Result<(), String> { /execution resume /execution cancel /execution patch [--max-iterations N] [--max-concurrent-candidates N] + /template list + /template get + /template dry-run + /template execute + /batch dry-run + /batch run + /yolo dry-run + /yolo run + /team dry-run + /team run /help /exit @@ -1588,11 +2084,313 @@ Policy presets: fast | balanced | safe" } } - let client = VoidBoxRuntimeClient::new(base_url.clone(), 250); - let session_file = session_path(); - let mut session = load_session(&session_file); - let mut rl = Editor::::new() - .map_err(|e| format!("readline init failed: {e}"))?; + if let CliCommand::Template(command) = parsed_cli { + match command { + TemplateCommand::List => { + match bridge_request(&bridge_base_url, "GET", "/v1/templates", None) { + Ok(response) => { + if response.status >= 400 { + return Err(bridge_error_message(&response)); + } + let templates = response + .json + .get("templates") + .and_then(|value| value.as_array()) + .cloned() + .unwrap_or_default(); + if templates.is_empty() { + println!("no templates"); + } else { + for template in templates { + println!( + "template_id={} execution_kind={} name={} description={}", + template + .get("id") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + template + .get("execution_kind") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + template + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + template + .get("description") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + ); + } + } + } + Err(err) => return Err(err), + } + return Ok(()); + } + TemplateCommand::Get { template_id } => { + let path = format!("/v1/templates/{template_id}"); + match bridge_request(&bridge_base_url, "GET", &path, None) { + Ok(response) => { + if response.status >= 400 { + return Err(bridge_error_message(&response)); + } + let template = response + .json + .get("template") + .cloned() + .unwrap_or(serde_json::Value::Null); + let workflow_template = response + .json + .get("defaults") + .and_then(|value| value.get("workflow_template")) + .and_then(|value| value.as_str()) + .unwrap_or("-"); + println!( + "template_id={} execution_kind={} name={} workflow_template={}", + template + .get("id") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + template + .get("execution_kind") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + template + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + workflow_template + ); + let inputs = response + .json + .get("inputs") + .and_then(|value| value.as_object()) + .cloned() + .unwrap_or_default(); + for (name, field) in inputs { + println!( + "input={} type={} required={} description={}", + name, + field + .get("type") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + field + .get("required") + .and_then(|value| value.as_bool()) + .map(|value| value.to_string()) + .unwrap_or_else(|| "-".to_string()), + field + .get("description") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + ); + } + } + Err(err) => return Err(err), + } + return Ok(()); + } + TemplateCommand::DryRun { + template_id, + inputs, + stdin, + } => { + let body = load_json_input(inputs.as_deref(), stdin)?; + let path = format!("/v1/templates/{template_id}/dry-run"); + match bridge_request(&bridge_base_url, "POST", &path, Some(&body)) { + Ok(response) => { + if response.status >= 400 { + return Err(bridge_error_message(&response)); + } + print_template_compilation_summary(&response.json); + } + Err(err) => return Err(err), + } + return Ok(()); + } + TemplateCommand::Execute { + template_id, + inputs, + stdin, + } => { + let body = load_json_input(inputs.as_deref(), stdin)?; + let path = format!("/v1/templates/{template_id}/execute"); + match bridge_request(&bridge_base_url, "POST", &path, Some(&body)) { + Ok(response) => { + if response.status >= 400 { + return Err(bridge_error_message(&response)); + } + println!( + "execution_id={} template_id={} execution_kind={} status={} goal={}", + response + .json + .get("execution_id") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + response + .json + .get("template") + .and_then(|value| value.get("id")) + .and_then(|value| value.as_str()) + .unwrap_or("-"), + response + .json + .get("template") + .and_then(|value| value.get("execution_kind")) + .and_then(|value| value.as_str()) + .unwrap_or("-"), + response + .json + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + response + .json + .get("goal") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + ); + } + Err(err) => return Err(err), + } + return Ok(()); + } + } + } + + if let CliCommand::Batch(command) = parsed_cli { + match command { + BatchCommand::DryRun { spec, stdin, alias } => { + let spec = load_execution_spec_input(spec.as_deref(), stdin)?; + let path = if alias == "yolo" { + "/v1/yolo/dry-run" + } else { + "/v1/batch/dry-run" + }; + match bridge_request(&bridge_base_url, "POST", path, Some(&spec)) { + Ok(response) => { + if response.status >= 400 { + return Err(bridge_error_message(&response)); + } + print_batch_compilation_summary(&response.json); + } + Err(err) => return Err(err), + } + return Ok(()); + } + BatchCommand::Run { spec, stdin, alias } => { + let spec = load_execution_spec_input(spec.as_deref(), stdin)?; + let path = if alias == "yolo" { + "/v1/yolo/run" + } else { + "/v1/batch/run" + }; + match bridge_request(&bridge_base_url, "POST", path, Some(&spec)) { + Ok(response) => { + if response.status >= 400 { + return Err(bridge_error_message(&response)); + } + println!( + "kind={} run_id={} compiled_primitive={} status={} goal={}", + response + .json + .get("kind") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + response + .json + .get("run_id") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + response + .json + .get("compiled_primitive") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + response + .json + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + response + .json + .get("goal") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + ); + } + Err(err) => return Err(err), + } + return Ok(()); + } + } + } + + if let CliCommand::Team(command) = parsed_cli { + match command { + TeamCommand::DryRun { spec, stdin } => { + let spec = load_execution_spec_input(spec.as_deref(), stdin)?; + match bridge_request(&bridge_base_url, "POST", "/v1/teams/dry-run", Some(&spec)) { + Ok(response) => { + if response.status >= 400 { + return Err(bridge_error_message(&response)); + } + print_batch_compilation_summary(&response.json); + } + Err(err) => return Err(err), + } + return Ok(()); + } + TeamCommand::Run { spec, stdin } => { + let spec = load_execution_spec_input(spec.as_deref(), stdin)?; + match bridge_request(&bridge_base_url, "POST", "/v1/teams/run", Some(&spec)) { + Ok(response) => { + if response.status >= 400 { + return Err(bridge_error_message(&response)); + } + println!( + "kind={} execution_id={} compiled_primitive={} status={} goal={}", + response + .json + .get("kind") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + response + .json + .get("execution_id") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + response + .json + .get("compiled_primitive") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + response + .json + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + response + .json + .get("goal") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + ); + } + Err(err) => return Err(err), + } + return Ok(()); + } + } + } + + let client = VoidBoxRuntimeClient::new(base_url.clone(), 250); + let session_file = session_path(); + let mut session = load_session(&session_file); + let mut rl = Editor::::new() + .map_err(|e| format!("readline init failed: {e}"))?; rl.set_helper(Some(VoidCtlHelper)); for cmd in &session.recent_commands { let _ = rl.add_history_entry(cmd.as_str()); @@ -1964,6 +2762,256 @@ Policy presets: fast | balanced | safe" Err(err) => println!("error: {err}"), } } + Command::TemplateList => { + match bridge_request(&bridge_base_url, "GET", "/v1/templates", None) { + Ok(response) => { + if response.status >= 400 { + println!("error: {}", bridge_error_message(&response)); + continue; + } + let templates = response + .json + .get("templates") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + if templates.is_empty() { + println!("no templates"); + } else { + for template in templates { + println!( + "template_id={} execution_kind={} name={} description={}", + template.get("id").and_then(|v| v.as_str()).unwrap_or("-"), + template + .get("execution_kind") + .and_then(|v| v.as_str()) + .unwrap_or("-"), + template.get("name").and_then(|v| v.as_str()).unwrap_or("-"), + template + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("-"), + ); + } + } + } + Err(err) => println!("error: {err}"), + } + } + Command::TemplateGet { template_id } => { + match bridge_request( + &bridge_base_url, + "GET", + &format!("/v1/templates/{template_id}"), + None, + ) { + Ok(response) => { + if response.status >= 400 { + println!("error: {}", bridge_error_message(&response)); + continue; + } + let template = response + .json + .get("template") + .cloned() + .unwrap_or(serde_json::Value::Null); + let workflow_template = response + .json + .get("defaults") + .and_then(|value| value.get("workflow_template")) + .and_then(|value| value.as_str()) + .unwrap_or("-"); + println!( + "template_id={} execution_kind={} name={} workflow_template={}", + template + .get("id") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + template + .get("execution_kind") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + template + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + workflow_template + ); + } + Err(err) => println!("error: {err}"), + } + } + Command::TemplateDryRun { + template_id, + inputs, + } => { + match load_json_input_file(&inputs).and_then(|body| { + bridge_request( + &bridge_base_url, + "POST", + &format!("/v1/templates/{template_id}/dry-run"), + Some(&body), + ) + }) { + Ok(response) => { + if response.status >= 400 { + println!("error: {}", bridge_error_message(&response)); + continue; + } + print_template_compilation_summary(&response.json); + } + Err(err) => println!("error: {err}"), + } + } + Command::TemplateExecute { + template_id, + inputs, + } => { + match load_json_input_file(&inputs).and_then(|body| { + bridge_request( + &bridge_base_url, + "POST", + &format!("/v1/templates/{template_id}/execute"), + Some(&body), + ) + }) { + Ok(response) => { + if response.status >= 400 { + println!("error: {}", bridge_error_message(&response)); + continue; + } + println!( + "execution_id={} template_id={} execution_kind={} status={} goal={}", + response + .json + .get("execution_id") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + response + .json + .get("template") + .and_then(|value| value.get("id")) + .and_then(|value| value.as_str()) + .unwrap_or("-"), + response + .json + .get("template") + .and_then(|value| value.get("execution_kind")) + .and_then(|value| value.as_str()) + .unwrap_or("-"), + response + .json + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + response + .json + .get("goal") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + ); + } + Err(err) => println!("error: {err}"), + } + } + Command::BatchDryRun { spec, alias } => { + let path = if alias == "yolo" { + "/v1/yolo/dry-run" + } else { + "/v1/batch/dry-run" + }; + match load_execution_spec_file(&spec).and_then(|spec_text| { + bridge_request(&bridge_base_url, "POST", path, Some(&spec_text)) + }) { + Ok(response) => { + if response.status >= 400 { + println!("error: {}", bridge_error_message(&response)); + continue; + } + print_batch_compilation_summary(&response.json); + } + Err(err) => println!("error: {err}"), + } + } + Command::BatchRun { spec, alias } => { + let path = if alias == "yolo" { + "/v1/yolo/run" + } else { + "/v1/batch/run" + }; + match load_execution_spec_file(&spec).and_then(|spec_text| { + bridge_request(&bridge_base_url, "POST", path, Some(&spec_text)) + }) { + Ok(response) => { + if response.status >= 400 { + println!("error: {}", bridge_error_message(&response)); + continue; + } + print_batch_run_summary(&response.json); + } + Err(err) => println!("error: {err}"), + } + } + Command::TeamDryRun { spec } => { + match load_execution_spec_file(&spec).and_then(|spec_text| { + bridge_request( + &bridge_base_url, + "POST", + "/v1/teams/dry-run", + Some(&spec_text), + ) + }) { + Ok(response) => { + if response.status >= 400 { + println!("error: {}", bridge_error_message(&response)); + continue; + } + print_batch_compilation_summary(&response.json); + } + Err(err) => println!("error: {err}"), + } + } + Command::TeamRun { spec } => { + match load_execution_spec_file(&spec).and_then(|spec_text| { + bridge_request(&bridge_base_url, "POST", "/v1/teams/run", Some(&spec_text)) + }) { + Ok(response) => { + if response.status >= 400 { + println!("error: {}", bridge_error_message(&response)); + continue; + } + println!( + "kind={} execution_id={} compiled_primitive={} status={} goal={}", + response + .json + .get("kind") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + response + .json + .get("execution_id") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + response + .json + .get("compiled_primitive") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + response + .json + .get("status") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"), + response + .json + .get("goal") + .and_then(|value| value.as_str()) + .unwrap_or("-"), + ); + } + Err(err) => println!("error: {err}"), + } + } } if let Err(e) = save_session(&session_file, &session) { @@ -2082,6 +3130,78 @@ mod tests { ); } + #[test] + fn parses_template_list() { + let command = parse_cli_args(["template", "list"]).unwrap(); + assert_eq!(command, CliCommand::Template(TemplateCommand::List)); + } + + #[test] + fn parses_template_get() { + let command = parse_cli_args(["template", "get", "single-agent-basic"]).unwrap(); + assert_eq!( + command, + CliCommand::Template(TemplateCommand::Get { + template_id: "single-agent-basic".to_string(), + }) + ); + } + + #[test] + fn parses_template_dry_run_with_inputs_file() { + let command = + parse_cli_args(["template", "dry-run", "single-agent-basic", "inputs.json"]).unwrap(); + assert_eq!( + command, + CliCommand::Template(TemplateCommand::DryRun { + template_id: "single-agent-basic".to_string(), + inputs: Some("inputs.json".to_string()), + stdin: false, + }) + ); + } + + #[test] + fn parses_template_execute_from_stdin() { + let command = + parse_cli_args(["template", "execute", "warm-agent-basic", "--stdin"]).unwrap(); + assert_eq!( + command, + CliCommand::Template(TemplateCommand::Execute { + template_id: "warm-agent-basic".to_string(), + inputs: None, + stdin: true, + }) + ); + } + + #[test] + fn parses_batch_run_alias() { + let command = + parse_cli_args(["yolo", "run", "examples/batch/background_repo_work.yaml"]).unwrap(); + assert_eq!( + command, + CliCommand::Batch(BatchCommand::Run { + spec: Some("examples/batch/background_repo_work.yaml".to_string()), + stdin: false, + alias: "yolo".to_string(), + }) + ); + } + + #[test] + fn parses_team_run() { + let command = + parse_cli_args(["team", "run", "examples/team/rust_article_team.yaml"]).unwrap(); + assert_eq!( + command, + CliCommand::Team(TeamCommand::Run { + spec: Some("examples/team/rust_article_team.yaml".to_string()), + stdin: false, + }) + ); + } + #[test] fn rejects_extra_execution_watch_args() { let err = parse_cli_args(["execution", "watch", "exec-1", "extra"]).unwrap_err(); @@ -2094,6 +3214,19 @@ mod tests { assert!(err.contains("unexpected extra argument")); } + #[test] + fn rejects_extra_template_execute_stdin_args() { + let err = parse_cli_args([ + "template", + "execute", + "warm-agent-basic", + "--stdin", + "extra", + ]) + .unwrap_err(); + assert!(err.contains("unexpected extra argument")); + } + #[test] fn completes_execution_subcommands() { let completions = execution_subcommand_candidates(); @@ -2106,6 +3239,29 @@ mod tests { assert!(completions.contains(&"runtime")); } + #[test] + fn completes_template_subcommands() { + let completions = template_subcommand_candidates(); + assert!(completions.contains(&"list")); + assert!(completions.contains(&"get")); + assert!(completions.contains(&"dry-run")); + assert!(completions.contains(&"execute")); + } + + #[test] + fn completes_batch_subcommands() { + let completions = batch_subcommand_candidates(); + assert!(completions.contains(&"dry-run")); + assert!(completions.contains(&"run")); + } + + #[test] + fn completes_team_subcommands() { + let completions = team_subcommand_candidates(); + assert!(completions.contains(&"dry-run")); + assert!(completions.contains(&"run")); + } + #[test] fn top_level_help_mentions_execution_commands() { let help = top_level_help_text(); @@ -2116,6 +3272,20 @@ mod tests { assert!(help.contains("voidctl execution events ")); assert!(help.contains("voidctl execution result ")); assert!(help.contains("voidctl execution runtime [candidate-id]")); + assert!(help.contains("voidctl template list")); + assert!(help.contains("voidctl template get ")); + assert!( + help.contains("voidctl template dry-run [ | --stdin]") + ); + assert!( + help.contains("voidctl template execute [ | --stdin]") + ); + assert!(help.contains("voidctl batch dry-run ")); + assert!(help.contains("voidctl batch run --stdin")); + assert!(help.contains("voidctl yolo dry-run ")); + assert!(help.contains("voidctl yolo run --stdin")); + assert!(help.contains("voidctl team dry-run ")); + assert!(help.contains("voidctl team run --stdin")); } #[test] diff --git a/src/bridge.rs b/src/bridge.rs index ceae1da..cf5d931 100644 --- a/src/bridge.rs +++ b/src/bridge.rs @@ -14,6 +14,8 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "serde")] use serde_json::{json, Value}; +#[cfg(feature = "serde")] +use crate::batch; #[cfg(feature = "serde")] use crate::contract::{ExecutionPolicy, RunState, StartRequest}; #[cfg(feature = "serde")] @@ -26,6 +28,10 @@ use crate::orchestration::{ }; #[cfg(feature = "serde")] use crate::runtime::{MockRuntime, VoidBoxRuntimeClient}; +#[cfg(feature = "serde")] +use crate::team; +#[cfg(feature = "serde")] +use crate::templates; #[cfg(feature = "serde")] #[derive(Debug, Serialize)] @@ -206,6 +212,12 @@ struct LaunchResponse { file: String, } +#[cfg(feature = "serde")] +#[derive(Debug, Deserialize)] +struct TemplateRequestBody { + inputs: Value, +} + #[cfg(feature = "serde")] #[derive(Debug, Serialize)] struct ApiError { @@ -411,6 +423,54 @@ fn handle_bridge_request( return handle_execution_dry_run(body); } + if method == "POST" && (path == "/v1/batch/dry-run" || path == "/v1/yolo/dry-run") { + return handle_batch_dry_run(body); + } + + if method == "POST" && path == "/v1/teams/dry-run" { + return handle_team_dry_run(body); + } + + if method == "POST" && path == "/v1/teams/run" { + return handle_team_run(body, config); + } + + if method == "GET" && path.starts_with("/v1/team-runs/") { + return handle_team_get(path, config); + } + + if method == "POST" && (path == "/v1/batch/run" || path == "/v1/yolo/run") { + return handle_batch_run(body, config); + } + + if method == "GET" && path.starts_with("/v1/batch-runs/") { + return handle_batch_get(path, config); + } + + if method == "GET" && path.starts_with("/v1/yolo-runs/") { + return handle_batch_get(path, config); + } + + if method == "GET" && path == "/v1/templates" { + return handle_template_list(); + } + + if method == "GET" + && path.starts_with("/v1/templates/") + && !path.ends_with("/dry-run") + && !path.ends_with("/execute") + { + return handle_template_get(path); + } + + if method == "POST" && path.starts_with("/v1/templates/") && path.ends_with("/dry-run") { + return handle_template_dry_run(path, body); + } + + if method == "POST" && path.starts_with("/v1/templates/") && path.ends_with("/execute") { + return handle_template_execute(path, body, config); + } + if method == "POST" && path == "/v1/executions" { return handle_execution_create(body, config, client.is_some()); } @@ -482,6 +542,516 @@ fn handle_execution_dry_run(body: &str) -> JsonHttpResponse { } }; + respond_with_execution_dry_run(spec, temp_root) +} + +#[cfg(feature = "serde")] +fn handle_execution_create( + body: &str, + config: &BridgeConfig, + _use_live_runtime: bool, +) -> JsonHttpResponse { + let spec = match parse_submitted_execution_spec(body, &config.spec_dir) { + Ok(spec) => spec, + Err(err) => { + return json_response( + 400, + &ApiError { + code: "INVALID_SPEC", + message: err, + retryable: false, + }, + ) + } + }; + + let store = FsExecutionStore::new(config.execution_dir.clone()); + let execution_id = format!("exec-{}", now_ms()); + submit_execution_spec(&store, &execution_id, &spec) +} + +#[cfg(feature = "serde")] +fn handle_batch_dry_run(body: &str) -> JsonHttpResponse { + let spec = match parse_submitted_batch_spec(body) { + Ok(spec) => spec, + Err(response) => return response, + }; + let execution = match batch::compile_batch_spec(&spec) { + Ok(execution) => execution, + Err(err) => { + return json_response( + 400, + &ApiError { + code: "INVALID_BATCH", + message: err.to_string(), + retryable: false, + }, + ) + } + }; + + json_response( + 200, + &json!({ + "kind": "batch", + "compiled_primitive": "swarm", + "compiled": compiled_execution_summary(&execution) + }), + ) +} + +#[cfg(feature = "serde")] +fn handle_team_dry_run(body: &str) -> JsonHttpResponse { + let spec = match parse_submitted_team_spec(body) { + Ok(spec) => spec, + Err(response) => return response, + }; + let execution = match team::compile_team_spec(&spec) { + Ok(execution) => execution, + Err(err) => { + return json_response( + 400, + &ApiError { + code: "INVALID_TEAM", + message: err.to_string(), + retryable: false, + }, + ) + } + }; + + json_response( + 200, + &json!({ + "kind": "team", + "compiled_primitive": execution.mode, + "compiled": compiled_execution_summary(&execution) + }), + ) +} + +#[cfg(feature = "serde")] +fn handle_team_run(body: &str, config: &BridgeConfig) -> JsonHttpResponse { + let spec = match parse_submitted_team_spec(body) { + Ok(spec) => spec, + Err(response) => return response, + }; + let execution_spec = match team::compile_team_spec(&spec) { + Ok(execution) => execution, + Err(err) => { + return json_response( + 400, + &ApiError { + code: "INVALID_TEAM", + message: err.to_string(), + retryable: false, + }, + ) + } + }; + + let store = FsExecutionStore::new(config.execution_dir.clone()); + let execution_id = format!("exec-{}", now_ms()); + match ExecutionService::::submit_execution(&store, &execution_id, &execution_spec) + { + Ok(execution) => json_response( + 200, + &json!({ + "kind": "team", + "execution_id": execution.execution_id, + "compiled_primitive": execution_spec.mode, + "status": execution.status, + "goal": execution.goal + }), + ), + Err(err) => json_response( + 500, + &ApiError { + code: "INTERNAL_ERROR", + message: err.to_string(), + retryable: true, + }, + ), + } +} + +#[cfg(feature = "serde")] +fn handle_team_get(path: &str, config: &BridgeConfig) -> JsonHttpResponse { + let Some(execution_id) = path.strip_prefix("/v1/team-runs/") else { + return json_response( + 404, + &ApiError { + code: "NOT_FOUND", + message: format!("no route for GET {path}"), + retryable: false, + }, + ); + }; + + let execution_path = format!("/v1/executions/{execution_id}"); + let response = handle_execution_get(&execution_path, config); + let Ok(mut value) = serde_json::from_slice::(&response.body) else { + return response; + }; + if response.status == 200 { + let Some(object) = value.as_object_mut() else { + return response; + }; + object.insert("kind".to_string(), Value::String("team".to_string())); + object.insert( + "run_id".to_string(), + Value::String(execution_id.to_string()), + ); + } + json_response(response.status, &value) +} + +#[cfg(feature = "serde")] +fn handle_batch_run(body: &str, config: &BridgeConfig) -> JsonHttpResponse { + let spec = match parse_submitted_batch_spec(body) { + Ok(spec) => spec, + Err(response) => return response, + }; + let execution_spec = match batch::compile_batch_spec(&spec) { + Ok(execution) => execution, + Err(err) => { + return json_response( + 400, + &ApiError { + code: "INVALID_BATCH", + message: err.to_string(), + retryable: false, + }, + ) + } + }; + + let store = FsExecutionStore::new(config.execution_dir.clone()); + let execution_id = format!("exec-{}", now_ms()); + match ExecutionService::::submit_execution(&store, &execution_id, &execution_spec) + { + Ok(execution) => json_response( + 200, + &json!({ + "kind": "batch", + "run_id": execution.execution_id, + "execution_id": execution.execution_id, + "compiled_primitive": "swarm", + "status": execution.status, + "goal": execution.goal + }), + ), + Err(err) => json_response( + 500, + &ApiError { + code: "INTERNAL_ERROR", + message: err.to_string(), + retryable: true, + }, + ), + } +} + +#[cfg(feature = "serde")] +fn handle_batch_get(path: &str, config: &BridgeConfig) -> JsonHttpResponse { + let execution_path = if let Some(execution_id) = path.strip_prefix("/v1/batch-runs/") { + format!("/v1/executions/{execution_id}") + } else if let Some(execution_id) = path.strip_prefix("/v1/yolo-runs/") { + format!("/v1/executions/{execution_id}") + } else { + String::new() + }; + if execution_path.is_empty() { + return json_response( + 404, + &ApiError { + code: "NOT_FOUND", + message: format!("no route for GET {path}"), + retryable: false, + }, + ); + } + + let response = handle_execution_get(&execution_path, config); + let Ok(mut value) = serde_json::from_slice::(&response.body) else { + return response; + }; + if response.status == 200 { + let Some(object) = value.as_object_mut() else { + return response; + }; + let execution_id = object + .get("execution") + .and_then(|execution| execution.get("execution_id")) + .cloned() + .unwrap_or(Value::Null); + object.insert("kind".to_string(), Value::String("batch".to_string())); + object.insert("run_id".to_string(), execution_id); + } + json_response(response.status, &value) +} + +#[cfg(feature = "serde")] +fn parse_submitted_batch_spec(body: &str) -> Result { + let trimmed = body.trim_start(); + let parsed = if trimmed.starts_with('{') || trimmed.starts_with('[') { + batch::parse_batch_json(body) + } else { + batch::parse_batch_yaml(body) + }; + parsed.map_err(|err| { + json_response( + 400, + &ApiError { + code: "INVALID_BATCH", + message: err.to_string(), + retryable: false, + }, + ) + }) +} + +#[cfg(feature = "serde")] +fn parse_submitted_team_spec(body: &str) -> Result { + let trimmed = body.trim_start(); + let parsed = if trimmed.starts_with('{') || trimmed.starts_with('[') { + team::parse_team_json(body) + } else { + team::parse_team_yaml(body) + }; + parsed.map_err(|err| { + json_response( + 400, + &ApiError { + code: "INVALID_TEAM", + message: err.to_string(), + retryable: false, + }, + ) + }) +} + +#[cfg(feature = "serde")] +fn handle_template_list() -> JsonHttpResponse { + match templates::list_templates() { + Ok(list) => json_response(200, &json!({ "templates": list })), + Err(err) => json_response( + 500, + &ApiError { + code: "INTERNAL_ERROR", + message: err.to_string(), + retryable: true, + }, + ), + } +} + +#[cfg(feature = "serde")] +fn handle_template_get(path: &str) -> JsonHttpResponse { + let Some(template_id) = path.strip_prefix("/v1/templates/") else { + return json_response( + 404, + &ApiError { + code: "NOT_FOUND", + message: format!("no route for GET {path}"), + retryable: false, + }, + ); + }; + + match templates::load_template(template_id) { + Ok(template) => json_response( + 200, + &json!({ + "template": template.template, + "inputs": template.inputs, + "defaults": { + "workflow_template": template.defaults.workflow_template + }, + "compile": { + "bindings": template.compile.bindings + } + }), + ), + Err(err) if err.to_string().contains("No such file or directory") => json_response( + 404, + &ApiError { + code: "NOT_FOUND", + message: format!("template '{}' not found", template_id), + retryable: false, + }, + ), + Err(err) => json_response( + 400, + &ApiError { + code: "INVALID_TEMPLATE", + message: err.to_string(), + retryable: false, + }, + ), + } +} + +#[cfg(feature = "serde")] +fn handle_template_dry_run(path: &str, body: &str) -> JsonHttpResponse { + let Some(template_id) = path + .strip_prefix("/v1/templates/") + .and_then(|rest| rest.strip_suffix("/dry-run")) + else { + return json_response( + 404, + &ApiError { + code: "NOT_FOUND", + message: format!("no route for POST {path}"), + retryable: false, + }, + ); + }; + + let compiled = match compile_template_request(template_id, body) { + Ok(compiled) => compiled, + Err(response) => return response, + }; + + json_response( + 200, + &json!({ + "template": { + "id": compiled.template.id, + "execution_kind": compiled.template.execution_kind + }, + "inputs": compiled.normalized_inputs, + "compiled": compiled_execution_summary(&compiled.execution_spec) + }), + ) +} + +#[cfg(feature = "serde")] +fn handle_template_execute(path: &str, body: &str, config: &BridgeConfig) -> JsonHttpResponse { + let Some(template_id) = path + .strip_prefix("/v1/templates/") + .and_then(|rest| rest.strip_suffix("/execute")) + else { + return json_response( + 404, + &ApiError { + code: "NOT_FOUND", + message: format!("no route for POST {path}"), + retryable: false, + }, + ); + }; + + let compiled = match compile_template_request(template_id, body) { + Ok(compiled) => compiled, + Err(response) => return response, + }; + + let execution_id = format!("exec-{}", now_ms()); + let store = FsExecutionStore::new(config.execution_dir.clone()); + match ExecutionService::::submit_execution( + &store, + &execution_id, + &compiled.execution_spec, + ) { + Ok(execution) => json_response( + 200, + &json!({ + "execution_id": execution.execution_id, + "template": { + "id": compiled.template.id, + "execution_kind": compiled.template.execution_kind + }, + "status": execution.status, + "goal": execution.goal + }), + ), + Err(err) => json_response( + 500, + &ApiError { + code: "INTERNAL_ERROR", + message: err.to_string(), + retryable: true, + }, + ), + } +} + +#[cfg(feature = "serde")] +fn compile_template_request( + template_id: &str, + body: &str, +) -> Result { + let template = templates::load_template(template_id).map_err(|err| { + if err.to_string().contains("No such file or directory") { + json_response( + 404, + &ApiError { + code: "NOT_FOUND", + message: format!("template '{}' not found", template_id), + retryable: false, + }, + ) + } else { + json_response( + 400, + &ApiError { + code: "INVALID_TEMPLATE", + message: err.to_string(), + retryable: false, + }, + ) + } + })?; + let request: TemplateRequestBody = serde_json::from_str(body).map_err(|err| { + json_response( + 400, + &ApiError { + code: "INVALID_TEMPLATE_REQUEST", + message: format!("invalid template request body: {err}"), + retryable: false, + }, + ) + })?; + templates::compile_template(&template, &request.inputs).map_err(|err| { + json_response( + 400, + &ApiError { + code: "INVALID_TEMPLATE_INPUTS", + message: err.to_string(), + retryable: false, + }, + ) + }) +} + +#[cfg(feature = "serde")] +fn compiled_execution_summary(spec: &ExecutionSpec) -> Value { + let candidate_overrides: Vec<_> = spec + .variation + .explicit + .iter() + .map(|proposal| proposal.overrides.clone()) + .collect(); + let overrides = spec + .variation + .explicit + .first() + .map(|proposal| proposal.overrides.clone()) + .unwrap_or_default(); + json!({ + "goal": spec.goal, + "workflow_template": spec.workflow.template, + "mode": spec.mode, + "variation_source": spec.variation.source, + "candidates_per_iteration": spec.variation.candidates_per_iteration, + "candidate_overrides": candidate_overrides, + "overrides": overrides + }) +} + +#[cfg(feature = "serde")] +fn respond_with_execution_dry_run(spec: ExecutionSpec, temp_root: PathBuf) -> JsonHttpResponse { let service = ExecutionService::new( GlobalConfig { max_concurrent_child_runs: 20, @@ -507,28 +1077,12 @@ fn handle_execution_dry_run(body: &str) -> JsonHttpResponse { } #[cfg(feature = "serde")] -fn handle_execution_create( - body: &str, - config: &BridgeConfig, - _use_live_runtime: bool, +fn submit_execution_spec( + store: &FsExecutionStore, + execution_id: &str, + spec: &ExecutionSpec, ) -> JsonHttpResponse { - let spec = match parse_submitted_execution_spec(body, &config.spec_dir) { - Ok(spec) => spec, - Err(err) => { - return json_response( - 400, - &ApiError { - code: "INVALID_SPEC", - message: err, - retryable: false, - }, - ) - } - }; - - let store = FsExecutionStore::new(config.execution_dir.clone()); - let execution_id = format!("exec-{}", now_ms()); - match ExecutionService::::submit_execution(&store, &execution_id, &spec) { + match ExecutionService::::submit_execution(store, execution_id, spec) { Ok(execution) => json_response(200, &execution), Err(err) => json_response( 500, diff --git a/src/lib.rs b/src/lib.rs index 56f108c..0f93c21 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,11 @@ #[cfg(feature = "serde")] +pub mod batch; +#[cfg(feature = "serde")] pub mod bridge; pub mod contract; pub mod orchestration; pub mod runtime; +#[cfg(feature = "serde")] +pub mod team; +#[cfg(feature = "serde")] +pub mod templates; diff --git a/src/team/compile.rs b/src/team/compile.rs new file mode 100644 index 0000000..d4bcfbe --- /dev/null +++ b/src/team/compile.rs @@ -0,0 +1,187 @@ +use std::collections::BTreeMap; + +use crate::orchestration::{ + BudgetPolicy, ConcurrencyPolicy, ConvergencePolicy, EvaluationConfig, ExecutionSpec, + GlobalConfig, OrchestrationPolicy, SupervisionConfig, SupervisionReviewPolicy, VariationConfig, + VariationProposal, WorkflowTemplateRef, +}; + +use super::{AgentSpec, TaskSpec, TeamSpec, TeamValidationError}; + +const DEFAULT_TEAM_TEMPLATE: &str = "examples/runtime-templates/warm_agent_basic.yaml"; + +/// Compiles a [`TeamSpec`] into a normal [`ExecutionSpec`]. +/// +/// # Errors +/// +/// Returns [`TeamValidationError`] if the compiled execution spec is invalid. +pub fn compile_team_spec(spec: &TeamSpec) -> Result { + spec.validate()?; + + let mut explicit = Vec::new(); + for task in &spec.tasks { + let agents = task_agents(spec, task)?; + for agent in agents { + let mut overrides = BTreeMap::new(); + overrides.insert("agent.prompt".to_string(), task.description.clone()); + overrides.insert("agent.role".to_string(), agent.role.clone()); + overrides.insert("agent.goal".to_string(), agent.goal.clone()); + explicit.push(VariationProposal { overrides }); + } + } + + let mode = match spec.process.kind.as_str() { + "lead_worker" => "supervision", + "sequential" | "parallel" => "swarm", + _ => "swarm", + }; + let candidate_count = explicit.len() as u32; + let candidates_per_iteration = candidates_per_iteration(spec); + let max_iterations = if spec.process.kind == "sequential" { + Some(spec.tasks.len() as u32) + } else { + Some(1) + }; + let execution = ExecutionSpec { + mode: mode.to_string(), + goal: team_goal(spec), + workflow: WorkflowTemplateRef { + template: workflow_template(spec)?.to_string(), + }, + policy: OrchestrationPolicy { + budget: BudgetPolicy { + max_iterations, + max_child_runs: Some(candidate_count), + max_wall_clock_secs: Some(1800), + max_cost_usd_millis: None, + }, + concurrency: ConcurrencyPolicy { + max_concurrent_candidates: concurrency_limit(spec), + }, + convergence: ConvergencePolicy { + strategy: "exhaustive".to_string(), + min_score: None, + max_iterations_without_improvement: None, + }, + max_candidate_failures_per_iteration: candidate_count, + missing_output_policy: "mark_failed".to_string(), + iteration_failure_policy: "continue".to_string(), + }, + evaluation: EvaluationConfig { + scoring_type: "weighted_metrics".to_string(), + weights: BTreeMap::from([("success".to_string(), 1.0)]), + pass_threshold: Some(1.0), + ranking: "highest_score".to_string(), + tie_breaking: "success".to_string(), + }, + variation: VariationConfig::explicit(candidates_per_iteration, explicit), + swarm: true, + supervision: supervision_config(spec), + }; + + execution + .validate(&GlobalConfig { + max_concurrent_child_runs: 20, + }) + .map_err(|err| { + TeamValidationError::new(format!("compiled execution spec is invalid: {err}")) + })?; + + Ok(execution) +} + +fn workflow_template(spec: &TeamSpec) -> Result<&str, TeamValidationError> { + let mut chosen = None; + for agent in &spec.agents { + let Some(template) = &agent.template else { + continue; + }; + if template.trim().is_empty() { + continue; + } + let Some(current) = chosen else { + chosen = Some(template.as_str()); + continue; + }; + if current != template { + return Err(TeamValidationError::new( + "team agents must share the same template in phase1", + )); + } + } + Ok(chosen.unwrap_or(DEFAULT_TEAM_TEMPLATE)) +} + +fn concurrency_limit(spec: &TeamSpec) -> u32 { + match spec.process.kind.as_str() { + "sequential" => 1, + "parallel" => spec.agents.len().max(1) as u32, + "lead_worker" => spec.tasks.len().max(1) as u32, + _ => spec.tasks.len().max(1) as u32, + } +} + +fn supervision_config(spec: &TeamSpec) -> Option { + if spec.process.kind != "lead_worker" { + return None; + } + let lead = spec.process.lead.as_deref()?; + let supervisor_role = match spec.agents.iter().find(|agent| agent.name == lead) { + Some(agent) => agent.role.clone(), + None => "Supervisor".to_string(), + }; + Some(SupervisionConfig { + supervisor_role, + review_policy: SupervisionReviewPolicy { + max_revision_rounds: 1, + retry_on_runtime_failure: true, + require_final_approval: false, + }, + }) +} + +fn team_goal(spec: &TeamSpec) -> String { + let Some(metadata) = &spec.metadata else { + return format!("run {} team tasks", spec.tasks.len()); + }; + let Some(name) = &metadata.name else { + return format!("run {} team tasks", spec.tasks.len()); + }; + name.clone() +} + +fn candidates_per_iteration(spec: &TeamSpec) -> u32 { + if spec.process.kind == "sequential" { + return 1; + } + + let mut count = 0u32; + for task in &spec.tasks { + if task.agent.is_some() { + count += 1; + continue; + } + count += spec.agents.len() as u32; + } + count.max(1) +} + +fn task_agents<'a>( + spec: &'a TeamSpec, + task: &TaskSpec, +) -> Result, TeamValidationError> { + let Some(agent_name) = task.agent.as_deref() else { + let mut agents = Vec::new(); + for agent in &spec.agents { + agents.push(agent); + } + return Ok(agents); + }; + let Some(agent) = spec.agents.iter().find(|agent| agent.name == agent_name) else { + return Err(TeamValidationError::new(format!( + "tasks['{}'].agent references unknown agent '{}'", + task.name, agent_name + ))); + }; + Ok(vec![agent]) +} diff --git a/src/team/mod.rs b/src/team/mod.rs new file mode 100644 index 0000000..f2caf12 --- /dev/null +++ b/src/team/mod.rs @@ -0,0 +1,14 @@ +//! Team authoring and compilation helpers. + +mod compile; +mod schema; + +/// Compiles a [`TeamSpec`] into a normal execution plan. +pub use compile::compile_team_spec; +pub use schema::{ + parse_team_json, parse_team_yaml, AgentSpec, ProcessSpec, TaskSpec, TeamMetadata, TeamSpec, + TeamValidationError, +}; + +/// Marks the public team module for compile-time tests. +pub struct TeamModuleMarker; diff --git a/src/team/schema.rs b/src/team/schema.rs new file mode 100644 index 0000000..5246b76 --- /dev/null +++ b/src/team/schema.rs @@ -0,0 +1,222 @@ +use std::collections::BTreeSet; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +/// Describes optional team metadata. +pub struct TeamMetadata { + #[cfg_attr(feature = "serde", serde(default))] + pub name: Option, +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +/// Describes one named team agent. +pub struct AgentSpec { + pub name: String, + pub role: String, + pub goal: String, + #[cfg_attr(feature = "serde", serde(default))] + pub template: Option, +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +/// Describes one team task. +pub struct TaskSpec { + pub name: String, + pub description: String, + #[cfg_attr(feature = "serde", serde(default))] + pub agent: Option, + #[cfg_attr(feature = "serde", serde(default))] + pub depends_on: Vec, +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +/// Describes the team process mode. +pub struct ProcessSpec { + #[cfg_attr(feature = "serde", serde(rename = "type"))] + pub kind: String, + #[cfg_attr(feature = "serde", serde(default))] + pub lead: Option, +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +/// Describes a high-level team submission. +pub struct TeamSpec { + pub api_version: String, + pub kind: String, + #[cfg_attr(feature = "serde", serde(default))] + pub metadata: Option, + #[cfg_attr(feature = "serde", serde(default))] + pub agents: Vec, + #[cfg_attr(feature = "serde", serde(default))] + pub tasks: Vec, + pub process: ProcessSpec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +/// Reports validation failures for team parsing or compilation. +pub struct TeamValidationError(String); + +impl TeamValidationError { + pub fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl Display for TeamValidationError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl Error for TeamValidationError {} + +impl TeamSpec { + /// Validates the parsed team spec. + /// + /// # Errors + /// + /// Returns [`TeamValidationError`] if the spec is invalid. + pub fn validate(&self) -> Result<(), TeamValidationError> { + if self.api_version.trim().is_empty() { + return Err(TeamValidationError::new("api_version is required")); + } + if self.kind != "team" { + return Err(TeamValidationError::new(format!( + "kind must be 'team', got '{}'", + self.kind + ))); + } + if self.agents.is_empty() { + return Err(TeamValidationError::new( + "team spec must include at least one agent", + )); + } + if self.tasks.is_empty() { + return Err(TeamValidationError::new( + "team spec must include at least one task", + )); + } + + let mut agent_names = BTreeSet::new(); + for agent in &self.agents { + if agent.name.trim().is_empty() { + return Err(TeamValidationError::new("agents[].name is required")); + } + if agent.role.trim().is_empty() { + return Err(TeamValidationError::new(format!( + "agents['{}'].role is required", + agent.name + ))); + } + if agent.goal.trim().is_empty() { + return Err(TeamValidationError::new(format!( + "agents['{}'].goal is required", + agent.name + ))); + } + if !agent_names.insert(agent.name.clone()) { + return Err(TeamValidationError::new(format!( + "duplicate agent name '{}'", + agent.name + ))); + } + } + + match self.process.kind.as_str() { + "sequential" | "parallel" | "lead_worker" => {} + other => { + return Err(TeamValidationError::new(format!( + "process.type must be one of sequential, parallel, lead_worker; got '{other}'" + ))) + } + } + if self.process.kind == "lead_worker" && self.process.lead.is_none() { + return Err(TeamValidationError::new( + "process.lead is required for lead_worker teams", + )); + } + if let Some(lead) = &self.process.lead { + if !agent_names.contains(lead) { + return Err(TeamValidationError::new(format!( + "process.lead references unknown agent '{}'", + lead + ))); + } + } + + let single_agent_name = if self.agents.len() == 1 { + Some(self.agents[0].name.as_str()) + } else { + None + }; + for task in &self.tasks { + if task.name.trim().is_empty() { + return Err(TeamValidationError::new("tasks[].name is required")); + } + if task.description.trim().is_empty() { + return Err(TeamValidationError::new(format!( + "tasks['{}'].description is required", + task.name + ))); + } + if !task.depends_on.is_empty() { + return Err(TeamValidationError::new(format!( + "phase1 team spec does not support depends_on; remove it from tasks['{}']", + task.name + ))); + } + if let Some(agent_name) = task.agent.as_deref() { + if !agent_names.contains(agent_name) { + return Err(TeamValidationError::new(format!( + "tasks['{}'].agent references unknown agent '{}'", + task.name, agent_name + ))); + } + continue; + } + if single_agent_name.is_none() && self.process.kind != "parallel" { + return Err(TeamValidationError::new(format!( + "tasks['{}'].agent is required unless process.type is parallel", + task.name + ))); + } + } + + Ok(()) + } +} + +/// Parses a YAML team spec. +/// +/// # Errors +/// +/// Returns [`TeamValidationError`] if the YAML is invalid or the parsed spec +/// fails validation. +pub fn parse_team_yaml(yaml: &str) -> Result { + let spec: TeamSpec = serde_yaml::from_str(yaml) + .map_err(|err| TeamValidationError::new(format!("invalid team yaml: {err}")))?; + spec.validate()?; + Ok(spec) +} + +/// Parses a JSON team spec. +/// +/// # Errors +/// +/// Returns [`TeamValidationError`] if the JSON is invalid or the parsed spec +/// fails validation. +pub fn parse_team_json(json: &str) -> Result { + let spec: TeamSpec = serde_json::from_str(json) + .map_err(|err| TeamValidationError::new(format!("invalid team json: {err}")))?; + spec.validate()?; + Ok(spec) +} diff --git a/src/templates/compile.rs b/src/templates/compile.rs new file mode 100644 index 0000000..f223f2d --- /dev/null +++ b/src/templates/compile.rs @@ -0,0 +1,402 @@ +use std::collections::BTreeMap; + +use serde_json::{Map, Value}; + +use crate::orchestration::{ExecutionSpec, GlobalConfig}; + +use super::{ControlTemplate, TemplateMetadata, TemplateValidationError}; + +#[derive(Debug, Clone, PartialEq)] +pub struct CompiledTemplate { + pub template: TemplateMetadata, + pub normalized_inputs: BTreeMap, + pub execution_spec: ExecutionSpec, +} + +pub fn compile_template( + template: &ControlTemplate, + inputs: &Value, +) -> Result { + let input_object = inputs + .as_object() + .ok_or_else(|| TemplateValidationError::new("template inputs must be a JSON object"))?; + let normalized_inputs = normalize_inputs(template, input_object)?; + let mut execution_spec = template.defaults.execution_spec.clone(); + execution_spec.workflow.template = template.defaults.workflow_template.clone(); + + for binding in &template.compile.bindings { + let Some(value) = normalized_inputs.get(&binding.input) else { + let field = template.inputs.get(&binding.input).ok_or_else(|| { + TemplateValidationError::new(format!( + "binding references unknown normalized input '{}'", + binding.input + )) + })?; + if field.required || field.default.is_some() { + return Err(TemplateValidationError::new(format!( + "binding references missing normalized input '{}'", + binding.input + ))); + } + continue; + }; + apply_binding(&mut execution_spec, &binding.target, value)?; + } + + validate_execution_kind_shape(template, &execution_spec)?; + execution_spec + .validate(&GlobalConfig { + max_concurrent_child_runs: 20, + }) + .map_err(|err| { + TemplateValidationError::new(format!("compiled execution spec is invalid: {err}")) + })?; + + Ok(CompiledTemplate { + template: template.template.clone(), + normalized_inputs, + execution_spec, + }) +} + +fn normalize_inputs( + template: &ControlTemplate, + inputs: &Map, +) -> Result, TemplateValidationError> { + for key in inputs.keys() { + if !template.inputs.contains_key(key) { + return Err(TemplateValidationError::new(format!( + "unknown input '{}'", + key + ))); + } + } + + let mut normalized = BTreeMap::new(); + for (name, field) in &template.inputs { + let value = match inputs.get(name) { + Some(value) => value.clone(), + None => match &field.default { + Some(value) => value.clone(), + None if field.required => { + return Err(TemplateValidationError::new(format!( + "missing required input '{}'", + name + ))) + } + None => continue, + }, + }; + + validate_input_value(name, field, &value)?; + normalized.insert(name.clone(), value); + } + Ok(normalized) +} + +fn validate_input_value( + name: &str, + field: &super::InputField, + value: &Value, +) -> Result<(), TemplateValidationError> { + match field.field_type.as_str() { + "string" => { + if !value.is_string() { + return Err(TemplateValidationError::new(format!( + "input '{}' must be a string", + name + ))); + } + } + "enum" => { + let Some(raw) = value.as_str() else { + return Err(TemplateValidationError::new(format!( + "input '{}' must be a string", + name + ))); + }; + let values = field.values.as_ref().ok_or_else(|| { + TemplateValidationError::new(format!( + "input '{}' enum values are not configured", + name + )) + })?; + if !values.iter().any(|candidate| candidate == raw) { + return Err(TemplateValidationError::new(format!( + "input '{}' must be one of [{}]", + name, + values.join(", ") + ))); + } + } + "integer" => { + let Some(number) = value.as_i64().or_else(|| value.as_u64().map(|n| n as i64)) else { + return Err(TemplateValidationError::new(format!( + "input '{}' must be an integer", + name + ))); + }; + validate_numeric_range(name, field, number as f64)?; + } + "number" => { + let Some(number) = value.as_f64() else { + return Err(TemplateValidationError::new(format!( + "input '{}' must be a number", + name + ))); + }; + validate_numeric_range(name, field, number)?; + } + "boolean" => { + if !value.is_boolean() { + return Err(TemplateValidationError::new(format!( + "input '{}' must be a boolean", + name + ))); + } + } + other => { + return Err(TemplateValidationError::new(format!( + "unsupported input type '{}' for '{}'", + other, name + ))) + } + } + Ok(()) +} + +fn validate_numeric_range( + name: &str, + field: &super::InputField, + value: f64, +) -> Result<(), TemplateValidationError> { + if let Some(min) = field.min { + if value < min { + return Err(TemplateValidationError::new(format!( + "input '{}' must be >= {}", + name, min + ))); + } + } + if let Some(max) = field.max { + if value > max { + return Err(TemplateValidationError::new(format!( + "input '{}' must be <= {}", + name, max + ))); + } + } + Ok(()) +} + +fn apply_binding( + execution_spec: &mut ExecutionSpec, + target: &str, + value: &Value, +) -> Result<(), TemplateValidationError> { + if let Some(path) = target.strip_prefix("execution_spec.") { + let mut spec_json = serde_json::to_value(&*execution_spec).map_err(|err| { + TemplateValidationError::new(format!("failed to serialize execution spec: {err}")) + })?; + set_json_path(&mut spec_json, path, value.clone())?; + *execution_spec = serde_json::from_value(spec_json).map_err(|err| { + TemplateValidationError::new(format!( + "failed to deserialize bound execution spec: {err}" + )) + })?; + return Ok(()); + } + + if let Some((index, key)) = parse_explicit_override_target(target)? { + let raw = value_to_string(value)?; + let proposal = execution_spec + .variation + .explicit + .get_mut(index) + .ok_or_else(|| { + TemplateValidationError::new(format!( + "variation.explicit[{index}] is required for override bindings" + )) + })?; + proposal.overrides.insert(key.to_string(), raw); + return Ok(()); + } + + Err(TemplateValidationError::new(format!( + "unsupported binding target '{}'", + target + ))) +} + +fn parse_explicit_override_target( + target: &str, +) -> Result, TemplateValidationError> { + let Some(rest) = target.strip_prefix("variation.explicit[") else { + return Ok(None); + }; + let Some(close) = rest.find(']') else { + return Err(TemplateValidationError::new(format!( + "unsupported binding target '{}'", + target + ))); + }; + let index = rest[..close].parse::().map_err(|_| { + TemplateValidationError::new(format!("unsupported binding target '{}'", target)) + })?; + let suffix = &rest[close + 1..]; + let Some(key) = suffix.strip_prefix(".overrides.") else { + return Err(TemplateValidationError::new(format!( + "unsupported binding target '{}'", + target + ))); + }; + if key.is_empty() { + return Err(TemplateValidationError::new(format!( + "unsupported binding target '{}'", + target + ))); + } + Ok(Some((index, key))) +} + +fn set_json_path( + root: &mut Value, + path: &str, + value: Value, +) -> Result<(), TemplateValidationError> { + let tokens = parse_path_tokens(path)?; + let mut current = root; + + for token in &tokens[..tokens.len().saturating_sub(1)] { + current = match token { + PathToken::Field(name) => current.get_mut(name).ok_or_else(|| { + TemplateValidationError::new(format!("unknown execution_spec field '{}'", name)) + })?, + PathToken::Index(index) => current.get_mut(*index).ok_or_else(|| { + TemplateValidationError::new(format!("missing execution_spec index [{}]", index)) + })?, + }; + } + + match tokens.last() { + Some(PathToken::Field(name)) => { + let object = current.as_object_mut().ok_or_else(|| { + TemplateValidationError::new(format!( + "execution_spec target parent for '{}' is not an object", + name + )) + })?; + object.insert(name.clone(), value); + Ok(()) + } + Some(PathToken::Index(index)) => { + let array = current.as_array_mut().ok_or_else(|| { + TemplateValidationError::new(format!( + "execution_spec target parent for index [{}] is not an array", + index + )) + })?; + if *index >= array.len() { + return Err(TemplateValidationError::new(format!( + "missing execution_spec index [{}]", + index + ))); + } + array[*index] = value; + Ok(()) + } + None => Err(TemplateValidationError::new( + "binding path must not be empty", + )), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PathToken { + Field(String), + Index(usize), +} + +fn parse_path_tokens(path: &str) -> Result, TemplateValidationError> { + let mut tokens = Vec::new(); + for part in path.split('.') { + let mut rest = part; + loop { + let Some(open) = rest.find('[') else { + if !rest.is_empty() { + tokens.push(PathToken::Field(rest.to_string())); + } + break; + }; + + if open > 0 { + tokens.push(PathToken::Field(rest[..open].to_string())); + } + + let close = rest[open + 1..].find(']').ok_or_else(|| { + TemplateValidationError::new(format!("invalid binding path '{}'", path)) + })? + open + + 1; + let index = rest[open + 1..close].parse::().map_err(|_| { + TemplateValidationError::new(format!("invalid binding path '{}'", path)) + })?; + tokens.push(PathToken::Index(index)); + rest = &rest[close + 1..]; + if rest.is_empty() { + break; + } + } + } + + if tokens.is_empty() { + return Err(TemplateValidationError::new( + "binding path must not be empty", + )); + } + Ok(tokens) +} + +fn value_to_string(value: &Value) -> Result { + if let Some(raw) = value.as_str() { + return Ok(raw.to_string()); + } + if let Some(raw) = value.as_bool() { + return Ok(raw.to_string()); + } + if let Some(raw) = value.as_i64() { + return Ok(raw.to_string()); + } + if let Some(raw) = value.as_u64() { + return Ok(raw.to_string()); + } + if let Some(raw) = value.as_f64() { + return Ok(raw.to_string()); + } + Err(TemplateValidationError::new( + "override bindings only support scalar input values", + )) +} + +fn validate_execution_kind_shape( + template: &ControlTemplate, + execution_spec: &ExecutionSpec, +) -> Result<(), TemplateValidationError> { + if matches!( + template.template.execution_kind.as_str(), + "single_agent" | "warm_agent" + ) { + if execution_spec.variation.source != "explicit" { + return Err(TemplateValidationError::new(format!( + "execution_kind '{}' requires variation.source=explicit", + template.template.execution_kind + ))); + } + if execution_spec.variation.explicit.len() != 1 { + return Err(TemplateValidationError::new(format!( + "execution_kind '{}' requires exactly one explicit variation proposal", + template.template.execution_kind + ))); + } + } + Ok(()) +} diff --git a/src/templates/mod.rs b/src/templates/mod.rs new file mode 100644 index 0000000..3e1c34b --- /dev/null +++ b/src/templates/mod.rs @@ -0,0 +1,72 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +mod compile; +mod schema; + +pub use compile::{compile_template, CompiledTemplate}; +pub use schema::{ + CompileBinding, ControlTemplate, InputField, TemplateCompile, TemplateDefaults, + TemplateMetadata, TemplateValidationError, +}; + +pub struct TemplateModuleMarker; + +pub fn parse_template_yaml(yaml: &str) -> Result { + let template: ControlTemplate = serde_yaml::from_str(yaml) + .map_err(|err| TemplateValidationError::new(format!("invalid template yaml: {err}")))?; + template.validate()?; + Ok(template) +} + +pub fn list_templates() -> Result, TemplateValidationError> { + let mut templates = Vec::new(); + let template_dir = template_dir(); + let entries = fs::read_dir(&template_dir).map_err(|err| { + TemplateValidationError::new(format!( + "failed to read template dir '{}': {err}", + template_dir.display() + )) + })?; + + for entry in entries { + let entry = entry.map_err(|err| { + TemplateValidationError::new(format!( + "failed to read template dir entry '{}': {err}", + template_dir.display() + )) + })?; + let path = entry.path(); + if !is_yaml_file(&path) { + continue; + } + let template = load_template_from_path(&path)?; + templates.push(template.template); + } + + templates.sort_by(|left, right| left.id.cmp(&right.id)); + Ok(templates) +} + +pub fn load_template(id: &str) -> Result { + let path = template_dir().join(format!("{id}.yaml")); + load_template_from_path(&path) +} + +fn load_template_from_path(path: &Path) -> Result { + let yaml = fs::read_to_string(path).map_err(|err| { + TemplateValidationError::new(format!( + "failed to read template file '{}': {err}", + path.display() + )) + })?; + parse_template_yaml(&yaml) +} + +fn template_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("templates") +} + +fn is_yaml_file(path: &Path) -> bool { + matches!(path.extension().and_then(|ext| ext.to_str()), Some("yaml")) +} diff --git a/src/templates/schema.rs b/src/templates/schema.rs new file mode 100644 index 0000000..c1c7903 --- /dev/null +++ b/src/templates/schema.rs @@ -0,0 +1,149 @@ +use std::collections::BTreeMap; +use std::error::Error; +use std::fmt::{Display, Formatter}; + +use crate::orchestration::ExecutionSpec; + +#[cfg(feature = "serde")] +use serde::{Deserialize, Serialize}; + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq)] +pub struct ControlTemplate { + pub api_version: String, + pub kind: String, + pub template: TemplateMetadata, + pub inputs: BTreeMap, + pub defaults: TemplateDefaults, + pub compile: TemplateCompile, +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TemplateMetadata { + pub id: String, + pub name: String, + pub execution_kind: String, + pub description: String, +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq)] +pub struct InputField { + #[cfg_attr(feature = "serde", serde(rename = "type"))] + pub field_type: String, + pub required: bool, + pub description: String, + #[cfg_attr(feature = "serde", serde(default))] + pub default: Option, + #[cfg_attr(feature = "serde", serde(default))] + pub values: Option>, + #[cfg_attr(feature = "serde", serde(default))] + pub min: Option, + #[cfg_attr(feature = "serde", serde(default))] + pub max: Option, +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq)] +pub struct TemplateDefaults { + pub workflow_template: String, + pub execution_spec: ExecutionSpec, +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TemplateCompile { + pub bindings: Vec, +} + +#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompileBinding { + pub input: String, + pub target: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TemplateValidationError(String); + +impl TemplateValidationError { + pub fn new(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl Display for TemplateValidationError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl Error for TemplateValidationError {} + +impl ControlTemplate { + pub fn validate(&self) -> Result<(), TemplateValidationError> { + if self.api_version.trim().is_empty() { + return Err(TemplateValidationError::new("api_version is required")); + } + if self.kind != "control_template" { + return Err(TemplateValidationError::new(format!( + "unsupported template kind '{}'", + self.kind + ))); + } + if !matches!( + self.template.execution_kind.as_str(), + "single_agent" | "warm_agent" | "execution" + ) { + return Err(TemplateValidationError::new(format!( + "unsupported execution_kind '{}'", + self.template.execution_kind + ))); + } + if self.defaults.workflow_template.trim().is_empty() { + return Err(TemplateValidationError::new( + "defaults.workflow_template is required", + )); + } + if self.compile.bindings.is_empty() { + return Err(TemplateValidationError::new( + "compile.bindings must not be empty", + )); + } + for (name, field) in &self.inputs { + match field.field_type.as_str() { + "string" | "integer" | "number" | "boolean" => {} + "enum" => { + let values = field.values.as_ref().ok_or_else(|| { + TemplateValidationError::new(format!( + "input '{}' of type enum must define values", + name + )) + })?; + if values.is_empty() { + return Err(TemplateValidationError::new(format!( + "input '{}' enum values must not be empty", + name + ))); + } + } + other => { + return Err(TemplateValidationError::new(format!( + "unsupported input type '{}' for '{}'", + other, name + ))) + } + } + } + for binding in &self.compile.bindings { + if !self.inputs.contains_key(&binding.input) { + return Err(TemplateValidationError::new(format!( + "binding references unknown input '{}'", + binding.input + ))); + } + } + Ok(()) + } +} diff --git a/templates/benchmark-runner-python.yaml b/templates/benchmark-runner-python.yaml new file mode 100644 index 0000000..dcab81e --- /dev/null +++ b/templates/benchmark-runner-python.yaml @@ -0,0 +1,93 @@ +api_version: v1 +kind: control_template + +template: + id: benchmark-runner-python + name: Benchmark Runner Python + execution_kind: execution + description: Compare multiple Python benchmark candidates in one swarm execution. + +inputs: + goal: + type: string + required: true + description: High-level benchmark goal shown in the execution record. + provider: + type: enum + required: false + default: claude + values: [claude, codex] + description: LLM provider override for every benchmark candidate. + snapshot: + type: string + required: false + description: Optional void-box snapshot id restored into every candidate run. + +defaults: + workflow_template: examples/runtime-templates/transform_optimizer_agent.yaml + execution_spec: + mode: swarm + goal: Compare benchmark candidates + workflow: + template: "" + policy: + budget: + max_iterations: 1 + max_child_runs: 3 + max_wall_clock_secs: 1800 + max_cost_usd_millis: null + concurrency: + max_concurrent_candidates: 3 + convergence: + strategy: threshold + min_score: 0.82 + max_iterations_without_improvement: 0 + max_candidate_failures_per_iteration: 3 + missing_output_policy: mark_failed + iteration_failure_policy: continue + evaluation: + scoring_type: weighted_metrics + weights: + latency_p99_ms: -0.50 + error_rate: -0.35 + cpu_pct: -0.15 + pass_threshold: 0.82 + ranking: highest_score + tie_breaking: latency_p99_ms + variation: + source: explicit + candidates_per_iteration: 3 + selection: null + parameter_space: {} + explicit: + - overrides: + sandbox.env.TRANSFORM_STRATEGY: baseline + sandbox.env.TRANSFORM_PARALLELISM: "2" + sandbox.env.TRANSFORM_ROLE: latency-baseline + - overrides: + sandbox.env.TRANSFORM_STRATEGY: cache-aware + sandbox.env.TRANSFORM_CACHE_MODE: hot-path + sandbox.env.TRANSFORM_ROLE: cache-locality + - overrides: + sandbox.env.TRANSFORM_STRATEGY: high-throughput + sandbox.env.TRANSFORM_PARALLELISM: "8" + sandbox.env.TRANSFORM_ROLE: max-throughput + swarm: true + supervision: null + +compile: + bindings: + - input: goal + target: execution_spec.goal + - input: provider + target: variation.explicit[0].overrides.llm.provider + - input: provider + target: variation.explicit[1].overrides.llm.provider + - input: provider + target: variation.explicit[2].overrides.llm.provider + - input: snapshot + target: variation.explicit[0].overrides.snapshot + - input: snapshot + target: variation.explicit[1].overrides.snapshot + - input: snapshot + target: variation.explicit[2].overrides.snapshot diff --git a/templates/single-agent-basic.yaml b/templates/single-agent-basic.yaml new file mode 100644 index 0000000..fd1295b --- /dev/null +++ b/templates/single-agent-basic.yaml @@ -0,0 +1,72 @@ +api_version: v1 +kind: control_template + +template: + id: single-agent-basic + name: Single Agent + execution_kind: single_agent + description: Run one agent once and return the result. + +inputs: + goal: + type: string + required: true + description: High-level goal shown in the execution record. + prompt: + type: string + required: true + description: Prompt passed to the agent. + provider: + type: enum + required: false + default: claude + values: [claude, codex] + description: LLM provider override. + +defaults: + workflow_template: examples/runtime-templates/claude_mcp_diagnostic_agent.yaml + execution_spec: + mode: swarm + goal: Single agent task + workflow: + template: "" + policy: + budget: + max_iterations: 1 + max_child_runs: 1 + max_wall_clock_secs: 900 + max_cost_usd_millis: null + concurrency: + max_concurrent_candidates: 1 + convergence: + strategy: threshold + min_score: 1.0 + max_iterations_without_improvement: 0 + max_candidate_failures_per_iteration: 1 + missing_output_policy: mark_failed + iteration_failure_policy: fail_execution + evaluation: + scoring_type: weighted_metrics + weights: + success: 1.0 + pass_threshold: 1.0 + ranking: highest_score + tie_breaking: success + variation: + source: explicit + candidates_per_iteration: 1 + selection: null + parameter_space: {} + explicit: + - overrides: {} + swarm: true + supervision: null + +compile: + bindings: + - input: goal + target: execution_spec.goal + - input: prompt + target: variation.explicit[0].overrides.agent.prompt + - input: provider + target: variation.explicit[0].overrides.llm.provider diff --git a/templates/warm-agent-basic.yaml b/templates/warm-agent-basic.yaml new file mode 100644 index 0000000..da33d54 --- /dev/null +++ b/templates/warm-agent-basic.yaml @@ -0,0 +1,72 @@ +api_version: v1 +kind: control_template + +template: + id: warm-agent-basic + name: Warm Agent + execution_kind: warm_agent + description: Start one long-running service-mode agent. + +inputs: + goal: + type: string + required: true + description: High-level goal shown in the execution record. + prompt: + type: string + required: true + description: Prompt passed to the service agent. + provider: + type: enum + required: false + default: claude + values: [claude, codex] + description: LLM provider override. + +defaults: + workflow_template: examples/runtime-templates/warm_agent_basic.yaml + execution_spec: + mode: swarm + goal: Warm agent task + workflow: + template: "" + policy: + budget: + max_iterations: 1 + max_child_runs: 1 + max_wall_clock_secs: 3600 + max_cost_usd_millis: null + concurrency: + max_concurrent_candidates: 1 + convergence: + strategy: threshold + min_score: 1.0 + max_iterations_without_improvement: 0 + max_candidate_failures_per_iteration: 1 + missing_output_policy: mark_failed + iteration_failure_policy: fail_execution + evaluation: + scoring_type: weighted_metrics + weights: + success: 1.0 + pass_threshold: 1.0 + ranking: highest_score + tie_breaking: success + variation: + source: explicit + candidates_per_iteration: 1 + selection: null + parameter_space: {} + explicit: + - overrides: {} + swarm: true + supervision: null + +compile: + bindings: + - input: goal + target: execution_spec.goal + - input: prompt + target: variation.explicit[0].overrides.agent.prompt + - input: provider + target: variation.explicit[0].overrides.llm.provider diff --git a/tests/batch_api.rs b/tests/batch_api.rs new file mode 100644 index 0000000..f2dd48e --- /dev/null +++ b/tests/batch_api.rs @@ -0,0 +1,328 @@ +#![cfg(feature = "serde")] + +use void_control::batch; + +#[test] +fn batch_api_module_is_exposed() { + let _ = std::any::type_name::(); +} + +#[test] +fn batch_schema_parses_batch_shape() { + let yaml = r#" +api_version: v1 +kind: batch + +metadata: + name: repo-background-work + +worker: + template: coder-agent + provider: claude + +mode: + parallelism: 3 + background: true + interaction: none + +jobs: + - name: auth + prompt: Fix failing auth tests + - name: logging + prompt: Improve retry logging +"#; + + let batch = batch::parse_batch_yaml(yaml).expect("parse batch"); + assert_eq!(batch.api_version, "v1"); + assert_eq!(batch.kind, "batch"); + assert_eq!( + batch.metadata.as_ref().and_then(|m| m.name.as_deref()), + Some("repo-background-work") + ); + assert_eq!(batch.worker.template, "coder-agent"); + assert_eq!(batch.worker.provider.as_deref(), Some("claude")); + assert_eq!(batch.mode.as_ref().and_then(|m| m.parallelism), Some(3)); + assert_eq!( + batch.mode.as_ref().and_then(|m| m.interaction.as_deref()), + Some("none") + ); + assert_eq!(batch.jobs.len(), 2); + assert_eq!(batch.jobs[0].prompt, "Fix failing auth tests"); +} + +#[test] +fn batch_schema_normalizes_yolo_alias_to_batch() { + let json = r#" +{ + "api_version": "v1", + "kind": "yolo", + "worker": { + "template": "coder-agent" + }, + "jobs": [ + { + "prompt": "Review migration safety" + } + ] +} +"#; + + let batch = batch::parse_batch_json(json).expect("parse yolo alias"); + assert_eq!(batch.kind, "batch"); + assert_eq!(batch.worker.template, "coder-agent"); + assert_eq!(batch.jobs.len(), 1); + assert_eq!(batch.jobs[0].prompt, "Review migration safety"); +} + +#[test] +fn batch_schema_rejects_missing_jobs() { + let yaml = r#" +api_version: v1 +kind: batch + +worker: + template: coder-agent + +jobs: [] +"#; + + let err = batch::parse_batch_yaml(yaml).expect_err("batch should fail"); + assert!( + err.to_string().contains("jobs must not be empty"), + "unexpected error: {err}" + ); +} + +#[test] +fn batch_compile_builds_swarm_execution_spec() { + let yaml = r#" +api_version: v1 +kind: batch + +worker: + template: examples/runtime-templates/warm_agent_basic.yaml + provider: claude + +mode: + parallelism: 2 + +jobs: + - name: auth + prompt: Fix failing auth tests + - name: logging + prompt: Improve retry logging + - name: migrations + prompt: Review DB migration safety +"#; + + let batch = batch::parse_batch_yaml(yaml).expect("parse batch"); + let execution = batch::compile_batch_spec(&batch).expect("compile batch"); + + assert_eq!(execution.mode, "swarm"); + assert!(execution.swarm); + assert_eq!( + execution.workflow.template, + "examples/runtime-templates/warm_agent_basic.yaml" + ); + assert_eq!(execution.variation.source, "explicit"); + assert_eq!(execution.variation.explicit.len(), 3); + assert_eq!(execution.variation.candidates_per_iteration, 2); + assert_eq!(execution.policy.concurrency.max_concurrent_candidates, 2); + assert_eq!( + execution.variation.explicit[0] + .overrides + .get("agent.prompt") + .map(String::as_str), + Some("Fix failing auth tests") + ); + assert_eq!( + execution.variation.explicit[1] + .overrides + .get("agent.prompt") + .map(String::as_str), + Some("Improve retry logging") + ); + assert_eq!( + execution.variation.explicit[2] + .overrides + .get("agent.prompt") + .map(String::as_str), + Some("Review DB migration safety") + ); + for proposal in &execution.variation.explicit { + assert_eq!( + proposal.overrides.get("llm.provider").map(String::as_str), + Some("claude") + ); + } +} + +#[test] +fn batch_compile_uses_job_count_when_parallelism_is_omitted() { + let yaml = r#" +api_version: v1 +kind: yolo + +worker: + template: examples/runtime-templates/warm_agent_basic.yaml + +jobs: + - prompt: Fix failing auth tests + - prompt: Improve retry logging +"#; + + let batch = batch::parse_batch_yaml(yaml).expect("parse batch"); + let execution = batch::compile_batch_spec(&batch).expect("compile batch"); + + assert_eq!(batch.kind, "batch"); + assert_eq!(execution.variation.explicit.len(), 2); + assert_eq!(execution.variation.candidates_per_iteration, 2); + assert_eq!(execution.policy.concurrency.max_concurrent_candidates, 2); +} + +fn temp_root(label: &str) -> std::path::PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos(); + std::env::temp_dir().join(format!("void-control-batch-{label}-{nanos}")) +} + +#[test] +fn batch_bridge_dry_run_returns_compiled_preview() { + let body = serde_json::json!({ + "api_version": "v1", + "kind": "batch", + "worker": { + "template": "examples/runtime-templates/warm_agent_basic.yaml", + "provider": "claude" + }, + "mode": { + "parallelism": 2 + }, + "jobs": [ + { "name": "auth", "prompt": "Fix failing auth tests" }, + { "name": "logging", "prompt": "Improve retry logging" }, + { "name": "migrations", "prompt": "Review DB migration safety" } + ] + }) + .to_string(); + + let response = void_control::bridge::handle_bridge_request_for_test( + "POST", + "/v1/batch/dry-run", + Some(&body), + ) + .expect("response"); + + assert_eq!(response.status, 200); + assert_eq!(response.json["kind"], "batch"); + assert_eq!(response.json["compiled_primitive"], "swarm"); + assert_eq!(response.json["compiled"]["variation_source"], "explicit"); + assert_eq!(response.json["compiled"]["candidates_per_iteration"], 2); + assert_eq!( + response.json["compiled"]["candidate_overrides"] + .as_array() + .map(|items| items.len()), + Some(3) + ); +} + +#[test] +fn batch_bridge_run_creates_normal_execution() { + let root = temp_root("run"); + let spec_dir = root.join("specs"); + let execution_dir = root.join("executions"); + let body = serde_json::json!({ + "api_version": "v1", + "kind": "batch", + "worker": { + "template": "examples/runtime-templates/warm_agent_basic.yaml" + }, + "jobs": [ + { "name": "auth", "prompt": "Fix failing auth tests" }, + { "name": "logging", "prompt": "Improve retry logging" } + ] + }) + .to_string(); + + let response = void_control::bridge::handle_bridge_request_with_dirs_for_test( + "POST", + "/v1/batch/run", + Some(&body), + &spec_dir, + &execution_dir, + ) + .expect("response"); + + assert_eq!(response.status, 200); + assert_eq!(response.json["kind"], "batch"); + assert_eq!(response.json["compiled_primitive"], "swarm"); + assert_eq!(response.json["status"], "Pending"); + + let execution_id = response.json["execution_id"] + .as_str() + .expect("execution_id"); + let store = void_control::orchestration::FsExecutionStore::new(execution_dir.clone()); + let spec = store.load_spec(execution_id).expect("load compiled spec"); + assert_eq!(spec.mode, "swarm"); + assert_eq!(spec.variation.explicit.len(), 2); + + let inspect = void_control::bridge::handle_bridge_request_with_dirs_for_test( + "GET", + &format!("/v1/batch-runs/{execution_id}"), + None, + &spec_dir, + &execution_dir, + ) + .expect("inspect"); + + assert_eq!(inspect.status, 200); + assert_eq!(inspect.json["kind"], "batch"); + assert_eq!(inspect.json["run_id"], execution_id); + assert_eq!(inspect.json["execution"]["execution_id"], execution_id); +} + +#[test] +fn yolo_bridge_alias_runs_as_batch() { + let root = temp_root("yolo"); + let spec_dir = root.join("specs"); + let execution_dir = root.join("executions"); + let body = serde_json::json!({ + "api_version": "v1", + "kind": "yolo", + "worker": { + "template": "examples/runtime-templates/warm_agent_basic.yaml" + }, + "jobs": [ + { "prompt": "Review migration safety" } + ] + }) + .to_string(); + + let response = void_control::bridge::handle_bridge_request_with_dirs_for_test( + "POST", + "/v1/yolo/run", + Some(&body), + &spec_dir, + &execution_dir, + ) + .expect("response"); + + assert_eq!(response.status, 200); + assert_eq!(response.json["kind"], "batch"); + assert_eq!(response.json["compiled_primitive"], "swarm"); +} + +#[test] +fn batch_example_file_parses_and_compiles() { + let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("examples/batch/background_repo_work.yaml"); + let yaml = std::fs::read_to_string(&path).expect("read example batch spec"); + let batch = batch::parse_batch_yaml(&yaml).expect("parse example"); + let execution = batch::compile_batch_spec(&batch).expect("compile example"); + + assert_eq!(batch.kind, "batch"); + assert_eq!(execution.mode, "swarm"); + assert!(!execution.variation.explicit.is_empty()); +} diff --git a/tests/execution_bridge.rs b/tests/execution_bridge.rs index 59eb54b..c67a347 100644 --- a/tests/execution_bridge.rs +++ b/tests/execution_bridge.rs @@ -5,6 +5,245 @@ use std::path::Path; use serde_json::json; +#[test] +fn template_list_route_returns_checked_in_templates() { + let response = + void_control::bridge::handle_bridge_request_for_test("GET", "/v1/templates", None) + .expect("response"); + + assert_eq!(response.status, 200); + let templates = response.json["templates"] + .as_array() + .expect("templates array"); + assert!(templates + .iter() + .any(|item| item["id"] == "single-agent-basic")); + assert!(templates + .iter() + .any(|item| item["id"] == "warm-agent-basic")); +} + +#[test] +fn template_get_route_returns_template_details() { + let response = void_control::bridge::handle_bridge_request_for_test( + "GET", + "/v1/templates/single-agent-basic", + None, + ) + .expect("response"); + + assert_eq!(response.status, 200); + assert_eq!(response.json["template"]["id"], "single-agent-basic"); + assert_eq!( + response.json["defaults"]["workflow_template"], + "examples/runtime-templates/claude_mcp_diagnostic_agent.yaml" + ); + assert!(response.json["inputs"]["prompt"].is_object()); +} + +#[test] +fn template_dry_run_route_returns_compiled_execution_preview() { + let body = json!({ + "inputs": { + "goal": "Summarize this repo", + "prompt": "Read the repo and summarize risks", + "provider": "claude" + } + }) + .to_string(); + + let response = void_control::bridge::handle_bridge_request_for_test( + "POST", + "/v1/templates/single-agent-basic/dry-run", + Some(&body), + ) + .expect("response"); + + assert_eq!(response.status, 200); + assert_eq!(response.json["template"]["id"], "single-agent-basic"); + assert_eq!(response.json["inputs"]["goal"], "Summarize this repo"); + assert_eq!(response.json["compiled"]["goal"], "Summarize this repo"); + assert_eq!( + response.json["compiled"]["workflow_template"], + "examples/runtime-templates/claude_mcp_diagnostic_agent.yaml" + ); + assert_eq!( + response.json["compiled"]["overrides"]["agent.prompt"], + "Read the repo and summarize risks" + ); + assert_eq!( + response.json["compiled"]["overrides"]["llm.provider"], + "claude" + ); +} + +#[test] +fn template_execute_route_creates_normal_execution() { + let root = temp_root("template-execute"); + let spec_dir = root.join("specs"); + let execution_dir = root.join("executions"); + let body = json!({ + "inputs": { + "goal": "Keep a warm agent ready", + "prompt": "Stay alive for follow-up repo work." + } + }) + .to_string(); + + let response = void_control::bridge::handle_bridge_request_with_dirs_for_test( + "POST", + "/v1/templates/warm-agent-basic/execute", + Some(&body), + &spec_dir, + &execution_dir, + ) + .expect("response"); + + assert_eq!(response.status, 200); + assert_eq!(response.json["template"]["id"], "warm-agent-basic"); + assert_eq!(response.json["template"]["execution_kind"], "warm_agent"); + assert_eq!(response.json["goal"], "Keep a warm agent ready"); + assert_eq!(response.json["status"], "Pending"); + + let execution_id = response.json["execution_id"] + .as_str() + .expect("execution_id"); + let store = void_control::orchestration::FsExecutionStore::new(execution_dir); + let spec = store.load_spec(execution_id).expect("load compiled spec"); + assert_eq!(spec.goal, "Keep a warm agent ready"); + assert_eq!( + spec.workflow.template, + "examples/runtime-templates/warm_agent_basic.yaml" + ); + assert_eq!( + spec.variation.explicit[0] + .overrides + .get("agent.prompt") + .map(String::as_str), + Some("Stay alive for follow-up repo work.") + ); + assert_eq!( + spec.variation.explicit[0] + .overrides + .get("llm.provider") + .map(String::as_str), + Some("claude") + ); +} + +#[test] +fn template_get_route_returns_benchmark_template_details() { + let response = void_control::bridge::handle_bridge_request_for_test( + "GET", + "/v1/templates/benchmark-runner-python", + None, + ) + .expect("response"); + + assert_eq!(response.status, 200); + assert_eq!(response.json["template"]["id"], "benchmark-runner-python"); + assert_eq!(response.json["template"]["execution_kind"], "execution"); + assert_eq!( + response.json["defaults"]["workflow_template"], + "examples/runtime-templates/transform_optimizer_agent.yaml" + ); + assert!(response.json["inputs"]["snapshot"].is_object()); +} + +#[test] +fn template_dry_run_route_returns_benchmark_candidate_overrides() { + let body = json!({ + "inputs": { + "goal": "Compare transform benchmark candidates", + "provider": "claude", + "snapshot": "snap-transform-01" + } + }) + .to_string(); + + let response = void_control::bridge::handle_bridge_request_for_test( + "POST", + "/v1/templates/benchmark-runner-python/dry-run", + Some(&body), + ) + .expect("response"); + + assert_eq!(response.status, 200); + assert_eq!(response.json["template"]["id"], "benchmark-runner-python"); + assert_eq!( + response.json["compiled"]["goal"], + "Compare transform benchmark candidates" + ); + assert_eq!( + response.json["compiled"]["workflow_template"], + "examples/runtime-templates/transform_optimizer_agent.yaml" + ); + let candidate_overrides = response.json["compiled"]["candidate_overrides"] + .as_array() + .expect("candidate_overrides array"); + assert_eq!(candidate_overrides.len(), 3); + assert_eq!( + candidate_overrides[0]["sandbox.env.TRANSFORM_ROLE"], + "latency-baseline" + ); + assert_eq!( + candidate_overrides[1]["sandbox.env.TRANSFORM_ROLE"], + "cache-locality" + ); + assert_eq!( + candidate_overrides[2]["sandbox.env.TRANSFORM_ROLE"], + "max-throughput" + ); + assert_eq!(candidate_overrides[0]["snapshot"], "snap-transform-01"); + assert_eq!(candidate_overrides[1]["llm.provider"], "claude"); +} + +#[test] +fn template_execute_route_creates_benchmark_execution() { + let root = temp_root("template-execute-benchmark"); + let spec_dir = root.join("specs"); + let execution_dir = root.join("executions"); + let body = json!({ + "inputs": { + "goal": "Compare transform benchmark candidates", + "provider": "claude" + } + }) + .to_string(); + + let response = void_control::bridge::handle_bridge_request_with_dirs_for_test( + "POST", + "/v1/templates/benchmark-runner-python/execute", + Some(&body), + &spec_dir, + &execution_dir, + ) + .expect("response"); + + assert_eq!(response.status, 200); + assert_eq!(response.json["template"]["id"], "benchmark-runner-python"); + assert_eq!(response.json["template"]["execution_kind"], "execution"); + assert_eq!( + response.json["goal"], + "Compare transform benchmark candidates" + ); + + let execution_id = response.json["execution_id"] + .as_str() + .expect("execution_id"); + let store = void_control::orchestration::FsExecutionStore::new(execution_dir); + let spec = store.load_spec(execution_id).expect("load compiled spec"); + assert_eq!(spec.variation.explicit.len(), 3); + assert_eq!(spec.variation.candidates_per_iteration, 3); + assert_eq!( + spec.variation.explicit[2] + .overrides + .get("sandbox.env.TRANSFORM_STRATEGY") + .map(String::as_str), + Some("high-throughput") + ); +} + #[test] fn dry_run_endpoint_returns_plan_without_creating_execution() { let body = json!({ diff --git a/tests/team_api.rs b/tests/team_api.rs new file mode 100644 index 0000000..bccf78b --- /dev/null +++ b/tests/team_api.rs @@ -0,0 +1,327 @@ +#![cfg(feature = "serde")] + +use std::path::{Path, PathBuf}; + +fn submit_team_dry_run(spec: &str) -> void_control::bridge::TestBridgeResponse { + void_control::bridge::handle_bridge_request_for_test("POST", "/v1/teams/dry-run", Some(spec)) + .expect("team dry-run response") +} + +fn submit_team_run(spec: &str) -> void_control::bridge::TestBridgeResponse { + void_control::bridge::handle_bridge_request_for_test("POST", "/v1/teams/run", Some(spec)) + .expect("team run response") +} + +fn temp_bridge_root(name: &str) -> PathBuf { + let mut root = std::env::temp_dir(); + root.push(format!( + "void-control-team-test-{}-{name}", + std::process::id() + )); + root +} + +fn submit_team_run_with_root(spec: &str, root: &Path) -> void_control::bridge::TestBridgeResponse { + void_control::bridge::handle_bridge_request_with_dirs_for_test( + "POST", + "/v1/teams/run", + Some(spec), + &root.join("specs"), + &root.join("executions"), + ) + .expect("team run response") +} + +fn fetch_team_run_with_root( + execution_id: &str, + root: &Path, +) -> void_control::bridge::TestBridgeResponse { + let path = format!("/v1/team-runs/{execution_id}"); + void_control::bridge::handle_bridge_request_with_dirs_for_test( + "GET", + &path, + None, + &root.join("specs"), + &root.join("executions"), + ) + .expect("team get response") +} + +#[test] +fn team_dry_run_rejects_missing_agents() { + let spec = r#" +api_version: v1 +kind: team +tasks: + - name: write + description: Write the article +process: + type: sequential +"#; + + let response = submit_team_dry_run(spec); + + assert_eq!(response.status, 400); + assert!( + response + .json + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("") + .contains("team spec must include at least one agent"), + "unexpected response: {response:?}" + ); +} + +#[test] +fn team_dry_run_compiles_parallel_process_to_swarm() { + let spec = r#" +api_version: v1 +kind: team +agents: + - name: researcher + role: Researcher + goal: Find information +tasks: + - name: research + description: Gather evidence + agent: researcher +process: + type: parallel +"#; + + let response = submit_team_dry_run(spec); + + assert_eq!(response.status, 200); + assert_eq!(response.json["kind"], "team"); + assert_eq!(response.json["compiled_primitive"], "swarm"); +} + +#[test] +fn team_dry_run_compiles_sequential_process_one_task_per_iteration() { + let spec = r#" +api_version: v1 +kind: team +metadata: + name: rust-article-team +agents: + - name: researcher + role: Researcher + goal: Find information +tasks: + - name: research + description: Gather evidence + agent: researcher + - name: write + description: Draft the article + agent: researcher +process: + type: sequential +"#; + + let response = submit_team_dry_run(spec); + + assert_eq!(response.status, 200); + assert_eq!(response.json["compiled"]["candidates_per_iteration"], 1); + assert_eq!( + response.json["compiled"]["candidate_overrides"] + .as_array() + .expect("candidate_overrides") + .len(), + 2 + ); +} + +#[test] +fn team_dry_run_rejects_conflicting_agent_templates() { + let spec = r#" +api_version: v1 +kind: team +agents: + - name: researcher + role: Researcher + goal: Find information + template: examples/runtime-templates/warm_agent_basic.yaml + - name: writer + role: Writer + goal: Draft the article + template: examples/runtime-templates/transform_optimizer_agent.yaml +tasks: + - name: research + description: Gather evidence + agent: researcher + - name: write + description: Draft the article + agent: writer +process: + type: parallel +"#; + + let response = submit_team_dry_run(spec); + + assert_eq!(response.status, 400); + assert!( + response + .json + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("") + .contains("team agents must share the same template"), + "unexpected response: {response:?}" + ); +} + +#[test] +fn team_dry_run_requires_explicit_lead_for_lead_worker() { + let spec = r#" +api_version: v1 +kind: team +agents: + - name: reviewer + role: Reviewer + goal: Review work + - name: worker + role: Worker + goal: Produce draft +tasks: + - name: review + description: Review the draft +process: + type: lead_worker +"#; + + let response = submit_team_dry_run(spec); + + assert_eq!(response.status, 400); + assert!( + response + .json + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("") + .contains("process.lead is required for lead_worker teams"), + "unexpected response: {response:?}" + ); +} + +#[test] +fn team_dry_run_parallel_without_task_agent_fans_out_across_agents() { + let spec = r#" +api_version: v1 +kind: team +agents: + - name: researcher + role: Researcher + goal: Find information + - name: writer + role: Writer + goal: Draft the article +tasks: + - name: article + description: Produce the article +process: + type: parallel +"#; + + let response = submit_team_dry_run(spec); + + assert_eq!(response.status, 200); + assert_eq!(response.json["compiled"]["candidates_per_iteration"], 2); +} + +#[test] +fn team_dry_run_rejects_depends_on_in_phase_one() { + let spec = r#" +api_version: v1 +kind: team +agents: + - name: researcher + role: Researcher + goal: Find information +tasks: + - name: research + description: Gather evidence + agent: researcher + - name: write + description: Draft the article + agent: researcher + depends_on: + - research +process: + type: sequential +"#; + + let response = submit_team_dry_run(spec); + + assert_eq!(response.status, 400); + assert!( + response + .json + .get("message") + .and_then(|value| value.as_str()) + .unwrap_or("") + .contains("phase1 team spec does not support depends_on"), + "unexpected response: {response:?}" + ); +} + +#[test] +fn team_run_returns_execution_summary() { + let spec = r#" +api_version: v1 +kind: team +metadata: + name: rust-article-team +agents: + - name: researcher + role: Researcher + goal: Find information +tasks: + - name: research + description: Gather evidence + agent: researcher +process: + type: parallel +"#; + + let response = submit_team_run(spec); + + assert_eq!(response.status, 200); + assert_eq!(response.json["kind"], "team"); + assert!(response.json.get("execution_id").is_some()); +} + +#[test] +fn team_run_get_wraps_execution_detail() { + let spec = r#" +api_version: v1 +kind: team +metadata: + name: rust-article-team +agents: + - name: researcher + role: Researcher + goal: Find information +tasks: + - name: research + description: Gather evidence + agent: researcher +process: + type: parallel +"#; + + let root = temp_bridge_root("roundtrip"); + let started = submit_team_run_with_root(spec, &root); + let execution_id = started + .json + .get("execution_id") + .and_then(|value| value.as_str()) + .expect("execution_id"); + + let response = fetch_team_run_with_root(execution_id, &root); + + assert_eq!(response.status, 200); + assert_eq!(response.json["kind"], "team"); + assert_eq!(response.json["run_id"], execution_id); + assert_eq!(response.json["execution"]["execution_id"], execution_id); +} diff --git a/tests/template_api.rs b/tests/template_api.rs new file mode 100644 index 0000000..28700d8 --- /dev/null +++ b/tests/template_api.rs @@ -0,0 +1,323 @@ +#[cfg(feature = "serde")] +use void_control::templates; + +#[cfg(feature = "serde")] +fn repo_root() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +#[cfg(feature = "serde")] +#[test] +fn template_api_module_is_exposed() { + let _ = std::any::type_name::(); +} + +#[cfg(feature = "serde")] +#[test] +fn template_schema_parses_phase_one_shape() { + let yaml = r#" +api_version: v1 +kind: control_template + +template: + id: single-agent-basic + name: Single Agent + execution_kind: single_agent + description: Run one agent once and return the result. + +inputs: + goal: + type: string + required: true + description: Goal shown in the execution record. + provider: + type: enum + required: false + default: claude + values: [claude, codex] + description: Provider override. + +defaults: + workflow_template: examples/runtime-templates/claude_mcp_diagnostic_agent.yaml + execution_spec: + mode: swarm + goal: Single agent task + workflow: + template: "" + policy: + budget: + max_iterations: 1 + max_child_runs: 1 + max_wall_clock_secs: 900 + max_cost_usd_millis: null + concurrency: + max_concurrent_candidates: 1 + convergence: + strategy: threshold + min_score: 1.0 + max_iterations_without_improvement: 0 + max_candidate_failures_per_iteration: 1 + missing_output_policy: mark_failed + iteration_failure_policy: fail_execution + evaluation: + scoring_type: weighted_metrics + weights: + success: 1.0 + pass_threshold: 1.0 + ranking: highest_score + tie_breaking: success + variation: + source: explicit + candidates_per_iteration: 1 + selection: null + parameter_space: {} + explicit: + - overrides: {} + swarm: true + supervision: null + +compile: + bindings: + - input: goal + target: execution_spec.goal + - input: provider + target: variation.explicit[0].overrides.llm.provider +"#; + + let template = templates::parse_template_yaml(yaml).expect("parse template"); + assert_eq!(template.template.id, "single-agent-basic"); + assert_eq!(template.template.execution_kind.as_str(), "single_agent"); + assert_eq!( + template.defaults.workflow_template, + "examples/runtime-templates/claude_mcp_diagnostic_agent.yaml" + ); + assert_eq!(template.compile.bindings.len(), 2); +} + +#[cfg(feature = "serde")] +#[test] +fn template_loader_lists_checked_in_templates() { + let template_dir = repo_root().join("templates"); + assert!(template_dir.exists(), "template dir should exist"); + + let templates = templates::list_templates().expect("list templates"); + assert!( + templates + .iter() + .any(|template| template.id == "single-agent-basic"), + "single-agent-basic should be listed" + ); + assert!( + templates + .iter() + .any(|template| template.id == "warm-agent-basic"), + "warm-agent-basic should be listed" + ); +} + +#[cfg(feature = "serde")] +#[test] +fn template_loader_loads_single_agent_template() { + let template = templates::load_template("single-agent-basic").expect("load single agent"); + assert_eq!(template.template.id, "single-agent-basic"); + assert_eq!(template.template.execution_kind.as_str(), "single_agent"); +} + +#[cfg(feature = "serde")] +#[test] +fn template_loader_loads_warm_agent_template() { + let template = templates::load_template("warm-agent-basic").expect("load warm agent"); + assert_eq!(template.template.id, "warm-agent-basic"); + assert_eq!(template.template.execution_kind.as_str(), "warm_agent"); + assert_eq!( + template.defaults.workflow_template, + "examples/runtime-templates/warm_agent_basic.yaml" + ); +} + +#[cfg(feature = "serde")] +#[test] +fn template_loader_loads_benchmark_runner_python_template() { + let template = + templates::load_template("benchmark-runner-python").expect("load benchmark template"); + assert_eq!(template.template.id, "benchmark-runner-python"); + assert_eq!(template.template.execution_kind.as_str(), "execution"); + assert_eq!( + template.defaults.workflow_template, + "examples/runtime-templates/transform_optimizer_agent.yaml" + ); + assert_eq!(template.defaults.execution_spec.variation.explicit.len(), 3); + assert!(template.inputs.contains_key("snapshot")); +} + +#[cfg(feature = "serde")] +#[test] +fn template_compile_builds_single_agent_execution_spec() { + let template = templates::load_template("single-agent-basic").expect("load single agent"); + let inputs = serde_json::json!({ + "goal": "Review the repo", + "prompt": "Summarize the highest-risk areas.", + "provider": "claude" + }); + + let compiled = templates::compile_template(&template, &inputs).expect("compile template"); + assert_eq!(compiled.execution_spec.goal, "Review the repo"); + assert_eq!( + compiled.execution_spec.workflow.template, + "examples/runtime-templates/claude_mcp_diagnostic_agent.yaml" + ); + assert_eq!( + compiled.execution_spec.variation.explicit[0] + .overrides + .get("agent.prompt") + .map(String::as_str), + Some("Summarize the highest-risk areas.") + ); + assert_eq!( + compiled.execution_spec.variation.explicit[0] + .overrides + .get("llm.provider") + .map(String::as_str), + Some("claude") + ); +} + +#[cfg(feature = "serde")] +#[test] +fn template_compile_builds_warm_agent_execution_spec() { + let template = templates::load_template("warm-agent-basic").expect("load warm agent"); + let inputs = serde_json::json!({ + "goal": "Keep a warm agent ready", + "prompt": "Stay alive for follow-up repo work." + }); + + let compiled = templates::compile_template(&template, &inputs).expect("compile warm template"); + assert_eq!(compiled.execution_spec.goal, "Keep a warm agent ready"); + assert_eq!( + compiled.execution_spec.workflow.template, + "examples/runtime-templates/warm_agent_basic.yaml" + ); + assert_eq!( + compiled.execution_spec.variation.explicit[0] + .overrides + .get("agent.prompt") + .map(String::as_str), + Some("Stay alive for follow-up repo work.") + ); + assert_eq!( + compiled.execution_spec.variation.explicit[0] + .overrides + .get("llm.provider") + .map(String::as_str), + Some("claude") + ); +} + +#[cfg(feature = "serde")] +#[test] +fn template_compile_rejects_missing_required_input() { + let template = templates::load_template("single-agent-basic").expect("load single agent"); + let inputs = serde_json::json!({ + "goal": "Missing prompt" + }); + + let err = templates::compile_template(&template, &inputs).expect_err("compile should fail"); + assert!( + err.to_string().contains("missing required input 'prompt'"), + "unexpected error: {err}" + ); +} + +#[cfg(feature = "serde")] +#[test] +fn template_compile_rejects_invalid_enum_input() { + let template = templates::load_template("single-agent-basic").expect("load single agent"); + let inputs = serde_json::json!({ + "goal": "Bad provider", + "prompt": "Run once.", + "provider": "openai" + }); + + let err = templates::compile_template(&template, &inputs).expect_err("compile should fail"); + assert!( + err.to_string().contains("input 'provider' must be one of"), + "unexpected error: {err}" + ); +} + +#[cfg(feature = "serde")] +#[test] +fn template_compile_builds_benchmark_runner_python_execution_spec() { + let template = + templates::load_template("benchmark-runner-python").expect("load benchmark template"); + let inputs = serde_json::json!({ + "goal": "Compare transform benchmark candidates", + "provider": "claude", + "snapshot": "snap-transform-01" + }); + + let compiled = templates::compile_template(&template, &inputs).expect("compile benchmark"); + assert_eq!( + compiled.execution_spec.goal, + "Compare transform benchmark candidates" + ); + assert_eq!( + compiled.execution_spec.workflow.template, + "examples/runtime-templates/transform_optimizer_agent.yaml" + ); + assert_eq!(compiled.execution_spec.variation.source, "explicit"); + assert_eq!(compiled.execution_spec.variation.explicit.len(), 3); + assert_eq!( + compiled.execution_spec.variation.candidates_per_iteration, + 3 + ); + assert_eq!( + compiled.execution_spec.variation.explicit[0] + .overrides + .get("sandbox.env.TRANSFORM_ROLE") + .map(String::as_str), + Some("latency-baseline") + ); + assert_eq!( + compiled.execution_spec.variation.explicit[1] + .overrides + .get("sandbox.env.TRANSFORM_ROLE") + .map(String::as_str), + Some("cache-locality") + ); + assert_eq!( + compiled.execution_spec.variation.explicit[2] + .overrides + .get("sandbox.env.TRANSFORM_ROLE") + .map(String::as_str), + Some("max-throughput") + ); + for proposal in &compiled.execution_spec.variation.explicit { + assert_eq!( + proposal.overrides.get("llm.provider").map(String::as_str), + Some("claude") + ); + assert_eq!( + proposal.overrides.get("snapshot").map(String::as_str), + Some("snap-transform-01") + ); + } +} + +#[cfg(feature = "serde")] +#[test] +fn template_compile_skips_optional_benchmark_snapshot_binding_when_omitted() { + let template = + templates::load_template("benchmark-runner-python").expect("load benchmark template"); + let inputs = serde_json::json!({ + "goal": "Compare transform benchmark candidates" + }); + + let compiled = templates::compile_template(&template, &inputs).expect("compile benchmark"); + for proposal in &compiled.execution_spec.variation.explicit { + assert!( + !proposal.overrides.contains_key("snapshot"), + "snapshot override should be omitted when no snapshot input is provided" + ); + } +} diff --git a/tests/voidctl_execution_cli.rs b/tests/voidctl_execution_cli.rs index bdd8c67..61ea390 100644 --- a/tests/voidctl_execution_cli.rs +++ b/tests/voidctl_execution_cli.rs @@ -1,8 +1,10 @@ #![cfg(feature = "serde")] use std::collections::VecDeque; +use std::fs; use std::io::{Read, Write}; use std::net::TcpListener; +use std::path::PathBuf; use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex}; use std::thread; @@ -22,6 +24,12 @@ struct RecordedRequest { body: String, } +fn temp_inputs_path(name: &str) -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("voidctl-test-{}-{name}", std::process::id())); + path +} + fn spawn_fake_bridge( responses: Vec, ) -> ( @@ -431,3 +439,887 @@ fn runtime_uses_best_candidate_when_not_explicitly_requested() { assert!(stdout.contains("candidate_id=candidate-2")); assert!(stdout.contains("runtime_run_id=run-2")); } + +#[test] +fn template_list_prints_available_templates() { + let (base_url, requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 200, + body: json!({ + "templates": [ + { + "id": "single-agent-basic", + "name": "Single Agent", + "execution_kind": "single_agent", + "description": "Run one agent once and return the result." + }, + { + "id": "warm-agent-basic", + "name": "Warm Agent", + "execution_kind": "warm_agent", + "description": "Start one long-running service-mode agent." + } + ] + }), + }]); + + let output = voidctl_command(&base_url) + .args(["template", "list"]) + .output() + .expect("template list output"); + server.join().expect("join fake bridge"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("template_id=single-agent-basic")); + assert!(stdout.contains("execution_kind=single_agent")); + assert!(stdout.contains("template_id=warm-agent-basic")); + + let requests = requests.lock().expect("lock requests"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "GET"); + assert_eq!(requests[0].path, "/v1/templates"); +} + +#[test] +fn template_get_prints_template_details() { + let (base_url, requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 200, + body: json!({ + "template": { + "id": "single-agent-basic", + "name": "Single Agent", + "execution_kind": "single_agent", + "description": "Run one agent once and return the result." + }, + "inputs": { + "goal": { "type": "string", "required": true, "description": "Goal" }, + "prompt": { "type": "string", "required": true, "description": "Prompt" } + }, + "defaults": { + "workflow_template": "examples/runtime-templates/claude_mcp_diagnostic_agent.yaml" + } + }), + }]); + + let output = voidctl_command(&base_url) + .args(["template", "get", "single-agent-basic"]) + .output() + .expect("template get output"); + server.join().expect("join fake bridge"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("template_id=single-agent-basic")); + assert!(stdout.contains("execution_kind=single_agent")); + assert!(stdout + .contains("workflow_template=examples/runtime-templates/claude_mcp_diagnostic_agent.yaml")); + assert!(stdout.contains("input=goal")); + assert!(stdout.contains("input=prompt")); + + let requests = requests.lock().expect("lock requests"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "GET"); + assert_eq!(requests[0].path, "/v1/templates/single-agent-basic"); +} + +#[test] +fn template_dry_run_from_stdin_posts_inputs_and_prints_compiled_summary() { + let (base_url, requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 200, + body: json!({ + "template": { + "id": "single-agent-basic", + "execution_kind": "single_agent" + }, + "inputs": { + "goal": "Summarize this repo", + "prompt": "Read the repo and summarize risks", + "provider": "claude" + }, + "compiled": { + "goal": "Summarize this repo", + "workflow_template": "examples/runtime-templates/claude_mcp_diagnostic_agent.yaml", + "mode": "swarm", + "variation_source": "explicit", + "candidates_per_iteration": 1, + "overrides": { + "agent.prompt": "Read the repo and summarize risks", + "llm.provider": "claude" + } + } + }), + }]); + + let mut child = voidctl_command(&base_url) + .args(["template", "dry-run", "single-agent-basic", "--stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn voidctl"); + let inputs = r#"{"inputs":{"goal":"Summarize this repo","prompt":"Read the repo and summarize risks","provider":"claude"}}"#; + child + .stdin + .take() + .expect("stdin") + .write_all(inputs.as_bytes()) + .expect("write stdin"); + + let output = child.wait_with_output().expect("wait output"); + server.join().expect("join fake bridge"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("template_id=single-agent-basic")); + assert!(stdout.contains("execution_kind=single_agent")); + assert!(stdout + .contains("workflow_template=examples/runtime-templates/claude_mcp_diagnostic_agent.yaml")); + assert!(stdout.contains("agent.prompt=Read the repo and summarize risks")); + assert!(stdout.contains("llm.provider=claude")); + + let requests = requests.lock().expect("lock requests"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/v1/templates/single-agent-basic/dry-run"); + assert_eq!(requests[0].body, inputs); +} + +#[test] +fn template_execute_from_stdin_posts_inputs_and_prints_execution_summary() { + let (base_url, requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 200, + body: json!({ + "execution_id": "exec-template-1", + "template": { + "id": "warm-agent-basic", + "execution_kind": "warm_agent" + }, + "status": "Pending", + "goal": "Keep a warm agent ready" + }), + }]); + + let mut child = voidctl_command(&base_url) + .args(["template", "execute", "warm-agent-basic", "--stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn voidctl"); + let inputs = r#"{"inputs":{"goal":"Keep a warm agent ready","prompt":"Stay alive for follow-up repo work."}}"#; + child + .stdin + .take() + .expect("stdin") + .write_all(inputs.as_bytes()) + .expect("write stdin"); + + let output = child.wait_with_output().expect("wait output"); + server.join().expect("join fake bridge"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("execution_id=exec-template-1")); + assert!(stdout.contains("template_id=warm-agent-basic")); + assert!(stdout.contains("execution_kind=warm_agent")); + assert!(stdout.contains("status=Pending")); + assert!(stdout.contains("goal=Keep a warm agent ready")); + + let requests = requests.lock().expect("lock requests"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/v1/templates/warm-agent-basic/execute"); + assert_eq!(requests[0].body, inputs); +} + +#[test] +fn template_get_failure_returns_non_zero_and_prints_bridge_message() { + let (base_url, _requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 404, + body: json!({ + "message": "template 'missing-template' not found" + }), + }]); + + let output = voidctl_command(&base_url) + .args(["template", "get", "missing-template"]) + .output() + .expect("template get output"); + server.join().expect("join fake bridge"); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).expect("stderr"); + assert!(stderr.contains("fatal: template 'missing-template' not found")); +} + +#[test] +fn template_dry_run_failure_returns_non_zero_and_prints_bridge_message() { + let (base_url, _requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 400, + body: json!({ + "message": "missing required input 'prompt'" + }), + }]); + + let mut child = voidctl_command(&base_url) + .args(["template", "dry-run", "single-agent-basic", "--stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn voidctl"); + child + .stdin + .take() + .expect("stdin") + .write_all(br#"{"inputs":{"goal":"Summarize this repo"}}"#) + .expect("write stdin"); + + let output = child.wait_with_output().expect("wait output"); + server.join().expect("join fake bridge"); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).expect("stderr"); + assert!(stderr.contains("fatal: missing required input 'prompt'")); +} + +#[test] +fn template_execute_failure_returns_non_zero_and_prints_bridge_message() { + let (base_url, _requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 400, + body: json!({ + "message": "invalid template request body: expected value" + }), + }]); + + let mut child = voidctl_command(&base_url) + .args(["template", "execute", "warm-agent-basic", "--stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn voidctl"); + child + .stdin + .take() + .expect("stdin") + .write_all(b"not-json") + .expect("write stdin"); + + let output = child.wait_with_output().expect("wait output"); + server.join().expect("join fake bridge"); + + assert!(!output.status.success()); + let stderr = String::from_utf8(output.stderr).expect("stderr"); + assert!(stderr.contains("fatal: invalid template request body: expected value")); +} + +#[test] +fn interactive_template_get_prints_error_for_bridge_failure() { + let (base_url, _requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 404, + body: json!({ + "message": "template 'missing-template' not found" + }), + }]); + + let mut child = voidctl_command(&base_url) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn voidctl"); + child + .stdin + .take() + .expect("stdin") + .write_all(b"/template get missing-template\n/exit\n") + .expect("write stdin"); + + let output = child.wait_with_output().expect("wait output"); + server.join().expect("join fake bridge"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("error: template 'missing-template' not found")); +} + +#[test] +fn interactive_batch_dry_run_posts_spec_and_prints_compiled_summary() { + let (base_url, requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 200, + body: json!({ + "kind": "batch", + "compiled_primitive": "swarm", + "compiled": { + "goal": "repo-background-work", + "workflow_template": "examples/runtime-templates/warm_agent_basic.yaml", + "mode": "swarm", + "variation_source": "explicit", + "candidates_per_iteration": 2, + "candidate_overrides": [ + { "agent.prompt": "Fix failing auth tests" }, + { "agent.prompt": "Improve retry logging" } + ], + "overrides": { + "agent.prompt": "Fix failing auth tests" + } + } + }), + }]); + + let inputs_path = temp_inputs_path("batch.json"); + fs::write( + &inputs_path, + r#"{"api_version":"v1","kind":"batch","worker":{"template":"examples/runtime-templates/warm_agent_basic.yaml"},"mode":{"parallelism":2},"jobs":[{"prompt":"Fix failing auth tests"},{"prompt":"Improve retry logging"}]}"#, + ) + .expect("write inputs"); + + let mut child = voidctl_command(&base_url) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn voidctl"); + let command = format!("/batch dry-run {}\n/exit\n", inputs_path.display()); + child + .stdin + .take() + .expect("stdin") + .write_all(command.as_bytes()) + .expect("write stdin"); + + let output = child.wait_with_output().expect("wait output"); + server.join().expect("join fake bridge"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("kind=batch")); + assert!(stdout.contains("compiled_primitive=swarm")); + assert!(stdout.contains("workflow_template=examples/runtime-templates/warm_agent_basic.yaml")); + + let requests = requests.lock().expect("lock requests"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/v1/batch/dry-run"); +} + +#[test] +fn interactive_batch_dry_run_prints_error_for_bridge_failure() { + let (base_url, _requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 400, + body: json!({ + "message": "batch spec must include at least one job" + }), + }]); + + let inputs_path = temp_inputs_path("batch-invalid.json"); + fs::write( + &inputs_path, + r#"{"api_version":"v1","kind":"batch","worker":{"template":"examples/runtime-templates/warm_agent_basic.yaml"},"jobs":[]}"#, + ) + .expect("write inputs"); + + let mut child = voidctl_command(&base_url) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn voidctl"); + let command = format!("/batch dry-run {}\n/exit\n", inputs_path.display()); + child + .stdin + .take() + .expect("stdin") + .write_all(command.as_bytes()) + .expect("write stdin"); + + let output = child.wait_with_output().expect("wait output"); + server.join().expect("join fake bridge"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("error: batch spec must include at least one job")); +} + +#[test] +fn interactive_yolo_run_alias_posts_to_yolo_route() { + let (base_url, requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 200, + body: json!({ + "kind": "batch", + "run_id": "exec-yolo-2", + "execution_id": "exec-yolo-2", + "compiled_primitive": "swarm", + "status": "Pending", + "goal": "run 1 background jobs" + }), + }]); + + let inputs_path = temp_inputs_path("yolo.json"); + fs::write( + &inputs_path, + r#"{"api_version":"v1","kind":"yolo","worker":{"template":"examples/runtime-templates/warm_agent_basic.yaml"},"jobs":[{"prompt":"Review migration safety"}]}"#, + ) + .expect("write inputs"); + + let mut child = voidctl_command(&base_url) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn voidctl"); + let command = format!("/yolo run {}\n/exit\n", inputs_path.display()); + child + .stdin + .take() + .expect("stdin") + .write_all(command.as_bytes()) + .expect("write stdin"); + + let output = child.wait_with_output().expect("wait output"); + server.join().expect("join fake bridge"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("run_id=exec-yolo-2")); + assert!(stdout.contains("compiled_primitive=swarm")); + + let requests = requests.lock().expect("lock requests"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/v1/yolo/run"); +} + +#[test] +fn interactive_yolo_dry_run_prints_error_for_bridge_failure() { + let (base_url, requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 400, + body: json!({ + "message": "yolo spec must include at least one job" + }), + }]); + + let inputs_path = temp_inputs_path("yolo-invalid.json"); + fs::write( + &inputs_path, + r#"{"api_version":"v1","kind":"yolo","worker":{"template":"examples/runtime-templates/warm_agent_basic.yaml"},"jobs":[]}"#, + ) + .expect("write inputs"); + + let mut child = voidctl_command(&base_url) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn voidctl"); + let command = format!("/yolo dry-run {}\n/exit\n", inputs_path.display()); + child + .stdin + .take() + .expect("stdin") + .write_all(command.as_bytes()) + .expect("write stdin"); + + let output = child.wait_with_output().expect("wait output"); + server.join().expect("join fake bridge"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("error: yolo spec must include at least one job")); + + let requests = requests.lock().expect("lock requests"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/v1/yolo/dry-run"); +} + +#[test] +fn interactive_team_dry_run_posts_spec_and_prints_compiled_summary() { + let (base_url, requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 200, + body: json!({ + "kind": "team", + "compiled_primitive": "swarm", + "compiled": { + "goal": "rust-article-team", + "workflow_template": "examples/runtime-templates/warm_agent_basic.yaml", + "mode": "swarm", + "variation_source": "explicit", + "candidates_per_iteration": 1, + "candidate_overrides": [ + { "agent.prompt": "Gather evidence", "agent.role": "Researcher", "agent.goal": "Find information" } + ], + "overrides": { + "agent.prompt": "Gather evidence", + "agent.role": "Researcher", + "agent.goal": "Find information" + } + } + }), + }]); + + let inputs_path = temp_inputs_path("team.json"); + fs::write( + &inputs_path, + r#"{"api_version":"v1","kind":"team","metadata":{"name":"rust-article-team"},"agents":[{"name":"researcher","role":"Researcher","goal":"Find information"}],"tasks":[{"name":"research","description":"Gather evidence","agent":"researcher"}],"process":{"type":"parallel"}}"#, + ) + .expect("write inputs"); + + let mut child = voidctl_command(&base_url) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn voidctl"); + let command = format!("/team dry-run {}\n/exit\n", inputs_path.display()); + child + .stdin + .take() + .expect("stdin") + .write_all(command.as_bytes()) + .expect("write stdin"); + + let output = child.wait_with_output().expect("wait output"); + server.join().expect("join fake bridge"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("kind=team")); + assert!(stdout.contains("compiled_primitive=swarm")); + assert!(stdout.contains("workflow_template=examples/runtime-templates/warm_agent_basic.yaml")); + + let requests = requests.lock().expect("lock requests"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/v1/teams/dry-run"); +} + +#[test] +fn interactive_team_run_posts_spec_and_prints_run_summary() { + let (base_url, requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 200, + body: json!({ + "kind": "team", + "execution_id": "exec-team-2", + "compiled_primitive": "swarm", + "status": "Pending", + "goal": "rust-article-team" + }), + }]); + + let inputs_path = temp_inputs_path("team-run.json"); + fs::write( + &inputs_path, + r#"{"api_version":"v1","kind":"team","metadata":{"name":"rust-article-team"},"agents":[{"name":"researcher","role":"Researcher","goal":"Find information"}],"tasks":[{"name":"research","description":"Gather evidence","agent":"researcher"}],"process":{"type":"parallel"}}"#, + ) + .expect("write inputs"); + + let mut child = voidctl_command(&base_url) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn voidctl"); + let command = format!("/team run {}\n/exit\n", inputs_path.display()); + child + .stdin + .take() + .expect("stdin") + .write_all(command.as_bytes()) + .expect("write stdin"); + + let output = child.wait_with_output().expect("wait output"); + server.join().expect("join fake bridge"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("kind=team")); + assert!(stdout.contains("execution_id=exec-team-2")); + assert!(stdout.contains("compiled_primitive=swarm")); + assert!(stdout.contains("status=Pending")); + + let requests = requests.lock().expect("lock requests"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/v1/teams/run"); +} + +#[test] +fn interactive_team_dry_run_prints_error_for_bridge_failure() { + let (base_url, _requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 400, + body: json!({ + "message": "team spec must include at least one task" + }), + }]); + + let inputs_path = temp_inputs_path("team-invalid.json"); + fs::write( + &inputs_path, + r#"{"api_version":"v1","kind":"team","agents":[{"name":"researcher","role":"Researcher","goal":"Find information"}],"tasks":[],"process":{"type":"parallel"}}"#, + ) + .expect("write inputs"); + + let mut child = voidctl_command(&base_url) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn voidctl"); + let command = format!("/team dry-run {}\n/exit\n", inputs_path.display()); + child + .stdin + .take() + .expect("stdin") + .write_all(command.as_bytes()) + .expect("write stdin"); + + let output = child.wait_with_output().expect("wait output"); + server.join().expect("join fake bridge"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("error: team spec must include at least one task")); +} + +#[test] +fn team_dry_run_from_stdin_posts_spec_and_prints_compiled_summary() { + let (base_url, requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 200, + body: json!({ + "kind": "team", + "compiled_primitive": "swarm", + "compiled": { + "goal": "rust-article-team", + "workflow_template": "examples/runtime-templates/warm_agent_basic.yaml", + "mode": "swarm", + "variation_source": "explicit", + "candidates_per_iteration": 1, + "candidate_overrides": [ + { "agent.prompt": "Gather evidence", "agent.role": "Researcher", "agent.goal": "Find information" } + ], + "overrides": { + "agent.prompt": "Gather evidence", + "agent.role": "Researcher", + "agent.goal": "Find information" + } + } + }), + }]); + + let mut child = voidctl_command(&base_url) + .args(["team", "dry-run", "--stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn voidctl"); + let spec = r#"{"api_version":"v1","kind":"team","metadata":{"name":"rust-article-team"},"agents":[{"name":"researcher","role":"Researcher","goal":"Find information"}],"tasks":[{"name":"research","description":"Gather evidence","agent":"researcher"}],"process":{"type":"parallel"}}"#; + child + .stdin + .take() + .expect("stdin") + .write_all(spec.as_bytes()) + .expect("write stdin"); + + let output = child.wait_with_output().expect("wait output"); + server.join().expect("join fake bridge"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("kind=team")); + assert!(stdout.contains("compiled_primitive=swarm")); + assert!(stdout.contains("workflow_template=examples/runtime-templates/warm_agent_basic.yaml")); + + let requests = requests.lock().expect("lock requests"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/v1/teams/dry-run"); + assert_eq!(requests[0].body, spec); +} + +#[test] +fn team_run_from_stdin_posts_spec_and_prints_run_summary() { + let (base_url, requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 200, + body: json!({ + "kind": "team", + "execution_id": "exec-team-1", + "compiled_primitive": "swarm", + "status": "Pending", + "goal": "rust-article-team" + }), + }]); + + let mut child = voidctl_command(&base_url) + .args(["team", "run", "--stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn voidctl"); + let spec = r#"{"api_version":"v1","kind":"team","metadata":{"name":"rust-article-team"},"agents":[{"name":"researcher","role":"Researcher","goal":"Find information"}],"tasks":[{"name":"research","description":"Gather evidence","agent":"researcher"}],"process":{"type":"parallel"}}"#; + child + .stdin + .take() + .expect("stdin") + .write_all(spec.as_bytes()) + .expect("write stdin"); + + let output = child.wait_with_output().expect("wait output"); + server.join().expect("join fake bridge"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("kind=team")); + assert!(stdout.contains("execution_id=exec-team-1")); + assert!(stdout.contains("compiled_primitive=swarm")); + assert!(stdout.contains("status=Pending")); + + let requests = requests.lock().expect("lock requests"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/v1/teams/run"); + assert_eq!(requests[0].body, spec); +} + +#[test] +fn batch_dry_run_from_stdin_posts_spec_and_prints_compiled_summary() { + let (base_url, requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 200, + body: json!({ + "kind": "batch", + "compiled_primitive": "swarm", + "compiled": { + "goal": "repo-background-work", + "workflow_template": "examples/runtime-templates/warm_agent_basic.yaml", + "mode": "swarm", + "variation_source": "explicit", + "candidates_per_iteration": 2, + "candidate_overrides": [ + { "agent.prompt": "Fix failing auth tests" }, + { "agent.prompt": "Improve retry logging" }, + { "agent.prompt": "Review DB migration safety" } + ], + "overrides": { + "agent.prompt": "Fix failing auth tests" + } + } + }), + }]); + + let mut child = voidctl_command(&base_url) + .args(["batch", "dry-run", "--stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn voidctl"); + let spec = r#"{"api_version":"v1","kind":"batch","worker":{"template":"examples/runtime-templates/warm_agent_basic.yaml"},"mode":{"parallelism":2},"jobs":[{"prompt":"Fix failing auth tests"},{"prompt":"Improve retry logging"},{"prompt":"Review DB migration safety"}]}"#; + child + .stdin + .take() + .expect("stdin") + .write_all(spec.as_bytes()) + .expect("write stdin"); + + let output = child.wait_with_output().expect("wait output"); + server.join().expect("join fake bridge"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("kind=batch")); + assert!(stdout.contains("compiled_primitive=swarm")); + assert!(stdout.contains("workflow_template=examples/runtime-templates/warm_agent_basic.yaml")); + + let requests = requests.lock().expect("lock requests"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/v1/batch/dry-run"); + assert_eq!(requests[0].body, spec); +} + +#[test] +fn batch_run_from_stdin_posts_spec_and_prints_run_summary() { + let (base_url, requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 200, + body: json!({ + "kind": "batch", + "run_id": "exec-batch-1", + "execution_id": "exec-batch-1", + "compiled_primitive": "swarm", + "status": "Pending", + "goal": "repo-background-work" + }), + }]); + + let mut child = voidctl_command(&base_url) + .args(["batch", "run", "--stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn voidctl"); + let spec = r#"{"api_version":"v1","kind":"batch","worker":{"template":"examples/runtime-templates/warm_agent_basic.yaml"},"jobs":[{"prompt":"Fix failing auth tests"},{"prompt":"Improve retry logging"}]}"#; + child + .stdin + .take() + .expect("stdin") + .write_all(spec.as_bytes()) + .expect("write stdin"); + + let output = child.wait_with_output().expect("wait output"); + server.join().expect("join fake bridge"); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout"); + assert!(stdout.contains("kind=batch")); + assert!(stdout.contains("run_id=exec-batch-1")); + assert!(stdout.contains("compiled_primitive=swarm")); + assert!(stdout.contains("status=Pending")); + + let requests = requests.lock().expect("lock requests"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, "POST"); + assert_eq!(requests[0].path, "/v1/batch/run"); + assert_eq!(requests[0].body, spec); +} + +#[test] +fn yolo_run_alias_posts_to_yolo_route() { + let (base_url, requests, server) = spawn_fake_bridge(vec![FakeResponse { + status: 200, + body: json!({ + "kind": "batch", + "run_id": "exec-yolo-1", + "execution_id": "exec-yolo-1", + "compiled_primitive": "swarm", + "status": "Pending", + "goal": "run 1 background jobs" + }), + }]); + + let mut child = voidctl_command(&base_url) + .args(["yolo", "run", "--stdin"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn voidctl"); + let spec = r#"{"api_version":"v1","kind":"yolo","worker":{"template":"examples/runtime-templates/warm_agent_basic.yaml"},"jobs":[{"prompt":"Review migration safety"}]}"#; + child + .stdin + .take() + .expect("stdin") + .write_all(spec.as_bytes()) + .expect("write stdin"); + + let output = child.wait_with_output().expect("wait output"); + server.join().expect("join fake bridge"); + + assert!(output.status.success()); + let requests = requests.lock().expect("lock requests"); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, "/v1/yolo/run"); +}