Compare commits
16
Commits
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
APP_SECRET=
|
||||||
|
DATA_DIR=/data
|
||||||
+22
-9
@@ -1,11 +1,24 @@
|
|||||||
node_modules
|
# next
|
||||||
.next
|
/node_modules
|
||||||
out
|
/.next
|
||||||
build
|
/out
|
||||||
data
|
/build
|
||||||
.env
|
next-env.d.ts
|
||||||
.env.local
|
|
||||||
.env*.local
|
|
||||||
*.tsbuildinfo
|
*.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
|
.DS_Store
|
||||||
.vercel
|
.playwright-data
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
[{ "date": "2026-09-21", "macrostructure": "Workbench", "theme": "Terminal", "enrichment": "none", "brief": "SQL Server qAI \u00b7 plan triage console" }]
|
||||||
@@ -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 -->
|
||||||
+27
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -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" } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const GET = () => Response.json({ ok: true });
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
import { Runner } from "@/components/runner";
|
||||||
|
export default function Run() { return <Runner />; }
|
||||||
@@ -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; } }
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { sessionUser } from "@/lib/auth";
|
||||||
|
export default async function Home() { redirect((await sessionUser()) ? "/app" : "/login"); }
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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
@@ -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; } }
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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**.
|
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)
|
## Actors (two models, strict roles)
|
||||||
|
|
||||||
- **Analyst** — any OpenAI-compatible endpoint the user configures (`base_url`,
|
- **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
|
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
|
top pick is defensible because Jev's probabilities are on screen, and the winning
|
||||||
SQL is one click from the clipboard.
|
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.
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 > 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>
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
@@ -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
@@ -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
@@ -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)] };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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] })),
|
||||||
|
});
|
||||||
@@ -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>;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)}` : "");
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+5705
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" } },
|
||||||
|
});
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
Executable
+23
@@ -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; }
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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 > 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);
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user