kb/ is its own wiki root: 34 pages across 7 categories, index.md catalog, log.md, AGENTS.md schema layer, and raw/ holding copies of README, the spec, the 13-task plan, the design master and the SDD ledger. Every page cross-references 3 to 6 others and all 107 wikilinks resolve except two [["list"]] artefacts inside JSON snippets in the copied plan. Kept separate from the repository root on purpose: its generated graph lands in kb/.ua/ instead of overwriting the code graph. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2413 lines
130 KiB
Markdown
2413 lines
130 KiB
Markdown
# SQL Server qAI Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** A containerised portal where a DBA pastes a SQL Server ShowPlan XML, gets 2–4 ranked remediation options plus SQL rewrites, with Jev (TypeSafe System One) scoring every option and choosing the first one to run, rendered as streamed OpenUI Lang elements.
|
||
|
||
**Architecture:** One Next.js process, one SQLite file. The pipeline is deterministic-first: code parses the plan XML into a bounded digest, the user's OpenAI-compatible **analyst** model proposes candidates as JSON, **Jev** answers typed `score`/`choice`/`noul` questions about each candidate, code combines them with visible weights, then the analyst streams the final report in OpenUI Lang which `<Renderer>` paints as it arrives. Reopening a history item replays the stored report — zero model calls.
|
||
|
||
**Tech Stack:** Next.js 16 (App Router) + React 19 + TypeScript, Tailwind v4 (layout utilities only), `node:sqlite`, `node:crypto`, `fast-xml-parser`, `zod/v4`, `@openuidev/react-lang`, `@typesafe-ai/sdk`, `lucide-react`. Tests with `node --test` on `.ts` files — Node strips types natively, so there is no test framework to install.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-09-21-sql-server-qai.md`
|
||
|
||
## What the user sees
|
||
|
||
Written for the implementer and for anyone reviewing whether this is the product that was asked for. No code in this section.
|
||
|
||
- **Sign up** with email + password. Nothing else exists before that; there is no public analysis page.
|
||
- **First-run guide, 3 steps.** Step 1 explains what the tool does. Step 2 is one form: the analyst model (base URL, key, model name, extra JSON params) and the Jev key. Step 3 confirms both models are reachable and sends them to the console. Every credential is typed by the logged-in user in this panel or in Settings — none is read from server config, so a second user brings their own keys and their own spend.
|
||
- **Settings** holds the same form plus email/password change. Stored keys show as `••••1234`; leaving a key blank keeps the stored one.
|
||
- **Console**: left rail is the history (rename inline, delete behind a confirm); the main pane is one job — paste ShowPlan XML or drop a `.sqlplan` file, optionally add a note, press Analyse. A "Where do I get this XML?` disclosure gives the SSMS route (Ctrl+M → run → save the `.sqlplan`) and the `SET STATISTICS XML ON` route for people without SSMS.
|
||
- **During a run** four stages are named one at a time: reading the plan, asking for options, Jev is scoring, writing the report. The report then paints while it is still streaming.
|
||
- **The report** is OpenUI elements, never a markdown blob: the finding, Jev's first move with its confidence, plan metrics, the findings with plan evidence, a ranked options table with the weights printed, each option's action steps and any rewritten SQL, per-dimension Jev score bars, and a danger callout whenever an option could change results.
|
||
- **Copy** puts the SQL (or the whole report source when there is no SQL) on the clipboard, per block and for the whole report. **Export to PDF** is the browser print path with a paper stylesheet — the dark console flips to black-on-white and the rail and buttons disappear.
|
||
- **Nothing leaves the container** except the plan digest — top operators, missing indexes, warnings, waits, the statement text — sent to the user's own analyst endpoint and to TypeSafe. Raw XML stays in the local SQLite file.
|
||
- **Failures are named where they happen**: not-a-plan, analyst 401/prose/too-few-options, Jev rate-limited (composite order, labelled as such), empty report. A partially stored run still opens from history and shows what it managed to produce.
|
||
|
||
## Global Constraints
|
||
|
||
- Node ≥ 24 (Docker base `node:24-slim`). Verified on this machine: `node --test x.test.ts` runs TypeScript directly; `node:sqlite` `DatabaseSync` works without a flag.
|
||
- Allowed runtime deps, nothing else without a one-line reason in the commit message: `next react react-dom zod @openuidev/react-lang @typesafe-ai/sdk fast-xml-parser lucide-react`.
|
||
- No ORM. No auth library. No PDF library. No queue/worker. No OpenAI SDK — raw `fetch` to `/chat/completions`.
|
||
- Model + Jev credentials are per-user, AES-256-GCM encrypted at rest with `APP_SECRET`, never sent to the browser (masked `••••1234`).
|
||
- Jev is `@typesafe-ai/sdk` → `POST /v1/systemone`, model `jev-latest`. Jev never generates prose; it returns probabilities. Combining happens in code.
|
||
- Analyst is any OpenAI-compatible base URL + key + model + extra params, from Settings.
|
||
- All colour and type flows through named tokens (`var(--qai-*)`) — Hallmark gate 48: no inline hex/oklch inside components.
|
||
- Dark-only console (OLED slate + phosphor green). Print/PDF flips the same tokens to paper white.
|
||
- Copy = clipboard. PDF = `window.print()` + print stylesheet. No generated-file endpoints.
|
||
- Mobile: 320 / 375 / 414 / 768 px, no horizontal scroll, `overflow-x: clip` on `html` and `body`.
|
||
- Delete confirms first. Every mutation shows a visible result.
|
||
|
||
## Design System (locked — Hallmark + ui-ux-pro-max)
|
||
|
||
Generated with `ui-ux-pro-max --design-system --variance 4 --motion 3 --density 8`; persisted at `design-system/sql-server-qai/MASTER.md`. Read that file before any UI task. Page overrides go in `design-system/sql-server-qai/pages/<page>.md`.
|
||
|
||
- Genre **atmospheric** · theme **Terminal** (mono display, phosphor accent) · macrostructure **Workbench** (app-console variant; auth/setup use **Long Document**) · nav **N3 side-rail** — the history rail *is* the nav · footer **Ft4 dense colophon** on auth/setup/settings only; the console has no footer.
|
||
- Type: JetBrains Mono for display/data/labels, IBM Plex Sans for prose. No italic headings, no gradient text, no glassmorphism, no emoji icons (lucide-react SVG only).
|
||
- Density 8/10: 4 px scale, 15 px body, 12 px floor.
|
||
- Motion 3/10: fade + 120–220 ms `--qai-ease-out` only. `prefers-reduced-motion` → instant.
|
||
- Every UI task: keep values in `tokens.css`, reference tokens by name, keep the Hallmark stamp at the top of `app/ui.css`, append the run to `.hallmark/log.json`.
|
||
|
||
## File Structure
|
||
|
||
```
|
||
tokens.css all colour/type/space/easing tokens (single source)
|
||
app/globals.css tailwind import + tokens.css + ui.css
|
||
app/ui.css every component class (Hallmark stamped)
|
||
app/layout.tsx fonts + <html>
|
||
app/page.tsx redirect → /app or /login
|
||
app/(auth)/layout.tsx centred auth shell + Ft4 colophon
|
||
app/(auth)/login/page.tsx login
|
||
app/(auth)/signup/page.tsx signup
|
||
app/setup/page.tsx first-run guide, 3 steps
|
||
app/settings/page.tsx model / Jev / account / history
|
||
app/app/layout.tsx console shell: side rail + main
|
||
app/app/page.tsx run surface
|
||
app/app/[id]/page.tsx replay a stored report (no model calls)
|
||
app/api/analyze/route.ts SSE pipeline: digest → candidates → Jev → report
|
||
app/api/healthz/route.ts "ok"
|
||
lib/db.ts node:sqlite + schema + all queries
|
||
lib/auth.ts scrypt, sessions, requireUser
|
||
lib/vault.ts AES-256-GCM seal/open/mask
|
||
lib/settings.ts settings row <-> typed config, encrypt on write
|
||
lib/digest.ts ShowPlan XML -> Digest (pure, no I/O)
|
||
lib/analyst.ts candidates + report stream (OpenAI-compatible)
|
||
lib/jev.ts Jev questions, composite weights, verdict
|
||
lib/report-ui.tsx OpenUI component library + prompt
|
||
lib/stream.ts SSE encode/decode + OpenAI delta extractor (pure)
|
||
lib/actions.ts server actions: signup/login/logout/rename/delete/settings
|
||
components/*.tsx auth-form, settings-form, runner, rail, report-view
|
||
scripts/jev-gate.mjs asks Jev yes/no: does this plan match the goal?
|
||
scripts/smoke.sh container smoke
|
||
tests/*.test.ts node:test, no framework
|
||
Dockerfile docker-compose.yml .env.example README.md
|
||
```
|
||
|
||
Pure logic lives in `lib/*.ts` so it is testable without Next. Pages and components get build + manual checks.
|
||
|
||
## Review Focus
|
||
|
||
None of these is exercised by the spec's happy path. Each is pinned to a test in the named task.
|
||
|
||
1. **Not a plan.** Pasted a `.sql` script, truncated XML, or a namespaced `.sqlplan` → inline error naming what was found; never an endless spinner. *(Task 6 + Task 10)*
|
||
2. **Analyst misconfig.** Wrong base URL, 401, prose instead of JSON, or 1 candidate instead of 2–4 → error names the stage and the HTTP status; no empty report renders. *(Task 7)*
|
||
3. **Semantically unsafe rewrite.** A `SELECT TOP 100 …` that drops a predicate must never be the safe recommendation — Jev's `semantic_safety` under 0.6 forces a visible "verify results first" flag and keeps it out of the headline slot. *(Task 8)*
|
||
4. **Huge plans.** 5 MB+ XML with thousands of operators → hard 12 MB cap, digest bounded to the top operators, report says it analysed a digest. No hang, no OOM, no full-XML prompt. *(Task 6 + Task 10)*
|
||
5. **Partial failure mid-run.** Jev 429/529 or timeout after candidates exist → composite-only ranking with a `jev_unavailable` flag; the row is saved with what it has; reopening it never re-calls a model. *(Task 8 + Task 10)*
|
||
|
||
---
|
||
|
||
### Task 1: Scaffold, tokens, console shell
|
||
|
||
**Files:** create via `create-next-app` in `.` (keep existing `docs/`, `design-system/`); create `tokens.css`, `app/globals.css`, `app/ui.css`, `app/layout.tsx`, `app/page.tsx`, `app/app/layout.tsx`, `app/app/page.tsx`, `app/api/healthz/route.ts`, `.env.example`, `.hallmark/log.json`; modify `package.json`, `.gitignore`.
|
||
|
||
**Interfaces — produces:** tokens `--qai-{paper,paper-2,paper-3,ink,ink-muted,accent,on-accent,warn,danger,info,rule,focus}`, `--qai-font-{display,body}`, `--qai-space-{xs,s,md,l,xl,2xl,3xl}`, `--qai-text-{xs,sm,base,lg,xl,2xl,display}`, `--qai-ease-out`, `--qai-dur-{1,2}`, `--qai-radius`. Classes `.shell .rail .main .card .btn .btn-primary .field .label .pill .colophon .report .sql .bar .grid-metrics .runner .toolbar`.
|
||
|
||
- [ ] **Step 1: Scaffold and install**
|
||
|
||
```bash
|
||
npx create-next-app@latest . --ts --tailwind --eslint --app --no-src-dir --import-alias "@/*" --use-npm
|
||
npm i zod @openuidev/react-lang @typesafe-ai/sdk fast-xml-parser lucide-react
|
||
npm pkg set scripts.test="node --no-warnings --test tests/*.test.ts"
|
||
git init -q && git add -A && git commit -qm "scaffold: next 16 + tailwind"
|
||
```
|
||
|
||
- [ ] **Step 2: `tokens.css`**
|
||
|
||
```css
|
||
/* Hallmark · genre: atmospheric · macrostructure: Workbench · theme: Terminal
|
||
* enrichment: none (typography only) · nav: N3 side-rail · footer: Ft4 dense colophon
|
||
* inferred: audience = DBAs/consultants · use = analyse one plan · tone = technical-utilitarian
|
||
* axes: paper band dark (L<30%) · display mono · accent phosphor green
|
||
*/
|
||
:root {
|
||
--qai-paper: oklch(17% 0.014 255);
|
||
--qai-paper-2: oklch(21% 0.015 258);
|
||
--qai-paper-3: oklch(26% 0.016 260);
|
||
--qai-ink: oklch(96% 0.004 250);
|
||
--qai-ink-muted: oklch(72% 0.02 250);
|
||
--qai-accent: oklch(80% 0.19 152);
|
||
--qai-on-accent: oklch(20% 0.04 152);
|
||
--qai-warn: oklch(82% 0.15 86);
|
||
--qai-danger: oklch(66% 0.21 25);
|
||
--qai-info: oklch(77% 0.11 232);
|
||
--qai-rule: oklch(31% 0.016 258);
|
||
--qai-focus: var(--qai-accent);
|
||
--qai-font-display: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
||
--qai-font-body: "IBM Plex Sans", system-ui, sans-serif;
|
||
--qai-space-xs: 4px; --qai-space-s: 8px; --qai-space-md: 12px; --qai-space-l: 16px;
|
||
--qai-space-xl: 24px; --qai-space-2xl: 32px; --qai-space-3xl: 48px;
|
||
--qai-text-xs: 12px; --qai-text-sm: 13px; --qai-text-base: 15px; --qai-text-lg: 18px;
|
||
--qai-text-xl: 22px; --qai-text-2xl: 28px; --qai-text-display: 34px;
|
||
--qai-ease-out: cubic-bezier(0.2, 0.7, 0.2, 1);
|
||
--qai-dur-1: 120ms; --qai-dur-2: 220ms;
|
||
--qai-radius: 6px;
|
||
}
|
||
@media print {
|
||
:root { --qai-paper:#fff; --qai-paper-2:#f4f5f7; --qai-paper-3:#e9ebef; --qai-ink:#14161a;
|
||
--qai-ink-muted:#4a5160; --qai-rule:#c9ced8; --qai-accent:#0a7d3c; --qai-on-accent:#fff;
|
||
--qai-warn:#8a5a00; --qai-danger:#a11212; --qai-info:#1a4fb8; }
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: `app/globals.css` + `app/ui.css`**
|
||
|
||
`app/globals.css` — keep create-next-app's Tailwind directives at the top, then add:
|
||
|
||
```css
|
||
@import "./tokens.css";
|
||
@import "./ui.css";
|
||
|
||
html, body { overflow-x: clip; }
|
||
body { background: var(--qai-paper); color: var(--qai-ink); font-family: var(--qai-font-body);
|
||
font-size: var(--qai-text-base); line-height: 1.5; margin: 0; }
|
||
* { box-sizing: border-box; }
|
||
:focus-visible { outline: 2px solid var(--qai-focus); outline-offset: 2px; }
|
||
@media (prefers-reduced-motion: reduce) { * { animation-duration: .01ms !important; transition-duration: .01ms !important; } }
|
||
```
|
||
|
||
`app/ui.css` (starts with the stamp above — copy the stamp from `tokens.css` into it too):
|
||
|
||
```css
|
||
.shell { display: grid; grid-template-columns: 260px minmax(0,1fr); min-height: 100dvh; }
|
||
.rail { background: var(--qai-paper-2); border-inline-end: 1px solid var(--qai-rule);
|
||
padding: var(--qai-space-l); display: flex; flex-direction: column; gap: var(--qai-space-md);
|
||
font-family: var(--qai-font-display); min-width: 0; }
|
||
.main { min-width: 0; padding: var(--qai-space-2xl); display: flex; flex-direction: column; gap: var(--qai-space-xl); }
|
||
.card { background: var(--qai-paper-2); border-radius: var(--qai-radius); padding: var(--qai-space-l); min-width: 0; }
|
||
.btn { font: 500 var(--qai-text-sm)/1 var(--qai-font-display); padding: 10px 14px; border-radius: var(--qai-radius);
|
||
border: 1px solid var(--qai-rule); background: var(--qai-paper-3); color: var(--qai-ink); cursor: pointer;
|
||
transition: background var(--qai-dur-1) var(--qai-ease-out), transform var(--qai-dur-1) var(--qai-ease-out); }
|
||
.btn:hover { background: var(--qai-rule); } .btn:active { transform: translateY(1px); }
|
||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||
.btn-primary { background: var(--qai-accent); color: var(--qai-on-accent); border-color: transparent; }
|
||
.field { width: 100%; background: var(--qai-paper); color: var(--qai-ink); border: 1px solid var(--qai-rule);
|
||
border-radius: var(--qai-radius); padding: 10px 12px; font-family: var(--qai-font-display); font-size: var(--qai-text-sm); }
|
||
.label { font: 500 var(--qai-text-xs)/1 var(--qai-font-display); color: var(--qai-ink-muted);
|
||
text-transform: uppercase; letter-spacing: .04em; }
|
||
.pill { font: 500 var(--qai-text-xs)/1 var(--qai-font-display); padding: 4px 8px; border-radius: 999px;
|
||
border: 1px solid var(--qai-rule); color: var(--qai-ink-muted); }
|
||
.colophon { border-top: 1px solid var(--qai-rule); padding: var(--qai-space-l) 0;
|
||
font: 400 var(--qai-text-xs)/1.6 var(--qai-font-display); color: var(--qai-ink-muted); }
|
||
.report { display: flex; flex-direction: column; gap: var(--qai-space-l); min-width: 0; }
|
||
.grid-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: var(--qai-space-md); }
|
||
.sql { overflow-x: auto; font-family: var(--qai-font-display); font-size: var(--qai-text-sm); white-space: pre; }
|
||
.bar { height: 6px; border-radius: 999px; background: var(--qai-accent); max-width: 100%; }
|
||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
|
||
[data-level="danger"] { border-inline-start: 3px solid var(--qai-danger); }
|
||
[data-level="warn"] { border-inline-start: 3px solid var(--qai-warn); }
|
||
[data-verdict] { border-inline-start: 3px solid var(--qai-accent); }
|
||
@media (max-width: 860px) { .shell { grid-template-columns: minmax(0,1fr); } .rail { border-inline-end: 0; } }
|
||
@media print { .rail, .runner, .toolbar, .colophon, .no-print { display: none !important; }
|
||
.shell { grid-template-columns: minmax(0,1fr); } .main { padding: 0; } }
|
||
```
|
||
|
||
- [ ] **Step 4: Layout, shell, healthz**
|
||
|
||
`app/layout.tsx`:
|
||
|
||
```tsx
|
||
import type { Metadata, Viewport } from "next";
|
||
import { IBM_Plex_Sans, JetBrains_Mono } from "next/font/google";
|
||
import "./globals.css";
|
||
|
||
const body = IBM_Plex_Sans({ subsets: ["latin"], weight: ["400", "500", "600"], variable: "--font-body" });
|
||
const mono = JetBrains_Mono({ subsets: ["latin"], weight: ["400", "500", "700"], variable: "--font-mono" });
|
||
|
||
export const metadata: Metadata = { title: "SQL Server qAI", description: "Query plan triage, ranked by Jev." };
|
||
export const viewport: Viewport = { width: "device-width", initialScale: 1 };
|
||
|
||
export default function Root({ children }: { children: React.ReactNode }) {
|
||
return <html lang="en" className={`${body.variable} ${mono.variable}`}><body>{children}</body></html>;
|
||
}
|
||
```
|
||
|
||
`app/api/healthz/route.ts`:
|
||
|
||
```ts
|
||
export const dynamic = "force-dynamic";
|
||
export const GET = () => Response.json({ ok: true });
|
||
```
|
||
|
||
`app/page.tsx`:
|
||
|
||
```tsx
|
||
import { redirect } from "next/navigation";
|
||
import { sessionUser } from "@/lib/auth";
|
||
export default async function Home() { redirect((await sessionUser()) ? "/app" : "/login"); }
|
||
```
|
||
|
||
`app/app/layout.tsx` (rail content arrives in Task 11; the shell must render now):
|
||
|
||
```tsx
|
||
export default function Console({ children }: { children: React.ReactNode }) {
|
||
return <div className="shell">
|
||
<aside className="rail" aria-label="Previous analyses">qAI</aside>
|
||
<main className="main">{children}</main>
|
||
</div>;
|
||
}
|
||
```
|
||
|
||
`app/app/page.tsx`: an `<h1>New analysis</h1>` — replaced in Task 11.
|
||
|
||
- [ ] **Step 5: Verify + commit**
|
||
|
||
`npm run build && (npm run start & sleep 6; curl -s localhost:3000/api/healthz; kill %1)` → `{"ok":true}`.
|
||
Create `.hallmark/log.json`:
|
||
|
||
```json
|
||
[{ "date": "2026-09-21", "macrostructure": "Workbench", "theme": "Terminal", "enrichment": "none", "brief": "SQL Server qAI · plan triage console" }]
|
||
```
|
||
|
||
`.env.example`: `APP_SECRET=` and `DATA_DIR=/data`. Add `data/` and `.hallmark/` to `.gitignore` only if the repo already ignores build junk. Commit `feat: tokens, shell, healthz`.
|
||
|
||
---
|
||
|
||
### Task 2: Database layer
|
||
|
||
**Files:** create `lib/db.ts`, `tests/db.test.ts`.
|
||
|
||
**Interfaces — produces:** `db` (`DatabaseSync`) · `createUser(email, passHash): string` · `userByEmail(email): { id, pass } | null` · `userEmail(id): { email } | null` · `putSession(tokenHash, userId, expiresAt): void` · `sessionUserId(tokenHash): string | null` · `dropSession(tokenHash): void` · `getSettings(userId): SettingsRow | null` · `saveSettings(userId, SettingsInput): void` · `newAnalysis(userId, title, xml): string` · `patchAnalysis(id, Record<string,string>): void` · `listAnalyses(userId): { id, title, status, created_at }[]` · `getAnalysis(id, userId): Analysis | null` · `renameAnalysis(id, userId, title): void` · `deleteAnalysis(id, userId): void` · types `SettingsInput`, `Analysis`.
|
||
|
||
- [ ] **Step 1: Write the failing test** — `DATA_DIR` must be set before `lib/db.ts` is imported; the module opens the file at import time.
|
||
|
||
```ts
|
||
import test from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import { mkdtempSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
|
||
process.env.DATA_DIR = mkdtempSync(`${tmpdir()}/qai-`);
|
||
const q = await import("../lib/db.ts");
|
||
|
||
test("user, settings upsert, analysis lifecycle", () => {
|
||
const id = q.createUser("a@b.c", "salt:hash");
|
||
assert.equal(q.userByEmail("a@b.c")?.id, id);
|
||
assert.equal(q.userByEmail("nope@x.y"), null);
|
||
assert.equal(q.userEmail(id)?.email, "a@b.c");
|
||
|
||
const s = { analyst_base_url: "http://x/v1", analyst_key: "k1", analyst_model: "m",
|
||
analyst_extra: "{}", jev_key: "j", jev_model: "jev-latest", onboarded: 1 };
|
||
q.saveSettings(id, s);
|
||
q.saveSettings(id, { ...s, analyst_key: "k2" });
|
||
assert.equal(q.getSettings(id)?.analyst_key, "k2", "upsert not insert");
|
||
|
||
const aid = q.newAnalysis(id, "Orders scan", "<xml/>");
|
||
q.patchAnalysis(aid, { status: "done", oul: "root = Report([])", digest: "{}" });
|
||
assert.equal(q.listAnalyses(id)[0].title, "Orders scan");
|
||
assert.equal(q.getAnalysis(aid, id)?.status, "done");
|
||
q.renameAnalysis(aid, id, "Renamed");
|
||
assert.equal(q.getAnalysis(aid, id)?.title, "Renamed");
|
||
q.deleteAnalysis(aid, id);
|
||
assert.equal(q.getAnalysis(aid, id), null);
|
||
});
|
||
|
||
test("session resolves only while unexpired", () => {
|
||
const id = q.createUser("s@b.c", "h");
|
||
q.putSession("tok", id, Date.now() + 1000);
|
||
assert.equal(q.sessionUserId("tok"), id);
|
||
q.putSession("old", id, Date.now() - 1);
|
||
assert.equal(q.sessionUserId("old"), null);
|
||
q.dropSession("tok");
|
||
assert.equal(q.sessionUserId("tok"), null);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it** — `npm run test` → cannot find module `../lib/db.ts`.
|
||
|
||
- [ ] **Step 3: Implement `lib/db.ts`**
|
||
|
||
```ts
|
||
import { DatabaseSync } from "node:sqlite";
|
||
import { mkdirSync } from "node:fs";
|
||
import { randomUUID } from "node:crypto";
|
||
|
||
const dir = process.env.DATA_DIR ?? "data";
|
||
mkdirSync(dir, { recursive: true });
|
||
|
||
const g = globalThis as { __qaiDb?: DatabaseSync };
|
||
export const db = (g.__qaiDb ??= (() => {
|
||
const d = new DatabaseSync(`${dir}/qai.db`);
|
||
d.exec(`
|
||
PRAGMA journal_mode = WAL;
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, pass TEXT NOT NULL, created_at INTEGER NOT NULL);
|
||
CREATE TABLE IF NOT EXISTS sessions (
|
||
token_hash TEXT PRIMARY KEY, user_id TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL);
|
||
CREATE TABLE IF NOT EXISTS settings (
|
||
user_id TEXT PRIMARY KEY, analyst_base_url TEXT NOT NULL DEFAULT '', analyst_key TEXT NOT NULL DEFAULT '',
|
||
analyst_model TEXT NOT NULL DEFAULT '', analyst_extra TEXT NOT NULL DEFAULT '{}',
|
||
jev_key TEXT NOT NULL DEFAULT '', jev_model TEXT NOT NULL DEFAULT 'jev-latest',
|
||
onboarded INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL);
|
||
CREATE TABLE IF NOT EXISTS analyses (
|
||
id TEXT PRIMARY KEY, user_id TEXT NOT NULL, title TEXT NOT NULL, xml TEXT NOT NULL,
|
||
digest TEXT, candidates TEXT, verdict TEXT, oul TEXT,
|
||
status TEXT NOT NULL DEFAULT 'running', error TEXT,
|
||
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL);
|
||
CREATE INDEX IF NOT EXISTS ix_analyses_user ON analyses(user_id, created_at DESC);`);
|
||
return d;
|
||
})());
|
||
|
||
const now = () => Date.now();
|
||
const one = <T>(sql: string, ...p: unknown[]): T | null => (db.prepare(sql).get(...p) as T) ?? null;
|
||
// node:sqlite binds null/number/string/bigint/Buffer only: pass 0/1, never booleans.
|
||
|
||
export type SettingsInput = { analyst_base_url: string; analyst_key: string; analyst_model: string;
|
||
analyst_extra: string; jev_key: string; jev_model: string; onboarded: number };
|
||
export type Analysis = { id: string; title: string; xml: string; digest: string | null; candidates: string | null;
|
||
verdict: string | null; oul: string | null; status: string; error: string | null; created_at: number };
|
||
|
||
export function createUser(email: string, passHash: string): string {
|
||
const id = randomUUID();
|
||
db.prepare("INSERT INTO users (id, email, pass, created_at) VALUES (?,?,?,?)").run(id, email, passHash, now());
|
||
return id;
|
||
}
|
||
export const userByEmail = (email: string) =>
|
||
one<{ id: string; pass: string }>("SELECT id, pass FROM users WHERE email = ?", email);
|
||
export const userEmail = (id: string) => one<{ email: string }>("SELECT email FROM users WHERE id = ?", id);
|
||
|
||
export function putSession(tokenHash: string, userId: string, expiresAt: number) {
|
||
db.prepare("INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?,?,?,?)")
|
||
.run(tokenHash, userId, now(), expiresAt);
|
||
}
|
||
export function sessionUserId(tokenHash: string): string | null {
|
||
return one<{ user_id: string }>("SELECT user_id FROM sessions WHERE token_hash = ? AND expires_at > ?",
|
||
tokenHash, now())?.user_id ?? null;
|
||
}
|
||
export const dropSession = (tokenHash: string) =>
|
||
db.prepare("DELETE FROM sessions WHERE token_hash = ?").run(tokenHash);
|
||
|
||
export const getSettings = (userId: string) =>
|
||
one<SettingsInput & { onboarded: number }>("SELECT * FROM settings WHERE user_id = ?", userId);
|
||
|
||
export function saveSettings(userId: string, s: SettingsInput) {
|
||
db.prepare(`INSERT INTO settings (user_id, analyst_base_url, analyst_key, analyst_model, analyst_extra,
|
||
jev_key, jev_model, onboarded, updated_at) VALUES (?,?,?,?,?,?,?,?,?)
|
||
ON CONFLICT(user_id) DO UPDATE SET analyst_base_url=excluded.analyst_base_url, analyst_key=excluded.analyst_key,
|
||
analyst_model=excluded.analyst_model, analyst_extra=excluded.analyst_extra, jev_key=excluded.jev_key,
|
||
jev_model=excluded.jev_model, onboarded=excluded.onboarded, updated_at=excluded.updated_at`)
|
||
.run(userId, s.analyst_base_url, s.analyst_key, s.analyst_model, s.analyst_extra, s.jev_key, s.jev_model,
|
||
s.onboarded, now());
|
||
}
|
||
|
||
export function newAnalysis(userId: string, title: string, xml: string): string {
|
||
const id = randomUUID();
|
||
db.prepare("INSERT INTO analyses (id, user_id, title, xml, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?)")
|
||
.run(id, userId, title, xml, "running", now(), now());
|
||
return id;
|
||
}
|
||
export function patchAnalysis(id: string, p: Record<string, string>) {
|
||
const keys = Object.keys(p);
|
||
if (!keys.length) return;
|
||
db.prepare(`UPDATE analyses SET ${keys.map((k) => `${k} = ?`).join(", ")}, updated_at = ? WHERE id = ?`)
|
||
.run(...keys.map((k) => p[k]), now(), id);
|
||
}
|
||
export const listAnalyses = (userId: string) =>
|
||
db.prepare("SELECT id, title, status, created_at FROM analyses WHERE user_id = ? ORDER BY created_at DESC")
|
||
.all(userId) as { id: string; title: string; status: string; created_at: number }[];
|
||
export const getAnalysis = (id: string, userId: string) =>
|
||
one<Analysis>("SELECT * FROM analyses WHERE id = ? AND user_id = ?", id, userId);
|
||
export const renameAnalysis = (id: string, userId: string, title: string) =>
|
||
db.prepare("UPDATE analyses SET title = ?, updated_at = ? WHERE id = ? AND user_id = ?").run(title, now(), id, userId);
|
||
export const deleteAnalysis = (id: string, userId: string) =>
|
||
db.prepare("DELETE FROM analyses WHERE id = ? AND user_id = ?").run(id, userId);
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, commit** — `npm run test` → 2 pass. Commit `feat: sqlite layer via node:sqlite`.
|
||
|
||
---
|
||
|
||
### Task 3: Auth — signup, login, db-backed sessions
|
||
|
||
**Files:** create `lib/auth.ts`, `tests/auth.test.ts`, `lib/actions.ts`, `app/(auth)/layout.tsx`, `app/(auth)/login/page.tsx`, `app/(auth)/signup/page.tsx`, `components/auth-form.tsx`.
|
||
|
||
**Interfaces — consumes** Task 2 queries. **Produces:** `hashPassword(pw): string` · `verifyPassword(pw, stored): boolean` · `hashToken(t): string` · `emailOk(e)` · `passwordOk(p)` · `SESSION_COOKIE = "qai_s"` · `SESSION_DAYS = 30` · `startSession(userId)` · `endSession()` · `sessionUser(): { id, email } | null` · `requireUser(): { id, email }` (redirects to `/login`) · `signUpNewUser(email, password): { ok: true; id } | { ok: false; error }` · server actions `signUp`, `signIn`, `signOut` in `lib/actions.ts`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```ts
|
||
import test from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import { mkdtempSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
process.env.DATA_DIR = mkdtempSync(`${tmpdir()}/qai-auth-`);
|
||
const { hashPassword, verifyPassword, hashToken } = await import("../lib/auth.ts");
|
||
|
||
test("password hash round-trips and rejects", () => {
|
||
const h = hashPassword("correct horse battery staple");
|
||
assert.ok(h.includes(":"), "salt:hash shape");
|
||
assert.ok(verifyPassword("correct horse battery staple", h));
|
||
assert.ok(!verifyPassword("wrong", h));
|
||
});
|
||
test("malformed stored hash never verifies", () => {
|
||
for (const bad of ["", "nosep", "a:", ":b", "zz:zz"]) assert.ok(!verifyPassword("x", bad));
|
||
});
|
||
test("token hash is stable, 64 hex", () => {
|
||
assert.equal(hashToken("abc"), hashToken("abc"));
|
||
assert.match(hashToken("abc"), /^[0-9a-f]{64}$/);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it** — module not found.
|
||
|
||
- [ ] **Step 3: Implement `lib/auth.ts`**
|
||
|
||
```ts
|
||
import { cookies } from "next/headers";
|
||
import { redirect } from "next/navigation";
|
||
import { randomBytes, scryptSync, createHash, timingSafeEqual } from "node:crypto";
|
||
import { createUser, userByEmail, userEmail, putSession, sessionUserId, dropSession, getSettings } from "@/lib/db";
|
||
|
||
export const SESSION_COOKIE = "qai_s";
|
||
export const SESSION_DAYS = 30;
|
||
export const emailOk = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e);
|
||
export const passwordOk = (p: string) => p.length >= 10; // trust boundary: signup + password change only
|
||
|
||
export function hashPassword(pw: string): string {
|
||
const salt = randomBytes(16);
|
||
return `${salt.toString("hex")}:${scryptSync(pw, salt, 64).toString("hex")}`;
|
||
}
|
||
export function verifyPassword(pw: string, stored: string): boolean {
|
||
const [salt, hash] = stored.split(":");
|
||
if (!salt || !hash) return false;
|
||
try {
|
||
const a = scryptSync(pw, Buffer.from(salt, "hex"), 64);
|
||
const b = Buffer.from(hash, "hex");
|
||
return a.length === b.length && timingSafeEqual(a, b);
|
||
} catch { return false; }
|
||
}
|
||
export const hashToken = (t: string) => createHash("sha256").update(t).digest("hex");
|
||
|
||
export function signUpNewUser(email: string, password: string): { ok: true; id: string } | { ok: false; error: string } {
|
||
const e = email.trim().toLowerCase();
|
||
if (!emailOk(e)) return { ok: false, error: "Enter a valid email address." };
|
||
if (!passwordOk(password)) return { ok: false, error: "Use at least 10 characters." };
|
||
if (userByEmail(e)) return { ok: false, error: "That email already has an account." };
|
||
// ponytail: no rate limiting — single-container tool. Add a token bucket before exposing this to a network.
|
||
return { ok: true, id: createUser(e, hashPassword(password)) };
|
||
}
|
||
|
||
export async function startSession(userId: string) {
|
||
const token = randomBytes(32).toString("hex");
|
||
putSession(hashToken(token), userId, Date.now() + SESSION_DAYS * 864e5);
|
||
(await cookies()).set(SESSION_COOKIE, token, {
|
||
httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production",
|
||
path: "/", maxAge: SESSION_DAYS * 86400 });
|
||
}
|
||
export async function endSession() {
|
||
const store = await cookies();
|
||
const t = store.get(SESSION_COOKIE)?.value;
|
||
if (t) dropSession(hashToken(t));
|
||
store.delete(SESSION_COOKIE);
|
||
}
|
||
export async function sessionUser(): Promise<{ id: string; email: string } | null> {
|
||
const t = (await cookies()).get(SESSION_COOKIE)?.value;
|
||
if (!t) return null;
|
||
const id = sessionUserId(hashToken(t));
|
||
if (!id) return null;
|
||
const row = userEmail(id);
|
||
return row ? { id, email: row.email } : null;
|
||
}
|
||
export async function requireUser() {
|
||
const u = await sessionUser();
|
||
if (!u) redirect("/login");
|
||
return u;
|
||
}
|
||
export const needsOnboarding = (userId: string) => getSettings(userId)?.onboarded !== 1;
|
||
```
|
||
|
||
The cookie stores a random token; the DB stores only its SHA-256, so a DB dump does not hand out live sessions.
|
||
|
||
- [ ] **Step 4: Server actions + pages**
|
||
|
||
`lib/actions.ts`:
|
||
|
||
```ts
|
||
"use server";
|
||
import { redirect } from "next/navigation";
|
||
import { revalidatePath } from "next/cache";
|
||
import { userByEmail, getSettings, renameAnalysis, deleteAnalysis } from "@/lib/db";
|
||
import { signUpNewUser, verifyPassword, startSession, endSession, requireUser, emailOk } from "@/lib/auth";
|
||
|
||
export async function signUp(_: void, fd: FormData) {
|
||
const r = signUpNewUser(String(fd.get("email") ?? ""), String(fd.get("password") ?? ""));
|
||
if (!r.ok) return r;
|
||
await startSession(r.id);
|
||
redirect("/setup");
|
||
}
|
||
export async function signIn(_: void, fd: FormData) {
|
||
const e = String(fd.get("email") ?? "").trim().toLowerCase();
|
||
const u = emailOk(e) ? userByEmail(e) : null;
|
||
if (!u || !verifyPassword(String(fd.get("password") ?? ""), u.pass))
|
||
return { ok: false, error: "Email or password is wrong." };
|
||
await startSession(u.id);
|
||
redirect(getSettings(u.id)?.onboarded === 1 ? "/app" : "/setup");
|
||
}
|
||
export async function signOut() { await endSession(); redirect("/login"); }
|
||
|
||
export async function rename(_: void, fd: FormData) {
|
||
const u = await requireUser();
|
||
const id = String(fd.get("id") ?? "");
|
||
const title = String(fd.get("title") ?? "").trim().slice(0, 120);
|
||
if (id && title) renameAnalysis(id, u.id, title);
|
||
revalidatePath("/app");
|
||
}
|
||
export async function remove(_: void, fd: FormData) {
|
||
const u = await requireUser();
|
||
deleteAnalysis(String(fd.get("id") ?? ""), u.id);
|
||
revalidatePath("/app");
|
||
}
|
||
```
|
||
|
||
`components/auth-form.tsx` — visible labels, inline error, pending state:
|
||
|
||
```tsx
|
||
"use client";
|
||
import { useActionState } from "react";
|
||
|
||
export function AuthForm({ action, submit, fields }: {
|
||
action: (_: void, fd: FormData) => Promise<{ ok?: boolean; error?: string } | never>;
|
||
submit: string;
|
||
fields: { name: string; label: string; type: string; autoComplete?: string; help?: string }[];
|
||
}) {
|
||
const [state, formAction, pending] = useActionState(action, null);
|
||
return <form action={formAction} className="card" style={{ display: "grid", gap: "var(--qai-space-md)" }}>
|
||
{fields.map((f) => <div key={f.name} style={{ display: "grid", gap: "var(--qai-space-xs)" }}>
|
||
<label className="label" htmlFor={f.name}>{f.label}</label>
|
||
<input className="field" id={f.name} name={f.name} type={f.type} autoComplete={f.autoComplete} required />
|
||
{f.help && <span style={{ fontSize: "var(--qai-text-xs)", color: "var(--qai-ink-muted)" }}>{f.help}</span>}
|
||
</div>)}
|
||
{state && !state.ok && <p role="alert" style={{ color: "var(--qai-danger)" }}>{state.error}</p>}
|
||
<button className="btn btn-primary" disabled={pending}>{submit}</button>
|
||
</form>;
|
||
}
|
||
```
|
||
|
||
`app/(auth)/layout.tsx`:
|
||
|
||
```tsx
|
||
export default function Auth({ children }: { children: React.ReactNode }) {
|
||
return <div style={{ maxWidth: 420, margin: "0 auto", padding: "var(--qai-space-3xl) var(--qai-space-l)" }}>
|
||
<p className="label">SQL Server qAI</p>{children}
|
||
<p className="colophon">ShowPlan XML in · Jev-ranked remediation out · self-hosted</p>
|
||
</div>;
|
||
}
|
||
```
|
||
|
||
`app/(auth)/login/page.tsx`:
|
||
|
||
```tsx
|
||
import { signIn } from "@/lib/actions";
|
||
import { AuthForm } from "@/components/auth-form";
|
||
export default function Login() {
|
||
return <>
|
||
<h1 style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-2xl)" }}>Sign in</h1>
|
||
<AuthForm action={signIn} submit="Sign in" fields={[
|
||
{ name: "email", label: "Email", type: "email", autoComplete: "email" },
|
||
{ name: "password", label: "Password", type: "password", autoComplete: "current-password" }]} />
|
||
<p><a href="/signup">Create an account</a></p>
|
||
</>;
|
||
}
|
||
```
|
||
|
||
`app/(auth)/signup/page.tsx` — same shape, `action={signUp}`, `submit="Create account"`, password field `autoComplete="new-password"` and `help: "10 characters minimum."`, link to `/login`.
|
||
|
||
- [ ] **Step 5: Verify + commit**
|
||
|
||
`npm run test` passes; `npm run build` clean. Manual: `/signup` → lands on `/setup`; wrong password → inline alert, no redirect; sign out → cookie gone; `/app` while signed out → `/login`. Commit `feat: email+password auth with db-backed sessions`.
|
||
|
||
---
|
||
|
||
### Task 4: Credential vault + settings store
|
||
|
||
**Files:** create `lib/vault.ts`, `lib/settings.ts`, `tests/vault.test.ts`.
|
||
|
||
**Interfaces — produces:** `seal(plain): string` · `open(sealed): string` (`""` on any malformed input) · `mask(secret): string` · `AnalystConfig = { baseUrl, apiKey, model, extra }` · `saveSettingsForm(userId, fd, onboarded = 1)` (blank secret field keeps the stored key) · `analystConfig(userId): AnalystConfig | null` · `jevKey(userId): string` · `jevModel(userId): string` · `settingsView(userId)` returning masked/ready flags only — never a raw key.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```ts
|
||
import test from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import { mkdtempSync } from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
process.env.APP_SECRET = "unit-test-secret-value";
|
||
process.env.DATA_DIR = mkdtempSync(`${tmpdir()}/qai-vault-`);
|
||
const { seal, open, mask } = await import("../lib/vault.ts");
|
||
const db = await import("../lib/db.ts");
|
||
const { saveSettingsForm, analystConfig, settingsView, jevKey } = await import("../lib/settings.ts");
|
||
|
||
test("seal/open round-trips; junk and tamper yield empty", () => {
|
||
assert.equal(open(seal("sk-abc123")), "sk-abc123");
|
||
assert.equal(open("deadbeef.00.zzz"), "");
|
||
assert.equal(open(""), "");
|
||
assert.equal(mask("sk-abcdefgh1234"), "••••1234");
|
||
assert.equal(mask(""), "");
|
||
});
|
||
|
||
test("blank secret keeps the stored key", () => {
|
||
const u = db.createUser("v@x.y", "h");
|
||
const fd = (o: Record<string, string>) => { const f = new FormData();
|
||
Object.entries(o).forEach(([k, v]) => f.set(k, v)); return f; };
|
||
saveSettingsForm(u, fd({ analyst_base_url: "https://api/x/v1/", analyst_key: "sk-1111", analyst_model: "gpt",
|
||
analyst_extra: '{"temperature":0.2}', jev_key: "ts-1", jev_model: "jev-latest" }));
|
||
assert.equal(analystConfig(u)?.apiKey, "sk-1111");
|
||
|
||
saveSettingsForm(u, fd({ analyst_base_url: "https://api/y/v1", analyst_key: "", analyst_model: "gpt",
|
||
analyst_extra: "not json", jev_key: "", jev_model: "" }));
|
||
const c = analystConfig(u)!;
|
||
assert.equal(c.baseUrl, "https://api/y/v1", "trailing slash trimmed");
|
||
assert.equal(c.apiKey, "sk-1111", "blank must not wipe the key");
|
||
assert.deepEqual(c.extra, {}, "bad JSON falls back to empty params");
|
||
assert.equal(jevKey(u), "ts-1");
|
||
assert.equal(settingsView(u).has_analyst, true);
|
||
assert.equal(settingsView(u).analyst_key_masked, "••••1111");
|
||
assert.equal((settingsView(u) as Record<string, unknown>).analyst_key, undefined, "no raw key to the client");
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it** — module not found.
|
||
|
||
- [ ] **Step 3: Implement**
|
||
|
||
`lib/vault.ts`:
|
||
|
||
```ts
|
||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
|
||
|
||
function key() {
|
||
const s = process.env.APP_SECRET;
|
||
if (!s || s.length < 16) throw new Error("APP_SECRET is missing or shorter than 16 chars — set it in .env");
|
||
return scryptSync(s, "qai-vault-v1", 32);
|
||
}
|
||
export function seal(plain: string): string {
|
||
if (!plain) return "";
|
||
const iv = randomBytes(12);
|
||
const c = createCipheriv("aes-256-gcm", key(), iv);
|
||
const ct = Buffer.concat([c.update(plain, "utf8"), c.final()]);
|
||
return `${iv.toString("hex")}.${c.getAuthTag().toString("hex")}.${ct.toString("hex")}`;
|
||
}
|
||
export function open(sealed: string): string {
|
||
if (!sealed) return "";
|
||
try {
|
||
const [iv, tag, ct] = sealed.split(".");
|
||
const d = createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "hex"), { authTagLength: 16 });
|
||
d.setAuthTag(Buffer.from(tag, "hex"));
|
||
return Buffer.concat([d.update(Buffer.from(ct, "hex")), d.final()]).toString("utf8");
|
||
} catch { return ""; }
|
||
}
|
||
export const mask = (v: string) => (v ? `••••${v.slice(-4)}` : "");
|
||
```
|
||
|
||
`lib/settings.ts`:
|
||
|
||
```ts
|
||
import { getSettings, saveSettings, type SettingsInput } from "@/lib/db";
|
||
import { seal, open, mask } from "@/lib/vault";
|
||
|
||
export type AnalystConfig = { baseUrl: string; apiKey: string; model: string; extra: Record<string, unknown> };
|
||
const str = (fd: FormData, k: string) => String(fd.get(k) ?? "").trim();
|
||
const kept = (fd: FormData, k: string, existing: string) => { const v = str(fd, k); return v ? seal(v) : existing; };
|
||
|
||
export function saveSettingsForm(userId: string, fd: FormData, onboarded = 1) {
|
||
const cur = getSettings(userId);
|
||
let extra = str(fd, "analyst_extra") || "{}";
|
||
try { JSON.parse(extra); } catch { extra = "{}"; }
|
||
saveSettings(userId, {
|
||
analyst_base_url: str(fd, "analyst_base_url").replace(/\/+$/, ""),
|
||
analyst_key: kept(fd, "analyst_key", cur?.analyst_key ?? ""),
|
||
analyst_model: str(fd, "analyst_model"),
|
||
analyst_extra: extra,
|
||
jev_key: kept(fd, "jev_key", cur?.jev_key ?? ""),
|
||
jev_model: str(fd, "jev_model") || "jev-latest",
|
||
onboarded: cur?.onboarded === 1 ? 1 : onboarded,
|
||
} satisfies SettingsInput);
|
||
}
|
||
export function analystConfig(userId: string): AnalystConfig | null {
|
||
const s = getSettings(userId);
|
||
const baseUrl = s?.analyst_base_url ?? "";
|
||
const apiKey = open(s?.analyst_key ?? "");
|
||
const model = s?.analyst_model ?? "";
|
||
if (!baseUrl || !apiKey || !model) return null;
|
||
let extra: Record<string, unknown> = {};
|
||
try { extra = JSON.parse(s!.analyst_extra) as Record<string, unknown>; } catch { /* bad JSON means no extra params */ }
|
||
return { baseUrl, apiKey, model, extra };
|
||
}
|
||
export const jevKey = (userId: string) => open(getSettings(userId)?.jev_key ?? "");
|
||
export const jevModel = (userId: string) => getSettings(userId)?.jev_model || "jev-latest";
|
||
export function settingsView(userId: string) {
|
||
const s = getSettings(userId);
|
||
const analystKey = open(s?.analyst_key ?? "");
|
||
const jev = open(s?.jev_key ?? "");
|
||
return {
|
||
analyst_base_url: s?.analyst_base_url ?? "", analyst_model: s?.analyst_model ?? "",
|
||
analyst_extra: s?.analyst_extra ?? "{}", jev_model: s?.jev_model ?? "jev-latest",
|
||
analyst_key_masked: mask(analystKey), jev_key_masked: mask(jev),
|
||
has_analyst: !!(s?.analyst_base_url && s?.analyst_model && analystKey), has_jev: !!jev,
|
||
};
|
||
}
|
||
export type SettingsView = ReturnType<typeof settingsView>;
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, commit** — commit `feat: encrypted credential vault`.
|
||
|
||
---
|
||
|
||
### Task 5: First-run guide + settings page
|
||
|
||
**Files:** create `components/settings-form.tsx`, `app/setup/page.tsx`, `app/settings/page.tsx`; modify `lib/actions.ts`.
|
||
|
||
**Interfaces — consumes** `saveSettingsForm`, `settingsView`, `SettingsView`, `requireUser`, `getSettings`, `listAnalyses`. **Produces** `saveSettingsAction(_: void, fd): { ok: boolean; error?: string }`.
|
||
|
||
- [ ] **Step 1: Shared form** — `components/settings-form.tsx`. One form, two hosts (wizard + settings). Visible labels, helper text under each field, error beside the field, never a placeholder-as-label:
|
||
|
||
```tsx
|
||
"use client";
|
||
import { useActionState } from "react";
|
||
import type { SettingsView } from "@/lib/settings";
|
||
|
||
export function SettingsForm({ action, view, step, account }: {
|
||
action: (_: void, fd: FormData) => Promise<{ ok?: boolean; error?: string }>;
|
||
view: SettingsView; step?: string; account?: boolean;
|
||
}) {
|
||
const [state, formAction, pending] = useActionState(action, null);
|
||
const F = (name: string, label: string, value: string, help: string, type = "text") => (
|
||
<div key={name} style={{ display: "grid", gap: "var(--qai-space-xs)" }}>
|
||
<label className="label" htmlFor={name}>{label}</label>
|
||
<input className="field" id={name} name={name} type={type} defaultValue={value} />
|
||
<span style={{ fontSize: "var(--qai-text-xs)", color: "var(--qai-ink-muted)" }}>{help}</span>
|
||
</div>);
|
||
const Group = ({ title, children }: { title: string; children: React.ReactNode }) => (
|
||
<fieldset style={{ border: 0, padding: 0, margin: 0, display: "grid", gap: "var(--qai-space-md)" }}>
|
||
<legend className="label">{title}</legend>{children}</fieldset>);
|
||
return <form action={formAction} className="card" style={{ display: "grid", gap: "var(--qai-space-l)" }}>
|
||
{step && <p className="label">{step}</p>}
|
||
<Group title="Analyst model — OpenAI-compatible">
|
||
{F("analyst_base_url", "Base URL", view.analyst_base_url, "e.g. https://api.openai.com/v1")}
|
||
{F("analyst_key", "API key", "", view.has_analyst
|
||
? `Saved ${view.analyst_key_masked} — leave blank to keep it` : "Encrypted with APP_SECRET before it hits disk")}
|
||
{F("analyst_model", "Model name", view.analyst_model, "e.g. gpt-4o-mini, llama-3.3-70b")}
|
||
{F("analyst_extra", "Extra params (JSON)", view.analyst_extra, 'Merged into the request body, e.g. {"temperature":0.2}')}
|
||
</Group>
|
||
<Group title="Jev — TypeSafe System One">
|
||
{F("jev_key", "TypeSafe API key", "", view.has_jev
|
||
? `Saved ${view.jev_key_masked} — leave blank to keep it` : "console.typesafe.ai/keys")}
|
||
{F("jev_model", "Jev model", view.jev_model, "jev-latest unless you know better")}
|
||
</Group>
|
||
{account && <Group title="Account">
|
||
{F("email", "Email", "", "Used to sign in", "email")}
|
||
{F("password", "New password", "", "Leave blank to keep the current one", "password")}
|
||
</Group>}
|
||
{state && !state.ok && <p role="alert" style={{ color: "var(--qai-danger)" }}>{state.error}</p>}
|
||
{state?.ok && <p role="status" style={{ color: "var(--qai-accent)" }}>Saved.</p>}
|
||
<button className="btn btn-primary" disabled={pending}>Save</button>
|
||
</form>;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Action + pages**
|
||
|
||
`lib/actions.ts` addition:
|
||
|
||
```ts
|
||
import { saveSettingsForm } from "@/lib/settings";
|
||
import { db } from "@/lib/db";
|
||
import { hashPassword } from "@/lib/auth";
|
||
|
||
export async function saveSettingsAction(_: void, fd: FormData) {
|
||
const u = await requireUser();
|
||
const email = String(fd.get("email") ?? "").trim().toLowerCase();
|
||
const password = String(fd.get("password") ?? "");
|
||
if (email && !emailOk(email)) return { ok: false, error: "Enter a valid email address." };
|
||
if (password && password.length < 10) return { ok: false, error: "Use at least 10 characters." };
|
||
saveSettingsForm(u.id, fd);
|
||
if (password) db.prepare("UPDATE users SET pass = ? WHERE id = ?").run(hashPassword(password), u.id);
|
||
if (email) db.prepare("UPDATE users SET email = ? WHERE id = ?").run(email, u.id);
|
||
revalidatePath("/settings");
|
||
return { ok: true };
|
||
}
|
||
```
|
||
|
||
`app/setup/page.tsx` — 3 steps, `?step=2` deep links, visible "Step 2 of 3" (ui-ux-pro-max: multi-step needs progress):
|
||
|
||
```tsx
|
||
import { redirect } from "next/navigation";
|
||
import { requireUser } from "@/lib/auth";
|
||
import { settingsView } from "@/lib/settings";
|
||
import { getSettings } from "@/lib/db";
|
||
import { saveSettingsAction } from "@/lib/actions";
|
||
import { SettingsForm } from "@/components/settings-form";
|
||
|
||
const TITLES = { 1: "Paste a plan. Get a ranked fix.", 2: "Say which models to ask.", 3: "Ready." } as const;
|
||
|
||
export default async function Setup({ searchParams }: { searchParams: Promise<{ step?: string }> }) {
|
||
const u = await requireUser();
|
||
const n = (["1", "2", "3"].includes((await searchParams).step ?? "") ? (await searchParams).step! : "1") as "1" | "2" | "3";
|
||
const view = settingsView(u.id);
|
||
if (getSettings(u.id)?.onboarded === 1 && view.has_analyst && view.has_jev) redirect("/app");
|
||
return <main style={{ maxWidth: 560, margin: "0 auto", padding: "var(--qai-space-3xl) var(--qai-space-l)",
|
||
display: "grid", gap: "var(--qai-space-xl)" }}>
|
||
<p className="label">Step {n} of 3</p>
|
||
<h1 style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-display)", letterSpacing: "-0.02em" }}>
|
||
{TITLES[n]}</h1>
|
||
{n === "1" && <p>qAI reads a SQL Server ShowPlan XML, works out the bottleneck in code, asks your model for several ways to fix it, then has Jev score each one. You configure nothing on this screen.</p>}
|
||
{n === "2" && <SettingsForm action={saveSettingsAction} view={view} step="Analyst model and Jev key" />}
|
||
{n === "3" && <>
|
||
<p>One run = one digest, a few model calls, four Jev questions per option. If the analyst or Jev is unreachable, the report says so instead of inventing a verdict.</p>
|
||
<p className="label">Analyst {view.has_analyst ? "ready" : "missing"} · Jev {view.has_jev ? "ready" : "missing"}</p>
|
||
<a className="btn btn-primary" href="/app" style={{ justifySelf: "start", textDecoration: "none" }}>Go</a>
|
||
</>}
|
||
<p className="colophon">Keys stay in this container, encrypted with APP_SECRET · <a href="/settings">settings</a></p>
|
||
{n !== "3" && <a className="btn" href={`/setup?step=${Number(n) + 1}`}
|
||
style={{ justifySelf: "start", textDecoration: "none" }}>Next</a>}
|
||
</main>;
|
||
}
|
||
```
|
||
|
||
`app/settings/page.tsx`:
|
||
|
||
```tsx
|
||
```tsx
|
||
import { requireUser } from "@/lib/auth";
|
||
import { settingsView } from "@/lib/settings";
|
||
import { listAnalyses } from "@/lib/db";
|
||
import { saveSettingsAction, signOut } from "@/lib/actions";
|
||
import { SettingsForm } from "@/components/settings-form";
|
||
|
||
export default async function Settings() {
|
||
const u = await requireUser();
|
||
const rows = listAnalyses(u.id);
|
||
return <main className="main" style={{ maxWidth: 640, margin: "0 auto" }}>
|
||
<h1 style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-2xl)" }}>Settings</h1>
|
||
<SettingsForm action={saveSettingsAction} view={settingsView(u.id)} account />
|
||
<section className="card">
|
||
<p className="label">History · {rows.length}</p>
|
||
<p style={{ fontSize: "var(--qai-text-sm)" }}>Rename or delete individual runs from the left rail. Nothing here deletes them in bulk — that is on purpose.</p>
|
||
</section>
|
||
<form action={signOut}><button className="btn">Sign out</button></form>
|
||
</main>;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Verify + commit**
|
||
|
||
`npm run build`. Manual: fresh signup lands on `/setup` step 1 → step 2 saves both models → step 3 `Go` → `/app`. Reload `/settings`: keys show as `••••`, blank fields keep the stored values. Commit `feat: setup wizard + settings`.
|
||
|
||
---
|
||
|
||
### Task 6: ShowPlan digest parser
|
||
|
||
**Files:** create `lib/digest.ts`, `tests/digest.test.ts`.
|
||
|
||
**Interfaces — consumes** nothing. **Produces:** `class PlanError` · `MAX_XML_BYTES = 12_000_000` ·
|
||
`type Operator = { id: string; op: string; cost: number; estRows: number; actualRows: number | null; execs: number | null; io: number | null; cpu: number | null; spillLevels: number | null; parallel: boolean }` ·
|
||
`type RowGuess = { id: string; op: string; est: number; actual: number; ratio: number }` ·
|
||
`type MissingIndex = { table: string; equality: string[]; inequality: string[]; included: string[]; impact: number }` ·
|
||
`type Wait = { type: string; ms: number }` ·
|
||
`type Digest = { sql: string; bytes: number; subtreeCost: number | null; parallel: boolean; nonParallelReason: string | null; earlyAbort: string | null; tables: string[]; topOperators: Operator[]; rowGuessErrors: RowGuess[]; missingIndexes: MissingIndex[]; warnings: string[]; waits: Wait[] }` ·
|
||
`digestPlan(xml: string): Digest` · `digestForModel(d: Digest): object` — the only shape any model or Jev ever sees.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```ts
|
||
import test from "node:test";
|
||
import assert from "node:assert/strict";
|
||
const { digestPlan, PlanError, digestForModel } = await import("../lib/digest.ts");
|
||
|
||
const PLAN = `<?xml version="1.0" encoding="utf-16"?>
|
||
<ShowPlanXML xmlns="http://schemas.microsoft.com/sqlserver/2004/07/showplan" Version="1.6" Build="16.0.4085.2">
|
||
<BatchSequence><Batch><Statements>
|
||
<StmtSimple StatementId="1" StatementSubtreeCost="4.871" StatementOptmLevel="FULL"
|
||
StatementText="SELECT o.Id FROM Sales.Orders o WHERE o.CustomerId = @c1 AND o.Total > 500">
|
||
<StatementOptmEarlyAbortReason>GoodEnoughPlanFound</StatementOptmEarlyAbortReason>
|
||
<QueryBlock QueryHash="0x1A" QueryPlanHash="0x2B">
|
||
<RelOp NodeId="0" PhysicalOp="Clustered Index Scan" LogicalOp="Clustered Index Scan" EstimateRows="1000"
|
||
EstimateIO="41.6" EstimateCPU="39.0" EstimatedTotalSubtreeCost="4.10" Parallel="false">
|
||
<RunTimeInformation><RunTimeCountersPerThread Thread="0" ActualRows="1243500" ActualExecs="1"
|
||
ActualElapsedms="2411" ActualCPUms="2210"/></RunTimeInformation>
|
||
<IndexScan Database="[wide]" Schema="[Sales]" Table="[Orders]"/>
|
||
<Warnings><NoJoinPredicate/><SpillToTempDb SpillLevel="2"/>
|
||
<WaitTime WaitType="PAGEIOLATCH_SH" WaitTime="812"/></Warnings>
|
||
</RelOp>
|
||
<RelOp NodeId="1" PhysicalOp="Index Seek" LogicalOp="Index Seek" EstimateRows="3"
|
||
EstimateIO="0.7" EstimateCPU="0.02" EstimatedTotalSubtreeCost="0.77" Parallel="true">
|
||
<RunTimeInformation><RunTimeCountersPerThread Thread="0" ActualRows="4" ActualExecs="2"/></RunTimeInformation>
|
||
<IndexScan Database="[wide]" Schema="[Sales]" Table="[Customer]"/>
|
||
</RelOp>
|
||
<MissingIndexes><MissingIndexGroup Impact="94.17">
|
||
<MissingIndex Database="[wide]" Schema="[Sales]" Table="[Orders]">
|
||
<ColumnGroup Usage="EQUALITY"><Column Name="[CustomerId]"/></ColumnGroup>
|
||
<ColumnGroup Usage="INEQUALITY"><Column Name="[Total]"/></ColumnGroup>
|
||
<IncludedColumns><Column Name="[Id]"/></IncludedColumns>
|
||
</MissingIndex></MissingIndexGroup></MissingIndexes>
|
||
<NonParallelPlanReason>Could Not Generate Valid Parallel Plan</NonParallelPlanReason>
|
||
</QueryBlock>
|
||
</StmtSimple>
|
||
</Statements></Batch></BatchSequence>
|
||
</ShowPlanXML>`;
|
||
|
||
test("digests what matters in a showplan", () => {
|
||
const d = digestPlan(PLAN);
|
||
assert.match(d.sql, /o\.Total > 500/, "XML entities decoded");
|
||
assert.equal(d.subtreeCost, 4.871);
|
||
assert.equal(d.earlyAbort, "GoodEnoughPlanFound");
|
||
assert.equal(d.nonParallelReason, "Could Not Generate Valid Parallel Plan");
|
||
assert.deepEqual(d.tables, ["wide.Sales.Orders", "wide.Sales.Customer"]);
|
||
assert.equal(d.topOperators[0].op, "Clustered Index Scan");
|
||
assert.equal(d.topOperators[0].spillLevels, 2);
|
||
assert.equal(d.topOperators[0].actualRows, 1243500);
|
||
assert.equal(d.topOperators[0].parallel, false);
|
||
assert.deepEqual(d.missingIndexes[0], { table: "wide.Sales.Orders", equality: ["CustomerId"],
|
||
inequality: ["Total"], included: ["Id"], impact: 94.17 });
|
||
assert.ok(d.warnings.includes("NoJoinPredicate"));
|
||
assert.ok(d.warnings.some((w) => w.startsWith("SpillToTempDb")));
|
||
assert.deepEqual(d.waits, [{ type: "PAGEIOLATCH_SH", ms: 812 }]);
|
||
assert.ok(d.rowGuessErrors[0].ratio > 100);
|
||
assert.equal(d.bytes, PLAN.length);
|
||
});
|
||
test("a BOM-prefixed plan still digests — SSMS writes .sqlplan as UTF-16 with a BOM", () => {
|
||
assert.equal(digestPlan("\uFEFF" + PLAN).subtreeCost, 4.871);
|
||
});
|
||
test("rejects input that is not a plan", () => {
|
||
for (const bad of ["", "SELECT 1", "<html></html>", "<ShowPlanXML><trunc"])
|
||
assert.throws(() => digestPlan(bad), PlanError);
|
||
});
|
||
test("digestForModel stays bounded on a fat plan", () => {
|
||
const big = `<ShowPlanXML><Batch><Statements><StmtSimple StatementText="q">` +
|
||
Array.from({ length: 400 }, (_, i) =>
|
||
`<RelOp NodeId="${i}" LogicalOp="Scan" EstimateRows="1" EstimatedTotalSubtreeCost="${i}"/>`).join("") +
|
||
`</StmtSimple></Statements></Batch></Batch></ShowPlanXML>`;
|
||
const v = digestForModel(digestPlan(big)) as { topOperators: unknown[]; tables: unknown[] };
|
||
assert.ok(v.topOperators.length <= 12);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it** — cannot find module `../lib/digest.ts`.
|
||
|
||
- [ ] **Step 3: Implement `lib/digest.ts`**
|
||
|
||
```ts
|
||
import { XMLParser } from "fast-xml-parser";
|
||
|
||
export class PlanError extends Error {}
|
||
export const MAX_XML_BYTES = 12_000_000;
|
||
|
||
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "", removeNSPrefix: true });
|
||
|
||
export type Operator = { id: string; op: string; cost: number; estRows: number; actualRows: number | null;
|
||
execs: number | null; io: number | null; cpu: number | null; spillLevels: number | null; parallel: boolean };
|
||
export type RowGuess = { id: string; op: string; est: number; actual: number; ratio: number };
|
||
export type MissingIndex = { table: string; equality: string[]; inequality: string[]; included: string[]; impact: number };
|
||
export type Wait = { type: string; ms: number };
|
||
export type Digest = { sql: string; bytes: number; subtreeCost: number | null; parallel: boolean;
|
||
nonParallelReason: string | null; earlyAbort: string | null; tables: string[]; topOperators: Operator[];
|
||
rowGuessErrors: RowGuess[]; missingIndexes: MissingIndex[]; warnings: string[]; waits: Wait[] };
|
||
|
||
function collect(node: any, name: string, out: any[] = []): any[] {
|
||
if (Array.isArray(node)) { for (const n of node) collect(n, name, out); return out; }
|
||
if (node && typeof node === "object") for (const [k, v] of Object.entries(node)) {
|
||
if (k === "#text") continue;
|
||
if (k === name) (Array.isArray(v) ? v : [v]).forEach((n) => out.push(n));
|
||
collect(v, name, out);
|
||
}
|
||
return out;
|
||
}
|
||
const num = (v: any): number | null =>
|
||
(v === undefined || v === null || v === "" || Number.isNaN(Number(v)) ? null : Number(v));
|
||
const text = (v: any): string | null =>
|
||
(typeof v === "object" && v !== null ? (String(v["#text"] ?? "") || null) : (v == null ? null : String(v)));
|
||
const unbrack = (v: any) => String(v ?? "").replace(/[[\]]/g, "");
|
||
const colNames = (node: any) => collect(node, "Column").map((c) => unbrack(c.Name)).filter(Boolean);
|
||
|
||
export function digestPlan(raw: string): Digest {
|
||
const xml = raw.replace(/^\uFEFF/, ""); // SSMS writes a BOM; the XML parser does not want it
|
||
if (!xml || xml.trim().length < 40) throw new PlanError("Empty input. Paste the ShowPlan XML, or drop a .sqlplan file.");
|
||
if (xml.length > MAX_XML_BYTES)
|
||
throw new PlanError(`Plan is ${Math.round(xml.length / 1e6)} MB, over the ${MAX_XML_BYTES / 1e6} MB cap.`);
|
||
let doc: any;
|
||
try { doc = parser.parse(xml); } catch { throw new PlanError("That XML will not parse — re-export the actual execution plan."); }
|
||
if (!doc?.ShowPlanXML) throw new PlanError("Not a SQL Server ShowPlan XML document. In SSMS: Results → Save As → .sqlplan, or run the query with ATTNPL.");
|
||
|
||
const stmts = collect(doc, "StmtSimple");
|
||
const stmt = stmts.find((s) => s?.QueryBlock || s?.RelOp) ?? stmts[0] ?? {};
|
||
const operators: Operator[] = collect(doc, "RelOp").map((o) => {
|
||
const rt = collect(o, "RunTimeCountersPerThread")[0];
|
||
return {
|
||
id: String(o.NodeId ?? o["Node_id"] ?? "?"),
|
||
op: String(o.LogicalOp ?? o.PhysicalOp ?? "Unknown"),
|
||
// Real ShowPlan spells these EstimateIO / EstimateCPU; builds differ, so fall through every spelling.
|
||
cost: num(o.EstimatedOperatorCost) ?? num(o.EstimatedTotalSubtreeCost) ?? num(o.EstimateIO) ?? 0,
|
||
estRows: num(o.EstimateRows) ?? 0,
|
||
actualRows: rt ? num(rt.ActualRows) : null,
|
||
execs: rt ? num(rt.ActualExecs) : null,
|
||
io: num(o.EstimateIO) ?? num(o.EstimatedIO), cpu: num(o.EstimateCPU) ?? num(o.EstimatedCPU),
|
||
spillLevels: num(collect(o, "SpillToTempDb")[0]?.SpillLevel),
|
||
parallel: o.Parallel === "true",
|
||
};
|
||
});
|
||
|
||
const tables = [...new Set([...collect(doc, "IndexScan"), ...collect(doc, "TableScan")]
|
||
.map((s) => unbrack(s.Table)).filter(Boolean))];
|
||
|
||
const missingIndexes: MissingIndex[] = collect(doc, "MissingIndexGroup").map((g) => {
|
||
const mi = collect(g, "MissingIndex")[0] ?? {};
|
||
const byUsage = (u: string) => collect(mi, "ColumnGroup").filter((c) => c.Usage === u).flatMap(colNames);
|
||
return {
|
||
table: [mi.Database, mi.Schema, mi.Table].map(unbrack).filter(Boolean).join("."),
|
||
equality: byUsage("EQUALITY"), inequality: byUsage("INEQUALITY"),
|
||
included: collect(mi, "IncludedColumns").flatMap(colNames),
|
||
impact: num(g.Impact) ?? 0,
|
||
};
|
||
}).sort((a, b) => b.impact - a.impact);
|
||
|
||
// Generic on purpose: SQL Server adds warning elements over time, unknown ones still surface.
|
||
const warnings = [...new Set(collect(doc, "Warnings").flatMap((w) =>
|
||
Object.entries(w).filter(([k]) => k !== "#text").map(([k, v]: [string, any]) => {
|
||
const attrs = v && typeof v === "object"
|
||
? Object.entries(v).filter(([, x]) => typeof x !== "object").map(([ak, x]) => `${ak}=${x}`).join(" ")
|
||
: "";
|
||
return attrs ? `${k} ${attrs}` : k;
|
||
})))];
|
||
|
||
const waits: Wait[] = collect(doc, "WaitTime")
|
||
.map((w) => ({ type: String(w.WaitType ?? "unknown"), ms: num(w.WaitTime) ?? 0 }))
|
||
.filter((w) => w.ms > 0).sort((a, b) => b.ms - a.ms).slice(0, 5);
|
||
|
||
const rowGuessErrors: RowGuess[] = operators
|
||
.filter((o) => o.actualRows !== null && o.estRows > 0)
|
||
.map((o) => ({ id: o.id, op: o.op, est: o.estRows, actual: o.actualRows as number,
|
||
ratio: Math.max(o.estRows, o.actualRows as number) / Math.min(o.estRows, o.actualRows as number) }))
|
||
.filter((r) => r.ratio >= 10).sort((a, b) => b.ratio - a.ratio).slice(0, 5);
|
||
|
||
return {
|
||
sql: text(stmt.StatementText) ?? "", bytes: xml.length,
|
||
subtreeCost: num(stmt.StatementSubtreeCost), parallel: operators.some((o) => o.parallel),
|
||
nonParallelReason: text(collect(doc, "NonParallelPlanReason")[0]),
|
||
earlyAbort: text(collect(doc, "StatementOptmEarlyAbortReason")[0]),
|
||
tables, topOperators: operators.sort((a, b) => b.cost - a.cost).slice(0, 12),
|
||
rowGuessErrors, missingIndexes, warnings, waits,
|
||
};
|
||
}
|
||
|
||
// Tokens are the constraint: models and Jev read this, never the raw XML.
|
||
export const digestForModel = (d: Digest) => ({
|
||
sql: d.sql.slice(0, 4000), subtreeCost: d.subtreeCost, parallel: d.parallel,
|
||
nonParallelReason: d.nonParallelReason, earlyAbort: d.earlyAbort, tables: d.tables.slice(0, 20),
|
||
topOperators: d.topOperators, rowGuessErrors: d.rowGuessErrors,
|
||
missingIndexes: d.missingIndexes.slice(0, 5), warnings: d.warnings.slice(0, 20), waits: d.waits,
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Prove it on a real plan, not just the fixture**
|
||
|
||
`mkdir -p fixtures`, drop one genuine `.sqlplan` from SSMS in as `fixtures/real.sqlplan`, and add this test. It asserts nothing about specific numbers — only that a real plan yields a non-empty digest. If no real plan is available, leave the test skipped and say so in the commit message:
|
||
|
||
```ts
|
||
test("a real .sqlplan digests", () => {
|
||
let xml: string;
|
||
try { xml = readFileSync("fixtures/real.sqlplan", "utf8"); } catch { return console.log("skip: no fixtures/real.sqlplan"); }
|
||
const d = digestPlan(xml);
|
||
assert.ok(d.topOperators.length > 0, "no operators found — check attribute spellings");
|
||
assert.ok(d.sql.length > 0);
|
||
});
|
||
```
|
||
|
||
Add `import { readFileSync } from "node:fs";` to the test file.
|
||
|
||
- [ ] **Step 5: Run tests, commit**
|
||
|
||
If the entity assertion fails on your `fast-xml-parser` version, decode in `digestPlan`: wrap `text(stmt.StatementText)` with `.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&")`, then re-run. Commit `feat: showplan digest`.
|
||
|
||
---
|
||
|
||
### Task 7: Analyst — multiple options, multiple rewrites
|
||
|
||
**Files:** create `lib/analyst.ts`, `tests/analyst.test.ts`.
|
||
|
||
**Interfaces — consumes** `AnalystConfig` (Task 4), `Digest` + `digestForModel` (Task 6). **Produces:** `class AnalystError` · `type Action = { title: string; detail: string; effort: "low" | "medium" | "high"; requires_change_control: boolean }` · `type Candidate = { key: string; title: string; diagnosis: string; actions: Action[]; rewrite_sql: string | null; expected: string }` · `extractJson(text): unknown` · `proposeCandidates(cfg, digest, note, fetchImpl?): Promise<Candidate[]>` (2–4 items, schema-validated) · `CANDIDATE_SYSTEM_PROMPT`.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```ts
|
||
import test from "node:test";
|
||
import assert from "node:assert/strict";
|
||
const { proposeCandidates, extractJson, AnalystError } = await import("../lib/analyst.ts");
|
||
|
||
const cfg = { baseUrl: "https://api.test/v1", apiKey: "k", model: "m", extra: {} };
|
||
const digest = { sql: "SELECT 1", bytes: 8, subtreeCost: 1, parallel: false, nonParallelReason: null,
|
||
earlyAbort: null, tables: ["t"], topOperators: [], rowGuessErrors: [], missingIndexes: [], warnings: [], waits: [] } as any;
|
||
const ok = (content: string) => ({ ok: true, status: 200,
|
||
json: async () => ({ choices: [{ message: { content } }] }) });
|
||
const CANDS = { candidates: [
|
||
{ key: "idx_customer", title: "Covering index", diagnosis: "Scan of 1.2M rows on Sales.Orders, no key on CustomerId.",
|
||
actions: [{ title: "Create index", detail: "CREATE INDEX ...", effort: "medium", requires_change_control: true }],
|
||
rewrite_sql: null, expected: "seek instead of scan" },
|
||
{ key: "rewrite_split", title: "Split the statement", diagnosis: "One statement scans everything then filters.",
|
||
actions: [{ title: "Deploy rewrite", detail: "Two statements", effort: "high", requires_change_control: true }],
|
||
rewrite_sql: "SELECT ...", expected: "far fewer logical reads" }] };
|
||
|
||
test("returns schema-valid candidates", async () => {
|
||
const got = await proposeCandidates(cfg, digest, "", async () => ok(JSON.stringify(CANDS)) as any);
|
||
assert.equal(got.length, 2);
|
||
assert.equal(got[0].key, "idx_customer");
|
||
});
|
||
test("survives code fences and sends extra params", async () => {
|
||
let sent: any;
|
||
const fetchImpl = async (_u: any, init: any) => { sent = JSON.parse(init.body);
|
||
return ok("```json\n" + JSON.stringify(CANDS) + "\n```") as any; };
|
||
const got = await proposeCandidates({ ...cfg, extra: { temperature: 0.1 } }, digest, "index exists already", fetchImpl as any);
|
||
assert.equal(got.length, 2);
|
||
assert.equal(sent.temperature, 0.1);
|
||
assert.match(sent.messages[0].content, /candidates/);
|
||
assert.match(sent.messages[1].content, /index exists already/);
|
||
});
|
||
test("names the stage on auth, prose, and shape failures", async () => {
|
||
await assert.rejects(() => proposeCandidates(cfg, digest, "",
|
||
async () => ({ ok: false, status: 401, text: async () => "bad key" }) as any),
|
||
(e: any) => e instanceof AnalystError && /401/.test(e.message));
|
||
await assert.rejects(() => proposeCandidates(cfg, digest, "", async () => ok("no json here") as any), AnalystError);
|
||
await assert.rejects(() => proposeCandidates(cfg, digest, "",
|
||
async () => ok(JSON.stringify({ candidates: [CANDS.candidates[0]] })) as any),
|
||
(e: any) => /at least 2/.test(e.message));
|
||
});
|
||
test("extractJson copes with fences and chatter", () => {
|
||
assert.deepEqual(extractJson('Sure!\n```json\n{"a":1}\n```'), { a: 1 });
|
||
assert.deepEqual(extractJson('noise {"a":1} noise'), { a: 1 });
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it** — module not found.
|
||
|
||
- [ ] **Step 3: Implement `lib/analyst.ts`**
|
||
|
||
```ts
|
||
import { z } from "zod/v4";
|
||
import type { Digest } from "@/lib/digest";
|
||
import { digestForModel } from "@/lib/digest";
|
||
import type { AnalystConfig } from "@/lib/settings";
|
||
|
||
export class AnalystError extends Error {}
|
||
|
||
const Action = z.object({ title: z.string().min(3), detail: z.string().min(3),
|
||
effort: z.enum(["low", "medium", "high"]), requires_change_control: z.boolean() });
|
||
const RawCandidate = z.object({ key: z.string().min(2), title: z.string().min(3), diagnosis: z.string().min(15),
|
||
actions: z.array(Action).min(1), rewrite_sql: z.string().nullable(), expected: z.string().min(5) });
|
||
const Proposal = z.object({ candidates: z.array(RawCandidate).min(2).max(4) });
|
||
export type Action = z.infer<typeof Action>;
|
||
export type Candidate = z.infer<typeof RawCandidate>;
|
||
|
||
export const CANDIDATE_SYSTEM_PROMPT = `You are a SQL Server performance engineer. You receive a JSON digest of one
|
||
execution plan. Judge only what the digest contains — never invent operators, waits, or row counts you were not given.
|
||
|
||
Return 2 to 4 genuinely different remediation options, not one option restated. Real differences: an index change, a
|
||
rewritten query, a statistics or parallelism fix, a batching or schema change. At least one option must be the
|
||
lowest-risk one. Each option needs a snake_case key, a diagnosis naming WHY the plan is slow, 1-4 ordered actions, an
|
||
optional rewritten query, and an expected effect stated as a concrete claim about the plan (logical reads, seek vs
|
||
scan, tempdb spill, degree of parallelism).
|
||
|
||
A rewrite must return the same rows as the original. If you cannot rewrite safely, set rewrite_sql to null.
|
||
Output ONE JSON object and nothing else:
|
||
{"candidates":[{"key":"idx_covering_orders","title":"...","diagnosis":"...","actions":[{"title":"...","detail":"...",
|
||
"effort":"low|medium|high","requires_change_control":false}],"rewrite_sql":null,"expected":"..."}]}`;
|
||
|
||
export function extractJson(text: string): unknown {
|
||
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
|
||
const s = (fenced ? fenced[1] : text).trim();
|
||
const start = s.indexOf("{"); const end = s.lastIndexOf("}");
|
||
if (start === -1 || end <= start) throw new AnalystError("The analyst model returned prose instead of JSON.");
|
||
try { return JSON.parse(s.slice(start, end + 1)); }
|
||
catch { throw new AnalystError("The analyst model returned malformed JSON."); }
|
||
}
|
||
|
||
export async function proposeCandidates(cfg: AnalystConfig, digest: Digest, note: string,
|
||
fetchImpl: typeof fetch = fetch): Promise<Candidate[]> {
|
||
const res = await fetchImpl(`${cfg.baseUrl}/chat/completions`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` },
|
||
body: JSON.stringify({ model: cfg.model, temperature: 0.6, ...cfg.extra, stream: false,
|
||
messages: [{ role: "system", content: CANDIDATE_SYSTEM_PROMPT },
|
||
{ role: "user", content: JSON.stringify({ digest: digestForModel(digest), user_note: note || null }) }] }),
|
||
}).catch((e) => { throw new AnalystError(`Could not reach ${cfg.baseUrl}: ${(e as Error).message}`); });
|
||
|
||
if (!res.ok) throw new AnalystError(`Analyst model returned HTTP ${res.status}. ${(await res.text()).slice(0, 300)}`);
|
||
const content: string = ((await res.json() as any)?.choices?.[0]?.message?.content) ?? "";
|
||
const parsed = Proposal.safeParse(extractJson(content));
|
||
if (!parsed.success) {
|
||
const n = (extractJson(content) as any)?.candidates?.length ?? 0;
|
||
throw new AnalystError(n < 2
|
||
? `The analyst model proposed ${n} option(s); at least 2 are needed before Jev can rank anything.`
|
||
: `Analyst output did not match the schema at ${parsed.error.issues[0].path.join(".")}: ${parsed.error.issues[0].message}`);
|
||
}
|
||
return parsed.data.candidates;
|
||
}
|
||
```
|
||
|
||
No `response_format` is sent: plenty of OpenAI-compatible endpoints 400 on it, and `extractJson` already handles the fenced/prose cases it would have prevented.
|
||
|
||
- [ ] **Step 4: Run tests, commit** — commit `feat: analyst proposes multiple options`.
|
||
|
||
---
|
||
|
||
### Task 8: Jev — score, rank, decide
|
||
|
||
**Files:** create `lib/jev.ts`, `tests/jev.test.ts`.
|
||
|
||
**Interfaces — consumes** `Digest` + `digestForModel` (Task 6), `Candidate` (Task 7), `@typesafe-ai/sdk`. **Produces:**
|
||
`makeJevClient(apiKey: string, model?: string): TypeSafeClient` · `type Dim = "bottleneck_fit" | "semantic_safety" | "ease" | "root_cause"` · `const WEIGHTS: Record<Dim, number>` (sums to 1) ·
|
||
`type Ranked = { key: string; dims: Record<Dim, { value: number; confidence: number | null }>; composite: number; flags: string[] }` ·
|
||
`type Verdict = { order: Ranked[]; headline: string; source: "jev" | "composite"; jev_pick: string | null; jev_confidence: number; agrees: boolean; anything_worth_running: number; weights: Record<Dim, number>; flags: string[] }` ·
|
||
`judgeCandidates(digest, candidates, client): Promise<Verdict>` · `jevFallback(candidates, why): Verdict`.
|
||
|
||
Question design follows TypeSafe's composite-scoring pattern: one narrow judgment per question, four questions in a
|
||
single per-candidate call (the API answers them in parallel), then a second call that compares all candidates side by
|
||
side. Every rubric is worded so **higher is better** — nothing gets inverted in code.
|
||
|
||
Weights are a starting opinion, not a discovery: 0.40 bottleneck_fit / 0.30 semantic_safety / 0.15 ease / 0.15 root_cause.
|
||
They are shipped inside the `Verdict` so the UI can print them, and they are the only tuning knob in the product. A DBA
|
||
who disagrees with the ranking changes four numbers in `lib/jev.ts` — that is the intended edit point, so state it in the
|
||
README rather than building a weights UI.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```ts
|
||
import test from "node:test";
|
||
import assert from "node:assert/strict";
|
||
const { judgeCandidates, WEIGHTS, jevFallback } = await import("../lib/jev.ts");
|
||
const { TypeSafeClient } = await import("@typesafe-ai/sdk");
|
||
|
||
const digest = { sql: "SELECT", bytes: 1, subtreeCost: 1, parallel: false, nonParallelReason: null, earlyAbort: null,
|
||
tables: [], rowGuessErrors: [], missingIndexes: [], warnings: [], waits: [],
|
||
topOperators: [{ id: "0", op: "Clustered Index Scan", cost: 4.1, estRows: 1000, actualRows: 1200000,
|
||
execs: 1, io: 40, cpu: 39, spillLevels: 2, parallel: false }] } as any;
|
||
const candidates = [
|
||
{ key: "a", title: "Covering index", diagnosis: "Scan of a million rows; no key exists on CustomerId.",
|
||
actions: [{ title: "Create index", detail: "CREATE INDEX", effort: "medium", requires_change_control: true }],
|
||
rewrite_sql: null, expected: "seek instead of scan" },
|
||
{ key: "b", title: "Add TOP 100", diagnosis: "Too many rows leave the scan, so cut them off.",
|
||
actions: [{ title: "Add TOP 100", detail: "TOP 100", effort: "low", requires_change_control: false }],
|
||
rewrite_sql: "SELECT TOP 100", expected: "less I/O" },
|
||
] as any;
|
||
|
||
const scored = (fit: number, safe: number, ease: number, cause: number) => ({
|
||
bottleneck_fit: { type: "score", score: fit, confidence: 0.9, legend: {}, probabilities: {} },
|
||
semantic_safety: { type: "score", score: safe, confidence: 0.8, legend: {}, probabilities: {} },
|
||
ease: { type: "score", score: ease, confidence: 0.7, legend: {}, probabilities: {} },
|
||
root_cause: { type: "noul", noul: cause } });
|
||
|
||
// The SDK's client takes an injectable fetch — no network in tests.
|
||
function fakeJev(map: any) {
|
||
return new TypeSafeClient({ apiKey: "test", fetch: (async (_u: any, init: any) => {
|
||
const body = JSON.parse(init.body);
|
||
const answers = body.questions.first_to_run ? map.cross : map.byKey[body.state.candidate.key];
|
||
return new Response(JSON.stringify({ model: "jev-1.13.0", usage: { input_tokens: 1, output_tokens: 1 }, answers }),
|
||
{ status: 200, headers: { "content-type": "application/json" } });
|
||
}) as any, retry: { maxRetries: 0 } });
|
||
}
|
||
const GOOD = { byKey: { a: scored(4, 4, 2, 1), b: scored(0.4, 0.5, 4, 0.1) },
|
||
cross: { first_to_run: { type: "choice", choice: "a", confidence: 0.82, probabilities: { a: 0.82, b: 0.18 } },
|
||
anything_worth_running: { type: "noul", noul: 0.9 } } };
|
||
|
||
test("weights sum to 1", () => assert.equal(Object.values(WEIGHTS).reduce((a, b) => a + b, 0), 1));
|
||
|
||
test("jev picks the winner; the unsafe option is flagged and cannot lead", async () => {
|
||
const v = await judgeCandidates(digest, candidates, fakeJev(GOOD));
|
||
assert.equal(v.source, "jev");
|
||
assert.equal(v.headline, "a");
|
||
assert.equal(v.order[0].key, "a");
|
||
assert.equal(v.agrees, true);
|
||
assert.equal(v.jev_confidence, 0.82);
|
||
assert.ok(v.order.find((r) => r.key === "b")!.flags.includes("verify_semantics"));
|
||
assert.ok(v.order.find((r) => r.key === "a")!.flags.includes("change_control"));
|
||
assert.ok(v.order.find((r) => r.key === "a")!.dims.bottleneck_fit.value === 1);
|
||
});
|
||
test("a low-confidence jev pick defers to composite order", async () => {
|
||
const low = { byKey: GOOD.byKey, cross: { first_to_run: { type: "choice", choice: "b", confidence: 0.2,
|
||
probabilities: { a: 0.4, b: 0.6 } }, anything_worth_running: { type: "noul", noul: 0.4 } } };
|
||
const v = await judgeCandidates(digest, candidates, fakeJev(low));
|
||
assert.equal(v.source, "composite");
|
||
assert.equal(v.headline, "a");
|
||
assert.equal(v.agrees, false);
|
||
assert.ok(v.flags.includes("low_confidence"));
|
||
assert.ok(v.flags.includes("nothing_clearly_worthwhile"));
|
||
});
|
||
test("an unknown jev choice label falls back to composite order", async () => {
|
||
const bogus = { byKey: GOOD.byKey, cross: { first_to_run: { type: "choice", choice: "zzz", confidence: 0.99,
|
||
probabilities: { zzz: 0.99 } }, anything_worth_running: { type: "noul", noul: 0.9 } } };
|
||
const v = await judgeCandidates(digest, candidates, fakeJev(bogus));
|
||
assert.equal(v.source, "composite");
|
||
assert.equal(v.headline, "a");
|
||
assert.equal(v.jev_pick, null);
|
||
});
|
||
test("fallback ranks without Jev and says so", () => {
|
||
const v = jevFallback(candidates, "429 rate limited");
|
||
assert.equal(v.source, "composite");
|
||
assert.ok(v.flags.includes("jev_unavailable"));
|
||
assert.equal(v.order.length, 2);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it** — module not found.
|
||
|
||
- [ ] **Step 3: Implement `lib/jev.ts`**
|
||
|
||
```ts
|
||
import { TypeSafeClient, score, noul, choice } from "@typesafe-ai/sdk";
|
||
import type { Digest } from "@/lib/digest";
|
||
import { digestForModel } from "@/lib/digest";
|
||
import type { Candidate } from "@/lib/analyst";
|
||
|
||
export type Dim = "bottleneck_fit" | "semantic_safety" | "ease" | "root_cause";
|
||
export const WEIGHTS: Record<Dim, number> = { bottleneck_fit: 0.4, semantic_safety: 0.3, ease: 0.15, root_cause: 0.15 };
|
||
const SAFETY_FLOOR = 0.6;
|
||
const CONFIDENCE_FLOOR = 0.35;
|
||
|
||
const BOTTLENECK = [
|
||
"Unrelated to the costly operator or the worst estimate error in the plan.",
|
||
"Touches a minor operator; the dominant cost is untouched.",
|
||
"Partly addresses the dominant cost; a scan or spill probably remains.",
|
||
"Directly removes the dominant cost for this query shape.",
|
||
"Removes the dominant cost and the pattern behind it, so the shape itself changes.",
|
||
];
|
||
const SAFETY = [
|
||
"Changes results: a predicate, join, grouping, or NULL handling is altered.",
|
||
"Likely changes results in edge cases: duplicates, ordering, tie-breaking.",
|
||
"Equivalent on most data but leans on unstated assumptions.",
|
||
"Clearly equivalent, with a stated reason.",
|
||
"Equivalent and checkable: ships a comparison or rollback plan.",
|
||
];
|
||
const EASE = [
|
||
"Needs a schema plus application redesign and a release.",
|
||
"Needs an application deploy with change control on the app tier.",
|
||
"DBA work with approval, e.g. an index create during a window.",
|
||
"One rewritten query or session setting, reviewable in minutes.",
|
||
"Copy-paste change with no server-side change at all.",
|
||
];
|
||
|
||
export type Ranked = { key: string; dims: Record<Dim, { value: number; confidence: number | null }>;
|
||
composite: number; flags: string[] };
|
||
export type Verdict = { order: Ranked[]; headline: string; source: "jev" | "composite"; jev_pick: string | null;
|
||
jev_confidence: number; agrees: boolean; anything_worth_running: number; weights: Record<Dim, number>; flags: string[] };
|
||
|
||
export const makeJevClient = (apiKey: string, model = "jev-latest") =>
|
||
new TypeSafeClient({ apiKey, defaultModel: model, timeout: 20_000 });
|
||
|
||
const norm = (s: number, levels: number) => Math.max(0, Math.min(1, s / (levels - 1)));
|
||
|
||
function rank(c: Candidate, a: any): Ranked {
|
||
const dims = {
|
||
bottleneck_fit: { value: norm(a.bottleneck_fit.score, BOTTLENECK.length), confidence: a.bottleneck_fit.confidence },
|
||
semantic_safety: { value: norm(a.semantic_safety.score, SAFETY.length), confidence: a.semantic_safety.confidence },
|
||
ease: { value: norm(a.ease.score, EASE.length), confidence: a.ease.confidence },
|
||
root_cause: { value: a.root_cause.noul, confidence: null }, // noul reports no confidence
|
||
} as Record<Dim, { value: number; confidence: number | null }>;
|
||
const composite = (Object.keys(WEIGHTS) as Dim[]).reduce((s, k) => s + WEIGHTS[k] * dims[k].value, 0);
|
||
const confs = Object.values(dims).map((d) => d.confidence).filter((x): x is number => x !== null);
|
||
const flags: string[] = [];
|
||
if (dims.semantic_safety.value < SAFETY_FLOOR) flags.push("verify_semantics");
|
||
if (confs.length && Math.min(...confs) < CONFIDENCE_FLOOR) flags.push("low_confidence");
|
||
if (c.actions.some((x) => x.requires_change_control)) flags.push("change_control");
|
||
return { key: c.key, dims, composite: Number(composite.toFixed(4)), flags };
|
||
}
|
||
|
||
export async function judgeCandidates(digest: Digest, candidates: Candidate[], client: TypeSafeClient): Promise<Verdict> {
|
||
const plan = digestForModel(digest);
|
||
const per = await Promise.all(candidates.map((c) => client.systemOne({
|
||
state: { plan, candidate: c },
|
||
questions: {
|
||
bottleneck_fit: score("How directly do the actions in `candidate` attack the dominant bottleneck in `plan` — the most expensive operator and the worst estimate error?", BOTTLENECK),
|
||
semantic_safety: score("If `candidate` is applied, how certain is it that the query still returns exactly the same rows as the original?", SAFETY),
|
||
ease: score("How easy is `candidate` to apply in a real shop, counting people, approvals, and downtime?", EASE),
|
||
root_cause: noul("The diagnosis in `candidate` names the actual cause of the cost in `plan`, not a symptom of it."),
|
||
},
|
||
})));
|
||
|
||
const cross = await client.systemOne({
|
||
state: { plan, options: candidates.map((c) => ({ key: c.key, title: c.title, diagnosis: c.diagnosis, expected: c.expected })) },
|
||
questions: {
|
||
first_to_run: choice("Which single entry in `options` should the DBA run first for the biggest drop in the cost shown in `plan`?",
|
||
Object.fromEntries(candidates.map((c) => [c.key, `${c.title} — ${c.expected}`]))),
|
||
anything_worth_running: noul("At least one entry in `options` would meaningfully reduce the cost shown in `plan`."),
|
||
},
|
||
});
|
||
|
||
const order = candidates.map((c, i) => rank(c, (per[i] as any).answers)).sort((a, b) => b.composite - a.composite);
|
||
const pick = (cross as any).answers.first_to_run;
|
||
const worth = (cross as any).answers.anything_worth_running.noul;
|
||
const valid = candidates.some((c) => c.key === pick.choice);
|
||
const useJev = valid && pick.confidence >= 0.5; // confidence-gated routing: act only when Jev is sure enough
|
||
const flags: string[] = [];
|
||
if (!useJev) flags.push(pick.confidence < CONFIDENCE_FLOOR ? "low_confidence" : "composite_order");
|
||
if (worth < 0.5) flags.push("nothing_clearly_worthwhile");
|
||
|
||
return { order, source: useJev ? "jev" : "composite", headline: useJev ? pick.choice : order[0].key,
|
||
jev_pick: valid ? pick.choice : null, jev_confidence: pick.confidence,
|
||
agrees: valid && pick.choice === order[0].key, anything_worth_running: worth, weights: WEIGHTS, flags };
|
||
}
|
||
|
||
// Neutral answers: Jev never spoke, so nothing is claimed about safety or fit. The UI must say so.
|
||
export function jevFallback(candidates: Candidate[], why: string): Verdict {
|
||
const order = candidates.map((c) => rank(c, { bottleneck_fit: { score: 0, confidence: null },
|
||
semantic_safety: { score: 0, confidence: null }, ease: { score: 0, confidence: null },
|
||
root_cause: { noul: 0 } })).sort((a, b) => b.composite - a.composite);
|
||
return { order, source: "composite", headline: order[0]?.key ?? "", jev_pick: null, jev_confidence: 0,
|
||
agrees: false, anything_worth_running: 0, weights: WEIGHTS, flags: ["jev_unavailable", why.slice(0, 200)] };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests, commit** — `npm run test` → all suites green. Commit `feat: jev composite scoring and verdict`.
|
||
|
||
---
|
||
|
||
### Task 9: OpenUI report library (rendered output, not markdown)
|
||
|
||
**Files:** create `lib/report-ui.tsx`, `tests/report-ui.test.ts`.
|
||
|
||
**Interfaces — consumes** `@openuidev/react-lang`, `zod/v4`. **Produces:** `library` (`createLibrary`, `root: "Report"`), `reportPromptOptions` (`preamble`, `additionalRules`, `examples`), and the components `Headline Metric MetricGrid Finding Findings Step StepList SqlBlock ScoreBar OptionRow OptionTable VerdictBlock Callout Report`. The same module is imported by the route (for `library.prompt()`) and by the client (for `<Renderer>`), so the prompt and the renderer can never drift apart.
|
||
|
||
Composition uses `.ref` (`z.array(Child.ref)`) and `renderNode(children)` — children are declared before their container, root last.
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
```ts
|
||
import test from "node:test";
|
||
import assert from "node:assert/strict";
|
||
const { library, reportPromptOptions } = await import("../lib/report-ui.tsx");
|
||
|
||
test("the prompt advertises every component and the root", () => {
|
||
const p = library.prompt(reportPromptOptions);
|
||
for (const n of ["Report", "Headline", "VerdictBlock", "MetricGrid", "Findings", "OptionTable",
|
||
"StepList", "SqlBlock", "ScoreBar", "Callout"]) assert.ok(p.includes(n), `missing ${n}`);
|
||
assert.match(p, /root/i);
|
||
assert.match(p, /never invent/i);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it** — module not found.
|
||
|
||
- [ ] **Step 3: Implement `lib/report-ui.tsx`**
|
||
|
||
```tsx
|
||
import { defineComponent, createLibrary } from "@openuidev/react-lang";
|
||
import { z } from "zod/v4";
|
||
|
||
const Headline = defineComponent({ name: "Headline",
|
||
description: "Report title: the one-line finding, with severity.",
|
||
props: z.object({ title: z.string(), subtitle: z.string().optional(),
|
||
severity: z.enum(["critical", "warning", "info"]).optional() }),
|
||
component: ({ props }) => <header>
|
||
<p className="label">{props.severity === "critical" ? "Critical" : props.severity === "warning" ? "Warning" : "Note"}</p>
|
||
<h2 style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-2xl)", margin: "var(--qai-space-s) 0" }}>{props.title}</h2>
|
||
{props.subtitle && <p style={{ color: "var(--qai-ink-muted)" }}>{props.subtitle}</p>}</header> });
|
||
|
||
const Metric = defineComponent({ name: "Metric", description: "One plan metric.",
|
||
props: z.object({ label: z.string(), value: z.string(), note: z.string().optional() }),
|
||
component: ({ props }) => <div className="card">
|
||
<p className="label">{props.label}</p>
|
||
<p style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-xl)" }}>{props.value}</p>
|
||
{props.note && <p style={{ fontSize: "var(--qai-text-xs)", color: "var(--qai-ink-muted)" }}>{props.note}</p>}</div> });
|
||
|
||
const MetricGrid = defineComponent({ name: "MetricGrid", description: "Row of plan metrics.",
|
||
props: z.object({ items: z.array(Metric.ref) }),
|
||
component: ({ props, renderNode }) => <div className="grid-metrics">{renderNode(props.items)}</div> });
|
||
|
||
const Finding = defineComponent({ name: "Finding", description: "One problem, the plan evidence for it, its impact.",
|
||
props: z.object({ where: z.string(), issue: z.string(), evidence: z.string(), impact: z.string() }),
|
||
component: ({ props }) => <div className="card">
|
||
<p className="label">{props.where}</p>
|
||
<p style={{ margin: "var(--qai-space-s) 0" }}><strong>{props.issue}</strong></p>
|
||
<p className="sql">{props.evidence}</p>
|
||
<p style={{ color: "var(--qai-ink-muted)" }}>{props.impact}</p></div> });
|
||
|
||
const Findings = defineComponent({ name: "Findings", description: "Everything wrong with the plan.",
|
||
props: z.object({ items: z.array(Finding.ref) }),
|
||
component: ({ props, renderNode }) => <section className="report">{renderNode(props.items)}</section> });
|
||
|
||
const Step = defineComponent({ name: "Step", description: "One action step.",
|
||
props: z.object({ text: z.string(), effort: z.enum(["low", "medium", "high"]).optional(),
|
||
owner: z.enum(["dba", "developer", "both"]).optional(), changeControl: z.boolean().optional() }),
|
||
component: ({ props }) => <li className="card" style={{ display: "flex", gap: "var(--qai-space-md)",
|
||
alignItems: "center", flexWrap: "wrap" }}>
|
||
<span style={{ flex: "1 1 200px", minWidth: 0 }}>{props.text}</span>
|
||
{props.effort && <span className="pill">effort {props.effort}</span>}
|
||
{props.owner && <span className="pill">{props.owner}</span>}
|
||
{props.changeControl && <span className="pill">change control</span>}</li> });
|
||
|
||
const StepList = defineComponent({ name: "StepList", description: "Ordered action plan for one option.",
|
||
props: z.object({ title: z.string(), steps: z.array(Step.ref) }),
|
||
component: ({ props, renderNode }) => <section>
|
||
<h3 style={{ fontFamily: "var(--qai-font-display)" }}>{props.title}</h3>
|
||
<ol style={{ listStyle: "none", padding: 0, margin: 0, display: "grid", gap: "var(--qai-space-md)" }}>
|
||
{renderNode(props.steps)}</ol></section> });
|
||
|
||
const SqlBlock = defineComponent({ name: "SqlBlock", description: "A SQL snippet the user can copy.",
|
||
props: z.object({ title: z.string(), sql: z.string(), caption: z.string().optional() }),
|
||
component: ({ props }) => <figure className="card" style={{ margin: 0 }}>
|
||
<figcaption className="label">{props.title}</figcaption>
|
||
<pre className="sql">{props.sql}</pre>
|
||
<button className="btn no-print" onClick={() => navigator.clipboard.writeText(props.sql)}>Copy SQL</button>
|
||
{props.caption && <p style={{ fontSize: "var(--qai-text-xs)", color: "var(--qai-ink-muted)" }}>{props.caption}</p>}</figure> });
|
||
|
||
const ScoreBar = defineComponent({ name: "ScoreBar", description: "One Jev dimension: normalised value plus confidence.",
|
||
props: z.object({ label: z.string(), value: z.number(), confidence: z.number().optional(), note: z.string().optional() }),
|
||
component: ({ props }) => <div>
|
||
<p className="label">{props.label}{props.confidence !== undefined
|
||
? ` · confidence ${props.confidence.toFixed(2)}` : " · no confidence reported"}</p>
|
||
<div className="bar" style={{ width: `${Math.round(props.value * 100)}%` }} />
|
||
{props.note && <p style={{ fontSize: "var(--qai-text-xs)", color: "var(--qai-ink-muted)" }}>{props.note}</p>}</div> });
|
||
|
||
const OptionRow = defineComponent({ name: "OptionRow", description: "One ranked option.",
|
||
props: z.object({ rank: z.number(), title: z.string(), composite: z.number(), headline: z.boolean(),
|
||
flags: z.string().optional() }),
|
||
component: ({ props }) => <tr data-headline={props.headline || undefined}>
|
||
<td>{props.rank}</td><td>{props.title}</td>
|
||
<td className="sql">{props.composite.toFixed(2)}</td>
|
||
<td>{props.flags || "—"}</td></tr> });
|
||
|
||
const OptionTable = defineComponent({ name: "OptionTable", description: "All options ranked by composite score.",
|
||
props: z.object({ title: z.string(), weights: z.string(), rows: z.array(OptionRow.ref) }),
|
||
component: ({ props, renderNode }) => <section className="card">
|
||
<h3 style={{ fontFamily: "var(--qai-font-display)" }}>{props.title}</h3>
|
||
<p style={{ fontSize: "var(--qai-text-xs)", color: "var(--qai-ink-muted)" }}>{props.weights}</p>
|
||
<table style={{ width: "100%", borderCollapse: "collapse" }}><tbody>{renderNode(props.rows)}</tbody></table></section> });
|
||
|
||
const VerdictBlock = defineComponent({ name: "VerdictBlock", description: "What Jev decided, and how sure it was.",
|
||
props: z.object({ headline: z.string(), why: z.string(), source: z.enum(["jev", "composite"]), confidence: z.number() }),
|
||
component: ({ props }) => <section className="card" data-verdict>
|
||
<p className="label">First move · {props.source === "jev"
|
||
? `Jev, confidence ${props.confidence.toFixed(2)}` : "composite order — Jev did not decide"}</p>
|
||
<p style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-lg)" }}>{props.headline}</p>
|
||
<p>{props.why}</p></section> });
|
||
|
||
const Callout = defineComponent({ name: "Callout", description: "A warning or note the user must not miss.",
|
||
props: z.object({ level: z.enum(["info", "warn", "danger", "success"]), title: z.string(), text: z.string() }),
|
||
component: ({ props }) => <div className="card" data-level={props.level}
|
||
role={props.level === "danger" ? "alert" : "note"}>
|
||
<p><strong>{props.title}</strong></p><p>{props.text}</p></div> });
|
||
|
||
const Report = defineComponent({ name: "Report", description: "Root. A query-plan report in reading order.",
|
||
props: z.object({ blocks: z.array(z.union([Headline.ref, VerdictBlock.ref, MetricGrid.ref, Findings.ref,
|
||
OptionTable.ref, StepList.ref, SqlBlock.ref, ScoreBar.ref, Callout.ref])) }),
|
||
component: ({ props, renderNode }) => <div className="report" id="report">{renderNode(props.blocks)}</div> });
|
||
|
||
export const library = createLibrary({ root: "Report", components: [Headline, Metric, MetricGrid, Finding, Findings,
|
||
Step, StepList, SqlBlock, ScoreBar, OptionRow, OptionTable, VerdictBlock, Callout, Report] });
|
||
|
||
export const reportPromptOptions = {
|
||
preamble: "You output only OpenUI Lang. No prose, no markdown fences, no commentary before or after.",
|
||
additionalRules: [
|
||
"root = Report([...]) and it must list every block you define.",
|
||
"Block order: Headline, VerdictBlock, MetricGrid, Findings, OptionTable, then per option its StepList and SqlBlock, then ScoreBars, then Callout.",
|
||
"Use ONLY numbers present in the input. Never invent a cost, row count, impact, or confidence.",
|
||
"One SqlBlock per rewrite, titled with that option's key. T-SQL only, no markdown.",
|
||
"Every option flagged verify_semantics gets a Callout level danger: compare result rows before deploying.",
|
||
"If verdict.source is composite, say plainly that Jev did not decide.",
|
||
],
|
||
examples: [`root = Report([head, verdict])
|
||
head = Headline("Clustered Index Scan on Sales.Orders is 84% of the plan", "No usable key on CustomerId", "critical")
|
||
verdict = VerdictBlock("Create the covering index", "bottleneck_fit 1.00, semantic_safety 1.00, ease 0.50.", "jev", 0.82)`],
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Verify + commit** — `npm run test` green; `npm run build` compiles the `.tsx` on both server and client boundaries. Commit `feat: openui report library`.
|
||
|
||
---
|
||
|
||
### Task 10: The pipeline — `POST /api/analyze` as a server-sent event stream
|
||
|
||
**Files:** create `lib/stream.ts`, `app/api/analyze/route.ts`, `tests/stream.test.ts`; modify `lib/analyst.ts` (add `streamReport` + `REPORT_SYSTEM_PROMPT`).
|
||
|
||
**Interfaces — consumes** `requireUser`, `analystConfig`, `jevKey`, `jevModel`, `digestPlan`, `PlanError`, `digestForModel`, `proposeCandidates`, `judgeCandidates`, `makeJevClient`, `jevFallback`, `library`, `reportPromptOptions`, `newAnalysis`, `patchAnalysis`.
|
||
**Produces:** `sse(event, data): string` · `readSse(body): AsyncGenerator<{ event: string; data: any }>` · `openAiTextDeltas(res): AsyncGenerator<string>` · `streamReport(cfg, payload, fetchImpl?): AsyncGenerator<string>` · `REPORT_SYSTEM_PROMPT(openuiPrompt: string): string`.
|
||
**Wire format (server → client):** `stage {stage}` (`digesting` | `proposing` | `judging` | `writing`) · `candidate {key,title}` · `verdict {Verdict}` · `oul {chunk}` · `done {id}` · `error {stage, message}`.
|
||
|
||
- [ ] **Step 1: Write the failing test for the pure stream helpers**
|
||
|
||
```ts
|
||
import test from "node:test";
|
||
import assert from "node:assert/strict";
|
||
const { sse, readSse, openAiTextDeltas } = await import("../lib/stream.ts");
|
||
|
||
const streamOf = (parts: string[]) => new ReadableStream<Uint8Array>({ start(c) {
|
||
parts.forEach((p) => c.enqueue(new TextEncoder().encode(p))); c.close(); } });
|
||
|
||
test("sse frames one event", () =>
|
||
assert.equal(sse("stage", { stage: "digesting" }), 'event: stage\ndata: {"stage":"digesting"}\n\n'));
|
||
|
||
test("readSse rejoins frames split anywhere", async () => {
|
||
const raw = sse("stage", { stage: "digesting" }) + sse("oul", { chunk: "root = Report([])" });
|
||
const events: any[] = [];
|
||
for await (const e of readSse(streamOf([raw.slice(0, 12), raw.slice(12, 40), raw.slice(40)]))) events.push(e);
|
||
assert.deepEqual(events, [{ event: "stage", data: { stage: "digesting" } },
|
||
{ event: "oul", data: { chunk: "root = Report([])" } }]);
|
||
});
|
||
test("readSse ignores a keep-alive frame it cannot parse", async () => {
|
||
const events: any[] = [];
|
||
for await (const e of readSse(streamOf([": ping\n\n", sse("done", { id: "1" })]))) events.push(e);
|
||
assert.deepEqual(events, [{ event: "message", data: undefined } satisfies any].slice(1));
|
||
});
|
||
test("openAiTextDeltas yields content and stops at [DONE]", async () => {
|
||
const res = new Response(streamOf(['data: {"choices":[{"delta":{"content":"ab"}}]}\n\n',
|
||
'data: {"choices":[{"delta":{"content":"cd"}}]}\n\n', "data: [DONE]\n\n"]), { status: 200 });
|
||
const out: string[] = [];
|
||
for await (const d of openAiTextDeltas(res)) out.push(d);
|
||
assert.equal(out.join(""), "abcd");
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run it** — module not found.
|
||
|
||
- [ ] **Step 3: Implement `lib/stream.ts`**
|
||
|
||
```ts
|
||
export const sse = (event: string, data: unknown) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||
|
||
export async function* readSse(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event: string; data: any }> {
|
||
const reader = body.getReader(); const dec = new TextDecoder(); let buf = "";
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buf += dec.decode(value, { stream: true });
|
||
let at: number;
|
||
while ((at = buf.indexOf("\n\n")) !== -1) {
|
||
const frame = buf.slice(0, at); buf = buf.slice(at + 2);
|
||
const event = /^event: (.+)$/m.exec(frame)?.[1] ?? "message";
|
||
const raw = /^data: (.*)$/m.exec(frame)?.[1] ?? "";
|
||
try { yield { event, data: JSON.parse(raw) }; } catch { /* keep-alive or unparsable frame */ }
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function* openAiTextDeltas(res: Response): AsyncGenerator<string> {
|
||
const reader = res.body!.getReader(); const dec = new TextDecoder(); let buf = "";
|
||
while (true) {
|
||
const { value, done } = await reader.read();
|
||
if (done) break;
|
||
buf += dec.decode(value, { stream: true });
|
||
let at: number;
|
||
while ((at = buf.indexOf("\n\n")) !== -1) {
|
||
const line = (buf.slice(0, at).split("\n").find((l) => l.startsWith("data:")) ?? "").slice(5).trim();
|
||
buf = buf.slice(at + 2);
|
||
if (!line) continue;
|
||
if (line === "[DONE]") return;
|
||
let delta: unknown;
|
||
try { delta = JSON.parse(line)?.choices?.[0]?.delta?.content; } catch { continue; }
|
||
if (typeof delta === "string" && delta) yield delta;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
Delete the third test's odd `satisfies` line and use this instead — a keep-alive frame yields nothing:
|
||
|
||
```ts
|
||
test("readSse ignores frames it cannot parse", async () => {
|
||
const events: any[] = [];
|
||
for await (const e of readSse(streamOf([": ping\n\n", sse("done", { id: "1" })]))) events.push(e);
|
||
assert.deepEqual(events, [{ event: "done", data: { id: "1" } }]);
|
||
});
|
||
```
|
||
|
||
Append to `lib/analyst.ts`:
|
||
|
||
```ts
|
||
export const REPORT_SYSTEM_PROMPT = (openuiPrompt: string) => `${openuiPrompt}
|
||
|
||
You are writing the final answer for a DBA holding one SQL Server execution plan. The ranking you are given was
|
||
computed by Jev from typed judgments. It is not your opinion: do not re-rank it, soften it, or hide it. Quote each
|
||
option's scores as given. A rewrite must return the same rows as the original — when in doubt keep the original text
|
||
and say why. Never invent a number that is not in the input.`;
|
||
|
||
export async function* streamReport(cfg: AnalystConfig, payload: unknown, openuiPrompt: string,
|
||
fetchImpl: typeof fetch = fetch): AsyncGenerator<string> {
|
||
const res = await fetchImpl(`${cfg.baseUrl}/chat/completions`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` },
|
||
body: JSON.stringify({ model: cfg.model, temperature: 0.3, ...cfg.extra, stream: true,
|
||
messages: [{ role: "system", content: REPORT_SYSTEM_PROMPT(openuiPrompt) },
|
||
{ role: "user", content: JSON.stringify(payload) }] }),
|
||
}).catch((e) => { throw new AnalystError(`Could not reach ${cfg.baseUrl}: ${(e as Error).message}`); });
|
||
if (!res.ok || !res.body) throw new AnalystError(`Analyst model returned HTTP ${res.status} while writing the report.`);
|
||
yield* openAiTextDeltas(res);
|
||
}
|
||
```
|
||
|
||
Add the import at the top of `lib/analyst.ts`: `import { openAiTextDeltas } from "@/lib/stream";`.
|
||
|
||
- [ ] **Step 4: Implement `app/api/analyze/route.ts`**
|
||
|
||
The whole run is one stream. Every stage is announced, every failure is named with its stage, and the row is saved with whatever it managed to produce.
|
||
|
||
```ts
|
||
import { requireUser } from "@/lib/auth";
|
||
import { analystConfig, jevKey, jevModel } from "@/lib/settings";
|
||
import { digestPlan, digestForModel, PlanError } from "@/lib/digest";
|
||
import { proposeCandidates, streamReport, AnalystError } from "@/lib/analyst";
|
||
import { judgeCandidates, jevFallback, makeJevClient } from "@/lib/jev";
|
||
import { sse } from "@/lib/stream";
|
||
import { newAnalysis, patchAnalysis } from "@/lib/db";
|
||
import { library, reportPromptOptions } from "@/lib/report-ui";
|
||
|
||
export const runtime = "nodejs";
|
||
export const dynamic = "force-dynamic";
|
||
const MAX_BODY_BYTES = 16_000_000;
|
||
|
||
export async function POST(req: Request) {
|
||
const user = await requireUser().catch(() => null);
|
||
if (!user) return Response.json({ error: "Sign in first." }, { status: 401 });
|
||
|
||
const analyst = analystConfig(user.id);
|
||
const jev = jevKey(user.id);
|
||
if (!analyst) return Response.json({ error: "Set the analyst model in Settings first." }, { status: 412 });
|
||
if (!jev) return Response.json({ error: "Set your Jev (TypeSafe) API key in Settings first." }, { status: 412 });
|
||
|
||
const raw = await req.text();
|
||
if (raw.length > MAX_BODY_BYTES) return Response.json({ error: "Request too large." }, { status: 413 });
|
||
let xml = ""; let note = "";
|
||
try { const body = JSON.parse(raw); xml = String(body.xml ?? ""); note = String(body.note ?? "").slice(0, 500); }
|
||
catch { return Response.json({ error: "Expected JSON with an xml field." }, { status: 400 }); }
|
||
|
||
const id = newAnalysis(user.id, note.trim().slice(0, 120) || "Plan analysis", xml);
|
||
const enc = new TextEncoder();
|
||
const prompt = library.prompt(reportPromptOptions);
|
||
|
||
const stream = new ReadableStream<Uint8Array>({ async start(ctrl) {
|
||
const send = (event: string, data: unknown) => ctrl.enqueue(enc.encode(sse(event, data)));
|
||
let oul = "";
|
||
const close = (status: string, error?: string) => {
|
||
patchAnalysis(id, { status, ...(error ? { error } : {}), ...(oul ? { oul } : {}) });
|
||
send("done", { id }); ctrl.close();
|
||
};
|
||
try {
|
||
send("stage", { stage: "digesting" });
|
||
let digest;
|
||
try { digest = digestPlan(xml); }
|
||
catch (e) { const m = e instanceof PlanError ? e.message : "Could not read that plan.";
|
||
send("error", { stage: "digesting", message: m }); patchAnalysis(id, { status: "failed", error: m });
|
||
send("done", { id }); return ctrl.close(); }
|
||
patchAnalysis(id, { digest: JSON.stringify(digest) });
|
||
|
||
send("stage", { stage: "proposing" });
|
||
let candidates;
|
||
try { candidates = await proposeCandidates(analyst, digest, note); }
|
||
catch (e) { const m = e instanceof AnalystError ? e.message : "The analyst model call failed.";
|
||
send("error", { stage: "proposing", message: m }); patchAnalysis(id, { status: "failed", error: m });
|
||
send("done", { id }); return ctrl.close(); }
|
||
candidates.forEach((c) => send("candidate", { key: c.key, title: c.title }));
|
||
patchAnalysis(id, { candidates: JSON.stringify(candidates) });
|
||
|
||
send("stage", { stage: "judging" });
|
||
let verdict;
|
||
try { verdict = await judgeCandidates(digest, candidates, makeJevClient(jev, jevModel(user.id))); }
|
||
catch (e) { verdict = jevFallback(candidates, `Jev call failed: ${(e as Error).message}`); }
|
||
send("verdict", verdict);
|
||
patchAnalysis(id, { verdict: JSON.stringify(verdict) });
|
||
|
||
send("stage", { stage: "writing" });
|
||
for await (const chunk of streamReport(analyst,
|
||
{ plan: digestForModel(digest), options: candidates, verdict }, prompt)) {
|
||
oul += chunk; send("oul", { chunk });
|
||
}
|
||
close(oul ? "done" : "partial", oul ? undefined : "The analyst model returned an empty report.");
|
||
} catch (e) {
|
||
const m = (e as Error)?.message ?? "Analysis failed.";
|
||
send("error", { stage: "stream", message: m });
|
||
patchAnalysis(id, { status: "failed", error: m, ...(oul ? { oul } : {}) });
|
||
send("done", { id }); ctrl.close();
|
||
}
|
||
} });
|
||
|
||
return new Response(stream, { headers: { "content-type": "text/event-stream; charset=utf-8",
|
||
"cache-control": "no-store", connection: "keep-alive", "x-accel-buffering": "no" } });
|
||
}
|
||
```
|
||
|
||
No `cancel()` handler: the default is right. When the DBA closes the tab, the producer's next `send` throws, the `catch` stores the partial row, and that is the behaviour we want.
|
||
|
||
- [ ] **Step 5: Verify + commit**
|
||
|
||
`npm run test` green. Manual, with `npm run dev`, a signed-in browser session, and real keys:
|
||
|
||
```bash
|
||
curl -sN -X POST localhost:3000/api/analyze -H 'content-type: application/json' \
|
||
--cookie "qai_s=<session token>" \
|
||
-d '{"xml":"<ShowPlanXML ...>","note":"slow after the migration"}' | head -20
|
||
```
|
||
|
||
Expected: `event: stage` frames, then `candidate`, `verdict`, a run of `oul` chunks, then `done`. Then prove Review-Focus 1 and 5: post `{"xml":"SELECT 1"}` and confirm exactly one `error` frame with `stage: digesting` and a `done` frame, never a hanging stream; and post a valid plan with the Jev key invalidated to confirm a `verdict` with `source: "composite"` and the `jev_unavailable` flag. Commit `feat: analysis pipeline with staged SSE`.
|
||
|
||
---
|
||
|
||
### Task 11: Console UI — rail, run, live render, rename, delete, copy, PDF
|
||
|
||
**Files:** create `app/app/page.tsx` (replace), `app/app/[id]/page.tsx`, `components/rail.tsx`, `components/history-item.tsx`, `components/runner.tsx`, `components/report-view.tsx`, `components/export-buttons.tsx`.
|
||
|
||
**Interfaces — consumes** `library`, `readSse`, `listAnalyses`, `getAnalysis`, `rename`, `remove`, `digestForModel`. **Produces:** the `/app` run surface and `/app/[id]` replay.
|
||
|
||
- [ ] **Step 1: Rail + history rows (server components)**
|
||
|
||
`components/history-item.tsx` — inline rename, delete behind a confirm:
|
||
|
||
```tsx
|
||
import { rename, remove } from "@/lib/actions";
|
||
|
||
export function HistoryItem({ row }: { row: { id: string; title: string; status: string; created_at: number } }) {
|
||
const when = new Date(row.created_at).toLocaleString(undefined,
|
||
{ month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
|
||
return <li className="card" style={{ padding: "var(--qai-space-md)", display: "grid", gap: "var(--qai-space-s)" }}>
|
||
<a href={`/app/${row.id}`} style={{ color: "var(--qai-ink)", textDecoration: "none", fontSize: "var(--qai-text-sm)" }}>
|
||
{row.title}{row.status !== "done" && <span className="pill" style={{ marginInlineStart: 8 }}>{row.status}</span>}
|
||
</a>
|
||
<div style={{ display: "flex", gap: "var(--qai-space-s)", alignItems: "center" }}>
|
||
<span className="label">{when}</span>
|
||
<form action={rename} style={{ display: "flex", gap: 4, marginLeft: "auto" }}>
|
||
<input type="hidden" name="id" value={row.id} />
|
||
<input className="field" name="title" aria-label="New name" defaultValue={row.title}
|
||
style={{ width: 90, padding: "4px 6px" }} />
|
||
<button className="btn" style={{ padding: "4px 8px" }}>Save</button>
|
||
</form>
|
||
<form action={remove} onSubmit={(e) => { if (!confirm("Delete this analysis?")) e.preventDefault(); }}>
|
||
<input type="hidden" name="id" value={row.id} />
|
||
<button className="btn" style={{ padding: "4px 8px" }}>Delete</button>
|
||
</form>
|
||
</div>
|
||
</li>;
|
||
}
|
||
```
|
||
|
||
`onSubmit` with `confirm` needs a client boundary — put `HistoryItem` in a file starting with `"use client";` and import `confirm` from `window` (it is a browser global; no import needed). Delete is the only destructive action in the product: it must never fire on a stray click.
|
||
|
||
`components/rail.tsx`:
|
||
|
||
```tsx
|
||
import { listAnalyses } from "@/lib/db";
|
||
import { HistoryItem } from "./history-item";
|
||
export async function Rail({ userId }: { userId: string }) {
|
||
const rows = listAnalyses(userId);
|
||
return <>
|
||
<a href="/app" className="label" style={{ textDecoration: "none", color: "var(--qai-accent)" }}>qAI</a>
|
||
<p className="label">History · {rows.length}</p>
|
||
<ul style={{ listStyle: "none", padding: 0, margin: 0, display: "grid", gap: "var(--qai-space-s)" }}>
|
||
{rows.map((r) => <HistoryItem key={r.id} row={r} />)}
|
||
</ul>
|
||
<p className="label" style={{ marginTop: "auto" }}>
|
||
<a href="/settings" style={{ color: "var(--qai-ink-muted)" }}>Settings</a></p>
|
||
</>;
|
||
}
|
||
```
|
||
|
||
`app/app/layout.tsx` — replace the placeholder rail:
|
||
|
||
```tsx
|
||
import { requireUser } from "@/lib/auth";
|
||
import { Rail } from "@/components/rail";
|
||
export default async function Console({ children }: { children: React.ReactNode }) {
|
||
const u = await requireUser();
|
||
return <div className="shell">
|
||
<aside className="rail no-print" aria-label="Previous analyses"><Rail userId={u.id} /></aside>
|
||
<main className="main">{children}</main>
|
||
</div>;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Runner + live report**
|
||
|
||
`components/report-view.tsx`:
|
||
|
||
```tsx
|
||
"use client";
|
||
import { Renderer } from "@openuidev/react-lang";
|
||
import { library } from "@/lib/report-ui";
|
||
export function ReportView({ oul, streaming }: { oul: string; streaming: boolean }) {
|
||
if (!oul) return null;
|
||
return <Renderer response={oul} library={library} isStreaming={streaming} />;
|
||
}
|
||
```
|
||
|
||
`components/export-buttons.tsx` — Copy takes the OpenUI Lang source plus the SQL blocks it contains; PDF is the browser's own print path with a stylesheet that flips tokens to paper:
|
||
|
||
```tsx
|
||
"use client";
|
||
export function ExportButtons({ oul, title }: { oul: string; title: string }) {
|
||
const copy = async () => {
|
||
const sql = [...document.querySelectorAll<HTMLElement>("#report pre.sql")].map((p) => p.textContent).join("\n\n");
|
||
await navigator.clipboard.writeText(`${title}\n\n${sql || oul}`);
|
||
};
|
||
return <div className="toolbar no-print" style={{ display: "flex", gap: "var(--qai-space-s)" }}>
|
||
<button className="btn" onClick={copy}>Copy</button>
|
||
<button className="btn" onClick={() => window.print()}>Export to PDF</button>
|
||
</div>;
|
||
}
|
||
```
|
||
|
||
`components/runner.tsx` — stage indicator then token-by-token render (ui-ux-pro-max: no spinner for 30 seconds, always show progress):
|
||
|
||
```tsx
|
||
"use client";
|
||
import { useState } from "react";
|
||
import { readSse } from "@/lib/stream";
|
||
import { ReportView } from "./report-view";
|
||
import { ExportButtons } from "./export-buttons";
|
||
|
||
const STAGES = ["digesting", "proposing", "judging", "writing"] as const;
|
||
const LABEL: Record<string, string> = { digesting: "Reading the plan", proposing: "Asking for options",
|
||
judging: "Jev is scoring", writing: "Writing the report" };
|
||
|
||
export function Runner() {
|
||
const [xml, setXml] = useState(""); const [note, setNote] = useState("");
|
||
const [stage, setStage] = useState<string | null>(null);
|
||
const [oul, setOul] = useState(""); const [error, setError] = useState<string | null>(null);
|
||
const at = STAGES.indexOf(stage ?? "");
|
||
|
||
async function run() {
|
||
setError(null); setOul(""); setStage("digesting");
|
||
const res = await fetch("/api/analyze", { method: "POST",
|
||
headers: { "content-type": "application/json" }, body: JSON.stringify({ xml, note }) });
|
||
if (!res.ok || !res.body) { setError((await res.json().catch(() => null))?.error ?? `Request failed (${res.status}).`);
|
||
return setStage(null); }
|
||
for await (const e of readSse(res.body)) {
|
||
if (e.event === "stage") setStage(e.data.stage);
|
||
else if (e.event === "oul") setOul((s) => s + e.data.chunk);
|
||
else if (e.event === "error") setError(`${e.data.stage}: ${e.data.message}`);
|
||
else if (e.event === "done") { setStage(null); history.replaceState(null, "", `/app/${e.data.id}`); }
|
||
}
|
||
}
|
||
return <section style={{ display: "grid", gap: "var(--qai-space-xl)" }}>
|
||
<h1 style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-display)",
|
||
letterSpacing: "-0.02em", margin: 0 }}>Paste a plan</h1>
|
||
<details className="card">
|
||
<summary style={{ cursor: "pointer", fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-sm)" }}>
|
||
Where do I get this XML?</summary>
|
||
<ol style={{ fontSize: "var(--qai-text-sm)", color: "var(--qai-ink-muted)", margin: "var(--qai-space-md) 0 0" }}>
|
||
<li>SSMS: press <strong>Ctrl+M</strong> (Include Actual Execution Plan), then run the query.</li>
|
||
<li>On the Execution Plan tab, right-click → <strong>Save As .sqlplan</strong>. Or right-click the statement → <strong>Showplan XML</strong> and copy the cell.</li>
|
||
<li>No SSMS: prefix the batch with <code>SET STATISTICS XML ON;</code> and copy the XML result cell.</li>
|
||
<li>Drop the file or paste its contents below. Only the digest leaves this container, and only to your own models.</li>
|
||
</ol>
|
||
</details>
|
||
<div className="runner card" style={{ display: "grid", gap: "var(--qai-space-md)" }}>
|
||
<label className="label" htmlFor="xml">ShowPlan XML or .sqlplan contents</label>
|
||
<textarea className="field" id="xml" rows={7} spellCheck={false} value={xml}
|
||
onChange={(e) => setXml(e.target.value)} placeholder="<ShowPlanXML …>" />
|
||
<label className="label" htmlFor="note">Anything the plan cannot see? (optional)</label>
|
||
<input className="field" id="note" value={note} onChange={(e) => setNote(e.target.value)}
|
||
placeholder="slow since the 16.0.4085 migration" />
|
||
<div style={{ display: "flex", gap: "var(--qai-space-s)", alignItems: "center", flexWrap: "wrap" }}>
|
||
<button className="btn btn-primary" onClick={run} disabled={!xml.trim() || stage !== null}>Analyse</button>
|
||
<input type="file" accept=".sqlplan,.xml" aria-label="Load a .sqlplan file" style={{ fontSize: "var(--qai-text-xs)" }}
|
||
onChange={async (e) => {
|
||
const f = e.target.files?.[0]; if (!f) return;
|
||
const buf = new Uint8Array(await f.arrayBuffer());
|
||
// SSMS saves UTF-16LE; File.text() decodes that as UTF-8 and fills the plan with NUL bytes.
|
||
const utf16 = buf[0] === 0xff && buf[1] === 0xfe;
|
||
setXml(new TextDecoder(utf16 ? "utf-16le" : "utf-8").decode(buf).replace(/^\uFEFF/, ""));
|
||
}} />
|
||
{at >= 0 && <span className="pill" aria-live="polite">{at + 1} of 4 · {LABEL[STAGES[at]]}</span>}
|
||
</div>
|
||
{error && <p role="alert" style={{ color: "var(--qai-danger)" }}>{error}</p>}
|
||
</div>
|
||
{oul && <ExportButtons oul={oul} title={note || "Query plan analysis"} />}
|
||
<ReportView oul={oul} streaming={stage !== null} />
|
||
</section>;
|
||
}
|
||
```
|
||
|
||
`app/app/page.tsx`:
|
||
|
||
```tsx
|
||
import { Runner } from "@/components/runner";
|
||
export default function Run() { return <Runner />; }
|
||
```
|
||
|
||
- [ ] **Step 3: Replay a stored analysis (zero model calls)**
|
||
|
||
`app/app/[id]/page.tsx`:
|
||
|
||
```tsx
|
||
import { notFound, redirect } from "next/navigation";
|
||
import { requireUser } from "@/lib/auth";
|
||
import { getAnalysis } from "@/lib/db";
|
||
import { ReportView } from "@/components/report-view";
|
||
import { ExportButtons } from "@/components/export-buttons";
|
||
import { VerdictSummary } from "@/components/verdict-summary";
|
||
|
||
export default async function Stored({ params }: { params: Promise<{ id: string }> }) {
|
||
const u = await requireUser();
|
||
const a = getAnalysis((await params).id, u.id);
|
||
if (!a) notFound();
|
||
if (a.status === "running") redirect(`/app/${a.id}`); // still streaming elsewhere; rare, bounce once
|
||
return <main style={{ display: "grid", gap: "var(--qai-space-xl)" }}>
|
||
<h1 style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-2xl)", margin: 0 }}>{a.title}</h1>
|
||
{a.error && <p role="alert" style={{ color: "var(--qai-danger)" }}>{a.error}</p>}
|
||
{a.verdict && <VerdictSummary verdict={JSON.parse(a.verdict)} />}
|
||
{a.oul ? <><ExportButtons oul={a.oul} title={a.title} /><ReportView oul={a.oul} streaming={false} /></>
|
||
: <p className="label">No report was stored for this run.</p>}
|
||
</main>;
|
||
}
|
||
```
|
||
|
||
`components/verdict-summary.tsx` — server component, so a stored Jev verdict is readable even if the report body failed to generate:
|
||
|
||
```tsx
|
||
export function VerdictSummary({ verdict }: { verdict: any }) {
|
||
const win = verdict.order.find((o: any) => o.key === verdict.headline) ?? verdict.order[0];
|
||
return <section className="card" data-verdict>
|
||
<p className="label">Jev verdict · {verdict.source === "jev" ? `chosen, confidence ${verdict.jev_confidence.toFixed(2)}`
|
||
: "composite order — Jev did not decide"}</p>
|
||
<p style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-lg)" }}>{win?.key}</p>
|
||
<p className="label">weights {Object.entries(verdict.weights as Record<string, number>)
|
||
.map(([k, v]) => `${k} ${v}`).join(" · ")}</p>
|
||
{(verdict.flags as string[]).length > 0 && <p style={{ color: "var(--qai-warn)" }}>{verdict.flags.join(" · ")}</p>}
|
||
</section>;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Verify at four widths, then commit**
|
||
|
||
`npm run build`, then `npm run dev` and walk the flow: paste the fixture plan from `tests/digest.test.ts` → stage pills advance 1 of 4 → 2 → 3 → 4 → report blocks appear while `oul` is still arriving → Copy puts SQL on the clipboard → Export to PDF prints white-on-black-free paper (tokens flip in print) with rail and buttons hidden. Rename a row and confirm the title changes without a reload; delete one and confirm the dialog appears first. Resize to 320 / 375 / 414 / 768 px: no horizontal scroll, rail unsticks below 860 px. Commit `feat: console ui, streaming report, history actions`.
|
||
|
||
---
|
||
|
||
### Task 12: Container, README, Jev goal gate
|
||
|
||
**Files:** create `Dockerfile`, `docker-compose.yml`, `.dockerignore`, `README.md`, `scripts/smoke.sh`.
|
||
|
||
- [ ] **Step 1: Dockerfile** — single image, no rebuild of native modules because nothing is native:
|
||
|
||
```dockerfile
|
||
FROM node:24-slim AS deps
|
||
WORKDIR /app
|
||
COPY package.json package-lock.json ./
|
||
RUN npm ci
|
||
|
||
FROM node:24-slim AS build
|
||
WORKDIR /app
|
||
COPY --from=deps /app/node_modules ./node_modules
|
||
COPY . .
|
||
ENV NEXT_TELEMETRY_DISABLED=1
|
||
RUN npm run build
|
||
|
||
FROM node:24-slim AS run
|
||
WORKDIR /app
|
||
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 DATA_DIR=/data PORT=3000
|
||
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* \
|
||
&& mkdir -p /data && chown node:node /data
|
||
COPY --from=build --chown=node:node /app/.next ./.next
|
||
COPY --from=build --chown=node:node /app/public ./public
|
||
COPY --from=build --chown=node:node /app/node_modules ./node_modules
|
||
COPY --from=build --chown=node:node /app/package.json ./package.json
|
||
COPY --from=build --chown=node:node /app/next.config.* ./
|
||
USER node
|
||
VOLUME /data
|
||
EXPOSE 3000
|
||
HEALTHCHECK --interval=30s --timeout=3s CMD curl -fsS localhost:3000/api/healthz
|
||
CMD ["npm", "start"]
|
||
```
|
||
|
||
`docker-compose.yml`:
|
||
|
||
```yaml
|
||
services:
|
||
qai:
|
||
build: .
|
||
ports: ["3000:3000"]
|
||
environment:
|
||
APP_SECRET: ${APP_SECRET:?set APP_SECRET in .env, 16+ chars}
|
||
volumes: ["qai-data:/data"]
|
||
restart: unless-stopped
|
||
volumes:
|
||
qai-data:
|
||
```
|
||
|
||
`.dockerignore`: `.next`, `node_modules`, `data`, `.git`, `.hallmark`, `docs`, `design-system`, `tests`.
|
||
|
||
- [ ] **Step 2: `scripts/smoke.sh`** — the one command that proves a container works:
|
||
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
docker compose up --build -d
|
||
trap 'docker compose down -v' EXIT
|
||
for i in $(seq 1 40); do curl -fsS localhost:3000/api/healthz >/dev/null 2>&1 && break; sleep 2; done
|
||
curl -fsS localhost:3000/api/healthz
|
||
curl -fsS localhost:3000/login | grep -qi "sign in"
|
||
curl -fsS -o /dev/null -w '%{http_code}\n' localhost:3000/app | grep -q 307
|
||
echo "smoke ok"
|
||
```
|
||
|
||
- [ ] **Step 3: README** — keep it to what a person needs in the first five minutes:
|
||
|
||
```markdown
|
||
# SQL Server qAI
|
||
|
||
Paste a SQL Server execution plan. Get several ranked ways to fix it, with Jev deciding which one to run first.
|
||
|
||
cp .env.example .env # set APP_SECRET (16+ chars)
|
||
docker compose up --build # http://localhost:3000
|
||
|
||
Sign up → 3-step guide (analyst model + Jev key) → paste XML → Analyse.
|
||
|
||
Getting the XML: SSMS → Ctrl+M (include actual execution plan) → run the query → save the .sqlplan from the
|
||
Execution Plan tab. No SSMS: prefix the batch with SET STATISTICS XML ON; and copy the XML result cell.
|
||
|
||
- **Analyst model**: any OpenAI-compatible base URL + key + model. Proposes 2–4 options and rewrites.
|
||
Keys are entered by each user in the app — never in a server env var.
|
||
- **Jev**: your TypeSafe API key (console.typesafe.ai/keys), also entered per user in the app.
|
||
- Credentials are encrypted at rest with APP_SECRET and never leave the container.
|
||
- Dev analyst endpoint used for local QA: `http://192.168.88.131:8888/v1`, model `qwen3.8-flash-next`, key `EMPTY`.
|
||
Verified against that endpoint: `system` role only (no developer role), `max_tokens` is the token field, streaming
|
||
emits `data:` lines and ends with `data: [DONE]`, and content may arrive with leading whitespace — `extractJson` handles it.
|
||
- `npm test` runs the unit suites. `bash scripts/smoke.sh` proves the image.
|
||
- Deleting the `qai-data` volume deletes every account and analysis.
|
||
```
|
||
|
||
- [ ] **Step 4: Jev goal gate** — one call, three typed questions, a printed yes/no. Run it against the spec, not the plan: the spec is the goal statement, the plan is this file.
|
||
|
||
```js
|
||
// scripts/jev-gate.mjs — TYPESAFE_API_KEY=... node scripts/jev-gate.mjs docs/superpowers/plans/<plan>.md
|
||
import { readFileSync } from "node:fs";
|
||
const key = process.env.TYPESAFE_API_KEY ?? process.env.JEV_API_KEY;
|
||
if (!key) { console.error("set TYPESAFE_API_KEY"); process.exit(1); }
|
||
const plan = readFileSync(process.argv[2] ?? "docs/superpowers/plans/2026-09-21-sql-server-qai.md", "utf8").slice(0, 60_000);
|
||
const goal = `A portal that takes the XML of a SQL Server query plan, uses generative AI to analyse it, provides
|
||
multiple action steps and multiple query rewrites where possible, and then has Jev rank the options and decide the
|
||
best one, returning the best query too. Output is rendered with OpenUI elements, with copy and export-to-PDF.`;
|
||
const r = await fetch("https://api.typesafe.ai/v1/systemone", {
|
||
method: "POST", headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
||
body: JSON.stringify({ model: "jev-latest", state: { goal, plan }, questions: {
|
||
matches_goal: { type: "noul", instructions: "Would software built exactly as described in `plan` satisfy the goal in `goal`?",
|
||
criteria: { true: "Every part of the goal is built by some task in the plan", false: "A part of the goal is missing or replaced by something else" } },
|
||
coverage: { type: "score", instructions: "How completely does `plan` cover each part of the goal in `goal`?",
|
||
criteria: ["Most of the goal is absent.", "The happy path only; failures and edge cases missing.",
|
||
"Most parts, with one thin area.", "Every part has a task with a test.",
|
||
"Every part has a task, a test, and a named failure behaviour."] },
|
||
missing_something: { type: "noul", instructions: "Does `plan` omit something the user must do to reach the goal in `goal`?" } } }) });
|
||
if (!r.ok) { console.error(`Jev HTTP ${r.status}: ${(await r.text()).slice(0, 300)}`); process.exit(1); }
|
||
const a = (await r.json()).answers;
|
||
console.log({ verdict: a.matches_goal.noul >= 0.5 ? "YES" : "NO", ...a,
|
||
note: "YES needs matches_goal >= 0.5 AND coverage >= 3 AND missing_something < 0.5" });
|
||
```
|
||
|
||
- [ ] **Step 5: Verify + commit**
|
||
|
||
```bash
|
||
npm test && bash scripts/smoke.sh
|
||
TYPESAFE_API_KEY=sk-... node scripts/jev-gate.mjs docs/superpowers/plans/2026-09-21-sql-server-qai.md
|
||
```
|
||
|
||
Expected: smoke prints `smoke ok`; the gate prints `verdict: YES` with `coverage >= 3`. If it says NO, `missing_something` names the gap in the plan — fix the task that owns it, then re-run. Commit `feat: docker, readme, jev goal gate`.
|
||
|
||
---
|
||
|
||
### Task 13: Browser QA with Playwright
|
||
|
||
**Files:** create `playwright.config.ts`, `fixtures/sample.sqlplan`, `tests-e2e/auth.setup.ts`, `tests-e2e/analysis.spec.ts`, `tests-e2e/history.spec.ts`, `tests-e2e/responsive.spec.ts`; modify `package.json`, `.gitignore`.
|
||
|
||
**Interfaces — consumes** the running app on `localhost:3000`, `.env.local` (`APP_SECRET`, `JEV_API_KEY`), the local analyst endpoint `http://192.168.88.131:8888/v1` with model `qwen3.8-flash-next` and key `EMPTY`.
|
||
|
||
- [ ] **Step 1: Install**
|
||
|
||
```bash
|
||
npm i -D @playwright/test && npx playwright install chromium
|
||
npm pkg set scripts.e2e="playwright test"
|
||
echo ".auth/test-results/playwright-report" >> .gitignore
|
||
```
|
||
|
||
- [ ] **Step 2: `playwright.config.ts`** — reads `.env.local` itself (Playwright does not load Next's env files), boots the app, and runs the auth setup once:
|
||
|
||
```ts
|
||
import { defineConfig, devices } from "@playwright/test";
|
||
import { readFileSync, existsSync } from "node:fs";
|
||
|
||
if (existsSync(".env.local")) for (const line of readFileSync(".env.local", "utf8").split("\n")) {
|
||
const m = /^([A-Z_]+)=(.*)$/.exec(line.trim());
|
||
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
|
||
}
|
||
|
||
export default defineConfig({
|
||
testDir: "./tests-e2e",
|
||
fullyParallel: false, // one SQLite file, one app process
|
||
workers: 1,
|
||
retries: 0,
|
||
timeout: 120_000, // real models are slow; do not paper over it with retries
|
||
reporter: [["list"]],
|
||
use: { baseURL: "http://localhost:3000", trace: "retain-on-failure" },
|
||
projects: [
|
||
{ name: "setup", testMatch: /auth\.setup\.ts/ },
|
||
{ name: "chromium", use: { ...devices["Desktop Chrome"] }, dependencies: ["setup"] },
|
||
{ name: "mobile", use: { ...devices["Pixel 7"] }, dependencies: ["setup"] },
|
||
],
|
||
webServer: { command: "npm run dev", url: "http://localhost:3000/api/healthz", reuseExistingServer: true,
|
||
env: { DATA_DIR: ".playwright-data", APP_SECRET: process.env.APP_SECRET ?? "playwright-secret-value" } },
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: `tests-e2e/auth.setup.ts`** — signs up, then fills Settings from the panel exactly the way a real user does (this is also the proof that keys are user-supplied, not server config):
|
||
|
||
```ts
|
||
import { test, expect } from "@playwright/test";
|
||
|
||
const EMAIL = `qa+${Date.now()}@qai.test`;
|
||
|
||
test("signup + first-run guide configures both models", async ({ page }) => {
|
||
await page.goto("/signup");
|
||
await page.fill('#email', EMAIL);
|
||
await page.fill('#password', "playwright password");
|
||
await page.getByRole("button", { name: "Create account" }).click();
|
||
|
||
await expect(page.getByText("Step 1 of 3")).toBeVisible();
|
||
await page.getByRole("link", { name: "Next" }).click();
|
||
await expect(page.getByText("Step 2 of 3")).toBeVisible();
|
||
|
||
await page.fill('#analyst_base_url', "http://192.168.88.131:8888/v1");
|
||
await page.fill('#analyst_key', "EMPTY");
|
||
await page.fill('#analyst_model', "qwen3.8-flash-next");
|
||
await page.fill('#jev_key', process.env.JEV_API_KEY ?? "");
|
||
await page.getByRole("button", { name: "Save" }).click();
|
||
await expect(page.getByText("Saved.")).toBeVisible();
|
||
|
||
await page.goto("/setup?step=3");
|
||
await expect(page.getByText(/Analyst ready · Jev ready/)).toBeVisible();
|
||
await page.context().storageState({ path: ".auth/user.json" });
|
||
});
|
||
|
||
test("signed-out visitor is pushed to login", async ({ browser }) => {
|
||
const ctx = await browser.newContext();
|
||
await ctx.goto("/app");
|
||
expect(ctx.pages[0].url()).toContain("/login");
|
||
await ctx.close();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: `fixtures/sample.sqlplan`** — copy the `PLAN` template string from `tests/digest.test.ts` into this file, and save it **with a UTF-8 BOM** so the file-upload path is tested against the byte order mark real SSMS files carry.
|
||
|
||
- [ ] **Step 5: `tests-e2e/analysis.spec.ts`**
|
||
|
||
```ts
|
||
import { test, expect } from "@playwright/test";
|
||
import { readFileSync } from "node:fs";
|
||
|
||
const xml = readFileSync("fixtures/sample.sqlplan", "utf8");
|
||
test.use({ storageState: ".auth/user.json", permissions: ["clipboard-read", "clipboard-write"] });
|
||
|
||
test("full run: paste → stages → Jev verdict → rendered report", async ({ page }) => {
|
||
await page.goto("/app");
|
||
await page.fill('#xml', xml);
|
||
await page.fill('#note', "QA run");
|
||
await page.getByRole("button", { name: "Analyse" }).click();
|
||
|
||
await expect(page.getByText(/1 of 4|2 of 4|3 of 4|4 of 4/)).toBeVisible({ timeout: 15_000 });
|
||
await expect(page.locator('#report')).toBeVisible({ timeout: 120_000 });
|
||
|
||
// Jev decided, or honestly did not — either way the report must say which.
|
||
await expect(page.locator('#report').getByText(/First move/)).toBeVisible();
|
||
// The report is OpenUI elements, not a markdown blob.
|
||
expect(await page.locator('#report .card, #report h2, #report table').count()).toBeGreaterThan(2);
|
||
// At least one copyable SQL block.
|
||
expect(await page.locator('#report pre.sql').count()).toBeGreaterThan(0);
|
||
|
||
await page.getByRole("button", { name: "Copy SQL" }).first().click();
|
||
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain("SELECT");
|
||
|
||
await page.getByRole("button", { name: "Export to PDF" }).click(); // window.print()
|
||
await page.emulateMedia({ media: "print" });
|
||
await expect(page.locator('.rail')).toBeHidden();
|
||
await expect(page.locator('.toolbar')).toBeHidden();
|
||
});
|
||
|
||
test("a pasted SQL script is refused with a named reason", async ({ page }) => {
|
||
await page.goto("/app");
|
||
await page.fill('#xml', "SELECT * FROM dbo.Orders WHERE CustomerId = 42;");
|
||
await page.getByRole("button", { name: "Analyse" }).click();
|
||
await expect(page.getByRole("alert").getByText(/ShowPlan XML/)).toBeVisible({ timeout: 20_000 });
|
||
await expect(page.locator('#report')).toHaveCount(0);
|
||
});
|
||
|
||
test("a dropped .sqlplan file decodes and is accepted", async ({ page }) => {
|
||
await page.goto("/app");
|
||
await page.setInputFiles('input[type="file"]', "fixtures/sample.sqlplan");
|
||
expect(await page.locator('#xml').inputValue()).toContain("ShowPlanXML");
|
||
expect(await page.locator('#xml').inputValue()).not.toContain("\u0000");
|
||
});
|
||
|
||
test("reopening history renders the stored report with no new run", async ({ page }) => {
|
||
await page.goto("/app");
|
||
await page.locator('.rail a[href^="/app/"]').first().click();
|
||
await expect(page.locator('#report, [data-verdict]').first()).toBeVisible({ timeout: 20_000 });
|
||
await expect(page.getByRole('button', { name: 'Analyse' })).toHaveCount(0);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: `tests-e2e/history.spec.ts` and `tests-e2e/responsive.spec.ts`**
|
||
|
||
```ts
|
||
// history.spec.ts
|
||
import { test, expect } from "@playwright/test";
|
||
test.use({ storageState: ".auth/user.json" });
|
||
|
||
test("rename sticks, delete asks first then removes", async ({ page }) => {
|
||
await page.goto("/app");
|
||
const row = page.locator('.rail li').first();
|
||
await row.locator('input[name="title"]').fill("Renamed by QA");
|
||
await row.getByRole("button", { name: "Save" }).click();
|
||
await expect(page.locator('.rail', { hasText: "Renamed by QA" })).toBeVisible();
|
||
|
||
const before = await page.locator('.rail li').count();
|
||
let asked = false;
|
||
page.once("dialog", async (d) => { asked = true; await d.dismiss(); }); // refuse: nothing must vanish
|
||
await page.locator('.rail li').first().getByRole("button", { name: "Delete" }).click();
|
||
await page.waitForTimeout(500);
|
||
expect(asked).toBe(true);
|
||
expect(await page.locator('.rail li').count()).toBe(before);
|
||
|
||
page.once("dialog", async (d) => { await d.accept(); });
|
||
await page.locator('.rail li').first().getByRole("button", { name: "Delete" }).click();
|
||
await expect.poll(async () => page.locator('.rail li').count(), { timeout: 15_000 }).toBe(before - 1);
|
||
});
|
||
```
|
||
|
||
```ts
|
||
// responsive.spec.ts
|
||
import { test, expect } from "@playwright/test";
|
||
test.use({ storageState: ".auth/user.json" });
|
||
|
||
for (const width of [320, 375, 414, 768]) {
|
||
test(`no horizontal scroll at ${width}px`, async ({ page }) => {
|
||
await page.setViewportSize({ width, height: 800 });
|
||
await page.goto("/app");
|
||
const over = await page.evaluate(() => document.documentElement.scrollWidth
|
||
- document.documentElement.clientWidth);
|
||
expect(over).toBeLessThanOrEqual(0);
|
||
await expect(page.getByRole('button', { name: 'Analyse' })).toBeVisible();
|
||
const box = await page.getByRole('button', { name: 'Analyse' }).boundingBox();
|
||
expect(box!.width).toBeGreaterThanOrEqual(100); // never two-line tap targets
|
||
});
|
||
}
|
||
|
||
test("every field has a visible label", async ({ page }) => {
|
||
await page.goto("/settings");
|
||
const orphans = await page.evaluate(() => [...document.querySelectorAll('input')]
|
||
.filter((i) => !i.id || !document.querySelector(`label[for="${i.id}"]`)).length);
|
||
expect(orphans).toBe(0);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 7: Run it, then commit**
|
||
|
||
```bash
|
||
npm run dev & # reuseExistingServer keeps this instance
|
||
npm run e2e
|
||
```
|
||
|
||
Expected: setup, then chromium and mobile suites green. A red `full run` is worth reading before touching code — it means the report never rendered, and the SSE frames from `curl -sN` will say which stage stopped. Commit `test: playwright end-to-end qa`.
|
||
|
||
---
|
||
|
||
## Self-Review notes (already run against the spec)
|
||
|
||
- **Spec coverage.** Requirements 1–3 → Tasks 3, 11. 4 → Task 6. 5 → Task 7. 6–7 → Task 8. 8 → Tasks 2, 10, 11 (stored replay). 9 → Tasks 10, 11 (staged SSE). 10 → Task 12. Copy + PDF → Task 11, verified in Task 13. Setup guide + per-user keys in the settings panel → Task 5, exercised end to end by `auth.setup.ts`. History rename/delete → Tasks 2, 11, verified in Task 13. Non-goals are never built: no SQL Server connection, no sharing, no billing, no weights UI.
|
||
- **Placeholders.** None. Task 6 step 4 and Task 13 step 4 are the only steps that depend on a real artefact you supply (one `.sqlplan` from SSMS); both say what to do when it is absent instead of leaving a hole.
|
||
- **Type consistency.** `Candidate`, `Digest`, `AnalystConfig`, `Verdict` are each defined once (Tasks 7, 6, 4, 8) and only imported afterwards. `digestForModel` is the single payload shaper for both models. `readSse`/`sse` are defined once in `lib/stream.ts` and used by both ends.
|
||
- **Review Focus.** 1 → digest test (`rejects input that is not a plan`) + Task 13's refused-script test. 2 → analyst tests (`names the stage on auth, prose, and shape failures`). 3 → jev tests (`the unsafe option is flagged and cannot lead`, `an unknown jev choice label falls back`). 4 → digest test (`digestForModel stays bounded`) + `MAX_BODY_BYTES` in the route. 5 → jev tests (`fallback ranks without Jev`, unknown label) + Task 10 step 5's invalidated-key check.
|
||
- **Fixed after the first Jev gate run (NO, coverage 3.2, runnable-as-written 0.11).** ShowPlan attribute spellings were wrong (`EstimatedIO`/`EstimatedCPU` are really `EstimateIO`/`EstimateCPU`), now a fall-through chain plus a real-`.sqlplan` test. Three "fix this line as you write it" hedges are gone — the correct code is inline. Jev's unknown-choice-label branch had no test; it does now. Weights now carry their provenance and the README names them as the tuning knob. Task 13 (browser QA) and the analyst endpoint facts added.
|
||
|