Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
2f4bc3b
Add read-only Repl plan exploration pilot
autocarl Jul 15, 2026
4c4678b
fix(repl): harden plan exploration pilot
autocarl Jul 16, 2026
d8f90c3
docs(repl): clarify pilot guarantees
autocarl Jul 16, 2026
4ab01f1
Harden plan analysis resource handling
autocarl Jul 16, 2026
5c84769
Preserve CLI App and MCP compatibility
autocarl Jul 16, 2026
3de4520
Document the bounded REPL pilot
autocarl Jul 16, 2026
10419f9
fix(core): enforce plan resource boundaries
autocarl Jul 16, 2026
48c50c1
fix(app): preserve shared analysis contracts
autocarl Jul 16, 2026
6908fe6
docs: align repl routing and resource guarantees
autocarl Jul 16, 2026
ce89626
test(core): keep cancellation analysis clean
autocarl Jul 16, 2026
490ad95
fix(core): bind preflight to decoded plan bytes
autocarl Jul 16, 2026
7b1dadc
fix(mcp): bound client roots negotiation
autocarl Jul 16, 2026
03026e3
fix(app): preserve query store tool behavior
autocarl Jul 16, 2026
dc03dc1
fix(core): propagate cancellation through plan parsing
autocarl Jul 16, 2026
91f8617
fix: enforce shared budgets and cancellable MCP analysis
autocarl Jul 16, 2026
1372214
fix: restore desktop MCP compatibility and simplify sessions
autocarl Jul 16, 2026
8b12614
Merge dev into agent/repl-readonly-pilot (resolve csproj version conf…
erikdarlingdata Jul 25, 2026
f9c50cc
Make the historical CLI hash test platform-independent
erikdarlingdata Jul 25, 2026
4ba94ee
Merge pull request #388 from autocarl/agent/repl-readonly-pilot
erikdarlingdata Jul 25, 2026
d051c5f
Merge remote-tracking branch 'origin/main' into fix/dependabot-target…
erikdarlingdata Jul 25, 2026
ebdabb3
Point Dependabot at dev, and sync main back into dev
erikdarlingdata Jul 25, 2026
fd00422
Merge pull request #409 from erikdarlingdata/fix/dependabot-target-dev
erikdarlingdata Jul 25, 2026
024adfe
Hold Repl packages to patch bumps only
erikdarlingdata Jul 25, 2026
f29201b
Never publish a release before signing; harden PlanShare
erikdarlingdata Jul 25, 2026
fdd038e
Merge pull request #410 from erikdarlingdata/fix/dependabot-repl-pin
erikdarlingdata Jul 25, 2026
e6fc564
Fix two rate-limiter defects the PR review caught
erikdarlingdata Jul 25, 2026
653af54
Let the dashboard authenticate when STATS_TOKEN is set
erikdarlingdata Jul 26, 2026
092b7f8
Bootstrap the stats token from a URL fragment, not a query string
erikdarlingdata Jul 26, 2026
d658d43
Merge pull request #411 from erikdarlingdata/fix/release-order-and-pl…
erikdarlingdata Jul 26, 2026
916f79a
Bump version to 1.19.0
erikdarlingdata Jul 26, 2026
6bbe939
Merge pull request #412 from erikdarlingdata/release/v1.19.0
erikdarlingdata Jul 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,14 @@ version: 2
# or PlanViewer.Ssms.Installer, so any solution-level scan silently skips them.
# Every project directory is therefore listed explicitly below. If a new .csproj
# is added anywhere, add its directory here too.
#
# target-branch is REQUIRED on every entry here. Without it Dependabot opens PRs
# against the *default* branch, which is `main` — and in this repo `main` is only
# ever updated by a dev -> main release merge. Omitting it once already put two
# dependency bumps directly onto `main`, behind `dev`.
updates:
- package-ecosystem: nuget
target-branch: dev
directories:
- "/src/PlanViewer.App"
- "/src/PlanViewer.Core"
Expand Down Expand Up @@ -74,7 +80,27 @@ updates:
# one is handled by hand.
- dependency-name: "Velopack"

# Repl / Repl.Mcp / Repl.Testing (#388) are pre-1.0. Under semver a 0.x
# minor bump is allowed to break, and Dependabot classifies 0.11 -> 0.12
# as a *minor*, so the usual "majors only get their own PR" split does not
# protect anything here. Patch bumps still flow, so genuine fixes arrive.
# This matters more than for a normal dependency: the app ships an
# auto-updater, so a breaking bump reaches users without them choosing it.
- dependency-name: "Repl"
update-types:
- version-update:semver-major
- version-update:semver-minor
- dependency-name: "Repl.Mcp"
update-types:
- version-update:semver-major
- version-update:semver-minor
- dependency-name: "Repl.Testing"
update-types:
- version-update:semver-major
- version-update:semver-minor

- package-ecosystem: github-actions
target-branch: dev
directory: "/"
schedule:
interval: weekly
Expand Down
68 changes: 40 additions & 28 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,11 @@ jobs:
echo "EXISTS=false" >> $GITHUB_OUTPUT
fi

- name: Create release
if: steps.check.outputs.EXISTS == 'false'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "v${{ steps.version.outputs.VERSION }}" --title "v${{ steps.version.outputs.VERSION }}" --generate-notes --target main
# NOTE: the release is deliberately NOT created here. It is created after
# signing succeeds, further down. Creating it up front meant a signing
# failure left a published, publicly-visible release with no app assets -
# which is exactly what happened to v1.17.0 when the SignPath approval
# window lapsed. Nothing before the signing step publishes anything now.

- name: Setup .NET 10.0
uses: actions/setup-dotnet@v6
Expand Down Expand Up @@ -103,29 +101,12 @@ jobs:
"BUILT=true" >> $env:GITHUB_OUTPUT
Write-Host "SSMS extension built: $vsix"
} else {
Write-Host "::warning::SSMS extension did not produce the expected artifacts - release published without the VSIX (issue 343)"
Write-Host "::warning::SSMS extension did not produce the expected artifacts - the release will be published without the VSIX (issue 343)"
}

