forked from molecule-ai/molecule-core
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74297485c0 | |||
| c53ca971c3 | |||
| 8e8334795e | |||
| f3bd81244e | |||
| 9e53a426ef | |||
| e3cc0adcd5 | |||
| 6ef7aa7361 | |||
| 6cfb0bf5bf | |||
| 0036d94ec2 |
@@ -1 +0,0 @@
|
||||
CI re-trigger at Tue Apr 21 15:40:21 UTC 2026\n
|
||||
@@ -1,41 +0,0 @@
|
||||
# Coverage allowlist — security-critical files that are currently below
|
||||
# the 10% per-file floor and are being tracked for remediation.
|
||||
#
|
||||
# Format: one path per line, relative to workspace-server/.
|
||||
# Lines starting with # and blank lines are ignored.
|
||||
#
|
||||
# Process:
|
||||
# - A path in this list is WARNED on each CI run, not failed.
|
||||
# - Each entry must reference a tracking issue and expiry date.
|
||||
# - On expiry, either the coverage is fixed OR the path graduates to
|
||||
# hard-fail (revert the allowlist entry).
|
||||
#
|
||||
# See #1823 for the gate design and ratchet plan.
|
||||
|
||||
# ============== Active exceptions ==============
|
||||
|
||||
# Filed 2026-04-23 — expiry 2026-05-23 (30 days). Tracking: #1823.
|
||||
# These are the files flagged by the first run of the critical-path gate.
|
||||
# QA team + platform team share ownership of test coverage remediation.
|
||||
|
||||
internal/handlers/a2a_proxy.go
|
||||
internal/handlers/a2a_proxy_helpers.go
|
||||
internal/handlers/registry.go
|
||||
internal/handlers/secrets.go
|
||||
internal/handlers/tokens.go
|
||||
internal/handlers/workspace_provision.go
|
||||
internal/middleware/wsauth_middleware.go
|
||||
|
||||
# The following paths matched via looser CRITICAL_PATH substrings
|
||||
# (e.g. "registry" matched both internal/registry/ and internal/channels/registry.go).
|
||||
# Adding them here so the gate can land without blocking staging merges;
|
||||
# a follow-up PR will tighten CRITICAL_PATHS to exact prefixes so these
|
||||
# graduate to hard-fail precisely where security-critical.
|
||||
|
||||
internal/channels/registry.go
|
||||
internal/crypto/aes.go
|
||||
internal/registry/access.go
|
||||
internal/registry/healthsweep.go
|
||||
internal/registry/hibernation.go
|
||||
internal/registry/provisiontimeout.go
|
||||
internal/wsauth/tokens.go
|
||||
+6
-31
@@ -1,23 +1,13 @@
|
||||
# Postgres
|
||||
# These defaults match docker-compose.infra.yml, which is the stack
|
||||
# launched by `./infra/scripts/setup.sh`. Override for production.
|
||||
POSTGRES_USER=dev
|
||||
POSTGRES_PASSWORD=dev
|
||||
POSTGRES_USER=
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_DB=molecule
|
||||
# DATABASE_URL points at the host-published Postgres port so that
|
||||
# `go run ./cmd/server` on the host (the README quickstart path) can
|
||||
# connect. When running the platform *inside* docker-compose.yml, the
|
||||
# compose file builds a DATABASE_URL with host `postgres` automatically
|
||||
# from POSTGRES_USER/PASSWORD/DB above — that path ignores this value.
|
||||
DATABASE_URL=postgres://dev:dev@localhost:5432/molecule?sslmode=disable
|
||||
DATABASE_URL=postgres://USER:PASS@postgres:5432/molecule?sslmode=disable
|
||||
|
||||
# Redis — same host-vs-container story as DATABASE_URL above.
|
||||
REDIS_URL=redis://localhost:6379
|
||||
# Redis
|
||||
REDIS_URL=redis://redis:6379
|
||||
|
||||
# Platform
|
||||
# PORT only applies to the Go platform (workspace-server). The Canvas pins
|
||||
# itself to 3000 in canvas/package.json, so sourcing this file before
|
||||
# `npm run dev` won't accidentally make Next.js try to bind 8080.
|
||||
PORT=8080
|
||||
# ---- Admin credential — REQUIRED to close issue #684 (AdminAuth bearer bypass) ----
|
||||
# When ADMIN_TOKEN is set, only this value is accepted on /admin/* and /approvals/* routes.
|
||||
@@ -34,7 +24,7 @@ PLUGINS_DIR= # Path to plugins/ directory (default: /plugins i
|
||||
# MOLECULE_MCP_ALLOW_SEND_MESSAGE= # Set to "true" to include send_message_to_user in the MCP bridge tool list (issue #810). Excluded by default to prevent unintended WebSocket pushes from CLI sessions.
|
||||
# MOLECULE_MCP_URL=http://localhost:8080 # Platform URL for opencode MCP config (opencode.json). Same as PLATFORM_URL; separate var so opencode configs can reference it without ambiguity.
|
||||
# WORKSPACE_DIR= # Optional global host path bind-mounted to /workspace in every container. Per-workspace workspace_dir column overrides this; if neither is set each workspace gets an isolated Docker named volume.
|
||||
MOLECULE_ENV=development # Environment label (development/staging/production). Used for log tagging and for the AdminAuth dev-mode escape hatch (lets the Canvas dashboard keep working after the first workspace is created, when ADMIN_TOKEN is unset). SaaS deployments MUST set MOLECULE_ENV=production.
|
||||
# MOLECULE_ENV=development # Environment label (development/staging/production). Used for log tagging and conditional behaviour.
|
||||
# MOLECULE_ENABLE_TEST_TOKENS= # Set to 1 to expose GET /admin/workspaces/:id/test-token (mints a fresh bearer token for E2E scripts). The route is auto-enabled when MOLECULE_ENV != production; this flag is the explicit override. Leave unset/0 in prod — the route 404s unless enabled.
|
||||
# MOLECULE_ORG_ID= # SaaS only: org UUID set by control plane on tenant machines. When set, workspace provisioning auto-routes through the control plane API instead of Docker.
|
||||
# CP_PROVISION_URL= # Override control plane URL for workspace provisioning (default: https://api.moleculesai.app). Only needed for testing against a non-production control plane.
|
||||
@@ -168,18 +158,3 @@ GSC_SERVICE_ACCOUNT= # Search Console reporter service account email
|
||||
# Token goes in Authorization: Bearer header — never embed in the URL.
|
||||
MOLECULE_MCP_URL= # e.g. https://api.molecule.ai or http://localhost:8080
|
||||
MOLECULE_MCP_TOKEN= # workspace-scoped bearer token — NEVER COMMIT
|
||||
|
||||
# ---- workspace-template image refresh ----
|
||||
# IMAGE_AUTO_REFRESH=true makes the platform poll GHCR every 5 min for digest
|
||||
# changes on each workspace-template-*:latest. When a digest moves the
|
||||
# platform pulls + force-recreates matching ws-* containers (same code path
|
||||
# as POST /admin/workspace-images/refresh). Closes the runtime CD chain to
|
||||
# zero operator steps.
|
||||
# Default in docker-compose.yml is "true" for local dev so the runtime → ws
|
||||
# loop is tight; explicit override here lets you turn it off when running a
|
||||
# long test that shouldn't be disturbed by a publish.
|
||||
IMAGE_AUTO_REFRESH= # true|false; unset = inherit compose default (true for local dev)
|
||||
# GHCR_USER + GHCR_TOKEN are required only for private template images
|
||||
# (current workspace-template-* set is public; both can stay unset).
|
||||
GHCR_USER=
|
||||
GHCR_TOKEN=
|
||||
|
||||
@@ -13,11 +13,3 @@ workspace/entrypoint.sh text eol=lf
|
||||
# but keep LF for consistency across platforms.
|
||||
Dockerfile text eol=lf
|
||||
*.dockerfile text eol=lf
|
||||
|
||||
# Snapshot golden files — workspace/tests/snapshots/*.txt is consumed by
|
||||
# byte-exact comparisons in test_platform_tools.py. A Windows contributor
|
||||
# with auto-CRLF=true would otherwise convert \n → \r\n on checkout, the
|
||||
# snapshot tests would fail mysteriously locally / pass in CI (or vice
|
||||
# versa), and the regen instructions in the test-file header would
|
||||
# produce LF files that disagree with the working-copy CRLF versions.
|
||||
workspace/tests/snapshots/*.txt text eol=lf
|
||||
|
||||
+8
-78
@@ -95,91 +95,21 @@ if [ -n "$STAGED_GO" ]; then
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# 5. Go: build check — catches bot-generated structurally-invalid Go (#1770)
|
||||
# 5. Secrets: No tokens/keys in staged files
|
||||
# ──────────────────────────────────────────────────────────
|
||||
#
|
||||
# Background: bot agents have produced syntactically-broken Go that the
|
||||
# patch tool happily applied (e.g. PR #1769 commit 66ea0b64 — function
|
||||
# declaration nested inside another function's body). Compilation failed,
|
||||
# staging Platform(Go) was red for hours. CI catches this AT PR-time but
|
||||
# by then the malformed commit is already shared.
|
||||
#
|
||||
# Pre-commit guard: when ANY .go file in workspace-server/ is staged, run
|
||||
# `go build ./...` from workspace-server. If it fails, reject the commit.
|
||||
# Cost: ~5-10s on a warm cache; acceptable for the class of bug it
|
||||
# catches. Skip when go isn't available (CI runners that need to bypass).
|
||||
|
||||
if [ -n "$STAGED_GO" ]; then
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
if ! (cd workspace-server && go build ./... >/tmp/precommit-go-build.log 2>&1); then
|
||||
echo "❌ GO BUILD FAILED — staged Go changes don't compile (workspace-server/)."
|
||||
echo " Output:"
|
||||
sed 's/^/ /' /tmp/precommit-go-build.log | head -20
|
||||
echo " Fix the build error before committing. See #1770 for context."
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
else
|
||||
# Bots and CI runners may bypass when go isn't installed — surface a
|
||||
# warning so the absence is visible, but don't block. Humans hit this
|
||||
# only if they didn't run setup.sh.
|
||||
echo "⚠️ go not installed — skipping go-build pre-commit check (#1770)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# 6. Secrets: No tokens/keys in staged files
|
||||
# ──────────────────────────────────────────────────────────
|
||||
#
|
||||
# Pattern set MUST match .github/workflows/secret-scan.yml SECRET_PATTERNS
|
||||
# and molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh —
|
||||
# .github/workflows/secret-pattern-drift.yml lints this invariant. Rebuilt
|
||||
# against canonical 2026-05-02 after #1569 Phase 1 discovery surfaced
|
||||
# real ghs_*/github_pat_* leaks that the prior pattern set
|
||||
# ('sk-ant-|sk-proj-|ghp_|gho_|AKIA|mol_pk_|cfut_') would have missed:
|
||||
# (a) it lacked ghs_ / ghu_ / ghr_ / github_pat_ / sk-svcacct- / sk-cp- /
|
||||
# xox[baprs]- / ASIA prefixes, (b) it skipped *.md and docs/* — but the
|
||||
# actual leaks lived in tick-reflections-temp.md, qa-audit-2026-04-21.md,
|
||||
# docs/incidents/INCIDENT_LOG.md.
|
||||
SECRET_PATTERNS=(
|
||||
'ghp_[A-Za-z0-9]{36,}' # GitHub PAT (classic)
|
||||
'ghs_[A-Za-z0-9]{36,}' # GitHub App installation token
|
||||
'gho_[A-Za-z0-9]{36,}' # GitHub OAuth user-to-server
|
||||
'ghu_[A-Za-z0-9]{36,}' # GitHub OAuth user
|
||||
'ghr_[A-Za-z0-9]{36,}' # GitHub OAuth refresh
|
||||
'github_pat_[A-Za-z0-9_]{82,}' # GitHub fine-grained PAT
|
||||
'sk-ant-[A-Za-z0-9_-]{40,}' # Anthropic API key
|
||||
'sk-proj-[A-Za-z0-9_-]{40,}' # OpenAI project key
|
||||
'sk-svcacct-[A-Za-z0-9_-]{40,}' # OpenAI service-account key
|
||||
'sk-cp-[A-Za-z0-9_-]{60,}' # MiniMax API key (F1088 vector — caught only after the fact)
|
||||
'xox[baprs]-[A-Za-z0-9-]{20,}' # Slack tokens (bot/app/user/refresh)
|
||||
'AKIA[0-9A-Z]{16}' # AWS access key ID
|
||||
'ASIA[0-9A-Z]{16}' # AWS STS temp access key ID
|
||||
)
|
||||
|
||||
ALL_STAGED=$(git diff --cached --name-only --diff-filter=ACM || true)
|
||||
if [ -n "$ALL_STAGED" ]; then
|
||||
for f in $ALL_STAGED; do
|
||||
# Skip ONLY binary + lockfiles + the hook itself. Markdown +
|
||||
# docs/* are NOT skipped — that was the bug (#1569 leaks were
|
||||
# all in *.md). If a doc legitimately needs a token-shaped
|
||||
# placeholder, use ghs_EXAMPLE_TOKEN_DO_NOT_USE — short enough
|
||||
# to dodge the {36,} length suffix.
|
||||
if echo "$f" | grep -qE '\.png$|\.jpg$|\.ico$|\.woff|node_modules|\.lock$|\.githooks/'; then
|
||||
# Skip binary, known safe files, hooks, docs, and markdown
|
||||
if echo "$f" | grep -qE '\.png$|\.jpg$|\.ico$|\.woff|node_modules|\.lock$|\.githooks/|\.md$|docs/'; then
|
||||
continue
|
||||
fi
|
||||
DIFF=$(git diff --cached --no-color --unified=0 -- "$f" 2>/dev/null | grep -E '^\+[^+]' || true)
|
||||
[ -z "$DIFF" ] && continue
|
||||
for pattern in "${SECRET_PATTERNS[@]}"; do
|
||||
if echo "$DIFF" | grep -qE "$pattern"; then
|
||||
echo "❌ POSSIBLE SECRET in $f (matched: ${pattern})"
|
||||
echo " The actual matched value is NOT echoed here — round-tripping a"
|
||||
echo " leaked credential into scrollback widens the blast radius."
|
||||
echo " If false positive (test/docs example), use a short placeholder"
|
||||
echo " like ghs_EXAMPLE_TOKEN_DO_NOT_USE that doesn't satisfy the length."
|
||||
ERRORS=$((ERRORS + 1))
|
||||
break
|
||||
fi
|
||||
done
|
||||
DIFF=$(git diff --cached "$f" 2>/dev/null | grep '^+' | grep -v '^+++' || true)
|
||||
if echo "$DIFF" | grep -qE 'sk-ant-|sk-proj-|ghp_|gho_|AKIA[A-Z0-9]|mol_pk_|cfut_' 2>/dev/null; then
|
||||
echo "❌ POSSIBLE SECRET in $f — do not commit API keys or tokens"
|
||||
ERRORS=$((ERRORS + 1))
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Default reviewer routing for molecule-core.
|
||||
#
|
||||
# `*` matches every changed path, so every PR auto-requests review from
|
||||
# @hongmingwang-moleculeai. The agent-PR pattern is that the
|
||||
# HongmingWang-Rabbit (agent) account authors PRs; this file routes
|
||||
# them into the personal account's review queue automatically — no
|
||||
# manual `gh pr edit --add-reviewer` per PR.
|
||||
#
|
||||
# Why CODEOWNERS instead of branch-protection's review-from-anyone gate:
|
||||
# the gate just says "1 review needed"; CODEOWNERS specifies *which*
|
||||
# reviewer the request goes to. Without it, agent PRs sit unreviewed
|
||||
# until a human happens to look at the queue.
|
||||
#
|
||||
# Note: `require_code_owner_reviews` on the staging branch protection
|
||||
# is currently OFF, so the routing is informational rather than
|
||||
# enforced. Flip it on (in branch protection settings) if you want
|
||||
# CODEOWNERS approval to be the *required* review type. Until then,
|
||||
# any approving review still satisfies the 1-review gate — this just
|
||||
# makes sure the right person sees it.
|
||||
* @hongmingwang-moleculeai
|
||||
@@ -1,80 +0,0 @@
|
||||
# Dependabot — auto-bump pinned dependencies.
|
||||
#
|
||||
# Why this exists:
|
||||
#
|
||||
# All `uses:` references in .github/workflows/*.yml are pinned to commit
|
||||
# SHAs (with `# v<N>` comments for human readability) instead of mutable
|
||||
# tags like `@v4`. Tag pinning is a known supply-chain risk: a maintainer
|
||||
# (or compromised maintainer account) can repoint `@v4` to malicious code
|
||||
# and our pipelines silently pull it. SHA pinning closes that risk.
|
||||
#
|
||||
# But SHA pinning has a maintenance cost: each upstream legitimate fix
|
||||
# requires manually finding + bumping the SHA. Dependabot for Actions
|
||||
# closes that gap by opening PRs to bump pinned SHAs whenever upstream
|
||||
# tags a new version. Reviewer evaluates the bump like any other
|
||||
# dependency PR.
|
||||
#
|
||||
# Combined: SHA pinning gives us security, Dependabot keeps us current.
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# GitHub Actions — every workflow file under .github/workflows/.
|
||||
# Weekly cadence is enough for a CI surface this size; the supply-
|
||||
# chain attack window is "minutes between repoint and pull," and
|
||||
# weekly auto-bumps don't help with zero-days regardless. The point
|
||||
# is to pull in non-zero-day fixes without operator effort, not to
|
||||
# be real-time.
|
||||
- package-ecosystem: github-actions
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- dependencies
|
||||
- github-actions
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
include: scope
|
||||
|
||||
# Go module — workspace-server. Bumps go.mod deps via PR weekly.
|
||||
- package-ecosystem: gomod
|
||||
directory: "/workspace-server"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- dependencies
|
||||
- go
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
include: scope
|
||||
|
||||
# npm — canvas (Next.js bundle). Largest dep tree in this repo;
|
||||
# weekly cadence keeps the security surface fresh without flooding
|
||||
# the queue. open-pull-requests-limit: 10 because npm churns more
|
||||
# than the others.
|
||||
- package-ecosystem: npm
|
||||
directory: "/canvas"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- dependencies
|
||||
- npm
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
include: scope
|
||||
|
||||
# Python — workspace runtime requirements. Pip/requirements.txt-
|
||||
# backed rather than pyproject.toml; Dependabot supports both.
|
||||
- package-ecosystem: pip
|
||||
directory: "/workspace"
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- dependencies
|
||||
- python
|
||||
commit-message:
|
||||
prefix: chore(deps)
|
||||
include: scope
|
||||
@@ -1,166 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lint SECRET_PATTERNS drift across known consumers of molecule-core's canonical.
|
||||
|
||||
The canonical SECRET_PATTERNS array in
|
||||
.github/workflows/secret-scan.yml is mirrored by every other side
|
||||
that scans for credentials: the workspace-runtime's bundled
|
||||
pre-commit hook, the molecule-controlplane inlined copy, etc. The
|
||||
mirror is enforced socially today — when someone adds a new pattern
|
||||
to canonical (e.g. the sk-cp- MiniMax token after F1088), the other
|
||||
sides are supposed to be updated in lockstep.
|
||||
|
||||
This script automates the check. Diffs the canonical's pattern set
|
||||
against each known public consumer and exits non-zero on any
|
||||
mismatch. Wired into a daily cron + on-push gate via
|
||||
.github/workflows/secret-pattern-drift.yml.
|
||||
|
||||
Private-repo consumers (currently molecule-controlplane's inlined
|
||||
copy) are out of scope here because the molecule-core workflow's
|
||||
GITHUB_TOKEN can't read other private repos in the org. They're
|
||||
expected to self-monitor via their own copy of this script — not a
|
||||
hard barrier, just a future expansion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
CANONICAL_FILE = Path(".github/workflows/secret-scan.yml")
|
||||
|
||||
# Public consumer mirrors. Each entry is (label, raw_url) — raw_url
|
||||
# points at the file's RAW content on the consumer's default branch
|
||||
# (or staging where applicable). Add an entry here when a new public
|
||||
# repo starts shipping its own SECRET_PATTERNS array.
|
||||
CONSUMERS: list[tuple[str, str]] = [
|
||||
(
|
||||
"molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh",
|
||||
"https://raw.githubusercontent.com/Molecule-AI/molecule-ai-workspace-runtime/main/molecule_runtime/scripts/pre-commit-checks.sh",
|
||||
),
|
||||
]
|
||||
|
||||
# In-repo consumers — paths read locally from the workflow checkout.
|
||||
# Read-from-disk avoids the staging→main lag that the URL fetcher
|
||||
# would hit (a freshly-edited canonical wouldn't yet be on the
|
||||
# consumer's default branch). Same drift semantics, no network.
|
||||
LOCAL_CONSUMERS: list[tuple[str, Path]] = [
|
||||
(
|
||||
".githooks/pre-commit (molecule-core local hook)",
|
||||
Path(".githooks/pre-commit"),
|
||||
),
|
||||
]
|
||||
|
||||
# Matches the SECRET_PATTERNS=( ... ) array in either yaml-indented
|
||||
# (the canonical workflow's `run:` block) or shell-flat (runtime
|
||||
# hook) format. Patterns inside are single-quoted Bash strings; we
|
||||
# pull each via _PATTERN_RE.
|
||||
#
|
||||
# Closing `)` is anchored to the start of a line (possibly indented)
|
||||
# because pattern comments like `# GitHub PAT (classic)` contain
|
||||
# their own `)` mid-line — a non-anchored regex would match through
|
||||
# the comment's paren and capture only the first pattern.
|
||||
_ARRAY_RE = re.compile(r"SECRET_PATTERNS=\((.*?)^\s*\)", re.DOTALL | re.MULTILINE)
|
||||
_PATTERN_RE = re.compile(r"'([^']+)'")
|
||||
|
||||
|
||||
def extract_patterns(content: str, source_label: str) -> list[str]:
|
||||
"""Pull the SECRET_PATTERNS list out of either format. Raises if missing."""
|
||||
m = _ARRAY_RE.search(content)
|
||||
if not m:
|
||||
raise SystemExit(f"::error::{source_label}: SECRET_PATTERNS=(...) array not found")
|
||||
return _PATTERN_RE.findall(m.group(1))
|
||||
|
||||
|
||||
def fetch(url: str) -> str:
|
||||
req = urllib.request.Request(
|
||||
url, headers={"User-Agent": "secret-pattern-drift-lint/1"}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return resp.read().decode("utf-8")
|
||||
|
||||
|
||||
def diff_patterns(canonical: list[str], consumer: list[str]) -> tuple[list[str], list[str]]:
|
||||
"""Return (missing_from_consumer, extra_in_consumer) — both sorted."""
|
||||
canonical_set = set(canonical)
|
||||
consumer_set = set(consumer)
|
||||
return (
|
||||
sorted(canonical_set - consumer_set),
|
||||
sorted(consumer_set - canonical_set),
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not CANONICAL_FILE.exists():
|
||||
print(f"::error::canonical not found at {CANONICAL_FILE}")
|
||||
return 1
|
||||
|
||||
canonical = extract_patterns(CANONICAL_FILE.read_text(), str(CANONICAL_FILE))
|
||||
print(f"canonical ({CANONICAL_FILE}): {len(canonical)} patterns")
|
||||
|
||||
drift = False
|
||||
|
||||
# In-repo consumers first — these are read from the workflow's own
|
||||
# checkout, so they never lag behind the canonical and a missing
|
||||
# file IS a real error (not a fetch warning).
|
||||
for label, path in LOCAL_CONSUMERS:
|
||||
if not path.exists():
|
||||
print(f"::error::{label}: file not found at {path}")
|
||||
drift = True
|
||||
continue
|
||||
consumer = extract_patterns(path.read_text(), label)
|
||||
missing, extra = diff_patterns(canonical, consumer)
|
||||
if not missing and not extra:
|
||||
print(f" ✓ {label}: aligned ({len(consumer)} patterns)")
|
||||
continue
|
||||
drift = True
|
||||
print(f"::error::DRIFT in {label}:")
|
||||
for p in missing:
|
||||
print(f" - missing from consumer: {p!r}")
|
||||
for p in extra:
|
||||
print(f" - extra in consumer (not in canonical): {p!r}")
|
||||
|
||||
for label, url in CONSUMERS:
|
||||
try:
|
||||
content = fetch(url)
|
||||
except Exception as e:
|
||||
# Fetch failures are warnings, not errors. A consumer
|
||||
# whose default branch was just renamed (or whose file
|
||||
# moved) shouldn't fail the lint until someone updates
|
||||
# the URL above. Real drift is the failure mode this
|
||||
# gate exists to catch — fetch reliability isn't.
|
||||
print(f"::warning::{label}: fetch failed ({e}) — skipping")
|
||||
continue
|
||||
|
||||
consumer = extract_patterns(content, label)
|
||||
missing, extra = diff_patterns(canonical, consumer)
|
||||
if not missing and not extra:
|
||||
print(f" ✓ {label}: aligned ({len(consumer)} patterns)")
|
||||
continue
|
||||
|
||||
drift = True
|
||||
print(f"::error::DRIFT in {label}:")
|
||||
for p in missing:
|
||||
print(f" - missing from consumer: {p!r}")
|
||||
for p in extra:
|
||||
print(f" - extra in consumer (not in canonical): {p!r}")
|
||||
|
||||
if drift:
|
||||
print()
|
||||
print("::error::SECRET_PATTERNS drift detected. Bring consumer(s) into")
|
||||
print("alignment with the canonical SECRET_PATTERNS array in")
|
||||
print(f"{CANONICAL_FILE} by adding the missing patterns and removing")
|
||||
print("any extras. The two sides must stay byte-aligned on the pattern")
|
||||
print("list — the runtime hook is the developer's local pre-commit,")
|
||||
print("the canonical is the org-wide CI gate, divergence means a token")
|
||||
print("can pass one but get rejected by the other.")
|
||||
return 1
|
||||
|
||||
print()
|
||||
print("✓ All known consumers aligned with canonical SECRET_PATTERNS.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,429 +0,0 @@
|
||||
name: Auto-promote :latest after main image build
|
||||
|
||||
# Retags `ghcr.io/molecule-ai/{platform,platform-tenant}:staging-<sha>`
|
||||
# → `:latest` after either the image build or E2E completes on a `main`
|
||||
# push, gated on E2E Staging SaaS not being red for that SHA.
|
||||
#
|
||||
# Why two triggers:
|
||||
#
|
||||
# `publish-workspace-server-image` and `e2e-staging-saas` are both
|
||||
# paths-filtered, but with DIFFERENT path sets:
|
||||
#
|
||||
# publish-workspace-server-image:
|
||||
# workspace-server/**, canvas/**, manifest.json
|
||||
#
|
||||
# e2e-staging-saas (full lifecycle):
|
||||
# workspace-server/internal/handlers/{registry,workspace_provision,
|
||||
# a2a_proxy}.go, workspace-server/internal/middleware/**,
|
||||
# workspace-server/internal/provisioner/**, tests/e2e/test_staging_full_saas.sh
|
||||
#
|
||||
# The E2E set is a strict SUBSET of the publish set. So:
|
||||
# - canvas/** changes → publish fires, E2E does not
|
||||
# - workspace-server/cmd/** changes → publish fires, E2E does not
|
||||
# - workspace-server/internal/sweep/** → publish fires, E2E does not
|
||||
#
|
||||
# The previous version triggered ONLY on E2E completion, which meant
|
||||
# non-E2E-path changes (canvas, cmd, sweep, etc.) rebuilt the image
|
||||
# but never advanced `:latest`. Result: as of 2026-04-28 this workflow
|
||||
# had run zero times since merge despite eight main pushes — `:latest`
|
||||
# was ~7 hours / 9 PRs behind main with no human realising. See
|
||||
# `molecule-core` Slack discussion 2026-04-28.
|
||||
#
|
||||
# Adding `publish-workspace-server-image` as a second trigger closes
|
||||
# the gap: any image rebuild on main eligibly advances `:latest`.
|
||||
#
|
||||
# Why E2E remains a kill-switch (not the trigger):
|
||||
#
|
||||
# When E2E DID run for this SHA and ended red, we abort — `:latest`
|
||||
# stays on the prior known-good digest. When E2E didn't run (paths
|
||||
# filtered out), we proceed: pre-merge gates already validated this
|
||||
# SHA on staging via auto-promote-staging requiring CI + E2E Canvas +
|
||||
# E2E API + CodeQL all green. Image content for non-E2E-paths
|
||||
# (canvas, cmd, sweep) is exercised by those staging gates.
|
||||
#
|
||||
# Why `main` only:
|
||||
#
|
||||
# `:latest` is what prod tenants pull. We only want SHAs that have
|
||||
# reached main (via auto-promote-staging) to advance `:latest`.
|
||||
# Triggering on staging would let a staging-only revert advance
|
||||
# `:latest` to a SHA that never reaches main, breaking the "production
|
||||
# runs what's on main" invariant.
|
||||
#
|
||||
# Idempotency:
|
||||
#
|
||||
# When a SHA touches paths that match BOTH publish and E2E, both
|
||||
# workflows fire and complete. Both trigger this workflow on
|
||||
# completion → two runs race. Both retag `:staging-<sha>` →
|
||||
# `:latest`. crane tag is idempotent (re-tagging the same digest is a
|
||||
# no-op), so the second run is harmless. concurrency group serializes
|
||||
# them anyway.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- 'E2E Staging SaaS (full lifecycle)'
|
||||
- 'publish-workspace-server-image'
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sha:
|
||||
description: 'Short sha to promote (override; defaults to upstream workflow_run head_sha)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
# Serialize promotes per-SHA so the publish+E2E both-fired race lands
|
||||
# cleanly. Different SHAs can promote in parallel.
|
||||
group: auto-promote-latest-${{ github.event.workflow_run.head_sha || github.event.inputs.sha || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/molecule-ai/platform
|
||||
TENANT_IMAGE_NAME: ghcr.io/molecule-ai/platform-tenant
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
# Proceed if upstream succeeded OR manual dispatch. Upstream-failure
|
||||
# paths are filtered here; the E2E-was-red kill-switch lives in the
|
||||
# gate-check step below (covers the case where upstream is publish
|
||||
# success but E2E for the same SHA failed).
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Compute short sha
|
||||
id: sha
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${{ github.event.inputs.sha }}" ]; then
|
||||
FULL="${{ github.event.inputs.sha }}"
|
||||
else
|
||||
FULL="${{ github.event.workflow_run.head_sha }}"
|
||||
fi
|
||||
echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT"
|
||||
echo "full=${FULL}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Gate — E2E Staging SaaS state for this SHA
|
||||
# When upstream IS E2E success, we know it's green (filtered by
|
||||
# the job-level `if` already). When upstream is publish, look up
|
||||
# E2E state for the same SHA. Four buckets:
|
||||
#
|
||||
# - completed/success: E2E confirmed safe → proceed
|
||||
# - completed/failure|cancelled|timed_out: E2E found a
|
||||
# regression → ABORT (exit 1), `:latest` stays put
|
||||
# - in_progress|queued|requested: E2E is RACING with publish
|
||||
# for a runtime-touching SHA. publish typically completes
|
||||
# ~5-10min before E2E (~10-15min). If we promote on the
|
||||
# publish signal here, a later E2E failure can't roll back
|
||||
# `:latest` — it'd already be wrongly advanced. So we DEFER:
|
||||
# skip subsequent steps (proceed=false) and let E2E's own
|
||||
# completion event re-fire this workflow, which then takes
|
||||
# the upstream-is-E2E path. exit 0 so the run shows as
|
||||
# success rather than a noisy fake-failure.
|
||||
# - none/none: E2E was paths-filtered out for this SHA (the
|
||||
# change touched canvas/cmd/sweep/etc. — paths covered by
|
||||
# publish but not by E2E). pre-merge gates on staging
|
||||
# already validated this SHA → proceed.
|
||||
#
|
||||
# Manual dispatch skips this check — operator override.
|
||||
id: gate
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ steps.sha.outputs.full }}
|
||||
UPSTREAM_NAME: ${{ github.event.workflow_run.name }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Manual dispatch — skipping E2E gate (operator override)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$UPSTREAM_NAME" = "E2E Staging SaaS (full lifecycle)" ]; then
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Upstream is E2E itself (success per job-level if) — gate trivially satisfied"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Upstream is publish-workspace-server-image. Check E2E state.
|
||||
# The jq filter must defend against TWO empty cases that gh
|
||||
# CLI emits indistinguishably:
|
||||
# 1. gh exits non-zero (network blip, auth issue) → handled
|
||||
# by the `|| echo "none/none"` fallback below.
|
||||
# 2. gh exits zero but returns `[]` (no E2E run on this
|
||||
# main SHA — the common case for canvas-only / cmd-only
|
||||
# / sweep-only changes whose paths don't trigger E2E).
|
||||
# Without `(.[0] // {})`, jq sees `null` and emits
|
||||
# "null/none" — which the case statement below has no
|
||||
# branch for, so it falls into *) → exit 1.
|
||||
# Surfaced 2026-04-30 the first time the App-token chain
|
||||
# (#2389) actually fired auto-promote-on-e2e from a publish
|
||||
# upstream — every prior run was E2E-upstream which
|
||||
# short-circuits before this gate.
|
||||
RESULT=$(gh run list \
|
||||
--repo "$REPO" \
|
||||
--workflow e2e-staging-saas.yml \
|
||||
--branch main \
|
||||
--commit "$SHA" \
|
||||
--limit 1 \
|
||||
--json status,conclusion \
|
||||
--jq '(.[0] // {}) | "\(.status // "none")/\(.conclusion // "none")"' \
|
||||
2>/dev/null || echo "none/none")
|
||||
|
||||
echo "E2E Staging SaaS for ${SHA:0:7}: $RESULT"
|
||||
|
||||
case "$RESULT" in
|
||||
completed/success)
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::E2E green for this SHA — proceeding with promote"
|
||||
;;
|
||||
completed/failure|completed/timed_out)
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## ❌ Auto-promote aborted — E2E Staging SaaS failed"
|
||||
echo
|
||||
echo "E2E Staging SaaS for \`${SHA:0:7}\`: \`$RESULT\`"
|
||||
echo "\`:latest\` stays on the prior known-good digest."
|
||||
echo
|
||||
echo "If the failure was a flake, manually dispatch this workflow with the same sha to override."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
;;
|
||||
completed/cancelled)
|
||||
# cancelled ≠ failure. Per-SHA concurrency cancels older E2E
|
||||
# runs when a newer push lands (memory:
|
||||
# feedback_concurrency_group_per_sha) — the newer SHA will
|
||||
# have its own E2E + promote chain. Treat the same as
|
||||
# in_progress: defer without aborting, let the next E2E run
|
||||
# promote when it lands.
|
||||
#
|
||||
# Caught 2026-05-05 02:03 on sha 31f9a5e — auto-promote
|
||||
# blocked the whole chain because this case fell through to
|
||||
# exit 1 instead of clean defer.
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## ⏭ Auto-promote deferred — E2E Staging SaaS was cancelled"
|
||||
echo
|
||||
echo "E2E Staging SaaS for \`${SHA:0:7}\`: \`$RESULT\`"
|
||||
echo "Likely per-SHA concurrency (newer push superseded this E2E run)."
|
||||
echo "The newer SHA's E2E will fire its own promote when it lands."
|
||||
echo "If you need this specific SHA promoted, manually dispatch."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
;;
|
||||
in_progress/*|queued/*|requested/*|waiting/*|pending/*)
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## ⏳ Auto-promote deferred — E2E Staging SaaS still running"
|
||||
echo
|
||||
echo "Publish completed before E2E for \`${SHA:0:7}\` (state: \`$RESULT\`)."
|
||||
echo "Skipping retag here — E2E's own completion event will re-fire this workflow."
|
||||
echo "If E2E ends green, that run promotes \`:latest\`. If red, it aborts."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
;;
|
||||
none/none)
|
||||
echo "proceed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::E2E paths-filtered out for this SHA — pre-merge staging gates carry"
|
||||
;;
|
||||
*)
|
||||
echo "proceed=false" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## ❓ Auto-promote aborted — unexpected E2E state"
|
||||
echo
|
||||
echo "E2E Staging SaaS for \`${SHA:0:7}\`: \`$RESULT\` (unhandled)"
|
||||
echo "Manual investigation needed; re-dispatch with the same sha once resolved."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- if: steps.gate.outputs.proceed == 'true'
|
||||
uses: imjasonh/setup-crane@6da1ae018866400525525ce74ff892880c099987 # v0.5
|
||||
|
||||
- name: GHCR login
|
||||
if: steps.gate.outputs.proceed == 'true'
|
||||
run: |
|
||||
echo "${{ secrets.GITHUB_TOKEN }}" | \
|
||||
crane auth login ghcr.io -u "${{ github.actor }}" --password-stdin
|
||||
|
||||
- name: Verify :staging-<sha> exists for both images
|
||||
# Better to fail fast with a clear message than to half-tag
|
||||
# (platform retagged but platform-tenant missing → tenants pull
|
||||
# a stale image).
|
||||
if: steps.gate.outputs.proceed == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for img in "${IMAGE_NAME}" "${TENANT_IMAGE_NAME}"; do
|
||||
tag="${img}:staging-${{ steps.sha.outputs.short }}"
|
||||
if ! crane manifest "$tag" >/dev/null 2>&1; then
|
||||
echo "::error::Missing tag: $tag"
|
||||
echo "::error::publish-workspace-server-image must complete on this SHA before auto-promote can retag :latest."
|
||||
exit 1
|
||||
fi
|
||||
echo " ok: $tag exists"
|
||||
done
|
||||
|
||||
- name: Ancestry check — refuse to promote :latest backwards
|
||||
# #2244: workflow_run completions arrive in arbitrary order. If
|
||||
# SHA-A and SHA-B both reach main within ~10 min and SHA-B's E2E
|
||||
# completes before SHA-A's, this workflow can fire for SHA-A
|
||||
# AFTER it already promoted SHA-B → :latest goes backwards. The
|
||||
# orphan-reconciler "next run corrects it" doesn't apply: there's
|
||||
# no auto-corrective re-promote, :latest stays wrong until the
|
||||
# next main push lands.
|
||||
#
|
||||
# Detection: read current :latest's `org.opencontainers.image.revision`
|
||||
# label (set by publish-workspace-server-image.yml at build time)
|
||||
# and ask the GitHub compare API whether the candidate SHA is
|
||||
# ahead-of / identical-to / behind / diverged-from current.
|
||||
# Hard-fail on `behind` and `diverged` per the approved design —
|
||||
# silent-bypass is the class we're moving away from. Workflow
|
||||
# goes red, oncall sees it, operator decides how to recover
|
||||
# (manual dispatch with the right SHA, force-promote, etc.).
|
||||
#
|
||||
# Manual dispatch skips this check — operator override semantics
|
||||
# match the gate-check step above.
|
||||
#
|
||||
# Backward-compat: when current :latest carries no revision
|
||||
# label (legacy image pre-publish-with-label), skip-with-warning.
|
||||
# All :latest images on main are post-label as of 2026-04-29, so
|
||||
# this branch will be dead within 90 days; remove then.
|
||||
if: steps.gate.outputs.proceed == 'true' && github.event_name != 'workflow_dispatch'
|
||||
id: ancestry
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
TARGET_SHA: ${{ steps.sha.outputs.full }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Read the current :latest config and pull the revision label.
|
||||
# `crane config` returns the OCI image config blob (not the manifest);
|
||||
# labels live under `.config.Labels`. `// empty` makes jq return ""
|
||||
# rather than the literal "null" so the test below works.
|
||||
CURRENT_REVISION=$(crane config "${IMAGE_NAME}:latest" 2>/dev/null \
|
||||
| jq -r '.config.Labels["org.opencontainers.image.revision"] // empty' \
|
||||
|| true)
|
||||
|
||||
if [ -z "$CURRENT_REVISION" ]; then
|
||||
echo "decision=skip-no-label" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## ⚠ Ancestry check skipped — current :latest has no revision label"
|
||||
echo
|
||||
echo "Likely a legacy image built before \`org.opencontainers.image.revision\` was set."
|
||||
echo "Falling through to retag. After all \`:latest\` images are post-label (TODO 90 days), this branch is dead and should be removed."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::warning::Current :latest carries no revision label — skipping ancestry check (legacy image)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$CURRENT_REVISION" = "$TARGET_SHA" ]; then
|
||||
echo "decision=identical" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice:::latest already at ${TARGET_SHA:0:7} — retag will be a no-op"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ask GitHub which side of the merge graph TARGET_SHA sits on
|
||||
# relative to CURRENT_REVISION. Returns one of: ahead | identical
|
||||
# | behind | diverged. Network or auth errors collapse to "error"
|
||||
# via the explicit fallback so the case below always matches.
|
||||
STATUS=$(gh api \
|
||||
"repos/${REPO}/compare/${CURRENT_REVISION}...${TARGET_SHA}" \
|
||||
--jq '.status' 2>/dev/null || echo "error")
|
||||
|
||||
echo "ancestry compare ${CURRENT_REVISION:0:7} → ${TARGET_SHA:0:7}: $STATUS"
|
||||
|
||||
case "$STATUS" in
|
||||
ahead)
|
||||
echo "decision=ahead" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Target ${TARGET_SHA:0:7} is ahead of current :latest (${CURRENT_REVISION:0:7}) — proceeding with retag"
|
||||
;;
|
||||
identical)
|
||||
echo "decision=identical" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Target identical to :latest — retag will be a no-op"
|
||||
;;
|
||||
behind)
|
||||
echo "decision=behind" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## ❌ Auto-promote refused — target is BEHIND current :latest"
|
||||
echo
|
||||
echo "| Field | Value |"
|
||||
echo "|---|---|"
|
||||
echo "| Target SHA | \`$TARGET_SHA\` |"
|
||||
echo "| Current :latest revision | \`$CURRENT_REVISION\` |"
|
||||
echo "| GitHub compare status | \`behind\` |"
|
||||
echo
|
||||
echo "This guard catches the workflow_run-completion-order race (#2244):"
|
||||
echo "two rapid main pushes whose E2Es complete out-of-order can otherwise"
|
||||
echo "promote \`:latest\` backwards. \`:latest\` stays on \`${CURRENT_REVISION:0:7}\`."
|
||||
echo
|
||||
echo "**Recovery:** if this is a legitimate revert that should land on \`:latest\`,"
|
||||
echo "manually dispatch this workflow with the target sha as input — the manual-dispatch"
|
||||
echo "path skips the ancestry check (operator override)."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
;;
|
||||
diverged)
|
||||
echo "decision=diverged" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## ❓ Auto-promote refused — history diverged"
|
||||
echo
|
||||
echo "| Field | Value |"
|
||||
echo "|---|---|"
|
||||
echo "| Target SHA | \`$TARGET_SHA\` |"
|
||||
echo "| Current :latest revision | \`$CURRENT_REVISION\` |"
|
||||
echo "| GitHub compare status | \`diverged\` |"
|
||||
echo
|
||||
echo "Likely cause: force-push rewrote main's history, leaving the previous"
|
||||
echo "\`:latest\` revision orphaned. Needs human review before \`:latest\` advances."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
;;
|
||||
error|*)
|
||||
echo "decision=error" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## ❌ Auto-promote aborted — ancestry-check API error"
|
||||
echo
|
||||
echo "\`gh api repos/${REPO}/compare/${CURRENT_REVISION}...${TARGET_SHA}\` returned unexpected status: \`$STATUS\`"
|
||||
echo
|
||||
echo "Manual dispatch with the target sha bypasses this check."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Retag platform :staging-<sha> → :latest
|
||||
if: steps.gate.outputs.proceed == 'true'
|
||||
run: |
|
||||
crane tag "${IMAGE_NAME}:staging-${{ steps.sha.outputs.short }}" latest
|
||||
|
||||
- name: Retag tenant :staging-<sha> → :latest
|
||||
if: steps.gate.outputs.proceed == 'true'
|
||||
run: |
|
||||
crane tag "${TENANT_IMAGE_NAME}:staging-${{ steps.sha.outputs.short }}" latest
|
||||
|
||||
- name: Summary
|
||||
if: steps.gate.outputs.proceed == 'true'
|
||||
run: |
|
||||
{
|
||||
echo "## :latest promoted to ${{ steps.sha.outputs.short }}"
|
||||
echo
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "- Trigger: manual dispatch"
|
||||
else
|
||||
echo "- Upstream: \`${{ github.event.workflow_run.name }}\` ([run](${{ github.event.workflow_run.html_url }}))"
|
||||
fi
|
||||
echo "- platform:staging-${{ steps.sha.outputs.short }} → :latest"
|
||||
echo "- platform-tenant:staging-${{ steps.sha.outputs.short }} → :latest"
|
||||
echo
|
||||
echo "Tenant fleet auto-pulls within 5 min via IMAGE_AUTO_REFRESH=true."
|
||||
echo "Force immediate fanout: dispatch redeploy-tenants-on-main.yml."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,434 +0,0 @@
|
||||
name: Auto-promote staging → main
|
||||
|
||||
# Fires after any of the staging-branch quality gates complete. When ALL
|
||||
# required gates are green on the same staging SHA, opens (or re-uses)
|
||||
# a PR `staging → main` and enables auto-merge so the merge queue lands
|
||||
# it. Closes the gap that historically let features sit on staging for
|
||||
# weeks waiting for a bulk promotion PR (see molecule-core#1496 for the
|
||||
# 1172-commit example).
|
||||
#
|
||||
# 2026-04-28 rewrite (PR #142): the previous version did a direct
|
||||
# `git merge --ff-only origin staging && git push origin main`. That
|
||||
# breaks against main's branch-protection ruleset, which requires
|
||||
# status checks "set by the expected GitHub apps" — direct pushes
|
||||
# can't satisfy that condition (only PR merges through the queue can).
|
||||
# The workflow was failing every tick with:
|
||||
# remote: error: GH006: Protected branch update failed for refs/heads/main.
|
||||
# remote: - Required status checks ... were not set by the expected GitHub apps.
|
||||
# Fix: mirror the PR-based pattern from auto-sync-main-to-staging.yml
|
||||
# (the reverse-direction sync, fixed in #2234 for the same reason).
|
||||
# Both directions now use the same merge-queue path that humans use,
|
||||
# no special-case bypass.
|
||||
#
|
||||
# Safety model:
|
||||
# - Runs ONLY on workflow_run events for the staging branch.
|
||||
# - Requires EVERY named gate workflow to have the same head_sha and
|
||||
# all be `conclusion == success`. If any of them is red, skipped,
|
||||
# cancelled, or pending, we abort (stay on the current main).
|
||||
# - The PR base=main head=staging path lets GitHub itself enforce
|
||||
# branch protection. If main has diverged from staging or required
|
||||
# checks aren't satisfied, the merge queue declines the PR — no
|
||||
# need for a manual ff-only ancestry check here.
|
||||
# - Loop safety: the auto-sync-main-to-staging workflow fires when
|
||||
# main lands the auto-promote PR, but its merge into staging is by
|
||||
# GITHUB_TOKEN which doesn't trigger downstream workflow_run events
|
||||
# (GitHub Actions safety). So this workflow doesn't re-fire from
|
||||
# its own promote landing.
|
||||
#
|
||||
# Toggle via repo variable AUTO_PROMOTE_ENABLED (true/unset). When
|
||||
# unset, the workflow logs what it would have done but doesn't open
|
||||
# the PR — useful for dry-running the gate logic without surfacing
|
||||
# a noisy PR while staging CI is still flaky.
|
||||
#
|
||||
# **One-time repo setting (load-bearing):** this workflow opens the
|
||||
# staging→main PR via `gh pr create` using the default GITHUB_TOKEN.
|
||||
# Since GitHub's 2022 default change, that token cannot create or
|
||||
# approve PRs unless the repo opts in. The toggle is at:
|
||||
#
|
||||
# Settings → Actions → General → Workflow permissions
|
||||
# → ✅ Allow GitHub Actions to create and approve pull requests
|
||||
#
|
||||
# Without it, every workflow_run fails with:
|
||||
#
|
||||
# pull request create failed: GraphQL: GitHub Actions is not
|
||||
# permitted to create or approve pull requests (createPullRequest)
|
||||
#
|
||||
# Observed 2026-04-29 01:43 UTC blocking promotion of fcd87b9 (PRs
|
||||
# #2248 + #2249); manually bridged via PR #2252. Re-check this
|
||||
# setting if auto-promote starts failing with createPullRequest
|
||||
# errors after a repo or org admin change.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- CI
|
||||
- E2E Staging Canvas (Playwright)
|
||||
- E2E API Smoke Test
|
||||
- CodeQL
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: "Force promote even when AUTO_PROMOTE_ENABLED is unset (manual override)"
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
# actions: write is needed by the post-merge dispatch tail step
|
||||
# (#2358 / #2357) — `gh workflow run publish-workspace-server-image.yml`
|
||||
# POSTs to /actions/workflows/.../dispatches which requires this scope.
|
||||
# Without it the call 403s and the publish/canary/redeploy chain still
|
||||
# doesn't run on staging→main promotions, undoing #2358.
|
||||
actions: write
|
||||
|
||||
# Serialize auto-promote runs. Multiple staging gate completions can land
|
||||
# in quick succession (CI + E2E + CodeQL all finish within seconds of
|
||||
# each other on a green PR) — without this, two parallel runs both:
|
||||
# 1. Open / re-use the same promote PR.
|
||||
# 2. Both call `gh pr merge --auto` (idempotent — fine).
|
||||
# 3. Both poll for the same mergedAt and both `gh workflow run` publish
|
||||
# → 2× redundant publish builds racing for the same `:staging-latest`
|
||||
# retag, and 2× canary-verify chains.
|
||||
# cancel-in-progress: false because we don't want a brand-new run to kill
|
||||
# a polling-tail that's about to dispatch — the polling tail's 30 min cap
|
||||
# is the right backstop, not workflow-level cancel.
|
||||
concurrency:
|
||||
group: auto-promote-staging
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check-all-gates-green:
|
||||
# Only consider staging pushes. PRs into staging don't promote.
|
||||
if: >
|
||||
(github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.head_branch == 'staging' &&
|
||||
github.event.workflow_run.event == 'push')
|
||||
|| github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
all_green: ${{ steps.gates.outputs.all_green }}
|
||||
head_sha: ${{ steps.gates.outputs.head_sha }}
|
||||
steps:
|
||||
# Skip empty-tree promotes (the perpetual auto-promote↔auto-sync cycle
|
||||
# observed 2026-05-03). Sequence: auto-promote merges via the staging
|
||||
# merge-queue's MERGE strategy, creating a merge commit on main that
|
||||
# staging doesn't have. auto-sync then merges main back into staging
|
||||
# via another merge commit (the queue's MERGE strategy applies on
|
||||
# the staging side too, even when the workflow's local FF would
|
||||
# have sufficed). Now staging has a new merge-commit SHA whose
|
||||
# tree == main's tree — but auto-promote sees "staging ahead of
|
||||
# main by 1" and opens YET another empty promote PR. Each round
|
||||
# costs ~30-40 min wallclock, ~2 manual approvals, and burns a
|
||||
# full CodeQL Go run (~15 min). Without this guard the cycle
|
||||
# repeats indefinitely.
|
||||
#
|
||||
# Long-term fix is to switch the merge_queue ruleset's
|
||||
# `merge_method` away from MERGE so FF-able PRs land cleanly,
|
||||
# but that's a broader change affecting every staging PR's
|
||||
# commit shape. This guard is the one-line surgical fix that
|
||||
# breaks the cycle without touching merge-queue config.
|
||||
#
|
||||
# Fail-open: if `git diff` errors for any reason, fall through
|
||||
# to the gate check (preserve existing behavior). Only skip
|
||||
# when the diff is DEFINITIVELY empty.
|
||||
- name: Checkout for tree-diff check
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: staging
|
||||
- name: Skip if staging tree == main tree (perpetual-cycle break)
|
||||
id: tree-diff
|
||||
env:
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
run: |
|
||||
set -eu
|
||||
git fetch origin main --depth=50 || { echo "::warning::git fetch main failed — proceeding (fail-open)"; exit 0; }
|
||||
# Compare staging tip's tree against main's tree. `git diff
|
||||
# --quiet` exits 0 if no differences, 1 if there are.
|
||||
if git diff --quiet origin/main "$HEAD_SHA" -- 2>/dev/null; then
|
||||
{
|
||||
echo "## ⏭ Skipped — no code to promote"
|
||||
echo
|
||||
echo "staging tip (\`${HEAD_SHA:0:8}\`) and \`main\` have identical trees."
|
||||
echo "This is the auto-promote↔auto-sync merge-commit cycle: staging has a"
|
||||
echo "new SHA (a sync-back merge commit) but the underlying file tree is"
|
||||
echo "already on main, so there's no real code to ship."
|
||||
echo
|
||||
echo "Skipping to avoid opening an empty promote PR. Cycle terminates here."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::notice::auto-promote: staging tree == main tree — no code to promote, skipping"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Check all required gates on this SHA
|
||||
if: steps.tree-diff.outputs.skip != 'true'
|
||||
id: gates
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Required gate workflow files. Use file paths (relative to
|
||||
# .github/workflows/) rather than display names because:
|
||||
#
|
||||
# 1. `gh run list --workflow=<name>` is ambiguous when two
|
||||
# workflows have the same `name:` — observed 2026-04-28
|
||||
# with "CodeQL" matching both `codeql.yml` (explicit) and
|
||||
# GitHub's UI-configured Code-quality default setup
|
||||
# (internal "codeql"). gh CLI returns "could not resolve
|
||||
# to a unique workflow" → empty result → gate evaluated
|
||||
# as missing/none → auto-promote dead-locked despite all
|
||||
# checks actually passing.
|
||||
#
|
||||
# 2. File paths are the unique identifier for workflows;
|
||||
# `name:` is just a display string and can collide.
|
||||
#
|
||||
# When adding/removing a gate, update this list AND the
|
||||
# branch-protection required-checks list (which uses check-run
|
||||
# display names, not workflow names; the two are decoupled and
|
||||
# should be kept in sync manually).
|
||||
GATES=(
|
||||
"ci.yml"
|
||||
"e2e-staging-canvas.yml"
|
||||
"e2e-api.yml"
|
||||
"codeql.yml"
|
||||
)
|
||||
|
||||
echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
|
||||
echo "Checking gates on SHA ${HEAD_SHA}"
|
||||
|
||||
ALL_GREEN=true
|
||||
for gate in "${GATES[@]}"; do
|
||||
# Query the most recent run of this workflow on this SHA.
|
||||
# event=push to avoid picking up PR runs. branch=staging to
|
||||
# guard against someone dispatching the gate on a non-staging
|
||||
# branch at the same SHA.
|
||||
RESULT=$(gh run list \
|
||||
--repo "$REPO" \
|
||||
--workflow "$gate" \
|
||||
--branch staging \
|
||||
--event push \
|
||||
--commit "$HEAD_SHA" \
|
||||
--limit 1 \
|
||||
--json status,conclusion \
|
||||
--jq '.[0] | "\(.status)/\(.conclusion // "none")"' \
|
||||
2>/dev/null || echo "missing/none")
|
||||
|
||||
echo " $gate → $RESULT"
|
||||
|
||||
# Only completed/success counts. completed/failure or
|
||||
# in_progress/anything or no record at all = abort.
|
||||
if [ "$RESULT" != "completed/success" ]; then
|
||||
ALL_GREEN=false
|
||||
fi
|
||||
done
|
||||
|
||||
echo "all_green=${ALL_GREEN}" >> "$GITHUB_OUTPUT"
|
||||
if [ "$ALL_GREEN" != "true" ]; then
|
||||
echo "::notice::auto-promote: not all gates are green on ${HEAD_SHA} — staying on current main"
|
||||
fi
|
||||
|
||||
promote:
|
||||
needs: check-all-gates-green
|
||||
if: needs.check-all-gates-green.outputs.all_green == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check rollout gate
|
||||
env:
|
||||
AUTO_PROMOTE_ENABLED: ${{ vars.AUTO_PROMOTE_ENABLED }}
|
||||
FORCE_INPUT: ${{ github.event.inputs.force }}
|
||||
run: |
|
||||
set -eu
|
||||
# Repo variable AUTO_PROMOTE_ENABLED=true flips this on. While
|
||||
# it's unset, the workflow dry-runs (logs what it would have
|
||||
# done) but doesn't open the promote PR. Set the variable in
|
||||
# Settings → Secrets and variables → Actions → Variables.
|
||||
if [ "${AUTO_PROMOTE_ENABLED:-}" != "true" ] && [ "${FORCE_INPUT:-false}" != "true" ]; then
|
||||
{
|
||||
echo "## ⏸ Auto-promote disabled"
|
||||
echo
|
||||
echo "Repo variable \`AUTO_PROMOTE_ENABLED\` is not set to \`true\`."
|
||||
echo "All gates are green on staging; would have opened a promote PR to \`main\`."
|
||||
echo
|
||||
echo "To enable: Settings → Secrets and variables → Actions → Variables → \`AUTO_PROMOTE_ENABLED=true\`."
|
||||
echo "To test once manually: workflow_dispatch with \`force=true\`."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "::notice::auto-promote disabled — dry run only"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Mint the App token BEFORE the promote-PR step so the auto-merge
|
||||
# call can use it. GITHUB_TOKEN-initiated merges suppress the
|
||||
# downstream `push` event on main, breaking the
|
||||
# publish-workspace-server-image → canary-verify → redeploy-tenants
|
||||
# chain (issue #2357). Using the App token here means the
|
||||
# merge-queue-landed merge IS able to fire the cascade naturally;
|
||||
# the polling tail below stays as defense-in-depth.
|
||||
- name: Mint App token for promote-PR + downstream dispatch
|
||||
if: ${{ vars.AUTO_PROMOTE_ENABLED == 'true' || github.event.inputs.force == 'true' }}
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.MOLECULE_AI_APP_ID }}
|
||||
private-key: ${{ secrets.MOLECULE_AI_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Open (or reuse) staging → main promote PR + enable auto-merge
|
||||
if: ${{ vars.AUTO_PROMOTE_ENABLED == 'true' || github.event.inputs.force == 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
TARGET_SHA: ${{ needs.check-all-gates-green.outputs.head_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Look for an existing open promote PR (idempotent on re-run
|
||||
# of the workflow). The PR's head IS the staging branch — the
|
||||
# whole point is "advance main to staging's tip", so we don't
|
||||
# need a per-SHA branch like auto-sync-main-to-staging uses.
|
||||
PR_NUM=$(gh pr list --repo "$REPO" \
|
||||
--base main --head staging --state open \
|
||||
--json number --jq '.[0].number // ""')
|
||||
|
||||
if [ -z "$PR_NUM" ]; then
|
||||
TITLE="staging → main: auto-promote ${TARGET_SHA:0:7}"
|
||||
BODY_FILE=$(mktemp)
|
||||
cat > "$BODY_FILE" <<EOFBODY
|
||||
Automated promotion of \`staging\` (\`${TARGET_SHA:0:8}\`) to \`main\`. All required staging gates green at this SHA: CI, E2E Staging Canvas, E2E API Smoke, CodeQL.
|
||||
|
||||
This PR is auto-generated by \`.github/workflows/auto-promote-staging.yml\` whenever every required gate completes green on the same staging SHA. It exists because main's branch protection requires status checks "set by the expected GitHub apps" — direct \`git push\` from a workflow can't satisfy that, only PR merges through the queue can.
|
||||
|
||||
Merge queue lands this; no human action needed unless gates fail. Reverse-direction sync (the merge commit on main → staging) is handled by \`auto-sync-main-to-staging.yml\`.
|
||||
EOFBODY
|
||||
PR_URL=$(gh pr create --repo "$REPO" \
|
||||
--base main --head staging \
|
||||
--title "$TITLE" \
|
||||
--body-file "$BODY_FILE")
|
||||
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$' | tail -1)
|
||||
rm -f "$BODY_FILE"
|
||||
echo "::notice::Opened PR #${PR_NUM}"
|
||||
else
|
||||
echo "::notice::Re-using existing promote PR #${PR_NUM}"
|
||||
fi
|
||||
|
||||
# Enable auto-merge — the merge queue picks it up once
|
||||
# required gates are green on the merge_group ref.
|
||||
if ! gh pr merge "$PR_NUM" --repo "$REPO" --auto --merge 2>&1; then
|
||||
echo "::warning::Failed to enable auto-merge on PR #${PR_NUM} — operator may need to merge manually."
|
||||
fi
|
||||
|
||||
{
|
||||
echo "## ✅ Auto-promote PR opened"
|
||||
echo
|
||||
echo "- Source: staging at \`${TARGET_SHA:0:8}\`"
|
||||
echo "- PR: #${PR_NUM}"
|
||||
echo
|
||||
echo "Merge queue lands the PR once required gates are green; no human action needed unless gates fail."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Hand the PR number to the next step so we can dispatch the
|
||||
# tenant-redeploy chain after the merge queue lands the merge.
|
||||
echo "promote_pr_num=${PR_NUM}" >> "$GITHUB_OUTPUT"
|
||||
id: promote_pr
|
||||
|
||||
# The App token minted above (before the promote-PR step) is
|
||||
# also used by the polling tail below. Defense-in-depth: with
|
||||
# the merge-queue-landed merge now using the App token, the
|
||||
# main-branch push event SHOULD fire the publish/canary/redeploy
|
||||
# cascade naturally — but if for any reason it doesn't (e.g. an
|
||||
# unrelated event-suppression edge case), the explicit dispatches
|
||||
# below still wake the chain.
|
||||
- name: Wait for promote merge, then dispatch publish + redeploy (#2357)
|
||||
# Defense-in-depth dispatch. With the auto-merge call above
|
||||
# now using the App token (this commit), the merge-queue-landed
|
||||
# merge SHOULD fire publish-workspace-server-image naturally
|
||||
# via on:push:[main] — App-token-initiated pushes DO trigger
|
||||
# workflow_run cascades, unlike GITHUB_TOKEN-initiated ones
|
||||
# (the documented "no recursion" rule —
|
||||
# https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow).
|
||||
#
|
||||
# This explicit dispatch stays as belt-and-suspenders for any
|
||||
# edge case where the natural cascade misfires. If it never
|
||||
# observably fires after this token swap (i.e. the publish
|
||||
# workflow has already started by the time we get here), the
|
||||
# second dispatch is a harmless no-op (publish-workspace-server-image
|
||||
# has its own concurrency group that dedupes).
|
||||
#
|
||||
# See PR for #2357: pre-fix the merge action was via
|
||||
# GITHUB_TOKEN, suppressing the cascade and forcing this tail
|
||||
# to be the SOLE chain trigger. With the auto-merge token swap
|
||||
# the tail becomes redundant in the happy path; keep until
|
||||
# we've observed >=10 successful natural cascades, then drop.
|
||||
if: steps.promote_pr.outputs.promote_pr_num != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUM: ${{ steps.promote_pr.outputs.promote_pr_num }}
|
||||
run: |
|
||||
# Poll for merge — max 30 min (60 × 30s). The merge queue
|
||||
# typically lands within 5-10 min when gates are green. Break
|
||||
# early if the PR is closed without merging (operator action,
|
||||
# gates flipped red post-approval, branch-protection rejection)
|
||||
# so we don't tie up a runner for the full 30 min on a dead PR.
|
||||
MERGED=""
|
||||
STATE=""
|
||||
for _ in $(seq 1 60); do
|
||||
VIEW=$(gh pr view "$PR_NUM" --repo "$REPO" --json mergedAt,state)
|
||||
MERGED=$(echo "$VIEW" | jq -r '.mergedAt // ""')
|
||||
STATE=$(echo "$VIEW" | jq -r '.state // ""')
|
||||
if [ -n "$MERGED" ] && [ "$MERGED" != "null" ]; then
|
||||
echo "::notice::Promote PR #${PR_NUM} merged at ${MERGED}"
|
||||
break
|
||||
fi
|
||||
if [ "$STATE" = "CLOSED" ]; then
|
||||
echo "::warning::Promote PR #${PR_NUM} was closed without merging — skipping deploy dispatch."
|
||||
exit 0
|
||||
fi
|
||||
sleep 30
|
||||
done
|
||||
|
||||
if [ -z "$MERGED" ] || [ "$MERGED" = "null" ]; then
|
||||
echo "::warning::Promote PR #${PR_NUM} didn't merge within 30min — skipping deploy dispatch (manually run \`gh workflow run publish-workspace-server-image.yml --ref main\` once it lands)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Dispatch publish on main using the App token. App-initiated
|
||||
# workflow_dispatch DOES propagate the workflow_run cascade,
|
||||
# unlike GITHUB_TOKEN-initiated dispatch.
|
||||
# publish completes → canary-verify chains via workflow_run →
|
||||
# redeploy-tenants-on-main chains via workflow_run + branches:[main].
|
||||
if gh workflow run publish-workspace-server-image.yml \
|
||||
--repo "$REPO" --ref main 2>&1; then
|
||||
echo "::notice::Dispatched publish-workspace-server-image on ref=main as molecule-ai App — canary-verify and redeploy-tenants-on-main will chain via workflow_run."
|
||||
{
|
||||
echo "## 🚀 Tenant redeploy chain dispatched"
|
||||
echo
|
||||
echo "- publish-workspace-server-image (workflow_dispatch on \`main\`, actor: \`molecule-ai[bot]\`)"
|
||||
echo "- canary-verify will chain on completion"
|
||||
echo "- redeploy-tenants-on-main will chain on canary green"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "::error::Failed to dispatch publish-workspace-server-image. Run manually: gh workflow run publish-workspace-server-image.yml --ref main"
|
||||
fi
|
||||
|
||||
# ALSO dispatch auto-sync-main-to-staging.yml. Same root cause as
|
||||
# publish above (issue #2357): the merge-queue-initiated push to
|
||||
# main is by GITHUB_TOKEN → no `on: push` triggers fire downstream.
|
||||
# Without this dispatch, every staging→main promote leaves staging
|
||||
# one merge commit BEHIND main, which silently dead-locks the NEXT
|
||||
# promote PR as `mergeStateStatus: BEHIND` because main's
|
||||
# branch-protection has `strict: true`. Verified empirically on
|
||||
# 2026-05-02 against PR #2442 (Phase 2 promote): only the explicit
|
||||
# publish-workspace-server-image dispatch fired on the previous
|
||||
# promote SHA 76c604fb, while auto-sync silently no-op'd, leaving
|
||||
# staging behind for ~24h until manually bridged.
|
||||
if gh workflow run auto-sync-main-to-staging.yml \
|
||||
--repo "$REPO" --ref main 2>&1; then
|
||||
echo "::notice::Dispatched auto-sync-main-to-staging on ref=main as molecule-ai App — staging will absorb the new main merge commit via PR + merge queue."
|
||||
else
|
||||
echo "::error::Failed to dispatch auto-sync-main-to-staging. Run manually: gh workflow run auto-sync-main-to-staging.yml --ref main"
|
||||
fi
|
||||
@@ -1,83 +0,0 @@
|
||||
name: auto-promote-stale-alarm
|
||||
|
||||
# Hourly cron + on-demand alarm for the silent-block failure mode that
|
||||
# motivated issue #2975:
|
||||
# - The auto-promote-staging.yml workflow opened a PR + armed
|
||||
# auto-merge, but main's branch protection requires a human review
|
||||
# (reviewDecision=REVIEW_REQUIRED). The PR sat BLOCKED with no
|
||||
# surface-up-the-stack for 12+ hours, holding 25 commits hostage
|
||||
# including the Memory v2 redesign and a reno-stars data-loss fix.
|
||||
#
|
||||
# This workflow runs `scripts/check-stale-promote-pr.sh` against the
|
||||
# repo's open auto-promote PRs (base=main head=staging). When a PR has
|
||||
# been BLOCKED on REVIEW_REQUIRED for >4h, it:
|
||||
# 1. Emits a workflow-level warning (visible in run summary + the
|
||||
# Actions UI feed).
|
||||
# 2. Posts a comment on the PR (idempotent — one alarm per PR).
|
||||
#
|
||||
# The detection logic lives in scripts/check-stale-promote-pr.sh so
|
||||
# it's unit-testable with stubbed `gh` (see test-check-stale-promote-pr.sh).
|
||||
# This file is the schedule + invocation surface only — SSOT for the
|
||||
# detector itself.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Hourly. Cheap (one `gh pr list` + jq), and 1h granularity is
|
||||
# plenty for a 4h staleness threshold — operators see the alarm
|
||||
# within at most 1h of crossing the threshold.
|
||||
- cron: "27 * * * *" # at :27 to dodge the cron herd at :00
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
stale_hours:
|
||||
description: "Hours after which a BLOCKED+REVIEW_REQUIRED PR is stale (default 4)"
|
||||
required: false
|
||||
default: "4"
|
||||
post_comment:
|
||||
description: "Post a comment on stale PRs (default true)"
|
||||
required: false
|
||||
default: "true"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # post comments on stale PRs
|
||||
|
||||
# Serialize so the on-demand and scheduled runs don't double-comment
|
||||
# the same PR. cancel-in-progress=false because the script is idempotent
|
||||
# (existing comment marker prevents dupes), but a scheduled run firing
|
||||
# while a manual one runs would just re-list the same PR set.
|
||||
concurrency:
|
||||
group: auto-promote-stale-alarm
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout (need scripts/ only)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
sparse-checkout: |
|
||||
scripts/check-stale-promote-pr.sh
|
||||
sparse-checkout-cone-mode: false
|
||||
- name: Run stale-PR detector
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
STALE_HOURS: ${{ inputs.stale_hours || '4' }}
|
||||
POST_COMMENT: ${{ inputs.post_comment || 'true' }}
|
||||
run: |
|
||||
# The script's exit code reflects the count of stale PRs.
|
||||
# We don't want a stale finding to fail the workflow run —
|
||||
# the warning + comment are the signal, the green/red is
|
||||
# noise. So convert any non-zero exit to a workflow notice
|
||||
# and exit 0.
|
||||
set +e
|
||||
bash scripts/check-stale-promote-pr.sh
|
||||
rc=$?
|
||||
set -e
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "::notice::Stale PR detector found $rc PR(s) needing attention. See warnings above + comments on the PRs."
|
||||
fi
|
||||
# Always succeed — operator-facing surface is the warning,
|
||||
# not the workflow status.
|
||||
exit 0
|
||||
@@ -1,237 +0,0 @@
|
||||
name: Auto-sync main → staging
|
||||
|
||||
# Reflects every push to `main` back onto `staging` so the
|
||||
# staging-as-superset-of-main invariant holds.
|
||||
#
|
||||
# Background:
|
||||
#
|
||||
# `auto-promote-staging.yml` advances main via `git merge --ff-only`
|
||||
# + `git push origin main` — that's a clean fast-forward, no merge
|
||||
# commit. But manual merges of `staging → main` PRs through the
|
||||
# GitHub UI / API create a merge commit on main that staging
|
||||
# doesn't have. The next `staging → main` PR then evaluates as
|
||||
# "BEHIND" because staging is missing that merge commit, requiring
|
||||
# a manual `gh pr update-branch` round-trip.
|
||||
#
|
||||
# This happened twice on 2026-04-28 (PRs #2202, #2205, both manual
|
||||
# bridges). Each time the bridge needed update-branch + a re-CI
|
||||
# round before merging. Operationally annoying and avoidable.
|
||||
#
|
||||
# Architecture:
|
||||
#
|
||||
# This repo's `staging` branch is protected by a `merge_queue`
|
||||
# ruleset (id 15500102) that blocks ALL direct pushes — no bypass
|
||||
# even for org admins or the GitHub Actions integration. Direct
|
||||
# `git push origin staging` returns GH013. So instead of pushing
|
||||
# directly, this workflow:
|
||||
#
|
||||
# 1. Checks if main is already in staging's ancestry → no-op.
|
||||
# 2. Creates an `auto-sync/main-<sha>` branch from staging.
|
||||
# 3. Tries `git merge --ff-only origin/main` → if staging hasn't
|
||||
# diverged this is a clean ff.
|
||||
# 4. Otherwise `git merge --no-ff origin/main` to absorb main's
|
||||
# tip while keeping staging's history.
|
||||
# 5. Pushes the auto-sync branch.
|
||||
# 6. Opens a PR (base=staging, head=auto-sync/main-<sha>) and
|
||||
# enables auto-merge so the merge queue lands it.
|
||||
#
|
||||
# This mirrors the path human PRs take through staging — same
|
||||
# rules, same gates, no special-case bypass.
|
||||
#
|
||||
# Loop safety:
|
||||
#
|
||||
# `GITHUB_TOKEN`-authored merges (including the merge queue's land
|
||||
# of the auto-sync PR) do NOT trigger downstream workflow runs
|
||||
# (GitHub Actions safety). So when the auto-sync PR lands on
|
||||
# staging, `auto-promote-staging.yml` is NOT triggered by that
|
||||
# push. The next developer push to staging triggers auto-promote
|
||||
# normally. No loop possible.
|
||||
#
|
||||
# Concurrency:
|
||||
#
|
||||
# Two pushes to main in quick succession (e.g., manual UI merge
|
||||
# immediately followed by auto-promote-staging's ff-merge) could
|
||||
# otherwise open two overlapping auto-sync PRs. The concurrency
|
||||
# group serializes runs; the second waits for the first to exit.
|
||||
# (The first run exits after opening + auto-merge-queueing the PR,
|
||||
# not after the merge actually completes — so multiple PRs can be
|
||||
# open simultaneously, but the merge queue handles them serially.)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# workflow_dispatch lets:
|
||||
# 1. Operators manually backfill a missed sync (e.g. after a manual
|
||||
# UI merge that the runner missed).
|
||||
# 2. auto-promote-staging.yml's polling tail explicitly invoke us
|
||||
# after the promote PR lands. This is load-bearing: when the
|
||||
# merge queue lands a promote-PR merge, the resulting push to
|
||||
# `main` is "by GITHUB_TOKEN", and per GitHub's no-recursion
|
||||
# rule (https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow)
|
||||
# that push event does NOT fire any downstream workflows. The
|
||||
# `on: push` trigger above is silently dead for the very pattern
|
||||
# we exist to handle. Verified empirically 2026-05-02 against
|
||||
# SHA 76c604fb (PR #2437 staging→main): only ONE workflow fired
|
||||
# (publish-workspace-server-image, dispatched explicitly by
|
||||
# auto-promote's polling tail with an App token). Every other
|
||||
# `on: push: branches: [main]` workflow — including this one —
|
||||
# was suppressed. Until the underlying merge call moves to an
|
||||
# App token, an explicit dispatch is the only reliable path.
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: auto-sync-main-to-staging
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync-staging:
|
||||
# ubuntu-latest matches every other workflow in this repo. The
|
||||
# earlier `[self-hosted, macos, arm64]` was a copy-paste artefact
|
||||
# from the molecule-controlplane repo (which IS private and uses a
|
||||
# Mac runner) — molecule-core has no Mac runner registered, so the
|
||||
# job sat unassigned whenever the trigger fired. Verified 2026-05-02:
|
||||
# this is the ONLY workflow in molecule-core/.github/workflows/ with
|
||||
# a non-ubuntu runs-on.
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout staging
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: staging
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Configure git author
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Check if staging already contains main
|
||||
id: check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git fetch origin main
|
||||
if git merge-base --is-ancestor origin/main HEAD; then
|
||||
echo "needs_sync=false" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "## ✅ No-op"
|
||||
echo
|
||||
echo "staging already contains \`origin/main\` ($(git rev-parse --short=8 origin/main))."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "needs_sync=true" >> "$GITHUB_OUTPUT"
|
||||
MAIN_SHORT=$(git rev-parse --short=8 origin/main)
|
||||
echo "main_short=${MAIN_SHORT}" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=auto-sync/main-${MAIN_SHORT}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::staging is missing main's tip (${MAIN_SHORT}) — opening sync PR"
|
||||
fi
|
||||
|
||||
- name: Create auto-sync branch + merge main
|
||||
if: steps.check.outputs.needs_sync == 'true'
|
||||
id: prep
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BRANCH="${{ steps.check.outputs.branch }}"
|
||||
|
||||
# If a previous auto-sync run already opened a branch for the
|
||||
# same main sha, prefer reusing it (idempotent behavior on
|
||||
# workflow restart). Force-update from latest staging anyway
|
||||
# so it absorbs any staging-side commits that landed since.
|
||||
git checkout -B "$BRANCH"
|
||||
|
||||
if git merge --ff-only origin/main; then
|
||||
echo "did_ff=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::Fast-forwarded ${BRANCH} to origin/main"
|
||||
else
|
||||
echo "did_ff=false" >> "$GITHUB_OUTPUT"
|
||||
if ! git merge --no-ff origin/main -m "chore: sync main → staging (auto)"; then
|
||||
# Hygiene: leave the work tree clean before failing.
|
||||
git merge --abort || true
|
||||
{
|
||||
echo "## ❌ Conflict"
|
||||
echo
|
||||
echo "Auto-merge \`main → staging\` failed with conflicts."
|
||||
echo "A human needs to resolve manually."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
- name: Push auto-sync branch
|
||||
if: steps.check.outputs.needs_sync == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Force-with-lease so a concurrent auto-sync run can't
|
||||
# silently clobber an in-flight branch we just updated. If a
|
||||
# different writer touched the branch, we abort and the next
|
||||
# run picks up the latest state.
|
||||
git push --force-with-lease origin "${{ steps.check.outputs.branch }}"
|
||||
|
||||
- name: Open auto-sync PR + enable auto-merge
|
||||
if: steps.check.outputs.needs_sync == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BRANCH: ${{ steps.check.outputs.branch }}
|
||||
MAIN_SHORT: ${{ steps.check.outputs.main_short }}
|
||||
DID_FF: ${{ steps.prep.outputs.did_ff }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Find existing PR for this branch (idempotent on workflow
|
||||
# restart) before creating a new one.
|
||||
PR_NUM=$(gh pr list --head "$BRANCH" --base staging --state open --json number --jq '.[0].number // ""')
|
||||
|
||||
if [ -z "$PR_NUM" ]; then
|
||||
# Body lives in a temp file to keep the multi-line content
|
||||
# out of the YAML block scalar (un-indented newlines inside
|
||||
# an inline shell string break YAML parsing).
|
||||
BODY_FILE=$(mktemp)
|
||||
if [ "$DID_FF" = "true" ]; then
|
||||
TITLE="chore: sync main → staging (auto, ff to ${MAIN_SHORT})"
|
||||
cat > "$BODY_FILE" <<EOFBODY
|
||||
Automated fast-forward of \`staging\` to \`origin/main\` (\`${MAIN_SHORT}\`). Staging has no in-flight commits that diverge from main. Merge queue lands this; no human action needed.
|
||||
|
||||
This PR is auto-generated by \`.github/workflows/auto-sync-main-to-staging.yml\` on every push to \`main\`. It exists because this repo's \`staging\` branch has a \`merge_queue\` ruleset that blocks direct pushes — even from the GitHub Actions integration.
|
||||
EOFBODY
|
||||
else
|
||||
TITLE="chore: sync main → staging (auto, merge ${MAIN_SHORT})"
|
||||
cat > "$BODY_FILE" <<EOFBODY
|
||||
Automated merge of \`origin/main\` (\`${MAIN_SHORT}\`) into \`staging\`. Staging has commits main doesn't, so this is a non-ff merge that absorbs main's tip. Merge queue lands this.
|
||||
|
||||
This PR is auto-generated by \`.github/workflows/auto-sync-main-to-staging.yml\` on every push to \`main\`.
|
||||
EOFBODY
|
||||
fi
|
||||
|
||||
# gh pr create prints the URL on stdout; extract the PR number.
|
||||
PR_URL=$(gh pr create \
|
||||
--base staging \
|
||||
--head "$BRANCH" \
|
||||
--title "$TITLE" \
|
||||
--body-file "$BODY_FILE")
|
||||
PR_NUM=$(echo "$PR_URL" | grep -oE '[0-9]+$' | tail -1)
|
||||
rm -f "$BODY_FILE"
|
||||
echo "::notice::Opened PR #${PR_NUM}"
|
||||
else
|
||||
echo "::notice::Re-using existing PR #${PR_NUM} for ${BRANCH}"
|
||||
fi
|
||||
|
||||
# Enable auto-merge — the merge queue picks it up once
|
||||
# required gates are green. Use --merge for merge commits
|
||||
# (matches the rest of this repo's PR convention).
|
||||
if ! gh pr merge "$PR_NUM" --auto --merge 2>&1; then
|
||||
echo "::warning::Failed to enable auto-merge on PR #${PR_NUM} — operator may need to merge manually."
|
||||
fi
|
||||
|
||||
{
|
||||
echo "## ✅ Auto-sync PR opened"
|
||||
echo
|
||||
echo "- Branch: \`$BRANCH\`"
|
||||
echo "- PR: #$PR_NUM"
|
||||
echo "- Strategy: $([ "$DID_FF" = "true" ] && echo "ff" || echo "merge commit")"
|
||||
echo
|
||||
echo "Merge queue lands the PR once required gates are green; no human action needed unless gates fail."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,113 +0,0 @@
|
||||
name: auto-tag-runtime
|
||||
|
||||
# Auto-tag runtime releases on every merge to main that touches workspace/.
|
||||
# This is the entry point of the runtime CD chain:
|
||||
#
|
||||
# merge PR → auto-tag-runtime (this) → publish-runtime → cascade → template
|
||||
# image rebuilds → repull on hosts.
|
||||
#
|
||||
# Default bump is patch. Override via PR label `release:minor` or
|
||||
# `release:major` BEFORE merging — the label is read off the merged PR
|
||||
# associated with the push commit.
|
||||
#
|
||||
# Skips when:
|
||||
# - The push isn't to main (other branches don't auto-release).
|
||||
# - The merge commit message contains `[skip-release]` (escape hatch
|
||||
# for cleanup PRs that touch workspace/ but shouldn't ship).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "workspace/**"
|
||||
- "scripts/build_runtime_package.py"
|
||||
- ".github/workflows/auto-tag-runtime.yml"
|
||||
- ".github/workflows/publish-runtime.yml"
|
||||
|
||||
permissions:
|
||||
contents: write # to push the new tag
|
||||
pull-requests: read # to read labels off the merged PR
|
||||
|
||||
concurrency:
|
||||
# Serialize tag bumps so two near-simultaneous merges can't both think
|
||||
# they're 0.1.6 and race to push the same tag.
|
||||
group: auto-tag-runtime
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # need full tag history for `git describe` / sort
|
||||
|
||||
- name: Skip when commit asks
|
||||
id: skip
|
||||
run: |
|
||||
MSG=$(git log -1 --format=%B "${{ github.sha }}")
|
||||
if echo "$MSG" | grep -qiE '\[skip-release\]|\[no-release\]'; then
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Commit message contains [skip-release] — no tag will be created."
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Determine bump kind from PR label
|
||||
id: bump
|
||||
if: steps.skip.outputs.skip != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
# The merged PR for this push commit. `gh pr list --search` finds
|
||||
# closed PRs whose merge commit matches; we take the first.
|
||||
PR=$(gh pr list --state merged --search "${{ github.sha }}" --json number,labels --jq '.[0]' 2>/dev/null || echo "")
|
||||
if [ -z "$PR" ] || [ "$PR" = "null" ]; then
|
||||
echo "No merged PR found for ${{ github.sha }} — defaulting to patch bump."
|
||||
echo "kind=patch" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
LABELS=$(echo "$PR" | jq -r '.labels[].name')
|
||||
if echo "$LABELS" | grep -qx 'release:major'; then
|
||||
echo "kind=major" >> "$GITHUB_OUTPUT"
|
||||
elif echo "$LABELS" | grep -qx 'release:minor'; then
|
||||
echo "kind=minor" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "kind=patch" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Compute next version from latest runtime-v* tag
|
||||
id: version
|
||||
if: steps.skip.outputs.skip != 'true'
|
||||
run: |
|
||||
# Find the highest runtime-vX.Y.Z tag. `sort -V` handles semver
|
||||
# ordering; `grep` filters to the right tag prefix.
|
||||
LATEST=$(git tag --list 'runtime-v*' | sort -V | tail -1)
|
||||
if [ -z "$LATEST" ]; then
|
||||
# No prior tag — start the runtime line at 0.1.0.
|
||||
CURRENT="0.0.0"
|
||||
else
|
||||
CURRENT="${LATEST#runtime-v}"
|
||||
fi
|
||||
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
|
||||
MINOR=$(echo "$CURRENT" | cut -d. -f2)
|
||||
PATCH=$(echo "$CURRENT" | cut -d. -f3)
|
||||
case "${{ steps.bump.outputs.kind }}" in
|
||||
major) MAJOR=$((MAJOR+1)); MINOR=0; PATCH=0;;
|
||||
minor) MINOR=$((MINOR+1)); PATCH=0;;
|
||||
patch) PATCH=$((PATCH+1));;
|
||||
esac
|
||||
NEW="$MAJOR.$MINOR.$PATCH"
|
||||
echo "current=$CURRENT" >> "$GITHUB_OUTPUT"
|
||||
echo "new=$NEW" >> "$GITHUB_OUTPUT"
|
||||
echo "Bumping runtime $CURRENT → $NEW (${{ steps.bump.outputs.kind }})"
|
||||
|
||||
- name: Push new tag
|
||||
if: steps.skip.outputs.skip != 'true'
|
||||
run: |
|
||||
NEW_TAG="runtime-v${{ steps.version.outputs.new }}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$NEW_TAG" -m "runtime $NEW_TAG (auto-bump from ${{ steps.bump.outputs.kind }})"
|
||||
git push origin "$NEW_TAG"
|
||||
echo "Pushed $NEW_TAG — publish-runtime workflow will fire on the tag."
|
||||
@@ -1,154 +0,0 @@
|
||||
name: Block internal-flavored paths
|
||||
|
||||
# Hard CI gate. Internal content (positioning, competitive briefs, sales
|
||||
# playbooks, PMM/press drip, draft campaigns) lives in molecule-ai/internal —
|
||||
# this public monorepo must never re-acquire those paths. CEO directive
|
||||
# 2026-04-23 after a fleet-wide audit found 79 internal files leaked here.
|
||||
#
|
||||
# Failure mode without this gate: agents (PMM, Research, DevRel, Sales) drop
|
||||
# briefs into the easiest path their cwd resolves to (root /research,
|
||||
# /marketing, /docs/marketing) and gitignore alone won't catch a `git add -f`
|
||||
# or a stale gitignore line. This workflow is the mechanical backstop.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
push:
|
||||
branches: [main, staging]
|
||||
# Required for GitHub merge queue: the queue's pre-merge CI run on
|
||||
# `gh-readonly-queue/...` refs needs this check to fire so the queue
|
||||
# gets a real result instead of stalling forever AWAITING_CHECKS.
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Block forbidden paths
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 2 # need previous commit to diff against on push events
|
||||
|
||||
# For pull_request events the diff base is github.event.pull_request.base.sha,
|
||||
# which may be many commits behind HEAD and therefore absent from the
|
||||
# shallow clone above. Fetch it explicitly (depth=1 keeps it fast).
|
||||
- name: Fetch PR base SHA (pull_request events only)
|
||||
if: github.event_name == 'pull_request'
|
||||
run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
# For merge_group events the queue's pre-merge ref is a commit on
|
||||
# `gh-readonly-queue/...` whose parent is the queue's base_sha.
|
||||
# That parent isn't part of the queue branch's shallow clone, so
|
||||
# we fetch it explicitly. Mirrors the equivalent step in
|
||||
# secret-scan.yml (#2120) — same shallow-clone bug class.
|
||||
- name: Fetch merge_group base SHA (merge_group events only)
|
||||
if: github.event_name == 'merge_group'
|
||||
run: git fetch --depth=1 origin ${{ github.event.merge_group.base_sha }}
|
||||
|
||||
- name: Refuse if forbidden paths appear
|
||||
env:
|
||||
# Plumb event-specific SHAs through env so the script doesn't
|
||||
# need conditional `${{ ... }}` interpolation per event type.
|
||||
# github.event.before/after only exist on push events;
|
||||
# merge_group has its own base_sha/head_sha; pull_request has
|
||||
# pull_request.base.sha / pull_request.head.sha.
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
MG_BASE_SHA: ${{ github.event.merge_group.base_sha }}
|
||||
MG_HEAD_SHA: ${{ github.event.merge_group.head_sha }}
|
||||
PUSH_BEFORE: ${{ github.event.before }}
|
||||
PUSH_AFTER: ${{ github.event.after }}
|
||||
run: |
|
||||
# Paths that must NEVER live in the public monorepo. Add to this
|
||||
# list narrowly — broader patterns belong in .gitignore so day-to-day
|
||||
# docs work isn't accidentally blocked.
|
||||
FORBIDDEN_PATTERNS=(
|
||||
"^research/"
|
||||
"^marketing/"
|
||||
"^docs/marketing/"
|
||||
"^comment-[0-9]+\.json$"
|
||||
"^test-pmm.*\.(txt|md)$"
|
||||
"^tick-reflections.*\.(txt|md)$"
|
||||
".*-temp\.(md|txt)$"
|
||||
)
|
||||
|
||||
# Determine the diff base. Each event type stores its SHAs in
|
||||
# a different place — see the env block above.
|
||||
case "${{ github.event_name }}" in
|
||||
pull_request)
|
||||
BASE="$PR_BASE_SHA"
|
||||
HEAD="$PR_HEAD_SHA"
|
||||
;;
|
||||
merge_group)
|
||||
BASE="$MG_BASE_SHA"
|
||||
HEAD="$MG_HEAD_SHA"
|
||||
;;
|
||||
*)
|
||||
BASE="$PUSH_BEFORE"
|
||||
HEAD="$PUSH_AFTER"
|
||||
;;
|
||||
esac
|
||||
|
||||
# On push events with shallow clones, BASE may be present in
|
||||
# the event payload but absent from the local object DB
|
||||
# (fetch-depth=2 doesn't always reach the previous commit
|
||||
# across true merges). Try fetching it on demand. If the
|
||||
# fetch fails — e.g. the SHA was force-overwritten — we fall
|
||||
# through to the empty-BASE branch below, which scans the
|
||||
# entire tree as if every file were new. Correct, just slow.
|
||||
# Same recovery shape as secret-scan.yml (#2120 — incident
|
||||
# 2026-04-27 06:50Z block-internal-paths exit 128 with
|
||||
# "fatal: bad object <sha>" on staging push).
|
||||
if [ -n "$BASE" ] && ! echo "$BASE" | grep -qE '^0+$'; then
|
||||
if ! git cat-file -e "$BASE" 2>/dev/null; then
|
||||
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Files added or modified in this change.
|
||||
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$' || ! git cat-file -e "$BASE" 2>/dev/null; then
|
||||
# New branch / no previous SHA / BASE unreachable — check
|
||||
# the entire tree as if every file were new. Slower but
|
||||
# correct on first push or post-fetch-failure recovery.
|
||||
CHANGED=$(git ls-tree -r --name-only HEAD)
|
||||
else
|
||||
CHANGED=$(git diff --name-only --diff-filter=AM "$BASE" "$HEAD")
|
||||
fi
|
||||
|
||||
if [ -z "$CHANGED" ]; then
|
||||
echo "No changed files to inspect."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
OFFENDING=""
|
||||
for path in $CHANGED; do
|
||||
for pattern in "${FORBIDDEN_PATTERNS[@]}"; do
|
||||
if echo "$path" | grep -qE "$pattern"; then
|
||||
OFFENDING="${OFFENDING}${path} (matched: ${pattern})\n"
|
||||
break
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [ -n "$OFFENDING" ]; then
|
||||
echo "::error::Forbidden internal-flavored paths detected:"
|
||||
printf "$OFFENDING"
|
||||
echo ""
|
||||
echo "These paths belong in molecule-ai/internal, not this public repo."
|
||||
echo "See docs/internal-content-policy.md for canonical locations."
|
||||
echo ""
|
||||
echo "If your file is genuinely public-facing (e.g. a blog post"
|
||||
echo "ready to ship), use one of these alternatives instead:"
|
||||
echo " • Public-bound blog posts: docs/blog/<slug>.md"
|
||||
echo " • Public-bound tutorials: docs/tutorials/<slug>.md"
|
||||
echo " • Public devrel content: docs/devrel/<slug>.md"
|
||||
echo ""
|
||||
echo "If you legitimately need to add a new top-level path that"
|
||||
echo "happens to match a forbidden pattern, edit"
|
||||
echo ".github/workflows/block-internal-paths.yml and update the"
|
||||
echo "FORBIDDEN_PATTERNS list with reviewer signoff."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ No forbidden paths in this change."
|
||||
@@ -1,81 +0,0 @@
|
||||
name: branch-protection drift check
|
||||
|
||||
# Catches out-of-band edits to branch protection (UI clicks, manual gh
|
||||
# api PATCH from a one-off ops session) by comparing live state against
|
||||
# tools/branch-protection/apply.sh's desired state every day. Fails the
|
||||
# workflow when they drift; the failure is the signal.
|
||||
#
|
||||
# When it fails: re-run apply.sh to put the live state back to the
|
||||
# script's intent, OR update apply.sh to encode the new intent and
|
||||
# commit. Either way the script is the source of truth.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 14:00 UTC daily. Off-hours for most teams; gives a fresh signal
|
||||
# at the start of every working day.
|
||||
- cron: '0 14 * * *'
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [staging, main]
|
||||
paths:
|
||||
- 'tools/branch-protection/**'
|
||||
- '.github/workflows/branch-protection-drift.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
drift:
|
||||
name: Branch protection drift
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# Token strategy by trigger:
|
||||
#
|
||||
# - schedule (daily canary): hard-fail when the admin token is
|
||||
# missing. This is the *only* trigger where silent soft-skip is
|
||||
# dangerous — a missing secret on the cron run means the drift
|
||||
# gate has effectively disappeared with no human in the loop to
|
||||
# notice. Per feedback_schedule_vs_dispatch_secrets_hardening.md
|
||||
# the rule is "schedule/automated triggers must hard-fail".
|
||||
#
|
||||
# - pull_request (touching tools/branch-protection/**): soft-skip
|
||||
# with a prominent warning. A PR cannot retroactively drift the
|
||||
# live state — drift happens *between* PRs (UI clicks, manual
|
||||
# gh api PATCH) and is the schedule's job to catch. The PR-time
|
||||
# gate would only catch typos in apply.sh, which the apply.sh
|
||||
# *_payload unit tests catch better. A human is reviewing the
|
||||
# PR and will see the warning in the workflow log.
|
||||
#
|
||||
# - workflow_dispatch (operator one-off): soft-skip with warning,
|
||||
# so an operator can run a diagnostic without configuring the
|
||||
# secret first.
|
||||
- name: Verify admin token present (hard-fail on schedule only)
|
||||
env:
|
||||
GH_TOKEN_FOR_ADMIN_API: ${{ secrets.GH_TOKEN_FOR_ADMIN_API }}
|
||||
run: |
|
||||
if [[ -n "$GH_TOKEN_FOR_ADMIN_API" ]]; then
|
||||
echo "GH_TOKEN_FOR_ADMIN_API present — drift_check will run with admin scope."
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${{ github.event_name }}" == "schedule" ]]; then
|
||||
echo "::error::GH_TOKEN_FOR_ADMIN_API secret missing on the daily canary." >&2
|
||||
echo "" >&2
|
||||
echo "The schedule run is the SoT for branch-protection drift detection." >&2
|
||||
echo "Without admin scope it silently passes, hiding any out-of-band edits." >&2
|
||||
echo "Set GH_TOKEN_FOR_ADMIN_API at Settings → Secrets and variables → Actions." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::GH_TOKEN_FOR_ADMIN_API secret missing — drift_check will be SKIPPED."
|
||||
echo "::warning::PR drift checks need repo-admin scope to read /branches/:b/protection."
|
||||
echo "::warning::This is non-fatal: the daily schedule run is the canonical drift gate."
|
||||
echo "SKIP_DRIFT_CHECK=1" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run drift check
|
||||
if: env.SKIP_DRIFT_CHECK != '1'
|
||||
env:
|
||||
# Repo-admin scope, needed for /branches/:b/protection.
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN_FOR_ADMIN_API }}
|
||||
run: bash tools/branch-protection/drift_check.sh
|
||||
@@ -1,318 +0,0 @@
|
||||
name: Canary — staging SaaS smoke (every 30 min)
|
||||
|
||||
# Minimum viable health check: provisions one Hermes workspace on a fresh
|
||||
# staging org, sends one A2A message, verifies PONG, tears down. ~8 min
|
||||
# wall clock. Pages on failure by opening a GitHub issue; auto-closes the
|
||||
# issue on the next green run.
|
||||
#
|
||||
# The full-SaaS workflow (e2e-staging-saas.yml) covers the broader surface
|
||||
# but runs only on provisioning-critical pushes + nightly — this one
|
||||
# catches drift in the 30-min window between those runs (AMI health, CF
|
||||
# cert rotation, WorkOS session stability, etc.).
|
||||
#
|
||||
# Lean mode: E2E_MODE=canary skips the child workspace + HMA memory +
|
||||
# peers/activity checks. One parent workspace + one A2A turn is enough
|
||||
# to signal "SaaS stack end-to-end is alive."
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 30 min. Cron on GitHub-hosted runners has a known drift of
|
||||
# a few minutes under load — that's fine for a canary.
|
||||
- cron: '*/30 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
# Serialise with the full-SaaS workflow so they don't contend for the
|
||||
# same org-create quota on staging. Different group key from
|
||||
# e2e-staging-saas since we don't mind queueing canaries behind one
|
||||
# full run, but two canaries SHOULD queue against each other.
|
||||
concurrency:
|
||||
group: canary-staging
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
# Needed to open / close the alerting issue.
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
canary:
|
||||
name: Canary smoke
|
||||
runs-on: ubuntu-latest
|
||||
# 25 min headroom over the 15-min TLS-readiness deadline in
|
||||
# tests/e2e/test_staging_full_saas.sh (#2107). Without the buffer
|
||||
# the job is killed at the wall-clock 15:00 mark BEFORE the bash
|
||||
# `fail` + diagnostic burst can fire, leaving every cancellation
|
||||
# silent. Sibling staging E2E jobs run at 20-45 min — keeping
|
||||
# canary tighter than them so a true wedge still surfaces here
|
||||
# first.
|
||||
timeout-minutes: 25
|
||||
|
||||
env:
|
||||
MOLECULE_CP_URL: https://staging-api.moleculesai.app
|
||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
|
||||
# MiniMax is the canary's PRIMARY LLM auth path post-2026-05-04.
|
||||
# Switched from hermes+OpenAI after #2578 (the staging OpenAI key
|
||||
# account went over quota and stayed dead for 36+ hours, taking
|
||||
# the canary red the entire time). claude-code template's
|
||||
# `minimax` provider routes ANTHROPIC_BASE_URL to
|
||||
# api.minimax.io/anthropic and reads MINIMAX_API_KEY at boot —
|
||||
# ~5-10x cheaper per token than gpt-4.1-mini AND on a separate
|
||||
# billing account, so OpenAI quota collapse no longer wedges the
|
||||
# canary. Mirrors the migration continuous-synth-e2e.yml made on
|
||||
# 2026-05-03 (#265) for the same reason. tests/e2e/test_staging_
|
||||
# full_saas.sh branches SECRETS_JSON on which key is present —
|
||||
# MiniMax wins when set.
|
||||
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
|
||||
# Direct-Anthropic alternative for operators who don't want to
|
||||
# set up a MiniMax account (priority below MiniMax — first
|
||||
# non-empty wins in test_staging_full_saas.sh's secrets-injection
|
||||
# block). See #2578 PR comment for the rationale.
|
||||
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
|
||||
# OpenAI fallback — kept wired so an operator-dispatched run with
|
||||
# E2E_RUNTIME=hermes overridden via workflow_dispatch can still
|
||||
# exercise the OpenAI path without re-editing the workflow.
|
||||
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_KEY }}
|
||||
E2E_MODE: canary
|
||||
E2E_RUNTIME: claude-code
|
||||
# Pin the canary to a specific MiniMax model rather than relying
|
||||
# on the per-runtime default (which could resolve to "sonnet" →
|
||||
# direct Anthropic and defeat the cost saving). M2.7-highspeed
|
||||
# is "Token Plan only" but cheap-per-token and fast.
|
||||
E2E_MODEL_SLUG: MiniMax-M2.7-highspeed
|
||||
E2E_RUN_ID: "canary-${{ github.run_id }}"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify admin token present
|
||||
run: |
|
||||
if [ -z "$MOLECULE_ADMIN_TOKEN" ]; then
|
||||
echo "::error::MOLECULE_STAGING_ADMIN_TOKEN not set"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
- name: Verify LLM key present
|
||||
run: |
|
||||
# Per-runtime key check — claude-code uses MiniMax; hermes /
|
||||
# langgraph (operator-dispatched only) use OpenAI. Hard-fail
|
||||
# rather than soft-skip per the lesson from synth E2E #2578:
|
||||
# an empty key silently falls through to the wrong
|
||||
# SECRETS_JSON branch and the canary fails 5 min later with
|
||||
# a confusing auth error instead of the clean "secret
|
||||
# missing" message at the top.
|
||||
case "${E2E_RUNTIME}" in
|
||||
claude-code)
|
||||
# Either MiniMax OR direct-Anthropic works — first
|
||||
# non-empty wins in the test script's secrets-injection
|
||||
# priority chain. Operators only need to set ONE of these
|
||||
# secrets; we don't force a choice between them.
|
||||
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
|
||||
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY"
|
||||
required_secret_value="${E2E_MINIMAX_API_KEY}"
|
||||
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
|
||||
required_secret_name="MOLECULE_STAGING_ANTHROPIC_API_KEY"
|
||||
required_secret_value="${E2E_ANTHROPIC_API_KEY}"
|
||||
else
|
||||
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY or MOLECULE_STAGING_ANTHROPIC_API_KEY"
|
||||
required_secret_value=""
|
||||
fi
|
||||
;;
|
||||
langgraph|hermes)
|
||||
required_secret_name="MOLECULE_STAGING_OPENAI_KEY"
|
||||
required_secret_value="${E2E_OPENAI_API_KEY:-}"
|
||||
;;
|
||||
*)
|
||||
echo "::warning::Unknown E2E_RUNTIME='${E2E_RUNTIME}' — skipping LLM-key check"
|
||||
required_secret_name=""
|
||||
required_secret_value="present"
|
||||
;;
|
||||
esac
|
||||
if [ -n "$required_secret_name" ] && [ -z "$required_secret_value" ]; then
|
||||
echo "::error::${required_secret_name} secret not set for runtime=${E2E_RUNTIME} — A2A will fail at request time with 'No LLM provider configured'"
|
||||
exit 2
|
||||
fi
|
||||
echo "LLM key present ✓ (runtime=${E2E_RUNTIME}, key=${required_secret_name}, len=${#required_secret_value})"
|
||||
|
||||
- name: Canary run
|
||||
id: canary
|
||||
run: bash tests/e2e/test_staging_full_saas.sh
|
||||
|
||||
# Alerting: open an issue only after THREE consecutive failures so
|
||||
# transient flakes (Cloudflare DNS hiccup, AWS API blip) don't spam
|
||||
# the issue list. If an issue is already open, we still comment on
|
||||
# every failure so ops sees the streak. Auto-close on next green.
|
||||
#
|
||||
# Threshold rationale: canary fires every 30 min, so 3 failures =
|
||||
# ~90 min of consecutive red — well past any single-run flake but
|
||||
# still tight enough that a real outage gets surfaced before the
|
||||
# next deploy window.
|
||||
- name: Open issue on failure
|
||||
if: failure()
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
# Inject the workflow path explicitly — context.workflow is
|
||||
# the *name*, not the file path the actions API needs.
|
||||
WORKFLOW_PATH: '.github/workflows/canary-staging.yml'
|
||||
CONSECUTIVE_THRESHOLD: '3'
|
||||
with:
|
||||
script: |
|
||||
const title = '🔴 Canary failing: staging SaaS smoke';
|
||||
const runURL = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
|
||||
// Find an existing open canary issue (stable title match).
|
||||
// If one exists, this isn't a "first failure" — comment and exit.
|
||||
const { data: existing } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
state: 'open', labels: 'canary-staging',
|
||||
per_page: 10,
|
||||
});
|
||||
const match = existing.find(i => i.title === title);
|
||||
if (match) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: match.number,
|
||||
body: `Canary still failing. ${runURL}`,
|
||||
});
|
||||
core.info(`Commented on existing issue #${match.number}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// No open issue yet — check the last N-1 runs' conclusions.
|
||||
// We open the issue only if the last (THRESHOLD-1) runs ALSO
|
||||
// failed (so this is the 3rd consecutive red).
|
||||
const threshold = parseInt(process.env.CONSECUTIVE_THRESHOLD, 10);
|
||||
const { data: runs } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
workflow_id: process.env.WORKFLOW_PATH,
|
||||
status: 'completed',
|
||||
per_page: threshold,
|
||||
// Skip the current in-progress run; it isn't 'completed' yet.
|
||||
});
|
||||
// listWorkflowRuns returns recent first. We need (threshold-1)
|
||||
// prior failures (current run is the threshold-th).
|
||||
const priorFailures = (runs.workflow_runs || [])
|
||||
.slice(0, threshold - 1)
|
||||
.filter(r => r.id !== context.runId)
|
||||
.filter(r => r.conclusion === 'failure')
|
||||
.length;
|
||||
if (priorFailures < threshold - 1) {
|
||||
core.info(`Below threshold: ${priorFailures + 1}/${threshold} consecutive failures — not filing yet`);
|
||||
return;
|
||||
}
|
||||
|
||||
const body =
|
||||
`Canary run failed at ${new Date().toISOString()}, ` +
|
||||
`${threshold} consecutive runs red.\n\n` +
|
||||
`Run: ${runURL}\n\n` +
|
||||
`This issue auto-closes on the next green canary run. ` +
|
||||
`Consecutive failures add a comment here rather than a new issue.`;
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
title, body,
|
||||
labels: ['canary-staging', 'bug'],
|
||||
});
|
||||
core.info(`Opened canary failure issue (${threshold} consecutive reds)`);
|
||||
|
||||
- name: Auto-close canary issue on success
|
||||
if: success()
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const title = '🔴 Canary failing: staging SaaS smoke';
|
||||
const { data: open } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
state: 'open', labels: 'canary-staging',
|
||||
per_page: 10,
|
||||
});
|
||||
const match = open.find(i => i.title === title);
|
||||
if (match) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: match.number,
|
||||
body: `Canary recovered at ${new Date().toISOString()}. Closing.`,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: match.number,
|
||||
state: 'closed',
|
||||
});
|
||||
core.info(`Closed recovered canary issue #${match.number}`);
|
||||
}
|
||||
|
||||
- name: Teardown safety net
|
||||
if: always()
|
||||
env:
|
||||
ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
|
||||
run: |
|
||||
set +e
|
||||
# Slug prefix matches what test_staging_full_saas.sh emits
|
||||
# in canary mode:
|
||||
# SLUG="e2e-canary-$(date +%Y%m%d)-${RUN_ID_SUFFIX}"
|
||||
# Earlier this was `e2e-{today}-canary-` — that was the
|
||||
# full-mode pattern (date FIRST, mode SECOND); canary slugs
|
||||
# have mode FIRST, date SECOND. The mismatch silently
|
||||
# never matched, leaving every cancelled-canary EC2 alive
|
||||
# until the once-an-hour sweep eventually caught it
|
||||
# (incident 2026-04-26 21:03Z: 1h25m EC2 leak before manual
|
||||
# cleanup; same gap on three earlier cancellations today).
|
||||
orgs=$(curl -sS "$MOLECULE_CP_URL/cp/admin/orgs" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null \
|
||||
| python3 -c "
|
||||
import json, sys, os, datetime
|
||||
run_id = os.environ.get('GITHUB_RUN_ID', '')
|
||||
d = json.load(sys.stdin)
|
||||
# Scope to slugs from THIS canary run when GITHUB_RUN_ID is
|
||||
# available; the canary workflow sets E2E_RUN_ID='canary-\${run_id}'
|
||||
# so the slug suffix is '-canary-\${run_id}-...'. Mirrors the
|
||||
# full-mode safety net's per-run scoping (e2e-staging-saas.yml)
|
||||
# added after the 2026-04-21 cross-run cleanup incident.
|
||||
# Sweep both today AND yesterday's UTC dates so a run that
|
||||
# crosses midnight still cleans up its own slug — see the
|
||||
# 2026-04-26→27 canvas-safety-net incident.
|
||||
today = datetime.date.today()
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
dates = (today.strftime('%Y%m%d'), yesterday.strftime('%Y%m%d'))
|
||||
if run_id:
|
||||
prefixes = tuple(f'e2e-canary-{d}-canary-{run_id}' for d in dates)
|
||||
else:
|
||||
prefixes = tuple(f'e2e-canary-{d}-' for d in dates)
|
||||
candidates = [o['slug'] for o in d.get('orgs', [])
|
||||
if any(o.get('slug','').startswith(p) for p in prefixes)
|
||||
and o.get('status') not in ('purged',)]
|
||||
print('\n'.join(candidates))
|
||||
" 2>/dev/null)
|
||||
# Per-slug DELETE with HTTP-code verification. The previous
|
||||
# `... >/dev/null || true` swallowed every failure, so a 5xx
|
||||
# or timeout from CP looked identical to "successfully cleaned
|
||||
# up" and the tenant kept eating ~2 vCPU until the hourly
|
||||
# stale sweep caught it (up to 2h later). Now we capture the
|
||||
# response code and surface non-2xx as a workflow warning, so
|
||||
# the run page shows which slug leaked. We still don't `exit 1`
|
||||
# on cleanup failure — a single-canary cleanup miss shouldn't
|
||||
# fail-flag the canary itself when the actual smoke check
|
||||
# passed. The sweep-stale-e2e-orgs cron (now every 15 min,
|
||||
# 30-min threshold) is the safety net for whatever slips past.
|
||||
# See molecule-controlplane#420.
|
||||
leaks=()
|
||||
for slug in $orgs; do
|
||||
# Tempfile-routed -w + set +e/-e prevents curl-exit-code
|
||||
# pollution of the captured status (lint-curl-status-capture.yml).
|
||||
set +e
|
||||
curl -sS -o /tmp/canary-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\"}" >/tmp/canary-cleanup.code
|
||||
set -e
|
||||
code=$(cat /tmp/canary-cleanup.code 2>/dev/null || echo "000")
|
||||
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
|
||||
echo "[teardown] deleted $slug (HTTP $code)"
|
||||
else
|
||||
echo "::warning::canary teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/canary-cleanup.out 2>/dev/null)"
|
||||
leaks+=("$slug")
|
||||
fi
|
||||
done
|
||||
if [ ${#leaks[@]} -gt 0 ]; then
|
||||
echo "::warning::canary teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
|
||||
fi
|
||||
exit 0
|
||||
@@ -34,13 +34,14 @@ jobs:
|
||||
canary-smoke:
|
||||
# Skip when the upstream workflow failed — no image to test against.
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
# Self-hosted mac mini — GitHub-hosted minutes are quota-blocked on
|
||||
# this org (same reason publish/promote-latest moved earlier).
|
||||
runs-on: [self-hosted, macos, arm64]
|
||||
outputs:
|
||||
sha: ${{ steps.compute.outputs.sha }}
|
||||
smoke_ran: ${{ steps.smoke.outputs.ran }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compute sha
|
||||
id: compute
|
||||
@@ -48,10 +49,11 @@ jobs:
|
||||
|
||||
- name: Wait for canary tenants to pick up :staging-<sha>
|
||||
# Poll canary health endpoints every 30s for up to 7 min instead
|
||||
# of a fixed 6-min sleep. Exits as soon as ALL canaries report
|
||||
# the new SHA (~2-3 min typical vs 6 min fixed). Falls back to
|
||||
# proceeding after 7 min even if not all canaries responded —
|
||||
# the smoke suite will catch any that didn't update.
|
||||
# of a fixed 6-min sleep. Exits as soon as ALL canaries report the
|
||||
# new SHA, freeing the self-hosted runner slot sooner (~2-3 min
|
||||
# typical vs 6 min fixed). Falls back to proceeding after 7 min
|
||||
# even if not all canaries responded — the smoke suite will catch
|
||||
# any that didn't update.
|
||||
env:
|
||||
CANARY_TENANT_URLS: ${{ secrets.CANARY_TENANT_URLS }}
|
||||
EXPECTED_SHA: ${{ steps.compute.outputs.sha }}
|
||||
@@ -86,38 +88,12 @@ jobs:
|
||||
echo "Timeout after ${MAX_WAIT}s — proceeding anyway (smoke suite will validate)"
|
||||
|
||||
- name: Run canary smoke suite
|
||||
id: smoke
|
||||
# Graceful-skip when no canary fleet is configured (Phase 2 not yet
|
||||
# stood up — see molecule-controlplane/docs/canary-tenants.md).
|
||||
# Sets `ran=false` on skip so promote-to-latest stays off (we don't
|
||||
# want every main merge auto-promoting without gating). Manual
|
||||
# promote-latest.yml is the release gate while canary is absent.
|
||||
# Once the fleet is real: delete the early-exit branch.
|
||||
env:
|
||||
CANARY_TENANT_URLS: ${{ secrets.CANARY_TENANT_URLS }}
|
||||
CANARY_ADMIN_TOKENS: ${{ secrets.CANARY_ADMIN_TOKENS }}
|
||||
CANARY_CP_BASE_URL: https://staging-api.moleculesai.app
|
||||
CANARY_CP_SHARED_SECRET: ${{ secrets.CANARY_CP_SHARED_SECRET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${CANARY_TENANT_URLS:-}" ] \
|
||||
|| [ -z "${CANARY_ADMIN_TOKENS:-}" ] \
|
||||
|| [ -z "${CANARY_CP_SHARED_SECRET:-}" ]; then
|
||||
{
|
||||
echo "## ⚠️ canary-verify skipped"
|
||||
echo
|
||||
echo "One or more canary secrets are unset (\`CANARY_TENANT_URLS\`, \`CANARY_ADMIN_TOKENS\`, \`CANARY_CP_SHARED_SECRET\`)."
|
||||
echo "Phase 2 canary fleet has not been stood up yet —"
|
||||
echo "see [canary-tenants.md](https://github.com/molecule-ai/molecule-controlplane/blob/main/docs/canary-tenants.md)."
|
||||
echo
|
||||
echo "**Skipped — promote-to-latest will NOT auto-fire.** Dispatch \`promote-latest.yml\` manually when ready."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "ran=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::canary-verify: skipped — no canary fleet configured"
|
||||
exit 0
|
||||
fi
|
||||
bash scripts/canary-smoke.sh
|
||||
echo "ran=true" >> "$GITHUB_OUTPUT"
|
||||
run: bash scripts/canary-smoke.sh
|
||||
|
||||
- name: Summary on failure
|
||||
if: ${{ failure() }}
|
||||
@@ -136,14 +112,23 @@ jobs:
|
||||
# On green, retag :staging-<sha> → :latest for BOTH images.
|
||||
# crane is a lightweight registry client (no Docker daemon needed on
|
||||
# the runner) that can retag remotely with a single API call each.
|
||||
# Gated on smoke_ran=true — without a real canary fleet the smoke
|
||||
# step no-ops with success, and we don't want that to silently
|
||||
# auto-promote every main merge.
|
||||
needs: canary-smoke
|
||||
if: ${{ needs.canary-smoke.result == 'success' && needs.canary-smoke.outputs.smoke_ran == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.canary-smoke.result == 'success' }}
|
||||
runs-on: [self-hosted, macos, arm64]
|
||||
steps:
|
||||
- uses: imjasonh/setup-crane@6da1ae018866400525525ce74ff892880c099987 # v0.5
|
||||
- name: Ensure crane installed
|
||||
# Matches the install pattern in promote-latest.yml — brew
|
||||
# cleanup exits non-zero on the shared runner's /opt/homebrew
|
||||
# symlinks, so skip it.
|
||||
env:
|
||||
HOMEBREW_NO_INSTALL_CLEANUP: "1"
|
||||
HOMEBREW_NO_AUTO_UPDATE: "1"
|
||||
HOMEBREW_NO_ENV_HINTS: "1"
|
||||
run: |
|
||||
if ! command -v crane >/dev/null 2>&1; then
|
||||
brew install crane
|
||||
fi
|
||||
crane version
|
||||
|
||||
- name: GHCR login
|
||||
run: |
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
name: cascade-list-drift-gate
|
||||
|
||||
# Structural gate: TEMPLATES list in publish-runtime.yml must match
|
||||
# manifest.json's workspace_templates exactly. Closes the recurrence
|
||||
# path of PR #2556 (the data fix) and is the first concrete deliverable
|
||||
# of RFC #388 PR-3.
|
||||
#
|
||||
# Why a gate, not just discipline: PR #2536 pruned the manifest, but the
|
||||
# cascade list wasn't updated for ~weeks before someone (PR #2556)
|
||||
# noticed during an unrelated audit. During that window, codex never
|
||||
# rebuilt on a runtime publish. A structural gate catches the drift
|
||||
# the same day either file changes.
|
||||
#
|
||||
# Triggers narrowly to keep CI quiet: only on PRs that actually change
|
||||
# one of the two files. The path-filtered split + always-emit-result
|
||||
# pattern (memory: "Required check names need a job that always runs")
|
||||
# is unnecessary here because the workflow IS the check name and PR
|
||||
# branch protection should require it directly. Future-proof: if this
|
||||
# becomes a required check, add a no-op aggregator with always() so the
|
||||
# name still emits when paths don't match.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [staging, main]
|
||||
paths:
|
||||
- manifest.json
|
||||
- .github/workflows/publish-runtime.yml
|
||||
- scripts/check-cascade-list-vs-manifest.sh
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Check cascade list matches manifest
|
||||
run: bash scripts/check-cascade-list-vs-manifest.sh
|
||||
@@ -1,123 +0,0 @@
|
||||
name: Check merge_group trigger on required workflows
|
||||
|
||||
# Pre-merge guard against the deadlock pattern where a workflow whose
|
||||
# check is in `required_status_checks` lacks a `merge_group:` trigger.
|
||||
# Without it, GitHub merge queue stalls forever in AWAITING_CHECKS
|
||||
# because the required check can't fire on `gh-readonly-queue/...` refs.
|
||||
#
|
||||
# This workflow:
|
||||
# 1. Lists required status checks on the branch protection rule for `staging`
|
||||
# 2. For each required check, finds the workflow that produces it (by job
|
||||
# name match)
|
||||
# 3. Fails if any such workflow lacks `merge_group:` in its triggers
|
||||
#
|
||||
# Reasoning for staging-only: main has its own CI gating model (PR review),
|
||||
# but staging is what the merge queue runs on, so it's the trigger that
|
||||
# matters.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/**.yml'
|
||||
- '.github/workflows/**.yaml'
|
||||
push:
|
||||
branches: [staging, main]
|
||||
paths:
|
||||
- '.github/workflows/**.yml'
|
||||
- '.github/workflows/**.yaml'
|
||||
# Self-listen on merge_group so the linter passes its own queue run.
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Required workflows have merge_group trigger
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Verify merge_group trigger on required-check workflows
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Branch we care about — the one merge queue runs on.
|
||||
BRANCH=staging
|
||||
|
||||
# Pull the list of required status check contexts. If the branch
|
||||
# has no protection or no required checks, exit clean — nothing
|
||||
# to lint.
|
||||
REQUIRED=$(gh api "repos/${REPO}/branches/${BRANCH}/protection/required_status_checks" \
|
||||
--jq '.contexts[]' 2>/dev/null || true)
|
||||
if [ -z "$REQUIRED" ]; then
|
||||
echo "No required status checks on ${BRANCH} — nothing to verify."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Required checks on ${BRANCH}:"
|
||||
echo "${REQUIRED}" | sed 's/^/ - /'
|
||||
echo
|
||||
|
||||
# Build a map: workflow file -> set of job names declared in it.
|
||||
# We use yq if available, otherwise grep the `name:` lines under
|
||||
# `jobs:`. Stick with grep for portability — runner image always
|
||||
# has it; yq isn't in the default image as of 2026-04.
|
||||
declare -A workflow_jobs
|
||||
shopt -s nullglob
|
||||
for wf in .github/workflows/*.yml .github/workflows/*.yaml; do
|
||||
[ -f "$wf" ] || continue
|
||||
# Extract the workflow name (the `name:` at file root).
|
||||
wf_name=$(awk '/^name:[[:space:]]/ {sub(/^name:[[:space:]]+/,""); gsub(/^"|"$/,""); print; exit}' "$wf")
|
||||
# Extract job step names from the `jobs:` block. A job step is:
|
||||
# - id under `jobs:` (key with 2-space indent followed by colon)
|
||||
# - the `name:` field inside that job (4-space indent)
|
||||
# We collect both because required_status_checks contexts can
|
||||
# match either, depending on how the workflow was authored.
|
||||
jobs_block=$(awk '/^jobs:/{flag=1; next} flag' "$wf")
|
||||
job_names=$(echo "$jobs_block" | awk '/^[[:space:]]{4}name:[[:space:]]/ {sub(/^[[:space:]]+name:[[:space:]]+/,""); gsub(/^["'"'"']|["'"'"']$/,""); print}')
|
||||
workflow_jobs["$wf"]="${wf_name}"$'\n'"${job_names}"
|
||||
done
|
||||
|
||||
# For each required check, find the workflow that produces it.
|
||||
# Then verify that workflow lists merge_group as a trigger.
|
||||
FAILED=0
|
||||
while IFS= read -r check; do
|
||||
[ -z "$check" ] && continue
|
||||
owning_wf=""
|
||||
for wf in "${!workflow_jobs[@]}"; do
|
||||
if echo "${workflow_jobs[$wf]}" | grep -Fxq "$check"; then
|
||||
owning_wf="$wf"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -z "$owning_wf" ]; then
|
||||
echo "::warning::Required check '${check}' has no matching workflow in this repo. Skipping (may be from an external app)."
|
||||
continue
|
||||
fi
|
||||
|
||||
# Does the workflow's trigger list include merge_group?
|
||||
# Match either bare `merge_group:` line or merge_group with
|
||||
# subsequent indented config (types: [checks_requested]).
|
||||
if grep -qE '^[[:space:]]*merge_group:' "$owning_wf"; then
|
||||
echo "OK: '${check}' (in $owning_wf) — has merge_group trigger"
|
||||
else
|
||||
echo "::error file=${owning_wf}::Required check '${check}' is produced by ${owning_wf}, but the workflow does not declare a 'merge_group:' trigger. With merge queue enabled on ${BRANCH}, this will deadlock the queue (every PR sits AWAITING_CHECKS forever). Add this to the workflow's 'on:' block:"
|
||||
echo "::error file=${owning_wf}:: merge_group:"
|
||||
echo "::error file=${owning_wf}:: types: [checks_requested]"
|
||||
FAILED=1
|
||||
fi
|
||||
done <<< "$REQUIRED"
|
||||
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo
|
||||
echo "::error::Block. See errors above. Reference: $(grep -l 'reference_merge_queue' /dev/null 2>/dev/null || echo 'memory: reference_merge_queue_enablement.md')."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "All required workflows on ${BRANCH} declare merge_group triggers."
|
||||
@@ -1,58 +0,0 @@
|
||||
name: Check migration collisions
|
||||
|
||||
# Hard gate (#2341): fails a PR that adds a migration prefix already
|
||||
# claimed by the base branch or another open PR. Caught manually 2026-04-30
|
||||
# during PR #2276 rebase: 044_runtime_image_pins collided with
|
||||
# 044_platform_inbound_secret from RFC #2312. This workflow makes that
|
||||
# check automatic.
|
||||
#
|
||||
# Trigger model: pull_request only — there's no value running this on
|
||||
# pushes to staging or main (those are post-merge; the gate must fire
|
||||
# pre-merge to be useful). Path filter scopes to PRs that actually touch
|
||||
# migrations.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'workspace-server/migrations/**'
|
||||
- 'scripts/ops/check_migration_collisions.py'
|
||||
- '.github/workflows/check-migration-collisions.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# gh pr list/diff need read access to other PRs
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Migration version collision check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# Need history to diff against base ref
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Detect collisions
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
BASE_REF: origin/${{ github.event.pull_request.base.ref }}
|
||||
HEAD_REF: ${{ github.event.pull_request.head.sha }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
# gh CLI uses GH_TOKEN from env
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Ensure the named base ref exists locally. checkout@v4 with
|
||||
# fetch-depth=0 pulls full history, but the explicit fetch is
|
||||
# cheap insurance against form-of-ref differences across runs.
|
||||
#
|
||||
# IMPORTANT: do NOT pass --depth=1 here. The script below uses
|
||||
# `git diff origin/<base>...<head>` (three-dot, merge-base form),
|
||||
# which fails with "fatal: no merge base" if the base ref is
|
||||
# shallow. The auto-promote staging→main PR (#2361) was blocked
|
||||
# by exactly this for ~5h on 2026-04-30 — the depth=1 fetch
|
||||
# overwrote checkout@v4's full-history clone with a shallow tip.
|
||||
git fetch origin "${{ github.event.pull_request.base.ref }}" || true
|
||||
python3 scripts/ops/check_migration_collisions.py
|
||||
+63
-298
@@ -5,24 +5,20 @@ on:
|
||||
branches: [main, staging]
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
# GitHub merge queue fires `merge_group` for the queue's pre-merge CI run.
|
||||
# Required so the queue gets a real check result instead of a false-green
|
||||
# from the absence of a triggered workflow. Safe to add unconditionally —
|
||||
# the event simply doesn't fire until the queue is enabled on the branch.
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
# Cancel in-progress CI runs when a new commit arrives on the same ref.
|
||||
# This prevents stale runs from queuing behind each other. The merge_group
|
||||
# refs (refs/heads/gh-readonly-queue/...) get their own concurrency group
|
||||
# automatically because github.ref differs from the PR ref.
|
||||
# This prevents multiple stale runs from queuing and keeps the self-hosted
|
||||
# macOS arm64 runner (publish-canvas-image, publish-workspace-server-image)
|
||||
# available for the jobs that genuinely require it.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Detect which paths changed so downstream jobs can skip when only
|
||||
# docs/markdown files were modified.
|
||||
# docs/markdown files were modified. Uses plain `git diff` — no macOS
|
||||
# dependency, so this runs on ubuntu-latest to free the self-hosted
|
||||
# macOS arm64 runner for jobs that genuinely need it.
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: ubuntu-latest
|
||||
@@ -32,22 +28,17 @@ jobs:
|
||||
python: ${{ steps.check.outputs.python }}
|
||||
scripts: ${{ steps.check.outputs.scripts }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: check
|
||||
run: |
|
||||
# For PR events: diff against the base branch (not HEAD~1 of the branch,
|
||||
# which may be unrelated after force-pushes). When a push updates a PR,
|
||||
# both pull_request and push events fire — prefer the PR base so that
|
||||
# the diff is always computed against the actual merge base, not the
|
||||
# previous SHA on the branch which may be on a different history line.
|
||||
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
|
||||
# GITHUB_BASE_REF is set by GitHub for PR events (the base branch name).
|
||||
# For pull_request events we use the stored base.sha; for push events
|
||||
# (or when base.sha is unavailable) fall back to github.event.before.
|
||||
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
|
||||
# For push events: diff against previous commit (handles merge commits)
|
||||
# For PR events: diff against the base branch
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
else
|
||||
BASE="${{ github.event.before }}"
|
||||
fi
|
||||
# Fallback: if BASE is empty or all zeros (new branch), run everything
|
||||
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
|
||||
@@ -61,237 +52,91 @@ jobs:
|
||||
echo "platform=$(echo "$DIFF" | grep -qE '^workspace-server/|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
echo "canvas=$(echo "$DIFF" | grep -qE '^canvas/|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
echo "python=$(echo "$DIFF" | grep -qE '^workspace/|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
echo "scripts=$(echo "$DIFF" | grep -qE '^tests/e2e/|^scripts/|^infra/scripts/|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
echo "scripts=$(echo "$DIFF" | grep -qE '^tests/e2e/|^scripts/|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Platform (Go) is a required check on staging. Always-run + per-step
|
||||
# gating (see Canvas (Next.js) for the rationale and the failure mode
|
||||
# this avoids).
|
||||
platform-build:
|
||||
name: Platform (Go)
|
||||
needs: changes
|
||||
if: needs.changes.outputs.platform == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace-server
|
||||
steps:
|
||||
- if: needs.changes.outputs.platform != 'true'
|
||||
working-directory: .
|
||||
run: echo "No platform/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
run: go mod download
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
run: go build ./cmd/server
|
||||
# CLI (molecli) moved to standalone repo: github.com/molecule-ai/molecule-cli
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
run: go vet ./... || true
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Run golangci-lint
|
||||
run: golangci-lint run --timeout 3m ./... || true
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Run tests with race detection and coverage
|
||||
- run: go mod download
|
||||
- run: go build ./cmd/server
|
||||
# CLI (molecli) moved to standalone repo: github.com/Molecule-AI/molecule-cli
|
||||
- run: go vet ./...
|
||||
# golangci-lint-action uses a Linux Docker image (ubuntu is the only arch+OS
|
||||
# combo the official image publishes for). Previously this step was pinned to
|
||||
# [self-hosted, macos, arm64] because the Docker image can't run on macOS ARM.
|
||||
# Now that the job itself runs on ubuntu-latest, the Docker image works natively.
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
version: latest
|
||||
working-directory: workspace-server
|
||||
args: --timeout 3m
|
||||
continue-on-error: true # Warn but don't block until codebase is clean
|
||||
- name: Run tests with race detection and coverage
|
||||
run: go test -race -coverprofile=coverage.out ./...
|
||||
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Per-file coverage report
|
||||
# Advisory — lists every source file with its coverage so reviewers
|
||||
# can see at-a-glance where gaps are. Sorted ascending so the worst
|
||||
# offenders float to the top. Does NOT fail the build; the hard
|
||||
# gate is the threshold check below. (#1823)
|
||||
- name: Check coverage baseline
|
||||
run: |
|
||||
echo "=== Per-file coverage (worst first) ==="
|
||||
go tool cover -func=coverage.out \
|
||||
| grep -v '^total:' \
|
||||
| awk '{file=$1; sub(/:[0-9][0-9.]*:.*/, "", file); pct=$NF; gsub(/%/,"",pct); s[file]+=pct; c[file]++}
|
||||
END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \
|
||||
| sort -n
|
||||
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Check coverage thresholds
|
||||
# Enforces two gates from #1823 Layer 1:
|
||||
# 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md).
|
||||
# 2. Per-file floor — non-test .go files in security-critical
|
||||
# paths with coverage <10% fail the build, UNLESS the file
|
||||
# path is listed in .coverage-allowlist.txt (acknowledged
|
||||
# historical debt with a tracking issue + expiry).
|
||||
run: |
|
||||
set -e
|
||||
TOTAL_FLOOR=25
|
||||
# Security-critical paths where a 0%-coverage file is a real risk.
|
||||
CRITICAL_PATHS=(
|
||||
"internal/handlers/tokens"
|
||||
"internal/handlers/workspace_provision"
|
||||
"internal/handlers/a2a_proxy"
|
||||
"internal/handlers/registry"
|
||||
"internal/handlers/secrets"
|
||||
"internal/middleware/wsauth"
|
||||
"internal/crypto"
|
||||
)
|
||||
|
||||
TOTAL=$(go tool cover -func=coverage.out | grep '^total:' | awk '{print $3}' | sed 's/%//')
|
||||
echo "Total coverage: ${TOTAL}%"
|
||||
if awk "BEGIN{exit !($TOTAL < $TOTAL_FLOOR)}"; then
|
||||
echo "::error::Total coverage ${TOTAL}% is below the ${TOTAL_FLOOR}% floor. See COVERAGE_FLOOR.md for ratchet plan."
|
||||
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
|
||||
echo "Total coverage: ${COVERAGE}%"
|
||||
THRESHOLD=25
|
||||
awk "BEGIN{if ($COVERAGE < $THRESHOLD) exit 1}" || {
|
||||
echo "::error::Coverage ${COVERAGE}% is below the ${THRESHOLD}% threshold"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Aggregate per-file coverage → /tmp/perfile.txt: "<fullpath> <pct>"
|
||||
go tool cover -func=coverage.out \
|
||||
| grep -v '^total:' \
|
||||
| awk '{file=$1; sub(/:[0-9][0-9.]*:.*/, "", file); pct=$NF; gsub(/%/,"",pct); s[file]+=pct; c[file]++}
|
||||
END {for (f in s) printf "%s %.1f\n", f, s[f]/c[f]}' \
|
||||
> /tmp/perfile.txt
|
||||
|
||||
# Build allowlist — paths relative to workspace-server, one per line.
|
||||
# Lines starting with # are comments.
|
||||
ALLOWLIST=""
|
||||
if [ -f ../.coverage-allowlist.txt ]; then
|
||||
ALLOWLIST=$(grep -vE '^(#|[[:space:]]*$)' ../.coverage-allowlist.txt || true)
|
||||
fi
|
||||
|
||||
FAILED=0
|
||||
WARNED=0
|
||||
for path in "${CRITICAL_PATHS[@]}"; do
|
||||
while read -r file pct; do
|
||||
[[ "$file" == *_test.go ]] && continue
|
||||
[[ "$file" == *"$path"* ]] || continue
|
||||
awk "BEGIN{exit !($pct < 10)}" || continue
|
||||
|
||||
# Strip the package-import prefix so we can match .coverage-allowlist.txt
|
||||
# entries written as paths relative to workspace-server/.
|
||||
# Handle both module paths: platform/workspace-server/... and platform/...
|
||||
rel=$(echo "$file" | sed 's|^github.com/molecule-ai/molecule-monorepo/platform/workspace-server/||; s|^github.com/molecule-ai/molecule-monorepo/platform/||')
|
||||
|
||||
if echo "$ALLOWLIST" | grep -qxF "$rel"; then
|
||||
echo "::warning file=workspace-server/$rel::Critical file at ${pct}% coverage (allowlisted, #1823) — fix before expiry."
|
||||
WARNED=$((WARNED+1))
|
||||
else
|
||||
echo "::error file=workspace-server/$rel::Critical file at ${pct}% coverage — must be >=10% (target 80%). See #1823. To acknowledge as known debt, add this path to .coverage-allowlist.txt."
|
||||
FAILED=$((FAILED+1))
|
||||
fi
|
||||
done < /tmp/perfile.txt
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Critical-path check: $FAILED new failures, $WARNED allowlisted warnings."
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "$FAILED security-critical file(s) have <10% test coverage and are"
|
||||
echo "NOT in the allowlist. These paths handle auth, tokens, secrets, or"
|
||||
echo "workspace provisioning — a 0% file here is the exact gap that let"
|
||||
echo "CWE-22, CWE-78, KI-005 slip through in past incidents. Either:"
|
||||
echo " (a) add tests to raise coverage above 10%, or"
|
||||
echo " (b) add the path to .coverage-allowlist.txt with an expiry date"
|
||||
echo " and a tracking issue reference."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Canvas (Next.js) — required check, always runs. See platform-build
|
||||
# comment above for the rationale.
|
||||
#
|
||||
# Supersedes the canvas-build-noop pattern attempted in PR #2321: two
|
||||
# jobs sharing `name:` doesn't actually satisfy branch protection
|
||||
# because the SKIPPED check run sibling is treated as not-passed
|
||||
# regardless of how many SUCCESS siblings it has. Verified empirically
|
||||
# on PR #2314 — mergeStateStatus stayed BLOCKED until I collapsed to
|
||||
# a single-job-with-conditional-steps shape.
|
||||
canvas-build:
|
||||
name: Canvas (Next.js)
|
||||
needs: changes
|
||||
if: needs.changes.outputs.canvas == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: canvas
|
||||
steps:
|
||||
- if: needs.changes.outputs.canvas != 'true'
|
||||
working-directory: .
|
||||
run: echo "No canvas/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
run: rm -f package-lock.json && npm install
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
run: npm run build
|
||||
- if: needs.changes.outputs.canvas == 'true'
|
||||
name: Run tests with coverage
|
||||
# Coverage instrumentation is configured in canvas/vitest.config.ts
|
||||
# (provider: v8, reporters: text + html + json-summary). Step 2 of
|
||||
# #1815 — wires coverage into CI so we get a baseline visible on
|
||||
# every PR. No threshold gate yet; thresholds dial in (Step 3, also
|
||||
# tracked in #1815) after the team sees what current coverage is.
|
||||
# Per the inline comment in vitest.config.ts: "first land
|
||||
# observability so we can see the baseline, then dial in
|
||||
# thresholds + a hard gate" — this PR ships the observability half.
|
||||
run: npx vitest run --coverage
|
||||
- name: Upload coverage summary as artifact
|
||||
if: needs.changes.outputs.canvas == 'true' && always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: canvas-coverage-${{ github.run_id }}
|
||||
path: canvas/coverage/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
- run: rm -f package-lock.json && npm install
|
||||
- run: npm run build
|
||||
- name: Run tests
|
||||
run: npx vitest run
|
||||
|
||||
# MCP Server + SDK removed from CI — now in standalone repos:
|
||||
# - github.com/molecule-ai/molecule-mcp-server (npm CI)
|
||||
# - github.com/molecule-ai/molecule-sdk-python (PyPI CI)
|
||||
# - github.com/Molecule-AI/molecule-mcp-server (npm CI)
|
||||
# - github.com/Molecule-AI/molecule-sdk-python (PyPI CI)
|
||||
|
||||
# e2e-api job moved to .github/workflows/e2e-api.yml (issue #458).
|
||||
# It now has workflow-level concurrency (cancel-in-progress: false) so
|
||||
# new pushes queue the E2E run rather than cancelling it at the run level.
|
||||
|
||||
# Shellcheck (E2E scripts) — required check, always runs. See
|
||||
# platform-build for the rationale.
|
||||
shellcheck:
|
||||
name: Shellcheck (E2E scripts)
|
||||
needs: changes
|
||||
if: needs.changes.outputs.scripts == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- if: needs.changes.outputs.scripts != 'true'
|
||||
run: echo "No tests/e2e/ or infra/scripts/ changes — skipping real shellcheck; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh
|
||||
# shellcheck is pre-installed on ubuntu-latest runners (via apt).
|
||||
# infra/scripts/ is included because setup.sh + nuke.sh gate the
|
||||
# README quickstart — a shellcheck regression there silently breaks
|
||||
# new-user onboarding. scripts/ is intentionally excluded until its
|
||||
# pre-existing SC3040/SC3043 warnings are cleaned up.
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run shellcheck on tests/e2e/*.sh
|
||||
# shellcheck is pre-installed on ubuntu-latest GitHub-hosted runners.
|
||||
run: |
|
||||
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
echo "::error::shellcheck is not installed on the runner"
|
||||
exit 1
|
||||
fi
|
||||
find tests/e2e -type f -name '*.sh' -print0 \
|
||||
| xargs -0 shellcheck --severity=warning
|
||||
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
name: Lint cleanup-trap hygiene (RFC #2873)
|
||||
# Asserts every shell E2E test that calls `mktemp` also installs
|
||||
# an EXIT trap. Catches the /tmp-leak class — a missing trap
|
||||
# silently leaks scratch into CI runners (~10-100KB per run).
|
||||
# See tests/e2e/lint_cleanup_traps.sh for the rule + fix pattern.
|
||||
run: bash tests/e2e/lint_cleanup_traps.sh
|
||||
|
||||
- if: needs.changes.outputs.scripts == 'true'
|
||||
name: Run E2E bash unit tests (no live infra)
|
||||
# Pure-bash unit tests for E2E helper libs (lib/*.sh). These pin
|
||||
# behavior of dispatch logic that — when broken — silently masks as
|
||||
# "Could not resolve authentication method" only after a successful
|
||||
# tenant + workspace provision (PR #2571 incident, 2026-05-03). Add
|
||||
# new self-contained unit tests here as the lib/ directory grows;
|
||||
# tests requiring live CP/tenant credentials belong in the dedicated
|
||||
# e2e-staging-* workflows, not this job.
|
||||
run: |
|
||||
bash tests/e2e/test_model_slug.sh
|
||||
|
||||
canvas-deploy-reminder:
|
||||
name: Canvas Deploy Reminder
|
||||
runs-on: ubuntu-latest
|
||||
@@ -336,103 +181,23 @@ jobs:
|
||||
"repos/${{ github.repository }}/commits/${{ github.sha }}/comments" \
|
||||
--field "body=@/tmp/deploy-reminder.md"
|
||||
|
||||
# Python Lint & Test — required check, always runs. See platform-build
|
||||
# for the rationale.
|
||||
python-lint:
|
||||
name: Python Lint & Test
|
||||
needs: changes
|
||||
if: needs.changes.outputs.python == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
WORKSPACE_ID: test
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace
|
||||
steps:
|
||||
- if: needs.changes.outputs.python != 'true'
|
||||
working-directory: .
|
||||
run: echo "No workspace/** changes — skipping real lint+test; this job always runs to satisfy the required-check name on branch protection."
|
||||
- if: needs.changes.outputs.python == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.changes.outputs.python == 'true'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: pip
|
||||
cache-dependency-path: workspace/requirements.txt
|
||||
- if: needs.changes.outputs.python == 'true'
|
||||
run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov
|
||||
# Coverage flags + fail-under floor moved into workspace/pytest.ini
|
||||
# (issue #1817) so local `pytest` and CI use identical config.
|
||||
- if: needs.changes.outputs.python == 'true'
|
||||
run: python -m pytest --tb=short
|
||||
|
||||
- if: needs.changes.outputs.python == 'true'
|
||||
name: Per-file critical-path coverage (MCP / inbox / auth)
|
||||
# MCP-critical Python files have a per-file floor on top of the
|
||||
# 86% total floor in pytest.ini. Rationale (issue #2790, after
|
||||
# the PR #2766 → PR #2771 cycle): the total floor averages ~6000
|
||||
# lines, so a single MCP file could regress to ~50% with no
|
||||
# complaint as long as other modules compensate. These five
|
||||
# files handle multi-tenant routing + auth + inbox dispatch —
|
||||
# a coverage drop here is the same risk shape as a Go-side
|
||||
# workspace-server token/secrets file dropping below 10%.
|
||||
#
|
||||
# Floor 75% sits below current actuals (80-96%) so this gate is
|
||||
# strictly additive — no existing PR fails. Ratchet plan in
|
||||
# COVERAGE_FLOOR.md.
|
||||
run: |
|
||||
set -e
|
||||
PER_FILE_FLOOR=75
|
||||
CRITICAL_FILES=(
|
||||
"a2a_mcp_server.py"
|
||||
"mcp_cli.py"
|
||||
"a2a_tools.py"
|
||||
"a2a_tools_inbox.py"
|
||||
"inbox.py"
|
||||
"platform_auth.py"
|
||||
)
|
||||
|
||||
# pytest already wrote .coverage; emit a JSON view scoped to
|
||||
# the critical files so jq/python can read the per-file pct
|
||||
# without parsing tabular text. --include uses fnmatch, and
|
||||
# the leading "*" allows the file to live anywhere under the
|
||||
# workspace root (today they sit at workspace/<name>.py).
|
||||
INCLUDES=$(printf '*%s,' "${CRITICAL_FILES[@]}")
|
||||
INCLUDES="${INCLUDES%,}"
|
||||
python -m coverage json -o /tmp/critical-cov.json --include="$INCLUDES"
|
||||
|
||||
FAILED=0
|
||||
for f in "${CRITICAL_FILES[@]}"; do
|
||||
# Match by top-level path key (e.g. "a2a_tools.py", not
|
||||
# "builtin_tools/a2a_tools.py" — different file at 100%).
|
||||
# The keys in coverage.json are paths relative to the run
|
||||
# cwd (workspace/), so the critical-path entry sits at the
|
||||
# bare basename.
|
||||
pct=$(jq -r --arg f "$f" '.files | to_entries | map(select(.key == $f)) | .[0].value.summary.percent_covered // "MISSING"' /tmp/critical-cov.json)
|
||||
if [ "$pct" = "MISSING" ]; then
|
||||
echo "::error file=workspace/$f::No coverage data — file may have moved or test exclusion mis-set."
|
||||
FAILED=$((FAILED+1))
|
||||
continue
|
||||
fi
|
||||
echo "$f: ${pct}%"
|
||||
if awk "BEGIN{exit !($pct < $PER_FILE_FLOOR)}"; then
|
||||
echo "::error file=workspace/$f::${pct}% < ${PER_FILE_FLOOR}% per-file floor (MCP critical path). See COVERAGE_FLOOR.md."
|
||||
FAILED=$((FAILED+1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "$FAILED MCP critical-path file(s) below the ${PER_FILE_FLOOR}% per-file floor."
|
||||
echo "These paths handle multi-tenant routing, auth tokens, and inbox dispatch."
|
||||
echo "A coverage drop here is the same risk shape as Go-side tokens/secrets files"
|
||||
echo "dropping below 10% (see COVERAGE_FLOOR.md). Either:"
|
||||
echo " (a) add tests to raise coverage back above ${PER_FILE_FLOOR}%, or"
|
||||
echo " (b) if this is unavoidable historical debt, file an issue and propose"
|
||||
echo " adjusting the floor with rationale in COVERAGE_FLOOR.md."
|
||||
exit 1
|
||||
fi
|
||||
- run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov
|
||||
- run: WORKSPACE_ID=ci-placeholder python -m pytest --tb=short -q --cov=. --cov-report=term-missing
|
||||
|
||||
# SDK + plugin validation moved to standalone repo:
|
||||
# github.com/molecule-ai/molecule-sdk-python
|
||||
|
||||
# github.com/Molecule-AI/molecule-sdk-python
|
||||
|
||||
@@ -8,29 +8,24 @@ name: CodeQL
|
||||
# scanned. This workflow fills that gap by explicitly scanning both
|
||||
# branches on push and PR.
|
||||
#
|
||||
# Runs on ubuntu-latest (GHA-hosted — public repo, free). GHAS is NOT
|
||||
# enabled on this repo, so results are not uploaded to the Security
|
||||
# tab — the scan fails the PR check on findings, and the SARIF is
|
||||
# kept as a workflow artifact for triage.
|
||||
# Runs on the self-hosted mac mini (matches the org-wide Code Quality
|
||||
# runner-label config). GHAS is NOT enabled on this repo, so results
|
||||
# are not uploaded to the Security tab — the scan fails the PR check
|
||||
# on findings, and the SARIF is kept as a workflow artifact for
|
||||
# triage.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
# GitHub merge queue fires `merge_group` for the queue's pre-merge CI run.
|
||||
# Required so CodeQL Analyze checks get a real result on the queued
|
||||
# commit instead of a false-green. Event only fires once merge queue is
|
||||
# enabled on the target branch — safe to add unconditionally.
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
schedule:
|
||||
# Weekly run picks up findings in code that hasn't been touched.
|
||||
- cron: '30 1 * * 0'
|
||||
|
||||
# Workflow-level concurrency: only one CodeQL run per branch/PR at a time.
|
||||
# `cancel-in-progress: false` queues new runs so a quick follow-up push
|
||||
# doesn't nuke a 45-min analysis mid-flight.
|
||||
# `cancel-in-progress: false` queues new runs — the 45-min analysis is the
|
||||
# longest CI occupant and fights the single mac mini runner the hardest.
|
||||
concurrency:
|
||||
group: codeql-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
@@ -43,7 +38,7 @@ permissions:
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, macos, arm64]
|
||||
timeout-minutes: 45
|
||||
|
||||
strategy:
|
||||
@@ -53,23 +48,31 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout sibling plugin repo
|
||||
# Same reasoning as publish-workspace-server-image.yml — the Go
|
||||
# module's replace directive needs the plugin source so
|
||||
# CodeQL's "go build" phase can resolve.
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: molecule-ai/molecule-ai-plugin-github-app-auth
|
||||
repository: Molecule-AI/molecule-ai-plugin-github-app-auth
|
||||
path: molecule-ai-plugin-github-app-auth
|
||||
token: ${{ secrets.PLUGIN_REPO_PAT || secrets.GITHUB_TOKEN }}
|
||||
|
||||
# jq is pre-installed on ubuntu-latest — no setup step needed.
|
||||
- name: Ensure jq installed
|
||||
# Follows the crane-install pattern in promote-latest.yml.
|
||||
# HOMEBREW_NO_* flags skip the cleanup that fails on the shared
|
||||
# runner's /opt/homebrew symlinks.
|
||||
env:
|
||||
HOMEBREW_NO_INSTALL_CLEANUP: "1"
|
||||
HOMEBREW_NO_AUTO_UPDATE: "1"
|
||||
HOMEBREW_NO_ENV_HINTS: "1"
|
||||
run: command -v jq >/dev/null || brew install jq
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# security-extended widens past the default to include the
|
||||
@@ -77,11 +80,11 @@ jobs:
|
||||
queries: security-extended
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
id: analyze
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
# upload: never — GHAS isn't enabled on this repo, so the
|
||||
@@ -121,7 +124,7 @@ jobs:
|
||||
# 14-day retention — longer than default 3, short enough not
|
||||
# to bloat quota.
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: codeql-sarif-${{ matrix.language }}
|
||||
path: sarif-results/${{ matrix.language }}/
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
name: Continuous synthetic E2E (staging)
|
||||
|
||||
# Hard gate (#2342): cron-driven full-lifecycle E2E that catches
|
||||
# regressions visible only at runtime — schema drift, deployment-pipeline
|
||||
# gaps, vendor outages, env-var rotations, DNS / CF / Railway side-effects.
|
||||
#
|
||||
# Why this gate exists:
|
||||
# PR-time CI catches code-level regressions but not deployment-time or
|
||||
# integration-time ones. Today's empirical data:
|
||||
# • #2345 (A2A v0.2 silent drop) — passed all unit tests, broke at
|
||||
# JSON-RPC parse layer between sender and receiver. Visible only
|
||||
# to a sender exercising the full path.
|
||||
# • RFC #2312 chat upload — landed on staging-branch but never
|
||||
# reached staging tenants because publish-workspace-server-image
|
||||
# was main-only. Caught by manual dogfooding hours after deploy.
|
||||
# Both would have surfaced within 15-20 min of regression if a
|
||||
# continuous synth-E2E was running.
|
||||
#
|
||||
# Cadence: every 20 min (3x/hour). The script is conservatively
|
||||
# bounded at 10 min wall-clock; even on degraded staging it should
|
||||
# finish before the next firing. cron-overlap is guarded by the
|
||||
# concurrency group below.
|
||||
#
|
||||
# Cost: ~3 runs/hour × 5-10 min × $0.008/min GHA = ~$0.50-$1/day.
|
||||
# Plus a fresh tenant provisioned + torn down each run (Railway +
|
||||
# AWS pennies). Negligible.
|
||||
#
|
||||
# Failure handling: when the run fails, the workflow exits non-zero
|
||||
# and GitHub's standard email/notification path fires. Operators
|
||||
# can subscribe to this workflow's failure channel for paging-grade
|
||||
# alerting.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 10 minutes, on :02 :12 :22 :32 :42 :52. Three constraints:
|
||||
# 1. Stay off the top-of-hour. GitHub Actions scheduler drops
|
||||
# :00 firings under high load (own docs:
|
||||
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule).
|
||||
# Prior history: cron was '0,20,40' (2026-05-02) — only :00
|
||||
# ever survived. Bumped to '10,30,50' (2026-05-03) on the
|
||||
# theory that further-from-:00 wins. Empirically 2026-05-04
|
||||
# that ALSO dropped to ~60 min effective cadence (only ~1
|
||||
# schedule fire per hour — see molecule-core#2726). Detection
|
||||
# latency was claimed 20 min, actual 60 min.
|
||||
# 2. Avoid colliding with the existing :15 sweep-cf-orphans
|
||||
# and :45 sweep-cf-tunnels — both hit the CF API and we
|
||||
# don't want to fight for rate-limit tokens.
|
||||
# 3. Avoid the :30 heavy slot (canary-staging /30, sweep-aws-
|
||||
# secrets, sweep-stale-e2e-orgs every :15) — multiple
|
||||
# overlapping cron registrations on the same minute is part
|
||||
# of what GH drops under load.
|
||||
# Solution: bump fires-per-hour 3 → 6 AND keep all slots in clean
|
||||
# lanes (1-3 min away from any other cron). Even with empirically-
|
||||
# observed ~67% GH drop ratio, 6 attempts/hour yields ~2 effective
|
||||
# fires = ~30 min cadence; closer to the 20-min target than the
|
||||
# current shape and provides a real degradation alarm if drops
|
||||
# get worse.
|
||||
- cron: '2,12,22,32,42,52 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
runtime:
|
||||
description: "Runtime to provision (claude-code = default + cheapest via MiniMax; langgraph = OpenAI-only; hermes = SDK-native path, slower)"
|
||||
required: false
|
||||
default: "claude-code"
|
||||
type: string
|
||||
model_slug:
|
||||
description: "Model id to provision the workspace with (default MiniMax-M2.7-highspeed; e.g. 'sonnet' to test direct Anthropic, 'openai/gpt-4o' for hermes)"
|
||||
required: false
|
||||
default: "MiniMax-M2.7-highspeed"
|
||||
type: string
|
||||
keep_org:
|
||||
description: "Skip teardown for post-mortem debugging (only manual dispatch — never set this for cron runs)"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# No issue-write here — failures surface as red runs in the workflow
|
||||
# history. If you want auto-issue-on-fail, add a follow-up step that
|
||||
# uses gh issue create gated on `if: failure()`. Keeping the surface
|
||||
# minimal until that's actually wanted.
|
||||
|
||||
# Serialize so two firings can never overlap. Cron firing every 20 min
|
||||
# but scripts conservatively bounded at 10 min — overlap shouldn't
|
||||
# happen in steady state, but if a run hangs we don't want N more
|
||||
# stacking up.
|
||||
concurrency:
|
||||
group: continuous-synth-e2e
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
synth:
|
||||
name: Synthetic E2E against staging
|
||||
runs-on: ubuntu-latest
|
||||
# Bumped from 12 → 20 (2026-05-04). Tenant user-data install phase
|
||||
# (apt-get update + install docker.io/jq/awscli/caddy + snap install
|
||||
# ssm-agent) runs from raw Ubuntu on every boot — none of it is
|
||||
# pre-baked into the tenant AMI. Empirical fetch_secrets/ok timing
|
||||
# across today's canaries: 51s → 82s → 143s → 625s. apt-mirror tail
|
||||
# latency drives the boot-to-fetch_secrets phase from ~1min to >10min.
|
||||
# A 12min budget leaves only ~2min for the workspace (which needs
|
||||
# ~3.5min for claude-code cold boot) on slow-apt days, blowing the
|
||||
# budget. 20min absorbs the worst tenant tail so the workspace probe
|
||||
# gets the full ~7min it needs even on a slow apt day. Real fix:
|
||||
# pre-bake caddy + ssm-agent into the tenant AMI (controlplane#TBD).
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
# claude-code default: cold-start ~5 min (comparable to langgraph),
|
||||
# but uses MiniMax-M2.7-highspeed via the template's third-party-
|
||||
# Anthropic-compat path (workspace-configs-templates/claude-code-
|
||||
# default/config.yaml:64-69). MiniMax is ~5-10x cheaper than
|
||||
# gpt-4.1-mini per token AND avoids the recurring OpenAI quota-
|
||||
# exhaustion class that took the canary down 2026-05-03 (#265).
|
||||
# Operators can pick langgraph / hermes via workflow_dispatch
|
||||
# when they specifically need to exercise the OpenAI or SDK-
|
||||
# native paths.
|
||||
E2E_RUNTIME: ${{ github.event.inputs.runtime || 'claude-code' }}
|
||||
# Pin the canary to a specific MiniMax model rather than relying
|
||||
# on the per-runtime default ("sonnet" → routes to direct
|
||||
# Anthropic, defeats the cost saving). Operators can override
|
||||
# via workflow_dispatch by setting a different E2E_MODEL_SLUG
|
||||
# input if they need to exercise a specific model. M2.7-highspeed
|
||||
# is "Token Plan only" but cheap-per-token and fast.
|
||||
E2E_MODEL_SLUG: ${{ github.event.inputs.model_slug || 'MiniMax-M2.7-highspeed' }}
|
||||
# Bound to 10 min so a stuck provision fails the run instead of
|
||||
# holding up the next cron firing. 15-min default in the script
|
||||
# is for the on-PR full lifecycle where we have more headroom.
|
||||
E2E_PROVISION_TIMEOUT_SECS: '600'
|
||||
# Slug suffix — namespaced "synth-" so these runs are
|
||||
# distinguishable from PR-driven runs in CP admin.
|
||||
E2E_RUN_ID: synth-${{ github.run_id }}
|
||||
# Forced false for cron; respected for manual dispatch
|
||||
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org == 'true' && '1' || '' }}
|
||||
MOLECULE_CP_URL: ${{ vars.STAGING_CP_URL || 'https://staging-api.moleculesai.app' }}
|
||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
# MiniMax key is the canary's PRIMARY auth path. claude-code
|
||||
# template's `minimax` provider routes ANTHROPIC_BASE_URL to
|
||||
# api.minimax.io/anthropic and reads MINIMAX_API_KEY at boot.
|
||||
# tests/e2e/test_staging_full_saas.sh branches SECRETS_JSON on
|
||||
# which key is present — MiniMax wins when set.
|
||||
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
|
||||
# Direct-Anthropic alternative for operators who don't want to
|
||||
# set up a MiniMax account (priority below MiniMax — first
|
||||
# non-empty wins in test_staging_full_saas.sh's secrets-injection
|
||||
# block). See #2578 PR comment for the rationale.
|
||||
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
|
||||
# OpenAI fallback — kept wired so operators can dispatch with
|
||||
# E2E_RUNTIME=langgraph or =hermes and still have a working
|
||||
# canary path. The script picks the right blob shape based on
|
||||
# which key is non-empty.
|
||||
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_KEY }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify required secrets present
|
||||
run: |
|
||||
# Hard-fail on missing secret REGARDLESS of trigger. Previously
|
||||
# this step soft-skipped on workflow_dispatch via `exit 0`, but
|
||||
# `exit 0` only ends the STEP — subsequent steps still ran with
|
||||
# the empty secret, the synth script fell through to the wrong
|
||||
# SECRETS_JSON branch, and the canary failed 5 min later with a
|
||||
# confusing "Agent error (Exception)" instead of the clean
|
||||
# "secret missing" message at the top. Caught 2026-05-04 by
|
||||
# dispatched run 25296530706: claude-code + missing MINIMAX
|
||||
# silently used OpenAI keys but kept model=MiniMax-M2.7, then
|
||||
# the workspace 401'd against MiniMax once it tried to call.
|
||||
# Fix: exit 1 in both cron and dispatch paths. Operators who
|
||||
# want to verify a YAML change without setting up the secret
|
||||
# can read the verify-secrets step's stderr — the failure is
|
||||
# itself the verification signal.
|
||||
if [ -z "${MOLECULE_ADMIN_TOKEN:-}" ]; then
|
||||
echo "::error::CP_STAGING_ADMIN_API_TOKEN secret missing — synth E2E cannot run"
|
||||
echo "::error::Set it at Settings → Secrets and Variables → Actions; pull from staging-CP's CP_ADMIN_API_TOKEN env in Railway."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# LLM-key requirement is per-runtime: claude-code accepts
|
||||
# EITHER MiniMax OR direct-Anthropic (whichever is set first),
|
||||
# langgraph + hermes use OpenAI (MOLECULE_STAGING_OPENAI_KEY).
|
||||
case "${E2E_RUNTIME}" in
|
||||
claude-code)
|
||||
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
|
||||
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY"
|
||||
required_secret_value="${E2E_MINIMAX_API_KEY}"
|
||||
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
|
||||
required_secret_name="MOLECULE_STAGING_ANTHROPIC_API_KEY"
|
||||
required_secret_value="${E2E_ANTHROPIC_API_KEY}"
|
||||
else
|
||||
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY or MOLECULE_STAGING_ANTHROPIC_API_KEY"
|
||||
required_secret_value=""
|
||||
fi
|
||||
;;
|
||||
langgraph|hermes)
|
||||
required_secret_name="MOLECULE_STAGING_OPENAI_KEY"
|
||||
required_secret_value="${E2E_OPENAI_API_KEY:-}"
|
||||
;;
|
||||
*)
|
||||
echo "::warning::Unknown E2E_RUNTIME='${E2E_RUNTIME}' — skipping LLM-key check"
|
||||
required_secret_name=""
|
||||
required_secret_value="present"
|
||||
;;
|
||||
esac
|
||||
if [ -n "$required_secret_name" ] && [ -z "$required_secret_value" ]; then
|
||||
echo "::error::${required_secret_name} secret missing — runtime=${E2E_RUNTIME} cannot authenticate against its LLM provider"
|
||||
echo "::error::Set it at Settings → Secrets and Variables → Actions, OR dispatch with a different runtime"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install required tools
|
||||
run: |
|
||||
# The script depends on jq + curl (already on ubuntu-latest)
|
||||
# and python3 (likewise). Verify they're all present so we
|
||||
# fail fast on a runner image regression rather than mid-script.
|
||||
for cmd in jq curl python3; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || {
|
||||
echo "::error::required tool '$cmd' not on PATH — runner image regression?"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
- name: Run synthetic E2E
|
||||
# The script handles its own teardown via EXIT trap; even on
|
||||
# failure (timeout, assertion), the org is deprovisioned and
|
||||
# leaks are reported. Exit code propagates from the script.
|
||||
run: |
|
||||
bash tests/e2e/test_staging_full_saas.sh
|
||||
|
||||
- name: Failure summary
|
||||
# Runs only on failure. Adds a job summary so the workflow run
|
||||
# page shows a quick "what happened" instead of forcing readers
|
||||
# to scroll through script output.
|
||||
if: failure()
|
||||
run: |
|
||||
{
|
||||
echo "## Continuous synth E2E failed"
|
||||
echo ""
|
||||
echo "**Run ID:** ${{ github.run_id }}"
|
||||
echo "**Trigger:** ${{ github.event_name }}"
|
||||
echo "**Runtime:** ${E2E_RUNTIME}"
|
||||
echo "**Slug:** synth-${{ github.run_id }}"
|
||||
echo ""
|
||||
echo "### What this means"
|
||||
echo ""
|
||||
echo "Staging just regressed on a path that previously worked. Likely classes:"
|
||||
echo "- Schema mismatch between sender and receiver (#2345 class)"
|
||||
echo "- Deployment-pipeline gap (RFC #2312 / staging-tenant-image-stale class)"
|
||||
echo "- Vendor outage (Cloudflare, Railway, AWS, GHCR)"
|
||||
echo "- Staging-CP env var rotation"
|
||||
echo ""
|
||||
echo "### Next steps"
|
||||
echo ""
|
||||
echo "1. Check the script output above for the assertion that failed"
|
||||
echo "2. If it's a vendor outage, no action needed — next firing in ~20 min"
|
||||
echo "3. If it's a code regression, find the causing PR via \`git log\` against last green run and revert/fix"
|
||||
echo "4. Keep an eye on the next 1-2 firings — flake vs persistent fail differs in priority"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -2,81 +2,46 @@ name: E2E API Smoke Test
|
||||
# Extracted from ci.yml so workflow-level concurrency can protect this job
|
||||
# from run-level cancellation (issue #458).
|
||||
#
|
||||
# Trigger model (revised 2026-04-29):
|
||||
# Problem: the job-level `concurrency.cancel-in-progress: false` in ci.yml
|
||||
# prevented *sibling* E2E jobs from killing each other, but GitHub still
|
||||
# cancelled the parent *workflow run* when a new push arrived. Since the job
|
||||
# lived inside that run, it got cancelled too.
|
||||
#
|
||||
# Always FIRES on push/pull_request to staging+main. Real work is gated
|
||||
# per-step on `needs.detect-changes.outputs.api` — when paths under
|
||||
# `workspace-server/`, `tests/e2e/`, or this workflow file haven't
|
||||
# changed, the no-op step alone runs and emits SUCCESS for the
|
||||
# `E2E API Smoke Test` check, satisfying branch protection without
|
||||
# spending CI cycles. See the in-job comment on the `e2e-api` job for
|
||||
# why this is one job (not two-jobs-sharing-name) and the 2026-04-29
|
||||
# PR #2264 incident that drove the consolidation.
|
||||
# Fix: a dedicated workflow gets its own concurrency group at the workflow
|
||||
# level. New pushes to the same branch queue here instead of cancelling.
|
||||
# Fast jobs (platform-build, canvas-build, etc.) stay in ci.yml and continue
|
||||
# to benefit from run-level cancellation for quick feedback.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'workspace-server/**'
|
||||
- 'tests/e2e/**'
|
||||
- '.github/workflows/e2e-api.yml'
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
workflow_dispatch:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'workspace-server/**'
|
||||
- 'tests/e2e/**'
|
||||
- '.github/workflows/e2e-api.yml'
|
||||
|
||||
# Workflow-level concurrency: new runs queue rather than cancel.
|
||||
# `cancel-in-progress: false` is load-bearing — without it GitHub would still
|
||||
# cancel this run when the next push arrives, defeating the whole fix.
|
||||
# The group key includes github.ref so PRs don't compete with main.
|
||||
concurrency:
|
||||
# Per-SHA grouping (changed 2026-04-28 from per-ref). Per-ref had the
|
||||
# same auto-promote-staging brittleness as e2e-staging-canvas — back-
|
||||
# to-back staging pushes share refs/heads/staging, so the older push's
|
||||
# queued run gets cancelled when a newer push lands. Auto-promote-
|
||||
# staging then sees `completed/cancelled` for the older SHA and stays
|
||||
# put; the newer SHA's gates may eventually save the day, but if the
|
||||
# newer push gets cancelled too, we deadlock.
|
||||
#
|
||||
# See e2e-staging-canvas.yml's identical concurrency block for the full
|
||||
# rationale and the 2026-04-28 incident reference.
|
||||
group: e2e-api-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
group: e2e-api-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
api: ${{ steps.decide.outputs.api }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
api:
|
||||
- 'workspace-server/**'
|
||||
- 'tests/e2e/**'
|
||||
- '.github/workflows/e2e-api.yml'
|
||||
- id: decide
|
||||
# Always run real work for manual dispatch — no diff context to
|
||||
# filter against and ops dispatching this expects the suite to
|
||||
# actually exercise the platform.
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "api=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "api=${{ steps.filter.outputs.api }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# ONE job (no job-level `if:`) that always runs and reports under the
|
||||
# required-check name `E2E API Smoke Test`. Real work is gated per-step
|
||||
# on `needs.detect-changes.outputs.api`. Reason: GitHub registers a
|
||||
# check run for every job that matches `name:`, and a job-level
|
||||
# `if: false` produces a SKIPPED check run. Branch protection treats
|
||||
# all check runs with a matching context name on the latest commit as a
|
||||
# SET — any SKIPPED in the set fails the required-check eval, even with
|
||||
# SUCCESS siblings. Verified 2026-04-29 on PR #2264 (staging→main):
|
||||
# 4 check runs (2 SKIPPED + 2 SUCCESS) at the head SHA blocked
|
||||
# promotion despite all real work succeeding. Collapsing to a single
|
||||
# always-running job with conditional steps emits exactly one SUCCESS
|
||||
# check run regardless of paths filter — branch-protection-clean.
|
||||
e2e-api:
|
||||
needs: detect-changes
|
||||
name: E2E API Smoke Test
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, macos, arm64]
|
||||
timeout-minutes: 15
|
||||
# `services:` is Linux-only on self-hosted runners — we start postgres
|
||||
# and redis via `docker run` instead. Ports 15432/16379 avoid collision
|
||||
# with anything the host may already have on the standard ports.
|
||||
env:
|
||||
DATABASE_URL: postgres://dev:dev@localhost:15432/molecule?sslmode=disable
|
||||
REDIS_URL: redis://localhost:16379
|
||||
@@ -84,24 +49,21 @@ jobs:
|
||||
PG_CONTAINER: molecule-ci-postgres
|
||||
REDIS_CONTAINER: molecule-ci-redis
|
||||
steps:
|
||||
- name: No-op pass (paths filter excluded this commit)
|
||||
if: needs.detect-changes.outputs.api != 'true'
|
||||
run: |
|
||||
echo "No workspace-server / tests/e2e / workflow changes — E2E API gate satisfied without running tests."
|
||||
echo "::notice::E2E API Smoke Test no-op pass (paths filter excluded this commit)."
|
||||
- if: needs.detect-changes.outputs.api == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.detect-changes.outputs.api == 'true'
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
cache: true
|
||||
cache-dependency-path: workspace-server/go.sum
|
||||
- name: Start Postgres (docker)
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
run: |
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$PG_CONTAINER" -e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule -p 15432:5432 postgres:16
|
||||
docker run -d --name "$PG_CONTAINER" \
|
||||
-e POSTGRES_USER=dev \
|
||||
-e POSTGRES_PASSWORD=dev \
|
||||
-e POSTGRES_DB=molecule \
|
||||
-p 15432:5432 \
|
||||
postgres:16
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1; then
|
||||
echo "Postgres ready after ${i}s"
|
||||
@@ -113,7 +75,6 @@ jobs:
|
||||
docker logs "$PG_CONTAINER" || true
|
||||
exit 1
|
||||
- name: Start Redis (docker)
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
run: |
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$REDIS_CONTAINER" -p 16379:6379 redis:7
|
||||
@@ -125,20 +86,16 @@ jobs:
|
||||
sleep 1
|
||||
done
|
||||
echo "::error::Redis did not become ready in 15s"
|
||||
docker logs "$REDIS_CONTAINER" || true
|
||||
exit 1
|
||||
- name: Build platform
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
working-directory: workspace-server
|
||||
run: go build -o platform-server ./cmd/server
|
||||
- name: Start platform (background)
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
working-directory: workspace-server
|
||||
run: |
|
||||
./platform-server > platform.log 2>&1 &
|
||||
echo $! > platform.pid
|
||||
- name: Wait for /health
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf http://localhost:8080/health > /dev/null; then
|
||||
@@ -151,41 +108,29 @@ jobs:
|
||||
cat workspace-server/platform.log || true
|
||||
exit 1
|
||||
- name: Assert migrations applied
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
# Migrations auto-run at platform boot. Fail fast if they silently
|
||||
# didn't — catches future migration-author mistakes before the E2E run.
|
||||
run: |
|
||||
tables=$(docker exec "$PG_CONTAINER" psql -U dev -d molecule -tAc "SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='workspaces'")
|
||||
if [ "$tables" != "1" ]; then
|
||||
echo "::error::Migrations did not apply"
|
||||
echo "::error::Migrations did not apply — 'workspaces' table missing"
|
||||
cat workspace-server/platform.log || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Migrations OK"
|
||||
echo "Migrations OK (workspaces table present)"
|
||||
- name: Run E2E API tests
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
run: bash tests/e2e/test_api.sh
|
||||
- name: Run notify-with-attachments E2E
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
run: bash tests/e2e/test_notify_attachments_e2e.sh
|
||||
- name: Run priority-runtimes E2E (claude-code + hermes — skips when keys absent)
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
run: bash tests/e2e/test_priority_runtimes_e2e.sh
|
||||
- name: Run poll-mode + since_id cursor E2E (#2339)
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
run: bash tests/e2e/test_poll_mode_e2e.sh
|
||||
- name: Run poll-mode chat upload E2E (RFC #2891)
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
run: bash tests/e2e/test_poll_mode_chat_upload_e2e.sh
|
||||
- name: Dump platform log on failure
|
||||
if: failure() && needs.detect-changes.outputs.api == 'true'
|
||||
if: failure()
|
||||
run: cat workspace-server/platform.log || true
|
||||
- name: Stop platform
|
||||
if: always() && needs.detect-changes.outputs.api == 'true'
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f workspace-server/platform.pid ]; then
|
||||
kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true
|
||||
fi
|
||||
- name: Stop service containers
|
||||
if: always() && needs.detect-changes.outputs.api == 'true'
|
||||
if: always()
|
||||
run: |
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
name: E2E Staging Canvas (Playwright)
|
||||
|
||||
# Playwright test suite that provisions a fresh staging org per run and
|
||||
# verifies every workspace-panel tab renders without crashing. Complements
|
||||
# e2e-staging-saas.yml (which tests the API shape) by exercising the
|
||||
# actual browser + canvas bundle against live staging.
|
||||
#
|
||||
# Triggers: push to main/staging or PR touching canvas sources + this workflow,
|
||||
# manual dispatch, and weekly cron to catch browser/runtime drift even
|
||||
# when canvas is quiet.
|
||||
# Added staging to push/pull_request branches so the auto-promote gate
|
||||
# check (--event push --branch staging) can see a completed run for this
|
||||
# workflow — mirrors what PR #1891 does for e2e-api.yml.
|
||||
|
||||
on:
|
||||
# Trigger model (revised 2026-04-29):
|
||||
#
|
||||
# Always fires on push/pull_request; real work is gated per-step on
|
||||
# `needs.detect-changes.outputs.canvas`. When canvas/ paths haven't
|
||||
# changed, the no-op step alone runs and emits SUCCESS for the
|
||||
# `Canvas tabs E2E` check, satisfying branch protection without
|
||||
# spending CI cycles. See e2e-api.yml for the rationale on why this
|
||||
# is a single job rather than two-jobs-sharing-name.
|
||||
push:
|
||||
branches: [main, staging]
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Weekly on Sunday 08:00 UTC — catches Chrome / Playwright / Next.js
|
||||
# release-note-shaped regressions that don't ride in with a PR.
|
||||
- cron: '0 8 * * 0'
|
||||
|
||||
concurrency:
|
||||
# Per-SHA grouping (changed 2026-04-28 from a single global group). The
|
||||
# global group made auto-promote-staging brittle: when a staging push
|
||||
# queued behind an in-flight run and a third entrant (a PR run, a
|
||||
# follow-on push) entered the group, the staging push got cancelled —
|
||||
# leaving auto-promote-staging looking at `completed/cancelled` for a
|
||||
# required gate and refusing to advance main. Observed 2026-04-28
|
||||
# 23:51-23:53 on staging tip 3f99fede.
|
||||
#
|
||||
# The original intent of the global group was to throttle parallel
|
||||
# E2E provisions (each spins a fresh EC2). At our scale that throttle
|
||||
# isn't worth the correctness cost — fresh-org-per-run isolates the
|
||||
# state, and the cost of two parallel runs (~$0.001/min × 10min × 2)
|
||||
# is rounding error vs. the cost of a stuck pipeline.
|
||||
#
|
||||
# Per-SHA still dedupes accidental double-triggers for the SAME SHA.
|
||||
# It does NOT cancel obsolete-PR-version runs on force-push; that
|
||||
# wasted CI is acceptable given the alternative is losing staging-tip
|
||||
# data that auto-promote-staging needs.
|
||||
group: e2e-staging-canvas-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
canvas: ${{ steps.decide.outputs.canvas }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
canvas:
|
||||
- 'canvas/**'
|
||||
- '.github/workflows/e2e-staging-canvas.yml'
|
||||
- id: decide
|
||||
# Always run real tests for manual dispatch and the weekly cron —
|
||||
# both exist precisely to exercise the suite, regardless of diff.
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ] || [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
echo "canvas=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "canvas=${{ steps.filter.outputs.canvas }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# ONE job (no job-level `if:`) that always runs and reports under the
|
||||
# required-check name `Canvas tabs E2E`. Real work is gated per-step on
|
||||
# `needs.detect-changes.outputs.canvas`. See e2e-api.yml for the full
|
||||
# rationale — same path-filter check-name parity issue blocked PR #2264
|
||||
# (staging→main) on 2026-04-29 because branch protection treats matching-
|
||||
# name check runs as a SET, and any SKIPPED member fails the eval.
|
||||
playwright:
|
||||
needs: detect-changes
|
||||
name: Canvas tabs E2E
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
|
||||
env:
|
||||
CANVAS_E2E_STAGING: '1'
|
||||
MOLECULE_CP_URL: https://staging-api.moleculesai.app
|
||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: canvas
|
||||
|
||||
steps:
|
||||
- name: No-op pass (paths filter excluded this commit)
|
||||
if: needs.detect-changes.outputs.canvas != 'true'
|
||||
working-directory: .
|
||||
run: |
|
||||
echo "No canvas / workflow changes — E2E Staging Canvas gate satisfied without running tests."
|
||||
echo "::notice::E2E Staging Canvas no-op pass (paths filter excluded this commit)."
|
||||
|
||||
- if: needs.detect-changes.outputs.canvas == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify admin token present
|
||||
if: needs.detect-changes.outputs.canvas == 'true'
|
||||
run: |
|
||||
if [ -z "$MOLECULE_ADMIN_TOKEN" ]; then
|
||||
echo "::error::Missing MOLECULE_STAGING_ADMIN_TOKEN"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
- name: Set up Node
|
||||
if: needs.detect-changes.outputs.canvas == 'true'
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: canvas/package-lock.json
|
||||
|
||||
- name: Install canvas deps
|
||||
if: needs.detect-changes.outputs.canvas == 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
if: needs.detect-changes.outputs.canvas == 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run staging canvas E2E
|
||||
if: needs.detect-changes.outputs.canvas == 'true'
|
||||
run: npx playwright test --config=playwright.staging.config.ts
|
||||
|
||||
- name: Upload Playwright report on failure
|
||||
if: failure() && needs.detect-changes.outputs.canvas == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-staging
|
||||
path: canvas/playwright-report-staging/
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload screenshots on failure
|
||||
if: failure() && needs.detect-changes.outputs.canvas == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-screenshots
|
||||
path: canvas/test-results/
|
||||
retention-days: 14
|
||||
|
||||
# Safety-net teardown — fires only when Playwright's globalTeardown
|
||||
# didn't (worker crash, runner cancel). Reads the slug from
|
||||
# canvas/.playwright-staging-state.json (written by staging-setup
|
||||
# as its first action, before any CP call) and deletes only that
|
||||
# slug.
|
||||
#
|
||||
# Earlier versions of this step pattern-swept `e2e-canvas-<today>-*`
|
||||
# orgs to compensate for setup-crash-before-state-file-write. That
|
||||
# over-aggressive cleanup raced concurrent canvas-E2E runs and
|
||||
# poisoned each other's tenants — observed 2026-04-30 when three
|
||||
# real-test runs killed each other mid-test, surfacing as
|
||||
# `getaddrinfo ENOTFOUND` once CP had cleaned up the just-deleted
|
||||
# DNS record. Pattern-sweep removed; setup now writes the state
|
||||
# file before any CP work, so the slug is always recoverable.
|
||||
- name: Teardown safety net
|
||||
if: always() && needs.detect-changes.outputs.canvas == 'true'
|
||||
env:
|
||||
ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
|
||||
run: |
|
||||
set +e
|
||||
STATE_FILE=".playwright-staging-state.json"
|
||||
if [ ! -f "$STATE_FILE" ]; then
|
||||
echo "::notice::No state file at canvas/$STATE_FILE — Playwright globalTeardown handled it (or setup never ran)."
|
||||
exit 0
|
||||
fi
|
||||
slug=$(python3 -c "import json; print(json.load(open('$STATE_FILE')).get('slug',''))")
|
||||
if [ -z "$slug" ]; then
|
||||
echo "::warning::State file present but slug missing; nothing to clean up."
|
||||
exit 0
|
||||
fi
|
||||
echo "Deleting orphan tenant: $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.
|
||||
# Tempfile-routed -w + set +e/-e prevents curl-exit-code
|
||||
# pollution of the captured status (lint-curl-status-capture.yml).
|
||||
set +e
|
||||
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\"}" >/tmp/canvas-cleanup.code
|
||||
set -e
|
||||
code=$(cat /tmp/canvas-cleanup.code 2>/dev/null || 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
|
||||
@@ -1,184 +0,0 @@
|
||||
name: E2E Staging External Runtime
|
||||
|
||||
# Regression for the four/five workspaces.status=awaiting_agent transitions
|
||||
# that silently failed in production for five days before migration 046
|
||||
# extended the workspace_status enum (see
|
||||
# workspace-server/migrations/046_workspace_status_awaiting_agent.up.sql).
|
||||
#
|
||||
# Why this is its own workflow (not folded into e2e-staging-saas.yml):
|
||||
# - The full-saas harness defaults to runtime=hermes, never exercises
|
||||
# external-runtime. Adding an `external` parameter to that script
|
||||
# would force every push to staging through both lifecycles in
|
||||
# series, doubling the EC2 cold-start budget.
|
||||
# - The external lifecycle has unique timing (REMOTE_LIVENESS_STALE_AFTER
|
||||
# window, 90s default + sweep interval), which we wait through
|
||||
# deliberately. Folding it into hermes would make the long path
|
||||
# even longer.
|
||||
# - It can run in parallel with the hermes E2E since both create
|
||||
# fresh tenant orgs with distinct slug prefixes (`e2e-ext-...` vs
|
||||
# `e2e-...`).
|
||||
#
|
||||
# Triggers:
|
||||
# - Push to staging when any source affecting external runtime,
|
||||
# hibernation, or the migration set changes.
|
||||
# - PR review for the same set.
|
||||
# - Manual workflow_dispatch.
|
||||
# - Daily cron at 07:30 UTC (catches drift on quiet days; staggered
|
||||
# 30 min after e2e-staging-saas.yml's 07:00 UTC cron).
|
||||
#
|
||||
# Concurrency: serialized so two staging pushes don't fight for the
|
||||
# same EC2 quota window. cancel-in-progress=false so a half-rolled
|
||||
# tenant always finishes its teardown.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [staging, main]
|
||||
paths:
|
||||
- 'workspace-server/internal/handlers/workspace.go'
|
||||
- 'workspace-server/internal/handlers/registry.go'
|
||||
- 'workspace-server/internal/handlers/workspace_restart.go'
|
||||
- 'workspace-server/internal/registry/healthsweep.go'
|
||||
- 'workspace-server/internal/registry/liveness.go'
|
||||
- 'workspace-server/migrations/**'
|
||||
- 'workspace-server/internal/db/workspace_status_enum_drift_test.go'
|
||||
- 'tests/e2e/test_staging_external_runtime.sh'
|
||||
- '.github/workflows/e2e-staging-external.yml'
|
||||
pull_request:
|
||||
branches: [staging, main]
|
||||
paths:
|
||||
- 'workspace-server/internal/handlers/workspace.go'
|
||||
- 'workspace-server/internal/handlers/registry.go'
|
||||
- 'workspace-server/internal/handlers/workspace_restart.go'
|
||||
- 'workspace-server/internal/registry/healthsweep.go'
|
||||
- 'workspace-server/internal/registry/liveness.go'
|
||||
- 'workspace-server/migrations/**'
|
||||
- 'workspace-server/internal/db/workspace_status_enum_drift_test.go'
|
||||
- 'tests/e2e/test_staging_external_runtime.sh'
|
||||
- '.github/workflows/e2e-staging-external.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
keep_org:
|
||||
description: "Skip teardown for debugging (only via manual dispatch)"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
stale_wait_secs:
|
||||
description: "Seconds to wait for the heartbeat-staleness sweep (default 180 = 90s window + 90s buffer)"
|
||||
required: false
|
||||
default: "180"
|
||||
schedule:
|
||||
- cron: '30 7 * * *'
|
||||
|
||||
concurrency:
|
||||
group: e2e-staging-external
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e-staging-external:
|
||||
name: E2E Staging External Runtime
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
|
||||
env:
|
||||
MOLECULE_CP_URL: https://staging-api.moleculesai.app
|
||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
|
||||
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org && '1' || '0' }}
|
||||
E2E_STALE_WAIT_SECS: ${{ github.event.inputs.stale_wait_secs || '180' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify admin token present
|
||||
run: |
|
||||
if [ -z "$MOLECULE_ADMIN_TOKEN" ]; then
|
||||
# Schedule + push triggers must hard-fail when the token is
|
||||
# missing — silent skip would mask infra rot. Manual dispatch
|
||||
# gets the same hard-fail; an operator running this on a fork
|
||||
# without secrets configured needs to know up-front.
|
||||
echo "::error::MOLECULE_STAGING_ADMIN_TOKEN secret not set (Railway staging CP_ADMIN_API_TOKEN)"
|
||||
exit 2
|
||||
fi
|
||||
echo "Admin token present ✓"
|
||||
|
||||
- name: CP staging health preflight
|
||||
run: |
|
||||
code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 "$MOLECULE_CP_URL/health")
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "::error::Staging CP unhealthy (got HTTP $code). Skipping — not a workspace bug."
|
||||
exit 1
|
||||
fi
|
||||
echo "Staging CP healthy ✓"
|
||||
|
||||
- name: Run external-runtime E2E
|
||||
id: e2e
|
||||
run: bash tests/e2e/test_staging_external_runtime.sh
|
||||
|
||||
# Mirror the e2e-staging-saas.yml safety net: if the runner is
|
||||
# cancelled (e.g. concurrent staging push), the test script's
|
||||
# EXIT trap may not fire, so we sweep e2e-ext-* slugs scoped to
|
||||
# *this* run id.
|
||||
- name: Teardown safety net (runs on cancel/failure)
|
||||
if: always()
|
||||
env:
|
||||
ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
|
||||
run: |
|
||||
set +e
|
||||
orgs=$(curl -sS "$MOLECULE_CP_URL/cp/admin/orgs" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null \
|
||||
| python3 -c "
|
||||
import json, sys, os, datetime
|
||||
run_id = os.environ.get('GITHUB_RUN_ID', '')
|
||||
d = json.load(sys.stdin)
|
||||
# Scope STRICTLY to this run id (e2e-ext-YYYYMMDD-<runid>-...)
|
||||
# so concurrent runs and unrelated dev probes are not touched.
|
||||
# Sweep today AND yesterday so a midnight-crossing run still
|
||||
# cleans up its own slug.
|
||||
today = datetime.date.today()
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
dates = (today.strftime('%Y%m%d'), yesterday.strftime('%Y%m%d'))
|
||||
if not run_id:
|
||||
# Without a run id we cannot scope safely; bail rather
|
||||
# than risk deleting unrelated tenants.
|
||||
sys.exit(0)
|
||||
prefixes = tuple(f'e2e-ext-{d}-{run_id}-' for d in dates)
|
||||
for o in d.get('orgs', []):
|
||||
s = o.get('slug', '')
|
||||
if s.startswith(prefixes) and o.get('status') != 'purged':
|
||||
print(s)
|
||||
" 2>/dev/null)
|
||||
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
|
||||
# Tempfile-routed -w + set +e/-e prevents curl-exit-code
|
||||
# pollution of the captured status (lint-curl-status-capture.yml).
|
||||
set +e
|
||||
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\"}" >/tmp/external-cleanup.code
|
||||
set -e
|
||||
code=$(cat /tmp/external-cleanup.code 2>/dev/null || 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
|
||||
@@ -1,247 +0,0 @@
|
||||
name: E2E Staging SaaS (full lifecycle)
|
||||
|
||||
# Dedicated workflow that provisions a fresh staging org per run, exercises
|
||||
# the full workspace lifecycle (register → heartbeat → A2A → delegation →
|
||||
# HMA memory → activity → peers), then tears down and asserts leak-free.
|
||||
#
|
||||
# Why a separate workflow (not folded into ci.yml):
|
||||
# - The run takes ~25-35 min (EC2 boot + cloudflared DNS + provision sweeps +
|
||||
# agent bootstrap), way too slow for every PR.
|
||||
# - Needs its own concurrency group so two pushes don't fight over the
|
||||
# same staging org slug prefix.
|
||||
# - Has its own required secrets (session cookie, admin token) that most
|
||||
# PRs don't need to read.
|
||||
#
|
||||
# Triggers:
|
||||
# - Push to main (regression guard)
|
||||
# - workflow_dispatch (manual re-run from UI)
|
||||
# - Nightly cron (catches drift even when no pushes land)
|
||||
# - Changes to any provisioning-critical file under PR review (opt-in
|
||||
# via the same paths watcher that e2e-api.yml uses)
|
||||
|
||||
on:
|
||||
# Fire on staging push too — previously this only ran on main, which
|
||||
# meant the most thorough end-to-end test caught regressions AFTER
|
||||
# they shipped to staging (and then to the auto-promote PR). Running
|
||||
# on staging push catches them BEFORE the staging→main promotion
|
||||
# opens, so a green canary into auto-promote is more meaningful.
|
||||
push:
|
||||
branches: [staging, main]
|
||||
paths:
|
||||
- 'workspace-server/internal/handlers/registry.go'
|
||||
- 'workspace-server/internal/handlers/workspace_provision.go'
|
||||
- 'workspace-server/internal/handlers/a2a_proxy.go'
|
||||
- 'workspace-server/internal/middleware/**'
|
||||
- 'workspace-server/internal/provisioner/**'
|
||||
- 'tests/e2e/test_staging_full_saas.sh'
|
||||
- '.github/workflows/e2e-staging-saas.yml'
|
||||
pull_request:
|
||||
branches: [staging, main]
|
||||
paths:
|
||||
- 'workspace-server/internal/handlers/registry.go'
|
||||
- 'workspace-server/internal/handlers/workspace_provision.go'
|
||||
- 'workspace-server/internal/handlers/a2a_proxy.go'
|
||||
- 'workspace-server/internal/middleware/**'
|
||||
- 'workspace-server/internal/provisioner/**'
|
||||
- 'tests/e2e/test_staging_full_saas.sh'
|
||||
- '.github/workflows/e2e-staging-saas.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
runtime:
|
||||
description: "Runtime to test (claude-code [default, MiniMax] | hermes [OpenAI] | langgraph [OpenAI])"
|
||||
required: false
|
||||
default: "claude-code"
|
||||
keep_org:
|
||||
description: "Skip teardown for debugging (only use via manual dispatch!)"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
schedule:
|
||||
# 07:00 UTC every day — catches AMI drift, WorkOS cert rotation,
|
||||
# Cloudflare API regressions, etc. even on quiet days.
|
||||
- cron: '0 7 * * *'
|
||||
|
||||
# Serialize: staging has a finite per-hour org creation quota. Two pushes
|
||||
# landing in quick succession should queue, not race. `cancel-in-progress:
|
||||
# false` mirrors e2e-api.yml — GitHub would otherwise cancel the running
|
||||
# teardown step and leave orphan EC2s.
|
||||
concurrency:
|
||||
group: e2e-staging-saas
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
e2e-staging-saas:
|
||||
name: E2E Staging SaaS
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
MOLECULE_CP_URL: https://staging-api.moleculesai.app
|
||||
# Single admin-bearer secret drives provision + tenant-token
|
||||
# retrieval + teardown. Configure in
|
||||
# Settings → Secrets and variables → Actions → Repository secrets.
|
||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
|
||||
# MiniMax is the PRIMARY LLM auth path post-2026-05-04. Switched
|
||||
# from hermes+OpenAI default after #2578 (the staging OpenAI key
|
||||
# account went over quota and stayed dead for 36+ hours, taking
|
||||
# the full-lifecycle E2E red on every provisioning-critical push).
|
||||
# claude-code template's `minimax` provider routes
|
||||
# ANTHROPIC_BASE_URL to api.minimax.io/anthropic and reads
|
||||
# MINIMAX_API_KEY at boot — separate billing account so an
|
||||
# OpenAI quota collapse no longer wedges the gate. Mirrors the
|
||||
# canary-staging.yml + continuous-synth-e2e.yml migrations.
|
||||
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
|
||||
# Direct-Anthropic alternative for operators who don't want to
|
||||
# set up a MiniMax account (priority below MiniMax — first
|
||||
# non-empty wins in test_staging_full_saas.sh's secrets-injection
|
||||
# block). See #2578 PR comment for the rationale.
|
||||
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
|
||||
# OpenAI fallback — kept wired so an operator-dispatched run with
|
||||
# E2E_RUNTIME=hermes or =langgraph via workflow_dispatch can still
|
||||
# exercise the OpenAI path.
|
||||
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_KEY }}
|
||||
E2E_RUNTIME: ${{ github.event.inputs.runtime || 'claude-code' }}
|
||||
# Pin the model when running on the default claude-code path —
|
||||
# the per-runtime default ("sonnet") routes to direct Anthropic
|
||||
# and defeats the cost saving. Operators can override via the
|
||||
# workflow_dispatch flow (no input wired here yet — runtime
|
||||
# override is enough for ad-hoc).
|
||||
E2E_MODEL_SLUG: ${{ github.event.inputs.runtime == 'hermes' && 'openai/gpt-4o' || github.event.inputs.runtime == 'langgraph' && 'openai:gpt-4o' || 'MiniMax-M2.7-highspeed' }}
|
||||
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org && '1' || '0' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify admin token present
|
||||
run: |
|
||||
if [ -z "$MOLECULE_ADMIN_TOKEN" ]; then
|
||||
echo "::error::MOLECULE_STAGING_ADMIN_TOKEN secret not set (Railway staging CP_ADMIN_API_TOKEN)"
|
||||
exit 2
|
||||
fi
|
||||
echo "Admin token present ✓"
|
||||
|
||||
- name: Verify LLM key present
|
||||
run: |
|
||||
# Per-runtime key check — claude-code uses MiniMax; hermes /
|
||||
# langgraph (operator-dispatched only) use OpenAI. Hard-fail
|
||||
# rather than soft-skip per #2578's lesson — empty key
|
||||
# silently falls through to the wrong SECRETS_JSON branch and
|
||||
# produces a confusing auth error 5 min later instead of the
|
||||
# clean "secret missing" message at the top.
|
||||
case "${E2E_RUNTIME}" in
|
||||
claude-code)
|
||||
# Either MiniMax OR direct-Anthropic works — first
|
||||
# non-empty wins in the test script's secrets-injection
|
||||
# priority chain.
|
||||
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
|
||||
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY"
|
||||
required_secret_value="${E2E_MINIMAX_API_KEY}"
|
||||
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
|
||||
required_secret_name="MOLECULE_STAGING_ANTHROPIC_API_KEY"
|
||||
required_secret_value="${E2E_ANTHROPIC_API_KEY}"
|
||||
else
|
||||
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY or MOLECULE_STAGING_ANTHROPIC_API_KEY"
|
||||
required_secret_value=""
|
||||
fi
|
||||
;;
|
||||
langgraph|hermes)
|
||||
required_secret_name="MOLECULE_STAGING_OPENAI_KEY"
|
||||
required_secret_value="${E2E_OPENAI_API_KEY:-}"
|
||||
;;
|
||||
*)
|
||||
echo "::warning::Unknown E2E_RUNTIME='${E2E_RUNTIME}' — skipping LLM-key check"
|
||||
required_secret_name=""
|
||||
required_secret_value="present"
|
||||
;;
|
||||
esac
|
||||
if [ -n "$required_secret_name" ] && [ -z "$required_secret_value" ]; then
|
||||
echo "::error::${required_secret_name} secret not set for runtime=${E2E_RUNTIME} — workspaces will fail at boot with 'No provider API key found'"
|
||||
exit 2
|
||||
fi
|
||||
echo "LLM key present ✓ (runtime=${E2E_RUNTIME}, key=${required_secret_name}, len=${#required_secret_value})"
|
||||
|
||||
- name: CP staging health preflight
|
||||
run: |
|
||||
code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 "$MOLECULE_CP_URL/health")
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "::error::Staging CP unhealthy (got HTTP $code). Skipping — not a workspace bug."
|
||||
exit 1
|
||||
fi
|
||||
echo "Staging CP healthy ✓"
|
||||
|
||||
- name: Run full-lifecycle E2E
|
||||
id: e2e
|
||||
run: bash tests/e2e/test_staging_full_saas.sh
|
||||
|
||||
# Belt-and-braces teardown: the test script itself installs a trap
|
||||
# for EXIT/INT/TERM, but if the GH runner itself is cancelled (e.g.
|
||||
# someone pushes a new commit and workflow concurrency is set to
|
||||
# cancel), the trap may not fire. This `always()` step runs even on
|
||||
# cancellation and attempts the delete a second time. The admin
|
||||
# DELETE endpoint is idempotent so double-invoking is safe.
|
||||
- name: Teardown safety net (runs on cancel/failure)
|
||||
if: always()
|
||||
env:
|
||||
ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
|
||||
run: |
|
||||
# Best-effort: find any e2e-YYYYMMDD-* orgs matching this run and
|
||||
# nuke them. Catches the case where the script died before
|
||||
# exporting its slug.
|
||||
set +e
|
||||
orgs=$(curl -sS "$MOLECULE_CP_URL/cp/admin/orgs" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null \
|
||||
| python3 -c "
|
||||
import json, sys, os, datetime
|
||||
run_id = os.environ.get('GITHUB_RUN_ID', '')
|
||||
d = json.load(sys.stdin)
|
||||
# ONLY sweep slugs from *this* CI run. Previously the filter was
|
||||
# f'e2e-{today}-' which stomped on parallel CI runs AND any manual
|
||||
# E2E probes a dev was running against staging (incident 2026-04-21
|
||||
# 15:02Z: this workflow's safety net deleted an unrelated manual
|
||||
# run's tenant 1s after it hit 'running').
|
||||
# Sweep both today AND yesterday's UTC dates so a run that crosses
|
||||
# midnight still matches its own slug — see the 2026-04-26→27
|
||||
# canvas-safety-net incident for the same bug class.
|
||||
today = datetime.date.today()
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
dates = (today.strftime('%Y%m%d'), yesterday.strftime('%Y%m%d'))
|
||||
if run_id:
|
||||
prefixes = tuple(f'e2e-{d}-{run_id}-' for d in dates)
|
||||
else:
|
||||
prefixes = tuple(f'e2e-{d}-' for d in dates)
|
||||
candidates = [o['slug'] for o in d.get('orgs', [])
|
||||
if any(o.get('slug','').startswith(p) for p in prefixes)
|
||||
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"
|
||||
# Tempfile-routed -w + set +e/-e prevents curl-exit-code
|
||||
# pollution of the captured status (lint-curl-status-capture.yml).
|
||||
set +e
|
||||
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\"}" >/tmp/saas-cleanup.code
|
||||
set -e
|
||||
code=$(cat /tmp/saas-cleanup.code 2>/dev/null || 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
|
||||
@@ -1,171 +0,0 @@
|
||||
name: E2E Staging Sanity (leak-detection self-check)
|
||||
|
||||
# Periodic assertion that the teardown safety nets in e2e-staging-saas
|
||||
# and canary-staging actually work. Runs the E2E harness with
|
||||
# E2E_INTENTIONAL_FAILURE=1, which poisons the tenant admin token after
|
||||
# the org is provisioned. The workspace-provision step then fails, the
|
||||
# script exits non-zero, and the EXIT trap + workflow always()-step
|
||||
# must still tear down cleanly.
|
||||
#
|
||||
# A green run means:
|
||||
# - The script exited non-zero (intentional failure caught)
|
||||
# - The trap fired teardown
|
||||
# - The leak-detection poll found zero orphan orgs
|
||||
#
|
||||
# A red run means the teardown path itself is broken — act on this the
|
||||
# same way you'd act on a canary failure (the whole E2E safety net is
|
||||
# compromised until it's fixed).
|
||||
#
|
||||
# Cadence: once a week, Monday 06:00 UTC. Drift-slow, not per-PR — the
|
||||
# teardown path rarely changes, and a weekly heartbeat is enough to
|
||||
# catch silent regressions in cleanup code paths.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
# Shares the group with canary + full so they don't collide on
|
||||
# staging org-create quota.
|
||||
group: e2e-staging-sanity
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sanity:
|
||||
name: Intentional-failure teardown sanity
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
env:
|
||||
MOLECULE_CP_URL: https://staging-api.moleculesai.app
|
||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
|
||||
E2E_MODE: canary # lean lifecycle; we only need the org to exist
|
||||
E2E_RUNTIME: hermes
|
||||
E2E_RUN_ID: "sanity-${{ github.run_id }}"
|
||||
E2E_INTENTIONAL_FAILURE: "1"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify admin token present
|
||||
run: |
|
||||
if [ -z "$MOLECULE_ADMIN_TOKEN" ]; then
|
||||
echo "::error::MOLECULE_STAGING_ADMIN_TOKEN not set"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Inverted assertion: the run MUST fail. If it passes, the
|
||||
# E2E_INTENTIONAL_FAILURE path is broken (token not being
|
||||
# poisoned correctly, or the harness silently recovered).
|
||||
- name: Run harness — expecting exit !=0
|
||||
id: harness
|
||||
run: |
|
||||
set +e
|
||||
bash tests/e2e/test_staging_full_saas.sh
|
||||
rc=$?
|
||||
echo "harness_rc=$rc" >> "$GITHUB_OUTPUT"
|
||||
# The only acceptable outcomes:
|
||||
# 1 — harness failed mid-run, teardown ran, leak-check passed
|
||||
# (exit 4 means teardown left a leak — that's the real bug
|
||||
# this sanity check exists to catch)
|
||||
if [ "$rc" = "1" ]; then
|
||||
echo "✓ Harness failed as expected (rc=1); teardown trap ran, leak-check passed"
|
||||
exit 0
|
||||
elif [ "$rc" = "0" ]; then
|
||||
echo "::error::Harness succeeded under E2E_INTENTIONAL_FAILURE=1 — the poisoning path is broken"
|
||||
exit 1
|
||||
elif [ "$rc" = "4" ]; then
|
||||
echo "::error::LEAK DETECTED (rc=4) — teardown failed to clean up the org. Safety net broken."
|
||||
exit 4
|
||||
else
|
||||
echo "::error::Unexpected rc=$rc — neither clean-failure nor leak. Investigate harness."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Open issue if safety net is broken
|
||||
if: failure()
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const title = "🚨 E2E teardown safety net broken";
|
||||
const runURL = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
const body =
|
||||
`The weekly sanity run (E2E_INTENTIONAL_FAILURE=1) did not exit ` +
|
||||
`as expected. This means one of:\n` +
|
||||
` - poisoning didn't actually cause failure (test harness regression), OR\n` +
|
||||
` - teardown left an orphan org (leak detection caught a real bug)\n\n` +
|
||||
`Run: ${runURL}\n\n` +
|
||||
`This is higher priority than a canary failure — the whole ` +
|
||||
`E2E safety net can't be trusted until this is resolved.`;
|
||||
|
||||
const { data: existing } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
state: 'open', labels: 'e2e-safety-net',
|
||||
});
|
||||
const match = existing.find(i => i.title === title);
|
||||
if (match) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: match.number,
|
||||
body: `Still broken. ${runURL}`,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
title, body,
|
||||
labels: ['e2e-safety-net', 'bug', 'priority-high'],
|
||||
});
|
||||
}
|
||||
|
||||
# Belt-and-braces: if teardown left anything behind, nuke it here
|
||||
# so we don't bleed staging quota. Different label from the
|
||||
# always()-steps in the other workflows so sanity-only orgs get
|
||||
# cleaned up by sanity runs.
|
||||
- name: Teardown safety net
|
||||
if: always()
|
||||
env:
|
||||
ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
|
||||
run: |
|
||||
set +e
|
||||
orgs=$(curl -sS "$MOLECULE_CP_URL/cp/admin/orgs" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null \
|
||||
| python3 -c "
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
today = __import__('datetime').date.today().strftime('%Y%m%d')
|
||||
candidates = [o['slug'] for o in d.get('orgs', [])
|
||||
if o.get('slug','').startswith(f'e2e-canary-{today}-sanity-')
|
||||
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
|
||||
# Tempfile-routed -w + set +e/-e prevents curl-exit-code
|
||||
# pollution of the captured status (lint-curl-status-capture.yml).
|
||||
set +e
|
||||
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\"}" >/tmp/sanity-cleanup.code
|
||||
set -e
|
||||
code=$(cat /tmp/sanity-cleanup.code 2>/dev/null || 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
|
||||
@@ -1,171 +0,0 @@
|
||||
name: Handlers Postgres Integration
|
||||
|
||||
# Real-Postgres integration tests for workspace-server/internal/handlers/.
|
||||
# Triggered on every PR/push that touches the handlers package.
|
||||
#
|
||||
# Why this workflow exists
|
||||
# ------------------------
|
||||
# Strict-sqlmock unit tests pin which SQL statements fire — they're fast
|
||||
# and let us iterate without a DB. But sqlmock CANNOT detect bugs that
|
||||
# depend on the row state AFTER the SQL runs. The result_preview-lost
|
||||
# bug shipped to staging in PR #2854 because every unit test was
|
||||
# satisfied with "an UPDATE statement fired" — none verified the row's
|
||||
# preview field actually landed. The local-postgres E2E that retrofit
|
||||
# self-review caught it took 2 minutes to set up and would have caught
|
||||
# the bug at PR-time.
|
||||
#
|
||||
# This job spins a Postgres service container, applies the migration,
|
||||
# and runs `go test -tags=integration` against a live DB. Required
|
||||
# check on staging branch protection — backend handler PRs cannot
|
||||
# merge without a real-DB regression gate.
|
||||
#
|
||||
# Cost: ~30s job (postgres pull from GH cache + go build + 4 tests).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: handlers-pg-integ-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
name: detect-changes
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
handlers: ${{ steps.filter.outputs.handlers }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
handlers:
|
||||
- 'workspace-server/internal/handlers/**'
|
||||
- 'workspace-server/internal/wsauth/**'
|
||||
- 'workspace-server/migrations/**'
|
||||
- '.github/workflows/handlers-postgres-integration.yml'
|
||||
|
||||
# Single-job-with-per-step-if pattern: always runs to satisfy the
|
||||
# required-check name on branch protection; real work gates on the
|
||||
# paths filter. See ci.yml's Platform (Go) for the same shape.
|
||||
integration:
|
||||
name: Handlers Postgres Integration
|
||||
needs: detect-changes
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
env:
|
||||
POSTGRES_PASSWORD: test
|
||||
POSTGRES_DB: molecule
|
||||
ports:
|
||||
- 5432:5432
|
||||
# GHA spins this with --health-cmd built in for postgres images.
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace-server
|
||||
steps:
|
||||
- if: needs.detect-changes.outputs.handlers != 'true'
|
||||
working-directory: .
|
||||
run: echo "No handlers/migrations changes — skipping; this job always runs to satisfy the required-check name."
|
||||
|
||||
- if: needs.detect-changes.outputs.handlers == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- if: needs.detect-changes.outputs.handlers == 'true'
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
|
||||
- if: needs.detect-changes.outputs.handlers == 'true'
|
||||
name: Apply migrations to Postgres service
|
||||
env:
|
||||
PGPASSWORD: test
|
||||
run: |
|
||||
# Wait for postgres to actually accept connections (the
|
||||
# GHA --health-cmd is best-effort but psql can still race).
|
||||
for i in {1..15}; do
|
||||
if pg_isready -h localhost -p 5432 -U postgres -q; then break; fi
|
||||
echo "waiting for postgres..."; sleep 2
|
||||
done
|
||||
|
||||
# Apply every .up.sql in lexicographic order with
|
||||
# ON_ERROR_STOP=0 — failing migrations are SKIPPED rather than
|
||||
# blocking the suite. This handles the current schema state
|
||||
# where a few historical migrations (e.g. 017_memories_fts_*)
|
||||
# depend on tables that were later renamed/dropped and so
|
||||
# cannot replay from scratch. The migrations that DO succeed
|
||||
# land their tables, which is sufficient for the integration
|
||||
# tests in handlers/.
|
||||
#
|
||||
# Why not maintain a curated allowlist: every new migration
|
||||
# touching a handlers/-tested table would have to update this
|
||||
# workflow. With apply-all-or-skip, a future migration that
|
||||
# adds a column to delegations runs automatically (its base
|
||||
# table 049_delegations.up.sql already succeeded above it in
|
||||
# the order). Operators only need to revisit this if the
|
||||
# migration chain becomes legitimately replayable end-to-end.
|
||||
#
|
||||
# Per-migration result is logged so a failed migration that
|
||||
# SHOULD have been replayable surfaces in the CI log instead
|
||||
# of silently failing.
|
||||
# Apply both *.sql (legacy, lives next to its module) and
|
||||
# *.up.sql (newer up/down convention) in a single
|
||||
# lexicographically-sorted pass. Excluding *.down.sql so the
|
||||
# newest-naming-convention pairs don't undo themselves mid-run.
|
||||
# Pre-#149-followup this loop only globbed *.up.sql, which
|
||||
# silently skipped 001_workspaces.sql + 009_activity_logs.sql
|
||||
# — fine while no integration test depended on those tables,
|
||||
# not fine once a cross-table atomicity test came in.
|
||||
set +e
|
||||
for migration in $(ls migrations/*.sql 2>/dev/null | grep -v '\.down\.sql$' | sort); do
|
||||
if psql -h localhost -U postgres -d molecule -v ON_ERROR_STOP=1 \
|
||||
-f "$migration" >/dev/null 2>&1; then
|
||||
echo "✓ $(basename "$migration")"
|
||||
else
|
||||
echo "⊘ $(basename "$migration") (skipped — see comment in workflow)"
|
||||
fi
|
||||
done
|
||||
set -e
|
||||
|
||||
# Sanity: the delegations + workspaces + activity_logs tables
|
||||
# MUST exist for the integration tests to be meaningful. Hard-
|
||||
# fail if any didn't land — that would be a real regression we
|
||||
# want loud.
|
||||
for tbl in delegations workspaces activity_logs pending_uploads; do
|
||||
if ! psql -h localhost -U postgres -d molecule -tA \
|
||||
-c "SELECT 1 FROM information_schema.tables WHERE table_name = '$tbl'" \
|
||||
| grep -q 1; then
|
||||
echo "::error::$tbl table missing after migration replay — handler integration tests would be meaningless"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ $tbl table present"
|
||||
done
|
||||
|
||||
- if: needs.detect-changes.outputs.handlers == 'true'
|
||||
name: Run integration tests
|
||||
env:
|
||||
INTEGRATION_DB_URL: postgres://postgres:test@localhost:5432/molecule?sslmode=disable
|
||||
run: |
|
||||
go test -tags=integration -timeout 5m -v ./internal/handlers/ -run "^TestIntegration_"
|
||||
|
||||
- if: needs.detect-changes.outputs.handlers == 'true' && failure()
|
||||
name: Diagnostic dump on failure
|
||||
env:
|
||||
PGPASSWORD: test
|
||||
run: |
|
||||
echo "::group::delegations table state"
|
||||
psql -h localhost -U postgres -d molecule -c "SELECT * FROM delegations LIMIT 50;" || true
|
||||
echo "::endgroup::"
|
||||
@@ -1,170 +0,0 @@
|
||||
name: Harness Replays
|
||||
|
||||
# Boots tests/harness (production-shape compose topology with TenantGuard,
|
||||
# /cp/* proxy, canvas proxy, real production Dockerfile.tenant) and runs
|
||||
# every replay under tests/harness/replays/. Fails the PR if any replay
|
||||
# fails.
|
||||
#
|
||||
# Why this exists: 2026-04-30 we shipped #2398 which added /buildinfo as
|
||||
# a public route in router.go but forgot to add it to TenantGuard's
|
||||
# allowlist. The handler-level test in buildinfo_test.go constructed a
|
||||
# minimal gin engine without TenantGuard — green. The harness's
|
||||
# buildinfo-stale-image.sh replay would have caught it (cf-proxy doesn't
|
||||
# inject X-Molecule-Org-Id, so the curl path is identical to production's
|
||||
# redeploy verifier), but no one ran the harness pre-merge. The bug
|
||||
# shipped; the redeploy verifier silently soft-warned every tenant as
|
||||
# "unreachable" for ~1 day before being noticed.
|
||||
#
|
||||
# This gate makes "did you actually run the harness?" a CI invariant
|
||||
# instead of a memory-discipline thing.
|
||||
#
|
||||
# Trigger model — match e2e-api.yml: always FIRES on push/pull_request
|
||||
# to staging+main, real work is gated per-step on detect-changes output.
|
||||
# One job → one check run → branch-protection-clean (the SKIPPED-in-set
|
||||
# trap from PR #2264 is documented in e2e-api.yml's e2e-api job comment).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- 'workspace-server/**'
|
||||
- 'canvas/**'
|
||||
- 'tests/harness/**'
|
||||
- '.github/workflows/harness-replays.yml'
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- 'workspace-server/**'
|
||||
- 'canvas/**'
|
||||
- 'tests/harness/**'
|
||||
- '.github/workflows/harness-replays.yml'
|
||||
workflow_dispatch:
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
concurrency:
|
||||
# Per-SHA grouping. Per-ref kept hitting the auto-promote-staging
|
||||
# cancellation deadlock — see e2e-api.yml's concurrency block for
|
||||
# the 2026-04-28 incident that codified this pattern.
|
||||
group: harness-replays-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
run: ${{ steps.decide.outputs.run }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
run:
|
||||
- 'workspace-server/**'
|
||||
- 'canvas/**'
|
||||
- 'tests/harness/**'
|
||||
- '.github/workflows/harness-replays.yml'
|
||||
- id: decide
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=${{ steps.filter.outputs.run }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# ONE job that always runs. Real work is gated per-step on
|
||||
# detect-changes.outputs.run so an unrelated PR (e.g. doc-only
|
||||
# change to molecule-controlplane wired here later) emits the
|
||||
# required check without spending CI cycles. Single-job pattern
|
||||
# matches e2e-api.yml — see that workflow's comment for why a
|
||||
# job-level `if: false` would block branch protection via the
|
||||
# SKIPPED-in-set bug.
|
||||
harness-replays:
|
||||
needs: detect-changes
|
||||
name: Harness Replays
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: No-op pass (paths filter excluded this commit)
|
||||
if: needs.detect-changes.outputs.run != 'true'
|
||||
run: |
|
||||
echo "No workspace-server / canvas / tests/harness / workflow changes — Harness Replays gate satisfied without running."
|
||||
echo "::notice::Harness Replays no-op pass (paths filter excluded this commit)."
|
||||
|
||||
- if: needs.detect-changes.outputs.run == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Checkout sibling plugin repo
|
||||
# Dockerfile.tenant copies molecule-ai-plugin-github-app-auth/
|
||||
# at the build-context root (see workspace-server/Dockerfile.tenant
|
||||
# line 19). PLUGIN_REPO_PAT pattern matches publish-workspace-server-image.yml.
|
||||
if: needs.detect-changes.outputs.run == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: molecule-ai/molecule-ai-plugin-github-app-auth
|
||||
path: molecule-ai-plugin-github-app-auth
|
||||
token: ${{ secrets.PLUGIN_REPO_PAT || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install Python deps for replays
|
||||
# peer-discovery-404 (and future replays) eval Python against the
|
||||
# running tenant — importing workspace/a2a_client.py pulls in
|
||||
# httpx. tests/harness/requirements.txt holds just the HTTP-client
|
||||
# surface to keep CI install fast (~3s) vs the full
|
||||
# workspace/requirements.txt (~30s).
|
||||
if: needs.detect-changes.outputs.run == 'true'
|
||||
run: pip install -r tests/harness/requirements.txt
|
||||
|
||||
- name: Run all replays against the harness
|
||||
# run-all-replays.sh: boot via up.sh → seed via seed.sh → run
|
||||
# every replays/*.sh → tear down via down.sh on EXIT (trap).
|
||||
# Non-zero exit on any replay failure.
|
||||
#
|
||||
# KEEP_UP=1: without this, the script's trap-on-EXIT tears
|
||||
# down containers immediately on failure, leaving the dump
|
||||
# step below with nothing to dump (verified on PR #2410's
|
||||
# first run — tenant became unhealthy, trap fired, dump
|
||||
# step saw empty containers). Keeping them up lets the
|
||||
# failure path collect tenant/cp-stub/cf-proxy logs. The
|
||||
# always-run "Force teardown" step does the actual cleanup.
|
||||
if: needs.detect-changes.outputs.run == 'true'
|
||||
working-directory: tests/harness
|
||||
env:
|
||||
KEEP_UP: "1"
|
||||
run: ./run-all-replays.sh
|
||||
|
||||
- name: Dump compose logs on failure
|
||||
# SECRETS_ENCRYPTION_KEY: docker compose validates the entire compose
|
||||
# file even for read-only `logs` calls. up.sh generates a per-run key
|
||||
# and exports it to its OWN shell — this step runs in a fresh shell
|
||||
# that wouldn't see it, so without a placeholder the validate step
|
||||
# errors before logs print (verified against PR #2492's first run:
|
||||
# "required variable SECRETS_ENCRYPTION_KEY is missing a value").
|
||||
# A placeholder is fine — we're only reading log streams, not booting.
|
||||
if: failure() && needs.detect-changes.outputs.run == 'true'
|
||||
working-directory: tests/harness
|
||||
env:
|
||||
SECRETS_ENCRYPTION_KEY: dump-logs-placeholder
|
||||
run: |
|
||||
echo "=== docker compose ps ==="
|
||||
docker compose -f compose.yml ps || true
|
||||
echo "=== tenant-alpha logs ==="
|
||||
docker compose -f compose.yml logs tenant-alpha || true
|
||||
echo "=== tenant-beta logs ==="
|
||||
docker compose -f compose.yml logs tenant-beta || true
|
||||
echo "=== cp-stub logs ==="
|
||||
docker compose -f compose.yml logs cp-stub || true
|
||||
echo "=== cf-proxy logs ==="
|
||||
docker compose -f compose.yml logs cf-proxy || true
|
||||
echo "=== postgres-alpha logs (last 100) ==="
|
||||
docker compose -f compose.yml logs --tail 100 postgres-alpha || true
|
||||
echo "=== postgres-beta logs (last 100) ==="
|
||||
docker compose -f compose.yml logs --tail 100 postgres-beta || true
|
||||
|
||||
- name: Force teardown
|
||||
# We pass KEEP_UP=1 to run-all-replays.sh so the dump step
|
||||
# above sees real containers — that means we own teardown
|
||||
# explicitly here. Always run.
|
||||
if: always() && needs.detect-changes.outputs.run == 'true'
|
||||
working-directory: tests/harness
|
||||
run: ./down.sh || true
|
||||
@@ -1,94 +0,0 @@
|
||||
name: Lint curl status-code capture
|
||||
|
||||
# Pins the workflow-bash anti-pattern that produced "HTTP 000000" on the
|
||||
# 2026-05-04 redeploy-tenants-on-main run for sha 2b862f6:
|
||||
#
|
||||
# HTTP_CODE=$(curl ... -w '%{http_code}' ... || echo "000")
|
||||
#
|
||||
# When curl exits non-zero (connection reset → 56, --fail-with-body 4xx/5xx
|
||||
# → 22), the `-w '%{http_code}'` already wrote a status to stdout — usually
|
||||
# "000" for connection failures or the actual code for HTTP errors. The
|
||||
# `|| echo "000"` then fires AND appends ANOTHER "000" to the captured
|
||||
# stdout, producing values like "000000" or "409000" that fail string
|
||||
# comparisons against "200" while looking superficially right.
|
||||
#
|
||||
# Same class of bug the synth-E2E §7c gate hit twice (PRs #2779/#2783 +
|
||||
# #2797). Memory: feedback_curl_status_capture_pollution.md.
|
||||
#
|
||||
# Fix shape (route -w into a tempfile so curl's exit code can't pollute):
|
||||
#
|
||||
# set +e
|
||||
# curl ... -w '%{http_code}' >code.txt 2>/dev/null
|
||||
# set -e
|
||||
# HTTP_CODE=$(cat code.txt 2>/dev/null)
|
||||
# [ -z "$HTTP_CODE" ] && HTTP_CODE="000"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths: ['.github/workflows/**']
|
||||
push:
|
||||
branches: [main, staging]
|
||||
paths: ['.github/workflows/**']
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: Scan workflows for curl status-capture pollution
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Find curl ... -w '%{http_code}' ... || echo "000" subshells
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# Multi-line aware: look for `$(curl ... -w '%{http_code}' ... || echo "000")`
|
||||
# subshell where the entire command-substitution wraps a curl that
|
||||
# ends with `|| echo "000"`. Must distinguish from the SAFE shape
|
||||
# `$(cat tempfile 2>/dev/null || echo "000")` — `cat` with a missing
|
||||
# tempfile produces empty stdout, no pollution.
|
||||
python3 <<'PY'
|
||||
import os, re, sys, glob
|
||||
|
||||
BAD_FILES = []
|
||||
|
||||
# Match the buggy substitution across newlines: $(curl ... -w '%{http_code}' ... || echo "000")
|
||||
# The `\\n` is the bash line-continuation that lets curl flags span lines.
|
||||
# We collapse continuation lines first, then look for the single-line bad pattern.
|
||||
PATTERN = re.compile(
|
||||
r'\$\(\s*curl\b[^)]*-w\s*[\'"]%\{http_code\}[\'"][^)]*\|\|\s*echo\s+"000"\s*\)',
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
# Self-skip: this lint workflow contains the literal anti-pattern in
|
||||
# its own docstring — that's intentional, not a bug.
|
||||
SELF = ".github/workflows/lint-curl-status-capture.yml"
|
||||
|
||||
for f in sorted(glob.glob(".github/workflows/*.yml")):
|
||||
if f == SELF:
|
||||
continue
|
||||
with open(f) as fh:
|
||||
content = fh.read()
|
||||
# Collapse bash line-continuations (\\\n + leading whitespace)
|
||||
# into a single logical line so the regex can see the full
|
||||
# curl invocation as one chunk.
|
||||
flat = re.sub(r'\\\s*\n\s*', ' ', content)
|
||||
for m in PATTERN.finditer(flat):
|
||||
BAD_FILES.append((f, m.group(0)[:120]))
|
||||
|
||||
if not BAD_FILES:
|
||||
print("✓ No curl-status-capture pollution patterns detected")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"::error::Found {len(BAD_FILES)} curl-status-capture pollution site(s):")
|
||||
for f, snippet in BAD_FILES:
|
||||
print(f"::error file={f}::Curl status-capture pollution: '|| echo \"000\"' inside a $(curl ... -w '%{{http_code}}' ...) subshell. On non-2xx or connection failure, curl's -w writes a status, then exits non-zero, then the || echo appends another '000' — producing 'HTTP 000000' or '409000' that fails comparisons silently. Fix: route -w into a tempfile so the exit code can't pollute stdout. See memory feedback_curl_status_capture_pollution.md.")
|
||||
print(f" matched: {snippet}…")
|
||||
print()
|
||||
print("Fix template:")
|
||||
print(' set +e')
|
||||
print(' curl ... -w \'%{http_code}\' >code.txt 2>/dev/null')
|
||||
print(' set -e')
|
||||
print(' HTTP_CODE=$(cat code.txt 2>/dev/null)')
|
||||
print(' [ -z "$HTTP_CODE" ] && HTTP_CODE="000"')
|
||||
sys.exit(1)
|
||||
PY
|
||||
@@ -1,22 +0,0 @@
|
||||
name: pr-guards
|
||||
|
||||
# Thin caller that delegates to the molecule-ci reusable guard. Today
|
||||
# the guard is just "disable auto-merge when a new commit is pushed
|
||||
# after auto-merge was enabled" — added 2026-04-27 after PR #2174
|
||||
# auto-merged with only its first commit because the second commit
|
||||
# was pushed after the merge queue had locked the PR's SHA.
|
||||
#
|
||||
# When more PR-time guards land in molecule-ci, add them here as
|
||||
# additional jobs that share the same pull_request:synchronize
|
||||
# trigger.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
disable-auto-merge-on-push:
|
||||
uses: molecule-ai/molecule-ci/.github/workflows/disable-auto-merge-on-push.yml@main
|
||||
@@ -32,9 +32,24 @@ env:
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
runs-on: ubuntu-latest
|
||||
# Self-hosted mac mini — GitHub-hosted minutes are currently quota-
|
||||
# blocked. mac mini already has crane available via homebrew.
|
||||
runs-on: [self-hosted, macos, arm64]
|
||||
steps:
|
||||
- uses: imjasonh/setup-crane@6da1ae018866400525525ce74ff892880c099987 # v0.5
|
||||
- name: Ensure crane installed
|
||||
# HOMEBREW_NO_INSTALL_CLEANUP + HOMEBREW_NO_AUTO_UPDATE stop
|
||||
# brew from touching unrelated symlinks in /opt/homebrew owned
|
||||
# by other users on this shared runner — cleanup was exiting
|
||||
# non-zero even though crane itself installed successfully.
|
||||
env:
|
||||
HOMEBREW_NO_INSTALL_CLEANUP: "1"
|
||||
HOMEBREW_NO_AUTO_UPDATE: "1"
|
||||
HOMEBREW_NO_ENV_HINTS: "1"
|
||||
run: |
|
||||
if ! command -v crane >/dev/null 2>&1; then
|
||||
brew install crane
|
||||
fi
|
||||
crane version
|
||||
|
||||
- name: GHCR login
|
||||
run: |
|
||||
|
||||
@@ -39,20 +39,56 @@ env:
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & push canvas image
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, macos, arm64]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
- name: Configure GHCR auth (write auths map; do NOT call docker login)
|
||||
# `docker login` on macOS unconditionally writes credentials to the
|
||||
# osxkeychain credential helper, even when DOCKER_CONFIG/config.json
|
||||
# declares `credsStore: ""` and even when invoked with `--config`.
|
||||
# Verified locally 2026-04-16 — after a successful login, Docker
|
||||
# rewrites the same config file to:
|
||||
# { "auths": { "ghcr.io": {} }, "credsStore": "osxkeychain" }
|
||||
# i.e. the auth lives in the Keychain, not the config file. The
|
||||
# Mac mini runner is a launchd user agent with a locked Keychain,
|
||||
# so storage fails with `User interaction is not allowed (-25308)`.
|
||||
#
|
||||
# Six prior PRs (#273, #319, #322, #341, #484, #486) all kept calling
|
||||
# `docker login` and tried to coerce credsStore — none worked.
|
||||
# The only reliable fix is to skip `docker login` entirely and write
|
||||
# the auth string directly. `docker/build-push-action@v6` and the
|
||||
# daemon honor the `auths` map for push without needing login.
|
||||
shell: bash
|
||||
env:
|
||||
GHCR_USER: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p "${RUNNER_TEMP}/docker-config"
|
||||
AUTH=$(printf '%s:%s' "${GHCR_USER}" "${GHCR_TOKEN}" | base64)
|
||||
umask 077
|
||||
cat > "${RUNNER_TEMP}/docker-config/config.json" <<JSON
|
||||
{ "auths": { "ghcr.io": { "auth": "${AUTH}" } } }
|
||||
JSON
|
||||
echo "DOCKER_CONFIG=${RUNNER_TEMP}/docker-config" >> "${GITHUB_ENV}"
|
||||
# Diagnostics that don't leak the token.
|
||||
echo "=== docker ==="
|
||||
command -v docker || echo "(docker not in PATH)"
|
||||
docker --version 2>&1 || true
|
||||
ls -la /usr/local/bin/docker /opt/homebrew/bin/docker 2>&1 || true
|
||||
echo "=== auths registries (no values) ==="
|
||||
grep -o '"[a-zA-Z0-9.-]*\.io"' "${RUNNER_TEMP}/docker-config/config.json" || true
|
||||
|
||||
- name: Set up QEMU
|
||||
# Apple-silicon runner building linux/amd64 images for x86 hosts.
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Compute tags
|
||||
id: tags
|
||||
@@ -85,7 +121,7 @@ jobs:
|
||||
echo "ws_url=${WS_URL}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build & push canvas image to GHCR
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./canvas
|
||||
file: ./canvas/Dockerfile
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
name: publish-runtime
|
||||
|
||||
# Publishes molecule-ai-workspace-runtime to PyPI from monorepo workspace/.
|
||||
# Monorepo workspace/ is the only source-of-truth for runtime code; this
|
||||
# workflow is the bridge from monorepo edits to the PyPI artifact that
|
||||
# the 8 workspace-template-* repos depend on.
|
||||
#
|
||||
# Triggered by:
|
||||
# - Pushing a tag matching `runtime-vX.Y.Z` (the version is derived from
|
||||
# the tag — `runtime-v0.1.6` publishes `0.1.6`).
|
||||
# - Manual workflow_dispatch with an explicit `version` input (useful for
|
||||
# dev/test releases without tagging the repo).
|
||||
# - Auto: any push to `staging` that touches `workspace/**`. The version
|
||||
# is derived by querying PyPI for the current latest and bumping the
|
||||
# patch component. This closes the human-in-loop gap that caused the
|
||||
# 2026-04-27 RuntimeCapabilities ImportError outage — adapter symbol
|
||||
# additions in workspace/adapters/base.py used to require an operator
|
||||
# to remember to publish; now the merge itself triggers the publish.
|
||||
#
|
||||
# The workflow:
|
||||
# 1. Runs scripts/build_runtime_package.py to copy workspace/ →
|
||||
# build/molecule_runtime/ with imports rewritten (`a2a_client` →
|
||||
# `molecule_runtime.a2a_client`).
|
||||
# 2. Builds wheel + sdist with `python -m build`.
|
||||
# 3. Publishes to PyPI via the PyPA Trusted Publisher action (OIDC).
|
||||
# No static API token is stored — PyPI verifies the workflow's
|
||||
# OIDC claim against the trusted-publisher config registered for
|
||||
# molecule-ai-workspace-runtime (molecule-ai/molecule-core,
|
||||
# publish-runtime.yml, environment pypi-publish).
|
||||
#
|
||||
# After publish: the 8 template repos pick up the new version on their
|
||||
# next image rebuild (their requirements.txt pin
|
||||
# `molecule-ai-workspace-runtime>=0.1.0`, so any new release is eligible).
|
||||
# To force-pull immediately, bump the pin in each template repo's
|
||||
# requirements.txt and merge — that triggers their own publish-image.yml.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "runtime-v*"
|
||||
branches:
|
||||
- staging
|
||||
paths:
|
||||
# Auto-publish when staging gets changes that affect what gets
|
||||
# published. Path filter ONLY applies to branch pushes — tag pushes
|
||||
# still fire regardless.
|
||||
#
|
||||
# workspace/** is the source-of-truth for runtime code.
|
||||
# scripts/build_runtime_package.py is the build script — changes to
|
||||
# it (e.g. a fix to the import rewriter or a manifest emit) directly
|
||||
# affect what ships in the wheel even if no workspace/ file changes.
|
||||
# The 2026-04-27 lib/ subpackage incident missed an auto-publish for
|
||||
# exactly this reason — PR #2174 only changed scripts/ and the
|
||||
# operator had to remember a manual dispatch.
|
||||
- "workspace/**"
|
||||
- "scripts/build_runtime_package.py"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to publish (e.g. 0.1.6). Required for manual dispatch."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Serialize publishes so two staging merges landing seconds apart don't
|
||||
# both compute "latest+1" and race on PyPI upload. The second one waits.
|
||||
concurrency:
|
||||
group: publish-runtime
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi-publish
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # PyPI Trusted Publisher (OIDC) — no PYPI_TOKEN needed
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
wheel_sha256: ${{ steps.wheel_hash.outputs.wheel_sha256 }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
|
||||
- name: Derive version (tag, manual input, or PyPI auto-bump)
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="${{ inputs.version }}"
|
||||
elif echo "$GITHUB_REF_NAME" | grep -q "^runtime-v"; then
|
||||
# Tag is `runtime-vX.Y.Z` — strip the prefix.
|
||||
VERSION="${GITHUB_REF_NAME#runtime-v}"
|
||||
else
|
||||
# Auto-publish from staging push. Query PyPI for the current
|
||||
# latest and bump the patch component. concurrency: group above
|
||||
# serializes parallel staging merges so we don't race on the
|
||||
# bump. If PyPI is unreachable, fail loud — better to skip a
|
||||
# publish than to overwrite an existing version.
|
||||
LATEST=$(curl -fsS --retry 3 https://pypi.org/pypi/molecule-ai-workspace-runtime/json \
|
||||
| python -c "import sys,json; print(json.load(sys.stdin)['info']['version'])")
|
||||
MAJOR=$(echo "$LATEST" | cut -d. -f1)
|
||||
MINOR=$(echo "$LATEST" | cut -d. -f2)
|
||||
PATCH=$(echo "$LATEST" | cut -d. -f3)
|
||||
VERSION="${MAJOR}.${MINOR}.$((PATCH+1))"
|
||||
echo "Auto-bumped from PyPI latest $LATEST -> $VERSION"
|
||||
fi
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(\.dev[0-9]+|rc[0-9]+|a[0-9]+|b[0-9]+|\.post[0-9]+)?$'; then
|
||||
echo "::error::version $VERSION does not match PEP 440"
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Publishing molecule-ai-workspace-runtime $VERSION"
|
||||
|
||||
- name: Install build tooling
|
||||
run: pip install build twine
|
||||
|
||||
- name: Build package from workspace/
|
||||
run: |
|
||||
python scripts/build_runtime_package.py \
|
||||
--version "${{ steps.version.outputs.version }}" \
|
||||
--out "${{ runner.temp }}/runtime-build"
|
||||
|
||||
- name: Build wheel + sdist
|
||||
working-directory: ${{ runner.temp }}/runtime-build
|
||||
run: python -m build
|
||||
|
||||
- name: Capture wheel SHA256 for cascade content-verification
|
||||
# Recorded BEFORE upload so the cascade probe can verify the
|
||||
# bytes Fastly serves under the new version's URL match what
|
||||
# we built. Closes a hole left by #2197: that probe verified
|
||||
# pip can resolve the version (catches propagation lag) but
|
||||
# not that the wheel content matches (would silently pass a
|
||||
# Fastly stale-content scenario where the new version's URL
|
||||
# serves an old wheel binary).
|
||||
id: wheel_hash
|
||||
working-directory: ${{ runner.temp }}/runtime-build
|
||||
run: |
|
||||
set -eu
|
||||
WHEEL=$(ls dist/*.whl 2>/dev/null | head -1)
|
||||
if [ -z "$WHEEL" ]; then
|
||||
echo "::error::No .whl in dist/ — `python -m build` must have failed silently"
|
||||
exit 1
|
||||
fi
|
||||
HASH=$(sha256sum "$WHEEL" | awk '{print $1}')
|
||||
echo "wheel_sha256=${HASH}" >> "$GITHUB_OUTPUT"
|
||||
echo "Local wheel SHA256 (pre-upload): ${HASH}"
|
||||
echo "Wheel filename: $(basename "$WHEEL")"
|
||||
|
||||
- name: Verify package contents (sanity)
|
||||
working-directory: ${{ runner.temp }}/runtime-build
|
||||
# Smoke logic lives in scripts/wheel_smoke.py so the same gate runs
|
||||
# at both PR-time (runtime-prbuild-compat.yml) and publish-time
|
||||
# (here). Splitting the smoke across two heredocs let them drift
|
||||
# apart historically — one script keeps them locked.
|
||||
run: |
|
||||
python -m twine check dist/*
|
||||
python -m venv /tmp/smoke
|
||||
/tmp/smoke/bin/pip install --quiet dist/*.whl
|
||||
/tmp/smoke/bin/python "$GITHUB_WORKSPACE/scripts/wheel_smoke.py"
|
||||
|
||||
- name: Publish to PyPI (Trusted Publisher / OIDC)
|
||||
# PyPI side is configured: project molecule-ai-workspace-runtime →
|
||||
# publisher molecule-ai/molecule-core, workflow publish-runtime.yml,
|
||||
# environment pypi-publish. The action mints a short-lived OIDC
|
||||
# token and exchanges it for a PyPI upload credential — no static
|
||||
# API token in this repo's secrets.
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ${{ runner.temp }}/runtime-build/dist/
|
||||
|
||||
cascade:
|
||||
# After PyPI accepts the upload, fan out a repository_dispatch to each
|
||||
# template repo so they rebuild their image against the new runtime.
|
||||
# Each template's `runtime-published.yml` receiver picks up the event,
|
||||
# pulls the new PyPI version (their requirements.txt pin is `>=`), and
|
||||
# republishes ghcr.io/molecule-ai/workspace-template-<runtime>:latest.
|
||||
#
|
||||
# Soft-fail per repo: if one template's dispatch fails (perms missing,
|
||||
# repo archived, etc.) we still try the others and surface the failures
|
||||
# in the workflow summary instead of aborting the whole cascade.
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Wait for PyPI to propagate the new version
|
||||
# PyPI accepts the upload, then takes a few seconds to make the
|
||||
# new version visible across all THREE surfaces pip touches:
|
||||
# 1. /pypi/<pkg>/<ver>/json — metadata endpoint
|
||||
# 2. /simple/<pkg>/ — pip's primary download index
|
||||
# 3. files.pythonhosted.org — CDN-fronted wheel binary
|
||||
# Each has its own cache. The previous check polled only (1)
|
||||
# and would let the cascade fire while (2) or (3) still served
|
||||
# the previous version, so downstream `pip install` resolved
|
||||
# to the old wheel. Docker layer cache then locked that stale
|
||||
# resolution in for subsequent rebuilds (the cache trap that
|
||||
# bit us five times in one night).
|
||||
#
|
||||
# Two-stage probe per poll:
|
||||
# (a) `pip install --no-cache-dir PACKAGE==VERSION` — succeeds
|
||||
# only when the version is resolvable. Catches surface (1)
|
||||
# and (2) propagation lag.
|
||||
# (b) `pip download` of the same wheel + SHA256 compare against
|
||||
# the just-built dist's hash. Catches surface (3) lag AND
|
||||
# Fastly serving stale content under the new version's URL
|
||||
# (a separate Fastly-corruption mode that pip-install alone
|
||||
# can't see, since pip install resolves+unpacks against
|
||||
# whatever bytes Fastly returns and never inspects them).
|
||||
# Both must pass before the cascade fans out.
|
||||
#
|
||||
# The venv is reused across polls; only `pip install`/`pip
|
||||
# download` run in the loop, with --force-reinstall +
|
||||
# --no-cache-dir so the previous poll's cached state doesn't
|
||||
# mask propagation lag.
|
||||
env:
|
||||
RUNTIME_VERSION: ${{ needs.publish.outputs.version }}
|
||||
EXPECTED_SHA256: ${{ needs.publish.outputs.wheel_sha256 }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ -z "$EXPECTED_SHA256" ]; then
|
||||
echo "::error::publish job did not expose wheel_sha256 — cannot verify wheel content. Refusing to fan out cascade."
|
||||
exit 1
|
||||
fi
|
||||
python -m venv /tmp/propagation-probe
|
||||
PROBE=/tmp/propagation-probe/bin
|
||||
$PROBE/pip install --upgrade --quiet pip
|
||||
# Poll budget: 30 attempts × (~3-5s pip install + ~3s pip
|
||||
# download + 4s sleep) ≈ 5-6 min wall on a slow GH runner.
|
||||
# Generous vs PyPI's typical few-seconds propagation;
|
||||
# failures past this are signal of a real PyPI / Fastly
|
||||
# issue, not just lag.
|
||||
for i in $(seq 1 30); do
|
||||
# Stage (a): can pip resolve and install the version?
|
||||
if $PROBE/pip install \
|
||||
--quiet \
|
||||
--no-cache-dir \
|
||||
--force-reinstall \
|
||||
--no-deps \
|
||||
"molecule-ai-workspace-runtime==${RUNTIME_VERSION}" \
|
||||
>/dev/null 2>&1; then
|
||||
INSTALLED=$($PROBE/pip show molecule-ai-workspace-runtime 2>/dev/null \
|
||||
| awk -F': ' '/^Version:/{print $2}')
|
||||
if [ "$INSTALLED" = "$RUNTIME_VERSION" ]; then
|
||||
# Stage (b): does Fastly serve the bytes we uploaded?
|
||||
# `pip download` writes the actual .whl file to disk so
|
||||
# we can sha256sum it (vs `pip install` which unpacks
|
||||
# and discards).
|
||||
rm -rf /tmp/probe-dl
|
||||
mkdir -p /tmp/probe-dl
|
||||
if $PROBE/pip download \
|
||||
--quiet \
|
||||
--no-cache-dir \
|
||||
--no-deps \
|
||||
--dest /tmp/probe-dl \
|
||||
"molecule-ai-workspace-runtime==${RUNTIME_VERSION}" \
|
||||
>/dev/null 2>&1; then
|
||||
WHEEL=$(ls /tmp/probe-dl/*.whl 2>/dev/null | head -1)
|
||||
if [ -n "$WHEEL" ]; then
|
||||
ACTUAL=$(sha256sum "$WHEEL" | awk '{print $1}')
|
||||
if [ "$ACTUAL" = "$EXPECTED_SHA256" ]; then
|
||||
echo "::notice::✓ pip resolves AND wheel content matches after ${i} poll(s) (sha256=${EXPECTED_SHA256})"
|
||||
exit 0
|
||||
fi
|
||||
# Hash mismatch: PyPI accepted our upload but Fastly
|
||||
# is serving different bytes under the version's URL.
|
||||
# Most often this is propagation lag of the BINARY
|
||||
# surface — the version is resolvable but the wheel
|
||||
# cache hasn't caught up. Retry.
|
||||
echo "::warning::poll ${i}: wheel content mismatch (got ${ACTUAL:0:12}…, want ${EXPECTED_SHA256:0:12}…) — Fastly likely still serving stale binary, retrying"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
sleep 4
|
||||
done
|
||||
echo "::error::pip never resolved molecule-ai-workspace-runtime==${RUNTIME_VERSION} with matching wheel content within ~5 min."
|
||||
echo "::error::Expected wheel SHA256: ${EXPECTED_SHA256}"
|
||||
echo "::error::Refusing to fan out cascade against stale or corrupt PyPI surfaces."
|
||||
exit 1
|
||||
|
||||
- name: Fan out repository_dispatch
|
||||
env:
|
||||
# Fine-grained PAT with `actions:write` on the 8 template repos.
|
||||
# GITHUB_TOKEN can't fire dispatches across repos — needs an explicit
|
||||
# token. Stored as a repo secret; rotate per the standard schedule.
|
||||
DISPATCH_TOKEN: ${{ secrets.TEMPLATE_DISPATCH_TOKEN }}
|
||||
# Single source of truth: the publish job's output, which handles
|
||||
# tag/manual-input/auto-bump uniformly. The previous fallback
|
||||
# (`steps.version.outputs.version` from inside the cascade job)
|
||||
# was a dead reference — different job, no shared step scope.
|
||||
RUNTIME_VERSION: ${{ needs.publish.outputs.version }}
|
||||
run: |
|
||||
set +e # don't abort on a single repo failure — collect them all
|
||||
# Schedule-vs-dispatch behaviour split (hardened 2026-04-28
|
||||
# after the sweep-cf-orphans soft-skip incident — same class
|
||||
# of bug):
|
||||
#
|
||||
# The earlier "skipping cascade. templates will pick up the
|
||||
# new version on their own next rebuild" message was wrong —
|
||||
# templates only build on this dispatch trigger; without it
|
||||
# they stay pinned to whatever runtime version they last saw.
|
||||
# A silent skip here means "PyPI is current, templates are
|
||||
# not" and the gap is invisible until someone notices a
|
||||
# template still on the old version weeks later.
|
||||
#
|
||||
# - push → exit 1 (red CI surfaces the gap)
|
||||
# - workflow_dispatch → exit 0 with a warning (operator
|
||||
# ran this ad-hoc; let them rerun
|
||||
# after fixing the secret)
|
||||
if [ -z "$DISPATCH_TOKEN" ]; then
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "::warning::TEMPLATE_DISPATCH_TOKEN secret not set — skipping cascade."
|
||||
echo "::warning::set it at Settings → Secrets and Variables → Actions, then rerun. Templates will stay on the prior runtime version until either this token is set or each template is rebuilt manually."
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::TEMPLATE_DISPATCH_TOKEN secret missing — cascade cannot fan out."
|
||||
echo "::error::PyPI was published, but the 8 template repos will NOT pick up the new version until this token is restored and a republish dispatches the cascade."
|
||||
echo "::error::set it at Settings → Secrets and Variables → Actions; then re-trigger publish-runtime via workflow_dispatch."
|
||||
exit 1
|
||||
fi
|
||||
VERSION="$RUNTIME_VERSION"
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "::error::publish job did not expose a version output — cascade cannot fan out"
|
||||
exit 1
|
||||
fi
|
||||
# All 9 active workspace template repos. The PR #2536 pruning
|
||||
# ("deprecated, no shipping images") was empirically wrong:
|
||||
# continuous-synth-e2e.yml defaults to langgraph as its primary
|
||||
# canary (line 44), and every excluded template had successful
|
||||
# publish-image runs as of 2026-05-03 — none were dormant.
|
||||
# Symptom of the prune: today's a2a-sdk strict-mode fix
|
||||
# (#2566 / commit e1628c4) cascaded to 4 templates but never
|
||||
# reached langgraph, so the synth-E2E correctly canary'd a fix
|
||||
# that had landed but not deployed. Re-added the 5 templates.
|
||||
# Long-term: derive this list from manifest.json so cascade
|
||||
# scope can't drift from E2E scope — tracked in RFC #388 as a
|
||||
# Phase-1 invariant.
|
||||
TEMPLATES="claude-code hermes openclaw codex langgraph crewai autogen deepagents gemini-cli"
|
||||
FAILED=""
|
||||
for tpl in $TEMPLATES; do
|
||||
REPO="molecule-ai/molecule-ai-workspace-template-$tpl"
|
||||
STATUS=$(curl -sS -o /tmp/dispatch.out -w "%{http_code}" \
|
||||
-X POST "https://api.github.com/repos/$REPO/dispatches" \
|
||||
-H "Authorization: Bearer $DISPATCH_TOKEN" \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||||
-d "{\"event_type\":\"runtime-published\",\"client_payload\":{\"runtime_version\":\"$VERSION\"}}")
|
||||
if [ "$STATUS" = "204" ]; then
|
||||
echo "✓ dispatched $tpl ($VERSION)"
|
||||
else
|
||||
echo "::warning::✗ failed to dispatch $tpl: HTTP $STATUS — $(cat /tmp/dispatch.out)"
|
||||
FAILED="$FAILED $tpl"
|
||||
fi
|
||||
done
|
||||
if [ -n "$FAILED" ]; then
|
||||
echo "::warning::Cascade incomplete. Failed templates:$FAILED"
|
||||
# Don't fail the whole job — PyPI publish already succeeded;
|
||||
# operators can retry the failed templates manually.
|
||||
fi
|
||||
@@ -1,60 +1,19 @@
|
||||
name: publish-workspace-server-image
|
||||
|
||||
# Builds and pushes Docker images to GHCR on staging or main pushes.
|
||||
# Builds and pushes Docker images to GHCR when staging is promoted to main.
|
||||
# PRs target staging (default branch). Only main push triggers production builds.
|
||||
# EC2 tenant instances pull the tenant image from GHCR.
|
||||
#
|
||||
# Branch / tag policy (see Compute tags step for the per-branch logic):
|
||||
#
|
||||
# staging push → builds image, tags :staging-<sha> + :staging-latest.
|
||||
# staging-CP pins TENANT_IMAGE=:staging-latest, so it
|
||||
# picks up staging-branch code automatically. This is
|
||||
# what makes staging-CP actually test staging-branch
|
||||
# code instead of "yesterday's main" — pre-fix, this
|
||||
# workflow only ran on main, so staging tenants
|
||||
# silently served stale code (#2308 fix RFC #2312
|
||||
# landed on staging but never reached tenants because
|
||||
# staging→main was wedged on path-filter parity bugs).
|
||||
#
|
||||
# main push → builds image, tags :staging-<sha> + :staging-latest
|
||||
# (same as before). canary-verify.yml retags
|
||||
# :staging-<sha> → :latest after canary tenants
|
||||
# green-light the digest. The :staging-latest retag
|
||||
# on main push is intentional: when main lands AFTER a
|
||||
# staging push, staging-CP gets the post-promote code
|
||||
# (which equals what it had + any merge resolution),
|
||||
# so the canary-on-staging-CP step still runs against
|
||||
# the prod-bound digest.
|
||||
#
|
||||
# In the steady state both branches refresh :staging-latest; the
|
||||
# semantic is "most recent staging-or-main build of tenant code."
|
||||
# Drift between the two is bounded by the staging→main auto-promote
|
||||
# cadence and is corrected on the next staging push.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [staging, main]
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'workspace-server/**'
|
||||
- 'canvas/**'
|
||||
- 'manifest.json'
|
||||
- '.github/workflows/publish-workspace-server-image.yml'
|
||||
- '.github/workflows/publish-platform-image.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Serialize per-branch so two rapid staging pushes don't race the same
|
||||
# :staging-latest tag retag. Allow staging and main to run in parallel
|
||||
# (different github.ref → different concurrency group) since they
|
||||
# produce different :staging-<sha> tags and last-write-wins on
|
||||
# :staging-latest is acceptable across branches (the post-promote
|
||||
# main code equals current staging code in a healthy flow).
|
||||
#
|
||||
# cancel-in-progress: false → in-flight builds finish; the next push's
|
||||
# build queues. This avoids a partially-pushed image and keeps the
|
||||
# canary fleet pin (:staging-<sha>) consistent with what was actually
|
||||
# tested at canary-verify time.
|
||||
concurrency:
|
||||
group: publish-workspace-server-image-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -65,10 +24,10 @@ env:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: [self-hosted, macos, arm64]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Checkout sibling plugin repo
|
||||
# workspace-server/Dockerfile expects
|
||||
@@ -76,62 +35,57 @@ jobs:
|
||||
# the Go module has a `replace` directive pointing at /plugin inside
|
||||
# the image. Pre-repo-split the plugin lived in the monorepo; the
|
||||
# 2026-04-18 restructure moved it out but didn't add this clone step
|
||||
# — which is why publish was failing after that restructure.
|
||||
# — which is why publish has been failing since then.
|
||||
#
|
||||
# Uses a fine-grained PAT (PLUGIN_REPO_PAT) because the plugin repo
|
||||
# is private and the default GITHUB_TOKEN is scoped to THIS repo.
|
||||
# The PAT needs Contents:Read on molecule-ai/molecule-ai-plugin-
|
||||
# The PAT needs Contents:Read on Molecule-AI/molecule-ai-plugin-
|
||||
# github-app-auth. Falls back to the default token for the (rare)
|
||||
# case where an operator made the plugin repo public.
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: molecule-ai/molecule-ai-plugin-github-app-auth
|
||||
repository: Molecule-AI/molecule-ai-plugin-github-app-auth
|
||||
path: molecule-ai-plugin-github-app-auth
|
||||
token: ${{ secrets.PLUGIN_REPO_PAT || secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
|
||||
- name: Configure GHCR auth
|
||||
shell: bash
|
||||
env:
|
||||
GHCR_USER: ${{ github.actor }}
|
||||
GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p "${RUNNER_TEMP}/docker-config"
|
||||
GHCR_AUTH=$(printf '%s:%s' "${GHCR_USER}" "${GHCR_TOKEN}" | base64)
|
||||
umask 077
|
||||
printf '{"auths":{"ghcr.io":{"auth":"%s"}}}' "${GHCR_AUTH}" > "${RUNNER_TEMP}/docker-config/config.json"
|
||||
echo "DOCKER_CONFIG=${RUNNER_TEMP}/docker-config" >> "${GITHUB_ENV}"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
platforms: linux/amd64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Compute tags
|
||||
id: tags
|
||||
run: |
|
||||
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Canary-gated release flow:
|
||||
# - This step always publishes :staging-<sha> + :staging-latest.
|
||||
# - On staging push, staging-CP picks up :staging-latest immediately
|
||||
# (its TENANT_IMAGE pin is :staging-latest) — so staging-branch
|
||||
# code reaches staging tenants without waiting for main.
|
||||
# - On main push, canary-verify.yml runs smoke tests against
|
||||
# canary tenants (which pin :staging-<sha>), and on green retags
|
||||
# :staging-<sha> → :latest. Prod tenants pull :latest.
|
||||
# - On red, :latest stays on the prior good digest — prod is safe.
|
||||
#
|
||||
# Why :staging-latest is retagged on main push too: when main lands
|
||||
# after a staging promote, staging-CP gets the post-promote code so
|
||||
# the canary-on-staging-CP step still runs against the prod-bound
|
||||
# digest. In a healthy flow the post-promote main code == the
|
||||
# current staging code, so this is effectively a no-op except for
|
||||
# the canary fleet pin handoff.
|
||||
#
|
||||
# Pre-fix history: this workflow used to only trigger on main. That
|
||||
# meant staging-CP served "yesterday's main" indefinitely whenever
|
||||
# staging→main was wedged. The 2026-04-30 dogfooding session
|
||||
# surfaced this when RFC #2312 (chat upload HTTP-forward) landed on
|
||||
# staging but staging tenants kept failing chat upload because they
|
||||
# were running pre-RFC code. Adding the staging trigger above closes
|
||||
# that gap. Earlier 2026-04-24 incident: a static :staging-<sha> pin
|
||||
# drifted 10 days behind staging — same class of bug, different
|
||||
# mechanism.
|
||||
- name: Build & push platform image to GHCR (staging-<sha> + staging-latest)
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
# Canary-gated release: we publish :staging-<sha> ONLY here. The
|
||||
# :latest tag (which existing prod tenants auto-pull every 5 min)
|
||||
# is promoted by .github/workflows/canary-verify.yml after the
|
||||
# staging canary fleet green-lights this digest.
|
||||
# That means:
|
||||
# - Every main merge produces a :staging-<sha> image
|
||||
# - Canary tenants (configured to pull :staging-<sha>) pick it up
|
||||
# - canary-verify.yml runs smoke tests against them
|
||||
# - On green → canary-verify retags :staging-<sha> → :latest
|
||||
# - On red → :latest stays on the prior good digest, prod is safe
|
||||
- name: Build & push platform image to GHCR (staging-<sha> only)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./workspace-server/Dockerfile
|
||||
@@ -139,23 +93,15 @@ jobs:
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}:staging-${{ steps.tags.outputs.sha }}
|
||||
${{ env.IMAGE_NAME }}:staging-latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
# GIT_SHA bakes into the Go binary via -ldflags so /buildinfo
|
||||
# returns it at runtime — see Dockerfile + buildinfo/buildinfo.go.
|
||||
# This is the same value as the OCI revision label below; passing
|
||||
# it twice is intentional, the OCI label is for registry tooling
|
||||
# while /buildinfo is for the redeploy verification step.
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
org.opencontainers.image.description=Molecule AI platform (Go API server) — pending canary verify
|
||||
|
||||
- name: Build & push tenant image to GHCR (staging-<sha> + staging-latest)
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
- name: Build & push tenant image to GHCR (staging-<sha> only)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./workspace-server/Dockerfile.tenant
|
||||
@@ -163,7 +109,6 @@ jobs:
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.TENANT_IMAGE_NAME }}:staging-${{ steps.tags.outputs.sha }}
|
||||
${{ env.TENANT_IMAGE_NAME }}:staging-latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
# Canvas uses same-origin fetches. The tenant Go platform
|
||||
@@ -179,7 +124,6 @@ jobs:
|
||||
# NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080).
|
||||
build-args: |
|
||||
NEXT_PUBLIC_PLATFORM_URL=
|
||||
GIT_SHA=${{ github.sha }}
|
||||
labels: |
|
||||
org.opencontainers.image.source=https://github.com/${{ github.repository }}
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
name: Railway pin audit (drift detection)
|
||||
|
||||
# Daily audit of Railway env vars for drift-prone image-tag pins —
|
||||
# automation-cadence layer over the detection script + regression test
|
||||
# shipped in PR #2168 (#2001 closure).
|
||||
#
|
||||
# Background: on 2026-04-24 a stale `:staging-a14cf86` SHA pin in CP's
|
||||
# TENANT_IMAGE caused 3+ hours of E2E failure with the appearance that
|
||||
# "every fix didn't propagate" — really the tenant image was so old it
|
||||
# didn't read the env vars those fixes produced. The audit script
|
||||
# (scripts/ops/audit-railway-sha-pins.sh) flags drift; this workflow
|
||||
# runs the same check unattended on a daily cron.
|
||||
#
|
||||
# Cadence: once a day, 13:00 UTC (06:00 PT). Daily is the right
|
||||
# cadence for variables-tier config — Railway env var changes are
|
||||
# deliberate operator actions, low-frequency. Hourly would risk
|
||||
# Railway API rate-limit surprises and is overkill for the change rate.
|
||||
#
|
||||
# Issue-on-failure: drift triggers a priority-high issue, mirroring
|
||||
# .github/workflows/e2e-staging-sanity.yml's pattern. Drift is
|
||||
# medium-priority "config slipped, fix at next ops window," not
|
||||
# active-outage paging.
|
||||
#
|
||||
# Secret hardening: per feedback_schedule_vs_dispatch_secrets_hardening,
|
||||
# the schedule trigger HARD-FAILS on missing RAILWAY_AUDIT_TOKEN
|
||||
# (silent-success on schedule was the failure-mode class that bit the
|
||||
# team before; cron firing without checking anything is worse than no
|
||||
# cron). The workflow_dispatch trigger SOFT-SKIPS on missing secret so
|
||||
# an operator can dry-run the workflow shape during initial provisioning
|
||||
# without tripping a fake red.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 13 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: railway-pin-audit
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
audit:
|
||||
name: Audit Railway env vars for drift-prone pins
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify RAILWAY_AUDIT_TOKEN present
|
||||
# Schedule trigger: hard-fail when the secret is missing —
|
||||
# otherwise the cron silently runs against the wrong scope (or
|
||||
# exits 2 from the script and we issue-spam) without anyone
|
||||
# noticing the token rot.
|
||||
# Dispatch trigger: soft-skip — operator may be dry-running the
|
||||
# workflow shape before provisioning the secret. Logged as a
|
||||
# workflow notice, not a failure.
|
||||
env:
|
||||
RAILWAY_AUDIT_TOKEN: ${{ secrets.RAILWAY_AUDIT_TOKEN }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
id: secret_check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${RAILWAY_AUDIT_TOKEN:-}" ]; then
|
||||
echo "have_secret=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "have_secret=false" >> "$GITHUB_OUTPUT"
|
||||
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
echo "::notice::RAILWAY_AUDIT_TOKEN not configured — soft-skipping (manual dispatch)"
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::RAILWAY_AUDIT_TOKEN secret missing — schedule trigger requires it. Provision the token (read-only \`variables\` scope on the molecule-platform Railway project) and store as repo secret RAILWAY_AUDIT_TOKEN."
|
||||
exit 1
|
||||
|
||||
- name: Install Railway CLI
|
||||
if: steps.secret_check.outputs.have_secret == 'true'
|
||||
# Pinned hash matching the public install instructions; bump in
|
||||
# tandem with the audit-script's documented Railway CLI version.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL https://railway.com/install.sh | sh
|
||||
# The installer drops the binary in ~/.railway/bin
|
||||
echo "$HOME/.railway/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Verify Railway CLI authenticated
|
||||
if: steps.secret_check.outputs.have_secret == 'true'
|
||||
env:
|
||||
RAILWAY_TOKEN: ${{ secrets.RAILWAY_AUDIT_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# `railway whoami` exits non-zero when the token is
|
||||
# unauthenticated or doesn't have any project access.
|
||||
if ! railway whoami >/dev/null 2>&1; then
|
||||
echo "::error::Railway CLI failed to authenticate with RAILWAY_AUDIT_TOKEN — token may be revoked or scoped incorrectly"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
- name: Link molecule-platform project
|
||||
if: steps.secret_check.outputs.have_secret == 'true'
|
||||
env:
|
||||
RAILWAY_TOKEN: ${{ secrets.RAILWAY_AUDIT_TOKEN }}
|
||||
# Project ID from reference_production_stack: molecule-platform
|
||||
# / 7ccc8c68-61f4-42ab-9be5-586eeee11768. Linking is per-process,
|
||||
# so we re-link in this CI shell (the audit script comment says
|
||||
# it deliberately doesn't chdir for you because the linked
|
||||
# project's identity matters).
|
||||
run: |
|
||||
set -euo pipefail
|
||||
railway link --project 7ccc8c68-61f4-42ab-9be5-586eeee11768
|
||||
|
||||
- name: Run drift audit
|
||||
if: steps.secret_check.outputs.have_secret == 'true'
|
||||
id: audit
|
||||
env:
|
||||
RAILWAY_TOKEN: ${{ secrets.RAILWAY_AUDIT_TOKEN }}
|
||||
run: |
|
||||
set +e
|
||||
bash scripts/ops/audit-railway-sha-pins.sh 2>&1 | tee /tmp/audit.log
|
||||
rc=${PIPESTATUS[0]}
|
||||
echo "rc=$rc" >> "$GITHUB_OUTPUT"
|
||||
# Capture the audit log for the issue body.
|
||||
{
|
||||
echo 'log<<AUDIT_EOF'
|
||||
cat /tmp/audit.log
|
||||
echo 'AUDIT_EOF'
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
# Exit codes from the script:
|
||||
# 0 — no drift; workflow goes green
|
||||
# 1 — drift detected; we'll file an issue and fail the run
|
||||
# 2 — railway CLI unauthenticated / project unlinked; fail
|
||||
# Anything else: also fail.
|
||||
case "$rc" in
|
||||
0) exit 0 ;;
|
||||
1) echo "::warning::Drift-prone pin(s) detected — issue will be filed"; exit 1 ;;
|
||||
2) echo "::error::Railway CLI auth/link failed mid-script — token or project ID drift"; exit 2 ;;
|
||||
*) echo "::error::Unexpected audit rc=$rc"; exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Open / update drift issue
|
||||
if: failure() && steps.audit.outputs.rc == '1'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
AUDIT_LOG: ${{ steps.audit.outputs.log }}
|
||||
with:
|
||||
script: |
|
||||
const title = "🚨 Railway env-var drift detected";
|
||||
const runURL = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
const body =
|
||||
`Daily Railway pin audit found drift-prone image-tag pins in the molecule-platform Railway project.\n\n` +
|
||||
`**What this means:** an env var (likely on \`controlplane\`) is pinned to a SHA-shaped or semver tag instead of a floating tag. ` +
|
||||
`Same pattern that caused the 2026-04-24 TENANT_IMAGE incident — fix-PRs land but the running service doesn't pick them up.\n\n` +
|
||||
`**Recovery:** open the Railway dashboard, replace the flagged value with a floating tag (\`:staging-latest\`, \`:main\`) unless the pin is intentional and documented in the ops runbook.\n\n` +
|
||||
`**Audit output:**\n\n\`\`\`\n${process.env.AUDIT_LOG || '(log unavailable)'}\n\`\`\`\n\n` +
|
||||
`Run: ${runURL}\n\n` +
|
||||
`Closes automatically when a subsequent daily run reports clean.`;
|
||||
|
||||
const { data: existing } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
state: 'open', labels: 'railway-drift',
|
||||
});
|
||||
const match = existing.find(i => i.title === title);
|
||||
if (match) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: match.number,
|
||||
body: `Still drifting. ${runURL}\n\n\`\`\`\n${process.env.AUDIT_LOG || '(log unavailable)'}\n\`\`\``,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.create({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
title, body,
|
||||
labels: ['railway-drift', 'bug', 'priority-high'],
|
||||
});
|
||||
}
|
||||
|
||||
- name: Close stale drift issue on clean run
|
||||
# When a previously-flagged drift gets fixed by an operator,
|
||||
# the next daily run goes green. Close any open `railway-drift`
|
||||
# issue with a confirmation comment so the queue doesn't carry
|
||||
# stale ones.
|
||||
if: success() && steps.audit.outputs.rc == '0'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const runURL = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
const { data: existing } = await github.rest.issues.listForRepo({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
state: 'open', labels: 'railway-drift',
|
||||
});
|
||||
for (const issue of existing) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
body: `Daily audit clean — drift resolved. ${runURL}`,
|
||||
});
|
||||
await github.rest.issues.update({
|
||||
owner: context.repo.owner, repo: context.repo.repo,
|
||||
issue_number: issue.number,
|
||||
state: 'closed',
|
||||
state_reason: 'completed',
|
||||
});
|
||||
}
|
||||
@@ -1,396 +0,0 @@
|
||||
name: redeploy-tenants-on-main
|
||||
|
||||
# Auto-refresh prod tenant EC2s after every main merge.
|
||||
#
|
||||
# Why this workflow exists: publish-workspace-server-image builds and
|
||||
# pushes a new platform-tenant:latest + :<sha> to GHCR on every merge
|
||||
# to main, but running tenants pulled their image once at boot and
|
||||
# never re-pull. Users see stale code indefinitely.
|
||||
#
|
||||
# This workflow closes the gap by calling the control-plane admin
|
||||
# endpoint that performs a canary-first, batched, health-gated rolling
|
||||
# redeploy across every live tenant. Implemented in molecule-ai/
|
||||
# molecule-controlplane as POST /cp/admin/tenants/redeploy-fleet
|
||||
# (feat/tenant-auto-redeploy, landing alongside this workflow).
|
||||
#
|
||||
# Runtime ordering:
|
||||
# 1. publish-workspace-server-image completes → new :latest in GHCR.
|
||||
# 2. This workflow fires via workflow_run, waits 30s for GHCR's
|
||||
# CDN to propagate the new tag to the region the tenants pull from.
|
||||
# 3. Calls redeploy-fleet with canary_slug=hongming and a 60s
|
||||
# soak. 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=<sha>, re-pulling the older image on every tenant.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['publish-workspace-server-image']
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_tag:
|
||||
# Empty default → auto-trigger and dispatch-without-input both
|
||||
# resolve to `staging-<short_head_sha>` (the digest publish-image
|
||||
# just pushed). Pre-fix this defaulted to 'latest', which only
|
||||
# gets retagged by canary-verify's promote-to-latest job — and
|
||||
# that job soft-skips when CANARY_TENANT_URLS is unset (the
|
||||
# current state, until Phase 2 canary fleet is live). Result:
|
||||
# `:latest` had been pinned to a 4-day-old digest (2026-04-28)
|
||||
# while every main push pushed fresh `staging-<sha>` images;
|
||||
# every prod redeploy pulled the stale `:latest` and the verify
|
||||
# step correctly flagged 3/3 tenants STALE. Pulling the
|
||||
# just-published `staging-<sha>` directly skips the dead retag
|
||||
# path. When canary fleet is real, this workflow should chain
|
||||
# on canary-verify completion (workflow_run from canary-verify),
|
||||
# not publish-image — separate, smaller PR.
|
||||
description: 'Tenant image tag to deploy (e.g. "latest", "staging-a59f1a6c"). Empty = auto staging-<head_sha>.'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
canary_slug:
|
||||
description: 'Tenant slug to deploy first + soak (empty = skip canary, fan out immediately).'
|
||||
required: false
|
||||
type: string
|
||||
# Must be an actual prod tenant slug (current: hongming,
|
||||
# chloe-dong, reno-stars). The previous default 'hongmingwang'
|
||||
# didn't match any tenant — CP soft-skipped the missing canary
|
||||
# and the fleet rolled out without the soak gate, defeating the
|
||||
# whole point of canary-first.
|
||||
default: 'hongming'
|
||||
soak_seconds:
|
||||
description: 'Seconds to wait after canary before fanning out.'
|
||||
required: false
|
||||
type: string
|
||||
default: '60'
|
||||
batch_size:
|
||||
description: 'How many tenants SSM redeploys in parallel per batch.'
|
||||
required: false
|
||||
type: string
|
||||
default: '3'
|
||||
dry_run:
|
||||
description: 'Plan only — do not actually redeploy.'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# No write scopes needed — the workflow hits an external CP endpoint,
|
||||
# not the GitHub API.
|
||||
|
||||
# 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
|
||||
|
||||
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.
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Wait for GHCR tag propagation
|
||||
# GHCR's edge cache takes ~15-30s to consistently serve the new
|
||||
# manifest after the registry accepts the push. Without this
|
||||
# sleep, the first tenant's docker pull sometimes races and
|
||||
# fetches the previous digest; sleeping is the cheapest way to
|
||||
# reduce that without polling GHCR for the new digest.
|
||||
run: sleep 30
|
||||
|
||||
- name: Compute target tag
|
||||
id: tag
|
||||
# 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-<sha>` to roll back to a known-good build.
|
||||
# 2. Default → `staging-<short_head_sha>`. The just-published
|
||||
# digest. Bypasses the `:latest` retag path that's currently
|
||||
# dead (canary-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:
|
||||
INPUT_TAG: ${{ inputs.target_tag }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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 auto tag: staging-$SHORT (head_sha=$HEAD_SHA)"
|
||||
fi
|
||||
|
||||
- name: Call CP redeploy-fleet
|
||||
# CP_ADMIN_API_TOKEN must be set as a repo/org secret on
|
||||
# molecule-ai/molecule-core, matching the staging/prod CP's
|
||||
# CP_ADMIN_API_TOKEN env. Stored in Railway, mirrored to this
|
||||
# repo's secrets for CI.
|
||||
env:
|
||||
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: ${{ 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
|
||||
|
||||
if [ -z "${CP_ADMIN_API_TOKEN:-}" ]; then
|
||||
echo "::error::CP_ADMIN_API_TOKEN secret not set — skipping redeploy"
|
||||
echo "::notice::Set CP_ADMIN_API_TOKEN in repo secrets to enable auto-redeploy."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BODY=$(jq -nc \
|
||||
--arg tag "$TARGET_TAG" \
|
||||
--arg canary "$CANARY_SLUG" \
|
||||
--argjson soak "$SOAK_SECONDS" \
|
||||
--argjson batch "$BATCH_SIZE" \
|
||||
--argjson dry "$DRY_RUN" \
|
||||
'{
|
||||
target_tag: $tag,
|
||||
canary_slug: $canary,
|
||||
soak_seconds: $soak,
|
||||
batch_size: $batch,
|
||||
dry_run: $dry
|
||||
}')
|
||||
|
||||
echo "POST $CP_URL/cp/admin/tenants/redeploy-fleet"
|
||||
echo " body: $BODY"
|
||||
|
||||
HTTP_RESPONSE=$(mktemp)
|
||||
HTTP_CODE_FILE=$(mktemp)
|
||||
# Route -w into its own tempfile so curl's exit code (e.g. 56
|
||||
# on connection-reset, 22 on --fail-with-body 4xx/5xx) can't
|
||||
# pollute the captured stdout. The previous inline-substitution
|
||||
# shape produced "000000" on connection reset (curl wrote
|
||||
# "000" via -w, then the inline echo-fallback appended another
|
||||
# "000") — caught on the 2026-05-04 redeploy of sha 2b862f6.
|
||||
# set +e/-e keeps the non-zero curl exit from tripping the
|
||||
# outer pipeline. See lint-curl-status-capture.yml for the
|
||||
# CI gate that pins this fix shape.
|
||||
set +e
|
||||
curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \
|
||||
-m 1200 \
|
||||
-H "Authorization: Bearer $CP_ADMIN_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST "$CP_URL/cp/admin/tenants/redeploy-fleet" \
|
||||
-d "$BODY" >"$HTTP_CODE_FILE"
|
||||
set -e
|
||||
# Stderr from curl (e.g. dial errors with -sS) goes to the runner
|
||||
# log so operators can see WHY a connection failed. Stdout is
|
||||
# captured to $HTTP_CODE_FILE because that's where -w writes.
|
||||
HTTP_CODE=$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")
|
||||
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
|
||||
|
||||
echo "HTTP $HTTP_CODE"
|
||||
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
|
||||
# into the raw response.
|
||||
{
|
||||
echo "## Tenant redeploy fleet"
|
||||
echo ""
|
||||
echo "**Target tag:** \`$TARGET_TAG\`"
|
||||
echo "**Canary:** \`$CANARY_SLUG\` (soak ${SOAK_SECONDS}s)"
|
||||
echo "**Batch size:** $BATCH_SIZE"
|
||||
echo "**Dry run:** $DRY_RUN"
|
||||
echo "**HTTP:** $HTTP_CODE"
|
||||
echo ""
|
||||
echo "### Per-tenant result"
|
||||
echo ""
|
||||
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
|
||||
echo "::error::redeploy-fleet returned HTTP $HTTP_CODE"
|
||||
exit 1
|
||||
fi
|
||||
OK=$(jq -r '.ok' "$HTTP_RESPONSE")
|
||||
if [ "$OK" != "true" ]; then
|
||||
echo "::error::redeploy-fleet reported ok=false (see summary for which tenant halted the rollout)"
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::Tenant fleet redeploy reported ssm_status=Success — verifying actual image roll on each tenant..."
|
||||
|
||||
# Stash the response for the verify step. $RUNNER_TEMP outlasts
|
||||
# the step boundary; $HTTP_RESPONSE doesn't.
|
||||
cp "$HTTP_RESPONSE" "$RUNNER_TEMP/redeploy-response.json"
|
||||
|
||||
- name: Verify each tenant /buildinfo matches published SHA
|
||||
# ROOT FIX FOR #2395.
|
||||
#
|
||||
# `redeploy-fleet`'s `ssm_status=Success` means "the SSM RPC
|
||||
# didn't error" — NOT "the new image is running on the tenant."
|
||||
# `:latest` lives in the local Docker daemon's image cache; if
|
||||
# the SSM document does `docker compose up -d` without an
|
||||
# explicit `docker pull`, the daemon serves the previously-
|
||||
# cached digest and the container restarts on stale code.
|
||||
# 2026-04-30 incident: hongmingwang's tenant reported
|
||||
# ssm_status=Success at 17:00:53Z but kept serving pre-501a42d7
|
||||
# chat_files for 30+ min — the lazy-heal fix never reached the
|
||||
# user despite green deploy + green redeploy.
|
||||
#
|
||||
# This step closes the gap by curling each tenant's /buildinfo
|
||||
# endpoint (added in workspace-server/internal/buildinfo +
|
||||
# /Dockerfile* GIT_SHA build-arg, this PR) and comparing the
|
||||
# returned git_sha to the SHA the workflow expects. Mismatches
|
||||
# fail the workflow, which is what `ok=true` should have
|
||||
# guaranteed all along.
|
||||
#
|
||||
# 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.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 `<slug>.moleculesai.app`;
|
||||
# staging CP issues `<slug>.staging.moleculesai.app`. This
|
||||
# workflow runs on main → prod CP → no `staging.` infix.
|
||||
TENANT_DOMAIN: 'moleculesai.app'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
EXPECTED_SHORT="${EXPECTED_SHA:0:7}"
|
||||
if [ "$TARGET_TAG" != "latest" ] \
|
||||
&& [ "$TARGET_TAG" != "$EXPECTED_SHA" ] \
|
||||
&& [ "$TARGET_TAG" != "staging-$EXPECTED_SHORT" ]; then
|
||||
# workflow_dispatch with a pinned tag that isn't the head
|
||||
# SHA — operator is rolling back / pinning. Skip the
|
||||
# verification because we don't have the expected SHA in
|
||||
# this context (would need to crane-inspect the GHCR
|
||||
# manifest, which is a follow-up). Failing-open here is
|
||||
# safe: the operator chose the tag deliberately.
|
||||
#
|
||||
# `staging-<short_head_sha>` IS verified — it's the new
|
||||
# auto-trigger default (see Compute target tag step) and
|
||||
# the digest under that tag SHOULD match EXPECTED_SHA.
|
||||
echo "::notice::target_tag=$TARGET_TAG (operator-pinned) — skipping per-tenant SHA verification."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RESP="$RUNNER_TEMP/redeploy-response.json"
|
||||
if [ ! -s "$RESP" ]; then
|
||||
echo "::error::redeploy-response.json missing or empty — verify step ran without a response to read"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pull only successfully-redeployed tenants. Any tenant that
|
||||
# halted the rollout already failed the previous step, so we
|
||||
# don't double-count them here.
|
||||
mapfile -t SLUGS < <(jq -r '.results[]? | select(.healthz_ok == true) | .slug' "$RESP")
|
||||
if [ ${#SLUGS[@]} -eq 0 ]; then
|
||||
echo "::warning::No tenants reported healthz_ok — nothing to verify"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Verifying ${#SLUGS[@]} tenant(s) against EXPECTED_SHA=${EXPECTED_SHA:0:7}..."
|
||||
|
||||
# Two distinct failure modes — STALE (the #2395 bug class, hard-fail)
|
||||
# vs UNREACHABLE (teardown race, soft-warn). See the staging variant's
|
||||
# comment for the full rationale; same logic applies on prod even
|
||||
# though prod has fewer ephemeral tenants — the asymmetry would be a
|
||||
# gratuitous fork.
|
||||
STALE_COUNT=0
|
||||
UNREACHABLE_COUNT=0
|
||||
STALE_LINES=()
|
||||
UNREACHABLE_LINES=()
|
||||
for slug in "${SLUGS[@]}"; do
|
||||
URL="https://${slug}.${TENANT_DOMAIN}/buildinfo"
|
||||
# 30s total: tenant just SSM-restarted, may still be coming
|
||||
# up. Retry-on-empty rather than retry-on-status — we want
|
||||
# to fail fast on "responded with wrong SHA", not "still
|
||||
# warming up".
|
||||
BODY=$(curl -sS --max-time 30 --retry 3 --retry-delay 5 --retry-connrefused "$URL" || true)
|
||||
ACTUAL_SHA=$(echo "$BODY" | jq -r '.git_sha // ""' 2>/dev/null || echo "")
|
||||
if [ -z "$ACTUAL_SHA" ]; then
|
||||
UNREACHABLE_COUNT=$((UNREACHABLE_COUNT + 1))
|
||||
UNREACHABLE_LINES+=("| $slug | (no /buildinfo response) | ${EXPECTED_SHA:0:7} | ⚠ unreachable (likely teardown race) |")
|
||||
continue
|
||||
fi
|
||||
if [ "$ACTUAL_SHA" = "$EXPECTED_SHA" ]; then
|
||||
echo " $slug: ${ACTUAL_SHA:0:7} ✓"
|
||||
else
|
||||
STALE_COUNT=$((STALE_COUNT + 1))
|
||||
STALE_LINES+=("| $slug | ${ACTUAL_SHA:0:7} | ${EXPECTED_SHA:0:7} | ❌ stale |")
|
||||
fi
|
||||
done
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "### Per-tenant /buildinfo verification"
|
||||
echo ""
|
||||
echo "Expected SHA: \`${EXPECTED_SHA:0:7}\`"
|
||||
echo ""
|
||||
if [ $STALE_COUNT -gt 0 ]; then
|
||||
echo "**${STALE_COUNT} STALE tenant(s) — these did NOT pick up the new image despite ssm_status=Success:**"
|
||||
echo ""
|
||||
echo "| Slug | Actual /buildinfo SHA | Expected | Status |"
|
||||
echo "|------|----------------------|----------|--------|"
|
||||
for line in "${STALE_LINES[@]}"; do echo "$line"; done
|
||||
echo ""
|
||||
fi
|
||||
if [ $UNREACHABLE_COUNT -gt 0 ]; then
|
||||
echo "**${UNREACHABLE_COUNT} unreachable tenant(s) — likely teardown race (soft-warn, not failing):**"
|
||||
echo ""
|
||||
echo "| Slug | Actual /buildinfo SHA | Expected | Status |"
|
||||
echo "|------|----------------------|----------|--------|"
|
||||
for line in "${UNREACHABLE_LINES[@]}"; do echo "$line"; done
|
||||
echo ""
|
||||
fi
|
||||
if [ $STALE_COUNT -eq 0 ] && [ $UNREACHABLE_COUNT -eq 0 ]; then
|
||||
echo "All ${#SLUGS[@]} tenants returned matching SHA. ✓"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ $UNREACHABLE_COUNT -gt 0 ]; then
|
||||
echo "::warning::$UNREACHABLE_COUNT tenant(s) unreachable post-redeploy. Likely benign teardown race — CP healthz monitor catches real outages."
|
||||
fi
|
||||
|
||||
# Belt-and-suspenders sanity floor: same logic as the staging
|
||||
# variant — see that file's comment for the full rationale.
|
||||
# Floor only applies when fleet >= 4; below that, canary-verify
|
||||
# is the actual gate.
|
||||
TOTAL_VERIFIED=${#SLUGS[@]}
|
||||
if [ $TOTAL_VERIFIED -ge 4 ] && [ $UNREACHABLE_COUNT -gt $((TOTAL_VERIFIED / 2)) ]; then
|
||||
echo "::error::$UNREACHABLE_COUNT of $TOTAL_VERIFIED tenant(s) unreachable — exceeds 50% threshold on a fleet large enough that this signals a real outage, not teardown race."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ $STALE_COUNT -gt 0 ]; then
|
||||
echo "::error::$STALE_COUNT tenant(s) returned a stale SHA. ssm_status=Success was misleading — see job summary."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::notice::Tenant fleet redeploy complete — all reachable tenants on ${EXPECTED_SHA:0:7} (${UNREACHABLE_COUNT} unreachable, soft-warned)."
|
||||
@@ -1,362 +0,0 @@
|
||||
name: redeploy-tenants-on-staging
|
||||
|
||||
# Auto-refresh staging tenant EC2s after every staging-branch merge.
|
||||
#
|
||||
# Mirror of redeploy-tenants-on-main.yml, with the staging-CP host and
|
||||
# the :staging-latest tag. Sister workflow exists for prod (rolls
|
||||
# :latest after canary-verify). Both share the same shape — just
|
||||
# different CP_URL + target_tag + admin token secret.
|
||||
#
|
||||
# Why this workflow exists: publish-workspace-server-image now builds
|
||||
# on every staging-branch push (PR #2335), pushing
|
||||
# platform-tenant:staging-latest to GHCR. Existing tenants pulled
|
||||
# their image once at boot and never re-pull, so the new image just
|
||||
# sits unused until the tenant is reprovisioned.
|
||||
#
|
||||
# This workflow closes the gap by calling staging-CP's
|
||||
# /cp/admin/tenants/redeploy-fleet, which performs a canary-first,
|
||||
# batched, health-gated SSM redeploy across every live staging tenant.
|
||||
# Same endpoint shape as prod CP — only the host differs.
|
||||
#
|
||||
# Runtime ordering:
|
||||
# 1. publish-workspace-server-image completes on staging branch →
|
||||
# new :staging-latest in GHCR.
|
||||
# 2. This workflow fires via workflow_run, waits 30s for GHCR's CDN
|
||||
# to propagate the new tag.
|
||||
# 3. Calls redeploy-fleet with no canary (staging IS canary; we don't
|
||||
# need a sub-canary inside it). Soak still applies to the first
|
||||
# tenant in case of bad-deploy detection.
|
||||
# 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 with workflow_dispatch + target_tag=staging-<sha>
|
||||
# of a known-good build.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['publish-workspace-server-image']
|
||||
types: [completed]
|
||||
branches: [staging]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_tag:
|
||||
description: 'Tenant image tag to deploy (e.g. "staging-latest" or "staging-a59f1a6c"). Defaults to staging-latest when empty.'
|
||||
required: false
|
||||
type: string
|
||||
default: 'staging-latest'
|
||||
canary_slug:
|
||||
description: 'Tenant slug to deploy first + soak (empty = skip canary, fan out immediately). Default empty for staging since staging itself is the canary.'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
soak_seconds:
|
||||
description: 'Seconds to wait after canary before fanning out. Only meaningful if canary_slug is set.'
|
||||
required: false
|
||||
type: string
|
||||
default: '60'
|
||||
batch_size:
|
||||
description: 'How many tenants SSM redeploys in parallel per batch.'
|
||||
required: false
|
||||
type: string
|
||||
default: '3'
|
||||
dry_run:
|
||||
description: 'Plan only — do not actually redeploy.'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# No write scopes needed — the workflow hits an external CP endpoint,
|
||||
# not the GitHub API.
|
||||
|
||||
# Serialize per-branch so two rapid staging pushes' redeploys don't
|
||||
# overlap and cause confusing per-tenant SSM state. cancel-in-progress
|
||||
# is false because aborting a half-rolled-out fleet leaves tenants
|
||||
# stuck on whatever image they happened to be on when cancelled.
|
||||
concurrency:
|
||||
group: redeploy-tenants-on-staging
|
||||
cancel-in-progress: false
|
||||
|
||||
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.
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Wait for GHCR tag propagation
|
||||
# GHCR's edge cache takes ~15-30s to consistently serve the new
|
||||
# :staging-latest manifest after the registry accepts the push.
|
||||
# Same rationale as redeploy-tenants-on-main.yml.
|
||||
run: sleep 30
|
||||
|
||||
- name: Call staging-CP redeploy-fleet
|
||||
# CP_STAGING_ADMIN_API_TOKEN must be set as a repo/org secret
|
||||
# on molecule-ai/molecule-core, matching staging-CP's
|
||||
# CP_ADMIN_API_TOKEN env var (visible in Railway controlplane
|
||||
# / staging environment). Stored separately from the prod
|
||||
# CP_ADMIN_API_TOKEN so a leak of one doesn't auth the other.
|
||||
env:
|
||||
CP_URL: ${{ vars.STAGING_CP_URL || 'https://staging-api.moleculesai.app' }}
|
||||
CP_STAGING_ADMIN_API_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
TARGET_TAG: ${{ inputs.target_tag || 'staging-latest' }}
|
||||
CANARY_SLUG: ${{ inputs.canary_slug || '' }}
|
||||
SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }}
|
||||
BATCH_SIZE: ${{ inputs.batch_size || '3' }}
|
||||
DRY_RUN: ${{ inputs.dry_run || false }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Schedule-vs-dispatch hardening (mirrors sweep-cf-orphans
|
||||
# and sweep-cf-tunnels): hard-fail on auto-trigger when the
|
||||
# secret is missing so a misconfigured-repo doesn't silently
|
||||
# serve stale staging tenants. Soft-skip on operator dispatch.
|
||||
if [ -z "${CP_STAGING_ADMIN_API_TOKEN:-}" ]; then
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "::warning::CP_STAGING_ADMIN_API_TOKEN secret not set — skipping redeploy"
|
||||
echo "::warning::Set CP_STAGING_ADMIN_API_TOKEN in repo secrets to enable auto-redeploy."
|
||||
echo "::notice::Pull the value from staging-CP's CP_ADMIN_API_TOKEN env in Railway."
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::staging redeploy cannot run — CP_STAGING_ADMIN_API_TOKEN secret missing"
|
||||
echo "::error::set it at Settings → Secrets and Variables → Actions; pull from staging-CP's CP_ADMIN_API_TOKEN env in Railway."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BODY=$(jq -nc \
|
||||
--arg tag "$TARGET_TAG" \
|
||||
--arg canary "$CANARY_SLUG" \
|
||||
--argjson soak "$SOAK_SECONDS" \
|
||||
--argjson batch "$BATCH_SIZE" \
|
||||
--argjson dry "$DRY_RUN" \
|
||||
'{
|
||||
target_tag: $tag,
|
||||
canary_slug: $canary,
|
||||
soak_seconds: $soak,
|
||||
batch_size: $batch,
|
||||
dry_run: $dry
|
||||
}')
|
||||
|
||||
echo "POST $CP_URL/cp/admin/tenants/redeploy-fleet"
|
||||
echo " body: $BODY"
|
||||
|
||||
HTTP_RESPONSE=$(mktemp)
|
||||
HTTP_CODE_FILE=$(mktemp)
|
||||
# Route -w into its own tempfile so curl's exit code (e.g. 56
|
||||
# on connection-reset) can't pollute the captured stdout. The
|
||||
# previous inline-substitution shape produced "000000" on
|
||||
# connection reset — caught on main variant 2026-05-04
|
||||
# redeploying sha 2b862f6. Same fix shape as the synth-E2E
|
||||
# §9c gate (PR #2797). See lint-curl-status-capture.yml for
|
||||
# the CI gate that pins this fix shape.
|
||||
set +e
|
||||
curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \
|
||||
-m 1200 \
|
||||
-H "Authorization: Bearer $CP_STAGING_ADMIN_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST "$CP_URL/cp/admin/tenants/redeploy-fleet" \
|
||||
-d "$BODY" >"$HTTP_CODE_FILE"
|
||||
set -e
|
||||
# Stderr from curl (-sS shows dial errors etc.) goes to the
|
||||
# runner log so operators can see WHY a connection failed.
|
||||
HTTP_CODE=$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")
|
||||
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
|
||||
|
||||
echo "HTTP $HTTP_CODE"
|
||||
cat "$HTTP_RESPONSE" | jq . || cat "$HTTP_RESPONSE"
|
||||
|
||||
{
|
||||
echo "## Staging tenant redeploy fleet"
|
||||
echo ""
|
||||
echo "**Target tag:** \`$TARGET_TAG\`"
|
||||
echo "**Canary:** \`${CANARY_SLUG:-(none — staging is itself the canary)}\` (soak ${SOAK_SECONDS}s)"
|
||||
echo "**Batch size:** $BATCH_SIZE"
|
||||
echo "**Dry run:** $DRY_RUN"
|
||||
echo "**HTTP:** $HTTP_CODE"
|
||||
echo ""
|
||||
echo "### Per-tenant result"
|
||||
echo ""
|
||||
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"
|
||||
|
||||
# Distinguish "real fleet failure" from "E2E teardown race".
|
||||
#
|
||||
# CP returns HTTP 500 + ok=false whenever ANY tenant in the
|
||||
# fleet failed SSM or healthz. In practice the recurring source
|
||||
# of these is ephemeral test tenants being torn down by their
|
||||
# parent E2E run mid-redeploy: the EC2 dies → SSM exit=2 or
|
||||
# healthz timeout → CP marks the fleet failed → this workflow
|
||||
# goes red even though every operator-facing tenant rolled fine.
|
||||
#
|
||||
# Ephemeral slug prefixes (kept in sync with sweep-stale-e2e-orgs.yml
|
||||
# — see that file for the source-of-truth list and rationale):
|
||||
# - e2e-* — canvas/saas/ext E2E suites
|
||||
# - rt-e2e-* — runtime-test harness fixtures (RFC #2251)
|
||||
# Long-lived prefixes that are NOT ephemeral and MUST hard-fail:
|
||||
# demo-prep, dryrun-*, dryrun2-*, plus all human tenant slugs.
|
||||
#
|
||||
# Filter: if HTTP=500/ok=false AND every failed slug matches an
|
||||
# ephemeral prefix, treat as soft-warn and let the verify step
|
||||
# downstream handle unreachable-vs-stale (#2402). Any non-ephemeral
|
||||
# failure or a non-500 HTTP response remains a hard failure.
|
||||
OK=$(jq -r '.ok // "false"' "$HTTP_RESPONSE")
|
||||
FAILED_SLUGS=$(jq -r '
|
||||
.results[]?
|
||||
| select((.healthz_ok != true) or (.ssm_status != "Success"))
|
||||
| .slug' "$HTTP_RESPONSE" 2>/dev/null || true)
|
||||
EPHEMERAL_PREFIX_RE='^(e2e-|rt-e2e-)'
|
||||
NON_EPHEMERAL_FAILED=$(printf '%s\n' "$FAILED_SLUGS" | grep -v '^$' | grep -Ev "$EPHEMERAL_PREFIX_RE" || true)
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ] && [ "$OK" = "true" ]; then
|
||||
: # happy path — fall through to verification
|
||||
elif [ "$HTTP_CODE" = "500" ] && [ -z "$NON_EPHEMERAL_FAILED" ] && [ -n "$FAILED_SLUGS" ]; then
|
||||
COUNT=$(printf '%s\n' "$FAILED_SLUGS" | grep -Ec "$EPHEMERAL_PREFIX_RE" || true)
|
||||
echo "::warning::redeploy-fleet returned HTTP 500 but every failed tenant ($COUNT) is ephemeral (e2e-*/rt-e2e-*) — treating as teardown race, soft-warning."
|
||||
printf '%s\n' "$FAILED_SLUGS" | sed 's/^/::warning:: failed: /'
|
||||
elif [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "::error::redeploy-fleet returned HTTP $HTTP_CODE"
|
||||
if [ -n "$NON_EPHEMERAL_FAILED" ]; then
|
||||
echo "::error::non-ephemeral tenant(s) failed:"
|
||||
printf '%s\n' "$NON_EPHEMERAL_FAILED" | sed 's/^/::error:: /'
|
||||
fi
|
||||
exit 1
|
||||
else
|
||||
# HTTP=200 but ok=false (shouldn't happen with current CP
|
||||
# but keep the gate for completeness).
|
||||
echo "::error::redeploy-fleet reported ok=false (see summary for which tenant halted the rollout)"
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::Staging tenant fleet redeploy reported ssm_status=Success — verifying actual image roll on each tenant..."
|
||||
|
||||
cp "$HTTP_RESPONSE" "$RUNNER_TEMP/redeploy-response.json"
|
||||
|
||||
- name: Verify each staging tenant /buildinfo matches published SHA
|
||||
# Mirror of the verify step in redeploy-tenants-on-main.yml — see
|
||||
# there for the rationale (#2395 root fix). Staging has the same
|
||||
# ssm_status-success-but-stale-image hazard and benefits from the
|
||||
# same gate. Diff: TENANT_DOMAIN includes the `staging.` infix.
|
||||
env:
|
||||
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
TARGET_TAG: ${{ inputs.target_tag || 'staging-latest' }}
|
||||
TENANT_DOMAIN: 'staging.moleculesai.app'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# staging-latest is the staging-side moving tag; treat it the
|
||||
# same way main treats `latest`. Operator-pinned SHAs skip
|
||||
# verification (see main variant for why).
|
||||
if [ "$TARGET_TAG" != "staging-latest" ] && [ "$TARGET_TAG" != "latest" ] && [ "$TARGET_TAG" != "$EXPECTED_SHA" ]; then
|
||||
echo "::notice::target_tag=$TARGET_TAG (operator-pinned) — skipping per-tenant SHA verification."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RESP="$RUNNER_TEMP/redeploy-response.json"
|
||||
if [ ! -s "$RESP" ]; then
|
||||
echo "::error::redeploy-response.json missing or empty"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mapfile -t SLUGS < <(jq -r '.results[]? | select(.healthz_ok == true) | .slug' "$RESP")
|
||||
if [ ${#SLUGS[@]} -eq 0 ]; then
|
||||
echo "::warning::No staging tenants reported healthz_ok — nothing to verify"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Verifying ${#SLUGS[@]} staging tenant(s) against EXPECTED_SHA=${EXPECTED_SHA:0:7}..."
|
||||
|
||||
# Two distinct failure modes here:
|
||||
# STALE_COUNT — tenant returned a SHA that doesn't match. THIS is
|
||||
# the #2395 bug class: tenant up + serving old code.
|
||||
# Always hard-fail the workflow.
|
||||
# UNREACHABLE_COUNT — tenant didn't respond. Almost always a benign
|
||||
# teardown race: redeploy-fleet snapshot says
|
||||
# healthz_ok=true, then the E2E suite tears the
|
||||
# ephemeral tenant down before this step runs (the
|
||||
# e2e-* fixtures churn 5-10/hour on staging). Soft-
|
||||
# warn so we don't block staging→main on cleanup.
|
||||
# Real "tenant up but unreachable" is caught by CP's
|
||||
# own healthz monitor + the post-redeploy alert; we
|
||||
# don't need to double-count it here.
|
||||
STALE_COUNT=0
|
||||
UNREACHABLE_COUNT=0
|
||||
STALE_LINES=()
|
||||
UNREACHABLE_LINES=()
|
||||
for slug in "${SLUGS[@]}"; do
|
||||
URL="https://${slug}.${TENANT_DOMAIN}/buildinfo"
|
||||
BODY=$(curl -sS --max-time 30 --retry 3 --retry-delay 5 --retry-connrefused "$URL" || true)
|
||||
ACTUAL_SHA=$(echo "$BODY" | jq -r '.git_sha // ""' 2>/dev/null || echo "")
|
||||
if [ -z "$ACTUAL_SHA" ]; then
|
||||
UNREACHABLE_COUNT=$((UNREACHABLE_COUNT + 1))
|
||||
UNREACHABLE_LINES+=("| $slug | (no /buildinfo response) | ${EXPECTED_SHA:0:7} | ⚠ unreachable (likely teardown race) |")
|
||||
continue
|
||||
fi
|
||||
if [ "$ACTUAL_SHA" = "$EXPECTED_SHA" ]; then
|
||||
echo " $slug: ${ACTUAL_SHA:0:7} ✓"
|
||||
else
|
||||
STALE_COUNT=$((STALE_COUNT + 1))
|
||||
STALE_LINES+=("| $slug | ${ACTUAL_SHA:0:7} | ${EXPECTED_SHA:0:7} | ❌ stale |")
|
||||
fi
|
||||
done
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "### Per-tenant /buildinfo verification (staging)"
|
||||
echo ""
|
||||
echo "Expected SHA: \`${EXPECTED_SHA:0:7}\`"
|
||||
echo ""
|
||||
if [ $STALE_COUNT -gt 0 ]; then
|
||||
echo "**${STALE_COUNT} STALE tenant(s) — these did NOT pick up the new image despite ssm_status=Success:**"
|
||||
echo ""
|
||||
echo "| Slug | Actual /buildinfo SHA | Expected | Status |"
|
||||
echo "|------|----------------------|----------|--------|"
|
||||
for line in "${STALE_LINES[@]}"; do echo "$line"; done
|
||||
echo ""
|
||||
fi
|
||||
if [ $UNREACHABLE_COUNT -gt 0 ]; then
|
||||
echo "**${UNREACHABLE_COUNT} unreachable tenant(s) — likely E2E teardown race (soft-warn, not failing):**"
|
||||
echo ""
|
||||
echo "| Slug | Actual /buildinfo SHA | Expected | Status |"
|
||||
echo "|------|----------------------|----------|--------|"
|
||||
for line in "${UNREACHABLE_LINES[@]}"; do echo "$line"; done
|
||||
echo ""
|
||||
fi
|
||||
if [ $STALE_COUNT -eq 0 ] && [ $UNREACHABLE_COUNT -eq 0 ]; then
|
||||
echo "All ${#SLUGS[@]} staging tenants returned matching SHA. ✓"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ $UNREACHABLE_COUNT -gt 0 ]; then
|
||||
echo "::warning::$UNREACHABLE_COUNT staging tenant(s) unreachable post-redeploy. Likely benign teardown race — CP healthz monitor catches real outages."
|
||||
fi
|
||||
|
||||
# Belt-and-suspenders sanity floor: if MORE than half the fleet is
|
||||
# unreachable AND the fleet is large enough that "half down" is
|
||||
# statistically meaningful, this is a real outage (e.g. new image
|
||||
# crashes on startup), not a teardown race. Hard-fail.
|
||||
#
|
||||
# Floor only applies when TOTAL_VERIFIED >= 4 — below that, the
|
||||
# canary-verify step is the actual gate for "all tenants down"
|
||||
# detection (it runs against the canary first and aborts the
|
||||
# rollout if the canary fails to come up). Without the >=4 gate,
|
||||
# a 1-tenant fleet (e.g. a single ephemeral e2e-* tenant on a
|
||||
# quiet staging push) would re-flake on the exact teardown-race
|
||||
# condition #2402 fixed: 1 of 1 unreachable = 100% > 50% → fail.
|
||||
TOTAL_VERIFIED=${#SLUGS[@]}
|
||||
if [ $TOTAL_VERIFIED -ge 4 ] && [ $UNREACHABLE_COUNT -gt $((TOTAL_VERIFIED / 2)) ]; then
|
||||
echo "::error::$UNREACHABLE_COUNT of $TOTAL_VERIFIED staging tenant(s) unreachable — exceeds 50% threshold on a fleet large enough that this signals a real outage, not teardown race."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ $STALE_COUNT -gt 0 ]; then
|
||||
echo "::error::$STALE_COUNT staging tenant(s) returned a stale SHA. ssm_status=Success was misleading — see job summary."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::notice::Staging tenant fleet redeploy complete — all reachable tenants on ${EXPECTED_SHA:0:7} (${UNREACHABLE_COUNT} unreachable, soft-warned)."
|
||||
@@ -1,105 +0,0 @@
|
||||
name: Retarget main PRs to staging
|
||||
|
||||
# Mechanical enforcement of SHARED_RULES rule 8 ("Staging-first workflow, no
|
||||
# exceptions"). When a bot opens a PR against main, retarget it to staging
|
||||
# automatically and leave an explanatory comment. Human CEO-authored PRs (the
|
||||
# staging→main promotion PR, etc.) are left alone — they're the authorised
|
||||
# exception to the rule.
|
||||
#
|
||||
# Why an Action instead of only a prompt rule: prompt rules depend on every
|
||||
# role's system-prompt.md staying in sync. Today 5 of 8 engineer roles
|
||||
# (core-be, core-fe, app-fe, app-qa, devops-engineer) don't have the
|
||||
# staging-first section — the bot keeps opening PRs to main. An Action
|
||||
# enforces the invariant regardless of prompt drift.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
retarget:
|
||||
name: Retarget to staging
|
||||
runs-on: ubuntu-latest
|
||||
# Only fire for bot-authored PRs. Human CEO PRs (staging→main promotion)
|
||||
# are intentional and pass through.
|
||||
#
|
||||
# Head-ref guard: never retarget a PR whose head IS `staging` — those
|
||||
# are the auto-promote staging→main PRs (opened by molecule-ai[bot]
|
||||
# since #2586 switched to an App token, which now passes the bot
|
||||
# filter below). Retargeting head=staging onto base=staging fails
|
||||
# with HTTP 422 "no new commits between base 'staging' and head
|
||||
# 'staging'", which used to surface as a noisy red workflow run on
|
||||
# every auto-promote (caught 2026-05-03 on PR #2588).
|
||||
if: >-
|
||||
github.event.pull_request.head.ref != 'staging'
|
||||
&& (
|
||||
github.event.pull_request.user.type == 'Bot'
|
||||
|| endsWith(github.event.pull_request.user.login, '[bot]')
|
||||
|| github.event.pull_request.user.login == 'app/molecule-ai'
|
||||
|| github.event.pull_request.user.login == 'molecule-ai[bot]'
|
||||
)
|
||||
steps:
|
||||
- name: Retarget PR base to staging
|
||||
id: retarget
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
# Issue #1884: when the bot opens a PR against main and there's
|
||||
# already another PR on the same head branch targeting staging,
|
||||
# GitHub's PATCH /pulls returns 422 with
|
||||
# "A pull request already exists for base branch 'staging' …".
|
||||
# The retarget can't proceed — but the right response is to
|
||||
# close the now-redundant main-PR, not to fail the workflow
|
||||
# noisily. Detect that specific 422 and close instead.
|
||||
run: |
|
||||
set +e
|
||||
echo "Retargeting PR #${PR_NUMBER} (author: ${PR_AUTHOR}) from main → staging"
|
||||
PATCH_OUTPUT=$(gh api -X PATCH \
|
||||
"repos/${{ github.repository }}/pulls/${PR_NUMBER}" \
|
||||
-f base=staging \
|
||||
--jq '.base.ref' 2>&1)
|
||||
PATCH_EXIT=$?
|
||||
set -e
|
||||
if [ "$PATCH_EXIT" -eq 0 ]; then
|
||||
echo "::notice::Retargeted PR #${PR_NUMBER} → staging"
|
||||
echo "outcome=retargeted" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
# Specifically match the 422 duplicate-base/head error so
|
||||
# any OTHER PATCH failure (auth, deleted PR, etc.) still
|
||||
# surfaces as a real workflow failure.
|
||||
if echo "$PATCH_OUTPUT" | grep -q "pull request already exists for base branch 'staging'"; then
|
||||
echo "::notice::PR #${PR_NUMBER}: duplicate target-staging PR exists on same head — closing this main-PR as redundant."
|
||||
gh pr close "$PR_NUMBER" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--comment "[retarget-bot] Closing — another PR on the same head branch already targets \`staging\`. This PR is redundant. See issue #1884 for the rationale."
|
||||
echo "outcome=closed-as-duplicate" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::Retarget PATCH failed and was NOT a duplicate-base error:"
|
||||
echo "$PATCH_OUTPUT" >&2
|
||||
exit 1
|
||||
|
||||
- name: Post explainer comment
|
||||
if: steps.retarget.outputs.outcome == 'retargeted'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
gh pr comment "$PR_NUMBER" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--body "$(cat <<'BODY'
|
||||
[retarget-bot] This PR was opened against `main` and has been retargeted to `staging` automatically.
|
||||
|
||||
**Why:** per [SHARED_RULES rule 8](https://github.com/molecule-ai/molecule-ai-org-template-molecule-dev/blob/main/SHARED_RULES.md), all feature work targets `staging` first; the CEO promotes `staging → main` separately.
|
||||
|
||||
**What changed:** just the base branch — no code change. CI will re-run against `staging`. If you get merge conflicts, rebase on `staging`.
|
||||
|
||||
**If this PR is the CEO's staging→main promotion:** the Action skipped you (only bot-authored PRs are retargeted). If you see this comment on your CEO PR, that's a bug — please tag @HongmingWang-Rabbit.
|
||||
BODY
|
||||
)"
|
||||
@@ -1,91 +0,0 @@
|
||||
name: Runtime Pin Compatibility
|
||||
|
||||
# CI gate that prevents the 5-hour staging outage from 2026-04-24 from
|
||||
# recurring (controlplane#253). The original failure mode:
|
||||
# 1. molecule-ai-workspace-runtime 0.1.13 declared `a2a-sdk<1.0` in its
|
||||
# requires_dist metadata (incorrect — it actually imports
|
||||
# a2a.server.routes which only exists in a2a-sdk 1.0+)
|
||||
# 2. `pip install molecule-ai-workspace-runtime` resolved cleanly
|
||||
# 3. `from molecule_runtime.main import main_sync` raised ImportError
|
||||
# 4. Every tenant workspace crashed; the canary tenant caught it but
|
||||
# only after 5 hours of degraded staging
|
||||
#
|
||||
# This workflow installs the CURRENTLY PUBLISHED runtime from PyPI on
|
||||
# top of `workspace/requirements.txt` and smoke-imports. Catches:
|
||||
# - Upstream PyPI yanks
|
||||
# - Bad re-releases of molecule-ai-workspace-runtime
|
||||
# - Already-shipped wheels that stop importing because a transitive
|
||||
# dep moved underneath
|
||||
#
|
||||
# This is the "PyPI artifact health" half of pin compatibility. The
|
||||
# companion workflow `runtime-prbuild-compat.yml` covers the
|
||||
# "PR-introduced breakage" half by building the wheel from THIS PR's
|
||||
# workspace/ source. Splitting the two means each gets a narrow
|
||||
# `paths:` filter — the pypi-latest job no longer fires on doc-only
|
||||
# workspace/ edits whose content can't change what's currently on PyPI.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
# Narrow filter: pypi-latest is sensitive only to changes that
|
||||
# affect what we're INSTALLING (requirements.txt) or WHAT THE
|
||||
# CHECK ITSELF DOES (this workflow file). Edits to workspace/
|
||||
# source code don't change what's on PyPI right now, so they
|
||||
# don't change this gate's verdict.
|
||||
- 'workspace/requirements.txt'
|
||||
- '.github/workflows/runtime-pin-compat.yml'
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- 'workspace/requirements.txt'
|
||||
- '.github/workflows/runtime-pin-compat.yml'
|
||||
# Daily catch for upstream PyPI publishes that break the pin combo
|
||||
# without any change in our repo (e.g. someone re-yanks an a2a-sdk
|
||||
# release or molecule-ai-workspace-runtime publishes a bad bump).
|
||||
schedule:
|
||||
- cron: '0 13 * * *' # 06:00 PT
|
||||
workflow_dispatch:
|
||||
# Required-check support: when this becomes a branch-protection gate,
|
||||
# merge_group runs let the queue green-check this in addition to PRs.
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
pypi-latest-install:
|
||||
name: PyPI-latest install + import smoke
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: pip
|
||||
cache-dependency-path: workspace/requirements.txt
|
||||
- name: Install runtime + workspace requirements
|
||||
# Install order is load-bearing: install the runtime FIRST so pip
|
||||
# honors whatever a2a-sdk constraint the runtime metadata declares
|
||||
# (this is the surface that broke in 2026-04-24 — runtime declared
|
||||
# `a2a-sdk<1.0` but actually needed >=1.0). The follow-up install
|
||||
# of workspace/requirements.txt then upgrades a2a-sdk to the
|
||||
# constraint our runtime image actually pins. The import smoke
|
||||
# below verifies the upgraded combination is consistent.
|
||||
run: |
|
||||
python -m venv /tmp/venv
|
||||
/tmp/venv/bin/pip install --upgrade pip
|
||||
/tmp/venv/bin/pip install molecule-ai-workspace-runtime
|
||||
/tmp/venv/bin/pip install -r workspace/requirements.txt
|
||||
/tmp/venv/bin/pip show molecule-ai-workspace-runtime a2a-sdk \
|
||||
| grep -E '^(Name|Version):'
|
||||
- name: Smoke import — fail if metadata declares deps that don't satisfy real imports
|
||||
# WORKSPACE_ID is validated at import time by platform_auth.py — EC2
|
||||
# user-data sets it from the cloud-init template; set a placeholder
|
||||
# here so the import smoke doesn't trip on the env-var guard.
|
||||
env:
|
||||
WORKSPACE_ID: 00000000-0000-0000-0000-000000000001
|
||||
run: |
|
||||
/tmp/venv/bin/python -c "from molecule_runtime.main import main_sync; print('runtime imports OK')"
|
||||
@@ -1,152 +0,0 @@
|
||||
name: Runtime PR-Built Compatibility
|
||||
|
||||
# Companion to `runtime-pin-compat.yml`. That workflow tests what's
|
||||
# CURRENTLY PUBLISHED on PyPI; this workflow tests what WOULD BE
|
||||
# PUBLISHED if THIS PR merges.
|
||||
#
|
||||
# Why two workflows: the chicken-and-egg #128 fix added a "PR-built
|
||||
# wheel" job to the original runtime-pin-compat.yml, but both jobs
|
||||
# shared a `paths:` filter that was the union of their needs
|
||||
# (`workspace/**`). That meant the PyPI-latest job ran on every doc
|
||||
# edit even though the upstream PyPI artifact can't change with our
|
||||
# workspace/ source. Splitting the two means each gets a narrow
|
||||
# `paths:` filter that matches the inputs it actually depends on.
|
||||
#
|
||||
# Catches the failure mode where a PR adds an import requiring a newer
|
||||
# SDK than `workspace/requirements.txt` pins:
|
||||
# 1. Pip resolves the existing PyPI wheel + the old SDK pin → smoke
|
||||
# passes (it imports the OLD main.py from the wheel, not the PR's
|
||||
# new main.py).
|
||||
# 2. Merge → publish-runtime.yml ships a wheel WITH the new import.
|
||||
# 3. Tenant images redeploy → all crash on first boot with
|
||||
# ImportError.
|
||||
#
|
||||
# By building from the PR's source and smoke-importing THAT wheel, we
|
||||
# fail at PR-time instead of after publish.
|
||||
#
|
||||
# Required-check shape (2026-05-01): the workflow runs on EVERY push +
|
||||
# PR + merge_group event with no top-level `paths:` filter, then uses a
|
||||
# detect-changes job + per-step `if:` gates inside ONE always-running
|
||||
# job named `PR-built wheel + import smoke`. PRs that don't touch
|
||||
# wheel-relevant paths get a no-op SUCCESS check run, satisfying branch
|
||||
# protection without re-running the heavy build. Same pattern as
|
||||
# e2e-api.yml — see its comment for the full rationale + the 2026-04-29
|
||||
# PR #2264 incident that motivated the always-run-with-if-gates shape.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
workflow_dispatch:
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
concurrency:
|
||||
# Include event_name so a PR sync (event=pull_request) and the
|
||||
# subsequent staging push (event=push) on the SAME merge SHA don't
|
||||
# collide in one group. Without event_name, both runs hashed to
|
||||
# the same key and cancel-in-progress=true cancelled whichever
|
||||
# arrived second — usually the push run, which staging branch-
|
||||
# protection then sees as a CANCELLED required check and refuses
|
||||
# to mark merged. Caught 2026-05-05 across PR #2869's runs (run
|
||||
# ids 25371863455 / 25371811486 / 25371078157 / 25370403142 — every
|
||||
# staging push run cancelled, every matching PR run green).
|
||||
#
|
||||
# Per memory `feedback_concurrency_group_per_sha.md` — same drift
|
||||
# class that broke auto-promote-staging on 2026-04-28. Pin invariant:
|
||||
# event_name + sha is the minimum unique key for these workflows.
|
||||
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
wheel: ${{ steps.decide.outputs.wheel }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
wheel:
|
||||
- 'workspace/**'
|
||||
- 'scripts/build_runtime_package.py'
|
||||
- 'scripts/wheel_smoke.py'
|
||||
- '.github/workflows/runtime-prbuild-compat.yml'
|
||||
- id: decide
|
||||
# Always run real work for manual dispatch + merge_group — no
|
||||
# diff-against-base in those contexts, and the gate exists to
|
||||
# validate the to-be-merged state regardless of which paths it
|
||||
# touched (paths-filter would default to "no changes" which is
|
||||
# the wrong answer when the queue is composing many PRs).
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ] || [ "${{ github.event_name }}" = "merge_group" ]; then
|
||||
echo "wheel=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "wheel=${{ steps.filter.outputs.wheel }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# ONE job (no job-level `if:`) that always runs and reports under the
|
||||
# required-check name `PR-built wheel + import smoke`. Real work is
|
||||
# gated per-step on `needs.detect-changes.outputs.wheel`. Same shape
|
||||
# as e2e-api.yml's e2e-api job — see its comment block for the full
|
||||
# rationale (SKIPPED check runs block branch protection even with
|
||||
# SUCCESS siblings; collapsing to one always-run job emits exactly
|
||||
# one SUCCESS check run).
|
||||
local-build-install:
|
||||
needs: detect-changes
|
||||
name: PR-built wheel + import smoke
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: No-op pass (paths filter excluded this commit)
|
||||
if: needs.detect-changes.outputs.wheel != 'true'
|
||||
run: |
|
||||
echo "No workspace/ / scripts/{build_runtime_package,wheel_smoke}.py / workflow changes — wheel gate satisfied without rebuilding."
|
||||
echo "::notice::PR-built wheel + import smoke no-op pass (paths filter excluded this commit)."
|
||||
- if: needs.detect-changes.outputs.wheel == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: needs.detect-changes.outputs.wheel == 'true'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: pip
|
||||
cache-dependency-path: workspace/requirements.txt
|
||||
- name: Install build tooling
|
||||
if: needs.detect-changes.outputs.wheel == 'true'
|
||||
run: pip install build
|
||||
- name: Build wheel from PR source (mirrors publish-runtime.yml)
|
||||
if: needs.detect-changes.outputs.wheel == 'true'
|
||||
# Use a fixed test version so the wheel filename is predictable.
|
||||
# Doesn't reach PyPI — this build is local-only for the smoke.
|
||||
# Use the SAME build script with the SAME args as
|
||||
# publish-runtime.yml's build step. The temp dir path differs
|
||||
# (`/tmp/runtime-build` here vs `${{ runner.temp }}/runtime-build`
|
||||
# in publish-runtime.yml — they coincide on ubuntu-latest but
|
||||
# the call sites are not byte-identical). The smoke import is
|
||||
# also intentionally narrower than publish's: this gate exists
|
||||
# to catch SDK-version-import drift specifically; full invariant
|
||||
# coverage lives in publish-runtime.yml's own pre-PyPI smoke.
|
||||
run: |
|
||||
python scripts/build_runtime_package.py \
|
||||
--version "0.0.0.dev0+pin-compat" \
|
||||
--out /tmp/runtime-build
|
||||
cd /tmp/runtime-build && python -m build
|
||||
- name: Install built wheel + workspace requirements
|
||||
if: needs.detect-changes.outputs.wheel == 'true'
|
||||
run: |
|
||||
python -m venv /tmp/venv-built
|
||||
/tmp/venv-built/bin/pip install --upgrade pip
|
||||
/tmp/venv-built/bin/pip install /tmp/runtime-build/dist/*.whl
|
||||
/tmp/venv-built/bin/pip install -r workspace/requirements.txt
|
||||
/tmp/venv-built/bin/pip show molecule-ai-workspace-runtime a2a-sdk \
|
||||
| grep -E '^(Name|Version):'
|
||||
- name: Smoke import the PR-built wheel
|
||||
if: needs.detect-changes.outputs.wheel == 'true'
|
||||
# Same script publish-runtime.yml runs against the to-be-PyPI wheel.
|
||||
# Closes the PR-time vs publish-time gap: a PR adding a new SDK
|
||||
# call-shape no longer passes here (narrow `import main_sync`) only
|
||||
# to fail post-merge in publish-runtime's broader smoke.
|
||||
run: |
|
||||
/tmp/venv-built/bin/python "$GITHUB_WORKSPACE/scripts/wheel_smoke.py"
|
||||
@@ -1,58 +0,0 @@
|
||||
name: SECRET_PATTERNS drift lint
|
||||
|
||||
# Detects when the canonical SECRET_PATTERNS array in
|
||||
# .github/workflows/secret-scan.yml diverges from known consumer
|
||||
# mirrors (workspace-runtime's bundled pre-commit hook today; more
|
||||
# can be added as the consumer set grows).
|
||||
#
|
||||
# Why this exists: every side that scans for credentials has its own
|
||||
# copy of the pattern list. They drift — most recently the runtime
|
||||
# hook lagged the canonical by one pattern (sk-cp- / MiniMax F1088),
|
||||
# so a developer's local pre-commit would let a sk-cp- token through
|
||||
# while the org-wide CI scan would refuse it. The cost of that drift
|
||||
# is dev confusion + delayed feedback; the fix is automated detection.
|
||||
#
|
||||
# Triggers:
|
||||
# - schedule: daily 05:00 UTC. Catches drift introduced by edits
|
||||
# to a consumer copy that didn't update canonical here.
|
||||
# - push to main/staging where the canonical or this lint changed:
|
||||
# catches the inverse — canonical updated but consumers not yet
|
||||
# bumped. The lint will fail the push; that's intentional, the
|
||||
# person editing canonical is the right person to also update
|
||||
# the consumer.
|
||||
# - workflow_dispatch: ad-hoc operator runs.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 05:00 UTC = 22:00 PT / 01:00 ET. Quiet hours so a failure
|
||||
# email lands when humans are starting their day, not
|
||||
# interrupting it.
|
||||
- cron: "0 5 * * *"
|
||||
push:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- ".github/workflows/secret-scan.yml"
|
||||
- ".github/workflows/secret-pattern-drift.yml"
|
||||
- ".github/scripts/lint_secret_pattern_drift.py"
|
||||
- ".githooks/pre-commit"
|
||||
workflow_dispatch:
|
||||
|
||||
# GITHUB_TOKEN scoped to read-only. The lint only does git checkout
|
||||
# + HTTPS GETs to public consumer files; no writes to anything.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Detect SECRET_PATTERNS drift
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Run drift lint
|
||||
run: python3 .github/scripts/lint_secret_pattern_drift.py
|
||||
@@ -1,214 +0,0 @@
|
||||
name: Secret scan
|
||||
|
||||
# Hard CI gate. Refuses any PR / push whose diff additions contain a
|
||||
# recognisable credential. Defense-in-depth for the #2090-class incident
|
||||
# (2026-04-24): GitHub's hosted Copilot Coding Agent leaked a ghs_*
|
||||
# installation token into tenant-proxy/package.json via `npm init`
|
||||
# slurping the URL from a token-embedded origin remote. We can't fix
|
||||
# upstream's clone hygiene, so we gate here.
|
||||
#
|
||||
# Also the canonical reusable workflow for the rest of the org. Other
|
||||
# Molecule-AI repos enroll with a single 3-line workflow:
|
||||
#
|
||||
# jobs:
|
||||
# secret-scan:
|
||||
# uses: molecule-ai/molecule-core/.github/workflows/secret-scan.yml@staging
|
||||
#
|
||||
# Pin to @staging not @main — staging is the active default branch,
|
||||
# main lags via the staging-promotion workflow. Updates ride along
|
||||
# automatically on the next consumer workflow run.
|
||||
#
|
||||
# Same regex set as the runtime's bundled pre-commit hook
|
||||
# (molecule-ai-workspace-runtime: molecule_runtime/scripts/pre-commit-checks.sh).
|
||||
# Keep the two sides aligned when adding patterns.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
push:
|
||||
branches: [main, staging]
|
||||
# Required for GitHub merge queue: the queue's pre-merge CI run on
|
||||
# `gh-readonly-queue/...` refs needs this check to fire so the queue
|
||||
# gets a real result instead of stalling forever AWAITING_CHECKS.
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
# Reusable workflow entry point for other Molecule-AI repos.
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: Scan diff for credential-shaped strings
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 2 # need previous commit to diff against on push events
|
||||
|
||||
# For pull_request events the diff base may be many commits behind
|
||||
# HEAD and absent from the shallow clone. Fetch it explicitly.
|
||||
- name: Fetch PR base SHA (pull_request events only)
|
||||
if: github.event_name == 'pull_request'
|
||||
run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
# For merge_group events the queue's pre-merge ref is a commit on
|
||||
# `gh-readonly-queue/...` whose parent is the queue's base_sha.
|
||||
# That parent isn't part of the queue branch's shallow clone, so
|
||||
# we fetch it explicitly. Without this the diff falls through to
|
||||
# "no BASE → scan entire tree" mode and false-positives on legit
|
||||
# test fixtures (e.g. canvas/src/lib/validation/__tests__/secret-formats.test.ts).
|
||||
- name: Fetch merge_group base SHA (merge_group events only)
|
||||
if: github.event_name == 'merge_group'
|
||||
run: git fetch --depth=1 origin ${{ github.event.merge_group.base_sha }}
|
||||
|
||||
- name: Refuse if credential-shaped strings appear in diff additions
|
||||
env:
|
||||
# Plumb event-specific SHAs through env so the script doesn't
|
||||
# need conditional `${{ ... }}` interpolation per event type.
|
||||
# github.event.before/after only exist on push events;
|
||||
# merge_group has its own base_sha/head_sha; pull_request has
|
||||
# pull_request.base.sha / pull_request.head.sha.
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
MG_BASE_SHA: ${{ github.event.merge_group.base_sha }}
|
||||
MG_HEAD_SHA: ${{ github.event.merge_group.head_sha }}
|
||||
PUSH_BEFORE: ${{ github.event.before }}
|
||||
PUSH_AFTER: ${{ github.event.after }}
|
||||
run: |
|
||||
# Pattern set covers GitHub family (the actual #2090 vector),
|
||||
# Anthropic / OpenAI / Slack / AWS. Anchored on prefixes with low
|
||||
# false-positive rates against agent-generated content. Mirror of
|
||||
# molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh
|
||||
# — keep aligned.
|
||||
SECRET_PATTERNS=(
|
||||
'ghp_[A-Za-z0-9]{36,}' # GitHub PAT (classic)
|
||||
'ghs_[A-Za-z0-9]{36,}' # GitHub App installation token
|
||||
'gho_[A-Za-z0-9]{36,}' # GitHub OAuth user-to-server
|
||||
'ghu_[A-Za-z0-9]{36,}' # GitHub OAuth user
|
||||
'ghr_[A-Za-z0-9]{36,}' # GitHub OAuth refresh
|
||||
'github_pat_[A-Za-z0-9_]{82,}' # GitHub fine-grained PAT
|
||||
'sk-ant-[A-Za-z0-9_-]{40,}' # Anthropic API key
|
||||
'sk-proj-[A-Za-z0-9_-]{40,}' # OpenAI project key
|
||||
'sk-svcacct-[A-Za-z0-9_-]{40,}' # OpenAI service-account key
|
||||
'sk-cp-[A-Za-z0-9_-]{60,}' # MiniMax API key (F1088 vector — caught only after the fact)
|
||||
'xox[baprs]-[A-Za-z0-9-]{20,}' # Slack tokens
|
||||
'AKIA[0-9A-Z]{16}' # AWS access key ID
|
||||
'ASIA[0-9A-Z]{16}' # AWS STS temp access key ID
|
||||
)
|
||||
|
||||
# Determine the diff base. Each event type stores its SHAs in
|
||||
# a different place — see the env block above.
|
||||
case "${{ github.event_name }}" in
|
||||
pull_request)
|
||||
BASE="$PR_BASE_SHA"
|
||||
HEAD="$PR_HEAD_SHA"
|
||||
;;
|
||||
merge_group)
|
||||
BASE="$MG_BASE_SHA"
|
||||
HEAD="$MG_HEAD_SHA"
|
||||
;;
|
||||
*)
|
||||
BASE="$PUSH_BEFORE"
|
||||
HEAD="$PUSH_AFTER"
|
||||
;;
|
||||
esac
|
||||
|
||||
# On push events with shallow clones, BASE may be present in
|
||||
# the event payload but absent from the local object DB
|
||||
# (fetch-depth=2 doesn't always reach the previous commit
|
||||
# across true merges). Try fetching it on demand. If the
|
||||
# fetch fails — e.g. the SHA was force-overwritten — we fall
|
||||
# through to the empty-BASE branch below, which scans the
|
||||
# entire tree as if every file were new. Correct, just slow.
|
||||
if [ -n "$BASE" ] && ! echo "$BASE" | grep -qE '^0+$'; then
|
||||
if ! git cat-file -e "$BASE" 2>/dev/null; then
|
||||
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Files added or modified in this change.
|
||||
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$' || ! git cat-file -e "$BASE" 2>/dev/null; then
|
||||
# New branch / no previous SHA / BASE unreachable — check the
|
||||
# entire tree as added content. Slower, but correct on first
|
||||
# push.
|
||||
CHANGED=$(git ls-tree -r --name-only HEAD)
|
||||
DIFF_RANGE=""
|
||||
else
|
||||
CHANGED=$(git diff --name-only --diff-filter=AM "$BASE" "$HEAD")
|
||||
DIFF_RANGE="$BASE $HEAD"
|
||||
fi
|
||||
|
||||
if [ -z "$CHANGED" ]; then
|
||||
echo "No changed files to inspect."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Self-exclude: this workflow file legitimately contains the
|
||||
# pattern strings as regex literals. Without an exclude it would
|
||||
# block its own merge.
|
||||
SELF=".github/workflows/secret-scan.yml"
|
||||
|
||||
OFFENDING=""
|
||||
# `while IFS= read -r` (not `for f in $CHANGED`) so filenames
|
||||
# containing whitespace don't word-split silently — a path
|
||||
# with a space would otherwise produce two iterations on
|
||||
# tokens that aren't real filenames, breaking the
|
||||
# self-exclude + diff lookup.
|
||||
while IFS= read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
[ "$f" = "$SELF" ] && continue
|
||||
if [ -n "$DIFF_RANGE" ]; then
|
||||
ADDED=$(git diff --no-color --unified=0 "$BASE" "$HEAD" -- "$f" 2>/dev/null | grep -E '^\+[^+]' || true)
|
||||
else
|
||||
# No diff range (new branch first push) — scan the full file
|
||||
# contents as if every line were new.
|
||||
ADDED=$(cat "$f" 2>/dev/null || true)
|
||||
fi
|
||||
[ -z "$ADDED" ] && continue
|
||||
for pattern in "${SECRET_PATTERNS[@]}"; do
|
||||
if echo "$ADDED" | grep -qE "$pattern"; then
|
||||
OFFENDING="${OFFENDING}${f} (matched: ${pattern})\n"
|
||||
break
|
||||
fi
|
||||
done
|
||||
done <<< "$CHANGED"
|
||||
|
||||
if [ -n "$OFFENDING" ]; then
|
||||
echo "::error::Credential-shaped strings detected in diff additions:"
|
||||
# `printf '%b' "$OFFENDING"` interprets backslash escapes
|
||||
# (the literal `\n` we appended above becomes a newline)
|
||||
# WITHOUT treating OFFENDING as a format string. Plain
|
||||
# `printf "$OFFENDING"` is a format-string sink: a filename
|
||||
# containing `%` would be interpreted as a conversion
|
||||
# specifier, corrupting the error message (or printing
|
||||
# `%(missing)` artifacts).
|
||||
printf '%b' "$OFFENDING"
|
||||
echo ""
|
||||
echo "The actual matched values are NOT echoed here, deliberately —"
|
||||
echo "round-tripping a leaked credential into CI logs widens the blast"
|
||||
echo "radius (logs are searchable + retained)."
|
||||
echo ""
|
||||
echo "Recovery:"
|
||||
echo " 1. Remove the secret from the file. Replace with an env var"
|
||||
echo " reference (e.g. \${{ secrets.GITHUB_TOKEN }} in workflows,"
|
||||
echo " process.env.X in code)."
|
||||
echo " 2. If the credential was already pushed (this PR's commit"
|
||||
echo " history reaches a public ref), treat it as compromised —"
|
||||
echo " ROTATE it immediately, do not just remove it. The token"
|
||||
echo " remains valid in git history forever and may be in any"
|
||||
echo " log/cache that consumed this branch."
|
||||
echo " 3. Force-push the cleaned commit (or stack a revert) and"
|
||||
echo " re-run CI."
|
||||
echo ""
|
||||
echo "If the match is a false positive (test fixture, docs example,"
|
||||
echo "or this workflow's own regex literals): use a clearly-fake"
|
||||
echo "placeholder like ghs_EXAMPLE_DO_NOT_USE that doesn't satisfy"
|
||||
echo "the length suffix, OR add the file path to the SELF exclude"
|
||||
echo "list in this workflow with a short reason."
|
||||
echo ""
|
||||
echo "Mirror of the regex set lives in the runtime's bundled"
|
||||
echo "pre-commit hook (molecule-ai-workspace-runtime:"
|
||||
echo "molecule_runtime/scripts/pre-commit-checks.sh) — keep aligned."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✓ No credential-shaped strings in this change."
|
||||
@@ -1,129 +0,0 @@
|
||||
name: Sweep stale AWS Secrets Manager secrets
|
||||
|
||||
# Janitor for per-tenant AWS Secrets Manager secrets
|
||||
# (`molecule/tenant/<org_id>/bootstrap`) whose backing tenant no
|
||||
# longer exists. Parallel-shape to sweep-cf-tunnels.yml and
|
||||
# sweep-cf-orphans.yml — different cloud, same justification.
|
||||
#
|
||||
# Why this exists separately from a long-term reconciler integration:
|
||||
# - molecule-controlplane's tenant_resources audit table (mig 024)
|
||||
# currently tracks four resource kinds: CloudflareTunnel,
|
||||
# CloudflareDNS, EC2Instance, SecurityGroup. SecretsManager is
|
||||
# not in the list, so the existing reconciler doesn't catch
|
||||
# orphan secrets.
|
||||
# - At ~$0.40/secret/month the cost grew to ~$19/month before this
|
||||
# sweeper was written, indicating ~45+ orphan secrets from
|
||||
# crashed provisions and incomplete deprovision flows.
|
||||
# - The proper fix (KindSecretsManagerSecret + recorder hook +
|
||||
# reconciler enumerator) is filed as a separate controlplane
|
||||
# issue. This sweeper is the immediate cost-relief stopgap.
|
||||
#
|
||||
# IAM principal: AWS_JANITOR_ACCESS_KEY_ID / AWS_JANITOR_SECRET_ACCESS_KEY.
|
||||
# This is a DEDICATED principal — the production `molecule-cp` IAM
|
||||
# user lacks `secretsmanager:ListSecrets` (it only has
|
||||
# Get/Create/Update/Delete on specific resources, scoped to its
|
||||
# operational needs). The janitor needs ListSecrets across the
|
||||
# `molecule/tenant/*` prefix, which warrants a separate principal so
|
||||
# we don't broaden the prod-CP policy.
|
||||
#
|
||||
# Safety: the script's MAX_DELETE_PCT gate (default 50%, mirroring
|
||||
# sweep-cf-orphans.yml — tenant secrets are durable by design, unlike
|
||||
# the mostly-orphan tunnels) refuses to nuke past the threshold.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Hourly at :30 — offsets from sweep-cf-orphans (:15) and
|
||||
# sweep-cf-tunnels (:45) so the three janitors don't burst the
|
||||
# CP admin endpoints at the same minute.
|
||||
- cron: '30 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Dry run only — list what would be deleted, no deletion"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
max_delete_pct:
|
||||
description: "Override safety gate (default 50, set higher only for major cleanup)"
|
||||
required: false
|
||||
default: "50"
|
||||
grace_hours:
|
||||
description: "Skip secrets created within this many hours (default 24)"
|
||||
required: false
|
||||
default: "24"
|
||||
|
||||
# Don't let two sweeps race the same AWS account.
|
||||
concurrency:
|
||||
group: sweep-aws-secrets
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
name: Sweep AWS Secrets Manager
|
||||
runs-on: ubuntu-latest
|
||||
# 30 min cap, mirroring the other janitors. AWS DeleteSecret is
|
||||
# fast (~0.3s/call) so even a 100+ backlog drains in seconds
|
||||
# under the 8-way xargs parallelism, but the cap is set generously
|
||||
# to leave headroom for any actual API hang.
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
AWS_REGION: ${{ secrets.AWS_REGION || 'us-east-1' }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_JANITOR_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_JANITOR_SECRET_ACCESS_KEY }}
|
||||
CP_PROD_ADMIN_TOKEN: ${{ secrets.CP_PROD_ADMIN_TOKEN }}
|
||||
CP_STAGING_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_TOKEN }}
|
||||
MAX_DELETE_PCT: ${{ github.event.inputs.max_delete_pct || '50' }}
|
||||
GRACE_HOURS: ${{ github.event.inputs.grace_hours || '24' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify required secrets present
|
||||
id: verify
|
||||
# Schedule-vs-dispatch behaviour split mirrors sweep-cf-orphans
|
||||
# and sweep-cf-tunnels (hardened 2026-04-28). Same principle:
|
||||
# - schedule → exit 1 on missing secrets (red CI surfaces it)
|
||||
# - workflow_dispatch → exit 0 with warning (operator-driven,
|
||||
# they already accepted the repo state)
|
||||
run: |
|
||||
missing=()
|
||||
for var in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY CP_PROD_ADMIN_TOKEN CP_STAGING_ADMIN_TOKEN; do
|
||||
if [ -z "${!var:-}" ]; then
|
||||
missing+=("$var")
|
||||
fi
|
||||
done
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "::warning::skipping sweep — secrets not configured: ${missing[*]}"
|
||||
echo "::warning::set them at Settings → Secrets and Variables → Actions, then rerun."
|
||||
echo "::warning::AWS_JANITOR_* must belong to a principal with secretsmanager:ListSecrets and secretsmanager:DeleteSecret on molecule/tenant/* (the prod molecule-cp principal lacks ListSecrets)."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::sweep cannot run — required secrets missing: ${missing[*]}"
|
||||
echo "::error::set them at Settings → Secrets and Variables → Actions, or disable this workflow."
|
||||
echo "::error::AWS_JANITOR_* must belong to a principal with secretsmanager:ListSecrets and secretsmanager:DeleteSecret on molecule/tenant/*."
|
||||
exit 1
|
||||
fi
|
||||
echo "All required secrets present ✓"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run sweep
|
||||
if: steps.verify.outputs.skip != 'true'
|
||||
# Schedule-vs-dispatch dry-run asymmetry mirrors sweep-cf-tunnels:
|
||||
# - Scheduled: input empty → "false" → --execute (the whole
|
||||
# point of an hourly janitor).
|
||||
# - Manual workflow_dispatch: input default true → dry-run;
|
||||
# operator must flip it to actually delete.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event.inputs.dry_run || 'false' }}" = "true" ]; then
|
||||
echo "Running in dry-run mode — no deletions"
|
||||
bash scripts/ops/sweep-aws-secrets.sh
|
||||
else
|
||||
echo "Running with --execute — will delete identified orphans"
|
||||
bash scripts/ops/sweep-aws-secrets.sh --execute
|
||||
fi
|
||||
@@ -1,146 +0,0 @@
|
||||
name: Sweep stale Cloudflare DNS records
|
||||
|
||||
# Janitor for Cloudflare DNS records whose backing tenant/workspace no
|
||||
# longer exists. Without this loop, every short-lived E2E or canary
|
||||
# leaves a CF record on the moleculesai.app zone — the zone has a
|
||||
# 200-record quota (controlplane#239 hit it 2026-04-23+) and provisions
|
||||
# start failing with code 81045 once exhausted.
|
||||
#
|
||||
# Why a separate workflow vs sweep-stale-e2e-orgs.yml:
|
||||
# - That workflow operates at the CP layer (DELETE /cp/admin/tenants/:slug
|
||||
# drives the cascade). It assumes CP has the org row to drive the
|
||||
# deprovision from. It doesn't catch records left behind when CP
|
||||
# itself never knew about the tenant (canary scratch, manual ops
|
||||
# experiments) or when the cascade's CF-delete branch failed.
|
||||
# - sweep-cf-orphans.sh enumerates the CF zone directly and matches
|
||||
# each record against live CP slugs + AWS EC2 names. It catches
|
||||
# leaks the CP-driven sweep can't.
|
||||
#
|
||||
# Safety: the script's own MAX_DELETE_PCT gate refuses to nuke more
|
||||
# than 50% of records in a single run. If something has gone weird
|
||||
# (CP admin endpoint returns no orgs → every tenant looks orphan) the
|
||||
# gate halts before damage. Decision-function unit tests in
|
||||
# scripts/ops/test_sweep_cf_decide.py (#2027) cover the rule
|
||||
# classifier.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Hourly. Mirrors sweep-stale-e2e-orgs cadence so the two janitors
|
||||
# converge on the same tick. CF API rate budget is generous (1200
|
||||
# req/5min); a single sweep makes ~1 list + N deletes (N<=quota/2).
|
||||
- cron: '15 * * * *' # offset from sweep-stale-e2e-orgs (top of hour)
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Dry run only — list what would be deleted, no deletion"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
max_delete_pct:
|
||||
description: "Override safety gate (default 50, set higher only for major cleanup)"
|
||||
required: false
|
||||
default: "50"
|
||||
# No `merge_group:` trigger on purpose. This is a janitor — it doesn't
|
||||
# need to gate merges, and including it as written before #2088 fired
|
||||
# the full sweep job (or its secret-check) on every PR going through
|
||||
# the merge queue, generating one red CI run per merge-queue eval. If
|
||||
# this workflow is ever wired up as a required check, re-add
|
||||
# merge_group: { types: [checks_requested] }
|
||||
# AND gate the sweep step with `if: github.event_name != 'merge_group'`
|
||||
# so merge-queue evals report success without actually running.
|
||||
|
||||
# Don't let two sweeps race the same zone. workflow_dispatch during a
|
||||
# scheduled run would otherwise issue duplicate DELETE calls.
|
||||
concurrency:
|
||||
group: sweep-cf-orphans
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
name: Sweep CF orphans
|
||||
runs-on: ubuntu-latest
|
||||
# 3 min surfaces hangs (CF API stall, AWS describe-instances stuck)
|
||||
# within one cron interval instead of burning a full tick. Realistic
|
||||
# worst case is ~2 min: 4 sequential curls + 1 aws + N×CF-DELETE
|
||||
# each individually capped at 10s by the script's curl -m flag.
|
||||
timeout-minutes: 3
|
||||
env:
|
||||
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||
CF_ZONE_ID: ${{ secrets.CF_ZONE_ID }}
|
||||
CP_PROD_ADMIN_TOKEN: ${{ secrets.CP_PROD_ADMIN_TOKEN }}
|
||||
CP_STAGING_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_TOKEN }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-2
|
||||
MAX_DELETE_PCT: ${{ github.event.inputs.max_delete_pct || '50' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify required secrets present
|
||||
id: verify
|
||||
# Schedule-vs-dispatch behaviour split (hardened 2026-04-28
|
||||
# after the silent-no-op incident below):
|
||||
#
|
||||
# The earlier soft-skip-on-schedule policy hid a real leak. All
|
||||
# six secrets were unset on this repo for an unknown duration;
|
||||
# every hourly run printed a yellow ::warning:: and exited 0,
|
||||
# so the workflow registered as "passing" while doing nothing.
|
||||
# CF orphans accumulated to 152/200 (~76% of the zone quota
|
||||
# gone) before a manual `dig`-driven audit caught it. Anything
|
||||
# that runs as a janitor and reports green while idle is
|
||||
# indistinguishable from "the janitor is healthy" — so we now
|
||||
# treat schedule (and any future workflow_run/push triggers)
|
||||
# as a hard-fail when secrets are missing.
|
||||
#
|
||||
# - schedule / workflow_run / push → exit 1 (red CI run
|
||||
# surfaces the misconfiguration the next tick)
|
||||
# - workflow_dispatch → exit 0 with a warning
|
||||
# (an operator ran this ad-hoc; they already accepted the
|
||||
# state of the repo and want the workflow to short-circuit
|
||||
# so they can rerun after fixing the secret)
|
||||
run: |
|
||||
missing=()
|
||||
for var in CF_API_TOKEN CF_ZONE_ID CP_PROD_ADMIN_TOKEN CP_STAGING_ADMIN_TOKEN AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
|
||||
if [ -z "${!var:-}" ]; then
|
||||
missing+=("$var")
|
||||
fi
|
||||
done
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "::warning::skipping sweep — secrets not configured: ${missing[*]}"
|
||||
echo "::warning::set them at Settings → Secrets and Variables → Actions, then rerun."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::sweep cannot run — required secrets missing: ${missing[*]}"
|
||||
echo "::error::set them at Settings → Secrets and Variables → Actions, or disable this workflow."
|
||||
echo "::error::a silent skip masked an active CF DNS leak (152/200 zone records) caught only by a manual audit on 2026-04-28; this gate exists to make the gap visible."
|
||||
exit 1
|
||||
fi
|
||||
echo "All required secrets present ✓"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run sweep
|
||||
if: steps.verify.outputs.skip != 'true'
|
||||
# Schedule-vs-dispatch dry-run asymmetry (intentional):
|
||||
# - Scheduled runs: github.event.inputs.dry_run is empty →
|
||||
# defaults to "false" below → script runs with --execute
|
||||
# (the whole point of an hourly janitor).
|
||||
# - Manual workflow_dispatch: input default is true (line 38)
|
||||
# so an ad-hoc operator-triggered run is dry-run by default;
|
||||
# they have to flip the toggle to actually delete.
|
||||
# The script's MAX_DELETE_PCT gate (default 50%) is the second
|
||||
# line of defense regardless of mode.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event.inputs.dry_run || 'false' }}" = "true" ]; then
|
||||
echo "Running in dry-run mode — no deletions"
|
||||
bash scripts/ops/sweep-cf-orphans.sh
|
||||
else
|
||||
echo "Running with --execute — will delete identified orphans"
|
||||
bash scripts/ops/sweep-cf-orphans.sh --execute
|
||||
fi
|
||||
@@ -1,124 +0,0 @@
|
||||
name: Sweep stale Cloudflare Tunnels
|
||||
|
||||
# Janitor for Cloudflare Tunnels whose backing tenant no longer
|
||||
# exists. Parallel-shape to sweep-cf-orphans.yml (which sweeps DNS
|
||||
# records); same justification, different CF resource.
|
||||
#
|
||||
# Why this exists separately from sweep-cf-orphans:
|
||||
# - DNS records live on the zone (`/zones/<id>/dns_records`).
|
||||
# - Tunnels live on the account (`/accounts/<id>/cfd_tunnel`).
|
||||
# - Different CF API surface, different scopes; the existing CF
|
||||
# token might not have `account:cloudflare_tunnel:edit`. Splitting
|
||||
# the workflows keeps each one's secret-presence gate independent
|
||||
# so neither silent-skips when the other's secret is missing.
|
||||
# - Cleaner blast radius — operators can disable one without the
|
||||
# other if a regression surfaces.
|
||||
#
|
||||
# Safety: the script's MAX_DELETE_PCT gate (default 90% — higher than
|
||||
# the DNS sweep's 50% because tenant-shaped tunnels are mostly
|
||||
# orphans by design) refuses to nuke past the threshold.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Hourly at :45 — offset from sweep-cf-orphans (:15) so the two
|
||||
# janitors don't issue parallel CF API bursts at the same minute.
|
||||
- cron: '45 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: "Dry run only — list what would be deleted, no deletion"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
max_delete_pct:
|
||||
description: "Override safety gate (default 90, set higher only for major cleanup)"
|
||||
required: false
|
||||
default: "90"
|
||||
|
||||
# Don't let two sweeps race the same account.
|
||||
concurrency:
|
||||
group: sweep-cf-tunnels
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
name: Sweep CF tunnels
|
||||
runs-on: ubuntu-latest
|
||||
# 30 min cap. Was 5 min on the theory that the only thing that
|
||||
# could take >5min is a CF-API hang — but on 2026-05-02 a backlog
|
||||
# of 672 stale tunnels accumulated (large staging E2E run + delayed
|
||||
# sweep) and the serial `curl -X DELETE` loop (~0.7s/tunnel) needed
|
||||
# ~7-8min to drain. The 5-min cap killed the run mid-sweep
|
||||
# (cancelled at 424/672, see run 25248788312); a manual rerun
|
||||
# finished the remainder fine.
|
||||
#
|
||||
# The fix is two-part: parallelize the delete loop (8-way xargs in
|
||||
# the script — see scripts/ops/sweep-cf-tunnels.sh), AND raise the
|
||||
# cap so a one-off backlog doesn't trip a hangs-detector that
|
||||
# turned out to be a real-job-too-slow detector. With 8-way
|
||||
# parallelism, 600+ tunnels drains in ~60s; 30 min is generous
|
||||
# headroom for actual hangs to still surface (and is in line with
|
||||
# the sweep-cf-orphans companion job).
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
CF_API_TOKEN: ${{ secrets.CF_API_TOKEN }}
|
||||
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
CP_PROD_ADMIN_TOKEN: ${{ secrets.CP_PROD_ADMIN_TOKEN }}
|
||||
CP_STAGING_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_TOKEN }}
|
||||
MAX_DELETE_PCT: ${{ github.event.inputs.max_delete_pct || '90' }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Verify required secrets present
|
||||
id: verify
|
||||
# Schedule-vs-dispatch behaviour split mirrors sweep-cf-orphans
|
||||
# (hardened 2026-04-28 after the silent-no-op incident: the
|
||||
# janitor reported green while doing nothing because secrets
|
||||
# were unset, masking a 152/200 zone-record leak). Same
|
||||
# principle applies here:
|
||||
# - schedule → exit 1 on missing secrets (red CI surfaces it)
|
||||
# - workflow_dispatch → exit 0 with warning (operator-driven,
|
||||
# they already accepted the repo state)
|
||||
run: |
|
||||
missing=()
|
||||
for var in CF_API_TOKEN CF_ACCOUNT_ID CP_PROD_ADMIN_TOKEN CP_STAGING_ADMIN_TOKEN; do
|
||||
if [ -z "${!var:-}" ]; then
|
||||
missing+=("$var")
|
||||
fi
|
||||
done
|
||||
if [ ${#missing[@]} -gt 0 ]; then
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "::warning::skipping sweep — secrets not configured: ${missing[*]}"
|
||||
echo "::warning::set them at Settings → Secrets and Variables → Actions, then rerun."
|
||||
echo "::warning::CF_API_TOKEN must include account:cloudflare_tunnel:edit scope (separate from the zone:dns:edit scope used by sweep-cf-orphans)."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::sweep cannot run — required secrets missing: ${missing[*]}"
|
||||
echo "::error::set them at Settings → Secrets and Variables → Actions, or disable this workflow."
|
||||
echo "::error::CF_API_TOKEN must include account:cloudflare_tunnel:edit scope."
|
||||
exit 1
|
||||
fi
|
||||
echo "All required secrets present ✓"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run sweep
|
||||
if: steps.verify.outputs.skip != 'true'
|
||||
# Schedule-vs-dispatch dry-run asymmetry mirrors sweep-cf-orphans:
|
||||
# - Scheduled: input empty → "false" → --execute (the whole
|
||||
# point of an hourly janitor).
|
||||
# - Manual workflow_dispatch: input default true → dry-run;
|
||||
# operator must flip it to actually delete.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ github.event.inputs.dry_run || 'false' }}" = "true" ]; then
|
||||
echo "Running in dry-run mode — no deletions"
|
||||
bash scripts/ops/sweep-cf-tunnels.sh
|
||||
else
|
||||
echo "Running with --execute — will delete identified orphans"
|
||||
bash scripts/ops/sweep-cf-tunnels.sh --execute
|
||||
fi
|
||||
@@ -1,239 +0,0 @@
|
||||
name: Sweep stale e2e-* orgs (staging)
|
||||
|
||||
# Janitor for staging tenants left behind when E2E cleanup didn't run:
|
||||
# CI cancellations, runner crashes, transient AWS errors mid-cascade,
|
||||
# bash trap missed (signal 9), etc. Without this loop, every failed
|
||||
# teardown leaks an EC2 + DNS + DB row until manual ops cleanup —
|
||||
# 2026-04-23 staging hit the 64 vCPU AWS quota from ~27 such orphans.
|
||||
#
|
||||
# Why not rely on per-test-run teardown:
|
||||
# - Per-run teardown is best-effort by definition. Any process death
|
||||
# after the test starts but before the trap fires leaves debris.
|
||||
# - GH Actions cancellation kills the runner without grace period.
|
||||
# The workflow's `if: always()` step usually catches this, but it
|
||||
# too can fail (CP transient 5xx, runner network issue at the
|
||||
# wrong moment).
|
||||
# - Even when teardown runs, the CP cascade is best-effort in places
|
||||
# (cascadeTerminateWorkspaces logs+continues; DNS deletion same).
|
||||
# - This sweep is the catch-all that converges staging back to clean
|
||||
# regardless of which specific path leaked.
|
||||
#
|
||||
# The PROPER fix is making CP cleanup transactional + verify-after-
|
||||
# terminate (filed separately as cleanup-correctness work). This
|
||||
# workflow is the safety net that catches everything else AND any
|
||||
# future leak source we haven't yet identified.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 15 min. E2E orgs are short-lived (~8-25 min wall clock from
|
||||
# create to teardown — canary is ~8 min, full SaaS ~25 min). The
|
||||
# previous hourly + 120-min stale threshold meant a leaked tenant
|
||||
# could keep an EC2 alive for up to 2 hours, eating ~2 vCPU per
|
||||
# leak. Tightening the cadence + threshold reduces the worst-case
|
||||
# leak window from 120 min to ~45 min (15-min sweep cadence + 30-min
|
||||
# threshold) without risk of catching in-progress runs (the longest
|
||||
# e2e run is the 25-min canary, well under the 30-min threshold).
|
||||
# See molecule-controlplane#420 for the leak-class accounting that
|
||||
# motivated this tightening.
|
||||
- cron: '*/15 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
max_age_minutes:
|
||||
description: "Delete e2e-* orgs older than N minutes (default 30)"
|
||||
required: false
|
||||
default: "30"
|
||||
dry_run:
|
||||
description: "Dry run only — list what would be deleted"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
# Don't let two sweeps fight. Cron + workflow_dispatch could overlap
|
||||
# on a manual trigger; queue rather than parallel-delete.
|
||||
concurrency:
|
||||
group: sweep-stale-e2e-orgs
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
name: Sweep e2e orgs
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
MOLECULE_CP_URL: https://staging-api.moleculesai.app
|
||||
ADMIN_TOKEN: ${{ secrets.MOLECULE_STAGING_ADMIN_TOKEN }}
|
||||
MAX_AGE_MINUTES: ${{ github.event.inputs.max_age_minutes || '30' }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||
# Refuse to delete more than this many orgs in one tick. If the
|
||||
# CP DB is briefly empty (or the admin endpoint goes weird and
|
||||
# returns no created_at), every e2e- org would look stale.
|
||||
# Bailing protects against runaway nukes.
|
||||
SAFETY_CAP: 50
|
||||
|
||||
steps:
|
||||
- name: Verify admin token present
|
||||
run: |
|
||||
if [ -z "$ADMIN_TOKEN" ]; then
|
||||
echo "::error::MOLECULE_STAGING_ADMIN_TOKEN not set"
|
||||
exit 2
|
||||
fi
|
||||
echo "Admin token present ✓"
|
||||
|
||||
- name: Identify stale e2e orgs
|
||||
id: identify
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Fetch into a file so the python step reads it via stdin —
|
||||
# cleaner than embedding $(curl ...) into a heredoc.
|
||||
curl -sS --fail-with-body --max-time 30 \
|
||||
"$MOLECULE_CP_URL/cp/admin/orgs?limit=500" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
> orgs.json
|
||||
|
||||
# Filter:
|
||||
# 1. slug starts with one of the ephemeral test prefixes:
|
||||
# - 'e2e-' — covers e2e-canary-, e2e-canvas-*, etc.
|
||||
# - 'rt-e2e-' — runtime-test harness fixtures (RFC #2251);
|
||||
# missing this prefix left two such tenants
|
||||
# orphaned 8h on staging (2026-05-03), then
|
||||
# hard-failed redeploy-tenants-on-staging
|
||||
# and broke the staging→main auto-promote
|
||||
# chain. Kept in sync with the EPHEMERAL_PREFIX_RE
|
||||
# regex in redeploy-tenants-on-staging.yml.
|
||||
# 2. created_at is older than MAX_AGE_MINUTES ago
|
||||
# Output one slug per line to a file the next step reads.
|
||||
python3 > stale_slugs.txt <<'PY'
|
||||
import json, os
|
||||
from datetime import datetime, timezone, timedelta
|
||||
# SSOT for this list lives in the controlplane Go code:
|
||||
# molecule-controlplane/internal/slugs/ephemeral.go
|
||||
# (var EphemeralPrefixes). The redeploy-fleet auto-rollout
|
||||
# also reads from there to SKIP these slugs — without that
|
||||
# filter, fleet redeploy SSM-failed in-flight E2E tenants
|
||||
# whose containers were still booting, breaking the test
|
||||
# that just spun them up (molecule-controlplane#493).
|
||||
# Update both files together.
|
||||
EPHEMERAL_PREFIXES = ("e2e-", "rt-e2e-")
|
||||
with open("orgs.json") as f:
|
||||
data = json.load(f)
|
||||
max_age = int(os.environ["MAX_AGE_MINUTES"])
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(minutes=max_age)
|
||||
for o in data.get("orgs", []):
|
||||
slug = o.get("slug", "")
|
||||
if not slug.startswith(EPHEMERAL_PREFIXES):
|
||||
continue
|
||||
created = o.get("created_at")
|
||||
if not created:
|
||||
# Defensively skip rows without created_at — better
|
||||
# to leave one orphan than nuke a brand-new row
|
||||
# whose timestamp didn't render.
|
||||
continue
|
||||
# Python 3.11+ handles RFC3339 with Z directly via
|
||||
# fromisoformat; older runners need the trailing Z swap.
|
||||
created_dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
|
||||
if created_dt < cutoff:
|
||||
print(slug)
|
||||
PY
|
||||
|
||||
count=$(wc -l < stale_slugs.txt | tr -d ' ')
|
||||
echo "Found $count stale e2e org(s) older than ${MAX_AGE_MINUTES}m"
|
||||
if [ "$count" -gt 0 ]; then
|
||||
echo "First 20:"
|
||||
head -20 stale_slugs.txt | sed 's/^/ /'
|
||||
fi
|
||||
echo "count=$count" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Safety gate
|
||||
if: steps.identify.outputs.count != '0'
|
||||
run: |
|
||||
count="${{ steps.identify.outputs.count }}"
|
||||
if [ "$count" -gt "$SAFETY_CAP" ]; then
|
||||
echo "::error::Refusing to delete $count orgs in one sweep (cap=$SAFETY_CAP). Investigate manually — this usually means the CP admin API returned no created_at or returned a degraded result. Re-run with workflow_dispatch + max_age_minutes if intentional."
|
||||
exit 1
|
||||
fi
|
||||
echo "Within safety cap ($count ≤ $SAFETY_CAP) ✓"
|
||||
|
||||
- name: Delete stale orgs
|
||||
if: steps.identify.outputs.count != '0' && env.DRY_RUN != 'true'
|
||||
run: |
|
||||
set -uo pipefail
|
||||
deleted=0
|
||||
failed=0
|
||||
while IFS= read -r slug; do
|
||||
[ -z "$slug" ] && continue
|
||||
# The DELETE handler requires {"confirm": "<slug>"} matching
|
||||
# the URL slug — fat-finger guard. Idempotent: re-issuing
|
||||
# picks up via org_purges.last_step.
|
||||
# Tempfile-routed -w + set +e/-e prevents curl-exit-code
|
||||
# pollution of the captured status (lint-curl-status-capture.yml).
|
||||
set +e
|
||||
curl -sS -o /tmp/del_resp -w "%{http_code}" \
|
||||
--max-time 60 \
|
||||
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"confirm\":\"$slug\"}" >/tmp/del_code
|
||||
set -e
|
||||
# Stderr from curl (-sS shows dial errors etc.) goes to runner log.
|
||||
http_code=$(cat /tmp/del_code 2>/dev/null || echo "000")
|
||||
if [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; then
|
||||
deleted=$((deleted+1))
|
||||
echo " deleted: $slug"
|
||||
else
|
||||
failed=$((failed+1))
|
||||
echo " FAILED ($http_code): $slug — $(cat /tmp/del_resp 2>/dev/null | head -c 200)"
|
||||
fi
|
||||
done < stale_slugs.txt
|
||||
echo ""
|
||||
echo "Sweep summary: deleted=$deleted failed=$failed"
|
||||
# Don't fail the workflow on per-org delete errors — the
|
||||
# sweeper is best-effort. Next hourly tick re-attempts. We
|
||||
# only fail loud at the safety-cap gate above.
|
||||
|
||||
- name: Sweep orphan tunnels
|
||||
# Stale-org cleanup deletes the org (which cascades to tunnel
|
||||
# delete inside the CP). But when that cascade fails partway —
|
||||
# CP transient 5xx after the org row is deleted but before the
|
||||
# CF tunnel delete completes — the tunnel persists with no
|
||||
# matching org row. The reconciler in internal/sweep flags this
|
||||
# as `cf_tunnel kind=orphan`, but nothing automatically reaps it.
|
||||
#
|
||||
# `/cp/admin/orphan-tunnels/cleanup` is the operator-triggered
|
||||
# reaper. Calling it here at the end of every sweep tick
|
||||
# converges the staging CF account to clean even when CP
|
||||
# cascades half-fail.
|
||||
#
|
||||
# PR #492 made the underlying DeleteTunnel actually check
|
||||
# status — pre-fix it silent-succeeded on CF code 1022
|
||||
# ("active connections"), so this step would have been a no-op
|
||||
# against stuck connectors. Post-fix the cleanup invokes
|
||||
# CleanupTunnelConnections + retry, which actually clears the
|
||||
# 1022 case. (#2987)
|
||||
#
|
||||
# Best-effort. Failure here doesn't fail the workflow — next
|
||||
# tick re-attempts. Errors flow to step output for ops review.
|
||||
if: env.DRY_RUN != 'true'
|
||||
run: |
|
||||
set +e
|
||||
curl -sS -o /tmp/cleanup_resp -w "%{http_code}" \
|
||||
--max-time 60 \
|
||||
-X POST "$MOLECULE_CP_URL/cp/admin/orphan-tunnels/cleanup" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" >/tmp/cleanup_code
|
||||
set -e
|
||||
http_code=$(cat /tmp/cleanup_code 2>/dev/null || echo "000")
|
||||
body=$(cat /tmp/cleanup_resp 2>/dev/null | head -c 500)
|
||||
if [ "$http_code" = "200" ]; then
|
||||
count=$(echo "$body" | python3 -c "import sys,json; d=json.loads(sys.stdin.read() or '{}'); print(d.get('deleted_count', 0))" 2>/dev/null || echo "0")
|
||||
failed_n=$(echo "$body" | python3 -c "import sys,json; d=json.loads(sys.stdin.read() or '{}'); print(len(d.get('failed') or {}))" 2>/dev/null || echo "0")
|
||||
echo "Orphan-tunnel sweep: deleted=$count failed=$failed_n"
|
||||
else
|
||||
echo "::warning::orphan-tunnels cleanup returned HTTP $http_code — body: $body"
|
||||
fi
|
||||
|
||||
- name: Dry-run summary
|
||||
if: env.DRY_RUN == 'true'
|
||||
run: |
|
||||
echo "DRY RUN — would have deleted ${{ steps.identify.outputs.count }} org(s) AND triggered orphan-tunnels cleanup. Re-run with dry_run=false to actually delete."
|
||||
@@ -1,52 +0,0 @@
|
||||
name: Ops Scripts Tests
|
||||
|
||||
# Runs the unittest suite for scripts/ on every PR + push that touches
|
||||
# anything under scripts/. Kept separate from the main CI so a script-only
|
||||
# change doesn't trigger the heavier Go/Canvas/Python pipelines.
|
||||
#
|
||||
# Discovery layout: tests sit alongside the code they test (see
|
||||
# scripts/ops/test_sweep_cf_decide.py for the pattern; scripts/
|
||||
# test_build_runtime_package.py for the rewriter coverage). The job
|
||||
# below runs `unittest discover` TWICE — once from `scripts/`, once
|
||||
# from `scripts/ops/` — because neither dir has an `__init__.py`, so
|
||||
# a single discover from `scripts/` doesn't recurse into the ops
|
||||
# subdir. Two passes is simpler than retrofitting namespace packages.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- 'scripts/**'
|
||||
- '.github/workflows/test-ops-scripts.yml'
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- 'scripts/**'
|
||||
- '.github/workflows/test-ops-scripts.yml'
|
||||
merge_group:
|
||||
types: [checks_requested]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Ops scripts (unittest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Run scripts/ unittests (build_runtime_package, …)
|
||||
# Top-level scripts/ tests live alongside their target file
|
||||
# (e.g. scripts/test_build_runtime_package.py exercises
|
||||
# scripts/build_runtime_package.py). discover from scripts/
|
||||
# picks up only top-level test_*.py because scripts/ops/ has
|
||||
# no __init__.py — that's intentional, so we run two passes.
|
||||
working-directory: scripts
|
||||
run: python -m unittest discover -t . -p 'test_*.py' -v
|
||||
- name: Run scripts/ops/ unittests (sweep_cf_decide, …)
|
||||
working-directory: scripts/ops
|
||||
run: python -m unittest discover -p 'test_*.py' -v
|
||||
+5
-24
@@ -117,33 +117,14 @@ backups/
|
||||
|
||||
# Cloned-via-manifest dirs — populated locally by scripts/clone-manifest.sh,
|
||||
# tracked in their own standalone repos. Never commit to core.
|
||||
# org-templates live in Molecule-AI/molecule-ai-org-template-* repos
|
||||
# (including molecule-dev — no checkin exception).
|
||||
# org-templates live in Molecule-AI/molecule-ai-org-template-* repos.
|
||||
# plugins live in Molecule-AI/molecule-ai-plugin-* repos.
|
||||
# All three directories are populated by scripts/clone-manifest.sh
|
||||
# (now auto-run by infra/scripts/setup.sh). The in-tree exception for
|
||||
# molecule-dev was removed because the checked-in copy drifted from
|
||||
# the standalone repo and shipped with broken !include references to
|
||||
# role files that never existed in the snapshot.
|
||||
/org-templates/
|
||||
# Exception: molecule-dev is checked in so it doubles as the internal-team
|
||||
# seed template (not fetched via clone-manifest).
|
||||
/org-templates/*
|
||||
!/org-templates/molecule-dev/
|
||||
/plugins/
|
||||
/workspace-configs-templates/
|
||||
# Cloned by publish-workspace-server-image.yml so the Dockerfile's
|
||||
# replace-directive path resolves. Lives in its own repo.
|
||||
/molecule-ai-plugin-github-app-auth/
|
||||
|
||||
# Internal-flavored content lives in Molecule-AI/internal — NEVER in this
|
||||
# public monorepo. Migrated 2026-04-23 (CEO directive). The CI workflow
|
||||
# .github/workflows/block-internal-paths.yml enforces this; this gitignore
|
||||
# is the second line of defence so accidental local writes don't reach a
|
||||
# commit. See docs/internal-content-policy.md for the full rationale.
|
||||
/research/
|
||||
/marketing/
|
||||
/docs/marketing/
|
||||
# Common temp/scratch patterns agents have produced
|
||||
/comment-*.json
|
||||
*-temp.md
|
||||
*-temp.txt
|
||||
/test-pmm-*.txt
|
||||
/tick-reflections-*.md
|
||||
tests/harness/cp-stub/cp-stub
|
||||
|
||||
+3
-58
@@ -12,29 +12,21 @@ development workflow, conventions, and how to get your changes merged.
|
||||
- **Python 3.11+** — workspace runtime
|
||||
- **Docker** — infrastructure services (Postgres, Redis)
|
||||
- **Git** — with hooks path set to `.githooks`
|
||||
- **jq** — parses `manifest.json` during `setup.sh` to clone the
|
||||
template/plugin registry. Install via `brew install jq` (macOS) or
|
||||
`apt install jq` (Debian). Without it, setup.sh prints a note and
|
||||
leaves the registry dirs empty (recoverable by installing jq and
|
||||
re-running).
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/Molecule-AI/molecule-core.git
|
||||
cd molecule-core
|
||||
git clone https://github.com/Molecule-AI/molecule-monorepo.git
|
||||
cd molecule-monorepo
|
||||
|
||||
# Install git hooks
|
||||
git config core.hooksPath .githooks
|
||||
|
||||
# Copy and edit .env (generate ADMIN_TOKEN + SECRETS_ENCRYPTION_KEY)
|
||||
cp .env.example .env
|
||||
|
||||
# Start infrastructure (Postgres, Redis, Langfuse, Temporal)
|
||||
./infra/scripts/setup.sh
|
||||
|
||||
# Build and run the platform — applies pending migrations on first boot
|
||||
# Build and run the platform
|
||||
cd workspace-server
|
||||
go run ./cmd/server
|
||||
|
||||
@@ -53,29 +45,6 @@ cp .env.example .env
|
||||
|
||||
See `CLAUDE.md` for a full list of environment variables and their purposes.
|
||||
|
||||
## What goes where (content vs code)
|
||||
|
||||
This repo is scoped to **code** (canvas, workspace, workspace-server, related
|
||||
infra). Public content (blog posts, marketing copy, OG images, SEO briefs,
|
||||
DevRel demos) lives in [`Molecule-AI/docs`](https://github.com/Molecule-AI/docs).
|
||||
The `Block forbidden paths` CI gate fails any PR that writes to `marketing/`
|
||||
or other removed paths — open against `Molecule-AI/docs` instead.
|
||||
|
||||
| Content type | Target |
|
||||
|---|---|
|
||||
| Blog posts | `Molecule-AI/docs` → `content/blog/<YYYY-MM-DD-slug>/` |
|
||||
| Doc pages | `Molecule-AI/docs` → `content/docs/` |
|
||||
| Marketing copy / PMM positioning | `Molecule-AI/docs` → `marketing/` |
|
||||
| OG images, visual assets | `Molecule-AI/docs` → `app/` or `marketing/` |
|
||||
| SEO briefs | `Molecule-AI/docs` → `marketing/` |
|
||||
| DevRel demos (runnable code) | Standalone repo under `Molecule-AI/`, OR embedded in `Molecule-AI/docs` |
|
||||
| Launch checklists, internal tracking | GitHub Issues — **not** committed files |
|
||||
| Engineering docs (`docs/adr/`, `docs/architecture/`, `docs/incidents/`) | This repo (internal, not published) |
|
||||
| Live product pages (e.g. `canvas/src/app/pricing/page.tsx`) | This repo (these are app code, not marketing copy) |
|
||||
|
||||
If a PR fails the `Block forbidden paths` check, the contents belong in
|
||||
`Molecule-AI/docs`. No CI drag, no Canvas E2E, content lands in minutes.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Branch Naming
|
||||
@@ -104,19 +73,6 @@ causing a render loop when any node position changed.
|
||||
- Include a test plan in the PR description
|
||||
- PRs are merged with **merge commits** (not squash or rebase)
|
||||
|
||||
#### Auto-merge & the "extra commit" trap
|
||||
|
||||
**Two system guards protect against pushing commits after auto-merge has been enabled.** Don't try to work around them — they exist because we shipped a half-merged PR on 2026-04-27 (`#2174` merged with only its first commit; the second was orphaned on a branch GitHub had already deleted).
|
||||
|
||||
1. **Repo-wide:** "Automatically delete head branches" is on. Once a PR merges, the branch is deleted server-side. Any subsequent `git push` to that branch fails with `remote rejected — no such branch`.
|
||||
|
||||
2. **CI:** the `pr-guards` workflow (calling [molecule-ci `disable-auto-merge-on-push`](https://github.com/Molecule-AI/molecule-ci/blob/main/.github/workflows/disable-auto-merge-on-push.yml)) fires on every push to an open PR. If auto-merge was already enabled, it's disabled and a comment is posted. You must explicitly re-enable after verifying the new commit.
|
||||
|
||||
**Workflow rules that follow from the guards:**
|
||||
- Push **all** commits before running `gh pr merge --auto`.
|
||||
- If you realize you need another commit after enabling auto-merge: push it, then **re-run** `gh pr merge --auto` — the guard will already have disabled it. The disable + re-enable is the verification step.
|
||||
- For changes that depend on each other across PRs (e.g. a build-script change + a workflow that consumes it), prefer a **stack** of PRs (PR-B branched off PR-A's branch, opened only after PR-A is in queue) over amending one in-flight PR.
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
@@ -175,17 +131,6 @@ and run CI manually.
|
||||
- Type hints on public functions
|
||||
- pytest for all tests
|
||||
|
||||
## External integrations
|
||||
|
||||
Code in this repo lands in molecule-core. Some related runtime artifacts
|
||||
live in their own repos:
|
||||
|
||||
- [`Molecule-AI/molecule-ai-workspace-runtime`](https://github.com/Molecule-AI/molecule-ai-workspace-runtime) — Python adapter SDK (`molecule_runtime`) that runs inside containerized Molecule workspaces. Bridges Claude Code SDK / hermes / langgraph / etc. → A2A queue.
|
||||
- [`Molecule-AI/molecule-sdk-python`](https://github.com/Molecule-AI/molecule-sdk-python) — `A2AServer` + `RemoteAgentClient` for external agents that register over the public `/registry/register` flow.
|
||||
- [`Molecule-AI/molecule-mcp-claude-channel`](https://github.com/Molecule-AI/molecule-mcp-claude-channel) — Claude Code channel plugin. Bridges A2A traffic into a running Claude Code session via MCP `notifications/claude/channel`. Polling-based (no tunnel required); install with `claude --channels plugin:molecule@Molecule-AI/molecule-mcp-claude-channel`.
|
||||
|
||||
When extending the **A2A surface** in molecule-core (`workspace-server/internal/handlers/a2a_proxy.go` etc.), consider whether the change has a downstream impact on the runtime SDK or the channel plugin — they're versioned independently but share the wire shape.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
See `CLAUDE.md` for detailed architecture documentation, including:
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
# Coverage Floor
|
||||
|
||||
CI enforces coverage gates on two surfaces — `workspace-server` (Go) and
|
||||
`workspace/` (Python). All defined in `.github/workflows/ci.yml`.
|
||||
|
||||
## Current floors (2026-04-23)
|
||||
|
||||
| Gate | Threshold | What fails |
|
||||
|---|---|---|
|
||||
| **Total floor** | `25%` | `go tool cover -func` reports total below floor |
|
||||
| **Critical-path per-file floor** | `10%` | Any non-test source file in a security-critical path with coverage ≤10% |
|
||||
| **Per-file report** | advisory | Printed in CI log, sorted worst-first, does not fail |
|
||||
|
||||
Total floor starts at 25% (unchanged from pre-#1823 to keep this PR strictly
|
||||
additive). The new protection is the critical-path per-file floor, which
|
||||
directly closes the gap that prompted the issue. Ratchet plan below begins
|
||||
the month after to let the team first observe the gate in action.
|
||||
|
||||
## Security-critical paths (Gate 2)
|
||||
|
||||
Changes to these paths have historically introduced security issues (CWE-22,
|
||||
CWE-78, KI-005, SSRF) or billing/auth risk. Coverage must not drop to zero.
|
||||
|
||||
- `internal/handlers/tokens*`
|
||||
- `internal/handlers/workspace_provision*`
|
||||
- `internal/handlers/a2a_proxy*`
|
||||
- `internal/handlers/registry*`
|
||||
- `internal/handlers/secrets*`
|
||||
- `internal/middleware/wsauth*`
|
||||
- `internal/crypto*`
|
||||
|
||||
## Ratchet plan
|
||||
|
||||
Floor ratchets upward on a fixed cadence. Any ratchet is a PR — reviewable,
|
||||
reversible, and creates history. The table below is the intended schedule.
|
||||
|
||||
| Date | Total floor | Critical-path floor | Notes |
|
||||
|---|---|---|---|
|
||||
| 2026-04-23 | 25% | 10% | Initial gate (this file). |
|
||||
| 2026-05-23 | 30% | 20% | First ratchet |
|
||||
| 2026-06-23 | 40% | 30% | |
|
||||
| 2026-07-23 | 50% | 40% | |
|
||||
| 2026-08-23 | 55% | 50% | |
|
||||
| 2026-09-23 | 60% | 60% | |
|
||||
| 2026-10-23 | 70% | 70% | Target steady-state |
|
||||
|
||||
The target end-state matches the per-role QA prompts which specify
|
||||
"coverage >80% on changed files". CI enforces the floor; reviewers still
|
||||
enforce the per-PR bar.
|
||||
|
||||
## Exceptions
|
||||
|
||||
If a critical-path file genuinely cannot have coverage above the floor (e.g.
|
||||
thin wrapper around a third-party SDK with no branches to test), add an entry
|
||||
here with:
|
||||
|
||||
1. **File**: `internal/handlers/example.go`
|
||||
2. **Reason**: Why coverage can't hit the floor
|
||||
3. **Tracking issue**: GitHub issue for the real fix
|
||||
4. **Expiry**: 14 days from entry date; after expiry either coverage is fixed
|
||||
or the issue is closed as "accepted technical debt"
|
||||
|
||||
### Active exceptions
|
||||
|
||||
*(none — add here if you need to land code that legitimately can't clear the floor)*
|
||||
|
||||
## Why this gate exists
|
||||
|
||||
Issue #1823: an external audit found critical files at 0% coverage despite
|
||||
test files existing with hundreds of lines. The existing CI step measured
|
||||
coverage but didn't enforce a meaningful threshold. Any file could go from
|
||||
80% → 0% and CI stayed green, because the single gate (total ≥25%) ignored
|
||||
per-file distribution.
|
||||
|
||||
This gate makes "no untested critical paths merged" a mechanical property of
|
||||
the CI, not a behavioural property of QA agents or individual reviewers —
|
||||
which is the only way to make it survive fleet outages, agent rotations, or
|
||||
QA process changes.
|
||||
|
||||
## Python (workspace/) — added 2026-05-04 from #2790
|
||||
|
||||
The Python side has its own gates in the `python-lint` job:
|
||||
|
||||
| Gate | Threshold | Where |
|
||||
|---|---|---|
|
||||
| **Total floor** | `86%` | `workspace/pytest.ini` `--cov-fail-under=86` (issue #1817) |
|
||||
| **Critical-path per-file floor** | `75%` | Inline shell step after the pytest run |
|
||||
|
||||
### Critical-path Python files
|
||||
|
||||
These handle multi-tenant routing, auth tokens, and inbox dispatch. A
|
||||
coverage drop here is the same risk shape as a Go-side `tokens*` /
|
||||
`secrets*` file regressing below 10%.
|
||||
|
||||
- `workspace/a2a_mcp_server.py` — MCP dispatcher (PR #2766 / #2771)
|
||||
- `workspace/mcp_cli.py` — molecule-mcp standalone CLI entry
|
||||
- `workspace/a2a_tools.py` — workspace-scoped tool implementations
|
||||
- `workspace/inbox.py` — multi-workspace inbox + per-workspace cursors
|
||||
- `workspace/platform_auth.py` — per-workspace token resolver
|
||||
|
||||
### Why 75% (vs 86% total)
|
||||
|
||||
The total floor averages ~6000 lines across `workspace/`. A single MCP
|
||||
file could drop to ~50% with no CI complaint as long as other modules
|
||||
compensate. The per-file floor closes that distribution gap. 75% sits
|
||||
below current actuals (80–96% as of 2026-05-04) — strictly additive,
|
||||
no existing PR fails.
|
||||
|
||||
### Python ratchet plan
|
||||
|
||||
| Date | Total | Per-file critical | Notes |
|
||||
|---|---|---|---|
|
||||
| 2026-05-04 | 86% | 75% | Initial gate (this file). |
|
||||
| 2026-06-04 | 86% | 80% | First ratchet — at-floor files must catch up. |
|
||||
| 2026-07-04 | 88% | 85% | |
|
||||
| 2026-08-04 | 90% | 90% | Target steady-state. |
|
||||
|
||||
### Why this Python gate exists
|
||||
|
||||
Issue #2790, after the PR #2766 → PR #2771 cycle. PR #2766 added
|
||||
multi-workspace routing through `a2a_tools.py` + `a2a_mcp_server.py`,
|
||||
shipped to main with green CI, but the dispatcher silently dropped a
|
||||
load-bearing kwarg for 4 of 9 tools — caught only by post-merge code
|
||||
review. The structural drift gate (`test_dispatcher_schema_drift.py`,
|
||||
PR #2791) catches the schema↔dispatcher mismatch class; this floor
|
||||
catches the broader "MCP-critical file regressed" class.
|
||||
@@ -252,24 +252,14 @@ Workspace Runtime (Python image with adapters)
|
||||
git clone https://github.com/Molecule-AI/molecule-monorepo.git
|
||||
cd molecule-monorepo
|
||||
|
||||
cp .env.example .env
|
||||
# Defaults boot the stack locally out of the box. See .env.example for
|
||||
# production hardening knobs (ADMIN_TOKEN, SECRETS_ENCRYPTION_KEY, etc.).
|
||||
|
||||
./infra/scripts/setup.sh
|
||||
# Boots Postgres (:5432), Redis (:6379), Langfuse (:3001),
|
||||
# and Temporal (:7233 gRPC, :8233 UI) on the shared
|
||||
# `molecule-monorepo-net` Docker network. Temporal runs with
|
||||
# no auth on localhost — dev-only; production must gate it.
|
||||
#
|
||||
# Also populates the template/plugin registry by cloning every repo
|
||||
# listed in manifest.json into workspace-configs-templates/,
|
||||
# org-templates/, and plugins/. Requires jq — install via
|
||||
# `brew install jq` (macOS) or `apt install jq` (Debian). Idempotent:
|
||||
# re-runs skip any target dir that's already populated.
|
||||
|
||||
cd workspace-server
|
||||
go run ./cmd/server # applies pending migrations on first boot
|
||||
go run ./cmd/server
|
||||
|
||||
cd ../canvas
|
||||
npm install
|
||||
@@ -294,10 +284,6 @@ Then open `http://localhost:3000`:
|
||||
- [Workspace Runtime](./docs/agent-runtime/workspace-runtime.md)
|
||||
- [Canvas UI](./docs/frontend/canvas.md)
|
||||
- [Local Development](./docs/development/local-development.md)
|
||||
- [Backend Parity Matrix](./docs/architecture/backends.md) — Docker vs EC2 feature parity tracker
|
||||
- [Testing Strategy](./docs/engineering/testing-strategy.md) — tiered coverage floors, not blanket 100%
|
||||
- [PR Hygiene](./docs/engineering/pr-hygiene.md) — small PRs, clean branches, cherry-pick on drift
|
||||
- [Engineering Postmortems](./docs/engineering/) — architecture + testing lessons from real incidents
|
||||
- [Ecosystem Watch](./docs/ecosystem-watch.md) — adjacent projects we track (Holaboss, Hermes, gstack, …)
|
||||
- [Glossary](./docs/glossary.md) — how we use "harness", "workspace", "plugin", "flow" vs. ecosystem neighbors
|
||||
|
||||
|
||||
+5
-14
@@ -38,8 +38,8 @@
|
||||
<a href="./docs/agent-runtime/workspace-runtime.md"><strong>Workspace Runtime</strong></a>
|
||||
</p>
|
||||
|
||||
[](https://railway.app/new/template?template=https://github.com/Molecule-AI/molecule-core)
|
||||
[](https://render.com/deploy?repo=https://github.com/Molecule-AI/molecule-core)
|
||||
[](https://railway.app/new/template?template=https://github.com/Molecule-AI/molecule-monorepo)
|
||||
[](https://render.com/deploy?repo=https://github.com/Molecule-AI/molecule-monorepo)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -248,26 +248,17 @@ Workspace Runtime (Python image with adapters)
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Molecule-AI/molecule-core.git
|
||||
cd molecule-core
|
||||
|
||||
cp .env.example .env
|
||||
# 默认值即可在本地启动整套服务。.env.example 里有针对生产部署的
|
||||
# 安全配置说明(ADMIN_TOKEN、SECRETS_ENCRYPTION_KEY 等)。
|
||||
git clone https://github.com/Molecule-AI/molecule-monorepo.git
|
||||
cd molecule-monorepo
|
||||
|
||||
./infra/scripts/setup.sh
|
||||
# 启动 Postgres (:5432)、Redis (:6379)、Langfuse (:3001)
|
||||
# 以及 Temporal (:7233 gRPC, :8233 UI),全部挂在共享的
|
||||
# `molecule-monorepo-net` Docker 网络上。Temporal 默认无鉴权,
|
||||
# 仅用于本地开发;生产环境必须加 mTLS / API Key。
|
||||
#
|
||||
# 同时会根据 manifest.json 拉取所有模板/插件仓库到
|
||||
# workspace-configs-templates/、org-templates/、plugins/ 三个目录。
|
||||
# 需要安装 jq:`brew install jq`(macOS)或 `apt install jq`(Debian)。
|
||||
# 脚本幂等:已经存在内容的目录会被跳过,可以安全重跑。
|
||||
|
||||
cd workspace-server
|
||||
go run ./cmd/server # 首次启动会自动跑 schema_migrations 里未应用的迁移
|
||||
go run ./cmd/server
|
||||
|
||||
cd ../canvas
|
||||
npm install
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine AS builder
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
@@ -11,7 +11,7 @@ ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
|
||||
ENV NEXT_PUBLIC_ADMIN_TOKEN=$NEXT_PUBLIC_ADMIN_TOKEN
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
@@ -20,7 +20,7 @@ COPY --from=builder /app/public ./public
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
# Non-root runtime — use addgroup/adduser without fixed GID/UID to avoid conflicts with base image
|
||||
RUN addgroup canvas 2>/dev/null || true && adduser -G canvas -s /bin/sh -D canvas 2>/dev/null || true
|
||||
# Non-root runtime — node image defaults to root, explicitly drop.
|
||||
RUN addgroup -g 1000 canvas && adduser -u 1000 -G canvas -s /bin/sh -D canvas
|
||||
USER canvas
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": false
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
|
||||
@@ -1,293 +0,0 @@
|
||||
/**
|
||||
* Playwright global setup for the staging canvas E2E.
|
||||
*
|
||||
* Provisions a fresh staging org per run (POST /cp/admin/orgs), fetches
|
||||
* the per-tenant admin token, provisions one hermes workspace, waits
|
||||
* for online, then exports:
|
||||
*
|
||||
* STAGING_TENANT_URL https://<slug>.staging.moleculesai.app
|
||||
* STAGING_WORKSPACE_ID UUID of the hermes workspace
|
||||
* STAGING_TENANT_TOKEN per-tenant admin bearer (for spec requests)
|
||||
* STAGING_SLUG org slug (used by teardown)
|
||||
*
|
||||
* Required env:
|
||||
* MOLECULE_CP_URL default: https://staging-api.moleculesai.app
|
||||
* MOLECULE_ADMIN_TOKEN CP admin bearer (Railway staging
|
||||
* CP_ADMIN_API_TOKEN). Drives provision +
|
||||
* tenant-token retrieval + teardown via a
|
||||
* single credential.
|
||||
* STAGING_TENANT_DOMAIN default: staging.moleculesai.app — the
|
||||
* DNS suffix the CP provisioner writes for
|
||||
* staging tenants. Override only when
|
||||
* running this harness against a non-default
|
||||
* zone.
|
||||
*/
|
||||
|
||||
import type { FullConfig } from "@playwright/test";
|
||||
import { writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const CP_URL = process.env.MOLECULE_CP_URL || "https://staging-api.moleculesai.app";
|
||||
const ADMIN_TOKEN = process.env.MOLECULE_ADMIN_TOKEN;
|
||||
const STAGING = process.env.CANVAS_E2E_STAGING === "1";
|
||||
// Tenant DNS zone for staging. CP provisioner registers DNS as
|
||||
// `<slug>.staging.moleculesai.app` (see internal/provisioner/ec2.go's
|
||||
// EC2 provisioner: DNS log line). The previous default of plain
|
||||
// `moleculesai.app` matched prod tenant naming and silently broke
|
||||
// every staging E2E at the TLS readiness step — DNS literally didn't
|
||||
// resolve, fetch threw NXDOMAIN, waitFor saw null on every poll, and
|
||||
// the harness wedged at TLS_TIMEOUT_MS instead of failing loud.
|
||||
const TENANT_DOMAIN = process.env.STAGING_TENANT_DOMAIN || "staging.moleculesai.app";
|
||||
|
||||
// Tenant cold boot on staging regularly takes 12-15 min when the
|
||||
// workspace-server Docker image isn't already cached on the AMI. Raised
|
||||
// to 20 min to match tests/e2e/test_staging_full_saas.sh (PR #1930)
|
||||
// after repeated "tenant provision: timed out after 900s" flakes
|
||||
// were blocking staging→main syncs on 2026-04-24.
|
||||
const PROVISION_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
const WORKSPACE_ONLINE_TIMEOUT_MS = 20 * 60 * 1000;
|
||||
|
||||
// TLS readiness depends on (1) Cloudflare DNS propagation through the
|
||||
// edge, (2) the tenant's CF Tunnel registering the new hostname, (3)
|
||||
// CF's edge ACME cert provisioning + cache. Each of these layers can
|
||||
// add 1-3 min on its own under heavy staging load. Bumped 10→15 min
|
||||
// after a burst of canary failures correlated with CP changes (#2090).
|
||||
// Stays below the 20-min PROVISION_TIMEOUT envelope so a genuinely-
|
||||
// stuck tenant fails-loud at the provision step rather than
|
||||
// masquerading as a TLS issue. Kept aligned with
|
||||
// tests/e2e/test_staging_full_saas.sh.
|
||||
const TLS_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
async function jsonFetch(
|
||||
url: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<{ status: number; body: any }> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...(init.headers || {}) },
|
||||
});
|
||||
let body: any = null;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
/* non-JSON */
|
||||
}
|
||||
return { status: res.status, body };
|
||||
}
|
||||
|
||||
async function waitFor<T>(
|
||||
op: () => Promise<T | null>,
|
||||
deadlineMs: number,
|
||||
intervalMs: number,
|
||||
desc: string,
|
||||
): Promise<T> {
|
||||
const deadline = Date.now() + deadlineMs;
|
||||
while (Date.now() < deadline) {
|
||||
const v = await op();
|
||||
if (v !== null) return v;
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
throw new Error(`${desc}: timed out after ${Math.round(deadlineMs / 1000)}s`);
|
||||
}
|
||||
|
||||
function makeSlug(): string {
|
||||
const y = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
const rand = Math.random().toString(36).slice(2, 8);
|
||||
return `e2e-canvas-${y}-${rand}`.slice(0, 32);
|
||||
}
|
||||
|
||||
export default async function globalSetup(_config: FullConfig): Promise<void> {
|
||||
if (!STAGING) {
|
||||
console.log("[staging-setup] CANVAS_E2E_STAGING not set, skipping");
|
||||
return;
|
||||
}
|
||||
if (!ADMIN_TOKEN) {
|
||||
throw new Error(
|
||||
"MOLECULE_ADMIN_TOKEN required (Railway staging CP_ADMIN_API_TOKEN)",
|
||||
);
|
||||
}
|
||||
|
||||
const slug = makeSlug();
|
||||
const adminAuth = { Authorization: `Bearer ${ADMIN_TOKEN}` };
|
||||
console.log(`[staging-setup] Using slug=${slug}`);
|
||||
|
||||
// Write the state file FIRST, before any CP call. Teardown (both
|
||||
// Playwright globalTeardown and the workflow safety-net) reads this
|
||||
// file to identify the slug it must clean up. If we wait until the
|
||||
// end of setup to write it (the previous behavior), a crash during
|
||||
// any of steps 1-6 leaves the org orphaned in CP with no record on
|
||||
// disk — forcing the workflow safety-net into a pattern-sweep over
|
||||
// every `e2e-canvas-<date>-*` org, which races with concurrent
|
||||
// canvas-E2E runs and deletes their live tenants. Race observed
|
||||
// 2026-04-30 on PR #2264 staging→main: three real-test runs killed
|
||||
// each other's tenants mid-test, surfacing as `getaddrinfo ENOTFOUND`
|
||||
// when CP cleaned up the just-deleted DNS record.
|
||||
const stateFile = join(process.cwd(), ".playwright-staging-state.json");
|
||||
writeFileSync(stateFile, JSON.stringify({ slug }, null, 2));
|
||||
|
||||
// 1. Create org via admin endpoint — no WorkOS session needed
|
||||
const create = await jsonFetch(`${CP_URL}/cp/admin/orgs`, {
|
||||
method: "POST",
|
||||
headers: adminAuth,
|
||||
body: JSON.stringify({
|
||||
slug,
|
||||
name: `E2E Canvas ${slug}`,
|
||||
owner_user_id: `e2e-runner:${slug}`,
|
||||
}),
|
||||
});
|
||||
if (create.status >= 400) {
|
||||
throw new Error(
|
||||
`POST /cp/admin/orgs ${create.status}: ${JSON.stringify(create.body)}`,
|
||||
);
|
||||
}
|
||||
console.log(`[staging-setup] Org created: ${slug}`);
|
||||
|
||||
// 2. Wait for tenant running (admin-orgs list is the status source).
|
||||
//
|
||||
// The CP /cp/admin/orgs endpoint returns each org with an
|
||||
// `instance_status` field (handlers/admin.go:adminOrgSummary,
|
||||
// sourced from `org_instances.status`). NOT `status` — there's no
|
||||
// top-level `status` on the row at all. A previous version of this
|
||||
// test polled `row.status`, which was always undefined, so this
|
||||
// waitFor never resolved truthy and the harness invariably timed
|
||||
// out at 1200s — masking real CP bugs (see #242 chain) AND
|
||||
// surviving real CP fixes alike.
|
||||
// Capture the org UUID alongside the running check — every request
|
||||
// we send to the tenant URL after this point needs an
|
||||
// X-Molecule-Org-Id header (see workspace-server middleware/tenant_guard.go).
|
||||
// Without it, TenantGuard returns 404 ("must not be inferable by
|
||||
// probing other orgs' machines"). The CP returns the id on the
|
||||
// admin-orgs row; capture it here while we're already polling.
|
||||
let orgID = "";
|
||||
await waitFor<boolean>(
|
||||
async () => {
|
||||
const r = await jsonFetch(`${CP_URL}/cp/admin/orgs`, { headers: adminAuth });
|
||||
if (r.status !== 200) return null;
|
||||
const row = (r.body?.orgs || []).find((o: any) => o.slug === slug);
|
||||
if (!row) return null;
|
||||
if (row.instance_status === "running") {
|
||||
orgID = row.id;
|
||||
return true;
|
||||
}
|
||||
if (row.instance_status === "failed") {
|
||||
// Dump every diagnostic field the admin row carries — boot stage,
|
||||
// last error, terraform/SSM state, etc. The bare slug message used
|
||||
// to surface ZERO context, so triaging a failed provision meant
|
||||
// re-running locally to repro. Now the failure log carries enough
|
||||
// to point at the right subsystem (CP/AWS/SSM/runtime) without a
|
||||
// second round-trip.
|
||||
throw new Error(
|
||||
`provision failed: ${slug} — admin-orgs row: ${JSON.stringify(row)}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
PROVISION_TIMEOUT_MS,
|
||||
15_000,
|
||||
"tenant provision",
|
||||
);
|
||||
if (!orgID) {
|
||||
throw new Error(`expected admin-orgs row to carry id, got empty for slug=${slug}`);
|
||||
}
|
||||
console.log(`[staging-setup] Tenant running (org_id=${orgID})`);
|
||||
|
||||
// 3. Fetch per-tenant admin token
|
||||
const tokRes = await jsonFetch(
|
||||
`${CP_URL}/cp/admin/orgs/${slug}/admin-token`,
|
||||
{ headers: adminAuth },
|
||||
);
|
||||
if (tokRes.status !== 200 || !tokRes.body?.admin_token) {
|
||||
throw new Error(
|
||||
`tenant-token fetch ${tokRes.status}: ${JSON.stringify(tokRes.body)}`,
|
||||
);
|
||||
}
|
||||
const tenantToken: string = tokRes.body.admin_token;
|
||||
const tenantURL = `https://${slug}.${TENANT_DOMAIN}`;
|
||||
console.log(`[staging-setup] Tenant URL: ${tenantURL}`);
|
||||
|
||||
// 4. TLS readiness
|
||||
await waitFor<boolean>(
|
||||
async () => {
|
||||
try {
|
||||
const res = await fetch(`${tenantURL}/health`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return res.ok ? true : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
TLS_TIMEOUT_MS,
|
||||
5_000,
|
||||
"tenant TLS",
|
||||
);
|
||||
|
||||
// 5. Provision workspace
|
||||
//
|
||||
// tenantAuth carries TWO headers, both required:
|
||||
// - Authorization: Bearer <admin-token> — wsAdmin middleware gate
|
||||
// - X-Molecule-Org-Id: <uuid> — TenantGuard cross-org gate
|
||||
// Missing the org-id header silently 404s every non-allowlisted
|
||||
// route, with no body and no security headers. The 404 is intentional
|
||||
// (existence-non-inference) which makes it look like a missing route.
|
||||
const tenantAuth = {
|
||||
"Authorization": `Bearer ${tenantToken}`,
|
||||
"X-Molecule-Org-Id": orgID,
|
||||
};
|
||||
const ws = await jsonFetch(`${tenantURL}/workspaces`, {
|
||||
method: "POST",
|
||||
headers: tenantAuth,
|
||||
body: JSON.stringify({
|
||||
name: "E2E Canvas Test",
|
||||
runtime: "hermes",
|
||||
tier: 2,
|
||||
model: "gpt-4o",
|
||||
}),
|
||||
});
|
||||
if (ws.status >= 400 || !ws.body?.id) {
|
||||
throw new Error(`Workspace create ${ws.status}: ${JSON.stringify(ws.body)}`);
|
||||
}
|
||||
const workspaceId = ws.body.id as string;
|
||||
console.log(`[staging-setup] Workspace created: ${workspaceId}`);
|
||||
|
||||
// 6. Wait for workspace online
|
||||
await waitFor<boolean>(
|
||||
async () => {
|
||||
const r = await jsonFetch(`${tenantURL}/workspaces/${workspaceId}`, {
|
||||
headers: tenantAuth,
|
||||
});
|
||||
if (r.status !== 200) return null;
|
||||
if (r.body?.status === "online") return true;
|
||||
if (r.body?.status === "failed") {
|
||||
// last_sample_error is often empty when the failure happens before
|
||||
// the agent emits a sample (e.g. boot crash, image pull error,
|
||||
// missing PYTHONPATH, OpenAI quota at startup). Dumping the full
|
||||
// body gives triage the boot_stage / last_error / image fields it
|
||||
// needs without a second probe. Otherwise this propagates as a
|
||||
// bare "Workspace failed: " — the exact useless message that
|
||||
// sent #2632 to the issue tracker.
|
||||
const detail = r.body.last_sample_error
|
||||
? r.body.last_sample_error
|
||||
: `(no last_sample_error) full body: ${JSON.stringify(r.body)}`;
|
||||
throw new Error(`Workspace failed: ${detail}`);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
WORKSPACE_ONLINE_TIMEOUT_MS,
|
||||
10_000,
|
||||
"workspace online",
|
||||
);
|
||||
console.log(`[staging-setup] Workspace online`);
|
||||
|
||||
// 7. Hand state off to tests + teardown — overwrite the slug-only
|
||||
// bootstrap state with the full state spec tests need.
|
||||
writeFileSync(
|
||||
stateFile,
|
||||
JSON.stringify({ slug, tenantURL, workspaceId, tenantToken }, null, 2),
|
||||
);
|
||||
process.env.STAGING_SLUG = slug;
|
||||
process.env.STAGING_TENANT_URL = tenantURL;
|
||||
process.env.STAGING_WORKSPACE_ID = workspaceId;
|
||||
process.env.STAGING_TENANT_TOKEN = tenantToken;
|
||||
console.log(`[staging-setup] Ready — ${stateFile}`);
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* Staging canvas E2E — opens each of the 13 workspace-panel tabs against a
|
||||
* fresh staging org provisioned in the global setup. Asserts each tab
|
||||
* renders without throwing and captures a screenshot for visual review.
|
||||
*
|
||||
* Auth model: the tenant platform's AdminAuth middleware accepts a bearer
|
||||
* token OR a WorkOS session cookie. Playwright can't mint a WorkOS
|
||||
* session, so we feed the per-tenant admin token (fetched in global
|
||||
* setup via GET /cp/admin/orgs/:slug/admin-token) as an Authorization:
|
||||
* Bearer header via context.setExtraHTTPHeaders(). Every browser
|
||||
* request inherits the header.
|
||||
*
|
||||
* Known SaaS gaps — documented in #1369 and allowed to render errored
|
||||
* content without failing the test (the gate is "no hard crash, no
|
||||
* 'Failed to load' toast"):
|
||||
* - Files tab: empty (platform can't docker exec into a remote EC2)
|
||||
* - Terminal tab: WS connect fails
|
||||
* - Peers tab: 401 without workspace-scoped token
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
// Tab ids as declared in canvas/src/components/SidePanel.tsx TABS.
|
||||
const TAB_IDS = [
|
||||
"chat",
|
||||
"activity",
|
||||
"details",
|
||||
"skills",
|
||||
"terminal",
|
||||
"config",
|
||||
"schedule",
|
||||
"channels",
|
||||
"files",
|
||||
"memory",
|
||||
"traces",
|
||||
"events",
|
||||
"audit",
|
||||
] as const;
|
||||
|
||||
const STAGING = process.env.CANVAS_E2E_STAGING === "1";
|
||||
|
||||
test.skip(!STAGING, "CANVAS_E2E_STAGING not set — skipping staging-only tests");
|
||||
|
||||
test.describe("staging canvas tabs", () => {
|
||||
test("each workspace-panel tab renders without error", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
const tenantURL = process.env.STAGING_TENANT_URL;
|
||||
const tenantToken = process.env.STAGING_TENANT_TOKEN;
|
||||
const workspaceId = process.env.STAGING_WORKSPACE_ID;
|
||||
|
||||
if (!tenantURL || !tenantToken || !workspaceId) {
|
||||
throw new Error(
|
||||
"staging-setup.ts did not export STAGING_TENANT_URL / STAGING_TENANT_TOKEN / STAGING_WORKSPACE_ID — did global setup run?",
|
||||
);
|
||||
}
|
||||
|
||||
// Attach the per-tenant admin bearer to every outbound request.
|
||||
// The tenant platform's AdminAuth middleware accepts this; no
|
||||
// WorkOS session needed.
|
||||
await context.setExtraHTTPHeaders({
|
||||
Authorization: `Bearer ${tenantToken}`,
|
||||
});
|
||||
|
||||
// canvas/src/components/AuthGate.tsx fetches /cp/auth/me on mount
|
||||
// and redirects to the login page on 401. The bearer header above
|
||||
// is for platform API calls — it does NOT satisfy /cp/auth/me,
|
||||
// which is cookie-based (WorkOS session). Without this mock, the
|
||||
// canvas page mounts AuthGate, sees 401 from /cp/auth/me, and
|
||||
// redirects away from the tenant URL before the React Flow root
|
||||
// ever renders. The [aria-label] selector wait then times out.
|
||||
//
|
||||
// Intercept /cp/auth/me + return a fake Session shape so AuthGate
|
||||
// resolves to "authenticated" and renders {children}. The session
|
||||
// contents are cosmetic — the canvas only inspects org_id/user_id
|
||||
// in a few places that don't fail when these are dummy values.
|
||||
await context.route("**/cp/auth/me", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
user_id: `e2e-test-user-${workspaceId}`,
|
||||
org_id: "e2e-test-org",
|
||||
email: "e2e@test.local",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// Universal 401 → empty-200 fallback (defense-in-depth).
|
||||
//
|
||||
// The original product bug was canvas/src/lib/api.ts:62-74 calling
|
||||
// `redirectToLogin` on EVERY 401 — a single workspace-scoped 401
|
||||
// (e.g. /workspaces/:id/peers, /plugins) yanked the user (and the
|
||||
// test) to AuthKit. That's now fixed at the source: api.ts probes
|
||||
// /cp/auth/me before redirecting, so a 401 from a non-auth path
|
||||
// with a live session throws a regular error instead.
|
||||
//
|
||||
// This route handler stays as a SAFETY NET, not the primary
|
||||
// defense:
|
||||
// 1. It silences resource-load console noise from the browser
|
||||
// (those messages don't include the URL — useless in
|
||||
// diagnostics, captured by the filter in the assertion
|
||||
// block but having no 401s reach the network is cleaner).
|
||||
// 2. It guards against panels that DON'T have try/catch around
|
||||
// their api calls — an unhandled rejection would surface
|
||||
// as console.error → fail the assertion. Panels SHOULD
|
||||
// handle errors, but until they're all audited, this is
|
||||
// the test's belt to api.ts's braces.
|
||||
//
|
||||
// Pass-through real responses; swap 401s for 200 + empty body.
|
||||
// Skip /cp/auth/me (mocked above) and non-fetch resources
|
||||
// (HTML/JS/CSS bundles that should NOT be intercepted).
|
||||
await context.route("**", async (route, request) => {
|
||||
if (request.resourceType() !== "fetch") {
|
||||
return route.fallback();
|
||||
}
|
||||
// /cp/auth/me is mocked above with a fixed Session shape — let
|
||||
// that handler win without us round-tripping the network.
|
||||
if (request.url().includes("/cp/auth/me")) {
|
||||
return route.fallback();
|
||||
}
|
||||
let resp;
|
||||
try {
|
||||
resp = await route.fetch();
|
||||
} catch {
|
||||
return route.fallback();
|
||||
}
|
||||
if (resp.status() !== 401) {
|
||||
return route.fulfill({ response: resp });
|
||||
}
|
||||
const lastSeg =
|
||||
new URL(request.url()).pathname.split("/").filter(Boolean).pop() || "";
|
||||
const looksLikeList = !/^[0-9a-f-]{8,}$/.test(lastSeg);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: looksLikeList ? "[]" : "{}",
|
||||
});
|
||||
});
|
||||
|
||||
const consoleErrors: string[] = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
consoleErrors.push(msg.text());
|
||||
}
|
||||
});
|
||||
|
||||
// Capture the URL of any failed network request so a "Failed to load
|
||||
// resource: 404" console message we filter out below leaves a
|
||||
// breadcrumb. Browser console messages for resource-load failures
|
||||
// omit the URL, so we'd otherwise be flying blind. Logged to the
|
||||
// test's stdout (visible in the workflow log under the failed step).
|
||||
page.on("requestfailed", (req) => {
|
||||
console.log(`[e2e/requestfailed] ${req.method()} ${req.url()}: ${req.failure()?.errorText ?? "?"}`);
|
||||
});
|
||||
page.on("response", (res) => {
|
||||
if (res.status() >= 400) {
|
||||
console.log(`[e2e/response-${res.status()}] ${res.request().method()} ${res.url()}`);
|
||||
}
|
||||
});
|
||||
|
||||
// waitUntil="networkidle" is wrong here — the canvas keeps a
|
||||
// WebSocket open + polls /events and /workspaces every few
|
||||
// seconds, so the network is *never* idle for 500ms. page.goto
|
||||
// would hang until its 45s default timeout. "domcontentloaded"
|
||||
// returns as soon as the HTML is parsed; React hydration + the
|
||||
// selector wait below is what actually gates ready-for-interaction.
|
||||
await page.goto(tenantURL, { waitUntil: "domcontentloaded" });
|
||||
|
||||
// Canvas hydration races WebSocket connect + /workspaces fetch.
|
||||
// Wait for the React Flow canvas wrapper (always present once
|
||||
// hydrated, even with zero workspaces) or the hydration-error
|
||||
// banner — whichever wins first. Previous version of this wait
|
||||
// used `[role="tablist"]`, but that selector only appears AFTER
|
||||
// a workspace node is clicked (which happens below at L100), so
|
||||
// the wait would always time out at 45s before any meaningful
|
||||
// failure surfaced.
|
||||
await page.waitForSelector(
|
||||
'[aria-label="Molecule AI workspace canvas"], [data-testid="hydration-error"]',
|
||||
{ timeout: 45_000 },
|
||||
);
|
||||
|
||||
const hydrationErr = await page
|
||||
.locator('[data-testid="hydration-error"]')
|
||||
.count();
|
||||
expect(
|
||||
hydrationErr,
|
||||
"canvas hydration failed — check staging CP + tenant reachability",
|
||||
).toBe(0);
|
||||
|
||||
// Click the workspace node to open the side panel. Try a data
|
||||
// attribute first, fall back to a generic role-based selector so
|
||||
// the test doesn't break when the node-card markup changes.
|
||||
const byDataAttr = page.locator(`[data-workspace-id="${workspaceId}"]`).first();
|
||||
if ((await byDataAttr.count()) > 0) {
|
||||
await byDataAttr.click({ timeout: 10_000 });
|
||||
} else {
|
||||
const firstNode = page
|
||||
.locator('[role="button"][aria-label*="Workspace" i]')
|
||||
.first();
|
||||
await firstNode.click({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
await page.waitForSelector('[role="tablist"]', { timeout: 15_000 });
|
||||
|
||||
for (const tabId of TAB_IDS) {
|
||||
await test.step(`tab: ${tabId}`, async () => {
|
||||
const tabButton = page.locator(`#tab-${tabId}`);
|
||||
// The TABS bar is `overflow-x-auto` (SidePanel.tsx:~tabs
|
||||
// wrapper) — tabs after position ~3 are clipped behind the
|
||||
// right-edge fade gradient on smaller viewports. Playwright's
|
||||
// `toBeVisible()` returns false for clipped elements, so a
|
||||
// bare visibility check fails on `skills` and later tabs in
|
||||
// CI. scrollIntoViewIfNeeded brings the button into view
|
||||
// before the visibility check, mirroring what SidePanel's own
|
||||
// keyboard handler does on arrow-key navigation.
|
||||
await tabButton.scrollIntoViewIfNeeded({ timeout: 5_000 });
|
||||
await expect(
|
||||
tabButton,
|
||||
`tab-${tabId} button missing — TABS list may have drifted`,
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
await tabButton.click();
|
||||
|
||||
const panel = page.locator(`#panel-${tabId}`);
|
||||
await expect(panel, `panel for ${tabId} never rendered`).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// "Failed to load" toast = hard crash. Known SaaS-mode gaps
|
||||
// (Files empty, Terminal disconnected, Peers 401) surface as
|
||||
// in-panel content, not toasts.
|
||||
const errorToasts = await page
|
||||
.locator('[role="alert"]:has-text("Failed to load")')
|
||||
.count();
|
||||
expect(errorToasts, `tab ${tabId}: "Failed to load" toast`).toBe(0);
|
||||
|
||||
await page.screenshot({
|
||||
path: `test-results/staging-tab-${tabId}.png`,
|
||||
fullPage: false,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Aggregate console-error budget. Known-noisy sources whitelisted:
|
||||
// Sentry, Vercel analytics, WS reconnects (expected on SaaS
|
||||
// terminal), favicon 404 (cosmetic), and the browser's generic
|
||||
// "Failed to load resource: ... 404" message which never includes
|
||||
// the URL — uninformative on its own and impossible to filter
|
||||
// meaningfully without a URL. The page.on('requestfailed') +
|
||||
// page.on('response>=400') logging above captures the actual URLs
|
||||
// so a real bug still leaves a breadcrumb in the workflow log;
|
||||
// a real exception (panel crash, JS error) surfaces as a typed
|
||||
// error with file path which the filter still catches.
|
||||
const appErrors = consoleErrors.filter(
|
||||
(msg) =>
|
||||
!msg.includes("sentry") &&
|
||||
!msg.includes("vercel") &&
|
||||
!msg.includes("WebSocket") &&
|
||||
!msg.includes("favicon") &&
|
||||
!msg.includes("molecule-icon.png") && // cosmetic 404
|
||||
!msg.includes("Failed to load resource"),
|
||||
);
|
||||
expect(
|
||||
appErrors,
|
||||
`unexpected console errors:\n${appErrors.join("\n")}`,
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Playwright global teardown — deletes the staging org provisioned by
|
||||
* staging-setup.ts via DELETE /cp/admin/tenants/:slug. Runs on success AND
|
||||
* failure (Playwright calls globalTeardown regardless).
|
||||
*
|
||||
* The workflow's always()-step safety net also catches orphan orgs
|
||||
* tagged with the run ID, so this is the primary cleanup and the
|
||||
* workflow step is the belt-and-braces backup.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, unlinkSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const CP_URL = process.env.MOLECULE_CP_URL || "https://staging-api.moleculesai.app";
|
||||
const ADMIN_TOKEN = process.env.MOLECULE_ADMIN_TOKEN;
|
||||
const STAGING = process.env.CANVAS_E2E_STAGING === "1";
|
||||
|
||||
export default async function globalTeardown(): Promise<void> {
|
||||
if (!STAGING) return;
|
||||
if (!ADMIN_TOKEN) {
|
||||
console.warn("[staging-teardown] no MOLECULE_ADMIN_TOKEN, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
const stateFile = join(process.cwd(), ".playwright-staging-state.json");
|
||||
if (!existsSync(stateFile)) {
|
||||
// staging-setup writes this file as its first action, before any
|
||||
// CP call. Missing here means setup never ran (CANVAS_E2E_STAGING
|
||||
// unset, or ran in a different cwd) — there's no slug we created
|
||||
// that needs cleaning up.
|
||||
console.warn("[staging-teardown] no state file — nothing to tear down");
|
||||
return;
|
||||
}
|
||||
|
||||
let slug: string;
|
||||
try {
|
||||
const state = JSON.parse(readFileSync(stateFile, "utf-8"));
|
||||
slug = state.slug;
|
||||
} catch (e) {
|
||||
console.warn(`[staging-teardown] state file unreadable: ${e}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[staging-teardown] Deleting org ${slug}...`);
|
||||
try {
|
||||
const res = await fetch(`${CP_URL}/cp/admin/tenants/${slug}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ADMIN_TOKEN}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ confirm: slug }),
|
||||
});
|
||||
if (res.ok) {
|
||||
console.log(`[staging-teardown] ${slug} deleted`);
|
||||
} else {
|
||||
console.warn(
|
||||
`[staging-teardown] DELETE returned ${res.status} (may already be gone)`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[staging-teardown] DELETE failed: ${e}`);
|
||||
}
|
||||
|
||||
try {
|
||||
unlinkSync(stateFile);
|
||||
} catch {
|
||||
/* non-fatal */
|
||||
}
|
||||
}
|
||||
@@ -1,100 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
// Load NEXT_PUBLIC_* vars from the monorepo root .env so a fresh
|
||||
// `pnpm dev` works without a per-developer canvas/.env.local. Next.js
|
||||
// only auto-loads .env from the project root by default — but our
|
||||
// canonical config (NEXT_PUBLIC_PLATFORM_URL, NEXT_PUBLIC_WS_URL,
|
||||
// MOLECULE_ENV, etc.) lives at the monorepo root, gitignored, shared
|
||||
// by the Go platform binary. Without this, the canvas falls back to
|
||||
// `window.location` (`ws://localhost:3000/ws`) and the WS pill stays
|
||||
// "Reconnecting" forever because Next.js dev doesn't serve /ws.
|
||||
//
|
||||
// Mirrors workspace-server/cmd/server/dotenv.go's monorepo-rooted .env
|
||||
// loader. Both processes look for the SAME marker (`workspace-server/
|
||||
// go.mod`) so a developer renaming or relocating the repo only has to
|
||||
// update one heuristic. Production is unaffected: `output: "standalone"`
|
||||
// bakes resolved env into the build, and the marker file isn't shipped.
|
||||
loadMonorepoEnv();
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
function loadMonorepoEnv() {
|
||||
const root = findMonorepoRoot(__dirname);
|
||||
if (!root) return;
|
||||
const envPath = join(root, ".env");
|
||||
if (!existsSync(envPath)) return;
|
||||
const body = readFileSync(envPath, "utf8");
|
||||
let loaded = 0;
|
||||
let skipped = 0;
|
||||
for (const line of body.split(/\r?\n/)) {
|
||||
const kv = parseLine(line);
|
||||
if (!kv) continue;
|
||||
const [k, v] = kv;
|
||||
// Existing env wins. NOTE: an explicitly-set empty string
|
||||
// (`KEY=` exported from a parent shell, where Node represents it
|
||||
// as `""` not `undefined`) counts as "set" — we keep the empty
|
||||
// value rather than backfilling from the file. Matches Go's
|
||||
// os.LookupEnv check in workspace-server/cmd/server/dotenv.go so
|
||||
// both processes treat the same input identically. Operators who
|
||||
// want the file value to win must `unset KEY` in the launching
|
||||
// shell.
|
||||
if (process.env[k] !== undefined) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
process.env[k] = v;
|
||||
loaded++;
|
||||
}
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[next.config] loaded ${loaded} vars from ${envPath} (${skipped} already set in env)`,
|
||||
);
|
||||
}
|
||||
|
||||
function findMonorepoRoot(start: string): string | null {
|
||||
let dir = start;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
if (existsSync(join(dir, "workspace-server", "go.mod"))) return dir;
|
||||
const parent = dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Mirror of workspace-server/cmd/server/dotenv.go's parseDotEnvLine
|
||||
// — same rules so the two loaders agree on every line in the shared
|
||||
// .env. If you change one parser, change the other.
|
||||
function parseLine(raw: string): [string, string] | null {
|
||||
let line = raw.replace(/^/, "").trim();
|
||||
if (line === "" || line.startsWith("#")) return null;
|
||||
// `export ` prefix uses a literal space — `export\tFOO=bar` with a
|
||||
// tab is intentionally rejected, matching the Go mirror in
|
||||
// workspace-server/cmd/server/dotenv.go. Shells emit the prefix
|
||||
// with a space; tabs would only appear in hand-mangled files.
|
||||
if (line.startsWith("export ")) line = line.slice("export ".length).trimStart();
|
||||
const eq = line.indexOf("=");
|
||||
if (eq <= 0) return null;
|
||||
const k = line.slice(0, eq).trim();
|
||||
let v = line.slice(eq + 1).replace(/^[ \t]+/, "");
|
||||
if (v.length >= 2 && (v[0] === '"' || v[0] === "'")) {
|
||||
const quote = v[0];
|
||||
const end = v.indexOf(quote, 1);
|
||||
if (end >= 0) return [k, v.slice(1, end)];
|
||||
// unterminated — fall through to bare-value handling
|
||||
}
|
||||
for (let i = 0; i < v.length; i++) {
|
||||
if (v[i] !== "#") continue;
|
||||
if (i === 0 || v[i - 1] === " " || v[i - 1] === "\t") {
|
||||
v = v.slice(0, i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [k, v.trim()];
|
||||
}
|
||||
|
||||
Generated
+1593
-965
File diff suppressed because it is too large
Load Diff
+7
-9
@@ -3,12 +3,11 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 3000",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
@@ -32,15 +31,14 @@
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@testing-library/jest-dom": "^6.6.0",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.5.13",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Playwright config for staging canvas E2E.
|
||||
*
|
||||
* Separate from playwright.config.ts (local dev) so:
|
||||
* - globalSetup / globalTeardown don't run for every local `pnpm test`
|
||||
* - Retries + timeouts can be longer (staging is remote + shared)
|
||||
* - baseURL is dynamic (set by globalSetup → STAGING_TENANT_URL)
|
||||
*
|
||||
* Invoked by the e2e-staging-canvas GH Actions workflow:
|
||||
* npx playwright test --config=playwright.staging.config.ts
|
||||
*/
|
||||
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
// Only the staging-*.spec.ts files run under this config. The smoke +
|
||||
// unit specs (chat-separation, filestab-smoke, etc.) stay on the local
|
||||
// config so they don't hit staging.
|
||||
testMatch: /staging-.*\.spec\.ts/,
|
||||
// Global setup provisions the org; budget generously because EC2 boot
|
||||
// is ~5 min and can drift to 10+ on cold AMI days.
|
||||
timeout: 120_000,
|
||||
expect: { timeout: 15_000 },
|
||||
fullyParallel: false,
|
||||
// A transient network blip shouldn't cost us the whole run. Two retries
|
||||
// mean up to 3 attempts — staging flakes fall within that budget.
|
||||
retries: 2,
|
||||
// One worker: the setup provisions exactly one org/workspace, and
|
||||
// parallel specs would fight over the shared workspace selector state.
|
||||
workers: 1,
|
||||
globalSetup: "./e2e/staging-setup.ts",
|
||||
globalTeardown: "./e2e/staging-teardown.ts",
|
||||
use: {
|
||||
// STAGING_TENANT_URL gets written to process.env in global setup, but
|
||||
// Playwright resolves baseURL before setup runs. We read it inside
|
||||
// each spec instead — don't hard-code here.
|
||||
headless: true,
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
trace: "retain-on-failure",
|
||||
navigationTimeout: 45_000,
|
||||
actionTimeout: 15_000,
|
||||
},
|
||||
reporter: [
|
||||
["list"],
|
||||
["html", { outputFolder: "playwright-report-staging", open: "never" }],
|
||||
],
|
||||
projects: [{ name: "chromium", use: { browserName: "chromium" } }],
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,8 +15,7 @@
|
||||
* - Polling: provisioning orgs schedule a 5s refresh (fake timers)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act } from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { render, screen, waitFor, cleanup } from "@testing-library/react";
|
||||
|
||||
// ── Hoisted mocks ────────────────────────────────────────────────────────────
|
||||
// vi.mock factories are hoisted above imports; any captured references must
|
||||
@@ -128,10 +127,14 @@ describe("/orgs — auth guard", () => {
|
||||
describe("/orgs — error state", () => {
|
||||
it("shows error + Retry button when /cp/orgs fails", async () => {
|
||||
mockFetchSession.mockResolvedValue({ userId: "u-1" });
|
||||
mockFetch.mockResolvedValueOnce(notOk(500, "db down"));
|
||||
mockFetch.mockImplementationOnce(() =>
|
||||
Promise.reject(new Error("GET /cp/orgs: 500"))
|
||||
);
|
||||
render(<OrgsPage />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
|
||||
expect(screen.getByText(/Error:/)).toBeTruthy();
|
||||
// PR #1243 replaced waitFor polling with vi.advanceTimersByTimeAsync(50),
|
||||
// which fires the timer but does not guarantee React render flush completes
|
||||
// before the assertion runs. Restores waitFor for the error-state test.
|
||||
await waitFor(() => expect(screen.getByText(/Error:/)).toBeTruthy());
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -141,7 +144,7 @@ describe("/orgs — empty list", () => {
|
||||
mockFetchSession.mockResolvedValue({ userId: "u-1" });
|
||||
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
|
||||
render(<OrgsPage />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(screen.getByText(/don't have any organizations/i)).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /create organization/i })).toBeTruthy();
|
||||
});
|
||||
@@ -168,7 +171,7 @@ describe("/orgs — CTAs by status", () => {
|
||||
})
|
||||
);
|
||||
render(<OrgsPage />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
const link = screen.getByRole("link", { name: /open/i }) as HTMLAnchorElement;
|
||||
expect(link.href).toBe("https://acme.moleculesai.app/");
|
||||
});
|
||||
@@ -191,7 +194,7 @@ describe("/orgs — CTAs by status", () => {
|
||||
})
|
||||
);
|
||||
render(<OrgsPage />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
const link = screen.getByRole("link", {
|
||||
name: /complete payment/i,
|
||||
}) as HTMLAnchorElement;
|
||||
@@ -216,7 +219,7 @@ describe("/orgs — CTAs by status", () => {
|
||||
})
|
||||
);
|
||||
render(<OrgsPage />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
const link = screen.getByRole("link", {
|
||||
name: /contact support/i,
|
||||
}) as HTMLAnchorElement;
|
||||
@@ -245,7 +248,7 @@ describe("/orgs — post-checkout banner", () => {
|
||||
})
|
||||
);
|
||||
render(<OrgsPage />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(screen.getByText(/Payment confirmed/i)).toBeTruthy();
|
||||
// URL must be rewritten to drop the ?checkout flag so reload doesn't re-show the banner
|
||||
expect(replaceState).toHaveBeenCalled();
|
||||
@@ -257,7 +260,7 @@ describe("/orgs — post-checkout banner", () => {
|
||||
mockFetchSession.mockResolvedValue({ userId: "u-1" });
|
||||
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
|
||||
render(<OrgsPage />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(screen.getByText(/don't have any organizations/i)).toBeTruthy();
|
||||
expect(screen.queryByText(/Payment confirmed/i)).toBeNull();
|
||||
});
|
||||
@@ -268,7 +271,7 @@ describe("/orgs — fetch includes credentials + timeout signal", () => {
|
||||
mockFetchSession.mockResolvedValue({ userId: "u-1" });
|
||||
mockFetch.mockResolvedValueOnce(okJson({ orgs: [] }));
|
||||
render(<OrgsPage />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(50); });
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
const callArgs = mockFetch.mock.calls.find((c) =>
|
||||
String(c[0]).includes("/cp/orgs")
|
||||
);
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Canvas /api/buildinfo — version-display endpoint mirroring
|
||||
* workspace-server's /buildinfo. Lets `curl <url>/api/buildinfo`
|
||||
* confirm which git SHA is live on a canvas deployment.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { GET } from "../route";
|
||||
|
||||
const ENV_KEYS = ["VERCEL_GIT_COMMIT_SHA", "VERCEL_GIT_COMMIT_REF", "VERCEL_ENV"];
|
||||
|
||||
describe("GET /api/buildinfo", () => {
|
||||
let saved: Record<string, string | undefined>;
|
||||
|
||||
beforeEach(() => {
|
||||
saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
||||
for (const k of ENV_KEYS) delete process.env[k];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
});
|
||||
|
||||
it("returns dev sentinel when Vercel env vars are unset", async () => {
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ git_sha: "dev", git_ref: "", vercel_env: "local" });
|
||||
});
|
||||
|
||||
it("reports the SHA Vercel injected at build time", async () => {
|
||||
process.env.VERCEL_GIT_COMMIT_SHA = "abc1234567890";
|
||||
process.env.VERCEL_GIT_COMMIT_REF = "main";
|
||||
process.env.VERCEL_ENV = "production";
|
||||
const res = await GET();
|
||||
const body = await res.json();
|
||||
expect(body.git_sha).toBe("abc1234567890");
|
||||
expect(body.git_ref).toBe("main");
|
||||
expect(body.vercel_env).toBe("production");
|
||||
});
|
||||
|
||||
it("returns 200 status and JSON content type", async () => {
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-type")).toContain("application/json");
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// Mirror of workspace-server's GET /buildinfo (PR #2398). Lets a developer
|
||||
// confirm which git SHA is live on a canvas deployment with the same
|
||||
// `curl <url>/buildinfo` flow they use against tenant workspaces.
|
||||
//
|
||||
// Vercel injects VERCEL_GIT_COMMIT_SHA / _REF / VERCEL_ENV at build time
|
||||
// from the deploying commit; outside Vercel (local `next dev`, harness)
|
||||
// these are unset and the endpoint reports `git_sha: "dev"`. Same sentinel
|
||||
// the workspace-server uses pre-ldflags-injection so both surfaces speak
|
||||
// the same vocabulary.
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
git_sha: process.env.VERCEL_GIT_COMMIT_SHA ?? "dev",
|
||||
git_ref: process.env.VERCEL_GIT_COMMIT_REF ?? "",
|
||||
vercel_env: process.env.VERCEL_ENV ?? "local",
|
||||
});
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
---
|
||||
title: "Give Your AI Agent Browser Superpowers: Chrome DevTools MCP Integration"
|
||||
date: "2026-04-20"
|
||||
canonical: "https://docs.molecule.ai/blog/chrome-devtools-mcp"
|
||||
og_title: "Give Your AI Agent Browser Superpowers with Chrome DevTools MCP"
|
||||
og_description: "Chrome DevTools MCP brings AI agent browser control to Molecule AI. Every browser action is audit-attributed via org API keys. MCP browser automation with governance built in."
|
||||
og_image: "/blog/chrome-devtools-mcp/chrome-devtools-mcp-social-card.png"
|
||||
twitter_card: "summary_large_image"
|
||||
author: "Molecule AI"
|
||||
keywords:
|
||||
- "AI agent browser control"
|
||||
- "MCP browser automation"
|
||||
- "browser automation AI agents"
|
||||
- "browser automation governance"
|
||||
- "Chrome DevTools MCP"
|
||||
- "MCP governance layer"
|
||||
- "AI agent web UI automation"
|
||||
---
|
||||
|
||||
import { Callout } from '@/components/blog/Callout'
|
||||
import { CodeBlock } from '@/components/blog/CodeBlock'
|
||||
|
||||
# Give Your AI Agent Browser Superpowers: Chrome DevTools MCP Integration
|
||||
|
||||
Every AI agent platform eventually gets asked the same question: "Can it interact with a web interface?" The answer is usually some variant of "sort of — give it your credentials and hope for the best." That's not a real answer. It's a trust fall.
|
||||
|
||||
Chrome DevTools MCP changes this. It gives your AI agent a structured, governed interface to a real Chrome browser session — with full **MCP browser automation** capability and an audit trail that actually answers the question: "which agent touched what, and what did it do?"
|
||||
|
||||
This post covers what Chrome DevTools MCP is, how Molecule AI's governance layer makes it enterprise-safe, and how to put it to work in your agent fleet.
|
||||
|
||||
---
|
||||
|
||||
## What is Chrome DevTools MCP?
|
||||
|
||||
Chrome DevTools MCP is an integration between the [MCP (Model Context Protocol)](https://modelcontextprotocol.io) and Google Chrome's DevTools Protocol. MCP is a standardized interface layer that lets AI agents connect to external tools with consistent tooling, authentication, and telemetry. The DevTools Protocol is Chrome's native debugging interface — the same interface your browser's developer tools use to inspect pages, capture network traffic, and control the browser.
|
||||
|
||||
When you connect an AI agent to Chrome DevTools via MCP, you get:
|
||||
|
||||
- **Full CDP access** — navigate, click, type, screenshot, evaluate JavaScript, read network logs, intercept requests, read cookies and local storage
|
||||
- **MCP protocol layer** — structured JSON-RPC instead of raw CDP, consistent tool naming, type-safe parameters
|
||||
- **Molecule AI governance layer** — org API key attribution, audit logging, session scoping, instant revocation
|
||||
|
||||
The third item is what separates this from "use Puppeteer with an API key." It's the difference between browser automation AI agents and browser automation AI agents with a compliance story.
|
||||
|
||||
---
|
||||
|
||||
## The Browser Problem: Trust Falls and Black Boxes
|
||||
|
||||
When most teams give an AI agent browser access, the workflow looks like this:
|
||||
|
||||
1. Agent receives a task ("find our competitors' pricing pages")
|
||||
2. Agent uses browser credentials to log into Chrome
|
||||
3. Agent navigates, reads, screenshots, and reports
|
||||
4. Nobody knows exactly what the agent did, which session it used, or whether credentials were exposed
|
||||
|
||||
This is a trust fall, not a governance model. The agent *can* do the task. But you have no audit trail if something goes wrong. No way to revoke access if the agent's behavior becomes unexpected. No attribution if you need to trace a call back to a specific integration.
|
||||
|
||||
The **MCP governance layer** in Molecule AI addresses all three:
|
||||
|
||||
- Every browser action is logged with the org API key prefix that initiated it
|
||||
- Chrome sessions are token-scoped — Agent A's session is never Agent B's
|
||||
- Revocation is one API call — the key stops working, the session closes, no redeploy required
|
||||
|
||||
---
|
||||
|
||||
## How MCP Browser Automation Works in Molecule AI
|
||||
|
||||
The integration uses Chrome's CDP over a WebSocket connection managed by the MCP server. Molecule AI's MCP server exposes a structured set of tools that map to CDP commands. Your agent calls these tools like any other MCP tool — the same interface whether you're automating Chrome, reading memory, or querying the platform API.
|
||||
|
||||
Here's the sequence:
|
||||
|
||||
1. **Workspace starts with a Chrome session attached** — the session is scoped to a specific Chrome profile or fresh browser context, isolated from other agents
|
||||
2. **Agent calls MCP tools** — `cdp_navigate`, `cdp_click`, `cdp_evaluate`, `cdp_screenshot`, and others are available as structured tools with type-safe parameters
|
||||
3. **Every call is audit-attributed** — the org API key prefix (e.g., `mole_a1b2`) is logged with the tool name, parameters, and result for every CDP call
|
||||
4. **Session is revocable at any time** — revoke the org API key and the agent loses Chrome access immediately
|
||||
|
||||
### AI Agent Browser Control: What You Can Do
|
||||
|
||||
**Navigation and interaction:**
|
||||
- `cdp_navigate` — navigate to any URL (supports `data:` and `about:` URLs via browser UI)
|
||||
- `cdp_click` — click a DOM element by selector
|
||||
- `cdp_type` — type text into a focused element
|
||||
- `cdp_hover` — hover over a DOM element
|
||||
- `cdp_scroll` — scroll an element or the page
|
||||
|
||||
**Inspection and debugging:**
|
||||
- `cdp_screenshot` — capture a full-page or viewport screenshot
|
||||
- `cdp_evaluate` — execute JavaScript in the page context
|
||||
- `cdp_get_cookies` / `cdp_set_cookies` — read and write cookies for authenticated sessions
|
||||
- `cdp_get_local_storage` / `cdp_set_local_storage` — read and write localStorage
|
||||
|
||||
**Network and performance:**
|
||||
- `cdp_get_requests` — capture and filter network requests (XHR, fetch, WS)
|
||||
- `cdp_block_urls` — block specific URL patterns to simulate adblocked environments
|
||||
- `cdp_set_throttle` — throttle network conditions (3G, LTE, offline)
|
||||
|
||||
---
|
||||
|
||||
## Browser Automation AI Agents: Use Cases That Actually Ship
|
||||
|
||||
The Chrome DevTools MCP integration is most useful in workflows where browser state is the source of truth — and where audit attribution matters.
|
||||
|
||||
### Automated Lighthouse audits on every PR
|
||||
|
||||
A research agent runs a Lighthouse audit against every pull request in your repo. It navigates to the preview URL, captures the performance score, flags regressions below your threshold, and reports to the PM agent. Every audit run is logged with the org API key — your observability team can trace which agent ran which audit and when.
|
||||
|
||||
```bash
|
||||
# Agent calls cdp_navigate to the PR preview URL
|
||||
# Agent calls cdp_evaluate to run Lighthouse inline
|
||||
# Agent calls cdp_screenshot to capture the score
|
||||
# Agent delegates results to PM workspace
|
||||
```
|
||||
|
||||
### Visual regression detection
|
||||
|
||||
An agent maintains a baseline set of screenshots for your key user flows. On every code change, it navigates to each flow, captures screenshots, and diffs against the baseline. Drift beyond your threshold opens a ticket automatically. The governance layer means your QA team can review the full history of which screenshots were captured, when, and by which agent.
|
||||
|
||||
### Auth scraping
|
||||
|
||||
An agent reads authenticated browser state from an existing Chrome session — cookies, localStorage, session tokens — and uses that state to authenticate API calls that would otherwise require separate credential management. The session is scoped; the credentials never leave the browser context.
|
||||
|
||||
---
|
||||
|
||||
## MCP Governance Layer: Why It Matters
|
||||
|
||||
The MCP protocol gives you tool connectivity. The governance layer is what makes it enterprise-ready.
|
||||
|
||||
### Per-action audit logging
|
||||
|
||||
Every CDP call your agent makes generates an audit log entry. The log includes:
|
||||
|
||||
- **Org API key prefix** — which integration made the call (e.g., `mole_a1b2`)
|
||||
- **Tool name and parameters** — `cdp_navigate(url=https://...)`
|
||||
- **Result or error** — success, timeout, or CDP error code
|
||||
- **Timestamp and workspace ID** — for timeline reconstruction
|
||||
|
||||
This is the audit trail your security team will ask for in the next compliance review. It exists because Molecule AI's MCP server generates it — not because you built a custom logging pipeline.
|
||||
|
||||
### Token-scoped Chrome sessions
|
||||
|
||||
Chrome sessions are isolated per org API key. When you create an org API key for a specific integration (`lighthouse-reporter`), that key's Chrome session is separate from every other key's session. No credential cross-contamination — Agent A cannot read Agent B's authenticated state because their sessions are isolated at the MCP tool layer.
|
||||
|
||||
### Instant revocation without redeployment
|
||||
|
||||
If you need to revoke access — the integration is compromised, the agent behavior is unexpected, the contractor relationship ended — you revoke the org API key:
|
||||
|
||||
```bash
|
||||
curl -X DELETE https://platform.moleculesai.app/org/tokens/<token-id> \
|
||||
-H "Authorization: Bearer <admin-session-token>"
|
||||
```
|
||||
|
||||
The key stops working immediately. The Chrome session is closed. The agent loses browser access before the next heartbeat. No redeploy, no container restart, no waiting for DNS cache expiration.
|
||||
|
||||
---
|
||||
|
||||
## Setting Up Chrome DevTools MCP
|
||||
|
||||
Chrome DevTools MCP requires a Chrome instance running with the remote debugging port enabled, and a `chromedp` or equivalent CDP client connected through Molecule AI's MCP server.
|
||||
|
||||
### Step 1: Enable Chrome remote debugging
|
||||
|
||||
Start Chrome with the `--remote-debugging-port=9222` flag:
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir=/tmp/chrome-debug
|
||||
|
||||
# Linux
|
||||
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug
|
||||
```
|
||||
|
||||
### Step 2: Configure Molecule AI
|
||||
|
||||
In your workspace config, add the Chrome DevTools MCP server URL:
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
mcpServers:
|
||||
- name: chrome-devtools
|
||||
url: "http://localhost:9222" # CDP WebSocket endpoint
|
||||
transport: cdp
|
||||
```
|
||||
|
||||
### Step 3: Verify the connection
|
||||
|
||||
Your agent can now call CDP tools. Test with a simple navigation:
|
||||
|
||||
```
|
||||
Agent: navigate to https://example.com and screenshot the page
|
||||
```
|
||||
|
||||
The audit log should show `cdp_navigate` and `cdp_screenshot` entries attributed to the workspace's org API key prefix.
|
||||
|
||||
---
|
||||
|
||||
## What the Security Review Looks Like
|
||||
|
||||
When your security team asks "what does this integration actually do?", here's the answer:
|
||||
|
||||
**What it can do:**
|
||||
- Navigate to any URL (with org API key attribution on every navigation)
|
||||
- Read and write browser state (cookies, localStorage, session tokens)
|
||||
- Screenshot pages and DOM elements
|
||||
- Execute JavaScript in the page context
|
||||
|
||||
**What it can't do (by default):**
|
||||
- Access the host machine beyond the Chrome sandbox
|
||||
- Read files outside the browser context
|
||||
- Exfiltrate session tokens across session boundaries
|
||||
|
||||
**What revocation looks like:**
|
||||
- Revoke org API key → immediate session close
|
||||
- No redeploy, no agent restart
|
||||
- Audit trail shows every action taken before revocation
|
||||
|
||||
---
|
||||
|
||||
## Browser Automation Governance: The Bigger Picture
|
||||
|
||||
Chrome DevTools MCP is one piece of Molecule AI's broader MCP governance story. MCP is a general-purpose protocol — it connects agents to any tool that speaks CDP, stdio, or HTTP. The governance layer applies uniformly: every MCP call gets the same treatment — org API key attribution, audit logging, instant revocation.
|
||||
|
||||
This means you can add new MCP integrations — databases, APIs, code execution environments — with the same governance posture. The MCP protocol is the connectivity layer. Molecule AI's MCP governance layer is the control plane.
|
||||
|
||||
If you're evaluating AI agent platforms for browser automation governance, the question to ask is not "can it control a browser?" It's "can I audit every action, attribute every call, and revoke access in one step?" Chrome DevTools MCP with Molecule AI's MCP governance layer is the answer to that question.
|
||||
|
||||
---
|
||||
|
||||
## Get Started
|
||||
|
||||
Chrome DevTools MCP is available on all Molecule AI deployments running Phase 30 or later.
|
||||
|
||||
- [MCP Server Setup Guide](/docs/guides/mcp-server-setup) — configure MCP tools in your workspace
|
||||
- [Org API Keys: Audit Attribution Setup](/blog/org-scoped-api-keys) — set up org API keys with attribution
|
||||
- [A2A Protocol Reference](/docs/api-protocol/a2a-protocol) — how agents delegate browser tasks to each other
|
||||
|
||||
<Callout variant="info">
|
||||
Chrome DevTools MCP requires Chrome running with the remote debugging port enabled. CDP access is scoped per org API key — multiple agents can share Chrome sessions only if intentionally scoped that way via key design.
|
||||
</Callout>
|
||||
+12
-141
@@ -1,139 +1,24 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
/*
|
||||
* Tailwind v4 defaults the `dark:` variant to `prefers-color-scheme: dark`.
|
||||
* Our theme switcher writes `data-theme="dark"` on <html> instead (so user
|
||||
* choice via the toggle wins over OS preference). Re-bind `dark:` to that
|
||||
* attribute so component classes like `dark:bg-zinc-800` track the same
|
||||
* source of truth as the `[data-theme="dark"]` token overrides below.
|
||||
*/
|
||||
@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
|
||||
|
||||
/*
|
||||
* Load order:
|
||||
* 1. Tailwind core (v4) — provides preflight + utility generation.
|
||||
* 2. xterm — overrides preflight on its own .xterm-* class names; must
|
||||
* load AFTER tailwind so its specificity wins.
|
||||
* 3. theme-tokens.css — canvas-only motion + deploy animation vars
|
||||
* (--mol-duration-*, --mol-easing-*, --mol-deploy-*). NOT colour
|
||||
* tokens; the warm-paper @theme block below owns those.
|
||||
* 4. settings-panel.css / org-deploy.css — feature stylesheets that
|
||||
* reference the variables above.
|
||||
*/
|
||||
@import "xterm/css/xterm.css";
|
||||
@import "../styles/theme-tokens.css";
|
||||
@import "../styles/settings-panel.css";
|
||||
@import "../styles/org-deploy.css";
|
||||
|
||||
/*
|
||||
* Warm-paper semantic tokens — light defaults via @theme, dark
|
||||
* overrides via [data-theme="dark"]. Names are role-based
|
||||
* (`bg-surface`, `text-ink`, `border-line`) not colour-based, so the
|
||||
* same component classes work in either mode.
|
||||
*
|
||||
* Source of truth: molecule-app/app/globals.css. Keep aligned across
|
||||
* surfaces (landing, market, app, canvas) so a token tweak ripples
|
||||
* everywhere via a single PR per repo.
|
||||
*
|
||||
* Theme preference is persisted in the `mol_theme` cookie scoped to
|
||||
* Domain=.moleculesai.app so the choice follows the user across
|
||||
* subdomains. The inline boot script in app/layout.tsx applies it
|
||||
* before paint to eliminate flash.
|
||||
*/
|
||||
@theme {
|
||||
/* Surface — page / elevated card / sunken input / deep card */
|
||||
--color-surface: #fafaf7;
|
||||
--color-surface-elevated: #ffffff;
|
||||
--color-surface-sunken: #f3f1ec;
|
||||
--color-surface-card: #efece4;
|
||||
|
||||
/* Borders */
|
||||
--color-line: #e6e2d8;
|
||||
--color-line-soft: #efece4;
|
||||
|
||||
/* Text */
|
||||
--color-ink: #15181c;
|
||||
--color-ink-mid: #5a5e66;
|
||||
--color-ink-soft: #8b8e95;
|
||||
|
||||
/* Brand + state */
|
||||
--color-accent: #3b5bdb;
|
||||
--color-accent-strong: #1a2f99;
|
||||
--color-warm: #c0532b;
|
||||
--color-good: #2f7a4d;
|
||||
--color-bad: #b94e4a;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--color-surface: #0e1014;
|
||||
--color-surface-elevated: #15181c;
|
||||
--color-surface-sunken: #0a0b0e;
|
||||
--color-surface-card: #1a1d23;
|
||||
|
||||
--color-line: #2a2f3a;
|
||||
--color-line-soft: #1f2329;
|
||||
|
||||
--color-ink: #f4f1e9;
|
||||
--color-ink-mid: #c8c2b4;
|
||||
--color-ink-soft: #8d92a0;
|
||||
|
||||
/* Accents brighten slightly for AA contrast on dark backgrounds. */
|
||||
--color-accent: #6883e8;
|
||||
--color-accent-strong: #8aa1ee;
|
||||
--color-warm: #d96f48;
|
||||
--color-good: #4ca06e;
|
||||
--color-bad: #d27773;
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
/*
|
||||
* Always-dark surface tokens. Terminals (xterm), the console modal,
|
||||
* and log streams stay dark in both modes — readable green-on-black
|
||||
* code surfaces don't translate cleanly to a light theme. Components
|
||||
* that should not light-flip use `bg-bg`, `bg-bg-elev`, `bg-bg-card`,
|
||||
* `text-ink-mute`, `text-ink-dim`, `border-line-strong` instead of
|
||||
* the warm-paper utilities above.
|
||||
*
|
||||
* Distinct names (bg-* / ink-mute / ink-dim / line-strong) so they
|
||||
* don't collide with the warm-paper namespace (surface / ink /
|
||||
* line). Both palettes coexist; the choice between them is per
|
||||
* component, not per theme.
|
||||
*/
|
||||
@theme {
|
||||
--color-bg: rgb(9 9 11); /* zinc-950 */
|
||||
--color-bg-elev: rgb(24 24 27); /* zinc-900 */
|
||||
--color-bg-card: rgb(39 39 42); /* zinc-800 */
|
||||
--color-line-strong: rgb(63 63 70); /* zinc-700 */
|
||||
--color-ink-mute: rgb(161 161 170); /* zinc-400 */
|
||||
--color-ink-dim: rgb(113 113 122); /* zinc-500 */
|
||||
--color-accent-dim: rgb(96 165 250);/* blue-400 */
|
||||
--color-plasma: rgb(59 130 246); /* blue-500 */
|
||||
--color-warn: rgb(251 191 36); /* amber-400 */
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-ink);
|
||||
background: #09090b;
|
||||
color: #e4e4e7;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* React Flow overrides for both themes. Edge stroke pulls from the
|
||||
semantic line token so dark mode keeps its existing zinc-700 look
|
||||
and light mode picks up the warm-paper line colour. */
|
||||
/* React Flow overrides for dark theme */
|
||||
.react-flow__edge-path {
|
||||
stroke: var(--color-line) !important;
|
||||
stroke: #3f3f46 !important;
|
||||
stroke-width: 1.5 !important;
|
||||
}
|
||||
|
||||
@@ -153,24 +38,10 @@ body {
|
||||
}
|
||||
|
||||
.react-flow__node {
|
||||
/* Transform transition drives the "spawn from parent" motion —
|
||||
org-deploy sets the node's initial position to the parent's
|
||||
absolute coords, then repositions to the real slot, and this
|
||||
transition interpolates the translate() in between.
|
||||
Non-deploy workspace moves (drag, nest) get the same smoothing
|
||||
for free. */
|
||||
transition:
|
||||
box-shadow var(--mol-duration-fast) ease,
|
||||
transform var(--mol-duration-spawn) var(--mol-easing-bounce-out);
|
||||
}
|
||||
/* Drag events must feel instant — React Flow adds this class
|
||||
for the lifetime of the gesture. */
|
||||
.react-flow__node.dragging {
|
||||
transition: box-shadow var(--mol-duration-fast) ease;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
/* Scrollbar styling. Track + thumb pull from the surface tokens so
|
||||
they feel native to either theme. */
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -181,17 +52,17 @@ body {
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--color-line);
|
||||
background: #3f3f46;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-line-strong, var(--color-ink-soft));
|
||||
background: #52525b;
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
background: rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
/* Panel slide animation */
|
||||
|
||||
+16
-56
@@ -1,14 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { headers } from "next/headers";
|
||||
import "./globals.css";
|
||||
import { AuthGate } from "@/components/AuthGate";
|
||||
import { CookieConsent } from "@/components/CookieConsent";
|
||||
import { ThemeProvider } from "@/lib/theme-provider";
|
||||
import {
|
||||
THEME_COOKIE,
|
||||
readThemeCookie,
|
||||
themeBootScript,
|
||||
} from "@/lib/theme-cookie";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Molecule AI",
|
||||
@@ -21,7 +15,7 @@ export default async function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// Read the per-request CSP nonce that middleware.ts sets via the
|
||||
// `x-nonce` request header. This call is load-bearing for THREE
|
||||
// `x-nonce` request header. This call is load-bearing for TWO
|
||||
// independent reasons:
|
||||
//
|
||||
// 1. It opts the root layout into dynamic rendering. Without a
|
||||
@@ -37,56 +31,22 @@ export default async function RootLayout({
|
||||
// is actually read via `headers()`. The header's existence on
|
||||
// the request isn't enough — Next.js watches for the read.
|
||||
//
|
||||
// 3. We need the nonce to attach to the inline theme boot script
|
||||
// below, otherwise CSP rejects it in production where
|
||||
// script-src is `'self' 'nonce-{nonce}' 'strict-dynamic'`.
|
||||
// 'strict-dynamic' propagates trust from a nonce'd script to
|
||||
// scripts it inserts, but does NOT forgive an un-nonce'd
|
||||
// sibling — the boot script must carry its own nonce.
|
||||
const hdrs = await headers();
|
||||
const nonce = hdrs.get("x-nonce") ?? undefined;
|
||||
|
||||
// SSR: read the user's saved preference. For light/dark we can stamp
|
||||
// data-theme on <html> here so the very first paint matches; for
|
||||
// "system" we leave the attribute off and let the inline boot script
|
||||
// resolve from matchMedia before paint.
|
||||
const cookieStore = await cookies();
|
||||
const theme = readThemeCookie(cookieStore.get(THEME_COOKIE)?.value);
|
||||
const initialDataTheme = theme === "system" ? undefined : theme;
|
||||
// Keeping the `nonce` variable unused is intentional: we don't need
|
||||
// to pass it to any custom <Script nonce={...}> tags right now, the
|
||||
// framework takes care of its own bootstrap scripts once the read
|
||||
// happens. Destructuring via `await` + `.get()` is the minimum shape
|
||||
// Next.js recognizes as "dynamic server-side access".
|
||||
await headers();
|
||||
|
||||
return (
|
||||
// suppressHydrationWarning on <html>: the inline boot script below
|
||||
// mutates `data-theme` before React hydrates (system mode reads
|
||||
// matchMedia + writes the attribute). That's the entire point of the
|
||||
// script — eliminate the flash — and it's the documented escape hatch
|
||||
// for "the server-rendered HTML is intentionally not what React would
|
||||
// produce client-side at this exact attribute."
|
||||
<html lang="en" data-theme={initialDataTheme} suppressHydrationWarning>
|
||||
<head>
|
||||
{/*
|
||||
* Boot script: runs synchronously before the body paints, sets
|
||||
* data-theme on <html> for "system" preference based on the OS
|
||||
* media query. For explicit light/dark, SSR already set the
|
||||
* attribute above and the script's write is a no-op.
|
||||
*
|
||||
* `nonce` comes from middleware's per-request CSP nonce — see
|
||||
* the comment block above for why CSP requires this even though
|
||||
* the page also has 'strict-dynamic'.
|
||||
*/}
|
||||
<script
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{ __html: themeBootScript }}
|
||||
/>
|
||||
</head>
|
||||
<body className="bg-surface text-ink">
|
||||
<ThemeProvider initialTheme={theme}>
|
||||
{/* AuthGate is a client component; it checks the session on mount
|
||||
and bounces anonymous users to the control plane's login page
|
||||
when running on a tenant subdomain. Non-SaaS hosts (localhost,
|
||||
vercel preview URL, apex) pass through unchanged. */}
|
||||
<AuthGate>{children}</AuthGate>
|
||||
<CookieConsent />
|
||||
</ThemeProvider>
|
||||
<html lang="en">
|
||||
<body className="bg-zinc-950 text-white">
|
||||
{/* AuthGate is a client component; it checks the session on mount
|
||||
and bounces anonymous users to the control plane's login page
|
||||
when running on a tenant subdomain. Non-SaaS hosts (localhost,
|
||||
vercel preview URL, apex) pass through unchanged. */}
|
||||
<AuthGate>{children}</AuthGate>
|
||||
<CookieConsent />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
// quick bounce between signup and either Checkout or the tenant UI.
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchSession, redirectToLogin, signOut, type Session } from "@/lib/auth";
|
||||
import { fetchSession, redirectToLogin, type Session } from "@/lib/auth";
|
||||
import { PLATFORM_URL } from "@/lib/api";
|
||||
import { formatCredits, pillTone, bannerKind } from "@/lib/credits";
|
||||
import { TermsGate } from "@/components/TermsGate";
|
||||
@@ -110,15 +110,15 @@ export default function OrgsPage() {
|
||||
}, []);
|
||||
|
||||
if (session === "loading" || (orgs === null && error === null)) {
|
||||
return <Shell><p className="text-ink-mid">Loading…</p></Shell>;
|
||||
return <Shell><p className="text-zinc-400">Loading…</p></Shell>;
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<Shell>
|
||||
<p role="alert" className="text-bad">Error: {error}</p>
|
||||
<p className="text-red-400">Error: {error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 rounded bg-surface-card px-4 py-2 text-sm text-ink hover:bg-surface-card"
|
||||
className="mt-4 rounded bg-zinc-800 px-4 py-2 text-sm text-zinc-200 hover:bg-zinc-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -129,14 +129,14 @@ export default function OrgsPage() {
|
||||
return <EmptyState banner={justCheckedOut ? <CheckoutBanner /> : null} />;
|
||||
}
|
||||
return (
|
||||
<Shell session={session}>
|
||||
<Shell>
|
||||
{justCheckedOut && <CheckoutBanner />}
|
||||
<ul className="space-y-3">
|
||||
{orgs.map((o) => (
|
||||
<OrgRow key={o.id} org={o} />
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-8 border-t border-line pt-6">
|
||||
<div className="mt-8 border-t border-zinc-800 pt-6">
|
||||
<CreateOrgForm
|
||||
onCreated={(slug) => {
|
||||
// Refresh the list so the new org appears + its CTA fires.
|
||||
@@ -151,32 +151,22 @@ export default function OrgsPage() {
|
||||
|
||||
function CheckoutBanner() {
|
||||
return (
|
||||
<div role="status" aria-live="polite" className="mb-6 rounded-lg border border-emerald-700 bg-emerald-950 p-4">
|
||||
<div className="mb-6 rounded-lg border border-emerald-700 bg-emerald-950 p-4">
|
||||
<p className="text-sm text-emerald-200">
|
||||
<span aria-hidden="true">✓</span> Payment confirmed. Your workspace is spinning up now — this page
|
||||
✓ Payment confirmed. Your workspace is spinning up now — this page
|
||||
refreshes automatically when it's ready.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Shell({
|
||||
children,
|
||||
session,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
// Optional: when present, the header renders the signed-in email +
|
||||
// a Sign-out button. The empty-state Shell call doesn't have a
|
||||
// session in scope, so accept null and skip the header chrome there.
|
||||
session?: Session | null;
|
||||
}) {
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="min-h-screen bg-surface text-ink">
|
||||
<main className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<TermsGate>
|
||||
<div className="mx-auto max-w-2xl px-6 pt-20 pb-12">
|
||||
{session ? <AccountBar session={session} /> : null}
|
||||
<h1 className="text-3xl font-bold text-ink">Your organizations</h1>
|
||||
<p className="mt-2 text-ink-mid">
|
||||
<h1 className="text-3xl font-bold text-white">Your organizations</h1>
|
||||
<p className="mt-2 text-zinc-400">
|
||||
Each org is an isolated Molecule workspace.
|
||||
</p>
|
||||
<DataResidencyNotice />
|
||||
@@ -187,40 +177,6 @@ function Shell({
|
||||
);
|
||||
}
|
||||
|
||||
// AccountBar renders the signed-in email + a Sign-out button at the
|
||||
// top of the page. Without this the user has no way to log out — the
|
||||
// /cp/auth/signout endpoint exists on the control plane but no UI ever
|
||||
// called it. Reported externally on 2026-05-05; this is the fix.
|
||||
//
|
||||
// Click → calls signOut() which POSTs /cp/auth/signout (clears the
|
||||
// WorkOS session cookie + revokes at the provider) then bounces to
|
||||
// /cp/auth/login. The signOut helper is best-effort — even on a 5xx
|
||||
// or network failure the redirect fires so the user never gets stuck
|
||||
// on an authed-looking page after they clicked Sign out.
|
||||
function AccountBar({ session }: { session: Session }) {
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
return (
|
||||
<div className="mb-6 flex items-center justify-between text-sm text-ink-mid">
|
||||
<span title="Signed-in user">{session.email}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={signingOut}
|
||||
onClick={async () => {
|
||||
setSigningOut(true);
|
||||
await signOut();
|
||||
// Redirect happens inside signOut; this line is for tests +
|
||||
// edge cases (jsdom, blocked navigation) where it doesn't.
|
||||
setSigningOut(false);
|
||||
}}
|
||||
className="rounded border border-line bg-surface-card px-3 py-1 text-xs text-ink hover:bg-surface-card disabled:opacity-50"
|
||||
aria-label="Sign out"
|
||||
>
|
||||
{signingOut ? "Signing out…" : "Sign out"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// DataResidencyNotice surfaces where workspace data lives so EU-based
|
||||
// signups can make an informed choice (GDPR Art. 13 disclosure
|
||||
// requirement). Plain text, no icon — the goal is clarity, not
|
||||
@@ -228,7 +184,7 @@ function AccountBar({ session }: { session: Session }) {
|
||||
// region dropdown.
|
||||
function DataResidencyNotice() {
|
||||
return (
|
||||
<p className="mt-3 rounded border border-line bg-surface-sunken/60 px-3 py-2 text-xs text-ink-mid">
|
||||
<p className="mt-3 rounded border border-zinc-800 bg-zinc-900/60 px-3 py-2 text-xs text-zinc-400">
|
||||
Workspaces run in AWS us-east-2 (Ohio, United States). EU region support is on the roadmap — reach out to
|
||||
{" "}
|
||||
<a href="mailto:support@moleculesai.app" className="underline">
|
||||
@@ -241,11 +197,11 @@ function DataResidencyNotice() {
|
||||
|
||||
function OrgRow({ org }: { org: Org }) {
|
||||
return (
|
||||
<li className="rounded-lg border border-line bg-surface-sunken p-4">
|
||||
<li className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-ink">{org.name}</div>
|
||||
<div className="text-sm text-ink-mid">
|
||||
<div className="font-medium text-white">{org.name}</div>
|
||||
<div className="text-sm text-zinc-400">
|
||||
{org.slug} · <StatusLabel status={org.status} /> · {org.plan || "free"}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
@@ -281,21 +237,21 @@ function LowCreditsBanner({ org }: { org: Org }) {
|
||||
if (kind === "overage") {
|
||||
const used = (org.overage_used_credits ?? 0).toLocaleString();
|
||||
return (
|
||||
<span className="text-xs text-warm">
|
||||
<span className="text-xs text-amber-300">
|
||||
overage active · {used} used
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (kind === "out-of-credits") {
|
||||
return (
|
||||
<a href={`/pricing?org=${encodeURIComponent(org.slug)}`} className="text-xs text-bad underline">
|
||||
<a href={`/pricing?org=${encodeURIComponent(org.slug)}`} className="text-xs text-red-300 underline">
|
||||
out of credits — upgrade to keep running
|
||||
</a>
|
||||
);
|
||||
}
|
||||
// trial-tail
|
||||
return (
|
||||
<a href={`/pricing?org=${encodeURIComponent(org.slug)}`} className="text-xs text-warm underline">
|
||||
<a href={`/pricing?org=${encodeURIComponent(org.slug)}`} className="text-xs text-amber-300 underline">
|
||||
trial almost out
|
||||
</a>
|
||||
);
|
||||
@@ -304,11 +260,11 @@ function LowCreditsBanner({ org }: { org: Org }) {
|
||||
function StatusLabel({ status }: { status: OrgStatus }) {
|
||||
const cls =
|
||||
status === "running"
|
||||
? "text-good"
|
||||
? "text-emerald-400"
|
||||
: status === "awaiting_payment"
|
||||
? "text-warm"
|
||||
? "text-amber-400"
|
||||
: status === "failed"
|
||||
? "text-bad"
|
||||
? "text-red-400"
|
||||
: "text-sky-400";
|
||||
const label =
|
||||
status === "awaiting_payment"
|
||||
@@ -347,22 +303,22 @@ function OrgCTA({ org }: { org: Org }) {
|
||||
return (
|
||||
<a
|
||||
href="mailto:support@moleculesai.app"
|
||||
className="rounded bg-surface-card px-4 py-2 text-sm font-medium text-ink hover:bg-surface-card"
|
||||
className="rounded bg-zinc-700 px-4 py-2 text-sm font-medium text-zinc-200 hover:bg-zinc-600"
|
||||
>
|
||||
Contact support
|
||||
</a>
|
||||
);
|
||||
}
|
||||
// provisioning / unknown — non-interactive
|
||||
return <span className="text-sm text-ink-soft">{org.status}…</span>;
|
||||
return <span className="text-sm text-zinc-500">{org.status}…</span>;
|
||||
}
|
||||
|
||||
function EmptyState({ banner }: { banner?: React.ReactNode }) {
|
||||
return (
|
||||
<Shell>
|
||||
{banner}
|
||||
<p className="text-ink-mid">
|
||||
You don't have any organizations yet. Create one to get started — your
|
||||
<p className="text-zinc-300">
|
||||
You don't have any organizations yet. Create one to get started — your
|
||||
workspace spins up automatically once billing is set up.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
@@ -408,38 +364,32 @@ function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) {
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="org-slug" className="block text-sm text-ink-mid">Slug (URL)</label>
|
||||
<label className="block">
|
||||
<span className="text-sm text-zinc-300">Slug (URL)</span>
|
||||
<input
|
||||
id="org-slug"
|
||||
value={slug}
|
||||
onChange={(e) => setSlug(e.target.value.toLowerCase())}
|
||||
pattern="^[a-z][a-z0-9-]{2,31}$"
|
||||
placeholder="acme"
|
||||
required
|
||||
aria-describedby="org-slug-hint"
|
||||
className="mt-1 w-full rounded border border-line bg-surface-card px-3 py-2 text-sm text-ink"
|
||||
className="mt-1 w-full rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100"
|
||||
/>
|
||||
<p id="org-slug-hint" className="mt-1 text-xs text-ink-soft">
|
||||
Lowercase letters, numbers, and hyphens only. Cannot be changed later.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="org-name" className="block text-sm text-ink-mid">Display name</label>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm text-zinc-300">Display name</span>
|
||||
<input
|
||||
id="org-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Acme Corp"
|
||||
required
|
||||
className="mt-1 w-full rounded border border-line bg-surface-card px-3 py-2 text-sm text-ink"
|
||||
className="mt-1 w-full rounded border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
{err && <p role="alert" className="text-sm text-bad">{err}</p>}
|
||||
</label>
|
||||
{err && <p className="text-sm text-red-400">{err}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="rounded bg-accent-strong px-4 py-2 text-sm font-medium text-white hover:bg-accent disabled:opacity-50"
|
||||
className="rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "Creating…" : "Create organization"}
|
||||
</button>
|
||||
|
||||
+8
-66
@@ -7,19 +7,13 @@ import { CommunicationOverlay } from "@/components/CommunicationOverlay";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { connectSocket, disconnectSocket } from "@/store/socket";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { api, PlatformUnavailableError } from "@/lib/api";
|
||||
import { api } from "@/lib/api";
|
||||
import type { WorkspaceData } from "@/store/socket";
|
||||
|
||||
export default function Home() {
|
||||
const hydrationError = useCanvasStore((s) => s.hydrationError);
|
||||
const setHydrationError = useCanvasStore((s) => s.setHydrationError);
|
||||
const [hydrating, setHydrating] = useState(true);
|
||||
// Distinct from hydrationError: platform-down is its own UX path
|
||||
// (different copy, different action — the user's next step is to
|
||||
// check local services, not to retry the API call). Tracked
|
||||
// separately rather than encoded into hydrationError so the
|
||||
// generic-error branch can stay simple.
|
||||
const [platformDown, setPlatformDown] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
connectSocket();
|
||||
@@ -34,11 +28,8 @@ export default function Home() {
|
||||
useCanvasStore.getState().setViewport(viewport);
|
||||
}
|
||||
}).catch((err) => {
|
||||
// Initial hydration failed — show error banner to user
|
||||
console.error("Canvas: initial hydration failed", err);
|
||||
if (err instanceof PlatformUnavailableError) {
|
||||
setPlatformDown(true);
|
||||
return;
|
||||
}
|
||||
useCanvasStore.getState().setHydrationError(
|
||||
err instanceof Error && err.message ? err.message : "Failed to load canvas"
|
||||
);
|
||||
@@ -53,19 +44,15 @@ export default function Home() {
|
||||
|
||||
if (hydrating) {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-surface">
|
||||
<div role="status" aria-live="polite" className="flex flex-col items-center gap-3">
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-zinc-950">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Spinner size="lg" />
|
||||
<span className="text-xs text-ink-soft">Loading canvas...</span>
|
||||
<span className="text-xs text-zinc-500">Loading canvas...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (platformDown) {
|
||||
return <PlatformDownDiagnostic />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Canvas />
|
||||
@@ -74,20 +61,15 @@ export default function Home() {
|
||||
{hydrationError && (
|
||||
<div
|
||||
role="alert"
|
||||
// Stable testid so the staging E2E (canvas/e2e/staging-tabs.spec.ts)
|
||||
// can detect this banner without depending on the role="alert"
|
||||
// selector that's used by other transient toasts. Don't rename
|
||||
// without updating that spec.
|
||||
data-testid="hydration-error"
|
||||
className="fixed inset-0 flex flex-col items-center justify-center bg-surface text-ink-mid gap-4 z-[9999]"
|
||||
className="fixed inset-0 flex flex-col items-center justify-center bg-zinc-950 text-zinc-300 gap-4 z-[9999]"
|
||||
>
|
||||
<p className="text-ink-mid text-sm">{hydrationError}</p>
|
||||
<p className="text-zinc-400 text-sm">{hydrationError}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setHydrationError(null);
|
||||
window.location.reload();
|
||||
}}
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm"
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-md text-sm"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -96,43 +78,3 @@ export default function Home() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedicated diagnostic for the case where the platform reported its
|
||||
* datastore (Postgres / Redis) is unreachable. Distinct from the
|
||||
* generic API-error overlay: the user's next action is to check
|
||||
* local services, not to retry the API call. Includes the exact
|
||||
* commands for the common dev-host setup.
|
||||
*/
|
||||
function PlatformDownDiagnostic() {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="fixed inset-0 flex flex-col items-center justify-center bg-surface text-ink-mid gap-5 z-[9999] px-6"
|
||||
>
|
||||
<div className="text-warm text-sm font-semibold uppercase tracking-wider">
|
||||
Platform infrastructure unreachable
|
||||
</div>
|
||||
<p className="text-ink-mid text-sm max-w-lg text-center leading-relaxed">
|
||||
The platform server returned <code className="font-mono text-warm">503 platform_unavailable</code>.
|
||||
That means it can't reach Postgres or Redis to validate your session.
|
||||
Most common cause on a dev host: one of those services stopped.
|
||||
</p>
|
||||
<div className="bg-surface-sunken/80 border border-line/50 rounded-lg px-4 py-3 max-w-lg w-full">
|
||||
<div className="text-[10px] uppercase tracking-wider text-ink-soft mb-2">Try first</div>
|
||||
<pre className="text-[12px] text-ink-mid font-mono whitespace-pre-wrap leading-relaxed">{`brew services start postgresql@14
|
||||
brew services start redis`}</pre>
|
||||
</div>
|
||||
<p className="text-[11px] text-ink-soft max-w-lg text-center">
|
||||
If both are running, check <code className="font-mono">/tmp/molecule-server.log</code> for
|
||||
the underlying error. If you're on hosted SaaS, this is a platform incident — try again in a moment.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm mt-2"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,65 +14,61 @@ import { PricingTable } from "@/components/PricingTable";
|
||||
export const metadata = {
|
||||
title: "Pricing — Molecule AI",
|
||||
description:
|
||||
"Flat-rate team and org pricing — no per-seat fees. Free to start, $29/month for teams, $99/month for production orgs. Full runtime stack included on every paid tier.",
|
||||
"Free while you tinker, paid tiers for shipping production multi-agent organizations. Transparent usage-based overage pricing on Pro.",
|
||||
};
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-surface text-ink">
|
||||
<main className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<div className="mx-auto max-w-5xl px-6 pt-20 pb-8 text-center">
|
||||
<h1 className="text-5xl font-bold tracking-tight text-ink md:text-6xl">
|
||||
<h1 className="text-5xl font-bold tracking-tight text-white md:text-6xl">
|
||||
Pricing
|
||||
</h1>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-ink-mid">
|
||||
One flat price per org — not per seat. Every paid tier includes the
|
||||
full runtime stack. You upgrade for scale, support, and dedicated
|
||||
infrastructure.
|
||||
</p>
|
||||
<p className="mx-auto mt-2 max-w-xl text-sm text-ink-mid">
|
||||
5-person team? You pay $29/month — not $200. No seat math, ever.
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-zinc-300">
|
||||
Free while you tinker. Pay when you ship real agents to production.
|
||||
Every tier includes the full runtime stack — you upgrade for scale,
|
||||
support, and dedicated infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PricingTable />
|
||||
|
||||
<section className="mx-auto mt-20 max-w-3xl px-6 text-center">
|
||||
<h2 className="text-2xl font-semibold text-ink">Questions?</h2>
|
||||
<p className="mt-2 text-ink-mid">
|
||||
<h2 className="text-2xl font-semibold text-white">Questions?</h2>
|
||||
<p className="mt-2 text-zinc-400">
|
||||
We publish the{" "}
|
||||
<a
|
||||
href="https://github.com/Molecule-AI/molecule-monorepo"
|
||||
className="text-accent underline hover:text-accent"
|
||||
className="text-blue-400 underline hover:text-blue-300"
|
||||
>
|
||||
full source on GitHub
|
||||
</a>
|
||||
{" "}— if something's ambiguous, file an issue or{" "}
|
||||
<a
|
||||
href="mailto:support@moleculesai.app"
|
||||
className="text-accent underline hover:text-accent"
|
||||
className="text-blue-400 underline hover:text-blue-300"
|
||||
>
|
||||
email support
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p className="mt-6 text-sm text-ink-soft">
|
||||
Prices shown in USD. Flat-rate per org — no per-seat fees on any paid tier.
|
||||
Enterprise / self-hosted licensing available — contact us.
|
||||
<p className="mt-6 text-sm text-zinc-500">
|
||||
Prices shown in USD. Enterprise / self-hosted licensing available — contact us.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<footer className="mx-auto mt-20 max-w-5xl border-t border-line px-6 py-6 text-center text-sm text-ink-soft">
|
||||
<footer className="mx-auto mt-20 max-w-5xl border-t border-zinc-800 px-6 py-6 text-center text-sm text-zinc-500">
|
||||
<p>
|
||||
© {new Date().getFullYear()} Molecule AI, Inc. ·{" "}
|
||||
<a href="/legal/terms" className="hover:text-ink-mid">
|
||||
<a href="/legal/terms" className="hover:text-zinc-300">
|
||||
Terms
|
||||
</a>
|
||||
{" "}·{" "}
|
||||
<a href="/legal/privacy" className="hover:text-ink-mid">
|
||||
<a href="/legal/privacy" className="hover:text-zinc-300">
|
||||
Privacy
|
||||
</a>
|
||||
{" "}·{" "}
|
||||
<a href="/legal/dpa" className="hover:text-ink-mid">
|
||||
<a href="/legal/dpa" className="hover:text-zinc-300">
|
||||
DPA
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -74,11 +74,7 @@ export function buildA2AEdges(
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Build React Flow Edge objects. We tag every overlay edge with
|
||||
// type: "a2a" so React Flow renders it via our custom A2AEdge
|
||||
// component (canvas/A2AEdge.tsx). The custom component portals
|
||||
// its label out of the SVG layer so it (a) doesn't get hidden
|
||||
// behind workspace cards and (b) is clickable.
|
||||
// 3. Build React Flow Edge objects
|
||||
return Array.from(map.values()).map(({ source, target, count, lastAt }) => {
|
||||
const isHot = now - lastAt < A2A_HOT_MS;
|
||||
const stroke = isHot ? "#8b5cf6" : "#3b82f6"; // violet-500 : blue-500
|
||||
@@ -88,7 +84,6 @@ export function buildA2AEdges(
|
||||
|
||||
return {
|
||||
id: `a2a-${source}-${target}`,
|
||||
type: "a2a",
|
||||
source,
|
||||
target,
|
||||
animated: isHot,
|
||||
@@ -101,22 +96,22 @@ export function buildA2AEdges(
|
||||
style: {
|
||||
stroke,
|
||||
strokeWidth: 2,
|
||||
// Path itself stays non-interactive so node drags through
|
||||
// the line still work. The clickable target is the label
|
||||
// pill, which sets pointerEvents: all on its own div.
|
||||
// Non-blocking: label overlay never intercepts pointer events
|
||||
pointerEvents: "none" as React.CSSProperties["pointerEvents"],
|
||||
},
|
||||
// `label` keeps the same string for back-compat with any test
|
||||
// that asserts on it (e.g. buildA2AEdges output shape). Custom
|
||||
// edge reads the rich data from `data` so the label visual is
|
||||
// not constrained to a string anymore.
|
||||
label,
|
||||
data: {
|
||||
count,
|
||||
lastAt,
|
||||
isHot,
|
||||
label,
|
||||
labelStyle: {
|
||||
fill: "#a1a1aa", // zinc-400
|
||||
fontSize: 10,
|
||||
pointerEvents: "none" as React.CSSProperties["pointerEvents"],
|
||||
},
|
||||
labelBgStyle: {
|
||||
fill: "#18181b", // zinc-900
|
||||
fillOpacity: 0.9,
|
||||
pointerEvents: "none" as React.CSSProperties["pointerEvents"],
|
||||
},
|
||||
labelBgPadding: [4, 6] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -138,37 +133,14 @@ export function A2ATopologyOverlay() {
|
||||
// Stable Zustand action reference — safe to call inside effects
|
||||
const setA2AEdges = useCanvasStore((s) => s.setA2AEdges);
|
||||
|
||||
// Subscribe to a STABLE STRING KEY of visible workspace IDs, not the
|
||||
// nodes array itself. Zustand returns a new array reference on every
|
||||
// store update (status flips, position drags, peer-discovery writes,
|
||||
// workspace-tab opens, etc.) — even when the set of visible IDs is
|
||||
// unchanged. Selecting a sorted-CSV string makes Zustand's default
|
||||
// shallow-equal short-circuit the re-render unless the actual ID set
|
||||
// changes.
|
||||
//
|
||||
// Why this matters: previously visibleIds was useMemo'd on `nodes`, so
|
||||
// the array reference recreated on every store mutation. fetchAndUpdate
|
||||
// (useCallback'd on visibleIds) then recreated, the useEffect re-fired,
|
||||
// it tore down the 60s setInterval and immediately re-ran the fan-out.
|
||||
// With ~5 store updates/second from heartbeats + polling, the canvas
|
||||
// hammered /workspaces/<id>/activity?type=delegation 5×N requests/sec
|
||||
// until edge rate-limit kicked in with HTTP 429. The recursive React
|
||||
// render trace in the original bug report (uE → ux → uE → ux ...) is
|
||||
// the symptom of this re-render storm.
|
||||
//
|
||||
// The fix is purely the dependency-stability change here; the fetch
|
||||
// logic is unchanged.
|
||||
const visibleIdsKey = useCanvasStore((s) =>
|
||||
s.nodes
|
||||
.filter((n) => !n.hidden)
|
||||
.map((n) => n.id)
|
||||
.sort()
|
||||
.join(",")
|
||||
);
|
||||
// Read the nodes array as a primitive ref; derive visible IDs outside the selector
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
|
||||
// IDs of visible (non-nested, non-hidden) workspace nodes.
|
||||
// Recomputed only when the nodes array reference changes.
|
||||
const visibleIds = useMemo(
|
||||
() => (visibleIdsKey ? visibleIdsKey.split(",") : []),
|
||||
[visibleIdsKey]
|
||||
() => nodes.filter((n) => !n.hidden).map((n) => n.id),
|
||||
[nodes]
|
||||
);
|
||||
|
||||
// Fetch delegation activity for all visible workspaces and rebuild overlay edges.
|
||||
|
||||
@@ -61,31 +61,24 @@ export function ApprovalBanner() {
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-800/40 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<span className="text-warm text-lg" aria-hidden="true">⚠</span>
|
||||
<span className="text-amber-300 text-lg" aria-hidden="true">⚠</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-amber-200 font-semibold">{approval.workspace_name} needs approval</div>
|
||||
<div className="text-sm text-amber-100 mt-0.5 font-medium">{approval.action}</div>
|
||||
{approval.reason && (
|
||||
<div className="text-xs text-warm/70 mt-1">{approval.reason}</div>
|
||||
<div className="text-xs text-amber-300/70 mt-1">{approval.reason}</div>
|
||||
)}
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDecide(approval, "approved")}
|
||||
// Hover DARKER not lighter — emerald-500 on white text
|
||||
// drops contrast vs emerald-700.
|
||||
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-xs rounded-lg text-white font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-emerald-400/70"
|
||||
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-xs rounded-lg text-white font-medium transition-colors"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDecide(approval, "denied")}
|
||||
// Was a no-op hover (`bg-surface-card hover:bg-surface-card`).
|
||||
// Lift to surface-elevated on hover so the button visibly
|
||||
// responds before a destructive deny.
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-xs rounded-lg text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-amber-400/70"
|
||||
className="px-3 py-1.5 bg-zinc-700 hover:bg-zinc-600 text-xs rounded-lg text-zinc-300 transition-colors"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { AuditEntry, AuditResponse } from "@/types/audit";
|
||||
type EventFilter = "all" | AuditEntry["event_type"];
|
||||
|
||||
const BADGE_COLORS: Record<AuditEntry["event_type"], { text: string; bg: string; border: string }> = {
|
||||
delegation: { text: "text-accent", bg: "bg-blue-950/40", border: "border-blue-800/40" },
|
||||
delegation: { text: "text-blue-400", bg: "bg-blue-950/40", border: "border-blue-800/40" },
|
||||
decision: { text: "text-violet-400", bg: "bg-violet-950/40", border: "border-violet-800/40" },
|
||||
gate: { text: "text-yellow-400", bg: "bg-yellow-950/40", border: "border-yellow-800/40" },
|
||||
hitl: { text: "text-orange-400", bg: "bg-orange-950/40", border: "border-orange-800/40" },
|
||||
@@ -127,7 +127,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<span className="text-xs text-ink-soft">Loading audit trail…</span>
|
||||
<span className="text-xs text-zinc-500">Loading audit trail…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -135,17 +135,16 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Filter bar */}
|
||||
<div className="px-4 py-2.5 border-b border-line/40 flex items-center gap-1 overflow-x-auto shrink-0">
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800/40 flex items-center gap-1 overflow-x-auto shrink-0">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
type="button"
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
aria-pressed={filter === f.id}
|
||||
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${
|
||||
filter === f.id
|
||||
? "bg-surface-card text-ink ring-1 ring-zinc-600"
|
||||
: "text-ink-soft hover:text-ink-mid hover:bg-surface-card/60"
|
||||
? "bg-zinc-700 text-zinc-100 ring-1 ring-zinc-600"
|
||||
: "text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800/60"
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
@@ -153,9 +152,8 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadEntries}
|
||||
className="px-2 py-1 text-[10px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors shrink-0"
|
||||
className="px-2 py-1 text-[10px] bg-zinc-800 hover:bg-zinc-700 text-zinc-400 rounded transition-colors shrink-0"
|
||||
aria-label="Refresh audit trail"
|
||||
>
|
||||
↻
|
||||
@@ -164,7 +162,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
|
||||
{/* Error banner */}
|
||||
{error && (
|
||||
<div className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-bad shrink-0">
|
||||
<div className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-red-400 shrink-0">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -174,9 +172,9 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
{entries.length === 0 ? (
|
||||
/* Empty state */
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<span className="text-4xl text-ink-soft" aria-hidden="true">⊟</span>
|
||||
<p className="text-sm font-medium text-ink-mid">No audit events yet</p>
|
||||
<p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
|
||||
<span className="text-4xl text-zinc-700" aria-hidden="true">⊟</span>
|
||||
<p className="text-sm font-medium text-zinc-400">No audit events yet</p>
|
||||
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
|
||||
Delegation, decision, gate, and human-in-the-loop events will appear here.
|
||||
</p>
|
||||
</div>
|
||||
@@ -192,10 +190,9 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
{cursor && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
className="px-4 py-2 text-[11px] bg-surface-card hover:bg-surface-card disabled:opacity-50 disabled:cursor-not-allowed text-ink-mid rounded-lg transition-colors"
|
||||
className="px-4 py-2 text-[11px] bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed text-zinc-300 rounded-lg transition-colors"
|
||||
>
|
||||
{loadingMore ? "Loading…" : "Load more"}
|
||||
</button>
|
||||
@@ -203,7 +200,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
)}
|
||||
|
||||
{/* Entry count footer */}
|
||||
<p className="mt-3 text-center text-[9px] text-ink-soft">
|
||||
<p className="mt-3 text-center text-[9px] text-zinc-600">
|
||||
{entries.length} event{entries.length !== 1 ? "s" : ""} loaded
|
||||
{cursor ? " · more available" : " · all loaded"}
|
||||
</p>
|
||||
@@ -227,15 +224,15 @@ export interface AuditEntryRowProps {
|
||||
*/
|
||||
export function AuditEntryRow({ entry, now }: AuditEntryRowProps) {
|
||||
const badge = BADGE_COLORS[entry.event_type] ?? {
|
||||
text: "text-ink-mid",
|
||||
bg: "bg-surface-card/40",
|
||||
border: "border-line/40",
|
||||
text: "text-zinc-400",
|
||||
bg: "bg-zinc-800/40",
|
||||
border: "border-zinc-700/40",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="listitem"
|
||||
className="rounded-lg border border-line/60 bg-surface-sunken/50 px-3 py-2.5 space-y-1.5"
|
||||
className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 px-3 py-2.5 space-y-1.5"
|
||||
>
|
||||
{/* Header row: badge · actor · tamper flag · timestamp */}
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -248,14 +245,14 @@ export function AuditEntryRow({ entry, now }: AuditEntryRowProps) {
|
||||
</span>
|
||||
|
||||
{/* Actor name */}
|
||||
<span className="text-[10px] text-ink-mid truncate flex-1 min-w-0 font-mono">
|
||||
<span className="text-[10px] text-zinc-400 truncate flex-1 min-w-0 font-mono">
|
||||
{entry.actor}
|
||||
</span>
|
||||
|
||||
{/* Tamper warning — only rendered when chain is invalid */}
|
||||
{!entry.chain_valid && (
|
||||
<span
|
||||
className="shrink-0 text-[11px] text-bad font-bold leading-none"
|
||||
className="shrink-0 text-[11px] text-red-400 font-bold leading-none"
|
||||
title="Chain integrity check failed — this entry may have been tampered with"
|
||||
aria-label="Chain integrity warning: tampered entry"
|
||||
role="img"
|
||||
@@ -265,13 +262,13 @@ export function AuditEntryRow({ entry, now }: AuditEntryRowProps) {
|
||||
)}
|
||||
|
||||
{/* Relative timestamp */}
|
||||
<span className="shrink-0 text-[9px] text-ink-soft">
|
||||
<span className="shrink-0 text-[9px] text-zinc-600">
|
||||
{formatAuditRelativeTime(entry.created_at, now)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Summary text */}
|
||||
<p className="text-[11px] text-ink-mid leading-relaxed break-words">
|
||||
<p className="text-[11px] text-zinc-300 leading-relaxed break-words">
|
||||
{entry.summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -29,11 +29,6 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
setState({ kind: "anonymous", skipRedirect: true });
|
||||
return;
|
||||
}
|
||||
// Never gate /cp/auth/* paths — these ARE the login pages.
|
||||
if (typeof window !== "undefined" && window.location.pathname.startsWith("/cp/auth/")) {
|
||||
setState({ kind: "anonymous", skipRedirect: true });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
fetchSession()
|
||||
.then((s) => {
|
||||
@@ -63,7 +58,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
if (state.kind === "loading") {
|
||||
// Zinc-950 backdrop matches the canvas background so the browser
|
||||
// never paints a white flash while the session round-trip resolves.
|
||||
return <div className="fixed inset-0 bg-surface" aria-hidden="true" />;
|
||||
return <div className="fixed inset-0 bg-zinc-950" aria-hidden="true" />;
|
||||
}
|
||||
if (state.kind === "anonymous" && !state.skipRedirect) {
|
||||
// Redirect already firing from the effect above; render nothing in
|
||||
|
||||
@@ -30,24 +30,6 @@ 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;
|
||||
@@ -98,18 +80,17 @@ export function BatchActionBar() {
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Batch workspace actions"
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[200] flex items-center gap-3 px-4 py-2.5 rounded-2xl bg-surface-sunken/95 border border-line/70 shadow-2xl shadow-black/50 backdrop-blur-md"
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[200] flex items-center gap-3 px-4 py-2.5 rounded-2xl bg-zinc-900/95 border border-zinc-700/70 shadow-2xl shadow-black/50 backdrop-blur-md"
|
||||
>
|
||||
{/* Selection count badge */}
|
||||
<span className="text-[12px] font-semibold text-white bg-accent-strong/80 px-2.5 py-0.5 rounded-full tabular-nums">
|
||||
<span className="text-[12px] font-semibold text-zinc-100 bg-blue-600/80 px-2.5 py-0.5 rounded-full tabular-nums">
|
||||
{count} selected
|
||||
</span>
|
||||
|
||||
<div className="w-px h-5 bg-surface-card/60" aria-hidden="true" />
|
||||
<div className="w-px h-5 bg-zinc-700/60" aria-hidden="true" />
|
||||
|
||||
{/* Action buttons */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setPending("restart")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-sky-300 bg-sky-900/30 hover:bg-sky-800/50 border border-sky-700/30 hover:border-sky-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70"
|
||||
@@ -119,35 +100,32 @@ export function BatchActionBar() {
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setPending("pause")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-warm bg-amber-900/30 hover:bg-amber-800/50 border border-amber-700/30 hover:border-amber-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-amber-300 bg-amber-900/30 hover:bg-amber-800/50 border border-amber-700/30 hover:border-amber-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
|
||||
>
|
||||
<span aria-hidden="true">⏸</span>
|
||||
Pause All
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => setPending("delete")}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-bad bg-red-900/30 hover:bg-red-800/50 border border-red-700/30 hover:border-red-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-red-300 bg-red-900/30 hover:bg-red-800/50 border border-red-700/30 hover:border-red-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70"
|
||||
>
|
||||
<span aria-hidden="true">✕</span>
|
||||
Delete All
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-surface-card/60" aria-hidden="true" />
|
||||
<div className="w-px h-5 bg-zinc-700/60" aria-hidden="true" />
|
||||
|
||||
{/* Deselect */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
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-accent/50"
|
||||
className="p-1.5 rounded-lg text-[12px] text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500/70"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
@@ -108,44 +108,30 @@ export function BundleDropZone() {
|
||||
{/* Keyboard-accessible import button — visible on focus or hover so
|
||||
keyboard / AT users can trigger bundle import without drag-and-drop (WCAG 2.1.1) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
aria-label="Import bundle file"
|
||||
aria-controls="bundle-file-input"
|
||||
className="sr-only focus:not-sr-only fixed bottom-20 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent transition-colors"
|
||||
className="sr-only focus:not-sr-only fixed bottom-20 right-4 z-30 px-3 py-1.5 bg-zinc-900/90 border border-zinc-700/50 rounded-lg text-[10px] text-zinc-400 hover:text-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 transition-colors"
|
||||
>
|
||||
📦 Import bundle
|
||||
</button>
|
||||
|
||||
{/* Visual overlay when dragging — was hardcoded blue-950/blue-400
|
||||
which doesn't flip with theme. accent colors stay visually
|
||||
consistent with the rest of the canvas in both modes. */}
|
||||
{/* Visual overlay when dragging */}
|
||||
{isDragging && (
|
||||
<div className="fixed inset-0 z-20 flex items-center justify-center bg-accent/15 backdrop-blur-sm border-2 border-dashed border-accent/40 pointer-events-none">
|
||||
<div className="bg-surface-sunken/95 border border-accent/50 rounded-2xl px-8 py-6 shadow-2xl text-center">
|
||||
<div className="fixed inset-0 z-20 flex items-center justify-center bg-blue-950/40 backdrop-blur-sm border-2 border-dashed border-blue-400/50 pointer-events-none">
|
||||
<div className="bg-zinc-900/95 border border-blue-500/50 rounded-2xl px-8 py-6 shadow-2xl text-center">
|
||||
<div className="text-3xl mb-2" aria-hidden="true">📦</div>
|
||||
<div className="text-sm font-semibold text-ink">Drop Bundle to Import</div>
|
||||
<div className="text-xs text-ink-soft mt-1">.bundle.json files only</div>
|
||||
<div className="text-sm font-semibold text-zinc-100">Drop Bundle to Import</div>
|
||||
<div className="text-xs text-zinc-500 mt-1">.bundle.json files only</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Importing indicator — role=status + aria-live so SR users hear
|
||||
"Importing bundle..." while the API call is in flight, not just
|
||||
the result toast that fires after. motion-safe:animate-spin
|
||||
respects prefers-reduced-motion (Tailwind's motion-safe variant
|
||||
gates animation on the user's OS setting). */}
|
||||
{/* Importing spinner */}
|
||||
{importing && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-surface-sunken/95 border border-line/60 rounded-xl px-5 py-3 shadow-2xl flex items-center gap-3"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="w-4 h-4 border-2 border-accent border-t-transparent rounded-full motion-safe:animate-spin"
|
||||
/>
|
||||
<span className="text-sm text-ink">Importing bundle...</span>
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-zinc-900/95 border border-zinc-700/60 rounded-xl px-5 py-3 shadow-2xl flex items-center gap-3">
|
||||
<div className="w-4 h-4 border-2 border-sky-400 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-sm text-zinc-200">Importing bundle...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
+338
-271
@@ -1,19 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useRef, useMemo, useEffect, useState } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useReactFlow,
|
||||
type OnNodeDrag,
|
||||
type Node,
|
||||
type Edge,
|
||||
BackgroundVariant,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { useTheme } from "@/lib/theme-provider";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { A2ATopologyOverlay } from "./A2ATopologyOverlay";
|
||||
import { WorkspaceNode } from "./WorkspaceNode";
|
||||
import { SidePanel } from "./SidePanel";
|
||||
@@ -25,34 +27,30 @@ import { BundleDropZone } from "./BundleDropZone";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { OnboardingWizard } from "./OnboardingWizard";
|
||||
import { SearchDialog } from "./SearchDialog";
|
||||
import { Toaster, showToast } from "./Toaster";
|
||||
import { Toaster } from "./Toaster";
|
||||
import { Toolbar } from "./Toolbar";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
import { DeleteCascadeConfirmDialog } from "./DeleteCascadeConfirmDialog";
|
||||
import { api } from "@/lib/api";
|
||||
import { showToast } from "./Toaster";
|
||||
// Phase 20 components
|
||||
import { SettingsPanel, DeleteConfirmDialog } from "./settings";
|
||||
// Phase 20.3 batch operations
|
||||
import { BatchActionBar } from "./BatchActionBar";
|
||||
import { ProvisioningTimeout } from "./ProvisioningTimeout";
|
||||
|
||||
import { DropTargetBadge } from "./canvas/DropTargetBadge";
|
||||
import { useDragHandlers } from "./canvas/useDragHandlers";
|
||||
import { useKeyboardShortcuts } from "./canvas/useKeyboardShortcuts";
|
||||
import { useCanvasViewport } from "./canvas/useCanvasViewport";
|
||||
import { A2AEdge } from "./canvas/A2AEdge";
|
||||
// Drag-to-nest proximity: nodes must be within this many pixels (center-to-center)
|
||||
// to trigger the "Nest Workspace" dialog. The default ReactFlow intersection
|
||||
// detection uses bounding-box overlap which fires from large distances when
|
||||
// nodes have large CSS min-width/min-height values.
|
||||
const NEST_PROXIMITY_THRESHOLD = 150; // px — ~60% of a collapsed node width
|
||||
const DEFAULT_NODE_WIDTH = 245; // px — approx mid-range of min-w-[210px] / max-w-[280px]
|
||||
const DEFAULT_NODE_HEIGHT = 110; // px — approx min-height for a collapsed node
|
||||
|
||||
const nodeTypes = {
|
||||
workspaceNode: WorkspaceNode,
|
||||
};
|
||||
|
||||
// Custom edge types. The default React Flow edge renders its label
|
||||
// inside the SVG group (always under nodes) with pointerEvents: none
|
||||
// inherited from the path. A2AEdge portals the label to a sibling
|
||||
// DOM layer so it renders above nodes and accepts clicks. Keep the
|
||||
// reference stable (module-scope const) so React Flow doesn't see a
|
||||
// new edgeTypes object on every render and warn about prop churn.
|
||||
const edgeTypes = {
|
||||
a2a: A2AEdge,
|
||||
};
|
||||
|
||||
const defaultEdgeOptions: Partial<Edge> = {
|
||||
animated: true,
|
||||
style: {
|
||||
@@ -70,167 +68,124 @@ export function Canvas() {
|
||||
}
|
||||
|
||||
function CanvasInner() {
|
||||
// ReactFlow's `colorMode` prop drives the styling of every viewport
|
||||
// primitive it renders directly (background dots, edge defaults,
|
||||
// selection rings, controls, minimap mask). Pre-fix this was hard-pinned
|
||||
// to "dark" — so on light theme the chrome (toolbar, side panel) flipped
|
||||
// to warm-paper but the canvas backplate + edges stayed black, leaving a
|
||||
// half-themed page. Pull resolvedTheme so the canvas matches the user's
|
||||
// selected mode (and the system preference when they pick "system").
|
||||
const { resolvedTheme } = useTheme();
|
||||
const rawNodes = useCanvasStore((s) => s.nodes);
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
const edges = useCanvasStore((s) => s.edges);
|
||||
const a2aEdges = useCanvasStore((s) => s.a2aEdges);
|
||||
const showA2AEdges = useCanvasStore((s) => s.showA2AEdges);
|
||||
const deletingIds = useCanvasStore((s) => s.deletingIds);
|
||||
// Merge topology edges with A2A overlay edges via useMemo (no new object in selector)
|
||||
const allEdges = useMemo(
|
||||
() => (showA2AEdges ? [...edges, ...a2aEdges] : edges),
|
||||
[edges, a2aEdges, showA2AEdges],
|
||||
[edges, a2aEdges, showA2AEdges]
|
||||
);
|
||||
// Drag-lock during a system-owned operation (deploy OR delete).
|
||||
// React Flow respects Node.draggable, which stops the gesture
|
||||
// before it starts — preventDefault() on the drag-start callback
|
||||
// isn't authoritative in v12. We project `draggable: false` onto
|
||||
// each locked node before handing the array to ReactFlow; the
|
||||
// drag-start handler in useDragHandlers remains as a belt-and-
|
||||
// braces check.
|
||||
//
|
||||
// Perf: short-circuit when nothing is provisioning so the memo
|
||||
// passes rawNodes through unchanged (identity-stable → RF
|
||||
// reconciles nothing). When a deploy IS active, build an O(n)
|
||||
// root index once and re-use it. Critically, do NOT spread every
|
||||
// node — only mutate the locked ones — so unmodified nodes keep
|
||||
// their object identity and RF's per-node memo short-circuits.
|
||||
const nodes = useMemo(() => {
|
||||
const anyProvisioning = rawNodes.some((n) => n.data.status === "provisioning");
|
||||
const anyDeleting = deletingIds.size > 0;
|
||||
if (!anyProvisioning && !anyDeleting) return rawNodes;
|
||||
|
||||
const byId = new Map<string, typeof rawNodes[number]>();
|
||||
for (const n of rawNodes) byId.set(n.id, n);
|
||||
const rootOf = new Map<string, string>();
|
||||
const resolveRoot = (id: string): string => {
|
||||
// Iterative walk guards against a pathological cycle (hostile
|
||||
// data) — recursion would hit the stack limit on a deep tree.
|
||||
const visited = new Set<string>();
|
||||
let cursor: string | null = id;
|
||||
while (cursor) {
|
||||
if (visited.has(cursor)) break;
|
||||
visited.add(cursor);
|
||||
const cached = rootOf.get(cursor);
|
||||
if (cached) {
|
||||
for (const seenId of visited) rootOf.set(seenId, cached);
|
||||
return cached;
|
||||
}
|
||||
const n = byId.get(cursor);
|
||||
if (!n) break;
|
||||
if (!n.data.parentId) {
|
||||
for (const seenId of visited) rootOf.set(seenId, cursor);
|
||||
return cursor;
|
||||
}
|
||||
cursor = n.data.parentId;
|
||||
}
|
||||
return id;
|
||||
};
|
||||
|
||||
const provisioningByRoot = new Map<string, number>();
|
||||
for (const n of rawNodes) {
|
||||
if (n.data.status !== "provisioning") continue;
|
||||
const rootId = resolveRoot(n.id);
|
||||
provisioningByRoot.set(rootId, (provisioningByRoot.get(rootId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
let touched = false;
|
||||
const next = rawNodes.map((n) => {
|
||||
const rootId = resolveRoot(n.id);
|
||||
const deployLocked = n.id !== rootId && (provisioningByRoot.get(rootId) ?? 0) > 0;
|
||||
// Delete-locked: nothing in a subtree whose DELETE is in
|
||||
// flight should be draggable, INCLUDING the root of that
|
||||
// subtree (unlike deploy, there's no cancel — the delete
|
||||
// is irrevocable at this point).
|
||||
const deleteLocked = deletingIds.has(n.id);
|
||||
const shouldLock = deployLocked || deleteLocked;
|
||||
if (shouldLock && n.draggable !== false) {
|
||||
touched = true;
|
||||
return { ...n, draggable: false };
|
||||
}
|
||||
if (!shouldLock && n.draggable === false) {
|
||||
// Node was locked in a prior render; deploy cancelled /
|
||||
// completed, or delete failed and was reverted. Restore
|
||||
// default dragability.
|
||||
touched = true;
|
||||
const { draggable: _d, ...rest } = n;
|
||||
void _d;
|
||||
return rest as typeof n;
|
||||
}
|
||||
return n; // identity-preserved
|
||||
});
|
||||
return touched ? next : rawNodes;
|
||||
}, [rawNodes, deletingIds]);
|
||||
const onNodesChange = useCanvasStore((s) => s.onNodesChange);
|
||||
const savePosition = useCanvasStore((s) => s.savePosition);
|
||||
const selectNode = useCanvasStore((s) => s.selectNode);
|
||||
const selectedNodeId = useCanvasStore((s) => s.selectedNodeId);
|
||||
const setDragOverNode = useCanvasStore((s) => s.setDragOverNode);
|
||||
const nestNode = useCanvasStore((s) => s.nestNode);
|
||||
const isDescendant = useCanvasStore((s) => s.isDescendant);
|
||||
const dragStartParentRef = useRef<string | null>(null);
|
||||
|
||||
// Drag / nest lifecycle — handlers, pending-nest state, confirm/cancel.
|
||||
const {
|
||||
onNodeDragStart,
|
||||
onNodeDrag,
|
||||
onNodeDragStop,
|
||||
pendingNest,
|
||||
confirmNest,
|
||||
cancelNest,
|
||||
} = useDragHandlers();
|
||||
const onNodeDragStart: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
|
||||
(_event, node) => {
|
||||
dragStartParentRef.current = (node.data as WorkspaceNodeData).parentId;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Window-level keyboard shortcuts (Esc, Enter, Shift+Enter, Cmd+]/[, Z).
|
||||
useKeyboardShortcuts();
|
||||
const onNodeDrag: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
|
||||
(_event, node) => {
|
||||
const { nodes: allNodes } = useCanvasStore.getState();
|
||||
const nodeCenterX = node.position.x + (node.measured?.width ?? DEFAULT_NODE_WIDTH) / 2;
|
||||
const nodeCenterY = node.position.y + (node.measured?.height ?? DEFAULT_NODE_HEIGHT) / 2;
|
||||
|
||||
// Pan-to-node / zoom-to-team CustomEvent listeners + viewport save.
|
||||
const { onMoveEnd } = useCanvasViewport();
|
||||
let closest: string | null = null;
|
||||
let closestDist = NEST_PROXIMITY_THRESHOLD;
|
||||
|
||||
for (const n of allNodes) {
|
||||
if (n.id === node.id || isDescendant(node.id, n.id)) continue;
|
||||
const otherWidth = n.measured?.width ?? DEFAULT_NODE_WIDTH;
|
||||
const otherHeight = n.measured?.height ?? DEFAULT_NODE_HEIGHT;
|
||||
const otherCenterX = n.position.x + otherWidth / 2;
|
||||
const otherCenterY = n.position.y + otherHeight / 2;
|
||||
const dist = Math.sqrt(
|
||||
(nodeCenterX - otherCenterX) ** 2 + (nodeCenterY - otherCenterY) ** 2
|
||||
);
|
||||
if (dist < closestDist) {
|
||||
closestDist = dist;
|
||||
closest = n.id;
|
||||
}
|
||||
}
|
||||
setDragOverNode(closest);
|
||||
},
|
||||
[isDescendant, setDragOverNode]
|
||||
);
|
||||
|
||||
// Confirmation dialog state for structure changes
|
||||
const [pendingNest, setPendingNest] = useState<{ nodeId: string; targetId: string | null; nodeName: string; targetName: string } | null>(null);
|
||||
// Delete-confirmation lives in the store so the dialog survives ContextMenu
|
||||
// unmounting — the prior local-in-ContextMenu state raced with the menu's
|
||||
// outside-click handler.
|
||||
// outside-click handler (the portal-rendered Confirm button counted as
|
||||
// "outside" and closed the menu, killing the dialog mid-click).
|
||||
const pendingDelete = useCanvasStore((s) => s.pendingDelete);
|
||||
const setPendingDelete = useCanvasStore((s) => s.setPendingDelete);
|
||||
const removeSubtree = useCanvasStore((s) => s.removeSubtree);
|
||||
const removeNode = useCanvasStore((s) => s.removeNode);
|
||||
// Cascade guard: when deleting a workspace with children, the operator must
|
||||
// tick "I understand the cascade" before Delete All becomes active.
|
||||
const [cascadeConfirmChecked, setCascadeConfirmChecked] = useState(false);
|
||||
const confirmDelete = useCallback(async () => {
|
||||
if (!pendingDelete) return;
|
||||
// If hasChildren and checkbox not ticked, do nothing — user must confirm
|
||||
if (pendingDelete.hasChildren && !cascadeConfirmChecked) return;
|
||||
const { id } = pendingDelete;
|
||||
setPendingDelete(null);
|
||||
// Compute the full subtree and mark it as "deleting" so every
|
||||
// node in the chain renders dim + non-draggable during the
|
||||
// network round-trip + the server-side cascade. Matches the
|
||||
// deploy-lock UX: once a system-initiated operation owns this
|
||||
// subtree, the user shouldn't be able to move its pieces
|
||||
// around until it resolves.
|
||||
const state = useCanvasStore.getState();
|
||||
const subtree = new Set<string>();
|
||||
const stack = [id];
|
||||
while (stack.length) {
|
||||
const nid = stack.pop()!;
|
||||
subtree.add(nid);
|
||||
for (const n of state.nodes) {
|
||||
if (n.data.parentId === nid) stack.push(n.id);
|
||||
}
|
||||
}
|
||||
state.beginDelete(subtree);
|
||||
setCascadeConfirmChecked(false);
|
||||
try {
|
||||
await api.del(`/workspaces/${id}?confirm=true`);
|
||||
// Mirror the server-side cascade locally — drop the parent AND
|
||||
// every descendant in one atomic update. The per-descendant
|
||||
// WORKSPACE_REMOVED WS events still arrive (and are no-ops
|
||||
// because the nodes are already gone), but we no longer depend
|
||||
// on them: a wedged WS used to leave orphan child cards on the
|
||||
// canvas until the user refreshed the page.
|
||||
removeSubtree(id);
|
||||
state.endDelete(subtree);
|
||||
removeNode(id);
|
||||
} catch (e) {
|
||||
// Network or server error — restore the subtree to normal
|
||||
// interaction and surface the error.
|
||||
state.endDelete(subtree);
|
||||
showToast(e instanceof Error ? e.message : "Delete failed", "error");
|
||||
}
|
||||
}, [pendingDelete, setPendingDelete, removeSubtree]);
|
||||
}, [pendingDelete, cascadeConfirmChecked, setPendingDelete, removeNode]);
|
||||
const cascadeMessage = pendingDelete?.hasChildren
|
||||
? `⚠️ Deleting "${pendingDelete.name}" will permanently delete all child workspaces and their data. This cannot be undone.`
|
||||
: null;
|
||||
|
||||
const onNodeDragStop: OnNodeDrag<Node<WorkspaceNodeData>> = useCallback(
|
||||
(_event, node) => {
|
||||
const { dragOverNodeId, nodes: allNodes } = useCanvasStore.getState();
|
||||
setDragOverNode(null);
|
||||
|
||||
const nodeName = (node.data as WorkspaceNodeData).name;
|
||||
|
||||
if (dragOverNodeId) {
|
||||
const targetNode = allNodes.find((n) => n.id === dragOverNodeId);
|
||||
const targetName = targetNode?.data.name || "Unknown";
|
||||
setPendingNest({ nodeId: node.id, targetId: dragOverNodeId, nodeName, targetName });
|
||||
} else {
|
||||
const currentParentId = (node.data as WorkspaceNodeData).parentId;
|
||||
if (currentParentId) {
|
||||
const parentNode = allNodes.find((n) => n.id === currentParentId);
|
||||
const parentName = parentNode?.data.name || "Unknown";
|
||||
setPendingNest({ nodeId: node.id, targetId: null, nodeName, targetName: parentName });
|
||||
}
|
||||
}
|
||||
|
||||
savePosition(node.id, node.position.x, node.position.y);
|
||||
},
|
||||
[savePosition, setDragOverNode]
|
||||
);
|
||||
|
||||
const confirmNest = useCallback(() => {
|
||||
if (pendingNest) {
|
||||
nestNode(pendingNest.nodeId, pendingNest.targetId);
|
||||
setPendingNest(null);
|
||||
}
|
||||
}, [pendingNest, nestNode]);
|
||||
|
||||
const cancelNest = useCallback(() => {
|
||||
setPendingNest(null);
|
||||
}, []);
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
selectNode(null);
|
||||
@@ -239,142 +194,254 @@ function CanvasInner() {
|
||||
state.clearSelection();
|
||||
}, [selectNode]);
|
||||
|
||||
// Team zoom-in: double-click a team node to zoom to its children
|
||||
const { fitBounds, fitView } = useReactFlow();
|
||||
|
||||
// Pan to newly deployed workspace.
|
||||
// Uses fitView({ nodes }) so the viewport adapts to any current zoom level
|
||||
// instead of forcing zoom=1 (which was jarring when the user was zoomed out).
|
||||
const panTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const { nodeId } = (e as CustomEvent<{ nodeId: string }>).detail;
|
||||
// Small delay so ReactFlow has time to measure the newly rendered node
|
||||
clearTimeout(panTimerRef.current);
|
||||
panTimerRef.current = setTimeout(() => {
|
||||
fitView({ nodes: [{ id: nodeId }], duration: 400, padding: 0.3 });
|
||||
}, 100);
|
||||
};
|
||||
window.addEventListener("molecule:pan-to-node", handler);
|
||||
return () => {
|
||||
window.removeEventListener("molecule:pan-to-node", handler);
|
||||
clearTimeout(panTimerRef.current);
|
||||
};
|
||||
}, [fitView]);
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const { nodeId } = (e as CustomEvent).detail;
|
||||
const state = useCanvasStore.getState();
|
||||
const children = state.nodes.filter((n) => n.data.parentId === nodeId);
|
||||
if (children.length === 0) return;
|
||||
|
||||
const parent = state.nodes.find((n) => n.id === nodeId);
|
||||
const allNodes = parent ? [parent, ...children] : children;
|
||||
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const n of allNodes) {
|
||||
minX = Math.min(minX, n.position.x);
|
||||
minY = Math.min(minY, n.position.y);
|
||||
maxX = Math.max(maxX, n.position.x + 260);
|
||||
maxY = Math.max(maxY, n.position.y + 120);
|
||||
}
|
||||
|
||||
fitBounds(
|
||||
{ x: minX - 50, y: minY - 50, width: maxX - minX + 100, height: maxY - minY + 100 },
|
||||
{ padding: 0.2, duration: 500 }
|
||||
);
|
||||
};
|
||||
window.addEventListener("molecule:zoom-to-team", handler);
|
||||
return () => window.removeEventListener("molecule:zoom-to-team", handler);
|
||||
}, [fitBounds]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
const state = useCanvasStore.getState();
|
||||
if (state.contextMenu) {
|
||||
state.closeContextMenu();
|
||||
} else if (state.selectedNodeIds.size > 0) {
|
||||
state.clearSelection();
|
||||
} else if (state.selectedNodeId) {
|
||||
state.selectNode(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Z — keyboard equivalent for double-click zoom-to-team (WCAG 2.1.1)
|
||||
if (e.key === "z" || e.key === "Z") {
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
(e.target as HTMLElement).isContentEditable
|
||||
)
|
||||
return;
|
||||
const state = useCanvasStore.getState();
|
||||
const selectedId = state.selectedNodeId;
|
||||
if (!selectedId) return;
|
||||
const hasChildren = state.nodes.some((n) => n.data.parentId === selectedId);
|
||||
if (hasChildren) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("molecule:zoom-to-team", { detail: { nodeId: selectedId } })
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, []);
|
||||
|
||||
const saveViewport = useCanvasStore((s) => s.saveViewport);
|
||||
const viewport = useCanvasStore((s) => s.viewport);
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
// Cleanup debounced save timer on unmount
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(saveTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const onMoveEnd = useCallback(
|
||||
(_event: unknown, vp: { x: number; y: number; zoom: number }) => {
|
||||
// Debounce viewport saves to avoid spamming the API
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveViewport(vp.x, vp.y, vp.zoom);
|
||||
}, 1000);
|
||||
},
|
||||
[saveViewport]
|
||||
);
|
||||
|
||||
const defaultViewport = useMemo(
|
||||
() => ({ x: viewport.x, y: viewport.y, zoom: viewport.zoom }),
|
||||
// Only use the initial viewport — don't re-render on every save
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
[]
|
||||
);
|
||||
|
||||
// Determine which workspace ID to use for global settings.
|
||||
// Fall back to "global" when no specific node is selected.
|
||||
const settingsWorkspaceId = selectedNodeId ?? "global";
|
||||
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
href="#canvas-main"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:bg-surface-sunken focus:text-ink focus:rounded-lg focus:border focus:border-line"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:px-4 focus:py-2 focus:bg-zinc-900 focus:text-zinc-100 focus:rounded-lg focus:border focus:border-zinc-700"
|
||||
>
|
||||
Skip to canvas
|
||||
</a>
|
||||
<main id="canvas-main" className="w-screen h-screen bg-surface">
|
||||
<ReactFlow
|
||||
colorMode={resolvedTheme}
|
||||
nodes={nodes}
|
||||
edges={allEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onNodeDrag={onNodeDrag}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onPaneClick={onPaneClick}
|
||||
onMoveEnd={onMoveEnd}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
defaultViewport={defaultViewport}
|
||||
fitView={viewport.x === 0 && viewport.y === 0 && viewport.zoom === 1}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
aria-label="Molecule AI workspace canvas"
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={24}
|
||||
size={1}
|
||||
// Match the line token so dots fade with the surface.
|
||||
// Hard-coded zinc-800 was invisible on warm-paper.
|
||||
color={resolvedTheme === "dark" ? "#27272a" : "#d4d0c4"}
|
||||
/>
|
||||
<Controls
|
||||
className="!bg-surface-sunken/90 !border-line/50 !rounded-lg !shadow-xl !shadow-black/20 [&>button]:!bg-surface-card [&>button]:!border-line/50 [&>button]:!text-ink-mid [&>button:hover]:!bg-surface-card [&>button:hover]:!text-ink"
|
||||
showInteractive={false}
|
||||
/>
|
||||
<MiniMap
|
||||
className="!bg-surface-sunken/90 !border-line/50 !rounded-lg !shadow-xl !shadow-black/20"
|
||||
// Mask dims off-viewport areas; tint matches the surface so
|
||||
// the dimming doesn't show as a black bar in light mode.
|
||||
maskColor={resolvedTheme === "dark" ? "rgba(0, 0, 0, 0.7)" : "rgba(232, 226, 211, 0.7)"}
|
||||
nodeColor={(node) => {
|
||||
// Parents show as a filled region — hierarchy visible at
|
||||
// a glance in the minimap without needing to zoom.
|
||||
const hasChildren = nodes.some((n) => n.parentId === node.id);
|
||||
if (hasChildren) return "#3b82f6";
|
||||
const status = (node.data as Record<string, unknown>)?.status;
|
||||
switch (status) {
|
||||
case "online":
|
||||
return "#34d399";
|
||||
case "offline":
|
||||
return "#52525b";
|
||||
case "degraded":
|
||||
return "#fbbf24";
|
||||
case "failed":
|
||||
return "#f87171";
|
||||
case "provisioning":
|
||||
return "#38bdf8";
|
||||
default:
|
||||
return "#3f3f46";
|
||||
}
|
||||
}}
|
||||
nodeStrokeColor={(node) => {
|
||||
const hasChildren = nodes.some((n) => n.parentId === node.id);
|
||||
return hasChildren ? "#60a5fa" : "transparent";
|
||||
}}
|
||||
nodeStrokeWidth={2}
|
||||
nodeBorderRadius={4}
|
||||
/>
|
||||
<DropTargetBadge />
|
||||
</ReactFlow>
|
||||
|
||||
{/* Screen-reader live region: announces workspace count on canvas load or change */}
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{nodes.filter((n) => !n.parentId).length === 0
|
||||
? "No workspaces on canvas"
|
||||
: `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas`}
|
||||
</div>
|
||||
|
||||
{nodes.length === 0 && <EmptyState />}
|
||||
<A2ATopologyOverlay />
|
||||
<OnboardingWizard />
|
||||
<Toolbar />
|
||||
<ApprovalBanner />
|
||||
<BundleDropZone />
|
||||
<TemplatePalette />
|
||||
<SidePanel />
|
||||
<ContextMenu />
|
||||
<SearchDialog />
|
||||
<Toaster />
|
||||
<ProvisioningTimeout />
|
||||
{!selectedNodeId && <CreateWorkspaceButton />}
|
||||
<BatchActionBar />
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!pendingNest}
|
||||
title={pendingNest?.targetId ? "Nest Workspace" : "Extract Workspace"}
|
||||
message={
|
||||
pendingNest?.targetId
|
||||
? `Move "${pendingNest.nodeName}" inside "${pendingNest.targetName}"? This changes the org hierarchy — ${pendingNest.nodeName} will become a sub-workspace of ${pendingNest.targetName}.`
|
||||
: `Extract "${pendingNest?.nodeName}" from "${pendingNest?.targetName}"? This moves it to the root level.`
|
||||
}
|
||||
confirmLabel={pendingNest?.targetId ? "Nest" : "Extract"}
|
||||
onConfirm={confirmNest}
|
||||
onCancel={cancelNest}
|
||||
<main id="canvas-main" className="w-screen h-screen bg-zinc-950">
|
||||
<ReactFlow
|
||||
colorMode="dark"
|
||||
nodes={nodes}
|
||||
edges={allEdges}
|
||||
onNodesChange={onNodesChange}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onNodeDrag={onNodeDrag}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
onPaneClick={onPaneClick}
|
||||
onMoveEnd={onMoveEnd}
|
||||
nodeTypes={nodeTypes}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
defaultViewport={defaultViewport}
|
||||
fitView={viewport.x === 0 && viewport.y === 0 && viewport.zoom === 1}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
aria-label="Molecule AI workspace canvas"
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={24}
|
||||
size={1}
|
||||
color="#27272a"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!pendingDelete}
|
||||
title={pendingDelete?.hasChildren ? "Delete Workspace and Children" : "Delete Workspace"}
|
||||
message={pendingDelete?.hasChildren
|
||||
? `⚠️ Deleting "${pendingDelete?.name}" will permanently delete all of its child workspaces and their data. This cannot be undone.`
|
||||
: `Permanently delete "${pendingDelete?.name}"? This will stop the container and remove all configuration. This action cannot be undone.`}
|
||||
confirmLabel={pendingDelete?.hasChildren ? "Delete All" : "Delete"}
|
||||
confirmVariant="danger"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
<Controls
|
||||
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20 [&>button]:!bg-zinc-800 [&>button]:!border-zinc-700/50 [&>button]:!text-zinc-400 [&>button:hover]:!bg-zinc-700 [&>button:hover]:!text-zinc-200"
|
||||
showInteractive={false}
|
||||
/>
|
||||
<MiniMap
|
||||
className="!bg-zinc-900/90 !border-zinc-700/50 !rounded-lg !shadow-xl !shadow-black/20"
|
||||
maskColor="rgba(0, 0, 0, 0.7)"
|
||||
nodeColor={(node) => {
|
||||
const status = (node.data as Record<string, unknown>)?.status;
|
||||
switch (status) {
|
||||
case "online":
|
||||
return "#34d399";
|
||||
case "offline":
|
||||
return "#52525b";
|
||||
case "degraded":
|
||||
return "#fbbf24";
|
||||
case "failed":
|
||||
return "#f87171";
|
||||
case "provisioning":
|
||||
return "#38bdf8";
|
||||
default:
|
||||
return "#3f3f46";
|
||||
}
|
||||
}}
|
||||
nodeStrokeWidth={0}
|
||||
nodeBorderRadius={4}
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
<SettingsPanel workspaceId={settingsWorkspaceId} />
|
||||
<DeleteConfirmDialog workspaceId={settingsWorkspaceId} />
|
||||
{/* Screen-reader live region: announces workspace count when canvas loads or changes */}
|
||||
<div role="status" aria-live="polite" className="sr-only">
|
||||
{nodes.filter((n) => !n.data.parentId).length === 0
|
||||
? "No workspaces on canvas"
|
||||
: `${nodes.filter((n) => !n.data.parentId).length} workspace${nodes.filter((n) => !n.data.parentId).length !== 1 ? "s" : ""} on canvas`}
|
||||
</div>
|
||||
|
||||
{nodes.length === 0 && <EmptyState />}
|
||||
<A2ATopologyOverlay />
|
||||
<OnboardingWizard />
|
||||
<Toolbar />
|
||||
<ApprovalBanner />
|
||||
<BundleDropZone />
|
||||
<TemplatePalette />
|
||||
<SidePanel />
|
||||
<ContextMenu />
|
||||
<SearchDialog />
|
||||
<Toaster />
|
||||
<ProvisioningTimeout />
|
||||
{!selectedNodeId && <CreateWorkspaceButton />}
|
||||
<BatchActionBar />
|
||||
|
||||
{/* Confirmation dialog for structure changes */}
|
||||
<ConfirmDialog
|
||||
open={!!pendingNest}
|
||||
title={pendingNest?.targetId ? "Nest Workspace" : "Extract Workspace"}
|
||||
message={
|
||||
pendingNest?.targetId
|
||||
? `Move "${pendingNest.nodeName}" inside "${pendingNest.targetName}"? This changes the org hierarchy — ${pendingNest.nodeName} will become a sub-workspace of ${pendingNest.targetName}.`
|
||||
: `Extract "${pendingNest?.nodeName}" from "${pendingNest?.targetName}"? This moves it to the root level.`
|
||||
}
|
||||
confirmLabel={pendingNest?.targetId ? "Nest" : "Extract"}
|
||||
onConfirm={confirmNest}
|
||||
onCancel={cancelNest}
|
||||
/>
|
||||
|
||||
{/* Confirmation dialog for workspace delete — driven by store */}
|
||||
{/* When the workspace has children, render an inline cascade guard instead
|
||||
of the generic ConfirmDialog so we can show the child list and require
|
||||
an explicit checkbox before Delete All activates. */}
|
||||
{pendingDelete ? (
|
||||
pendingDelete.hasChildren ? (
|
||||
<DeleteCascadeConfirmDialog
|
||||
name={pendingDelete.name}
|
||||
children={pendingDelete.children}
|
||||
checked={cascadeConfirmChecked}
|
||||
onCheckedChange={setCascadeConfirmChecked}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => { setPendingDelete(null); setCascadeConfirmChecked(false); }}
|
||||
/>
|
||||
) : (
|
||||
<ConfirmDialog
|
||||
open={true}
|
||||
title="Delete Workspace"
|
||||
message={`Permanently delete "${pendingDelete.name}"? This will stop the container and remove all configuration. This action cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
confirmVariant="danger"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{/* Settings Panel — global secrets management drawer */}
|
||||
<SettingsPanel workspaceId={settingsWorkspaceId} />
|
||||
<DeleteConfirmDialog workspaceId={settingsWorkspaceId} />
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -32,18 +32,11 @@ export function CommunicationOverlay() {
|
||||
|
||||
const fetchComms = useCallback(async () => {
|
||||
try {
|
||||
// Fan-out cap: each polled workspace = 1 round-trip. The platform
|
||||
// rate limits at 600 req/min/IP; combined with heartbeats + other
|
||||
// canvas polling, every workspace polled here costs ~6 req/min
|
||||
// (1 every 30s × 1 per workspace). Capping at 3 keeps this
|
||||
// overlay's footprint at 18 req/min worst case — well under
|
||||
// budget even with 8+ workspaces visible. Caught 2026-05-04 when
|
||||
// a user with 8+ workspaces (Design Director + 6 sub-agents +
|
||||
// 3 standalones) saw sustained 429s in canvas console.
|
||||
// Fetch activity from all online workspaces
|
||||
const onlineNodes = nodesRef.current.filter((n) => n.data.status === "online");
|
||||
const allComms: Communication[] = [];
|
||||
|
||||
for (const node of onlineNodes.slice(0, 3)) {
|
||||
for (const node of onlineNodes.slice(0, 6)) {
|
||||
try {
|
||||
const activities = await api.get<Array<{
|
||||
id: string;
|
||||
@@ -98,28 +91,17 @@ export function CommunicationOverlay() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Gate polling on visibility — when the user collapses the overlay
|
||||
// the data isn't being read, so the per-workspace fan-out becomes
|
||||
// pure rate-limit overhead. Pre-fix this overlay polled regardless
|
||||
// of whether the panel was shown, costing ~36 req/min from a
|
||||
// hidden surface.
|
||||
if (!visible) return;
|
||||
fetchComms();
|
||||
// 30s cadence (was 10s). At 3-workspace fan-out that's 6 req/min
|
||||
// worst case from this overlay. Combined with heartbeats (~30/min)
|
||||
// and other canvas polling, leaves ample headroom under the 600/
|
||||
// min/IP server-side rate limit even at 8+ workspace tenants.
|
||||
const interval = setInterval(fetchComms, 30000);
|
||||
const interval = setInterval(fetchComms, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchComms, visible]);
|
||||
}, [fetchComms]);
|
||||
|
||||
if (!visible || comms.length === 0) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible(true)}
|
||||
aria-label="Show communications panel"
|
||||
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-surface-sunken/90 border border-line/50 rounded-lg text-[10px] text-ink-mid hover:text-ink transition-colors"
|
||||
className="fixed top-16 right-4 z-30 px-3 py-1.5 bg-zinc-900/90 border border-zinc-700/50 rounded-lg text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
<span aria-hidden="true">↗↙ </span>{comms.length > 0 ? `${comms.length} comms` : "Communications"}
|
||||
</button>
|
||||
@@ -127,16 +109,15 @@ export function CommunicationOverlay() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-16 right-4 z-30 w-[320px] max-h-[400px] bg-surface-sunken/95 border border-line/50 rounded-xl shadow-xl shadow-black/30 backdrop-blur-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-line/60">
|
||||
<div className="text-[10px] font-semibold text-ink-mid uppercase tracking-wider">
|
||||
<div className="fixed top-16 right-4 z-30 w-[320px] max-h-[400px] bg-zinc-900/95 border border-zinc-700/50 rounded-xl shadow-xl shadow-black/30 backdrop-blur-sm overflow-hidden">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-zinc-800/60">
|
||||
<div className="text-[10px] font-semibold text-zinc-400 uppercase tracking-wider">
|
||||
<span aria-hidden="true">↗↙ </span>Communications ({comms.length})
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible(false)}
|
||||
aria-label="Close communications panel"
|
||||
className="text-ink-soft hover:text-ink-mid text-xs"
|
||||
className="text-zinc-500 hover:text-zinc-300 text-xs"
|
||||
>
|
||||
<span aria-hidden="true">✕</span>
|
||||
</button>
|
||||
@@ -145,10 +126,10 @@ export function CommunicationOverlay() {
|
||||
<div className="overflow-y-auto max-h-[350px] p-2 space-y-1">
|
||||
{comms.map((c) => {
|
||||
const isSelected = selectedNodeId === c.sourceId || selectedNodeId === c.targetId;
|
||||
const typeColor = c.type === "a2a_send" ? "text-cyan-400" : c.type === "a2a_receive" ? "text-accent" : "text-warm";
|
||||
const typeColor = c.type === "a2a_send" ? "text-cyan-400" : c.type === "a2a_receive" ? "text-blue-400" : "text-amber-400";
|
||||
const typeIcon = c.type === "a2a_send" ? "↗" : c.type === "a2a_receive" ? "↙" : "◆";
|
||||
const statusIcon = c.status === "ok" ? "✓" : c.status === "error" ? "✕" : "⏱";
|
||||
const statusColor = c.status === "ok" ? "text-good" : c.status === "error" ? "text-bad" : "text-warm";
|
||||
const statusColor = c.status === "ok" ? "text-emerald-400" : c.status === "error" ? "text-red-400" : "text-amber-400";
|
||||
const age = formatAge(c.timestamp);
|
||||
|
||||
return (
|
||||
@@ -157,31 +138,31 @@ export function CommunicationOverlay() {
|
||||
className={`rounded-lg px-2.5 py-1.5 text-[9px] border transition-all ${
|
||||
isSelected
|
||||
? "bg-blue-950/30 border-blue-800/40"
|
||||
: "bg-surface-card/30 border-line/20 hover:bg-surface-card/50"
|
||||
: "bg-zinc-800/30 border-zinc-700/20 hover:bg-zinc-800/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span className={typeColor} aria-hidden="true">{typeIcon}</span>
|
||||
<span className="sr-only">{COMM_TYPE_LABELS[c.type] ?? c.type}</span>
|
||||
<span className="text-ink-mid font-medium truncate">
|
||||
<span className="text-zinc-300 font-medium truncate">
|
||||
{c.sourceName}
|
||||
</span>
|
||||
<span className="text-ink-mid" aria-hidden="true">→</span>
|
||||
<span className="text-zinc-400" aria-hidden="true">→</span>
|
||||
<span className="sr-only">to</span>
|
||||
<span className="text-ink-mid truncate">{c.targetName}</span>
|
||||
<span className="text-zinc-300 truncate">{c.targetName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<span className={statusColor} aria-hidden="true">{statusIcon}</span>
|
||||
<span className="sr-only">{c.status}</span>
|
||||
<span className="text-ink-mid">{age}</span>
|
||||
<span className="text-zinc-400">{age}</span>
|
||||
</div>
|
||||
</div>
|
||||
{c.summary && (
|
||||
<div className="text-ink-soft truncate mt-0.5 pl-4">{c.summary}</div>
|
||||
<div className="text-zinc-500 truncate mt-0.5 pl-4">{c.summary}</div>
|
||||
)}
|
||||
{c.durationMs && (
|
||||
<div className="text-ink-mid pl-4">{c.durationMs}ms</div>
|
||||
<div className="text-zinc-400 pl-4">{c.durationMs}ms</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -91,15 +91,12 @@ export function ConfirmDialog({
|
||||
|
||||
if (!open || !mounted) return null;
|
||||
|
||||
// Hover goes DARKER, not lighter — lighter shades on white text drop
|
||||
// contrast below AA on the accent and red ramps. Darker hovers stay
|
||||
// readable in both light and dark themes.
|
||||
const confirmColors =
|
||||
confirmVariant === "danger"
|
||||
? "bg-red-600 hover:bg-red-700 text-white"
|
||||
? "bg-red-600 hover:bg-red-500 text-white"
|
||||
: confirmVariant === "warning"
|
||||
? "bg-amber-600 hover:bg-amber-700 text-white"
|
||||
: "bg-accent hover:bg-accent-strong text-white";
|
||||
? "bg-amber-600 hover:bg-amber-500 text-white"
|
||||
: "bg-blue-600 hover:bg-blue-500 text-white";
|
||||
|
||||
// Render via Portal so the fixed-position dialog escapes any containing block
|
||||
// (e.g. parents with transform, filter, will-change that break position:fixed).
|
||||
@@ -114,27 +111,25 @@ export function ConfirmDialog({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="confirm-dialog-title"
|
||||
className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl shadow-black/50 max-w-[380px] w-full mx-4 overflow-hidden"
|
||||
className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[380px] w-full mx-4 overflow-hidden"
|
||||
>
|
||||
<div className="px-5 py-4">
|
||||
<h3 id="confirm-dialog-title" className="text-sm font-semibold text-ink mb-2">{title}</h3>
|
||||
<p className="text-[13px] text-ink-mid leading-relaxed">{message}</p>
|
||||
<h3 id="confirm-dialog-title" className="text-sm font-semibold text-zinc-100 mb-2">{title}</h3>
|
||||
<p className="text-[13px] text-zinc-400 leading-relaxed">{message}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-line bg-surface/50">
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-zinc-800 bg-zinc-950/50">
|
||||
{!singleButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[13px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
className="px-3.5 py-1.5 text-[13px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken focus-visible:ring-accent/60 ${confirmColors}`}
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors ${confirmColors}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { api } from "@/lib/api";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
@@ -27,21 +27,11 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Focus close button when modal opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const raf = requestAnimationFrame(() => {
|
||||
closeButtonRef.current?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let ignore = false;
|
||||
@@ -90,33 +80,28 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
|
||||
<div aria-hidden="true" className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="console-modal-title"
|
||||
className="relative bg-surface border border-line rounded-xl shadow-2xl w-[min(900px,90vw)] h-[min(70vh,700px)] flex flex-col overflow-hidden"
|
||||
className="relative bg-zinc-950 border border-zinc-800 rounded-xl shadow-2xl w-[min(900px,90vw)] h-[min(70vh,700px)] flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-line">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-zinc-800">
|
||||
<div>
|
||||
<h3 id="console-modal-title" className="text-sm font-semibold text-ink">
|
||||
<h3 id="console-modal-title" className="text-sm font-semibold text-zinc-100">
|
||||
EC2 console output
|
||||
</h3>
|
||||
{workspaceName && (
|
||||
<div className="text-[11px] text-ink-soft mt-0.5 truncate max-w-[600px]">
|
||||
<div className="text-[11px] text-zinc-500 mt-0.5 truncate max-w-[600px]">
|
||||
{workspaceName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
ref={closeButtonRef}
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
// 24x24 touch target (was ~10x16, well under WCAG 2.5.5).
|
||||
// Hover bg makes the area visible; focus-visible ring matches
|
||||
// the rest of the canvas chrome.
|
||||
className="w-6 h-6 inline-flex items-center justify-center rounded text-sm text-ink-mid hover:text-ink hover:bg-surface-card/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors"
|
||||
className="text-zinc-400 hover:text-zinc-100 text-sm px-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -124,14 +109,13 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
|
||||
<div className="flex-1 overflow-auto bg-black/80 p-4">
|
||||
{loading && (
|
||||
<div className="text-[12px] text-ink-soft" data-testid="console-loading">
|
||||
<div className="text-[12px] text-zinc-500" data-testid="console-loading">
|
||||
Loading console output…
|
||||
</div>
|
||||
)}
|
||||
{!loading && error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="text-[12px] text-warm bg-amber-950/30 border border-amber-900/40 rounded px-3 py-2"
|
||||
className="text-[12px] text-amber-300 bg-amber-950/30 border border-amber-900/40 rounded px-3 py-2"
|
||||
data-testid="console-error"
|
||||
>
|
||||
{error}
|
||||
@@ -139,7 +123,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
)}
|
||||
{!loading && !error && output !== null && (
|
||||
<pre
|
||||
className="text-[11px] text-ink-mid font-mono whitespace-pre-wrap break-all leading-tight"
|
||||
className="text-[11px] text-zinc-300 font-mono whitespace-pre-wrap break-all leading-tight"
|
||||
data-testid="console-output"
|
||||
>
|
||||
{output || "(console output is empty — the instance may still be booting)"}
|
||||
@@ -147,36 +131,24 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-line bg-surface-sunken/40">
|
||||
<div className="flex items-center justify-end gap-2 px-4 py-3 border-t border-zinc-800 bg-zinc-900/40">
|
||||
{output && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (navigator.clipboard) {
|
||||
// Add success feedback — without it, clicking Copy
|
||||
// looked like a no-op since the previous hover bg was
|
||||
// also a no-op (`hover:bg-surface-card` on top of the
|
||||
// same base). Toast confirms the write actually fired.
|
||||
navigator.clipboard
|
||||
.writeText(output)
|
||||
.then(() => showToast("Console output copied", "success"))
|
||||
.catch(() => showToast("Copy failed", "error"));
|
||||
navigator.clipboard.writeText(output);
|
||||
} else {
|
||||
showToast("Copy requires HTTPS — please select and copy manually", "info");
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1.5 text-[11px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
className="px-3 py-1.5 text-[11px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
// Was hover:bg-surface-card (same as base — silent no-op).
|
||||
// Lift to surface-elevated so the button visibly responds,
|
||||
// matching the Cancel button in ConfirmDialog.
|
||||
className="px-3 py-1.5 text-[11px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
className="px-3 py-1.5 text-[11px] text-zinc-300 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
@@ -23,44 +23,22 @@ export function ContextMenu() {
|
||||
const setPanelTab = useCanvasStore((s) => s.setPanelTab);
|
||||
const nestNode = useCanvasStore((s) => s.nestNode);
|
||||
const contextNodeId = contextMenu?.nodeId ?? null;
|
||||
const hasChildren = useCanvasStore((s) =>
|
||||
contextNodeId ? s.nodes.some((n) => n.data.parentId === contextNodeId) : false
|
||||
const children = useCanvasStore((s) =>
|
||||
contextNodeId ? s.nodes.filter((n) => n.data.parentId === contextNodeId) : []
|
||||
);
|
||||
const hasChildren = children.length > 0;
|
||||
const setPendingDelete = useCanvasStore((s) => s.setPendingDelete);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
// Clamped position — (left, top) from contextMenu may overflow when the
|
||||
// user right-clicks near the right/bottom viewport edge. We measure the
|
||||
// rendered menu and shift it back inside on the same frame the cursor
|
||||
// opens it, so it never visibly clips. Falls back to the raw cursor
|
||||
// coords until the rAF runs.
|
||||
const [clamped, setClamped] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Auto-focus first enabled item when menu opens, AND clamp position.
|
||||
// Both run together in a single rAF so we avoid two synchronous layout
|
||||
// reads + a paint between them.
|
||||
// Auto-focus first enabled item when menu opens
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
setClamped(null);
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const first = node.querySelector<HTMLButtonElement>("button:not(:disabled)");
|
||||
requestAnimationFrame(() => {
|
||||
const first = ref.current?.querySelector<HTMLButtonElement>("button:not(:disabled)");
|
||||
first?.focus();
|
||||
// 8px viewport margin so the menu doesn't kiss the edge — matches
|
||||
// the floating-tooltip top-edge clamp in Tooltip.tsx.
|
||||
const margin = 8;
|
||||
const rect = node.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
let x = contextMenu.x;
|
||||
let y = contextMenu.y;
|
||||
if (x + rect.width + margin > vw) x = Math.max(margin, vw - rect.width - margin);
|
||||
if (y + rect.height + margin > vh) y = Math.max(margin, vh - rect.height - margin);
|
||||
if (x !== contextMenu.x || y !== contextMenu.y) setClamped({ x, y });
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [contextMenu?.nodeId, contextMenu?.x, contextMenu?.y]);
|
||||
}, [contextMenu?.nodeId]);
|
||||
|
||||
// Close on click outside or Escape
|
||||
useEffect(() => {
|
||||
@@ -189,8 +167,7 @@ export function ContextMenu() {
|
||||
// it survives ContextMenu unmount. Closing the menu here avoids the
|
||||
// prior race where the portal dialog's Confirm click was treated as
|
||||
// "outside" by the menu's outside-click handler.
|
||||
const childNodes = useCanvasStore.getState().nodes.filter((n) => n.data.parentId === contextMenu.nodeId);
|
||||
setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren, children: childNodes.map(c => ({ id: c.id, name: c.data.name })) });
|
||||
setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren, children: children.map(c => ({ id: c.id, name: c.data.name })) });
|
||||
closeContextMenu();
|
||||
}, [contextMenu, setPendingDelete, closeContextMenu]);
|
||||
|
||||
@@ -215,22 +192,25 @@ export function ContextMenu() {
|
||||
closeContextMenu();
|
||||
}, [contextMenu, selectNode, setPanelTab, closeContextMenu]);
|
||||
|
||||
const setCollapsed = useCanvasStore((s) => s.setCollapsed);
|
||||
const handleExpand = useCallback(async () => {
|
||||
if (!contextMenu) return;
|
||||
try {
|
||||
await api.post(`/workspaces/${contextMenu.nodeId}/expand`, {});
|
||||
} catch (e) {
|
||||
showToast("Expand failed", "error");
|
||||
}
|
||||
closeContextMenu();
|
||||
}, [contextMenu, closeContextMenu]);
|
||||
|
||||
const handleCollapse = useCallback(async () => {
|
||||
if (!contextMenu) return;
|
||||
const nodeId = contextMenu.nodeId;
|
||||
const wasCollapsed = !!contextMenu.nodeData.collapsed;
|
||||
// Optimistic local flip so the card shrinks/expands immediately.
|
||||
// Descendants' hidden flags are toggled atomically by the store.
|
||||
setCollapsed(nodeId, !wasCollapsed);
|
||||
try {
|
||||
await api.patch(`/workspaces/${nodeId}`, { collapsed: !wasCollapsed });
|
||||
await api.post(`/workspaces/${contextMenu.nodeId}/collapse`, {});
|
||||
} catch (e) {
|
||||
setCollapsed(nodeId, wasCollapsed);
|
||||
showToast("Collapse failed", "error");
|
||||
}
|
||||
closeContextMenu();
|
||||
}, [contextMenu, setCollapsed, closeContextMenu]);
|
||||
}, [contextMenu, closeContextMenu]);
|
||||
|
||||
const handleRemoveFromTeam = useCallback(async () => {
|
||||
if (!contextMenu) return;
|
||||
@@ -243,13 +223,6 @@ export function ContextMenu() {
|
||||
closeContextMenu();
|
||||
}, [contextMenu, nestNode, closeContextMenu]);
|
||||
|
||||
const arrangeChildren = useCanvasStore((s) => s.arrangeChildren);
|
||||
const handleArrangeChildren = useCallback(() => {
|
||||
if (!contextMenu) return;
|
||||
arrangeChildren(contextMenu.nodeId);
|
||||
closeContextMenu();
|
||||
}, [contextMenu, arrangeChildren, closeContextMenu]);
|
||||
|
||||
const handleZoomToTeam = useCallback(() => {
|
||||
if (!contextMenu) return;
|
||||
window.dispatchEvent(
|
||||
@@ -277,15 +250,10 @@ export function ContextMenu() {
|
||||
: []),
|
||||
...(hasChildren
|
||||
? [
|
||||
{ label: "Arrange Children", icon: "▦", action: handleArrangeChildren },
|
||||
{
|
||||
label: contextMenu.nodeData.collapsed ? "Expand Team" : "Collapse Team",
|
||||
icon: contextMenu.nodeData.collapsed ? "▽" : "◁",
|
||||
action: handleCollapse,
|
||||
},
|
||||
{ label: "Collapse Team", icon: "◁", action: handleCollapse },
|
||||
{ label: "Zoom to Team", icon: "⊕", action: handleZoomToTeam },
|
||||
]
|
||||
: []),
|
||||
: [{ label: "Expand to Team", icon: "▷", action: handleExpand }]),
|
||||
{ label: "", icon: "", action: () => {}, divider: true },
|
||||
...(isPaused
|
||||
? [{ label: "Resume", icon: "▶", action: handleResume }]
|
||||
@@ -300,37 +268,36 @@ export function ContextMenu() {
|
||||
role="menu"
|
||||
aria-label={`Actions for ${contextMenu.nodeData.name}`}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
className="fixed z-[60] min-w-[200px] bg-surface/95 backdrop-blur-xl border border-line/60 rounded-xl shadow-2xl shadow-black/60 py-1 overflow-hidden"
|
||||
style={{ left: clamped?.x ?? contextMenu.x, top: clamped?.y ?? contextMenu.y }}
|
||||
className="fixed z-[60] min-w-[200px] bg-zinc-950/95 backdrop-blur-xl border border-zinc-800/60 rounded-xl shadow-2xl shadow-black/60 py-1 overflow-hidden"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-3.5 py-2 border-b border-line/40 mb-0.5">
|
||||
<div className="text-[11px] font-semibold text-ink truncate">{contextMenu.nodeData.name}</div>
|
||||
<div className="px-3.5 py-2 border-b border-zinc-800/40 mb-0.5">
|
||||
<div className="text-[11px] font-semibold text-zinc-200 truncate">{contextMenu.nodeData.name}</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`w-1.5 h-1.5 rounded-full ${statusDotClass(contextMenu.nodeData.status)}`}
|
||||
/>
|
||||
<span className="text-[10px] text-ink-soft">{contextMenu.nodeData.status}</span>
|
||||
<span className="text-[10px] text-zinc-500">{contextMenu.nodeData.status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{items.map((item, i) => {
|
||||
if (item.divider) {
|
||||
return <div key={i} role="separator" className="h-px bg-surface-card/60 my-1" />;
|
||||
return <div key={i} role="separator" className="h-px bg-zinc-800/60 my-1" />;
|
||||
}
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={i}
|
||||
role="menuitem"
|
||||
onClick={item.action}
|
||||
disabled={item.disabled}
|
||||
aria-disabled={item.disabled}
|
||||
className={`w-full px-3.5 py-1.5 flex items-center gap-2.5 text-left text-[11px] transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-accent/50 disabled:opacity-25 disabled:cursor-not-allowed ${
|
||||
className={`w-full px-3.5 py-1.5 flex items-center gap-2.5 text-left text-[11px] transition-colors focus:outline-none focus:ring-1 focus:ring-inset focus:ring-zinc-600 disabled:opacity-25 disabled:cursor-not-allowed ${
|
||||
item.danger
|
||||
? "text-bad hover:bg-red-950/40 hover:text-bad"
|
||||
: "text-ink-mid hover:bg-surface-card/40 hover:text-ink"
|
||||
? "text-red-400 hover:bg-red-950/40 hover:text-red-300"
|
||||
: "text-zinc-300 hover:bg-zinc-800/40 hover:text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true" className="w-4 text-center text-[10px] shrink-0 opacity-50">{item.icon}</span>
|
||||
|
||||
@@ -97,24 +97,24 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
<Dialog.Content
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
||||
aria-label="Conversation trace"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{/* Modal panel */}
|
||||
<div className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl max-w-[700px] w-full max-h-[85vh] flex flex-col overflow-hidden">
|
||||
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl max-w-[700px] w-full max-h-[85vh] flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-line">
|
||||
<div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800">
|
||||
<div>
|
||||
<Dialog.Title className="text-sm font-semibold text-ink">
|
||||
<Dialog.Title className="text-sm font-semibold text-zinc-100">
|
||||
Conversation Trace
|
||||
</Dialog.Title>
|
||||
<p className="text-[10px] text-ink-soft mt-0.5">
|
||||
<p className="text-[10px] text-zinc-500 mt-0.5">
|
||||
{entries.length} events across all workspaces
|
||||
</p>
|
||||
</div>
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close conversation trace"
|
||||
className="text-ink-soft hover:text-ink-mid text-lg px-2"
|
||||
className="text-zinc-500 hover:text-zinc-300 text-lg px-2"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -124,13 +124,13 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
{/* Timeline */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{loading && (
|
||||
<div className="text-xs text-ink-soft text-center py-8">
|
||||
<div className="text-xs text-zinc-500 text-center py-8">
|
||||
Loading trace from all workspaces...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && entries.length === 0 && (
|
||||
<div className="text-xs text-ink-soft text-center py-8">
|
||||
<div className="text-xs text-zinc-500 text-center py-8">
|
||||
No activity found
|
||||
</div>
|
||||
)}
|
||||
@@ -160,28 +160,28 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
: isSend
|
||||
? "bg-cyan-500"
|
||||
: isReceive
|
||||
? "bg-accent"
|
||||
: "bg-surface-card"
|
||||
? "bg-blue-500"
|
||||
: "bg-zinc-600"
|
||||
}`}
|
||||
/>
|
||||
<div className="w-px flex-1 bg-surface-card min-h-[8px]" />
|
||||
<div className="w-px flex-1 bg-zinc-800 min-h-[8px]" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 pb-3 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[9px] text-ink-mid font-mono">
|
||||
<span className="text-[9px] text-zinc-400 font-mono">
|
||||
{time}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[9px] font-semibold px-1.5 py-0.5 rounded ${
|
||||
isError
|
||||
? "bg-red-950/50 text-bad"
|
||||
? "bg-red-950/50 text-red-400"
|
||||
: isSend
|
||||
? "bg-cyan-950/50 text-cyan-400"
|
||||
: isReceive
|
||||
? "bg-blue-950/50 text-accent"
|
||||
: "bg-surface-card text-ink-mid"
|
||||
? "bg-blue-950/50 text-blue-400"
|
||||
: "bg-zinc-800 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{isSend
|
||||
@@ -191,7 +191,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
: entry.activity_type.toUpperCase()}
|
||||
</span>
|
||||
{entry.duration_ms != null && entry.duration_ms > 0 && (
|
||||
<span className="text-[9px] text-ink-mid">
|
||||
<span className="text-[9px] text-zinc-400">
|
||||
{entry.duration_ms > 1000
|
||||
? `${Math.round(entry.duration_ms / 1000)}s`
|
||||
: `${entry.duration_ms}ms`}
|
||||
@@ -207,19 +207,19 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
<span className="text-cyan-400 font-medium">
|
||||
{sourceName || wsName}
|
||||
</span>
|
||||
<span className="text-ink-mid"> → </span>
|
||||
<span className="text-accent font-medium">
|
||||
<span className="text-zinc-400"> → </span>
|
||||
<span className="text-blue-400 font-medium">
|
||||
{targetName}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
<span className="text-accent font-medium">
|
||||
<span className="text-blue-400 font-medium">
|
||||
{targetName || wsName}
|
||||
</span>
|
||||
{sourceName && (
|
||||
<>
|
||||
<span className="text-ink-mid">
|
||||
<span className="text-zinc-400">
|
||||
{" "}← {" "}
|
||||
</span>
|
||||
<span className="text-cyan-400 font-medium">
|
||||
@@ -234,40 +234,40 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
|
||||
{/* Summary */}
|
||||
{entry.summary && !isA2A(entry) && (
|
||||
<div className="text-[10px] text-ink-mid mt-1">
|
||||
<span className="text-ink-mid font-medium">{wsName}:</span>{" "}
|
||||
<div className="text-[10px] text-zinc-400 mt-1">
|
||||
<span className="text-zinc-300 font-medium">{wsName}:</span>{" "}
|
||||
{entry.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{isError && entry.error_detail && (
|
||||
<div className="text-[10px] text-bad/80 mt-1 truncate">
|
||||
<div className="text-[10px] text-red-400/80 mt-1 truncate">
|
||||
{entry.error_detail.slice(0, 200)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message content — show request and/or response */}
|
||||
{requestText && (
|
||||
<div className="mt-1.5 bg-surface/60 border border-line/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
||||
<div className="text-[8px] text-ink-soft uppercase mb-1">
|
||||
<div className="mt-1.5 bg-zinc-950/60 border border-zinc-800/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
||||
<div className="text-[8px] text-zinc-500 uppercase mb-1">
|
||||
{isSend ? "Task" : "Request"}
|
||||
</div>
|
||||
<div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">
|
||||
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
|
||||
{requestText.slice(0, 2000)}
|
||||
{requestText.length > 2000 && (
|
||||
<span className="text-ink-mid"> ...({requestText.length} chars)</span>
|
||||
<span className="text-zinc-400"> ...({requestText.length} chars)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{responseText && (
|
||||
<div className="mt-1 bg-surface/60 border border-emerald-900/30 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
||||
<div className="text-[8px] text-good/60 uppercase mb-1">Response</div>
|
||||
<div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">
|
||||
<div className="mt-1 bg-zinc-950/60 border border-emerald-900/30 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
|
||||
<div className="text-[8px] text-emerald-500/60 uppercase mb-1">Response</div>
|
||||
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
|
||||
{responseText.slice(0, 2000)}
|
||||
{responseText.length > 2000 && (
|
||||
<span className="text-ink-mid"> ...({responseText.length} chars)</span>
|
||||
<span className="text-zinc-400"> ...({responseText.length} chars)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,11 +281,10 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3 border-t border-line bg-surface/50 flex justify-end">
|
||||
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex justify-end">
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 py-1.5 text-[12px] bg-surface-card hover:bg-surface-card text-ink-mid rounded-lg transition-colors"
|
||||
className="px-4 py-1.5 text-[12px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { isSaaSTenant } from "@/lib/tenant";
|
||||
|
||||
const STORAGE_KEY = "molecule_cookie_consent";
|
||||
|
||||
@@ -75,18 +74,7 @@ export function CookieConsent() {
|
||||
// Read persisted decision on mount. useState's initialState can't run
|
||||
// on first render because localStorage is SSR-unsafe — defer to
|
||||
// useEffect so the initial HTML is identical to the server snapshot.
|
||||
//
|
||||
// The banner is SaaS-only: it carries a link to the hosted
|
||||
// privacy policy (moleculesai.app/legal/privacy) and presumes
|
||||
// GDPR/ePrivacy obligations that only apply to the hosted offering.
|
||||
// Self-hosted / local-dev / Vercel-preview hosts get no banner —
|
||||
// matches the `isSaaSTenant()` convention used by AuthGate and
|
||||
// the tier picker.
|
||||
useEffect(() => {
|
||||
if (!isSaaSTenant()) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
setVisible(getStoredConsent() === null);
|
||||
}, []);
|
||||
|
||||
@@ -98,34 +86,25 @@ export function CookieConsent() {
|
||||
};
|
||||
|
||||
return (
|
||||
// role="region" + aria-label, NOT role="dialog" + aria-modal. The
|
||||
// banner is informational — it never blocks the page, never traps
|
||||
// focus, and the user can keep using the canvas while it's up.
|
||||
// Claiming aria-modal="true" without a focus trap is genuinely
|
||||
// harmful for screen-reader users: they get told the rest of the
|
||||
// page is inert, jump into the banner, and then can't escape.
|
||||
// Region semantics let assistive tech navigate around it normally.
|
||||
// (Also: forcing a modal cookie banner would be a dark pattern —
|
||||
// GDPR explicitly discourages it.)
|
||||
<section
|
||||
role="region"
|
||||
<div
|
||||
role="dialog"
|
||||
aria-labelledby="cookie-consent-title"
|
||||
aria-describedby="cookie-consent-body"
|
||||
className="fixed bottom-0 left-0 right-0 z-[9999] border-t border-line bg-surface/95 backdrop-blur-sm p-4 shadow-[0_-4px_12px_rgba(0,0,0,0.4)]"
|
||||
className="fixed bottom-0 left-0 right-0 z-[9999] border-t border-zinc-800 bg-zinc-950/95 backdrop-blur-sm p-4 shadow-[0_-4px_12px_rgba(0,0,0,0.4)]"
|
||||
>
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="text-sm text-ink-mid">
|
||||
<p id="cookie-consent-title" className="font-medium text-ink">
|
||||
<div className="text-sm text-zinc-300">
|
||||
<p id="cookie-consent-title" className="font-medium text-zinc-100">
|
||||
Cookies & your privacy
|
||||
</p>
|
||||
<p id="cookie-consent-body" className="mt-1 text-ink-mid">
|
||||
<p id="cookie-consent-body" className="mt-1 text-zinc-400">
|
||||
We use strictly-necessary cookies for authentication and session
|
||||
continuity. Accept to also allow optional functional cookies that
|
||||
improve your canvas experience (layout preferences, recent
|
||||
workspaces). See our{" "}
|
||||
<a
|
||||
href="https://moleculesai.app/legal/privacy"
|
||||
className="text-accent underline underline-offset-2 hover:text-accent-strong focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded-sm"
|
||||
className="text-blue-400 underline hover:text-blue-300"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
@@ -138,20 +117,20 @@ export function CookieConsent() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => decide("rejected")}
|
||||
className="rounded border border-line bg-surface-sunken px-4 py-2 text-sm text-ink hover:bg-surface-card focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
className="rounded border border-zinc-700 bg-zinc-900 px-4 py-2 text-sm text-zinc-200 hover:bg-zinc-800"
|
||||
>
|
||||
Necessary only
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => decide("accepted")}
|
||||
className="rounded border border-accent bg-accent-strong px-4 py-2 text-sm font-medium text-white hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
className="rounded border border-blue-600 bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500"
|
||||
>
|
||||
Accept all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, useId, useMemo } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useId } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { api } from "@/lib/api";
|
||||
import { isSaaSTenant } from "@/lib/tenant";
|
||||
import { ExternalConnectModal, type ExternalConnectionInfo } from "./ExternalConnectModal";
|
||||
|
||||
interface WorkspaceOption {
|
||||
id: string;
|
||||
@@ -12,122 +10,54 @@ interface WorkspaceOption {
|
||||
tier: number;
|
||||
}
|
||||
|
||||
// Subset of the /templates row used here. Mirrors the shape ConfigTab
|
||||
// reads. `providers` is the per-template declarative list of supported
|
||||
// LLM providers — sourced from the template's
|
||||
// runtime_config.providers (config.yaml). When present, it filters
|
||||
// the modal's provider <select> so an operator can only pick a
|
||||
// provider the template actually supports.
|
||||
interface TemplateSpec {
|
||||
id: string;
|
||||
name?: string;
|
||||
runtime?: string;
|
||||
providers?: string[];
|
||||
}
|
||||
|
||||
interface HermesProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
envVar: string;
|
||||
defaultModel: string;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
// All providers supported by Hermes runtime via providers.resolve_provider().
|
||||
// `defaultModel` is the slug injected into the workspace provision request
|
||||
// when the user picks this provider — template-hermes's derive-provider.sh
|
||||
// maps the prefix back to the provider name at install time, so this is
|
||||
// the canonical handshake. `models` are additional suggestions surfaced in
|
||||
// the datalist so the user can pick a different size without typing the
|
||||
// whole slug.
|
||||
// All providers supported by Hermes runtime via providers.resolve_provider()
|
||||
export const HERMES_PROVIDERS: HermesProvider[] = [
|
||||
{ id: "anthropic", label: "Anthropic (Claude)", envVar: "ANTHROPIC_API_KEY", defaultModel: "anthropic/claude-sonnet-4-5", models: ["anthropic/claude-opus-4-5", "anthropic/claude-sonnet-4-5", "anthropic/claude-haiku-4-5"] },
|
||||
{ id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY", defaultModel: "openai/gpt-4o", models: ["openai/gpt-4o", "openai/gpt-4o-mini", "openai/o3-mini"] },
|
||||
{ id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY", defaultModel: "openrouter/auto", models: ["openrouter/auto", "openrouter/anthropic/claude-sonnet-4", "openrouter/meta-llama/llama-3.3-70b"] },
|
||||
{ id: "xai", label: "xAI (Grok)", envVar: "XAI_API_KEY", defaultModel: "xai/grok-4", models: ["xai/grok-4", "xai/grok-4-mini"] },
|
||||
{ id: "gemini", label: "Google Gemini", envVar: "GEMINI_API_KEY", defaultModel: "gemini/gemini-2.5-pro", models: ["gemini/gemini-2.5-pro", "gemini/gemini-2.5-flash"] },
|
||||
{ id: "qwen", label: "Qwen (Alibaba)", envVar: "QWEN_API_KEY", defaultModel: "alibaba/qwen3-max", models: ["alibaba/qwen3-max", "alibaba/qwen3-coder"] },
|
||||
{ id: "glm", label: "GLM (Zhipu AI)", envVar: "GLM_API_KEY", defaultModel: "zai/glm-4.6", models: ["zai/glm-4.6", "zai/glm-4.5-air"] },
|
||||
{ id: "kimi", label: "Kimi (Moonshot)", envVar: "KIMI_API_KEY", defaultModel: "kimi-coding/kimi-k2", models: ["kimi-coding/kimi-k2", "kimi-coding/kimi-k1.5"] },
|
||||
{ id: "minimax", label: "MiniMax", envVar: "MINIMAX_API_KEY", defaultModel: "minimax/MiniMax-M2.7", models: ["minimax/MiniMax-M2.7", "minimax/MiniMax-M2.7-highspeed", "minimax/MiniMax-M1"] },
|
||||
{ id: "deepseek", label: "DeepSeek", envVar: "DEEPSEEK_API_KEY", defaultModel: "deepseek/deepseek-chat", models: ["deepseek/deepseek-chat", "deepseek/deepseek-reasoner"] },
|
||||
{ id: "groq", label: "Groq", envVar: "GROQ_API_KEY", defaultModel: "openrouter/groq/llama-3.3-70b", models: ["openrouter/groq/llama-3.3-70b"] },
|
||||
{ id: "mistral", label: "Mistral", envVar: "MISTRAL_API_KEY", defaultModel: "openrouter/mistralai/mistral-large", models: ["openrouter/mistralai/mistral-large"] },
|
||||
{ id: "together", label: "Together AI", envVar: "TOGETHER_API_KEY", defaultModel: "openrouter/meta-llama/llama-3.3-70b", models: ["openrouter/meta-llama/llama-3.3-70b"] },
|
||||
{ id: "fireworks", label: "Fireworks AI", envVar: "FIREWORKS_API_KEY", defaultModel: "openrouter/meta-llama/llama-3.3-70b", models: ["openrouter/meta-llama/llama-3.3-70b"] },
|
||||
{ id: "hermes", label: "Hermes / Nous (legacy)", envVar: "HERMES_API_KEY", defaultModel: "nousresearch/Hermes-3-Llama-3.1-405B", models: ["nousresearch/Hermes-3-Llama-3.1-405B", "nousresearch/Hermes-4-14B"] },
|
||||
{ id: "anthropic", label: "Anthropic (Claude)", envVar: "ANTHROPIC_API_KEY" },
|
||||
{ id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY" },
|
||||
{ id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY" },
|
||||
{ id: "xai", label: "xAI (Grok)", envVar: "XAI_API_KEY" },
|
||||
{ id: "gemini", label: "Google Gemini", envVar: "GEMINI_API_KEY" },
|
||||
{ id: "qwen", label: "Qwen (Alibaba)", envVar: "QWEN_API_KEY" },
|
||||
{ id: "glm", label: "GLM (Zhipu AI)", envVar: "GLM_API_KEY" },
|
||||
{ id: "kimi", label: "Kimi (Moonshot)", envVar: "KIMI_API_KEY" },
|
||||
{ id: "minimax", label: "MiniMax", envVar: "MINIMAX_API_KEY" },
|
||||
{ id: "deepseek", label: "DeepSeek", envVar: "DEEPSEEK_API_KEY" },
|
||||
{ id: "groq", label: "Groq", envVar: "GROQ_API_KEY" },
|
||||
{ id: "mistral", label: "Mistral", envVar: "MISTRAL_API_KEY" },
|
||||
{ id: "together", label: "Together AI", envVar: "TOGETHER_API_KEY" },
|
||||
{ id: "fireworks", label: "Fireworks AI", envVar: "FIREWORKS_API_KEY" },
|
||||
{ id: "hermes", label: "Hermes / Nous (legacy)", envVar: "HERMES_API_KEY" },
|
||||
];
|
||||
|
||||
export function CreateWorkspaceButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const [tier, setTier] = useState(1);
|
||||
const [template, setTemplate] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [budgetLimit, setBudgetLimit] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
|
||||
// Templates fetched from /api/templates — drives the dynamic provider
|
||||
// filter below. Same data source ConfigTab uses (PR #2454). When the
|
||||
// selected template declares `runtime_config.providers` in its
|
||||
// config.yaml, the modal surfaces only those providers in the
|
||||
// <select>. Empty/missing list falls back to the full HERMES_PROVIDERS
|
||||
// catalog so older templates without the field keep working.
|
||||
const [templateSpecs, setTemplateSpecs] = useState<TemplateSpec[]>([]);
|
||||
// External-runtime path: skip docker provision, mint a workspace_auth_token,
|
||||
// and surface the connection snippet in a modal after create. When
|
||||
// isExternal is true the template / model / hermes-provider fields are
|
||||
// hidden (they're meaningless for BYO-compute agents).
|
||||
const [isExternal, setIsExternal] = useState(false);
|
||||
const [externalConnection, setExternalConnection] =
|
||||
useState<ExternalConnectionInfo | null>(null);
|
||||
|
||||
// Hermes-specific state
|
||||
const [hermesProvider, setHermesProvider] = useState("anthropic");
|
||||
const [hermesApiKey, setHermesApiKey] = useState("");
|
||||
// Model slug is sent to CP as `model` and plumbed to the workspace EC2
|
||||
// as HERMES_DEFAULT_MODEL env var. template-hermes's derive-provider.sh
|
||||
// reads the prefix (`minimax/…`, `anthropic/…`) to set
|
||||
// HERMES_INFERENCE_PROVIDER at install time. Missing model → provider
|
||||
// falls back to "auto" and hermes picks its compiled-in default
|
||||
// (Anthropic), which 401s if the user's key is for a different
|
||||
// provider. Hence: require model when template=hermes.
|
||||
const [hermesModel, setHermesModel] = useState("");
|
||||
|
||||
// Tier picker: on SaaS every workspace gets its own EC2 VM (Full Access
|
||||
// by construction), so we hide the T1/T2/T3 Docker-sandbox tiers and
|
||||
// lock to T4 — the full-host access tier, which maps to t3.large at the
|
||||
// CP level. On self-hosted we still offer T1/T2/T3 because the Docker-
|
||||
// sandbox distinction is a real choice there; T4 is available too for
|
||||
// operators who want the full-host tier.
|
||||
//
|
||||
// SSR-safe via isSaaSTenant() contract (returns false on server); first
|
||||
// client render may flip the picker — acceptable one-frame reflow.
|
||||
const isSaaS = useMemo(() => isSaaSTenant(), []);
|
||||
const TIERS = useMemo(
|
||||
() =>
|
||||
isSaaS
|
||||
? [{ value: 4, label: "T4", desc: "Full Access" }]
|
||||
: [
|
||||
{ value: 1, label: "T1", desc: "Sandboxed" },
|
||||
{ value: 2, label: "T2", desc: "Standard" },
|
||||
{ value: 3, label: "T3", desc: "Privileged" },
|
||||
{ value: 4, label: "T4", desc: "Full Access" },
|
||||
],
|
||||
[isSaaS],
|
||||
);
|
||||
// T3 ("Privileged") is the self-hosted default — gives agents the
|
||||
// read_write workspace mount + Docker daemon access most templates
|
||||
// expect to do real work. T1 sandboxed and T2 standard are kept as
|
||||
// explicit opt-ins for low-trust agents. SaaS still defaults to T4
|
||||
// because every SaaS workspace gets its own EC2 (sibling VMs, no
|
||||
// shared blast radius — see isSaaSTenant() / tier picker hide logic).
|
||||
const defaultTier = isSaaS ? 4 : 3;
|
||||
const [tier, setTier] = useState(defaultTier);
|
||||
|
||||
// Refs for roving tabIndex on the tier radio group (WCAG 2.1 arrow-key nav)
|
||||
const radioRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const TIERS = [
|
||||
{ value: 1, label: "T1", desc: "Sandboxed" },
|
||||
{ value: 2, label: "T2", desc: "Standard" },
|
||||
{ value: 3, label: "T3", desc: "Full Access" },
|
||||
];
|
||||
|
||||
const handleRadioKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent, currentIndex: number) => {
|
||||
@@ -150,92 +80,22 @@ export function CreateWorkspaceButton() {
|
||||
|
||||
const isHermes = template.trim().toLowerCase() === "hermes";
|
||||
|
||||
// Resolve the selected template's spec from the /templates response.
|
||||
// The `template` input is free-text; templates can be matched by id,
|
||||
// name, or runtime so any of those work. Lower-cased compare keeps
|
||||
// "Hermes" / "hermes" / "HERMES" interchangeable.
|
||||
const selectedTemplateSpec = useMemo<TemplateSpec | null>(() => {
|
||||
const t = template.trim().toLowerCase();
|
||||
if (!t) return null;
|
||||
return (
|
||||
templateSpecs.find(
|
||||
(s) =>
|
||||
(s.id || "").toLowerCase() === t ||
|
||||
(s.name || "").toLowerCase() === t ||
|
||||
(s.runtime || "").toLowerCase() === t,
|
||||
) ?? null
|
||||
);
|
||||
}, [template, templateSpecs]);
|
||||
|
||||
// Filter HERMES_PROVIDERS by what the template declares it supports.
|
||||
// Empty/missing declared list → fall back to the full catalog so
|
||||
// templates that haven't migrated to the explicit `providers:` field
|
||||
// (and self-hosted setups without /templates) keep working unchanged.
|
||||
const availableProviders = useMemo<HermesProvider[]>(() => {
|
||||
const declared = selectedTemplateSpec?.providers;
|
||||
if (!declared || declared.length === 0) return HERMES_PROVIDERS;
|
||||
const allowed = new Set(declared.map((p) => p.toLowerCase()));
|
||||
const filtered = HERMES_PROVIDERS.filter((p) => allowed.has(p.id.toLowerCase()));
|
||||
// Defensive: if the template's declared list doesn't match anything
|
||||
// in our static catalog (e.g. brand-new provider id we don't have
|
||||
// metadata for yet), fall back to the full list rather than render
|
||||
// an empty <select>. Better to over-show than to lock the user out.
|
||||
return filtered.length > 0 ? filtered : HERMES_PROVIDERS;
|
||||
}, [selectedTemplateSpec]);
|
||||
|
||||
// If the currently-selected provider is filtered out by a template
|
||||
// change, snap back to the first available. Without this, the
|
||||
// hermesProvider state could refer to a provider not in the dropdown
|
||||
// — confusing UI + the API key field's envVar would be wrong.
|
||||
useEffect(() => {
|
||||
if (!isHermes) return;
|
||||
if (availableProviders.length === 0) return;
|
||||
if (!availableProviders.some((p) => p.id === hermesProvider)) {
|
||||
setHermesProvider(availableProviders[0].id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [availableProviders, isHermes]);
|
||||
|
||||
// Auto-fill hermesModel with the provider's defaultModel whenever the
|
||||
// provider changes, but only if the user hasn't already typed their own
|
||||
// slug. Prevents the empty-model → "auto" → Anthropic-default 401 trap.
|
||||
useEffect(() => {
|
||||
if (!isHermes) return;
|
||||
const p = HERMES_PROVIDERS.find((x) => x.id === hermesProvider);
|
||||
if (!p) return;
|
||||
// Replace model only if current value matches another provider's
|
||||
// default (user hasn't customized it) OR is empty.
|
||||
const isUntouched =
|
||||
hermesModel === "" ||
|
||||
HERMES_PROVIDERS.some((x) => x.defaultModel === hermesModel);
|
||||
if (isUntouched) setHermesModel(p.defaultModel);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hermesProvider, isHermes]);
|
||||
|
||||
// Reset form and load workspaces whenever dialog opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName("");
|
||||
setRole("");
|
||||
setTier(defaultTier);
|
||||
setTier(1);
|
||||
setTemplate("");
|
||||
setParentId("");
|
||||
setBudgetLimit("");
|
||||
setError(null);
|
||||
setHermesProvider("anthropic");
|
||||
setHermesApiKey("");
|
||||
setHermesModel("");
|
||||
api
|
||||
.get<WorkspaceOption[]>("/workspaces")
|
||||
.then((ws) => setWorkspaces(ws))
|
||||
.catch(() => {});
|
||||
api
|
||||
.get<TemplateSpec[]>("/templates")
|
||||
.then((rows) => setTemplateSpecs(Array.isArray(rows) ? rows : []))
|
||||
.catch(() => { /* keep empty — HERMES_PROVIDERS fallback below */ });
|
||||
// defaultTier is stable for the session (derived from window.location),
|
||||
// safe to omit from deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
@@ -247,10 +107,6 @@ export function CreateWorkspaceButton() {
|
||||
setError("API key is required for Hermes workspaces");
|
||||
return;
|
||||
}
|
||||
if (isHermes && !hermesModel.trim()) {
|
||||
setError("Model is required for Hermes workspaces — provider routing depends on the model slug prefix");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
|
||||
@@ -263,42 +119,18 @@ export function CreateWorkspaceButton() {
|
||||
? parseFloat(budgetLimit)
|
||||
: null;
|
||||
|
||||
const createResp = await api.post<{
|
||||
id: string;
|
||||
status: string;
|
||||
external?: boolean;
|
||||
connection?: ExternalConnectionInfo;
|
||||
}>("/workspaces", {
|
||||
await api.post("/workspaces", {
|
||||
name: name.trim(),
|
||||
role: role.trim() || undefined,
|
||||
// External workspaces don't consume a template — skip it so the
|
||||
// backend doesn't try to resolve a non-existent dir and log a
|
||||
// misleading "template not found" warning.
|
||||
template: isExternal ? undefined : (template.trim() || undefined),
|
||||
template: template.trim() || undefined,
|
||||
tier,
|
||||
parent_id: parentId || undefined,
|
||||
budget_limit: parsedBudget,
|
||||
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
|
||||
// Runtime=external flips the backend into awaiting-agent mode:
|
||||
// no container provisioning, token minted, connection payload
|
||||
// returned in the response for the modal below.
|
||||
...(isExternal ? { runtime: "external" } : {}),
|
||||
...(!isExternal && isHermes && provider
|
||||
? {
|
||||
secrets: { [provider.envVar]: hermesApiKey.trim() },
|
||||
model: hermesModel.trim(),
|
||||
}
|
||||
...(isHermes && provider
|
||||
? { secrets: { [provider.envVar]: hermesApiKey.trim() } }
|
||||
: {}),
|
||||
});
|
||||
// External path: keep the create dialog open just long enough to
|
||||
// hand control to the connect modal, then close. The connect
|
||||
// modal holds the token; we CANNOT re-fetch it later. If the
|
||||
// backend somehow returns external=true without a connection
|
||||
// payload we still close the create dialog — the operator will
|
||||
// have to mint a token via POST /workspaces/:id/tokens.
|
||||
if (isExternal && createResp.connection) {
|
||||
setExternalConnection(createResp.connection);
|
||||
}
|
||||
setOpen(false);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to create workspace");
|
||||
@@ -310,7 +142,7 @@ export function CreateWorkspaceButton() {
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={setOpen}>
|
||||
<Dialog.Trigger asChild>
|
||||
<button type="button" className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-accent hover:bg-accent-strong active:bg-accent text-sm font-medium rounded-xl text-white shadow-lg shadow-accent/20 hover:shadow-xl hover:shadow-accent/30 transition-all duration-200 flex items-center gap-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface">
|
||||
<button className="fixed bottom-6 right-6 z-40 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm font-medium rounded-xl text-white shadow-lg shadow-blue-600/20 hover:shadow-xl hover:shadow-blue-500/30 transition-all duration-200 flex items-center gap-2">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
@@ -333,12 +165,13 @@ export function CreateWorkspaceButton() {
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm" />
|
||||
<Dialog.Content
|
||||
className="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-surface-sunken border border-line/60 rounded-2xl shadow-2xl shadow-black/40 w-[400px] max-h-[90vh] overflow-y-auto p-6"
|
||||
className="fixed z-50 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 bg-zinc-900 border border-zinc-700/60 rounded-2xl shadow-2xl shadow-black/40 w-[400px] max-h-[90vh] overflow-y-auto p-6"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<Dialog.Title className="text-base font-semibold text-ink mb-1">
|
||||
<Dialog.Title className="text-base font-semibold text-zinc-100 mb-1">
|
||||
Create Workspace
|
||||
</Dialog.Title>
|
||||
<p className="text-xs text-ink-soft mb-5">
|
||||
<p className="text-xs text-zinc-500 mb-5">
|
||||
Add a new workspace node to the canvas
|
||||
</p>
|
||||
|
||||
@@ -364,46 +197,25 @@ export function CreateWorkspaceButton() {
|
||||
type="number"
|
||||
helper="Leave blank for unlimited"
|
||||
/>
|
||||
{/* External toggle — when on, this workspace is BYO-compute:
|
||||
no template, no model, no hermes provider fields. Backend
|
||||
returns a copyable connection snippet via the modal. */}
|
||||
<label className="flex items-start gap-2 rounded-lg border border-line p-3 cursor-pointer hover:border-line transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isExternal}
|
||||
onChange={(e) => setIsExternal(e.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="text-xs">
|
||||
<div className="text-ink font-medium">External agent (bring your own compute)</div>
|
||||
<div className="text-ink-soft mt-0.5">
|
||||
Skip the container. We'll return a workspace_id + auth token + ready-to-paste snippet so an agent running on your laptop / server / CI can register via A2A.
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{!isExternal && (
|
||||
<InputField
|
||||
label="Template"
|
||||
value={template}
|
||||
onChange={setTemplate}
|
||||
placeholder="e.g. seo-agent (from workspace-configs-templates/)"
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
<InputField
|
||||
label="Template"
|
||||
value={template}
|
||||
onChange={setTemplate}
|
||||
placeholder="e.g. seo-agent (from workspace-configs-templates/)"
|
||||
mono
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Workspace tier"
|
||||
className={`grid gap-1.5 ${isSaaS ? "grid-cols-1" : "grid-cols-4"}`}
|
||||
className="grid grid-cols-3 gap-1.5"
|
||||
>
|
||||
<div className={`text-[11px] text-ink-mid mb-1 ${isSaaS ? "" : "col-span-4"}`}>
|
||||
Tier{isSaaS ? " — dedicated VM" : ""}
|
||||
<div className="col-span-3 text-[11px] text-zinc-400 mb-1">
|
||||
Tier
|
||||
</div>
|
||||
{TIERS.map((t, idx) => (
|
||||
<button
|
||||
type="button"
|
||||
key={t.value}
|
||||
ref={(el) => { radioRefs.current[idx] = el; }}
|
||||
role="radio"
|
||||
@@ -413,8 +225,8 @@ export function CreateWorkspaceButton() {
|
||||
onKeyDown={(e) => handleRadioKeyDown(e, idx)}
|
||||
className={`py-2 rounded-lg text-center transition-colors ${
|
||||
tier === t.value
|
||||
? "bg-accent-strong/20 border border-accent/50 text-accent"
|
||||
: "bg-surface-card/60 border border-line/40 text-ink-mid hover:text-ink-mid hover:border-line"
|
||||
? "bg-blue-600/20 border border-blue-500/50 text-blue-300"
|
||||
: "bg-zinc-800/60 border border-zinc-700/40 text-zinc-400 hover:text-zinc-300 hover:border-zinc-600"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-mono font-semibold">
|
||||
@@ -429,13 +241,13 @@ export function CreateWorkspaceButton() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-[11px] text-ink-mid block mb-1">
|
||||
<label className="text-[11px] text-zinc-400 block mb-1">
|
||||
Parent Workspace
|
||||
</label>
|
||||
<select
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors"
|
||||
>
|
||||
<option value="">None (root level)</option>
|
||||
{workspaces.map((ws) => (
|
||||
@@ -456,7 +268,7 @@ export function CreateWorkspaceButton() {
|
||||
<p className="text-[11px] font-semibold text-violet-400 uppercase tracking-wide">
|
||||
Hermes Provider
|
||||
</p>
|
||||
<p className="text-[11px] text-ink-soft -mt-1">
|
||||
<p className="text-[11px] text-zinc-500 -mt-1">
|
||||
Choose the AI provider and paste your API key. The key is
|
||||
stored as an encrypted workspace secret.
|
||||
</p>
|
||||
@@ -464,7 +276,7 @@ export function CreateWorkspaceButton() {
|
||||
<div>
|
||||
<label
|
||||
htmlFor="hermes-provider-select"
|
||||
className="text-[11px] text-ink-mid block mb-1"
|
||||
className="text-[11px] text-zinc-400 block mb-1"
|
||||
>
|
||||
Provider
|
||||
</label>
|
||||
@@ -473,9 +285,9 @@ export function CreateWorkspaceButton() {
|
||||
value={hermesProvider}
|
||||
onChange={(e) => setHermesProvider(e.target.value)}
|
||||
aria-label="Hermes provider"
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors"
|
||||
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors"
|
||||
>
|
||||
{availableProviders.map((p) => (
|
||||
{HERMES_PROVIDERS.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
@@ -486,10 +298,10 @@ export function CreateWorkspaceButton() {
|
||||
<div>
|
||||
<label
|
||||
htmlFor="hermes-api-key-input"
|
||||
className="text-[11px] text-ink-mid block mb-1"
|
||||
className="text-[11px] text-zinc-400 block mb-1"
|
||||
>
|
||||
API Key{" "}
|
||||
<span aria-hidden="true" className="text-bad">
|
||||
<span aria-hidden="true" className="text-red-400">
|
||||
*
|
||||
</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
@@ -502,49 +314,16 @@ export function CreateWorkspaceButton() {
|
||||
placeholder="sk-…"
|
||||
aria-label="Hermes API key"
|
||||
autoComplete="off"
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
|
||||
className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="hermes-model-input"
|
||||
className="text-[11px] text-ink-mid block mb-1"
|
||||
>
|
||||
Model{" "}
|
||||
<span aria-hidden="true" className="text-bad">
|
||||
*
|
||||
</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
</label>
|
||||
<input
|
||||
id="hermes-model-input"
|
||||
type="text"
|
||||
value={hermesModel}
|
||||
onChange={(e) => setHermesModel(e.target.value)}
|
||||
placeholder="e.g. minimax/MiniMax-M2.7"
|
||||
aria-label="Hermes model slug"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
list="hermes-model-suggestions"
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
|
||||
/>
|
||||
<datalist id="hermes-model-suggestions">
|
||||
{HERMES_PROVIDERS.find((p) => p.id === hermesProvider)?.models.map(
|
||||
(m) => <option key={m} value={m} />,
|
||||
)}
|
||||
</datalist>
|
||||
<p className="text-[10px] text-ink-soft mt-1">
|
||||
Slug determines which provider hermes routes to at install time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-bad"
|
||||
className="mt-4 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
@@ -552,29 +331,20 @@ export function CreateWorkspaceButton() {
|
||||
|
||||
<div className="flex justify-end gap-2.5 mt-6">
|
||||
<Dialog.Close asChild>
|
||||
<button type="button" className="px-4 py-2 bg-surface-card hover:bg-surface-elevated hover:text-ink text-sm rounded-lg text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">
|
||||
<button className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-sm rounded-lg text-zinc-300 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="px-5 py-2 bg-accent hover:bg-accent-strong active:bg-accent text-sm rounded-lg text-white disabled:opacity-50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
className="px-5 py-2 bg-blue-600 hover:bg-blue-500 active:bg-blue-700 text-sm rounded-lg text-white disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
{/* Rendered as a sibling so it stays mounted after the create dialog
|
||||
closes. Without this the auth_token would disappear the moment
|
||||
the create modal unmounted its React subtree — the operator
|
||||
would never see the copy-paste snippet. */}
|
||||
<ExternalConnectModal
|
||||
info={externalConnection}
|
||||
onClose={() => setExternalConnection(null)}
|
||||
/>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
@@ -604,11 +374,11 @@ function InputField({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={inputId} className="text-[11px] text-ink-mid block mb-1">
|
||||
<label htmlFor={inputId} className="text-[11px] text-zinc-400 block mb-1">
|
||||
{label}{" "}
|
||||
{required && (
|
||||
<>
|
||||
<span aria-hidden="true" className="text-bad">
|
||||
<span aria-hidden="true" className="text-red-400">
|
||||
*
|
||||
</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
@@ -623,10 +393,10 @@ function InputField({
|
||||
placeholder={placeholder}
|
||||
min={type === "number" ? "0" : undefined}
|
||||
step={type === "number" ? "0.01" : undefined}
|
||||
className={`w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
|
||||
className={`w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-blue-500/60 focus:ring-1 focus:ring-blue-500/20 transition-colors ${mono ? "font-mono text-xs" : ""}`}
|
||||
/>
|
||||
{helper && (
|
||||
<p className="mt-1 text-xs text-ink-soft">{helper}</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">{helper}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -81,7 +81,7 @@ export function DeleteCascadeConfirmDialog({
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div aria-hidden="true" className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onCancel} />
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
@@ -89,10 +89,10 @@ export function DeleteCascadeConfirmDialog({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="cascade-dialog-title"
|
||||
className="relative bg-surface-sunken border border-red-800/60 rounded-xl shadow-2xl shadow-black/50 max-w-[420px] w-full mx-4 overflow-hidden"
|
||||
className="relative bg-zinc-900 border border-red-800/60 rounded-xl shadow-2xl shadow-black/50 max-w-[420px] w-full mx-4 overflow-hidden"
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-line">
|
||||
<h3 id="cascade-dialog-title" className="text-sm font-semibold text-bad">
|
||||
<div className="px-5 py-4 border-b border-zinc-800">
|
||||
<h3 id="cascade-dialog-title" className="text-sm font-semibold text-red-400">
|
||||
Delete Workspace and Children
|
||||
</h3>
|
||||
</div>
|
||||
@@ -101,20 +101,20 @@ export function DeleteCascadeConfirmDialog({
|
||||
{/* Warning */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="mt-0.5 shrink-0 w-8 h-8 rounded-full bg-red-900/30 flex items-center justify-center">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-bad" aria-hidden="true">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-red-400">
|
||||
<path d="M8 3L14 13H2L8 3Z" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round"/>
|
||||
<path d="M8 7v3M8 11.5v.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-[13px] text-ink-mid leading-relaxed">
|
||||
<span className="font-medium text-bad">"{name}"</span> has{" "}
|
||||
<strong className="text-ink">{children.length}</strong> child{" "}
|
||||
<p className="text-[13px] text-zinc-300 leading-relaxed">
|
||||
<span className="font-medium text-red-300">"{name}"</span> has{" "}
|
||||
<strong className="text-zinc-100">{children.length}</strong> child{" "}
|
||||
{children.length === 1 ? "workspace" : "workspaces"}:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Child list */}
|
||||
<ul className="space-y-1.5 mb-4 ml-4 list-disc list-inside text-[12px] text-ink-mid max-h-32 overflow-y-auto">
|
||||
<ul className="space-y-1.5 mb-4 ml-4 list-disc list-inside text-[12px] text-zinc-400 max-h-32 overflow-y-auto">
|
||||
{children.map((c) => (
|
||||
<li key={c.id} className="truncate" title={c.name}>{c.name}</li>
|
||||
))}
|
||||
@@ -122,51 +122,39 @@ export function DeleteCascadeConfirmDialog({
|
||||
|
||||
{/* Cascade warning */}
|
||||
<div className="rounded border border-red-900/40 bg-red-950/20 px-3 py-2.5 mb-4">
|
||||
<p className="text-[12px] text-bad/80 leading-relaxed">
|
||||
<p className="text-[12px] text-red-300/80 leading-relaxed">
|
||||
Deleting will cascade — <strong className="text-red-200">all child workspaces and their data will be permanently removed.</strong> This cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Checkbox guard. Ring-offset color was zinc-900 — the dialog
|
||||
actually sits on bg-surface-sunken, so the offset showed
|
||||
the wrong color through the ring gap. Switched to the
|
||||
real bg + a danger-tinted ring. */}
|
||||
{/* Checkbox guard */}
|
||||
<label className="flex items-start gap-2.5 cursor-pointer group select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onCheckedChange(e.target.checked)}
|
||||
className="mt-0.5 w-4 h-4 rounded border-line bg-surface-card text-bad cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
|
||||
className="mt-0.5 w-4 h-4 rounded border-zinc-600 bg-zinc-800 text-red-500 focus:ring-red-500 focus:ring-offset-0 focus:ring-offset-zinc-900 cursor-pointer"
|
||||
/>
|
||||
<span className="text-[12px] text-ink-mid group-hover:text-ink-mid leading-relaxed">
|
||||
<span className="text-[12px] text-zinc-400 group-hover:text-zinc-300 leading-relaxed">
|
||||
I understand this will permanently delete all listed workspaces and their data
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-line bg-surface/50">
|
||||
<div className="flex items-center justify-end gap-2 px-5 py-3 border-t border-zinc-800 bg-zinc-950/50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
// Was hover:bg-surface-card (same as base — silent no-op).
|
||||
// Lift to surface-elevated to match the Cancel pattern in
|
||||
// ConfirmDialog. Added focus-visible ring so keyboard users
|
||||
// see where focus lands.
|
||||
className="px-3.5 py-1.5 text-[13px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-elevated border border-line hover:border-line-soft rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
|
||||
className="px-3.5 py-1.5 text-[13px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={!checked}
|
||||
// Hover goes DARKER, not lighter — bg-red-500 on white text
|
||||
// drops contrast below AA vs bg-red-700. Same trap fixed in
|
||||
// ConfirmDialog and ApprovalBanner. focus-visible ring matches.
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors
|
||||
${checked
|
||||
? "bg-red-600 hover:bg-red-700 text-white cursor-pointer"
|
||||
: "bg-red-900/30 text-bad/40 cursor-not-allowed"
|
||||
? "bg-red-600 hover:bg-red-500 text-white cursor-pointer"
|
||||
: "bg-red-900/30 text-red-500/40 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
Delete All
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { OrgTemplatesSection } from "./TemplatePalette";
|
||||
import { type Template } from "@/lib/deploy-preflight";
|
||||
import { useTemplateDeploy } from "@/hooks/useTemplateDeploy";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { TIER_CONFIG } from "@/lib/design-tokens";
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tier: number;
|
||||
model: string;
|
||||
skills: string[];
|
||||
skill_count: number;
|
||||
}
|
||||
|
||||
export function EmptyState() {
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [blankCreating, setBlankCreating] = useState(false);
|
||||
const [blankError, setBlankError] = useState<string | null>(null);
|
||||
const [deploying, setDeploying] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
@@ -23,68 +31,55 @@ export function EmptyState() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Canvas fills in a visible "center-ish" spot on a fresh tenant so
|
||||
// the user doesn't have to pan to find their new workspace. Fixed
|
||||
// (200, 150) instead of the sidebar's random placement because the
|
||||
// canvas is guaranteed empty when this component mounts.
|
||||
const firstDeployCoords = useCallback(() => ({ x: 200, y: 150 }), []);
|
||||
|
||||
// After the POST succeeds, auto-select the new workspace and flip
|
||||
// the panel to Chat. This is a UX flourish that only makes sense
|
||||
// on first deploy (the canvas is empty so the selection can't
|
||||
// surprise anyone); the sidebar intentionally skips this step.
|
||||
// 500 ms delay so React Flow has a frame to render the new node
|
||||
// before it receives focus.
|
||||
const handleDeployed = useCallback((workspaceId: string) => {
|
||||
setTimeout(() => {
|
||||
useCanvasStore.getState().selectNode(workspaceId);
|
||||
useCanvasStore.getState().setPanelTab("chat");
|
||||
}, 500);
|
||||
}, []);
|
||||
|
||||
const { deploy, deploying, error, modal } = useTemplateDeploy({
|
||||
canvasCoords: firstDeployCoords,
|
||||
onDeployed: handleDeployed,
|
||||
});
|
||||
|
||||
// "Create blank" bypasses templates entirely — no preflight, no
|
||||
// modal, just POST /workspaces with a default name. Deliberately
|
||||
// NOT routed through useTemplateDeploy because it has no
|
||||
// `template.id` to deploy against.
|
||||
//
|
||||
// tier is omitted so the backend picks a SaaS-aware default
|
||||
// (T4 on SaaS, T3 on self-hosted — see WorkspaceHandler.DefaultTier).
|
||||
// The previous hardcoded `tier: 2` shipped every fresh-tenant agent
|
||||
// at Standard regardless of host, which surprised SaaS users whose
|
||||
// CreateWorkspaceDialog already defaults to T4.
|
||||
const createBlank = async () => {
|
||||
setBlankCreating(true);
|
||||
setBlankError(null);
|
||||
const deploy = async (template: Template) => {
|
||||
setDeploying(template.id);
|
||||
setError(null);
|
||||
try {
|
||||
const ws = await api.post<{ id: string }>("/workspaces", {
|
||||
name: "My First Agent",
|
||||
canvas: firstDeployCoords(),
|
||||
name: template.name,
|
||||
template: template.id,
|
||||
tier: template.tier,
|
||||
canvas: { x: 200, y: 150 },
|
||||
});
|
||||
handleDeployed(ws.id);
|
||||
// Auto-select the new workspace and open chat
|
||||
setTimeout(() => {
|
||||
useCanvasStore.getState().selectNode(ws.id);
|
||||
useCanvasStore.getState().setPanelTab("chat");
|
||||
}, 500);
|
||||
} catch (e) {
|
||||
setBlankError(e instanceof Error ? e.message : "Create failed");
|
||||
setError(e instanceof Error ? e.message : "Deploy failed");
|
||||
} finally {
|
||||
setBlankCreating(false);
|
||||
setDeploying(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Any active gesture locks every button so the user can't fire a
|
||||
// second POST while the first is still in flight.
|
||||
const anyDeploying = !!deploying || blankCreating;
|
||||
const displayError = error ?? blankError;
|
||||
const createBlank = async () => {
|
||||
setDeploying("blank");
|
||||
setError(null);
|
||||
try {
|
||||
const ws = await api.post<{ id: string }>("/workspaces", {
|
||||
name: "My First Agent",
|
||||
tier: 2,
|
||||
canvas: { x: 200, y: 150 },
|
||||
});
|
||||
setTimeout(() => {
|
||||
useCanvasStore.getState().selectNode(ws.id);
|
||||
useCanvasStore.getState().setPanelTab("chat");
|
||||
}, 500);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Create failed");
|
||||
} finally {
|
||||
setDeploying(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-start justify-center pointer-events-none z-[1] overflow-y-auto py-8">
|
||||
<div className="relative max-w-2xl w-full rounded-3xl border border-line/70 bg-surface/80 backdrop-blur-xl px-8 py-8 text-center shadow-2xl shadow-black/40 pointer-events-auto mx-4">
|
||||
<div className="relative max-w-2xl w-full rounded-3xl border border-zinc-800/70 bg-zinc-950/80 backdrop-blur-xl px-8 py-8 text-center shadow-2xl shadow-black/40 pointer-events-auto mx-4">
|
||||
<div className="absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-blue-500/50 to-transparent" />
|
||||
|
||||
{/* Logo */}
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-sky-500/20 via-blue-500/20 to-violet-500/20 border border-accent/20 flex items-center justify-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-sky-500/20 via-blue-500/20 to-violet-500/20 border border-blue-500/20 flex items-center justify-center">
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
||||
<rect x="3" y="3" width="10" height="10" rx="2" stroke="#60a5fa" strokeWidth="1.5" opacity="0.65" />
|
||||
<rect x="15" y="3" width="10" height="10" rx="2" stroke="#60a5fa" strokeWidth="1.5" opacity="0.65" />
|
||||
@@ -96,16 +91,16 @@ export function EmptyState() {
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.28em] text-sky-400/80 mb-2">
|
||||
Welcome to Molecule AI
|
||||
</p>
|
||||
<h2 className="text-xl font-semibold text-ink mb-1">
|
||||
<h2 className="text-xl font-semibold text-zinc-100 mb-1">
|
||||
Deploy your first agent
|
||||
</h2>
|
||||
<p className="text-sm text-ink-mid mb-6 leading-relaxed">
|
||||
<p className="text-sm text-zinc-400 mb-6 leading-relaxed">
|
||||
Pick a template to get started instantly, or create a blank workspace.
|
||||
</p>
|
||||
|
||||
{/* Template grid */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-ink-mid py-4">
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-zinc-400 py-4">
|
||||
<Spinner />
|
||||
Loading templates...
|
||||
</div>
|
||||
@@ -115,25 +110,24 @@ export function EmptyState() {
|
||||
const tierColor = TIER_CONFIG[t.tier]?.border || TIER_CONFIG[1].border;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
onClick={() => void deploy(t)}
|
||||
disabled={anyDeploying}
|
||||
className="group rounded-xl border border-line/60 bg-surface-sunken/50 px-3.5 py-3 hover:border-accent/40 hover:bg-surface-sunken/80 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-line/60 disabled:hover:bg-surface-sunken/50 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70"
|
||||
onClick={() => deploy(t)}
|
||||
disabled={!!deploying}
|
||||
className="group rounded-xl border border-zinc-800/60 bg-zinc-900/50 px-3.5 py-3 hover:border-blue-500/40 hover:bg-zinc-900/80 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-zinc-800/60 disabled:hover:bg-zinc-900/50 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium text-ink group-hover:text-ink truncate">
|
||||
<span className="text-sm font-medium text-zinc-200 group-hover:text-zinc-100 truncate">
|
||||
{deploying === t.id ? "Deploying..." : t.name}
|
||||
</span>
|
||||
<span className={`text-[8px] font-mono font-semibold px-1.5 py-0.5 rounded-md border ${tierColor}`}>
|
||||
T{t.tier}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-ink-soft line-clamp-2 leading-relaxed">
|
||||
<p className="text-[11px] text-zinc-500 line-clamp-2 leading-relaxed">
|
||||
{t.description || "No description"}
|
||||
</p>
|
||||
{t.skill_count > 0 && (
|
||||
<p className="text-[9px] text-ink-soft mt-1.5">
|
||||
<p className="text-[9px] text-zinc-500 mt-1.5">
|
||||
{t.skill_count} skill{t.skill_count !== 1 ? "s" : ""}
|
||||
{t.model ? ` · ${t.model}` : ""}
|
||||
</p>
|
||||
@@ -146,38 +140,32 @@ export function EmptyState() {
|
||||
|
||||
{/* Create blank */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={createBlank}
|
||||
disabled={anyDeploying}
|
||||
className="w-full rounded-xl border border-dashed border-line/60 bg-surface-sunken/30 px-4 py-3 text-sm text-ink-mid hover:text-ink hover:border-line hover:bg-surface-sunken/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-ink-mid disabled:hover:border-line/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70"
|
||||
disabled={!!deploying}
|
||||
className="w-full rounded-xl border border-dashed border-zinc-700/60 bg-zinc-900/30 px-4 py-3 text-sm text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 hover:bg-zinc-900/50 transition-all disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:text-zinc-400 disabled:hover:border-zinc-700/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/70"
|
||||
>
|
||||
{blankCreating ? "Creating..." : "+ Create blank workspace"}
|
||||
{deploying === "blank" ? "Creating..." : "+ Create blank workspace"}
|
||||
</button>
|
||||
|
||||
{/* Org templates — instantiate a whole team in one click */}
|
||||
<div className="mt-4 pt-4 border-t border-line/50 text-left">
|
||||
<div className="mt-4 pt-4 border-t border-zinc-800/50 text-left">
|
||||
<OrgTemplatesSection />
|
||||
</div>
|
||||
|
||||
{displayError && (
|
||||
<div role="alert" className="mt-3 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-bad">
|
||||
{displayError}
|
||||
{error && (
|
||||
<div role="alert" className="mt-3 px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-xs text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Missing-keys preflight modal — owned by useTemplateDeploy,
|
||||
shared with TemplatePalette. Rendered inline here so it
|
||||
overlays this card naturally. */}
|
||||
{modal}
|
||||
|
||||
{/* Tips */}
|
||||
<div className="mt-5 pt-4 border-t border-line/50">
|
||||
<div className="flex items-center justify-center gap-6 text-[10px] text-ink-mid">
|
||||
<div className="mt-5 pt-4 border-t border-zinc-800/50">
|
||||
<div className="flex items-center justify-center gap-6 text-[10px] text-zinc-400">
|
||||
<span>Drag to nest workspaces into teams</span>
|
||||
<span className="text-ink-soft">|</span>
|
||||
<span className="text-zinc-700">|</span>
|
||||
<span>Right-click for actions</span>
|
||||
<span className="text-ink-soft">|</span>
|
||||
<span>Press <kbd className="px-1 py-0.5 bg-surface-card rounded text-ink-soft font-mono">⌘K</kbd> to search</span>
|
||||
<span className="text-zinc-700">|</span>
|
||||
<span>Press <kbd className="px-1 py-0.5 bg-zinc-800 rounded text-zinc-500 font-mono">⌘K</kbd> to search</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,8 +51,8 @@ export class ErrorBoundary extends React.Component<
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-surface z-50">
|
||||
<div className="max-w-md rounded-2xl border border-red-500/30 bg-surface-sunken/90 px-8 py-8 text-center shadow-2xl shadow-black/40">
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-zinc-950 z-50">
|
||||
<div className="max-w-md rounded-2xl border border-red-500/30 bg-zinc-900/90 px-8 py-8 text-center shadow-2xl shadow-black/40">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-red-500/10 border border-red-500/30">
|
||||
<svg
|
||||
width="24"
|
||||
@@ -63,27 +63,25 @@ export class ErrorBoundary extends React.Component<
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-ink mb-2">
|
||||
<h2 className="text-lg font-semibold text-zinc-100 mb-2">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-sm text-ink-mid mb-1">
|
||||
<p className="text-sm text-zinc-400 mb-1">
|
||||
An unexpected error occurred while rendering the application.
|
||||
</p>
|
||||
<p className="text-xs text-bad/80 mb-6 font-mono break-all">
|
||||
<p className="text-xs text-red-400/80 mb-6 font-mono break-all">
|
||||
{this.state.error?.message ?? "Unknown error"}
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={this.handleReload}
|
||||
className="rounded-lg bg-accent-strong hover:bg-accent px-5 py-2 text-sm font-medium text-white transition-colors"
|
||||
className="rounded-lg bg-blue-600 hover:bg-blue-500 px-5 py-2 text-sm font-medium text-white transition-colors"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
@@ -93,7 +91,7 @@ export class ErrorBoundary extends React.Component<
|
||||
e.preventDefault();
|
||||
this.handleReport();
|
||||
}}
|
||||
className="rounded-lg border border-line hover:border-line px-5 py-2 text-sm font-medium text-ink-mid hover:text-ink transition-colors"
|
||||
className="rounded-lg border border-zinc-700 hover:border-zinc-600 px-5 py-2 text-sm font-medium text-zinc-300 hover:text-zinc-100 transition-colors"
|
||||
>
|
||||
Report
|
||||
</a>
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
'use client';
|
||||
|
||||
// ExternalConnectModal — shown once after creating a runtime="external"
|
||||
// workspace. Surfaces the workspace_auth_token + ready-to-paste snippets
|
||||
// so the operator can hand them to whoever runs their off-host agent
|
||||
// without piecing together the register payload from docs.
|
||||
//
|
||||
// Security posture:
|
||||
// - The auth_token is visible once. After the modal closes, the value
|
||||
// is unrecoverable (the /workspaces/:id read endpoints never echo it).
|
||||
// UI warns the operator before they dismiss.
|
||||
// - A "copy to clipboard" button uses the navigator.clipboard API which
|
||||
// is same-origin and requires user gesture — no cross-origin leak.
|
||||
// - Snippets use placeholders for the operator's own public URL
|
||||
// ($AGENT_URL). They ARE NOT filled in server-side because the
|
||||
// server doesn't know where the operator's agent will live.
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
|
||||
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "fields";
|
||||
|
||||
export interface ExternalConnectionInfo {
|
||||
workspace_id: string;
|
||||
platform_url: string;
|
||||
auth_token: string;
|
||||
registry_endpoint: string;
|
||||
heartbeat_endpoint: string;
|
||||
curl_register_template: string;
|
||||
python_snippet: string;
|
||||
// Claude Code channel plugin snippet — for operators whose external
|
||||
// agent IS a Claude Code session. Polling-based; no tunnel required.
|
||||
// Optional in the type for backward compat with platforms that
|
||||
// haven't shipped molecule-core PR #2304 yet (older response payload
|
||||
// omits the field; tab is hidden if empty).
|
||||
claude_code_channel_snippet?: string;
|
||||
// Universal MCP snippet — runtime-agnostic outbound tool path via
|
||||
// the `molecule-mcp` console script in the
|
||||
// molecule-ai-workspace-runtime PyPI wheel. Works with any MCP-aware
|
||||
// agent runtime (Claude Code, hermes, codex, third-party). Outbound-
|
||||
// only: pair with claude_code_channel or python tabs for heartbeat
|
||||
// + inbound. Optional for backward compat with platforms that
|
||||
// haven't shipped PR #2413 yet.
|
||||
universal_mcp_snippet?: string;
|
||||
// Hermes channel snippet — for operators whose external agent IS a
|
||||
// hermes-agent session. Routes A2A traffic into the hermes gateway
|
||||
// via the molecule-channel plugin (Molecule-AI/hermes-channel-molecule).
|
||||
// Long-poll based (no tunnel) — same UX shape as the Claude Code
|
||||
// channel tab. Gives hermes true push parity. Optional for backward
|
||||
// compat with platforms that haven't shipped this PR yet.
|
||||
hermes_channel_snippet?: string;
|
||||
// Codex MCP config snippet — wires the molecule MCP server into
|
||||
// ~/.codex/config.toml so codex agents can call platform tools.
|
||||
// Outbound-tools-only today (codex's MCP client doesn't route
|
||||
// notifications/*); push parity would need a separate bridge daemon.
|
||||
codex_snippet?: string;
|
||||
// OpenClaw MCP config snippet — wires molecule MCP + starts the
|
||||
// openclaw gateway on loopback. Outbound-tools-only today; push
|
||||
// parity on an external openclaw needs a sessions.steer bridge.
|
||||
openclaw_snippet?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
info: ExternalConnectionInfo | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
// Default to Universal MCP when the platform offers it — runtime-
|
||||
// agnostic outbound tool path that works for any MCP-aware runtime
|
||||
// (Claude Code, hermes, codex, etc.) and lets operators inspect the
|
||||
// primitives before picking a runtime-specific tab. Python SDK is
|
||||
// the fallback for platforms predating the universal_mcp_snippet
|
||||
// field. Pre-2026-05-03 the default was "claude" (Claude Code first)
|
||||
// but operators using non-Claude runtimes opened to a tab they had
|
||||
// to skip past — universal MCP works for everyone as a starting
|
||||
// point and the runtime-specific tabs are still one click away.
|
||||
const initialTab: Tab = info?.universal_mcp_snippet
|
||||
? "mcp"
|
||||
: "python";
|
||||
const [tab, setTab] = useState<Tab>(initialTab);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
||||
const copy = useCallback(async (value: string, key: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopiedKey(key);
|
||||
// Auto-clear the "Copied!" label after 1.5s so a second copy
|
||||
// attempt feels responsive — without the reset, the second
|
||||
// click appears as a no-op.
|
||||
window.setTimeout(() => setCopiedKey(null), 1500);
|
||||
} catch {
|
||||
// Fallback for browsers that refuse clipboard access (http://
|
||||
// over insecure origin, Safari private mode, etc.). We surface
|
||||
// a minimal textarea so the operator can manually copy.
|
||||
const el = document.getElementById(`fallback-${key}`) as HTMLTextAreaElement | null;
|
||||
if (el) {
|
||||
el.select();
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!info) return null;
|
||||
|
||||
// Python snippet is stamped server-side with workspace_id +
|
||||
// platform_url but leaves AUTH_TOKEN as a "<paste …>" placeholder
|
||||
// (that's what we're showing in the modal). Fill in the real
|
||||
// token here so the snippet the operator copies is truly ready-to-run.
|
||||
const filledPython = info.python_snippet.replace(
|
||||
'AUTH_TOKEN = "<paste from create response>"',
|
||||
`AUTH_TOKEN = "${info.auth_token}"`,
|
||||
);
|
||||
const filledCurl = info.curl_register_template.replace(
|
||||
'WORKSPACE_AUTH_TOKEN="<paste from create response>"',
|
||||
`WORKSPACE_AUTH_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
// The channel snippet asks the operator to paste the auth_token into
|
||||
// the .env file's MOLECULE_WORKSPACE_TOKENS field. Stamp it server-side
|
||||
// here so the copy-paste-block is truly ready-to-run.
|
||||
const filledChannel = info.claude_code_channel_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>',
|
||||
`MOLECULE_WORKSPACE_TOKENS=${info.auth_token}`,
|
||||
);
|
||||
// Universal MCP snippet uses MOLECULE_WORKSPACE_TOKEN as the env-var
|
||||
// name passed through to molecule-mcp via `claude mcp add ... -- env
|
||||
// MOLECULE_WORKSPACE_TOKEN=...`. The placeholder must match the
|
||||
// template's literal — pre-2026-04-30 polish this looked for
|
||||
// WORKSPACE_AUTH_TOKEN (carryover from the curl tab), which silently
|
||||
// skipped the substitution and left "<paste from create response>"
|
||||
// visible in the operator's clipboard.
|
||||
const filledUniversalMcp = info.universal_mcp_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
// Hermes channel snippet uses MOLECULE_WORKSPACE_TOKEN (same env-var
|
||||
// name as Universal MCP). Stamp the auth_token in so the operator's
|
||||
// copy-paste is fully ready-to-run.
|
||||
const filledHermes = info.hermes_channel_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
// Codex + OpenClaw snippets carry the placeholder inside the
|
||||
// generated config block (TOML / JSON respectively). Stamp the
|
||||
// token in so the copy-paste is one less manual edit.
|
||||
const filledCodex = info.codex_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN = "${info.auth_token}"`,
|
||||
);
|
||||
const filledOpenClaw = info.openclaw_snippet?.replace(
|
||||
'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`WORKSPACE_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/60 z-50" />
|
||||
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 w-[min(720px,92vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl bg-surface-sunken border border-line p-6 shadow-2xl">
|
||||
<Dialog.Title className="text-lg font-semibold text-ink">
|
||||
Connect your external agent
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="mt-1 text-sm text-ink-mid">
|
||||
Paste the snippet below into your agent's deployment. The
|
||||
auth token is shown <span className="text-warm">only once</span>
|
||||
{" "}— save it somewhere safe before closing this dialog.
|
||||
</Dialog.Description>
|
||||
|
||||
{/* Tabs */}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Connection snippet format"
|
||||
className="mt-4 flex gap-1 border-b border-line"
|
||||
>
|
||||
{(() => {
|
||||
// Build the tab order dynamically. Claude Code first
|
||||
// (when offered) since it's the simplest setup; Python
|
||||
// SDK second (full register+heartbeat+inbound); Universal
|
||||
// MCP third (any MCP-aware runtime, outbound-only); curl
|
||||
// for one-shot register; Fields for raw values.
|
||||
// Tab order: Universal MCP first (default, runtime-
|
||||
// agnostic primitives), then runtime-specific channel/
|
||||
// SDK tabs, then curl + Fields. Each runtime tab only
|
||||
// appears when the platform supplies the snippet — no
|
||||
// dead "tab missing snippet" UX.
|
||||
const tabs: Tab[] = [];
|
||||
if (filledUniversalMcp) tabs.push("mcp");
|
||||
tabs.push("python");
|
||||
if (filledChannel) tabs.push("claude");
|
||||
if (filledHermes) tabs.push("hermes");
|
||||
if (filledCodex) tabs.push("codex");
|
||||
if (filledOpenClaw) tabs.push("openclaw");
|
||||
tabs.push("curl", "fields");
|
||||
return tabs;
|
||||
})().map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
|
||||
tab === t
|
||||
? "border-accent text-ink"
|
||||
: "border-transparent text-ink-soft hover:text-ink-mid"
|
||||
}`}
|
||||
>
|
||||
{t === "claude"
|
||||
? "Claude Code"
|
||||
: t === "hermes"
|
||||
? "Hermes"
|
||||
: t === "codex"
|
||||
? "Codex"
|
||||
: t === "openclaw"
|
||||
? "OpenClaw"
|
||||
: t === "python"
|
||||
? "Python SDK"
|
||||
: t === "mcp"
|
||||
? "Universal MCP"
|
||||
: t === "curl"
|
||||
? "curl"
|
||||
: "Fields"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Snippet area */}
|
||||
<div className="mt-3">
|
||||
{tab === "claude" && filledChannel && (
|
||||
<SnippetBlock
|
||||
value={filledChannel}
|
||||
label="Claude Code channel — polls workspace's A2A; no tunnel needed"
|
||||
copyKey="claude"
|
||||
copied={copiedKey === "claude"}
|
||||
onCopy={() => copy(filledChannel, "claude")}
|
||||
/>
|
||||
)}
|
||||
{tab === "python" && (
|
||||
<SnippetBlock
|
||||
value={filledPython}
|
||||
label="Python SDK — includes heartbeat loop (push-mode, needs public URL)"
|
||||
copyKey="python"
|
||||
copied={copiedKey === "python"}
|
||||
onCopy={() => copy(filledPython, "python")}
|
||||
/>
|
||||
)}
|
||||
{tab === "curl" && (
|
||||
<SnippetBlock
|
||||
value={filledCurl}
|
||||
label="curl — one-shot register only (no heartbeat)"
|
||||
copyKey="curl"
|
||||
copied={copiedKey === "curl"}
|
||||
onCopy={() => copy(filledCurl, "curl")}
|
||||
/>
|
||||
)}
|
||||
{tab === "mcp" && filledUniversalMcp && (
|
||||
<SnippetBlock
|
||||
value={filledUniversalMcp}
|
||||
label="Universal MCP — standalone register + heartbeat + tools for any MCP-aware runtime (Claude Code, hermes, codex). Pair with Python or Claude Code tab if you need inbound A2A delivery."
|
||||
copyKey="mcp"
|
||||
copied={copiedKey === "mcp"}
|
||||
onCopy={() => copy(filledUniversalMcp, "mcp")}
|
||||
/>
|
||||
)}
|
||||
{tab === "hermes" && filledHermes && (
|
||||
<SnippetBlock
|
||||
value={filledHermes}
|
||||
label="Hermes channel — bridges this workspace's A2A traffic into your hermes-agent session as platform messages (push parity with Claude Code). Long-poll based; no tunnel needed."
|
||||
copyKey="hermes"
|
||||
copied={copiedKey === "hermes"}
|
||||
onCopy={() => copy(filledHermes, "hermes")}
|
||||
/>
|
||||
)}
|
||||
{tab === "codex" && filledCodex && (
|
||||
<SnippetBlock
|
||||
value={filledCodex}
|
||||
label="Codex MCP config — wires the molecule MCP server into ~/.codex/config.toml. Outbound tools today; inbound A2A push needs the Python SDK tab paired in (codex's MCP runtime doesn't route arbitrary notifications/* yet)."
|
||||
copyKey="codex"
|
||||
copied={copiedKey === "codex"}
|
||||
onCopy={() => copy(filledCodex, "codex")}
|
||||
/>
|
||||
)}
|
||||
{tab === "openclaw" && filledOpenClaw && (
|
||||
<SnippetBlock
|
||||
value={filledOpenClaw}
|
||||
label="OpenClaw MCP config — wires the molecule MCP server via openclaw mcp set + starts the gateway on loopback. Outbound tools today; inbound A2A push on an external openclaw needs the Python SDK tab paired in (a sessions.steer bridge daemon is future work)."
|
||||
copyKey="openclaw"
|
||||
copied={copiedKey === "openclaw"}
|
||||
onCopy={() => copy(filledOpenClaw, "openclaw")}
|
||||
/>
|
||||
)}
|
||||
{tab === "fields" && (
|
||||
<div className="space-y-2">
|
||||
<Field label="workspace_id" value={info.workspace_id} onCopy={() => copy(info.workspace_id, "wsid")} copied={copiedKey === "wsid"} />
|
||||
<Field label="platform_url" value={info.platform_url} onCopy={() => copy(info.platform_url, "url")} copied={copiedKey === "url"} />
|
||||
<Field
|
||||
label="auth_token"
|
||||
value={info.auth_token}
|
||||
onCopy={() => copy(info.auth_token, "tok")}
|
||||
copied={copiedKey === "tok"}
|
||||
mono
|
||||
/>
|
||||
<Field label="registry_endpoint" value={info.registry_endpoint} onCopy={() => copy(info.registry_endpoint, "reg")} copied={copiedKey === "reg"} />
|
||||
<Field label="heartbeat_endpoint" value={info.heartbeat_endpoint} onCopy={() => copy(info.heartbeat_endpoint, "hb")} copied={copiedKey === "hb"} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm rounded-lg bg-surface-card hover:bg-surface-card text-ink"
|
||||
>
|
||||
I've saved it — close
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function SnippetBlock({
|
||||
value,
|
||||
label,
|
||||
copied,
|
||||
onCopy,
|
||||
}: {
|
||||
value: string;
|
||||
label: string;
|
||||
copyKey: string;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between pb-1">
|
||||
<span className="text-xs text-ink-soft">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-xs bg-surface border border-line rounded-lg p-3 max-h-80 overflow-auto whitespace-pre-wrap break-all font-mono text-ink">
|
||||
{value}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
onCopy,
|
||||
copied,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onCopy: () => void;
|
||||
copied: boolean;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-ink-soft w-36 shrink-0">{label}</span>
|
||||
<code
|
||||
className={`flex-1 text-xs bg-surface border border-line rounded px-2 py-1 text-ink break-all ${mono ? "font-mono" : ""}`}
|
||||
>
|
||||
{value || "(missing)"}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
disabled={!value}
|
||||
className="text-xs px-2 py-1 rounded bg-surface-card hover:bg-surface-card text-ink disabled:opacity-40"
|
||||
>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { STATUS_CONFIG, TIER_CONFIG } from "@/lib/design-tokens";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { STATUS_CONFIG } from "@/lib/design-tokens";
|
||||
|
||||
const LEGEND_STATUSES = ["online", "provisioning", "degraded", "failed", "paused", "offline"] as const;
|
||||
|
||||
// Tier descriptions kept in sync with CreateWorkspaceDialog.tsx (the
|
||||
// source of truth for what each tier means semantically). Colors come
|
||||
// from TIER_CONFIG so the legend swatch matches the badge actually
|
||||
// rendered on every WorkspaceNode — drift here misled users into
|
||||
// thinking the legend documented a different tier than the one shown.
|
||||
const LEGEND_TIERS: ReadonlyArray<{ tier: number; label: string }> = [
|
||||
{ tier: 1, label: "Sandboxed" },
|
||||
{ tier: 2, label: "Standard" },
|
||||
{ tier: 3, label: "Privileged" },
|
||||
{ tier: 4, label: "Full Access" },
|
||||
];
|
||||
|
||||
// Persist the user's choice across sessions. Default is "open" so
|
||||
// first-time users still see the symbol key; once dismissed we
|
||||
// respect that until they explicitly reopen via the floating pill.
|
||||
const STORAGE_KEY = "molecule.legend.open";
|
||||
|
||||
function readStoredOpen(): boolean {
|
||||
if (typeof window === "undefined") return true;
|
||||
try {
|
||||
const v = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (v === null) return true;
|
||||
return v === "1";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredOpen(open: boolean) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, open ? "1" : "0");
|
||||
} catch {
|
||||
// localStorage can throw in private mode / quota / disabled
|
||||
// contexts. Silent fallback — the in-memory state still works
|
||||
// for the current session.
|
||||
}
|
||||
}
|
||||
|
||||
export function Legend() {
|
||||
// TemplatePalette (when open) is fixed top-0 left-0 w-[280px] — the
|
||||
// default bottom-6 left-4 position of this legend would sit under it.
|
||||
// Shift past the 280 px palette + a 16 px gap when the palette is open.
|
||||
const paletteOpen = useCanvasStore((s) => s.templatePaletteOpen);
|
||||
const leftClass = paletteOpen ? "left-[296px]" : "left-4";
|
||||
|
||||
// SSR-safe pattern: mount with the default (true) so first paint
|
||||
// matches the server output, then hydrate the persisted value
|
||||
// after mount. Avoids a hydration mismatch warning when the user
|
||||
// had previously closed the legend.
|
||||
const [open, setOpen] = useState(true);
|
||||
useEffect(() => {
|
||||
setOpen(readStoredOpen());
|
||||
}, []);
|
||||
|
||||
const closeLegend = () => {
|
||||
setOpen(false);
|
||||
writeStoredOpen(false);
|
||||
};
|
||||
const openLegend = () => {
|
||||
setOpen(true);
|
||||
writeStoredOpen(true);
|
||||
};
|
||||
|
||||
if (!open) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openLegend}
|
||||
aria-label="Show legend"
|
||||
title="Show legend"
|
||||
className={`fixed bottom-6 ${leftClass} z-30 flex items-center gap-1.5 rounded-full bg-surface-sunken/95 border border-line/50 px-3 py-1.5 text-[11px] font-semibold text-ink-mid uppercase tracking-wider shadow-xl shadow-black/30 backdrop-blur-sm hover:text-ink hover:border-line focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface transition-[left,colors] duration-200`}
|
||||
>
|
||||
<span aria-hidden="true" className="text-[10px]">ⓘ</span>
|
||||
Legend
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`fixed bottom-6 ${leftClass} z-30 bg-surface-sunken/95 border border-line/50 rounded-xl px-4 py-3 shadow-xl shadow-black/30 backdrop-blur-sm max-w-[280px] transition-[left] duration-200`}>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="text-[11px] font-semibold text-ink-mid uppercase tracking-wider">Legend</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeLegend}
|
||||
aria-label="Hide legend"
|
||||
title="Hide legend"
|
||||
// 24×24 touch target (was ~10×16, well under WCAG 2.5.5 min).
|
||||
// Negative margin keeps the visual position the same as before
|
||||
// — only the hit area + focus ring are larger.
|
||||
className="-mt-1.5 -mr-1.5 w-6 h-6 inline-flex items-center justify-center rounded text-[14px] leading-none text-ink-soft hover:text-ink hover:bg-surface-card/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="fixed bottom-6 left-4 z-30 bg-zinc-900/95 border border-zinc-700/50 rounded-xl px-4 py-3 shadow-xl shadow-black/30 backdrop-blur-sm max-w-[280px]">
|
||||
<div className="text-[11px] font-semibold text-zinc-400 uppercase tracking-wider mb-2">Legend</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="mb-2">
|
||||
<div className="text-[11px] text-ink-soft font-medium mb-1">Status</div>
|
||||
<div className="text-[11px] text-zinc-500 font-medium mb-1">Status</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{LEGEND_STATUSES.map((s) => (
|
||||
<StatusItem key={s} color={STATUS_CONFIG[s].dot} label={STATUS_CONFIG[s].label} />
|
||||
@@ -115,22 +21,22 @@ export function Legend() {
|
||||
|
||||
{/* Tiers */}
|
||||
<div className="mb-2">
|
||||
<div className="text-[11px] text-ink-soft font-medium mb-1">Tier</div>
|
||||
<div className="text-[11px] text-zinc-500 font-medium mb-1">Tier</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{LEGEND_TIERS.map(({ tier, label }) => (
|
||||
<TierItem key={tier} tier={tier} label={label} color={TIER_CONFIG[tier].border} />
|
||||
))}
|
||||
<TierItem tier={1} label="Sandboxed" color="text-sky-300 bg-sky-950/40 border-sky-700/30" />
|
||||
<TierItem tier={2} label="Standard" color="text-violet-300 bg-violet-950/40 border-violet-700/30" />
|
||||
<TierItem tier={3} label="Full Access" color="text-amber-300 bg-amber-950/40 border-amber-700/30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Communication */}
|
||||
<div>
|
||||
<div className="text-[11px] text-ink-soft font-medium mb-1">Communication</div>
|
||||
<div className="text-[11px] text-zinc-500 font-medium mb-1">Communication</div>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<CommItem icon="↗" color="text-cyan-400" label="A2A Out" />
|
||||
<CommItem icon="↙" color="text-accent" label="A2A In" />
|
||||
<CommItem icon="◆" color="text-warm" label="Task" />
|
||||
<CommItem icon="!" color="text-bad" label="Error" />
|
||||
<CommItem icon="↙" color="text-blue-400" label="A2A In" />
|
||||
<CommItem icon="◆" color="text-amber-400" label="Task" />
|
||||
<CommItem icon="!" color="text-red-400" label="Error" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,7 +47,7 @@ function StatusItem({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${color}`} />
|
||||
<span className="text-[11px] text-ink-mid">{label}</span>
|
||||
<span className="text-[11px] text-zinc-400">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -150,7 +56,7 @@ function TierItem({ tier, label, color }: { tier: number; label: string; color:
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`text-[11px] font-mono px-1 py-0.5 rounded border ${color}`}>T{tier}</span>
|
||||
<span className="text-[11px] text-ink-mid">{label}</span>
|
||||
<span className="text-[11px] text-zinc-400">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -159,7 +65,7 @@ function CommItem({ icon, color, label }: { icon: string; color: string; label:
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`text-[11px] ${color}`}>{icon}</span>
|
||||
<span className="text-[11px] text-ink-mid">{label}</span>
|
||||
<span className="text-[11px] text-zinc-400">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,81 +1,29 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* MemoryInspectorPanel — Memory v2 redesign.
|
||||
*
|
||||
* Reads the canvas Memory tab from the v2 plugin via the
|
||||
* workspace-server proxy at /v2/{namespaces,memories}, replacing the
|
||||
* v1 LOCAL/TEAM/GLOBAL trio that mapped to the deprecated
|
||||
* shared_context model.
|
||||
*
|
||||
* Surface differences from v1:
|
||||
* - Namespace dropdown driven by GET /v2/namespaces (workspace /
|
||||
* team / org / custom — labels rendered server-side).
|
||||
* - Per-row badges for kind (fact|summary|checkpoint), source
|
||||
* (agent|runtime|user), pin (📌), TTL countdown, and propagation
|
||||
* source-workspace if the memory came from a peer.
|
||||
* - No Edit affordance — v2's plugin contract has no PATCH; the
|
||||
* model is forget + recommit. Delete (Forget) stays.
|
||||
*
|
||||
* Shipping note: when the plugin isn't wired (MEMORY_PLUGIN_URL
|
||||
* unset), every endpoint returns 503 with a clear hint. The panel
|
||||
* surfaces that as a banner so operators know to set the env var,
|
||||
* rather than rendering a perpetual empty state that looks like
|
||||
* "no memories yet".
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type NamespaceKind = 'workspace' | 'team' | 'org' | 'custom';
|
||||
|
||||
export interface NamespaceView {
|
||||
name: string;
|
||||
kind: NamespaceKind;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface NamespacesResponse {
|
||||
readable: NamespaceView[];
|
||||
writable: NamespaceView[];
|
||||
}
|
||||
|
||||
export type MemoryKind = 'fact' | 'summary' | 'checkpoint';
|
||||
export type MemorySource = 'agent' | 'runtime' | 'user';
|
||||
|
||||
export interface MemoryV2 {
|
||||
/** Memory entry returned by GET /workspaces/:id/memories */
|
||||
export interface MemoryEntry {
|
||||
id: string;
|
||||
namespace: string;
|
||||
workspace_id: string;
|
||||
content: string;
|
||||
kind: MemoryKind;
|
||||
source: MemorySource;
|
||||
pin: boolean;
|
||||
expires_at?: string | null;
|
||||
scope: "LOCAL" | "TEAM" | "GLOBAL";
|
||||
namespace: string;
|
||||
created_at: string;
|
||||
/** 0..1 plugin similarity score; only present when ?q= is set. */
|
||||
score?: number | null;
|
||||
// Note: an earlier iteration of this type carried a `source_workspace_id`
|
||||
// field rendered as a "from peer" badge. The propagation contract that
|
||||
// would have populated it ("Reserved for future cross-namespace
|
||||
// propagation semantics" in memory-plugin-v1.yaml) is unimplemented —
|
||||
// nothing in the codebase writes that key. Removed in self-review.
|
||||
// Re-add when propagation gains a concrete shape.
|
||||
/**
|
||||
* Semantic similarity score (0–1). Only present when the API is queried
|
||||
* with ?q=<query> and the pgvector backend has been deployed.
|
||||
* Absent on plain list fetches — renders gracefully without a badge.
|
||||
*/
|
||||
similarity_score?: number;
|
||||
}
|
||||
|
||||
interface MemoriesResponse {
|
||||
memories: MemoryV2[];
|
||||
}
|
||||
|
||||
// MemoryEntry kept as a back-compat type alias so any other component
|
||||
// still importing it doesn't break the build. New consumers should
|
||||
// prefer MemoryV2 — the v1 shape (LOCAL/TEAM/GLOBAL scope) is gone.
|
||||
//
|
||||
// `unknown` is used over `any` so TS still flags accidental field
|
||||
// access on the legacy shape.
|
||||
export type MemoryEntry = MemoryV2;
|
||||
type Scope = "LOCAL" | "TEAM" | "GLOBAL";
|
||||
const SCOPES: Scope[] = ["LOCAL", "TEAM", "GLOBAL"];
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
@@ -83,26 +31,11 @@ interface Props {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function sanitizeId(id: string): string {
|
||||
return id.replace(/[^a-zA-Z0-9]/g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a memory-plugin-503 error from the api wrapper's stringified
|
||||
* Error message. Matches on the literal env-var name rather than the
|
||||
* status code, because the api shim renders status codes inside a
|
||||
* larger formatted message and a future status-code reformat would
|
||||
* silently break the detection.
|
||||
*
|
||||
* The substring `MEMORY_PLUGIN_URL` is hard-coded in the handler at
|
||||
* `workspace-server/internal/handlers/memories_v2.go:available()`,
|
||||
* so this is a pinned cross-layer contract — drift is caught by both
|
||||
* the Go test (TestMemoriesV2_PluginUnwired_All503) and the canvas
|
||||
* test (TestMemoryInspectorPanel — plugin unavailable).
|
||||
* Sanitise a memory id for use in an HTML id attribute.
|
||||
*/
|
||||
export function isPluginUnavailableError(err: unknown): boolean {
|
||||
const msg = err instanceof Error ? err.message : '';
|
||||
return msg.includes('MEMORY_PLUGIN_URL');
|
||||
function sanitizeId(id: string): string {
|
||||
return id.replace(/[^a-zA-Z0-9]/g, "-");
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
@@ -113,24 +46,6 @@ function formatRelativeTime(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a TTL countdown like "12h", "3d", or "expired" (when the
|
||||
* stored expires_at is in the past). Non-fatal if expires_at is null
|
||||
* or invalid — falls through to empty string so the badge doesn't
|
||||
* render.
|
||||
*/
|
||||
export function formatTTL(expiresAt: string | null | undefined): string {
|
||||
if (!expiresAt) return '';
|
||||
const ts = new Date(expiresAt).getTime();
|
||||
if (Number.isNaN(ts)) return '';
|
||||
const diff = ts - Date.now();
|
||||
if (diff <= 0) return 'expired';
|
||||
if (diff < 60_000) return `${Math.floor(diff / 1000)}s`;
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h`;
|
||||
return `${Math.floor(diff / 86_400_000)}d`;
|
||||
}
|
||||
|
||||
// ── Skeleton rows ──────────────────────────────────────────────────────────────
|
||||
|
||||
function MemorySkeletonRows() {
|
||||
@@ -139,13 +54,13 @@ function MemorySkeletonRows() {
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-lg border border-line/60 bg-surface-sunken/50 px-3 py-3 animate-pulse"
|
||||
className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 px-3 py-3 animate-pulse"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 rounded bg-surface-card/50 flex-1" />
|
||||
<div className="h-2 rounded bg-surface-card/50 w-8" />
|
||||
<div className="h-2 rounded bg-surface-card/50 w-6" />
|
||||
<div className="h-2 rounded bg-surface-card/50 w-10" />
|
||||
<div className="h-2 rounded bg-zinc-700/50 flex-1" />
|
||||
<div className="h-2 rounded bg-zinc-700/50 w-8" />
|
||||
<div className="h-2 rounded bg-zinc-700/50 w-6" />
|
||||
<div className="h-2 rounded bg-zinc-700/50 w-10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -155,92 +70,56 @@ function MemorySkeletonRows() {
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const ALL_NAMESPACES = '__all__';
|
||||
|
||||
export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
const [namespaces, setNamespaces] = useState<NamespacesResponse | null>(null);
|
||||
const [activeNamespace, setActiveNamespace] = useState<string>(ALL_NAMESPACES);
|
||||
const [entries, setEntries] = useState<MemoryV2[]>([]);
|
||||
const [activeScope, setActiveScope] = useState<Scope>("LOCAL");
|
||||
const [activeNamespace, setActiveNamespace] = useState("");
|
||||
const [entries, setEntries] = useState<MemoryEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Plugin-disabled banner (503 from server). Stored separately so we
|
||||
// can keep showing the namespace dropdown empty rather than
|
||||
// hiding the whole panel.
|
||||
const [pluginUnavailable, setPluginUnavailable] = useState(false);
|
||||
|
||||
// Search state (debounced)
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
// ── Search state (debounced) ────────────────────────────────────────────────
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedQuery(searchQuery.trim()), 300);
|
||||
const timer = setTimeout(
|
||||
() => setDebouncedQuery(searchQuery.trim()),
|
||||
300
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery]);
|
||||
|
||||
// Delete state
|
||||
// ── Delete state ─────────────────────────────────────────────────────────────
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
|
||||
// ── Namespace loading ──────────────────────────────────────────────────────
|
||||
|
||||
const loadNamespaces = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<NamespacesResponse>(
|
||||
`/workspaces/${workspaceId}/v2/namespaces`,
|
||||
);
|
||||
setNamespaces(data);
|
||||
setPluginUnavailable(false);
|
||||
} catch (e) {
|
||||
// Plugin-unavailable (503) indicates MEMORY_PLUGIN_URL isn't set.
|
||||
// Anything else stays as a generic load failure that the
|
||||
// entries-load path will also flag.
|
||||
if (isPluginUnavailableError(e)) {
|
||||
setPluginUnavailable(true);
|
||||
}
|
||||
setNamespaces({ readable: [], writable: [] });
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
// ── Entries loading ────────────────────────────────────────────────────────
|
||||
// ── Data loading ────────────────────────────────────────────────────────────
|
||||
|
||||
const loadEntries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (activeNamespace !== ALL_NAMESPACES) {
|
||||
params.set('namespace', activeNamespace);
|
||||
}
|
||||
if (debouncedQuery) params.set('q', debouncedQuery);
|
||||
params.set("scope", activeScope);
|
||||
if (debouncedQuery) params.set("q", debouncedQuery);
|
||||
if (activeNamespace) params.set("namespace", activeNamespace);
|
||||
|
||||
const url = `/workspaces/${workspaceId}/v2/memories?${params.toString()}`;
|
||||
const data = await api.get<MemoriesResponse>(url);
|
||||
const url = `/workspaces/${workspaceId}/memories?${params.toString()}`;
|
||||
const data = await api.get<MemoryEntry[]>(url);
|
||||
|
||||
// When a semantic query is active and the plugin returns
|
||||
// scores, sort by score descending so the most-relevant hit
|
||||
// sits at the top. Empty score → push to bottom.
|
||||
// When a semantic query is active, sort by similarity_score descending.
|
||||
const sorted = debouncedQuery
|
||||
? [...data.memories].sort(
|
||||
(a, b) => (b.score ?? 0) - (a.score ?? 0),
|
||||
? [...data].sort(
|
||||
(a, b) => (b.similarity_score ?? 0) - (a.similarity_score ?? 0)
|
||||
)
|
||||
: data.memories;
|
||||
: data;
|
||||
setEntries(sorted);
|
||||
} catch (e) {
|
||||
if (isPluginUnavailableError(e)) {
|
||||
setPluginUnavailable(true);
|
||||
setError(null); // surfaced via banner, not row error
|
||||
} else {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load memories');
|
||||
}
|
||||
setError(e instanceof Error ? e.message : "Failed to load memories");
|
||||
setEntries([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId, activeNamespace, debouncedQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
loadNamespaces();
|
||||
}, [loadNamespaces]);
|
||||
}, [workspaceId, activeScope, debouncedQuery, activeNamespace]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEntries();
|
||||
@@ -257,87 +136,56 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
setEntries((prev) => prev.filter((e) => e.id !== id));
|
||||
|
||||
try {
|
||||
await api.del(`/workspaces/${workspaceId}/v2/memories/${encodeURIComponent(id)}`);
|
||||
await api.del(`/workspaces/${workspaceId}/memories/${encodeURIComponent(id)}`);
|
||||
} catch (e) {
|
||||
// Reload first (which clears any stale error), THEN set the
|
||||
// delete-failure message — otherwise loadEntries' own
|
||||
// `setError(null)` wipes our error before the user sees it.
|
||||
// Caught by the rollback test in MemoryInspectorPanel.test.tsx.
|
||||
const msg = e instanceof Error ? e.message : 'Delete failed — reloading…';
|
||||
setError(e instanceof Error ? e.message : "Delete failed — reloading...");
|
||||
await loadEntries();
|
||||
setError(msg);
|
||||
}
|
||||
}, [pendingDeleteId, workspaceId, loadEntries]);
|
||||
|
||||
// ── Namespace dropdown options ─────────────────────────────────────────────
|
||||
|
||||
const dropdownOptions = useMemo(() => {
|
||||
const opts: Array<{ value: string; label: string; kind?: NamespaceKind }> = [
|
||||
{ value: ALL_NAMESPACES, label: 'All namespaces' },
|
||||
];
|
||||
if (namespaces) {
|
||||
for (const ns of namespaces.readable) {
|
||||
opts.push({ value: ns.name, label: ns.label, kind: ns.kind });
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}, [namespaces]);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
if (loading && entries.length === 0 && !error && !pluginUnavailable) {
|
||||
if (loading && entries.length === 0 && !error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<span className="text-xs text-ink-soft">Loading memories…</span>
|
||||
<span className="text-xs text-zinc-500">Loading memories…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Plugin-unavailable banner */}
|
||||
{pluginUnavailable && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="mx-4 mt-3 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded text-xs text-amber-300 shrink-0"
|
||||
data-testid="plugin-unavailable-banner"
|
||||
>
|
||||
Memory plugin not configured. Set <code>MEMORY_PLUGIN_URL</code> on the
|
||||
workspace-server to enable v2 memory.
|
||||
{/* Scope tabs */}
|
||||
<div className="px-4 pt-3 pb-2 border-b border-zinc-800/40 shrink-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{SCOPES.map((scope) => (
|
||||
<button
|
||||
key={scope}
|
||||
onClick={() => setActiveScope(scope)}
|
||||
aria-pressed={activeScope === scope}
|
||||
className={[
|
||||
"px-3 py-1 text-[11px] rounded transition-colors",
|
||||
activeScope === scope
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-200",
|
||||
].join(" ")}
|
||||
>
|
||||
{scope}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Namespace dropdown */}
|
||||
<div className="px-4 pt-3 pb-2 border-b border-line/40 shrink-0 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="namespace-dropdown" className="text-[10px] text-ink-soft shrink-0">
|
||||
Namespace:
|
||||
</label>
|
||||
<select
|
||||
id="namespace-dropdown"
|
||||
value={activeNamespace}
|
||||
onChange={(e) => setActiveNamespace(e.target.value)}
|
||||
aria-label="Filter by namespace"
|
||||
disabled={pluginUnavailable}
|
||||
className="flex-1 bg-surface-sunken border border-line/60 focus:border-accent/60 rounded px-2 py-1 text-[11px] text-ink focus:outline-none transition-colors min-w-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{dropdownOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
{/* Search bar + namespace filter */}
|
||||
<div className="px-4 pt-3 pb-2 border-b border-zinc-800/40 shrink-0 space-y-2">
|
||||
<div className="relative flex items-center">
|
||||
{/* Magnifying glass icon */}
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
className="absolute left-2.5 text-ink-soft pointer-events-none shrink-0"
|
||||
className="absolute left-2.5 text-zinc-500 pointer-events-none shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
@@ -349,39 +197,51 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Semantic search…"
|
||||
aria-label="Search memories"
|
||||
disabled={pluginUnavailable}
|
||||
className="w-full bg-surface-sunken border border-line/60 focus:border-accent/60 rounded-lg pl-8 pr-7 py-1.5 text-[11px] text-ink placeholder-zinc-600 focus:outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full bg-zinc-900 border border-zinc-700/60 focus:border-blue-500/60 rounded-lg pl-8 pr-7 py-1.5 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none transition-colors"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setDebouncedQuery('');
|
||||
setSearchQuery("");
|
||||
setDebouncedQuery("");
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
className="absolute right-2 text-ink-soft hover:text-ink transition-colors text-sm leading-none"
|
||||
className="absolute right-2 text-zinc-500 hover:text-zinc-200 transition-colors text-sm leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Namespace filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="namespace-filter" className="text-[10px] text-zinc-500 shrink-0">
|
||||
Namespace:
|
||||
</label>
|
||||
<input
|
||||
id="namespace-filter"
|
||||
type="text"
|
||||
value={activeNamespace}
|
||||
onChange={(e) => setActiveNamespace(e.target.value)}
|
||||
placeholder="all namespaces"
|
||||
aria-label="Filter by namespace"
|
||||
className="flex-1 bg-zinc-900 border border-zinc-700/60 focus:border-blue-500/60 rounded px-2 py-1 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none transition-colors min-w-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="px-4 py-2.5 border-b border-line/40 flex items-center justify-between shrink-0">
|
||||
<span className="text-[11px] text-ink-soft">
|
||||
<div className="px-4 py-2.5 border-b border-zinc-800/40 flex items-center justify-between shrink-0">
|
||||
<span className="text-[11px] text-zinc-500">
|
||||
{debouncedQuery
|
||||
? `${entries.length} result${entries.length !== 1 ? 's' : ''}`
|
||||
? `${entries.length} result${entries.length !== 1 ? "s" : ""}`
|
||||
: entries.length === 1
|
||||
? '1 memory'
|
||||
: `${entries.length} memories`}
|
||||
? "1 memory"
|
||||
: `${entries.length} memories`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadEntries}
|
||||
disabled={pluginUnavailable}
|
||||
className="px-2 py-1 text-[11px] bg-surface-card hover:bg-surface-card text-ink-mid rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="px-2 py-1 text-[11px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors"
|
||||
aria-label="Refresh memories"
|
||||
>
|
||||
↻ Refresh
|
||||
@@ -393,7 +253,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-bad shrink-0"
|
||||
className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-red-400 shrink-0"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
@@ -404,7 +264,39 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
{loading ? (
|
||||
<MemorySkeletonRows />
|
||||
) : entries.length === 0 ? (
|
||||
<EmptyState query={debouncedQuery} pluginUnavailable={pluginUnavailable} />
|
||||
debouncedQuery ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<span className="text-4xl text-zinc-700" aria-hidden="true">◇</span>
|
||||
<p className="text-sm font-medium text-zinc-400">
|
||||
No memories match your search
|
||||
</p>
|
||||
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
|
||||
Try a different query or{" "}
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setDebouncedQuery("");
|
||||
}}
|
||||
className="text-blue-500 hover:text-blue-400 underline transition-colors"
|
||||
>
|
||||
clear the search
|
||||
</button>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<span className="text-4xl text-zinc-700" aria-hidden="true">◇</span>
|
||||
<p className="text-sm font-medium text-zinc-400">No {activeScope} memories</p>
|
||||
<p className="text-[11px] text-zinc-600 max-w-[200px] leading-relaxed">
|
||||
{activeScope === "LOCAL"
|
||||
? "This workspace has not written any local memories yet."
|
||||
: activeScope === "TEAM"
|
||||
? "No team memories shared with this workspace yet."
|
||||
: "No global memories exist yet."}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{entries.map((entry) => (
|
||||
@@ -421,9 +313,9 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
{/* Delete confirmation dialog */}
|
||||
<ConfirmDialog
|
||||
open={pendingDeleteId !== null}
|
||||
title="Forget memory"
|
||||
message="Forget this memory? This cannot be undone."
|
||||
confirmLabel="Forget"
|
||||
title="Delete memory"
|
||||
message={`Delete this ${activeScope} memory? This cannot be undone.`}
|
||||
confirmLabel="Delete"
|
||||
confirmVariant="danger"
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDeleteId(null)}
|
||||
@@ -432,177 +324,72 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Empty state ─────────────────────────────────────────────────────────────
|
||||
|
||||
function EmptyState({
|
||||
query,
|
||||
pluginUnavailable,
|
||||
}: {
|
||||
query: string;
|
||||
pluginUnavailable: boolean;
|
||||
}) {
|
||||
if (pluginUnavailable) {
|
||||
// The banner already explains the problem; the empty rows just
|
||||
// mirror it so the operator sees both signals.
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<span className="text-4xl text-ink-soft" aria-hidden="true">
|
||||
◇
|
||||
</span>
|
||||
<p className="text-sm font-medium text-ink-mid">Memory plugin disabled</p>
|
||||
<p className="text-[11px] text-ink-soft max-w-[220px] leading-relaxed">
|
||||
See banner above for the operator-side fix.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (query) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<span className="text-4xl text-ink-soft" aria-hidden="true">
|
||||
◇
|
||||
</span>
|
||||
<p className="text-sm font-medium text-ink-mid">No memories match your search</p>
|
||||
<p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
|
||||
Try a different query or clear the search.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
|
||||
<span className="text-4xl text-ink-soft" aria-hidden="true">
|
||||
◇
|
||||
</span>
|
||||
<p className="text-sm font-medium text-ink-mid">No memories yet</p>
|
||||
<p className="text-[11px] text-ink-soft max-w-[220px] leading-relaxed">
|
||||
Agents commit memories via MCP tools (commit_memory, commit_summary). They
|
||||
appear here once written.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── MemoryEntryRow sub-component ──────────────────────────────────────────────
|
||||
|
||||
interface MemoryEntryRowProps {
|
||||
entry: MemoryV2;
|
||||
entry: MemoryEntry;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const KIND_BADGE_CLASS: Record<MemoryKind, string> = {
|
||||
fact: 'bg-surface-card text-ink-mid',
|
||||
summary: 'bg-blue-950 text-accent',
|
||||
checkpoint: 'bg-violet-950 text-violet-400',
|
||||
};
|
||||
|
||||
const SOURCE_BADGE_CLASS: Record<MemorySource, string> = {
|
||||
agent: 'bg-surface-card text-ink-mid',
|
||||
runtime: 'bg-amber-950 text-amber-300',
|
||||
user: 'bg-emerald-950 text-emerald-400',
|
||||
};
|
||||
|
||||
function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const bodyId = `mem-body-${sanitizeId(entry.id)}`;
|
||||
const ttl = formatTTL(entry.expires_at);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border border-line/60 bg-surface-sunken/50 overflow-hidden"
|
||||
data-testid={`memory-row-${entry.id}`}
|
||||
>
|
||||
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/50 overflow-hidden">
|
||||
{/* Header row */}
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-surface-card/30 transition-colors"
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-left hover:bg-zinc-800/30 transition-colors"
|
||||
onClick={() => setExpanded((prev) => !prev)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={bodyId}
|
||||
>
|
||||
{/* Kind badge */}
|
||||
{/* Scope badge */}
|
||||
<span
|
||||
className={[
|
||||
'text-[9px] shrink-0 font-mono px-1 py-0.5 rounded',
|
||||
KIND_BADGE_CLASS[entry.kind] ?? 'bg-surface-card text-ink-mid',
|
||||
].join(' ')}
|
||||
title={`Kind: ${entry.kind}`}
|
||||
data-testid="kind-badge"
|
||||
"text-[9px] shrink-0 font-mono px-1 py-0.5 rounded",
|
||||
entry.scope === "LOCAL"
|
||||
? "bg-zinc-700 text-zinc-400"
|
||||
: entry.scope === "TEAM"
|
||||
? "bg-blue-950 text-blue-400"
|
||||
: "bg-violet-950 text-violet-400",
|
||||
].join(" ")}
|
||||
title={`Scope: ${entry.scope}`}
|
||||
>
|
||||
{entry.kind[0].toUpperCase()}
|
||||
{entry.scope[0]}
|
||||
</span>
|
||||
|
||||
{/* Source badge */}
|
||||
<span
|
||||
className={[
|
||||
'text-[9px] shrink-0 font-mono px-1 py-0.5 rounded',
|
||||
SOURCE_BADGE_CLASS[entry.source] ?? 'bg-surface-card text-ink-mid',
|
||||
].join(' ')}
|
||||
title={`Source: ${entry.source}`}
|
||||
data-testid="source-badge"
|
||||
>
|
||||
{entry.source}
|
||||
</span>
|
||||
|
||||
{/* Pin indicator */}
|
||||
{entry.pin && (
|
||||
<span
|
||||
className="text-[9px] shrink-0"
|
||||
title="Pinned"
|
||||
data-testid="pin-badge"
|
||||
aria-label="Pinned"
|
||||
>
|
||||
📌
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Namespace tag */}
|
||||
<span
|
||||
className="text-[9px] shrink-0 font-mono text-ink-soft truncate max-w-[100px]"
|
||||
title={entry.namespace}
|
||||
>
|
||||
<span className="text-[9px] shrink-0 font-mono text-zinc-500 truncate max-w-[80px]" title={entry.namespace}>
|
||||
{entry.namespace}
|
||||
</span>
|
||||
|
||||
{/* Content preview */}
|
||||
<span className="flex-1 min-w-0 text-[10px] font-mono text-ink-mid truncate text-left">
|
||||
{entry.content.length > 60 ? entry.content.slice(0, 60) + '…' : entry.content}
|
||||
<span className="flex-1 min-w-0 text-[10px] font-mono text-zinc-300 truncate text-left">
|
||||
{entry.content.length > 60 ? entry.content.slice(0, 60) + "…" : entry.content}
|
||||
</span>
|
||||
|
||||
{/* Score badge (semantic search only) */}
|
||||
{entry.score != null && (
|
||||
{/* Similarity badge */}
|
||||
{entry.similarity_score != null && (
|
||||
<span
|
||||
className={[
|
||||
'text-[9px] shrink-0 font-mono tabular-nums',
|
||||
entry.score >= 0.8 ? 'text-accent' : 'text-ink-mid',
|
||||
].join(' ')}
|
||||
title={`Similarity: ${(entry.score * 100).toFixed(1)}%`}
|
||||
data-testid="score-badge"
|
||||
"text-[9px] shrink-0 font-mono tabular-nums",
|
||||
entry.similarity_score >= 0.8
|
||||
? "text-blue-500"
|
||||
: "text-zinc-400",
|
||||
].join(" ")}
|
||||
title={`Similarity: ${(entry.similarity_score * 100).toFixed(1)}%`}
|
||||
data-testid="similarity-badge"
|
||||
>
|
||||
{Math.round(entry.score * 100)}%
|
||||
{Math.round(entry.similarity_score * 100)}%
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* TTL countdown */}
|
||||
{ttl && (
|
||||
<span
|
||||
className={[
|
||||
'text-[9px] shrink-0 font-mono',
|
||||
ttl === 'expired' ? 'text-bad' : 'text-amber-400',
|
||||
].join(' ')}
|
||||
title={`Expires: ${entry.expires_at}`}
|
||||
data-testid="ttl-badge"
|
||||
>
|
||||
⌛{ttl}
|
||||
</span>
|
||||
)}
|
||||
|
||||
|
||||
<span className="text-[9px] text-ink-soft shrink-0">
|
||||
<span className="text-[9px] text-zinc-600 shrink-0">
|
||||
{formatRelativeTime(entry.created_at)}
|
||||
</span>
|
||||
<span className="text-[9px] text-ink-soft shrink-0" aria-hidden="true">
|
||||
{expanded ? '▼' : '▶'}
|
||||
<span className="text-[9px] text-zinc-500 shrink-0" aria-hidden="true">
|
||||
{expanded ? "▼" : "▶"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -612,26 +399,24 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
|
||||
id={bodyId}
|
||||
role="region"
|
||||
aria-label="Memory details"
|
||||
className="border-t border-line/50 px-3 pb-3 pt-2 space-y-2"
|
||||
className="border-t border-zinc-800/50 px-3 pb-3 pt-2 space-y-2"
|
||||
>
|
||||
<pre className="text-[10px] font-mono text-ink-mid bg-surface rounded p-2 overflow-x-auto max-h-48 whitespace-pre-wrap break-all">
|
||||
<pre className="text-[10px] font-mono text-zinc-300 bg-zinc-950 rounded p-2 overflow-x-auto max-h-48 whitespace-pre-wrap break-all">
|
||||
{entry.content}
|
||||
</pre>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[9px] text-ink-soft">
|
||||
<span className="text-[9px] text-zinc-600">
|
||||
Created: {new Date(entry.created_at).toLocaleString()}
|
||||
{entry.expires_at && ` · Expires: ${new Date(entry.expires_at).toLocaleString()}`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
aria-label="Forget memory"
|
||||
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-bad transition-colors shrink-0"
|
||||
aria-label="Delete memory"
|
||||
className="text-[10px] px-2 py-0.5 bg-red-950/40 hover:bg-red-900/50 border border-red-900/30 rounded text-red-400 transition-colors shrink-0"
|
||||
>
|
||||
Forget
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,538 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import {
|
||||
getKeyLabel,
|
||||
type ModelSpec,
|
||||
type ProviderChoice,
|
||||
} from "@/lib/deploy-preflight";
|
||||
import {
|
||||
ProviderModelSelector,
|
||||
buildProviderCatalog,
|
||||
findProviderForModel,
|
||||
type SelectorValue,
|
||||
} from "./ProviderModelSelector";
|
||||
import { getKeyLabel } from "@/lib/deploy-preflight";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
/** Flat list of every candidate env var. Used as the fallback input
|
||||
* set when `providers` is empty (or length 1). */
|
||||
missingKeys: string[];
|
||||
/** Grouped provider options derived from the template's models[] /
|
||||
* required_env. When length ≥ 2 the modal shows a radio picker. */
|
||||
providers?: ProviderChoice[];
|
||||
/** Runtime slug — used only for the "The <runtime> runtime …"
|
||||
* headline; behavior is driven by providers/missingKeys. */
|
||||
runtime: string;
|
||||
/** Called when all required keys for the chosen provider are saved.
|
||||
* Receives the model slug if the modal collected one (template-deploy
|
||||
* flow); legacy callers ignore it. */
|
||||
onKeysAdded: (model?: string) => void;
|
||||
/** Called when the user cancels the deploy. */
|
||||
/** Called when user adds all keys and wants to proceed with deploy. */
|
||||
onKeysAdded: () => void;
|
||||
/** Called when user cancels the deploy. */
|
||||
onCancel: () => void;
|
||||
/** Optional — open the Settings Panel (Config tab → Secrets). */
|
||||
/** Called when user wants to open the Settings Panel (Config tab → Secrets). */
|
||||
onOpenSettings?: () => void;
|
||||
/** If provided, secrets save at workspace scope instead of global. */
|
||||
/** Optional workspace ID — if provided, secrets are saved at workspace scope. */
|
||||
workspaceId?: string;
|
||||
/** Set of env var names already configured in the relevant scope
|
||||
* (global or workspace). When provided, entries whose key is already
|
||||
* in this set start as `saved: true` so the user can confirm without
|
||||
* re-entering. Used by the template-deploy "always ask" flow so a
|
||||
* user can pick a different provider even when global env covers
|
||||
* the default one. */
|
||||
configuredKeys?: Set<string>;
|
||||
/** Model slug suggestions (datalist) — populated from the template's
|
||||
* models[]. When non-empty the picker renders a model input above
|
||||
* the API-key fields. The picker passes the entered slug back via
|
||||
* onKeysAdded. */
|
||||
modelSuggestions?: string[];
|
||||
/** Full model specs from the template (with required_env per model).
|
||||
* When provided, the picker auto-snaps the provider radio to the
|
||||
* matching provider as the user changes the model — fixes the
|
||||
* "type MiniMax model, see ANTHROPIC_API_KEY field" cascade bug
|
||||
* (sibling of the ConfigTab cascade fix in #2516). Optional so
|
||||
* callers without model→provider mapping data can still use the
|
||||
* picker as-is. */
|
||||
models?: ModelSpec[];
|
||||
/** Pre-fill the model input. */
|
||||
initialModel?: string;
|
||||
/** Override the modal's title + description copy. The default
|
||||
* "Missing API Keys" title misreads when the modal is opened to
|
||||
* pick provider/model with keys already configured. */
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface KeyEntry {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
saved: boolean;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* MissingKeysModal
|
||||
* ----------------
|
||||
* Dispatches between two modes based on what the template declares:
|
||||
*
|
||||
* 1. PROVIDER PICKER — when the preflight returned ≥2 `providers` (e.g.
|
||||
* a Hermes template whose models[].required_env enumerate OpenRouter,
|
||||
* Anthropic, Nous-native, etc.). Radio list of options, saving the
|
||||
* chosen option's env vars satisfies the deploy.
|
||||
*
|
||||
* 2. ALL-KEYS — every entry in `missingKeys` rendered as its own input,
|
||||
* all must save before Deploy. Used when the template has a single
|
||||
* provider option or no declared alternatives.
|
||||
*
|
||||
* The modal never hardcodes per-runtime provider lists; the upstream
|
||||
* preflight derives that from the template config.yaml.
|
||||
*/
|
||||
export function MissingKeysModal({
|
||||
open,
|
||||
missingKeys,
|
||||
providers,
|
||||
runtime,
|
||||
onKeysAdded,
|
||||
onCancel,
|
||||
onOpenSettings,
|
||||
workspaceId,
|
||||
configuredKeys,
|
||||
modelSuggestions,
|
||||
models,
|
||||
initialModel,
|
||||
title,
|
||||
description,
|
||||
}: Props) {
|
||||
const pickerProviders = providers ?? [];
|
||||
const pickerMode = pickerProviders.length > 1;
|
||||
|
||||
if (pickerMode) {
|
||||
return (
|
||||
<ProviderPickerModal
|
||||
open={open}
|
||||
providers={pickerProviders}
|
||||
runtime={runtime}
|
||||
onKeysAdded={onKeysAdded}
|
||||
onCancel={onCancel}
|
||||
onOpenSettings={onOpenSettings}
|
||||
workspaceId={workspaceId}
|
||||
configuredKeys={configuredKeys}
|
||||
modelSuggestions={modelSuggestions}
|
||||
models={models}
|
||||
initialModel={initialModel}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Prefer the (single) provider's envVars over the raw missingKeys when
|
||||
// we have one — the provider list is already de-duped and ordered.
|
||||
const keys =
|
||||
pickerProviders.length === 1 ? pickerProviders[0].envVars : missingKeys;
|
||||
|
||||
return (
|
||||
<AllKeysModal
|
||||
open={open}
|
||||
missingKeys={keys}
|
||||
runtime={runtime}
|
||||
onKeysAdded={onKeysAdded}
|
||||
onCancel={onCancel}
|
||||
onOpenSettings={onOpenSettings}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Provider-picker mode — choose one option, save its env var(s), deploy.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Provider id derived from a model spec — sorted+joined required_env,
|
||||
* matching the formula in providersFromTemplate(). When the model has
|
||||
* no required_env (local/self-hosted endpoints) returns null, since
|
||||
* there's no provider option the radio could snap to. Exported for
|
||||
* the cascade-snap test. */
|
||||
export function providerIdForModel(
|
||||
modelId: string,
|
||||
models: ModelSpec[] | undefined,
|
||||
): string | null {
|
||||
const trimmed = modelId.trim();
|
||||
if (!trimmed || !models) return null;
|
||||
const m = models.find((x) => x.id === trimmed);
|
||||
if (!m?.required_env || m.required_env.length === 0) return null;
|
||||
return [...m.required_env].sort().join("|");
|
||||
}
|
||||
|
||||
function ProviderPickerModal({
|
||||
open,
|
||||
providers,
|
||||
runtime,
|
||||
onKeysAdded,
|
||||
onCancel,
|
||||
onOpenSettings,
|
||||
workspaceId,
|
||||
configuredKeys,
|
||||
modelSuggestions,
|
||||
models,
|
||||
initialModel,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
open: boolean;
|
||||
providers: ProviderChoice[];
|
||||
runtime: string;
|
||||
onKeysAdded: (model?: string) => void;
|
||||
onCancel: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
workspaceId?: string;
|
||||
configuredKeys?: Set<string>;
|
||||
modelSuggestions?: string[];
|
||||
models?: ModelSpec[];
|
||||
initialModel?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
// Single model source: `models` from caller when present, else
|
||||
// synthesize a stub list from the legacy `providers` shape so older
|
||||
// callers (pre-PR-2534) still drive the picker. ProviderModelSelector
|
||||
// and findProviderForModel BOTH consume this list — passing the same
|
||||
// shape to both keeps ids identical, so back-derivation matches the
|
||||
// dropdown's option values.
|
||||
const selectorModels = useMemo(() => {
|
||||
if (models && models.length > 0) return models;
|
||||
return providers.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.label,
|
||||
required_env: p.envVars,
|
||||
}));
|
||||
}, [models, providers]);
|
||||
|
||||
const catalog = useMemo(() => buildProviderCatalog(selectorModels), [selectorModels]);
|
||||
|
||||
// Initial selector value: prefer back-derivation from initialModel
|
||||
// (template-deploy passes the template default), then the first
|
||||
// provider already satisfied by configuredKeys, then catalog[0].
|
||||
const initial = useMemo<SelectorValue>(() => {
|
||||
if (initialModel) {
|
||||
const matched = findProviderForModel(catalog, initialModel);
|
||||
if (matched) {
|
||||
return {
|
||||
providerId: matched.id,
|
||||
model: initialModel,
|
||||
envVars: matched.envVars,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (configuredKeys) {
|
||||
const satisfied = catalog.find((p) =>
|
||||
p.envVars.every((k) => configuredKeys.has(k)),
|
||||
);
|
||||
if (satisfied) {
|
||||
return {
|
||||
providerId: satisfied.id,
|
||||
model: satisfied.wildcard ? "" : satisfied.models[0]?.id ?? "",
|
||||
envVars: satisfied.envVars,
|
||||
};
|
||||
}
|
||||
}
|
||||
const first = catalog[0];
|
||||
if (!first) return { providerId: "", model: "", envVars: [] };
|
||||
return {
|
||||
providerId: first.id,
|
||||
model: first.wildcard ? "" : first.models[0]?.id ?? "",
|
||||
envVars: first.envVars,
|
||||
};
|
||||
}, [catalog, initialModel, configuredKeys]);
|
||||
|
||||
const [selectorValue, setSelectorValue] = useState<SelectorValue>(initial);
|
||||
const [entries, setEntries] = useState<KeyEntry[]>([]);
|
||||
const firstInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Legacy compat: map the selector value back into the old `selected`/
|
||||
// `model` shape for the rest of the modal body (footer copy, etc.).
|
||||
const selected = useMemo(
|
||||
() =>
|
||||
providers.find((p) => p.id === selectorValue.providerId) ??
|
||||
providers[0],
|
||||
[providers, selectorValue.providerId],
|
||||
);
|
||||
const model = selectorValue.model;
|
||||
const showModelInput = catalog.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSelectorValue(initial);
|
||||
}, [open, initial]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setEntries(
|
||||
selectorValue.envVars.map((key) => ({
|
||||
key,
|
||||
value: "",
|
||||
// Pre-mark as saved when the key is already in the configured
|
||||
// set (global or workspace scope). Lets the user click Deploy
|
||||
// without re-entering a key the platform already holds.
|
||||
saved: configuredKeys?.has(key) ?? false,
|
||||
saving: false,
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
}, [open, selectorValue.envVars, configuredKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const raf = requestAnimationFrame(() => firstInputRef.current?.focus());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [open, selectorValue.providerId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onCancel();
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [open, onCancel]);
|
||||
|
||||
const updateEntry = useCallback(
|
||||
(index: number, updates: Partial<KeyEntry>) => {
|
||||
setEntries((prev) =>
|
||||
prev.map((e, i) => (i === index ? { ...e, ...updates } : e)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSaveKey = useCallback(
|
||||
async (index: number) => {
|
||||
const entry = entries[index];
|
||||
if (!entry.value.trim()) return;
|
||||
updateEntry(index, { saving: true, error: null });
|
||||
try {
|
||||
if (workspaceId) {
|
||||
await api.put(`/workspaces/${workspaceId}/secrets`, {
|
||||
key: entry.key,
|
||||
value: entry.value.trim(),
|
||||
});
|
||||
} else {
|
||||
await api.put("/settings/secrets", {
|
||||
key: entry.key,
|
||||
value: entry.value.trim(),
|
||||
});
|
||||
}
|
||||
updateEntry(index, { saved: true, saving: false });
|
||||
} catch (e) {
|
||||
updateEntry(index, {
|
||||
saving: false,
|
||||
error: e instanceof Error ? e.message : "Failed to save",
|
||||
});
|
||||
}
|
||||
},
|
||||
[entries, updateEntry, workspaceId],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
// Portal to document.body for the same reason as
|
||||
// OrgImportPreflightModal — several callers (TemplatePalette,
|
||||
// EmptyState) render the modal inside their own fixed+filtered
|
||||
// containers, which re-anchor the "fixed" positioning to the
|
||||
// wrapper's bounds instead of the viewport.
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
|
||||
const anySaving = entries.some((e) => e.saving);
|
||||
const runtimeLabel = runtime
|
||||
.replace(/[-_]/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
return createPortal(
|
||||
// z-[60] so this stacks ABOVE OrgImportPreflightModal (z-50).
|
||||
// Both can be on screen at once during an org import: the org-
|
||||
// preflight is open while the user clicks a per-workspace deploy
|
||||
// that triggers MissingKeys. Without the explicit z-order the
|
||||
// backdrop click might dismiss the wrong modal depending on
|
||||
// React's commit ordering.
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="missing-keys-title"
|
||||
className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl shadow-black/50 max-w-[480px] w-full mx-4 max-h-[80vh] overflow-auto"
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-line">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div
|
||||
className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M6 1L11 10H1L6 1Z" stroke="#fbbf24" strokeWidth="1.2" strokeLinejoin="round" />
|
||||
<path d="M6 5V7" stroke="#fbbf24" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<circle cx="6" cy="8.5" r="0.5" fill="#fbbf24" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 id="missing-keys-title" className="text-sm font-semibold text-ink">
|
||||
{title ?? "Missing API Keys"}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[12px] text-ink-mid leading-relaxed">
|
||||
{description ?? (
|
||||
<>
|
||||
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
|
||||
runtime supports multiple providers. Pick one and paste its API key.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
{/* Shared provider→model selector. Source of truth for provider
|
||||
taxonomy + model filtering. Same component is used in
|
||||
ConfigTab so behavior + vendor split is identical across
|
||||
all 3 deploy surfaces (modal here, settings tab, template
|
||||
palette flow). */}
|
||||
<ProviderModelSelector
|
||||
models={selectorModels}
|
||||
value={selectorValue}
|
||||
onChange={setSelectorValue}
|
||||
variant="stack"
|
||||
idPrefix="provider-picker"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry, index) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className="bg-surface-card/50 rounded-lg px-3 py-2.5 border border-line/50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div>
|
||||
<div className="text-[11px] text-ink-mid font-medium">
|
||||
{getKeyLabel(entry.key)}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-ink-soft">{entry.key}</div>
|
||||
</div>
|
||||
{entry.saved && (
|
||||
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||||
<path d="M1.5 4L3.5 6L6.5 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!entry.saved && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<input
|
||||
value={entry.value}
|
||||
onChange={(e) => updateEntry(index, { value: e.target.value.trimStart() })}
|
||||
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
|
||||
type="password"
|
||||
ref={index === 0 ? firstInputRef : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && entry.value.trim()) {
|
||||
handleSaveKey(index);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSaveKey(index)}
|
||||
disabled={!entry.value.trim() || entry.saving}
|
||||
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
|
||||
>
|
||||
{entry.saving ? "..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.error && (
|
||||
<div className="mt-1.5 text-[10px] text-bad">{entry.error}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-line bg-surface/50 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="text-[11px] text-accent hover:text-accent transition-colors"
|
||||
>
|
||||
Open Settings Panel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
|
||||
>
|
||||
Cancel Deploy
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onKeysAdded(showModelInput ? model.trim() : undefined)}
|
||||
disabled={
|
||||
!allSaved ||
|
||||
anySaving ||
|
||||
!selectorValue.providerId ||
|
||||
(showModelInput && model.trim() === "")
|
||||
}
|
||||
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40"
|
||||
>
|
||||
{allSaved ? "Deploy" : entries.length > 1 ? "Add Keys" : "Add Key"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// All-keys mode — every missingKey rendered as its own input, all required.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function AllKeysModal({
|
||||
open,
|
||||
missingKeys,
|
||||
runtime,
|
||||
onKeysAdded,
|
||||
onCancel,
|
||||
onOpenSettings,
|
||||
workspaceId,
|
||||
}: {
|
||||
open: boolean;
|
||||
missingKeys: string[];
|
||||
runtime: string;
|
||||
onKeysAdded: () => void;
|
||||
onCancel: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
workspaceId?: string;
|
||||
}) {
|
||||
const [entries, setEntries] = useState<KeyEntry[]>([]);
|
||||
const [globalError, setGlobalError] = useState<string | null>(null);
|
||||
|
||||
// Initialize entries when modal opens or missingKeys change
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setEntries(
|
||||
missingKeys.map((key) => ({
|
||||
key,
|
||||
label: getKeyLabel(key),
|
||||
value: "",
|
||||
saved: false,
|
||||
saving: false,
|
||||
@@ -542,6 +55,7 @@ function AllKeysModal({
|
||||
setGlobalError(null);
|
||||
}, [open, missingKeys]);
|
||||
|
||||
// Keyboard handler
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
@@ -568,6 +82,7 @@ function AllKeysModal({
|
||||
updateEntry(index, { saving: true, error: null });
|
||||
|
||||
try {
|
||||
// Save to global scope by default (available to all workspaces)
|
||||
if (workspaceId) {
|
||||
await api.put(`/workspaces/${workspaceId}/secrets`, {
|
||||
key: entry.key,
|
||||
@@ -604,81 +119,65 @@ function AllKeysModal({
|
||||
onKeysAdded();
|
||||
}, [entries, onKeysAdded]);
|
||||
|
||||
// Focus trap: auto-focus first input when modal opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timer = requestAnimationFrame(() => {
|
||||
document.getElementById("missing-keys-title")?.focus();
|
||||
});
|
||||
return () => cancelAnimationFrame(timer);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
|
||||
const allSaved = entries.every((e) => e.saved);
|
||||
const anySaving = entries.some((e) => e.saving);
|
||||
const runtimeLabel = runtime
|
||||
.replace(/[-_]/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const runtimeLabel = runtime.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
return createPortal(
|
||||
// z-[60] so this stacks ABOVE OrgImportPreflightModal (z-50).
|
||||
// Both can be on screen at once during an org import: the org-
|
||||
// preflight is open while the user clicks a per-workspace deploy
|
||||
// that triggers MissingKeys. Without the explicit z-order the
|
||||
// backdrop click might dismiss the wrong modal depending on
|
||||
// React's commit ordering.
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
aria-hidden="true"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="missing-keys-title"
|
||||
className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl shadow-black/50 max-w-[440px] w-full mx-4 max-h-[80vh] overflow-auto"
|
||||
>
|
||||
<div className="px-5 py-4 border-b border-line">
|
||||
{/* Dialog */}
|
||||
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[440px] w-full mx-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-5 py-4 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div
|
||||
className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<path d="M6 1L11 10H1L6 1Z" stroke="#fbbf24" strokeWidth="1.2" strokeLinejoin="round" />
|
||||
<div className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center">
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M6 1L11 10H1L6 1Z"
|
||||
stroke="#fbbf24"
|
||||
strokeWidth="1.2"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M6 5V7" stroke="#fbbf24" strokeWidth="1.2" strokeLinecap="round" />
|
||||
<circle cx="6" cy="8.5" r="0.5" fill="#fbbf24" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 id="missing-keys-title" className="text-sm font-semibold text-ink">
|
||||
<h3 className="text-sm font-semibold text-zinc-100">
|
||||
Missing API Keys
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[12px] text-ink-mid leading-relaxed">
|
||||
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
|
||||
runtime requires the following keys to be configured before deploying.
|
||||
<p className="text-[12px] text-zinc-400 leading-relaxed">
|
||||
The <span className="text-amber-300 font-medium">{runtimeLabel}</span> runtime
|
||||
requires the following keys to be configured before deploying.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body — key list */}
|
||||
<div className="px-5 py-4 space-y-3 max-h-[50vh] overflow-y-auto">
|
||||
{entries.map((entry, index) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className="bg-surface-card/50 rounded-lg px-3 py-2.5 border border-line/50"
|
||||
className="bg-zinc-800/50 rounded-lg px-3 py-2.5 border border-zinc-700/50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<div className="text-[11px] text-ink-mid font-medium">
|
||||
{getKeyLabel(entry.key)}
|
||||
<div className="text-[11px] text-zinc-300 font-medium">
|
||||
{entry.label}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-zinc-500">
|
||||
{entry.key}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-ink-soft">{entry.key}</div>
|
||||
</div>
|
||||
{entry.saved && (
|
||||
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
<span className="text-[9px] text-emerald-400 bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
<svg width="8" height="8" viewBox="0 0 8 8" fill="none">
|
||||
<path d="M1.5 4L3.5 6L6.5 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
@@ -700,37 +199,38 @@ function AllKeysModal({
|
||||
handleSaveKey(index);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveKey(index)}
|
||||
disabled={!entry.value.trim() || entry.saving}
|
||||
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
|
||||
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
|
||||
>
|
||||
{entry.saving ? "..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.error && <div className="mt-1.5 text-[10px] text-bad">{entry.error}</div>}
|
||||
{entry.error && (
|
||||
<div className="mt-1.5 text-[10px] text-red-400">{entry.error}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{globalError && (
|
||||
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[11px] text-bad">
|
||||
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[11px] text-red-400">
|
||||
{globalError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-line bg-surface/50 flex items-center justify-between gap-2">
|
||||
{/* Footer */}
|
||||
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSettings}
|
||||
className="text-[11px] text-accent hover:text-accent transition-colors"
|
||||
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
|
||||
>
|
||||
Open Settings Panel
|
||||
</button>
|
||||
@@ -738,24 +238,21 @@ function AllKeysModal({
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
|
||||
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel Deploy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddKeysAndDeploy}
|
||||
disabled={!allSaved || anySaving}
|
||||
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40"
|
||||
className="px-3.5 py-1.5 text-[12px] bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-40"
|
||||
>
|
||||
{anySaving ? "Saving..." : allSaved ? "Deploy" : "Add Keys"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -132,14 +132,12 @@ export function OnboardingWizard() {
|
||||
<div
|
||||
role="complementary"
|
||||
aria-label="Onboarding guide"
|
||||
className="fixed bottom-20 left-4 z-50 w-80 rounded-2xl border border-line/60 bg-surface-sunken/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
className="fixed bottom-20 left-4 z-50 w-80 rounded-2xl border border-zinc-700/60 bg-zinc-900/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
>
|
||||
{/* Progress bar — was hardcoded from-blue-500 to-sky-400, neither
|
||||
tone exists in warm-paper light theme. Switched to the accent
|
||||
ramp so the gradient reads as brand color in both themes. */}
|
||||
<div className="h-1 bg-surface-card">
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 bg-zinc-800">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-accent to-accent-strong transition-all duration-500"
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-sky-400 transition-all duration-500"
|
||||
style={{ width: `${((currentStepIdx + 1) / STEPS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -157,39 +155,31 @@ export function OnboardingWizard() {
|
||||
<div className="p-4">
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{/* text-sky-400/80 was hardcoded; flip to text-accent so the
|
||||
indicator stays brand-tinted in both themes. */}
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-accent">
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-sky-400/80">
|
||||
Step {currentStepIdx + 1} of {STEPS.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label="Skip onboarding guide"
|
||||
className="text-[10px] text-ink-mid hover:text-ink transition-colors rounded-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
className="text-[10px] text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
Skip guide
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h3 className="text-sm font-medium text-ink mb-1">
|
||||
<h3 className="text-sm font-medium text-zinc-100 mb-1">
|
||||
{currentStep.title}
|
||||
</h3>
|
||||
<p className="text-[11px] text-ink-mid leading-relaxed mb-3">
|
||||
<p className="text-[11px] text-zinc-400 leading-relaxed mb-3">
|
||||
{currentStep.description}
|
||||
</p>
|
||||
|
||||
{/* Action button */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAction}
|
||||
// Was bg-accent-strong/90 hover:bg-accent — accent is the
|
||||
// LIGHTER variant, so this hovered lighter on white text and
|
||||
// dropped contrast below AA. Same trap fixed in
|
||||
// ConfirmDialog/ApprovalBanner. Hover the OTHER direction.
|
||||
className="flex-1 px-3 py-1.5 bg-accent hover:bg-accent-strong rounded-lg text-[11px] font-medium text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
|
||||
className="flex-1 px-3 py-1.5 bg-blue-600/90 hover:bg-blue-500 rounded-lg text-[11px] font-medium text-white transition-colors"
|
||||
>
|
||||
{step === "welcome"
|
||||
? "Create Workspace"
|
||||
@@ -201,16 +191,12 @@ export function OnboardingWizard() {
|
||||
</button>
|
||||
{step !== "done" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = STEPS[currentStepIdx + 1];
|
||||
if (next) setStep(next.id);
|
||||
else dismiss();
|
||||
}}
|
||||
// Was hover:bg-surface-card on top of bg-surface-card —
|
||||
// silent no-op hover. Lift to surface-elevated, matching
|
||||
// the Cancel pattern in ConfirmDialog.
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink rounded-lg text-[11px] text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
|
||||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-[11px] text-zinc-400 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
|
||||
@@ -1,540 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { createSecret } from "@/lib/api/secrets";
|
||||
|
||||
/**
|
||||
* One entry from the server's preflight `required_env` / `recommended_env`.
|
||||
*
|
||||
* - A plain string is a STRICT requirement: that exact env var must be
|
||||
* configured.
|
||||
* - A `{any_of: [...]}` object is an OR group: at least one member
|
||||
* must be configured to satisfy it. Lets a template say "either
|
||||
* ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN" without forcing
|
||||
* both.
|
||||
*
|
||||
* Matches the Go `EnvRequirement` type's JSON shape (MarshalJSON in
|
||||
* workspace-server/internal/handlers/org.go). The union is written so
|
||||
* that a narrow check — `typeof e === "string"` — distinguishes cleanly.
|
||||
*/
|
||||
export type EnvRequirement = string | { any_of: string[] };
|
||||
|
||||
/** Flat member list for a requirement. */
|
||||
export function envReqMembers(r: EnvRequirement): string[] {
|
||||
return typeof r === "string" ? [r] : r.any_of;
|
||||
}
|
||||
|
||||
/** True if any member is present in `configured`. */
|
||||
export function envReqSatisfied(r: EnvRequirement, configured: Set<string>): boolean {
|
||||
if (typeof r === "string") return configured.has(r);
|
||||
return r.any_of.some((m) => configured.has(m));
|
||||
}
|
||||
|
||||
/** Stable react-key / dedup key for a requirement. Sorted for groups so
|
||||
* reordered-member variants still collapse to one entry. */
|
||||
export function envReqKey(r: EnvRequirement): string {
|
||||
if (typeof r === "string") return r;
|
||||
return [...r.any_of].sort().join("|");
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
/** Display name of the org template — headline only. */
|
||||
orgName: string;
|
||||
/** Total workspace count so the header can read "12 workspaces". */
|
||||
workspaceCount: number;
|
||||
/** Env vars the server has declared MUST be set as global secrets.
|
||||
* Import is disabled until every entry here is configured. Entries
|
||||
* are either a single key name or an any-of group. */
|
||||
requiredEnv: EnvRequirement[];
|
||||
/** Env vars the server suggests — import can proceed without them,
|
||||
* but the user sees them listed so they can decide. Same union
|
||||
* shape as `requiredEnv`. */
|
||||
recommendedEnv: EnvRequirement[];
|
||||
/** Names of env vars already configured globally. Used to strike
|
||||
* through entries the user has already set up in another
|
||||
* session. Passed in rather than queried inside the modal so the
|
||||
* parent can refresh after each save without prop-driven effects. */
|
||||
configuredKeys: Set<string>;
|
||||
/** Called after a successful secret save so the parent can refresh
|
||||
* `configuredKeys`. */
|
||||
onSecretSaved: () => void;
|
||||
/** User clicked Import with all required envs satisfied. */
|
||||
onProceed: () => void;
|
||||
/** User dismissed the modal. Import is NOT fired. */
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface DraftEntry {
|
||||
key: string;
|
||||
value: string;
|
||||
saving: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* OrgImportPreflightModal
|
||||
* -----------------------
|
||||
* Two-tier env preflight before POST /org/import:
|
||||
*
|
||||
* - REQUIRED section (red, blocking) — every entry MUST be configured
|
||||
* globally before the Import button enables. Matches the server-
|
||||
* side preflight that would 412 the import anyway.
|
||||
*
|
||||
* - RECOMMENDED section (yellow, non-blocking) — listed so the user
|
||||
* can add them if they want the full experience, but the Import
|
||||
* button stays enabled regardless.
|
||||
*
|
||||
* Saving goes to the GLOBAL secrets endpoint (PUT /settings/secrets)
|
||||
* because org-level templates deploy shared resources. Per-workspace
|
||||
* overrides still work via the Config tab on an individual node
|
||||
* after import. The modal does NOT enable Import the moment a key is
|
||||
* typed — only after it saves successfully (so a half-entered token
|
||||
* can't proceed and then fail at container-start time instead).
|
||||
*/
|
||||
export function OrgImportPreflightModal({
|
||||
open,
|
||||
orgName,
|
||||
workspaceCount,
|
||||
requiredEnv,
|
||||
recommendedEnv,
|
||||
configuredKeys,
|
||||
onSecretSaved,
|
||||
onProceed,
|
||||
onCancel,
|
||||
}: Props) {
|
||||
const [drafts, setDrafts] = useState<Record<string, DraftEntry>>({});
|
||||
|
||||
// Flatten the union-shaped requirement lists to the set of every key
|
||||
// that could ever appear as an input row. Used purely to seed the
|
||||
// drafts map — satisfaction semantics still read from the grouped
|
||||
// EnvRequirement entries (a group can be satisfied by any one
|
||||
// member).
|
||||
const allMemberKeys = useMemo(() => {
|
||||
const keys: string[] = [];
|
||||
for (const r of requiredEnv) keys.push(...envReqMembers(r));
|
||||
for (const r of recommendedEnv) keys.push(...envReqMembers(r));
|
||||
return keys;
|
||||
}, [requiredEnv, recommendedEnv]);
|
||||
|
||||
// Seed a draft entry per declared key the first time the modal
|
||||
// opens. Entries persist across `configuredKeys` changes so a mid-
|
||||
// save recheck doesn't wipe what the user typed.
|
||||
//
|
||||
// Dep: derive a STABLE string from the env-name lists rather than
|
||||
// the array refs themselves. The parent computes
|
||||
// `preflight.org.required_env ?? []`, which produces a fresh []
|
||||
// identity on every re-render (e.g. when refreshConfiguredKeys
|
||||
// bumps state); depending on the array refs would re-fire the
|
||||
// effect on every parent render and mask any future edit that
|
||||
// drops the `if (!next[k])` guard as a silent input-reset bug.
|
||||
const envKeysSignature = useMemo(
|
||||
() => [...allMemberKeys].sort().join("|"),
|
||||
[allMemberKeys],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setDrafts((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const k of allMemberKeys) {
|
||||
if (!next[k]) {
|
||||
next[k] = { key: k, value: "", saving: false, error: null };
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, envKeysSignature]);
|
||||
|
||||
const missingRequired = useMemo(
|
||||
() => requiredEnv.filter((r) => !envReqSatisfied(r, configuredKeys)),
|
||||
[requiredEnv, configuredKeys],
|
||||
);
|
||||
const missingRecommended = useMemo(
|
||||
() => recommendedEnv.filter((r) => !envReqSatisfied(r, configuredKeys)),
|
||||
[recommendedEnv, configuredKeys],
|
||||
);
|
||||
const canProceed = missingRequired.length === 0;
|
||||
|
||||
// Synchronous in-flight gate. A ref (not state) so two clicks
|
||||
// dispatched in the SAME microtask both see the gate flip — state
|
||||
// commits don't help here because setState is async. The previous
|
||||
// closure-based `current.saving` gate worked under React Testing
|
||||
// Library's act() flushing but failed for true microtask-level
|
||||
// double-fires (programmatic clicks, dblclick events, Enter-spam
|
||||
// before React commits). Set is keyed by env var name so different
|
||||
// rows can save concurrently.
|
||||
const inFlightRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Latest-drafts ref so saveOne can read the current input value
|
||||
// without taking `drafts` as a useCallback dep — that dep would
|
||||
// re-create saveOne on every keystroke and re-bind every Save
|
||||
// button's onClick handler, churn that scales with row count.
|
||||
const draftsRef = useRef(drafts);
|
||||
useEffect(() => {
|
||||
draftsRef.current = drafts;
|
||||
}, [drafts]);
|
||||
|
||||
const saveOne = useCallback(
|
||||
async (key: string) => {
|
||||
// Microtask-safe gate: claim the slot synchronously BEFORE any
|
||||
// await so a second click in the same tick bounces immediately.
|
||||
if (inFlightRef.current.has(key)) return;
|
||||
const current = draftsRef.current[key];
|
||||
if (!current || !current.value.trim()) return;
|
||||
inFlightRef.current.add(key);
|
||||
|
||||
const startValue = current.value;
|
||||
setDrafts((d) => ({
|
||||
...d,
|
||||
[key]: { ...d[key], saving: true, error: null },
|
||||
}));
|
||||
try {
|
||||
await createSecret("global", key, startValue);
|
||||
setDrafts((d) => ({
|
||||
...d,
|
||||
[key]: { ...d[key], value: "", saving: false, error: null },
|
||||
}));
|
||||
// Let the parent refresh configuredKeys so the strike-through
|
||||
// updates and canProceed recomputes.
|
||||
onSecretSaved();
|
||||
} catch (e) {
|
||||
setDrafts((d) => ({
|
||||
...d,
|
||||
[key]: {
|
||||
...d[key],
|
||||
saving: false,
|
||||
error: e instanceof Error ? e.message : "Save failed",
|
||||
},
|
||||
}));
|
||||
} finally {
|
||||
inFlightRef.current.delete(key);
|
||||
}
|
||||
},
|
||||
[onSecretSaved],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
// Portal the dialog to document.body so it escapes any ancestor
|
||||
// containing block. TemplatePalette renders this modal inside a
|
||||
// sidebar whose `fixed` container plus backdrop-filter together
|
||||
// re-anchor descendants' `position: fixed` to the sidebar's own
|
||||
// bounds instead of the viewport — the modal ends up glued to the
|
||||
// sidebar's scrollable region and only becomes visible after the
|
||||
// user scrolls the sidebar. Portal dodges that class of issue
|
||||
// once and for all, regardless of what future wrappers do.
|
||||
//
|
||||
// SSR-safe guard: `document` is undefined on the server. Since
|
||||
// the modal is gated by `if (!open) return null` above, this
|
||||
// effectively only runs after open flips true on the client.
|
||||
if (typeof document === "undefined") return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="org-preflight-title"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<div
|
||||
className="w-[560px] max-h-[80vh] overflow-auto rounded-xl bg-surface-sunken border border-line shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="px-5 py-4 border-b border-line">
|
||||
<h2 id="org-preflight-title" className="text-sm font-semibold text-ink">
|
||||
Deploy {orgName}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-[11px] text-ink-soft">
|
||||
{workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}.
|
||||
Review the credentials needed before import.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="p-5 space-y-5">
|
||||
{requiredEnv.length > 0 && (
|
||||
<EnvList
|
||||
tone="required"
|
||||
title="Required"
|
||||
subtitle="Import is blocked until every key below is saved globally."
|
||||
entries={requiredEnv}
|
||||
configuredKeys={configuredKeys}
|
||||
drafts={drafts}
|
||||
onChange={(key, value) =>
|
||||
setDrafts((d) => ({ ...d, [key]: { ...d[key], value } }))
|
||||
}
|
||||
onSave={saveOne}
|
||||
/>
|
||||
)}
|
||||
{recommendedEnv.length > 0 && (
|
||||
<EnvList
|
||||
tone="recommended"
|
||||
title="Recommended"
|
||||
subtitle="Not required, but some features degrade without them. Add them now for the best experience."
|
||||
entries={recommendedEnv}
|
||||
configuredKeys={configuredKeys}
|
||||
drafts={drafts}
|
||||
onChange={(key, value) =>
|
||||
setDrafts((d) => ({ ...d, [key]: { ...d[key], value } }))
|
||||
}
|
||||
onSave={saveOne}
|
||||
/>
|
||||
)}
|
||||
{requiredEnv.length === 0 && recommendedEnv.length === 0 && (
|
||||
<p className="text-[12px] text-ink-mid">
|
||||
No additional credentials required for this template.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<footer className="px-5 py-3 border-t border-line flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1.5 text-[11px] rounded bg-surface-card hover:bg-surface-elevated hover:text-ink text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{missingRecommended.length > 0 && canProceed && (
|
||||
<span className="text-[10px] text-warm/90">
|
||||
{missingRecommended.length} recommended key
|
||||
{missingRecommended.length === 1 ? "" : "s"} still unset
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onProceed}
|
||||
disabled={!canProceed}
|
||||
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed"
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
interface EnvListProps {
|
||||
tone: "required" | "recommended";
|
||||
title: string;
|
||||
subtitle: string;
|
||||
entries: EnvRequirement[];
|
||||
configuredKeys: Set<string>;
|
||||
drafts: Record<string, DraftEntry>;
|
||||
onChange: (key: string, value: string) => void;
|
||||
onSave: (key: string) => void;
|
||||
}
|
||||
|
||||
function EnvList({
|
||||
tone,
|
||||
title,
|
||||
subtitle,
|
||||
entries,
|
||||
configuredKeys,
|
||||
drafts,
|
||||
onChange,
|
||||
onSave,
|
||||
}: EnvListProps) {
|
||||
const accent =
|
||||
tone === "required"
|
||||
? "border-red-800/60 bg-red-950/20"
|
||||
: "border-amber-800/50 bg-amber-950/15";
|
||||
const headerColor =
|
||||
tone === "required" ? "text-bad" : "text-warm";
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border ${accent} p-3`}>
|
||||
<h3 className={`text-[11px] font-semibold uppercase tracking-wide ${headerColor}`}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className="mt-0.5 mb-2 text-[10px] text-ink-mid">{subtitle}</p>
|
||||
<ul className="space-y-2">
|
||||
{entries.map((entry) =>
|
||||
typeof entry === "string" ? (
|
||||
<StrictEnvRow
|
||||
key={envReqKey(entry)}
|
||||
envKey={entry}
|
||||
configured={configuredKeys.has(entry)}
|
||||
draft={drafts[entry]}
|
||||
onChange={onChange}
|
||||
onSave={onSave}
|
||||
/>
|
||||
) : (
|
||||
<AnyOfEnvGroup
|
||||
key={envReqKey(entry)}
|
||||
members={entry.any_of}
|
||||
configuredKeys={configuredKeys}
|
||||
drafts={drafts}
|
||||
onChange={onChange}
|
||||
onSave={onSave}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StrictEnvRowProps {
|
||||
envKey: string;
|
||||
configured: boolean;
|
||||
draft: DraftEntry | undefined;
|
||||
onChange: (key: string, value: string) => void;
|
||||
onSave: (key: string) => void;
|
||||
}
|
||||
|
||||
function StrictEnvRow({
|
||||
envKey,
|
||||
configured,
|
||||
draft: d,
|
||||
onChange,
|
||||
onSave,
|
||||
}: StrictEnvRowProps) {
|
||||
return (
|
||||
<li className="flex items-center gap-2 rounded bg-surface-sunken/70 border border-line px-2 py-1.5">
|
||||
<code
|
||||
className={`text-[11px] font-mono flex-1 ${
|
||||
configured ? "text-ink-soft line-through" : "text-ink"
|
||||
}`}
|
||||
>
|
||||
{envKey}
|
||||
</code>
|
||||
{configured ? (
|
||||
<span className="text-[10px] text-good">✓ set</span>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={`Value for ${envKey}`}
|
||||
placeholder="paste value"
|
||||
value={d?.value ?? ""}
|
||||
onChange={(e) => onChange(envKey, e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onSave(envKey);
|
||||
}
|
||||
}}
|
||||
disabled={d?.saving}
|
||||
className="flex-1 px-2 py-1 rounded bg-surface-card border border-line text-[11px] text-ink focus:outline-none focus:border-accent disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave(envKey)}
|
||||
disabled={d?.saving || !d?.value.trim()}
|
||||
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{d?.saving ? "…" : "Save"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{d?.error && (
|
||||
<span className="text-[9px] text-bad basis-full pl-1">
|
||||
{d.error}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
interface AnyOfEnvGroupProps {
|
||||
members: string[];
|
||||
configuredKeys: Set<string>;
|
||||
drafts: Record<string, DraftEntry>;
|
||||
onChange: (key: string, value: string) => void;
|
||||
onSave: (key: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an OR group: the user only needs to configure ONE of the
|
||||
* members to satisfy the requirement. Once any member is configured
|
||||
* the group shows a green banner identifying the satisfying key; the
|
||||
* other inputs remain visible but muted so the user can still switch
|
||||
* providers if they want (uncommon but cheap to support).
|
||||
*/
|
||||
function AnyOfEnvGroup({
|
||||
members,
|
||||
configuredKeys,
|
||||
drafts,
|
||||
onChange,
|
||||
onSave,
|
||||
}: AnyOfEnvGroupProps) {
|
||||
const satisfiedBy = members.find((m) => configuredKeys.has(m));
|
||||
return (
|
||||
<li className="rounded border border-line bg-surface-sunken/50 px-2.5 py-2">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wide text-ink-mid">
|
||||
Configure any one
|
||||
</span>
|
||||
{satisfiedBy && (
|
||||
<span className="text-[10px] text-good">
|
||||
✓ using <code className="font-mono">{satisfiedBy}</code>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{members.map((m) => {
|
||||
const isConfigured = configuredKeys.has(m);
|
||||
const d = drafts[m];
|
||||
const dimmed = !!satisfiedBy && !isConfigured;
|
||||
return (
|
||||
<li
|
||||
key={m}
|
||||
className={`flex items-center gap-2 rounded bg-surface-sunken/70 border border-line px-2 py-1 ${
|
||||
dimmed ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<code
|
||||
className={`text-[11px] font-mono flex-1 ${
|
||||
isConfigured ? "text-ink-soft line-through" : "text-ink"
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</code>
|
||||
{isConfigured ? (
|
||||
<span className="text-[10px] text-good">✓ set</span>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="password"
|
||||
aria-label={`Value for ${m}`}
|
||||
placeholder="paste value"
|
||||
value={d?.value ?? ""}
|
||||
onChange={(e) => onChange(m, e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onSave(m);
|
||||
}
|
||||
}}
|
||||
disabled={d?.saving}
|
||||
className="flex-1 px-2 py-1 rounded bg-surface-card border border-line text-[11px] text-ink focus:outline-none focus:border-accent disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave(m)}
|
||||
disabled={d?.saving || !d?.value.trim()}
|
||||
className="px-2 py-1 text-[10px] rounded bg-accent hover:bg-accent-strong text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{d?.saving ? "…" : "Save"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{d?.error && (
|
||||
<span className="text-[9px] text-bad basis-full pl-1">
|
||||
{d.error}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -97,27 +97,27 @@ function PlanCard({
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const ring = plan.highlighted
|
||||
? "border-accent ring-2 ring-blue-600/30"
|
||||
: "border-line";
|
||||
? "border-blue-600 ring-2 ring-blue-600/30"
|
||||
: "border-zinc-800";
|
||||
return (
|
||||
<article
|
||||
className={`flex flex-col rounded-lg border ${ring} bg-surface-sunken/40 p-6`}
|
||||
className={`flex flex-col rounded-lg border ${ring} bg-zinc-900/40 p-6`}
|
||||
aria-labelledby={`plan-${plan.id}-name`}
|
||||
>
|
||||
{plan.highlighted && (
|
||||
<span className="mb-3 inline-block rounded-full bg-accent-strong/20 px-3 py-1 text-xs font-medium text-accent">
|
||||
<span className="mb-3 inline-block rounded-full bg-blue-600/20 px-3 py-1 text-xs font-medium text-blue-300">
|
||||
Most popular
|
||||
</span>
|
||||
)}
|
||||
<h2 id={`plan-${plan.id}-name`} className="text-xl font-semibold text-ink">
|
||||
<h2 id={`plan-${plan.id}-name`} className="text-xl font-semibold text-white">
|
||||
{plan.name}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-ink-mid">{plan.tagline}</p>
|
||||
<p className="mt-4 text-3xl font-bold text-ink">{plan.price}</p>
|
||||
<ul className="mt-6 flex-1 space-y-2 text-sm text-ink-mid">
|
||||
<p className="mt-1 text-sm text-zinc-400">{plan.tagline}</p>
|
||||
<p className="mt-4 text-3xl font-bold text-white">{plan.price}</p>
|
||||
<ul className="mt-6 flex-1 space-y-2 text-sm text-zinc-300">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start">
|
||||
<span className="mr-2 text-accent" aria-hidden>
|
||||
<span className="mr-2 text-blue-400" aria-hidden>
|
||||
✓
|
||||
</span>
|
||||
{f}
|
||||
@@ -130,8 +130,8 @@ function PlanCard({
|
||||
disabled={loading}
|
||||
className={`mt-6 rounded-lg px-4 py-3 text-sm font-medium ${
|
||||
plan.highlighted
|
||||
? "bg-accent-strong text-white hover:bg-accent disabled:bg-blue-900"
|
||||
: "border border-line bg-surface-sunken text-ink hover:bg-surface-card disabled:opacity-50"
|
||||
? "bg-blue-600 text-white hover:bg-blue-500 disabled:bg-blue-900"
|
||||
: "border border-zinc-700 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
|
||||
}`}
|
||||
>
|
||||
{loading ? "Opening checkout…" : plan.ctaLabel}
|
||||
|
||||
@@ -1,523 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ProviderModelSelector — single source of truth for the provider→model
|
||||
* dropdown chain shared across:
|
||||
* 1. MissingKeysModal (template deploy / first-time onboarding modal)
|
||||
* 2. ConfigTab (per-workspace settings — Runtime section)
|
||||
* 3. TemplatePalette (template side panel — inherits via MissingKeysModal)
|
||||
*
|
||||
* The user picks Provider FIRST (Anthropic API, Claude Code subscription,
|
||||
* MiniMax, Z.ai GLM, ...). The model dropdown then filters to only that
|
||||
* provider's models. Wildcard providers (huggingface/*, openrouter/*,
|
||||
* custom/*) reveal a free-text model input with a tooltip explaining the
|
||||
* wildcard.
|
||||
*
|
||||
* Provider taxonomy:
|
||||
* - Multiple models can share the same `required_env` (e.g. all
|
||||
* ANTHROPIC_AUTH_TOKEN-routed third-party providers — MiniMax, GLM,
|
||||
* Kimi, DeepSeek). Grouping ONLY by env-tuple collapses them all into
|
||||
* one bucket. We split further by vendor inferred from the model id
|
||||
* so the user sees "MiniMax" and "Z.ai (GLM)" as separate options.
|
||||
* - Vendor is inferred via prefix rules below. Templates that ship
|
||||
* explicit vendor metadata (future) should override the heuristic.
|
||||
*/
|
||||
|
||||
import { useId, useMemo } from "react";
|
||||
|
||||
export interface SelectorModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
required_env?: string[];
|
||||
}
|
||||
|
||||
/** A provider option in the dropdown — one row corresponds to one
|
||||
* vendor + env-tuple combo, holding the models that map to it. */
|
||||
export interface ProviderEntry {
|
||||
/** Stable id used as the <option value>. `${vendor}|${sortedEnv}`. */
|
||||
id: string;
|
||||
/** Inferred vendor key (e.g. "minimax", "anthropic-oauth"). */
|
||||
vendor: string;
|
||||
/** Human label shown in the dropdown. */
|
||||
label: string;
|
||||
/** Env vars required by every model in this provider. */
|
||||
envVars: string[];
|
||||
/** Models bucketed under this provider. */
|
||||
models: SelectorModel[];
|
||||
/** True when ANY model id contains "*" — UI shows free-text model input. */
|
||||
wildcard: boolean;
|
||||
/** Optional tooltip text (rendered as native title=). */
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export interface SelectorValue {
|
||||
/** ProviderEntry.id of the selected provider. Empty string = nothing
|
||||
* picked yet (parent should treat as invalid for save). */
|
||||
providerId: string;
|
||||
/** Selected model slug. For wildcard providers this is whatever the
|
||||
* user typed in the free-text input. */
|
||||
model: string;
|
||||
/** Snapshot of envVars from the selected provider. Re-emitted on every
|
||||
* change so consumers can re-render credential fields without
|
||||
* re-inferring from the model. */
|
||||
envVars: string[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
models: SelectorModel[];
|
||||
value: SelectorValue;
|
||||
onChange: (next: SelectorValue) => void;
|
||||
/** Display variant. "grid" = label+control side-by-side (used in ConfigTab
|
||||
* Runtime section). "stack" = vertical (used in MissingKeysModal). */
|
||||
variant?: "grid" | "stack";
|
||||
/** When true, parent caller is opting in to power-user free-text. Adds a
|
||||
* "Custom (type model id)..." escape-hatch entry as a model option even
|
||||
* when the chosen provider isn't wildcard. ConfigTab uses this; the
|
||||
* deploy modal does not. */
|
||||
allowCustomModelEscape?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Optional id-prefix for label↔control wiring (WCAG 1.3.1). Default
|
||||
* uses useId(). */
|
||||
idPrefix?: string;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Vendor detection — id-prefix heuristic + bare-name patterns.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Vendor keys → human label. Add new vendors here when templates pick
|
||||
* up new model families. */
|
||||
const VENDOR_LABELS: Record<string, string> = {
|
||||
"anthropic-oauth": "Claude Code subscription",
|
||||
anthropic: "Anthropic API",
|
||||
minimax: "MiniMax",
|
||||
zai: "Z.ai (GLM)",
|
||||
moonshot: "Moonshot (Kimi)",
|
||||
deepseek: "DeepSeek",
|
||||
"xiaomi-mimo": "Xiaomi MiMo",
|
||||
openai: "OpenAI",
|
||||
google: "Google Gemini",
|
||||
alibaba: "Alibaba Qwen (DashScope)",
|
||||
nousresearch: "Nous Research (Hermes)",
|
||||
openrouter: "OpenRouter (any model)",
|
||||
huggingface: "Hugging Face Inference",
|
||||
"ai-gateway": "Vercel AI Gateway",
|
||||
"opencode-zen": "OpenCode Zen",
|
||||
"opencode-go": "OpenCode Go",
|
||||
kilocode: "Kilo Code",
|
||||
"kimi-coding": "Moonshot Kimi (coding-tuned)",
|
||||
"minimax-cn": "MiniMax China",
|
||||
"ollama-cloud": "Ollama Cloud",
|
||||
ollama: "Ollama (self-hosted)",
|
||||
nvidia: "NVIDIA NIM",
|
||||
arcee: "Arcee",
|
||||
xiaomi: "Xiaomi MiMo",
|
||||
gemini: "Google Gemini",
|
||||
custom: "Custom OpenAI-compat endpoint",
|
||||
};
|
||||
|
||||
/** Optional per-vendor tooltip shown on hover. */
|
||||
const VENDOR_TOOLTIPS: Record<string, string> = {
|
||||
"anthropic-oauth":
|
||||
"Use your Claude.ai (Pro/Max/Team) subscription via OAuth. Run `claude login` in the workspace terminal to mint the token, then paste it here. No API spend.",
|
||||
anthropic:
|
||||
"Pay-per-token via the Anthropic API (Console). Provide an API key starting with sk-ant-…",
|
||||
minimax:
|
||||
"MiniMax models served through their Anthropic-API-compatible endpoint. Get a key at platform.minimax.io.",
|
||||
zai:
|
||||
"Zhipu AI / z.ai GLM models through the Anthropic-compatible gateway. Get a key at docs.z.ai.",
|
||||
moonshot:
|
||||
"Moonshot Kimi K2-series via Anthropic-API-compatible endpoint. Get a key at platform.kimi.ai.",
|
||||
deepseek:
|
||||
"DeepSeek V4 via Anthropic-API-compatible endpoint. Get a key at api-docs.deepseek.com.",
|
||||
openrouter:
|
||||
"OpenRouter routes to 200+ models behind one API. Use any openrouter/<model> id. Get a key at openrouter.ai.",
|
||||
huggingface:
|
||||
"Any model hosted on Hugging Face Inference. Type the full model id (e.g. mistralai/Mistral-7B-Instruct-v0.3).",
|
||||
custom:
|
||||
"Self-hosted OpenAI-compatible endpoint (LM Studio, Ollama local, vLLM, llama.cpp). Configure base_url in the workspace's runtime config. No API key required.",
|
||||
};
|
||||
|
||||
/** Sentinel value used in the model <select> for the free-text escape hatch
|
||||
* added by `allowCustomModelEscape`. The component swaps to a text input
|
||||
* when this is selected. */
|
||||
const CUSTOM_MODEL_SENTINEL = "__custom__";
|
||||
|
||||
/** Bare-id vendor patterns (no slash separator). Order matters — first
|
||||
* match wins. */
|
||||
const BARE_VENDOR_PATTERNS: Array<{ test: (id: string) => boolean; vendor: string }> = [
|
||||
{ test: (id) => /^minimax-/i.test(id) || /^MiniMax-/.test(id), vendor: "minimax" },
|
||||
{ test: (id) => /^GLM-/i.test(id), vendor: "zai" },
|
||||
{ test: (id) => /^kimi-/i.test(id), vendor: "moonshot" },
|
||||
{ test: (id) => /^deepseek-/i.test(id), vendor: "deepseek" },
|
||||
{ test: (id) => /^mimo-/i.test(id), vendor: "xiaomi-mimo" },
|
||||
{ test: (id) => /^claude-/i.test(id), vendor: "anthropic" },
|
||||
{ test: (id) => /^gpt-/i.test(id), vendor: "openai" },
|
||||
{ test: (id) => /^gemini-/i.test(id), vendor: "google" },
|
||||
{ test: (id) => /^qwen-/i.test(id), vendor: "alibaba" },
|
||||
// Claude-Code OAuth aliases — bare "sonnet"/"opus"/"haiku" + CLAUDE_CODE_OAUTH_TOKEN
|
||||
// is the strongest signal that this is a subscription model. We also
|
||||
// gate on env in inferVendor() below to avoid mis-tagging non-OAuth
|
||||
// models that happen to be named "sonnet".
|
||||
{ test: (id) => /^(sonnet|opus|haiku)$/i.test(id), vendor: "anthropic-oauth" },
|
||||
];
|
||||
|
||||
/** Infer a vendor key from a model spec. Combines id-prefix and env
|
||||
* signals. Exported for tests. */
|
||||
export function inferVendor(model: SelectorModel): string {
|
||||
const id = model.id || "";
|
||||
const envSet = new Set(model.required_env ?? []);
|
||||
|
||||
// 1. Explicit slash-separated prefix wins (e.g. nousresearch/hermes-4-70b).
|
||||
const slashIdx = id.indexOf("/");
|
||||
if (slashIdx > 0) {
|
||||
return id.slice(0, slashIdx).toLowerCase();
|
||||
}
|
||||
|
||||
// 2. Bare-id pattern. Special-case the OAuth aliases — they only count
|
||||
// when the env actually demands the OAuth token. Otherwise (e.g.
|
||||
// a hypothetical "sonnet" alias against ANTHROPIC_API_KEY) fall
|
||||
// through and let the env-based fallback bucket it under
|
||||
// "anthropic".
|
||||
for (const p of BARE_VENDOR_PATTERNS) {
|
||||
if (!p.test(id)) continue;
|
||||
if (p.vendor === "anthropic-oauth" && !envSet.has("CLAUDE_CODE_OAUTH_TOKEN")) {
|
||||
continue;
|
||||
}
|
||||
return p.vendor;
|
||||
}
|
||||
|
||||
// 3. Env-tuple fallback. Pick the first env's "namespace" as the
|
||||
// vendor — e.g. OPENROUTER_API_KEY → "openrouter".
|
||||
const env = model.required_env?.[0];
|
||||
if (env) {
|
||||
const ns = env.replace(/_API_KEY$|_TOKEN$|_KEY$/i, "").toLowerCase();
|
||||
return ns || "unknown";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/** Build the provider catalog from the template's models[]. Models are
|
||||
* bucketed by `(vendor, sortedEnv)` so two distinct env-tuples for the
|
||||
* same vendor (rare but possible) become two separate entries. */
|
||||
export function buildProviderCatalog(models: SelectorModel[]): ProviderEntry[] {
|
||||
const buckets = new Map<string, ProviderEntry>();
|
||||
|
||||
for (const m of models) {
|
||||
const envs = m.required_env ?? [];
|
||||
const sortedEnv = [...envs].sort().join("|");
|
||||
const vendor = inferVendor(m);
|
||||
const id = `${vendor}|${sortedEnv}`;
|
||||
const wildcard = m.id.includes("*");
|
||||
|
||||
let entry = buckets.get(id);
|
||||
if (!entry) {
|
||||
const baseLabel = VENDOR_LABELS[vendor] ?? vendor;
|
||||
entry = {
|
||||
id,
|
||||
vendor,
|
||||
label: baseLabel,
|
||||
envVars: envs,
|
||||
models: [],
|
||||
wildcard,
|
||||
tooltip: VENDOR_TOOLTIPS[vendor],
|
||||
};
|
||||
buckets.set(id, entry);
|
||||
}
|
||||
entry.models.push(m);
|
||||
// Wildcard sticks if any model in the bucket is a wildcard — same
|
||||
// bucket can't mix wildcard and concrete because they'd typically
|
||||
// share required_env but rarely the same vendor. Defensive OR.
|
||||
entry.wildcard = entry.wildcard || wildcard;
|
||||
}
|
||||
|
||||
// Decorate label with model-count when ≥2 concrete models share the
|
||||
// bucket. Helps the user understand "Anthropic API (5 models)" vs
|
||||
// "MiniMax (3 models)".
|
||||
for (const e of buckets.values()) {
|
||||
if (!e.wildcard && e.models.length > 1) {
|
||||
e.label = `${e.label} (${e.models.length} models)`;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(buckets.values());
|
||||
}
|
||||
|
||||
/** Find the provider entry that contains a given model id. Used by
|
||||
* callers to back-derive the provider when only the model is known
|
||||
* (e.g. ConfigTab loading from saved state). */
|
||||
export function findProviderForModel(
|
||||
catalog: ProviderEntry[],
|
||||
modelId: string,
|
||||
): ProviderEntry | null {
|
||||
if (!modelId) return null;
|
||||
for (const p of catalog) {
|
||||
if (p.models.some((m) => m.id === modelId)) return p;
|
||||
// Wildcard match — entry has model id ending in "*" and the typed
|
||||
// id starts with the wildcard's prefix (e.g. "openrouter/anthropic/
|
||||
// claude-3.5-sonnet" matches the "openrouter/*" bucket).
|
||||
if (p.wildcard) {
|
||||
for (const m of p.models) {
|
||||
if (!m.id.endsWith("*")) continue;
|
||||
const prefix = m.id.slice(0, -1);
|
||||
if (modelId.startsWith(prefix)) return p;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Component
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export function ProviderModelSelector({
|
||||
models,
|
||||
value,
|
||||
onChange,
|
||||
variant = "stack",
|
||||
allowCustomModelEscape = false,
|
||||
disabled = false,
|
||||
idPrefix,
|
||||
}: Props) {
|
||||
const generatedId = useId();
|
||||
const baseId = idPrefix ?? generatedId;
|
||||
const providerSelectId = `${baseId}-provider`;
|
||||
const modelSelectId = `${baseId}-model`;
|
||||
|
||||
const catalog = useMemo(() => buildProviderCatalog(models), [models]);
|
||||
const selected = useMemo(
|
||||
() => catalog.find((p) => p.id === value.providerId) ?? null,
|
||||
[catalog, value.providerId],
|
||||
);
|
||||
|
||||
// True when the user picked the "Custom (type model id)..." escape entry
|
||||
// in the model dropdown — switches to free-text. Wildcard providers
|
||||
// ALWAYS use free-text, so this flag is for the escape hatch on
|
||||
// non-wildcard providers.
|
||||
const userPickedCustom = value.model === CUSTOM_MODEL_SENTINEL || (
|
||||
!!selected &&
|
||||
!selected.wildcard &&
|
||||
!!value.model &&
|
||||
!selected.models.some((m) => m.id === value.model)
|
||||
);
|
||||
const useTextInput = (selected?.wildcard ?? false) || userPickedCustom;
|
||||
|
||||
const handleProviderChange = (nextProviderId: string) => {
|
||||
const next = catalog.find((p) => p.id === nextProviderId) ?? null;
|
||||
if (!next) {
|
||||
onChange({ providerId: "", model: "", envVars: [] });
|
||||
return;
|
||||
}
|
||||
// When switching providers:
|
||||
// - wildcard provider → empty (free-text input takes over)
|
||||
// - exactly 1 concrete model → auto-pick (no choice to make)
|
||||
// - 2+ concrete models → leave empty so the operator MUST pick
|
||||
//
|
||||
// Background: previously this defaulted to `next.models[0]` for any
|
||||
// non-wildcard provider, which silently set the alphabetically-first
|
||||
// model in the bucket. Bit a real user on 2026-05-03 — they picked
|
||||
// the MiniMax provider intending `MiniMax-M2.7` but the form silently
|
||||
// set `MiniMax-M2` (first in the list). They never saw the model
|
||||
// dropdown change because the provider+model widgets are visually
|
||||
// distinct, and the workspace deployed with the wrong model. Caller
|
||||
// already disables Deploy/Save while `model.trim() === ""`, so the
|
||||
// empty default forces an explicit pick without loosening any other
|
||||
// gate.
|
||||
const defaultModel = next.wildcard
|
||||
? ""
|
||||
: next.models.length === 1
|
||||
? next.models[0]?.id ?? ""
|
||||
: "";
|
||||
onChange({
|
||||
providerId: next.id,
|
||||
model: defaultModel,
|
||||
envVars: next.envVars,
|
||||
});
|
||||
};
|
||||
|
||||
const handleModelChange = (nextModel: string) => {
|
||||
if (!selected) {
|
||||
onChange({ ...value, model: nextModel });
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
providerId: selected.id,
|
||||
model: nextModel,
|
||||
envVars: selected.envVars,
|
||||
});
|
||||
};
|
||||
|
||||
const containerClass = variant === "grid" ? "grid grid-cols-2 gap-3" : "space-y-3";
|
||||
|
||||
return (
|
||||
<div className={containerClass} data-testid="provider-model-selector">
|
||||
<div>
|
||||
<label
|
||||
htmlFor={providerSelectId}
|
||||
className="text-[10px] uppercase tracking-wide text-ink-soft font-semibold mb-1.5 block"
|
||||
>
|
||||
Provider <span aria-hidden="true" className="text-bad">*</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
</label>
|
||||
<select
|
||||
id={providerSelectId}
|
||||
value={value.providerId}
|
||||
onChange={(e) => handleProviderChange(e.target.value)}
|
||||
disabled={disabled || catalog.length === 0}
|
||||
aria-describedby={selected?.tooltip ? `${providerSelectId}-help` : undefined}
|
||||
data-testid="provider-select"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<option value="" disabled>
|
||||
— select provider —
|
||||
</option>
|
||||
{catalog.map((p) => (
|
||||
<option key={p.id} value={p.id} title={p.tooltip}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selected?.tooltip && (
|
||||
<p
|
||||
id={`${providerSelectId}-help`}
|
||||
className="text-[9px] text-ink-soft mt-1 leading-relaxed"
|
||||
>
|
||||
{selected.tooltip}
|
||||
</p>
|
||||
)}
|
||||
{selected && selected.envVars.length > 0 && (
|
||||
<p className="text-[9px] text-ink-soft mt-0.5 font-mono">
|
||||
requires: {selected.envVars.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor={modelSelectId}
|
||||
className="text-[10px] uppercase tracking-wide text-ink-soft font-semibold mb-1.5 block"
|
||||
>
|
||||
Model <span aria-hidden="true" className="text-bad">*</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
</label>
|
||||
{useTextInput ? (
|
||||
<>
|
||||
<input
|
||||
id={modelSelectId}
|
||||
type="text"
|
||||
value={
|
||||
value.model === CUSTOM_MODEL_SENTINEL ? "" : value.model
|
||||
}
|
||||
onChange={(e) => handleModelChange(e.target.value.trim())}
|
||||
placeholder={
|
||||
selected?.wildcard
|
||||
? wildcardPlaceholder(selected)
|
||||
: "type any model id"
|
||||
}
|
||||
disabled={disabled || !selected}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
data-testid="model-input"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50"
|
||||
/>
|
||||
<p className="text-[9px] text-ink-soft mt-1 leading-relaxed">
|
||||
{selected?.wildcard
|
||||
? wildcardHelpText(selected)
|
||||
: "Free-text model id. Make sure the provider can resolve it."}
|
||||
</p>
|
||||
{!selected?.wildcard && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Switch back to dropdown by setting model to first
|
||||
// concrete option.
|
||||
if (selected) {
|
||||
handleModelChange(selected.models[0]?.id ?? "");
|
||||
}
|
||||
}}
|
||||
className="text-[9px] text-accent hover:text-accent mt-0.5"
|
||||
>
|
||||
← back to model list
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<select
|
||||
id={modelSelectId}
|
||||
value={
|
||||
value.model && selected?.models.some((m) => m.id === value.model)
|
||||
? value.model
|
||||
: ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === CUSTOM_MODEL_SENTINEL) {
|
||||
handleModelChange(CUSTOM_MODEL_SENTINEL);
|
||||
} else {
|
||||
handleModelChange(e.target.value);
|
||||
}
|
||||
}}
|
||||
disabled={disabled || !selected || selected.models.length === 0}
|
||||
data-testid="model-select"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<option value="" disabled>
|
||||
{selected ? "— select model —" : "— select provider first —"}
|
||||
</option>
|
||||
{selected?.models
|
||||
.filter((m) => !m.id.includes("*"))
|
||||
.map((m) => (
|
||||
<option
|
||||
key={m.id}
|
||||
value={m.id}
|
||||
title={m.name ?? m.id}
|
||||
>
|
||||
{m.name ?? m.id}
|
||||
</option>
|
||||
))}
|
||||
{allowCustomModelEscape && selected && (
|
||||
<option value={CUSTOM_MODEL_SENTINEL}>
|
||||
Custom (type model id)…
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function wildcardPlaceholder(p: ProviderEntry): string {
|
||||
const example = p.models.find((m) => m.id.includes("*"))?.id ?? "";
|
||||
if (!example) return "type any model id";
|
||||
// Strip trailing star — show the pattern as a hint.
|
||||
const prefix = example.replace(/\*$/, "");
|
||||
switch (p.vendor) {
|
||||
case "huggingface":
|
||||
return `e.g. ${prefix}meta-llama/Meta-Llama-3-70B-Instruct`;
|
||||
case "openrouter":
|
||||
return `e.g. ${prefix}anthropic/claude-3.5-sonnet`;
|
||||
case "custom":
|
||||
return `e.g. ${prefix}my-local-model`;
|
||||
default:
|
||||
return `e.g. ${prefix}<model-id>`;
|
||||
}
|
||||
}
|
||||
|
||||
function wildcardHelpText(p: ProviderEntry): string {
|
||||
switch (p.vendor) {
|
||||
case "huggingface":
|
||||
return "Any model hosted on Hugging Face Inference. Browse at huggingface.co/models?inference=warm.";
|
||||
case "openrouter":
|
||||
return "Any of OpenRouter's 200+ routed models. Browse at openrouter.ai/models.";
|
||||
case "custom":
|
||||
return "Self-hosted endpoint. Configure base_url in your workspace's runtime config (no API key required).";
|
||||
case "ai-gateway":
|
||||
return "Vercel AI Gateway model id. See vercel.com/docs/ai-gateway.";
|
||||
case "opencode-zen":
|
||||
return "OpenCode Zen model id. See opencode.zen.";
|
||||
default:
|
||||
return "Wildcard provider — type the model id in full. Provider routes by id prefix.";
|
||||
}
|
||||
}
|
||||
@@ -2,37 +2,12 @@
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { pruneStaleKeys } from "./canvas/useCanvasViewport";
|
||||
import { api } from "@/lib/api";
|
||||
import { showToast } from "./Toaster";
|
||||
import { ConsoleModal } from "./ConsoleModal";
|
||||
|
||||
import {
|
||||
DEFAULT_RUNTIME_PROFILE,
|
||||
provisionTimeoutForRuntime,
|
||||
} from "@/lib/runtimeProfiles";
|
||||
|
||||
/** Re-export for backward compatibility with tests and other importers
|
||||
* that previously imported DEFAULT_PROVISION_TIMEOUT_MS from this file.
|
||||
* New code should read via getRuntimeProfile() from @/lib/runtimeProfiles. */
|
||||
export const DEFAULT_PROVISION_TIMEOUT_MS =
|
||||
DEFAULT_RUNTIME_PROFILE.provisionTimeoutMs;
|
||||
|
||||
/** The server provisions up to `PROVISION_CONCURRENCY` containers at
|
||||
* once and paces the rest in a queue (`workspaceCreatePacingMs` =
|
||||
* 2s). Mirrors the Go constants — if those change, bump these. */
|
||||
const PROVISION_CONCURRENCY = 3;
|
||||
const PER_QUEUE_SLOT_EXTRA_MS = 45_000; // ~45s head-room per queued workspace
|
||||
|
||||
/** Scale the base timeout by how many workspaces are provisioning at
|
||||
* once. A 30-workspace org import has tail items that legitimately
|
||||
* wait minutes before Docker even starts on them — flagging each as
|
||||
* "stuck" after 2m creates a wall of 27 yellow banners that buries
|
||||
* the canvas. */
|
||||
function effectiveTimeoutMs(base: number, concurrentCount: number): number {
|
||||
const overflow = Math.max(0, concurrentCount - PROVISION_CONCURRENCY);
|
||||
return base + overflow * PER_QUEUE_SLOT_EXTRA_MS;
|
||||
}
|
||||
/** Default provisioning timeout in milliseconds (2 minutes). */
|
||||
export const DEFAULT_PROVISION_TIMEOUT_MS = 120_000;
|
||||
|
||||
interface TimeoutEntry {
|
||||
workspaceId: string;
|
||||
@@ -50,65 +25,29 @@ interface TimeoutEntry {
|
||||
* time per node.
|
||||
*/
|
||||
export function ProvisioningTimeout({
|
||||
timeoutMs,
|
||||
timeoutMs = DEFAULT_PROVISION_TIMEOUT_MS,
|
||||
}: {
|
||||
// If undefined (the default when mounted without a prop), each workspace's
|
||||
// threshold is resolved from its runtime via timeoutForRuntime().
|
||||
// Pass an explicit number to force a single threshold for every workspace
|
||||
// (used by tests that want deterministic behavior regardless of runtime).
|
||||
timeoutMs?: number;
|
||||
}) {
|
||||
const [timedOut, setTimedOut] = useState<TimeoutEntry[]>([]);
|
||||
const [retrying, setRetrying] = useState<Set<string>>(new Set());
|
||||
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
|
||||
const trackingRef = useRef<Map<string, number>>(new Map());
|
||||
// Workspaces the user explicitly dismissed — don't re-show their
|
||||
// banner even if they stay in provisioning. Cleared when the
|
||||
// workspace leaves provisioning (status changes).
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(new Set());
|
||||
// Watch the live WS health. While it's not "connected", local node
|
||||
// status reflects the last event we received before the drop —
|
||||
// workspaces may have actually transitioned to online minutes ago.
|
||||
// Suppress the banner until WS recovers + rehydrate confirms each
|
||||
// workspace is genuinely still provisioning.
|
||||
const wsStatus = useCanvasStore((s) => s.wsStatus);
|
||||
|
||||
// Subscribe to provisioning nodes — use shallow compare to avoid infinite re-render
|
||||
// (filter+map creates new array reference on every store update).
|
||||
// Runtime included so the timeout threshold can be resolved per-node
|
||||
// (hermes cold-boot legitimately takes 8-13 min vs 30-90s for docker
|
||||
// runtimes — a single threshold would false-alarm on one or the other).
|
||||
// provisionTimeoutMs added by #2054 — server-declared per-workspace
|
||||
// override that wins over the runtime profile when present.
|
||||
// Separator: `|` between fields, `,` between nodes. Only `name` is
|
||||
// user-typed (gets sanitized below); the other fields are
|
||||
// primitive-typed (id is a UUID, runtime is a [a-z-]+ slug,
|
||||
// provisionTimeoutMs is numeric). If a future field is string-typed,
|
||||
// extend the sanitize step to strip `|` + `,` from it too.
|
||||
// Empty-string sentinels for missing values so split/index stays positional.
|
||||
// (filter+map creates new array reference on every store update)
|
||||
const provisioningNodes = useCanvasStore((s) => {
|
||||
const result = s.nodes
|
||||
.filter((n) => n.data.status === "provisioning")
|
||||
.map((n) => {
|
||||
const safeName = (n.data.name ?? "").replace(/[|,]/g, " ");
|
||||
const runtime = n.data.runtime ?? "";
|
||||
const provisionTimeoutMs = n.data.provisionTimeoutMs ?? "";
|
||||
return `${n.id}|${safeName}|${runtime}|${provisionTimeoutMs}`;
|
||||
});
|
||||
.map((n) => `${n.id}:${n.data.name}`);
|
||||
return result.join(",");
|
||||
});
|
||||
const parsedProvisioningNodes = useMemo(
|
||||
() =>
|
||||
provisioningNodes
|
||||
? provisioningNodes.split(",").map((entry) => {
|
||||
const [id, name, runtime, provisionTimeoutMs] = entry.split("|");
|
||||
const ptms = provisionTimeoutMs ? Number(provisionTimeoutMs) : undefined;
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
runtime,
|
||||
provisionTimeoutMs: Number.isFinite(ptms) ? ptms : undefined,
|
||||
};
|
||||
const [id, name] = entry.split(":");
|
||||
return { id, name };
|
||||
})
|
||||
: [],
|
||||
[provisioningNodes],
|
||||
@@ -126,52 +65,23 @@ export function ProvisioningTimeout({
|
||||
|
||||
// Remove tracking for nodes that are no longer provisioning
|
||||
const activeIds = new Set(parsedProvisioningNodes.map((n) => n.id));
|
||||
pruneStaleKeys(tracking, activeIds);
|
||||
|
||||
// Also remove from timedOut list if no longer provisioning, and
|
||||
// clear `dismissed` entries for workspaces that finished so a
|
||||
// re-provision (e.g. retry) can surface a fresh banner.
|
||||
setTimedOut((prev) => prev.filter((e) => activeIds.has(e.workspaceId)));
|
||||
setDismissed((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set(prev);
|
||||
for (const id of prev) {
|
||||
if (!activeIds.has(id)) {
|
||||
next.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
for (const id of tracking.keys()) {
|
||||
if (!activeIds.has(id)) {
|
||||
tracking.delete(id);
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}
|
||||
|
||||
// Also remove from timedOut list if no longer provisioning
|
||||
setTimedOut((prev) => prev.filter((e) => activeIds.has(e.workspaceId)));
|
||||
|
||||
// Interval to check for timeouts
|
||||
const interval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
const newTimedOut: TimeoutEntry[] = [];
|
||||
|
||||
// Per-node timeout: each workspace resolves its own base via
|
||||
// @/lib/runtimeProfiles (server-override → runtime profile →
|
||||
// default), then scales by concurrent-provisioning count. A
|
||||
// hermes workspace in a batch alongside two langgraph workspaces
|
||||
// gets hermes's 12-min base, not langgraph's 2-min base.
|
||||
//
|
||||
// Resolution priority (most specific wins):
|
||||
// 1. node.provisionTimeoutMs — server-declared per-workspace
|
||||
// override (#2054, sourced from template manifest)
|
||||
// 2. timeoutMs prop — single-threshold test override
|
||||
// 3. runtime profile in @/lib/runtimeProfiles
|
||||
// 4. DEFAULT_RUNTIME_PROFILE
|
||||
for (const node of parsedProvisioningNodes) {
|
||||
const startedAt = tracking.get(node.id);
|
||||
if (!startedAt) continue;
|
||||
const base = provisionTimeoutForRuntime(node.runtime, {
|
||||
provisionTimeoutMs: node.provisionTimeoutMs ?? timeoutMs,
|
||||
});
|
||||
const effective = effectiveTimeoutMs(
|
||||
base,
|
||||
parsedProvisioningNodes.length,
|
||||
);
|
||||
if (now - startedAt >= effective) {
|
||||
if (startedAt && now - startedAt >= timeoutMs) {
|
||||
newTimedOut.push({
|
||||
workspaceId: node.id,
|
||||
workspaceName: node.name,
|
||||
@@ -194,11 +104,6 @@ export function ProvisioningTimeout({
|
||||
return () => clearInterval(interval);
|
||||
}, [parsedProvisioningNodes, timeoutMs]);
|
||||
|
||||
const handleDismiss = useCallback((workspaceId: string) => {
|
||||
setDismissed((prev) => new Set(prev).add(workspaceId));
|
||||
setTimedOut((prev) => prev.filter((e) => e.workspaceId !== workspaceId));
|
||||
}, []);
|
||||
|
||||
const RETRY_COOLDOWN_MS = 5_000;
|
||||
const [retryCooldown, setRetryCooldown] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -275,19 +180,11 @@ export function ProvisioningTimeout({
|
||||
setConsoleFor(workspaceId);
|
||||
}, []);
|
||||
|
||||
const visibleTimedOut = useMemo(
|
||||
() =>
|
||||
wsStatus === "connected"
|
||||
? timedOut.filter((e) => !dismissed.has(e.workspaceId))
|
||||
: [],
|
||||
[timedOut, dismissed, wsStatus],
|
||||
);
|
||||
|
||||
if (visibleTimedOut.length === 0) return null;
|
||||
if (timedOut.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div role="alert" aria-live="assertive" className="fixed top-14 left-1/2 -translate-x-1/2 z-40 flex flex-col gap-2 max-w-[480px] w-full px-4">
|
||||
{visibleTimedOut.map((entry) => {
|
||||
{timedOut.map((entry) => {
|
||||
const elapsed = Math.round((Date.now() - entry.startedAt) / 1000);
|
||||
const isRetrying = retrying.has(entry.workspaceId);
|
||||
const isCancelling = cancelling.has(entry.workspaceId);
|
||||
@@ -299,8 +196,8 @@ export function ProvisioningTimeout({
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Warning icon */}
|
||||
<div aria-hidden="true" className="w-8 h-8 rounded-lg bg-amber-600/20 border border-amber-500/30 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-600/20 border border-amber-500/30 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path
|
||||
d="M8 2L14 13H2L8 2Z"
|
||||
stroke="#fbbf24"
|
||||
@@ -313,32 +210,19 @@ export function ProvisioningTimeout({
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-0.5 gap-2">
|
||||
<div className="text-[12px] font-semibold text-amber-200">
|
||||
Provisioning Timeout
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDismiss(entry.workspaceId)}
|
||||
aria-label="Dismiss provisioning timeout warning"
|
||||
title="Dismiss — keep this workspace running without the warning"
|
||||
className="shrink-0 text-warm/60 hover:text-amber-200 transition-colors -mr-1"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="text-[12px] font-semibold text-amber-200 mb-0.5">
|
||||
Provisioning Timeout
|
||||
</div>
|
||||
<div className="text-[11px] text-warm/80 leading-relaxed">
|
||||
<div className="text-[11px] text-amber-300/80 leading-relaxed">
|
||||
<span className="font-medium text-amber-200">{entry.workspaceName}</span>{" "}
|
||||
has been provisioning for{" "}
|
||||
<span className="font-mono text-warm">{formatDuration(elapsed)}</span>.
|
||||
<span className="font-mono text-amber-300">{formatDuration(elapsed)}</span>.
|
||||
It may have encountered an issue.
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 mt-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRetry(entry.workspaceId)}
|
||||
disabled={isRetrying || isCancelling || retryCooldown.has(entry.workspaceId)}
|
||||
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors"
|
||||
@@ -346,17 +230,15 @@ export function ProvisioningTimeout({
|
||||
{isRetrying ? "Retrying..." : retryCooldown.has(entry.workspaceId) ? "Wait..." : "Retry"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCancelRequest(entry.workspaceId)}
|
||||
disabled={isRetrying || isCancelling}
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-[11px] text-ink-mid rounded-lg border border-line disabled:opacity-40 transition-colors"
|
||||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-[11px] text-zinc-300 rounded-lg border border-zinc-600 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
{isCancelling ? "Cancelling..." : "Cancel"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleViewLogs(entry.workspaceId)}
|
||||
className="px-3 py-1.5 text-[11px] text-warm hover:text-warm transition-colors"
|
||||
className="px-3 py-1.5 text-[11px] text-amber-400 hover:text-amber-300 transition-colors"
|
||||
>
|
||||
View Logs
|
||||
</button>
|
||||
@@ -370,24 +252,22 @@ export function ProvisioningTimeout({
|
||||
{/* Cancel confirmation dialog */}
|
||||
{confirmingCancel && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div aria-hidden="true" className="absolute inset-0 bg-black/60" onClick={() => setConfirmingCancel(null)} />
|
||||
<div className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl p-5 max-w-[340px] w-full mx-4">
|
||||
<h3 className="text-sm font-semibold text-ink mb-2">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={() => setConfirmingCancel(null)} />
|
||||
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl p-5 max-w-[340px] w-full mx-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-100 mb-2">
|
||||
Cancel deployment?
|
||||
</h3>
|
||||
<p className="text-[12px] text-ink-mid mb-4 leading-relaxed">
|
||||
<p className="text-[12px] text-zinc-400 mb-4 leading-relaxed">
|
||||
This will permanently remove the workspace. This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingCancel(null)}
|
||||
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
|
||||
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelConfirm}
|
||||
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors"
|
||||
>
|
||||
|
||||
@@ -36,6 +36,11 @@ export function SearchDialog() {
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// Reset focused index when query changes
|
||||
useEffect(() => {
|
||||
setFocusedIndex(-1);
|
||||
}, [query]);
|
||||
|
||||
const filtered = nodes.filter((n) => {
|
||||
if (!query) return true;
|
||||
const q = query.toLowerCase();
|
||||
@@ -46,18 +51,6 @@ export function SearchDialog() {
|
||||
);
|
||||
});
|
||||
|
||||
// Auto-highlight the first match while the user is typing, so Enter
|
||||
// selects something instead of being a no-op. With an empty query we
|
||||
// keep -1 so opening the dialog (which shows ALL workspaces) doesn't
|
||||
// visually pin one row arbitrarily — only commit a highlight once the
|
||||
// user has narrowed the list.
|
||||
useEffect(() => {
|
||||
setFocusedIndex(query && filtered.length > 0 ? 0 : -1);
|
||||
// Re-running on filtered.length keeps the highlight pinned to the
|
||||
// first row while the result set shrinks/grows; the effect handler
|
||||
// above already short-circuits to -1 when results disappear.
|
||||
}, [query, filtered.length]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(nodeId: string) => {
|
||||
selectNode(nodeId);
|
||||
@@ -99,12 +92,12 @@ export function SearchDialog() {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search workspaces"
|
||||
className="w-[420px] bg-surface/95 backdrop-blur-xl border border-line/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
|
||||
className="w-[420px] bg-zinc-950/95 backdrop-blur-xl border border-zinc-800/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-line/40">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-ink-soft" aria-hidden="true">
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-zinc-800/40">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-zinc-500" aria-hidden="true">
|
||||
<circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M11 11l3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
@@ -120,9 +113,9 @@ export function SearchDialog() {
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder="Search workspaces..."
|
||||
className="flex-1 bg-transparent text-sm text-ink placeholder-ink-soft focus:outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent rounded"
|
||||
className="flex-1 bg-transparent text-sm text-zinc-100 placeholder-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus:outline-none rounded"
|
||||
/>
|
||||
<kbd className="text-[9px] text-ink-mid bg-surface-card/60 px-1.5 py-0.5 rounded border border-line/40">ESC</kbd>
|
||||
<kbd className="text-[9px] text-zinc-400 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">ESC</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
@@ -133,20 +126,19 @@ export function SearchDialog() {
|
||||
className="max-h-[300px] overflow-y-auto py-1"
|
||||
>
|
||||
{filtered.length === 0 ? (
|
||||
<div role="status" aria-live="polite" className="px-4 py-6 text-center text-xs text-ink-mid">
|
||||
<div role="status" aria-live="polite" className="px-4 py-6 text-center text-xs text-zinc-400">
|
||||
{query ? "No workspaces match" : "No workspaces yet"}
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((node, index) => (
|
||||
<button
|
||||
type="button"
|
||||
key={node.id}
|
||||
id={`search-result-${node.id}`}
|
||||
role="option"
|
||||
aria-selected={index === focusedIndex}
|
||||
onClick={() => handleSelect(node.id)}
|
||||
className={`w-full px-4 py-2.5 flex items-center gap-3 text-left transition-colors ${
|
||||
index === focusedIndex ? "bg-surface-card/60" : "hover:bg-surface-card/40"
|
||||
index === focusedIndex ? "bg-zinc-800/60" : "hover:bg-zinc-800/40"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
@@ -154,13 +146,13 @@ export function SearchDialog() {
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${statusDotClass(node.data.status)}`}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm text-ink truncate">{node.data.name}</div>
|
||||
<div className="text-sm text-zinc-200 truncate">{node.data.name}</div>
|
||||
{node.data.role && (
|
||||
<div className="text-[10px] text-ink-soft truncate">{node.data.role}</div>
|
||||
<div className="text-[10px] text-zinc-500 truncate">{node.data.role}</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className="text-[9px] font-mono text-ink-mid"
|
||||
className="text-[9px] font-mono text-zinc-400"
|
||||
aria-label={`Tier ${node.data.tier}`}
|
||||
>
|
||||
T{node.data.tier}
|
||||
@@ -171,11 +163,11 @@ export function SearchDialog() {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-4 py-2 border-t border-line/40 flex items-center justify-between">
|
||||
<span className="text-[9px] text-ink-mid">{filtered.length} workspace{filtered.length !== 1 ? "s" : ""}</span>
|
||||
<div className="px-4 py-2 border-t border-zinc-800/40 flex items-center justify-between">
|
||||
<span className="text-[9px] text-zinc-400">{filtered.length} workspace{filtered.length !== 1 ? "s" : ""}</span>
|
||||
<div className="flex gap-2">
|
||||
<kbd className="text-[9px] text-ink-mid bg-surface-card/60 px-1.5 py-0.5 rounded border border-line/40">↑↓ navigate</kbd>
|
||||
<kbd className="text-[9px] text-ink-mid bg-surface-card/60 px-1.5 py-0.5 rounded border border-line/40">↵ select</kbd>
|
||||
<kbd className="text-[9px] text-zinc-400 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">↑↓ navigate</kbd>
|
||||
<kbd className="text-[9px] text-zinc-400 bg-zinc-800/60 px-1.5 py-0.5 rounded border border-zinc-700/40">↵ select</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user