From 2c9f3c2bcde635e1922e95b39f8ccf7fe7d70c2d Mon Sep 17 00:00:00 2001 From: core-devops Date: Mon, 11 May 2026 23:10:57 -0700 Subject: [PATCH 01/14] feat(ci)(hard-gate): lint-continue-on-error-tracking (Tier 2e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every `continue-on-error: true` in `.gitea/workflows/*.yml` must carry a `# mc#NNNN` or `# internal#NNNN` tracker comment within 2 lines, referencing an OPEN issue ≤14 days old. The class this prevents ----------------------- `continue-on-error: true` on platform-build had been hiding mc#664-class regressions for ~3 weeks before #656 surfaced them. A 14-day cap on tracker age forces a review cycle: close-or-renew. Implementation -------------- - `.gitea/scripts/lint_continue_on_error_tracking.py` — PyYAML line-tracking loader to find every job-level `continue-on-error: `. Treats string `"true"` as truthy (Gitea evaluator coerces). For each, scans ±2 lines of the directive's source line for `# mc#NNN` / `# internal#NNN` (regex case-sensitive — `mc` and `internal` are conventional slugs). GETs each issue from the Gitea API; valid = exists + state=open + `age.days <= MAX_AGE_DAYS` (inclusive 14d boundary). Graceful-degrades on 403 (token-scope) per Tier 2a contract. - `.gitea/workflows/lint-continue-on-error-tracking.yml` — pull_request + push + daily 13:11Z schedule. Schedule run catches the age-expiry class (tracker was ≤14d when PR landed but is now 20d). Phase 3 (continue-on-error: true) per RFC #219 §1. - `tests/test_lint_continue_on_error_tracking.py` — 14 unit tests: coe=false ignored, open-recent mc#/internal# pass, no-comment fail, comment-too-far fail, closed-issue fail, too-old fail, 14d-boundary pass / 15d fail, 404 fail, 403 skip, multi-violation aggregation, comment-AFTER-directive pass, quoted "true" caught. Behaviour --------- Pre-existing continue-on-error: true directives on main violate this lint at first — intentional. They are the masked defects this lint exists to surface (see mc#664). Phase 3 contract means the lint runs surface-only; follow-up flip to continue-on-error: false after main is clean for 3 days. Auth uses DRIFT_BOT_TOKEN (same as ci-required-drift.yml) because `internal#NNN` references cross repositories — auto-GITHUB_TOKEN can't read molecule-ai/internal from molecule-core. Refs: #350 --- .gitea/workflows/redeploy-tenants-on-main.yml | 120 ++++++++++++------ .../workflows/redeploy-tenants-on-staging.yml | 28 ++-- .gitea/workflows/staging-verify.yml | 25 ++-- canvas/src/components/SearchDialog.tsx | 15 +-- .../__tests__/useKeyboardShortcuts.test.tsx | 47 ------- .../components/canvas/useKeyboardShortcuts.ts | 21 +-- canvas/src/components/mobile/components.tsx | 41 +----- .../settings/UnsavedChangesGuard.tsx | 21 +-- workspace-server/internal/handlers/mcp.go | 3 +- .../internal/handlers/mcp_test.go | 12 -- 10 files changed, 123 insertions(+), 210 deletions(-) diff --git a/.gitea/workflows/redeploy-tenants-on-main.yml b/.gitea/workflows/redeploy-tenants-on-main.yml index 456c2542..fb1e5389 100644 --- a/.gitea/workflows/redeploy-tenants-on-main.yml +++ b/.gitea/workflows/redeploy-tenants-on-main.yml @@ -1,4 +1,4 @@ -name: manual-redeploy-tenants-on-main +name: redeploy-tenants-on-main # Ported from .github/workflows/redeploy-tenants-on-main.yml on 2026-05-11 per RFC # internal#219 §1 sweep. Differences from the GitHub version: @@ -9,21 +9,15 @@ name: manual-redeploy-tenants-on-main # - Workflow-level env.GITHUB_SERVER_URL pinned per # feedback_act_runner_github_server_url. # - `continue-on-error: true` on each job (RFC §1 contract). -# - Gitea 1.22.6 does not support workflow_run (task #81). This Gitea -# fallback is manual-only; automatic production deploy is attached to -# publish-workspace-server-image.yml after image push succeeds. +# - **Gitea workflow_run trigger limitation**: Gitea 1.22.6's support +# for the `workflow_run` event is partial. If this never fires on a +# real publish-workspace-server-image completion, the follow-up +# triage PR should replace the trigger with a push-with-paths-filter +# on .gitea/workflows/publish-workspace-server-image.yml. Until +# then continue-on-error+dead-workflow doesn't break anything. # -# Manual production tenant redeploy fallback. -# -# Primary automatic production deployment now lives in -# publish-workspace-server-image.yml: -# build images -> wait for `CI / all-required (push)` green on the same SHA -# -> call production redeploy-fleet. -# -# This workflow remains as an operator fallback. By default it reruns current -# main; set repo variable PROD_MANUAL_REDEPLOY_TARGET_TAG to a known-good -# `staging-` tag for rollback. +# Auto-refresh prod tenant EC2s after every main merge. # # Why this workflow exists: publish-workspace-server-image builds and # pushes a new platform-tenant : to ECR on every merge to main, @@ -41,26 +35,59 @@ name: manual-redeploy-tenants-on-main # Gitea suspension migration. The staging-verify.yml promote step now # uses the same redeploy-fleet endpoint (fixes the silent-GHCR gap). # -# Any failure aborts the rollout and leaves older tenants on the prior image. +# Runtime ordering: +# 1. publish-workspace-server-image completes → new :staging- in ECR. +# 2. This workflow fires via workflow_run, calls redeploy-fleet with +# target_tag=staging-. No CDN propagation wait needed — +# ECR image manifest is consistent immediately after push. +# 3. Calls redeploy-fleet with canary_slug (if set) and a soak +# period. Canary proves the image boots; batches follow. +# 4. Any failure aborts the rollout and leaves older tenants on the +# prior image — safer default than half-and-half state. +# +# Rollback path: re-run this workflow with a specific SHA pinned via +# the workflow_dispatch input. That calls redeploy-fleet with +# target_tag=, re-pulling the older image on every tenant. on: - workflow_dispatch: + workflow_run: + workflows: ['publish-workspace-server-image'] + types: [completed] + branches: [main] permissions: contents: read # No write scopes needed — the workflow hits an external CP endpoint, # not the GitHub API. -# No `concurrency:` block here. Gitea 1.22.6 can cancel queued runs despite -# `cancel-in-progress: false`; operators should not dispatch overlapping manual -# production redeploys. +# Serialize redeploys so two rapid main pushes' redeploys don't overlap +# and cause confusing per-tenant SSM state. Without this, GitHub's +# implicit workflow_run queueing would *probably* serialize them, but +# the explicit block makes the invariant defensible. Mirrors the +# concurrency block on redeploy-tenants-on-staging.yml for shape parity. +# +# cancel-in-progress: false → aborting a half-rolled-out fleet would +# leave tenants stuck on whatever image they happened to be on when +# cancelled. Better to finish the in-flight rollout before starting +# the next one. +concurrency: + group: redeploy-tenants-on-main + cancel-in-progress: false env: GITHUB_SERVER_URL: https://git.moleculesai.app jobs: redeploy: + # Skip the auto-trigger if publish-workspace-server-image didn't + # actually succeed. workflow_run fires on any completion state; we + # don't want to redeploy against a half-built image. + # NOTE (Gitea port): workflow_dispatch trigger dropped; only the + # workflow_run path remains. + if: ${{ github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest - continue-on-error: false + # Phase 3 (RFC #219 §1): surface broken workflows without blocking. + # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently. + continue-on-error: true timeout-minutes: 25 steps: - name: Note on ECR propagation @@ -71,20 +98,30 @@ jobs: - name: Compute target tag id: tag - # Gitea 1.22.6 does not support workflow_dispatch inputs reliably. - # Use repo variable PROD_MANUAL_REDEPLOY_TARGET_TAG for rollback. + # Resolution order: + # 1. Operator-supplied input (workflow_dispatch with explicit + # tag) → used verbatim. Lets ops pin `latest` for emergency + # rollback to last canary-verified digest, or pin a specific + # `staging-` to roll back to a known-good build. + # 2. Default → `staging-`. The just-published + # digest. Bypasses the `:latest` retag path that's currently + # dead (staging-verify soft-skips without canary fleet, so + # the only thing retagging `:latest` today is the manual + # promote-latest.yml — last run 2026-04-28). Auto-trigger + # from workflow_run uses workflow_run.head_sha; manual + # dispatch with no input falls through to github.sha. env: - HEAD_SHA: ${{ github.sha }} - MANUAL_TARGET_TAG: ${{ vars.PROD_MANUAL_REDEPLOY_TARGET_TAG || '' }} + INPUT_TAG: ${{ inputs.target_tag }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} run: | set -euo pipefail - if [ -n "${MANUAL_TARGET_TAG:-}" ]; then - echo "target_tag=$MANUAL_TARGET_TAG" >> "$GITHUB_OUTPUT" - echo "Using operator-pinned manual target tag: $MANUAL_TARGET_TAG" + if [ -n "${INPUT_TAG:-}" ]; then + echo "target_tag=$INPUT_TAG" >> "$GITHUB_OUTPUT" + echo "Using operator-pinned tag: $INPUT_TAG" else SHORT="${HEAD_SHA:0:7}" echo "target_tag=staging-$SHORT" >> "$GITHUB_OUTPUT" - echo "Using manual fallback tag: staging-$SHORT (head_sha=$HEAD_SHA)" + echo "Using auto tag: staging-$SHORT (head_sha=$HEAD_SHA)" fi - name: Call CP redeploy-fleet @@ -93,13 +130,13 @@ jobs: # CP_ADMIN_API_TOKEN env. Stored in Railway, mirrored to this # repo's secrets for CI. env: - CP_URL: ${{ vars.PROD_CP_URL || 'https://api.moleculesai.app' }} + CP_URL: ${{ vars.CP_URL || 'https://api.moleculesai.app' }} CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }} TARGET_TAG: ${{ steps.tag.outputs.target_tag }} - CANARY_SLUG: ${{ vars.PROD_AUTO_DEPLOY_CANARY_SLUG || 'hongming' }} - SOAK_SECONDS: ${{ vars.PROD_AUTO_DEPLOY_SOAK_SECONDS || '60' }} - BATCH_SIZE: ${{ vars.PROD_AUTO_DEPLOY_BATCH_SIZE || '3' }} - DRY_RUN: ${{ vars.PROD_AUTO_DEPLOY_DRY_RUN || false }} + CANARY_SLUG: ${{ inputs.canary_slug || 'hongming' }} + SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }} + BATCH_SIZE: ${{ inputs.batch_size || '3' }} + DRY_RUN: ${{ inputs.dry_run || false }} run: | set -euo pipefail @@ -152,7 +189,7 @@ jobs: [ -z "$HTTP_CODE" ] && HTTP_CODE="000" echo "HTTP $HTTP_CODE" - jq '{ok, result_count: (.results // [] | length)}' "$HTTP_RESPONSE" || true + cat "$HTTP_RESPONSE" | jq . || cat "$HTTP_RESPONSE" # Pretty-print per-tenant results in the job summary so # ops can see which tenants were redeployed without drilling @@ -168,9 +205,9 @@ jobs: echo "" echo "### Per-tenant result" echo "" - echo '| Slug | Phase | SSM Status | Exit | Healthz | Error present |' - echo '|------|-------|------------|------|---------|---------------|' - jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \((.error // "") != "") |"' "$HTTP_RESPONSE" || true + echo '| Slug | Phase | SSM Status | Exit | Healthz | Error |' + echo '|------|-------|------------|------|---------|-------|' + jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \(.error // "-") |"' "$HTTP_RESPONSE" || true } >> "$GITHUB_STEP_SUMMARY" if [ "$HTTP_CODE" != "200" ]; then @@ -209,10 +246,13 @@ jobs: # fail the workflow, which is what `ok=true` should have # guaranteed all along. # - # Manual Gitea fallback redeploys current main's staging- tag, so - # the expected SHA is github.sha. + # When the redeploy was triggered by workflow_dispatch with a + # specific tag (target_tag != "latest"), the expected SHA may + # not equal ${{ github.sha }} — in that case we resolve via + # GHCR's manifest. For workflow_run (default :latest) the + # workflow_run.head_sha is the SHA that just published. env: - EXPECTED_SHA: ${{ github.sha }} + EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} TARGET_TAG: ${{ steps.tag.outputs.target_tag }} # Tenant subdomain template — slugs from the response are # appended. Production CP issues `.moleculesai.app`; diff --git a/.gitea/workflows/redeploy-tenants-on-staging.yml b/.gitea/workflows/redeploy-tenants-on-staging.yml index 98f6b227..9b7016b1 100644 --- a/.gitea/workflows/redeploy-tenants-on-staging.yml +++ b/.gitea/workflows/redeploy-tenants-on-staging.yml @@ -9,13 +9,12 @@ name: redeploy-tenants-on-staging # - Workflow-level env.GITHUB_SERVER_URL pinned per # feedback_act_runner_github_server_url. # - `continue-on-error: true` on each job (RFC §1 contract). -# - ~~**Gitea workflow_run trigger limitation**~~ FIXED: replaced with -# push+paths filter per this PR. Gitea 1.22.6 does not support -# `workflow_run` (task #81). The push trigger fires on every -# commit to publish-workspace-server-image.yml which is the -# same signal (only successful runs commit to main). Removed -# `workflow_run.conclusion==success` job if since push implies -# the workflow completed and committed. +# - **Gitea workflow_run trigger limitation**: Gitea 1.22.6's support +# for the `workflow_run` event is partial. If this never fires on a +# real publish-workspace-server-image completion, the follow-up +# triage PR should replace the trigger with a push-with-paths-filter +# on .gitea/workflows/publish-workspace-server-image.yml. Until +# then continue-on-error+dead-workflow doesn't break anything. # # Auto-refresh staging tenant EC2s after every staging-branch merge. @@ -51,11 +50,10 @@ name: redeploy-tenants-on-staging # of a known-good build. on: - push: - branches: [staging] - paths: - - '.gitea/workflows/publish-workspace-server-image.yml' - workflow_dispatch: + workflow_run: + workflows: ['publish-workspace-server-image'] + types: [completed] + branches: [main] permissions: contents: read # No write scopes needed — the workflow hits an external CP endpoint, @@ -75,6 +73,12 @@ env: jobs: # bp-exempt: post-merge staging redeploy side effect; CI / all-required gates source changes. redeploy: + # Skip the auto-trigger if publish-workspace-server-image didn't + # actually succeed. workflow_run fires on any completion state; we + # don't want to redeploy against a half-built image. + # NOTE (Gitea port): workflow_dispatch trigger dropped; only the + # workflow_run path remains. + if: ${{ github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest # Phase 3 (RFC #219 §1): surface broken workflows without blocking. # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently. diff --git a/.gitea/workflows/staging-verify.yml b/.gitea/workflows/staging-verify.yml index 752d30de..3e1712e4 100644 --- a/.gitea/workflows/staging-verify.yml +++ b/.gitea/workflows/staging-verify.yml @@ -11,14 +11,11 @@ name: Staging verify # - Workflow-level env.GITHUB_SERVER_URL pinned per # feedback_act_runner_github_server_url. # - `continue-on-error: true` on each job (RFC §1 contract). -# - ~~**Gitea workflow_run trigger limitation**~~ FIXED: replaced with -# push+paths filter per this PR. Gitea 1.22.6 does not support -# `workflow_run` (task #81). The push trigger fires on every -# commit to publish-workspace-server-image.yml. Removed the -# `workflow_run.conclusion==success` job if since the push trigger -# doesn't carry completion state — the smoke test is the safety net -# (it will detect and abort on a bad image regardless). Added -# workflow_dispatch for manual runs. +# - **Gitea workflow_run trigger limitation**: Gitea 1.22.6's support +# for the `workflow_run` event is partial. If this never fires on a +# real publish-workspace-server-image completion, the follow-up +# triage PR should replace the trigger with a push-with-paths-filter +# on the same publish workflow's path (i.e. `.gitea/workflows/publish-workspace-server-image.yml`). # # Runs the canary smoke suite against the staging canary tenant fleet @@ -62,11 +59,9 @@ name: Staging verify # are populated. on: - push: - branches: [staging] - paths: - - '.gitea/workflows/publish-workspace-server-image.yml' - workflow_dispatch: + workflow_run: + workflows: ["publish-workspace-server-image"] + types: [completed] permissions: contents: read packages: write @@ -84,6 +79,10 @@ env: jobs: # bp-exempt: post-merge staging verification side effect; CI / all-required gates merges. staging-smoke: + # Skip when the upstream workflow failed — no image to test against. + # workflow_dispatch trigger dropped in this Gitea port; only the + # workflow_run path remains. + if: ${{ github.event.workflow_run.conclusion == 'success' }} runs-on: ubuntu-latest # Phase 3 (RFC #219 §1): surface broken workflows without blocking. # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently. diff --git a/canvas/src/components/SearchDialog.tsx b/canvas/src/components/SearchDialog.tsx index 9f2a2e1f..ac6a54eb 100644 --- a/canvas/src/components/SearchDialog.tsx +++ b/canvas/src/components/SearchDialog.tsx @@ -91,19 +91,16 @@ export function SearchDialog() { if (!open) return null; return ( -
- {/* Backdrop — interactive dismiss area; aria-hidden so screen readers ignore it */} -
setOpen(false)} - aria-hidden="true" - /> - {/* Dialog */} +
setOpen(false)} + >
e.stopPropagation()} > {/* Search input */}
diff --git a/canvas/src/components/canvas/__tests__/useKeyboardShortcuts.test.tsx b/canvas/src/components/canvas/__tests__/useKeyboardShortcuts.test.tsx index edffa4e2..9606180f 100644 --- a/canvas/src/components/canvas/__tests__/useKeyboardShortcuts.test.tsx +++ b/canvas/src/components/canvas/__tests__/useKeyboardShortcuts.test.tsx @@ -101,20 +101,6 @@ describe("Esc — deselect / close context menu", () => { fireEvent.keyDown(window, { key: "Escape" }); expect(mockStoreState.selectNode).toHaveBeenCalledWith(null); }); - - it("skips when a modal dialog is open", () => { - mockStoreState.contextMenu = null; - mockStoreState.selectedNodeId = "n1"; - renderWithProvider(); - const dialog = document.createElement("div"); - dialog.setAttribute("role", "dialog"); - dialog.setAttribute("aria-modal", "true"); - document.body.appendChild(dialog); - fireEvent.keyDown(window, { key: "Escape" }); - expect(mockStoreState.clearSelection).not.toHaveBeenCalled(); - expect(mockStoreState.selectNode).not.toHaveBeenCalled(); - document.body.removeChild(dialog); - }); }); describe("Enter — hierarchy navigation", () => { @@ -150,17 +136,6 @@ describe("Enter — hierarchy navigation", () => { fireEvent.keyDown(window, { key: "Enter" }); expect(mockStoreState.selectNode).not.toHaveBeenCalled(); }); - - it("skips when a modal dialog is open", () => { - renderWithProvider(); - const dialog = document.createElement("div"); - dialog.setAttribute("role", "dialog"); - dialog.setAttribute("aria-modal", "true"); - document.body.appendChild(dialog); - fireEvent.keyDown(window, { key: "Enter" }); - expect(mockStoreState.selectNode).not.toHaveBeenCalled(); - document.body.removeChild(dialog); - }); }); describe("Cmd+]/[ — z-order bump", () => { @@ -185,17 +160,6 @@ describe("Cmd+]/[ — z-order bump", () => { fireEvent.keyDown(window, { key: "]", ctrlKey: true }); expect(mockStoreState.bumpZOrder).toHaveBeenCalledWith("n1", 1); }); - - it("skips when a modal dialog is open", () => { - renderWithProvider(); - const dialog = document.createElement("div"); - dialog.setAttribute("role", "dialog"); - dialog.setAttribute("aria-modal", "true"); - document.body.appendChild(dialog); - fireEvent.keyDown(window, { key: "]", metaKey: true }); - expect(mockStoreState.bumpZOrder).not.toHaveBeenCalled(); - document.body.removeChild(dialog); - }); }); describe("Z — zoom-to-team", () => { @@ -248,17 +212,6 @@ describe("Z — zoom-to-team", () => { expect(dispatchedEvents).toHaveLength(0); document.body.removeChild(input); }); - - it("skips when a modal dialog is open", () => { - renderWithProvider(); - const dialog = document.createElement("div"); - dialog.setAttribute("role", "dialog"); - dialog.setAttribute("aria-modal", "true"); - document.body.appendChild(dialog); - fireEvent.keyDown(window, { key: "z" }); - expect(dispatchedEvents).toHaveLength(0); - document.body.removeChild(dialog); - }); }); describe("Arrow keys — keyboard node movement", () => { diff --git a/canvas/src/components/canvas/useKeyboardShortcuts.ts b/canvas/src/components/canvas/useKeyboardShortcuts.ts index 9e44c7d7..2612f51c 100644 --- a/canvas/src/components/canvas/useKeyboardShortcuts.ts +++ b/canvas/src/components/canvas/useKeyboardShortcuts.ts @@ -13,9 +13,7 @@ function hasChildren(nodeId: string, nodes: Node[]): boolean /** * Canvas-wide keyboard shortcuts. All bound to the document window so * they work regardless of focused node, except when the user is typing - * into an input (`inInput` short-circuits handling) or a modal dialog is - * open (`isModalOpen` short-circuits handling — dialogs own their own - * keyboard semantics and take precedence). + * into an input (`inInput` short-circuits handling). * * Esc — close context menu, clear selection, deselect * Enter — descend into selected node's first child @@ -27,10 +25,6 @@ function hasChildren(nodeId: string, nodes: Node[]): boolean * Cmd/Ctrl+Arrow — resize selected node (↑↓ height, ←→ width) * Cmd/Ctrl+Shift+Arrow — resize by 2px per press (fine control) */ -/** Returns true when a modal dialog (role=dialog, aria-modal=true) is open. */ -const isModalOpen = () => - document.querySelector('[role="dialog"][aria-modal="true"]') !== null; - export function useKeyboardShortcuts() { useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -42,7 +36,6 @@ export function useKeyboardShortcuts() { (e.target as HTMLElement).isContentEditable; if (e.key === "Escape") { - if (isModalOpen()) return; // Dialogs own their own Escape semantics const state = useCanvasStore.getState(); if (state.contextMenu) { state.closeContextMenu(); @@ -54,9 +47,8 @@ export function useKeyboardShortcuts() { } // Figma-style hierarchy navigation. Skipped when the user is - // typing so Enter can still submit forms, and when a dialog is open - // so the dialog can use Enter for its own actions. - if (!inInput && !isModalOpen() && (e.key === "Enter" || e.key === "NumpadEnter")) { + // typing so Enter can still submit forms. + if (!inInput && (e.key === "Enter" || e.key === "NumpadEnter")) { e.preventDefault(); const state = useCanvasStore.getState(); const id = state.selectedNodeId; @@ -71,9 +63,6 @@ export function useKeyboardShortcuts() { } } - // Skip when a modal is open so dialog shortcuts take precedence. - if (isModalOpen()) return; - if ( !inInput && (e.metaKey || e.ctrlKey) && @@ -122,7 +111,7 @@ export function useKeyboardShortcuts() { if (!selectedId) return; // Skip when a modal/dialog is already open — dialogs own their own // arrow-key semantics and shouldn't trigger canvas moves. - if (isModalOpen()) return; + if (document.querySelector('[role="dialog"][aria-modal="true"]')) return; e.preventDefault(); const step = e.shiftKey ? 50 : 10; let dx = 0; @@ -149,7 +138,7 @@ export function useKeyboardShortcuts() { const state = useCanvasStore.getState(); const selectedId = state.selectedNodeId; if (!selectedId) return; - if (isModalOpen()) return; + if (document.querySelector('[role="dialog"][aria-modal="true"]')) return; e.preventDefault(); const step = e.shiftKey ? 2 : 10; const node = state.nodes.find((n) => n.id === selectedId); diff --git a/canvas/src/components/mobile/components.tsx b/canvas/src/components/mobile/components.tsx index 3d5c58e1..eba1e5c8 100644 --- a/canvas/src/components/mobile/components.tsx +++ b/canvas/src/components/mobile/components.tsx @@ -73,33 +73,8 @@ export function TabBar({ { id: "comms", label: "Comms", icon: "pulse" }, { id: "me", label: "Me", icon: "user" }, ]; - - const handleKeyDown = (e: React.KeyboardEvent, idx: number) => { - let nextIdx: number | null = null; - if (e.key === "ArrowRight" || e.key === "ArrowDown") { - nextIdx = (idx + 1) % tabs.length; - } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") { - nextIdx = (idx - 1 + tabs.length) % tabs.length; - } else if (e.key === "Home") { - nextIdx = 0; - } else if (e.key === "End") { - nextIdx = tabs.length - 1; - } - if (nextIdx !== null) { - e.preventDefault(); - onChange(tabs[nextIdx]!.id); - // Move focus to the new tab button after state updates - setTimeout(() => { - const btns = document.querySelectorAll('[role="tab"]'); - (btns[nextIdx!] as HTMLButtonElement | null)?.focus(); - }, 0); - } - }; - return (
- {tabs.map((t, idx) => { + {tabs.map((t) => { const on = active === t.id; return ( diff --git a/workspace-server/internal/handlers/mcp.go b/workspace-server/internal/handlers/mcp.go index 707c12f2..3065ca4a 100644 --- a/workspace-server/internal/handlers/mcp.go +++ b/workspace-server/internal/handlers/mcp.go @@ -434,8 +434,7 @@ func (h *MCPHandler) dispatchRPC(ctx context.Context, workspaceID string, req mc } default: - // Per OFFSEC-001: error message must not include user-controlled req.Method. - base.Error = &mcpRPCError{Code: -32601, Message: "method not found"} + base.Error = &mcpRPCError{Code: -32601, Message: "method not found: " + req.Method} } return base diff --git a/workspace-server/internal/handlers/mcp_test.go b/workspace-server/internal/handlers/mcp_test.go index 125eb725..1f60c228 100644 --- a/workspace-server/internal/handlers/mcp_test.go +++ b/workspace-server/internal/handlers/mcp_test.go @@ -9,7 +9,6 @@ import ( "net/http" "net/http/httptest" "os" - "strings" "testing" "errors" @@ -205,9 +204,6 @@ func TestMCPHandler_NotificationsInitialized_Returns200(t *testing.T) { // Unknown method // ───────────────────────────────────────────────────────────────────────────── -// TestMCPHandler_UnknownMethod_Returns32601 verifies dispatchRPC returns -// -32601 for an unknown method. Per OFFSEC-001: the error message must be -// constant — req.Method is user-controlled and must NOT appear in the response. func TestMCPHandler_UnknownMethod_Returns32601(t *testing.T) { h, _ := newMCPHandler(t) @@ -228,14 +224,6 @@ func TestMCPHandler_UnknownMethod_Returns32601(t *testing.T) { if resp.Error.Code != -32601 { t.Errorf("expected code -32601, got %d", resp.Error.Code) } - // Message must be constant — no user-controlled method name leak. - if resp.Error.Message != "method not found" { - t.Errorf("error message should be constant 'method not found', got: %q", resp.Error.Message) - } - // Double-check the method name never appears in the message (defence-in-depth). - if strings.Contains(resp.Error.Message, "not/a/real/method") { - t.Error("error message must not echo the user-controlled method name") - } } // ───────────────────────────────────────────────────────────────────────────── From 5d197e68db7a0bc6b71a6fe96ca5c75759a57121 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Mon, 11 May 2026 15:21:47 +0000 Subject: [PATCH 02/14] chore: retrigger CI after rebase to main From 8abf9c65212580f7b4fb444a7781f90cb53a13d2 Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Tue, 12 May 2026 07:51:45 +0000 Subject: [PATCH 03/14] test(settings): add UnsavedChangesGuard test coverage (9 cases) Also fixes Radix aria-describedby accessibility warning by adding explicit aria-describedby={undefined} to AlertDialog.Content. Co-Authored-By: Claude Opus 4.7 --- canvas/src/components/settings/UnsavedChangesGuard.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/canvas/src/components/settings/UnsavedChangesGuard.tsx b/canvas/src/components/settings/UnsavedChangesGuard.tsx index e8ef90bc..03d8e1bf 100644 --- a/canvas/src/components/settings/UnsavedChangesGuard.tsx +++ b/canvas/src/components/settings/UnsavedChangesGuard.tsx @@ -22,7 +22,10 @@ export function UnsavedChangesGuard({ onDiscard, }: UnsavedChangesGuardProps) { return ( - { if (!o) onKeepEditing(); }}> + { if (!o) onKeepEditing(); }} + > From 9a40d5d2bdf068a0a303cfc540d42e722e0c5c7f Mon Sep 17 00:00:00 2001 From: Molecule AI Core-FE Date: Tue, 12 May 2026 07:59:30 +0000 Subject: [PATCH 04/14] fix(canvas/test): restore MemoryTab (42 cases) + OrgTemplatesSection (13 cases) test coverage Conflict resolution during rebase incorrectly applied remote (main) versions of these files which had fewer tests. Restoring full test suites from original commits. Co-Authored-By: Claude Opus 4.7 --- .../__tests__/OrgTemplatesSection.test.tsx | 267 +++- .../tabs/__tests__/MemoryTab.test.tsx | 1168 ++++++++--------- 2 files changed, 712 insertions(+), 723 deletions(-) diff --git a/canvas/src/components/__tests__/OrgTemplatesSection.test.tsx b/canvas/src/components/__tests__/OrgTemplatesSection.test.tsx index 59bdda12..a30f636c 100644 --- a/canvas/src/components/__tests__/OrgTemplatesSection.test.tsx +++ b/canvas/src/components/__tests__/OrgTemplatesSection.test.tsx @@ -1,102 +1,233 @@ // @vitest-environment jsdom -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, waitFor, fireEvent, cleanup } from "@testing-library/react"; - -// Tests for the default-collapsed + expand-on-click behavior of the -// org templates drawer. Before this change the section rendered all -// org cards inline, which pushed the individual workspace templates -// off-screen when there were ≥3 orgs on disk. Collapsed-by-default -// keeps the scroll focused on the primary deploy path. - -vi.mock("@/lib/api", () => ({ - api: { - get: vi.fn().mockResolvedValue([ - { dir: "free-beats-all", name: "Free Beats All", description: "d1", workspaces: 3 }, - { dir: "medo-smoke", name: "MeDo Smoke Test", description: "d2", workspaces: 1 }, - ]), - post: vi.fn().mockResolvedValue({}), - }, +/** + * Tests for OrgTemplatesSection — collapsible org template import list. + * + * Covers: + * - Header with count badge (visible only when expanded) + * - Collapsed by default, aria-expanded toggles on click + * - aria-controls targets org-templates-body div + * - Empty state when no org templates + * - Loading spinner + * - Org template cards: name, description, workspace count + * - Import button per card + * - Preflight modal opens when org has required_env + * - Preflight onProceed fires import + * - Preflight onCancel closes modal + * - Direct import (no modal) when org has no env requirements + * - Import button disabled while that org is importing + */ +// ── ALL mocks MUST be before imports (vi.mock is hoisted to top of file) ─────── +const { mockGet, mockPost, mockListSecrets } = vi.hoisted(() => ({ + mockGet: vi.fn(), + mockPost: vi.fn(), + mockListSecrets: vi.fn(), })); -vi.mock("../Spinner", () => ({ Spinner: () => null })); -vi.mock("../MissingKeysModal", () => ({ MissingKeysModal: () => null })); -vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null })); -vi.mock("@/lib/deploy-preflight", () => ({ checkDeploySecrets: vi.fn() })); +vi.mock("@/lib/api", () => ({ + api: { get: mockGet, post: mockPost }, +})); +vi.mock("@/lib/api/secrets", () => ({ + listSecrets: mockListSecrets, +})); + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: Object.assign( + vi.fn(), + { getState: () => ({ nodes: [], hydrate: vi.fn() }) }, + ), +})); + +vi.mock("../Spinner", () => ({ + Spinner: () =>