- name: Upload SSMS extension to release
if: steps.ssms.outputs.BUILT == 'true'
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.VERSION }}
run: gh release upload "v$env:VERSION" releases/PlanViewer.Ssms.vsix releases/InstallSsmsExtension.exe --clobber

# Publish to the SSMS Gallery (https://ssmsgallery.azurewebsites.net, issue 343).
# The gallery extracts version/description from the vsixmanifest, so the
# upload filename is irrelevant. continue-on-error: a gallery outage must
# never fail the release. bash so `curl` is curl.exe, not the pwsh alias.
- name: Publish SSMS extension to SSMS Gallery
if: steps.ssms.outputs.BUILT == 'true'
continue-on-error: true
shell: bash
run: |
echo "Uploading PlanViewer.Ssms.vsix to the SSMS Gallery..."
curl -sS -f "https://ssmsgallery.azurewebsites.net/api/upload" \
-F "file=@releases/PlanViewer.Ssms.vsix"
# The VSIX is built above but NOT uploaded here — uploading requires the
# release to exist, and the release is not created until signing succeeds.
# Both uploads happen after the signing step.

# ── SignPath code signing (Windows). Signing is REQUIRED for a release:
# if the SignPath token is missing the job fails loudly rather than
Expand Down Expand Up @@ -172,6 +153,37 @@ jobs:
Remove-Item -Recurse -Force publish/win-x64
Copy-Item -Recurse signed/win-x64 publish/win-x64

# ── Everything above this line is reversible: nothing has been published.
# The release is created only now that signed binaries exist. ──
- name: Create release
if: steps.check.outputs.EXISTS == 'false'
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "v${{ steps.version.outputs.VERSION }}" --title "v${{ steps.version.outputs.VERSION }}" --generate-notes --target main

