diff --git a/.superpowers/sdd/2026-09-28-dashboard-refresh/paused-time-report.md b/.superpowers/sdd/2026-09-28-dashboard-refresh/paused-time-report.md new file mode 100644 index 00000000..8682c06f --- /dev/null +++ b/.superpowers/sdd/2026-09-28-dashboard-refresh/paused-time-report.md @@ -0,0 +1,35 @@ +# V3 Activity paused-time consumer report + +## Source and contract + +- Base: `66982f22` in `feat/dashboard-refresh`. +- Read the V4 B8 producer in `agentic-kit-v4-rest` without changing it. A paused history row has `recordedAt` and `completedAt: null`; completed rows retain `completedAt`. + +## Change + +- Activity projection keeps a bounded, valid ISO `recordedAt` separately from `completedAt`, uses it to choose the latest state per source and environment, and falls back to a valid completion time. Invalid scan timestamps become `null`; unrelated metadata is not projected. +- The v2 Activity API allowlists a bounded ISO pause timestamp and omits invalid stamps and private metadata. +- The Activity history table sorts, groups, and displays by the valid recorded time or completion time. Its labels and empty copy describe scan records without claiming every scan completed. + +## Evidence + +- Red phase: guarded focused suites had 3 expected failures for missing pause time and stale latest-state selection. +- Green phase: `env -u FORCE_COLOR node scripts/run-tests.mjs exec -- --test tests/kit/maintenance-management-activity.test.mjs tests/kit/maintenance-dashboard-v2-api.test.mjs`: 58 passed, 0 failed. +- Guarded actual dashboard browser run, `env -u FORCE_COLOR node scripts/run-tests.mjs exec -- tests/ui/dashboard-ui.mjs`: 514 passed, 0 failed. The new assertion inspects actual rendered table rows for a newer paused record and older completed records. +- `./node_modules/.bin/tsc -p tsconfig.json --noEmit`: passed. +- Focused ESLint: 0 errors, one pre-existing `max-lines` warning in `maintenance-api.mjs` (file has 1021 lines, threshold 1000). +- `git diff --check`: passed. + +## Limits + +- V4 B8 producer remains on its separate branch. This change is the consumer contract only, pending integration and independent review. +- Full unit and UI suites were not repeated after the final timestamp-validation refinement; focused unit tests, lint, and typecheck passed after it. The guarded browser run covered the Activity renderer before that refinement. + +## Scoped review fix: impossible calendar dates + +- Review found that the prior ISO shape plus `Date.parse` accepted `2026-09-31T12:00:00Z`, letting a paused row outrank a real September 30 completion and render on October 1. +- The Activity projection now checks the calendar day against its month and leap year, plus clock component bounds, before accepting a scan timestamp. The v2 Activity API uses the same validator for `recordedAt`. +- New cases reject September 31 and a non-leap February 29 at both projection boundaries, retain a valid completion timestamp when the recorded time is rejected, and retain a valid leap day with an offset and fractional seconds. +- Guarded focused tests: `env -u FORCE_COLOR node scripts/run-tests.mjs exec -- --test tests/kit/maintenance-management-activity.test.mjs tests/kit/maintenance-dashboard-v2-api.test.mjs` passed 61 tests after the validator change. The final fallback assertions were added afterward and rerun before this fix commit. +- `./node_modules/.bin/tsc -p tsconfig.json --noEmit` passed. Focused ESLint had 0 errors and the existing file-length warning in `maintenance-api.mjs`. +- Browser and full suites were not repeated for this scoped validation fix; the prior guarded browser run remains the renderer evidence, with full gates assigned to integration. diff --git a/README.md b/README.md index bc502fb3..cefb605d 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ and current platform limits. | **setup** | Installs/updates ruflo + agentic-qe globally (handling npm ≥11.17's `allow-scripts` so natives build; AgentDB ships inside ruflo, so ak installs no separate copy), installs and verifies the exact Ruflo-compatible **agent-browser** native executor without adding its plugin/skills (`--no-agent-browser` disables it), installs the **RuvNet Brain** (an offline knowledge base over the rUv stack, powering the `search_ruvnet` MCP — a ~2 GB one-time download, prompted; skip with `--no-ruvnet-brain`), deploys the token-audit skill, merges the managed guidance blocks into the machine-wide guidance files (`~/.claude/CLAUDE.md`, plus `~/.codex/AGENTS.md` on codex machines), offers one-time MCP registration (user scope, with a tool-family picker), and — inside a repo — initializes the project: sanitized `ruflo init`, absolute memory-path pin, a **verified** store→disk write, statusline footer, and a background daemon with **local-only ($0) workers** (token-spending AI workers stay opt-in behind upstream's machine-wide budget). Project scope triggers on a `.git` entry in the current folder; without one it's skipped with a note. `--project` forces the same project setup in the current directory (e.g. a not-yet-`git init`-ed folder); it does not locate an ancestor repository. Project initialization runs `ruflo init --full --force` and can replace existing agent configuration, so read the [setup scope and project mutation contract](docs/setup.md) before using it on an existing project. `--minimal` skips it, `--yes` accepts all prompts (non-interactive), `--no-aqe` / `--no-agent-browser` / `--no-ruvnet-brain` / `--no-security` disable those subsystems, and `--reconfigure` re-offers MCP registration. `--codex` enables + installs the Codex host during setup (ambidextrous dual-host mode; both hosts become available for routing), and `--primary-host claude\|codex` picks which host leads (codex implies `--codex`). | | **status** | Per-subsystem ✓/⚠/✗ (versions, the kit's own version, **ruvnet-brain**, natives, **memory-pin**, security, learning, aqe/RVF, the managed **agent-browser** package/native/config/browser readiness, MCP, **hosts**, **providers**, **routing**, daemons, guidance blocks, statusline), each drift row naming what `sync` would do about it, or marking a step you must take yourself as `→ manual:` (sync never plans those) — plus a **health-history** line that flags regressions since the last sync. Browser status is filesystem-only: it never runs doctor or launches Chrome. | | **sync** | The one convergence verb: upgrades first when a new release exists, then re-heals everything an upgrade wipes, then re-checks and reports. Included in that heal: it **installs any enabled frontier host** (claude/codex/opencode) that's entirely absent — never touching an external (mise/brew/native) install — and **re-applies provider wiring** (the `ENABLE_*` host env, OpenCode's native configuration, the AQE default/fallback/agent overrides, admitted Agentic-QE 3.13.12+ `externalProviders`, and ruflo API providers) whenever it has drifted. External-provider reconciliation preserves foreign entries, refuses same-id conflicts, and prunes only entries whose exact value still matches an agentic-kit ownership receipt. On a dual-host project, sync also **seeds/heals the Claude/Codex default routing policy**. It appends a health-history snapshot, refreshes RuvNet Brain when enabled, and self-updates the kit last. A planned fix whose status row is still there afterwards is reported `unresolved:` and sync exits 1. A row whose fix you do by hand (`→ manual:`) never changes sync's exit code; a failing or warning one is listed under "needs your action". `--no-upgrade` skips self-update and package upgrades. `--skip ` (repeatable) leaves one subsystem out of this run only, including the step it owns; it is reported "skipped by request" and never counts as a failure. `--json` prints one JSON result on stdout (`plan`, `steps`, `unresolved`, `skipped`, `needsYourAction`, `converged`, `exitCode`) and sends the human lines to stderr. Model refresh/diff/plan findings remain advisory. | -| **dashboard** | Opens the local web dashboard (`127.0.0.1:7431`, localhost-only, never detaches) with five primary areas: **About · Overview · Usage · Observability · System**. Ordinary views remain observation-only. System's **Full scan** remeasures local inventory and then chains one provider check. **System → Maintenance** is the sole action surface, with four destinations: **Inventory** (scope → repository where applicable → type → resource → exact installation), **Guidance** (only outcomes the kit can ground), **Discovery** (where it looks), and **Activity** (receipts, undo, interruption audits). Every write is one exact placement and one action, with a server-derived short-lived plan, explicit confirmation, and a one-use capability. Advisory remains a measurement; the former Catalog tab redirects to Inventory and its cards now sit in System Summary. The page is self-contained, offline-first, protected by a per-session token, and never executes a browser-supplied command. Action targets resolve server-side; Discovery accepts validated source-root configuration. Full navigation and security semantics: [Dashboard guide](docs/dashboard.md); provider and recovery limits: [Maintenance runbook](docs/maintenance.md). **Auto-opens your browser** (`--no-open` for headless/SSH); `--port N` changes the port. Stop with Ctrl-C. (Also available as `ak x dashboard`.) | +| **dashboard** | Opens the local web dashboard (`127.0.0.1:7431`, localhost-only, never detaches) with five primary areas: **About · Overview · Usage · Observability · System**. Ordinary views remain observation-only. Choose **Refresh machine** in the header and press **Refresh** to remeasure the machine, refresh Maintenance evidence, and rebuild the inventory. **System → Maintenance** is the sole action surface, with four destinations: **Inventory** (scope → repository where applicable → type → resource → exact installation), **Guidance** (only outcomes the kit can ground), **Discovery** (where it looks), and **Activity** (receipts, undo, interruption audits). Every write is one exact placement and one action, with a server-derived short-lived plan, explicit confirmation, and a one-use capability. Advisory remains a measurement; the former Catalog tab redirects to Inventory and its cards now sit in System Summary. The page is self-contained, offline-first, protected by a per-session token, and never executes a browser-supplied command. Action targets resolve server-side; Discovery accepts validated source-root configuration. Full navigation and security semantics: [Dashboard guide](docs/dashboard.md); provider and recovery limits: [Maintenance runbook](docs/maintenance.md). **Auto-opens your browser** (`--no-open` for headless/SSH); `--port N` changes the port. Stop with Ctrl-C. (Also available as `ak x dashboard`.) | | **usage** | `score` and `prompts` summarize retained local transcript evidence. `status` reads provider-account analytics from cache; `refresh openrouter` explicitly contacts the OpenRouter management API using `OPENROUTER_MANAGEMENT_KEY`, then writes a credential-free mode-`0600` cache. Cache reads make no OpenRouter request. Account rows have no grounded host/session/project correlation and are never merged into transcript totals. | | **models** | Builds a private, host-scoped model inventory from Claude, Codex, OpenCode, Ollama, bounded local usage evidence, and a dated bundled record of Anthropic's public model/lifecycle facts. `status`, `diff`, `explain`, and `plan` are cache-only and read-only; `refresh --online` is the sole online-catalogue boundary. Public facts never imply account or OpenRouter routability. Swap plans enumerate routes plus Agentic QE/Ruflo consumers and print a copyable canonical action without executing it. The CLI exposes exact local evidence deliberately; the Dashboard exposes source-proven public catalogue identity and uses the owner-visible model read contract; secret-shaped values remain masked. See [Model lifecycle intelligence](docs/models.md). | | **admin** | Opens the **maintainer admin** (`127.0.0.1:7432`, localhost-only, foreground) — the project-telemetry sibling of `dashboard`, with the same dark/light visual theme and persisted theme preference: unique repo visitors and cloners (GitHub traffic API, needs a push-access token via `GITHUB_TOKEN`/`GH_TOKEN`/`gh auth token` — panels degrade honestly without one), contributors and watchers, npm download momentum (last 7d vs prior 7d, sparklines — shown as trend only, never an absolute reach number, since mirrors/CI inflate the raw count), latest CI run status and open Dependabot alerts, a **"since you last looked"** delta strip over a local baseline, open issues/PRs from others (oldest first), and external humans ranked by recency (bots excluded). Access is gated by a **per-session token** carried in the URL fragment and sent header-only; the page makes **zero external fetches** (the server proxies GitHub/npm; your credential never reaches the page or the payload). Where `dashboard` is offline-first, `admin` does deliberate GitHub/npm egress — that contract split is why they're siblings, not tabs. `--port N`, `--no-open`; Ctrl-C stops. (Also available as `ak x admin`.) | diff --git a/docs/adr/0012-observability.md b/docs/adr/0012-observability.md index 90adc04f..c095b68b 100644 --- a/docs/adr/0012-observability.md +++ b/docs/adr/0012-observability.md @@ -670,3 +670,15 @@ turns the dashboard into a fleet service nor makes telemetry collection continuo - **Exact-folder leases (#238 item 2).** A runtime lease no longer requires a Git repository when an exact-folder match joins the process to its transcript; see the 2026-08-03 runtime identity amendment above. + +### 2026-09-29 follow-up: bounded re-entry and structured-source evidence + +An idle stop retains active tailer offsets. A native transcript displaced from the newest-file +window also retains its reader state in memory, up to twice the configured file bound (at least +two dormant readers). Re-entry within that bound resumes without replaying accepted records. +After dormant eviction, re-entry reads from the start; a new dashboard process has no persisted +native offset and bootstraps existing-file metadata before following new appends. This does not +create durable exactly-once delivery. + +The optional Ruflo and agentic-qe structured live-events input is experimental. Parser fixtures +exist, but no real producer has been verified. An explicit source path does not prove activity. diff --git a/docs/adr/0025-machine-footprint-metrics.md b/docs/adr/0025-machine-footprint-metrics.md index fa112028..5dacb494 100644 --- a/docs/adr/0025-machine-footprint-metrics.md +++ b/docs/adr/0025-machine-footprint-metrics.md @@ -1,7 +1,11 @@ # ADR-0025 — Machine footprint: infrastructure metrics for install, runtime, storage, and catalog - **Status:** Implemented -- **Updated:** 2026-09-28 — CLI parity (§5) is now `ak system [--refresh[=live|machine]] +- **Updated:** 2026-09-29 — §5 deep measurement now starts with `POST /api/refresh` at + `machine` strength; GET routes are passive. The old GET-started scan rationale is withdrawn + because a read request must not start measurement work. See + [ADR-0063](0063-evidence-store-and-refresh-vocabulary.md). +- **Earlier update:** 2026-09-28 — CLI parity (§5) is now `ak system [--refresh[=live|machine]] [--project-trees] [--json]` (`src/lib/refresh.mjs`'s shared strengths, replacing the retired `--deep`); §5's `GET /api/system?refresh=deep` rationale is untouched by this branch and belongs to a later remediation program's dashboard work (remediation program, branch 6b; see @@ -322,14 +326,14 @@ silent "Other" slice into a to-do list a release can close. ([ADR-0014](0014-dashboard-auth-and-remediation.md)), and zero egress ([ADR-0007](0007-maintainer-admin-local-telemetry.md)'s offline side of the line) as every other dashboard route. -- `GET /api/system?refresh=deep` — starts or attaches to the single-flight deep scan. The - dashboard server is deliberately GET-only; a refresh is a re-*measurement* of local state, not - a mutation of user data, so it stays within that contract. `&trees=1|0` sets whether that scan - walks project working trees; it is a **measurement** parameter, not a view filter, because - trees that were never walked cannot be un-hidden client-side. +- `POST /api/refresh` with `{"strength":"machine","projectTrees":true}` starts + the staged single-flight refresh; `projectTrees` is an optional boolean measurement choice. + The earlier `GET /api/system?refresh=deep&trees=1|0` trigger and its GET-only rationale + are withdrawn: a GET must never start scan work, even when the work only measures local + state. Trees that were never walked cannot be un-hidden client-side. - `ak system [--refresh[=live|machine]] [--project-trees] [--json]` — CLI parity sharing the same collector, following the usage-scorecard precedent of one collector behind both surfaces - (`--refresh=machine` is the CLI equivalent of the `?refresh=deep` route below). + (`--refresh=machine` selects the same strength as the dashboard POST). - `GET /api/system/summary` (amendment, 2026-09-26; extended 2026-09-28) — the page's read: the same payload and parameters with `catalog`, `storage`, `install`, `projects` and `consumers` each projected to an allow-list of keys and items cut to what the page draws (including @@ -568,7 +572,8 @@ The draft left four points open. All four are decided; this section is the recor large corpus — the surprise cost is worse than a stale figure that says how stale it is. The snapshot's `asOf` is always rendered, and beyond `SNAPSHOT_STALE_AFTER_MS` (7 days) the freshness label turns amber and reads "stale, rescan". Opening the System tab issues a plain - `GET /api/system/summary`; only the Rescan control adds `?refresh=deep`. + `GET /api/system/summary`; only explicit Refresh machine starts a measurement with + `POST /api/refresh`. 4. **Windows ships a current-user census plus a best-effort true `cwd`, degrading honestly, with no dependency added.** The draft's "unsupported on win32" answer would have blanked the whole Runtime view on a supported platform. Instead `src/lib/live/win-process-survey.ps1` — a plain text diff --git a/docs/adr/0044-receipt-aware-maintenance-control-plane.md b/docs/adr/0044-receipt-aware-maintenance-control-plane.md index 63b02baa..9280e4aa 100644 --- a/docs/adr/0044-receipt-aware-maintenance-control-plane.md +++ b/docs/adr/0044-receipt-aware-maintenance-control-plane.md @@ -2,7 +2,9 @@ - **Status:** Implemented - **Date:** 2026-09-03 -- **Updated:** 2026-09-28 — the CLI verb for the explicit scan this ADR's v1 `ak maintain scan` +- **Updated:** 2026-09-29 — the dashboard explicit scan starts with `POST /api/refresh`; + the retired `GET /api/maintenance?refresh=scan` is rejected. See ADR-0063. +- **Earlier update:** 2026-09-28 — the CLI verb for the explicit scan this ADR's v1 `ak maintain scan` contract describes is now `ak maintain --refresh[=machine]` (the exact `?refresh=scan` dashboard route below is unaffected — that half of the vocabulary belongs to a later remediation program's dashboard work; see [ADR-0063](0063-evidence-store-and-refresh-vocabulary.md)) @@ -232,9 +234,9 @@ a body no larger than 64 KiB. The SSE query-token exception does not apply. The read-only interruption audit and separately confirmed single-receipt reconciliation. Plain GET /api/maintenance reads the latest persisted scan report and never polls a -provider. The exact ?refresh=scan query performs and atomically persists a provider scan; -other or duplicate query parameters are rejected. The global browser poll remains passive. A -successful persisted System deep rescan chains exactly one Maintenance scan. See ADR-0045. +provider. An explicit `POST /api/refresh` runs the provider scan as its Maintenance stage and persists +the report; `GET /api/maintenance?refresh=scan` is rejected. The global browser poll remains +passive. A successful machine measurement precedes one Maintenance scan. See ADR-0045. The view groups **Updates ready**, **Safe cleanup**, **Needs review**, **Unsupported or blocked**, and **Recent changes / Undo**. Every row exposes a direct imperative; the selected finding adds its diff --git a/docs/adr/0045-artifact-consumer-bindings-and-explicit-maintenance-scans.md b/docs/adr/0045-artifact-consumer-bindings-and-explicit-maintenance-scans.md index e7959521..5c1cef1b 100644 --- a/docs/adr/0045-artifact-consumer-bindings-and-explicit-maintenance-scans.md +++ b/docs/adr/0045-artifact-consumer-bindings-and-explicit-maintenance-scans.md @@ -2,7 +2,10 @@ - **Status:** Implemented - **Date:** 2026-09-03 -- **Updated:** 2026-09-09 — reconciled against repository source and tests for issue #211 +- **Updated:** 2026-09-29 — the explicit provider scan now starts through + `POST /api/refresh`; `GET /api/maintenance` only reads, and the retired + `?refresh=scan` GET trigger is superseded by ADR-0063. +- **Earlier update:** 2026-09-09 — reconciled against repository source and tests for issue #211 - **Earlier update:** 2026-09-04 — proposed ADR-0048 retains physical artifact and consumer-binding identity, adds exact management placements, and plans configurable/resumable discovery; current explicit provider-scan behavior remains authoritative until implementation @@ -24,9 +27,9 @@ Artifact/consumer identity and the explicit provider-scan contract remain current. The **Browser refresh / Scan now** wording below describes the v1 view; current -Maintenance uses the consolidated measurement toolbar and v2 scan routes under -ADR-0048. Neither passive report/query reads nor filesystem discovery grant -provider mutation authority. FootprintSnapshot is now v7; v6 below records the +Maintenance uses the single Refresh control and explicit POST operation under +ADR-0063, alongside ADR-0048's separate v2 scan routes. Neither passive report/query +reads nor filesystem discovery grant provider mutation authority. FootprintSnapshot is now v7; v6 below records the Catalog v4 migration. The independent configurable Discovery scan does not replace the Footprint deep worker or the explicit provider scan. @@ -93,15 +96,16 @@ a session. Those would require host-native runtime receipts. ### Make scanning explicit and browser refresh passive -Maintenance has two read paths: +Maintenance has one report read and one explicit refresh start: - GET /api/maintenance reads the latest private persisted report. It does not call a host CLI, provider, registry, network source, or version detector. -- GET /api/maintenance?refresh=scan performs one explicit provider scan, persists the - resulting report atomically, and returns it. Unknown or duplicate query parameters fail closed. +- `POST /api/refresh` explicitly runs the Maintenance evidence stage, persists its provider + scan report, and then rebuilds inventory. A GET with the retired `?refresh=scan` + query is rejected. -The dashboard labels these controls **Browser refresh** and **Scan now**. The global poll clock uses -the first path. **Scan now** uses the second. A successful persisted System deep rescan chains one +The dashboard uses **Reload** to re-read a view and **Refresh** to start the staged POST +operation. The global poll clock only reads. A successful persisted System deep rescan chains one Maintenance provider scan so inventory and provider evidence converge without double-scanning concurrent callers. diff --git a/docs/adr/0048-inventory-led-maintenance-resource-management.md b/docs/adr/0048-inventory-led-maintenance-resource-management.md index a3f43266..06e67562 100644 --- a/docs/adr/0048-inventory-led-maintenance-resource-management.md +++ b/docs/adr/0048-inventory-led-maintenance-resource-management.md @@ -1,8 +1,13 @@ # ADR-0048 — Inventory-led Maintenance resource management - **Status:** Accepted — implementation delivered 2026-09-05; Implemented withheld pending - human-evaluation and cross-platform gates -- **Updated:** 2026-09-28 — Branch 6b gives this ADR's two scan controls CLI equivalents: + human usability, screen-reader, and cross-platform evaluation gates deferred to v5; + the former Refresh evidence and Re-measure machine controls are superseded by ADR-0063 +- **Updated:** 2026-09-29 — [ADR-0063](0063-evidence-store-and-refresh-vocabulary.md) replaces the + two dashboard scan controls with one Refresh control; its three visible choices start the + shared staged POST operation. D-15 moves the human usability, screen-reader, and + cross-platform evaluation gates to v5; automated checks do not satisfy them. +- **Earlier update:** 2026-09-28 — Branch 6b gives this ADR's two scan controls CLI equivalents: `ak maintain --refresh` and `ak maintain --refresh=machine` (`src/lib/refresh.mjs`'s `--refresh[=live|machine]`, `ak status --help` for the shared stages). The `scan` verb, the `scans start`/`plan --deep`/`--refresh-inventory` re-measure flags, and `ak maintain recipes @@ -113,7 +118,8 @@ The Focus browser amendment approved on 2026-09-08 is implemented and passes foc approved prototype establishes interaction intent, not production or adapter completeness. It is not yet Implemented in this record's own sense, because the [live acceptance criteria](../maintenance-acceptance.md)'s human-evaluation and -cross-platform acceptance gates have not run on this machine. The dashboard's Maintenance panel now +cross-platform acceptance gates have not run on this machine and are deferred to v5 under +D-15. The dashboard's Maintenance panel now renders this ADR's Inventory/Guidance/Discovery/Activity workspace; ADR-0044's v1 HTTP routes and CLI verbs remain available as a documented compatibility surface until those gates pass and this record is updated again. ADR-0044 is not marked Superseded; see "Implementation status" for why. @@ -121,7 +127,8 @@ record is updated again. ADR-0044 is not marked Superseded; see "Implementation ## Current implementation boundary (2026-09-09) This stays **Accepted with implementation delivered**, not a completed human or -cross-platform release certification. ADR-0050 adds evidence-backed repository +cross-platform release certification. D-15 defers the usability, screen-reader, and +cross-platform evaluation gates to v5. ADR-0050 adds evidence-backed repository groups, independent Desktop-origin filtering, and wrapping language icons. Current cards have no three-icon disclosure or repeated uncertainty labels. Installation counts and exact action identities are unchanged. diff --git a/docs/adr/0053-host-setup-evidence-and-usage-diagnostics.md b/docs/adr/0053-host-setup-evidence-and-usage-diagnostics.md index 3890587c..685042ea 100644 --- a/docs/adr/0053-host-setup-evidence-and-usage-diagnostics.md +++ b/docs/adr/0053-host-setup-evidence-and-usage-diagnostics.md @@ -12,7 +12,10 @@ 15-minute-capped, consent-gated, untouched by this branch (its `host-health-evidence.mjs` input-fingerprint helper, used only to invalidate that in-memory cache, is also untouched). See [ADR-0063](0063-evidence-store-and-refresh-vocabulary.md) (remediation program, branch 6a tasks 5 and 7) -- **Updated:** 2026-09-28 — `ak host check-connection ` is the CLI twin of +- **Updated:** 2026-09-29 — the dashboard **Check again** local re-check is folded into + header Refresh; `POST /api/host-health/local` was removed. The separate consent-gated + connection check remains. See [ADR-0063](0063-evidence-store-and-refresh-vocabulary.md). +- **Earlier update:** 2026-09-28 — `ak host check-connection ` is the CLI twin of this connection check: it reuses `createHostReadinessReader`, so it refuses for exactly the same hosts and reasons the dashboard dialog would (managed-only, `canCheckConnection`), and applies the same consent rule (`--yes` or an interactive y/N; a non-TTY without `--yes` is refused; @@ -112,8 +115,9 @@ the requesting client cancels the owned connected subprocess. ### HTTP and presentation boundaries -`GET /api/host-health` reads local/cached evidence. The separate POST allowlist is -`/api/host-health/local` and `/api/host-health/connection`. Both require the session +`GET /api/host-health` reads local/cached evidence. The local re-check now runs within +`POST /api/refresh`, while the separate consent-gated connection check uses +`POST /api/host-health/connection`. Both require the session token header and exact same-origin fetch metadata; query tokens cannot authorize POST. Requests are size-bounded and accept fixed fields, never arbitrary commands, paths, prompts, environment or client-selected models. Connection checks additionally @@ -168,8 +172,8 @@ from one module, `src/lib/host-management.mjs`. - **The hint is the complete host list.** `ak host pick --host` replaces the enabled set, so the hint names every currently enabled host (including admitted external hosts) plus the one to add, for example `ak host pick --host claude,codex`. -- **The paid connection check stays managed-only.** The local re-check button reads - **Check again** and runs for every host. +- **The paid connection check stays managed-only.** The local re-check is part of header + **Refresh** and runs for every host. **Consequences.** Automatic local checks now spawn at most the same bounded, read-only commands for up to three hosts per minute while the dashboard is open. The previous diff --git a/docs/adr/0063-evidence-store-and-refresh-vocabulary.md b/docs/adr/0063-evidence-store-and-refresh-vocabulary.md index c3a15207..f7001912 100644 --- a/docs/adr/0063-evidence-store-and-refresh-vocabulary.md +++ b/docs/adr/0063-evidence-store-and-refresh-vocabulary.md @@ -1,7 +1,8 @@ # ADR-0063 — One evidence store and the refresh vocabulary - **Status:** Accepted -- **Updated:** 2026-09-28 — Branch 6b delivered the CLI refresh vocabulary +- **Updated:** 2026-09-29 — Branch 6c delivered the dashboard refresh operation and retired GET-started scans +- **Earlier update:** 2026-09-28 — Branch 6b delivered the CLI refresh vocabulary - **Date:** 2026-09-28 - **Deciders:** agentic-kit maintainers - **Related:** [ADR-0025](0025-machine-footprint-metrics.md) (`/api/system/summary` projection), @@ -15,10 +16,11 @@ already recorded its own `Updated:` line), [ADR-0055](0055-aqe-embedding-lifecycle.md) (live-check evidence storage relocation, mechanical), [ADR-0053](0053-host-setup-evidence-and-usage-diagnostics.md) (host setup checks are now persisted evidence with an age rule) -- **Supersedes:** [ADR-0048](0048-inventory-led-maintenance-resource-management.md)'s use of the - word "evidence" for its own, separate **Refresh evidence** / **Re-measure machine** scan - controls — terminology only; see "Relationship to ADR-0048" below. Their UI, backing code - (`scan-store.mjs`), and evidence semantics are unchanged by this branch. +- **Supersedes:** [ADR-0048](0048-inventory-led-maintenance-resource-management.md)'s separate + **Refresh evidence** / **Re-measure machine** dashboard controls; + [ADR-0025](0025-machine-footprint-metrics.md) §5's GET-started deep refresh rationale; and + [ADR-0045](0045-artifact-consumer-bindings-and-explicit-maintenance-scans.md)'s + `GET /api/maintenance?refresh=scan` trigger. Their underlying measurements and stores remain. ## Context @@ -404,7 +406,7 @@ the [issues 237–239 audit](../plans/2026-09-26-issues-237-238-239-verification Item 4 named. It closed CLI-only: the dashboard's own controls are untouched, and the dashboard half of this work moved to the next remediation program — see [the branch 6b plan](../archive/2026-09-28-superpowers-plan-branch-6b-one-refresh-flag.md)'s "Closing -this branch" section, and "Ahead: the dashboard half" below. +this branch" section and "Delivered in 6c" below. - **One flag, three strengths, one ordered stage table** — `--refresh[=live|machine]` across `ak status`, `ak system` and `ak maintain [report]`; see "The `--refresh` flag's three strengths" @@ -485,42 +487,55 @@ this branch" section, and "Ahead: the dashboard half" below. and stopping before any write; `ak host adapters` refuses `--dry-run` outright (exit 2) because its verbs have no preview. -## Ahead: the dashboard half - -6b closed CLI-only — see -[the branch 6b plan](../archive/2026-09-28-superpowers-plan-branch-6b-one-refresh-flag.md)'s "Closing -this branch" section. The dashboard's own controls are unchanged by this branch and remain future -work for the next remediation program: - -- One dashboard **Refresh** control offering the same three strengths the CLI now has, and a - **Reload** control that only re-reads the current view. -- A `POST /api/refresh` route driving that control through the same `runRefresh`/stage machinery - this branch built for the CLI, and the dashboard's existing read-only `GET` routes. -- The eventual supersession of ADR-0048's **Refresh evidence** / **Re-measure machine** controls - and of [ADR-0025](0025-machine-footprint-metrics.md) §5's `GET ?refresh=deep` rationale, once - that dashboard work lands — neither is superseded by this branch, and both remain exactly as - their own ADRs describe them today. - -What stays out of scope regardless: this store's storage does not unify with Maintenance's own -`scan-store.mjs`/deep-snapshot system (R1 — one flag and, eventually, one dashboard control drive -the existing chain; the footprint snapshot and its storage stay where they are), and -`src/lib/host-health-evidence.mjs` (ADR-0053's setup-proof input-fingerprint helper) is still not -folded into this store — see "What is deliberately not folded into this store" above, unchanged -by 6b. +## Delivered in 6c + +Branch 6c adds one dashboard **Refresh** control. Its visible choices are **Refresh**, +**Refresh live**, and **Refresh machine**; the operation request calls their conceptual strengths +`local`, `live`, and `machine`. The separate header **Reload** re-reads the active view and +starts no checks. The former **Check again** local host-health button is folded into Refresh. + +`POST /api/refresh` starts one explicit operation with a bounded JSON body: `strength` is +required; `projectTrees` is an optional boolean for `machine` only. The response is 202 with +`started: true` and the operation state, or 409 with the current state when work is already +running. The server requires the per-session `x-dash-token` header and same-origin mutation +metadata; a query token cannot authorize this POST. `GET /api/refresh` reads the latest state +without starting work. That state has an `operationId`, strength, timestamps, running/completion +fields, and sanitized stage progress, but no stage results. `GET /api/system`, +`GET /api/system/summary`, `GET /api/maintenance`, and the host-health read remain reads: +legacy refresh/scan query arguments are rejected, and `/api/host-health/local` was removed. +The separate consent-gated `POST /api/host-health/connection` remains. + +The dashboard runs the shared `runRefresh` stage order from `src/lib/refresh.mjs`: + +| Strength | Ordered stages | +|---|---| +| `local` (Refresh) | Maintenance evidence → inventory → local evidence and versions | +| `live` (Refresh live) | Maintenance evidence → inventory → live checks → local evidence and versions | +| `machine` (Refresh machine) | Machine measurement → Maintenance evidence → inventory → local evidence and versions | + +A failed machine measurement skips its dependent Maintenance and inventory stages; the local +stage still runs. Other stage failures do not stop later stages. Machine measurement can include +project trees when explicitly selected. The Maintenance stage scans provider evidence; inventory +rebuilds after it; local collects status and forces the host-readiness check. These stages use +the existing Maintenance scan and footprint stores, not a merged evidence store. + +`createRefreshOperation` holds one current operation in one dashboard server process. +Two tabs connected to that server share its single-flight state, so a concurrent POST gets 409. +This is volatile process state, with neither durable operation history nor distributed mutual +exclusion across servers. The client retains its accepted `operationId` and reports a result +only for that identity. If its POST response is lost or another operation supersedes the +server's latest state, the page may say its outcome is unavailable; it does not claim the +other operation's result. ## Relationship to ADR-0048 -ADR-0048's **Refresh evidence** and **Re-measure machine** dashboard controls predate this branch -and use the word "evidence" in the Maintenance-inventory sense (provider probes feeding the -Inventory/Guidance/Discovery/Activity workspace), which is conceptually adjacent to — but a -genuinely separate system from — the evidence store this ADR describes. This ADR's evidence store -is the eventual "live" tier's technical precursor and this branch's own interim `--refresh` boolean -is its no-suffix groundwork; ADR-0048's own controls, their backing code (`scan-store.mjs`), and -their UI are unchanged by this branch. No file under Maintenance's own scan system was touched by -Tasks 1–11. A reader should not infer that ADR-0048's controls now share code, storage, or an age -rule with this ADR's evidence store — they do not, yet. Branch 6b gives those two controls CLI -equivalents — `ak maintain --refresh` and `ak maintain --refresh=machine` — without changing the -controls, their backing code, or their UI themselves; see "Delivered in 6b" above. +ADR-0048's separate **Refresh evidence** and **Re-measure machine** dashboard controls are +superseded by the single Refresh control above. Its Inventory/Guidance/Discovery/Activity +workspace, scan storage (`scan-store.mjs`), evidence semantics, and guarded management +actions remain. The CLI equivalents delivered in 6b are `ak maintain --refresh` and +`ak maintain --refresh=machine`. The control unifies the user's start path, not the +underlying storage or age rules. `src/lib/host-health-evidence.mjs` also remains outside the +shared evidence envelope. ## Known limitations (recorded, not fixed, by this branch) diff --git a/docs/adr/README.md b/docs/adr/README.md index 24982403..7b12aab7 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -58,7 +58,7 @@ Consequences**, and cites the grounded source it rests on where relevant. | [0045](0045-artifact-consumer-bindings-and-explicit-maintenance-scans.md) | Physical artifacts, host consumers, and explicit Maintenance scans | Implemented | | [0046](0046-scan-local-observation-reuse-and-nonblocking-deep-scans.md) | Scan-local observation reuse and nonblocking deep scans | Implemented | | [0047](0047-streaming-observation-forest.md) | Streaming observation forest for deep scans | Accepted; Projects pilot and separate Discovery continuation implemented | -| [0048](0048-inventory-led-maintenance-resource-management.md) | Inventory-led Maintenance resource management | Accepted; Focus browser implemented and focused checks pass; human/cross-platform gates pending | +| [0048](0048-inventory-led-maintenance-resource-management.md) | Inventory-led Maintenance resource management | Accepted; two dashboard controls superseded by 0063; human usability, screen-reader and cross-platform gates deferred to v5 | | [0050](0050-dashboard-project-identity-and-context-reporting.md) | Dashboard project identity and context reporting | Implemented | | [0051](0051-supported-peer-delegation-and-host-realignment.md) | Supported peer delegation and scoped host realignment | Accepted; implemented locally | | [0052](0052-codex-usage-attribution.md) | Codex usage attribution: own usage, imports, segments, streaming | Accepted | @@ -69,7 +69,7 @@ Consequences**, and cites the grounded source it rests on where relevant. | [0060](0060-session-surface-initiator-and-product-names.md) | Session surface, initiator and official product names | Proposed; §3 implemented for project discovery (2026-09-27), the rest staged follow-on | | [0061](0061-brain-reclaim-stuck-remediation.md) | RuvNet Brain "unresolved rollback state" remediation | Accepted | | [0062](0062-aqe-project-store-integrity.md) | AQE project store integrity | Accepted | -| [0063](0063-evidence-store-and-refresh-vocabulary.md) | One evidence store and the refresh vocabulary | Accepted | +| [0063](0063-evidence-store-and-refresh-vocabulary.md) | One evidence store and the refresh vocabulary | Accepted; CLI and dashboard refresh operation delivered | Theme: ADRs **0001–0006** define **dual-host LLM routing and leadership** — how `ak` lets ruflo route each development activity (architecture, implementation, testing, review, …) to the right host (Claude diff --git a/docs/archive/2026-09-28-plan-dashboard-refresh.md b/docs/archive/2026-09-28-plan-dashboard-refresh.md new file mode 100644 index 00000000..de0a8107 --- /dev/null +++ b/docs/archive/2026-09-28-plan-dashboard-refresh.md @@ -0,0 +1,28 @@ +# Dashboard Refresh delivery plan + +## Status + +**Implemented; final integration gates pending** — Captured 2026-09-29 after `d2c1833b`. Tasks 6c-1 through 6c-5, the live-view follow-up, native plain-folder proof, and the paused Activity timestamp handoff passed scoped independent reviews. The branch incorporates green `develop@af825c9f`. Full branch gates, whole-branch review and feature PR CI remain at this archival capture; this status does not claim a merge or release. + +Implement the deferred 6c work in order, with one reviewed task and unit commit at a time. The [remediation program](../plans/2026-09-28-remediation-program-v2.md) and [6b handoff](2026-09-28-superpowers-plan-branch-6b-one-refresh-flag.md) define the contracts. A passing exact-head develop CI gate precedes production edits. Tests use sandbox state and injected services; final branch gates and integration belong to the controller. + +| Task | Dependency | Code and proof | +| --- | --- | --- | +| 6c-1: additive server operation | 6b Tasks 2 and 4 | Add `dashboard/refresh-api.mjs` with shared stage runner, single-flight state, validated POST and GET; wire it into `dashboard-server.mjs` without changing the client or poll cost. Add API, security, stage and cost tests. This dispatch only. | +| 6c-2: one Refresh control | 6c-1, 6b Tasks 2, 3 and 5 | Add `client/refresh-control.mjs`; wire page, boot and client views to POST once, poll only the page's operation, and reload the active view. Remove retired controls. Prove request counts, stage progress, blocked Maintenance writes, host consent and narrow-screen UI. | +| 6c-3: read-only GET routes | 6c-2 | Reject scan-starting GET query parameters, remove GET scan paths and `/api/host-health/local`; keep the 6c-1 stage's Maintenance and inventory refresh dependencies. Prove all GET routes have no scan side effects. | +| 6c-4: current vocabulary | 6c-1 through 6c-3, 6b Task 13 | Extend the vocabulary guard for retired dashboard strings and legacy refresh URLs; update dashboard, Maintenance, upgrading, DDD and installed guidance. Include the remaining README/dashboard noun and `maintenance-discovery.mjs` string. Check links and Markdown. | +| 6c-5: decisions and supersessions | 6c-1 through 6c-4, 6b Task 14 | Reconcile ADR-0063 with ADR-0048, ADR-0025, ADR-0045, ADR-0044 and ADR-0053, including index/status rows and the decision log; record actual POST behavior and read-only GETs. Run docs and final branch gates. | + +V3 carry-ins from the program: + +- **6c-2 client fixes:** B0-22 shows the Claude Code badge as "Unknown" when Configuration was not assessed. B0-23 measures the Codex header icon against WCAG's 3:1 non-text contrast minimum and changes it only if the measurement fails. B6a-12 makes `mntSyncHash` and `mntApplyHashState` re-derive state from `location.hash` if 6c-2 touches that code; otherwise open a small issue. +- **6c-5 decision record:** B6a-9 requires ADR-0063 to state that two dashboard tabs share one server process's module state. If 6c-1's tests build the `ruflo-components` path, cover its cwd case; otherwise record the ruling that drops that test case. ADR-0048's status line moves its human-evaluation gates to v5 under D-15. +- **Live view (#256):** Confirm the session re-read changes no total before documenting D-18; if it does, apply D-18's offset alternative. Label the structured live-events input experimental under D-19. Observe a plain-folder, non-Git bind on a real machine once and fix what the observation shows. +- **Live-view follow-up (2026-09-29):** A nonzero regression showed that bounded-window re-entry increased the accepted-record total, so D-18 now retains displaced native readers and offsets in a bounded in-memory map. A genuine Codex transcript kept accepted, session, and project totals stable across idle restart and window re-entry in an isolated service probe. Eviction can still replay old records, and a new service process has no persisted offset; this does not establish exactly-once delivery or unchanged historical token accounting. D-19's structured live-events input is experimental; no real producer was verified. See the V3 live-view report. +- **Plain-folder native proof (2026-09-29):** An independently reviewed, genuine Codex `0.159.0` native `thread/fork` created a new transcript in an isolated plain folder. The real process survey and `LiveSessionsService` joined the actual host PID/cwd to that new transcript, with observed presence. The copied parent retained its exact upstream bytes and remained presence-unknown. The five-session service snapshot included unrelated controllers; it does not show five plain-folder joins. This was an idle fork with no `turn/start`, inference, billing measurement, detailed activity proof, or browser journey. The fork's empty RPC `turns` field does not mean its copied history was empty. See the V3 plain-native report. +- **Evidence-gated issue #254:** Diagnose the "CONNECTING" stall only if the browser network trace specified by the program arrives; otherwise leave it to V7. +- **Paused Activity handoff:** V4 B8 records pauses with a distinct `recordedAt` and no completion time. This branch carries that validated timestamp through Activity projection and the API, then sorts and displays pause records without claiming completion. Impossible calendar dates are rejected; valid completion timestamps remain a fallback. The V4 producer is integrated separately. +- **Pre-PR flags check (2026-09-29):** The latest Codex remained `0.159.0` at the 11:16 UTC registry recheck. Its native artifact passed integrity verification, help checks and `-s read-only -a never app-server` initialization in an isolated home. This proves flag acceptance and startup, not inference or billing. The installed global CLI was not changed. + +Extend 6c-4's guard exclusions to `docs/plans/` and the current archive/proposal/ADR layout. Move this finished plan to `docs/archive/` in the completing pull request with an archive index row. diff --git a/docs/archive/README.md b/docs/archive/README.md index e4c03435..157b2176 100644 --- a/docs/archive/README.md +++ b/docs/archive/README.md @@ -162,6 +162,7 @@ reconfirmed by this metadata audit. The per-file inventory and limitations are r | [2026-09-28-superpowers-spec-upstream-watch-ledger-branch-design.md](2026-09-28-superpowers-spec-upstream-watch-ledger-branch-design.md) | `docs/superpowers/specs/2026-09-28-upstream-watch-ledger-branch-design.md` | Finished Superpowers specification | Implemented work record preserved after its implementing PR merged. | | [2026-09-28-plan-docs-taxonomy-and-archive.md](2026-09-28-plan-docs-taxonomy-and-archive.md) | `docs/plans/2026-09-28-docs-taxonomy-and-archive.md` | Finished documentation taxonomy and archive plan | Implemented in [PR #264](https://github.com/pacphi/agentic-kit/pull/264) and [PR #266](https://github.com/pacphi/agentic-kit/pull/266). Current layout rules live in the [docs index](../README.md) and [layout guard](../../scripts/docs-layout.mjs); this plan records the build steps. | | [2026-09-28-plan-sonnet-5-5-routing-refresh.md](2026-09-28-plan-sonnet-5-5-routing-refresh.md) | `docs/plans/2026-09-28-sonnet-5-5-routing-refresh.md` | Routing and pricing research with the implemented model-catalog decision | Implemented in [PR #268](https://github.com/pacphi/agentic-kit/pull/268). The operative tier decision is in [ADR-0006](../adr/0006-primary-host-and-ambidextrous-mirroring.md); prices and benchmarks here are dated evidence. | +| [2026-09-28-plan-dashboard-refresh.md](2026-09-28-plan-dashboard-refresh.md) | `docs/plans/2026-09-28-dashboard-refresh.md` | Completed V3 implementation plan | Scoped implementation and independent review through `d2c1833b`, including native plain-folder evidence and paused Activity timestamps. Full branch gates, PR CI and develop integration remain separate gates at capture. | ## Naming convention diff --git a/docs/dashboard.md b/docs/dashboard.md index 9c6ccc16..ad48fdea 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -53,7 +53,7 @@ permanent. | System | Sessions | `#system/sessions` | Sessions | The largest retained sessions, with a localized two-line native identity, working context, and share of that host's retained bytes | | System | Storage | `#system/storage` | Storage | Where the retained bytes are, by category and host — learning stores counted separately because they dwarf everything else — plus per-series growth | | System | Runtime | `#system/runtime` | Runtime | Live host processes, their CPU and memory, background daemons, and machine denominators — refreshed on the header's poll clock while open | -| System | Catalog | `#system/catalog` | (redirect) | Retired as a visible destination. The link redirects to Maintenance › Inventory. Full scan still collects the catalog measurement, and its cards now sit in Summary | +| System | Catalog | `#system/catalog` | (redirect) | Retired as a visible destination. The link redirects to Maintenance › Inventory. Refresh machine still collects the catalog measurement, and its cards now sit in Summary | | System | Projects | `#system/projects` | Projects | Every repository with a remote that a host has recorded a session in — its approximate lines of code, language mix, total disk size and last activity. Worktrees, sub-folders and remote-less repositories are counted below the table, not listed | | System | Maintenance | `#system/maintenance` | Maintenance | Four destinations: **Inventory** (`#system/maintenance/inventory`, Focus navigation from scope through resource family to exact installation details), **Guidance** (`/guidance`, only outcomes the kit can ground, in five lanes), **Discovery** (`/discovery`, automatic sources, exact projects, collection roots, exclusions, scan coverage), and **Activity** (`/activity`, receipts, undo, interruption audits, dispositions, recipe changes, scan records). Inventory links carry scope, view, sort, `facet.` values, and the selected placement as opaque state | @@ -557,6 +557,8 @@ updates. See [Observability](https://github.com/pacphi/agentic-kit/blob/main/docs/observability.md) for the map legend, workspace facts, host capability coverage, History/Review semantics, privacy limits, and troubleshooting. +The optional `--live-source` structured input is experimental: its Ruflo and +agentic-qe examples have fixture coverage, with no verified real producer. ## System @@ -573,13 +575,11 @@ Opening System costs almost nothing. The cheap tier — the live process census, file sizes, and the figures carried forward from the last full scan — is served on every read and cached briefly. -Everything else comes from the **Full scan**—the dashboard name for the deep tier—which walks -install trees, retained-data roots, host catalog surfaces, and the eligible hosted-repository -population. That is real I/O and can take minutes on a large machine, so it runs **only when you -press Full scan** (or run `ak system --refresh=machine`). Opening the tab never triggers it. Production runs -the synchronous collectors in one worker thread so the page can report phases and remain usable -while they run. Its status names the current phase, bounded count when available, and elapsed time. -Worker containment does not claim that the filesystem work itself completes faster. +Everything else comes from choosing **Refresh machine** in the header and pressing **Refresh**. +It walks install trees, retained-data roots, host catalog surfaces, and eligible hosted +repositories. That is real I/O and can take minutes, so opening System never starts it. +Production runs the synchronous collectors in one worker thread so the page can report phases +and remain usable. Worker containment does not make the filesystem work itself faster. One scan may reuse a complete physical observation when another section asks the same bounded question. Catalog reads one physical surface once per compatible reader contract even when several @@ -591,17 +591,19 @@ the same scan. Reuse is confined to that scan. Incomplete, older, differently rooted, or differently scoped evidence falls back to a fresh bounded walk rather than being treated as equivalent. -Measurement views fetch once, then again only while a scan you started is running (Runtime also -refreshes on the header's poll clock). They read `GET /api/system/summary`, which carries only what -the page draws; `GET /api/system` and `ak system --json` keep the complete payload. Maintenance -loads when you open it, and also reloads on the shared status poll while it stays the open view -(and no measurement or provider check is already running), reading the last complete inventory each -time; opening it checks no host provider and executes nothing. **Refresh evidence** on the -Maintenance workspace is the explicit control that runs provider probes, and it rebuilds the -Inventory afterwards; **Re-measure machine** beside it runs the System Full scan, walks every -discovery source to completion, then refreshes evidence. Full scan from the System rail chains the -same provider check after the snapshot is persisted. `ak maintain --refresh` and -`ak maintain --refresh=machine` are the CLI equivalents. +Measurement views read `GET /api/system/summary`, which carries only what the page draws; +`GET /api/system` and `ak system --json` keep the complete payload. Opening Maintenance reads +the last complete inventory and starts no provider or machine check. The header has one +**Refresh** button. Its selector shows **Refresh** (local strength), **Refresh live** (live +strength), and **Refresh machine** (machine strength). The local strength refreshes Maintenance +evidence, rebuilds the inventory, and re-checks local evidence and versions. The live strength +adds bounded live checks. The machine strength first measures the machine, then refreshes +Maintenance evidence; its inventory stage walks the discovery sources and rebuilds from the new +measurement. A failed machine measurement skips those two dependent stages. +The control starts an operation with `POST /api/refresh` and reads its progress with +`GET /api/refresh`. The System and Maintenance GET routes remain read-only. +`ak maintain --refresh` and `ak maintain --refresh=machine` offer the CLI equivalents. +**Reload** re-reads the active view; it starts no machine or provider check. ### Session identity and local time @@ -632,7 +634,7 @@ while provider-backed actions live only in Maintenance. ### Catalog cards in Summary -The cross-host capability catalog is still measured by Full scan, and its three cards now live at +The cross-host capability catalog is still measured by Refresh machine, and its three cards now live at the bottom of Summary: **Host inventory profile**, **Unique across hosts** (the presence matrix, filtered by what to show, which host carries it, and which source scope), and **Project skill pressure** (a project-by-host table with per-project host disclosure). Project, user, and @@ -669,15 +671,17 @@ Guidance and Activity tabs carry a count only when something is admitted or need A fresh installation shows an empty Inventory and every installed automatic source as **Not scanned yet**; a host that is not installed reads **Not installed**. -Two actions sit side by side above the tabs, each with its helper text: **Refresh evidence** runs -provider probes on the saved measurement and rebuilds the inventory in seconds, and **Re-measure -machine** walks the filesystem, then every discovery source, then refreshes evidence, which takes -minutes. Choose Refresh evidence to build the inventory; `ak maintain --refresh` -does the same from a terminal, together with the local status checks. While either runs, both -buttons are disabled, the status line names what is running ("Refreshing evidence…"; during -Re-measure machine, each phase in turn, from "Preparing measurement…" through "Machine measured · -refreshing evidence…"), and apply, undo, and record are refused; if the work does not finish, the -previous evidence is kept. +Select **Refresh** in the header selector and press the **Refresh** button to build the inventory +from saved measurement. **Refresh live** adds bounded live checks. **Refresh machine** measures +the machine, then refreshes Maintenance evidence; the inventory stage walks the discovery sources +and rebuilds the inventory. `ak maintain --refresh` runs the local stages from a terminal. The +ordered progress labels are **Measuring the machine** (machine strength only), +**Refreshing Maintenance evidence**, **Rebuilding the inventory** (including the discovery walk +for machine strength), **Running live checks** (live strength only), and +**Re-checking local evidence and versions**. While an operation runs, another cannot start, +and Maintenance apply, undo, and record are refused. If work does not finish, the previous +complete evidence is kept. + After the probes settle the inventory builds in the background: the empty state reads **Building the inventory…** until rows appear, or names the reason if the build did not complete. @@ -743,8 +747,8 @@ A saved root starts scanning at once. Scan progress reads as visited work, never added offer **Pause** and **Stop** while running, **Resume** and **Stop** while paused, **Retry scan** after a failure, and **Scan this root** if never run; stopping shows what would be affected and asks **Stop this source?**. Automatic sources carry no per-source control: each reads Not -scanned yet with "measured by Re-measure machine", or Complete with "covered by the last -measurement". A host source whose folder is not on this machine reads **Not installed** and is +scanned yet, or Complete after the last measurement. A host source whose folder is not on this +machine reads **Not installed** and is not counted in the progress sentence or the Inventory banner. A started source keeps running until it completes, pauses, stops, or fails. Host configuration sources skip transcript, session, log, and cache trees by name so they can complete. @@ -783,12 +787,11 @@ the totals. Every parent with breakdowns also gets an "everything else" row, so adds up to its parent. Roots that do not exist on this machine are listed as absent rather than ranked at 0 B, and roots that could not be read say so with their reason. -**Project trees** are excluded by default, and the chip that includes them is a *scan* control, -not a filter. One large repository can outweigh every shared cache combined, and a chart -containing it is a chart of one repository — so the ranking says, in the panel, that they were -left out. Turning the chip on starts a new Full scan that walks them (and turning it off starts -one that does not); it is disabled while a scan is running. `ak system --refresh=machine` scans -without project trees; add `--project-trees` to include them. +**Project trees** are excluded by default. The **Include project trees** option is +available only when **Refresh machine** is selected; it changes the measurement scope. +One large repository can outweigh every shared cache combined, so the panel says when +project trees were left out. Select that option and press Refresh to measure them. +`ak system --refresh=machine` omits them unless `--project-trees` is added. ### Two reclaimable tiers, never one total @@ -810,7 +813,7 @@ removes anything; where a CLI already owns the cleanup, the row names it. ### Reading the numbers honestly -- **A section that has never been scanned says so.** It reads "not measured yet — run Full scan", +- **A section that has never been scanned says so.** It reports an unmeasured state, never `0`. A zero here means a real, measured zero. - **A total whose inputs were incomplete renders as `≥ N`.** If one subtree could not be read or a walk hit its cap, the sum is a floor, not a total, and is labeled that way. @@ -954,8 +957,9 @@ provider, model/default agent, and applicable credentials or local endpoint. Automatic checks do not invoke its config-debug command, which can install dependencies. Unresolved remote configuration and native overrides stay Unknown. -**Check again** refreshes the local evidence for any host. **Check connection** -runs only for hosts managed by ak, and requires +Select **Refresh** in the header selector and press the **Refresh** button to re-check local +evidence for any host. +**Check connection** runs only for hosts managed by ak, and requires checking a confirmation box first: it sends one small provider request, using normal billing and native context. Native startup may initialize dependencies and update local cache/session files. Agent tools are restricted, and no repair diff --git a/docs/ddd/machine-footprint.md b/docs/ddd/machine-footprint.md index 28effd21..a27b8295 100644 --- a/docs/ddd/machine-footprint.md +++ b/docs/ddd/machine-footprint.md @@ -193,7 +193,8 @@ FootprintSnapshot { asOf, completeness, install, runtime, storage, catalog, pro v Delivery GET /api/system → cheap tier + persisted snapshot (token auth, loopback, no egress) - GET /api/system?refresh=deep → start-or-attach the single-flight deep scan + POST /api/refresh → start the single-flight staged refresh + GET /api/refresh → read that operation's progress GET /api/system/summary → the same read, catalog/storage/install/projects/consumers each projected to what the System page draws ak system [--refresh[=live|machine]] [--project-trees] [--json] → the same collector, CLI-rendered @@ -521,7 +522,7 @@ That identity also governs acquisition cost inside one Catalog collection. Compa keyed by normalized physical path and reader contract, so Claude and OpenCode bindings to the same skill surface share one bounded observation while retaining two ConsumerBindings. Markdown and file-stem entrypoints likewise compute one digest per file. The observation map is created and -discarded inside the collection; a later Full scan always observes the filesystem again. A path +discarded inside the collection; a later Refresh machine always observes the filesystem again. A path match under a different reader contract is not reusable evidence. Every occurrence retains host, surface, source scope (`user`, `project`, or `plugin`), project @@ -704,38 +705,28 @@ The link is user-initiated browser navigation; the kit itself never fetches the token auth ([ADR-0014](../adr/0014-dashboard-auth-and-remediation.md)), `no-store`, zero egress. The response is the cheap tier computed fresh (TTL ~60s, shared-cache pattern like the project-snapshot cache) merged with the persisted deep snapshot and its `asOf`. -`?refresh=deep` starts the dashboard's **Full scan** or attaches to the one in flight -(single-flight, like the usage index's coalesced builds). In production, `index.mjs` retains the -single-flight promise and public activity state while `deep-scan-worker.mjs` runs the synchronous -runner in one worker thread. Phase and Projects progress messages return to the main thread, so -ordinary reads remain responsive while the worker is busy. Injected collectors and filesystem -implementations run the same `deep-scan-runner.mjs` inline rather than attempting to serialize test -functions. This containment is not evidence that total scan duration decreased. - -The System measurement routes stay GET-only: a Full scan re-measures local state and writes only this domain's own -snapshot file — it mutates no user data. - -`GET /api/system` is the complete read model, the same shape as `ak system --json`. -`GET /api/system/summary` is the page's read: the same payload (and the same `?refresh=deep` and -`&trees=` parameters) with `catalog`, `storage`, `install`, `projects` and `consumers` each -projected by `dashboard/system-summary.mjs` to an allow-list of keys — the catalog's items cut to -key, kind, name, hosts, source scopes, digest coverage, and distinct plugin providers -(`presence[].provider` with `ref` and `version`); storage's category/host/project/session tree cut -to key, label, bytes and children; a measured project row's per-tool native-addon lists and -per-project framework/dependency stack detection dropped entirely (never rendered). The catalog's -repeated presence copies (`item.presence` details, `consumerBindings`, `artifacts`) grow with -items × projects × hosts and are not drawn, so the page and its 30-second Runtime poll never -download them; the other four sections carried the same shape of excess and were the majority of -the endpoint's real-machine bytes once the catalog alone was slimmed. The projection is a -Dashboard-delivery view; this domain's collector output is unchanged. - -**A deep scan never runs on its own.** Opening the System area issues a plain `GET /api/system/summary`; -only **Full scan** adds `?refresh=deep`. A deep scan can cost minutes of I/O on a large corpus, and -making the act of *looking* cost that is a worse trade than a stale figure that states -how stale it is. Staleness is therefore surfaced rather than pre-empted: every deep-tier figure -renders with its snapshot's `asOf`, and past `SNAPSHOT_STALE_AFTER_MS` (7 days) the freshness -label turns amber and reads "stale, scan again". The client polls only while a user-started scan is -running, and stops when it finishes. +The header's **Refresh** control starts an operation through `POST /api/refresh`. +Choosing **Refresh machine** runs the deep collector first; `GET /api/refresh` reads the operation's +stage progress. In production, `index.mjs` retains the single-flight promise and public +activity state while `deep-scan-worker.mjs` runs the synchronous runner in one worker thread. +Phase and Projects progress messages return to the main thread so ordinary reads remain +responsive. Injected collectors run the same `deep-scan-runner.mjs` inline in tests. +Worker containment does not show that total scan duration decreased. + +The System measurement routes are read-only GETs. `GET /api/system` returns the complete +read model, the same shape as `ak system --json`. `GET /api/system/summary` projects +`catalog`, `storage`, `install`, `projects`, and `consumers` to the allow-listed keys +drawn by the page. The catalog omits repeated presence details, consumer bindings and +artifacts; Projects omits native-addon and stack details. This projection reduces the +payload downloaded by the page and its Runtime poll while the collector output stays +unchanged. + +Opening System reads the saved snapshot and starts no measurement. A deep scan can cost +minutes of I/O, so the user explicitly chooses **Refresh machine** and presses **Refresh**. +Every deep-tier figure renders with its snapshot's `asOf`; after `SNAPSHOT_STALE_AFTER_MS` (7 days), +the freshness label turns amber. The client reads progress for the user-started operation +and stops polling when it finishes. **Reload** re-reads the active view without starting +machine or provider checks. One deliberate divergence from Observability's delivery: absolute paths are **part of this payload**. `publicLivePayload`'s leaf-only rule exists to keep incidental provenance out of @@ -746,7 +737,7 @@ nothing to leak. The CLI twin (`ak system`) renders the same collector output, `--json` emitting the collector's payload verbatim, following the one-collector-two-surfaces precedent of the usage scorecard. -`ak system --refresh=machine` is the terminal spelling of **Full scan** and writes the same snapshot. +`ak system --refresh=machine` is the terminal spelling of **Refresh machine** and writes the same snapshot. ## Invariants @@ -856,7 +847,7 @@ normative and this table restates it for readers of this document. | Definition digest | SHA-256 over one complete bounded observed capability definition; equality proves those files match, not host selection, ownership, usage, or removal safety | | ProjectCapabilityPressure | Project/user/plugin contributions and exact overlap per project and host; context inclusion remains unknown | | ProjectFootprint | One eligible hosted repository's size facts: approximate LOC by language, tree/`.git`/`node_modules` bytes, last activity, and a proven HTTPS web link | -| Deep scan | The explicit, user-triggered, single-flight measurement pass called **Full scan** in the dashboard; it produces a FootprintSnapshot over the stated bounded populations | +| Deep scan | The explicit, user-triggered, single-flight measurement pass selected by **Refresh machine** in the dashboard; it produces a FootprintSnapshot over the stated bounded populations | | Cheap tier | The per-request census + known-file stats + snapshot carry-forward served on every read | ## References diff --git a/docs/ddd/observability.md b/docs/ddd/observability.md index 513ff96e..33e7aaeb 100644 --- a/docs/ddd/observability.md +++ b/docs/ddd/observability.md @@ -901,6 +901,8 @@ agentic-qe source adapters require explicit, repeatable `--live-source 'surface= registration, where `surface` is `ruflo` or `aqe`. Explicit sources consume tailer capacity before Claude/Codex discovery. Paths resolve against the startup working directory and are not confined to the project, so registration is an operator authorization to read that file. +The structured input remains experimental: parser fixtures exist, but no real producer has been +verified. Registration alone supplies no activity or runtime coverage. Independent plugin, skill, MCP, and gate registries are not implemented. This is an explicit source coverage limitation; ADR-0012 is Implemented because the supported adapter contract does not claim automatic upstream discovery. diff --git a/docs/ddd/ubiquitous-language.md b/docs/ddd/ubiquitous-language.md index d84bbe64..c62b9a92 100644 --- a/docs/ddd/ubiquitous-language.md +++ b/docs/ddd/ubiquitous-language.md @@ -88,7 +88,7 @@ token estimates never become observed token evidence. An absent or incompatible | Quick live checks | The bounded, no-cost subset of checks that a plain `ak status --refresh=live` runs in parallel: AQE embedding request, Codex MCP handshake, provider wiring, security packages, deja-vu structure, and the `memory` check's temp-dir CLI store/retrieve/purge round trip (no MCP tool calls; that is the separate `memory-routes` slow proof); a timeout is `inconclusive`. `--only CHECK[,CHECK...]` names exactly which checks to run, including a slow proof | | Slow proof | A live check that runs only when named with `--only`, up to six minutes each: `learning` (trains a temporary fixture, asserts patterns persist), `harvest` (records an outcome and distills through Ruflo in an isolated store), `aqe` (storage, embedding configuration/provenance, and the browser payload), `memory-routes` (the memory round trip plus whether CLI and MCP see each other's writes, remembered as the `memory` check). A named check runs even when it would not otherwise apply; `learning` and `harvest` are never remembered | | Connection check | `ak host check-connection [--yes] [--json] [--dry-run]`: the consent-gated, paid probe of a managed host's live connection. It is never a strength of `--refresh` and runs only for a managed host whose local checks already pass, after printing the shared disclosure and asking `[y/N]` on a TTY (refused without `--yes` off a TTY) | -| Machine measurement | The full re-measure `--refresh=machine` runs: walking install trees, storage, the cross-host catalog, and (with `--project-trees`) your projects' working trees, then persisting the result and rebuilding the inventory. The dashboard's Full scan and Re-measure machine controls run the same chain | +| Machine measurement | The full re-measure `--refresh=machine` runs: walking install trees, storage, the cross-host catalog, and (with `--project-trees`) your projects' working trees, then persisting the result and rebuilding the inventory. Choosing **Refresh machine** in the dashboard and pressing **Refresh** runs the same chain; **Reload** only re-reads the active view | | Memory route observation | What `ak status --refresh=live --only memory-routes` reports after its CLI proof, in its throwaway project only: whether a key written through Ruflo's CLI is readable through MCP and the reverse, where each landed, and the MCP backend seen. A split is a warning and an unusable interface is "not observed"; it never fails the suite, is not part of the quick live checks, and says nothing about an existing corpus | | Observed routing pair | An exact `@claude-flow/cli` release and platform on which the memory route observation was recorded. Only for such a pair does status say which Ruflo interface reads which project-memory store; a neighbouring, prerelease or build-tagged version, or another platform, stays unverified | | Canonical memory store | `/.swarm` for the root every ak memory launch contract pins (the repository root, else the folder, unless that is an unsuitable memory folder): `memory.db` and, with the native bridge, `agentdb-memory.db`. Status reports it from any subfolder, with each file's size, live WAL, largest namespace and how much of it is set to expire | diff --git a/docs/maintenance.md b/docs/maintenance.md index fa9bafb4..441b072c 100644 --- a/docs/maintenance.md +++ b/docs/maintenance.md @@ -23,7 +23,7 @@ environment; native Windows mutation support remains an integration gate. ## Host alignment in User and Project views Select **Host alignment** under **More views**, then choose **User** or **Projects** -and an optional project filter. Use **Refresh evidence** to inspect current host +and an optional project filter. Use **Refresh** to inspect current host configuration. The rows identify retired peer transports and other host-alignment anomalies without exposing configuration contents or local paths in the inventory. @@ -58,25 +58,22 @@ The dashboard workspace has four tabs. Each answers a different question. | **Activity** | What changed? Receipts, undo, interruption audits, dispositions, recipe changes, and scan records. | Opening Maintenance reads the last complete inventory and opens **Inventory** across all scopes. -Nothing scans on open. A fresh installation has no inventory yet; the empty state reads **No -inventory has been built yet. Use Refresh evidence, above, to build it.** and every installed -automatic source reads **Not scanned yet** (a host that is not installed reads **Not installed**). Two actions sit side by side above the tabs, each with its own helper -text: - -- **Refresh evidence** runs provider probes on the saved measurement and rebuilds the inventory. - It takes seconds. The CLI equivalent is `ak maintain --refresh`. -- **Re-measure machine** walks the filesystem to re-measure installs, storage, projects, and every - discovery source, then refreshes evidence. It takes minutes. The CLI equivalent is - `ak maintain --refresh=machine`. - -Both controls run provider probes: Re-measure machine includes the Refresh evidence stage. While either action -runs, both buttons are disabled, the status line names what is running ("Refreshing evidence…"; -during Re-measure machine, each phase in turn, from "Preparing measurement…" through "Machine -measured · refreshing evidence…"), and apply, undo, and record are refused. If -the work does not finish, the previous evidence is kept. The inventory build runs after the probes -settle and can take a few seconds on a large footprint; the empty state reads **Building the -inventory…** until the rows appear, and **The last inventory build did not complete** with a short -reason if it fails. The retired Catalog link (`#system/catalog`) redirects to Inventory. +Nothing scans on open. A fresh installation has no inventory yet; the empty state asks you to +use **Refresh** in the header, and every installed automatic source reads **Not scanned yet** +(a host that is not installed reads **Not installed**). + +The header has one **Refresh** button. Its selector shows **Refresh** (local strength), +**Refresh live** (live strength), and **Refresh machine** (machine strength). The local strength +runs provider probes on the saved measurement, rebuilds the inventory, and re-checks local +evidence and versions. The live strength adds bounded live checks. The machine strength first +re-measures installs, storage, and projects, then refreshes Maintenance evidence. Its inventory +stage walks the discovery sources before rebuilding the inventory from the new measurement. +The CLI equivalents are `ak maintain --refresh`, `ak maintain --refresh=live`, and +`ak maintain --refresh=machine`. **Include project trees** applies only to **Refresh machine**. +While an operation runs, another refresh cannot start; apply, undo, and record are refused. +The prior complete evidence is retained if work does not finish. The empty state reads +**Building the inventory…** while the inventory builds, or names the failure reason. +The retired Catalog link (`#system/catalog`) redirects to Inventory. ## Inventory @@ -299,7 +296,7 @@ Discovery is where you tell Agentic Kit where to look. Configuration is user int configuration, Codex user configuration, OpenCode user configuration, Hermes user configuration, Projects (every project a recorded host session has visited), Runtimes, Package managers, Ollama (over loopback only), and Providers. Each states what it inspects, never a path. Automatic - sources have no per-source scan control: their coverage comes from **Re-measure machine**, and + sources have no per-source scan control: their coverage comes from **Refresh machine**, and the non-filesystem ones (Runtimes, Package managers, Ollama, Providers) are covered by the provider check. Asking `ak maintain scans start` to walk one of those is refused with `SOURCE_NOT_SCANNABLE`. A host source whose folder is not on this machine (for example Hermes @@ -337,7 +334,7 @@ Scans are resumable and completion-oriented. run. A started root keeps running through its work slices until it completes, pauses, stops, or fails; you never have to resume it yourself. `ak maintain scans start --source ID` does the same from the CLI and waits for the final state. Automatic sources show instead whether they are - measured by Re-measure machine or covered by the last measurement. + measured by choosing **Refresh machine** or covered by the last measurement. Progress is factual: "Scanned N entries. X of Y sources are complete. N sources have not been scanned yet." Counts are visited work, never totals. @@ -460,7 +457,7 @@ ak maintain undo --receipt RECEIPT_ID --yes Undo needs the recorded provider and version, a reversible or compensating operation, and an exact current postimage. If anything changed after apply, undo refuses instead of overwriting the new state. If no inventory has been built yet, plan and apply refuse with `SCAN_REQUIRED`; choose -**Refresh evidence** or run `ak maintain --refresh` first. If a placement cannot be +**Refresh** or run `ak maintain --refresh` first. If a placement cannot be bound to an exact executable finding, they refuse with `PLACEMENT_FINDING_UNRESOLVED`. Rollback classes are separate from safety: **reversible** (the provider restores and verifies the @@ -494,7 +491,8 @@ server registration and are not separate MCP installations. Measured user instruction files retain their resolved configuration location through **Reveal exact path**. Their paths remain private in ordinary inventory responses. -Refresh evidence after upgrading to populate locations missing from an older snapshot. +Select **Refresh** in the header selector and press the **Refresh** button after upgrading to +populate locations missing from an older snapshot. Inventory can relate a standalone skill and a plugin-contributed skill by exact name, bounded entrypoint digest, or bounded full-definition digest. Full-definition equality includes the @@ -542,7 +540,7 @@ transaction applies; follow the verb-specific options below. | Verb | What it does | |------|--------------| -| `[report] [--refresh[=live\|machine]] [--project-trees]` | `report` (the default verb) reads the last measurement. A bare `--refresh` refreshes Maintenance evidence and rebuilds the Inventory first (the dashboard's **Refresh evidence**); `--refresh=machine` re-measures System first and walks every discovery source to completion before rebuilding (the dashboard's **Re-measure machine**); `--project-trees` with `--refresh=machine` also measures your projects' working trees. | +| `[report] [--refresh[=live\|machine]] [--project-trees]` | `report` (the default verb) reads the last measurement. A bare `--refresh` refreshes Maintenance evidence and rebuilds the Inventory first (the dashboard's **Refresh**); `--refresh=machine` re-measures System first and walks every discovery source to completion before rebuilding (the dashboard's **Refresh machine**); `--project-trees` with `--refresh=machine` also measures your projects' working trees. | | `inventory [--scope S] [--view V] [--facet name=value ...] [--search TEXT] [--sort ORDER] [--cursor TOKEN] [--limit N]` | Queries placements. | | `show --placement ID [--reveal]` | Prints the inspector; `--reveal` prints the exact, owner-only path. | | `guidance [--lane LANE]` | Lists admitted Guidance entries and per-lane counts. | @@ -627,10 +625,13 @@ or an export contains a local path unless you reveal or export it deliberately. ## Dashboard security boundary -Maintenance is the only dashboard mutation surface. The v1 routes remain as compatibility: +Maintenance actions are the dashboard's exact-placement mutation surface. The v1 routes remain +for reads and explicit actions; refresh starts through the shared operation route: ```text -GET /api/maintenance (?refresh=scan runs the provider check, then rebuilds the Inventory) +GET /api/maintenance (reads the saved report) +POST /api/refresh (starts the selected refresh strength) +GET /api/refresh (reads operation progress) POST /api/maintenance/plans POST /api/maintenance/apply POST /api/maintenance/undo @@ -788,5 +789,6 @@ rows. Column headers stay pinned while dates and records scroll. Executable installations expose their measured launcher through **Reveal exact path**. Detection checks PATH (including Windows PATHEXT), resolves symlinks, and reads bounded npm `bin` metadata when a launcher is not on PATH. When only the installation root was measured, -that root remains revealable. Paths stay out of the public inventory. Re-measure machine -to collect new launcher evidence; discovery covers the environment running the scan. +that root remains revealable. Paths stay out of the public inventory. Select **Refresh machine** +in the header selector and press **Refresh** to collect new launcher evidence; discovery covers +the environment running the scan. diff --git a/docs/models.md b/docs/models.md index a24aa393..8c9c1906 100644 --- a/docs/models.md +++ b/docs/models.md @@ -236,7 +236,7 @@ browser never derives a provider or publisher from a name and never invents an e An `unknown` cell explains which evidence is absent. Model details separately name published or discovered status, account access, local routability, and the operator's next evidence step. The table labels the discovery dimension **Catalogued**, not **Available**, so provider publication or -local discovery cannot be mistaken for account entitlement. A local refresh now resolves OpenCode's +local discovery cannot be mistaken for account entitlement. A local model refresh resolves OpenCode's effective configuration, removing unknowns caused only by ignored global, JSONC, agent, or command layers. Discovery still does not establish entitlement; configuration does not establish successful use; and a model id never establishes the serving provider. Catalog Explorer model details show an diff --git a/docs/observability.md b/docs/observability.md index ee049371..d8a1ff98 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -23,8 +23,9 @@ ak dashboard \ > OpenCode process presence is observed. What you don't get: ruflo and agentic-qe activity, which are never > auto-discovered and only appear once you register their event file > explicitly (see [Evidence and limitations](#evidence-and-limitations)). -> `--live-source` reads a file that something else already writes. It does not make Ruflo or -> agentic-qe produce events. +> `--live-source` is **experimental**. Its schema and parser have fixture coverage, but no +> verified Ruflo or agentic-qe producer currently writes a structured live-events file. +> Registering a path reads that file; it does not start a producer or establish runtime coverage. Open `#observability/live` or `#observability/history`, for example `http://127.0.0.1:7431/#observability/live` — once the dashboard's per-session token is already in @@ -37,6 +38,14 @@ leaves, collectors stop after 30 seconds by default. The next request resumes ea stopped, so work written while nobody was watching still appears. Stopping the dashboard closes the live service and all clients. +When a native transcript leaves the newest-file window, the service keeps a bounded in-memory +reader state. A file that returns while that state is retained resumes at its prior byte offset; +its accepted-record count does not rise from replay alone. The retained state is limited to twice +the configured file bound, with a minimum of two readers. Once evicted, a returning file is read +from its start. A new dashboard process has no saved native offset; its first scan bootstraps +metadata and begins following existing files at their ends. The live view does not provide a +durable, exactly-once event archive. + Model lifecycle is a separate read model under **Usage → Models**. It may consume bounded model ids already derived by the historical usage index, but it never consumes live transcript content or changes Observability state. Public catalogue enrichment cannot rename, add, remove, or alter @@ -358,7 +367,8 @@ paths remain absolute. The parser rejects an unsupported/missing surface or an empty path, but registration does not prove that the file exists, is a regular file, is inside the current project, or is produced by the named subsystem. Registration also does not turn on event output: `--live-source` observes a file an -existing producer writes. Unreadable/malformed sources degrade their adapter rather than +external producer may write. No real Ruflo or agentic-qe producer has been verified for this +structured format. Unreadable/malformed sources degrade their adapter rather than crashing the dashboard. Only register a local file you trust the dashboard process to read. The structured adapter still constructs allowlisted events, so arbitrary JSON fields do not pass through to the browser. diff --git a/docs/plans/2026-09-26-issues-237-238-239-verification-and-decisions.md b/docs/plans/2026-09-26-issues-237-238-239-verification-and-decisions.md index bf641d53..07114b43 100644 --- a/docs/plans/2026-09-26-issues-237-238-239-verification-and-decisions.md +++ b/docs/plans/2026-09-26-issues-237-238-239-verification-and-decisions.md @@ -2540,3 +2540,20 @@ in a throwaway repository (run 36451224053) showed that the job token pushes the email). Each firing is recorded as a `fired` line, so a routine that fails is fired again at most once; the Actions API's last successful run replaces a daily heartbeat commit; #243 closes. Design: `docs/archive/2026-09-28-superpowers-spec-upstream-watch-ledger-branch-design.md`. + +### V3 dashboard refresh implementation status (2026-09-29) + +The V3 branch at `19953b6d` delivers Addendum 3 Item 4's dashboard half: one header +Refresh control with Refresh, Refresh live, and Refresh machine choices; Reload only +re-reads the active view. `POST /api/refresh` starts the ordered operation; +`GET /api/refresh` reads the latest process-local state. GET system and Maintenance +routes reject retired scan-starting query arguments. ADR-0063 records the operation +identity and volatile single-flight boundary, and supersedes ADR-0048's two old +controls, ADR-0025 §5's GET-started scan rationale, and ADR-0045's GET scan trigger. +ADR-0044 and ADR-0053 now point to the explicit POST and header Refresh paths. + +D-15 keeps ADR-0048 Accepted with partial delivery: the human usability, screen-reader, +and cross-platform evaluation gates move to v5. The V4 offline retry change is on a +separate unmerged branch; this V3 source retains ADR-0063 Known limitations item 1. +This entry records V3 task 6c-5's source-bound documentation integration, not final +branch acceptance or completion of the separately dispatched live-view work. diff --git a/docs/upgrading.md b/docs/upgrading.md index c6e61c49..0b93b93b 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -150,7 +150,7 @@ When the ChatGPT desktop app imports a Claude Code transcript, it saves a copy a Project discovery no longer counts these copies: they give a folder no Codex host and no Desktop origin, and System says how many it set aside. A snapshot taken before this change still holds the old hosts and origins, so the Footprint snapshot schema advances to v8. This build reports a v7 -snapshot as unreadable until you run **Full scan** in System or `ak system --refresh=machine`. It +snapshot as unreadable until you run **Refresh machine** in System or `ak system --refresh=machine`. It is never shown under the new rule. See [ADR-0060](adr/0060-session-surface-initiator-and-product-names.md) §3. ## 2026-09-27: Ruflo support window @@ -389,7 +389,7 @@ working context for the already-ranked top-N rows; it does not read prompts, tit System > Sessions renders the identity as one two-line transcript link: localized date/time first, then a shortened opaque native ID. Focus or hover discloses the original filename, full native ID, and detailed localized time with timezone. If an older snapshot or host has no declared opening -instant, the measured mtime is explicitly labeled **Last active**. Run **Full scan** or +instant, the measured mtime is explicitly labeled **Last active**. Run **Refresh machine** or `ak system --refresh=machine` to populate native identity for an existing snapshot; no configuration or payload migration is required. @@ -403,7 +403,7 @@ such as the user home from triggering several hundred thousand unrelated filesys Because that population is narrower than the v6 measurement contract, the Footprint snapshot schema advances to v7. A v6 snapshot is reported as unreadable by this build until the next explicit -**Full scan** or `ak system --refresh=machine`; it is never silently reinterpreted. +**Refresh machine** or `ak system --refresh=machine`; it is never silently reinterpreted. ## 2026-09-03: System Catalog snapshot v6 @@ -452,9 +452,10 @@ user's agentic-kit state directory. Existing System snapshot files remain read-o Catalog schema v4 is still refreshed with `ak system --refresh=machine`. `ak sync` neither selects nor executes Maintenance findings. -Browser refresh now reads the saved Maintenance report without polling providers. Use **Refresh -evidence** or `ak maintain --refresh` for current provider/version evidence. A successful System -deep rescan also chains one Maintenance scan after the snapshot is persisted. +Browser **Reload** reads the saved Maintenance report without polling providers. Use the header's +**Refresh** control with **Refresh** selected, or `ak maintain --refresh`, for current provider/version +evidence. A successful **Refresh machine** operation persists a new System snapshot before +updating Maintenance. The first provider set is intentionally narrower than the inventory. Claude plugin disable, update, and remove; exact Codex plugin/MCP removal; exact receipt-owned skill archive; one bounded diff --git a/docs/usage-scorecard-metrics.md b/docs/usage-scorecard-metrics.md index 4221efdd..ed402269 100644 --- a/docs/usage-scorecard-metrics.md +++ b/docs/usage-scorecard-metrics.md @@ -1951,7 +1951,7 @@ aggregated here, and deriving the baseline from a widened bound would silently stretch it to whatever lookback the caller happened to pass. The dashboard route widens it to the depth the personal tap-share baseline needs rather than to the previous window alone: `days + BASELINE_TRAILING_DAYS` (`lookbackDays`, -`src/lib/dashboard-server.mjs:1860`); `ak usage score` applies the same rule +`src/lib/dashboard-server.mjs:1872`); `ak usage score` applies the same rule (`src/commands/usage.mjs:316`). One extra window would be a strict subset — too shallow for `promptBaselines`, which needs BASELINE_MIN_ACTIVE_DAYS of history BEFORE the displayed window and returns diff --git a/src/lib/dashboard-server.mjs b/src/lib/dashboard-server.mjs index 5b86f267..cef33622 100644 --- a/src/lib/dashboard-server.mjs +++ b/src/lib/dashboard-server.mjs @@ -1,4 +1,6 @@ import { HOST_HEALTH_POST_ROUTES, handleHostHealthPost } from './dashboard/host-health-api.mjs'; +import { REFRESH_POST_ROUTES, REFRESH_LOCAL_TIMEOUT_MS, createRefreshOperation, + dashboardRefreshStages, handleRefreshPost, handleRefreshGet } from './dashboard/refresh-api.mjs'; import { createHostReadinessReader } from './host-readiness.mjs'; // dashboard-server.mjs — a read-only, localhost-only web dashboard for the kit. // @@ -37,14 +39,13 @@ import { createHostReadinessReader } from './host-readiness.mjs'; // GET /api/system → the machine-footprint payload (ADR-0025): the cheap // tier (runtime census + known-file stats, TTL-cached // ~60s) merged with the last persisted deep snapshot, -// carried forward with ITS asOf. `?refresh=deep` starts -// or attaches to the single-flight deep scan and returns -// immediately with progress state; `&trees=1|0` sets -// whether that scan walks project working trees. -// GET /api/system/summary → the same read (same `?refresh=deep&trees=`) +// carried forward with ITS asOf. This GET is read-only. +// GET /api/system/summary → the same read // with the catalog projected to what the System page // draws (dashboard/system-summary.mjs). The page and its // Runtime poll read this; /api/system stays complete. +// POST /api/refresh → starts one explicit staged refresh operation. +// GET /api/refresh → reads progress for that operation. // // The status rows are gathered by calling status.mjs's own collect() IN // PROCESS — safe only because the evidence store (ADR-0063) made a warm-cache @@ -148,16 +149,16 @@ const STATUS_TIMEOUT_MS = 30_000; * settles within STATUS_TIMEOUT_MS, resolves to an honest empty payload rather than * rejecting or hanging the server, so /api/status always answers with valid * JSON. */ -function inProcessStatus(cwd) { +function inProcessStatus(cwd, { refresh = false, timeoutMs = STATUS_TIMEOUT_MS } = {}) { return () => new Promise((resolve) => { let settled = false; const timer = setTimeout(() => { if (settled) return; settled = true; resolve({ overall: 'unknown', rows: [], error: 'status collection timed out' }); - }, STATUS_TIMEOUT_MS); + }, timeoutMs); timer.unref?.(); - statusCollect({ pkgRoot: PKG_ROOT, cwd, refresh: false }) + statusCollect({ pkgRoot: PKG_ROOT, cwd, refresh }) .then((rows) => ({ overall: worstLevel(rows), rows })) .catch((e) => ({ overall: 'unknown', rows: [], error: String(e?.message ?? e) })) .then((result) => { @@ -1086,7 +1087,7 @@ function lazyLive(liveOptions = {}) { * machineWideIntel?: (projects: Array) => any, * models?: any, modelScopeKey?: string, system?: any, systemOptions?: any, * maintenance?: any, maintenanceOptions?: any, hostReadiness?: any, - * management?: any, managementOptions?: any }} [opts] + * management?: any, managementOptions?: any, refreshStages?: Record Promise> }} [opts] * @returns {Promise<{ url: string, urlWithToken: string, port: number, token: string, close: () => Promise }>} */ export function startDashboard({ @@ -1097,7 +1098,7 @@ export function startDashboard({ transcriptClientBuffer = 64, transcriptMaxClients = 16, intelWatch, intelClientBuffer = 256, intelMaxClients = 32, discoverProjects, machineWideIntel, models, modelScopeKey, system, systemOptions = {}, - maintenance, maintenanceOptions = {}, management, managementOptions = {}, hostReadiness, + maintenance, maintenanceOptions = {}, management, managementOptions = {}, hostReadiness, refreshStages, } = {}) { const refused = refusesDefaultState({ fetchStatus, usage, limits, hooks, live, transcripts, intelWatch, discoverProjects, machineWideIntel, @@ -1166,7 +1167,7 @@ export function startDashboard({ const provideMaintenance = typeof maintenance === 'function' ? maintenance : maintenance ? async () => maintenance : async () => { if (refused) { - // Logged here because refreshMaintenanceAfterSystem swallows errors. + // Log once when the default Maintenance service is refused. if (!refusalLogged) { refusalLogged = true; console.error(HERMETIC_REFUSAL); } throw new TypeError(HERMETIC_REFUSAL); } @@ -1216,24 +1217,11 @@ export function startDashboard({ .finally(() => { inventoryRefreshPromise = null; }); return inventoryRefreshPromise; } - let maintenanceRefreshSource = null; - let maintenanceRefreshPromise = null; - function refreshMaintenanceAfterSystem(deepScan) { - if (maintenanceRefreshSource === deepScan) return maintenanceRefreshPromise; - maintenanceRefreshSource = deepScan; - maintenanceRefreshPromise = Promise.resolve(deepScan).then(async (result) => { - if (result?.ok !== true || result?.persisted?.ok === false) return null; - const model = await (await getMaintenance()).scan({ deep: false }); - refreshInventoryAfterProviderScan({ measured: true }); - return model; - }).catch(() => null).finally(() => { - if (maintenanceRefreshSource === deepScan) { - maintenanceRefreshSource = null; - maintenanceRefreshPromise = null; - } - }); - return maintenanceRefreshPromise; - } + const refreshOperation = createRefreshOperation({ stages: refreshStages ?? dashboardRefreshStages({ + cwd, pkgRoot: PKG_ROOT, getSystem, getMaintenance, refreshInventoryAfterProviderScan, + getHostReadiness, statusCollect: inProcessStatus(cwd, { refresh: true, timeoutMs: REFRESH_LOCAL_TIMEOUT_MS }), + loadConfig: loadKitConfig, + }) }); let transcriptServicePromise; const provideTranscripts = typeof transcripts === 'function' ? transcripts : transcripts ? async () => transcripts : async () => { @@ -1403,7 +1391,7 @@ export function startDashboard({ let maintenanceApiPromise; const getMaintenanceApi = async () => (maintenanceApiPromise ||= getMaintenance() .then((service) => createMaintenanceDashboardApi({ - service, management: getManagement, sessionToken: token, afterScan: refreshInventoryAfterProviderScan, + service, management: getManagement, sessionToken: token, }))); const server = http.createServer(async (req, res) => { @@ -1417,7 +1405,8 @@ export function startDashboard({ const maintenanceMutation = req.method === 'POST' && (MAINTENANCE_MUTATION_ROUTES.has(url) || MAINTENANCE_V2_MUTATION_ROUTES.has(url)); const healthMutation = req.method === 'POST' && HOST_HEALTH_POST_ROUTES.has(url); - if (req.method !== 'GET' && !maintenanceMutation && !healthMutation) { + const refreshMutation = req.method === 'POST' && REFRESH_POST_ROUTES.has(url); + if (req.method !== 'GET' && !maintenanceMutation && !healthMutation && !refreshMutation) { res.writeHead(405).end('method not allowed'); return; } @@ -1445,20 +1434,21 @@ export function startDashboard({ // Query tokens remain an SSE compatibility exception for GET. Mutation // capability can only be reached with the explicit header; it never rides // in a URL, browser history, referrer or server log. - const authorized = (maintenanceMutation || healthMutation) + const authorized = (maintenanceMutation || healthMutation || refreshMutation) ? tokenMatches(req.headers['x-dash-token'], token) : checkToken(req, query); if (url.startsWith('/api/') && !authorized) { sendUnauthorized(res, 'Wrong or missing dashboard token.'); return; } - if (maintenanceMutation || healthMutation) { + if (maintenanceMutation || healthMutation || refreshMutation) { const mutationRejection = maintenanceMutationRejection(req.headers); if (mutationRejection) { res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' }); res.end(mutationRejection); return; } - if (healthMutation) { await handleHostHealthPost(url, req, res, getHostReadiness); return; } + if (healthMutation) { await handleHostHealthPost(req, res, getHostReadiness); return; } + if (refreshMutation) { await handleRefreshPost(req, res, refreshOperation); return; } try { await (await getMaintenanceApi()).mutate(url, req, res); } catch { sendJson(res, 503, { error: 'maintenance operation unavailable' }); } return; @@ -1987,35 +1977,13 @@ export function startDashboard({ // `project` shapes the answer for a route: identity for /api/system (the // documented `ak system --json` shape), systemSummaryPayload for the page. async function handleSystem(req, res, query, project = (payload) => payload) { + if (query.has('refresh') || query.has('trees')) { + sendJson(res, 400, { error: 'start a refresh with POST /api/refresh' }); + return; + } try { const collector = await getSystem(); - // ORDER IS LOAD-BEARING: assemble the payload BEFORE starting a scan. - // The deep collectors are synchronous, so the first phase occupies the - // event loop the moment it gets a turn — and `read()` awaits, which - // hands it that turn. Starting first therefore made the *initiating* - // request wait out the phase it had just kicked off (measured: 9s), - // which is precisely the hang the progress state exists to avoid. const payload = await collector.read(); - if (query.get('refresh') === 'deep') { - // Start-or-attach and answer NOW. The collector's single flight means - // a second refresh joins the running scan rather than racing it, and - // it never rejects — the catch guards an injected collector that does - // not honour that contract, so a bad one cannot take the process down - // with an unhandled rejection. - // `trees` is a MEASUREMENT parameter, not a view filter: project - // working trees are only walked when it is set, and one large - // repository outweighs every shared cache combined — so the ranking - // has to be re-measured, not re-sorted. Absent means "keep whatever - // the collector already defaults to". - const trees = query.get('trees'); - const deepScan = Promise.resolve(collector.refreshDeep( - trees == null ? undefined : { includeProjectTrees: trees === '1' }, - )); - refreshMaintenanceAfterSystem(deepScan); - // The payload predates the start by microseconds; re-stamp the live - // scan block so this response reads "running", not "idle". - if (typeof collector.scanState === 'function') payload.scan = collector.scanState(); - } sendJson(res, 200, project(payload)); } catch (e) { sendJson(res, 503, { error: 'system footprint unavailable', reason: String(e && e.message || e) }); @@ -2024,13 +1992,11 @@ export function startDashboard({ } async function handleMaintenance(req, res, query) { - const refresh = query.getAll('refresh'); - if ([...query.keys()].some((key) => key !== 'refresh') - || refresh.length > 1 || (refresh.length === 1 && refresh[0] !== 'scan')) { - sendJson(res, 400, { error: 'invalid maintenance scan request' }); + if (query.size > 0) { + sendJson(res, 400, { error: 'start a refresh with POST /api/refresh' }); return; } - try { await (await getMaintenanceApi()).report(req, res, { refresh: query.get('refresh') === 'scan' }); } + try { await (await getMaintenanceApi()).report(req, res); } catch { sendJson(res, 503, { error: 'maintenance evidence unavailable' }); } return; } @@ -2113,6 +2079,7 @@ export function startDashboard({ // out separately into sse.mjs's sseRoute(). const ROUTES = { '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/api/status': handleStatus, + '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/api/refresh': (_req, res) => handleRefreshGet(res, refreshOperation), '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/api/host-health': async (_req, res) => { try { sendJson(res, 200, await getHostReadiness()); } catch { sendJson(res, 503, { error: 'Host health checks unavailable.' }); } diff --git a/src/lib/dashboard/client.mjs b/src/lib/dashboard/client.mjs index 90d166ba..47c217fb 100644 --- a/src/lib/dashboard/client.mjs +++ b/src/lib/dashboard/client.mjs @@ -94,6 +94,7 @@ const datetimeSrc = readSplit('datetime.mjs'); const hostReadinessSrc = readSplit('host-readiness.mjs'); const intelligenceSrc = readSplit('intelligence.mjs'); const pollSrc = readSplit('poll.mjs'); +const refreshControlSrc = readSplit('refresh-control.mjs'); // usage-rhythm.mjs declares its OWN `esc` on disk, and its comment says why: // the tests import it as real ESM, where bootstrap.mjs's `esc` is still the // build-time stub. In the concatenated bundle every file shares ONE scope, so @@ -164,5 +165,5 @@ const bootSrc = readSplit('boot.mjs'); export const JS = ` (function(){ ${bootstrapSrc}${contextCard.toString()}${contextHostCard.toString()}${repositoryTree.toString()}${overviewSrc}${datetimeSrc}${hostReadinessSrc} -${intelligenceSrc}${pollSrc}${usageRhythmSrc}${usagePromptsSrc}${usageContextHooksSrc}${usageSrc}${modelLifecycleSrc}${usageOrchestratorsSrc}${rufloComponentsSrc}${aboutSrc}${systemReadoutSrc}${systemProjectsSrc}${maintenanceWorkspaceSrc}${maintenanceFiltersSrc}${maintenanceCardsSrc}${maintenanceOperationSrc}${maintenanceLanguageLogosSrc}${maintenanceFocusSrc}${maintenanceInventorySrc}${maintenanceRelationshipsSrc}${maintenanceInspectorSrc}${maintenanceGuidanceSrc}${maintenanceDiscoverySrc}${maintenanceActivitySrc}${systemMaintenanceActionsSrc}${systemMaintenanceSrc}${bootSrc}})(); +${intelligenceSrc}${pollSrc}${refreshControlSrc}${usageRhythmSrc}${usagePromptsSrc}${usageContextHooksSrc}${usageSrc}${modelLifecycleSrc}${usageOrchestratorsSrc}${rufloComponentsSrc}${aboutSrc}${systemReadoutSrc}${systemProjectsSrc}${maintenanceWorkspaceSrc}${maintenanceFiltersSrc}${maintenanceCardsSrc}${maintenanceOperationSrc}${maintenanceLanguageLogosSrc}${maintenanceFocusSrc}${maintenanceInventorySrc}${maintenanceRelationshipsSrc}${maintenanceInspectorSrc}${maintenanceGuidanceSrc}${maintenanceDiscoverySrc}${maintenanceActivitySrc}${systemMaintenanceActionsSrc}${systemMaintenanceSrc}${bootSrc}})(); `; diff --git a/src/lib/dashboard/client/boot.mjs b/src/lib/dashboard/client/boot.mjs index a825ccaa..7b9f6cda 100644 --- a/src/lib/dashboard/client/boot.mjs +++ b/src/lib/dashboard/client/boot.mjs @@ -6,6 +6,7 @@ import { renderAbout, wireAboutNudge } from './about.mjs'; import { activeTab, initialLiveScope, setSystemView, setTab, syncHash, systemView } from './bootstrap.mjs'; import { tickClock, wireIntelPicker } from './intelligence.mjs'; import { pollStatus, schedulePoll, wirePoll, wireStripCollapse } from './poll.mjs'; +import { wireRefresh } from './refresh-control.mjs'; import { renderSystemFreshness, wireCatalogFilters, wireSystem } from './system-projects.mjs'; import { wireMaintenance } from './system-maintenance.mjs'; import { wireUsage } from './usage-orchestrators.mjs'; @@ -22,6 +23,7 @@ import { loadUsage, setUsageView } from './usage.mjs'; renderSystemFreshness(); wireHostHealth(); wirePoll(); + wireRefresh(); wireUsage(); wireIntelPicker(); wireAboutNudge(); diff --git a/src/lib/dashboard/client/host-readiness.mjs b/src/lib/dashboard/client/host-readiness.mjs index c05aef0b..8fd5dc47 100644 --- a/src/lib/dashboard/client/host-readiness.mjs +++ b/src/lib/dashboard/client/host-readiness.mjs @@ -1,6 +1,7 @@ // @ts-nocheck — browser bundle source; assembled by ../client.mjs. import { esc, authHeaders } from './bootstrap.mjs'; import { sourceHostIcon } from './usage.mjs'; +import { startRefresh, refreshRunning } from './refresh-control.mjs'; var HEALTH_REPORT=null, HEALTH_HOST=null, HEALTH_BUSY=false, HEALTH_BUSY_HOST=null, HEALTH_ACK=null; var HEALTH_NAMES={claude:'Claude Code',codex:'Codex',opencode:'OpenCode'}; @@ -30,7 +31,9 @@ export function renderHostReadiness(report,checking){ el.hidden=false; el.innerHTML=['claude','codex','opencode'].map(function(host){ var row=report&&report.hosts&&report.hosts[host]; - var state=checking||(HEALTH_BUSY&&HEALTH_BUSY_HOST===host)?'checking':row&&HEALTH_LABELS[row.status]?row.status:'unknown'; + var configuration=row&&row.checks&&row.checks.configuration; + var unassessed=host==='claude'&&configuration&&['unknown','not-run','not-checked'].includes(configuration.state); + var state=checking||(HEALTH_BUSY&&HEALTH_BUSY_HOST===host)?'checking':unassessed?'unknown':row&&HEALTH_LABELS[row.status]?row.status:'unknown'; var managed=!row||hostIsManaged(row); // A managed host's badge is its health; any other host's badge is its // management word, in a neutral colour: its problems are information. @@ -123,7 +126,7 @@ function renderHealthDialog(){ if(!row||HEALTH_ACK!==row.evidenceKey){consent.checked=false;HEALTH_ACK=null;} consent.disabled=HEALTH_BUSY||!row||!row.canCheckConnection; document.getElementById('host-health-connect').disabled=HEALTH_BUSY||!row||!row.canCheckConnection||!consent.checked; - document.getElementById('host-health-refresh').disabled=HEALTH_BUSY; + document.getElementById('host-health-run-refresh').disabled=HEALTH_BUSY||refreshRunning(); } async function runHealthCheck(connected){ @@ -175,7 +178,7 @@ export function wireHostHealth(){ var button=region.querySelector('[data-health-host="'+HEALTH_HOST+'"]');if(button)button.focus(); }); document.getElementById('host-health-consent').addEventListener('change',function(event){HEALTH_ACK=event.target.checked&&healthRow()?healthRow().evidenceKey:null;renderHealthDialog();}); - document.getElementById('host-health-refresh').addEventListener('click',function(){runHealthCheck(false);}); + document.getElementById('host-health-run-refresh').addEventListener('click',function(){startRefresh('local');}); document.getElementById('host-health-connect').addEventListener('click',function(){runHealthCheck(true);}); dialog.addEventListener('click',function(event){ var button=event.target.closest('[data-copy]'); diff --git a/src/lib/dashboard/client/maintenance-activity.mjs b/src/lib/dashboard/client/maintenance-activity.mjs index d075d107..611351e7 100644 --- a/src/lib/dashboard/client/maintenance-activity.mjs +++ b/src/lib/dashboard/client/maintenance-activity.mjs @@ -49,19 +49,23 @@ import { beginMaintUndo } from './system-maintenance-actions.mjs'; +(entry.at?" — "+esc(mntAge(entry.at)):"")+""; } var mntExpandedHistoryDays=new Set(); + function mntScanTime(entry){ + var recorded=entry&&entry.recordedAt,completed=entry&&entry.completedAt; + return recorded&&Number.isFinite(Date.parse(recorded))?recorded:(completed&&Number.isFinite(Date.parse(completed))?completed:null); + } function renderMntScanHistory(){ var el=document.getElementById("mnt-scan-history");if(!el)return; var history=(MNT.activity&&(MNT.activity.scanHistory||MNT.activity.scans))||[]; - if(!history.length){el.innerHTML="

