diff --git a/docs/DASHBOARD.md b/docs/DASHBOARD.md index 9cf1a70..7f934f5 100644 --- a/docs/DASHBOARD.md +++ b/docs/DASHBOARD.md @@ -852,3 +852,42 @@ Maintenance version measurements and update checks, model captures and local mod Relative ages remain relative. Published calendar dates (such as a retirement commitment) retain their calendar day; they are not midnight UTC instants. Stored/API timestamps and machine-readable `datetime` attributes retain their original instant. + +## Host health badges + +Claude, Codex and OpenCode use the same statuses: **OK**, **Attention**, +**Checking**, **Unknown**, and **Disabled**. Click a badge for the qualification, +check time, project and individual results. Keyboard users can focus the badge +and press Enter; Escape closes the details and restores focus. + +**Local OK** means the required local checks passed: executable launch, +supported configuration and provider/model selection, applicable authentication +setup, and known blocking integration configuration. These checks run in the +dashboard launch directory, independently of the Intelligence project picker. +Credentials are checked for setup, not remotely validated. Optional MCP tool +connections, runtime plugins and model access are not implied by Local OK. +Native defaults are valid selections; an explicit model setting is not required. +Unsupported or ambiguous evidence is Unknown, rather than an alarm. + +OpenCode has real local checks for its JSON/JSONC configuration layers, selected +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 local setup** refreshes the local evidence. **Check connection** 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 +is requested. No inference runs during automatic polling. + +A connection result is qualified as **Connected**, scoped to provider inference. +It must contain a completed response to a fresh challenge; successful process +exit alone is insufficient. MCP tool connectivity is separately marked untested. +Only one connected check runs at a time. Results expire after 15 minutes, +invalidate when observed settings change, and are not persisted across dashboard +restarts. The local cache lasts at most one minute; a stale or failed observation +cannot silently provide a new positive result. + +**Usage → Usage data sources** retains transcript/database reading diagnostics. +Partial historical records do not lower host health. That scan includes the +selected period plus 90 days for comparisons. diff --git a/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md b/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md index 5356072..04ff49e 100644 --- a/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md +++ b/docs/adr/0023-fail-closed-operations-and-explicit-degradation.md @@ -1,7 +1,8 @@ # ADR-0023 — Fail-closed mutations and explicit degraded operation evidence - **Status:** Implemented -- **Updated:** 2026-08-26 — ADR-0035 applies fail-closed preflight, bounded evidence, and +- **Updated:** 2026-09-20 — ADR-0053 separates qualified local/connected host health from usage-source diagnostics +- **Earlier update:** 2026-08-26 — ADR-0035 applies fail-closed preflight, bounded evidence, and content-free degradation to the opt-in deja-vu companion - **Earlier update:** 2026-09-03 — ADR-0044 implements these fail-closed principles in the Maintenance coordinator while explicitly refusing to claim filesystem atomicity for native lifecycle, @@ -140,6 +141,10 @@ purge is planned and confirmed separately from wiring or package removal. ### 7. Usage source degradation is visible in the dashboard, for all four local sources +> **2026-09-20 amendment:** [ADR-0053](0053-host-setup-evidence-and-usage-diagnostics.md) +> supersedes the persistent-tabbar placement described below. Usage-source +> diagnostics remain available in Usage; persistent badges now assess scoped host health. + The Usage API's `sourceHealth` field is rendered as persistent local-source pills in the dashboard's sticky tabbar (right-aligned, visible on every view once Usage data has loaded once — not confined to the Usage panel). `ok`, `absent`, `degraded`, and `not-read` remain distinct, and diff --git a/docs/adr/0051-supported-peer-delegation-and-host-realignment.md b/docs/adr/0051-supported-peer-delegation-and-host-realignment.md index 86b20ea..5594db7 100644 --- a/docs/adr/0051-supported-peer-delegation-and-host-realignment.md +++ b/docs/adr/0051-supported-peer-delegation-and-host-realignment.md @@ -1,6 +1,7 @@ # ADR-0051 — Supported peer delegation and host realignment -- **Status:** Accepted; implemented locally, release not published +- **Status:** Implemented; published in `4.0.0-alpha.48` +- **Updated:** 2026-09-20 — reconciled publication status against the GitHub release and npm artifact - **Date:** 2026-09-10 - **Deciders:** Project maintainer, through the current design discussion - **Amends:** [ADR-0033](0033-retire-codex-mcp-and-bound-qe-court-participants.md) @@ -155,3 +156,16 @@ Regression evidence covers scope filtering, path-free projection, stale action rejection, selected-only removal, and the real transaction coordinator. Browser verification exercises the actual markup, filtering client and preview selection against deterministic evidence fixtures. + +### Publication evidence — 2026-09-20 + +PR [#217](https://github.com/pacphi/agentic-kit/pull/217), commit `8caa25f`, +shipped in [v4.0.0-alpha.48](https://github.com/pacphi/agentic-kit/releases/tag/v4.0.0-alpha.48). +GitHub published the prerelease on 2026-09-10 at 18:51:42 UTC; npm published +`@pacphi/agentic-kit@4.0.0-alpha.48` at 18:51:33 UTC. This is an alpha +prerelease publication, not a stable-release designation. + +The registry tarball's SHA-512 matched its published integrity value. Eight +implementation files matched the release tag byte-for-byte: the alignment +engine and CLI, run guard, setup, sync, status section, and Maintenance provider +and management projection. The previous “release not published” header was stale. diff --git a/docs/adr/0053-host-setup-evidence-and-usage-diagnostics.md b/docs/adr/0053-host-setup-evidence-and-usage-diagnostics.md new file mode 100644 index 0000000..8882c22 --- /dev/null +++ b/docs/adr/0053-host-setup-evidence-and-usage-diagnostics.md @@ -0,0 +1,146 @@ +# ADR-0053 — Qualified host health and separate usage diagnostics + +- **Status:** Implemented; not yet released +- **Date:** 2026-09-20 +- **Updated:** 2026-09-20 — replace setup-only badges with consistent local health and explicit provider connection checks for Claude, Codex and OpenCode +- **Amends:** [ADR-0023](0023-fail-closed-operations-and-explicit-degradation.md) +- **Related:** [ADR-0041](0041-host-neutral-hook-configuration-assurance.md), [ADR-0051](0051-supported-peer-delegation-and-host-realignment.md) + +## Context + +Usage acquisition once drove persistent branded host badges. One historical +Codex rollout could label the entire host degraded. The initial revision of this +ADR separated setup evidence, but left OpenCode unassessed and offered no actual +connection check. The user requested a qualified health check consistently for +all three hosts. This decision supersedes that setup-only contract. + +## Decision + +The persistent badge answers whether required checks passed at a stated level, +in the dashboard launch directory. Clicking it opens a keyboard-accessible dialog +with the level, project, timestamp, individual evidence and connection controls. +Usage date ranges and Intelligence project selection do not change this scope. + +| Status | Meaning | +| --- | --- | +| OK | Required checks at the displayed level passed | +| Attention | A check established a concrete actionable failure | +| Checking | A requested check is running | +| Unknown | Required evidence is unsupported, ambiguous, inaccessible or timed out | +| Disabled | Host is intentionally outside the enabled kit setup | + +### Local health + +Automatic local checks cover executable launch, supported configuration inputs, +provider/model selection, applicable authentication setup, and known blocking +transport configuration. Native defaults are valid; optional files and tools are +not mandatory. Credentials are locally configured evidence, never proof of remote +validity, quota, model access or provider uptime. Tool execution and plugin runtime +behavior remain outside this local claim. + +Native capabilities and recognizable result schemas gate probes, rather than +exact version equality. Unsupported contracts remain Unknown. Claude uses doctor +and structured authentication results plus local selection precedence. Codex uses +native configuration loading through MCP listing and login status, with supported +system/user selection projection; unresolved trust/profile overrides remain Unknown. +Its broader doctor invokes network/runtime checks and is not used automatically. + +OpenCode reads bounded local JSON/JSONC layers, environment substitutions, provider +filters, selected default-agent/model fields and applicable credentials. Known +invalid nested configuration is actionable; unresolved remote organization config, +file references, selected agent Markdown or ambiguous native-default selection is +Unknown. Native debug/config startup is not called automatically: its upstream +implementation can install dependencies, update files and fetch remote config. + +Each subprocess is bounded and emits only allowlisted states, reasons, versions +and model/provider selectors. Raw credentials and native diagnostic output never +enter the API. Local results have a 60-second single-flight cache. Observed file, +environment, executable and kit configuration changes invalidate cached evidence; +opaque keys use a per-server secret, not a public hash of credentials. Claude usage +bookkeeping does not invalidate otherwise unchanged integration configuration. + +### Explicit connected checks + +The dialog requires affirmative confirmation before sending a bounded inference +request. It discloses normal provider billing/context usage and native startup's +possible dependency, cache and session initialization. This is a user-triggered +native operation; polling never triggers inference or automatic repair. + +The connection adapter has one absolute native-execution budget of at most 60 +seconds, capped output, no retry, cancellation and process-tree cleanup. Local +revalidation occurs before and after the native check. A fresh nonce challenge +and a recognized successful completion are required; exit zero alone cannot pass. + +Claude uses supported safe mode with tools/hooks/MCP disabled. Codex uses a +read-only sandbox, denies approvals, disables supported tool/plugin features and +verifies that the effective MCP roster is disabled. OpenCode uses pure mode and +a dedicated deny-all agent with discovered MCP integrations disabled. Unsupported +isolation capabilities produce Unknown before inference. The intended explicit +model selection is preserved, including OpenCode's provider/model selector. + +Connected evidence is specifically **provider inference**. Optional MCP server +handshakes are not claimed; their untested state is shown separately. Local checks +must still pass for a Connected OK. This scope prevents provider success from +being presented as proof that every installed integration works. + +The server issues a source-bound confirmation token. A check requires that exact +current token, consumes it, refuses concurrent requests, and rejects changed +inputs before attaching a result. Connected evidence expires after 15 minutes, +invalidates on observed input changes, and exists only for this server session. +Changes during a check discard its result. Closing the dashboard or disconnecting +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 +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 +require explicit confirmation and a fresh observation token. Native errors are +sanitized before HTTP responses. + +Usage keeps its original four `sourceHealth` fields and full diagnostics under +Usage data sources. The historical scan's extra 90 days are disclosed. Those +observations never drive the host health badges. + +## Grounding + +- [Claude CLI and safe mode](https://code.claude.com/docs/en/cli-reference), [installation diagnostics](https://code.claude.com/docs/en/setup), and [model configuration](https://code.claude.com/docs/en/model-config). +- [Codex native commands](https://learn.chatgpt.com/docs/developer-commands?surface=cli) and [configuration precedence](https://learn.chatgpt.com/docs/config-file/config-basic). +- [OpenCode CLI](https://opencode.ai/docs/cli/), [JSON/JSONC configuration](https://opencode.ai/docs/config/), and [permissions](https://opencode.ai/docs/permissions/). +- Installed native help and bounded read-only preflight inspected on 2026-09-20. OpenCode v1.18.31 source explains configuration initialization and stdin/structured completion behavior. + +## Validation + +Tests cover native schema/capability changes, all three local adapters, defaults +and precedence, invalid nested configuration, credential isolation, source +invalidation, expiration, explicit consent, replay/concurrency rejection, origin +and token enforcement, and native challenge completion/cancellation. Browser +verification exercises all three hosts, keyboard navigation, consent, pending +state, desktop/mobile layouts and separation from usage diagnostics. + +No paid live inference is part of the test suite. Connected paths use deterministic +native-boundary fixtures; real read-only preflight checks stop before inference. + +### Implementation evidence — 2026-09-20 + +The final focused suites passed 77 tests, including real subprocess stdin and +process-tree timeout cleanup against a local fixture executable. No model +request was made by that fixture. The full browser suite passed 491 assertions +plus 9 tests; the final health dialog also passed independently on desktop and +mobile. Legacy suites, typecheck, lint, complexity checks, Markdown lint and +build passed (lint retains repository warnings). + +The broader unit run passed 4,159 tests with 6 existing skips and one failure: +the existing stock OpenCode fixture timed out installing its npm SDK dependency +before any provider request. It measured 92.05% line, 81.28% branch and 91.55% +function coverage. That broad run preceded the last effective-isolation and +HTTP error-classification regressions, which passed in the final focused suite. +The full repository test command is therefore not claimed green. + +A live local collection reported Local OK for Claude, Codex and OpenCode, with +connection state not-run for each. Claude/Codex real native preflight reached +intercepted inference; OpenCode native help was checked while config startup was +mocked to avoid initialization. No paid inference, commit, push or release was +performed. ADR-0051's alpha.48 publication correction remains in this branch. diff --git a/docs/adr/README.md b/docs/adr/README.md index b29dbd1..fef5e99 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -368,3 +368,5 @@ replayed parent history is excluded (with a boundary that survives the host writ as the history start), Claude sessions Codex imports are excluded and counted, counter restarts are summed and each delta books on its own day and model, oversized rollouts are read by a bounded-memory streaming reader, and rollouts that still cannot be parsed are reported. Cache schema 23. + +- [ADR-0053 — Qualified host health and separate usage diagnostics](0053-host-setup-evidence-and-usage-diagnostics.md) — scoped local and connected health; usage acquisition remains separate. diff --git a/package.json b/package.json index 1cf4dcb..f7ec2fd 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ ], "scripts": { "test": "node --test --experimental-test-coverage --test-coverage-lines=70 --test-coverage-branches=70 --test-coverage-functions=70 \"tests/kit/*.test.mjs\" && node tests/statusline-segments.test.cjs && node tests/statusline-window-ledger.test.cjs && node tests/statusline-brain.test.cjs && node tests/agentdb.test.cjs && node tests/health-history.test.cjs && node tests/harvest.test.cjs && node tests/dashboard.test.cjs && node tests/admin-model.test.cjs && node tests/admin.test.cjs", - "test:ui": "node tests/ui/dashboard-ui.mjs && node --test tests/ui/dashboard-project-context.mjs tests/ui/maintenance-projects.mjs tests/ui/maintenance-host-alignment.mjs tests/ui/intelligence-picker.mjs tests/ui/usage-project-groups.mjs tests/ui/context-coverage.mjs", + "test:ui": "node tests/ui/dashboard-ui.mjs && node --test tests/ui/dashboard-project-context.mjs tests/ui/maintenance-projects.mjs tests/ui/maintenance-host-alignment.mjs tests/ui/intelligence-picker.mjs tests/ui/usage-project-groups.mjs tests/ui/context-coverage.mjs tests/ui/host-readiness.mjs", "test:surface": "node --test tests/kit/dispatch-surface.test.mjs", "test:aqe-external-provider-live": "node --test tests/live/aqe-external-provider-transport.test.mjs", "test:qe-court-live": "node --test tests/live/qe-court-participant-transport.test.mjs", diff --git a/src/lib/dashboard-server.mjs b/src/lib/dashboard-server.mjs index e62f6a0..9a9816a 100644 --- a/src/lib/dashboard-server.mjs +++ b/src/lib/dashboard-server.mjs @@ -1,3 +1,5 @@ +import { HOST_HEALTH_POST_ROUTES, handleHostHealthPost } from './dashboard/host-health-api.mjs'; +import { createHostReadinessReader } from './host-readiness.mjs'; // dashboard-server.mjs — a read-only, localhost-only web dashboard for the kit. // // Zero runtime deps: a plain node:http server bound to 127.0.0.1. Routes: @@ -273,7 +275,8 @@ function censusBackedDiscovery() { } /** Assemble the full /api/status payload. */ -async function collectData({ cwd, fetchStatus, projectParam, getProjectSnapshot }) { +async function collectData({ cwd, fetchStatus, projectParam, getProjectSnapshot, getHostReadiness }) { + const readiness = getHostReadiness().catch(() => null); let status; try { status = await fetchStatus(); } catch (e) { status = { overall: 'unknown', rows: [], error: String(e && e.message || e) }; } const rows = Array.isArray(status?.rows) ? status.rows : []; @@ -318,6 +321,7 @@ async function collectData({ cwd, fetchStatus, projectParam, getProjectSnapshot kit: { name: '@pacphi/agentic-kit', version: kitVersion() }, overall, error: status?.error ?? null, + hostReadiness: await readiness, rows, drift, // improvement — UNCHANGED contract: still the LAUNCHING project's own @@ -1038,7 +1042,7 @@ function lazyLive(liveOptions = {}) { * discoverProjects?: () => Array<{ path: string, label: string, source?: string }>, * machineWideIntel?: (projects: Array) => any, * models?: any, modelScopeKey?: string, system?: any, systemOptions?: any, - * maintenance?: any, maintenanceOptions?: any, + * maintenance?: any, maintenanceOptions?: any, hostReadiness?: any, * management?: any, managementOptions?: any }} [opts] * @returns {Promise<{ url: string, urlWithToken: string, port: number, token: string, close: () => Promise }>} */ @@ -1050,9 +1054,10 @@ export function startDashboard({ transcriptClientBuffer = 64, transcriptMaxClients = 16, intelWatch, intelClientBuffer = 256, intelMaxClients = 32, discoverProjects, machineWideIntel, models, modelScopeKey, system, systemOptions = {}, - maintenance, maintenanceOptions = {}, management, managementOptions = {}, + maintenance, maintenanceOptions = {}, management, managementOptions = {}, hostReadiness, } = {}) { const provide = fetchStatus || shellOutStatus(cwd); + const getHostReadiness = hostReadiness ?? (fetchStatus ? async () => null : createHostReadinessReader({ cwd })); const usageApi = usage || lazyUsage(); const getHooks = createHookDashboardReader({ hooks, cacheMs: hookCacheMs }); // Cache-only and lazy: model discovery is exclusively owned by @@ -1349,12 +1354,13 @@ export function startDashboard({ const qi = raw.indexOf('?'); const url = qi < 0 ? raw : raw.slice(0, qi); const query = new URLSearchParams(qi < 0 ? '' : raw.slice(qi + 1)); - // Maintenance has the only mutation allowlist (v1 compatibility routes plus + // Maintenance and explicit host connection checks have separate mutation allowlists (v1 compatibility routes plus // the exact ADR-0048 v2 POST paths). Every other route remains GET-only, // so adding a new read endpoint cannot accidentally create a write path. const maintenanceMutation = req.method === 'POST' && (MAINTENANCE_MUTATION_ROUTES.has(url) || MAINTENANCE_V2_MUTATION_ROUTES.has(url)); - if (req.method !== 'GET' && !maintenanceMutation) { + const healthMutation = req.method === 'POST' && HOST_HEALTH_POST_ROUTES.has(url); + if (req.method !== 'GET' && !maintenanceMutation && !healthMutation) { res.writeHead(405).end('method not allowed'); return; } @@ -1382,19 +1388,20 @@ 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 + const authorized = (maintenanceMutation || healthMutation) ? 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) { + if (maintenanceMutation || healthMutation) { 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; } try { await (await getMaintenanceApi()).mutate(url, req, res); } catch { sendJson(res, 503, { error: 'maintenance operation unavailable' }); } return; @@ -1404,7 +1411,7 @@ export function startDashboard({ let payload; try { payload = await collectData({ - cwd, fetchStatus: provide, projectParam: query.get('project'), getProjectSnapshot, + cwd, fetchStatus: provide, projectParam: query.get('project'), getProjectSnapshot, getHostReadiness, }); } catch (e) { payload = { @@ -2029,6 +2036,10 @@ export function startDashboard({ // out separately into sse.mjs's sseRoute(). const ROUTES = { '/api/status': handleStatus, + '/api/host-health': async (_req, res) => { + try { sendJson(res, 200, await getHostReadiness()); } + catch { sendJson(res, 503, { error: 'Host health checks unavailable.' }); } + }, '/api/live': handleLiveSnapshot, '/api/live/history': handleLiveHistory, '/api/live/events': handleLiveEvents, @@ -2067,6 +2078,7 @@ export function startDashboard({ return listenLoopback(server, { port, token, close: async () => { + getHostReadiness.close?.(); shuttingDown = true; cancelLiveIdle(); for (const cleanup of [...liveClients]) cleanup(true); diff --git a/src/lib/dashboard/client.mjs b/src/lib/dashboard/client.mjs index dda5bb1..68cdfd9 100644 --- a/src/lib/dashboard/client.mjs +++ b/src/lib/dashboard/client.mjs @@ -91,6 +91,7 @@ let aboutSrc = readSplit('about.mjs'); aboutSrc = inject(aboutSrc, 'var ABOUT = []; // PLACEHOLDER:ABOUT_JS', `var ABOUT=${ABOUT_JS};`); const datetimeSrc = readSplit('datetime.mjs'); +const hostReadinessSrc = readSplit('host-readiness.mjs'); const intelligenceSrc = readSplit('intelligence.mjs'); const pollSrc = readSplit('poll.mjs'); // usage-rhythm.mjs declares its OWN `esc` on disk, and its comment says why: @@ -158,5 +159,6 @@ const bootSrc = readSplit('boot.mjs'); // sequence) running in the same relative order it always has. export const JS = ` (function(){ -${bootstrapSrc}${contextCard.toString()}${contextHostCard.toString()}${repositoryTree.toString()}${overviewSrc}${datetimeSrc}${intelligenceSrc}${pollSrc}${usageRhythmSrc}${usagePromptsSrc}${usageContextHooksSrc}${usageSrc}${modelLifecycleSrc}${usageOrchestratorsSrc}${aboutSrc}${systemReadoutSrc}${systemProjectsSrc}${maintenanceWorkspaceSrc}${maintenanceFiltersSrc}${maintenanceCardsSrc}${maintenanceOperationSrc}${maintenanceLanguageLogosSrc}${maintenanceFocusSrc}${maintenanceInventorySrc}${maintenanceRelationshipsSrc}${maintenanceInspectorSrc}${maintenanceGuidanceSrc}${maintenanceDiscoverySrc}${maintenanceActivitySrc}${systemMaintenanceActionsSrc}${systemMaintenanceSrc}${bootSrc}})(); +${bootstrapSrc}${contextCard.toString()}${contextHostCard.toString()}${repositoryTree.toString()}${overviewSrc}${datetimeSrc}${hostReadinessSrc} +${intelligenceSrc}${pollSrc}${usageRhythmSrc}${usagePromptsSrc}${usageContextHooksSrc}${usageSrc}${modelLifecycleSrc}${usageOrchestratorsSrc}${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 f1ec275..a825cca 100644 --- a/src/lib/dashboard/client/boot.mjs +++ b/src/lib/dashboard/client/boot.mjs @@ -1,6 +1,7 @@ // @ts-nocheck — browser bundle source (never node-imported; client.mjs // reads it as text). See src/lib/dashboard/client/**'s eslint.config.mjs // override comment for why this directory isn't run through the node lib. +import { wireHostHealth } from './host-readiness.mjs'; import { renderAbout, wireAboutNudge } from './about.mjs'; import { activeTab, initialLiveScope, setSystemView, setTab, syncHash, systemView } from './bootstrap.mjs'; import { tickClock, wireIntelPicker } from './intelligence.mjs'; @@ -19,6 +20,7 @@ import { loadUsage, setUsageView } from './usage.mjs'; // "state unknown" until the first /api/status response supplies the join. renderAbout(null); renderSystemFreshness(); + wireHostHealth(); wirePoll(); wireUsage(); wireIntelPicker(); diff --git a/src/lib/dashboard/client/host-readiness.mjs b/src/lib/dashboard/client/host-readiness.mjs new file mode 100644 index 0000000..b8fddcf --- /dev/null +++ b/src/lib/dashboard/client/host-readiness.mjs @@ -0,0 +1,96 @@ +// @ts-nocheck — browser bundle source; assembled by ../client.mjs. +import { esc, authHeaders } from './bootstrap.mjs'; +import { sourceHostIcon } from './usage.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'}; +var HEALTH_LABELS={ok:'OK',attention:'Attention',unknown:'Unknown',disabled:'Disabled',checking:'Checking'}; +var HEALTH_CHECK_NAMES={installation:'Executable',configuration:'Configuration',model:'Provider / model selection',authentication:'Authentication setup',integration:'Integration configuration'}; + +function healthTime(value){var date=new Date(value);return Number.isFinite(date.getTime())?date.toLocaleString():'Not checked';} +function healthRow(){return HEALTH_REPORT&&HEALTH_REPORT.hosts&&HEALTH_REPORT.hosts[HEALTH_HOST];} +function healthState(state){return ({pass:'Passed',fail:'Needs attention',unknown:'Unknown','not-run':'Not run',running:'Checking',expired:'Expired',changed:'Settings changed','not-checked':'Not checked'})[state]||'Unknown';} + +export function renderHostReadiness(report,checking){ + var el=document.getElementById('host-readiness'); + if(!el)return; + if(report&&HEALTH_REPORT&&Date.parse(report.checkedAt)' + +''+sourceHostIcon(host)+'' + +''+HEALTH_LABELS[state]+''; + }).join(''); + renderHealthDialog(); +} + +function renderHealthDialog(){ + if(!HEALTH_HOST)return; + var row=healthRow(),dialog=document.getElementById('host-health-dialog'); + if(!dialog)return; + document.getElementById('host-health-title').textContent=HEALTH_NAMES[HEALTH_HOST]+' health'; + var level=row&&row.level==='connected'?'Connected':'Local'; + document.getElementById('host-health-summary').textContent=HEALTH_BUSY&&HEALTH_BUSY_HOST===HEALTH_HOST?'Checking…': + (row?HEALTH_LABELS[row.status]:'Unknown')+' · '+level+' checks · '+healthTime(row&&row.level==='connected'&&row.connection&&row.connection.checkedAt||row&&row.checkedAt); + document.getElementById('host-health-project').textContent='Project: '+(HEALTH_REPORT&&HEALTH_REPORT.project||'dashboard launch directory'); + var checks=row&&row.checks||{}; + document.getElementById('host-health-checks').innerHTML=Object.keys(HEALTH_CHECK_NAMES).map(function(key){ + var check=checks[key]||{state:'unknown',reason:row&&row.status==='disabled'?'Host is disabled.':'No current evidence.'}; + return '
  • '+HEALTH_CHECK_NAMES[key]+' '+healthState(check.state)+'' + +''+esc(check.reason)+(check.version?' · '+esc(check.version):'')+'
  • '; + }).join(''); + var target=row&&row.target; + document.getElementById('host-health-target').textContent='Selection: '+(target?[target.provider,target.model].filter(Boolean).join(' / ')||'native host default':'native host default or unassessed'); + var connection=row&&row.connection||{state:'not-run'}; + document.getElementById('host-health-connection').textContent=healthState(connection.state)+(connection.checkedAt?' · '+healthTime(connection.checkedAt):'')+(connection.reason?' — '+connection.reason:''); + document.getElementById('host-health-integrations').textContent='Connected check scope: provider inference. Optional MCP tool connections are not tested.'; + document.getElementById('host-health-eligibility').textContent=row&&row.connectionUnavailable||''; + var consent=document.getElementById('host-health-consent'); + 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; +} + +async function runHealthCheck(connected){ + var row=healthRow(); + if(HEALTH_BUSY||!HEALTH_HOST||connected&&(!row||!row.canCheckConnection||HEALTH_ACK!==row.evidenceKey))return; + var body={host:HEALTH_HOST}; + if(connected){body.confirm=true;body.evidenceKey=row.evidenceKey;} + HEALTH_BUSY=true;HEALTH_BUSY_HOST=HEALTH_HOST; + document.getElementById('host-health-message').textContent=connected?'Checking connection and revalidating local setup…':'Checking local setup…'; + renderHostReadiness(HEALTH_REPORT); + try{ + var response=await fetch('/api/host-health/'+(connected?'connection':'local'),{method:'POST',headers:Object.assign({'content-type':'application/json'},authHeaders()),body:JSON.stringify(body)}); + var data=await response.json(); + if(!response.ok)throw new Error(data.error||'Health check unavailable.'); + HEALTH_BUSY=false;HEALTH_BUSY_HOST=null;HEALTH_ACK=null; + renderHostReadiness(data); + document.getElementById('host-health-message').textContent='Check completed.'; + }catch(error){ + HEALTH_BUSY=false;HEALTH_BUSY_HOST=null;HEALTH_ACK=null; + renderHostReadiness(null); + document.getElementById('host-health-message').textContent=error.message||'Health check unavailable.'; + } +} + +export function wireHostHealth(){ + var region=document.getElementById('host-readiness'),dialog=document.getElementById('host-health-dialog'); + if(!region||!dialog)return; + region.addEventListener('click',function(event){ + var button=event.target.closest('[data-health-host]');if(!button)return; + HEALTH_HOST=button.getAttribute('data-health-host');HEALTH_ACK=null; + document.getElementById('host-health-message').textContent='';renderHealthDialog();dialog.showModal(); + }); + document.getElementById('host-health-close').addEventListener('click',function(){dialog.close();}); + dialog.addEventListener('close',function(){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-connect').addEventListener('click',function(){runHealthCheck(true);}); + renderHostReadiness(null,true); +} diff --git a/src/lib/dashboard/client/intelligence.mjs b/src/lib/dashboard/client/intelligence.mjs index 518c39c..be2ef3d 100644 --- a/src/lib/dashboard/client/intelligence.mjs +++ b/src/lib/dashboard/client/intelligence.mjs @@ -1,6 +1,7 @@ // @ts-nocheck — browser bundle source (never node-imported; client.mjs // reads it as text). See src/lib/dashboard/client/**'s eslint.config.mjs // override comment for why this directory isn't run through the node lib. +import { renderHostReadiness } from './host-readiness.mjs'; import { renderAbout } from './about.mjs'; import { DASH_TOKEN, activeTab, esc, overviewView, positionThumb } from './bootstrap.mjs'; import { renderModelSummary } from './model-lifecycle.mjs'; @@ -294,6 +295,7 @@ import { fmtNum, kpi } from './usage.mjs'; export function render(data){ if(!data)return; LAST=data; + renderHostReadiness(data.hostReadiness); renderVerdict(data.overall); renderNotice(data.drift); renderAbout(data); diff --git a/src/lib/dashboard/client/poll.mjs b/src/lib/dashboard/client/poll.mjs index 540865a..b4beb27 100644 --- a/src/lib/dashboard/client/poll.mjs +++ b/src/lib/dashboard/client/poll.mjs @@ -1,6 +1,7 @@ // @ts-nocheck — browser bundle source (never node-imported; client.mjs // reads it as text). See src/lib/dashboard/client/**'s eslint.config.mjs // override comment for why this directory isn't run through the node lib. +import { renderHostReadiness } from './host-readiness.mjs'; import { renderAbout } from './about.mjs'; import { DASH_TOKEN_KEY, activeTab, authHeaders, hideGate, showGate, systemView } from './bootstrap.mjs'; import { render, tickClock } from './intelligence.mjs'; @@ -117,6 +118,8 @@ import { loadModelLifecycle, loadUsage } from './usage.mjs'; if(seq===intelRequestSeq)render(d); tickClock(); }).catch(function(){ + if(seq!==intelRequestSeq)return; + renderHostReadiness(null); var t=document.getElementById("verdict-text"); if(t)t.textContent="server unreachable"; // About is editorial content plus a runtime join. Losing the join must // cost the chips, never the page: every card still renders, each one diff --git a/src/lib/dashboard/client/usage.mjs b/src/lib/dashboard/client/usage.mjs index e3a2bff..ee4f7d3 100644 --- a/src/lib/dashboard/client/usage.mjs +++ b/src/lib/dashboard/client/usage.mjs @@ -89,7 +89,7 @@ import { renderUsage } from './usage-orchestrators.mjs'; var el=document.getElementById("u-source-health"); if(!el)return; health=health||{}; - var pills=[]; + var pills=[],details=[]; for(var g=0; g'+esc(detail)+''); pills.push('' +''+sourceHostIcon(grp.host)+'' - +''+esc(lead.status)+'' + +''+esc(({ok:'Readable',degraded:'Partial data',absent:'No records','not-read':'Not checked'})[lead.status]||'Not checked')+'' +''); } el.hidden=pills.length===0; - el.innerHTML=pills.join(""); + el.innerHTML=pills.join("")+'
      '+details.join("")+'
    '; } export function loadUsage(force){ diff --git a/src/lib/dashboard/host-health-api.mjs b/src/lib/dashboard/host-health-api.mjs new file mode 100644 index 0000000..5118e8b --- /dev/null +++ b/src/lib/dashboard/host-health-api.mjs @@ -0,0 +1,42 @@ +import { sendJson } from '../loopback-server.mjs'; +import { readMaintenanceJson } from './maintenance-security.mjs'; + +export const HOST_HEALTH_POST_ROUTES = new Set(['/api/host-health/local', '/api/host-health/connection']); +const HOSTS = ['claude', 'codex', 'opencode']; + +/** The server enforces token + same-origin before entering this handler. + * Only a fixed host and server-issued observation token are accepted; callers + * cannot supply a command, prompt, model, project path, timeout, or environment. */ +export async function handleHostHealthPost(url, req, res, read) { + try { + const body = await readMaintenanceJson(req, { maxBytes: 4096 }); + if (!body || Array.isArray(body) || typeof body !== 'object' + || Object.keys(body).some(key => !['host', 'confirm', 'evidenceKey'].includes(key)) + || !HOSTS.includes(body.host)) { + sendJson(res, 400, { error: 'Invalid host health request.' }); return; + } + if (url === '/api/host-health/local') { + sendJson(res, 200, await read({ force: true })); return; + } + if (body.confirm !== true || typeof body.evidenceKey !== 'string' || !/^[a-f0-9]{64}$/.test(body.evidenceKey)) { + sendJson(res, 400, { error: 'Confirm the connection check using fresh health evidence.' }); return; + } + if (typeof read.checkConnection !== 'function') { + sendJson(res, 503, { error: 'Connection checks unavailable.' }); return; + } + const controller = new AbortController(); + const abort = () => { if (!res.writableEnded) controller.abort(); }; + res.on('close', abort); + try { + const report = await read.checkConnection({ ...body, signal: controller.signal }); + sendJson(res, 200, report); + } finally { res.off('close', abort); } + } catch (error) { + const code = error.status ?? error.statusCode; + const status = [400, 409, 413, 415].includes(code) ? code : 503; + const message = status === 409 ? 'Health evidence changed or a check is already running. Refresh and try again.' + : status === 415 ? 'Health requests must use application/json.' + : status === 413 ? 'Health request is too large.' : status === 400 ? 'Invalid health request.' : 'Health check unavailable.'; + sendJson(res, status, { error: message }); + } +} diff --git a/src/lib/dashboard/page.mjs b/src/lib/dashboard/page.mjs index 8ed531c..e9d0e1f 100644 --- a/src/lib/dashboard/page.mjs +++ b/src/lib/dashboard/page.mjs @@ -142,9 +142,25 @@ export function renderPage({ name, version }) { - + + +

    Host health

    +

    +

    +
      +

      +

      Connection check

      +

      +

      +

      +

      Sends one short request using your selected host and provider. Normal provider billing and native context usage apply. Native startup may initialize dependencies and update local cache or session files. Agent tools are restricted; this check does not repair your setup.

      + +
      +

      +
      +