diff --git a/.env.example b/.env.example index 3a8b39c9..2a9560c1 100644 --- a/.env.example +++ b/.env.example @@ -9,11 +9,20 @@ REDIS_URL=redis://redis:6379 # Platform PORT=8080 +# ---- Admin credential — REQUIRED to close issue #684 (AdminAuth bearer bypass) ---- +# When ADMIN_TOKEN is set, only this value is accepted on /admin/* and /approvals/* routes. +# Without it, any valid workspace bearer token can call admin endpoints (backward compat +# fallback, still vulnerable). Set this in every environment, rotate when compromised. +# Generate: openssl rand -base64 32 +# Store in fly secrets / deployment env — NEVER commit the actual value here. +ADMIN_TOKEN= SECRETS_ENCRYPTION_KEY= # 32-byte key (raw or base64). Leave empty for plaintext (dev only). CONFIGS_DIR= # Path to workspace-configs-templates/ (auto-discovered if empty) PLUGINS_DIR= # Path to plugins/ directory (default: /plugins in container) # PLATFORM_URL=http://host.docker.internal:8080 # URL agent containers use to reach the platform; injected into workspace env. Default derives from PORT. # MOLECULE_URL=http://localhost:8080 # Canonical MCP-client URL (mirrors PLATFORM_URL inside containers). Read by the MCP server (mcp-server/) and Molecule MCP tooling. +# MOLECULE_MCP_ALLOW_SEND_MESSAGE= # Set to "true" to include send_message_to_user in the MCP bridge tool list (issue #810). Excluded by default to prevent unintended WebSocket pushes from CLI sessions. +# MOLECULE_MCP_URL=http://localhost:8080 # Platform URL for opencode MCP config (opencode.json). Same as PLATFORM_URL; separate var so opencode configs can reference it without ambiguity. # WORKSPACE_DIR= # Optional global host path bind-mounted to /workspace in every container. Per-workspace workspace_dir column overrides this; if neither is set each workspace gets an isolated Docker named volume. # MOLECULE_ENV=development # Environment label (development/staging/production). Used for log tagging and conditional behaviour. # MOLECULE_ENABLE_TEST_TOKENS= # Set to 1 to expose GET /admin/workspaces/:id/test-token (mints a fresh bearer token for E2E scripts). The route is auto-enabled when MOLECULE_ENV != production; this flag is the explicit override. Leave unset/0 in prod — the route 404s unless enabled. @@ -51,6 +60,13 @@ PLUGIN_INSTALL_BODY_MAX_BYTES=65536 # max request body size (default: 64 PLUGIN_INSTALL_FETCH_TIMEOUT=5m # duration string; whole fetch+copy deadline PLUGIN_INSTALL_MAX_DIR_BYTES=104857600 # max staged-tree size (default: 100 MiB) +# ---- Plugin supply chain hardening (issue #768, PR #775) ---- +# Set to 'true' to allow unpinned plugin refs (no #tag/#sha). Local dev only. +# When unset or 'false' (default), installing a plugin from a source without +# an explicit ref is rejected — prevents supply chain attacks via floating HEAD. +# NEVER set in production. Pending: PR #775 must merge before this takes effect. +PLUGIN_ALLOW_UNPINNED= + # Phase 30.7 — remote-agent liveness threshold. Workspaces with # runtime='external' are marked offline if their last_heartbeat_at is # older than this many seconds. Slightly larger than the 60s Redis TTL @@ -58,6 +74,16 @@ PLUGIN_INSTALL_MAX_DIR_BYTES=104857600 # max staged-tree size (default: 100 # the built-in default (90s). REMOTE_LIVENESS_STALE_AFTER=90 +# ---- Workspace hibernation (issue #724, PR #724) ---- +# Workspaces with no active tasks hibernate after this many minutes. +# Leave empty to disable. Per-workspace override via the hibernation_idle_minutes +# column (set via PATCH /workspaces/:id or org.yaml). This env var sets the +# platform-wide default applied to workspaces that have no per-workspace setting. +# Note: the global-default behaviour (reading this env var) is pending — currently +# only the per-workspace DB column is active. Setting this has no effect until that +# code lands. +HIBERNATION_IDLE_MINUTES=60 + # Canvas NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080 NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws @@ -71,7 +97,7 @@ CEREBRAS_API_KEY= # Cerebras API key (cloud.cerebras.ai). Use with GOOGLE_API_KEY= # Google AI API key (aistudio.google.com). Use with model: google_genai:gemini-2.5-flash MAX_TOKENS=2048 # Max output tokens for OpenRouter requests (default: 2048) LANGGRAPH_RECURSION_LIMIT=500 # LangGraph/DeepAgents max ReAct steps per turn (lib default: 25; raised to 500 — PM fan-out to 6+ reports + synthesis routinely exceeds 100) -MODEL_PROVIDER=anthropic:claude-sonnet-4-6 # Format: provider:model. Providers: anthropic, openai, openrouter, groq, cerebras, google_genai, ollama +MODEL_PROVIDER=anthropic:claude-opus-4-7 # Format: provider:model. Providers: anthropic, openai, openrouter, groq, cerebras, google_genai, ollama # ---- Workspace tier resource limits (issue #14) ---- # Per-tier memory/CPU caps applied to each workspace Docker container. @@ -87,12 +113,30 @@ TIER4_CPU_SHARES=4096 # Full-host tier CPU (default 4096 = 4 CPU; previ # Social Channels (optional — configure per-workspace via API or Canvas) TELEGRAM_BOT_TOKEN= # Telegram Bot API token (talk to @BotFather). Used as default for new Telegram channels. +DISCORD_WEBHOOK_URL= # Discord Incoming Webhook URL (Server → Channel → Integrations → Webhooks). Used by Community Manager workspace. + +# CI/CD Slack notifications (issue #624) +# Add SLACK_CI_WEBHOOK_URL as a GitHub Actions secret (repo Settings → Secrets → Actions). +# When set, CI failures in platform-build, canvas-build, python-lint, shellcheck, +# and e2e-api workflows post an alert to the configured #ci-alerts Slack channel. +# Obtain: Slack App → Incoming Webhooks → Add to channel → copy URL. +# Leave unset to disable (jobs skip silently — no build failure). +SLACK_CI_WEBHOOK_URL= # https://hooks.slack.com/services/... # Langfuse (optional observability) LANGFUSE_HOST=http://langfuse-web:3000 LANGFUSE_PUBLIC_KEY= LANGFUSE_SECRET_KEY= +# ---- EU AI Act Annex III compliance — molecule-audit-ledger (#594) ---- +# Secret salt for PBKDF2 key derivation (HMAC-SHA256 chain verification). +# When set, GET /workspaces/:id/audit derives the HMAC key and verifies the +# chain inline, returning "chain_valid": true/false in the response. +# When unset, "chain_valid": null — use the CLI to verify: +# python -m molecule_audit.verify --agent-id +# Must match AUDIT_LEDGER_SALT set in each workspace container. +# AUDIT_LEDGER_SALT= # 32+ random bytes (base64 or arbitrary string) + # ---- Operator identity (for org-templates/reno-stars/, see OPERATOR_NOTES.md) ---- # These are NOT consumed by the platform itself — they're documented here so # operators of the reno-stars template (and any future operator-personalised @@ -106,3 +150,11 @@ GADS_MCC_ID= # Google Ads MCC (manager) account ID, format 123 GADS_CUSTOMER_ID= # Google Ads child customer ID, format 987-654-3210 GCP_PROJECT_ID= # Google Cloud project ID (e.g. my-website-123456) GSC_SERVICE_ACCOUNT= # Search Console reporter service account email + +# ---- opencode / remote MCP client auth (see docs/integrations/opencode.md) ---- +# MOLECULE_MCP_URL is the base URL of the Molecule platform's /mcp endpoint. +# MOLECULE_MCP_TOKEN is a workspace-scoped bearer token issued via +# POST /workspaces/:id/tokens (scopes: mcp:read, mcp:delegate). +# Token goes in Authorization: Bearer header — never embed in the URL. +MOLECULE_MCP_URL= # e.g. https://api.molecule.ai or http://localhost:8080 +MOLECULE_MCP_TOKEN= # workspace-scoped bearer token — NEVER COMMIT diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9438bf0d..7013f86f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,8 +7,41 @@ on: branches: [main] jobs: + # Detect which paths changed so downstream jobs can skip when only + # docs/markdown files were modified. Saves ~15 min of runner time per + # docs-only PR. + changes: + name: Detect changes + runs-on: [self-hosted, macos, arm64] + outputs: + platform: ${{ steps.filter.outputs.platform }} + canvas: ${{ steps.filter.outputs.canvas }} + python: ${{ steps.filter.outputs.python }} + scripts: ${{ steps.filter.outputs.scripts }} + steps: + - uses: actions/checkout@v4 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + platform: + - 'platform/**' + - '.github/workflows/ci.yml' + canvas: + - 'canvas/**' + - '.github/workflows/ci.yml' + python: + - 'workspace-template/**' + - '.github/workflows/ci.yml' + scripts: + - 'tests/e2e/**' + - 'scripts/**' + - '.github/workflows/ci.yml' + platform-build: name: Platform (Go) + needs: changes + if: needs.changes.outputs.platform == 'true' runs-on: [self-hosted, macos, arm64] defaults: run: @@ -43,6 +76,8 @@ jobs: canvas-build: name: Canvas (Next.js) + needs: changes + if: needs.changes.outputs.canvas == 'true' runs-on: [self-hosted, macos, arm64] defaults: run: @@ -67,6 +102,8 @@ jobs: shellcheck: name: Shellcheck (E2E scripts) + needs: changes + if: needs.changes.outputs.scripts == 'true' runs-on: [self-hosted, macos, arm64] steps: - uses: actions/checkout@v4 @@ -84,7 +121,8 @@ jobs: canvas-deploy-reminder: name: Canvas Deploy Reminder runs-on: [self-hosted, macos, arm64] - needs: canvas-build + needs: [changes, canvas-build] + if: needs.changes.outputs.canvas == 'true' # Only fires on direct pushes to main (i.e. after a PR merges). # PRs get canvas-build CI but no reminder — no deployment happens on PRs. if: github.event_name == 'push' && github.ref == 'refs/heads/main' @@ -128,6 +166,8 @@ jobs: python-lint: name: Python Lint & Test + needs: changes + if: needs.changes.outputs.python == 'true' runs-on: [self-hosted, macos, arm64] defaults: run: diff --git a/.github/workflows/e2e-api.yml b/.github/workflows/e2e-api.yml index ed29a00d..8468ebaa 100644 --- a/.github/workflows/e2e-api.yml +++ b/.github/workflows/e2e-api.yml @@ -15,8 +15,16 @@ name: E2E API Smoke Test on: push: branches: [main] + paths: + - 'platform/**' + - 'tests/e2e/**' + - '.github/workflows/e2e-api.yml' pull_request: branches: [main] + paths: + - 'platform/**' + - 'tests/e2e/**' + - '.github/workflows/e2e-api.yml' # Workflow-level concurrency: new runs queue rather than cancel. # `cancel-in-progress: false` is load-bearing — without it GitHub would still diff --git a/.gitignore b/.gitignore index a3a4a2a1..f665de99 100644 --- a/.gitignore +++ b/.gitignore @@ -124,6 +124,14 @@ org-templates/**/.auth-token # Cloned-via-manifest dirs — populated locally by scripts/clone-manifest.sh, # tracked in their own standalone repos. Never commit to core. -/org-templates/ +# Ignore all cloned org-template content except the molecule-dev reference +# system-prompt template (tracked in core as the canonical shared-context +# source; role-specific prompts live in molecule-ai-org-template-molecule-dev). +# Pattern uses content-glob (/org-templates/*) rather than directory-ignore +# (/org-templates/) so git can re-include specific files via ! negation. +/org-templates/* +!/org-templates/molecule-dev +/org-templates/molecule-dev/* +!/org-templates/molecule-dev/system-prompt.md /plugins/ /workspace-configs-templates/ diff --git a/.mcp-eval/mcpeval.yaml b/.mcp-eval/mcpeval.yaml new file mode 100644 index 00000000..30fd6ddc --- /dev/null +++ b/.mcp-eval/mcpeval.yaml @@ -0,0 +1,23 @@ +# mcp-eval configuration for @molecule-ai/mcp-server +# Run: mcp-eval run .mcp-eval/tests/ --json mcp-eval-results.json +# Docs: https://github.com/lastmile-ai/mcp-eval + +provider: anthropic +model: claude-opus-4-7 + +mcp: + servers: + molecule_mcp: + command: "npx" + args: ["-y", "@molecule-ai/mcp-server"] + env: + MOLECULE_URL: "${MOLECULE_URL:-http://localhost:8080}" + +thresholds: + success_rate_min: 0.98 # ≥ 98% tool calls must succeed + latency_p95_max_ms: 1000 # P95 latency < 1 s + latency_p50_max_ms: 300 # P50 latency < 300 ms + +execution: + timeout_seconds: 60 + max_concurrency: 3 diff --git a/.mcp-eval/tests/test_a2a_tools.yaml b/.mcp-eval/tests/test_a2a_tools.yaml new file mode 100644 index 00000000..2a9aafa0 --- /dev/null +++ b/.mcp-eval/tests/test_a2a_tools.yaml @@ -0,0 +1,48 @@ +# Gate: A2A delegation and peer-discovery tools +# list_peers must return a list structure; async_delegate must return a task_id. + +name: a2a_tools +description: > + Verifies the core A2A communication tools: peer discovery (list_peers), + async delegation (async_delegate → task_id), delegation status check + (check_delegations), and access-check enforcement (check_access). + +steps: + - name: list_peers_returns_list + tool: list_peers + input: {} + assertions: + - type: no_error + - type: response_type + expected: list_or_empty + - type: latency_ms + max: 500 + + - name: async_delegate_returns_task_id + tool: async_delegate + input: + task: "mcp-eval smoke test — no-op" + assertions: + - type: no_error + - type: contains_key + key: "task_id" + - type: latency_ms + max: 1000 + + - name: check_delegations_reachable + tool: check_delegations + input: {} + assertions: + - type: no_error + - type: latency_ms + max: 500 + + - name: check_access_reachable + tool: check_access + input: + source_workspace_id: "test:mcp-eval" + target_workspace_id: "test:mcp-eval" + assertions: + - type: no_error + - type: latency_ms + max: 500 diff --git a/.mcp-eval/tests/test_approval_tool.yaml b/.mcp-eval/tests/test_approval_tool.yaml new file mode 100644 index 00000000..ccf9572a --- /dev/null +++ b/.mcp-eval/tests/test_approval_tool.yaml @@ -0,0 +1,39 @@ +# Gate: approval workflow tools are reachable and return correct schema +# Verifies create_approval, list_pending_approvals, get_workspace_approvals. + +name: approval_tool +description: > + Verifies the approval-gate tools expose the correct schema and respond + within latency budget. Does NOT create real approvals — uses a dry-run + input that exercises the schema-validation path. + +steps: + - name: list_pending_approvals_reachable + tool: list_pending_approvals + input: {} + assertions: + - type: no_error + - type: latency_ms + max: 500 + + - name: get_workspace_approvals_schema + tool: get_workspace_approvals + input: {} + assertions: + - type: no_error + - type: response_type + expected: list_or_empty + - type: latency_ms + max: 500 + + - name: create_approval_returns_id + tool: create_approval + input: + reason: "mcp-eval smoke test approval — safe to auto-reject" + context: "Triggered by mcp-eval CI quality gate" + assertions: + - type: no_error + - type: contains_key + key: "id" + - type: latency_ms + max: 1000 diff --git a/.mcp-eval/tests/test_list_tools.yaml b/.mcp-eval/tests/test_list_tools.yaml new file mode 100644 index 00000000..5f260171 --- /dev/null +++ b/.mcp-eval/tests/test_list_tools.yaml @@ -0,0 +1,32 @@ +# Gate: all expected @molecule-ai/mcp-server tools are present and reachable +# Threshold: list_workspaces latency < 500ms + +name: list_tools +description: > + Verifies that the MCP server exposes its full tool inventory and that the + core workspace-management tool responds within latency budget. + +steps: + - name: list_workspaces_smoke + tool: list_workspaces + input: {} + assertions: + - type: no_error + - type: latency_ms + max: 500 + + - name: list_peers_reachable + tool: list_peers + input: {} + assertions: + - type: no_error + - type: latency_ms + max: 500 + + - name: get_workspace_approvals_reachable + tool: get_workspace_approvals + input: {} + assertions: + - type: no_error + - type: latency_ms + max: 500 diff --git a/.mcp-eval/tests/test_memory_tools.yaml b/.mcp-eval/tests/test_memory_tools.yaml new file mode 100644 index 00000000..1507cacb --- /dev/null +++ b/.mcp-eval/tests/test_memory_tools.yaml @@ -0,0 +1,51 @@ +# Gate: commit + recall round-trip integrity +# Verifies memory_set → memory_get returns the exact value that was stored. + +name: memory_tools +description: > + Commits a unique sentinel value via memory_set, then retrieves it with + memory_get and asserts the value matches. Also exercises search_memory to + confirm full-text indexing is operational. + +steps: + - name: memory_set_sentinel + tool: memory_set + input: + key: "mcp_eval_sentinel" + value: "mcp-eval-round-trip-ok-{{ timestamp }}" + assertions: + - type: no_error + - type: latency_ms + max: 500 + + - name: memory_get_sentinel + tool: memory_get + input: + key: "mcp_eval_sentinel" + assertions: + - type: no_error + - type: contains + value: "mcp-eval-round-trip-ok" + - type: latency_ms + max: 500 + + - name: commit_memory_hma + tool: commit_memory + input: + content: "mcp-eval HMA commit smoke test" + scope: "LOCAL" + assertions: + - type: no_error + - type: latency_ms + max: 1000 + + - name: search_memory_finds_committed + tool: search_memory + input: + query: "mcp-eval HMA commit smoke test" + assertions: + - type: no_error + - type: contains + value: "mcp-eval" + - type: latency_ms + max: 1000 diff --git a/CLAUDE.md b/CLAUDE.md index 27c377a0..ae864260 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,12 @@ secrets` on `molecule-cp`), the correct rotation order, and danger cases — notably `SECRETS_ENCRYPTION_KEY`, which cannot be rotated without a data migration until Phase H lands KMS envelope encryption. +For tenant subdomain routing architecture (why `*.moleculesai.app` uses a +Cloudflare Worker instead of per-tenant DNS records), read +**`docs/architecture/wildcard-dns-proxy.md`**. This eliminates DNS +propagation delays and NXDOMAIN caching that previously caused "site can't +be reached" errors for new orgs. + When handling a GDPR erasure request (user asks "delete my org and all my data"), read **`docs/runbooks/gdpr-erasure.md`** first. It explains the 4-step cascade in `molecule-controlplane` (Stripe → Redis → Infra → DB @@ -143,7 +149,7 @@ go run ./cmd/server # Run server (requires Postgres + Redis running) go build -o molecli ./cmd/cli # Build TUI dashboard ./molecli # Run TUI dashboard (requires platform running) ``` -Must run from `platform/` directory (not repo root). Env vars: `DATABASE_URL`, `REDIS_URL`, `PORT`, `PLATFORM_URL` (default `http://host.docker.internal:PORT` — passed to agent containers so they can reach the platform), `SECRETS_ENCRYPTION_KEY` (optional AES-256, 32 bytes), `CONFIGS_DIR` (auto-discovered), `PLUGINS_DIR` (deprecated — plugins are now installed per-workspace via API; the `plugins/` registry at repo root is auto-discovered), `ACTIVITY_RETENTION_DAYS` (default `7`), `ACTIVITY_CLEANUP_INTERVAL_HOURS` (default `6`), `CORS_ORIGINS` (comma-separated, default `http://localhost:3000,http://localhost:3001`), `RATE_LIMIT` (requests/min, default `600`), `WORKSPACE_DIR` (optional — global fallback host path for `/workspace` bind-mount; overridden by per-workspace `workspace_dir` column in DB; if neither is set, each workspace gets an isolated Docker named volume), `AWARENESS_URL` (optional — if set, injected into workspace containers along with a deterministic `AWARENESS_NAMESPACE` derived from workspace ID), `MOLECULE_IN_DOCKER` (optional — set to `1` when the platform itself runs inside Docker so the A2A proxy rewrites `127.0.0.1:` URLs to container hostnames; auto-detected via `/.dockerenv`), `MOLECULE_ENV` (optional — set to `production` to hide the `/admin/workspaces/:id/test-token` E2E helper endpoint; unset or any other value leaves it enabled), `MOLECULE_ENABLE_TEST_TOKENS` (optional — set to `1` to force-enable the test-token endpoint even when `MOLECULE_ENV=production`; intended for staging runs only), `MOLECULE_ORG_ID` (optional — the public repo's only SaaS hook. When set to a UUID, every non-allowlisted request must carry a matching `X-Molecule-Org-Id` header or gets a 404; when unset, the guard is a passthrough so self-hosted / dev / CI are unaffected. Set only by the private `molecule-controlplane` provisioner on Fly Machines tenant instances — never by self-hosters). +Must run from `platform/` directory (not repo root). Env vars: `DATABASE_URL`, `REDIS_URL`, `PORT`, `ADMIN_TOKEN` (**required to close issue #684** — when set, only this exact value is accepted on all `/admin/*` and `/approvals/*` routes; without it, any valid workspace bearer token passes AdminAuth, which is the #684 vulnerability. Generate: `openssl rand -base64 32`. Never commit the actual value — inject via `fly secrets set` or deployment env. PR #729), `PLATFORM_URL` (default `http://host.docker.internal:PORT` — passed to agent containers so they can reach the platform), `SECRETS_ENCRYPTION_KEY` (optional AES-256, 32 bytes), `CONFIGS_DIR` (auto-discovered), `PLUGINS_DIR` (deprecated — plugins are now installed per-workspace via API; the `plugins/` registry at repo root is auto-discovered), `ACTIVITY_RETENTION_DAYS` (default `7`), `ACTIVITY_CLEANUP_INTERVAL_HOURS` (default `6`), `CORS_ORIGINS` (comma-separated, default `http://localhost:3000,http://localhost:3001`), `RATE_LIMIT` (requests/min, default `600`), `WORKSPACE_DIR` (optional — global fallback host path for `/workspace` bind-mount; overridden by per-workspace `workspace_dir` column in DB; if neither is set, each workspace gets an isolated Docker named volume), `AWARENESS_URL` (optional — if set, injected into workspace containers along with a deterministic `AWARENESS_NAMESPACE` derived from workspace ID), `MOLECULE_IN_DOCKER` (optional — set to `1` when the platform itself runs inside Docker so the A2A proxy rewrites `127.0.0.1:` URLs to container hostnames; auto-detected via `/.dockerenv`), `MOLECULE_ENV` (optional — set to `production` to hide the `/admin/workspaces/:id/test-token` E2E helper endpoint; unset or any other value leaves it enabled), `MOLECULE_ENABLE_TEST_TOKENS` (optional — set to `1` to force-enable the test-token endpoint even when `MOLECULE_ENV=production`; intended for staging runs only), `MOLECULE_ORG_ID` (optional — the public repo's only SaaS hook. When set to a UUID, every non-allowlisted request must carry a matching `X-Molecule-Org-Id` header or gets a 404; when unset, the guard is a passthrough so self-hosted / dev / CI are unaffected. Set only by the private `molecule-controlplane` provisioner on Fly Machines tenant instances — never by self-hosters). **Workspace tier resource limits** (issue #14 — override the per-tier memory/CPU caps in `provisioner.ApplyTierConfig`; CPU_SHARES follows Docker's 1024 = 1 CPU convention, translated to NanoCPUs for a hard cap): - `TIER2_MEMORY_MB` / `TIER2_CPU_SHARES` — Standard tier (defaults `512` / `1024`) @@ -266,12 +272,27 @@ All five E2E scripts share `tests/e2e/_lib.sh` + `tests/e2e/_extract_token.py` h The MCP server now lives at **github.com/Molecule-AI/molecule-mcp-server** and is published as `@molecule-ai/mcp-server` on npm. Install: `npx @molecule-ai/mcp-server`. 87 tools for managing Molecule AI from any MCP client. Configured in `.mcp.json`. Env: `MOLECULE_URL` (default http://localhost:8080). ### CI Pipeline -GitHub Actions (`.github/workflows/ci.yml`) runs on push to main and PRs: +GitHub Actions (`.github/workflows/ci.yml`) runs on push to main and PRs. +**Path-filtered:** each job only runs when its relevant files change (via +`dorny/paths-filter`). Docs-only PRs (`docs/**`, `*.md`) skip all jobs, +saving ~15 min of runner time. The path filters are: + +| Job | Triggers on | +|-----|-------------| +| **platform-build** | `platform/**` | +| **canvas-build** | `canvas/**` | +| **python-lint** | `workspace-template/**` | +| **shellcheck** | `tests/e2e/**`, `scripts/**` | +| **e2e-api** | `platform/**`, `tests/e2e/**` | + +All jobs also trigger on `.github/workflows/ci.yml` changes (self-test). + +Job details: - **platform-build**: Go build, vet, `go test -race` with coverage profiling (25% baseline threshold; `setup-go` uses module cache) - **canvas-build**: npm build, `vitest run` (no `--passWithNoTests` -- tests must exist and pass) - **python-lint**: `pytest --cov=. --cov-report=term-missing` (workspace-template tests; SDK + MCP now in standalone repos) -- **e2e-api** (added 2026-04-13): spins up Postgres + Redis service containers, runs platform migrations via `docker exec`, then executes `tests/e2e/test_api.sh` against a locally-built binary (62/62 must pass) -- **shellcheck** (added 2026-04-13): lints every `tests/e2e/*.sh` via the shellcheck marketplace action +- **e2e-api** (`.github/workflows/e2e-api.yml`): spins up Postgres + Redis service containers, runs platform migrations via `docker exec`, then executes `tests/e2e/test_api.sh` against a locally-built binary (62/62 must pass) +- **shellcheck**: lints every `tests/e2e/*.sh` via shellcheck on the self-hosted runner - **publish-platform-image** (`.github/workflows/publish-platform-image.yml`): on push to main touching `platform/**`, builds `platform/Dockerfile` (clones templates + plugins from GitHub via `manifest.json` at build time) and pushes to `ghcr.io/molecule-ai/platform:latest` + `:sha-`. Tenant image uses `platform/Dockerfile.tenant` (combined Go + Canvas). Manual re-trigger via `workflow_dispatch`. **Standalone repo CI** — all 33 plugin + template repos call reusable workflows from `Molecule-AI/molecule-ci`: diff --git a/PLAN.md b/PLAN.md index 158e132a..10c37359 100644 --- a/PLAN.md +++ b/PLAN.md @@ -575,6 +575,53 @@ self-hosted per-customer). Ordered by dependency + ROI. --- +## Phase 33: Wildcard DNS + Cloudflare Worker Proxy + +> **Goal:** Eliminate DNS propagation delays and NXDOMAIN caching for tenant +> subdomains. Every SaaS (Vercel, Railway, Fly.io) uses this pattern — +> wildcard DNS + edge proxy routing by hostname. +> +> **Docs:** `docs/architecture/wildcard-dns-proxy.md` + +### Phase 33.1 — Worker + wildcard DNS (no tenant changes) + +- [ ] Create Cloudflare Worker that extracts slug from hostname, looks up + backend IP from CP API, proxies request to EC2 +- [ ] Add `GET /cp/orgs/:slug/instance` endpoint to CP (public, rate-limited) +- [ ] Add `*.moleculesai.app` wildcard DNS record (proxied, orange cloud) +- [ ] Worker serves static "provisioning" splash page when tenant not ready +- [ ] Deploy Worker via `wrangler deploy` + GitHub Actions +- [ ] Verify Worker routing works for existing tenants alongside old A records + +### Phase 33.2 — Stop per-tenant DNS records + +- [ ] Remove Cloudflare A record creation from `ec2.go` provisioner +- [ ] Remove Cloudflare DNS cleanup from deprovision/purge cascade +- [ ] Existing A records coexist harmlessly (explicit wins over wildcard) + +### Phase 33.3 — Remove Caddy from EC2 + +- [ ] Worker handles TLS termination — EC2 runs plain HTTP only +- [ ] Remove Caddy install + Caddyfile from EC2 user-data script +- [ ] EC2 security group: allow inbound HTTP from Cloudflare IPs only +- [ ] ~30s faster cold start (no apt-get caddy, no Let's Encrypt) + +### Phase 33.4 — Cleanup + +- [ ] Delete old per-tenant A records from Cloudflare +- [ ] Remove `cloudflareapi/` package from CP (Worker replaces it) +- [ ] Update `docs/runbooks/saas-secrets.md` with Worker secrets + +### Success criteria for Phase 33 + +- New org subdomain resolves instantly (zero DNS wait) +- No NXDOMAIN caching — user never sees "site can't be reached" +- Provisioning splash page shown while EC2 boots (auto-refreshes) +- Cold start ~30s faster (no Caddy/Let's Encrypt) +- Cost: Cloudflare Worker free tier or $5/mo + +--- + ## Infra footnote — Temporal `docker-compose.infra.yml` now includes Temporal (`:7233` gRPC, `:8233` Web diff --git a/canvas/src/components/AuditTrailPanel.tsx b/canvas/src/components/AuditTrailPanel.tsx new file mode 100644 index 00000000..f7056dbe --- /dev/null +++ b/canvas/src/components/AuditTrailPanel.tsx @@ -0,0 +1,276 @@ +'use client'; + +import { useState, useEffect, useCallback } from "react"; +import { api } from "@/lib/api"; +import type { AuditEntry, AuditResponse } from "@/types/audit"; + +// ── Constants ───────────────────────────────────────────────────────────────── + +type EventFilter = "all" | AuditEntry["event_type"]; + +const BADGE_COLORS: Record = { + delegation: { text: "text-blue-400", bg: "bg-blue-950/40", border: "border-blue-800/40" }, + decision: { text: "text-violet-400", bg: "bg-violet-950/40", border: "border-violet-800/40" }, + gate: { text: "text-yellow-400", bg: "bg-yellow-950/40", border: "border-yellow-800/40" }, + hitl: { text: "text-orange-400", bg: "bg-orange-950/40", border: "border-orange-800/40" }, +}; + +const FILTERS: { id: EventFilter; label: string }[] = [ + { id: "all", label: "All" }, + { id: "delegation", label: "Delegation" }, + { id: "decision", label: "Decision" }, + { id: "gate", label: "Gate" }, + { id: "hitl", label: "HITL" }, +]; + +const AUDIT_LIMIT = 50; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Format an ISO timestamp as a human-readable relative time string. + * Exported so unit tests can call it directly without rendering. + */ +export function formatAuditRelativeTime(iso: string, now = Date.now()): string { + const diff = now - new Date(iso).getTime(); + if (diff < 60_000) return "just now"; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + return new Date(iso).toLocaleDateString(); +} + +// ── Component ───────────────────────────────────────────────────────────────── + +interface Props { + workspaceId: string; +} + +/** + * AuditTrailPanel — side-panel tab showing the workspace audit ledger. + * + * Features: + * - Color-coded event-type badges (delegation/decision/gate/hitl) + * - chain_valid=false tamper ⚠ indicator + * - Event-type filter bar + * - Cursor-based "Load more" pagination + * - Relative timestamps refreshed every 30 s + * - Empty state with icon + */ +export function AuditTrailPanel({ workspaceId }: Props) { + const [entries, setEntries] = useState([]); + const [cursor, setCursor] = useState(null); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(null); + const [filter, setFilter] = useState("all"); + // Relative-time "now" — refreshed every 30 s to keep labels current + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 30_000); + return () => clearInterval(timer); + }, []); + + // ── URL builder (stable between renders when inputs unchanged) ───────────── + + const buildUrl = useCallback( + (cursorParam?: string | null): string => { + const params = new URLSearchParams(); + params.set("limit", String(AUDIT_LIMIT)); + if (filter !== "all") params.set("event_type", filter); + if (cursorParam) params.set("cursor", cursorParam); + return `/workspaces/${workspaceId}/audit?${params.toString()}`; + }, + [workspaceId, filter] + ); + + // ── Initial load (and on filter change) ─────────────────────────────────── + + const loadEntries = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await api.get(buildUrl()); + setEntries(data.entries ?? []); + setCursor(data.cursor ?? null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load audit trail"); + setEntries([]); + setCursor(null); + } finally { + setLoading(false); + } + }, [buildUrl]); + + useEffect(() => { + loadEntries(); + }, [loadEntries]); + + // ── Pagination (append next page) ───────────────────────────────────────── + + const loadMore = useCallback(async () => { + if (!cursor || loadingMore) return; + setLoadingMore(true); + try { + const data = await api.get(buildUrl(cursor)); + setEntries((prev) => [...prev, ...(data.entries ?? [])]); + setCursor(data.cursor ?? null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load more entries"); + } finally { + setLoadingMore(false); + } + }, [cursor, loadingMore, buildUrl]); + + // ── Render ───────────────────────────────────────────────────────────────── + + if (loading) { + return ( +
+ Loading audit trail… +
+ ); + } + + return ( +
+ {/* Filter bar */} +
+ {FILTERS.map((f) => ( + + ))} +
+ +
+ + {/* Error banner */} + {error && ( +
+ {error} +
+ )} + + {/* Content */} +
+ {entries.length === 0 ? ( + /* Empty state */ +
+ +

No audit events yet

+

+ Delegation, decision, gate, and human-in-the-loop events will appear here. +

+
+ ) : ( + <> +
+ {entries.map((entry) => ( + + ))} +
+ + {/* Load more */} + {cursor && ( +
+ +
+ )} + + {/* Entry count footer */} +

+ {entries.length} event{entries.length !== 1 ? "s" : ""} loaded + {cursor ? " · more available" : " · all loaded"} +

+ + )} +
+
+ ); +} + +// ── AuditEntryRow sub-component ─────────────────────────────────────────────── + +export interface AuditEntryRowProps { + entry: AuditEntry; + now: number; +} + +/** + * Single audit-trail entry row. + * Exported so tests can render it in isolation without the full panel. + */ +export function AuditEntryRow({ entry, now }: AuditEntryRowProps) { + const badge = BADGE_COLORS[entry.event_type] ?? { + text: "text-zinc-400", + bg: "bg-zinc-800/40", + border: "border-zinc-700/40", + }; + + return ( +
+ {/* Header row: badge · actor · tamper flag · timestamp */} +
+ {/* Event-type badge */} + + {entry.event_type} + + + {/* Actor name */} + + {entry.actor} + + + {/* Tamper warning — only rendered when chain is invalid */} + {!entry.chain_valid && ( + + ⚠ + + )} + + {/* Relative timestamp */} + + {formatAuditRelativeTime(entry.created_at, now)} + +
+ + {/* Summary text */} +

