test(explore): add the factory-closure fixture and its selection probe (CG-27)
A file whose top-level symbol spans almost all of it — createFoo() returning
an object of closures — is how Svelte 5 rune stores, React custom-hook modules,
IIFE module-pattern JS and Zustand's create((set,get)=>({…})) are all written.
probe-factory-closure.mjs measures what such a file DELIVERS from within: which
inner symbols' definitions reach the agent, not how many bytes did.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "factory-closure-ts",
|
||||
"version": "0.0.0",
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createDashboardStore } from './stores/dashboard-store';
|
||||
import { createAlertsStore } from './stores/alerts-store';
|
||||
import { mountPanel } from './ui/panel';
|
||||
import { parseFilterText } from './services/filter-parser';
|
||||
import { refreshMetricCache } from './services/metric-service';
|
||||
import type { StoreDeps } from './stores/types';
|
||||
|
||||
/** Wire a dashboard: build both stores, mount the panel, boot it. */
|
||||
export async function startDashboard(deps: StoreDeps, baseUrl: string, dashboardId: string) {
|
||||
const store = createDashboardStore(deps, baseUrl);
|
||||
const alerts = createAlertsStore(deps, baseUrl);
|
||||
const panel = mountPanel(store, dashboardId);
|
||||
await panel.boot();
|
||||
await alerts.refreshAlerts(dashboardId);
|
||||
return { store, alerts, panel };
|
||||
}
|
||||
|
||||
/** Apply the filter bar's text to the dashboard store. */
|
||||
export function searchDashboard(store: ReturnType<typeof createDashboardStore>, text: string) {
|
||||
return store.applyFilter(parseFilterText(text));
|
||||
}
|
||||
|
||||
export { refreshMetricCache };
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Minimal fetch helpers the dashboard store depends on. */
|
||||
|
||||
export interface RequestOptions {
|
||||
retries: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export const defaultRequestOptions: RequestOptions = { retries: 2, timeoutMs: 5_000 };
|
||||
|
||||
/** Build a query string from a plain record, skipping empty values. */
|
||||
export function toQueryString(params: Record<string, string | number | undefined>): string {
|
||||
const parts: string[] = [];
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === '') continue;
|
||||
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
|
||||
}
|
||||
return parts.length > 0 ? `?${parts.join('&')}` : '';
|
||||
}
|
||||
|
||||
/** Join a base path and a resource path without doubling the separator. */
|
||||
export function joinPath(base: string, resource: string): string {
|
||||
if (base.endsWith('/') && resource.startsWith('/')) return base + resource.slice(1);
|
||||
if (!base.endsWith('/') && !resource.startsWith('/')) return `${base}/${resource}`;
|
||||
return base + resource;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { MetricSample } from '../stores/types';
|
||||
|
||||
/** Statistics helpers shared by the store and the panel. */
|
||||
|
||||
export function meanOf(samples: readonly MetricSample[]): number {
|
||||
if (samples.length === 0) return 0;
|
||||
let total = 0;
|
||||
for (const sample of samples) total += sample.value;
|
||||
return total / samples.length;
|
||||
}
|
||||
|
||||
export function medianOf(samples: readonly MetricSample[]): number {
|
||||
if (samples.length === 0) return 0;
|
||||
const values = samples.map((s) => s.value).sort((a, b) => a - b);
|
||||
const mid = Math.floor(values.length / 2);
|
||||
return values.length % 2 === 0 ? (values[mid - 1]! + values[mid]!) / 2 : values[mid]!;
|
||||
}
|
||||
|
||||
export function rateOfChange(samples: readonly MetricSample[]): number {
|
||||
if (samples.length < 2) return 0;
|
||||
const ordered = samples.slice().sort((a, b) => a.at - b.at);
|
||||
const first = ordered[0]!;
|
||||
const last = ordered[ordered.length - 1]!;
|
||||
const elapsed = last.at - first.at;
|
||||
return elapsed > 0 ? (last.value - first.value) / elapsed : 0;
|
||||
}
|
||||
|
||||
export function bucketByHour(samples: readonly MetricSample[]): Map<number, MetricSample[]> {
|
||||
const buckets = new Map<number, MetricSample[]>();
|
||||
for (const sample of samples) {
|
||||
const hour = Math.floor(sample.at / 3_600_000);
|
||||
const bucket = buckets.get(hour);
|
||||
if (bucket) bucket.push(sample);
|
||||
else buckets.set(hour, [sample]);
|
||||
}
|
||||
return buckets;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { FilterSpec } from '../stores/types';
|
||||
|
||||
/** Parse the dashboard's filter bar text into filter specs. */
|
||||
|
||||
const OPERATORS: Record<string, FilterSpec['op']> = {
|
||||
':': 'eq',
|
||||
'~': 'contains',
|
||||
'>': 'gt',
|
||||
'<': 'lt',
|
||||
};
|
||||
|
||||
/** `title~sales kind:chart column>3` → three specs. */
|
||||
export function parseFilterText(text: string): FilterSpec[] {
|
||||
const specs: FilterSpec[] = [];
|
||||
for (const token of tokenize(text)) {
|
||||
const spec = parseToken(token);
|
||||
if (spec) specs.push(spec);
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
|
||||
/** Split on whitespace, honouring double-quoted values. */
|
||||
export function tokenize(text: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = '';
|
||||
let quoted = false;
|
||||
for (const ch of text) {
|
||||
if (ch === '"') { quoted = !quoted; continue; }
|
||||
if (!quoted && /\s/.test(ch)) {
|
||||
if (current.length > 0) { tokens.push(current); current = ''; }
|
||||
continue;
|
||||
}
|
||||
current += ch;
|
||||
}
|
||||
if (current.length > 0) tokens.push(current);
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/** One `field<op>value` token, or null when it does not parse. */
|
||||
export function parseToken(token: string): FilterSpec | null {
|
||||
for (const [symbol, op] of Object.entries(OPERATORS)) {
|
||||
const at = token.indexOf(symbol);
|
||||
if (at <= 0) continue;
|
||||
const field = token.slice(0, at).trim();
|
||||
const value = token.slice(at + symbol.length).trim();
|
||||
if (field.length === 0 || value.length === 0) return null;
|
||||
return { field, op, value };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Render specs back to filter-bar text — the round trip the URL uses. */
|
||||
export function formatFilterText(specs: readonly FilterSpec[]): string {
|
||||
const symbolFor = (op: FilterSpec['op']): string =>
|
||||
Object.entries(OPERATORS).find(([, candidate]) => candidate === op)?.[0] ?? ':';
|
||||
return specs
|
||||
.map((spec) => {
|
||||
const value = /\s/.test(spec.value) ? `"${spec.value}"` : spec.value;
|
||||
return `${spec.field}${symbolFor(spec.op)}${value}`;
|
||||
})
|
||||
.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { FilterSpec, MetricSample, Widget } from '../stores/types';
|
||||
import { bucketByHour, meanOf, rateOfChange } from '../lib/metrics';
|
||||
|
||||
/**
|
||||
* Stateless metric helpers — the server-shaped half of the same domain. These
|
||||
* are ordinary top-level functions, not closures, so they are the control the
|
||||
* factory-closure file is measured against.
|
||||
*/
|
||||
|
||||
const STALE_AFTER_MS = 15 * 60 * 1000;
|
||||
|
||||
/** Refresh a cached metric map in place, returning the widgets that changed. */
|
||||
export function refreshMetricCache(
|
||||
cache: Map<string, MetricSample[]>,
|
||||
incoming: readonly MetricSample[],
|
||||
now: number,
|
||||
): string[] {
|
||||
const touched = new Set<string>();
|
||||
for (const sample of incoming) {
|
||||
if (typeof sample.value !== 'number' || Number.isNaN(sample.value)) continue;
|
||||
const bucket = cache.get(sample.widgetId);
|
||||
if (bucket) bucket.push(sample);
|
||||
else cache.set(sample.widgetId, [sample]);
|
||||
touched.add(sample.widgetId);
|
||||
}
|
||||
for (const [widgetId, bucket] of cache) {
|
||||
const fresh = bucket.filter((s) => now - s.at <= STALE_AFTER_MS);
|
||||
if (fresh.length !== bucket.length) {
|
||||
cache.set(widgetId, fresh);
|
||||
touched.add(widgetId);
|
||||
}
|
||||
}
|
||||
return [...touched].sort();
|
||||
}
|
||||
|
||||
/** Apply a filter spec set to raw samples rather than to widgets. */
|
||||
export function filterMetrics(
|
||||
samples: readonly MetricSample[],
|
||||
specs: readonly FilterSpec[],
|
||||
): MetricSample[] {
|
||||
if (specs.length === 0) return samples.slice();
|
||||
return samples.filter((sample) => specs.every((spec) => {
|
||||
const field = spec.field === 'unit'
|
||||
? sample.unit
|
||||
: spec.field === 'widget'
|
||||
? sample.widgetId
|
||||
: String(sample.value);
|
||||
switch (spec.op) {
|
||||
case 'eq': return field === spec.value;
|
||||
case 'contains': return field.includes(spec.value);
|
||||
case 'gt': return Number(field) > Number(spec.value);
|
||||
case 'lt': return Number(field) < Number(spec.value);
|
||||
default: return false;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/** Per-widget rollup used by the server-rendered summary card. */
|
||||
export function rollupByWidget(
|
||||
samples: readonly MetricSample[],
|
||||
widgets: readonly Widget[],
|
||||
): Array<{ widgetId: string; title: string; mean: number; slope: number; hours: number }> {
|
||||
const titles = new Map(widgets.map((w) => [w.id, w.title]));
|
||||
const grouped = new Map<string, MetricSample[]>();
|
||||
for (const sample of samples) {
|
||||
const bucket = grouped.get(sample.widgetId);
|
||||
if (bucket) bucket.push(sample);
|
||||
else grouped.set(sample.widgetId, [sample]);
|
||||
}
|
||||
|
||||
const out: Array<{ widgetId: string; title: string; mean: number; slope: number; hours: number }> = [];
|
||||
for (const [widgetId, bucket] of grouped) {
|
||||
out.push({
|
||||
widgetId,
|
||||
title: titles.get(widgetId) ?? '(unknown)',
|
||||
mean: meanOf(bucket),
|
||||
slope: rateOfChange(bucket),
|
||||
hours: bucketByHour(bucket).size,
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => b.mean - a.mean);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Which widgets have not reported inside the staleness window. */
|
||||
export function staleWidgets(
|
||||
samples: readonly MetricSample[],
|
||||
widgets: readonly Widget[],
|
||||
now: number,
|
||||
): string[] {
|
||||
const newest = new Map<string, number>();
|
||||
for (const sample of samples) {
|
||||
const seen = newest.get(sample.widgetId) ?? 0;
|
||||
if (sample.at > seen) newest.set(sample.widgetId, sample.at);
|
||||
}
|
||||
return widgets
|
||||
.filter((w) => !w.hidden)
|
||||
.filter((w) => now - (newest.get(w.id) ?? 0) > STALE_AFTER_MS)
|
||||
.map((w) => w.id)
|
||||
.sort();
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { FilterSpec, StoreDeps } from './types';
|
||||
import { joinPath, toQueryString } from '../lib/http';
|
||||
|
||||
const ALERT_ENDPOINT = '/api/dashboard/alerts';
|
||||
|
||||
export interface Alert {
|
||||
id: string;
|
||||
widgetId: string;
|
||||
severity: 'info' | 'warn' | 'critical';
|
||||
message: string;
|
||||
raisedAt: number;
|
||||
acknowledgedAt: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The alerts store — the dashboard's second factory closure. Same shape as the
|
||||
* metric store: every operation is a closure over private state.
|
||||
*/
|
||||
export function createAlertsStore(deps: StoreDeps, baseUrl: string) {
|
||||
let alerts: Alert[] = [];
|
||||
let filters: FilterSpec[] = [];
|
||||
let mutedWidgets = new Set<string>();
|
||||
let lastRefreshedAt = 0;
|
||||
|
||||
/** Pull the current alert set and merge acknowledgements the user made locally. */
|
||||
async function refreshAlerts(dashboardId: string): Promise<Alert[]> {
|
||||
const url = joinPath(baseUrl, ALERT_ENDPOINT) + toQueryString({ dashboard: dashboardId });
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await deps.fetchJson(url);
|
||||
} catch (error) {
|
||||
deps.log(`refreshAlerts failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return alerts;
|
||||
}
|
||||
if (!Array.isArray(payload)) {
|
||||
deps.log('refreshAlerts got a non-array payload');
|
||||
return alerts;
|
||||
}
|
||||
|
||||
const acknowledged = new Map(
|
||||
alerts.filter((a) => a.acknowledgedAt !== null).map((a) => [a.id, a.acknowledgedAt]),
|
||||
);
|
||||
const merged: Alert[] = [];
|
||||
for (const raw of payload as Alert[]) {
|
||||
if (typeof raw.id !== 'string' || raw.id.length === 0) continue;
|
||||
merged.push({
|
||||
...raw,
|
||||
acknowledgedAt: acknowledged.get(raw.id) ?? raw.acknowledgedAt ?? null,
|
||||
});
|
||||
}
|
||||
merged.sort((a, b) => b.raisedAt - a.raisedAt);
|
||||
alerts = merged;
|
||||
lastRefreshedAt = deps.now();
|
||||
return alerts;
|
||||
}
|
||||
|
||||
/** Filter the alert list the same way the metric store filters widgets. */
|
||||
function applyAlertFilter(specs: readonly FilterSpec[]): Alert[] {
|
||||
filters = specs.slice();
|
||||
if (filters.length === 0) return alerts;
|
||||
|
||||
const fieldOf = (alert: Alert, field: string): string => {
|
||||
switch (field) {
|
||||
case 'severity': return alert.severity;
|
||||
case 'widget': return alert.widgetId;
|
||||
case 'message': return alert.message;
|
||||
default: return '';
|
||||
}
|
||||
};
|
||||
|
||||
return alerts.filter((alert) => filters.every((spec) => {
|
||||
const value = fieldOf(alert, spec.field);
|
||||
switch (spec.op) {
|
||||
case 'eq': return value.toLowerCase() === spec.value.toLowerCase();
|
||||
case 'contains': return value.toLowerCase().includes(spec.value.toLowerCase());
|
||||
case 'gt': return value > spec.value;
|
||||
case 'lt': return value < spec.value;
|
||||
default: return false;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/** Mark an alert acknowledged locally; the next refresh preserves it. */
|
||||
function acknowledge(alertId: string): boolean {
|
||||
const target = alerts.find((a) => a.id === alertId);
|
||||
if (!target || target.acknowledgedAt !== null) return false;
|
||||
target.acknowledgedAt = deps.now();
|
||||
deps.log(`acknowledged ${alertId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Silence a widget's alerts without dropping them from the buffer. */
|
||||
function muteWidget(widgetId: string): void {
|
||||
mutedWidgets.add(widgetId);
|
||||
deps.log(`muted ${widgetId} (${mutedWidgets.size} muted)`);
|
||||
}
|
||||
|
||||
function unmuteWidget(widgetId: string): boolean {
|
||||
return mutedWidgets.delete(widgetId);
|
||||
}
|
||||
|
||||
/** The alerts the dashboard should actually show right now. */
|
||||
function visibleAlerts(): Alert[] {
|
||||
return applyAlertFilter(filters)
|
||||
.filter((a) => !mutedWidgets.has(a.widgetId))
|
||||
.filter((a) => a.acknowledgedAt === null);
|
||||
}
|
||||
|
||||
/** Counts per severity, for the badge on the alerts tab. */
|
||||
function countBySeverity(): Record<Alert['severity'], number> {
|
||||
const counts: Record<Alert['severity'], number> = { info: 0, warn: 0, critical: 0 };
|
||||
for (const alert of visibleAlerts()) counts[alert.severity] += 1;
|
||||
return counts;
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
alerts = [];
|
||||
filters = [];
|
||||
mutedWidgets = new Set();
|
||||
lastRefreshedAt = 0;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return { alerts: visibleAlerts(), counts: countBySeverity(), lastRefreshedAt };
|
||||
}
|
||||
|
||||
return {
|
||||
refreshAlerts,
|
||||
applyAlertFilter,
|
||||
acknowledge,
|
||||
muteWidget,
|
||||
unmuteWidget,
|
||||
visibleAlerts,
|
||||
countBySeverity,
|
||||
reset,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export type AlertsStore = ReturnType<typeof createAlertsStore>;
|
||||
@@ -0,0 +1,384 @@
|
||||
import type { FilterSpec, MetricSample, StoreDeps, Widget } from './types';
|
||||
import { defaultRequestOptions, joinPath, toQueryString } from '../lib/http';
|
||||
|
||||
const WIDGET_ENDPOINT = '/api/dashboard/widgets';
|
||||
const METRIC_ENDPOINT = '/api/dashboard/metrics';
|
||||
const SAMPLE_RETENTION_MS = 6 * 60 * 60 * 1000;
|
||||
const MAX_SAMPLES_PER_WIDGET = 720;
|
||||
const COLUMN_COUNT = 12;
|
||||
|
||||
/**
|
||||
* The dashboard store: one factory closure holding every operation the
|
||||
* dashboard performs. Callers get an object of closures; nothing inside is
|
||||
* exported on its own.
|
||||
*/
|
||||
export function createDashboardStore(deps: StoreDeps, baseUrl: string) {
|
||||
let widgets: Widget[] = [];
|
||||
let samples: MetricSample[] = [];
|
||||
let activeFilters: FilterSpec[] = [];
|
||||
let lastSyncedAt = 0;
|
||||
let loading = false;
|
||||
let lastError: string | null = null;
|
||||
const listeners = new Set<(snapshot: ReturnType<typeof snapshot>) => void>();
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
widgets: widgets.filter((w) => !w.hidden),
|
||||
sampleCount: samples.length,
|
||||
filters: activeFilters.slice(),
|
||||
lastSyncedAt,
|
||||
loading,
|
||||
lastError,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the widget set for the current user and merge it into local state,
|
||||
* preserving any layout the user has moved since the last sync.
|
||||
*/
|
||||
async function loadWidgets(dashboardId: string, includeHidden = false): Promise<Widget[]> {
|
||||
loading = true;
|
||||
lastError = null;
|
||||
const url = joinPath(baseUrl, WIDGET_ENDPOINT) + toQueryString({
|
||||
dashboard: dashboardId,
|
||||
hidden: includeHidden ? '1' : undefined,
|
||||
});
|
||||
|
||||
let attempt = 0;
|
||||
let payload: unknown = null;
|
||||
while (attempt <= defaultRequestOptions.retries) {
|
||||
try {
|
||||
payload = await deps.fetchJson(url);
|
||||
break;
|
||||
} catch (error) {
|
||||
attempt += 1;
|
||||
if (attempt > defaultRequestOptions.retries) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
loading = false;
|
||||
deps.log(`loadWidgets failed after ${attempt} attempts: ${lastError}`);
|
||||
notify();
|
||||
return widgets;
|
||||
}
|
||||
deps.log(`loadWidgets retry ${attempt} for ${dashboardId}`);
|
||||
}
|
||||
}
|
||||
|
||||
const incoming = Array.isArray(payload) ? (payload as Widget[]) : [];
|
||||
const byId = new Map(widgets.map((w) => [w.id, w]));
|
||||
const merged: Widget[] = [];
|
||||
for (const next of incoming) {
|
||||
const existing = byId.get(next.id);
|
||||
if (!existing) {
|
||||
merged.push({ ...next });
|
||||
continue;
|
||||
}
|
||||
// Server owns identity and content; the client owns placement.
|
||||
merged.push({
|
||||
...next,
|
||||
column: existing.column,
|
||||
row: existing.row,
|
||||
span: existing.span,
|
||||
hidden: existing.hidden,
|
||||
});
|
||||
byId.delete(next.id);
|
||||
}
|
||||
for (const orphan of byId.values()) {
|
||||
deps.log(`widget ${orphan.id} no longer exists on the server`);
|
||||
}
|
||||
|
||||
widgets = merged;
|
||||
lastSyncedAt = deps.now();
|
||||
loading = false;
|
||||
notify();
|
||||
return widgets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull fresh metric samples for every visible widget, append them to the
|
||||
* rolling buffer, and drop anything past the retention window.
|
||||
*/
|
||||
async function refreshMetrics(windowMs = SAMPLE_RETENTION_MS): Promise<MetricSample[]> {
|
||||
if (widgets.length === 0) {
|
||||
deps.log('refreshMetrics called with no widgets loaded');
|
||||
return samples;
|
||||
}
|
||||
loading = true;
|
||||
const visible = widgets.filter((w) => !w.hidden);
|
||||
const collected: MetricSample[] = [];
|
||||
|
||||
for (const widget of visible) {
|
||||
const url = joinPath(baseUrl, METRIC_ENDPOINT) + toQueryString({
|
||||
widget: widget.id,
|
||||
since: deps.now() - windowMs,
|
||||
});
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await deps.fetchJson(url);
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : String(error);
|
||||
deps.log(`refreshMetrics failed for ${widget.id}: ${lastError}`);
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(payload)) {
|
||||
deps.log(`refreshMetrics got a non-array payload for ${widget.id}`);
|
||||
continue;
|
||||
}
|
||||
for (const raw of payload as MetricSample[]) {
|
||||
if (typeof raw.value !== 'number' || Number.isNaN(raw.value)) continue;
|
||||
if (typeof raw.at !== 'number' || raw.at <= 0) continue;
|
||||
collected.push({
|
||||
widgetId: widget.id,
|
||||
at: raw.at,
|
||||
value: raw.value,
|
||||
unit: raw.unit ?? 'count',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const cutoff = deps.now() - windowMs;
|
||||
const kept = samples.filter((s) => s.at >= cutoff);
|
||||
samples = kept.concat(collected);
|
||||
pruneSamples(MAX_SAMPLES_PER_WIDGET);
|
||||
lastSyncedAt = deps.now();
|
||||
loading = false;
|
||||
notify();
|
||||
return samples;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the active filter set and recompute which widgets stay visible.
|
||||
* A widget survives when every filter matches one of its fields.
|
||||
*/
|
||||
function applyFilter(specs: readonly FilterSpec[]): Widget[] {
|
||||
activeFilters = specs.slice();
|
||||
if (activeFilters.length === 0) {
|
||||
widgets = widgets.map((w) => ({ ...w, hidden: false }));
|
||||
notify();
|
||||
return widgets;
|
||||
}
|
||||
|
||||
const matches = (widget: Widget, spec: FilterSpec): boolean => {
|
||||
const field = spec.field === 'title'
|
||||
? widget.title
|
||||
: spec.field === 'kind'
|
||||
? widget.kind
|
||||
: spec.field === 'column'
|
||||
? String(widget.column)
|
||||
: '';
|
||||
switch (spec.op) {
|
||||
case 'eq':
|
||||
return field.toLowerCase() === spec.value.toLowerCase();
|
||||
case 'contains':
|
||||
return field.toLowerCase().includes(spec.value.toLowerCase());
|
||||
case 'gt':
|
||||
return Number(field) > Number(spec.value);
|
||||
case 'lt':
|
||||
return Number(field) < Number(spec.value);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let hiddenCount = 0;
|
||||
widgets = widgets.map((widget) => {
|
||||
const visible = activeFilters.every((spec) => matches(widget, spec));
|
||||
if (!visible) hiddenCount += 1;
|
||||
return { ...widget, hidden: !visible };
|
||||
});
|
||||
deps.log(`applyFilter hid ${hiddenCount} of ${widgets.length} widgets`);
|
||||
notify();
|
||||
return widgets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current sample buffer as CSV, one row per sample, ordered by
|
||||
* widget then timestamp so a diff between two exports stays readable.
|
||||
*/
|
||||
function exportCsv(separator = ','): string {
|
||||
const header = ['widget', 'title', 'at', 'value', 'unit'].join(separator);
|
||||
if (samples.length === 0) return header;
|
||||
|
||||
const titles = new Map(widgets.map((w) => [w.id, w.title]));
|
||||
const ordered = samples.slice().sort((a, b) => {
|
||||
if (a.widgetId !== b.widgetId) return a.widgetId < b.widgetId ? -1 : 1;
|
||||
return a.at - b.at;
|
||||
});
|
||||
|
||||
const escape = (value: string): string => {
|
||||
if (!value.includes(separator) && !value.includes('"') && !value.includes('\n')) return value;
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
const rows = ordered.map((sample) => [
|
||||
escape(sample.widgetId),
|
||||
escape(titles.get(sample.widgetId) ?? '(unknown)'),
|
||||
String(sample.at),
|
||||
String(sample.value),
|
||||
escape(sample.unit),
|
||||
].join(separator));
|
||||
|
||||
return [header, ...rows].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack widgets back into a dense grid after a move or a hide, so the layout
|
||||
* never leaves a hole a user has to scroll past.
|
||||
*/
|
||||
function reconcileLayout(columnCount = COLUMN_COUNT): Widget[] {
|
||||
const visible = widgets.filter((w) => !w.hidden);
|
||||
const hidden = widgets.filter((w) => w.hidden);
|
||||
|
||||
const ordered = visible.slice().sort((a, b) => {
|
||||
if (a.row !== b.row) return a.row - b.row;
|
||||
return a.column - b.column;
|
||||
});
|
||||
|
||||
const rowWidth = new Map<number, number>();
|
||||
const placed: Widget[] = [];
|
||||
for (const widget of ordered) {
|
||||
const span = Math.max(1, Math.min(widget.span, columnCount));
|
||||
let row = 0;
|
||||
let column = 0;
|
||||
for (;;) {
|
||||
const used = rowWidth.get(row) ?? 0;
|
||||
if (used + span <= columnCount) {
|
||||
column = used;
|
||||
rowWidth.set(row, used + span);
|
||||
break;
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
placed.push({ ...widget, row, column, span });
|
||||
}
|
||||
|
||||
let trailing = placed.length > 0 ? Math.max(...placed.map((w) => w.row)) + 1 : 0;
|
||||
for (const widget of hidden) {
|
||||
placed.push({ ...widget, row: trailing, column: 0 });
|
||||
trailing += 1;
|
||||
}
|
||||
|
||||
widgets = placed;
|
||||
notify();
|
||||
return widgets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cap the rolling buffer per widget, keeping the newest samples. Called after
|
||||
* every refresh so memory stays bounded on a long-lived dashboard.
|
||||
*/
|
||||
function pruneSamples(perWidget = MAX_SAMPLES_PER_WIDGET): number {
|
||||
if (samples.length === 0) return 0;
|
||||
const grouped = new Map<string, MetricSample[]>();
|
||||
for (const sample of samples) {
|
||||
const bucket = grouped.get(sample.widgetId);
|
||||
if (bucket) bucket.push(sample);
|
||||
else grouped.set(sample.widgetId, [sample]);
|
||||
}
|
||||
|
||||
let dropped = 0;
|
||||
const kept: MetricSample[] = [];
|
||||
for (const [, bucket] of grouped) {
|
||||
bucket.sort((a, b) => a.at - b.at);
|
||||
if (bucket.length > perWidget) {
|
||||
dropped += bucket.length - perWidget;
|
||||
kept.push(...bucket.slice(bucket.length - perWidget));
|
||||
} else {
|
||||
kept.push(...bucket);
|
||||
}
|
||||
}
|
||||
|
||||
kept.sort((a, b) => a.at - b.at);
|
||||
samples = kept;
|
||||
if (dropped > 0) deps.log(`pruneSamples dropped ${dropped} samples`);
|
||||
return dropped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce the buffer to one aggregate per widget — the numbers the summary
|
||||
* strip at the top of the dashboard renders.
|
||||
*/
|
||||
function summarize(): Array<{ widgetId: string; title: string; min: number; max: number; mean: number; count: number }> {
|
||||
const titles = new Map(widgets.map((w) => [w.id, w.title]));
|
||||
const grouped = new Map<string, MetricSample[]>();
|
||||
for (const sample of samples) {
|
||||
const bucket = grouped.get(sample.widgetId);
|
||||
if (bucket) bucket.push(sample);
|
||||
else grouped.set(sample.widgetId, [sample]);
|
||||
}
|
||||
|
||||
const out: Array<{ widgetId: string; title: string; min: number; max: number; mean: number; count: number }> = [];
|
||||
for (const [widgetId, bucket] of grouped) {
|
||||
let min = Number.POSITIVE_INFINITY;
|
||||
let max = Number.NEGATIVE_INFINITY;
|
||||
let total = 0;
|
||||
for (const sample of bucket) {
|
||||
if (sample.value < min) min = sample.value;
|
||||
if (sample.value > max) max = sample.value;
|
||||
total += sample.value;
|
||||
}
|
||||
out.push({
|
||||
widgetId,
|
||||
title: titles.get(widgetId) ?? '(unknown)',
|
||||
min: bucket.length > 0 ? min : 0,
|
||||
max: bucket.length > 0 ? max : 0,
|
||||
mean: bucket.length > 0 ? total / bucket.length : 0,
|
||||
count: bucket.length,
|
||||
});
|
||||
}
|
||||
|
||||
out.sort((a, b) => b.count - a.count || (a.title < b.title ? -1 : 1));
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Register a listener and get an unsubscribe back. */
|
||||
function subscribe(listener: (snapshot: ReturnType<typeof snapshot>) => void): () => void {
|
||||
listeners.add(listener);
|
||||
listener(snapshot());
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
const current = snapshot();
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(current);
|
||||
} catch (error) {
|
||||
deps.log(`dashboard listener threw: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop every sample and widget — used when the user switches dashboards. */
|
||||
function reset(): void {
|
||||
widgets = [];
|
||||
samples = [];
|
||||
activeFilters = [];
|
||||
lastSyncedAt = 0;
|
||||
lastError = null;
|
||||
loading = false;
|
||||
notify();
|
||||
}
|
||||
|
||||
return {
|
||||
loadWidgets,
|
||||
refreshMetrics,
|
||||
applyFilter,
|
||||
exportCsv,
|
||||
reconcileLayout,
|
||||
pruneSamples,
|
||||
summarize,
|
||||
subscribe,
|
||||
reset,
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export type DashboardStore = ReturnType<typeof createDashboardStore>;
|
||||
|
||||
/** One-line description of a store's state, for the debug panel. */
|
||||
export function describeStore(store: DashboardStore): string {
|
||||
const state = store.snapshot();
|
||||
return `${state.widgets.length} widgets · ${state.sampleCount} samples · synced ${state.lastSyncedAt}`;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export interface Widget {
|
||||
id: string;
|
||||
kind: 'chart' | 'table' | 'stat';
|
||||
title: string;
|
||||
column: number;
|
||||
row: number;
|
||||
span: number;
|
||||
hidden: boolean;
|
||||
}
|
||||
|
||||
export interface MetricSample {
|
||||
widgetId: string;
|
||||
at: number;
|
||||
value: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface FilterSpec {
|
||||
field: string;
|
||||
op: 'eq' | 'gt' | 'lt' | 'contains';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface StoreDeps {
|
||||
fetchJson: (url: string) => Promise<unknown>;
|
||||
now: () => number;
|
||||
log: (message: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { DashboardStore } from '../stores/dashboard-store';
|
||||
import type { FilterSpec } from '../stores/types';
|
||||
import { medianOf } from '../lib/metrics';
|
||||
|
||||
/** The dashboard panel — the only consumer of the store's closures. */
|
||||
export function mountPanel(store: DashboardStore, dashboardId: string) {
|
||||
let disposed = false;
|
||||
|
||||
const unsubscribe = store.subscribe((state) => {
|
||||
if (disposed) return;
|
||||
render(state.widgets.length, state.sampleCount, state.loading);
|
||||
});
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
await store.loadWidgets(dashboardId);
|
||||
await store.refreshMetrics();
|
||||
store.reconcileLayout();
|
||||
}
|
||||
|
||||
function search(text: string): void {
|
||||
const specs: FilterSpec[] = text.trim().length === 0
|
||||
? []
|
||||
: [{ field: 'title', op: 'contains', value: text.trim() }];
|
||||
store.applyFilter(specs);
|
||||
}
|
||||
|
||||
function download(): string {
|
||||
return store.exportCsv();
|
||||
}
|
||||
|
||||
function render(widgetCount: number, sampleCount: number, loading: boolean): void {
|
||||
void widgetCount;
|
||||
void sampleCount;
|
||||
void loading;
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
disposed = true;
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
return { boot, search, download, dispose, median: medianOf };
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* CG-27 measurement probe — what a factory-closure file actually delivers.
|
||||
*
|
||||
* `probe-allocation.mjs` measures how the envelope is split BETWEEN files. This
|
||||
* one measures what comes back from WITHIN one file whose top-level symbol spans
|
||||
* almost all of it: a `createFoo()` factory returning an object of closures
|
||||
* (Svelte 5 rune stores, React hook modules, Zustand `create((set,get)=>({…}))`,
|
||||
* IIFE module-pattern JS). The claim under test is a ranking one, not a byte one
|
||||
* — CG-30 already bounds the bytes — so the number that matters is WHICH inner
|
||||
* symbols reach the agent, not how many chars did.
|
||||
*
|
||||
* Prints, for the factory file: every line range the response delivered, and for
|
||||
* each inner function whether its DEFINITION LINE is inside one of them.
|
||||
*
|
||||
* Usage (needs a current `npm run build`):
|
||||
* node scripts/agent-eval/probe-factory-closure.mjs
|
||||
* node scripts/agent-eval/probe-factory-closure.mjs --json
|
||||
* node scripts/agent-eval/probe-factory-closure.mjs --query "..."
|
||||
*/
|
||||
import { cpSync, mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = resolve(HERE, '../..');
|
||||
const FIXTURE = join(REPO_ROOT, '__tests__/fixtures/factory-closure-ts');
|
||||
const TARGET = 'src/stores/dashboard-store.ts';
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const asJson = argv.includes('--json');
|
||||
const queryAt = argv.indexOf('--query');
|
||||
const QUERY = queryAt >= 0
|
||||
? argv[queryAt + 1]
|
||||
: 'how does the dashboard store refresh its metrics and apply a filter';
|
||||
|
||||
const say = (s = '') => { if (!asJson) console.log(s); };
|
||||
const num = (n) => Math.round(n).toLocaleString('en-US');
|
||||
|
||||
const load = (rel) => import(pathToFileURL(resolve(REPO_ROOT, rel)).href);
|
||||
if (!existsSync(join(REPO_ROOT, 'dist/index.js'))) {
|
||||
console.error('dist/ not built — run `npm run build` first.');
|
||||
process.exit(2);
|
||||
}
|
||||
const idxMod = await load('dist/index.js');
|
||||
const toolsMod = await load('dist/mcp/tools.js');
|
||||
const CodeGraph = idxMod.default?.default ?? idxMod.default ?? idxMod.CodeGraph;
|
||||
const ToolHandler = toolsMod.ToolHandler ?? toolsMod.default?.ToolHandler;
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cg-factory-'));
|
||||
cpSync(FIXTURE, dir, { recursive: true });
|
||||
rmSync(join(dir, '.codegraph'), { recursive: true, force: true });
|
||||
|
||||
let out;
|
||||
try {
|
||||
let cg = CodeGraph.initSync(dir);
|
||||
await cg.indexAll();
|
||||
|
||||
// Inner function definitions, straight from the index — the symbols the file's
|
||||
// enclosing factory range would otherwise swallow.
|
||||
const nodes = cg.getNodesInFile(TARGET);
|
||||
const factory = nodes.find((n) => n.name === 'createDashboardStore');
|
||||
const inner = nodes
|
||||
.filter((n) => (n.kind === 'function' || n.kind === 'method')
|
||||
&& n.name !== 'createDashboardStore'
|
||||
&& factory && n.startLine > factory.startLine && n.endLine <= factory.endLine)
|
||||
.sort((a, b) => a.startLine - b.startLine);
|
||||
cg.close?.();
|
||||
|
||||
const sidecar = join(dir, 'diag.jsonl');
|
||||
process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
|
||||
cg = CodeGraph.openSync(dir);
|
||||
const res = await new ToolHandler(cg).execute('codegraph_explore', { query: QUERY });
|
||||
const text = res.content?.[0]?.text ?? '';
|
||||
cg.close?.();
|
||||
delete process.env.CODEGRAPH_EXPLORE_DEBUG;
|
||||
const report = JSON.parse(readFileSync(sidecar, 'utf8').trim().split('\n').pop());
|
||||
|
||||
// Which source lines of the target file the response actually carries. The
|
||||
// response numbers every delivered line `<n>\t<text>`; match them back against
|
||||
// the file so a line number that merely appears in prose can't count.
|
||||
const source = readFileSync(join(dir, TARGET), 'utf8').split('\n');
|
||||
const delivered = new Set();
|
||||
for (const line of text.split('\n')) {
|
||||
const m = /^(\d+)\t(.*)$/.exec(line);
|
||||
if (!m) continue;
|
||||
const n = Number(m[1]);
|
||||
if (n >= 1 && n <= source.length && source[n - 1] === m[2]) delivered.add(n);
|
||||
}
|
||||
// Collapse to ranges for display.
|
||||
const ranges = [];
|
||||
for (const n of [...delivered].sort((a, b) => a - b)) {
|
||||
const last = ranges[ranges.length - 1];
|
||||
if (last && n === last.end + 1) last.end = n;
|
||||
else ranges.push({ start: n, end: n });
|
||||
}
|
||||
|
||||
const covered = (n) => delivered.has(n.startLine);
|
||||
const rec = report.files.find((f) => f.path === TARGET) ?? null;
|
||||
|
||||
out = {
|
||||
query: QUERY,
|
||||
target: TARGET,
|
||||
fileLines: source.length,
|
||||
factory: factory ? { name: factory.name, start: factory.startLine, end: factory.endLine } : null,
|
||||
file: rec && {
|
||||
rank: rec.rank, render: rec.render, clipped: rec.clipped,
|
||||
emittedChars: rec.emittedChars, finalChars: rec.finalChars,
|
||||
allowance: rec.allowance, spendable: rec.spendable, skipped: rec.skipped,
|
||||
},
|
||||
deliveredRanges: ranges,
|
||||
deliveredLines: delivered.size,
|
||||
inner: inner.map((n) => ({ name: n.name, start: n.startLine, end: n.endLine, delivered: covered(n) })),
|
||||
innerDelivered: inner.filter(covered).length,
|
||||
innerTotal: inner.length,
|
||||
envelope: report.envelope,
|
||||
allFiles: report.files
|
||||
.filter((f) => f.emittedChars > 0 || f.finalChars > 0)
|
||||
.map((f) => ({ rank: f.rank, path: f.path, render: f.render, emitted: f.emittedChars, final: f.finalChars })),
|
||||
};
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify(out, null, 2));
|
||||
} else {
|
||||
say(`query "${out.query}"`);
|
||||
say(`target ${out.target} — ${out.fileLines} lines, factory ${out.factory?.name} spans ${out.factory?.start}–${out.factory?.end}`);
|
||||
say('');
|
||||
say(' # render emitted final file');
|
||||
for (const f of out.allFiles) {
|
||||
say(` ${String(f.rank).padStart(2)} ${(f.render ?? '-').padEnd(10)} ${num(f.emitted).padStart(7)} ${num(f.final).padStart(7)} ${f.path}`);
|
||||
}
|
||||
say('');
|
||||
say(`delivered lines of ${out.target}: ${out.deliveredLines}`);
|
||||
say(` ranges: ${out.deliveredRanges.map((r) => `${r.start}-${r.end}`).join(', ') || '(none)'}`);
|
||||
say('');
|
||||
say(`inner symbols whose definition reached the agent: ${out.innerDelivered}/${out.innerTotal}`);
|
||||
for (const n of out.inner) {
|
||||
say(` ${n.delivered ? '✓' : '·'} ${n.name} (${n.start}–${n.end})`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user