Skip to content

feat(index): versioned Bleve mapping with automatic migration and a real RebuildIndex - #1386

Merged
github-actions[bot] merged 21 commits into
mainfrom
claude/nostalgic-bell-8dfa1a
Sep 26, 2026
Merged

github-actions[bot] merged 21 commits into
mainfrom
claude/nostalgic-bell-8dfa1a

Conversation

@Dumbris

@Dumbris Dumbris commented Sep 26, 2026 •

Copy link
Copy Markdown
Member

Follow-up to #1378 (merged). It replaces that PR's rebuildIfMappingPredatesAnnotations with a versioned mapping and migration.

What

internal/index/bleve.go now versions the Bleve index mapping and migrates old indexes, and RebuildIndex actually rebuilds.

  • Detecting a stale index on open (indexStaleReason): Bleve saves the index's mapping as JSON (internal key _mapping) when it creates the index. On open, that saved copy is compared byte-for-byte with currentIndexMapping(), so any future mapping change is caught without a manual version bump.
  • Schema version: a separate indexSchemaVersion stamp covers changes to how documents are built (e.g. searchable_text), which the mapping can't show.
  • RebuildIndex is real (it was a logging no-op):
    1. Pages through every document with all stored fields.
    2. Re-derives searchable_text through the same searchableText() helper toolDocument uses.
    3. Writes a current-mapping index in <path>.rebuild and stamps the version last.
    4. Swaps it in.
  • Rebuild source: the index's own stored fields, not BBolt. Storage keeps no annotations or index hash, and copying stored fields keeps full_tool_name unchanged, so scores don't move (SC-005).
  • Mapping: annotations_json and output_schema_json are explicit stored-only fields, and Dynamic=false stays.

Why

bleve.Open keeps whatever mapping the index was created with. On indexes created before these two fields were mapped, Bleve's dynamic defaults indexed both into _all. The field-less MatchQuery in SearchTools therefore ranked the same corpus differently depending only on when the index was created. #1378's review rounds 2–3 confirmed this and deferred it.

What this replaces from #1378 (rounds 6–9)

rebuildIfMappingPredatesAnnotations is removed. Its guarantees are kept:

  • Round 8: a failed migration never fails startup. The stale index keeps serving, or an empty one does if the failure came after the swap, and the next open retries.
  • Round 9: a file inside store/ that can't be deleted can't strand the swap. The old index directory is renamed as a whole to a <dir>.retired-<n> sibling, which is atomic and needs write access to the parent only. The directory is then recreated and the delete afterwards is best-effort. Renaming entries inside the directory doesn't work: the macOS 15 CI runner refuses to rename a non-writable store/ even within its own parent.

It also fixes a gap in that function: moving the whole index.bleve aside and deleting it removed the nested profiles/ directory holding the per-profile indexes. profiles/ is now moved back before the delete, and if that fails, everything is put back. A crash between those two steps is repaired on the next open. Root Manager.RebuildIndex also closes open profile indexes before the rename, because Windows can't rename a directory with open files inside; ForProfile reopens them lazily.

Crash safety

  • index_meta.json is taken out first and moved in last. Any interruption leaves either the old index or a directory with no metadata, which the next open recreates empty; discovery then re-populates it.
  • A leftover .rebuild directory is removed on open.
  • If the swap fails after the old index was closed, an empty index is recreated rather than leaving the index unset, so later calls don't panic.

Behaviour change

output_schema_json is stored and returned but no longer searchable through the field-less query on any index, matching annotations_json (the round-6 decision). On main, output-schema text is still searchable that way on every index.

Tests (internal/index/bleve_mapping_migration_test.go)

  • (a) A legacy index is migrated on open with every field kept, and its search scores equal a freshly built index for the same tools.
  • (b) annotations_json can't be matched by a field-less query on fresh, migrated, or runtime-rebuilt indexes. A precondition check shows the legacy index does leak it, so the test can't pass trivially.
  • (c) output_schema_json is explicitly mapped, stored, and returned on fresh and migrated indexes.
  • Also covered:
    • a stale schema version alone triggers a rebuild;
    • per-profile indexes survive a shared-index migration;
    • recovery from an interrupted swap;
    • a stale .rebuild directory is removed;
    • Manager.RebuildIndex keeps documents, scores, and writability;
    • pagination past one page;
    • a failed swap leaves a usable index;
    • the two round-8/9 tests, ported, plus an unremovable store/ during interrupted-swap recovery;
    • profiles/ restored from a retired leftover after a crash;
    • a root rebuild with an open profile index.

Mutation checks: with the migration disabled, 6 tests fail. With the round-8 "keep serving" path made fatal, its test fails.

Verification

  • go test -race ./internal/index/ passes.
  • go build ./... and GOOS=windows go vet ./internal/index are clean.
  • golangci-lint v2 (.github/.golangci.yml) reports 0 issues, bare and with --build-tags server.
  • -race runs of runtime, server (CI skip regex), httpapi and appctx passed, before the last two index-only commits.
  • zcode (GLM-5.3) cross-review: round 1 found the nil-index-after-failed-swap problem, now fixed; rounds 2–5 were clean.
  • CI on de867e8 failed the two unremovable-store/ tests on macOS 15, which the whole-directory rename in the follow-up commits fixes.

Dumbris and others added 16 commits September 25, 2026 23:59
Every <dialog class="modal"> now opens via showModal()/close() through a
shared useDialogOpen composable instead of the `open` attribute or a
`modal-open` class toggle, so a modal can never be painted over by the
sidebar or header regardless of z-index (the Add Server modal bug, H4).
Adds frontend/src/assets/z-index.css as the single source for the
sidebar < header < dropdown < modal < toast scale and retires the
ad-hoc z-40 on the sidebar's drawer-side.

Also folds in two small header changes that touch the same files:
Add-to-MCPProxy button state and header layout groundwork.
Cross-surface UX fixes with no dependency on later PRs in the spec:

- `/` lands on the Overview panel (interim step before Home in a later
  PR); `/usage` and `/overview` stay deep-linkable (FR-051).
- Server detail and Settings tabs read `?tab=` on mount and write it
  back with router.replace on change, keeping other query params
  (FR-016).
- Settings is named "Settings" everywhere (sidebar, route title, H1,
  document title), its tabs use line icons instead of emoji, and the
  "Server Edition" tab is gated on the runtime edition
  (systemStore.status.edition, now carried on every SSE status frame)
  rather than on whether the config happens to have a server_edition
  key (FR-056).
- Tool review states use one vocabulary — Approved / New, needs review
  / Changed, needs review — with the `awaiting` filter value removed;
  the Tools page's "Needs review" stat is a link to /review, backed by
  interim redirects to /servers (?status=needs_review) and
  /servers/:name (?tab=tools) until a later PR ships the real review
  views (FR-027, T026a).
