diff --git a/internal/webui/handlers_control_test.go b/internal/webui/handlers_control_test.go index 18d3970a..7abc6ffd 100644 --- a/internal/webui/handlers_control_test.go +++ b/internal/webui/handlers_control_test.go @@ -689,6 +689,175 @@ func TestHandleForkPoints_WithCheckpoints(t *testing.T) { } } +// --- Start Pipeline with advanced options tests --- + +func TestHandleStartPipeline_WithModel(t *testing.T) { + srv, _ := testServer(t) + setupPipelineDir(t, "test-pipeline", []string{"step1", "step2"}) + + body := strings.NewReader(`{"input":"test","model":"haiku"}`) + req := httptest.NewRequest("POST", "/api/pipelines/test-pipeline/start", body) + req.SetPathValue("name", "test-pipeline") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.handleStartPipeline(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp StartPipelineResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.PipelineName != "test-pipeline" { + t.Errorf("expected pipeline name 'test-pipeline', got %q", resp.PipelineName) + } + if resp.Status != "running" { + t.Errorf("expected status 'running', got %q", resp.Status) + } +} + +func TestHandleStartPipeline_WithSteps(t *testing.T) { + srv, _ := testServer(t) + setupPipelineDir(t, "test-pipeline", []string{"step1", "step2", "step3"}) + + body := strings.NewReader(`{"input":"test","steps":"step1,step3"}`) + req := httptest.NewRequest("POST", "/api/pipelines/test-pipeline/start", body) + req.SetPathValue("name", "test-pipeline") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.handleStartPipeline(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestHandleStartPipeline_WithExclude(t *testing.T) { + srv, _ := testServer(t) + setupPipelineDir(t, "test-pipeline", []string{"step1", "step2", "step3"}) + + body := strings.NewReader(`{"input":"test","exclude":"step2"}`) + req := httptest.NewRequest("POST", "/api/pipelines/test-pipeline/start", body) + req.SetPathValue("name", "test-pipeline") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.handleStartPipeline(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestHandleStartPipeline_DryRun(t *testing.T) { + srv, _ := testServer(t) + setupPipelineDir(t, "test-pipeline", []string{"step1", "step2"}) + + body := strings.NewReader(`{"input":"test","dry_run":true}`) + req := httptest.NewRequest("POST", "/api/pipelines/test-pipeline/start", body) + req.SetPathValue("name", "test-pipeline") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.handleStartPipeline(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 for dry-run, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp StartPipelineResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.PipelineName != "test-pipeline" { + t.Errorf("expected pipeline name 'test-pipeline', got %q", resp.PipelineName) + } +} + +func TestHandleStartPipeline_DryRunCreatesCompletedRun(t *testing.T) { + srv, rwStore := testServer(t) + setupPipelineDir(t, "test-pipeline", []string{"step1", "step2"}) + + body := strings.NewReader(`{"input":"test","dry_run":true}`) + req := httptest.NewRequest("POST", "/api/pipelines/test-pipeline/start", body) + req.SetPathValue("name", "test-pipeline") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.handleStartPipeline(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 for dry-run, got %d: %s", rec.Code, rec.Body.String()) + } + + // Dry-run creates a run + runs, err := rwStore.ListRuns(state.ListRunsOptions{Limit: 10}) + if err != nil { + t.Fatalf("failed to list runs: %v", err) + } + if len(runs) != 1 { + t.Fatalf("expected 1 run after dry-run, got %d", len(runs)) + } +} + +// --- SubmitRun with advanced options tests --- + +func TestHandleSubmitRun_WithModel(t *testing.T) { + srv, _ := testServer(t) + setupPipelineDir(t, "test-pipeline", []string{"step1", "step2"}) + + body := strings.NewReader(`{"pipeline":"test-pipeline","input":"test","model":"opus"}`) + req := httptest.NewRequest("POST", "/api/runs", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.handleSubmitRun(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp SubmitRunResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.PipelineName != "test-pipeline" { + t.Errorf("expected pipeline 'test-pipeline', got %q", resp.PipelineName) + } +} + +func TestHandleSubmitRun_DryRun(t *testing.T) { + srv, rwStore := testServer(t) + setupPipelineDir(t, "test-pipeline", []string{"step1", "step2"}) + + body := strings.NewReader(`{"pipeline":"test-pipeline","input":"test","dry_run":true}`) + req := httptest.NewRequest("POST", "/api/runs", body) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.handleSubmitRun(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201 for dry-run, got %d: %s", rec.Code, rec.Body.String()) + } + + var resp SubmitRunResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp.PipelineName != "test-pipeline" { + t.Errorf("expected pipeline name 'test-pipeline', got %q", resp.PipelineName) + } + + // Dry-run creates a run + runs, err := rwStore.ListRuns(state.ListRunsOptions{Limit: 10}) + if err != nil { + t.Fatalf("failed to list runs: %v", err) + } + if len(runs) != 1 { + t.Fatalf("expected 1 run after dry-run, got %d", len(runs)) + } +} + +// --- buildExecOptions / buildStepFilter tests --- + func TestIsHeartbeat(t *testing.T) { tests := []struct { name string diff --git a/internal/webui/templates/pipeline_detail.html b/internal/webui/templates/pipeline_detail.html index 89bf1a75..c78d48f9 100644 --- a/internal/webui/templates/pipeline_detail.html +++ b/internal/webui/templates/pipeline_detail.html @@ -113,6 +113,42 @@

Start Pipeline:

