From 521d8390ea2dc349eba8dd6baa55135b795933ff Mon Sep 17 00:00:00 2001 From: Boris Tyshkevich Date: Thu, 30 Jul 2026 18:40:35 +0200 Subject: [PATCH] chore(skills): promote skills/ to a first-class repo-root directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move ship and ship-phase out of .claude/skills into a new root-level skills/ directory, and symlink .claude/skills, .agents/skills, and .codex/skills to it so Claude Code, the Codex CLI, and generic agent tooling share one source of truth instead of three copies. Also vendor the sql-browser-dashboard skill: it turns an already-known SQL/result-column investigation into a validated PortableBundleV2 Dashboard bundle and publishes it through the save_dashboard MCP tool (or leaves it as a downloadable JSON file when that tool isn't wired up). Verified its authoring profile field-for-field against this repo's real schemas/*.json — no drift found. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01L3pqYYJrP8zAmLmrJvzcxx --- .agents/skills | 1 + .agents/skills/ship | 1 - .agents/skills/ship-phase | 1 - .claude/skills | 1 + .codex/skills | 1 + .gitignore | 8 +- .../skills => skills}/ship-phase/SKILL.md | 0 {.claude/skills => skills}/ship/SKILL.md | 34 +- .../ship/references/per-issue-cycle.md | 14 +- .../ship/references/unattended.md | 15 +- skills/sql-browser-dashboard/SKILL.md | 155 +++++++++ .../sql-browser-dashboard/agents/openai.yaml | 4 + .../references/dashboard-authoring-profile.md | 283 +++++++++++++++++ .../references/example-dashboard.json | 139 ++++++++ .../references/panel-guide.md | 138 ++++++++ .../references/save-dashboard-tool.md | 83 +++++ ...ql-browser-dashboard-authoring.schema.json | 297 ++++++++++++++++++ .../scripts/validate_bundle.py | 180 +++++++++++ 18 files changed, 1336 insertions(+), 19 deletions(-) create mode 120000 .agents/skills delete mode 120000 .agents/skills/ship delete mode 120000 .agents/skills/ship-phase create mode 120000 .claude/skills create mode 120000 .codex/skills rename {.claude/skills => skills}/ship-phase/SKILL.md (100%) rename {.claude/skills => skills}/ship/SKILL.md (84%) rename {.claude/skills => skills}/ship/references/per-issue-cycle.md (86%) rename {.claude/skills => skills}/ship/references/unattended.md (89%) create mode 100644 skills/sql-browser-dashboard/SKILL.md create mode 100644 skills/sql-browser-dashboard/agents/openai.yaml create mode 100644 skills/sql-browser-dashboard/references/dashboard-authoring-profile.md create mode 100644 skills/sql-browser-dashboard/references/example-dashboard.json create mode 100644 skills/sql-browser-dashboard/references/panel-guide.md create mode 100644 skills/sql-browser-dashboard/references/save-dashboard-tool.md create mode 100644 skills/sql-browser-dashboard/references/sql-browser-dashboard-authoring.schema.json create mode 100755 skills/sql-browser-dashboard/scripts/validate_bundle.py diff --git a/.agents/skills b/.agents/skills new file mode 120000 index 00000000..42c5394a --- /dev/null +++ b/.agents/skills @@ -0,0 +1 @@ +../skills \ No newline at end of file diff --git a/.agents/skills/ship b/.agents/skills/ship deleted file mode 120000 index c453f8a8..00000000 --- a/.agents/skills/ship +++ /dev/null @@ -1 +0,0 @@ -../../.claude/skills/ship \ No newline at end of file diff --git a/.agents/skills/ship-phase b/.agents/skills/ship-phase deleted file mode 120000 index c22638ee..00000000 --- a/.agents/skills/ship-phase +++ /dev/null @@ -1 +0,0 @@ -../../.claude/skills/ship-phase \ No newline at end of file diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 00000000..42c5394a --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../skills \ No newline at end of file diff --git a/.codex/skills b/.codex/skills new file mode 120000 index 00000000..42c5394a --- /dev/null +++ b/.codex/skills @@ -0,0 +1 @@ +../skills \ No newline at end of file diff --git a/.gitignore b/.gitignore index cd76c346..145789e7 100644 --- a/.gitignore +++ b/.gitignore @@ -28,12 +28,10 @@ /.wiki/ # impeccable design skill — vendored dev tool + its local state; kept local, not in the repo -/.claude/skills/impeccable/ +/skills/impeccable/ /.impeccable/ -# Codex agent-skills mirror: ignore the vendored content, keep only the ship/ship-phase symlinks -/.agents/skills/* -!/.agents/skills/ship -!/.agents/skills/ship-phase + +# .claude/skills, .codex/skills, .agents/skills are symlinks to /skills/ (checked in, see /skills/) # OS / editor .DS_Store diff --git a/.claude/skills/ship-phase/SKILL.md b/skills/ship-phase/SKILL.md similarity index 100% rename from .claude/skills/ship-phase/SKILL.md rename to skills/ship-phase/SKILL.md diff --git a/.claude/skills/ship/SKILL.md b/skills/ship/SKILL.md similarity index 84% rename from .claude/skills/ship/SKILL.md rename to skills/ship/SKILL.md index cf834f7d..9ae8b261 100644 --- a/.claude/skills/ship/SKILL.md +++ b/skills/ship/SKILL.md @@ -37,9 +37,13 @@ unattended mode, which has no gates but the last (see step 0). > mid-run. Inheriting these steps is not the same as being told to execute them. State the > boundary explicitly in every subagent prompt (no Edit/Write, no git/gh mutating commands, no > TaskCreate/TaskUpdate, no memory writes — return only the requested output), and prefer a -> fresh non-`fork` agent for this kind of fan-out. **Steps 5–7 — reconcile, PR, and the merge -> gate — are performed by this session only, never delegated.** After any batch of subagents -> returns, verify with `git diff`, `git log`, and `gh pr list` before trusting a self-report. +> fresh non-`fork` agent for this kind of fan-out. **Steps 5–8 — reconcile, PR, the post-PR +> ChatGPT second opinion, and the merge gate — are performed by this session only, never +> delegated.** The high-risk *plan*-stage `chatgpt-review` call in step 2 +> (`references/per-issue-cycle.md`) is different: it never touches git/gh, so a worker may run +> it directly even in unattended mode — only the post-PR call in step 7 is session-only. +> After any batch of subagents returns, verify with `git diff`, `git log`, and `gh pr list` +> before trusting a self-report. ## 0 — Resolve the argument @@ -52,8 +56,8 @@ unattended mode, which has no gates but the last (see step 0). Attended is always the default. **Unattended requires the literal word** — never infer it. -For unattended, Read `references/unattended.md` now and follow it; it replaces steps 1, 6 and -7 below and reuses steps 2–5 as the worker contract. +For unattended, Read `references/unattended.md` now and follow it; it replaces steps 1, 6, 7 +and 8 below and reuses steps 2–5 as the worker contract. ## 1 — Orient, resolve the phase, set up the workspace @@ -145,7 +149,25 @@ are held to — one source of truth, so the two paths cannot drift. final phase. Every earlier phase uses **`Part of #`**. - Tick the checklist (gate, layers, deps, CHANGELOG, reconcile). Report the **PR URL**. -## 7 — 🛑 Merge gate — STOP +## 7 — Third-party review (ChatGPT) + +Invoke the `chatgpt-review` skill (`Skill` tool, `skill: "chatgpt-review"`) on the PR just +opened in step 6, passing its number/URL as the argument. Read `chatgpt-review`'s own +`SKILL.md` and follow it as written — it already covers gathering the diff, prompting +ChatGPT, waiting for the full answer, and verifying every claim against the real repo before +trusting it. + +- This is a **second opinion, not a gate** — a negative or contested verdict does not block + progress to step 8, but every claim you accept as real must be fixed here. +- Apply real findings, commit (`fix(#): address ChatGPT review feedback` or fold into + the PR's existing commits per the repo's amend policy), `npm test`, and `git push` before + moving on. +- If `chatgpt-review` reports it couldn't reach ChatGPT (agent Chrome down, network denied), + don't block the ship on it — note the skip in the merge-gate summary (step 8) and continue. +- Note what you verified vs. dismissed in the phase log / PR comment so the human at the merge + gate sees it, not just a silent pass. + +## 8 — 🛑 Merge gate — STOP Do **not** merge. Merging to `main` is a human call. diff --git a/.claude/skills/ship/references/per-issue-cycle.md b/skills/ship/references/per-issue-cycle.md similarity index 86% rename from .claude/skills/ship/references/per-issue-cycle.md rename to skills/ship/references/per-issue-cycle.md index c3460a29..b1044b7f 100644 --- a/.claude/skills/ship/references/per-issue-cycle.md +++ b/skills/ship/references/per-issue-cycle.md @@ -34,7 +34,19 @@ judge under-determined despite its acceptance criteria: 1. **second opinion** — spawn a `Plan` subagent (`subagent_type: "Plan"`, read-only boundary stated) to independently stress the approach: seams, migration order, coverage strategy, rollback. Fold its critique into the plan; -2. 🛑 **post the resulting plan and wait for approval** (I review on mobile). +2. **third-party opinion** — invoke the `chatgpt-review` skill (`Skill` tool, + `skill: "chatgpt-review"`) on the plan itself, before any code exists. There is no diff yet, + so hand it the plan text plus enough issue/phase context to review cold (the phase contract, + the acceptance subset it claims, the files it intends to touch) and ask the same kind of + pointed question the skill asks of a diff: does this approach actually close the gap the + phase claims to close, is the seam/migration-order choice sound, is there a simpler design? + Open a **fresh tab** for this — never continue an earlier chatgpt.com conversation thread + into a new review, even though running several tabs in parallel is fine. Verify every claim + against the real repo (skill step 6) before folding it in — a second opinion, not a source of + truth, exactly like the post-PR case. Use the skill's paste-inline path, not its + point-at-GitHub path — this step must not post anything to GitHub, so it stays safe for a + worker to run directly in unattended mode too; +3. 🛑 **post the resulting plan and wait for approval** (I review on mobile). Low-risk, well-specified work proceeds straight from the written plan with no approval gate. diff --git a/.claude/skills/ship/references/unattended.md b/skills/ship/references/unattended.md similarity index 89% rename from .claude/skills/ship/references/unattended.md rename to skills/ship/references/unattended.md index a7286a63..d341bb2c 100644 --- a/.claude/skills/ship/references/unattended.md +++ b/skills/ship/references/unattended.md @@ -6,7 +6,7 @@ Reached only via the literal word `unattended`: - `/ship 424,425,426 unattended` — several whole **issues**. Both are the same machine: a **unit** is a phase or an issue, and units run in dependency -order. This file replaces `SKILL.md` steps 1, 6 and 7. Steps 2–5 stay exactly as written in +order. This file replaces `SKILL.md` steps 1, 6, 7 and 8. Steps 2–5 stay exactly as written in `references/per-issue-cycle.md` — that is the worker contract, quoted, not paraphrased. You are the **coordinator**. You do not implement units yourself. You plan waves, spawn @@ -69,7 +69,7 @@ The worker prompt must contain, explicitly: - **the mutation boundary**: Edit/Write + local `git commit` on its own branch only — **no push, no PR, no `gh` mutations, no issue edits, no phase-log writes, no memory writes, no `CHANGELOG.md` beyond its own entry, no TaskCreate/TaskUpdate**; -- the instruction to follow `.claude/skills/ship/references/per-issue-cycle.md` steps 2–4 and +- the instruction to follow `skills/ship/references/per-issue-cycle.md` steps 2–4 and the CHANGELOG part of 5 — **the phase log is yours, not the worker's**; - commit message `(#): ` + the repo footer convention; - what to return: plan summary, files touched, test/build output tail, and the contract @@ -107,6 +107,11 @@ agents on this repo have edited files despite an explicit report-only boundary. 4. **One PR** (`gh pr create --base main`), body per the PR template, with one `Closes #` line per fully completed issue (partially completed or skipped: `Part of #`), a per-unit summary table, and the repo PR footer. -5. 🛑 **Merge gate — the only stop.** Report: PR URL, per-unit status (shipped / skipped + - why), review findings applied, CI status. Do not merge. -6. Friction → memory. The coordinator writes it, not a subagent. +5. **Third-party review**: invoke the `chatgpt-review` skill on the PR just opened, per + `SKILL.md` step 7. A negative or contested verdict doesn't stop the line, but apply every + claim you verify as real, `npm test`, and push before continuing — this is coordinator work, + never delegated to a worker. If ChatGPT is unreachable, note the skip in the final report and + continue. +6. 🛑 **Merge gate — the only stop.** Report: PR URL, per-unit status (shipped / skipped + + why), review findings applied (including the ChatGPT pass), CI status. Do not merge. +7. Friction → memory. The coordinator writes it, not a subagent. diff --git a/skills/sql-browser-dashboard/SKILL.md b/skills/sql-browser-dashboard/SKILL.md new file mode 100644 index 00000000..bf668999 --- /dev/null +++ b/skills/sql-browser-dashboard/SKILL.md @@ -0,0 +1,155 @@ +--- +name: sql-browser-dashboard +description: > + Build and publish Altinity SQL Browser-compatible business analytics dashboards as + PortableBundleV2 JSON through an Altinity MCP dynamic tool named save_dashboard. + Use when the user asks to create, assemble, save, publish, or share a SQL Browser + dashboard and the analytical SQL plus relevant result-column knowledge is already + available in the conversation or from another skill/tool. This skill owns Dashboard + composition, Presentation Specs, flow layout, bundle validation, and the final typed + save_dashboard call. It does not discover ClickHouse schemas, navigate databases, + design source data models, or execute analytical queries. +--- + +# SQL Browser Dashboard + +Create one SQL Browser Dashboard that communicates useful business insights, package it as one `PortableBundleV2`, validate it, and publish it through the Altinity MCP dynamic tool whose exact basename is `save_dashboard`. + +## Required boundaries + +- Use only already-known SQL and result-column information. +- Do not inspect ClickHouse databases, tables, columns, or sample rows. +- Do not call generic SQL execution tools to test or publish the Dashboard. +- Do not invent unknown table or column names. +- Do not use BentoClick's panel schema. Produce the SQL Browser schema described in [references/dashboard-authoring-profile.md](references/dashboard-authoring-profile.md). +- Use the typed `save_dashboard` tool for the write. Do not fall back to `execute_query`, raw `INSERT`, HTTP, or shell clients. +- Publish exactly one Dashboard per bundle. + +If the requested Dashboard lacks usable SQL or the output-column order needed by a positional chart, ask for that missing input or state that schema/query discovery must be completed outside this skill. + +## Load references selectively + +1. Read [references/dashboard-authoring-profile.md](references/dashboard-authoring-profile.md) before constructing the bundle. +2. Read [references/panel-guide.md](references/panel-guide.md) for every panel type used. +3. Read [references/save-dashboard-tool.md](references/save-dashboard-tool.md) immediately before publishing. +4. Consult [references/example-dashboard.json](references/example-dashboard.json) when a complete minimal example is useful. +5. Use [references/sql-browser-dashboard-authoring.schema.json](references/sql-browser-dashboard-authoring.schema.json) as the skill's supported authoring-profile schema. SQL Browser's production schemas remain authoritative. + +## Workflow + +### 1. Confirm the dashboard contract + +Determine from the conversation: + +- Dashboard title and business purpose. +- Dashboard description or executive takeaway. +- Available analytical queries. +- Exact result-column order for `bar`, `hbar`, `line`, `area`, and `pie` panels. +- Any ClickHouse parameters already present in SQL, written as `{name:Type}`. +- Any supplied option-list SQL for those inferred variables. + +Do not restart database discovery. Ask only for data required to serialize the Dashboard correctly. + +### 2. Choose an insight structure + +Prefer a compact decision-oriented sequence: + +1. headline KPIs; +2. trend or comparison; +3. categorical breakdown; +4. supporting detail table; +5. optional explanatory Markdown. + +Avoid filling the page with redundant charts. Every tile must answer a distinct business question. + +### 3. Create saved queries + +Create one `SavedQueryV2` per distinct tile query. + +- Use unique, readable IDs containing only stable ASCII slug characters. +- Preserve supplied SQL exactly except for clearly requested formatting corrections. +- Set `specVersion` to `1`. +- Set `spec.view` to `panel`. +- Set `spec.name` and `spec.description` to business-readable text. +- Set `spec.dashboard.role` to `panel`. +- Configure only implemented panel types: `kpi`, `table`, `bar`, `hbar`, `line`, `area`, `pie`, `logs`, and `text`. +- For chart panels, use zero-based result-column indexes. Never guess the projection order. +- Use `fieldConfig.columns` to provide readable labels, units, decimal precision, descriptions, and missing-value text. + +Do not add unused Library queries. The bundle's query array must equal the Dashboard's exact query dependency closure. + +### 4. Create the Dashboard document + +Create one `DashboardDocumentV2`: + +- `documentVersion`: `2`. +- `id`: stable dashboard slug, reused as the catalogue identity. +- `title`: user-visible title. +- `description`: concise purpose and intended audience. +- `revision`: `1` for a newly authored Dashboard. +- `tiles`: one tile per visual instance, in semantic reading order. +- `layout`: `flow@1` using `report`, `columns-2`, or `columns-3`. +- `variableConfigs`: include only supplied option SQL keyed by exact case-sensitive inferred variable name. + +Tile `queryId` values must resolve to bundled queries. Layout item keys must resolve to tile IDs. + +### 5. Create the portable bundle + +Create a top-level object with: + +- `$schema`: the SQL Browser PortableBundleV2 schema identifier; +- `format`: `altinity-sql-browser/portable-bundle`; +- `version`: `2`; +- `exportedAt`: current RFC 3339 timestamp; +- `metadata.name` and optional `metadata.description`; +- `queries`: exact dependency closure; +- `dashboards`: an array containing exactly one Dashboard. + +Never include credentials, hosts, session data, query results, caches, runtime variable values, tabs, drafts, or unrelated queries. + +### 6. Validate before publishing + +When code execution is available, write the candidate bundle to a temporary JSON file and run: + +```bash +python scripts/validate_bundle.py candidate.json +``` + +Fix every reported error. The validator checks the supported authoring profile and important cross-resource semantics. It does not replace SQL Browser's production decoder. + +Without code execution, manually apply the checklist in [references/dashboard-authoring-profile.md](references/dashboard-authoring-profile.md). + +### 7. Publish once + +Locate the connected MCP tool whose exact basename is `save_dashboard`. Call it with one argument: + +```text +payload = JSON-encoded PortableBundleV2 +``` + +Call it only after the full bundle is complete and validated. Do not make one call per query or tile. + +Do not automatically retry after a timeout, transport interruption, or ambiguous MCP error because the insert may have committed. Ask the user to verify catalogue state before attempting another save. + +### 8. Report the result + +On confirmed success, report: + +- Dashboard title; +- Dashboard ID; +- query count; +- tile count; +- that it was published to the SQL Browser Dashboard catalogue. + +Do not claim a generated server version unless the tool response explicitly returns it. + +## Quality rules + +- Optimize for business comprehension, not feature demonstration. +- Put the most decision-relevant information first. +- Keep titles short and descriptions specific. +- Use consistent units and precision. +- Avoid pie charts for numerous categories or values requiring close comparison. +- Prefer line/area for ordered time, bar/hbar for category comparison, KPI for a small one-row summary, and table for exact detail. +- Keep Dashboard variables few, predictable, and directly useful. +- Never publish a structurally invalid bundle merely to let the browser diagnose it later. diff --git a/skills/sql-browser-dashboard/agents/openai.yaml b/skills/sql-browser-dashboard/agents/openai.yaml new file mode 100644 index 00000000..2eda3d42 --- /dev/null +++ b/skills/sql-browser-dashboard/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "SQL Browser Dashboard" + short_description: "Build and publish SQL Browser business dashboards" + brand_color: "#4F8CFF" diff --git a/skills/sql-browser-dashboard/references/dashboard-authoring-profile.md b/skills/sql-browser-dashboard/references/dashboard-authoring-profile.md new file mode 100644 index 00000000..6848dc9c --- /dev/null +++ b/skills/sql-browser-dashboard/references/dashboard-authoring-profile.md @@ -0,0 +1,283 @@ +# SQL Browser Dashboard authoring profile + +## Contents + +1. Purpose and authority +2. Object graph +3. Portable bundle +4. Saved queries +5. Panel configuration +6. Dashboard document +7. Variables +8. Identity and dependency rules +9. Validation checklist + +## 1. Purpose and authority + +This reference defines the deliberately narrow profile generated by the `sql-browser-dashboard` skill. It targets the current SQL Browser write contracts: + +- Portable bundle version 2 +- Saved query version 2 +- Query Presentation Spec version 1 +- Dashboard document version 2 +- Flow layout version 1 + +The production JSON Schemas in the SQL Browser repository remain authoritative. The profile is stricter in several places: it permits only implemented panel types, exactly one Dashboard, no unused queries, no setup queries, and no orphaned variable configurations. + +## 2. Object graph + +```text +PortableBundleV2 +├── queries[]: SavedQueryV2 +└── dashboards[0]: DashboardDocumentV2 + ├── tiles[].queryId ───────────────► queries[].id + ├── layout.items keys ─────────────► tiles[].id + └── variableConfigs keys ──────────► {name:Type} placeholders in query SQL +``` + +The server catalogue stores the complete bundle as one payload. SQL Browser imports it as one independent local Dashboard plus its exact saved-query dependency closure. + +## 3. Portable bundle + +Required shape: + +```jsonc +{ + "$schema": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v2.schema.json", + "format": "altinity-sql-browser/portable-bundle", + "version": 2, + "exportedAt": "2026-07-28T12:00:00.000Z", + "metadata": { + "name": "Revenue overview", + "description": "Executive revenue, growth, and regional mix." + }, + "queries": [], + "dashboards": [] +} +``` + +Rules: + +- `dashboards` contains exactly one item for this skill. +- `queries` contains every query referenced by a tile and nothing else. +- `exportedAt` is a valid RFC 3339 date-time. +- Do not persist runtime state or connection information. + +## 4. Saved queries + +Required envelope: + +```jsonc +{ + "id": "revenue-kpis", + "sql": "SELECT ...", + "specVersion": 1, + "spec": { + "name": "Revenue KPIs", + "description": "Revenue, growth, and active customers.", + "view": "panel", + "panel": { + "cfg": { "type": "kpi" }, + "fieldConfig": { + "defaults": { "noValue": "—" }, + "columns": {} + } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "compact", + "minimum": "compact", + "aspectRatio": 2 + } + } + } +} +``` + +### Presentation metadata + +`fieldConfig.columns` is keyed by exact result-column name. Useful fields are: + +- `displayName`: visible label; +- `description`: tooltip/help text; +- `unit`: display suffix such as ` £`, `%`, or ` customers`; +- `decimals`: integer from 0 through 20; +- `noValue`: text for NULL/unavailable values; +- `hidden`: hide an otherwise eligible field; +- `color`: renderer colour hint; +- `delta`: KPI delta metadata. + +### Dashboard metadata + +`spec.dashboard.role` must be `panel` in this profile. `sizeHints` guide initial authoring but do not own placement. + +## 5. Panel configuration + +The implemented profile is: + +| Type | Primary use | Required configuration | +|---|---|---| +| `kpi` | Small one-row summary | `{ "type": "kpi" }` | +| `table` | Exact records and rankings | `{ "type": "table" }` | +| `bar` | Vertical category comparison | `x`, non-empty `y`, optional `series`, optional `style` | +| `hbar` | Horizontal category comparison | `x`, non-empty `y`, optional `series`, optional `style` | +| `line` | Ordered/time trend | `x`, non-empty `y`, optional `series`, optional `style` | +| `area` | Filled trend or stacked magnitude | `x`, non-empty `y`, optional `series`, optional `style` | +| `pie` | Small part-to-whole breakdown | `x`, exactly one `y`, optional `style` | +| `logs` | Timestamped messages/events | optional `time`, `msg`, `level` column names | +| `text` | Safe Markdown narrative | `content` | + +Chart `x`, `y`, and `series` values are zero-based result-column indexes. The author must know the SQL projection order. Example: + +```sql +SELECT region, sum(revenue) AS revenue +FROM sales +GROUP BY region +ORDER BY revenue DESC +``` + +```json +{ + "type": "hbar", + "x": 0, + "y": [1], + "series": null, + "style": { + "mode": "grouped", + "density": "normal", + "scale": "zero", + "legend": "hide", + "grid": "auto", + "axes": "show" + } +} +``` + +## 6. Dashboard document + +Required shape: + +```jsonc +{ + "documentVersion": 2, + "id": "revenue-overview", + "title": "Revenue overview", + "description": "Executive revenue, growth, and regional mix.", + "revision": 1, + "layout": { + "type": "flow", + "version": 1, + "preset": "columns-2", + "items": { + "tile-kpis": { "span": 2, "height": "compact" }, + "tile-trend": { "span": 2, "height": "large" } + } + }, + "tiles": [ + { + "id": "tile-kpis", + "queryId": "revenue-kpis" + }, + { + "id": "tile-trend", + "queryId": "revenue-trend" + } + ], + "variableConfigs": {} +} +``` + +### Tiles + +Tiles are in canonical semantic order. Execution planning, keyboard order, fallback rendering, printing, and serialization follow this sequence. + +Each tile requires: + +- `id`: unique within the Dashboard; +- `queryId`: exact saved-query ID. + +Optional fields: + +- `title`: Dashboard-local title override; +- `description`: Dashboard-local description override; +- `presentation.variant`: named query presentation variant; +- `presentation.override`: JSON Merge Patch over the base panel. + +### Flow layout + +`layout.type` is `flow` and `layout.version` is `1`. + +Presets: + +- `report`: one constrained-width column; +- `columns-2`: two equal desktop columns; +- `columns-3`: three equal desktop columns. + +Placement fields: + +- `span`: `1`, `2`, or `3`; clamped to the active preset's column count; +- `height`: `compact`, `medium`, or `large`. + +A missing placement defaults to span 1 and medium height, but generated Dashboards should provide explicit placement for every tile. + +## 7. Variables + +SQL Browser infers Dashboard variables from ClickHouse placeholders in tile-owned SQL: + +```sql +WHERE event_date >= {from:Date} + AND event_date <= {to:Date} + AND region = {region:String} +``` + +Variable identity is the exact case-sensitive name. The type comes from the placeholder declaration. + +Optional list values are Dashboard-local: + +```json +{ + "variableConfigs": { + "region": { + "sql": "SELECT toString(region), toString(region) FROM dimensions.regions ORDER BY region", + "lastKnownType": "String" + } + } +} +``` + +Option SQL rules: + +- one read query; +- exactly two String-compatible columns: value, then visible label; +- no Dashboard-variable placeholders; +- no credentials or runtime values; +- key must match an inferred variable in this authoring profile. + +## 8. Identity and dependency rules + +- Dashboard ID is stable and becomes the catalogue's `dashboard_id`. +- Query IDs are unique within the bundle. +- Tile IDs are unique within the Dashboard. +- Every tile query reference resolves. +- Every bundled query is referenced by at least one tile. +- A query may be reused by more than one tile with tile-local presentation overrides. +- Every layout key names a tile. +- Generated variable configurations are not orphaned. + +## 9. Validation checklist + +Before `save_dashboard`: + +- [ ] Bundle format is `altinity-sql-browser/portable-bundle` version 2. +- [ ] Exactly one Dashboard document version 2 exists. +- [ ] Every saved query uses envelope version 2 and Spec version 1. +- [ ] Only implemented panel types are present. +- [ ] Chart column indexes match known SQL projection order. +- [ ] Query IDs and tile IDs are unique. +- [ ] Tile references and layout references resolve. +- [ ] Query array is the exact tile dependency closure. +- [ ] Dashboard variables use consistent exact names and ClickHouse types. +- [ ] Option SQL contains no Dashboard variables. +- [ ] No connection details, credentials, results, caches, or runtime values are stored. +- [ ] Payload parses as JSON and passes `scripts/validate_bundle.py` when available. diff --git a/skills/sql-browser-dashboard/references/example-dashboard.json b/skills/sql-browser-dashboard/references/example-dashboard.json new file mode 100644 index 00000000..6e580f40 --- /dev/null +++ b/skills/sql-browser-dashboard/references/example-dashboard.json @@ -0,0 +1,139 @@ +{ + "$schema": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v2.schema.json", + "format": "altinity-sql-browser/portable-bundle", + "version": 2, + "exportedAt": "2026-07-28T12:00:00.000Z", + "metadata": { + "name": "Revenue overview", + "description": "Example business dashboard using SQL supplied by the user." + }, + "queries": [ + { + "id": "revenue-kpis", + "sql": "SELECT 1250000 AS revenue, 8.4 AS growth_pct, 842 AS customers", + "specVersion": 1, + "spec": { + "name": "Revenue KPIs", + "description": "Headline revenue, growth, and customer count.", + "favorite": false, + "view": "panel", + "panel": { + "cfg": { + "type": "kpi" + }, + "fieldConfig": { + "defaults": { + "noValue": "—" + }, + "columns": { + "revenue": { + "displayName": "Revenue", + "unit": " £", + "decimals": 0 + }, + "growth_pct": { + "displayName": "Growth", + "unit": "%", + "decimals": 1 + }, + "customers": { + "displayName": "Customers", + "decimals": 0 + } + } + } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "compact", + "minimum": "compact", + "aspectRatio": 2 + } + } + } + }, + { + "id": "revenue-by-region", + "sql": "SELECT region, revenue FROM supplied_business_view ORDER BY revenue DESC LIMIT 10", + "specVersion": 1, + "spec": { + "name": "Revenue by region", + "description": "Top regions ranked by revenue.", + "favorite": false, + "view": "panel", + "panel": { + "cfg": { + "type": "hbar", + "x": 0, + "y": [1], + "series": null, + "style": { + "mode": "grouped", + "density": "normal", + "scale": "zero", + "legend": "hide", + "grid": "auto", + "axes": "show" + } + }, + "fieldConfig": { + "columns": { + "region": { + "displayName": "Region" + }, + "revenue": { + "displayName": "Revenue", + "unit": " £", + "decimals": 0 + } + } + } + }, + "dashboard": { + "role": "panel", + "sizeHints": { + "preferred": "wide", + "minimum": "medium", + "aspectRatio": 1.8 + } + } + } + } + ], + "dashboards": [ + { + "documentVersion": 2, + "id": "revenue-overview", + "title": "Revenue overview", + "description": "Headline revenue and regional contribution for business review.", + "revision": 1, + "layout": { + "type": "flow", + "version": 1, + "preset": "columns-2", + "items": { + "tile-revenue-kpis": { + "span": 2, + "height": "compact" + }, + "tile-revenue-region": { + "span": 2, + "height": "large" + } + } + }, + "tiles": [ + { + "id": "tile-revenue-kpis", + "queryId": "revenue-kpis" + }, + { + "id": "tile-revenue-region", + "queryId": "revenue-by-region" + } + ], + "variableConfigs": {} + } + ] +} diff --git a/skills/sql-browser-dashboard/references/panel-guide.md b/skills/sql-browser-dashboard/references/panel-guide.md new file mode 100644 index 00000000..b26d1130 --- /dev/null +++ b/skills/sql-browser-dashboard/references/panel-guide.md @@ -0,0 +1,138 @@ +# SQL Browser panel guide + +Read the sections for every panel type used. + +## KPI (`kpi`) + +Use for a small set of headline values returned in one row. Each top-level result column becomes a KPI unless hidden. Use named tuples for values carrying runtime delta metadata when supported by the SQL Browser renderer. + +```json +{ + "cfg": { "type": "kpi" }, + "fieldConfig": { + "defaults": { "noValue": "—" }, + "columns": { + "revenue": { "displayName": "Revenue", "unit": " £", "decimals": 0 }, + "growth": { "displayName": "Growth", "unit": "%", "decimals": 1 } + } + } +} +``` + +Use a compact full-width tile for 3–6 KPIs. + +## Table (`table`) + +Use for exact values, rankings, and supporting detail. Keep result sets bounded in SQL. Put business-readable aliases in the SQL or `fieldConfig.columns`. + +```json +{ + "cfg": { "type": "table" }, + "fieldConfig": { + "columns": { + "customer": { "displayName": "Customer" }, + "revenue": { "displayName": "Revenue", "unit": " £", "decimals": 0 } + } + } +} +``` + +## Bar (`bar`) and horizontal bar (`hbar`) + +Use `bar` for a small number of short category labels. Use `hbar` for rankings and long labels. + +`x` is the category-column index. `y` is one or more measure-column indexes. `series` optionally names a splitting column by index. + +```json +{ + "cfg": { + "type": "hbar", + "x": 0, + "y": [1], + "series": null, + "style": { + "mode": "grouped", + "density": "normal", + "scale": "zero", + "legend": "hide", + "grid": "auto", + "axes": "show" + } + } +} +``` + +## Line (`line`) and area (`area`) + +Use for ordered or time-based trends. Sort the query by the X column. Use `area` when magnitude/accumulation is important; use `line` for precise trend comparison. + +```json +{ + "cfg": { + "type": "line", + "x": 0, + "y": [1, 2], + "series": null, + "style": { + "curve": "linear", + "points": "auto", + "scale": "data", + "legend": "show", + "grid": "auto", + "axes": "show" + } + } +} +``` + +For `area`, `style.stack` may be `overlay` or `stacked`. + +## Pie (`pie`) + +Use only for a small part-to-whole breakdown with one measure. Prefer bars when differences are close or there are many categories. + +```json +{ + "cfg": { + "type": "pie", + "x": 0, + "y": [1], + "series": null, + "style": { + "shape": "donut", + "legend": "show", + "frame": "normal" + } + } +} +``` + +## Logs (`logs`) + +Use for timestamped business events or operational messages. Name roles when the query aliases differ from defaults. + +```json +{ + "cfg": { + "type": "logs", + "time": "event_time", + "msg": "message", + "level": "level" + } +} +``` + +## Text (`text`) + +Use for safe Markdown context, definitions, caveats, or a short executive interpretation. SQL may be an empty string for a text-only saved query. + +```json +{ + "cfg": { + "type": "text", + "content": "## Interpretation\n\nRevenue grew while customer concentration declined." + } +} +``` + +Do not use text tiles to conceal missing analysis. Keep narrative tied to the actual metrics shown. diff --git a/skills/sql-browser-dashboard/references/save-dashboard-tool.md b/skills/sql-browser-dashboard/references/save-dashboard-tool.md new file mode 100644 index 00000000..b852f33e --- /dev/null +++ b/skills/sql-browser-dashboard/references/save-dashboard-tool.md @@ -0,0 +1,83 @@ +# Altinity MCP `save_dashboard` contract + +## Skill-visible tool + +The skill requires one Altinity MCP dynamic tool with the exact basename: + +```text +save_dashboard +``` + +Recommended reflected signature: + +```text +save_dashboard(payload: string) +``` + +`payload` is the complete JSON-encoded SQL Browser PortableBundleV2 containing exactly one Dashboard and its exact query dependency closure. + +A single-payload tool is preferred over duplicating `dashboard_id`, title, version, and counts as caller-supplied arguments. The storage write path can derive catalogue metadata from the validated bundle, preventing metadata drift or spoofing. + +## Server-side reflection pattern + +Follow the BentoClick pattern: + +1. expose a narrow write relation suitable for Altinity MCP reflection; +2. use primitive reflected column types (`String` for JSON text); +3. grant only the required column-level `INSERT` privilege to the MCP writer role; +4. route writes through a validating `SQL SECURITY DEFINER` materialized view; +5. keep direct `INSERT` on the readable catalogue table unavailable to ordinary writers; +6. let the server derive author and version metadata. + +Conceptual objects: + +```text +asb.dashboards_raw Null engine; reflected as save_dashboard(payload String) + │ + ▼ +asb.dashboards_mv validates/extracts bundle metadata under definer privileges + │ + ▼ +asb.dashboards versioned read catalogue consumed by SQL Browser +``` + +The exact Altinity MCP deployment YAML is deployment-specific and intentionally outside this skill. Configure the dynamic tool alias so the reflected write relation appears to agents as `save_dashboard`. + +## Catalogue metadata derived by the server + +From the payload: + +- `dashboard_id` = the only Dashboard document's `id`; +- `title` = the Dashboard document's `title`; +- `description` = the Dashboard document's description or empty string; +- `bundle_version` = top-level bundle version; +- `query_count` = top-level query array length; +- `tile_count` = Dashboard tile array length; +- `payload` = original validated JSON text. + +From server/session defaults: + +- `version` = generated immutable `UInt64` version; +- `saved_at` = timestamp derived from or stored with the version; +- `saved_by` = authenticated ClickHouse/MCP user. + +## Minimum write-path checks + +The server-side write path should reject at least: + +- malformed JSON; +- wrong bundle format or version; +- zero or multiple Dashboards; +- missing Dashboard ID/title; +- count fields inconsistent with the payload if duplicated anywhere; +- payload above the deployment's accepted byte bound. + +SQL Browser still validates the complete bundle as untrusted input during catalogue import. The materialized view is a write-path guard, not a replacement for the browser decoder. + +## Invocation rules + +- Call once per complete Dashboard publication. +- Never send one request per query or tile. +- Never include credentials or connection details inside the payload. +- Do not automatically retry ambiguous failures. +- Do not claim a version unless returned explicitly by the tool. diff --git a/skills/sql-browser-dashboard/references/sql-browser-dashboard-authoring.schema.json b/skills/sql-browser-dashboard/references/sql-browser-dashboard-authoring.schema.json new file mode 100644 index 00000000..b8db5ff5 --- /dev/null +++ b/skills/sql-browser-dashboard/references/sql-browser-dashboard-authoring.schema.json @@ -0,0 +1,297 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://altinity.com/schemas/skills/sql-browser-dashboard-authoring-v1.schema.json", + "title": "SQL Browser Dashboard skill authoring profile", + "description": "A strict, self-contained authoring profile for one SQL Browser PortableBundleV2 Dashboard. SQL Browser production schemas remain authoritative.", + "type": "object", + "required": ["format", "version", "exportedAt", "queries", "dashboards"], + "properties": { + "$schema": { + "const": "https://altinity.com/schemas/altinity-sql-browser/portable-bundle-v2.schema.json" + }, + "format": { + "const": "altinity-sql-browser/portable-bundle" + }, + "version": { + "const": 2 + }, + "exportedAt": { + "type": "string", + "format": "date-time" + }, + "metadata": { + "type": "object", + "properties": { + "name": { "type": "string", "maxLength": 512 }, + "description": { "type": "string", "maxLength": 16384 } + }, + "additionalProperties": false + }, + "queries": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { "$ref": "#/$defs/savedQuery" } + }, + "dashboards": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": { "$ref": "#/$defs/dashboard" } + } + }, + "additionalProperties": false, + "$defs": { + "nonBlankId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "pattern": "\\S" + }, + "fieldConfigValue": { + "type": "object", + "properties": { + "displayName": { "type": "string" }, + "description": { "type": "string" }, + "unit": { "type": "string" }, + "decimals": { "type": "integer", "minimum": 0, "maximum": 20 }, + "color": { "type": "string" }, + "noValue": { "type": "string" }, + "hidden": { "type": "boolean" }, + "delta": { + "type": "object", + "properties": { + "displayName": { "type": "string" }, + "unit": { "type": "string" }, + "decimals": { "type": "integer", "minimum": 0, "maximum": 20 }, + "positiveIsGood": { "type": "boolean" }, + "show": { "type": "boolean" } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "fieldConfig": { + "type": "object", + "properties": { + "defaults": { "$ref": "#/$defs/fieldConfigValue" }, + "columns": { + "type": "object", + "additionalProperties": { "$ref": "#/$defs/fieldConfigValue" } + } + }, + "additionalProperties": true + }, + "chartStyle": { + "type": "object", + "additionalProperties": true + }, + "chartCfg": { + "type": "object", + "required": ["type", "x", "y"], + "properties": { + "type": { "enum": ["bar", "hbar", "line", "area", "pie"] }, + "x": { "type": "integer", "minimum": 0 }, + "y": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { "type": "integer", "minimum": 0 } + }, + "series": { + "type": ["integer", "null"], + "minimum": 0 + }, + "style": { "$ref": "#/$defs/chartStyle" } + }, + "additionalProperties": true, + "allOf": [ + { + "if": { "properties": { "type": { "const": "pie" } }, "required": ["type"] }, + "then": { "properties": { "y": { "maxItems": 1 } } } + } + ] + }, + "panelCfg": { + "oneOf": [ + { "$ref": "#/$defs/chartCfg" }, + { + "type": "object", + "required": ["type"], + "properties": { "type": { "const": "kpi" } }, + "additionalProperties": true + }, + { + "type": "object", + "required": ["type"], + "properties": { "type": { "const": "table" } }, + "additionalProperties": true + }, + { + "type": "object", + "required": ["type"], + "properties": { + "type": { "const": "logs" }, + "time": { "type": "string", "minLength": 1 }, + "msg": { "type": "string", "minLength": 1 }, + "level": { "type": "string", "minLength": 1 } + }, + "additionalProperties": true + }, + { + "type": "object", + "required": ["type", "content"], + "properties": { + "type": { "const": "text" }, + "content": { "type": "string" } + }, + "additionalProperties": true + } + ] + }, + "sizeHints": { + "type": "object", + "properties": { + "preferred": { "enum": ["compact", "medium", "wide"] }, + "minimum": { "enum": ["compact", "medium", "wide"] }, + "aspectRatio": { "type": "number", "exclusiveMinimum": 0 } + }, + "additionalProperties": true + }, + "savedQuery": { + "type": "object", + "required": ["id", "sql", "specVersion", "spec"], + "properties": { + "id": { "$ref": "#/$defs/nonBlankId" }, + "sql": { "type": "string" }, + "specVersion": { "const": 1 }, + "spec": { + "type": "object", + "required": ["name", "view", "panel", "dashboard"], + "properties": { + "name": { "type": "string", "minLength": 1, "pattern": "\\S" }, + "description": { "type": "string" }, + "favorite": { "type": "boolean" }, + "view": { "const": "panel" }, + "panel": { + "type": "object", + "required": ["cfg"], + "properties": { + "cfg": { "$ref": "#/$defs/panelCfg" }, + "key": { "type": ["string", "null"] }, + "fieldConfig": { "$ref": "#/$defs/fieldConfig" } + }, + "additionalProperties": true + }, + "dashboard": { + "type": "object", + "required": ["role"], + "properties": { + "role": { "const": "panel" }, + "defaultVariant": { "type": "string", "minLength": 1, "maxLength": 256 }, + "variants": { + "type": "object", + "maxProperties": 32, + "additionalProperties": { "type": "object" } + }, + "sizeHints": { "$ref": "#/$defs/sizeHints" } + }, + "additionalProperties": true + }, + "timeRanges": { + "type": "array", + "maxItems": 1, + "items": { + "type": "object", + "required": ["from", "to"], + "properties": { + "from": { "type": "string", "minLength": 1 }, + "to": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "tile": { + "type": "object", + "required": ["id", "queryId"], + "properties": { + "id": { "$ref": "#/$defs/nonBlankId" }, + "queryId": { "$ref": "#/$defs/nonBlankId" }, + "title": { "type": "string", "maxLength": 512 }, + "description": { "type": "string", "maxLength": 16384 }, + "presentation": { + "type": "object", + "properties": { + "variant": { "type": "string", "minLength": 1, "maxLength": 256 }, + "override": { "type": "object" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "flowPlacement": { + "type": "object", + "properties": { + "span": { "enum": [1, 2, 3] }, + "height": { "enum": ["compact", "medium", "large"] } + }, + "additionalProperties": false + }, + "flowLayout": { + "type": "object", + "required": ["type", "version", "preset", "items"], + "properties": { + "type": { "const": "flow" }, + "version": { "const": 1 }, + "preset": { "enum": ["report", "columns-2", "columns-3"] }, + "items": { + "type": "object", + "maxProperties": 100, + "additionalProperties": { "$ref": "#/$defs/flowPlacement" } + } + }, + "additionalProperties": false + }, + "variableConfig": { + "type": "object", + "required": ["sql"], + "properties": { + "sql": { "type": "string", "minLength": 1, "maxLength": 1048576 }, + "lastKnownType": { "type": "string", "maxLength": 256 } + }, + "additionalProperties": false + }, + "dashboard": { + "type": "object", + "required": ["documentVersion", "id", "title", "revision", "layout", "tiles"], + "properties": { + "documentVersion": { "const": 2 }, + "id": { "$ref": "#/$defs/nonBlankId" }, + "title": { "type": "string", "maxLength": 512 }, + "description": { "type": "string", "maxLength": 16384 }, + "revision": { "type": "integer", "minimum": 1 }, + "layout": { "$ref": "#/$defs/flowLayout" }, + "tiles": { + "type": "array", + "minItems": 1, + "maxItems": 100, + "items": { "$ref": "#/$defs/tile" } + }, + "variableConfigs": { + "type": "object", + "maxProperties": 32, + "additionalProperties": { "$ref": "#/$defs/variableConfig" } + } + }, + "additionalProperties": false + } + } +} diff --git a/skills/sql-browser-dashboard/scripts/validate_bundle.py b/skills/sql-browser-dashboard/scripts/validate_bundle.py new file mode 100755 index 00000000..31d37ff0 --- /dev/null +++ b/skills/sql-browser-dashboard/scripts/validate_bundle.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""Validate the strict SQL Browser Dashboard skill authoring profile. + +This validator is intentionally self-contained and uses only Python's standard +library. It checks the profile's cross-resource semantics in addition to basic +shape constraints. SQL Browser's production decoder remains authoritative. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + +SUPPORTED_PANELS = {"bar", "hbar", "line", "area", "pie", "kpi", "table", "logs", "text"} +CHART_PANELS = {"bar", "hbar", "line", "area", "pie"} +FLOW_PRESETS = {"report", "columns-2", "columns-3"} +FLOW_HEIGHTS = {"compact", "medium", "large"} +PARAM_RE = re.compile(r"\{([^{}:]+):([^{}]+)\}") + + +class ValidationError(Exception): + pass + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValidationError(message) + + +def non_blank(value: Any, label: str) -> str: + require(isinstance(value, str) and bool(value.strip()), f"{label} must be a non-blank string") + return value + + +def validate_bundle(bundle: Any) -> dict[str, Any]: + require(isinstance(bundle, dict), "bundle must be a JSON object") + require(bundle.get("format") == "altinity-sql-browser/portable-bundle", "bundle.format must be altinity-sql-browser/portable-bundle") + require(bundle.get("version") == 2, "bundle.version must be 2") + non_blank(bundle.get("exportedAt"), "bundle.exportedAt") + + queries = bundle.get("queries") + dashboards = bundle.get("dashboards") + require(isinstance(queries, list) and 1 <= len(queries) <= 100, "bundle.queries must contain 1..100 queries") + require(isinstance(dashboards, list) and len(dashboards) == 1, "bundle.dashboards must contain exactly one Dashboard") + + query_by_id: dict[str, dict[str, Any]] = {} + inferred_variables: dict[str, set[str]] = {} + + for index, query in enumerate(queries): + path = f"queries[{index}]" + require(isinstance(query, dict), f"{path} must be an object") + qid = non_blank(query.get("id"), f"{path}.id") + require(qid not in query_by_id, f"duplicate query id: {qid}") + require(isinstance(query.get("sql"), str), f"{path}.sql must be a string") + require(query.get("specVersion") == 1, f"{path}.specVersion must be 1") + + spec = query.get("spec") + require(isinstance(spec, dict), f"{path}.spec must be an object") + non_blank(spec.get("name"), f"{path}.spec.name") + require(spec.get("view") == "panel", f"{path}.spec.view must be panel") + + panel = spec.get("panel") + require(isinstance(panel, dict), f"{path}.spec.panel must be an object") + cfg = panel.get("cfg") + require(isinstance(cfg, dict), f"{path}.spec.panel.cfg must be an object") + panel_type = cfg.get("type") + require(panel_type in SUPPORTED_PANELS, f"{path} uses unsupported panel type {panel_type!r}") + + if panel_type in CHART_PANELS: + require(isinstance(cfg.get("x"), int) and cfg["x"] >= 0, f"{path} chart x must be a zero-based integer") + y = cfg.get("y") + require(isinstance(y, list) and y and all(isinstance(v, int) and v >= 0 for v in y), f"{path} chart y must be a non-empty array of zero-based integers") + require(len(y) == len(set(y)), f"{path} chart y indexes must be unique") + if panel_type == "pie": + require(len(y) == 1, f"{path} pie panel must have exactly one y index") + series = cfg.get("series") + require(series is None or (isinstance(series, int) and series >= 0), f"{path} chart series must be null or a zero-based integer") + + if panel_type == "text": + require(isinstance(cfg.get("content"), str), f"{path} text panel requires string content") + + dashboard_spec = spec.get("dashboard") + require(isinstance(dashboard_spec, dict), f"{path}.spec.dashboard must be an object") + require(dashboard_spec.get("role") == "panel", f"{path}.spec.dashboard.role must be panel") + + query_by_id[qid] = query + for name, ch_type in PARAM_RE.findall(query["sql"]): + inferred_variables.setdefault(name, set()).add(ch_type) + + dashboard = dashboards[0] + require(isinstance(dashboard, dict), "dashboards[0] must be an object") + require(dashboard.get("documentVersion") == 2, "Dashboard documentVersion must be 2") + dashboard_id = non_blank(dashboard.get("id"), "Dashboard id") + title = non_blank(dashboard.get("title"), "Dashboard title") + require(isinstance(dashboard.get("revision"), int) and dashboard["revision"] >= 1, "Dashboard revision must be an integer >= 1") + + tiles = dashboard.get("tiles") + require(isinstance(tiles, list) and 1 <= len(tiles) <= 100, "Dashboard tiles must contain 1..100 entries") + tile_ids: set[str] = set() + referenced_query_ids: set[str] = set() + for index, tile in enumerate(tiles): + path = f"dashboard.tiles[{index}]" + require(isinstance(tile, dict), f"{path} must be an object") + tile_id = non_blank(tile.get("id"), f"{path}.id") + require(tile_id not in tile_ids, f"duplicate tile id: {tile_id}") + tile_ids.add(tile_id) + query_id = non_blank(tile.get("queryId"), f"{path}.queryId") + require(query_id in query_by_id, f"{path}.queryId references missing query {query_id!r}") + referenced_query_ids.add(query_id) + + require(set(query_by_id) == referenced_query_ids, "bundle queries must equal the exact tile query dependency closure") + + layout = dashboard.get("layout") + require(isinstance(layout, dict), "Dashboard layout must be an object") + require(layout.get("type") == "flow" and layout.get("version") == 1, "Dashboard layout must be flow@1") + require(layout.get("preset") in FLOW_PRESETS, "Dashboard flow preset must be report, columns-2, or columns-3") + items = layout.get("items") + require(isinstance(items, dict), "Dashboard layout.items must be an object") + require(set(items) == tile_ids, "generated Dashboard layout.items must contain exactly every tile id") + for tile_id, placement in items.items(): + require(isinstance(placement, dict), f"layout placement for {tile_id} must be an object") + if "span" in placement: + require(placement["span"] in {1, 2, 3}, f"layout span for {tile_id} must be 1, 2, or 3") + if "height" in placement: + require(placement["height"] in FLOW_HEIGHTS, f"layout height for {tile_id} is invalid") + + for name, types in inferred_variables.items(): + require(len(types) == 1, f"variable {name!r} is declared with inconsistent ClickHouse types: {sorted(types)}") + + variable_configs = dashboard.get("variableConfigs", {}) + require(isinstance(variable_configs, dict), "Dashboard variableConfigs must be an object") + for name, config in variable_configs.items(): + require(name in inferred_variables, f"variableConfigs contains orphaned key {name!r}") + require(isinstance(config, dict), f"variable config {name!r} must be an object") + sql = non_blank(config.get("sql"), f"variableConfigs.{name}.sql") + require(not PARAM_RE.search(sql), f"variable option SQL for {name!r} must not reference Dashboard variables") + + return { + "dashboard_id": dashboard_id, + "title": title, + "query_count": len(queries), + "tile_count": len(tiles), + "variables": sorted(inferred_variables), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("bundle", type=Path, help="PortableBundleV2 JSON file") + parser.add_argument("--normalized-out", type=Path, help="Write deterministic compact JSON after validation") + args = parser.parse_args() + + try: + raw = args.bundle.read_text(encoding="utf-8") + bundle = json.loads(raw) + summary = validate_bundle(bundle) + if args.normalized_out: + args.normalized_out.write_text( + json.dumps(bundle, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + "\n", + encoding="utf-8", + ) + except (OSError, json.JSONDecodeError, ValidationError) as exc: + print(f"INVALID: {exc}", file=sys.stderr) + return 1 + + print( + "VALID: " + f"{summary['title']} ({summary['dashboard_id']}); " + f"{summary['query_count']} queries, {summary['tile_count']} tiles, " + f"variables={summary['variables']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())