diff --git a/CHANGELOG.md b/CHANGELOG.md index da7895396c..a5948e1c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ Hive did not historically maintain a complete changelog. This file starts a prag ## Unreleased +## 2026-11-12 (v5.228.6) + +### Fixed + +- Detect missing GitHub App repository coverage at boot, during self-heal, and when connecting a repository, with a settings link to fix the installation selection (#17696). + ## 2026-11-12 (v5.228.5) ### Fixed diff --git a/changelog.d/added-inbox-voice-quickstart.md b/changelog.d/added-inbox-voice-quickstart.md new file mode 100644 index 0000000000..0f63ff56c8 --- /dev/null +++ b/changelog.d/added-inbox-voice-quickstart.md @@ -0,0 +1 @@ +- Added an Inbox voice sources quickstart link to Plaud setup and Integrations ([#17882](https://github.com/hivecommons/hive/issues/17882)). diff --git a/changelog.d/added-workflow-enabled-toggle.md b/changelog.d/added-workflow-enabled-toggle.md new file mode 100644 index 0000000000..44fc411700 --- /dev/null +++ b/changelog.d/added-workflow-enabled-toggle.md @@ -0,0 +1 @@ +- Settings → Workflows → Library gains an owner-only **Derive stages from bound models** switch (`workflow.enabled`, [#17620](https://github.com/hivecommons/hive/issues/17620)) backed by the new `PUT /api/workflows/enabled {enabled}`, so a hosted hive with no `hive.yaml` access can turn model-based stage derivation on or off from the dashboard. Off: a binding only changes the Activity Monitor's columns; on: a bound repository's stages are derived by its model's rules and the model's labels are provisioned in the repository immediately. Persists `hive.yaml`, takes effect on the next derivation pass without a restart, audited as `workflow_enabled` / `workflow_disabled`; the Repos binding row now says *columns only — turn on derivation in Workflows* when a repository is bound while the flag is off. diff --git a/changelog.d/fixed-17696-app-repo-coverage.md b/changelog.d/fixed-17696-app-repo-coverage.md deleted file mode 100644 index 8855f73672..0000000000 --- a/changelog.d/fixed-17696-app-repo-coverage.md +++ /dev/null @@ -1 +0,0 @@ -- Detect missing GitHub App repository coverage at boot, during self-heal, and when connecting a repository, with a settings link to fix the installation selection (#17696). diff --git a/changelog.d/fixed-proxy-plain-forward-report.md b/changelog.d/fixed-proxy-plain-forward-report.md new file mode 100644 index 0000000000..2e525cf0dd --- /dev/null +++ b/changelog.d/fixed-proxy-plain-forward-report.md @@ -0,0 +1 @@ +- Classify repeated plain HTTP proxy forwarding failures separately, with diagnostics for the requested host and network path. diff --git a/dashboard/openapi.json b/dashboard/openapi.json index 766056713f..651888249f 100644 --- a/dashboard/openapi.json +++ b/dashboard/openapi.json @@ -36029,6 +36029,78 @@ } } }, + "/api/workflows/enabled": { + "put": { + "tags": [ + "Workflows" + ], + "summary": "Turn model-based stage derivation on or off", + "description": "Owner only. Sets workflow.enabled from the dashboard so a hosted hive with no hive.yaml access can enable it (hivecommons/hive#17620). Body {enabled}. Persists hive.yaml; the stage engine and the tracker read the live flag, so it takes effect on the next derivation pass without a restart. Turning it on also provisions every bound repository's model labels immediately; a repository that could not be provisioned is listed in warnings and the next sweep retries it. Audited as workflow_enabled / workflow_disabled; a no-op is not audited.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Flag after the change", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "changed": { + "type": "boolean" + }, + "bindings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowBinding" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Bound repositories whose labels could not be provisioned now (only when turning on)" + } + } + } + } + } + }, + "400": { + "description": "Body is not {enabled: boolean}" + }, + "403": { + "description": "Not an owner" + }, + "500": { + "description": "hive.yaml could not be saved; the flag is left unchanged" + }, + "503": { + "description": "No config" + } + } + } + }, "/api/workflows/{name}/{version}": { "get": { "tags": [ diff --git a/src/cmd/hive/main.go b/src/cmd/hive/main.go index a65b1db999..2cbba7bd59 100644 --- a/src/cmd/hive/main.go +++ b/src/cmd/hive/main.go @@ -1094,11 +1094,12 @@ func (b *boot) wireBootClosures() { WorkflowItemSnapshot: func(ctx context.Context, repo string, number int) (github.WorkflowItemSnapshot, error) { return b.ghClient.WorkflowItemSnapshot(ctx, repo, number) }, - WorkflowRepoFile: workflowRepoFile(func() *github.Client { return b.ghClient }), - WorkflowClusterRunner: workflowClusterRunner(), - WorkflowSpecLLM: newWorkflowSpecRunner(func() *config.Config { return b.cfg }), - AgentMgr: b.agentMgr, - CruxRuntimeHealth: cruxRuntimeHealthFunc(b.cruxSupervision), + WorkflowRepoFile: workflowRepoFile(func() *github.Client { return b.ghClient }), + WorkflowProvisionLabels: workflowProvisionLabels(b.cfg, func() *github.Client { return b.ghClient }, b.logger), + WorkflowClusterRunner: workflowClusterRunner(), + WorkflowSpecLLM: newWorkflowSpecRunner(func() *config.Config { return b.cfg }), + AgentMgr: b.agentMgr, + CruxRuntimeHealth: cruxRuntimeHealthFunc(b.cruxSupervision), // Provider gateways (#5565 slice 3): concrete openrouter/watsonx/ // linearagent adapters behind the dashboard's consumer-defined // interfaces — this composition root is the only non-test place that diff --git a/src/cmd/hive/workflow_labels.go b/src/cmd/hive/workflow_labels.go new file mode 100644 index 0000000000..df5f076da3 --- /dev/null +++ b/src/cmd/hive/workflow_labels.go @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/hivecommons/hive/pkg/config" + "github.com/hivecommons/hive/pkg/github" + "github.com/hivecommons/hive/pkg/github/automerge" +) + +// workflowProvisionLabels is the dashboard's WorkflowProvisionLabels +// dependency (hivecommons/hive#17620): when an owner turns workflow.enabled +// on from Settings → Workflows, every bound repository's model labels are +// provisioned at once through the sweep's own memoised path +// (automerge.Engine.EnsureStageLabels over the process-shared stage engine +// options and tracker), rather than on the next PR that needs a label. +// Without a GitHub client the call reports an error and the handler +// surfaces it as a warning; the flag itself still flips. +func workflowProvisionLabels(cfg *config.Config, client func() *github.Client, logger *slog.Logger) func(ctx context.Context, repo string) error { + return func(ctx context.Context, repo string) error { + var gh *github.Client + if client != nil { + gh = client() + } + if gh == nil { + return errNoForgeClient + } + owner, name, ok := strings.Cut(config.QualifyRepo(cfg.Project.Org, repo), "/") + if !ok || owner == "" || name == "" { + return fmt.Errorf("repository %q is not owner/name", repo) + } + eng := automerge.New(gh, automerge.Options{ + Logger: logger, + StageLabelPolicy: stageLabelPolicyFunc(cfg), + StageEngine: stageEngineOptions(cfg, logger), + }) + return eng.EnsureStageLabels(ctx, owner, name) + } +} diff --git a/src/cmd/hive/workflow_labels_test.go b/src/cmd/hive/workflow_labels_test.go new file mode 100644 index 0000000000..205caf29a8 --- /dev/null +++ b/src/cmd/hive/workflow_labels_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/hivecommons/hive/pkg/config" + "github.com/hivecommons/hive/pkg/github" +) + +func TestWorkflowProvisionLabelsWithoutClient(t *testing.T) { + cfg := &config.Config{Project: config.ProjectConfig{Org: "acme"}} + provision := workflowProvisionLabels(cfg, func() *github.Client { return nil }, nil) + if err := provision(context.Background(), "widgets"); !errors.Is(err, errNoForgeClient) { + t.Fatalf("err = %v", err) + } +} + +func TestWorkflowProvisionLabelsRejectsBareRepoWithoutOrg(t *testing.T) { + cfg := &config.Config{} + provision := workflowProvisionLabels(cfg, func() *github.Client { return &github.Client{} }, nil) + err := provision(context.Background(), "widgets") + if err == nil || !strings.Contains(err.Error(), "owner/name") { + t.Fatalf("err = %v", err) + } +} diff --git a/src/docs/api-reference.md b/src/docs/api-reference.md index 44a4feadf9..33ea39c580 100644 --- a/src/docs/api-reference.md +++ b/src/docs/api-reference.md @@ -13,7 +13,7 @@ History point limits, timestamp cutoffs, incremental cost reads and ETag/304 beh | Health, status, events | [`api-reference/health-status-events.md`](api-reference/health-status-events.md) | 50 | | Snapshots and style | [`api-reference/snapshots-and-style.md`](api-reference/snapshots-and-style.md) | 5 | | Auth and external accounts | [`api-reference/auth.md`](api-reference/auth.md) | 22 | -| Configuration | [`api-reference/configuration.md`](api-reference/configuration.md) | 105 | +| Configuration | [`api-reference/configuration.md`](api-reference/configuration.md) | 110 | | Agents and controls | [`api-reference/agents-and-controls.md`](api-reference/agents-and-controls.md) | 36 | | Packs and ACMM | [`api-reference/packs-and-acmm.md`](api-reference/packs-and-acmm.md) | 18 | | Cost, tokens, telemetry | [`api-reference/cost-tokens-telemetry.md`](api-reference/cost-tokens-telemetry.md) | 13 | diff --git a/src/docs/api-reference/configuration.md b/src/docs/api-reference/configuration.md index 00f31ea1e5..4941f8f958 100644 --- a/src/docs/api-reference/configuration.md +++ b/src/docs/api-reference/configuration.md @@ -117,3 +117,4 @@ Part of the [Dashboard REST API reference](../api-reference.md); see that page f | `PUT` | `/api/config/governor/project-observability` | Owner only | Governor Project Observability Set | `pkg/dashboard/api.go:282` | | `GET` | `/api/config/governor/inference-auth` | Owner only | Governor Inference Auth Get | `pkg/dashboard/api.go:297` | | `PUT` | `/api/config/governor/inference-auth` | Owner only | Governor Inference Auth Set | `pkg/dashboard/api.go:298` | +| `PUT` | `/api/workflows/enabled` | Owner only | Turn model-based stage derivation (`workflow.enabled`, [#17620](https://github.com/hivecommons/hive/issues/17620)) on or off from the dashboard, for hosted hives with no `hive.yaml` access. Body `{enabled: bool}`; persists `hive.yaml`; the stage engine and tracker read the live flag so it applies on the next derivation pass without a restart. Turning on also provisions every bound repository's model labels now (`warnings[]` lists repositories that could not be provisioned; the next sweep retries). Returns `{enabled, changed, bindings[], warnings?[]}`. Audited `workflow_enabled` / `workflow_disabled`; `400` on a non-boolean body | `pkg/dashboard/api_workflows.go:28` | diff --git a/src/docs/design/workflow-models.md b/src/docs/design/workflow-models.md index 08d1e7f5cd..cba8a0dd33 100644 --- a/src/docs/design/workflow-models.md +++ b/src/docs/design/workflow-models.md @@ -169,6 +169,22 @@ and `GET /api/workflows/{name}/{version}`: Duplicate (saves a copy under a new name at version 1, provenance `local`) and Delete; Delete is withheld while anything is bound (the API returns 409 anyway) and the row says `bound` instead. Export shows the model JSON in-app (copy to clipboard) for everyone. +- **Derivation switch** — the Library header carries an owner-only toggle, *Derive stages from + bound models* (`renderWorkflowEnabledToggle`, `PUT /api/workflows/enabled {enabled}`), so a + hosted hive with no `hive.yaml` access can turn `workflow.enabled` on or off. Off: a binding + only changes the Activity Monitor's columns. On: a bound repository's stages are derived by its + model's rules and the model's labels are provisioned. Turning on asks for confirmation (it + changes derivation for every bound repository and creates labels in them); turning off is + immediate. The handler sets `Config.Workflow.Enabled`, saves `hive.yaml`, audits + `workflow_enabled` / `workflow_disabled`, and — on the way on — calls + `Dependencies.WorkflowProvisionLabels` for each binding (cmd/hive wires it to + `automerge.Engine.EnsureStageLabels`, the sweep's memoised `ensureStageLabels` path over the + shared stage-engine options and tracker), so the bound models' labels exist at once rather than + on the next PR that needs a label write; a repository that fails is returned in `warnings[]` + and the next sweep retries. No reload hook is needed: `stageEngineWorkflowFor` and the tracker + read `cfg.Workflow.Enabled` on every call, and the hive.yaml reloader mutates the same + `*Config` in place. The Repos binding row shows *columns only — turn on derivation in + Workflows* when a binding exists while the flag is off. - **Detail** — facts, the diagram, diagnostics, citations and the raw model/profile JSON. `workflowDiagramSvg(model, profile)` is a pure function: the compiled profile's columns are lanes in display order, each state is a chip carrying its padded stage number, transitions are bezier diff --git a/src/docs/fleet-report.md b/src/docs/fleet-report.md index ad7f77ae2a..fcd9a05adc 100644 --- a/src/docs/fleet-report.md +++ b/src/docs/fleet-report.md @@ -153,6 +153,7 @@ title is `[selfreport] : `. | `agent_cli_restart_loop` | the agent CLI crashed (bare shell) or sat on a consent screen and the harness restarted it before the kick (`agent CLI crashed or stuck on consent screen, restarting before kick`, `crux/agent/manager_kick.go`, `kick_async.go`), repeated ≥5 times in 10 min — a restart loop, not transient noise. Log-fed: same repeat threshold and idle clear as `agent_pane_timeout` | medium | | `copilot_cli_hung` | a Copilot agent showed none of the CLI's startup chrome (input prompt, help hint, login request) 180 s after launch, outside kick delivery, so the hang detector killed its session, ran bare `copilot` to capture the error and relaunched the agent (`copilot hung with no CLI prompt, running diagnostic`, `crux/agent/manager_poll.go`), or that diagnostic saw no output before its own deadline and relaunched anyway (`diagnostic: timed out waiting for copilot error output`, `crux/agent/manager_credentials.go`; the line carries the pane's `output_tail`). The key is the message, so ≥5 in 10 min counts every Copilot agent on the hive together: its Copilot agents keep failing to reach their prompt. Typical causes are a stale Copilot token the CLI hangs on (the diagnostic restores or clears it when it sees an auth error), a blocked network path to the Copilot API, or a CLI build whose startup screen the detector does not recognise (`cliReadyIndicators`, `crux/agent/pane_classify.go`). Log-fed: same repeat threshold and idle clear as `agent_pane_timeout` | medium | | `proxy_connect_dial_failed` | the hive proxy could not dial the upstream host of a CONNECT request (`proxy: CONNECT dial failed`, `crux/proxy/github_proxy_connect.go`) or of a Copilot usage-sniffed connection (`copilot sniff: upstream dial failed`, `crux/proxy/github_proxy_copilot.go`) — typically DNS or network unreachability of an agent-requested host (for example a dial timeout to `telemetry.enterprise.githubcopilot.com`), repeated ≥5 times in 10 min. Log-fed: same repeat threshold and idle clear as `agent_pane_timeout` | medium | +| `proxy_plain_forward_failed` | the proxy could not complete an agent's plain-HTTP upstream request (`proxy: plain HTTP forward failed`, `crux/proxy/github_proxy_connect.go`), returning HTTP 502. Check the requested host and logged error, including DNS and outbound connectivity; this differs from a destination refused by the proxy policy. Log-fed: ≥5 occurrences in 10 min, clears after 20 quiet minutes | medium | | `proxy_tls_handshake_failed` | the hive proxy's TLS handshake with an upstream host failed (`proxy client tls handshake failed`, `transparent proxy tls handshake failed`, `copilot sniff: client tls handshake failed`, `crux/proxy/github_proxy_connect.go`, `crux/proxy/github_proxy_copilot.go`) — typically a certificate validation failure or encryption mismatch on an agent-requested host, repeated ≥5 times in 10 min. Log-fed: same repeat threshold and idle clear as `agent_pane_timeout` | medium | | `review_evidence_write_failed` | the github-client failed to write a review evidence bundle to persistent storage (`review evidence: could not write bundle`, `pkg/github`), repeated ≥5 times in 10 min. Log-fed: same repeat threshold and idle clear as `agent_pane_timeout` | medium | | `selfreport_cleared_comment_failed` | the fleet report publisher failed to post the "condition cleared" comment on a self-report issue (`fleet report: cleared comment failed`, `pkg/github`), repeated ≥5 times in 10 min. Log-fed: same repeat threshold and idle clear as `agent_pane_timeout` | medium | diff --git a/src/hive.yaml.example b/src/hive.yaml.example index 3277db7eba..cbd09db9a6 100644 --- a/src/hive.yaml.example +++ b/src/hive.yaml.example @@ -1290,3 +1290,14 @@ data: claude_sessions_dir: /data/home/.claude/projects copilot_sessions_dir: /data/home/.copilot/session-state agents_dir: /data/agent-configs + +# Workflow models (hivecommons/hive#17620, docs/design/workflow-models.md). +# Repositories are bound to a model on Settings → Repos (project.workflow_bindings). +# A binding always changes the Activity Monitor's columns for that repository; +# `enabled` decides whether the bound model's rules also derive its stages and +# whether the model's labels are provisioned in the repository. Owners can flip +# it from Settings → Workflows → Library (PUT /api/workflows/enabled) without +# editing this file; the change applies on the next derivation pass. +# workflow: +# enabled: false # false (default): bindings are columns only +# label_gc: true # delete labels a previous binding provisioned once nothing wears them diff --git a/src/pkg/dashboard/api_workflows.go b/src/pkg/dashboard/api_workflows.go index d937eca0b9..71769ec9b8 100644 --- a/src/pkg/dashboard/api_workflows.go +++ b/src/pkg/dashboard/api_workflows.go @@ -1,6 +1,7 @@ package dashboard import ( + "context" "errors" "fmt" "net/http" @@ -24,6 +25,7 @@ func (s *Server) registerWorkflowRoutes() { s.mux.HandleFunc("POST /api/workflows/ingest", s.handleWorkflowIngest) s.mux.HandleFunc("GET /api/workflows/bindings", s.handleWorkflowBindingsGet) s.mux.HandleFunc("PUT /api/workflows/bindings", s.handleWorkflowBindingPut) + s.mux.HandleFunc("PUT /api/workflows/enabled", s.handleWorkflowEnabledPut) s.mux.HandleFunc("GET /api/workflows/{name}/{version}", s.handleWorkflowGet) s.mux.HandleFunc("PUT /api/workflows/{name}/{version}", s.handleWorkflowSave) s.mux.HandleFunc("DELETE /api/workflows/{name}/{version}", s.handleWorkflowDelete) @@ -236,3 +238,88 @@ func (s *Server) handleWorkflowBindingPut(w http.ResponseWriter, r *http.Request } jsonResponse(w, map[string]interface{}{"changed": changed, "bindings": s.workflowBindings()}) } + +// workflowProvisionTimeout bounds the label provisioning PUT +// /api/workflows/enabled runs across every bound repository when it turns +// derivation on: one label list plus a create per missing label per +// repository, all within the request. +const workflowProvisionTimeout = 60 * time.Second + +// handleWorkflowEnabledPut flips workflow.enabled (hivecommons/hive#17620) +// from the dashboard so a hosted hive with no hive.yaml access can turn +// model-based stage derivation on or off. The stage engine +// (cmd/hive stageEngineWorkflowFor) and the tracker read the live config +// on every call, so the change takes effect on the next derivation pass +// with no restart or reload. Turning it on also provisions every bound +// repository's model labels now through Dependencies.WorkflowProvisionLabels; +// a provisioning failure is reported in the response as a warning, never as +// a failure of the flip itself, because the sweep retries it on its next +// pass. Audited as workflow_enabled / workflow_disabled; a no-op flip is +// not audited. +func (s *Server) handleWorkflowEnabledPut(w http.ResponseWriter, r *http.Request) { + if !requireOwnerRole(w, r) { + return + } + if s.deps == nil || s.deps.Config == nil { + jsonError(w, "config unavailable", http.StatusServiceUnavailable) + return + } + var body struct { + Enabled *bool `json:"enabled"` + } + if err := decodeBody(r, &body); err != nil { + jsonError(w, err.Error(), http.StatusBadRequest) + return + } + if body.Enabled == nil { + jsonError(w, "body must be {\"enabled\": true|false}", http.StatusBadRequest) + return + } + changed := s.deps.Config.Workflow.Enabled != *body.Enabled + if changed { + s.deps.Config.Workflow.Enabled = *body.Enabled + if err := s.saveConfig(); err != nil { + s.deps.Config.Workflow.Enabled = !*body.Enabled + jsonError(w, "save config: "+err.Error(), http.StatusInternalServerError) + return + } + action := "workflow_disabled" + if *body.Enabled { + action = "workflow_enabled" + } + s.auditFromRequest(r, action, fmt.Sprintf("%d bound repositories", len(s.workflowBindings())), "") + } + resp := map[string]interface{}{ + "enabled": s.deps.Config.Workflow.Enabled, + "changed": changed, + "bindings": s.workflowBindings(), + } + if *body.Enabled { + if warnings := s.provisionBoundWorkflowLabels(r.Context()); len(warnings) > 0 { + resp["warnings"] = warnings + } + } + jsonResponse(w, resp) +} + +// provisionBoundWorkflowLabels runs WorkflowProvisionLabels for every bound +// repository and returns one message per repository it could not provision. +func (s *Server) provisionBoundWorkflowLabels(ctx context.Context) []string { + if s.deps.WorkflowProvisionLabels == nil { + return nil + } + bindings := s.workflowBindings() + if len(bindings) == 0 { + return nil + } + ctx, cancel := context.WithTimeout(ctx, workflowProvisionTimeout) + defer cancel() + var warnings []string + for _, b := range bindings { + if err := s.deps.WorkflowProvisionLabels(ctx, b.Repo); err != nil { + s.logger.Warn("workflow labels: provisioning on enable failed; the next sweep retries", "repo", b.Repo, "workflow", b.Key(), "err", err) + warnings = append(warnings, fmt.Sprintf("%s: %s", b.Repo, err.Error())) + } + } + return warnings +} diff --git a/src/pkg/dashboard/api_workflows_test.go b/src/pkg/dashboard/api_workflows_test.go index bb9e077957..7a7a7d9c59 100644 --- a/src/pkg/dashboard/api_workflows_test.go +++ b/src/pkg/dashboard/api_workflows_test.go @@ -2,12 +2,16 @@ package dashboard import ( "bytes" + "context" "encoding/json" + "errors" "io" "log/slog" "net/http" "net/http/httptest" "os" + "path/filepath" + "strings" "testing" "time" @@ -276,3 +280,101 @@ func TestStageTrackerWorkflowsBlock(t *testing.T) { t.Fatalf("%+v", resp.Workflows) } } + +// PUT /api/workflows/enabled flips workflow.enabled from the dashboard +// (hivecommons/hive#17620): owner-gated, persisted to hive.yaml, audited, +// and on the way on it provisions every bound repository's labels. +func TestWorkflowEnabledPut(t *testing.T) { + s := workflowTestServer(t, true) + s.deps.Config.SourcePath = filepath.Join(t.TempDir(), "hive.yaml") + var provisioned []string + s.deps.WorkflowProvisionLabels = func(_ context.Context, repo string) error { + provisioned = append(provisioned, repo) + if repo == "repo2" { + return errors.New("no such repository") + } + return nil + } + if rec := wfReq(s, http.MethodPost, "/api/workflows", techdocsModel(t), true); rec.Code != http.StatusCreated { + t.Fatal(rec.Body.String()) + } + for _, repo := range []string{"repo1", "repo2"} { + if _, err := s.deps.Config.SetWorkflowBinding(repo, "cncf-techdocs", "1"); err != nil { + t.Fatal(err) + } + } + + if rec := wfReq(s, http.MethodPut, "/api/workflows/enabled", map[string]bool{"enabled": true}, false); rec.Code != http.StatusForbidden { + t.Fatalf("non-owner: %d %s", rec.Code, rec.Body) + } + for _, bad := range []string{"{", "{}", `{"enabled":"yes"}`} { + if rec := wfReq(s, http.MethodPut, "/api/workflows/enabled", bad, true); rec.Code != http.StatusBadRequest { + t.Fatalf("body %s: %d %s", bad, rec.Code, rec.Body) + } + } + if s.deps.Config.Workflow.Enabled || len(provisioned) != 0 { + t.Fatalf("rejected requests changed state: enabled=%v provisioned=%v", s.deps.Config.Workflow.Enabled, provisioned) + } + + rec := wfReq(s, http.MethodPut, "/api/workflows/enabled", map[string]bool{"enabled": true}, true) + var out struct { + Enabled bool + Changed bool + Bindings []struct{ Repo string } + Warnings []string + } + if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &out) != nil || !out.Enabled || !out.Changed || len(out.Bindings) != 2 { + t.Fatalf("enable: %d %s", rec.Code, rec.Body) + } + if !s.deps.Config.Workflow.Enabled { + t.Fatal("flag not applied to the live config") + } + if len(provisioned) != 2 || len(out.Warnings) != 1 || !strings.Contains(out.Warnings[0], "repo2") { + t.Fatalf("provisioned=%v warnings=%v", provisioned, out.Warnings) + } + saved, err := os.ReadFile(s.deps.Config.SourcePath) + if err != nil || !bytes.Contains(saved, []byte("enabled: true")) { + t.Fatalf("hive.yaml not persisted: %v\n%s", err, saved) + } + rec = wfReq(s, http.MethodGet, "/api/workflows", nil, false) + if rec.Code != 200 || !bytes.Contains(rec.Body.Bytes(), []byte(`"enabled":true`)) { + t.Fatalf("list after enable: %d %s", rec.Code, rec.Body) + } + + // Idempotent: same value again is not a change, not audited, but still + // re-provisions (the memo makes that cheap). + rec = wfReq(s, http.MethodPut, "/api/workflows/enabled", map[string]bool{"enabled": true}, true) + if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &out) != nil || !out.Enabled || out.Changed { + t.Fatalf("enable again: %d %s", rec.Code, rec.Body) + } + provisioned = nil + out.Warnings = nil + rec = wfReq(s, http.MethodPut, "/api/workflows/enabled", map[string]bool{"enabled": false}, true) + if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &out) != nil || out.Enabled || !out.Changed || len(out.Warnings) != 0 { + t.Fatalf("disable: %d %s", rec.Code, rec.Body) + } + if s.deps.Config.Workflow.Enabled || len(provisioned) != 0 { + t.Fatalf("disable: enabled=%v provisioned=%v", s.deps.Config.Workflow.Enabled, provisioned) + } + + var actions []string + for _, e := range s.audit.Recent(0) { + if e.Action == "workflow_enabled" || e.Action == "workflow_disabled" { + actions = append(actions, e.Action) + } + } + if strings.Join(actions, ",") != "workflow_disabled,workflow_enabled" && strings.Join(actions, ",") != "workflow_enabled,workflow_disabled" { + t.Fatalf("audit actions %v", actions) + } + + // A failed save leaves the live flag where it was. + s.deps.Config.SourcePath = filepath.Join(t.TempDir(), "missing", "hive.yaml") + if rec := wfReq(s, http.MethodPut, "/api/workflows/enabled", map[string]bool{"enabled": true}, true); rec.Code != http.StatusInternalServerError || s.deps.Config.Workflow.Enabled { + t.Fatalf("unsaveable: %d %s enabled=%v", rec.Code, rec.Body, s.deps.Config.Workflow.Enabled) + } + + s.deps.Config = nil + if rec := wfReq(s, http.MethodPut, "/api/workflows/enabled", map[string]bool{"enabled": true}, true); rec.Code != http.StatusServiceUnavailable { + t.Fatalf("no config: %d", rec.Code) + } +} diff --git a/src/pkg/dashboard/deps.go b/src/pkg/dashboard/deps.go index 351b2f1074..8ed04dbb1a 100644 --- a/src/pkg/dashboard/deps.go +++ b/src/pkg/dashboard/deps.go @@ -55,6 +55,10 @@ type Dependencies struct { // spec) for POST /api/workflows/ingest/spec {repo, path}. Nil disables // repo-path spec ingest. WorkflowRepoFile func(ctx context.Context, repo, path string) (string, error) + // WorkflowProvisionLabels creates (or recolours) one bound repository's + // stage and model labels now, for PUT /api/workflows/enabled turning + // derivation on. Nil leaves provisioning to the next sweep pass. + WorkflowProvisionLabels func(ctx context.Context, repo string) error // WorkflowClusterRunner runs kubectl for POST /api/workflows/ingest/cluster // (RFC #17620 phase 6). Nil means no kube access is configured on this // spoke and the route answers 422. diff --git a/src/pkg/dashboard/static-src/css/workflows.css b/src/pkg/dashboard/static-src/css/workflows.css index 7ae7bc94b8..0fcbaf339f 100644 --- a/src/pkg/dashboard/static-src/css/workflows.css +++ b/src/pkg/dashboard/static-src/css/workflows.css @@ -6,6 +6,10 @@ .wf-key { color: var(--text); text-decoration: none; font-weight: 700; } .wf-key:hover { text-decoration: underline; } .wf-desc { font-size: var(--fs-xs); margin-top: var(--sp-1); max-width: 36em; } + .wf-enabled-toggle { margin: var(--sp-3) 0 var(--sp-1); } + .wf-enabled-toggle .config-toggle-label { font-weight: 700; } + .wf-enabled-explain { margin: 0 0 var(--sp-4); max-width: 60em; } + .wf-derivation-off { color: var(--status-warn); } .wf-prov, .wf-compiled { display: inline-block; border-radius: var(--r-pill); padding: var(--sp-1) var(--sp-3); font-size: var(--fs-xs); font-weight: 700; white-space: nowrap; border: var(--line-width) solid var(--border); color: var(--muted); } .wf-prov-built-in { color: var(--status-info); border-color: color-mix(in srgb, var(--status-info) 40%, transparent); background: color-mix(in srgb, var(--status-info) 10%, transparent); } .wf-prov-curated { color: var(--status-ok); border-color: color-mix(in srgb, var(--status-ok) 40%, transparent); background: color-mix(in srgb, var(--status-ok) 10%, transparent); } diff --git a/src/pkg/dashboard/static-src/js/config-workflows-library.js b/src/pkg/dashboard/static-src/js/config-workflows-library.js index b4b9a4260a..c1f05464d1 100644 --- a/src/pkg/dashboard/static-src/js/config-workflows-library.js +++ b/src/pkg/dashboard/static-src/js/config-workflows-library.js @@ -235,7 +235,7 @@ pr: { checks: 'unknown', review: 'pending', dco: 'unknown', conflicting: false } }; } function wfSimDefaultState() { return { kind: 'pr', repo: '', facts: wfSimDefaultFacts(), result: null, error: '', busy: false, open: false }; } - const _wfLib = { view: 'library', selected: null, detail: null, detailError: '', loadingKey: '', pending: null, + const _wfLib = { view: 'library', selected: null, detail: null, detailError: '', loadingKey: '', pending: null, enabledBusy: false, ingest: { repo: '', name: '', busy: false, result: null, error: '' }, sim: wfSimDefaultState() }; @@ -314,10 +314,30 @@ }).join(''); } - function renderWorkflowLibraryTable(workflows, bindings, owner) { + // renderWorkflowEnabledToggle is the Library header's owner switch for + // workflow.enabled (PUT /api/workflows/enabled). Pure: enabled is the + // live flag from GET /api/workflows, owner gates the control, busy dims + // it while a PUT is in flight. + function renderWorkflowEnabledToggle(enabled, owner, busy) { + const on = !!enabled; + const locked = !owner || !!busy; + const explain = on + ? 'On: a bound repository\'s stages are derived by its model\'s rules and the model\'s labels are provisioned in the repository.' + : 'Off: a binding only changes the Activity Monitor\'s columns; stages are still derived by the built-in engine.'; + return '
' + + '
' + + 'Derive stages from bound models (' + yamlHint('workflow.enabled') + ')' + + '
' + + '