Input (optional) +
+ Advanced Options +
+ + +
+
+ + +
+
+ +
+ Loading... +
+
+
+ +
+ Loading... +
+
+
+ +
+
@@ -127,6 +163,15 @@

Start Pipeline:

Start Pipeline: Start Pipeline: ' + escapeHTML(steps[i].id) + ''; + } + if (fromStep) fromStep.innerHTML = opts; + var checksHTML = ''; + for (var i = 0; i < steps.length; i++) { + checksHTML += ''; + } + if (stepsChecks) stepsChecks.innerHTML = checksHTML || 'No steps'; + var exclHTML = ''; + for (var i = 0; i < steps.length; i++) { + exclHTML += ''; + } + if (excludeChecks) excludeChecks.innerHTML = exclHTML || 'No steps'; + }).catch(function() { + if (stepsChecks) stepsChecks.innerHTML = 'Failed to load steps'; + if (excludeChecks) excludeChecks.innerHTML = 'Failed to load steps'; + }); +} + +function onQsFromStepChange() { + var fromStep = document.getElementById('qs-from-step'); + var stepsGroup = document.getElementById('qs-steps-group'); + if (fromStep && fromStep.value) { + if (stepsGroup) stepsGroup.style.opacity = '0.5'; + var checks = document.querySelectorAll('#qs-steps-checks input[type="checkbox"]'); + for (var i = 0; i < checks.length; i++) { checks[i].checked = false; checks[i].disabled = true; } + } else { + if (stepsGroup) stepsGroup.style.opacity = '1'; + var checks = document.querySelectorAll('#qs-steps-checks input[type="checkbox"]'); + for (var i = 0; i < checks.length; i++) { checks[i].disabled = false; } + } +} + document.addEventListener('keydown', function(e) { if (e.key === 'Escape') hideQuickStart(); }); diff --git a/internal/webui/templates/pipelines.html b/internal/webui/templates/pipelines.html index 538c25e0..8a64bb85 100644 --- a/internal/webui/templates/pipelines.html +++ b/internal/webui/templates/pipelines.html @@ -68,6 +68,42 @@

Start Pipeline:

Input (optional)
+
+ Advanced Options +
+ + +
+
+ + +
+
+ +
+ Loading... +
+
+
+ +
+ Loading... +
+
+
+ +
+
@@ -82,6 +118,15 @@

Start Pipeline:

Start Pipeline: Start Pipeline: ' + escapeHTML(steps[i].id) + ''; + } + if (fromStep) fromStep.innerHTML = opts; + var checksHTML = ''; + for (var i = 0; i < steps.length; i++) { + checksHTML += ''; + } + if (stepsChecks) stepsChecks.innerHTML = checksHTML || 'No steps'; + var exclHTML = ''; + for (var i = 0; i < steps.length; i++) { + exclHTML += ''; + } + if (excludeChecks) excludeChecks.innerHTML = exclHTML || 'No steps'; + }).catch(function() { + if (stepsChecks) stepsChecks.innerHTML = 'Failed to load steps'; + if (excludeChecks) excludeChecks.innerHTML = 'Failed to load steps'; + }); +} + +function onQsFromStepChange() { + var fromStep = document.getElementById('qs-from-step'); + var stepsGroup = document.getElementById('qs-steps-group'); + if (fromStep && fromStep.value) { + if (stepsGroup) stepsGroup.style.opacity = '0.5'; + var checks = document.querySelectorAll('#qs-steps-checks input[type="checkbox"]'); + for (var i = 0; i < checks.length; i++) { checks[i].checked = false; checks[i].disabled = true; } + } else { + if (stepsGroup) stepsGroup.style.opacity = '1'; + var checks = document.querySelectorAll('#qs-steps-checks input[type="checkbox"]'); + for (var i = 0; i < checks.length; i++) { checks[i].disabled = false; } + } +} + // Set category badges from pipeline name prefix on load document.querySelectorAll('#pipeline-tbody tr').forEach(function(row) { var name = row.dataset.name || ''; diff --git a/specs/690-webui-start-flags/plan.md b/specs/690-webui-start-flags/plan.md new file mode 100644 index 00000000..248e89db --- /dev/null +++ b/specs/690-webui-start-flags/plan.md @@ -0,0 +1,72 @@ +# Implementation Plan: WebUI Start Pipeline Dialog Flags + +## Objective + +Expose the five high-priority CLI flags (`--model`, `--from-step`, `--steps`, `--exclude`, `--dry-run`) in the web UI's "Start Pipeline" dialog so users can configure pipeline runs without the CLI. Step lists must be dynamically populated from the pipeline definition. + +## Approach + +The implementation touches three layers: **types** (request struct), **handlers** (wiring to executor options), and **UI** (templates + JS). There are three distinct start dialogs that need updating: + +1. **Runs page** (`runs.html`) — full dialog with pipeline selector + new controls +2. **Pipelines list page** (`pipelines.html`) — quickstart dialog (pipeline is pre-selected) +3. **Pipeline detail page** (`pipeline_detail.html`) — quickstart dialog (pipeline is pre-selected) + +The existing `GET /api/pipelines/{name}` endpoint already returns step IDs, personas, and dependencies — this will be used to populate step dropdowns dynamically when a pipeline is selected. + +### Key Design Decisions + +1. **Collapsible "Advanced Options" section**: The five new controls go into a collapsible `
` element below the Input field. This keeps the dialog simple for basic use while exposing power-user controls on demand. + +2. **Step selection via checkboxes, not multi-select**: Multi-select `