- name: Upload SSMS extension to release
if: steps.ssms.outputs.BUILT == 'true'
shell: pwsh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.VERSION }}
run: gh release upload "v$env:VERSION" releases/PlanViewer.Ssms.vsix releases/InstallSsmsExtension.exe --clobber

# Publish to the SSMS Gallery (https://ssmsgallery.azurewebsites.net, issue 343).
# The gallery extracts version/description from the vsixmanifest, so the
# upload filename is irrelevant. continue-on-error: a gallery outage must
# never fail the release. bash so `curl` is curl.exe, not the pwsh alias.
- name: Publish SSMS extension to SSMS Gallery
if: steps.ssms.outputs.BUILT == 'true'
continue-on-error: true
shell: bash
run: |
echo "Uploading PlanViewer.Ssms.vsix to the SSMS Gallery..."
curl -sS -f "https://ssmsgallery.azurewebsites.net/api/upload" \
-F "file=@releases/PlanViewer.Ssms.vsix"

# ── Velopack (uses signed Windows binaries) ───────────────────────
- name: Create Velopack release (Windows)
shell: pwsh
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ authors:
website: "https://erikdarling.com"
repository-code: "https://github.com/erikdarlingdata/PerformanceStudio"
license: MIT
version: "1.18.0"
version: "1.19.0"
date-released: "2026-07-25"
keywords:
- sql-server
Expand Down
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ dotnet build
To verify the build:

```bash
dotnet test tests/PlanViewer.Core.Tests # 37 tests should pass
dotnet test PlanViewer.sln
dotnet run --project src/PlanViewer.Cli -- analyze --help
```