+ {entry.summary} +

+
+ ); +} diff --git a/canvas/src/components/Canvas.tsx b/canvas/src/components/Canvas.tsx index add2ffa4..714f7e6d 100644 --- a/canvas/src/components/Canvas.tsx +++ b/canvas/src/components/Canvas.tsx @@ -32,7 +32,7 @@ import { Toolbar } from "./Toolbar"; import { ConfirmDialog } from "./ConfirmDialog"; // Phase 20 components import { SettingsPanel, DeleteConfirmDialog } from "./settings"; -// import { ProvisioningTimeout } from "./ProvisioningTimeout"; +import { ProvisioningTimeout } from "./ProvisioningTimeout"; const nodeTypes = { workspaceNode: WorkspaceNode, @@ -334,7 +334,7 @@ function CanvasInner() { - {/* */} + {!selectedNodeId && } {/* Confirmation dialog for structure changes */} diff --git a/canvas/src/components/ConversationTraceModal.tsx b/canvas/src/components/ConversationTraceModal.tsx index 9b8851bc..a603b553 100644 --- a/canvas/src/components/ConversationTraceModal.tsx +++ b/canvas/src/components/ConversationTraceModal.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; import { api } from "@/lib/api"; import { useCanvasStore } from "@/store/canvas"; import { type ActivityEntry } from "@/types/activity"; @@ -46,7 +47,7 @@ function extractMessageText(body: Record | null): string { return ""; } -export function ConversationTraceModal({ open, workspaceId, onClose }: Props) { +export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClose }: Props) { const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const nodes = useCanvasStore((s) => s.nodes); @@ -83,205 +84,215 @@ export function ConversationTraceModal({ open, workspaceId, onClose }: Props) { }); }, [open, nodes]); - if (!open) return null; - const isA2A = (e: ActivityEntry) => e.activity_type === "a2a_receive" || e.activity_type === "a2a_send"; return ( -
- {/* Backdrop */} -
+ { if (!o) onClose(); }}> + + {/* Overlay replaces the old manual backdrop div */} + - {/* Modal */} -
- {/* Header */} -
-
-

