/**
* @module routes/healing
* @description Self-healing telemetry routes (CAP-004). Mounted at `/api/v1`
* behind `requireAuth` + `workspaceScope`, so every handler runs with
* `req.workspaceId` already populated.
*
* ### Endpoints
* | Method | Path | Description |
* |--------|-----------------------------|--------------------------------------------------------------|
* | `GET` | `/api/v1/healing/summary` | Workspace-wide self-healing summary for the `/healing` page. |
*
* The summary endpoint aggregates `healingRepo` rows scoped to the requesting
* workspace via `getByTestIds()` (SQL-level `key LIKE` filter — never loads
* other workspaces' rows into memory) and merges the `healing.savings`
* `metric_samples` series across all of the workspace's projects by
* timestamp so the `<TrendChart>` reflects whole-workspace savings, not a
* single project.
*/
import { Router } from "express";
import * as testRepo from "../database/repositories/testRepo.js";
import * as projectRepo from "../database/repositories/projectRepo.js";
import * as healingRepo from "../database/repositories/healingRepo.js";
import * as metricSamplesRepo from "../database/repositories/metricSamplesRepo.js";
import { requireRole } from "../middleware/requireRole.js";
const router = Router();
/**
* GET /api/v1/healing/summary
*
* Returns a workspace-scoped self-healing summary used by the `/healing`
* dashboard (`frontend/src/pages/HealingDashboard.jsx`).
*
* Response shape:
* {
* strategies: [{ strategyIndex, total, successes, successRate }],
* topSelectors: [{ selector, healCount, totalCount }], // sorted DESC by healCount, capped at 10
* estimates: { testsThatWouldHaveFailed: number }, // count of rows with strategyIndex > 0
* savingsTrend: [{ ts, value }] // merged across all workspace projects by timestamp
* }
*
* Healing keys are formatted `<testId>::<action>::<label>` (see
* `selfHealing.js:48`); selector aggregation preserves both `action` and
* `label` so e.g. `click::Save` and `fill::Save` stay distinct in the
* "top healed selectors" list.
*/
router.get("/healing/summary", requireRole("viewer"), (req, res) => {
const projectIds = projectRepo.getAll(req.workspaceId).map((p) => p.id);
const tests = testRepo.getAllByProjectIds(projectIds);
const testIds = tests.map((t) => t.id);
// Workspace-scoped at the SQL layer — `getByTestIds` filters via `key LIKE`
// patterns so we never pull other workspaces' healing rows into memory.
// Replaces the old `getAllAsDict()` + JS filter, which scaled with total
// system data rather than the requesting workspace's data.
//
// Rows arrive ordered `strategyVersion ASC NULLS FIRST` (legacy unversioned
// first, then v1, v2, …). Deduplicate via "later-row-wins" Map.set keyed on
// `<baseTestId>::<action>::<label>` so a versioned entry overrides any
// legacy unversioned row for the same tuple — mirrors the dict-overwrite
// pattern in `healingRepo.getByTestId`. Without this, workspaces upgraded
// from pre-versioned scopes would double-count strategy totals,
// `wouldFail`, and selector heal/totalCounts.
const rawRows = healingRepo.getByTestIds(testIds);
const dedup = new Map();
for (const r of rawRows) {
const sepIdx = String(r.key).indexOf("::");
if (sepIdx < 0) continue;
const rawTestId = r.key.slice(0, sepIdx);
const baseTestId = rawTestId.replace(/@v\d+$/, "");
const suffix = r.key.slice(sepIdx + 2); // "<action>::<label>"
dedup.set(`${baseTestId}::${suffix}`, r);
}
const rows = [...dedup.values()];
const byStrategy = new Map();
const selectorAgg = new Map(); // selector → { selector, healCount, totalCount }
let wouldFail = 0;
for (const r of rows) {
const key = r.strategyIndex >= 0 ? String(r.strategyIndex) : "failed";
const prev = byStrategy.get(key) || { strategyIndex: r.strategyIndex, total: 0, successes: 0 };
prev.total += 1;
if (r.strategyIndex >= 0 && r.succeededAt) prev.successes += 1;
byStrategy.set(key, prev);
if (r.strategyIndex > 0) wouldFail += 1;
// Healing keys are formatted "<testId>::<action>::<label>" (see
// selfHealing.js:48). The selector aggregation should preserve both
// `action` and `label` so different actions on identically-labelled
// elements (e.g. `click::Save` vs `fill::Save`) stay distinct in the
// dashboard's "top healed selectors" list. Using `slice(1)` keeps the
// last two segments; `slice(2)` would drop the action and merge them.
const parts = String(r.key).split("::");
const selector = parts.slice(1).join("::") || "unknown";
const agg = selectorAgg.get(selector) || { selector, healCount: 0, totalCount: 0 };
agg.totalCount += 1;
if (r.strategyIndex > 0 && r.succeededAt) agg.healCount += 1;
selectorAgg.set(selector, agg);
}
// Sort by actual heal count, not by boolean cast.
const topSelectors = [...selectorAgg.values()]
.filter((s) => s.healCount > 0)
.sort((a, b) => b.healCount - a.healCount)
.slice(0, 10);
// Aggregate savings trend across ALL workspace projects, merging by timestamp.
const merged = new Map();
for (const pid of projectIds) {
const series = metricSamplesRepo.getSeries(pid, "healing.savings", { limit: 90 });
for (const s of series) {
const cur = merged.get(s.ts) || { ts: s.ts, value: 0 };
cur.value += Number(s.value || 0);
merged.set(s.ts, cur);
}
}
const savingsTrend = [...merged.values()].sort((a, b) => a.ts - b.ts);
res.json({
strategies: [...byStrategy.values()].map((s) => ({
...s,
successRate: s.total ? s.successes / s.total : 0,
})),
topSelectors,
estimates: { testsThatWouldHaveFailed: wouldFail },
savingsTrend,
});
});
export default router;