- One pure function, contracts.AnnotationTier, computes a tool's tier
  (read/write/destructive/unannotated) from its annotations; GET /tools
  and GET /servers/{id}/tools carry it, and the Web Tools page and CLI
  `tools list --tier` (`--risk` kept as an alias) use it instead of each
  deriving their own — the CLI's old `--risk` read a field
  (annotations.operation_type) that doesn't exist on a real payload, so
  it matched nothing (FR-028, X11).
- Count charts (calls per tool, activity over time) use integer ticks,
  and the excluded "never completed a call" group is titled "Calls to
  unknown tools" (FR-074).
- "Add to MCPProxy" (Repositories + CLI `registry add`), flipping to
  "Added ✓ · Open" after a successful add (FR-063).
- The header ProfileSwitcher is hidden until at least one profile
  exists (FR-057).
…PR a)

- ToolLabels.swift: one shared approval-state and tier label table
  (Approved / New, needs review / Changed, needs review; Read / Write /
  Destructive / Unannotated / Unknown), matching the Web/CLI vocabulary.
  ServerDetailView's approval badge and ToolsView's new tier badge both
  go through it.
- ServerTool/SearchTool decode the backend-computed `tier` field
  (contracts.AnnotationTier) rather than deriving one from raw
  annotations.
- ServerBrowseView: "Add to MCPProxy", flipping to "Added ✓ · Open"
  (opens the server) after a successful add.
make swagger, after adding contracts.Tier / Tool.Tier.
Verified findings against 109-a-quick-wins (PR-a of Spec 109).

FIXED:

- z-index: --z-header/--z-sidebar shipped inverted, so the sticky
  header painted over the open mobile drawer sidebar instead of the
  other way around (the same class of stacking bug this scale exists
  to prevent, on the header/sidebar pair instead of sidebar/modal).
  Swapped the token values, updated the doc comment and
  z-index-scale.spec.ts's ordering assertion.

