Merge remote-tracking branch 'origin/staging' into auto/issue-2312-pr-b-workspace-ingest

This commit is contained in:
Hongming Wang 2026-04-29 16:18:01 -07:00
commit 83e3fe436f
2 changed files with 342 additions and 44 deletions

View File

@ -63,29 +63,42 @@ jobs:
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"
# 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:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
- 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- if: needs.changes.outputs.platform == 'true'
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
- run: go mod download
- run: go build ./cmd/server
- 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
- run: go vet ./... || true
- name: Run golangci-lint
- 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
- name: Run tests with race detection and coverage
- if: needs.changes.outputs.platform == 'true'
name: Run tests with race detection and coverage
run: go test -race -coverprofile=coverage.out ./...
- name: Per-file coverage report
- 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
@ -98,7 +111,8 @@ jobs:
END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \
| sort -n
- name: Check coverage thresholds
- 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
@ -178,42 +192,38 @@ jobs:
exit 1
fi
# Path-filter no-op shadow for Canvas (Next.js).
# Canvas (Next.js) — required check, always runs. See platform-build
# comment above for the rationale.
#
# Branch protection on staging requires a "Canvas (Next.js)" check.
# When a PR doesn't touch canvas/** paths, the real canvas-build job
# below is skipped via `if:`, and GitHub reports its conclusion as
# SKIPPED — which branch protection treats as not-passed → merge
# BLOCKED on every workspace-server-only or migration-only PR.
#
# Pattern (per durable feedback memory: branch_protection_check_name_parity):
# split into a real job + a no-op shadow that share the same `name:`.
# Exactly one runs per PR; both report the same check context, and at
# least one always reports SUCCESS, satisfying the required check.
canvas-build-noop:
name: Canvas (Next.js)
needs: changes
if: needs.changes.outputs.canvas != 'true'
runs-on: ubuntu-latest
steps:
- run: echo "No canvas/** changes in this PR — Canvas (Next.js) skip is intentional, satisfying required-check via this no-op."
# 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:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
- 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- if: needs.changes.outputs.canvas == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- run: rm -f package-lock.json && npm install
- run: npm run build
- name: Run tests with coverage
- 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
@ -224,7 +234,7 @@ jobs:
# thresholds + a hard gate" — this PR ships the observability half.
run: npx vitest run --coverage
- name: Upload coverage summary as artifact
if: always()
if: needs.changes.outputs.canvas == 'true' && always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: canvas-coverage-${{ github.run_id }}
@ -240,14 +250,19 @@ jobs:
# 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:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh
- 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- 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
@ -301,10 +316,11 @@ 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
@ -312,16 +328,23 @@ jobs:
run:
working-directory: workspace
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- if: needs.changes.outputs.python == 'true'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'
cache: pip
cache-dependency-path: workspace/requirements.txt
- run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov
- 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.
- run: python -m pytest --tb=short
- if: needs.changes.outputs.python == 'true'
run: python -m pytest --tb=short
# SDK + plugin validation moved to standalone repo:
# github.com/Molecule-AI/molecule-sdk-python

View File

