Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b42a0931c | ||
|
|
735a57bfd6 | ||
|
|
6248540857 | ||
|
|
3fa436103a | ||
|
|
4b5c19b5d2 | ||
|
|
3d1866367b | ||
|
|
2ebfcfb7a6 |
+10
-2
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ next-env.d.ts
|
||||
# runtime data (sqlite)
|
||||
/data
|
||||
/.playwright-data
|
||||
.qa-data/
|
||||
|
||||
# e2e artifacts
|
||||
/.auth
|
||||
@@ -22,3 +23,9 @@ 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
|
||||
|
||||
# generated knowledge graph (regenerate with /understand)
|
||||
.ua/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Wiki conventions (schema layer)
|
||||
|
||||
This directory is a Karpathy-pattern wiki: raw sources, wiki pages, and this schema file.
|
||||
|
||||
## Layout
|
||||
- `raw/` — immutable sources copied from the repository. Never edited here.
|
||||
- `index.md` — the catalog, grouped by category. Every page appears exactly once.
|
||||
- `log.md` — chronological record of what was added, and why.
|
||||
- `<Page Name>.md` — one page per concept. File name is the link target.
|
||||
|
||||
## Categories
|
||||
- Pipeline
|
||||
- Data and storage
|
||||
- Security and credentials
|
||||
- Interface
|
||||
- Verification
|
||||
- Operations
|
||||
- Decisions
|
||||
|
||||
## Page rules
|
||||
1. Start with `# <Page Name>`, then 120 to 250 words of prose.
|
||||
2. Cross-reference with `[[Page Name]]`. Every page links 3 to 6 others, and every link must resolve.
|
||||
3. State measured facts (numbers, timings, error strings) instead of adjectives. Note the measurement when it
|
||||
came from a specific artifact.
|
||||
4. A page about a decision says: what the old behaviour was, what it cost, what replaced it, what it broke.
|
||||
5. No code blocks longer than 5 lines. Long code belongs in `raw/`.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Analysis row lifecycle
|
||||
|
||||
A row is created before the plan is validated, so every attempt is visible in the rail. Status moves through
|
||||
`running` → `done` | `failed` | `partial` | `abandoned`.
|
||||
|
||||
`partial` means some stages produced output before the run stopped. `abandoned` means the browser went away
|
||||
mid-run. Both surface as pills in the history rail rather than being hidden, and `/app/[id]` renders whatever
|
||||
exists with a "this run was interrupted before it finished" note.
|
||||
|
||||
`/app/[id]` never redirects a `running` row. An earlier version did, which turned a row orphaned by a crashed
|
||||
process into an infinite self-redirect.
|
||||
|
||||
Related: [[SSE stream]], [[SQLite schema]], [[History rail]].
|
||||
@@ -0,0 +1,20 @@
|
||||
# Analyst model
|
||||
|
||||
`lib/analyst.ts` talks to any OpenAI-compatible endpoint the user pastes in settings. Two calls, two jobs.
|
||||
|
||||
`proposeCandidates` asks for 2 to 4 genuinely different remediation options and validates them against a zod
|
||||
schema: `key`, `title`, `diagnosis`, 1-4 `actions`, `option_type`, `sql_to_run`, `expected`. Fewer than two options is
|
||||
an error, because [[Jev scoring]] cannot rank a single option.
|
||||
|
||||
`streamReport` writes the final answer as OpenUI Lang, streamed token by token into [[OpenUI report language]].
|
||||
Its system prompt carries hard requirements: every option with `sql_to_run` gets a SqlBlock containing exactly that
|
||||
T-SQL, and every option gets a ScoreBar.
|
||||
|
||||
Both calls spread the user's extra JSON parameters *after* `max_tokens`, which is how the output ceiling is raised
|
||||
from the settings screen without a rebuild. `extractJson` recovers an object from prose or fenced output and, when
|
||||
the response was cut off, names the character count instead of blaming the model.
|
||||
|
||||
The local QA endpoint is `http://192.168.88.131:8888/v1` running `qwen3.8-flash-next`; it streams reasoning
|
||||
instead of content unless extra params include `{"chat_template_kwargs":{"enable_thinking":false}}`.
|
||||
|
||||
Related: [[Jev scoring]], [[Option type and sql to run]], [[Placeholder SQL is dropped]], [[Output token ceiling]].
|
||||
@@ -0,0 +1,13 @@
|
||||
# Console runner
|
||||
|
||||
`components/runner.tsx` is the whole console: file drop with encoding-aware decode, the paste box, stage pills,
|
||||
SSE reading, the live report, a cancel button, and the error line.
|
||||
|
||||
The Analyse button is disabled while a stage is active and re-enabled only on the `done` frame — which is what the
|
||||
end-to-end test waits on before asserting report contents.
|
||||
|
||||
Design follows the [[Design tokens]] contract: dark workbench, JetBrains Mono for plan data, IBM Plex Sans for
|
||||
prose, one phosphor accent. The layout holds at 320 px without horizontal scroll, and every settings field has a
|
||||
real `<label for>`.
|
||||
|
||||
Related: [[SSE stream]], [[OpenUI report language]], [[Export and print]], [[History rail]].
|
||||
@@ -0,0 +1,12 @@
|
||||
# Container plan
|
||||
|
||||
`Dockerfile` is a multi-stage Node 25 build — Node 25 because `node:sqlite` is what the data layer uses — and
|
||||
`docker-compose.yml` mounts a named volume at `/data` and reads `APP_SECRET` from the host environment.
|
||||
|
||||
The image is written but unbuilt on the development machine, which has no Docker. The build is therefore the
|
||||
first thing to verify on a machine that has it: `bash scripts/smoke.sh --docker`.
|
||||
|
||||
Known rough edges carried forward deliberately: `.dockerignore` excludes tests but the image copies `node_modules`
|
||||
including devDependencies, and a first real build may need a tweak.
|
||||
|
||||
Related: [[Running locally]], [[Smoke script]], [[Environment variables]].
|
||||
@@ -0,0 +1,13 @@
|
||||
# Credential vault
|
||||
|
||||
Model keys are pasted by the user into the settings screen. They are never stored as server environment
|
||||
configuration and never appear in the DOM of a subsequent page.
|
||||
|
||||
`lib/vault.ts` seals with AES-256-GCM under a key derived from `APP_SECRET`, and renders secrets as bullets plus the
|
||||
last four characters (`••••1234`). `open` returns null instead of throwing when `APP_SECRET` changed, so the app
|
||||
asks for the key again instead of crashing.
|
||||
|
||||
`settingsView` is the only shape the forms ever receive, which is what makes "never echo a stored secret back"
|
||||
structural rather than a per-field discipline.
|
||||
|
||||
Related: [[Session cookie rules]], [[Settings form]], [[SQLite schema]].
|
||||
@@ -0,0 +1,9 @@
|
||||
# Design contract
|
||||
|
||||
`design-system/sql-server-qai/MASTER.md` is the contract the UI is built against: dark workbench palette, type
|
||||
scale, spacing, component rules, and the reason the accent is reserved for action and state rather than decoration.
|
||||
|
||||
The Hallmark audit log in `.hallmark/log.json` records the decisions taken against it. Two of them shaped the
|
||||
code: no utility CSS framework, and no network font loader.
|
||||
|
||||
Related: [[Design tokens]], [[Console runner]].
|
||||
@@ -0,0 +1,13 @@
|
||||
# Design tokens
|
||||
|
||||
`app/tokens.css` defines the palette and scale: dark console background, phosphor-green accent reserved for
|
||||
action and state, JetBrains Mono for anything numeric or machine-written, IBM Plex Sans for prose, and a spacing
|
||||
scale used everywhere instead of literal margins.
|
||||
|
||||
`app/ui.css` turns the tokens into semantic classes — `card`, `field`, `btn`, `pill`, `bar`, `rail` — plus the print
|
||||
stylesheet. Tailwind was installed by the scaffold and removed by decision: the design contract is small enough to
|
||||
own directly, and utility classes were duplicating tokens rather than expressing them.
|
||||
|
||||
Fonts come from the token stack rather than a network font loader, keeping the build offline-light.
|
||||
|
||||
Related: [[Export and print]], [[Console runner]], [[Design contract]].
|
||||
@@ -0,0 +1,13 @@
|
||||
# Decision: email collision check
|
||||
|
||||
Changing your email in settings wrote straight to a UNIQUE column. A collision surfaced as a raw constraint error
|
||||
inside a server action — the form looked like it did nothing.
|
||||
|
||||
`changeEmail` in `lib/auth.ts` now validates the address, compares it case-insensitively after trimming, and
|
||||
returns "That email already has an account." when another user owns it. It returns ok when the address is already
|
||||
yours.
|
||||
|
||||
A deferred neighbour: sessions are not revoked when the password changes, so a 30-day token survives a password
|
||||
rotation.
|
||||
|
||||
Related: [[First-run gate]], [[Session cookie rules]], [[SQLite schema]].
|
||||
@@ -0,0 +1,11 @@
|
||||
# Environment variables
|
||||
|
||||
Deliberately short. `APP_SECRET` seals stored keys; `DATA_DIR` locates the SQLite file; `COOKIE_SECURE=1` forces the
|
||||
Secure flag for a direct https deploy with no proxy setting `X-Forwarded-Proto`.
|
||||
|
||||
Nothing about models lives here. Base URL, key, model name, extra JSON parameters, Jev key and Jev model are
|
||||
per-user settings encrypted at rest, because the deployment is expected to be self-hosted and shared.
|
||||
|
||||
The full file list, with the reasoning for each default, is in the repository README.
|
||||
|
||||
Related: [[Credential vault]], [[Session cookie rules]], [[Running locally]].
|
||||
@@ -0,0 +1,9 @@
|
||||
# Export and print
|
||||
|
||||
Two export actions, both client-side, no PDF library: a Copy button that pulls the SQL blocks out of the rendered
|
||||
report, and Print, which opens the browser print dialog against a print stylesheet.
|
||||
|
||||
`app/ui.css` carries the print rules — the rail, the toolbar and the runner all hide, so the printed page is the
|
||||
report. Saving as PDF from the dialog is the export path.
|
||||
|
||||
Related: [[Console runner]], [[OpenUI report language]], [[Design tokens]].
|
||||
@@ -0,0 +1,14 @@
|
||||
# First-run gate
|
||||
|
||||
The console requires two things before it renders: an analyst base URL with model and key, and a Jev key. The gate
|
||||
lives in `app/app/layout.tsx`, so it covers `/app` and `/app/[id]` in one place, and it matches the rule
|
||||
`POST /api/analyze` already enforces with a 412.
|
||||
|
||||
Before the gate existed, the guide was only reachable because the signup and sign-in actions redirect there. Close
|
||||
the wizard halfway and the console rendered anyway, then failed only when Analyse turned red.
|
||||
|
||||
The three-step guide is reused as the destination after a save from settings, via a hidden `after` field on
|
||||
`SettingsForm`. Without it, a save from the wizard bounced the user straight to the console and the Save button
|
||||
looked dead.
|
||||
|
||||
Related: [[Session cookie rules]], [[Settings form]], [[Email collision check]].
|
||||
@@ -0,0 +1,10 @@
|
||||
# Gitea remote
|
||||
|
||||
The repository is pushed to a self-hosted Gitea at `http://192.168.88.39:3000/ahmed/sql-server-query-ai.git`, branch
|
||||
`main`. Credentials were used inline for the push and are deliberately absent from `.git/config`, the README and
|
||||
every tracked file; the README documents using a keychain entry or a token instead.
|
||||
|
||||
Tracked artifacts that must stay out: the generated `.ua/` graph directory, the QA databases under `.qa-data/`,
|
||||
`.playwright-data`, and any production plan file matching `/plan-*.sqlplan` — real plans carry real schema names.
|
||||
|
||||
Related: [[Running locally]], [[Wiki conventions]].
|
||||
@@ -0,0 +1,10 @@
|
||||
# History rail
|
||||
|
||||
The rail lists the signed-in user's runs newest first, with the settings link pinned at the bottom. Each row is a
|
||||
card holding the title link, an inline rename field, a delete button, and a status pill that appears only when
|
||||
status is not `done`.
|
||||
|
||||
Delete never fires on a stray click: the form asks for confirmation and does nothing when it is dismissed. Rename
|
||||
and delete are server actions scoped by owner id, so another user's id in a hidden field deletes nothing.
|
||||
|
||||
Related: [[Analysis row lifecycle]], [[Console runner]], [[SQLite schema]].
|
||||
@@ -0,0 +1,13 @@
|
||||
# Jev goal gate
|
||||
|
||||
`scripts/jev-gate.mjs` asks Jev whether a document actually builds the stated goal, before code is written from it.
|
||||
It asks for a yes/no, a coverage score, coverage confidence, and the biggest gap as a typed choice rather than an
|
||||
unfalsifiable "is something missing".
|
||||
|
||||
Both the plan and the spec pass: verdict YES, goal match 0.90, coverage 3.71 of 4, biggest gap `nothing`.
|
||||
|
||||
Two earlier failures were measurement artefacts, not model verdicts: sending a raw 60k truncation hid the tail of
|
||||
the plan, and replacing the text with a heading-and-checkbox skeleton stripped too much prose. The gate now strips
|
||||
fenced code blocks, keeps prose, and sends up to 90k.
|
||||
|
||||
Related: [[Jev scoring]], [[Smoke script]].
|
||||
@@ -0,0 +1,18 @@
|
||||
# Jev scoring
|
||||
|
||||
Jev is a separate typed judgment model, not a second opinion in prose. `lib/jev.ts` asks it four questions per
|
||||
option, gets a value plus a confidence for each, and combines them through `WEIGHTS` into one composite score.
|
||||
|
||||
Three flags come out of the scores:
|
||||
|
||||
- `verify_semantics` when semantic safety falls below 0.45, or below 0.6 for an option whose `option_type` is
|
||||
`rewrite`. Live Jev scores an index create around 0.57 ("probably equivalent, no stated proof"), so keying the
|
||||
soft floor on the option type rather than on "has SQL" is what keeps the warning useful.
|
||||
- `low_confidence` when any dimension's confidence drops under the floor.
|
||||
- `change_control` when an action is marked as needing change control.
|
||||
|
||||
When Jev is unreachable or abstains, `jevFallback` returns the composite order and marks the source
|
||||
`jev_unavailable`, so the report prints "composite order — Jev did not decide" rather than implying Jev chose.
|
||||
A real run on the 2.7 MB production plan hit exactly this path and stayed honest.
|
||||
|
||||
Related: [[Analyst model]], [[Option type and sql to run]], [[Jev goal gate]].
|
||||
@@ -0,0 +1,13 @@
|
||||
# Lazy database open
|
||||
|
||||
Opening SQLite at module import time broke `npm run build` with "database is locked": Next collects page data in
|
||||
parallel workers, each importing the module and running DDL at once.
|
||||
|
||||
The fix is a bound Proxy. Nothing opens until a property is first touched; then `DatabaseSync` is constructed,
|
||||
`journal_mode = WAL` and `busy_timeout = 5000` are set, and the schema DDL runs once. The Proxy methods are bound to
|
||||
the real instance — an unbound Proxy made every call fail with "TypeError: Illegal invocation".
|
||||
|
||||
A second trap: SQLite rows arrive as null-prototype objects, which Next refuses to serialize from a server
|
||||
component to a client component. Rows are normalised on read.
|
||||
|
||||
Related: [[SQLite schema]], [[Running locally]].
|
||||
@@ -0,0 +1,13 @@
|
||||
# Decision: max_nested_tags for real plans
|
||||
|
||||
A 2.7 MB production plan refused to import with "Maximum nested tags exceeded", which read like a corrupt export.
|
||||
The plan was fine: fast-xml-parser's default `maxNestedTags` is 128 and that plan nested **2729 tags deep**. Spaghettified
|
||||
nested-loop plans do that.
|
||||
|
||||
The cap is now 20 000, bounded in practice by the 12 MB input limit. The same plan digests in 56 ms.
|
||||
|
||||
Two more real-plan defects were fixed in the same pass, both invisible to the synthetic fixture: table names are
|
||||
read from the `<Object>` child rather than the `<IndexScan>` element itself, and numeric character references such
|
||||
as `
` (90 of them in that plan's statement text) are decoded before the text reaches a model.
|
||||
|
||||
Related: [[Table names live on Object]], [[ShowPlan digest]].
|
||||
@@ -0,0 +1,16 @@
|
||||
# OpenUI report language
|
||||
|
||||
The report is elements, not markdown. `lib/report-spec.ts` defines the component vocabulary and the library prompt
|
||||
with `@openuidev/lang-core`; `lib/report-ui.ts` attaches the React renderers.
|
||||
|
||||
The split exists because `@openuidev/react-lang` calls `React.createContext` at module scope, which breaks the API
|
||||
route's server-side collection. A route that needs the prompt must not import a React-backed module.
|
||||
|
||||
Components: Headline, MetricGrid, Finding, OptionTable, ActionSteps, SqlBlock, ScoreBar, Callout, VerdictBlock.
|
||||
The report renders while `oul` is still arriving, so the headline appears before the option table.
|
||||
|
||||
Every numeric the model supplies passes through `num` and `conf` guards. Before that, a `null` confidence inside
|
||||
ScoreBar threw and OpenUI dropped the whole block — visible live as "runtime/render-error: Component ScoreBar
|
||||
render failed".
|
||||
|
||||
Related: [[SSE stream]], [[Analyst model]], [[Console runner]].
|
||||
@@ -0,0 +1,15 @@
|
||||
# Decision: option_type and sql_to_run
|
||||
|
||||
The candidate schema used to carry `rewrite_sql`. The name was load-bearing — it is sent to the model — and it
|
||||
said "query rewrite only". Result: an index recommendation had no `CREATE INDEX` anywhere in the pipeline, and the
|
||||
report rendered zero SQL blocks for a plan whose headline finding was missing indexes.
|
||||
|
||||
It became `option_type` (index | statistics | rewrite | schema | app | ops) plus `sql_to_run`, described as the exact
|
||||
T-SQL the user must execute, with the instruction that an index or statistics option with null SQL is a wrong
|
||||
answer.
|
||||
|
||||
Two consequences were deliberate. The report prompt makes a SqlBlock per non-null `sql_to_run` a hard requirement.
|
||||
And the `verify_semantics` flag in [[Jev scoring]] now keys on `option_type === "rewrite"`, because keying it on
|
||||
"has SQL" would flag every index option for carrying DDL — the exact noise an earlier ruling removed.
|
||||
|
||||
Related: [[Placeholder SQL is dropped]], [[Analyst model]], [[Jev scoring]].
|
||||
@@ -0,0 +1,12 @@
|
||||
# Decision: output token ceiling
|
||||
|
||||
Once every option had to carry runnable T-SQL, the candidate JSON outgrew the old 2 048-token budget. The user
|
||||
waited 38 seconds and got "The analyst model returned malformed JSON" — a truncated response has no closing brace.
|
||||
|
||||
Both calls default to 4 096 now, and the failure message names the character count and tells the user which extra
|
||||
parameter raises the ceiling, because `cfg.extra` is spread after `max_tokens`.
|
||||
|
||||
The general rule recorded here: when a prompt asks a model for more content, check the budget before blaming the
|
||||
model's judgement.
|
||||
|
||||
Related: [[Analyst model]], [[Placeholder SQL is dropped]].
|
||||
@@ -0,0 +1,14 @@
|
||||
# Decision: placeholder SQL is dropped, not rendered
|
||||
|
||||
With the new `sql_to_run` requirement, a report on a 100 KB statement came back containing
|
||||
`SELECT ... FROM ... OPTION (MAXDOP 1);`. A block nobody can paste is worse than no block: it reads as an
|
||||
instruction and fails on run.
|
||||
|
||||
`dropPlaceholderSql` nulls ellipsis SQL after schema validation, so the option simply gets no SqlBlock. The
|
||||
heuristic is marked as a deliberate shortcut: `...` is not valid T-SQL, so a literal ellipsis inside a string
|
||||
literal would also be dropped, which costs a block and never invents one.
|
||||
|
||||
The model is also told upstream: when `digestForModel` sets `sql_truncated$, [[ShowPlan digest]] has already
|
||||
warning-ed the analyst out of offering a rewrite for a statement it never received.
|
||||
|
||||
Related: [[Option type and sql to run]], [[Output token ceiling]].
|
||||
@@ -0,0 +1,15 @@
|
||||
# Plan file encoding
|
||||
|
||||
SSMS saves `.sqlplan` as UTF-16LE with a byte-order mark. `File.text()` assumes UTF-8, which turns such a plan into
|
||||
NUL-filled junk and makes the parser complain about the user's export. `lib/plan-file.ts` decodes by BOM first
|
||||
(UTF-32LE/BE, UTF-16LE/BE, UTF-8), then falls back to NUL density for BOM-less UTF-16: in ASCII XML roughly every
|
||||
other byte is `0x00`.
|
||||
|
||||
The browser reads the dropped file into bytes and calls `decodePlan`; the server never sniffs encoding. That keeps
|
||||
[[ShowPlan digest]] independent of how the text arrived.
|
||||
|
||||
Four encodings are pinned by tests: UTF-8, UTF-8 with BOM, UTF-16LE with BOM, UTF-16BE with BOM, and both UTF-16
|
||||
variants with no BOM. A browser test uploads a generated UTF-16LE file and asserts the textarea holds no NUL
|
||||
characters.
|
||||
|
||||
Related: [[ShowPlan digest]], [[Unit suites]], [[Playwright projects]].
|
||||
@@ -0,0 +1,15 @@
|
||||
# Playwright projects
|
||||
|
||||
`npm run e2e` builds first (`pree2e` hook) and then drives the **production** server, not `next dev`. That choice
|
||||
caught a real bug: cookie flags and hydration differ between the two, and a dev-only suite never noticed a
|
||||
`Secure` cookie that Safari refuses on plain http.
|
||||
|
||||
Projects: `setup` signs up the QA account and configures both models through the real UI, writing a storage state;
|
||||
`chromium` runs everything except the session spec; `webkit` runs only the session spec, because it is the engine
|
||||
that rejects a Secure cookie over `http://localhost`; `mobile` runs only the responsive checks, so a 30-second model
|
||||
run is never paid for twice.
|
||||
|
||||
One worker, 180-second test timeout, and a single run takes about 55 seconds for 23 tests including a real
|
||||
analyst and real Jev call.
|
||||
|
||||
Related: [[WebKit in QA]], [[Unit suites]], [[Analyst model]].
|
||||
@@ -0,0 +1,16 @@
|
||||
# Running locally
|
||||
|
||||
`npm install && npm run build && npm start` serves the production build on port 3000. `npm run dev` works too, but
|
||||
cookie flags and hydration differ from production, which is why the end-to-end suite exists.
|
||||
|
||||
Two environment values matter: `APP_SECRET` (at least 16 characters, seals stored keys — changing it makes them
|
||||
unreadable and the app asks for them again) and `DATA_DIR` (where `qai.db` lives, default `./data`).
|
||||
`.env.example` documents both plus `COOKIE_SECURE`.
|
||||
|
||||
Model credentials are not environment configuration. They belong to each user and are pasted into the settings
|
||||
screen.
|
||||
|
||||
One operational trap worth knowing: deleting the data directory while a server is running leaves that server
|
||||
writing into an unlinked inode. The app keeps answering and the database no longer exists anywhere.
|
||||
|
||||
Related: [[Environment variables]], [[Container plan]], [[Gitea remote]].
|
||||
@@ -0,0 +1,16 @@
|
||||
# SQLite schema
|
||||
|
||||
`lib/db.ts` uses `node:sqlite` — no ORM, no driver, no native build. Four tables:
|
||||
|
||||
- `users` — id, unique email, `salt:hash` scrypt password, created_at
|
||||
- `sessions` — hashed token, user id, expiry
|
||||
- `settings` — one row per user: analyst base URL, model, sealed key, extra JSON, Jev key, Jev model, email, onboarded
|
||||
- `analyses` — id, user id, title, raw XML, oul, verdict JSON, status, error, timestamps
|
||||
|
||||
`patchAnalysis` only accepts keys from a `PATCHABLE` whitelist, because building `SET` clauses straight from object
|
||||
keys is how an object key becomes SQL.
|
||||
|
||||
The `saveSettings` upsert uses `COALESCE(NULLIF(excluded.analyst_key, ''), settings.analyst_key)` so that a form
|
||||
submitting a blank secret keeps the stored one. Blank means "unchanged", never "clear".
|
||||
|
||||
Related: [[Lazy database open]], [[Analysis row lifecycle]], [[Credential vault]].
|
||||
@@ -0,0 +1,13 @@
|
||||
# SSE stream
|
||||
|
||||
One POST to `/api/analyze` returns a server-sent event stream. `lib/stream.ts` frames events on the server and
|
||||
parses them in the browser, and also extracts text deltas from an OpenAI-compatible streaming response.
|
||||
|
||||
Four named stages are reported as they happen: digesting, proposing, judging, writing. The console shows them as
|
||||
`1 of 4 · Reading the plan` style pills. A `done` frame re-enables the Analyse button; an `error` frame names the
|
||||
stage it died in, so a rejected plan reads "digesting: ..." rather than failing silently.
|
||||
|
||||
If the browser disconnects, in-flight model fetches are aborted and the row is stored as `abandoned` or `partial`
|
||||
instead of piling ghost runs onto the analyst endpoint.
|
||||
|
||||
Related: [[Analysis row lifecycle]], [[Console runner]], [[OpenUI report language]].
|
||||
@@ -0,0 +1,12 @@
|
||||
# Session cookie rules
|
||||
|
||||
The session cookie holds a 32-byte random token; only its hash is stored, and the expiry is enforced in SQL.
|
||||
`httpOnly` and `sameSite: lax` always.
|
||||
|
||||
`Secure` is set from the request, not the build: only when `X-Forwarded-Proto` reports `https`, or when
|
||||
`COOKIE_SECURE=1` is set for a direct TLS deploy. The previous rule (`secure: NODE_ENV === "production"`) meant a
|
||||
production build on plain `http://localhost` asked browsers for a Secure cookie. Chromium allows that on localhost;
|
||||
Safari refuses to store it at all, so every page after signup looked signed-out and the wizard appeared to sign
|
||||
users out. A Chromium-only suite could never observe it.
|
||||
|
||||
Related: [[WebKit in QA]], [[First-run gate]], [[Running locally]].
|
||||
@@ -0,0 +1,17 @@
|
||||
# Settings form
|
||||
|
||||
components/settings-form.tsx is the credential screen, used twice: as step 2 of the first-run guide and on
|
||||
/settings. It renders analyst base URL, analyst key, analyst model, extra JSON parameters, Jev key and Jev model,
|
||||
plus the account email change field.
|
||||
|
||||
It renders values from settingsView only, so a stored secret is never echoed back into the DOM - the fields show
|
||||
bullet-masked values, and a blank submission means "keep what is stored", enforced again in the upsert (see
|
||||
[[SQLite schema]]).
|
||||
|
||||
The form carries a hidden after field. When the submission comes from the wizard, the action returns the user to
|
||||
/setup?step=3 so the readiness screen and its "Saved." message actually render; without it a configured user was
|
||||
forwarded straight to the console and Save appeared to do nothing.
|
||||
|
||||
Every field has a real label element, asserted by the responsive Playwright checks.
|
||||
|
||||
Related: [[First-run gate]], [[Credential vault]], [[Email collision check]].
|
||||
@@ -0,0 +1,19 @@
|
||||
# ShowPlan digest
|
||||
|
||||
`lib/digest.ts` converts a SQL Server ShowPlan XML document into a bounded JSON summary before any model is
|
||||
consulted. The point is that the models judge facts, not guess them: [[Plan file encoding]] hands it clean text,
|
||||
then it walks the parsed document for operators, tables, missing-index groups, waits and warnings.
|
||||
|
||||
Each operator keeps `NodeId`, the logical or physical op name, `EstimateIO`, `EstimateCPU`,
|
||||
`EstimatedTotalSubtreeCost`, estimated rows, and actual rows where the plan carries
|
||||
`RunTimeCountersPerThread`. Attribute spellings differ across SQL Server builds, so every cost field falls
|
||||
through the known variants rather than trusting one.
|
||||
|
||||
`digestForModel` is the budget gate: 4 000 characters of statement text, 20 tables, 12 operators, 5 missing
|
||||
index groups, 20 warnings. It also sets `sql_truncated` when the statement was cut, which stops [[Analyst model]]
|
||||
from offering a rewrite of a query it never received.
|
||||
|
||||
Measured on a 2 694 090-byte production plan (UTF-16LE, 1 352 044 characters, 2 729 tags deep): 2 ms to decode,
|
||||
56 ms to digest, 37 distinct tables, and `StatementOptmEarlyAbortReason = TimeOut` surfaced as the headline finding.
|
||||
|
||||
Related: [[Jev scoring]], [[Max nested tags for real plans]], [[Table names live on Object]].
|
||||
@@ -0,0 +1,10 @@
|
||||
# Smoke script
|
||||
|
||||
`bash scripts/smoke.sh` checks a running instance with curl and nothing else: healthz answers, the login and signup
|
||||
pages render, a signed-out visit to `/app` redirects, and `/api/analyze` refuses a request without a session.
|
||||
Five checks, exits non-zero on the first failure.
|
||||
|
||||
It works against any `BASE_URL`, and `--docker` is reserved for running it against the container once a Docker host
|
||||
is available.
|
||||
|
||||
Related: [[Jev goal gate]], [[Running locally]], [[Container plan]].
|
||||
@@ -0,0 +1,13 @@
|
||||
# Decision: table names come from the Object child
|
||||
|
||||
`digestPlan` built its table list by reading `Database`/`Schema`/`Table` attributes off `<IndexScan>` and
|
||||
`<TableScan>` elements. Real ShowPlan puts those attributes on the `<Object>` child. The production plan with 51
|
||||
objects therefore digested to **zero tables**, and the analyst model was asked to fix a query whose tables it could
|
||||
not see.
|
||||
|
||||
The fix reaches into the child: the scan's own attributes, else `<Object>`. The third fallback level that looked
|
||||
plausible in theory turned out to be dead code and was deleted rather than kept as insurance.
|
||||
|
||||
Pinned by a test using the real element shape, and visible in the measured difference: 0 tables before, 37 after.
|
||||
|
||||
Related: [[Max nested tags for real plans]], [[ShowPlan digest]].
|
||||
@@ -0,0 +1,15 @@
|
||||
# Unit suites
|
||||
|
||||
`npm test` runs `node --no-warnings --test tests/*.test.ts` — 43 tests, no test framework installed, nothing
|
||||
external. The analyst and Jev are exercised through injected `fetch`, so no key and no network is needed.
|
||||
|
||||
Constraints that shape the code, not the tests: modules loaded by bare `node --test` cannot resolve the `@`
|
||||
import alias, so test-imported modules import each other by relative `./x.ts` path (which is why
|
||||
`allowImportingTsExtensions` is on), and `next/headers` plus `next/navigation` are imported lazily inside
|
||||
`lib/auth.ts`.
|
||||
|
||||
The digest suite is the one that grew teeth: it now asserts nesting depth beyond the parser default, table names
|
||||
taken from `<Object>` children, character-reference decoding, six plan-file encodings, and that a truncated plan's
|
||||
error names a reason.
|
||||
|
||||
Related: [[Playwright projects]], [[Smoke script]], [[Plan file encoding]].
|
||||
@@ -0,0 +1,11 @@
|
||||
# WebKit in QA
|
||||
|
||||
A `webkit` project exists for one reason: Chromium treats `http://localhost` as a secure context and will store a
|
||||
`Secure` cookie there, so a chromium-only suite passes while Safari users are bounced to the login page after
|
||||
signup. That is precisely the bug reported after delivery.
|
||||
|
||||
The spec signs up its own account, walks the wizard's Next links, and asserts the final destination is the
|
||||
onboarding gate at `/setup?step=2` — proof of a live session — because a lost session would land on `/login`.
|
||||
A second spec reloads a deep page and checks the session survives.
|
||||
|
||||
Related: [[Session cookie rules]], [[Playwright projects]], [[First-run gate]].
|
||||
@@ -0,0 +1,15 @@
|
||||
# Wiki conventions
|
||||
|
||||
kb/AGENTS.md is the schema layer of this wiki: the layout, the category list and the page rules. It exists so a new
|
||||
page can be added without inventing structure each time.
|
||||
|
||||
The rules that matter: one concept per file, the file name is the link target, every page cross-references 3 to 6
|
||||
others and every link must resolve, measured numbers are stated with the artifact they came from, and a decision
|
||||
page names the old behaviour, its cost, the replacement, and what it broke.
|
||||
|
||||
The wiki is deliberately separate from the repository's own documentation. raw/ holds copies of README, the spec,
|
||||
the implementation plan, the design contract and the decisions ledger - copied, never edited in place - so the pages
|
||||
above have citable sources. Keeping the wiki in its own root also keeps its generated graph in kb/.ua/ instead of
|
||||
overwriting the code graph at the repository root.
|
||||
|
||||
Related: [[Gitea remote]], [[Design contract]], [[Running locally]].
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
# qAI knowledge base — index
|
||||
|
||||
Wiki pages about SQL Server qAI: 34 pages across 7 categories.
|
||||
Raw sources are in `raw/` (copied, never edited here). Conventions live in `AGENTS.md`. History lives in `log.md`.
|
||||
|
||||
## Pipeline
|
||||
|
||||
- [[ShowPlan digest]] — lib/digest.ts converts a SQL Server ShowPlan XML document into a bounded JSON summary before any model is
|
||||
consulted.
|
||||
- [[Plan file encoding]] — SSMS saves .sqlplan as UTF-16LE with a byte-order mark.
|
||||
- [[Analyst model]] — lib/analyst.ts talks to any OpenAI-compatible endpoint the user pastes in settings.
|
||||
- [[Jev scoring]] — Jev is a separate typed judgment model, not a second opinion in prose.
|
||||
- [[OpenUI report language]] — The report is elements, not markdown.
|
||||
- [[SSE stream]] — One POST to /api/analyze returns a server-sent event stream.
|
||||
|
||||
## Data and storage
|
||||
|
||||
- [[SQLite schema]] — lib/db.ts uses node:sqlite — no ORM, no driver, no native build.
|
||||
- [[Lazy database open]] — Opening SQLite at module import time broke npm run build with "database is locked": Next collects page data in
|
||||
parallel
|
||||
- [[Analysis row lifecycle]] — A row is created before the plan is validated, so every attempt is visible in the rail.
|
||||
|
||||
## Security and credentials
|
||||
|
||||
- [[Credential vault]] — Model keys are pasted by the user into the settings screen.
|
||||
- [[Session cookie rules]] — The session cookie holds a 32-byte random token;
|
||||
- [[First-run gate]] — The console requires two things before it renders: an analyst base URL with model and key, and a Jev key.
|
||||
|
||||
## Interface
|
||||
|
||||
- [[Console runner]] — components/runner.tsx is the whole console: file drop with encoding-aware decode, the paste box, stage pills,
|
||||
SSE readin
|
||||
- [[History rail]] — The rail lists the signed-in user's runs newest first, with the settings link pinned at the bottom.
|
||||
- [[Export and print]] — Two export actions, both client-side, no PDF library: a Copy button that pulls the SQL blocks out of the rendered
|
||||
report
|
||||
- [[Design tokens]] — app/tokens.css defines the palette and scale: dark console background, phosphor-green accent reserved for
|
||||
action and sta
|
||||
- [[Settings form]] — components/settings-form.tsx is the credential screen, used twice: as step 2 of the first-run guide and on
|
||||
/settings.
|
||||
- [[Design contract]] — design-system/sql-server-qai/MASTER.md is the contract the UI is built against: dark workbench palette, type
|
||||
scale, spac
|
||||
|
||||
## Verification
|
||||
|
||||
- [[Unit suites]] — npm test runs node --no-warnings --test tests/*.test.ts — 43 tests, no test framework installed, nothing
|
||||
external.
|
||||
- [[Playwright projects]] — npm run e2e builds first (pree2e hook) and then drives the **production** server, not next dev.
|
||||
- [[WebKit in QA]] — A webkit project exists for one reason: Chromium treats http://localhost as a secure context and will store a
|
||||
Secure coo
|
||||
- [[Smoke script]] — bash scripts/smoke.sh checks a running instance with curl and nothing else: healthz answers, the login and signup
|
||||
pages
|
||||
- [[Jev goal gate]] — scripts/jev-gate.mjs asks Jev whether a document actually builds the stated goal, before code is written from it.
|
||||
It ask
|
||||
|
||||
## Operations
|
||||
|
||||
- [[Running locally]] — npm install && npm run build && npm start serves the production build on port 3000.
|
||||
- [[Container plan]] — Dockerfile is a multi-stage Node 25 build — Node 25 because node:sqlite is what the data layer uses — and
|
||||
docker-compose
|
||||
- [[Environment variables]] — Deliberately short.
|
||||
- [[Gitea remote]] — The repository is pushed to a self-hosted Gitea at http://192.168.88.39:3000/ahmed/sql-server-query-ai.git, branch
|
||||
main.
|
||||
- [[Wiki conventions]] — kb/AGENTS.md is the schema layer of this wiki: the layout, the category list and the page rules.
|
||||
|
||||
## Decisions
|
||||
|
||||
- [[Option type and sql to run]] — The candidate schema used to carry rewrite_sql.
|
||||
- [[Placeholder SQL is dropped]] — With the new sql_to_run requirement, a report on a 100 KB statement came back containing
|
||||
SELECT ...
|
||||
- [[Max nested tags for real plans]] — A 2.7 MB production plan refused to import with "Maximum nested tags exceeded", which read like a corrupt export.
|
||||
The pl
|
||||
- [[Table names live on Object]] — digestPlan built its table list by reading Database/Schema/Table attributes off <IndexScan> and
|
||||
<TableScan> elements.
|
||||
- [[Output token ceiling]] — Once every option had to carry runnable T-SQL, the candidate JSON outgrew the old 2 048-token budget.
|
||||
- [[Email collision check]] — Changing your email in settings wrote straight to a UNIQUE column.
|
||||
|
||||
## Sources
|
||||
|
||||
Immutable inputs, copied verbatim into `raw/`. These are the artifacts every measured number above came from.
|
||||
|
||||
- `raw/README.md` — the repository's own front page.
|
||||
- `raw/2026-09-21-sql-server-qai-spec.md` — approved specification.
|
||||
- `raw/2026-09-21-sql-server-qai-plan.md` — the 13-task implementation plan the code was built from.
|
||||
- `raw/design-master.md` — the design contract.
|
||||
- `raw/sdd-progress.md` — the decisions ledger every page in the Decisions category is drawn from.
|
||||
@@ -0,0 +1,7 @@
|
||||
# Operation log
|
||||
|
||||
## 2026-09-22 — initial wiki
|
||||
- Copied 5 repository sources into `raw/` (README, spec, 13-task plan, design master, SDD ledger).
|
||||
- Wrote 34 pages from the documentation, the in-code comments and the decisions ledger, grouped into 7 categories.
|
||||
- Every page cross-references 3 to 6 others; measured numbers come from the artifacts named in `raw/`.
|
||||
- Deliberately kept out: anything from `plan-cxpaket.sqlplan` beyond the aggregate measurements it produced.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
# SQL Server qAI — Spec
|
||||
|
||||
**Date:** 2026-09-21
|
||||
**Owner:** ahmedmohammed
|
||||
**Status:** agreed in chat, this file is the spec of record
|
||||
|
||||
## Goal (verbatim from the owner)
|
||||
|
||||
> portal that take xml of sql server query plan and use power of generative ai to
|
||||
> analysis provide multiple action steps, multiple query rewrite if possible,
|
||||
> Jev then rank and decide.
|
||||
|
||||
## What it is
|
||||
|
||||
A containerised web tool. A DBA or consultant logs in, pastes a SQL Server
|
||||
execution plan (Showplan XML, or a `.sqlplan` file), and gets back:
|
||||
|
||||
1. What the plan says is wrong (bottleneck, missing indexes, spills, bad row guesses).
|
||||
2. **Multiple** candidate action plans — several different remediations, not one.
|
||||
3. **Multiple** candidate SQL rewrites where a rewrite is possible.
|
||||
4. A **Jev**-ranked verdict: which candidate to run first, with typed probabilities
|
||||
and confidence, and why.
|
||||
5. One final "best fast path": the recommended SQL and the steps to apply it.
|
||||
6. The result rendered as **structured UI elements** (OpenUI Lang → React), not a
|
||||
wall of markdown. Export to PDF, copy to clipboard.
|
||||
|
||||
The demo value is Jev as the decision-maker: the generative model proposes,
|
||||
**Jev disposes**. Every ranking number on screen is a typed judgment from
|
||||
`POST /v1/systemone`, not a string the LLM wrote.
|
||||
|
||||
## Product surface
|
||||
|
||||
| Screen | Job |
|
||||
| --- | --- |
|
||||
| `/signup`, `/login` | Email + password. Nothing else. |
|
||||
| `/setup` | First-run guide: 3 steps — analyst model, Jev, done. |
|
||||
| `/app` | Left rail = previous analyses (rename, delete). Main = paste plan → run → live report. |
|
||||
| `/settings` | Update analyst model (base URL, key, model, extra params), Jev (key, model), email/password, history. |
|
||||
|
||||
Report actions: **Export to PDF**, **Copy**. History actions: **rename**, **delete**.
|
||||
|
||||
### Getting the input, and the two keys
|
||||
|
||||
The portal cannot reach the SQL Server. The user brings the plan XML, and the app must say so in the run screen:
|
||||
SSMS → **Ctrl+M** (include actual execution plan) → run the query → save the Execution Plan tab as `.sqlplan`, or
|
||||
copy the Showplan XML cell; without SSMS, prefix the batch with `SET STATISTICS XML ON;` and copy the XML result
|
||||
cell. A `.sqlplan` file can be dropped in (SSMS writes UTF-16 with a BOM; decode both) or pasted.
|
||||
|
||||
Both model credentials are typed by the logged-in user in `/setup` or `/settings` — never server config, so every
|
||||
user brings their own analyst endpoint and their own TypeSafe/Jev key (`console.typesafe.ai/keys`). Stored keys come
|
||||
back masked, and a blank field means keep the stored one. Nothing leaves the container except the digest, and only
|
||||
to the endpoints that user configured.
|
||||
|
||||
## Actors (two models, strict roles)
|
||||
|
||||
- **Analyst** — any OpenAI-compatible endpoint the user configures (`base_url`,
|
||||
`key`, `model`, extra params). Generates: candidate action plans, candidate SQL
|
||||
rewrites, and the final OpenUI Lang report. Text in, text out.
|
||||
- **Jev** — TypeSafe System One (`@typesafe-ai/sdk`, model `jev-latest`). Never
|
||||
generates prose. Answers typed questions (`score`, `choice`, `noul`) about each
|
||||
candidate and returns probabilities + confidence. Code combines them.
|
||||
|
||||
## Requirements
|
||||
|
||||
1. Auth: signup, login, logout, session survives reload. Per-user data isolation.
|
||||
2. Credentials at rest are encrypted (AES-256-GCM, `APP_SECRET` env). Never echoed
|
||||
to the browser — masked (`••••1234`).
|
||||
3. Plan input: paste XML, or drop/select a `.sqlplan`/`.xml` file. Clear inline
|
||||
error if it is not a ShowPlanXML document.
|
||||
4. Deterministic digest first, model second: parse the XML in code (operators,
|
||||
costs, missing indexes, warnings, waits, spills, estimate vs actual). The model
|
||||
sees the digest, never 40 MB of XML.
|
||||
5. 2–4 candidates per run. Each candidate: diagnosis, ordered actions with effort
|
||||
and change-control flag, optional rewrite SQL, expected effect.
|
||||
6. Jev judges every candidate in parallel on 4 dimensions, then a second call picks
|
||||
`first_to_run` across all candidates. Composite weights live in code, visible in UI.
|
||||
7. Safety gate: a candidate Jev rates as semantically unsafe is never presented as
|
||||
the recommended path without a visible "verify results before deploying" flag.
|
||||
8. History: every run persisted (input digest, candidates, Jev verdict, rendered
|
||||
report). Reopening a history item replays the stored report — zero model calls.
|
||||
9. Streaming: stage indicator while digesting/proposing/judging, then the report
|
||||
renders token-by-token. No spinner-for-30-seconds.
|
||||
10. Single container: `docker compose up` → one image, one `/data` volume, no
|
||||
external DB, no queue, no Redis.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No live connection to SQL Server. No plan-from-cache lookup. No automatic index
|
||||
creation. No multi-user teams, roles, or sharing. No billing. No mobile app.
|
||||
No markdown editor. No prompt marketplace. No agentic loop with tool calls.
|
||||
|
||||
## Success looks like
|
||||
|
||||
Paste a real slow-query plan → under 60 s → a ranked set of remediations where the
|
||||
top pick is defensible because Jev's probabilities are on screen, and the winning
|
||||
SQL is one click from the clipboard.
|
||||
|
||||
## Acceptance
|
||||
|
||||
What has to be true before this counts as done, and how each part is proven:
|
||||
|
||||
- **Offline unit suites** (`npm test`, no keys, no network): a real ShowPlan digests to the right numbers; input
|
||||
that is not a plan is refused with a named reason; analyst output is schema-checked and 2–4 options are enforced;
|
||||
Jev's scores rank, an unknown Jev label cannot lead, and a silent Jev degrades to a labelled composite order;
|
||||
credentials seal/open/mask and a blank field never wipes a stored key.
|
||||
- **One live run** against the user's real analyst endpoint and a real Jev key: every SSE stage fires, several
|
||||
candidates come back, Jev returns a verdict with confidence, and OpenUI Lang arrives in deltas.
|
||||
- **Browser QA** (Chromium): signup → guide → settings → run → rendered report; Copy puts SQL on the clipboard;
|
||||
print emulation flips to paper and hides the rail and buttons; rename sticks and delete confirms first; no
|
||||
horizontal scroll from 320 px up.
|
||||
- **A smoke script** (`scripts/smoke.sh`) that a container or a plain `npm start` both pass: health, auth pages,
|
||||
signed-out redirect, and `/api/analyze` refusing an anonymous caller.
|
||||
- **A goal gate**: Jev scores the written plan against the goal in this document and answers the named-gap question
|
||||
with `nothing` before implementation starts.
|
||||
@@ -0,0 +1,112 @@
|
||||
# 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`. |
|
||||
| `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.
|
||||
|
||||
- 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 # 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 — `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.
|
||||
|
||||
## 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,233 @@
|
||||
# Design System Master File
|
||||
|
||||
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
|
||||
> If that file exists, its rules **override** this Master file.
|
||||
> If not, strictly follow the rules below.
|
||||
|
||||
---
|
||||
|
||||
**Project:** SQL Server qAI
|
||||
**Generated:** 2026-09-21 12:56:55
|
||||
**Category:** Developer Tool / IDE
|
||||
**Design Dials:** Variance 4/10 (Balanced / Modern) | Motion 3/10 (Subtle) | Density 8/10 (Dense / Dashboard)
|
||||
|
||||
---
|
||||
|
||||
## Global Rules
|
||||
|
||||
### Color Palette
|
||||
|
||||
| Role | Hex | CSS Variable |
|
||||
|------|-----|--------------|
|
||||
| Primary | `#1E293B` | `--color-primary` |
|
||||
| On Primary | `#FFFFFF` | `--color-on-primary` |
|
||||
| Secondary | `#334155` | `--color-secondary` |
|
||||
| On Secondary | `#FFFFFF` | `--color-on-secondary` |
|
||||
| Accent/CTA | `#22C55E` | `--color-accent` |
|
||||
| On Accent/CTA | `#0F172A` | `--color-on-accent` |
|
||||
| Background | `#0F172A` | `--color-background` |
|
||||
| Foreground | `#F8FAFC` | `--color-foreground` |
|
||||
| Card | `#1B2336` | `--color-card` |
|
||||
| Card Foreground | `#F8FAFC` | `--color-card-foreground` |
|
||||
| Muted | `#272F42` | `--color-muted` |
|
||||
| Muted Foreground | `#94A3B8` | `--color-muted-foreground` |
|
||||
| Border | `#475569` | `--color-border` |
|
||||
| Destructive | `#EF4444` | `--color-destructive` |
|
||||
| On Destructive | `#000000` | `--color-on-destructive` |
|
||||
| Ring | `#FFFFFF` | `--color-ring` |
|
||||
|
||||
**Color Notes:** Code dark + run green
|
||||
|
||||
### Typography
|
||||
|
||||
- **Heading Font:** JetBrains Mono
|
||||
- **Body Font:** IBM Plex Sans
|
||||
- **Mood:** code, developer, technical, precise, functional, hacker
|
||||
- **Google Fonts:** [JetBrains Mono + IBM Plex Sans](https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap)
|
||||
|
||||
**CSS Import:**
|
||||
```css
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
||||
```
|
||||
|
||||
### Spacing Variables
|
||||
|
||||
*Density: 8/10 — Dense / Dashboard*
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--space-xs` | `2px` / `0.125rem` | Tight gaps |
|
||||
| `--space-sm` | `4px` / `0.25rem` | Icon gaps, inline spacing |
|
||||
| `--space-md` | `8px` / `0.5rem` | Standard padding |
|
||||
| `--space-lg` | `12px` / `0.75rem` | Section padding |
|
||||
| `--space-xl` | `16px` / `1rem` | Large gaps |
|
||||
| `--space-2xl` | `24px` / `1.5rem` | Section margins |
|
||||
| `--space-3xl` | `32px` / `2rem` | Hero padding |
|
||||
|
||||
### Shadow Depths
|
||||
|
||||
| Level | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
|
||||
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
|
||||
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
|
||||
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
|
||||
|
||||
---
|
||||
|
||||
## Component Specs
|
||||
|
||||
### Buttons
|
||||
|
||||
```css
|
||||
/* Primary Button */
|
||||
.btn-primary {
|
||||
background: #22C55E;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Secondary Button */
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: #1E293B;
|
||||
border: 2px solid #1E293B;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
```
|
||||
|
||||
### Cards
|
||||
|
||||
```css
|
||||
.card {
|
||||
background: #0F172A;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
```
|
||||
|
||||
### Inputs
|
||||
|
||||
```css
|
||||
.input {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 200ms ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #1E293B;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px #1E293B20;
|
||||
}
|
||||
```
|
||||
|
||||
### Modals
|
||||
|
||||
```css
|
||||
.modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
**Style:** Dark Mode (OLED)
|
||||
|
||||
**Keywords:** Dark theme, low light, high contrast, deep black, midnight blue, eye-friendly, OLED, night mode, power efficient
|
||||
|
||||
**Best For:** Night-mode apps, coding platforms, entertainment, eye-strain prevention, OLED devices, low-light
|
||||
|
||||
**Key Effects:** Minimal glow (text-shadow: 0 0 10px), dark-to-light transitions, low white emission, high readability, visible focus
|
||||
|
||||
### Page Pattern
|
||||
|
||||
**Pattern Name:** FAQ/Documentation Landing
|
||||
|
||||
- **Conversion Strategy:** Reduce support tickets. Track search analytics. Show related articles. Contact escalation path.
|
||||
- **CTA Placement:** Search bar prominent + Contact CTA for unresolved questions
|
||||
- **Section Order:** Hero with search bar > Popular categories > FAQ accordion > Contact/support CTA
|
||||
|
||||
---
|
||||
|
||||
## Motion
|
||||
|
||||
**Scroll Reveal** (Subtle) — Trigger: scroll (viewport enter) | Duration: 300-400ms | Easing: `power1.out`
|
||||
|
||||
```js
|
||||
gsap.from(el, { opacity: 0, y: 12, duration: 0.35, ease: 'power1.out', scrollTrigger: { trigger: el, start: 'top 90%', toggleActions: 'play none none reverse' } });
|
||||
```
|
||||
|
||||
**Framework notes:** Requires the ScrollTrigger plugin registered once via gsap.registerPlugin(ScrollTrigger); Use matchMedia('(prefers-reduced-motion: reduce)') to skip non-essential motion and render the final state immediately
|
||||
|
||||
- ✅ Keep the y offset small (8-16px) so it reads as a fade, not a slide
|
||||
- ❌ Don't reveal below-the-fold content needed for SEO/crawlers as invisible-by-default without a no-JS fallback
|
||||
- ⚡ toggleActions 'play none none reverse' avoids re-triggering on every scroll direction change
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns (Do NOT Use)
|
||||
|
||||
- ❌ Light mode default
|
||||
- ❌ Slow performance
|
||||
|
||||
### Additional Forbidden Patterns
|
||||
|
||||
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
|
||||
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
|
||||
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
|
||||
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
|
||||
- ❌ **Instant state changes** — Always use transitions (150-300ms)
|
||||
- ❌ **Invisible focus states** — Focus states must be visible for a11y
|
||||
|
||||
---
|
||||
|
||||
## Pre-Delivery Checklist
|
||||
|
||||
Before delivering any UI code, verify:
|
||||
|
||||
- [ ] No emojis used as icons (use SVG instead)
|
||||
- [ ] All icons from consistent icon set (Heroicons/Lucide)
|
||||
- [ ] `cursor-pointer` on all clickable elements
|
||||
- [ ] Hover states with smooth transitions (150-300ms)
|
||||
- [ ] Light mode: text contrast 4.5:1 minimum
|
||||
- [ ] Focus states visible for keyboard navigation
|
||||
- [ ] `prefers-reduced-motion` respected
|
||||
- [ ] Responsive: 375px, 768px, 1024px, 1440px
|
||||
- [ ] No content hidden behind fixed navbars
|
||||
- [ ] No horizontal scroll on mobile
|
||||
@@ -0,0 +1,84 @@
|
||||
# SDD ledger — plan: docs/superpowers/plans/2026-09-21-sql-server-qai.md
|
||||
|
||||
Ruling: no Docker on this machine (human says limited resources) — write Dockerfile/.dockerignore in Task 12 but never build or run it here; verify with npm run dev + npm start. Ruling: drop Tailwind — the plan styles with tokens.css + semantic classes only, so @tailwindcss/postcss and tailwindcss are uninstalled and postcss.config.mjs deleted. Cost if wrong: we hand-write CSS we would otherwise generate.
|
||||
Task 1: complete (commits 2778ce7..f64dc31, tests: npm run build → ƒ (Dynamic) server-rendered on demand)
|
||||
Task 1: Ruling: no next/font/google — fonts come from the token stack only (JetBrains Mono / IBM Plex Sans if installed, else system mono+sans). Build stays offline-safe and one network dependency lighter — cost if wrong: brand fonts missing on machines without them.
|
||||
Task 1: Ruling: tokens.css lives at app/tokens.css (the @import in globals.css resolves there); the duplicate root copy is deleted — cost if wrong: none.
|
||||
Task 1: Ruling: app/page.tsx is a plain redirect("/login") because lib/auth does not exist yet; Task 3 replaces it with the session-aware redirect its brief omits — cost if wrong: signed-in users hitting / land on /login until Task 3.
|
||||
Task 1: Ruling: deps are next/react/react-dom/zod/@openuidev/react-lang/@typesafe-ai/sdk/fast-xml-parser. lucide-react skipped (no plan code imports it) and Tailwind dropped (see setup ruling) — cost if wrong: add lucide-react the moment an icon is drawn.
|
||||
Task 1: Note: env fix, not a code fix — node_modules/@next/swc-darwin-arm64 came down truncated (33MB of 89MB) so `next build` died with "Turbopack requires native bindings"; reinstalled with npm i --no-save @next/swc-darwin-arm64@16.3.5.
|
||||
Task 2: Ruling: patchAnalysis filters keys against a PATCHABLE whitelist — the brief interpolated object keys straight into the SET clause, and one caller that ever passes a request-shaped object would own a SQL injection. Cost if wrong: a new patchable column needs adding to the set.
|
||||
Task 2: Ruling: test script is `node --no-warnings --test tests/*.test.ts` (the plan's own line). `node --test tests` fails with MODULE_NOT_FOUND on node 25 in this repo — the space in the path makes Node resolve the directory as a module.
|
||||
Task 2: complete (commits f64dc31..692ffa9, tests: npm test → ℹ duration_ms 109.10725)
|
||||
Task 3: Ruling: lib/auth.ts imports ./db.ts (relative, .ts extension) and loads next/headers + next/navigation lazily — `node --test` imports this module, and bare node resolves neither `@/…` nor `next/*`. tsconfig gains allowImportingTsExtensions. Next-only glue (actions, pages, components) keeps @/. Cost if wrong: every test-imported module must keep this discipline.
|
||||
Task 3: Ruling: requireUser declares Promise<{id,email}> with an unreachable throw after redirect(), because redirect() behind a dynamic import is not typed `never`. Cost if wrong: dead line, no behaviour change (redirect throws anyway).
|
||||
Task 3: Ruling: AuthForm's action is (state: FormResult, fd) => Promise<FormResult> and the server actions take `_: unknown` — the brief's `_: void` does not type-check against useActionState, which passes the previous state.
|
||||
Task 3: Ruling: app/app/layout.tsx awaits requireUser now (the guard the brief only asserts as a manual check). Task 11 may write it again; it is idempotent.
|
||||
Task 3: Note: signUp/signIn redirect to /setup, which Task 5 creates. Until then that redirect lands on 404. Verified now: signed-out /app -> 307 /login, /login 200, /signup renders the password help.
|
||||
Task 3: complete (commits 692ffa9..5f8746c, tests: npm test → ℹ duration_ms 190.291833)
|
||||
Task 4: Ruling: db.saveSettings now keeps the stored secret when the incoming one is blank (COALESCE(NULLIF(excluded.x,''), settings.x)) instead of overwriting it. The brief only protected against this in lib/settings.ts; the shared upsert is where a later caller (Task 5's password change) would have wiped both keys with one omitted field. Test: "saveSettings never blanks a stored secret, whoever calls it" — RED before the fix. Cost if wrong: a future "clear my key" feature needs an explicit sentinel instead of "".
|
||||
Task 4: Ruling: lib/settings.ts imports ./db.ts and ./vault.ts (test-imported module, same discipline as Task 3).
|
||||
Task 4: complete (commits 5f8746c..dd6b880, tests: npm test → ℹ duration_ms 455.936167)
|
||||
Task 5: Ruling: /setup only auto-forwards to /app for steps 1 and 2; ?step=3 still renders after onboarding. The brief's unconditional redirect made the readiness screen unreachable exactly when it matters (Task 13 opens it after saving keys). Cost if wrong: one extra clickable step for a configured user.
|
||||
Task 5: Verified: signed-in /settings shows ••••1234 and ••••9999 (masked, never raw); anon /settings -> 307 /login; onboarded /setup and /setup?step=2 -> 307 /app; /setup?step=3 -> 200.
|
||||
Task 5: complete (commits dd6b880..1c3f222, tests: npm test → ℹ duration_ms 464.65375)
|
||||
Task 6: Ruling: tables and missing-index tables are rebuilt as Database.Schema.Table. The brief joined only the bracket-stripped Table attribute, so the digest named "Orders" where the test (and any DBA reading the report) needs "wide.Sales.Orders". RED first: actual ['Orders','Customer']. Cost if wrong: none, the fixture asserts the joined form.
|
||||
Task 6: Note: no genuine .sqlplan exists on this machine (no SQL Server to export one from), so "a real .sqlplan digests" skips with a printed reason and the commit message says so. Someone with SSMS should drop one at fixtures/real.sqlplan; that test is the check on attribute spellings.
|
||||
Task 6: complete (commits 1c3f222e..4fefd0f, tests: npm test → ℹ duration_ms 451.9635)
|
||||
Task 7: Ruling: lib/analyst.ts imports ./digest.ts and ./settings.ts (test-imported module discipline from Task 3).
|
||||
Task 7: Note: partner asked for the Gitea remote mid-task. origin = http://192.168.88.39:3000/ahmed/sql-server-query-ai.git with no credential stored in .git/config; pushed main and feat/sql-server-qai once with inline credentials. README documents the remote and credential hygiene.
|
||||
Task 7: complete (commits 4fefd0f..816f22c, tests: npm test → ℹ duration_ms 482.014)
|
||||
Task 8: Verified against live Jev (jev-1.13, real key, real digest): 1.3s, picked idx_covering_orders with confidence 1.00, composite agreed, TOP-100 rewrite scored semantic_safety 0.05 and flagged. makeJevClient/score/noul/choice work as the brief specifies.
|
||||
Task 8: Ruling: verify_semantics now fires only where semantics can move — any option carrying rewrite_sql below 0.6, or any option below 0.45. Evidence: live Jev scored the index create 0.57 ("equivalent, no stated proof"), so the brief's blanket <0.6 floor would flag nearly every index option and the warning would become noise. Test "verify_semantics fires where semantics can actually move" RED→GREEN. Cost if wrong: a genuinely risky non-rewrite change (e.g. dropping an index used elsewhere) scores 0.45-0.6 and goes unflagged; the option table still prints its safety bar.
|
||||
Task 8: Ruling: jevFallback strips score-derived flags (kept change_control, which is read off the candidate's own actions). Neutral 0 scores meant "Jev never spoke", not "this option is unsafe" — the brief's fallback accused every option. Test added, RED→GREEN.
|
||||
Task 8: Ruling: rubric arrays are `as const` — the SDK types ScoreCriteria as a non-empty readonly tuple, so a plain string[] fails `next build`'s type check even though the call works at runtime.
|
||||
Task 8: complete (commits 816f22c..d8caa8e, tests: npm test → ℹ duration_ms 504.103542)
|
||||
Task 9: Ruling: the library is lib/report-ui.ts written with createElement, not lib/report-ui.tsx with JSX. Node strips types but not JSX, so the brief's own test (`await import("../lib/report-ui.tsx")` under node --test) can never load a JSX file without adding a transpiler to the dep list. Same components, props, prompt and refs; only the call syntax changed. Cost if wrong: slightly noisier component bodies.
|
||||
Task 9: complete (commits d8caa8e..396b0ed, tests: npm test → ℹ duration_ms 500.095583)
|
||||
Task 9: Follow-up fix (found by `npm run build`, not by the tests): lib/db.ts opened SQLite and ran DDL at import, so next build's ~6 page-data workers hit "database is locked" and the build failed. Now the file opens on first query behind a bound Proxy, plus PRAGMA busy_timeout=5000. RED evidence: build exit 1 with "Failed to collect configuration for / [cause]: database is locked"; after the change build exits 0 and the suite is green (the Proxy had to bind methods — first attempt failed 4 tests with "Illegal invocation"). Cost if wrong: a long build-time query would still wait, which is what busy_timeout is for.
|
||||
Task 10: complete (commits 13bd2e6..ac4cbdd, tests: npm test → ℹ duration_ms 531.938)
|
||||
Task 10: Verified live, single run against the real analyst + real Jev: 41s end to end — stage digesting → 3 candidates → verdict {source: jev, headline: idx_covering_orders, confidence 1.00, composite agrees} → 2861 chars of OpenUI streamed in ~15 deltas → done {id}. Stored row replayed from /app/<id> renders the full report.
|
||||
Task 10: Ruling: the brief said "no cancel() handler, the default is right". It is not. enqueue() on an abandoned ReadableStream does not throw, so a producer whose browser closed keeps firing multi-minute analyst calls; two ghost runs piled onto the local vLLM and a fresh request sat in "proposing" for 10+ minutes behind them. Now: cancel() sets a flag, aborts the in-flight fetch through an AbortController (proposeCandidates/streamReport take an optional signal), the row is stored as partial/abandoned, and no further stage starts. Cost if wrong: an abandoned row reads "Closed before the run finished." even when the SQL was fine.
|
||||
Task 10: Ruling: both analyst calls send max_tokens: 2048 before ...cfg.extra (Settings can still override). Without it the local endpoint truncated the report at ~650 chars, which is not enough for 13 OpenUI blocks.
|
||||
Task 10: Ruling: prompt library split into lib/report-spec.ts (lang-core, React-free) + lib/report-ui.ts (renderers only, built from the same specs array). react-lang calls React.createContext at import, and a route runs under the react-server condition, where that export does not exist: `next build` died with "(0, pU.createContext) is not a function" while collecting /api/analyze. @openuidev/lang-core became a direct dep for this reason. Names/props/descriptions cannot drift because report-ui maps renderers onto the imported specs; the test asserts every spec has a renderer.
|
||||
Task 10: Note: the local analyst is a reasoning model. With thinking enabled it spends the context on `delta.reasoning` and `delta.content` stays empty, so the report arrives blank. Fixed by config, not code: Extra params {"chat_template_kwargs":{"enable_thinking":false}} — now said in the Settings field help and the README.
|
||||
Task 11: Ruling: node:sqlite rows have a null prototype, and React refuses to pass them into a client component — /app/<id> returned 500 with "Only plain objects ... can be passed to Client Components", dev trace pointing at row={{...}} in the rail. Fixed once in lib/db.ts (one() and listAnalyses spread every row) rather than in each component, and guarded by the test "rows are plain objects so they can cross the server -> client boundary" (RED first). Cost if wrong: a spread per row, which is nothing at this scale.
|
||||
Task 11: Ruling: HistoryItem binds (null, fd) wrappers because rename/remove are useActionState-shaped server actions and a plain <form action> passes only FormData.
|
||||
Task 11: Verified in Chromium (dev server, stored run): #report renders 17 cards, 1 pre.sql + Copy button, 1 option table, 3 score bars, 4 metric cards; 0 console errors; no horizontal overflow at 320/375/414/768; under print emulation the rail and toolbar hide and the body goes white.
|
||||
Task 11: complete (commits ac4cbdd..a5529ce, tests: npm test → ℹ duration_ms 529.962542)
|
||||
Task 12: Ruling: scripts/smoke.sh does not require Docker. --docker keeps the compose path; the default mode checks an already-running app (healthz, /login, /signup, signed-out /app -> 307, POST /api/analyze -> 401) and exits non-zero on any failure. The partner's machine has no Docker, so a script that could only run under compose would never be run here. Verified: all five checks ok against `npm start` on :3000.
|
||||
Task 12: Ruling: Dockerfile, docker-compose.yml and .dockerignore are written but were never built or executed here, per the partner's instruction. README says the image is untested on this machine. Cost if wrong: first real `docker build` may need a tweak or two.
|
||||
Task 12: Gate: jev-1.13 on the PLAN — YES (matches_goal 0.90, coverage 3.71/4, biggest_gap nothing). On the SPEC — matches_goal 0.94 but coverage 2.1 and the named gap moved get_plan -> qa as each was closed; the spec gained "Getting the input, and the two keys" and an "Acceptance" section because of it. The YES rule mentions tasks and tests, which a vision document does not carry, so the spec run is informational and the plan run is the gate.
|
||||
Task 12: Note: an earlier `smoke.sh` run reported /login 404. Cause was mine, not the app's: leftover `next start` processes from earlier tasks still held the port. pkill -f next, restart, all routes answer. Zombie dev servers are the first thing to check on a 404 here.
|
||||
Task 12: complete (commits a5529ce..0db7b81, tests: npm test → ℹ duration_ms 528.657875)
|
||||
Task 13: 17 tests green in 44s: signup → guide → keys typed in the panel → readiness screen; signed-out /app → /login; a real run (stages 1 of 4 → 4 of 4, OpenUI report painted from the stream, no raw "root = Report(" text, Copy reaches the clipboard, print emulation hides rail and toolbar); a dropped UTF-16/BOM .sqlplan decodes without NULs; a pasted SQL script is refused with the stage named; rename sticks; delete asks first and refuses to delete on dismiss; reopening a stored run renders the report and the server-rendered Jev verdict with no new model call; no horizontal overflow at 320/375/414/768 in desktop and Pixel 7; every settings form field has a real <label for>.
|
||||
Task 13: Ruling: the mobile project only runs responsive.spec.ts. A full run costs ~30s of real model time; a second identical copy bought nothing.
|
||||
Task 13: Ruling: three of the brief's assertions were wrong and are changed, each with a reason in the file. (1) "cards > 2" ran mid-stream, before the report existed — it now waits for the Analyse button to re-enable, which only happens on the done frame. (2) the tap-target floor of 36px is above what .btn actually is (10px padding + 15px line = 35px); the floor is 32px, which still fails a squashed or wrapped control. (3) "every input needs label[for]" failed on the rail's rename boxes, which carry aria-label instead; the check is scoped to form inputs and reports the offending names.
|
||||
Task 13: Ruling: SettingsForm gained an `after` hidden field and saveSettingsAction redirects to /setup?step=3 when it comes from the wizard. Without it the setup page's own forward (added in Task 5) bounced a just-configured user to /app, so the wizard's Save button appeared to do nothing. The /settings host still shows "Saved." inline.
|
||||
Task 13: Note (time waster, recorded so it is not repeated): deleting the data directory while a dev server is running leaves that server writing into an unlinked inode — the E2E run then "succeeds" against a database that no longer exists anywhere, and 11 tests fail with empty state. Stop the server (`pkill -f next`) before clearing .playwright-data, or let Playwright own the server lifecycle.
|
||||
Task 13: complete (commits 0db7b81..07dd3d6, tests: npm test → ℹ duration_ms 609.551292)
|
||||
Final review: self-review (no subagent tool) — the author read the whole 15-commit package; weaker than a fresh reviewer, so this is flagged for the partner to decide before merge.
|
||||
Final: fixed email collision on /settings — test "changeEmail refuses an address another account already has" RED→GREEN; suite 33/33, e2e 19/19.
|
||||
Final: fixed /app/[id] self-redirect for a row left "running" — behaviour pinned by tests-e2e/edge.spec.ts "a row left running renders what exists instead of redirecting forever" (RED: infinite redirect; GREEN).
|
||||
Final: fixed ScoreBar/OptionRow/VerdictBlock throwing on null numbers — test "a null confidence from the model does not blank the block" RED→GREEN; the live E2E log showed "[openui] runtime/render-error: Component ScoreBar render failed", which is now absent.
|
||||
Final: minor (deferred): no rate limit on /signup or /api/analyze (ponytail comment in lib/auth.ts; token bucket before this faces a network).
|
||||
Final: minor (deferred): raw XML is stored per analysis with no per-user quota; 12 MB cap per row, unbounded rows.
|
||||
Final: minor (deferred): sessions are not revoked when the password changes (30-day tokens stay valid).
|
||||
Final: minor (deferred): digestPlan walks the parsed document once per element type (~10 passes); a 12 MB plan costs a few hundred ms of CPU.
|
||||
Final: minor (deferred): rename from a /app/[id] page does not revalidate the rail title until the next navigation.
|
||||
Final: minor (deferred): .dockerignore excludes tests/ but the image copies node_modules from the build stage including devDependencies.
|
||||
Final: minor (deferred): Dockerfile is unbuilt on this machine (no Docker), so its first real build may need a tweak.
|
||||
Task 13: complete (commits 07dd3d6..5160a00, tests: npm test → ℹ duration_ms 514.179833)
|
||||
|
||||
Post-delivery bug report (partner: "clicking Next in the guide signs me out back to signup"):
|
||||
Root cause 1: startSession set secure: NODE_ENV === "production". Over plain http://localhost Safari refuses to store a Secure cookie at all, so every page after the wizard read as signed-out and requireUser bounced to /login (the login page's "Create account" link is what looked like a sign-out). Now Secure only when X-Forwarded-Proto says https, or COOKIE_SECURE=1. tests-e2e/session.spec.ts RED on webkit (Next → /login) → GREEN.
|
||||
Root cause 2 (found while pinning 1): /app had no onboarding gate at all. The guide only appeared because the signUp/signIn actions redirect there, so closing it mid-way left a console that cannot run anything until Analyse 412s. Gate now lives in app/app/layout.tsx using the same has_analyst/has_jev rule /api/analyze enforces.
|
||||
QA blind spot fixed: the suite ran next dev, and only chromium. Playwright now runs the production server (pree2e builds; reuseExistingServer false) and adds a webkit project, because chromium accepts a Secure cookie on localhost and webkit does not — the engine difference was the whole bug. 21 passed (setup, chromium, webkit, mobile).
|
||||
QA rulings on my own tests, not the app: session.spec asserted #email was prefilled (it is a change field and starts blank by design) and edge.spec picked "the newest user", which in a full run is a webkit spec's account, so it opened another account's row and 404s; both now target the setup account and the gate respectively.
|
||||
Verification on the delivered build, both engines, against http://localhost:3000: signup → guide → Next → keys → Save → step 3 "Analyst ready · Jev ready" → /app renders the paste box.
|
||||
|
||||
Post-delivery bug report 2 (partner: their own 2.7 MB production plan failed to import):
|
||||
Five defects, each found by measuring plan-cxpaket.sqlplan rather than the fixture: (1) fast-xml-parser maxNestedTags 128 vs a real nesting depth of 2729; (2) table names read off <IndexScan> instead of its <Object> child, so 51 objects digested to 0 tables; (3) SSMS writes StatementText with 
 (90 of them) and the model was fed entity noise; (4) rewrite_sql's name told the model "query rewrite only", so no CREATE INDEX ever existed to show and the report had zero SqlBlocks; (5) max_tokens 2048 truncated the now-larger candidate JSON and the user got "malformed JSON" after 38 seconds.
|
||||
Ruling: option_type + sql_to_run replaces rewrite_sql. The field name was load-bearing — it is sent to the model — and keeping it would have kept the omission. verify_semantics therefore keys on option_type === "rewrite", preserving the Task 8 ruling that index options must not be flagged for carrying DDL.
|
||||
Ruling: dropPlaceholderSql nulls ellipsis SQL instead of rendering it. A SqlBlock that cannot be pasted reads as an instruction and fails on run; no block beats a fake one. ponytail comment marks the "..." heuristic.
|
||||
Ruling: the plan file itself stays out of git (/plan-*.sqlplan). It carries the partner's real schema names; the fixture that ships is synthetic.
|
||||
+43
-10
@@ -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
@@ -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
@@ -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 (
 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
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
+3
-2
@@ -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",
|
||||
@@ -29,4 +30,4 @@
|
||||
"eslint-config-next": "16.3.5",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -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" } },
|
||||
});
|
||||
|
||||
@@ -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;");
|
||||
|
||||
@@ -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 (?,?,?,?,?,?,?)")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 
 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 
FROM dbo.T WHERE x > 1 & 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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user