From 35507f9e9ef3deb1148498cbd5dd44f0915ab4c6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 24 Aug 2026 01:31:35 +0000 Subject: [PATCH 1/3] fix(runtime-core): commit the missing background CPU module perf(observation) landed an import of tracedecay_runtime_core::background_cpu, but neither the module file nor its declaration was added, so the pushed integration branch does not compile: every branch cut from it fails on an unresolved import. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/background_cpu.rs | 461 ++++++++++++++++++ crates/tracedecay-runtime-core/src/lib.rs | 1 + 2 files changed, 462 insertions(+) create mode 100644 crates/tracedecay-runtime-core/src/background_cpu.rs diff --git a/crates/tracedecay-runtime-core/src/background_cpu.rs b/crates/tracedecay-runtime-core/src/background_cpu.rs new file mode 100644 index 000000000..75366049a --- /dev/null +++ b/crates/tracedecay-runtime-core/src/background_cpu.rs @@ -0,0 +1,461 @@ +//! Process-wide weighted admission for background CPU work. +//! +//! The authority counts active CPU units rather than owning an executor. Code +//! indexing, semantic native threads, and session preparation can therefore +//! use their existing execution substrates while sharing one hard process +//! ceiling. FIFO waiter order prevents a continuously busy class from starving +//! another class, and RAII releases capacity on success, cancellation, or +//! unwind. + +use std::cell::Cell; +use std::collections::VecDeque; +use std::fmt; +use std::num::NonZeroUsize; +use std::sync::{Arc, Condvar, Mutex, OnceLock}; + +#[derive(Debug)] +struct BackgroundCpuWaiterV1 { + units: usize, +} + +#[derive(Default)] +struct BackgroundCpuStateV1 { + active_units: usize, + waiters: VecDeque>, +} + +/// One process-wide background CPU budget shared across subsystems. +pub struct ProcessBackgroundCpuV1 { + width: NonZeroUsize, + state: Mutex, + available: Condvar, +} + +impl fmt::Debug for ProcessBackgroundCpuV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProcessBackgroundCpuV1") + .field("width", &self.width) + .field("active_units", &self.active_units()) + .finish_non_exhaustive() + } +} + +thread_local! { + static BACKGROUND_CPU_DEPTH: Cell = const { Cell::new(0) }; + static BACKGROUND_CPU_UNITS: Cell = const { Cell::new(0) }; +} + +struct BackgroundCpuScopeV1; + +impl BackgroundCpuScopeV1 { + fn enter() -> Self { + BACKGROUND_CPU_DEPTH.with(|depth| depth.set(depth.get().saturating_add(1))); + Self + } +} + +struct YieldedBackgroundCpuV1<'a> { + authority: &'a Arc, + units: usize, + depth: usize, +} + +impl Drop for YieldedBackgroundCpuV1<'_> { + fn drop(&mut self) { + self.authority.admit_units(self.units); + BACKGROUND_CPU_UNITS.with(|units| units.set(self.units)); + BACKGROUND_CPU_DEPTH.with(|depth| depth.set(self.depth)); + } +} + +impl Drop for BackgroundCpuScopeV1 { + fn drop(&mut self) { + BACKGROUND_CPU_DEPTH.with(|depth| { + let remaining = depth.get().saturating_sub(1); + depth.set(remaining); + if remaining == 0 { + BACKGROUND_CPU_UNITS.with(|units| units.set(0)); + } + }); + } +} + +impl ProcessBackgroundCpuV1 { + fn new(width: NonZeroUsize) -> Self { + Self { + width, + state: Mutex::new(BackgroundCpuStateV1::default()), + available: Condvar::new(), + } + } + + #[must_use] + pub const fn width(&self) -> NonZeroUsize { + self.width + } + + #[must_use] + pub fn active_units(&self) -> usize { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .active_units + } + + #[must_use] + pub fn waiting_work_units(&self) -> usize { + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + waiting_units(&state) + } + + /// Acquire one CPU unit, waiting in FIFO order when the process budget is + /// full. The returned guard must remain alive for the active work unit. + pub fn acquire(self: &Arc) -> BackgroundCpuPermitV1 { + self.acquire_units(1) + } + + /// Acquire one CPU unit only when no earlier waiter exists and capacity is + /// immediately available. + pub fn try_acquire(self: &Arc) -> Option { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !state.waiters.is_empty() || state.active_units >= self.width.get() { + return None; + } + state.active_units += 1; + record_state(&state, self.width); + Some(BackgroundCpuPermitV1 { + authority: Arc::clone(self), + units: 1, + }) + } + + /// Run one active work unit under the process budget. Nested work on the + /// same thread reuses a sufficient parent admission instead of waiting on + /// itself. + pub fn with_permit(self: &Arc, operation: impl FnOnce() -> R) -> R { + self.with_permits(1, operation) + } + + /// Run a weighted work unit, clamped to the entire process width. Semantic + /// inference uses its native intra-op thread count as the weight; ordinary + /// index/session preparation uses one. + pub fn with_permits( + self: &Arc, + requested_units: usize, + operation: impl FnOnce() -> R, + ) -> R { + let units = requested_units.max(1).min(self.width.get()); + let active_units = BACKGROUND_CPU_UNITS.with(Cell::get); + if active_units >= units { + return operation(); + } + if active_units > 0 { + let depth = BACKGROUND_CPU_DEPTH.with(Cell::get); + self.release(active_units); + BACKGROUND_CPU_UNITS.with(|active| active.set(0)); + BACKGROUND_CPU_DEPTH.with(|active| active.set(0)); + let _restore = YieldedBackgroundCpuV1 { + authority: self, + units: active_units, + depth, + }; + let _permit = self.acquire_units(units); + let _scope = BackgroundCpuScopeV1::enter(); + BACKGROUND_CPU_UNITS.with(|active| active.set(units)); + return operation(); + } + let _permit = self.acquire_units(units); + let _scope = BackgroundCpuScopeV1::enter(); + BACKGROUND_CPU_UNITS.with(|active| active.set(units)); + operation() + } + + /// Temporarily yield the caller's active units while a nested executor + /// fans out independently admitted leaf work. This prevents a parent + /// Rayon worker from holding capacity while it waits for child workers, + /// including a full-width weighted child. Capacity is reacquired before + /// the parent resumes, including during unwind. + pub fn with_yielded_permits(self: &Arc, operation: impl FnOnce() -> R) -> R { + let units = BACKGROUND_CPU_UNITS.with(Cell::get); + if units == 0 { + return operation(); + } + let depth = BACKGROUND_CPU_DEPTH.with(Cell::get); + self.release(units); + BACKGROUND_CPU_UNITS.with(|active| active.set(0)); + BACKGROUND_CPU_DEPTH.with(|active| active.set(0)); + let _restore = YieldedBackgroundCpuV1 { + authority: self, + units, + depth, + }; + operation() + } + + fn acquire_units(self: &Arc, units: usize) -> BackgroundCpuPermitV1 { + self.admit_units(units); + BackgroundCpuPermitV1 { + authority: Arc::clone(self), + units, + } + } + + fn admit_units(&self, units: usize) { + let waiter = Arc::new(BackgroundCpuWaiterV1 { units }); + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.waiters.push_back(Arc::clone(&waiter)); + record_state(&state, self.width); + loop { + let is_front = state + .waiters + .front() + .is_some_and(|front| Arc::ptr_eq(front, &waiter)); + if is_front && state.active_units.saturating_add(waiter.units) <= self.width.get() { + state.waiters.pop_front(); + state.active_units += waiter.units; + record_state(&state, self.width); + self.available.notify_all(); + return; + } + state = self + .available + .wait(state) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + } + + fn release(&self, units: usize) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + debug_assert!(state.active_units >= units); + state.active_units = state.active_units.saturating_sub(units); + record_state(&state, self.width); + self.available.notify_all(); + } +} + +fn record_state(state: &BackgroundCpuStateV1, width: NonZeroUsize) { + hotpath::gauge!("runtime_core.background_cpu.width").set(width.get()); + hotpath::gauge!("runtime_core.background_cpu.active_units").set(state.active_units); + hotpath::gauge!("runtime_core.background_cpu.waiting_work_units").set(waiting_units(state)); +} + +fn waiting_units(state: &BackgroundCpuStateV1) -> usize { + state + .waiters + .iter() + .fold(0usize, |total, waiter| total.saturating_add(waiter.units)) +} + +/// RAII ownership of active CPU capacity. Dropping it is cancellation-safe and +/// releases the exact acquired weight. +pub struct BackgroundCpuPermitV1 { + authority: Arc, + units: usize, +} + +impl fmt::Debug for BackgroundCpuPermitV1 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BackgroundCpuPermitV1") + .field("units", &self.units) + .finish_non_exhaustive() + } +} + +impl Drop for BackgroundCpuPermitV1 { + fn drop(&mut self) { + self.authority.release(self.units); + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)] +pub enum BackgroundCpuInstallErrorV1 { + #[error( + "background CPU authority is already installed at width {installed_width}, not requested width {requested_width}" + )] + ConflictingWidth { + installed_width: usize, + requested_width: usize, + }, + #[error("background CPU authority installation did not settle")] + InstallationDidNotSettle, +} + +static PROCESS_BACKGROUND_CPU: OnceLock> = OnceLock::new(); + +/// Install or idempotently reuse the one process background CPU authority. +pub fn install_process_background_cpu( + width: NonZeroUsize, +) -> Result, BackgroundCpuInstallErrorV1> { + if let Some(installed) = PROCESS_BACKGROUND_CPU.get() { + return compare_installed_width(installed, width); + } + let requested = Arc::new(ProcessBackgroundCpuV1::new(width)); + match PROCESS_BACKGROUND_CPU.set(Arc::clone(&requested)) { + Ok(()) => Ok(requested), + Err(_) => PROCESS_BACKGROUND_CPU.get().map_or_else( + || Err(BackgroundCpuInstallErrorV1::InstallationDidNotSettle), + |installed| compare_installed_width(installed, width), + ), + } +} + +fn compare_installed_width( + installed: &Arc, + requested: NonZeroUsize, +) -> Result, BackgroundCpuInstallErrorV1> { + if installed.width == requested { + Ok(Arc::clone(installed)) + } else { + Err(BackgroundCpuInstallErrorV1::ConflictingWidth { + installed_width: installed.width.get(), + requested_width: requested.get(), + }) + } +} + +/// Installed process authority, or `None` before daemon worker-plan admission. +#[must_use] +pub fn process_background_cpu() -> Option> { + PROCESS_BACKGROUND_CPU.get().map(Arc::clone) +} + +#[cfg(test)] +mod tests { + use std::num::NonZeroUsize; + use std::sync::{ + Arc, Barrier, + atomic::{AtomicUsize, Ordering}, + }; + use std::time::Duration; + + use super::*; + + #[test] + fn combined_classes_never_exceed_width_and_both_progress() { + let authority = Arc::new(ProcessBackgroundCpuV1::new( + NonZeroUsize::new(4).expect("nonzero width"), + )); + let active = Arc::new(AtomicUsize::new(0)); + let maximum = Arc::new(AtomicUsize::new(0)); + let index_completed = Arc::new(AtomicUsize::new(0)); + let session_completed = Arc::new(AtomicUsize::new(0)); + let start = Arc::new(Barrier::new(17)); + let mut workers = Vec::new(); + for ordinal in 0..16 { + let authority = Arc::clone(&authority); + let active = Arc::clone(&active); + let maximum = Arc::clone(&maximum); + let index_completed = Arc::clone(&index_completed); + let session_completed = Arc::clone(&session_completed); + let start = Arc::clone(&start); + workers.push(std::thread::spawn(move || { + start.wait(); + authority.with_permit(|| { + let current = active.fetch_add(1, Ordering::SeqCst) + 1; + maximum.fetch_max(current, Ordering::SeqCst); + std::thread::sleep(Duration::from_millis(5)); + active.fetch_sub(1, Ordering::SeqCst); + if ordinal % 2 == 0 { + index_completed.fetch_add(1, Ordering::SeqCst); + } else { + session_completed.fetch_add(1, Ordering::SeqCst); + } + }); + })); + } + start.wait(); + for worker in workers { + worker.join().expect("background worker"); + } + + assert!(maximum.load(Ordering::SeqCst) <= 4); + assert_eq!(index_completed.load(Ordering::SeqCst), 8); + assert_eq!(session_completed.load(Ordering::SeqCst), 8); + } + + #[test] + fn waiting_demand_sums_weighted_work_units() { + let state = BackgroundCpuStateV1 { + active_units: 4, + waiters: VecDeque::from([ + Arc::new(BackgroundCpuWaiterV1 { units: 4 }), + Arc::new(BackgroundCpuWaiterV1 { units: 1 }), + ]), + }; + + assert_eq!(waiting_units(&state), 5); + } + + #[test] + fn weighted_units_and_nested_work_share_one_width() { + let authority = Arc::new(ProcessBackgroundCpuV1::new( + NonZeroUsize::new(4).expect("nonzero width"), + )); + authority.with_permits(4, || { + assert_eq!(authority.active_units(), 4); + authority.with_permit(|| assert_eq!(authority.active_units(), 4)); + }); + authority.with_permit(|| { + assert_eq!(authority.active_units(), 1); + authority.with_permits(4, || assert_eq!(authority.active_units(), 4)); + assert_eq!(authority.active_units(), 1); + }); + assert_eq!(authority.active_units(), 0); + } + + #[test] + fn nested_executor_yields_parent_units_and_reacquires_them() { + let authority = Arc::new(ProcessBackgroundCpuV1::new( + NonZeroUsize::new(4).expect("nonzero width"), + )); + authority.with_permit(|| { + assert_eq!(authority.active_units(), 1); + authority.with_yielded_permits(|| { + assert_eq!(authority.active_units(), 0); + authority.with_permits(4, || assert_eq!(authority.active_units(), 4)); + }); + assert_eq!(authority.active_units(), 1); + }); + assert_eq!(authority.active_units(), 0); + } + + #[test] + fn panic_and_cancellation_drop_release_every_unit() { + let authority = Arc::new(ProcessBackgroundCpuV1::new( + NonZeroUsize::new(2).expect("nonzero width"), + )); + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + authority.with_permits(2, || panic!("injected background panic")); + })); + assert!(panic.is_err()); + assert_eq!(authority.active_units(), 0); + + let nested_panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + authority.with_permit(|| { + authority.with_yielded_permits(|| panic!("injected nested executor panic")); + }); + })); + assert!(nested_panic.is_err()); + assert_eq!(authority.active_units(), 0); + + let cancelled = authority.acquire(); + assert_eq!(authority.active_units(), 1); + drop(cancelled); + assert_eq!(authority.active_units(), 0); + assert!(authority.try_acquire().is_some()); + } +} diff --git a/crates/tracedecay-runtime-core/src/lib.rs b/crates/tracedecay-runtime-core/src/lib.rs index 7a9d0ef45..7a176ffb2 100644 --- a/crates/tracedecay-runtime-core/src/lib.rs +++ b/crates/tracedecay-runtime-core/src/lib.rs @@ -76,6 +76,7 @@ #![allow(rustdoc::broken_intra_doc_links)] #![allow(rustdoc::private_intra_doc_links)] +pub mod background_cpu; pub mod branch; pub mod branch_meta; pub mod cancellation; From c8970f7261688510f1dde2c194a1bde02767ddc6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 24 Aug 2026 01:56:59 +0000 Subject: [PATCH 2/3] fix(sessions): classify the stopped batch worker failure The batching commit added ObservationApplicationError::BatchWorkerStopped but left the ingest failure classifier's match on that enum without an arm for it, so tracedecay-sessions does not compile on this branch. Classified as retryable/unavailable rather than contended or permanent: a worker that went away before completing never reached a verdict about the observation, so the same input succeeds once a worker is running again. --- .../src/runtime/ingest/failure.rs | 8 ++++++++ .../src/runtime/ingest/tests.rs | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/crates/tracedecay-sessions/src/runtime/ingest/failure.rs b/crates/tracedecay-sessions/src/runtime/ingest/failure.rs index f4e78e63d..8380f308b 100644 --- a/crates/tracedecay-sessions/src/runtime/ingest/failure.rs +++ b/crates/tracedecay-sessions/src/runtime/ingest/failure.rs @@ -531,6 +531,14 @@ pub fn classify_claude_observation_failure( crate::observation::ObservationApplicationError::BatchContainsNonDurable => { permanent("observation_batch_non_durable") } + // The batch worker went away before the batch completed, so this + // pass never reached a verdict about the observation itself. That + // is a missing runtime, not backpressure and not bad data: the + // same input succeeds once a worker is running again, so it is + // retryable and reported as unavailable rather than contended. + crate::observation::ObservationApplicationError::BatchWorkerStopped => { + unavailable("observation_batch_worker_stopped") + } }, Ingest::MissingParsedRecord => permanent("observation_parsed_record_missing"), Ingest::InvalidFrameState => permanent("observation_frame_state_invalid"), diff --git a/crates/tracedecay-sessions/src/runtime/ingest/tests.rs b/crates/tracedecay-sessions/src/runtime/ingest/tests.rs index 5debd4682..80fc9d62a 100644 --- a/crates/tracedecay-sessions/src/runtime/ingest/tests.rs +++ b/crates/tracedecay-sessions/src/runtime/ingest/tests.rs @@ -804,3 +804,23 @@ fn project_provider_deferral_preserves_existing_deferred_work() { } ); } + +#[test] +fn a_stopped_batch_worker_is_retryable_and_unavailable() { + // Distinct from BatchContainsNonDurable, which is permanent: that one means + // the batch itself was invalid, while this one means no verdict was ever + // reached because the worker went away. Re-running the same input can + // succeed, so it must not be classified as permanent. + let error = claude_observation::ClaudeObservationIngestError::Application( + crate::observation::ObservationApplicationError::BatchWorkerStopped, + ); + + let failure = classify_claude_observation_failure(&error); + + assert_eq!(failure.reason_code, "observation_batch_worker_stopped"); + assert!(failure.retryable); + assert_eq!( + failure.status, + crate::admission::HostAdmissionStatus::Unavailable + ); +} From 8c932170dc28d2f00898a09afa44d64008395752 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Mon, 24 Aug 2026 02:10:35 +0000 Subject: [PATCH 3/3] test(dashboard): sweep graph payloads across every project live-sweep.ts proves each workspace renders; it cannot prove anything is behind that render. A workspace whose read failed still draws its chrome and so passes, which is how the Code workspace passed while /api/plugins/graph/overview was answering 200 with a null payload. live-multiproject.ts asserts the payload instead: it walks /api/projects and requires every enrolled project to serve a graph overview with a non-zero node count, reporting node/edge/file/language counts per project. When a read fails it prints coverage.omission_reasons, which is where the envelope actually carries the cause. Also covers the decode step that makes those reasons reachable: an unavailable envelope arrives as domain_state unknown with a null payload, and the existing test asserted only the state, not that the reason survived. --- dashboard/package.json | 1 + dashboard/src/data/query/envelope.test.ts | 20 +++ dashboard/stories/live-multiproject.ts | 173 ++++++++++++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 dashboard/stories/live-multiproject.ts diff --git a/dashboard/package.json b/dashboard/package.json index d9fe3b68f..067d67475 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -15,6 +15,7 @@ "visual:audit": "tsx stories/audit.ts", "visual:topography": "tsx stories/topography-audit.ts", "live:sweep": "tsx stories/live-sweep.ts", + "live:multiproject": "tsx stories/live-multiproject.ts", "axe:audit": "tsx e2e/axe-audit.ts", "axe:explorer": "tsx e2e/axe-explorer.ts" }, diff --git a/dashboard/src/data/query/envelope.test.ts b/dashboard/src/data/query/envelope.test.ts index a86547ee3..33b5fd079 100644 --- a/dashboard/src/data/query/envelope.test.ts +++ b/dashboard/src/data/query/envelope.test.ts @@ -98,6 +98,26 @@ describe('fetchEnvelope', () => { }); }); + it('carries the daemon reason when an unavailable read returns no payload', async () => { + // `DashboardEnvelopeV1::unavailable` is how every failed graph read reaches + // the client: domain_state `unknown`, payload null, and the cause pushed + // onto `coverage.omission_reasons`. The state alone is not actionable — + // "unknown" does not tell a reader whether the index is still sealing or + // the generation is gone — so the reason has to survive the decode. Without + // this the Code workspace renders a blocked panel with no explanation, + // which is indistinguishable from an empty project. + const envelope = fixtureEnvelope(null, 'unknown'); + const coverage = { ...(envelope['coverage'] as Record) }; + coverage['omission_reasons'] = ['no code generation is currently serving for this project']; + stub(200, { ...envelope, coverage }); + + expect(await fetchEnvelope('/api/x', PayloadSchema)).toMatchObject({ + outcome: 'transport', + state: 'unknown', + detail: 'no code generation is currently serving for this project', + }); + }); + it('reports a network failure as offline', async () => { vi.stubGlobal( 'fetch', diff --git a/dashboard/stories/live-multiproject.ts b/dashboard/stories/live-multiproject.ts new file mode 100644 index 000000000..a1f1658f0 --- /dev/null +++ b/dashboard/stories/live-multiproject.ts @@ -0,0 +1,173 @@ +/** + * Multi-project live payload sweep. + * + * `live-sweep.ts` proves each workspace *renders*: it loads a route and checks + * the main element is non-empty and did not fall into React Router's error + * boundary. That is necessary but not sufficient — a workspace whose data + * source is unavailable still renders its chrome (headers, empty-state cards, + * nav) and so passes that sweep while showing nothing real. The Code workspace + * did exactly that: `/api/plugins/graph/overview` answered 200 with + * `payload: null`, and the render sweep called it a pass. + * + * This sweep asserts on the payload instead of the pixels, for every enrolled + * project rather than only the active one. A read that failed is not silently + * equivalent to a read that found nothing: the dashboard envelope distinguishes + * them, and this harness surfaces that distinction rather than flattening it. + * + * `domain_state: "ready"` -> payload present, counts below + * `domain_state: "complete_zero_findings"` -> genuinely empty, and says so + * `domain_state: "unknown"` + null payload -> the read FAILED; the reason is + * in `coverage.omission_reasons` + * + * That last case is the one worth reading carefully. `DashboardEnvelopeV1:: + * unavailable` sets `Unknown` and pushes the reason onto + * `coverage.omission_reasons`, so a null payload always carries a machine- + * readable cause. Printing the state without the reason (as a bare 200/!=200 + * check does) throws away the only part that says what to fix. + * + * Usage: + * SWEEP_BASE_URL=http://127.0.0.1:8397 npx tsx stories/live-multiproject.ts + * + * Exit code is non-zero if any enrolled project fails to serve a graph + * overview, so this is usable as a gate and not only as a report. + */ + +// This file has no imports, and top-level `await` needs it to be a module. +export {}; + +const BASE = process.env['SWEEP_BASE_URL'] ?? 'http://127.0.0.1:8397'; + +/** Envelope fields this harness reads. Deliberately loose: the point is to + * report whatever a live daemon actually sent, including shapes a stricter + * schema would reject outright. */ +interface Envelope { + domain_state?: string; + coverage?: { omission_reasons?: string[] }; + payload?: unknown; +} + +interface ProjectRow { + id: string; + root: string; +} + +/** A transport failure is reported as a row, not as an unhandled rejection: + * "the daemon is not listening" is a result this sweep should print next to + * the projects it did reach, not a stack trace that hides them. */ +async function getJson(path: string): Promise<{ status: number; body: Envelope }> { + let res: Response; + try { + res = await fetch(`${BASE}${path}`, { headers: { accept: 'application/json' } }); + } catch (cause) { + return { + status: 0, + body: { domain_state: 'unreachable', coverage: { omission_reasons: [String(cause).slice(0, 120)] } }, + }; + } + let body: Envelope = {}; + try { + body = (await res.json()) as Envelope; + } catch { + body = {}; + } + return { status: res.status, body }; +} + +/** Registry rows name their project differently across surfaces; accept any of + * the documented spellings rather than guessing one and reporting zero. */ +function readProjects(payload: unknown): ProjectRow[] { + const rows = (payload as { projects?: unknown } | undefined)?.projects; + if (!Array.isArray(rows)) return []; + return rows.flatMap((raw) => { + const row = raw as Record; + const id = row['project_id'] ?? row['id'] ?? row['projectId']; + const root = row['root'] ?? row['project_root'] ?? row['path'] ?? ''; + return typeof id === 'string' ? [{ id, root: String(root) }] : []; + }); +} + +function num(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + +/** Totals are reported by the server; fall back to counting the by-kind arrays + * so a payload that omits a total is described rather than scored as zero. */ +function overviewCounts(payload: unknown): { + nodes: number; + edges: number; + files: number; + languages: number; + topLanguages: string; +} { + const p = (payload ?? {}) as Record; + const totals = (p['totals'] ?? {}) as Record; + const byLanguage = Array.isArray(p['files_by_language']) + ? (p['files_by_language'] as Record[]) + : []; + const sum = (rows: unknown): number => + Array.isArray(rows) + ? (rows as Record[]).reduce((acc, r) => acc + num(r['count']), 0) + : 0; + const nodes = num(totals['nodes']) || sum(p['nodes_by_kind']); + const edges = num(totals['edges']) || sum(p['edges_by_kind']); + const files = num(totals['files']) || sum(byLanguage); + const topLanguages = byLanguage + .slice() + .sort((a, b) => num(b['count']) - num(a['count'])) + .slice(0, 3) + .map((r) => `${String(r['language'] ?? '?')}:${num(r['count'])}`) + .join(','); + return { nodes, edges, files, languages: byLanguage.length, topLanguages }; +} + +function reasons(env: Envelope): string { + const list = env.coverage?.omission_reasons; + return Array.isArray(list) && list.length > 0 ? list.join('; ').slice(0, 160) : ''; +} + +const registry = await getJson('/api/projects'); +const projects = readProjects(registry.body.payload); +console.log(`registry: status=${registry.status} domain_state=${registry.body.domain_state ?? '?'} projects=${projects.length}`); +if (projects.length === 0) { + console.log(`registry reasons: ${reasons(registry.body) || '(none)'}`); +} + +let failures = 0; +const rows: string[] = []; + +for (const project of projects) { + const overview = await getJson(`/api/projects/${encodeURIComponent(project.id)}/plugins/graph/overview`); + const state = overview.body.domain_state ?? '?'; + const counts = overviewCounts(overview.body.payload); + const served = overview.body.payload != null && counts.nodes > 0; + if (!served) failures++; + const name = project.root.split('/').filter(Boolean).pop() ?? project.id; + rows.push( + [ + name.padEnd(16), + String(overview.status).padEnd(4), + state.padEnd(22), + `nodes=${String(counts.nodes).padStart(7)}`, + `edges=${String(counts.edges).padStart(7)}`, + `files=${String(counts.files).padStart(6)}`, + `langs=${String(counts.languages).padStart(3)}`, + served ? `ok ${counts.topLanguages}` : `FAIL ${reasons(overview.body) || '(no reason given)'}`, + ].join(' '), + ); +} + +for (const row of rows) console.log(row); +// An empty registry is a failure with its own sentence rather than a vacuous +// pass: "0 of 0 projects failed" is true and useless. +if (projects.length === 0) { + console.log('MULTIPROJECT SWEEP FAIL: the registry returned no enrolled projects'); +} else if (failures === 0) { + console.log( + `MULTIPROJECT SWEEP PASS: ${projects.length} project(s) served a non-empty graph overview`, + ); +} else { + console.log( + `MULTIPROJECT SWEEP FAIL: ${failures}/${projects.length} project(s) served no graph payload`, + ); +} +process.exitCode = failures === 0 && projects.length > 0 ? 0 : 1;