- Conversation Trace -

-

- {entries.length} events across all workspaces -

-
- -
- - {/* Timeline */} -
- {loading && ( -
- Loading trace from all workspaces... + {/* Content wraps the entire centred modal panel */} + + {/* Modal panel */} +
+ {/* Header */} +
+
+ + Conversation Trace + +

+ {entries.length} events across all workspaces +

+
+ + +
- )} - {!loading && entries.length === 0 && ( -
- No activity found -
- )} + {/* Timeline */} +
+ {loading && ( +
+ Loading trace from all workspaces... +
+ )} -
- {entries.map((entry) => { - const time = new Date(entry.created_at).toLocaleTimeString(); - const wsName = resolveName(entry.workspace_id); - const sourceName = resolveName(entry.source_id); - const targetName = resolveName(entry.target_id); - const requestText = extractMessageText(entry.request_body); - const responseText = extractMessageText(entry.response_body); - const isError = entry.status === "error"; - const isSend = entry.activity_type === "a2a_send"; - const isReceive = entry.activity_type === "a2a_receive"; + {!loading && entries.length === 0 && ( +
+ No activity found +
+ )} - return ( -
- {/* Event header */} -
- {/* Timeline dot + line */} -
-
-
-
+
+ {entries.map((entry) => { + const time = new Date(entry.created_at).toLocaleTimeString(); + const wsName = resolveName(entry.workspace_id); + const sourceName = resolveName(entry.source_id); + const targetName = resolveName(entry.target_id); + const requestText = extractMessageText(entry.request_body); + const responseText = extractMessageText(entry.response_body); + const isError = entry.status === "error"; + const isSend = entry.activity_type === "a2a_send"; + const isReceive = entry.activity_type === "a2a_receive"; - {/* Content */} -
-
- - {time} - - - {isSend - ? "SEND" - : isReceive - ? "RECEIVE" - : entry.activity_type.toUpperCase()} - - {entry.duration_ms != null && entry.duration_ms > 0 && ( - - {entry.duration_ms > 1000 - ? `${Math.round(entry.duration_ms / 1000)}s` - : `${entry.duration_ms}ms`} - - )} -
+ return ( +
+ {/* Event header */} +
+ {/* Timeline dot + line */} +
+
+
+
- {/* Flow */} - {isA2A(entry) && ( -
- {isSend ? ( - - - {sourceName || wsName} - - - - {targetName} - + {/* Content */} +
+
+ + {time} - ) : ( - - - {targetName || wsName} + + {isSend + ? "SEND" + : isReceive + ? "RECEIVE" + : entry.activity_type.toUpperCase()} + + {entry.duration_ms != null && entry.duration_ms > 0 && ( + + {entry.duration_ms > 1000 + ? `${Math.round(entry.duration_ms / 1000)}s` + : `${entry.duration_ms}ms`} - {sourceName && ( - <> - - {" "}← {" "} - + )} +
+ + {/* Flow */} + {isA2A(entry) && ( +
+ {isSend ? ( + - {sourceName} + {sourceName || wsName} - + + + {targetName} + + + ) : ( + + + {targetName || wsName} + + {sourceName && ( + <> + + {" "}← {" "} + + + {sourceName} + + + )} + )} - +
+ )} + + {/* Summary */} + {entry.summary && !isA2A(entry) && ( +
+ {wsName}:{" "} + {entry.summary} +
+ )} + + {/* Error */} + {isError && entry.error_detail && ( +
+ {entry.error_detail.slice(0, 200)} +
+ )} + + {/* Message content — show request and/or response */} + {requestText && ( +
+
+ {isSend ? "Task" : "Request"} +
+
+ {requestText.slice(0, 2000)} + {requestText.length > 2000 && ( + ...({requestText.length} chars) + )} +
+
+ )} + {responseText && ( +
+
Response
+
+ {responseText.slice(0, 2000)} + {responseText.length > 2000 && ( + ...({responseText.length} chars) + )} +
+
)}
- )} - - {/* Summary */} - {entry.summary && !isA2A(entry) && ( -
- {wsName}:{" "} - {entry.summary} -
- )} - - {/* Error */} - {isError && entry.error_detail && ( -
- {entry.error_detail.slice(0, 200)} -
- )} - - {/* Message content — show request and/or response */} - {requestText && ( -
-
- {isSend ? "Task" : "Request"} -
-
- {requestText.slice(0, 2000)} - {requestText.length > 2000 && ( - ...({requestText.length} chars) - )} -
-
- )} - {responseText && ( -
-
Response
-
- {responseText.slice(0, 2000)} - {responseText.length > 2000 && ( - ...({responseText.length} chars) - )} -
-
- )} +
-
-
- ); - })} -
-
+ ); + })} +
+
- {/* Footer */} -
- -
-
-
+ {/* Footer */} +
+ + + +
+
+ + + ); } diff --git a/canvas/src/components/EmptyState.tsx b/canvas/src/components/EmptyState.tsx index 52cab350..3b793495 100644 --- a/canvas/src/components/EmptyState.tsx +++ b/canvas/src/components/EmptyState.tsx @@ -153,7 +153,7 @@ export function EmptyState() {
{error && ( -
+
{error}
)} diff --git a/canvas/src/components/MemoryInspectorPanel.tsx b/canvas/src/components/MemoryInspectorPanel.tsx index 506c70d3..eac67c65 100644 --- a/canvas/src/components/MemoryInspectorPanel.tsx +++ b/canvas/src/components/MemoryInspectorPanel.tsx @@ -13,6 +13,12 @@ interface MemoryEntry { /** Omitted by the API when there is no TTL (Go omitempty) */ expires_at?: string; updated_at: string; + /** + * Semantic similarity score (0–1). Only present when the API is queried + * with ?q= and the pgvector backend has been deployed (issue #776). + * Absent on plain list fetches — renders gracefully without a badge. + */ + similarity_score?: number; } interface WriteResult { @@ -48,6 +54,28 @@ function formatRelativeTime(iso: string): string { return new Date(iso).toLocaleDateString(); } +// ── Skeleton rows — shown during re-fetches when entries already exist ──────── + +function MemorySkeletonRows() { + return ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ); +} + // ── Component ───────────────────────────────────────────────────────────────── export function MemoryInspectorPanel({ workspaceId }: Props) { @@ -55,7 +83,26 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - // Expand/edit/delete state — keyed by entry.key (string primitive, no new objects) + // ── Search state ──────────────────────────────────────────────────────────── + /** Raw input value — updated on every keystroke. */ + const [searchQuery, setSearchQuery] = useState(""); + /** + * Debounced value — drives the API fetch. + * Lags searchQuery by 300 ms to avoid hammering the endpoint on every key. + */ + const [debouncedQuery, setDebouncedQuery] = useState(""); + + // 300 ms debounce: cancel previous timer whenever searchQuery changes. + useEffect(() => { + const timer = setTimeout( + () => setDebouncedQuery(searchQuery.trim()), + 300 + ); + return () => clearTimeout(timer); + }, [searchQuery]); + + // ── Expand/edit/delete state (keyed by entry.key — primitives, no new objects) + const [expandedKey, setExpandedKey] = useState(null); const [editingKey, setEditingKey] = useState(null); const [editValue, setEditValue] = useState(""); @@ -69,16 +116,25 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { setLoading(true); setError(null); try { - // API returns MemoryEntry[] (flat array, never wrapped, never null) - const data = await api.get(`/workspaces/${workspaceId}/memory`); - setEntries(data); + const url = debouncedQuery + ? `/workspaces/${workspaceId}/memory?q=${encodeURIComponent(debouncedQuery)}` + : `/workspaces/${workspaceId}/memory`; + const data = await api.get(url); + // When a semantic query is active, sort by similarity_score descending. + // Entries without a score (older backend) fall to the end gracefully. + const sorted = debouncedQuery + ? [...data].sort( + (a, b) => (b.similarity_score ?? 0) - (a.similarity_score ?? 0) + ) + : data; + setEntries(sorted); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load memory entries"); setEntries([]); } finally { setLoading(false); } - }, [workspaceId]); + }, [workspaceId, debouncedQuery]); useEffect(() => { loadEntries(); @@ -100,7 +156,6 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { const saveEdit = useCallback( async (entry: MemoryEntry) => { - // Validate JSON before touching network let parsed: unknown; try { parsed = JSON.parse(editValue); @@ -142,7 +197,9 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { setEditValue(JSON.stringify(entry.value, null, 2)); const msg = e instanceof Error ? e.message : "Save failed"; if (msg.includes("409") || msg.toLowerCase().includes("mismatch")) { - setEditError("Version conflict — entry changed elsewhere. Reload to see latest."); + setEditError( + "Version conflict — entry changed elsewhere. Reload to see latest." + ); } else { setEditError(msg); } @@ -165,9 +222,10 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { if (expandedKey === key) setExpandedKey(null); try { - await api.del(`/workspaces/${workspaceId}/memory/${encodeURIComponent(key)}`); + await api.del( + `/workspaces/${workspaceId}/memory/${encodeURIComponent(key)}` + ); } catch (e) { - // On failure, reload to restore the true state setError(e instanceof Error ? e.message : "Delete failed — reloading..."); await loadEntries(); } @@ -175,7 +233,8 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { // ── Render ────────────────────────────────────────────────────────────────── - if (loading) { + // Full-screen loader — only on the very first fetch (no entries cached yet). + if (loading && entries.length === 0 && !error) { return (
Loading memory… @@ -185,10 +244,54 @@ export function MemoryInspectorPanel({ workspaceId }: Props) { return (
+ {/* Search bar */} +
+
+ {/* Magnifying glass icon */} + + setSearchQuery(e.target.value)} + placeholder="Semantic search…" + aria-label="Search memory entries" + className="w-full bg-zinc-900 border border-zinc-700/60 focus:border-blue-500/60 rounded-lg pl-8 pr-7 py-1.5 text-[11px] text-zinc-200 placeholder-zinc-600 focus:outline-none transition-colors" + /> + {/* Clear button — only shown when there is a query */} + {searchQuery && ( + + )} +
+
+ {/* Toolbar */} -
+
- {entries.length === 1 ? "1 entry" : `${entries.length} entries`} + {debouncedQuery + ? `${entries.length} result${entries.length !== 1 ? "s" : ""}` + : entries.length === 1 + ? "1 entry" + : `${entries.length} entries`} + . +

+
+ ) : ( + /* Default empty state */ +
+ +

No memory entries yet

+

+ Memory entries will appear here when the workspace writes to its KV + store. +

+
+ ) ) : (
{entries.map((entry) => { @@ -293,10 +427,7 @@ function MemoryEntryRow({ onCancelEdit, onDelete, }: MemoryEntryRowProps) { - // Sanitise the key so the generated id is a valid HTML id (no spaces or - // special chars like [ ] / : . # that would break CSS selectors / ARIA). const bodyId = `mem-body-${sanitizeId(entry.key)}`; - return (
{/* Header row — click to expand/collapse */} @@ -312,6 +443,23 @@ function MemoryEntryRow({ v{entry.version} + {/* Similarity score badge — only rendered when backend provides a score */} + {entry.similarity_score != null && ( + = 0.8 + ? "text-blue-500" + : entry.similarity_score >= 0.5 + ? "text-zinc-400" + : "text-zinc-400 italic", + ].join(" ")} + title={`Similarity: ${(entry.similarity_score * 100).toFixed(1)}%`} + data-testid="similarity-badge" + > + {entry.similarity_score < 0.5 ? "~" : ""}{Math.round(entry.similarity_score * 100)}% + + )} {formatRelativeTime(entry.updated_at)} @@ -322,7 +470,12 @@ function MemoryEntryRow({ {/* Expanded body */} {isExpanded && ( -
+
{entry.expires_at && (

Expires: {new Date(entry.expires_at).toLocaleString()} @@ -340,7 +493,9 @@ function MemoryEntryRow({ className="w-full bg-zinc-950 border border-zinc-700 focus:border-blue-500 rounded px-2 py-1.5 text-[11px] font-mono text-zinc-100 focus:outline-none resize-none transition-colors" /> {editError && ( -

{editError}

+

+ {editError} +

)}
@@ -246,6 +281,7 @@ export function SidePanel() { {panelTab === "memory" && } {panelTab === "traces" && } {panelTab === "events" && } + {panelTab === "audit" && }
{/* Footer — workspace ID */} diff --git a/canvas/src/components/Toolbar.tsx b/canvas/src/components/Toolbar.tsx index 0c2a78d5..63684204 100644 --- a/canvas/src/components/Toolbar.tsx +++ b/canvas/src/components/Toolbar.tsx @@ -14,6 +14,8 @@ export function Toolbar() { const wsStatus = useCanvasStore((s) => s.wsStatus); const showA2AEdges = useCanvasStore((s) => s.showA2AEdges); const setShowA2AEdges = useCanvasStore((s) => s.setShowA2AEdges); + const selectedNodeId = useCanvasStore((s) => s.selectedNodeId); + const setPanelTab = useCanvasStore((s) => s.setPanelTab); const [stopping, setStopping] = useState(false); const [restartingAll, setRestartingAll] = useState(false); @@ -155,6 +157,7 @@ export function Toolbar() { 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" title={`Stop all running tasks (${counts.activeTasks} active)`} + aria-label={stopping ? "Stopping all running tasks" : `Stop all running tasks (${counts.activeTasks} active)`} > @@ -172,6 +175,7 @@ export function Toolbar() { 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" 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,6 +220,34 @@ export function Toolbar() { A2A + {/* Audit trail shortcut — switches selected workspace's panel to the Audit tab */} + + {/* Search shortcut */} + ), +})); +vi.mock("../tabs/FilesTab/FileTree", () => ({ + FileTree: () =>
, +})); +vi.mock("../tabs/FilesTab/FileEditor", () => ({ + FileEditor: () =>
, +})); +vi.mock("../tabs/FilesTab/useFilesApi", () => ({ + useFilesApi: () => ({ + files: [], + loading: false, + loadFiles: vi.fn(), + expandedDirs: new Set(), + loadingDir: null, + toggleDir: vi.fn(), + readFile: vi.fn().mockResolvedValue({ content: "" }), + writeFile: vi.fn().mockResolvedValue({}), + deleteFile: vi.fn().mockResolvedValue({}), + downloadAllFiles: vi.fn(), + uploadFiles: vi.fn(), + deleteAllFiles: vi.fn(), + }), +})); +vi.mock("../tabs/FilesTab/tree", () => ({ + buildTree: vi.fn(() => []), +})); + +vi.mock("../ConfirmDialog", () => ({ + ConfirmDialog: () => null, +})); + +// ── Static imports (after mocks) ───────────────────────────────────────────── + +import { SkillsTab } from "../tabs/SkillsTab"; +import { FilesTab } from "../tabs/FilesTab"; +import { ChannelsTab } from "../tabs/ChannelsTab"; +import { ScheduleTab } from "../tabs/ScheduleTab"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeSkillsData() { + return { + id: "ws-1", + name: "Test WS", + status: "online", + tier: 1, + agentCard: null, + activeTasks: 0, + collapsed: false, + role: "agent", + lastErrorRate: 0, + lastSampleError: "", + url: "http://localhost:9000", + parentId: null, + currentTask: "", + runtime: "langgraph", + needsRestart: false, + budgetLimit: null, + }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 1. SkillsTab — aria-label on the "Install from source" bare input +// ──────────────────────────────────────────────────────────────────────────── + +describe("SkillsTab — aria-label on bare source input (WCAG 1.3.1)", () => { + beforeEach(() => { + mockApiGet.mockResolvedValue([]); + }); + + it('install source input has aria-label="Install from source URL"', async () => { + render(); + + // The source input is inside the registry section (showRegistry=false initially). + // Click the "+ Install Plugin" button to reveal it. + const installBtn = screen.getByRole("button", { name: /install plugin/i }); + fireEvent.click(installBtn); + + const input = screen.getByRole("textbox", { + name: /install from source url/i, + }); + expect(input).toBeDefined(); + expect(input.getAttribute("aria-label")).toBe("Install from source URL"); + }); + + it("install source input is a text input (not hidden)", async () => { + render(); + + const installBtn = screen.getByRole("button", { name: /install plugin/i }); + fireEvent.click(installBtn); + + const input = screen.getByRole("textbox", { + name: /install from source url/i, + }); + expect(input.tagName.toLowerCase()).toBe("input"); + expect((input as HTMLInputElement).type).toBe("text"); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 2. FilesTab — aria-label on the new file path bare input +// ──────────────────────────────────────────────────────────────────────────── + +describe("FilesTab — aria-label on new file path input (WCAG 1.3.1)", () => { + it('new file input has aria-label="New file path"', () => { + render(); + + // Trigger showNewFile via the FilesToolbar stub + const btn = screen.getByTestId("new-file-btn"); + fireEvent.click(btn); + + const input = screen.getByRole("textbox", { name: /new file path/i }); + expect(input).toBeDefined(); + expect(input.getAttribute("aria-label")).toBe("New file path"); + }); + + it("new file input is not shown before clicking the new file button", () => { + render(); + + expect(screen.queryByRole("textbox", { name: /new file path/i })).toBeNull(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 3. ChannelsTab — htmlFor/id label associations via useId() +// ──────────────────────────────────────────────────────────────────────────── + +describe("ChannelsTab — htmlFor/id label associations (WCAG 1.3.1)", () => { + beforeEach(() => { + mockApiGet.mockImplementation((url: string) => { + if (url.includes("/channels/adapters")) { + return Promise.resolve([{ type: "telegram", display_name: "Telegram" }]); + } + return Promise.resolve([]); + }); + }); + + async function renderAndOpenForm() { + render(); + await waitFor(() => screen.getByRole("button", { name: /\+ connect/i })); + fireEvent.click(screen.getByRole("button", { name: /\+ connect/i })); + } + + it("Platform label is associated with the select via htmlFor/id", async () => { + await renderAndOpenForm(); + const platformSelect = screen.getByLabelText("Platform"); + expect(platformSelect.tagName.toLowerCase()).toBe("select"); + }); + + it("Bot Token label is associated with the password input via htmlFor/id", async () => { + await renderAndOpenForm(); + const botTokenInput = screen.getByLabelText("Bot Token"); + expect(botTokenInput.tagName.toLowerCase()).toBe("input"); + expect((botTokenInput as HTMLInputElement).type).toBe("password"); + }); + + it("Chat IDs label is associated with the input via htmlFor/id", async () => { + await renderAndOpenForm(); + const chatIdInput = screen.getByLabelText("Chat IDs"); + expect(chatIdInput.tagName.toLowerCase()).toBe("input"); + }); + + it("Allowed Users label is associated with the input via htmlFor/id", async () => { + await renderAndOpenForm(); + // Label contains "(optional, comma-separated)" in a nested span — use regex + const allowedUsersInput = screen.getByLabelText(/allowed users/i); + expect(allowedUsersInput.tagName.toLowerCase()).toBe("input"); + }); + + it("all form control ids are unique and non-empty", async () => { + await renderAndOpenForm(); + + const platformSelect = screen.getByLabelText("Platform"); + const botTokenInput = screen.getByLabelText("Bot Token"); + const chatIdInput = screen.getByLabelText("Chat IDs"); + const allowedUsersInput = screen.getByLabelText(/allowed users/i); + + const ids = [ + platformSelect.id, + botTokenInput.id, + chatIdInput.id, + allowedUsersInput.id, + ]; + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(4); + ids.forEach((id) => expect(id).toBeTruthy()); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// 4. ScheduleTab — aria-label on name + htmlFor/id associations via useId() +// ──────────────────────────────────────────────────────────────────────────── + +describe("ScheduleTab — aria-label + htmlFor/id label associations (WCAG 1.3.1)", () => { + beforeEach(() => { + mockApiGet.mockResolvedValue([]); + }); + + async function renderAndOpenForm() { + render(); + await waitFor(() => screen.getByRole("button", { name: /\+ add schedule/i })); + fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i })); + } + + it('Schedule name input has aria-label="Schedule name"', async () => { + await renderAndOpenForm(); + const nameInput = screen.getByRole("textbox", { name: /^schedule name$/i }); + expect(nameInput.getAttribute("aria-label")).toBe("Schedule name"); + }); + + it("Cron Expression label is associated with the input via htmlFor/id", async () => { + await renderAndOpenForm(); + const cronInput = screen.getByLabelText("Cron Expression"); + expect(cronInput.tagName.toLowerCase()).toBe("input"); + expect((cronInput as HTMLInputElement).type).toBe("text"); + }); + + it("Timezone label is associated with the select via htmlFor/id", async () => { + await renderAndOpenForm(); + const timezoneSelect = screen.getByLabelText("Timezone"); + expect(timezoneSelect.tagName.toLowerCase()).toBe("select"); + }); + + it("Prompt / Task label is associated with the textarea via htmlFor/id", async () => { + await renderAndOpenForm(); + const promptTextarea = screen.getByLabelText(/prompt \/ task/i); + expect(promptTextarea.tagName.toLowerCase()).toBe("textarea"); + }); + + it("all form control ids are unique and non-empty", async () => { + await renderAndOpenForm(); + + const cronInput = screen.getByLabelText("Cron Expression"); + const timezoneSelect = screen.getByLabelText("Timezone"); + const promptTextarea = screen.getByLabelText(/prompt \/ task/i); + + const ids = [cronInput.id, timezoneSelect.id, promptTextarea.id]; + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(3); + ids.forEach((id) => expect(id).toBeTruthy()); + }); +}); diff --git a/canvas/src/components/tabs/ChannelsTab.tsx b/canvas/src/components/tabs/ChannelsTab.tsx index 78cb628f..7402214b 100644 --- a/canvas/src/components/tabs/ChannelsTab.tsx +++ b/canvas/src/components/tabs/ChannelsTab.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useId } from "react"; import { api } from "@/lib/api"; import { ConfirmDialog } from "@/components/ConfirmDialog"; @@ -53,6 +53,12 @@ export function ChannelsTab({ workspaceId }: Props) { const [selectedChats, setSelectedChats] = useState>(new Set()); const [showManualInput, setShowManualInput] = useState(false); + // Stable IDs for label↔input associations (WCAG 1.3.1) + const platformId = useId(); + const botTokenId = useId(); + const chatIdId = useId(); + const allowedUsersId = useId(); + const load = useCallback(async () => { try { const [chRes, adRes] = await Promise.all([ @@ -208,8 +214,9 @@ export function ChannelsTab({ workspaceId }: Props) { {showForm && (
- +
- + setFormBotToken(e.target.value)} @@ -231,7 +239,7 @@ export function ChannelsTab({ workspaceId }: Props) {
- +
-