diff --git a/.github/workflows/e2e-staging-canvas.yml b/.github/workflows/e2e-staging-canvas.yml
index c1620a20..6c59e72a 100644
--- a/.github/workflows/e2e-staging-canvas.yml
+++ b/.github/workflows/e2e-staging-canvas.yml
@@ -184,8 +184,23 @@ jobs:
exit 0
fi
echo "Deleting orphan tenant: $slug"
- curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
+ # Verify HTTP 2xx instead of `>/dev/null || true` swallowing
+ # failures. A 5xx or timeout previously looked identical to
+ # success, leaving the tenant alive for up to ~45 min until
+ # sweep-stale-e2e-orgs caught it. Surface failures as
+ # workflow warnings naming the slug. Don't `exit 1` — a single
+ # cleanup miss shouldn't fail-flag the canvas test when the
+ # actual smoke check passed; the sweeper is the safety net.
+ # See molecule-controlplane#420.
+ code=$(curl -sS -o /tmp/canvas-cleanup.out -w "%{http_code}" \
+ -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
- -d "{\"confirm\":\"$slug\"}" >/dev/null || true
+ -d "{\"confirm\":\"$slug\"}" \
+ || echo "000")
+ if [ "$code" = "200" ] || [ "$code" = "204" ]; then
+ echo "[teardown] deleted $slug (HTTP $code)"
+ else
+ echo "::warning::canvas teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/canvas-cleanup.out 2>/dev/null)"
+ fi
exit 0
diff --git a/.github/workflows/e2e-staging-external.yml b/.github/workflows/e2e-staging-external.yml
index d1d8def7..12ac4577 100644
--- a/.github/workflows/e2e-staging-external.yml
+++ b/.github/workflows/e2e-staging-external.yml
@@ -153,12 +153,28 @@ jobs:
if [ -n "$orgs" ]; then
echo "Safety-net sweep: deleting leftover orgs:"
echo "$orgs"
+ # Per-slug verified DELETE — see molecule-controlplane#420.
+ # `>/dev/null 2>&1` previously hid every failure; surface
+ # non-2xx as workflow warnings so the run page names what
+ # leaked. Sweeper catches the rest within ~45 min.
+ leaks=()
for slug in $orgs; do
- curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
+ code=$(curl -sS -o /tmp/external-cleanup.out -w "%{http_code}" \
+ -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
- -d "{\"confirm\":\"$slug\"}" >/dev/null 2>&1
+ -d "{\"confirm\":\"$slug\"}" \
+ || echo "000")
+ if [ "$code" = "200" ] || [ "$code" = "204" ]; then
+ echo "[teardown] deleted $slug (HTTP $code)"
+ else
+ echo "::warning::external teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/external-cleanup.out 2>/dev/null)"
+ leaks+=("$slug")
+ fi
done
+ if [ ${#leaks[@]} -gt 0 ]; then
+ echo "::warning::external teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
+ fi
else
echo "Safety-net sweep: no leftover orgs to clean."
fi
diff --git a/.github/workflows/e2e-staging-saas.yml b/.github/workflows/e2e-staging-saas.yml
index f055c491..2a7efe16 100644
--- a/.github/workflows/e2e-staging-saas.yml
+++ b/.github/workflows/e2e-staging-saas.yml
@@ -164,11 +164,27 @@ jobs:
and o.get('instance_status') not in ('purged',)]
print('\n'.join(candidates))
" 2>/dev/null)
+ # Per-slug verified DELETE (was `>/dev/null || true` — see
+ # molecule-controlplane#420). Surface non-2xx as a workflow
+ # warning naming the leaked slug; don't exit 1 (sweeper is
+ # the safety net within ~45 min).
+ leaks=()
for slug in $orgs; do
echo "Safety-net teardown: $slug"
- curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
+ code=$(curl -sS -o /tmp/saas-cleanup.out -w "%{http_code}" \
+ -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
- -d "{\"confirm\":\"$slug\"}" >/dev/null || true
+ -d "{\"confirm\":\"$slug\"}" \
+ || echo "000")
+ if [ "$code" = "200" ] || [ "$code" = "204" ]; then
+ echo "[teardown] deleted $slug (HTTP $code)"
+ else
+ echo "::warning::saas teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/saas-cleanup.out 2>/dev/null)"
+ leaks+=("$slug")
+ fi
done
+ if [ ${#leaks[@]} -gt 0 ]; then
+ echo "::warning::saas teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
+ fi
exit 0
diff --git a/.github/workflows/e2e-staging-sanity.yml b/.github/workflows/e2e-staging-sanity.yml
index edfa5359..e98b38fe 100644
--- a/.github/workflows/e2e-staging-sanity.yml
+++ b/.github/workflows/e2e-staging-sanity.yml
@@ -143,10 +143,25 @@ jobs:
and o.get('status') not in ('purged',)]
print('\n'.join(candidates))
" 2>/dev/null)
+ # Per-slug verified DELETE — see molecule-controlplane#420.
+ # Failures surface as workflow warnings; the sweeper is the
+ # safety net within ~45 min.
+ leaks=()
for slug in $orgs; do
- curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
+ code=$(curl -sS -o /tmp/sanity-cleanup.out -w "%{http_code}" \
+ -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
- -d "{\"confirm\":\"$slug\"}" >/dev/null || true
+ -d "{\"confirm\":\"$slug\"}" \
+ || echo "000")
+ if [ "$code" = "200" ] || [ "$code" = "204" ]; then
+ echo "[teardown] deleted $slug (HTTP $code)"
+ else
+ echo "::warning::sanity teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/sanity-cleanup.out 2>/dev/null)"
+ leaks+=("$slug")
+ fi
done
+ if [ ${#leaks[@]} -gt 0 ]; then
+ echo "::warning::sanity teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
+ fi
exit 0
diff --git a/canvas/src/components/BatchActionBar.tsx b/canvas/src/components/BatchActionBar.tsx
index 004b3205..2a293631 100644
--- a/canvas/src/components/BatchActionBar.tsx
+++ b/canvas/src/components/BatchActionBar.tsx
@@ -30,6 +30,24 @@ export function BatchActionBar() {
if (count === 0 && hasFailedBatch) setHasFailedBatch(false);
}, [count, hasFailedBatch]);
+ // Esc clears selection — the deselect button title has been promising
+ // "(Escape)" since the bar shipped, but no handler was wired. Skip when
+ // the confirm dialog is open (`pending !== null`) so the dialog's own
+ // Esc-cancels takes precedence and we don't double-handle the keystroke.
+ // Also skip during a busy in-flight action so the user can't accidentally
+ // strand a partial-failure mid-flight.
+ useEffect(() => {
+ if (count === 0 || pending !== null || busy) return;
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ e.stopPropagation();
+ clearSelection();
+ }
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [count, pending, busy, clearSelection]);
+
// Hide when nothing is selected. Hide for single-node selection UNLESS a
// partial-failure left a survivor awaiting retry.
if (count === 0) return null;
@@ -129,7 +147,7 @@ export function BatchActionBar() {
onClick={clearSelection}
aria-label="Clear selection"
title="Clear selection (Escape)"
- className="p-1.5 rounded-lg text-[12px] text-ink-mid hover:text-ink hover:bg-surface-card/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500/70"
+ className="p-1.5 rounded-lg text-[12px] text-ink-mid hover:text-ink hover:bg-surface-card/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
>
✕
diff --git a/canvas/src/components/__tests__/BatchActionBar.test.tsx b/canvas/src/components/__tests__/BatchActionBar.test.tsx
index 4dd9fef6..f72b7570 100644
--- a/canvas/src/components/__tests__/BatchActionBar.test.tsx
+++ b/canvas/src/components/__tests__/BatchActionBar.test.tsx
@@ -130,6 +130,26 @@ describe("BatchActionBar", () => {
const toolbar = screen.getByRole("toolbar");
expect(toolbar.getAttribute("aria-label")).toBe("Batch workspace actions");
});
+
+ it("Esc clears the selection — matches the deselect button title", () => {
+ // The deselect button has been promising "Clear selection (Escape)"
+ // since the bar shipped, but no handler was wired. This pins the
+ // contract.
+ mockSelectedNodeIds = new Set(["ws-1", "ws-2"]);
+ render();
+ fireEvent.keyDown(window, { key: "Escape" });
+ expect(mockClearSelection).toHaveBeenCalled();
+ });
+
+ it("Esc is a no-op when nothing is selected", () => {
+ mockSelectedNodeIds = new Set();
+ render();
+ fireEvent.keyDown(window, { key: "Escape" });
+ // The early-return at count===0 prevents the bar from mounting at all,
+ // so the keydown listener never registers. clearSelection must NOT be
+ // called.
+ expect(mockClearSelection).not.toHaveBeenCalled();
+ });
});
/**