Compare commits

..
16 Commits
Author SHA1 Message Date
ahmed mohammedandClaude Opus 4.7 5160a00db5 fix: final review pass — email collision, orphaned-run loop, null confidence guards
- changeEmail validates and refuses an address another account owns (users.email is UNIQUE; a collision was a 500 inside the settings form).
- /app/[id] no longer redirects a 'running' row to itself; it renders what exists and says the run was interrupted.
- ScoreBar / OptionRow / VerdictBlock survive null numbers from the model instead of throwing and losing the block (seen live: '[openui] runtime/render-error: Component ScoreBar render failed').
- drop the ad-hoc render-debug scripts now that Playwright covers the browser.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 09:10:29 +03:00
ahmed mohammedandClaude Opus 4.7 07dd3d6bf8 test: playwright end-to-end qa (17 tests, real models)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 09:02:53 +03:00
ahmed mohammedandClaude Opus 4.7 0db7b8140b feat: docker, readme, jev goal gate
Dockerfile/compose written but never built or run on this machine (no Docker). smoke.sh now works in both modes and fails loudly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 08:40:57 +03:00
ahmed mohammedandClaude Opus 4.7 a5529ce542 feat: console ui, streaming report, history actions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 08:37:28 +03:00
ahmed mohammedandClaude Opus 4.7 ee4a88e499 chore: dev dep @playwright/test
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 08:32:40 +03:00
ahmed mohammedandClaude Opus 4.7 ac4cbdd9e6 feat: analysis pipeline with staged SSE
Adds @openuidev/lang-core (one-line reason: the prompt-side library must build in a non-React Node runtime; react-lang's createContext crashes the route's server bundle).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 08:32:22 +03:00
ahmed mohammedandClaude Opus 4.7 13bd2e6bfd fix: open sqlite lazily so parallel build workers stop locking the file
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 07:56:18 +03:00
ahmed mohammedandClaude Opus 4.7 396b0edd9e feat: openui report library
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 07:54:52 +03:00
ahmed mohammedandClaude Opus 4.7 d8caa8e6db feat: jev composite scoring and verdict
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 07:53:03 +03:00
ahmed mohammedandClaude Opus 4.7 816f22c227 feat: analyst proposes multiple options; README with gitea remote
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 07:44:01 +03:00
ahmed mohammedandClaude Opus 4.7 4fefd0f6da feat: showplan digest
No fixtures/real.sqlplan available (no SQL Server reachable from this machine); the real-plan test skips and says so.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 07:39:01 +03:00
ahmed mohammedandClaude Opus 4.7 1c3f222f9d feat: setup wizard + settings
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 07:36:55 +03:00
ahmed mohammedandClaude Opus 4.7 dd6b880f49 feat: encrypted credential vault
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 07:34:01 +03:00
ahmed mohammedandClaude Opus 4.7 5f8746cfae feat: email+password auth with db-backed sessions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 07:32:07 +03:00
ahmed mohammedandClaude Opus 4.7 692ffa97c5 feat: sqlite layer via node:sqlite
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 07:23:20 +03:00
ahmed mohammedandClaude Opus 4.7 f64dc3184b feat: tokens, shell, healthz
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 07:21:42 +03:00
70 changed files with 8043 additions and 9 deletions
+16
View File
@@ -0,0 +1,16 @@
.next
node_modules
data
.git
.hallmark
docs
design-system
tests
tests-e2e
fixtures
.superpowers
.env*
.playwright-data
.auth
playwright-report
test-results
+2
View File
@@ -0,0 +1,2 @@
APP_SECRET=
DATA_DIR=/data
+22 -9
View File
@@ -1,11 +1,24 @@
node_modules
.next
out
build
data
.env
.env.local
.env*.local
# next
/node_modules
/.next
/out
/build
next-env.d.ts
*.tsbuildinfo
# env + secrets
.env*
!.env.example
# runtime data (sqlite)
/data
/.playwright-data
# e2e artifacts
/.auth
/test-results
/playwright-report
# e2e fixture input is committed; scratch output is not
.DS_Store
.vercel
.playwright-data
+1
View File
@@ -0,0 +1 @@
[{ "date": "2026-09-21", "macrostructure": "Workbench", "theme": "Terminal", "enrichment": "none", "brief": "SQL Server qAI \u00b7 plan triage console" }]
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+27
View File
@@ -0,0 +1,27 @@
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"]
+106
View File
@@ -0,0 +1,106 @@
# SQL Server qAI
Paste a SQL Server ShowPlan XML (or drop a `.sqlplan`), get a report with several ranked remediation options and
rewritten queries. Two models, one job: a **generative analyst model** proposes the options and writes the report, and
**Jev** (TypeSafe System One) scores every option and decides which one to run first. Jev never writes SQL; it returns
typed scores, a choice, and confidences. The report renders as OpenUI elements, not a markdown blob.
## Pipeline
1. `lib/digest.ts` parses the XML in code: top operators, missing indexes, warnings, waits, estimate-vs-actual row
errors, tables, statement text, subtree cost, parallelism. Raw XML never reaches a model.
2. The analyst model receives the digest JSON and proposes 2–4 genuinely different options (index, rewrite, statistics,
parallelism, schema), each with ordered action steps and an optional rewrite.
3. Jev answers four questions per option (`bottleneck_fit`, `semantic_safety`, `ease` `score`; `root_cause` `noul`) and
one cross-option call (`first_to_run` `choice`, `anything_worth_running` `noul`).
4. `lib/jev.ts` combines them with visible weights (0.40 / 0.30 / 0.15 / 0.15 — four numbers in that file, the only
tuning knob) and prints a verdict. Jev's pick leads only when its confidence clears 0.5; otherwise the report says
the order came from composite scoring.
5. The analyst streams the final report as OpenUI Lang; `<Renderer>` paints it as it arrives.
Getting the XML: SSMS → **Ctrl+M** (include actual execution plan) → run the query → right-click the Execution Plan tab
→ *Save As .sqlplan*. No SSMS: prefix the batch with `SET STATISTICS XML ON;` and copy the XML result cell.
## Run it
This repo runs as a plain Node process. `Dockerfile` and `docker-compose.yml` are ready, but the image has **not
been built or run on this machine** (Docker is not installed here); treat it as untested until
`bash scripts/smoke.sh --docker` says otherwise. Local work is `npm run dev`.
```bash
npm install
cp .env.example .env.local # then fill in APP_SECRET and DATA_DIR
npm run dev # http://localhost:3000
bash scripts/smoke.sh # healthz, login page, signed-out redirect, analyze needs a session
```
`.env.local`:
| key | why |
| --- | --- |
| `APP_SECRET` | ≥16 chars. Encrypts API keys at rest (AES-256-GCM). Changing it makes stored keys unreadable. |
| `DATA_DIR` | where `qai.db` (SQLite, `node:sqlite`) lives. Default `./data`. Docker uses `/data`. |
Model keys are **not** config. Each user types them in the app (first-run guide or `/settings`), so a second user
brings their own endpoint and their own spend. Blank key field = keep the stored key.
- Analyst: any OpenAI-compatible base URL + key + model, plus optional extra JSON params.
A local endpoint such as `http://192.168.88.131:8888/v1` works; use key `EMPTY` when the server has no auth.
For a reasoning model (Qwen3 on vLLM) put `{"chat_template_kwargs":{"enable_thinking":false}}` in **Extra params**:
with thinking on, the model spends the whole context budget reasoning and the report arrives empty.
Both model calls default to `max_tokens: 2048`; the same field overrides it.
- Jev: a TypeSafe API key from `console.typesafe.ai/keys`, model usually `jev-latest`.
## Tests
```bash
npm test # node --test on tests/*.test.ts, no test framework installed
npm run build # production build + type check
npm run e2e # Playwright: signup, settings, real analyst + real Jev run, copy, print, history, 4 widths
```
The unit suites run offline: the analyst and Jev are exercised through injected `fetch`, so no key and no network.
`npm run e2e` is the opposite — it drives the real app against the real models, and reads `JEV_API_KEY` from
`.env.local`. Expect it to be slow; that is the models, not the harness.
`tests/digest.test.ts` skips unless a genuine plan exists at `fixtures/real.sqlplan`. Drop one there from SSMS and the
skip becomes the real check on ShowPlan attribute spellings.
## Git remote (Gitea)
Origin points at the self-hosted Gitea:
```bash
git remote -v # http://192.168.88.39:3000/ahmed/sql-server-query-ai.git
git push origin feat/sql-server-qai
```
The remote URL deliberately contains **no password**. Git prompts for the username (`ahmed`) and password the first
time, or you can keep them out of prompts:
```bash
git config credential.helper osxkeychain # remember them locally
git push origin feat/sql-server-qai
```
Never commit the password into `.git/config`, a remote URL, or this README — a stored credential in the repo is
readable by whoever you hand the repo to. If you want it non-interactive in CI, create a Gitea token
(Settings → Applications) and use `http://192.168.88.39:3000/ahmed/sql-server-query-ai.git` with the token as password.
## Layout
```
lib/digest.ts XML → bounded digest (the only shape models see)
lib/analyst.ts propose 2–4 options; stream the final OpenUI report
lib/jev.ts questions, weights, ranking, verdict, fallback
lib/report-ui.tsx OpenUI component library — the same module builds the prompt and renders the answer
lib/db.ts node:sqlite: users, sessions, settings, analyses
lib/vault.ts AES-256-GCM seal/open/mask for credentials
lib/auth.ts scrypt passwords, hashed session tokens (DB stores the SHA-256, never the token)
app/api/analyze SSE pipeline: digesting → proposing → judging → writing
tests/ offline unit suites tests-e2e/ Playwright
docs/superpowers/ spec + the 13-task implementation plan this was built from
```
Design: dark terminal console, JetBrains Mono for data, IBM Plex Sans for prose, one phosphor accent.
Print stylesheet flips to paper white and drops the rail, buttons and chrome — that is the Export to PDF path.
+6
View File
@@ -0,0 +1,6 @@
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>;
}
+11
View File
@@ -0,0 +1,11 @@
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>
</>;
}
+12
View File
@@ -0,0 +1,12 @@
import { signUp } from "@/lib/actions";
import { AuthForm } from "@/components/auth-form";
export default function Signup() {
return <>
<h1 style={{ fontFamily: "var(--qai-font-display)", fontSize: "var(--qai-text-2xl)" }}>Create account</h1>
<AuthForm action={signUp} submit="Create account" fields={[
{ name: "email", label: "Email", type: "email", autoComplete: "email" },
{ name: "password", label: "Password", type: "password", autoComplete: "new-password",
help: "10 characters minimum." }]} />
<p><a href="/login">Sign in instead</a></p>
</>;
}
+95
View File
@@ -0,0 +1,95 @@
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 { reportPrompt } from "@/lib/report-spec";
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 = reportPrompt();
// The analyst model needs minutes per stage. If the DBA closes the tab, enqueue() does not throw, so the
// producer would keep firing model calls into a dead stream and queue behind its own ghost. cancel() is the
// only signal that the reader is gone: stop launching further calls and abort the one in flight.
const ac = new AbortController();
let abandoned = false;
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(); }
if (abandoned) return giveUp(id, oul);
patchAnalysis(id, { digest: JSON.stringify(digest) });
send("stage", { stage: "proposing" });
let candidates;
try { candidates = await proposeCandidates(analyst, digest, note, fetch, ac.signal); }
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(); }
if (abandoned) return giveUp(id, oul);
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, fetch, ac.signal)) {
oul += chunk; send("oul", { chunk });
}
if (abandoned) return giveUp(id, oul);
close(oul ? "done" : "partial", oul ? undefined : "The analyst model returned an empty report.");
} catch (e) {
const m = abandoned ? "Browser stopped listening." : ((e as Error)?.message ?? "Analysis failed.");
if (!abandoned) send("error", { stage: "stream", message: m });
patchAnalysis(id, { status: "failed", error: m, ...(oul ? { oul } : {}) });
send("done", { id }); ctrl.close();
}
function giveUp(id: string, oul: string) {
patchAnalysis(id, { status: oul ? "partial" : "abandoned",
error: "Closed before the run finished.", ...(oul ? { oul } : {}) });
try { ctrl.close(); } catch { /* already closed */ }
}
}, cancel() { abandoned = true; ac.abort(); } });
return new Response(stream, { headers: { "content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store", connection: "keep-alive", "x-accel-buffering": "no" } });
}
+2
View File
@@ -0,0 +1,2 @@
export const dynamic = "force-dynamic";
export const GET = () => Response.json({ ok: true });
+24
View File
@@ -0,0 +1,24 @@
import { notFound } 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();
// Never redirect here: a row left "running" by a crashed process would bounce this page to itself forever.
const interrupted = a.status === "running";
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>
{interrupted && <p role="alert" style={{ color: "var(--qai-warn)" }}>
This run was interrupted before it finished — what is below is everything it managed to produce.
</p>}
{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>;
}
+10
View File
@@ -0,0 +1,10 @@
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>;
}
+2
View File
@@ -0,0 +1,2 @@
import { Runner } from "@/components/runner";
export default function Run() { return <Runner />; }
+9
View File
@@ -0,0 +1,9 @@
@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; } }
+9
View File
@@ -0,0 +1,9 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
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"><body>{children}</body></html>;
}
+3
View File
@@ -0,0 +1,3 @@
import { redirect } from "next/navigation";
import { sessionUser } from "@/lib/auth";
export default async function Home() { redirect((await sessionUser()) ? "/app" : "/login"); }
+19
View File
@@ -0,0 +1,19 @@
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>;
}
+33
View File
@@ -0,0 +1,33 @@
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 step = (await searchParams).step ?? "";
const n = (["1", "2", "3"].includes(step) ? step : "1") as "1" | "2" | "3";
const view = settingsView(u.id);
// Step 3 stays reachable after onboarding: it is the "are both models actually ready?" screen.
if (n !== "3" && 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" after="/setup?step=3" />}
{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>;
}
+33
View File
@@ -0,0 +1,33 @@
/* 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; }
}
+32
View File
@@ -0,0 +1,32 @@
/* same stamp as tokens.css — one design record, two files */
.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; } }
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { useActionState } from "react";
export type FormResult = { ok?: boolean; error?: string } | null;
export function AuthForm({ action, submit, fields }: {
action: (state: FormResult, fd: FormData) => Promise<FormResult>;
submit: string;
fields: { name: string; label: string; type: string; autoComplete?: string; help?: string }[];
}) {
const [state, formAction, pending] = useActionState<FormResult, FormData>(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>;
}
+11
View File
@@ -0,0 +1,11 @@
"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>;
}
+30
View File
@@ -0,0 +1,30 @@
"use client";
import { rename, remove } from "@/lib/actions";
export function HistoryItem({ row }: { row: { id: string; title: string; status: string; created_at: number } }) {
// The server actions take (previousState, formData) for useActionState; a plain <form action> passes only
// formData, so bind the unused slot here rather than duplicating two single-arg actions.
const doRename = (fd: FormData) => rename(null, fd);
const doRemove = (fd: FormData) => remove(null, fd);
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", flexWrap: "wrap" }}>
<span className="label">{when}</span>
<form action={doRename} 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>
{/* Delete is the only destructive action in the product: it never fires on a stray click. */}
<form action={doRemove} 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>;
}
+15
View File
@@ -0,0 +1,15 @@
import { listAnalyses } from "@/lib/db";
import { HistoryItem } from "./history-item";
export 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>
</>;
}
+8
View File
@@ -0,0 +1,8 @@
"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} />;
}
+74
View File
@@ -0,0 +1,74 @@
"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 = stage ? STAGES.indexOf(stage as (typeof STAGES)[number]) : -1;
async function run() {
setError(null); setOul(""); setStage("digesting");
try {
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}`); }
}
} catch (e) {
setError((e as Error).message || "The connection to the server dropped.");
setStage(null);
}
}
return <section style={{ display: "grid", gap: "var(--qai-space-xl)", minWidth: 0 }}>
<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",
paddingLeft: "1.2em" }}>
<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(/^/, ""));
}} />
{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)", margin: 0 }}>{error}</p>}
</div>
{oul && <ExportButtons oul={oul} title={note || "Query plan analysis"} />}
<ReportView oul={oul} streaming={stage !== null} />
</section>;
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useActionState } from "react";
import type { SettingsView } from "@/lib/settings";
type Result = { ok?: boolean; error?: string } | null;
export function SettingsForm({ action, view, step, account, after }: {
action: (state: Result, fd: FormData) => Promise<Result>;
view: SettingsView; step?: string; account?: boolean; after?: string;
}) {
const [state, formAction, pending] = useActionState<Result, FormData>(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)" }}>
{after && <input type="hidden" name="after" value={after} />}
{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}. Reasoning models stream thinking, not the answer: '
+ 'vLLM/Qwen needs {"chat_template_kwargs":{"enable_thinking":false}} or the report arrives empty.')}
</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>;
}
+12
View File
@@ -0,0 +1,12 @@
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>
{Array.isArray(verdict.flags) && verdict.flags.length > 0
&& <p style={{ color: "var(--qai-warn)" }}>{verdict.flags.join(" · ")}</p>}
</section>;
}
+10
View File
@@ -0,0 +1,10 @@
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:
@@ -39,6 +39,18 @@ The demo value is Jev as the decision-maker: the generative model proposes,
Report actions: **Export to PDF**, **Copy**. History actions: **rename**, **delete**.
### Getting the input, and the two keys
The portal cannot reach the SQL Server. The user brings the plan XML, and the app must say so in the run screen:
SSMS → **Ctrl+M** (include actual execution plan) → run the query → save the Execution Plan tab as `.sqlplan`, or
copy the Showplan XML cell; without SSMS, prefix the batch with `SET STATISTICS XML ON;` and copy the XML result
cell. A `.sqlplan` file can be dropped in (SSMS writes UTF-16 with a BOM; decode both) or pasted.
Both model credentials are typed by the logged-in user in `/setup` or `/settings` — never server config, so every
user brings their own analyst endpoint and their own TypeSafe/Jev key (`console.typesafe.ai/keys`). Stored keys come
back masked, and a blank field means keep the stored one. Nothing leaves the container except the digest, and only
to the endpoints that user configured.
## Actors (two models, strict roles)
- **Analyst** — any OpenAI-compatible endpoint the user configures (`base_url`,
@@ -82,3 +94,21 @@ Report actions: **Export to PDF**, **Copy**. History actions: **rename**, **dele
Paste a real slow-query plan → under 60 s → a ranked set of remediations where the
top pick is defensible because Jev's probabilities are on screen, and the winning
SQL is one click from the clipboard.
## Acceptance
What has to be true before this counts as done, and how each part is proven:
- **Offline unit suites** (`npm test`, no keys, no network): a real ShowPlan digests to the right numbers; input
that is not a plan is refused with a named reason; analyst output is schema-checked and 2–4 options are enforced;
Jev's scores rank, an unknown Jev label cannot lead, and a silent Jev degrades to a labelled composite order;
credentials seal/open/mask and a blank field never wipes a stored key.
- **One live run** against the user's real analyst endpoint and a real Jev key: every SSE stage fires, several
candidates come back, Jev returns a verdict with confidence, and OpenUI Lang arrives in deltas.
- **Browser QA** (Chromium): signup → guide → settings → run → rendered report; Copy puts SQL on the clipboard;
print emulation flips to paper and hides the rail and buttons; rename sticks and delete confirms first; no
horizontal scroll from 320 px up.
- **A smoke script** (`scripts/smoke.sh`) that a container or a plain `npm start` both pass: health, auth pages,
signed-out redirect, and `/api/analyze` refusing an anonymous caller.
- **A goal gate**: Jev scores the written plan against the goal in this document and answers the named-gap question
with `nothing` before implementation starts.
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+31
View File
@@ -0,0 +1,31 @@
<?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 &gt; 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>
+53
View File
@@ -0,0 +1,53 @@
"use server";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
import { userByEmail, getSettings, renameAnalysis, deleteAnalysis, db } from "@/lib/db";
import { signUpNewUser, verifyPassword, startSession, endSession, requireUser, changeEmail, emailOk, hashPassword } from "@/lib/auth";
import { saveSettingsForm } from "@/lib/settings";
export async function signUp(_: unknown, 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(_: unknown, 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(_: unknown, 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(_: unknown, fd: FormData) {
const u = await requireUser();
deleteAnalysis(String(fd.get("id") ?? ""), u.id);
revalidatePath("/app");
}
export async function saveSettingsAction(_: unknown, fd: FormData) {
const u = await requireUser();
const email = String(fd.get("email") ?? "").trim();
const password = String(fd.get("password") ?? "");
if (password && password.length < 10) return { ok: false, error: "Use at least 10 characters." };
saveSettingsForm(u.id, fd);
// The wizard sends `after`: a configured user must land on the readiness screen, and the setup page
// forwards configured users away from steps 1-2, so "Saved." would never be seen there.
if (String(fd.get("after") ?? "").startsWith("/setup")) redirect("/setup?step=3");
if (password) db.prepare("UPDATE users SET pass = ? WHERE id = ?").run(hashPassword(password), u.id);
if (email) {
const r = changeEmail(u.id, email);
if (!r.ok) return r;
}
revalidatePath("/settings");
return { ok: true };
}
+79
View File
@@ -0,0 +1,79 @@
import { z } from "zod/v4";
import { digestForModel, type Digest } from "./digest.ts";
import { openAiTextDeltas } from "./stream.ts";
import type { AnalystConfig } from "./settings.ts";
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, signal?: AbortSignal): Promise<Candidate[]> {
const res = await fetchImpl(`${cfg.baseUrl}/chat/completions`, {
method: "POST", signal,
headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` },
body: JSON.stringify({ model: cfg.model, temperature: 0.6, max_tokens: 2048, ...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;
}
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, signal?: AbortSignal): AsyncGenerator<string> {
const res = await fetchImpl(`${cfg.baseUrl}/chat/completions`, {
method: "POST", signal,
headers: { "content-type": "application/json", authorization: `Bearer ${cfg.apiKey}` },
body: JSON.stringify({ model: cfg.model, temperature: 0.3, max_tokens: 2048, ...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);
}
+74
View File
@@ -0,0 +1,74 @@
import { randomBytes, scryptSync, createHash, timingSafeEqual } from "node:crypto";
import { createUser, userByEmail, userEmail, putSession, sessionUserId, dropSession, getSettings, db } from "./db.ts";
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)) };
}
// next/headers is imported lazily: `node --test` loads this module to test the crypto above, and the
// request-scoped binding must not be touched at import time.
const jar = async () => (await import("next/headers")).cookies();
export function changeEmail(userId: string, email: string): { ok: true } | { ok: false; error: string } {
const e = email.trim().toLowerCase();
if (!emailOk(e)) return { ok: false, error: "Enter a valid email address." };
const taken = userByEmail(e);
if (taken && taken.id !== userId) return { ok: false, error: "That email already has an account." };
// users.email is UNIQUE: without this check a collision is a 500 inside the settings form.
if (!taken) db.prepare("UPDATE users SET email = ? WHERE id = ?").run(e, userId);
return { ok: true };
}
export async function startSession(userId: string) {
const token = randomBytes(32).toString("hex");
putSession(hashToken(token), userId, Date.now() + SESSION_DAYS * 864e5);
(await jar()).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 jar();
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 jar()).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(): Promise<{ id: string; email: string }> {
const { redirect } = await import("next/navigation"); // lazy for the same reason as jar()
const u = await sessionUser();
if (!u) { redirect("/login"); throw new Error("redirecting to /login"); } // redirect throws; this only satisfies the type
return u;
}
export const needsOnboarding = (userId: string) => getSettings(userId)?.onboarded !== 1;
+120
View File
@@ -0,0 +1,120 @@
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 });
// Open on first query, not at import: `next build` imports every page through lib/auth -> lib/db in ~6 parallel
// workers, and six processes running DDL against one file at once fails with "database is locked".
let _db: DatabaseSync | null = null;
function openDb(): DatabaseSync {
if (_db) return _db;
const d = new DatabaseSync(`${dir}/qai.db`);
d.exec(`
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;`);
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);`);
_db = d;
return d;
}
// Proxy keeps the terse call sites (db.prepare) while deferring the open; methods must be bound to the real
// instance or node:sqlite rejects the receiver ("Illegal invocation").
export const db: DatabaseSync = new Proxy({} as DatabaseSync, {
get: (_t, k) => {
const v = (openDb() as any)[k];
return typeof v === "function" ? v.bind(openDb()) : v;
},
});
const now = () => Date.now();
type Row = string | number | null;
// node:sqlite hands back null-prototype objects. React refuses to serialize those into a client component
// ("Only plain objects ... can be passed to Client Components"), so every row is spread back to a normal object here.
const plain = <T>(r: unknown): T | null => (r ? { ...r } as T : null);
const one = <T>(sql: string, ...p: Row[]): T | null => plain<T>(db.prepare(sql).get(...p));
// 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) {
// A blank secret means "keep what is stored" — enforced here, not only in the form layer, so no caller
// (password change, onboarding re-save, a future import) can blank a stored key by omitting it.
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=COALESCE(NULLIF(excluded.analyst_key, ''), settings.analyst_key),
analyst_model=excluded.analyst_model, analyst_extra=excluded.analyst_extra,
jev_key=COALESCE(NULLIF(excluded.jev_key, ''), settings.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;
}
// patchAnalysis builds its SET list from object keys, so the keys have to be checked, not trusted.
const PATCHABLE = new Set(["digest", "candidates", "verdict", "oul", "status", "error", "title"]);
export function patchAnalysis(id: string, p: Record<string, string>) {
const keys = Object.keys(p).filter((k) => PATCHABLE.has(k));
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 }[])
.map((r) => ({ ...r }));
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);
+111
View File
@@ -0,0 +1,111 @@
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: any) => 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: press Ctrl+M, run the query, then save the Execution Plan tab as .sqlplan.");
const stmts = collect(doc, "StmtSimple");
const stmt = stmts.find((s: any) => s?.QueryBlock || s?.RelOp) ?? stmts[0] ?? {};
const operators: Operator[] = collect(doc, "RelOp").map((o: any) => {
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",
};
});
// Database/Schema/Table arrive as separate attributes, each bracketed: rebuild the three-part name.
const fullName = (n: any) => [n.Database, n.Schema, n.Table].map(unbrack).filter(Boolean).join(".");
const tables = [...new Set([...collect(doc, "IndexScan"), ...collect(doc, "TableScan"), ...collect(doc, "Table")]
.map(fullName).filter(Boolean))];
const missingIndexes: MissingIndex[] = collect(doc, "MissingIndexGroup").map((g: any) => {
const mi = collect(g, "MissingIndex")[0] ?? {};
const byUsage = (u: string) => collect(mi, "ColumnGroup").filter((c: any) => c.Usage === u).flatMap(colNames);
return {
table: fullName(mi),
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: any) =>
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: any) => ({ 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,
});
+108
View File
@@ -0,0 +1,108 @@
import { TypeSafeClient, score, noul, choice } from "@typesafe-ai/sdk";
import { digestForModel, type Digest } from "./digest.ts";
import type { Candidate } from "./analyst.ts";
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 SAFETY_HARD_FLOOR = 0.45;
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.",
] as const;
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.",
] as const;
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.",
] as const;
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[] = [];
// Live Jev scores an index create around 0.57 ("probably equivalent, no proof stated"). A blanket floor
// flagged every index option, so the warning would be noise. Flag where semantics can actually move:
// any rewrite below the soft floor, or anything at all below the hard floor.
const safety = dims.semantic_safety.value;
if (safety < SAFETY_HARD_FLOOR || (c.rewrite_sql !== null && safety < 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 {
// Scores are 0 because Jev never answered, not because the options are bad: drop every flag a score produced.
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 } }))
.map((r) => ({ ...r, flags: r.flags.filter((f) => f === "change_control") }))
.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)] };
}
+81
View File
@@ -0,0 +1,81 @@
import { defineComponent, createLibrary } from "@openuidev/lang-core";
import { z } from "zod/v4";
// The single source of truth for what the analyst model is allowed to emit.
// This module must never import React or @openuidev/react-lang: the API route runs it in a plain
// Node runtime to build the prompt, and React's server build throws on createContext there.
// lib/report-ui.ts attaches the React renderers to these same specs, so prompt and renderer cannot drift.
const noop = () => null;
export const Headline = defineComponent({ props: z.object({ title: z.string(), subtitle: z.string().optional(),
severity: z.enum(["critical", "warning", "info"]).optional() }),
name: "Headline", description: "Report title: the one-line finding, with severity.", component: noop });
export const Metric = defineComponent({ props: z.object({ label: z.string(), value: z.string(), note: z.string().optional() }),
name: "Metric", description: "One plan metric.", component: noop });
export const MetricGrid = defineComponent({ props: z.object({ items: z.array(Metric.ref) }),
name: "MetricGrid", description: "Row of plan metrics.", component: noop });
export const Finding = defineComponent({ props: z.object({ where: z.string(), issue: z.string(), evidence: z.string(), impact: z.string() }),
name: "Finding", description: "One problem, the plan evidence for it, its impact.", component: noop });
export const Findings = defineComponent({ props: z.object({ items: z.array(Finding.ref) }),
name: "Findings", description: "Everything wrong with the plan.", component: noop });
export const Step = defineComponent({ props: z.object({ text: z.string(), effort: z.enum(["low", "medium", "high"]).optional(),
owner: z.enum(["dba", "developer", "both"]).optional(), changeControl: z.boolean().optional() }),
name: "Step", description: "One action step.", component: noop });
export const StepList = defineComponent({ props: z.object({ title: z.string(), steps: z.array(Step.ref) }),
name: "StepList", description: "Ordered action plan for one option.", component: noop });
export const SqlBlock = defineComponent({ props: z.object({ title: z.string(), sql: z.string(), caption: z.string().optional() }),
name: "SqlBlock", description: "A SQL snippet the user can copy.", component: noop });
export const ScoreBar = defineComponent({ props: z.object({ label: z.string(), value: z.number(),
confidence: z.number().optional(), note: z.string().optional() }),
name: "ScoreBar", description: "One Jev dimension: normalised value plus confidence.", component: noop });
export const OptionRow = defineComponent({ props: z.object({ rank: z.number(), title: z.string(), composite: z.number(),
headline: z.boolean(), flags: z.string().optional() }),
name: "OptionRow", description: "One ranked option.", component: noop });
export const OptionTable = defineComponent({ props: z.object({ title: z.string(), weights: z.string(), rows: z.array(OptionRow.ref) }),
name: "OptionTable", description: "All options ranked by composite score.", component: noop });
export const VerdictBlock = defineComponent({ props: z.object({ headline: z.string(), why: z.string(),
source: z.enum(["jev", "composite"]), confidence: z.number() }),
name: "VerdictBlock", description: "What Jev decided, and how sure it was.", component: noop });
export const Callout = defineComponent({ props: z.object({ level: z.enum(["info", "warn", "danger", "success"]),
title: z.string(), text: z.string() }),
name: "Callout", description: "A warning or note the user must not miss.", component: noop });
export const Report = defineComponent({ 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])) }),
name: "Report", description: "Root. A query-plan report in reading order.", component: noop });
export const specs = [Headline, Metric, MetricGrid, Finding, Findings, Step, StepList, SqlBlock, ScoreBar,
OptionRow, OptionTable, VerdictBlock, Callout, Report];
/** Prompt-side library: same specs, no renderers. Safe in a non-React Node runtime. */
export const promptLibrary = createLibrary({ root: "Report", components: specs });
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)`],
};
export const reportPrompt = () => promptLibrary.prompt(reportPromptOptions);
+90
View File
@@ -0,0 +1,90 @@
import { createElement as el } from "react";
import { defineComponent, createLibrary } from "@openuidev/react-lang";
import { specs } from "./report-spec.ts";
// Props, descriptions and the prompt come from lib/report-spec.ts (React-free, usable in the API route).
// This file only says what each component looks like. Names and schemas cannot drift: they are imported.
// No JSX here either — `node --test` imports modules and Node does not strip JSX.
const p = (style: Record<string, string | number>, ...kids: any[]) => el("p", { style }, ...kids);
const muted = { color: "var(--qai-ink-muted)" };
const small = { fontSize: "var(--qai-text-xs)", color: "var(--qai-ink-muted)" };
const disp = { fontFamily: "var(--qai-font-display)" };
// Model output is untrusted: the schema says a number is optional, models still send null, and one throw
// deletes the whole block from the report. 0 is the honest stand-in for "the model did not give a number".
const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
const conf = (v: unknown) => (typeof v === "number" ? ` ${v.toFixed(2)}` : " not reported");
const renderers: Record<string, (x: any) => any> = {
Headline: ({ props }) => el("header", null,
el("p", { className: "label" }, props.severity === "critical" ? "Critical" : props.severity === "warning" ? "Warning" : "Note"),
el("h2", { style: { ...disp, fontSize: "var(--qai-text-2xl)", margin: "var(--qai-space-s) 0" } }, props.title),
props.subtitle ? p(muted, props.subtitle) : null),
Metric: ({ props }) => el("div", { className: "card" },
el("p", { className: "label" }, props.label),
p({ ...disp, fontSize: "var(--qai-text-xl)" }, props.value),
props.note ? p(small, props.note) : null),
MetricGrid: ({ props, renderNode }: any) => el("div", { className: "grid-metrics" }, renderNode(props.items)),
Finding: ({ props }) => el("div", { className: "card" },
el("p", { className: "label" }, props.where),
p({ margin: "var(--qai-space-s) 0" }, el("strong", null, props.issue)),
el("p", { className: "sql" }, props.evidence),
p(muted, props.impact)),
Findings: ({ props, renderNode }: any) => el("section", { className: "report" }, renderNode(props.items)),
Step: ({ props }) => el("li", { className: "card", style: { display: "flex", gap: "var(--qai-space-md)",
alignItems: "center", flexWrap: "wrap" } },
el("span", { style: { flex: "1 1 200px", minWidth: 0 } }, props.text),
props.effort ? el("span", { className: "pill" }, `effort ${props.effort}`) : null,
props.owner ? el("span", { className: "pill" }, props.owner) : null,
props.changeControl ? el("span", { className: "pill" }, "change control") : null),
StepList: ({ props, renderNode }: any) => el("section", null,
el("h3", { style: disp }, props.title),
el("ol", { style: { listStyle: "none", padding: 0, margin: 0, display: "grid", gap: "var(--qai-space-md)" } },
renderNode(props.steps))),
SqlBlock: ({ props }) => el("figure", { className: "card", style: { margin: 0 } },
el("figcaption", { className: "label" }, props.title),
el("pre", { className: "sql" }, props.sql),
el("button", { className: "btn no-print", onClick: () => navigator.clipboard.writeText(props.sql) }, "Copy SQL"),
props.caption ? el("p", { style: small }, props.caption) : null),
ScoreBar: ({ props }) => el("div", null,
el("p", { className: "label" }, props.label + (props.confidence === undefined
? " · no confidence reported" : ` · confidence${conf(props.confidence)}`)),
el("div", { className: "bar", style: { width: `${Math.round(num(props.value) * 100)}%` } }),
props.note ? el("p", { style: small }, props.note) : null),
OptionRow: ({ props }) => el("tr", { "data-headline": props.headline || undefined },
el("td", null, String(props.rank)), el("td", null, props.title),
el("td", { className: "sql" }, num(props.composite).toFixed(2)),
el("td", null, props.flags || "—")),
OptionTable: ({ props, renderNode }: any) => el("section", { className: "card" },
el("h3", { style: disp }, props.title),
el("p", { style: small }, props.weights),
el("table", { style: { width: "100%", borderCollapse: "collapse" } }, el("tbody", null, renderNode(props.rows)))),
VerdictBlock: ({ props }) => el("section", { className: "card", "data-verdict": "" },
el("p", { className: "label" }, "First move · " + (props.source === "jev"
? `Jev, confidence${conf(props.confidence)}` : "composite order — Jev did not decide")),
p({ ...disp, fontSize: "var(--qai-text-lg)" }, props.headline),
p({}, props.why)),
Callout: ({ props }) => el("div", { className: "card", "data-level": props.level,
role: props.level === "danger" ? "alert" : "note" },
el("p", null, el("strong", null, props.title)), el("p", null, props.text)),
Report: ({ props, renderNode }: any) => el("div", { className: "report", id: "report" }, renderNode(props.blocks)),
};
export const library = createLibrary({
root: "Report",
components: specs.map((s) => defineComponent({ name: s.name, props: s.props, description: s.description,
component: renderers[s.name] })),
});
+45
View File
@@ -0,0 +1,45 @@
import { getSettings, saveSettings, type SettingsInput } from "./db.ts";
import { seal, open, mask } from "./vault.ts";
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>;
+36
View File
@@ -0,0 +1,36 @@
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;
}
}
}
+24
View File
@@ -0,0 +1,24 @@
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)}` : "");
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+5705
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "sql-server-qai",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "node --no-warnings --test tests/*.test.ts",
"e2e": "playwright test"
},
"dependencies": {
"@openuidev/lang-core": "^0.3.0",
"@openuidev/react-lang": "^0.3.0",
"@typesafe-ai/sdk": "^0.6.0",
"fast-xml-parser": "^5.7.3",
"next": "16.3.5",
"react": "19.2.8",
"react-dom": "19.2.8",
"zod": "^4.6.5"
},
"devDependencies": {
"@playwright/test": "^1.63.0",
"@types/node": "^24.13.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.5",
"typescript": "^5"
}
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig, devices } from "@playwright/test";
import { readFileSync, existsSync, mkdirSync } from "node:fs";
// Playwright does not load Next's .env.local; the E2E run needs the real Jev key and a stable APP_SECRET.
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];
}
mkdirSync(".auth", { recursive: true });
export default defineConfig({
testDir: "./tests-e2e",
fullyParallel: false, // one SQLite file, one app process
workers: 1,
retries: 0,
timeout: 180_000, // real models are slow; say so with a timeout, not 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"] },
// A real model run costs ~40s; the mobile project only needs the layout checks, not a second full run.
{ name: "mobile", use: { ...devices["Pixel 7"] }, dependencies: ["setup"], testMatch: /responsive\.spec\.ts/ },
],
webServer: { command: "npm run dev", url: "http://localhost:3000/api/healthz", reuseExistingServer: true,
timeout: 120_000,
env: { DATA_DIR: ".playwright-data", APP_SECRET: process.env.APP_SECRET ?? "playwright-secret-value" } },
});
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# The one command that proves the app answers. Two modes, same checks:
# bash scripts/smoke.sh # app already running (BASE=http://localhost:3000)
# bash scripts/smoke.sh --docker # via docker compose (Docker is not installed on this machine)
set -uo pipefail
BASE=${BASE:-http://localhost:3000}
fail=0
check() { # check <label> <expected> <actual>
if [ "$2" = "$3" ]; then echo "ok $1"; else echo "FAIL $1: expected [$2] got [$3]"; fail=1; fi
}
if [ "${1:-}" = "--docker" ]; then
docker compose up --build -d
trap 'docker compose down -v' EXIT
for _ in $(seq 1 40); do curl -fsS "$BASE/api/healthz" >/dev/null 2>&1 && break; sleep 2; done
fi
check "healthz" '{"ok":true}' "$(curl -fsS "$BASE/api/healthz" 2>/dev/null)"
check "login page" "200" "$(curl -fsS -o /dev/null -w '%{http_code}' "$BASE/login")"
check "signup page" "200" "$(curl -fsS -o /dev/null -w '%{http_code}' "$BASE/signup")"
check "signed-out /app redirects" "307" "$(curl -s -o /dev/null -w '%{http_code}' "$BASE/app")"
check "analyze needs a session" "401" "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H 'content-type: application/json' -d '{}' "$BASE/api/analyze")"
[ $fail -eq 0 ] && echo "smoke ok" || { echo "smoke FAILED"; exit 1; }
+50
View File
@@ -0,0 +1,50 @@
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 → streamed report → copy → print", async ({ page }) => {
await page.goto("/app");
await page.fill("#xml", xml);
await page.fill("#note", "slow since the migration");
await page.getByRole("button", { name: "Analyse" }).click();
// Stages are named one at a time; the whole run takes tens of seconds with a real model.
await expect(page.getByText(/1 of 4 · Reading the plan/)).toBeVisible({ timeout: 20_000 });
await expect(page.getByText(/4 of 4 · Writing the report/)).toBeVisible({ timeout: 180_000 });
// The report paints while oul is still arriving, so the headline shows up first.
await expect(page.locator("#report h2").first()).toBeVisible({ timeout: 180_000 });
// The Analyse button only unlocks on the done frame: by then the whole report has arrived and parsed.
await expect(page.getByRole("button", { name: "Analyse" })).toBeEnabled({ timeout: 180_000 });
expect(await page.locator("#report .card").count()).toBeGreaterThan(2);
// No raw OpenUI Lang leaked into the page as text.
expect(await page.locator("main").innerText()).not.toContain("root = Report(");
await page.getByRole("button", { name: "Copy", exact: true }).click();
const clip = await page.evaluate(() => navigator.clipboard.readText());
expect(clip).toContain("slow since the migration");
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 dropped .sqlplan file decodes and is accepted", async ({ page }) => {
await page.goto("/app");
await page.setInputFiles('input[type="file"]', "fixtures/sample.sqlplan");
const value = await page.locator("#xml").inputValue();
expect(value).toContain("ShowPlanXML");
expect(value).not.toContain("\u0000");
expect(value).not.toContain("\ufeff");
});
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").filter({ hasText: "digesting" })).toBeVisible({ timeout: 30_000 });
await expect(page.locator("#report")).toHaveCount(0);
});
+34
View File
@@ -0,0 +1,34 @@
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", process.env.QAI_ANALYST_BASE_URL ?? "http://192.168.88.131:8888/v1");
await page.fill("#analyst_key", process.env.QAI_ANALYST_KEY ?? "EMPTY");
await page.fill("#analyst_model", process.env.QAI_ANALYST_MODEL ?? "qwen3.8-flash-next");
// Reasoning models stream thinking, not content: without this the report arrives empty.
await page.fill("#analyst_extra", process.env.QAI_ANALYST_EXTRA ?? '{"chat_template_kwargs":{"enable_thinking":false}}');
await page.fill("#jev_key", process.env.JEV_API_KEY ?? "");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByText("Step 3 of 3")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/Analyst ready · Jev ready/)).toBeVisible();
await expect(page.getByRole("link", { name: "Go" })).toBeVisible();
await page.context().storageState({ path: ".auth/user.json" });
});
test("signed-out visitor is pushed to login", async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto("/app");
expect(page.url()).toContain("/login");
await ctx.close();
});
+38
View File
@@ -0,0 +1,38 @@
import { test, expect } from "@playwright/test";
import { DatabaseSync } from "node:sqlite";
import { randomUUID } from "node:crypto";
test.use({ storageState: ".auth/user.json" });
const DB = ".playwright-data/qai.db";
// The app opens this file too (WAL), so writes here are just another connection.
function withDb<T>(fn: (d: DatabaseSync) => T): T {
const d = new DatabaseSync(DB);
try { return fn(d); } finally { d.close(); }
}
test("a row left running renders what exists instead of redirecting forever", async ({ page }) => {
const id = withDb((d) => {
const userId = d.prepare("SELECT id FROM users ORDER BY rowid DESC LIMIT 1").get() as { id: string };
const aid = randomUUID();
// Backdated so it never becomes the rail's first row: history.spec.ts depends on that row being a real run.
d.prepare("INSERT INTO analyses (id, user_id, title, xml, status, created_at, updated_at) VALUES (?,?,?,?,?,?,?)")
.run(aid, userId.id, "Orphaned run", "<ShowPlanXML/>", "running", Date.now() - 3_600_000, Date.now() - 3_600_000);
return aid;
});
await page.goto(`/app/${id}`);
await expect(page.getByText(/This run was interrupted before it finished/)).toBeVisible({ timeout: 15_000 });
expect(page.url()).toContain(`/app/${id}`); // the old redirect sent this to itself
await expect(page.getByRole("button", { name: "Analyse" })).toHaveCount(0);
});
test("an email another account already has is refused inline", async ({ page }) => {
withDb((d) => d.prepare("INSERT INTO users (id, email, pass, created_at) VALUES (?,?,?,?)")
.run(randomUUID(), "taken@qai.test", "salt:hash", Date.now()));
await page.goto("/settings");
await page.fill("#email", "taken@qai.test");
await page.getByRole("button", { name: "Save" }).click();
await expect(page.locator("form p[role=alert]")).toContainText("That email already has an account", { timeout: 15_000 });
const still = withDb((d) => (d.prepare("SELECT email FROM users WHERE email = ?").get("taken@qai.test") as { email: string }).email);
expect(still).toBe("taken@qai.test");
});
+31
View File
@@ -0,0 +1,31 @@
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({ timeout: 15_000 });
const before = await page.locator(".rail li").count();
let asked = false;
page.once("dialog", async (d) => { asked = true; await d.dismiss(); }); // refuse: nothing may vanish
await page.locator(".rail li").first().getByRole("button", { name: "Delete" }).click();
await page.waitForTimeout(1000);
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: 20_000 }).toBe(before - 1);
});
test("reopening a stored run renders the report with no new model call", async ({ page }) => {
await page.goto("/app");
await page.locator('.rail a[href^="/app/"]').first().click();
await expect(page.locator("#report")).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Analyse" })).toHaveCount(0);
// The stored Jev verdict is server-rendered, so it is readable even if the report body had failed.
await expect(page.getByText(/Jev verdict ·/)).toBeVisible();
});
+26
View File
@@ -0,0 +1,26 @@
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);
const box = await page.getByRole("button", { name: "Analyse" }).boundingBox();
// .btn is 10px padding + 15px line = 35px at these widths; 32 leaves room for subpixel rounding
// while still failing a squashed or two-line control.
expect(box!.height).toBeGreaterThanOrEqual(32);
});
}
test("every settings field has a visible label", async ({ page }) => {
await page.goto("/settings");
// Scoped to the form: the rail's rename boxes are labelled for assistive tech with aria-label, not visibly.
const orphans = await page.evaluate(() => [...document.querySelectorAll("form input")]
.filter((el) => { const i = el as HTMLInputElement;
return i.type !== "hidden" && (!i.id || !document.querySelector(`label[for="${i.id}"]`)); })
.map((el) => (el as HTMLInputElement).name || (el as HTMLInputElement).type));
expect(orphans).toEqual([]);
});
+45
View File
@@ -0,0 +1,45 @@
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 });
});
+33
View File
@@ -0,0 +1,33 @@
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}$/);
});
test("changeEmail refuses an address another account already has", async () => {
const { signUpNewUser, changeEmail } = await import("../lib/auth.ts");
const db = await import("../lib/db.ts");
const a = signUpNewUser("first@x.test", "0123456789ab");
const b = signUpNewUser("second@x.test", "0123456789ab");
assert.ok(a.ok && b.ok);
assert.deepEqual(changeEmail(b.id, "first@x.test"), { ok: false, error: "That email already has an account." });
assert.deepEqual(changeEmail(b.id, "not an email"), { ok: false, error: "Enter a valid email address." });
assert.deepEqual(changeEmail(b.id, " FIRST@X.TEST "), { ok: false, error: "That email already has an account." });
assert.equal(changeEmail(a.id, "first@x.test").ok, true, "own address is not a conflict");
assert.equal(changeEmail(b.id, "fresh@x.test").ok, true);
assert.equal(db.userByEmail("fresh@x.test")!.id, b.id, "stored lower-cased");
});
+50
View File
@@ -0,0 +1,50 @@
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);
});
test("rows are plain objects so they can cross the server -> client boundary", () => {
const id = q.createUser("plain@x.y", "h");
const aid = q.newAnalysis(id, "t", "<xml/>");
const row = q.listAnalyses(id)[0];
assert.equal(Object.getPrototypeOf(row), Object.prototype, "listAnalyses returned a null-prototype row");
assert.equal(Object.getPrototypeOf(q.getAnalysis(aid, id)!), Object.prototype, "getAnalysis");
q.saveSettings(id, { analyst_base_url: "b", analyst_key: "k", analyst_model: "m", analyst_extra: "{}",
jev_key: "j", jev_model: "v", onboarded: 1 });
assert.equal(Object.getPrototypeOf(q.getSettings(id)!), Object.prototype, "getSettings");
});
+78
View File
@@ -0,0 +1,78 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const { digestPlan, PlanError, digestForModel } = await import("../lib/digest.ts");
export 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 &gt; 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);
});
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);
});
+93
View File
@@ -0,0 +1,93 @@
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);
});
test("a silent jev does not accuse every option of being unsafe", () => {
const v = jevFallback(candidates, "fetch failed");
// change_control is read off the candidate's own actions and stays; anything derived from a Jev score must go.
const derived = (flags: string[]) => flags.filter((f) => f !== "change_control");
assert.deepEqual(v.order.map((r) => derived(r.flags)), [[], []]);
});
test("verify_semantics fires where semantics can actually move", async () => {
// Real Jev scores an index create ~0.57 ("probably fine, no stated proof"), which a blanket <0.6 floor
// flagged as unsafe on every run. Index-only options must not shout; rewrites and bad scores must.
const idxOnly = [{ ...candidates[0], rewrite_sql: null }, candidates[1]];
const mid = { byKey: { a: scored(4, 2, 2, 1), b: scored(4, 2, 2, 1) }, cross: GOOD.cross };
const v = await judgeCandidates(digest, idxOnly, fakeJev(mid));
assert.ok(!v.order.find((r) => r.key === "a")!.flags.includes("verify_semantics"), "index create flagged");
assert.ok(v.order.find((r) => r.key === "b")!.flags.includes("verify_semantics"), "rewrite not flagged");
const bad = { byKey: { a: scored(4, 1, 2, 1), b: GOOD.byKey.b }, cross: GOOD.cross };
const v2 = await judgeCandidates(digest, idxOnly, fakeJev(bad));
assert.ok(v2.order.find((r) => r.key === "a")!.flags.includes("verify_semantics"), "bad score not flagged");
});
+28
View File
@@ -0,0 +1,28 @@
import test from "node:test";
import assert from "node:assert/strict";
const { promptLibrary, reportPromptOptions, specs } = await import("../lib/report-spec.ts");
test("the prompt advertises every component and the root", () => {
const p = promptLibrary.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);
});
test("every spec has a renderer, so the model can never emit an unrenderable block", async () => {
const { library } = await import("../lib/report-ui.ts");
for (const s of specs) assert.ok(library.components[s.name], `no renderer for ${s.name}`);
});
test("a null confidence from the model does not blank the block", async () => {
// The prompt says optional; models still send null. confidence != undefined let null through to .toFixed().
const { library } = await import("../lib/report-ui.ts");
const renderNode = () => null;
for (const [name, props] of [
["ScoreBar", { label: "fit", value: 0.5, confidence: null }],
["VerdictBlock", { headline: "h", why: "w", source: "jev", confidence: null }],
] as const) {
const el = library.components[name].component({ props: props as any, renderNode } as any);
assert.ok(el, `${name} returned nothing`);
}
});
+35
View File
@@ -0,0 +1,35 @@
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 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" } }]);
});
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");
});
test("openAiTextDeltas survives a delta split across chunks", async () => {
const res = new Response(streamOf(['data: {"choices":[{"delta":{"con', 'tent":"xy"}}]}\n\ndata: [DONE]\n\n']), { status: 200 });
const out: string[] = [];
for await (const d of openAiTextDeltas(res)) out.push(d);
assert.equal(out.join(""), "xy");
});
+48
View File
@@ -0,0 +1,48 @@
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");
});
test("saveSettings never blanks a stored secret, whoever calls it", () => {
const u = db.createUser("k@x.y", "h");
const base = { analyst_base_url: "b", analyst_key: "sealed", analyst_model: "m", analyst_extra: "{}",
jev_key: "jevsealed", jev_model: "j", onboarded: 1 };
db.saveSettings(u, base);
db.saveSettings(u, { ...base, analyst_key: "", jev_key: "" });
assert.equal(db.getSettings(u)?.analyst_key, "sealed");
assert.equal(db.getSettings(u)?.jev_key, "jevsealed");
assert.equal(db.getSettings(u)?.analyst_base_url, "b", "non-secret fields still update");
});
+43
View File
@@ -0,0 +1,43 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": [
"node_modules"
]
}