diff --git a/CHANGELOG.md b/CHANGELOG.md index a6cea8e4e3..2897a12964 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,111 @@ Hive did not historically maintain a complete changelog. This file starts a prag - The signed-commit reconciler's intentional refusal to rewrite a foreign or merge commit no longer files repeated unclassified-error fleet reports; its warning and PR remediation remain visible ([#17924](https://github.com/hivecommons/hive/issues/17924)). - Dashboard logs one summary warning per five minutes for dropped SSE events instead of one warning per event, so a stalled browser tab no longer triggers repeated-error fleet reports (#17995). +## 2026-11-13 (v5.244.1) + +### Fixed + +- Contributor-lane auto-promotion now warns once per refused author and once per refused repository when GitHub's search cannot see a repository, instead of once per author Γ— repository pair, so one invisible repository or one unsearchable author no longer floods WARN and self-reports as a repeated unclassified error (#18059). +- Knowledge Integrations: when the connector syncer is not running (`runtime: false`, hive booted with no `knowledge.connectors`), connector cards now show a `not loaded` badge with a restart hint and docs link instead of `pending`, and Connect / Check / Sync are disabled with the same hint rather than failing with 404 toasts; `pending` stays reserved for a loaded connector awaiting sign-in ([#18093](https://github.com/hivecommons/hive/issues/18093)). +- The ioscan classifier now retries a single transient model-endpoint failure (HTTP 429/5xx or a dropped connection) before applying its fail mode, so one gateway blip no longer passes untrusted text on unclassified or withholds it ([#18105](https://github.com/hivecommons/hive/issues/18105)). + +## 2026-11-13 (v5.244.0) + +### Added + +- Hub header nav and My Hives now link to the fleet benchmarks page (hivecommons.dev/benchmarks), and the spoke dashboard FAQ points at it for model comparisons (#18034). + +### Fixed + +- Knowledge Integrations: the per-connector **Check** button now reports its result β€” a success/info toast with the sign-in state (and the card refreshed), or an error toast carrying the server's `error` string verbatim (for example `no knowledge connectors are configured`) instead of a "checking" toast followed by silence ([#18092](https://github.com/hivecommons/hive/issues/18092)). + +## 2026-11-13 (v5.243.0) + +### Added + +- `/api/status` `referenceRepos[]` entries now carry an additive `reference_tier` field (`read-only`, `issues`, `prs` or `issues+prs`) so API consumers can tell a `project.reference_repos` entry from a managed repo; the entries stay out of `repos[]` and carry no ACMM, work-item or pause fields, and the dashboard's `πŸ“š reference Β· ` chip reads it ([#18021](https://github.com/hivecommons/hive/issues/18021)). + +### Changed + +- Dashboard: "Create webhook for me" is idempotent β€” it returns the existing hook for this hive's payload URL instead of creating a duplicate, and answers 412 naming the missing `organization_hooks`/`repository_hooks` write permission (#18028) +- **`review.perspectives_source` is now resolved at startup and on every config reload, and the review relay perspective set is reinstalled each time**, so a verdict naming a perspective the link removed is refused and one it added is accepted without a restart. A dashboard edit of the review config no longer drops the linked set, and a malformed or unreachable document keeps the last-known-good set with a warning. ([#17988](https://github.com/hivecommons/hive/issues/17988), part of [#17975](https://github.com/hivecommons/hive/issues/17975)) + +### Fixed + +- dashboard: a Projects-card Release that lands while the rows are re-sorting after a Hold is no longer sent, so holding items down a list no longer releases a neighbouring item's hold (#18054) +- Documents imported from the dashboard (PDF, HTML, DOCX, text) are now saved to `knowledge.documents` in `hive.yaml` like git sources, and boot re-imports any configured document whose on-disk record is missing, so an upgrade no longer drops a PDF knowledge source while keeping GitHub ones. + +### Security + +- The hub now withholds hub-held credentials and management actions (GitHub App config and keys, pending gateway, project config, cluster ID, access list, upgrade, restart and breaker instructions) from the heartbeat response of any hive whose ID is in the reserved `byo-` self-service namespace, through one guard driven by a classification of every response field ([#18060](https://github.com/hivecommons/hive/issues/18060)). + +## 2026-11-13 (v5.242.0) + +### Added + +- Activity monitor: a workflow-model chevron that is not the row's active stage now wears a glyph for what the stage means (inbox, bolt, hand, pencil, pull-request, flask, eye, people, thumbs-up, merge, check, …) picked by the column's name, dimmed to 60%, instead of its clipped label; the active chevron leads its word with the same glyph. Workflow columns take an optional `icon` (an allowlisted icon id or a single emoji, validated server- and client-side; never markup), and the workflow editor's column inspector previews the default and lets the operator replace it (#18033). + +### Changed + +- images: bump gh 2.103.3 -> 2.104.0 (#18063) +- kicks: register REFERENCE REPOS as layout section 10a and golden its wording (#18074) + +### Fixed + +- Benchmarks export now adds `model.canonical` and `model.effort` next to the reported id and derives `model.provider` from the model family, so `claude-sonnet-5-5` (Copilot CLI), `claude-sonnet-5.5` (Claude Code) and `openai-codex/gpt-6.1-sol` no longer appear as separate models on hivecommons.dev/benchmarks. +- Repeated trust-tier agent role claim rejections over contribute-ws are now logged once per user and role at INFO instead of flooding WARN, so they no longer surface as unclassified self-reported errors (#18071). + +## 2026-11-13 (v5.241.1) + +### Changed + +- **Settings β†’ Forge App β†’ GitHub webhooks names the App permission to grant** (#18029): when the GitHub App installation cannot manage hooks, the card now says which permission to grant (Organization β†’ Webhooks `organization_hooks`, or Repository β†’ Webhooks `repository_hooks`, Read and write) so **Create webhook for me** appears, instead of silently omitting the option. Node-executed tests pin the card's copy for relay, none and env-secret modes, and a parity test checks every endpoint the card calls is routed and owner-only. +- docs: project.reference_repos in agent-configuration and hive.yaml.example (#18073) + +## 2026-11-13 (v5.241.0) + +### Added + +- **Review Gate shows linked review perspectives read-only.** When `review.perspectives_source` links the perspective set to a repo file, Settings β†’ Features β†’ Review Gate β†’ Perspectives now shows the linked set (not the inline fallback), a "Linked to owner/repo/path@ref" line pointing at the file on GitHub, and the resolver's status and any fetch or malformed-document warning. The checkboxes and focus text are read-only and "Add perspective" is hidden, and `PUT /api/config/review` answers 409 for `perspectives` / `perspective_prompts` edits that the next reload would silently replace; `combined_perspectives`, `plan_match` and `max_perspectives_per_pr` stay editable. Unlinked hives see no change ([#17989](https://github.com/hivecommons/hive/issues/17989)). + +## 2026-11-13 (v5.240.0) + +### Added + +- **`project.reference_repos`: study-only repos outside the project** ([#18015](https://github.com/hivecommons/hive/issues/18015)). A hive whose deliverable is *about* another project (a docs assessment of `hivecommons/*` landing in `cncf/techdocs`) can list `owner/name` repos it may clone unauthenticated and GET, with the proxy stripping the hive's token and refusing every write by default; the kick tells agents such items are in scope and that findings go in the deliverable in the authorized repo, so the scanner no longer flags them out-of-scope/critical. A rare per-entry `write: [issues, prs]` opt-in allows only opening issues, opening PRs/pushing topic branches and commenting on the hive's own items, using the App's installation on the target org or `write_token_file`; boot refuses write-mode without either (exit 22). Dashboard shows a minimal `reference Β· ` card. +- Owner-facing GitHub webhook setup: βš™ Settings β†’ Forge App gains a "GitHub webhooks" section showing the payload URL, events and live receiver status, with **Generate secret** (stored in `/data/github-webhook-secret.key`, shown once) and **Create webhook for me** when the App installation has hooks write permission; a webhooks tile on the Governor card deep-links to it; new owner guide in `docs/webhooks.md` (#18017). + +## 2026-11-12 (v5.239.0) + +### Added + +- **Resolver for `review.perspectives_source`** that fetches a `kind: ReviewPerspectives` document behind the same seed-only allowlist as `definition_source`, and keeps the last-known-good set with a visible warning when a later fetch fails or a document no longer validates, instead of silently falling back to the built-in perspectives. The review relay, dispatch, head aggregation and `${REVIEW_PERSPECTIVES}` now read the resolved set via `ReviewConfig.EffectivePerspectives()`; with no `perspectives_source` configured, behaviour is unchanged. ([#17987](https://github.com/hivecommons/hive/issues/17987), part of [#17975](https://github.com/hivecommons/hive/issues/17975)) + +### Fixed + +- Activity monitor: the column header now follows the workflow model the rows wear β€” a repository bound to a custom model gets that model's column names over its chevrons (ungrouped pages are partitioned by model with a header per run), instead of hive-default's "PR in progress / Merge queue" names (#18002). +- Kicks are no longer refused wholesale as "prompt injection" because of the ioscan exfiltration canary: the preamble is now a labelled `=== HIVE HARNESS HEADER` block that states it is the operator's own configuration and not a task (Claude Sonnet 5 via the Copilot CLI refused ~65% of one hosted supervisor's kicks on the old `SECURITY CANARY:` marker), and a kick that is still refused with a canary in front is re-delivered once without it instead of burning the cadence slot (#18012). +- Stop contributor automerge passes cleanly when a client hand-over or shutdown cancels an in-flight sweep, without filing a spurious failure report. +- Spoke self-upgrade now installs the hosted template's startupProbe (10-min budget) on a hive Deployment that has none, not only raises an undersized one β€” pre-#9692 spokes on pull-only clusters were liveness-killed 90s into a forge-blocked boot and crash-looped (grey dot) for the length of the GHE outage; the fleet status-dot hovers now carry a colour legend and the grey dot says "no heartbeat in 5 min". + +## 2026-11-12 (v5.238.1) + +### Fixed + +- Spoke: a placeholder claimed onto a different forge than its pool default (GHE slot β†’ github.com repo) now moves GitHub App installation discovery to the delivered App and API URL even while installation_id is still 0, instead of polling the boot-time App/forge forever and reporting "App 5686 has no installation" until a pod restart. +- Hub fleet view: a placeholder assigned with an explicit forge now shows that forge (e.g. github.com) in the Location pill while the claim is pending, instead of the cluster default the spoke still heartbeats from its pre-claim config (github.ibm.com on vllm-d). +- Dashboard logs one summary warning per five minutes for dropped SSE events instead of one warning per event, so a stalled browser tab no longer triggers repeated-error fleet reports (#17995). + +## 2026-11-12 (v5.238.0) + +### Added + +- **`review.perspectives_source` config key and `kind: ReviewPerspectives` document format** for live-linking a hive's review perspective names and focus text to a file in a GitHub repo, so maintainers can change the set through ordinary PRs instead of hive config edits. This lands the config surface, a runtime-only resolved slot that is never written back to the overlay, and the validating document parser (`pkg/perspsrc`); fetching and wiring follow in later changes, so behaviour is unchanged until then and unchanged whenever the key is absent. Once resolved, the linked set wins over inline `review.perspectives` / `review.perspective_prompts`, which stay the fallback. ([#17986](https://github.com/hivecommons/hive/issues/17986), part of [#17975](https://github.com/hivecommons/hive/issues/17975)) + +### Fixed + +- The signed-commit reconciler's intentional refusal to rewrite a foreign or merge commit no longer files repeated unclassified-error fleet reports; its warning and PR remediation remain visible ([#17924](https://github.com/hivecommons/hive/issues/17924)). + ## 2026-11-12 (v6.27.3) ### Fixed diff --git a/changelog.d/added-17888-ship-spektacular.md b/changelog.d/added-17888-ship-spektacular.md new file mode 100644 index 0000000000..db2fb2c358 --- /dev/null +++ b/changelog.d/added-17888-ship-spektacular.md @@ -0,0 +1 @@ +- The hive image now ships the `spektacular` CLI (pinned 0.31.0, checksum-verified, amd64 and arm64), so the Inbox β†’ RFC destination runs `spektacular voice --publish` in-pod without an agent fallback (#17888). diff --git a/changelog.d/fixed-18084-close-on-merge-unreadable-pr.md b/changelog.d/fixed-18084-close-on-merge-unreadable-pr.md new file mode 100644 index 0000000000..76d3978235 --- /dev/null +++ b/changelog.d/fixed-18084-close-on-merge-unreadable-pr.md @@ -0,0 +1 @@ +- Close-on-merge no longer re-warns every tick for merged PRs it cannot read (404/403); it logs once per recheck window. diff --git a/changelog.d/fixed-18107-claude-rebridge-selfreport.md b/changelog.d/fixed-18107-claude-rebridge-selfreport.md new file mode 100644 index 0000000000..ecdbbba2d5 --- /dev/null +++ b/changelog.d/fixed-18107-claude-rebridge-selfreport.md @@ -0,0 +1 @@ +- Completed Claude credential re-bridges (a token refresh replacing an agent's shared-login symlink, repaired automatically) no longer file `unclassified_error_repeated` fleet self-reports; failed repairs still do. diff --git a/changelog.d/fixed-18110-navbar-agents-summary-button.md b/changelog.d/fixed-18110-navbar-agents-summary-button.md new file mode 100644 index 0000000000..7c48d4aa50 --- /dev/null +++ b/changelog.d/fixed-18110-navbar-agents-summary-button.md @@ -0,0 +1 @@ +- Dashboard top bar: when the centre column is narrow, the agent run-order strip's summary pill is now a real button that opens the Agents section (click, Enter or Space), and it shows how many scheduled agents are in the run order ("9 agents", "1 agent") instead of a count capped at 3; the misleading "β–Ύ" caret is gone ([#18110](https://github.com/hivecommons/hive/issues/18110)). diff --git a/changelog.d/fixed-quota-exhausted-agents-not-kicked.md b/changelog.d/fixed-quota-exhausted-agents-not-kicked.md new file mode 100644 index 0000000000..cce7aae2e1 --- /dev/null +++ b/changelog.d/fixed-quota-exhausted-agents-not-kicked.md @@ -0,0 +1 @@ +- **Agents out of provider quota are no longer kicked every cycle** ([#18112](https://github.com/hivecommons/hive/issues/18112)). The provider spending-limit gate only saw agents behind the hive's inference gateway, so an agent whose own provider refused it β€” GitHub Copilot's "You have exceeded your monthly quota" β€” kept being kicked and sent review work all night, each turn ending at once, until its review dispatches expired and pull requests were parked for a human. The governor now holds kicks per agent while that agent's `QuotaExhausted` flag is set, lets one probe kick through per `governor.provider_budget.probe_interval_s` (default 30 minutes) to notice when the quota resets, and resumes normal kicking as soon as the flag clears. Other agents are never held, and the "provider quota exhausted" alert now says the kicks are held and how often they are retried. diff --git a/dashboard/openapi.json b/dashboard/openapi.json index 34e8d69719..c77c99fd35 100644 --- a/dashboard/openapi.json +++ b/dashboard/openapi.json @@ -26341,6 +26341,284 @@ } } }, + "/api/config/github/webhooks": { + "get": { + "tags": [ + "System" + ], + "summary": "Describe GitHub webhook setup for this hive (owner)", + "description": "Owner-only. Everything the Settings β†’ Forge App β†’ GitHub webhooks section shows: the payload URL GitHub must call, the events to subscribe, whether a secret is configured and where it comes from (never the secret itself), whether this hive receives hub-signed relays (hosted), the live receiver health and whether the App installation's hooks permission allows creating the webhook from here. See docs/webhooks.md#setting-up-webhooks-owner-guide.", + "responses": { + "200": { + "description": "Webhook setup", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "payload_url": { + "type": "string" + }, + "payload_url_source": { + "type": "string", + "enum": [ + "config", + "hub", + "request", + "none" + ] + }, + "path": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "optional_events": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "verification": { + "type": "string" + }, + "hosted": { + "type": "boolean" + }, + "secret_configured": { + "type": "boolean" + }, + "secret_source": { + "type": "string", + "enum": [ + "env", + "file", + "" + ] + }, + "secret_path": { + "type": "string" + }, + "can_generate_secret": { + "type": "boolean" + }, + "health": { + "type": "object", + "properties": { + "healthy": { + "type": "boolean" + }, + "last_event_at": { + "type": "string" + }, + "events_1h": { + "type": "integer" + }, + "invalidations_1h": { + "type": "integer" + }, + "targeted_refreshes_1h": { + "type": "integer" + }, + "tracker_invalidations_1h": { + "type": "integer" + }, + "last_ci_event_at": { + "type": "string" + }, + "ci_events_1h": { + "type": "integer" + }, + "ci_events_applied_1h": { + "type": "integer" + }, + "ci_refetches_1h": { + "type": "integer" + }, + "verification": { + "type": "string" + } + } + }, + "org": { + "type": "string" + }, + "repos": { + "type": "array", + "items": { + "type": "string" + } + }, + "app_org_hooks": { + "type": "boolean" + }, + "app_repo_hooks": { + "type": "boolean" + }, + "app_probe_error": { + "type": "string" + }, + "create_available": { + "type": "boolean" + }, + "docs_url": { + "type": "string" + } + } + } + } + } + }, + "403": { + "description": "Not an owner" + }, + "503": { + "description": "Config not loaded" + } + } + } + }, + "/api/config/github/webhooks/secret": { + "post": { + "tags": [ + "System" + ], + "summary": "Generate this hive's GitHub webhook secret (owner)", + "description": "Owner-only. Writes a new random secret to the hive's secret file (mode 0600) and returns it exactly once; later GETs only report that a file secret is configured. Refused with 409 while GITHUB_WEBHOOK_SECRET is set in the environment, which always wins. Audited.", + "responses": { + "200": { + "description": "Secret, shown once", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "secret": { + "type": "string" + }, + "secret_path": { + "type": "string" + }, + "verification": { + "type": "string" + }, + "note": { + "type": "string" + } + } + } + } + } + }, + "403": { + "description": "Not an owner" + }, + "409": { + "description": "Secret is supplied by the environment" + }, + "500": { + "description": "Could not write the secret file" + } + } + } + }, + "/api/config/github/webhooks/create": { + "post": { + "tags": [ + "System" + ], + "summary": "Create the org or repo webhook through the GitHub App (owner)", + "description": "Owner-only. Uses the App installation's organization_hooks or repository_hooks write permission to create a webhook on GitHub pointing at this hive's payload URL with the configured secret and event list. Requires a configured secret, a public dashboard URL and the matching permission; the repository must be one of project.repos. Idempotent: when a hook with the same payload URL already exists on the target it is returned with created=false instead of creating a duplicate. Creation is audited.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "enum": [ + "organization", + "repository" + ] + }, + "repo": { + "type": "string", + "description": "Repository name (bare or org/name); required for scope=repository." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Webhook created, or the existing hook for this payload URL", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "hook_id": { + "type": "integer", + "description": "Same as id" + }, + "created": { + "type": "boolean", + "description": "false when an existing hook for this payload URL was returned" + }, + "events": { + "type": "array", + "items": { + "type": "string" + } + }, + "payload_url": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "target": { + "type": "string" + }, + "url": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Invalid scope or repository" + }, + "403": { + "description": "Not an owner" + }, + "409": { + "description": "No secret / no public URL / no project.org / no App auth" + }, + "412": { + "description": "The App installation lacks organization_hooks or repository_hooks write for the scope" + }, + "502": { + "description": "GitHub API error" + } + } + } + }, "/api/config/github": { "put": { "tags": [ diff --git a/src/Dockerfile b/src/Dockerfile index 20090bb380..db6fa645d7 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -246,9 +246,9 @@ RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" && \ chmod +x /usr/local/bin/ttyd # --- Layer 2b: GitHub CLI (off-PATH β€” only snapshot publisher uses it) --- -ARG GH_VERSION=2.103.3 -ARG GH_SHA256_AMD64=4c806978ffce1cec70853302a56d70ac6da4f19bae7adca4e1a8ab00633dd91d -ARG GH_SHA256_ARM64=ee32df2323c8847f5fac6cb6b4b69ca75828e9329a8f22f677021f7b3306ce63 +ARG GH_VERSION=2.104.0 +ARG GH_SHA256_AMD64=98cb101ad87dbbd3f6b2f2bfcbd43197ccd07643140121d8a9cfaa91eadbc24e +ARG GH_SHA256_ARM64=cb34cefc6f4e77b90731374a2b737516bb8f3033c6ca46aa949e5344f68069e3 RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" && \ case "$ARCH" in \ amd64) GH_ARCH=amd64; GH_SHA256="$GH_SHA256_AMD64" ;; \ @@ -325,7 +325,7 @@ RUN timeout 120s npm install -g --ignore-scripts --no-fund --no-audit \ # dashboard's codex model discovery (`codex app-server` model/list β€” pure # local stdio, no network, unaffected by the proxy CA) falls back to the # static list, and non-codex backends are unaffected. -ARG CODEX_VERSION=0.167.0 +ARG CODEX_VERSION=0.168.0 ARG CLINE_VERSION=3.0.74 # npm can briefly advertise AWS SDK versions before all registry replicas serve # them. Wait at most three minutes between attempts and revalidate metadata. @@ -343,7 +343,7 @@ RUN npm install -g --ignore-scripts --no-fund --no-audit @openai/codex@${CODEX_V echo "WARN: OpenAI Codex CLI install failed β€” skipping" # --- Layer 7: Claude Code (most frequent updates) --- -ARG CLAUDE_CODE_VERSION=2.1.322 +ARG CLAUDE_CODE_VERSION=2.1.323 # --ignore-scripts (kept, deliberately: no arbitrary postinstall runs during the # build) skips claude-code's install.cjs, which is what LINKS the platform # native binary into the package's bin/. The binary itself ships in the @@ -382,6 +382,33 @@ RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" && \ echo "WARN: Goose CLI download failed β€” skipping (backend: goose will be unavailable)"; \ fi +# --- Layer 8.1: spektacular CLI (Inbox -> RFC destination) --- +# Pinned hivecommons/spektacular release (includes `spektacular voice`, +# hivecommons/spektacular#123), SHA-256-verified before extraction, installed +# to /usr/local/bin/spektacular so inbox.spek.binary / runs.spektacular.binary +# resolve on PATH with no config. Download failures are tolerated (the Inbox +# falls back to inbox.spek.agent); checksum mismatches hard-fail the build. +# Bump note: see src/docs/cli-pins.md (spektacular row); update the version and +# both digests together from the release's checksums.txt. +ARG SPEKTACULAR_VERSION=0.31.0 +ARG SPEKTACULAR_SHA256_AMD64=21bd077b6723de41185fa657c0e54ea5726c1c2928794548ada4a8776a7b4746 +ARG SPEKTACULAR_SHA256_ARM64=81d3b92c907bd6d66e4e1db79df24be8b64803dd26e0de1d7147f1ff5fc9b8d0 +RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" && \ + case "$ARCH" in \ + amd64) SPEK_ARCH=x86_64; SPEK_SHA256="$SPEKTACULAR_SHA256_AMD64" ;; \ + arm64) SPEK_ARCH=arm64; SPEK_SHA256="$SPEKTACULAR_SHA256_ARM64" ;; \ + *) echo "unsupported arch for spektacular: $ARCH" >&2; exit 1 ;; \ + esac && \ + mkdir -p /tmp/spek-dl && \ + if curl -fsSL --retry 5 --retry-delay 5 --retry-connrefused "https://github.com/hivecommons/spektacular/releases/download/${SPEKTACULAR_VERSION}/spektacular_${SPEKTACULAR_VERSION}_linux_${SPEK_ARCH}.tar.gz" -o /tmp/spek-dl/spektacular.tar.gz; then \ + echo "${SPEK_SHA256} /tmp/spek-dl/spektacular.tar.gz" | sha256sum -c - && \ + tar xz -C /usr/local/bin -f /tmp/spek-dl/spektacular.tar.gz spektacular && \ + chmod +x /usr/local/bin/spektacular && \ + rm -rf /tmp/spek-dl; \ + else \ + echo "WARN: spektacular download failed - skipping (Inbox falls back to inbox.spek.agent)"; \ + fi + # --- Layer 8.25: Google Antigravity CLI (backend: agy) --- # The vendor distributes a self-contained Linux executable, not an npm package. # Pin the version, build ID, and SHA-512 from its public updater manifest; do @@ -412,9 +439,9 @@ RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" && \ # --- Layer 8.27: OMP CLI (Oh My Pi β€” backend: omp) --- # Mirrored from Dockerfile.contributor so hub agents can launch backend: omp. # Downloads are optional like Goose/agy, but checksum mismatches are fatal. -ARG OMP_VERSION=18.19.0 -ARG OMP_SHA256_AMD64=b44fda2ae5c9cce2bbe124d1672cba532a136d9e1eef68599b16efcd3bc5c626 -ARG OMP_SHA256_ARM64=61826bf89c2c0512417fee05fa2f486e60ee11b23ea95a17257a6c49edb6b8d5 +ARG OMP_VERSION=18.20.0 +ARG OMP_SHA256_AMD64=c8694be8c6e6a1f45e3636dd8a8e9eaa78878ad629a89000b403ec5e456d3f89 +ARG OMP_SHA256_ARM64=8b5e197db7c58b7ebabf83c6f03597f89bae3a766a84c6481b3a942a125f5e22 RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" \ && case "$ARCH" in \ amd64|x86_64) OMP_ASSET=omp-linux-x64; OMP_SHA256="$OMP_SHA256_AMD64" ;; \ diff --git a/src/Dockerfile.contributor b/src/Dockerfile.contributor index d45cd6f111..2a6bc8ab7e 100644 --- a/src/Dockerfile.contributor +++ b/src/Dockerfile.contributor @@ -263,7 +263,7 @@ RUN mkdir -p /etc/apt/keyrings \ # npm cache is cleaned in the same layer it's populated (issue #2613, # Observation 2 "also minor") so the cache doesn't linger in this layer's # diff, mirroring the cleanup already done for the bobshell install below. -ARG CLAUDE_CODE_VERSION=2.1.322 +ARG CLAUDE_CODE_VERSION=2.1.323 ARG COPILOT_VERSION=1.0.88 # --ignore-scripts (kept, deliberately: no arbitrary postinstall runs during # the build) skips claude-code's install.cjs, which is what LINKS the platform @@ -286,7 +286,7 @@ ARG KILO_CLI_VERSION=7.5.6 # Same ARG name and value as src/Dockerfile so bin/test_backend_smoke.sh can # assert the two pins agree β€” this line was previously an inline hardcode that # could (and did) drift from the main image's CODEX_VERSION unnoticed. -ARG CODEX_VERSION=0.167.0 +ARG CODEX_VERSION=0.168.0 RUN npm install -g --ignore-scripts --no-fund --no-audit \ @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} \ @github/copilot@${COPILOT_VERSION} \ @@ -414,6 +414,33 @@ RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" \ echo "Goose install skipped"; \ fi +# spektacular CLI (Inbox -> RFC destination) --- +# Pinned hivecommons/spektacular release (includes `spektacular voice`, +# hivecommons/spektacular#123), SHA-256-verified before extraction, installed +# to /usr/local/bin/spektacular so inbox.spek.binary / runs.spektacular.binary +# resolve on PATH with no config. Download failures are tolerated (the Inbox +# falls back to inbox.spek.agent); checksum mismatches hard-fail the build. +# Bump note: see src/docs/cli-pins.md (spektacular row); update the version and +# both digests together from the release's checksums.txt. +ARG SPEKTACULAR_VERSION=0.31.0 +ARG SPEKTACULAR_SHA256_AMD64=21bd077b6723de41185fa657c0e54ea5726c1c2928794548ada4a8776a7b4746 +ARG SPEKTACULAR_SHA256_ARM64=81d3b92c907bd6d66e4e1db79df24be8b64803dd26e0de1d7147f1ff5fc9b8d0 +RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" && \ + case "$ARCH" in \ + amd64) SPEK_ARCH=x86_64; SPEK_SHA256="$SPEKTACULAR_SHA256_AMD64" ;; \ + arm64) SPEK_ARCH=arm64; SPEK_SHA256="$SPEKTACULAR_SHA256_ARM64" ;; \ + *) echo "unsupported arch for spektacular: $ARCH" >&2; exit 1 ;; \ + esac && \ + mkdir -p /tmp/spek-dl && \ + if curl -fsSL --retry 5 --retry-delay 5 --retry-connrefused "https://github.com/hivecommons/spektacular/releases/download/${SPEKTACULAR_VERSION}/spektacular_${SPEKTACULAR_VERSION}_linux_${SPEK_ARCH}.tar.gz" -o /tmp/spek-dl/spektacular.tar.gz; then \ + echo "${SPEK_SHA256} /tmp/spek-dl/spektacular.tar.gz" | sha256sum -c - && \ + tar xz -C /usr/local/bin -f /tmp/spek-dl/spektacular.tar.gz spektacular && \ + chmod +x /usr/local/bin/spektacular && \ + rm -rf /tmp/spek-dl; \ + else \ + echo "WARN: spektacular download failed - skipping (Inbox falls back to inbox.spek.agent)"; \ + fi + # Install agy (Google Antigravity CLI). Not an npm package β€” Google ships it # as a standalone binary from a versioned, checksummed GCS URL (the same URL # Homebrew's antigravity-cli cask resolves to, confirmed against @@ -489,9 +516,9 @@ RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" \ # No credential is baked in: omp reads the selected model's provider # credential from its documented environment variables (ANTHROPIC_API_KEY, # OPENAI_API_KEY, …), which the Justfile's container arm already forwards. -ARG OMP_VERSION=18.19.0 -ARG OMP_SHA256_AMD64=b44fda2ae5c9cce2bbe124d1672cba532a136d9e1eef68599b16efcd3bc5c626 -ARG OMP_SHA256_ARM64=61826bf89c2c0512417fee05fa2f486e60ee11b23ea95a17257a6c49edb6b8d5 +ARG OMP_VERSION=18.20.0 +ARG OMP_SHA256_AMD64=c8694be8c6e6a1f45e3636dd8a8e9eaa78878ad629a89000b403ec5e456d3f89 +ARG OMP_SHA256_ARM64=8b5e197db7c58b7ebabf83c6f03597f89bae3a766a84c6481b3a942a125f5e22 RUN ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" \ && case "$ARCH" in \ amd64) OMP_ASSET=omp-linux-x64; OMP_SHA256="$OMP_SHA256_AMD64" ;; \ diff --git a/src/cmd/hive/boot.go b/src/cmd/hive/boot.go index fe4c8b2f07..50fc255afe 100644 --- a/src/cmd/hive/boot.go +++ b/src/cmd/hive/boot.go @@ -35,6 +35,7 @@ import ( "github.com/hivecommons/hive/pkg/mention" "github.com/hivecommons/hive/pkg/mergelane" "github.com/hivecommons/hive/pkg/notify" + "github.com/hivecommons/hive/pkg/perspsrc" "github.com/hivecommons/hive/pkg/planning" "github.com/hivecommons/hive/pkg/priorart" "github.com/hivecommons/hive/pkg/promptsrc" @@ -212,6 +213,7 @@ type boot struct { promptFetcher promptsrc.Fetcher defFetcher defsrc.Fetcher definitionResolver *defsrc.Resolver + perspectivesResolver *perspsrc.Resolver pendingTokenSeed []dashboard.TokenSparklineEntry pendingFactSeed []dashboard.FactHistoryEntry pendingCostSeed []dashboard.CostHistoryEntry diff --git a/src/cmd/hive/discovery_appauth_forge_switch_test.go b/src/cmd/hive/discovery_appauth_forge_switch_test.go new file mode 100644 index 0000000000..c9a946d56f --- /dev/null +++ b/src/cmd/hive/discovery_appauth_forge_switch_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "testing" + + "github.com/hivecommons/hive/pkg/config" +) + +// A placeholder booted against its pool's default forge (GHE App 5686) and +// then claimed onto github.com receives App 3568013 with installation_id 0. +// HasUsableApp is false for that identity, so the full client rebuild is +// (correctly) skipped β€” but installation discovery must still move to the new +// App/forge, or it polls the old one forever and the hive never adopts the +// installation its owner makes (live: `ash` β†’ thinkscientist on vllm-d). +func TestDiscoveryAppAuthStaleOnForgeSwitchWithoutInstallation(t *testing.T) { + gheCfg := &config.Config{} + gheCfg.GitHub.AppID = 5686 + gheCfg.GitHub.APIURL = "https://github.ibm.com/api/v3" + booted := testAppAuth(t, gheCfg) + + public := config.GitHubConfig{AppID: 3568013, InstallationID: 0, APIURL: "https://api.github.com"} + + if !discoveryAppAuthStale(booted, public, "/data/gh-app-key-3568013.pem") { + t.Fatal("GHE-booted AppAuth must be replaced when the hub delivers the github.com App with no installation") + } + if !discoveryAppAuthStale(nil, public, "/data/gh-app-key-3568013.pem") { + t.Fatal("a spoke that booted with no AppAuth must get one for discovery") + } +} + +func TestDiscoveryAppAuthStaleNoOpCases(t *testing.T) { + cfg := &config.Config{} + cfg.GitHub.AppID = 3568013 + cfg.GitHub.APIURL = "https://api.github.com" + current := testAppAuth(t, cfg) + + cases := []struct { + name string + gh config.GitHubConfig + key string + }{ + {"same App and forge already held", config.GitHubConfig{AppID: 3568013, APIURL: "https://api.github.com/"}, "/k.pem"}, + {"installation known β€” the usable-App rebuild owns this", config.GitHubConfig{AppID: 5686, InstallationID: 42, APIURL: "https://github.ibm.com/api/v3"}, "/k.pem"}, + {"no key resolved", config.GitHubConfig{AppID: 5686, APIURL: "https://github.ibm.com/api/v3"}, ""}, + {"placeholder App is not a real App", config.GitHubConfig{AppID: config.PlaceholderAppID}, "/k.pem"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if discoveryAppAuthStale(current, tc.gh, tc.key) { + t.Errorf("expected no discovery AppAuth swap") + } + }) + } +} diff --git a/src/cmd/hive/eval_cycle_quota_hold.go b/src/cmd/hive/eval_cycle_quota_hold.go new file mode 100644 index 0000000000..f7b552c562 --- /dev/null +++ b/src/cmd/hive/eval_cycle_quota_hold.go @@ -0,0 +1,137 @@ +package main + +import ( + "strings" + "sync" + "time" + + "github.com/hivecommons/hive/crux" + "github.com/hivecommons/hive/pkg/scheduler" +) + +// PER-AGENT PROVIDER QUOTA HOLD (hivecommons/hive#18112). +// +// The provider spend gate (gateKickMessagesForProviderBudget) only fires for +// agents behind the hive's own inference gateway: its latch is set by the +// gateway proxy, which never sees a backend that calls its provider directly +// (GitHub Copilot, for one). Those agents still raise crux.Process.QuotaExhausted +// from the pane ("You have exceeded your monthly quota"), but nothing withheld +// their kicks, so the governor kept kicking them β€” and sending them review +// work β€” every cycle, each turn ending at once on the same quota error. +// +// The hold is PER AGENT, unlike the spend gate, because the limit is on that +// agent's provider account, not on a key every agent shares: one agent out of +// quota must never hold any other agent. A held agent still gets one probe +// kick per governor.provider_budget.probe_interval_s, so the hive notices when +// the quota resets (the flag clears once the pane stops showing the message). + +// agentQuotaHoldState remembers, per quota-exhausted agent, when the last +// probe kick was actually delivered. Package-level (agentQuotaHold) for the +// same reason as providerBudgetProbe: runEvalCycle has no state of its own. +type agentQuotaHoldState struct { + mu sync.Mutex + lastProbe map[string]time.Time +} + +// MarkProbeDelivered records that a probe kick reached agent at the given +// time. Call it only after a kick is delivered: a withheld, deferred or failed +// kick learned nothing about the quota and must not push the next probe out. +func (s *agentQuotaHoldState) MarkProbeDelivered(agent string, at time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + if s.lastProbe == nil { + s.lastProbe = map[string]time.Time{} + } + s.lastProbe[agent] = at +} + +// Forget drops agent's probe time, so a later quota hit starts fresh. +func (s *agentQuotaHoldState) Forget(agent string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.lastProbe, agent) +} + +// LastProbe reports when a probe kick was last delivered to agent; the zero +// time means never, which makes the agent due a probe at once. +func (s *agentQuotaHoldState) LastProbe(agent string) time.Time { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastProbe[agent] +} + +// quotaExhaustedAgents reads the per-agent quota flag from one status snapshot +// and forgets the probe time of every agent whose flag is clear, so its kicks +// flow normally and its next quota hit starts a fresh interval. nil entries +// are skipped. The returned set holds only the exhausted agents. +func (s *agentQuotaHoldState) quotaExhaustedAgents(statuses map[string]*crux.Process) map[string]bool { + exhausted := map[string]bool{} + for name, proc := range statuses { + if proc == nil { + continue + } + if proc.QuotaExhausted { + exhausted[name] = true + continue + } + s.Forget(name) + } + return exhausted +} + +// gate applies the per-agent quota hold to one cycle's kicks. It always reads +// the flags from statuses (so cleared agents forget their probe time), then +// returns messages unchanged when no agent is held or there is nothing to +// gate; otherwise it defers to gateKickMessagesForAgentQuota. +func (s *agentQuotaHoldState) gate(messages []scheduler.KickMessage, statuses map[string]*crux.Process, now time.Time, probeInterval time.Duration) (kept []scheduler.KickMessage, withheld []string, probes []string) { + exhausted := s.quotaExhaustedAgents(statuses) + if len(exhausted) == 0 || len(messages) == 0 { + return messages, nil, nil + } + return gateKickMessagesForAgentQuota(messages, func(agent string) bool { return exhausted[agent] }, s.LastProbe, now, probeInterval) +} + +// gateKickMessagesForAgentQuota withholds kicks for agents whose provider +// quota is exhausted, except for one probe kick per held agent once +// probeInterval has passed since that agent's last delivered probe (a zero +// lastProbe is due at once). Only the FIRST message for a probing agent is +// kept; the rest of that agent's messages are withheld this cycle. Agents +// that are not exhausted pass through untouched. Message order is preserved. +// +// withheld and probes name agents in message order; an agent appears in +// withheld once per withheld message, and in probes at most once. +func gateKickMessagesForAgentQuota(messages []scheduler.KickMessage, quotaExhausted func(agent string) bool, lastProbe func(agent string) time.Time, now time.Time, probeInterval time.Duration) (kept []scheduler.KickMessage, withheld []string, probes []string) { + decided := map[string]bool{} + for _, msg := range messages { + if !quotaExhausted(msg.Agent) { + kept = append(kept, msg) + continue + } + if !decided[msg.Agent] { + decided[msg.Agent] = true + if last := lastProbe(msg.Agent); last.IsZero() || now.Sub(last) >= probeInterval { + probes = append(probes, msg.Agent) + kept = append(kept, msg) + continue + } + } + withheld = append(withheld, msg.Agent) + } + return kept, withheld, probes +} + +// compactDuration renders an operator-facing interval without the zero tails +// of time.Duration.String: 30m, 2h, 1h30m, 45s. +func compactDuration(d time.Duration) string { + if d <= 0 { + return "0s" + } + s := d.Round(time.Second).String() + if strings.HasSuffix(s, "m0s") { + s = strings.TrimSuffix(s, "0s") + } + if strings.HasSuffix(s, "h0m") { + s = strings.TrimSuffix(s, "0m") + } + return s +} diff --git a/src/cmd/hive/eval_cycle_quota_hold_test.go b/src/cmd/hive/eval_cycle_quota_hold_test.go new file mode 100644 index 0000000000..77c938fa8f --- /dev/null +++ b/src/cmd/hive/eval_cycle_quota_hold_test.go @@ -0,0 +1,272 @@ +package main + +import ( + "errors" + "log/slog" + "reflect" + "testing" + "time" + + "github.com/hivecommons/hive/crux" + "github.com/hivecommons/hive/pkg/scheduler" +) + +// Tests for the per-agent provider quota hold (hivecommons/hive#18112). + +var quotaHoldNow = time.Date(2026, 11, 13, 9, 0, 0, 0, time.UTC) + +func quotaSet(agents ...string) func(string) bool { + set := map[string]bool{} + for _, a := range agents { + set[a] = true + } + return func(agent string) bool { return set[agent] } +} + +func probesAt(at map[string]time.Time) func(string) time.Time { + return func(agent string) time.Time { return at[agent] } +} + +func kickAgents(msgs []scheduler.KickMessage) []string { + out := []string{} + for _, m := range msgs { + out = append(out, m.Agent+":"+m.Message) + } + return out +} + +func TestQuotaHoldGate_NoHeldAgentsPassesThrough(t *testing.T) { + msgs := []scheduler.KickMessage{{Agent: "a", Message: "1"}, {Agent: "b", Message: "2"}} + kept, withheld, probes := gateKickMessagesForAgentQuota(msgs, quotaSet(), probesAt(nil), quotaHoldNow, 30*time.Minute) + if !reflect.DeepEqual(kept, msgs) { + t.Fatalf("kept = %v, want every message unchanged", kickAgents(kept)) + } + if len(withheld) != 0 || len(probes) != 0 { + t.Fatalf("withheld=%v probes=%v, want none", withheld, probes) + } +} + +func TestQuotaHoldGate_HeldAgentNotDueIsWithheld(t *testing.T) { + msgs := []scheduler.KickMessage{{Agent: "copilot", Message: "gov"}} + last := map[string]time.Time{"copilot": quotaHoldNow.Add(-10 * time.Minute)} + kept, withheld, probes := gateKickMessagesForAgentQuota(msgs, quotaSet("copilot"), probesAt(last), quotaHoldNow, 30*time.Minute) + if len(kept) != 0 { + t.Fatalf("kept = %v, want the held agent's kick withheld", kickAgents(kept)) + } + if !reflect.DeepEqual(withheld, []string{"copilot"}) || len(probes) != 0 { + t.Fatalf("withheld=%v probes=%v, want [copilot] and no probe", withheld, probes) + } +} + +// A held agent that is due a probe gets exactly one kick; its other messages +// this cycle (for example a fix kick ahead of its governor kick) stay held. +func TestQuotaHoldGate_HeldAgentDueGetsOneProbe(t *testing.T) { + msgs := []scheduler.KickMessage{ + {Agent: "copilot", Message: "fix"}, + {Agent: "copilot", Message: "gov"}, + } + last := map[string]time.Time{"copilot": quotaHoldNow.Add(-30 * time.Minute)} + kept, withheld, probes := gateKickMessagesForAgentQuota(msgs, quotaSet("copilot"), probesAt(last), quotaHoldNow, 30*time.Minute) + if want := []string{"copilot:fix"}; !reflect.DeepEqual(kickAgents(kept), want) { + t.Fatalf("kept = %v, want %v (first message only)", kickAgents(kept), want) + } + if !reflect.DeepEqual(probes, []string{"copilot"}) || !reflect.DeepEqual(withheld, []string{"copilot"}) { + t.Fatalf("probes=%v withheld=%v, want one probe and one withheld", probes, withheld) + } +} + +// One agent out of quota must not hold any other agent; order is kept. +func TestQuotaHoldGate_MixedHeldAndHealthyKeepsOrder(t *testing.T) { + msgs := []scheduler.KickMessage{ + {Agent: "healthy-1", Message: "a"}, + {Agent: "held", Message: "review"}, + {Agent: "healthy-2", Message: "b"}, + {Agent: "held", Message: "gov"}, + } + last := map[string]time.Time{"held": quotaHoldNow.Add(-time.Minute)} + kept, withheld, probes := gateKickMessagesForAgentQuota(msgs, quotaSet("held"), probesAt(last), quotaHoldNow, 30*time.Minute) + if want := []string{"healthy-1:a", "healthy-2:b"}; !reflect.DeepEqual(kickAgents(kept), want) { + t.Fatalf("kept = %v, want %v", kickAgents(kept), want) + } + if !reflect.DeepEqual(withheld, []string{"held", "held"}) || len(probes) != 0 { + t.Fatalf("withheld=%v probes=%v, want [held held] and no probe", withheld, probes) + } +} + +func TestQuotaHoldGate_ZeroProbeTimeIsDueAtOnce(t *testing.T) { + msgs := []scheduler.KickMessage{ + {Agent: "healthy", Message: "a"}, + {Agent: "held", Message: "gov"}, + } + kept, withheld, probes := gateKickMessagesForAgentQuota(msgs, quotaSet("held"), probesAt(nil), quotaHoldNow, 30*time.Minute) + if want := []string{"healthy:a", "held:gov"}; !reflect.DeepEqual(kickAgents(kept), want) { + t.Fatalf("kept = %v, want %v", kickAgents(kept), want) + } + if !reflect.DeepEqual(probes, []string{"held"}) || len(withheld) != 0 { + t.Fatalf("probes=%v withheld=%v, want [held] probed at once", probes, withheld) + } +} + +func TestQuotaHoldState_MarkThenLastProbeThenForget(t *testing.T) { + var s agentQuotaHoldState + if got := s.LastProbe("a"); !got.IsZero() { + t.Fatalf("LastProbe on empty state = %v, want zero", got) + } + s.MarkProbeDelivered("a", quotaHoldNow) + if got := s.LastProbe("a"); !got.Equal(quotaHoldNow) { + t.Fatalf("LastProbe = %v, want %v", got, quotaHoldNow) + } + if got := s.LastProbe("b"); !got.IsZero() { + t.Fatalf("another agent's probe time = %v, want zero", got) + } + s.Forget("a") + if got := s.LastProbe("a"); !got.IsZero() { + t.Fatalf("LastProbe after Forget = %v, want zero", got) + } +} + +// When an agent's flag clears its probe time is forgotten, so its kicks flow +// normally and a later quota hit starts a fresh interval. +func TestQuotaHoldState_ClearedFlagForgetsProbe(t *testing.T) { + var s agentQuotaHoldState + s.MarkProbeDelivered("held", quotaHoldNow) + s.MarkProbeDelivered("recovered", quotaHoldNow) + got := s.quotaExhaustedAgents(map[string]*crux.Process{ + "held": {QuotaExhausted: true}, + "recovered": {QuotaExhausted: false}, + "gone": nil, + }) + if !reflect.DeepEqual(got, map[string]bool{"held": true}) { + t.Fatalf("exhausted = %v, want only held", got) + } + if !s.LastProbe("held").Equal(quotaHoldNow) { + t.Fatal("a still-held agent must keep its probe time") + } + if !s.LastProbe("recovered").IsZero() { + t.Fatal("a recovered agent's probe time must be forgotten") + } +} + +// gate is the eval cycle's entry point: it must return the cycle's messages +// untouched (same slice, no copy) when no agent is held or there is nothing to +// kick, still forget recovered agents, and gate only when both are non-empty. +func TestQuotaHoldState_GateShortCircuitsAndGates(t *testing.T) { + msgs := []scheduler.KickMessage{{Agent: "held", Message: "1"}, {Agent: "ok", Message: "2"}, {Agent: "held", Message: "3"}} + + t.Run("no held agent returns the same slice", func(t *testing.T) { + var s agentQuotaHoldState + s.MarkProbeDelivered("ok", quotaHoldNow) + kept, withheld, probes := s.gate(msgs, map[string]*crux.Process{"ok": {}}, quotaHoldNow, 30*time.Minute) + if len(kept) != len(msgs) || &kept[0] != &msgs[0] { + t.Fatalf("kept = %v, want the input slice itself", kickAgents(kept)) + } + if withheld != nil || probes != nil { + t.Fatalf("withheld=%v probes=%v, want nil", withheld, probes) + } + if !s.LastProbe("ok").IsZero() { + t.Fatal("a recovered agent's probe time must be forgotten even when nothing is gated") + } + }) + + t.Run("no messages returns the empty input", func(t *testing.T) { + var s agentQuotaHoldState + empty := []scheduler.KickMessage{} + kept, withheld, probes := s.gate(empty, map[string]*crux.Process{"held": {QuotaExhausted: true}}, quotaHoldNow, 30*time.Minute) + if kept == nil || len(kept) != 0 || withheld != nil || probes != nil { + t.Fatalf("kept=%v withheld=%v probes=%v, want the empty input and nothing else", kept, withheld, probes) + } + }) + + t.Run("one held agent is gated", func(t *testing.T) { + var s agentQuotaHoldState + s.MarkProbeDelivered("held", quotaHoldNow.Add(-time.Minute)) + kept, withheld, probes := s.gate(msgs, map[string]*crux.Process{"held": {QuotaExhausted: true}, "ok": {}}, quotaHoldNow, 30*time.Minute) + if got := kickAgents(kept); !reflect.DeepEqual(got, []string{"ok:2"}) { + t.Fatalf("kept = %v, want only ok:2", got) + } + if !reflect.DeepEqual(withheld, []string{"held", "held"}) || len(probes) != 0 { + t.Fatalf("withheld=%v probes=%v, want held twice and no probe", withheld, probes) + } + }) +} + +// The full cycle: withheld, then one probe after the interval, then withheld +// again on the next cycle once that probe was delivered. +func TestQuotaHoldGate_ProbeOncePerInterval(t *testing.T) { + var s agentQuotaHoldState + held := quotaSet("held") + msgs := []scheduler.KickMessage{{Agent: "held", Message: "gov"}} + interval := 30 * time.Minute + + s.MarkProbeDelivered("held", quotaHoldNow) + if kept, _, _ := gateKickMessagesForAgentQuota(msgs, held, s.LastProbe, quotaHoldNow.Add(5*time.Minute), interval); len(kept) != 0 { + t.Fatalf("inside the interval kept = %v, want none", kickAgents(kept)) + } + at := quotaHoldNow.Add(interval) + kept, _, probes := gateKickMessagesForAgentQuota(msgs, held, s.LastProbe, at, interval) + if len(kept) != 1 || !reflect.DeepEqual(probes, []string{"held"}) { + t.Fatalf("after the interval kept=%v probes=%v, want exactly one probe", kickAgents(kept), probes) + } + s.MarkProbeDelivered("held", at) + if kept, _, _ := gateKickMessagesForAgentQuota(msgs, held, s.LastProbe, at.Add(2*time.Minute), interval); len(kept) != 0 { + t.Fatalf("next cycle kept = %v, want the agent held again", kickAgents(kept)) + } +} + +// The probe time is recorded on delivery only: a probe whose send fails must +// not advance it, so the next cycle may probe again. +func TestQuotaHoldDispatch_ProbeRecordedOnDeliveryNotOnSendError(t *testing.T) { + for _, tc := range []struct { + name string + sendErr error + want bool + }{ + {"delivered", nil, true}, + {"send error", errors.New("agent CLI did not reach input prompt"), false}, + {"busy deferral", crux.ErrAgentBusy, false}, + } { + t.Run(tc.name, func(t *testing.T) { + var s agentQuotaHoldState + exhausted := map[string]bool{"held": true} + r := newKickDispatchRecorder() + r.sendErr["held"] = tc.sendErr + deps := r.deps() + inner := deps.onDelivered + deps.onDelivered = func(msg scheduler.KickMessage, deliveredAt time.Time) { + inner(msg, deliveredAt) + if exhausted[msg.Agent] { + s.MarkProbeDelivered(msg.Agent, deliveredAt) + } + } + msgs, _, probes := gateKickMessagesForAgentQuota( + []scheduler.KickMessage{{Agent: "held", Message: "gov"}, {Agent: "healthy", Message: "gov"}}, + func(a string) bool { return exhausted[a] }, s.LastProbe, quotaHoldNow, 30*time.Minute) + if !reflect.DeepEqual(probes, []string{"held"}) { + t.Fatalf("probes = %v, want [held]", probes) + } + dispatchAgentKicks(msgs, false, deps, slog.Default()) + if got := !s.LastProbe("held").IsZero(); got != tc.want { + t.Fatalf("probe recorded = %v, want %v", got, tc.want) + } + if !s.LastProbe("healthy").IsZero() { + t.Fatal("a healthy agent's delivery must never record a probe time") + } + }) + } +} + +func TestCompactDuration(t *testing.T) { + for d, want := range map[time.Duration]string{ + 30 * time.Minute: "30m", + 2 * time.Hour: "2h", + 90 * time.Minute: "1h30m", + 45 * time.Second: "45s", + 90 * time.Second: "1m30s", + 0: "0s", + time.Hour + 30*time.Second: "1h0m30s", + } { + if got := compactDuration(d); got != want { + t.Errorf("compactDuration(%v) = %q, want %q", d, got, want) + } + } +} diff --git a/src/cmd/hive/eval_cycle_seams.go b/src/cmd/hive/eval_cycle_seams.go index 43b792a7f6..98a5d8c93f 100644 --- a/src/cmd/hive/eval_cycle_seams.go +++ b/src/cmd/hive/eval_cycle_seams.go @@ -443,7 +443,11 @@ type providerBudgetAlert struct { // consulted the agent manager on the NOT-latched branch, and collecting agent // statuses every cycle while the provider is clipped would be work the old // code never did. Passing a thunk keeps that laziness exactly. -func decideProviderBudgetAlert(latched, suppress bool, cause string, since time.Time, rebuffs int, quotaReason func() string) providerBudgetAlert { +// +// quotaProbeInterval is how often an agent held for exhausted provider quota +// is probed (#18112); the quota banner names it so the operator knows the +// hive is holding those kicks and when it will retry them. +func decideProviderBudgetAlert(latched, suppress bool, cause string, since time.Time, rebuffs int, quotaProbeInterval time.Duration, quotaReason func() string) providerBudgetAlert { if latched { state := "agent kicks suspended" if !suppress { @@ -457,7 +461,8 @@ func decideProviderBudgetAlert(latched, suppress bool, cause string, since time. return providerBudgetAlert{Message: msg, Cause: msg} } if reason := quotaReason(); reason != "" { - return providerBudgetAlert{Message: "provider quota exhausted β€” " + reason} + return providerBudgetAlert{Message: fmt.Sprintf("provider quota exhausted β€” %s; their kicks are held and retried every %s", + reason, compactDuration(quotaProbeInterval))} } return providerBudgetAlert{Clear: true} } diff --git a/src/cmd/hive/main.go b/src/cmd/hive/main.go index 95a59c3843..5bbb0b94d4 100644 --- a/src/cmd/hive/main.go +++ b/src/cmd/hive/main.go @@ -72,6 +72,7 @@ import ( "github.com/hivecommons/hive/pkg/nousengine" "github.com/hivecommons/hive/pkg/nousstore" "github.com/hivecommons/hive/pkg/persona" + "github.com/hivecommons/hive/pkg/perspsrc" "github.com/hivecommons/hive/pkg/planning" "github.com/hivecommons/hive/pkg/policies" "github.com/hivecommons/hive/pkg/promptsrc" @@ -1330,7 +1331,9 @@ func (b *boot) wireBootClosures() { if b.ghClient == nil { return } - installReviewRelaySettings(b.ghClient, b.cfg, b.logger) + // LinkedPerspectives is runtime-only, so re-resolve it before + // the relay reinstall or a dashboard PUT would drop the link. + applyReviewPerspectivesSource(context.Background(), b.cfg, b.perspectivesResolver, b.ghClient, b.logger) // review_bots.min_priority and fix_human_prs are edited // through the same PUT; reinstall so the thread reconciler // applies them without a restart (#10481). @@ -1503,6 +1506,18 @@ func (b *boot) bootGitHubWith(deps bootGitHubDeps) { // The same function every rebuild path uses (#9614). Only the tiers whose // dependencies exist yet apply here; bootAdvisory, bootAgents and // bootDashboard apply the rest as those dependencies come up. + // Resolve review.perspectives_source before the first relay install so + // the relay never boots on a set the link has since replaced. + var persFetcher defsrc.Fetcher + if b.ghClient != nil { + persFetcher = b.ghClient + } + b.perspectivesResolver = perspsrc.NewResolver( + persFetcher, + func(slug string) bool { return b.cfg.GitHubPromptAllowed(slug) }, + b.logger, + ) + perspsrc.ApplyToConfig(b.ctx, b.cfg, b.perspectivesResolver, b.logger) b.configureGitHubClient(b.ghClient) // Long-lived consumers read the client through this provider, never a // captured b.ghClient, so every rebuild reaches them (#9621). @@ -3311,12 +3326,20 @@ func (b *boot) bootKnowledgeWith(deps bootKnowledgeDeps) { FilePath: doc.FilePath, Layer: knowledge.LayerType(doc.Layer), } - meta, err := b.knowledgeAPI.ImportDocument(b.ctx, docConfig) + // EnsureDocument keeps a document already reloaded from disk and + // re-imports one whose on-disk state is gone, so a configured + // document survives an upgrade the way git sources do (#18058). + meta, imported, err := b.knowledgeAPI.EnsureDocument(b.ctx, docConfig) if err != nil { b.logger.Warn("failed to import document source", "name", doc.Name, "error", err, ) + } else if !imported { + b.logger.Debug("document source already loaded", + "name", doc.Name, + "facts", meta.FactCount, + ) } else { b.logger.Info("document source imported", "name", doc.Name, @@ -4151,6 +4174,7 @@ func (b *boot) bootWatchersWith(deps bootWatchersDeps) { // fields; a fetch failure keeps each agent's baked definition. Runs before // initAgentConfigDrivenSystems so downstream systems see the merged config. defsrc.ApplyToConfig(context.Background(), b.cfg, b.definitionResolver, b.logger) + applyReviewPerspectivesSource(context.Background(), b.cfg, b.perspectivesResolver, b.ghClient, b.logger) if err := b.cfg.ExpandAgentReplicas(); err != nil { b.logger.Warn("failed to expand agent replicas after config reload", "error", err) } @@ -4386,6 +4410,10 @@ func (b *boot) bootProxyWith(deps bootProxyDeps) { // unconditionally: with HIVE_PROXY_INJECT_GH_AUTH unset (the default) // the proxy never consults the source and the registry stays empty. b.githubProxy.SetAgentTokenSource(github.AgentProxyToken) + // project.reference_repos (#18015): study-only repos outside the + // project, with a rare per-entry issues/prs opt-in served by a + // credential verified here (boot refuses otherwise). + b.wireReferenceRepos() // Durable attempted-vs-blocked write counters (#12069); observation only. if err := proxy.MigrateWriteCounters(proxy.LegacyWriteCountersPath, proxy.WriteCountersPath, time.Now()); err != nil { b.logger.Warn("write counters: legacy file not migrated", "error", err) @@ -5967,6 +5995,16 @@ func (b *boot) bootHeartbeatWith(deps bootHeartbeatDeps) { "app_id", b.cfg.GitHub.AppID, "installation_id", b.cfg.GitHub.InstallationID, ) + } else if discoveryAppAuthStale(b.appAuth, b.cfg.GitHub, rebuildKeyFile) { + // A real App with no installation yet (forge switch on claim): + // give installation discovery the NEW App's JWT so it polls + // the forge the repos actually live on. + newAppAuth, err := github.NewAppAuth(b.cfg.GitHub.AppID, 0, rebuildKeyFile, b.logger, b.cfg.GitHub.ResolvedAPIURL()) + if err != nil { + b.logger.Error("github app auth init for installation discovery failed", "error", err) + return + } + b.adoptDiscoveryAppAuth(newAppAuth) } }), diff --git a/src/cmd/hive/main_eval_cycle.go b/src/cmd/hive/main_eval_cycle.go index cb4eedeabf..08816e1a23 100644 --- a/src/cmd/hive/main_eval_cycle.go +++ b/src/cmd/hive/main_eval_cycle.go @@ -91,6 +91,11 @@ var providerBudgetNotify governor.ProviderBudgetNotifyState // providerBudgetNotify: runEvalCycle has no state of its own. var providerBudgetProbe governor.ProviderBudgetProbeState +// agentQuotaHold remembers, per agent out of provider quota, when its last +// probe kick was delivered (hivecommons/hive#18112). Package-level for the +// same reason as providerBudgetProbe. +var agentQuotaHold agentQuotaHoldState + // governorKickFairness remembers, per agent, how many cycles in a row the // governor kick was deferred behind a delivered review-fix kick // (hivecommons/hive#15166). Package-level for the same reason as @@ -996,7 +1001,7 @@ func runEvalCycle( suppressKicks := governor.ProviderBudgetSuppresses(providerBudgetLatched, providerBudgetProbe.Freshest(providerBudgetLastRebuff), time.Now(), providerBudgetProbeInterval) budgetAlert := decideProviderBudgetAlert(providerBudgetLatched, suppressKicks, - providerBudgetCause, providerBudgetSince, providerBudgetRebuffs, + providerBudgetCause, providerBudgetSince, providerBudgetRebuffs, providerBudgetProbeInterval, func() string { return hub.QuotaExhaustedAgentReason(hub.QuotaExhaustedProcessCount(agentMgr.AllStatuses())) }) @@ -1204,6 +1209,23 @@ func runEvalCycle( "last_rebuff", providerBudgetLastRebuff, "probe_interval", providerBudgetProbeInterval) } messages = kickGate.Kept + // PER-AGENT PROVIDER QUOTA HOLD (#18112). The spend gate above only sees + // agents behind the hive's inference gateway; an agent whose own provider + // refuses it for quota (Copilot's "monthly quota") is held here, per + // agent, from the QuotaExhausted flag every backend reports. Review-swarm + // kicks are in this list too, so a held reviewer gets no review work. + quotaProbeNow := map[string]bool{} + { + kept, quotaWithheld, quotaProbes := agentQuotaHold.gate(messages, agentMgr.AllStatuses(), time.Now(), providerBudgetProbeInterval) + if len(quotaWithheld) > 0 || len(quotaProbes) > 0 { + logger.Warn("provider quota exhausted: withholding agent kicks", + "withheld", quotaWithheld, "probe", quotaProbes, "probe_interval", providerBudgetProbeInterval) + } + for _, agent := range quotaProbes { + quotaProbeNow[agent] = true + } + messages = kept + } // Self-upgrade drain (#11876): a hub-instructed upgrade is waiting for // in-flight kicks to end, so starting new turns would only feed the // restart. Withheld kicks stay due and go out from the new process. @@ -1646,6 +1668,12 @@ func runEvalCycle( }, onDelivered: func(msg scheduler.KickMessage, deliveredAt time.Time) { deliveredKicks = append(deliveredKicks, msg) + if quotaProbeNow[msg.Agent] { + // The only kick a held agent can receive is its probe; + // stamp it on delivery so a failed or deferred probe + // is retried next cycle (#18112). + agentQuotaHold.MarkProbeDelivered(msg.Agent, deliveredAt) + } gov.RecordKickForRepoAt(msg.Agent, msg.Repo, deliveredAt) dashSrv.AuditLog("governor", "kick", "trigger=governor-eval", msg.Agent) diff --git a/src/cmd/hive/main_github_app.go b/src/cmd/hive/main_github_app.go index b747bdca20..a3a89f0f26 100644 --- a/src/cmd/hive/main_github_app.go +++ b/src/cmd/hive/main_github_app.go @@ -558,3 +558,57 @@ func advisoryIssueMissingError(repo string, cause error) string { } return base } + +// discoveryAppAuthStale reports whether the AppAuth the installation-discovery +// loop is polling with no longer names the App the config now claims. +// +// A hub-delivered identity with installation_id 0 (a placeholder claimed onto +// a DIFFERENT forge than its pool default: GHE slot β†’ github.com repo) is not +// "usable" β€” HasUsableApp is false β€” so the heartbeat rebuild skipped it and +// the boot-time AppAuth (old App, old api_url) kept serving auto-discovery. +// Discovery then polled the wrong forge forever: the installation the owner +// made on the right one could never be found, and the hive sat at "App has no installation" until a pod restart. Boot builds an AppAuth the +// moment a real app_id and key exist precisely so discovery has a JWT to use; +// this is the same rule applied to a hub delivery. +func discoveryAppAuthStale(current *github.AppAuth, gh config.GitHubConfig, keyFile string) bool { + if !gh.HasApp() || gh.InstallationID != 0 || keyFile == "" { + return false + } + if current == nil { + return true + } + return current.AppID() != gh.AppID || !sameAPIURL(current.APIURL(), gh.ResolvedAPIURL()) +} + +// sameAPIURL compares two GitHub API base URLs ignoring scheme-case and a +// trailing slash, the two spellings the config and the client disagree on. +func sameAPIURL(a, b string) bool { + norm := func(s string) string { return strings.TrimRight(strings.ToLower(strings.TrimSpace(s)), "/") } + return norm(a) == norm(b) +} + +// adoptDiscoveryAppAuth hands a not-yet-installed App's AppAuth to the +// installation-discovery loop and flips the banner/hub state to +// not-installed for the App it now names, then forces one discovery pass so +// an installation already made on the new forge is adopted without waiting +// for the next tick. No client is built: there is no installation to mint +// against yet β€” AutoDiscoverGitHubInstallationID builds it on success. +func (b *boot) adoptDiscoveryAppAuth(newAppAuth *github.AppAuth) { + if b == nil || newAppAuth == nil { + return + } + b.appAuth = newAppAuth + diag := "GitHub App " + strconv.FormatInt(b.cfg.GitHub.AppID, 10) + + " has no installation for this org β€” install it (the spoke adopts the installation automatically)" + b.appAuthFailure = "The GitHub App is configured but has no installation. Install the app on your org; this hive will discover the installation automatically." + if b.dashSrv != nil { + b.dashSrv.UpdateGitHubAppAuth(newAppAuth) + b.dashSrv.SetGitHubAppRequired(true) + b.dashSrv.SetGitHubAppState(github.AppStateNotInstalled.String()) + b.dashSrv.SetGitHubAppPermIssue(diag) + go func() { _, _ = b.dashSrv.AutoDiscoverGitHubInstallationID(b.ctx, true) }() + } + b.logger.Info("github app identity adopted for installation discovery", + "app_id", newAppAuth.AppID(), "api_url", newAppAuth.APIURL()) +} diff --git a/src/cmd/hive/main_sweeps.go b/src/cmd/hive/main_sweeps.go index b8e02989f4..6853892a9e 100644 --- a/src/cmd/hive/main_sweeps.go +++ b/src/cmd/hive/main_sweeps.go @@ -27,6 +27,7 @@ import ( "github.com/hivecommons/hive/pkg/governor" "github.com/hivecommons/hive/pkg/hooks" "github.com/hivecommons/hive/pkg/notify" + "github.com/hivecommons/hive/pkg/perspsrc" "github.com/hivecommons/hive/pkg/planning" "github.com/hivecommons/hive/pkg/review" "github.com/hivecommons/hive/pkg/rotation" @@ -2062,6 +2063,15 @@ func reviewAuthorBase(cfg *config.Config, agent string) string { return agent } +// applyReviewPerspectivesSource re-resolves review.perspectives_source and +// reinstalls the relay settings, so the relay validates verdicts against the +// set the link now names rather than the one it booted with. With no source +// set it fetches nothing and the inline set stands. +func applyReviewPerspectivesSource(ctx context.Context, cfg *config.Config, resolver *perspsrc.Resolver, client *github.Client, logger *slog.Logger) { + perspsrc.ApplyToConfig(ctx, cfg, resolver, logger) + installReviewRelaySettings(client, cfg, logger) +} + // installReviewRelaySettings gives a (possibly rebuilt) GitHub client the // review-relay knobs that live in cfg.Review: which repos may be revised in // place, which perspectives a verdict may name, and whether comments carry a @@ -2337,7 +2347,9 @@ func applyHumanDecisionLabels(ctx context.Context, cfg *config.Config, ghClient // silently becomes the current time would re-open every verdict in the // artifact at once β€” the opposite of the narrow, deliberate correction this // setting exists for. -// reviewPerspectiveSet resolves the hive's configured review perspectives. +// reviewPerspectiveSet resolves the hive's configured review perspectives: the +// set linked from review.perspectives_source when one has resolved, otherwise +// the inline review.perspectives (ReviewConfig.EffectivePerspectives). // // A bad configuration falls back to the built-in set and says so, rather than // disabling review. Silently reviewing nothing would look identical to a healthy @@ -2347,7 +2359,7 @@ func reviewPerspectiveSet(cfg *config.Config, logger *slog.Logger) review.Perspe if cfg == nil { return review.PerspectiveSet{} } - set, err := review.NewPerspectiveSet(cfg.Review.Perspectives, cfg.Review.PerspectivePrompts) + set, err := review.NewPerspectiveSet(cfg.Review.EffectivePerspectives()) if err != nil { if logger != nil { logger.Warn("review.perspectives is invalid; reviewing with the built-in set until it is fixed", diff --git a/src/cmd/hive/provider_budget_alert_test.go b/src/cmd/hive/provider_budget_alert_test.go index bcfb10685f..878d96bb04 100644 --- a/src/cmd/hive/provider_budget_alert_test.go +++ b/src/cmd/hive/provider_budget_alert_test.go @@ -23,7 +23,7 @@ func neverCalled(t *testing.T) func() string { } func TestDecideProviderBudgetAlertLatchedSuspended(t *testing.T) { - got := decideProviderBudgetAlert(true, true, "spend cap hit", time.Now(), 1, neverCalled(t)) + got := decideProviderBudgetAlert(true, true, "spend cap hit", time.Now(), 1, 30*time.Minute, neverCalled(t)) if got.Clear { t.Fatal("a held latch must not clear the banner") @@ -40,7 +40,7 @@ func TestDecideProviderBudgetAlertLatchedSuspended(t *testing.T) { } func TestDecideProviderBudgetAlertLatchedProbing(t *testing.T) { - got := decideProviderBudgetAlert(true, false, "spend cap hit", time.Now(), 1, neverCalled(t)) + got := decideProviderBudgetAlert(true, false, "spend cap hit", time.Now(), 1, 30*time.Minute, neverCalled(t)) if !strings.Contains(got.Message, "probing with a single agent kick") { t.Fatalf("a stale latch probes rather than staying muted, got %q", got.Message) @@ -54,7 +54,7 @@ func TestDecideProviderBudgetAlertLatchedProbing(t *testing.T) { // between "one call was refused" and "this has been refusing since Tuesday". func TestDecideProviderBudgetAlertRepeatedRebuffsAreQuantified(t *testing.T) { since := time.Date(2026, 9, 16, 14, 30, 0, 0, time.UTC) - got := decideProviderBudgetAlert(true, true, "spend cap hit", since, 47, neverCalled(t)) + got := decideProviderBudgetAlert(true, true, "spend cap hit", since, 47, 30*time.Minute, neverCalled(t)) if !strings.Contains(got.Message, "47 refused calls") { t.Fatalf("repeated rebuffs must be counted, got %q", got.Message) @@ -66,7 +66,7 @@ func TestDecideProviderBudgetAlertRepeatedRebuffsAreQuantified(t *testing.T) { func TestDecideProviderBudgetAlertSingleRebuffIsNotQuantified(t *testing.T) { since := time.Date(2026, 9, 16, 14, 30, 0, 0, time.UTC) - got := decideProviderBudgetAlert(true, true, "spend cap hit", since, 1, neverCalled(t)) + got := decideProviderBudgetAlert(true, true, "spend cap hit", since, 1, 30*time.Minute, neverCalled(t)) if strings.Contains(got.Message, "refused calls") { t.Fatalf("a single rebuff must not be reported as a run of them, got %q", got.Message) @@ -80,7 +80,7 @@ func TestDecideProviderBudgetAlertSingleRebuffIsNotQuantified(t *testing.T) { // branch: the provider is not refusing on money, but agents are individually // out of quota, which is a different cause and must not be silent. func TestDecideProviderBudgetAlertQuotaExhaustedFallback(t *testing.T) { - got := decideProviderBudgetAlert(false, false, "", time.Now(), 0, + got := decideProviderBudgetAlert(false, false, "", time.Now(), 0, 30*time.Minute, func() string { return "3 agents out of quota" }) if got.Clear { @@ -89,13 +89,18 @@ func TestDecideProviderBudgetAlertQuotaExhaustedFallback(t *testing.T) { if !strings.Contains(got.Message, "provider quota exhausted") || !strings.Contains(got.Message, "3 agents out of quota") { t.Fatalf("quota exhaustion must name itself and its reason, got %q", got.Message) } + // #18112: the banner says the hive is holding those agents' kicks and + // when it retries them, not just that the quota ran out. + if !strings.Contains(got.Message, "their kicks are held and retried every 30m") { + t.Fatalf("quota exhaustion must say kicks are held and the probe interval, got %q", got.Message) + } if got.Cause != "" { t.Fatalf("only the latched branch replaces the cause, got %q", got.Cause) } } func TestDecideProviderBudgetAlertHealthyClears(t *testing.T) { - got := decideProviderBudgetAlert(false, false, "", time.Now(), 0, func() string { return "" }) + got := decideProviderBudgetAlert(false, false, "", time.Now(), 0, 30*time.Minute, func() string { return "" }) if !got.Clear { t.Fatal("a healthy provider with no exhausted quota must clear the banner, or it sticks forever") @@ -121,7 +126,7 @@ func TestDecideProviderBudgetAlertNeverRaisesAndClears(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - got := decideProviderBudgetAlert(tc.latched, tc.suppress, "cause", time.Now(), 1, + got := decideProviderBudgetAlert(tc.latched, tc.suppress, "cause", time.Now(), 1, 30*time.Minute, func() string { return tc.quota }) if got.Message != "" && got.Clear { t.Fatalf("must never raise and clear in the same cycle: %+v", got) diff --git a/src/cmd/hive/reference_repos.go b/src/cmd/hive/reference_repos.go new file mode 100644 index 0000000000..f87c367245 --- /dev/null +++ b/src/cmd/hive/reference_repos.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + + "github.com/hivecommons/hive/crux/proxy" + "github.com/hivecommons/hive/pkg/config" + "github.com/hivecommons/hive/pkg/github" +) + +// referenceRepoCredentialExitCode marks an exit caused by a +// project.reference_repos entry that opted in to writes without a credential +// GitHub would honour for that repo (hivecommons/hive#18015). Its own code so +// the termination state says "install the App on the target org or supply +// write_token_file", not "bug". +const referenceRepoCredentialExitCode = 22 + +// referenceRepoDeps are the seams buildReferenceRepoPolicies needs: App +// installation discovery on a foreign org and token-identity resolution. +// Either may be nil when the hive has no GitHub App auth, in which case a +// write-mode entry can only be served by write_token_file. +type referenceRepoDeps struct { + botLogin string + discover func(ctx context.Context, org string) (int64, error) + whoAmI func(ctx context.Context, token string) (string, error) + auth func() *github.AppAuth +} + +// buildReferenceRepoPolicies turns the config list into proxy policies. A +// read-only entry needs nothing; a write-mode entry is verified now so the +// failure is a boot-time config error naming the repo. +func buildReferenceRepoPolicies(ctx context.Context, refs []config.ReferenceRepo, deps referenceRepoDeps, logger *slog.Logger) ([]proxy.ReferenceRepoPolicy, error) { + out := make([]proxy.ReferenceRepoPolicy, 0, len(refs)) + for _, rr := range refs { + pol := proxy.ReferenceRepoPolicy{Repo: rr.Repo, Issues: rr.WritesIssues(), PRs: rr.WritesPRs()} + if !rr.ReadOnly() { + login, err := github.VerifyReferenceRepoWriteCredential(ctx, rr, deps.botLogin, deps.discover, deps.whoAmI) + if err != nil { + return nil, err + } + pol.Login = login + pol.Token = github.ReferenceRepoTokenSource(rr, deps.auth, logger) + } + out = append(out, pol) + } + return out, nil +} + +// wireReferenceRepos installs project.reference_repos on the proxy, refusing +// to boot when a write-mode entry has no usable credential. +func (b *boot) wireReferenceRepos() { + refs := b.cfg.Project.ReferenceRepos + if len(refs) == 0 || b.githubProxy == nil { + return + } + deps := referenceRepoDeps{auth: func() *github.AppAuth { return nil }} + if b.ghClient != nil { + if auth := b.ghClient.AppAuth(); auth != nil { + apiURL := auth.APIURL() + deps.botLogin = auth.BotLogin() + deps.discover = auth.DiscoverInstallationID + deps.whoAmI = func(ctx context.Context, token string) (string, error) { + return github.TokenUserLogin(ctx, token, apiURL) + } + // Re-fetched per call: the AppAuth is replaced on key rotation. + deps.auth = func() *github.AppAuth { return b.ghClient.AppAuth() } + } else { + deps.whoAmI = func(ctx context.Context, token string) (string, error) { + return github.TokenUserLogin(ctx, token, "") + } + } + } + policies, err := buildReferenceRepoPolicies(b.ctx, refs, deps, b.logger) + if err != nil { + b.logger.Error("refusing to start: project.reference_repos write-mode entry has no usable credential (#18015)", "error", err.Error()) + if errors.Is(err, github.ErrReferenceRepoNoCredential) { + fmt.Fprintln(os.Stderr, err.Error()) + } + os.Exit(referenceRepoCredentialExitCode) + } + b.githubProxy.SetReferenceRepos(policies) + b.logger.Info("reference repos wired", "count", len(policies), "repos", b.cfg.ReferenceRepoSlugs()) +} diff --git a/src/cmd/hive/reference_repos_test.go b/src/cmd/hive/reference_repos_test.go new file mode 100644 index 0000000000..b10d70646a --- /dev/null +++ b/src/cmd/hive/reference_repos_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "errors" + "testing" + + "github.com/hivecommons/hive/pkg/config" + "github.com/hivecommons/hive/pkg/github" +) + +func TestBuildReferenceRepoPolicies(t *testing.T) { + ctx := context.Background() + deps := referenceRepoDeps{ + botLogin: "hive[bot]", + discover: func(_ context.Context, org string) (int64, error) { + if org == "hivecommons" { + return 1, nil + } + return 0, github.ErrNoInstallationForOrg + }, + auth: func() *github.AppAuth { return nil }, + } + refs := []config.ReferenceRepo{ + {Repo: "hivecommons/docs"}, + {Repo: "hivecommons/hive", Write: []string{"issues", "prs"}}, + } + pols, err := buildReferenceRepoPolicies(ctx, refs, deps, testLogger()) + if err != nil { + t.Fatal(err) + } + if len(pols) != 2 { + t.Fatalf("got %d policies", len(pols)) + } + if pols[0].Token != nil || pols[0].Issues || pols[0].PRs || pols[0].Login != "" { + t.Errorf("read-only policy carries write state: %+v", pols[0]) + } + if pols[1].Token == nil || !pols[1].Issues || !pols[1].PRs || pols[1].Login != "hive[bot]" { + t.Errorf("write policy incomplete: %+v", pols[1]) + } + + _, err = buildReferenceRepoPolicies(ctx, []config.ReferenceRepo{{Repo: "kubernetes/website", Write: []string{"issues"}}}, deps, testLogger()) + if !errors.Is(err, github.ErrReferenceRepoNoCredential) { + t.Fatalf("uninstalled org must refuse with ErrReferenceRepoNoCredential, got %v", err) + } +} diff --git a/src/cmd/hive/review_config_parsers_test.go b/src/cmd/hive/review_config_parsers_test.go index 1c0dc67eab..ad5a24d3cd 100644 --- a/src/cmd/hive/review_config_parsers_test.go +++ b/src/cmd/hive/review_config_parsers_test.go @@ -94,6 +94,26 @@ func TestReviewPerspectiveSet(t *testing.T) { } }) + t.Run("linked perspectives win over inline", func(t *testing.T) { + cfg := &config.Config{} + cfg.Review.Perspectives = []string{"correctness"} + cfg.Review.PerspectivesSource = &config.DefinitionSourceConfig{Owner: "torch-spyre", Repo: "spyre-inference", Path: ".hive/review-perspectives.yaml"} + cfg.Review.LinkedPerspectives = &config.LinkedPerspectives{ + Names: []string{"security", "spyre-numerics"}, + Focus: map[string]string{"spyre-numerics": "Silent CPU fallbacks and fp16 accumulation-order changes."}, + } + got := reviewPerspectiveSet(cfg, nil).List() + want := []review.Perspective{review.PerspectiveSecurity, review.Perspective("spyre-numerics")} + if len(got) != len(want) { + t.Fatalf("List() = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("List()[%d] = %q, want %q", i, got[i], want[i]) + } + } + }) + t.Run("invalid selection with nil logger does not panic", func(t *testing.T) { cfg := &config.Config{} cfg.Review.Perspectives = []string{"sekurity"} diff --git a/src/cmd/hive/review_perspectives_source_test.go b/src/cmd/hive/review_perspectives_source_test.go new file mode 100644 index 0000000000..d9ae106839 --- /dev/null +++ b/src/cmd/hive/review_perspectives_source_test.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "errors" + "io" + "log/slog" + "testing" + + "github.com/hivecommons/hive/pkg/config" + "github.com/hivecommons/hive/pkg/github" + "github.com/hivecommons/hive/pkg/perspsrc" + "github.com/hivecommons/hive/pkg/review" +) + +type fakePerspectivesFetcher struct { + doc string + err error + calls int +} + +func (f *fakePerspectivesFetcher) GetFileContentRef(_ context.Context, _, _, _, _ string) (string, error) { + f.calls++ + return f.doc, f.err +} + +const ( + perspectivesDocA = "kind: ReviewPerspectives\nperspectives:\n - name: correctness\n - name: spyre-numerics\n focus: Check numerics on the Spyre path.\n" + perspectivesDocB = "kind: ReviewPerspectives\nperspectives:\n - name: correctness\n" +) + +func perspectivesSourceFixture(f *fakePerspectivesFetcher) (*config.Config, *perspsrc.Resolver, *github.Client, *slog.Logger) { + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + cfg := &config.Config{} + cfg.Review.PerspectivesSource = &config.DefinitionSourceConfig{Owner: "acme", Repo: "review", Path: "perspectives.yaml"} + resolver := perspsrc.NewResolver(f, func(string) bool { return true }, logger) + client := github.NewClientForTest("http://127.0.0.1:0", "acme", nil, logger) + return cfg, resolver, client, logger +} + +func relayAccepts(cfg *config.Config, logger *slog.Logger, name string) bool { + return reviewPerspectiveSet(cfg, logger).Known(review.Perspective(name)) +} + +func TestApplyReviewPerspectivesSourceReinstallsRelaySet(t *testing.T) { + f := &fakePerspectivesFetcher{doc: perspectivesDocA} + cfg, resolver, client, logger := perspectivesSourceFixture(f) + + applyReviewPerspectivesSource(context.Background(), cfg, resolver, client, logger) + if !relayAccepts(cfg, logger, "spyre-numerics") { + t.Fatal("relay set should accept spyre-numerics from doc A") + } + + f.doc = perspectivesDocB + applyReviewPerspectivesSource(context.Background(), cfg, resolver, client, logger) + if relayAccepts(cfg, logger, "spyre-numerics") { + t.Fatal("relay set still accepts spyre-numerics after doc B removed it") + } + if !relayAccepts(cfg, logger, "correctness") { + t.Fatal("relay set lost correctness") + } +} + +func TestApplyReviewPerspectivesSourceMalformedKeepsRelaySet(t *testing.T) { + f := &fakePerspectivesFetcher{doc: perspectivesDocA} + cfg, resolver, client, logger := perspectivesSourceFixture(f) + applyReviewPerspectivesSource(context.Background(), cfg, resolver, client, logger) + + f.doc = "kind: Nope\n" + applyReviewPerspectivesSource(context.Background(), cfg, resolver, client, logger) + if !relayAccepts(cfg, logger, "spyre-numerics") { + t.Fatal("malformed document dropped the last-known-good set") + } + if cfg.Review.LinkedPerspectives == nil || cfg.Review.LinkedPerspectives.Warning == "" { + t.Fatalf("want a warning on the kept set, got %+v", cfg.Review.LinkedPerspectives) + } + + f.doc, f.err = "", errors.New("boom") + applyReviewPerspectivesSource(context.Background(), cfg, resolver, client, logger) + if !relayAccepts(cfg, logger, "spyre-numerics") { + t.Fatal("fetch failure dropped the last-known-good set") + } +} + +func TestApplyReviewPerspectivesSourceUnsetMakesNoFetch(t *testing.T) { + f := &fakePerspectivesFetcher{doc: perspectivesDocA} + cfg, resolver, client, logger := perspectivesSourceFixture(f) + cfg.Review.PerspectivesSource = nil + cfg.Review.Perspectives = []string{"correctness", "security"} + + applyReviewPerspectivesSource(context.Background(), cfg, resolver, client, logger) + if f.calls != 0 { + t.Fatalf("unset perspectives_source fetched %d times", f.calls) + } + if cfg.Review.LinkedPerspectives != nil { + t.Fatalf("LinkedPerspectives = %+v, want nil", cfg.Review.LinkedPerspectives) + } + if !relayAccepts(cfg, logger, "security") || relayAccepts(cfg, logger, "spyre-numerics") { + t.Fatal("boot relay set changed") + } +} diff --git a/src/crux/agent/claude_credential_bridge.go b/src/crux/agent/claude_credential_bridge.go index c6e94c3bfa..67c08e35f8 100644 --- a/src/crux/agent/claude_credential_bridge.go +++ b/src/crux/agent/claude_credential_bridge.go @@ -586,6 +586,9 @@ func (m *Manager) rebridgeClaudeCredential(agentName, link, target, trigger stri if rep == nil { return nil } + // The fleet self-report feed deliberately ignores this exact message (a + // completed repair, expected in a burst at every token refresh): + // claudeCredentialRebridged in pkg/fleetreport/slogfeed. Keep them in sync. m.logger.Warn("claude credential bridge: a private credential file had replaced the shared-login symlink; re-bridged", "agent", agentName, "trigger", trigger, diff --git a/src/crux/agent/kick_canary_retry.go b/src/crux/agent/kick_canary_retry.go new file mode 100644 index 0000000000..99eb8be642 --- /dev/null +++ b/src/crux/agent/kick_canary_retry.go @@ -0,0 +1,61 @@ +package agent + +// A kick that opens with the ioscan exfiltration canary can be refused as a +// whole by a model that reads the canary as a prompt-injection marker +// (Claude Sonnet 5 via the Copilot CLI: "this request includes a 'SECURITY +// CANARY' marker and asks me to act as a scanner agent …"). The refusal used +// to cost the entire cadence slot β€” a 4h scanner kick, or ~150 five-minute +// supervisor kicks a day on one hosted hive. The refused kick is instead +// re-delivered once with the canary preamble stripped; the retry is a plain +// kick, so a second refusal is the model's verdict on the work itself and +// stands. + +// kickTriggerCanaryRetry is the delivery trigger of the one canary-free +// re-delivery a refused canary kick gets. recordDeliveredKickLocked uses it +// to mark the retry so it is never itself retried. +const kickTriggerCanaryRetry = "canary-refusal-retry" + +// planCanaryRetry decides from the refused kick alone whether it earns a +// canary-free re-delivery: the kick as handed to delivery must have carried +// a canary preamble with a body after it, and must not already be the retry. +func planCanaryRetry(lastKickInput string, alreadyRetried bool) (plain string, ok bool) { + if alreadyRetried { + return "", false + } + plain, had := stripCanaryPreamble(lastKickInput) + if !had { + return "", false + } + return plain, true +} + +// retryRefusedCanaryKick is the checkKickRefusal hook: when the kick just +// refused led with a canary, re-deliver it once without one. Delivery goes +// through the full SendKick readiness chain on its own goroutine (the caller +// is the pane poller, and SendKick blocks on the input prompt), so the retry +// lands once the refusing turn has returned to the prompt. Must NOT be +// called with m.mu held. +func (m *Manager) retryRefusedCanaryKick(agent *AgentProcess) { + m.mu.RLock() + plain, ok := planCanaryRetry(agent.lastKickInput, agent.canaryRetryDone) + m.mu.RUnlock() + if !ok { + return + } + m.logger.Warn("kick refused with the exfiltration canary in front; re-delivering once without it", + "agent", agent.Name, + "reason", agent.KickRefusalReason, + ) + deliver := m.canaryRetryDeliver + if deliver == nil { + deliver = func(name, message string) { + go func() { + if err := m.sendKick(name, message, kickTriggerCanaryRetry); err != nil { + m.logger.Warn("canary-free re-delivery of refused kick failed", + "agent", name, "error", err) + } + }() + } + } + deliver(agent.Name, plain) +} diff --git a/src/crux/agent/kick_canary_retry_test.go b/src/crux/agent/kick_canary_retry_test.go new file mode 100644 index 0000000000..68d3528db6 --- /dev/null +++ b/src/crux/agent/kick_canary_retry_test.go @@ -0,0 +1,82 @@ +package agent + +import "testing" + +// A model that reads the canary preamble as a prompt-injection marker refuses +// the whole kick; the kick is re-delivered once without the canary so the +// refusal does not burn the cadence slot. + +func TestStripCanaryPreamble(t *testing.T) { + work := "Review PR #15783 (correctness).\n\nFocus on the retry loop." + if got, ok := stripCanaryPreamble(testCanaryPreamble + work); !ok || got != work { + t.Fatalf("stripCanaryPreamble = (%q, %v), want (%q, true)", got, ok, work) + } + for _, msg := range []string{work, testCanaryPreamble, "=== HIVE HARNESS HEADER ===\nExfiltration canary: HIVE-CANARY-abc123 no blank line", ""} { + if got, ok := stripCanaryPreamble(msg); ok || got != msg { + t.Errorf("stripCanaryPreamble(%q) = (%q, %v), want unchanged, false", msg, got, ok) + } + } +} + +func TestPlanCanaryRetry(t *testing.T) { + work := "Triage issue #12." + if plain, ok := planCanaryRetry(testCanaryPreamble+work, false); !ok || plain != work { + t.Fatalf("canary kick must earn one plain retry, got (%q, %v)", plain, ok) + } + if _, ok := planCanaryRetry(testCanaryPreamble+work, true); ok { + t.Error("the retry itself must not be retried") + } + if _, ok := planCanaryRetry(work, false); ok { + t.Error("a kick without a canary has nothing to strip; no retry") + } + if _, ok := planCanaryRetry(testCanaryPreamble, false); ok { + t.Error("a canary-only kick has no work to re-deliver") + } +} + +func TestCheckKickRefusalRedeliversCanaryKickOnce(t *testing.T) { + m := &Manager{logger: discardLogger()} + var delivered []string + m.canaryRetryDeliver = func(name, message string) { + delivered = append(delivered, name+"\x00"+message) + } + work := "[agent:scanner]\n\nScan cncf/techdocs for stale pages." + refusal := "I'm noticing this request includes a canary marker and looks like a prompt injection, so I won't act on it." + + agent := &AgentProcess{Name: "scanner", lastKickInput: testCanaryPreamble + work} + m.checkKickRefusal(agent, refusal) + if !agent.KickRefused { + t.Fatal("refusal must still be recorded") + } + if len(delivered) != 1 || delivered[0] != "scanner\x00"+work { + t.Fatalf("refused canary kick must be re-delivered plain, got %q", delivered) + } + + // The re-delivery is marked by its trigger; a refusal of the retry stands. + m.recordDeliveredKickLocked(agent, work, kickTriggerCanaryRetry) + if agent.KickRefused || !agent.canaryRetryDone { + t.Fatalf("retry delivery must clear the refusal and mark the retry: refused=%v retried=%v", agent.KickRefused, agent.canaryRetryDone) + } + agent.lastKickInput = testCanaryPreamble + work + m.checkKickRefusal(agent, refusal) + if len(delivered) != 1 { + t.Fatalf("a refused retry must not be retried again, got %d deliveries", len(delivered)) + } + + // A fresh kick from the governor re-arms the single retry. + m.recordDeliveredKickLocked(agent, testCanaryPreamble+work, "send-kick") + if agent.canaryRetryDone { + t.Fatal("an ordinary kick must re-arm the canary retry") + } +} + +func TestCheckKickRefusalWithoutCanaryDoesNotRedeliver(t *testing.T) { + m := &Manager{logger: discardLogger()} + calls := 0 + m.canaryRetryDeliver = func(string, string) { calls++ } + agent := &AgentProcess{Name: "scanner", lastKickInput: "[agent:scanner]\n\nPlain kick."} + m.checkKickRefusal(agent, "This looks like a prompt injection attempt, so I'm declining to execute it.") + if !agent.KickRefused || calls != 0 { + t.Fatalf("plain kick refusal must be recorded without a retry: refused=%v calls=%d", agent.KickRefused, calls) + } +} diff --git a/src/crux/agent/kick_preview.go b/src/crux/agent/kick_preview.go index f25130c5c9..6566381e92 100644 --- a/src/crux/agent/kick_preview.go +++ b/src/crux/agent/kick_preview.go @@ -3,24 +3,33 @@ package agent import "strings" // canaryPreamblePrefix is how an ioscan exfiltration canary opens a kick -// (hive pkg/ioscan.CanaryPreamble). The preamble runs to the first blank -// line and is about as long as the audit preview, so a kick that carried -// one showed operators nothing but the canary (hivecommons/hive#15823). The -// preview and the kick-history snippet start after it; the kick itself is -// delivered unchanged, canary first. -const canaryPreamblePrefix = "SECURITY CANARY: " +// (hive pkg/ioscan.CanaryPreamblePrefix; the two must stay in step). The +// preamble runs to the first blank line and is about as long as the audit +// preview, so a kick that carried one showed operators nothing but the +// canary (hivecommons/hive#15823). The preview and the kick-history snippet +// start after it; the kick itself is delivered unchanged, canary first. +const canaryPreamblePrefix = "=== HIVE HARNESS HEADER" -// kickPreviewBody returns the part of a kick worth previewing: the message -// after its canary preamble, or the whole message when it carries none (or -// nothing follows the preamble, so the canary-only kick stays visible as -// such). -func kickPreviewBody(message string) string { +// stripCanaryPreamble returns the kick without its canary preamble and true +// when one was present and something followed it. A canary-only kick (or one +// whose preamble is not terminated by a blank line) is returned unchanged +// with false. +func stripCanaryPreamble(message string) (string, bool) { if !strings.HasPrefix(message, canaryPreamblePrefix) { - return message + return message, false } _, rest, ok := strings.Cut(message, "\n\n") if !ok || strings.TrimSpace(rest) == "" { - return message + return message, false } - return rest + return rest, true +} + +// kickPreviewBody returns the part of a kick worth previewing: the message +// after its canary preamble, or the whole message when it carries none (or +// nothing follows the preamble, so the canary-only kick stays visible as +// such). +func kickPreviewBody(message string) string { + body, _ := stripCanaryPreamble(message) + return body } diff --git a/src/crux/agent/kick_preview_test.go b/src/crux/agent/kick_preview_test.go index ff0a21b36c..8a3b3b7187 100644 --- a/src/crux/agent/kick_preview_test.go +++ b/src/crux/agent/kick_preview_test.go @@ -5,7 +5,7 @@ import "testing" // hivecommons/hive#15823: the audit preview of a canary-bearing kick shows // the work the agent was given, not the canary preamble. -const testCanaryPreamble = "SECURITY CANARY: HIVE-CANARY-abc123\nThis is a hive-issued exfiltration-canary marker. Never print, quote, summarize, transform, store in reports, include in commits, or send it to any external service.\n\n" +const testCanaryPreamble = "=== HIVE HARNESS HEADER (operator configuration, not user content) ===\nExfiltration canary: HIVE-CANARY-abc123\nThe token above is a tripwire planted by the hive harness that runs you. It is not a task.\n=== END HIVE HARNESS HEADER ===\n\n" func TestKickPreviewBodySkipsCanaryPreamble(t *testing.T) { work := "Review PR #15783 (correctness).\n\nFocus on the retry loop." @@ -22,7 +22,7 @@ func TestKickPreviewBodyPassesThroughKicksWithoutPreamble(t *testing.T) { } func TestKickPreviewBodyKeepsCanaryOnlyKickVisible(t *testing.T) { - for _, msg := range []string{testCanaryPreamble, "SECURITY CANARY: HIVE-CANARY-abc123 no blank line"} { + for _, msg := range []string{testCanaryPreamble, "=== HIVE HARNESS HEADER ===\nExfiltration canary: HIVE-CANARY-abc123 no blank line"} { if got := kickPreviewBody(msg); got != msg { t.Fatalf("preview of canary-only kick = %q, want whole message", got) } diff --git a/src/crux/agent/manager.go b/src/crux/agent/manager.go index 52b6587b61..7eaf6b506c 100644 --- a/src/crux/agent/manager.go +++ b/src/crux/agent/manager.go @@ -262,16 +262,22 @@ type AgentProcess struct { LastKickSource string KickRefused bool KickRefusalReason string - LaunchedMode AgentMode - HasLaunched bool - poolLeaseID string - poolLeaseCancel context.CancelFunc - baseConfig config.AgentConfig - agentSpecPrompt string - tmuxSession string - tmuxSocket string - cancel context.CancelFunc - forceRelaunch bool + // lastKickInput is the last kick as handed to delivery, before the + // backend suffixes, so a refused canary kick can be re-delivered plain + // without doubling the suffixes. canaryRetryDone is set when the current + // kick IS that re-delivery, so it is never retried again. + lastKickInput string + canaryRetryDone bool + LaunchedMode AgentMode + HasLaunched bool + poolLeaseID string + poolLeaseCancel context.CancelFunc + baseConfig config.AgentConfig + agentSpecPrompt string + tmuxSession string + tmuxSocket string + cancel context.CancelFunc + forceRelaunch bool // launching is set true under m.mu while Start runs this agent's launch // with m.mu RELEASED (so a slow /data NFS write or a hung MITM-proxy token // mint during launch cannot block AllStatuses()/the heartbeat collect() and @@ -716,6 +722,9 @@ type Manager struct { // kickOutcomeObserver receives the verdict on how each kicked turn ended // (#7421); the governor consumes it. Same discipline as kickObserver. kickOutcomeObserver atomic.Pointer[func(agentName string, outcome KickOutcome)] + // canaryRetryDeliver, when set, replaces the goroutine+sendKick path a + // refused canary kick is re-delivered through (tests inject a recorder). + canaryRetryDeliver func(name, message string) // kickDispatches tracks asynchronous kick dispatches (#5325): the in-flight // guard that makes delivery exactly-once, and the latest outcome per agent diff --git a/src/crux/agent/manager_kick.go b/src/crux/agent/manager_kick.go index 7f3774258d..14f0f89473 100644 --- a/src/crux/agent/manager_kick.go +++ b/src/crux/agent/manager_kick.go @@ -111,6 +111,12 @@ func (m *Manager) SendKick(name string, message string) error { return m.SendKickWithSource(name, message, "") } +// sendKick is SendKick with the delivery trigger spelled out; the trigger is +// what the audit log, prompt history and the canary-retry bookkeeping see. +func (m *Manager) sendKick(name, message, trigger string) error { + return m.SendKickWithSource(name, message, trigger) +} + func (m *Manager) SendKickWithSource(name string, message string, source string) (err error) { // Agent-kick span. No-op with zero export cost when tracing is disabled // (the default). SendKick has no context parameter, so this span roots at @@ -291,6 +297,7 @@ func (m *Manager) deliverKickLocked(agent *AgentProcess, message, trigger string return fmt.Errorf("%w: agent %s kick delivery already in progress", crux.ErrAgentBusy, agent.Name) } defer agent.kickDelivering.Store(false) + agent.lastKickInput = message visible := m.captureVisiblePaneForAgent(agent) if err := m.checkKickPromptReadyLocked(agent, visible, trigger); err != nil { @@ -473,6 +480,7 @@ func (m *Manager) recordDeliveredKickLocked(agent *AgentProcess, message, trigge agent.LastKickSource = trigger agent.KickRefused = false agent.KickRefusalReason = "" + agent.canaryRetryDone = trigger == kickTriggerCanaryRetry // The session now holds this kick's output; the next rotation point // (kick, restart, shutdown) must archive it before it is destroyed. agent.kickLogPending = true diff --git a/src/crux/agent/manager_poll.go b/src/crux/agent/manager_poll.go index 0065b1a7c0..f32bf2b7a2 100644 --- a/src/crux/agent/manager_poll.go +++ b/src/crux/agent/manager_poll.go @@ -601,6 +601,7 @@ func (m *Manager) checkKickRefusal(agent *AgentProcess, line string) { "pattern", pattern, "line", reason, ) + m.retryRefusedCanaryKick(agent) return } } diff --git a/src/crux/proxy/github_proxy.go b/src/crux/proxy/github_proxy.go index c47abfedf8..5b616e62b2 100644 --- a/src/crux/proxy/github_proxy.go +++ b/src/crux/proxy/github_proxy.go @@ -86,6 +86,16 @@ type GitHubProxy struct { logger *slog.Logger uidMap *agent.UIDMap allowedRepos map[string]bool + // Reference repos (#18015): study-only repositories outside allowedRepos, + // read-only by default with a per-entry issues/prs opt-in. Reads to them + // are forwarded without the hive's credential; writes are gated by + // referenceRepoRefusal. referenceDefaults caches each repo's default + // branch for the push guard; referenceLookup is the GET those checks use + // (nil = defaultReferenceLookup; tests inject one). + referenceMu sync.RWMutex + referenceRepos map[string]ReferenceRepoPolicy + referenceDefaults map[string]referenceDefaultBranch + referenceLookup referenceLookupFunc // repoPaused reports whether a repo ("owner/repo", as ExtractRepo yields it) // is under an operator pause (#6203). Guarded because the dashboard can diff --git a/src/crux/proxy/github_proxy_mitm.go b/src/crux/proxy/github_proxy_mitm.go index da81467ffe..d750bd976e 100644 --- a/src/crux/proxy/github_proxy_mitm.go +++ b/src/crux/proxy/github_proxy_mitm.go @@ -77,7 +77,29 @@ func (p *GitHubProxy) proxyHTTPHost(client net.Conn, upstream net.Conn, host str // refused and audited as agent_write_refused. Checked after pause and // scope so their more specific reasons win when both apply. writeSurfaceKind := "" + // Reference repos (#18015) sit outside every list below: not in + // allowedRepos (so the mode chain's repo filter would refuse the + // write with the wrong reason), not pausable, not agent-scoped. The + // gate runs first so its refusal β€” naming reference_repos and the + // tier β€” is what the agent reads, and an allowed opt-in write is + // remembered so the repo filter and the PR hard-deny below do not + // undo it (the credential it will carry is the reference repo's + // own, so App-bot authorship is already guaranteed). + referenceWrite := false if agentName != internalCallerName { + if pol, isRef := p.referenceRepoFor(repoScopePath(req)); isRef { + if reason, refused := p.referenceRepoRefusal(req); refused { + blocked = true + blockReason = reason + blockCode = DenyReasonReferenceRepo + } else { + referenceWrite = referenceWriteCredential(pol, req.Method, req.URL.Path, req.URL.RawQuery) + } + } + } + if blocked { + // Refused as a reference-repo write; nothing below can un-block it. + } else if agentName != internalCallerName { if reason, paused := p.repoPauseRefusal(req.Method, repoScopePath(req)); paused { blocked = true blockReason = reason @@ -165,6 +187,12 @@ func (p *GitHubProxy) proxyHTTPHost(client net.Conn, upstream net.Conn, host str // autonomy. Its control-plane calls (App token mint, heartbeat) // must not be gated as if they were agent writes. Repo-filter and // canary-egress checks below still apply. + } else if referenceWrite && !referenceWriteAllowedByMode(mode, req.Method, req.URL.Path) { + blocked = true + blockCode = DenyReasonMode + } else if referenceWrite { + // Opt-in reference write at a sufficient mode: skip the PR + // hard-deny and the repo filter, both of which assume a managed repo. } else if !AllowedByModeCaps(autonomyModeForRepo(agentName, ExtractRepo(repoScopePath(req)), mode), caps, req.Method, req.URL.Path) { blocked = true blockCode = DenyReasonMode diff --git a/src/crux/proxy/github_proxy_policy.go b/src/crux/proxy/github_proxy_policy.go index b61b8d03ab..2d60d115e4 100644 --- a/src/crux/proxy/github_proxy_policy.go +++ b/src/crux/proxy/github_proxy_policy.go @@ -180,6 +180,17 @@ const loginPathPrefix = "/login/" func (p *GitHubProxy) rewriteGitHubAuth(req *http.Request, agentName string) { // NOTE: callers must gate on injectsGitHubAuthForHost first; this function // does not know which host the request is bound for. + if agentName != internalCallerName { + if pol, isRef := p.referenceRepoFor(repoScopePath(req)); isRef { + // Reference repo (#18015): the agent's own credential belongs to + // the hive's installation and is never valid here, so it is + // stripped whatever the injection flag says. A read goes out + // unauthenticated, as the public internet would send it; an + // opt-in write carries the repo's own write credential. + p.rewriteReferenceRepoAuth(req, agentName, pol) + return + } + } if !p.injectGHAuth { // Flag off (the default): behavior byte-identical to before #1861 β€” // no header is touched on any path. @@ -372,3 +383,28 @@ func isGitPath(path string) bool { strings.HasSuffix(path, "/git-upload-pack") || strings.HasSuffix(path, "/info/refs") } + +func (p *GitHubProxy) rewriteReferenceRepoAuth(req *http.Request, agentName string, pol ReferenceRepoPolicy) { + req.Header.Del("Authorization") + if isGitPath(req.URL.Path) { + // Same reason as the managed-repo git branch above: each git request + // must re-enter this rewrite on its own connection. + req.Close = true + } + if !referenceWriteCredential(pol, req.Method, req.URL.Path, req.URL.RawQuery) { + p.logger.Debug("proxy auth: reference repo read forwarded without credentials", "agent", agentName, "repo", pol.Repo, "injected", false) + return + } + token := p.referenceToken(pol) + if token == "" { + p.logger.Warn("proxy auth: reference repo write has no credential β€” forwarding without one (check the write_token_file or the App installation on the target org)", "agent", agentName, "repo", pol.Repo, "injected", false) + return + } + if isGitPath(req.URL.Path) { + basic := base64.StdEncoding.EncodeToString([]byte(gitInjectBasicUser + ":" + token)) + req.Header.Set("Authorization", "Basic "+basic) + } else { + req.Header.Set("Authorization", "token "+token) + } + p.logger.Debug("proxy auth: reference repo write credential attached", "agent", agentName, "repo", pol.Repo, "injected", true) +} diff --git a/src/crux/proxy/reference_repos.go b/src/crux/proxy/reference_repos.go new file mode 100644 index 0000000000..be69c0c531 --- /dev/null +++ b/src/crux/proxy/reference_repos.go @@ -0,0 +1,406 @@ +package proxy + +import ( + "bufio" + "bytes" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/hivecommons/hive/crux/agent" +) + +// Reference repositories (hivecommons/hive#18015). +// +// A reference repo is one the hive's agents may STUDY without managing it: +// project.reference_repos. Reads β€” git clone/fetch and GitHub GETs β€” pass, and +// pass WITHOUT the hive's credential: the agent's injected token belongs to the +// hive's own installation and is not valid for a foreign repo, so it is +// stripped and the request goes out as the public internet would send it. +// +// Writes are refused unless the entry opted in, and even then only to the +// narrow surface an assessor needs: open an issue, open a PR from a topic +// branch, comment on the hive's OWN issue or PR. Merging, pushing the default +// branch, labels/assignees/closing on anyone's items and reviews are never +// allowed β€” there is no mode, capability or relay that widens this. The write +// credential is the policy's own Token source (an installation token minted +// on the TARGET org, or an operator-supplied file), never the hive's token. + +// DenyReasonReferenceRepo is the 403 reason code for a refused reference-repo +// write (see denial_message.go for the vocabulary). +const DenyReasonReferenceRepo = "reference_repo" + +// ReferenceRepoPolicy is the proxy's view of one project.reference_repos entry. +type ReferenceRepoPolicy struct { + // Repo is the "owner/name" slug. + Repo string + // Issues and PRs are the write opt-ins. + Issues, PRs bool + // Token yields the write credential for Repo. nil for a read-only entry. + Token func() (string, bool) + // Login is the GitHub login the Token acts as ("[bot]" or the PAT's + // user); own-item checks compare an item's author against it. + Login string +} + +func (r ReferenceRepoPolicy) readOnly() bool { return !r.Issues && !r.PRs } + +func (r ReferenceRepoPolicy) tier() string { + switch { + case r.Issues && r.PRs: + return "issues+prs" + case r.Issues: + return "issues" + case r.PRs: + return "prs" + } + return "read-only" +} + +// referenceDecision is the outcome of the path-only part of the write check. +type referenceDecision int + +const ( + referenceNotReference referenceDecision = iota // not a reference repo, or a read: nothing to do + referenceAllow + referenceDeny + referenceOwnItem // allowed only if item #N was authored by the policy's Login + referencePush // git push: allowed only when no ref targets the default branch +) + +var ( + refIssuesPath = regexp.MustCompile(`^/repos/[^/]+/[^/]+/issues$`) + refPullsPath = regexp.MustCompile(`^/repos/[^/]+/[^/]+/pulls$`) + refItemCommentsPath = regexp.MustCompile(`^/repos/[^/]+/[^/]+/(?:issues|pulls)/(\d+)/comments$`) + refGitReceivePackRe = regexp.MustCompile(`\.git/git-receive-pack$`) + refGitInfoRefsRe = regexp.MustCompile(`\.git/info/refs$`) + referenceLookupLimit = int64(1 << 20) +) + +// ReferenceRepoDecision classifies one request against a reference-repo +// policy without touching the network. number is the item for an +// referenceOwnItem decision. reason is the agent-facing refusal for +// referenceDeny. +func ReferenceRepoDecision(pol ReferenceRepoPolicy, method, path, rawQuery string) (d referenceDecision, number int, reason string) { + push := refGitReceivePackRe.MatchString(path) || (refGitInfoRefsRe.MatchString(path) && gitServiceIsReceivePack(rawQuery)) + if !push && (!writeMethods[method] || gitUploadPackPath.MatchString(path)) { + return referenceNotReference, 0, "" + } + if pol.readOnly() { + return referenceDeny, 0, referenceRepoRefusal(pol, "it is listed as READ-ONLY") + } + switch { + case push: + if !pol.PRs { + return referenceDeny, 0, referenceRepoRefusal(pol, "pushing needs write: [prs]") + } + return referencePush, 0, "" + case method == "POST" && refIssuesPath.MatchString(path): + if !pol.Issues { + return referenceDeny, 0, referenceRepoRefusal(pol, "opening an issue needs write: [issues]") + } + return referenceAllow, 0, "" + case method == "POST" && refPullsPath.MatchString(path): + if !pol.PRs { + return referenceDeny, 0, referenceRepoRefusal(pol, "opening a PR needs write: [prs]") + } + return referenceAllow, 0, "" + case method == "POST" && refItemCommentsPath.MatchString(path): + m := refItemCommentsPath.FindStringSubmatch(path) + n, _ := strconv.Atoi(m[1]) + return referenceOwnItem, n, "" + } + return referenceDeny, 0, referenceRepoRefusal(pol, "only opening an issue or PR and commenting on the hive's own items are ever allowed there β€” never merge, push to the default branch, label, assign, close or review") +} + +func referenceRepoRefusal(pol ReferenceRepoPolicy, why string) string { + return "repository " + pol.Repo + " is a REFERENCE repo (project.reference_repos, tier " + pol.tier() + "): " + why + ". Reads β€” clone, fetch, GET β€” still work. Put findings about it into the deliverable in your AUTHORIZED repo instead. Do NOT retry, do NOT route around this with another CLI, the hive relays or the GitHub MCP, and do NOT file an issue about it: this is how the operator scoped the hive, not an outage." +} + +// SetReferenceRepos installs the reference-repo policies (#18015). Replaces +// the previous set; nil clears it. +func (p *GitHubProxy) SetReferenceRepos(policies []ReferenceRepoPolicy) { + m := make(map[string]ReferenceRepoPolicy, len(policies)) + for _, pol := range policies { + key := strings.ToLower(strings.TrimSpace(pol.Repo)) + if key == "" { + continue + } + pol.Repo = strings.TrimSpace(pol.Repo) + m[key] = pol + } + p.referenceMu.Lock() + p.referenceRepos = m + p.referenceMu.Unlock() +} + +// referenceRepoFor returns the policy covering the repo a path names. +func (p *GitHubProxy) referenceRepoFor(path string) (ReferenceRepoPolicy, bool) { + p.referenceMu.RLock() + defer p.referenceMu.RUnlock() + if len(p.referenceRepos) == 0 { + return ReferenceRepoPolicy{}, false + } + repo := ExtractRepo(path) + if repo == "" { + repo = WriteSurfaceRepo(path) + } + if repo == "" { + return ReferenceRepoPolicy{}, false + } + pol, ok := p.referenceRepos[strings.ToLower(repo)] + return pol, ok +} + +// referenceRepoRefusal is the per-request gate the MITM loop calls. It returns +// the refusal reason and true when the request must be blocked. The own-item +// and default-branch checks consult GitHub through p.referenceLookup; a lookup +// that fails refuses (fail closed) and says so. +func (p *GitHubProxy) referenceRepoRefusal(req *http.Request) (string, bool) { + pol, ok := p.referenceRepoFor(repoScopePath(req)) + if !ok { + return "", false + } + d, number, reason := ReferenceRepoDecision(pol, req.Method, req.URL.Path, req.URL.RawQuery) + switch d { + case referenceNotReference, referenceAllow: + return "", false + case referenceDeny: + return reason, true + case referenceOwnItem: + author, err := p.referenceItemAuthor(req, pol, number) + if err != nil { + return referenceRepoRefusal(pol, fmt.Sprintf("could not confirm #%d is the hive's own item (%v)", number, err)), true + } + if pol.Login == "" || !strings.EqualFold(author, pol.Login) { + return referenceRepoRefusal(pol, fmt.Sprintf("#%d was opened by %s, not by the hive (%s); comments are allowed only on the hive's own issues and PRs", number, author, pol.Login)), true + } + return "", false + case referencePush: + if refGitInfoRefsRe.MatchString(req.URL.Path) { + return "", false // ref advertisement; the refs travel in the POST + } + def, err := p.referenceDefaultBranch(req, pol) + if err != nil { + return referenceRepoRefusal(pol, fmt.Sprintf("could not resolve its default branch to protect it (%v)", err)), true + } + refs, err := peekReceivePackRefs(req) + if err != nil { + return referenceRepoRefusal(pol, "push command list unreadable ("+err.Error()+")"), true + } + for _, ref := range refs { + if ref == "refs/heads/"+def { + return referenceRepoRefusal(pol, "pushing to its default branch ("+def+") is never allowed β€” push a topic branch and open a PR"), true + } + } + return "", false + } + return "", false +} + +// referenceWriteCredential says whether a request to a reference repo should +// carry the policy's write token (a write, or the push ref advertisement) β€” +// every other request to a reference repo goes out unauthenticated. +func referenceWriteCredential(pol ReferenceRepoPolicy, method, path, rawQuery string) bool { + if pol.Token == nil { + return false + } + d, _, _ := ReferenceRepoDecision(pol, method, path, rawQuery) + return d != referenceNotReference && d != referenceDeny +} + +// referenceAPIBase derives the REST base for the forge host a request is bound +// for: api.github.com for github.com, /api/v3 for GHE. +func referenceAPIBase(req *http.Request) string { + host := canonicalHost(req.Host) + if host == "" { + host = canonicalHost(req.URL.Host) + } + switch host { + case "", "github.com", "api.github.com": + return "https://api.github.com" + } + return "https://" + host + "/api/v3" +} + +// referenceLookupFunc is the GET the own-item and default-branch checks use. +// Tests swap it; production uses an ordinary client from this process. +type referenceLookupFunc func(url, token string) ([]byte, error) + +func defaultReferenceLookup(url, token string) ([]byte, error) { + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/vnd.github+json") + if token != "" { + req.Header.Set("Authorization", "token "+token) + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, referenceLookupLimit)) + if err != nil { + return nil, err + } + if resp.StatusCode/100 != 2 { + return nil, fmt.Errorf("GitHub answered %d", resp.StatusCode) + } + return body, nil +} + +func (p *GitHubProxy) referenceLookupOrDefault() referenceLookupFunc { + if p.referenceLookup != nil { + return p.referenceLookup + } + return defaultReferenceLookup +} + +func (p *GitHubProxy) referenceToken(pol ReferenceRepoPolicy) string { + if pol.Token == nil { + return "" + } + tok, ok := pol.Token() + if !ok { + return "" + } + return tok +} + +func (p *GitHubProxy) referenceItemAuthor(req *http.Request, pol ReferenceRepoPolicy, number int) (string, error) { + body, err := p.referenceLookupOrDefault()(referenceAPIBase(req)+"/repos/"+pol.Repo+"/issues/"+strconv.Itoa(number), p.referenceToken(pol)) + if err != nil { + return "", err + } + var item struct { + User struct { + Login string `json:"login"` + } `json:"user"` + } + if err := json.Unmarshal(body, &item); err != nil { + return "", err + } + if item.User.Login == "" { + return "", fmt.Errorf("item has no author") + } + return item.User.Login, nil +} + +// referenceDefaultBranchTTL bounds how long a resolved default branch is +// trusted before it is looked up again. +const referenceDefaultBranchTTL = 10 * time.Minute + +func (p *GitHubProxy) referenceDefaultBranch(req *http.Request, pol ReferenceRepoPolicy) (string, error) { + key := strings.ToLower(pol.Repo) + p.referenceMu.RLock() + cached, ok := p.referenceDefaults[key] + p.referenceMu.RUnlock() + if ok && time.Now().Before(cached.until) { + return cached.branch, nil + } + body, err := p.referenceLookupOrDefault()(referenceAPIBase(req)+"/repos/"+pol.Repo, p.referenceToken(pol)) + if err != nil { + return "", err + } + var meta struct { + DefaultBranch string `json:"default_branch"` + } + if err := json.Unmarshal(body, &meta); err != nil { + return "", err + } + if meta.DefaultBranch == "" { + return "", fmt.Errorf("repository metadata carries no default_branch") + } + p.referenceMu.Lock() + if p.referenceDefaults == nil { + p.referenceDefaults = map[string]referenceDefaultBranch{} + } + p.referenceDefaults[key] = referenceDefaultBranch{branch: meta.DefaultBranch, until: time.Now().Add(referenceDefaultBranchTTL)} + p.referenceMu.Unlock() + return meta.DefaultBranch, nil +} + +type referenceDefaultBranch struct { + branch string + until time.Time +} + +// receivePackPeekLimit bounds the command section read from a push body. The +// command list precedes the packfile and is a handful of lines; anything +// larger is not a push shape this gate will reason about. +const receivePackPeekLimit = 64 * 1024 + +// peekReceivePackRefs reads the pkt-line command section of a git-receive-pack +// body β€” " [\0caps]" lines up to the flush β€” returns the refs +// it names, and puts every byte back so the request forwards unchanged. A +// content-encoded body cannot be read here and is an error (fail closed). +func peekReceivePackRefs(req *http.Request) ([]string, error) { + if req.Body == nil { + return nil, fmt.Errorf("no body") + } + if enc := req.Header.Get("Content-Encoding"); enc != "" && enc != "identity" { + return nil, fmt.Errorf("content-encoding %q", enc) + } + br := bufio.NewReader(io.LimitReader(req.Body, receivePackPeekLimit)) + var consumed bytes.Buffer + tee := io.TeeReader(br, &consumed) + var refs []string + for { + var hdr [4]byte + if _, err := io.ReadFull(tee, hdr[:]); err != nil { + return nil, fmt.Errorf("pkt-line header: %w", err) + } + n, err := hex.DecodeString(string(hdr[:])) + if err != nil { + return nil, fmt.Errorf("pkt-line length %q", hdr) + } + size := int(n[0])<<8 | int(n[1]) + if size == 0 { + break // flush: end of commands + } + if size < 4 { + return nil, fmt.Errorf("pkt-line length %d", size) + } + line := make([]byte, size-4) + if _, err := io.ReadFull(tee, line); err != nil { + return nil, fmt.Errorf("pkt-line body: %w", err) + } + if i := bytes.IndexByte(line, 0); i >= 0 { + line = line[:i] + } + fields := strings.Fields(string(line)) + if len(fields) != 3 { + return nil, fmt.Errorf("malformed command %q", line) + } + refs = append(refs, fields[2]) + } + // Whatever bufio read ahead past the flush belongs to the packfile; it is + // also in neither consumed nor req.Body any more, so splice it back too. + rest, _ := br.Peek(br.Buffered()) + req.Body = io.NopCloser(io.MultiReader(bytes.NewReader(consumed.Bytes()), bytes.NewReader(append([]byte(nil), rest...)), req.Body)) + return refs, nil +} + +// referenceWriteAllowedByMode applies the agent's ACMM mode to an opt-in +// reference write: the per-repo opt-in widens WHERE an agent may write, never +// WHAT a mode lets it do. Opening an issue or commenting needs ISSUES_ONLY; +// pushing a branch or opening a PR needs ISSUES_AND_PRS, exactly as on a +// managed repo (the PR hard-deny that routes managed-repo PRs through +// hive-open-pr does not apply β€” the relay cannot act on a foreign repo, and +// the reference credential is the App bot's own). +func referenceWriteAllowedByMode(mode agent.AgentMode, method, path string) bool { + switch { + case refGitReceivePackRe.MatchString(path), refGitInfoRefsRe.MatchString(path), refPullsPath.MatchString(path): + return mode >= agent.ModeIssuesAndPRs + } + return mode >= agent.ModeIssuesOnly +} diff --git a/src/crux/proxy/reference_repos_test.go b/src/crux/proxy/reference_repos_test.go new file mode 100644 index 0000000000..546ed31470 --- /dev/null +++ b/src/crux/proxy/reference_repos_test.go @@ -0,0 +1,237 @@ +package proxy + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "testing" + + "github.com/hivecommons/hive/crux/agent" +) + +func newReferenceTestProxy(t *testing.T, pol ReferenceRepoPolicy, lookup referenceLookupFunc) *GitHubProxy { + t.Helper() + p := &GitHubProxy{logger: slog.New(slog.NewTextHandler(io.Discard, nil))} + p.SetReferenceRepos([]ReferenceRepoPolicy{pol}) + p.referenceLookup = lookup + return p +} + +func refReq(method, path, rawQuery string, body []byte) *http.Request { + req, _ := http.NewRequest(method, "https://api.github.com"+path, bytes.NewReader(body)) + req.URL.RawQuery = rawQuery + req.Host = "api.github.com" + return req +} + +// pktLine encodes one git pkt-line. +func pktLine(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) } + +func TestReferenceRepoDecision_Matrix(t *testing.T) { + ro := ReferenceRepoPolicy{Repo: "hivecommons/docs"} + issues := ReferenceRepoPolicy{Repo: "hivecommons/docs", Issues: true} + prs := ReferenceRepoPolicy{Repo: "hivecommons/docs", PRs: true} + both := ReferenceRepoPolicy{Repo: "hivecommons/docs", Issues: true, PRs: true} + + cases := []struct { + name string + pol ReferenceRepoPolicy + method string + path string + query string + want referenceDecision + }{ + // Reads pass for every tier. + {"ro GET", ro, "GET", "/repos/hivecommons/docs/contents/README.md", "", referenceNotReference}, + {"ro fetch", ro, "POST", "/hivecommons/docs.git/git-upload-pack", "", referenceNotReference}, + {"ro info/refs upload", ro, "GET", "/hivecommons/docs.git/info/refs", "service=git-upload-pack", referenceNotReference}, + // Read-only refuses every write, including the push advertisement. + {"ro issue", ro, "POST", "/repos/hivecommons/docs/issues", "", referenceDeny}, + {"ro comment", ro, "POST", "/repos/hivecommons/docs/issues/3/comments", "", referenceDeny}, + {"ro push adv", ro, "GET", "/hivecommons/docs.git/info/refs", "service=git-receive-pack", referenceDeny}, + {"ro push", ro, "POST", "/hivecommons/docs.git/git-receive-pack", "", referenceDeny}, + {"ro fork", ro, "POST", "/repos/hivecommons/docs/forks", "", referenceDeny}, + // issues tier. + {"issues open", issues, "POST", "/repos/hivecommons/docs/issues", "", referenceAllow}, + {"issues comment own-check", issues, "POST", "/repos/hivecommons/docs/issues/3/comments", "", referenceOwnItem}, + {"issues pr comment own-check", issues, "POST", "/repos/hivecommons/docs/pulls/9/comments", "", referenceOwnItem}, + {"issues edit", issues, "PATCH", "/repos/hivecommons/docs/issues/3", "", referenceDeny}, + {"issues label", issues, "POST", "/repos/hivecommons/docs/issues/3/labels", "", referenceDeny}, + {"issues assign", issues, "POST", "/repos/hivecommons/docs/issues/3/assignees", "", referenceDeny}, + {"issues open pr", issues, "POST", "/repos/hivecommons/docs/pulls", "", referenceDeny}, + {"issues push", issues, "POST", "/hivecommons/docs.git/git-receive-pack", "", referenceDeny}, + // prs tier. + {"prs open pr", prs, "POST", "/repos/hivecommons/docs/pulls", "", referenceAllow}, + {"prs push adv", prs, "GET", "/hivecommons/docs.git/info/refs", "service=git-receive-pack", referencePush}, + {"prs push", prs, "POST", "/hivecommons/docs.git/git-receive-pack", "", referencePush}, + {"prs open issue", prs, "POST", "/repos/hivecommons/docs/issues", "", referenceDeny}, + {"prs merge", prs, "PUT", "/repos/hivecommons/docs/pulls/9/merge", "", referenceDeny}, + {"prs review", prs, "POST", "/repos/hivecommons/docs/pulls/9/reviews", "", referenceDeny}, + {"prs edit pr", prs, "PATCH", "/repos/hivecommons/docs/pulls/9", "", referenceDeny}, + {"prs api ref", prs, "POST", "/repos/hivecommons/docs/git/refs", "", referenceDeny}, + {"prs delete ref", prs, "DELETE", "/repos/hivecommons/docs/git/refs/heads/x", "", referenceDeny}, + {"prs contents", prs, "PUT", "/repos/hivecommons/docs/contents/x.md", "", referenceDeny}, + // both. + {"both issue", both, "POST", "/repos/hivecommons/docs/issues", "", referenceAllow}, + {"both pr", both, "POST", "/repos/hivecommons/docs/pulls", "", referenceAllow}, + {"both merge", both, "PUT", "/repos/hivecommons/docs/pulls/9/merge", "", referenceDeny}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, _, reason := ReferenceRepoDecision(tc.pol, tc.method, tc.path, tc.query) + if got != tc.want { + t.Fatalf("decision=%v want %v (reason %q)", got, tc.want, reason) + } + if got == referenceDeny && (!strings.Contains(reason, "reference_repos") || !strings.Contains(reason, tc.pol.tier())) { + t.Fatalf("refusal must name reference_repos and the tier: %q", reason) + } + }) + } +} + +func TestReferenceRepoRefusal_OwnItemCheck(t *testing.T) { + pol := ReferenceRepoPolicy{Repo: "hivecommons/docs", Issues: true, Login: "hivecommons-hive[bot]", Token: func() (string, bool) { return "ref-tok", true }} + var gotURL, gotToken string + author := "hivecommons-hive[bot]" + p := newReferenceTestProxy(t, pol, func(url, token string) ([]byte, error) { + gotURL, gotToken = url, token + return []byte(`{"user":{"login":"` + author + `"}}`), nil + }) + + if reason, refused := p.referenceRepoRefusal(refReq("POST", "/repos/hivecommons/docs/issues/12/comments", "", nil)); refused { + t.Fatalf("comment on own issue refused: %s", reason) + } + if gotURL != "https://api.github.com/repos/hivecommons/docs/issues/12" || gotToken != "ref-tok" { + t.Fatalf("lookup url=%q token=%q", gotURL, gotToken) + } + + author = "someone-else" + reason, refused := p.referenceRepoRefusal(refReq("POST", "/repos/hivecommons/docs/issues/12/comments", "", nil)) + if !refused || !strings.Contains(reason, "someone-else") { + t.Fatalf("comment on another's issue must be refused naming the author, got refused=%v %q", refused, reason) + } + + p.referenceLookup = func(string, string) ([]byte, error) { return nil, fmt.Errorf("boom") } + if _, refused := p.referenceRepoRefusal(refReq("POST", "/repos/hivecommons/docs/issues/12/comments", "", nil)); !refused { + t.Fatal("a failed author lookup must fail closed") + } + + // A different repo is not a reference repo at all. + if _, refused := p.referenceRepoRefusal(refReq("POST", "/repos/cncf/techdocs/issues/1/comments", "", nil)); refused { + t.Fatal("non-reference repo must not be gated here") + } +} + +func TestReferenceRepoRefusal_PushGuardsDefaultBranch(t *testing.T) { + pol := ReferenceRepoPolicy{Repo: "hivecommons/docs", PRs: true, Token: func() (string, bool) { return "ref-tok", true }} + lookups := 0 + p := newReferenceTestProxy(t, pol, func(url, token string) ([]byte, error) { + lookups++ + if url != "https://api.github.com/repos/hivecommons/docs" { + t.Fatalf("unexpected lookup %s", url) + } + return []byte(`{"default_branch":"main"}`), nil + }) + zero := strings.Repeat("0", 40) + sha := strings.Repeat("a", 40) + pack := "PACK-bytes-follow" + + topic := pktLine(zero+" "+sha+" refs/heads/hive/assessment\x00report-status side-band-64k") + "0000" + pack + req := refReq("POST", "/hivecommons/docs.git/git-receive-pack", "", []byte(topic)) + if reason, refused := p.referenceRepoRefusal(req); refused { + t.Fatalf("topic-branch push refused: %s", reason) + } + rest, _ := io.ReadAll(req.Body) + if string(rest) != topic { + t.Fatalf("push body not restored byte-for-byte:\n got %q\nwant %q", rest, topic) + } + + def := pktLine(zero+" "+sha+" refs/heads/hive/x\x00caps") + pktLine(sha+" "+sha+" refs/heads/main") + "0000" + pack + reason, refused := p.referenceRepoRefusal(refReq("POST", "/hivecommons/docs.git/git-receive-pack", "", []byte(def))) + if !refused || !strings.Contains(reason, "default branch (main)") { + t.Fatalf("default-branch push must be refused, got refused=%v %q", refused, reason) + } + if lookups != 1 { + t.Fatalf("default branch should be cached after one lookup, got %d", lookups) + } + + gz := refReq("POST", "/hivecommons/docs.git/git-receive-pack", "", []byte(topic)) + gz.Header.Set("Content-Encoding", "gzip") + if _, refused := p.referenceRepoRefusal(gz); !refused { + t.Fatal("an encoded push body the guard cannot read must fail closed") + } + + if _, refused := p.referenceRepoRefusal(refReq("GET", "/hivecommons/docs.git/info/refs", "service=git-receive-pack", nil)); refused { + t.Fatal("push ref advertisement must pass for a prs entry") + } +} + +func TestReferenceRepoAuth_StripsOnReadsInjectsOnWrites(t *testing.T) { + pol := ReferenceRepoPolicy{Repo: "hivecommons/docs", Issues: true, PRs: true, Token: func() (string, bool) { return "ref-tok", true }} + p := newReferenceTestProxy(t, pol, nil) + p.injectGHAuth = true + p.agentTokenSource = func(string) (string, bool) { return "hive-app-token", true } + + read := refReq("GET", "/repos/hivecommons/docs/contents/README.md", "", nil) + read.Header.Set("Authorization", "token agent-held") + p.rewriteGitHubAuth(read, "scanner") + if got := read.Header.Get("Authorization"); got != "" { + t.Fatalf("reference read must go out unauthenticated, got %q", got) + } + + fetch := refReq("POST", "/hivecommons/docs.git/git-upload-pack", "", nil) + fetch.Header.Set("Authorization", "Basic xyz") + p.rewriteGitHubAuth(fetch, "scanner") + if got := fetch.Header.Get("Authorization"); got != "" { + t.Fatalf("reference fetch must go out unauthenticated, got %q", got) + } + + write := refReq("POST", "/repos/hivecommons/docs/issues", "", nil) + write.Header.Set("Authorization", "token agent-held") + p.rewriteGitHubAuth(write, "scanner") + if got := write.Header.Get("Authorization"); got != "token ref-tok" { + t.Fatalf("reference write must carry the reference credential, got %q", got) + } + + push := refReq("GET", "/hivecommons/docs.git/info/refs", "service=git-receive-pack", nil) + p.rewriteGitHubAuth(push, "scanner") + want := "Basic " + base64.StdEncoding.EncodeToString([]byte(gitInjectBasicUser+":ref-tok")) + if got := push.Header.Get("Authorization"); got != want || !push.Close { + t.Fatalf("push advertisement must carry basic reference credential and close, got %q close=%v", got, push.Close) + } + + // A managed repo still takes the ordinary injection path. + managed := refReq("GET", "/repos/cncf/techdocs/issues", "", nil) + p.rewriteGitHubAuth(managed, "scanner") + if got := managed.Header.Get("Authorization"); got != "token hive-app-token" { + t.Fatalf("managed repo should get the agent token, got %q", got) + } + + // Read-only entries never carry anything, even on a (refused upstream) write. + p.SetReferenceRepos([]ReferenceRepoPolicy{{Repo: "hivecommons/docs"}}) + ro := refReq("POST", "/repos/hivecommons/docs/issues", "", nil) + ro.Header.Set("Authorization", "token agent-held") + p.rewriteGitHubAuth(ro, "scanner") + if got := ro.Header.Get("Authorization"); got != "" { + t.Fatalf("read-only reference repo must never carry a credential, got %q", got) + } +} + +func TestReferenceWriteAllowedByMode(t *testing.T) { + if referenceWriteAllowedByMode(agent.ModeAdvisory, "POST", "/repos/o/r/issues") { + t.Error("ADVISORY may not open issues on a reference repo") + } + if !referenceWriteAllowedByMode(agent.ModeIssuesOnly, "POST", "/repos/o/r/issues") { + t.Error("ISSUES_ONLY may open issues") + } + if referenceWriteAllowedByMode(agent.ModeIssuesOnly, "POST", "/repos/o/r/pulls") { + t.Error("ISSUES_ONLY may not open PRs") + } + if !referenceWriteAllowedByMode(agent.ModeIssuesAndPRs, "POST", "/o/r.git/git-receive-pack") { + t.Error("ISSUES_AND_PRS may push a topic branch") + } +} diff --git a/src/deploy/ci-runners/Dockerfile b/src/deploy/ci-runners/Dockerfile index 817b9fa0f6..f3c0d780d2 100644 --- a/src/deploy/ci-runners/Dockerfile +++ b/src/deploy/ci-runners/Dockerfile @@ -48,7 +48,7 @@ ARG RUNNER_BASE_IMAGE=summerwind/actions-runner:v2.337.0-ubuntu-24.04 FROM ${RUNNER_BASE_IMAGE} -ARG GITHUB_CLI_VERSION=2.103.3 +ARG GITHUB_CLI_VERSION=2.104.0 ARG GITHUB_CLI_KEYRING_SHA256=6084d5d7bd8e288441e0e94fc6275570895da18e6751f70f057485dc2d1a811b # The base image runs as uid 1001 (`runner`), which does not own /etc/apt. diff --git a/src/docs/admin-mcp.md b/src/docs/admin-mcp.md index eb1d418fa5..8ed78d7530 100644 --- a/src/docs/admin-mcp.md +++ b/src/docs/admin-mcp.md @@ -495,6 +495,9 @@ Not reachable: credentials (`config_effective_read` is the vetted view of the effective configuration and its provenance); - an agent's export bundle (the `agent` and `agent_prompt` sections hold it); +- the GitHub webhook setup card, which probes the GitHub App's hook + permissions live on every read (webhook verification and health are in + `/api/status`); - the theme and sidebar, which are purely presentational. A test fails when the dashboard serves a new `GET /api/config` route that is diff --git a/src/docs/agent-configuration.md b/src/docs/agent-configuration.md index ee98fdc755..fbe0d04788 100644 --- a/src/docs/agent-configuration.md +++ b/src/docs/agent-configuration.md @@ -1247,6 +1247,32 @@ project: agent_issue_decision_policy: resolve # resolve (default) or escalate ``` +### Reference repos: what agents may study (`project.reference_repos`) + +Some hives produce a deliverable *about* another project β€” a CNCF TechDocs hive whose authorized repo is `cncf/techdocs` assesses `hivecommons/docs`, for example. Without configuration, the scanner reads such an item as out-of-scope or critical, and the proxy has no way to say "read this repo but do not work it". `project.reference_repos` names those repos ([#18015](https://github.com/hivecommons/hive/issues/18015)). A reference repo is never an authorized repo: it gets no queue, no ACMM evaluation and no work items, and it cannot also be in `project.repos`. + +Each entry is either a string (`owner/name`, read-only) or a mapping `{repo, write, write_token_file}`. Bare names are rejected because a reference repo lives outside the project org. + +```yaml +project: + repos: [techdocs] + reference_repos: + - hivecommons/docs # read-only (default) + - hivecommons/hivecommons.github.io + - repo: hivecommons/hive + write: [issues, prs] # opt-in tier + # write_token_file: /data/secrets/hive-ref-pat # only if the App is not installed on that org +``` + +| Tier | Agents may | Proxy refuses | +|---|---|---| +| `read-only` (string form, or no `write`) | `git clone`/fetch over HTTPS, GitHub REST/GraphQL GETs. Every request goes out without auth because the hive's App token is removed, so a private reference repo is not readable. | Every non-GET, with a 403 that names `reference_repos`. | +| `issues`, `prs` or `issues+prs` (`write:`) | The reads above, plus only: open an issue (`issues`), open a PR or push a topic branch (`prs`), and comment on an issue or PR the hive itself opened. | Merges, pushes to the default branch, labels, assignees, reviews, and closing other people's items. Each refusal is logged as `reference_repo` in the violations log, so retrying cannot succeed. | + +Writes on the opt-in tier use a credential valid for *that* repo, never the hive's own installation token. By default the same GitHub App must be installed on the target org: the hive discovers that installation and mints a token limited to the one repo and the opted-in permissions. Otherwise, set `write_token_file`. If a write-mode entry has neither, boot fails with exit code 22. These are direct POSTs. The `hive-open-issue`/`hive-open-pr` relays only act on `project.repos`, so a lane under `write_surface.enforce` cannot write to a reference repo at all ([GitHub write surface](github-write-surface.md#reference-repos)). + +What agents see: every kick carries a `πŸ“š REFERENCE REPOS` block listing each repo with its tier (`[reference Β· read-only]`, `[reference Β· issues+prs]`, …). It tells agents that an authorized-repo item whose deliverables name a reference repo is **in scope**. Findings belong in the deliverable inside the authorized repo, unless the tier allows an issue or PR. On the dashboard, each reference repo is a minimal card with a `reference Β· ` chip and no stats, ACMM level or work items. Reference repos come in a separate payload field, so no queue or ACMM consumer counts them. Field-by-field defaults are in the [operator reference](operator-reference/README.md#notable-fields). + ## Label policy: which issues agents may work There is exactly **one** label-policy surface for the hive's own agents β€” the **Governor Configuration β†’ Labels** tab β€” and it has two polarities: diff --git a/src/docs/api-reference.md b/src/docs/api-reference.md index 97098cc40f..d7b4e253ac 100644 --- a/src/docs/api-reference.md +++ b/src/docs/api-reference.md @@ -40,17 +40,17 @@ summary and the `health_deep_read` diagnostic tool. | `GET` | `/api/version` | Dashboard auth/session | Build/version metadata; includes `upgradeMarker` (`target`, `current`, `attempts`, `maxAttempts`, `failed`, `requestedAt`, `lastError`) while a self-upgrade is in flight or has failed ([#6765](https://github.com/hivecommons/hive/issues/6765)), the hub-delivered `upgradePolicy` (`hub_managed`, `spoke_managed`, `schedule`, `schedule_hour`, `schedule_timezone`, `schedule_weekday`, `paused`, `branch`, `channel`, `target_sha`, `target_resolved`, `armed_target`, and `next_update_at` β€” the hub's RFC3339 ETA for the next promotion into the `stable` channel, omitted when unknown ([#10256](https://github.com/hivecommons/hive/issues/10256))) when available, and an `autoUpdate` object (`enabled`, `state` β€” one of `disabled`/`up_to_date`/`behind`/`retrying`/`failed`/`unknown` β€” `healthy`, `period`, `targetBranch`, `targetCommit`, `currentCommit`, `commitsBehind`, `lastAttemptAt`, `lastError`, `detail`, and `nextUpdateAt` β€” the hub's RFC3339 ETA for the next update, omitted when unknown, disabled or paused ([#10257](https://github.com/hivecommons/hive/issues/10257))) that never reports a failed or unknown update as healthy ([#6962](https://github.com/hivecommons/hive/issues/6962), [#6963](https://github.com/hivecommons/hive/issues/6963)); also `hub_build` (the hub's short SHA) and, once a stdio `hive-admin-mcp` client has called `/api/version`, `/api/status` or `/api/status/summary` with its `hive-admin-mcp/+` User-Agent, `last_seen_stdio_build` and `last_seen_stdio_at` (RFC3339) so the dashboard can flag build skew ([#12318](https://github.com/hivecommons/hive/issues/12318)) | `pkg/dashboard/api.go:46` | | `GET` | `/api/version/release-notes` | Dashboard auth/session | Changelog gap between two revisions: `?from=&to=` returns `{from:{sha,version?}, to:{sha,ref,version?}, sections:[{version,date,categories:{added,changed,fixed,security,deprecated}}], unreleased?, truncated, source, error?}` β€” the `CHANGELOG.md` release sections present at `to` and absent at `from`, newest first, plus (when `to` is not a release tag) the `changelog.d/` fragments new at `to` under an `Unreleased (in this build)` section. Fetch failures return `source: "unavailable"` with `error` rather than an error status; results are cached per (from, to) for an hour ([#11065](https://github.com/hivecommons/hive/issues/11065)) | `pkg/dashboard/api.go:47` | | `POST` | `/api/release-channel` | Owner only | Hosted spoke self-service release-channel selector; relays `stable`/`candidate`/`edge` to the hub's existing switch-branch endpoint with the spoke dashboard-token proof, and reports the requested channel as pending until the Deployment image lands | `pkg/dashboard/api.go:77` | -| `GET` | `/api/health` | Public | Basic health probe | `pkg/dashboard/server.go:1849` | -| `GET` | `/api/health/deep` | Public | Deep health probe; the `webhooks` check (`status`, `detail`, `mode`, `mode_reason_code`, `verification`, `rejected_1h`) warns when deliveries arrive but cannot be used β€” no `GITHUB_WEBHOOK_SECRET` and no hub relay key, signature mismatch, undecodable payload, or a hub-relayed delivery rejected for `relay_signature_invalid`, `relay_timestamp_stale`, `relay_delivery_replayed` or `relay_delivery_missing` β€” and never changes the overall verdict; `verification` (`github-signed`, `relay (hub-signed)`, `none`) says how deliveries are authenticated | `pkg/dashboard/server.go:1850` | -| `GET` | `/api/livez` | Public | Kubernetes liveness probe | `pkg/dashboard/server.go:1851` | -| `GET` | `/metrics` | Registered only when `HIVE_METRICS_ENABLED`; requires `Authorization: ****** (403 if the token is unset) | Prometheus metrics | `pkg/dashboard/server.go:1857` | -| `GET` | `/api/status` | Dashboard auth/session | Dashboard aggregate status; `labelPresets[]` (`{key,label,description,labels[],states[]}`) lists the built-in label-filter presets for the Projects cards, led by `needs-human` whose `labels[]` are the effective hard-suppress labels plus `needs-signal`, the hold labels, `stage/071-needs-author` and the reporter-trust awaiting label (#12172); accepts `?fields=a,b` for top-level selection and `?omit=repos` to drop heavy sections, and honors `Accept-Encoding: gzip`; `agents[]` includes busy-pane visibility fields (`kicksUndeliverable`, `busySince`, `lastTranscriptActivity`, `condition`, `conditionMessage`) when a Working pane cannot accept kicks; `sla` (fleet) and `repos[].sla` (per repo) carry the Delivery SLA block derived from the lifecycle timeline with no extra GitHub calls: `windows.{7d,30d}.{open_to_triage,triage_to_start,start_to_pr,pr_to_merge,open_to_merge}` as `{p50_h,p95_h,n}` (hours, nearest-rank), `history[]` of up to 30 daily buckets `{day,start_to_pr_p50,pr_to_merge_p50,open_to_merge_p50,n_started,n_pr,n_merged}` persisted under `/data/sla-history.json`, `stale_in_progress[]` (`{number,repo,started_at,age_h,agent}` β€” issues carrying `hive/in-progress` longer than the agent claim TTL with no linked PR), `stale_count`, `merged_7d`, `claim_ttl_s` and, on the fleet block, the effective `targets` (`start_to_pr_h`, `pr_to_merge_h`, `open_to_merge_h`); `staleSince` and `repos[].countsStale` / `countsStaleSince` mark totals served from last-good figures β€” the previous enumeration (#13382) or the ForgeState cache's last-good counts (#13376); every your-turn issue and PR row carries the critical path (#13646): `waits_on[]` (`{kind,ref}`, kind one of `pr`, `issue`, `external`, `base-check` (ref = the check red on the base branch), `approval`, `merge`; ref `#N` in the same repository or `owner/repo#N`), `unblocks` (transitive count of your-turn items waiting on it, cycle-guarded), `may_conflict_with[]` (PR numbers of the same repository sharing a closing issue or a known changed file; never an edge), `action` (`merge`, `release-hold`, `rebase`, `rerun` or `approve`), `action_ref`, `root_cause` and `blocker` (the root cause it collapses under); `repos[].yourTurn` has `issues`, `prs` (your turn, `CLOSE` excluded), `root_issues`, `root_prs`, `root_cause_count`, `order[]` (`{kind,repo,number}` by unblocks then age), `groups[]` (`{blocker,blocker_title,dependents[]}`) and `main_red` (`{check,since,fix,fix_your_turn}`); the top-level `yourTurn` sums the counts; `api_budget_pacing` `{enabled, active, factor, effective_interval_s, burn_per_min, reason}` is the API budget pacing state (`governor.api_budget_pacing`), and `evalIntervalS` is the loop interval after pacing | `pkg/dashboard/server.go:1862` | -| `GET` | `/api/status/summary` | Dashboard auth/session | Compact agent/governor/budget status summary for pollers, with the hive-wide `yourTurn` root-cause counts the phone widget reads (#13646); honors `Accept-Encoding: gzip` | `pkg/dashboard/server.go:1863` | -| `GET` | `/api/overview/issues.csv` | Dashboard auth/session | Cached Overview issue-band export as browser-compatible CSV; filters `band`, `repo`/`repos`, `stale`, and `held` are ANDed. | `pkg/dashboard/server.go:1866` | -| `GET` | `/api/overview/issues.json` | Dashboard auth/session | Cached Overview issue-band export as typed JSON with `bands[]` metadata and row fields matching the CSV columns. | `pkg/dashboard/server.go:1867` | -| `GET` | `/api/overview/prs.csv` | Dashboard auth/session | Cached Overview PR-band export as browser-compatible CSV; filters `band`, `repo`/`repos`, `stale`, and `held` are ANDed. | `pkg/dashboard/server.go:1868` | -| `GET` | `/api/overview/prs.json` | Dashboard auth/session | Cached Overview PR-band export as typed JSON with `bands[]` metadata and row fields matching the CSV columns. | `pkg/dashboard/server.go:1869` | -| `GET` | `/api/events` | Dashboard auth/session | Server-sent event stream | `pkg/dashboard/server.go:1870` | +| `GET` | `/api/health` | Public | Basic health probe | `pkg/dashboard/server.go:1864` | +| `GET` | `/api/health/deep` | Public | Deep health probe; the `webhooks` check (`status`, `detail`, `mode`, `mode_reason_code`, `verification`, `rejected_1h`) warns when deliveries arrive but cannot be used β€” no `GITHUB_WEBHOOK_SECRET` and no hub relay key, signature mismatch, undecodable payload, or a hub-relayed delivery rejected for `relay_signature_invalid`, `relay_timestamp_stale`, `relay_delivery_replayed` or `relay_delivery_missing` β€” and never changes the overall verdict; `verification` (`github-signed`, `relay (hub-signed)`, `none`) says how deliveries are authenticated | `pkg/dashboard/server.go:1865` | +| `GET` | `/api/livez` | Public | Kubernetes liveness probe | `pkg/dashboard/server.go:1866` | +| `GET` | `/metrics` | Registered only when `HIVE_METRICS_ENABLED`; requires `Authorization: ****** (403 if the token is unset) | Prometheus metrics | `pkg/dashboard/server.go:1872` | +| `GET` | `/api/status` | Dashboard auth/session | Dashboard aggregate status; `labelPresets[]` (`{key,label,description,labels[],states[]}`) lists the built-in label-filter presets for the Projects cards, led by `needs-human` whose `labels[]` are the effective hard-suppress labels plus `needs-signal`, the hold labels, `stage/071-needs-author` and the reporter-trust awaiting label (#12172); accepts `?fields=a,b` for top-level selection and `?omit=repos` to drop heavy sections, and honors `Accept-Encoding: gzip`; `agents[]` includes busy-pane visibility fields (`kicksUndeliverable`, `busySince`, `lastTranscriptActivity`, `condition`, `conditionMessage`) when a Working pane cannot accept kicks; `sla` (fleet) and `repos[].sla` (per repo) carry the Delivery SLA block derived from the lifecycle timeline with no extra GitHub calls: `windows.{7d,30d}.{open_to_triage,triage_to_start,start_to_pr,pr_to_merge,open_to_merge}` as `{p50_h,p95_h,n}` (hours, nearest-rank), `history[]` of up to 30 daily buckets `{day,start_to_pr_p50,pr_to_merge_p50,open_to_merge_p50,n_started,n_pr,n_merged}` persisted under `/data/sla-history.json`, `stale_in_progress[]` (`{number,repo,started_at,age_h,agent}` β€” issues carrying `hive/in-progress` longer than the agent claim TTL with no linked PR), `stale_count`, `merged_7d`, `claim_ttl_s` and, on the fleet block, the effective `targets` (`start_to_pr_h`, `pr_to_merge_h`, `open_to_merge_h`); `staleSince` and `repos[].countsStale` / `countsStaleSince` mark totals served from last-good figures β€” the previous enumeration (#13382) or the ForgeState cache's last-good counts (#13376); every your-turn issue and PR row carries the critical path (#13646): `waits_on[]` (`{kind,ref}`, kind one of `pr`, `issue`, `external`, `base-check` (ref = the check red on the base branch), `approval`, `merge`; ref `#N` in the same repository or `owner/repo#N`), `unblocks` (transitive count of your-turn items waiting on it, cycle-guarded), `may_conflict_with[]` (PR numbers of the same repository sharing a closing issue or a known changed file; never an edge), `action` (`merge`, `release-hold`, `rebase`, `rerun` or `approve`), `action_ref`, `root_cause` and `blocker` (the root cause it collapses under); `repos[].yourTurn` has `issues`, `prs` (your turn, `CLOSE` excluded), `root_issues`, `root_prs`, `root_cause_count`, `order[]` (`{kind,repo,number}` by unblocks then age), `groups[]` (`{blocker,blocker_title,dependents[]}`) and `main_red` (`{check,since,fix,fix_your_turn}`); the top-level `yourTurn` sums the counts; `api_budget_pacing` `{enabled, active, factor, effective_interval_s, burn_per_min, reason}` is the API budget pacing state (`governor.api_budget_pacing`), and `evalIntervalS` is the loop interval after pacing | `pkg/dashboard/server.go:1877` | +| `GET` | `/api/status/summary` | Dashboard auth/session | Compact agent/governor/budget status summary for pollers, with the hive-wide `yourTurn` root-cause counts the phone widget reads (#13646); honors `Accept-Encoding: gzip` | `pkg/dashboard/server.go:1878` | +| `GET` | `/api/overview/issues.csv` | Dashboard auth/session | Cached Overview issue-band export as browser-compatible CSV; filters `band`, `repo`/`repos`, `stale`, and `held` are ANDed. | `pkg/dashboard/server.go:1881` | +| `GET` | `/api/overview/issues.json` | Dashboard auth/session | Cached Overview issue-band export as typed JSON with `bands[]` metadata and row fields matching the CSV columns. | `pkg/dashboard/server.go:1882` | +| `GET` | `/api/overview/prs.csv` | Dashboard auth/session | Cached Overview PR-band export as browser-compatible CSV; filters `band`, `repo`/`repos`, `stale`, and `held` are ANDed. | `pkg/dashboard/server.go:1883` | +| `GET` | `/api/overview/prs.json` | Dashboard auth/session | Cached Overview PR-band export as typed JSON with `bands[]` metadata and row fields matching the CSV columns. | `pkg/dashboard/server.go:1884` | +| `GET` | `/api/events` | Dashboard auth/session | Server-sent event stream | `pkg/dashboard/server.go:1885` | | `GET` | `/api/swarm` | Dashboard auth/session | Current swarm status: display name, duration, active repo, expiry, persisted prep metrics when available, and idle-unlock fields. | `pkg/dashboard/swarm.go:310` | | `POST` | `/api/swarm` | Owner only | Start a 24h repo swarm for a configured repo, returning 409 when another swarm is active and 423 when a previous swarm keeps the next swarm locked until enough agents are idle unless body `{"repo":"owner/name","force":true}` is used; saves the active record before repo prep, persists prep metrics, and announces the start to Discord when configured. | `pkg/dashboard/swarm.go:311` | | `DELETE` | `/api/swarm` | Owner only | End the active swarm, score issues closed and PRs merged in the window, append it to history, and announce the result to Discord when configured. | `pkg/dashboard/swarm.go:312` | @@ -93,7 +93,7 @@ summary and the `health_deep_read` diagnostic tool. | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| | `GET` | `/api/style` | Public | Sanitized custom dashboard CSS | `pkg/dashboard/api.go:48` | -| `GET` | `/branding/custom.css` | Dashboard auth/session | Operator branding stylesheet override, read per request (see [branding](branding.md)) | `pkg/dashboard/server.go:1944` | +| `GET` | `/branding/custom.css` | Dashboard auth/session | Operator branding stylesheet override, read per request (see [branding](branding.md)) | `pkg/dashboard/server.go:1959` | | `GET` | `/api/snapshot/frame-ancestors` | Public | Snapshot framing allowlist | `pkg/dashboard/api.go:91` | | `GET` | `/api/snapshot` | Public | Snapshot data | `pkg/dashboard/api.go:92` | | `GET` | `/snapshot` | Public | Public read-only snapshot page | `pkg/dashboard/api.go:93` | @@ -121,9 +121,9 @@ summary and the `health_deep_read` diagnostic tool. | `GET` | `/api/openrouter/models` | Dashboard auth/session | Open Router Models | `pkg/dashboard/openrouter.go:47` | | `GET` | `/api/openrouter/credit` | Dashboard auth/session | Open Router Credit | `pkg/dashboard/openrouter.go:48` | | `GET` | `/openrouter/callback` | Public | Open Router Callback | `pkg/dashboard/openrouter.go:49` | -| `POST` | `/api/github-app/recheck` | Dashboard auth/session | GitHub App Recheck | `pkg/dashboard/server.go:1871` | -| `POST` | `/api/github-app/install-clicked` | Dashboard auth/session | GitHub App Install Clicked | `pkg/dashboard/server.go:1872` | -| `GET` | `/gh-setup` | Public | GitHub App Setup Callback | `pkg/dashboard/server.go:1873` | +| `POST` | `/api/github-app/recheck` | Dashboard auth/session | GitHub App Recheck | `pkg/dashboard/server.go:1886` | +| `POST` | `/api/github-app/install-clicked` | Dashboard auth/session | GitHub App Install Clicked | `pkg/dashboard/server.go:1887` | +| `GET` | `/gh-setup` | Public | GitHub App Setup Callback | `pkg/dashboard/server.go:1888` | ## Configuration @@ -196,17 +196,17 @@ summary and the `health_deep_read` diagnostic tool. | `POST` | `/api/config/governor/repos/check-access` | Dashboard auth/session | Governor Repo Check Access | `pkg/dashboard/api.go:332` | | `PUT` | `/api/config/github` | Owner only | Config GitHub | `pkg/dashboard/api.go:333` | | `GET` | `/api/config/github/forge-apps` | Dashboard auth/session | Config GitHub Forge Apps | `pkg/dashboard/api.go:336` | -| `GET` | `/api/config/sidebar` | Dashboard auth/session | Sidebar Get | `pkg/dashboard/api.go:396` | -| `PUT` | `/api/config/sidebar` | Dashboard auth/session | Sidebar Set | `pkg/dashboard/api.go:397` | -| `GET` | `/api/config/backends` | Dashboard auth/session | Backends | `pkg/dashboard/api.go:398` | -| `GET` | `/api/compliance/status` | Merger or owner | Evaluated compliance controls for the frameworks in `compliance.frameworks` (`compliance.Report`, JSON): per control `meets`/`deviates`/`off`/`not_covered` with current vs recommended value per mapped setting, a summary, and the non-certification disclaimer β€” see [Compliance](compliance.md) ([#11078](https://github.com/hivecommons/hive/issues/11078)) | `pkg/dashboard/api.go:388` | -| `GET` | `/api/compliance/posture` | Merger or owner | Posture-check catalogue, effective interval / window / history retention, and the latest run (`compliance.PostureRun`: schedule/manual trigger, `triggered_by` identity, and per-check `pass`/`fail`/`skip`/`error`, detail, control ids, evidence refs); `?since=` (RFC 3339, `YYYY-MM-DD` or a look-back such as `168h`) adds every run since then, oldest first, capped at 1000 β€” see [Compliance](compliance.md#posture-checks-catalogue) ([#11079](https://github.com/hivecommons/hive/issues/11079)) | `pkg/dashboard/api.go:389` | -| `POST` | `/api/compliance/posture/run` | Merger or owner | Run the posture checks now, attributed to the authenticated user, and record the pass in the history; audited as `compliance_posture_run`; `409` when no framework is selected or a pass is already running ([#11079](https://github.com/hivecommons/hive/issues/11079)) | `pkg/dashboard/api.go:390` | -| `GET` | `/api/compliance/posture/history` | Owner only | Posture history for `?since=`/`?until=` (default the last 30 days) as one series per check: points (`at`, `status`), status counts, `last_fail_at` and the latest result with detail and evidence refs; at most the 1000 most recent runs β€” see [Compliance](compliance.md#evidence-exports-and-attestations) ([#11081](https://github.com/hivecommons/hive/issues/11081)) | `pkg/dashboard/api.go:391` | -| `GET` | `/api/compliance/export` | Owner only | Evidence download for `?since=`/`?until=`: `kind=controls` (`json`/`md`), `posture` (`json`/`csv`), `audit` (`json`), `config` (redacted effective config + sha256, `json`), `attestations` (`json`/`csv`) or `bundle` (`json`). Every format includes source, period, generator, selected profile versions, attesters, retention and a per-section SHA-256 manifest; audited as `compliance_export` with the SHA-256 of the delivered bytes ([#11081](https://github.com/hivecommons/hive/issues/11081), [#13582](https://github.com/hivecommons/hive/issues/13582)) | `pkg/dashboard/api.go:392` | -| `GET` | `/api/compliance/attestations` | Owner only | Owner attestations, newest first (`?framework=` filters) ([#11081](https://github.com/hivecommons/hive/issues/11081)) | `pkg/dashboard/api.go:393` | -| `POST` | `/api/compliance/attestations` | Owner only | Record `{"framework", "reviewed_on", "note"}` for the signed-in owner; audited as `compliance_attestation`; `201` with the attestation, `400` for an unknown framework, a bad or future date, or a note over 2000 characters ([#11081](https://github.com/hivecommons/hive/issues/11081)) | `pkg/dashboard/api.go:394` | -| `PUT` | `/api/config/governor/compliance` | Owner only | Settings β†’ Compliance framework picker: `{"frameworks": [...]}` sets `compliance.frameworks` (empty list deselects all, absent key leaves it unchanged; unknown/duplicate ids are a `400` before any change); audited as `config_compliance`; returns the re-evaluated `compliance.Report` ([#11080](https://github.com/hivecommons/hive/issues/11080)) | `pkg/dashboard/api.go:573` | +| `GET` | `/api/config/sidebar` | Dashboard auth/session | Sidebar Get | `pkg/dashboard/api.go:399` | +| `PUT` | `/api/config/sidebar` | Dashboard auth/session | Sidebar Set | `pkg/dashboard/api.go:400` | +| `GET` | `/api/config/backends` | Dashboard auth/session | Backends | `pkg/dashboard/api.go:401` | +| `GET` | `/api/compliance/status` | Merger or owner | Evaluated compliance controls for the frameworks in `compliance.frameworks` (`compliance.Report`, JSON): per control `meets`/`deviates`/`off`/`not_covered` with current vs recommended value per mapped setting, a summary, and the non-certification disclaimer β€” see [Compliance](compliance.md) ([#11078](https://github.com/hivecommons/hive/issues/11078)) | `pkg/dashboard/api.go:391` | +| `GET` | `/api/compliance/posture` | Merger or owner | Posture-check catalogue, effective interval / window / history retention, and the latest run (`compliance.PostureRun`: schedule/manual trigger, `triggered_by` identity, and per-check `pass`/`fail`/`skip`/`error`, detail, control ids, evidence refs); `?since=` (RFC 3339, `YYYY-MM-DD` or a look-back such as `168h`) adds every run since then, oldest first, capped at 1000 β€” see [Compliance](compliance.md#posture-checks-catalogue) ([#11079](https://github.com/hivecommons/hive/issues/11079)) | `pkg/dashboard/api.go:392` | +| `POST` | `/api/compliance/posture/run` | Merger or owner | Run the posture checks now, attributed to the authenticated user, and record the pass in the history; audited as `compliance_posture_run`; `409` when no framework is selected or a pass is already running ([#11079](https://github.com/hivecommons/hive/issues/11079)) | `pkg/dashboard/api.go:393` | +| `GET` | `/api/compliance/posture/history` | Owner only | Posture history for `?since=`/`?until=` (default the last 30 days) as one series per check: points (`at`, `status`), status counts, `last_fail_at` and the latest result with detail and evidence refs; at most the 1000 most recent runs β€” see [Compliance](compliance.md#evidence-exports-and-attestations) ([#11081](https://github.com/hivecommons/hive/issues/11081)) | `pkg/dashboard/api.go:394` | +| `GET` | `/api/compliance/export` | Owner only | Evidence download for `?since=`/`?until=`: `kind=controls` (`json`/`md`), `posture` (`json`/`csv`), `audit` (`json`), `config` (redacted effective config + sha256, `json`), `attestations` (`json`/`csv`) or `bundle` (`json`). Every format includes source, period, generator, selected profile versions, attesters, retention and a per-section SHA-256 manifest; audited as `compliance_export` with the SHA-256 of the delivered bytes ([#11081](https://github.com/hivecommons/hive/issues/11081), [#13582](https://github.com/hivecommons/hive/issues/13582)) | `pkg/dashboard/api.go:395` | +| `GET` | `/api/compliance/attestations` | Owner only | Owner attestations, newest first (`?framework=` filters) ([#11081](https://github.com/hivecommons/hive/issues/11081)) | `pkg/dashboard/api.go:396` | +| `POST` | `/api/compliance/attestations` | Owner only | Record `{"framework", "reviewed_on", "note"}` for the signed-in owner; audited as `compliance_attestation`; `201` with the attestation, `400` for an unknown framework, a bad or future date, or a note over 2000 characters ([#11081](https://github.com/hivecommons/hive/issues/11081)) | `pkg/dashboard/api.go:397` | +| `PUT` | `/api/config/governor/compliance` | Owner only | Settings β†’ Compliance framework picker: `{"frameworks": [...]}` sets `compliance.frameworks` (empty list deselects all, absent key leaves it unchanged; unknown/duplicate ids are a `400` before any change); audited as `config_compliance`; returns the re-evaluated `compliance.Report` ([#11080](https://github.com/hivecommons/hive/issues/11080)) | `pkg/dashboard/api.go:576` | | `GET` | `/api/config/governor/threshold-scaling` | Owner only | Governor Threshold Scaling Get | `pkg/dashboard/api.go:233` | | `PUT` | `/api/config/governor/threshold-scaling` | Owner only | Governor Threshold Scaling Set | `pkg/dashboard/api.go:234` | @@ -215,8 +215,8 @@ summary and the `health_deep_read` diagnostic tool. | `PUT` | `/api/config/governor/watchdog` | Owner only | Governor Watchdog | `pkg/dashboard/api.go:243` | | `GET` | `/api/config/escalation` | Owner only | Escalation Config Get | `pkg/dashboard/api.go:246` | | `PUT` | `/api/config/escalation` | Owner only | Escalation Config Set | `pkg/dashboard/api.go:247` | -| `GET` | `/api/config/write-surface` | Owner only | Lane write allowlist (`write_surface.allowlist`, [#9587](https://github.com/hivecommons/hive/issues/9587)): `allowlist`, `ops`, `warnings` | `pkg/dashboard/api.go:569` | -| `PUT` | `/api/config/write-surface` | Owner only | Replace the lane write allowlist; `{"allowlist": {}}` clears it; unknown operation or bad lane name is a 400 with nothing changed | `pkg/dashboard/api.go:570` | +| `GET` | `/api/config/write-surface` | Owner only | Lane write allowlist (`write_surface.allowlist`, [#9587](https://github.com/hivecommons/hive/issues/9587)): `allowlist`, `ops`, `warnings` | `pkg/dashboard/api.go:572` | +| `PUT` | `/api/config/write-surface` | Owner only | Replace the lane write allowlist; `{"allowlist": {}}` clears it; unknown operation or bad lane name is a 400 with nothing changed | `pkg/dashboard/api.go:573` | | `GET` | `/api/config/review` | Dashboard auth/session | Review Config Get | `pkg/dashboard/api.go:254` | | `PUT` | `/api/config/review` | Owner only | Review Config Set | `pkg/dashboard/api.go:257` | | `PUT` | `/api/config/governor/features` | Owner only | Governor Features; `writebackKinds` replaces the whole `writeback.kinds` map (a kind sent as `null` or `{}` is dropped). Wrapped by the admin MCP `governor.feature_settings` and `writeback.kind_policy` operations | `pkg/dashboard/api.go:263` | @@ -272,21 +272,21 @@ summary and the `health_deep_read` diagnostic tool. | `GET` | `/api/model-advisor` | Dashboard auth/session | Model Advisor | `pkg/dashboard/api.go:188` | | `GET` | `/api/governor/pr-models` | Dashboard auth/session | Agent-authored PR distribution by normalized attribution model/backend for `window=7d`, `30d`, or `all`, including per-model rework stats and the top 10 most-reworked PRs | `pkg/dashboard/api.go:189` | | `GET` | `/api/reviewer/accuracy` | Dashboard auth/session | Reviewer verdict calibration for the configured window: false-approve/false-block rates and confidence buckets per perspective and reviewer model | `pkg/dashboard/api.go:256` | -| `GET` | `/api/review/queue` | Dashboard auth/session | PR review queue: every open PR (agent and contributor, actionable and held) ranked by triage class, confidence band, CI state and age, with per-PR `reasons`; unreviewed items carry `pending_since` and `redispatch_count` from the review dispatch state; each entry carries `stage` `{stage, entered_at, last_activity_at, quiet_for, stalled, waiting_on_hive, past_dwell, history[]}` from the stage-label store (`stalled`/`past_dwell` follow `governor.labels.stage.dwell` and `stalled.require_quiet`; `history[]` is `{from, to, at, reason, regressed}`, newest last, capped at 8) and the top-level `hive_held` counts PRs past their stage dwell while waiting on the hive (`waiting_on_hive` and `past_dwell` both true); paged with `limit` (1-200, default 50) and `offset` | `pkg/dashboard/api.go:543` | -| `POST` | `/api/review/queue/{owner}/{repo}/{number}/superseded/close` | Owner only | Close a superseded PR now (grace window or not): posts the close comment, adds `hive/superseded`, closes it | `pkg/dashboard/api.go:546` | -| `POST` | `/api/review/queue/{owner}/{repo}/{number}/superseded/keep-open` | Owner only | Keep a superseded PR open: adds `hive/keep-open`, which the supersession sweep never auto-closes | `pkg/dashboard/api.go:547` | -| `GET` | `/api/review/pipeline` | Dashboard auth/session | Review pipeline: one card per review-queue PR placed in its stage (`unreviewed`, `reviewing`, `changes_requested`, `fixing`, `human_hold`, `approved`, `merged`, `abandoned`) with `since`, `reviewers`, P0–P3 `severity` counts, `loop_count`/`loop_cap`, `next_action` and `reasons`; filtered by `repo` and `stage`, paged with `limit` (1-200, default 50) and `offset` | `pkg/dashboard/api.go:551` | +| `GET` | `/api/review/queue` | Dashboard auth/session | PR review queue: every open PR (agent and contributor, actionable and held) ranked by triage class, confidence band, CI state and age, with per-PR `reasons`; unreviewed items carry `pending_since` and `redispatch_count` from the review dispatch state; each entry carries `stage` `{stage, entered_at, last_activity_at, quiet_for, stalled, waiting_on_hive, past_dwell, history[]}` from the stage-label store (`stalled`/`past_dwell` follow `governor.labels.stage.dwell` and `stalled.require_quiet`; `history[]` is `{from, to, at, reason, regressed}`, newest last, capped at 8) and the top-level `hive_held` counts PRs past their stage dwell while waiting on the hive (`waiting_on_hive` and `past_dwell` both true); paged with `limit` (1-200, default 50) and `offset` | `pkg/dashboard/api.go:546` | +| `POST` | `/api/review/queue/{owner}/{repo}/{number}/superseded/close` | Owner only | Close a superseded PR now (grace window or not): posts the close comment, adds `hive/superseded`, closes it | `pkg/dashboard/api.go:549` | +| `POST` | `/api/review/queue/{owner}/{repo}/{number}/superseded/keep-open` | Owner only | Keep a superseded PR open: adds `hive/keep-open`, which the supersession sweep never auto-closes | `pkg/dashboard/api.go:550` | +| `GET` | `/api/review/pipeline` | Dashboard auth/session | Review pipeline: one card per review-queue PR placed in its stage (`unreviewed`, `reviewing`, `changes_requested`, `fixing`, `human_hold`, `approved`, `merged`, `abandoned`) with `since`, `reviewers`, P0–P3 `severity` counts, `loop_count`/`loop_cap`, `next_action` and `reasons`; filtered by `repo` and `stage`, paged with `limit` (1-200, default 50) and `offset` | `pkg/dashboard/api.go:554` | | `GET` | `/api/review/dispatch/events` | Dashboard auth/session | Webhook review dispatch queue: `enabled`, `webhooks_configured`, `debounce_s`, `pending` (soonest first) and `recent` (newest first) dispatches with repo, number, head SHA, action, timestamps and coalesced count, plus `stats` counters; in memory, never calls GitHub | `pkg/dashboard/api_review_event_dispatch.go:15` | -| `GET` | `/api/review/evidence` | Merger or owner | [Review evidence bundle](review-evidence.md) for one PR head. Query `repo` (`owner/name`), `number`, optional `head` (full SHA or unique prefix of at least 7 characters; `409` when ambiguous; default is the newest head), `format=json\|zip` (default `json`) and `download=1` for an attachment. `json` returns the bundle bytes exactly as sealed (verdicts, posted reviews, policy, CI check runs, sentinel findings, human actions, merge event, `hash`, optional `signature`); `zip` adds `verdicts.json`, `review-links.json` and a `manifest.json` of SHA-256s plus the signing public key when readable. `400` for a bad `repo`/`number`; `404` with `status: "expired"`/`expired: true` when retention removed a bundle Hive can prove existed, otherwise `status: "not_found"` | `pkg/dashboard/api.go:555` | -| `GET` | `/api/review/evidence/list` | Merger or owner | Every retained evidence bundle id and head for one PR (`repo`, `number`), oldest first, with the newest marked latest; `400` for a bad `repo`/`number` | `pkg/dashboard/api.go:556` | -| `GET` | `/api/search` | Merger or owner | Portfolio search over this spoke's local index (no hub fan-out). Query `q` (full text), `kind` and `repo` (`owner/name`; both repeatable or comma-separated), `from` (inclusive) and `to` (exclusive; a `YYYY-MM-DD` date covers that day) as RFC 3339 or `YYYY-MM-DD`, and `limit` (1–100, default 20); `probe=1` runs no query and is not audited, answering `204` when the index is ready. Returns `total`, highlighted `hits` (snippet plus byte `spans`) and `facets` by `kind`, `repo`, `verdict` and `sentinel.rule`; `coverage` names repos whose index could not be read. Hit text passes through ioscan, so injected text appears only as a visible redaction marker (`redacted: true`). The query string, actor and role are audited as `search_query`; result bodies are not. `404` unless `search.enabled`; `400` for bad parameters and for the reserved `semantic=true`; `503` while the index is not yet available | `pkg/dashboard/api.go:559` | -| `GET` | `/api/evidence/write-counters` | Merger or owner | Durable attempted vs blocked agent GitHub write counters ([#12069](https://github.com/hivecommons/hive/issues/12069)): `totals` by intent tier (ACMM mode) with `blocked_by_reason`, and `weeks` keyed by the UTC Monday of each ISO week with per-tier and per-reason counts. Persisted across restarts; observation only, never gates a write or merge. Empty `totals`/`weeks` before anything is recorded | `pkg/dashboard/api.go:561` | -| `GET` | `/api/evidence/summary` | Dashboard auth/session | Read-only aggregate over `/data/evidence` bundles (schema v1) plus reviewer accuracy. Query `hours` (default 24, capped like `/api/pr-throughput`; `0` = all time) or a custom `from`/`to` range (RFC3339 or `YYYY-MM-DD`, date-only `to` inclusive, `to` defaults to now; `400` when `to` precedes `from`, the span exceeds 3 Γ— 366 days, or `to` is given without `from`; the response echoes `from`/`to`), `repo` (`owner/name`) and `author_kind=human\|agent\|bot`; `400` for a bad value. A PR falls in the window by its open time (earliest bundle `created_at` or `merge.opened_at`), never by when a bundle was written; all bundles of a PR are folded so the newest supplies verdicts and the merge / `reverted_by` / backfilled close come from whichever record knows them. Returns `available_windows` (`{"24","168","720","all"}` β†’ PR count under the same filters), `outcomes_by_author_kind` (merged/closed/open; `closed` comes from backfilled records, `reverted_14d` is null until bundles record it), `reviewer_calibration` buckets per perspective, `reviewer_accuracy_by_model` split by author kind (blocks split into `enforced_blocks` / `advisory_blocks` by `policy.require_approval`; advisory blocks that preceded a merge are `dissent_before_merge`, not false blocks), `sentinel_hits_per_week`, `shadow_gate` (`n`, `would_have_held`, `would_have_blocked`, and per gate `review_gate` / `intent` the evaluated `n` with `held` / `passed` outcomes `{n, merged, closed, open, reverted}`, plus `review_gate_by_lane` `{merge_eligible, self_merge}`), `write_counters` (per intent tier `{attempted, blocked, blocked_by_reason}` from the proxy write counter file; omitted when absent or unreadable), `write_activity` (blocked Γ· every agent write in the window: `total_writes`, `blocked`, `direct` `{recorded, since, partial_coverage, attempted, blocked, by_tier, blocked_by_reason}` from the proxy's weekly counter buckets under `/data/hive-metrics/write-counters.json`, `mediated` `{n, refused, by_action}` from the audit log), `claim_vs_verdict` (per ISO week of agent-authored PRs: `n`, `claimed`, `ci_captured`, `ci_not_green`, `verdict_not_approve`, `both`; records with empty CI are excluded from the CI column and disclosed under `missing.records_empty_ci`), `ci_recovery` (`policy` `{self_authored_automerge, required_checks}`, `n`, `at_claim` `{green, red, pending, none}`, `red` `{n, recovered_merged, green_open, abandoned, pending, outcome_unknown, enriched, not_enriched, median_pushes_to_green, median_hours_to_green, same_head_recovered}`, `gate` `{merged_n, with_checks, green, green_from_bundles, non_required_red, required_unknown, breaches, breach_prs[], suspected, suspected_prs[], pending, unknown, green_to_merge_n, median_green_to_merge_hours}` over every merged PR by GitHub-confirmed state of the *required* checks on the merged head, as the merge gate judges (`breach_prs[]` carry `red`, `merged_by`; a red non-required check is disclosed, not a breach; a red review-time snapshot, a required check with no run, or a red read without a resolvable required set is only suspected), and `convergence` `{n, passes {"1","2","3","4+","never"}, settled, in_flight, unknown, median_passes, from_github, from_bundles, by_week[]}` over agent PRs by CI passes to first green, from the durable `/data/hive-metrics/ci-recovery.json` GitHub check-run trail rollup), `work_balance` (per ISO week: `n`, `opened` by actor kind `{human, agent, bot}`, `closed`, `prs_opened`, `prs_merged`, `prs_closed`, `queue_issues`, `queue_prs`, `issue_source` `github|audit`, `throttled`) with `work_balance_sources` (`issues_github_since`, `issues_fetched_at`, `issues_known`, `audit_only_weeks`, `issue_series_weeks`, `queue_samples`, `queue_retention_days`; issues from the durable GitHub rollup `/data/hive-metrics/issue-activity.json` for weeks at or after `issues_github_since` and, for earlier weeks, from the backfilled issue series `///issues/.json` written by the issue backfill ([#12527](https://github.com/hivecommons/hive/pull/12527)) when its coverage spans the requested portion of the week (`issue_series_weeks` counts the repo-weeks it supplied), else the audit log (`audit_only_weeks`), PRs from folded bundles, queue depth from governor snapshots; `missing.issues_human_opens_unobserved` is true while no GitHub rollup exists unless the issue series covers every row, and whenever any row fell back to the audit log, `missing.throttle_windows_unavailable` discloses that no governor throttle windows are reported), `intent_counters` (per intent tier `{attempted, blocked}` from `/data/hive-metrics/intent-counters.json` β€” evaluations and the subset the intent gate would have blocked with enforcement on, [#12362](https://github.com/hivecommons/hive/issues/12362); omitted when absent or unreadable), `complexity_by_repo` (per managed repo the newest 12 monthly complexity snapshots, oldest first, each `{month, ref, captured_at, files, functions, mean_cyclomatic, median_cyclomatic, p90_cyclomatic, max_cyclomatic, functions_over_threshold, threshold, static_warnings, static_warnings_by_tool}`; omitted when no repo has a snapshot), the `/api/reviewer/accuracy` summary, and `n` plus `missing` counts (unknown-model verdicts, empty CI, unreadable records). Never consulted by merge decisions. | `pkg/dashboard/api.go:564` | -| `GET` | `/api/repos/{owner}/{repo}/health` | Dashboard auth/session | Report-only repo health score from the search index (`search.enabled`; `503` when off, `404` until the repo has indexed review evidence, `400` for a bad owner/repo). Recomputed on every index rebuild. Returns `score` (0–100, or `null` when no component is reported), `status`, `window_days` (90), `min_n` (10, the hub `minEvidenceN` rule), `computed_at` and `components[]` in order `ci_pass_rate`, `time_to_merge`, `revert_followup_rate`, `review_verdict_mix`, `stale_issue_ratio`, `signoff_rate`, `dependency_freshness`, `security_doc_rot`, each `{name, status, score (0–1), weight, contribution, n, inputs, reason}`. A component with `n` below `min_n` or without local data is `not_reported` (never zero) and the remaining weights are renormalised; weights and ages come from `search.health`. Spoke-only; never an ACMM input and never read by merge decisions or Governor priority. | `pkg/dashboard/api.go:565` | -| `GET` | `/api/agents` | Dashboard auth/session | Agents List | `pkg/dashboard/api.go:338` | -| `POST` | `/api/agents` | Owner only | Agent Create | `pkg/dashboard/api.go:339` | -| `POST` | `/api/agents/import` | Owner only | Agent Import | `pkg/dashboard/api.go:340` | -| `DELETE` | `/api/agents/{name}` | Owner only | Agent Delete | `pkg/dashboard/api.go:341` | +| `GET` | `/api/review/evidence` | Merger or owner | [Review evidence bundle](review-evidence.md) for one PR head. Query `repo` (`owner/name`), `number`, optional `head` (full SHA or unique prefix of at least 7 characters; `409` when ambiguous; default is the newest head), `format=json\|zip` (default `json`) and `download=1` for an attachment. `json` returns the bundle bytes exactly as sealed (verdicts, posted reviews, policy, CI check runs, sentinel findings, human actions, merge event, `hash`, optional `signature`); `zip` adds `verdicts.json`, `review-links.json` and a `manifest.json` of SHA-256s plus the signing public key when readable. `400` for a bad `repo`/`number`; `404` with `status: "expired"`/`expired: true` when retention removed a bundle Hive can prove existed, otherwise `status: "not_found"` | `pkg/dashboard/api.go:558` | +| `GET` | `/api/review/evidence/list` | Merger or owner | Every retained evidence bundle id and head for one PR (`repo`, `number`), oldest first, with the newest marked latest; `400` for a bad `repo`/`number` | `pkg/dashboard/api.go:559` | +| `GET` | `/api/search` | Merger or owner | Portfolio search over this spoke's local index (no hub fan-out). Query `q` (full text), `kind` and `repo` (`owner/name`; both repeatable or comma-separated), `from` (inclusive) and `to` (exclusive; a `YYYY-MM-DD` date covers that day) as RFC 3339 or `YYYY-MM-DD`, and `limit` (1–100, default 20); `probe=1` runs no query and is not audited, answering `204` when the index is ready. Returns `total`, highlighted `hits` (snippet plus byte `spans`) and `facets` by `kind`, `repo`, `verdict` and `sentinel.rule`; `coverage` names repos whose index could not be read. Hit text passes through ioscan, so injected text appears only as a visible redaction marker (`redacted: true`). The query string, actor and role are audited as `search_query`; result bodies are not. `404` unless `search.enabled`; `400` for bad parameters and for the reserved `semantic=true`; `503` while the index is not yet available | `pkg/dashboard/api.go:562` | +| `GET` | `/api/evidence/write-counters` | Merger or owner | Durable attempted vs blocked agent GitHub write counters ([#12069](https://github.com/hivecommons/hive/issues/12069)): `totals` by intent tier (ACMM mode) with `blocked_by_reason`, and `weeks` keyed by the UTC Monday of each ISO week with per-tier and per-reason counts. Persisted across restarts; observation only, never gates a write or merge. Empty `totals`/`weeks` before anything is recorded | `pkg/dashboard/api.go:564` | +| `GET` | `/api/evidence/summary` | Dashboard auth/session | Read-only aggregate over `/data/evidence` bundles (schema v1) plus reviewer accuracy. Query `hours` (default 24, capped like `/api/pr-throughput`; `0` = all time) or a custom `from`/`to` range (RFC3339 or `YYYY-MM-DD`, date-only `to` inclusive, `to` defaults to now; `400` when `to` precedes `from`, the span exceeds 3 Γ— 366 days, or `to` is given without `from`; the response echoes `from`/`to`), `repo` (`owner/name`) and `author_kind=human\|agent\|bot`; `400` for a bad value. A PR falls in the window by its open time (earliest bundle `created_at` or `merge.opened_at`), never by when a bundle was written; all bundles of a PR are folded so the newest supplies verdicts and the merge / `reverted_by` / backfilled close come from whichever record knows them. Returns `available_windows` (`{"24","168","720","all"}` β†’ PR count under the same filters), `outcomes_by_author_kind` (merged/closed/open; `closed` comes from backfilled records, `reverted_14d` is null until bundles record it), `reviewer_calibration` buckets per perspective, `reviewer_accuracy_by_model` split by author kind (blocks split into `enforced_blocks` / `advisory_blocks` by `policy.require_approval`; advisory blocks that preceded a merge are `dissent_before_merge`, not false blocks), `sentinel_hits_per_week`, `shadow_gate` (`n`, `would_have_held`, `would_have_blocked`, and per gate `review_gate` / `intent` the evaluated `n` with `held` / `passed` outcomes `{n, merged, closed, open, reverted}`, plus `review_gate_by_lane` `{merge_eligible, self_merge}`), `write_counters` (per intent tier `{attempted, blocked, blocked_by_reason}` from the proxy write counter file; omitted when absent or unreadable), `write_activity` (blocked Γ· every agent write in the window: `total_writes`, `blocked`, `direct` `{recorded, since, partial_coverage, attempted, blocked, by_tier, blocked_by_reason}` from the proxy's weekly counter buckets under `/data/hive-metrics/write-counters.json`, `mediated` `{n, refused, by_action}` from the audit log), `claim_vs_verdict` (per ISO week of agent-authored PRs: `n`, `claimed`, `ci_captured`, `ci_not_green`, `verdict_not_approve`, `both`; records with empty CI are excluded from the CI column and disclosed under `missing.records_empty_ci`), `ci_recovery` (`policy` `{self_authored_automerge, required_checks}`, `n`, `at_claim` `{green, red, pending, none}`, `red` `{n, recovered_merged, green_open, abandoned, pending, outcome_unknown, enriched, not_enriched, median_pushes_to_green, median_hours_to_green, same_head_recovered}`, `gate` `{merged_n, with_checks, green, green_from_bundles, non_required_red, required_unknown, breaches, breach_prs[], suspected, suspected_prs[], pending, unknown, green_to_merge_n, median_green_to_merge_hours}` over every merged PR by GitHub-confirmed state of the *required* checks on the merged head, as the merge gate judges (`breach_prs[]` carry `red`, `merged_by`; a red non-required check is disclosed, not a breach; a red review-time snapshot, a required check with no run, or a red read without a resolvable required set is only suspected), and `convergence` `{n, passes {"1","2","3","4+","never"}, settled, in_flight, unknown, median_passes, from_github, from_bundles, by_week[]}` over agent PRs by CI passes to first green, from the durable `/data/hive-metrics/ci-recovery.json` GitHub check-run trail rollup), `work_balance` (per ISO week: `n`, `opened` by actor kind `{human, agent, bot}`, `closed`, `prs_opened`, `prs_merged`, `prs_closed`, `queue_issues`, `queue_prs`, `issue_source` `github|audit`, `throttled`) with `work_balance_sources` (`issues_github_since`, `issues_fetched_at`, `issues_known`, `audit_only_weeks`, `issue_series_weeks`, `queue_samples`, `queue_retention_days`; issues from the durable GitHub rollup `/data/hive-metrics/issue-activity.json` for weeks at or after `issues_github_since` and, for earlier weeks, from the backfilled issue series `///issues/.json` written by the issue backfill ([#12527](https://github.com/hivecommons/hive/pull/12527)) when its coverage spans the requested portion of the week (`issue_series_weeks` counts the repo-weeks it supplied), else the audit log (`audit_only_weeks`), PRs from folded bundles, queue depth from governor snapshots; `missing.issues_human_opens_unobserved` is true while no GitHub rollup exists unless the issue series covers every row, and whenever any row fell back to the audit log, `missing.throttle_windows_unavailable` discloses that no governor throttle windows are reported), `intent_counters` (per intent tier `{attempted, blocked}` from `/data/hive-metrics/intent-counters.json` β€” evaluations and the subset the intent gate would have blocked with enforcement on, [#12362](https://github.com/hivecommons/hive/issues/12362); omitted when absent or unreadable), `complexity_by_repo` (per managed repo the newest 12 monthly complexity snapshots, oldest first, each `{month, ref, captured_at, files, functions, mean_cyclomatic, median_cyclomatic, p90_cyclomatic, max_cyclomatic, functions_over_threshold, threshold, static_warnings, static_warnings_by_tool}`; omitted when no repo has a snapshot), the `/api/reviewer/accuracy` summary, and `n` plus `missing` counts (unknown-model verdicts, empty CI, unreadable records). Never consulted by merge decisions. | `pkg/dashboard/api.go:567` | +| `GET` | `/api/repos/{owner}/{repo}/health` | Dashboard auth/session | Report-only repo health score from the search index (`search.enabled`; `503` when off, `404` until the repo has indexed review evidence, `400` for a bad owner/repo). Recomputed on every index rebuild. Returns `score` (0–100, or `null` when no component is reported), `status`, `window_days` (90), `min_n` (10, the hub `minEvidenceN` rule), `computed_at` and `components[]` in order `ci_pass_rate`, `time_to_merge`, `revert_followup_rate`, `review_verdict_mix`, `stale_issue_ratio`, `signoff_rate`, `dependency_freshness`, `security_doc_rot`, each `{name, status, score (0–1), weight, contribution, n, inputs, reason}`. A component with `n` below `min_n` or without local data is `not_reported` (never zero) and the remaining weights are renormalised; weights and ages come from `search.health`. Spoke-only; never an ACMM input and never read by merge decisions or Governor priority. | `pkg/dashboard/api.go:568` | +| `GET` | `/api/agents` | Dashboard auth/session | Agents List | `pkg/dashboard/api.go:341` | +| `POST` | `/api/agents` | Owner only | Agent Create | `pkg/dashboard/api.go:342` | +| `POST` | `/api/agents/import` | Owner only | Agent Import | `pkg/dashboard/api.go:343` | +| `DELETE` | `/api/agents/{name}` | Owner only | Agent Delete | `pkg/dashboard/api.go:344` | | `GET` | `/api/agents/{name}/log` | Dashboard auth/session | Agent Full Log | `pkg/dashboard/api.go:139` | | `GET` | `/api/agents/{name}/terminal-urls` | Dashboard auth/session | Agent Terminal URLs | `pkg/dashboard/api.go:143` | @@ -315,26 +315,26 @@ Read the result from `GET /api/kick/{agent}/status`, which returns `status` of ` | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `GET` | `/api/packs` | Dashboard auth/session | Packs List | `pkg/dashboard/api.go:343` | -| `POST` | `/api/packs/{level}/apply` | Owner only | Pack Apply | `pkg/dashboard/api.go:344` | -| `PUT` | `/api/packs/level` | Owner only | Set the ACMM level. Body `{"level": N}` persists and applies the pack; optional `{"release_level_holds": true}` performs a deliberate one-off release of Hive App level-applied `hold` labels for the transition. Level holds are otherwise never auto-released. When the change makes the L6 self-authored merge sweep eligible, the success body adds `self_merge_sweep_active`, `level_changed_at`, `level_holds_pending: [{repo, number, title, url, agent}]`, and `repos: [{repo, auto_merge}]` so the dashboard can show the owner an informational notice and repo toggles. | `pkg/dashboard/api.go:345` | +| `GET` | `/api/packs` | Dashboard auth/session | Packs List | `pkg/dashboard/api.go:346` | +| `POST` | `/api/packs/{level}/apply` | Owner only | Pack Apply | `pkg/dashboard/api.go:347` | +| `PUT` | `/api/packs/level` | Owner only | Set the ACMM level. Body `{"level": N}` persists and applies the pack; optional `{"release_level_holds": true}` performs a deliberate one-off release of Hive App level-applied `hold` labels for the transition. Level holds are otherwise never auto-released. When the change makes the L6 self-authored merge sweep eligible, the success body adds `self_merge_sweep_active`, `level_changed_at`, `level_holds_pending: [{repo, number, title, url, agent}]`, and `repos: [{repo, auto_merge}]` so the dashboard can show the owner an informational notice and repo toggles. | `pkg/dashboard/api.go:348` | | `POST` | `/api/repos/rescan` | Dashboard auth/session | Re-enumerate watched repositories' open issues/PRs for the dashboard Projects cards. The scan is read-only with respect to agents/governor actions, collapses concurrent requests, and debounces repeated presses for 30 seconds (`repoRescanDebounce`) so the button cannot hammer GitHub | `pkg/dashboard/api.go`, `pkg/dashboard/api_repos_rescan.go` | | `POST` | `/api/repos/pause` | Owner | Quiet ONE repository without stopping the hive: agents stop writing to it and stop being handed work on it, while it keeps its dashboard card and ACMM eval. Body `{"repo": "...", "reason": "..."}` β€” the repo travels in the body because a `project.repos` entry may be an explicit cross-org `owner/name`. Records who/when/why; `changed: false` marks a no-op re-pause and leaves the original provenance intact; `persisted: false` means the pause is in force but could not be written to config. Rejects a repo outside `project.repos`. See [per-repo agent pause](repo-pause.md) | `pkg/dashboard/api_repo_pause.go` | | `POST` | `/api/repos/resume` | Owner | Lift a repository's pause. Unlike pause, does not require the repo to be in `project.repos`, so a pause left behind by a removed repo can still be cleared | `pkg/dashboard/api_repo_pause.go` | | `GET` | `/api/repos/pauses` | Dashboard auth/session | Every repo pause with its provenance. Read-only β€” the same state is already on the repository cards | `pkg/dashboard/api_repo_pause.go` | -| `GET` | `/api/repos/{owner}/{repo}/hold-permission` | Dashboard auth/session | Reports whether the signed-in user can toggle hold chips for a repository card. Owners are allowed; otherwise the server checks GitHub `repos/{owner}/{repo}/collaborators/{user}/permission` and briefly caches the effective permission | `pkg/dashboard/api.go:363`, `pkg/dashboard/api_repo_hold.go` | +| `GET` | `/api/repos/{owner}/{repo}/hold-permission` | Dashboard auth/session | Reports whether the signed-in user can toggle hold chips for a repository card. Owners are allowed; otherwise the server checks GitHub `repos/{owner}/{repo}/collaborators/{user}/permission` and briefly caches the effective permission | `pkg/dashboard/api.go:366`, `pkg/dashboard/api_repo_hold.go` | | `GET` | `/api/repos/{owner}/{repo}/items/{number}` | Dashboard session | Per-item detail for the activity monitor's decision badge: `{repo, number, stage, stage_slug, operator_actions[], operator_action_count, last_operator_action}`. `operator_actions[]` is the server-side ledger of every row action submitted from the dashboard (hold/release, decision, decide-delegated, send-to-human, accept-triage β€” recorded by `recordOperatorAction` around those routes) as `{action, actor, at, note, target, outcome: pending|applied|no-op|failed, detail, head_sha, stage, job_id}` (`job_id` is the let-hive-decide job a record is for); `last_operator_action` adds the derived `state` (`waiting|addressed|stuck|pending|failed`), `stuck_after_seconds` and `followed[]` (the stage hops after the action). The same `last_operator_action` / `operator_action_count` ride every stage-tracker row and linked PR. Persisted in `/data/operator-actions.json`; `HIVE_OPERATOR_ACTION_STUCK_AFTER` (default `30m`) sets the stuck window. | `pkg/dashboard/api.go`, `pkg/dashboard/operator_actions.go` | -| `POST` | `/api/repos/{owner}/{repo}/items/{number}/hold` | Owner or repo write/maintain/admin | Add or remove a dashboard hold chip for an issue or PR. Body `{"held": true}` adds the hive's exact-match canonical `hive-pause/` label; `{"held": false}` removes exactly the labels causing the hold (`hive-pause/` and/or generic hold labels) and never removes `hive/` provenance. Every toggle writes an audit-log entry; the #8851 upgrade migration writes `/data/hive-hold-migration-.json` with audit-backed, provenance-only, and ambiguous legacy `hive/` classifications. | `pkg/dashboard/api.go:367`, `pkg/dashboard/api_repo_hold.go` | -| `POST` | `/api/repos/{owner}/{repo}/items/{number}/decision` | Owner or repo write/maintain/admin, signed-in GitHub user | Record an operator decision from the activity monitor's row menu / selection bar. Body `{"decision": "approve"|"direction"|"close-superseded"|"close-wontfix", "comment": "…", "type": "issue"|"pr", "superseded_by": "#458"}`. On an issue parked at 045, `approve`/`direction` act exactly as a maintainer's `/hive approve` / `/hive decision ` comment would (labels, `approved-direction`, stage-engine Unparked fact, reply comment naming the operator). On a PR parked at 085 (`type: "pr"`), `approve` posts the `/lgtm` and `/approve` commands as two comments attributed to the operator, records the approval against the head as the human answer to a `requires_human` / decision-requested verdict (the lanes and the stage engine honour it like a trusted merger's Approve review), lifts `needs-human`, clears the sentinel label and acknowledges the bundle's sentinel findings for that head, re-derives the stage and drops the tracker cache β€” the response adds `released` (`approval-recorded`, `needs-human`, `sentinel-label`, `sentinel-acknowledged`) and `stage`; the merge gate decides from there (no review as the operator, no merge) β€” and `direction` records the text as a human change request against the head β€” the `/hive decision` fix lane β€” on a hive-authored PR only (409 otherwise). The close variants post a comment naming the operator (and the superseding PR when `superseded_by` is given) and close the item as not planned. Never merges. Every decision writes an audit-log entry (`repo_item_decision_*`). | `pkg/dashboard/api.go:368`, `pkg/dashboard/api_repo_item_decision.go` | +| `POST` | `/api/repos/{owner}/{repo}/items/{number}/hold` | Owner or repo write/maintain/admin | Add or remove a dashboard hold chip for an issue or PR. Body `{"held": true}` adds the hive's exact-match canonical `hive-pause/` label; `{"held": false}` removes exactly the labels causing the hold (`hive-pause/` and/or generic hold labels) and never removes `hive/` provenance. Every toggle writes an audit-log entry; the #8851 upgrade migration writes `/data/hive-hold-migration-.json` with audit-backed, provenance-only, and ambiguous legacy `hive/` classifications. | `pkg/dashboard/api.go:370`, `pkg/dashboard/api_repo_hold.go` | +| `POST` | `/api/repos/{owner}/{repo}/items/{number}/decision` | Owner or repo write/maintain/admin, signed-in GitHub user | Record an operator decision from the activity monitor's row menu / selection bar. Body `{"decision": "approve"|"direction"|"close-superseded"|"close-wontfix", "comment": "…", "type": "issue"|"pr", "superseded_by": "#458"}`. On an issue parked at 045, `approve`/`direction` act exactly as a maintainer's `/hive approve` / `/hive decision ` comment would (labels, `approved-direction`, stage-engine Unparked fact, reply comment naming the operator). On a PR parked at 085 (`type: "pr"`), `approve` posts the `/lgtm` and `/approve` commands as two comments attributed to the operator, records the approval against the head as the human answer to a `requires_human` / decision-requested verdict (the lanes and the stage engine honour it like a trusted merger's Approve review), lifts `needs-human`, clears the sentinel label and acknowledges the bundle's sentinel findings for that head, re-derives the stage and drops the tracker cache β€” the response adds `released` (`approval-recorded`, `needs-human`, `sentinel-label`, `sentinel-acknowledged`) and `stage`; the merge gate decides from there (no review as the operator, no merge) β€” and `direction` records the text as a human change request against the head β€” the `/hive decision` fix lane β€” on a hive-authored PR only (409 otherwise). The close variants post a comment naming the operator (and the superseding PR when `superseded_by` is given) and close the item as not planned. Never merges. Every decision writes an audit-log entry (`repo_item_decision_*`). | `pkg/dashboard/api.go:371`, `pkg/dashboard/api_repo_item_decision.go` | | `POST` / `GET` | `/api/repos/{owner}/{repo}/items/{number}/decide-delegated` | Same as `…/hold` (owner or repo write), signed-in GitHub user; `decisions.delegate.enabled` (403 when off) | *Let hive decide*: enqueue a delegated-decision job in which the hive decides the parked item itself as the owner's delegate under `pkg/delegatedecision.PolicyText` and records it as a human decision (`/hive decision` comment with the `` marker, park labels off, `triage/accepted`, queue approval on a PR head). Body `{"type": "issue"|"pr", "reason", "caption", "sentinel_reason"}`. `POST` answers 202 with the job (`{id, status: "running"|"done"|"failed", decision: {verdict, rule, text, rationale, actions, keeps_park}, notes, error}`); 409 with the running job while one is in flight; `GET` reads it, from memory while this process holds the job and otherwise from the operator-action ledger (`from_ledger: true`; status, requester, start, verdict and rule, with the outcome's words in `notes` or `error`), so the last result outlives the 30-minute in-memory eviction and a restart. A job still running when the hive restarts is never resumed: the next process marks its ledger record `failed` (*interrupted by a hive restart before it finished, and not resumed …*) with a `repo_item_decision_delegated_failed` audit entry, and the operator asks again. Rails: never merges, refuses items wearing `hold`, never grants secrets/permissions (`needs-owner` + `hold` instead), recommends only on hive policy/governance questions (keeps 085). Audited as `repo_item_decision_delegated[_failed]`. See [dashboard-sections.md](dashboard-sections.md#let-hive-decide). | `pkg/dashboard/api.go:329`, `pkg/dashboard/api_repo_item_decide_delegated.go` | -| `POST` | `/api/repos/{owner}/{repo}/items/{number}/accept-triage` | Owner or repo write/maintain/admin | Accept triage on an issue from the activity monitor's row menu β€” what a maintainer accepting triage on GitHub does by hand: adds the reporter-trust triage label (`triage/accepted`, or the first of `issue_filter.reporter_trust.untrusted_require_labels`; the label is created in the repo if missing) and removes the awaiting label (`needs-triage` / `reporter_trust.awaiting_label`) and a Hive-written `stage/015-triage-needed`. Empty body. Returns `{ok, repo, number, acceptLabel, added, removed, alreadyAccepted, minStatusSeq}`; an issue already carrying the accept label with nothing left to remove is a no-op (`alreadyAccepted: true`, no write, no audit). 404 for a pull request or an unknown item. The status snapshot's labels are patched so the next reconcile reads 010 Actionable before the engine's own 015 β†’ 010 derivation. Every write is audited as `repo_item_accept_triage` under the dashboard user. | `pkg/dashboard/api.go:594`, `pkg/dashboard/api_repo_item_accept_triage.go` | -| `GET` | `/api/acmm/evaluation` | Dashboard auth/session | ACMMEvaluation β€” combined codebase + operational result, cached server-side for 1 hour (`acmmEvalTTL`). `?refresh=1` (the dashboard's "πŸ”„ Re-evaluate" button, #5877) bypasses the hourly TTL but is debounced server-side: requests within 1 minute of the last evaluation (`acmmRefreshDebounce`) still serve the cache, since a full refresh costs up to ~29 GitHub GetContents calls per repo. The response's `last_evaluated_at` timestamp reports when the cached evaluation was computed | `pkg/dashboard/api.go:383`, `pkg/dashboard/api_acmm_eval.go` | -| `POST` | `/api/acmm/issue` | Owner only | ACMMCreate Issue β€” files on GitHub or, with `governor.acmm.issue_tracker: work_source` / body `tracker: "work_source"` on a Linear-sourced hive, on Linear; response `tracker` says which. See [ACMM policy matrix](acmm-policy-matrix.md#where-acmm-gap-issues-are-filed) | `pkg/dashboard/api.go:384` | -| `GET` | `/api/acmm-recommendation` | Dashboard auth/session | Advisory level-up recommendation (`acmmadvisor.Recommendation`, JSON): never changes the applied level β€” see [ACMM advisor](acmm-advisor.md) | `pkg/dashboard/api.go:385` | -| `GET` | `/api/hive-advice` | Dashboard auth/session | Weekly owner advice (`hiveadvisor.Result`, JSON) shared with the advisory digest: recommendations with `items`, `links` (Overview export paths) and `bands`, and on the needs-human advice `why` (plain-language reasons with counts) and `help`; `bands[]` rule text; `counts` naming the governor actionable queue vs. the Overview total. Which recommendations show is frozen for 7 days; their numbers refresh every call β€” see [Owner advice](advisory.md#owner-advice) | `pkg/dashboard/api.go:387`, `pkg/dashboard/api_hive_advice.go` | -| `POST` | `/api/repos/{owner}/{repo}/issues/{number}/release` | Owner or repo write/maintain/admin | Execute the typed `kind=release` owner step the hub proposed on a parked issue, with the same live preconditions as `/hive approve` ([#12635](https://github.com/hivecommons/hive/issues/12635)). Tag and target are resolved from the trusted owner-step, never from the request. Returns 503 when the GitHub client or config is unavailable; 409 when the repository is `label_driven` (the same refusal `/hive approve` gets there), when `release_on_approve` is off, or when a live precondition fails (`error` carries the refusal reason, which is also posted on the issue); 502 when the release write itself fails. Success returns `{ok, repo, number, tag, url, minStatusSeq}` plus `warning` if the follow-up issue comment failed after the release was created. | `pkg/dashboard/api.go:376`, `pkg/dashboard/api_issue_release.go` | +| `POST` | `/api/repos/{owner}/{repo}/items/{number}/accept-triage` | Owner or repo write/maintain/admin | Accept triage on an issue from the activity monitor's row menu β€” what a maintainer accepting triage on GitHub does by hand: adds the reporter-trust triage label (`triage/accepted`, or the first of `issue_filter.reporter_trust.untrusted_require_labels`; the label is created in the repo if missing) and removes the awaiting label (`needs-triage` / `reporter_trust.awaiting_label`) and a Hive-written `stage/015-triage-needed`. Empty body. Returns `{ok, repo, number, acceptLabel, added, removed, alreadyAccepted, minStatusSeq}`; an issue already carrying the accept label with nothing left to remove is a no-op (`alreadyAccepted: true`, no write, no audit). 404 for a pull request or an unknown item. The status snapshot's labels are patched so the next reconcile reads 010 Actionable before the engine's own 015 β†’ 010 derivation. Every write is audited as `repo_item_accept_triage` under the dashboard user. | `pkg/dashboard/api.go:597`, `pkg/dashboard/api_repo_item_accept_triage.go` | +| `GET` | `/api/acmm/evaluation` | Dashboard auth/session | ACMMEvaluation β€” combined codebase + operational result, cached server-side for 1 hour (`acmmEvalTTL`). `?refresh=1` (the dashboard's "πŸ”„ Re-evaluate" button, #5877) bypasses the hourly TTL but is debounced server-side: requests within 1 minute of the last evaluation (`acmmRefreshDebounce`) still serve the cache, since a full refresh costs up to ~29 GitHub GetContents calls per repo. The response's `last_evaluated_at` timestamp reports when the cached evaluation was computed | `pkg/dashboard/api.go:386`, `pkg/dashboard/api_acmm_eval.go` | +| `POST` | `/api/acmm/issue` | Owner only | ACMMCreate Issue β€” files on GitHub or, with `governor.acmm.issue_tracker: work_source` / body `tracker: "work_source"` on a Linear-sourced hive, on Linear; response `tracker` says which. See [ACMM policy matrix](acmm-policy-matrix.md#where-acmm-gap-issues-are-filed) | `pkg/dashboard/api.go:387` | +| `GET` | `/api/acmm-recommendation` | Dashboard auth/session | Advisory level-up recommendation (`acmmadvisor.Recommendation`, JSON): never changes the applied level β€” see [ACMM advisor](acmm-advisor.md) | `pkg/dashboard/api.go:388` | +| `GET` | `/api/hive-advice` | Dashboard auth/session | Weekly owner advice (`hiveadvisor.Result`, JSON) shared with the advisory digest: recommendations with `items`, `links` (Overview export paths) and `bands`, and on the needs-human advice `why` (plain-language reasons with counts) and `help`; `bands[]` rule text; `counts` naming the governor actionable queue vs. the Overview total. Which recommendations show is frozen for 7 days; their numbers refresh every call β€” see [Owner advice](advisory.md#owner-advice) | `pkg/dashboard/api.go:390`, `pkg/dashboard/api_hive_advice.go` | +| `POST` | `/api/repos/{owner}/{repo}/issues/{number}/release` | Owner or repo write/maintain/admin | Execute the typed `kind=release` owner step the hub proposed on a parked issue, with the same live preconditions as `/hive approve` ([#12635](https://github.com/hivecommons/hive/issues/12635)). Tag and target are resolved from the trusted owner-step, never from the request. Returns 503 when the GitHub client or config is unavailable; 409 when the repository is `label_driven` (the same refusal `/hive approve` gets there), when `release_on_approve` is off, or when a live precondition fails (`error` carries the refusal reason, which is also posted on the issue); 502 when the release write itself fails. Success returns `{ok, repo, number, tag, url, minStatusSeq}` plus `warning` if the follow-up issue comment failed after the release was created. | `pkg/dashboard/api.go:379`, `pkg/dashboard/api_issue_release.go` | ## Cost, tokens, telemetry @@ -358,37 +358,37 @@ Read the result from `GET /api/kick/{agent}/status`, which returns `status` of ` | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `GET` | `/api/knowledge` | Dashboard auth/session | Paginated knowledge facts: `type` filter, `tag` (comma-separated; a fact must carry every tag), `state` (comma-separated lifecycle states or `all`; only facts in those states are listed, and any state other than `approved` requires a verified owner and returns 403 otherwise), `limit` (default 50, max 200; invalid/non-positive values use the default), `offset` (default 0). Returns `enabled`, `facts`, `count` (this page), `total`, `limit`, `offset`, `has_more`; ordered by layer then slug. Offsets are not snapshot cursors: concurrent edits may shift pages. | `pkg/dashboard/api.go:403` | -| `GET` | `/api/knowledge/export` | Dashboard auth/session | Knowledge Export | `pkg/dashboard/api.go:404` | -| `POST` | `/mcp/knowledge` | Public (read-only) when the dashboard setting or `HIVE_PUBLIC_KNOWLEDGE` is on; otherwise 404 to a signed-in caller and 401 to one who is not | Public Knowledge MCP endpoint β€” `initialize`, `tools/list`, `tools/call` for `knowledge_search`/`knowledge_toc`/`knowledge_get`/`knowledge_export` ([docs](public-knowledge-mcp.md)) | `pkg/dashboard/api.go:413` | -| `GET` | `/api/knowledge/search` | Dashboard auth/session | Knowledge Search | `pkg/dashboard/api.go:405` | -| `GET` | `/api/knowledge/toc` | Dashboard auth/session | Scoped knowledge table of contents: capped, body-free entries (`id`, `title`, `type`, `layer`, `repo`, `tags`, `status`, `confidence`, `updated`, `size_bytes`, `source`) plus `total`/`returned`/`truncated`. Query: `layers`, `repos`, `types`, `tags` (comma-separated), `limit` (default 50, max 200), `include_states` (operator opt-in for `draft`/`deprecated`/`superseded`/`all`), `format=prompt` with `max_chars` for a bounded markdown rendering, `agent` to apply that agent's `knowledge.agent_scopes` entry (the other parameters can only narrow it). Approved knowledge only by default ([docs](knowledge-toc.md)) | `pkg/dashboard/api.go:406` | -| `GET` | `/api/knowledge/effective` | Dashboard auth/session | Effective knowledge for one agent (`agent` required): the agent's `knowledge.agent_scopes` entry (unrestricted when it has none) intersected with the request scope (approved only unless `include_states` is set). Returns `scope` (resolved scope applied, with `configured`), `included` TOC entries, `excluded` entries each with a `reason` (`lifecycle`, `layer`, `repo`, `type` or `tag` β€” first failing check, in that order), `included_total`/`excluded_total`/`included_returned`/`excluded_returned` and `truncated`; `limit` caps each list (default 50, max 200) ([docs](knowledge-toc.md#effective-knowledge)) | `pkg/dashboard/api.go:407` | -| `GET` | `/api/knowledge/entry/{id}` | Dashboard auth/session | Full read of one knowledge entry: `markdown` (front-matter plus body) and the fact. Takes the same scope, `include_states` and `agent` query as the table of contents and returns 404 for an entry outside it ([docs](knowledge-toc.md)) | `pkg/dashboard/api.go:408` | -| `GET` | `/api/knowledge/public` | Dashboard auth/session | Public Knowledge MCP sharing status (`enabled`, `tags`, `source`, endpoint URL) | `pkg/dashboard/api.go:414` | -| `PUT` | `/api/knowledge/public` | Owner only | Persist public Knowledge MCP sharing setting; overrides env vars until changed | `pkg/dashboard/api.go:415` | -| `GET` | `/api/knowledge/health` | Dashboard auth/session | Knowledge Health | `pkg/dashboard/api.go:416` | -| `GET` | `/api/knowledge/stats` | Dashboard auth/session | Knowledge Stats | `pkg/dashboard/api.go:417` | -| `GET` | `/api/knowledge/graph` | Dashboard auth/session | Knowledge Graph | `pkg/dashboard/api.go:418` | -| `GET` | `/api/knowledge/fact-history` | Dashboard auth/session | Fact History | `pkg/dashboard/api.go:419` | -| `POST` | `/api/knowledge/create` | Dashboard auth/session | Knowledge Create | `pkg/dashboard/api.go:420` | -| `POST` | `/api/knowledge/import` | Dashboard auth/session | Knowledge Import | `pkg/dashboard/api.go:421` | -| `POST` | `/api/knowledge/promote` | Dashboard auth/session | Knowledge Promote | `pkg/dashboard/api.go:427` | -| `GET` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs List | `pkg/dashboard/api.go:428` | -| `POST` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs Add | `pkg/dashboard/api.go:429` | -| `DELETE` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs Remove | `pkg/dashboard/api.go:430` | -| `PUT` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Update | `pkg/dashboard/api.go:431` | -| `DELETE` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Delete | `pkg/dashboard/api.go:432` | -| `GET` | `/api/knowledge/{layer}` | Dashboard auth/session | Knowledge Layer | `pkg/dashboard/api.go:433` | -| `GET` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Fact | `pkg/dashboard/api.go:434` | -| `PUT` | `/api/knowledge/enabled` | Owner only | Knowledge Toggle | `pkg/dashboard/api.go:435` | -| `GET` | `/api/knowledge/bead-synthesizer` | Dashboard auth/session | Bead Synth Status | `pkg/dashboard/api.go:436` | -| `PUT` | `/api/knowledge/bead-synthesizer/enabled` | Owner only | Bead Synth Toggle | `pkg/dashboard/api.go:437` | -| `GET` | `/api/knowledge/vaults` | Dashboard auth/session | Vaults List | `pkg/dashboard/api.go:438` | -| `POST` | `/api/knowledge/vaults` | Owner only | Vaults Connect | `pkg/dashboard/api.go:439` | -| `DELETE` | `/api/knowledge/vaults` | Dashboard auth/session | Vaults Disconnect | `pkg/dashboard/api.go:440` | -| `POST` | `/api/knowledge/vaults/reindex` | Dashboard auth/session | Vaults Reindex | `pkg/dashboard/api.go:441` | -| `GET` | `/api/knowledge/vaults/{name}/facts` | Dashboard auth/session | Vault Facts | `pkg/dashboard/api.go:442` | +| `GET` | `/api/knowledge` | Dashboard auth/session | Paginated knowledge facts: `type` filter, `tag` (comma-separated; a fact must carry every tag), `state` (comma-separated lifecycle states or `all`; only facts in those states are listed, and any state other than `approved` requires a verified owner and returns 403 otherwise), `limit` (default 50, max 200; invalid/non-positive values use the default), `offset` (default 0). Returns `enabled`, `facts`, `count` (this page), `total`, `limit`, `offset`, `has_more`; ordered by layer then slug. Offsets are not snapshot cursors: concurrent edits may shift pages. | `pkg/dashboard/api.go:406` | +| `GET` | `/api/knowledge/export` | Dashboard auth/session | Knowledge Export | `pkg/dashboard/api.go:407` | +| `POST` | `/mcp/knowledge` | Public (read-only) when the dashboard setting or `HIVE_PUBLIC_KNOWLEDGE` is on; otherwise 404 to a signed-in caller and 401 to one who is not | Public Knowledge MCP endpoint β€” `initialize`, `tools/list`, `tools/call` for `knowledge_search`/`knowledge_toc`/`knowledge_get`/`knowledge_export` ([docs](public-knowledge-mcp.md)) | `pkg/dashboard/api.go:416` | +| `GET` | `/api/knowledge/search` | Dashboard auth/session | Knowledge Search | `pkg/dashboard/api.go:408` | +| `GET` | `/api/knowledge/toc` | Dashboard auth/session | Scoped knowledge table of contents: capped, body-free entries (`id`, `title`, `type`, `layer`, `repo`, `tags`, `status`, `confidence`, `updated`, `size_bytes`, `source`) plus `total`/`returned`/`truncated`. Query: `layers`, `repos`, `types`, `tags` (comma-separated), `limit` (default 50, max 200), `include_states` (operator opt-in for `draft`/`deprecated`/`superseded`/`all`), `format=prompt` with `max_chars` for a bounded markdown rendering, `agent` to apply that agent's `knowledge.agent_scopes` entry (the other parameters can only narrow it). Approved knowledge only by default ([docs](knowledge-toc.md)) | `pkg/dashboard/api.go:409` | +| `GET` | `/api/knowledge/effective` | Dashboard auth/session | Effective knowledge for one agent (`agent` required): the agent's `knowledge.agent_scopes` entry (unrestricted when it has none) intersected with the request scope (approved only unless `include_states` is set). Returns `scope` (resolved scope applied, with `configured`), `included` TOC entries, `excluded` entries each with a `reason` (`lifecycle`, `layer`, `repo`, `type` or `tag` β€” first failing check, in that order), `included_total`/`excluded_total`/`included_returned`/`excluded_returned` and `truncated`; `limit` caps each list (default 50, max 200) ([docs](knowledge-toc.md#effective-knowledge)) | `pkg/dashboard/api.go:410` | +| `GET` | `/api/knowledge/entry/{id}` | Dashboard auth/session | Full read of one knowledge entry: `markdown` (front-matter plus body) and the fact. Takes the same scope, `include_states` and `agent` query as the table of contents and returns 404 for an entry outside it ([docs](knowledge-toc.md)) | `pkg/dashboard/api.go:411` | +| `GET` | `/api/knowledge/public` | Dashboard auth/session | Public Knowledge MCP sharing status (`enabled`, `tags`, `source`, endpoint URL) | `pkg/dashboard/api.go:417` | +| `PUT` | `/api/knowledge/public` | Owner only | Persist public Knowledge MCP sharing setting; overrides env vars until changed | `pkg/dashboard/api.go:418` | +| `GET` | `/api/knowledge/health` | Dashboard auth/session | Knowledge Health | `pkg/dashboard/api.go:419` | +| `GET` | `/api/knowledge/stats` | Dashboard auth/session | Knowledge Stats | `pkg/dashboard/api.go:420` | +| `GET` | `/api/knowledge/graph` | Dashboard auth/session | Knowledge Graph | `pkg/dashboard/api.go:421` | +| `GET` | `/api/knowledge/fact-history` | Dashboard auth/session | Fact History | `pkg/dashboard/api.go:422` | +| `POST` | `/api/knowledge/create` | Dashboard auth/session | Knowledge Create | `pkg/dashboard/api.go:423` | +| `POST` | `/api/knowledge/import` | Dashboard auth/session | Knowledge Import | `pkg/dashboard/api.go:424` | +| `POST` | `/api/knowledge/promote` | Dashboard auth/session | Knowledge Promote | `pkg/dashboard/api.go:430` | +| `GET` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs List | `pkg/dashboard/api.go:431` | +| `POST` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs Add | `pkg/dashboard/api.go:432` | +| `DELETE` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs Remove | `pkg/dashboard/api.go:433` | +| `PUT` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Update | `pkg/dashboard/api.go:434` | +| `DELETE` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Delete | `pkg/dashboard/api.go:435` | +| `GET` | `/api/knowledge/{layer}` | Dashboard auth/session | Knowledge Layer | `pkg/dashboard/api.go:436` | +| `GET` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Fact | `pkg/dashboard/api.go:437` | +| `PUT` | `/api/knowledge/enabled` | Owner only | Knowledge Toggle | `pkg/dashboard/api.go:438` | +| `GET` | `/api/knowledge/bead-synthesizer` | Dashboard auth/session | Bead Synth Status | `pkg/dashboard/api.go:439` | +| `PUT` | `/api/knowledge/bead-synthesizer/enabled` | Owner only | Bead Synth Toggle | `pkg/dashboard/api.go:440` | +| `GET` | `/api/knowledge/vaults` | Dashboard auth/session | Vaults List | `pkg/dashboard/api.go:441` | +| `POST` | `/api/knowledge/vaults` | Owner only | Vaults Connect | `pkg/dashboard/api.go:442` | +| `DELETE` | `/api/knowledge/vaults` | Dashboard auth/session | Vaults Disconnect | `pkg/dashboard/api.go:443` | +| `POST` | `/api/knowledge/vaults/reindex` | Dashboard auth/session | Vaults Reindex | `pkg/dashboard/api.go:444` | +| `GET` | `/api/knowledge/vaults/{name}/facts` | Dashboard auth/session | Vault Facts | `pkg/dashboard/api.go:445` | | `GET` | `/api/config/knowledge/connectors` | Dashboard auth/session | Knowledge connectors config (name, type, layer, scope, env/file auth reference) and registered types | `pkg/dashboard/api.go` | | `PUT` | `/api/config/knowledge/connectors` | Owner only | Replace `knowledge.connectors` after validation; inline secrets rejected | `pkg/dashboard/api.go` | | `GET` | `/api/config/knowledge/connectors/status` | Dashboard auth/session | Per-connector status pill, last sync, pages/facts, last error, plus the `publish` mirror status | `pkg/dashboard/api.go` | @@ -396,36 +396,36 @@ Read the result from `GET /api/kick/{agent}/status`, which returns `status` of ` | `POST` | `/api/config/knowledge/connectors/{name}/sync` | Owner only | Start an immediate background sync of one connector | `pkg/dashboard/api.go` | | `GET` | `/api/config/knowledge/agent-scopes` | Dashboard auth/session | Per-agent knowledge scopes (`knowledge.agent_scopes`: layers, repos, types, tags, include_states) ([docs](knowledge-toc.md#per-agent-scopes)) | `pkg/dashboard/api.go` | | `PUT` | `/api/config/knowledge/agent-scopes` | Owner only | Replace `knowledge.agent_scopes` after validating layer and state names; persisted and audited as `config_knowledge_agent_scopes` | `pkg/dashboard/api.go` | -| `GET` | `/api/knowledge/git-sources` | Dashboard auth/session | Git Sources List | `pkg/dashboard/api.go:452` | -| `POST` | `/api/knowledge/git-sources` | Owner only | Git Sources Connect | `pkg/dashboard/api.go:453` | -| `DELETE` | `/api/knowledge/git-sources` | Owner only | Git Sources Disconnect | `pkg/dashboard/api.go:454` | -| `PUT` | `/api/knowledge/git-sources` | Owner only | Git Sources Update | `pkg/dashboard/api.go:455` | -| `POST` | `/api/knowledge/obsidian/sync` | Dashboard auth/session | Obsidian Sync | `pkg/dashboard/api.go:456` | -| `GET` | `/api/knowledge/documents` | Dashboard auth/session | Documents List | `pkg/dashboard/api.go:457` | -| `POST` | `/api/knowledge/documents` | Dashboard auth/session | Documents Import | `pkg/dashboard/api.go:458` | -| `GET` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Get | `pkg/dashboard/api.go:459` | -| `DELETE` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Delete | `pkg/dashboard/api.go:460` | -| `POST` | `/api/knowledge/documents/{slug}/reimport` | Dashboard auth/session | Document Reimport | `pkg/dashboard/api.go:461` | -| `GET` | `/api/knowledge/context7/search` | Dashboard auth/session | Context7 Search | `pkg/dashboard/api.go:462` | -| `POST` | `/api/knowledge/cleanup-orphans` | Dashboard auth/session | Cleanup Orphans | `pkg/dashboard/api.go:463` | -| `GET` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channels List | `pkg/dashboard/api.go:425` | -| `POST` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channel Create | `pkg/dashboard/api.go:426` | +| `GET` | `/api/knowledge/git-sources` | Dashboard auth/session | Git Sources List | `pkg/dashboard/api.go:455` | +| `POST` | `/api/knowledge/git-sources` | Owner only | Git Sources Connect | `pkg/dashboard/api.go:456` | +| `DELETE` | `/api/knowledge/git-sources` | Owner only | Git Sources Disconnect | `pkg/dashboard/api.go:457` | +| `PUT` | `/api/knowledge/git-sources` | Owner only | Git Sources Update | `pkg/dashboard/api.go:458` | +| `POST` | `/api/knowledge/obsidian/sync` | Dashboard auth/session | Obsidian Sync | `pkg/dashboard/api.go:459` | +| `GET` | `/api/knowledge/documents` | Dashboard auth/session | Documents List | `pkg/dashboard/api.go:460` | +| `POST` | `/api/knowledge/documents` | Dashboard auth/session | Documents Import | `pkg/dashboard/api.go:461` | +| `GET` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Get | `pkg/dashboard/api.go:462` | +| `DELETE` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Delete | `pkg/dashboard/api.go:463` | +| `POST` | `/api/knowledge/documents/{slug}/reimport` | Dashboard auth/session | Document Reimport | `pkg/dashboard/api.go:464` | +| `GET` | `/api/knowledge/context7/search` | Dashboard auth/session | Context7 Search | `pkg/dashboard/api.go:465` | +| `POST` | `/api/knowledge/cleanup-orphans` | Dashboard auth/session | Cleanup Orphans | `pkg/dashboard/api.go:466` | +| `GET` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channels List | `pkg/dashboard/api.go:428` | +| `POST` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channel Create | `pkg/dashboard/api.go:429` | | `GET` | `/api/config/knowledge/pipeline` | Owner only | Scalar bead synthesizer, curator, code map and primer settings, plus the read-only engine, vault paths and primer merge strategy ([docs](settings.md#knowledge)) | `pkg/dashboard/api.go` | | `PUT` | `/api/config/knowledge/pipeline` | Owner only | Update those settings after `config.ValidateKnowledgePipeline`; 0 or empty restores the default; the bead synthesizer switch applies at once, the rest after a restart; audited as `config_knowledge_pipeline` | `pkg/dashboard/api.go` | -| `GET` | `/api/knowledge/git-sources` | Dashboard auth/session | Git Sources List | `pkg/dashboard/api.go:452` | -| `POST` | `/api/knowledge/git-sources` | Owner only | Git Sources Connect | `pkg/dashboard/api.go:453` | -| `DELETE` | `/api/knowledge/git-sources` | Owner only | Git Sources Disconnect | `pkg/dashboard/api.go:454` | -| `PUT` | `/api/knowledge/git-sources` | Owner only | Git Sources Update | `pkg/dashboard/api.go:455` | -| `POST` | `/api/knowledge/obsidian/sync` | Dashboard auth/session | Obsidian Sync | `pkg/dashboard/api.go:456` | -| `GET` | `/api/knowledge/documents` | Dashboard auth/session | Documents List | `pkg/dashboard/api.go:457` | -| `POST` | `/api/knowledge/documents` | Dashboard auth/session | Documents Import | `pkg/dashboard/api.go:458` | -| `GET` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Get | `pkg/dashboard/api.go:459` | -| `DELETE` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Delete | `pkg/dashboard/api.go:460` | -| `POST` | `/api/knowledge/documents/{slug}/reimport` | Dashboard auth/session | Document Reimport | `pkg/dashboard/api.go:461` | -| `GET` | `/api/knowledge/context7/search` | Dashboard auth/session | Context7 Search | `pkg/dashboard/api.go:462` | -| `POST` | `/api/knowledge/cleanup-orphans` | Dashboard auth/session | Cleanup Orphans | `pkg/dashboard/api.go:463` | -| `GET` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channels List | `pkg/dashboard/api.go:425` | -| `POST` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channel Create | `pkg/dashboard/api.go:426` | +| `GET` | `/api/knowledge/git-sources` | Dashboard auth/session | Git Sources List | `pkg/dashboard/api.go:455` | +| `POST` | `/api/knowledge/git-sources` | Owner only | Git Sources Connect | `pkg/dashboard/api.go:456` | +| `DELETE` | `/api/knowledge/git-sources` | Owner only | Git Sources Disconnect | `pkg/dashboard/api.go:457` | +| `PUT` | `/api/knowledge/git-sources` | Owner only | Git Sources Update | `pkg/dashboard/api.go:458` | +| `POST` | `/api/knowledge/obsidian/sync` | Dashboard auth/session | Obsidian Sync | `pkg/dashboard/api.go:459` | +| `GET` | `/api/knowledge/documents` | Dashboard auth/session | Documents List | `pkg/dashboard/api.go:460` | +| `POST` | `/api/knowledge/documents` | Dashboard auth/session | Documents Import | `pkg/dashboard/api.go:461` | +| `GET` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Get | `pkg/dashboard/api.go:462` | +| `DELETE` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Delete | `pkg/dashboard/api.go:463` | +| `POST` | `/api/knowledge/documents/{slug}/reimport` | Dashboard auth/session | Document Reimport | `pkg/dashboard/api.go:464` | +| `GET` | `/api/knowledge/context7/search` | Dashboard auth/session | Context7 Search | `pkg/dashboard/api.go:465` | +| `POST` | `/api/knowledge/cleanup-orphans` | Dashboard auth/session | Cleanup Orphans | `pkg/dashboard/api.go:466` | +| `GET` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channels List | `pkg/dashboard/api.go:428` | +| `POST` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channel Create | `pkg/dashboard/api.go:429` | | `GET` | `/api/knowledge/connectors` | Dashboard auth/session | Knowledge connector statuses and publish mirror status (`{"connectors": [...], "publish": {...}\|null}`, see [knowledge-connectors.md](knowledge-connectors.md)) | `pkg/dashboard/api_knowledge_connectors.go:23` | | `POST` | `/api/knowledge/connectors/{name}/sync` | Owner only | Start one connector sync now (202; 404 unknown, 409 running) | `pkg/dashboard/api_knowledge_connectors.go:24` | | `POST` | `/api/knowledge/publish/sync` | Owner only | Run a publish mirror batch now (202 with publish status; 503 mirror not running, 409 batch in progress) | `pkg/dashboard/api_knowledge_connectors.go:25` | @@ -536,70 +536,70 @@ always resolved server-side from the validated token. | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `GET` | `/api/nous/status` | Dashboard auth/session | Nous Status β€” includes `experimentLoop` (`dir`, `lastWriteAt`, `running`, `idleAfterSeconds`): whether the external experiment loop, the only producer of proposals, has written under `/var/run/nous/governor` within 24 evaluation cycles; ready cards then carry `experimentLoopIdle` ([#12541](https://github.com/hivecommons/hive/issues/12541)) | `pkg/dashboard/api.go:498` | -| `GET` | `/api/nous/ledger` | Dashboard auth/session | Nous Ledger | `pkg/dashboard/api.go:499` | -| `GET` | `/api/nous/principles` | Dashboard auth/session | Nous Principles | `pkg/dashboard/api.go:500` | -| `POST` | `/api/nous/approve` | Owner only | Nous Approve | `pkg/dashboard/api.go:501` | -| `POST` | `/api/nous/abort` | Owner only | Nous Abort | `pkg/dashboard/api.go:502` | -| `PUT` | `/api/nous/mode` | Owner only | Nous Mode | `pkg/dashboard/api.go:504` | -| `PUT` | `/api/nous/scope` | Owner only | Nous Scope | `pkg/dashboard/api.go:505` | -| `GET` | `/api/nous/phase` | Dashboard auth/session | Nous Phase | `pkg/dashboard/api.go:506` | -| `PUT` | `/api/nous/gate-decision` | Owner only | Nous Gate Decision | `pkg/dashboard/api.go:507` | -| `GET` | `/api/nous/gate-pending` | Dashboard auth/session | Nous Gate Pending | `pkg/dashboard/api.go:508` | -| `POST` | `/api/nous/gate-respond` | Owner only | Nous Gate Respond | `pkg/dashboard/api.go:509` | -| `GET` | `/api/nous/gate-response` | Dashboard auth/session | Nous Gate Response | `pkg/dashboard/api.go:510` | -| `GET` | `/api/nous/config` | Dashboard auth/session | Nous Config Get | `pkg/dashboard/api.go:511` | -| `PUT` | `/api/nous/config/goals` | Owner only | Nous Config Goals β€” legacy; the dashboard no longer exposes the research question in the βš™ dialog, use `PATCH /api/nous/campaigns/{id}` | `pkg/dashboard/api.go:512` | -| `PUT` | `/api/nous/config/repos` | Owner only | Nous Config Repos β€” the allow-list of repos experiments may target (empty = any) | `pkg/dashboard/api.go:513` | -| `PUT` | `/api/nous/config/output` | Owner only | Nous Config Output | `pkg/dashboard/api.go:514` | -| `PUT` | `/api/nous/config/fast-fail` | Owner only | Nous Config Fast Fail | `pkg/dashboard/api.go:515` | -| `PUT` | `/api/nous/config/schedule` | Owner only | Nous Config Schedule | `pkg/dashboard/api.go:516` | -| `PUT` | `/api/nous/config/controllables` | Owner only | Nous Config Controllables | `pkg/dashboard/api.go:517` | -| `PUT` | `/api/nous/config/principles` | Owner only | Nous Config Principles | `pkg/dashboard/api.go:518` | -| `DELETE` | `/api/nous/principles/{id}` | Owner only | Nous Delete Principle | `pkg/dashboard/api.go:519` | -| `POST` | `/api/nous/campaign/start` | Owner only | Nous Campaign Start β€” archives the active campaign and starts a new experiment whose baseline restarts at 0 ([#12501](https://github.com/hivecommons/hive/issues/12501)) | `pkg/dashboard/api.go:522` | -| `GET` | `/api/nous/campaigns` | Any | Nous Campaigns List β€” every experiment with its per-campaign baseline, scope, results and stage ([#12532](https://github.com/hivecommons/hive/issues/12532)) | `pkg/dashboard/api.go:523` | -| `POST` | `/api/nous/campaigns` | Owner only | Nous Campaigns Create β€” starts an experiment alongside the running ones; 409 `scope_conflict` when a running experiment shares a repo target or both are hive-wide ([#12532](https://github.com/hivecommons/hive/issues/12532)) | `pkg/dashboard/api.go:524` | -| `PATCH` | `/api/nous/campaigns/{id}` | Owner only | Nous Campaign Patch β€” pause / resume / archive, or edit question, hypothesis and scope. Hypothesis/tag edits save in place (`status: "updated"`); a changed question or repo set needs `restart: true` (else 409 `restart_required`) and archives the campaign as version N while creating version N+1 with `restartedFrom` (`status: "restarted", campaign, archived`); resume, scope widening and restarts re-run the conflict guard ([#12532](https://github.com/hivecommons/hive/issues/12532)) | `pkg/dashboard/api.go:525` | -| `POST` | `/api/nous/promote` | Owner only | Nous Promote (manual verdict promotion to an issue or Spektacular run; see [experiment-lab.md](experiment-lab.md#verdict-card-and-manual-promote)) | `pkg/dashboard/api.go:503` | -| `POST` | `/api/nous/artifacts/sync` | Owner only | Nous Artifact Sync | `pkg/dashboard/api.go:520` | -| `POST` | `/api/nous/reset` | Owner only | Nous Reset (wipe baseline snapshots, learned knowledge or everything; see [experiment-lab.md](experiment-lab.md#resetting-data)) | `pkg/dashboard/api.go:521` | +| `GET` | `/api/nous/status` | Dashboard auth/session | Nous Status β€” includes `experimentLoop` (`dir`, `lastWriteAt`, `running`, `idleAfterSeconds`): whether the external experiment loop, the only producer of proposals, has written under `/var/run/nous/governor` within 24 evaluation cycles; ready cards then carry `experimentLoopIdle` ([#12541](https://github.com/hivecommons/hive/issues/12541)) | `pkg/dashboard/api.go:501` | +| `GET` | `/api/nous/ledger` | Dashboard auth/session | Nous Ledger | `pkg/dashboard/api.go:502` | +| `GET` | `/api/nous/principles` | Dashboard auth/session | Nous Principles | `pkg/dashboard/api.go:503` | +| `POST` | `/api/nous/approve` | Owner only | Nous Approve | `pkg/dashboard/api.go:504` | +| `POST` | `/api/nous/abort` | Owner only | Nous Abort | `pkg/dashboard/api.go:505` | +| `PUT` | `/api/nous/mode` | Owner only | Nous Mode | `pkg/dashboard/api.go:507` | +| `PUT` | `/api/nous/scope` | Owner only | Nous Scope | `pkg/dashboard/api.go:508` | +| `GET` | `/api/nous/phase` | Dashboard auth/session | Nous Phase | `pkg/dashboard/api.go:509` | +| `PUT` | `/api/nous/gate-decision` | Owner only | Nous Gate Decision | `pkg/dashboard/api.go:510` | +| `GET` | `/api/nous/gate-pending` | Dashboard auth/session | Nous Gate Pending | `pkg/dashboard/api.go:511` | +| `POST` | `/api/nous/gate-respond` | Owner only | Nous Gate Respond | `pkg/dashboard/api.go:512` | +| `GET` | `/api/nous/gate-response` | Dashboard auth/session | Nous Gate Response | `pkg/dashboard/api.go:513` | +| `GET` | `/api/nous/config` | Dashboard auth/session | Nous Config Get | `pkg/dashboard/api.go:514` | +| `PUT` | `/api/nous/config/goals` | Owner only | Nous Config Goals β€” legacy; the dashboard no longer exposes the research question in the βš™ dialog, use `PATCH /api/nous/campaigns/{id}` | `pkg/dashboard/api.go:515` | +| `PUT` | `/api/nous/config/repos` | Owner only | Nous Config Repos β€” the allow-list of repos experiments may target (empty = any) | `pkg/dashboard/api.go:516` | +| `PUT` | `/api/nous/config/output` | Owner only | Nous Config Output | `pkg/dashboard/api.go:517` | +| `PUT` | `/api/nous/config/fast-fail` | Owner only | Nous Config Fast Fail | `pkg/dashboard/api.go:518` | +| `PUT` | `/api/nous/config/schedule` | Owner only | Nous Config Schedule | `pkg/dashboard/api.go:519` | +| `PUT` | `/api/nous/config/controllables` | Owner only | Nous Config Controllables | `pkg/dashboard/api.go:520` | +| `PUT` | `/api/nous/config/principles` | Owner only | Nous Config Principles | `pkg/dashboard/api.go:521` | +| `DELETE` | `/api/nous/principles/{id}` | Owner only | Nous Delete Principle | `pkg/dashboard/api.go:522` | +| `POST` | `/api/nous/campaign/start` | Owner only | Nous Campaign Start β€” archives the active campaign and starts a new experiment whose baseline restarts at 0 ([#12501](https://github.com/hivecommons/hive/issues/12501)) | `pkg/dashboard/api.go:525` | +| `GET` | `/api/nous/campaigns` | Any | Nous Campaigns List β€” every experiment with its per-campaign baseline, scope, results and stage ([#12532](https://github.com/hivecommons/hive/issues/12532)) | `pkg/dashboard/api.go:526` | +| `POST` | `/api/nous/campaigns` | Owner only | Nous Campaigns Create β€” starts an experiment alongside the running ones; 409 `scope_conflict` when a running experiment shares a repo target or both are hive-wide ([#12532](https://github.com/hivecommons/hive/issues/12532)) | `pkg/dashboard/api.go:527` | +| `PATCH` | `/api/nous/campaigns/{id}` | Owner only | Nous Campaign Patch β€” pause / resume / archive, or edit question, hypothesis and scope. Hypothesis/tag edits save in place (`status: "updated"`); a changed question or repo set needs `restart: true` (else 409 `restart_required`) and archives the campaign as version N while creating version N+1 with `restartedFrom` (`status: "restarted", campaign, archived`); resume, scope widening and restarts re-run the conflict guard ([#12532](https://github.com/hivecommons/hive/issues/12532)) | `pkg/dashboard/api.go:528` | +| `POST` | `/api/nous/promote` | Owner only | Nous Promote (manual verdict promotion to an issue or Spektacular run; see [experiment-lab.md](experiment-lab.md#verdict-card-and-manual-promote)) | `pkg/dashboard/api.go:506` | +| `POST` | `/api/nous/artifacts/sync` | Owner only | Nous Artifact Sync | `pkg/dashboard/api.go:523` | +| `POST` | `/api/nous/reset` | Owner only | Nous Reset (wipe baseline snapshots, learned knowledge or everything; see [experiment-lab.md](experiment-lab.md#resetting-data)) | `pkg/dashboard/api.go:524` | ## Inception | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `POST` | `/api/inception/start` | Owner only | Inception Start | `pkg/dashboard/api.go:468` | -| `POST` | `/api/inception/scan` | Owner only | Inception Scan | `pkg/dashboard/api.go:469` | -| `GET` | `/api/inception/state` | Dashboard auth/session | Inception State | `pkg/dashboard/api.go:470` | -| `POST` | `/api/inception/questions` | Owner only | Inception Set Questions | `pkg/dashboard/api.go:471` | -| `POST` | `/api/inception/answer` | Owner only | Inception Answer | `pkg/dashboard/api.go:472` | -| `POST` | `/api/inception/facts` | Owner only | Inception Record Facts | `pkg/dashboard/api.go:473` | -| `GET` | `/api/inception/scaffold` | Dashboard auth/session | Inception Scaffold | `pkg/dashboard/api.go:474` | -| `POST` | `/api/inception/approve` | Owner only | Inception Approve | `pkg/dashboard/api.go:475` | -| `POST` | `/api/inception/reset` | Owner only | Inception Reset | `pkg/dashboard/api.go:476` | -| `GET` | `/api/inception/ideation-facts` | Dashboard auth/session | Inception Ideation Facts | `pkg/dashboard/api.go:477` | -| `GET` | `/api/inception/download` | Dashboard auth/session | Inception Download | `pkg/dashboard/api.go:478` | -| `GET` | `/api/inception/has-files` | Dashboard auth/session | Inception Has Files | `pkg/dashboard/api.go:479` | -| `PUT` | `/api/inception/wiki-name` | Owner only | Inception Rename Wiki | `pkg/dashboard/api.go:480` | -| `POST` | `/api/inception/import` | Owner only | Inception Import | `pkg/dashboard/api.go:481` | +| `POST` | `/api/inception/start` | Owner only | Inception Start | `pkg/dashboard/api.go:471` | +| `POST` | `/api/inception/scan` | Owner only | Inception Scan | `pkg/dashboard/api.go:472` | +| `GET` | `/api/inception/state` | Dashboard auth/session | Inception State | `pkg/dashboard/api.go:473` | +| `POST` | `/api/inception/questions` | Owner only | Inception Set Questions | `pkg/dashboard/api.go:474` | +| `POST` | `/api/inception/answer` | Owner only | Inception Answer | `pkg/dashboard/api.go:475` | +| `POST` | `/api/inception/facts` | Owner only | Inception Record Facts | `pkg/dashboard/api.go:476` | +| `GET` | `/api/inception/scaffold` | Dashboard auth/session | Inception Scaffold | `pkg/dashboard/api.go:477` | +| `POST` | `/api/inception/approve` | Owner only | Inception Approve | `pkg/dashboard/api.go:478` | +| `POST` | `/api/inception/reset` | Owner only | Inception Reset | `pkg/dashboard/api.go:479` | +| `GET` | `/api/inception/ideation-facts` | Dashboard auth/session | Inception Ideation Facts | `pkg/dashboard/api.go:480` | +| `GET` | `/api/inception/download` | Dashboard auth/session | Inception Download | `pkg/dashboard/api.go:481` | +| `GET` | `/api/inception/has-files` | Dashboard auth/session | Inception Has Files | `pkg/dashboard/api.go:482` | +| `PUT` | `/api/inception/wiki-name` | Owner only | Inception Rename Wiki | `pkg/dashboard/api.go:483` | +| `POST` | `/api/inception/import` | Owner only | Inception Import | `pkg/dashboard/api.go:484` | ## Beads | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `GET` | `/api/beads` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:527` | -| `GET` | `/api/beads/{agent}` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:528` | -| `POST` | `/api/beads/{agent}` | Owner only | Beads Create | `pkg/dashboard/api.go:529` | -| `POST` | `/api/beads/reset` | Owner only | Beads Reset | `pkg/dashboard/api.go:530` | -| `POST` | `/api/beads/reset/{agent}` | Owner only | Beads Reset Agent | `pkg/dashboard/api.go:531` | +| `GET` | `/api/beads` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:530` | +| `GET` | `/api/beads/{agent}` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:531` | +| `POST` | `/api/beads/{agent}` | Owner only | Beads Create | `pkg/dashboard/api.go:532` | +| `POST` | `/api/beads/reset` | Owner only | Beads Reset | `pkg/dashboard/api.go:533` | +| `POST` | `/api/beads/reset/{agent}` | Owner only | Beads Reset Agent | `pkg/dashboard/api.go:534` | ## Dashboard miscellaneous | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| | `GET` | `/api/audit` | Read-write role | Audit Log β€” `{"entries": [...]}` envelope, newest first, capped at 200; response shape and the serve-time `user_name` field in [audit-log.md](audit-log.md#get-apiaudit) | `pkg/dashboard/api.go:64` | -| `GET` | `/api/watchdog/activity` | Read-write role | Watchdog activity readout for the Health tab (#7254): `watchdog-*` audit actions over `?days=` (default 30, clamped to 90) β€” total / taken / observed / `byAction`, a zero-filled per-day `daily` histogram, per-agent `agents` liveness, and the Observe β†’ Heal `promotion` hint; see [agent-watchdog.md](agent-watchdog.md#watchdog-activity-strip) | `pkg/dashboard/api.go:538` | +| `GET` | `/api/watchdog/activity` | Read-write role | Watchdog activity readout for the Health tab (#7254): `watchdog-*` audit actions over `?days=` (default 30, clamped to 90) β€” total / taken / observed / `byAction`, a zero-filled per-day `daily` histogram, per-agent `agents` liveness, and the Observe β†’ Heal `promotion` hint; see [agent-watchdog.md](agent-watchdog.md#watchdog-activity-strip) | `pkg/dashboard/api.go:541` | | `POST` | `/api/presence` | Dashboard auth/session | Presence | `pkg/dashboard/api.go:73` | | `GET` | `/api/prompt-history` | Dashboard auth/session | Prompt History | `pkg/dashboard/api.go:74` | | `POST` | `/api/self-upgrade` | Owner only | Self Upgrade | `pkg/dashboard/api.go:75` | @@ -613,11 +613,11 @@ always resolved server-side from the validated token. | `GET` | `/api/convergence/soak` | Owner only | Convergence Soak Status | `pkg/dashboard/api.go:281` | -| `POST` | `/api/plan/from-issue` | Dashboard auth/session | Plan From Issue | `pkg/dashboard/api.go:486` | -| `GET` | `/api/plan/{epicID}` | Dashboard auth/session | Plan Tree | `pkg/dashboard/api.go:489` | -| `POST` | `/api/plan/{epicID}/approve` | Owner only | Plan Approve | `pkg/dashboard/api.go:490` | -| `POST` | `/api/plan/{epicID}/reject` | Owner only | Plan Reject | `pkg/dashboard/api.go:492` | -| `POST` | `/api/plan/{epicID}/child/{childID}` | Owner only | Plan Child Action | `pkg/dashboard/api.go:493` | +| `POST` | `/api/plan/from-issue` | Dashboard auth/session | Plan From Issue | `pkg/dashboard/api.go:489` | +| `GET` | `/api/plan/{epicID}` | Dashboard auth/session | Plan Tree | `pkg/dashboard/api.go:492` | +| `POST` | `/api/plan/{epicID}/approve` | Owner only | Plan Approve | `pkg/dashboard/api.go:493` | +| `POST` | `/api/plan/{epicID}/reject` | Owner only | Plan Reject | `pkg/dashboard/api.go:495` | +| `POST` | `/api/plan/{epicID}/child/{childID}` | Owner only | Plan Child Action | `pkg/dashboard/api.go:496` | | `POST` | `/api/linear/agent/install` | Owner only | Linear Agent Install (OAuth start) | `pkg/dashboard/api_linear_agent.go:219` | | `GET` | `/api/linear/agent/status` | Owner only | Linear Agent Status | `pkg/dashboard/api_linear_agent.go:220` | | `POST` | `/api/linear/agent/disconnect` | Owner only | Linear Agent Disconnect | `pkg/dashboard/api_linear_agent.go:221` | @@ -633,11 +633,11 @@ always resolved server-side from the validated token. | `GET` | `/api/summaries` | Dashboard auth/session | Summaries | `pkg/dashboard/api.go:203` | | `GET` | `/api/stage-tracker` | Dashboard auth/session | Stage Tracker: every open issue and pull request of the watched repos for the Hive activity monitor β€” actionable, held, claimed, in-progress, covered-by-PR, filtered (`needs-*`), draft and exempt-labelled items alike, each with `stage`, `stage_since`, `labels`, `created_at`, `author_kind`, `modifiers`, (PRs) `sentinel_alert` / `sentinel_findings` / `sentinel_reason` when the sentinel flagged the PR, `finisher` / `finisher_verb` / `finisher_reason` (who can finish the item, from the your-turn decision: `hive`, `person` when it is the operator's turn, `elsewhere` when it waits on someone else (a hold, a covering PR, a reporter, an open blocker, or its author on a returned PR or a person's draft), absent when the snapshot has no decision; also on each `linked_prs` entry), `decision_needed` on a row parked on a person (085 / 045 / 015, the needs-human pill; mirrored onto the issue's `linked_prs[]` entry): `{question, asked_by, since, blocker, blocker_reason, options: [{action, label, enabled, reason_if_disabled}]}` β€” the reviewer's own decision question when the verdict record has one, the blocker in plain words, and the applicable actions under the row context menu's action ids (`decide:delegate` first), and (issues) `linked_prs`; items with no stage yet report stage `0` "Open"; `needs_human_labels` is the "a person must act" label set behind the monitor's Needs human facet (the same list as `/api/status.labelPresets[needs-human]`). Standing meta issues (bot Dependency Dashboards and the Hive Advisory Report) are in `items` with `bucket` `dependency_dashboard` / `hive_advisory`, so `open_issues` equals the Overview tile, but the monitor never draws them as rows; `recently_merged` lists the PRs (`repo`, `number`, `merged_at`) whose `pr_merged` audit entry is under 30 minutes old, so a client diffing two payloads can tell a merge from a close; `merged` is the durable merged ledger's summary β€” `{today, total_7d, total_30d, all_time, recent: [≀20 Γ— {repo, number, title, url, merged_at, merged_by (`hive` or a login), lane, issue}], tz, day_start}`, `today` rolling at midnight in the spoke's zone β€” behind the monitor's Merged chip. Served from a server-side cache rebuilt at most every `dashboard.stage_tracker.cache_ttl_s` (default `governor.eval_interval_s`) from the spoke's existing snapshot β€” a request never calls GitHub. Carries `generated_at`, `source` (`cache` or `fresh`), `cache_ttl_s`, an `ETag` and `Cache-Control: private, max-age=`; `If-None-Match` yields 304. Each hit is recorded under REST caller `dashboard:stage-tracker` so `/api/gh-rate-limits` shows it charged nothing. | `pkg/dashboard/api.go:204` | | `POST` | `/api/prs/{owner}/{repo}/{number}/queue-automerge` | Merger/owner role | Queue PRAuto Merge | `pkg/dashboard/api.go:206` | -| `GET` | `/api/inference/models/{backend}` | Dashboard auth/session | Inference Models | `pkg/dashboard/api.go:401` | -| `GET` | `/api/hive-id` | Dashboard auth/session | Hive IDGet | `pkg/dashboard/api.go:465` | -| `PUT` | `/api/hive-id` | Owner only | Hive IDSet | `pkg/dashboard/api.go:466` | -| `POST` | `/api/chat` | Dashboard auth/session | Chat. JSON body `{"query": "", "history": [...]}`; `message` is accepted as an alias for `query` (`query` wins when both are set). A missing or blank field returns 400 `query is required`. `!`-prefixed commands go to the chat spine (`{"accepted":true,"seq":N}`, read-write); while an inception is in `clarify` with unanswered questions, an owner's unprefixed reply also goes to the spine, which submits it as the answer to the next unanswered question. | `pkg/dashboard/api.go:495` | -| `GET` | `/api/auth/token` | Public | Auth Token | `pkg/dashboard/api.go:533` | +| `GET` | `/api/inference/models/{backend}` | Dashboard auth/session | Inference Models | `pkg/dashboard/api.go:404` | +| `GET` | `/api/hive-id` | Dashboard auth/session | Hive IDGet | `pkg/dashboard/api.go:468` | +| `PUT` | `/api/hive-id` | Owner only | Hive IDSet | `pkg/dashboard/api.go:469` | +| `POST` | `/api/chat` | Dashboard auth/session | Chat. JSON body `{"query": "", "history": [...]}`; `message` is accepted as an alias for `query` (`query` wins when both are set). A missing or blank field returns 400 `query is required`. `!`-prefixed commands go to the chat spine (`{"accepted":true,"seq":N}`, read-write); while an inception is in `clarify` with unanswered questions, an owner's unprefixed reply also goes to the spine, which submits it as the answer to the next unanswered question. | `pkg/dashboard/api.go:498` | +| `GET` | `/api/auth/token` | Public | Auth Token | `pkg/dashboard/api.go:536` | | `GET` | `/api/v1/` | GitHub token | Contributor v1 API dispatcher | `pkg/dashboard/api_contribute.go:362` | | `POST` | `/api/v1/` | GitHub token | Contributor v1 API dispatcher | `pkg/dashboard/api_contribute.go:363` | | `GET` | `/api/docs` | Dashboard auth/session | APIDocs | `pkg/dashboard/api_contribute.go:364` | @@ -665,7 +665,7 @@ always resolved server-side from the validated token. | `POST` | `/api/hives/{id}/heartbeat` | Dashboard auth/session | Hives Heartbeat | `pkg/dashboard/api_contribute.go:389` | | `DELETE` | `/api/hives/{id}` | Owner only | Hives Delete | `pkg/dashboard/api_contribute.go:390` | | `POST` | `/api/hives/onboard` | Dashboard auth/session | Hives Onboard | `pkg/dashboard/api_contribute.go:391` | -| `GET` | `/sso` | Public | SSO | `pkg/dashboard/server.go:1878` | +| `GET` | `/sso` | Public | SSO | `pkg/dashboard/server.go:1893` | ## Hub SaaS 102 routes β€” see [api-reference/hub-saas.md](api-reference/hub-saas.md). diff --git a/src/docs/api-reference/agents-and-controls.md b/src/docs/api-reference/agents-and-controls.md index adb6c03e2a..896334c6b3 100644 --- a/src/docs/api-reference/agents-and-controls.md +++ b/src/docs/api-reference/agents-and-controls.md @@ -23,21 +23,21 @@ Part of the [Dashboard REST API reference](../api-reference.md); see that page f | `GET` | `/api/model-advisor` | Dashboard auth/session | Model Advisor | `pkg/dashboard/api.go:188` | | `GET` | `/api/governor/pr-models` | Dashboard auth/session | Agent-authored PR distribution by normalized attribution model/backend for `window=7d`, `30d`, or `all`, including per-model rework stats and the top 10 most-reworked PRs | `pkg/dashboard/api.go:189` | | `GET` | `/api/reviewer/accuracy` | Dashboard auth/session | Reviewer verdict calibration for the configured window: false-approve/false-block rates and confidence buckets per perspective and reviewer model | `pkg/dashboard/api.go:256` | -| `GET` | `/api/review/queue` | Dashboard auth/session | PR review queue: every open PR (agent and contributor, actionable and held) ranked by triage class, confidence band, CI state and age, with per-PR `reasons`; unreviewed items carry `pending_since` and `redispatch_count` from the review dispatch state; each entry carries `stage` `{stage, entered_at, last_activity_at, quiet_for, stalled, waiting_on_hive, past_dwell, history[]}` from the stage-label store (`stalled`/`past_dwell` follow `governor.labels.stage.dwell` and `stalled.require_quiet`; `history[]` is `{from, to, at, reason, regressed}`, newest last, capped at 8) and the top-level `hive_held` counts PRs past their stage dwell while waiting on the hive (`waiting_on_hive` and `past_dwell` both true); paged with `limit` (1-200, default 50) and `offset` | `pkg/dashboard/api.go:543` | -| `POST` | `/api/review/queue/{owner}/{repo}/{number}/superseded/close` | Owner only | Close a superseded PR now (grace window or not): posts the close comment, adds `hive/superseded`, closes it | `pkg/dashboard/api.go:546` | -| `POST` | `/api/review/queue/{owner}/{repo}/{number}/superseded/keep-open` | Owner only | Keep a superseded PR open: adds `hive/keep-open`, which the supersession sweep never auto-closes | `pkg/dashboard/api.go:547` | -| `GET` | `/api/review/pipeline` | Dashboard auth/session | Review pipeline: one card per review-queue PR placed in its stage (`unreviewed`, `reviewing`, `changes_requested`, `fixing`, `human_hold`, `approved`, `merged`, `abandoned`) with `since`, `reviewers`, P0–P3 `severity` counts, `loop_count`/`loop_cap`, `next_action` and `reasons`; filtered by `repo` and `stage`, paged with `limit` (1-200, default 50) and `offset` | `pkg/dashboard/api.go:551` | +| `GET` | `/api/review/queue` | Dashboard auth/session | PR review queue: every open PR (agent and contributor, actionable and held) ranked by triage class, confidence band, CI state and age, with per-PR `reasons`; unreviewed items carry `pending_since` and `redispatch_count` from the review dispatch state; each entry carries `stage` `{stage, entered_at, last_activity_at, quiet_for, stalled, waiting_on_hive, past_dwell, history[]}` from the stage-label store (`stalled`/`past_dwell` follow `governor.labels.stage.dwell` and `stalled.require_quiet`; `history[]` is `{from, to, at, reason, regressed}`, newest last, capped at 8) and the top-level `hive_held` counts PRs past their stage dwell while waiting on the hive (`waiting_on_hive` and `past_dwell` both true); paged with `limit` (1-200, default 50) and `offset` | `pkg/dashboard/api.go:546` | +| `POST` | `/api/review/queue/{owner}/{repo}/{number}/superseded/close` | Owner only | Close a superseded PR now (grace window or not): posts the close comment, adds `hive/superseded`, closes it | `pkg/dashboard/api.go:549` | +| `POST` | `/api/review/queue/{owner}/{repo}/{number}/superseded/keep-open` | Owner only | Keep a superseded PR open: adds `hive/keep-open`, which the supersession sweep never auto-closes | `pkg/dashboard/api.go:550` | +| `GET` | `/api/review/pipeline` | Dashboard auth/session | Review pipeline: one card per review-queue PR placed in its stage (`unreviewed`, `reviewing`, `changes_requested`, `fixing`, `human_hold`, `approved`, `merged`, `abandoned`) with `since`, `reviewers`, P0–P3 `severity` counts, `loop_count`/`loop_cap`, `next_action` and `reasons`; filtered by `repo` and `stage`, paged with `limit` (1-200, default 50) and `offset` | `pkg/dashboard/api.go:554` | | `GET` | `/api/review/dispatch/events` | Dashboard auth/session | Webhook review dispatch queue: `enabled`, `webhooks_configured`, `debounce_s`, `pending` (soonest first) and `recent` (newest first) dispatches with repo, number, head SHA, action, timestamps and coalesced count, plus `stats` counters; in memory, never calls GitHub | `pkg/dashboard/api_review_event_dispatch.go:15` | -| `GET` | `/api/review/evidence` | Merger or owner | [Review evidence bundle](../review-evidence.md) for one PR head. Query `repo` (`owner/name`), `number`, optional `head` (full SHA or unique prefix of at least 7 characters; `409` when ambiguous; default is the newest head), `format=json\|zip` (default `json`) and `download=1` for an attachment. `json` returns the bundle bytes exactly as sealed (verdicts, posted reviews, policy, CI check runs, sentinel findings, human actions, merge event, `hash`, optional `signature`); `zip` adds `verdicts.json`, `review-links.json` and a `manifest.json` of SHA-256s plus the signing public key when readable. `400` for a bad `repo`/`number`; `404` with `status: "expired"`/`expired: true` when retention removed a bundle Hive can prove existed, otherwise `status: "not_found"` | `pkg/dashboard/api.go:555` | -| `GET` | `/api/review/evidence/list` | Merger or owner | Every retained evidence bundle id and head for one PR (`repo`, `number`), oldest first, with the newest marked latest; `400` for a bad `repo`/`number` | `pkg/dashboard/api.go:556` | -| `GET` | `/api/evidence/write-counters` | Merger or owner | Durable attempted vs blocked agent GitHub write counters ([#12069](https://github.com/hivecommons/hive/issues/12069)): `totals` by intent tier (ACMM mode) with `blocked_by_reason`, and `weeks` keyed by the UTC Monday of each ISO week with per-tier and per-reason counts. Persisted across restarts; observation only, never gates a write or merge. Empty `totals`/`weeks` before anything is recorded | `pkg/dashboard/api.go:561` | -| `GET` | `/api/evidence/summary` | Dashboard auth/session | Read-only aggregate over `/data/evidence` bundles (schema v1) plus reviewer accuracy. Query `hours` (default 24, capped like `/api/pr-throughput`; `0` = all time) or a custom `from`/`to` range (RFC3339 or `YYYY-MM-DD`, date-only `to` inclusive, `to` defaults to now; `400` when `to` precedes `from`, the span exceeds 3 Γ— 366 days, or `to` is given without `from`; the response echoes `from`/`to`), `repo` (`owner/name`) and `author_kind=human\|agent\|bot`; `400` for a bad value. A PR falls in the window by its open time (earliest bundle `created_at` or `merge.opened_at`), never by when a bundle was written; all bundles of a PR are folded so the newest supplies verdicts and the merge / `reverted_by` / backfilled close come from whichever record knows them. Returns `available_windows` (`{"24","168","720","all"}` β†’ PR count under the same filters), `outcomes_by_author_kind` (merged/closed/open; `closed` comes from backfilled records, `reverted_14d` is null until bundles record it), `reviewer_calibration` buckets per perspective, `reviewer_accuracy_by_model` split by author kind (blocks split into `enforced_blocks` / `advisory_blocks` by `policy.require_approval`; advisory blocks that preceded a merge are `dissent_before_merge`, not false blocks), `sentinel_hits_per_week`, `shadow_gate` (`n`, `would_have_held`, `would_have_blocked`, and per gate `review_gate` / `intent` the evaluated `n` with `held` / `passed` outcomes `{n, merged, closed, open, reverted}`, plus `review_gate_by_lane` `{merge_eligible, self_merge}`), `write_counters` (per intent tier `{attempted, blocked, blocked_by_reason}` from the proxy write counter file; omitted when absent or unreadable), `write_activity` (blocked Γ· every agent write in the window: `total_writes`, `blocked`, `direct` `{recorded, since, partial_coverage, attempted, blocked, by_tier, blocked_by_reason}` from the proxy's weekly counter buckets under `/data/hive-metrics/write-counters.json`, `mediated` `{n, refused, by_action}` from the audit log), `claim_vs_verdict` (per ISO week of agent-authored PRs: `n`, `claimed`, `ci_captured`, `ci_not_green`, `verdict_not_approve`, `both`; records with empty CI are excluded from the CI column and disclosed under `missing.records_empty_ci`), `ci_recovery` (`policy` `{self_authored_automerge, required_checks}`, `n`, `at_claim` `{green, red, pending, none}`, `red` `{n, recovered_merged, green_open, abandoned, pending, outcome_unknown, enriched, not_enriched, median_pushes_to_green, median_hours_to_green, same_head_recovered}`, `gate` `{merged_n, with_checks, green, green_from_bundles, non_required_red, required_unknown, breaches, breach_prs[], suspected, suspected_prs[], pending, unknown, green_to_merge_n, median_green_to_merge_hours}` over every merged PR by GitHub-confirmed state of the *required* checks on the merged head, as the merge gate judges (`breach_prs[]` carry `red`, `merged_by`; a red non-required check is disclosed, not a breach; a red review-time snapshot, a required check with no run, or a red read without a resolvable required set is only suspected), and `convergence` `{n, passes {"1","2","3","4+","never"}, settled, in_flight, unknown, median_passes, from_github, from_bundles, by_week[]}` over agent PRs by CI passes to first green, from the durable `/data/hive-metrics/ci-recovery.json` GitHub check-run trail rollup), `work_balance` (per ISO week: `n`, `opened` by actor kind `{human, agent, bot}`, `closed`, `prs_opened`, `prs_merged`, `prs_closed`, `queue_issues`, `queue_prs`, `issue_source` `github|audit`, `throttled`) with `work_balance_sources` (`issues_github_since`, `issues_fetched_at`, `issues_known`, `audit_only_weeks`, `issue_series_weeks`, `queue_samples`, `queue_retention_days`; issues from the durable GitHub rollup `/data/hive-metrics/issue-activity.json` for weeks at or after `issues_github_since` and, for earlier weeks, from the backfilled issue series `///issues/.json` written by the issue backfill ([#12527](https://github.com/hivecommons/hive/pull/12527)) when its coverage spans the requested portion of the week (`issue_series_weeks` counts the repo-weeks it supplied), else the audit log (`audit_only_weeks`), PRs from folded bundles, queue depth from governor snapshots; `missing.issues_human_opens_unobserved` is true while no GitHub rollup exists unless the issue series covers every row, and whenever any row fell back to the audit log, `missing.throttle_windows_unavailable` discloses that no governor throttle windows are reported), `intent_counters` (per intent tier `{attempted, blocked}` from `/data/hive-metrics/intent-counters.json` β€” evaluations and the subset the intent gate would have blocked with enforcement on, [#12362](https://github.com/hivecommons/hive/issues/12362); omitted when absent or unreadable), `complexity_by_repo` (per managed repo the newest 12 monthly complexity snapshots, oldest first, each `{month, ref, captured_at, files, functions, mean_cyclomatic, median_cyclomatic, p90_cyclomatic, max_cyclomatic, functions_over_threshold, threshold, static_warnings, static_warnings_by_tool}`; omitted when no repo has a snapshot), the `/api/reviewer/accuracy` summary, and `n` plus `missing` counts (unknown-model verdicts, empty CI, unreadable records). Never consulted by merge decisions. | `pkg/dashboard/api.go:564` | -| `GET` | `/api/agents` | Dashboard auth/session | Agents List | `pkg/dashboard/api.go:338` | -| `POST` | `/api/agents` | Owner only | Agent Create | `pkg/dashboard/api.go:339` | -| `POST` | `/api/agents/import` | Owner only | Agent Import | `pkg/dashboard/api.go:340` | -| `DELETE` | `/api/agents/{name}` | Owner only | Agent Delete | `pkg/dashboard/api.go:341` | - -| `GET` | `/api/benchmarks/export` | Owner only | Anonymized per-PR [benchmark records](../benchmarks.md) (schema `hivecommons/benchmarks` `result.schema.json`) built from the evidence bundles updated since `since` (RFC3339 or `YYYY-MM-DD`; default 90 days; `400` when malformed) plus `hive_hash`, `publish`, `generated_at` and `count`. `records` is empty when `benchmarks.publish: false`; repo/org names appear only for GitHub-public repos when `benchmarks.repo_consent` is on | `pkg/dashboard/api.go:566` | +| `GET` | `/api/review/evidence` | Merger or owner | [Review evidence bundle](../review-evidence.md) for one PR head. Query `repo` (`owner/name`), `number`, optional `head` (full SHA or unique prefix of at least 7 characters; `409` when ambiguous; default is the newest head), `format=json\|zip` (default `json`) and `download=1` for an attachment. `json` returns the bundle bytes exactly as sealed (verdicts, posted reviews, policy, CI check runs, sentinel findings, human actions, merge event, `hash`, optional `signature`); `zip` adds `verdicts.json`, `review-links.json` and a `manifest.json` of SHA-256s plus the signing public key when readable. `400` for a bad `repo`/`number`; `404` with `status: "expired"`/`expired: true` when retention removed a bundle Hive can prove existed, otherwise `status: "not_found"` | `pkg/dashboard/api.go:558` | +| `GET` | `/api/review/evidence/list` | Merger or owner | Every retained evidence bundle id and head for one PR (`repo`, `number`), oldest first, with the newest marked latest; `400` for a bad `repo`/`number` | `pkg/dashboard/api.go:559` | +| `GET` | `/api/evidence/write-counters` | Merger or owner | Durable attempted vs blocked agent GitHub write counters ([#12069](https://github.com/hivecommons/hive/issues/12069)): `totals` by intent tier (ACMM mode) with `blocked_by_reason`, and `weeks` keyed by the UTC Monday of each ISO week with per-tier and per-reason counts. Persisted across restarts; observation only, never gates a write or merge. Empty `totals`/`weeks` before anything is recorded | `pkg/dashboard/api.go:564` | +| `GET` | `/api/evidence/summary` | Dashboard auth/session | Read-only aggregate over `/data/evidence` bundles (schema v1) plus reviewer accuracy. Query `hours` (default 24, capped like `/api/pr-throughput`; `0` = all time) or a custom `from`/`to` range (RFC3339 or `YYYY-MM-DD`, date-only `to` inclusive, `to` defaults to now; `400` when `to` precedes `from`, the span exceeds 3 Γ— 366 days, or `to` is given without `from`; the response echoes `from`/`to`), `repo` (`owner/name`) and `author_kind=human\|agent\|bot`; `400` for a bad value. A PR falls in the window by its open time (earliest bundle `created_at` or `merge.opened_at`), never by when a bundle was written; all bundles of a PR are folded so the newest supplies verdicts and the merge / `reverted_by` / backfilled close come from whichever record knows them. Returns `available_windows` (`{"24","168","720","all"}` β†’ PR count under the same filters), `outcomes_by_author_kind` (merged/closed/open; `closed` comes from backfilled records, `reverted_14d` is null until bundles record it), `reviewer_calibration` buckets per perspective, `reviewer_accuracy_by_model` split by author kind (blocks split into `enforced_blocks` / `advisory_blocks` by `policy.require_approval`; advisory blocks that preceded a merge are `dissent_before_merge`, not false blocks), `sentinel_hits_per_week`, `shadow_gate` (`n`, `would_have_held`, `would_have_blocked`, and per gate `review_gate` / `intent` the evaluated `n` with `held` / `passed` outcomes `{n, merged, closed, open, reverted}`, plus `review_gate_by_lane` `{merge_eligible, self_merge}`), `write_counters` (per intent tier `{attempted, blocked, blocked_by_reason}` from the proxy write counter file; omitted when absent or unreadable), `write_activity` (blocked Γ· every agent write in the window: `total_writes`, `blocked`, `direct` `{recorded, since, partial_coverage, attempted, blocked, by_tier, blocked_by_reason}` from the proxy's weekly counter buckets under `/data/hive-metrics/write-counters.json`, `mediated` `{n, refused, by_action}` from the audit log), `claim_vs_verdict` (per ISO week of agent-authored PRs: `n`, `claimed`, `ci_captured`, `ci_not_green`, `verdict_not_approve`, `both`; records with empty CI are excluded from the CI column and disclosed under `missing.records_empty_ci`), `ci_recovery` (`policy` `{self_authored_automerge, required_checks}`, `n`, `at_claim` `{green, red, pending, none}`, `red` `{n, recovered_merged, green_open, abandoned, pending, outcome_unknown, enriched, not_enriched, median_pushes_to_green, median_hours_to_green, same_head_recovered}`, `gate` `{merged_n, with_checks, green, green_from_bundles, non_required_red, required_unknown, breaches, breach_prs[], suspected, suspected_prs[], pending, unknown, green_to_merge_n, median_green_to_merge_hours}` over every merged PR by GitHub-confirmed state of the *required* checks on the merged head, as the merge gate judges (`breach_prs[]` carry `red`, `merged_by`; a red non-required check is disclosed, not a breach; a red review-time snapshot, a required check with no run, or a red read without a resolvable required set is only suspected), and `convergence` `{n, passes {"1","2","3","4+","never"}, settled, in_flight, unknown, median_passes, from_github, from_bundles, by_week[]}` over agent PRs by CI passes to first green, from the durable `/data/hive-metrics/ci-recovery.json` GitHub check-run trail rollup), `work_balance` (per ISO week: `n`, `opened` by actor kind `{human, agent, bot}`, `closed`, `prs_opened`, `prs_merged`, `prs_closed`, `queue_issues`, `queue_prs`, `issue_source` `github|audit`, `throttled`) with `work_balance_sources` (`issues_github_since`, `issues_fetched_at`, `issues_known`, `audit_only_weeks`, `issue_series_weeks`, `queue_samples`, `queue_retention_days`; issues from the durable GitHub rollup `/data/hive-metrics/issue-activity.json` for weeks at or after `issues_github_since` and, for earlier weeks, from the backfilled issue series `///issues/.json` written by the issue backfill ([#12527](https://github.com/hivecommons/hive/pull/12527)) when its coverage spans the requested portion of the week (`issue_series_weeks` counts the repo-weeks it supplied), else the audit log (`audit_only_weeks`), PRs from folded bundles, queue depth from governor snapshots; `missing.issues_human_opens_unobserved` is true while no GitHub rollup exists unless the issue series covers every row, and whenever any row fell back to the audit log, `missing.throttle_windows_unavailable` discloses that no governor throttle windows are reported), `intent_counters` (per intent tier `{attempted, blocked}` from `/data/hive-metrics/intent-counters.json` β€” evaluations and the subset the intent gate would have blocked with enforcement on, [#12362](https://github.com/hivecommons/hive/issues/12362); omitted when absent or unreadable), `complexity_by_repo` (per managed repo the newest 12 monthly complexity snapshots, oldest first, each `{month, ref, captured_at, files, functions, mean_cyclomatic, median_cyclomatic, p90_cyclomatic, max_cyclomatic, functions_over_threshold, threshold, static_warnings, static_warnings_by_tool}`; omitted when no repo has a snapshot), the `/api/reviewer/accuracy` summary, and `n` plus `missing` counts (unknown-model verdicts, empty CI, unreadable records). Never consulted by merge decisions. | `pkg/dashboard/api.go:567` | +| `GET` | `/api/agents` | Dashboard auth/session | Agents List | `pkg/dashboard/api.go:341` | +| `POST` | `/api/agents` | Owner only | Agent Create | `pkg/dashboard/api.go:342` | +| `POST` | `/api/agents/import` | Owner only | Agent Import | `pkg/dashboard/api.go:343` | +| `DELETE` | `/api/agents/{name}` | Owner only | Agent Delete | `pkg/dashboard/api.go:344` | + +| `GET` | `/api/benchmarks/export` | Owner only | Anonymized per-PR [benchmark records](../benchmarks.md) (schema `hivecommons/benchmarks` `result.schema.json`) built from the evidence bundles updated since `since` (RFC3339 or `YYYY-MM-DD`; default 90 days; `400` when malformed) plus `hive_hash`, `publish`, `generated_at` and `count`. `records` is empty when `benchmarks.publish: false`; repo/org names appear only for GitHub-public repos when `benchmarks.repo_consent` is on | `pkg/dashboard/api.go:569` | | `GET` | `/api/agents/{name}/log` | Dashboard auth/session | Agent Full Log | `pkg/dashboard/api.go:139` | | `GET` | `/api/agents/{name}/terminal-urls` | Dashboard auth/session | Agent Terminal URLs | `pkg/dashboard/api.go:143` | | `GET` | `/api/agents/{name}/kicks` | Dashboard auth/session | Agent Kick Log List | `pkg/dashboard/api.go:146` | diff --git a/src/docs/api-reference/auth.md b/src/docs/api-reference/auth.md index e5e2eb8e3e..c7c182fa25 100644 --- a/src/docs/api-reference/auth.md +++ b/src/docs/api-reference/auth.md @@ -24,6 +24,6 @@ Part of the [Dashboard REST API reference](../api-reference.md); see that page f | `GET` | `/api/openrouter/models` | Dashboard auth/session | Open Router Models | `pkg/dashboard/openrouter.go:47` | | `GET` | `/api/openrouter/credit` | Dashboard auth/session | Open Router Credit | `pkg/dashboard/openrouter.go:48` | | `GET` | `/openrouter/callback` | Public | Open Router Callback | `pkg/dashboard/openrouter.go:49` | -| `POST` | `/api/github-app/recheck` | Dashboard auth/session | GitHub App Recheck | `pkg/dashboard/server.go:1871` | -| `POST` | `/api/github-app/install-clicked` | Dashboard auth/session | GitHub App Install Clicked | `pkg/dashboard/server.go:1872` | -| `GET` | `/gh-setup` | Public | GitHub App Setup Callback | `pkg/dashboard/server.go:1873` | +| `POST` | `/api/github-app/recheck` | Dashboard auth/session | GitHub App Recheck | `pkg/dashboard/server.go:1886` | +| `POST` | `/api/github-app/install-clicked` | Dashboard auth/session | GitHub App Install Clicked | `pkg/dashboard/server.go:1887` | +| `GET` | `/gh-setup` | Public | GitHub App Setup Callback | `pkg/dashboard/server.go:1888` | diff --git a/src/docs/api-reference/beads.md b/src/docs/api-reference/beads.md index 047ca85b2a..42b122f466 100644 --- a/src/docs/api-reference/beads.md +++ b/src/docs/api-reference/beads.md @@ -5,8 +5,8 @@ Part of the [Dashboard REST API reference](../api-reference.md); see that page f | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `GET` | `/api/beads` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:527` | -| `GET` | `/api/beads/{agent}` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:528` | -| `POST` | `/api/beads/{agent}` | Owner only | Beads Create | `pkg/dashboard/api.go:529` | -| `POST` | `/api/beads/reset` | Owner only | Beads Reset | `pkg/dashboard/api.go:530` | -| `POST` | `/api/beads/reset/{agent}` | Owner only | Beads Reset Agent | `pkg/dashboard/api.go:531` | +| `GET` | `/api/beads` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:530` | +| `GET` | `/api/beads/{agent}` | Dashboard auth/session | Beads List | `pkg/dashboard/api.go:531` | +| `POST` | `/api/beads/{agent}` | Owner only | Beads Create | `pkg/dashboard/api.go:532` | +| `POST` | `/api/beads/reset` | Owner only | Beads Reset | `pkg/dashboard/api.go:533` | +| `POST` | `/api/beads/reset/{agent}` | Owner only | Beads Reset Agent | `pkg/dashboard/api.go:534` | diff --git a/src/docs/api-reference/configuration.md b/src/docs/api-reference/configuration.md index 3a169c6611..6b45c70280 100644 --- a/src/docs/api-reference/configuration.md +++ b/src/docs/api-reference/configuration.md @@ -75,17 +75,17 @@ Part of the [Dashboard REST API reference](../api-reference.md); see that page f | `POST` | `/api/config/governor/repos/check-access` | Dashboard auth/session | Governor Repo Check Access | `pkg/dashboard/api.go:332` | | `PUT` | `/api/config/github` | Owner only | Config GitHub | `pkg/dashboard/api.go:333` | | `GET` | `/api/config/github/forge-apps` | Dashboard auth/session | Config GitHub Forge Apps | `pkg/dashboard/api.go:336` | -| `GET` | `/api/config/sidebar` | Dashboard auth/session | Sidebar Get | `pkg/dashboard/api.go:396` | -| `PUT` | `/api/config/sidebar` | Dashboard auth/session | Sidebar Set | `pkg/dashboard/api.go:397` | -| `GET` | `/api/config/backends` | Dashboard auth/session | Backends | `pkg/dashboard/api.go:398` | -| `GET` | `/api/compliance/status` | Merger or owner | Evaluated compliance controls for the frameworks in `compliance.frameworks` (`compliance.Report`, JSON): per control `meets`/`deviates`/`off`/`not_covered` with current vs recommended value per mapped setting, a summary, and the non-certification disclaimer β€” see [Compliance](../compliance.md) ([#11078](https://github.com/hivecommons/hive/issues/11078)) | `pkg/dashboard/api.go:388` | -| `GET` | `/api/compliance/posture` | Merger or owner | Posture-check catalogue, effective interval / window / history retention, and the latest run (`compliance.PostureRun`: schedule/manual trigger, `triggered_by` identity, and per-check `pass`/`fail`/`skip`/`error`, detail, control ids, evidence refs); `?since=` (RFC 3339, `YYYY-MM-DD` or a look-back such as `168h`) adds every run since then, oldest first, capped at 1000 β€” see [Compliance](../compliance.md#posture-checks-catalogue) ([#11079](https://github.com/hivecommons/hive/issues/11079)) | `pkg/dashboard/api.go:389` | -| `POST` | `/api/compliance/posture/run` | Merger or owner | Run the posture checks now, attributed to the authenticated user, and record the pass in the history; audited as `compliance_posture_run`; `409` when no framework is selected or a pass is already running ([#11079](https://github.com/hivecommons/hive/issues/11079)) | `pkg/dashboard/api.go:390` | -| `GET` | `/api/compliance/posture/history` | Owner only | Posture history for `?since=`/`?until=` (default the last 30 days) as one series per check: points (`at`, `status`), status counts, `last_fail_at` and the latest result with detail and evidence refs; at most the 1000 most recent runs β€” see [Compliance](../compliance.md#evidence-exports-and-attestations) ([#11081](https://github.com/hivecommons/hive/issues/11081)) | `pkg/dashboard/api.go:391` | -| `GET` | `/api/compliance/export` | Owner only | Evidence download for `?since=`/`?until=`: `kind=controls` (`json`/`md`), `posture` (`json`/`csv`), `audit` (`json`), `config` (redacted effective config + sha256, `json`), `attestations` (`json`/`csv`) or `bundle` (`json`). Every format includes source, period, generator, selected profile versions, attesters, retention and a per-section SHA-256 manifest; audited as `compliance_export` with the SHA-256 of the delivered bytes ([#11081](https://github.com/hivecommons/hive/issues/11081), [#13582](https://github.com/hivecommons/hive/issues/13582)) | `pkg/dashboard/api.go:392` | -| `GET` | `/api/compliance/attestations` | Owner only | Owner attestations, newest first (`?framework=` filters) ([#11081](https://github.com/hivecommons/hive/issues/11081)) | `pkg/dashboard/api.go:393` | -| `POST` | `/api/compliance/attestations` | Owner only | Record `{"framework", "reviewed_on", "note"}` for the signed-in owner; audited as `compliance_attestation`; `201` with the attestation, `400` for an unknown framework, a bad or future date, or a note over 2000 characters ([#11081](https://github.com/hivecommons/hive/issues/11081)) | `pkg/dashboard/api.go:394` | -| `PUT` | `/api/config/governor/compliance` | Owner only | Settings β†’ Compliance framework picker: `{"frameworks": [...]}` sets `compliance.frameworks` (empty list deselects all, absent key leaves it unchanged; unknown/duplicate ids are a `400` before any change); audited as `config_compliance`; returns the re-evaluated `compliance.Report` ([#11080](https://github.com/hivecommons/hive/issues/11080)) | `pkg/dashboard/api.go:573` | +| `GET` | `/api/config/sidebar` | Dashboard auth/session | Sidebar Get | `pkg/dashboard/api.go:399` | +| `PUT` | `/api/config/sidebar` | Dashboard auth/session | Sidebar Set | `pkg/dashboard/api.go:400` | +| `GET` | `/api/config/backends` | Dashboard auth/session | Backends | `pkg/dashboard/api.go:401` | +| `GET` | `/api/compliance/status` | Merger or owner | Evaluated compliance controls for the frameworks in `compliance.frameworks` (`compliance.Report`, JSON): per control `meets`/`deviates`/`off`/`not_covered` with current vs recommended value per mapped setting, a summary, and the non-certification disclaimer β€” see [Compliance](../compliance.md) ([#11078](https://github.com/hivecommons/hive/issues/11078)) | `pkg/dashboard/api.go:391` | +| `GET` | `/api/compliance/posture` | Merger or owner | Posture-check catalogue, effective interval / window / history retention, and the latest run (`compliance.PostureRun`: schedule/manual trigger, `triggered_by` identity, and per-check `pass`/`fail`/`skip`/`error`, detail, control ids, evidence refs); `?since=` (RFC 3339, `YYYY-MM-DD` or a look-back such as `168h`) adds every run since then, oldest first, capped at 1000 β€” see [Compliance](../compliance.md#posture-checks-catalogue) ([#11079](https://github.com/hivecommons/hive/issues/11079)) | `pkg/dashboard/api.go:392` | +| `POST` | `/api/compliance/posture/run` | Merger or owner | Run the posture checks now, attributed to the authenticated user, and record the pass in the history; audited as `compliance_posture_run`; `409` when no framework is selected or a pass is already running ([#11079](https://github.com/hivecommons/hive/issues/11079)) | `pkg/dashboard/api.go:393` | +| `GET` | `/api/compliance/posture/history` | Owner only | Posture history for `?since=`/`?until=` (default the last 30 days) as one series per check: points (`at`, `status`), status counts, `last_fail_at` and the latest result with detail and evidence refs; at most the 1000 most recent runs β€” see [Compliance](../compliance.md#evidence-exports-and-attestations) ([#11081](https://github.com/hivecommons/hive/issues/11081)) | `pkg/dashboard/api.go:394` | +| `GET` | `/api/compliance/export` | Owner only | Evidence download for `?since=`/`?until=`: `kind=controls` (`json`/`md`), `posture` (`json`/`csv`), `audit` (`json`), `config` (redacted effective config + sha256, `json`), `attestations` (`json`/`csv`) or `bundle` (`json`). Every format includes source, period, generator, selected profile versions, attesters, retention and a per-section SHA-256 manifest; audited as `compliance_export` with the SHA-256 of the delivered bytes ([#11081](https://github.com/hivecommons/hive/issues/11081), [#13582](https://github.com/hivecommons/hive/issues/13582)) | `pkg/dashboard/api.go:395` | +| `GET` | `/api/compliance/attestations` | Owner only | Owner attestations, newest first (`?framework=` filters) ([#11081](https://github.com/hivecommons/hive/issues/11081)) | `pkg/dashboard/api.go:396` | +| `POST` | `/api/compliance/attestations` | Owner only | Record `{"framework", "reviewed_on", "note"}` for the signed-in owner; audited as `compliance_attestation`; `201` with the attestation, `400` for an unknown framework, a bad or future date, or a note over 2000 characters ([#11081](https://github.com/hivecommons/hive/issues/11081)) | `pkg/dashboard/api.go:397` | +| `PUT` | `/api/config/governor/compliance` | Owner only | Settings β†’ Compliance framework picker: `{"frameworks": [...]}` sets `compliance.frameworks` (empty list deselects all, absent key leaves it unchanged; unknown/duplicate ids are a `400` before any change); audited as `config_compliance`; returns the re-evaluated `compliance.Report` ([#11080](https://github.com/hivecommons/hive/issues/11080)) | `pkg/dashboard/api.go:576` | | `GET` | `/api/config/governor/threshold-scaling` | Owner only | Governor Threshold Scaling Get | `pkg/dashboard/api.go:233` | | `PUT` | `/api/config/governor/threshold-scaling` | Owner only | Governor Threshold Scaling Set | `pkg/dashboard/api.go:234` | @@ -94,8 +94,8 @@ Part of the [Dashboard REST API reference](../api-reference.md); see that page f | `PUT` | `/api/config/governor/watchdog` | Owner only | Governor Watchdog | `pkg/dashboard/api.go:243` | | `GET` | `/api/config/escalation` | Owner only | Escalation Config Get | `pkg/dashboard/api.go:246` | | `PUT` | `/api/config/escalation` | Owner only | Escalation Config Set | `pkg/dashboard/api.go:247` | -| `GET` | `/api/config/write-surface` | Owner only | Lane write allowlist (`write_surface.allowlist`, [#9587](https://github.com/hivecommons/hive/issues/9587)): `allowlist`, `ops`, `warnings` | `pkg/dashboard/api.go:569` | -| `PUT` | `/api/config/write-surface` | Owner only | Replace the lane write allowlist; `{"allowlist": {}}` clears it; unknown operation or bad lane name is a 400 with nothing changed | `pkg/dashboard/api.go:570` | +| `GET` | `/api/config/write-surface` | Owner only | Lane write allowlist (`write_surface.allowlist`, [#9587](https://github.com/hivecommons/hive/issues/9587)): `allowlist`, `ops`, `warnings` | `pkg/dashboard/api.go:572` | +| `PUT` | `/api/config/write-surface` | Owner only | Replace the lane write allowlist; `{"allowlist": {}}` clears it; unknown operation or bad lane name is a 400 with nothing changed | `pkg/dashboard/api.go:573` | | `GET` | `/api/config/review` | Dashboard auth/session | Review Config Get | `pkg/dashboard/api.go:254` | | `PUT` | `/api/config/review` | Owner only | Review Config Set | `pkg/dashboard/api.go:257` | | `PUT` | `/api/config/governor/features` | Owner only | Governor Features | `pkg/dashboard/api.go:263` | @@ -103,6 +103,9 @@ Part of the [Dashboard REST API reference](../api-reference.md); see that page f | `PUT` | `/api/config/governor/general-advanced` | Owner only | Change those settings; every field is optional and an absent one is left alone. `api_budget_pacing` (boolean) turns GitHub API budget pacing on or off from the next eval cycle. Returns the GET payload | `pkg/dashboard/api.go:265` | | `GET` | `/api/config/auto-merge` | Owner only | Auto-Merge Config Get | `pkg/dashboard/api.go:269` | | `PUT` | `/api/config/auto-merge` | Owner only | Auto-Merge Config Set | `pkg/dashboard/api.go:270` | +| `GET` | `/api/config/github/webhooks` | Owner only | Config GitHub Webhooks (payload URL, events, secret source, receiver health, App hook permissions) | `pkg/dashboard/api_github_webhook_setup.go:88` | +| `POST` | `/api/config/github/webhooks/secret` | Owner only | Config GitHub Webhooks Generate Secret (returned once) | `pkg/dashboard/api_github_webhook_setup.go:89` | +| `POST` | `/api/config/github/webhooks/create` | Owner only | Config GitHub Webhooks Create Hook (through the App installation) | `pkg/dashboard/api_github_webhook_setup.go:90` | | `PATCH` | `/api/config/auto-merge` | Owner only | Partially update auto-merge settings, including nested `merge_train`; omitted fields stay unchanged; returns effective settings and bot-author metadata | `pkg/dashboard/api_config_automerge.go` | | `GET` | `/api/config/convergence` | Owner only | Convergence Config Get | `pkg/dashboard/api.go:276` | | `PUT` | `/api/config/convergence` | Owner only | Convergence Config Set | `pkg/dashboard/api.go:278` | diff --git a/src/docs/api-reference/dashboard-misc.md b/src/docs/api-reference/dashboard-misc.md index 8ea3fbddee..9c4de907e9 100644 --- a/src/docs/api-reference/dashboard-misc.md +++ b/src/docs/api-reference/dashboard-misc.md @@ -25,7 +25,7 @@ The monitor renders CI and Review concurrently on one shared PR row; see | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| | `GET` | `/api/audit` | Read-write role | Audit Log β€” `{"entries": [...]}` envelope, newest first, capped at 200; response shape and the serve-time `user_name` field in [audit-log.md](../audit-log.md#get-apiaudit) | `pkg/dashboard/api.go:64` | -| `GET` | `/api/watchdog/activity` | Read-write role | Watchdog activity readout for the Health tab (#7254): `watchdog-*` audit actions over `?days=` (default 30, clamped to 90) β€” total / taken / observed / `byAction`, a zero-filled per-day `daily` histogram, per-agent `agents` liveness, and the Observe β†’ Heal `promotion` hint; see [agent-watchdog.md](../agent-watchdog.md#watchdog-activity-strip) | `pkg/dashboard/api.go:538` | +| `GET` | `/api/watchdog/activity` | Read-write role | Watchdog activity readout for the Health tab (#7254): `watchdog-*` audit actions over `?days=` (default 30, clamped to 90) β€” total / taken / observed / `byAction`, a zero-filled per-day `daily` histogram, per-agent `agents` liveness, and the Observe β†’ Heal `promotion` hint; see [agent-watchdog.md](../agent-watchdog.md#watchdog-activity-strip) | `pkg/dashboard/api.go:541` | | `POST` | `/api/presence` | Dashboard auth/session | Presence | `pkg/dashboard/api.go:73` | | `POST` | `/api/upgrade/announce` | Owner role | Announce a pod roll before it happens ([#15788](https://github.com/hivecommons/hive/issues/15788)): body `{"target": "", "source": "hub"|"operator", "reason": "..."}` (`version` is an alias of `target`; all fields optional). Marks `/api/status`/`/api/version` `upgradeProgress` as `state: pulling, phase: announced` with the target and publishes an SSE `upgrading` event so every open dashboard shows the upgrade animation; a SIGTERM afterwards (or with no announce at all) publishes `phase: restarting` from the same record. The hub sends the same announce through the heartbeat response (`upgrade_announce`) for hub-managed hives; an announce with no kill expires after 15 minutes. Returns `{ok, upgradeProgress}` | `pkg/dashboard/api.go:76` | | `GET` | `/api/prompt-history` | Dashboard auth/session | Prompt History | `pkg/dashboard/api.go:74` | @@ -40,18 +40,18 @@ The monitor renders CI and Review concurrently on one shared PR row; see | `GET` | `/api/convergence/soak` | Owner only | Convergence Soak Status | `pkg/dashboard/api.go:281` | -| `POST` | `/api/plan/from-issue` | Dashboard auth/session | Plan From Issue | `pkg/dashboard/api.go:486` | -| `GET` | `/api/plan/{epicID}` | Dashboard auth/session | Plan Tree | `pkg/dashboard/api.go:489` | -| `POST` | `/api/plan/{epicID}/approve` | Owner only | Plan Approve | `pkg/dashboard/api.go:490` | -| `POST` | `/api/plan/{epicID}/reject` | Owner only | Plan Reject | `pkg/dashboard/api.go:492` | -| `POST` | `/api/plan/{epicID}/child/{childID}` | Owner only | Plan Child Action | `pkg/dashboard/api.go:493` | +| `POST` | `/api/plan/from-issue` | Dashboard auth/session | Plan From Issue | `pkg/dashboard/api.go:489` | +| `GET` | `/api/plan/{epicID}` | Dashboard auth/session | Plan Tree | `pkg/dashboard/api.go:492` | +| `POST` | `/api/plan/{epicID}/approve` | Owner only | Plan Approve | `pkg/dashboard/api.go:493` | +| `POST` | `/api/plan/{epicID}/reject` | Owner only | Plan Reject | `pkg/dashboard/api.go:495` | +| `POST` | `/api/plan/{epicID}/child/{childID}` | Owner only | Plan Child Action | `pkg/dashboard/api.go:496` | | `POST` | `/api/linear/agent/install` | Owner only | Linear Agent Install (OAuth start) | `pkg/dashboard/api_linear_agent.go:219` | | `GET` | `/api/linear/agent/status` | Owner only | Linear Agent Status | `pkg/dashboard/api_linear_agent.go:220` | | `POST` | `/api/linear/agent/disconnect` | Owner only | Linear Agent Disconnect | `pkg/dashboard/api_linear_agent.go:221` | | `GET` | `/api/upstream-watch` | Owner only | Upstream-watch divergence: per watched fork the upstream followed, the watermark and last-run time, surfaced/ported/dismissed/skipped counts, and the recent upstream refs with their upstream URL, diff URL, fork issue number and state. Read-only β€” it reads the config and the watch state file, never GitHub. Also carries `interval`, `project_repos` and each repo's configured entry (`config`) for the editor. | `pkg/dashboard/api.go:324` | | `PUT` | `/api/config/upstream-watch` | Owner only | Upstream Watch Config Update: edit `upstream_watch` from the Features tab. Body `{enabled?, interval?, repos?: {: {upstream?, sources?, pr_labels?, max_issues_per_run?, label?} \| null}}` β€” absent keys are unchanged, `null` removes a repo, keys are stored as the bare repo name. Validated with the loader's rules before the config changes (400 on failure), then persisted and audited as `config_upstream_watch`. | `pkg/dashboard/api.go:325` | -| `GET` | `/api/hotspots` | Dashboard auth/session | Merge-hotspot advisor report (`?repo=owner/name` optional): files repeatedly touched by concurrent PRs over the last `governor.hotspot_window_days`, each with `prs` (distinct PRs), `pr_numbers`, `overlaps` (PR pairs open at the same time), `conflicts` (rebase conflicts blamed on the file), `lines`, `kind` (`code`/`doc`/`lock`/`generated`/`data`), `score`, `symbol_groups`, `recommendation` (`split-code`/`split-doc`/`split-source`/`report-only`/`none`), `reason`, `generated_from`, `source_files` (files under `generated_from`, when known), `issue_number`/`issue_url` of the modularize issue filed, `last_updated`; top-level `count`/`actionable`/`filed`. Derived from the merge train's own PR file lists and the rebase follow-through's conflict records β€” never lists PRs or files from GitHub. See [operator-reference.md Β§ Merge-hotspot advisor](../operator-reference.md#merge-hotspot-advisor-governorhotspot_). | `pkg/dashboard/api.go:583` | +| `GET` | `/api/hotspots` | Dashboard auth/session | Merge-hotspot advisor report (`?repo=owner/name` optional): files repeatedly touched by concurrent PRs over the last `governor.hotspot_window_days`, each with `prs` (distinct PRs), `pr_numbers`, `overlaps` (PR pairs open at the same time), `conflicts` (rebase conflicts blamed on the file), `lines`, `kind` (`code`/`doc`/`lock`/`generated`/`data`), `score`, `symbol_groups`, `recommendation` (`split-code`/`split-doc`/`split-source`/`report-only`/`none`), `reason`, `generated_from`, `source_files` (files under `generated_from`, when known), `issue_number`/`issue_url` of the modularize issue filed, `last_updated`; top-level `count`/`actionable`/`filed`. Derived from the merge train's own PR file lists and the rebase follow-through's conflict records β€” never lists PRs or files from GitHub. See [operator-reference.md Β§ Merge-hotspot advisor](../operator-reference.md#merge-hotspot-advisor-governorhotspot_). | `pkg/dashboard/api.go:586` | | `GET` | `/api/widget` | Dashboard auth/session | Widget | `pkg/dashboard/api.go:131` | | `GET` | `/api/pane/{agent}` | Dashboard auth/session | Pane | `pkg/dashboard/api.go:136` | | `GET` | `/api/role` | Dashboard auth/session | Role | `pkg/dashboard/api.go:150` | @@ -62,11 +62,11 @@ The monitor renders CI and Review concurrently on one shared PR row; see | `GET` | `/api/stage-tracker` | Dashboard auth/session | Stage Tracker: every open issue and pull request of the watched repos for the Hive activity monitor β€” actionable, held, claimed, in-progress, covered-by-PR, filtered (`needs-*`), draft and exempt-labelled items alike, each with `stage`, `stage_since`, `labels`, `created_at`, `author_kind`, `modifiers`, `claimant: {kind: hive-agent|clanker|person, login, agent, hive, since, source: claim-ledger|claim-comment|assignee|kick-history|author}` naming who holds a claimed / in-progress issue or a PR under review or rework (absent when nobody is named; mirrored onto `linked_prs[]`), (PRs) `sentinel_alert` / `sentinel_findings` / `sentinel_reason` when the sentinel flagged the PR, `decision_needed` on a row parked on a person (085 / 045 / 015, the needs-human pill; mirrored onto the issue's `linked_prs[]` entry): `{question, asked_by, since, blocker, blocker_reason, options: [{action, label, enabled, reason_if_disabled}]}` β€” the reviewer's own decision question when the verdict record has one, the blocker in plain words, and the applicable actions under the row context menu's action ids (`decide:delegate` first), (PRs) `lineage` on the hive's copy of an adopted PR β€” `{kind: adopted|superseded|rebased-by-hive, from_number, from_url, from_author, from_author_kind, to_number, to_url, at, reason}`, derived from the stage store's adoption record or the `hive/adopt-` head branch, mirrored onto the issue's `linked_prs[]` entry; absent on every other row β€” and (issues) `linked_prs`; items with no stage yet report stage `0` "Open"; `needs_human_labels` is the "a person must act" label set behind the monitor's Needs human facet (the same list as `/api/status.labelPresets[needs-human]`); `recently_merged` lists the PRs (`repo`, `number`, `merged_at`) whose `pr_merged` audit entry is under 30 minutes old, so a client diffing two payloads can tell a merge from a close; a PR in a conflict-aware merge train carries `merge_train` `{id, state, front, position, size, attempt, max_attempts, overlap_files, ahead, length, head_number, head_state, head_reason, head_since, eta_s, eta_at, eta_basis, late, stalled, stalled_s}` β€” `head_state` one of `needs-human` / `ci` / `soaking` / `ready` / `merging` / `fixing` / `unknown` with `head_reason` in plain words; `eta_s` / `eta_at` the estimated wait for this member's own merge attempt (own remaining soak + each PR ahead's remaining soak or one sweep + the next sweep tick; absent while the head waits on a person or CI), `eta_at` anchored to the member's first estimate at its position so a countdown survives polls, `eta_basis` its short reading ("2 ahead Β· head soaking 12m Β· next sweep in 40s"), `late` once now has overrun the first estimate twice over, `stalled` / `stalled_s` once the head has sat longer than `governor.merge_train_stall_warn_s` ([#15395](https://github.com/hivecommons/hive/issues/15395)); a PR the queue holds because another open PR declares the same `Fixes #N` carries no `merge_train` and a `merge_gate` of `{state: "duplicate-of:", duplicate_of, duplicate_issue}` plus the `needs-decision` modifier (the queue keeps one PR per issue: green first, then the earlier-opened); a PR GitHub reports conflicting on its own, or one under repair (stage/070, 071, 072, 074, 085, or a red required check), carries no `merge_train` and is not counted in any member's `size` / `length` β€” a repaired member the view dropped carries `left_train: {why: fixing|needs-author|returned-to-contributor|adopted-by-hive|needs-decision|checks-failing, note}` instead (mirrored onto the issue's `linked_prs[]` entry), and a train left with one member reports no `merge_train` at all; a PR short of the merge queue (below stage/080) the planner ordered rides no train either β€” it carries `overlap: {behind, with, files, note}` ("overlaps #15637 (sidebar.html, index.html) β€” will queue behind it") and no `merge_train`, and counts in no member's `size` β€” the state slugs (`front`, `queued-behind`) are payload values only, the SPA renders ordinal places ("Queued for merge Β· 1st of 2", with the train's tag when more than one train is on the page). Served from a server-side cache rebuilt at most every `dashboard.stage_tracker.cache_ttl_s` (default `governor.eval_interval_s`) from the spoke's existing snapshot β€” a request never calls GitHub. Carries `generated_at`, `source` (`cache` or `fresh`), `cache_ttl_s`, an `ETag` and `Cache-Control: private, max-age=`; `If-None-Match` yields 304. Each hit is recorded under REST caller `dashboard:stage-tracker` so `/api/gh-rate-limits` shows it charged nothing. | `pkg/dashboard/api.go:204` | | `GET` | `/api/matrix/lines` | Dashboard auth/session | Matrix Lines (deprecated name, see [Published-checkpoint regions](#published-checkpoint-regions)): serve bounded backlog of pushed lines behind dashboard.matrix with repo allowlist filtering. `max` counts only lines the repo allowlist admits, so a page is never emptied by filtered-out lines. A client paging with `max` should use the last returned line's `seq` as its next `since`, not the top-level `seq`, which is the source's latest sequence number and would skip lines still past the page. `more` is true only while another line the caller may see remains past the page; stop paging when it is false. | `pkg/dashboard/api.go:205` | | `POST` | `/api/prs/{owner}/{repo}/{number}/queue-automerge` | Merger/owner role | Queue PRAuto Merge | `pkg/dashboard/api.go:206` | -| `GET` | `/api/inference/models/{backend}` | Dashboard auth/session | Inference Models | `pkg/dashboard/api.go:401` | -| `GET` | `/api/hive-id` | Dashboard auth/session | Hive IDGet | `pkg/dashboard/api.go:465` | -| `PUT` | `/api/hive-id` | Owner only | Hive IDSet | `pkg/dashboard/api.go:466` | -| `POST` | `/api/chat` | Dashboard auth/session | Chat | `pkg/dashboard/api.go:495` | -| `GET` | `/api/auth/token` | Public | Auth Token | `pkg/dashboard/api.go:533` | +| `GET` | `/api/inference/models/{backend}` | Dashboard auth/session | Inference Models | `pkg/dashboard/api.go:404` | +| `GET` | `/api/hive-id` | Dashboard auth/session | Hive IDGet | `pkg/dashboard/api.go:468` | +| `PUT` | `/api/hive-id` | Owner only | Hive IDSet | `pkg/dashboard/api.go:469` | +| `POST` | `/api/chat` | Dashboard auth/session | Chat | `pkg/dashboard/api.go:498` | +| `GET` | `/api/auth/token` | Public | Auth Token | `pkg/dashboard/api.go:536` | | `GET` | `/api/v1/` | GitHub token | Contributor v1 API dispatcher | `pkg/dashboard/api_contribute.go:362` | | `POST` | `/api/v1/` | GitHub token | Contributor v1 API dispatcher | `pkg/dashboard/api_contribute.go:363` | | `GET` | `/api/docs` | Dashboard auth/session | APIDocs | `pkg/dashboard/api_contribute.go:364` | @@ -84,7 +84,7 @@ The monitor renders CI and Review concurrently on one shared PR row; see | `POST` | `/api/hives/{id}/heartbeat` | Dashboard auth/session | Hives Heartbeat | `pkg/dashboard/api_contribute.go:389` | | `DELETE` | `/api/hives/{id}` | Owner only | Hives Delete | `pkg/dashboard/api_contribute.go:390` | | `POST` | `/api/hives/onboard` | Dashboard auth/session | Hives Onboard | `pkg/dashboard/api_contribute.go:391` | -| `GET` | `/sso` | Public | SSO | `pkg/dashboard/server.go:1878` | +| `GET` | `/sso` | Public | SSO | `pkg/dashboard/server.go:1893` | ### Activity observation provenance diff --git a/src/docs/api-reference/health-status-events.md b/src/docs/api-reference/health-status-events.md index c49f7afa4f..526275537b 100644 --- a/src/docs/api-reference/health-status-events.md +++ b/src/docs/api-reference/health-status-events.md @@ -24,17 +24,17 @@ Part of the [Dashboard REST API reference](../api-reference.md); see that page f | `GET` | `/api/version` | Dashboard auth/session | Build/version metadata; includes `upgradeMarker` (`target`, `current`, `attempts`, `maxAttempts`, `failed`, `requestedAt`, `lastError`) while a self-upgrade is in flight or has failed ([#6765](https://github.com/hivecommons/hive/issues/6765)), the hub-delivered `upgradePolicy` (`hub_managed`, `spoke_managed`, `schedule`, `schedule_hour`, `schedule_timezone`, `schedule_weekday`, `paused`, `branch`, `channel`, `target_sha`, `target_resolved`, `armed_target`, and `next_update_at` β€” the hub's RFC3339 ETA for the next promotion into the `stable` channel, omitted when unknown ([#10256](https://github.com/hivecommons/hive/issues/10256))) when available, and an `autoUpdate` object (`enabled`, `state` β€” one of `disabled`/`up_to_date`/`behind`/`retrying`/`failed`/`unknown` β€” `healthy`, `period`, `targetBranch`, `targetCommit`, `currentCommit`, `commitsBehind`, `lastAttemptAt`, `lastError`, `detail`, and `nextUpdateAt` β€” the hub's RFC3339 ETA for the next update, omitted when unknown, disabled or paused ([#10257](https://github.com/hivecommons/hive/issues/10257))) that never reports a failed or unknown update as healthy ([#6962](https://github.com/hivecommons/hive/issues/6962), [#6963](https://github.com/hivecommons/hive/issues/6963)). Also `startedAt` (RFC3339 process start β€” changes on every pod roll even when the build does not, so the dashboard can say "Restarted" vs "Upgraded") and, until the first status is published, `startupPhase`/`startupPhaseLabel` (the boot phase a fresh pod is in) ([#15788](https://github.com/hivecommons/hive/issues/15788)) | `pkg/dashboard/api.go:46` | | `GET` | `/api/version/release-notes` | Dashboard auth/session | Changelog gap between two revisions: `?from=&to=` returns `{from:{sha,version?}, to:{sha,ref,version?}, sections:[{version,date,categories:{added,changed,fixed,security,deprecated}}], unreleased?, truncated, source, error?}` β€” the `CHANGELOG.md` release sections present at `to` and absent at `from`, newest first, plus (when `to` is not a release tag) the `changelog.d/` fragments new at `to` under an `Unreleased (in this build)` section. Fetch failures return `source: "unavailable"` with `error` rather than an error status; results are cached per (from, to) for an hour ([#11065](https://github.com/hivecommons/hive/issues/11065)) | `pkg/dashboard/api.go:47` | | `POST` | `/api/release-channel` | Owner only | Hosted spoke self-service release-channel selector; relays `stable`/`candidate`/`edge` to the hub's existing switch-branch endpoint with the spoke dashboard-token proof, and reports the requested channel as pending until the Deployment image lands | `pkg/dashboard/api.go:77` | -| `GET` | `/api/health` | Public | Basic health probe | `pkg/dashboard/server.go:1849` | -| `GET` | `/api/health/deep` | Public | Deep health probe; the `webhooks` check (`status`, `detail`, `mode`, `mode_reason_code`, `verification`, `rejected_1h`) warns when deliveries arrive but cannot be used β€” no `GITHUB_WEBHOOK_SECRET` and no hub relay key, signature mismatch, undecodable payload, or a hub-relayed delivery rejected for `relay_signature_invalid`, `relay_timestamp_stale`, `relay_delivery_replayed` or `relay_delivery_missing` β€” and never changes the overall verdict; `verification` (`github-signed`, `relay (hub-signed)`, `none`) says how deliveries are authenticated | `pkg/dashboard/server.go:1850` | -| `GET` | `/api/livez` | Public | Kubernetes liveness probe | `pkg/dashboard/server.go:1851` | -| `GET` | `/metrics` | Registered only when `HIVE_METRICS_ENABLED`; requires `Authorization: ****** (403 if the token is unset) | Prometheus metrics | `pkg/dashboard/server.go:1857` | -| `GET` | `/api/status` | Dashboard auth/session | Dashboard aggregate status; `labelPresets[]` (`{key,label,description,labels[],states[]}`) lists the built-in label-filter presets for the Projects cards, led by `needs-human` whose `labels[]` are the effective hard-suppress labels plus `needs-signal`, the hold labels, `stage/071-needs-author` and the reporter-trust awaiting label (#12172); accepts `?fields=a,b` for top-level selection and `?omit=repos` to drop heavy sections, and honors `Accept-Encoding: gzip`; `agents[]` includes busy-pane visibility fields (`kicksUndeliverable`, `busySince`, `lastTranscriptActivity`, `condition`, `conditionMessage`) when a Working pane cannot accept kicks; a working agent also carries `agents[].run` β€” the live per-run line (#14576): `{since, elapsedS, costUSD, tokens, priced, tests, filesChanged}`, the estimated spend/tokens since the kick was delivered (`lastKickAt`, falling back to `busySince`) (delta of the token collector's per-agent cumulative figure; `priced:false` when the agent has none yet), the last visible test-runner result (`pass`/`fail`/empty) and the last `N files changed` git summary (`-1` when none) parsed from the captured pane; top-level `fleetRun` `{running, hourUSD, hourPriced, dayUSD, weekUSD, monthUSD, windowsPriced}` is the governor strip's rotating `running N Β· $X this hour / today / this week / this month` figure (#15614) β€” the hour is the rise since the top of the clock hour, the wider windows are the daily cost ledger summed from 00:00 UTC today, Monday 00:00 UTC (ISO week) and the 1st 00:00 UTC plus the rise since the ledger's last snapshot, `windowsPriced:false` when there is no ledger yet; `sla` (fleet) and `repos[].sla` (per repo) carry the Delivery SLA block derived from the lifecycle timeline with no extra GitHub calls: `windows.{7d,30d}.{open_to_triage,triage_to_start,start_to_pr,pr_to_merge,open_to_merge}` as `{p50_h,p95_h,n}` (hours, nearest-rank), `history[]` of up to 30 daily buckets `{day,start_to_pr_p50,pr_to_merge_p50,open_to_merge_p50,n_started,n_pr,n_merged}` persisted under `/data/sla-history.json`, `stale_in_progress[]` (`{number,repo,started_at,age_h,agent}` β€” issues carrying `hive/in-progress` longer than the agent claim TTL with no linked PR), `stale_count`, `merged_7d`, `claim_ttl_s` and, on the fleet block, the effective `targets` (`start_to_pr_h`, `pr_to_merge_h`, `open_to_merge_h`); `staleSince` and `repos[].countsStale` / `countsStaleSince` mark totals served from last-good figures β€” the previous enumeration (#13382) or the ForgeState cache's last-good counts (#13376); `ci_failure_signals` `{count, last, kicks, unmatched}` is the webhook CI-failure signal store β€” distinct red (repo, head, check) signals retained, the most recent one `{repo, number, head_sha, check, conclusion, details_url, agent, at}`, webhook-triggered kicks/nudges since boot, and failure deliveries whose head matched no open PR; `workflow_approvals` `{approved, denied, permission_errors, last}` counts the hive's approvals of GitHub-gated workflow runs since boot (#16603) β€” runs approved, runs refused by the trust policy (once per head), `403`/`404` refusals for want of `actions: write`, and the most recent decision `{repo, number, head, run_id, outcome, at}`; `code_scanning` `{enabled, repos[], notices[]}` is the code scanning sweep's picture, absent while the sweep is off everywhere β€” per repo `{repo, security_url, checked_at, open_alerts, by_severity{critical,high,medium,low,note}, qualifying, below_threshold, excluded, tracked, tracked_closed, untracked, open_issues, filed, updated, closed, truncated, notice, notice_kind, error}`, where `tracked` is open alerts listed on an open hive issue and `notice_kind` is `missing_permission` (the App lacks Code scanning alerts: Read) or `not_enabled` (see `docs/code-scanning.md`); `hotspots` `{count, actionable, filed}` is the merge-hotspot advisor's one-line summary (files at/above the thresholds, those with a split recommendation, those with a modularize issue filed), absent when the advisor is not wired β€” details on `GET /api/hotspots`; `writeback.dedupe` `{enabled, dedupe_same_item, dedupe_burst, rate_capped, hourly_cap, burst_items, burst_window_s, same_item_ttl_s}` is the write-back gate's content layer β€” the effective `writeback.dedupe.*` settings and the last-hour counts of writes held as `duplicate`, `burst` and `rate-capped` (each also listed under `GET /api/writeback/suppressed`); `config.gate_regressions[]` `{gate, previous, effective, source}` is the boot-time safety-gate regression list (#16438) β€” gates a previous runtime overlay on the PVC carried that the running config lacks or loosens (`review.require_labels`, `project.issue_filter.require_labels`, `project.paused_repos[]`, `writeback.per_repo[].mode`), empty on a healthy boot; `ghRateLimits.agent_caps` `{fleet_cap, fleet_cap_set, window_s, agents: [{agent, cap, source: agent|base_agent|fleet, base_agent, calls}]}` is the GitHub API budget panel's per-agent proxy cap (0 = no cap) and the calls the proxy counted against it in the current sliding hour (`calls` is null for an uncapped agent, which the proxy does not count); `api_budget_pacing` `{enabled, active, factor, effective_interval_s, burn_per_min, reason}` is the API budget pacing state (`governor.api_budget_pacing`), and `evalIntervalS` is the loop interval after pacing | `pkg/dashboard/server.go:1862` | -| `GET` | `/api/status/summary` | Dashboard auth/session | Compact agent/governor/budget status summary for pollers; honors `Accept-Encoding: gzip` | `pkg/dashboard/server.go:1863` | -| `GET` | `/api/overview/issues.csv` | Dashboard auth/session | Cached Overview issue-band export as browser-compatible CSV; filters `band`, `repo`/`repos`, `stale`, and `held` are ANDed. | `pkg/dashboard/server.go:1866` | -| `GET` | `/api/overview/issues.json` | Dashboard auth/session | Cached Overview issue-band export as typed JSON with `bands[]` metadata and row fields matching the CSV columns. | `pkg/dashboard/server.go:1867` | -| `GET` | `/api/overview/prs.csv` | Dashboard auth/session | Cached Overview PR-band export as browser-compatible CSV; filters `band`, `repo`/`repos`, `stale`, and `held` are ANDed. | `pkg/dashboard/server.go:1868` | -| `GET` | `/api/overview/prs.json` | Dashboard auth/session | Cached Overview PR-band export as typed JSON with `bands[]` metadata and row fields matching the CSV columns. | `pkg/dashboard/server.go:1869` | -| `GET` | `/api/events` | Dashboard auth/session | Server-sent event stream | `pkg/dashboard/server.go:1870` | +| `GET` | `/api/health` | Public | Basic health probe | `pkg/dashboard/server.go:1864` | +| `GET` | `/api/health/deep` | Public | Deep health probe; the `webhooks` check (`status`, `detail`, `mode`, `mode_reason_code`, `verification`, `rejected_1h`) warns when deliveries arrive but cannot be used β€” no `GITHUB_WEBHOOK_SECRET` and no hub relay key, signature mismatch, undecodable payload, or a hub-relayed delivery rejected for `relay_signature_invalid`, `relay_timestamp_stale`, `relay_delivery_replayed` or `relay_delivery_missing` β€” and never changes the overall verdict; `verification` (`github-signed`, `relay (hub-signed)`, `none`) says how deliveries are authenticated | `pkg/dashboard/server.go:1865` | +| `GET` | `/api/livez` | Public | Kubernetes liveness probe | `pkg/dashboard/server.go:1866` | +| `GET` | `/metrics` | Registered only when `HIVE_METRICS_ENABLED`; requires `Authorization: ****** (403 if the token is unset) | Prometheus metrics | `pkg/dashboard/server.go:1872` | +| `GET` | `/api/status` | Dashboard auth/session | Dashboard aggregate status; `labelPresets[]` (`{key,label,description,labels[],states[]}`) lists the built-in label-filter presets for the Projects cards, led by `needs-human` whose `labels[]` are the effective hard-suppress labels plus `needs-signal`, the hold labels, `stage/071-needs-author` and the reporter-trust awaiting label (#12172); accepts `?fields=a,b` for top-level selection and `?omit=repos` to drop heavy sections, and honors `Accept-Encoding: gzip`; `agents[]` includes busy-pane visibility fields (`kicksUndeliverable`, `busySince`, `lastTranscriptActivity`, `condition`, `conditionMessage`) when a Working pane cannot accept kicks; a working agent also carries `agents[].run` β€” the live per-run line (#14576): `{since, elapsedS, costUSD, tokens, priced, tests, filesChanged}`, the estimated spend/tokens since the kick was delivered (`lastKickAt`, falling back to `busySince`) (delta of the token collector's per-agent cumulative figure; `priced:false` when the agent has none yet), the last visible test-runner result (`pass`/`fail`/empty) and the last `N files changed` git summary (`-1` when none) parsed from the captured pane; top-level `fleetRun` `{running, hourUSD, hourPriced, dayUSD, weekUSD, monthUSD, windowsPriced}` is the governor strip's rotating `running N Β· $X this hour / today / this week / this month` figure (#15614) β€” the hour is the rise since the top of the clock hour, the wider windows are the daily cost ledger summed from 00:00 UTC today, Monday 00:00 UTC (ISO week) and the 1st 00:00 UTC plus the rise since the ledger's last snapshot, `windowsPriced:false` when there is no ledger yet; `sla` (fleet) and `repos[].sla` (per repo) carry the Delivery SLA block derived from the lifecycle timeline with no extra GitHub calls: `windows.{7d,30d}.{open_to_triage,triage_to_start,start_to_pr,pr_to_merge,open_to_merge}` as `{p50_h,p95_h,n}` (hours, nearest-rank), `history[]` of up to 30 daily buckets `{day,start_to_pr_p50,pr_to_merge_p50,open_to_merge_p50,n_started,n_pr,n_merged}` persisted under `/data/sla-history.json`, `stale_in_progress[]` (`{number,repo,started_at,age_h,agent}` β€” issues carrying `hive/in-progress` longer than the agent claim TTL with no linked PR), `stale_count`, `merged_7d`, `claim_ttl_s` and, on the fleet block, the effective `targets` (`start_to_pr_h`, `pr_to_merge_h`, `open_to_merge_h`); `staleSince` and `repos[].countsStale` / `countsStaleSince` mark totals served from last-good figures β€” the previous enumeration (#13382) or the ForgeState cache's last-good counts (#13376); `ci_failure_signals` `{count, last, kicks, unmatched}` is the webhook CI-failure signal store β€” distinct red (repo, head, check) signals retained, the most recent one `{repo, number, head_sha, check, conclusion, details_url, agent, at}`, webhook-triggered kicks/nudges since boot, and failure deliveries whose head matched no open PR; `workflow_approvals` `{approved, denied, permission_errors, last}` counts the hive's approvals of GitHub-gated workflow runs since boot (#16603) β€” runs approved, runs refused by the trust policy (once per head), `403`/`404` refusals for want of `actions: write`, and the most recent decision `{repo, number, head, run_id, outcome, at}`; `code_scanning` `{enabled, repos[], notices[]}` is the code scanning sweep's picture, absent while the sweep is off everywhere β€” per repo `{repo, security_url, checked_at, open_alerts, by_severity{critical,high,medium,low,note}, qualifying, below_threshold, excluded, tracked, tracked_closed, untracked, open_issues, filed, updated, closed, truncated, notice, notice_kind, error}`, where `tracked` is open alerts listed on an open hive issue and `notice_kind` is `missing_permission` (the App lacks Code scanning alerts: Read) or `not_enabled` (see `docs/code-scanning.md`); `hotspots` `{count, actionable, filed}` is the merge-hotspot advisor's one-line summary (files at/above the thresholds, those with a split recommendation, those with a modularize issue filed), absent when the advisor is not wired β€” details on `GET /api/hotspots`; `writeback.dedupe` `{enabled, dedupe_same_item, dedupe_burst, rate_capped, hourly_cap, burst_items, burst_window_s, same_item_ttl_s}` is the write-back gate's content layer β€” the effective `writeback.dedupe.*` settings and the last-hour counts of writes held as `duplicate`, `burst` and `rate-capped` (each also listed under `GET /api/writeback/suppressed`); `config.gate_regressions[]` `{gate, previous, effective, source}` is the boot-time safety-gate regression list (#16438) β€” gates a previous runtime overlay on the PVC carried that the running config lacks or loosens (`review.require_labels`, `project.issue_filter.require_labels`, `project.paused_repos[]`, `writeback.per_repo[].mode`), empty on a healthy boot; `ghRateLimits.agent_caps` `{fleet_cap, fleet_cap_set, window_s, agents: [{agent, cap, source: agent|base_agent|fleet, base_agent, calls}]}` is the GitHub API budget panel's per-agent proxy cap (0 = no cap) and the calls the proxy counted against it in the current sliding hour (`calls` is null for an uncapped agent, which the proxy does not count); `api_budget_pacing` `{enabled, active, factor, effective_interval_s, burn_per_min, reason}` is the API budget pacing state (`governor.api_budget_pacing`), and `evalIntervalS` is the loop interval after pacing | `pkg/dashboard/server.go:1877` | +| `GET` | `/api/status/summary` | Dashboard auth/session | Compact agent/governor/budget status summary for pollers; honors `Accept-Encoding: gzip` | `pkg/dashboard/server.go:1878` | +| `GET` | `/api/overview/issues.csv` | Dashboard auth/session | Cached Overview issue-band export as browser-compatible CSV; filters `band`, `repo`/`repos`, `stale`, and `held` are ANDed. | `pkg/dashboard/server.go:1881` | +| `GET` | `/api/overview/issues.json` | Dashboard auth/session | Cached Overview issue-band export as typed JSON with `bands[]` metadata and row fields matching the CSV columns. | `pkg/dashboard/server.go:1882` | +| `GET` | `/api/overview/prs.csv` | Dashboard auth/session | Cached Overview PR-band export as browser-compatible CSV; filters `band`, `repo`/`repos`, `stale`, and `held` are ANDed. | `pkg/dashboard/server.go:1883` | +| `GET` | `/api/overview/prs.json` | Dashboard auth/session | Cached Overview PR-band export as typed JSON with `bands[]` metadata and row fields matching the CSV columns. | `pkg/dashboard/server.go:1884` | +| `GET` | `/api/events` | Dashboard auth/session | Server-sent event stream | `pkg/dashboard/server.go:1885` | | `GET` | `/api/swarm` | Dashboard auth/session | Current swarm status: display name, duration, active repo, expiry, persisted prep metrics when available, and idle-unlock fields. | `pkg/dashboard/swarm.go:310` | | `POST` | `/api/swarm` | Owner only | Start a 24h repo swarm for a configured repo, returning 409 when another swarm is active and 423 when a previous swarm keeps the next swarm locked until enough agents are idle unless body `{"repo":"owner/name","force":true}` is used; saves the active record before repo prep, persists prep metrics, and announces the start to Discord when configured. | `pkg/dashboard/swarm.go:311` | | `DELETE` | `/api/swarm` | Owner only | End the active swarm, score issues closed and PRs merged in the window, append it to history, and announce the result to Discord when configured. | `pkg/dashboard/swarm.go:312` | diff --git a/src/docs/api-reference/inception.md b/src/docs/api-reference/inception.md index ce9f4ce88e..261263799c 100644 --- a/src/docs/api-reference/inception.md +++ b/src/docs/api-reference/inception.md @@ -5,17 +5,17 @@ Part of the [Dashboard REST API reference](../api-reference.md); see that page f | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `POST` | `/api/inception/start` | Owner only | Inception Start | `pkg/dashboard/api.go:468` | -| `POST` | `/api/inception/scan` | Owner only | Inception Scan | `pkg/dashboard/api.go:469` | -| `GET` | `/api/inception/state` | Dashboard auth/session | Inception State | `pkg/dashboard/api.go:470` | -| `POST` | `/api/inception/questions` | Owner only | Inception Set Questions | `pkg/dashboard/api.go:471` | -| `POST` | `/api/inception/answer` | Owner only | Inception Answer | `pkg/dashboard/api.go:472` | -| `POST` | `/api/inception/facts` | Owner only | Inception Record Facts | `pkg/dashboard/api.go:473` | -| `GET` | `/api/inception/scaffold` | Dashboard auth/session | Inception Scaffold | `pkg/dashboard/api.go:474` | -| `POST` | `/api/inception/approve` | Owner only | Inception Approve | `pkg/dashboard/api.go:475` | -| `POST` | `/api/inception/reset` | Owner only | Inception Reset | `pkg/dashboard/api.go:476` | -| `GET` | `/api/inception/ideation-facts` | Dashboard auth/session | Inception Ideation Facts | `pkg/dashboard/api.go:477` | -| `GET` | `/api/inception/download` | Dashboard auth/session | Inception Download | `pkg/dashboard/api.go:478` | -| `GET` | `/api/inception/has-files` | Dashboard auth/session | Inception Has Files | `pkg/dashboard/api.go:479` | -| `PUT` | `/api/inception/wiki-name` | Owner only | Inception Rename Wiki | `pkg/dashboard/api.go:480` | -| `POST` | `/api/inception/import` | Owner only | Inception Import | `pkg/dashboard/api.go:481` | +| `POST` | `/api/inception/start` | Owner only | Inception Start | `pkg/dashboard/api.go:471` | +| `POST` | `/api/inception/scan` | Owner only | Inception Scan | `pkg/dashboard/api.go:472` | +| `GET` | `/api/inception/state` | Dashboard auth/session | Inception State | `pkg/dashboard/api.go:473` | +| `POST` | `/api/inception/questions` | Owner only | Inception Set Questions | `pkg/dashboard/api.go:474` | +| `POST` | `/api/inception/answer` | Owner only | Inception Answer | `pkg/dashboard/api.go:475` | +| `POST` | `/api/inception/facts` | Owner only | Inception Record Facts | `pkg/dashboard/api.go:476` | +| `GET` | `/api/inception/scaffold` | Dashboard auth/session | Inception Scaffold | `pkg/dashboard/api.go:477` | +| `POST` | `/api/inception/approve` | Owner only | Inception Approve | `pkg/dashboard/api.go:478` | +| `POST` | `/api/inception/reset` | Owner only | Inception Reset | `pkg/dashboard/api.go:479` | +| `GET` | `/api/inception/ideation-facts` | Dashboard auth/session | Inception Ideation Facts | `pkg/dashboard/api.go:480` | +| `GET` | `/api/inception/download` | Dashboard auth/session | Inception Download | `pkg/dashboard/api.go:481` | +| `GET` | `/api/inception/has-files` | Dashboard auth/session | Inception Has Files | `pkg/dashboard/api.go:482` | +| `PUT` | `/api/inception/wiki-name` | Owner only | Inception Rename Wiki | `pkg/dashboard/api.go:483` | +| `POST` | `/api/inception/import` | Owner only | Inception Import | `pkg/dashboard/api.go:484` | diff --git a/src/docs/api-reference/knowledge.md b/src/docs/api-reference/knowledge.md index 391b7c4287..886110f84d 100644 --- a/src/docs/api-reference/knowledge.md +++ b/src/docs/api-reference/knowledge.md @@ -5,38 +5,38 @@ Part of the [Dashboard REST API reference](../api-reference.md); see that page f | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `GET` | `/api/knowledge` | Dashboard auth/session | Paginated knowledge facts: `type` filter, `limit` (default 50, max 200; invalid/non-positive values use the default), `offset` (default 0). Returns `enabled`, `facts`, `count` (this page), `total`, `limit`, `offset`, `has_more`; ordered by layer then slug. Offsets are not snapshot cursors: concurrent edits may shift pages. | `pkg/dashboard/api.go:403` | -| `GET` | `/api/knowledge/export` | Dashboard auth/session | Knowledge Export | `pkg/dashboard/api.go:404` | -| `POST` | `/mcp/knowledge` | Public (read-only) when the dashboard setting or `HIVE_PUBLIC_KNOWLEDGE` is on; otherwise 404 to a signed-in caller and 401 to one who is not | Public Knowledge MCP endpoint β€” `initialize`, `tools/list`, `tools/call` for `knowledge_search`/`knowledge_toc`/`knowledge_get`/`knowledge_export` ([docs](../public-knowledge-mcp.md)) | `pkg/dashboard/api.go:413` | -| `GET` | `/api/knowledge/search` | Dashboard auth/session | Knowledge Search | `pkg/dashboard/api.go:405` | -| `GET` | `/api/knowledge/toc` | Dashboard auth/session | Scoped knowledge table of contents: capped, body-free entries (`id`, `title`, `type`, `layer`, `repo`, `tags`, `status`, `confidence`, `updated`, `size_bytes`, `source`) plus `total`/`returned`/`truncated`. Query: `layers`, `repos`, `types`, `tags` (comma-separated), `limit` (default 50, max 200), `include_states` (operator opt-in for `draft`/`deprecated`/`superseded`/`all`), `format=prompt` with `max_chars` for a bounded markdown rendering, `agent` to apply that agent's `knowledge.agent_scopes` entry (the other parameters can only narrow it). Approved knowledge only by default ([docs](../knowledge-toc.md)) | `pkg/dashboard/api.go:406` | -| `GET` | `/api/knowledge/effective` | Dashboard auth/session | Effective knowledge for one agent (`agent` required): the agent's `knowledge.agent_scopes` entry (unrestricted when it has none) intersected with the request scope (approved only unless `include_states` is set). Returns `scope` (resolved scope applied, with `configured`), `included` TOC entries, `excluded` entries each with a `reason` (`lifecycle`, `layer`, `repo`, `type` or `tag` β€” first failing check, in that order), `included_total`/`excluded_total`/`included_returned`/`excluded_returned` and `truncated`; `limit` caps each list (default 50, max 200) ([docs](../knowledge-toc.md#effective-knowledge)) | `pkg/dashboard/api.go:407` | -| `GET` | `/api/knowledge/entry/{id}` | Dashboard auth/session | Full read of one knowledge entry: `markdown` (front-matter plus body) and the fact. Takes the same scope, `include_states` and `agent` query as the table of contents and returns 404 for an entry outside it ([docs](../knowledge-toc.md)) | `pkg/dashboard/api.go:408` | -| `POST` | `/api/knowledge/cli-memory/drain-legacy` | Owner only | One-shot drain of the shared home's legacy Claude memory (`/data/home/.claude/projects/*/memory`) into `state: draft` entries in `bead-synth-wiki`, tagged `cli-memory` and `agent:shared`, deduplicated by content hash. Body `{"truncate": bool}` (default false) empties only files whose every fact is now stored. Returns `store`, `agent`, `sources`, `drained`, `dupes`, `skipped`, `truncate`, `truncated`, `kept`, `errors`; 409 when the vault is not connected. Audited as `knowledge_drain_legacy_memory` ([docs](../knowledge-lifecycle.md#auto-memory-drafts)) | `pkg/dashboard/api.go:410` | -| `GET` | `/api/knowledge/public` | Dashboard auth/session | Public Knowledge MCP sharing status (`enabled`, `tags`, `source`, endpoint URL) | `pkg/dashboard/api.go:414` | -| `PUT` | `/api/knowledge/public` | Owner only | Persist public Knowledge MCP sharing setting; overrides env vars until changed | `pkg/dashboard/api.go:415` | -| `GET` | `/api/knowledge/health` | Dashboard auth/session | Knowledge Health | `pkg/dashboard/api.go:416` | -| `GET` | `/api/knowledge/stats` | Dashboard auth/session | Knowledge Stats | `pkg/dashboard/api.go:417` | -| `GET` | `/api/knowledge/graph` | Dashboard auth/session | Knowledge Graph | `pkg/dashboard/api.go:418` | -| `GET` | `/api/knowledge/fact-history` | Dashboard auth/session | Fact History | `pkg/dashboard/api.go:419` | -| `POST` | `/api/knowledge/create` | Dashboard auth/session | Create one fact (`title`, `body`, `type`, `tags`, `layer`, `confidence`, `state`). `state` is `approved` (default) or `draft`; a draft is written with `state: draft` into the local channel named by `layer` (400 when there is none, 409 when its title already names an entry). A request tagged `cli-memory` must be a draft carrying only `cli-memory`, `agent:`, `cli:`, `memhash:` and `repo:/` tags (400 otherwise) | `pkg/dashboard/api.go:420` | -| `POST` | `/api/knowledge/import` | Dashboard auth/session | Knowledge Import | `pkg/dashboard/api.go:421` | -| `POST` | `/api/knowledge/promote` | Dashboard auth/session | Knowledge Promote | `pkg/dashboard/api.go:427` | -| `GET` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs List | `pkg/dashboard/api.go:428` | -| `POST` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs Add | `pkg/dashboard/api.go:429` | -| `DELETE` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs Remove | `pkg/dashboard/api.go:430` | -| `PUT` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Update | `pkg/dashboard/api.go:431` | -| `DELETE` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Delete | `pkg/dashboard/api.go:432` | -| `GET` | `/api/knowledge/{layer}` | Dashboard auth/session | Knowledge Layer | `pkg/dashboard/api.go:433` | -| `GET` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Fact | `pkg/dashboard/api.go:434` | -| `PUT` | `/api/knowledge/enabled` | Owner only | Knowledge Toggle | `pkg/dashboard/api.go:435` | -| `GET` | `/api/knowledge/bead-synthesizer` | Dashboard auth/session | Bead Synth Status | `pkg/dashboard/api.go:436` | -| `PUT` | `/api/knowledge/bead-synthesizer/enabled` | Owner only | Bead Synth Toggle | `pkg/dashboard/api.go:437` | -| `GET` | `/api/knowledge/vaults` | Dashboard auth/session | Vaults List | `pkg/dashboard/api.go:438` | -| `POST` | `/api/knowledge/vaults` | Owner only | Vaults Connect | `pkg/dashboard/api.go:439` | -| `DELETE` | `/api/knowledge/vaults` | Dashboard auth/session | Vaults Disconnect | `pkg/dashboard/api.go:440` | -| `POST` | `/api/knowledge/vaults/reindex` | Dashboard auth/session | Vaults Reindex | `pkg/dashboard/api.go:441` | -| `GET` | `/api/knowledge/vaults/{name}/facts` | Dashboard auth/session | Vault Facts | `pkg/dashboard/api.go:442` | +| `GET` | `/api/knowledge` | Dashboard auth/session | Paginated knowledge facts: `type` filter, `limit` (default 50, max 200; invalid/non-positive values use the default), `offset` (default 0). Returns `enabled`, `facts`, `count` (this page), `total`, `limit`, `offset`, `has_more`; ordered by layer then slug. Offsets are not snapshot cursors: concurrent edits may shift pages. | `pkg/dashboard/api.go:406` | +| `GET` | `/api/knowledge/export` | Dashboard auth/session | Knowledge Export | `pkg/dashboard/api.go:407` | +| `POST` | `/mcp/knowledge` | Public (read-only) when the dashboard setting or `HIVE_PUBLIC_KNOWLEDGE` is on; otherwise 404 to a signed-in caller and 401 to one who is not | Public Knowledge MCP endpoint β€” `initialize`, `tools/list`, `tools/call` for `knowledge_search`/`knowledge_toc`/`knowledge_get`/`knowledge_export` ([docs](../public-knowledge-mcp.md)) | `pkg/dashboard/api.go:416` | +| `GET` | `/api/knowledge/search` | Dashboard auth/session | Knowledge Search | `pkg/dashboard/api.go:408` | +| `GET` | `/api/knowledge/toc` | Dashboard auth/session | Scoped knowledge table of contents: capped, body-free entries (`id`, `title`, `type`, `layer`, `repo`, `tags`, `status`, `confidence`, `updated`, `size_bytes`, `source`) plus `total`/`returned`/`truncated`. Query: `layers`, `repos`, `types`, `tags` (comma-separated), `limit` (default 50, max 200), `include_states` (operator opt-in for `draft`/`deprecated`/`superseded`/`all`), `format=prompt` with `max_chars` for a bounded markdown rendering, `agent` to apply that agent's `knowledge.agent_scopes` entry (the other parameters can only narrow it). Approved knowledge only by default ([docs](../knowledge-toc.md)) | `pkg/dashboard/api.go:409` | +| `GET` | `/api/knowledge/effective` | Dashboard auth/session | Effective knowledge for one agent (`agent` required): the agent's `knowledge.agent_scopes` entry (unrestricted when it has none) intersected with the request scope (approved only unless `include_states` is set). Returns `scope` (resolved scope applied, with `configured`), `included` TOC entries, `excluded` entries each with a `reason` (`lifecycle`, `layer`, `repo`, `type` or `tag` β€” first failing check, in that order), `included_total`/`excluded_total`/`included_returned`/`excluded_returned` and `truncated`; `limit` caps each list (default 50, max 200) ([docs](../knowledge-toc.md#effective-knowledge)) | `pkg/dashboard/api.go:410` | +| `GET` | `/api/knowledge/entry/{id}` | Dashboard auth/session | Full read of one knowledge entry: `markdown` (front-matter plus body) and the fact. Takes the same scope, `include_states` and `agent` query as the table of contents and returns 404 for an entry outside it ([docs](../knowledge-toc.md)) | `pkg/dashboard/api.go:411` | +| `POST` | `/api/knowledge/cli-memory/drain-legacy` | Owner only | One-shot drain of the shared home's legacy Claude memory (`/data/home/.claude/projects/*/memory`) into `state: draft` entries in `bead-synth-wiki`, tagged `cli-memory` and `agent:shared`, deduplicated by content hash. Body `{"truncate": bool}` (default false) empties only files whose every fact is now stored. Returns `store`, `agent`, `sources`, `drained`, `dupes`, `skipped`, `truncate`, `truncated`, `kept`, `errors`; 409 when the vault is not connected. Audited as `knowledge_drain_legacy_memory` ([docs](../knowledge-lifecycle.md#auto-memory-drafts)) | `pkg/dashboard/api.go:413` | +| `GET` | `/api/knowledge/public` | Dashboard auth/session | Public Knowledge MCP sharing status (`enabled`, `tags`, `source`, endpoint URL) | `pkg/dashboard/api.go:417` | +| `PUT` | `/api/knowledge/public` | Owner only | Persist public Knowledge MCP sharing setting; overrides env vars until changed | `pkg/dashboard/api.go:418` | +| `GET` | `/api/knowledge/health` | Dashboard auth/session | Knowledge Health | `pkg/dashboard/api.go:419` | +| `GET` | `/api/knowledge/stats` | Dashboard auth/session | Knowledge Stats | `pkg/dashboard/api.go:420` | +| `GET` | `/api/knowledge/graph` | Dashboard auth/session | Knowledge Graph | `pkg/dashboard/api.go:421` | +| `GET` | `/api/knowledge/fact-history` | Dashboard auth/session | Fact History | `pkg/dashboard/api.go:422` | +| `POST` | `/api/knowledge/create` | Dashboard auth/session | Create one fact (`title`, `body`, `type`, `tags`, `layer`, `confidence`, `state`). `state` is `approved` (default) or `draft`; a draft is written with `state: draft` into the local channel named by `layer` (400 when there is none, 409 when its title already names an entry). A request tagged `cli-memory` must be a draft carrying only `cli-memory`, `agent:`, `cli:`, `memhash:` and `repo:/` tags (400 otherwise) | `pkg/dashboard/api.go:423` | +| `POST` | `/api/knowledge/import` | Dashboard auth/session | Knowledge Import | `pkg/dashboard/api.go:424` | +| `POST` | `/api/knowledge/promote` | Dashboard auth/session | Knowledge Promote | `pkg/dashboard/api.go:430` | +| `GET` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs List | `pkg/dashboard/api.go:431` | +| `POST` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs Add | `pkg/dashboard/api.go:432` | +| `DELETE` | `/api/knowledge/subscriptions` | Dashboard auth/session | Knowledge Subs Remove | `pkg/dashboard/api.go:433` | +| `PUT` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Update | `pkg/dashboard/api.go:434` | +| `DELETE` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Delete | `pkg/dashboard/api.go:435` | +| `GET` | `/api/knowledge/{layer}` | Dashboard auth/session | Knowledge Layer | `pkg/dashboard/api.go:436` | +| `GET` | `/api/knowledge/{layer}/{slug}` | Dashboard auth/session | Knowledge Fact | `pkg/dashboard/api.go:437` | +| `PUT` | `/api/knowledge/enabled` | Owner only | Knowledge Toggle | `pkg/dashboard/api.go:438` | +| `GET` | `/api/knowledge/bead-synthesizer` | Dashboard auth/session | Bead Synth Status | `pkg/dashboard/api.go:439` | +| `PUT` | `/api/knowledge/bead-synthesizer/enabled` | Owner only | Bead Synth Toggle | `pkg/dashboard/api.go:440` | +| `GET` | `/api/knowledge/vaults` | Dashboard auth/session | Vaults List | `pkg/dashboard/api.go:441` | +| `POST` | `/api/knowledge/vaults` | Owner only | Vaults Connect | `pkg/dashboard/api.go:442` | +| `DELETE` | `/api/knowledge/vaults` | Dashboard auth/session | Vaults Disconnect | `pkg/dashboard/api.go:443` | +| `POST` | `/api/knowledge/vaults/reindex` | Dashboard auth/session | Vaults Reindex | `pkg/dashboard/api.go:444` | +| `GET` | `/api/knowledge/vaults/{name}/facts` | Dashboard auth/session | Vault Facts | `pkg/dashboard/api.go:445` | | `GET` | `/api/config/knowledge/connectors` | Dashboard auth/session | Knowledge connectors config (name, type, layer, scope, env/file auth reference) and registered types | `pkg/dashboard/api.go` | | `PUT` | `/api/config/knowledge/connectors` | Owner only | Replace `knowledge.connectors` after validation; inline secrets rejected | `pkg/dashboard/api.go` | | `GET` | `/api/config/knowledge/connectors/status` | Dashboard auth/session | Per-connector status pill, last sync, pages/facts, last error, plus the `publish` mirror status | `pkg/dashboard/api.go` | @@ -46,20 +46,20 @@ Part of the [Dashboard REST API reference](../api-reference.md); see that page f | `PUT` | `/api/config/knowledge/agent-scopes` | Owner only | Replace `knowledge.agent_scopes` after validating layer and state names; persisted and audited as `config_knowledge_agent_scopes` | `pkg/dashboard/api.go` | | `GET` | `/api/config/knowledge/pipeline` | Owner only | Scalar bead synthesizer, curator, code map and primer settings, plus the read-only engine, vault paths and primer merge strategy ([docs](../settings.md#knowledge)) | `pkg/dashboard/api.go` | | `PUT` | `/api/config/knowledge/pipeline` | Owner only | Update those settings after `config.ValidateKnowledgePipeline`; 0 or empty restores the default; the bead synthesizer switch applies at once, the rest after a restart; audited as `config_knowledge_pipeline` | `pkg/dashboard/api.go` | -| `GET` | `/api/knowledge/git-sources` | Dashboard auth/session | Git Sources List | `pkg/dashboard/api.go:452` | -| `POST` | `/api/knowledge/git-sources` | Owner only | Git Sources Connect | `pkg/dashboard/api.go:453` | -| `DELETE` | `/api/knowledge/git-sources` | Owner only | Git Sources Disconnect | `pkg/dashboard/api.go:454` | -| `PUT` | `/api/knowledge/git-sources` | Owner only | Git Sources Update | `pkg/dashboard/api.go:455` | -| `POST` | `/api/knowledge/obsidian/sync` | Dashboard auth/session | Obsidian Sync | `pkg/dashboard/api.go:456` | -| `GET` | `/api/knowledge/documents` | Dashboard auth/session | Documents List | `pkg/dashboard/api.go:457` | -| `POST` | `/api/knowledge/documents` | Dashboard auth/session | Documents Import | `pkg/dashboard/api.go:458` | -| `GET` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Get | `pkg/dashboard/api.go:459` | -| `DELETE` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Delete | `pkg/dashboard/api.go:460` | -| `POST` | `/api/knowledge/documents/{slug}/reimport` | Dashboard auth/session | Document Reimport | `pkg/dashboard/api.go:461` | -| `GET` | `/api/knowledge/context7/search` | Dashboard auth/session | Context7 Search | `pkg/dashboard/api.go:462` | -| `POST` | `/api/knowledge/cleanup-orphans` | Dashboard auth/session | Cleanup Orphans | `pkg/dashboard/api.go:463` | -| `GET` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channels List | `pkg/dashboard/api.go:425` | -| `POST` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channel Create | `pkg/dashboard/api.go:426` | +| `GET` | `/api/knowledge/git-sources` | Dashboard auth/session | Git Sources List | `pkg/dashboard/api.go:455` | +| `POST` | `/api/knowledge/git-sources` | Owner only | Git Sources Connect | `pkg/dashboard/api.go:456` | +| `DELETE` | `/api/knowledge/git-sources` | Owner only | Git Sources Disconnect | `pkg/dashboard/api.go:457` | +| `PUT` | `/api/knowledge/git-sources` | Owner only | Git Sources Update | `pkg/dashboard/api.go:458` | +| `POST` | `/api/knowledge/obsidian/sync` | Dashboard auth/session | Obsidian Sync | `pkg/dashboard/api.go:459` | +| `GET` | `/api/knowledge/documents` | Dashboard auth/session | Documents List | `pkg/dashboard/api.go:460` | +| `POST` | `/api/knowledge/documents` | Dashboard auth/session | Documents Import | `pkg/dashboard/api.go:461` | +| `GET` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Get | `pkg/dashboard/api.go:462` | +| `DELETE` | `/api/knowledge/documents/{slug}` | Dashboard auth/session | Document Delete | `pkg/dashboard/api.go:463` | +| `POST` | `/api/knowledge/documents/{slug}/reimport` | Dashboard auth/session | Document Reimport | `pkg/dashboard/api.go:464` | +| `GET` | `/api/knowledge/context7/search` | Dashboard auth/session | Context7 Search | `pkg/dashboard/api.go:465` | +| `POST` | `/api/knowledge/cleanup-orphans` | Dashboard auth/session | Cleanup Orphans | `pkg/dashboard/api.go:466` | +| `GET` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channels List | `pkg/dashboard/api.go:428` | +| `POST` | `/api/knowledge/channels` | Dashboard auth/session | Knowledge Channel Create | `pkg/dashboard/api.go:429` | | `GET` | `/api/knowledge/connectors` | Dashboard auth/session | Knowledge connector statuses and publish mirror status (`{"connectors": [...], "publish": {...}\|null}`, see [knowledge-connectors.md](../knowledge-connectors.md)) | `pkg/dashboard/api_knowledge_connectors.go:23` | | `POST` | `/api/knowledge/connectors/{name}/sync` | Owner only | Start one connector sync now (202; 404 unknown, 409 running) | `pkg/dashboard/api_knowledge_connectors.go:24` | | `POST` | `/api/knowledge/publish/sync` | Owner only | Run a publish mirror batch now (202 with publish status; 503 mirror not running, 409 batch in progress) | `pkg/dashboard/api_knowledge_connectors.go:25` | diff --git a/src/docs/api-reference/nous.md b/src/docs/api-reference/nous.md index ffafe72ff7..e03925f1c6 100644 --- a/src/docs/api-reference/nous.md +++ b/src/docs/api-reference/nous.md @@ -5,28 +5,28 @@ Part of the [Dashboard REST API reference](../api-reference.md); see that page f | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `GET` | `/api/nous/status` | Dashboard auth/session | Nous Status β€” includes `experimentLoop` (`dir`, `lastWriteAt`, `running`, `idleAfterSeconds`): whether the external experiment loop, the only producer of proposals, has written under `/var/run/nous/governor` within 24 evaluation cycles; ready cards then carry `experimentLoopIdle` ([#12541](https://github.com/hivecommons/hive/issues/12541)) | `pkg/dashboard/api.go:498` | -| `GET` | `/api/nous/ledger` | Dashboard auth/session | Nous Ledger | `pkg/dashboard/api.go:499` | -| `GET` | `/api/nous/principles` | Dashboard auth/session | Nous Principles | `pkg/dashboard/api.go:500` | -| `POST` | `/api/nous/approve` | Owner only | Nous Approve | `pkg/dashboard/api.go:501` | -| `POST` | `/api/nous/abort` | Owner only | Nous Abort | `pkg/dashboard/api.go:502` | -| `PUT` | `/api/nous/mode` | Owner only | Nous Mode | `pkg/dashboard/api.go:504` | -| `PUT` | `/api/nous/scope` | Owner only | Nous Scope | `pkg/dashboard/api.go:505` | -| `GET` | `/api/nous/phase` | Dashboard auth/session | Nous Phase | `pkg/dashboard/api.go:506` | -| `PUT` | `/api/nous/gate-decision` | Owner only | Nous Gate Decision | `pkg/dashboard/api.go:507` | -| `GET` | `/api/nous/gate-pending` | Dashboard auth/session | Nous Gate Pending | `pkg/dashboard/api.go:508` | -| `POST` | `/api/nous/gate-respond` | Owner only | Nous Gate Respond | `pkg/dashboard/api.go:509` | -| `GET` | `/api/nous/gate-response` | Dashboard auth/session | Nous Gate Response | `pkg/dashboard/api.go:510` | -| `GET` | `/api/nous/config` | Dashboard auth/session | Nous Config Get | `pkg/dashboard/api.go:511` | -| `PUT` | `/api/nous/config/goals` | Owner only | Nous Config Goals β€” legacy; the dashboard no longer exposes the research question in the βš™ dialog, use `PATCH /api/nous/campaigns/{id}` | `pkg/dashboard/api.go:512` | -| `PUT` | `/api/nous/config/repos` | Owner only | Nous Config Repos β€” the allow-list of repos experiments may target (empty = any) | `pkg/dashboard/api.go:513` | -| `PUT` | `/api/nous/config/output` | Owner only | Nous Config Output | `pkg/dashboard/api.go:514` | -| `PUT` | `/api/nous/config/fast-fail` | Owner only | Nous Config Fast Fail | `pkg/dashboard/api.go:515` | -| `PUT` | `/api/nous/config/schedule` | Owner only | Nous Config Schedule | `pkg/dashboard/api.go:516` | -| `PUT` | `/api/nous/config/controllables` | Owner only | Nous Config Controllables | `pkg/dashboard/api.go:517` | -| `PUT` | `/api/nous/config/principles` | Owner only | Nous Config Principles | `pkg/dashboard/api.go:518` | -| `DELETE` | `/api/nous/principles/{id}` | Owner only | Nous Delete Principle | `pkg/dashboard/api.go:519` | -| `POST` | `/api/nous/campaign/start` | Owner only | Nous Campaign Start β€” archives the active campaign and starts a new experiment whose baseline restarts at 0 ([#12501](https://github.com/hivecommons/hive/issues/12501)) | `pkg/dashboard/api.go:522` | -| `GET` | `/api/nous/campaigns` | Any | Nous Campaigns List β€” every experiment with its per-campaign baseline, scope, results and stage ([#12532](https://github.com/hivecommons/hive/issues/12532)) | `pkg/dashboard/api.go:523` | -| `POST` | `/api/nous/campaigns` | Owner only | Nous Campaigns Create β€” starts an experiment alongside the running ones; 409 `scope_conflict` when a running experiment shares a repo target or both are hive-wide ([#12532](https://github.com/hivecommons/hive/issues/12532)) | `pkg/dashboard/api.go:524` | -| `PATCH` | `/api/nous/campaigns/{id}` | Owner only | Nous Campaign Patch β€” pause / resume / archive, or edit question, hypothesis and scope. Hypothesis/tag edits save in place (`status: "updated"`); a changed question or repo set needs `restart: true` (else 409 `restart_required`) and archives the campaign as version N while creating version N+1 with `restartedFrom` (`status: "restarted", campaign, archived`); resume, scope widening and restarts re-run the conflict guard ([#12532](https://github.com/hivecommons/hive/issues/12532)) | `pkg/dashboard/api.go:525` | +| `GET` | `/api/nous/status` | Dashboard auth/session | Nous Status β€” includes `experimentLoop` (`dir`, `lastWriteAt`, `running`, `idleAfterSeconds`): whether the external experiment loop, the only producer of proposals, has written under `/var/run/nous/governor` within 24 evaluation cycles; ready cards then carry `experimentLoopIdle` ([#12541](https://github.com/hivecommons/hive/issues/12541)) | `pkg/dashboard/api.go:501` | +| `GET` | `/api/nous/ledger` | Dashboard auth/session | Nous Ledger | `pkg/dashboard/api.go:502` | +| `GET` | `/api/nous/principles` | Dashboard auth/session | Nous Principles | `pkg/dashboard/api.go:503` | +| `POST` | `/api/nous/approve` | Owner only | Nous Approve | `pkg/dashboard/api.go:504` | +| `POST` | `/api/nous/abort` | Owner only | Nous Abort | `pkg/dashboard/api.go:505` | +| `PUT` | `/api/nous/mode` | Owner only | Nous Mode | `pkg/dashboard/api.go:507` | +| `PUT` | `/api/nous/scope` | Owner only | Nous Scope | `pkg/dashboard/api.go:508` | +| `GET` | `/api/nous/phase` | Dashboard auth/session | Nous Phase | `pkg/dashboard/api.go:509` | +| `PUT` | `/api/nous/gate-decision` | Owner only | Nous Gate Decision | `pkg/dashboard/api.go:510` | +| `GET` | `/api/nous/gate-pending` | Dashboard auth/session | Nous Gate Pending | `pkg/dashboard/api.go:511` | +| `POST` | `/api/nous/gate-respond` | Owner only | Nous Gate Respond | `pkg/dashboard/api.go:512` | +| `GET` | `/api/nous/gate-response` | Dashboard auth/session | Nous Gate Response | `pkg/dashboard/api.go:513` | +| `GET` | `/api/nous/config` | Dashboard auth/session | Nous Config Get | `pkg/dashboard/api.go:514` | +| `PUT` | `/api/nous/config/goals` | Owner only | Nous Config Goals β€” legacy; the dashboard no longer exposes the research question in the βš™ dialog, use `PATCH /api/nous/campaigns/{id}` | `pkg/dashboard/api.go:515` | +| `PUT` | `/api/nous/config/repos` | Owner only | Nous Config Repos β€” the allow-list of repos experiments may target (empty = any) | `pkg/dashboard/api.go:516` | +| `PUT` | `/api/nous/config/output` | Owner only | Nous Config Output | `pkg/dashboard/api.go:517` | +| `PUT` | `/api/nous/config/fast-fail` | Owner only | Nous Config Fast Fail | `pkg/dashboard/api.go:518` | +| `PUT` | `/api/nous/config/schedule` | Owner only | Nous Config Schedule | `pkg/dashboard/api.go:519` | +| `PUT` | `/api/nous/config/controllables` | Owner only | Nous Config Controllables | `pkg/dashboard/api.go:520` | +| `PUT` | `/api/nous/config/principles` | Owner only | Nous Config Principles | `pkg/dashboard/api.go:521` | +| `DELETE` | `/api/nous/principles/{id}` | Owner only | Nous Delete Principle | `pkg/dashboard/api.go:522` | +| `POST` | `/api/nous/campaign/start` | Owner only | Nous Campaign Start β€” archives the active campaign and starts a new experiment whose baseline restarts at 0 ([#12501](https://github.com/hivecommons/hive/issues/12501)) | `pkg/dashboard/api.go:525` | +| `GET` | `/api/nous/campaigns` | Any | Nous Campaigns List β€” every experiment with its per-campaign baseline, scope, results and stage ([#12532](https://github.com/hivecommons/hive/issues/12532)) | `pkg/dashboard/api.go:526` | +| `POST` | `/api/nous/campaigns` | Owner only | Nous Campaigns Create β€” starts an experiment alongside the running ones; 409 `scope_conflict` when a running experiment shares a repo target or both are hive-wide ([#12532](https://github.com/hivecommons/hive/issues/12532)) | `pkg/dashboard/api.go:527` | +| `PATCH` | `/api/nous/campaigns/{id}` | Owner only | Nous Campaign Patch β€” pause / resume / archive, or edit question, hypothesis and scope. Hypothesis/tag edits save in place (`status: "updated"`); a changed question or repo set needs `restart: true` (else 409 `restart_required`) and archives the campaign as version N while creating version N+1 with `restartedFrom` (`status: "restarted", campaign, archived`); resume, scope widening and restarts re-run the conflict guard ([#12532](https://github.com/hivecommons/hive/issues/12532)) | `pkg/dashboard/api.go:528` | diff --git a/src/docs/api-reference/packs-and-acmm.md b/src/docs/api-reference/packs-and-acmm.md index 22b5c77ed8..a0d1888f83 100644 --- a/src/docs/api-reference/packs-and-acmm.md +++ b/src/docs/api-reference/packs-and-acmm.md @@ -5,29 +5,29 @@ Part of the [Dashboard REST API reference](../api-reference.md); see that page f | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| -| `GET` | `/api/packs` | Dashboard auth/session | Packs List | `pkg/dashboard/api.go:343` | -| `POST` | `/api/packs/{level}/apply` | Owner only | Pack Apply | `pkg/dashboard/api.go:344` | -| `PUT` | `/api/packs/level` | Owner only | Set the ACMM level. Body `{"level": N}` persists and applies the pack; optional `{"release_level_holds": true}` performs a deliberate one-off release of Hive App level-applied `hold` labels for the transition. Level holds are otherwise never auto-released. When the change makes the L6 self-authored merge sweep eligible, the success body adds `self_merge_sweep_active`, `level_changed_at`, `level_holds_pending: [{repo, number, title, url, agent}]`, and `repos: [{repo, auto_merge}]` so the dashboard can show the owner an informational notice and repo toggles. | `pkg/dashboard/api.go:345` | +| `GET` | `/api/packs` | Dashboard auth/session | Packs List | `pkg/dashboard/api.go:346` | +| `POST` | `/api/packs/{level}/apply` | Owner only | Pack Apply | `pkg/dashboard/api.go:347` | +| `PUT` | `/api/packs/level` | Owner only | Set the ACMM level. Body `{"level": N}` persists and applies the pack; optional `{"release_level_holds": true}` performs a deliberate one-off release of Hive App level-applied `hold` labels for the transition. Level holds are otherwise never auto-released. When the change makes the L6 self-authored merge sweep eligible, the success body adds `self_merge_sweep_active`, `level_changed_at`, `level_holds_pending: [{repo, number, title, url, agent}]`, and `repos: [{repo, auto_merge}]` so the dashboard can show the owner an informational notice and repo toggles. | `pkg/dashboard/api.go:348` | | `POST` | `/api/repos/rescan` | Dashboard auth/session | Re-enumerate watched repositories' open issues/PRs for the dashboard Projects cards. The scan is read-only with respect to agents/governor actions, collapses concurrent requests, and debounces repeated presses for 30 seconds (`repoRescanDebounce`) so the button cannot hammer GitHub | `pkg/dashboard/api.go`, `pkg/dashboard/api_repos_rescan.go` | | `POST` | `/api/repos/pause` | Owner | Quiet ONE repository without stopping the hive: agents stop writing to it and stop being handed work on it, while it keeps its dashboard card and ACMM eval. Body `{"repo": "...", "reason": "..."}` β€” the repo travels in the body because a `project.repos` entry may be an explicit cross-org `owner/name`. Records who/when/why; `changed: false` marks a no-op re-pause and leaves the original provenance intact; `persisted: false` means the pause is in force but could not be written to config. Rejects a repo outside `project.repos`. See [per-repo agent pause](../repo-pause.md) | `pkg/dashboard/api_repo_pause.go` | | `POST` | `/api/repos/resume` | Owner | Lift a repository's pause. Unlike pause, does not require the repo to be in `project.repos`, so a pause left behind by a removed repo can still be cleared | `pkg/dashboard/api_repo_pause.go` | | `GET` | `/api/repos/pauses` | Dashboard auth/session | Every repo pause with its provenance. Read-only β€” the same state is already on the repository cards | `pkg/dashboard/api_repo_pause.go` | -| `GET` | `/api/repos/{owner}/{repo}/hold-permission` | Dashboard auth/session | Reports whether the signed-in user can toggle hold chips for a repository card. Owners are allowed; otherwise the server checks GitHub `repos/{owner}/{repo}/collaborators/{user}/permission` and briefly caches the effective permission | `pkg/dashboard/api.go:363`, `pkg/dashboard/api_repo_hold.go` | +| `GET` | `/api/repos/{owner}/{repo}/hold-permission` | Dashboard auth/session | Reports whether the signed-in user can toggle hold chips for a repository card. Owners are allowed; otherwise the server checks GitHub `repos/{owner}/{repo}/collaborators/{user}/permission` and briefly caches the effective permission | `pkg/dashboard/api.go:366`, `pkg/dashboard/api_repo_hold.go` | | `GET` | `/api/repos/{owner}/{repo}/items/{number}` | Dashboard session | Per-item detail for the activity monitor's decision badge: `{repo, number, stage, stage_slug, operator_actions[], operator_action_count, last_operator_action}`. `operator_actions[]` is the server-side ledger of every row action submitted from the dashboard (hold/release, decision, decide-delegated, send-to-human, accept-triage β€” recorded by `recordOperatorAction` around those routes) as `{action, actor, at, note, target, outcome: pending|applied|no-op|failed, detail, head_sha, stage}`; `last_operator_action` adds the derived `state` (`waiting|addressed|stuck|pending|failed`), `stuck_after_seconds` and `followed[]` (the stage hops after the action). The same `last_operator_action` / `operator_action_count` ride every stage-tracker row and linked PR. Persisted in `/data/operator-actions.json`; `HIVE_OPERATOR_ACTION_STUCK_AFTER` (default `30m`) sets the stuck window. Rows also carry `rebase_merge: {state: approved|rebasing|ci-pending|merged|failed, actor, base, text, fail_reason, conflict_files[], armed_at}` while a *Rebase, then merge as maintainer* follow-through is live or just settled (persisted in `/data/rebase-merge-followups.json`; `fail_reason: foreign-head` when the head moved after the approval by someone other than the hive, so nothing merged), and `rebase: {attempt, max, exhausted, head, text}` on a conflicting PR the fixer is rebasing under the two-attempt loop guard (`/data/rebase-attempts.json`). | `pkg/dashboard/api.go`, `pkg/dashboard/operator_actions.go` | -| `POST` | `/api/repos/{owner}/{repo}/items/{number}/hold` | Owner or repo write/maintain/admin | Add or remove a dashboard hold chip for an issue or PR. Body `{"held": true}` adds the hive's exact-match canonical `hive-pause/` label (shortened with a hash suffix when the plain spelling would exceed GitHub's 50-character label cap, [#17037](https://github.com/hivecommons/hive/issues/17037)); `{"held": false}` removes exactly the labels causing the hold (`hive-pause/` and/or generic hold labels) and never removes `hive/` provenance. A GitHub failure answers `424` with the step and GitHub's reason β€” never `502`/`503`, which the hosted Ingress replaces with its generic "hive unavailable" page. Every toggle writes an audit-log entry; the #8851 upgrade migration writes `/data/hive-hold-migration-.json` with audit-backed, provenance-only, and ambiguous legacy `hive/` classifications. | `pkg/dashboard/api.go:367`, `pkg/dashboard/api_repo_hold.go` | -| `POST` | `/api/repos/{owner}/{repo}/items/{number}/decision` | Owner or repo write/maintain/admin, signed-in GitHub user | Record an operator decision from the activity monitor's row menu / selection bar. Body `{"decision": "approve"|"direction"|"close-superseded"|"close-wontfix"|"close-read"|"close-issue"|"adopt"|"reroute"|"re-author"|"merge-now"|"queue-merge", "comment": "…", "type": "issue"|"pr", "superseded_by": "#458", "force": true, "to": ""|"any"}` (`force` only with `adopt`, `to` only with `reroute`; an unknown `decision` is a 400). On an issue parked at 045, `approve`/`direction` act exactly as a maintainer's `/hive approve` / `/hive decision ` comment would (labels, `approved-direction`, stage-engine Unparked fact, reply comment naming the operator). On a PR parked at 085 (`type: "pr"`), `approve` posts the `/lgtm` and `/approve` commands as two comments attributed to the operator, records the approval against the head as the human answer to a `requires_human` / decision-requested verdict (the lanes and the stage engine honour it like a trusted merger's Approve review), lifts `needs-human`, clears the sentinel label and acknowledges the bundle's sentinel findings for that head, re-derives the stage and drops the tracker cache β€” the response adds `released` (`approval-recorded`, `needs-human`, `sentinel-label`, `sentinel-acknowledged`) and `stage`; the merge gate decides from there (no review as the operator, no merge) β€” and `direction` records the text as a human change request against the head β€” the `/hive decision` fix lane β€” on a hive-authored PR, and on a contributor's clanker PR (`author/clanker` / hive attribution markers) where the text is posted to the contributor's hive and the PR moves to `stage/072-returned-to-contributor` (`status: "direction-returned-to-contributor"`); a person's PR, which no hive can carry direction to, answers 409. The close variants first post a required comment naming the operator (and the superseding PR when `superseded_by` is given); a failed comment prevents closure. `close-superseded` and `close-wontfix` close as **not planned**; `close-read` acknowledges an advisory issue and closes it **as completed**. `close-issue` (`type: "issue"` only) closes the single named open issue **as completed**, at any stage, leaving linked PRs and other issues untouched; it returns 400 for a PR (including one sent as an issue), 404 when the issue is not found, and 409 when already closed. It retains the existing permission and mutation gates, including the paused-repo `status: "refused"` result without writes. `adopt` and `re-author` require `type: "pr"` and queue adoption via the `adoption/supersede` label (409 unless `governor.labels.stage.adoption` is enabled). `adopt` with `"force": true` is *Adopt now* ([#15038](https://github.com/hivecommons/hive/issues/15038)): it bypasses the author's presence, the grace window and `maintainer_policy`, marks the stage record and nudges the automerge sweep so the adoption engine supersedes the head at once (responds `status: "already-adopted"` when nothing is left to do; 400 on the hive's own PR). `reroute` (`type: "pr"`, `"to": ""|"any"`) hands the PR's follow-up work to another online clanker through the contributor fix queue, posts a `` note and arms a pickup timer that falls back to Adopt now after `governor.reroute.pickup_timeout`; answers `status: "rerouted"`, 409 when no clanker is online or adoption is disabled. On a PR at 090, `queue-merge` (*Approve and let hive merge*, the lead action) records an approval attributed to the operator and applies the automerge label, returning `status: "queued-for-merge"` (on the hive's own PR, which the App cannot review, the queue note is a comment and the operator's approval is recorded as `approve` records it β€” `released` lists `approval-recorded`, `admitted`); hive's merge sweep merges on its next tick once CI is green and the head has soaked, and that recorded operator approval is the one thing that lets the sweep merge an untrusted-author PR (the merge comment names the operator). `merge-now` (*Merge now as maintainer*, secondary) posts an attributed comment and merges immediately through the hive's GitHub App, returning `status: "merged"`. Both require `type: "pr"` and a verified owner or `merger` role in addition to the route's repo permission check; both refuse closed/merged PRs and the operator's own PR. `merge-now` on a PR whose `mergeable_state` is `dirty`/`behind` instead answers `202` with `status: "rebase-queued"` (approval recorded, rebase kicked, merge follows via the follow-through once the hive's own rebase is green; a head pushed by anyone else fails the follow-through closed; `head-not-pushable` refusal on a fork without maintainer edits). `approve` on such a PR adds `rebase: {attempt, max, exhausted, head, text}` for the rebase episode it opened. Every decision writes an audit-log entry (`repo_item_decision_*`, e.g. `repo_item_decision_adopt_now`, `repo_item_decision_reroute`). | `pkg/dashboard/api.go:368`, `pkg/dashboard/api_repo_item_decision.go` | -| `GET` | `/api/notes` | Signed-in GitHub user (or verified owner) | Maintainer notes β€” the private per-item notes maintainers leave each other on Activity Monitor rows ([#maintainer-notes](../operator-reference.md#maintainer-notes)). `?repo=owner/name&number=N` lists one item's live notes oldest first; with no query, every live note in the hive (the SPA's count badges). `{notes: [{id, repo, number, author, text, created_at}]}`. Never read from or written to GitHub. | `pkg/dashboard/api.go:372`, `pkg/dashboard/api_maintainer_notes.go` | -| `POST` | `/api/notes` | Owner or repo write/maintain/admin, signed-in GitHub user | Append a maintainer note. Body `{repo, number, text}` β€” `text` is plain text up to 2000 characters; the author is the dashboard session's GitHub login, never a body field. `401` signed out, `403` without write on the repo, `400` for an empty/oversized note or a bad item. `201 {ok, note}`; appended to `/data/maintainer-notes.jsonl`, audited as `maintainer_note_add`, and broadcast as the `maintainer-notes` SSE event `{seq, repo, number, count, action}` so other maintainers' browsers refresh the row. | `pkg/dashboard/api.go:373`, `pkg/dashboard/api_maintainer_notes.go` | -| `DELETE` | `/api/notes/{id}` | The note's author, or a verified owner | Tombstone a maintainer note (append-only store: a `deleted_at` record, never a rewrite). `404` unknown or already deleted, `403` for anyone else. `{ok, note}`; audited as `maintainer_note_delete`, broadcast as `maintainer-notes`. | `pkg/dashboard/api.go:374`, `pkg/dashboard/api_maintainer_notes.go` | +| `POST` | `/api/repos/{owner}/{repo}/items/{number}/hold` | Owner or repo write/maintain/admin | Add or remove a dashboard hold chip for an issue or PR. Body `{"held": true}` adds the hive's exact-match canonical `hive-pause/` label (shortened with a hash suffix when the plain spelling would exceed GitHub's 50-character label cap, [#17037](https://github.com/hivecommons/hive/issues/17037)); `{"held": false}` removes exactly the labels causing the hold (`hive-pause/` and/or generic hold labels) and never removes `hive/` provenance. A GitHub failure answers `424` with the step and GitHub's reason β€” never `502`/`503`, which the hosted Ingress replaces with its generic "hive unavailable" page. Every toggle writes an audit-log entry; the #8851 upgrade migration writes `/data/hive-hold-migration-.json` with audit-backed, provenance-only, and ambiguous legacy `hive/` classifications. | `pkg/dashboard/api.go:370`, `pkg/dashboard/api_repo_hold.go` | +| `POST` | `/api/repos/{owner}/{repo}/items/{number}/decision` | Owner or repo write/maintain/admin, signed-in GitHub user | Record an operator decision from the activity monitor's row menu / selection bar. Body `{"decision": "approve"|"direction"|"close-superseded"|"close-wontfix"|"close-read"|"close-issue"|"adopt"|"reroute"|"re-author"|"merge-now"|"queue-merge", "comment": "…", "type": "issue"|"pr", "superseded_by": "#458", "force": true, "to": ""|"any"}` (`force` only with `adopt`, `to` only with `reroute`; an unknown `decision` is a 400). On an issue parked at 045, `approve`/`direction` act exactly as a maintainer's `/hive approve` / `/hive decision ` comment would (labels, `approved-direction`, stage-engine Unparked fact, reply comment naming the operator). On a PR parked at 085 (`type: "pr"`), `approve` posts the `/lgtm` and `/approve` commands as two comments attributed to the operator, records the approval against the head as the human answer to a `requires_human` / decision-requested verdict (the lanes and the stage engine honour it like a trusted merger's Approve review), lifts `needs-human`, clears the sentinel label and acknowledges the bundle's sentinel findings for that head, re-derives the stage and drops the tracker cache β€” the response adds `released` (`approval-recorded`, `needs-human`, `sentinel-label`, `sentinel-acknowledged`) and `stage`; the merge gate decides from there (no review as the operator, no merge) β€” and `direction` records the text as a human change request against the head β€” the `/hive decision` fix lane β€” on a hive-authored PR, and on a contributor's clanker PR (`author/clanker` / hive attribution markers) where the text is posted to the contributor's hive and the PR moves to `stage/072-returned-to-contributor` (`status: "direction-returned-to-contributor"`); a person's PR, which no hive can carry direction to, answers 409. The close variants first post a required comment naming the operator (and the superseding PR when `superseded_by` is given); a failed comment prevents closure. `close-superseded` and `close-wontfix` close as **not planned**; `close-read` acknowledges an advisory issue and closes it **as completed**. `close-issue` (`type: "issue"` only) closes the single named open issue **as completed**, at any stage, leaving linked PRs and other issues untouched; it returns 400 for a PR (including one sent as an issue), 404 when the issue is not found, and 409 when already closed. It retains the existing permission and mutation gates, including the paused-repo `status: "refused"` result without writes. `adopt` and `re-author` require `type: "pr"` and queue adoption via the `adoption/supersede` label (409 unless `governor.labels.stage.adoption` is enabled). `adopt` with `"force": true` is *Adopt now* ([#15038](https://github.com/hivecommons/hive/issues/15038)): it bypasses the author's presence, the grace window and `maintainer_policy`, marks the stage record and nudges the automerge sweep so the adoption engine supersedes the head at once (responds `status: "already-adopted"` when nothing is left to do; 400 on the hive's own PR). `reroute` (`type: "pr"`, `"to": ""|"any"`) hands the PR's follow-up work to another online clanker through the contributor fix queue, posts a `` note and arms a pickup timer that falls back to Adopt now after `governor.reroute.pickup_timeout`; answers `status: "rerouted"`, 409 when no clanker is online or adoption is disabled. On a PR at 090, `queue-merge` (*Approve and let hive merge*, the lead action) records an approval attributed to the operator and applies the automerge label, returning `status: "queued-for-merge"` (on the hive's own PR, which the App cannot review, the queue note is a comment and the operator's approval is recorded as `approve` records it β€” `released` lists `approval-recorded`, `admitted`); hive's merge sweep merges on its next tick once CI is green and the head has soaked, and that recorded operator approval is the one thing that lets the sweep merge an untrusted-author PR (the merge comment names the operator). `merge-now` (*Merge now as maintainer*, secondary) posts an attributed comment and merges immediately through the hive's GitHub App, returning `status: "merged"`. Both require `type: "pr"` and a verified owner or `merger` role in addition to the route's repo permission check; both refuse closed/merged PRs and the operator's own PR. `merge-now` on a PR whose `mergeable_state` is `dirty`/`behind` instead answers `202` with `status: "rebase-queued"` (approval recorded, rebase kicked, merge follows via the follow-through once the hive's own rebase is green; a head pushed by anyone else fails the follow-through closed; `head-not-pushable` refusal on a fork without maintainer edits). `approve` on such a PR adds `rebase: {attempt, max, exhausted, head, text}` for the rebase episode it opened. Every decision writes an audit-log entry (`repo_item_decision_*`, e.g. `repo_item_decision_adopt_now`, `repo_item_decision_reroute`). | `pkg/dashboard/api.go:371`, `pkg/dashboard/api_repo_item_decision.go` | +| `GET` | `/api/notes` | Signed-in GitHub user (or verified owner) | Maintainer notes β€” the private per-item notes maintainers leave each other on Activity Monitor rows ([#maintainer-notes](../operator-reference.md#maintainer-notes)). `?repo=owner/name&number=N` lists one item's live notes oldest first; with no query, every live note in the hive (the SPA's count badges). `{notes: [{id, repo, number, author, text, created_at}]}`. Never read from or written to GitHub. | `pkg/dashboard/api.go:375`, `pkg/dashboard/api_maintainer_notes.go` | +| `POST` | `/api/notes` | Owner or repo write/maintain/admin, signed-in GitHub user | Append a maintainer note. Body `{repo, number, text}` β€” `text` is plain text up to 2000 characters; the author is the dashboard session's GitHub login, never a body field. `401` signed out, `403` without write on the repo, `400` for an empty/oversized note or a bad item. `201 {ok, note}`; appended to `/data/maintainer-notes.jsonl`, audited as `maintainer_note_add`, and broadcast as the `maintainer-notes` SSE event `{seq, repo, number, count, action}` so other maintainers' browsers refresh the row. | `pkg/dashboard/api.go:376`, `pkg/dashboard/api_maintainer_notes.go` | +| `DELETE` | `/api/notes/{id}` | The note's author, or a verified owner | Tombstone a maintainer note (append-only store: a `deleted_at` record, never a rewrite). `404` unknown or already deleted, `403` for anyone else. `{ok, note}`; audited as `maintainer_note_delete`, broadcast as `maintainer-notes`. | `pkg/dashboard/api.go:377`, `pkg/dashboard/api_maintainer_notes.go` | | `POST` / `GET` | `/api/repos/{owner}/{repo}/items/{number}/decide-delegated` | Same as `…/hold` (owner or repo write), signed-in GitHub user; `decisions.delegate.enabled` (403 when off) | *Let hive decide*: enqueue a delegated-decision job in which the hive decides the parked item itself as the owner's delegate under `pkg/delegatedecision.PolicyText` and records it as a human decision (`/hive decision` comment with the `` marker, park labels off, `triage/accepted`, queue approval on a PR head). Body `{"type": "issue"|"pr", "reason", "caption", "sentinel_reason"}`. `POST` answers 202 with the job (`{id, status: "running"|"done"|"failed", decision: {verdict, rule, text, rationale, actions, keeps_park, ladder?: {decision, reason, ruled_out[]}}, notes, error}`; `ladder` is set on contributor-PR decisions and names the rung that decided β€” `already-fixed` β†’ `mergeable-as-is` β†’ `rebase-then-merge` β†’ `return-to-contributor` β†’ `contributor-offline` β€” and why each rung above it did not apply); 409 with the running job while one is in flight; `GET` reads it. Rails: never merges, refuses items wearing `hold`, never grants secrets/permissions (`needs-owner` + `hold` instead), recommends only on hive policy/governance questions (keeps 085). Audited as `repo_item_decision_delegated[_failed]`. See [dashboard-sections.md](../dashboard-sections.md#let-hive-decide). | `pkg/dashboard/api.go:329`, `pkg/dashboard/api_repo_item_decide_delegated.go` | -| `POST` | `/api/repos/{owner}/{repo}/items/{number}/decide-delegated/plan` | Same as `…/decide-delegated` | Preview for *Let hive decide*: runs the same owner-delegate policy with **no side effects** (nothing written, no job) and answers `{would, rule, acting, title, summary, why, consequences[], ladder, mergeable_as_is, alternatives[], writes: false, prior, notes}` β€” `summary` is one verb-first sentence with no "Hive would:" prefix (*Send #15332 back to its author with one instruction: make CI green, nothing else.*), `why` one short line (*CI is red.*), `consequences` at most two items, `ladder` the rung that decided with its `ruled_out` list, and `mergeable_as_is` whether an as-is merge is honest (the queue-merge and merge-now alternatives are dropped when it is not). The dialog shows **Cancel** / **Let hive do that**, plus **Approve and let hive merge** (or **Merge now as maintainer** when the hive would approve anyway) only when `mergeable_as_is`; only then does the SPA post `…/decide-delegated` with `chosen` naming the button. A transient GitHub failure (5xx, rate limit, timeout) is retried twice with backoff before the plan gives up. A failure answers `{ok: false, error, reason, retryable, attempts, elapsed_ms, hive, hint}` under **409** when the item is no longer open (`reason: not-open`) or **424** when a read it depends on failed (`github`), is not configured (`config`) or the plan itself failed (`plan`) β€” never 502/503, which the hosted Ingress intercepts and replaces with its generic *hive unavailable* page (hivecommons/hive#16776). See [dashboard-sections.md](../dashboard-sections.md#let-hive-decide). | `pkg/dashboard/api.go:378`, `pkg/dashboard/api_repo_item_decide_delegated_plan.go` | -| `POST` | `/api/repos/{owner}/{repo}/items/{number}/blocker/recheck` | Same as `…/hold` (owner or repo write), signed-in GitHub user | *Re-check permissions* on an issue parked on a capability blocker (#15099): probes the GitHub App installation's permissions; when the missing permission is granted, clears every ledger entry blocked on it and un-parks the item (`{result: {outcome: "released", …}}`), otherwise `{result: {outcome: "no-effect", reason: "blocker", options: [...]}}`; 404 when the item has no blocker, 409 when the blocker is not a capability gap | `pkg/dashboard/api.go:379` | -| `POST` | `/api/repos/{owner}/{repo}/items/{number}/blocker/close-partial` | Same as `…/hold` (owner or repo write), signed-in GitHub user | Option (c) on a capability blocker (#15099): labels the issue `hive/partially-addressed`, posts the operator's `{"comment"}` naming what remains and closes it; the ledger entry is cleared | `pkg/dashboard/api.go:381` | -| `POST` | `/api/repos/{owner}/{repo}/items/{number}/accept-triage` | Owner or repo write/maintain/admin | Accept triage on an issue from the activity monitor's row menu β€” what a maintainer accepting triage on GitHub does by hand: adds the reporter-trust triage label (`triage/accepted`, or the first of `issue_filter.reporter_trust.untrusted_require_labels`; the label is created in the repo if missing) and removes the awaiting label (`needs-triage` / `reporter_trust.awaiting_label`) and a Hive-written `stage/015-triage-needed`. Empty body. Returns `{ok, repo, number, acceptLabel, added, removed, alreadyAccepted, minStatusSeq}`; an issue already carrying the accept label with nothing left to remove is a no-op (`alreadyAccepted: true`, no write, no audit). 404 for a pull request or an unknown item. The status snapshot's labels are patched so the next reconcile reads 010 Actionable before the engine's own 015 β†’ 010 derivation. Every write is audited as `repo_item_accept_triage` under the dashboard user. | `pkg/dashboard/api.go:594`, `pkg/dashboard/api_repo_item_accept_triage.go` | -| `POST` | `/api/repos/{owner}/{repo}/items/{number}/sentinel-confirm` | Owner or repo write/maintain/admin | **Confirm safe** on the activity monitor's human-gate pill ([#17470](https://github.com/hivecommons/hive/issues/17470)): a person confirms a sentinel finding is intended. Body `{comment?, type?}` (`type` is `pr` or `issue`, comment ≀ 4000 characters). Posts the confirmation as a comment first β€” ``, `@user confirmed the sentinel finding safe from the dashboard.`, then the comment text β€” and removes every label matching the configured sentinel label (`sentinel.label`, default `sentinel-alert`, case-insensitive). Returns `{ok, repo, number, label, removed, alreadyClear, result}`; with no sentinel label on the item the comment is still posted and `alreadyClear` is true. 403 for a repository the hive does not watch. Audited as `repo_item_sentinel_confirm` under the dashboard user; the operator-action ledger records it as `confirm-sentinel`. | `pkg/dashboard/api.go:369`, `pkg/dashboard/api_repo_item_sentinel_confirm.go` | -| `GET` | `/api/acmm/evaluation` | Dashboard auth/session | ACMMEvaluation β€” combined codebase + operational result, cached server-side for 1 hour (`acmmEvalTTL`). `?refresh=1` (the dashboard's "πŸ”„ Re-evaluate" button, #5877) bypasses the hourly TTL but is debounced server-side: requests within 1 minute of the last evaluation (`acmmRefreshDebounce`) still serve the cache, since a full refresh costs up to ~29 GitHub GetContents calls per repo. The response's `last_evaluated_at` timestamp reports when the cached evaluation was computed | `pkg/dashboard/api.go:383`, `pkg/dashboard/api_acmm_eval.go` | -| `POST` | `/api/acmm/issue` | Owner only | ACMMCreate Issue β€” files on GitHub or, with `governor.acmm.issue_tracker: work_source` / body `tracker: "work_source"` on a Linear-sourced hive, on Linear; response `tracker` says which. See [ACMM policy matrix](../acmm-policy-matrix.md#where-acmm-gap-issues-are-filed) | `pkg/dashboard/api.go:384` | -| `GET` | `/api/acmm-recommendation` | Dashboard auth/session | Advisory level-up recommendation (`acmmadvisor.Recommendation`, JSON): never changes the applied level β€” see [ACMM advisor](../acmm-advisor.md) | `pkg/dashboard/api.go:385` | -| `GET` | `/api/hive-advice` | Dashboard auth/session | Weekly owner advice (`hiveadvisor.Result`, JSON) shared with the advisory digest: recommendations with `items`, `links` (Overview export paths) and `bands`; `bands[]` rule text; `counts` naming the governor actionable queue vs. the Overview total. Which recommendations show is frozen for 7 days; their numbers refresh every call β€” see [Owner advice](../advisory.md#owner-advice) | `pkg/dashboard/api.go:387`, `pkg/dashboard/api_hive_advice.go` | +| `POST` | `/api/repos/{owner}/{repo}/items/{number}/decide-delegated/plan` | Same as `…/decide-delegated` | Preview for *Let hive decide*: runs the same owner-delegate policy with **no side effects** (nothing written, no job) and answers `{would, rule, acting, title, summary, why, consequences[], ladder, mergeable_as_is, alternatives[], writes: false, prior, notes}` β€” `summary` is one verb-first sentence with no "Hive would:" prefix (*Send #15332 back to its author with one instruction: make CI green, nothing else.*), `why` one short line (*CI is red.*), `consequences` at most two items, `ladder` the rung that decided with its `ruled_out` list, and `mergeable_as_is` whether an as-is merge is honest (the queue-merge and merge-now alternatives are dropped when it is not). The dialog shows **Cancel** / **Let hive do that**, plus **Approve and let hive merge** (or **Merge now as maintainer** when the hive would approve anyway) only when `mergeable_as_is`; only then does the SPA post `…/decide-delegated` with `chosen` naming the button. A transient GitHub failure (5xx, rate limit, timeout) is retried twice with backoff before the plan gives up. A failure answers `{ok: false, error, reason, retryable, attempts, elapsed_ms, hive, hint}` under **409** when the item is no longer open (`reason: not-open`) or **424** when a read it depends on failed (`github`), is not configured (`config`) or the plan itself failed (`plan`) β€” never 502/503, which the hosted Ingress intercepts and replaces with its generic *hive unavailable* page (hivecommons/hive#16776). See [dashboard-sections.md](../dashboard-sections.md#let-hive-decide). | `pkg/dashboard/api.go:381`, `pkg/dashboard/api_repo_item_decide_delegated_plan.go` | +| `POST` | `/api/repos/{owner}/{repo}/items/{number}/blocker/recheck` | Same as `…/hold` (owner or repo write), signed-in GitHub user | *Re-check permissions* on an issue parked on a capability blocker (#15099): probes the GitHub App installation's permissions; when the missing permission is granted, clears every ledger entry blocked on it and un-parks the item (`{result: {outcome: "released", …}}`), otherwise `{result: {outcome: "no-effect", reason: "blocker", options: [...]}}`; 404 when the item has no blocker, 409 when the blocker is not a capability gap | `pkg/dashboard/api.go:382` | +| `POST` | `/api/repos/{owner}/{repo}/items/{number}/blocker/close-partial` | Same as `…/hold` (owner or repo write), signed-in GitHub user | Option (c) on a capability blocker (#15099): labels the issue `hive/partially-addressed`, posts the operator's `{"comment"}` naming what remains and closes it; the ledger entry is cleared | `pkg/dashboard/api.go:384` | +| `POST` | `/api/repos/{owner}/{repo}/items/{number}/accept-triage` | Owner or repo write/maintain/admin | Accept triage on an issue from the activity monitor's row menu β€” what a maintainer accepting triage on GitHub does by hand: adds the reporter-trust triage label (`triage/accepted`, or the first of `issue_filter.reporter_trust.untrusted_require_labels`; the label is created in the repo if missing) and removes the awaiting label (`needs-triage` / `reporter_trust.awaiting_label`) and a Hive-written `stage/015-triage-needed`. Empty body. Returns `{ok, repo, number, acceptLabel, added, removed, alreadyAccepted, minStatusSeq}`; an issue already carrying the accept label with nothing left to remove is a no-op (`alreadyAccepted: true`, no write, no audit). 404 for a pull request or an unknown item. The status snapshot's labels are patched so the next reconcile reads 010 Actionable before the engine's own 015 β†’ 010 derivation. Every write is audited as `repo_item_accept_triage` under the dashboard user. | `pkg/dashboard/api.go:597`, `pkg/dashboard/api_repo_item_accept_triage.go` | +| `POST` | `/api/repos/{owner}/{repo}/items/{number}/sentinel-confirm` | Owner or repo write/maintain/admin | **Confirm safe** on the activity monitor's human-gate pill ([#17470](https://github.com/hivecommons/hive/issues/17470)): a person confirms a sentinel finding is intended. Body `{comment?, type?}` (`type` is `pr` or `issue`, comment ≀ 4000 characters). Posts the confirmation as a comment first β€” ``, `@user confirmed the sentinel finding safe from the dashboard.`, then the comment text β€” and removes every label matching the configured sentinel label (`sentinel.label`, default `sentinel-alert`, case-insensitive). Returns `{ok, repo, number, label, removed, alreadyClear, result}`; with no sentinel label on the item the comment is still posted and `alreadyClear` is true. 403 for a repository the hive does not watch. Audited as `repo_item_sentinel_confirm` under the dashboard user; the operator-action ledger records it as `confirm-sentinel`. | `pkg/dashboard/api.go:372`, `pkg/dashboard/api_repo_item_sentinel_confirm.go` | +| `GET` | `/api/acmm/evaluation` | Dashboard auth/session | ACMMEvaluation β€” combined codebase + operational result, cached server-side for 1 hour (`acmmEvalTTL`). `?refresh=1` (the dashboard's "πŸ”„ Re-evaluate" button, #5877) bypasses the hourly TTL but is debounced server-side: requests within 1 minute of the last evaluation (`acmmRefreshDebounce`) still serve the cache, since a full refresh costs up to ~29 GitHub GetContents calls per repo. The response's `last_evaluated_at` timestamp reports when the cached evaluation was computed | `pkg/dashboard/api.go:386`, `pkg/dashboard/api_acmm_eval.go` | +| `POST` | `/api/acmm/issue` | Owner only | ACMMCreate Issue β€” files on GitHub or, with `governor.acmm.issue_tracker: work_source` / body `tracker: "work_source"` on a Linear-sourced hive, on Linear; response `tracker` says which. See [ACMM policy matrix](../acmm-policy-matrix.md#where-acmm-gap-issues-are-filed) | `pkg/dashboard/api.go:387` | +| `GET` | `/api/acmm-recommendation` | Dashboard auth/session | Advisory level-up recommendation (`acmmadvisor.Recommendation`, JSON): never changes the applied level β€” see [ACMM advisor](../acmm-advisor.md) | `pkg/dashboard/api.go:388` | +| `GET` | `/api/hive-advice` | Dashboard auth/session | Weekly owner advice (`hiveadvisor.Result`, JSON) shared with the advisory digest: recommendations with `items`, `links` (Overview export paths) and `bands`; `bands[]` rule text; `counts` naming the governor actionable queue vs. the Overview total. Which recommendations show is frozen for 7 days; their numbers refresh every call β€” see [Owner advice](../advisory.md#owner-advice) | `pkg/dashboard/api.go:390`, `pkg/dashboard/api_hive_advice.go` | diff --git a/src/docs/api-reference/snapshots-and-style.md b/src/docs/api-reference/snapshots-and-style.md index 5bddc23ff2..3e82c88beb 100644 --- a/src/docs/api-reference/snapshots-and-style.md +++ b/src/docs/api-reference/snapshots-and-style.md @@ -6,7 +6,7 @@ Part of the [Dashboard REST API reference](../api-reference.md); see that page f | Method | Path | Auth | Purpose | Source | |---|---|---|---|---| | `GET` | `/api/style` | Public | Sanitized custom dashboard CSS | `pkg/dashboard/api.go:48` | -| `GET` | `/branding/custom.css` | Dashboard auth/session | Operator branding stylesheet override, read per request (see [branding](../branding.md)) | `pkg/dashboard/server.go:1944` | +| `GET` | `/branding/custom.css` | Dashboard auth/session | Operator branding stylesheet override, read per request (see [branding](../branding.md)) | `pkg/dashboard/server.go:1959` | | `GET` | `/api/snapshot/frame-ancestors` | Public | Snapshot framing allowlist | `pkg/dashboard/api.go:91` | | `GET` | `/api/snapshot` | Public | Snapshot data | `pkg/dashboard/api.go:92` | | `GET` | `/snapshot` | Public | Public read-only snapshot page | `pkg/dashboard/api.go:93` | diff --git a/src/docs/benchmarks.md b/src/docs/benchmarks.md index ef2ef3c072..5641dae5fe 100644 --- a/src/docs/benchmarks.md +++ b/src/docs/benchmarks.md @@ -22,7 +22,7 @@ One record per PR per role, conforming to | `source.hive_id` | **Salted hash** of the hive id: `sha256("hivecommons-benchmarks/v1/" + hive_id)[:16]`. The hive id itself never leaves the spoke. | | `source.hive_version`, `source.cluster` | Build version and `HIVE_CLUSTER_ID`. | | `agent.name`, `agent.role` (`author` / `reviewer`), `agent.harness` | Bundle `author.agent`, `author.backend`; reviewer records come from the dominant `(backend, model)` among the swarm verdicts. | -| `model.provider`, `model.name` | Bundle `author.model`; the provider is inferred from the model name prefix. | +| `model.provider`, `model.name`, `model.canonical`, `model.effort` | Bundle `author.model`. `name` is the id exactly as the harness reported it; `canonical` folds CLI spellings to one id (`claude-sonnet-5-5`, `anthropic/claude-sonnet-5.5` β†’ `claude-sonnet-5.5`; `openai-codex/gpt-6.1-sol` β†’ `gpt-6.1-sol`; `auto` for a runtime auto-select) and `effort` holds a stripped reasoning-effort suffix (`gemini-3.8-flash-low` β†’ `low`). `provider` is the vendor of the canonical family, never a CLI or gateway name. Rules: `pkg/modelfamily.Resolve`. | | `sandbox_runtime` | Stamped on new bundles from the agent's effective crux sandbox (`podman`, `kubernetes-job`, …) or `none` when agents run in-process. Old bundles fall back to the current config. | | `task.kind` | Stamped from the agent's lane (`docs`, `security-fix`, `ci-repair`, `release`, `dependency-update`, `triage`, `refactor`), else `unknown`. Reviewer records are `pr-review`. | | `task.acmm_level` | `policy.acmm_level` at review time. | diff --git a/src/docs/cli-pins.md b/src/docs/cli-pins.md index e53751befa..4a430bed07 100644 --- a/src/docs/cli-pins.md +++ b/src/docs/cli-pins.md @@ -10,6 +10,7 @@ Every agent CLI hive drives is baked into the images at a fixed version: | pi | `PI_VERSION` (hub), `PI_CODING_AGENT_VERSION` (contributor) | npm `@earendil-works/pi-coding-agent` | npm | | Goose | `GOOSE_VERSION`, `GOOSE_SHA256_{AMD64,ARM64}` | GitHub releases `aaif-goose/goose` | SHA-256 per arch | | Antigravity (agy) | `AGY_VERSION` + `AGY_BUILD`, `AGY_SHA512_*` (hub); `AGY_VERSION=-`, `AGY_SHA256_*` (contributor) | Google's updater artifact, as tracked by the Homebrew `antigravity-cli` cask | SHA-512 (hub) / SHA-256 (contributor) per arch | +| spektacular | `SPEKTACULAR_VERSION`, `SPEKTACULAR_SHA256_{AMD64,ARM64}` | GitHub releases `hivecommons/spektacular` | SHA-256 per arch, from the release `checksums.txt` (manual bump for now; not yet in `cli-pin-bump.sh`) | | Oh My Pi (omp) | `OMP_VERSION`, `OMP_SHA256_*` | GitHub releases `can1357/oh-my-pi` | SHA-256 per arch, cross-checked with `SHA256SUMS.txt` | | Muse Code | `MUSE_VERSION`, `MUSE_SHA256_*` | `api.meta.ai/muse-code/channels/muse-stable` and its release manifest | SHA-256 per arch, cross-checked with the manifest | | bobshell (bob) | `BOBSHELL_VERSION`, `BOBSHELL_SHA256_*` | `bobshell2-version.txt` on IBM COS | SHA-256, cross-checked with the vendor `.tgz.sha256` | diff --git a/src/docs/dashboard-source-layout.md b/src/docs/dashboard-source-layout.md index e75eca06f9..2e907c305a 100644 --- a/src/docs/dashboard-source-layout.md +++ b/src/docs/dashboard-source-layout.md @@ -198,7 +198,7 @@ CSS (`css/`): `base` (tokens, reset, shared primitives), `sidebar`, `topbar`, `agent-detail`, `agent-cards`, `auth`, `agent-state`, `tracker` (Hive activity monitor), `tracker-native-queue`, `agent-fields`, `governor`, `advisory-lifecycle`, `evidence`, `throughput`, `repos`, `label-filter`, `overview`, `sparklines-indicators`, -`governor-matrix`, `cost`, `alerts-toasts`, `feedback-faq`, `config-dialog`, +`governor-matrix`, `cost`, `alerts-toasts`, `feedback-faq`, `config-dialog`, `config-webhooks`, `chat`, `nous`, `knowledge`, `sections-collapse`, `design-system`, `responsive`, `recipes-utilities`, `welcome`, `review-campaigns`, `variables`. @@ -224,7 +224,7 @@ JS (`js/`): `boot`, `core`, `feedback`, `sections`, `layout`, `agent-detail`, `review-queue`, `contributors`, `refresh`, `inception`, `debug-logs`, `tooltips-dialogs`, `plan-agent-controls`, `acmm`, `version`, `sse-bootstrap`, `config-state`, `settings-search`, `config-dialog`, `config-tabs`, -`config-hub`, `config-agent`, `config-governor`, `config-watchdog-security`, +`config-webhooks`, `config-hub`, `config-agent`, `config-governor`, `config-watchdog-security`, `config-features`, `config-sentinel`, `compliance`, `config-helpers`, `linear-repos-save`, `chat`, `gh-auth`, `nps`, `welcome`, `shimmy-handlers`. diff --git a/src/docs/env-vars.md b/src/docs/env-vars.md index 2c05e70eb6..2d21c53537 100644 --- a/src/docs/env-vars.md +++ b/src/docs/env-vars.md @@ -104,7 +104,7 @@ behavior. [Maintenance limitations](#keeping-this-reference-current) still apply | `HIVE_CONTRIBUTE_SKIP_LABELS` | No | `blocked,tracking,epic,discussion,question,needs-decision,needs-triage` | Comma-separated, case-insensitive label patterns for issues that are not contributor work and must never be offered by the relay. Patterns use `path.Match`-style `*` globs; `blocked` is always unioned into the effective set even if omitted. Same setting as `hub.contribute_skip_labels`. | | `HIVE_FEDERATION_REGISTRY_PATH` | No | `/data/federation/registry.json` | Federation registry path override. | | `HIVE_WEBHOOK_SECRET` | No | none | HMAC secret for the spoke `/webhook` channel. | -| `GITHUB_WEBHOOK_SECRET` | No | `/data/saas/webhook-secret.key` when present (hub only) | GitHub App webhook HMAC secret. The hub verifies `/api/github/webhook` with it; a self-hosted spoke verifies `/api/webhook/github` (direct or hub-relayed deliveries) with the same secret. A hosted spoke leaves it unset and verifies hub relays with its own per-hive relay key from the heartbeat (`/api/status.webhooks.verification: relay (hub-signed)`); with neither, every delivery is rejected and the spoke reports `/api/status.webhooks.mode_reason_code: no_secret` and a `webhooks` warning on `/api/health/deep` once deliveries arrive. | +| `GITHUB_WEBHOOK_SECRET` | No | `/data/saas/webhook-secret.key` when present (hub); `/data/github-webhook-secret.key` when present (spoke, written by Settings β†’ Forge App β†’ **Generate secret**) | GitHub App or org/repo webhook HMAC secret. The hub verifies `/api/github/webhook` with it; a self-hosted spoke verifies `/api/webhook/github` (direct or hub-relayed deliveries) with the same secret. When the variable is set it wins over the generated file and the dashboard's Generate button is disabled. See [Setting up webhooks](webhooks.md#setting-up-webhooks-owner-guide). A hosted spoke leaves it unset and verifies hub relays with its own per-hive relay key from the heartbeat; with neither, every delivery is rejected. | | `HIVE_DASHBOARD_URL` | No | none | Base URL the `hive tui` client targets (`pkg/tui/client`). A bad value surfaces as a request error on the first call, not at startup. On the hub it is also consulted (fourth) in the hub public-origin chain (see `HIVE_HUB_PUBLIC_URL`). | | `HIVE_CONVERGENCE_MODE` | No | `convergence.mode` in `hive.yaml`, else `shadow` | Process-level override of the convergence mode (`off`, `shadow`, `enforce`) so an operator can flip the mode without editing `hive.yaml`. An **unset** mode takes the default, `shadow`, which computes and records admission decisions without withholding any work. A **non-empty but unrecognised** value β€” a typo, or a mode this build does not know β€” still resolves to `off`. | | `HIVE_RELEASE_LINE_LAG_MAX` | No | `5` | Alarm threshold for the release-line drift surface ([#6960](https://github.com/hivecommons/hive/issues/6960)): how many commits the hosted edge line (`v5`) may sit behind the stable default branch (`v4`) before the dashboard status flags the line as drifting (`releaseLineLag.exceeded`). An unset, empty, non-numeric, or negative value falls back to the default β€” it can never silently disable the alarm. An unknown lag (tips unresolved or the compare failed) is reported as `unknown`, never a healthy zero. | diff --git a/src/docs/fleet-report.md b/src/docs/fleet-report.md index beecf18ad7..e27f361992 100644 --- a/src/docs/fleet-report.md +++ b/src/docs/fleet-report.md @@ -155,7 +155,7 @@ title is `[selfreport] : `. | `hub_heartbeat_rejected_unprovisioned` | the hub answered the heartbeat 403 `hosted/saas hive IDs can only be created via provisioning` β€” it does not know this hive ID (deprovisioned, renamed or registry drift), so retrying cannot succeed. The hive backs off 10 minutes between probes; fix by provisioning or re-IDing the hive. Log-fed, fires on first sighting, 3-hour idle clear | medium | | `github_app_not_installed` | a per-agent scoped-token mint was attempted while the GitHub App has no installation ID (`installation_id` 0): the App is not installed on the hive's org, or discovery never found it. An operator configuration condition β€” install the App or set `github.installation_id` β€” not a hive defect. Key is the agent name; reported on the first observation and cleared by the next successful mint. The runtime logs the matching WARN once per agent instead of every supervisor tick (#14186) | medium | | `forge_unreachable` | a per-agent scoped-token mint got no answer from the GitHub host (dial timeout, connection refused, context deadline β€” `isForgeTransportErr` in `crux/agent/manager_tokens.go`): egress from the pod to the forge is broken. Nothing about the App key or installation follows from it, so it is reported apart from `github_app_token_mint_failed` and clears that class if it was open. Key is the agent name; cleared by the next successful mint. Fix cluster egress, DNS or the proxy/`hostAliases` in front of the forge, not the App | medium | -| `ioscan_classifier_unavailable` | the ioscan model classifier failed closed (`ioscan classifier failed closed; text withheld`, `pkg/scheduler/ioscan_enforce.go`), withholding untrusted text, or a genuine backend error under `ioscan.classifier.fail_mode: open` passed text on unclassified (`ioscan classifier failed open; text passes on unclassified`). Under fail-open, a missing model endpoint instead logs `ioscan classifier not configured; semantic scan skipped (fail-open)`; semantic scanning is skipped and this operator configuration condition is not fleet-reported. A missing endpoint under fail-closed still withholds text and is reportable. The scheduler logs `ioscan fail-closed blocked kick` (`pkg/scheduler/templates.go`) or `ioscan fail-closed blocked held-PR coordination kick` (`pkg/scheduler/policy_overlays.go`) when it withholds a kick for the same reason, which maps to this class too. The WARN is already deduped per failure by `warnOnce` (once per process), so it is reported on the first observation (`MinCount` 1) with the normal idle clear; this is a real degradation, not noise. Log-fed | medium | +| `ioscan_classifier_unavailable` | the ioscan model classifier failed closed (`ioscan classifier failed closed; text withheld`, `pkg/scheduler/ioscan_enforce.go`), withholding untrusted text, or a genuine backend error under `ioscan.classifier.fail_mode: open` passed text on unclassified (`ioscan classifier failed open; text passes on unclassified`). The HTTP model endpoint retries one transient failure (429, 500/502/503/504, or a connection dropped before a reply; not a timeout) before either fail mode applies, so a single gateway blip is neither withheld nor passed on unclassified. Under fail-open, a missing model endpoint instead logs `ioscan classifier not configured; semantic scan skipped (fail-open)`; semantic scanning is skipped and this operator configuration condition is not fleet-reported. A missing endpoint under fail-closed still withholds text and is reportable. The scheduler logs `ioscan fail-closed blocked kick` (`pkg/scheduler/templates.go`) or `ioscan fail-closed blocked held-PR coordination kick` (`pkg/scheduler/policy_overlays.go`) when it withholds a kick for the same reason, which maps to this class too. The WARN is already deduped per failure by `warnOnce` (once per process), so it is reported on the first observation (`MinCount` 1) with the normal idle clear; this is a real degradation, not noise. Log-fed | medium | | `github_fetch_transient` | a non-critical GitHub fetch failed and the feature degrades gracefully (`channel commit date: fetch failed β€” the row will render without a timestamp`, `pkg/hub/channel_commit_date.go`) β€” typically a rate limit or transient API error, not a hive defect. Reported only when persistent: log-fed, same β‰₯5-in-10-min repeat threshold and idle clear as `agent_pane_timeout`. The sibling `SHA poll: commit message fetch non-200` has its own class, `sha_poll_commit_message_unavailable` | low | | `label_provisioning_failed` | provisioning the issue stage labels on a repo failed (`issue stage labels: provisioning failed`, `pkg/github/issue_stage_labels.go`) β€” typically the App lacking label permission on that repo or a transient API error; diagnostics carry the repo and scrubbed error. Log-fed: same repeat threshold and idle clear as `agent_pane_timeout` | low | | `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 | @@ -184,6 +184,10 @@ logger-bound attributes: typed errors wrapping `context.Canceled`, exact Warnings remain in local logs; deadlines and other errors remain reportable, including when another `error` attribute accompanies a cancellation. +When a client hand-over or shutdown cancels an in-flight contributor-lane +automerge pass, the pass stops without warning or stage reconciliation. Other +contributor sweep failures remain reportable. + The GitHub rate-limit exclusion also covers the local REST reserve guard: when optional issue activity or CI trail fetches are shed below the reserve, their warnings remain in local logs but do not create repeated-error reports. diff --git a/src/docs/github-write-surface.md b/src/docs/github-write-surface.md index 3534779501..ce5e75e59b 100644 --- a/src/docs/github-write-surface.md +++ b/src/docs/github-write-surface.md @@ -272,6 +272,21 @@ A refused request is handled like every other policy denial: Refusals are not counted as output activity. +## Reference repos + +`project.reference_repos` ([#18015](https://github.com/hivecommons/hive/issues/18015)) +names repos *outside* the project an agent may study. They are not part of +the write surface at all by default: the proxy strips the hive's App token +from every request to them and refuses every non-GET with a message naming +`reference_repos`. A per-entry `write: [issues, prs]` opt-in unlocks exactly +three direct writes on that repo β€” open an issue, open a PR / push a topic +branch, comment on the hive's own issue or PR β€” sent with a credential valid +for *that* repo (the App installed on the target org, or `write_token_file`). +Merge, default-branch push, labels, assignees, reviews and closing others' +items stay refused. The relays never act on a reference repo, so a lane under +`write_surface.enforce` cannot write to one. See the +[operator reference](operator-reference/README.md#configuration-blocks). + ## Enforcing the write surface `write_surface.enforce` lists the lanes whose **direct** GitHub writes from the diff --git a/src/docs/hub-deployment.md b/src/docs/hub-deployment.md index 5090ba7a1a..2b81eb7390 100644 --- a/src/docs/hub-deployment.md +++ b/src/docs/hub-deployment.md @@ -90,6 +90,10 @@ env: Without `HIVE_HUB_SECRET`, a protected hub returns `401 unauthorized`, the spoke logs `hub heartbeat rejected status=401`, and the hub shows the hive offline. This matches the gotcha in `manual-provisioning.md`. +### Reserved `byo-` hive IDs (credential fence) + +Hive IDs that start with `byo-` are reserved for hives that their owners run and register themselves ([#18060](https://github.com/hivecommons/hive/issues/18060)). A hub lists such a hive as directory metadata only, so its heartbeat response never carries hub-held credentials or management actions, whatever key the spoke authenticated with. The hub withholds these fields: the GitHub App config (including cluster, secondary and webhook relay keys), the pending gateway, the claimed project config, the cluster ID, the authorized-user list, visibility, upgrade and branch-switch instructions, the upgrade announcement and policy, spoke restarts, agent-restart resets, fleet-breaker instructions, and self-experiment flags. Display-only data such as admin banners still flows. When the fence withholds a field, the hub logs `heartbeat: credential fence withheld fields from self-service hive` with the field names, never the values. Do not give an operator-provisioned hive a `byo-` ID. + ## Hosted spoke provisioning model The hub's SaaS provisioner creates or records: diff --git a/src/docs/operator-reference/README.md b/src/docs/operator-reference/README.md index 165848d497..8024f6847f 100644 --- a/src/docs/operator-reference/README.md +++ b/src/docs/operator-reference/README.md @@ -279,6 +279,9 @@ Go callers keep reading `cfg.Agents`, `cfg.Governor.Gateways` and so on through | `project.repo_policies[].max_open_held_prs` | Unset (no cap) for every repo. | Per-repo cap on open hive PRs still waiting on a human ([#13645](https://github.com/hivecommons/hive/issues/13645)). `0` means the default of `12`; a positive number is the cap. At the cap `hive-open-pr` refuses new PRs in that repo and PR-opening agents are told "repo at capacity: review or close existing PRs first". Needs GitHub App auth: under a PAT the cap cannot be enforced and the governor warns once ([#13739](https://github.com/hivecommons/hive/issues/13739)). See [Open held PR cap](v6-controls.md#open-held-pr-cap-max_open_held_prs). | | `project.repo_policies[].sentinel_sensitive_paths` | Unset: inherits `sentinel.sensitive_paths`. | This repository's own Sentinel sensitive-path list. It replaces the hive-wide list for this repository's pull requests only, so it can be stricter or looser, and a later change to `sentinel.sensitive_paths` does not reach it. An explicit empty list (`[]`) matches nothing, as the hive-wide key does. A blank pattern is rejected when the config loads. Set it in hive.yaml, in Settings β†’ Security β†’ Suspicious Activity β†’ **Sensitive files per repository**, through `PUT /api/config/governor/security` (`sentinel.repoSensitivePaths`, owner only, audited), or with the admin MCP `sentinel.settings` `repo_sensitive_paths`; a blank or malformed glob is refused there, and clearing it returns the repository to the hive-wide list. Settings and the `sentinel.settings` preview name every repository with its own list. See [Suspicious-activity alerts](sweeps-and-operations.md#suspicious-activity-alerts-sentinel). | | `project.repo_policies[].workflow_fence_scope` | Unset: inherits `auto_merge.contributor_lane.workflow_fence_scope`. | This repository's own contributor-lane workflow fence scope, `all` or `human_merge_paths`. It can be stricter or looser than the hive-wide value, and a later change to the hive-wide value does not reach it. `human_merge_paths` still keeps the full fence when the repository has no `auto_merge.human_merge_paths` entry. Any other value is rejected when the config loads. Set it in hive.yaml, in Settings β†’ Features β†’ Auto Merge β†’ **Workflow fence scope per repository**, through `PUT /api/config/auto-merge` (`contributor_lane.workflow_fence_scope_repos`, owner only, audited), or with the admin MCP `auto_merge.update`; clearing it returns the repository to the hive-wide scope. `settings_read` section `auto_merge` returns the overrides as `contributor_lane.workflow_fence_scope_repos`. See [hive-merge.md](../hive-merge.md#contributor-lane-auto-merge). | +| `project.reference_repos` | Unset (none). | Repos **outside** the project the hive may *study* without managing them ([#18015](https://github.com/hivecommons/hive/issues/18015)) β€” the rare case of a hive whose deliverable is *about* another project, e.g. a CNCF TechDocs hive (authorized repo `cncf/techdocs`) assessing `hivecommons/docs`. Each entry is `owner/name` (bare names are rejected, as is any repo also in `project.repos`). Default tier is **read-only**: agents may `git clone`/fetch them unauthenticated over HTTPS and issue GitHub REST/GraphQL GETs; the proxy strips the hive's App token from every request to them and refuses every non-GET with a message naming `reference_repos`. The kick lists them under `πŸ“š REFERENCE REPOS` so an authorized-repo item whose deliverables name one is treated as in scope (not out-of-scope/critical), and agents are told findings go in the deliverable inside the authorized repo, never as issues or PRs on the reference repo. The dashboard shows each as a minimal card with a `reference Β· ` chip β€” no stats, no ACMM, no work items. Private reference repos are not readable (reads are unauthenticated). Example:
`project:`
` repos: [techdocs]`
` reference_repos:`
` - hivecommons/docs`
` - hivecommons/hivecommons.github.io`
` - {repo: hivecommons/hive, write: [issues, prs]}` | +| `project.reference_repos[].write` | Unset (read-only). | Per-entry write opt-in, any of `issues`, `prs`. The **only** writes it unlocks on that repo: open an issue; open a PR / push a topic branch; comment on an issue or PR the hive itself opened. Never: merge, push to the default branch, labels or assignees on others' items, reviews, closing others' issues β€” the proxy refuses those deterministically (`reference_repo` in the violations log). The write is sent with a credential GitHub honours for *that* repo, never the hive's own installation token: by default the same GitHub App must be **installed on the target org** (the hive discovers that installation and mints a token scoped to the one repo and the opted-in permissions); otherwise set `write_token_file`. Boot refuses (exit code 22) a write-mode entry with neither. Direct POSTs are used here, not the `hive-open-pr`/`hive-open-issue` relays, which only act on `project.repos`; a lane under `write_surface.enforce` therefore cannot write to a reference repo. | +| `project.reference_repos[].write_token_file` | Unset. | Path to a file holding a token (a maintainer PAT or fine-grained token) authorized for that repo; read on every use so a rotation needs no restart. Only meaningful with `write`. Its `GET /user` login is what the proxy treats as "own item" for comments. | | `project.label_driven_approval_labels` | `["triage/accepted"]` | Labels whose trusted addition approves an issue on every label-driven repo without its own list. Ignored by repos without `label_driven: true`. Dashboard: Settings β†’ Repos β†’ "Default approval labels". | | `project.label_driven_trust_org_members` | `true` | Whether a public member of the repo's owning org who applies an approval label is trusted (Prow's own rule). Dashboard: Settings β†’ Repos β†’ "trust public org members". | | `project.label_driven_trusted_bots` | `["kubestellar-prow[bot]"]` | Bot logins whose label additions count as a trusted triager (Prow command bots relaying `/triage accepted`). `github-actions[bot]` is not in the default because it is the shared identity of every workflow in the repo, including ones triggered by untrusted events; operators whose Prow emulation runs as `github-actions[bot]` must add it explicitly per deployment. Dashboard: Settings β†’ Repos β†’ "Trusted bots". | diff --git a/src/docs/operator-reference/runtime-and-quota.md b/src/docs/operator-reference/runtime-and-quota.md index 896126e5c6..30aefe0fa9 100644 --- a/src/docs/operator-reference/runtime-and-quota.md +++ b/src/docs/operator-reference/runtime-and-quota.md @@ -498,7 +498,11 @@ and each agent's effective cap under `agent_github_api_hourly_caps`. See [GitHub webhooks: cadence, patch and reconcile](../webhooks.md) for the cadence keys, the fallback to polling when deliveries degrade, and the -accuracy guarantees of the ForgeState contract. +accuracy guarantees of the ForgeState contract. Hive owners (not cluster +operators) should start at the +[owner guide](../webhooks.md#setting-up-webhooks-owner-guide): it covers the +βš™ Settings β†’ **Forge App** β†’ **GitHub webhooks** section, which shows the +payload URL, generates a per-hive secret and can create the org/repo hook. A spoke accepts GitHub App webhooks on the public `POST /api/webhook/github`, either delivered directly by GitHub or relayed by the hub (the hub forwards the @@ -511,10 +515,13 @@ Deliveries for repositories the hive does not manage are ignored. #### What the operator configures -1. **Secret (self-hosted only).** Set `GITHUB_WEBHOOK_SECRET` in the spoke's - environment (a Secret mount on Kubernetes; never in `hive.yaml`) to the - App's webhook secret. A hosted spoke needs nothing here: it never holds the - App secret and verifies relays with its hub relay key instead (item 3). +1. **Secret (self-hosted only).** Either press **Generate secret** in + βš™ Settings β†’ Forge App β†’ GitHub webhooks (the hive writes + `/data/github-webhook-secret.key`, mode 0600, and shows the value once), or + set `GITHUB_WEBHOOK_SECRET` in the spoke's environment (a Secret mount on + Kubernetes; never in `hive.yaml`). The environment variable wins when both + exist. A hosted spoke needs nothing here: it never holds a GitHub secret and + verifies relays with its hub relay key instead (item 3). 2. **GitHub App webhook.** Point the App's webhook URL at the hub (`POST /api/github/webhook`) for hosted spokes, or at the spoke's own `/api/webhook/github` for a stand-alone hive (a repository or organization diff --git a/src/docs/operator-reference/sweeps-and-operations.md b/src/docs/operator-reference/sweeps-and-operations.md index b5ad8db109..0e76b41366 100644 --- a/src/docs/operator-reference/sweeps-and-operations.md +++ b/src/docs/operator-reference/sweeps-and-operations.md @@ -1073,6 +1073,7 @@ governor: ``` Lower it to resume sooner after a reset at the cost of a rebuffed run per probe; raise it to waste less while noticing later. +- An agent whose **own** provider refuses it for quota β€” for example GitHub Copilot's "You have exceeded your monthly quota", which never passes through the hive's inference gateway β€” is held per agent ([#18112](https://github.com/hivecommons/hive/issues/18112)). Every backend reports the pane's quota message as the agent's `QuotaExhausted` flag; while it is set, the governor withholds that agent's kicks, including review-swarm review and fix kicks, so a held reviewer is not sent review work that would only expire. Once per `governor.provider_budget.probe_interval_s` (the same setting as above) one kick is let through as a probe, and the interval restarts only when that probe is actually delivered; a failed or deferred probe is retried on the next cycle. When the pane stops showing the quota message the flag clears and the agent's kicks flow normally from the next cycle. One agent out of quota never holds another, and manual kicks from the dashboard are not held. The dashboard alert reads `provider quota exhausted β€” N agent(s) out of provider quota; their kicks are held and retried every 30m`, and the log shows `provider quota exhausted: withholding agent kicks` with the withheld and probed agents. ## Merge-hotspot advisor (`governor.hotspot_*`) diff --git a/src/docs/scale-envelope.md b/src/docs/scale-envelope.md index 3b32dda14e..31067bb82c 100644 --- a/src/docs/scale-envelope.md +++ b/src/docs/scale-envelope.md @@ -92,7 +92,7 @@ in `src/pkg/scheduler/kick_layout.go` and asserted by | # | Block | Varies | |---|---|---| -| 1 | `SECURITY CANARY` preamble | every kick (random, opt-in `ioscan.canaries`; always first β€” [ADR-0008](adr/0008-ioscan-untrusted-input.md)) | +| 1 | `=== HIVE HARNESS HEADER` canary preamble | every kick (random, opt-in `ioscan.canaries`; always first β€” [ADR-0008](adr/0008-ioscan-untrusted-input.md)) | | 2 | `[agent:]` header | never | | 3 | governor work-source rule | never | | 4 | independent review section | per agent config | diff --git a/src/docs/webhooks.md b/src/docs/webhooks.md index 315ac161d5..7c75be8b14 100644 --- a/src/docs/webhooks.md +++ b/src/docs/webhooks.md @@ -13,6 +13,85 @@ The priority order throughout is **accuracy β†’ latency β†’ API thrift**. A delivery is a free hint; every outbound GitHub call is budgeted. The rules below exist so that the hive never trades the first for the third. +## Setting up webhooks (owner guide) + +Webhooks are optional. Without them a hive polls GitHub every +`governor.eval_interval_s` (default 300 s) and finds out about a review, a red +check or a new comment at the next tick. With them the hive is told at once: +the governor wakes early, CI-failure and review kicks land within seconds +instead of minutes, the base tick stretches to `governor.eval_interval_webhook_s` +(default 900 s), and the outbound API budget drops because each delivery costs +one confirming `GET` instead of a full re-list. Open βš™ Settings β†’ **Forge App** +β†’ **GitHub webhooks** on your hive, or click the **webhooks** tile on the +Governor card, to see which of the two cases below applies to you. + +Hive never shares its own webhook secret. Every hive verifies deliveries with +a key that belongs to it alone. + +### Hosted hive (hub-managed) + +Nothing to do. GitHub sends App deliveries to the hub, the hub verifies them +and relays each one to every hive that manages that repository, signed with +the relay key it minted for your hive when it was provisioned. The Settings +section reports **Receiver: hub relay** and the events/hour count; the +Governor tile reads `N/1h`. If it stays at **no events yet** for an hour of +activity, confirm the hivecommons GitHub App is installed on the organization +or repositories your hive tracks (Settings β†’ Forge App β†’ Installation) β€” the +hub can only relay what GitHub sends it. + +### Self-hosted hive + +Your hive needs an org- or repo-level webhook in GitHub that points at it and +uses a secret only you hold. + +1. **Generate the secret.** Settings β†’ Forge App β†’ GitHub webhooks β†’ + **Generate secret**. The hive writes it to `/data/github-webhook-secret.key` + (0600) and shows it once with a copy button; it is never shown again + (regenerate to rotate). If you prefer to manage it yourself, set + `GITHUB_WEBHOOK_SECRET` in the hive's environment instead β€” the button then + reports that the environment variable is in charge. +2. **Create the webhook.** If the section offers **Create webhook for me**, + click it: the hive's GitHub App installation holds `organization_hooks` or + `repository_hooks` write permission and the hive creates the hook with the + right URL, secret and events. Clicking it again is safe: when a hook for + this hive's payload URL already exists it is reported, not duplicated. + Otherwise do it by hand in GitHub: + organization (or repository) β†’ Settings β†’ Webhooks β†’ **Add webhook**. + - Payload URL: the value shown in the section, i.e. + `https:///api/webhook/github` + - Content type: `application/json` + - Secret: the value from step 1 + - SSL verification: enabled + - Events: *Let me select individual events* β†’ `check_run`, `check_suite`, + `issue_comment`, `issues`, `pull_request`, `pull_request_review`, + `pull_request_review_comment`, `push`, `status`, `workflow_run` + (optionally `workflow_job`, and `projects_v2_item` if your work source is + GitHub Projects). +3. **Verify.** GitHub sends a `ping` immediately; the section's **Receiver** + card should read **github-signed** with a fresh *last event*, and the + Governor tile moves from **set up β†’** to `N/1h` after the first real event. + +The payload URL comes from `dashboard.public_url`; if the section shows no URL +or the wrong one, set that key (or the hub-reported dashboard URL) to the +address GitHub can reach. + +### Troubleshooting + +| Symptom (GitHub β†’ Settings β†’ Webhooks β†’ Recent deliveries) | Cause | Fix | +| --- | --- | --- | +| `401 invalid signature` | Secret in GitHub differs from the hive's | Paste the current secret into the GitHub hook (Edit β†’ Secret), or regenerate in Settings and update GitHub. `GITHUB_WEBHOOK_SECRET` overrides the generated file β€” check which one the section says is active. | +| `503 webhooks disabled: no secret configured` | Hive has neither a generated/env secret nor a hub relay key | Step 1 above. | +| `404` | Wrong path | The path is `/api/webhook/github` on the spoke (the hub's App endpoint is `/api/github/webhook` β€” do not point an org hook there). | +| `200` but **no events yet** | Deliveries are for repos the hive does not manage | Check `project.repos`; deliveries for other repos are acknowledged and dropped. | +| Hosted hive, no events | App not installed on the repo / relay rejected | Install the App on the org or repo; the hub relays to the dashboard URL your hive reports, which must be reachable from the hub. | +| Deliveries time out | Hive not reachable from GitHub (private network, redirects) | GitHub (and the hub relay) follow no redirects and need a public HTTPS endpoint. | + +Hub relay vs direct: a hive registered with the hub whose repos carry the +hivecommons App receives relays as well as any direct hook you add; both are +accepted, duplicates are dropped by delivery id. Only one is needed. A +self-hosted hive with a private dashboard URL cannot receive relays and must +use a direct hook. + ## Polling cadence vs webhook patch vs reconcile Three writers feed one snapshot per repository. A fourth path writes PR diff --git a/src/hive.yaml.example b/src/hive.yaml.example index 435bda6e7c..e15296f417 100644 --- a/src/hive.yaml.example +++ b/src/hive.yaml.example @@ -39,6 +39,22 @@ project: # - repo: repo-two # by: your-github-login # reason: release freeze until Friday + # Optional: repos OUTSIDE the project that agents may study but not work + # (src/docs/agent-configuration.md#reference-repos-what-agents-may-study-projectreference_repos). + # Always owner/name; never also in `repos`. No queue, ACMM or work items. + # String form = read-only: clone/fetch and API GETs, sent unauthenticated + # (the hive token is stripped, so private repos are unreadable); every + # non-GET is refused with a 403 naming reference_repos. `write` opts in to + # ONLY: open an issue, open a PR / push a topic branch, comment on the hive's + # own issue/PR β€” never merge, default-branch push, labels, assignees, reviews + # or closing others' items. Writes use the App installed on that org (minted + # for the one repo) or write_token_file; boot exits 22 with neither. Kicks + # list them as πŸ“š REFERENCE REPOS; the dashboard shows a `reference Β· ` chip. + # reference_repos: + # - hivecommons/docs # read-only + # - repo: hivecommons/hive + # write: [issues, prs] # issues, prs, or both + # write_token_file: /data/secrets/ref-pat # optional; only with write # Optional opt-in "clanker-requested" policy, under project.issue_filter.reporter_trust. # Off by default. While on, a PR carrying the label below is treated as held. diff --git a/src/pkg/config/project_config.go b/src/pkg/config/project_config.go index c25aed0f66..1453cebaea 100644 --- a/src/pkg/config/project_config.go +++ b/src/pkg/config/project_config.go @@ -75,6 +75,13 @@ type ProjectConfig struct { // Optional and additive. Absent β€” the state of every existing config β€” // means nothing is paused and the hive behaves exactly as before. PausedRepos []RepoPause `yaml:"paused_repos,omitempty"` + // ReferenceRepos are repositories agents may STUDY but do not manage + // (hivecommons/hive#18015): explicit owner/name entries, read-only by + // default, with a rare per-entry opt-in for opening issues/PRs. They are + // NOT in Repos: no work is enumerated from them, no ACMM eval runs on + // them, and writes to them are refused unless opted in. See + // reference_repos.go. + ReferenceRepos []ReferenceRepo `yaml:"reference_repos,omitempty" json:"reference_repos,omitempty"` // RepoPolicies stores optional per-repository policy overrides keyed by // repo name. project.repos remains the watched-repo identity list; this // sidecar list lets existing string-list configs keep round-tripping. diff --git a/src/pkg/config/reference_repos.go b/src/pkg/config/reference_repos.go new file mode 100644 index 0000000000..f67a976c34 --- /dev/null +++ b/src/pkg/config/reference_repos.go @@ -0,0 +1,231 @@ +package config + +import ( + "fmt" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// ReferenceRepo is one entry of project.reference_repos (hivecommons/hive#18015): +// a repository the hive's agents may STUDY β€” clone, fetch, read through the +// GitHub API β€” without it being an authorized repo. The motivating case is a +// documentation-assessment hive (CNCF TechDocs) whose deliverable lives in its +// own authorized repo but describes another project's repos: the agent has to +// read those repos, and must not be told they are out of scope, yet nothing +// about the assessment should ever land on them. +// +// The default tier is READ-ONLY: public clones and GETs, never carrying the +// hive's App token (which is not valid for a foreign installation anyway). +// Every write is refused by the proxy with a 403 that names reference_repos. +// +// Write is the rare opt-in for an assessor who also files findings upstream. +// It is deliberately narrow β€” open an issue, open a PR on a topic branch, +// comment on the hive's own items β€” and the credential behind it has to be one +// GitHub actually honours for that repo: the App's installation on the TARGET +// org (minted on demand, scoped to this one repo) or an operator-supplied +// write_token_file. Boot refuses a write-mode entry that has neither. +type ReferenceRepo struct { + // Repo is the explicit "owner/name" reference. Bare names are rejected: + // a reference repo is by definition outside the project's own list, so + // there is no org to qualify it against without guessing. + Repo string `yaml:"repo" json:"repo"` + // Write lists the opt-in write surfaces: "issues" and/or "prs". Empty is + // the read-only default. + Write []string `yaml:"write,omitempty" json:"write,omitempty"` + // WriteTokenFile is a path on the hive host holding a token that is + // authorized for Repo, used instead of minting from the App installation + // on the target org. Only meaningful with Write. + WriteTokenFile string `yaml:"write_token_file,omitempty" json:"write_token_file,omitempty"` +} + +const ( + ReferenceWriteIssues = "issues" + ReferenceWritePRs = "prs" +) + +// UnmarshalYAML accepts both the scalar form ("owner/name", read-only) and the +// mapping form ({repo: owner/name, write: [...]}). +func (r *ReferenceRepo) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.ScalarNode { + r.Repo = strings.TrimSpace(value.Value) + r.Write = nil + r.WriteTokenFile = "" + return nil + } + type plain ReferenceRepo + var p plain + if err := value.Decode(&p); err != nil { + return err + } + *r = ReferenceRepo(p) + r.Repo = strings.TrimSpace(r.Repo) + return nil +} + +// MarshalYAML writes the scalar form back for a read-only entry so a config +// that was written as a plain list round-trips unchanged. +func (r ReferenceRepo) MarshalYAML() (any, error) { + if len(r.Write) == 0 && r.WriteTokenFile == "" { + return r.Repo, nil + } + type plain ReferenceRepo + return plain(r), nil +} + +// WritesIssues reports whether the entry opted in to opening issues (and +// commenting on its own). +func (r ReferenceRepo) WritesIssues() bool { return r.hasWrite(ReferenceWriteIssues) } + +// WritesPRs reports whether the entry opted in to pushing topic branches and +// opening PRs. +func (r ReferenceRepo) WritesPRs() bool { return r.hasWrite(ReferenceWritePRs) } + +// ReadOnly reports whether the entry has no write opt-in at all. +func (r ReferenceRepo) ReadOnly() bool { return !r.WritesIssues() && !r.WritesPRs() } + +func (r ReferenceRepo) hasWrite(kind string) bool { + for _, w := range r.Write { + if strings.EqualFold(strings.TrimSpace(w), kind) { + return true + } + } + return false +} + +// WriteLabel is the human-facing tier: "read-only", "issues", "prs" or +// "issues+prs". The dashboard chip and the kick both print it. +func (r ReferenceRepo) WriteLabel() string { + switch { + case r.WritesIssues() && r.WritesPRs(): + return "issues+prs" + case r.WritesIssues(): + return "issues" + case r.WritesPRs(): + return "prs" + } + return "read-only" +} + +// Owner returns the owner half of Repo ("" when malformed). +func (r ReferenceRepo) Owner() string { + owner, _, _ := strings.Cut(r.Repo, "/") + return owner +} + +// Name returns the repository half of Repo ("" when malformed). +func (r ReferenceRepo) Name() string { + _, name, _ := strings.Cut(r.Repo, "/") + return name +} + +// referenceRepoKey is the case-folded comparison key for a reference entry. +func referenceRepoKey(repo string) string { + return strings.ToLower(strings.TrimSpace(repo)) +} + +// ReferenceRepoFor returns the reference entry covering repo ("owner/name", +// case-insensitive), and whether one exists. +func (c *Config) ReferenceRepoFor(repo string) (ReferenceRepo, bool) { + if c == nil { + return ReferenceRepo{}, false + } + key := referenceRepoKey(repo) + if key == "" { + return ReferenceRepo{}, false + } + for _, r := range c.Project.ReferenceRepos { + if referenceRepoKey(r.Repo) == key { + return r, true + } + } + return ReferenceRepo{}, false +} + +// IsReferenceRepo reports whether repo ("owner/name") is a reference repo. +func (c *Config) IsReferenceRepo(repo string) bool { + _, ok := c.ReferenceRepoFor(repo) + return ok +} + +// ReferenceReposWithWrite returns the entries that opted in to any write. +func (c *Config) ReferenceReposWithWrite() []ReferenceRepo { + if c == nil { + return nil + } + var out []ReferenceRepo + for _, r := range c.Project.ReferenceRepos { + if !r.ReadOnly() { + out = append(out, r) + } + } + return out +} + +// validReferenceRepoSlug accepts exactly "owner/name" with no whitespace, no +// scheme and no extra path. +func validReferenceRepoSlug(repo string) bool { + owner, name, ok := strings.Cut(repo, "/") + if !ok || owner == "" || name == "" { + return false + } + if strings.ContainsAny(repo, " \t\n:@") || strings.Contains(name, "/") { + return false + } + return true +} + +// validateReferenceRepos enforces the shape of project.reference_repos: every +// entry is an explicit owner/name, none duplicates another, none is also an +// authorized repo (a repo is either managed or reference, never both β€” the +// two tiers would otherwise contradict each other on every write), write +// kinds are known, and write_token_file is only set alongside write. +func (c *Config) validateReferenceRepos() error { + if len(c.Project.ReferenceRepos) == 0 { + return nil + } + authorized := make(map[string]bool, len(c.Project.Repos)) + for _, repo := range c.Project.Repos { + authorized[referenceRepoKey(QualifyRepo(c.Project.Org, repo))] = true + } + seen := make(map[string]bool, len(c.Project.ReferenceRepos)) + for i, r := range c.Project.ReferenceRepos { + if !validReferenceRepoSlug(r.Repo) { + return fmt.Errorf("project.reference_repos[%d]: %q must be an explicit owner/name (a reference repo lives outside this project, so there is no org to qualify a bare name against)", i, r.Repo) + } + key := referenceRepoKey(r.Repo) + if authorized[key] { + return fmt.Errorf("project.reference_repos[%d]: %q is already in project.repos β€” a repository is either authorized (agents work it) or reference (agents only study it), not both", i, r.Repo) + } + if seen[key] { + return fmt.Errorf("project.reference_repos[%d]: %q is listed twice", i, r.Repo) + } + seen[key] = true + for _, w := range r.Write { + switch strings.ToLower(strings.TrimSpace(w)) { + case ReferenceWriteIssues, ReferenceWritePRs: + default: + return fmt.Errorf("project.reference_repos[%d]: write %q is not one of %q, %q", i, w, ReferenceWriteIssues, ReferenceWritePRs) + } + } + if r.WriteTokenFile != "" && r.ReadOnly() { + return fmt.Errorf("project.reference_repos[%d]: write_token_file is set but write is empty β€” a read-only reference repo never carries a credential", i) + } + } + return nil +} + +// ReferenceRepoSlugs returns the reference repos as sorted "owner/name" +// strings, for kicks and environment. +func (c *Config) ReferenceRepoSlugs() []string { + if c == nil { + return nil + } + out := make([]string, 0, len(c.Project.ReferenceRepos)) + for _, r := range c.Project.ReferenceRepos { + out = append(out, r.Repo) + } + sort.Strings(out) + return out +} diff --git a/src/pkg/config/reference_repos_test.go b/src/pkg/config/reference_repos_test.go new file mode 100644 index 0000000000..f0973865ec --- /dev/null +++ b/src/pkg/config/reference_repos_test.go @@ -0,0 +1,101 @@ +package config + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func referenceRepoTestConfig(t *testing.T, refs string) *Config { + t.Helper() + src := ` +project: + org: cncf + repos: [techdocs] + reference_repos: +` + refs + ` +agents: + scanner: + backend: claude +` + var c Config + if err := yaml.Unmarshal([]byte(src), &c); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return &c +} + +func TestReferenceReposParseScalarAndMapping(t *testing.T) { + c := referenceRepoTestConfig(t, ` + - hivecommons/docs + - repo: hivecommons/hivecommons.github.io + write: [issues, prs] + - repo: hivecommons/hive + write: [issues] + write_token_file: /data/ref-token +`) + if err := c.validateReferenceRepos(); err != nil { + t.Fatalf("validate: %v", err) + } + if got := len(c.Project.ReferenceRepos); got != 3 { + t.Fatalf("want 3 entries, got %d", got) + } + ro, ok := c.ReferenceRepoFor("HiveCommons/Docs") + if !ok || !ro.ReadOnly() || ro.WriteLabel() != "read-only" { + t.Fatalf("docs: ok=%v entry=%+v", ok, ro) + } + rw, ok := c.ReferenceRepoFor("hivecommons/hivecommons.github.io") + if !ok || !rw.WritesIssues() || !rw.WritesPRs() || rw.WriteLabel() != "issues+prs" { + t.Fatalf("site: ok=%v entry=%+v", ok, rw) + } + tok, _ := c.ReferenceRepoFor("hivecommons/hive") + if tok.WriteTokenFile != "/data/ref-token" || tok.WriteLabel() != "issues" || tok.Owner() != "hivecommons" || tok.Name() != "hive" { + t.Fatalf("hive: %+v", tok) + } + if c.IsReferenceRepo("cncf/techdocs") { + t.Fatal("authorized repo must not read as reference") + } + if got := len(c.ReferenceReposWithWrite()); got != 2 { + t.Fatalf("want 2 write entries, got %d", got) + } + // Round-trip: the scalar entry stays a scalar. + out, err := yaml.Marshal(c.Project.ReferenceRepos) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), "- hivecommons/docs\n") { + t.Fatalf("read-only entry should marshal as a scalar:\n%s", out) + } +} + +func TestReferenceReposValidationRejects(t *testing.T) { + cases := []struct { + name, refs, want string + }{ + {"bare name", " - docs\n", "explicit owner/name"}, + {"url", " - https://github.com/hivecommons/docs\n", "explicit owner/name"}, + {"overlap with project.repos", " - cncf/techdocs\n", "already in project.repos"}, + {"overlap case-insensitive", " - CNCF/TechDocs\n", "already in project.repos"}, + {"duplicate", " - hivecommons/docs\n - HiveCommons/docs\n", "listed twice"}, + {"unknown write", " - repo: hivecommons/docs\n write: [merge]\n", "not one of"}, + {"token without write", " - repo: hivecommons/docs\n write_token_file: /x\n", "write is empty"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := referenceRepoTestConfig(t, tc.refs) + err := c.validateReferenceRepos() + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("want error containing %q, got %v", tc.want, err) + } + }) + } +} + +func TestReferenceReposValidationRunsFromValidate(t *testing.T) { + c := referenceRepoTestConfig(t, " - docs\n") + err := c.Validate() + if err == nil || !strings.Contains(err.Error(), "reference_repos") { + t.Fatalf("Validate should surface the reference_repos error, got %v", err) + } +} diff --git a/src/pkg/config/validate.go b/src/pkg/config/validate.go index d09e825597..b79f52723b 100644 --- a/src/pkg/config/validate.go +++ b/src/pkg/config/validate.go @@ -126,6 +126,9 @@ func (c *Config) ValidateWithOptions(opts ValidateOptions) error { if err := ValidateRedMainThreshold("project", c.Project.RedMainThreshold); err != nil { return err } + if err := c.validateReferenceRepos(); err != nil { + return err + } if _, err := c.Ioscan.Policy(); err != nil { return err } diff --git a/src/pkg/dashboard/admin_mcp_settings_test.go b/src/pkg/dashboard/admin_mcp_settings_test.go index 9b56c4093f..02fc9387a7 100644 --- a/src/pkg/dashboard/admin_mcp_settings_test.go +++ b/src/pkg/dashboard/admin_mcp_settings_test.go @@ -26,6 +26,7 @@ var settingsReadExcludedRoutes = map[string]string{ "/api/config/agent/{name}/export": "a download bundle of the agent and agent_prompt sections", "/api/config/dashboard/theme": "purely presentational (the exclusion of presentational endpoints)", "/api/config/sidebar": "purely presentational (the exclusion of presentational endpoints)", + "/api/config/github/webhooks": "an owner setup card that probes the GitHub App's hook permissions live on every read; webhook verification and health are in /api/status.webhooks", } // settingsPageOtherRoutes are the GET routes outside /api/config that Settings diff --git a/src/pkg/dashboard/agent_collapsed_tiles_test.go b/src/pkg/dashboard/agent_collapsed_tiles_test.go index 1895afb12a..c4acfd6db0 100644 --- a/src/pkg/dashboard/agent_collapsed_tiles_test.go +++ b/src/pkg/dashboard/agent_collapsed_tiles_test.go @@ -46,7 +46,8 @@ func TestAgentsSidebarLinkAndNavbarUpNextContracts(t *testing.T) { `id="agent-navbar-upnext"`, `class="agent-navbar-upnext"`, `function renderAgentNavbarUpNext(agents, nowMs)`, - `agentTileStates(agents, now, window._lastStatus && window._lastStatus.governor).filter(item => agentNavbarHasSchedule(item.agent, now)).slice(0, 3)`, + `const scheduled = agentTileStates(agents, now, window._lastStatus && window._lastStatus.governor).filter(item => agentNavbarHasSchedule(item.agent, now));`, + `const list = scheduled.slice(0, 3);`, `function renderAgentNavbarTileDiff(wrap, list, now)`, `data-agent-key`, `function openAgentsUpNextPanel()`, @@ -54,8 +55,8 @@ func TestAgentsSidebarLinkAndNavbarUpNextContracts(t *testing.T) { `.agent-navbar-tile { flex: 0 0 auto; min-width: 136px;`, `.agent-navbar-tile .agent-tile-name { grid-column: 1; grid-row: 1; min-width: max-content; overflow: visible; text-overflow: clip; }`, `@container navbar-center (max-width: 560px)`, - `.agent-navbar-upnext::after { content: attr(data-agent-summary);`, - `wrap.dataset.agentSummary = list.length ? String(list.length) + ' agents β–Ύ' : '';`, + `.agent-navbar-summary { flex: 0 0 auto; display: none;`, + `const summary = renderAgentNavbarSummary(wrap, scheduled.length);`, `renderAgentNavbarUpNext(agents, Date.now())`, `refreshAgentNavbarUpNextCountdowns(now);`, `@media (max-width: 900px) { .agent-navbar-upnext { display: none !important; } }`, diff --git a/src/pkg/dashboard/agent_navbar_summary_ui_test.go b/src/pkg/dashboard/agent_navbar_summary_ui_test.go new file mode 100644 index 0000000000..2de20abeaf --- /dev/null +++ b/src/pkg/dashboard/agent_navbar_summary_ui_test.go @@ -0,0 +1,143 @@ +package dashboard + +import ( + "os/exec" + "strings" + "testing" +) + +// When the navbar centre column is narrow the up-next strip collapses to a +// single summary control. It used to be a CSS ::after pseudo-element whose +// text was capped at 3 (the tile slice) and which could not receive clicks +// (#18110). These tests execute the real render functions against a tiny fake +// DOM so the count, the action wiring and the "no button when empty" rule are +// exercised, not just pattern-matched. +func TestAgentNavbarSummaryButtonCountAndAction(t *testing.T) { + node, err := exec.LookPath("node") + if err != nil { + t.Skip("node unavailable: agent navbar summary behavior was not executed") + } + html := indexHTML(t) + block := jsFunc(t, html, "agentNavbarSummaryLabel") + "\n" + + jsFunc(t, html, "renderAgentNavbarSummary") + "\n" + + jsFunc(t, html, "renderAgentNavbarUpNext") + + script := ` +class El { + constructor(tag) { this.tagName = tag.toUpperCase(); this.children = []; this.attrs = {}; this.className = ''; this._text = ''; this.parentNode = null; this.hidden = false; this.dataset = {}; } + setAttribute(k, v) { if (k === 'class') this.className = String(v); else this.attrs[k] = String(v); } + getAttribute(k) { if (k === 'class') return this.className; return Object.prototype.hasOwnProperty.call(this.attrs, k) ? this.attrs[k] : null; } + appendChild(c) { if (c.parentNode) c.parentNode.removeChild(c); c.parentNode = this; this.children.push(c); return c; } + insertBefore(c, ref) { if (!ref) return this.appendChild(c); if (c.parentNode) c.parentNode.removeChild(c); const i = this.children.indexOf(ref); c.parentNode = this; this.children.splice(i, 0, c); return c; } + removeChild(c) { this.children = this.children.filter(x => x !== c); c.parentNode = null; return c; } + remove() { if (this.parentNode) this.parentNode.removeChild(this); } + get lastElementChild() { return this.children[this.children.length - 1] || null; } + get textContent() { return this.children.length ? this.children.map(c => c.textContent).join('') : this._text; } + set textContent(v) { this.children = []; this._text = String(v); } + querySelector(sel) { const cls = sel.replace(/^\./, ''); for (const c of this.children) { if (c.className.split(/\s+/).includes(cls)) return c; const d = c.querySelector(sel); if (d) return d; } return null; } + querySelectorAll(sel) { const cls = sel.replace(/^\./, ''); const out = []; const walk = el => el.children.forEach(c => { if (c.className.split(/\s+/).includes(cls)) out.push(c); walk(c); }); walk(this); return out; } +} +const wrap = new El('span'); +wrap.setAttribute('id', 'agent-navbar-upnext'); +const document = { getElementById: id => id === 'agent-navbar-upnext' ? wrap : null, createElement: tag => new El(tag) }; +const window = { _lastStatus: { governor: {} } }; +function agentTileStates(agents) { return agents.map(a => ({ id: a.name, agent: a })); } +function agentNavbarHasSchedule(a) { return a.scheduled !== false; } +// Mirror the real diff's index-based insertBefore so tile placement relative +// to the summary button is exercised. +function renderAgentNavbarTileDiff(w, list) { + w.querySelectorAll('.agent-navbar-tile').forEach(t => t.remove()); + list.forEach((item, i) => { const tile = new El('button'); tile.className = 'agent-navbar-tile'; tile.setAttribute('data-agent-key', item.id); w.insertBefore(tile, w.children[i] || null); }); +} +function ensureAgentsCollapsedCountdownTicker() {} +// v6 measures the strip after every render; layout is out of scope here. +function fitAgentNavbarTiles() { return 0; } +function ensureAgentNavbarFitObserver() {} +` + block + ` +function agents(n, extraUnscheduled) { + const out = []; + for (let i = 0; i < n; i++) out.push({ name: 'a' + i }); + for (let i = 0; i < (extraUnscheduled || 0); i++) out.push({ name: 'off' + i, scheduled: false }); + return out; +} +function fail(msg) { throw new Error(msg); } +function summary() { return wrap.querySelector('.agent-navbar-summary'); } +function check(name, n, extra, wantText) { + renderAgentNavbarUpNext(agents(n, extra), 0); + const btn = summary(); + if (!btn) fail(name + ': summary button missing'); + if (btn.tagName !== 'BUTTON' || btn.getAttribute('type') !== 'button') fail(name + ': summary is not a type=button ` : ''} + ${isCustom && !rvLinked ? `` : ''} - - ${defaultFocus ? `

Built-in: ${esc(defaultFocus)}${custom ? ' β€” use built-in' : ''}

` : ''} + + ${defaultFocus ? `

Built-in: ${esc(defaultFocus)}${custom && !rvLinked ? ' β€” use built-in' : ''}

` : ''} `; }; const rvRec = rv.recommendations || {}; @@ -805,13 +819,14 @@

Which questions the reviewer asks of every PR, and what each one looks for. Edit the text to tell this hive's reviewer what matters in your repos — e.g. name the conventions doc for style. Blank means the built-in. Unchecking a perspective stops it running and stops its verdicts being accepted.

+ ${rvLinkedBanner} ${rvBuiltin.map(b => rvPerspectiveRow(b.name, b.focus)).join('')} ${rvCustomNames.map(n => rvPerspectiveRow(n, '')).join('')} -
+ ${rvLinked ? '' : `
-

A new perspective needs focus text before it can be saved. Names: lowercase letters, digits, single dashes.

+

A new perspective needs focus text before it can be saved. Names: lowercase letters, digits, single dashes.

`}
Reviewers
diff --git a/src/pkg/dashboard/static-src/js/config-tabs.js b/src/pkg/dashboard/static-src/js/config-tabs.js index df9dcef5ff..fbfcb89a7b 100644 --- a/src/pkg/dashboard/static-src/js/config-tabs.js +++ b/src/pkg/dashboard/static-src/js/config-tabs.js @@ -50,7 +50,7 @@ } // The Forge App tab renders a shell synchronously, then loads the // credential inventory (fingerprints only) from the spoke. - if (tabId === 'Forge App') loadForgeApps(); + if (tabId === 'Forge App') { loadForgeApps(); loadWebhookSetup(); } // The Features tab's Linear Agent panel loads install/session state // from its own endpoint (owner-only; the render is a static shell). if (tabId === 'Features') loadLinearAgentStatus(); @@ -1798,7 +1798,8 @@ when its forge matches the forge this hive's repos live on; everything else is read-only. Private keys appear as fingerprints β€” key material never leaves the hive.
-
Loading forge Apps…
`; +
Loading forge Apps…
+ ${renderGovWebhooksSection()}`; } async function loadForgeApps() { diff --git a/src/pkg/dashboard/static-src/js/config-webhooks.js b/src/pkg/dashboard/static-src/js/config-webhooks.js new file mode 100644 index 0000000000..18cc6682ec --- /dev/null +++ b/src/pkg/dashboard/static-src/js/config-webhooks.js @@ -0,0 +1,220 @@ + /* Settings β†’ Forge App β†’ GitHub webhooks (hivecommons/hive#18017). + Owner-facing setup for /api/webhook/github: the exact payload URL, + the events to tick, a per-hive secret the owner generates here and + sees once, optional App-side hook creation, and the live receiver + status. Hosted spokes get deliveries through the hub relay with no + owner action, so for them the section is a status readout. */ + + let _webhookSetup = null; + let _webhookSecretOnce = ''; + let _webhookConfirmRegenerate = false; + + function renderGovWebhooksSection() { + return ` +
GitHub webhooks
+
+ Webhooks let GitHub tell this hive about pushes, PRs, reviews and checks as they + happen: faster kicks, fewer API calls, a slower safety-net cadence. + Owner guide β†— +
+
Loading webhook status…
`; + } + + function webhookDocsHref() { + return (_webhookSetup && _webhookSetup.docs_url) || 'https://github.com/hivecommons/hive/blob/v5/src/docs/webhooks.md#setting-up-webhooks-owner-guide'; + } + + async function loadWebhookSetup() { + const host = document.getElementById('webhook-setup-host'); + if (!host) return; + try { + const res = await fetch('/api/config/github/webhooks'); + if (res.status === 403) { + host.innerHTML = '
Owner access required to view webhook setup.
'; + return; + } + if (!res.ok) throw new Error('HTTP ' + res.status); + _webhookSetup = await res.json(); + renderWebhookSetup(); + } catch (e) { + host.innerHTML = `
Failed to load webhook status: ${esc(e.message)}
`; + } + } + + function webhookStatusChip(d) { + const h = d.health || {}; + if (d.verification === 'none') return chip('not configured', 'muted'); + if (h.events_1h > 0 || h.healthy) return chip(`receiving Β· ${h.events_1h || 0}/1h`, 'ok'); + if (h.last_event_at) return chip('quiet Β· last ' + webhookAgo(h.last_event_at), 'warn'); + return chip('no events yet', 'warn'); + function chip(text, tone) { + return `${esc(text)}`; + } + } + + function webhookAgo(iso) { + const t = Date.parse(iso); + if (!t) return 'β€”'; + const s = Math.max(0, Math.round((Date.now() - t) / 1000)); + if (s < 60) return s + 's ago'; + if (s < 3600) return Math.round(s / 60) + 'm ago'; + if (s < 86400) return Math.round(s / 3600) + 'h ago'; + return Math.round(s / 86400) + 'd ago'; + } + + function webhookCopyBtn(text, label) { + return ``; + } + + async function webhookCopy(btn) { + const text = btn && btn.dataset.copy || ''; + const ok = await writeClipboardText(text); + showToast(ok ? 'Copied' : 'Could not copy β€” select the text and copy it by hand', ok ? 'info' : 'error'); + } + + function renderWebhookSetup() { + const host = document.getElementById('webhook-setup-host'); + const d = _webhookSetup; + if (!host || !d) return; + const h = d.health || {}; + const row = (label, valueHtml) => forgeAppRow(label, valueHtml); + const code = (t) => `${esc(t)}`; + const relayHeld = !!d.hosted; + const direct = d.secret_source !== ''; + + let html = `
+
+ Receiver status + ${webhookStatusChip(d)} +
+ ${row('Verification', code(d.verification || 'none') + (relayHeld && !direct ? ' β€” hub relay; the GitHub App installation delivers through the hub, nothing to set up' : ''))} + ${row('Last event', h.last_event_at ? esc(formatDashboardDateTime(h.last_event_at)) + ' (' + esc(webhookAgo(h.last_event_at)) + ')' : 'none yet')} + ${row('Deliveries (1h)', esc(String(h.events_1h || 0)) + ' Β· ' + esc(String(h.invalidations_1h || 0)) + ' cache patches Β· ' + esc(String(h.ci_events_1h || 0)) + ' CI events')} + ${row('Cadence', code(h.mode || 'polling') + (h.mode_reason ? ' ' + esc(h.mode_reason) + '' : ''))} + ${(!h.last_event_at && d.verification !== 'none') ? '
No events yet β€” on GitHub open the webhook (Settings β†’ Webhooks β†’ your hook β†’ Recent deliveries) and check the response code: 401 means the secret differs, 404 means the path is wrong, 503 means this hive has no secret.
' : ''} +
`; + + html += `
+
${relayHeld ? 'Direct webhook (optional)' : 'Set up a webhook on GitHub'}
+ ${relayHeld ? '
This hive already receives deliveries the hub relays from the GitHub App installation. A direct webhook with your own secret is only needed for repositories the App is not installed on.
' : ''} +
1. Secret
+ ${renderWebhookSecretBlock(d)} +
2. Add the webhook in GitHub
+
Organization (one hook for every repo) β†’ Settings β†’ Webhooks β†’ Add webhook, or per repository under the repo's Settings.
+ ${row('Payload URL', d.payload_url ? code(d.payload_url) + webhookCopyBtn(d.payload_url) + ' (' + esc(d.payload_url_source) + ')' : 'unknown β€” set dashboard.public_url to this hive\u2019s public origin; the path is ' + esc(d.path) + '')} + ${row('Content type', code(d.content_type))} + ${row('Secret', 'the value from step 1')} + ${row('SSL verification', 'keep enabled')} + ${row('Events', '"Let me select individual events", then tick:
' + (d.events || []).map(e => code(e)).join(' ') + webhookCopyBtn((d.events || []).join(', '), 'Copy list'))} + ${row('Optional', Object.entries(d.optional_events || {}).map(([k, v]) => code(k) + ' β€” ' + esc(v) + '').join('
'))} + ${renderWebhookCreateBlock(d)} +
`; + host.innerHTML = html; + } + + function renderWebhookSecretBlock(d) { + const code = (t) => `${esc(t)}`; + let html = ''; + if (_webhookSecretOnce) { + html += `
+
New secret β€” shown once
+
${code(_webhookSecretOnce)}${webhookCopyBtn(_webhookSecretOnce)}
+
Paste it into the GitHub webhook\u2019s Secret field. The hive keeps only the file; closing this dialog forgets the value.
+
`; + } + if (d.secret_source === 'env') { + html += `
Configured from GITHUB_WEBHOOK_SECRET (a Secret mount). Use that same value in GitHub; rotate it in the deployment, not here.
`; + return html; + } + const state = d.secret_source === 'file' + ? `Configured Β· stored at ${esc(d.secret_path || '')}${_webhookSecretOnce ? '' : ' (value not shown again β€” regenerate if lost)'}` + : 'No secret yet β€” generate one. Without it every delivery is rejected with 503.'; + const label = _webhookConfirmRegenerate ? 'Confirm: replace secret' : (d.secret_source === 'file' ? 'Regenerate secret' : 'Generate secret'); + const cls = _webhookConfirmRegenerate ? 'btn-danger' : 'btn-primary'; + html += `
+ ${state} + + ${_webhookConfirmRegenerate ? 'GitHub hooks using the old secret will start failing with 401 until you update them.' : ''} +
`; + return html; + } + + function renderWebhookCreateBlock(d) { + if (d.app_probe_error) { + return `
Could not read the App installation\u2019s permissions (${esc(d.app_probe_error)}); create the hook by hand as above.
`; + } + if (!d.app_org_hooks && !d.app_repo_hooks) { + return `
To have the App create the hook for you, grant it Organization β†’ Webhooks (organization_hooks) or Repository β†’ Webhooks (repository_hooks) at Read and write, then reopen this tab.
`; + } + const scopes = []; + if (d.app_org_hooks) scopes.push(``); + if (d.app_repo_hooks) (d.repos || []).forEach(r => scopes.push(``)); + const disabled = !d.create_available; + const why = !d.secret_configured ? 'generate a secret first' : (!d.payload_url ? 'set dashboard.public_url first' : ''); + return `
+
Or let the App create it
+
+ + + ${why ? 'β€” ' + esc(why) + '' : 'uses the App installation\u2019s hooks permission; subscribes the events above with this hive\u2019s secret'} +
+
+
`; + } + + function webhookCancelRegenerate() { + _webhookConfirmRegenerate = false; + renderWebhookSetup(); + } + + async function webhookGenerateSecret(btn) { + if (_webhookSetup && _webhookSetup.secret_source === 'file' && !_webhookConfirmRegenerate) { + _webhookConfirmRegenerate = true; + renderWebhookSetup(); + return; + } + _webhookConfirmRegenerate = false; + if (btn) btn.disabled = true; + try { + const res = await fetch('/api/config/github/webhooks/secret', { method: 'POST' }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || ('HTTP ' + res.status)); + _webhookSecretOnce = body.secret || ''; + showToast('Webhook secret generated β€” copy it now, it is shown once', 'info'); + await loadWebhookSetup(); + } catch (e) { + showToast('Could not generate secret: ' + e.message, 'error'); + if (btn) btn.disabled = false; + } + } + + async function webhookCreateHook(btn) { + const sel = document.getElementById('webhook-create-scope'); + const out = document.getElementById('webhook-create-result'); + const v = (sel && sel.value) || 'organization'; + const [scope, repo] = v.split(':'); + if (btn) btn.disabled = true; + if (out) out.textContent = 'Creating…'; + try { + const res = await fetch('/api/config/github/webhooks/create', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scope, repo: repo || '' }) + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || ('HTTP ' + res.status)); + const verb = body.created === false ? 'Already set up:' : 'Created'; + if (out) out.innerHTML = `${verb} ${esc(body.scope)} hook #${esc(String(body.id || body.hook_id))} on ${esc(body.target)}. View on GitHub β†— β€” push a commit or comment on a PR and watch β€œDeliveries (1h)” move.`; + showToast(body.created === false ? 'Webhook already exists' : 'Webhook created', 'info'); + } catch (e) { + if (out) out.innerHTML = `${esc(e.message)}`; + } finally { + if (btn) btn.disabled = false; + } + } + + /* Forget the one-time secret whenever the Settings dialog closes so it + never lingers in a re-rendered tab. */ + function webhookSetupReset() { + _webhookSecretOnce = ''; + _webhookConfirmRegenerate = false; + } diff --git a/src/pkg/dashboard/static-src/js/config-workflows-editor.js b/src/pkg/dashboard/static-src/js/config-workflows-editor.js index 6649151761..4dfd898aab 100644 --- a/src/pkg/dashboard/static-src/js/config-workflows-editor.js +++ b/src/pkg/dashboard/static-src/js/config-workflows-editor.js @@ -877,6 +877,24 @@ options.map(o => { const v = Array.isArray(o) ? o[0] : o, l = Array.isArray(o) ? o[1] : (o || '\u2014'); return ``; }).join('') + ''; } + // The column's chevron icon (hivecommons/hive#18033): a preview of what + // the Activity Monitor draws, a pick-list of the allowlisted ids with + // the label's default first, and a box for a single emoji. Both write + // spec.columns[id].icon through wfEdSetColumnIcon, which refuses + // anything outside the allowlist. + function wfEdColumnIconField(c, base, dis) { + if (typeof stageIconFor !== 'function') return ''; + const cur = String(c.icon == null ? '' : c.icon).trim(); + const def = stageIconIdForName(c.label || c.id); + const isEmoji = !!cur && !stageIconIsId(cur); + const ids = Object.keys(STAGE_ICON_SET); + const options = [['', 'default \u00b7 ' + def]].concat(ids.map(id => [id, id])); + const preview = `${stageIconHtml(stageIconFor(c), 'ham-ico ham-stage-ico')}`; + const select = ``; + const emoji = ``; + return wfEdField('Icon', `
${preview}${select}${emoji}
`, 'drawn in this column\u2019s chevrons; blank = by label'); + } + function renderWfEdInspector(st) { const m = st.model, spec = m.spec, sel = st.selection; const can = wfEdCanEdit(); @@ -927,6 +945,7 @@ wfEdField('Id', `
`) + wfEdField('Label', wfEdText(base + '.label', c.label, dis)) + wfEdField('Hint', wfEdText(base + '.hint', c.hint, dis), 'shown as the lane\u2019s tooltip in the Activity Monitor') + + wfEdColumnIconField(c, base, dis) + `
states here need no exit
` + wfEdField('Width', ``) + `

${(c.states || []).length} state${(c.states || []).length === 1 ? '' : 's'}

` + @@ -1498,6 +1517,19 @@ wfEdCommit(wfEdUpdate(_wfEd.model, path, isNaN(n) || n <= 0 ? '' : n)); } function wfEdFieldSetBool(path, checked) { wfEdCommit(wfEdUpdate(_wfEd.model, path, checked ? true : '')); } + // An icon is '' (by label), an allowlisted id or one emoji; anything + // else is refused with a toast and the inspector redraws the last good + // value, so no markup ever reaches a chevron (hivecommons/hive#18033). + function wfEdSetColumnIcon(path, value) { + if (!_wfEd.model) return; + const v = String(value == null ? '' : value).trim(); + if (typeof stageIconIsValid === 'function' && !stageIconIsValid(v)) { + if (typeof showToast === 'function') showToast('Icon must be one of the built-in icon ids or a single emoji.', 'error'); + wfEdRefresh(['inspector']); + return; + } + wfEdCommit(wfEdUpdate(_wfEd.model, path, v), { parts: ['canvas', 'inspector', 'drawer'] }); + } function wfEdSetList(path, value) { wfEdCommit(wfEdUpdate(_wfEd.model, path, String(value || '').split(/[,\s]+/).map(s => s.trim()).filter(Boolean))); } function wfEdSetStateColumn(id, columnId) { wfEdCommit(wfEdMoveState(_wfEd.model, id, columnId === WF_ED_UNASSIGNED ? '' : columnId)); } function wfEdSetInitial(id) { wfEdCommit(wfEdUpdate(_wfEd.model, 'spec.initial', id)); } diff --git a/src/pkg/dashboard/static-src/js/governor-view.js b/src/pkg/dashboard/static-src/js/governor-view.js index 2201e4d468..06d441c12c 100644 --- a/src/pkg/dashboard/static-src/js/governor-view.js +++ b/src/pkg/dashboard/static-src/js/governor-view.js @@ -644,6 +644,30 @@
`; } + /* Webhooks tile (hivecommons/hive#18017): whether GitHub deliveries + reach this hive (direct or hub relay) and how many in the last hour. + Owners click through to Settings β†’ Forge App β†’ GitHub webhooks. */ + function webhookGovStatHtml(data) { + const wh = data && data.webhooks; + if (!wh || typeof wh !== 'object') return ''; + const owner = dashboardRoleAtLeast(window._hiveRole || 'read', 'owner'); + const none = !wh.verification || wh.verification === 'none'; + const n = Number(wh.events_1h || 0); + let val, cls; + if (none) { val = 'off'; cls = ' dead'; } + else if (n > 0 || wh.healthy) { val = n + '/1h'; cls = ''; } + else if (wh.last_event_at) { val = 'quiet'; cls = ' starting'; } + else { val = 'no events'; cls = ' starting'; } + const how = none + ? 'OFF β€” no webhook secret and no hub relay key, so every delivery is rejected and the hive polls GitHub on the base cadence.' + : `Verification: ${wh.verification}. Cadence: ${wh.mode || 'polling'}${wh.mode_reason ? ' (' + wh.mode_reason + ')' : ''}. ${n} deliveries in the last hour.`; + const tip = 'WEBHOOKS β€” does GitHub tell this hive about changes as they happen?\n\nWHAT: With webhooks on, pushes, PRs, reviews and checks wake the governor at once, CI failures kick the owning agent, and the safety-net cadence slows to save API calls.\n\nHOW: ' + how + + (owner ? '\n\nClick to set up or check webhooks (Settings β†’ Forge App).' : ''); + const action = owner ? ' data-action="openConfigDialog" data-config-type="governor" data-tab="Forge App"' : ''; + const label = owner && none ? 'webhooks Β· set up β†’' : 'webhooks'; + return `
${label}
${escapeHtml(val)}
`; + } + function renderGovernor(gov, cadenceMatrix, data) { const el = document.getElementById('governor'); const isStarting = !gov.active && !gov.mode; @@ -717,6 +741,7 @@
actionable PRs
${prCount}${prsSpark}
${prEquationHTML ? `
${prEquationHTML}
` : ''}
MTTR
${formatFixTime(itmMedian)}${itmSpark}
${fleetRunStatHtml()} + ${webhookGovStatHtml(data)}
on hold
${holdTotal}${holdSpark}
${standbyPauseFactsHtml(gov, 'stat')} ${contributorFirstFactsHtml(gov, 'stat')} diff --git a/src/pkg/dashboard/static-src/js/knowledge.js b/src/pkg/dashboard/static-src/js/knowledge.js index 1e08331dc8..b1081695b2 100644 --- a/src/pkg/dashboard/static-src/js/knowledge.js +++ b/src/pkg/dashboard/static-src/js/knowledge.js @@ -2431,7 +2431,7 @@ const cfg = await cfgRes.json().catch(() => ({})); if (!cfgRes.ok) throw new Error(cfg.error || ('HTTP ' + cfgRes.status)); const st = stRes.ok ? await stRes.json().catch(() => ({})) : {}; - _kcState = Object.assign(_kcState, { loaded: true, error: '', connectors: cfg.connectors || [], types: cfg.types || [], ctx7AutoImport: cfg.context7_auto_import || [], layers: cfg.layers || _kcState.layers, rows: st.connectors || [], runtime: !!st.runtime, publish: st.publish || null }); + _kcState = Object.assign(_kcState, { loaded: true, error: '', connectors: cfg.connectors || [], types: cfg.types || [], ctx7AutoImport: cfg.context7_auto_import || [], layers: cfg.layers || _kcState.layers, rows: st.connectors || [], runtime: st.runtime !== false, publish: st.publish || null }); } catch (e) { _kcState = Object.assign(_kcState, { loaded: true, error: e.message }); } @@ -2487,23 +2487,40 @@ return body; } + // Badge shown when the hive's connector syncer is not running (status + // response `runtime: false`): the entry is saved but this process never + // constructed it, so sign-in, Check and Sync would all 404. Distinct from + // `pending`, which is a loaded connector awaiting sign-in or first sync. + // Only an explicit `runtime: false` counts: a failed or absent status + // response must not tell the owner to restart the hive + // (hivecommons/hive#18093). + const KC_STATUS_NOT_LOADED = 'not loaded'; + const KC_NOT_LOADED_HINT = 'Connector runtime is not running on this hive β€” restart the hive after enabling knowledge connectors.'; + function kbConnectorNotLoaded(c) { return !!c.enabled && !_kcState.runtime; } + function kbConnectorNotLoadedHtml() { + return '
' + escapeHtml(KC_NOT_LOADED_HINT) + ' Docs β†—
'; + } + function kbConnectorRow(type, c, owner) { const byName = {}; (_kcState.rows || []).forEach(r => { byName[r.name] = r; }); const r = byName[c.name] || {}; - const status = r.status || (c.enabled ? 'pending' : 'disabled'); + const notLoaded = kbConnectorNotLoaded(c); + const status = notLoaded ? KC_STATUS_NOT_LOADED : (r.status || (c.enabled ? 'pending' : 'disabled')); const publish = _kcState.publish && _kcState.publish.connector === c.name ? ' Β· publish ' + (_kcState.publish.running ? 'syncing' : _kcState.publish.last_error ? 'error' : _kcState.publish.last_success ? 'ok' : 'pending') : ''; let html = '
'; html += '
' + escapeHtml(c.name) + ' ' + escapeHtml(status) + '
'; html += '
' + escapeHtml(c.layer || 'project') + ' Β· ' + escapeHtml(c.interval || '15m') + ' Β· last sync ' + escapeHtml(kcAgo(r.last_sync)) + ' Β· pages/facts ' + (r.pages || 0) + '/' + (r.facts || 0) + escapeHtml(publish) + '
'; if (r.last_error) html += '
' + escapeHtml(r.last_error.length > 140 ? r.last_error.slice(0, 140) + '…' : r.last_error) + '
'; - if (type === 'plaud') html += kbPlaudAuthLine(c.name) + kbPlaudPasteHtml(c.name); + if (notLoaded) html += kbConnectorNotLoadedHtml(); + else if (type === 'plaud') html += kbPlaudAuthLine(c.name) + kbPlaudPasteHtml(c.name); if (type === 'wisprflow') html += kbWisprFlowSourceLine(c); html += '
'; if (owner) { + const disabledAttr = ' disabled title="' + escapeHtml(KC_NOT_LOADED_HINT) + '"'; html += '
'; - if (type === 'plaud') html += kbPlaudAuthButtons(c.name); - html += ''; + if (type === 'plaud') html += kbPlaudAuthButtons(c.name, notLoaded ? disabledAttr : ''); + html += ''; html += ''; html += ''; html += '
'; @@ -2641,13 +2658,15 @@ else text = 'Plaud: not connected' + (a.message ? ' β€” ' + escapeHtml(a.message) : ''); return '
' + text + '
'; } - function kbPlaudAuthButtons(name) { + // disabledAttr, when set, is appended to every button (connector not loaded, #18093). + function kbPlaudAuthButtons(name, disabledAttr) { const a = _kbPlaudAuth[name] || {}; const n = escapeHtml(name); + const off = disabledAttr || ''; let html = ''; - if (a.connected) html += ''; - else html += ''; - html += ''; + if (a.connected) html += ''; + else html += ''; + html += ''; return html; } async function kbPlaudAuthLoad(name, refresh) { @@ -2660,10 +2679,22 @@ _kbPlaudAuth[name] = { error: e.message }; } if (document.querySelector('[data-kb-plaud-auth="' + CSS.escape(name) + '"]')) kbRenderIntegrationsModal(); + return _kbPlaudAuth[name]; + } + // One-line summary of a sign-in state for the Check toast (hivecommons/hive#18092). + function kbPlaudAuthSummary(a) { + if (a.connected) return 'connected' + (a.account ? ' as ' + a.account : ''); + if (a.pending) return 'waiting for authorization' + (a.auth_url ? ' β€” open the link on the card' : ''); + return 'not connected' + (a.message ? ' β€” ' + a.message : ''); } + // Check: re-query the sign-in state and always report the outcome, so a + // 404 "no knowledge connectors are configured" reads as an error toast + // with the server's message verbatim, not as silence (hivecommons/hive#18092). async function kbPlaudRefreshAuth(name) { showToast('Checking Plaud sign-in…', 'info'); - await kbPlaudAuthLoad(name, true); + const a = (await kbPlaudAuthLoad(name, true)) || {}; + if (a.error) showToast('Check failed: ' + a.error, 'error'); + else showToast('Plaud ' + name + ': ' + kbPlaudAuthSummary(a), a.connected ? 'success' : 'info'); } async function kbPlaudConnect(name) { try { diff --git a/src/pkg/dashboard/static-src/js/repos-render.js b/src/pkg/dashboard/static-src/js/repos-render.js index 402c0ce8aa..7a2325539c 100644 --- a/src/pkg/dashboard/static-src/js/repos-render.js +++ b/src/pkg/dashboard/static-src/js/repos-render.js @@ -2,6 +2,7 @@ refreshSectionCardShell('repos-section'); if (_repoResizeDrag || _repoOrderDrag) return; repos = repoOrderApply(repos || []); + noteRepoHoldLayout(repos, Date.now()); const el = document.getElementById('repos'); const showPlan = planningAllowed(); const showDesign = showPlan && dashboardRoleAtLeast(window._hiveRole || 'read', 'owner'); @@ -395,12 +396,37 @@
${laneHtml}${collapseHtml}${pillsHtml}${hfRepoSectionHtml(r)} `; - }).join('')); + }).join('') + renderReferenceRepoCards(status.referenceRepos, hostLabel)); hfRenderEditor(); hfRenderPinned(); hfRenderCrossRepo(); } + /** + * project.reference_repos (#18015): repos outside the project the hive may + * study. Deliberately minimal β€” a name and a tier chip, no stats, no ACMM, + * no pause/swarm controls β€” so they are legible as "not managed here". + * Hidden while a pill or project filter is active (they have no work items + * to match). + */ + function renderReferenceRepoCards(refs, hostLabel) { + if (!Array.isArray(refs) || !refs.length) return ''; + if (repoPillFilterActive(repoPillFilterState()) || overviewProjectFilterState()) return ''; + const base = window._githubBaseUrl || 'https://github.com'; + return refs.map(rr => { + const full = String(rr.full || ''); + const tier = String(rr.reference_tier || rr.tier || 'read-only'); + const tip = tier === 'read-only' + ? 'Reference repo: agents may clone and read it; every write is refused by the proxy.' + : `Reference repo: agents may clone and read it, and may open ${tier.replace('+', ' and ')} (never merge, push to the default branch, label, assign, review, or close others' items).`; + return `
+
${esc(hostLabel)}
+
${esc(full)}πŸ“š reference Β· ${esc(tier)}
+
Outside this project β€” studied, not worked. No work items, no ACMM evaluation.
+
`; + }).join(''); + } + /** * Where a repo card points and what it calls its host (#14115). r.forge is * the repo's forge kind from /api/status; GitHub (and an older hive that diff --git a/src/pkg/dashboard/static-src/js/repos.js b/src/pkg/dashboard/static-src/js/repos.js index 043e089213..e8ed0b6d49 100644 --- a/src/pkg/dashboard/static-src/js/repos.js +++ b/src/pkg/dashboard/static-src/js/repos.js @@ -778,11 +778,49 @@ repaintReposForWidth(); return { repo, item, fromKey, toKey }; } + // A hold toggle moves its row between the held and actionable groups, and + // the status render that follows re-sorts them, so the button under the + // pointer changes a moment after every click. Clicking Hold down a card + // then landed the next click on another item's Release, and that item's + // hold came straight back off (hivecommons/hive#18054: #151 released in + // the same second #283 was held). A Release that arrives within + // REPO_HOLD_SHIFT_GUARD_MS of the rows moving is not sent; the operator + // clicks it again once the list is still. Hold is never delayed: under + // the pointer it can only park an item, and parking is what that click + // was doing anyway. + const REPO_HOLD_SHIFT_GUARD_MS = 1500; + let _repoHoldLayoutSig = null; + let _repoHoldShiftAt = 0; + const _repoHoldInFlight = {}; + // Pure: the order and membership of every card's hold-toggle rows. + function repoHoldLayoutSignature(repos) { + const nums = list => (list || []).map(x => Number(x && x.number) || 0).join(','); + return (repos || []).map(r => (r.full || r.name || '') + ':' + nums(r.actionableIssues) + '|' + nums(r.heldIssues) + '|' + nums(r.openPrs) + '|' + nums(r.heldPrs)).join(';'); + } + // Called on every repos render: remembers when the rows last moved. The + // first render is not a move; nothing was under the pointer before it. + function noteRepoHoldLayout(repos, now) { + const sig = repoHoldLayoutSignature(repos); + if (_repoHoldLayoutSig !== null && sig !== _repoHoldLayoutSig) _repoHoldShiftAt = now; + _repoHoldLayoutSig = sig; + } + function repoHoldReleaseTooSoon(now) { + return _repoHoldShiftAt > 0 && now - _repoHoldShiftAt < REPO_HOLD_SHIFT_GUARD_MS; + } async function toggleRepoItemHold(repoFull, number, type, held, btn) { if (btn && btn.dataset.busy === '1') return; const p = repoParts(repoFull); if (!p.owner || !p.repo || !number) return; const wantHeld = !!held; + // The busy mark lives on a button the optimistic repaint replaces, so + // the item itself carries the in-flight guard. + const flightKey = String(repoFull).toLowerCase() + '/' + type + '#' + number; + if (_repoHoldInFlight[flightKey]) return; + if (!wantHeld && repoHoldReleaseTooSoon(Date.now())) { + showToast('The list moved just as you clicked, so ' + repoFull + '#' + number + ' is still held. Click Release again to release it.', 'info'); + return; + } + _repoHoldInFlight[flightKey] = true; const prevText = btn ? btn.textContent : ''; const moved = moveRepoItemHold(repoFull, number, type, wantHeld, canonicalHiveHoldLabel()); if (btn) { btn.dataset.busy = '1'; btn.textContent = '…'; } @@ -801,6 +839,8 @@ if (moved) moveRepoItemHold(repoFull, number, type, !wantHeld, canonicalHiveHoldLabel()); showToast('Hold toggle failed: ' + (e && e.message ? e.message : 'network error'), 'error'); if (btn) { btn.dataset.busy = ''; btn.textContent = prevText || (wantHeld ? '⏸ Hold' : 'β–Ά Release'); } + } finally { + delete _repoHoldInFlight[flightKey]; } } diff --git a/src/pkg/dashboard/static-src/js/sections.js b/src/pkg/dashboard/static-src/js/sections.js index f7b3cdb1f2..1dceea02c3 100644 --- a/src/pkg/dashboard/static-src/js/sections.js +++ b/src/pkg/dashboard/static-src/js/sections.js @@ -1254,14 +1254,52 @@ next kick: ${agentCollapsedTimestampDetail(nextMs, a && (a.nextKickAt || a.nextK wrap.dataset.upNextKey = nextItem ? nextItem.id : ''; animateAgentNavbarReorder(wrap, beforeRects); } + function agentNavbarSummaryLabel(count) { + return String(count) + (count === 1 ? ' agent' : ' agents'); + } + // The narrow-strip summary is a real button (not a CSS pseudo-element) so + // it can carry data-action and receive clicks and keyboard activation. It + // stays the strip's LAST child so renderAgentNavbarTileDiff's index-based + // insertBefore always places tiles ahead of it. + function renderAgentNavbarSummary(wrap, count) { + let btn = wrap.querySelector('.agent-navbar-summary'); + if (!count) { + if (btn) btn.remove(); + return null; + } + if (!btn) { + btn = document.createElement('button'); + btn.setAttribute('type', 'button'); + btn.className = 'agent-navbar-summary'; + btn.setAttribute('data-action', 'openAgentsUpNextPanel'); + btn.setAttribute('data-stop', '1'); + const full = document.createElement('span'); + full.className = 'agent-navbar-summary-full'; + const short = document.createElement('span'); + short.className = 'agent-navbar-summary-short'; + short.textContent = 'Next'; + btn.appendChild(full); + btn.appendChild(short); + } + const label = agentNavbarSummaryLabel(count); + const title = label + ' in the run order β€” open the Agents section'; + const full = btn.querySelector('.agent-navbar-summary-full'); + if (full && full.textContent !== label) full.textContent = label; + btn.setAttribute('title', title); + btn.setAttribute('aria-label', title); + if (wrap.lastElementChild !== btn) wrap.appendChild(btn); + return btn; + } function renderAgentNavbarUpNext(agents, nowMs) { const wrap = document.getElementById('agent-navbar-upnext'); if (!wrap) return; const now = Number.isFinite(nowMs) ? nowMs : Date.now(); - const list = agentTileStates(agents, now, window._lastStatus && window._lastStatus.governor).filter(item => agentNavbarHasSchedule(item.agent, now)).slice(0, 3); + const scheduled = agentTileStates(agents, now, window._lastStatus && window._lastStatus.governor).filter(item => agentNavbarHasSchedule(item.agent, now)); + const list = scheduled.slice(0, 3); wrap.hidden = list.length === 0; - wrap.dataset.agentSummary = list.length ? String(list.length) + ' agents β–Ύ' : ''; + const summary = renderAgentNavbarSummary(wrap, scheduled.length); renderAgentNavbarTileDiff(wrap, list, now); + if (summary && wrap.lastElementChild !== summary) wrap.appendChild(summary); fitAgentNavbarTiles(wrap); ensureAgentNavbarFitObserver(wrap); if (list.length) ensureAgentsCollapsedCountdownTicker(); diff --git a/src/pkg/dashboard/static-src/js/tracker-filter-render.js b/src/pkg/dashboard/static-src/js/tracker-filter-render.js index 740f74b828..7bad6b1b17 100644 --- a/src/pkg/dashboard/static-src/js/tracker-filter-render.js +++ b/src/pkg/dashboard/static-src/js/tracker-filter-render.js @@ -117,6 +117,17 @@ ? stageTrackerGroupPage(stageTrackerGroups(ordered, { primary: stageTrackerPrimaryRepo(), collapsed: _stageTrackerCollapsed, stages }), f) : stageTrackerPage(ordered, f); if (!tv && page.page !== f.page) f.page = page.page; + // Rows bound to a workflow model wear its columns (tracker-workflows.js), + // so the header over them must be that model's too (hivecommons/hive#18002: + // hive-default names over cncf-techdocs chevrons). Ungrouped, the + // page is partitioned by model (stable, first-seen order) with a + // header per run; grouped, a group header carries its own columns + // when the sticky header is another model's. The sticky header is + // the lead row's. A hive with no binding resolves one run, no model. + const wfRuns = !grouping && ctx.workflows && typeof stageTrackerWorkflowRuns === 'function' ? stageTrackerWorkflowRuns(page.rows, ctx) : null; + if (wfRuns && wfRuns.length > 1) page.rows = wfRuns.reduce((all, run) => all.concat(run.rows), []); + const leadRepo = page.rows.length ? (grouping ? page.rows[0].group.repo : page.rows[0].repo) : ''; + ctx.leadWorkflow = typeof stageTrackerWorkflowOf === 'function' ? stageTrackerWorkflowOf(ctx, leadRepo) : null; // A folded secondary is not filtered out: it counts toward the total. if (foldedShared > 0) page.total += foldedShared; // The rows this render draws (this page, group headers aside), for @@ -192,7 +203,9 @@ : itemRow(pageItems[i], i); const trainRows = stageTrackerTrainRows(page.rows, rowOf, typeof stageTrackerTrainWhere === 'function' ? stageTrackerTrainWhere(ordered, d.items) : null); const inlineRows = typeof stageTrackerPreparationInlineRows === 'function' ? stageTrackerPreparationInlineRows(trainRows, pageItems, stages) : trainRows; - const rows = stageTrackerPinnedRows(el, [{ key: STAGE_TRACKER_HEADER_KEY, html: stageTrackerHeaderHtml(stages) }].concat(inlineRows), nowMs, it => stageTrackerRowHtml(it, stages, ctx)); + const runRows = wfRuns && wfRuns.length > 1 ? stageTrackerWorkflowRunHeaderRows(inlineRows, wfRuns, stages) : inlineRows; + const headHtml = typeof stageTrackerColumnsHeaderHtml === 'function' ? stageTrackerColumnsHeaderHtml(ctx.leadWorkflow, stages) : stageTrackerHeaderHtml(stages); + const rows = stageTrackerPinnedRows(el, [{ key: STAGE_TRACKER_HEADER_KEY, html: headHtml }].concat(runRows), nowMs, it => stageTrackerRowHtml(it, stages, ctx)); const rec = stageTrackerReconcile(el, rows, { effect, hidden: document.hidden }); if (rec.deferred) stageTrackerScheduleSettle(); // Selection is keyed on the row, never baked into its HTML, so a kept diff --git a/src/pkg/dashboard/static-src/js/tracker-row-render.js b/src/pkg/dashboard/static-src/js/tracker-row-render.js index ee7a393061..edafd44820 100644 --- a/src/pkg/dashboard/static-src/js/tracker-row-render.js +++ b/src/pkg/dashboard/static-src/js/tracker-row-render.js @@ -74,7 +74,7 @@ ${stageTrackerRepoChipHtml(g.repo, chipCtx)}${esc(g.repo)}${groupWorkflow ? stageTrackerWorkflowBadgeHtml(groupWorkflow) : ''} ${g.count} ${g.count === 1 ? 'row' : 'rows'}${attn} ${groupWorkflow ? '' : `${dist}`} - ${groupWorkflow && !g.collapsed ? stageTrackerWorkflowHeaderHtml(groupWorkflow) : ''}`; + ${typeof stageTrackerGroupColumnsHtml === 'function' ? stageTrackerGroupColumnsHtml(g, groupWorkflow, stages, ctx) : ''}`; } // Row = title line (number, title, chips, since) + thin strip. Non-active diff --git a/src/pkg/dashboard/static-src/js/tracker-stage-icons.js b/src/pkg/dashboard/static-src/js/tracker-stage-icons.js new file mode 100644 index 0000000000..df7464dfbb --- /dev/null +++ b/src/pkg/dashboard/static-src/js/tracker-stage-icons.js @@ -0,0 +1,160 @@ +/* ---- Stage icons for workflow-model chevrons (hivecommons/hive#18033) --- + A chevron that is not the row's active stage used to carry only its + clipped column label. Now every column has a glyph that says what the + stage means: picked by the column's name (STAGE_ICON_BY_NAME, keyed + by the normalised label), or by the model's own `icon` on the column + β€” an allowlisted id below or a single emoji, never markup. The set is + 16-unit monochrome strokes in currentColor, so it follows the theme + like STAGE_TRACKER_ICONS does. Pure helpers: the node harness + (stage_tracker_stage_icons_ui_test.go) lifts them by name, and a Go + test keeps the id list equal to workflowmodel.ColumnIconIDs. */ + const STAGE_ICON_SET = { + inbox: '', + circle: '', + bolt: '', + hand: '', + person: '', + pencil: '', + 'pull-request': '', + flask: '', + gear: '', + eye: '', + people: '', + 'thumbs-up': '', + 'shield-check': '', + merge: '', + check: '', + checkbox: '', + list: '', + tag: '', + flag: '', + rocket: '', + book: '', + search: '', + clock: '', + pause: '', + archive: '', + dot: '', + }; + const STAGE_ICON_DEFAULT = 'dot'; + + // Pure: the key a column name is matched by β€” lower-case, trimmed, + // inner whitespace and the " / " and " Β· " separators collapsed to one + // space, so "Reviewer check", "reviewer check" and "CI / Review" each + // land on one entry. + function stageIconNormalizeName(name) { + return String(name == null ? '' : name).toLowerCase().replace(/[\sΒ·/]+/g, ' ').trim(); + } + + // Canonical stage name β†’ icon id. Every column label across hive-default, + // the curated marketplace bundles (cncf-techdocs-assessment), the ingest + // skeleton (Intake, Triage) and the editor palette has an entry; the + // workflow editor's column inspector shows the one its label resolves to. + const STAGE_ICON_BY_NAME = { + // hive-default + 'open': 'inbox', + 'actionable': 'bolt', + 'claimed': 'hand', + 'pr in progress': 'pull-request', + 'ci review': 'flask', + 'ci': 'flask', + 'review': 'eye', + 'merge queue': 'merge', + 'merged': 'check', + // cncf-techdocs-assessment + 'requested': 'inbox', + 'accepted': 'checkbox', + 'drafting': 'pencil', + 'reviewer check': 'eye', + 'stakeholder review': 'people', + 'approval': 'thumbs-up', + 'done': 'check', + // ingest skeleton and editor palette + 'intake': 'inbox', + 'triage': 'tag', + 'analysis': 'search', + 'writing': 'pencil', + 'publish': 'rocket', + 'published': 'rocket', + 'needs decision': 'person', + 'work in progress': 'pencil', + 'in progress': 'pencil', + 'in review': 'eye', + 'ci failing': 'flask', + 'released': 'rocket', + 'deployed': 'rocket', + 'deployed staging': 'rocket', + 'deployed production': 'rocket', + 'closed': 'archive', + 'verified': 'shield-check', + 'verification': 'shield-check', + 'approved': 'thumbs-up', + 'blocked': 'pause', + 'hold': 'pause', + 'on hold': 'pause', + 'waiting': 'clock', + 'backlog': 'list', + 'todo': 'list', + 'to do': 'list', + 'docs': 'book', + 'documentation': 'book', + }; + + // Pure: is v an allowlisted icon id? + function stageIconIsId(v) { return Object.prototype.hasOwnProperty.call(STAGE_ICON_SET, String(v || '')); } + + // Pure: is v a single emoji β€” one or two pictographs (a ZWJ sequence or + // a flag) plus modifiers, no letters, digits, spaces or markup? The + // mirror of workflowmodel.IsColumnIconEmoji. + function stageIconIsEmoji(v) { + const s = String(v == null ? '' : v); + if (!s || Array.from(s).length > 8) return false; + let pict = 0; + for (const ch of s) { + const cp = ch.codePointAt(0); + if (cp === 0x200D || cp === 0xFE0F || cp === 0xFE0E || cp === 0x20E3 || (cp >= 0x1F3FB && cp <= 0x1F3FF)) continue; + if (cp < 0x80 || /[\p{L}\p{N}\s\p{Cc}]/u.test(ch)) return false; + if ((cp >= 0x1F000 && cp <= 0x1FAFF) || (cp >= 0x2600 && cp <= 0x27BF) || (cp >= 0x2B00 && cp <= 0x2BFF) || (cp >= 0x2300 && cp <= 0x23FF) || (cp >= 0x2190 && cp <= 0x21FF) || (cp >= 0x25A0 && cp <= 0x25FF) || (cp >= 0x2900 && cp <= 0x297F) || (cp >= 0x1F1E6 && cp <= 0x1F1FF) || [0xA9, 0xAE, 0x2122, 0x2139, 0x3030, 0x303D, 0x3297, 0x3299].indexOf(cp) >= 0) { pict++; continue; } + return false; + } + return pict >= 1 && pict <= 2; + } + + // Pure: '' (default), an icon id or a single emoji validate; anything + // else is refused β€” the editor shows why and keeps the last good value. + function stageIconIsValid(v) { + const s = String(v == null ? '' : v).trim(); + return s === '' || stageIconIsId(s) || stageIconIsEmoji(s); + } + + // Pure: the icon id a column name resolves to; STAGE_ICON_DEFAULT when + // the name is unknown. + function stageIconIdForName(name) { + return STAGE_ICON_BY_NAME[stageIconNormalizeName(name)] || STAGE_ICON_DEFAULT; + } + + // Pure: what a column draws β€” its valid `icon` override (id or emoji), + // else the id its label resolves to. An invalid override is ignored, + // never rendered. + function stageIconFor(col) { + const c = col || {}; + const over = String(c.icon == null ? '' : c.icon).trim(); + if (over && (stageIconIsId(over) || stageIconIsEmoji(over))) return over; + return stageIconIdForName(c.label || c.id); + } + + // The icon as markup: an allowlisted id becomes its inline SVG, an + // emoji a text span (escaped), anything else nothing. label is for + // assistive tech only; the chevron's title carries the hover text. + function stageIconHtml(icon, cls, label) { + const v = String(icon == null ? '' : icon).trim(); + const aria = label ? ` role="img" aria-label="${esc(label)}"` : ' aria-hidden="true"'; + if (stageIconIsId(v)) return `${STAGE_ICON_SET[v]}`; + if (stageIconIsEmoji(v)) return `${esc(v)}`; + return ''; + } + + // The glyph a column's chevron wears, by its override or its name. + function stageIconColumnHtml(col, cls, label) { + return stageIconHtml(stageIconFor(col), cls, label); + } diff --git a/src/pkg/dashboard/static-src/js/tracker-workflows.js b/src/pkg/dashboard/static-src/js/tracker-workflows.js index 03b8e6d24a..5004eac845 100644 --- a/src/pkg/dashboard/static-src/js/tracker-workflows.js +++ b/src/pkg/dashboard/static-src/js/tracker-workflows.js @@ -67,14 +67,80 @@ // The model's column header, drawn under a bound repository's group // header in place of the shared hive-default header. function stageTrackerWorkflowHeaderHtml(wf) { - const cols = wf.columns.map(c => `
  • ${esc(c.label)}
  • `).join(''); + const cols = wf.columns.map(c => `
  • ${stageTrackerWorkflowColumnIconHtml(c, 'ham-ico ham-col-ico ham-stage-ico')}${esc(c.label)}
  • `).join(''); return ``; } + // Pure: the key a row's model resolves to ('' for hive-default). + function stageTrackerWorkflowKey(wf) { return wf ? wf.name + '@' + wf.version : ''; } + + // Pure: the column header the rows under it follow β€” the model's own + // when they are bound to one, else hive-default's. The one path for + // the sticky header and the run headers, so the names above a strip + // are always the names its chevrons wear (hivecommons/hive#18002: + // hive-default's "PR in progress / Merge queue" over model chevrons). + function stageTrackerColumnsHeaderHtml(wf, stages) { + return wf ? stageTrackerWorkflowHeaderHtml(wf) : stageTrackerHeaderHtml(stages); + } + + // Pure: an ungrouped page's rows partitioned, stably and in first-seen + // order, by the model each row's repository follows: [{ wf, key, rows }]. + // One run when every row follows the same model (or none does), so a + // hive-default page is left exactly as sorted. + function stageTrackerWorkflowRuns(rows, ctx) { + const runs = []; + const by = {}; + for (const it of rows || []) { + const wf = stageTrackerWorkflowOf(ctx, it.repo); + const key = stageTrackerWorkflowKey(wf); + if (!by[key]) { by[key] = { wf, key, rows: [] }; runs.push(by[key]); } + by[key].rows.push(it); + } + return runs; + } + + // Run headers are keyed under the group prefix so the selection and + // the reconcile treat them as headers, never as item rows. + const STAGE_TRACKER_WORKFLOW_HEADER_KEY_PREFIX = STAGE_TRACKER_GROUP_KEY_PREFIX + 'workflow:'; + + // Pure: the page's keyed rows with a column header spliced in before + // each run after the first (the first run sits under the sticky + // header). A run whose lead row the train/preparation wrappers did not + // keep by key gets no header rather than a misplaced one. + function stageTrackerWorkflowRunHeaderRows(rows, runs, stages) { + const out = (rows || []).slice(); + (runs || []).slice(1).forEach(run => { + const lead = run.rows[0] ? stageTrackerItemKey(run.rows[0]) : null; + const at = out.findIndex(r => r.key === lead); + if (at >= 0) out.splice(at, 0, { key: STAGE_TRACKER_WORKFLOW_HEADER_KEY_PREFIX + (run.key || 'hive-default'), html: stageTrackerColumnsHeaderHtml(run.wf, stages) }); + }); + return out; + } + + // Under a group header: a bound repository's own column header; a + // hive-default repository's only when the sticky header above is a + // model's (ctx.leadWorkflow), so its rows are not read against that + // model's names. Collapsed groups draw none. + function stageTrackerGroupColumnsHtml(g, groupWorkflow, stages, ctx) { + if (!g || g.collapsed) return ''; + if (groupWorkflow) return stageTrackerWorkflowHeaderHtml(groupWorkflow); + return ctx && ctx.leadWorkflow ? stageTrackerHeaderHtml(stages) : ''; + } + + // The glyph a model column wears (hivecommons/hive#18033): its `icon` + // override when valid, else the icon its label resolves to. Guarded so + // harnesses that lift the strip alone still render. + function stageTrackerWorkflowColumnIconHtml(c, cls, label) { + return typeof stageIconColumnHtml === 'function' ? stageIconColumnHtml(c, cls, label) : ''; + } + // One row's strip under its model: done before the anchored column, // active at it (the stage's own word from `stages`, or the column // label), todo after. An uncovered stage lights the first column amber // with the "Not covered" word so the gap is visible, not silent. + // Done and todo chevrons wear the column's icon alone (the label is + // the header's and the hover's; in a chevron it only clipped); the + // active one leads its word with the icon (hivecommons/hive#18033). function stageTrackerWorkflowStripItems(item, wf, stages) { const cur = Number(item.stage) || 0; const terminal = cur >= (typeof STAGE_TRACKER_TERMINAL === 'number' ? STAGE_TRACKER_TERMINAL : 110); @@ -92,6 +158,8 @@ ? `stage/${String(cur).padStart(3, '0')} is not a state of ${wf.name}@${wf.version}; the engine leaves this item where it is.` : (c.hint || c.label) + (state === 'active' && cur ? ` β€” stage/${String(cur).padStart(3, '0')}` : ''); const cls = ['ham-seg', 'ham-wf-seg', state, c.terminal ? 'cap' : '', uncovered && state === 'active' ? 'ham-wf-uncovered' : ''].filter(Boolean).join(' '); - return `
  • ${esc(word)}
  • `; + const ico = stageTrackerWorkflowColumnIconHtml(c, 'ham-ico ham-stage-ico'); + const inner = state === 'active' ? `${ico}${esc(word)}` : (ico || `${esc(word)}`); + return `
  • ${inner}
  • `; }).join(''); } diff --git a/src/pkg/dashboard/static-src/manifest.txt b/src/pkg/dashboard/static-src/manifest.txt index d6c8e81100..310a469a9b 100644 --- a/src/pkg/dashboard/static-src/manifest.txt +++ b/src/pkg/dashboard/static-src/manifest.txt @@ -22,6 +22,7 @@ css/cost.css css/alerts-toasts.css css/feedback-faq.css css/config-dialog.css +css/config-webhooks.css css/workflows.css css/workflows-editor.css css/workflows-ingest.css @@ -80,6 +81,7 @@ js/tracker-words-gate.js js/tracker-words.js js/tracker-row-anchor.js js/tracker-row.js +js/tracker-stage-icons.js js/tracker-row-strip.js js/tracker-workflows.js js/tracker-row-acted.js @@ -138,6 +140,7 @@ js/config-state.js js/settings-search.js js/config-dialog.js js/config-tabs.js +js/config-webhooks.js js/config-hub.js js/config-agent.js js/config-governor.js diff --git a/src/pkg/dashboard/static/index.html b/src/pkg/dashboard/static/index.html index b0aed3b20b..3d3939b006 100644 --- a/src/pkg/dashboard/static/index.html +++ b/src/pkg/dashboard/static/index.html @@ -1618,6 +1618,14 @@ .ham-wf-badge { flex: 0 0 auto; font-family: var(--font-mono); font-size: var(--fs-2xs); font-weight: 700; letter-spacing: 0.02em; padding: var(--sp-1) var(--sp-3); border-radius: var(--r-pill); white-space: nowrap; color: var(--indigo); background: color-mix(in srgb, var(--indigo) 12%, transparent); border: 1px solid color-mix(in srgb, var(--indigo) 40%, transparent); } .ham-wf-cols { position: static; } .ham-wf-cols .ham-wf-col { color: var(--indigo); } + /* Model chevrons wear the column's stage icon (hivecommons/hive issue 18033): + alone and dimmed in a done / todo step, leading the word in the lit + one. An emoji override is sized to the SVG glyph so the row keeps + its height. */ + .ham-seg.ham-wf-seg .ham-stage-ico { flex: 0 0 auto; } + .ham-seg.ham-wf-seg.active .ham-stage-ico { margin-right: var(--sp-1); } + .ham-seg.ham-wf-seg.done .ham-stage-ico { opacity: 0.6; } + .ham-emoji-ico { display: inline-block; width: 12px; height: 12px; font-size: var(--fs-xs); line-height: 12px; text-align: center; font-style: normal; } .ham-seg.ham-wf-uncovered { background: color-mix(in srgb, var(--amber) 18%, transparent); color: var(--amber); outline: 1px dashed var(--amber); } .ham-group-dist { flex: 0 0 auto; margin-left: auto; margin-right: calc(4em + var(--sp-3)); display: inline-flex; align-items: flex-end; gap: var(--sp-1); height: 0.9em; } .ham-gd { display: inline-block; width: 0.45em; height: 15%; border-radius: var(--sp-1); background: color-mix(in srgb, var(--text-faint) 35%, transparent); } @@ -3363,6 +3371,13 @@ .search-hit-snippet { margin-top: var(--sp-1); color: var(--text-muted); white-space: pre-wrap; overflow-wrap: anywhere; } .search-redacted { font-size: var(--fs-2xs); font-weight: 700; padding: 0 var(--sp-2); border-radius: var(--r-pill); border: 1px solid color-mix(in srgb, var(--red) 45%, transparent); color: var(--red); cursor: help; } .repo-paused-pill { flex: 0 0 auto; font-size: var(--fs-2xs); font-weight: 700; letter-spacing: 0.02em; line-height: 1; padding: var(--sp-1) var(--sp-3); border-radius: var(--r-pill); white-space: nowrap; text-transform: uppercase; border: 1px solid color-mix(in srgb, var(--yellow) 45%, transparent); background: color-mix(in srgb, var(--yellow) 15%, transparent); color: var(--yellow); cursor: help; } + /* project.reference_repos (#18015): a studied-not-worked repo. Muted + border, no stats block, one tier chip. */ + .repo-card-reference { border-style: dashed; opacity: 0.85; } + .repo-reference-provider { color: var(--muted); } + .repo-reference-link { color: inherit; text-decoration: none; } + .repo-reference-pill { flex: 0 0 auto; font-size: var(--fs-2xs); font-weight: 700; letter-spacing: 0.02em; line-height: 1; padding: var(--sp-1) var(--sp-3); border-radius: var(--r-pill); white-space: nowrap; border: 1px solid color-mix(in srgb, var(--indigo) 45%, transparent); background: color-mix(in srgb, var(--indigo) 12%, transparent); color: var(--indigo); cursor: help; } + .repo-reference-note { font-size: var(--fs-2xs); color: var(--muted); margin-top: var(--sp-2); } .repo-pause-btn { flex: 0 0 auto; margin-left: 0; font-size: 0.66rem; font-weight: 600; line-height: 1; padding: 3px 7px; border-radius: var(--r-pill); white-space: nowrap; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--muted); opacity: 0.7; transition: opacity 0.15s, color 0.15s, border-color 0.15s; } .repo-automerge-toggle { flex: 0 0 auto; white-space: nowrap; gap: var(--sp-2); margin: 0; font-size: var(--fs-2xs); color: var(--muted); } /* margin: 0 beats .config-toggle's margin-bottom (that rule is declared @@ -4864,6 +4879,10 @@ .rv-reviewer-help { margin: var(--sp-1) var(--sp-0) var(--sp-0); } .rv-reviewer-help-above { margin: var(--sp-0) var(--sp-0) var(--sp-2); } #rv-require-labels-field[hidden] { display: none; } + /* Linked review.perspectives_source banner in the Review Gate (#17989). */ + .rv-perspectives-linked { margin: var(--sp-0) var(--sp-0) var(--sp-3); padding: var(--sp-4) var(--sp-5); background: var(--surface); border: 1px solid var(--border); border-radius: var(--r); font-size: var(--fs-sm); } + .rv-perspectives-linked-status, .rv-perspectives-linked-warning { margin-top: var(--sp-2); color: var(--muted); } + .rv-perspectives-linked-warning { color: var(--yellow); } .am-trusted-authors-repos { min-height: 72px; } .am-bot-empty { color: var(--muted); margin: var(--sp-1) 0; } .am-bot-add { display: flex; gap: var(--sp-3); margin-top: var(--sp-4); align-items: center; } @@ -5254,6 +5273,33 @@ .config-body { --config-body-pad: 14px; padding: var(--sp-0) var(--config-body-pad) var(--config-body-pad); } .config-field-row, .config-field-row4 { grid-template-columns: 1fr; } } + /* Settings β†’ Forge App β†’ GitHub webhooks (hivecommons/hive#18017) */ + .wh-section-title { font-weight: 600; font-size: var(--fs-base); margin: var(--sp-5) 0 var(--sp-2); } + .wh-intro { font-size: var(--fs-sm); color: var(--muted); margin-bottom: var(--sp-3); } + .wh-muted { color: var(--muted); } + div.wh-muted { font-size: var(--fs-sm); } + .wh-small { font-size: var(--fs-sm); } + .wh-warn { color: var(--yellow); } + .wh-ok { color: var(--green); } + .wh-error { font-size: var(--fs-sm); color: var(--red); } + .wh-note { margin-top: var(--sp-2); } + .wh-card { border: 1px solid var(--border); border-radius: var(--r); padding: var(--sp-4) var(--sp-5); margin-bottom: var(--sp-5); background: var(--surface); } + .wh-card-head { display: flex; align-items: center; gap: var(--sp-4); flex-wrap: wrap; margin-bottom: var(--sp-4); } + .wh-card-title { font-weight: 600; font-size: var(--fs-md); } + .wh-step { font-size: var(--fs-sm); font-weight: 600; margin: var(--sp-2) 0 var(--sp-1); } + .wh-step-gap { margin-top: var(--sp-4); } + .wh-code { font-size: var(--fs-xs); user-select: all; } + .wh-chip { font-size: var(--fs-xs); font-weight: 600; padding: var(--sp-1) var(--sp-4); border-radius: var(--r); border: 1px solid currentColor; } + .wh-chip-muted { color: var(--muted); } + .wh-chip-ok { color: var(--green); } + .wh-chip-warn { color: var(--yellow); } + .wh-actions { display: flex; align-items: center; gap: var(--sp-4); flex-wrap: wrap; font-size: var(--fs-sm); } + .wh-actions .hv-btn { margin-left: var(--sp-2); } + .wh-secret-once { border: 1px solid var(--green); border-radius: var(--r); padding: var(--sp-3) var(--sp-4); margin: var(--sp-2) 0; font-size: var(--fs-sm); } + .wh-secret-once-title { font-weight: 600; color: var(--green); } + .wh-secret-value { margin: var(--sp-1) 0; word-break: break-all; } + .wh-create { border-top: 1px solid var(--border); margin-top: var(--sp-4); padding-top: var(--sp-4); } + .gov-stat.wh-clickable { cursor: pointer; } /* Settings β†’ Workflows (#17620 phase 3): library, detail, diagram, ingest */ .wf-nav { display: flex; gap: var(--sp-2); margin-bottom: var(--sp-5); border-bottom: var(--line-width) solid var(--border); } .wf-nav-item { background: none; border: none; border-bottom: 2px solid transparent; color: var(--muted); font: inherit; font-size: var(--fs-sm); font-weight: 700; padding: var(--sp-2) var(--sp-4); cursor: pointer; } @@ -5469,6 +5515,12 @@ .wf-ed-field input[readonly] { color: var(--muted); } .wf-ed-inline { display: flex; gap: var(--sp-2); align-items: center; } .wf-ed-inline input { flex: 1 1 auto; min-width: 0; } + /* Column icon row (hivecommons/hive#18033): preview glyph, id pick-list, emoji box. */ + .wf-ed-icon-row .wf-ed-icon-preview { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; flex: 0 0 auto; border-radius: var(--r-sm, 4px); background: var(--panel-strong); color: var(--text); } + .wf-ed-icon-row .wf-ed-icon-preview .ham-ico { width: 14px; height: 14px; } + .wf-ed-icon-row .wf-ed-icon-preview .ham-emoji-ico { width: 14px; height: 14px; font-size: var(--fs-sm); line-height: 14px; } + .wf-ed-icon-row select { flex: 1 1 auto; min-width: 0; } + .wf-ed-icon-row .wf-ed-icon-emoji { flex: 0 0 7em; } .wf-ed-checks { display: flex; gap: var(--sp-4); align-items: center; flex-wrap: wrap; } .wf-ed-warn { color: var(--status-warn); } .wf-ed-translist { margin: var(--sp-0); padding-left: var(--sp-5); font-size: var(--fs-xs); } @@ -6023,6 +6075,7 @@ .kc-pill[data-status="ok"] { background: var(--green); } .kc-pill[data-status="syncing"] { background: var(--blue); } .kc-pill[data-status="error"] { background: var(--red); } + .kc-pill[data-status="not loaded"] { background: var(--orange); } .kc-publish { margin-top: var(--sp-4); border-top: var(--line-width) solid var(--border); padding-top: var(--sp-3); } .kc-publish-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--sp-2); } .kc-publish-head h4 { margin: 0; } @@ -6333,7 +6386,7 @@ .agent-navbar-tile .agent-up-next.running-now { color: var(--green); } .agent-navbar-tile.active .agent-tile-dot { animation: green-pulse 2.4s ease-in-out infinite; } .agent-navbar-tile.stopped .agent-tile-dot, .agent-navbar-tile.needs-login .agent-tile-dot { background: var(--red); box-shadow: 0 0 0 1px color-mix(in srgb, var(--red) 40%, transparent); } - .agent-navbar-upnext::after { content: attr(data-agent-summary); display: none; align-items: center; justify-content: center; min-height: calc(var(--nav-chip-h) + var(--sp-4)); padding: var(--sp-2) var(--nav-chip-px); border: 1px solid var(--line-subtle); border-radius: var(--r); background: color-mix(in srgb, var(--surface-2) 64%, transparent); color: var(--text); font-size: var(--fs-xs); font-weight: 800; white-space: nowrap; } + .agent-navbar-upnext[data-agent-more]::after { display: none; align-items: center; justify-content: center; min-height: calc(var(--nav-chip-h) + var(--sp-4)); padding: var(--sp-2) var(--nav-chip-px); border: 1px solid var(--line-subtle); border-radius: var(--r); background: color-mix(in srgb, var(--surface-2) 64%, transparent); color: var(--text); font-size: var(--fs-xs); font-weight: 800; white-space: nowrap; } /* The "+N" count of tiles that did not fit. While the fit is measured it sits first in the row, so its own width is reserved. */ @container navbar-center (width > 340px) { @@ -6341,6 +6394,9 @@ .agent-navbar-upnext--measuring::after { order: -1; } } /* One line per tile: name, label, dot, each in its own column. */ + .agent-navbar-summary { flex: 0 0 auto; display: none; align-items: center; justify-content: center; min-height: calc(var(--nav-chip-h) + var(--sp-4)); padding: var(--sp-2) var(--nav-chip-px); border: 1px solid var(--line-subtle); border-radius: var(--r); background: color-mix(in srgb, var(--surface-2) 64%, transparent); color: var(--text); cursor: pointer; font: inherit; font-size: var(--fs-xs); font-weight: 800; white-space: nowrap; } + .agent-navbar-summary:hover, .agent-navbar-summary:focus-visible { border-color: var(--border); background: var(--surface-2); outline: none; } + .agent-navbar-summary-short { display: none; } @container navbar-center (max-width: 560px) { .agent-navbar-tile { min-width: 104px; grid-template-columns: max-content auto auto; } .agent-navbar-tile .agent-tile-next { display: none; } @@ -6355,10 +6411,12 @@ @container navbar-center (max-width: 340px) { .agent-navbar-upnext { overflow: hidden; mask-image: none; } .agent-navbar-upnext .agent-navbar-tile { display: none; } - .agent-navbar-upnext::after { display: inline-flex; } + .agent-navbar-upnext .agent-navbar-summary { display: inline-flex; } } @container navbar-center (max-width: 220px) { - .agent-navbar-upnext::after { content: 'Next'; padding-inline: var(--sp-1); } + .agent-navbar-upnext .agent-navbar-summary { padding-inline: var(--sp-1); } + .agent-navbar-summary .agent-navbar-summary-full { display: none; } + .agent-navbar-summary .agent-navbar-summary-short { display: inline; } } @keyframes navbar-up-next-pulse { 0% { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--amber) 72%, transparent), 0 0 0 var(--sp-0) color-mix(in srgb, var(--amber) 0%, transparent); } @@ -9527,7 +9585,7 @@

    All labels

    ? stageTrackerGroupPage(stageTrackerGroups(ordered, { primary: stageTrackerPrimaryRepo(), collapsed: _stageTrackerCollapsed, stages }), f) : stageTrackerPage(ordered, f); if (!tv && page.page !== f.page) f.page = page.page; + // Rows bound to a workflow model wear its columns (tracker-workflows.js), + // so the header over them must be that model's too (hivecommons/hive#18002: + // hive-default names over cncf-techdocs chevrons). Ungrouped, the + // page is partitioned by model (stable, first-seen order) with a + // header per run; grouped, a group header carries its own columns + // when the sticky header is another model's. The sticky header is + // the lead row's. A hive with no binding resolves one run, no model. + const wfRuns = !grouping && ctx.workflows && typeof stageTrackerWorkflowRuns === 'function' ? stageTrackerWorkflowRuns(page.rows, ctx) : null; + if (wfRuns && wfRuns.length > 1) page.rows = wfRuns.reduce((all, run) => all.concat(run.rows), []); + const leadRepo = page.rows.length ? (grouping ? page.rows[0].group.repo : page.rows[0].repo) : ''; + ctx.leadWorkflow = typeof stageTrackerWorkflowOf === 'function' ? stageTrackerWorkflowOf(ctx, leadRepo) : null; // A folded secondary is not filtered out: it counts toward the total. if (foldedShared > 0) page.total += foldedShared; // The rows this render draws (this page, group headers aside), for @@ -24263,7 +24598,9 @@

    All labels

    : itemRow(pageItems[i], i); const trainRows = stageTrackerTrainRows(page.rows, rowOf, typeof stageTrackerTrainWhere === 'function' ? stageTrackerTrainWhere(ordered, d.items) : null); const inlineRows = typeof stageTrackerPreparationInlineRows === 'function' ? stageTrackerPreparationInlineRows(trainRows, pageItems, stages) : trainRows; - const rows = stageTrackerPinnedRows(el, [{ key: STAGE_TRACKER_HEADER_KEY, html: stageTrackerHeaderHtml(stages) }].concat(inlineRows), nowMs, it => stageTrackerRowHtml(it, stages, ctx)); + const runRows = wfRuns && wfRuns.length > 1 ? stageTrackerWorkflowRunHeaderRows(inlineRows, wfRuns, stages) : inlineRows; + const headHtml = typeof stageTrackerColumnsHeaderHtml === 'function' ? stageTrackerColumnsHeaderHtml(ctx.leadWorkflow, stages) : stageTrackerHeaderHtml(stages); + const rows = stageTrackerPinnedRows(el, [{ key: STAGE_TRACKER_HEADER_KEY, html: headHtml }].concat(runRows), nowMs, it => stageTrackerRowHtml(it, stages, ctx)); const rec = stageTrackerReconcile(el, rows, { effect, hidden: document.hidden }); if (rec.deferred) stageTrackerScheduleSettle(); // Selection is keyed on the row, never baked into its HTML, so a kept @@ -33786,6 +34123,30 @@

    Approval and audit records

    ${approval}`; `; } + /* Webhooks tile (hivecommons/hive#18017): whether GitHub deliveries + reach this hive (direct or hub relay) and how many in the last hour. + Owners click through to Settings β†’ Forge App β†’ GitHub webhooks. */ + function webhookGovStatHtml(data) { + const wh = data && data.webhooks; + if (!wh || typeof wh !== 'object') return ''; + const owner = dashboardRoleAtLeast(window._hiveRole || 'read', 'owner'); + const none = !wh.verification || wh.verification === 'none'; + const n = Number(wh.events_1h || 0); + let val, cls; + if (none) { val = 'off'; cls = ' dead'; } + else if (n > 0 || wh.healthy) { val = n + '/1h'; cls = ''; } + else if (wh.last_event_at) { val = 'quiet'; cls = ' starting'; } + else { val = 'no events'; cls = ' starting'; } + const how = none + ? 'OFF β€” no webhook secret and no hub relay key, so every delivery is rejected and the hive polls GitHub on the base cadence.' + : `Verification: ${wh.verification}. Cadence: ${wh.mode || 'polling'}${wh.mode_reason ? ' (' + wh.mode_reason + ')' : ''}. ${n} deliveries in the last hour.`; + const tip = 'WEBHOOKS β€” does GitHub tell this hive about changes as they happen?\n\nWHAT: With webhooks on, pushes, PRs, reviews and checks wake the governor at once, CI failures kick the owning agent, and the safety-net cadence slows to save API calls.\n\nHOW: ' + how + + (owner ? '\n\nClick to set up or check webhooks (Settings β†’ Forge App).' : ''); + const action = owner ? ' data-action="openConfigDialog" data-config-type="governor" data-tab="Forge App"' : ''; + const label = owner && none ? 'webhooks Β· set up β†’' : 'webhooks'; + return `
    ${label}
    ${escapeHtml(val)}
    `; + } + function renderGovernor(gov, cadenceMatrix, data) { const el = document.getElementById('governor'); const isStarting = !gov.active && !gov.mode; @@ -33859,6 +34220,7 @@

    Approval and audit records

    ${approval}`;
    actionable PRs
    ${prCount}${prsSpark}
    ${prEquationHTML ? `
    ${prEquationHTML}
    ` : ''}
    MTTR
    ${formatFixTime(itmMedian)}${itmSpark}
    ${fleetRunStatHtml()} + ${webhookGovStatHtml(data)}
    on hold
    ${holdTotal}${holdSpark}
    ${standbyPauseFactsHtml(gov, 'stat')} ${contributorFirstFactsHtml(gov, 'stat')} @@ -35012,11 +35374,49 @@

    Approval and audit records

    ${approval}`; repaintReposForWidth(); return { repo, item, fromKey, toKey }; } + // A hold toggle moves its row between the held and actionable groups, and + // the status render that follows re-sorts them, so the button under the + // pointer changes a moment after every click. Clicking Hold down a card + // then landed the next click on another item's Release, and that item's + // hold came straight back off (hivecommons/hive#18054: #151 released in + // the same second #283 was held). A Release that arrives within + // REPO_HOLD_SHIFT_GUARD_MS of the rows moving is not sent; the operator + // clicks it again once the list is still. Hold is never delayed: under + // the pointer it can only park an item, and parking is what that click + // was doing anyway. + const REPO_HOLD_SHIFT_GUARD_MS = 1500; + let _repoHoldLayoutSig = null; + let _repoHoldShiftAt = 0; + const _repoHoldInFlight = {}; + // Pure: the order and membership of every card's hold-toggle rows. + function repoHoldLayoutSignature(repos) { + const nums = list => (list || []).map(x => Number(x && x.number) || 0).join(','); + return (repos || []).map(r => (r.full || r.name || '') + ':' + nums(r.actionableIssues) + '|' + nums(r.heldIssues) + '|' + nums(r.openPrs) + '|' + nums(r.heldPrs)).join(';'); + } + // Called on every repos render: remembers when the rows last moved. The + // first render is not a move; nothing was under the pointer before it. + function noteRepoHoldLayout(repos, now) { + const sig = repoHoldLayoutSignature(repos); + if (_repoHoldLayoutSig !== null && sig !== _repoHoldLayoutSig) _repoHoldShiftAt = now; + _repoHoldLayoutSig = sig; + } + function repoHoldReleaseTooSoon(now) { + return _repoHoldShiftAt > 0 && now - _repoHoldShiftAt < REPO_HOLD_SHIFT_GUARD_MS; + } async function toggleRepoItemHold(repoFull, number, type, held, btn) { if (btn && btn.dataset.busy === '1') return; const p = repoParts(repoFull); if (!p.owner || !p.repo || !number) return; const wantHeld = !!held; + // The busy mark lives on a button the optimistic repaint replaces, so + // the item itself carries the in-flight guard. + const flightKey = String(repoFull).toLowerCase() + '/' + type + '#' + number; + if (_repoHoldInFlight[flightKey]) return; + if (!wantHeld && repoHoldReleaseTooSoon(Date.now())) { + showToast('The list moved just as you clicked, so ' + repoFull + '#' + number + ' is still held. Click Release again to release it.', 'info'); + return; + } + _repoHoldInFlight[flightKey] = true; const prevText = btn ? btn.textContent : ''; const moved = moveRepoItemHold(repoFull, number, type, wantHeld, canonicalHiveHoldLabel()); if (btn) { btn.dataset.busy = '1'; btn.textContent = '…'; } @@ -35035,6 +35435,8 @@

    Approval and audit records

    ${approval}`; if (moved) moveRepoItemHold(repoFull, number, type, !wantHeld, canonicalHiveHoldLabel()); showToast('Hold toggle failed: ' + (e && e.message ? e.message : 'network error'), 'error'); if (btn) { btn.dataset.busy = ''; btn.textContent = prevText || (wantHeld ? '⏸ Hold' : 'β–Ά Release'); } + } finally { + delete _repoHoldInFlight[flightKey]; } } @@ -38528,6 +38930,7 @@

    Approval and audit records

    ${approval}`; refreshSectionCardShell('repos-section'); if (_repoResizeDrag || _repoOrderDrag) return; repos = repoOrderApply(repos || []); + noteRepoHoldLayout(repos, Date.now()); const el = document.getElementById('repos'); const showPlan = planningAllowed(); const showDesign = showPlan && dashboardRoleAtLeast(window._hiveRole || 'read', 'owner'); @@ -38921,12 +39324,37 @@

    Approval and audit records

    ${approval}`; ${laneHtml}${collapseHtml}${pillsHtml}${hfRepoSectionHtml(r)} `; - }).join('')); + }).join('') + renderReferenceRepoCards(status.referenceRepos, hostLabel)); hfRenderEditor(); hfRenderPinned(); hfRenderCrossRepo(); } + /** + * project.reference_repos (#18015): repos outside the project the hive may + * study. Deliberately minimal β€” a name and a tier chip, no stats, no ACMM, + * no pause/swarm controls β€” so they are legible as "not managed here". + * Hidden while a pill or project filter is active (they have no work items + * to match). + */ + function renderReferenceRepoCards(refs, hostLabel) { + if (!Array.isArray(refs) || !refs.length) return ''; + if (repoPillFilterActive(repoPillFilterState()) || overviewProjectFilterState()) return ''; + const base = window._githubBaseUrl || 'https://github.com'; + return refs.map(rr => { + const full = String(rr.full || ''); + const tier = String(rr.reference_tier || rr.tier || 'read-only'); + const tip = tier === 'read-only' + ? 'Reference repo: agents may clone and read it; every write is refused by the proxy.' + : `Reference repo: agents may clone and read it, and may open ${tier.replace('+', ' and ')} (never merge, push to the default branch, label, assign, review, or close others' items).`; + return `
    +
    ${esc(hostLabel)}
    +
    ${esc(full)}πŸ“š reference Β· ${esc(tier)}
    +
    Outside this project β€” studied, not worked. No work items, no ACMM evaluation.
    +
    `; + }).join(''); + } + /** * Where a repo card points and what it calls its host (#14115). r.forge is * the repo's forge kind from /api/status; GitHub (and an older hive that @@ -48673,7 +49101,7 @@ const cfg = await cfgRes.json().catch(() => ({})); if (!cfgRes.ok) throw new Error(cfg.error || ('HTTP ' + cfgRes.status)); const st = stRes.ok ? await stRes.json().catch(() => ({})) : {}; - _kcState = Object.assign(_kcState, { loaded: true, error: '', connectors: cfg.connectors || [], types: cfg.types || [], ctx7AutoImport: cfg.context7_auto_import || [], layers: cfg.layers || _kcState.layers, rows: st.connectors || [], runtime: !!st.runtime, publish: st.publish || null }); + _kcState = Object.assign(_kcState, { loaded: true, error: '', connectors: cfg.connectors || [], types: cfg.types || [], ctx7AutoImport: cfg.context7_auto_import || [], layers: cfg.layers || _kcState.layers, rows: st.connectors || [], runtime: st.runtime !== false, publish: st.publish || null }); } catch (e) { _kcState = Object.assign(_kcState, { loaded: true, error: e.message }); } @@ -48729,23 +49157,40 @@ return body; } + // Badge shown when the hive's connector syncer is not running (status + // response `runtime: false`): the entry is saved but this process never + // constructed it, so sign-in, Check and Sync would all 404. Distinct from + // `pending`, which is a loaded connector awaiting sign-in or first sync. + // Only an explicit `runtime: false` counts: a failed or absent status + // response must not tell the owner to restart the hive + // (hivecommons/hive#18093). + const KC_STATUS_NOT_LOADED = 'not loaded'; + const KC_NOT_LOADED_HINT = 'Connector runtime is not running on this hive β€” restart the hive after enabling knowledge connectors.'; + function kbConnectorNotLoaded(c) { return !!c.enabled && !_kcState.runtime; } + function kbConnectorNotLoadedHtml() { + return '
    ' + escapeHtml(KC_NOT_LOADED_HINT) + ' Docs β†—
    '; + } + function kbConnectorRow(type, c, owner) { const byName = {}; (_kcState.rows || []).forEach(r => { byName[r.name] = r; }); const r = byName[c.name] || {}; - const status = r.status || (c.enabled ? 'pending' : 'disabled'); + const notLoaded = kbConnectorNotLoaded(c); + const status = notLoaded ? KC_STATUS_NOT_LOADED : (r.status || (c.enabled ? 'pending' : 'disabled')); const publish = _kcState.publish && _kcState.publish.connector === c.name ? ' Β· publish ' + (_kcState.publish.running ? 'syncing' : _kcState.publish.last_error ? 'error' : _kcState.publish.last_success ? 'ok' : 'pending') : ''; let html = '
    '; html += '
    ' + escapeHtml(c.name) + ' ' + escapeHtml(status) + '
    '; html += '
    ' + escapeHtml(c.layer || 'project') + ' Β· ' + escapeHtml(c.interval || '15m') + ' Β· last sync ' + escapeHtml(kcAgo(r.last_sync)) + ' Β· pages/facts ' + (r.pages || 0) + '/' + (r.facts || 0) + escapeHtml(publish) + '
    '; if (r.last_error) html += '
    ' + escapeHtml(r.last_error.length > 140 ? r.last_error.slice(0, 140) + '…' : r.last_error) + '
    '; - if (type === 'plaud') html += kbPlaudAuthLine(c.name) + kbPlaudPasteHtml(c.name); + if (notLoaded) html += kbConnectorNotLoadedHtml(); + else if (type === 'plaud') html += kbPlaudAuthLine(c.name) + kbPlaudPasteHtml(c.name); if (type === 'wisprflow') html += kbWisprFlowSourceLine(c); html += '
    '; if (owner) { + const disabledAttr = ' disabled title="' + escapeHtml(KC_NOT_LOADED_HINT) + '"'; html += '
    '; - if (type === 'plaud') html += kbPlaudAuthButtons(c.name); - html += ''; + if (type === 'plaud') html += kbPlaudAuthButtons(c.name, notLoaded ? disabledAttr : ''); + html += ''; html += ''; html += ''; html += '
    '; @@ -48883,13 +49328,15 @@ else text = 'Plaud: not connected' + (a.message ? ' β€” ' + escapeHtml(a.message) : ''); return '
    ' + text + '
    '; } - function kbPlaudAuthButtons(name) { + // disabledAttr, when set, is appended to every button (connector not loaded, #18093). + function kbPlaudAuthButtons(name, disabledAttr) { const a = _kbPlaudAuth[name] || {}; const n = escapeHtml(name); + const off = disabledAttr || ''; let html = ''; - if (a.connected) html += ''; - else html += ''; - html += ''; + if (a.connected) html += ''; + else html += ''; + html += ''; return html; } async function kbPlaudAuthLoad(name, refresh) { @@ -48902,10 +49349,22 @@ _kbPlaudAuth[name] = { error: e.message }; } if (document.querySelector('[data-kb-plaud-auth="' + CSS.escape(name) + '"]')) kbRenderIntegrationsModal(); + return _kbPlaudAuth[name]; + } + // One-line summary of a sign-in state for the Check toast (hivecommons/hive#18092). + function kbPlaudAuthSummary(a) { + if (a.connected) return 'connected' + (a.account ? ' as ' + a.account : ''); + if (a.pending) return 'waiting for authorization' + (a.auth_url ? ' β€” open the link on the card' : ''); + return 'not connected' + (a.message ? ' β€” ' + a.message : ''); } + // Check: re-query the sign-in state and always report the outcome, so a + // 404 "no knowledge connectors are configured" reads as an error toast + // with the server's message verbatim, not as silence (hivecommons/hive#18092). async function kbPlaudRefreshAuth(name) { showToast('Checking Plaud sign-in…', 'info'); - await kbPlaudAuthLoad(name, true); + const a = (await kbPlaudAuthLoad(name, true)) || {}; + if (a.error) showToast('Check failed: ' + a.error, 'error'); + else showToast('Plaud ' + name + ': ' + kbPlaudAuthSummary(a), a.connected ? 'success' : 'info'); } async function kbPlaudConnect(name) { try { @@ -59026,6 +59485,7 @@

    Configure LiteLLM

    function closeConfigDialog() { _configModalOpen = false; + if (typeof webhookSetupReset === 'function') webhookSetupReset(); // Invalidate any in-flight load so a late reply cannot repopulate a // dialog the operator has already dismissed. _configLoadSeq++; @@ -59617,7 +60077,7 @@

    Configure LiteLLM

    } // The Forge App tab renders a shell synchronously, then loads the // credential inventory (fingerprints only) from the spoke. - if (tabId === 'Forge App') loadForgeApps(); + if (tabId === 'Forge App') { loadForgeApps(); loadWebhookSetup(); } // The Features tab's Linear Agent panel loads install/session state // from its own endpoint (owner-only; the render is a static shell). if (tabId === 'Features') loadLinearAgentStatus(); @@ -61365,7 +61825,8 @@

    Configure LiteLLM

    when its forge matches the forge this hive's repos live on; everything else is read-only. Private keys appear as fingerprints β€” key material never leaves the hive.
    -
    Loading forge Apps…
    `; +
    Loading forge Apps…
    + ${renderGovWebhooksSection()}`; } async function loadForgeApps() { @@ -62802,6 +63263,226 @@

    Advisory stats

    function contribRepoMatchingDisabledEntries(repoFull, disabled) { return (disabled || []).filter(function(entry) { return contribRepoDisabledEntryMatches(repoFull, entry); }); } + /* Settings β†’ Forge App β†’ GitHub webhooks (hivecommons/hive#18017). + Owner-facing setup for /api/webhook/github: the exact payload URL, + the events to tick, a per-hive secret the owner generates here and + sees once, optional App-side hook creation, and the live receiver + status. Hosted spokes get deliveries through the hub relay with no + owner action, so for them the section is a status readout. */ + + let _webhookSetup = null; + let _webhookSecretOnce = ''; + let _webhookConfirmRegenerate = false; + + function renderGovWebhooksSection() { + return ` +
    GitHub webhooks
    +
    + Webhooks let GitHub tell this hive about pushes, PRs, reviews and checks as they + happen: faster kicks, fewer API calls, a slower safety-net cadence. + Owner guide β†— +
    +
    Loading webhook status…
    `; + } + + function webhookDocsHref() { + return (_webhookSetup && _webhookSetup.docs_url) || 'https://github.com/hivecommons/hive/blob/v5/src/docs/webhooks.md#setting-up-webhooks-owner-guide'; + } + + async function loadWebhookSetup() { + const host = document.getElementById('webhook-setup-host'); + if (!host) return; + try { + const res = await fetch('/api/config/github/webhooks'); + if (res.status === 403) { + host.innerHTML = '
    Owner access required to view webhook setup.
    '; + return; + } + if (!res.ok) throw new Error('HTTP ' + res.status); + _webhookSetup = await res.json(); + renderWebhookSetup(); + } catch (e) { + host.innerHTML = `
    Failed to load webhook status: ${esc(e.message)}
    `; + } + } + + function webhookStatusChip(d) { + const h = d.health || {}; + if (d.verification === 'none') return chip('not configured', 'muted'); + if (h.events_1h > 0 || h.healthy) return chip(`receiving Β· ${h.events_1h || 0}/1h`, 'ok'); + if (h.last_event_at) return chip('quiet Β· last ' + webhookAgo(h.last_event_at), 'warn'); + return chip('no events yet', 'warn'); + function chip(text, tone) { + return `${esc(text)}`; + } + } + + function webhookAgo(iso) { + const t = Date.parse(iso); + if (!t) return 'β€”'; + const s = Math.max(0, Math.round((Date.now() - t) / 1000)); + if (s < 60) return s + 's ago'; + if (s < 3600) return Math.round(s / 60) + 'm ago'; + if (s < 86400) return Math.round(s / 3600) + 'h ago'; + return Math.round(s / 86400) + 'd ago'; + } + + function webhookCopyBtn(text, label) { + return ``; + } + + async function webhookCopy(btn) { + const text = btn && btn.dataset.copy || ''; + const ok = await writeClipboardText(text); + showToast(ok ? 'Copied' : 'Could not copy β€” select the text and copy it by hand', ok ? 'info' : 'error'); + } + + function renderWebhookSetup() { + const host = document.getElementById('webhook-setup-host'); + const d = _webhookSetup; + if (!host || !d) return; + const h = d.health || {}; + const row = (label, valueHtml) => forgeAppRow(label, valueHtml); + const code = (t) => `${esc(t)}`; + const relayHeld = !!d.hosted; + const direct = d.secret_source !== ''; + + let html = `
    +
    + Receiver status + ${webhookStatusChip(d)} +
    + ${row('Verification', code(d.verification || 'none') + (relayHeld && !direct ? ' β€” hub relay; the GitHub App installation delivers through the hub, nothing to set up' : ''))} + ${row('Last event', h.last_event_at ? esc(formatDashboardDateTime(h.last_event_at)) + ' (' + esc(webhookAgo(h.last_event_at)) + ')' : 'none yet')} + ${row('Deliveries (1h)', esc(String(h.events_1h || 0)) + ' Β· ' + esc(String(h.invalidations_1h || 0)) + ' cache patches Β· ' + esc(String(h.ci_events_1h || 0)) + ' CI events')} + ${row('Cadence', code(h.mode || 'polling') + (h.mode_reason ? ' ' + esc(h.mode_reason) + '' : ''))} + ${(!h.last_event_at && d.verification !== 'none') ? '
    No events yet β€” on GitHub open the webhook (Settings β†’ Webhooks β†’ your hook β†’ Recent deliveries) and check the response code: 401 means the secret differs, 404 means the path is wrong, 503 means this hive has no secret.
    ' : ''} +
    `; + + html += `
    +
    ${relayHeld ? 'Direct webhook (optional)' : 'Set up a webhook on GitHub'}
    + ${relayHeld ? '
    This hive already receives deliveries the hub relays from the GitHub App installation. A direct webhook with your own secret is only needed for repositories the App is not installed on.
    ' : ''} +
    1. Secret
    + ${renderWebhookSecretBlock(d)} +
    2. Add the webhook in GitHub
    +
    Organization (one hook for every repo) β†’ Settings β†’ Webhooks β†’ Add webhook, or per repository under the repo's Settings.
    + ${row('Payload URL', d.payload_url ? code(d.payload_url) + webhookCopyBtn(d.payload_url) + ' (' + esc(d.payload_url_source) + ')' : 'unknown β€” set dashboard.public_url to this hive\u2019s public origin; the path is ' + esc(d.path) + '')} + ${row('Content type', code(d.content_type))} + ${row('Secret', 'the value from step 1')} + ${row('SSL verification', 'keep enabled')} + ${row('Events', '"Let me select individual events", then tick:
    ' + (d.events || []).map(e => code(e)).join(' ') + webhookCopyBtn((d.events || []).join(', '), 'Copy list'))} + ${row('Optional', Object.entries(d.optional_events || {}).map(([k, v]) => code(k) + ' β€” ' + esc(v) + '').join('
    '))} + ${renderWebhookCreateBlock(d)} +
    `; + host.innerHTML = html; + } + + function renderWebhookSecretBlock(d) { + const code = (t) => `${esc(t)}`; + let html = ''; + if (_webhookSecretOnce) { + html += `
    +
    New secret β€” shown once
    +
    ${code(_webhookSecretOnce)}${webhookCopyBtn(_webhookSecretOnce)}
    +
    Paste it into the GitHub webhook\u2019s Secret field. The hive keeps only the file; closing this dialog forgets the value.
    +
    `; + } + if (d.secret_source === 'env') { + html += `
    Configured from GITHUB_WEBHOOK_SECRET (a Secret mount). Use that same value in GitHub; rotate it in the deployment, not here.
    `; + return html; + } + const state = d.secret_source === 'file' + ? `Configured Β· stored at ${esc(d.secret_path || '')}${_webhookSecretOnce ? '' : ' (value not shown again β€” regenerate if lost)'}` + : 'No secret yet β€” generate one. Without it every delivery is rejected with 503.'; + const label = _webhookConfirmRegenerate ? 'Confirm: replace secret' : (d.secret_source === 'file' ? 'Regenerate secret' : 'Generate secret'); + const cls = _webhookConfirmRegenerate ? 'btn-danger' : 'btn-primary'; + html += `
    + ${state} + + ${_webhookConfirmRegenerate ? 'GitHub hooks using the old secret will start failing with 401 until you update them.' : ''} +
    `; + return html; + } + + function renderWebhookCreateBlock(d) { + if (d.app_probe_error) { + return `
    Could not read the App installation\u2019s permissions (${esc(d.app_probe_error)}); create the hook by hand as above.
    `; + } + if (!d.app_org_hooks && !d.app_repo_hooks) { + return `
    To have the App create the hook for you, grant it Organization β†’ Webhooks (organization_hooks) or Repository β†’ Webhooks (repository_hooks) at Read and write, then reopen this tab.
    `; + } + const scopes = []; + if (d.app_org_hooks) scopes.push(``); + if (d.app_repo_hooks) (d.repos || []).forEach(r => scopes.push(``)); + const disabled = !d.create_available; + const why = !d.secret_configured ? 'generate a secret first' : (!d.payload_url ? 'set dashboard.public_url first' : ''); + return `
    +
    Or let the App create it
    +
    + + + ${why ? 'β€” ' + esc(why) + '' : 'uses the App installation\u2019s hooks permission; subscribes the events above with this hive\u2019s secret'} +
    +
    +
    `; + } + + function webhookCancelRegenerate() { + _webhookConfirmRegenerate = false; + renderWebhookSetup(); + } + + async function webhookGenerateSecret(btn) { + if (_webhookSetup && _webhookSetup.secret_source === 'file' && !_webhookConfirmRegenerate) { + _webhookConfirmRegenerate = true; + renderWebhookSetup(); + return; + } + _webhookConfirmRegenerate = false; + if (btn) btn.disabled = true; + try { + const res = await fetch('/api/config/github/webhooks/secret', { method: 'POST' }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || ('HTTP ' + res.status)); + _webhookSecretOnce = body.secret || ''; + showToast('Webhook secret generated β€” copy it now, it is shown once', 'info'); + await loadWebhookSetup(); + } catch (e) { + showToast('Could not generate secret: ' + e.message, 'error'); + if (btn) btn.disabled = false; + } + } + + async function webhookCreateHook(btn) { + const sel = document.getElementById('webhook-create-scope'); + const out = document.getElementById('webhook-create-result'); + const v = (sel && sel.value) || 'organization'; + const [scope, repo] = v.split(':'); + if (btn) btn.disabled = true; + if (out) out.textContent = 'Creating…'; + try { + const res = await fetch('/api/config/github/webhooks/create', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ scope, repo: repo || '' }) + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error || ('HTTP ' + res.status)); + const verb = body.created === false ? 'Already set up:' : 'Created'; + if (out) out.innerHTML = `${verb} ${esc(body.scope)} hook #${esc(String(body.id || body.hook_id))} on ${esc(body.target)}. View on GitHub β†— β€” push a commit or comment on a PR and watch β€œDeliveries (1h)” move.`; + showToast(body.created === false ? 'Webhook already exists' : 'Webhook created', 'info'); + } catch (e) { + if (out) out.innerHTML = `${esc(e.message)}`; + } finally { + if (btn) btn.disabled = false; + } + } + + /* Forget the one-time secret whenever the Settings dialog closes so it + never lingers in a re-rendered tab. */ + function webhookSetupReset() { + _webhookSecretOnce = ''; + _webhookConfirmRegenerate = false; + } function renderGovHub(hub) { const dashUrl = hub.dashboard_url || window.location.origin || ''; const snapUrl = hub.snapshot_url || (hub.auto_snapshot && dashUrl ? dashUrl.replace(/\/$/, '') + '/snapshot' : ''); @@ -69143,6 +69824,24 @@

    Agent Watchdog

    options.map(o => { const v = Array.isArray(o) ? o[0] : o, l = Array.isArray(o) ? o[1] : (o || '\u2014'); return ``; }).join('') + ''; } + // The column's chevron icon (hivecommons/hive#18033): a preview of what + // the Activity Monitor draws, a pick-list of the allowlisted ids with + // the label's default first, and a box for a single emoji. Both write + // spec.columns[id].icon through wfEdSetColumnIcon, which refuses + // anything outside the allowlist. + function wfEdColumnIconField(c, base, dis) { + if (typeof stageIconFor !== 'function') return ''; + const cur = String(c.icon == null ? '' : c.icon).trim(); + const def = stageIconIdForName(c.label || c.id); + const isEmoji = !!cur && !stageIconIsId(cur); + const ids = Object.keys(STAGE_ICON_SET); + const options = [['', 'default \u00b7 ' + def]].concat(ids.map(id => [id, id])); + const preview = `${stageIconHtml(stageIconFor(c), 'ham-ico ham-stage-ico')}`; + const select = ``; + const emoji = ``; + return wfEdField('Icon', `
    ${preview}${select}${emoji}
    `, 'drawn in this column\u2019s chevrons; blank = by label'); + } + function renderWfEdInspector(st) { const m = st.model, spec = m.spec, sel = st.selection; const can = wfEdCanEdit(); @@ -69193,6 +69892,7 @@

    Agent Watchdog

    wfEdField('Id', `
    `) + wfEdField('Label', wfEdText(base + '.label', c.label, dis)) + wfEdField('Hint', wfEdText(base + '.hint', c.hint, dis), 'shown as the lane\u2019s tooltip in the Activity Monitor') + + wfEdColumnIconField(c, base, dis) + `
    states here need no exit
    ` + wfEdField('Width', ``) + `

    ${(c.states || []).length} state${(c.states || []).length === 1 ? '' : 's'}

    ` + @@ -69764,6 +70464,19 @@

    Agent Watchdog

    wfEdCommit(wfEdUpdate(_wfEd.model, path, isNaN(n) || n <= 0 ? '' : n)); } function wfEdFieldSetBool(path, checked) { wfEdCommit(wfEdUpdate(_wfEd.model, path, checked ? true : '')); } + // An icon is '' (by label), an allowlisted id or one emoji; anything + // else is refused with a toast and the inspector redraws the last good + // value, so no markup ever reaches a chevron (hivecommons/hive#18033). + function wfEdSetColumnIcon(path, value) { + if (!_wfEd.model) return; + const v = String(value == null ? '' : value).trim(); + if (typeof stageIconIsValid === 'function' && !stageIconIsValid(v)) { + if (typeof showToast === 'function') showToast('Icon must be one of the built-in icon ids or a single emoji.', 'error'); + wfEdRefresh(['inspector']); + return; + } + wfEdCommit(wfEdUpdate(_wfEd.model, path, v), { parts: ['canvas', 'inspector', 'drawer'] }); + } function wfEdSetList(path, value) { wfEdCommit(wfEdUpdate(_wfEd.model, path, String(value || '').split(/[,\s]+/).map(s => s.trim()).filter(Boolean))); } function wfEdSetStateColumn(id, columnId) { wfEdCommit(wfEdMoveState(_wfEd.model, id, columnId === WF_ED_UNASSIGNED ? '' : columnId)); } function wfEdSetInitial(id) { wfEdCommit(wfEdUpdate(_wfEd.model, 'spec.initial', id)); } @@ -72454,17 +73167,31 @@

    ${esc(s.title)}

    .concat(rvSelected.filter(n => !rvBuiltinNames.includes(n))) .filter((n, i, a) => a.indexOf(n) === i); const rvIsOn = (name) => rvSelected.length === 0 ? rvBuiltinNames.includes(name) || rvCustomNames.includes(name) : rvSelected.includes(name); + // Linked to a repo file (review.perspectives_source): the set shown is + // the linked one and every control is read-only, because the API + // refuses inline edits the next reload would replace (#17989). + const rvLinked = rv.perspectives_linked === true; + const rvSrc = rv.perspectives_source || {}; + const rvSrcLabel = `${rvSrc.owner || ''}/${rvSrc.repo || ''}/${rvSrc.path || ''}${rvSrc.ref ? '@' + rvSrc.ref : ''}`; + const rvSrcStatus = rv.perspectives_source_status || ''; + const rvSrcURL = /^https:\/\/github\.com\//.test(rv.perspectives_source_url || '') ? rv.perspectives_source_url : ''; + const rvLinkedBanner = !rvLinked ? '' : `
    +
    Linked to ${rvSrcURL ? `${esc(rvSrcLabel)} ↗` : `${esc(rvSrcLabel)}`} ${yamlHint('review.perspectives_source')} — read-only here; change the perspectives in that file, or remove the link from hive.yaml to edit them inline.
    +
    ${rvSrcStatus === 'unresolved' ? 'Not resolved yet (repo unreachable, not allowlisted, or invalid) — the inline perspectives below are in effect.' : `Status: ${esc(rvSrcStatus)}`}
    + ${rv.perspectives_source_warning ? `
    ⚠ ${esc(rv.perspectives_source_warning)}
    ` : ''} +
    `; + const rvLockAttr = rvLinked ? ' disabled' : ''; const rvPerspectiveRow = (name, defaultFocus) => { const custom = rvPrompts[name] || ''; const isCustom = !rvBuiltinNames.includes(name); return `
    -
    `; }; const rvRec = rv.recommendations || {}; @@ -72762,13 +73489,14 @@

    ${esc(s.title)}

    Which questions the reviewer asks of every PR, and what each one looks for. Edit the text to tell this hive's reviewer what matters in your repos — e.g. name the conventions doc for style. Blank means the built-in. Unchecking a perspective stops it running and stops its verdicts being accepted.

    + ${rvLinkedBanner} ${rvBuiltin.map(b => rvPerspectiveRow(b.name, b.focus)).join('')} ${rvCustomNames.map(n => rvPerspectiveRow(n, '')).join('')} -
    + ${rvLinked ? '' : `
    -

    A new perspective needs focus text before it can be saved. Names: lowercase letters, digits, single dashes.

    +

    A new perspective needs focus text before it can be saved. Names: lowercase letters, digits, single dashes.

    `}
    Reviewers
    diff --git a/src/pkg/dashboard/status_builder.go b/src/pkg/dashboard/status_builder.go index daa1480968..0eac1ac40e 100644 --- a/src/pkg/dashboard/status_builder.go +++ b/src/pkg/dashboard/status_builder.go @@ -449,6 +449,7 @@ func BuildFrontendStatus( Governor: buildGovernorWithLaneDepths(govState, cfg, buildLaneQueueDepths(cfg, actionable), apiBudgetFromRateLimits(ghRateLimits)), Tokens: buildTokens(tokenCollector), Repos: repos, + ReferenceRepos: buildReferenceRepos(cfg), OverviewKPIIncomplete: overviewKPIIncomplete(cfg, actionable) || overviewStaleSince(repos) != "", StaleSince: earliestRFC3339(staleSinceRFC3339(actionable), overviewStaleSince(repos)), RestartRefresh: restartRefreshInfo(actionable, repos), @@ -2501,6 +2502,20 @@ func buildRepos(cfg *config.Config, actionable *github.ActionableResult, govStat return buildReposWithForge(cfg, actionable, govState, nil) } +// buildReferenceRepos lists project.reference_repos with their tier for the +// dashboard's minimal reference cards (hivecommons/hive#18015). +func buildReferenceRepos(cfg *config.Config) []FrontendReferenceRepo { + if cfg == nil || len(cfg.Project.ReferenceRepos) == 0 { + return nil + } + out := make([]FrontendReferenceRepo, 0, len(cfg.Project.ReferenceRepos)) + for _, rr := range cfg.Project.ReferenceRepos { + tier := rr.WriteLabel() + out = append(out, FrontendReferenceRepo{Full: rr.Repo, Tier: tier, ReferenceTier: tier}) + } + return out +} + func buildReposWithForge(cfg *config.Config, actionable *github.ActionableResult, govState governor.State, forge forgeOpenCounter) []FrontendRepo { repos := make([]FrontendRepo, 0, len(cfg.Project.Repos)) repoRows := make(map[string]string, len(cfg.Project.Repos)*2) diff --git a/src/pkg/dashboard/twitch_link_test.go b/src/pkg/dashboard/twitch_link_test.go index 0ad4f8aa70..d5d462a828 100644 --- a/src/pkg/dashboard/twitch_link_test.go +++ b/src/pkg/dashboard/twitch_link_test.go @@ -12,7 +12,7 @@ func TestTwitchLiveStreamLinkInDashboardShell(t *testing.T) { for _, want := range []string{ `Watch live on Twitch`, - `Watch the hive live on Twitch.

    `, + `Watch the hive live on Twitch.`, } { if !strings.Contains(html, want) { t.Fatalf("index.html missing %q", want) diff --git a/src/pkg/dashboard/your_turn_ui_11566_test.go b/src/pkg/dashboard/your_turn_ui_11566_test.go index 6ffc366b98..a25f29c75b 100644 --- a/src/pkg/dashboard/your_turn_ui_11566_test.go +++ b/src/pkg/dashboard/your_turn_ui_11566_test.go @@ -273,7 +273,7 @@ const location = { search: '', hash: '' }; function getComputedStyle() { return { getPropertyValue() { return ''; } }; } const historyData = []; function getHistoryTimes() { return []; } function sparkSvg() { return ''; } let _yourTurnMenu = null; -let _repoResizeDrag = null, _repoOrderDrag = null, _overviewProjectFilterKey = '', _overviewProjectFilterCount = null, _overviewLastRepos = []; +let _repoResizeDrag = null, _repoOrderDrag = null, _overviewProjectFilterKey = '', _overviewProjectFilterCount = null, _overviewLastRepos = [], _repoHoldLayoutSig = null, _repoHoldShiftAt = 0; function marker(repo, kind, number, variant, tip) { const el = mkEl('marker-' + variant + '-' + repo + number, 'button'); el.dataset = { ytRepo: repo, ytKind: kind, ytNumber: String(number), ytVariant: variant, ytTip: tip }; diff --git a/src/pkg/evidence/benchmarks/convert.go b/src/pkg/evidence/benchmarks/convert.go index 6f52f14ab5..f3f1fcf001 100644 --- a/src/pkg/evidence/benchmarks/convert.go +++ b/src/pkg/evidence/benchmarks/convert.go @@ -7,6 +7,7 @@ import ( "time" "github.com/hivecommons/hive/pkg/evidence" + "github.com/hivecommons/hive/pkg/modelfamily" ) // Options parameterise one export. @@ -65,12 +66,6 @@ var kindHints = []struct{ hint, kind string }{ {"dep", "dependency-update"}, {"renovate", "dependency-update"}, {"triage", "triage"}, {"refactor", "refactor"}, } -var providerPrefixes = []struct{ prefix, provider string }{ - {"claude", "anthropic"}, {"gpt", "openai"}, {"o1", "openai"}, {"o3", "openai"}, {"o4", "openai"}, - {"gemini", "google"}, {"granite", "ibm"}, {"llama", "meta"}, {"mistral", "mistral"}, - {"qwen", "alibaba"}, {"deepseek", "deepseek"}, {"grok", "xai"}, {"kimi", "moonshot"}, -} - // Convert folds bundles per PR and emits an author record for each PR a hive // agent opened and a reviewer record for each PR the swarm scored. Output is // sorted by id so exports are byte-stable. @@ -133,7 +128,7 @@ func Convert(bundles []*evidence.Bundle, opts Options) []Record { name = "agent" } rec.Agent = Agent{Name: name, Role: RoleAuthor, Harness: harnessOf(a.Backend)} - rec.Model = Model{Provider: providerOf(a.Model), Name: strings.TrimSpace(a.Model)} + rec.Model = modelOf(a.Model) rec.SandboxRuntime = runtimeOf(opts, a, name) rec.Task.Kind = kindOf(a.TaskKind, name) rec.Cost = costOf(opts.CostLookup, authorCostRefs(latest)) @@ -143,7 +138,7 @@ func Convert(bundles []*evidence.Bundle, opts Options) []Record { rec := base rec.ID = recordID(hash, latest.Repo, latest.Number, RoleReviewer) rec.Agent = Agent{Name: reviewSwarmAgent, Role: RoleReviewer, Harness: harnessOf(backend)} - rec.Model = Model{Provider: providerOf(model), Name: model} + rec.Model = modelOf(model) rec.SandboxRuntime = SandboxRuntimeNone rec.Task.Kind = kindPRReview rec.Cost = costOf(opts.CostLookup, prCostRefs(latest.Repo, latest.Number, costKindPR)) @@ -367,17 +362,15 @@ func harnessOf(backend string) string { return harnessOther } -func providerOf(model string) string { - m := strings.ToLower(strings.TrimSpace(model)) - if p, _, ok := strings.Cut(m, "/"); ok && p != "" { - return p - } - for _, pp := range providerPrefixes { - if strings.HasPrefix(m, pp.prefix) { - return pp.provider - } +// modelOf keeps the reported id and adds its canonical reading so one model +// aggregates under one row whichever CLI reported it. +func modelOf(reported string) Model { + r := modelfamily.Resolve(reported, "") + provider := r.Vendor + if provider == modelfamily.Unknown { + provider = providerUnknown } - return providerUnknown + return Model{Provider: provider, Name: strings.TrimSpace(reported), Canonical: r.Canonical, Effort: r.Effort} } // kindOf prefers the task kind stamped on the bundle and otherwise guesses diff --git a/src/pkg/evidence/benchmarks/convert_test.go b/src/pkg/evidence/benchmarks/convert_test.go index 6bef3b93b8..b9c91c8e27 100644 --- a/src/pkg/evidence/benchmarks/convert_test.go +++ b/src/pkg/evidence/benchmarks/convert_test.go @@ -331,3 +331,20 @@ func TestConvertEdgeCases(t *testing.T) { t.Errorf("qualityOf with nothing to report = %+v, want nil", got) } } + +func TestConvertCanonicalisesReportedModel(t *testing.T) { + bundles := fixtureBundles() + bundles[1].Author.Model = "openai-codex/gpt-5.6-sol:high" + for i := range bundles[1].Verdicts { + bundles[1].Verdicts[i].Model = "claude-sonnet-5-5" + } + recs := Convert(bundles, testOptions()) + a := byRole(recs, "hivecommons/hive", RoleAuthor) + if a == nil || a.Model.Name != "openai-codex/gpt-5.6-sol:high" || a.Model.Canonical != "gpt-5.6-sol" || a.Model.Provider != "openai" || a.Model.Effort != "high" { + t.Errorf("author model not canonicalised: %+v", a) + } + r := byRole(recs, "hivecommons/hive", RoleReviewer) + if r == nil || r.Model.Name != "claude-sonnet-5-5" || r.Model.Canonical != "claude-sonnet-5.5" || r.Model.Provider != "anthropic" || r.Model.Effort != "" { + t.Errorf("reviewer model not canonicalised: %+v", r) + } +} diff --git a/src/pkg/evidence/benchmarks/record.go b/src/pkg/evidence/benchmarks/record.go index 875297febd..9e985f1f3a 100644 --- a/src/pkg/evidence/benchmarks/record.go +++ b/src/pkg/evidence/benchmarks/record.go @@ -68,10 +68,16 @@ type Agent struct { Harness string `json:"harness"` } -// Model is the model the agent ran on. +// Model is the model the agent ran on. Name is the id exactly as the +// harness reported it; Canonical folds the spellings different CLIs use for +// one model ("claude-sonnet-5-5", "anthropic/claude-sonnet-5.5" β†’ +// "claude-sonnet-5.5", modelfamily.Resolve) so the leaderboard shows one +// row per model, and Provider is the vendor derived the same way. type Model struct { - Provider string `json:"provider"` - Name string `json:"name"` + Provider string `json:"provider"` + Name string `json:"name"` + Canonical string `json:"canonical,omitempty"` + Effort string `json:"effort,omitempty"` } // Task describes the unit of work. diff --git a/src/pkg/evidence/benchmarks/testdata/result.schema.json b/src/pkg/evidence/benchmarks/testdata/result.schema.json index 87f9edd6bb..a946e89d0d 100644 --- a/src/pkg/evidence/benchmarks/testdata/result.schema.json +++ b/src/pkg/evidence/benchmarks/testdata/result.schema.json @@ -142,12 +142,16 @@ "type": "string", "description": "Exact model id as reported by the harness (evidence author.model)." }, + "canonical": { + "type": "string", + "description": "Canonical model id the reported name folds to (pkg/modelfamily.Resolve): lowercase, CLI/gateway prefix and reasoning-effort suffix stripped, version separators as dots, e.g. claude-sonnet-5.5; 'auto' for a runtime auto-select." + }, "version": { "type": "string" }, "effort": { "type": "string", - "description": "Reasoning effort / thinking budget label if the harness exposes one." + "description": "Reasoning effort / thinking budget label if the harness exposes one (low, medium, high, xhigh, max); split off the model id by the exporter." } } }, diff --git a/src/pkg/fleetreport/slogfeed/slogfeed.go b/src/pkg/fleetreport/slogfeed/slogfeed.go index 5a9507e578..3375f2902e 100644 --- a/src/pkg/fleetreport/slogfeed/slogfeed.go +++ b/src/pkg/fleetreport/slogfeed/slogfeed.go @@ -115,7 +115,7 @@ func Key(msg string) string { } func (h *Handler) feed(r slog.Record) { - if canceledError(r, h.attrs) || expectedKickHold(r) || rateLimitedError(r) || sentinelPacingSkip(r) || upstreamUnavailableError(r) || failClosedStatus500(r) || hubHeartbeatUnavailable(r) || hubHeartbeatCachedFallback(r) || reviewCommentCapped(r) || inferenceBlockedStall(r) || automergeMergeGuardSkip(r) || reviewRetryServerError(r) || stageDampenerSuppression(r) || signedCommitOwnershipSkip(r) { + if canceledError(r, h.attrs) || expectedKickHold(r) || rateLimitedError(r) || sentinelPacingSkip(r) || upstreamUnavailableError(r) || failClosedStatus500(r) || hubHeartbeatUnavailable(r) || hubHeartbeatCachedFallback(r) || reviewCommentCapped(r) || inferenceBlockedStall(r) || automergeMergeGuardSkip(r) || reviewRetryServerError(r) || stageDampenerSuppression(r) || signedCommitOwnershipSkip(r) || claudeCredentialRebridged(r) { return } class := fleetreport.ClassUnclassifiedRepeated @@ -143,6 +143,23 @@ func signedCommitOwnershipSkip(r slog.Record) bool { return r.Level == slog.LevelWarn && strings.TrimSpace(r.Message) == "signed-commit reconciler: skipped; the PR keeps an unsigned commit" } +// claudeCredentialRebridgedMessage is the WARN crux/agent logs after it has +// SUCCESSFULLY restored a per-agent Claude credential symlink that a token +// refresh replaced (src/crux/agent/claude_credential_bridge.go). Keep the two +// strings in sync. +const claudeCredentialRebridgedMessage = "claude credential bridge: a private credential file had replaced the shared-login symlink; re-bridged" + +// claudeCredentialRebridged is the credential bridge's completed repair. Claude +// Code renames a fresh file over the shared-login symlink on every token +// refresh, every agent shares one access token and so refreshes within the same +// minute or two, and the watchdog checks each link every 5s: a burst of one +// re-bridge per agent is the designed recovery, already recorded in the audit +// log, not a defect (hivecommons/hive#18107). The WARN stays in local logs; a +// failed or refused repair logs a different message and stays reportable. +func claudeCredentialRebridged(r slog.Record) bool { + return r.Level == slog.LevelWarn && strings.TrimSpace(r.Message) == claudeCredentialRebridgedMessage +} + // canceledError applies the existing cancellation exclusion to structured // errors too. An accompanying different error must remain reportable. func canceledError(r slog.Record, bound []slog.Attr) bool { diff --git a/src/pkg/fleetreport/slogfeed/slogfeed_test.go b/src/pkg/fleetreport/slogfeed/slogfeed_test.go index c3cfd32828..992b0df8ba 100644 --- a/src/pkg/fleetreport/slogfeed/slogfeed_test.go +++ b/src/pkg/fleetreport/slogfeed/slogfeed_test.go @@ -84,6 +84,24 @@ func TestSignedCommitOwnershipSkipStaysInLogsWithoutFleetReport(t *testing.T) { } } +func TestClaudeCredentialRebridgeStaysInLogsWithoutFleetReport(t *testing.T) { + logger, seen, out := newFeed(t) + for range 6 { + logger.Warn(claudeCredentialRebridgedMessage, "agent", "reviewer", "trigger", "watchdog", "kept", "shared") + } + if len(*seen) != 0 { + t.Fatalf("completed re-bridges produced observations: %+v", *seen) + } + if n := strings.Count(out.String(), "re-bridged"); n != 6 { + t.Fatalf("local logs lost the re-bridge warning: got %d, want 6: %s", n, out.String()) + } + logger.Warn("claude credential bridge: repair failed", "agent", "reviewer", "error", "restore credential symlink: file exists") + logger.Error(claudeCredentialRebridgedMessage, "agent", "reviewer") + if len(*seen) != 2 || (*seen)[0].class != fleetreport.ClassUnclassifiedRepeated || (*seen)[1].class != fleetreport.ClassUnclassifiedRepeated { + t.Fatalf("failed repairs must remain reportable: %+v", *seen) + } +} + func TestCanceledStageDigestStaysLocal(t *testing.T) { logger, seen, out := newFeed(t) const msg = "stage labels: check digest failed" diff --git a/src/pkg/github/app.go b/src/pkg/github/app.go index 321f4bceea..c0d09e6e35 100644 --- a/src/pkg/github/app.go +++ b/src/pkg/github/app.go @@ -873,7 +873,9 @@ func installationPermissionMap(p *gh.InstallationPermissions) map[string]string put("contents", p.GetContents()) put("issues", p.GetIssues()) put("metadata", p.GetMetadata()) + put(PermissionOrganizationHooks, p.GetOrganizationHooks()) put("pull_requests", p.GetPullRequests()) + put(PermissionRepositoryHooks, p.GetRepositoryHooks()) put("secrets", p.GetSecrets()) put("vulnerability_alerts", p.GetVulnerabilityAlerts()) put("workflows", p.GetWorkflows()) diff --git a/src/pkg/github/automerge/automerge_contributor_unsearchable.go b/src/pkg/github/automerge/automerge_contributor_unsearchable.go index ee9714616b..eab10b38e9 100644 --- a/src/pkg/github/automerge/automerge_contributor_unsearchable.go +++ b/src/pkg/github/automerge/automerge_contributor_unsearchable.go @@ -17,8 +17,12 @@ import ( // TTL lets a repaired repository or account come back without a restart. const contributorUnsearchableTTL = time.Hour -// contributorUnsearchableMessage is logged once per (author, repository) -// pair per process at WARN, then at Debug. +// contributorUnsearchableMessage is logged at WARN only for a refusal whose +// author and searched repository have both not been warned for yet in this +// process, then at Debug. Keying the WARN by pair let one invisible +// repository warn once for every contributor (or one unsearchable author once +// for every hive repository), which the self-report classifier counted as a +// repeated unclassified error (hivecommons/hive#18059). const contributorUnsearchableMessage = "contributor-lane auto-promotion search cannot see repository for this author; leaving it out of the hive-wide count" // searchQueryUnsearchable reports whether err is GitHub's 422 Validation @@ -45,20 +49,31 @@ func (c *Engine) contributorSearchSkips(author string, ref contributorRepoRef) b } // noteContributorUnsearchable records that GitHub refused to search ref for -// author, so the next contributorUnsearchableTTL of sweeps leave it out. The -// first refusal for a pair logs at WARN; a refusal again after the TTL logs -// at Debug, so a permanently unsearchable pair is not a repeated warning. +// author, so the next contributorUnsearchableTTL of sweeps leave it out. A +// refusal logs at WARN only when neither its author nor its searched +// repository has warned before in this process; every other refusal β€” +// another author for the same invisible repository, another repository for +// the same unsearchable author, or the same pair again after the TTL β€” logs +// at Debug, so the warnings stay bounded by the distinct causes rather than +// growing with authors Γ— repositories. func (c *Engine) noteContributorUnsearchable(owner, repo, author string, ref contributorRepoRef, err error) { key := contributorUnsearchableKey(author, ref) + authorKey := "author:" + strings.ToLower(author) + repoKey := "repo:" + strings.ToLower(ref.Owner+"/"+ref.Repo) c.contributorUnsearchableMu.Lock() if c.contributorUnsearchable == nil || len(c.contributorUnsearchable) > 4096 { c.contributorUnsearchable = map[string]time.Time{} } - _, seen := c.contributorUnsearchable[key] + if c.contributorUnsearchableWarned == nil || len(c.contributorUnsearchableWarned) > 4096 { + c.contributorUnsearchableWarned = map[string]bool{} + } c.contributorUnsearchable[key] = c.clock().Add(contributorUnsearchableTTL) + warned := c.contributorUnsearchableWarned[authorKey] || c.contributorUnsearchableWarned[repoKey] + c.contributorUnsearchableWarned[authorKey] = true + c.contributorUnsearchableWarned[repoKey] = true c.contributorUnsearchableMu.Unlock() args := []any{"repo", owner + "/" + repo, "author", author, "searched_repo", ref.Owner + "/" + ref.Repo, "retry_after", contributorUnsearchableTTL, "error", err} - if seen { + if warned { c.debug(contributorUnsearchableMessage, args...) return } diff --git a/src/pkg/github/automerge/automerge_contributor_unsearchable_test.go b/src/pkg/github/automerge/automerge_contributor_unsearchable_test.go index 6fbd911e47..f6c345a610 100644 --- a/src/pkg/github/automerge/automerge_contributor_unsearchable_test.go +++ b/src/pkg/github/automerge/automerge_contributor_unsearchable_test.go @@ -3,6 +3,8 @@ package automerge import ( "bytes" "context" + "errors" + "fmt" "log/slog" "strings" "testing" @@ -79,3 +81,68 @@ func TestContributorMergedReposRemembersUnsearchableRepository(t *testing.T) { t.Fatalf("warning should name the refused repository:\n%s", out) } } + +// Regression for hivecommons/hive#18059: one author refused for several +// repositories, and several authors refused for the same repository, warn +// once in total instead of once per (author, repository) pair β€” the pair +// product flooded WARN (15 in 10m on one hive) and self-reported as an +// unclassified repeated error. Every refusal is still remembered per pair. +func TestContributorUnsearchableWarnsOncePerAuthorAndRepository(t *testing.T) { + fx := validContributorFixture() + fx.unsearchable = map[string]bool{"acme/docs": true, "acme/homebrew-hive": true} + api := newContributorLaneAPI(t, &fx) + defer api.Close() + var logs bytes.Buffer + clock := &contributorLaneClock{at: time.Date(2026, 11, 13, 3, 0, 0, 0, time.UTC)} + engine := newContributorLaneEngineRepos(api.URL, []string{"widget", "hive", "docs", "homebrew-hive"}, defaultContributorLanePolicy(), clock, Options{ + Logger: slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelWarn})), + }) + authors := []string{contributorLaneTestAuthor, "thatguywob", "another-dev"} + for _, author := range authors { + engine.contributorMergedRepos(context.Background(), "acme", "widget", author) + } + + out := logs.String() + if n := strings.Count(out, contributorUnsearchableMessage); n != 1 { + t.Fatalf("unsearchable warning logged %d times for %d authors Γ— 2 refused repositories, want 1:\n%s", n, len(authors), out) + } + for _, author := range authors { + for _, refused := range []string{"docs", "homebrew-hive"} { + if !engine.contributorSearchSkips(author, contributorRepoRef{Owner: "acme", Repo: refused}) { + t.Fatalf("refusal of acme/%s for %s was not remembered", refused, author) + } + } + } +} + +// The pair TTL map and the WARN-dedup map are each bounded at 4096 entries: +// a map holding exactly 4096 is kept, and only one past the cap is reset, so +// the cap is the documented size and not one entry fewer. +func TestNoteContributorUnsearchableResetsOnlyPastCap(t *testing.T) { + const capacity = 4096 + engine := &Engine{ + contributorUnsearchable: map[string]time.Time{}, + contributorUnsearchableWarned: map[string]bool{}, + } + for i := 0; i < capacity; i++ { + engine.contributorUnsearchable[fmt.Sprintf("filler-%d", i)] = time.Time{} + engine.contributorUnsearchableWarned[fmt.Sprintf("filler-%d", i)] = true + } + refusal := errors.New("422") + + engine.noteContributorUnsearchable("acme", "widget", "first-dev", contributorRepoRef{Owner: "acme", Repo: "docs"}, refusal) + if got := len(engine.contributorUnsearchable); got != capacity+1 { + t.Fatalf("pair map at the cap: len = %d, want %d (kept, new pair added)", got, capacity+1) + } + if got := len(engine.contributorUnsearchableWarned); got != capacity+2 { + t.Fatalf("warned map at the cap: len = %d, want %d (kept, author and repo added)", got, capacity+2) + } + + engine.noteContributorUnsearchable("acme", "widget", "second-dev", contributorRepoRef{Owner: "acme", Repo: "wiki"}, refusal) + if got := len(engine.contributorUnsearchable); got != 1 { + t.Fatalf("pair map past the cap: len = %d, want 1 (reset, new pair added)", got) + } + if got := len(engine.contributorUnsearchableWarned); got != 2 { + t.Fatalf("warned map past the cap: len = %d, want 2 (reset, author and repo added)", got) + } +} diff --git a/src/pkg/github/automerge/automerge_sweep.go b/src/pkg/github/automerge/automerge_sweep.go index 5f52d52026..19c78ef9d6 100644 --- a/src/pkg/github/automerge/automerge_sweep.go +++ b/src/pkg/github/automerge/automerge_sweep.go @@ -355,8 +355,12 @@ type Engine struct { // contributorUnsearchable maps author|owner/repo to the time until which // contributorMergedRepos leaves that pair out of its search, after // GitHub refused to search it (see automerge_contributor_unsearchable.go). - contributorUnsearchableMu sync.Mutex - contributorUnsearchable map[string]time.Time + // contributorUnsearchableWarned holds the "author:" and + // "repo:" keys a refusal has already warned for, so the + // author Γ— repository product logs WARN at most once per side. + contributorUnsearchableMu sync.Mutex + contributorUnsearchable map[string]time.Time + contributorUnsearchableWarned map[string]bool mergerAuthzMu sync.RWMutex // humanReturnChecked remembers, per PR key, the head whose worn diff --git a/src/pkg/github/automerge/automerge_sweep_cancellation_test.go b/src/pkg/github/automerge/automerge_sweep_cancellation_test.go new file mode 100644 index 0000000000..8de5fd12c3 --- /dev/null +++ b/src/pkg/github/automerge/automerge_sweep_cancellation_test.go @@ -0,0 +1,57 @@ +package automerge + +import ( + "bytes" + "context" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestContributorSweepPassCancellation(t *testing.T) { + for _, tc := range []struct { + name string + cancel bool + }{ + {name: "generation canceled", cancel: true}, + {name: "github failure"}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var requests atomic.Int32 + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if tc.cancel { + cancel() + <-r.Context().Done() + return + } + http.Error(w, "upstream failure", http.StatusInternalServerError) + })) + defer api.Close() + + var logs bytes.Buffer + clock := &contributorLaneClock{at: time.Date(2026, 10, 15, 13, 0, 0, 0, time.UTC)} + engine := newContributorLaneEngine(api.URL, defaultContributorLanePolicy(), clock, Options{ + Logger: slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelWarn})), + }) + result := engine.sweepPass(ctx, 1, false, nil, "tick") + if requests.Load() == 0 { + t.Fatal("contributor lane did not attempt to list pull requests") + } + failed := strings.Contains(logs.String(), "contributor-lane automerge sweep failed") + if tc.cancel { + if result != nil || failed || strings.Contains(logs.String(), "stage labels reconcile failed") { + t.Fatalf("canceled pass: result=%+v logs=%s", result, logs.String()) + } + } else if result == nil || !failed { + t.Fatalf("GitHub failure must remain visible: result=%+v logs=%s", result, logs.String()) + } + }) + } +} diff --git a/src/pkg/github/automerge/automerge_sweep_loop.go b/src/pkg/github/automerge/automerge_sweep_loop.go index ddf710f4a7..9c553614e9 100644 --- a/src/pkg/github/automerge/automerge_sweep_loop.go +++ b/src/pkg/github/automerge/automerge_sweep_loop.go @@ -777,7 +777,8 @@ func (c *Engine) StartSelfAuthoredAutoMergeSweep(ctx context.Context, maxMerges // sweepPass runs the three lanes once, in order, sharing one merge budget, // then reconciles stage labels. targets narrows an event-driven pass to the // PRs a webhook named (nil: everything); cause names what started the pass -// for the log. It returns nil when the self-authored lane failed outright +// for the log. It returns nil when the self-authored lane failed outright or +// the contributor lane was interrupted by cancellation of the sweep context // (its callers then keep the current cadence). func (c *Engine) sweepPass(ctx context.Context, maxMerges int, acmmAllowed bool, targets SweepTargets, cause string) *AutoMergeSweepResult { c.sweepClock.begin(cause, targets, c.now()) @@ -826,6 +827,12 @@ func (c *Engine) sweepPass(ctx context.Context, maxMerges int, acmmAllowed bool, if remaining > 0 && c.currentContributorLanePolicy().Enabled { laneResult, laneErr := c.SweepContributorLaneAutoMerges(ctx, AutoMergeSweepOptions{MaxMerges: remaining, Targets: targets}) if laneErr != nil { + // A client hand-over or shutdown cancels the whole sweep. Do not + // report that expected interruption as a failed contributor pass, + // or start stage reconciliation on the canceled context. + if ctx.Err() == context.Canceled && errors.Is(laneErr, context.Canceled) { + return nil + } c.warn("contributor-lane automerge sweep failed", "error", laneErr, "cause", cause) if isRateLimited(laneErr) { c.refreshRateLimitCache(ctx) diff --git a/src/pkg/github/close_on_merge.go b/src/pkg/github/close_on_merge.go index 79e92216cc..6a00c6d08b 100644 --- a/src/pkg/github/close_on_merge.go +++ b/src/pkg/github/close_on_merge.go @@ -193,6 +193,17 @@ func closeOnMergeClaimSettled(claim IssueClaim, now time.Time, recheck time.Dura return now.Sub(claim.CloseOnMergeSettledAt) < recheck } +// closeOnMergePRUnreadable reports a terminal read failure: the PR is gone or +// this identity cannot see it (404/403), so retrying every tick is noise. +func closeOnMergePRUnreadable(err error) bool { + var ghErr *gh.ErrorResponse + if !errors.As(err, &ghErr) || ghErr.Response == nil { + return false + } + code := ghErr.Response.StatusCode + return code == http.StatusNotFound || code == http.StatusForbidden +} + // closeOnMergeClaimMergedAt is the instant the sweep treats as the claim's // merge time for MergedAfter filtering: MergedAt, else the first observation. func closeOnMergeClaimMergedAt(claim IssueClaim) time.Time { @@ -298,6 +309,11 @@ func (c *Client) CloseOnMergeLedgerSweep(ctx context.Context, ledger *ClaimLedge if opts.Logger != nil { opts.Logger.Warn("close-on-merge: could not read merged PR", "repo", claim.PRRepo, "pr", claim.PRNumber, "error", err) } + // 404/403 will not heal on the next tick: remember it so the + // warning repeats at most once per recheck window. + if closeOnMergePRUnreadable(err) && ledger.MarkCloseOnMergeSettled(claim.Repo, claim.Issue, claim.PRNumber, now) { + sweep.Marked++ + } continue } res := c.closeIssueForMergedPRClaimWithIssue(ctx, claim.Repo, claim.Issue, pr, true, opts, issue, closeOnMergeListedChildren(openIssueChildren[key], claim.Issue)) diff --git a/src/pkg/github/close_on_merge_paths_test.go b/src/pkg/github/close_on_merge_paths_test.go index 876f73592d..db7bddda1f 100644 --- a/src/pkg/github/close_on_merge_paths_test.go +++ b/src/pkg/github/close_on_merge_paths_test.go @@ -110,6 +110,10 @@ func TestCloseOnMergeForLedgerFiltersAndCloses(t *testing.T) { w.WriteHeader(http.StatusInternalServerError) return } + if strings.HasSuffix(r.URL.Path, "/404") { + w.WriteHeader(http.StatusNotFound) + return + } _ = json.NewEncoder(w).Encode(map[string]any{ "number": 42, "title": "fix", "body": "Fixes #7", "merged": true, "merged_at": "2026-10-09T12:00:00Z", "merge_commit_sha": "abcdef0123456789", @@ -121,6 +125,7 @@ func TestCloseOnMergeForLedgerFiltersAndCloses(t *testing.T) { _ = json.NewEncoder(w).Encode([]map[string]any{ {"number": 7, "state": "open", "body": "body", "labels": []map[string]string{}}, {"number": 10, "state": "open", "body": "body", "labels": []map[string]string{}}, + {"number": 14, "state": "open", "body": "body", "labels": []map[string]string{}}, // An open PR is listed by the issues API but never matches a claim. {"number": 12, "state": "open", "pull_request": map[string]string{"url": "x"}}, }) @@ -158,6 +163,8 @@ func TestCloseOnMergeForLedgerFiltersAndCloses(t *testing.T) { {Repo: "o/r", Issue: 9, PRNumber: 44, PRRepo: "o/r", MergedPR: true, Reference: true}, // Merged claim whose PR cannot be read: logged and skipped. {Repo: "o/r", Issue: 10, PRNumber: 500, PRRepo: "o/r", MergedPR: true}, + // Merged claim whose PR is 404: warned once, then settled. + {Repo: "o/r", Issue: 14, PRNumber: 404, PRRepo: "o/r", MergedPR: true}, // Merged claim missing a PR number: skipped. {Repo: "o/r", Issue: 11, PRNumber: 0, PRRepo: "o/r", MergedPR: true}, } { @@ -185,8 +192,19 @@ func TestCloseOnMergeForLedgerFiltersAndCloses(t *testing.T) { if len(patched) != 1 { t.Fatalf("patched = %v, want one close", patched) } - if len(prGets) != 2 { - t.Fatalf("PR GETs = %v, want /pulls/42 and /pulls/500 only", prGets) + if len(prGets) != 3 { + t.Fatalf("PR GETs = %v, want /pulls/42, /pulls/500 and /pulls/404 only", prGets) + } + // Second sweep: the 404 claim is settled and not re-read; the 500 one is. + prGets = nil + c.CloseOnMergeForLedger(context.Background(), ledger, CloseOnMergeOptions{ + Identity: HiveIdentity{AppLogin: "hive[bot]"}, + Logger: logger, + }) + for _, p := range prGets { + if strings.HasSuffix(p, "/404") { + t.Fatalf("404 PR re-read on second sweep: %v", prGets) + } } if !strings.Contains(logBuf.String(), "could not read merged PR") { t.Fatalf("unreadable PR was not logged: %s", logBuf.String()) diff --git a/src/pkg/github/prclaims_reopen_test.go b/src/pkg/github/prclaims_reopen_test.go index 2fd8906cd6..651cfc234c 100644 --- a/src/pkg/github/prclaims_reopen_test.go +++ b/src/pkg/github/prclaims_reopen_test.go @@ -233,8 +233,13 @@ func TestReconcileReopenedIssuesVoidsClaimFromTimeline(t *testing.T) { } func TestReopenedClaimPersistsAcrossLoad(t *testing.T) { + // Pin the clock near the fixture dates: with the wall clock, the merged + // claim ages past settledClaimRetention 30 days after reopenTestMerged and + // the reload prunes it, which is retention working, not a reopen bug. + now := func() time.Time { return reopenTestReopened.Add(time.Hour) } path := filepath.Join(t.TempDir(), "ledger.json") ledger := NewClaimLedger(path, testLogger()) + ledger.SetClock(now) ledger.Reconcile([]IssueClaim{reopenTestClaim()}, true) if !ledger.MarkIssueReopened("r", 7, reopenTestReopened) { t.Fatal("setup: reopen not recorded") @@ -242,10 +247,7 @@ func TestReopenedClaimPersistsAcrossLoad(t *testing.T) { if err := ledger.Save(); err != nil { t.Fatal(err) } - // Load with a clock fixed just after the reopen: the fixture's merge date - // is fixed, so on the wall clock the settled claim's retention runs out - // and the load drops the claim this test is about. - reloaded, err := loadClaimLedgerAt(path, testLogger(), func() time.Time { return reopenTestReopened.Add(time.Hour) }) + reloaded, err := loadClaimLedgerAt(path, testLogger(), now) if err != nil { t.Fatal(err) } diff --git a/src/pkg/github/reference_repo_token.go b/src/pkg/github/reference_repo_token.go new file mode 100644 index 0000000000..7000ddfc47 --- /dev/null +++ b/src/pkg/github/reference_repo_token.go @@ -0,0 +1,190 @@ +package github + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + "strings" + "sync" + "time" + + gh "github.com/google/go-github/v72/github" + + "github.com/hivecommons/hive/pkg/config" +) + +// Reference-repo write credentials (hivecommons/hive#18015). +// +// A project.reference_repos entry that opted in to writes needs a credential +// GitHub actually honours for THAT repo. The hive's own installation token +// cannot be it: an App installation is per-account, and a reference repo is by +// definition outside the hive's org. Two sources are supported, in the order +// an operator reaches for them: +// +// - the same App installed on the target org β€” the hive discovers that +// installation and mints a token on it, scoped to the one listed repo and +// to exactly the permissions the opt-in needs; +// - write_token_file β€” an operator-supplied token (a maintainer PAT, a +// fine-grained token) read from disk on each use so a rotation is picked up +// without a restart. +// +// Boot refuses a write-mode entry that has neither (see +// VerifyReferenceRepoWriteCredential) so the failure is a config error with +// the repo named, not a 401 a day later. + +// referenceTokenRefreshMargin is how long before expiry a minted reference +// token is replaced. +const referenceTokenRefreshMargin = 5 * time.Minute + +// ErrReferenceRepoNoCredential is the sentinel for a write-mode entry with no +// usable credential source. +var ErrReferenceRepoNoCredential = errors.New("reference repo write-mode has no credential") + +// referenceRepoPermissions are the installation permissions an opt-in needs: +// metadata always, issues:write for issues, contents:write + pull_requests:write +// for prs (a PR needs a branch to be pushed first). Nothing wider β€” the proxy +// narrows further to the exact write paths. +func referenceRepoPermissions(rr config.ReferenceRepo) *gh.InstallationPermissions { + perms := &gh.InstallationPermissions{Metadata: gh.Ptr("read"), Contents: gh.Ptr("read")} + if rr.WritesIssues() { + perms.Issues = gh.Ptr("write") + } + if rr.WritesPRs() { + perms.Contents = gh.Ptr("write") + perms.PullRequests = gh.Ptr("write") + } + return perms +} + +// ReferenceRepoTokenSource returns a token function for the proxy: the +// write_token_file when set, else a cached installation token minted on the +// target org. auth may be replaced on key rotation, so it is taken through a +// getter rather than captured. +func ReferenceRepoTokenSource(rr config.ReferenceRepo, auth func() *AppAuth, logger *slog.Logger) func() (string, bool) { + if rr.ReadOnly() { + return nil + } + if rr.WriteTokenFile != "" { + return func() (string, bool) { + tok, err := readTokenFile(rr.WriteTokenFile) + if err != nil { + logger.Warn("reference repo write token unreadable", "repo", rr.Repo, "file", rr.WriteTokenFile, "error", err) + return "", false + } + return tok, true + } + } + var ( + mu sync.Mutex + token string + expiry time.Time + ) + return func() (string, bool) { + mu.Lock() + defer mu.Unlock() + if token != "" && time.Now().Add(referenceTokenRefreshMargin).Before(expiry) { + return token, true + } + a := auth() + if a == nil { + logger.Warn("reference repo write token: no App auth available", "repo", rr.Repo) + return "", false + } + ctx, cancel := context.WithTimeout(context.Background(), mintClientTimeout) + defer cancel() + tok, exp, err := a.MintReferenceRepoToken(ctx, rr) + if err != nil { + logger.Warn("reference repo write token mint failed", "repo", rr.Repo, "error", err) + return "", false + } + token, expiry = tok, exp + return token, true + } +} + +// MintReferenceRepoToken mints an installation token on the TARGET org's +// installation of this App, scoped to rr's single repository and to the +// permissions its write opt-in needs. +func (a *AppAuth) MintReferenceRepoToken(ctx context.Context, rr config.ReferenceRepo) (string, time.Time, error) { + if a == nil { + return "", time.Time{}, ErrNoGitHubClient + } + installationID, err := a.DiscoverInstallationID(ctx, rr.Owner()) + if err != nil { + return "", time.Time{}, fmt.Errorf("%w: %s: %v", ErrReferenceRepoNoCredential, rr.Repo, err) + } + opts := &gh.InstallationTokenOptions{ + Repositories: []string{rr.Name()}, + Permissions: referenceRepoPermissions(rr), + } + return a.mintInstallationTokenWithOptions(ctx, installationID, opts) +} + +func readTokenFile(path string) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + return "", err + } + tok := strings.TrimSpace(string(b)) + if tok == "" { + return "", fmt.Errorf("%s is empty", path) + } + return tok, nil +} + +// VerifyReferenceRepoWriteCredential is the boot-time check for one write-mode +// entry: the write_token_file must be readable and non-empty, or the App must +// have an installation on the target org. It returns the login the credential +// acts as β€” the App bot login, or the token's user β€” for the proxy's own-item +// check. discover and whoAmI are seams: production passes the App's installation +// discovery and a GET /user for file tokens. +func VerifyReferenceRepoWriteCredential(ctx context.Context, rr config.ReferenceRepo, botLogin string, discover func(ctx context.Context, org string) (int64, error), whoAmI func(ctx context.Context, token string) (string, error)) (string, error) { + if rr.ReadOnly() { + return "", nil + } + if rr.WriteTokenFile != "" { + tok, err := readTokenFile(rr.WriteTokenFile) + if err != nil { + return "", fmt.Errorf("%w: project.reference_repos %s: write_token_file %s: %v", ErrReferenceRepoNoCredential, rr.Repo, rr.WriteTokenFile, err) + } + if whoAmI == nil { + return "", fmt.Errorf("%w: project.reference_repos %s: cannot resolve the write_token_file identity", ErrReferenceRepoNoCredential, rr.Repo) + } + login, err := whoAmI(ctx, tok) + if err != nil || login == "" { + return "", fmt.Errorf("%w: project.reference_repos %s: write_token_file %s is not a usable GitHub token (%v)", ErrReferenceRepoNoCredential, rr.Repo, rr.WriteTokenFile, err) + } + return login, nil + } + if discover == nil { + return "", fmt.Errorf("%w: project.reference_repos %s: write: %v needs the GitHub App installed on %s (or write_token_file), and this hive has no App auth", ErrReferenceRepoNoCredential, rr.Repo, rr.Write, rr.Owner()) + } + if _, err := discover(ctx, rr.Owner()); err != nil { + return "", fmt.Errorf("%w: project.reference_repos %s: write: %v needs the GitHub App installed on %s (or write_token_file): %v", ErrReferenceRepoNoCredential, rr.Repo, rr.Write, rr.Owner(), err) + } + if botLogin == "" { + return "", fmt.Errorf("%w: project.reference_repos %s: App bot login unknown, cannot attribute the hive's own items", ErrReferenceRepoNoCredential, rr.Repo) + } + return botLogin, nil +} + +// TokenUserLogin resolves the login a user token acts as (GET /user). +func TokenUserLogin(ctx context.Context, token, apiURL string) (string, error) { + user, _, err := newTokenClient(token, apiURL).Users.Get(ctx, "") + if err != nil { + return "", err + } + return user.GetLogin(), nil +} + +// BotLogin returns the App bot login recorded by SetBotLogin ("" when unknown). +func (a *AppAuth) BotLogin() string { + if a == nil { + return "" + } + a.mu.Lock() + defer a.mu.Unlock() + return a.botLogin +} diff --git a/src/pkg/github/reference_repo_token_test.go b/src/pkg/github/reference_repo_token_test.go new file mode 100644 index 0000000000..0024f336aa --- /dev/null +++ b/src/pkg/github/reference_repo_token_test.go @@ -0,0 +1,120 @@ +package github + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/hivecommons/hive/pkg/config" +) + +func TestVerifyReferenceRepoWriteCredential(t *testing.T) { + ctx := context.Background() + dir := t.TempDir() + tokFile := filepath.Join(dir, "tok") + if err := os.WriteFile(tokFile, []byte("ghp_x\n"), 0o600); err != nil { + t.Fatal(err) + } + emptyFile := filepath.Join(dir, "empty") + if err := os.WriteFile(emptyFile, []byte(" \n"), 0o600); err != nil { + t.Fatal(err) + } + installed := func(_ context.Context, org string) (int64, error) { + if org == "hivecommons" { + return 42, nil + } + return 0, ErrNoInstallationForOrg + } + whoAmI := func(_ context.Context, token string) (string, error) { + if token == "ghp_x" { + return "nate", nil + } + return "", errors.New("bad credentials") + } + + cases := []struct { + name string + rr config.ReferenceRepo + botLogin string + discover func(context.Context, string) (int64, error) + wantLogin string + wantErr bool + }{ + {name: "read-only needs nothing", rr: config.ReferenceRepo{Repo: "hivecommons/docs"}, wantLogin: ""}, + {name: "app installed on target org", rr: config.ReferenceRepo{Repo: "hivecommons/docs", Write: []string{"issues"}}, botLogin: "hive[bot]", discover: installed, wantLogin: "hive[bot]"}, + {name: "app not installed, no token file", rr: config.ReferenceRepo{Repo: "other/docs", Write: []string{"issues"}}, botLogin: "hive[bot]", discover: installed, wantErr: true}, + {name: "no app auth at all", rr: config.ReferenceRepo{Repo: "hivecommons/docs", Write: []string{"prs"}}, wantErr: true}, + {name: "token file wins over installation", rr: config.ReferenceRepo{Repo: "other/docs", Write: []string{"issues", "prs"}, WriteTokenFile: tokFile}, discover: installed, wantLogin: "nate"}, + {name: "empty token file", rr: config.ReferenceRepo{Repo: "other/docs", Write: []string{"issues"}, WriteTokenFile: emptyFile}, wantErr: true}, + {name: "missing token file", rr: config.ReferenceRepo{Repo: "other/docs", Write: []string{"issues"}, WriteTokenFile: filepath.Join(dir, "nope")}, wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + login, err := VerifyReferenceRepoWriteCredential(ctx, tc.rr, tc.botLogin, tc.discover, whoAmI) + if tc.wantErr { + if err == nil { + t.Fatalf("want error, got login %q", login) + } + if !errors.Is(err, ErrReferenceRepoNoCredential) { + t.Errorf("error should wrap ErrReferenceRepoNoCredential: %v", err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if login != tc.wantLogin { + t.Errorf("login = %q, want %q", login, tc.wantLogin) + } + }) + } +} + +func TestReferenceRepoTokenSource_File(t *testing.T) { + tokFile := filepath.Join(t.TempDir(), "tok") + if err := os.WriteFile(tokFile, []byte("first\n"), 0o600); err != nil { + t.Fatal(err) + } + rr := config.ReferenceRepo{Repo: "hivecommons/docs", Write: []string{"issues"}, WriteTokenFile: tokFile} + src := ReferenceRepoTokenSource(rr, func() *AppAuth { return nil }, testLogger()) + if tok, ok := src(); !ok || tok != "first" { + t.Fatalf("got %q,%v want first,true", tok, ok) + } + // Rotation is picked up without a restart. + if err := os.WriteFile(tokFile, []byte("second"), 0o600); err != nil { + t.Fatal(err) + } + if tok, ok := src(); !ok || tok != "second" { + t.Fatalf("got %q,%v want second,true", tok, ok) + } + if err := os.Remove(tokFile); err != nil { + t.Fatal(err) + } + if _, ok := src(); ok { + t.Fatal("removed token file should yield ok=false") + } + if ReferenceRepoTokenSource(config.ReferenceRepo{Repo: "a/b"}, nil, testLogger()) != nil { + t.Fatal("read-only entry must have no token source") + } +} + +func TestReferenceRepoTokenSource_NoAuth(t *testing.T) { + rr := config.ReferenceRepo{Repo: "hivecommons/docs", Write: []string{"prs"}} + src := ReferenceRepoTokenSource(rr, func() *AppAuth { return nil }, testLogger()) + if _, ok := src(); ok { + t.Fatal("no App auth must yield ok=false, not a credential") + } +} + +func TestReferenceRepoPermissions(t *testing.T) { + p := referenceRepoPermissions(config.ReferenceRepo{Repo: "a/b", Write: []string{"issues"}}) + if p.GetIssues() != "write" || p.GetContents() != "read" || p.GetPullRequests() != "" { + t.Errorf("issues-only perms wrong: issues=%q contents=%q prs=%q", p.GetIssues(), p.GetContents(), p.GetPullRequests()) + } + p = referenceRepoPermissions(config.ReferenceRepo{Repo: "a/b", Write: []string{"prs"}}) + if p.GetIssues() != "" || p.GetContents() != "write" || p.GetPullRequests() != "write" { + t.Errorf("prs perms wrong: issues=%q contents=%q prs=%q", p.GetIssues(), p.GetContents(), p.GetPullRequests()) + } +} diff --git a/src/pkg/github/stage_ledger_backfill_test.go b/src/pkg/github/stage_ledger_backfill_test.go index 70d182408a..d93aa802cd 100644 --- a/src/pkg/github/stage_ledger_backfill_test.go +++ b/src/pkg/github/stage_ledger_backfill_test.go @@ -177,14 +177,22 @@ func TestBackfillCoveredByLedger_HealsRecordBelow040(t *testing.T) { c.SetIssueStageLabels(func() IssueStagePolicy { return IssueStagePolicy{Enabled: true} }) store := stagelabels.Shared() issue := c.IssueStageRef("o/r", 7) - store.Observe(issue.Key(), stagelabels.Actionable, "actionable") covered := c.IssueStageRef("o/r", 9) - store.Observe(covered.Key(), stagelabels.CoveredByPR, "open PR covers it") - t.Cleanup(func() { + unseen := c.IssueStageRef("o/r", 11) + // The store is process-wide and other tests in this package drive stage + // facts for o/r#9 / o/r#11 without forgetting them; under -shuffle a + // leaked sub-040 record for the "never seen" #11 made the backfill count + // it too (checked=2). Start from a clean slate for every key we assert on. + forget := func() { store.Forget(issue.Key()) store.Forget(covered.Key()) + store.Forget(unseen.Key()) store.Forget(c.PRStageRef("o/r", 8).Key()) - }) + } + forget() + t.Cleanup(forget) + store.Observe(issue.Key(), stagelabels.Actionable, "actionable") + store.Observe(covered.Key(), stagelabels.CoveredByPR, "open PR covers it") ledger := backfillLedger(t, IssueClaim{Repo: "r", Issue: 7, PRNumber: 8, PRRepo: "r", Closing: true}, IssueClaim{Repo: "r", Issue: 9, PRNumber: 8, PRRepo: "r", Closing: true}, diff --git a/src/pkg/github/webhook_relay_key.go b/src/pkg/github/webhook_relay_key.go index d3e712bfdf..ec4d3a61ba 100644 --- a/src/pkg/github/webhook_relay_key.go +++ b/src/pkg/github/webhook_relay_key.go @@ -40,7 +40,8 @@ const ( // Values of /api/status.webhooks.verification: how this spoke authenticates // GitHub webhook deliveries. const ( - // WebhookVerificationGitHub: GITHUB_WEBHOOK_SECRET is set and GitHub's + // WebhookVerificationGitHub: a webhook secret is configured (WebhookSecret: + // GITHUB_WEBHOOK_SECRET or the generated secret file) and GitHub's // X-Hub-Signature-256 is verified (self-hosted, or a hub relay to a spoke // that holds the secret). WebhookVerificationGitHub = "github-signed" @@ -121,7 +122,7 @@ func WebhookRelayKeyFingerprint() string { // (WebhookVerification* above). Only the mode ever leaves this function, never // the secret or the key. func WebhookVerification() string { - if WebhookSecretConfigured() { + if WebhookSecret() != "" { return WebhookVerificationGitHub } if WebhookRelayKey() != "" { diff --git a/src/pkg/github/webhook_secret.go b/src/pkg/github/webhook_secret.go new file mode 100644 index 0000000000..49d074b16a --- /dev/null +++ b/src/pkg/github/webhook_secret.go @@ -0,0 +1,133 @@ +package github + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "sync" +) + +// GitHub webhook secret for a self-hosted spoke (hivecommons/hive#18017). +// +// A self-hosted hive verifies X-Hub-Signature-256 on /api/webhook/github +// with a secret the owner also pastes into the GitHub webhook form. It comes +// from GITHUB_WEBHOOK_SECRET when the operator mounts one, otherwise from a +// 0600 file on the data volume that the dashboard's "Generate secret" button +// writes (same pattern as the hub relay key, webhook_relay_key.go). The env +// var always wins: a Secret mount is the deployment's source of truth and the +// dashboard must not pretend it can rotate it. + +const defaultWebhookSecretPath = "/data/github-webhook-secret.key" + +// Values of WebhookSecretSource: where the active webhook secret comes from. +const ( + WebhookSecretSourceEnv = "env" + WebhookSecretSourceFile = "file" + WebhookSecretSourceNone = "" +) + +// webhookSecretBytes is the entropy of a generated secret (256 bits, hex). +const webhookSecretBytes = 32 + +// ErrWebhookSecretFromEnv is returned by GenerateWebhookSecret when +// GITHUB_WEBHOOK_SECRET is set: the file would never be consulted, so writing +// one would only mislead the owner about which secret is active. +var ErrWebhookSecretFromEnv = errors.New("webhook secret is set by " + WebhookSecretEnvVar + "; rotate it there") + +var webhookSecretState = struct { + mu sync.Mutex + path string + secret string + loaded bool +}{path: defaultWebhookSecretPath} + +func webhookSecretFileLocked() string { + st := &webhookSecretState + if !st.loaded { + st.loaded = true + if data, err := os.ReadFile(st.path); err == nil { + st.secret = strings.TrimSpace(string(data)) + } + } + return st.secret +} + +// WebhookSecret is the HMAC secret this spoke verifies GitHub-signed +// deliveries with: GITHUB_WEBHOOK_SECRET, else the generated secret file, else +// "". +func WebhookSecret() string { + if v := strings.TrimSpace(os.Getenv(WebhookSecretEnvVar)); v != "" { + return v + } + webhookSecretState.mu.Lock() + defer webhookSecretState.mu.Unlock() + return webhookSecretFileLocked() +} + +// WebhookSecretSource reports where WebhookSecret comes from +// (WebhookSecretSource*). Never the secret itself. +func WebhookSecretSource() string { + if strings.TrimSpace(os.Getenv(WebhookSecretEnvVar)) != "" { + return WebhookSecretSourceEnv + } + if WebhookSecret() != "" { + return WebhookSecretSourceFile + } + return WebhookSecretSourceNone +} + +// WebhookSecretPath is the file a generated secret is persisted to. +func WebhookSecretPath() string { + webhookSecretState.mu.Lock() + defer webhookSecretState.mu.Unlock() + return webhookSecretState.path +} + +// GenerateWebhookSecret mints a fresh random secret, persists it 0600 to the +// secret file (replacing any previous generated secret) and returns it. This +// is the only time the value leaves the hive; callers show it once. It +// refuses with ErrWebhookSecretFromEnv when the env var is set. +func GenerateWebhookSecret() (string, error) { + if strings.TrimSpace(os.Getenv(WebhookSecretEnvVar)) != "" { + return "", ErrWebhookSecretFromEnv + } + b := make([]byte, webhookSecretBytes) + if _, err := rand.Read(b); err != nil { + return "", err + } + secret := hex.EncodeToString(b) + st := &webhookSecretState + st.mu.Lock() + defer st.mu.Unlock() + if err := os.MkdirAll(filepath.Dir(st.path), 0o700); err != nil { + return "", err + } + tmp := st.path + ".tmp" + if err := os.WriteFile(tmp, []byte(secret+"\n"), 0o600); err != nil { + return "", err + } + if err := os.Rename(tmp, st.path); err != nil { + return "", err + } + st.secret = secret + st.loaded = true + return secret, nil +} + +// SetWebhookSecretPathForTest points the secret store at path, forgets any +// secret held in memory, and returns a func that restores the previous state. +func SetWebhookSecretPathForTest(path string) func() { + st := &webhookSecretState + st.mu.Lock() + prevPath, prevSecret, prevLoaded := st.path, st.secret, st.loaded + st.path, st.secret, st.loaded = path, "", false + st.mu.Unlock() + return func() { + st.mu.Lock() + st.path, st.secret, st.loaded = prevPath, prevSecret, prevLoaded + st.mu.Unlock() + } +} diff --git a/src/pkg/github/webhook_secret_test.go b/src/pkg/github/webhook_secret_test.go new file mode 100644 index 0000000000..7abdc64ad9 --- /dev/null +++ b/src/pkg/github/webhook_secret_test.go @@ -0,0 +1,105 @@ +package github + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWebhookSecret_FileFallbackAndGenerate(t *testing.T) { + t.Setenv(WebhookSecretEnvVar, "") + dir := t.TempDir() + restore := SetWebhookSecretPathForTest(filepath.Join(dir, "nested", "secret.key")) + defer restore() + + if got := WebhookSecret(); got != "" { + t.Fatalf("no file yet: WebhookSecret() = %q, want empty", got) + } + if got := WebhookSecretSource(); got != WebhookSecretSourceNone { + t.Fatalf("source = %q, want none", got) + } + if got := WebhookVerification(); got == WebhookVerificationGitHub { + t.Fatalf("verification = %q before any secret", got) + } + + secret, err := GenerateWebhookSecret() + if err != nil { + t.Fatalf("GenerateWebhookSecret: %v", err) + } + if len(secret) != webhookSecretBytes*2 { + t.Fatalf("secret length %d, want %d hex chars", len(secret), webhookSecretBytes*2) + } + if got := WebhookSecret(); got != secret { + t.Fatalf("WebhookSecret() = %q, want the generated value", got) + } + if got := WebhookSecretSource(); got != WebhookSecretSourceFile { + t.Fatalf("source = %q, want file", got) + } + if got := WebhookVerification(); got != WebhookVerificationGitHub { + t.Fatalf("verification = %q, want %q", got, WebhookVerificationGitHub) + } + st, err := os.Stat(WebhookSecretPath()) + if err != nil { + t.Fatalf("stat secret file: %v", err) + } + if perm := st.Mode().Perm(); perm != 0o600 { + t.Fatalf("secret file mode %o, want 0600", perm) + } + + // A fresh process reads the persisted value back. + restore2 := SetWebhookSecretPathForTest(WebhookSecretPath()) + defer restore2() + if got := WebhookSecret(); got != secret { + t.Fatalf("reloaded secret = %q, want %q", got, secret) + } + + // Regenerating replaces it. + next, err := GenerateWebhookSecret() + if err != nil || next == secret { + t.Fatalf("regenerate: err=%v same=%v", err, next == secret) + } +} + +func TestWebhookSecret_EnvWinsAndBlocksGenerate(t *testing.T) { + restore := SetWebhookSecretPathForTest(filepath.Join(t.TempDir(), "secret.key")) + defer restore() + if _, err := GenerateWebhookSecret(); err != nil { + t.Fatalf("seed file secret: %v", err) + } + t.Setenv(WebhookSecretEnvVar, "from-env") + if got := WebhookSecret(); got != "from-env" { + t.Fatalf("WebhookSecret() = %q, want env value", got) + } + if got := WebhookSecretSource(); got != WebhookSecretSourceEnv { + t.Fatalf("source = %q, want env", got) + } + if _, err := GenerateWebhookSecret(); err != ErrWebhookSecretFromEnv { + t.Fatalf("GenerateWebhookSecret with env set: err=%v, want ErrWebhookSecretFromEnv", err) + } +} + +func TestWebhookHookPermissions(t *testing.T) { + org, repo := WebhookHookPermissions(nil) + if org || repo { + t.Fatal("nil info must grant nothing") + } + info := &InstallationInfo{Permissions: map[string]string{PermissionOrganizationHooks: "write", PermissionRepositoryHooks: "read"}} + org, repo = WebhookHookPermissions(info) + if !org || repo { + t.Fatalf("org=%v repo=%v, want org write only", org, repo) + } +} + +func TestWebBaseURLForAPI(t *testing.T) { + cases := map[string]string{ + "": "https://github.com", + "https://api.github.com": "https://github.com", + "https://ghe.example.com/api/v3": "https://ghe.example.com", + "https://ghe.example.com/api/v3/": "https://ghe.example.com", + } + for in, want := range cases { + if got := webBaseURLForAPI(in); got != want { + t.Errorf("webBaseURLForAPI(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/src/pkg/github/webhook_setup.go b/src/pkg/github/webhook_setup.go new file mode 100644 index 0000000000..0e95da85a6 --- /dev/null +++ b/src/pkg/github/webhook_setup.go @@ -0,0 +1,223 @@ +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + + gh "github.com/google/go-github/v72/github" +) + +// Owner-facing webhook setup (hivecommons/hive#18017): the events a hive +// consumes on /api/webhook/github and the App-side hook creation the +// dashboard offers when the installation may manage hooks. + +// WebhookEvents is the list of GitHub webhook events the spoke receiver acts +// on, in the order the GitHub webhook form lists them. It is what the +// Settings card tells an owner to tick, and what CreateWebhook subscribes. +// projects_v2_item is organization-scoped and only useful with the +// github_projects work source, so it is listed separately +// (WebhookEventsOptional). +var WebhookEvents = []string{ + "check_run", + "check_suite", + "issue_comment", + "issues", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "push", + "status", + "workflow_run", +} + +// WebhookEventsOptional are events the receiver understands but that only +// matter with a specific feature enabled. +var WebhookEventsOptional = map[string]string{ + "workflow_job": "CI job counts from the job payload (needs Actions: read on the App)", + "projects_v2_item": "GitHub Projects work source (governor.work_source.type: github_projects); organization webhook only", +} + +// Installation permissions that allow the App to create hooks. +const ( + PermissionOrganizationHooks = "organization_hooks" + PermissionRepositoryHooks = "repository_hooks" +) + +// WebhookHookPermissions reports which hook-management permissions the +// installation holds at write level: org-level hooks and repo-level hooks. +func WebhookHookPermissions(info *InstallationInfo) (orgHooks, repoHooks bool) { + if info == nil { + return false, false + } + return info.Granted(PermissionOrganizationHooks, "write"), info.Granted(PermissionRepositoryHooks, "write") +} + +// ErrWebhookExists is returned when GitHub already has a hook for the same +// payload URL at that scope (GitHub answers 422 "Hook already exists"). +var ErrWebhookExists = errors.New("a webhook for this payload URL already exists") + +// WebhookCreateRequest describes the hook CreateWebhook should create. +type WebhookCreateRequest struct { + // Org is the owner login; Repo is empty for an organization hook. + Org, Repo string + // PayloadURL is the spoke's public receiver URL; Secret the HMAC secret. + PayloadURL, Secret string + Events []string +} + +// WebhookCreateResult names the hook CreateWebhook created or found. +type WebhookCreateResult struct { + // ID is the GitHub hook id; HookID repeats it for the Settings card. + ID int64 `json:"id"` + HookID int64 `json:"hook_id"` + Scope string `json:"scope"` // "organization" or "repository" + Target string `json:"target"` + URL string `json:"url"` // GitHub settings page for the hook + Events []string `json:"events"` + // PayloadURL is the hook's config.url (this hive's receiver). + PayloadURL string `json:"payload_url"` + // Created is false when a hook with the same payload URL already existed + // on the target and was returned instead of creating a duplicate. + Created bool `json:"created"` +} + +// webhookListPages bounds the hook listing; GitHub caps hooks per target at +// 20 for repositories and organizations, so one page is normally enough. +const webhookListPages = 5 + +// CreateWebhook creates an organization hook (req.Repo == "") or a repository +// hook through the App installation token. It is idempotent: the target's +// hooks are listed first and a hook whose config.url equals req.PayloadURL is +// returned (Created=false) instead of creating a duplicate. The token carries +// every granted permission, so the caller checks WebhookHookPermissions first +// and only offers the action when the matching write permission is held. +func (a *AppAuth) CreateWebhook(ctx context.Context, req WebhookCreateRequest) (*WebhookCreateResult, error) { + if a == nil { + return nil, errors.New("GitHub App auth not configured") + } + if strings.TrimSpace(req.Org) == "" || strings.TrimSpace(req.PayloadURL) == "" || strings.TrimSpace(req.Secret) == "" { + return nil, errors.New("org, payload URL and secret are required") + } + token, err := a.Token(ctx) + if err != nil { + return nil, fmt.Errorf("minting installation token: %w", err) + } + client := newTokenClient(token, a.apiURL) + events := req.Events + if len(events) == 0 { + events = WebhookEvents + } + + existing, err := findWebhookByURL(ctx, client, req.Org, req.Repo, req.PayloadURL) + if err != nil { + return nil, fmt.Errorf("listing existing hooks: %w", err) + } + if existing != nil { + return a.webhookResult(req, existing, false), nil + } + + hook := &gh.Hook{ + Active: gh.Ptr(true), + Events: events, + Config: &gh.HookConfig{ + URL: gh.Ptr(req.PayloadURL), + ContentType: gh.Ptr("json"), + Secret: gh.Ptr(req.Secret), + InsecureSSL: gh.Ptr("0"), + }, + } + var ( + created *gh.Hook + resp *gh.Response + ) + if req.Repo == "" { + created, resp, err = client.Organizations.CreateHook(ctx, req.Org, hook) + } else { + created, resp, err = client.Repositories.CreateHook(ctx, req.Org, req.Repo, hook) + } + if err != nil { + if resp != nil && resp.StatusCode == http.StatusUnprocessableEntity && strings.Contains(strings.ToLower(err.Error()), "already exists") { + // Lost a race with a concurrent create: return the winner. + if found, ferr := findWebhookByURL(ctx, client, req.Org, req.Repo, req.PayloadURL); ferr == nil && found != nil { + return a.webhookResult(req, found, false), nil + } + return nil, ErrWebhookExists + } + return nil, err + } + if len(created.Events) == 0 { + created.Events = events + } + return a.webhookResult(req, created, true), nil +} + +// findWebhookByURL returns the hook on the org (repo == "") or repository +// whose config.url equals payloadURL, or nil when there is none. +func findWebhookByURL(ctx context.Context, client *gh.Client, org, repo, payloadURL string) (*gh.Hook, error) { + want := strings.TrimRight(strings.TrimSpace(payloadURL), "/") + opts := &gh.ListOptions{PerPage: 100} + for page := 0; page < webhookListPages; page++ { + var ( + hooks []*gh.Hook + resp *gh.Response + err error + ) + if repo == "" { + hooks, resp, err = client.Organizations.ListHooks(ctx, org, opts) + } else { + hooks, resp, err = client.Repositories.ListHooks(ctx, org, repo, opts) + } + if err != nil { + return nil, err + } + for _, h := range hooks { + if h.GetConfig() == nil { + continue + } + if strings.TrimRight(strings.TrimSpace(h.GetConfig().GetURL()), "/") == want { + return h, nil + } + } + if resp == nil || resp.NextPage == 0 { + return nil, nil + } + opts.Page = resp.NextPage + } + return nil, nil +} + +func (a *AppAuth) webhookResult(req WebhookCreateRequest, hook *gh.Hook, created bool) *WebhookCreateResult { + res := &WebhookCreateResult{ + ID: hook.GetID(), + HookID: hook.GetID(), + Events: append([]string(nil), hook.Events...), + PayloadURL: req.PayloadURL, + Created: created, + } + if hook.GetConfig() != nil && hook.GetConfig().GetURL() != "" { + res.PayloadURL = hook.GetConfig().GetURL() + } + base := strings.TrimSuffix(webBaseURLForAPI(a.apiURL), "/") + if req.Repo == "" { + res.Scope, res.Target = "organization", req.Org + res.URL = fmt.Sprintf("%s/organizations/%s/settings/hooks/%d", base, req.Org, res.ID) + } else { + res.Scope, res.Target = "repository", req.Org+"/"+req.Repo + res.URL = fmt.Sprintf("%s/%s/%s/settings/hooks/%d", base, req.Org, req.Repo, res.ID) + } + return res +} + +// webBaseURLForAPI maps an API URL to the web origin a hook's settings page +// lives on: api.github.com β†’ github.com, https://ghe.example/api/v3 β†’ +// https://ghe.example. +func webBaseURLForAPI(apiURL string) string { + u := strings.TrimSpace(apiURL) + if u == "" || strings.Contains(u, "api.github.com") { + return "https://github.com" + } + return strings.TrimSuffix(strings.TrimSuffix(u, "/"), "/api/v3") +} diff --git a/src/pkg/github/webhook_setup_test.go b/src/pkg/github/webhook_setup_test.go new file mode 100644 index 0000000000..2a4f86b737 --- /dev/null +++ b/src/pkg/github/webhook_setup_test.go @@ -0,0 +1,153 @@ +package github + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" +) + +// hookStub is a minimal GitHub hooks API: GET/POST on /orgs/{org}/hooks and +// /repos/{owner}/{repo}/hooks, keeping created hooks per path. +type hookStub struct { + mu sync.Mutex + hooks map[string][]map[string]any + posts map[string]int + nextID int64 + lastReq map[string]any +} + +func newHookStub() *hookStub { + return &hookStub{hooks: map[string][]map[string]any{}, posts: map[string]int{}, nextID: 100} +} + +func (s *hookStub) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + path := strings.TrimSuffix(r.URL.Path, "/") + if !strings.HasSuffix(path, "/hooks") { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + list := s.hooks[path] + if list == nil { + list = []map[string]any{} + } + _ = json.NewEncoder(w).Encode(list) + case http.MethodPost: + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + s.lastReq = body + s.posts[path]++ + s.nextID++ + hook := map[string]any{ + "id": s.nextID, + "active": body["active"], + "events": body["events"], + "config": body["config"], + } + s.hooks[path] = append(s.hooks[path], hook) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(hook) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +func (s *hookStub) postCount(path string) int { + s.mu.Lock() + defer s.mu.Unlock() + return s.posts[path] +} + +func (s *hookStub) lastBody() map[string]any { + s.mu.Lock() + defer s.mu.Unlock() + return s.lastReq +} + +func hookStubAuth(t *testing.T, stub http.Handler) *AppAuth { + t.Helper() + srv := httptest.NewServer(stub) + t.Cleanup(srv.Close) + return &AppAuth{ + cachedToken: "ghs_hook_stub", + tokenExpiry: time.Now().Add(2 * time.Hour), + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + installationID: 1, + apiURL: srv.URL + "/", + } +} + +func TestCreateWebhook_OrgCreatesThenReturnsExisting(t *testing.T) { + stub := newHookStub() + auth := hookStubAuth(t, stub) + req := WebhookCreateRequest{Org: "acme", PayloadURL: "https://hive.example.org/api/webhook/github", Secret: "s3cret"} + + res, err := auth.CreateWebhook(context.Background(), req) + if err != nil { + t.Fatalf("first create: %v", err) + } + if !res.Created || res.ID == 0 || res.ID != res.HookID || res.Scope != "organization" || res.Target != "acme" { + t.Fatalf("first result = %+v", res) + } + if len(res.Events) != len(WebhookEvents) { + t.Fatalf("events = %v, want default WebhookEvents", res.Events) + } + body := stub.lastBody() + cfg, _ := body["config"].(map[string]any) + if cfg["url"] != req.PayloadURL || cfg["content_type"] != "json" || cfg["secret"] != "s3cret" || cfg["insecure_ssl"] != "0" || body["active"] != true { + t.Fatalf("create body = %v", body) + } + + again, err := auth.CreateWebhook(context.Background(), req) + if err != nil { + t.Fatalf("second create: %v", err) + } + if again.Created || again.ID != res.ID { + t.Fatalf("second result = %+v, want existing hook %d", again, res.ID) + } + if n := stub.postCount("/orgs/acme/hooks"); n != 1 { + t.Fatalf("POST count = %d, want 1 (no duplicate)", n) + } +} + +func TestCreateWebhook_RepoScopeHitsRepoEndpoint(t *testing.T) { + stub := newHookStub() + auth := hookStubAuth(t, stub) + res, err := auth.CreateWebhook(context.Background(), WebhookCreateRequest{ + Org: "acme", Repo: "widgets", PayloadURL: "https://hive.example.org/api/webhook/github", Secret: "s3cret", + }) + if err != nil { + t.Fatalf("create: %v", err) + } + if stub.postCount("/repos/acme/widgets/hooks") != 1 || stub.postCount("/orgs/acme/hooks") != 0 { + t.Fatal("want exactly one POST, to the repo endpoint") + } + if !res.Created || res.Scope != "repository" || res.Target != "acme/widgets" || !strings.HasSuffix(res.URL, "/acme/widgets/settings/hooks/101") { + t.Fatalf("result = %+v", res) + } +} + +// A hook for another URL on the same target does not count as existing. +func TestCreateWebhook_OtherURLDoesNotMatch(t *testing.T) { + stub := newHookStub() + stub.hooks["/orgs/acme/hooks"] = []map[string]any{{"id": 7, "config": map[string]any{"url": "https://ci.example.org/hook"}}} + auth := hookStubAuth(t, stub) + res, err := auth.CreateWebhook(context.Background(), WebhookCreateRequest{Org: "acme", PayloadURL: "https://hive.example.org/api/webhook/github", Secret: "x"}) + if err != nil { + t.Fatalf("create: %v", err) + } + if !res.Created || res.ID == 7 { + t.Fatalf("result = %+v, want a new hook", res) + } +} diff --git a/src/pkg/hub/assets/dashboard.html b/src/pkg/hub/assets/dashboard.html index 7e2d532666..a85f6f6d8e 100644 --- a/src/pkg/hub/assets/dashboard.html +++ b/src/pkg/hub/assets/dashboard.html @@ -107,6 +107,8 @@ } h1 { letter-spacing: 0; font-size: clamp(2rem, 4vw, 3rem); line-height: .98; margin-bottom: .8rem; } .subtitle { color: var(--muted); font-size: 1.02rem; line-height: 1.7; margin-bottom: var(--sp-9); } + .benchmarks-pointer { margin-top: var(--sp-3); padding: var(--sp-3) var(--sp-5); border: 1px solid var(--line); border-radius: var(--r-sm); font-size: var(--fs-sm); color: var(--muted); } + .benchmarks-pointer a { color: var(--amber); font-weight: 700; } /* ── Table ── */ /* Status filter chips above the My Hives table. --chip-color is set inline @@ -764,6 +766,7 @@ API Docs + Benchmarks
    @@ -779,6 +782,7 @@

    My Hives

    Hive instances you own or have access to

    πŸ‘‹ New to Hive? Read the Getting Started Guide before diving in.

    +

    πŸ“Š See how models perform across the fleet — merge rate, CI first-try and estimated cost per PR, from real hive output. Open the benchmarks β†—