@ -0,0 +1,275 @@
#!/usr/bin/env bash
# Staging E2E for #2307 — create fresh tenant, test peer visibility, tear down.
#
# Mirrors tests/e2e/test_staging_full_saas.sh's pattern (org create via
# /cp/admin/orgs, EXIT-trap teardown via DELETE /cp/admin/tenants/:slug
# with required {"confirm":slug} body).
#
# Required: MOLECULE_ADMIN_TOKEN exported (CP admin bearer).
# Optional:
# MOLECULE_CP_URL default https://staging-api.moleculesai.app
# PARENT_RUNTIME default claude-code
set -uo pipefail
CP_URL="${MOLECULE_CP_URL:-https://staging-api.moleculesai.app}"
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:?MOLECULE_ADMIN_TOKEN required}"
PARENT_RUNTIME="${PARENT_RUNTIME:-claude-code}"
RUN_ID=$(date +%s | tail -c 8)
SLUG="e2e-2307-$RUN_ID"
ORG_ID=""
TENANT_URL=""
TENANT_TOKEN=""
PARENT=""
CHILD=""
CTOK=""
admin_call() {
local method="$1" path="$2"
shift 2
curl -sS -X "$method" "$CP_URL$path" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
"$@"
}
tenant_call() {
local method="$1" path="$2"
shift 2
curl -sS -X "$method" "$TENANT_URL$path" \
-H "Authorization: Bearer $TENANT_TOKEN" \
-H "X-Molecule-Org-Id: $ORG_ID" \
-H "Content-Type: application/json" \
"$@"
}
teardown() {
local rc=$?
set +e
echo ""
echo "[teardown] DELETE /cp/admin/tenants/$SLUG ..."
admin_call DELETE "/cp/admin/tenants/$SLUG" \
--max-time 120 \
-d "{\"confirm\":\"$SLUG\"}" >/dev/null 2>&1
# Poll up to 60s for purge
for j in $(seq 1 12); do
LIST=$(admin_call GET /cp/admin/orgs 2>/dev/null)
LEAK=$(echo "$LIST" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
print(1); sys.exit(0)
orgs = d if isinstance(d, list) else d.get('orgs', [])
n = sum(1 for o in orgs if o.get('slug') == '$SLUG' and o.get('status') != 'purged')
print(n)
" 2>/dev/null || echo 1)
if [ "$LEAK" = "0" ]; then
echo " ✓ tenant purged (after ${j}x5s)"
exit $rc
fi
sleep 5
done
echo " ⚠ LEAK: $SLUG still in /cp/admin/orgs after 60s — manual cleanup needed"
[ $rc -eq 0 ] && rc=4
exit $rc
}
trap teardown EXIT INT TERM
# ─── 1. Create the org ────────────────────────────────────────────────
echo "[1/8] POST /cp/admin/orgs — slug=$SLUG"
CREATE=$(admin_call POST /cp/admin/orgs \
-d "{\"slug\":\"$SLUG\",\"name\":\"E2E #2307 $SLUG\",\"owner_user_id\":\"e2e-runner:$SLUG\"}")
echo " resp: $(echo "$CREATE" | head -c 300)"
ORG_ID=$(echo "$CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
[ -n "$ORG_ID" ] || { echo " ✗ org creation failed"; exit 1; }
echo " ✓ ORG_ID=$ORG_ID"
# ─── 2. Wait for tenant ready ─────────────────────────────────────────
echo "[2/8] waiting for tenant to come up (cold-start ~5-10min)..."
for i in $(seq 1 180); do
STATUS=$(admin_call GET /cp/admin/orgs 2>/dev/null | python3 -c "
import sys, json
try: d = json.load(sys.stdin)
except Exception: sys.exit(0)
orgs = d if isinstance(d, list) else d.get('orgs', [])
for o in orgs:
if o.get('slug') == '$SLUG':
print(o.get('instance_status') or o.get('status') or 'unknown')
break
" 2>/dev/null)
[ $((i % 6)) -eq 1 ] && echo " attempt $i: status=$STATUS"
case "$STATUS" in running|online|ready) break ;; esac
sleep 5
done
case "$STATUS" in running|online|ready) ;;
*) echo " ✗ tenant never came up (last=$STATUS)"; exit 2 ;; esac
echo " ✓ tenant status=$STATUS"
# ─── 3. Per-tenant admin token ────────────────────────────────────────
echo "[3/8] fetching per-tenant admin token..."
TT_RESP=$(admin_call GET "/cp/admin/orgs/$SLUG/admin-token")
TENANT_TOKEN=$(echo "$TT_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('admin_token',''))" 2>/dev/null)
[ -n "$TENANT_TOKEN" ] || { echo " ✗ tenant token fetch failed: $TT_RESP"; exit 2; }
echo " ✓ got tenant admin token (len ${#TENANT_TOKEN})"
CP_HOST=$(echo "$CP_URL" | sed -E 's#^https?://##; s#/.*$##')
case "$CP_HOST" in
api.*) DERIVED_DOMAIN="${CP_HOST#api.}" ;;
staging-api.*) DERIVED_DOMAIN="staging.${CP_HOST#staging-api.}" ;;
*) DERIVED_DOMAIN="$CP_HOST" ;;
esac
TENANT_URL="https://${SLUG}.${DERIVED_DOMAIN}"
echo " tenant url: $TENANT_URL"
# ─── 4. Wait for tenant TLS/DNS readiness ─────────────────────────────
echo "[4/8] waiting for tenant /health (TLS/DNS, up to 10min)..."
for i in $(seq 1 120); do
if curl -fsS "$TENANT_URL/health" -m 5 -k >/dev/null 2>&1; then
echo " ✓ /health ok (attempt $i)"
break
fi
sleep 5
done
# ─── 5. Provision parent CEO workspace ────────────────────────────────
echo "[5/8] creating parent CEO ($PARENT_RUNTIME)..."
P_RESP=$(tenant_call POST /workspaces \
-d "{\"name\":\"e2e-CEO\",\"runtime\":\"$PARENT_RUNTIME\",\"tier\":3}")
echo " parent resp: $(echo "$P_RESP" | head -c 300)"
PARENT=$(echo "$P_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
PTOK=$(echo "$P_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('auth_token',''))" 2>/dev/null)
[ -n "$PARENT" ] || { echo " ✗ parent create failed"; exit 3; }
echo " ✓ PARENT=$PARENT (parent_token_returned=$([ -n "$PTOK" ] && echo yes || echo no))"
# ─── 6. Wait for parent online ────────────────────────────────────────
echo "[6/8] waiting for parent to come online (up to 12min)..."
for i in $(seq 1 144); do
WS_JSON=$(tenant_call GET "/workspaces/$PARENT" 2>/dev/null)
S=$(echo "$WS_JSON" | python3 -c "
import sys, json
try: d = json.load(sys.stdin)
except Exception: sys.exit(0)
w = d.get('workspace') if isinstance(d.get('workspace'), dict) else d
print(w.get('status') or '')
" 2>/dev/null)
[ $((i % 6)) -eq 1 ] && echo " attempt $i: parent status=$S"
[ "$S" = "online" ] && break
sleep 5
done
[ "$S" = "online" ] || { echo " ✗ parent never online (last=$S)"; exit 3; }
echo " ✓ parent online"
# ─── 7. Create external child + register URL ──────────────────────────
echo "[7/8] creating external child + registering..."
C_RESP=$(tenant_call POST /workspaces \
-d "{\"name\":\"e2e-Reno-Server\",\"runtime\":\"external\",\"external\":true,\"tier\":2,\"parent_id\":\"$PARENT\"}")
echo " child resp: $(echo "$C_RESP" | head -c 400)"
CHILD=$(echo "$C_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
# External-runtime token is nested under `connection.auth_token` (verified
# 2026-04-29 against staging response shape). Fall back to top-level for
# parity with older clients.
CTOK=$(echo "$C_RESP" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print(d.get('connection', {}).get('auth_token') or d.get('auth_token') or '')
" 2>/dev/null)
[ -n "$CHILD" ] || { echo " ✗ child create failed"; exit 3; }
echo " ✓ CHILD=$CHILD (child_token_returned=$([ -n "$CTOK" ] && echo yes || echo no))"
# Try register with child's own token (bootstrap path); fall back to tenant_call
if [ -n "$CTOK" ]; then
REG_RESP=$(curl -sS -X POST "$TENANT_URL/registry/register" \
-H "Authorization: Bearer $CTOK" \
-H "X-Molecule-Org-Id: $ORG_ID" \
-H "Content-Type: application/json" \
-d "{\"id\":\"$CHILD\",\"url\":\"https://example.com/molecule-test\",\"agent_card\":{\"name\":\"Reno Server\",\"description\":\"Mock\",\"version\":\"0.1.0\"}}")
else
REG_RESP=$(tenant_call POST /registry/register \
-d "{\"id\":\"$CHILD\",\"url\":\"https://example.com/molecule-test\",\"agent_card\":{\"name\":\"Reno Server\",\"description\":\"Mock\",\"version\":\"0.1.0\"}}")
fi
echo " register resp: $(echo "$REG_RESP" | head -c 300)"
# ─── 8. THE TEST — peer visibility ────────────────────────────────────
echo ""
echo "[8/8] === Verdict — does parent see external child? ==="
echo ""
echo "(a) DB shape via admin: GET /cp/admin/orgs/$SLUG (workspaces listing if exposed)"
# Check children listing — most direct DB-shape signal we can get from outside
LIST=$(tenant_call GET "/workspaces?parent_id=$PARENT")
echo " /workspaces?parent_id=$PARENT response: $(echo "$LIST" | head -c 500)"
echo ""
CHILD_LISTED=$(echo "$LIST" | python3 -c "
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
print('parse_error'); sys.exit(0)
ws = d if isinstance(d, list) else d.get('workspaces', d.get('items', []))
print('yes' if any(w.get('id') == '$CHILD' for w in ws) else 'no')
" 2>/dev/null)
echo " child appears in parent's children listing: $CHILD_LISTED"
# (b) /peers from PARENT side using PTOK if provided
if [ -n "$PTOK" ]; then
PEERS=$(curl -sS "$TENANT_URL/registry/$PARENT/peers" \
-H "Authorization: Bearer $PTOK" \
-H "X-Molecule-Org-Id: $ORG_ID")
echo ""
echo "(b) GET /registry/$PARENT/peers (parent's bearer):"
echo " $(echo "$PEERS" | head -c 600)"
if echo "$PEERS" | grep -q "$CHILD"; then
echo " ✓ child IS in parent's /peers"
VERDICT_B=ok
else
echo " ✗ child is NOT in parent's /peers — bug REPRODUCES at API layer"
VERDICT_B=fail
fi
else
echo ""
echo "(b) parent's auth_token not exposed by /workspaces create — skipping direct /peers check"
VERDICT_B=skipped
fi
# (c) /peers from CHILD side using CTOK
if [ -n "$CTOK" ]; then
PEERS_C=$(curl -sS "$TENANT_URL/registry/$CHILD/peers" \
-H "Authorization: Bearer $CTOK" \
-H "X-Molecule-Org-Id: $ORG_ID")
echo ""
echo "(c) GET /registry/$CHILD/peers (child's bearer):"
echo " $(echo "$PEERS_C" | head -c 600)"
if echo "$PEERS_C" | grep -q "$PARENT"; then
echo " ✓ parent IS in child's /peers"
VERDICT_C=ok
else
echo " ✗ parent is NOT in child's /peers"
VERDICT_C=fail
fi
else
VERDICT_C=skipped
fi
echo ""
echo "=== SUMMARY for #2307 staging E2E ==="
echo " child listed under parent: $CHILD_LISTED"
echo " /peers parent→child: $VERDICT_B"
echo " /peers child→parent: $VERDICT_C"
# Exit code: 0 if everything visible, 10 if bug reproduces, 11 if inconclusive
if [ "$CHILD_LISTED" = "yes" ] && [ "$VERDICT_B" = "ok" ]; then
echo ""
echo "✓ STAGING: parent fully sees external child — bug is downstream (agent code, not platform API)"
exit 0
elif [ "$VERDICT_B" = "fail" ] || [ "$CHILD_LISTED" = "no" ]; then
echo ""
echo "✗ STAGING: bug REPRODUCES at platform-API layer"
exit 10
else
echo ""
echo "? STAGING: inconclusive (need parent token to call /peers definitively)"
exit 11
fi