Compare commits

...
Author SHA1 Message Date
ahmed mohammedandClaude Opus 4.7 6248540857 fix: candidates carry runnable T-SQL; placeholder sql is dropped, not rendered
Measured on a 2.7 MB production plan (plan-cxpaket.sqlplan, kept out of git):
- rewrite_sql becomes option_type + sql_to_run. The old name told the model 'query rewrite only', so an index option carried no CREATE INDEX anywhere in the pipeline and the report had zero SqlBlocks. It now requires paste-ready T-SQL per option, and the report prompt makes a SqlBlock per non-null sql_to_run a hard requirement.
- verify_semantics keys on option_type === 'rewrite' rather than 'has sql', so index and statistics options are not flagged just for carrying DDL.
- dropPlaceholderSql: 'SELECT ... FROM ... OPTION (MAXDOP 1)' is not runnable and was rendered as if it were. Ellipsis SQL is now nulled, and digestForModel reports sql_truncated so the model is told not to offer a rewrite for a statement it never received.
- max_tokens 2048 -> 4096 for both calls: the larger JSON outgrew the old ceiling and the user waited 38s for 'malformed JSON'. A truncated response now says how many characters it got, and extra params still override the ceiling.
- Real plan, live: 52s, 17 cards, 3 SqlBlocks with real table names and no placeholders, 3 ScoreBars, headline 'Query Plan Timeout on Parallel Join'. 43 unit, 23 e2e.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 11:30:27 +03:00
ahmed mohammedandClaude Opus 4.7 3fa436103a fix: digest real production plans (nesting depth, Object table names, character refs)
Measured against a 2.7 MB production plan that failed to import:
- maxNestedTags: real spaghettified plans nest 2729 tags deep and fast-xml-parser caps at 128, which surfaced as 'Maximum nested tags exceeded' and blamed the export. Raised to 20000; the 12 MB input cap bounds the worst case.
- table names live on <Object>, not on <IndexScan>/<TableScan>. The digest reported 0 tables for a plan with 51 objects; the analyst model never saw a table name. Now 37 distinct tables.
- SSMS writes StatementText with numeric character references (&#xD;&#xA; on every line, 90 in this plan). Attribute/tag processors decode them, so the model gets SQL instead of entity noise.
- 3 new digest tests, each from one of the three measurements above. 39 unit tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 11:16:18 +03:00
ahmed mohammedandClaude Opus 4.7 4b5c19b5d2 fix: decode .sqlplan files the way SSMS writes them; name the parse failure
- lib/plan-file.ts: BOM-aware decode for UTF-8, UTF-16LE/BE (with and without BOM) and UTF-32, with a NUL-density fallback for BOM-less UTF-16. The browser had a two-byte FF FE sniff only, so a UTF-16BE or BOM-less plan arrived as NUL-filled junk and the digest blamed the export.
- digestPlan now quotes fast-xml-parser's first line instead of a fixed sentence, so a truncated file, a stray entity and a wrong file stay distinguishable.
- plan-file.ts holds the decode so the XML parser stays out of the client bundle.
- tests: 6 encodings round-trip through decodePlan into a digest; a truncated plan's message is asserted to carry a reason; e2e drops a real UTF-16LE file. 36 unit, 23 e2e.
- history.spec picked the rail's first row, which is now one of the new failing-input rows; it selects the newest finished run instead.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 11:08:55 +03:00
ahmed mohammedandClaude Opus 4.7 3d1866367b docs: ledger for the secure-cookie sign-out and the missing console gate
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 10:46:43 +03:00
ahmed mohammedandClaude Opus 4.7 2ebfcfb7a6 fix: session cookie over plain http, console onboarding gate, webkit qa coverage
- the session cookie was Secure whenever NODE_ENV=production, so a production build on http://localhost got no cookie back in Safari: every page after signup looked signed-out. It is now Secure only when X-Forwarded-Proto says https, or when COOKIE_SECURE=1.
- /app and /app/[id] now require an analyst and a Jev key, the same rule /api/analyze enforces with a 412. Closing the guide mid-way used to leave a console that could not run anything.
- Playwright runs the production server (pree2e builds first) and gains a webkit project for session survival; chromium-only QA could never see a Secure-cookie rejection.
- tests-e2e/session.spec.ts pins both; .env.example gains COOKIE_SECURE and stops shipping a blank APP_SECRET.
- e2e: 21 passed (setup, chromium, webkit, mobile).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-22 10:45:12 +03:00
24 changed files with 364 additions and 41 deletions
+10 -2
View File
@@ -1,2 +1,10 @@
APP_SECRET=
DATA_DIR=/data
# At least 16 characters. Encrypts the model keys you paste in /settings (AES-256-GCM).
# Changing it makes already-stored keys unreadable — you will have to paste them again.
APP_SECRET=replace-with-32-random-characters
# Where qai.db lives. Default ./data; the Docker image sets /data.
DATA_DIR=./data
# Only for a direct https deploy with no proxy setting X-Forwarded-Proto.
# Leave unset on plain http: Safari will not store a Secure cookie there.
# COOKIE_SECURE=1
+3
View File
@@ -22,3 +22,6 @@ next-env.d.ts
# e2e fixture input is committed; scratch output is not
.DS_Store
.playwright-data
# real production plans dropped in for debugging stay out of git
/plan-*.sqlplan
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
+9 -3
View File
@@ -40,6 +40,7 @@ bash scripts/smoke.sh # healthz, login page, signed-out redirect, analy
| --- | --- |
| `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`. |
| `COOKIE_SECURE` | set to `1` only when the app is served over https without a proxy that sets `X-Forwarded-Proto`. The session cookie is `Secure` when that header says `https`; forcing it on plain http makes Safari refuse to store it. |
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.
@@ -56,12 +57,17 @@ brings their own endpoint and their own spend. Blank key field = keep the stored
```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
npm run e2e # builds first, then Playwright against the production server
```
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.
`npm run e2e` is the opposite — `pree2e` builds, then Playwright drives `npm run start` (the production server,
not `next dev`: cookie flags and hydration differ, and that difference once hid a sign-out bug) against the real
models. It reads `JEV_API_KEY` from `.env.local`. Expect it to be slow; that is the models, not the harness.
Projects: `chromium` (everything except the session spec, reusing one signed-in session), `webkit` (Safari —
session survival only, because it is the one engine that rejects a `Secure` cookie over `http://localhost`),
and `mobile` (Pixel 7, layout only, so no model run is paid for twice).
`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.
+6
View File
@@ -1,8 +1,14 @@
import { redirect } from "next/navigation";
import { requireUser } from "@/lib/auth";
import { settingsView } from "@/lib/settings";
import { Rail } from "@/components/rail";
export default async function Console({ children }: { children: React.ReactNode }) {
const u = await requireUser();
const v = settingsView(u.id);
// Same requirement /api/analyze enforces (it answers 412). Without this, a user who closes the guide
// mid-way lands on a console that cannot run anything, and only finds out when Analyse turns red.
if (!v.has_analyst || !v.has_jev) redirect("/setup?step=2");
return <div className="shell">
<aside className="rail no-print" aria-label="Previous analyses"><Rail userId={u.id} /></aside>
<main className="main">{children}</main>
+3 -4
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { readSse } from "@/lib/stream";
import { decodePlan } from "@/lib/plan-file.ts";
import { ReportView } from "./report-view";
import { ExportButtons } from "./export-buttons";
@@ -59,10 +60,8 @@ export function Runner() {
<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(/^/, ""));
// SSMS saves UTF-16LE; File.text() would decode that as UTF-8 and fill the plan with NULs.
setXml(decodePlan(new Uint8Array(await f.arrayBuffer())));
}} />
{at >= 0 && <span className="pill" aria-live="polite">{at + 1} of 4 · {LABEL[STAGES[at]]}</span>}
</div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 MiB

+43 -10
View File
@@ -8,7 +8,9 @@ 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) });
actions: z.array(Action).min(1),
option_type: z.enum(["index", "statistics", "rewrite", "schema", "app", "ops"]),
sql_to_run: 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>;
@@ -22,18 +24,41 @@ lowest-risk one. Each option needs a snake_case key, a diagnosis naming WHY the
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.
A rewrite must return the same rows as the original. If you cannot rewrite safely, do not offer one.
When the digest says sql_truncated is true, you were not given the whole statement: never offer option_type
"rewrite", because you cannot reproduce text you never received. Offer an index, statistics, or query-hint
change instead, and say in expected that the statement itself was not rewritten here.
option_type is what the option actually is: index | statistics | rewrite | schema | app | ops.
sql_to_run is the exact T-SQL the user must execute for that option — CREATE INDEX, CREATE STATISTICS,
UPDATE STATISTICS, ALTER TABLE, or the rewritten query — with the real table, column and index names taken from
the digest. Null only when the option needs no SQL at all (query-store verification, app-side batching, an ops
change). An option whose key says index or statistics and whose sql_to_run is null is a wrong answer.
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":"..."}]}`;
"effort":"low|medium|high","requires_change_control":false}],"option_type":"index","sql_to_run":"CREATE INDEX ...","expected":"..."}]}`;
export function extractJson(text: string): unknown {
const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
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."); }
catch {
// "malformed JSON" after 38 seconds blamed the model for what was usually our own output ceiling:
// a truncated response has no closing brace, so say the length out loud and let the user raise it
// through the extra-params field (which overrides max_tokens).
throw new AnalystError(`The analyst model's JSON stopped after ${s.length} characters without closing. `
+ `Raise the ceiling in settings extra params, e.g. {"max_tokens":8192}.`);
}
}
// A SqlBlock the user cannot paste is worse than no SqlBlock: it reads as an instruction and breaks on run.
// Models reach for "SELECT ... FROM ..." when the original statement is too long to reproduce verbatim.
// ponytail: "..." is not valid T-SQL, so the ellipsis is a safe tell; a literal '...' inside a string
// literal would be dropped too, which costs a block and never invents one.
export function dropPlaceholderSql<T extends { sql_to_run: string | null }>(candidates: T[]): T[] {
return candidates.map((c) => (c.sql_to_run && /[\u2026]|\.\.\.\s*(FROM|WHERE|OPTION|;|$)|(FROM|SELECT)\s+\.\.\./i.test(c.sql_to_run)
? { ...c, sql_to_run: null } : c));
}
export async function proposeCandidates(cfg: AnalystConfig, digest: Digest, note: string,
@@ -41,7 +66,7 @@ export async function proposeCandidates(cfg: AnalystConfig, digest: Digest, note
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,
body: JSON.stringify({ model: cfg.model, temperature: 0.6, max_tokens: 4096, ...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}`); });
@@ -55,7 +80,7 @@ export async function proposeCandidates(cfg: AnalystConfig, digest: Digest, note
? `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;
return dropPlaceholderSql(parsed.data.candidates);
}
export const REPORT_SYSTEM_PROMPT = (openuiPrompt: string) => `${openuiPrompt}
@@ -63,14 +88,22 @@ 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.`;
and say why. Never invent a number that is not in the input.
Hard requirements, in order:
1. Every option whose sql_to_run is not null gets its own SqlBlock containing exactly that T-SQL, unaltered and
runnable as pasted. Never write an ellipsis or a placeholder inside a SqlBlock: if the option's sql_to_run is
null, that option gets no SqlBlock at all. A report whose first move needs something executed and contains no
SqlBlock is wrong.
2. Every option gets a ScoreBar for its Jev composite score, carrying the confidence the ranking gave it.
3. VerdictBlock names the first move and states what to run first.`;
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,
body: JSON.stringify({ model: cfg.model, temperature: 0.3, max_tokens: 4096, ...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}`); });
+9 -2
View File
@@ -33,6 +33,7 @@ export function signUpNewUser(email: string, password: string): { ok: true; id:
// 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();
const fwdProto = async () => ((await (await import("next/headers")).headers())).get("x-forwarded-proto") ?? "";
export function changeEmail(userId: string, email: string): { ok: true } | { ok: false; error: string } {
const e = email.trim().toLowerCase();
@@ -47,9 +48,15 @@ export function changeEmail(userId: string, email: string): { ok: true } | { ok:
export async function startSession(userId: string) {
const token = randomBytes(32).toString("hex");
putSession(hashToken(token), userId, Date.now() + SESSION_DAYS * 864e5);
const secure = process.env.COOKIE_SECURE === "1" || (await fwdProto()).includes("https");
(await jar()).set(SESSION_COOKIE, token, {
httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production",
path: "/", maxAge: SESSION_DAYS * 86400 });
httpOnly: true, sameSite: "lax", secure,
path: "/", maxAge: SESSION_DAYS * 86400,
// NODE_ENV is the wrong signal here. A production build served over plain http://localhost would set
// Secure, Safari then refuses to store the cookie at all, and every page after signup looks signed-out.
// (Chromium accepts Secure on localhost, which is why a chromium-only suite could never see it.)
// Only a TLS proxy reports https; COOKIE_SECURE=1 forces it on for a direct https deploy.
});
}
export async function endSession() {
const store = await jar();
+33 -4
View File
@@ -3,7 +3,26 @@ 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 });
// maxNestedTags defaults to 128 and a spaghettified SQL Server plan blows straight through it: a real
// 2.7 MB production plan measured 2729 tags deep. The 12 MB input cap bounds the worst case.
const NAMED_ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
// SSMS writes plan text with numeric references (&#xD;&#xA; for each line break, 90 of them in a production
// plan) and fast-xml-parser leaves them alone, so the model was handed entity noise instead of SQL.
const decodeEntities = (v: string) => v.includes("&")
? v.replace(/&(#[0-9]+|#[xX][0-9a-fA-F]+|[a-zA-Z]+);/g, (m, body) => {
if (body[0] === "#") {
const cp = /^#[xX]/.test(body) ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10);
return Number.isFinite(cp) && cp > 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : m;
}
return NAMED_ENTITIES[body] ?? m;
})
: v;
const parser = new XMLParser({
ignoreAttributes: false, attributeNamePrefix: "", removeNSPrefix: true, maxNestedTags: 20000,
attributeValueProcessor: (_n: string, value: string) => decodeEntities(value),
tagValueProcessor: (_n: string, value: string) => decodeEntities(value),
});
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 };
@@ -36,7 +55,13 @@ export function digestPlan(raw: string): Digest {
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."); }
// Name the parser's complaint. "re-export the plan" is what a user gets told either way, so a truncated
// file, a stray entity and a genuinely wrong file have to stay distinguishable.
try { doc = parser.parse(xml); }
catch (e: any) {
const why = String(e?.message ?? e).split("\n")[0].slice(0, 140);
throw new PlanError(`The XML stopped parsing: ${why}. Check the file was saved completely (SSMS: Save As, not copy-paste).`);
}
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");
@@ -59,8 +84,11 @@ export function digestPlan(raw: string): Digest {
// 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(".");
// The names sit on <Object> (or its <Table> child), not on <IndexScan>/<TableScan> itself: a production
// plan with 51 objects digested to zero tables until this reached into the child.
const scanName = (n: any) => fullName(n) || fullName(n?.Object ?? {});
const tables = [...new Set([...collect(doc, "IndexScan"), ...collect(doc, "TableScan"), ...collect(doc, "Table")]
.map(fullName).filter(Boolean))];
.map(scanName).filter(Boolean))];
const missingIndexes: MissingIndex[] = collect(doc, "MissingIndexGroup").map((g: any) => {
const mi = collect(g, "MissingIndex")[0] ?? {};
@@ -104,7 +132,8 @@ export function digestPlan(raw: string): Digest {
// 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,
sql: d.sql.slice(0, 4000), sql_truncated: d.sql.length > 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,
+4 -4
View File
@@ -50,11 +50,11 @@ function rank(c: Candidate, a: any): Ranked {
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.
// Live Jev scores an index create around 0.57 ("probably equivalent, no proof stated"), so a blanket floor
// flags every index option and the warning becomes noise. Semantics only move when the query text changes:
// flag a 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 (safety < SAFETY_HARD_FLOOR || (c.option_type === "rewrite" && 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 };
+25
View File
@@ -0,0 +1,25 @@
"use client";
// SSMS saves .sqlplan as UTF-16LE with a BOM; other tools write UTF-8 (sometimes with a BOM), and a few write
// UTF-16BE or BOM-less UTF-16. File.text() assumes UTF-8, which turns a UTF-16 plan into NUL-filled junk that
// fails to parse with a message blaming the user's export. BOM first, then a NUL-density guess.
export function decodePlan(buf: Uint8Array): string {
const enc = utfEncoding(buf);
return new TextDecoder(enc).decode(buf).replace(/^\uFEFF/, "");
}
function utfEncoding(buf: Uint8Array): "utf-8" | "utf-16le" | "utf-16be" | "utf-32le" | "utf-32be" {
const b = (i: number) => buf[i] ?? 0;
if (b(0) === 0xff && b(1) === 0xfe && b(2) === 0 && b(3) === 0) return "utf-32le";
if (b(0) === 0 && b(1) === 0 && b(2) === 0xfe && b(3) === 0xff) return "utf-32be";
if (b(0) === 0xff && b(1) === 0xfe) return "utf-16le";
if (b(0) === 0xfe && b(1) === 0xff) return "utf-16be";
// BOM-less UTF-16 of ASCII XML: in the first 200 bytes roughly every other byte is 0x00.
const n = Math.min(buf.length - (buf.length % 2), 200);
if (n < 40) return "utf-8";
let even = 0, odd = 0;
for (let i = 0; i < n; i += 2) { if (b(i) === 0) even++; if (b(i + 1) === 0) odd++; }
if (odd > n / 6) return "utf-16le";
if (even > n / 6) return "utf-16be";
return "utf-8";
}
+2 -1
View File
@@ -8,7 +8,8 @@
"start": "next start",
"lint": "eslint",
"test": "node --no-warnings --test tests/*.test.ts",
"e2e": "playwright test"
"e2e": "playwright test",
"pree2e": "npm run build"
},
"dependencies": {
"@openuidev/lang-core": "^0.3.0",
Binary file not shown.
+10 -2
View File
@@ -18,11 +18,19 @@ export default defineConfig({
use: { baseURL: "http://localhost:3000", trace: "retain-on-failure" },
projects: [
{ name: "setup", testMatch: /auth\.setup\.ts/ },
{ name: "chromium", use: { ...devices["Desktop Chrome"] }, dependencies: ["setup"] },
// The expensive specs are chromium-only (each real run costs ~30s of model time) and reuse the
// session the setup project wrote.
{ name: "chromium", use: { ...devices["Desktop Chrome"] }, dependencies: ["setup"],
testIgnore: /session\.spec\.ts/ },
// Safari/WebKit only checks session survival: it is the one engine that refuses a Secure cookie on
// http://localhost, which is exactly how the wizard "signed itself out" in a production build.
{ name: "webkit", use: { ...devices["Desktop Safari"] }, testMatch: /session\.spec\.ts/ },
// 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,
webServer: { // Run the production server: the cookie flags and hydration behaviour differ from dev, and that
// difference hid a sign-out bug from a dev-only suite. `npm run pree2e` builds before this boots.
command: "npm run start", url: "http://localhost:3000/api/healthz", reuseExistingServer: false,
timeout: 120_000,
env: { DATA_DIR: ".playwright-data", APP_SECRET: process.env.APP_SECRET ?? "playwright-secret-value" } },
});
+23 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from "@playwright/test";
import { readFileSync } from "node:fs";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
const xml = readFileSync("fixtures/sample.sqlplan", "utf8");
test.use({ storageState: ".auth/user.json", permissions: ["clipboard-read", "clipboard-write"] });
@@ -41,6 +41,28 @@ test("a dropped .sqlplan file decodes and is accepted", async ({ page }) => {
expect(value).not.toContain("\ufeff");
});
test("a UTF-16LE .sqlplan, which is what SSMS writes, decodes without NULs", async ({ page }) => {
// fixtures/sample.sqlplan is UTF-8+BOM, so the test above never exercised the SSMS encoding.
mkdirSync(".playwright-data", { recursive: true });
const path = ".playwright-data/upload-utf16le.sqlplan";
const clean = readFileSync("fixtures/sample.sqlplan", "utf8").replace(/^/, "");
writeFileSync(path, Buffer.from("\uFEFF" + clean, "utf16le"));
await page.goto("/app");
await page.setInputFiles('input[type="file"]', path);
const value = await page.locator("#xml").inputValue();
expect(value.startsWith("<?xml")).toBe(true);
expect(value).not.toContain("\u0000");
});
test("a truncated plan names the parse failure instead of blaming the export", async ({ page }) => {
await page.goto("/app");
await page.fill("#xml", readFileSync("fixtures/sample.sqlplan", "utf8").slice(0, 900));
await page.getByRole("button", { name: "Analyse" }).click();
const alert = page.getByRole("alert").filter({ hasText: "digesting" });
await expect(alert).toBeVisible({ timeout: 30_000 });
expect(await alert.innerText()).toMatch(/stopped parsing|Unexpected|unclosed|tag name/i);
});
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;");
+3 -1
View File
@@ -13,7 +13,9 @@ function withDb<T>(fn: (d: DatabaseSync) => T): T {
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 };
// The setup project's account, not "the newest user": the webkit specs sign up their own accounts and
// would otherwise put this row under an account the stored session cannot open (404, not the note).
const userId = d.prepare("SELECT id FROM users WHERE email LIKE 'qa+%@qai.test' 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 (?,?,?,?,?,?,?)")
+3 -1
View File
@@ -23,7 +23,9 @@ test("rename sticks, delete asks first then removes", async ({ page }) => {
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();
// The newest finished run, not the newest row: the encoding tests above leave `failed` rows on top,
// and only unfinished rows carry a status pill in the rail.
await page.locator('.rail a[href^="/app/"]:not(:has(.pill))').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.
+44
View File
@@ -0,0 +1,44 @@
import { test, expect } from "@playwright/test";
// WebKit-only on purpose. Chromium accepts a `Secure` cookie on http://localhost; Safari does not, and it
// silently drops it, so a production build on plain http looked like "the wizard signed me out".
// This file must stay self-contained: it signs up its own account and never uses .auth/user.json.
test("signup, wizard Next links, and /app stay logged in over plain http", async ({ page }) => {
const email = `safari${Date.now()}@qai.test`;
await page.goto("/signup");
await page.fill("#email", email);
await page.fill("#password", "0123456789ab");
await page.getByRole("button", { name: "Create account" }).click();
await expect(page).toHaveURL(/\/setup/, { timeout: 20_000 });
await expect(page.getByText("Step 1 of 3")).toBeVisible();
await page.getByRole("link", { name: "Next" }).click();
await expect(page).toHaveURL(/step=2/);
await expect(page.getByText("Say which models to ask.")).toBeVisible();
await page.getByRole("link", { name: "Next" }).click();
await expect(page).toHaveURL(/step=3/);
// Session proof: this user has no keys yet, so the console gate sends them to the guide.
// Signed-in lands on /setup?step=2; a lost session would land on /login.
await page.goto("/app");
await expect(page).toHaveURL(/\/setup\?step=2/);
});
test("a reload deep in the app keeps the session", async ({ page }) => {
const email = `reload${Date.now()}@qai.test`;
await page.goto("/signup");
await page.fill("#email", email);
await page.fill("#password", "0123456789ab");
await page.getByRole("button", { name: "Create account" }).click();
// Wait for the signup POST to finish: the cookie is not in effect until its response lands, and an
// immediate goto races it (which reads as "the session did not survive a reload").
await expect(page).toHaveURL(/\/setup/, { timeout: 20_000 });
await page.goto("/settings");
await page.reload();
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
// The form is still here after the reload: this is the account's own settings page, not the login form.
await expect(page.getByRole("button", { name: "Save" })).toBeVisible();
});
+58 -2
View File
@@ -10,10 +10,10 @@ const ok = (content: string) => ({ ok: true, status: 200,
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" },
option_type: "index", sql_to_run: "CREATE NONCLUSTERED INDEX [IX_Orders_CustomerId] ON [dbo].[Orders] ([CustomerId]);", 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" }] };
option_type: "rewrite", sql_to_run: "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);
@@ -43,3 +43,59 @@ 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 });
});
test("a response cut off mid-JSON says it was cut off", async () => {
// With every option required to carry runnable T-SQL, the candidates payload outgrew the old 2048-token
// budget and the user was told "malformed JSON" after 38 seconds of nothing useful.
const { extractJson, AnalystError } = await import("../lib/analyst.ts");
const truncated = '{"candidates":[{"key":"a","title":"x","diagnosis":"y","actions":[{"title":"t","detail":"d","effort":"low","requires_change_control":false}],"option_type":"index","sql_to_run":"CREATE INDEX [IX_a] ON [dbo].[a] ([b]);","expected":"see';
let err: unknown = null;
try { extractJson(truncated); } catch (e) { err = e; }
assert.ok(err instanceof AnalystError, String(err));
assert.match((err as Error).message, /stopped after \d+ characters/);
assert.doesNotThrow(() => extractJson('{"a":1}'));
});
test("the candidate call leaves the model room to finish the JSON", async () => {
const { proposeCandidates } = await import("../lib/analyst.ts");
const calls: any[] = [];
const fetchImpl = async (_u: string, init: any) => {
calls.push(JSON.parse(init.body));
const two = { candidates: [0, 1].map((i) => ({ key: "k" + i, title: "Covering index " + i, diagnosis: "full scan of dbo.Orders",
actions: [{ title: "Create the index", detail: "in the reporting DB off-peak", effort: "low", requires_change_control: false }],
option_type: "index", sql_to_run: "CREATE INDEX [IX_a] ON [dbo].[a] ([b]);", expected: "fewer reads" })) };
return { ok: true, json: async () => ({ choices: [{ message: { content: JSON.stringify(two) } }] }) } as any;
};
const digest = { sql: "SELECT 1", bytes: 100, subtreeCost: 1, parallel: false, nonParallelReason: null,
earlyAbort: null, tables: ["dbo.Orders"], topOperators: [], rowGuessErrors: [], missingIndexes: [],
warnings: [], waits: [] };
const cfg = { baseUrl: "http://x/v1", apiKey: "k", model: "m", extra: {} };
await proposeCandidates(cfg as any, digest as any, "", fetchImpl as any);
assert.ok(calls[0].max_tokens >= 4096, `max_tokens was ${calls[0].max_tokens}`);
// extra params still win: that is how a user raises the ceiling without a rebuild.
await proposeCandidates({ ...cfg, extra: { max_tokens: 8192 } } as any, digest as any, "", fetchImpl as any);
assert.equal(calls[1].max_tokens, 8192);
});
test("placeholder SQL never reaches the report as if it were runnable", async () => {
// On a plan whose query text is 100 KB the model cannot reproduce the statement, and it offered
// "SELECT ... FROM ... OPTION (MAXDOP 1);" as a rewrite. A block that cannot be pasted is worse than no block.
const { dropPlaceholderSql } = await import("../lib/analyst.ts");
const cands = [
{ key: "hint_maxdop", option_type: "rewrite", sql_to_run: "SELECT ... FROM dbo.T OPTION (MAXDOP 1);" },
{ key: "idx", option_type: "index", sql_to_run: "CREATE INDEX [IX_T_A] ON [dbo].[T] ([A]);" },
{ key: "ops", option_type: "ops", sql_to_run: null },
];
const out = dropPlaceholderSql(cands as any);
assert.equal(out[0].sql_to_run, null, "the ellipsis rewrite should not survive");
assert.equal(out[1].sql_to_run, "CREATE INDEX [IX_T_A] ON [dbo].[T] ([A]);");
assert.equal(out[2].sql_to_run, null);
});
test("the digest tells the model when the query text was cut", async () => {
const { digestForModel } = await import("../lib/digest.ts");
const base = { bytes: 1, subtreeCost: null, parallel: false, nonParallelReason: null, earlyAbort: null,
tables: [], topOperators: [], rowGuessErrors: [], missingIndexes: [], warnings: [], waits: [] };
assert.equal(digestForModel({ ...base, sql: "SELECT 1" } as any).sql_truncated, false);
assert.equal(digestForModel({ ...base, sql: "SELECT 1" + " ".repeat(5000) } as any).sql_truncated, true);
});
+72
View File
@@ -76,3 +76,75 @@ test("a real .sqlplan digests", () => {
assert.ok(d.topOperators.length > 0, "no operators found — check attribute spellings");
assert.ok(d.sql.length > 0);
});
// ---- plan file decoding (lib/plan-file.ts: the browser reads a .sqlplan into bytes, then hands text to the digest) ----
const enc = (s: string) => new TextEncoder().encode(s);
const utf16le = (s: string) => { const b = new Uint8Array(s.length * 2); for (let i = 0; i < s.length; i++) { b[i * 2] = s.charCodeAt(i); b[i * 2 + 1] = 0; } return b; };
const utf16be = (s: string) => { const b = new Uint8Array(s.length * 2); for (let i = 0; i < s.length; i++) { b[i * 2] = 0; b[i * 2 + 1] = s.charCodeAt(i); } return b; };
const sample = '<?xml version="1.0"?><ShowPlanXML xmlns="http://schemas.microsoft.com/sqlserver/2004/07/showplan" Version="1.0"><BatchSequence><Batch><Statements><StmtSimple StatementText="SELECT 1"/></Statements></Batch></BatchSequence></ShowPlanXML>';
test("every .sqlplan encoding SSMS and friends produce arrives as clean text", async () => {
const { decodePlan } = await import("../lib/plan-file.ts");
const cases: [string, Uint8Array][] = [
["utf-8", enc(sample)],
["utf-8 + bom", enc("\uFEFF" + sample)],
["utf-16le + bom (what SSMS writes)", Uint8Array.from([0xff, 0xfe, ...utf16le(sample)])],
["utf-16be + bom", Uint8Array.from([0xfe, 0xff, ...utf16be(sample)])],
["utf-16le, no bom", utf16le(sample)],
["utf-16be, no bom", utf16be(sample)],
];
for (const [name, bytes] of cases) {
const out = decodePlan(bytes);
assert.ok(out.startsWith("<?xml"), `${name}: starts with ${JSON.stringify(out.slice(0, 12))}`);
assert.ok(!out.includes("\0"), `${name}: still contains NUL bytes`);
assert.equal(digestPlan(out).sql, "SELECT 1", `${name}: digest after decode`);
}
});
test("a parse failure says why instead of blaming the export", async () => {
const { PlanError } = await import("../lib/digest.ts");
const e: unknown = (() => { try { digestPlan("<ShowPlanXML><Unclosed></ShowPlanXML>"); return null; } catch (e) { return e; } })();
assert.ok(e instanceof PlanError);
assert.ok(e.message.length > 40, `message too thin: ${e.message}`);
assert.notEqual(e.message, "That XML will not parse — re-export the actual execution plan.");
});
test("a deeply nested real plan parses (spaghettified plans are thousands of tags deep)", async () => {
// fast-xml-parser defaults to maxNestedTags: 128. A 2.7 MB production plan measured 2729 deep and died
// with "Maximum nested tags exceeded", which read like a corrupt export.
const depth = 3000;
const open = "<ShowPlanXML><RelOp NodeId='1'>".repeat(1);
const inner = "<RelOp NodeId='n'>".repeat(depth) + "<ColumnReference Column='[dbo].[t].[c]'/>" + "</RelOp>".repeat(depth);
let err: unknown = null;
let out: any = null;
try { out = digestPlan(open + inner + "</RelOp></ShowPlanXML>"); } catch (e) { err = e; }
assert.equal(err, null, String((err as Error)?.message));
assert.equal(out.topOperators.length, 12, "digest should rank, not truncate");
});
// ---- real-plan shapes measured on a 2.7 MB production plan (plan-cxpaket.sqlplan) ----
test("table names come off the Object child, not the scan element", async () => {
// ShowPlan carries Database/Schema/Table on <Object>, so reading them off <IndexScan> returned nothing:
// a 51-object production plan digested to zero tables and the model never saw a table name.
const xml = `<ShowPlanXML><BatchSequence><Batch><Statements><StmtSimple StatementText="SELECT 1">
<QueryPlan><RelOp PhysicalOp="Index Scan"><IndexScan>
<Object Database="[crm]" Schema="[dbo]" Table="[Orders]" Index="[IX_Orders_CustomerId]" />
</IndexScan></RelOp>
<RelOp PhysicalOp="Table Scan"><TableScan>
<Object Database="[crm]" Schema="[dbo]" Table="[Customers]" />
</TableScan></RelOp></QueryPlan></StmtSimple></Statements></Batch></BatchSequence></ShowPlanXML>`;
assert.deepEqual(digestPlan(xml).tables, ["crm.dbo.Orders", "crm.dbo.Customers"]);
});
test("numeric character references in StatementText become the characters", () => {
// SSMS writes the plan text with &#xD;&#xA; line breaks: 90 of them in the production plan, so the
// analyst model was handed entity noise instead of SQL.
const xml = `<ShowPlanXML><BatchSequence><Batch><Statements>
<StmtSimple StatementText="SELECT a &#xD;&#xA;FROM dbo.T WHERE x &gt; 1 &amp; y = 2"/>
</Statements></Batch></BatchSequence></ShowPlanXML>`;
const sql = digestPlan(xml).sql;
assert.match(sql, /FROM dbo\.T/);
assert.match(sql, /\n/);
assert.ok(!sql.includes("&#x"), sql);
assert.match(sql, /x > 1 & y = 2/);
});
+3 -3
View File
@@ -10,10 +10,10 @@ const digest = { sql: "SELECT", bytes: 1, subtreeCost: 1, parallel: false, nonPa
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" },
option_type: "index", sql_to_run: "CREATE NONCLUSTERED INDEX [IX_Orders_CustomerId] ON [dbo].[Orders] ([CustomerId]) INCLUDE ([TotalDue]);", 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" },
option_type: "rewrite", sql_to_run: "SELECT TOP 100 * FROM dbo.Orders ORDER BY TotalDue DESC;", expected: "less I/O" },
] as any;
const scored = (fit: number, safe: number, ease: number, cause: number) => ({
@@ -81,7 +81,7 @@ test("a silent jev does not accuse every option of being unsafe", () => {
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 idxOnly = [{ ...candidates[0], option_type: "statistics", sql_to_run: "UPDATE STATISTICS dbo.Orders WITH FULLSCAN;" }, 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");