Scan history

No scans have completed yet.

";return;} + if(!history.length){el.innerHTML="

Scan history

No scan records yet.

";return;} var groups=new Map(); - history.slice().sort(function(a,b){return (Date.parse(b.completedAt)||0)-(Date.parse(a.completedAt)||0);}).forEach(function(entry){ - var day=formatLocalDay(entry.completedAt)||'Date not recorded'; + history.slice().sort(function(a,b){return (Date.parse(mntScanTime(b))||0)-(Date.parse(mntScanTime(a))||0);}).forEach(function(entry){ + var day=formatLocalDay(mntScanTime(entry))||'Date not recorded'; if(!groups.has(day))groups.set(day,[]); groups.get(day).push(entry); }); - el.innerHTML='

Scan history

' + el.innerHTML='

Scan history

Scanned sources grouped by local date, newest first
Date / timeSource scannedStatusEntries
' +Array.from(groups,function(group,index){var expanded=mntExpandedHistoryDays.has(group[0]);return '' - +group[1].map(function(entry){return '';}).join('')+'';}).join('')+'
Scan records grouped by local date, newest first
Date / timeSourceStatusEntries
'+esc(formatLocalTime(entry.completedAt)||'Time not recorded')+''+esc(entry.label||'Source no longer configured')+''+esc(MNT_SOURCE_COVERAGE_LABELS[entry.state]||(entry.state==='published'?'Complete':entry.state)) + +group[1].map(function(entry){return ''+esc(formatLocalTime(mntScanTime(entry))||'Time not recorded')+''+esc(entry.label||'Source no longer configured')+''+esc(MNT_SOURCE_COVERAGE_LABELS[entry.state]||(entry.state==='published'?'Complete':entry.state)) +(entry.limitingReason?''+esc(entry.limitingReason)+'':'')+''+(Number.isFinite(entry.visited)?esc(entry.visited.toLocaleString()):'—')+'
'; el.onclick=function(event){ var button=event.target.closest&&event.target.closest('[data-mnt-history-day]'); diff --git a/src/lib/dashboard/client/maintenance-discovery.mjs b/src/lib/dashboard/client/maintenance-discovery.mjs index cf6effe7..8774f6f4 100644 --- a/src/lib/dashboard/client/maintenance-discovery.mjs +++ b/src/lib/dashboard/client/maintenance-discovery.mjs @@ -144,7 +144,7 @@ import { MNT, mntAge, MNT_SOURCE_COVERAGE_LABELS, mntGet, mntPost, mntRegisterDe // optional structured per-source detail, not text — this workspace has // no use for it beyond what `coverage` already gives, so it stays unread. var narrative=(MNT.discovery&&(MNT.discovery.narrative||MNT.discovery.progress))||""; - // Automatic sources are covered by Re-measure machine (System Full scan) + // Automatic sources are covered by Refresh machine (machine measurement) // and carry no per-source controls; only roots the user added expose // Pause/Stop while running, Resume/Stop while paused, and Retry after a // failure. A user root starts scanning when it is saved (no "scan now"). @@ -170,7 +170,7 @@ import { MNT, mntAge, MNT_SOURCE_COVERAGE_LABELS, mntGet, mntPost, mntRegisterDe +(entry.limitingReason?''+esc(entry.limitingReason)+'':'') +(entry.lastCompletedAt?''+esc(mntAge(entry.lastCompletedAt))+'':'')+' '+controls+''; }).join('')+'' - +'