' + explain + '

'; + } + + function renderWorkflowLibraryTable(workflows, bindings, owner, enabled, busy) { return '
' + '

Workflow library

' + - '

Every workflow model this hive knows: the built-in hive-default lifecycle, models saved here, and drafts ingested from repository pipelines. A repository follows a model only once it is bound on Settings → Repos; binding changes the Activity Monitor\'s columns for that repository and, when ' + yamlHint('workflow.enabled') + ' is on, how its stages are derived.

' + + renderWorkflowEnabledToggle(enabled, owner, busy) + + '

Every workflow model this hive knows: the built-in hive-default lifecycle, models saved here, and drafts ingested from repository pipelines. A repository follows a model only once it is bound on Settings → Repos; binding changes the Activity Monitor\'s columns for that repository and, when the switch above is on, how its stages are derived.

' + '' + '' + renderWorkflowLibraryRows(workflows, bindings, owner) + '
ModelProvenanceSourceCompileS / CBound
' + '
'; @@ -783,7 +803,7 @@ } else if (_wfLib.view === 'marketplace') { body = renderWorkflowMarketplaceView(owner); } else { - body = renderWorkflowLibraryTable(_workflowState.workflows, _workflowState.bindings, owner); + body = renderWorkflowLibraryTable(_workflowState.workflows, _workflowState.bindings, owner, _workflowState.enabled, _wfLib.enabledBusy); } return wfLibNav(_wfLib.view) + body; } @@ -829,6 +849,45 @@ openConfigDialog('governor', null, 'Workflows'); } + // wfToggleEnabled flips workflow.enabled through PUT /api/workflows/enabled. + // Turning it on changes how every bound repository's stages are derived + // and creates labels in those repositories, so it asks first; turning it + // off is immediate (columns stay, derivation reverts to the built-in + // engine). + async function wfToggleEnabled(el) { + if (!dashboardRoleAtLeast(window._hiveRole || 'read', 'owner')) { showToast('Owner role is required to change workflow.enabled.', 'error'); return; } + if (_wfLib.enabledBusy) return; + const next = !_workflowState.enabled; + if (next) { + const bound = (_workflowState.bindings || []).length; + const ok = await hiveConfirm({ + title: 'Derive stages from bound models?', + message: 'Stages for ' + (bound ? bound + ' bound repositor' + (bound === 1 ? 'y' : 'ies') : 'any repository you bind') + ' will be derived by the bound model\'s rules instead of the built-in engine, and each model\'s labels are created in the repository now. Unbound repositories are unaffected. You can turn this off again at any time.', + confirmLabel: 'Turn on', + }); + if (!ok) return; + } + _wfLib.enabledBusy = true; + wfLibRerender(); + try { + const res = await fetch('/api/workflows/enabled', { method: 'PUT', headers: _inceptionAuthHeaders(), body: JSON.stringify({ enabled: next }) }); + if (!res.ok) throw new Error(await saveErrorMessage(res)); + const data = await res.json(); + _workflowState.enabled = !!data.enabled; + if (Array.isArray(data.bindings)) _workflowState.bindings = data.bindings; + if (Array.isArray(data.warnings) && data.warnings.length) { + showToast('Derivation is on, but some labels could not be provisioned yet (the next sweep retries): ' + data.warnings.join('; '), 'error'); + } else { + showToast(_workflowState.enabled ? 'Stages are now derived from bound models.' : 'Stage derivation is back on the built-in engine; bound columns stay.'); + } + } catch (err) { + showToast('Could not change workflow.enabled: ' + err.message, 'error'); + } finally { + _wfLib.enabledBusy = false; + wfLibRerender(); + } + } + async function wfLibReload() { _workflowState.loaded = false; await loadWorkflowLibrary(); diff --git a/src/pkg/dashboard/static-src/js/config-workflows.js b/src/pkg/dashboard/static-src/js/config-workflows.js index 407bed42df..74536c106b 100644 --- a/src/pkg/dashboard/static-src/js/config-workflows.js +++ b/src/pkg/dashboard/static-src/js/config-workflows.js @@ -60,9 +60,9 @@ const label = key + (w.description ? ' — ' + w.description : '') + (compiled ? '' : ' (draft, does not compile)'); return ``; }).join(''); - const flag = _workflowState.enabled + const flag = _workflowState.enabled || !bound ? '' - : 'derivation off'; + : 'columns only — turn on derivation in Workflows'; return `
${flag}${bound ? `bound` : 'default'}
`; } diff --git a/src/pkg/dashboard/static-src/js/knowledge.js b/src/pkg/dashboard/static-src/js/knowledge.js index 10fdd87fd5..e11eda71b6 100644 --- a/src/pkg/dashboard/static-src/js/knowledge.js +++ b/src/pkg/dashboard/static-src/js/knowledge.js @@ -1978,6 +1978,7 @@ html += '
🎙️ Plaud voice notes'; html += '
'; html += '
Make your Plaud Note recordings (Plaud summary + transcript) searchable knowledge via the free official Plaud MCP server. $0 beyond your Plaud plan; sign-in is Plaud OAuth in your browser.
'; + html += ''; html += '
'; html += '
'; @@ -2455,6 +2456,7 @@ if (!_kbIntegrationEditName || !connectors.some(c => c.name === _kbIntegrationEditName)) _kbIntegrationEditName = connectors[0]?.name || ''; const edited = connectors.find(c => c.name === _kbIntegrationEditName) || { name: '', type, enabled: true, layer: 'project', interval: '', scope: {}, auth: {} }; let html = '
' + kbIntegrationIcon(type) + escapeHtml(info.label) + '

' + escapeHtml(info.description) + '

Docs ↗
'; + if (type === 'plaud') html += '

Inbox voice sources quickstart ↗

'; html += '
' + (owner ? '' : '') + '
'; html += connectors.length ? connectors.map(c => kbConnectorRow(type, c, owner)).join('') : '
No ' + escapeHtml(info.label) + ' connector configured yet.
'; html += owner ? kbConnectorFormHTML(type, edited, !!edited.name) : '

Only the hive owner can edit connector configuration.

'; diff --git a/src/pkg/dashboard/static/index.html b/src/pkg/dashboard/static/index.html index 2fe603c8b8..060ad211ba 100644 --- a/src/pkg/dashboard/static/index.html +++ b/src/pkg/dashboard/static/index.html @@ -5053,6 +5053,10 @@ .wf-key { color: var(--text); text-decoration: none; font-weight: 700; } .wf-key:hover { text-decoration: underline; } .wf-desc { font-size: var(--fs-xs); margin-top: var(--sp-1); max-width: 36em; } + .wf-enabled-toggle { margin: var(--sp-3) 0 var(--sp-1); } + .wf-enabled-toggle .config-toggle-label { font-weight: 700; } + .wf-enabled-explain { margin: 0 0 var(--sp-4); max-width: 60em; } + .wf-derivation-off { color: var(--status-warn); } .wf-prov, .wf-compiled { display: inline-block; border-radius: var(--r-pill); padding: var(--sp-1) var(--sp-3); font-size: var(--fs-xs); font-weight: 700; white-space: nowrap; border: var(--line-width) solid var(--border); color: var(--muted); } .wf-prov-built-in { color: var(--status-info); border-color: color-mix(in srgb, var(--status-info) 40%, transparent); background: color-mix(in srgb, var(--status-info) 10%, transparent); } .wf-prov-curated { color: var(--status-ok); border-color: color-mix(in srgb, var(--status-ok) 40%, transparent); background: color-mix(in srgb, var(--status-ok) 10%, transparent); } @@ -45520,6 +45524,7 @@

🎙️ Plaud voice notes'; html += '
'; html += '
Make your Plaud Note recordings (Plaud summary + transcript) searchable knowledge via the free official Plaud MCP server. $0 beyond your Plaud plan; sign-in is Plaud OAuth in your browser.
'; + html += ''; html += '
'; html += '
'; @@ -45997,6 +46002,7 @@

if (!_kbIntegrationEditName || !connectors.some(c => c.name === _kbIntegrationEditName)) _kbIntegrationEditName = connectors[0]?.name || ''; const edited = connectors.find(c => c.name === _kbIntegrationEditName) || { name: '', type, enabled: true, layer: 'project', interval: '', scope: {}, auth: {} }; let html = '
' + kbIntegrationIcon(type) + escapeHtml(info.label) + '

' + escapeHtml(info.description) + '

Docs ↗
'; + if (type === 'plaud') html += '

Inbox voice sources quickstart ↗

'; html += '
' + (owner ? '' : '') + '
'; html += connectors.length ? connectors.map(c => kbConnectorRow(type, c, owner)).join('') : '
No ' + escapeHtml(info.label) + ' connector configured yet.
'; html += owner ? kbConnectorFormHTML(type, edited, !!edited.name) : '

Only the hive owner can edit connector configuration.

'; @@ -63280,9 +63286,9 @@

Agent Watchdog

const label = key + (w.description ? ' — ' + w.description : '') + (compiled ? '' : ' (draft, does not compile)'); return ``; }).join(''); - const flag = _workflowState.enabled + const flag = _workflowState.enabled || !bound ? '' - : 'derivation off'; + : 'columns only — turn on derivation in Workflows'; return `
${flag}${bound ? `bound` : 'default'}
`; } @@ -63551,7 +63557,7 @@

Agent Watchdog

pr: { checks: 'unknown', review: 'pending', dco: 'unknown', conflicting: false } }; } function wfSimDefaultState() { return { kind: 'pr', repo: '', facts: wfSimDefaultFacts(), result: null, error: '', busy: false, open: false }; } - const _wfLib = { view: 'library', selected: null, detail: null, detailError: '', loadingKey: '', pending: null, + const _wfLib = { view: 'library', selected: null, detail: null, detailError: '', loadingKey: '', pending: null, enabledBusy: false, ingest: { repo: '', name: '', busy: false, result: null, error: '' }, sim: wfSimDefaultState() }; @@ -63630,10 +63636,30 @@

Agent Watchdog

}).join(''); } - function renderWorkflowLibraryTable(workflows, bindings, owner) { + // renderWorkflowEnabledToggle is the Library header's owner switch for + // workflow.enabled (PUT /api/workflows/enabled). Pure: enabled is the + // live flag from GET /api/workflows, owner gates the control, busy dims + // it while a PUT is in flight. + function renderWorkflowEnabledToggle(enabled, owner, busy) { + const on = !!enabled; + const locked = !owner || !!busy; + const explain = on + ? 'On: a bound repository\'s stages are derived by its model\'s rules and the model\'s labels are provisioned in the repository.' + : 'Off: a binding only changes the Activity Monitor\'s columns; stages are still derived by the built-in engine.'; + return '
' + + '
' + + 'Derive stages from bound models (' + yamlHint('workflow.enabled') + ')' + + '
' + + '

' + explain + '

'; + } + + function renderWorkflowLibraryTable(workflows, bindings, owner, enabled, busy) { return '
' + '

Workflow library

' + - '

Every workflow model this hive knows: the built-in hive-default lifecycle, models saved here, and drafts ingested from repository pipelines. A repository follows a model only once it is bound on Settings → Repos; binding changes the Activity Monitor\'s columns for that repository and, when ' + yamlHint('workflow.enabled') + ' is on, how its stages are derived.

' + + renderWorkflowEnabledToggle(enabled, owner, busy) + + '

Every workflow model this hive knows: the built-in hive-default lifecycle, models saved here, and drafts ingested from repository pipelines. A repository follows a model only once it is bound on Settings → Repos; binding changes the Activity Monitor\'s columns for that repository and, when the switch above is on, how its stages are derived.

' + '' + '' + renderWorkflowLibraryRows(workflows, bindings, owner) + '
ModelProvenanceSourceCompileS / CBound
' + '
'; @@ -64099,7 +64125,7 @@

Agent Watchdog

} else if (_wfLib.view === 'marketplace') { body = renderWorkflowMarketplaceView(owner); } else { - body = renderWorkflowLibraryTable(_workflowState.workflows, _workflowState.bindings, owner); + body = renderWorkflowLibraryTable(_workflowState.workflows, _workflowState.bindings, owner, _workflowState.enabled, _wfLib.enabledBusy); } return wfLibNav(_wfLib.view) + body; } @@ -64145,6 +64171,45 @@

Agent Watchdog

openConfigDialog('governor', null, 'Workflows'); } + // wfToggleEnabled flips workflow.enabled through PUT /api/workflows/enabled. + // Turning it on changes how every bound repository's stages are derived + // and creates labels in those repositories, so it asks first; turning it + // off is immediate (columns stay, derivation reverts to the built-in + // engine). + async function wfToggleEnabled(el) { + if (!dashboardRoleAtLeast(window._hiveRole || 'read', 'owner')) { showToast('Owner role is required to change workflow.enabled.', 'error'); return; } + if (_wfLib.enabledBusy) return; + const next = !_workflowState.enabled; + if (next) { + const bound = (_workflowState.bindings || []).length; + const ok = await hiveConfirm({ + title: 'Derive stages from bound models?', + message: 'Stages for ' + (bound ? bound + ' bound repositor' + (bound === 1 ? 'y' : 'ies') : 'any repository you bind') + ' will be derived by the bound model\'s rules instead of the built-in engine, and each model\'s labels are created in the repository now. Unbound repositories are unaffected. You can turn this off again at any time.', + confirmLabel: 'Turn on', + }); + if (!ok) return; + } + _wfLib.enabledBusy = true; + wfLibRerender(); + try { + const res = await fetch('/api/workflows/enabled', { method: 'PUT', headers: _inceptionAuthHeaders(), body: JSON.stringify({ enabled: next }) }); + if (!res.ok) throw new Error(await saveErrorMessage(res)); + const data = await res.json(); + _workflowState.enabled = !!data.enabled; + if (Array.isArray(data.bindings)) _workflowState.bindings = data.bindings; + if (Array.isArray(data.warnings) && data.warnings.length) { + showToast('Derivation is on, but some labels could not be provisioned yet (the next sweep retries): ' + data.warnings.join('; '), 'error'); + } else { + showToast(_workflowState.enabled ? 'Stages are now derived from bound models.' : 'Stage derivation is back on the built-in engine; bound columns stay.'); + } + } catch (err) { + showToast('Could not change workflow.enabled: ' + err.message, 'error'); + } finally { + _wfLib.enabledBusy = false; + wfLibRerender(); + } + } + async function wfLibReload() { _workflowState.loaded = false; await loadWorkflowLibrary(); diff --git a/src/pkg/dashboard/workflows_library_ui_test.go b/src/pkg/dashboard/workflows_library_ui_test.go index 9d02ec9922..e7f0f5156b 100644 --- a/src/pkg/dashboard/workflows_library_ui_test.go +++ b/src/pkg/dashboard/workflows_library_ui_test.go @@ -124,3 +124,59 @@ console.log('ok'); t.Fatalf("unexpected node output: %s", out) } } + +// The Library header's workflow.enabled switch (hivecommons/hive#17620): +// wired to PUT /api/workflows/enabled with a confirm on the way on, and the +// Repos binding row's "columns only" hint when a binding exists while +// derivation is off. +func TestWorkflowsEnabledToggleWiring(t *testing.T) { + html := indexHTML(t) + for _, want := range []string{ + `function renderWorkflowEnabledToggle(enabled, owner, busy)`, + `async function wfToggleEnabled(el)`, + `fetch('/api/workflows/enabled', { method: 'PUT'`, + `title: 'Derive stages from bound models?'`, + `data-action="wfToggleEnabled"`, + `renderWorkflowLibraryTable(_workflowState.workflows, _workflowState.bindings, owner, _workflowState.enabled, _wfLib.enabledBusy)`, + `columns only — turn on derivation in Workflows`, + `.wf-enabled-toggle {`, + } { + if !strings.Contains(html, want) { + t.Errorf("index.html missing workflow.enabled toggle wiring %q", want) + } + } +} + +func TestWorkflowsEnabledToggleNode(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node not installed") + } + html := indexHTML(t) + script := `const assert = require('assert'); +function esc(s) { return String(s).replace(/&/g, '&').replace(/' + p + ''; } +` + jsFunc(t, html, "renderWorkflowEnabledToggle") + ` +const off = renderWorkflowEnabledToggle(false, true, false); +assert.ok(off.includes('data-wf-enabled="off"') && off.includes('aria-checked="false"'), 'off state'); +assert.ok(off.includes('data-action="wfToggleEnabled"'), 'owner can flip'); +assert.ok(!off.includes('config-toggle-switch on'), 'switch not on'); +assert.ok(off.includes('Off: a binding only changes'), 'off explanation'); +assert.ok(off.includes('workflow.enabled'), 'names the yaml key'); + +const on = renderWorkflowEnabledToggle(true, true, false); +assert.ok(on.includes('config-toggle-switch on') && on.includes('aria-checked="true"'), 'on state'); +assert.ok(on.includes('On: a bound repository'), 'on explanation'); + +const viewer = renderWorkflowEnabledToggle(false, false, false); +assert.ok(!viewer.includes('data-action="wfToggleEnabled"') && viewer.includes('aria-disabled="true"') && viewer.includes(' disabled'), 'non-owner sees a locked switch'); +assert.ok(viewer.includes('Owner role is required'), 'non-owner title explains'); + +const busy = renderWorkflowEnabledToggle(true, true, true); +assert.ok(!busy.includes('data-action="wfToggleEnabled"') && busy.includes(' disabled'), 'busy switch is locked'); +` + cmd := exec.Command(node, "-e", script) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("node: %v\n%s", err, out) + } +} diff --git a/src/pkg/delegatedecision/issue_judgment.go b/src/pkg/delegatedecision/issue_judgment.go index e21278a332..377adbc3da 100644 --- a/src/pkg/delegatedecision/issue_judgment.go +++ b/src/pkg/delegatedecision/issue_judgment.go @@ -101,6 +101,7 @@ var selfreportOpsRemedies = map[string]string{ "github_app_not_installed": "install the GitHub App on the organisation or repositories the spoke serves", "github_app_repo_not_covered": "add the repository to the GitHub App installation the spoke uses", "proxy_connect_dial_failed": "restore the spoke's outbound proxy or network path", + "proxy_plain_forward_failed": "check the requested HTTP host, DNS and outbound network path on the spoke", "proxy_tls_handshake_failed": "fix the spoke's proxy TLS trust (certificate or CA bundle)", "crux_runtime_unavailable": "restart or re-deploy the crux runtime sidecar on the spoke", "model_discovery_degraded": "check the model endpoint and credentials the spoke's provider is configured with", diff --git a/src/pkg/delegatedecision/issue_judgment_test.go b/src/pkg/delegatedecision/issue_judgment_test.go index 4c20d4367e..9f0bdc0ae3 100644 --- a/src/pkg/delegatedecision/issue_judgment_test.go +++ b/src/pkg/delegatedecision/issue_judgment_test.go @@ -160,7 +160,7 @@ func TestSelfreportOpsClassesMatchFleetReport(t *testing.T) { for _, c := range []string{ fleetreport.ClassAgentLoginRequired, fleetreport.ClassInferenceAuthFailed, fleetreport.ClassDataDiskPressure, fleetreport.ClassGitHubAppTokenMintFailed, fleetreport.ClassGitHubAppNotInstalled, fleetreport.ClassGitHubAppRepoNotCovered, - fleetreport.ClassProxyConnectDialFailed, fleetreport.ClassProxyTLSHandshakeFailed, fleetreport.ClassCruxRuntimeUnavailable, + fleetreport.ClassProxyConnectDialFailed, fleetreport.ClassProxyPlainForwardFailed, fleetreport.ClassProxyTLSHandshakeFailed, fleetreport.ClassCruxRuntimeUnavailable, fleetreport.ClassModelDiscoveryDegraded, fleetreport.ClassInferenceUpstreamError, fleetreport.ClassInferenceCallerUnknown, fleetreport.ClassLabelProvisioningFailed, fleetreport.ClassSharedCIRerunForbidden, } { diff --git a/src/pkg/fleetreport/runtime.go b/src/pkg/fleetreport/runtime.go index 9e9b3c3c48..3946d8adb4 100644 --- a/src/pkg/fleetreport/runtime.go +++ b/src/pkg/fleetreport/runtime.go @@ -79,6 +79,7 @@ const ( ClassGitHubAppRepoNotCovered = "github_app_repo_not_covered" ClassHubHeartbeatUnprovisioned = "hub_heartbeat_rejected_unprovisioned" ClassProxyConnectDialFailed = "proxy_connect_dial_failed" + ClassProxyPlainForwardFailed = "proxy_plain_forward_failed" ClassProxyPlainForwardRefused = "proxy_plain_forward_refused" ClassInferenceUpstreamError = "inference_upstream_error" ClassInferenceCallerUnknown = "inference_caller_unidentified" @@ -201,6 +202,7 @@ var Classes = map[string]ClassSpec{ ClassHubHeartbeatUnprovisioned: {Component: "agent-runtime", Severity: "medium", MinCount: 1, IdleClear: 3 * time.Hour, LogFed: true, Keywords: []string{"hub heartbeat rejected: hive id is not provisioned"}}, ClassGitHubAppRepoNotCovered: {Component: "github-client", Severity: "low", MinCount: unclassifiedRepeatCount, Window: unclassifiedRepeatWindow, IdleClear: unclassifiedIdleClear, LogFed: true, Keywords: []string{"installation does not cover every configured repo", "state=repo-not-covered"}}, ClassProxyConnectDialFailed: {Component: "github-proxy", Severity: "medium", MinCount: unclassifiedRepeatCount, Window: unclassifiedRepeatWindow, IdleClear: unclassifiedIdleClear, LogFed: true, Keywords: []string{"proxy: connect dial failed", "copilot sniff: upstream dial failed"}}, + ClassProxyPlainForwardFailed: {Component: "github-proxy", Severity: "medium", MinCount: unclassifiedRepeatCount, Window: unclassifiedRepeatWindow, IdleClear: unclassifiedIdleClear, LogFed: true, Keywords: []string{"proxy: plain http forward failed"}}, ClassProxyPlainForwardRefused: {Component: "github-proxy", Severity: "low", MinCount: unclassifiedRepeatCount, Window: unclassifiedRepeatWindow, IdleClear: unclassifiedIdleClear, LogFed: true, Keywords: []string{"proxy: plain http forward refused"}}, ClassInferenceCallerUnknown: {Component: "inference-gateway", Severity: "medium", MinCount: unclassifiedRepeatCount, Window: unclassifiedRepeatWindow, IdleClear: unclassifiedIdleClear, LogFed: true, Keywords: []string{"inference request from unidentified caller refused"}}, ClassInferenceNoRoute: {Component: "inference-gateway", Severity: "medium", MinCount: unclassifiedRepeatCount, Window: unclassifiedRepeatWindow, IdleClear: unclassifiedIdleClear, LogFed: true, Keywords: []string{"inference no route"}}, diff --git a/src/pkg/fleetreport/runtime_test.go b/src/pkg/fleetreport/runtime_test.go index da3b933e4a..bf9c2dafbf 100644 --- a/src/pkg/fleetreport/runtime_test.go +++ b/src/pkg/fleetreport/runtime_test.go @@ -456,6 +456,7 @@ func TestEveryRuntimeClassTriggerAndNonTriggerThresholds(t *testing.T) { {class: ClassHubHeartbeatUnprovisioned, message: "hub heartbeat rejected: hive id is not provisioned on the hub (403); backing off"}, {class: ClassGitHubAppNotInstalled, message: "github app installation id is unset — per-agent scoped token NOT delivered"}, {class: ClassProxyConnectDialFailed, message: "proxy: CONNECT dial failed"}, + {class: ClassProxyPlainForwardFailed, message: "proxy: plain HTTP forward failed"}, {class: ClassProxyPlainForwardRefused, message: "proxy: plain HTTP forward refused — destination is not globally routable"}, {class: ClassInferenceCallerUnknown, message: "inference request from unidentified caller refused"}, {class: ClassInferenceNoRoute, message: "inference no route"}, @@ -1241,6 +1242,7 @@ func TestRecurringRuntimeMessagesAreClassified(t *testing.T) { "github app recheck: installation does not cover every configured repo": ClassGitHubAppRepoNotCovered, "proxy: plain HTTP forward refused — destination is not globally routable": ClassProxyPlainForwardRefused, "proxy: CONNECT dial failed": ClassProxyConnectDialFailed, + "proxy: plain HTTP forward failed": ClassProxyPlainForwardFailed, "copilot sniff: upstream dial failed": ClassProxyConnectDialFailed, "inference no route": ClassInferenceNoRoute, "inference upstream error": ClassInferenceUpstreamError, diff --git a/src/pkg/github/automerge/automerge_stage_labels_gc.go b/src/pkg/github/automerge/automerge_stage_labels_gc.go index d0d9747ea4..6861ca575b 100644 --- a/src/pkg/github/automerge/automerge_stage_labels_gc.go +++ b/src/pkg/github/automerge/automerge_stage_labels_gc.go @@ -180,3 +180,18 @@ func (c *Engine) workflowLabelInUse(ctx context.Context, owner, repo, name strin } return res.GetTotal(), nil } + +// EnsureStageLabels provisions owner/repo's label vocabulary now — the +// built-in family plus the bound workflow model's LabelDefs — through the +// same memoised path the sweep's stage pass uses (ensureStageLabels), so a +// dashboard action that turns model-based derivation on can create the +// bound models' labels immediately instead of waiting for the next PR that +// needs a label write. The shared tracker's memo is honoured and updated: +// a repository already provisioned for the current profile hash is a +// no-op, and the sweep will not re-list it afterwards. +func (c *Engine) EnsureStageLabels(ctx context.Context, owner, repo string) error { + if c == nil || c.gh == nil { + return fmt.Errorf("stage labels: no GitHub transport") + } + return c.ensureStageLabels(ctx, owner, repo, c.stageTrackerOrShared(), c.stagePolicy().AuthorKindLabels) +} diff --git a/src/pkg/github/automerge/automerge_stage_labels_workflow_test.go b/src/pkg/github/automerge/automerge_stage_labels_workflow_test.go index ef0c3505e6..3247be7128 100644 --- a/src/pkg/github/automerge/automerge_stage_labels_workflow_test.go +++ b/src/pkg/github/automerge/automerge_stage_labels_workflow_test.go @@ -369,3 +369,33 @@ func TestEnsureStageLabelsGCNoStoreOrRecordIsInert(t *testing.T) { t.Fatalf("unbound repo touched GC routes: %v %v", gcAPI.searches, gcAPI.deleted) } } + +// EnsureStageLabels is the dashboard's on-demand entry into the same +// memoised path (PUT /api/workflows/enabled turning derivation on). +func TestEnsureStageLabelsExportedUsesEngineTrackerAndPolicy(t *testing.T) { + ctx := context.Background() + api := &stageAPI{labels: map[string]string{}} + srv := newStageAPI(t, api) + defer srv.Close() + profile := workflowTestProfile(t, workflowmodel.LabelEntry{Name: "docs/010-requested", State: "requested"}) + e, tracker := newWorkflowStageEngine(t, srv.URL, func(string, string) *workflowmodel.Profile { return profile }) + + if err := e.EnsureStageLabels(ctx, "acme", "widget"); err != nil { + t.Fatal(err) + } + if api.lists != 1 || !slices.Contains(api.creates, "docs/010-requested") || tracker.ensuredWorkflow["acme/widget"] != profile.Hash { + t.Fatalf("lists=%d creates=%v memo=%q", api.lists, api.creates, tracker.ensuredWorkflow["acme/widget"]) + } + // The sweep's own path then finds the memo and does nothing. + if err := e.ensureStageLabels(ctx, "acme", "widget", tracker, authorkind.DefaultNames); err != nil || api.lists != 1 { + t.Fatalf("sweep after dashboard provisioning: err=%v lists=%d", err, api.lists) + } + + var nilEngine *Engine + if err := nilEngine.EnsureStageLabels(ctx, "acme", "widget"); err == nil { + t.Fatal("nil engine must report an error") + } + if err := New(nil, Options{}).EnsureStageLabels(ctx, "acme", "widget"); err == nil { + t.Fatal("engine without a transport must report an error") + } +}