diff --git a/.gitea/workflows/redeploy-tenants-on-main.yml b/.gitea/workflows/redeploy-tenants-on-main.yml index 456c2542..8568b217 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,14 @@ 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**~~ 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). # -# 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 +34,60 @@ 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: + push: + branches: [main] + paths: + - '.gitea/workflows/publish-workspace-server-image.yml' workflow_dispatch: 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/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/__tests__/OrgTemplatesSection.test.tsx b/canvas/src/components/__tests__/OrgTemplatesSection.test.tsx index 59bdda12..f464036a 100644 --- a/canvas/src/components/__tests__/OrgTemplatesSection.test.tsx +++ b/canvas/src/components/__tests__/OrgTemplatesSection.test.tsx @@ -1,102 +1,237 @@ // @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: () =>