Evidence checks

Runtimes, package managers, Ollama, and providers are checked by Refresh evidence, separately from filesystem coverage. The toolbar reports the latest operation outcome.

' + +'

Evidence checks

Runtimes, package managers, Ollama, and providers are checked by Refresh, separately from filesystem coverage. The toolbar reports the latest operation outcome.

' +'

Project coverage

'+esc(MNT.discovery&&MNT.discovery.projectCoverageNote||'Configured project roots contribute to filesystem coverage. Machine-discovered projects appear in Inventory.')+'

'; } export function renderMntDiscovery(){ diff --git a/src/lib/dashboard/client/maintenance-guidance.mjs b/src/lib/dashboard/client/maintenance-guidance.mjs index eef6576a..5ece2538 100644 --- a/src/lib/dashboard/client/maintenance-guidance.mjs +++ b/src/lib/dashboard/client/maintenance-guidance.mjs @@ -205,7 +205,7 @@ import { beginMaintPreview, beginMaintReconcile } from './system-maintenance-act var el=document.getElementById('mnt-guidance-coverage');if(!el)return; var coverage=MNT.guidance&&MNT.guidance.coverage||[]; el.innerHTML='

Guidance shows evidence-backed issues, updates, and recovery work. Optional management actions are available in Inventory.

