Compare commits
148 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5978cb3c45 | |||
| 3934325e23 | |||
| 2e3e36b91f | |||
| 63d9158e12 | |||
| b7c962bf86 | |||
| 26789988df | |||
| b6ff280ca3 | |||
| acc10ca467 | |||
| f071cbb0a3 | |||
| 3c70ddea5c | |||
| 89e10962b9 | |||
| ff20fe4f61 | |||
| da59b8c5bc | |||
| e307334ca4 | |||
| 0945936eee | |||
| 16ad941a1e | |||
| 25979072fd | |||
| 99738975e2 | |||
| 66de1f1471 | |||
| 0e3e2559af | |||
| 4f6678ae52 | |||
| 5de0eee328 | |||
| 40e35e0b6d | |||
| 7a30af5af0 | |||
| 67e2c9c6b3 | |||
| 43e0f69dc8 | |||
| a6ef5f9583 | |||
| 38b1af3b84 | |||
| 5a50ba86e8 | |||
| 9fea10524e | |||
| 211e375ef1 | |||
| 38e0fc8ea0 | |||
| dd5832a8fc | |||
| 8622829848 | |||
| c5d8ce9ffe | |||
| 90b561add0 | |||
| 81c8a8b35d | |||
| 7ce0138150 | |||
| 408e308ce5 | |||
| 05596803f7 | |||
| 6cd650f48c | |||
| 754e5b2da1 | |||
| f23665d4d9 | |||
| 68c9bd8fe4 | |||
| 4d747de218 | |||
| 4a8a72f4ae | |||
| c4d476d0dc | |||
| 9689c6f6d5 | |||
| 3e4ff1ce9c | |||
| ad24703d74 | |||
| 3e6c7075d0 | |||
| 390425afbc | |||
| 663c5b7e70 | |||
| b70d857409 | |||
| 2f89a05f2f | |||
| d684e28228 | |||
| 71fb499dee | |||
| e5c9656016 | |||
| e5a8ace677 | |||
| d5eb58af56 | |||
| 166c677a09 | |||
| a7f1b378de | |||
| a306a97dd3 | |||
| ec54942628 | |||
| 065e39dda2 | |||
| 54d32d1ee2 | |||
| 4cd01a2df1 | |||
| ccb7ca5d8a | |||
| 10f2b9f01c | |||
| 8760ee1628 | |||
| 28f5108a7c | |||
| e9fdd992a9 | |||
| f6fa3669dc | |||
| b1a1c8e4a9 | |||
| aedbbc4a10 | |||
| 8b9e7e6d59 | |||
| 3c127ae3b9 | |||
| 98da627170 | |||
| 3cd8c53de0 | |||
| 69fcfe9b3a | |||
| 24d64677ab | |||
| 1141a42910 | |||
| 84448d452b | |||
| f689c81a70 | |||
| 2268027581 | |||
| 652124284b | |||
| 79a0203798 | |||
| bae2727074 | |||
| 2c4d92d9bc | |||
| 4c49ff75f6 | |||
| 2e9686036d | |||
| 2bc304bfd3 | |||
| 7ca764f917 | |||
| d2c0041b2b | |||
| 149d0bf3d7 | |||
| c6eec15292 | |||
| 68f8fa2621 | |||
| e4db4cfb11 | |||
| 65b42c33b9 | |||
| 9d45211fd3 | |||
| 14494fe67c | |||
| 3b244ca6c6 | |||
| 18e88e7039 | |||
| f7d663d19a | |||
| c8e422f6c6 | |||
| 1d303ee75e | |||
| 1ec7e4af6d | |||
| ae4739f35b | |||
| 6f203c5646 | |||
| ff0d4dae77 | |||
| 01bbf4c87b | |||
| e89dd892ac | |||
| eba0c5e3f1 | |||
| c3ba5df9ff | |||
| c37596fc26 | |||
| d2c202ddab | |||
| 79590eb861 | |||
| 2d1a86cac9 | |||
| 954d2172f0 | |||
| 9fd52e9cd4 | |||
| ffcffa1375 | |||
| b5dea3c5df | |||
| 54f3c4d34f | |||
| 8d5e78d629 | |||
| ac6f65ab5e | |||
| 5cd5a28bd1 | |||
| 026c81acf0 | |||
| a03045e5e4 | |||
| 61223de305 | |||
| 1355a1b539 | |||
| db132351a3 | |||
| 4e90d3a32d | |||
| e1d635a099 | |||
| 80c6f6e4b6 | |||
| a1e40fe0d9 | |||
| a8708caf73 | |||
| 02ae2fd6fb | |||
| f21d79c4ad | |||
| 120bb1f0a2 | |||
| cfd5ec8d82 | |||
| a4a32cded5 | |||
| 257079c7a2 | |||
| 0567502316 | |||
| 7cba0477cc | |||
| ff3dcd37f6 | |||
| 4e72f1d1db | |||
| e22f7969f8 | |||
| 3d145da99d |
@@ -231,10 +231,34 @@ jobs:
|
||||
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
|
||||
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
code=$(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\"}" >/dev/null || true
|
||||
-d "{\"confirm\":\"$slug\"}" \
|
||||
|| 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
|
||||
|
||||
@@ -272,6 +272,18 @@ jobs:
|
||||
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
|
||||
| xargs -0 shellcheck --severity=warning
|
||||
|
||||
- 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
|
||||
|
||||
@@ -32,16 +32,31 @@ name: Continuous synthetic E2E (staging)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every 20 minutes, on the :00 :20 :40. Offsets the existing :15
|
||||
# sweep-cf-orphans and :45 sweep-cf-tunnels so the three
|
||||
# operations don't all hit Cloudflare/AWS at the same minute.
|
||||
- cron: '0,20,40 * * * *'
|
||||
# Every 20 minutes, on :10 :30 :50. Two 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).
|
||||
# Empirical 2026-05-03: cron was '0,20,40 * * * *' but actual
|
||||
# firings landed at :08, :03, :01, :03 with :20 + :40 silently
|
||||
# dropped — only the :00-region run survived. Detection
|
||||
# latency degraded from claimed 20 min to actual ~60 min.
|
||||
# :10/:30/:50 sit far enough from :00 that GH-load skips
|
||||
# stop dropping us.
|
||||
# 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.
|
||||
- cron: '10,30,50 * * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
runtime:
|
||||
description: "Runtime to provision (langgraph = fastest, default; hermes = slower but covers SDK-native path; claude-code = needs OAUTH token in tenant env)"
|
||||
description: "Runtime to provision (claude-code = default + cheapest via MiniMax; langgraph = OpenAI-only; hermes = SDK-native path, slower)"
|
||||
required: false
|
||||
default: "langgraph"
|
||||
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)"
|
||||
@@ -70,13 +85,23 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 12
|
||||
env:
|
||||
# langgraph default keeps cold-start under 5 min on staging EC2.
|
||||
# hermes is slower (~7-10 min) and isn't needed for the
|
||||
# regression class this gate exists to catch (deployment-pipeline
|
||||
# + schema-drift + integration). Operators can pick hermes via
|
||||
# workflow_dispatch when they need to exercise the SDK-native
|
||||
# session path.
|
||||
E2E_RUNTIME: ${{ github.event.inputs.runtime || 'langgraph' }}
|
||||
# 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.
|
||||
@@ -88,37 +113,66 @@ jobs:
|
||||
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 }}
|
||||
# Provisioned tenant's default model (langgraph: openai:gpt-4.1-mini)
|
||||
# needs OPENAI_API_KEY at first call. Sibling workflows
|
||||
# e2e-staging-saas.yml + canary-staging.yml use the same secret;
|
||||
# without this wire-up the tenant boots, accepts a2a messages,
|
||||
# then returns "Could not resolve authentication method" — masked
|
||||
# earlier by the a2a-sdk task-mode contract bugs PR #2558+#2563
|
||||
# fixed. tests/e2e/test_staging_full_saas.sh:325 reads this and
|
||||
# persists it as a workspace_secret on tenant create.
|
||||
# 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 }}
|
||||
# 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 secret present
|
||||
- name: Verify required secrets present
|
||||
run: |
|
||||
# Schedule-vs-dispatch hardening (mirrors the sweep-cf-* and
|
||||
# redeploy-tenants-on-* workflows): hard-fail on missing secret
|
||||
# for cron firing so a misconfigured-repo doesn't silently
|
||||
# report green while doing nothing. Soft-skip on operator
|
||||
# dispatch — operators can dispatch ad-hoc to verify a fix
|
||||
# without setting up the secret first.
|
||||
# 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
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "::warning::CP_STAGING_ADMIN_API_TOKEN not set — synth E2E cannot run"
|
||||
echo "::warning::Set it at Settings → Secrets and Variables → Actions"
|
||||
exit 0
|
||||
fi
|
||||
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 uses MiniMax
|
||||
# (MOLECULE_STAGING_MINIMAX_API_KEY), langgraph + hermes use
|
||||
# OpenAI (MOLECULE_STAGING_OPENAI_KEY).
|
||||
case "${E2E_RUNTIME}" in
|
||||
claude-code)
|
||||
required_secret_name="MOLECULE_STAGING_MINIMAX_API_KEY"
|
||||
required_secret_value="${E2E_MINIMAX_API_KEY:-}"
|
||||
;;
|
||||
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)
|
||||
|
||||
@@ -184,8 +184,23 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
echo "Deleting orphan tenant: $slug"
|
||||
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
# Verify HTTP 2xx instead of `>/dev/null || true` swallowing
|
||||
# failures. A 5xx or timeout previously looked identical to
|
||||
# success, leaving the tenant alive for up to ~45 min until
|
||||
# sweep-stale-e2e-orgs caught it. Surface failures as
|
||||
# workflow warnings naming the slug. Don't `exit 1` — a single
|
||||
# cleanup miss shouldn't fail-flag the canvas test when the
|
||||
# actual smoke check passed; the sweeper is the safety net.
|
||||
# See molecule-controlplane#420.
|
||||
code=$(curl -sS -o /tmp/canvas-cleanup.out -w "%{http_code}" \
|
||||
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"confirm\":\"$slug\"}" >/dev/null || true
|
||||
-d "{\"confirm\":\"$slug\"}" \
|
||||
|| echo "000")
|
||||
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
|
||||
echo "[teardown] deleted $slug (HTTP $code)"
|
||||
else
|
||||
echo "::warning::canvas teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/canvas-cleanup.out 2>/dev/null)"
|
||||
fi
|
||||
exit 0
|
||||
|
||||
@@ -153,12 +153,28 @@ jobs:
|
||||
if [ -n "$orgs" ]; then
|
||||
echo "Safety-net sweep: deleting leftover orgs:"
|
||||
echo "$orgs"
|
||||
# Per-slug verified DELETE — see molecule-controlplane#420.
|
||||
# `>/dev/null 2>&1` previously hid every failure; surface
|
||||
# non-2xx as workflow warnings so the run page names what
|
||||
# leaked. Sweeper catches the rest within ~45 min.
|
||||
leaks=()
|
||||
for slug in $orgs; do
|
||||
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
code=$(curl -sS -o /tmp/external-cleanup.out -w "%{http_code}" \
|
||||
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"confirm\":\"$slug\"}" >/dev/null 2>&1
|
||||
-d "{\"confirm\":\"$slug\"}" \
|
||||
|| echo "000")
|
||||
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
|
||||
echo "[teardown] deleted $slug (HTTP $code)"
|
||||
else
|
||||
echo "::warning::external teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/external-cleanup.out 2>/dev/null)"
|
||||
leaks+=("$slug")
|
||||
fi
|
||||
done
|
||||
if [ ${#leaks[@]} -gt 0 ]; then
|
||||
echo "::warning::external teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
|
||||
fi
|
||||
else
|
||||
echo "Safety-net sweep: no leftover orgs to clean."
|
||||
fi
|
||||
|
||||
@@ -164,11 +164,27 @@ jobs:
|
||||
and o.get('instance_status') not in ('purged',)]
|
||||
print('\n'.join(candidates))
|
||||
" 2>/dev/null)
|
||||
# Per-slug verified DELETE (was `>/dev/null || true` — see
|
||||
# molecule-controlplane#420). Surface non-2xx as a workflow
|
||||
# warning naming the leaked slug; don't exit 1 (sweeper is
|
||||
# the safety net within ~45 min).
|
||||
leaks=()
|
||||
for slug in $orgs; do
|
||||
echo "Safety-net teardown: $slug"
|
||||
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
code=$(curl -sS -o /tmp/saas-cleanup.out -w "%{http_code}" \
|
||||
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"confirm\":\"$slug\"}" >/dev/null || true
|
||||
-d "{\"confirm\":\"$slug\"}" \
|
||||
|| echo "000")
|
||||
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
|
||||
echo "[teardown] deleted $slug (HTTP $code)"
|
||||
else
|
||||
echo "::warning::saas teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/saas-cleanup.out 2>/dev/null)"
|
||||
leaks+=("$slug")
|
||||
fi
|
||||
done
|
||||
if [ ${#leaks[@]} -gt 0 ]; then
|
||||
echo "::warning::saas teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
|
||||
fi
|
||||
exit 0
|
||||
|
||||
@@ -143,10 +143,25 @@ jobs:
|
||||
and o.get('status') not in ('purged',)]
|
||||
print('\n'.join(candidates))
|
||||
" 2>/dev/null)
|
||||
# Per-slug verified DELETE — see molecule-controlplane#420.
|
||||
# Failures surface as workflow warnings; the sweeper is the
|
||||
# safety net within ~45 min.
|
||||
leaks=()
|
||||
for slug in $orgs; do
|
||||
curl -sS -X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
code=$(curl -sS -o /tmp/sanity-cleanup.out -w "%{http_code}" \
|
||||
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"confirm\":\"$slug\"}" >/dev/null || true
|
||||
-d "{\"confirm\":\"$slug\"}" \
|
||||
|| echo "000")
|
||||
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
|
||||
echo "[teardown] deleted $slug (HTTP $code)"
|
||||
else
|
||||
echo "::warning::sanity teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/sanity-cleanup.out 2>/dev/null)"
|
||||
leaks+=("$slug")
|
||||
fi
|
||||
done
|
||||
if [ ${#leaks[@]} -gt 0 ]; then
|
||||
echo "::warning::sanity teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
|
||||
fi
|
||||
exit 0
|
||||
|
||||
@@ -25,16 +25,23 @@ name: Sweep stale e2e-* orgs (staging)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every hour on the hour. E2E orgs are short-lived (~10-25 min wall
|
||||
# clock from create to teardown). Anything older than the
|
||||
# MAX_AGE_MINUTES threshold below is presumed dead.
|
||||
- cron: '0 * * * *'
|
||||
# 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 120)"
|
||||
description: "Delete e2e-* orgs older than N minutes (default 30)"
|
||||
required: false
|
||||
default: "120"
|
||||
default: "30"
|
||||
dry_run:
|
||||
description: "Dry run only — list what would be deleted"
|
||||
required: false
|
||||
@@ -58,7 +65,7 @@ jobs:
|
||||
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 || '120' }}
|
||||
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
|
||||
|
||||
@@ -169,7 +169,17 @@ export default async function globalSetup(_config: FullConfig): Promise<void> {
|
||||
orgID = row.id;
|
||||
return true;
|
||||
}
|
||||
if (row.instance_status === "failed") throw new Error(`provision failed: ${slug}`);
|
||||
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,
|
||||
@@ -249,7 +259,17 @@ export default async function globalSetup(_config: FullConfig): Promise<void> {
|
||||
if (r.status !== 200) return null;
|
||||
if (r.body?.status === "online") return true;
|
||||
if (r.body?.status === "failed") {
|
||||
throw new Error(`Workspace failed: ${r.body.last_sample_error || ""}`);
|
||||
// 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;
|
||||
},
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
@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.
|
||||
|
||||
@@ -73,14 +73,19 @@ export function ApprovalBanner() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDecide(approval, "approved")}
|
||||
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-xs rounded-lg text-white font-medium transition-colors"
|
||||
// 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"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDecide(approval, "denied")}
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card text-xs rounded-lg text-ink-mid transition-colors"
|
||||
// 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"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
|
||||
@@ -30,6 +30,24 @@ export function BatchActionBar() {
|
||||
if (count === 0 && hasFailedBatch) setHasFailedBatch(false);
|
||||
}, [count, hasFailedBatch]);
|
||||
|
||||
// Esc clears selection — the deselect button title has been promising
|
||||
// "(Escape)" since the bar shipped, but no handler was wired. Skip when
|
||||
// the confirm dialog is open (`pending !== null`) so the dialog's own
|
||||
// Esc-cancels takes precedence and we don't double-handle the keystroke.
|
||||
// Also skip during a busy in-flight action so the user can't accidentally
|
||||
// strand a partial-failure mid-flight.
|
||||
useEffect(() => {
|
||||
if (count === 0 || pending !== null || busy) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
clearSelection();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [count, pending, busy, clearSelection]);
|
||||
|
||||
// Hide when nothing is selected. Hide for single-node selection UNLESS a
|
||||
// partial-failure left a survivor awaiting retry.
|
||||
if (count === 0) return null;
|
||||
@@ -129,7 +147,7 @@ export function BatchActionBar() {
|
||||
onClick={clearSelection}
|
||||
aria-label="Clear selection"
|
||||
title="Clear selection (Escape)"
|
||||
className="p-1.5 rounded-lg text-[12px] text-ink-mid hover:text-ink hover:bg-surface-card/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-zinc-500/70"
|
||||
className="p-1.5 rounded-lg text-[12px] text-ink-mid hover:text-ink hover:bg-surface-card/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
@@ -117,9 +117,11 @@ export function BundleDropZone() {
|
||||
📦 Import bundle
|
||||
</button>
|
||||
|
||||
{/* Visual overlay when dragging */}
|
||||
{/* 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. */}
|
||||
{isDragging && (
|
||||
<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="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="text-3xl mb-2" aria-hidden="true">📦</div>
|
||||
<div className="text-sm font-semibold text-ink">Drop Bundle to Import</div>
|
||||
@@ -128,10 +130,21 @@ export function BundleDropZone() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Importing spinner */}
|
||||
{/* 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 && (
|
||||
<div 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 className="w-4 h-4 border-2 border-sky-400 border-t-transparent rounded-full animate-spin" />
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -91,12 +91,15 @@ 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-500 text-white"
|
||||
? "bg-red-600 hover:bg-red-700 text-white"
|
||||
: confirmVariant === "warning"
|
||||
? "bg-amber-600 hover:bg-amber-500 text-white"
|
||||
: "bg-accent-strong hover:bg-accent text-white";
|
||||
? "bg-amber-600 hover:bg-amber-700 text-white"
|
||||
: "bg-accent hover:bg-accent-strong 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).
|
||||
@@ -123,7 +126,7 @@ export function ConfirmDialog({
|
||||
<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-card border border-line rounded-lg transition-colors"
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -131,7 +134,7 @@ export function ConfirmDialog({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors ${confirmColors}`}
|
||||
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}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
|
||||
@@ -113,7 +113,10 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
ref={closeButtonRef}
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="text-ink-mid hover:text-ink text-sm px-2"
|
||||
// 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"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
@@ -150,12 +153,19 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(output);
|
||||
// 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"));
|
||||
} 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-card border border-line rounded-lg transition-colors"
|
||||
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"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
@@ -163,7 +173,10 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-[11px] text-ink-mid bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
|
||||
// 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"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
@@ -29,15 +29,38 @@ export function ContextMenu() {
|
||||
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
|
||||
// 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.
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return;
|
||||
requestAnimationFrame(() => {
|
||||
const first = ref.current?.querySelector<HTMLButtonElement>("button:not(:disabled)");
|
||||
setClamped(null);
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const node = ref.current;
|
||||
if (!node) return;
|
||||
const first = node.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 });
|
||||
});
|
||||
}, [contextMenu?.nodeId]);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [contextMenu?.nodeId, contextMenu?.x, contextMenu?.y]);
|
||||
|
||||
// Close on click outside or Escape
|
||||
useEffect(() => {
|
||||
@@ -288,7 +311,7 @@ export function ContextMenu() {
|
||||
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: contextMenu.x, top: contextMenu.y }}
|
||||
style={{ left: clamped?.x ?? contextMenu.x, top: clamped?.y ?? contextMenu.y }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-3.5 py-2 border-b border-line/40 mb-0.5">
|
||||
@@ -314,7 +337,7 @@ export function ContextMenu() {
|
||||
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:ring-1 focus:ring-inset focus:ring-zinc-600 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-visible:ring-1 focus-visible:ring-inset focus-visible:ring-accent/50 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"
|
||||
|
||||
@@ -98,9 +98,17 @@ export function CookieConsent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
// 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"
|
||||
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)]"
|
||||
@@ -117,7 +125,7 @@ export function CookieConsent() {
|
||||
workspaces). See our{" "}
|
||||
<a
|
||||
href="https://moleculesai.app/legal/privacy"
|
||||
className="text-accent underline hover:text-accent"
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
@@ -130,20 +138,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"
|
||||
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"
|
||||
>
|
||||
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"
|
||||
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"
|
||||
>
|
||||
Accept all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -310,7 +310,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-strong hover:bg-accent active:bg-accent-strong 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">
|
||||
<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">
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
@@ -502,7 +502,7 @@ 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-zinc-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -527,7 +527,7 @@ export function CreateWorkspaceButton() {
|
||||
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-zinc-600 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
|
||||
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(
|
||||
@@ -552,7 +552,7 @@ 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-card text-sm rounded-lg text-ink-mid transition-colors">
|
||||
<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">
|
||||
Cancel
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
@@ -560,7 +560,7 @@ export function CreateWorkspaceButton() {
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="px-5 py-2 bg-accent-strong hover:bg-accent active:bg-accent-strong text-sm rounded-lg text-white disabled:opacity-50 transition-colors"
|
||||
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"
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</button>
|
||||
@@ -623,7 +623,7 @@ 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-zinc-500 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-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" : ""}`}
|
||||
/>
|
||||
{helper && (
|
||||
<p className="mt-1 text-xs text-ink-soft">{helper}</p>
|
||||
|
||||
@@ -127,13 +127,16 @@ export function DeleteCascadeConfirmDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Checkbox guard */}
|
||||
{/* 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. */}
|
||||
<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 focus:ring-red-500 focus:ring-offset-0 focus:ring-offset-zinc-900 cursor-pointer"
|
||||
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"
|
||||
/>
|
||||
<span className="text-[12px] text-ink-mid group-hover:text-ink-mid leading-relaxed">
|
||||
I understand this will permanently delete all listed workspaces and their data
|
||||
@@ -145,7 +148,11 @@ export function DeleteCascadeConfirmDialog({
|
||||
<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-card border border-line rounded-lg transition-colors"
|
||||
// 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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -153,9 +160,12 @@ export function DeleteCascadeConfirmDialog({
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={!checked}
|
||||
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors
|
||||
// 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
|
||||
${checked
|
||||
? "bg-red-600 hover:bg-red-500 text-white cursor-pointer"
|
||||
? "bg-red-600 hover:bg-red-700 text-white cursor-pointer"
|
||||
: "bg-red-900/30 text-bad/40 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -18,6 +18,157 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
|
||||
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "fields";
|
||||
|
||||
// Per-tab help metadata: docs link, where-to-install link, common errors.
|
||||
// All URLs verified against repo content (docs/guides/* file paths map to
|
||||
// docs.molecule.ai/docs/guides/*; canonical hostname confirmed by existing
|
||||
// blog post canonical metadata) or against the snippet text the operator
|
||||
// just copied. Never linking to a URL that wasn't already in product —
|
||||
// dead links here defeat the purpose of "more comprehensive instructions."
|
||||
const TAB_HELP: Record<
|
||||
Tab,
|
||||
{
|
||||
docsUrl?: string;
|
||||
docsLabel?: string;
|
||||
downloadUrl?: string;
|
||||
downloadLabel?: string;
|
||||
commonIssues?: { symptom: string; check: string }[];
|
||||
}
|
||||
> = {
|
||||
mcp: {
|
||||
docsUrl: "https://docs.molecule.ai/docs/guides/mcp-server-setup",
|
||||
docsLabel: "MCP server setup guide",
|
||||
downloadUrl: "https://pypi.org/project/molecule-ai-workspace-runtime/",
|
||||
downloadLabel: "molecule-ai-workspace-runtime on PyPI",
|
||||
commonIssues: [
|
||||
{
|
||||
symptom: "Tools not appearing in your agent",
|
||||
check:
|
||||
"Run `claude mcp list` (or your runtime's equivalent) — the molecule entry should be listed. If missing, re-run the `claude mcp add` line.",
|
||||
},
|
||||
{
|
||||
symptom: "ConnectionRefused / DNS error on first call",
|
||||
check:
|
||||
"PLATFORM_URL must include the scheme (https://) and have no trailing slash. Verify with `curl $PLATFORM_URL/healthz`.",
|
||||
},
|
||||
],
|
||||
},
|
||||
python: {
|
||||
docsUrl:
|
||||
"https://docs.molecule.ai/docs/guides/external-agent-registration",
|
||||
docsLabel: "External agent registration guide",
|
||||
downloadUrl: "https://pypi.org/project/molecule-ai-workspace-runtime/",
|
||||
downloadLabel: "molecule-ai-workspace-runtime on PyPI",
|
||||
commonIssues: [
|
||||
{
|
||||
symptom: "401 from /heartbeat",
|
||||
check:
|
||||
"AUTH_TOKEN expired or wrong workspace_id. Tokens are shown only once at create time — re-create the workspace to get a fresh token.",
|
||||
},
|
||||
{
|
||||
symptom: "AGENT_URL not reachable from platform",
|
||||
check:
|
||||
"Public HTTPS URL required for inbound A2A. Use ngrok or Cloudflare Tunnel if your agent is behind NAT.",
|
||||
},
|
||||
],
|
||||
},
|
||||
claude: {
|
||||
docsUrl:
|
||||
"https://docs.molecule.ai/docs/guides/external-agent-registration",
|
||||
docsLabel: "External agent registration guide",
|
||||
downloadUrl: "https://claude.com/claude-code",
|
||||
downloadLabel: "Claude Code (claude.com)",
|
||||
commonIssues: [
|
||||
{
|
||||
symptom: "plugin not installed",
|
||||
check:
|
||||
"Run `/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel` then `/plugin install molecule@molecule-mcp-claude-channel` inside Claude Code, then `/reload-plugins`.",
|
||||
},
|
||||
{
|
||||
symptom: "not on the approved channels allowlist",
|
||||
check:
|
||||
"Custom channels need `--dangerously-load-development-channels` on the launch command. Team/Enterprise orgs need admin to set `channelsEnabled` + `allowedChannelPlugins` in claude.ai admin settings.",
|
||||
},
|
||||
{
|
||||
symptom: "Inbound messages not arriving",
|
||||
check:
|
||||
"Check stderr for `molecule channel: connected — watching N workspace(s)`. Verify ~/.claude/channels/molecule/.env has the right PLATFORM_URL + token.",
|
||||
},
|
||||
],
|
||||
},
|
||||
hermes: {
|
||||
docsUrl:
|
||||
"https://docs.molecule.ai/docs/guides/external-agent-registration",
|
||||
docsLabel: "External agent registration guide",
|
||||
downloadUrl: "https://github.com/NousResearch/hermes-agent",
|
||||
downloadLabel: "hermes-agent (NousResearch)",
|
||||
commonIssues: [
|
||||
{
|
||||
symptom: "Gateway start failure",
|
||||
check:
|
||||
"Tail ~/.hermes/gateway.log. YAML duplicate-key in config.yaml is the most common cause — `gateway:` block must appear exactly once.",
|
||||
},
|
||||
{
|
||||
symptom: "Plugin not discovered after install",
|
||||
check:
|
||||
"Run `pip show hermes-channel-molecule` to confirm install. Some hermes builds need `hermes plugin reload` before the new platform_plugins entry takes effect.",
|
||||
},
|
||||
],
|
||||
},
|
||||
codex: {
|
||||
docsUrl: "https://docs.molecule.ai/docs/guides/mcp-server-setup",
|
||||
docsLabel: "MCP server setup guide",
|
||||
downloadUrl: "https://github.com/openai/codex",
|
||||
downloadLabel: "openai/codex",
|
||||
commonIssues: [
|
||||
{
|
||||
symptom: "[mcp_servers.molecule] not loaded",
|
||||
check:
|
||||
"Codex must be ≥ 0.57. Check with `codex --version`; upgrade via `npm install -g @openai/codex@latest`.",
|
||||
},
|
||||
{
|
||||
symptom: "TOML parse error after re-running setup",
|
||||
check:
|
||||
"TOML rejects duplicate `[mcp_servers.molecule]` tables. Open ~/.codex/config.toml and remove the old block before pasting the new one.",
|
||||
},
|
||||
],
|
||||
},
|
||||
openclaw: {
|
||||
docsUrl: "https://docs.molecule.ai/docs/guides/mcp-server-setup",
|
||||
docsLabel: "MCP server setup guide",
|
||||
commonIssues: [
|
||||
{
|
||||
symptom: "Gateway not starting",
|
||||
check:
|
||||
"Tail ~/.openclaw/gateway.log. The loopback bind requires :18789 to be free — check with `lsof -iTCP:18789`.",
|
||||
},
|
||||
{
|
||||
symptom: "openclaw mcp set rejected",
|
||||
check:
|
||||
"The heredoc generates JSON; verify it parsed by running `jq < ~/.openclaw/mcp/molecule.json`. Re-run `openclaw mcp set` if the file is malformed.",
|
||||
},
|
||||
],
|
||||
},
|
||||
curl: {
|
||||
docsUrl:
|
||||
"https://docs.molecule.ai/docs/guides/external-agent-registration",
|
||||
docsLabel: "External agent registration guide",
|
||||
commonIssues: [
|
||||
{
|
||||
symptom: "401 / 403 on register",
|
||||
check:
|
||||
"WORKSPACE_AUTH_TOKEN must be the value shown at workspace create. Tokens are shown only once.",
|
||||
},
|
||||
],
|
||||
},
|
||||
fields: {
|
||||
docsUrl:
|
||||
"https://docs.molecule.ai/docs/guides/external-agent-registration",
|
||||
docsLabel: "External agent registration guide",
|
||||
},
|
||||
};
|
||||
|
||||
export interface ExternalConnectionInfo {
|
||||
workspace_id: string;
|
||||
platform_url: string;
|
||||
@@ -40,6 +191,22 @@ export interface ExternalConnectionInfo {
|
||||
// + 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 {
|
||||
@@ -47,13 +214,19 @@ interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Tab = "python" | "curl" | "claude" | "mcp" | "fields";
|
||||
|
||||
export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
// Default to Claude Code when the platform offers it — that's the
|
||||
// newest + simplest path (no tunnel needed). Falls back to Python
|
||||
// for older platform builds that don't ship the snippet.
|
||||
const initialTab: Tab = info?.claude_code_channel_snippet ? "claude" : "python";
|
||||
// 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);
|
||||
|
||||
@@ -108,6 +281,24 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
'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()}>
|
||||
@@ -135,10 +326,18 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
// 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 (filledChannel) tabs.push("claude");
|
||||
tabs.push("python");
|
||||
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) => (
|
||||
@@ -156,6 +355,12 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
>
|
||||
{t === "claude"
|
||||
? "Claude Code"
|
||||
: t === "hermes"
|
||||
? "Hermes"
|
||||
: t === "codex"
|
||||
? "Codex"
|
||||
: t === "openclaw"
|
||||
? "OpenClaw"
|
||||
: t === "python"
|
||||
? "Python SDK"
|
||||
: t === "mcp"
|
||||
@@ -205,6 +410,33 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
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"} />
|
||||
@@ -220,6 +452,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
<Field label="heartbeat_endpoint" value={info.heartbeat_endpoint} onCopy={() => copy(info.heartbeat_endpoint, "hb")} copied={copiedKey === "hb"} />
|
||||
</div>
|
||||
)}
|
||||
<HelpBlock help={TAB_HELP[tab]} />
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
@@ -268,6 +501,70 @@ function SnippetBlock({
|
||||
);
|
||||
}
|
||||
|
||||
// HelpBlock — collapsible "Need help?" section under each tab's snippet.
|
||||
// Renders only the keys present in the per-tab help metadata (no empty
|
||||
// sections). Closed by default so the snippet stays the visual focus;
|
||||
// operators with a working setup never see this. Uses native <details>
|
||||
// for keyboard accessibility (Tab + Enter) without extra ARIA wiring.
|
||||
function HelpBlock({
|
||||
help,
|
||||
}: {
|
||||
help: (typeof TAB_HELP)[Tab] | undefined;
|
||||
}) {
|
||||
if (!help) return null;
|
||||
const { docsUrl, docsLabel, downloadUrl, downloadLabel, commonIssues } = help;
|
||||
if (!docsUrl && !downloadUrl && !commonIssues?.length) return null;
|
||||
|
||||
return (
|
||||
<details className="mt-3 border border-line rounded-lg bg-surface text-xs">
|
||||
<summary className="cursor-pointer select-none px-3 py-2 text-ink-mid hover:text-ink">
|
||||
Need help? — install link, docs, common errors
|
||||
</summary>
|
||||
<div className="px-3 pb-3 pt-1 space-y-2">
|
||||
{downloadUrl && (
|
||||
<div>
|
||||
<span className="text-ink-soft">Where to install: </span>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent underline hover:text-accent-strong"
|
||||
>
|
||||
{downloadLabel || downloadUrl}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{docsUrl && (
|
||||
<div>
|
||||
<span className="text-ink-soft">Documentation: </span>
|
||||
<a
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent underline hover:text-accent-strong"
|
||||
>
|
||||
{docsLabel || docsUrl}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{commonIssues && commonIssues.length > 0 && (
|
||||
<div>
|
||||
<div className="text-ink-soft mb-1">Common errors:</div>
|
||||
<ul className="space-y-1.5 pl-3">
|
||||
{commonIssues.map((issue, i) => (
|
||||
<li key={i}>
|
||||
<code className="text-warm font-mono">{issue.symptom}</code>
|
||||
<span className="text-ink-mid"> — {issue.check}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
|
||||
@@ -77,7 +77,7 @@ export function Legend() {
|
||||
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 transition-[left,colors] duration-200`}
|
||||
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
|
||||
@@ -94,7 +94,10 @@ export function Legend() {
|
||||
onClick={closeLegend}
|
||||
aria-label="Hide legend"
|
||||
title="Hide legend"
|
||||
className="-mt-0.5 -mr-1 px-1.5 text-[14px] leading-none text-ink-soft hover:text-ink transition-colors"
|
||||
// 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>
|
||||
|
||||
@@ -134,10 +134,12 @@ export function OnboardingWizard() {
|
||||
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"
|
||||
>
|
||||
{/* Progress bar */}
|
||||
{/* 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">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-sky-400 transition-all duration-500"
|
||||
className="h-full bg-gradient-to-r from-accent to-accent-strong transition-all duration-500"
|
||||
style={{ width: `${((currentStepIdx + 1) / STEPS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -155,14 +157,16 @@ export function OnboardingWizard() {
|
||||
<div className="p-4">
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[9px] font-semibold uppercase tracking-widest text-sky-400/80">
|
||||
{/* 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">
|
||||
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"
|
||||
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"
|
||||
>
|
||||
Skip guide
|
||||
</button>
|
||||
@@ -181,7 +185,11 @@ export function OnboardingWizard() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAction}
|
||||
className="flex-1 px-3 py-1.5 bg-accent-strong/90 hover:bg-accent rounded-lg text-[11px] font-medium text-white transition-colors"
|
||||
// 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"
|
||||
>
|
||||
{step === "welcome"
|
||||
? "Create Workspace"
|
||||
@@ -199,7 +207,10 @@ export function OnboardingWizard() {
|
||||
if (next) setStep(next.id);
|
||||
else dismiss();
|
||||
}}
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card rounded-lg text-[11px] text-ink-mid transition-colors"
|
||||
// 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"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
|
||||
@@ -293,7 +293,7 @@ export function OrgImportPreflightModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1.5 text-[11px] rounded bg-surface-card hover:bg-surface-card text-ink-mid"
|
||||
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>
|
||||
@@ -308,7 +308,7 @@ export function OrgImportPreflightModal({
|
||||
type="button"
|
||||
onClick={onProceed}
|
||||
disabled={!canProceed}
|
||||
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent-strong hover:bg-accent text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed"
|
||||
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>
|
||||
@@ -428,7 +428,7 @@ function StrictEnvRow({
|
||||
type="button"
|
||||
onClick={() => onSave(envKey)}
|
||||
disabled={d?.saving || !d?.value.trim()}
|
||||
className="px-2 py-1 text-[10px] rounded bg-accent-strong hover:bg-accent text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
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>
|
||||
@@ -520,7 +520,7 @@ function AnyOfEnvGroup({
|
||||
type="button"
|
||||
onClick={() => onSave(m)}
|
||||
disabled={d?.saving || !d?.value.trim()}
|
||||
className="px-2 py-1 text-[10px] rounded bg-accent-strong hover:bg-accent text-white disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
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>
|
||||
|
||||
@@ -36,11 +36,6 @@ 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();
|
||||
@@ -51,6 +46,18 @@ 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);
|
||||
@@ -113,7 +120,7 @@ export function SearchDialog() {
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder="Search workspaces..."
|
||||
className="flex-1 bg-transparent text-sm text-ink placeholder-zinc-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus:outline-none rounded"
|
||||
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"
|
||||
/>
|
||||
<kbd className="text-[9px] text-ink-mid bg-surface-card/60 px-1.5 py-0.5 rounded border border-line/40">ESC</kbd>
|
||||
</div>
|
||||
|
||||
@@ -202,7 +202,7 @@ export function SidePanel() {
|
||||
{/* Tabs — relative wrapper lets the fade gradient position against the scroll container */}
|
||||
<div className="relative border-b border-line/40">
|
||||
{/* Right-edge fade: signals more tabs are hidden off-screen when the bar overflows */}
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-zinc-950 to-transparent z-10" aria-hidden="true" />
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-surface to-transparent z-10" aria-hidden="true" />
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Workspace panel tabs"
|
||||
@@ -232,8 +232,8 @@ export function SidePanel() {
|
||||
onClick={() => setPanelTab(tab.id)}
|
||||
className={`shrink-0 px-3 py-2.5 text-[10px] font-medium tracking-wide transition-all rounded-t-lg mx-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 ${
|
||||
panelTab === tab.id
|
||||
? "text-ink bg-surface-card/40 border-b-2 border-accent"
|
||||
: "text-ink-soft hover:text-ink hover:bg-surface-card/40"
|
||||
? "text-ink bg-surface-card border-b-2 border-accent"
|
||||
: "text-ink-mid hover:text-ink hover:bg-surface-card/60"
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1 opacity-50" aria-hidden="true">{tab.icon}</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { PLATFORM_URL } from "@/lib/api";
|
||||
|
||||
// TermsGate blocks the page it wraps until the user has accepted the
|
||||
@@ -73,39 +73,72 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Move focus to the "I agree" button when the modal opens (WCAG 2.4.3).
|
||||
// The dialog is a hard gate — no Esc dismiss — so we don't need a focus
|
||||
// trap loop, just a one-shot focus move into the dialog.
|
||||
const agreeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
if (status !== "pending") return;
|
||||
const raf = requestAnimationFrame(() => agreeButtonRef.current?.focus());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{status === "pending" && (
|
||||
<div aria-hidden="true" className="fixed inset-0 z-50 flex items-center justify-center bg-surface/80 backdrop-blur-sm">
|
||||
// Backdrop is decorative — does NOT carry aria-hidden anymore.
|
||||
// The earlier version put aria-hidden="true" on this wrapper,
|
||||
// which hid the dialog AND its descendants from screen readers,
|
||||
// making the entire terms-acceptance flow invisible to AT users.
|
||||
// Backdrop click intentionally does nothing — this is a hard
|
||||
// gate.
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-surface/80 backdrop-blur-sm">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="terms-dialog-title"
|
||||
aria-describedby="terms-dialog-body"
|
||||
className="mx-4 max-w-lg rounded-lg border border-line bg-surface-sunken p-6 shadow-xl"
|
||||
>
|
||||
<h2 id="terms-dialog-title" className="text-lg font-semibold text-ink">Terms & conditions</h2>
|
||||
<p className="mt-3 text-sm text-ink-mid">
|
||||
Before you create an organization, please review our{" "}
|
||||
<a href="/legal/terms" className="text-sky-400 underline" target="_blank" rel="noreferrer">
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="/legal/privacy" className="text-sky-400 underline" target="_blank" rel="noreferrer">
|
||||
Privacy Policy
|
||||
</a>
|
||||
. Click agree to continue.
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-ink-soft">
|
||||
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
|
||||
</p>
|
||||
<div id="terms-dialog-body">
|
||||
<p className="mt-3 text-sm text-ink-mid">
|
||||
Before you create an organization, please review our{" "}
|
||||
<a
|
||||
href="/legal/terms"
|
||||
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"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a
|
||||
href="/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"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
. Click agree to continue.
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-ink-soft">
|
||||
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
|
||||
</p>
|
||||
</div>
|
||||
{error && <p role="alert" className="mt-3 text-sm text-bad">{error}</p>}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
ref={agreeButtonRef}
|
||||
onClick={accept}
|
||||
disabled={submitting}
|
||||
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||
// Hover goes DARKER, not lighter — emerald-500 on white
|
||||
// text drops contrast below AA vs emerald-700. Same trap
|
||||
// I fixed in ApprovalBanner + ConfirmDialog.
|
||||
className="rounded bg-emerald-600 hover:bg-emerald-700 px-4 py-2 text-sm font-medium text-white disabled:opacity-50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/70 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
|
||||
>
|
||||
{submitting ? "Saving…" : "I agree"}
|
||||
</button>
|
||||
|
||||
@@ -38,6 +38,18 @@ export function Toaster() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Esc dismisses the newest toast — keyboard parity with the × button.
|
||||
// Errors never auto-expire, so without this a keyboard-only user has to
|
||||
// tab through the entire app to reach the dismiss button on a stuck error.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
setToasts((prev) => (prev.length === 0 ? prev : prev.slice(0, -1)));
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const toastCls = (type: Toast["type"]) =>
|
||||
`flex items-center gap-2 pl-4 pr-2 py-2.5 rounded-xl shadow-2xl shadow-black/40 text-sm backdrop-blur-md animate-in slide-in-from-bottom duration-200 ${
|
||||
type === "success"
|
||||
@@ -47,6 +59,17 @@ export function Toaster() {
|
||||
: "bg-surface-sunken/90 border border-line/40 text-ink"
|
||||
}`;
|
||||
|
||||
// Success/error toasts are intentionally dark in both themes (high-vis).
|
||||
// Info uses the semantic surface that flips with theme — so the dismiss
|
||||
// button needs a tint that stays visible on a light bg in light mode.
|
||||
const dismissCls = (type: Toast["type"]) => {
|
||||
const base =
|
||||
"ml-1 w-7 h-7 inline-flex items-center justify-center text-base leading-none rounded transition-colors opacity-70 hover:opacity-100 focus-visible:opacity-100 focus:outline-none focus-visible:ring-2 shrink-0";
|
||||
return type === "info"
|
||||
? `${base} hover:bg-ink/10 focus-visible:ring-accent/60`
|
||||
: `${base} hover:bg-white/15 focus-visible:ring-white/70`;
|
||||
};
|
||||
|
||||
const pos =
|
||||
"fixed bottom-16 left-1/2 -translate-x-1/2 z-[80] flex flex-col gap-2 items-center";
|
||||
|
||||
@@ -66,7 +89,7 @@ export function Toaster() {
|
||||
type="button"
|
||||
onClick={() => dismiss(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
className="ml-1 p-1 rounded hover:bg-surface-card/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
|
||||
className={dismissCls(toast.type)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -94,7 +117,7 @@ export function Toaster() {
|
||||
type="button"
|
||||
onClick={() => dismiss(toast.id)}
|
||||
aria-label="Dismiss notification"
|
||||
className="ml-1 p-1 rounded hover:bg-surface-card/50 transition-colors opacity-70 hover:opacity-100 shrink-0"
|
||||
className={dismissCls(toast.type)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -154,10 +154,10 @@ export function Toolbar() {
|
||||
{counts.failed > 0 && (
|
||||
<StatusPill color={statusDotClass("failed")} count={counts.failed} label="failed" />
|
||||
)}
|
||||
<span className="text-ink-soft" aria-hidden="true">·</span>
|
||||
<span className="text-[10px] text-ink-soft whitespace-nowrap">
|
||||
<span className="text-ink-mid" aria-hidden="true">·</span>
|
||||
<span className="text-[10px] text-ink-mid whitespace-nowrap">
|
||||
{counts.roots} workspace{counts.roots !== 1 ? "s" : ""}
|
||||
{counts.children > 0 && <span className="text-ink-soft"> + {counts.children} sub</span>}
|
||||
{counts.children > 0 && <span className="text-ink-mid"> + {counts.children} sub</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -172,7 +172,7 @@ export function Toolbar() {
|
||||
type="button"
|
||||
onClick={stopAll}
|
||||
disabled={stopping}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-red-950/50 hover:bg-red-900/60 border border-red-800/40 rounded-lg transition-colors disabled:opacity-50"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-bad/10 hover:bg-bad/20 border border-bad/40 rounded-lg transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-bad/40"
|
||||
title={`Stop all running tasks (${counts.activeTasks} active)`}
|
||||
aria-label={stopping ? "Stopping all running tasks" : `Stop all running tasks (${counts.activeTasks} active)`}
|
||||
>
|
||||
@@ -191,7 +191,7 @@ export function Toolbar() {
|
||||
type="button"
|
||||
onClick={() => setRestartConfirmOpen(true)}
|
||||
disabled={restartingAll}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-amber-950/40 hover:bg-amber-900/50 border border-amber-800/40 rounded-lg transition-colors disabled:opacity-50"
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 bg-warm/10 hover:bg-warm/20 border border-warm/40 rounded-lg transition-colors disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-warm/40"
|
||||
title={`Restart ${needsRestartNodes.length} workspace${needsRestartNodes.length === 1 ? "" : "s"} that need to pick up config or secret changes`}
|
||||
aria-label={restartingAll ? "Restarting workspaces" : `Restart ${needsRestartNodes.length} workspace${needsRestartNodes.length === 1 ? "" : "s"} pending config or secret changes`}
|
||||
>
|
||||
@@ -216,10 +216,10 @@ export function Toolbar() {
|
||||
aria-pressed={showA2AEdges}
|
||||
aria-label={showA2AEdges ? "Hide A2A edges" : "Show A2A edges"}
|
||||
title={showA2AEdges ? "Hide A2A delegation edges" : "Show A2A delegation edges (last 60 min)"}
|
||||
className={`flex items-center justify-center w-7 h-7 border rounded-lg transition-colors ${
|
||||
className={`flex items-center justify-center w-7 h-7 border rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
|
||||
showA2AEdges
|
||||
? "bg-blue-950/50 hover:bg-blue-900/50 border-blue-800/40 text-accent"
|
||||
: "bg-surface-card/50 hover:bg-surface-card/50 border-line/40 text-ink-soft hover:text-ink-mid"
|
||||
? "bg-accent/15 hover:bg-accent/25 border-accent/50 text-accent"
|
||||
: "bg-surface-card hover:bg-surface-card/70 border-line text-ink-mid hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{/* Mesh / network icon */}
|
||||
@@ -255,7 +255,7 @@ export function Toolbar() {
|
||||
}}
|
||||
aria-label="Open audit trail for selected workspace"
|
||||
title="Audit — view ledger for the selected workspace"
|
||||
className="flex items-center justify-center w-7 h-7 bg-surface-card/50 hover:bg-surface-card/50 border border-line/40 rounded-lg transition-colors text-ink-soft hover:text-ink-mid"
|
||||
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{/* Scroll / ledger icon */}
|
||||
<svg
|
||||
@@ -277,7 +277,7 @@ export function Toolbar() {
|
||||
onClick={() => useCanvasStore.getState().setSearchOpen(true)}
|
||||
aria-label="Search workspaces"
|
||||
title="Search (⌘K)"
|
||||
className="flex items-center justify-center w-7 h-7 bg-surface-card/50 hover:bg-surface-card/50 border border-line/40 rounded-lg transition-colors text-ink-soft hover:text-ink-mid"
|
||||
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5" />
|
||||
@@ -290,7 +290,7 @@ export function Toolbar() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen((open) => !open)}
|
||||
className="flex items-center justify-center w-7 h-7 bg-surface-card/50 hover:bg-surface-card/50 border border-line/40 rounded-lg transition-colors text-ink-soft hover:text-ink-mid"
|
||||
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
aria-expanded={helpOpen}
|
||||
aria-label="Open quick help"
|
||||
title="Help — shortcuts & quick start"
|
||||
@@ -308,7 +308,7 @@ export function Toolbar() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen(false)}
|
||||
className="text-[10px] text-ink-soft hover:text-ink-mid transition-colors"
|
||||
className="text-[10px] text-ink-mid hover:text-ink transition-colors focus:outline-none focus-visible:underline"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
@@ -358,7 +358,7 @@ function WsStatusPill({ status }: { status: "connected" | "connecting" | "discon
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" title="Real-time updates: connected" aria-label="Real-time updates: connected">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${statusDotClass("online")}`} aria-hidden="true" />
|
||||
<span className="text-[10px] text-ink-soft" aria-hidden="true">Live</span>
|
||||
<span className="text-[10px] text-ink-mid" aria-hidden="true">Live</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -366,14 +366,14 @@ function WsStatusPill({ status }: { status: "connected" | "connecting" | "discon
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" title="Real-time updates: reconnecting…" aria-label="Real-time updates: reconnecting">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 motion-safe:animate-pulse" aria-hidden="true" />
|
||||
<span className="text-[10px] text-ink-soft" aria-hidden="true">Reconnecting</span>
|
||||
<span className="text-[10px] text-warm" aria-hidden="true">Reconnecting</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" title="Real-time updates: disconnected" aria-label="Real-time updates: disconnected">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${statusDotClass("failed")}`} aria-hidden="true" />
|
||||
<span className="text-[10px] text-ink-soft" aria-hidden="true">Offline</span>
|
||||
<span className="text-[10px] text-bad" aria-hidden="true">Offline</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -384,7 +384,7 @@ function HelpRow({ shortcut, text }: { shortcut: string; text: string }) {
|
||||
<span className="shrink-0 rounded-md border border-line/60 bg-surface/70 px-2 py-0.5 text-[9px] font-medium uppercase tracking-[0.18em] text-ink-mid">
|
||||
{shortcut}
|
||||
</span>
|
||||
<p className="text-[11px] leading-relaxed text-ink-soft">{text}</p>
|
||||
<p className="text-[11px] leading-relaxed text-ink-mid">{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,24 @@ export function Tooltip({ text, children }: Props) {
|
||||
|
||||
useEffect(() => () => clearTimeout(timerRef.current), []);
|
||||
|
||||
// WCAG 1.4.13 (Content on Hover or Focus) — Dismissible: a mechanism
|
||||
// is available to dismiss the additional content WITHOUT moving
|
||||
// pointer hover or keyboard focus. Esc dismisses while the trigger
|
||||
// stays focused/hovered, so a screen-magnifier user can read what
|
||||
// the tooltip was covering without losing their place.
|
||||
useEffect(() => {
|
||||
if (!show) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
clearTimeout(timerRef.current);
|
||||
setShow(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => window.removeEventListener("keydown", onKey, true);
|
||||
}, [show]);
|
||||
|
||||
const enter = useCallback(() => {
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (triggerRef.current) {
|
||||
|
||||
@@ -36,7 +36,7 @@ function EjectIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
|
||||
export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>) {
|
||||
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline;
|
||||
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-ink-soft bg-surface-card" };
|
||||
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-ink-mid bg-surface-card border border-line" };
|
||||
// Org-deploy context — four derived flags off one store subscription.
|
||||
// Drives the shimmer while provisioning, the dimmed/non-draggable
|
||||
// treatment on locked descendants, and the Cancel pill on the root.
|
||||
@@ -179,7 +179,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{hasChildren && (
|
||||
<span className="text-[10px] font-mono text-violet-300 bg-violet-900/40 border border-violet-700/30 px-1.5 py-0.5 rounded-md">
|
||||
<span className="text-[10px] font-mono text-accent bg-accent/15 border border-accent/40 px-1.5 py-0.5 rounded-md">
|
||||
{descendantCount} sub
|
||||
</span>
|
||||
)}
|
||||
@@ -207,13 +207,13 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
{runtime === "external" ? (
|
||||
<span
|
||||
className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-violet-200 bg-violet-900/50 border border-violet-500/40"
|
||||
className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-white bg-violet-600 border border-violet-700"
|
||||
title="Phase 30 remote agent — runs outside this platform's Docker network. Lifecycle managed via heartbeat-based polling, not Docker exec."
|
||||
>
|
||||
★ REMOTE
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-ink-mid bg-surface-card/60 border border-line/30">
|
||||
<span className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-ink-mid bg-surface-card border border-line">
|
||||
{runtime}
|
||||
</span>
|
||||
)}
|
||||
@@ -237,15 +237,15 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
key={skill}
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-md border ${
|
||||
isOnline
|
||||
? "text-good/80 bg-emerald-950/30 border-emerald-800/30"
|
||||
: "text-ink-mid bg-surface-card/60 border-line/40"
|
||||
? "text-good bg-good/15 border-good/40"
|
||||
: "text-ink-mid bg-surface-card border-line"
|
||||
}`}
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{skills.length > 4 && (
|
||||
<span className="text-[10px] text-ink-soft self-center">
|
||||
<span className="text-[10px] text-ink-mid self-center">
|
||||
+{skills.length - 4}
|
||||
</span>
|
||||
)}
|
||||
@@ -274,10 +274,10 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
e.stopPropagation();
|
||||
useCanvasStore.getState().restartWorkspace(id).catch(() => showToast("Restart failed", "error"));
|
||||
}}
|
||||
className="flex items-center gap-1.5 mt-1 w-full bg-sky-950/30 px-2 py-1 rounded-md border border-sky-800/30 hover:bg-sky-900/40 transition-colors text-left focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:outline-none"
|
||||
className="flex items-center gap-1.5 mt-1 w-full bg-accent/10 px-2 py-1 rounded-md border border-accent/40 hover:bg-accent/20 transition-colors text-left focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:outline-none"
|
||||
>
|
||||
<span className="text-[10px]">↻</span>
|
||||
<span className="text-[10px] text-sky-300/80">Restart to apply changes</span>
|
||||
<span className="text-[10px] text-accent">↻</span>
|
||||
<span className="text-[10px] text-accent">Restart to apply changes</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -287,8 +287,8 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
<div className={`text-[10px] uppercase tracking-widest font-medium ${
|
||||
data.status === "failed" ? "text-bad" :
|
||||
data.status === "degraded" ? "text-warm" :
|
||||
data.status === "provisioning" ? "text-sky-400" :
|
||||
"text-ink-soft"
|
||||
data.status === "provisioning" ? "text-accent" :
|
||||
"text-ink-mid"
|
||||
}`}>
|
||||
{statusCfg.label}
|
||||
</div>
|
||||
@@ -296,8 +296,8 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
|
||||
{data.activeTasks > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="w-1 h-1 rounded-full bg-amber-400 motion-safe:animate-pulse" />
|
||||
<span className="text-[10px] text-warm/80 tabular-nums">
|
||||
<div className="w-1 h-1 rounded-full bg-warm motion-safe:animate-pulse" />
|
||||
<span className="text-[10px] text-warm tabular-nums">
|
||||
{data.activeTasks} task{data.activeTasks > 1 ? "s" : ""}
|
||||
</span>
|
||||
</div>
|
||||
@@ -307,7 +307,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
{/* Degraded error preview */}
|
||||
{data.status === "degraded" && data.lastSampleError && (
|
||||
<div
|
||||
className="text-[10px] text-warm/60 truncate mt-1 bg-amber-950/20 px-1.5 py-0.5 rounded border border-amber-800/20"
|
||||
className="text-[10px] text-warm truncate mt-1 bg-warm/10 px-1.5 py-0.5 rounded border border-warm/40"
|
||||
title={data.lastSampleError}
|
||||
>
|
||||
{data.lastSampleError}
|
||||
@@ -357,7 +357,7 @@ function TeamMemberChip({
|
||||
}) {
|
||||
const { data } = node;
|
||||
const statusCfg = STATUS_CONFIG[data.status] || STATUS_CONFIG.offline;
|
||||
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-ink-soft bg-surface-card" };
|
||||
const tierCfg = TIER_CONFIG[data.tier] || { label: `T${data.tier}`, color: "text-ink-mid bg-surface-card border border-line" };
|
||||
const isOnline = data.status === "online";
|
||||
const skills = getSkillNames(data.agentCard);
|
||||
|
||||
@@ -408,7 +408,7 @@ function TeamMemberChip({
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{hasSubChildren && (
|
||||
<span className="text-[7px] font-mono text-violet-300 bg-violet-900/40 border border-violet-700/30 px-1 py-0.5 rounded">
|
||||
<span className="text-[7px] font-mono text-accent bg-accent/15 border border-accent/40 px-1 py-0.5 rounded">
|
||||
{descendantCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -423,7 +423,7 @@ function TeamMemberChip({
|
||||
e.stopPropagation();
|
||||
onExtract(node.id);
|
||||
}}
|
||||
className="opacity-0 group-hover/child:opacity-100 text-ink-soft hover:text-sky-400 transition-all focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:outline-none rounded"
|
||||
className="opacity-0 group-hover/child:opacity-100 text-ink-mid hover:text-accent transition-all focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:outline-none rounded"
|
||||
>
|
||||
<EjectIcon aria-hidden="true" />
|
||||
</button>
|
||||
@@ -432,7 +432,7 @@ function TeamMemberChip({
|
||||
|
||||
{/* Role */}
|
||||
{data.role && (
|
||||
<div className="text-[10px] text-ink-soft mb-1 leading-tight truncate">{data.role}</div>
|
||||
<div className="text-[10px] text-ink-mid mb-1 leading-tight truncate">{data.role}</div>
|
||||
)}
|
||||
|
||||
{/* Skills */}
|
||||
@@ -443,8 +443,8 @@ function TeamMemberChip({
|
||||
key={skill}
|
||||
className={`text-[10px] px-1 py-0.5 rounded border ${
|
||||
isOnline
|
||||
? "text-good/70 bg-emerald-950/20 border-emerald-800/20"
|
||||
: "text-ink-soft bg-surface-card/40 border-line/30"
|
||||
? "text-good bg-good/15 border-good/40"
|
||||
: "text-ink-mid bg-surface-card border-line"
|
||||
}`}
|
||||
>
|
||||
{skill}
|
||||
@@ -462,8 +462,8 @@ function TeamMemberChip({
|
||||
<span className={`text-[10px] uppercase tracking-widest font-medium ${
|
||||
data.status === "failed" ? "text-bad" :
|
||||
data.status === "degraded" ? "text-warm" :
|
||||
data.status === "provisioning" ? "text-sky-400" :
|
||||
"text-ink-soft"
|
||||
data.status === "provisioning" ? "text-accent" :
|
||||
"text-ink-mid"
|
||||
}`}>
|
||||
{statusCfg.label}
|
||||
</span>
|
||||
|
||||
@@ -130,6 +130,26 @@ describe("BatchActionBar", () => {
|
||||
const toolbar = screen.getByRole("toolbar");
|
||||
expect(toolbar.getAttribute("aria-label")).toBe("Batch workspace actions");
|
||||
});
|
||||
|
||||
it("Esc clears the selection — matches the deselect button title", () => {
|
||||
// The deselect button has been promising "Clear selection (Escape)"
|
||||
// since the bar shipped, but no handler was wired. This pins the
|
||||
// contract.
|
||||
mockSelectedNodeIds = new Set(["ws-1", "ws-2"]);
|
||||
render(<BatchActionBar />);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(mockClearSelection).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("Esc is a no-op when nothing is selected", () => {
|
||||
mockSelectedNodeIds = new Set<string>();
|
||||
render(<BatchActionBar />);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
// The early-return at count===0 prevents the bar from mounting at all,
|
||||
// so the keydown listener never registers. clearSelection must NOT be
|
||||
// called.
|
||||
expect(mockClearSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,7 +40,7 @@ afterEach(() => {
|
||||
describe("CookieConsent", () => {
|
||||
it("renders the banner when no decision is stored", () => {
|
||||
render(<CookieConsent />);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
expect(screen.getByRole("region")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Accept all" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Necessary only" })).toBeTruthy();
|
||||
});
|
||||
@@ -48,7 +48,7 @@ describe("CookieConsent", () => {
|
||||
it("stores 'accepted' and hides the banner when user clicks Accept all", () => {
|
||||
render(<CookieConsent />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Accept all" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(screen.queryByRole("region")).toBeNull();
|
||||
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
expect(raw).not.toBeNull();
|
||||
@@ -61,7 +61,7 @@ describe("CookieConsent", () => {
|
||||
it("stores 'rejected' and hides the banner when user clicks Necessary only", () => {
|
||||
render(<CookieConsent />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Necessary only" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(screen.queryByRole("region")).toBeNull();
|
||||
|
||||
const parsed = JSON.parse(window.localStorage.getItem(STORAGE_KEY)!);
|
||||
expect(parsed.decision).toBe("rejected");
|
||||
@@ -73,7 +73,7 @@ describe("CookieConsent", () => {
|
||||
JSON.stringify({ decision: "accepted", decidedAt: new Date().toISOString(), version: 1 }),
|
||||
);
|
||||
render(<CookieConsent />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(screen.queryByRole("region")).toBeNull();
|
||||
});
|
||||
|
||||
it("re-prompts when the stored decision is on an older policy version", () => {
|
||||
@@ -82,13 +82,13 @@ describe("CookieConsent", () => {
|
||||
JSON.stringify({ decision: "accepted", decidedAt: new Date().toISOString(), version: 0 }),
|
||||
);
|
||||
render(<CookieConsent />);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
expect(screen.getByRole("region")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("re-prompts when localStorage contains invalid JSON", () => {
|
||||
window.localStorage.setItem(STORAGE_KEY, "{not json");
|
||||
render(<CookieConsent />);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
expect(screen.getByRole("region")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("exposes a privacy-policy link with target='_blank'", () => {
|
||||
@@ -99,11 +99,19 @@ describe("CookieConsent", () => {
|
||||
expect(link.getAttribute("rel")).toContain("noreferrer");
|
||||
});
|
||||
|
||||
it("uses role=dialog with aria-labelledby and aria-describedby for screen readers", () => {
|
||||
it("uses role=region (NOT dialog) with aria-labelledby/describedby — banner is informational, not modal", () => {
|
||||
// Regression guard: an earlier version claimed role="dialog"
|
||||
// aria-modal="true" without a focus trap. That falsely told screen
|
||||
// readers the rest of the page was inert, trapping AT users in a
|
||||
// banner they couldn't escape. role="region" lets assistive tech
|
||||
// navigate around it normally; the banner stays informational.
|
||||
render(<CookieConsent />);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog.getAttribute("aria-labelledby")).toBe("cookie-consent-title");
|
||||
expect(dialog.getAttribute("aria-describedby")).toBe("cookie-consent-body");
|
||||
const banner = screen.getByRole("region");
|
||||
expect(banner.getAttribute("aria-labelledby")).toBe("cookie-consent-title");
|
||||
expect(banner.getAttribute("aria-describedby")).toBe("cookie-consent-body");
|
||||
// No aria-modal claim — explicit guard against regression.
|
||||
expect(banner.getAttribute("aria-modal")).toBeNull();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT render on local dev (non-SaaS hostname)", () => {
|
||||
@@ -116,7 +124,7 @@ describe("CookieConsent", () => {
|
||||
value: { ...window.location, hostname: "localhost" },
|
||||
});
|
||||
render(<CookieConsent />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(screen.queryByRole("region")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT render on a LAN hostname (192.168.*, *.local)", () => {
|
||||
@@ -125,7 +133,7 @@ describe("CookieConsent", () => {
|
||||
value: { ...window.location, hostname: "192.168.1.74" },
|
||||
});
|
||||
render(<CookieConsent />);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(screen.queryByRole("region")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -155,18 +155,31 @@ describe("SearchDialog — keyboard accessibility", () => {
|
||||
expect(selectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("typing a new query resets focusedIndex to -1", () => {
|
||||
it("typing a query that matches auto-highlights the first result", () => {
|
||||
// Replaces the older "resets to -1" assertion. New behavior: a query
|
||||
// with at least one match pins the highlight to row 0 so Enter picks
|
||||
// a result instead of being a no-op. Empty-query case is covered by
|
||||
// "Enter at focusedIndex=-1 does not select anything" above.
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.change(input, { target: { value: "Alpha" } });
|
||||
const options = screen.getAllByRole("option");
|
||||
expect(options[0].getAttribute("aria-selected")).toBe("true");
|
||||
// Enter on the auto-highlighted match should select it without
|
||||
// needing a manual ArrowDown first.
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(selectNode).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
|
||||
it("typing a query that matches NOTHING resets focusedIndex to -1", () => {
|
||||
render(<SearchDialog />);
|
||||
const input = screen.getByRole("combobox");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" }); // focusedIndex → 0
|
||||
// Verify selection before reset
|
||||
expect(screen.getAllByRole("option")[0].getAttribute("aria-selected")).toBe("true");
|
||||
// Change query — triggers the useEffect that resets focusedIndex
|
||||
fireEvent.change(input, { target: { value: "Alpha" } });
|
||||
// After reset all options must have aria-selected="false"
|
||||
screen.getAllByRole("option").forEach((opt) => {
|
||||
expect(opt.getAttribute("aria-selected")).toBe("false");
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "zzz-no-match" } });
|
||||
// No options remain, so nothing to assert on aria-selected directly —
|
||||
// the empty-state message takes over. But Enter should be a no-op.
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(selectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("aria-activedescendant matches the focused option's id", () => {
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { Toaster, showToast } from "../Toaster";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Toaster keyboard a11y", () => {
|
||||
it("Esc dismisses the most recent toast", () => {
|
||||
render(<Toaster />);
|
||||
act(() => {
|
||||
showToast("first", "info");
|
||||
showToast("second", "info");
|
||||
});
|
||||
expect(screen.getByText("first")).toBeTruthy();
|
||||
expect(screen.getByText("second")).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
});
|
||||
expect(screen.queryByText("second")).toBeNull();
|
||||
expect(screen.getByText("first")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Esc dismisses persistent error toasts", () => {
|
||||
render(<Toaster />);
|
||||
act(() => {
|
||||
showToast("boom", "error");
|
||||
});
|
||||
expect(screen.getByText("boom")).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
});
|
||||
expect(screen.queryByText("boom")).toBeNull();
|
||||
});
|
||||
|
||||
it("Esc with no toasts is a no-op", () => {
|
||||
render(<Toaster />);
|
||||
act(() => {
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
});
|
||||
// no throw, nothing rendered
|
||||
expect(screen.queryAllByRole("button", { name: "Dismiss notification" })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("dismiss button has accessible label and is keyboard reachable", () => {
|
||||
render(<Toaster />);
|
||||
act(() => {
|
||||
showToast("hi", "info");
|
||||
});
|
||||
const btn = screen.getByRole("button", { name: "Dismiss notification" });
|
||||
expect(btn).toBeTruthy();
|
||||
// Native <button> defaults to keyboard-focusable; explicit assertion guards
|
||||
// against a future regression where someone adds tabindex=-1.
|
||||
expect(btn.getAttribute("tabindex")).not.toBe("-1");
|
||||
});
|
||||
|
||||
it("dismiss button click removes that specific toast", () => {
|
||||
render(<Toaster />);
|
||||
act(() => {
|
||||
showToast("a", "info");
|
||||
showToast("b", "info");
|
||||
});
|
||||
const buttons = screen.getAllByRole("button", { name: "Dismiss notification" });
|
||||
expect(buttons).toHaveLength(2);
|
||||
|
||||
// Click the first dismiss → "a" goes away, "b" stays
|
||||
act(() => {
|
||||
fireEvent.click(buttons[0]);
|
||||
});
|
||||
expect(screen.queryByText("a")).toBeNull();
|
||||
expect(screen.getByText("b")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -110,8 +110,11 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
Full Trace
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadActivities}
|
||||
className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[11px] rounded text-ink-mid"
|
||||
// hover:bg-surface-card on top of itself was a no-op;
|
||||
// lift to surface-elevated + focus-visible ring.
|
||||
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[11px] rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
|
||||
@@ -365,8 +365,12 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
<p className="text-[10px] text-bad">{formError}</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreate}
|
||||
className="w-full text-xs py-1.5 rounded bg-accent-strong hover:bg-accent text-white transition"
|
||||
// Was bg-accent-strong hover:bg-accent — accent is the
|
||||
// LIGHTER variant; same AA contrast trap fixed in
|
||||
// ScheduleTab/MemoryTab/OnboardingWizard.
|
||||
className="w-full text-xs py-1.5 rounded bg-accent hover:bg-accent-strong text-white transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface"
|
||||
>
|
||||
Connect Channel
|
||||
</button>
|
||||
|
||||
@@ -177,10 +177,10 @@ export function ChatTab({ workspaceId, data }: Props) {
|
||||
aria-controls="chat-panel-my-chat"
|
||||
tabIndex={subTab === "my-chat" ? 0 : -1}
|
||||
onClick={() => setSubTab("my-chat")}
|
||||
className={`px-3 py-1.5 text-[10px] font-medium transition-colors ${
|
||||
className={`px-3 py-1.5 text-[10px] font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
|
||||
subTab === "my-chat"
|
||||
? "text-ink border-b-2 border-accent"
|
||||
: "text-ink-soft hover:text-ink-mid"
|
||||
: "text-ink-mid hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
My Chat
|
||||
@@ -192,10 +192,10 @@ export function ChatTab({ workspaceId, data }: Props) {
|
||||
aria-controls="chat-panel-agent-comms"
|
||||
tabIndex={subTab === "agent-comms" ? 0 : -1}
|
||||
onClick={() => setSubTab("agent-comms")}
|
||||
className={`px-3 py-1.5 text-[10px] font-medium transition-colors ${
|
||||
className={`px-3 py-1.5 text-[10px] font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
|
||||
subTab === "agent-comms"
|
||||
? "text-ink border-b-2 border-accent"
|
||||
: "text-ink-soft hover:text-ink-mid"
|
||||
: "text-ink-mid hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
Agent Comms
|
||||
@@ -773,14 +773,39 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
|
||||
msg.role === "user"
|
||||
? "bg-accent-strong/30 text-blue-100 border border-accent/20"
|
||||
// Solid blue-600 in both modes — `bg-accent` themes
|
||||
// lighter in dark, dropping white-text contrast to
|
||||
// ~3:1 (fails AA). blue-600 keeps ~5:1 against white
|
||||
// on both warm-paper and dark-slate panels.
|
||||
? "bg-blue-600 text-white border border-blue-700 dark:bg-blue-500 dark:border-blue-400 shadow-sm"
|
||||
: msg.role === "system"
|
||||
? "bg-red-900/30 text-red-200 border border-red-800/30"
|
||||
: "bg-surface-card/80 text-ink border border-line/30"
|
||||
// Bump the system bubble's opacity in dark — /10
|
||||
// overlay was nearly invisible against the dark
|
||||
// panel bg.
|
||||
? "bg-bad/10 text-bad border border-bad/40 dark:bg-bad/25 dark:text-bad dark:border-bad/60"
|
||||
// Agent bubble in dark: surface-card (#1a1d23) is
|
||||
// only ~7% lighter than the panel bg-surface
|
||||
// (#0e1014). Bump to zinc-700 for a clearly
|
||||
// elevated bubble; light mode keeps the warm
|
||||
// surface-card tint.
|
||||
: "bg-surface-card text-ink border border-line dark:bg-zinc-700 dark:text-zinc-100 dark:border-zinc-600 shadow-sm"
|
||||
}`}
|
||||
>
|
||||
{msg.content && (
|
||||
<div className="prose prose-sm prose-invert max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0">
|
||||
<div
|
||||
className={`prose prose-sm max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0 ${
|
||||
msg.role === "user"
|
||||
? "prose-invert"
|
||||
// Agent bubbles in dark mode: invert prose AND brighten
|
||||
// the body/heading/bold/code tokens. prose-invert's
|
||||
// default `--tw-prose-invert-body: zinc-300` lands at
|
||||
// ~5.3:1 against bg-zinc-700 — passes AA but reads
|
||||
// washed out next to the user bubble's crisp
|
||||
// white-on-blue (~10:1). Push body to zinc-100 so the
|
||||
// agent text matches that crispness.
|
||||
: "dark:prose-invert dark:[--tw-prose-invert-body:theme(colors.zinc.100)] dark:[--tw-prose-invert-headings:theme(colors.white)] dark:[--tw-prose-invert-bold:theme(colors.white)] dark:[--tw-prose-invert-code:theme(colors.zinc.100)]"
|
||||
}`}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
@@ -796,7 +821,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[9px] text-ink-soft mt-1">
|
||||
<div className={`text-[9px] mt-1 ${msg.role === "user" ? "text-white/70" : "text-ink-mid"}`}>
|
||||
{new Date(msg.timestamp).toLocaleTimeString()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -896,7 +921,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
placeholder={agentReachable ? "Send a message... (Shift+Enter for new line, paste images to attach)" : `Agent is ${data.status}`}
|
||||
disabled={!agentReachable || sending}
|
||||
rows={1}
|
||||
className="flex-1 bg-surface-card border border-line rounded-lg px-3 py-2 text-xs text-ink placeholder-zinc-500 focus:outline-none focus:border-accent resize-none disabled:opacity-50"
|
||||
className="flex-1 bg-surface-card border border-line rounded-lg px-3 py-2 text-xs text-ink placeholder-ink-soft dark:bg-zinc-800 dark:border-zinc-600 dark:placeholder-zinc-500 focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 resize-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
|
||||
@@ -65,11 +65,11 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
|
||||
{error && <div className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={handleSave} disabled={saving}
|
||||
className="px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white disabled:opacity-50">
|
||||
className="px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded 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">
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setEditing(false)}
|
||||
className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid">Cancel</button>
|
||||
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded 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>
|
||||
</div>
|
||||
) : (
|
||||
@@ -956,7 +956,8 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
type="button"
|
||||
onClick={() => handleSave(true)}
|
||||
disabled={!isDirty || saving}
|
||||
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-xs rounded text-white disabled:opacity-30 transition-colors"
|
||||
// Same accent-LIGHTER fix shipped on every other tab.
|
||||
className="px-3 py-1.5 bg-accent hover:bg-accent-strong text-xs rounded text-white disabled:opacity-30 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
>
|
||||
{saving ? "Restarting..." : "Save & Restart"}
|
||||
</button>
|
||||
|
||||
@@ -166,7 +166,10 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-3 py-1 bg-accent-strong hover:bg-accent text-xs rounded text-white disabled:opacity-50"
|
||||
// Was bg-accent-strong hover:bg-accent — accent is the
|
||||
// LIGHTER variant; flipped + focus-visible ring (same
|
||||
// trap fix shipped on every other tab).
|
||||
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded 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"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
@@ -322,7 +325,10 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="px-3 py-1 bg-red-600 hover:bg-red-500 text-xs rounded text-white"
|
||||
// hover:bg-red-500 LIGHTER on white text drops AA;
|
||||
// flipped to bg-red-700 + focus-visible danger ring,
|
||||
// matching the ConfirmDialog/DeleteCascade pattern.
|
||||
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-xs rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
>
|
||||
Confirm Delete
|
||||
</button>
|
||||
@@ -334,7 +340,9 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
// Return focus to the trigger so keyboard users aren't stranded
|
||||
deleteButtonRef.current?.focus();
|
||||
}}
|
||||
className="px-3 py-1 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid"
|
||||
// Was hover:bg-surface-card on top of itself (no-op);
|
||||
// lift to surface-elevated.
|
||||
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated hover:text-ink text-xs rounded 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>
|
||||
|
||||
@@ -15,14 +15,20 @@ interface EventEntry {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Use semantic warm-paper tokens so colors flip with theme. Earlier
|
||||
// the table referenced text-yellow-400 / text-purple-400 (Tailwind
|
||||
// raw colors, no theme variant), which read fine in dark mode but
|
||||
// washed out in the warm-paper light theme. text-warm covers the
|
||||
// "degraded" amber tone in both modes; AGENT_CARD_UPDATED is informational
|
||||
// metadata, so reuse text-accent for theme-consistency.
|
||||
const EVENT_COLORS: Record<string, string> = {
|
||||
WORKSPACE_ONLINE: "text-good",
|
||||
WORKSPACE_OFFLINE: "text-ink-mid",
|
||||
WORKSPACE_DEGRADED: "text-yellow-400",
|
||||
WORKSPACE_DEGRADED: "text-warm",
|
||||
WORKSPACE_PROVISIONING: "text-accent",
|
||||
WORKSPACE_REMOVED: "text-bad",
|
||||
WORKSPACE_PROVISION_FAILED: "text-bad",
|
||||
AGENT_CARD_UPDATED: "text-purple-400",
|
||||
AGENT_CARD_UPDATED: "text-accent",
|
||||
};
|
||||
|
||||
export function EventsTab({ workspaceId }: Props) {
|
||||
@@ -64,8 +70,12 @@ export function EventsTab({ workspaceId }: Props) {
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-ink-mid">{events.length} events</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadEvents}
|
||||
className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid"
|
||||
// Was hover:bg-surface-card on top of bg-surface-card — silent
|
||||
// no-op hover. Lift to surface-elevated, matching the Cancel
|
||||
// pattern from ConfirmDialog.
|
||||
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
@@ -81,39 +91,51 @@ export function EventsTab({ workspaceId }: Props) {
|
||||
<p className="text-xs text-ink-soft text-center py-4">No events yet</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{events.map((event) => (
|
||||
<div key={event.id} className="bg-surface-card rounded border border-line">
|
||||
<button
|
||||
onClick={() => setExpanded(expanded === event.id ? null : event.id)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left"
|
||||
>
|
||||
<span
|
||||
className={`text-xs font-mono ${
|
||||
EVENT_COLORS[event.event_type] || "text-ink-mid"
|
||||
}`}
|
||||
{events.map((event) => {
|
||||
const isOpen = expanded === event.id;
|
||||
const panelId = `events-payload-${event.id}`;
|
||||
return (
|
||||
<div key={event.id} className="bg-surface-card rounded border border-line">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(isOpen ? null : event.id)}
|
||||
// aria-expanded + aria-controls so screen readers
|
||||
// announce the open/closed state and link the row to
|
||||
// its payload panel. Without these, AT users hear
|
||||
// a generic "button" with no indication that it
|
||||
// toggles or what it controls.
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={panelId}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left rounded-t hover:bg-surface-elevated/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/50 transition-colors"
|
||||
>
|
||||
{event.event_type}
|
||||
</span>
|
||||
<span className="text-[9px] text-ink-soft ml-auto">
|
||||
{formatTime(event.created_at)}
|
||||
</span>
|
||||
<span className="text-[10px] text-ink-soft">
|
||||
{expanded === event.id ? "▼" : "▶"}
|
||||
</span>
|
||||
</button>
|
||||
<span
|
||||
className={`text-xs font-mono ${
|
||||
EVENT_COLORS[event.event_type] || "text-ink-mid"
|
||||
}`}
|
||||
>
|
||||
{event.event_type}
|
||||
</span>
|
||||
<span className="text-[9px] text-ink-soft ml-auto">
|
||||
{formatTime(event.created_at)}
|
||||
</span>
|
||||
<span aria-hidden="true" className="text-[10px] text-ink-soft">
|
||||
{isOpen ? "▼" : "▶"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{expanded === event.id && (
|
||||
<div className="px-3 pb-2">
|
||||
<pre className="text-[10px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-40">
|
||||
{JSON.stringify(event.payload, null, 2)}
|
||||
</pre>
|
||||
<div className="mt-1 text-[9px] text-ink-soft font-mono">
|
||||
ID: {event.id}
|
||||
{isOpen && (
|
||||
<div id={panelId} className="px-3 pb-2">
|
||||
<pre className="text-[10px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-40">
|
||||
{JSON.stringify(event.payload, null, 2)}
|
||||
</pre>
|
||||
<div className="mt-1 text-[9px] text-ink-soft font-mono">
|
||||
ID: {event.id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -162,25 +162,29 @@ export function FilesTab({ workspaceId }: Props) {
|
||||
/>
|
||||
|
||||
{showDeleteAll && (
|
||||
<div className="mx-3 mt-2 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded space-y-1.5">
|
||||
<p className="text-xs text-bad">Delete all {files.filter((f) => !f.dir).length} files? This cannot be undone.</p>
|
||||
// role=alertdialog so SR users hear this destructive prompt
|
||||
// immediately. Delete-All hovers DARKER (bg-red-700) — same AA
|
||||
// contrast trap that bit ConfirmDialog/ApprovalBanner. Cancel
|
||||
// lifts to surface-elevated instead of the prior no-op hover.
|
||||
<div role="alertdialog" aria-labelledby="files-delete-all-msg" className="mx-3 mt-2 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded space-y-1.5">
|
||||
<p id="files-delete-all-msg" className="text-xs text-bad">Delete all {files.filter((f) => !f.dir).length} files? This cannot be undone.</p>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => { handleDeleteAll(); setShowDeleteAll(false); }} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">Delete All</button>
|
||||
<button type="button" onClick={() => setShowDeleteAll(false)} className="px-2 py-0.5 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid">Cancel</button>
|
||||
<button type="button" onClick={() => { handleDeleteAll(); setShowDeleteAll(false); }} className="px-2 py-0.5 bg-red-600 hover:bg-red-700 text-[10px] rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Delete All</button>
|
||||
<button type="button" onClick={() => setShowDeleteAll(false)} className="px-2 py-0.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mx-3 mt-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">{error}</div>
|
||||
<div role="alert" className="mx-3 mt-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">{error}</div>
|
||||
)}
|
||||
|
||||
{confirmDelete && (
|
||||
<div className="mx-3 mt-2 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded space-y-1.5">
|
||||
<p className="text-xs text-warm">Delete <span className="font-mono">{confirmDelete}</span>{files.find((f) => f.path === confirmDelete && f.dir) ? " and all its contents" : ""}?</p>
|
||||
<div role="alertdialog" aria-labelledby="files-delete-one-msg" className="mx-3 mt-2 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded space-y-1.5">
|
||||
<p id="files-delete-one-msg" className="text-xs text-warm">Delete <span className="font-mono">{confirmDelete}</span>{files.find((f) => f.path === confirmDelete && f.dir) ? " and all its contents" : ""}?</p>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={confirmDeleteFile} className="px-2 py-0.5 bg-red-600 hover:bg-red-500 text-[10px] rounded text-white">Delete</button>
|
||||
<button type="button" onClick={() => setConfirmDelete(null)} className="px-2 py-0.5 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid">Cancel</button>
|
||||
<button type="button" onClick={confirmDeleteFile} className="px-2 py-0.5 bg-red-600 hover:bg-red-700 text-[10px] rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Delete</button>
|
||||
<button type="button" onClick={() => setConfirmDelete(null)} className="px-2 py-0.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -137,14 +137,14 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAwareness((prev) => !prev)}
|
||||
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink"
|
||||
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink"
|
||||
>
|
||||
{showAwareness ? "Collapse" : "Expand"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openAwareness}
|
||||
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink"
|
||||
className="shrink-0 px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
@@ -177,7 +177,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAwareness(true)}
|
||||
className="shrink-0 px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white"
|
||||
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white"
|
||||
>
|
||||
Expand
|
||||
</button>
|
||||
@@ -212,21 +212,21 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((prev) => !prev)}
|
||||
className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid"
|
||||
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid"
|
||||
>
|
||||
{showAdvanced ? "Hide Advanced" : "Advanced"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMemory}
|
||||
className="px-2 py-1 bg-surface-card hover:bg-surface-card text-[10px] rounded text-ink-mid"
|
||||
className="px-2 py-1 bg-surface-card hover:bg-surface-elevated text-[10px] rounded text-ink-mid"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowAdd(!showAdd); if (!showAdd) setShowAdvanced(true); }}
|
||||
className="px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white"
|
||||
className="px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
@@ -262,7 +262,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
className="px-3 py-1 bg-accent-strong hover:bg-accent text-xs rounded text-white"
|
||||
className="px-3 py-1 bg-accent hover:bg-accent-strong text-xs rounded text-white"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
@@ -272,7 +272,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
setShowAdd(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="px-3 py-1 bg-surface-card hover:bg-surface-card text-xs rounded text-ink-mid"
|
||||
className="px-3 py-1 bg-surface-card hover:bg-surface-elevated text-xs rounded text-ink-mid"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -318,7 +318,11 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(entry.key)}
|
||||
className="text-[10px] text-bad hover:text-bad"
|
||||
// hover:text-bad on top of text-bad was a no-op.
|
||||
// Switch to a hover bg + focus-visible ring so
|
||||
// the destructive button visibly responds and
|
||||
// keyboard users see focus.
|
||||
className="text-[10px] text-bad hover:bg-red-950/40 rounded px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
@@ -340,7 +344,7 @@ export function MemoryTab({ workspaceId }: Props) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(true)}
|
||||
className="shrink-0 px-2 py-1 bg-accent-strong hover:bg-accent text-[10px] rounded text-white"
|
||||
className="shrink-0 px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white"
|
||||
>
|
||||
Show
|
||||
</button>
|
||||
|
||||
@@ -269,15 +269,23 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
{error && <div className="text-[10px] text-bad">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={!formCron || !formPrompt}
|
||||
className="text-[11px] px-3 py-1 bg-accent-strong text-white rounded hover:bg-accent disabled:opacity-40 transition-colors"
|
||||
// Was bg-accent-strong hover:bg-accent — accent is the
|
||||
// LIGHTER variant, so this hovered lighter on white text
|
||||
// and dropped contrast below AA. Same trap fixed in
|
||||
// OnboardingWizard, ConfirmDialog, ApprovalBanner.
|
||||
className="text-[11px] px-3 py-1 bg-accent text-white rounded hover:bg-accent-strong disabled:opacity-40 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface"
|
||||
>
|
||||
{editId ? "Update" : "Create"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetForm}
|
||||
className="text-[11px] px-3 py-1 bg-surface-card text-ink-mid rounded hover:bg-surface-card transition-colors"
|
||||
// Was hover:bg-surface-card on top of bg-surface-card —
|
||||
// silent no-op hover. Lift to surface-elevated.
|
||||
className="text-[11px] px-3 py-1 bg-surface-card text-ink-mid rounded hover:bg-surface-elevated hover:text-ink 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>
|
||||
|
||||
@@ -403,7 +403,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
}}
|
||||
placeholder="e.g. github://owner/repo#v1.0"
|
||||
spellCheck={false}
|
||||
className="flex-1 rounded border border-line bg-surface px-2 py-1 text-[10px] text-ink placeholder:text-ink-soft focus:border-violet-600 focus:outline-none"
|
||||
className="flex-1 rounded border border-line bg-surface px-2 py-1 text-[10px] text-ink placeholder:text-ink-soft focus:outline-none focus:border-violet-600 focus-visible:ring-2 focus-visible:ring-violet-600/50"
|
||||
/>
|
||||
<button
|
||||
onClick={handleInstallCustom}
|
||||
|
||||
@@ -123,15 +123,18 @@ export function TerminalTab({ workspaceId }: Props) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Status bar — role="status" so connection state changes are announced politely */}
|
||||
{/* Status bar — role="status" so connection state changes are announced politely.
|
||||
Terminal body stays dark unconditionally (Canvas v4 design rule), but the
|
||||
chrome wrapping it now uses semantic status colors so the dot/text stay
|
||||
readable in both themes. */}
|
||||
<div role="status" aria-live="polite" className="flex items-center justify-between px-3 py-1.5 border-b border-zinc-700 bg-zinc-800/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
status === "connected" ? "bg-green-500" :
|
||||
status === "connecting" ? "bg-yellow-500 motion-safe:animate-pulse" :
|
||||
status === "error" ? "bg-red-500" : "bg-zinc-500"
|
||||
status === "connected" ? "bg-good" :
|
||||
status === "connecting" ? "bg-warm motion-safe:animate-pulse" :
|
||||
status === "error" ? "bg-bad" : "bg-ink-soft"
|
||||
}`} />
|
||||
<span className="text-[10px] text-zinc-400">
|
||||
<span className="text-[10px] text-zinc-300">
|
||||
{status === "connected" ? "Shell active" :
|
||||
status === "connecting" ? "Connecting..." :
|
||||
status === "error" ? "Connection failed" : "Disconnected"}
|
||||
@@ -139,8 +142,13 @@ export function TerminalTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
{(status === "disconnected" || status === "error") && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={reconnect}
|
||||
className="text-[10px] text-blue-400 hover:text-blue-300"
|
||||
// Accent over hardcoded blue. text-accent + hover-strong stays
|
||||
// readable on the dark terminal chrome and matches the rest
|
||||
// of the canvas semantic palette. Focus-visible ring added so
|
||||
// keyboard users see where focus lands on a recovery button.
|
||||
className="text-[10px] text-accent hover:text-accent-strong rounded-sm px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
||||
>
|
||||
Reconnect
|
||||
</button>
|
||||
@@ -149,7 +157,7 @@ export function TerminalTab({ workspaceId }: Props) {
|
||||
|
||||
{/* Error message — role="alert" announces immediately via assertive live region */}
|
||||
{errorMsg && (
|
||||
<div role="alert" className="mx-3 mt-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-red-400">
|
||||
<div role="alert" className="mx-3 mt-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -55,7 +55,13 @@ export function TracesTab({ workspaceId }: Props) {
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs text-ink-mid">{traces.length} traces</span>
|
||||
<button type="button" onClick={loadTraces} className="text-[10px] text-ink-soft hover:text-ink-mid">
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadTraces}
|
||||
// Added focus-visible ring; previous version was hover-only,
|
||||
// invisible to keyboard users.
|
||||
className="text-[10px] text-ink-soft hover:text-ink-mid rounded-sm px-1 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
@@ -79,66 +85,79 @@ export function TracesTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{traces.map((trace) => (
|
||||
<div key={trace.id} className="bg-surface-card/40 border border-line/40 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpanded(expanded === trace.id ? null : trace.id)}
|
||||
className="w-full px-3 py-2 flex items-center gap-2 text-left hover:bg-surface-card/60 transition-colors"
|
||||
>
|
||||
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${
|
||||
trace.status === "ERROR" ? "bg-red-400" : "bg-emerald-400"
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[11px] text-ink truncate">{trace.name || "trace"}</div>
|
||||
<div className="text-[9px] text-ink-soft">{formatTime(trace.timestamp)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{trace.latency != null && (
|
||||
<span className="text-[9px] text-ink-soft tabular-nums">
|
||||
{trace.latency > 1000 ? `${(trace.latency / 1000).toFixed(1)}s` : `${trace.latency}ms`}
|
||||
</span>
|
||||
)}
|
||||
{trace.usage?.total != null && (
|
||||
<span className="text-[9px] text-ink-soft tabular-nums">
|
||||
{trace.usage.total} tok
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[9px] text-ink-soft">
|
||||
{expanded === trace.id ? "▼" : "▶"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded === trace.id && (
|
||||
<div className="px-3 pb-2 space-y-2 border-t border-line/30">
|
||||
{trace.input && (
|
||||
<div>
|
||||
<div className="text-[9px] text-ink-soft uppercase tracking-wider mt-2 mb-1">Input</div>
|
||||
<pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32">
|
||||
{String(typeof trace.input === "string" ? trace.input : JSON.stringify(trace.input, null, 2))}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{trace.output && (
|
||||
<div>
|
||||
<div className="text-[9px] text-ink-soft uppercase tracking-wider mb-1">Output</div>
|
||||
<pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32">
|
||||
{String(typeof trace.output === "string" ? trace.output : JSON.stringify(trace.output, null, 2))}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{trace.totalCost != null && (
|
||||
<div className="text-[9px] text-ink-soft">
|
||||
Cost: ${trace.totalCost.toFixed(6)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[8px] text-ink-soft font-mono select-all">
|
||||
{trace.id}
|
||||
{traces.map((trace) => {
|
||||
const isOpen = expanded === trace.id;
|
||||
const panelId = `trace-detail-${trace.id}`;
|
||||
return (
|
||||
<div key={trace.id} className="bg-surface-card/40 border border-line/40 rounded-lg overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(isOpen ? null : trace.id)}
|
||||
// aria-expanded + aria-controls so SR announces the
|
||||
// open/closed state and links the row to its detail
|
||||
// panel. Same pattern shipped on EventsTab.
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={panelId}
|
||||
className="w-full px-3 py-2 flex items-center gap-2 text-left hover:bg-surface-card/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent/50 transition-colors"
|
||||
>
|
||||
{/* Status dot uses semantic bad/good tokens — was hardcoded
|
||||
bg-red-400 / bg-emerald-400 which doesn't pin to the
|
||||
canvas-wide ramp. */}
|
||||
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${
|
||||
trace.status === "ERROR" ? "bg-bad" : "bg-good"
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[11px] text-ink truncate">{trace.name || "trace"}</div>
|
||||
<div className="text-[9px] text-ink-soft">{formatTime(trace.timestamp)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{trace.latency != null && (
|
||||
<span className="text-[9px] text-ink-soft tabular-nums">
|
||||
{trace.latency > 1000 ? `${(trace.latency / 1000).toFixed(1)}s` : `${trace.latency}ms`}
|
||||
</span>
|
||||
)}
|
||||
{trace.usage?.total != null && (
|
||||
<span className="text-[9px] text-ink-soft tabular-nums">
|
||||
{trace.usage.total} tok
|
||||
</span>
|
||||
)}
|
||||
<span aria-hidden="true" className="text-[9px] text-ink-soft">
|
||||
{isOpen ? "▼" : "▶"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div id={panelId} className="px-3 pb-2 space-y-2 border-t border-line/30">
|
||||
{trace.input && (
|
||||
<div>
|
||||
<div className="text-[9px] text-ink-soft uppercase tracking-wider mt-2 mb-1">Input</div>
|
||||
<pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32">
|
||||
{String(typeof trace.input === "string" ? trace.input : JSON.stringify(trace.input, null, 2))}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{trace.output && (
|
||||
<div>
|
||||
<div className="text-[9px] text-ink-soft uppercase tracking-wider mb-1">Output</div>
|
||||
<pre className="text-[9px] text-ink-mid bg-surface-sunken rounded p-2 overflow-x-auto max-h-32">
|
||||
{String(typeof trace.output === "string" ? trace.output : JSON.stringify(trace.output, null, 2))}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{trace.totalCost != null && (
|
||||
<div className="text-[9px] text-ink-soft">
|
||||
Cost: ${trace.totalCost.toFixed(6)}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[8px] text-ink-soft font-mono select-all">
|
||||
{trace.id}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -472,6 +472,7 @@ function GroupedCommsView({
|
||||
<NormalMessage key={msg.id} msg={msg} />
|
||||
),
|
||||
)}
|
||||
<WaitingBubbles visible={visible} />
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -560,6 +561,83 @@ function PeerTabButton({
|
||||
);
|
||||
}
|
||||
|
||||
/** WaitingBubbles renders one "typing" bubble per peer that has an
|
||||
* in-flight outbound delegation — i.e., the most recent outbound
|
||||
* message to that peer is still pending or queued and no later inbound
|
||||
* reply has arrived. Mirrors the bouncing-dots indicator in ChatTab so
|
||||
* the operator sees the same visual cue regardless of whether they're
|
||||
* watching their own chat or a peer thread.
|
||||
*
|
||||
* Why "per peer" not "one global": when multiple delegations are in
|
||||
* flight to different peers (common during fan-out), one shared
|
||||
* spinner under-reports — the user can't tell whether ALL peers are
|
||||
* still working or only the visible ones. Per-peer matches Slack-style
|
||||
* typing indicators and keeps the signal honest.
|
||||
*
|
||||
* Why we look at the LAST per-peer message: once a peer replies (an
|
||||
* "in" bubble lands), the corresponding "out" bubble is no longer the
|
||||
* tail — even if status hasn't been mutated to "completed", the inbound
|
||||
* reply means the wait is over. Looking at the tail collapses both
|
||||
* cases into one rule.
|
||||
*/
|
||||
function WaitingBubbles({ visible }: { visible: CommMessage[] }) {
|
||||
// Group by peer, keep only the chronologically-last message per peer,
|
||||
// emit a bubble when that tail is an outbound pending/queued.
|
||||
const tailByPeer = new Map<string, CommMessage>();
|
||||
for (const m of visible) {
|
||||
const prev = tailByPeer.get(m.peerId);
|
||||
if (!prev || m.timestamp > prev.timestamp) tailByPeer.set(m.peerId, m);
|
||||
}
|
||||
const waitingPeers = Array.from(tailByPeer.values()).filter(
|
||||
(m) => m.flow === "out" && (m.status === "pending" || m.status === "queued"),
|
||||
);
|
||||
if (waitingPeers.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{waitingPeers.map((m) => (
|
||||
<div
|
||||
key={`waiting-${m.peerId}`}
|
||||
className="flex justify-end"
|
||||
// Outbound thread → right-justified to match the "out" bubble
|
||||
// alignment, so the dots feel like they belong to the message
|
||||
// they're replying to.
|
||||
>
|
||||
<div
|
||||
className="max-w-[85%] rounded-lg px-3 py-2 text-xs bg-cyan-900/30 border border-cyan-700/20"
|
||||
// role+aria-label so screen readers announce the wait;
|
||||
// matches the announcing pattern used by Toaster.
|
||||
role="status"
|
||||
aria-label={`Waiting for reply from ${m.peerName}`}
|
||||
>
|
||||
<div className="text-[9px] text-ink-soft mb-1">→ To {m.peerName}</div>
|
||||
<span className="flex items-center gap-2 text-ink-mid">
|
||||
<span className="flex gap-0.5" aria-hidden="true">
|
||||
<span
|
||||
className="w-1.5 h-1.5 bg-cyan-300/70 rounded-full motion-safe:animate-bounce"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
/>
|
||||
<span
|
||||
className="w-1.5 h-1.5 bg-cyan-300/70 rounded-full motion-safe:animate-bounce"
|
||||
style={{ animationDelay: "150ms" }}
|
||||
/>
|
||||
<span
|
||||
className="w-1.5 h-1.5 bg-cyan-300/70 rounded-full motion-safe:animate-bounce"
|
||||
style={{ animationDelay: "300ms" }}
|
||||
/>
|
||||
</span>
|
||||
<span className="text-[10px]">
|
||||
{m.status === "queued"
|
||||
? `${m.peerName} is busy — reply will arrive when they're free`
|
||||
: `Waiting for ${m.peerName}…`}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NormalMessage({ msg }: { msg: CommMessage }) {
|
||||
return (
|
||||
<div className={`flex ${msg.flow === "out" ? "justify-end" : "justify-start"}`}>
|
||||
@@ -574,12 +652,22 @@ function NormalMessage({ msg }: { msg: CommMessage }) {
|
||||
{msg.flow === "out" ? `→ To ${msg.peerName}` : `← From ${msg.peerName}`}
|
||||
</div>
|
||||
{msg.text ? (
|
||||
<MarkdownBody className="text-ink-mid">{msg.text}</MarkdownBody>
|
||||
// Outgoing bubble (cyan-900) is dark in both themes → prose-invert default.
|
||||
// Incoming bubble (surface-card) themes light → only invert in dark.
|
||||
<MarkdownBody
|
||||
className="text-ink-mid"
|
||||
invert={msg.flow === "out" ? "always" : "dark-only"}
|
||||
>
|
||||
{msg.text}
|
||||
</MarkdownBody>
|
||||
) : (
|
||||
<div className="text-ink-mid">(no message text)</div>
|
||||
)}
|
||||
{msg.responseText && (
|
||||
<MarkdownBody className="mt-1.5 pt-1.5 border-t border-line/30 text-ink-mid">
|
||||
<MarkdownBody
|
||||
className="mt-1.5 pt-1.5 border-t border-line/30 text-ink-mid"
|
||||
invert={msg.flow === "out" ? "always" : "dark-only"}
|
||||
>
|
||||
{msg.responseText}
|
||||
</MarkdownBody>
|
||||
)}
|
||||
@@ -706,17 +794,29 @@ function ErrorMessage({ msg }: { msg: CommMessage }) {
|
||||
* prose tweaks that keep paragraphs tight inside a small bubble.
|
||||
* Code blocks get an `overflow-x-auto` so a long line of code doesn't
|
||||
* blow out the bubble's max-width — agent-to-agent replies routinely
|
||||
* ship code samples and JSON. */
|
||||
* ship code samples and JSON.
|
||||
*
|
||||
* `invert` controls the prose color flip:
|
||||
* - "always": container bg is dark in BOTH themes (cyan-900, red-950),
|
||||
* so prose always wants light body text.
|
||||
* - "dark-only": container bg uses a theming token that goes light in
|
||||
* light mode (e.g. bg-surface-card). Prose only inverts in dark
|
||||
* mode; light mode keeps default dark prose colors against the
|
||||
* light bg. Without this, light mode rendered light text on light
|
||||
* bg = invisible markdown. */
|
||||
function MarkdownBody({
|
||||
children,
|
||||
className,
|
||||
invert = "always",
|
||||
}: {
|
||||
children: string;
|
||||
className?: string;
|
||||
invert?: "always" | "dark-only";
|
||||
}) {
|
||||
const proseInvert = invert === "always" ? "prose-invert" : "dark:prose-invert";
|
||||
return (
|
||||
<div
|
||||
className={`prose prose-sm prose-invert max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0 [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto ${className ?? ""}`}
|
||||
className={`prose prose-sm ${proseInvert} max-w-none [&>p]:mb-1 [&>p:last-child]:mb-0 [&_pre]:overflow-x-auto [&_table]:block [&_table]:overflow-x-auto ${className ?? ""}`}
|
||||
>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
@@ -12,10 +12,10 @@ export function statusDotClass(status: string): string {
|
||||
}
|
||||
|
||||
export const TIER_CONFIG: Record<number, { label: string; color: string; border: string }> = {
|
||||
1: { label: "T1", color: "text-ink-soft bg-surface-card/80", border: "text-ink-mid border-line/60" },
|
||||
2: { label: "T2", color: "text-sky-400 bg-sky-950/50", border: "text-sky-400 border-sky-500/30" },
|
||||
3: { label: "T3", color: "text-violet-400 bg-violet-950/50", border: "text-violet-400 border-violet-500/30" },
|
||||
4: { label: "T4", color: "text-warm bg-amber-950/50", border: "text-warm border-amber-500/30" },
|
||||
1: { label: "T1", color: "text-ink-mid bg-surface-card border border-line", border: "text-ink-mid border-line" },
|
||||
2: { label: "T2", color: "text-white bg-accent border border-accent-strong", border: "text-accent border-accent" },
|
||||
3: { label: "T3", color: "text-white bg-violet-600 border border-violet-700", border: "text-violet-600 border-violet-500" },
|
||||
4: { label: "T4", color: "text-white bg-warm border border-warm", border: "text-warm border-warm" },
|
||||
};
|
||||
|
||||
export const COMM_TYPE_LABELS: Record<string, string> = {
|
||||
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
# Per-runtime model slug dispatch for E2E provisioning.
|
||||
#
|
||||
# Different runtimes parse the model slug differently (PR #2571 incident,
|
||||
# 2026-05-03):
|
||||
#
|
||||
# hermes → "openai/gpt-4o" (slash-form: derive-provider.sh splits
|
||||
# on the prefix to set
|
||||
# HERMES_INFERENCE_PROVIDER. Bare
|
||||
# "gpt-4o" falls through to Anthropic
|
||||
# default + 401, see PR #1714.)
|
||||
#
|
||||
# langgraph → "openai:gpt-4o" (colon-form: langchain init_chat_model
|
||||
# requires "<provider>:<model>".
|
||||
# Slash-form was misinterpreted as
|
||||
# OpenRouter routing → fell through
|
||||
# without auth, surfaced 2026-05-03
|
||||
# after the a2a-sdk v1 contract bugs
|
||||
# PR #2558+#2563+#2567 cleared the
|
||||
# masking layers.)
|
||||
#
|
||||
# claude-code → "sonnet" (entry-id form: claude-code template's
|
||||
# config.yaml uses bare model names,
|
||||
# auth comes via CLAUDE_CODE_OAUTH_TOKEN
|
||||
# or ANTHROPIC_API_KEY rather than the
|
||||
# slug.)
|
||||
#
|
||||
# When E2E_MODEL_SLUG is set, it overrides this dispatch — useful when an
|
||||
# operator dispatches the workflow to test a specific slug.
|
||||
#
|
||||
# Unit tested by tests/e2e/test_model_slug.sh — every branch must stay
|
||||
# pinned because regressions silently mask as "Could not resolve
|
||||
# authentication method" + the synth-E2E gate goes red without naming
|
||||
# the slug-format mismatch.
|
||||
|
||||
# Usage: pick_model_slug <runtime>
|
||||
# stdout: the slug string
|
||||
# E2E_MODEL_SLUG (env): if set + non-empty, used as-is (operator override)
|
||||
pick_model_slug() {
|
||||
local runtime="${1:-}"
|
||||
if [ -n "${E2E_MODEL_SLUG:-}" ]; then
|
||||
printf '%s' "$E2E_MODEL_SLUG"
|
||||
return 0
|
||||
fi
|
||||
case "$runtime" in
|
||||
hermes) printf 'openai/gpt-4o' ;;
|
||||
langgraph) printf 'openai:gpt-4o' ;;
|
||||
claude-code) printf 'sonnet' ;;
|
||||
*) printf 'openai/gpt-4o' ;; # safest fallback (matches hermes)
|
||||
esac
|
||||
}
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression test for tests/e2e/lib/model_slug.sh.
|
||||
#
|
||||
# PR #2571 fixed a synth-E2E masking bug where MODEL_SLUG was hardcoded
|
||||
# to "openai/gpt-4o" (slash-form) but langgraph's init_chat_model needs
|
||||
# "openai:gpt-4o" (colon-form). Fix shipped as a per-runtime case
|
||||
# statement. Without this regression test, dropping any branch of the
|
||||
# case (or flipping a slug format) would silently revert behavior — the
|
||||
# E2E only fails as "Could not resolve authentication method" at the
|
||||
# very first message, after a successful tenant + workspace provision.
|
||||
#
|
||||
# Each branch must FAIL the test if the dispatch behavior changes, not
|
||||
# just produce some non-empty string.
|
||||
set -uo pipefail
|
||||
|
||||
# Resolve to the lib relative to this test file so the test runs from
|
||||
# any cwd (CI, local invocation, repo root).
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=lib/model_slug.sh
|
||||
source "$SCRIPT_DIR/lib/model_slug.sh"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_eq() {
|
||||
local label="$1" got="$2" want="$3"
|
||||
if [ "$got" = "$want" ]; then
|
||||
echo " ✓ $label"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo " ✗ $label: got=$(printf %q "$got") want=$(printf %q "$want")" >&2
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
run_test() {
|
||||
local label="$1" runtime="$2" want="$3"
|
||||
# Pin per-test isolation: explicitly unset the override so a leaked
|
||||
# E2E_MODEL_SLUG from caller env can't poison the dispatch branches.
|
||||
local got
|
||||
got=$(unset E2E_MODEL_SLUG; pick_model_slug "$runtime")
|
||||
assert_eq "$label" "$got" "$want"
|
||||
}
|
||||
|
||||
echo "Test: pick_model_slug — per-runtime dispatch"
|
||||
echo
|
||||
|
||||
# ── Per-runtime branches (the load-bearing ones for synth-E2E) ──
|
||||
run_test "hermes → slash-form (derive-provider.sh contract)" hermes "openai/gpt-4o"
|
||||
run_test "langgraph → colon-form (init_chat_model contract)" langgraph "openai:gpt-4o"
|
||||
run_test "claude-code → bare model name (entry-id form)" claude-code "sonnet"
|
||||
|
||||
# ── Fallback for unknown runtime ──
|
||||
# Picks slash-form (hermes-shaped) since hermes is the historical
|
||||
# default and most third-party runtimes behave hermes-like. Pinning
|
||||
# this so a future "smarter" fallback (e.g., empty string, error) is
|
||||
# a deliberate choice, not silent drift.
|
||||
run_test "unknown runtime → slash-form fallback" gemini "openai/gpt-4o"
|
||||
run_test "empty runtime → slash-form fallback" "" "openai/gpt-4o"
|
||||
|
||||
# ── Override via E2E_MODEL_SLUG ──
|
||||
# When the operator sets E2E_MODEL_SLUG, the per-runtime dispatch is
|
||||
# bypassed. Used during workflow_dispatch to A/B specific slugs.
|
||||
echo
|
||||
echo "Test: pick_model_slug — E2E_MODEL_SLUG override"
|
||||
echo
|
||||
|
||||
got=$(E2E_MODEL_SLUG="anthropic:claude-opus-4-7" pick_model_slug langgraph)
|
||||
assert_eq "override beats langgraph default" "$got" "anthropic:claude-opus-4-7"
|
||||
|
||||
got=$(E2E_MODEL_SLUG="custom/whatever" pick_model_slug hermes)
|
||||
assert_eq "override beats hermes default" "$got" "custom/whatever"
|
||||
|
||||
got=$(E2E_MODEL_SLUG="some-bare-id" pick_model_slug claude-code)
|
||||
assert_eq "override beats claude-code default" "$got" "some-bare-id"
|
||||
|
||||
# Empty-string override does NOT activate (falls through to dispatch).
|
||||
# This is the historical bash idiom: -n "" → false → no override. Pin
|
||||
# it because changing this behavior (e.g. via -v test) would silently
|
||||
# break the dispatch when an operator passes "" to clear an inherited
|
||||
# env var.
|
||||
got=$(E2E_MODEL_SLUG="" pick_model_slug langgraph)
|
||||
assert_eq "empty-string override falls through to dispatch" "$got" "openai:gpt-4o"
|
||||
|
||||
echo
|
||||
echo "─────────────────────────────────────────────────"
|
||||
echo "PASSED: $PASS"
|
||||
echo "FAILED: $FAIL"
|
||||
echo "─────────────────────────────────────────────────"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+145
@@ -0,0 +1,145 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression test for the SECRETS_JSON branching in
|
||||
# tests/e2e/test_staging_full_saas.sh (lines ~322-368).
|
||||
#
|
||||
# The synth-E2E canary picks one of two LLM auth paths based on which
|
||||
# E2E_*_API_KEY is set. The branch order is load-bearing:
|
||||
#
|
||||
# E2E_MINIMAX_API_KEY first → claude-code MiniMax path (cheap canary
|
||||
# default since 2026-05-03; routes via
|
||||
# workspace-configs-templates/claude-
|
||||
# code-default/config.yaml's `minimax`
|
||||
# provider entry).
|
||||
#
|
||||
# E2E_OPENAI_API_KEY second → langgraph + hermes legacy path (kept
|
||||
# as fallback for operator dispatches
|
||||
# that need the OpenAI-shaped
|
||||
# HERMES_CUSTOM_* env block).
|
||||
#
|
||||
# Without this gate, a future "tidy up the if/elif" refactor could
|
||||
# silently flip the precedence (OpenAI wins when both are set →
|
||||
# claude-code workspace boots without MINIMAX_API_KEY → 401 at first
|
||||
# turn → canary red without any signal that the wrong key shape was
|
||||
# selected). The 2026-05-03 OpenAI-quota incident took ~16h to
|
||||
# diagnose for exactly this class of "looks like an LLM problem,
|
||||
# was actually a wiring problem" failure.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SAAS_SCRIPT="$SCRIPT_DIR/test_staging_full_saas.sh"
|
||||
|
||||
if [ ! -f "$SAAS_SCRIPT" ]; then
|
||||
echo "FATAL: cannot locate test_staging_full_saas.sh at $SAAS_SCRIPT" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_eq() {
|
||||
local label="$1" got="$2" want="$3"
|
||||
if [ "$got" = "$want" ]; then
|
||||
echo " ✓ $label"
|
||||
PASS=$((PASS+1))
|
||||
else
|
||||
echo " ✗ $label" >&2
|
||||
echo " got: $got" >&2
|
||||
echo " want: $want" >&2
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Extract just the SECRETS_JSON block from the saas script and source
|
||||
# it into a sub-shell so we can run the branching logic in isolation.
|
||||
# Anchor on the comment header so a structural refactor that moves the
|
||||
# block fails this test loudly rather than silently sourcing nothing.
|
||||
extract_block() {
|
||||
awk '
|
||||
/^# ─── 5\. Provision parent workspace/ {capture=1; next}
|
||||
capture && /^MODEL_SLUG=/ {exit}
|
||||
capture {print}
|
||||
' "$SAAS_SCRIPT"
|
||||
}
|
||||
|
||||
BLOCK=$(extract_block)
|
||||
if [ -z "$BLOCK" ]; then
|
||||
echo "FATAL: SECRETS_JSON block not found in $SAAS_SCRIPT — refactor anchor changed?" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Run the extracted block in a clean env, capturing SECRETS_JSON.
|
||||
run_block() {
|
||||
# Caller passes vars on the command line, e.g.
|
||||
# run_block E2E_MINIMAX_API_KEY=mx-test
|
||||
env -i PATH="$PATH" "$@" bash -c "
|
||||
set -uo pipefail
|
||||
$BLOCK
|
||||
echo \"\$SECRETS_JSON\"
|
||||
" 2>/dev/null | tail -1
|
||||
}
|
||||
|
||||
# Resolve a JSON key from the captured payload using python3 (already
|
||||
# a hard dep of the saas script). Returns empty string on missing key.
|
||||
get_json_key() {
|
||||
local payload="$1" key="$2"
|
||||
python3 -c "
|
||||
import json, sys
|
||||
p = json.loads(sys.argv[1])
|
||||
print(p.get(sys.argv[2], ''))
|
||||
" "$payload" "$key"
|
||||
}
|
||||
|
||||
list_json_keys() {
|
||||
python3 -c "
|
||||
import json, sys
|
||||
p = json.loads(sys.argv[1])
|
||||
print(','.join(sorted(p.keys())))
|
||||
" "$1"
|
||||
}
|
||||
|
||||
echo "Test: SECRETS_JSON branching in test_staging_full_saas.sh"
|
||||
echo
|
||||
|
||||
# ── Branch 1: MiniMax wins when set ──
|
||||
SECRETS_JSON=$(run_block E2E_MINIMAX_API_KEY=mx-test)
|
||||
assert_eq "MiniMax key set → MINIMAX_API_KEY in payload" \
|
||||
"$(get_json_key "$SECRETS_JSON" MINIMAX_API_KEY)" "mx-test"
|
||||
assert_eq "MiniMax-only payload contains exactly MINIMAX_API_KEY" \
|
||||
"$(list_json_keys "$SECRETS_JSON")" "MINIMAX_API_KEY"
|
||||
|
||||
# ── Branch 1 precedence: MiniMax beats OpenAI when both set ──
|
||||
# Critical: the 2026-05-03 incident shape was "two paths exist, wrong
|
||||
# one wins". The bash if/elif must keep MiniMax above OpenAI so the
|
||||
# claude-code default canary doesn't accidentally use the (more
|
||||
# expensive, quota-burnt) OpenAI key.
|
||||
SECRETS_JSON=$(run_block E2E_MINIMAX_API_KEY=mx-priority E2E_OPENAI_API_KEY=oai-loser)
|
||||
assert_eq "Both keys set → MiniMax wins" \
|
||||
"$(get_json_key "$SECRETS_JSON" MINIMAX_API_KEY)" "mx-priority"
|
||||
assert_eq "Both keys set → OpenAI block NOT emitted" \
|
||||
"$(get_json_key "$SECRETS_JSON" OPENAI_API_KEY)" ""
|
||||
assert_eq "Both keys set → no HERMES_* leakage from OpenAI branch" \
|
||||
"$(get_json_key "$SECRETS_JSON" HERMES_INFERENCE_PROVIDER)" ""
|
||||
|
||||
# ── Branch 2: OpenAI used when MiniMax absent ──
|
||||
SECRETS_JSON=$(run_block E2E_OPENAI_API_KEY=oai-test)
|
||||
assert_eq "Only OpenAI set → OPENAI_API_KEY in payload" \
|
||||
"$(get_json_key "$SECRETS_JSON" OPENAI_API_KEY)" "oai-test"
|
||||
assert_eq "Only OpenAI set → HERMES_CUSTOM_API_KEY mirrors OpenAI key" \
|
||||
"$(get_json_key "$SECRETS_JSON" HERMES_CUSTOM_API_KEY)" "oai-test"
|
||||
assert_eq "Only OpenAI set → MODEL_PROVIDER pinned to colon-form" \
|
||||
"$(get_json_key "$SECRETS_JSON" MODEL_PROVIDER)" "openai:gpt-4o"
|
||||
assert_eq "Only OpenAI set → MINIMAX_API_KEY NOT emitted" \
|
||||
"$(get_json_key "$SECRETS_JSON" MINIMAX_API_KEY)" ""
|
||||
|
||||
# ── No keys: empty payload ──
|
||||
SECRETS_JSON=$(run_block)
|
||||
assert_eq "No keys set → SECRETS_JSON is empty object" \
|
||||
"$SECRETS_JSON" "{}"
|
||||
|
||||
echo
|
||||
echo "─────────────────────────────────────────────────"
|
||||
echo "PASSED: $PASS"
|
||||
echo "FAILED: $FAIL"
|
||||
echo "─────────────────────────────────────────────────"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -67,6 +67,12 @@ log() { echo "[$(date +%H:%M:%S)] $*"; }
|
||||
fail() { echo "[$(date +%H:%M:%S)] ❌ $*" >&2; exit 1; }
|
||||
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
|
||||
|
||||
# Per-runtime model slug dispatch — see lib/model_slug.sh for the rationale.
|
||||
# Extracted so unit tests (tests/e2e/test_model_slug.sh) can pin every branch
|
||||
# without booting the full 11-step lifecycle.
|
||||
# shellcheck source=lib/model_slug.sh
|
||||
source "$(dirname "$0")/lib/model_slug.sh"
|
||||
|
||||
CURL_COMMON=(-sS --fail-with-body --max-time 30)
|
||||
|
||||
# ─── cleanup trap ───────────────────────────────────────────────────────
|
||||
@@ -314,29 +320,39 @@ tenant_call() {
|
||||
}
|
||||
|
||||
# ─── 5. Provision parent workspace ─────────────────────────────────────
|
||||
# Runtimes like hermes crash at boot with "No provider API key found"
|
||||
# if nothing in the standard env-var list is set. Inject the API key
|
||||
# from E2E_OPENAI_API_KEY so the runtime can actually start — it's
|
||||
# per-workspace secret, so it's persisted as a workspace_secret and
|
||||
# materialized into the container env. Missing key falls through to
|
||||
# an empty secrets map; workspace will still fail but the error is
|
||||
# expected and actionable.
|
||||
# Inject the LLM provider key so the runtime can authenticate at boot.
|
||||
# Branch by which secret is set so the script supports both paths
|
||||
# without forcing every dispatch to ship both keys:
|
||||
#
|
||||
# E2E_MINIMAX_API_KEY → claude-code MiniMax path. Cheapest, default
|
||||
# for the cron canary post-2026-05-03. Routes via the claude-code
|
||||
# template's `minimax` provider (workspace-configs-templates/
|
||||
# claude-code-default/config.yaml:64-69) which sets
|
||||
# ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic at boot.
|
||||
# MINIMAX_API_KEY is the vendor-specific env name the adapter
|
||||
# reads (PR #244 — per-vendor envs prevent ANTHROPIC_AUTH_TOKEN
|
||||
# collisions when a user runs MiniMax + Z.ai workspaces side-by-
|
||||
# side).
|
||||
#
|
||||
# E2E_OPENAI_API_KEY → langgraph + hermes paths. Kept as fallback
|
||||
# for operator dispatches that explicitly want to exercise the
|
||||
# OpenAI path. The HERMES_* fields pin hermes-agent's bridge to
|
||||
# api.openai.com (template-hermes' derive-provider.sh otherwise
|
||||
# resolves openai/* → openrouter.ai and 401s). MODEL_PROVIDER
|
||||
# follows workspace/config.py:258's 'provider:model' format.
|
||||
#
|
||||
# Both empty → '{}' (workspace will fail at first turn with an
|
||||
# expected, actionable auth error rather than masking the test).
|
||||
SECRETS_JSON='{}'
|
||||
if [ -n "${E2E_OPENAI_API_KEY:-}" ]; then
|
||||
# MODEL_PROVIDER is a full model slug in 'provider:model' format per
|
||||
# workspace/config.py:258. Using just "openai" gets parsed as the
|
||||
# model name → 404 model_not_found. Also set OPENAI_BASE_URL to
|
||||
# OpenAI's own endpoint — default is openrouter.ai which would need
|
||||
# a different key format.
|
||||
#
|
||||
# The HERMES_* fields below bypass template-hermes/scripts/derive-provider.sh
|
||||
# — verified 2026-04-24 that even with template-hermes#19's fix in main,
|
||||
# staging tenants sometimes resolve openai/* to PROVIDER=openrouter and
|
||||
# emit {'message':'Missing Authentication header','code':401} (OpenRouter's
|
||||
# shape) in the A2A reply. Setting HERMES_INFERENCE_PROVIDER=custom +
|
||||
# HERMES_CUSTOM_{BASE_URL,API_KEY,API_MODE} pins the bridge deterministically
|
||||
# so the test doesn't depend on every tenant EC2 having a freshly-cloned
|
||||
# template-hermes.
|
||||
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
|
||||
SECRETS_JSON=$(python3 -c "
|
||||
import json, os
|
||||
k = os.environ['E2E_MINIMAX_API_KEY']
|
||||
print(json.dumps({
|
||||
'MINIMAX_API_KEY': k,
|
||||
}))
|
||||
")
|
||||
elif [ -n "${E2E_OPENAI_API_KEY:-}" ]; then
|
||||
SECRETS_JSON=$(python3 -c "
|
||||
import json, os
|
||||
k = os.environ['E2E_OPENAI_API_KEY']
|
||||
@@ -352,42 +368,7 @@ print(json.dumps({
|
||||
")
|
||||
fi
|
||||
|
||||
# Model slug format depends on the runtime — different model resolvers
|
||||
# parse it differently:
|
||||
#
|
||||
# hermes → "openai/gpt-4o" (slash-form: derive-provider.sh splits
|
||||
# on the prefix to set
|
||||
# HERMES_INFERENCE_PROVIDER. Bare
|
||||
# "gpt-4o" falls through to Anthropic
|
||||
# default + 401, see PR #1714.)
|
||||
#
|
||||
# langgraph → "openai:gpt-4o" (colon-form: langchain init_chat_model
|
||||
# requires "<provider>:<model>".
|
||||
# Slash-form was misinterpreted as
|
||||
# OpenRouter routing → fell through
|
||||
# without auth, surfaced 2026-05-03
|
||||
# after the a2a-sdk v1 contract bugs
|
||||
# PR #2558+#2563+#2567 cleared the
|
||||
# masking layers.)
|
||||
#
|
||||
# claude-code → "sonnet" (entry-id form: claude-code template's
|
||||
# config.yaml uses bare model names,
|
||||
# auth comes via CLAUDE_CODE_OAUTH_TOKEN
|
||||
# or ANTHROPIC_API_KEY rather than the
|
||||
# slug.)
|
||||
#
|
||||
# When E2E_MODEL_SLUG is set, it overrides this dispatch — useful when an
|
||||
# operator dispatches the workflow to test a specific slug.
|
||||
if [ -n "${E2E_MODEL_SLUG:-}" ]; then
|
||||
MODEL_SLUG="$E2E_MODEL_SLUG"
|
||||
else
|
||||
case "$RUNTIME" in
|
||||
hermes) MODEL_SLUG="openai/gpt-4o" ;;
|
||||
langgraph) MODEL_SLUG="openai:gpt-4o" ;;
|
||||
claude-code) MODEL_SLUG="sonnet" ;;
|
||||
*) MODEL_SLUG="openai/gpt-4o" ;; # safest fallback (matches hermes)
|
||||
esac
|
||||
fi
|
||||
MODEL_SLUG=$(pick_model_slug "$RUNTIME")
|
||||
|
||||
log "5/11 Provisioning parent workspace (runtime=$RUNTIME)..."
|
||||
PARENT_RESP=$(tenant_call POST /workspaces \
|
||||
@@ -458,6 +439,42 @@ for wid in $WS_TO_CHECK; do
|
||||
ok " $wid online"
|
||||
done
|
||||
|
||||
# ─── 7b. Canvas-terminal diagnose (EIC chain probe) ────────────────────
|
||||
# This step exists because the canvas-terminal failure of 2026-05-03
|
||||
# was structurally invisible to local-dev (handleLocalConnect uses
|
||||
# docker exec; handleRemoteConnect uses EIC + ssh). The CP provisioner
|
||||
# shipped without the tcp/22 EIC ingress rule for ~6 months and nobody
|
||||
# noticed until a paying tenant clicked Terminal in canvas. Probing the
|
||||
# diagnose endpoint here at synth-E2E time means a regression in
|
||||
# - tenantIngressRules / workspaceIngressRules (CP)
|
||||
# - eicSSHIngressRule helper (CP)
|
||||
# - AuthorizeIngress source-group support (CP awsapi)
|
||||
# - EIC_ENDPOINT_SG_ID Railway env
|
||||
# - handleRemoteConnect's send-ssh-public-key/open-tunnel/ssh chain
|
||||
# surfaces within ~20 min of merge instead of waiting for a user report.
|
||||
#
|
||||
# The diagnose endpoint runs the full EIC + ssh probe from inside the
|
||||
# tenant's workspace-server (which already has AWS creds via its IAM
|
||||
# profile) and reports per-step status. We only need to call it as the
|
||||
# tenant — no AWS creds needed on the GHA runner. Returns
|
||||
# {"ok": bool, "first_failure": "name", "steps": [...]}.
|
||||
#
|
||||
# Local-docker workspaces (instance_id NULL) get diagnoseLocal which
|
||||
# probes docker.Ping + container exec; we still expect ok=true there
|
||||
# since local-docker is the alternative production path.
|
||||
log "7b/11 Canvas-terminal EIC diagnose probe..."
|
||||
for wid in $WS_TO_CHECK; do
|
||||
DIAG_JSON=$(tenant_call GET "/workspaces/$wid/terminal/diagnose" 2>/dev/null || echo '{}')
|
||||
DIAG_OK=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print('true' if d.get('ok') else 'false')" 2>/dev/null || echo "false")
|
||||
if [ "$DIAG_OK" = "true" ]; then
|
||||
ok " $wid terminal-reachable (canvas terminal will work)"
|
||||
else
|
||||
DIAG_FAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('first_failure','unknown'))" 2>/dev/null || echo "unknown")
|
||||
DIAG_DETAIL=$(echo "$DIAG_JSON" | python3 -c "import json,sys; d=json.load(sys.stdin); s=[x for x in d.get('steps',[]) if not x.get('ok')]; print(s[0].get('error','') if s else '')" 2>/dev/null || echo "")
|
||||
fail "Workspace $wid terminal diagnose failed at step '$DIAG_FAIL': $DIAG_DETAIL — check tenant SG has tcp/22 from EIC endpoint SG (sg-0785d5c6138220523), EIC_ENDPOINT_SG_ID set in Railway, and EIC endpoint health"
|
||||
fi
|
||||
done
|
||||
|
||||
# ─── 8. A2A round-trip on parent ───────────────────────────────────────
|
||||
log "8/11 Sending A2A message to parent — expecting agent response..."
|
||||
# Smoke prompt phrasing — DO NOT trim back to the bare "Reply with exactly: PONG"
|
||||
@@ -510,6 +527,7 @@ fi
|
||||
# "Encrypted content is not supported" → hermes codex_responses API misroute (#14)
|
||||
# "Unknown provider" → bridge misconfigured PROVIDER= (regression of #13 fix)
|
||||
# "hermes-agent unreachable" → gateway process died
|
||||
# "exceeded your current quota" → MOLECULE_STAGING_OPENAI_KEY billing (NOT a platform regression — #2578)
|
||||
#
|
||||
# Fail LOUD with the specific pattern so CI log + alert channel makes the
|
||||
# regression unambiguous.
|
||||
@@ -535,6 +553,16 @@ fi
|
||||
if echo "$AGENT_TEXT" | grep -qF "Invalid API key"; then
|
||||
fail "A2A — REGRESSION: tenant auth chain returned 'Invalid API key'. Likely CP boot-event 401 race (CP #238) or stale OPENAI_API_KEY in the runtime env. Raw: $AGENT_TEXT"
|
||||
fi
|
||||
# Provider quota exhausted — distinguish from a platform regression so
|
||||
# the canary alert names the operator action directly instead of falling
|
||||
# through to the generic "error-shaped response" message. Steps 0-7 having
|
||||
# passed means the platform itself is healthy (CP up, tenant provisioned,
|
||||
# workspace online, A2A delivery end-to-end). When the agent comes back
|
||||
# with a provider-side 429, that is a billing event on the configured
|
||||
# OpenAI key, not a platform regression. Tracked in #2578.
|
||||
if echo "$AGENT_TEXT" | grep -qiE "exceeded your current quota|insufficient_quota"; then
|
||||
fail "A2A — PROVIDER QUOTA EXHAUSTED (NOT a platform regression). Operator action: top up MOLECULE_STAGING_OPENAI_KEY billing or rotate to a higher-quota org at Settings → Secrets and Variables → Actions. Tracked in #2578. Raw: $AGENT_TEXT"
|
||||
fi
|
||||
# Generic catch-all — falls through if none of the known regressions hit.
|
||||
if echo "$AGENT_TEXT" | grep -qiE "error|exception"; then
|
||||
fail "A2A returned an error-shaped response: $AGENT_TEXT"
|
||||
|
||||
@@ -83,7 +83,20 @@ curl -fsS -X POST "{{PLATFORM_URL}}/registry/register" \
|
||||
const externalChannelTemplate = `# Claude Code channel — bridges this workspace's A2A traffic into your
|
||||
# Claude Code session. No tunnel/public URL needed (polling-based).
|
||||
#
|
||||
# 1. Save this token + workspace_id, then create ~/.claude/channels/molecule/.env:
|
||||
# Prereq: Bun installed (channel plugins are Bun scripts).
|
||||
# bun --version # must print a version number
|
||||
#
|
||||
# 1. Inside Claude Code, install the channel plugin from its GitHub repo.
|
||||
# The plugin is NOT on Anthropic's default allowlist, so a one-time
|
||||
# marketplace-add is needed before install:
|
||||
#
|
||||
# /plugin marketplace add Molecule-AI/molecule-mcp-claude-channel
|
||||
# /plugin install molecule@molecule-mcp-claude-channel
|
||||
#
|
||||
# Then either run /reload-plugins or restart Claude Code so the
|
||||
# plugin is registered.
|
||||
#
|
||||
# 2. Create the per-watched-workspace config file:
|
||||
mkdir -p ~/.claude/channels/molecule
|
||||
cat > ~/.claude/channels/molecule/.env <<'EOF'
|
||||
MOLECULE_PLATFORM_URL={{PLATFORM_URL}}
|
||||
@@ -92,13 +105,32 @@ MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>
|
||||
EOF
|
||||
chmod 600 ~/.claude/channels/molecule/.env
|
||||
|
||||
# 2. Launch Claude Code with the channel enabled:
|
||||
claude --channels plugin:molecule@Molecule-AI/molecule-mcp-claude-channel
|
||||
# 3. Launch Claude Code with the channel enabled. Custom (non-Anthropic-
|
||||
# allowlisted) channels need the --dangerously-load-development-channels
|
||||
# flag to opt in — without it, you'll see "not on the approved channels
|
||||
# allowlist" on startup.
|
||||
claude --dangerously-load-development-channels \
|
||||
--channels plugin:molecule@molecule-mcp-claude-channel
|
||||
|
||||
# You should see on stderr:
|
||||
# molecule channel: connected — watching 1 workspace(s) at {{PLATFORM_URL}}
|
||||
#
|
||||
# Inbound A2A messages now surface as conversation turns. Claude's
|
||||
# replies route back via the reply_to_workspace MCP tool — no extra
|
||||
# wiring on your side.
|
||||
#
|
||||
# Common errors:
|
||||
# "plugin not installed" → Step 1 didn't run; run /plugin install
|
||||
# inside Claude Code, then /reload-plugins.
|
||||
# "not on approved channels allowlist" → Add --dangerously-load-development-channels
|
||||
# to the launch command (Step 3).
|
||||
# "config-missing" → ~/.claude/channels/molecule/.env not
|
||||
# readable; re-run Step 2 and check chmod.
|
||||
#
|
||||
# Team/Enterprise orgs: the --dangerously-load-development-channels flag is
|
||||
# blocked by managed settings. Your admin must set channelsEnabled=true and
|
||||
# add the plugin to allowedChannelPlugins in claude.ai admin settings.
|
||||
#
|
||||
# Multi-workspace: comma-separate IDs and tokens (same order). See
|
||||
# https://github.com/Molecule-AI/molecule-mcp-claude-channel for
|
||||
# pairing flow, push-mode upgrade, and v0.2 roadmap.
|
||||
@@ -186,3 +218,191 @@ async def main():
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
`
|
||||
|
||||
// externalHermesChannelTemplate — install snippet for operators whose
|
||||
// external agent IS a hermes-agent session. Routes the workspace's
|
||||
// A2A traffic into the running hermes gateway as platform messages
|
||||
// via the molecule-channel plugin.
|
||||
//
|
||||
// The plugin (Molecule-AI/hermes-channel-molecule) is a hermes
|
||||
// platform adapter that:
|
||||
// 1. Spawns ``python -m molecule_runtime.a2a_mcp_server`` as a
|
||||
// stdio MCP subprocess (separate from any hermes-side MCP
|
||||
// client connection).
|
||||
// 2. Long-polls ``wait_for_message`` on the platform's inbox.
|
||||
// 3. Dispatches each inbound activity into the hermes gateway as a
|
||||
// MessageEvent — same code path Telegram/Discord use.
|
||||
// 4. Outbound replies route via ``send_message_to_user`` (canvas
|
||||
// user) or ``delegate_task`` (peer agent) MCP tool calls.
|
||||
//
|
||||
// Result: hermes gets push parity with Claude Code / codex / openclaw —
|
||||
// canvas messages and peer A2A arrive as conversation turns mid-session,
|
||||
// not just at the start of a new ``hermes`` invocation.
|
||||
//
|
||||
// Plugin uses the upstream ``register_platform`` API shipped by
|
||||
// NousResearch/hermes-agent#17751 (merged 2026-04-30) and falls back
|
||||
// to the legacy ``register_platform_adapter`` shape on older forks —
|
||||
// same wheel installs cleanly on stock or patched hermes-agent.
|
||||
const externalHermesChannelTemplate = `# Hermes channel — bridges this workspace's A2A traffic into your
|
||||
# hermes-agent session. No tunnel/public URL needed (long-poll based,
|
||||
# same shape as the Claude Code channel).
|
||||
#
|
||||
# Prereq: a hermes-agent install on the target machine. Latest builds
|
||||
# (post #17751) ship the platform-plugin API natively; older ones are
|
||||
# also supported via the plugin's dual-mode fallback.
|
||||
#
|
||||
# 1. Install the runtime + plugin:
|
||||
pip install molecule-ai-workspace-runtime
|
||||
pip install 'git+https://github.com/Molecule-AI/hermes-channel-molecule.git'
|
||||
|
||||
# 2. Export the workspace credentials:
|
||||
export MOLECULE_WORKSPACE_ID={{WORKSPACE_ID}}
|
||||
export MOLECULE_PLATFORM_URL={{PLATFORM_URL}}
|
||||
export MOLECULE_WORKSPACE_TOKEN="<paste from create response>"
|
||||
export MOLECULE_ORG_ID="<your org id>"
|
||||
|
||||
# 3. Edit ~/.hermes/config.yaml — under your existing top-level
|
||||
# gateway: block, add a plugin_platforms entry:
|
||||
#
|
||||
# gateway:
|
||||
# # ...your existing gateway settings...
|
||||
# plugin_platforms:
|
||||
# molecule:
|
||||
# enabled: true
|
||||
#
|
||||
# If you don't yet have a gateway: block, create one with just
|
||||
# that plugin_platforms entry. Don't append blindly — YAML
|
||||
# rejects duplicate top-level keys, so a second gateway: block
|
||||
# will silently break hermes config loading.
|
||||
|
||||
# 4. Restart the hermes gateway:
|
||||
hermes gateway --replace
|
||||
|
||||
# Inbound canvas messages + peer A2A now arrive as MessageEvents —
|
||||
# same dispatch path Telegram/Discord/Slack use. The agent replies via
|
||||
# send_message_to_user / delegate_task MCP tool calls (already wired
|
||||
# by the plugin's molecule_runtime MCP subprocess).
|
||||
#
|
||||
# Source + issue tracker:
|
||||
# https://github.com/Molecule-AI/hermes-channel-molecule
|
||||
`
|
||||
|
||||
// externalCodexTemplate — for operators whose external agent is a
|
||||
// codex CLI (@openai/codex) session. Wires the molecule_runtime A2A
|
||||
// MCP server into codex's config.toml so the agent can call
|
||||
// list_peers / delegate_task / send_message_to_user / commit_memory.
|
||||
//
|
||||
// Push parity caveat: codex's MCP client doesn't forward arbitrary
|
||||
// notifications/* from configured MCP servers (verified by reading
|
||||
// codex-rs/codex-mcp/src/connection_manager.rs in openai/codex). So
|
||||
// this snippet gives outbound tools but NOT mid-turn push from
|
||||
// inbound A2A. For full push parity on a codex external, the
|
||||
// equivalent of hermes-channel-molecule would be needed — a bridge
|
||||
// daemon that long-polls the platform inbox and calls codex's
|
||||
// turn/steer RPC. Tracked separately; this snippet is the
|
||||
// outbound-tool-only first cut.
|
||||
const externalCodexTemplate = `# Codex MCP config — outbound tool path. For operators whose external
|
||||
# agent is a codex CLI (@openai/codex) session.
|
||||
#
|
||||
# This wires the molecule platform's A2A MCP server into codex so
|
||||
# the agent can call list_peers / delegate_task / send_message_to_user
|
||||
# / commit_memory. Inbound A2A (canvas messages, peer-initiated tasks)
|
||||
# does NOT push into the running codex turn yet — codex's MCP runtime
|
||||
# doesn't route arbitrary notifications/* from configured MCP servers.
|
||||
# For inbound delivery into a codex session, pair with the Python SDK
|
||||
# tab for now.
|
||||
|
||||
# 1. Install codex CLI + the workspace runtime wheel:
|
||||
npm install -g @openai/codex@^0.57
|
||||
pip install molecule-ai-workspace-runtime
|
||||
|
||||
# 2. Edit ~/.codex/config.toml and add the block below. {{PLATFORM_URL}}
|
||||
# and {{WORKSPACE_ID}} are stamped server-side; paste your auth
|
||||
# token for MOLECULE_WORKSPACE_TOKEN before saving.
|
||||
#
|
||||
# Don't append blindly — TOML rejects duplicate
|
||||
# [mcp_servers.molecule] tables, so re-running on an existing
|
||||
# config will break codex parsing. If [mcp_servers.molecule]
|
||||
# already exists (e.g. you set this up before), replace the
|
||||
# existing block instead of appending.
|
||||
|
||||
mkdir -p ~/.codex
|
||||
# (then open ~/.codex/config.toml in your editor and paste:)
|
||||
#
|
||||
# [mcp_servers.molecule]
|
||||
# command = "python3"
|
||||
# args = ["-m", "molecule_runtime.a2a_mcp_server"]
|
||||
# startup_timeout_sec = 30
|
||||
#
|
||||
# [mcp_servers.molecule.env]
|
||||
# WORKSPACE_ID = "{{WORKSPACE_ID}}"
|
||||
# PLATFORM_URL = "{{PLATFORM_URL}}"
|
||||
# MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"
|
||||
# MOLECULE_ORG_ID = "<your org id>"
|
||||
|
||||
# 3. Run codex — the molecule tools are now available to the agent:
|
||||
codex
|
||||
`
|
||||
|
||||
// externalOpenClawTemplate — for operators whose external agent is an
|
||||
// openclaw session. Wires the molecule MCP server via openclaw's
|
||||
// `mcp set` config + starts the openclaw gateway on loopback.
|
||||
//
|
||||
// Like the codex tab, this is outbound-only. Full push parity on an
|
||||
// external openclaw would need a sessions.steer bridge daemon (the
|
||||
// equivalent of hermes-channel-molecule for openclaw). Tracked
|
||||
// separately; outbound tools is the first cut.
|
||||
const externalOpenClawTemplate = `# OpenClaw MCP config — outbound tool path. For operators whose
|
||||
# external agent is an openclaw session.
|
||||
#
|
||||
# This wires the molecule platform's A2A MCP server into openclaw's
|
||||
# gateway so the agent can call list_peers / delegate_task /
|
||||
# send_message_to_user / commit_memory. Inbound A2A push into a
|
||||
# running openclaw run is not wired here yet — the platform-side
|
||||
# openclaw template (template-openclaw) implements the full
|
||||
# sessions.steer push path; an external setup would need the same
|
||||
# bridge daemon the template uses. For inbound delivery on an
|
||||
# external machine today, pair with the Python SDK tab.
|
||||
|
||||
# 1. Install openclaw CLI + the workspace runtime wheel:
|
||||
npm install -g openclaw@latest
|
||||
pip install molecule-ai-workspace-runtime
|
||||
|
||||
# 2. Onboard openclaw against your model provider (one-time setup).
|
||||
# --non-interactive needs an explicit --provider + --model so it
|
||||
# doesn't prompt; pick what matches your API key. Skip step 2 if
|
||||
# you've already onboarded on this host.
|
||||
#
|
||||
# openclaw onboard --non-interactive \
|
||||
# --provider openai \
|
||||
# --model gpt-5
|
||||
|
||||
# 3. Wire the molecule MCP server. {{WORKSPACE_ID}} + {{PLATFORM_URL}}
|
||||
# are stamped server-side; paste the auth token before running.
|
||||
WORKSPACE_TOKEN="<paste from create response>"
|
||||
MOLECULE_ORG_ID="<your org id>"
|
||||
openclaw mcp set molecule "$(cat <<EOF
|
||||
{
|
||||
"command": "python3",
|
||||
"args": ["-m", "molecule_runtime.a2a_mcp_server"],
|
||||
"env": {
|
||||
"WORKSPACE_ID": "{{WORKSPACE_ID}}",
|
||||
"PLATFORM_URL": "{{PLATFORM_URL}}",
|
||||
"MOLECULE_WORKSPACE_TOKEN": "$WORKSPACE_TOKEN",
|
||||
"MOLECULE_ORG_ID": "$MOLECULE_ORG_ID"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
)"
|
||||
|
||||
# 4. Start the openclaw gateway as a durable background process.
|
||||
# A bare '&' dies when the terminal closes; nohup + log file keeps
|
||||
# the gateway alive across logout. For systemd-managed hosts,
|
||||
# register a unit instead.
|
||||
nohup openclaw gateway --dev --port 18789 --bind loopback \
|
||||
> ~/.openclaw/gateway.log 2>&1 &
|
||||
disown
|
||||
|
||||
# 5. Run an agent turn — molecule tools are now available:
|
||||
openclaw agent --message "list my peers"
|
||||
`
|
||||
|
||||
@@ -454,6 +454,41 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
"{{PLATFORM_URL}}", platformURL),
|
||||
"{{WORKSPACE_ID}}", id,
|
||||
),
|
||||
// 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 as the Claude Code
|
||||
// channel tab. Gives hermes true push parity with the
|
||||
// other runtime templates.
|
||||
"hermes_channel_snippet": strings.ReplaceAll(
|
||||
strings.ReplaceAll(externalHermesChannelTemplate,
|
||||
"{{PLATFORM_URL}}", platformURL),
|
||||
"{{WORKSPACE_ID}}", id,
|
||||
),
|
||||
// Codex MCP config snippet — for operators whose
|
||||
// external agent is a codex CLI (@openai/codex)
|
||||
// session. Wires the molecule MCP server into
|
||||
// ~/.codex/config.toml. Outbound-tools-only today;
|
||||
// codex's MCP client doesn't route arbitrary
|
||||
// notifications/* so push parity needs a separate
|
||||
// bridge daemon (future work).
|
||||
"codex_snippet": strings.ReplaceAll(
|
||||
strings.ReplaceAll(externalCodexTemplate,
|
||||
"{{PLATFORM_URL}}", platformURL),
|
||||
"{{WORKSPACE_ID}}", id,
|
||||
),
|
||||
// OpenClaw MCP config snippet — for operators whose
|
||||
// external agent is an openclaw session. Wires the
|
||||
// molecule MCP server via `openclaw mcp set` + starts
|
||||
// the gateway on loopback. Outbound-tools-only today;
|
||||
// full push parity needs a sessions.steer bridge
|
||||
// daemon (future work).
|
||||
"openclaw_snippet": strings.ReplaceAll(
|
||||
strings.ReplaceAll(externalOpenClawTemplate,
|
||||
"{{PLATFORM_URL}}", platformURL),
|
||||
"{{WORKSPACE_ID}}", id,
|
||||
),
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Reverse 048_activity_logs_peer_indexes.up.sql.
|
||||
-- Drops the partial peer-conversation indexes added there.
|
||||
-- chat_history queries fall back to the existing idx_activity_ws_type_time
|
||||
-- + workspace-scoped seq scan / filter on the OR clause.
|
||||
|
||||
DROP INDEX IF EXISTS idx_activity_ws_target;
|
||||
DROP INDEX IF EXISTS idx_activity_ws_source;
|
||||
@@ -0,0 +1,42 @@
|
||||
-- Add per-peer indexes on activity_logs to make chat_history queries
|
||||
-- index-driven instead of seq-scan-driven on workspaces with thousands
|
||||
-- of accumulated rows. #2478.
|
||||
--
|
||||
-- chat_history hits:
|
||||
--
|
||||
-- SELECT ... FROM activity_logs
|
||||
-- WHERE workspace_id = $1
|
||||
-- AND activity_type = 'a2a_receive'
|
||||
-- AND (source_id = $2 OR target_id = $2)
|
||||
-- ORDER BY created_at DESC LIMIT 20;
|
||||
--
|
||||
-- The existing idx_activity_ws_type_time covers workspace_id+type
|
||||
-- prefix but the (source_id = $X OR target_id = $X) clause then forces
|
||||
-- a workspace-scoped seq-scan-and-filter. Two separate indexes (one per
|
||||
-- nullable column) let Postgres BitmapOr them into a workspace-scoped
|
||||
-- BitmapAnd against the existing index.
|
||||
--
|
||||
-- Partial WHERE NOT NULL because most activity rows (heartbeats,
|
||||
-- agent_log, memory_write, etc.) have NULL source_id/target_id and
|
||||
-- shouldn't bloat the index. Per-row index size drops from ~all rows
|
||||
-- to ~A2A-only rows.
|
||||
--
|
||||
-- Anti-pattern caveat from the issue: a single compound (a, b) index
|
||||
-- can't serve `a OR b` — Postgres can only use compound for prefix
|
||||
-- match. Two separate indexes + BitmapOr is the right shape.
|
||||
--
|
||||
-- CONCURRENTLY would be ideal for online deploys, but goose runs
|
||||
-- migrations in a single transaction by default which doesn't allow
|
||||
-- CONCURRENTLY. The alternative (annotating the migration to skip the
|
||||
-- transaction wrapper) is a per-runner concern; leaving as plain
|
||||
-- CREATE INDEX so this works under any goose config. activity_logs is
|
||||
-- typically <O(100k) rows per workspace × <O(100) workspaces in
|
||||
-- production today; the lock is sub-second-scale.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_ws_source
|
||||
ON activity_logs(workspace_id, source_id)
|
||||
WHERE source_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_ws_target
|
||||
ON activity_logs(workspace_id, target_id)
|
||||
WHERE target_id IS NOT NULL;
|
||||
@@ -162,6 +162,31 @@ async def handle_tool_call(name: str, arguments: dict) -> str:
|
||||
_CHANNEL_NOTIFICATION_METHOD = "notifications/claude/channel"
|
||||
|
||||
|
||||
# ============= Trust-boundary gates for channel-notification meta ==============
|
||||
_VALID_KINDS = frozenset({"canvas_user", "peer_agent"})
|
||||
_VALID_METHODS = frozenset({"message/send", "tasks/send", "tasks/get", "notify", ""})
|
||||
|
||||
import re as _re
|
||||
_ACTIVITY_ID_RE = _re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
||||
_ISO8601_RE = _re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$")
|
||||
|
||||
|
||||
def _safe_meta_field(value, allowlist) -> str:
|
||||
return value if value in allowlist else ""
|
||||
|
||||
|
||||
def _safe_activity_id(value) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return value if _ACTIVITY_ID_RE.match(value) else ""
|
||||
|
||||
|
||||
def _safe_ts(value) -> str:
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
return value if _ISO8601_RE.match(value) else ""
|
||||
|
||||
|
||||
# Default seconds the agent should block on `wait_for_message` per
|
||||
# turn. 2s is the cost/latency knee — long enough that a peer A2A
|
||||
# landing 0-2s before the agent starts its turn is caught, short
|
||||
@@ -402,11 +427,11 @@ def _build_channel_notification(msg: dict) -> dict:
|
||||
"""
|
||||
meta = {
|
||||
"source": "molecule",
|
||||
"kind": msg.get("kind", ""),
|
||||
"kind": _safe_meta_field(msg.get("kind", ""), _VALID_KINDS),
|
||||
"peer_id": msg.get("peer_id", ""),
|
||||
"method": msg.get("method", ""),
|
||||
"activity_id": msg.get("activity_id", ""),
|
||||
"ts": msg.get("created_at", ""),
|
||||
"method": _safe_meta_field(msg.get("method", ""), _VALID_METHODS),
|
||||
"activity_id": _safe_activity_id(msg.get("activity_id", "")),
|
||||
"ts": _safe_ts(msg.get("created_at", "")),
|
||||
}
|
||||
|
||||
peer_id = msg.get("peer_id") or ""
|
||||
@@ -433,16 +458,84 @@ def _build_channel_notification(msg: dict) -> dict:
|
||||
# endpoint to hit for capabilities lookup.
|
||||
meta["agent_card_url"] = _agent_card_url_for(safe_peer_id)
|
||||
|
||||
# Compose the conversation-turn text Claude actually sees. Header
|
||||
# carries peer identity (name + role when registry-resolved, peer_id
|
||||
# always); footer carries the exact reply-tool call shape so the
|
||||
# model doesn't have to remember which tool to call or what args to
|
||||
# pass. See _format_channel_content for the rationale + tradeoff on
|
||||
# coupling display to behaviour. Mirrors the change shipped for the
|
||||
# external channel-plugin path
|
||||
# (Molecule-AI/molecule-mcp-claude-channel#24); the universal MCP
|
||||
# path is the same display surface for in-workspace agents.
|
||||
content = _format_channel_content(
|
||||
text=msg.get("text", ""),
|
||||
kind=meta["kind"],
|
||||
peer_id=meta["peer_id"],
|
||||
peer_name=meta.get("peer_name"),
|
||||
peer_role=meta.get("peer_role"),
|
||||
)
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"method": _CHANNEL_NOTIFICATION_METHOD,
|
||||
"params": {
|
||||
"content": msg.get("text", ""),
|
||||
"content": content,
|
||||
"meta": meta,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _format_channel_content(
|
||||
*,
|
||||
text: str,
|
||||
kind: str,
|
||||
peer_id: str,
|
||||
peer_name: str | None = None,
|
||||
peer_role: str | None = None,
|
||||
) -> str:
|
||||
"""Prepend identity + append reply-tool example to the inbound text.
|
||||
|
||||
Why this couples display to behaviour: Claude Code surfaces the
|
||||
notification's ``content`` as the conversation turn. Without context
|
||||
in the text, the model has to remember (a) who sent the message,
|
||||
(b) which tool to call to reply, (c) which args to pass. Putting it
|
||||
in the turn itself makes the reply path self-documenting at the
|
||||
cost of ~80 extra chars per push.
|
||||
|
||||
The reply-tool names live in the same module as the notification
|
||||
builder so the ``feedback_doc_tool_alignment`` drift class can't bite:
|
||||
a future tool-rename PR that misses this hint would also fail
|
||||
``test_format_channel_content_*`` below.
|
||||
|
||||
canvas_user → ``send_message_to_user({message: "..."})`` — pushed via
|
||||
canvas WebSocket, lands in the user's chat panel.
|
||||
peer_agent → ``delegate_task({workspace_id: peer_id, task: "..."})``
|
||||
— sends an A2A reply to the calling peer.
|
||||
"""
|
||||
if kind == "canvas_user":
|
||||
header = "[from canvas user]"
|
||||
hint = '↩ Reply: send_message_to_user({message: "..."})'
|
||||
elif kind == "peer_agent":
|
||||
if peer_name and peer_role:
|
||||
identity = f"{peer_name} ({peer_role})"
|
||||
elif peer_name:
|
||||
identity = peer_name
|
||||
else:
|
||||
identity = "peer-agent"
|
||||
header = f"[from {identity} · peer_id={peer_id}]"
|
||||
hint = (
|
||||
f'↩ Reply: delegate_task({{workspace_id: "{peer_id}", '
|
||||
f'task: "..."}})'
|
||||
)
|
||||
else:
|
||||
# Defensive default — _safe_meta_field already constrains kind to
|
||||
# _VALID_KINDS, so this branch is unreachable in practice. Emit
|
||||
# the bare text rather than crash so a future kind value (added
|
||||
# to the allowlist but not the formatter) degrades gracefully
|
||||
# instead of breaking every push.
|
||||
return text
|
||||
return f"{header}\n{text}\n{hint}"
|
||||
|
||||
|
||||
# --- MCP Server (JSON-RPC over stdio) ---
|
||||
|
||||
|
||||
|
||||
@@ -559,9 +559,10 @@ async def tool_chat_history(peer_id: str, limit: int = 20, before_ts: str = "")
|
||||
|
||||
Hits ``/workspaces/<self>/activity?peer_id=<peer>&limit=<N>``
|
||||
against the workspace-server, which returns activity rows where
|
||||
this workspace is either the sender (``source_id=peer``) or the
|
||||
recipient (``target_id=peer``) of an A2A turn — both sides of the
|
||||
conversation in chronological order.
|
||||
the peer is either the sender (``source_id=peer`` — they sent us
|
||||
the message) or the recipient (``target_id=peer`` — we sent to
|
||||
them) of an A2A turn — both sides of the conversation in
|
||||
chronological order.
|
||||
|
||||
Args:
|
||||
peer_id: The other workspace's UUID. Same value the agent
|
||||
|
||||
@@ -171,9 +171,19 @@ def test_build_channel_notification_method_matches_claude_contract():
|
||||
assert payload["jsonrpc"] == "2.0"
|
||||
|
||||
|
||||
def test_build_channel_notification_content_is_message_text():
|
||||
"""`content` is what becomes the agent conversation turn —
|
||||
pulled directly from the inbox message text."""
|
||||
def test_build_channel_notification_content_wraps_text_with_identity_and_reply_hint():
|
||||
"""`content` is what becomes the agent conversation turn — wrapped
|
||||
with an identity header AND a reply-tool hint. The wrapping makes the
|
||||
reply path self-documenting so the agent doesn't have to remember
|
||||
which platform tool to call (per the cross-codepath fix shipped with
|
||||
Molecule-AI/molecule-mcp-claude-channel#24).
|
||||
|
||||
Before this change `content == msg["text"]` and the agent had to
|
||||
reach into meta + recall send_message_to_user / delegate_task on
|
||||
every push. Now the conversation turn carries the identity inline
|
||||
and a copy-pasteable reply call, so the model surfaces the right
|
||||
routing without round-tripping through tool documentation each time.
|
||||
"""
|
||||
from a2a_mcp_server import _build_channel_notification
|
||||
|
||||
payload = _build_channel_notification({
|
||||
@@ -185,7 +195,14 @@ def test_build_channel_notification_content_is_message_text():
|
||||
"created_at": "2026-05-01T00:00:00Z",
|
||||
})
|
||||
|
||||
assert payload["params"]["content"] == "hello from canvas"
|
||||
# Exact match — per `feedback_assert_exact_not_substring`, substring
|
||||
# asserts pass for both correct formatting AND for "raw input echoed"
|
||||
# regression. Only equality discriminates.
|
||||
assert payload["params"]["content"] == (
|
||||
"[from canvas user]\n"
|
||||
"hello from canvas\n"
|
||||
'↩ Reply: send_message_to_user({message: "..."})'
|
||||
)
|
||||
|
||||
|
||||
def test_build_channel_notification_meta_carries_routing_fields():
|
||||
@@ -196,7 +213,11 @@ def test_build_channel_notification_meta_carries_routing_fields():
|
||||
from a2a_mcp_server import _build_channel_notification
|
||||
|
||||
payload = _build_channel_notification({
|
||||
"activity_id": "act-7",
|
||||
# Production-shape UUID — required by the trust-boundary gate
|
||||
# in _safe_activity_id (#2488). Synthetic ids like "act-7" used
|
||||
# to pass through but get stripped now; updating to a real-shape
|
||||
# UUID matches what activity_logs.id actually emits.
|
||||
"activity_id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
|
||||
"text": "ping",
|
||||
"peer_id": "11111111-2222-3333-4444-555555555555",
|
||||
"kind": "peer_agent",
|
||||
@@ -209,7 +230,7 @@ def test_build_channel_notification_meta_carries_routing_fields():
|
||||
assert meta["kind"] == "peer_agent"
|
||||
assert meta["peer_id"] == "11111111-2222-3333-4444-555555555555"
|
||||
assert meta["method"] == "message/send"
|
||||
assert meta["activity_id"] == "act-7"
|
||||
assert meta["activity_id"] == "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
||||
assert meta["ts"] == "2026-05-01T01:23:45Z"
|
||||
|
||||
|
||||
@@ -231,7 +252,14 @@ def test_build_channel_notification_no_id_field():
|
||||
def test_build_channel_notification_handles_missing_fields_gracefully():
|
||||
"""Some fields may be absent on edge-case messages (e.g. cursor
|
||||
bootstrapping with no created_at yet). Default to empty strings
|
||||
so the wire shape stays valid JSON instead of crashing."""
|
||||
so the wire shape stays valid JSON instead of crashing.
|
||||
|
||||
With an empty-kind payload the formatter falls through its
|
||||
defensive default branch (kind not in _VALID_KINDS) and emits the
|
||||
bare text — no header, no reply hint. This degrades gracefully
|
||||
rather than emitting a "[from None]" header that would mislead the
|
||||
receiving agent about who sent the empty payload.
|
||||
"""
|
||||
from a2a_mcp_server import _build_channel_notification
|
||||
|
||||
payload = _build_channel_notification({})
|
||||
@@ -243,6 +271,120 @@ def test_build_channel_notification_handles_missing_fields_gracefully():
|
||||
assert meta["kind"] == ""
|
||||
|
||||
|
||||
# ----- _format_channel_content: identity header + reply-tool hint ----------
|
||||
#
|
||||
# Pinned separately from _build_channel_notification so a regression in
|
||||
# the formatter surfaces with a tight failure message ("expected
|
||||
# delegate_task hint, got send_message_to_user") rather than buried in a
|
||||
# generic envelope-shape diff. Per `feedback_assert_exact_not_substring`,
|
||||
# all asserts pin exact strings.
|
||||
|
||||
|
||||
def test_format_channel_content_canvas_user_uses_send_message_to_user():
|
||||
"""canvas_user → reply via send_message_to_user (canvas WebSocket
|
||||
push). Header omits peer_id since canvas messages don't carry one."""
|
||||
from a2a_mcp_server import _format_channel_content
|
||||
|
||||
out = _format_channel_content(
|
||||
text="what's the deploy status?",
|
||||
kind="canvas_user",
|
||||
peer_id="",
|
||||
)
|
||||
assert out == (
|
||||
"[from canvas user]\n"
|
||||
"what's the deploy status?\n"
|
||||
'↩ Reply: send_message_to_user({message: "..."})'
|
||||
)
|
||||
|
||||
|
||||
def test_format_channel_content_peer_agent_with_full_enrichment():
|
||||
"""peer_agent + name + role → friendly identity, delegate_task hint
|
||||
with workspace_id arg pinned to the peer's UUID."""
|
||||
from a2a_mcp_server import _format_channel_content
|
||||
|
||||
peer_uuid = "11111111-2222-3333-4444-555555555555"
|
||||
out = _format_channel_content(
|
||||
text="ping",
|
||||
kind="peer_agent",
|
||||
peer_id=peer_uuid,
|
||||
peer_name="ops-agent",
|
||||
peer_role="sre",
|
||||
)
|
||||
assert out == (
|
||||
f"[from ops-agent (sre) · peer_id={peer_uuid}]\n"
|
||||
"ping\n"
|
||||
f'↩ Reply: delegate_task({{workspace_id: "{peer_uuid}", task: "..."}})'
|
||||
)
|
||||
|
||||
|
||||
def test_format_channel_content_peer_agent_name_only():
|
||||
"""peer_agent + name (no role) → identity uses bare name. Catches
|
||||
the regression where role-only or both-missing branches accidentally
|
||||
print 'None' or '(undefined)' in the header."""
|
||||
from a2a_mcp_server import _format_channel_content
|
||||
|
||||
peer_uuid = "11111111-2222-3333-4444-555555555555"
|
||||
out = _format_channel_content(
|
||||
text="ping",
|
||||
kind="peer_agent",
|
||||
peer_id=peer_uuid,
|
||||
peer_name="ops-agent",
|
||||
)
|
||||
assert out.startswith(f"[from ops-agent · peer_id={peer_uuid}]\n")
|
||||
assert "(None)" not in out
|
||||
assert "(undefined)" not in out
|
||||
|
||||
|
||||
def test_format_channel_content_peer_agent_no_enrichment_falls_back():
|
||||
"""peer_agent without name/role (registry miss) → identity is
|
||||
'peer-agent' and peer_id is still surfaced so the reply call has
|
||||
a value to copy."""
|
||||
from a2a_mcp_server import _format_channel_content
|
||||
|
||||
peer_uuid = "11111111-2222-3333-4444-555555555555"
|
||||
out = _format_channel_content(
|
||||
text="ping",
|
||||
kind="peer_agent",
|
||||
peer_id=peer_uuid,
|
||||
)
|
||||
assert out == (
|
||||
f"[from peer-agent · peer_id={peer_uuid}]\n"
|
||||
"ping\n"
|
||||
f'↩ Reply: delegate_task({{workspace_id: "{peer_uuid}", task: "..."}})'
|
||||
)
|
||||
|
||||
|
||||
def test_format_channel_content_unknown_kind_degrades_to_raw_text():
|
||||
"""Defensive default — _safe_meta_field already constrains kind to
|
||||
_VALID_KINDS, so this branch is unreachable in practice. But if a
|
||||
future kind is added to the allowlist before the formatter learns
|
||||
about it, emitting raw text is better than crashing the push path."""
|
||||
from a2a_mcp_server import _format_channel_content
|
||||
|
||||
assert _format_channel_content(
|
||||
text="something", kind="future_kind", peer_id="",
|
||||
) == "something"
|
||||
|
||||
|
||||
def test_format_channel_content_preserves_multiline_text():
|
||||
"""Body text may contain newlines (multi-paragraph user prose,
|
||||
code blocks). Content composition must not collapse or truncate
|
||||
them — the agent's reply quality depends on seeing the full
|
||||
inbound message."""
|
||||
from a2a_mcp_server import _format_channel_content
|
||||
|
||||
multi = "first paragraph\n\nsecond paragraph\nstill second"
|
||||
out = _format_channel_content(
|
||||
text=multi, kind="canvas_user", peer_id="",
|
||||
)
|
||||
# Body sandwiched between header and hint, separated by single
|
||||
# newlines. Body itself unchanged.
|
||||
assert (
|
||||
f"[from canvas user]\n{multi}\n"
|
||||
'↩ Reply: send_message_to_user({message: "..."})'
|
||||
) == out
|
||||
|
||||
|
||||
# ----- Channel envelope enrichment (peer_name / peer_role / agent_card_url) ---
|
||||
#
|
||||
# The bare envelope only carries `peer_id` for peer_agent inbound, so the
|
||||
@@ -622,6 +764,85 @@ def test_envelope_enrichment_strips_path_traversal_peer_id(_reset_peer_metadata_
|
||||
)
|
||||
|
||||
|
||||
def test_envelope_strips_unknown_kind(_reset_peer_metadata_cache):
|
||||
"""Trust-boundary: ``kind`` is rendered as an XML attr in the
|
||||
agent's <channel> tag. Any value outside the closed set
|
||||
{canvas_user, peer_agent} is replaced with empty so an attacker
|
||||
landing ``kind=canvas_user' onclick='alert(1)`` into the inbox row
|
||||
can't reflect raw into the agent's context. #2488.
|
||||
"""
|
||||
from a2a_mcp_server import _build_channel_notification
|
||||
|
||||
payload = _build_channel_notification({
|
||||
"kind": "canvas_user' onclick='alert(1)",
|
||||
"text": "x",
|
||||
})
|
||||
assert payload["params"]["meta"]["kind"] == ""
|
||||
|
||||
|
||||
def test_envelope_strips_unknown_method(_reset_peer_metadata_cache):
|
||||
"""Trust-boundary: ``method`` is rendered as an XML attr. Closed
|
||||
allowlist {message/send, tasks/send, tasks/get, notify, ""}; an
|
||||
upstream row with attacker-controlled method gets stripped. #2488.
|
||||
"""
|
||||
from a2a_mcp_server import _build_channel_notification
|
||||
|
||||
payload = _build_channel_notification({
|
||||
"method": "tasks/send\"><script>alert(1)</script>",
|
||||
"text": "x",
|
||||
})
|
||||
assert payload["params"]["meta"]["method"] == ""
|
||||
|
||||
|
||||
def test_envelope_strips_malformed_activity_id(_reset_peer_metadata_cache):
|
||||
"""Trust-boundary: ``activity_id`` must match UUID shape. A row
|
||||
with non-UUID activity_id (path-traversal chars, embedded XML
|
||||
quotes, stray newlines) gets stripped. #2488.
|
||||
"""
|
||||
from a2a_mcp_server import _build_channel_notification
|
||||
|
||||
payload = _build_channel_notification({
|
||||
"activity_id": "../../../etc/passwd",
|
||||
"text": "x",
|
||||
})
|
||||
assert payload["params"]["meta"]["activity_id"] == ""
|
||||
|
||||
|
||||
def test_envelope_strips_malformed_ts(_reset_peer_metadata_cache):
|
||||
"""Trust-boundary: ``ts`` must match ISO-8601 RFC3339. A row
|
||||
with attacker-controlled created_at (e.g. ``2026-05-01' onload='x``
|
||||
or unparseable garbage) gets stripped to empty. #2488.
|
||||
"""
|
||||
from a2a_mcp_server import _build_channel_notification
|
||||
|
||||
payload = _build_channel_notification({
|
||||
"created_at": "2026-05-01' onload='alert(1)",
|
||||
"text": "x",
|
||||
})
|
||||
assert payload["params"]["meta"]["ts"] == ""
|
||||
|
||||
|
||||
def test_envelope_keeps_valid_meta_fields_unchanged(_reset_peer_metadata_cache):
|
||||
"""Negative case: properly-shaped values pass through unchanged.
|
||||
Pin so a future tightening of the gates can't silently strip
|
||||
legitimate row contents. #2488.
|
||||
"""
|
||||
from a2a_mcp_server import _build_channel_notification
|
||||
|
||||
payload = _build_channel_notification({
|
||||
"kind": "canvas_user",
|
||||
"method": "message/send",
|
||||
"activity_id": "12345678-1234-1234-1234-123456789abc",
|
||||
"created_at": "2026-05-01T12:34:56.789Z",
|
||||
"text": "x",
|
||||
})
|
||||
meta = payload["params"]["meta"]
|
||||
assert meta["kind"] == "canvas_user"
|
||||
assert meta["method"] == "message/send"
|
||||
assert meta["activity_id"] == "12345678-1234-1234-1234-123456789abc"
|
||||
assert meta["ts"] == "2026-05-01T12:34:56.789Z"
|
||||
|
||||
|
||||
# ============== initialize handshake — capability declaration ==============
|
||||
# Without `experimental.claude/channel`, Claude Code's MCP client drops
|
||||
# our notifications/claude/channel emissions instead of routing them as
|
||||
@@ -971,7 +1192,8 @@ async def test_inbox_bridge_emits_channel_notification_to_writer():
|
||||
cb = _setup_inbox_bridge(writer, loop)
|
||||
|
||||
msg = {
|
||||
"activity_id": "act-bridge-test",
|
||||
# Production-shape UUID per the trust-boundary gate (#2488)
|
||||
"activity_id": "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff",
|
||||
"text": "hello from peer",
|
||||
"peer_id": "11111111-2222-3333-4444-555555555555",
|
||||
"kind": "peer_agent",
|
||||
@@ -1004,12 +1226,21 @@ async def test_inbox_bridge_emits_channel_notification_to_writer():
|
||||
|
||||
assert payload["jsonrpc"] == "2.0"
|
||||
assert payload["method"] == "notifications/claude/channel"
|
||||
assert payload["params"]["content"] == "hello from peer"
|
||||
# Content is wrapped with the identity header + reply hint —
|
||||
# see _format_channel_content. The bridge test pins the full
|
||||
# composition so a regression to "raw text only" surfaces here
|
||||
# as well as in the per-formatter tests above.
|
||||
assert payload["params"]["content"] == (
|
||||
"[from peer-agent · peer_id=11111111-2222-3333-4444-555555555555]\n"
|
||||
"hello from peer\n"
|
||||
'↩ Reply: delegate_task({workspace_id: '
|
||||
'"11111111-2222-3333-4444-555555555555", task: "..."})'
|
||||
)
|
||||
meta = payload["params"]["meta"]
|
||||
assert meta["source"] == "molecule"
|
||||
assert meta["kind"] == "peer_agent"
|
||||
assert meta["peer_id"] == "11111111-2222-3333-4444-555555555555"
|
||||
assert meta["activity_id"] == "act-bridge-test"
|
||||
assert meta["activity_id"] == "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff"
|
||||
assert meta["ts"] == "2026-05-01T22:00:00Z"
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
@@ -1050,6 +1050,27 @@ class TestChatHistory:
|
||||
|
||||
assert mc.get.call_args.kwargs["params"]["before_ts"] == "2026-05-01T00:00:00Z"
|
||||
|
||||
async def test_empty_history_returns_empty_json_list(self):
|
||||
"""Pin the happy-path-with-no-rows shape: server returns 200
|
||||
with an empty list, the wheel returns the JSON literal ``"[]"``.
|
||||
|
||||
Without this pin the surrounding tests all pre-populate rows;
|
||||
none verify what an agent sees when there's literally no chat
|
||||
history with this peer yet (a fresh A2A peering, or a peer
|
||||
whose history was rotated out). #2485.
|
||||
"""
|
||||
import a2a_tools
|
||||
|
||||
mc = _make_http_mock(get_resp=_resp(200, []))
|
||||
with patch("a2a_tools.httpx.AsyncClient", return_value=mc):
|
||||
result = await a2a_tools.tool_chat_history(peer_id=_PEER)
|
||||
|
||||
# Exact-equality on the JSON literal (per assert-exact memory) —
|
||||
# substring "[]" would also match `{"items": []}` or any number
|
||||
# of envelope shapes, only `result == "[]"` discriminates the
|
||||
# bare-list contract callers depend on.
|
||||
assert result == "[]"
|
||||
|
||||
async def test_reverses_desc_response_to_chronological(self):
|
||||
"""Server returns DESC (newest first); the wheel reverses to
|
||||
chronological so the agent reads the chat top-down — same
|
||||
|
||||
Reference in New Issue
Block a user