diff --git a/.gitea/workflows/e2e-legacy-advisory.yml b/.gitea/workflows/e2e-legacy-advisory.yml new file mode 100644 index 000000000..aeeb83f07 --- /dev/null +++ b/.gitea/workflows/e2e-legacy-advisory.yml @@ -0,0 +1,242 @@ +name: E2E Legacy Advisory + +# Advisory lane for older/manual E2E scripts that are too broad or +# environment-dependent for required PR CI. This intentionally does not run on +# pull_request or push so it cannot block merges/deploys; scheduled/manual reds +# still surface drift in scripts that would otherwise only be shellchecked. +# +# Gitea 1.22.6 rejects workflow_dispatch.inputs, so keep dispatch input-free. + +on: + schedule: + # Stagger after the staging smoke/canvas morning lanes. + - cron: '15 9 * * *' + workflow_dispatch: + +concurrency: + group: e2e-legacy-advisory + cancel-in-progress: false + +permissions: + contents: read + +env: + GITHUB_SERVER_URL: https://git.moleculesai.app + +jobs: + legacy-local-platform: + name: Legacy local-platform E2E + runs-on: docker-host + timeout-minutes: 45 + env: + PG_CONTAINER: pg-e2e-legacy-${{ github.run_id }}-${{ github.run_attempt }} + REDIS_CONTAINER: redis-e2e-legacy-${{ github.run_id }}-${{ github.run_attempt }} + MOLECULE_ENV: development + BIND_ADDR: 127.0.0.1 + MOLECULE_IN_DOCKER: "false" + A2A_TIMEOUT: "30" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version: 'stable' + cache: true + cache-dependency-path: workspace-server/go.sum + + - name: Prepare local platform dependencies + run: | + set -euo pipefail + docker pull postgres:16 >/dev/null + docker pull redis:7 >/dev/null + docker pull alpine:latest >/dev/null + docker network create molecule-core-net >/dev/null 2>&1 || true + + - name: Start Postgres + run: | + set -euo pipefail + 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 0:5432 postgres:16 >/dev/null + PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}') + if [ -z "$PG_PORT" ]; then + PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}') + fi + if [ -z "$PG_PORT" ]; then + echo "::error::Could not resolve host port for $PG_CONTAINER" + docker port "$PG_CONTAINER" 5432/tcp || true + docker logs "$PG_CONTAINER" || true + exit 1 + fi + echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV" + for i in $(seq 1 30); do + docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1 && exit 0 + sleep 1 + done + docker logs "$PG_CONTAINER" || true + exit 1 + + - name: Start Redis + run: | + set -euo pipefail + docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true + docker run -d --name "$REDIS_CONTAINER" -p 0:6379 redis:7 >/dev/null + REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}') + if [ -z "$REDIS_PORT" ]; then + REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}') + fi + if [ -z "$REDIS_PORT" ]; then + echo "::error::Could not resolve host port for $REDIS_CONTAINER" + docker port "$REDIS_CONTAINER" 6379/tcp || true + docker logs "$REDIS_CONTAINER" || true + exit 1 + fi + echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV" + for i in $(seq 1 15); do + docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG && exit 0 + sleep 1 + done + docker logs "$REDIS_CONTAINER" || true + exit 1 + + - name: Pick platform port + run: | + set -euo pipefail + PLATFORM_PORT=$(python3 - <<'PY' + import socket + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + print(s.getsockname()[1]) + PY + ) + echo "PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV" + echo "BASE=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV" + + - name: Build platform + working-directory: workspace-server + run: go build -o platform-server ./cmd/server + + - name: Populate template manifests for dev-mode E2E + run: | + set -euo pipefail + if command -v jq >/dev/null 2>&1; then + bash scripts/clone-manifest.sh manifest.json workspace-configs-templates org-templates plugins + else + echo "::warning::jq unavailable; dev-mode template assertion may fail if templates are absent" + fi + + - name: Start platform + run: | + set -euo pipefail + ./workspace-server/platform-server > workspace-server/platform.log 2>&1 & + echo $! > workspace-server/platform.pid + for i in $(seq 1 30); do + curl -sf "$BASE/health" >/dev/null && exit 0 + sleep 1 + done + cat workspace-server/platform.log || true + exit 1 + + - name: Run comprehensive E2E + run: bash tests/e2e/test_comprehensive_e2e.sh + + - name: Run workspace abilities E2E + run: bash tests/e2e/test_workspace_abilities_e2e.sh + + - name: Run dev-mode E2E + run: bash tests/e2e/test_dev_mode.sh + + - name: Start stub A2A agents + run: | + set -euo pipefail + cat > /tmp/molecule-stub-a2a.py <<'PY' + import json + from http.server import BaseHTTPRequestHandler, HTTPServer + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + length = int(self.headers.get("content-length", "0")) + raw = self.rfile.read(length) if length else b"{}" + try: + req = json.loads(raw) + except Exception: + req = {} + method = req.get("method") + if method not in ("message/send", None): + body = {"jsonrpc": "2.0", "id": req.get("id"), "error": {"code": -32601, "message": "method not found"}} + else: + body = { + "jsonrpc": "2.0", + "id": req.get("id", "stub"), + "result": { + "role": "agent", + "parts": [{"kind": "text", "type": "text", "text": "stub agent response"}], + }, + } + data = json.dumps(body, separators=(",", ":")).encode() + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(data))) + self.end_headers() + self.wfile.write(data) + def log_message(self, *_): + return + + HTTPServer(("127.0.0.1", 18080), Handler).serve_forever() + PY + python3 /tmp/molecule-stub-a2a.py > /tmp/molecule-stub-a2a.log 2>&1 & + echo $! > /tmp/molecule-stub-a2a.pid + + - name: Seed external agents for legacy A2A/activity scripts + run: | + set -euo pipefail + create_agent() { + local name="$1" role="$2" + curl -sS -X POST "$BASE/workspaces" \ + -H "Content-Type: application/json" \ + -d "{\"name\":\"${name}\",\"role\":\"${role}\",\"tier\":1,\"runtime\":\"external\",\"external\":true,\"url\":\"http://127.0.0.1:18080\"}" \ + | python3 -c "import json,sys; print(json.load(sys.stdin)['id'])" + } + ECHO_ID=$(create_agent "Echo Agent" "Echo") + SEO_ID=$(create_agent "SEO Agent" "SEO") + curl -sS -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ + -d "{\"id\":\"$ECHO_ID\",\"url\":\"http://127.0.0.1:18080\",\"agent_card\":{\"name\":\"Echo Agent\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\"}]}}" >/dev/null + curl -sS -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ + -d "{\"id\":\"$SEO_ID\",\"url\":\"http://127.0.0.1:18080\",\"agent_card\":{\"name\":\"SEO Agent\",\"skills\":[{\"id\":\"seo\",\"name\":\"SEO\"}]}}" >/dev/null + + - name: Run activity E2E + run: bash tests/e2e/test_activity_e2e.sh + + - name: Run A2A E2E + run: bash tests/e2e/test_a2a_e2e.sh + + - name: Runtime-dependent legacy E2E preflight + run: | + set -euo pipefail + if [ -f workspace-configs-templates/claude-code-default/.auth-token ] && docker image inspect workspace:latest >/dev/null 2>&1; then + bash tests/e2e/test_claude_code_e2e.sh + bash tests/e2e/test_chat_upload_e2e.sh + else + echo "::notice::Skipping test_claude_code_e2e.sh and test_chat_upload_e2e.sh: require workspace:latest plus workspace-configs-templates/claude-code-default/.auth-token" + fi + + - name: Dump platform log on failure + if: failure() + run: cat workspace-server/platform.log || true + + - name: Stop platform and stub agents + if: always() + run: | + if [ -f workspace-server/platform.pid ]; then + kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true + fi + if [ -f /tmp/molecule-stub-a2a.pid ]; then + kill "$(cat /tmp/molecule-stub-a2a.pid)" 2>/dev/null || true + fi + + - name: Stop service containers + if: always() + run: | + docker rm -f "$PG_CONTAINER" 2>/dev/null || true + docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true diff --git a/tests/e2e/test_a2a_e2e.sh b/tests/e2e/test_a2a_e2e.sh index cf6f748d6..a7711aa0a 100644 --- a/tests/e2e/test_a2a_e2e.sh +++ b/tests/e2e/test_a2a_e2e.sh @@ -1,11 +1,14 @@ #!/usr/bin/env bash set -euo pipefail -BASE="http://localhost:8080" +BASE="${BASE:-http://localhost:8080}" PASS=0 FAIL=0 TIMEOUT="${A2A_TIMEOUT:-120}" # seconds per A2A call (override via A2A_TIMEOUT env var) +# shellcheck source=_lib.sh +source "$(dirname "$0")/_lib.sh" + check() { local desc="$1" local expected="$2" @@ -130,7 +133,7 @@ echo "" # ======================================== echo "--- Test 6: Offline workspace ---" # Create a workspace but don't provision it -R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d '{"name":"Offline Test","tier":1}') +R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d '{"name":"Offline Test","tier":1,"runtime":"external","external":true}') OFFLINE_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") R=$(curl -s --max-time 10 -X POST "$BASE/workspaces/$OFFLINE_ID/a2a" \ -H "Content-Type: application/json" \ diff --git a/tests/e2e/test_activity_e2e.sh b/tests/e2e/test_activity_e2e.sh index b3a6b2b6c..8ae2156f9 100755 --- a/tests/e2e/test_activity_e2e.sh +++ b/tests/e2e/test_activity_e2e.sh @@ -215,7 +215,7 @@ echo "" echo "--- Activity Isolation ---" # Test 19: Create a second workspace to verify isolation -R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d '{"name":"Activity Test Workspace","tier":1}') +R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d '{"name":"Activity Test Workspace","tier":1,"runtime":"external","external":true}') TEMP_ID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])") # Test 20: New workspace has empty activity diff --git a/tests/e2e/test_comprehensive_e2e.sh b/tests/e2e/test_comprehensive_e2e.sh index 92af762d2..96370e26c 100755 --- a/tests/e2e/test_comprehensive_e2e.sh +++ b/tests/e2e/test_comprehensive_e2e.sh @@ -76,8 +76,8 @@ echo "--- Section 2: Workspace CRUD ---" # create; sections that depend on container readiness (RT_* in 2b) # still run normally. R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ - -d '{"name":"Test PM","role":"Project Manager","tier":2}') -check "Create PM" '"status":"provisioning"' "$R" + -d '{"name":"Test PM","role":"Project Manager","tier":2,"runtime":"external","external":true}') +check "Create PM" '"status":"awaiting_agent"' "$R" PM_ID=$(echo "$R" | jq_extract "['id']") echo " PM_ID=$PM_ID" RR=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ @@ -86,8 +86,8 @@ PM_TOKEN=$(echo "$RR" | e2e_extract_token) # Create child workspace under PM R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ - -d "{\"name\":\"Test Dev\",\"role\":\"Developer\",\"tier\":2,\"parent_id\":\"$PM_ID\"}") -check "Create Dev (child of PM)" '"status":"provisioning"' "$R" + -d "{\"name\":\"Test Dev\",\"role\":\"Developer\",\"tier\":2,\"parent_id\":\"$PM_ID\",\"runtime\":\"external\",\"external\":true}") +check "Create Dev (child of PM)" '"status":"awaiting_agent"' "$R" DEV_ID=$(echo "$R" | jq_extract "['id']") RR=$(curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ -d "{\"id\":\"$DEV_ID\",\"url\":\"http://localhost:9001\",\"agent_card\":{\"name\":\"Dev Agent\",\"skills\":[],\"version\":\"1.0.0\"}}") @@ -95,16 +95,16 @@ DEV_TOKEN=$(echo "$RR" | e2e_extract_token) # Create sibling R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ - -d "{\"name\":\"Test QA\",\"role\":\"QA\",\"tier\":1,\"parent_id\":\"$PM_ID\"}") -check "Create QA (sibling of Dev)" '"status":"provisioning"' "$R" + -d "{\"name\":\"Test QA\",\"role\":\"QA\",\"tier\":1,\"parent_id\":\"$PM_ID\",\"runtime\":\"external\",\"external\":true}") +check "Create QA (sibling of Dev)" '"status":"awaiting_agent"' "$R" QA_ID=$(echo "$R" | jq_extract "['id']") curl -s -X POST "$BASE/registry/register" -H "Content-Type: application/json" \ -d "{\"id\":\"$QA_ID\",\"url\":\"http://localhost:9002\",\"agent_card\":{\"name\":\"QA\",\"skills\":[]}}" > /dev/null # Create unrelated workspace R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ - -d '{"name":"Test Outsider","role":"External","tier":1}') -check "Create Outsider (unrelated)" '"status":"provisioning"' "$R" + -d '{"name":"Test Outsider","role":"External","tier":1,"runtime":"external","external":true}') +check "Create Outsider (unrelated)" '"status":"awaiting_agent"' "$R" OUTSIDER_ID=$(echo "$R" | jq_extract "['id']") # List workspaces @@ -130,19 +130,24 @@ check "PM position persisted" '"x":100' "$R" echo "" echo "--- Section 2b: Runtime Assignment ---" +if [ "${RUN_SPAWNED_RUNTIME_LEGACY_E2E:-0}" != "1" ]; then + echo " SKIP: spawned-runtime image checks require local runtime images; set RUN_SPAWNED_RUNTIME_LEGACY_E2E=1 to enable" + SKIP=$((SKIP + 5)) +else + # Create workspace with explicit runtime R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ - -d '{"name":"RT Claude","role":"Test","tier":2,"runtime":"claude-code"}') + -d '{"name":"RT Claude","role":"Test","tier":2,"runtime":"claude-code","model":"sonnet"}') check "Create claude-code workspace" '"status":"provisioning"' "$R" RT_CC_ID=$(echo "$R" | jq_extract "['id']") R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ - -d '{"name":"RT Codex","role":"Test","tier":2,"runtime":"codex"}') + -d '{"name":"RT Codex","role":"Test","tier":2,"runtime":"codex","model":"openai:gpt-5"}') check "Create codex workspace" '"status":"provisioning"' "$R" RT_CX_ID=$(echo "$R" | jq_extract "['id']") R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ - -d '{"name":"RT Hermes","role":"Test","tier":2,"runtime":"hermes"}') + -d '{"name":"RT Hermes","role":"Test","tier":2,"runtime":"hermes","model":"openai:gpt-5"}') check "Create hermes workspace" '"status":"provisioning"' "$R" RT_HM_ID=$(echo "$R" | jq_extract "['id']") @@ -235,6 +240,8 @@ sleep 0.3 e2e_delete_workspace "$RT_HM_ID" "RT Hermes" sleep 0.3 +fi + # ============================================================ # Section 3: Registry & Heartbeat # ============================================================ diff --git a/tests/e2e/test_dev_mode.sh b/tests/e2e/test_dev_mode.sh index 8406762bb..e7914c7a3 100755 --- a/tests/e2e/test_dev_mode.sh +++ b/tests/e2e/test_dev_mode.sh @@ -71,7 +71,7 @@ check_http "GET /workspaces (empty DB)" "200" "$R" # Create a workspace so tokens land in the DB. R=$(curl -s -w "\n%{http_code}" -X POST "$BASE/workspaces" \ -H "Content-Type: application/json" \ - -d '{"name":"Dev-Mode-Test","tier":1}') + -d '{"name":"Dev-Mode-Test","tier":1,"runtime":"external","external":true}') CODE=$(echo "$R" | tail -n1) BODY=$(echo "$R" | sed '$d') check_http "POST /workspaces (create)" "201" "$CODE" diff --git a/tests/e2e/test_workspace_abilities_e2e.sh b/tests/e2e/test_workspace_abilities_e2e.sh index f097c8688..c9e7f66cb 100755 --- a/tests/e2e/test_workspace_abilities_e2e.sh +++ b/tests/e2e/test_workspace_abilities_e2e.sh @@ -97,7 +97,7 @@ except Exception: done R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \ - -d '{"name":"Abilities Sender","tier":1}') + -d '{"name":"Abilities Sender","tier":1,"runtime":"external","external":true}') SENDER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true) [ -n "$SENDER_ID" ] || { echo "Failed to create sender workspace: $R"; exit 1; } SENDER_TOKEN=$(echo "$R" | e2e_extract_token) @@ -113,7 +113,7 @@ ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:-$SENDER_TOKEN}" ADMIN_AUTH="Authorization: Bearer $ADMIN_TOKEN" R=$(curl -s -X POST "$BASE/workspaces" -H "$ADMIN_AUTH" -H "Content-Type: application/json" \ - -d '{"name":"Abilities Receiver","tier":1}') + -d '{"name":"Abilities Receiver","tier":1,"runtime":"external","external":true}') RECEIVER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true) [ -n "$RECEIVER_ID" ] || { echo "Failed to create receiver workspace: $R"; exit 1; } RECEIVER_TOKEN=$(echo "$R" | e2e_extract_token)