' - +(coverage.length?'
Host coverage

Counts reflect saved evidence, not a health assessment. Action checks cover the listed resource types in host-specific adapters. Refresh evidence to update these checks.

    '+coverage.map(function(row){ + +(coverage.length?'
    Host coverage

    Counts reflect saved evidence, not a health assessment. Action checks cover the listed resource types in host-specific adapters. Refresh to update these checks.

      '+coverage.map(function(row){ return '
    • '+esc(row.label)+' · '+esc(row.placements)+' installations · '+esc(row.recommendations)+' guidance items · '+esc(row.optionalActions)+' optional actions
      '+esc(row.actionStatusLabel) +((row.actionKinds||[]).length?' ('+esc(row.actionKinds.map(mntKindLabel).join(', '))+')':'')+'
    • '; }).join('')+'
    ':''); @@ -282,7 +282,7 @@ import { beginMaintPreview, beginMaintReconcile } from './system-maintenance-act el.querySelector("#mnt-procedure-close").focus(); }).catch(function(){ if(seq!==mntProcedureSeq||!el.open)return; - el.innerHTML='

    This procedure could not be loaded. Refresh evidence and try again.

    '; + el.innerHTML='

    This procedure could not be loaded. Refresh and try again.

    '; el.querySelector("#mnt-procedure-close").focus(); }); } diff --git a/src/lib/dashboard/client/maintenance-inspector.mjs b/src/lib/dashboard/client/maintenance-inspector.mjs index a8eac097..bfd3797f 100644 --- a/src/lib/dashboard/client/maintenance-inspector.mjs +++ b/src/lib/dashboard/client/maintenance-inspector.mjs @@ -134,7 +134,7 @@ import { mntRenderGuidanceEntry, mntWireGuidanceActions } from './maintenance-gu if(MNT.inspector&&MNT.inspector.scanRequired){ el.hidden=false; el.innerHTML=mntInspectorCloseButton()+'

    No inventory has been built yet. ' - +"Use Refresh evidence, above, to build it.

    "; + +"Use Refresh, above, to build it.

    "; return; } if(mntInspectorError||!MNT.inspector){ @@ -194,7 +194,7 @@ import { mntRenderGuidanceEntry, mntWireGuidanceActions } from './maintenance-gu mntRevealed=result;mntRevealError=null;renderMntInspector(); }).catch(function(){ if(seq!==mntInspectorSeq||placementId!==MNT.plc)return; - mntRevealError="The exact path could not be revealed. Refresh evidence and try again.";renderMntInspector(); + mntRevealError="The exact path could not be revealed. Refresh and try again.";renderMntInspector(); }); } diff --git a/src/lib/dashboard/client/maintenance-operation.mjs b/src/lib/dashboard/client/maintenance-operation.mjs index 0c9d6cb2..1ca3c0ba 100644 --- a/src/lib/dashboard/client/maintenance-operation.mjs +++ b/src/lib/dashboard/client/maintenance-operation.mjs @@ -1,11 +1,11 @@ // @ts-nocheck — dashboard browser bundle. import { MNT, mntGet, mntRefreshActiveDestination } from './maintenance-workspace.mjs'; -import { loadSystem } from './system-projects.mjs'; -import { SYSTEM, systemBusy } from './system-readout.mjs'; +import { SYSTEM } from './system-readout.mjs'; +import { refreshRunning } from './refresh-control.mjs'; var mntOperationTimer=null,mntOperationWired=false; var MNT_SCAN_PHASES={install:'Reading installed tools',storage:'Measuring retained data',catalog:'Comparing skills, plugins, and MCP servers',projects:'Measuring projects',consumers:'Ranking disk use',persist:'Saving report'}; -export function mntWritesBlocked(){return !!(MNT.providersBusy||MNT.remeasureBusy||MNT.externalScanBusy);} +export function mntWritesBlocked(){return !!(refreshRunning()||MNT.externalScanBusy);} export function mntOperationText(){return MNT.operation&&MNT.operation.message||'No measurement in progress.';} function mntElapsed(started){ var seconds=Math.max(0,Math.floor((Date.now()-started)/1000)); @@ -13,7 +13,6 @@ function mntElapsed(started){ } export function renderMntOperation(){ var op=MNT.operation,busy=mntWritesBlocked(); - ['mnt-check-providers','mnt-remeasure'].forEach(function(id){var button=document.getElementById(id);if(button)button.disabled=busy;}); var el=document.getElementById('mnt-check-providers-status'); if(el){ var message=op?op.message:''; @@ -31,7 +30,7 @@ function mntSetOperation(message){ MNT.operation.message=message;renderMntOperation(); } function mntBeginOperation(kind){ - MNT.operation={kind:kind,startedAt:Date.now(),message:kind==='measure'?'Preparing measurement…':'Refreshing evidence…',failed:false}; + MNT.operation={kind:kind,startedAt:Date.now(),message:kind==='measure'?'Preparing measurement…':'Refreshing…',failed:false}; if(mntOperationTimer)clearInterval(mntOperationTimer); mntOperationTimer=setInterval(renderMntOperation,1000); renderMntOperation();mntRefreshActiveDestination(); @@ -41,16 +40,6 @@ function mntSetMeasurement(scan){ mntSetOperation(phase+(scan.total?' · '+scan.scanned+' of '+scan.total:'')); } function mntDelay(ms){return new Promise(function(resolve){setTimeout(resolve,ms);});} -function mntPollSystemMeasurement(){ - // Publish completion through System so its scheduled poll cannot replay stale running state. - return loadSystem().then(function(){ - var data=SYSTEM; - if(!data||data.error||!data.scan)throw new Error('The measurement status could not be read.'); - if(data.scan.running){mntSetMeasurement(data.scan);return mntDelay(3000).then(mntPollSystemMeasurement);} - if(data.scan.error)throw new Error('The measurement reported a problem.'); - mntSetOperation('Machine measured · refreshing evidence…'); - }); -} export function mntBuildStatusOf(page){ var refresh=page&&page.lastRefresh; if(refresh&&(refresh.status==='running'||refresh.status==='failed'))return refresh.status; @@ -81,7 +70,7 @@ function mntPollProviders(previousCheck){ if(activity&&activity.status==='failed')throw new Error('The evidence check failed.'); var fresh=previousCheck===undefined||(scan&&scan.checkedAt!==previousCheck); if(activity&&activity.status==='running'||!fresh){ - mntSetOperation('Refreshing evidence…'); + mntSetOperation('Refreshing…'); if(Date.now()-started>300000)throw new Error('The evidence check is still pending.'); return mntDelay(2000).then(tick); } @@ -92,27 +81,6 @@ function mntPollProviders(previousCheck){ });} return tick(); } -function mntRunOperation(measure){ - if(mntWritesBlocked()||(measure&&systemBusy))return Promise.resolve(); - MNT.remeasureBusy=measure;MNT.providersBusy=!measure;mntBeginOperation(measure?'measure':'evidence'); - var previousAt,previousCheck; - return Promise.all([mntGet('/api/maintenance/v2/inventory?limit=1'),mntGet('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/api/maintenance')]).then(function(before){ - previousAt=before[0].lastRefresh&&before[0].lastRefresh.at; - previousCheck=before[1].scan&&before[1].scan.checkedAt; - if(measure){if(systemBusy)throw new Error('Another system request is in progress. Please retry.');return loadSystem(true).then(function(){if(!SYSTEM||SYSTEM.error)throw new Error('The measurement could not be started.');return mntPollSystemMeasurement();});} - return mntGet('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/api/maintenance?refresh=scan'); - }).then(function(){return mntPollProviders(previousCheck);}) - .then(function(){return mntAwaitInventoryBuild(previousAt);}) - .then(function(){mntSetOperation(MNT.operation.staleMeasurement?'Evidence refreshed · machine measurement is stale. Re-measure machine.':MNT.operation.gaps?'Finished with coverage gaps · see Discovery.':'Inventory updated · checks complete.');}) - .catch(function(error){MNT.operation.failed=true;mntSetOperation(error.message+' Previous inventory remains available.');}) - .then(function(){ - MNT.remeasureBusy=false;MNT.providersBusy=false; - if(mntOperationTimer){clearInterval(mntOperationTimer);mntOperationTimer=null;} - renderMntOperation();mntRefreshActiveDestination(); - }); -} -export function mntRemeasureMachine(){return mntRunOperation(true);} -export function mntCheckProviders(){return mntRunOperation(false);} function mntObserveSystemScan(scan){ if(MNT.remeasureBusy||MNT.providersBusy){ MNT.externalScanBusy=!!(scan&&scan.running); @@ -133,7 +101,7 @@ function mntObserveSystemScan(scan){ Promise.resolve(op.baseline).then(function(before){ if(scan&&scan.error)throw new Error('The measurement reported a problem.'); return mntPollProviders(before&&before.check).then(function(){return mntAwaitInventoryBuild(before&&before.at);}); - }).then(function(){mntSetOperation(op.staleMeasurement?'Evidence refreshed · machine measurement is stale. Re-measure machine.':op.gaps?'Finished with coverage gaps · see Discovery.':'Inventory updated · checks complete.');}) + }).then(function(){mntSetOperation(op.staleMeasurement?'Evidence refreshed · machine measurement is stale. Refresh machine.':op.gaps?'Finished with coverage gaps · see Discovery.':'Inventory updated · checks complete.');}) .catch(function(error){op.failed=true;mntSetOperation(error.message+' Previous inventory remains available.');}) .then(function(){MNT.externalScanBusy=false;clearInterval(mntOperationTimer);mntOperationTimer=null;renderMntOperation();mntRefreshActiveDestination();}); } diff --git a/src/lib/dashboard/client/maintenance-workspace.mjs b/src/lib/dashboard/client/maintenance-workspace.mjs index 1ccfa42b..830f7905 100644 --- a/src/lib/dashboard/client/maintenance-workspace.mjs +++ b/src/lib/dashboard/client/maintenance-workspace.mjs @@ -10,7 +10,7 @@ // a node module — tests/kit/maintenance-dashboard-client-labels.test.mjs // asserts these literal maps stay byte-identical to that contract). import { authHeaders, esc } from './bootstrap.mjs'; -import { mntCheckProviders, mntRemeasureMachine, mntWireOperation } from './maintenance-operation.mjs'; +import { mntWireOperation } from './maintenance-operation.mjs'; import { ago } from './intelligence.mjs'; // ── Label vocabulary (copied from src/lib/maintenance/management/model.mjs) ─ @@ -186,7 +186,7 @@ import { ago } from './intelligence.mjs'; if(lastRefresh&&lastRefresh.status==="running"){ return '
    Building the inventory…
    '; } - return '
    No inventory has been built yet. Use Refresh evidence, above, to build it.
    '; + return '
    No inventory has been built yet. Use Refresh, above, to build it.
    '; } export function mntScanRequiredAnnouncement(lastRefresh){ if(lastRefresh&&lastRefresh.status==="failed"){ @@ -241,8 +241,16 @@ import { ago } from './intelligence.mjs'; }; } + var mntLastSyncedHash=null; export function mntSyncHash(){ - try{if(history.replaceState)history.replaceState(null,"",mntHash());}catch(e){} + var current=String(location.hash||""); + if(current&&!/^#system\/(?:maintenance|catalog)(?:\/|$)/.test(current))return; + if(mntLastSyncedHash!==null&¤t!==mntLastSyncedHash){ + var state=mntApplyHashState(); + if(state&&state.hasState)mntApplyState(state); + } + var next=mntHash(); + try{if(history.replaceState)history.replaceState(null,"",next);mntLastSyncedHash=next;}catch(e){} } // ── Preferences (owner-private; URL state overrides it on load, MNT-PRV-006) ─ @@ -434,8 +442,4 @@ import { ago } from './intelligence.mjs'; MNT.wired=true; mntWireTabs();mntWireOperation(); document.addEventListener("keydown",mntHandleEscape); - var checkProviders=document.getElementById("mnt-check-providers"); - if(checkProviders)checkProviders.addEventListener("click",mntCheckProviders); - var remeasure=document.getElementById("mnt-remeasure"); - if(remeasure)remeasure.addEventListener("click",mntRemeasureMachine); } diff --git a/src/lib/dashboard/client/poll.mjs b/src/lib/dashboard/client/poll.mjs index 9aed4904..1c161dec 100644 --- a/src/lib/dashboard/client/poll.mjs +++ b/src/lib/dashboard/client/poll.mjs @@ -13,7 +13,7 @@ import { loadModelLifecycle, loadUsage } from './usage.mjs'; // Governs EVERY tab, not just Usage (ADR-0009 §7). The old hardcoded 5 s poll // predated any expensive view; 30 s is the default now, and the whole range is // user-chosen and persisted. Every refresh path — automatic or manual — funnels - // through refreshAll(), so the single-flight guard and the cooldown are + // through reloadView(), so the single-flight guard and the cooldown are // impossible to route around. var LS_POLL="ak-dash-poll"; var POLL_DEFAULT_MS=30000; @@ -21,6 +21,7 @@ import { loadModelLifecycle, loadUsage } from './usage.mjs'; var POLL_LABEL={15000:"15s",30000:"30s",60000:"1m",300000:"5m",900000:"15m", 1800000:"30m",3600000:"1h",21600000:"6h",43200000:"12h",86400000:"24h"}; export var pollOn=true, pollMs=POLL_DEFAULT_MS, pollTimer=null, inflight=false, lastAttempt=0; + var lastManualAttempt=0; try{ var savedPoll=JSON.parse(localStorage.getItem(LS_POLL)||"null"); @@ -128,11 +129,17 @@ import { loadModelLifecycle, loadUsage } from './usage.mjs'; }); } - function refreshAll(){ + export function reloadView(force){ + // A deliberate Reload should not be lost to a recent background tick. + // Still coalesce a double-click before joining an in-flight read. + if(force==="manual"){ + if(Date.now()-lastManualAttempt0 + &&Number.isFinite(Date.parse(state.startedAt)) + &&typeof state.running==='boolean'&&Array.isArray(state.stages) + &&(state.running||typeof state.ok==='boolean'); +} +function refreshText(state){ + var stages=state&&state.stages||[]; + var active=stages.find(function(stage){return stage.state==='running';}); + var latest=active||stages[stages.length-1]; + var elapsed=Math.max(0,Math.floor((Date.now()-refreshStartedAt)/1000)); + if(state&&state.running)return (latest&&latest.label||'Preparing refresh')+' · '+elapsed+'s'; + return state&&state.ok?'Refresh complete.':state?'Refresh did not complete.':''; +} +function renderRefresh(state,message){ + var button=document.getElementById('refresh-run'),status=document.getElementById('refresh-status'); + if(button)button.disabled=refreshBusy; + if(status)status.textContent=message||refreshText(state); + document.querySelectorAll('[data-mnt-plan-plc], [data-mnt-undo-receipt], [data-mnt-reconcile-receipt]').forEach(function(action){ + if(refreshBusy){ + if(action.dataset.refreshWasDisabled===undefined)action.dataset.refreshWasDisabled=action.disabled?'1':'0'; + action.disabled=true; + }else if(action.dataset.refreshWasDisabled!==undefined){ + action.disabled=action.dataset.refreshWasDisabled==='1'; + delete action.dataset.refreshWasDisabled; + } + }); + if(refreshRenderedBusy!==refreshBusy){refreshRenderedBusy=refreshBusy;if(refreshBusy)mntRefreshActiveDestination();} +} +function refreshPoll(){ + if(!refreshBusy)return; + fetch('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/api/refresh',{cache:'no-store',headers:authHeaders()}).then(function(response){ + if(!response.ok)throw new Error('Refresh status unavailable.'); + return response.json(); + }).then(function(state){ + if(!validRefreshState(state))throw new Error('Refresh status was incomplete.'); + if(refreshOperationId===null&&refreshRequireNewer&&Date.parse(state.startedAt) ({ kind: 'dict', value, max, keyPrefix: prefix }), either: (...options) => ({ kind: 'either', options }), /** ISO timestamp, bounded machine token, and a user-facing label that refuses PROHIBITED_LABELS. */ - iso: Object.freeze({ kind: 'iso' }), token: (max = 64) => ({ kind: 'token', max }), label: (max = 200) => ({ kind: 'label', max }), + iso: Object.freeze({ kind: 'iso' }), scanStamp: Object.freeze({ kind: 'scanStamp' }), token: (max = 64) => ({ kind: 'token', max }), label: (max = 200) => ({ kind: 'label', max }), }); const [DICT_KEY, MAX_PAGE_ROWS, TOKEN] = [/^[A-Za-z0-9._:-]{1,120}$/, 200, /^[A-Za-z0-9._:-]+$/]; @@ -430,6 +431,7 @@ const SCALARS = Object.freeze({ label: (node, value) => { const safe = evidenceText(value, node.max); return safe && !isProhibitedLabel(safe) ? safe : undefined; }, token: (node, value) => (typeof value === 'string' && value.length <= node.max && TOKEN.test(value) ? value : undefined), iso: (_node, value) => (typeof value === 'string' && value.length <= 40 && Number.isFinite(Date.parse(value)) ? value : undefined), + scanStamp: (_node, value) => validScanTime(value) ?? undefined, owner: (node, value) => text(value, node.max) ?? undefined, bool: (_node, value) => (typeof value === 'boolean' ? value : undefined), int: (_node, value) => (Number.isInteger(value) ? value : undefined), @@ -585,7 +587,7 @@ const CONFIGURED_SOURCE = T.obj({ sourceId: ID, kind: T.oneOf(SOURCE_TYPES), root: T.owner(1024), label: LABEL, maxDepth: T.int, includeNetwork: T.bool, present: T.bool, }); const SCAN_SUMMARY = T.obj({ - scanId: ID, sourceId: ID, environmentId: ID, state: T.oneOf(SCAN_STATES), startedAt: STAMP, completedAt: STAMP, visited: T.int, + scanId: ID, sourceId: ID, environmentId: ID, state: T.oneOf(SCAN_STATES), startedAt: STAMP, recordedAt: T.scanStamp, completedAt: STAMP, visited: T.int, limitingReason: T.oneOf(LIMITING_REASONS), ceiling: T.oneOf(SAFETY_CEILINGS), label: LABEL, }); const DISCOVERY = T.obj({ @@ -837,12 +839,10 @@ function reconcileConfirmation({ receiptId, outcome, audit, now }) { * now?: () => number, * capabilities?: ReturnType, * scanAckMs?: number, - * afterScan?: () => any, - * }} options `afterScan` runs (never awaited) after a successful `?refresh=scan` - * provider scan so the server can chain the inventory rebuild. + * }} options */ export function createMaintenanceDashboardApi({ - service, management = null, sessionToken, now = Date.now, capabilities, scanAckMs = 250, afterScan = null, + service, management = null, sessionToken, now = Date.now, capabilities, scanAckMs = 250, } = {}) { if (!service || typeof service.report !== 'function' || typeof service.scan !== 'function' || typeof service.plan !== 'function') { @@ -860,11 +860,9 @@ export function createMaintenanceDashboardApi({ const withActivity = (model) => ({ ...model, activity: typeof service.scanState === 'function' ? service.scanState() : null }); - async function report(_req, res, { refresh = false } = {}) { + async function report(_req, res) { try { - const model = await (refresh ? service.scan() : service.report()); - // The chained inventory rebuild is fire-and-forget: the scan response stands on its own. - if (refresh && typeof afterScan === 'function') { try { Promise.resolve(afterScan()).catch(() => {}); } catch { /* ignored */ } } + const model = await service.report(); sendJson(res, 200, publicMaintenanceModel(withActivity(model))); } catch (error) { diff --git a/src/lib/dashboard/page.mjs b/src/lib/dashboard/page.mjs index f0abcaa2..8ad12e06 100644 --- a/src/lib/dashboard/page.mjs +++ b/src/lib/dashboard/page.mjs @@ -114,7 +114,14 @@ export function renderPage({ name, version }) {
    - + +
    +
    + + + + +
    +

    @@ -219,8 +226,7 @@ export function renderPage({ name, version }) {
    - full scan — not run yet - + machine measurement — not run yet
    @@ -916,10 +922,6 @@ ${LIVE_HTML}

    - - Runs provider probes on the saved measurement and rebuilds the inventory. Seconds. - - Walks the filesystem, then refreshes evidence. Minutes.
    diff --git a/src/lib/dashboard/refresh-api.mjs b/src/lib/dashboard/refresh-api.mjs new file mode 100644 index 00000000..f588cf74 --- /dev/null +++ b/src/lib/dashboard/refresh-api.mjs @@ -0,0 +1,97 @@ +import { randomUUID } from 'node:crypto'; +import { REFRESH_STRENGTHS, runRefresh as sharedRunRefresh } from '../refresh.mjs'; +import { sendJson } from '../loopback-server.mjs'; +import { readMaintenanceJson } from './maintenance-security.mjs'; + +export const REFRESH_POST_ROUTES = new Set(['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/api/refresh']); +export const REFRESH_LOCAL_TIMEOUT_MS = 5 * 60_000; + +/** One operation per dashboard server. Its public state never exposes stage results. */ +export function createRefreshOperation({ stages, runRefresh = sharedRunRefresh }) { + let current = null; + const state = () => current ? { ...current, stages: current.stages.map(stage => ({ ...stage })) } + : { running: false, lastRun: null }; + function start({ strength, projectTrees = false }) { + if (current?.running) return null; + current = { operationId: randomUUID(), running: true, strength, projectTrees, startedAt: new Date().toISOString(), + finishedAt: null, ok: null, stages: [] }; + Promise.resolve().then(() => runRefresh({ strength, projectTrees, stages, + onStage(event) { + const index = current.stages.findIndex(stage => stage.id === event.id); + if (index < 0) current.stages.push(event); + else current.stages[index] = event; + }, + })).then(outcome => { + current.ok = outcome.ok; + current.stages = outcome.stages.map(({ id, label, state: stageState, detail, elapsedMs }) => + ({ id, label, state: stageState, detail, elapsedMs })); + }).catch(error => { + current.ok = false; + current.stages.push({ id: 'refresh', label: 'Refresh', state: 'failed', + detail: error?.message ?? String(error), elapsedMs: 0 }); + }).finally(() => { current.running = false; current.finishedAt = new Date().toISOString(); }); + return state(); + } + return { start, state }; +} + +/** Dashboard collaborators are already memoized by startDashboard. */ +/** @param {{ cwd: string, pkgRoot?: string, getSystem: Function, getMaintenance: Function, + * refreshInventoryAfterProviderScan: Function, getHostReadiness: Function, + * statusCollect: Function, loadConfig: Function }} options */ +export function dashboardRefreshStages({ cwd, getSystem, getMaintenance, refreshInventoryAfterProviderScan, + getHostReadiness, statusCollect, loadConfig }) { + return { + async machine({ projectTrees }) { + const result = await (await getSystem()).refreshDeep({ includeProjectTrees: projectTrees === true }); + const ok = result?.ok === true && result.persisted?.ok !== false; + return { ok, detail: ok ? null : result?.error ?? 'the measurement did not finish' }; + }, + async maintenance() { + const model = await (await getMaintenance()).scan({ deep: false }); + const { providersChecked, providersTotal } = model?.scan ?? {}; + const detail = Number.isInteger(providersChecked) && Number.isInteger(providersTotal) + ? `checked ${providersChecked} of ${providersTotal} providers` : null; + return { ok: true, detail }; + }, + async inventory({ strength }) { + const result = await refreshInventoryAfterProviderScan({ measured: strength === 'machine' }); + return { ok: result != null, detail: result == null ? 'the inventory rebuild did not finish' : null }; + }, + async live() { + const { runLiveChecks } = await import('../live-checks.mjs'); + const results = await runLiveChecks({ cfg: loadConfig(), cwd }); + const counts = new Map(); + for (const { status } of results) counts.set(status, (counts.get(status) ?? 0) + 1); + return { ok: true, detail: results.length ? [...counts].map(([status, n]) => `${n} ${status}`).join(', ') + : 'no live check applies' }; + }, + async local() { + const status = await statusCollect(); + await getHostReadiness({ force: true }); + return { ok: !status?.error, detail: status?.error ?? null }; + }, + }; +} + +/** Authentication and same-origin policy are enforced by the server gate. */ +export async function handleRefreshPost(req, res, operation) { + let body; + try { body = await readMaintenanceJson(req, { maxBytes: 4096 }); } + catch (error) { + const code = error.status ?? error.statusCode; + sendJson(res, [400, 413, 415].includes(code) ? code : 400, { error: 'invalid refresh request' }); + return; + } + if (!body || Object.keys(body).some(key => !['strength', 'projectTrees'].includes(key)) + || !REFRESH_STRENGTHS.includes(body.strength) + || (body.projectTrees !== undefined && typeof body.projectTrees !== 'boolean') + || (body.projectTrees !== undefined && body.strength !== 'machine')) { + sendJson(res, 400, { error: 'invalid refresh request' }); return; + } + const state = operation.start(body); + if (!state) sendJson(res, 409, { error: 'a refresh is already running', state: operation.state() }); + else sendJson(res, 202, { started: true, state }); +} + +export function handleRefreshGet(res, operation) { sendJson(res, 200, operation.state()); } diff --git a/src/lib/dashboard/styles/base.mjs b/src/lib/dashboard/styles/base.mjs index b9127c46..71bd47b3 100644 --- a/src/lib/dashboard/styles/base.mjs +++ b/src/lib/dashboard/styles/base.mjs @@ -115,7 +115,7 @@ body.gated .band,body.gated .tabbar,body.gated main{display:none} background:var(--panel); } .verdict-text{font-size:13px; font-weight:500; letter-spacing:-.006em} -.band-tools{display:flex; align-items:center; gap:10px} +.band-tools{display:flex; align-items:center; gap:10px;flex-wrap:wrap;max-width:100%} .pulse{ width:8px; height:8px; border-radius:50%; background:var(--accent); flex:none; animation:pulse 2.4s ease-out infinite; @@ -148,7 +148,15 @@ body.gated .band,body.gated .tabbar,body.gated main{display:none} .poll .play.on{color:var(--accent)} .poll .ivl{min-width:56px; justify-content:space-between} .poll .caret{opacity:.5} -.poll .refresh{width:28px; padding:0; font-size:14px} +.poll .refresh{padding:0 8px; font-size:12px} +.refresh-control{display:flex;align-items:center;gap:5px;min-width:0;flex-wrap:wrap} +.refresh-control button,.refresh-control select{border:1px solid var(--line);border-radius:8px;background:var(--panel);color:var(--ink);font:inherit;font-size:11px;padding:5px 8px} +.refresh-control button{cursor:pointer;font-weight:700} +.refresh-control button:disabled{opacity:.5;cursor:wait} +.refresh-control :focus-visible{outline:2px solid var(--accent);outline-offset:2px} +.refresh-trees{display:flex;align-items:center;gap:3px;font-size:10px;white-space:nowrap} +#refresh-status{font-size:10px;color:var(--ink-2);min-width:0} +@media(max-width:560px){.band-tools{width:100%;gap:6px}.refresh-control{width:100%}.refresh-trees{white-space:normal}} .poll .refresh.spin{animation:spin .6s linear} @keyframes spin{to{transform:rotate(360deg)}} .menu{ diff --git a/src/lib/dashboard/styles/maintenance.mjs b/src/lib/dashboard/styles/maintenance.mjs index 4c5e1c69..8976c3dd 100644 --- a/src/lib/dashboard/styles/maintenance.mjs +++ b/src/lib/dashboard/styles/maintenance.mjs @@ -349,7 +349,6 @@ export const MAINTENANCE_CSS = ` } /* The Maintenance toolbar owns measurement actions in this destination. */ -body:has(#panel-sys-maintenance:not([hidden])) #sys-rescan{display:none} .mnt-project-section{list-style:none;margin:0 0 24px} .mnt-project-section>h4{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;margin:8px 0 12px;color:var(--ink)} .mnt-inspector:not([hidden]){animation:mnt-inspector-enter .16s ease-out} diff --git a/src/lib/dashboard/styles/usage.mjs b/src/lib/dashboard/styles/usage.mjs index f0b96990..47e0d22d 100644 --- a/src/lib/dashboard/styles/usage.mjs +++ b/src/lib/dashboard/styles/usage.mjs @@ -173,6 +173,7 @@ export const USAGE_CSS = ` width:32px; height:32px; display:grid; place-items:center; border-radius:50%; background:var(--bg); border:1px solid var(--line); } +.tabbar .source-pill .live-host[data-host=codex]{color:var(--ink)} .tabbar .source-pill .live-host-icon{width:20px; height:20px; stroke-width:1.8} .source-pill .sp-status{ display:flex; align-items:center; padding:5px 14px 5px 10px; font-size:13px; diff --git a/src/lib/live/live-sessions-service.mjs b/src/lib/live/live-sessions-service.mjs index 0e22fc24..470141d0 100644 --- a/src/lib/live/live-sessions-service.mjs +++ b/src/lib/live/live-sessions-service.mjs @@ -57,6 +57,10 @@ export class LiveSessionsService { #projection = emptyLiveProjection(); #tailers = new Map(); #contexts = new Map(); + // Keep a bounded set of displaced native readers with their byte offsets + // and partial-line state. Re-entry within this retention window must not + // replay already counted records. + #dormantTailers = new Map(); #timer = null; #started = false; #edgeKeys = new Set(); @@ -140,6 +144,7 @@ export class LiveSessionsService { if (this.#timer != null) this.#options.clearInterval(this.#timer); this.#timer = null; for (const tailer of this.#tailers.values()) tailer.close(); + for (const { tailer } of this.#dormantTailers.values()) tailer.close(); this.#runtimeBindings.clear(); this.#historyPages.clear(); this.#started = false; @@ -253,9 +258,14 @@ export class LiveSessionsService { const desiredNative = new Set([...claude, ...codex]); for (const [file, context] of this.#contexts) { if (!['claude', 'codex'].includes(context.adapter) || desiredNative.has(file)) continue; - this.#tailers.get(file)?.close(); + const tailer = this.#tailers.get(file); + tailer?.close(); + if (tailer) this.#dormantTailers.set(file, { tailer, context }); this.#tailers.delete(file); this.#contexts.delete(file); + while (this.#dormantTailers.size > Math.max(2, this.#options.maxFiles * 2)) { + this.#dormantTailers.delete(this.#dormantTailers.keys().next().value); + } } for (const file of claude) { this.#add(file, { @@ -272,6 +282,13 @@ export class LiveSessionsService { #add(file, context, initial) { if (this.#tailers.has(file) || this.#tailers.size >= this.#options.maxFiles) return; + const dormant = this.#dormantTailers.get(file); + if (dormant) { + this.#dormantTailers.delete(file); + this.#tailers.set(file, dormant.tailer); + this.#contexts.set(file, dormant.context); + return; + } if (initial && ['claude', 'codex'].includes(context.adapter)) { // One shared bootstrap context so metadata learned early (codex // session_meta id/meta, project, model, provider) persists across the diff --git a/src/lib/maintenance/management/activity.mjs b/src/lib/maintenance/management/activity.mjs index 7c4093e8..531a1287 100644 --- a/src/lib/maintenance/management/activity.mjs +++ b/src/lib/maintenance/management/activity.mjs @@ -71,14 +71,26 @@ function recipeEventSummary(event) { return { kind: event.kind, recipeId: event.recipeId ?? null, recipeVersion: event.recipeVersion ?? null, at: event.at, pendingIds: event.pendingIds ?? undefined }; } +export function validScanTime(value) { + if (typeof value !== 'string' || value.length > 40) return null; + const parts = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.exec(value); + if (!parts) return null; + const [, yearText, monthText, dayText, hourText, minuteText, secondText] = parts; + const [year, month, day, hour, minute, second] = [yearText, monthText, dayText, hourText, minuteText, secondText].map(Number); + const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const monthDays = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return day >= 1 && day <= monthDays[month - 1] && hour <= 23 && minute <= 59 && second <= 59 + && Number.isFinite(Date.parse(value)) ? value : null; +} + function scanSummary(entry) { if (!SCAN_STATES.includes(entry.state)) throw new TypeError(`unknown scan state: ${entry.state}`); - return { sourceId: entry.sourceId, environmentId: entry.environmentId, state: entry.state, label: entry.label, visited: entry.visited, limitingReason: entry.limitingReason ?? null, completedAt: entry.completedAt ?? null }; + return { sourceId: entry.sourceId, environmentId: entry.environmentId, state: entry.state, label: entry.label, visited: entry.visited, limitingReason: entry.limitingReason ?? null, recordedAt: validScanTime(entry.recordedAt), completedAt: validScanTime(entry.completedAt) }; } function latestScans(scanHistory) { const latest = new Map(); - const timestamp = (entry) => Date.parse(entry.completedAt) || 0; + const timestamp = (entry) => Date.parse(entry.recordedAt ?? entry.completedAt) || 0; for (const entry of scanHistory.map(scanSummary)) { const key = JSON.stringify([entry.environmentId, entry.sourceId]); const previous = latest.get(key); diff --git a/src/lib/maintenance/management/service-discovery.mjs b/src/lib/maintenance/management/service-discovery.mjs index 4339e33b..78e94538 100644 --- a/src/lib/maintenance/management/service-discovery.mjs +++ b/src/lib/maintenance/management/service-discovery.mjs @@ -391,7 +391,7 @@ export function scanProgress(ctx) { coverage, progress: ctx.orchestrator().progress(), narrative: progressNarrative(filesystemCoverage, { totalSources: filesystemCoverage.length }), - evidenceChecks: coverage.filter((entry) => entry.filesystem === false).map((entry) => ({ sourceId: entry.sourceId, label: entry.label, method: 'Refresh evidence' })), + evidenceChecks: coverage.filter((entry) => entry.filesystem === false).map((entry) => ({ sourceId: entry.sourceId, label: entry.label, method: 'Refresh' })), forbiddenClaims: claimsAllowed(filesystemCoverage), }; }; diff --git a/src/lib/maintenance/service.mjs b/src/lib/maintenance/service.mjs index b0228f86..1c00ba59 100644 --- a/src/lib/maintenance/service.mjs +++ b/src/lib/maintenance/service.mjs @@ -58,7 +58,7 @@ function scanRequiredModel({ status = 'not-scanned', now = Date.now } = {}) { observedUsage: { status: 'not-measured', statement: 'Usage evidence is unavailable.' }, consumerHosts: { basis: 'not-measured', hosts: [], count: 0, truncated: false }, impact: { summary: 'Scanning changes no installed resource.', bytes: null, files: null, dependencies: 'unknown', preserved: ['All installed resources'] }, - nextAction: { operation: 'scan', label, providerId: 'system.deep-scan', providerVersion: '1', safetyClass: 'never-automatic', rollback: 'reversible', restart: 'not-required', executable: false, recommendation: label, steps: ['Run `ak maintain --refresh=machine` to measure the machine.', 'Return to Maintenance when the scan completes.'], preserved: ['All installed resources'], blockedReason: 'A current saved scan is required before Maintenance can recommend changes.' }, + nextAction: { operation: 'scan', label, providerId: 'system.deep-scan', providerVersion: '1', safetyClass: 'never-automatic', rollback: 'reversible', restart: 'not-required', executable: false, recommendation: label, steps: ['Run `ak maintain --refresh=machine` or Refresh › Machine in the dashboard to measure the machine.', 'Return to Maintenance when the scan completes.'], preserved: ['All installed resources'], blockedReason: 'A current saved scan is required before Maintenance can recommend changes.' }, }; return deepFreeze({ schemaVersion: 1, mode: 'control-plane', capabilities: NO_CONTROL_CAPABILITIES, diff --git a/tests/dashboard.test.cjs b/tests/dashboard.test.cjs index 016c3d18..5eab2a07 100644 --- a/tests/dashboard.test.cjs +++ b/tests/dashboard.test.cjs @@ -1127,44 +1127,20 @@ async function main() { await sysSrv.close(); } - await test('?refresh=deep is single-flight — two concurrent refreshes share one scan', async () => { - let release; - const gate = new Promise((resolve) => { release = resolve; }); - // Gate the CHEAP tier, not the deep one. Both requests then resume from the - // same promise in one microtask drain, and runDeep's first act is a - // setImmediate — so the second request PROVABLY reaches refreshDeep() while - // the first still holds the slot. Racing two bare HTTP requests would be - // testing the scheduler, not the single-flight rule. - const fx = systemFixture({ collectors: { runtime: async () => { await gate; return runtimeCensus(); } } }); - let maintenanceScans = 0; - const maintenance = { - async report() { return {}; }, - async scan() { maintenanceScans += 1; return {}; }, - async plan() { return {}; }, - }; + await test('GET /api/system rejects scan queries before reading the collector', async () => { + const fx = systemFixture(); const srv = await startDashboard({ port: 0, cwd: fixture, fetchStatus: async () => STUB_STATUS, usage: spyUsage().api, - system: fx.collector, maintenance, + system: fx.collector, }); try { - const both = Promise.all([ - get(srv.url + 'api/system?refresh=deep', srv.token), - get(srv.url + 'api/system?refresh=deep', srv.token), - ]); - await eventually(() => fx.calls.runtime === 2, 'both refreshes must reach the collector'); - release(); - const [a, b] = await both; - assert(a.status === 200 && b.status === 200, 'both refreshes must answer 200'); - const scanA = JSON.parse(a.body).scan; - assert(scanA.running === true && scanA.phase !== 'idle', - 'a refresh must report the scan it started, got ' + JSON.stringify(scanA)); - await eventually(() => fx.calls.persist === 1, 'the shared scan must run to completion'); - await eventually(() => maintenanceScans === 1, 'the completed scan must refresh Maintenance evidence once'); - assert(fx.calls.install === 1 && fx.calls.storage === 1 - && fx.calls.catalog === 1 && fx.calls.projects === 1, - 'the deep collectors ran twice — the single-flight slot did not hold: ' + JSON.stringify(fx.calls)); - assert(fx.calls.persist === 1, 'a shared scan must write exactly one snapshot'); - assert(maintenanceScans === 1, 'two attached System requests must not double-run Maintenance providers'); + for (const query of ['refresh=deep', 'refresh=deep&refresh=scan', 'trees=1']) { + const response = await get(srv.url + 'api/system?' + query, srv.token); + assert(response.status === 400, 'scan query must be rejected: ' + query); + contains(response.body, 'start a refresh with POST /api/refresh'); + } + assert(fx.calls.runtime === 0 && fx.calls.persist === 0, + 'rejected GET queries must not run collectors or persist measurements'); } finally { await srv.close(); } @@ -1411,13 +1387,14 @@ async function main() { contains(r.body, 'POLL_COOLDOWN_MS=3000'); }); - await test('every refresh path is single-flight + cooldown guarded', async () => { + await test('Refresh control guards duplicate POSTs and status polling retains its cooldown', async () => { const r = await get(uiSrv.url); - const fn = r.body.slice(r.body.indexOf('function refreshAll(')); - const body = fn.slice(0, fn.indexOf('\n function ')); - assert(/inflight/.test(body), 'refreshAll must consult the single-flight flag'); - assert(/POLL_COOLDOWN_MS/.test(body), 'refreshAll must consult the cooldown'); - assert(/setInterval\(refreshAll/.test(r.body), 'the automatic poll must go through the SAME guarded path'); + const fn = r.body.slice(r.body.indexOf('function startRefresh(')); + const body = fn.slice(0, fn.indexOf('function refreshProjectTrees')); + contains(body, 'if(refreshBusy)return Promise.resolve(false)'); + contains(body, "fetch('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/api/refresh',{method:'POST'"); + contains(r.body, 'POLL_COOLDOWN_MS=3000'); + assert(!/setInterval\(refreshAll/.test(r.body), 'retired automatic refresh path remains'); }); await test('the Usage tab is lazy — the shared status poll never fetches /api/usage', async () => { diff --git a/tests/fixtures/dashboard-status-child.mjs b/tests/fixtures/dashboard-status-child.mjs index 435ff3ce..9d4a4b51 100644 --- a/tests/fixtures/dashboard-status-child.mjs +++ b/tests/fixtures/dashboard-status-child.mjs @@ -10,9 +10,12 @@ // real HTTP — this script only starts the server and reports where it is // listening; the parent marks ledger call boundaries itself by appending // directly to the same ndjson file between requests. -import { startDashboard } from '../../src/lib/dashboard-server.mjs'; +// An optional test-only global root makes the Ruflo component path deterministic. +import { _setGlobalRootForTest } from '../../src/lib/paths.mjs'; -const [, , cwd] = process.argv; +const [, , cwd, fakeGlobalRoot] = process.argv; +if (fakeGlobalRoot) _setGlobalRootForTest(fakeGlobalRoot); +const { startDashboard } = await import('../../src/lib/dashboard-server.mjs'); const { port, token } = await startDashboard({ port: 0, cwd }); // Unbuffered, single line, parsed by the parent — printed only once the diff --git a/tests/kit/dashboard-get-is-read-only.test.mjs b/tests/kit/dashboard-get-is-read-only.test.mjs new file mode 100644 index 00000000..fa0f7769 --- /dev/null +++ b/tests/kit/dashboard-get-is-read-only.test.mjs @@ -0,0 +1,92 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http from 'node:http'; +import { sandboxHome, sandboxProject, rmrf } from './helpers/home-sandbox.mjs'; + +const home = sandboxHome('ak-dashboard-read-only'); +const project = sandboxProject('ak-dashboard-read-only'); +after(() => rmrf(home, project)); +const { startDashboard } = await import('../../src/lib/dashboard-server.mjs'); + +const EXACT = [ + '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/api/status', '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/api/refresh', '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/api/host-health', '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/api/live', '/api/live/history', + '/api/live/events', '/api/live/intelligence', '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/api/models', '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/api/ruflo-components', + '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/api/usage', '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/api/hooks', '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/api/limits', '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/api/system', '/api/system/summary', + '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/api/maintenance', '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/api/sessions', +]; +const PARAMETERIZED = [ + '/api/maintenance/v2/inventory', '/api/hooks/source/bad', + '/api/live/playback/bad/bad', '/api/live/transcripts/bad/bad/events', '/api/session/bad', +]; +const ERROR = { error: 'start a refresh with POST /api/refresh' }; + +function request(server, route, method = 'GET') { + return new Promise((resolve, reject) => { + const req = http.request(new URL(route, server.url), { method, headers: { + 'x-dash-token': server.token, origin: server.url.replace(/\/$/, ''), + 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', + } }, res => { + // SSE endpoints intentionally stay open. Their response headers are enough + // to prove that route dispatch has happened; close the reader promptly. + if (res.headers['content-type']?.includes('text/event-stream')) { + res.destroy(); resolve({ status: res.statusCode, body: '' }); return; + } + let body = ''; + res.on('data', chunk => { body += chunk; }); + res.on('end', () => resolve({ status: res.statusCode, body })); + }); + req.on('error', reject); + req.setTimeout(2000, () => req.destroy(new Error(`timed out: ${route}`))); + req.end(method === 'POST' ? '{}' : undefined); + }); +} + +test('GET route inventory stays aligned with the server dispatch table', () => { + const source = fs.readFileSync(new URL('../../src/lib/dashboard-server.mjs', import.meta.url), 'utf8'); + const exact = source.match(/const ROUTES = \{([\s\S]*?)\n {4}\};/)?.[1] ?? ''; + const parameterized = source.match(/const PARAM_ROUTES = \[([\s\S]*?)\n {4}\];/)?.[1] ?? ''; + assert.deepEqual([...exact.matchAll(/^ {6}'([^']+)':/gm)].map(match => match[1]), EXACT); + assert.deepEqual([...parameterized.matchAll(/^ {6}\[(\/.+?\/),/gm)].map(match => match[1]), [ + String.raw`/^\/api\/maintenance\/v2\/.*$/`, + String.raw`/^\/api\/hooks\/source\/([^/]+)$/`, + String.raw`/^\/api\/live\/playback\/([^/]+)\/([^/]+)$/`, + String.raw`/^\/api\/live\/transcripts\/([^/]+)\/([^/]+)\/events$/`, + String.raw`/^\/api\/session\/(.*)$/`, + ]); +}); + +test('all GET routes, including root, cannot start measurement or provider work through scan queries', async t => { + const calls = { system: 0, maintenance: 0, inventory: 0, rebuild: 0, provider: 0 }; + const hostReadiness = async () => ({ hosts: {} }); + hostReadiness.checkConnection = async () => { calls.provider++; return {}; }; + const server = await startDashboard({ port: 0, cwd: project, + fetchStatus: async () => ({ overall: 'ok', rows: [], drift: [] }), + hostReadiness, discoverProjects: () => [], + system: { read: async () => ({ runtime: {}, knownFiles: [], storage: {}, projects: [], scan: {} }), + refreshDeep: async () => { calls.system++; return { ok: true }; } }, + maintenance: { report: async () => ({}), scan: async () => { calls.maintenance++; return {}; }, + plan: async () => ({}) }, + management: { refreshInventory: async () => { calls.inventory++; }, + rebuildAfterMeasurement: async () => { calls.rebuild++; } }, + usage: { readIndex: async () => ({ sessions: [] }), readSession: async () => null, + masker: async () => value => value }, live: { snapshot: async () => ({}), replay: async () => ({ events: [] }), + subscribe: () => () => {} }, models: async () => ({ status: 'empty' }), + limits: async () => ({}), hooks: {}, transcripts: {}, + }); + t.after(() => server.close()); + for (const route of ['/', '/index.html', ...EXACT, ...PARAMETERIZED]) { + for (const suffix of ['?refresh=deep', '?refresh=scan&refresh=deep&trees=1', '?trees=1']) { + await request(server, route + suffix); + assert.deepEqual(calls, { system: 0, maintenance: 0, inventory: 0, rebuild: 0, provider: 0 }, route + suffix); + } + } + for (const route of ['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/api/system', '/api/system/summary', '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/api/maintenance']) { + for (const suffix of ['?refresh=deep', '?refresh=scan&refresh=deep&trees=1', '?trees=1']) { + const response = await request(server, route + suffix); + assert.equal(response.status, 400, route + suffix); + assert.deepEqual(JSON.parse(response.body), ERROR); + } + } + assert.equal((await request(server, '/api/host-health/local', 'POST')).status, 405); +}); diff --git a/tests/kit/dashboard-hermetic-defaults.test.mjs b/tests/kit/dashboard-hermetic-defaults.test.mjs index 20d8bd12..47b92d1d 100644 --- a/tests/kit/dashboard-hermetic-defaults.test.mjs +++ b/tests/kit/dashboard-hermetic-defaults.test.mjs @@ -10,7 +10,6 @@ import fs from 'node:fs'; import http from 'node:http'; import path from 'node:path'; import { sandboxHome, assertSandboxed, rmrf } from './helpers/home-sandbox.mjs'; -import { waitUntil } from './helpers/wait-until.mjs'; const home = sandboxHome('ak-dash-hermetic'); after(() => rmrf(home)); @@ -42,9 +41,7 @@ test('an injected System collector alone never builds the default maintenance se t.after(() => server.close()); const deep = await get(server, 'api/system?refresh=deep'); - assert.equal(deep.status, 200); - await waitUntil(() => errors.some((e) => /maintenanceOptions\.controlRoot/.test(e)), - 'the refused default maintenance service must be logged', { timeout: 5000 }); + assert.equal(deep.status, 400); const maintenance = await get(server, 'api/maintenance'); assert.equal(maintenance.status, 503); assert.equal(fs.existsSync(path.join(paths.maintenanceControlDir())), false, diff --git a/tests/kit/dashboard-refresh-api.test.mjs b/tests/kit/dashboard-refresh-api.test.mjs new file mode 100644 index 00000000..3a45b2e9 --- /dev/null +++ b/tests/kit/dashboard-refresh-api.test.mjs @@ -0,0 +1,188 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { sandboxHome, sandboxProject, rmrf } from './helpers/home-sandbox.mjs'; + +const home = sandboxHome('ak-dashboard-refresh'); +const project = sandboxProject('ak-dashboard-refresh'); +const controlRoot = fs.mkdtempSync(path.join(home, 'control-')); +after(() => rmrf(home, project)); +const { startDashboard } = await import('../../src/lib/dashboard-server.mjs'); + +function request(server, method, route, body, headers = {}) { + const url = new URL(route, server.url); + return new Promise((resolve, reject) => { + const req = http.request(url, { method, headers: { + 'content-type': 'application/json', 'x-dash-token': server.token, + origin: url.origin, 'sec-fetch-site': 'same-origin', ...headers, + } }, res => { + let data = ''; res.on('data', chunk => { data += chunk; }); + res.on('end', () => { + let json; + try { json = JSON.parse(data); } catch { json = null; } + resolve({ status: res.statusCode, json, body: data }); + }); + }); + req.on('error', reject); + req.end(body === undefined ? undefined : JSON.stringify(body)); + }); +} + +function stages(overrides = {}) { + return Object.fromEntries(['machine', 'maintenance', 'inventory', 'live', 'local'].map(id => + [id, overrides[id] ?? (async () => ({ ok: true }))])); +} + +async function serverWith(stagesMap) { + return startDashboard({ port: 0, cwd: project, + fetchStatus: async () => ({ overall: 'ok', rows: [] }), + hostReadiness: async () => ({ hosts: {} }), + system: { read: async () => ({}), refreshDeep: async () => ({ ok: true }) }, + maintenance: { report: async () => ({}), scan: async () => ({}), plan: async () => ({}) }, + management: { refreshInventory: async () => ({ ok: true }) }, + maintenanceOptions: { controlRoot }, usage: {}, discoverProjects: () => [], + refreshStages: stagesMap, + }); +} + +async function finished(server) { + const deadline = Date.now() + 3000; + while (Date.now() < deadline) { + const latest = await request(server, 'GET', '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/api/refresh'); + if (latest.json.running === false && latest.json.lastRun !== null) return latest.json; + await new Promise(resolve => setTimeout(resolve, 5)); + } + throw new Error('refresh did not finish'); +} + +test('refresh POST needs the header capability and same origin, and validates its bounded body', async t => { + const server = await serverWith(stages()); + t.after(() => server.close()); + assert.deepEqual((await request(server, 'GET', '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/api/refresh')).json, { running: false, lastRun: null }); + const valid = { strength: 'local' }; + assert.equal((await request(server, 'POST', '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/api/refresh', valid, { 'x-dash-token': '' })).status, 401); + assert.equal((await request(server, 'POST', `/api/refresh?token=${server.token}`, valid, { 'x-dash-token': '' })).status, 401); + assert.equal((await request(server, 'POST', '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/api/refresh', valid, { origin: 'http://evil.test' })).status, 403); + for (const body of [{ strength: 'other' }, { strength: 'local', extra: true }, + { strength: 'local', projectTrees: true }, { strength: 'machine', projectTrees: 'yes' }, {}]) { + assert.equal((await request(server, 'POST', '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/api/refresh', body)).status, 400, JSON.stringify(body)); + } +}); + +test('refresh POST starts ordered local work once and GET exposes progress and completion', async t => { + const calls = []; + const server = await serverWith(stages(Object.fromEntries(['maintenance', 'inventory', 'local'].map(id => + [id, async () => { calls.push(id); return { ok: true }; }])))); + t.after(() => server.close()); + const post = await request(server, 'POST', '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/api/refresh', { strength: 'local' }); + assert.equal(post.status, 202); + assert.equal(post.json.started, true); + const state = await finished(server); + assert.equal(state.ok, true); + assert.equal(state.strength, 'local'); + assert.deepEqual(state.stages.map(({ id, state: stageState }) => [id, stageState]), + [['maintenance', 'done'], ['inventory', 'done'], ['local', 'done']]); + assert.deepEqual(calls, ['maintenance', 'inventory', 'local']); + assert.ok(state.startedAt && state.finishedAt); +}); + +test('successive refreshes expose distinct stable operation identities', async t => { + const server = await serverWith(stages()); + t.after(() => server.close()); + const firstPost = await request(server, 'POST', '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/api/refresh', { strength: 'local' }); + const first = await finished(server); + const secondPost = await request(server, 'POST', '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/api/refresh', { strength: 'local' }); + const second = await finished(server); + assert.equal(firstPost.status, 202); + assert.equal(secondPost.status, 202); + assert.match(first.operationId, /^[0-9a-f-]{36}$/); + assert.equal(firstPost.json.state.operationId, first.operationId); + assert.equal(secondPost.json.state.operationId, second.operationId); + assert.notEqual(first.operationId, second.operationId); +}); + +test('a second POST cannot start work while the operation is in flight', async t => { + let release; + const held = new Promise(resolve => { release = resolve; }); + const server = await serverWith(stages({ maintenance: async () => { await held; return { ok: true }; } })); + t.after(() => { release(); return server.close(); }); + assert.equal((await request(server, 'POST', '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/api/refresh', { strength: 'local' })).status, 202); + const conflict = await request(server, 'POST', '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/api/refresh', { strength: 'machine' }); + assert.equal(conflict.status, 409); + assert.equal(conflict.json.error, 'a refresh is already running'); + assert.equal(conflict.json.state.running, true); + release(); + await finished(server); +}); + +test('a failed machine measurement skips dependent stages and still performs local refresh', async t => { + const called = []; + const server = await serverWith(stages({ + machine: async () => ({ ok: false, detail: 'measurement failed' }), + maintenance: async () => { called.push('maintenance'); return { ok: true }; }, + inventory: async () => { called.push('inventory'); return { ok: true }; }, + local: async () => { called.push('local'); return { ok: true }; }, + })); + t.after(() => server.close()); + assert.equal((await request(server, 'POST', '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/api/refresh', { strength: 'machine', projectTrees: true })).status, 202); + const state = await finished(server); + assert.deepEqual(state.stages.map(({ id, state: stageState }) => [id, stageState]), + [['machine', 'failed'], ['maintenance', 'skipped'], ['inventory', 'skipped'], ['local', 'done']]); + assert.deepEqual(called, ['local']); + assert.equal(state.ok, false); +}); + +test('dashboard local stage forces host readiness after collecting status', async () => { + const { dashboardRefreshStages } = await import('../../src/lib/dashboard/refresh-api.mjs'); + const calls = []; + const actual = dashboardRefreshStages({ cwd: project, pkgRoot: project, + getSystem: async () => ({ refreshDeep: async () => ({ ok: true }) }), + getMaintenance: async () => ({ scan: async () => ({}) }), + refreshInventoryAfterProviderScan: async () => ({}), + statusCollect: async () => { calls.push('status'); return { rows: [] }; }, + getHostReadiness: async options => { calls.push(options); return { hosts: {} }; }, + loadConfig: () => ({}), + }); + assert.equal((await actual.local()).ok, true); + assert.deepEqual(calls, ['status', { force: true }]); +}); + +test('an inventory rebuild that the server reports unavailable fails its stage', async () => { + const { dashboardRefreshStages } = await import('../../src/lib/dashboard/refresh-api.mjs'); + const actual = dashboardRefreshStages({ cwd: project, + getSystem: async () => ({}), getMaintenance: async () => ({}), + refreshInventoryAfterProviderScan: async () => null, + statusCollect: async () => ({}), getHostReadiness: async () => ({}), loadConfig: () => ({}), + }); + assert.equal((await actual.inventory({ strength: 'local' })).ok, false); +}); + +test('machine refresh honors each current project-tree choice through a shared persistent collector', async t => { + const { dashboardRefreshStages } = await import('../../src/lib/dashboard/refresh-api.mjs'); + const { createSystemCollector } = await import('../../src/lib/footprint/index.mjs'); + const measured = []; + const collector = createSystemCollector({ cwd: project, + snapshotFile: path.join(home, 'refresh-snapshot.json'), + runWorkerImpl: async ({ includeProjectTrees, startedAt }) => { + measured.push(includeProjectTrees); + return { ok: true, asOf: startedAt, sections: {}, completeness: { complete: true }, + persisted: { ok: true }, error: null, + terminal: { running: false, phase: 'done', finishedAt: startedAt, durationMs: 0, error: null } }; + }, + }); + const actual = dashboardRefreshStages({ cwd: project, getSystem: async () => collector, + getMaintenance: async () => ({}), refreshInventoryAfterProviderScan: async () => ({}), + statusCollect: async () => ({}), getHostReadiness: async () => ({}), loadConfig: () => ({}), + }); + const server = await serverWith(stages({ machine: actual.machine })); + t.after(() => server.close()); + // Separate HTTP callers (including another tab) share this server's collector. + // An omitted choice must also override a prior checked selection. + for (const projectTrees of [false, true, false, true, undefined]) { + assert.equal((await request(server, 'POST', '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/api/refresh', { strength: 'machine', projectTrees })).status, 202); + assert.equal((await finished(server)).ok, true); + } + assert.deepEqual(measured, [false, true, false, true, false]); +}); diff --git a/tests/kit/dashboard-status-cost.test.mjs b/tests/kit/dashboard-status-cost.test.mjs index f731e30a..50fcdb85 100644 --- a/tests/kit/dashboard-status-cost.test.mjs +++ b/tests/kit/dashboard-status-cost.test.mjs @@ -16,13 +16,28 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import http from 'node:http'; import { spawnEnv, sandboxProject, writeKitConfig, offlineKitConfig } from './helpers/home-sandbox.mjs'; import { startGuardedDashboard, stopGuardedDashboard, getJson, markLedgerBoundary, readLedger, sliceByCallBoundary, isVersionDriftLookup, } from './helpers/dashboard-child-server.mjs'; -test('two 30s-poll-tick /api/status requests: the second starts no processes and transfers no more data than the first', async () => { +function postRefresh(port, token) { + return new Promise((resolve, reject) => { + const req = http.request({ host: '127.0.0.1', port, path: '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/api/refresh', method: 'POST', headers: { + 'x-dash-token': token, 'content-type': 'application/json', + origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', + } }, res => { + let body = ''; res.on('data', chunk => { body += chunk; }); + res.on('end', () => resolve({ status: res.statusCode, body })); + }); + req.on('error', reject); + req.end(JSON.stringify({ strength: 'local' })); + }); +} + +test('two 30s-poll-tick /api/status requests: the second starts no processes and transfers no more data than the first', async t => { const ledgerFile = path.join(os.tmpdir(), `ak-dash-cost-${process.pid}.ndjson`); const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ak-dash-cost-home-')); fs.mkdirSync(path.join(home, '.config'), { recursive: true }); @@ -69,6 +84,29 @@ test('two 30s-poll-tick /api/status requests: the second starts no processes and assert.ok(second.bytes <= first.bytes + budget, `second /api/status response (${second.bytes} bytes) exceeds the first (${first.bytes} bytes) ` + `by more than the ${budget}-byte budget — possible in-process cache growth/leak across polls`); + + const startedAt = Date.now(); + const refreshStart = await postRefresh(port, token); + assert.equal(refreshStart.status, 202, refreshStart.body); + let refresh; + do { + refresh = await getJson(port, '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/api/refresh', token); + if (refresh.json.running) await new Promise(resolve => setTimeout(resolve, 50)); + } while (refresh.json.running && Date.now() - startedAt < 60_000); + assert.equal(refresh.json.running, false, 'local refresh must finish within 60 s in the sandbox'); + assert.deepEqual(refresh.json.stages.map(({ id, state }) => [id, state]), + [['maintenance', 'done'], ['inventory', 'done'], ['local', 'done']]); + markLedgerBoundary(ledgerFile, 'refresh'); + const third = await getJson(port, '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/api/status', token); + assert.equal(third.status, 200); + markLedgerBoundary(ledgerFile, 'third'); + const { third: thirdSlice } = sliceByCallBoundary(readLedger(ledgerFile), ['first', 'second', 'refresh', 'third']); + const thirdUnexplained = thirdSlice.filter((l) => !isVersionDriftLookup(l)); + assert.equal(thirdUnexplained.length, 0, JSON.stringify(thirdUnexplained.map((l) => [l.cmd, l.args]))); + assert.ok(third.bytes <= second.bytes + budget, `third /api/status: ${third.bytes} bytes; second: ${second.bytes}`); + assert.deepEqual(Object.keys(third.json).sort(), Object.keys(second.json).sort()); + t.diagnostic(`status bytes cold/warm/post-refresh=${first.bytes}/${second.bytes}/${third.bytes}; ` + + `local refresh=${Date.now() - startedAt}ms; third unexplained spawns=${thirdUnexplained.length}`); } finally { await stopGuardedDashboard(child); fs.rmSync(ledgerFile, { force: true }); @@ -76,3 +114,8 @@ test('two 30s-poll-tick /api/status requests: the second starts no processes and fs.rmSync(project, { recursive: true, force: true }); } }); + +test('the idle polling client never requests the refresh route', () => { + const poll = fs.readFileSync(new URL('../../src/lib/dashboard/client/poll.mjs', import.meta.url), 'utf8'); + assert.doesNotMatch(poll, /\/api\/refresh/); +}); diff --git a/tests/kit/dashboard-status-inprocess.test.mjs b/tests/kit/dashboard-status-inprocess.test.mjs index 5dcf8e9d..72c901f1 100644 --- a/tests/kit/dashboard-status-inprocess.test.mjs +++ b/tests/kit/dashboard-status-inprocess.test.mjs @@ -14,12 +14,12 @@ // return, which the in-process path would otherwise silently drop. import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { spawnEnv, sandboxProject, writeKitConfig, offlineKitConfig } from './helpers/home-sandbox.mjs'; +import { spawnEnv, sandboxProject, writeKitConfig, offlineKitConfig, fakeGlobalRoot } from './helpers/home-sandbox.mjs'; import { startGuardedDashboard, stopGuardedDashboard, getJson, markLedgerBoundary, readLedger, sliceByCallBoundary, isVersionDriftLookup, @@ -139,3 +139,47 @@ test('GET /api/status (in-process) carries the same {overall, rows} `ak status - cleanup(home, project); } }); + +test('GET /api/status passes the server cwd through ruflo-components project-root discovery', async () => { + const { home, project, env } = sandbox('ak-dash-ruflo-cwd'); + const decoy = sandboxProject('ak-dash-ruflo-decoy'); + const fakeRoot = fakeGlobalRoot(home, { ruflo: '9.9.9' }); + fs.mkdirSync(path.join(project, '.claude-flow')); + fs.mkdirSync(path.join(project, '.harness')); + fs.writeFileSync(path.join(project, '.harness', 'mcp-policy.json'), '{invalid'); + fs.mkdirSync(path.join(decoy, '.claude-flow')); + writeKitConfig(home, offlineKitConfig({ rufloComponents: { mcpGovernance: { maxCallsPerMinute: 60 } } })); + + let child; + try { + const ready = await new Promise((resolve, reject) => { + child = spawn(process.execPath, [path.join(PKG_ROOT, 'tests/fixtures/dashboard-status-child.mjs'), project, fakeRoot], { + cwd: decoy, env, stdio: ['ignore', 'pipe', 'pipe'], + }); + let out = '', err = ''; + child.stdout.on('data', chunk => { + out += chunk; + const match = out.match(/READY (\d+) (\S+)\n/); + if (match) resolve({ port: Number(match[1]), token: match[2] }); + }); + child.stderr.on('data', chunk => { err += chunk; }); + child.once('error', reject); + child.once('exit', code => reject(new Error(`dashboard child exited ${code}: ${err || out}`))); + }); + const response = await getJson(ready.port, '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/api/status', ready.token); + assert.equal(response.status, 200); + assert.ok(response.json.rows.some(row => row.subsystem === 'ruflo-components' + && /ruflo components:.*ruflo 9\.9\.9/.test(row.message)), + 'the status request must use the disposable fake Ruflo package'); + const governance = response.json.rows.find(row => row.subsystem === 'ruflo-components' + && /MCP tool governance/.test(row.message)); + assert.ok(governance, 'fake installed Ruflo must reach its component projection'); + assert.equal(governance.state, 'blocked'); + assert.match(governance.message, /policy file is invalid/, + 'the server-supplied project cwd, not process.cwd(), must drive rufloProjectRoot'); + } finally { + await stopGuardedDashboard(child); + cleanup(home, project); + fs.rmSync(decoy, { recursive: true, force: true }); + } +}); diff --git a/tests/kit/host-health-api.test.mjs b/tests/kit/host-health-api.test.mjs index 4b43311e..ceae90cb 100644 --- a/tests/kit/host-health-api.test.mjs +++ b/tests/kit/host-health-api.test.mjs @@ -37,9 +37,9 @@ test('connection route requires same-origin header auth, explicit consent, and b assert.equal((await request(server, 'POST', '/api/host-health/connection', [])).status, 400); assert.equal((await request(server, 'POST', '/api/host-health/connection', body)).status, 200); assert.equal(connections, 1); - assert.equal((await request(server, 'POST', '/api/host-health/local', { host: 'codex' })).status, 200); + assert.equal((await request(server, 'POST', '/api/host-health/local', { host: 'codex' })).status, 405); assert.equal(connections, 1); - assert.ok(reads >= 3); + assert.equal(reads, 2, 'retired local POST does not run another host check'); await server.close(); assert.equal(closed, true); }); diff --git a/tests/kit/live-service.test.mjs b/tests/kit/live-service.test.mjs index b5cb6528..85491242 100644 --- a/tests/kit/live-service.test.mjs +++ b/tests/kit/live-service.test.mjs @@ -185,6 +185,51 @@ test('bounded native discovery rotates to a newer transcript created after start service.close(); }); +test('a native transcript re-entering the bounded window resumes without replaying accepted records', () => { + const sb = sandbox(); + const old = path.join(sb.claude, 'old.jsonl'); + const recent = path.join(sb.claude, 'recent.jsonl'); + fs.writeFileSync(old, line({ + type: 'user', sessionId: 'old', cwd: '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/work/old-project', + timestamp: '2026-07-27T11:00:00Z', message: { content: 'fixture only' }, + })); + fs.utimesSync(old, new Date(1_000), new Date(1_000)); + let tick; + const service = new LiveSessionsService({ + roots: sb.roots, maxFiles: 1, readCodexState: () => null, + setInterval: (fn) => { tick = fn; return { unref() {} }; }, clearInterval: () => {}, + now: () => '2026-07-27T12:00:00Z', + }); + try { + service.start(); + const before = service.snapshot().health.claude.accepted; + assert.ok(before > 0); + fs.writeFileSync(recent, line({ + type: 'user', sessionId: 'recent', cwd: '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/work/recent-project', + timestamp: '2026-07-27T11:01:00Z', message: { content: 'fixture only' }, + })); + fs.utimesSync(recent, new Date(2_000), new Date(2_000)); + tick(); + const rotated = service.snapshot(); + const afterRotation = rotated.health.claude.accepted; + assert.ok(afterRotation > before); + service.close(); + service.start(); + fs.utimesSync(old, new Date(3_000), new Date(3_000)); + tick(); + const reentered = service.snapshot(); + assert.equal(reentered.health.claude.accepted, afterRotation); + assert.equal(reentered.sessions.length, rotated.sessions.length); + assert.equal(reentered.projects.length, rotated.projects.length); + fs.appendFileSync(old, line({ + type: 'assistant', sessionId: 'old', cwd: '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/work/old-project', + timestamp: '2026-07-27T12:00:01Z', message: { content: 'fixture only' }, + })); + tick(); + assert.equal(service.snapshot().health.claude.accepted, afterRotation + 1); + } finally { service.close(); } +}); + test('metadata bootstrap is adversarially privacy bounded', () => { const sb = sandbox(); fs.writeFileSync(path.join(sb.codex, 'rollout-2026-07-27T12-00-00-x1.jsonl'), [ diff --git a/tests/kit/maintenance-dashboard-api.test.mjs b/tests/kit/maintenance-dashboard-api.test.mjs index 9e3c1efe..56c56e77 100644 --- a/tests/kit/maintenance-dashboard-api.test.mjs +++ b/tests/kit/maintenance-dashboard-api.test.mjs @@ -303,14 +303,14 @@ test('dashboard Maintenance API keeps GET lazy and mutation paths exact', async assert.equal(unknownMutation.status, 405); const rescanned = await request(server, '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/api/maintenance?refresh=scan', { origin: false, fetchSite: null }); - assert.equal(rescanned.status, 200); - assert.equal(service.calls.scan, 1, 'only the explicit scan query invokes maintenance scanning'); + assert.equal(rescanned.status, 400); + assert.equal(service.calls.scan, 0, 'GET cannot invoke maintenance scanning'); const unknownRefresh = await request(server, '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/api/maintenance?refresh=deep', { origin: false, fetchSite: null }); const duplicateRefresh = await request(server, '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/api/maintenance?refresh=scan&refresh=scan', { origin: false, fetchSite: null }); const unknownQuery = await request(server, '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/api/maintenance?extra=scan', { origin: false, fetchSite: null }); assert.deepEqual([unknownRefresh.status, duplicateRefresh.status, unknownQuery.status], [400, 400, 400]); - assert.equal(service.calls.scan, 1, 'ambiguous scan queries never invoke maintenance scanning'); + assert.equal(service.calls.scan, 0, 'scan queries never invoke maintenance scanning'); }); test('dashboard Maintenance reports provider activity and refuses action requests during a scan', async (t) => { @@ -465,18 +465,6 @@ test('dashboard Maintenance API distinguishes pre-mutation refusal from a receip // ── ADR-0048: provider scans chain the inventory rebuild (QE defect D5) ───── -function eventually(predicate, message, timeout = 1500) { - const started = Date.now(); - return new Promise((resolve, reject) => { - const check = () => { - if (predicate()) return resolve(undefined); - if (Date.now() - started >= timeout) return reject(new Error(message)); - setTimeout(check, 5); - }; - check(); - }); -} - function recordingManagement({ refreshInventory, measured = true } = {}) { const calls = []; const rebuilt = { inventoryId: 'inv_refreshed', capturedAt: '2026-09-05T12:00:00.000Z' }; @@ -493,84 +481,39 @@ function recordingManagement({ refreshInventory, measured = true } = {}) { return { calls, facade }; } -test('GET /api/maintenance?refresh=scan chains exactly one management.refreshInventory({ deep:false }) without awaiting it', async (t) => { - const service = fixtureService(); - let release; - const gate = new Promise((resolve) => { release = resolve; }); - const management = recordingManagement({ refreshInventory: () => gate }); - const server = await startDashboard({ port: 0, maintenance: service, management: management.facade, usage: {} }); - t.after(() => server.close()); - - const plain = await request(server, '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/api/maintenance', { origin: false, fetchSite: null }); - assert.equal(plain.status, 200); - assert.deepEqual(management.calls, [], 'a plain read never rebuilds the inventory'); - - const first = await request(server, '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/api/maintenance?refresh=scan', { origin: false, fetchSite: null }); - assert.equal(first.status, 200, 'the provider-scan response never waits for the inventory rebuild'); - await eventually(() => management.calls.length === 1, 'the provider scan must chain one inventory rebuild'); - assert.deepEqual(management.calls, [{ deep: false }], 'a cheap provider check rebuilds only; it never walks discovery sources'); - - const second = await request(server, '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/api/maintenance?refresh=scan', { origin: false, fetchSite: null }); - assert.equal(second.status, 200); - assert.equal(service.calls.scan, 2); - assert.equal(management.calls.length, 1, 'a rebuild still in flight is joined, not duplicated'); - release(); - await eventually(() => JSON.parse(JSON.stringify(management.calls)).length === 1, 'settled'); - const third = await request(server, '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/api/maintenance?refresh=scan', { origin: false, fetchSite: null }); - assert.equal(third.status, 200); - await eventually(() => management.calls.length === 2, 'a later provider scan rebuilds again once the flight settled'); -}); - -test('an inventory rebuild failure is logged and never turns the provider-scan response into an error', async (t) => { - const service = fixtureService(); - const management = recordingManagement({ refreshInventory: async () => { throw new Error('inventory store unavailable'); } }); - const logged = []; - const original = console.error; - console.error = (...args) => { logged.push(args.map(String).join(' ')); }; - t.after(() => { console.error = original; }); - const server = await startDashboard({ port: 0, maintenance: service, management: management.facade, usage: {} }); - t.after(() => server.close()); - - const scanned = await request(server, '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/api/maintenance?refresh=scan', { origin: false, fetchSite: null }); - assert.equal(scanned.status, 200); - await eventually(() => logged.some((line) => /maintenance inventory refresh failed/.test(line)), 'the failure is logged'); - assert.deepEqual(management.calls, [{ deep: false }]); - const again = await request(server, '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/api/maintenance?refresh=scan', { origin: false, fetchSite: null }); - assert.equal(again.status, 200); - await eventually(() => management.calls.length === 2, 'a failed rebuild does not wedge the single-flight slot'); -}); - -test('a completed System deep scan chains one provider scan and then one inventory rebuild', async (t) => { +test('GET Maintenance scan queries never invoke provider checks or inventory rebuilds', async (t) => { const service = fixtureService(); const management = recordingManagement(); - const collector = { - async read() { return { scan: { running: false, phase: 'idle' }, generatedAt: '2026-09-05T12:00:00.000Z' }; }, - async refreshDeep() { return { ok: true, persisted: { ok: true } }; }, - scanState() { return { running: true, phase: 'system' }; }, - }; - const server = await startDashboard({ port: 0, system: collector, maintenance: service, management: management.facade, usage: {} }); + const server = await startDashboard({ port: 0, maintenance: service, management: management.facade, usage: {} }); t.after(() => server.close()); - - const started = await request(server, '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/api/system?refresh=deep', { origin: false, fetchSite: null }); - assert.equal(started.status, 200); - await eventually(() => service.calls.scan === 1, 'the completed System scan refreshes Maintenance evidence once'); - await eventually(() => management.calls.length === 1, 'the provider scan then rebuilds the inventory once'); - assert.deepEqual(management.calls, ['rebuildAfterMeasurement'], - 'a machine measurement walks discovery sources and rebuilds, and never also runs the cheap rebuild'); + assert.equal((await request(server, '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/api/maintenance', { origin: false, fetchSite: null })).status, 200); + for (const query of ['refresh=scan', 'refresh=deep', 'refresh=scan&refresh=scan', 'trees=1', 'extra=scan']) { + const response = await request(server, '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/api/maintenance?' + query, { origin: false, fetchSite: null }); + assert.equal(response.status, 400, query); + assert.deepEqual(JSON.parse(response.body), { error: 'start a refresh with POST /api/refresh' }); + } + assert.equal(service.calls.scan, 0); + assert.deepEqual(management.calls, []); }); -test('a completed System deep scan falls back to the cheap rebuild when the facade has no measured rebuild', async (t) => { - const service = fixtureService(); - const management = recordingManagement({ measured: false }); - const collector = { - async read() { return { scan: { running: false, phase: 'idle' } }; }, - async refreshDeep() { return { ok: true, persisted: { ok: true } }; }, - }; - const server = await startDashboard({ port: 0, system: collector, maintenance: service, management: management.facade, usage: {} }); - t.after(() => server.close()); - assert.equal((await request(server, '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/api/system?refresh=deep', { origin: false, fetchSite: null })).status, 200); - await eventually(() => management.calls.length === 1, 'the chain still rebuilds'); - assert.deepEqual(management.calls, [{ deep: false }]); +test('POST refresh stages retain machine measurement, provider scan and inventory rebuild order', async () => { + const { dashboardRefreshStages } = await import('../../src/lib/dashboard/refresh-api.mjs'); + const calls = []; + const stage = dashboardRefreshStages({ cwd: process.cwd(), + getSystem: async () => ({ refreshDeep: async options => { calls.push(['machine', options]); return { ok: true, persisted: { ok: true } }; } }), + getMaintenance: async () => ({ scan: async options => { calls.push(['maintenance', options]); return { scan: {} }; } }), + refreshInventoryAfterProviderScan: async options => { calls.push(['inventory', options]); return {}; }, + getHostReadiness: async () => ({}), statusCollect: async () => ({}), loadConfig: () => ({}), + }); + assert.equal((await stage.machine({ projectTrees: true })).ok, true); + assert.equal((await stage.maintenance()).ok, true); + assert.equal((await stage.inventory({ strength: 'machine' })).ok, true); + assert.deepEqual(calls, [['machine', { includeProjectTrees: true }], ['maintenance', { deep: false }], + ['inventory', { measured: true }]]); + calls.length = 0; + assert.equal((await stage.maintenance()).ok, true); + assert.equal((await stage.inventory({ strength: 'local' })).ok, true); + assert.deepEqual(calls, [['maintenance', { deep: false }], ['inventory', { measured: false }]]); }); test('an injected maintenance service without an injected facade never composes the default facade (hermetic)', async (t) => { @@ -578,8 +521,8 @@ test('an injected maintenance service without an injected facade never composes const server = await startDashboard({ port: 0, maintenance: service, usage: {} }); t.after(() => server.close()); const scanned = await request(server, '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/api/maintenance?refresh=scan', { origin: false, fetchSite: null }); - assert.equal(scanned.status, 200); - assert.equal(service.calls.scan, 1); + assert.equal(scanned.status, 400); + assert.equal(service.calls.scan, 0); const inventory = await request(server, '/api/maintenance/v2/inventory', { origin: false, fetchSite: null }); assert.equal(inventory.status, 503); assert.deepEqual(JSON.parse(inventory.body), { error: 'maintenance management unavailable' }); diff --git a/tests/kit/maintenance-dashboard-client-labels.test.mjs b/tests/kit/maintenance-dashboard-client-labels.test.mjs index 1d1e7272..48a92331 100644 --- a/tests/kit/maintenance-dashboard-client-labels.test.mjs +++ b/tests/kit/maintenance-dashboard-client-labels.test.mjs @@ -100,17 +100,9 @@ test('MNT-EVD-006: extractor is not vacuous (finds real, allowed literals)', () assert.ok(literals.includes('Clear all')); }); -// The Refresh-evidence rename introduced two labels the extractor structurally -// cannot see: "Refresh evidence"/"Refreshing evidence…" are assigned via a -// plain JS ternary (`button.textContent=busy?...:...`), not a `label:` -// property or a static HTML text node, and "Re-measure machine" lives only in -// page.mjs's server-rendered markup, which this file does not scan at all. -// Assert them directly so a future rename cannot silently reintroduce a -// prohibited word here without any test noticing. -test('MNT-EVD-006: the Refresh evidence / Re-measure machine controls carry no prohibited label', () => { - for (const label of ['Refresh evidence', 'Refreshing evidence…', 'Re-measure machine']) { - assert.equal(isProhibitedLabel(label), false, label); - } +test('the retired Maintenance refresh controls are absent from the page', () => { + const page = fs.readFileSync(new URL('../../src/lib/dashboard/page.mjs', import.meta.url), 'utf8'); + for (const id of ['mnt-check-providers', 'mnt-remeasure']) assert.doesNotMatch(page, new RegExp('id="' + id + '"')); }); // (b) The copied label maps in maintenance-workspace.mjs must equal diff --git a/tests/kit/maintenance-dashboard-e2e.test.mjs b/tests/kit/maintenance-dashboard-e2e.test.mjs index 73e5e65a..a41d249d 100644 --- a/tests/kit/maintenance-dashboard-e2e.test.mjs +++ b/tests/kit/maintenance-dashboard-e2e.test.mjs @@ -186,10 +186,24 @@ test('dashboard HTTP executes and undoes one maintenance finding through the rea const server = await startDashboard({ port: 0, maintenance: service, usage: {}, maintenanceOptions: HERMETIC_MAINTENANCE, fetchStatus: async () => ({ overall: 'ok', rows: [] }), + refreshStages: { + maintenance: async () => { await service.scan({ deep: false }); return { ok: true }; }, + inventory: async () => ({ ok: true }), local: async () => ({ ok: true }), + }, }); t.after(() => server.close()); - const report = await request(server, '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/api/maintenance?refresh=scan'); + const started = await request(server, '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/api/refresh', { method: 'POST', body: { strength: 'local' } }); + assert.equal(started.status, 202); + let state; + for (let attempt = 0; attempt < 100; attempt++) { + state = (await request(server, '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/api/refresh')).body; + if (!state.running) break; + await new Promise(resolve => setTimeout(resolve, 5)); + } + assert.equal(state?.running, false, 'the explicit refresh finishes'); + assert.equal(state?.ok, true); + const report = await request(server, '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/api/maintenance'); assert.equal(report.status, 200); assert.equal(report.headers['cache-control'], 'no-store'); assertNoPrivateTransport(report.body); diff --git a/tests/kit/maintenance-dashboard-v2-api.test.mjs b/tests/kit/maintenance-dashboard-v2-api.test.mjs index 9f9ddede..a8edec94 100644 --- a/tests/kit/maintenance-dashboard-v2-api.test.mjs +++ b/tests/kit/maintenance-dashboard-v2-api.test.mjs @@ -748,26 +748,18 @@ test('v2 inventory projection carries row kind and opaque-id facet labels over e assert.doesNotMatch(JSON.stringify(hostile), /Users\/alice|leak/); }); -test('report({ refresh:true }) fires afterScan once after a successful provider scan and never lets it fail the response', async () => { +test('report reads persisted evidence and ignores retired refresh arguments', async () => { const events = []; - const service = { async report() { return {}; }, async scan() { events.push('scan'); return {}; }, async plan() { return {}; } }; + const service = { async report() { events.push('report'); return {}; }, async scan() { events.push('scan'); return {}; }, async plan() { return {}; } }; const api = createMaintenanceDashboardApi({ service, sessionToken: SESSION, afterScan: () => { events.push('afterScan'); throw new Error('rebuild failed'); }, }); const plain = fakeRes(); await api.report({}, plain, { refresh: false }); - assert.deepEqual([plain.out.status, events], [200, []]); + assert.deepEqual([plain.out.status, events], [200, ['report']]); const refreshed = fakeRes(); await api.report({}, refreshed, { refresh: true }); - assert.deepEqual([refreshed.out.status, events], [200, ['scan', 'afterScan']]); - const failing = createMaintenanceDashboardApi({ - service: { ...service, async scan() { throw new Error('provider check failed'); } }, sessionToken: SESSION, - afterScan: () => { events.push('never'); }, - }); - const failed = fakeRes(); - await failing.report({}, failed, { refresh: true }); - assert.equal(failed.out.status, 503); - assert.equal(events.includes('never'), false, 'afterScan only follows a successful scan'); + assert.deepEqual([refreshed.out.status, events], [200, ['report', 'report']]); }); test('lastRefresh is allowlisted on the report, inventory, and guidance envelopes with a guarded, label-safe message (QE D6b)', async () => { @@ -895,6 +887,31 @@ test('public activity retains historical scans as well as latest source summarie assert.equal(payload.scanHistory.length, 2); }); +test('public activity allows a bounded pause time while omitting invalid timestamps and private metadata', () => { + const scan = { sourceId: SOURCE, state: 'paused', recordedAt: '2026-09-09T12:00:00.000Z', completedAt: null, privatePath: PRIVATE_PATH }; + const payload = publicActivity({ scans: [scan], scanHistory: [scan, { ...scan, recordedAt: PRIVATE_PATH }, { ...scan, recordedAt: '1' }] }); + assert.equal(payload.scans[0].recordedAt, scan.recordedAt); + assert.equal(payload.scans[0].completedAt, null); + assert.equal(payload.scanHistory[1].recordedAt, undefined); + assert.equal(payload.scanHistory[2].recordedAt, undefined); + assert.equal(JSON.stringify(payload).includes(PRIVATE_PATH), false); +}); + +test('public activity rejects impossible pause dates and retains valid leap-day offset stamps', () => { + const base = { sourceId: SOURCE, state: 'paused', completedAt: null }; + const valid = '2024-02-29T23:59:59.125+05:30'; + const payload = publicActivity({ scans: [{ ...base, recordedAt: valid }], scanHistory: [ + { ...base, recordedAt: '2026-09-31T12:00:00Z', completedAt: '2026-09-30T12:00:00Z' }, + { ...base, recordedAt: '2025-02-29T12:00:00Z' }, + { ...base, recordedAt: valid }, + ] }); + assert.equal(payload.scans[0].recordedAt, valid); + assert.equal(payload.scanHistory[0].recordedAt, undefined); + assert.equal(payload.scanHistory[0].completedAt, '2026-09-30T12:00:00Z'); + assert.equal(payload.scanHistory[1].recordedAt, undefined); + assert.equal(payload.scanHistory[2].recordedAt, valid); +}); + test('v2 reports native persistence refusal without suggesting an action started', async () => { const refusal = Object.assign(new Error('private adapter unavailable'), { code: 'MAINTENANCE_PERSISTENCE_UNAVAILABLE' }); const { post } = harness({ management: stubManagement({ planAction: async () => { throw refusal; } }).facade }); diff --git a/tests/kit/maintenance-management-activity.test.mjs b/tests/kit/maintenance-management-activity.test.mjs index e0a6de0f..b6fddc27 100644 --- a/tests/kit/maintenance-management-activity.test.mjs +++ b/tests/kit/maintenance-management-activity.test.mjs @@ -77,6 +77,51 @@ test('activity shows the latest scan per source and environment without changing assert.deepEqual(scanHistory, original); }); +test('a newer paused record is the latest state and retains its distinct recorded time', () => { + const base = { sourceId: 'claude', environmentId: 'local', label: 'Claude', visited: 12 }; + const complete = { ...base, state: 'complete', completedAt: '2026-09-08T12:00:00.000Z' }; + const paused = { ...base, state: 'paused', recordedAt: '2026-09-09T12:00:00.000Z', completedAt: null }; + const result = buildActivity({ scanHistory: [paused, complete] }); + assert.equal(result.scans[0].state, 'paused'); + assert.equal(result.scans[0].recordedAt, paused.recordedAt); + assert.equal(result.scans[0].completedAt, null); + assert.equal(result.scanHistory[1].completedAt, complete.completedAt); +}); + +test('invalid recorded time falls back to completion without passing metadata through', () => { + const base = { sourceId: 'claude', environmentId: 'local', state: 'complete' }; + const result = buildActivity({ scanHistory: [ + { ...base, completedAt: '2026-09-09T12:00:00.000Z', recordedAt: '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/private/path', privatePath: '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/private/path' }, + { ...base, completedAt: '2026-09-08T12:00:00.000Z' }, + ] }); + assert.equal(result.scans[0].completedAt, '2026-09-09T12:00:00.000Z'); + assert.equal(result.scans[0].recordedAt, null); + assert.equal(buildActivity({ scanHistory: [{ ...base, completedAt: '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/private/path' }] }).scanHistory[0].completedAt, null); + assert.equal(JSON.stringify(result).includes('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/private/path'), false); +}); + +test('an impossible pause date cannot supersede a real completion or invent a calendar day', () => { + const base = { sourceId: 'claude', environmentId: 'local' }; + const completed = { ...base, state: 'complete', completedAt: '2026-09-30T12:00:00.000Z' }; + const paused = { ...base, state: 'paused', recordedAt: '2026-09-31T12:00:00Z', completedAt: null }; + const result = buildActivity({ scanHistory: [paused, completed] }); + assert.equal(result.scans[0].state, 'complete'); + assert.equal(result.scanHistory[0].recordedAt, null); + assert.equal(result.scanHistory[0].completedAt, null); + const fallback = buildActivity({ scanHistory: [{ ...completed, recordedAt: paused.recordedAt }] }); + assert.equal(fallback.scans[0].recordedAt, null); + assert.equal(fallback.scans[0].completedAt, completed.completedAt); +}); + +test('scan timestamps accept leap days, offsets, and fractional seconds', () => { + const recordedAt = '2024-02-29T23:59:59.125+05:30'; + const completedAt = '2024-02-29T08:00:00Z'; + const result = buildActivity({ scanHistory: [{ sourceId: 'a', environmentId: 'local', state: 'complete', recordedAt, completedAt }] }); + assert.equal(result.scans[0].recordedAt, recordedAt); + assert.equal(result.scans[0].completedAt, completedAt); + assert.equal(buildActivity({ scanHistory: [{ sourceId: 'a', environmentId: 'local', state: 'complete', completedAt: '2025-02-29T08:00:00Z' }] }).scans[0].completedAt, null); +}); + test('no label anywhere in buildActivity output is prohibited', () => { const activity = buildActivity({ receipts: [INTERRUPTED_RECEIPT], diff --git a/tests/kit/maintenance-presentation.test.mjs b/tests/kit/maintenance-presentation.test.mjs index adc9ff12..9ef6fe36 100644 --- a/tests/kit/maintenance-presentation.test.mjs +++ b/tests/kit/maintenance-presentation.test.mjs @@ -63,59 +63,37 @@ test('a project filter never strips context from a user placement', () => { const html = cards(state).renderMntGroups([group('r1', [row('p1')])], { value: 0 }); assert.match(html, /User · Codex › Skills/); }); -function operation(get) { - const state = {}; - const nodes = Object.fromEntries(['mnt-check-providers', 'mnt-remeasure', 'mnt-check-providers-status', 'mnt-operation-elapsed'].map((id) => [id, { textContent: '', dataset: {} }])); - const api = client('maintenance-operation', { MNT: state, mntGet: get, mntRefreshActiveDestination() {}, loadSystem: async () => {}, SYSTEM: {}, systemBusy: false, document: { getElementById: (id) => nodes[id], addEventListener() {} }, setInterval: () => 1, clearInterval() {}, setTimeout: (fn) => queueMicrotask(fn) }, ['mntCheckProviders', 'mntBuildStatusOf', 'mntAwaitInventoryBuild']); - return { state, nodes, ...api }; -} +test('Maintenance writes are blocked during the shared Refresh operation', () => { + const api = client('maintenance-operation', { + MNT: { externalScanBusy: false }, refreshRunning: () => true, + }, ['mntWritesBlocked']); + assert.equal(api.mntWritesBlocked(), true); +}); +test('Maintenance hash synchronization adopts an externally changed destination', () => { + const location = { hash: '#system/maintenance/inventory?scope=across' }; + const history = { replaceState(_state, _title, hash) { location.hash = hash; } }; + const api = client('maintenance-workspace', { location, history, localStorage: { setItem() {} } }, ['MNT', 'mntSyncHash']); + api.mntSyncHash(); + location.hash = '#system/maintenance/guidance?scope=project'; + api.mntSyncHash(); + assert.equal(api.MNT.destination, 'guidance'); + assert.equal(api.MNT.scope, 'project'); + assert.match(location.hash, /^#system\/maintenance\/guidance\?scope=project/); + location.hash = '#usage/score'; + api.mntSyncHash(); + assert.equal(location.hash, '#usage/score'); +}); test('an existing inventory does not mask a running or failed refresh', () => { - const api = operation(() => {}); + const api = client('maintenance-operation', {}, ['mntBuildStatusOf']); assert.equal(api.mntBuildStatusOf({ scanRequired: false, lastRefresh: { status: 'running' } }), 'running'); assert.equal(api.mntBuildStatusOf({ scanRequired: false, lastRefresh: { status: 'failed' } }), 'failed'); }); -test('refresh keeps both buttons disabled until a fresh inventory is published', async () => { - let inventoryCalls = 0, providerCalls = 0, release; - const publication = new Promise((resolve) => { release = resolve; }); - let signalWaiting; - const waiting = new Promise((resolve) => { signalWaiting = resolve; }); - const api = operation(async (url) => { - if (url.includes('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/v2/inventory')) { - inventoryCalls++; - if (inventoryCalls === 1) return { scanRequired: false, lastRefresh: { at: 'old', status: 'ok' } }; - signalWaiting();return publication; - } - if (url.includes('?refresh=scan')) return {}; - providerCalls++; - return { activity: { status: 'idle' }, scan: { status: 'complete', checkedAt: providerCalls === 1 ? 'old' : 'new', coverage: 'complete' } }; - }); - const run = api.mntCheckProviders(); - await waiting; - assert.equal(api.nodes['mnt-check-providers'].disabled, true); - assert.equal(api.nodes['mnt-remeasure'].disabled, true); - assert.match(api.state.operation.message, /Updating inventory/); - release({ scanRequired: false, lastRefresh: { at: 'new', status: 'ok' }, partialSources: { total: 1 } }); - await run; - assert.equal(api.nodes['mnt-remeasure'].disabled, false); - assert.match(api.state.operation.message, /coverage gaps/); -}); -test('provider failure is visible and releases the busy state', async () => { - const api = operation(async (url) => { - if (url.includes('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/v2/inventory')) return { lastRefresh: { at: 'old' } }; - if (url.includes('?refresh=scan')) throw new Error('Evidence request failed.'); - return { scan: { checkedAt: 'old' } }; - }); - await api.mntCheckProviders(); - assert.equal(api.state.operation.failed, true); - assert.match(api.state.operation.message, /Evidence request failed/); - assert.equal(api.nodes['mnt-remeasure'].disabled, false); -}); test('filesystem completion excludes provider checks without inventing their success', () => { const ctx = { state: {}, orchestrator: () => ({ coverage: () => [{ sourceId: 'files', state: 'complete', visited: 4 }], progress: () => [] }), lastGoodDiscoveryStore: { current: () => [] }, listSources: () => [{ sourceId: 'files', filesystem: true }, { sourceId: 'providers', filesystem: false, label: 'Providers' }] }; const result = scanProgress(ctx)(); assert.match(result.narrative, /1 of 1/); assert.equal(result.coverage.find((c) => c.sourceId === 'providers').state, 'not-scanned'); - assert.equal(result.evidenceChecks[0].method, 'Refresh evidence'); + assert.equal(result.evidenceChecks[0].method, 'Refresh'); }); test('Hermes path honors HERMES_HOME and otherwise resolves the user configuration', () => { const previous = process.env.HERMES_HOME; @@ -306,20 +284,6 @@ test('public inventory uses the measured project location instead of guessing fr assert.equal(page.facetLabels.project[skill.projectId], 'ampel'); assert.doesNotMatch(JSON.stringify(page), /\/private\/repo/); }); -test('a completed refresh over stale machine evidence waits for publication and asks for remeasurement', async () => { - let inventoryCalls=0, providerCalls=0; - const api=operation(async url=>{ - if(url.includes('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/v2/inventory'))return {scanRequired:false,lastRefresh:{at:++inventoryCalls===1?'old':'new',status:'ok'}}; - if(url.includes('?refresh=scan'))return {scan:{status:'complete',checkedAt:'new'}}; - return {activity:{status:'complete'},scan:{status:'stale',checkedAt:++providerCalls===1?'old':'new',coverage:'partial'}}; - }); - await api.mntCheckProviders(); - assert.equal(api.state.operation.failed,false); - assert.equal(inventoryCalls,2); - assert.match(api.state.operation.message,/Evidence refreshed.*Re-measure machine/); - assert.equal(api.nodes['mnt-check-providers'].disabled,false); -}); - test('version inspector localizes measured and checked instants without interpreting version identifiers', () => { const api = client('maintenance-inspector', { esc, mntKindLabel: (value) => value, diff --git a/tests/kit/refresh-vocabulary-guard.test.mjs b/tests/kit/refresh-vocabulary-guard.test.mjs index 7523a763..e36aeb38 100644 --- a/tests/kit/refresh-vocabulary-guard.test.mjs +++ b/tests/kit/refresh-vocabulary-guard.test.mjs @@ -1,21 +1,16 @@ // refresh-vocabulary-guard.test.mjs — ADR-0063 (the refresh vocabulary): no -// retired CLI spelling from before the one-refresh-flag vocabulary may +// retired CLI or dashboard spelling from before the one-refresh vocabulary may // reappear in help text, README, docs, or the installed `claude/` guidance. // -// Scope: src/** (comments included — they must describe current CLI -// behaviour), bin/**, claude/**, README.md, and living top-level docs/*.md. +// Scope: src/** (comments included — they must describe current behaviour), +// bin/**, claude/**, README.md, and current docs/**/*.md. // docs/adr/, docs/archive/, docs/plans/, and docs/proposals/ are records that // may preserve retired spellings. In // src/lib/hook-audit/agentic-dependency-constraints.json only the dated // watch[].history[].note strings are skipped — every other string, including // `adjustment`, is scanned like any other source text. // -// CLI patterns only: the dashboard's own retired spellings ("Full -// scan", "Refresh evidence", "Re-measure machine", "Check again", "refresh -// now") are out of this guard's scope until the dashboard half of this work -// lands in a later branch (see docs/superpowers/plans/2026-09-28-branch-6b- -// one-refresh-flag.md, "Closing this branch"). This guard never asserts an -// UPGRADING section or an old -> new table exists (no legacy, no hints). +// This guard never asserts an UPGRADING section or an old -> new table exists. import { test } from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; @@ -60,13 +55,25 @@ const RETIRED_CLI_PATTERNS = [ { label: 'ak usage prompts …--deep (flag anywhere on the same line)', pattern: /\bprompts\b[^\n]*\[?--deep\b/g }, ]; +const RETIRED_DASHBOARD_PATTERNS = [ + { label: 'retired dashboard scan control', pattern: /\bFull scan\b/g }, + { label: 'retired dashboard evidence control', pattern: /\bRefresh evidence\b/g }, + { label: 'retired dashboard measurement control', pattern: /\bRe-measure machine\b/g }, + { label: 'retired dashboard local-check control', pattern: /\bCheck again\b/g }, + { label: 'retired dashboard refresh prompt', pattern: /\brefresh now\b/g }, + { label: 'retired dashboard GET refresh query', pattern: /refresh=(?:deep|scan)/g }, + { label: 'retired dashboard host-health route', pattern: /\/api\/host-health\/local/g }, +]; + +const RETIRED_PATTERNS = [...RETIRED_CLI_PATTERNS, ...RETIRED_DASHBOARD_PATTERNS]; + function lineOf(text, offset) { return text.slice(0, offset).split('\n').length; } function violations(relPath, text) { const found = []; - for (const { label, pattern } of RETIRED_CLI_PATTERNS) { + for (const { label, pattern } of RETIRED_PATTERNS) { pattern.lastIndex = 0; for (const match of text.matchAll(pattern)) { found.push(`${relPath}:${lineOf(text, match.index)} ${label}: ${JSON.stringify(match[0])}`); @@ -157,14 +164,14 @@ function scopeFiles() { return files.filter((file) => file !== REGISTRY); } -test('no retired CLI spelling remains in help, README, docs, or installed guidance', () => { +test('no retired CLI or dashboard spelling remains in source, README, current docs, or installed guidance', () => { const found = []; for (const file of scopeFiles()) { const text = fs.readFileSync(file, 'utf8'); found.push(...violations(path.relative(ROOT, file), text)); } found.push(...registryViolations()); - assert.deepEqual(found, [], `retired CLI spellings remain:\n${found.join('\n')}`); + assert.deepEqual(found, [], `retired CLI or dashboard spellings remain:\n${found.join('\n')}`); }); test('the registry skip is narrow: dated watch[].history[].note strings still contain the old spellings they document', () => { diff --git a/tests/kit/refresh.test.mjs b/tests/kit/refresh.test.mjs index 8dcf0fae..0d5fed7e 100644 --- a/tests/kit/refresh.test.mjs +++ b/tests/kit/refresh.test.mjs @@ -163,7 +163,7 @@ test('runRefresh refuses an unknown strength or a missing stage', async () => { test('the refresh operation never references the paid connection check', () => { // Every module that runs refresh stages belongs in this list, including any // future server-side refresh module. - for (const file of [REFRESH_SOURCE]) { + for (const file of [REFRESH_SOURCE, path.join(PKG_ROOT, 'src/lib/dashboard/refresh-api.mjs')]) { const source = fs.readFileSync(file, 'utf8'); assert.doesNotMatch(source, /checkConnection|host-health-connected/, file); } diff --git a/tests/kit/system-summary.test.mjs b/tests/kit/system-summary.test.mjs index dbb9e4f2..a8d6553e 100644 --- a/tests/kit/system-summary.test.mjs +++ b/tests/kit/system-summary.test.mjs @@ -592,13 +592,13 @@ test('the projects note says how many imported copies discovery set aside, and n // ── The page reads the slim endpoint ──────────────────────────────────────── -test('loadSystem fetches /api/system/summary, deep refresh parameters included', async () => { +test('loadSystem only re-reads /api/system/summary', async () => { const urls = []; const fetchImpl = (url) => { urls.push(url); return Promise.resolve({ json: () => Promise.resolve(systemSummaryPayload(fullPayload(1))) }); }; const { projects } = systemClient({ fetchImpl }); await projects.loadSystem(); await projects.loadSystem(true, false); - assert.deepEqual(urls, ['/api/system/summary', '/api/system/summary?refresh=deep&trees=0']); + assert.deepEqual(urls, ['/api/system/summary', '/api/system/summary']); }); // ── The routes ────────────────────────────────────────────────────────────── @@ -661,15 +661,14 @@ test('GET /api/system/summary serves the projection; GET /api/system stays compl assert.ok(complete.catalog.items[0].presence[0].itemPath); }); -test('GET /api/system/summary?refresh=deep starts the scan and answers with its running state', async (t) => { +test('GET /api/system/summary rejects measurement queries before reading the collector', async (t) => { const collector = fakeCollector(); const cwd = tempDir('ak-system-summary'); const server = await startDashboard({ port: 0, cwd, system: collector, usage: {}, ...hermeticMaintenance() }); t.after(() => server.close()); const r = await request(server, '/api/system/summary?refresh=deep&trees=0'); - assert.equal(r.status, 200); + assert.equal(r.status, 400); const body = JSON.parse(r.body); - assert.deepEqual(collector.calls.refreshDeep, [{ includeProjectTrees: false }]); - assert.deepEqual(body.scan, { running: true, phase: 'catalog' }); - assert.equal('artifacts' in body.catalog, false); + assert.deepEqual(body, { error: 'start a refresh with POST /api/refresh' }); + assert.deepEqual(collector.calls.refreshDeep, []); }); diff --git a/tests/ui/dashboard-ui.mjs b/tests/ui/dashboard-ui.mjs index 5fff42c7..8ed62e36 100644 --- a/tests/ui/dashboard-ui.mjs +++ b/tests/ui/dashboard-ui.mjs @@ -789,10 +789,9 @@ const MAINTENANCE_PAYLOAD = { receipts: [], }; -let chainedMaintenanceScans = 0; const MAINTENANCE_STUB = { async report() { return MAINTENANCE_PAYLOAD; }, - async scan() { chainedMaintenanceScans += 1; return MAINTENANCE_PAYLOAD; }, + async scan() { return MAINTENANCE_PAYLOAD; }, async plan() { return {}; }, }; @@ -1133,6 +1132,12 @@ async function main() { console.log(`\ncorpus: ${REAL ? 'REAL (~/.claude, ~/.codex)' : 'fixtures (deterministic)'}`); console.log(`cache : ${cachePath} (temp — your real index is untouched)\n`); + const refreshStageGates = new Map(); + const refreshStages = Object.fromEntries(['machine', 'maintenance', 'inventory', 'live', 'local'].map((id) => [id, async () => { + const gate = refreshStageGates.get(id); + if (gate) await gate; + return { ok: true }; + }])); const srv = await startDashboard({ port: 0, fetchStatus: STATUS_STUB, @@ -1145,6 +1150,7 @@ async function main() { modelScopeKey: 'ab'.repeat(32), system: SYSTEM_STUB, maintenance: MAINTENANCE_STUB, + refreshStages, }); const ORIGIN = new URL(srv.url).origin; const modelHeaders = { 'x-dash-token': srv.token }; @@ -1184,11 +1190,9 @@ async function main() { const group = document.getElementById('secondary-system')?.getBoundingClientRect(); const tabs = document.getElementById('system-seg')?.getBoundingClientRect(); const status = document.getElementById('system-freshness')?.getBoundingClientRect(); - const button = document.getElementById('sys-rescan'); return { statusText: document.getElementById('sys-asof')?.innerText, running: document.getElementById('system-freshness')?.getAttribute('data-running'), - buttonHidden: button?.hidden, statusBesideTabs: !!status && !!tabs && status.top < tabs.bottom && status.bottom > tabs.top && status.left >= tabs.right + 12, trailingSegmentSpace: Math.abs((tabs?.right ?? 0) @@ -1199,10 +1203,9 @@ async function main() { documentFits: document.documentElement.scrollWidth <= globalThis.innerWidth, }; }); - check('running full-scan progress sits beside a content-width System menu on wide screens', + check('running machine measurement progress sits beside a content-width System menu on wide screens', runningScanLayout.running === '1' - && /Full scan running.*Ranking disk use.*15 of 15/.test(runningScanLayout.statusText ?? '') - && runningScanLayout.buttonHidden === true + && /Machine measurement running.*Ranking disk use.*15 of 15/.test(runningScanLayout.statusText ?? '') && runningScanLayout.statusBesideTabs && runningScanLayout.trailingSegmentSpace < 6 && runningScanLayout.statusInsideGroup @@ -1226,7 +1229,7 @@ async function main() { ? getComputedStyle(document.getElementById('sys-asof')).whiteSpace : null, }; }); - check('narrow System navigation scrolls internally while scan status stays in its own rail', + check('narrow System navigation scrolls internally while measurement status stays in its own rail', narrowScanLayout.documentFits && narrowScanLayout.tabsScrollInternally && narrowScanLayout.statusBelowTabs @@ -1264,9 +1267,37 @@ async function main() { `blocked-storage startup was ${JSON.stringify(blockedStorageStartup)} with status ${blockedStatus.join(',')}`); await storageBlockedPage.close(); + const idlePage = await browser.newPage(); + const idleRequests = []; + idlePage.on('request', request => { + const route = new URL(request.url()).pathname; + if (route === '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/api/status' || route === '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/api/refresh') idleRequests.push({ route, method: request.method() }); + }); + await idlePage.goto(srv.urlWithToken, { waitUntil: 'domcontentloaded' }); + await idlePage.click('#poll-ivl'); + await idlePage.click('#poll-menu [data-ms="15000"]'); + const idleDeadline = Date.now() + 35_000; + while (idleRequests.filter(request => request.route === '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/api/status').length < 3 && Date.now() < idleDeadline) { + await idlePage.waitForTimeout(250); + } + check('two idle poll ticks issue no /api/refresh request or POST', + idleRequests.filter(request => request.route === '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/api/status').length >= 3 + && idleRequests.every(request => request.route !== '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/api/refresh'), + JSON.stringify(idleRequests)); + await idlePage.close(); + const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, locale: 'en-US', timezoneId: 'America/Los_Angeles', }); + const refreshRequests = []; + const statusRequests = []; + const systemSummaryRequests = []; + page.on('request', (request) => { + const pathname = new URL(request.url()).pathname; + if (pathname === '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/api/refresh') refreshRequests.push({ method: request.method(), body: request.method() === 'POST' ? request.postDataJSON() : null }); + if (pathname === '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/api/status') statusRequests.push(request.url()); + if (pathname === '/api/system/summary') systemSummaryRequests.push(request.url()); + }); // Anything the page logs as an error, or any request it fails, is a defect — // collected globally so a failure in one view is not silently swallowed. @@ -1297,7 +1328,8 @@ async function main() { page.on('console', (m) => { if (m.type() !== 'error') return; const loc = m.location(); - if (/status of 409 \(Conflict\)/.test(m.text()) && loc?.url && expectedHttpConsoleErrors.delete(loc.url)) return; + if (/status of (?:409 \(Conflict\)|503 \(Service Unavailable\))/.test(m.text()) + && loc?.url && expectedHttpConsoleErrors.delete(loc.url)) return; const where = loc?.url ? ` @ ${loc.url}` : ''; consoleErrors.push(`${m.text()}${where}`); }); @@ -1424,7 +1456,6 @@ async function main() { // check: the inventory stub answers `running` that many times, then flips // scanRequired off so the workspace's bounded polling sees the built page. let maintenanceBuildPollsRemaining = 0; - let maintenanceRunningPollsServed = 0; // Per-label override for a coverage entry's `filesystem` flag, applied // on top of whatever the sentinel fixture's coverage() helper produced // (which never sets `filesystem` at all, exercising the "flag absent -> @@ -1472,7 +1503,8 @@ async function main() { for (const entry of entries) { counts[entry.lane] += 1; lanes[entry.lane].push(entry); } return { lanes, counts, entries }; } - await page.route(/\/api\/maintenance\/v2\//, async (route) => { + const maintenanceV2Route = /\/api\/maintenance\/v2\//; + const maintenanceV2Stub = async (route) => { const request = route.request(); const url = new URL(request.url()); const pathname = url.pathname; @@ -1496,7 +1528,6 @@ async function main() { maintenanceInventoryRequests.push(params); if (maintenanceBuildPollsRemaining > 0) { maintenanceBuildPollsRemaining -= 1; - maintenanceRunningPollsServed += 1; if (maintenanceBuildPollsRemaining === 0) maintenanceScanRequired = false; return reply(200, { scanRequired: true, total: 0, groups: [], facetCounts: {}, sortGroups: [], partialSources: [], @@ -1630,7 +1661,12 @@ async function main() { } if (request.method() === 'GET' && pathname === '/api/maintenance/v2/activity') { return reply(200, buildActivity({ - receipts: [INTERRUPTED_RECEIPT], dispositions: [], recipeEvents: [], scanHistory: [], inProgress: [], + receipts: [INTERRUPTED_RECEIPT], dispositions: [], recipeEvents: [], inProgress: [], + scanHistory: [ + { sourceId: 'src-claude', environmentId: 'env-local', label: 'Claude user configuration', state: 'complete', completedAt: '2026-09-07T12:00:00.000Z', visited: 12 }, + { sourceId: 'src-claude', environmentId: 'env-local', label: 'Claude user configuration', state: 'paused', recordedAt: '2026-09-09T12:00:00.000Z', completedAt: null, visited: 18 }, + { sourceId: 'src-other', environmentId: 'env-local', label: 'Codex configuration', state: 'complete', completedAt: '2026-09-08T12:00:00.000Z', visited: 8 }, + ], })); } const receiptMatch = pathname.match(/^\/api\/maintenance\/v2\/receipts\/([^/]+)$/); @@ -1762,7 +1798,8 @@ async function main() { }); } return reply(404, { code: 'NOT_FOUND' }); - }); + }; + await page.route(maintenanceV2Route, maintenanceV2Stub); // ── Refresh evidence (MNT-DSC-010): the workspace's explicit control reuses // the v1 endpoint verbatim, `?refresh=scan` then polling until settled. ── let maintenanceProviderPollCount = 0; @@ -1816,6 +1853,11 @@ async function main() { // connections. DOM readiness plus the application shell is the stable // navigation contract; network-idle can never be guaranteed by a Live UI. await page.goto(srv.urlWithToken, { waitUntil: 'domcontentloaded' }); + check('Refresh and Reload controls replace the retired scan buttons', + await page.locator('#refresh-run').count() === 1 + && await page.locator('#refresh-strength').count() === 1 + && await page.locator('#poll-now').getAttribute('aria-label') === 'Reload — re-read this view; runs no checks' + && await page.locator('#sys-rescan, #mnt-check-providers, #mnt-remeasure, #host-health-refresh').count() === 0); await page.waitForSelector('#panel-overview', { state: 'attached' }); // ── ADR-0026 · About leads the bar but must NOT hijack the landing view ── @@ -2588,7 +2630,7 @@ async function main() { check('a fresh install names coverage gaps and keeps measurement in the toolbar only', /^4 sources have not been scanned yet\./.test((freshInstallBanner || '').trim()) && await page.locator('#mnt-partial button').count() === 0 - && await page.isVisible('#mnt-remeasure') + && await page.locator('#mnt-remeasure').count() === 0 && !/Fresh source/.test(freshInstallBanner || ''), `fresh-install banner read ${JSON.stringify(freshInstallBanner)}`); @@ -2602,8 +2644,7 @@ async function main() { await page.fill('#mnt-search', ''); await page.waitForFunction(() => document.querySelectorAll('#mnt-results .mnt-row').length > 3); - // ── Refresh evidence (MNT-DSC-010): explicit, labeled; disables Apply/ - // Undo while it runs; never fires on its own ── + // The shared Refresh control owns provider checks; opening Maintenance does not start one. await page.click('[data-mnt-dest="guidance"]'); await page.waitForSelector('#mnt-tab-guidance[aria-selected="true"]'); await page.waitForSelector('[data-mnt-plan-plc]'); @@ -2611,29 +2652,8 @@ async function main() { check('MNT-GUD-001: Guidance has exactly the visible lanes Can apply here, Steps available, Decisions to make, Updates available, and Recovery to finish', JSON.stringify(guidanceLaneLabels) === JSON.stringify([ 'Can apply here', 'Steps available', 'Decisions to make', 'Updates available', 'Recovery to finish', - ]), - `guidance lane labels read ${JSON.stringify(guidanceLaneLabels)}`); - check('a provider check never starts on its own', - maintenanceCheckProvidersReads === 0, 'the workspace probed providers without an explicit click'); - const providerRefreshResponse = page.waitForResponse((response) => new URL(response.url()).searchParams.get('refresh') === 'scan'); - await page.click('#mnt-check-providers'); - await providerRefreshResponse; - // The button disables synchronously; Apply's disabled attribute follows - // once the guidance re-render that mntCheckProviders() triggers resolves. - await page.waitForFunction(() => document.querySelector('[data-mnt-plan-plc]')?.disabled === true, null, { timeout: 5000 }); - const providersRunning = await page.evaluate(() => ({ - buttonDisabled: document.getElementById('mnt-check-providers')?.disabled, - applyDisabled: document.querySelector('[data-mnt-plan-plc]')?.disabled, - })); - check('Refresh evidence is explicit, labeled, and disables Apply while it runs', - maintenanceCheckProvidersReads === 1 && providersRunning.buttonDisabled === true - && providersRunning.applyDisabled === true, - `providers-running state was ${JSON.stringify(providersRunning)}`); - await page.waitForFunction(() => document.getElementById('mnt-check-providers')?.disabled === false, null, { timeout: 8000 }); - check('the provider check settles and re-enables Apply', - await page.$eval('[data-mnt-plan-plc]', (button) => button.disabled === false), - 'Apply stayed disabled after the provider check settled'); - + ])); + check('a provider check never starts on its own', maintenanceCheckProvidersReads === 0); // ── Dispositions (MNT-GUD-009/011): explained before confirmation, one // exact guidanceId per write ── await page.waitForSelector('.mnt-dispositions [data-mnt-disposition-open="acknowledged"]'); @@ -2716,6 +2736,18 @@ async function main() { // typed-confirmation dialog ── await page.click('[data-mnt-dest="activity"]'); await page.waitForSelector('#mnt-tab-activity[aria-selected="true"]'); + await page.waitForSelector('#mnt-scan-history .mnt-history-day'); + const scanRows = await page.$$eval('#mnt-scan-history tbody', (groups) => groups.map((group) => ({ + heading: group.querySelector('.mnt-history-day')?.textContent?.trim(), + rows: [...group.querySelectorAll('tr:not(.mnt-history-day)')].map((row) => row.textContent?.trim()), + }))); + check('paused scan renders at its recorded time ahead of older completed scans', + scanRows.length === 3 && /Paused/.test(scanRows[0].rows[0]) + && /Claude user configuration/.test(scanRows[0].rows[0]) + && !/Time not recorded/.test(scanRows[0].rows[0]) + && /Codex configuration/.test(scanRows[1].rows[0]) + && /Complete/.test(scanRows[2].rows[0]), + `scan rows read ${JSON.stringify(scanRows)}`); await page.waitForSelector('[data-mnt-audit-receipt]'); const auditTriggerLabel = await page.textContent('[data-mnt-audit-receipt]'); check('MNT-RCV-001: an interrupted receipt offers Audit interruption, not generic Verify again', @@ -2776,12 +2808,6 @@ async function main() { `export requests were ${JSON.stringify(maintenanceExportRequests)}`); await page.click('#mnt-receipt-close'); - // ── Discovery: a light smoke check of the fourth destination. Waits for - // the CONTENT of #mnt-scan-progress specifically (not just an
  • in - // #mnt-automatic-sources, whose two rows are static and already present - // from the earlier group-collapse fixture's own stale Discovery visits) - // — a fresh fetch against base()'s real coverage can otherwise still be - // in flight when a weaker wait resolves on leftover data. ── await page.click('[data-mnt-dest="discovery"]'); await page.waitForSelector('#mnt-tab-discovery[aria-selected="true"]'); await page.waitForFunction(() => /Claude user configuration/.test( @@ -2893,37 +2919,22 @@ async function main() { )); const failedRefreshEmpty = await visibleText(page, '#mnt-results'); const failedRefreshStatus = await page.textContent('#mnt-status'); - check('a failed lastRefresh names "did not complete" plus the sanitized message, never the raw code, and keeps Refresh evidence available', + check('a failed lastRefresh names "did not complete" plus the sanitized message, never the raw code, and keeps Refresh available', /did not complete/.test(failedRefreshEmpty) && /did not respond before the timeout/.test(failedRefreshEmpty) && !/PROVIDER_TIMEOUT/.test(failedRefreshEmpty) && !/PROVIDER_TIMEOUT/.test(failedRefreshStatus || '') && /did not respond before the timeout/.test(failedRefreshStatus || '') - && await page.isEnabled('#mnt-check-providers'), + && await page.isEnabled('#refresh-run'), `empty state read ${JSON.stringify(failedRefreshEmpty)}, status read ${JSON.stringify(failedRefreshStatus)}`); maintenanceInventoryLastRefresh = null; - // ── D5: scanRequired points at Refresh evidence, and settling it - // refreshes Inventory in place, with no page reload. Hops off Inventory - // and back so the destination switch re-fetches against the reset - // (no-lastRefresh) fixture state above, without duplicating route - // handlers on a fresh page. ── + // A missing inventory points to the shared Refresh control. await page.click('[data-mnt-dest="discovery"]'); await page.click('[data-mnt-dest="inventory"]'); await page.waitForFunction(() => /No inventory has been built yet/.test( document.getElementById('mnt-results')?.innerText || '', )); const scanRequiredEmpty = await visibleText(page, '#mnt-results'); - check('the scanRequired empty state points at Refresh evidence on this workspace, not a System full scan', - /Refresh evidence/.test(scanRequiredEmpty) && !/full scan from System/i.test(scanRequiredEmpty), - `scanRequired empty state read ${JSON.stringify(scanRequiredEmpty)}`); - await page.click('#mnt-check-providers'); - await page.waitForFunction(() => document.getElementById('mnt-check-providers')?.disabled === false, null, { timeout: 8000 }); - await page.waitForSelector('#mnt-results .mnt-row', { timeout: 8000 }); - check('settling Refresh evidence clears scanRequired and shows Inventory results in place, with no reload', - await page.$$eval('#mnt-results .mnt-row', (els) => els.length) > 0, - 'Inventory did not refresh in place once the provider check settled'); - check('D8: the workspace polled the inventory through the running build (bounded, 1.5 s apart) instead of re-fetching once', - maintenanceRunningPollsServed >= 2 && maintenanceBuildPollsRemaining === 0, - `running polls served: ${maintenanceRunningPollsServed}, remaining ${maintenanceBuildPollsRemaining}`); + check('the scanRequired empty state points at Refresh', /Refresh/.test(scanRequiredEmpty)); maintenanceScanRequired = false; // ── D8: while the server reports a running build, the empty state says @@ -2937,68 +2948,13 @@ async function main() { document.getElementById('mnt-results')?.innerText || '', )); const runningEmpty = await visibleText(page, '#mnt-results'); - check('a running lastRefresh renders "Building the inventory…" and keeps Refresh evidence available', + check('a running lastRefresh renders "Building the inventory…" and keeps Refresh available', /Building the inventory/.test(runningEmpty) && !/not been built yet/.test(runningEmpty) - && await page.isEnabled('#mnt-check-providers'), + && await page.isEnabled('#refresh-run'), `running empty state read ${JSON.stringify(runningEmpty)}`); maintenanceInventoryLastRefresh = null; maintenanceScanRequired = false; - // ── Re-measure machine (System Full scan, exposed beside Refresh - // evidence): delegates to System's own #sys-rescan, then blocks writes - // (mntWritesBlocked) for the whole measurement + provider-check + - // inventory-rebuild chain. Uses its own temporary /api/system route - // rather than the real SYSTEM_STUB — that stub's systemDeepScans counter - // is asserted against a clean slate by the dedicated System-tab Rescan - // tests later in this run, and this block must neither depend on nor - // perturb that count. ── - await page.click('[data-mnt-dest="guidance"]'); - await page.waitForSelector('[data-mnt-plan-plc]'); - await page.waitForFunction(() => document.querySelector('[data-mnt-plan-plc]')?.disabled === false); - let remeasureSystemReadCount = 0; - let remeasureDeepScanRequests = 0; - // Deterministic on REQUEST COUNT, not wall-clock: the deep-scan kickoff - // itself is always the first read (reports running), every read after is - // settled. This cannot race system-projects.mjs's own poll cadence and - // mntPollSystemMeasurement's independent one against a Node-side timer. - await page.route(/\/api\/system(\/summary)?(\?|$)/, (route) => { - const reqUrl = new URL(route.request().url()); - if (reqUrl.searchParams.get('refresh') === 'deep') remeasureDeepScanRequests += 1; - remeasureSystemReadCount += 1; - if (remeasureSystemReadCount === 2) { - // Deep measurement also completes a fresh provider check and inventory build. - maintenanceCheckProvidersReads += 1; - maintenanceProviderPollCount = 2; - } - return route.fulfill({ - status: 200, contentType: 'application/json', - body: JSON.stringify({ ...SYSTEM_PAYLOAD, scan: { ...SYSTEM_PAYLOAD.scan, running: remeasureSystemReadCount <= 1 } }), - }); - }); - await page.click('#mnt-remeasure'); - // mntRemeasureMachine() re-renders the active destination the instant it - // sets MNT.remeasureBusy — before it even clicks #sys-rescan — so both - // of these are observable synchronously, exactly like Refresh evidence. - const remeasureStarted = await page.evaluate(() => ({ - remeasureDisabled: document.getElementById('mnt-remeasure')?.disabled, - applyDisabled: document.querySelector('[data-mnt-plan-plc]')?.disabled, - })); - check('Re-measure machine disables itself and blocks writes (mntWritesBlocked) the instant it starts', - remeasureStarted.remeasureDisabled === true && remeasureStarted.applyDisabled === true, - `remeasure-start state was ${JSON.stringify(remeasureStarted)}`); - await page.waitForFunction(() => document.getElementById('mnt-remeasure')?.disabled === false, null, { timeout: 15_000 }); - check('Re-measure machine delegates to #sys-rescan (a real deep scan) and settles, re-enabling itself and Apply', - remeasureDeepScanRequests === 1 && await page.$eval('[data-mnt-plan-plc]', (b) => b.disabled === false), - `deep scan requests: ${remeasureDeepScanRequests}, Apply stayed disabled after Re-measure machine settled: ${await page.$eval('[data-mnt-plan-plc]', (b) => b.disabled)}`); - // A stale scheduled System poll used to restart a completed owned scan as - // an external operation, waiting forever for a second evidence generation. - await page.waitForTimeout(3200); - check('a completed remeasurement stays settled after the System poll interval', - await page.isEnabled('#mnt-remeasure') && await page.isEnabled('[data-mnt-plan-plc]') - && remeasureSystemReadCount === 2, - `system reads: ${remeasureSystemReadCount}; operation: ${await page.textContent('#mnt-check-providers-status')}`); - await page.unroute(/\/api\/system(\/summary)?(\?|$)/); - // ── #system/catalog redirects to Maintenance Inventory (ADR-0048) ── await page.evaluate(() => { location.hash = '#system/catalog'; }); await page.reload({ waitUntil: 'domcontentloaded' }); @@ -3518,8 +3474,6 @@ async function main() { text: el?.textContent.trim(), stale: el?.getAttribute('data-stale'), title: el?.getAttribute('title'), - rescanDisabled: document.getElementById('sys-rescan')?.disabled, - fullScanLabel: document.getElementById('sys-rescan')?.innerText, live: el?.getAttribute('aria-live'), }; }); @@ -3528,28 +3482,12 @@ async function main() { `the freshness label read ${JSON.stringify(freshness)} — the snapshot is nine days old`); check('past the staleness horizon the label nudges without scanning', freshness.stale === '1' && /stale/i.test(String(freshness.text)) - && freshness.rescanDisabled === false && /Full scan/.test(String(freshness.fullScanLabel)) && freshness.live === 'polite', `staleness presentation was ${JSON.stringify(freshness)}`); check('opening System never starts a deep scan', systemDeepScans === 0, `${systemDeepScans} deep scan(s) had already run after opening the area and all five views`); - const deepResponse = page.waitForResponse( - (r) => r.url().includes('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/api/system') && r.url().includes('refresh=deep'), - { timeout: 8000 }, - ).catch(() => null); - await page.click('#sys-rescan'); - await deepResponse; - await page.waitForTimeout(200); - check('Rescan is the only thing that starts a deep scan, and it starts exactly one', - systemDeepScans === 1, - `the collector saw ${systemDeepScans} deep scan(s) after one Rescan click`); - await page.waitForTimeout(50); - check('a successful deep System rescan refreshes Maintenance provider evidence once', - chainedMaintenanceScans === 1, - `the Maintenance service saw ${chainedMaintenanceScans} scan(s)`); - // ── Observability: execution workspace + synchronized evidence ── await page.click('[data-tab="observability"]'); await page.waitForSelector('#live-nodes .live-node', { timeout: 8000 }); @@ -5771,6 +5709,330 @@ async function main() { check('and survives a reload rather than snapping back to the default', c2.expanded === 'true' && c2.hidden === false, JSON.stringify(c2)); + // Refresh is the only route that starts checks. Its stage state is server authored. + check('idle dashboard requested no refresh state or operation', refreshRequests.length === 0, + JSON.stringify(refreshRequests)); + await page.click('#tab-system'); + await page.click('[data-system-view="maintenance"]'); + await page.click('[data-mnt-dest="guidance"]'); + await page.waitForSelector('[data-mnt-plan-plc]'); + // The audit and undo actions are conditional on retained receipts. Keep + // their actual selectors in the fixture while this run has no such receipt. + await page.evaluate(() => { + const fixture = globalThis.document.createElement('div'); + fixture.id = 'refresh-write-fixture'; + fixture.hidden = true; + fixture.innerHTML = ''; + globalThis.document.body.appendChild(fixture); + }); + const stageRelease = new Map(); + for (const id of ['maintenance', 'inventory', 'local']) { + refreshStageGates.set(id, new Promise((resolve) => stageRelease.set(id, resolve))); + } + const statusReadsBefore = statusRequests.length; + await page.click('#refresh-run'); + await page.waitForFunction(() => document.getElementById('refresh-status')?.textContent.includes('Refreshing Maintenance evidence')); + check('Refresh status names the server stage and elapsed time', + /Refreshing Maintenance evidence · \d+s/.test(await page.locator('#refresh-status').innerText())); + check('Refresh starts exactly one local POST', refreshRequests.filter((r) => r.method === 'POST').length === 1 + && JSON.stringify(refreshRequests.find((r) => r.method === 'POST')?.body) === JSON.stringify({ strength: 'local' })); + await page.waitForFunction(() => document.querySelector('[data-mnt-plan-plc]')?.disabled === true); + check('Maintenance Apply, Undo and Record are disabled during Refresh', + await page.locator('[data-mnt-plan-plc]').first().isDisabled() + && await page.locator('#refresh-write-fixture [data-mnt-undo-receipt]').isDisabled() + && await page.locator('#refresh-write-fixture [data-mnt-reconcile-receipt]').isDisabled()); + await page.click('[data-mnt-dest="inventory"]'); + await page.waitForSelector('#mnt-tab-inventory[aria-selected="true"]'); + const activeMaintenanceReadsBefore = maintenanceInventoryRequests.length; + for (const [id, label] of [['maintenance', 'Rebuilding the inventory'], ['inventory', 'Re-checking local evidence and versions']]) { + stageRelease.get(id)(); + await page.waitForFunction((text) => document.getElementById('refresh-status')?.textContent.includes(text), label); + } + stageRelease.get('local')(); + await page.waitForFunction(() => document.getElementById('refresh-status')?.textContent === 'Refresh complete.'); + for (let attempt = 0; attempt < 30 && maintenanceInventoryRequests.length <= activeMaintenanceReadsBefore; attempt++) { + await page.waitForTimeout(100); + } + await page.click('[data-mnt-dest="guidance"]'); + await page.waitForFunction(() => document.querySelector('[data-mnt-plan-plc]')?.disabled === false); + check('Maintenance write controls are restored after Refresh', + await page.locator('[data-mnt-plan-plc]').first().isEnabled() + && await page.locator('#refresh-write-fixture [data-mnt-undo-receipt]').isEnabled() + && await page.locator('#refresh-write-fixture [data-mnt-reconcile-receipt]').isEnabled()); + await page.locator('#refresh-write-fixture').evaluate(element => element.remove()); + await page.waitForTimeout(200); + check('Refresh re-reads the active Maintenance view and host readiness after completion', + statusRequests.length > statusReadsBefore && maintenanceInventoryRequests.length > activeMaintenanceReadsBefore); + const localRefreshReads = refreshRequests.filter(request => request.method === 'GET').length; + await page.waitForTimeout(1700); + check('completed Refresh stops status polling', refreshRequests.filter(request => request.method === 'GET').length === localRefreshReads); + await page.selectOption('#refresh-strength', 'machine'); + await page.check('#refresh-project-trees'); + await page.click('#refresh-run'); + await page.waitForFunction(() => document.getElementById('refresh-status')?.textContent === 'Refresh complete.'); + check('machine Refresh carries the project tree scope', refreshRequests.filter((r) => r.method === 'POST').length === 2 + && JSON.stringify(refreshRequests.filter((r) => r.method === 'POST')[1].body) === JSON.stringify({ strength: 'machine', projectTrees: true })); + const postCount = refreshRequests.filter((r) => r.method === 'POST').length; + const reloadRequests = []; + const captureReload = request => reloadRequests.push(request.method()); + await page.click('#poll-play'); + page.on('request', captureReload); + await page.waitForTimeout(3100); + const reloadResponse = page.waitForResponse(response => new URL(response.url()).pathname === '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/api/status'); + await page.click('#poll-now'); + await reloadResponse; + page.off('request', captureReload); + check('Reload issues only GETs and starts no checks', reloadRequests.length > 0 + && reloadRequests.every(method => method === 'GET') + && refreshRequests.filter((r) => r.method === 'POST').length === postCount); + for (const view of ['summary', 'storage', 'projects']) { + await page.click(`[data-system-view="${view}"]`); + await page.waitForTimeout(3100); + const before = systemSummaryRequests.length; + await page.click('#poll-now'); + await page.waitForTimeout(300); + check(`Reload re-reads active System ${view} without measuring`, + systemSummaryRequests.length > before + && systemSummaryRequests.slice(before).every(url => !new URL(url).searchParams.has('refresh')), + `System reads before/after: ${before}/${systemSummaryRequests.length}`); + } + await page.click('[data-system-view="storage"]'); + await page.selectOption('#refresh-strength', 'local'); + const beforeCompletion = systemSummaryRequests.length; + await page.click('#refresh-run'); + await page.waitForFunction(() => document.getElementById('refresh-status')?.textContent === 'Refresh complete.'); + await page.waitForTimeout(300); + check('completed Refresh re-reads active System Storage without measuring', + systemSummaryRequests.length > beforeCompletion + && systemSummaryRequests.slice(beforeCompletion).every(url => !new URL(url).searchParams.has('refresh')), + `System reads before/after: ${beforeCompletion}/${systemSummaryRequests.length}`); + await page.click('#poll-ivl'); + await page.click('#poll-menu [data-ms="15000"]'); + const backgroundBefore = systemSummaryRequests.length; + await page.click('#poll-play'); + await page.waitForTimeout(16_000); + await page.click('#poll-play'); + check('background poll keeps System Storage on the existing cheap-read policy', + systemSummaryRequests.length === backgroundBefore, + `System reads before/after background tick: ${backgroundBefore}/${systemSummaryRequests.length}`); + console.log(`refresh requests: idle 0; local POST 1, GET ${localRefreshReads}; machine POST 1, total GET ${refreshRequests.filter(request => request.method === 'GET').length}; Reload ${reloadRequests.length} GET`); + await page.setViewportSize({ width: 390, height: 844 }); + await page.evaluate(() => { localStorage.setItem('ak-dash-theme', 'light'); location.reload(); }); + await page.waitForFunction(() => document.documentElement.getAttribute('data-theme') === 'light'); + await page.screenshot({ path: path.join(SHOTS, 'refresh-header-light-390.png'), animations: 'disabled' }); + await page.evaluate(() => { localStorage.setItem('ak-dash-theme', 'dark'); location.reload(); }); + await page.waitForFunction(() => document.documentElement.getAttribute('data-theme') === 'dark'); + await page.screenshot({ path: path.join(SHOTS, 'refresh-header-dark-390.png'), animations: 'disabled' }); + check('Refresh header fits at 390px in light and dark themes', + await page.evaluate(() => { + const header = document.querySelector('.band'); + const refresh = document.querySelector('.refresh-control'); + return header.getBoundingClientRect().right <= globalThis.innerWidth + && refresh.getBoundingClientRect().right <= globalThis.innerWidth; + })); + await page.setViewportSize({ width: 1440, height: 900 }); + check('wide dark screenshot has the dark theme at capture time', + await page.evaluate(() => document.documentElement.getAttribute('data-theme') === 'dark' + && getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() === '#000000')); + await page.screenshot({ path: path.join(SHOTS, 'refresh-header-dark-1440.png'), animations: 'disabled' }); + + // A rejected status read cannot release the write guard for an operation + // this page already started. The next valid state reconciles it. + await page.click('[data-system-view="maintenance"]'); + await page.click('[data-mnt-dest="guidance"]'); + await page.waitForSelector('[data-mnt-plan-plc]'); + let releaseStatusStage; + refreshStageGates.set('maintenance', new Promise(resolve => { releaseStatusStage = resolve; })); + let statusFailures = 2; + const rejectStatus = route => { + if (route.request().method() === 'GET' && statusFailures > 0) { + statusFailures--; + if (statusFailures === 1) expectedHttpConsoleErrors.add(route.request().url()); + return route.fulfill(statusFailures === 1 + ? { status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'temporary status failure' }) } + : { status: 200, contentType: 'application/json', body: JSON.stringify({ running: 'unknown', stages: [] }) }); + } + return route.continue(); + }; + await page.route('**/api/refresh', rejectStatus); + const rejectedStatus = page.waitForResponse(response => new URL(response.url()).pathname === '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/api/refresh' && response.status() === 503); + await page.click('#refresh-run'); + await rejectedStatus; + await page.waitForFunction(() => /retry/i.test(document.getElementById('refresh-status')?.textContent || '')); + check('rejected refresh-status GET keeps the owned operation and Maintenance writes blocked', + await page.locator('#refresh-run').isDisabled() + && await page.locator('[data-mnt-plan-plc]').first().isDisabled() + && /retry/i.test(await page.locator('#refresh-status').innerText())); + const readsAfterError = refreshRequests.filter(request => request.method === 'GET').length; + await page.waitForTimeout(1700); + check('refresh-status read retries after non-2xx JSON and keeps invalid success state blocked', + refreshRequests.filter(request => request.method === 'GET').length > readsAfterError + && await page.locator('#refresh-run').isDisabled() + && await page.locator('[data-mnt-plan-plc]').first().isDisabled()); + releaseStatusStage(); + await page.waitForFunction(() => document.getElementById('refresh-status')?.textContent === 'Refresh complete.', null, { timeout: 8000 }); + await page.waitForFunction(() => document.querySelector('[data-mnt-plan-plc]')?.disabled === false); + check('owned refresh completes after status recovery and then unblocks writes', + await page.locator('#refresh-run').isEnabled() + && await page.locator('[data-mnt-plan-plc]').first().isEnabled()); + await page.unroute('**/api/refresh', rejectStatus); + + // Two real dashboard pages can supersede a completed operation before its + // first polling GET. Test both a newer terminal state and a newer running + // state against the actual server operation, not a route-shaped fake. + activeMaintenanceInventory = structuredClone(SENTINEL_FIXTURES.base()); + const supersessionUpdate = activeMaintenanceInventory.guidanceEntries.find(entry => entry.lane === 'apply'); + Object.assign(supersessionUpdate, { verb: 'update', outcome: 'Update Claude plugin', + providerCapabilityId: 'claude-plugin:v1:update:user', + verifiedPremises: ['placement', 'installedVersion', 'published-update', 'consumers', 'impact'], + impact: { summary: 'Installs the verified newer frontend-design version.' } }); + async function latestRefresh() { + const response = await fetch(`${ORIGIN}/api/refresh`, { headers: modelHeaders }); + return response.json(); + } + async function waitServerRefresh(running, differentFrom) { + const deadline = Date.now() + 5000; + while (Date.now() < deadline) { + const state = await latestRefresh(); + const identity = state.operationId; + if (state.running === running && identity !== differentFrom && identity) return state; + await new Promise(resolve => setTimeout(resolve, 20)); + } + throw new Error('the fixture refresh did not reach the expected server state'); + } + async function exerciseSupersession(holdPeer) { + const firstPage = await browser.newPage(); + const peerPage = await browser.newPage(); + let releaseFirstPoll; + const firstPollGate = new Promise(resolve => { releaseFirstPoll = resolve; }); + let interceptFirstPoll = true; + let releasePeerStage; + const holdPeerStage = new Promise(resolve => { releasePeerStage = resolve; }); + try { + await firstPage.route(maintenanceV2Route, maintenanceV2Stub); + await Promise.all([ + firstPage.goto(srv.urlWithToken, { waitUntil: 'domcontentloaded' }), + peerPage.goto(srv.urlWithToken, { waitUntil: 'domcontentloaded' }), + ]); + await firstPage.route('**/api/refresh', async route => { + if (route.request().method() === 'GET' && interceptFirstPoll) { + interceptFirstPoll = false; + await firstPollGate; + } + await route.continue(); + }); + await firstPage.click('#tab-system'); + await firstPage.click('[data-system-view="maintenance"]'); + await firstPage.click('[data-mnt-dest="guidance"]'); + await firstPage.waitForSelector('[data-mnt-plan-plc]'); + refreshStageGates.set('maintenance', Promise.resolve()); + const firstPost = firstPage.waitForResponse(response => new URL(response.url()).pathname === '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/api/refresh' + && response.request().method() === 'POST'); + await firstPage.click('#refresh-run'); + await firstPost; + const firstDone = await waitServerRefresh(false, null); + const firstIdentity = firstDone.operationId; + if (holdPeer) refreshStageGates.set('maintenance', holdPeerStage); + const peerPost = peerPage.waitForResponse(response => new URL(response.url()).pathname === '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/api/refresh' + && response.request().method() === 'POST'); + await peerPage.click('#refresh-run'); + await peerPost; + const newer = await waitServerRefresh(holdPeer, firstIdentity); + const firstStatus = firstPage.waitForResponse(response => new URL(response.url()).pathname === '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/api/refresh' + && response.request().method() === 'GET'); + releaseFirstPoll(); + await firstStatus; + await firstPage.waitForTimeout(100); + if (holdPeer) { + check('superseding running refresh keeps the first page and real Apply blocked', + !!newer.operationId && newer.operationId !== firstDone.operationId + && await firstPage.locator('#refresh-run').isDisabled() + && await firstPage.locator('[data-mnt-plan-plc]').first().isDisabled() + && /another refresh|supersed/i.test(await firstPage.locator('#refresh-status').innerText())); + releasePeerStage(); + await waitServerRefresh(false, firstIdentity); + await firstPage.waitForFunction(() => !document.getElementById('refresh-run')?.disabled, + null, { timeout: 8000 }).catch(() => {}); + } + check(`first page recovers from newer ${holdPeer ? 'running' : 'completed'} refresh without claiming its outcome`, + await firstPage.locator('#refresh-run').isEnabled() + && await firstPage.locator('[data-mnt-plan-plc]').first().isEnabled() + && /outcome unavailable|supersed/i.test(await firstPage.locator('#refresh-status').innerText()) + && !/Refresh complete\.|Refresh did not complete\./.test(await firstPage.locator('#refresh-status').innerText())); + } finally { + releaseFirstPoll(); + releasePeerStage(); + await Promise.all([firstPage.close(), peerPage.close()]); + } + } + await exerciseSupersession(false); + await exerciseSupersession(true); + + // A page that loses the single-flight POST race must respect the running + // operation reported in the 409 response before accepting Maintenance writes. + { + const ownerPage = await browser.newPage(); + const losingPage = await browser.newPage(); + let releaseOwner; + const ownerStage = new Promise(resolve => { releaseOwner = resolve; }); + try { + await losingPage.route(maintenanceV2Route, maintenanceV2Stub); + await Promise.all([ + ownerPage.goto(srv.urlWithToken, { waitUntil: 'domcontentloaded' }), + losingPage.goto(srv.urlWithToken, { waitUntil: 'domcontentloaded' }), + ]); + await losingPage.click('#tab-system'); + await losingPage.click('[data-system-view="maintenance"]'); + await losingPage.click('[data-mnt-dest="guidance"]'); + await losingPage.waitForSelector('[data-mnt-plan-plc]'); + refreshStageGates.set('maintenance', ownerStage); + await ownerPage.click('#refresh-run'); + await waitServerRefresh(true, null); + const conflict = losingPage.waitForResponse(response => new URL(response.url()).pathname === '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/api/refresh' + && response.request().method() === 'POST' && response.status() === 409); + await losingPage.click('#refresh-run'); + await conflict; + check('409 refresh conflict blocks the losing page and real Apply while the winner runs', + await losingPage.locator('#refresh-run').isDisabled() + && await losingPage.locator('[data-mnt-plan-plc]').first().isDisabled()); + releaseOwner(); + await losingPage.waitForFunction(() => !document.getElementById('refresh-run')?.disabled, + null, { timeout: 8000 }).catch(() => {}); + check('409 losing page restores Apply with an outcome-unavailable message after the winner ends', + await losingPage.locator('[data-mnt-plan-plc]').first().isEnabled() + && /outcome unavailable/i.test(await losingPage.locator('#refresh-status').innerText())); + } finally { + releaseOwner(); + await Promise.all([ownerPage.close(), losingPage.close()]); + } + } + + // An unconfirmed POST cannot adopt an older terminal operation as proof + // that its own request ended. The page must retain the write guard. + { + const uncertainPage = await browser.newPage(); + try { + await uncertainPage.route(maintenanceV2Route, maintenanceV2Stub); + await uncertainPage.goto(srv.urlWithToken, { waitUntil: 'domcontentloaded' }); + await uncertainPage.click('#tab-system'); + await uncertainPage.click('[data-system-view="maintenance"]'); + await uncertainPage.click('[data-mnt-dest="guidance"]'); + await uncertainPage.waitForSelector('[data-mnt-plan-plc]'); + await uncertainPage.route('**/api/refresh', route => route.request().method() === 'POST' + ? route.abort() : route.continue()); + const staleRead = uncertainPage.waitForResponse(response => new URL(response.url()).pathname === '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/api/refresh' + && response.request().method() === 'GET'); + await uncertainPage.click('#refresh-run'); + await staleRead; + check('uncertain POST does not adopt an older completed operation or release real Apply', + await uncertainPage.locator('#refresh-run').isDisabled() + && await uncertainPage.locator('[data-mnt-plan-plc]').first().isDisabled() + && /retry|unavailable/i.test(await uncertainPage.locator('#refresh-status').innerText())); + } finally { + await uncertainPage.close(); + } + } + // ── nothing errored anywhere along the way ── // A 404 from /api/session/ is CORRECT behaviour for a session that does // not exist — the route was changed to stop answering 200-with-a-null-body. diff --git a/tests/ui/host-readiness.mjs b/tests/ui/host-readiness.mjs index d8d8f491..247a47c9 100644 --- a/tests/ui/host-readiness.mjs +++ b/tests/ui/host-readiness.mjs @@ -19,7 +19,12 @@ const report = () => ({ checkedAt: '2026-09-20T10:00:00Z', scope: 'Dashboard lau host, status: 'ok', level: 'local', checks, evidenceKey: 'a'.repeat(64), canCheckConnection: true, checkedAt: '2026-09-20T10:00:00Z', connection: { state: 'not-run' }, target: { nativeDefault: true }, }])) }); - +const luminance = color => { + const rgb = color.match(/[\d.]+/g).slice(0,3).map(Number).map(value => value/255) + .map(value => value <= .04045 ? value/12.92 : ((value+.055)/1.055)**2.4); + return rgb[0]*.2126 + rgb[1]*.7152 + rgb[2]*.0722; +}; +const contrast = (a,b) => { const values=[luminance(a),luminance(b)].sort((x,y)=>y-x);return (values[0]+.05)/(values[1]+.05); }; test('all hosts have qualified OK, accessible details and explicitly confirmed connection checks', async t => { const browser = await launchChrome(); t.after(() => browser.close()); @@ -42,7 +47,7 @@ test('all hosts have qualified OK, accessible details and explicitly confirmed c return route.fulfill({contentType:'text/html',body:renderPage({name:'Health fixture',version:'test'}).replace(/]*>[\s\S]*?<\/script>/gi,'')}); }); await page.goto('http://health.test/'); - await page.addScriptTag({content:`${esc.toString()}\nfunction authHeaders(){return {'x-dash-token':'fixture'};}\n${source('usage')}\n${source('host-readiness')}\nwireHostHealth();`}); + await page.addScriptTag({content:`${esc.toString()}\nfunction authHeaders(){return {'x-dash-token':'fixture'};}\n${source('usage')}\nfunction refreshRunning(){return false;}\nfunction startRefresh(strength){(window.__refreshCalls ||= []).push(strength);return Promise.resolve(true); }\n${source('host-readiness')}\nwireHostHealth();`}); assert.equal(await page.locator('[data-health-host="codex"] .sp-status').innerText(),'Checking'); await page.evaluate(data=>globalThis.renderHostReadiness(data),report()); for(const [host,name] of [['claude','Claude Code'],['codex','Codex'],['opencode','OpenCode']]){ @@ -83,6 +88,20 @@ test('all hosts have qualified OK, accessible details and explicitly confirmed c await page.locator('.usage-source-details summary').click(); assert.match(await page.locator('.source-diagnostics').innerText(),/parse-yield-partial/); assert.equal(await page.locator('[data-health-host="codex"] .sp-status').innerText(),'OK'); + const unassessed=report(); + unassessed.hosts.claude={...unassessed.hosts.claude,checks:{...checks,configuration:{state:'unknown',reason:'Configuration was not assessed.'}}}; + await page.evaluate(data=>globalThis.renderHostReadiness(data),unassessed); + assert.equal(await page.locator('[data-health-host="claude"] .sp-status').innerText(),'Unknown'); + const iconColors=await page.evaluate(() => ['light','dark'].map(theme=>{ + globalThis.document.documentElement.setAttribute('data-theme',theme); + const chip=globalThis.document.querySelector('[data-health-host="codex"] .live-host'); + return {theme,fill:globalThis.getComputedStyle(chip.querySelector('path')).fill,background:globalThis.getComputedStyle(chip).backgroundColor}; + })); + for(const row of iconColors) { + console.log(`Codex icon ${row.theme}: ${row.fill} on ${row.background}, ${contrast(row.fill,row.background).toFixed(2)}:1`); + assert.ok(contrast(row.fill,row.background)>=3, + `Codex icon ${row.theme}: ${row.fill} on ${row.background}, ratio ${contrast(row.fill,row.background).toFixed(2)}:1`); + } await page.evaluate(()=>globalThis.renderHostReadiness(null)); assert.equal(await page.locator('[data-health-host="codex"] .sp-status').innerText(),'Unknown'); assert.equal(errors.length,0,errors.join('\n')); @@ -121,7 +140,7 @@ test('unmanaged hosts read their management state everywhere, with information-o return route.fulfill({contentType:'text/html',body:renderPage({name:'Health fixture',version:'test'}).replace(/]*>[\s\S]*?<\/script>/gi,'')}); }); await page.goto('http://health.test/'); - await page.addScriptTag({content:`${esc.toString()}\nfunction authHeaders(){return {'x-dash-token':'fixture'};}\n${source('usage')}\n${source('host-readiness')}\nwireHostHealth();`}); + await page.addScriptTag({content:`${esc.toString()}\nfunction authHeaders(){return {'x-dash-token':'fixture'};}\n${source('usage')}\nfunction refreshRunning(){return false;}\nfunction startRefresh(strength){(window.__refreshCalls ||= []).push(strength);return Promise.resolve(true); }\n${source('host-readiness')}\nwireHostHealth();`}); await page.evaluate(data=>globalThis.renderHostReadiness(data),unmanagedReport()); // Header pills: health for the managed host, the management words otherwise, never amber. @@ -144,10 +163,10 @@ test('unmanaged hosts read their management state everywhere, with information-o assert.match(await page.locator('#host-health-participation').innerText(),/not participating/i); assert.equal(await page.locator('#host-health-participation code').innerText(),'ak host pick --host claude,codex'); assert.equal(await page.locator('#host-health-participation [data-copy]').getAttribute('data-copy'),'ak host pick --host claude,codex'); - assert.equal(await page.locator('#host-health-refresh').innerText(),'Check again'); - await page.locator('#host-health-refresh').click(); - await page.waitForFunction(()=>globalThis.document.getElementById('host-health-message').textContent==='Check completed.'); - assert.deepEqual(requests,['/api/host-health/local']); + assert.equal(await page.locator('#host-health-run-refresh').innerText(),'Refresh'); + await page.locator('#host-health-run-refresh').click(); + assert.deepEqual(await page.evaluate(() => globalThis.__refreshCalls), ['local']); + assert.deepEqual(requests, []); await page.keyboard.press('Escape'); // Participation view (Overview → Hosts & Routing): one row per host, the hint copyable text. @@ -200,7 +219,7 @@ test('a dialog close that lands after the user moved on does not steal focus bac await page.route('http://health.test/**', route => route.fulfill({ contentType: 'text/html', body: renderPage({ name: 'Health fixture', version: 'test' }).replace(/]*>[\s\S]*?<\/script>/gi, '') })); await page.goto('http://health.test/'); - await page.addScriptTag({ content: `${esc.toString()}\nfunction authHeaders(){return {};}\n${source('usage')}\n${source('host-readiness')}\nwireHostHealth();` }); + await page.addScriptTag({ content: `${esc.toString()}\nfunction authHeaders(){return {};}\n${source('usage')}\nfunction refreshRunning(){return false;}\nfunction startRefresh(strength){(window.__refreshCalls ||= []).push(strength);return Promise.resolve(true); }\nfunction refreshRunning(){return false;}\nfunction startRefresh(strength){(window.__refreshCalls ||= []).push(strength);return Promise.resolve(true); }\n${source('host-readiness')}\nwireHostHealth();` }); await page.evaluate(data => globalThis.renderHostReadiness(data), report()); await page.locator('[data-health-host="claude"]').click(); // The race, made deterministic: close the dialog and move focus in the SAME diff --git a/tests/ui/maintenance-host-alignment.mjs b/tests/ui/maintenance-host-alignment.mjs index e64d7002..9f39385a 100644 --- a/tests/ui/maintenance-host-alignment.mjs +++ b/tests/ui/maintenance-host-alignment.mjs @@ -50,6 +50,7 @@ test('Host alignment view filters User and Project rows and offers exact registr function authHeaders(){return {};} function esc(value){return String(value).replace(/[&<>"']/g,function(c){return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];});} function ago(){return '';} + function refreshRunning(){return false;} function beginMaintPreview(button, request){window.selectedPreview=request;} ${['maintenance-workspace','maintenance-operation','maintenance-cards','maintenance-filters','maintenance-guidance','maintenance-relationships','maintenance-inspector','maintenance-language-logos','maintenance-focus','maintenance-inventory'].map(clientSource).join('\n')} MNT.scope='user';MNT.view='host-alignment';wireMntInventory();wireMntInspector();wireMntGuidance();loadMntInventory();