- useDialogOpen: never listened for the native `close` event a
  showModal()-opened <dialog> fires on Escape, so the driving Vue
  state desynced from the DOM the first time a user hit Escape on
  Repositories.vue's three dialogs, UserTokens/UserServers/
  UserActivity's dialogs, OnboardingWizard or ConnectModal — the
  dialog stayed closed forever after that (no reopen). Added an
  `onClose` hook plus a `close` listener guarded against looping back
  on our own close() calls, wired every affected consumer's own
  close/reset function through it. AddServerModal/AddSecretModal
  (already safe via useModalA11y's capture-phase Escape handler) get
  the same wiring for defense in depth. New test:
  use-dialog-open-native-close.spec.ts.

- Search tier: GET /index/search always reported tier=unannotated
  regardless of a tool's real annotations, disagreeing with the same
  tool's tier on GET /servers/{id}/tools. The reported cause (a missing
  "annotations" key in server.searchResultsToMaps's tool map) was one
  level above the real one: the Bleve ToolDocument never stored
  annotations at all, so a search hit could not carry them no matter
  what the map-shaping code did. Added a stored (not indexed)
  annotations_json field to the Bleve schema, round-tripped through
  toolDocument/readToolMetadata, requested it in the shared search
  Fields list, and now surface it in searchResultsToMaps. New tests:
  bleve_annotations_test.go, search_tools_tier_test.go.

- Tools.vue: selectStatCard's toggle-to-total branch dropped the
  filterApproval reset that existed before this PR removed the old
  "pending" stat card. Total is the row's other "reset" gesture
  (clearFilters resets both filters too); restored it. New test:
  tools-total-card-resets-approval.spec.ts.

- macOS ServerBrowseView: the "Added checkmark Open" flow keyed
  addedServers on the registry catalog's display name, but the backend
  can assign a different name (empty name falls back to the entry id,
  a conflict gets de-duplicated) and echoes it in the response body,
  which AddServerResult discarded entirely. Now decoded and used,
  matching the Web UI's `result.server?.name || server.name`. New
  test: APIClientAddServerFromRegistryTests.swift.

- macOS ToolsView: the tier badge rendered nothing at all for a nil
  tier (an older core not yet sending the field), while the Web UI
  always renders one, defaulting to "Unannotated". Added
  ToolRow.displayTier and used it unconditionally. New tests in
  ToolLabelsTests.swift.

- cmd/mcpproxy/registry_cmd.go: registryAddErrorOutput's doc comment
  was glued to registryAddMessage by a missing blank line, hiding it
  from godoc. Reordered; cosmetic only.

REJECTED (false positive for this PR):

- scripts/test-api-e2e.sh's blanket `pkill -f "mcpproxy.*serve"` /
  `pkill -f "launcher-server.*--port 39933"` in its cleanup trap is a
  real, pre-existing operational risk (can kill another worktree's or
  the user's own running instance), but git blame shows these lines
  predate every commit in this PR and none of this PR's three commits
  touch the file. No tasks.md for Spec 108/109 exists in this repo to
  substantiate the finding's claim that removing them was a task this
  PR was itself scoped to do. Left alone here rather than fixed blind
  without the actual spec to guide the exact requirement (own-PID
  tracking vs. a decoy-process proof) on a script every worktree's E2E
  run depends on; flagged instead as a follow-up task.

DEFERRED (genuine, not implemented — infra, not a rejection):

- e2e/web-ui-sweep/visual-a11y-sweep.spec.ts has no elementFromPoint
  assertion proving the z-index fix above holds in a real browser.
  Adding one needs the real launcher (scripts/run-web-smoke.sh) and a
  live Playwright run to confirm it actually catches the regression
  rather than being vacuously true — this fix-and-verify pass could
  not do that locally, so an unverified test was not committed;
  flagged as a follow-up task instead.

Verification: go build ./... and -tags server both clean; go vet and
gofmt clean on touched files; go test -race on internal/index,
internal/server (CI skip regex, both bare and -tags server variants),
internal/httpapi, internal/contracts, cmd/mcpproxy, internal/config,
internal/oauth, internal/storage, internal/serveredition/* all green;
golangci-lint v2 (bare and --build-tags server) on touched packages
shows only 6 pre-existing staticcheck issues in test files this PR
never touched; frontend npm ci + npm run build (vue-tsc + vite) +
npx vitest run all green (147 files / 1393 tests, package-lock.json
unchanged); swift test full suite green except one known
environment-caused failure (AppLifecycleTests, caused by a real
~/.mcpproxy/tray-lifecycle.jsonl already present on this shared
machine from outside this session — unrelated to any file this PR
touches, reproduces identically before any of these changes).

Not pushed; branch left detached per instructions.
Verified each round-2 finding against current source before touching
anything; fixed the genuine ones test-first, deferred one with reasons
below. No false positives to reject this round.

FIXED:

1. (high) Repositories.vue: closeAddRegistry()/closeDeleteRegistry() are
   wired as useDialogOpen's native-close handler (Escape/backdrop), but
   their own addingRegistry/deletingRegistry guard silently skipped the
   showAddRegistry/showDeleteRegistry flip. A native close already happened
   in the DOM by the time that fires and cannot be undone, so the guard just
   desynced Vue state from an already-closed dialog — reopening became a
   no-op forever (round 1's H4 bug, reintroduced by round 1's own fix). Split
   the native-close handler from the Cancel-button handler; the Cancel/
   Delete-cancel buttons keep the guard (now via :disabled) since an
   explicit click CAN be blocked mid-submit.

2. (medium) OnboardingWizard.vue: dismiss() (also wired as useDialogOpen's
   onClose) awaited up to three sequential engagement-bookkeeping calls
   before emit('close'). props.show stayed true for that whole window, so
   reopening the wizard from the sidebar Setup entry was a no-op. emit
   synchronously first; the bookkeeping now runs decoupled in the
   background.

3. (low) ConnectModal.vue: close() (same onClose role) reset several result/
   preview fields but not disconnectTarget, so Escape during the "Disconnect
   X?" confirm sub-panel left it stale for the next open.

4. (medium-high) internal/runtime/lifecycle.go: annotations_json is only
   written at (re)index time, but the reindex trigger is a Hash comparison
   that deliberately EXCLUDES annotations (see calculateToolApprovalHash's
   own comment in tool_quarantine.go: annotations are unstable across
   reconnections, and hashing them in before caused false
   "tool_description_changed" spam on every reconnect). So an
   already-indexed, otherwise-unchanged tool could never pick up newly
   observed annotations short of a manual reindex. Fixed WITHOUT touching
   Hash/change-detection (which would reintroduce that exact regression):
   applyDifferentialToolUpdate now tracks annotation-only diffs separately
   and silently refreshes just those documents (no approval/quarantine
   state, no "Tool schema changed" log, no hash rewrite). Covered by
   TestApplyDifferentialToolUpdate_BackfillsAnnotationsForUnchangedTool and
   ...IsIdempotent.

6. (low-medium) RegistryModels.swift: RegistryAddServerErrorBody decoded a
   `message` key, but writeRegistryAddError (internal/httpapi/server.go)
   serializes the field as `error` — every other error envelope in this API
   uses the same shape. err?.message was therefore always nil against the
   real backend, so a failed Add-from-Registry always showed the generic
   "HTTP 400: ..." text instead of the actual reason. Fixed the CodingKeys
   mapping and added a test that asserts on the decoded message text (the
   round-1 test used a stub keyed `message`, which "passed" without ever
   checking the value — a green test over a broken decode).

7. (low) Same files: round 1's own comment/test claimed the backend
   "de-duplicates on conflict" for a name collision. It doesn't —
   AddServer (internal/server/server.go) hard-fails with duplicate_name; no
   rename/suffix logic exists anywhere in that path. Corrected the
   misleading comments in RegistryModels.swift and ServerBrowseView.swift,
   and reworked the test's success-case stub to model the one case that IS
   real (empty entry name falls back to the entry id) instead of an
   invented de-dup suffix.

8. (medium) Settings.vue: hasServerEdition depends on systemStore.status,
   populated asynchronously by App.vue's SSE connection in its own
   onMounted. On a reload of /settings?tab=teams, Settings.vue's onMounted
   could run its `?tab=` membership check before that status frame lands,
   silently dropping `teams` with no retry once it arrives. Added a
   one-shot retry gated on hasServerEdition, guarded so it never overrides
   a tab the user picks in the meantime and is a no-op when the edition
   really is personal.

DEFERRED (real, not a false positive, but out of scope for a quick-wins
fix round):

5. (low) internal/index/bleve.go: a pre-105/pre-this-PR on-disk index opens
   with its OWN persisted mapping, which never declared annotations_json —
   so that field falls to Bleve's dynamic-mapping default (Index=true,
   contributes to _all) on a legacy index, while a freshly created index
   gets the explicit Index=false mapping added in round 1. Confirmed real:
   this is genuine BM25 divergence between legacy and fresh indexes.
   NOT fixed here: the obvious-looking fix (disable dynamic mapping) is
   actively wrong — output_schema_json has NO explicit field mapping at all
   and already relies on the same dynamic-mapping fallback in every index,
   old and new alike; disabling it would silently stop storing that field
   on every newly created index. A correct fix needs an actual Bleve
   mapping-version/migration mechanism (there is none today — the
   OutputSchemaHashSchemaVersion precedent lives entirely in the BBolt
   approval-record store, not the search index), which is a properly scoped
   follow-up, not a round-2 quick fix.

Verification: go build ./... and -tags server; go test -race
./internal/runtime/... (192s, clean); golangci-lint v2 bare and
--build-tags server (only pre-existing, unrelated issues, e.g.
event_bus.go:887 reflect.Ptr); frontend npm run build + npx vitest run
(151 files, 1401 tests, all green; package-lock.json unmodified); swift
test (1179 tests, 1 failure — AppLifecycleTests.
testTheSharedJournalNeverWritesToTheRealInstanceRootUnderTests, a
documented pre-existing environmental flake tied to a real
~/.mcpproxy/tray-lifecycle.jsonl on this machine, unrelated to this diff
and already failing on main).
Verified each round-3 finding against current source before touching
anything; fixed the genuine one test-first, confirmed one is already
covered by existing tests, and reaffirmed one deferral. No false
positives to reject this round — all three findings described the
code accurately.

FIXED:

1. (medium, T011a) scripts/test-api-e2e.sh's cleanup trap used a
   blanket `pkill -f "mcpproxy.*serve"` / `pkill -f
   "launcher-server.*--port 39933"`. Round 1 rejected this same finding
   on the premise that no tasks.md substantiates it being an in-scope
   task for this PR; that premise was false — specs/109-ux-navigation-
   consistency/tasks.md (present on the sibling 109-ux-navigation-
   consistency branch, both branched from the same commit) lists T011a
   explicitly under "Phase 2: PR 109-a — quick-wins". Live-verified the
   actual risk before fixing: this machine had a real tray-managed
   `mcpproxy serve` (PID 15758) and a second, unrelated worktree's test
   instance running at the same time — the unfixed script's blanket
   pkill would have killed both. Replaced both blanket kills with
   `descendant_pids()`, which snapshots the actual OS-process
   descendants of $MCPPROXY_PID (recursively, via `pgrep -P`) BEFORE
   touching it, then reaps only those specific PIDs (and only if still
   alive) as a fallback for the launcher-test fixture getting orphaned
   before mcpproxy's own graceful-shutdown reap path runs. Added
   scripts/test-api-e2e-cleanup-check.sh (the check T011a specifies):
   starts a decoy `mcpproxy serve` on another port with a scratch data
   dir, runs the full E2E script, and asserts the decoy is still alive
   afterward. Ran it for real: 65/66 sub-tests passed (the one failure
   is the pre-existing, unrelated "server-edition binary present"
   check, which needs a separately built ./mcpproxy-server and fails
   identically on main), and the decoy — plus this machine's real tray
   core — survived the run.

2. (low-medium, T007 Playwright half) e2e/web-ui-sweep/
   visual-a11y-sweep.spec.ts had no browser-level proof that the H4
   z-index fix (frontend/src/assets/z-index.css) holds under real
   layout and paint, only jsdom token-ordering coverage. Added "mobile
   drawer sidebar paints above the sticky header, not under it (H4)":
   opens the mobile drawer against a real launched instance and asserts
   via `elementFromPoint` that the drawer, not the header, is the
   topmost element at a point they both occupy. Verified it actually
   catches the regression it targets, not just a code read: temporarily
   re-inverted --z-header/--z-sidebar back to round-1's shipped bug,
   rebuilt, and reran — the new test timed out failing exactly as
   expected; restored the tokens and reran the full 25-test suite
   clean. One real finding surfaced while building this: daisyUI's
   drawer opens via a CSS `visibility`/`opacity` transition
   (`allow-discrete`, ~0.2-0.3s), so `elementFromPoint` checked on the
   very next frame after the checkbox flips can still legitimately see
   the header for a couple of frames — normal opening animation, not
   the H4 regression (a permanently wrong settled z-index). The
   assertion polls for the settled state instead of asserting
   immediately, so it does not flake on that transition.

ALREADY COVERED (not a gap, no change needed):

- The finding's other T007 half — "a vitest asserting none of
  Repositories.vue's three dialogs still uses the `:open` binding" —
  already exists: frontend/tests/unit/z-index-scale.spec.ts (added in
  the original PR commit 8bc8d23de, kept through round 1) has both
  "Repositories.vue dialogs use showModal()/close(), not :open
  (FR-055)" (lines 71-88, checking exactly the `:open` binding and all
  three data-test ids) and a broader "every <dialog class=modal> drives
  its open state imperatively" sweep across all 8 affected components,
  Repositories.vue included. Wrote a duplicate test file first, then
  found this and deleted it rather than ship two guards for the same
  invariant.

DEFERRED (confirmed real, reaffirming round 2's deferral — not fixed
here):

3. internal/index/bleve.go: re-verified the pre-105/pre-this-PR
   legacy-index dynamic-mapping divergence against current source.
   annotations_json still has no on-disk mapping-version guard,
   RebuildIndex() is still a documented no-op, and output_schema_json
   still has no explicit field mapping of its own — so it's still true
   that disabling dynamic mapping to close this gap would silently stop
   indexing output_schema_json on every freshly created index, on top
   of every index that already exists today. Round 2's reasoning holds
   exactly as written: a correct fix needs an actual Bleve
   mapping-version/migration mechanism, which is properly scoped design
   work, not a quick-wins round change. Flagging a follow-up task for
   that design work separately rather than leaving it only in this
   commit message.

Verification: go build ./... and -tags server (-o /dev/null, per the
server-tags-build-overwrite gotcha) both clean. No Go, frontend/src, or
native code touched this round, so go test -race / vitest / swift test
are unaffected by this diff; ran golangci-lint v2 anyway (bare and
--build-tags server) — 16 and 19 pre-existing issues respectively, none
in any file this round touched (reflect.Ptr inlining, deprecated
TopK/Features test usage, deprecated ecdsa.PublicKey.X/Y, ParseDir
deprecation — all pre-existing on files untouched by any of the three
rounds). scripts/test-api-e2e-cleanup-check.sh and the new Playwright
test were both run for real against a locally built instance, not just
read.

Not pushed; branch left detached per instructions.
Round-4 zcode findings on the E2E cleanup trap (T011a) and a stale
Tools.vue stat-card bug. Confirmed genuine, fixed test-first:

- F3 (medium): descendant_pids had no test of its own. Extracted it to
  scripts/descendant-pids.sh (sourced by test-api-e2e.sh) and added
  scripts/descendant-pids.test.sh, a hermetic unit test that builds a
  real 3-level process tree and proves the recursive walk reaches a
  grandchild and great-grandchild, not just direct children. Also
  extended test-api-e2e-cleanup-check.sh to assert a REAL orphan of the
  run (the launcher-test fixture, left running by test_launcher_lifecycle
  Step 5) is actually gone after cleanup, not just that the decoy
  survives. Wired both scripts into the Makefile
  (test-descendant-pids, test-e2e-cleanup-check).
- F4 (medium): the audit_log sub-test's AUDIT_PID/AUDIT_DATA_DIR/
  AUDIT_JSONL_DIR/AUDIT_SERVER_LOG were only reaped on its own
  fall-through path; an early exit while that sub-test is running leaked
  them. cleanup() now reaps them too, idempotently.
- F1 (medium, partial fix): the descendant snapshot was taken once,
  before sending the kill signal, missing a child spawned during
  mcpproxy's own graceful-shutdown window. Now re-snapshotted on every
  poll of the wait loop while mcpproxy is confirmed still alive. The
  case where mcpproxy has already exited (crash/OOM) before cleanup()
  even runs is not fixable without reintroducing the system-wide pattern
  match T011a deliberately removed (kills unrelated mcpproxy instances)
  — documented as an accepted, narrow residual gap.
- F2 (low): the reap loop now records each descendant's command name at
  snapshot time and re-checks it before SIGKILL, so a PID reused by an
  unrelated process in the snapshot-to-reap window is skipped instead of
  killed.
- F6 (low): test-api-e2e-cleanup-check.sh's decoy port was a fixed
  default (only DECOY_DIR was made unique), colliding between concurrent
  runs. Now picks an OS-assigned free port.
- F7 (low): added --max-time to the two curl calls in
  test-api-e2e-cleanup-check.sh that lacked it, matching every curl call
  in test-api-e2e.sh.
- Tools.vue activeStatCard (low, flagged rounds 1 and 2, never actually
  fixed): it only inspected filterStatus, so an approval-only filter
  (filterStatus empty) still rang the Total stat card as active even
  though the table was filtered. Total now reads as active only when no
  filter narrows the table. New regression test:
  tools-active-stat-card-approval-only.spec.ts (reproduced the bug
  first, then fixed).

F5 (EXIT trap not firing on SIGINT) does not hold up under a faithful
reproduction: sending SIGINT to the actual process group (what a
terminal's Ctrl-C really delivers) already ran cleanup() correctly on
the pre-round-4 code, because the foreground child dies from the same
signal, bash's `wait` unblocks, and an untrapped terminating signal
still fires the EXIT trap. Verified this directly (group-wide kill -INT
and kill -TERM both fired the trap on the unmodified script; only a
single-PID kill -INT, which is not what a terminal sends, appeared to
hang, and that appearance was itself an artifact of test harness
backgrounding setting SIGINT to ignore). Kept explicit `trap ... INT
TERM` handlers anyway as harmless, idiomatic defense-in-depth for a
supervisor that signals only this process's PID, with an idempotent
cleanup() (CLEANUP_DONE guard) so it cannot double-run.

Verified locally: go build ./... clean (no Go files touched by this
commit); golangci-lint v2 (bare and --build-tags server) shows only
pre-existing unrelated findings; frontend build + full vitest run
(152 files / 1402 tests) green; scripts/descendant-pids.test.sh green;
a full scripts/test-api-e2e-cleanup-check.sh run against a real built
mcpproxy/mcpproxy-server passed all 70 E2E tests, confirmed the decoy
instance survived on a freshly-picked port, and confirmed the real
launcher-server orphan was reaped.
Verify each round-6 finding against the code before fixing; fix genuine
ones test-first, reject/defer two that are out of scope here.

1. internal/index/bleve.go (finding 1, high): an index created before the
   annotations_json field mapping existed keeps its old mapping forever
   after bleve.Open (no SetMapping in vendored bleve v2.6.1), so the
   differential-update backfill's annotations JSON gets indexed as free
   text via bleve's dynamic-field defaults, leaking into the field-less
   `_all` composite field and skewing BM25 ranking on any upgraded (not
   freshly installed) deployment. A full fix needs index-mapping-version
   tracking plus rebuild-from-storage, which doesn't exist yet
   (RebuildIndex is a stub) -- flagged as a follow-up task instead of
   building that here. Applied a bounded, tested mitigation:
   warnIfMappingPredatesAnnotations logs an actionable warning on open;
   toolMapping.Dynamic = false closes the whole bug class for indexes
   created from now on; and an explicit output_schema_json field mapping
   (previously undeclared, relying on the same dynamic fallback) closes a
   second, pre-existing instance of the same leak that surfaced only once
   Dynamic was turned off. New tests: bleve_mapping_migration_test.go.

2. cmd/mcpproxy/tools_cmd.go (finding 4, medium): --tier/--risk/--status/
   --approval were only ever applied on the global (no --server) tools-list
   path; `--server=x --tier destructive` silently printed every tool
   unfiltered. Client-mode (daemon-backed) now reuses
   applyGlobalToolFilters, since GET /api/v1/servers/{id}/tools shares
   enrichServerTools with the global endpoint and carries the same fields.
   Standalone (no-daemon) mode gets a new filterToolMetadataByTier for
   --tier (computed locally via contracts.AnnotationTier) and a fast,
   explicit error for --status/--approval, which need daemon-persisted
   state this path has no access to. A zcode re-review of this fix caught
   a residual message bug it introduced: emptying a non-empty standalone
   result via --tier fell into the "server doesn't support tools"
   diagnostic meant for a server with genuinely zero tools;
   standaloneNoToolsMessage now tells the two cases apart.

3. native/macos ServerBrowseView.swift / RegistryModels.swift (finding 2,
   medium): "Add to MCPProxy" -> "Added (checkmark) Open" state was tracked
   by bare server.id, while the same file's own cross-registry search
   dedupe already keys by "registry::id" (catalog ids collide across
   registries, MCP-866) -- two colliding cards from different registries
   could flip together. Added RepositoryServer.addedKey and switched both
   addedServers call sites to it. A zcode re-review found the same
   collision class still live in ForEach(results)'s SwiftUI identity and in
   addingID; fixed both, plus a stale doc comment that still described the
   bug this commit fixes.

4. frontend/src/components/AuthErrorModal.vue (finding 3, medium): the
   last dialog left on the old v-if/div modal pattern while its four
   siblings were migrated to the browser top layer (FR-055); a 401 raised
   while one of those is open painted underneath it. Migrated to a native
   <dialog> via the existing useDialogOpen/useModalA11y composables,
   deliberately without the click-catcher backdrop form the other dialogs
   use (clicking outside must not dismiss this one -- a pre-existing,
   audited rule). Replaced the onMounted form-reset with a
   watch(props.show), since the dialog no longer unmounts/remounts on
   every open/close the way the v-if version did.

5. scripts/test-api-e2e-cleanup-check.sh (finding 5, medium): the T011a
   proof's launcher-server pgrep check had no pre-run baseline, so a stray
   left by an earlier crashed run or a parallel worktree's own E2E run
   produced a false FAIL. Snapshots PIDs before running and diffs with
   comm -13 so only a genuinely new PID counts as this run's own leak.

Rejected (not fixed here):
- Finding 6 (specs/109-ux-navigation-consistency/tasks.md T026 vs macOS
  DashboardView.swift): tasks.md lives on branch docs/specs-108-109, not
  this branch, so it can't be edited from here. The same tasks.md's own
  "Follow-ups (not in this spec)" section already lists "a native macOS
  Usage view" as deferred future work, directly contradicting T026's
  in-scope macOS clause -- a spec/tasks inconsistency for whoever owns
  that branch to reconcile, not a reason to invent a new macOS chart
  feature inside a quick-wins PR. Flagged as a follow-up task.

Every fix is test-first: internal/index, cmd/mcpproxy and native/macos
each got new regression tests confirmed to fail before the fix (or, for
the macOS swift package which has no such build gate here, verified by
temporarily reverting and re-running) and pass after.

Reviewed with zcode (GLM), the mandated external reviewer for this
branch. It returned a clean pass on internal/index/bleve.go +
cmd/mcpproxy/tools_cmd.go (one residual message bug found and fixed, see
above), a clean pass with three genuine follow-ups on the macOS
ServerBrowseView/RegistryModels change (all three folded into this
commit), and a clean pass with no defects on
scripts/test-api-e2e-cleanup-check.sh. The AuthErrorModal.vue chunk could
not get a completed zcode pass despite five retries over ~20 minutes --
the shared zcode/GLM account was rate-limited (HTTP 429, "Rate limit
reached for requests") by concurrent sibling review sessions running the
same review round in parallel worktrees, not by anything in this diff.
Verified by hand instead, against the identical, already-shipped and
already-tested pattern in AddSecretModal.vue / AddServerModal.vue /
ConnectModal.vue / OnboardingWizard.vue, plus the full frontend build
(vue-tsc + vite) and vitest suite (152 files / 1402 tests, including
auth-single-surface.spec.ts) passing.

Local verification: go build ./...; go test -race on internal/index,
cmd/mcpproxy, internal/runtime (+ subpackages), and an internal/httpapi
tool/tier/search subset; golangci-lint v2 (bare and --build-tags server)
clean on every touched Go package, no new issues anywhere else; npm ci &&
npm run build && npx vitest run (152/152 files, 1402/1402 tests), then
package-lock.json left unchanged (no dependency changes); swift build +
swift test (one pre-existing, unrelated failure confirmed present with
and without this diff: AppLifecycleTests.
testTheSharedJournalNeverWritesToTheRealInstanceRootUnderTests).
Round 7 findings, verified against the code (zcode unavailable this
round: 5-hour usage limit, resets 10:35 — reviewed manually instead,
same as the finding's own note):

- frontend/src/composables/useDialogOpen.ts (high, confirmed): a
  showModal() dialog's own Escape handling fires a native `cancel`
  event and, unless that event is canceled, closes the dialog —
  independently of useModalA11y's document-level keydown listener,
  whose preventDefault() only affects the keydown event and does not
  stop the platform's cancel/close algorithm. AuthErrorModal relies on
  its close() being a no-op while canClose is false, but the native
  path ignored that gate entirely: Escape closed the <dialog> element
  regardless, desyncing it from isOpen() (same bricking shape as the
  existing H4 fix) and defeating the "must not be dismissable" intent
  the whole modal exists for. Fixed by always preventing the native
  `cancel` event in useDialogOpen and routing every Escape-driven close
  through the one close() callback that already owns the permission
  check, the same way useModalA11y's keydown handler does. Covered by
  frontend/tests/unit/use-dialog-open-cancel-prevented.spec.ts,
  confirmed red before the fix (dispatching a synthetic `cancel` event
  closed the dialog) and green after.

- internal/index/bleve.go (medium, confirmed, carried from round 6):
  round 6 only warned when an on-disk Bleve index predated the
  annotations_json field mapping, leaving an in-place upgrade with a
  silently stale mapping (and the resulting free-text/`_all` leakage)
  until an operator noticed the log line and deleted the directory by
  hand. Manager.RebuildIndex is a pre-existing unwired no-op stub, not
  a path to fix this. Replaced the warn-only function with one that
  closes the stale index, removes its directory, and creates a fresh
  one with the current mapping automatically — safe because
  index.bleve is a derived search cache, not a source of truth: an
  empty index makes every server's tools look newly discovered, and
  the normal discovery path (applyDifferentialToolUpdate) already
  reindexes everything as each server reconnects at startup, the same
  way it backfills a brand-new install. Extended
  bleve_mapping_migration_test.go with a regression test asserting the
  rebuilt index carries the current mapping and rejects free-text
  search on annotations_json, with no operator step.

- native/macos/MCPProxy/Views/DashboardView.swift (medium, carried
  from rounds 5-6): re-scoped rather than implemented. Spec 109 T026's
  "macOS DashboardView.swift equivalent label" describes the same
  integer-tick + "Calls to unknown tools" fix web's CallHistogram.vue
  got, but macOS has no per-tool call-histogram anywhere to attach it
  to — the file's only chart-like element (Token Distribution) is a
  per-server token-SIZE bar list with no ticked axis and no
  "unresolved tool name" concept, a different metric keyed by server
  name rather than call outcome. Spec 109's own tasks.md lists "a
  native macOS Usage view" as a Follow-up explicitly "not in this
  spec" — building one now would be new scope, not a quick win. Added
  an explanatory comment recording that scope decision next to
  tokenDistributionSection so this stops being silently re-flagged
  every round; tracked for the eventual macOS Usage view instead.

Verification: go build ./...; go test -race ./internal/index/...;
golangci-lint v2 (bare and --build-tags server) clean of new issues;
frontend npm ci + vitest run (153 files / 1405 tests green) + vue-tsc
build; swift build + swift test (1182 tests, 1 pre-existing failure —
AppLifecycleTests.testTheSharedJournalNeverWritesToTheRealInstanceRootUnderTests
fails on this machine because ~/.mcpproxy/tray-lifecycle.jsonl already
exists from real daily use, unrelated to and unaffected by this
comment-only Swift change; reproduces identically without it).
Verify each round-8 zcode finding against the code before fixing; all six
are genuine, fixed test-first, no false positives to reject.

1. internal/index/bleve.go (finding 1, high): the pre-annotations mapping
   migration was destructive-before-construct (Close -> os.RemoveAll(path)
   -> createBleveIndex(path)), so a failure after the wipe (disk full,
   permission error) left the daemon with no index and no way to recover
   without operator intervention -- turning a previously-degraded-but-
   working deployment (stale mapping, but an openable, functional index)
   into a hard outage on every restart. rebuildIfMappingPredatesAnnotations
   now builds the replacement at a temporary sibling path FIRST; only once
   that succeeds does it retire the original (close, remove, rename the
   replacement into place, reopen). If construction fails, the original
   index is left untouched and returned as-is, logging the error instead of
   failing startup -- the same degraded-but-running outcome round 6 had.
   New test: TestRebuildIfMappingPredatesAnnotations_ConstructionFailureKeepsStaleIndexUsable
   (forces the failure deterministically via a read-only parent dir).

2. frontend/src/composables/useDialogOpen.ts (finding 2, medium): Escape was
   a dead key on the ~8 dialogs (ConnectModal, OnboardingWizard,
   Repositories.vue's three dialogs, teams/UserActivity, UserServers,
   UserTokens) that pair useDialogOpen alone, with no useModalA11y keydown
   listener. handleCancel unconditionally prevented the native `cancel`
   event on the assumption a paired keydown listener would call close() --
   true only for the ~3 dialogs that pair both composables. handleCancel
   now also calls onClose() itself, guarded on isOpen() still reading true
   (a no-op, not a double call, when a paired keydown already closed it,
   since keydown is synchronous and cancel is a browser-queued task per the
   HTML spec). New test dispatches ONLY the native cancel event (no direct
   state flip), the case the existing round-7 tests didn't cover.

3. frontend/src/views/ServerDetail.vue (finding 3, medium): App.vue's
   <router-view> is keyed on the auth epoch, not the route, so navigating
   from one /servers/:serverName to another reuses the same component
   instance -- onMounted never reruns, so it never re-read `?tab=`. The
   existing props.serverName watch already resets per-server state for
   exactly that reason but never touched activeTab, so a stale tab (e.g.
   Security) kept showing for the next server even with no ?tab= in its
   URL. Factored the onMounted tab-read into readTabFromQuery() and call it
   from the watch too, resetting to the default when the new URL carries no
   ?tab= and honoring it when present.

4. frontend/src/views/Repositories.vue (finding 4, medium): "Added (check)
   Open" state (addedServers/addingServerId, Spec 109 FR-063) was keyed by
   the bare catalog entry id, while searchServers' own cross-registry dedupe
   already keys by `${registry}::${id}` since catalog ids collide across
   registries (MCP-866) -- the same bug the macOS half of this PR
   (ServerBrowseView.swift's addedKey) already fixed on that side. Added the
   matching addedKey() helper on the Web side and switched every
   addedServers/addingServerId read and write to it.

5. Makefile / .github/workflows (finding 5, medium): the two new tests
   proving the E2E cleanup trap no longer kills unrelated mcpproxy
   instances (test-descendant-pids, test-e2e-cleanup-check) were wired into
   neither `make test`/`test-e2e` nor any workflow, so a regression
   reintroducing the blanket `pkill -f "mcpproxy.*serve"` this PR removes
   would pass CI undetected. Wired the hermetic descendant-pids.test.sh into
   a new standalone job in unit-tests.yml (no build/binary needed), and the
   heavier test-api-e2e-cleanup-check.sh (needs the built binary, re-runs
   the full E2E suite as a subprocess) into release-qa-gate.yml's
   suite-api-e2e job right after the API E2E suite step, doubling that
   job's timeout budget accordingly.

6. cmd/mcpproxy/tools_cmd.go (finding 6, medium): --tier/--risk accepted any
   string with no validation; a typo like `--tier destrutive` silently
   matched nothing via strings.EqualFold and exited 0 with an empty table --
   a CI or audit script grepping for e.g. destructive tools reads that as
   "the host has none" instead of "the flag was misspelled". Added
   validateTierFilter, checked in runToolsList before any of the three list
   paths (global/client-mode/standalone) dispatch; names the actual flag
   (--tier vs --risk) in the error since resolvedTierFilter merges them.
   Manually verified against a live scratch instance: `--tier destrutive`
   now exits 1 with the flag list; `--tier read` still works.

Local verification: go build ./... and -tags server both clean; go test
-race on internal/index and cmd/mcpproxy (touched packages) pass;
golangci-lint v2 (bare and --build-tags server) clean on touched files, only
pre-existing unrelated debt elsewhere; frontend npm run build (vue-tsc +
vite) and full npx vitest run (154 files / 1410 tests) pass; manually built
and ran a scratch mcpproxy instance to exercise the bleve reopen path and the
new --tier validation end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…st gap)

Two findings from an external review pass:

1. internal/index/bleve.go (rebuildIfMappingPredatesAnnotations): the
   finalize step retired the original index directory with
   os.RemoveAll(indexPath). A partial RemoveAll failure (one permission-
   impaired or locked leftover file) can delete most-but-not-all of the
   original and still return an error; on the next startup
   bleve.Open(indexPath) fails on the mangled remnant, falls through to
   createBleveIndex(indexPath), and if index_meta.json survived, bleve's
   O_CREATE|O_EXCL index_meta.Save returns ErrorIndexPathExists — the same
   operator-intervention outage class round 8 fixed, reachable through a
   narrower door. Fixed by renaming the original aside (os.Rename) instead
   of deleting it, with restore-on-failure for both the move-into-place and
   reopen steps. A zcode follow-up review of this fix flagged the
   reopen-failure path as the identical gap one step later (very low
   probability, same shape); hardened it the same way rather than leaving a
   narrower version of the bug in place. Regression test reproduces the
   exact partial-delete shape against the real os.RemoveAll implementation
   (verified: index_meta.json removed, store/root.bolt survives, directory
   left mangled) and confirms it fails pre-fix, passes post-fix.

2. internal/gatereport/gatereport.go: the "Run E2E cleanup-trap safety
   check" CI step (suite/api-e2e-cleanup-check, added to close round-8
   finding 5) was never added to Manifest(), so deleting that step or
   typo'ing its --name would leave no fragment at all — nothing reports a
   manifest entry missing for a name the manifest never expected, and the
   gate stays green with no evidence the check ever ran. Added
   EntrySuiteAPIE2ECleanupCheck as a blocking manifest entry, with tests
   pinning both its presence/blocking-ness and that a missing fragment for
   it fails the gate.

Reviewed with zcode (GLM-5.3), read-only plan mode, per-file chunks.
Verdict on both: fix correct, tests genuinely reproduce the bug and fail
without it. Additional non-blocking observations from that review: a
stale doc line (docs/development/release-gate.md, now updated) and two
low-priority/theoretical hardening suggestions for bleve.go left as
documented, deferred (a currently-unreachable RemoveAll-error-ignored
footgun on future reuse of this rebuild path, and no test coverage for the
untestable-without-a-seam restore-on-second-rename-failure branch).

Local verification: go build ./... (bare + -tags server), go vet ./...,
go test -race ./internal/index/... ./internal/gatereport/...
./cmd/release-gate/..., golangci-lint v2 (.github/.golangci.yml, bare and
--build-tags server) clean on touched packages and repo-wide (pre-existing
issues elsewhere unrelated to this diff).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two review findings on Spec 109 PR-a (109-a-quick-wins). Both verified
against the real code test-first (regression test written first, shown
red against the pre-fix logic, then green), then cross-reviewed with
zcode.

- frontend/src/views/Tools.vue: activeStatCard's
  `if (filterApproval.value) return null` guard (added round 4 to stop
  Total misreporting active on an approval-only filter) ran BEFORE the
  filterStatus checks, so it also swallowed 'enabled'/'disabled'. With
  an approval filter set, clicking the Enabled/Disabled stat card still
  applied filterStatus (the table did narrow further) but the card
  never rendered active, and a second click re-ran the same
  `filterStatus.value = card` no-op instead of toggling off — a
  permanently inert control with no visible feedback. Reordered the
  checks so filterStatus === 'enabled'/'disabled' wins regardless of
  any additional approval filter; the filterApproval-only null (round
  4's actual fix target) still applies only when filterStatus is
  empty. New test: tools-enabled-stat-card-with-approval-filter.spec.ts.

- frontend/src/views/Settings.vue: the FR-016 `?tab=` sync and the
  pre-existing `?focus=` deep link were both applied only in
  onMounted. The header's ModeSwitcher (rendered on every page,
  including /settings) links to `/settings?focus=routing_mode`;
  clicking it while already on /settings is a query-only navigation
  reusing the mounted component instance (App.vue keys <router-view>
  on the auth epoch, not the route), so onMounted never reruns and the
  click did nothing — the same bug class this PR already fixed for
  ServerDetail.vue (round-8 finding) via a route-query watcher. Added
  the same pattern here: a watch on [route.query.tab,
  route.query.focus] that re-applies the tab / re-runs focusField on
  change.

  A first zcode pass on that fix found one more genuine (minor) gap:
  comparing only `focus !== prevFocus` left a REPEAT click of the same
  ModeSwitcher link a dead click once the user had manually switched
  Settings tabs in between (same focus value, tab drifted away).
  ModeSwitcher's RouterLink is a bare `/settings?focus=routing_mode`
  with no `tab` key, so following it always replaces the whole query —
  `tab` reverting from a real string to undefined is exactly that
  pattern, and is distinct from watch(activeTab)'s own
  `{ ...route.query, tab }` writeback, which never unsets `tab` — so
  the signal cannot false-positive on our own self-caused navigation.
  Added that second condition and a third regression test for the
  repeat-click-after-manual-drift scenario. A second zcode pass on the
  amended fix returned no further findings. New/updated test:
  settings-query-only-nav-focus.spec.ts.

Verification: go build ./... clean (no Go files touched); go vet
./internal/... and ./cmd/... clean; golangci-lint v2 (bare and
--build-tags server) on internal/httpapi and internal/server shows only
7 pre-existing staticcheck findings in test files this change never
touched; go test -race on internal/config, internal/contracts,
internal/oauth, internal/storage and internal/httpapi (server tag, CI
skip regex) all green; frontend npx vue-tsc --noEmit clean; npm run
build clean (package-lock.json unchanged); npx vitest run green (156
files / 1414 tests, up from 1411). Both fixes cross-reviewed with
zcode (GLM-5.3, read-only plan mode): Tools.vue fix verdict NONE first
pass; Settings.vue fix verdict NONE on the second pass after the
repeat-click gap found on the first was fixed.
…eal RebuildIndex

An index opened with bleve.Open keeps the mapping it was created with.
Indexes created before annotations_json / output_schema_json were mapped
explicitly fall back to bleve's dynamic defaults, which full-text index
both fields into `_all`, so the field-less MatchQuery in SearchTools
ranked identical corpora differently depending only on when the index
was created. Round 7 wiped such an index and waited for rediscovery;
RebuildIndex was a logging no-op.

- Staleness detection on open (indexStaleReason): the persisted `_mapping`
  bytes are compared with the current mapping (catches every future
  mapping change with no manual bump), plus an indexSchemaVersion stamp
  for changes in how documents are derived (searchable_text).
- RebuildIndex is real: reads every document's stored fields (sorted,
  paginated), re-derives searchable_text, writes a current-mapping index
  in <path>.rebuild, stamps the version last, then swaps it in. The
  index is its own source: storage keeps no annotations or index hash,
  and copying stored fields keeps full_tool_name byte-for-byte (SC-005).
- Crash safety: index_meta.json is removed first and moved in last; a
  directory left without it is recreated empty on the next open, and a
  leftover .rebuild dir is removed. The nested profiles/ directory under
  the shared index is never touched (round 7's RemoveAll deleted it).
- createBleveIndex split into currentIndexMapping() + create/stamp;
  output_schema_json and annotations_json stay explicit stored-only
  fields under Dynamic=false.

Tests: legacy index migrated with all fields kept and scores equal to a
fresh index; annotations_json not field-less matchable on fresh,
migrated and runtime-rebuilt indexes (with a precondition proving the
legacy leak); output_schema_json stored/returned on fresh and migrated
indexes; version-only staleness; profile dir preserved; interrupted
swap recovery; stale rebuild dir cleanup; Manager.RebuildIndex keeps
docs, scores and writability; pagination past one page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…build

zcode round 1: a swap or reopen failure after the old index was closed
left b.index nil, so every later call (Close included) panicked. Recover
by recreating an empty current-mapping index in place (discovery
re-populates it) and still return the error; a failed cleanup of the
.rebuild dir after a completed swap is now a warning. Close is nil-safe.
Also corrects newBleveIndexAt's comment: profile indexes live under
index.bleve/profiles/<slug>/, the only entry a shared-index rebuild keeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebasing onto 109-a-quick-wins brought rounds 8-9, which hardened the
now-replaced rebuildIfMappingPredatesAnnotations. Their two guarantees
are kept in the new mechanism:

- Round 8: a failed migration no longer fails startup. newBleveIndexAt
  logs and keeps serving the stale-mapping index (failure before the
  swap) or the empty recovered one (failure after it); the next open
  retries. It only errors if no index is left at all.
- Round 9: an undeletable file inside store/ must not strand the swap.
  retireIndexEntries renames bleve's entries to .retired-* names in the
  same directory (index_meta.json first) and only then best-effort
  deletes them; leftovers are retried on the next open. The rename stays
  in the same parent because moving a directory elsewhere also needs
  write access to it (its ".." entry), which is exactly what a
  permission-impaired store/ lacks.

Round 9's rename-aside of the whole index directory also moved and then
deleted the nested profiles/ directory; retiring entries individually
keeps it. Ported both regression tests, plus the same unremovable-store
case for interrupted-swap recovery.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 89bafad
Status: ✅  Deploy successful!
Preview URL: https://d9f4d3c6.mcpproxy-docs.pages.dev
Branch Preview URL: https://claude-nostalgic-bell-8dfa1a.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

codecov-commenter commented Sep 26, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 64.64646% with 70 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/index/bleve.go 64.73% 39 Missing and 28 partials ⚠️
internal/index/manager.go 62.50% 2 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Dumbris and others added 2 commits September 26, 2026 11:06
CI (macos-15 runner) failed TestNewBleveIndexAt_MigrationSurvivesUnremovableStore
and ...InterruptedSwapRecoverySurvivesUnremovableStore: that macOS refuses
to rename a non-writable store/ even within its own parent directory
(macOS 26 allows it, so it passed locally). Same-directory renames of
individual entries therefore cannot route around a permission-impaired
store/.

retireIndexEntries now renames the whole index directory to a
<dir>.retired-<n> sibling (atomic, needs write access to the parent only,
the approach PR 1378 round 9 used), recreates dir, and moves profiles/
back so per-profile indexes survive; if that move fails, everything is
put back instead. A crash between the rename and the move is repaired on
the next open: a retired leftover that still holds profiles/ returns it
before being deleted (new test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The shared index directory holds profiles/, and the rebuild now renames
that directory as a whole. Windows cannot rename a directory with open
files inside, and on POSIX a profile write during the rename would hit a
missing path. Root Manager.RebuildIndex closes and drops open profile
indexes first (as DropProfile does); ForProfile reopens them lazily.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: claude/nostalgic-bell-8dfa1a

Available Artifacts

  • archive-darwin-amd64 (30 MB)
  • archive-darwin-arm64 (27 MB)
  • archive-linux-amd64 (18 MB)
  • archive-linux-arm64 (16 MB)
  • archive-windows-amd64 (30 MB)
  • archive-windows-arm64 (26 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (24 MB)
  • installer-dmg-darwin-arm64 (22 MB)
  • smart-mcp-proxymcpproxy-goBQ1ZLO.dockerbuild (0 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 36252860325 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

@Dumbris
Dumbris changed the base branch from 109-a-quick-wins to main September 26, 2026 11:32
…-8dfa1a

# Conflicts:
#	frontend/src/components/OnboardingWizard.vue
#	internal/index/bleve.go
#	internal/index/bleve_mapping_migration_test.go
#	oas/docs.go
A Close() error on the old index during RebuildIndex returned early with
b.index still pointing at that handle. bleve's scorch backend tears an
index down internally (closing its internal channel, draining async
tasks) before the error-prone part of its own Close runs, so the old
handle is torn down whether or not Close returns an error. Leaving it
installed served requests off a dead index and risked a double-close
panic ("close of closed channel") the next time the index was closed.

RebuildIndex now logs a Close() error and proceeds to swap in the
already-built replacement instead of discarding it and bailing out with
the dead handle in place.

Added TestRebuildIndex_OldIndexCloseFailureStillSwaps, which wraps a real
index so Close tears it down but still reports an error, confirming the
migration completes and the swapped-in index is live (further writes and
Close succeed).

Review round 1 (zcode), finding F1.1: internal/index/bleve.go RebuildIndex.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved (Model B): Paperclip review verdicts = ACCEPT and qa-gate green at this head SHA. Arming auto-merge; GitHub merges when all required checks pass.

@github-actions
github-actions Bot merged commit 03ab4a3 into main Sep 26, 2026
50 of 56 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants