From d7ada375639f6f444e9885776fc66139085b1db4 Mon Sep 17 00:00:00 2001 From: hive-release-bot Date: Thu, 12 Nov 2026 07:05:44 +0000 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=94=96=20release:=20v5.228.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated release commit. Compiles changelog.d/ fragments and moves the CHANGELOG.md Unreleased section into a dated v5.228.6 entry. See src/docs/releases.md. Signed-off-by: hive-release-bot --- CHANGELOG.md | 6 ++++++ changelog.d/fixed-17696-app-repo-coverage.md | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) delete mode 100644 changelog.d/fixed-17696-app-repo-coverage.md 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/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). From ccb0bf1f262d3de6b21d36122f96408c2f2f68ad Mon Sep 17 00:00:00 2001 From: Andy Anderson Date: Thu, 12 Nov 2026 00:10:12 -0700 Subject: [PATCH 2/6] =?UTF-8?q?=E2=9C=A8=20workflows:=20owner=20toggle=20f?= =?UTF-8?q?or=20model-based=20stage=20derivation=20from=20the=20dashboard?= =?UTF-8?q?=20(#17886)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ workflows: owner toggle for model-based stage derivation from the dashboard workflow.enabled gates rules-based stage derivation and bound-model label provisioning, but could only be set by editing hive.yaml, which hosted hives cannot do. Add PUT /api/workflows/enabled {enabled} (owner-gated, persists hive.yaml, audited workflow_enabled / workflow_disabled) and a "Derive stages from bound models" switch in Settings → Workflows → Library that confirms before turning on. The stage engine and the tracker already read cfg.Workflow.Enabled live and the reloader mutates the same *Config in place, so no reload hook is needed. Turning on also provisions every bound repository's model labels at once through the sweep's memoised ensureStageLabels path (new automerge.Engine.EnsureStageLabels, wired as Dependencies.WorkflowProvisionLabels); failures come back as warnings and the next sweep retries. The Repos binding row says "columns only — turn on derivation in Workflows" when a binding exists while the flag is off. Refs hivecommons/hive#17620 Signed-off-by: Andrew Anderson * chore: move changelog fragment to root changelog.d/ Refs hivecommons/hive#17620 Signed-off-by: Andrew Anderson --------- Signed-off-by: Andrew Anderson --- changelog.d/added-workflow-enabled-toggle.md | 1 + dashboard/openapi.json | 72 +++++++++++++ src/cmd/hive/main.go | 11 +- src/cmd/hive/workflow_labels.go | 42 ++++++++ src/cmd/hive/workflow_labels_test.go | 28 +++++ src/docs/api-reference.md | 2 +- src/docs/api-reference/configuration.md | 1 + src/docs/design/workflow-models.md | 16 +++ src/hive.yaml.example | 11 ++ src/pkg/dashboard/api_workflows.go | 87 +++++++++++++++ src/pkg/dashboard/api_workflows_test.go | 102 ++++++++++++++++++ src/pkg/dashboard/deps.go | 4 + .../dashboard/static-src/css/workflows.css | 4 + .../static-src/js/config-workflows-library.js | 67 +++++++++++- .../static-src/js/config-workflows.js | 4 +- src/pkg/dashboard/static/index.html | 75 +++++++++++-- .../dashboard/workflows_library_ui_test.go | 56 ++++++++++ .../automerge/automerge_stage_labels_gc.go | 15 +++ .../automerge_stage_labels_workflow_test.go | 30 ++++++ 19 files changed, 610 insertions(+), 18 deletions(-) create mode 100644 changelog.d/added-workflow-enabled-toggle.md create mode 100644 src/cmd/hive/workflow_labels.go create mode 100644 src/cmd/hive/workflow_labels_test.go 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/dashboard/openapi.json b/dashboard/openapi.json index cc87eca843..8ecb48243e 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 51f69d747e..9062bf39e5 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/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 251c12bddb..e0bff6c556 100644 --- a/src/pkg/dashboard/deps.go +++ b/src/pkg/dashboard/deps.go @@ -54,6 +54,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/index.html b/src/pkg/dashboard/static/index.html index 07b1ff15dc..e75ead995d 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); } @@ -62957,9 +62961,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'}
`; } @@ -63228,7 +63232,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() }; @@ -63307,10 +63311,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
' + '
'; @@ -63776,7 +63800,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; } @@ -63822,6 +63846,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/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") + } +} From a59e6af0f4f2665dae7751d0e075a97879ebfc7b Mon Sep 17 00:00:00 2001 From: Karibhou <157208370+Karibhou@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:40:42 +0100 Subject: [PATCH 3/6] fix: classify plain HTTP proxy forwarding failures (#17864) Hive-Run: hivecommons/hive#17859 Hive-Plan: classify repeated proxy forward failures Hive-Spec: fleet-report#runtime-classes Signed-off-by: Karibhou Co-authored-by: Karibhou Co-authored-by: hivecommons-hive[bot] <280983584+hivecommons-hive[bot]@users.noreply.github.com> --- changelog.d/fixed-proxy-plain-forward-report.md | 1 + src/docs/fleet-report.md | 1 + src/pkg/delegatedecision/issue_judgment.go | 1 + src/pkg/delegatedecision/issue_judgment_test.go | 2 +- src/pkg/fleetreport/runtime.go | 2 ++ src/pkg/fleetreport/runtime_test.go | 2 ++ 6 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixed-proxy-plain-forward-report.md 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/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/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, From 0ba9674f35b1202dabafd8c20696f47f18b75bea Mon Sep 17 00:00:00 2001 From: Karibhou <157208370+Karibhou@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:43:31 +0100 Subject: [PATCH 4/6] docs: add inbox voice sources quickstart Hive-Run: hivecommons/hive#17882 Hive-Plan: inbox voice sources documentation Hive-Spec: voice-recorders#docs-quickstart Signed-off-by: Karibhou <157208370+Karibhou@users.noreply.github.com> --- src/docs/documentation-map.md | 1 + src/docs/inbox-voice-sources.md | 94 ++++++++++++++++++++ src/pkg/dashboard/static-src/js/knowledge.js | 1 + 3 files changed, 96 insertions(+) create mode 100644 src/docs/inbox-voice-sources.md diff --git a/src/docs/documentation-map.md b/src/docs/documentation-map.md index 64a28aa6f7..b6c2aab26d 100644 --- a/src/docs/documentation-map.md +++ b/src/docs/documentation-map.md @@ -172,6 +172,7 @@ Documentation for the current Hive line (branch `v5`; the code and docs live und - [Portable AgentDefinition format](../AGENT-DEFINITION.md) — standalone YAML schema for importing/exporting agent definitions. - [How Hive injects knowledge and memory into agents](agent-knowledge-and-memory.md) — the three context layers (Hive knowledge primer, CLI conversation, CLI persistent memory), the kick-primer path and its scoping/caps, why repo `AGENTS.md` is injected rather than read natively, which per-agent home directories are fleet-shared, and what survives a kick, `/clear`, pod restart or a new agent. - [Knowledge connectors](knowledge-connectors.md) — **Owner configuration:** connect, validate, and sync external document/wiki sources; includes scope and API reference. +- [Inbox and voice sources](inbox-voice-sources.md) — triage voice recordings and configure hosted Plaud sign-in. - [Knowledge curator](knowledge-curator.md) — configure fact extraction, sources, vaults, document imports, code maps, and retention. - [Knowledge confidence scoring](knowledge-confidence.md) — interpret confidence badges and score explanations. - [Knowledge lifecycle states](knowledge-lifecycle.md) — approve, deprecate, or supersede facts and keep stale knowledge out of prompts. diff --git a/src/docs/inbox-voice-sources.md b/src/docs/inbox-voice-sources.md new file mode 100644 index 0000000000..bb5c95792f --- /dev/null +++ b/src/docs/inbox-voice-sources.md @@ -0,0 +1,94 @@ +# Inbox and voice sources + +The **Inbox** (sidebar → Intelligence → 📥 Inbox) is where recordings and +dictations from voice sources land so you can turn them into work without +leaving the dashboard. It is source-agnostic: Plaud is the first source, +Wispr Flow (`type: wisprflow`) plugs in with the same model, and later sources +(file drop, webhook, Otter.ai, Fireflies, …) add nothing to the UI. + +Tracker RFC: *Voice recorders as first-class input sources (Inbox)* — +see the issue linked from the changelog entry. + +## Model + +- **Source** — a knowledge connector whose facts are recordings (`plaud`, + `wisprflow`). Connecting a source happens in **Knowledge → 🔌 Integrations**; + that card shows only the connection state and an **Open Inbox** button. +- **Item** — one recording, keyed `(source, item_id)` (for Plaud, the + `attr_plaud_file_id`). Every item **is already a Knowledge fact**; the Inbox + only tracks what you did with it. "📚 Fact" opens the fact in Knowledge. +- **Destination** — where an item goes: + - **→ RFC** (`spek_rfc`): the hive runs `spektacular voice --from stdin + --title --publish --repo <owner/repo>` on the transcript and + files one RFC issue plus native child issues with the hive handshake + marker. The hive's normal scanner then claims the children. If no + `spektacular` binary is on the host, the hive hands the same job to a + spoke agent as a kick prompt (state `delegated`): `inbox.spek.agent` if + set, otherwise the first enabled, unpaused agent by name. + - **→ dibs** (`dibs`): posts title, summary, transcript and the + `plaud://recordings/<id>` provenance to `POST {inbox.dibs.url}/api/ideas` + as a private draft idea (Bearer token from `inbox.dibs.token_env`). + - **Knowledge only** (`dismiss`): leaves the fact where it is and drops the + item from the unprocessed count. +- **State** per item per destination: `queued → running → filed | failed` + (or `delegated` for the agent fallback), with the RFC/idea URL and child + issue URLs. Stored in `connector-state/_inbox.json`. +- **Auto-routing rules** (⚙ Auto-routing in the Inbox, or `inbox.rules`): + `source?`, `title_prefix` or `tag`, `destination`, `repo?`. Rules run once + for each item that arrives after a connector sync; the first match wins; + items already in the Inbox are never re-routed. + +## Configuration + +```yaml +inbox: + sources: [plaud, wisprflow] # default: every configured voice connector + spek: + repo: hivecommons/hive # default RFC target + binary: spektacular # or an absolute path; empty = look on PATH + agent: "" # fallback agent; default: first enabled agent + dibs: + url: https://dibs.example.org + token_env: DIBS_TOKEN + rules: + - title_prefix: "RFC:" + destination: spek_rfc + - title_prefix: "Idea:" + destination: dibs +``` + +`inbox.rules`, `inbox.spek.repo` and `inbox.dibs.url` are editable from the +dashboard (`POST /api/inbox/settings`); the token is environment-only. + +## Hosted hives: signing in to Plaud + +Plaud's OAuth client only redirects to `http://localhost:8199/auth/callback`, +so a hive running in a cluster never receives the browser callback. The +Plaud card therefore offers **Finish sign-in by pasting**: + +1. Click **Connect Plaud**; open the authorization link and approve. +2. The browser lands on `localhost:8199/auth/callback?code=…` (a connection + error page is expected). Copy that URL and paste it into the card within + 10 minutes; the hive forwards it to the login session running in the pod. +3. Alternative: on a laptop run `npx -y @plaud-ai/mcp@latest install`, sign + in, and paste the contents of `~/.plaud/tokens-mcp.json`. + +Both go through `POST /api/knowledge/connectors/{name}/auth/complete`. +A browser-only redirect to the hive's public URL is not possible until Plaud +allows additional redirect URIs for its client. + +## Requirements on the hive image + +- Node.js ≥ 20 for the Plaud MCP server (the image ships Node 26). +- `spektacular` for → RFC: not in the image yet (tracked as a child of the + Inbox RFC); until then the agent fallback runs by default, or install it on + the host (`go install github.com/hivecommons/spektacular/cmd/spektacular@latest`). +- A GitHub token with issue-write on the target repo (the hive's app token + is injected as `GITHUB_TOKEN`). + +## API + +See [api-reference/knowledge.md](api-reference/knowledge.md) for +`GET /api/inbox`, `POST /api/inbox/route`, `POST /api/inbox/dismiss`, +`GET|POST /api/inbox/settings` and +`POST /api/knowledge/connectors/{name}/auth/complete`. diff --git a/src/pkg/dashboard/static-src/js/knowledge.js b/src/pkg/dashboard/static-src/js/knowledge.js index 81daf98c72..c25db8bbaf 100644 --- a/src/pkg/dashboard/static-src/js/knowledge.js +++ b/src/pkg/dashboard/static-src/js/knowledge.js @@ -1978,6 +1978,7 @@ html += '<details class="kb-sources-add"><summary class="summary-label">🎙️ Plaud voice notes</summary>'; html += '<div class="stack kb-sources-form">'; html += '<div class="muted-xs">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.</div>'; + html += '<div class="muted-xs"><a href="https://github.com/hivecommons/hive/blob/v5/src/docs/inbox-voice-sources.md" target="_blank" rel="noopener noreferrer">Inbox voice sources quickstart ↗</a></div>'; html += '<div><button type="button" class="hv-btn btn-secondary btn-sm" data-action="kbOpenPlaudIntegration">Configure in Integrations</button></div>'; html += '</div></details>'; From b2730bf5e9de95285acebe15532a1cb43754a3cd Mon Sep 17 00:00:00 2001 From: Karibhou <157208370+Karibhou@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:45:59 +0100 Subject: [PATCH 5/6] docs: add voice quickstart changelog fragment Signed-off-by: Karibhou <157208370+Karibhou@users.noreply.github.com> --- changelog.d/added-inbox-voice-quickstart.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/added-inbox-voice-quickstart.md diff --git a/changelog.d/added-inbox-voice-quickstart.md b/changelog.d/added-inbox-voice-quickstart.md new file mode 100644 index 0000000000..3b7b2a5759 --- /dev/null +++ b/changelog.d/added-inbox-voice-quickstart.md @@ -0,0 +1 @@ +- Added an inbox voice sources quickstart and a link from the Plaud knowledge source setup, covering recorder exports, inbox processing, and troubleshooting ([#17882](https://github.com/hivecommons/hive/issues/17882)). From d6f044fdfd88d1926afb2a4059b8614887e7904a Mon Sep 17 00:00:00 2001 From: Karibhou <157208370+Karibhou@users.noreply.github.com> Date: Thu, 12 Nov 2026 08:54:20 +0100 Subject: [PATCH 6/6] Regenerate dashboard for inbox voice link Signed-off-by: Karibhou <157208370+Karibhou@users.noreply.github.com> --- src/pkg/dashboard/static/index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pkg/dashboard/static/index.html b/src/pkg/dashboard/static/index.html index e75ead995d..fc05485b10 100644 --- a/src/pkg/dashboard/static/index.html +++ b/src/pkg/dashboard/static/index.html @@ -45498,6 +45498,7 @@ <h2 style="font-size:1rem;margin:var(--sp-0) var(--sp-0) var(--sp-0) var(--sp-0) html += '<details class="kb-sources-add"><summary class="summary-label">🎙️ Plaud voice notes</summary>'; html += '<div class="stack kb-sources-form">'; html += '<div class="muted-xs">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.</div>'; + html += '<div class="muted-xs"><a href="https://github.com/hivecommons/hive/blob/v5/src/docs/inbox-voice-sources.md" target="_blank" rel="noopener noreferrer">Inbox voice sources quickstart ↗</a></div>'; html += '<div><button type="button" class="hv-btn btn-secondary btn-sm" data-action="kbOpenPlaudIntegration">Configure in Integrations</button></div>'; html += '</div></details>';