Expand All @@ -149,6 +149,41 @@ planview analyze my_query.sqlplan --output text
planview analyze my_query.sqlplan --output text --warnings-only
```

### Explore plans interactively (Repl pilot)

Run `planview repl` to start a long-lived plan-exploration session. Zero arguments and the
existing `analyze`, `query-store`, and `credential` commands continue to use the legacy
System.CommandLine graph.

```text
planview repl
> open slow-query.sqlplan
[plan slow-query-<opaque-id>]> summary
[plan slow-query-<opaque-id>]> warnings --severity critical
[plan slow-query-<opaque-id>]> operators --top 10
[plan slow-query-<opaque-id>]> missing-indexes --json
[plan slow-query-<opaque-id>]> close
```

The same graph also works in one-shot and MCP modes:

```bash
planview plan open slow-query.sqlplan --json --no-logo
planview repl # explicit interactive alias
planview mcp serve # stdio MCP server generated from the graph
```

Bare `planview` preserves the legacy System.CommandLine behavior. Interactive exploration is
entered only through `planview repl`; scripts can use explicit legacy or one-shot `plan` commands.

The pilot uses the stable `Repl` `0.11.0` release. Loaded plans live only for the lifetime of
the process; `open` and `close` change only that in-memory catalog and never write the
source plan. MCP `plan_open` validates and reads the same opened file handle inside client
roots, accepts only `.sqlplan` files, and uses the server launch directory only when native
roots are unsupported and no soft roots exist. Advertised empty roots deny access. MCP and
local mode share the 16 MiB file/2-concurrent-open/32-session resource limits. See
[`docs/repl-pilot.md`](docs/repl-pilot.md) for architecture, security, and compatibility notes.

### Capture and analyze plans from a live server

Connect to a SQL Server instance, run queries, and capture their execution plans automatically.
Expand Down
84 changes: 84 additions & 0 deletions docs/repl-pilot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Repl Read-only Pilot

## Goal

Add a Repl 0.11.0 command surface for loaded SQL Server plans while preserving the existing System.CommandLine CLI and its JSON contracts.

Read-only describes the analyzed plans and external systems: the pilot never writes a plan file, database, Query Store, or credential. `open` and `close` do mutate the process-local session catalog and are therefore not annotated as read-only MCP operations.

## Architecture

1. Put detached analysis snapshots plus `IPlanCatalog` and its thread-safe in-memory implementation in `PlanViewer.Core`. REPL sessions retain the analyzed projection rather than the mutable parser graph; desktop sessions keep their UI graph and expose a separately captured MCP projection.
2. Put typed, renderer-free operations (`open`, `close`, `list`, `summary`, `warnings`, `missing indexes`, `expensive operators`) and the shared parse/analyze/score pipeline in `PlanViewer.Core`.
3. Keep the Avalonia `PlanSessionManager` as a thin compatibility singleton over the Core catalog. Existing App MCP tools delegate to Core operations using the already-resolved session snapshot, avoiding a second catalog lookup while preserving their historical output behavior.
4. Add a Repl `IReplModule` in `PlanViewer.Cli`, pinned to the stable `Repl`/`Repl.Mcp`/`Repl.Testing` 0.11.0 release. Launch Repl only for explicit `repl`, `plan`, `open`, or `mcp` prefixes; zero arguments and existing prefixes remain on System.CommandLine.
5. Return typed values from Repl handlers so text, JSON, tests, and MCP are renderer concerns rather than handler concerns.

## Security and resource boundaries

Local CLI/Repl commands may open any accessible `.sqlplan` file, matching normal command-line file semantics. The generated MCP server applies a stricter policy:

- `plan_open` rejects lexically outside paths before probing them, opens an allowed candidate once, validates its kernel-resolved handle against resolved MCP roots, and parses that same handle. Containment is ordinal on Linux and case-insensitive on Windows and macOS to match their default filesystems and client URI casing; per-directory/per-volume case-sensitive configurations on Windows or macOS are outside this pilot's scope. NTFS alternate-data-stream paths are rejected.
- The server launch directory is a fallback root only when native roots are unsupported and no soft roots are configured. Advertised-but-empty, invalid, unavailable, or unresponsive roots deny access; native roots negotiation has a 5-second server-side deadline.
- MCP errors do not disclose absolute paths outside the allowed roots.
- Only an explicit allow-list of plan tools is exported; automatic read-only resource promotion is disabled.
- Files are read through a hard 16 MiB streaming ceiling even if they grow during loading. A forward-only XML preflight prohibits DTDs and rejects depth over 512, more than 10,000 structurally recognized statements/cursor operations, 100,000 operators, 250,000 total elements, or 1,000,000 total attributes before `XDocument` and the parser graph are materialized; the parsed graph is checked again before analysis. Streaming SHA-256 values bind the preflight and decoded passes to identical bytes, rejecting in-place content changes before materialization.
- The limit of 2 concurrent opens is shared by every `PlanOperations` facade over the same catalog; excess calls wait asynchronously, honor request cancellation, and do not resolve paths or acquire files until admitted. Admission is coordinated atomically across those facades, with at most 32 sessions and a 64 MiB aggregate retained-analysis estimate. The estimate is reserved after streaming preflight and conservatively accounts for UTF-16 source text, statements (2 KiB each), operators (4 KiB each), XML elements (256 bytes each), and attributes (128 bytes each); `plan_close` releases it.
- Warning responses return at most 500 items, missing-index responses at most 100 suggestions, and operator queries at most 100 items. The query paths count or rank by bounded top-K storage instead of materializing an additional full-tree result list. The generated CLI/MCP pilot admits at most 4 full-tree query traversals concurrently per catalog and rejects excess work; the existing desktop App MCP host remains exempt from that new cap. Result metadata reports truncation, and plan-controlled text fields are bounded before serialization.
- `plan_close` provides explicit eviction and releases the retained-memory reservation. Session labels are capped at 48 characters and use a fresh 12-hex-character opaque suffix; active-ID collisions are retried.
- Streaming preflight, direct bounded text decoding, per-statement/per-operator analysis, scoring, mapping, registration, and Repl query traversals honor cancellation. XML decoding reads the authorized stream directly with a pooled buffer rather than retaining a second byte-for-byte copy.

## TDD slices

- Catalog registration, bounded concurrent allocation, list, lookup, and removal.
- File-open success plus extension, missing, empty, malformed, oversized, and complexity failures.
- Summary and severity-filtered warning DTOs.
- Expensive-operator ranking and `top` validation.
- Missing-index result shape.
- Repl one-shot graph, close/eviction, and interactive session persistence.
- App MCP behavior characterization for warning scope, invalid severity handling, bare object names, numeric actual-stat defaults, and single-snapshot lookup.
- Real stdio MCP coverage for the exact tool allow-list, annotations, empty resource list, roots confinement, advertised empty-root denial, symbolic-link escape rejection, and an allowed open/summary round trip.
- Direct path-policy coverage pins same-handle validation/reading across a pathname swap where the platform permits the swap.

## Verification

- `dotnet build PlanViewer.sln -c Release`
- `dotnet test PlanViewer.sln -c Release`
- Compare legacy compact JSON output byte-for-byte against `upstream/dev` for a fixed `.sqlplan`.
- Run Repl one-shot and persistent-session smoke tests.
- Start the generated MCP server over stdio with a real `McpClient` and a bounded timeout.
- Run `git diff --check`, package-vulnerability inspection, and added-line secret/injection scans.

## Command graph

```text
open {path} # local convenience command; navigates to the plan
plan open {path} # canonical open command
plan list # process-local loaded plan sessions
plan {id} summary
plan {id} warnings [--severity Critical|Warning|Info]
plan {id} expensive-operators [--top 10]
plan {id} operators [--top 10] # local alias
plan {id} missing-indexes
plan {id} close # evict the in-memory session
```

Inside an interactive plan context, the `plan {id}` prefix is omitted. Handlers return typed DTOs; operation results carry stable `JsonPropertyName` annotations, while catalog summaries retain their existing property casing. Rendering is selected by Repl (`--json`, text, YAML, or Markdown) rather than performed by handlers.

The generated MCP server identifies itself as `planview`. It exports the canonical commands only; the root `open` convenience command and `operators` alias remain local.

## Compatibility boundary

- `analyze`, `query-store`, `credential`, and their existing options remain on the original System.CommandLine root.
- Legacy `analyze` keeps its existing input behavior. The pilot intentionally accepts only `.sqlplan` in `open`/`plan_open`; this narrow local-file boundary is not retrofitted onto historical commands.
- The Repl graph is selected only for explicit `repl`, `plan`, `open`, or `mcp` prefixes; zero arguments preserve legacy routing.
- A committed process test runs `analyze --compact` and compares the exact stdout SHA-256 with the pre-change baseline (`0c609fed8e250d9366eb9a6cd5eaf40b661ee30d7ba2546bd7726960592e9d87`).
- The Avalonia MCP session manager implements `IPlanCatalog`; committed App-side tests pin the historical warning scope, invalid-severity message, bare `object_name`, numeric default metrics, and snapshot behavior.
- The desktop MCP host constructs shared operations with `AnalyzerConfig.Default`, so an unrelated malformed `planview.config.json` cannot prevent that server from starting.
- Sessions are process-local. Query Store mutations and credentials are intentionally outside this pilot.

## Dependency status

The pilot is pinned to the stable `0.11.0` packages. The CLI, persistent-session, and real MCP tests exercise the Repl APIs used by this integration.

The Repl repository is MIT licensed. The `0.11.0` NuGet packages still omit license metadata; correcting `PackageLicenseExpression` requires a subsequent Repl package publication and remains an upstream packaging follow-up rather than being represented as fixed by this repository.
Loading
Loading